GitLab RunnerでCI/CDしてみる(後編)
はじめに
サーバーレス開発部@大阪の岩田です。 前回に引き続き、GitLab Runnerを使ったCI/CDについて見ていきます。 今回は前回の設定を発展させて、より実践的なCI/CD環境を構築していきます。
前回のエントリはこちらです↓
ゴール
今回は下記の要件を満たす環境の構築をゴールに設定しました
- 各プランチにプッシュされたタイミングで、自動でテストを実行する。テストは単体テストと結合テストの2種類を実行する
- 単体テストはmotoでDynamoDBの処理をモックし、結合テストはLocalStackでDynamoDBをエミュレートする
- devブランチにプッシュされたら自動的にテスト環境へのデプロイまで行う
- masterブランチにプッシュされたら本番環境へのデプロイの準備を行う。実際のデプロイは手動で行う
- GitLabのGUIからデプロイ後のロールバックを可能にする
パラメータの設定
今回のゴールを実現するために、.gitlab-ci.ymlに下記のパラメータを設定していきます。
services
対象のジョブ実行中に起動するDockerイメージを指定します。
imagesではジョブを実行する...つまりscriptsを実行するためのDockerイメージを指定しますが、servicesで指定されたコンテナはimagesで指定されたコンテナとは別で起動し、対象のジョブが終了するまで稼働し続けます。
さらに、Dockerネットワーク機能で別コンテナから参照できるので、テストケースが依存するサービスを稼働させるのにうってつけの機能です。
only
各ジョブの定義内でonlyを指定すると、対象ジョブの実行を特定のブランチや特定のタグがプッシュされた時に限定することができます。
このパラメータを利用することで、本番環境用のジョブと開発環境用のジョブを分ける
といったことが容易になります。
artifacts
artifactsで指定されたファイルは、ジョブの成果物としてジョブを跨いで共有することが出来ます。
ビルド用のジョブでリリースに必要なパッケージ一式を作成し、デプロイ用の後続ジョブに引き渡す
といったことが可能です。
when
このパラメータでジョブを実行するタイミングを指定することができます。
- on_success
- on_failure
- always
- on_failure
の4種類が指定でき、manualを指定すると対象ジョブが自動実行の対象外となります。
environment
対象のジョブ内にこのパラメータを定義することで、対象のジョブがどの環境へデプロイを行うのかを定義することができます。 ここで定義した環境はGitLabの各種メニューから参照することが可能になります。
利用するソースコード
前回のエントリで作成したSAMの雛形アプリを加工して使用していきます。 GitLab Runnerに関する調査が目的なので、ソースは適当です。
まずはLambdaのhandlerです。
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 boto3import decimalimport jsonimport osimport requestsparam = {}if os.getenv('ENDPOINT_URL') is not None: param["endpoint_url"] = os.getenv('ENDPOINT_URL')dynamo = boto3.resource('dynamodb', **param)table = dynamo.Table(os.getenv('TABLE_NAME'))class DecimalEncoder(json.JSONEncoder): def default(self, o): if isinstance(o, decimal.Decimal): if o % 1 > 0: return float(o) else: return int(o) return super(DecimalEncoder, self).default(o)def lambda_handler(event, context): res = table.scan() return { "statusCode": 200, "body": json.dumps(res, cls=DecimalEncoder) } |
環境変数TABLE_NAMEで指定されたDynamoDBのテーブルをスキャンして、結果をJSONで返すだけの処理です。 ポイントとして、LocalStackやDyanmoDB Localの利用を想定して、環境変数ENDPOINT_URLがセットされている場合は、その値でDynamoDBのエンドポイントを上書きしています。
次に上記のLambdaに対する単体テストです。 motoでDynamoDBをモックしてテストを実行します。
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 | import boto3import jsonimport pytestfrom moto import mock_dynamodb2import osregion = os.getenv('AWS_DEFAULT_REGION', 'ap-northeast-1')table_name = 'mock'os.environ['TABLE_NAME'] = table_namefrom hello_world import app@mock_dynamodb2class Test_Class: @pytest.fixture() def apigw_event(self): dynamodb = boto3.resource('dynamodb') table = dynamodb.create_table( TableName=table_name, KeySchema=[ { 'AttributeName': 'id', 'KeyType': 'HASH' }, ], AttributeDefinitions=[ { 'AttributeName': 'id', 'AttributeType': 'N' }, ], ProvisionedThroughput={ 'ReadCapacityUnits': 1, 'WriteCapacityUnits': 1 } ) table.put_item( Item={ 'id': 1, 'col1': 'val1', } ) """ Generates API GW Event""" return { "body": "{ \"test\": \"body\"}", "resource": "/{proxy+}" #... #...略 def test_lambda_handler(self, apigw_event): ret = app.lambda_handler(apigw_event, "") assert ret['statusCode'] == 200 body = json.loads(ret['body']) assert body['ScannedCount'] == 1 assert body['Count'] == 1 assert body['Items'][0]['id'] == 1 assert body['Items'][0]['col1'] == 'val1' |
fixtureの中でテーブル作成とレコードの追加を行い、Lambdaのレスポンスと登録したレコードを比較しています。
次に結合テストのコードです。 呼び出しているLambdaが1つなので、結合では無いのですが、今回はGitLab Runnerの調査が目的なので、外部サービスを使用しているという観点から便宜上これを結合テストとします。
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 | import boto3import jsonimport pytestimport osregion = os.getenv('AWS_DEFAULT_REGION', 'ap-northeast-1')table_name = 'mock'os.environ['TABLE_NAME'] = table_namefrom hello_world import appclass Test_Class: @pytest.fixture() def apigw_event(self): dynamodb = boto3.resource('dynamodb', endpoint_url=os.environ['ENDPOINT_URL']) table = dynamodb.create_table( TableName=table_name, KeySchema=[ { 'AttributeName': 'id', 'KeyType': 'HASH' }, ], AttributeDefinitions=[ { 'AttributeName': 'id', 'AttributeType': 'N' }, ], ProvisionedThroughput={ 'ReadCapacityUnits': 1, 'WriteCapacityUnits': 1 } ) table.put_item( Item={ 'id': 1, 'col1': 'val1', } ) """ Generates API GW Event""" return { "body": "{ \"test\": \"body\"}", "resource": "/{proxy+}" #... #...略 def test_lambda_handler(self, apigw_event): ret = app.lambda_handler(apigw_event, "") assert ret['statusCode'] == 200 body = json.loads(ret['body']) assert body['ScannedCount'] == 1 assert body['Count'] == 1 assert body['Items'][0]['id'] == 1 assert body['Items'][0]['col1'] == 'val1' |
ほぼ単体テストのコピペです。実際の業務でテストを書く場合は、きちんと共通化しましょう。 先ほどの単体テストとの違いは、DynamoDBの処理をmotoでモックしていないところです。
完成したgitlab-ci.yml
最終的に下記の.gitlab-ci.ymlを作成しました。
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 | image: python:3.6.5variables: AWS_DEFAULT_REGION: ap-northeast-1 PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache"cache: key: ${CI_COMMIT_REF_SLUG} paths: - .cache/stages: - unittest - integrate_test - package - deployunittest: stage: unittest script: - pip install pytest moto - pip install -r requirements.txt - python -m pytest tests/unit -v integrate_test: stage: integrate_test script: - pip install pytest boto3 - pip install -r requirements.txt - python -m pytest tests/integrate -v services: - name: localstack/localstack:latest alias: localstack variables: ENDPOINT_URL: http://localstack:4569package: stage: package script: - pip install awscli - pip install -r requirements.txt -t hello_world/build/ - cp hello_world/*.py hello_world/build/ - aws cloudformation package --template-file template.yaml --output-template-file output.yaml --s3-bucket ${S3_BUCKET} artifacts: paths: - output.yamldeploy_dev: stage: deploy script: - pip install awscli - aws cloudformation deploy --stack-name gitlab-ci-dev --template-file output.yaml --capabilities CAPABILITY_NAMED_IAM only: - dev environment: name: developdeploy_prd: stage: deploy script: - pip install awscli - aws cloudformation deploy --stack-name gitlab-ci-prd --template-file output.yaml --capabilities CAPABILITY_NAMED_IAM --parameter-overrides Stage=prd only: - master when: manual environment: name: production |
cache
設定を1つ1つ見ていきます。
1 2 3 4 5 6 7 8 | image: python:3.6.5variables: AWS_DEFAULT_REGION: ap-northeast-1 PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache"cache: key: ${CI_COMMIT_REF_SLUG} paths: - .cache/ |
要件とは無関係ですが、まず冒頭で指定しているcacheでジョブを跨いで共有したいファイル(ディレクトリ)を指定しています。
artifactsと似ているのですが、cacheではあくまでビルド用に一時的に使用するファイルを指定します。
4行目の環境変数の設定と合わせて、各ジョブでのpip installを高速化しています。
stages
1 2 3 4 5 | stages: - unittest - integrate_test - package - deploy |
stagesは上記のように定義しました。
各ステージで
- 単体テスト
- 結合テスト
- デプロイパッケージの作成
- デプロイ実行
を行います。
unittestステージ
1 2 3 4 5 6 | unittest: stage: unittest script: - pip install pytest moto - pip install -r requirements.txt - python -m pytest tests/unit -v |
ここは特に特筆すべきことはありません。前回のエントリ同様です。
integrate_testステージ
1 2 3 4 5 6 7 8 9 10 11 | integrate_test: stage: integrate_test script: - pip install pytest boto3 - pip install -r requirements.txt - python -m pytest tests/integrate -v services: - name: localstack/localstack:latest alias: localstack variables: ENDPOINT_URL: http://localstack:4569 |
servicesでLocakStackのコンテナを指定しています。
これでジョブの実行中にaliasで指定されたlocalstackという名前を使用して、pytestを実行するメインのコンテナがlocalstackのコンテナにアクセスできるようになります。
packageステージ
1 2 3 4 5 6 7 8 9 10 | package: stage: package script: - pip install awscli - pip install -r requirements.txt -t hello_world/build/ - cp hello_world/*.py hello_world/build/ - aws cloudformation package --template-file template.yaml --output-template-file output.yaml --s3-bucket ${S3_BUCKET} artifacts: paths: - output.yaml |
このステージでデプロイパッケージの作成を行います。
作成されたoutput.yamlはartifactsとして指定し、後続のジョブで利用します。
deployステージ
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | deploy_dev: stage: deploy script: - pip install awscli - aws cloudformation deploy --stack-name gitlab-ci-dev --template-file output.yaml --capabilities CAPABILITY_NAMED_IAM only: - dev environment: name: developdeploy_prd: stage: deploy script: - pip install awscli - aws cloudformation deploy --stack-name gitlab-ci-prd --template-file output.yaml --capabilities CAPABILITY_NAMED_IAM --parameter-overrides Stage=prd only: - master when: manual environment: name: production |
このステージではonlyパラメータを利用してdev環境へのデプロイと、本番環境へのデプロイを別のジョブとして定義しています。
本番環境へのデプロイジョブdeploy_prdに関してはwhenをmanualに設定して自動実行の対象外としています。
さらにenvironmentで本番環境とテスト環境それぞれにproduction、developという名前を付けています。
scriptの中身が重複だらけなので、実際に業務で利用する際は、Makefileを活用するなどしてうまく共通化すべきでしょう。
CI/CDの実行
.gitlab-ci.ymlが作成できたので、実際にGitLabへのプッシュを行いジョブを実行してみます。
devブランチのプッシュ
まずはdevブランチをGitLabへプッシュしてみます。
GitLab Runnerがパイプラインを実行し、正常に終了しました。
LocalStackに依存した結合テストも問題なく終了しています。
なお、定義したartifactsはパイプラインの実行履歴からダウンロードすることが可能です。
masterブランチへのマージ
devブランチ(テスト環境)のデプロイが完了したので、masterブランチ(本番環境)にマージしてみます。 マージリクエストを作成し、セルフで承認してマージします。
マージすると自動でジョブが実行されますが、masterブランチ(本番環境)に関してはデプロイのジョブは実行されず、スキップされた状態となります。
この状態から、ジョブを手動実行するリンクをクリックすることで本番環境へのデプロイが実行されます。 メンテ枠を設けてのデプロイ作業などに便利ですね!
ロールバック
最後にロールバックを試してみます。 Lambdaをプチ修正し、レスポンスをhelloという文字列に変更します。 また、この修正に伴ってテストが通らなくなるので、テストコードも適宜コメントアウトしてしまいます。
1 2 3 4 5 6 7 8 | #...略 def lambda_handler(event, context): res = table.scan() return { "statusCode": 200, "body": "hello" } |
変更できたらそのままプッシュ〜デプロイまで一連の処理を実行し、curlコマンドで動作確認してみます。
1 2 | curl https://xxxxxx.execute-api.ap-northeast-1.amazonaws.com/Prod/hello/hello |
レスポンスがhelloに変わっているのが確認できました。 このデプロイに不具合が含まれていたと仮定して、GitLabの画面から1つ前のデプロイにロールバックしてみます。 デプロイの履歴はOperationsのEnviromentsから確認が可能です。
ここのRollbackをクリックすることで、デプロイのロールバックが可能です。 ロールバックできたら再度curlコマンドで確認してみます。
1 2 | curl https://xxxxxx.execute-api.ap-northeast-1.amazonaws.com/Prod/hello/{"Items": [], "Count": 0, "ScannedCount": 0, "ResponseMetadata": {"RequestId": "ACLTIRV3MUMT2VNOGTNI39KUTJVV4KQNSO5AEMVJF66Q9ASUAAJG", "HTTPStatusCode": 200, "HTTPHeaders": {"server": "Server", "date": "Tue, 03 Jul 2018 08:09:18 GMT", "content-type": "application/x-amz-json-1.0", "content-length": "39", "connection": "keep-alive", "x-amzn-requestid": "ACLTIRV3MUMT2VNOGTNI39KUTJVV4KQNSO5AEMVJF66Q9ASUAAJG", "x-amz-crc32": "3413411624"}, "RetryAttempts": 0}} |
無事ロールバックできました!!
まとめ
2回に分けてGitLab Runnerを使ったCI/CDについて見てきました。 GitLab自体は以前にも触ったことはあったのですが、知らない間に多くの機能が増えていて、正直ビックリしました。 ココでは紹介しきれていませんが、GitLabにはまだまだたくさんの便利機能が搭載されています。 皆さんも是非一度GitLabをお試し下さい!!