AWSにおける本番環境を想定したCI/CD実践

この記事は DMM.com Advent Calendar 2018 - Qiita の25日目です。

今日は KINGDOM HEARTS III の発売日のちょうど一ヶ月前ですね。わくわくですね。

About

CircleCIとCode兄弟を使いCI/CDを作っていこうというものです。
単純に環境を作るわけではなく、CloudFormationを使って本番環境を想定した構成にしていきます。

ターゲットとしては既にCircleCI・CodePipelineをざっくり知っている人で、本番向けのCI/CDをどのように構築していくかについて自分なりのプラクティスを紹介します。

サンプルコード

今回の構成を再現するためのコードを用意しました。
https://github.com/y-ohgi/ci-cd-example

CloudFormation

環境はCloudFormationで管理しているため以下のコマンドで再現できます。

VPC、Aurora、ECS、CodePipelineなど、今回必要なサービスが全てデプロイされます。
CloudFormationのベストプラクティス的にはテンプレートを分けたほうが良いのですが、今回はサンプルなので1枚のテンプレートに収めました。

# リポジトリのclone
$ git clone https://github.com/y-ohgi/ci-cd-example
$ cd ci-cd-example

# dockerのビルドとpush
$ aws ecr create-repository --repository-name nginx
$ aws ecr create-repository --repository-name web
$ NGINX_IMAGE=$(aws ecr describe-repositories --repository-name nginx | jq -r '.repositories[0].repositoryUri')
$ WEB_IMAGE=$(aws ecr describe-repositories --repository-name web | jq -r '.repositories[0].repositoryUri')
$ docker build -t ${NGINX_IMAGE} -f docker/nginx/Dockerfile .
$ docker build -t ${WEB_IMAGE} .
$ docker push ${NGINX_IMAGE}
$ docker push ${WEB_IMAGE}

# AWS環境のデプロイ
$ aws cloudformation deploy \
    --stack-name ci-cd-example \
    --template-file ./scripts/cloudformation.yaml \
    --capabilities CAPABILITY_NAMED_IAM

あとはCircleCIの設定をよしなに(後述)

主なコンポーネント

  • ECS
    • Laravel/Nginx
  • Circle CI
  • CodePipeline
  • CodeBuild

AWS環境

f:id:y-ohgi:20181226024513j:plain

ALBの後ろにECSがある単純な構成です。
今回は本番構成を想定しているので、NAT GatewayVPC FlowLogも含めました。

また、弊社では各プロジェクト毎にAWSアカウントを2つ用意しているのでそれも想定して構築していきます。
なぜAWSアカウントが2つあるかと言うと"本番環境用"と"本番以外の環境"に用いるためです。
本番以外のステージングや開発環境でのオペミスが本番に波及することを防止することが主な目的ですね。

CI/CDフロー

f:id:y-ohgi:20181226024526p:plain

GitHubの変更をCircleCIでテストを回し、ECRへ配布し、CodePipelineでデプロイを実行します。

弊社ではGitHub EnterpriseとCircleCI Enterpriseが使用可能なため、この2つを想定します。
AWSでCDをしたいとなると公式のCode兄弟が手軽なので採用します。

CI

今回はLaravelでプロジェクトを作成しました。理由は最近仕事で使ってるから。

Docker環境

例のごとくDockerで構築を行ったため、CIの前にまずはDocker環境の説明からします。

LaravelのDockerfile

xdebug含めるか・composerのマルチステージビルドを行うかは悩みどころだと思いますが、入れてしまいます。
この2つを含めた場合Dockerイメージはだいたい200MBほど増えるのですが、AWSのECS環境でこの200MBが致命的な差になるわけではないことと、ローカル環境と本番環境をなるべく近づけたいことが理由です。

# Dockerfile
FROM php:7.2-fpm-alpine

ARG UID=991
ARG UNAME=www
ARG GID=991
ARG GNAME=www

ENV WORKDIR=/var/www/html
WORKDIR $WORKDIR

COPY . .

RUN set -x \
  && apk add --no-cache php7-zlib zlib-dev ${PHPIZE_DEPS} \
  && pecl install xdebug \
  && docker-php-ext-install pdo_mysql zip \
  && docker-php-ext-enable xdebug \
  && curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer \
  && composer install \
  && addgroup ${GNAME} -g ${GID} \
  && adduser -D -G ${GNAME} -u ${UID} ${UNAME} \
  && chown -R ${UNAME}:${GNAME} $WORKDIR \
  && apk del --purge autoconf g++ make

USER ${UNAME}

nginxのDockerfile

nginxはdefault.confとnginx.confを用意してコピーするだけのものです。

特筆するほどのことでもないのですが、個人的にnginxに環境変数を埋め込む場合は envsubst ではなく sed を使います。
nginxの場合 $ は頻出し、 envsubst のために $エスケープを行うのが煩わしいのが理由です。

# docker/nginx/default.conf.template
    location ~ [^/]\.php(/|$) {
        fastcgi_pass            ${PHP_HOST}:9000;
  :

 

# Dockerfile
FROM nginx:1.15-alpine

# ECSのコンテナ間通信はDNSではなくlocalhost経由で行われる
ENV PHP_HOST=127.0.0.1

COPY public /var/www/html/public
COPY docker/nginx/default.conf.template /etc/nginx/conf.d/default.conf.template

EXPOSE "80"

CMD /bin/sh -c 'sed "s/\${PHP_HOST}/$PHP_HOST/" /etc/nginx/conf.d/default.conf.template  > /etc/nginx/conf.d/default.conf && nginx -g "daemon off;"'

docker-compose

同じく特筆するようなことはしてないです。
強いて言えばボリュームのマウントは用途に応じて :cached:delegated を使うとファイル共有が高速になるのでオススメです。

version: '3.5'

services:
  nginx:
    build:
      context: .
      dockerfile: docker/nginx/Dockerfile
    ports:
      - '8080:80'
    depends_on:
      - web
    environment:
      PHP_HOST: web
  web:
    build:
      context: .
      dockerfile: Dockerfile
    volumes:
      - .:/var/www/html:cached
  mysql:
    image: mysql:5.7
    ports:
      - '13306:3306'
    volumes:
      - mysql:/var/lib/mysql:delegated
      - ./docker/mysql/init:/docker-entrypoint-initdb.d:ro
    command: mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
    environment:
      MYSQL_DATABASE: 'example'
      MYSQL_ALLOW_EMPTY_PASSWORD: 'yes'

volumes:
  ? mysql

起動

Laravelは起動時にmigrateやcomposer installが必要なので、それを実行するためのスクリプトcompsoer.json へ記載します。

    "scripts": {
        "local-init": [
            "@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
            "composer install",
            "composer dump-autoload",
            "@php artisan migrate"
        ],
  :

実行

# ローカル環境構築用のcomposerのインストールやmigrateの実行スクリプト
$ docker-composer run web composer local-init
$ docker-compose up

CircleCI

さて、CIの本題のCircleCIについてです。
CircleCIで行うこととしては以下の4つです

  1. phpunitによるテスト
  2. phpunitによるテスト(developブランチをマージ)
  3. ステージングアカウントのECRへdockerのpush
  4. 本番アカウントのECRへdockerのpush

CircleCIのコンフィグは以下です。
ci-cd-example/.circleci/config.yml

それぞれ解説していきます。

1. phpunitによるテスト

後述の 2. phpunitによるテスト(developブランチをマージ) と重複箇所が多いのでアンカーを使っています。

行っていることをざっくりまとめると以下のとおりです。

  • phpmysqlのdocker環境を用意
    • CircleCIはdockerとmachineの2環境を選べるのですが、Enterpriseを管理している方いわくdockerの方が安定性が高いらしいのでdockerを使っていきます。
  • composerのインストールとキャッシュ
    • Enterpriseの場合EBSボリュームにキャッシュが保存されるので、ビルドによってキャッシュが使用されないことがあるそうです。
  • dockerizeやmigrateなどの初期設定
    • dockerizeはdockerの起動を待機するためのコマンドです。
    • 今回の場合MySQLが起動しきるのを待つために使用します。
  • phpunitの実行とカバレッジの出力
  • カバレッジをArtifactへ保存
    • Artifactへ保存したものはWeb上へホストすることが可能です
    • カバレッジのようなhtmlで出力されるものを保存すると素直に閲覧できるのでオススメです

f:id:y-ohgi:20181226024550p:plain CircleCIでArtifactにphpunitカバレッジを保存した例

anchors:
  - &test_environment
    docker:
      - image: circleci/php:7.2-browsers
      - image: circleci/mysql:5.7
        environment:
          MYSQL_DATABASE: testing
    environment:
      DB_HOST: 127.0.0.1

  - &restore_cache
    restore_cache:
      keys:
        - ci-cd-example-{{ checksum "composer.lock" }}
        - ci-cd-example-

  - &composer_install
    run: composer install

  - &save_cache
    save_cache:
      key: ci-cd-example-{{ checksum "composer.lock" }}
      paths:
        - vendor

  - &initialize
    run:
      name: initialize
      command: |
        DOCKERIZE_VERSION=v0.6.1
        wget https://github.com/jwilder/dockerize/releases/download/$DOCKERIZE_VERSION/dockerize-linux-amd64-$DOCKERIZ
E_VERSION.tar.gz
        sudo tar -C /usr/local/bin -xzvf dockerize-linux-amd64-$DOCKERIZE_VERSION.tar.gz
        sudo chmod +x /usr/local/bin/dockerize
        rm dockerize-linux-amd64-$DOCKERIZE_VERSION.tar.gz
        dockerize -wait tcp://${DB_HOST}:3306 -timeout 1m

        sudo docker-php-ext-install pdo_mysql

        cp .env.testing .env
        composer dump-autoload
        php artisan migrate

  - &test
    run: composer test

jobs:
  test:
    <<: *test_environment
    steps:
      - checkout
      - *restore_cache
      - *composer_install
      - *save_cache
      - *initialize
      - *test
      - store_artifacts:
          path: tmp/coverage

2. phpunitによるテスト(developブランチをマージ)

PRを出したとき、そのコミットだけではなくdevelopブランチともマージしてtestを行います。
複数人で開発しているときdevelopブランチとfeatureブランチがコンフリクトしていなくても、featureブランチでの変更外でデグレが起こることがあります。
それを回避するためにdevelopブランチをマージしてtestを実行します。

  # developブランチをマージしてtestを実行する
  merge_test:
    <<: *test_environment
    steps:
      - checkout
      # ここでmergeを行う
      - run: git merge develop
      - *restore_cache
      - *composer_install
      - *save_cache
      - *initialize
      - *test

特定コミットへのtestとは競合しないので、workflowをうまく使って並列で動かすと良いですね。

f:id:y-ohgi:20181226024630p:plain

3. ステージングアカウントのECRへdockerのpush

こちらも 4. 本番アカウントのECRへdockerのpush と重複箇所が多いのでアンカーで定義していきます。
アンカーで定義することでjobsでは environment だけ定義すればよいのが気持ちいですね。

ここでCircleCIのAWS Permissionsではなく何故 AWS_STG_* のようにしているかというと、
先述したとおり各プロジェクト毎にAWSアカウントが2つあるため、それぞれのECRへDockerイメージを配布するために複数環境のアクセスキーを持つためです。

なお、環境変数としてアクセスキーを定義するため、コンフィグから定義は必須になります。

f:id:y-ohgi:20181226024643p:plain

  - &delivery_image
    docker:
      - image: circleci/python:3
    steps:
      - checkout
      - setup_remote_docker
      - run:
          name: ecr login
          command: |
            sudo pip install awscli
            aws configure set aws_access_key_id $(eval echo $AWS_ACCESS_KEY)
            aws configure set aws_secret_access_key $(eval echo $AWS_SECRET_KEY)
            $(aws ecr get-login --no-include-email --region ap-northeast-1)
      - run:
          name: nginx image
          command: |
            IMAGE_TAG=$(echo ${CIRCLE_SHA1} | cut -c 1-6)
            docker build \
              -t $(eval echo $AWS_ACCOUNT_ID).dkr.ecr.ap-northeast-1.amazonaws.com/nginx:${IMAGE_TAG} \
              -f docker/nginx/Dockerfile \
              .
            docker push $(eval echo $AWS_ACCOUNT_ID).dkr.ecr.ap-northeast-1.amazonaws.com/nginx:${IMAGE_TAG}
      - run:
          name: web image
          command: |
            IMAGE_TAG=$(echo ${CIRCLE_SHA1} | cut -c 1-6)
            docker build \
              -t $(eval echo $AWS_ACCOUNT_ID).dkr.ecr.ap-northeast-1.amazonaws.com/web:${IMAGE_TAG} \
              .
            docker push $(eval echo $AWS_ACCOUNT_ID).dkr.ecr.ap-northeast-1.amazonaws.com/web:${IMAGE_TAG}
  :
  delivery_stg:
    <<: *delivery_image
    environment:
      AWS_ACCOUNT_ID: $AWS_STG_ACCOUNT_ID
      AWS_ACCESS_KEY: $AWS_STG_ACCESS_KEY
      AWS_SECRET_KEY: $AWS_STG_SECRET_KEY

4. 本番アカウントのECRへdockerのpush

こちらはSTGと同様のアンカーを使って environment でアクセスするAWSアカウントを切り替えます。

  delivery_prod:
    <<: *delivery_image
    environment:
      AWS_ACCOUNT_ID: $AWS_PROD_ACCOUNT_ID
      AWS_ACCESS_KEY: $AWS_PROD_ACCESS_KEY
      AWS_SECRET_KEY: $AWS_PROD_SECRET_KEY

CI環境の完成

これで大まかにCIからECRへのデプロイが完成しました。
次にCDを見ていきましょう。

CD

先述したとおりCodePieplineでデプロイを行います。
ソースにはECRを使用し、CodeBuildでCodePipeline用の定義ファイルを作成し、CodePipelineでデプロイを行います

Source

先述したとおりECRをSourceとして扱います。
re:Inventで公開されたばかりの機能ですね。

地味にこれの扱いづらいところとしてECRをCodePipelineのSourceとした場合、イメージが使えないことです。
例えばCodeCommitやS3をSourceとした場合はコードがSourceとして扱えるのですが、ECRの場合以下の imageDetail.json という定義ファイルがSourceの実体になります。

{
  "ImageSizeInBytes":"112307790",
  "ImageDigest":"sha256:8ecf232dd3c352880db55f6b7cd4b911c140ab635407f599d94b26ec640ba154",
  "Version":"1.0",
  "ImagePushedAt":"Mon Dec 24 14:34:28 UTC 2018",
  "RegistryId":"856925507022",
  "RepositoryName":"web",
  "ImageURI":"856925507022.dkr.ecr.ap-northeast-1.amazonaws.com/web@sha256:8ecf232dd3c352880db55f6b7cd4b911c140ab635407f599d94b26ec640ba154",
  "ImageTags":["latest"]
}

ImageURI でpull先のURIが帰ってきているので、これを利用することで初めてイメージ内のコードを扱うことができます。

CodeBuild

先述したとおりCodePipelineのSourceにECRを設定すると imageDetail.json という定義ファイルが帰ってきます。
このままだとCodePipelineからECSへデプロイする定義ファイルが用意できないため、CodeBuildでECSデプロイ用の定義ファイルを用意します。

従来であれば buildspec.yaml を用意すれば良いのですが、何度も言いますがECRの場合 imageDetail.json しか生成されません。
そのため以下のようにCloudFormation上から buildspec.yaml の定義を直接行い、ECSデプロイ用の定義ファイル( imageManifest.json )を生成します。

  CodeBuildProject:
    Type: 'AWS::CodeBuild::Project'
    Properties:
      Name: !Sub '${StackPrefix}'
      ServiceRole: !GetAtt CodeBuildRole.Arn
      Environment:
        Type: 'LINUX_CONTAINER'
        ComputeType: 'BUILD_GENERAL1_SMALL'
        Image: 'aws/codebuild/docker:17.09.0'
        PrivilegedMode: true
      Source:
        Type: 'CODEPIPELINE'
        BuildSpec: |
            version: 0.2
            phases:
              build:
                commands:
                  - sudo apt -y update && sudo apt -y install jq
                  - printf '[{"name":"web","imageUri":"%s"}]' $(cat imageDetail.json | jq -r '.ImageURI') > imageManifest.json
            artifacts:
              files:
                - imageManifest.json
      Artifacts:
        Type: 'CODEPIPELINE'
      TimeoutInMinutes: 30

CodePipelineの定義とECSへのデプロイ

ECRをSourceとし、CodeBuildでCodePipelineのECSデプロイ定義ファイルを生成し、CodePipelineからECSへデプロイを実行する3ステップです。

CloudFormationのテンプレートは以下です。

  CodePipeline:
    Type: 'AWS::CodePipeline::Pipeline'
    Properties:
      ArtifactStore:
        Location: !Ref CodePipelineArtifactBucket
        Type: 'S3'
      Name: !Sub '${StackPrefix}-Pipeline'
      RoleArn: !GetAtt CodePipelineRole.Arn
      Stages:
        - Name: 'Source'
          Actions:
            - Name: 'Source'
              RunOrder: 1
              ActionTypeId:
                Category: 'Source'
                Owner: 'AWS'
                Provider: 'ECR'
                Version: '1'
              Configuration:
                RepositoryName: 'web'
              OutputArtifacts:
                - Name: 'Source'

        - Name: 'Build'
          Actions:
            - Name: 'Build'
              RunOrder: 1
              InputArtifacts:
                - Name: 'Source'
              ActionTypeId:
                Category: 'Build'
                Owner: 'AWS'
                Provider: 'CodeBuild'
                Version: '1'
              Configuration:
                ProjectName: !Ref CodeBuildProject
              OutputArtifacts:
                - Name: 'Build'

        - Name: 'Deploy'
          Actions:
            - Name: 'Deploy'
              RunOrder: 1
              InputArtifacts:
                - Name: 'Build'
              ActionTypeId:
                Category: 'Deploy'
                Owner: 'AWS'
                Provider: 'ECS'
                Version: '1'
              Configuration:
                ClusterName: !Ref EcsCluster
                ServiceName: !GetAtt EcsService.Name
                FileName: 'imageManifest.json'

完成

f:id:y-ohgi:20181226024711p:plain

雑記

本当はCodeDeployのB/GデプロイでCDをやりたかった。
近い内にB/Gに対応させます。