スローテストと戦う

ここ数年 jenkins で rails 案件のテストをまわしながら改善していった日々をどこかでアウトプットしておきたかったので書いておきます。時期的に Advent Calendar とかに参加できればよかったんですが、jenkinsは今年は開催を見送りとの事なので普通の記事で。(というか、最終的にはjenkins特有のテクニックなどはほとんどないです)
本記事では対策の実現方法について具体的に書くのは面倒なので今回は割愛しました。また、具体的な改善時間については記憶に基づくものなので不正確なのでご了承くださいまし。

私たちのプロジェクトの前提と課題

  • ruby1.8系/rails2.3系/MySQL案件
  • railsプロジェクトは1つではなくサブシステムごとに複数ある
  • 複数のrailsプロジェクトは一部コードを共有しており、そこに手を入れると全プロジェクトの再試が必要
  • テストは年々肥大化し、全体のテストをローカルで実施すると1日仕事
  • ほぼすべてのテストにDBが必要。DDLを発行するようなテストも多い

対策その1「DBはインメモリで」
 テストの実施時に時間がかかっていたボトルネックをまず最初に計測すると、データベースがボトルネックになっていることが分かりました。対策として一通りMySQLのパラメーターをテスト向けにチューニングしたりするわけですが、やはり最後はディスクのIOが問題になりました。
 そこでサクッとMySQLのデータディレクトリをtmpfs上に配備しました。これだけでテストの実行時間が当時2時間程度だったのが30分程度になりました。また、MySQLとの接続がtcp接続だったのも地味に遅かったので、Unixドメインソケットでの接続に切り替えました。今後のジョブノードの並列化も見据え、各テスト用のDBをビルドノード上にMySQLインスタンスを構築する方針とも相性がよく、なかなかの改善だった気がします。

対策その2「テストをフェイズ・システムごとに分割する」
 CIにおけるスローテスト改善のための王道です。
 テスト用のジョブをシステム・フェイズごとに分割し、ビルドパイプラインを構築するスタイルに切り替えました。テストを実施するときに「直近の失敗したテストを再試し、すべて通ったら全テストを再試する」という仕組みもいれました。これにより確かに個別のフィードバックは早くなりました。しかし、私たちはIRCを流れる「unitテスト成功→functionalテスト失敗→unitテスト成功」という一連の流れを見たときに、functionalテストがいまだ失敗している事実を見逃してしまうという問題に直面しました。最後に成功という文字列を見たら、問題がすべて解決されたと勘違いしてしまうということですね。本来的には各開発者はIRCに流れるビルドメッセージをたよりにテストの成否を判断するのではなくダッシュボードを見るべきだというのは分かるのですが、現実問題としてダッシュボードを見ない人に対してべき論を語っても問題は解決しません。
 結局私たちはテストをフェイズごとに分ける事をやめました。私たちにとってコミットXがunitテストを壊したのか、functionalテストを壊したのかといった「どこが」壊れたかという情報はあまり重要ではなかったのです。
 壊れているのか壊れていないのか、それが一番重要だったのです。

  • ポイント
    • テストをフェイズ・システム毎に分割すると、システムの状態を判断するのが難しくなる

対策その3「1ノードでの並列化 その1」
 自動テストは本来他のテストケースと依存関係が無いので、並列実行することが出来ます。気をつけないといけないポイントはテストケースから利用するミドルウェアがきちんと分離されている必要があります。
 railsの場合はparallel_testsがあるのでそれを使えばよいでしょう。対策その1を実施した時点で、自動テストはIOバウンドからCPUバウンドな処理になっていたのでCPUの数だけ並列化させることによって劇的にテスト時間の改善ができました。ちなみに、私たちのプロジェクトではCPUの数よりもちょっと多めの並列数の時にもっともテスト時間がみじかくなりました。

  • ポイント
    • CPUバウンドなテストは並列化でテスト時間を改善できる
    • parallel_testsを使えば楽に並列化できる。

対策その4「1ノードでの並列化 その2」
 parallel_testsはテスト起動時に全テストケースを洗い出し、各プロセスにテストケースを割り当ててから実行します。
 つまりテストA,B,C,Dを2プロセスで処理するとき、プロセス1にA,Bを割り当て、プロセス2にC,Dを割り当ててテストを実行します。この時、テストA,B,C,Dがすべて5分で終わるのであればテスト時間は20分→10分と、およそ半分の時間で終わるのですが、A,Bのテストがそれぞれ9分、C,Dのテストが1分で終わるばあいテスト時間は20分→18分になるだけであまりうれしくありません。
 これを解決するには、テストケースを各プロセスに事前に割り当てるのではなくて手の開いたワーカープロセスが都度都度プロセスをブローカーから受け取るようにする必要があります。私たちはparallel_testsを使うのをやめ、xargsと自作のshell_stack*1というファイルベースのシンプルなスタックの仕組みを使ってこれを解決しました。これによって、各プロセス毎のCPU負荷の分散が平坦化されます。
 よりよく平坦化するにはこの対応では不十分で、過去のテストの実行結果の実績からブローカーが渡すテストケースをテスト実行時間の降順で並び替えるという仕組みも入れる必要があります。

  • ポイント
    • parallel_testsの並列化では個々のテストケースのテスト時間に大きな差がある場合に非効率になる場合がある
    • xargsなどのカジュアルな並列処理の仕組みでparallel_testsの抱える問題は改善できる

対策その5「元気玉大作戦(複数ノードでの並列化)」
 書くのが面倒くさくなって来たので駆け足で。1ノードで並列化できたので、今度は複数ノードでビルドを実行します。
 ビルドマシン単体の用途でマシンを多量に調達するのが難しかったので、開発者の開発環境にjenkinsのビルドノードを別VMで同居させて分散させることにしました。開発者がそれぞれの開発環境で全テストを実施するのはすでに非現実的になっていたので、テストはCIにすべて任せるようになっていました。開発環境のマシンはテストを実行することがほとんどなくなりその分リソースに余剰があったので、余剰リソースをjenkinsに割り当てました。

  • ポイント
    • 開発者のマシンリソースに余剰があるなら、それもjenkinsさんにわけてあげよう
    • ミスターサタンがいないと元気わけてもらえないかも…

対策その6「そしてクラウドへ…」
 諸々の事情で開発環境にも余剰リソースがなくなって来たので、ビルドノードにAWSのEC2を使おうというのが今の私たちの状況です。
 cc2.8xlargeという強力なインスタンスを借りて「1ノードで並列化 その2」を適用して頑張っています。いまプロジェクトは16コアで18並列でテストを実行し、テストを35分で全テストを回しきります。直列実行では8時間程度。何も対策していないマシンで普通に実行したら二日ぐらいはかかると思います。
 「継続的インテグレーション入門」ではテストのフィードバックはおよそ1時間以内という指標が示されています。またテストが1時間を越えるようになればEC2上で「元気玉大作戦(複数ノードでの並列化)」を行えるよう稟議書を書く必要に迫られるでしょう。

※EC2上のインスタンスはネットワーク的にAmazonVPCに接続されており、ビルドノードは外部から直接アクセスできないようにしています。jenkinsをEC2上を使ってるという資料を見たとき、それってセキュリティ的に大丈夫なの?どうやってんの?と私も疑問に思った事があるので書いておきます。

  • ポイント
    • 最後は金の力がすべてです

参考