SIerだけど技術やりたいブログ

5年目のSIerのブログです

Vuej.sでSPAを実現するときは注意してsetIntervalを使おう

setIntervalとは

一定の遅延間隔を置いて関数を繰り返し実行したいときに利用する。例えばポーリング。
developer.mozilla.org

1秒ごとにコンソール出力する例。

<!doctype html>
<html lang="ja">
<script>
  window.setInterval(function(){
    console.log("polling");
  }, 1000);
</script>
  <body>
  </body>
</html>

setIntervalの生存期間

リファレンスには特に記述がなかった。
6.3 Timers — HTML5
WindowOrWorkerGlobalScope.setInterval() - Web API インターフェイス | MDN

動作確認してみたら以下のようになった。

  • ページ内リンクやJSでの動的なDOM書き換え、はsetIntervalがクリアされない
  • ページ外リンク、はsetIntervalがクリアされる

以下、検証内容

Google Chromeバージョン: 60.0.3112.90で動作確認。

<!doctype html>
<html lang="ja">
<script>
  window.onload = function () {
    window.setInterval(function() { console.log("hi"); }, 1000);
    document.getElementById('btn').addEventListener('click', function() {
        var parent = document.getElementsByTagName('body')[0];
        while(parent.firstChild) parent.removeChild(parent.firstChild);
        var newElm = document.createElement('div');
        newElm.textContent = 'hello world';
        parent.appendChild(newElm);
    });
  }
</script>
  <body>
    <a href="#xxx">ページ内リンク</a>
    <a href="other.html">ページ外リンク</a>
    <button id="btn" type="button">JSで動的に書き替える</button>
    <p>dummy</p>
    ...
    <p>dummy</p>
    <p id="xxx">xxx</p>
    <p>dummy</p>
    ...
    <p>dummy</p>
  </body>
</html>
<!doctype html>
<html lang="ja">
<body>
<a href="polling.html">戻る</a>
</body>
</html>

f:id:kimulla:20170811110448g:plain

ということで、Chromeだと、DOMをunloadして次のDOMをloadするまで(=Documentオブジェクトと同じ生存期間)っぽい。
WindowオブジェクトとDocumentオブジェクトの関係はここが詳しかった。
Window オブジェクトや Document オブジェクト、DOMなど | Web Design Leaves


Vue.jsでsetIntervalを使うときの注意点

SPAの場合、データのみリクエストしてDOMを差分更新することになる。そのため、Documentオブジェクトの入れ替えが起こらず、一度setIntervalしたタイマは、全画面で有効になり続ける。

以下、検証内容

ポーリングしたいページ。

<template>
  <div>
    <h1>polling</h1>
   <router-link to="/goodbye">Goodbye</router-link>
  </div>
</template>

<script>
export default {
  name: 'hello',
  mounted () {
    setInterval(function () {
      console.log('hi')
    }, 1000)
  }
}
</script>


ポーリングしたくないページ。

<template>
  <div>
    <h1>ポーリングしたくないページ</h1>
  </div>
</template>

<script>
export default {
  name: 'boodbye'
}
</script>

f:id:kimulla:20170811111832g:plain

問題点と解決方法

この場合無駄なネットワークコストがかかるため、SPAでsetIntervalする際は明示的にclearIntervalを利用してタイマを削除したほうがよい。

window.clearInterval - Web API インターフェイス | MDN

ページを遷移する際にclearIntervalを呼ぶためには、ライフサイクルフックを利用できる。

jp.vuejs.org

ということで、以下のコードで、ページ遷移する際にポーリングを止められる。

<template>
  <div>
    <h1>polling</h1>
   <router-link to="/goodbye">Goodbye</router-link>
  </div>
</template>

<script>
export default {
  name: 'hello',
  data: function () {
    return {
      intervalId: undefined
    }
  },
  mounted () {
    this.intervalId = setInterval(function () {
      console.log('hi')
    }, 1000)
  },
  beforeDestroy () {
    console.log('clearInterval')
    clearInterval(this.intervalId)
  }
}
</script>

f:id:kimulla:20170811112524g:plain

結論

Vue.js(というかSPA全般)でsetIntervalするときは、ちゃんとclearIntervalしないとダメ。


p.s.
setIntervalの生存期間調べるためにWebKitのソース読もうとしたんだけど、C++難しすぎて1日頑張って諦めた。C++力がほしい。

Spring 抽象クラスに定義したアノテーションは引き継がれるのか

以下のコードの実行結果からわかるように、引き継がれる。はい終わり!

@EnableAsync //非同期処理を有効化する
@SpringBootApplication
public class InheritApplication {

    public static void main(String[] args) {
        new SpringApplicationBuilder(InheritApplication.class).web(false).run(args);
    }

    @Bean
    public CommandLineRunner job(AsyncService service) {
        return i -> {
            service.exec();
        };
    }
}
@Async //検証のため、抽象クラスにアノテーションをつける
public abstract class AsyncService {
    public abstract void exec();
}
@Slf4j
@Service
public class AsyncServiceImpl extends AsyncService {
    @Override
    public void exec() {
        // 別スレッドで非同期に実行される場合、実行スレッドがmainスレッドじゃなくなる
        log.info("-----------------------");
        log.info("exec");
        log.info("-----------------------");
    }
}
  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::        (v1.5.6.RELEASE)
...
2017-08-06 16:02:52.514  INFO 8224 --- [           main] com.example.demo.InheritApplication      : Started InheritApplication in 1.498 seconds (JVM running for 1.811)
// AsyncServiceクラスのexecメソッドの実行スレッドが変わっているため、@Asyncが有効になっている
2017-08-06 16:02:52.519  INFO 8224 --- [cTaskExecutor-1] com.example.demo.AsyncServiceImpl        : -----------------------
2017-08-06 16:02:52.519  INFO 8224 --- [cTaskExecutor-1] com.example.demo.AsyncServiceImpl        : exec
2017-08-06 16:02:52.519  INFO 8224 --- [cTaskExecutor-1] com.example.demo.AsyncServiceImpl        : -----------------------
...

これだとあまり勉強にならないので、もう少し調べる。

アノテーションを処理するには

そもそもアノテーションを処理するには主に、MethodInterceptorという仕組みが使われる。例えば@Transactionalを処理するTransactionInterceptorクラスや@Asyncを処理するAsyncExecutionInterceptorクラスといった感じ。

このMethodInterceptorはAOPのAdviceになっていて、これを実行するタイミングはPointcutで制御される。ざっくりいうと、Adviceが処理したい内容(トランザクション管理するとか、別スレッドで実行するとか、そういうやつ)で、Pointcutは実行されるタイミング(パッケージ配下は実行する、とか、アノテーションついてたら実行する、とかそういうやつ)。

11. Aspect Oriented Programming with Spring

MethodInterceptorを設定する流れ

具体例として、@Asyncを処理するAsyncExecutionInterceptorが設定されるまでの流れを調べる。
バージョンは 1.5.6.RELEASE

まずはConfigurationを読み込む

@EnableAsyncの定義は以下のようになっている。

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Import(AsyncConfigurationSelector.class)
public @interface EnableAsync {
...

@Importで指定されたクラスはImportSelectorを継承したクラスで、ImportSelectorは有効にするConfigurationを条件によって切り替えるためのクラスらしい。
ImportSelector (Spring Framework 4.3.10.RELEASE API)

AOPの処理をProxyで実現するかASPECTJで実現するかで、設定を切り替えているっぽい。Proxyの場合はProxyAsyncConfigurationが読み込まれる。

public class AsyncConfigurationSelector extends AdviceModeImportSelector<EnableAsync> {
    ...
    @Override
    public String[] selectImports(AdviceMode adviceMode) {
        switch (adviceMode) {
            case PROXY:
                return new String[] { ProxyAsyncConfiguration.class.getName() };
            case ASPECTJ:
                return new String[] { ASYNC_EXECUTION_ASPECT_CONFIGURATION_CLASS_NAME };
            default:
                return null;
        }
    }

@EnableAsyncから読み込まれるConfiguration内で@Asyncを有効にする設定を書くことで、@EnableAsyncがついているときのみ@Asyncを有効にできる。

BPPを定義する

Springの初期化フェーズは大まかに、Bean定義の読み込み -> BFPPの実行 -> Bean生成、依存性の解決 -> BPPの実行 の流れになっている。
howtodoinjava.com

ここらへんを日本語で詳しく知りたい人は「Spring徹底入門」がわかりやすいですよ!(お金もらってないのにステマ
www.shoeisha.co.jp

今回のBPPはAsyncAnnotationBeanPostProcessorで、このBPPが実行される際にAsyncExecutionInterceptorをAdviceとして埋め込む。

先ほどのImportSelectorによって読み込まれた@Configurationクラス内で、AsyncAnnotationBeanPostProcessorがBean登録される。

@Configuration
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
public class ProxyAsyncConfiguration extends AbstractAsyncConfiguration {

    @Bean(name = TaskManagementConfigUtils.ASYNC_ANNOTATION_PROCESSOR_BEAN_NAME)
    @Role(BeanDefinition.ROLE_INFRASTRUCTURE)
    public AsyncAnnotationBeanPostProcessor asyncAdvisor() {
        Assert.notNull(this.enableAsync, "@EnableAsync annotation metadata was not injected");
        AsyncAnnotationBeanPostProcessor bpp = new AsyncAnnotationBeanPostProcessor();
        Class<? extends Annotation> customAsyncAnnotation = this.enableAsync.getClass("annotation");
        if (customAsyncAnnotation != AnnotationUtils.getDefaultValue(EnableAsync.class, "annotation")) {
            bpp.setAsyncAnnotationType(customAsyncAnnotation);
        }
        ...
        return bpp;
     }

BPPを実行する

Bean登録されたAsyncAnnotationBeanPostProcessorは、postProcessAfterInitializationメソッド内でAsyncAnnotationAdvisorを呼び出す。

このAsyncAnnotationAdvisorは、AdviceとしてAnnotationAsyncExecutionInterceptorクラスを、PointcutとしてAnnotationMatchingPointcutクラスを用いる。

特にPointcutについて抜粋する。

    protected Pointcut buildPointcut(Set<Class<? extends Annotation>> asyncAnnotationTypes) {
        ComposablePointcut result = null;
        for (Class<? extends Annotation> asyncAnnotationType : asyncAnnotationTypes) {
            Pointcut cpc = new AnnotationMatchingPointcut(asyncAnnotationType, true);
            Pointcut mpc = AnnotationMatchingPointcut.forMethodAnnotation(asyncAnnotationType);
            if (result == null) {
                result = new ComposablePointcut(cpc);
            }
            else {
                result.union(cpc);
            }
            result = result.union(mpc);
        }
        return result;
    }

PointcutとしてAnnotationMatchingPointcutが利用されている。このクラスのJavadocには以下のように書かれている。

Simple Pointcut that looks for a specific Java 5 annotation being present on a class or method.

AnnotationMatchingPointcut (Spring Framework 4.3.10.RELEASE API)


ということで、AnnotationMatchingPointcutが実行される仕組みがわかれば、抽象クラスに定義したアノテーションが引き継がれた仕組みもわかりそう。

先ほどのコードの、クラスに対するPointcutをnewしている部分について

    Pointcut cpc = new AnnotationMatchingPointcut(asyncAnnotationType, true);

javadocには以下のように書かれている。
AnnotationMatchingPointcut (Spring Framework 4.3.10.RELEASE API)

public AnnotationMatchingPointcut(Class classAnnotationType,
boolean checkInherited)
Create a new AnnotationMatchingPointcut for the given annotation type.
Parameters:
classAnnotationType - the annotation type to look for at the class level
checkInherited - whether to explicitly check the superclasses and interfaces for the annotation type as well (even if the annotation type is not marked as inherited itself)

継承先のクラスまでアノテーションがついているかを探索してくれるっぽい。

なるほど。じゃあ抽象クラスに定義したアノテーションも引き継がれるな!安心した!

AnnotationMatchingPointcut

AnnotationMatchingPointcutは条件に合致するクラスやメソッドをフィルタするが、フィルタ処理自体はAnnotationClassFilterやAnnotationMethodMatcherに処理を委譲している。

AnnotationClassFilterを見ると、アノテーションの検索処理はAnnotationUtilsに委譲していた。

public class AnnotationClassFilter implements ClassFilter {
    @Override
    public boolean matches(Class<?> clazz) {
        return (this.checkInherited ?
                (AnnotationUtils.findAnnotation(clazz, this.annotationType) != null) :
                clazz.isAnnotationPresent(this.annotationType));
    }  

AnnotationUtilsクラスでのアノテーションの検索は、以下のようにClassクラスのgetDeclaredAnnotationsメソッドを利用しながら、親クラスや継承元のアノテーションまで再帰的に探すことで実現していた。

  private static <A extends Annotation> A findAnnotation(Class<?> clazz, Class<A> annotationType, Set<Annotation> visited) {
      try {
          Annotation[] anns = clazz.getDeclaredAnnotations();
          for (Annotation ann : anns) {
              if (ann.annotationType() == annotationType) {
                  return (A) ann;
              }
          }
          for (Annotation ann : anns) {
              if (!isInJavaLangAnnotationPackage(ann) && visited.add(ann)) {
                  A annotation = findAnnotation(ann.annotationType(), annotationType, visited);
                  if (annotation != null) {
                      return annotation;
                  }
              }
          }
            // 再帰的に親クラスや継承元のアノテーションまで探索する

ClassクラスのgetDeclaredAnnotationsを試してみると

public class Main {
    public static void main(String[] args) {
        Stream.of(Children.class.getDeclaredAnnotations())
                .forEach(System.out::println);
        System.out.println("--------------");
        Stream.of(Parent.class.getDeclaredAnnotations())
                .forEach(System.out::println);
    }
}

@Async
@Service
class Parent{}

@Controller
class Children extends Parent{}

確かに指定したクラスのアノテーションしか取得できない。

@org.springframework.stereotype.Controller(value=)
--------------
@org.springframework.scheduling.annotation.Async(value=)
@org.springframework.stereotype.Service(value=)

親クラスのアノテーションや、アノテーションの継承元のアノテーション再帰的に探索すると循環参照による無限ループの危険があるはずだが、いい感じにAnnotationUtilsは処理してくれる。さすがSpringさん、アタマいい。

@EnableAsyncの雑学

コードリーディングしてる最中に知らなかった機能に気づいたのでメモる。

@EnableAsyncのannotation属性に指定すれば、@Async以外のアノテーションも非同期のためのアノテーションに指定できる。

@EnableAsync(annotation = MyAsync.class)
@SpringBootApplication
public class InheritApplication {

    public static void main(String[] args) {
        new SpringApplicationBuilder(InheritApplication.class).web(false).run(args);
    }

    @Bean
    public CommandLineRunner job(AsyncService service) {
        return i -> {
            service.exec();
        };
    }
}
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface MyAsync {
}
@MyAsync //独自アノテーションをつける
public abstract class AsyncService {
    public abstract void exec();
}

結論

抽象クラスに定義したアノテーションは引き継がれる。(少なくともPointcutとしてAnnotationMatchingPointcutが使われていれば)

Spring 非同期タスクの同時実行数を制限する方法

Springには非同期処理を実行するための@Asyncアノテーションがある。
34. Task Execution and Scheduling
kimulla.hatenablog.com



この非同期タスクの同時実行数を絞るための方法をメモる。

デフォルトだと非同期処理の実行にSimpleAsyncTaskExecutorが使われるため、@Asyncの呼び出しごとにスレッドが生成されて即実行されてしまう。


TaskExecutorにThreadPoolTaskExecutorを指定すると、スレッドがプールされるようになる。ThreadPoolTaskExecutorはjava標準のThreadPoolExecutorをラップしたようなクラスのため、パラメータで以下の設定ができる。

corePoolSize - アイドルであってもプール内に維持されるスレッドの数
maximumPoolSize - プール内で可能なスレッドの最大数
keepAliveTime - スレッドの数がコアよりも多い場合、これは超過したアイドル状態のスレッドが新しいタスクを待機してから終了するまでの最大時間
unit - keepAliveTime 引数の時間単位
workQueue - タスクが超過するまで保持するために使用するキュー。このキューは、execute メソッドで送信された Runnable タスクだけを保持する

corePoolSize (getCorePoolSize() を参照) と maximumPoolSize (getMaximumPoolSize() を参照) で設定された境界に従って、ThreadPoolExecutor は自動的にプールサイズを調整します (getPoolSize() を参照)。新しいタスクが execute(java.lang.Runnable) メソッドで送信され、corePoolSize よりも少ない数のスレッドが実行中である場合は、その他のワークスレッドがアイドル状態であっても、要求を処理するために新しいスレッドが作成されます。corePoolSize よりも多く、maximumPoolSize よりも少ない数のスレッドが実行中である場合、新しいスレッドが作成されるのはキューがいっぱいである場合だけです。corePoolSize と maximumPoolSize を同じ値に設定すると、固定サイズのスレッドプールが作成されます。maximumPoolSize を Integer.MAX_VALUE などの実質的にアンバウンド形式である値に設定すると、プールに任意の数の並行タスクを格納することができます。コアプールサイズと最大プールサイズは構築時にのみ設定されるのがもっとも一般的ですが、setCorePoolSize(int) および setMaximumPoolSize(int) を使用して動的に変更することもできます。

ということで、corePoolSize=maximumPoolSize=制限したい同時実行数 にしてThreadTaskExecutorを生成すれば同時実行数が制御できる。

ついでに、QueueCapacityでいくつまでタスクをキューにため込むかも制御できる。

以下、SpringBoot 1.5.6.RELEASE で動作確認する。

まずは非同期処理を有効にするために@EnableAsyncをつける。またtaskExecutorを生成し、@QualifierでBeanに名前をつける。(TaskExecutorがひとつだけなら@Qualifierは不要)

@EnableAsync
@SpringBootApplication
public class AsyncApplication {

    public static void main(String[] args) {
        SpringApplication.run(AsyncApplication.class, args);
    }

    @Bean
    @Qualifier("heavyTaskTaskExecutor")
    public TaskExecutor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(1);
        executor.setMaxPoolSize(1);
        executor.setQueueCapacity(5);
        return executor;
    }
}


非同期にしたい処理に@Asyncアノテーションをつける。また、名前をつけたTaskExecutorを利用するために@Asyncアノテーションの引数に指定する。

@Slf4j
@Service
public class TaskServiceImpl implements TaskService {

    @Async("heavyTaskTaskExecutor")
    @Override
    public void heavyTask() {
        try {
            log.info("start heavy task");
            TimeUnit.SECONDS.sleep(5);
            log.info("end heavy task");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

}

非同期処理の呼び出し側は呼び出すだけ。スレッドのキューサイズよりも多く呼び出した場合、TaskRejectedExceptionが発生する。そのため、@ExceptionHandlerでキャッチしてハンドリングする。

@RestController
@AllArgsConstructor
@RequestMapping("api/tasks")
public class TaskRestController {
    private final TaskService taskService;

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public Boolean createNewTask() {
        taskService.heavyTask();
        return true;
    }

    @ExceptionHandler(TaskRejectedException.class)
    @ResponseStatus(HttpStatus.SERVICE_UNAVAILABLE)
    public String handle() {
        return "too busy";
    }

}

実行すると同時実行数が制御されていることがわかる。
f:id:kimulla:20170805215224p:plain

お手軽に非同期タスクの同時実行数を制限できた。

「mvn clean test」みたいに実行するコマンドと「mvn checkstyle:checkstyle」みたいに実行するコマンドとの差を理解したい

「:」なしで実行できるコマンドと、

mvn clean test 

「:」でつないで実行するコマンドとの差を理解するのが目的です。

mvn checkstyle:checkstyle

動作確認環境

mvn -v
Apache Maven 3.3.3 (7994120775791599e205a5524ec3e0dfe41d4a06; 2015-04-22T20:57:37+09:00)
Maven home: c:\Program Files\apache-maven-3.3.3
Java version: 1.8.0_25, vendor: Oracle Corporation
Java home: c:\Program Files\Java\jdk1.8.0_25\jre
Default locale: ja_JP, platform encoding: MS932
OS name: "windows 8.1", version: "6.3", arch: "amd64", family: "dos"

ライフサイクルとフェーズ

そもそもmavenには、ライフサイクルとフェーズという概念があるらしい。

7. Maven Tips | TECHSCORE(テックスコア)

Maven – Introduction to the Build Lifecycle


ライフサイクルは、フェーズをグループとしてまとめたもので、デフォルトは3つある。

  • default Lifecycle
  • clean Lifecycle
  • site Lifecycle

フェーズは、mavenが用意したライフサイクル中の各ステップ。
https://maven.apache.org/guides/introduction/introduction-to-the-lifecycle.html#Lifecycle_Reference

validate validate the project is correct and all necessary information is available.
initialize initialize build state, e.g. set properties or create directories.
generate-sources generate any source code for inclusion in compilation.
process-sources process the source code, for example to filter any values.
generate-resources generate resources for inclusion in the package.
process-resources copy and process the resources into the destination directory, ready for packaging.
compile compile the source code of the project.
process-classes post-process the generated files from compilation, for example to do bytecode enhancement on Java classes.
generate-test-sources generate any test source code for inclusion in compilation.
process-test-sources process the test source code, for example to filter any values.
generate-test-resources create resources for testing.
process-test-resources copy and process the resources into the test destination directory.
test-compile compile the test source code into the test destination directory
process-test-classes post-process the generated files from test compilation, for example to do bytecode enhancement on Java classes. For Maven 2.0.5 and above.
test run tests using a suitable unit testing framework. These tests should not require the code be packaged or deployed.
... ...


ポイントは、フェーズ自体は何らかの具体的な処理をするわけではなくて、そういうイベントってみんな共通してあるよね、くらいのものだということ。

それぞれのフェーズにpluginが登録でき、各フェーズで登録されたpluginのゴールを実行することで具体的な処理が実行できるようになる。

とはいえそれだとすぐ使い始められないので、組み込みでいくつかのpluginのゴールがフェーズに割り当てられている。(Built-in Lifecycle Bindings)

process-resources resources:resources
compile compiler:compile
process-test-resources resources:testResources
test-compile compiler:testCompile
test surefire:test


つまり、「mvn test」を実行したら裏で「mvn surefire:test」が実行されていたらしい。
(「mvn test」は指定されたフェーズまでの各フェーズ(compileとかもろもろ)も実行されるので、「mvn test」=「mvn surefire:test」ではない)

雑なたとえ話

フェーズとライフサイクルをカレーに例えると

  • カレーを作るライフサイクル
    • 具材を切るフェーズ
    • 具材を炒めるフェーズ
    • 具材を煮込むフェーズ
    • ルーを入れるフェーズ
    • 皿によそうフェーズ
  • 片づけるライフサイクル
    • 皿を集めるフェーズ
    • 洗うフェーズ

カレーの具材は人によって異なるため「具材を切るフェーズ」には自分で「たまねぎ切るplugin」や「ニンジン切るplugin」を割り当てる。

「ルーを入れるフェーズ」は当然99%の人がジャワカレーを選択するので、mavenが「ジャワカレーplugin」をデフォルトで割り当ててくれている。

具材を炒めるフェーズを実行すると、具材を切るフェーズから実行される。

うーん、あんまりわかりやすくならなかった。

サンプル

プロジェクトを作る。

mvn archetype:generate -DarchetypeArtifactId=maven-archetype-quickstart -DinteractiveMode=false -DgroupId=example.com -DartifactId=sample

echo-maven-pluginというechoするだけのpluginをtestフェーズに割り当てる。

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>example.com</groupId>
  <artifactId>sample</artifactId>
  <packaging>jar</packaging>
  <version>1.0-SNAPSHOT</version>
  <name>sample</name>
  <url>http://maven.apache.org</url>
  <dependencies>
    <dependency>
      <groupId>junit</groupId>
      <artifactId>junit</artifactId>
      <version>3.8.1</version>
      <scope>test</scope>
    </dependency>
  </dependencies>

  <build>
    <plugins>
      <plugin>
        <groupId>com.soebes.maven.plugins</groupId>
        <artifactId>echo-maven-plugin</artifactId>
        <version>0.3.0</version>
        <executions>
          <execution>
            <phase>test</phase>
            <goals>
              <goal>echo</goal>
            </goals>
          </execution>
        </executions>
        <configuration>
          <echos>
            <echo>This is the Test Text </echo>
          </echos>
        </configuration>
      </plugin>
    </plugins>
  </build>

</project>

testフェーズを実行するとecho-maven-pluginも実行されることがわかる。
ついでに、testフェーズまでの各フェーズも実行されている。

$ mvn test
[INFO] Scanning for projects...
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] Building sample 1.0-SNAPSHOT
[INFO] ------------------------------------------------------------------------
[INFO]
[INFO] --- maven-resources-plugin:2.6:resources (default-resources) @ sample ---
[WARNING] Using platform encoding (MS932 actually) to copy filtered resources, i.e. build is platform dependent!
[INFO] skip non existing resourceDirectory c:\Users\pbreh_000\Desktop\study\sample\src\main\resources
[INFO]
[INFO] --- maven-compiler-plugin:3.1:compile (default-compile) @ sample ---
[INFO] Nothing to compile - all classes are up to date
[INFO]
[INFO] --- maven-resources-plugin:2.6:testResources (default-testResources) @ sample ---
[WARNING] Using platform encoding (MS932 actually) to copy filtered resources, i.e. build is platform dependent!
[INFO] skip non existing resourceDirectory c:\Users\pbreh_000\Desktop\study\sample\src\test\resources
[INFO]
[INFO] --- maven-compiler-plugin:3.1:testCompile (default-testCompile) @ sample ---
[INFO] Nothing to compile - all classes are up to date
[INFO]
[INFO] --- maven-surefire-plugin:2.12.4:test (default-test) @ sample ---
[INFO] Surefire report directory: c:\Users\pbreh_000\Desktop\study\sample\target\surefire-reports

-------------------------------------------------------
 T E S T S
-------------------------------------------------------
Running com.example.AppTest
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.002 sec

Results :

Tests run: 1, Failures: 0, Errors: 0, Skipped: 0

[INFO]
[INFO] --- echo-maven-plugin:0.3.0:echo (default) @ sample ---
[INFO] This is the Test Text
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 1.049 s
[INFO] Finished at: 2017-07-31T20:24:31+09:00
[INFO] Final Memory: 11M/304M
[INFO] ------------------------------------------------------------------------

実効pomを表示すれば、各フェーズで実行されるpluginが調べられる。

mvn help:effective-pom
...

      <plugin>
        <artifactId>maven-surefire-plugin</artifactId>
        <version>2.12.4</version>
        <executions>
          <execution>
            <id>default-test</id>
            <phase>test</phase>
            <goals>
              <goal>test</goal>
            </goals>
          </execution>
        </executions>
      </plugin>
...

フェーズをスキップする

フェーズに割り当てられたpluginの実行をスキップする方法は各pluginごとに用意されている。(例えば、surefire:testだと-Dmaven.test.skip=trueや-DskipTests=trueだったり、install:installだと-Dmaven.install.skip=true)

しかし、フェーズ自体は飛ばせないっぽい。
試しにコマンド打ってみても、echo-maven-pluginは動いてしまう。

mvn clean package -Dmaven.test.skip=true
...
[INFO] --- maven-compiler-plugin:3.1:testCompile (default-testCompile) @ sample ---
[INFO] Not compiling test sources
[INFO]
[INFO] --- maven-surefire-plugin:2.12.4:test (default-test) @ sample ---
[INFO] Tests are skipped.
[INFO]
[INFO] --- echo-maven-plugin:0.3.0:echo (default) @ sample ---
[INFO] This is the Test Text
[INFO]
[INFO] --- maven-jar-plugin:2.4:jar (default-jar) @ sample ---
[INFO] Building jar: c:\Users\pbreh_000\Desktop\study\sample\target\sample-1.0-SNAPSHOT.jar
...

フェーズに割り当てたすべてのpluginをスキップさせるなら、Profileを使えば良さそう。

https://stackoverflow.com/questions/3147714/how-to-skip-install-phase-in-maven-build-if-i-already-have-this-version-installe

ゴール

mavenのpluginは各フェーズに割り当てなくても使える。そのときは以下の形式で実行する。

mvn prefix:goal

echo-maven-pluginだと、

$ mvn echo:echo
[INFO] Scanning for projects...
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] Building sample 1.0-SNAPSHOT
[INFO] ------------------------------------------------------------------------
[INFO]
[INFO] --- echo-maven-plugin:0.3.0:echo (default-cli) @ sample ---
[INFO] This is the Test Text
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 0.147 s
[INFO] Finished at: 2017-07-31T20:48:17+09:00
[INFO] Final Memory: 8M/304M
[INFO] ------------------------------------------------------------------------

ゴールはゴールしか実行されない。pluginによってはcompileフェーズで生成されたclassesファイルを使うもの(findbugsとか?)もあるので、注意する。


で、このprefix:goalのprefixってなに指定すりゃいいねんと思いながらいつもググってたけど、ちゃんとしたルールがあるらしい。

https://maven.apache.org/guides/introduction/introduction-to-plugin-prefix-mapping.html

またprefix:goalのgoalついても一覧を表示できる。

ググったら詳しいサイトがあったので詳細はこちらを参照。
qiita.com


とりあえずhelpゴールを利用すればいいっぽい。

$ mvn echo:help
[INFO] Scanning for projects...
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] Building sample 1.0-SNAPSHOT
[INFO] ------------------------------------------------------------------------
[INFO]
[INFO] --- echo-maven-plugin:0.3.0:help (default-cli) @ sample ---
[INFO] Maven Echo Plugin 0.3.0
  The Echo Maven Plugin is intended to print out messages during the build.

This plugin has 3 goals:

echo:echo

echo:format

echo:help
  Display help information on echo-maven-plugin.
  Call mvn echo:help -Ddetail=true -Dgoal=<goal-name> to display parameter
  details.
 ...

結論

「mvn clean test」みたいに実行するコマンドと「mvn checkstyle:checkstyle」みたいに実行するコマンドとの差は、「フェーズ」か「ゴール」か。

Vuejsでdataプロパティに値を動的に追加する方法(といいつつJSの基本を理解してなかったという話)

Vuejsのバージョン

この記事はVuejs v2.3.3 で動作確認をしました。

  ...
  "dependencies": {
    "vue": "^2.3.3",
    "vue-router": "^2.6.0"
  },
  ...

Vuejsのdataプロパティ

Vuejsはdataオブジェクトのプロパティをリアクティブな値として扱う。つまり、dataオブジェクトのプロパティの値が書き換わったタイミングで、HTMLも書き換わる。

例を挙げると

<template>
  <div>
    <h1>{{ msg }}</h1>
    <button type="button" @click="change">change</button>
  </div>
</template>

<script>
export default {
  name: 'sample',
  data () {
    return {
      msg: 'hello world'
    }
  },
  methods: {
    change: function () {
      this.msg = 'good bye'
    }
  }
}
</script>

<style scoped>
</style>

この単一コンポーネントのファイルをブラウザで表示すると、以下のように {{ msg }} 部分がdataオブジェクトのmsgプロパティの値である「hello world」に置き換えられたものが表示される。
f:id:kimulla:20170724215420p:plain

ボタンをクリックすると、メッセージが「good bye」に変わる。
f:id:kimulla:20170724215507p:plain

これは、

  1. HTMLのボタンをクリックするとclickイベントが発生し
  2. @click(イベントハンドラ的なもの)で指定したchangeメソッドが呼ばれ
  3. Vueインスタンスのdataオブジェクトのmsgプロパティが書き換わり
  4. dataオブジェクトのプロパティの変更をVueが検知して
  5. VuejsがHTMLに値を反映するため


このプロパティの変更の検知は、JSの機能的な制限(Object.observe)によってプロパティ自体の追加には対応していない。

つまり以下のような処理では、動的に追加したプロパティは無視されてしまう。

<template>
  <div>
    <ul>
      <li v-for="(value,key) in favorites">
        {{value}} likes {{key}}
      </li>
    </ul>
    <button type="button" @click="add">add charie</button>
  </div>
</template>

<script>
export default {
  name: 'sample',
  data () {
    return {
      favorites: {
        'alice': 'apple',
        'bob': 'banana'
      }
    }
  },
  methods: {
    add: function () {
      // 動的にプロパティとして値を追加する
      this.favorites.charie = 'cherry'
    }
  }
}
</script>

<style scoped>
</style>

実際にブラウザでボタンをクリックし、動的にdataオブジェクトにプロパティを追加してみる。
f:id:kimulla:20170724220021p:plain

Chrome Developer Toolで確認すると、favoritesに値が追加されているにもかかわらず、その値が画面に反映されていない。
f:id:kimulla:20170724220508p:plain


で、これをどうするかというのも公式に書いてある。
https://jp.vuejs.org/v2/guide/reactivity.html#変更検出の注意事項

抜粋すると

Vue はすでに作成されたインスタンスに対して動的に新しいルートレベルのリアクティブなプロパティを追加することはできません。しかしながら Vue.set(object, key, value) メソッドを使うことで、ネストしたオブジェクトにリアクティブなプロパティを追加することができます:
グローバル Vue.set の単なるエイリアスとなっている vm.$set インスタンスメソッドを使用することもできます:
Vue.set(vm.someObject, 'b', 2)


ここからが自分にとっては本題で、これを単一コンポーネントでどう使えばいいの?と悩んでしまった。

リファレンスみたいにVueコンポーネントなんてimportしてないし、vmインスタンスなんて変数に格納してないし、どうすればいいの?みたいな


でひとまずメソッド内ではthisがVueを指す(dataオブジェクトへの値の変更もthis.msgとしているように)とリファレンスに載っていたので、とりあえずchangeメソッド内でthisをChrome Developer Toolsで表示してみた。

すると

f:id:kimulla:20170724223116p:plain

$setプロパティがない・・・?



いちおう this.$set も試すと・・・

f:id:kimulla:20170724220443p:plain

$setプロパティがある・・・?ある!



JSでは、prototypeで定義されたメソッドはそのオブジェクトのプロパティとしては見えないらしい。
vividcode.hatenablog.com


コンソールで試してみると、確かにその通り。
f:id:kimulla:20170724220750p:plain

代わりに for..in.. を使えば全プロパティ表示される。なるほど。
f:id:kimulla:20170724220801p:plain


Vueでも確かめてみると確かに$setプロパティある!
f:id:kimulla:20170724220840p:plain

プロパティを片っ端から調べたければ for..in..で調べれば良かったのか。


ということで以下の通りにすれば動作します。

...
  methods: {
    add: function () {
      this.$set(this.favorites, 'charie', 'cherry')
    }
  }
...

めでたしめでたし。
f:id:kimulla:20170724223747p:plain


ちなみにディレクティブの値に直接書くならthisは不要。JSのthisはコンテキストによって指す場所変わるから・・・

    ...
    <button type="button" @click="$set(favorites, 'charie', 'cherry')">add charie</button>
  </div>

結論

Vueうんぬんの前にJSのスコープ周りをいちから勉強し直そう。

ついでに

テンプレート内の key,valueの順番を間違えて、「Apple likes Alice」みたいな食人植物が誕生してるけど、スクショ張り直すのめんどくさいので放置。

...
      <li v-for="(value,key) in favorites">
    // 誤
        {{value}} likes {{key}}
    // 正
        {{key}} likes {{value}}
      </li>
...

Jenkinsジョブ(ビルド~テスト)をDockerコンテナ上で実行する ~Docker Pipeline Pluginを使ってみる~

ビルドやテストなどの一連の作業をJenkinsで自動実行することを考える。
通常これらのジョブはJenkinsのmasterやslaveで実行されることになり、ビルドに必要な環境構築(例えばjavaのインストールやmavenのインストール)はJenkinsのmasterやslaveに対して、事前に行うことになる。そして一度環境構築したサーバは長いこと使われる。

そうなると、同一サーバで複数バージョンのjavaを入れる苦労をしたりディストリビューションの差に基づくLinuxコマンドのあるなしに翻弄されたり、ローカルにしかないファイルを参照してそのうちジョブが動かなくなったりする。

これを解決するための手段としてビルド〜テストをDockerコンテナ上で実行しようという例は多くみられ、以下の記事が導入検討の参考になる。

www.buildinsider.net

www.slideshare.net


以下、JenkinsジョブをDockerコンテナ上で実行するための方法を調べたときのメモ。
Jenkinsのpipelineが前提。使い方については昔にまとめました。

kimulla.hatenablog.com

JenkinsのDocker関連のplugin

Jenkins上でDockerコンテナを利用する方法は複数あるらしく

f:id:kimulla:20170612003857j:plain

Docker Pluginは、Dockerイメージを指定してスレーブに登録する作業が必要になるし、バージョン管理しているDockerfileからイメージを毎回生成する用途には向いてなさそう。(偏見かも)


またpluginを使わなくともJenkins上でshが実行できるので、自分でDockerコンテナを管理する方法もある。以下の記事がとても参考になった。

nulab-inc.com

現在自分がやってること


pluginは特に使わず、自身でDockerコンテナを管理している。
具体的には以下の4ステップをMakefile内で行っている。

  • Dockerイメージの生成
  • Dockerコンテナの生成とタスクの実行
  • 成果物の取り出し
  • Dockerコンテナの破棄

また、mavenやnpmのライブラリがコンテナ上で毎回全ダウンロードされないように、ホストにマウントしてキャッシュしている。

現在自分がやってることの詳細

以下にサンプルを作ったので抜粋する。
github.com

基本的にDockerfileを自分でビルドしてコンテナを起動している。
主なファイルは以下の4つ。

ファイル 説明
Dockerfile.build ビルドサーバを構築するためのDockerfile
Dockerfile.integration 結合試験を実施するためのDockerfile
Makefile 開発時にローカル環境で利用するコマンドをまとめたMakefile
Makefile.docker dockerコンテナを操作するためのコマンドをまとめたMakefile
Makefile

ビルドに必要なタスクはMakefileにまとめて記述する。

setup:
        npm --prefix client install

build: setup
        npm --prefix client run build
        cp -R client/dist/* server/src/main/resources/static
        mvn -f server/pom.xml package -DskipTests=true

使い方

# server/target配下にjarができる
make build
Dockerfile.build

ビルドサーバをDockerイメージとして構築する。
タスクはMakefileにまとまっているので、ENTRYPOINTにmakeを指定する。

FROM openjdk:8u131-jdk-alpine

RUN apk update &amp;&amp; apk upgrade &amp;&amp; \
    apk add curl nodejs-npm=6.10.3-r0 maven=3.3.9-r1 make

RUN curl -Ls "https://github.com/dustinblackman/phantomized/releases/download/2.1.1/Dockerized-phantomjs.tar.gz" | tar xz -C /
WORKDIR /build
COPY . /build

ENTRYPOINT ["make"]
CMD ["help"]
Makefile.docker

Dockerの操作が煩雑(イメージの作成~コンテナの生成~ビルドの実行~成果物の取り出し~破棄)なのでMakefileにまとめている。

# ホストで複数のDockerを起動できるようにイメージ/コンテナ名にidを付与する
ID = 'default'
BUILD_SERVER_IMAGE = sample-base-build-server-$(ID)
BUILD_CONTAINER    = sample-build-$(ID)
CACHE_PATH='/tmp/docker/cache'

base:
        docker build -t $(BUILD_SERVER_IMAGE) -f Dockerfile.build .

build: base
        docker run --name $(BUILD_CONTAINER) -v $(CACHE_PATH)/.m2:/root/.m2 -v $(CACHE_PATH)/.node_modules:/build/client/node_modules $(BUILD_SERVER_IMAGE) build || Docker rm $(BUILD_CONTAINER)
        docker cp $(BUILD_CONTAINER):/build/server/target server
        docker rm $(BUILD_CONTAINER

使い方

# コンテナ内でビルドしてホスト環境のserver/target配下にjarをコピーしてくる
make ID=1 -f Makefile.docker build
Jenkinsfile

Makefile.dockerを実行するだけ。

node {
  stage('build') {
      sh 'make ID=${BUILD_ID} -f Makefile.docker build'  
      archiveArtifacts 'server/target/*jar'
  }
  ...
}

自分でDockerを管理する問題点

成果物の取り出しやコンテナの破棄を作り込む(考慮する)のがめんどくさい。
対応できる範囲だけど、なんだかんだめんどくさい。

ジョブ実行時のファイルパスについても考慮が必要で、コンテナ内でビルドしたときのパスとJenkinsのworkspaceのパスが違うとlintツールの出力結果で問題になることがある。
例えばcheckstyleのテスト結果(checkstyle-result.xml)は各javaファイルへのリンクがフルパスで記述されるので、lint実行時のDockerコンテナ内のパスとJenkinsのworkspaceのパスが違うと出力レポートのリンクが開けないなんてこともある。

f:id:kimulla:20170611235823p:plain

f:id:kimulla:20170612002455p:plain

ただこれはコンテナのworkspaceをDocker起動時のディレクトリと同じにすれば解決する問題。

でも

つもりつもると・・・めんどくさい!

Docker Pipeline Plugin

そこでDocker Pipeline Pluginですよ!!!

CloudBees Jenkins Enterprise User Guide

動作確認環境

  • Jenkins version 2.46.3
  • Docker version 17.03.1-ce, build c6d412e

使い方(scripted pipeline)

新しめのバージョンだと、Global Variable Referenceに載ってる。

Global Variable Referenceはここ。
f:id:kimulla:20170611235425p:plain

f:id:kimulla:20170611235434p:plain

既存イメージを利用する場合

Pipelineプロジェクトを作成する。

f:id:kimulla:20170611235500p:plain

とりあえずGlobal Variable Referenceに記述されてる動きを確認するために、scripted pipelineで記述する。


f:id:kimulla:20170612004723p:plain

node {
    sh 'id'
    docker.image('openjdk:8u131-jdk').inside() {
        sh 'java -version'
    }
}

ビルド実行をクリックする。

f:id:kimulla:20170611235530p:plain

コンソール出力を読む。

f:id:kimulla:20170612004738p:plain


裏でDockerコマンドを実行してるのがわかる。

ユーザーanonymousが実行
[Pipeline] node
Running on master in /home/kimura/.jenkins/workspace/sample
[Pipeline] {
[Pipeline] sh
[sample] Running shell script
+ id
uid=1000(kimura) gid=1000(kimura) groups=1000(kimura),10(wheel),987(docker) context=unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023
[Pipeline] sh
[sample] Running shell script
+ docker inspect -f . openjdk:8u131-jdk
.
[Pipeline] withDockerContainer
Jenkins does not seem to be running inside a container
$ docker run -t -d -u 1000:1000 -w /home/kimura/.jenkins/workspace/sample -v /home/kimura/.jenkins/workspace/sample:/home/kimura/.jenkins/workspace/sample:rw -v /home/kimura/.jenkins/workspace/sample@tmp:/home/kimura/.jenkins/workspace/sample@tmp:rw -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** --entrypoint cat openjdk:8u131-jdk
[Pipeline] {
[Pipeline] sh
[sample] Running shell script
+ java -version
openjdk version "1.8.0_131"
OpenJDK Runtime Environment (build 1.8.0_131-8u131-b11-1~bpo8+1-b11)
OpenJDK 64-Bit Server VM (build 25.131-b11, mixed mode)
[Pipeline] }
$ docker stop --time=1 c98ccf713004056fc3a59cebe1b4cd1f924653c63e0ba2301ed0fa9ed86f976d
$ docker rm -f c98ccf713004056fc3a59cebe1b4cd1f924653c63e0ba2301ed0fa9ed86f976d
[Pipeline] // withDockerContainer
[Pipeline] }
[Pipeline] // node
[Pipeline] End of Pipeline
Finished: SUCCESS

コンテナ起動部分について抜粋し、さらに見ていく。


ローカル環境で実行したときと同じ環境になるように、

  • -wコマンドでコンテナのworkspaceをJenkinsのworkspaceと合わせている
  • -vコマンドでJenkinsのworkspaceをコンテナのworkspaceディレクトリにマウントしている
  • -uコマンドでJenkinsの起動ユーザでコンテナを実行している
...
+ id
uid=1000(kimura) gid=1000(kimura) groups=1000(kimura),10(wheel),987(docker) context=unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023
...
$ docker run -t -d -u 1000:1000 -w /home/kimura/.jenkins/workspace/sample -v /home/kimura/.jenkins/workspace/sample:/home/kimura/.jenkins/workspace/sample:rw -v /home/kimura/.jenkins/workspace/sample@tmp:/home/kimura/.jenkins/workspace/sample@tmp:rw -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** --entrypoint cat openjdk:8u131-jdk
...

あーなるほどー、そうすればよかったのか。


inside(){...}内で記述した...はコンテナ内で実行されている。

[Pipeline] sh
[sample] Running shell script
+ java -version
openjdk version "1.8.0_131"
OpenJDK Runtime Environment (build 1.8.0_131-8u131-b11-1~bpo8+1-b11)
OpenJDK 64-Bit Server VM (build 25.131-b11, mixed mode)
[Pipeline] }

コンテナが残らないようにstopしてrmしている。

...
[Pipeline] }
$ docker stop --time=1 a900ace94102ba4096ddeec5d172ae010d16379a8d0c486dbf8058c01dc7271b
$ docker rm -f a900ace94102ba4096ddeec5d172ae010d16379a8d0c486dbf8058c01dc7271b
...

既存のイメージを使う場合、docker.image(...).inside(...)を使えばいい感じにDockerを裏で実行してくれる。

Dockerfileからイメージをビルドする場合

Dockerfileからイメージを自分でビルドしたい場合に使うコマンド。

通常はcheckout scmでチェックアウトしたDockerfileを利用するが、とりあえずworkspaceにDockerfileを用意する。

$ pwd
/home/kimura/.jenkins/workspace/sample
$ ls -l
合計 4
-rw-rw-r--. 1 kimura kimura 528  61 14:35 Dockerfile.build
$ cat Dockerfile.build
FROM openjdk:8u131-jdk-alpine

RUN apk update && apk upgrade && \
    apk add curl nodejs-npm=6.10.3-r0 maven=3.3.9-r1 make
RUN curl -Ls "https://github.com/dustinblackman/phantomized/releases/download/2.1.1/dockerized-phantomjs.tar.gz" | tar xz -C /
node {
    docker.build("${BUILD_ID}", "-f Dockerfile.build .").inside() {
        sh 'node -v'  
    }
}

第一引数はイメージ名、第二引数はコマンド引数になる。
Dockerfileからビルドでき、またコマンドが実行されていることがわかる。

...
[sample] Running shell script
+ docker build -t 38 -f Dockerfile.build .
Sending build context to Docker daemon  2.56 kB

Step 1/3 : FROM openjdk:8u131-jdk-alpine
 ---> c7105179e75b
Step 2/3 : RUN apk update && apk upgrade &&     apk add curl nodejs-npm=6.10.3-r0 maven=3.3.9-r1 make
 ---> Using cache
 ---> 0a22744743d5
Step 3/3 : RUN curl -Ls "https://github.com/dustinblackman/phantomized/releases/download/2.1.1/dockerized-phantomjs.tar.gz" | tar xz -C /
 ---> Using cache
 ---> 9b99b208c53e
Successfully built 9b99b208c53e
[Pipeline] dockerFingerprintFrom
[Pipeline] sh
[sample] Running shell script
+ docker inspect -f . 38
.
[Pipeline] withDockerContainer
Jenkins does not seem to be running inside a container
$ docker run -t -d -u 1000:1000 -w /home/kimura/.jenkins/workspace/sample -v /home/kimura/.jenkins/workspace/sample:/home/kimura/.jenkins/workspace/sample:rw -v /home/kimura/.jenkins/workspace/sample@tmp:/home/kimura/.jenkins/workspace/sample@tmp:rw -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** --entrypoint cat 38
[Pipeline] {
[Pipeline] sh
[sample] Running shell script
+ node -v
v6.10.3
[Pipeline] }
$ docker stop --time=1 00cb2f603ca6eb3ea558bb82e906c6075f8593a5f2cb52115ae86ff4c3d82726
$ docker rm -f 00cb2f603ca6eb3ea558bb82e906c6075f8593a5f2cb52115ae86ff4c3d82726
...
Docker Pipeline Pluginの注意点

docker.image(...).inside(...)で実行するコマンドはDockerfileに記述したユーザではなくて、jenkinsの起動ユーザになる。

sampleのpipelineプロジェクトのworkspaceにDockerfileを置いてみる。

$ pwd
/home/kimura/.jenkins/workspace/sample
$ cat Dockerfile.build
FROM openjdk:8u131-jdk-alpine

USER root

Jenkinsのスクリプトに以下を書いて実行する。

node {
    sh 'id'
    docker.build("${BUILD_ID}","-f Dockerfile.build .").inside() {
        sh 'id'
    }
}

コンソール出力を見ると、userがrootではなくて1000になってる。
パーミッションで困ることがあるかも。困る場合は -u で実行ユーザを明示的に指定しちゃえばよさそう。

...
[sample] Running shell script
+ id
uid=1000(kimura) gid=1000(kimura) groups=1000(kimura),10(wheel),987(docker) context=unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023
[Pipeline] sh
[sample] Running shell script
+ docker build -t 5 -f Dockerfile.build .
Sending build context to Docker daemon 2.048 kB

Step 1/2 : FROM openjdk:8u131-jdk-alpine
 ---> c7105179e75b
Step 2/2 : USER root
 ---> Using cache
 ---> 4dc2ef524f48
Successfully built 4dc2ef524f48
[Pipeline] dockerFingerprintFrom
[Pipeline] sh
[sample] Running shell script
+ docker inspect -f . 5
.
[Pipeline] withDockerContainer
Jenkins does not seem to be running inside a container
$ docker run -t -d -u 1000:1000 -w /home/kimura/.jenkins/workspace/sample -v /home/kimura/.jenkins/workspace/sample:/home/kimura/.jenkins/workspace/sample:rw -v /home/kimura/.jenkins/workspace/sample@tmp:/home/kimura/.jenkins/workspace/sample@tmp:rw -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** --entrypoint cat 5
[Pipeline] {
[Pipeline] sh
[sample] Running shell script
+ id
uid=1000 gid=1000
...
ホスト環境にライブラリをキャッシュするときの注意点

Jenkinsのworkspace以外のホストディレクトリにキャッシュさせる場合、存在しないディレクトリにマウントするとアクセス権がrootになってしまうので注意。
Add ability to mount volume as user other than root · Issue #2259 · moby/moby · GitHub

以下、動きを確かめる。

dir1は作っておき、dir2をDockerコンテナ起動時の-vに指定する。

$ ls -al
合計 4
drwxrwxr-x.  3 kimura kimura   33  61 19:24 .
drwx------. 30 kimura kimura 4096  61 19:20 ..
drwxrwxr-x.  2 kimura kimura    6  61 19:21 dir1
-rw-rw-r--.  1 kimura kimura    0  61 19:20 hello.txt

起動時に-vに指定して生成されたdir2はrootになっていることがわかる。

]$ docker run -it --rm -u 1000:1000 -v $(pwd)/dir1:$(pwd)/dir1 -v $(pwd)/dir2:$(pwd)/dir2 -w
$(pwd) alpine /bin/sh
/home/kimura/sample $ ls -al
total 0
drwxr-xr-x    4 root     root            28 Jun  1 10:24 .
drwxr-xr-x    3 root     root            19 Jun  1 10:24 ..
drwxrwxr-x    2 1000     1000             6 Jun  1 10:21 dir1
drwxr-xr-x    2 root     root             6 Jun  1 10:24 dir2

なので、ライブラリをキャッシュするディレクトリは事前に作っておくと安全だと思う。
以下のようにするとうまくいった。(本当はユーザごとのディレクトリを指定したほうが安全)

  sh 'mkdir -p /tmp/docker/cache/.node_modules || true'
  sh 'mkdir -p /tmp/docker/cache/.m2 || true'
  docker.build("${BUILD_ID}", "-f Dockerfile.build .").inside("-v /tmp/docker/cache/.m2:/var/maven/.m2 -v /tmp/docker/cache/.node_modules:${WORKSPACE}/client/node_modules") {
  ...
npm使うときの注意点

jenkinsの実行ユーザがDockerコンテナ内でコマンドを実行するため、パーミッションに注意が必要。とくにnpmを使う場合は /.npm にキャッシュディレクトリを作ろうとしてJenkins実行ユーザにパーミッションがなくて失敗することがある。回避策は npm_config_cache変数を環境変数に設定してキャッシュディレクトリの作られる場所を変えること。

npm install fails in jenkins pipeline in docker - Stack Overflow

docker.build(...).inside(...) {
    withEnv(['npm_config_cache=npm-cache']) {
        stage('build') {
            ....
        }
サンプルコード

上記をふまえてJenkinsfile内でDockerコンテナを管理してみた。

github.com

実行した結果はこちら。
ビルドの48からキャッシュさせたところ、ダウンロード時間が大幅に減ってるのがわかる。

f:id:kimulla:20170613002527p:plain

Image.withRun[(args[, command])] {…}

Dockerコンテナを起動したあとに、ホスト側で{...}のコマンドを実行する。

node {
    docker.image('postgres:9.6.3').withRun("-p 5432:5432") {
        sh 'ls -al'
    }
}

argsで指定した引数が起動引数に追加される。
Dockerコンテナの停止は{...}を抜けたあとに勝手に行われる。

...
+ docker run -d -p 5432:5432 postgres:9.6.3
[Pipeline] dockerFingerprintRun
[Pipeline] sh
[sample] Running shell script
+ ls -al
合計 8
drwxrwxr-x.  2 kimura kimura   29  61 14:36 .
drwxrwxr-x. 18 kimura kimura 4096  61 13:20 ..
-rw-rw-r--.  1 kimura kimura  528  61 14:35 Dockerfile.build
[Pipeline] sh
[sample] Running shell script
+ docker stop 4be5a879c0ed353e7e0d5031a78a450d88c7392febcee75e90671b8171b95268
4be5a879c0ed353e7e0d5031a78a450d88c7392febcee75e90671b8171b95268
+ docker rm -f 4be5a879c0ed353e7e0d5031a78a450d88c7392febcee75e90671b8171b95268
4be5a879c0ed353e7e0d5031a78a450d88c7392febcee75e90671b8171b95268
...

テストのために一時的にDBを上げる用途に使うんでしょう、たぶん。

使い方(declarative pipeline)

最近は scripted pipeline よりも declarative pipeline のほうがおすすめらしい。
使い方はリファレンスに載っている。

jenkins.io

既存イメージを利用する場合

scripted pipeline と同じように、とりあえずPipelineプロジェクトで進める。

pipeline {
    agent {
        docker 'openjdk:8u131-jdk'
    }
    stages() {
        stage('build') {
            steps() {
                sh 'java -version'
            }
        }
    }
}

コンソール出力をみると、 scripted pipeline と同じようなことをやってる。
Dockerコンテナ上でshが実行され、ジョブが終わったときにコンテナをstopしてrmしている。

ユーザーanonymousが実行
[Pipeline] node
Running on master in /home/kimura/.jenkins/workspace/yyy
[Pipeline] {
[Pipeline] stage
[Pipeline] { (Declarative: Agent Setup)
[Pipeline] sh
[yyy] Running shell script
+ docker pull openjdk:8u131-jdk
8u131-jdk: Pulling from library/openjdk
Digest: sha256:90c3ac824aa5ce63d8540bb73b6d548b40dc0d536702a48e6a7f21efdc10a861
Status: Image is up to date for openjdk:8u131-jdk
[Pipeline] }
[Pipeline] // stage
[Pipeline] sh
[yyy] Running shell script
+ docker inspect -f . openjdk:8u131-jdk
.
[Pipeline] withDockerContainer
Jenkins does not seem to be running inside a container
$ docker run -t -d -u 1000:1000 -w /home/kimura/.jenkins/workspace/yyy -v /home/kimura/.jenkins/workspace/yyy:/home/kimura/.jenkins/workspace/yyy:rw -v /home/kimura/.jenkins/workspace/yyy@tmp:/home/kimura/.jenkins/workspace/yyy@tmp:rw -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** --entrypoint cat openjdk:8u131-jdk
[Pipeline] {
[Pipeline] stage
[Pipeline] { (build)
[Pipeline] sh
[yyy] Running shell script
+ java -version
openjdk version "1.8.0_131"
OpenJDK Runtime Environment (build 1.8.0_131-8u131-b11-1~bpo8+1-b11)
OpenJDK 64-Bit Server VM (build 25.131-b11, mixed mode)
[Pipeline] }
[Pipeline] // stage
[Pipeline] }
$ docker stop --time=1 3143403b1d45c91f61b64b1d983cbf0562e9fe682aee566f2cdf1f16216b9437
$ docker rm -f 3143403b1d45c91f61b64b1d983cbf0562e9fe682aee566f2cdf1f16216b9437
[Pipeline] // withDockerContainer
...
Dockerfileからイメージをビルドする場合

まずはJenkinsのworkspaceにDockerfileを用意する。

$ pwd
/home/kimura/.jenkins/workspace/yyy
$ cat Dockerfile.build
FROM openjdk:8u131-jdk-alpine

RUN apk update && apk upgrade && \
    apk add curl nodejs-npm=6.10.3-r0 maven=3.3.9-r1 make

以下のようにファイル名を指定する。

pipeline {
    agent {
        dockerfile {
            filename 'Dockerfile.build'
            args '-v /tmp/docker/cache/.m2:/var/maven/.m2'
        }
    }
    stages() {
        stage('build') {
            steps() {
                sh 'java -version'
            }
        }
    }
}

ただしDockerfileという名前でworkspace直下にある場合は指定不要。
その場合は以下のようにする。

    agent {
        dockerfile true
    }
...


コンソール出力をみると、Dockerfile.buildからイメージをビルドしているのがわかる。
また、argsに指定した引数がdocker run時に指定されているのがわかる。

ユーザーanonymousが実行
[Pipeline] node
Running on master in /home/kimura/.jenkins/workspace/yyy
[Pipeline] {
[Pipeline] stage
[Pipeline] { (Declarative: Agent Setup)
[Pipeline] readFile
[Pipeline] sh
[yyy] Running shell script
+ docker build -t 05acb8528ede11dbba8c1b1dc5d2f3ef110d3a0a -f Dockerfile.build .
Sending build context to Docker daemon 2.048 kB

Step 1/2 : FROM openjdk:8u131-jdk-alpine
 ---> c7105179e75b
Step 2/2 : RUN apk update && apk upgrade &&     apk add curl nodejs-npm=6.10.3-r0 maven=3.3.9-r1 make
 ---> Using cache
 ---> 0a22744743d5
Successfully built 0a22744743d5
[Pipeline] dockerFingerprintFrom
[Pipeline] }
[Pipeline] // stage
[Pipeline] sh
[yyy] Running shell script
+ docker inspect -f . 05acb8528ede11dbba8c1b1dc5d2f3ef110d3a0a
.
[Pipeline] withDockerContainer
Jenkins does not seem to be running inside a container
$ docker run -t -d -u 1000:1000 -v /tmp/docker/cache/.m2:/var/maven/.m2 -w /home/kimura/.jenkins/workspace/yyy -v /home/kimura/.jenkins/workspace/yyy:/home/kimura/.jenkins/workspace/yyy:rw -v /home/kimura/.jenkins/workspace/yyy@tmp:/home/kimura/.jenkins/workspace/yyy@tmp:rw -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** --entrypoint cat 05acb8528ede11dbba8c1b1dc5d2f3ef110d3a0a
...
サンプルコード

とりあえずscripted pipeline pluginのコードに変えてみた。

github.com

注意点

agentをDockerにしたときにpostでdeleteDir()すると、エラーになる。

#!/usr/bin/env groovy
pipeline {

    agent {
        dockerfile {
            filename 'Dockerfile.build'
            args '-v /tmp/docker/cache/.m2:/var/maven/.m2 -v /tmp/docker/cache/.node_modules:${WORKSPACE}/client/node_modules'
        }
    }
...
     post {
         always {
             deleteDir()
         }
     }


コンソール出力のエラー内容から予想するに、agentが1つしか指定されていない場合はすべてのステップをコンテナ内で実行するため、コンテナ起動時に割り当てられたworkspaceのディレクトリをdeleteDir()で消そうとしてエラーになってるっぽい。(自信ない)

[Pipeline] }
[Pipeline] // stage
[Pipeline] stage
[Pipeline] { (Declarative: Post Actions)
[Pipeline] deleteDir
[Pipeline] }
[Pipeline] // stage
[Pipeline] }
[Pipeline] // withEnv
[Pipeline] }
$ docker stop --time=1 dddaf3a162e5d72f112fa040cd2af2012dbd0df5d25b794d17e72412787ed750
$ docker rm -f dddaf3a162e5d72f112fa040cd2af2012dbd0df5d25b794d17e72412787ed750
[Pipeline] // withDockerContainer
[Pipeline] }
[Pipeline] // node
[Pipeline] End of Pipeline
java.nio.file.FileSystemException: /home/kimura/.jenkins/workspace/zzz_plugin-declarative-CRDZ7E2TTQRHZTXPZFVEXD5PM6IOKJAECQWTPPBF52DR5B4W7TKQ/client/node_modules: デバイスもしくはリソースがビジー状態です
	at sun.nio.fs.UnixException.translateToIOException(UnixException.java:91)
	at sun.nio.fs.UnixException.rethrowAsIOException(UnixException.java:102)
	at sun.nio.fs.UnixException.rethrowAsIOException(UnixException.java:107)
	at sun.nio.fs.UnixFileSystemProvider.implDelete(UnixFileSystemProvider.java:244)

試しにtargetディレクトリをdocker起動時にマウントして消そうとすると同じエラーが出る。

$ pwd
/home/kimura/docker/sample
$ mkdir target
$ ls -al
合計 0
drwxr-xr-x. 3 kimura kimura 19  62 07:27 .
drwxrwxr-x. 5 kimura kimura 57  61 19:36 ..
drwxrwxr-x. 2 kimura kimura  6  62 07:27 target
$ docker run -it -v $(pwd)/target:$(pwd)/target -w $(pwd) centos:7 /bin/bash
# ls
target
# ll
total 0
drwxrwxr-x. 2 1000 1000 6 Jun  1 22:27 target
# rm -rf target
rm: cannot remove 'target': Device or resource busy
#

とはいえ、scripted pipelineと似たような要領で進められそう。
declarative pipelineのほうが事後処理がやりやすいので(always, failureなどなど)、新規に作成する人は declarative pipelineで作成したほうがよさそう。

Docker Pipelin Plugin の良いところ

  • ビルドサーバのメンテナンス性向上
  • ビルドタスクの柔軟性の向上
  • バージョン管理できる
  • ほぼローカルで実行するのと変わらないくらいコンテナ起動停止が早い
  • コンテナに関するあれこれの考慮をあんまり意識せずに使える

Docker Pipelin Plugin の悪いところ

  • Jenkinsfileにビルドやテストに関するあれこれを書きすぎるとローカル環境でDockerを気軽に使えなくなりそう


ということでローカルでもDockerを利用したビルドができるように、Docker Pipeline Pluginを参考にMakefileを作ってみた。

github.com


f:id:kimulla:20170612235217j:plain

これが言いたかった

参考

PostgreSQLのDockerイメージの使い方

ホスト環境を汚さずにいろいろなバージョンのミドルウェアを構築するために、Dockerは非常に有効な手段だと思います。
この記事は、PostgreSQLのDockerイメージを使うために試行錯誤したときのメモです。

動作確認環境

$ cat /etc/redhat-release
CentOS Linux release 7.2.1511 (Core)
$ docker --version
Docker version 1.10.3, build d381c64-unsupported

2017/04/03 追記
さすがにdockerのバージョンが古かったので、新しめのCE版を入れて動作確認しました。
動きが違うところはバージョンごとに書いてます。
Get Docker for CentOS - Docker Documentation

$ cat /etc/redhat-release
CentOS Linux release 7.2.1511 (Core)
$ docker --version
Docker version 17.03.1-ce, build c6d412e

PostgreSQLの起動

$ docker run --name my-db -p 5432:5432 -e POSTGRES_USER=dev -e POSTGRES_PASSWORD=secret -d postgres:9.6
--name my-db

--nameでコンテナ名の指定ができる。
起動しているコンテナの名前は docker ps で確認できる。

$ docker ps
CONTAINER ID        IMAGE               COMMAND                  CREATED             STATUS              PORTS                    NAMES
f43f50193fa6        postgres:9.6        "docker-entrypoint..."   40 seconds ago      Up 38 seconds       0.0.0.0:5432->5432/tcp   my-db
-p 5432:5432

ポートフォワーディングしてほしければ、-pでバインディングする。
(-p ホストのポート:コンテナのポート)

# ホスト側の5432ポートに接続するとコンテナにフォワードされる
$ psql -h localhost -U dev
ユーザ dev のパスワード:
psql (9.6.1, サーバー 9.6.2)
"help" でヘルプを表示します.

dev=# \l
                                         データベース一覧
   名前    |  所有者  | エンコーディング |  照合順序  | Ctype(変換演算子) |      アクセス権
-----------+----------+------------------+------------+-------------------+-----------------------
 dev       | postgres | UTF8             | en_US.utf8 | en_US.utf8        |
 postgres  | postgres | UTF8             | en_US.utf8 | en_US.utf8        |
 template0 | postgres | UTF8             | en_US.utf8 | en_US.utf8        | =c/postgres          +

サーバロケールがen_US.utf8になっている。ja_JP.utf8にしたい人は変更が必要。
https://hub.docker.com/_/postgres/

-e POSTGRES_USER

スーパユーザ名(省略時は"postgres")

-e POSTGRES_PASSWORD

スーパユーザのパスワード(省略時はパスワードなしでログイン可)

-e POSTGRES_DB

PostgreSQL上のデータベース名(省略時はPOSTGRES_USERと同じ)
デフォルトだとユーザ名と同じデータベースが存在しないとエラーになるのであんまり変える機会なさそう。

-e PGDATA

PostgreSQLのデータの格納先ディレクトリ(省略時は/var/lib/postgresql/data)

※ -eコマンドでコンテナの環境変数を設定できる仕組みがDockerにあるらしい。
http://docs.docker.jp/engine/reference/run.html#env

以下のようなDockerfileを用意して試してみる。
docker runしたら環境変数が一覧表示されるはず。

$ cat Dockerfile
FROM centos:7
ENV HOGE hoge
ENTRYPOINT ["env"]

ビルドしてイメージを作る。

$ docker build -t xxx .

docker runしたら環境変数を一覧表示する。
ENVで指定したHOGEが出力される。

$ docker run xxx
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
HOSTNAME=c1844898c4c6
HOGE=hoge
HOME=/root

-eで環境変数を指定してみる。
Dockerfileで指定したENVが上書きされる。
また、-e FUGA=fuga で追加したFUGAも表示される。

$ docker run -e HOGE=fuga -e FUGA=fuga xxx
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
HOSTNAME=d845c635858c
HOGE=fuga
FUGA=fuga
HOME=/root

この仕組みを利用してPostgreSQLのイメージは起動時に実行されるdocker-entrypoint.sh内でふるまいを変えているっぽい。
以下、PostgreSQL9.6イメージのdocker-entrypoint.shのソース抜粋。

file_env() {
        local var="$1"
        local fileVar="${var}_FILE"
        local def="${2:-}"
        if [ "${!var:-}" ] && [ "${!fileVar:-}" ]; then
                echo >&2 "error: both $var and $fileVar are set (but are exclusive)"
                exit 1
        fi
        local val="$def"
        if [ "${!var:-}" ]; then
                val="${!var}"
        elif [ "${!fileVar:-}" ]; then
                val="$(< "${!fileVar}")"
        fi
        export "$var"="$val"
        unset "$fileVar"
}
   ...

   file_env 'POSTGRES_USER' 'postgres'
   file_env 'POSTGRES_DB' "$POSTGRES_USER"

   psql=( psql -v ON_ERROR_STOP=1 )

   if [ "$POSTGRES_DB" != 'postgres' ]; then
           "${psql[@]}" --username postgres <<-EOSQL
                   CREATE DATABASE "$POSTGRES_DB" ;
           EOSQL
           echo
   fi

   if [ "$POSTGRES_USER" = 'postgres' ]; then
           op='ALTER'
   ...

起動時の動きを確認するときは docker inspect を利用する。

$ docker inspect postgres:9.6
...
  "WorkingDir": "",
  "Entrypoint": [
    "docker-entrypoint.sh"
  ],
...

起動しているコンテナに入れば、スクリプトディレクトリ構成を手軽に確認できる。

$ docker exec -it my-db /bin/bash
root@f43f50193fa6:/# ls
bin   dev                         docker-entrypoint.sh  home  lib64  mnt  proc  run   srv  tmp  var
boot  docker-entrypoint-initdb.d  etc                   lib   media  opt  root  sbin  sys  usr

PostgreSQLへの接続(コンテナを利用してpsqlを実行する)

このほうがDocker使ってるっぽくて個人的に好み。
psqlのバージョンも気軽にサーバ側バージョンに合わせられる。

$ docker run -it --rm --link my-db:db postgres:9.6 psql -h db -U dev
Password for user dev:
psql (9.6.2)
Type "help" for help.

dev=# \l
                                 List of databases
   Name    |  Owner   | Encoding |  Collate   |   Ctype    |   Access privileges
-----------+----------+----------+------------+------------+-----------------------
 dev       | postgres | UTF8     | en_US.utf8 | en_US.utf8 |
 postgres  | postgres | UTF8     | en_US.utf8 | en_US.utf8 |
 template0 | postgres | UTF8     | en_US.utf8 | en_US.utf8 | =c/postgres          +
--rm

実行が終わったらpsqlを実行したコンテナを破棄する。

--link my-db:db

--link コンテナ名:ホスト名 の形式で、起動するコンテナの/etc/hostsにホスト名が設定される。(このホスト名はlink先のコンテナのIPアドレスを指す)
https://docs.docker.com/engine/userguide/networking/default_network/dockerlinks/

/etc/hostsに設定されたホスト名を利用して psql -h の接続先ホストを指定する。

PostgreSQLの停止

stopするだけ。

$ docker stop my-db

PostgreSQLの再開

コンテナの$PGDATAディレクトリにデータが残っているので前のデータはそのまま。
ただし、コンテナを消すとデータも消える。

$ docker start my-db

データの永続化

データをコンテナと切り離して永続化したい場合、PostgreSQLのデータ保存先をData Volumeに指定するといいらしい。

Data Volumeを作成する。

$ docker volume create --name pgdata
pgdata

作成されたData Volumeを確認する。

$ docker volume inspect pgdata
[
    {
        "Driver": "local",
        "Labels": {},
        "Mountpoint": "/var/lib/docker/volumes/pgdata/_data",
        "Name": "pgdata",
        "Options": {},
        "Scope": "local"
    }
]

起動時にData Volumeを指定する。

$ docker run -it --name my-db -v pgdata:/var/lib/postgresql/data -e POSTGRES_PASSWORD=secret -d postgres:9.6
$ docker run -it --rm --link my-db:db postgres:9.6 psql -h db -U postgres

postgres=# create table book();
CREATE TABLE

こうするとコンテナが消えてもデータは残る。
コンテナを再度作成してData Volumeを割り当てれば昔のデータがそのまま見れる。

$ docker stop my-db
$ docker rm my-db
$ docker run -it --name my-db -v pgdata:/var/lib/postgresql/data -e POSTGRES_PASSWORD=secret -d postgres:9.6
$ docker run -it --rm --link my-db:db postgres psql -h db -U postgres:9.6

postgres=# \d
        List of relations
 Schema | Name | Type  |  Owner
--------+------+-------+----------
 public | book | table | postgres

設定のカスタマイズ方法

設定ファイルの編集

コンテナからpostgresql.confをコピーする。

$ docker cp my-db:/var/lib/postgresql/data/postgresql.conf .

ホスト側でカスタマイズする。
今回はとりあえずmax_connectionsを100から5にしてみる。

$ cat postgresql.conf
...
max_connections = 5
...

編集した設定ファイルの配置

dockerのバージョンによってとった対処が違うので分けて記述する。

docker 1.10 向け

以下でコンテナ側にファイルを配置できると思ったけど、ダメ。

$ docker run -it --name my-db -v $(pwd)/postgresql.conf:/var/lib/postgresql/data/postgresql.conf -e POSTGRES_PASSWORD=secret postgres:9.6
chown: changing ownership of ‘/var/lib/postgresql/data/postgresql.conf’: Permission denied

なんでなんで?ググるとこんなのが。
DockerのVolumeのアクセス権限の問題について - Qiita

volumeで設定したファイルはuid,gidが1000になるらしい。たしかに。

$ docker run --rm -v $(pwd)/postgresql.conf:/postgresql.conf centos:7 ls -al
total 96
...
drwxr-xr-x.   2 root root  4096 Nov  5 15:38 mnt
drwxr-xr-x.   2 root root  4096 Nov  5 15:38 opt
-rw-------.   1 1000 1000 22203 Mar 31 21:08 postgresql.conf
dr-xr-xr-x. 317 root root     0 Mar 31 21:13 proc
dr-xr-x---.   2 root root  4096 Mar 15 20:00 root
...

じゃあrootで実行すればいいんでしょうね、と思ったらそんなこともなかった。

$ docker run --rm -v $(pwd)/postgresql.conf:/postgresql.conf centos:7 id
uid=0(root) gid=0(root) groups=0(root)
$ docker run --rm -v $(pwd)/postgresql.conf:/postgresql.conf centos:7 cat postgresql.conf
cat: postgresql.conf: Permission denied

ググるとこんなのが。selinuxの問題らしい。
Permission denied on accessing host directory in docker - Stack Overflow

ということで以下のコマンドを実行するとrootでは実行できるようになった。

$ chcon -Rt svirt_sandbox_file_t postgresql.conf
$ docker run --rm -v $(pwd)/postgresql.conf:/postgresql.conf centos:7 cat postgresql.conf
# -----------------------------
# PostgreSQL configuration file
# -----------------------------
#
# This file consists of lines of the form:
#
#   name = value

ということでselinuxの問題を解決してから再実行。

$ chcon -Rt svirt_sandbox_file_t postgresql.conf
$ docker run -it --name my-db -v $(pwd)/postgresql.conf:/var/lib/postgresql/data/postgresql.conf -e POSTGRES_PASSWORD=secret postgres:9.6
The files belonging to this database system will be owned by user "postgres".
This user must also own the server process.

The database cluster will be initialized with locale "en_US.utf8".
The default database encoding has accordingly been set to "UTF8".
The default text search configuration will be set to "english".

Data page checksums are disabled.

initdb: directory "/var/lib/postgresql/data" exists but is not empty
If you want to create a new database system, either remove or empty
the directory "/var/lib/postgresql/data" or run initdb
with an argument other than "/var/lib/postgresql/data".

またエラー。

PostgreSQLのinitdb実行前にPGDATAにファイルがおいてあるのが悪いっぽい。
Dockerがコンテナの/var/lib/postgresql/dataにVolumeを追加したあとにENTRYPOINT(docker-entrypoint.sh)が実行されるため、その中でinitdbした時にこけてると思われる。
https://github.com/docker-library/postgres/issues/105

PostgreSQL側でpostgresql.confを別ディレクトリから参照可能にするパラメータがあるため、それを利用する。

$ chcon -Rt svirt_sandbox_file_t postgresql.conf
$ docker run -it --name my-db -v $(pwd)/postgresql.conf:/etc/postgresql.conf -e POSTGRES_PASSWORD=secret  postgres:9.6 -c config_file=/etc/postgresql.conf
...
LOG:  autovacuum launcher shutting down
LOG:  shutting down
LOG:  database system is shut down
 done
server stopped

PostgreSQL init process complete; ready for start up.

LOG:  could not open configuration file "/etc/postgresql.conf": Permission denied
FATAL:  configuration file "/etc/postgresql.conf" contains errors

またまたエラー。

結局ファイルのuid,gidが1000なのでPostgreSQLを実行するユーザからは権限がない。
そのため、コンテナ内のpostgresユーザがアクセスできるようにファイルのパーミッションを変える必要がある。つまり、Dockerfileを用意する必要がある。

$ cat Dockerfile
FROM postgres
COPY postgresql.conf /etc/postgresql.conf
RUN chown -R postgres /etc/postgresql.conf

Dockerfileをビルドし、起動時に -c config_file=/etc/postgresql.conf を指定する。

$ docker build -t dev-postgres -f Dockerfile .
$ docker run -it --name my-db -e POSTGRES_PASSWORD=secret -d dev-postgres -c config_file=/etc/postgresql.conf

PostgreSQLに接続してmax_connectionsが変わってるか確認してみる。

$ docker run -it --rm --link my-db:db postgres:9.6 psql -h db -U postgres
postgres=# show max_connections;
 max_connections
-----------------
 5
(1 row)

いけた!!!

Docker version 17.03.1-ce, build c6d412e 向け

以下でコンテナ側にファイルを配置できると思ったけど、ダメ。

$ docker run -it --name my-db -v $(pwd)/postgresql.conf:/var/lib/postgresql/data/postgresql.conf -e POSTGRES_PASSWORD=secret postgres:9.6
The files belonging to this database system will be owned by user "postgres".
This user must also own the server process.

The database cluster will be initialized with locale "en_US.utf8".
The default database encoding has accordingly been set to "UTF8".
The default text search configuration will be set to "english".

Data page checksums are disabled.

initdb: directory "/var/lib/postgresql/data" exists but is not empty
If you want to create a new database system, either remove or empty
the directory "/var/lib/postgresql/data" or run initdb
with an argument other than "/var/lib/postgresql/data".

昔のバージョンのようにselinuxの問題はないが、やっぱりPostgreSQLのinitdb実行前にPGDATAにファイルがおいてあるのが悪いっぽい。
Dockerがコンテナの/var/lib/postgresql/dataにVolumeを追加したあとにENTRYPOINT(docker-entrypoint.sh)が実行されるため、その中でinitdbした時にこけてると思われる。
https://github.com/docker-library/postgres/issues/105

PostgreSQL側でpostgresql.confを別ディレクトリから参照可能にするパラメータがあるため、それを利用する。

$ docker run -it --name my-db -v $(pwd)/postgresql.conf:/etc/postgresql.conf -e POSTGRES_PASSWORD=secret -d postgres:9.6 -c config_file=/etc/postgresql.conf
bf55391afe2ae709e84efd81025b4d7f542f3db4bb42d13c348807cc9342b47f
$ docker run -it --rm --link my-db:db postgres:9.6 psql -h db -U postgres
Password for user postgres:
psql (9.6.2)
Type "help" for help.

postgres=# \l
                                 List of databases
   Name    |  Owner   | Encoding |  Collate   |   Ctype    |   Access privileges
-----------+----------+----------+------------+------------+-----------------------
 postgres  | postgres | UTF8     | en_US.utf8 | en_US.utf8 |
 template0 | postgres | UTF8     | en_US.utf8 | en_US.utf8 | =c/postgres          +
           |          |          |            |            | postgres=CTc/postgres
 template1 | postgres | UTF8     | en_US.utf8 | en_US.utf8 | =c/postgres          +

特にpostgresql.confファイルをchownしなくても実行できる。昔のバージョンよりだいぶ楽!!!

PostgreSQLの日本語化

ロケールを追加し、LANG環境変数に設定する。

$ cat Dockerfile
FROM postgres:9.6
RUN localedef -i ja_JP -c -f UTF-8 -A /usr/share/locale/locale.alias ja_JP.UTF-8
ENV LANG ja_JP.utf8

Dockerfileをビルドし、起動する。

$ docker build -t dev-postgres -f Dockerfile .
$ docker run -it --name my-db -e POSTGRES_PASSWORD=secret -d dev-postgres 

$ docker run -it --rm --link my-db:db postgres:9.6 psql -h db -U postgres
Password for user postgres:
psql (9.6.2)
Type "help" for help.

postgres=# \l
                                 List of databases
   Name    |  Owner   | Encoding |  Collate   |   Ctype    |   Access privileges
-----------+----------+----------+------------+------------+-----------------------
 postgres  | postgres | UTF8     | ja_JP.utf8 | ja_JP.utf8 |
 template0 | postgres | UTF8     | ja_JP.utf8 | ja_JP.utf8 | =c/postgres          +
           |          |          |            |            | postgres=CTc/postgres

永続化やら設定のカスタマイズをまとめると・・・

設定をカスタマイズするときの起動について、最終的には以下のDockerfileを用意しとけばよさそう。
(Docker version 17.03.1-ce, build c6d412e だとpostgresql.confファイルをDockerfile内でCOPYしてchownしなくてもいいが、どうせ日本語ロケールに設定するためにDockerfile作るのでコレで)

$ cat Dockerfile
FROM postgres:9.6
RUN localedef -i ja_JP -c -f UTF-8 -A /usr/share/locale/locale.alias ja_JP.UTF-8
ENV LANG ja_JP.utf8
COPY postgresql.conf /etc/postgresql.conf
RUN chown -R postgres /etc/postgresql.conf

そしてこう!

  # postgresql.confを取り出すために一時的なコンテナを起動
$ docker run --name tmp -d postgres:9.6
  # postgresql.confを取り出す
$ docker cp tmp:/var/lib/postgresql/data/postgresql.conf .
  # postgresql.confを適当に編集(例としてmax_connectionsを50にする)
  # 編集したpostgresql.confを配置したイメージを作成
$ docker build -t dev-postgres -f Dockerfile .
  # 永続化するためにData Volume作成
$ docker volume create pgdata
  # コンテナを起動
$ docker run -it --name my-db -e POSTGRES_USER=dev -e POSTGRES_PASSWORD=secret -v pgdata:/var/lib/postgresql/data -d dev-postgres -c config_file=/etc/postgresql.conf
  # PostgreSQLに接続
$ docker run -it --rm --link my-db:db postgres:9.6 psql -h db -U dev
Password for user dev:
psql (9.6.2)
Type "help" for help.

dev=# \l
                                 List of databases
   Name    |  Owner   | Encoding |  Collate   |   Ctype    |   Access privileges
-----------+----------+----------+------------+------------+-----------------------
 dev       | postgres | UTF8     | ja_JP.utf8 | ja_JP.utf8 |
 postgres  | postgres | UTF8     | ja_JP.utf8 | ja_JP.utf8 |
 template0 | postgres | UTF8     | ja_JP.utf8 | ja_JP.utf8 | =c/postgres          +

  # 自分で修正したpostgresql.confの内容が反映されている
dev=# show max_connections;
 max_connections
-----------------
 200
(1 row)

手順を簡素化する

上記の例(ビルド~起動)までのコマンドは起動引数が多くてちょっと大変。
docker-compose.ymlを使うと楽だよ、と教えてもらったので使ってみた。

まずインストール
Install Docker Compose - Docker Documentation

$ docker-compose --version
docker-compose version 1.11.2, build dfed245

docker-compose.ymlファイルを用意。

$ cat docker-compose.yml
version: "2"
volumes:
   pgdata:
     driver: 'local'
services:
  my-db:
    build: .
    image: "dev-postgres"
    container_name: "my-db"
    ports:
      - "5432:5432"
    environment:
      POSTGRES_USER: "dev"
      POSTGRES_PASSWORD: "secret"
    command: 'postgres -c config_file="/etc/postgresql.conf"'
    volumes:
      - pgdata:/var/lib/postgresql/data

postgresql.confをローカルに置いてさえ置けば、以下のコマンドでDockerfileのビルドから起動までやってくれるようになった。引数がコード化されている点がいい。

$ docker-compose up --build -d
docker-compose up --build -d
Building my-db
Step 1/5 : FROM postgres
 ---> 9910dc9f2ac0
Step 2/5 : RUN localedef -i ja_JP -c -f UTF-8 -A /usr/share/locale/locale.alias ja_JP.UTF-8
 ---> Using cache
 ---> 3e2faf03d333
Step 3/5 : ENV LANG ja_JP.utf8
 ---> Using cache
 ---> 4b8b89e1d0f7
Step 4/5 : COPY postgresql.conf /etc/postgresql.conf
 ---> Using cache
 ---> e40af9553b4a
Step 5/5 : RUN chown -R postgres /etc/postgresql.conf
 ---> Using cache
 ---> 70e2b24824d7
Successfully built 70e2b24824d7
my-db is up-to-date

ただし、psql実行時の --linkでコンテナ間リンクを貼れなくなった。docker-composeはコンテナグループごとにネットワーク作るとかなんとか・・・また土日に調べる。

ひとまずホスト側にあるpsqlで接続する。

$ psql -h localhost -U dev
ユーザ dev のパスワード:
psql (9.6.1, サーバー 9.6.2)
"help" でヘルプを表示します.

dev=# \l
                                         データベース一覧
   名前    |  所有者  | エンコーディング |  照合順序  | Ctype(変換演算子) |      アクセス権
-----------+----------+------------------+------------+-------------------+-----------------------
 dev       | postgres | UTF8             | ja_JP.utf8 | ja_JP.utf8        |
 postgres  | postgres | UTF8             | ja_JP.utf8 | ja_JP.utf8        |
 template0 | postgres | UTF8             | ja_JP.utf8 | ja_JP.utf8        | =c/postgres          +

docker-composeで起動した場合、各アプリケーションごとにネットワークが設定される。

$ docker-compose ps
Name               Command               State           Ports
-----------------------------------------------------------------------
my-db   docker-entrypoint.sh postg ...   Up      0.0.0.0:5432->5432/tcp

$ docker inspect my-db
...
      "Networks": {
        "mydocker_default": {
          "IPAMConfig": null,
          "Links": null,
          "Aliases": [
            "84eb940e53ad",
            "my-db"
          ],
          "NetworkID": "8ceeb0c7592109b449fc141b24c19c108d0c5ac6c7fcacc58642d5ce6f8f3a78",
          "EndpointID": "a9e8aa8f3f4ed045ef395220833aabc3eee704731f591f77cdbddd2a2430a6a1",
          "Gateway": "172.19.0.1",
          "IPAddress": "172.19.0.2",
          "IPPrefixLen": 16,
          "IPv6Gateway": "",
          "GlobalIPv6Address": "",
          "GlobalIPv6PrefixLen": 0,
          "MacAddress": "02:42:ac:13:00:02"
        }
      }
...

$ docker network ls
NETWORK ID          NAME                  DRIVER              SCOPE
...
8ceeb0c75921        mydocker_default      bridge              local
...

そのため、psqlをコンテナ間リンクで接続したい場合は以下のようにする。

$ docker run --rm -it --net mydocker_default --link my-db:db postgres:9.6 psql -h db -U dev
Password for user dev:
psql (9.6.2)
Type "help" for help.

dev=# show max_connections;
 max_connections
-----------------
 50
(1 row)

終わりに

Dockerを使いこなすためにも、まずはLinux力をつけていきたい。