Rails アプリの Docker Image ビルドと Amazon EC2 Container Service へのデプロイの自動化 - Atsushi NagaseRails アプリの Docker Image ビルドと Amazon EC2 Container Service へのデプロイの自動化
現在構築中のサービスの Rails アプリケーションのインフラとして、Amazon EC2 Container Service (ECS) を採用し、自動化を頑張ってみた内容を公開します。
サンプルコード、Docker Image はそれぞれ、以下で公開しています。
構成
今回開発しているアプリケーションは、以下の様な構成です。
CI フロー
circle.yml に以下の様な処理フローを設定しています。
dependencies/override
- awscli, jq インストール
- 依存 Gem Library, Docker Image インストール
- MySQL, Redis コンテナ起動
- Docker Image ビルド
- Serverspec の依存ライブラリインストール
test/override
- Rails アプリケーション側の Rspec
- Docker Image の Serverspec
test/post
- CI ビルド毎の Docker Image をレジストリーに Push
${DOCKER_REPO}:web-b${CIRCLE_BUILD_NUM}
${DOCKER_REPO}:job-b${CIRCLE_BUILD_NUM}
master
ブランチ: ビルド番号なしの Docker Image をレジストリーに Push
${DOCKER_REPO}:web
${DOCKER_REPO}:job
deployment/$ENV_NAME
ブランチ
- ビルド番号付きの Docker Image をソースに、Task Definition を作成
db:migrate
用のタスクを作成
- Service を更新する
- 既存タスクを終了させる (無停止デプロイは未設定)
Roles
以下の 2つの Role を持つコンテナを起動します。
job
- Sidekiq のデーモンを常駐させる
- Cron で Rake Task を実行する
web
常駐プロセスは Supervisor で監視します。
環境変数
CircleCI 上で、以下の環境変数を Project Settings > Environment variables にて設定しています。
Name |
Description |
AWS_DEFAULT_REGION |
AWS Commandline 用。今回は us-east-1 を使いました。 |
DATABASE_URL_PRODUCTION |
Production 環境用の DATABASE_URL . 例: mysql2://root:[email protected]:3306/docker_rails_example_production |
DOCKER_EMAIL |
レジストリーの Email。Robot user の場合 . で OK |
DOCKER_PASS |
レジストリーの Password |
DOCKER_REPO_HOST |
レジストリーの Host: quay.io |
DOCKER_REPO |
リポジトリ名: quay.io/atsnngs/docker-rails-example |
DOCKER_USER |
レジストリーの Username |
REDIS_URL_PRODUCTION |
Production 環境用の REDIS_URL . 例: redis://docker-rails-example.xxxx.0001.use1.cache.amazonaws.com/sidekiq_production |
AWS の Credential は Project Settings > AWS permissions にて設定しています。
Dockerfile
2つの Role によって、分岐が発生するので、Erb テンプレートで条件分岐と環境変数の出力を行います。
Dockerfile.erb
FROM ubuntu:14.04
MAINTAINER Atsushi Nagase<[email protected]>
RUN apt-get update -y && apt-get install -y software-properties-common
RUN apt-add-repository -y ppa:nginx/stable
RUN apt-add-repository -y ppa:brightbox/ruby-ng
RUN apt-get update -y && apt-get install -y \
locales \
language-pack-en \
language-pack-en-base \
openssh-server \
curl \
supervisor \
build-essential \
git-core \
g++ \
libcurl4-openssl-dev \
libffi-dev \
libmysqlclient-dev \
libreadline-dev \
libsqlite3-dev \
libssl-dev \
libxml2 \
libxml2-dev \
libxslt1-dev \
libyaml-dev \
python-software-properties \
zlib1g-dev \
ruby2.2-dev \
ruby2.2
ENV \
BUNDLE_PATH=/var/www/shared/vendor/bundle \
RAILS_ENV=production \
RAILS_ROOT=/var/www/app
RUN gem install bundler --no-rdoc --no-ri
RUN mkdir -p $RAILS_ROOT
WORKDIR ${RAILS_ROOT}
RUN (echo <%= `cat Gemfile`.inspect %> > "${RAILS_ROOT}/Gemfile") && (echo <%= `cat Gemfile.lock`.inspect %> > "${RAILS_ROOT}/Gemfile.lock")
RUN mkdir -p $BUNDLE_PATH && \
mkdir -p vendor && \
rm -rf vendor/bundle && \
ln -s $BUNDLE_PATH vendor/bundle && \
(bundle check || bundle install --without test development darwin assets --jobs 4 --retry 3 --deployment)
## Locales
RUN [ -f /var/lib/locales/supported.d/local ] || touch /var/lib/locales/supported.d/local
RUN echo 'LANG="en_US.UTF-8"' > /etc/default/locale
RUN dpkg-reconfigure --frontend noninteractive locales
RUN echo <%= `cat docker/files/etc/supervisor/supervisord.conf`.inspect %> > /etc/supervisor/supervisord.conf
<% if ENV['ROLE'] == 'web' %>
RUN apt-get update -y && apt-get install -y nginx
<% end %>
COPY . $RAILS_ROOT
WORKDIR ${RAILS_ROOT}
RUN mkdir -p $BUNDLE_PATH && \
mkdir -p vendor && \
rm -rf vendor/bundle && \
ln -s $BUNDLE_PATH vendor/bundle && \
(bundle check || bundle install --without test development darwin assets --jobs 4 --retry 3 --deployment) && \
(echo "SECRET_KEY_BASE=$(./bin/rake secret)" > .env)
ENV ROLE=<%= ENV['ROLE'] %>
<% if ENV['ROLE'] == 'web' %>
EXPOSE 80
ADD docker/files/etc/supervisor/conf.d/nginx.conf /etc/supervisor/conf.d/nginx.conf
ADD docker/files/etc/supervisor/conf.d/unicorn.conf /etc/supervisor/conf.d/unicorn.conf
ADD docker/files/etc/nginx/nginx.conf /etc/nginx/nginx.conf
ADD docker/files/etc/init.d/unicorn /etc/init.d/unicorn
RUN chmod +x /etc/init.d/unicorn
<% elsif ENV['ROLE'] == 'job' %>
ADD docker/files/etc/supervisor/conf.d/sidekiq.conf /etc/supervisor/conf.d/sidekiq.conf
ADD docker/files/etc/init.d/sidekiq /etc/init.d/sidekiq
RUN chmod +x /etc/init.d/sidekiq
<% end %>
CMD ["/bin/sh", "/var/www/app/script/run-supervisord.sh"]
COPY . $RAILS_ROOT
でプロジェクトディレクトリをコピーするまでは、
RUN echo <%= `cat docker/files/etc/supervisor/supervisord.conf`.inspect %> > \
/etc/supervisor/supervisord.conf
の様に、ADD
コマンドを使わず、ビルド時に更新タイムスタンプを元に更新の有無を見ているため、
キャッシュが無効になり、毎回ビルドが走ってしまうのを回避しています。
Docker Image をビルドする
上記の Dockerfile.erb
をレンダリングして、docker build
コマンドを実行します。
ROLE=web ./script/build-docker.sh
build-docker.sh
#!/bin/sh
set -eu
erb Dockerfile.erb > Dockerfile
docker build -t "${DOCKER_REPO}:${ROLE}" .
テスト用の MySQL, Redis のイメージを Pull し、デーモン起動しておきます。
docker pull redis
docker pull mysql
docker run --name dev-redis -d redis
docker run --name dev-mysql -e 'MYSQL_ROOT_PASSWORD=dev' -d mysql
スクリプトを実行します
TARGET=${DOCKER_REPO}:web ./script/run-server-spec.sh
run-server-spec.sh
#!/bin/sh
set -eu
HASH=$(openssl rand -hex 4)
DATABASE_URL=mysql2://root:dev@dev-mysql/docker-rails-example
REDIS_URL=redis://dev-redis:6379/dev
docker run \
-e DATABASE_URL="${DATABASE_URL}" \
-e REDIS_URL="${REDIS_URL}" \
--link dev-mysql:mysql \
--link dev-redis:redis \
--name "dbmigrate-${HASH}" \
-w /var/www/app -t $TARGET \
sh -c './bin/rake db:create; ./bin/rake db:migrate:reset'
docker run \
-e DATABASE_URL="${DATABASE_URL}" \
-e REDIS_URL="${REDIS_URL}" \
-v "$(pwd)/docker/serverspec"\:/mnt/serverspec \
--name "serverspec-${HASH}" \
--link dev-mysql:mysql \
--link dev-redis:redis \
-w /mnt/serverspec -t $TARGET \
sh -c 'echo "DATABASE_URL=${DATABASE_URL}" >> /var/www/app/.env && echo "REDIS_URL=${REDIS_URL}" >> /var/www/app/.env && service supervisor start && bundle install --path=vendor/bundle && sleep 10 && bundle exec rake spec'
set +eu
[ $CI ] || docker rm "dbmigrate-${HASH}"
[ $CI ] || docker rm "serverspec-${HASH}"
最後の2行は、CircleCI 上で Docker Container を削除しようとすると、Failed to destroy btrfs snapshot: operation not permitted
というエラーで失敗するため、ローカル環境など、CI
環境変数がセットされていない場合にのみ、Container の削除を行う様にしています。
前途のとおり、db:migrate
用のタスクを作成し、常駐プロセスが起動する、Task を Service に設定します。
export ENV_NAME=`echo $CIRCLE_BRANCH | sed 's/deployment\///'` && \
/bin/sh script/ecs-deploy-db-migrate.sh && \
sleep 5 && \
/bin/sh script/ecs-deploy-services.sh
Task Definition の Erb テンプレートをレンダリングして、実行中のビルド固有のタスクを定義します。
本番環境用の Redis, MySQL の URL を、予め _PRODUCTION
の接尾辞を付けて環境変数に設定していたものから取得し、この定義ファイルに、REDIS_URL
, DATABASE_URL
として上書きする記述を行います。
ecs-deploy-db-migrate.sh
rake db:migrate
を実行するタスクを作成します。
#!/bin/sh
set -eu
CLUSTER=default
UPPER_ENV_NAME=$(echo $ENV_NAME | awk '{print toupper($0)}')
DATABASE_URL=$(eval "echo \$DATABASE_URL_${UPPER_ENV_NAME}")
REDIS_URL=$(eval "echo \$REDIS_URL_${UPPER_ENV_NAME}")
APP_NAME='ngs-docker-rails-example-'
TASK_FAMILY="${APP_NAME}db-migrate-${ENV_NAME}"
REDIS_URL=$REDIS_URL DATABASE_URL=$DATABASE_URL \
erb ecs-task-definitions/task-db-migrate.json.erb > .ecs-task-definition.json
TASK_DEFINITION_JSON=$(aws ecs register-task-definition --family $TASK_FAMILY --cli-input-json "file://$(pwd)/.ecs-task-definition.json")
TASK_REVISION=$(echo $TASK_DEFINITION_JSON | jq .taskDefinition.revision)
aws ecs run-task --cluster ${CLUSTER} --task-definition "${TASK_FAMILY}:${TASK_REVISION}" | jq .
task-db-migrate.json.erb
{
"family": "ngs-docker-rails-example-db-migrate-<%= ENV['ENV_NAME'] %>",
"containerDefinitions": [
{
"image": "<%= ENV['DOCKER_REPO'] %>:job-b<%= ENV['CIRCLE_BUILD_NUM'] %>",
"name": "docker-rails-example-db-migrate",
"cpu": 1,
"memory": 128,
"essential": true,
"command": ["./bin/rake", "db:migrate"],
"mountPoints": [{ "containerPath": "/var/www/app/log", "sourceVolume": "log", "readOnly": false }],
"environment": [
{ "name": "DATABASE_URL", "value": "<%= ENV['DATABASE_URL'] %>" },
{ "name": "REDIS_URL", "value": "<%= ENV['REDIS_URL'] %>" }
],
"essential": true
}
],
"volumes": [
{
"name": "log",
"host": { "sourcePath": "/var/log/rails" }
}
]
}
ecs-deploy-services.sh
web
, job
の常駐プロセスのあるタスクを定義し、サービスを更新、古いタスクを停止します。
#!/bin/sh
set -eu
CLUSTER=default
UPPER_ENV_NAME=$(echo $ENV_NAME | awk '{print toupper($0)}')
DATABASE_URL=$(eval "echo \$DATABASE_URL_${UPPER_ENV_NAME}")
REDIS_URL=$(eval "echo \$REDIS_URL_${UPPER_ENV_NAME}")
APP_NAME='ngs-docker-rails-example-'
TASK_FAMILY="${APP_NAME}${ENV_NAME}"
SERVICE_NAME="${APP_NAME}service-${ENV_NAME}"
REDIS_URL=$REDIS_URL DATABASE_URL=$DATABASE_URL \
erb ecs-task-definitions/service.json.erb > .ecs-task-definition.json
TASK_DEFINITION_JSON=$(aws ecs register-task-definition --family $TASK_FAMILY --cli-input-json "file://$(pwd)/.ecs-task-definition.json")
TASK_REVISION=$(echo $TASK_DEFINITION_JSON | jq .taskDefinition.revision)
DESIRED_COUNT=$(aws ecs describe-services --services $SERVICE_NAME | jq '.services[0].desiredCount')
if [ ${DESIRED_COUNT} = "0" ]; then
DESIRED_COUNT="1"
fi
SERVICE_JSON=$(aws ecs update-service --cluster ${CLUSTER} --service ${SERVICE_NAME} --task-definition ${TASK_FAMILY}:${TASK_REVISION} --desired-count ${DESIRED_COUNT})
echo $SERVICE_JSON | jq .
TASK_ARN=$(aws ecs list-tasks --cluster ${CLUSTER} --service ${SERVICE_NAME} | jq -r '.taskArns[0]')
TASK_JSON=$(aws ecs stop-task --task ${TASK_ARN})
echo $TASK_JSON | jq .
service.json.erb
{
"family": "ngs-docker-rails-example-<%= ENV['ENV_NAME'] %>",
"containerDefinitions": [
{
"image": "<%= ENV['DOCKER_REPO'] %>:web-b<%= ENV['CIRCLE_BUILD_NUM'] %>",
"name": "docker-rails-example-web",
"cpu": 1,
"memory": 128,
"essential": true,
"portMappings": [{ "hostPort": 80, "containerPort": 80, "protocol": "tcp" }],
"mountPoints": [{ "containerPath": "/var/www/app/log", "sourceVolume": "log", "readOnly": false }],
"environment": [
{ "name": "DATABASE_URL", "value": "<%= ENV['DATABASE_URL'] %>" },
{ "name": "REDIS_URL", "value": "<%= ENV['REDIS_URL'] %>" }
],
"essential": true
},
{
"image": "<%= ENV['DOCKER_REPO'] %>:job-b<%= ENV['CIRCLE_BUILD_NUM'] %>",
"name": "docker-rails-example-job",
"cpu": 1,
"memory": 128,
"essential": true,
"mountPoints": [{ "containerPath": "/var/www/app/log", "sourceVolume": "log", "readOnly": false }],
"environment": [
{ "name": "DATABASE_URL", "value": "<%= ENV['DATABASE_URL'] %>" },
{ "name": "REDIS_URL", "value": "<%= ENV['REDIS_URL'] %>" }
],
"essential": true
}
],
"volumes": [
{
"name": "log",
"host": { "sourcePath": "/var/log/rails" }
}
]
}
WIP
今回記載した内容では、タスクの切り替え時にダウンタイムが発生してしまうので、以下の記事などを参考に、無停止デプロイの設定をしたいと思います。