この記事は DMM.com Advent Calendar 2018 - Qiita の25日目です。
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環境
ALBの後ろにECSがある単純な構成です。
今回は本番構成を想定しているので、NAT GatewayやVPC FlowLogも含めました。
また、弊社では各プロジェクト毎にAWSアカウントを2つ用意しているのでそれも想定して構築していきます。
なぜAWSアカウントが2つあるかと言うと"本番環境用"と"本番以外の環境"に用いるためです。
本番以外のステージングや開発環境でのオペミスが本番に波及することを防止することが主な目的ですね。
CI/CDフロー
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つです
- phpunitによるテスト
- phpunitによるテスト(developブランチをマージ)
- ステージングアカウントのECRへdockerのpush
- 本番アカウントのECRへdockerのpush
CircleCIのコンフィグは以下です。
ci-cd-example/.circleci/config.yml
それぞれ解説していきます。
1. phpunitによるテスト
後述の 2. phpunitによるテスト(developブランチをマージ)
と重複箇所が多いのでアンカーを使っています。
行っていることをざっくりまとめると以下のとおりです。
- phpとmysqlのdocker環境を用意
- CircleCIはdockerとmachineの2環境を選べるのですが、Enterpriseを管理している方いわくdockerの方が安定性が高いらしいのでdockerを使っていきます。
- composerのインストールとキャッシュ
- Enterpriseの場合EBSボリュームにキャッシュが保存されるので、ビルドによってキャッシュが使用されないことがあるそうです。
- dockerizeやmigrateなどの初期設定
- dockerizeはdockerの起動を待機するためのコマンドです。
- 今回の場合MySQLが起動しきるのを待つために使用します。
- phpunitの実行とカバレッジの出力
- カバレッジをArtifactへ保存
- Artifactへ保存したものはWeb上へホストすることが可能です
- カバレッジのようなhtmlで出力されるものを保存すると素直に閲覧できるのでオススメです
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をうまく使って並列で動かすと良いですね。
3. ステージングアカウントのECRへdockerのpush
こちらも 4. 本番アカウントのECRへdockerのpush
と重複箇所が多いのでアンカーで定義していきます。
アンカーで定義することでjobsでは environment
だけ定義すればよいのが気持ちいですね。
ここでCircleCIのAWS Permissionsではなく何故 AWS_STG_*
のようにしているかというと、
先述したとおり各プロジェクト毎にAWSアカウントが2つあるため、それぞれのECRへDockerイメージを配布するために複数環境のアクセスキーを持つためです。
なお、環境変数としてアクセスキーを定義するため、コンフィグから定義は必須になります。
- &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'
完成
雑記
本当はCodeDeployのB/GデプロイでCDをやりたかった。
近い内にB/Gに対応させます。