GitHub上でPull Requestを作成/更新するとCodeBuildのビルドが走り自動でテストが実行され、テスト結果が成功した場合のみマージを可能にする、よくあるCI環境の作成手順です。
最近になってプライベートのGit管理をCodeCommitからGitHubに移したこともあり、今後何度も設定する事になりそうだったのでメモ代わりに残しておきます。
今回やりたいこと
細かい手順は下で説明していきますが、今回実現したい事項は以下二つです。
- DevelopやMasterブランチは、常に全テストが通った状態で担保したい
- 実現する為の仕組み作成に対して、(できれば)コスト(お金/工数)をあまりかけたくない
Git-flowで開発する際の開発起点のdevelopブランチやProduction環境に反映させるmasterブランチは、常に全テストが通った状態を保ちたいです。
その担保の為には、実行させるテストは手動で行うのではなく自動で実行させ、全テストが通るまでは物理的にマージをブロックする仕組みが必要です。
上記のようなCI環境はプロダクトを作る際の多くのケースで必要になる事が想定され、その都度作成していく事を考えるとこの仕組みを作成する為のコスト(お金/工数)はできるだけ抑えた状態で実現したいのが正直なところです。
何故この構成か
上記事項を実現する為に何故GitHubとCodeBuildの二つを選択したかです。
GitHubの選択理由
CI環境を作る上で非常に重要な各種外部サービスとの連携がデフォルトでサポートされていて独自の作り込みが不要な点と、個人利用用途で使用するぐらいであれば無料で利用可能なコスト面に加えて、Gitのリモートリポジトリとして世の中のデファクトスタンダードなサービスになりつつあるので一度仕組みを覚えておけば色々な場面で使い回しが利く点が大きいです。
最近話題のGitHub Actionsを弄ってみたかったというのも、CodeCommitからGitHubに移した理由として実はありますw
AWS CodeBuildの選択理由
主な選択理由は二つあり、一つ目は自前でサーバを用意したりする必要がなく、使用した分だけの重量課金というコスト面でのメリットです。
インスタンスタイプをgeneral1.smallにすれば無料利用枠100min/monthもあるのと、無料枠をはみ出した場合でも非常に安価(東京リージョンでgeneral1.smallの場合だと、0.005USD/minしかかからない)で使用できるありがたさがあります。
参考: AWS Code Buildの料金
もう一つは、ビルド環境が自動でスケールするので複数人での共同開発時など規模が大きくなった場合の拡張性観点です。
無料で使用可能な他の有力な選択肢としてCircleCIなどもありましたが、無料枠ではビルドが直列実行になり複数人開発時に辛かったというのもありました
また、CI環境ができたら次はCD環境もという流れになるのが自然で、その際の有力な選択肢であるAWS Code Pipelineとの連携がし易かったというのも、選択理由の一つとしてありました。
作成方法
という事で、早速作っていきます。
1. GitHub Repositoryの作成
まずは対象のGitHub Repositoryを作成します。
今回は、example_repositoryというリポジトリを作成しました。
既に連携させたいリポジトリが存在する場合は、このステップは省略します。
2. テストコードの作成
PR作成時に自動で実行させたいテストコードを作成します。
今回は、簡単なDynomoDBへのデータ挿入処理と、それに対する正しくデータが挿入できたかを確認するテストコードをそれぞれPythonで作成しました。
今回はすぐ用意できる理由からPythonを使っていますが、言語やテスト内容は何でも可です。
また、開発時にDockerは必要不可欠になっているのと、CI環境でDockerを使う場合に多少設定が要るので、DynamoDB Local用にコンテナを一つ使用します。
1 2 3 4 5 6 7 8 |
$ tree /Users/masakimisawa/dev/example_repository . ├── README.md ├── docker-compose.yml ├── main.py └── tests ├── __init__.py └── test_main.py |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
import boto3 import sys DYNAMODB_REGION_NAME = 'ap-northeast-1' TABLE_NAME = 'example_table' def initialize_dynamodb(region_name): return boto3.client( 'dynamodb', region_name = region_name ) def save_dynamodb_record(dynamodb, table_name, id, name): dynamodb.put_item( TableName = table_name, Item = { 'id' : {'N' : id}, 'name' : {'S' : name} } ) if __name__ == "__main__": dynamodb = initialize_dynamodb(DYNAMODB_REGION_NAME) args = sys.argv save_dynamodb_record( dynamodb, TABLE_NAME, args[1], args[2], ) |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 |
import unittest import boto3 import random import main DYNAMODB_REGION_NAME = 'ap-northeast-1' DYNAMODB_ENDPOINT_URL = 'http://localhost:8000' ACCESS_KEY_ID = 'dummy_access_key_id' SECRET_ACCESS_KEY = 'dummy_secret_access_key' TABLE_NAME = 'example_table' class TestMain(unittest.TestCase): dynamodb = None @classmethod def setUpClass(cls): global dynamodb dynamodb = boto3.client( 'dynamodb', DYNAMODB_REGION_NAME, endpoint_url = DYNAMODB_ENDPOINT_URL, aws_access_key_id = ACCESS_KEY_ID, aws_secret_access_key = SECRET_ACCESS_KEY ) cls.__create_example_table(dynamodb) @classmethod def __create_example_table(cls, dynamodb): try: dynamodb.create_table( TableName = TABLE_NAME, KeySchema = [ { 'AttributeName': 'id', 'KeyType': 'HASH' } ], AttributeDefinitions = [ { 'AttributeName': 'id', 'AttributeType': 'N' } ], ProvisionedThroughput = { 'ReadCapacityUnits': 1, 'WriteCapacityUnits': 1 } ) except dynamodb.exceptions.ResourceInUseException: pass def test_save_dynamodb_record(self): ## given id = str(random.randint(1, 10)) name = 'name_' + id ## when main.save_dynamodb_record( dynamodb, TABLE_NAME, id, name ) ## then create_record = dynamodb.get_item( TableName = TABLE_NAME, Key = {'id' : {'N' : id}} )['Item'] self.assertEqual(create_record['id']['N'], id) self.assertEqual(create_record['name']['S'], name) @classmethod def tearDownClass(cls): dynamodb.delete_table( TableName = TABLE_NAME ) |
1 2 3 4 5 6 |
version: "3" services: dynamodb-local: image: amazon/dynamodb-local ports: - 8000:8000 |
テストを実行して通るのを確認できたら、一度この内容でリモートにpushします。
1 2 3 4 5 6 |
$ python -B -m unittest discover . ---------------------------------------------------------------------- Ran 1 test in 0.233s OK |
3. AWS CodeBuild ビルドプロジェクトの作成
次は、作成したテストを実行させるCodeBuildのビルドプロジェクトを作成します。
今回は、example_build_projectというプロジェクトを作成しました。
ソースプロバイダにGitHubを選択した状態でリポジトリをGitHub アカウントのリポジトリにチェックを付けるとGitHub側の認証を求める画面に移るので、AWS CodeBuildからのアクセスを許可します。
認可が完了するとCodeBuild側のGitHub リポジトリのセレクトボックスに連携対象リポジトリ一覧が出るようになるので、作成したexample_repositoryを選びます。
ウェブフックでコードの変更がこのレポジトリにプッシュされるたびに再構築するにチェックを入れるとビルド実行のトリガーが選択可能になるので、PRの作成/更新/再オープンの三つを選択しました。
もう一つのタイミングとしてマージされたタイミングをトリガーに設定する事も可能ですが、マージ時に実行したいのはテストなどのCIよりもマージ後の成果物を○○したいといったCDのイベントになる為、今回のCI用ビルドプロジェクトのトリガーには設定しません。
今回はマネージドのイメージで十分なのでビルド環境はマネージドイメージを。
OSやランタイムは、Amazon Linux2の最新イメージを選択しました。
また、今回のようにビルドタスクの中でdockerを使用する(dind)場合は、特権付与欄にチェックを入れる必要があります。
チェックを入れないと、dockerコマンドがコマンドとして認識されないようです。
設定したIAMロールにアタッチしたポリシーは、以下の通りです。
アーティファクト(ビルド成果物)をS3にあげたりする場合は別途権限が必要ですが、今回は何もしないので特に設定していません
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Resource": [ "arn:aws:logs:ap-northeast-1:xxxxxxxxxxxx:log-group:/aws/codebuild/example_build_project", "arn:aws:logs:ap-northeast-1:xxxxxxxxxxxx:log-group:/aws/codebuild/example_build_project:*" ], "Action": [ "logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents" ] }, { "Effect": "Allow", "Action": [ "codebuild:CreateReportGroup", "codebuild:CreateReport", "codebuild:UpdateReport", "codebuild:BatchPutTestCases" ], "Resource": [ "arn:aws:codebuild:ap-northeast-1:xxxxxxxxxxxx:report-group/example_build_project-*" ] } ] } |
1 2 3 4 5 6 7 8 9 10 11 12 |
{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Principal": { "Service": "codebuild.amazonaws.com" }, "Action": "sts:AssumeRole" } ] } |
追加設定のタイムアウト設定や対象VPC、証明書や環境変数設定、使用するコンピューティングリソースは、今回は全てデフォルト値のまま(タイムアウト1h、コンピューティングリソース 3GBメモリ、2vCPU)で作成しました。
非常に重要な設定になるので、ここは都度適切な値でカスタマイズする必要があります。
buildspecは、デフォルトのROOT直下のbuildspec.ymlを使うので何も変更せず。
アーティファクト(成果物)も今回は特に何もしないので変更せず。
出力ログも、CloudWatchLogsに出力するだけのデフォルト設定のまま。
以上でCodeBuildプロジェクト側の設定は完了です。
4. テストが成功するまでマージブロックの設定をGitHub側に追加
PR作成/更新/再オープン時にビルドが自動で走るようにはできましたが、この段階ではまだテストが成功していなくてもマージが可能な状態になってしまっているので、テストが通るまでマージをブロックする設定をGitHub側に追加します。
マージブロックの設定はリポジトリ毎のブランチ単位になるので、作成したexample_repositoryのSettingsからBrachesに入り、Branch protection rulesを追加する形で設定します。
今回は、masterブランチへのPRを対象に、Code Buildのexample_build_projectのビルドが成功するまでマージをブロックするように設定を追加しました。
過去のステータスチェック実行履歴を元に候補が出るようになっているようなので、作成したCodeBuildのビルドプロジェクトが候補に出ない場合は、一度適当なブランチ/PRを作成してCodeBuildのビルドを実行させると候補に出てくるようになるはずです。
また、趣旨とずれるのでapproveがされるまでマージブロックするルールは今回設定していませんが、通常用途の多くの場面ではapproveがマージの必須条件となると思うので、その場合は別途ルールを追加してください。
5. 別ブランチにチェックアウト後にbuildspec.ymlを追加し、PR作成
最後に、CodeBuildで実行させるビルド定義をbuildspec.ymlで記述してcommit/pushして、その内容でmasterブランチに対してPRを作成します。
1 2 3 4 5 6 7 8 9 |
$ tree . ├── README.md ├── buildspec.yml ├── docker-compose.yml ├── main.py └── tests ├── __init__.py └── test_main.py |
今回は、masterからチェックアウトしたtestブランチで、以下のビルド定義を作成しました。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
version: 0.2 phases: install: runtime-versions: python: 3.7 pre_build: commands: - docker-compose up -d build: commands: - python -B -m unittest discover |
ランタイムとしてPython3.7をインストールしておき、テスト実行前のpre_buildフェーズで必要なdockerコンテナ(今回はDynamoDB Local)を立ち上げる為にdocker-composeを実行した上で、buildフェーズでテスト実行のunittestを行う流れです。
buildspec.ymlを追加した内容をtestブランチでコミットしてmasterに対してPRを作成すると自動でテスト実行/成功の確認ができるまでマージがブロックされ…
Administrator権限を持っている関係で強制マージが可能になっている点は、今回は無視してください
テストが成功すると、マージが可能になります。
試しにわざとテストが失敗する状態でコミットしてPRを作ってみると…
テストが失敗してマージがブロックされているのが確認できます。
おまけ. テスト結果のSlack通知作成
ここまでで自動テストの実行とマージブロックの仕組みはできましたが、Ops観点でのCI環境作成と考えるとテスト結果の何かしらの通知はやはり欲しいので、最後に結果のSlack通知を設定するところをおまけで書いて終わりにします。
テスト結果の通知はCodeBuild側の通知ルールの作成から設定できるので、今回はテストの最終結果が成功/失敗したタイミングをトリガーにSlack通知するexample_build_result_notification_ruleというルールを作成しました。
通知のターゲットはSNSとAWS Chatbotのどちらかから選択可能ですが、前回の記事にも書いた通り、Slack通知する際はChatbotが凄く便利なので今回もAWS Chatbot経由でSlack通知させる形で作成します。
通知ルールのターゲットにSNSを選択して、SNSのサブスクリプションにAWS Chatbotを設定しておけば同じ結果になりそうですが、内部挙動的にはCodeBuildからSNSのpublishイベントを実行する形になる為、この形式では2020/05現在だとまだAWS Chatbotが対応サービスとして対応できておらず通知が全く飛んでこない状態になります。
AWS Chatbot経由で通知を飛ばしたい場合は、CodeBuildの通知ルールとして設定するターゲットに直接AWS Chatbotを設定する必要があるので、そこだけ注意が必要です。
通知ルールを作成後にPRを作成/更新して自動テストを実行させてみると、GitHub上のマージの可否だけでなくSlackにも結果が通知されるようになります。
手軽に安価でCI環境が作成できるのは非常にありがたいですし、新規プロダクト作成時にはここまでの流れのCI環境作成を自動化させておきたいですね。
ということで、以上で今回のCI環境作成は終わりです!