Rails アプリの Docker Image ビルドと Amazon EC2 Container Service へのデプロイの自動化
Rails アプリの 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:password@docker-rails-example.xxxxx.us-east-1.rds.amazonaws.com: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<a@ngs.io>
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
今回記載した内容では、タスクの切り替え時にダウンタイムが発生してしまうので、以下の記事などを参考に、無停止デプロイの設定をしたいと思います。