Perl Hackers Hub

第23回Perlアプリケーションのテストと高速なCI環境構築術(3)

(1)こちら⁠2)こちらから。

CIを高速に回す手法

今回は、CIをより高速に回す手法をまとめます。

なぜCIに速度が求められるのか

作り始めのプロダクトは小さく、テストはすぐに終わるかもしれませんが、テストコードの量や種類が多くなっていくにつれ、全体の実行時間は延びていきます。また、開発メンバーが増えていくと、さまざまな開発が並行で走ることになり、CIサーバが実行するテスト量が急激に増えます。1回のフルテスト実行が仮に30分かかるとすると、同時に開発しているプロジェクトが3件あると、最長1時間30分ほどCIの実行を待たなければ結果がわからないといったことも起こります。

個別のテストを高速化する手法は先述した『Perl徹底攻略』の記事でも取り上げられています。個別のテスト実行の高速化も重要ですが、それだけでは限界があります。ある程度規模が大きくなる場合には、スケールするしくみを用意する必要があります。

CIの高速化アプローチ

(2)ではUkigumo::Server+Ukigumo::Clientによる構成を紹介しましたが、筆者の環境ではクライアント部分を独自実装してテストを複数のサーバで分散実行できるような枠組みを実装しています。

マスタノードとなるサーバが定期的にフルテスト未実行のブランチを探し、未実行のブランチに存在する実行対象のテストを分割し、クラスタノードに実行させる構成です図2⁠。テストをクラスタノード数分に分割して実行するというシンプルな作りなので、既存のテストコードを一切変更する必要がなく、サーバを足すだけで速度を上げることができます。

図2 クラスタ構成イメージ
図2 クラスタ構成イメージ

筆者の環境では、30分かかっていたフルテストを10台ほどのサーバに分散実行することで、1/10程度の時間に短縮しました。

マスタノード側の実装

マスタノードの仕事は次のとおりです。

  • ① テスト対象のファイルをピックアップしてシャッフルして、実行対象テストをクラスタノード数で等分する
  • ② クラスタノードに実行対象テストの実行を依頼し、結果を受け取る
  • ③ クラスタノードからの実行結果をパースする
  • ④ パースした結果をUkigumo::ServerへPOSTする

①でシャッフルを行うのは次のためです。

  • 並び順によって実行時間が遅いものが集中しないようばらけさせる
  • 実行順序に依存のあるテストを書いてしまったまま気づかないことを防止する

① テスト対象のピックアップ/シャッフル/等分

まずテスト対象のファイルをピックアップしてシャッフルし等分する部分ですが、これはFile::Find、List::Util、List::MoreUtilsを用いれば簡単です。

use File::Find;
use List::Util qw/shuffle/;
use List::MoreUtils qw/part/;

my @tests;
File::Find::find(
    sub {
        return unless /\.t$/ and -f $_;
        my $full_path = $File::Find::name;
        push(@tests, $full_path)
    }, "/path/to/repo/t"
);

@tests = shuffle @tests;
# ['test1.t', 'test6.t', 'test2.t', 'test5.t', 'test3.t', 'test4.t']

my @test_clusters = ( 'node1', 'node2', 'node3' );
my $cluster_num = @test_clusters;
my $i = 0;
@tests = part { $i++ % $cluster_num } @tests;
# ['test1.t', 'test6.t'], ['test3.t', 'test5.t'], ['test2.t', 'test4.t']

my $node_task = +{};
for my $node (@test_clusters) {
    $node_task->{$node} = shift @tests;
}

この例では、対象ディレクトリから.tで終わる名前のファイルを取得し、配列に格納しています。ディレクトリを走査する前に、リポジトリの状態を最新化しておくことを忘れないでください。

取得した実行対象テストはList::MoreUtils::partでサーバ台数分に配列を分割し、サーバ名をキーにしたハッシュに格納しています。

② テストの実行依頼と、実行結果の受け取り

次に各クラスタノードへの実行依頼と集約ですが、Parallel::ForkManagerを用いてクラスタノード数分のプロセスをforkし、処理を分散します。マスタノードからクラスタノードへsshできる状態になっていることを前提とします。

use Parallel::ForkManager;
use Net::OpenSSH;

my $output = '';
my $pm = Parallel::ForkManager->new($cluster_num); # (1)
my %nodes;

for my $cluster_nodename (@{ config->{test_clusters} }) { # (2)
    if (my $pid = $pm->start) {
        $nodes{$pid} = $cluster_nodename;
        next;
    }
my $task_list =
    join '||', @{ $node_task->{$cluster_nodename} };
my $ssh = Net::OpenSSH->new($cluster_nodename);
my $sha1 = $repo->sha1($branch);

$ssh->system("
    cd /path/to/repo;
    git fetch;
    git reset --hard HEAD;
    git clean -fd;
    git checkout $sha1"); # (3)

my ($ret, $err) = $ssh->capture2(
    "perl /path/to/test_launcher.pl --tests='$task_list'
"); # (4)
$ret .= "\n[STDERR]\n$err" if $err;

$pm->finish(
    0,
    {
        pid => $$,
        result => $ret,
        branch => $branch,
        sha1 => $sha1
    }); # (5)
}
$pm->wait_all_children;

$pm->run_on_finish(sub { # (6)
    my (
        $pid, $exit_code, $ident,
        $exit_signal, $core_dump, $data) = @_;
    my ($branch, $sha1, $test_result) =
        ($data->{branch}, $data->{sha1}, $data->{result});
    my $nodename = $nodes{$pid};
    if ($exit_code != 0) { # Emulate output of prove.
        ($test_result //= '') .=
        "\n$0 (Wstat: 0 Tests: 0 Failed: 1)\n
        got exit code=$exit_code\n";
    }
    my $result =
        sprintf "run on %s \n%s=-=-=-=-=-=\n",
        $nodename, $test_result;
    $output = $output . $result;
});

まず(1)⁠2)で、クラスタ数分のプロセスをforkして処理を並列実行させます。実際の処理内容ですが、すでに①でクラスタノードと割り当てる実行対象テストが用意できているので、それぞれにsshで実行依頼を投げます。

(3)で、クラスタノードに指定リビジョンをチェックアウトさせます。

(4)で起動しているtest_launcher.plは、クラスタノード側で実際にテストを実行するコマンドです。これについては「クラスタノード側の実装」で後述します。

(5)の終了時に値を渡していますが、これは(6)run_on_finishに渡る$dataにあたります。Parallel::ForkManager 0.7.6以降であれば、このrun_on_finishでforkした子プロセスからデータを受信できるので、このメソッドを用いて子プロセスの実行結果を取得します。

最終的に、各子プロセスからクラスタノードに依頼した処理結果を受け取り$outputにまとめています。

③ 実行結果のパース

ここまでで、各クラスタノードに対してテスト実行を依頼し、結果を受け取ることができました。しかし個別に実行した結果を連結しただけなので、このままでは最終的な実行結果がわかりません。これを次のような処理でパースし、実行に失敗したテストの存在を確認します。

my @all;
while ($output =? m{^(\S+\.t)\s+?\.}gsm) {
    push @all, $1;
}

my @fails;
while ($output =?
  m{^(\S+)\s*?\(Wstat:(.*?)Tests:(.*?)Failed:(.*?)\)$}gsm) {
    my ($name, $wstat, $tests, $failed) =
      ($1, int $2, int $3, int $4);
    push @fails, $1 unless $wstat ==
      0 && $tests > 0 && $failed <= 0;
}

@allは実行されたすべてのテストケースをカウントし、@failsは失敗したテストケースをカウントします。

④ パース結果のUkigumo::ServerへのPOST

最後に、Ukigumo::Serverへ結果をPOSTします。先ほどまでで全実行結果とそのうちのfail数がカウントされているので、次のような形でPOSTします。

my $ua = LWP::UserAgent->new;
my $res = $ua->post(
    "http://ukigumo.example.com/api/v1/report/add", [
    status => @$fails ? 2 : 1,
           # status code: SUCCESS:1, FAIL:2, N/A:3
    project => 'My Project', # project name
    branch => 'my-branch', # branch name
    revision => 3, # revision
    repo => "-",
    body => <<__BODY__,
Failed ${\ scalar @$fails} / ${\ scalar @$all}.
==========
$body
__BODY__
]);

POSTの際に渡しているブランチ名やプロジェクト名は適宜対象リポジトリから取得しましょう。

そのほかIRCやメールなどで通知を行いたい場合は、このタイミングで行えばよいでしょう。

クラスタノード側の実装

クラスタノード側は先に紹介したTAP::Harnessを用いて、渡されたテストを実行し結果を返す役割を担います。②でssh経由でtest_launcher.plを呼び出していましたが、そのスクリプトは次のような処理を実装しています。

use Smart::Options;
use TAP::Harness;

my $argv = Smart::Options->new()->parse;
my $harness = TAP::Harness->new({
    exec => [
        'perl',
        '-Ilib',
        '-It/lib',
    ],
});

my @tests = split /\|\|/, $argv->{tests};
$harness->runtests(@tests);

$argv->{tests}にはマスタノード側で||をデリミタとして連結した文字列を渡しているので、単純に分割したうえで$harness->runtestsに渡すだけでテストを実行してくれます。結果出力はマスタノード側で受信し、後続の処理が行われる流れになります。

クラスタノード側で何らかの問題が起こった際のフォローは、サーバ側でエラーハンドリングを別途書いて、再実行させるなどのフォローを行えばよいでしょう。

さらなる高速化を目指して

ここまでの施策で、サーバを足すだけで高速化できる枠組みを作ることができました。しかしながら本稿で紹介した手法ではテストが1つのプロセスで順番に実行されているため、マルチコアが主流な現在においてはCPUリソースを有効に使えているとは言えません。筆者は現在、プロダクトの成長やテストの増加を支えるべく、さらに効率の良い手法を取り入れることも検討しています。

仮想サーバやコンテナを用いた高速化

マルチコアを活かす手法として、クラスタリングしたサーバ内でさらに仮想サーバを立ち上げるといったことが考えられます。また、LXCなどのコンテナを活用することも考えられます。これらを活用し、クラスタノード内部で複数のテスト実行インスタンスを立ち上げることで、サーバリソースをより効率良く利用してテストを高速化できるでしょう。

単体サーバでの実行高速化

単体サーバでの並列実行の方法として、proveコマンドには-jという並列実行のオプション(または環境変数$ENV{HARNESS_OPTIONS}がありますが、常時起動しているデータベースにアクセスできることを期待しているテストが存在する場合には、テストデータやテスト処理での操作が混じってしまい、問題が発生することがあります。

そのようなケースでは、Test::Synchronizedを用いて並列実行を抑制できます。

package My::Test::Module;
use Test::Synchronized;
# ...

ただし、排他制御していてもテストデータが残ってしまうようなテストがある場合は問題が発生する可能性がありますので、データのクリーンアップに注意が必要です。

Test::mysqldやTest::Memcachedなどを用いてテストごとに個別のサービスを立ち上げると、競合が起こらず分散実行が容易になるためスケールしやすくなります。また、常時起動しているサーバに依存しないため、テストクラスタノード管理の手間が減るという副次的な効果も得られます。ですので、できるだけ初めから個別のサービスを立ち上げるテストモジュールを活用することや、既存のテストもそちらの枠組みを使うものに変更していくほうが、スケールさせる場合には問題を単純化できるのでよいでしょう。

まとめ

本稿では、Perlアプリケーションの基本的なテストおよびテストモジュールの活用から、CIの高速な実行環境構築についてまで解説しました。高速なCI環境も、(ひも)解いていけば各種Perlモジュールの組み合わせで実現でき、けっして複雑なものではありません。古典的でも地道に積み上げていくことで、大きな問題も分解して処理できます。

プロダクトは大きくなるにつれ関わる人数や実行されるテスト量が増え、リリースサイクルが遅くなっていくのが常だと思います。そんなときは今回紹介したような手法で高速なテストサイクルを構築できれば、大規模化しても速度や品質を落とさず開発ができるようになるでしょう。本稿が読者のみなさんのPerl開発での一助となれば幸いです。

さて、次回の執筆者は長野雅広さんで、テーマは「PSGI/Plackサーバ実践入門」です。

おすすめ記事

記事・ニュース一覧