Perl Hackers Hub

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

この記事を読むのに必要な時間:およそ 4 分

(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にまとめています。

著者プロフィール

久森達郎(ひさもりたつろう)

株式会社フリークアウトの下回り系エンジニア。最近うっかり蒙古タンメン中本にハマってしまい,血が唐辛子色になりつつある。昨今のCasualムーブメントの一翼を担うMySQL Casualを運営。

YAPC::Asia 2012ではBest Talk Award 3位,YAPC::Asia 2013ではBest Talk Award 2位を受賞。

Twitter:@myfinder
blog:http://myfinder.hatenablog.com/

コメント

コメントの記入