はじめに
弊社サービスでは基本的にRailsをEC2インスタンス上で動かすという構成をとっています。
デプロイはcircleci + capistranoという定番の構成で行っていたものの、サービスの規模が大きくなるにつれ、
- デプロイ時間が遅い
→assets:precompileに時間がかかり、合計で20分程度かかる - デプロイ負荷が高い
→assets:precompileのタイミングでCPU負荷が100%近くまで上昇する
という課題が顕著となり、開発効率の低下やデプロイのためだけのサーバースペックの増強が生じていました。
これを解消するために、コンパイルをサーバー上で行うのではなく、別環境でコンパイルされたソースをサーバーに適用するという方法でデプロイを行うこととしました。
CodePipelineに寄せるか否か
「別環境でコンパイルされたソースをサーバーに適用する」を実現するために、大きく以下二つの選択肢を考えました。
- CodePipelineのワークフローを駆使し、ビルドデプロイフローを全体的にAWSで制御するパターン
- デプロイのみCodeDeployを使い、ビルドは引き続きcircleciに委ねるパターン
このどちらにするかは好みの範疇だと思います。
Code~は全体的に癖が強く、スイッチングコストが高くなりそうだったため2(ビルドとデプロイ起動はcircleciで運用する)のパターンとしました。
構成
流れとしては以下のフローとなります
- githubの特定のブランチへのプッシュ駆動でcircleciのワークフローが開始される
- circleciのビルド/テストジョブが実行され、ビルド成果物がS3に格納される
- circleciのデプロイジョブが実行され、CodeDeployが実行される
- CodeDeploy定義によって、S3に格納されたビルド成果物が指定される
- 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定義まとめ
以上をまとめると以下のファイルになります
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というソースが配置された後に実行されるステップにおいて、プロセスの再起動を行うこととしています。
以下スクリプトは環境によって必要有無が異なると思いますが、参考として記載します。
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を行なっています
#!/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
設定ファイルへのシンボリックリンクを作成しています
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
プロセスの停止を行うスクリプトです
#!/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の生成を行います
#!/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
プロセスの起動を行うスクリプトです
#!/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です。
同じような悩みをお抱えの方の参考になれば幸いです。