ParaTest と Docker で PHPUnit を並列実行する試み

少し前に下記の ParaTest で PHPUnit を並列実行しようとしている記事を読みまして、

 - [PHPUnit 並列実行に挑戦中 - jwata's diary](http://jwata.hatenablog.com/entry/2014/04/28/020832 "PHPUnit 並列実行に挑戦中 - jwata's diary")

> 次のステップとしては、プロセス毎にDB/KVSのアクセスを分けられるようにしてみよう。

並列に実行するプロセスごとに Docker コンテナで DB を立てて PHPUnit を実行すればいけるかも？ と思って試してみました。

結論から言うと試みとしては成功でも目的としては失敗です。

# アプリ側の準備

ParaTest を composer でインストールします。

```
$ composer require --dev brianium/paratest:dev-master
```

が、このままだと上手く動作しなかったので参考記事にあった下記のブランチを使わせて頂きました。

 - [Comparing brianium:master...Jwata:feature/test_fix · Jwata/paratest](https://github.com/Jwata/paratest/compare/feature/test_fix "Comparing brianium:master...Jwata:feature/test_fix · Jwata/paratest")

```
$ cd vendor/brianium/paratest
$ git remote add jwata https://github.com/Jwata/paratest.git
$ git fetch jwata
$ git checkout -t jwata/feature/test_fix
```

これだけだと composer install で元に戻るので、キチンとやるなら conposer.json に repository を書いた方が良いです。

次に PHPUnit を Docker コンテナで実行するためのスクリプトを作成します。

*tests/phpunit-docker*

```sh
#!/bin/bash

PHPUNIT_DOCKER_IMAGE=sample
PHPUNIT_DOCKER_DIR=$(cd "$(dirname "$0")"/..;pwd)

docker run --rm \
  -v /tmp:/tmp:rw \
  -v "$PHPUNIT_DOCKER_DIR:$PHPUNIT_DOCKER_DIR:ro" \
  -w "$PHPUNIT_DOCKER_DIR" "$PHPUNIT_DOCKER_IMAGE" \
  bash -c '
    service mysqld start
    vendor/bin/phpunit "$@"
  ' -- "$@"
```

`PHPUNIT_DOCKER_IMAGE` はこの後で作成するイメージの名前を指定します。

`PHPUNIT_DOCKER_DIR` はアプリのルートディレクトリを指定します。スクリプトからの相対で指定すると良いでしょう。

<del>`PHPUNIT_DOCKER_CONTAINER` は起動するコンテナの名前です。テストの実行後にコンテナを削除しないとゴミが残るので名前を付けて trap で削除します。一意な名前でなければならないのでイメージの名前にホスト側プロセスの pid 付けた名前にしています。</del> → フォアグラウンドで実行するなら `--rm` オプションでコンテナの停止時に自動的に削除させることができました。

`/tmp` をホストとコンテナで共有していますが、これは ParaTest から実行される PHPUnit が /tmp に junit 形式のファイルを出力し、ParaTest がそれを読んでテスト結果を取得しているからです（phpunit コマンドに --log-junit /tmp/XXX のようにオプションが追加されます）。

# ベースイメージの作成

ベースとなるイメージを作成します。Dockerfile の内容は次のような感じです。mysql や php をインストールして空のデータベースを作成します。

*Dockerfile*

```
FROM centos:centos6
MAINTAINER ore

RUN yum -y install epel-release rootfiles
RUN rm -f /etc/localtime && cp -p /usr/share/zoneinfo/Japan /etc/localtime

RUN yum -y install http://dev.mysql.com/get/mysql-community-release-el6-5.noarch.rpm
RUN yum -y install mysql-community-server

RUN yum -y install http://rpms.famillecollet.com/enterprise/remi-release-6.rpm
RUN yum -y --enablerepo=remi-php55 install php php-cli php-opcache \
  php-process php-mysqlnd php-pdo php-mbstring php-xml \
  php-pecl-xdebug php-pecl-zip  php-pecl-yaml php-pecl-jsonc

RUN echo -e "[mysqld]\ntmpdir = /var/tmp\n" > /etc/my.cnf
RUN touch /etc/sysconfig/network
RUN mysql_install_db --user=mysql

RUN service mysqld start && mysql -ve " \
  CREATE DATABASE sample_test DEFAULT CHARACTER SET utf8 DEFAULT COLLATE utf8_bin; \
  GRANT ALL ON sample_test.* TO sample@'localhost'; \
  GRANT ALL ON sample_test.* TO sample@'%'; \
" ; service mysqld stop
```

最初の方で rootfiles を yum で入れていますが、多分無くてもいいです。なんで入れたのだったかな・・・忘れました。

後はタイムゾーンを設定したり mysql を入れたり php を入れたりしています。

前述の通り /tmp をホストとコンテナで共有するので、MySQL の設定で tmpdir を別の場所に変更しています。/tmp のままだと複数のコンテナの MySQL が同じ名前の一時ファイルを作ろうとしてエラーが頻発しました。

```
PDOException: SQLSTATE[HY000]: General error: 1 Can't create/write to file '/tmp/#sql_100_0.MYI' (Errcode: 17 - File exists)
```

`service mysqld start` で mysqld を起動してます。これは CentOS6 の mysqld の起動が sysvinit なスクリプトだから可能なのだと思います。systemd とかならこれだけでは無理だろうので、mysqld や mysqld_safe を直接バックグラウンド実行させる必要があると思います。mysqld の起動を待ったり終了を待ったりするのがとても面倒だと思いますが。

イメージをビルドします。

```
$ docker build -t sample:base .
```

# テストを実行するイメージの作成

ベースイメージからコンテナを起動してデータベースのマイグレーションを行います。

`/path/to/sample` はアプリのルートディレクトリです。`./build.sh` はいわゆる秘伝のタレです。

```
$ docker run --name=tmp -v /path/to/sample:/path/to/sample:ro sample:base /bin/bash -c '
  service mysqld start
  cd /path/to/sample
  ./build.sh migrate --test
  service mysqld stop
'
```

コンテナからイメージを作成します。

```
$ docker commit tmp sample
```

イメージできたらコンテナはもう必要ないので削除します。

```
$ docker rm tmp
```

# Docker コンテナでテストを実行

この時点で `tests/phpunit-docker` を実行すれば Docker コンテナでテストが実行されるはずです。

```
$ cd /path/to/sample
$ tests/phpunit-docker
```

次のような結果になりました。

```
Starting mysqld:  [  OK  ]
PHPUnit 3.7.37 by Sebastian Bergmann.

Configuration read from /path/to/sample/phpunit.xml.dist

...............................................................  63 / 720 (  8%)
............................................................... 126 / 720 ( 17%)
............................................................... 189 / 720 ( 26%)
............................................................... 252 / 720 ( 35%)
............................................................... 315 / 720 ( 43%)
............................................................... 378 / 720 ( 52%)
............................................................... 441 / 720 ( 61%)
............................................................... 504 / 720 ( 70%)
............................................................... 567 / 720 ( 78%)
............................................................... 630 / 720 ( 87%)
............................................................... 693 / 720 ( 96%)
.........................

Time: 1.57 minutes, Memory: 111.50Mb

OK (718 tests, 2726 assertions)
```

１行目に表示されている通り、テストの開始時に MySQL が開始しています。

# ParaTest でテストを実行

ParaTest でテストを実行します。`-p` で並列数を指定し、`--phpunit` で `tests/phpunit-docker` を指定します。

```
$ cd /path/to/sample
$ vendor/bin/paratest -p 4 --phpunit=tests/phpunit-docker
```

次のような結果になりました。テスト数の分母がオカシイですが見なかったことにしておきます。

```
Running phpunit in 4 processes with tests/phpunit-docker

Configuration read from /path/to/sample/phpunit.xml.dist

...............................................................  63 / 644 (  9%)
............................................................... 126 / 644 ( 19%)
............................................................... 189 / 644 ( 29%)
............................................................... 252 / 644 ( 39%)
............................................................... 315 / 644 ( 48%)
............................................................... 378 / 644 ( 58%)
............................................................... 441 / 644 ( 68%)
............................................................... 504 / 644 ( 78%)
..........................................................E.... 567 / 644 ( 88%)
..F............................................................ 630 / 644 ( 97%)
...........................................................E... 693 / 644 (107%)
......FF.................

Time: 12.22 minutes, Memory: 36.50Mb

There were 2 errors:

(中略)

FAILURES!
Tests: 718, Assertions: 2717, Failures: 3, Errors: 2.
```

幾つかのテストがコケました。たぶん元々不安定なテストなのだと思います。恐らくあるテストケースが別のテストケースの実行を前提になっているのでしょう。要修正ですが、当初の目的である MySQL をコンテナの中で起動しつつのテストは成功しました。

ただし、ご覧の通り１プロセスで実行するときと比べて数倍の時間がかかりました。

# 結果

この方法で並列にテストを実行すれば MySQL はそれぞれ別の Docker コンテナになるので影響しあうことはありません。

「テストを実行するイメージの作成」と「ParaTest でテストを実行」を自動化すれば Jenkins でテストをまわすこともできるでしょう。

が、１プロセスで実行するのよりもはるかに時間がかかってしまいました。

## Docker コンテナの起動と削除のオーバヘッド

ParaTest で並列にテストを実行すると、phpunit コマンドがテストファイル（`*Test.php`）ごとに実行されるようです。なので、この仕組だとテストのファイルの数だけ Docker コンテナが起動→削除を繰り返します。/var/log/docker を `tail -f` すると起動→削除が何度も繰り返されているのがよくわかります。

テストファイルは 200 ぐらいあったので、さすがに 200 回も起動→削除を繰り返せば時間もかかるでしょう・・・MySQL の起動にも数秒から十数秒ぐらいの時間はかかりますし。

## ボトルネックはディスクだった

そもそもテストが遅い原因はディスクの I/O でした。なので並列に実行したところであまり早くなりません。

## サーバの性能がショボすぎた

メモリ 1G で 1 コアの仮想環境で試しました。並列数を 20 とかにするとスワップしまくってテストが終わる気配がありませんでした。

# まとめ

当初の目的である Docker コンテナの中で MySQL を起動して PHPUnit を並列に実行することはできましたが、全然早くはなりませんでした。

次のような点を改善すればもう少しマシになるかもしれません。

 - ParaTest を弄って Docker コンテナが並列数だけしか起動しないようにする
    - 最初に並列数の数だけコンテナを起動して nsenter でコンテナに入って phpunit を実行するとか
 - RAID0 とかでディスクの I/O を良くする
    - あるいは SSD とか AWS の Instance Store とか
 - もう少しマシな性能のサーバでやる

MySQL のデータディレクトリを tmpfs にするのが一番手っ取り早いですがね。