継続的統合システム
Malini Das は、コーディング速度の向上 (もちろんコードのセキュリティを確保しながら) に尽力し、常にクロスプログラミング ソリューションを探しているソフトウェア エンジニアです。彼女は以前は Mozilla でツール エンジニアとして働いていましたが、現在は Twitch で働いています。マリニの Twitter またはブログをフォローすると、マリニの最新ニュースを入手できます。
継続的インテグレーション システムとは
ソフトウェア開発のプロセスでは、すべての新機能が安定して実装され、すべてのバグが期待どおりに修正されることを保証する方法が必要です。一般に、この方法はコードをテストすることです。ほとんどの場合、開発者は、機能の実装が完全で安定していることを確認するために、開発環境で直接テストしますが、考えられるすべての動作環境でテストする時間がある人はほとんどいません。さらに、開発が継続するにつれて、必要なテストの数は増加し続け、開発環境でコードを完全にテストすることの実現可能性はますます低くなります。継続的インテグレーション システムの出現は、まさに開発におけるこのジレンマを解決することです。
継続的インテグレーション (CI) システムは、新しいコードをテストするために設計されたシステムです。新しいコードが提出されるとき、継続的インテグレーション システムの役割は、新しいコードが前のテスト サンプルの失敗を引き起こさないようにすることです。このような機能を実現するには、継続的インテグレーション システムが新しく変更されたコードを取得し、テストを自動的に完了し、テスト レポートを生成する必要があります。同時に、継続的インテグレーション システムは良好な安定性を確保する必要もあります。つまり、システムの一部に障害が発生したりクラッシュしたりした場合でも、システム全体が最後に中断されたところから動作を再開できる必要があります。
このシステムには、新しいバージョンを送信する時間がテストの実行時間より短い場合でも、適切な時間内にテスト結果が得られるようにするために、負荷を分散する機能も必要です。これは、テスト ケースを複数のスレッドに分散し、並列実行することで実現できます。このプロジェクトでは、小規模でスケーラブルなミニマリストの分散継続的統合システムが導入されます。
注意事項と関連する指示
このプロジェクトでは、テスト用のコード ホスティング システムとして Git が使用されます。標準のコード管理手順のみを呼び出しますので、Git の操作には詳しくなくても、svn や Mercurial などの他のバージョン管理システム (VCS) の使用には慣れている場合は、引き続き以下の手順に従うことができます。
コードの長さの制限と単体テストの要件のため、テスト サンプルの検索メカニズムを簡素化しました。という名前のフォルダー内のテスト サンプルのみを実行します。tests
一般に、継続的統合システムは、リモート コード ホスティング ライブラリの変更を監視する必要があります。ただし、便宜上、この例では、リモート ファイルではなくローカル コード ライブラリ ファイルをリッスンすることを選択します。
継続的インテグレーション システムは、固定スケジュールに従って実行する必要はありません。もちろん、毎回または複数回の送信ごとに自動的に実行するように設定することもできます。この例では、CI を定期的に実行するように設定します。つまり、CI システムを 5 秒ごとに実行するように設定した場合、システムは 5 秒ごとに 5 秒以内の最新の送信をテストします。この 5 秒以内に送信が何回発生しても、システムは最後の送信の結果を 1 回だけテストします。
CI システムは、コード ベースの変更をリッスンするように設計されています。実際に使用される CI システムは、コード ベースからの通知を通じてコミット情報を取得できます。たとえば、Github には特別な「コミットフック」が用意されており、このモデルでは、Github に設定された通知 URL に対応するサーバーによって CI システムが起動され、それに応じて応答します。しかし、このモデルはローカルの実験環境では複雑すぎるため、オブザーバー モデルを使用しました。このモデルでは、システムはコード管理ライブラリからの通知を待つのではなく、コードの変更を積極的に検出します。
CI システムには、テストをトリガーした人がテスト結果を CI 結果コンポーネントに送信し、他のプロジェクトの参加者が対応する結果を直接表示できるように、レポート フォーム (Web ページなど) も必要です。
私たちのプロジェクトでは、多くの CI システム フレームワークのうちの 1 つだけが説明されていることに注意してください。このフレームワーク内で、プロジェクトを 3 つの主要なコンポーネントに単純化します。
導入
最も基本的な継続的インテグレーション システムは、リスナー、テスト ケース スケジューラ、テスト ランナーの 3 つの部分に分かれています。まず、リスナーはコード ベースを監視し、コミットが発生するとスケジューラに通知します。その後、サンプル スケジューラは、送信されたバージョン番号に対応するテストを完了するテスト ランナーを割り当てます。
これら 3 つの部分を組み合わせる方法はたくさんあります。これらすべてを 1 台のコンピューター上の同じスレッドで実行できます。しかし、この方法では、CI システムは大きな負荷を処理する能力が不足し、多くの送信によって大量のテスト コンテンツがもたらされると、このソリューションでは簡単に作業のバックログが発生する可能性があります。同時に、このソリューションのフォールト トレランス率は非常に低く、システムを実行しているコンピュータに障害が発生するか電源が失われると、中断された作業を完了するバックアップ システムがありません。私たちは、CI システムがニーズに応じて複数のテスト タスクをできるだけ同時に完了し、マシンが予期せずシャットダウンした場合の適切なバックアップ運用計画を立てることを望んでいます。
このプロジェクトでは、耐荷重性と耐障害性の高い CI システムを構築するために、上記の各コンポーネントを独立したプロセスとして実行します。各プロセスは互いに完全に独立しており、各スレッドの複数のインスタンスを同時に実行できます。このソリューションは、多くのテスト作業を同時に実行する必要がある場合に非常に便利です。テスト ランナーの複数のインスタンスを異なるスレッドで同時に実行でき、各テスト ランナーは独立して動作するため、テスト キューのバックログの問題を効果的に解決できます。
このプロジェクトでは、これらのコンポーネントは別々のスレッドで独立して実行されますが、スレッドはソケットを介して通信できるため、これらのプロセスをインターネット上の異なるホストで別々に実行できます。各プロセスにアドレス/ポートを割り当て、割り当てられたアドレスにメッセージを送信することで各プロセスが相互に通信できるようにします。
分散アーキテクチャにより、ハードウェア エラーが発生したときに即座に対処できます。リスナー、テスト ケース スケジューラ、およびテスト ランナーを異なるマシン上で実行でき、ネットワークを通じて相互に通信できます。それらのいずれかで問題が発生した場合、問題のあるプロセスを実行するために新しいホストをオンラインにするよう手配できます。このようにして、システムのフォールト トレランス率は非常に高くなります。
このプロジェクトには、自動回復コードはありません。自動リカバリの機能は、使用している分散システムのアーキテクチャによって異なります。実際の使用では、CI システムは通常、障害情報の転送をサポートする分散システムで実行されます (たとえば、分散システム内のマシンに障害が発生した場合、設定したバックアップ マシンが中断された作業を自動的に引き継ぎます)。
システムのテストを容易にするために、このプロジェクトでは、いくつかのプロセスをローカルで手動でトリガーし、分散環境をシミュレートします。
プロジェクトファイルの構造
プロジェクト内の各コンポーネントの Python ファイル構造は次のとおりです:listener\newline( repo_observer.py
)、テスト サンプル スケジューラ( dispatcher.py
)、テスト ランナー\newline( test_runner.py
)。上記の各スレッドはソケットを介して通信し、通信機能を実装するために使用されるコードを helpers.py に配置します。これにより、各コンポーネントでこのコードを繰り返し記述することなく、各コンポーネントがこのファイルから関連する関数を直接インポートできるようになります。
さらに、bash スクリプトも使用しました。これらのスクリプトは、いくつかの単純な bash および git 操作を実行するために使用されます。Python によって提供されるシステム レベルのモジュール (os やサブプロセスなど) を使用するよりも、bash スクリプトを直接使用する方が便利です。
最後に、tests
CI システムの実行に必要なテスト サンプルを保存するディレクトリも作成しました。このディレクトリにはテスト用の 2 つのサンプルが含まれており、1 つはサンプルが合格したときの状況をシミュレートし、もう 1 つはサンプルが失敗したときの状況をシミュレートします。
デフォルト設定
私たちの CI システムは分散操作用に設計されていますが、CI システムの動作原理を理解する過程でネットワーク要因の影響を受けないようにするために、すべてのコンポーネントを同じコンピューター上で実行します。もちろん、分散オペレーティング環境を試したい場合は、各コンポーネントを異なるホストで実行することもできます。
継続的インテグレーション システムはコードの変更を監視することによってテストをトリガーするため、開始する前に監視用のコード ベースをセットアップする必要があります。
テスト用にこのプロジェクトを呼び出しましょう test_repo
。
$ mkdir test_repo $ cd test_repo $ git init
リスナー モジュールはコミットをチェックすることでコードの更新を監視するため、リスナー モジュールをテストするには少なくとも 1 つのコミットが必要です。
tests
フォルダーをコピーしてtest_repo
送信します。
$ cp -r /this/directory/tests /path/to/test_repo/ $ cd /path/to/test\_repo $ git add testing/ $ git commit -m ”テストを追加”
これで、テスト リポジトリのマスター ブランチでテストするためのコミットの準備が整いました。
リスナー コンポーネントには、新しいコミットを検出するためにコードの別のコピーが必要です。master ブランチからコードのコピーを作成し、次の名前を付けますtest_repo_clone_obs
。
$ git clone /path/to/test_repo test_repo_clone_obs
テスト ランナーには、コミットが発生したときに関連するテストを実行できるように、コードのコピーも必要です。また、master ブランチからコードのコピーを作成し、次の名前を付けますtest_repo_clone_runner
。
$ git clone /path/to/test_repo test_repo_clone_runner
コンポーネント
リスナー (repo_observer.py)
リスナーの仕事は、コード ベースの変更をリッスンし、変更が検出されたときにテスト ケース ディスパッチャに通知することです。CI システムがさまざまなバージョン管理システムと互換性があることを確認するために (すべての VCS に通知システムが組み込まれているわけではありません)、コード ベースに新しい送信があるかどうかを待機するのではなく、定期的にチェックするように CI システムを設定しました。 VCS によるコード ベースのチェック。送信時に通知を送信します。
リスナーは定期的にコード ベースをポーリングし、新しい送信があると、実行する必要があるコードのバージョン ID をディストリビュータにプッシュします。リスナーのポーリング プロセスは次のとおりです: まず、リスナーの記憶領域にある現在のコミット バージョンを取得します。次に、ローカル ライブラリをこのバージョンに更新します。最後に、このバージョンをリモート ライブラリの最新のコミット ID と比較します。このように、リスナー内の現在のローカル バージョンが最新のリモート バージョンと一致しない場合、新しい送信が発生したと判断されます。私たちの CI システムでは、リスナーは最新のコミットのみをディスパッチャーにプッシュします。これは、1 つのポーリング サイクル内で 2 つのコミットが発生した場合、リスナーは最新のもののテストのみを実行することを意味します。一般に、CI システムは、最後の更新以降、コミットごとに対応するテストを実行します。ただし、今回構築した CI システムでは、簡略化のため、最後の送信分のテストのみを実行するソリューションを採用しました。
リスナーは、どのコード ベースをリッスンしているかを知っている必要があります。/path/to/test_repo_clone_obs
リッスン用のコードのコピーはすでに作成されています。リスナーはこのコピーを検出に使用します。リスナーがこのコピーを使用できるように、repo_observer.py
呼び出し時にこのコードのコピーへのパスを渡します。リスナーはこのコピーを使用して、メイン リポジトリから最新のコードを取得します。
同様に、リスナーによってプッシュされたメッセージがディスパッチャーに配信されるように、リスナーにテスト ケース ディスパッチャーのアドレスを提供する必要もあります。リスナーを実行するときは、--dispatcher-server
コマンド ライン引数を介してアロケーターのアドレスを渡すことができます。アドレスが手動で入力されない場合、アロケーターのデフォルトのアドレス値は次のようになりますlocalhost:8888
。
def poll(): parser = argparse.ArgumentParser() parser.add_argument("--dispatcher-server", help="dispatcher host:port, " \ "デフォルトでは localhost:8888 を使用します", default="localhost:8888" "、 action="store") parser.add_argument("repo"、metavar="REPO"、type=str、 help="これが監視するリポジトリへのパス") args = parser.parse_args() dispatcher_host、dispatcher_port = args .dispatcher_server.split(":")
リスナー スクリプトを実行する場合、poll()
最初から直接実行されます。この関数はコマンド ライン パラメーターを渡し、無限の while ループを開始します。この while ループは、コードベースの変更を定期的にチェックします。このループで最初に行われるのは、Bash スクリプトupdate_repo.sh
1の実行です。
while True: try: # リポジトリを更新する bash スクリプトを呼び出し、 # 変更をチェックします。 変更がある場合は、最新のコミットを含む.commit_id ファイルを # 現在の作業ディレクトリに削除します。 subprocess.check_output(["./update_repo.sh", args.repo]) ただし、subprocess.CalledProcessError as e: raise Exception( "リポジトリを更新および確認できませんでした。" + "理由: %s" % e.output)
update_repo.sh
新しいコミットを識別し、リスナーに通知するために使用されます。まず現在のコミット ID を記録し、次に最新のコードを取得して、最新のコミット ID を確認します。現在のバージョン ID が最新のバージョン ID と一致する場合、コードが変更されていないことを意味するため、リスナーは応答しません。ただし、コミット ID が異なる場合は、新しいコミットがあることを意味します。このとき、最新の値上げIDを記録するupdate_repo.sh
というファイルが作成されます。.commit_id
update_repo.sh
分解手順は次のとおりです。
まず、スクリプトはrun_or_fail.sh
というファイルから始まります。run_or_fail.sh
シェルスクリプトにいくつかの補助関数を提供します。これらの関数を通じて、指定されたスクリプトを実行し、エラーが発生したときにエラー メッセージを出力できます。
#!/bin/bash ソース run_or_fail.sh
次に、スクリプトは.commit_id
ファイルの削除を試みます。repo_observer.py
ループ内で継続的に呼び出されるためupdaterepo.sh
、最後の呼び出しでファイルが生成され.commit_id
、そこに格納されているバージョン ID が最後のポーリングでテストされている場合、混乱が生じます。したがって、混乱を避けるために、毎回最初に最後のファイルを削除します.commit_id
。
bash rm -f .commit_id
ファイルを削除した後 (ファイルが既に存在する場合)、スクリプトは監視しているコード ベースが存在するかどうかを確認し、.commit_id
それを最新の送信に更新して、.commit_id
ファイルとコード ベースの送信 ID の間の同期を確保します。
run_or_fail "リポジトリ フォルダーが見つかりません!" Pushd $1 1> /dev/null run_or_fail "gitをリセットできませんでした" git restart --hard HEAD
その後、git ログを読み取り、最後のコミット ID を解析します。
COMMIT=$(run_or_fail "リポジトリで 'git log' を呼び出すことができませんでした" git log -n1) if [ $? != 0 ]; 次に echo "リポジトリで 'git log' を呼び出すことができませんでした" exit 1 fi COMMIT_ID=`echo $COMMIT | awk '{ print $2 }''
次に、リポジトリをプルし、最近の変更をすべて取得し、最新のコミット ID を取得します。
run_or_fail "リポジトリからプルできませんでした" git pull COMMIT=$(run_or_fail "リポジトリで 'git log' を呼び出すことができませんでした" git log -n1) if [ $? != 0 ]; 次に echo "リポジトリで 'git log' を呼び出すことができませんでした" exit 1 fi NEW_COMMIT_ID=`echo $COMMIT | awk '{ print $2 }''
最後に、新しく取得したコミット ID が以前の ID と一致しない場合は、ポーリングの間に新しいコミットが発生したことがわかるため、スクリプトは新しいコミット ID を .commit_id ファイルに保存する必要があります。
# ID が変更された場合は、それをファイルに書き込みます if [ $NEW_COMMIT_ID != $COMMIT_ID ]; 次に、 popd 1> /dev/null echo $NEW_COMMIT_ID > .commit_id fi
repo_observer.py
スクリプトがupdate_repo.sh
実行されると、リスナーはスクリプト.commit_id
が存在するかどうかを確認します。ファイルが存在する場合、最後のポーリング以降に新しいコミットが発生したことがわかり、テスト ケース スケジューラにテストを開始するように通知する必要があります。リスナーは、スケジューラ サービスに接続して「ステータス」リクエストを送信することで、スケジューラ サービスの実行ステータスをチェックし、スケジューラ サービスが通常の状態にあり、指示を正常に受け入れることができることを確認します。
if os.path.isfile(".commit_id"): try: response = helpers.communicate(dispatcher_host, int(dispatcher_port), "status") 例外ソケット.error as e: raise Exception("ディスパッチャーサーバーと通信できませんでした: %s" %e)
スケジューラが「OK」を返した場合、リスナーは.commit_id
ファイルから最新のコミット ID を読み取り、dispatch:<commit ID>
リクエストを使用してその ID をスケジューラに送信します。リスナーは 5 秒ごとにコマンドを送信します。エラーが発生した場合も、リスナーは 5 秒ごとに再試行します。
if 応答 == "OK": commit = "" with open(".commit_id", "r") as f: commit = f.readline() 応答 = helpers.communicate(dispatcher_host, int(dispatcher_port), "dispatch: %s" % commit) if response != "OK": raise Exception("テストをディスパッチできませんでした: %s" % response) print "ディスパッチされました!" else: raise Exception("テストをディスパッチできませんでした:
\newline (Ctrl+c) を使用して KeyboardInterrupt
リスナー送信プロセスを終了するか、kill シグナルを送信しない場合、リスナーはこの操作を永久に繰り返します。
テストサンプルディスパッチャ (dispatcher.py)
テスト ケース アロケーターは、テスト タスクをテスト ランナーに割り当てるために使用される別のプロセスです。指定されたポートでコード ベース リスナーとテスト ランナーからのリクエストをリッスンします。ディスパッチャーを使用すると、テスト ランナーがアクティブに登録できるようになり、リスナーがコミット ID を送信すると、すでに登録されているテスト ランナーにテスト作業が割り当てられます。同時に、テストランナーが遭遇するさまざまな問題にもスムーズに対応し、ランナーが失敗した場合には、そのランナーが実行したテストのサブミット ID を直ちに新しいテストランナーに再割り当てすることができます。
dispatch.py
スクリプトはserve
関数から開始して実行されます。まず、設定したディストリビューターのアドレスとポートが解決されます。
defserve(): parser = argparse.ArgumentParser() parser.add_argument("--host", help="ディスパッチャのホスト、デフォルトでは localhost を使用します", default="localhost", action="store") parser.add_argument ("--port"、 help="ディスパッチャのポート、デフォルトでは 8888 を使用します"、 default=8888、 action="store") args = parser.parse_args()
ここでは、アロケータプロセス、runner_checker
関数プロセス、およびredistribute
関数プロセスを開始します。
server = ThreadingTCPServer((args.host, int(args.port)), DispatcherHandler) print `serving on %s:%s` % (args.host, int(args.port)) ... runner_heartbeat = threading.Thread (target=runner_checker, args=(server,)) redistributor = threading.Thread(target=redistribute, args=(server,)) try: runner_heartbeat.start() redistributor.start() # サーバーをアクティブ化します。 これは、 # Ctrl+C または Cmd+ C でプログラムを中断する まで実行され続けます 。 runner_heartbeat.join() redistributor.join()
runner_checker
この関数は、登録されている各ランナーに定期的に ping を送信し、すべてが適切に動作していることを確認します。ランナーが応答しなくなった場合、この関数は登録されたランナー プールからランナーを削除し、以前にランナーに割り当てられていたコミット ID が新しく利用可能なランナーに再割り当てされます。この関数は、pending_commits
ランナーが応答しなくなったことによって影響を受けたコミットの ID を変数に記録します。
defrunner_checker(server): def manage_commit_lists(runner): コミットの場合、server.dispatched_commits.iteritems()のassigned_runner: if assign_runner ==runner: delserver.dispatched_commits[commit] server.pending_commits.append(commit) breakserver.runners .remove(runner) ですが、server.dead ではありません: time.sleep(1)、 server.runners のランナーの場合: s =ソケット.ソケット(socket.AF_INET、socket.SOCK_STREAM) 試行: 応答 = helpers.communicate(runner["host "]、 int(runner["port"]), "ping") if response != "pong": print "ランナー %s の削除" % ランナー manage_commit_lists(runner) ただし、socket.error として e: manage_commit_lists(runner)
redistribute
pending_commits
ファイルに記録されているコミットIDを再割り当てするために使用されます。redistribute
ランタイムは継続的にpending_commits
ファイルをチェックし、pending_commits
コミット ID が見つかると、関数は dispatch_tests
コミット ID を割り当てるメソッドを呼び出します。
def redistribute(server): server.deadではありませんが: server.pending_commitsのコミット用: 「実行中のredistribute」を印刷 server.pending_commitsdispatch_tests (server、commit) time.sleep(5)
dispatch_tests
登録されたランナー プールから利用可能なランナーを返すために使用される関数。ランナーが使用可能な場合、関数はコミット ID を含む実行テスト コマンドを送信します。現在使用可能なランナーが存在しない場合、関数は 2 秒間スリープした後、上記のプロセスを繰り返します。割り当てが成功すると、関数はdispatched_commits
コミット ID と、そのコミット ID のテストが実行されているランナーを変数に記録します。コミット ID が にある場合pending_commits
、dispatch_tests
関数は再割り当て後にpending_commits
そこから。
defdispatch_tests(server, commit_id): # 注: 通常、これを永久に実行することはありません が、True: print "ランナーにディスパッチしようとしています" for runner in server.runners: response = helpers.communicate(runner["host"], int(runner["port"]), "runtest:%s" % commit_id) if response == "OK": print "id %s の追加" % commit_id server.dispatched_commits[commit_id] = ランナー if commit_id in server.pending_commits : server.pending_commits。削除(commit_id) 戻り値 タイムスリープ(2)
ディスパッチャー サービスは、標準ライブラリのSocketServer
非常に単純な Web サーバー モジュールを使用します。SocketServer
このモジュールにはTCP
、 、 UDP
、 UnixStreamServer
の4 つの基本的なサーバー タイプがありますUnixDatagramServer
。データ送信の継続性と安定性を確保するために、TCP プロトコルに基づくソケットを使用します (UPD はデータの安定性と継続性を保証しません)。
SocketServer
で提供されるデフォルトでは、TCPServer
同時に最大 1 つのセッションの維持のみがサポートされます。したがって、ディスパッチャーがランナーとのセッションを確立すると、リスナーとの接続を確立できなくなります。この時点で、リスナーからのセッションは、最初のセッションが完了するまで待機することしかできず、ディスパッチャーへの接続を確立する前に切断されます。これは、ディスパッチャがすべてのランナーおよびリスナーと同時に直接かつ迅速に通信することを想定している私たちのプロジェクトにとってはあまり理想的ではありません。
ディスパッチャーが同時に複数の接続を維持できるように、カスタム クラスを使用してマルチスレッド機能をThreadingTCPServer
デフォルト クラスに追加します。SocketServer
つまり、ディスパッチャは接続リクエストを受信するたびに、セッションを処理するための新しいプロセスを作成します。これにより、ディストリビュータは複数の接続を同時に維持できるようになります。
class ThreadingTCPServer(SocketServer.ThreadingMixIn, SocketServer.TCPServer): runners = [] # テスト ランナー プールを追跡します Dead = False # 実行されていないことを他のスレッドに示します dispatchs_commits = {} # ディスパッチしたコミットを追跡します pending_commits = [] # まだディスパッチしていないコミットを追跡します
ディスパッチャーは、各リクエストのハンドラー関数を定義します。処理メソッドはSocketServer
とBaseRequestHandler
2 つのクラスを継承したクラスで定義されます。DispatcherHandler
基本クラスでは、いつでもリンク要求を処理できる関数を定義する必要があります。この関数のカスタム コンテンツを に書き込みDispatcherHandler
、リクエストが発生するたびにこの関数を呼び出せるようにします。この関数は、受信リクエストを継続的に監視し (self.request
リクエスト情報を伝送します)、リクエスト内の命令を解析します。
class DispatcherHandler(SocketServer.BaseRequestHandler): """ ディスパッチャーの RequestHandler クラス。 これにより、受信したコミットに対してテスト ランナーがディスパッチされ 、そのリクエストとテスト結果が処理されます。 """ command_re = re.compile(r"(\w+)( :.+)*") BUF_SIZE = 1024 def handle(self): self.data = self.request.recv(self.BUF_SIZE).strip() command_groups = self.command_re.match(self.data) command_groups でない場合: self.request.sendall("無効なコマンド") return コマンド = command_groups.group(1)
status
この関数は、、、、、 および の命令を処理できregister
ます 。この関数は、ディストリビュータ サービスが実行されているかどうかを検出するために使用されます。dispatch
results
status
if command == "ステータス": print "in status" self.request.sendall("OK")
アロケータ機能を有効にするには、少なくとも 1 つのランナーを登録する必要があります。レジストラが呼び出されるとき、テストをトリガーするために提出 ID を送信する必要があるときに、対応するランナーを正確に見つけられるようにするために、ランナーの「アドレス:ポート」データがリストに保存されます (ランナーのデータはThreadingTCPServer
というオブジェクトに保存されます)。
elif command == "register": # このテスト ランナーをプールに追加します print "register" address = command_groups.group(2) host, port = re.findall(r":(\w*)", address) ランナー = {"ホスト": ホスト、"ポート":ポート} self.server.runners.append(runner) self.request.sendall("OK")
dispatch
これは、コミットに対してテスト ランナーをディスパッチするためにリポジトリ オブザーバーによって使用されます。このコマンドの形式は です dispatch:<commit ID>
。ディスパッチャは、このメッセージからコミット ID を解析し、テスト ランナーに送信します。
dispatch
elif command == "dispatch": print "ディスパッチするつもりです" commit_id = command_groups.group(2)[1:] if not self.server.runners: self.request.sendall("ランナーが登録されていません") else: #コーディネーターは、テストのディスパッチを信頼できます self.request.sendall("OK") dispatch_tests(self.server, commit_id)
results
このコマンドは、テスト結果を報告するときにテスト ランナーによって呼び出されます。このコマンドの使用法は ですresults:<commit ID>:<length of results data in bytes>:<results>
。<commit ID>
テストレポートに対応する提出IDを識別するために使用されます。<length of results data in bytes>
結果として生じるデータ使用量を計算するために必要なバッファーの大きさ。最後に、<results>
は実際に報告された情報です。
elif command == "results": print "テスト結果を取得しました" results = command_groups.group(2)[1:] results = results.split(":") commit_id = results[0] length_msg = int(results[1] ) # 3 は送信コマンドの「:」の数 left_buffer = self.BUF_SIZE - \ (len(command) + len(commit_id) + len(results[1]) + 3) if length_msg > Remaining_buffer: self.data += self.request.recv(length_msg - Remaining_buffer).strip() del self.server.dispatched_commits[commit_id] os.path.exists("test_results") でない場合: os.makedirs("test_results") with open("test_results/%s" % commit_id, "w") as f: data = self.data.split(":")[3:] data = "\n"。 join(データ) f.write(データ) self.request.sendall("OK")
テストランナー ( test_runner.py )
テスト ランナーは、指定されたコミット ID に対してテストを実行し、テスト結果を報告する責任があります。これは、テストを実行するために必要なコミット ID を提供する責任があるディスパッチャーとのみ通信し、テスト結果レポートを受け取ります。
test_runner.py
ファイルは、テスト ランナー サービスをエントリ ポイントとして開始する関数で始まり、関数serve
を実行するスレッドを開始します。dispatcher_checker
この起動プロセスは の起動プロセスと非常に似ているためrepo_observer.py
、dispatcher.py
ここでは詳しく説明しません。
dispatcher_checker
この関数は、5 秒ごとにアロケーターに ping を送信し、アロケーターが引き続き適切に実行されていることを確認します。この操作は主にリソース管理を考慮したものです。対応するアロケータがハングした場合は、テスト ランナーもシャットダウンする必要があります。そうしないと、テスト ランナーは無駄に実行することしかできず、新しいタスクを受信したり、以前のタスクを送信してレポートを生成したりすることができません。
defdispatcher_checker(server): server.deadではない間: time.sleep(5) if (time.time() -server.last_communication) > 10: try: response = helpers.communicate( server.dispatcher_server["host"], int(server.dispatcher_server["port"]), "status") if response != "OK": print "Dispatcher は機能しません" server.shutdown()は 次のようなソケット.エラー を返します。 print "ディスパッチャと通信できません: %s" % e server.shutdown() return
テスト ランナーは、アロケーターと同じ目的を果たしますThreadingTCPServer
。アロケーターは、テスト実行中にコミット ID を送信し、場合によっては ping を送信するため、複数のスレッドで実行する必要があります。
class ThreadingTCPServer(SocketServer.ThreadingMixIn, SocketServer.TCPServer): dispatcher_server = None # ディスパッチャ サーバーのホスト/ポート情報を保持します last_communication = None # ディスパッチャからの最後の通信を追跡します Busy = False # ステータス フラグ Dead = False # ステータス フラグ
通信フロー全体は、ディスパッチャが実行する必要があるテストのコミット ID をテスト ランナーに送信することから始まります。テスト ランナーがテストを実行できる状態にある場合、ディスパッチャーに確認メッセージを送り返し、最初の接続を閉じます。テスト ランナーは、テストの実行中にディスパッチャからの他のリクエストを受け入れるために、テストを実行するための別のプロセスを開始します。
こうすることで、テスト ランナーがテストを実行中にディスパッチャがリクエスト (ping リクエストなど) を送信した場合、テスト ランナーのテストは別のプロセスで実行され、ランナー サービス自体は引き続き応答できます。このようにして、テスト ランナーは複数のタスクの同時実行をサポートできます。マルチスレッド実行の代替方法は、ディスパッチャーとテスト ランナーの間に長期間存続する接続を確立することです。しかし、この方法は接続を維持するためにアロケータ側で多くのメモリを消費し、またネットワークへの依存度も高くなります。ネットワークが変動した場合(突然の切断など)、システムに損傷を与える可能性があります。
テスト ランナーはディスパッチャーから 2 つのメッセージを受け取ります。1 つ目は、ping
テスト ランナーがまだアクティブであることを確認するためにディスパッチャが使用するメッセージです。
class TestHandler(SocketServer.BaseRequestHandler): ... def handle(self): .... if command == "ping": print "pinged" self.server.last_communication = time.time() self.request.sendall( 「ポン」)
もう 1 つはruntest
、その形式は ですruntest:<commit ID>
。このコマンドは、テストが必要なコミット ID を発行するためにアロケーターによって使用されます。runtest を受信すると、テスト ランナーは現在実行中のテストがあるかどうかを確認します。BUSY
そうである場合、アロケータによって返される応答が返されます。そうでない場合は、 を返しOK
、ステータスをビジーに設定し、run_tests
関数を実行します。
elif コマンド == "runtest": print "runtest コマンドを取得しました: 忙しいですか? %s" % self.server.busy if self.server.busy: self.request.sendall("BUSY") else: self.request. sendall("OK") print "running" commit_id = command_groups.group(2)[1:] self.server.busy = True self.run_tests(commit_id, self.server.repo_folder) self.server.busy = False
この関数はtest_runner_script.sh
というシェル スクリプトを呼び出し、コードを指定されたコミット ID に更新します。スクリプトが返された後、コード ベースが正常に更新された場合、ランナーはunittestを使用してテストを実行し、結果をファイルに収集します。テストの実行が終了すると、テスト ランナーは結果レポート ファイルを読み取り、レポートをスケジューラに送信します。
def run_tests(self, commit_id, repo_folder): # リポジトリ 出力を更新 = subprocess.check_output(["./test_runner_script.sh", repo_folder, commit_id]) 出力を印刷 # テストを実行 test_folder = os.path.join(repo_folder, "テスト") スイート =unittest.TestLoader().discover(test_folder) 結果ファイル = open("結果", "w") ユニットテスト.TextTestRunner(結果ファイル).run(スイート) 結果ファイル.close() 結果ファイル = open("結果" , "r") # ディスパッチャーに結果を与える Output = result_file.read() helpers.communicate(self.server.dispatcher_server["host"], int(self.server.dispatcher_server["port"]), "results:%s:%s:%s" % (commit_id, len(output),出力))
test_runner_script.sh
内容は次のとおりです。
#!/bin/bash REPO=$1 COMMIT=$2 source run_or_fail.sh run_or_fail "リポジトリ フォルダーが見つかりません" Pushd "$REPO" 1> /dev/null run_or_fail "リポジトリをクリーンできませんでした" git clean -d -f -x run_or_fail 「git pull を呼び出せませんでした」 git pull run_or_fail 「指定されたコミット ハッシュに更新できませんでした」 git restart --hard "$COMMIT"
を実行するにはtest_runner.py
、リポジトリのコピーを指定する必要があります。前に作成したコピーを/path/to/test_repo test_repo_clone_runner
起動パラメータとして使用できます。デフォルトでは、 test_runner.py
ローカルホストのポート 8900 ~ 9000 で起動し、localhost:8888
ローカルホストのスケジューラ サーバーへの接続を試行します。これらの値は、いくつかのオプションのパラメーターを使用して変更できます。--host
および--port
パラメータは、テスト ランナーを実行するサーバーのアドレスとポートを指定するために使用され、 --dispatcher-server
パラメータはスケジューラのアドレスを指定します。
制御フローチャート
システムの概要を以下の図に示します。この図では、3 つのファイル ( repo_observer.py
、 dispatcher.py
およびtest_runner.py
) がすべてすでに実行されていることを前提としており、新しいコミットが発生したときに各プロセスによって実行されるアクションが説明されています。
コードを実行する
プロセスごとに異なるターミナル シェルを使用して、この単純な CI システムをローカルで実行できます。まずディスパッチャを起動します。デフォルトではポート 8888 で実行されます。
$ Pythonディスパッチャー.py
新しいシェルを開いて、テスト ランナーを開始します (これにより、ディスパッチャーに登録できるようになります)。
$ python test_runner.py <path/to/test_repo_clone_runner>
テスト ランナーは、8900 ~ 9000 の範囲のポートを自動的に割り当てます。テスト ランナーは必要な数だけ作成できます。
最後に、別の新しいシェルで、リポジトリ リスナーを開始しましょう。
$ python repo_observer.py --dispatcher-server=localhost:8888 <path/to/repo_clone_obs>
すべての準備が整ったので、いくつかのテストをトリガーして試してみましょう。設計上、テストをトリガーするには新しいコミットを作成する必要があります。メインのコード リポジトリに切り替えて、必要な内容を変更します。
$ cd /path/to/test_repo $ touch new_file $ git add new_file $ git commit -m"new file" new_file
次に、repo_observer.py
新しいコミットが行われたことを認識し、アロケーターに通知します。それぞれのシェル ウィンドウで実行ログを表示できます。ディスパッチャーはテスト結果を受け取ると、test_results/
コミット ID をファイル名として使用して、このリポジトリ内のフォルダーに結果を保存します。
エラー処理
この CI システムには、いくつかの簡単なエラー処理が含まれています。
test_runner.py
プロセスを強制終了 すると、dispatcher.py
ランナーはノードがアクティブでなくなったことを認識し、ランナー プールからノードを削除します。
ネットワークまたはシステムの障害をシミュレートし、テストの実行中にテスト ランナーを強制終了することもできます。この時点で、アロケータはランナーがハングアップしたことを認識し、ハングしたランナーをランナー プールから削除し、ランナーが以前に実行していたタスクをプール内の他のランナーに割り当てます。
アロケータを強制終了すると、リスナーはエラーを直接報告します。テスト ランナーは、アロケーターが実行されていないことにも気づき、自動的にシャットダウンします。
結論は
各プロセスのさまざまな機能を 1 つずつ分析することで、分散型継続的インテグレーション システムの構築についての基本的な理解を得ることができます。プロセス間通信を実現するソケット リクエストを通じて、CI システムを分散してさまざまなマシン上で実行できるため、システムの信頼性と拡張性が向上します。
この CI システムの現在の機能はまだ非常にシンプルですが、自分の才能を活用してさまざまな方法で拡張し、より多くの機能を実現することもできます。改善のためのいくつかの提案を次に示します。
コミットごとにテストを自動的に実行する
現在のシステムは定期的に新しいコミットをチェックし、最新のコミットに対してテストを実行します。この設計は、コミットごとにテストをトリガーするように変更できます。これを行うには、ポーリング間で発生したすべてのコミットをフェッチするように定期チェッカーを変更します。
より賢いランナー
テスト ランナーは、アロケーターが応答していないことを検出すると、実行を停止します。テスト ランナーも、テストの実行中はすぐにシャットダウンします。アロケータが戻ってくるのを待つ間、テスト ランナーが待機期間または長時間実行できる (リソースの使用量を気にしない場合) 方が良いかもしれません。こうすることで、アロケータが復元されたときに、ランナーは以前に実行されたテストのレポートをアロケータに送り返すことができます。これにより、アロケーターの障害によって引き起こされるタスクの重複が回避され、送信ごとにテストする際にランナーのリソースが大幅に節約されます。
レポート表示
実際の CI システムでは、テスト レポートは通常、別のレポート サービスに送信されます。レポート システムでは、レポートの詳細を表示したり、障害やその他の特殊な状況が発生した場合に関係者に通知するための通知ルールを設定したりできます。ディスパッチャのレポート収集機能を置き換える、CI システム用の別個のレポート プロセスを作成できます。この新しいプロセスは Web サービス (または Web サービスにリンク) にすることができるため、テスト レポートを Web ページ上でオンラインで直接表示したり、電子メール サーバーを使用してテストが失敗したときにリマインダーを提供したりすることもできます。
テストランナーマネージャー
現在のシステムでは、test_runner.py
テスト ランナーを開始するにはファイルを手動で実行する必要があります。すべてのランナーの負荷とアロケーターからのリクエストを管理し、それに応じてランナーの数を調整するテスト ランナー マネージャー プロセスを作成できます。このプロセスはすべてのテスト タスクを受け入れ、タスクに従ってテスト ランナーを開始し、タスクが少ない場合はランナー インスタンスの数を減らします。
これらの提案に従うことで、このシンプルな CI システムをより堅牢でフォールト トレラントにし、他のシステム (Web ベースのレポート ビューアなど) と統合できるようにすることができます。
現在の継続的インテグレーション システムがどのような柔軟性を実現できるかを理解したい場合は、 Java で書かれた非常に強力なオープン ソース CI システムであるJenkinsを参照することをお勧めします。基本的な CI システムを提供すると同時に、プラグインを使用した拡張も可能です。GitHub 経由でソース コードにアクセスできます。もう 1 つの推奨プロジェクトはTravis CIです 。これは Ruby で書かれており、そのソース コードもGitHub から入手できます。
これは、CI システムがどのように機能するのか、また CI システムを自分で構築する方法を理解する試みです。これで、信頼性の高い分散システムを構築するために何が必要かについてより深く理解できるようになり、この知識を利用してより複雑なソリューションを開発できるようになることを期待しています。