Scala: Docker を使って sbt test を並列化してみた
Docker + CI が巷で流行しているそうなので便乗。
動機
ここに、残念なテストがある。
ローカル上の Redis サーバに接続し、100件のレコードを投入した後で 1件削除。
そのときにレコード数が 99件かどうかを検証するだけの簡単なプログラムだ。
これが全部で 4セットほどある。
このテストは明らかに
- 無駄な sleep が入っていて実行時間が長い (1クラスごとに10秒待たされる)
- テストを並列で実行するとカウントがずれてテストが通らない場合がある
- テストを流す前に既に DB に他のデータが入っていたら、カウントがずれてテストが通らない
というような問題を抱えている。
単に実行すると 48秒かかる
$ sbt test (略) [info] Passed: Total 4, Failed 0, Errors 0, Passed 4 [success] Total time: 48 s, completed Jan 11, 2014 1:35:59 AM
parallelExecution を有効にするとテストが通らない
$ sbt 'set parallelExecution in Test := true' test (略) [info] test1 should [error] x insert 100 records and delete 1 record [error] 'Some(96)' is not Some with value '99' (RedisConnectionSpec.scala:27) (略) [error] Failed: Total 4, Failed 4, Errors 0, Passed 0 [error] Failed tests: [error] com.github.mogproject.dockersbttest.example.RedisConnectionSpec1 [error] com.github.mogproject.dockersbttest.example.RedisConnectionSpec2 [error] com.github.mogproject.dockersbttest.example.RedisConnectionSpec3 [error] com.github.mogproject.dockersbttest.example.RedisConnectionSpec4 [error] (test:test) sbt.TestsFailedException: Tests unsuccessful [error] Total time: 14 s, completed Jan 11, 2014 1:37:52 AM
テストデータ以外が含まれていると、やはりテストが通らない
$ redis-cli set 'foo' 'bar' OK $ sbt test (略) [info] test1 should [error] x insert 100 records and delete 1 record [error] 'Some(100)' is not Some with value '99' (RedisConnectionSpec.scala:27) (略) [error] Failed: Total 4, Failed 4, Errors 0, Passed 0 [error] Failed tests: [error] com.github.mogproject.dockersbttest.example.RedisConnectionSpec4 [error] com.github.mogproject.dockersbttest.example.RedisConnectionSpec3 [error] com.github.mogproject.dockersbttest.example.RedisConnectionSpec2 [error] com.github.mogproject.dockersbttest.example.RedisConnectionSpec1 [error] (test:test) sbt.TestsFailedException: Tests unsuccessful [error] Total time: 46 s, completed Jan 11, 2014 1:39:52 AM $ redis-cli del 'foo' (integer) 1
アイデア
当然ながら、不要な待ち時間を減らしたり、モックを作ったりして状態への依存を極力少なくした
正しいテストを書くのが最もよいアプローチだと思う。
しかし現実にはそれが難しかったり、対応に時間がかかってしまう場合もある。
そこで、Docker を使ってテストクラスの単位で独立したコンテナを立ち上げ
その中で個別のテスト(sbt 'test-only クラス名')を実行したいと考えた。
処理の流れは以下のとおり。
- [事前準備] あらかじめ、テストで使う Docker イメージを作成しておく。(以下、ベースイメージとする)
- [前処理] ベースイメージに対して、2種類の変更を行い新規イメージを作成。
そのイメージ(以下、テストイメージとする) に対して新しいタグを付ける。
- 事前コマンド
テストの度に実行するコマンドがあれば、それを登録。
モジュールの最新化 (git pull) などを想定。 - コンパイル
テストを流すたびにコンパイルが走ると遅くなるので、事前に sbt compile test:compile を実行
- 事前コマンド
- [テスト一覧取得] テストイメージを使って、テスト対象クラス名の一覧を取得する。
これは sbt 'show test:defined-test-names' コマンドで確認できた。
- [テスト並列実行] 手順 3 で取得したクラス名に対して、並列で以下の処理を実行。
- テストイメージを使って新規コンテナを起動
- コンテナの中で sbt 'test-only クラス名' を実行 (実行中の標準出力はバッファリング)
- テストが完了したら、コンテナは停止する
- コマンドのリターンコードでテスト成功/失敗を判断
- バッファリングしていた標準出力を全て出力
- コンテナを削除
並列処理の多重度は、Docker ホストマシンの CPU数と同じにする。
- [結果表示] 全てのテストが完了したら、結果一覧を表示。
- [後処理] テストイメージを削除。
動作イメージ
作ってみた
上記の 2. 〜 6. の処理を行うスクリプトを Python で書いてみた。
docker_sbt_test.py というスクリプトに対して、ベースイメージのリポジトリ名(+タグ名)、テストイメージ内部にある sbt プロジェクトのディレクトリ、ベースイメージに対して行う事前実行コマンドを指定して実行する。
docker_sbt_test.py REPOSITORY[:TAG] [options] Options: -h, --help show this help message and exit -d DIR, --dir=DIR path to the sbt project directory in the container -s SETUP, --setup=SETUP commands to be executed in the shell before testing
より詳しい実行例はこちらを参照。
実行結果
手元 (4CPU, 4GB MEM の VirtualBox環境) で実行してみた結果はこちら。
それぞれのテストが並列で実行され、全て成功。sbt test-only は14〜15秒ほどで完了した。
ログ中の starting sbt と finished sbt の間で 30秒ほどかかっているように見えるが、
これは docker コンテナの起動/終了処理と sbt 自体の起動処理にかかる時間が含まれるためと考えられる。
(ちなみに、単体実行時でも sbt の起動には 5秒程度かかっていた。)
VirtualBox 環境でなければもっと速いかもしれない。
- 抜粋
$ ./docker_sbt_test.py local/sbt-test --dir /workspace/docker-sbt-test/example --setup " \ rm -fr /workspace/docker-sbt-test && \ git clone --depth 1 https://github.com/mogproject/docker-sbt-test.git /workspace/docker-sbt-test \ " 2014-01-11 07:24:53,272 [INFO] Creating test image (local/sbt-test:test-1389425093.27) ... (略) 2014-01-11 07:25:13,073 [INFO] Compiling... (略) 2014-01-11 07:25:41,707 [INFO] Getting test class names... 2014-01-11 07:25:48,859 [INFO] Starting sbt 'test-only com.github.mogproject.dockersbttest.example.RedisConnectionSpec1' ... 2014-01-11 07:25:48,859 [INFO] Starting sbt 'test-only com.github.mogproject.dockersbttest.example.RedisConnectionSpec2' ... 2014-01-11 07:25:48,859 [INFO] Starting sbt 'test-only com.github.mogproject.dockersbttest.example.RedisConnectionSpec3' ... 2014-01-11 07:25:48,859 [INFO] Starting sbt 'test-only com.github.mogproject.dockersbttest.example.RedisConnectionSpec4' ... 2014-01-11 07:26:13,257 [INFO] Finished sbt 'test-only com.github.mogproject.dockersbttest.example.RedisConnectionSpec3' ... Starting redis-server: [ OK ] (略) [info] RedisConnectionSpec3 [info] [info] test3 should [info] + insert 100 records and delete 1 record [info] [info] [info] Total for specification RedisConnectionSpec3 [info] Finished in 39 ms [info] 1 example, 0 failure, 0 error [info] [info] Passed: Total 1, Failed 0, Errors 0, Passed 1 [success] Total time: 15 s, completed Jan 11, 2014 2:26:13 AM 2014-01-11 07:26:13,600 [INFO] Finished sbt 'test-only com.github.mogproject.dockersbttest.example.RedisConnectionSpec4' ... (略) [info] Passed: Total 1, Failed 0, Errors 0, Passed 1 [success] Total time: 14 s, completed Jan 11, 2014 2:26:13 AM 2014-01-11 07:26:13,911 [INFO] Finished sbt 'test-only com.github.mogproject.dockersbttest.example.RedisConnectionSpec2' ... (略) [info] Passed: Total 1, Failed 0, Errors 0, Passed 1 [success] Total time: 14 s, completed Jan 11, 2014 2:26:13 AM 2014-01-11 07:26:14,035 [INFO] Finished sbt 'test-only com.github.mogproject.dockersbttest.example.RedisConnectionSpec1' ... (略) [info] Passed: Total 1, Failed 0, Errors 0, Passed 1 [success] Total time: 14 s, completed Jan 11, 2014 2:26:13 AM 2014-01-11 07:26:14,080 [INFO] *** SUMMARY *** 2014-01-11 07:26:14,081 [INFO] com.github.mogproject.dockersbttest.example.RedisConnectionSpec1 -> OK 2014-01-11 07:26:14,081 [INFO] com.github.mogproject.dockersbttest.example.RedisConnectionSpec2 -> OK 2014-01-11 07:26:14,081 [INFO] com.github.mogproject.dockersbttest.example.RedisConnectionSpec3 -> OK 2014-01-11 07:26:14,081 [INFO] com.github.mogproject.dockersbttest.example.RedisConnectionSpec4 -> OK 2014-01-11 07:26:14,081 [INFO] *************** 2014-01-11 07:26:14,081 [INFO] Removing test image (local/sbt-test:test-1389425093.27) ... (略)
- 全文 -> docker-sbt-test-example-result.txt
注意事項
こちらに dstat の結果を載せたが、複数のコンテナを同時に起動すると
思ったよりメモリリソースの消費が激しいようだ。
スワップが起こるような状況であれば、もちろん並列化しない時よりもずっと遅くなる。
まとめ
- sbt test を並列化する最善のアプローチは parallelExecution を有効にしても通るテストを書くこと。
ただ、同時に実行できない多数のテストが存在する場合でも、Docker + 並列化スクリプトで
高速化できる可能性がある。
- テストが外部環境の状態に依存する場合も、Docker を使えばクリーンな環境を保証できる。