Rails アプリの Docker Image ビルドと Amazon EC2 Container Service へのデプロイの自動化

現在構築中のサービスの Rails アプリケーションのインフラとして、Amazon EC2 Container Service (ECS) を採用し、自動化を頑張ってみた内容を公開します。

サンプルコード、Docker Image はそれぞれ、以下で公開しています。

構成

今回開発しているアプリケーションは、以下の様な構成です。

CI フロー

circle.yml に以下の様な処理フローを設定しています。

Roles

以下の 2つの Role を持つコンテナを起動します。

常駐プロセスは 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}" .

Serverspec でビルドした Image をテストする

テスト用の 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 の削除を行う様にしています。

Amazon EC2 Container Service にデプロイ

前途のとおり、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

今回記載した内容では、タスクの切り替え時にダウンタイムが発生してしまうので、以下の記事などを参考に、無停止デプロイの設定をしたいと思います。