GitLab RunnerでCI/CDしてみる(後編)

GitHubやCodeCommitと並んでソースコード管理に利用されることの多いGitLab GitLab環境でCI/CDを回す手法について調査してみました。 この後編では前編では触れなかった各種の機能を使って、より実践的なCI/CDの環境を構築していきます。
2018.07.03

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

はじめに

サーバーレス開発部@大阪の岩田です。 前回に引き続き、GitLab Runnerを使ったCI/CDについて見ていきます。 今回は前回の設定を発展させて、より実践的なCI/CD環境を構築していきます。

前回のエントリはこちらです↓

GitLab Runnerで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は上記のように定義しました。 各ステージで

  1. 単体テスト
  2. 結合テスト
  3. デプロイパッケージの作成
  4. デプロイ実行

を行います。

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に関してはwhenmanualに設定して自動実行の対象外としています。 さらにenvironmentで本番環境とテスト環境それぞれにproductiondevelopという名前を付けています。

scriptの中身が重複だらけなので、実際に業務で利用する際は、Makefileを活用するなどしてうまく共通化すべきでしょう。

make は強いタスクランナーだった。Lambda Function のライフサイクルを 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をお試し下さい!!