この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。
はじめに
サーバーレス開発部@大阪の岩田です。 前回に引き続き、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です。
hello_world/app.py
import boto3
import decimal
import json
import os
import requests
param = {}
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をモックしてテストを実行します。
tests/unit/test_handler.py
import boto3
import json
import pytest
from moto import mock_dynamodb2
import os
region = os.getenv('AWS_DEFAULT_REGION', 'ap-northeast-1')
table_name = 'mock'
os.environ['TABLE_NAME'] = table_name
from hello_world import app
@mock_dynamodb2
class 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の調査が目的なので、外部サービスを使用しているという観点から便宜上これを結合テストとします。
import boto3
import json
import pytest
import os
region = os.getenv('AWS_DEFAULT_REGION', 'ap-northeast-1')
table_name = 'mock'
os.environ['TABLE_NAME'] = table_name
from hello_world import app
class 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を作成しました。
.gitlab-ci.yml
image: python:3.6.5
variables:
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
- deploy
unittest:
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:4569
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
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: develop
deploy_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つ見ていきます。
image: python:3.6.5
variables:
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
stages:
- unittest
- integrate_test
- package
- deploy
stages
は上記のように定義しました。
各ステージで
- 単体テスト
- 結合テスト
- デプロイパッケージの作成
- デプロイ実行
を行います。
unittestステージ
unittest:
stage: unittest
script:
- pip install pytest moto
- pip install -r requirements.txt
- python -m pytest tests/unit -v
ここは特に特筆すべきことはありません。前回のエントリ同様です。
integrate_testステージ
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ステージ
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ステージ
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: develop
deploy_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という文字列に変更します。 また、この修正に伴ってテストが通らなくなるので、テストコードも適宜コメントアウトしてしまいます。
hello_world/app.py
#...略
def lambda_handler(event, context):
res = table.scan()
return {
"statusCode": 200,
"body": "hello"
}
変更できたらそのままプッシュ〜デプロイまで一連の処理を実行し、curlコマンドで動作確認してみます。
curl https://xxxxxx.execute-api.ap-northeast-1.amazonaws.com/Prod/hello/
hello
レスポンスがhelloに変わっているのが確認できました。 このデプロイに不具合が含まれていたと仮定して、GitLabの画面から1つ前のデプロイにロールバックしてみます。 デプロイの履歴はOperationsのEnviromentsから確認が可能です。
ここのRollbackをクリックすることで、デプロイのロールバックが可能です。 ロールバックできたら再度curlコマンドで確認してみます。
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をお試し下さい!!