AWS CodeDeployを導入してみた

## はじめに
弊社サービスでは基本的にRailsをEC2インスタンス上で動かすという構成をとっています。
デプロイはcircleci + capistranoという定番の構成で行っていたものの、サービスの規模が大きくなるにつれ、

1. デプロイ時間が遅い
→assets:precompileに時間がかかり、合計で20分程度かかる
1. デプロイ負荷が高い
→assets:precompileのタイミングでCPU負荷が100%近くまで上昇する

という課題が顕著となり、開発効率の低下やデプロイのためだけのサーバースペックの増強が生じていました。
これを解消するために、コンパイルをサーバー上で行うのではなく、別環境でコンパイルされたソースをサーバーに適用するという方法でデプロイを行うこととしました。

## CodePipelineに寄せるか否か
「別環境でコンパイルされたソースをサーバーに適用する」を実現するために、大きく以下二つの選択肢を考えました。

1. CodePipelineのワークフローを駆使し、ビルドデプロイフローを全体的にAWSで制御するパターン
1. デプロイのみCodeDeployを使い、ビルドは引き続きcircleciに委ねるパターン

このどちらにするかは好みの範疇だと思います。
Code~は全体的に癖が強く、スイッチングコストが高くなりそうだったため2（ビルドとデプロイ起動はcircleciで運用する）のパターンとしました。

## 構成
以上を踏まえ、以下図の構成となりました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/139602/b28297a5-6028-4eaf-eed1-eb198218de18.png)

流れとしては以下のフローとなります
1. githubの特定のブランチへのプッシュ駆動でcircleciのワークフローが開始される
1. circleciのビルド/テストジョブが実行され、ビルド成果物がS3に格納される
1. circleciのデプロイジョブが実行され、CodeDeployが実行される
1. CodeDeploy定義によって、S3に格納されたビルド成果物が指定される
1. CodeDeployによって、S3に格納されたビルド成果物がEC2に配置される。CodeDeploy定義に従って、プロセスの再起動などのスクリプト実行が行われる。

上記を実現するための手順を記載していきます。

## AWSリソースの設定
前段の準備として、以下AWSリソースの設定をします

- CodeDeploy
  - アプリケーション作成（ここでは「app」とします）
  - デプロイグループを作成（ここでは「app-staging」「app-production」とします）
- EC2へのDeployGroupタグ設定
- IAMロール作成

手順の記載は省きますが、以下記事を参考にしてください
https://qiita.com/tonishy/items/a9899a1b3d3f85f2beb0

## circleciでのビルド設定
circleciの設定ファイルについて説明します。

### setup_environment
CodeDeployを使ってみてつまづいたポイントの一つとして、環境ごとにジョブの実行を制御する方法がありました。
例えばmasterブランチへのプッシュ駆動ではステージング環境へのデプロイを実行し、production-deploy（本番デプロイ用のブランチ）へのプッシュ駆動では本番環境へのデプロイを実行したいケースです。
これを実現するにはいくつか方法があるのですが、今回はCIRCLE_BRANCHというcircleciが提供する環境変数によって、デプロイを実行するデプロイグループを環境変数として定義するという方法としました。

```
commands:
  setup_environment:
    steps:
      - run:
          name: Setup Environment
          command: |
            # define Deploy Group
            if [ "${CIRCLE_BRANCH}" == "production" ]; then
              echo 'export DEPLOY_GROUP=app-production' >> $BASH_ENV
            elif [ "${CIRCLE_BRANCH}" == "master" ]; then
              echo 'export DEPLOY_GROUP=app-staging' >> $BASH_ENV
            fi
      - run:
          name: Confirm
          command: |
            echo $DEPLOY_GROUP
```

### bundle install
bundle installを行います。特別なことはしておらず、一般的なジョブかと思います。
```
commands:
  configure_bundler:
    steps:
      - run:
          name: Configure Bundler
          command: |
            echo 'export BUNDLER_VERSION=$(cat Gemfile.lock | tail -1 | tr -d " ")' >> $BASH_ENV
            source $BASH_ENV
            gem install bundle
            bundle -v
  bundle_install:
    steps:
      - restore_cache:
          keys:
            - rails-bundle-v1-{{ checksum "Gemfile.lock" }}
            - rails-bundle-v1-
      - run:
          name: Bundle Install
          command: bundle check || bundle install -j4 --path vendor/bundle --clean
      - save_cache:
          key: rails-bundle-v1-{{ checksum "Gemfile.lock" }}
          paths:
            - vendor/bundle
```

### yarn install
yarn installを行います。これも同様に特別なことはしておらず、一般的なジョブかと思います。

```
commands:
  yarn_install:
    steps:
      - restore_cache:
          keys:
            - rails-demo-yarn-v3-{{ checksum "yarn.lock" }}
            - rails-demo-yarn-v3-
      - run:
          name: Yarn Install
          command: yarn install --cache-folder ~/.cache/yarn
      - save_cache:
          key: rails-demo-yarn-v3-{{ checksum "yarn.lock" }}
          paths:
            - ~/.cache/yarn
            - node_modules
```

### assets:precompile
assets:precompileを行います。これについてはassets:precompile対象のファイルのrevisionを確認し、変更があったときだけコンパイルを実行させることで無駄なコンパイルをスキップさせています

```
commands:
  assets_precompile:
    steps:
      # precompile対象のassetsのgitのrevisionをkeyにする
      - run:
          name: create key for cashing assets
          command: |
            git rev-parse $(git log --oneline -n 1 app/assets app/javascript lib/assets vendor/assets | awk '{{print $1}}') > VERSION
      - restore_cache:
          keys:
            - assets-cache-v1-{{ .Branch }}-{{ checksum "VERSION" }}
            - assets-cache-v1-{{ .Branch }}
            - assets-cache
      - run:
          name: assets:precompile
          # assetsのrevisionが変わってなければキャッシュから使う。変わっていればprecompile
          command: |
            export current_revision=VERSION
            export previous_revision=public/assets/VERSION

            if [ ! -e $previous_revision ] || ! diff $previous_revision $current_revision; then
              bundle exec rake assets:precompile
              cp -f VERSION public/assets/VERSION
            else
              echo "Skipped."
            fi
      - save_cache:
          key: assets-cache-v1-{{ .Branch }}-{{ checksum "VERSION" }}
          paths:
            - public/assets
            - public/packs
            - tmp/cache/assets
```

### ビルドジョブの作成
上記のcommandsを組み合わせてビルドのためのジョブを作成します

```
jobs:
  build:
    <<: *defaults
    executor: default
    resource_class: xlarge
    steps:
      - checkout
      - setup_environment
      - configure_bundler
      - bundle_install
      - yarn_install
      - assets_precompile
      - run: sudo apt install pigz # <- マルチコアでtarコマンドを使えるようにするライブラリ
      - run:
          name: Setup CodeBuild sources
          command: |
            mkdir -p ~/cache
            tar -c -I pigz -f ~/cache/$DEPLOY_GROUP.tar.gz -X .circleci/.tarignore .
      - persist_to_workspace:
          root: ~/cache
          paths:
            # persist_to_workspaceではファイル名を変数展開できないため'*.tar.gz'とする
            - '*.tar.gz'
```

以上でビルドされたソースを圧縮し、persist_to_workspaceでワークフローにファイルを保存するところまでできました。

## circleciでのCodeDeploy実行設定
先ほど保存した成果物をもとに、S3へのファイル転送およびCodeDeployの起動を行います

### デプロイジョブの作成
定義としては以下のようになります。ビルドジョブで実行したsetup_environmentで定義された$DEPLOY_GROUP変数によって、デプロイグループを指定しています。

```
  deploy:
    docker:
      - image: circleci/python:3.8
    steps:
      - attach_workspace:
          at: .
      - setup_environment
      - run:
          name: Install AWS CLI
          command: |
            sudo pip install awscli
      - run:
          name: Upload to S3
          command: |
            aws s3 cp ./$DEPLOY_GROUP.tar.gz s3://app-assets/
      - run:
          name: Deploy with CodeDeploy
          command: |
            aws deploy create-deployment \
              --application-name app \
              --deployment-config-name CodeDeployDefault.OneAtATime \
              --deployment-group-name $DEPLOY_GROUP \
              --s3-location bucket=app-assets,key=$DEPLOY_GROUP.tar.gz,bundleType=tgz
```

## circleci定義まとめ

以上をまとめると以下のファイルになります
```config.yml
version: 2.1 # use CircleCI 2.1

orbs:
  aws-cli: circleci/aws-cli@2.0.3

defaults: &defaults
  parallelism: 1
  working_directory: ~/app

executors:
  default:
    docker:
      - image: cimg/ruby:2.7.3-node
        environment:
          BUNDLER_VERSION: 2.3.14
          BUNDLE_JOBS: 3
          BUNDLE_RETRY: 3
          BUNDLE_PATH: vendor/bundle
          RAILS_ENV: test
commands:
  setup_environment:
    steps:
      - run:
          name: Setup Environment
          command: |
            # define Deploy Group
            if [ "${CIRCLE_BRANCH}" == "production" ]; then
              echo 'export DEPLOY_GROUP=app-production' >> $BASH_ENV
            elif [ "${CIRCLE_BRANCH}" == "master" ]; then
              echo 'export DEPLOY_GROUP=app-staging' >> $BASH_ENV
            fi
      - run:
          name: Confirm
          command: |
            echo $DEPLOY_GROUP
  configure_bundler:
    steps:
      - run:
          name: Configure Bundler
          command: |
            echo 'export BUNDLER_VERSION=$(cat Gemfile.lock | tail -1 | tr -d " ")' >> $BASH_ENV
            source $BASH_ENV
            gem install bundle
            bundle -v
  bundle_install:
    steps:
      - restore_cache:
          keys:
            - rails-bundle-v1-{{ checksum "Gemfile.lock" }}
            - rails-bundle-v1-
      - run:
          name: Bundle Install
          command: bundle check || bundle install -j4 --path vendor/bundle --clean
      - save_cache:
          key: rails-bundle-v1-{{ checksum "Gemfile.lock" }}
          paths:
            - vendor/bundle
  yarn_install:
    steps:
      - restore_cache:
          keys:
            - rails-demo-yarn-v3-{{ checksum "yarn.lock" }}
            - rails-demo-yarn-v3-
      - run:
          name: Yarn Install
          command: yarn install --cache-folder ~/.cache/yarn
      - save_cache:
          key: rails-demo-yarn-v3-{{ checksum "yarn.lock" }}
          paths:
            - ~/.cache/yarn
            - node_modules
  assets_precompile:
    steps:
      # precompile対象のassetsのgitのrevisionをkeyにする
      - run:
          name: create key for cashing assets
          command: |
            git rev-parse $(git log --oneline -n 1 app/assets app/javascript lib/assets vendor/assets | awk '{{print $1}}') > VERSION
      - restore_cache:
          keys:
            - assets-cache-v1-{{ .Branch }}-{{ checksum "VERSION" }}
            - assets-cache-v1-{{ .Branch }}
            - assets-cache
      - run:
          name: assets:precompile
          # assetsのrevisionが変わってなければキャッシュから使う。変わっていればprecompile
          command: |
            export current_revision=VERSION
            export previous_revision=public/assets/VERSION

            if [ ! -e $previous_revision ] || ! diff $previous_revision $current_revision; then
              bundle exec rake assets:precompile
              cp -f VERSION public/assets/VERSION
            else
              echo "Skipped."
            fi
      - save_cache:
          key: assets-cache-v1-{{ .Branch }}-{{ checksum "VERSION" }}
          paths:
            - public/assets
            - public/packs
            - tmp/cache/assets
jobs:
  build:
    <<: *defaults
    executor: default
    steps:
      - checkout
      - configure_bundler
      - bundle_install
      - yarn_install
  build:
    <<: *defaults
    executor: default
    resource_class: xlarge
    steps:
      - checkout
      - setup_environment
      - configure_bundler
      - bundle_install
      - yarn_install
      - assets_precompile
      - run: sudo apt install pigz # <- マルチコアでtarコマンドを使えるようにするライブラリ
      - run:
          name: Setup CodeBuild sources
          command: |
            mkdir -p ~/cache
            tar -c -I pigz -f ~/cache/$DEPLOY_GROUP.tar.gz -X .circleci/.tarignore .
      - persist_to_workspace:
          root: ~/cache
          paths:
            # persist_to_workspaceではファイル名を変数展開できないため'*.tar.gz'とする
            - '*.tar.gz'
  deploy:
    docker:
      - image: circleci/python:3.8
    steps:
      - attach_workspace:
          at: .
      - setup_environment
      - run:
          name: Install AWS CLI
          command: |
            sudo pip install awscli
      - run:
          name: Upload to S3
          command: |
            aws s3 cp ./$DEPLOY_GROUP.tar.gz s3://app-assets/
      - run:
          name: Deploy with CodeDeploy
          command: |
            aws deploy create-deployment \
              --application-name app \
              --deployment-config-name CodeDeployDefault.OneAtATime \
              --deployment-group-name $DEPLOY_GROUP \
              --s3-location bucket=app-assets,key=$DEPLOY_GROUP.tar.gz,bundleType=tgz
workflows:
  version: 2
  build-test-and-deploy:
    jobs:
      - build
      - deploy:
          requires:
            - build
```

## CodeDeploy定義設定
続いてCodeDeployの定義を作成します。CodeDeployではデプロイ対象のソースのルートディレクトリに配置されている`appspec.yml`という名前のファイルを定義ファイルとする作法となっています。
筆者はAfterInstallというソースが配置された後に実行されるステップにおいて、プロセスの再起動を行うこととしています。
以下スクリプトは環境によって必要有無が異なると思いますが、参考として記載します。

```appspec.yml
version: 0.0
os: linux
files:
  - source: / # 全てのファイルおよびディレクトリをコピーする
    destination: /var/www/app/current # デプロイ先
hooks:
  # ApplicationStop:
  AfterInstall:
    - location: scripts/setup.sh
      timeout: 300
      runas: app
    - location: scripts/switch_symlink.sh
      timeout: 300
      runas: app
    - location: scripts/stop_server.sh
      timeout: 300
      runas: app
    - location: scripts/update_crontab.sh
      timeout: 300
      runas: app
  ApplicationStart:
    - location: scripts/start_server.sh
      timeout: 300
      runas: app
```

### setup.sh
ディレクトリの権限やbundle installを行なっています

```setup.sh
#!/bin/bash
cd /var/www/app/current
sudo chown -R app:app /var/www/app/current

# bundler:config
~/.rbenv/bin/rbenv exec bundle config set --local deployment true
~/.rbenv/bin/rbenv exec bundle config set --local path /var/www/app/shared/bundle
~/.rbenv/bin/rbenv exec bundle config set --local without development:test

# bundler:install
~/.rbenv/bin/rbenv exec bundle install --path /var/www/app/shared/bundle

```

### common.sh
後続のスクリプトから共通で呼び出されるスクリプトです。
特筆すべき点としては、あらかじめRoleというタグでEC2の役割（web,batchなど）を定義しておき、そのタグを取得することで対象とするプロセスの切り替えをしています。
また、setup.shで定義したbundler configは別スクリプトに引きまわされないので、ここで共通的に定義することとしています。

```
#!/bin/bash
cd /var/www/app/current

# define Deploy Group
if [ "${DEPLOYMENT_GROUP_NAME}" == "app-staging" ]; then
  export RAILS_ENV=staging
else
  export RAILS_ENV=production
fi

# define Role
export INSTANCE_ID=$(curl -s http://169.254.169.254/latest/meta-data/instance-id)
export REGION=$(curl -s http://169.254.169.254/latest/meta-data/placement/availability-zone | sed 's/[a-z]$//')
export ROLE=$(aws ec2 describe-tags --region "$REGION" --filters "Name=resource-id,Values=$INSTANCE_ID" --query 'Tags[?Key==`Role`].Value | [0]' | tr -d '"')

# bundler:config
~/.rbenv/bin/rbenv exec bundle config set --local deployment true
~/.rbenv/bin/rbenv exec bundle config set --local path /var/www/app/shared/bundle
~/.rbenv/bin/rbenv exec bundle config set --local without development:test

```

### switch_symlink.sh
設定ファイルへのシンボリックリンクを作成しています

```switch_symlink.sh#!/bin/bash
source /var/www/app/current/scripts/common.sh

# deploy:symlink:linked_files
rm /var/www/app/current/config/database.yml
ln -sf /var/www/app/shared/config/database.yml /var/www/app/current/config/database.yml
rm /var/www/app/current/config/secrets.yml
ln -sf /var/www/app/shared/config/secrets.yml /var/www/app/current/config/secrets.yml
ln -sf /var/www/app/shared/config/master.key /var/www/app/current/config/master.key
ln -sf /var/www/app/shared/.env /var/www/app/current/.env

# deploy:symlink:linked_dirs
rm -rf /var/www/app/current/log
ln -sf /var/www/app/shared/log /var/www/app/current/log
rm -rf /var/www/app/current/tmp/pids
ln -sf /var/www/app/shared/tmp/pids /var/www/app/current/tmp/pids
rm -rf /var/www/app/current/tmp/cache
ln -sf /var/www/app/shared/tmp/cache /var/www/app/current/tmp/cache
rm -rf /var/www/app/current/tmp/sockets
ln -sf /var/www/app/shared/tmp/sockets /var/www/app/current/tmp/sockets
rm -rf /var/www/app/current/public/system
ln -sf /var/www/app/shared/public/system /var/www/app/current/public/system
rm -rf /var/www/app/current/public/sitemaps
ln -sf /var/www/app/shared/public/sitemaps /var/www/app/current/public/sitemaps
rm -rf /var/www/app/current/public/assets
ln -sf /var/www/app/shared/public/assets /var/www/app/current/public/assets

```

### stop_server.sh
プロセスの停止を行うスクリプトです

```stop_server.sh
#!/bin/bash
source /var/www/app/current/scripts/common.sh

if [ "${ROLE}" == "web" ] || [ "${ROLE}" == "web_and_batch" ]; then
  # unicorn:stop
  kill -s USR2 `cat /var/www/app/shared/tmp/pids/unicorn.pid`
  # deploy:websockets_stop
  RAILS_ENV=$RAILS_ENV ~/.rbenv/bin/rbenv exec bundle exec pumactl stop
fi

if [ "${ROLE}" == "batch" ] || [ "${ROLE}" == "web_and_batch" ]; then
  # sidekiq:stop
  ~/.rbenv/bin/rbenv exec bundle exec sidekiqctl stop /var/www/app/shared/tmp/pids/sidekiq-0.pid 10
fi

# deploy:clear_cache
RAILS_ENV=$RAILS_ENV ~/.rbenv/bin/rbenv exec bundle exec rails r 'Rails.cache.clear'
```

### update_crontab.sh
wheneverを使っているのでcronの生成を行います

```update_crontab.sh
#!/bin/bash
source /var/www/app/current/scripts/common.sh

if [ "${ROLE}" == "batch" ] || [ "${ROLE}" == "web_and_batch" ]; then
  # whenever:update_crontab
  RAILS_ENV=$RAILS_ENV ~/.rbenv/bin/rbenv exec bundle exec whenever --update-crontab app_$RAILS_ENV --set environment=$RAILS_ENV --roles=app,web,db,batch
fi

```

### start_server.sh
プロセスの起動を行うスクリプトです

```start_server.sh
#!/bin/bash
source /var/www/app/current/scripts/common.sh

if [ "${ROLE}" == "web" ] || [ "${ROLE}" == "web_and_batch" ]; then
  # deploy:ridgepole_apply
  RAILS_ENV=$RAILS_ENV ~/.rbenv/bin/rbenv exec bundle exec ridgepole -c config/database.yml -E $RAILS_ENV -f db/Schemafile --apply --mysql-change-table-comment
  # unicorn start
  RAILS_ENV=$RAILS_ENV ~/.rbenv/bin/rbenv exec bundle exec unicorn -c /var/www/app/current/config/unicorn/$RAILS_ENV.rb -E $RAILS_ENV -D
  # websockets_start
  RAILS_ENV=$RAILS_ENV ~/.rbenv/bin/rbenv exec bundle exec pumactl start
fi

if [ "${ROLE}" == "batch" ] || [ "${ROLE}" == "web_and_batch" ]; then
  # sidekiq:start
  RAILS_ENV=$RAILS_ENV ~/.rbenv/bin/rbenv exec bundle exec sidekiq --index 0 --pidfile /var/www/app/shared/tmp/pids/sidekiq-0.pid --environment $RAILS_ENV --logfile /var/www/app/shared/log/sidekiq.log --daemon
fi

```

## まとめ
以上がcircleciからCodeDeployを実行するために必要なフローとなります。
対象のブランチにプッシュしてワークフローが正常に終了すればOKです。
同じような悩みをお抱えの方の参考になれば幸いです。