Rails
AWS
docker
ECS

AWS ECS + CircleCIでRailsアプリのDocker本番環境を作ってみた

More than 1 year has passed since last update.


戦略


  • 開発環境はDockerComposeでRails + MySQL + Redisな構成


    • ファイル同期にはDockerSyncを使う



  • 本番環境はECS + RDS + ElastiCache


    • ALBを使って動的ポートマッピング

    • nginxは使わず、pumaでリクエストを受ける

    • assetsはDockerコンテナに含めてビルド


      • CDN(CloudFront)を経由して配布





  • CircleCIでRailsコンテナをビルド&デプロイ



  • 決めてないこと


    • cronの運用方法


      • CloudWatch + LambdaでRunTaskAPI叩く

      • kuroko2やsidekiq-cronなどのお手軽ジョブスケジューラを入れる






現時点の感触


  • DockerComposeな環境で開発できるのは本当に楽

  • 今までItamae + VagrantでやっててDockerfileに移行するの謎の抵抗あったけど、やってみると簡単だった


    • Itamaeで主に書いていたコードはミドルウェア中心だったので、定番系は公式コンテナ使えばOK



  • 小規模なRailsアプリで、GitHubにコードをpushしてから、CircleCI通じてデプロイ完了するまで約13分


    • Rspecテスト3分・Dockerビルド5分・ECR push4分・ECSローリングデプロイ1分

    • 正直、時間がかかりすぎていると感じている


      • CircleCIからDrone.io OSSに移行してDockerビルドを数十秒に短縮した

      • ECRへのDockerイメージpushが意外と時間かかるのでファイルサイズ削減大事


        • 今は250MBくらい。100MB台目指したい







  • ログ収集・バッチ処理など今までとは違う考え方にしないといけないが、そんなに苦じゃなかった


    • ログ収集はPapertrailなどを利用

    • バッチ処理はジョブスケジューラでいける



  • CIでビルドがコケた時のデバッグが本当に面倒くさかった…


事前にやっておくと幸せになれるかもしれないこと

ctrl-pで前のコマンドに戻れない!と思ったら既に割り当てられていましたよ…的な。docker-composeでは効かないので、dockerコマンドでコンテナに入るようにしています。


エイリアス

docker-composeがあまりに長すぎるので、前身のfigコマンドに置き換えました。


~/.zshrc

alias fig='docker-compose'

alias do='docker'
alias ds='docker-sync start'


Railsのコンテナ作成

まずは開発環境を作ります。Dockerfileなどのディレクトリ構成は以下のようにしました。必要そうな所だけ抜き出しています。基本はRailsディレクトリ直下にDockerfileを置いて、DBやKVSなどのDockerfileはcontainersディレクトリに押し込めています。まぁ、ここらへんはお好みで。

.

├── app
├── Dockerfile
├── Dockerfile.erb
├── Gemfile
├── Gemfile.lock
├── circle.yml
├── containers
│   ├── db
│   │   ├── Dockerfile
│   │   └── initdb.d
│   └── kvs
│   └── Dockerfile
├── docker
├── docker-compose.yml
└── docker-sync.yml


rails newする

rails newもDocker通じて行います。

$ do run --rm -v "$PWD":/app -w /app ruby:2.4 gem i rails --no-rdoc --no-ri && rails new myapp -d mysql


Dockerfile

イメージは ruby:2.4.0-alpine を利用しています。素のAlpineから入れるのも検証したんですが、そこまでイメージサイズに差が出なかったので、こちらにしています。Railsでよく使うGemに対応できるように一通りのライブラリを導入しています。最終的には約250MBくらいのイメージサイズになりました。もっと少なくしたい気持ちがあります👺 デプロイ時間に大きく影響するので…。

FROM ruby:2.4.0-alpine

ENV LANG ja_JP.UTF-8
ENV PAGER busybox less

RUN apk update && \
apk upgrade && \
apk add --update\
bash \
build-base \
curl-dev \
git \
libxml2-dev \
libxslt-dev \
linux-headers \
mysql-dev \
nodejs \
openssh \
ruby-dev \
ruby-json \
tzdata \
yaml \
yaml-dev \
zlib-dev \
imagemagick

RUN gem install bundler

RUN mkdir /app
WORKDIR /app
ADD Gemfile /app/Gemfile
ADD Gemfile.lock /app/Gemfile.lock
RUN bundle install --jobs 4
ADD . /app

EXPOSE 3000


docker-compose.yml

version: "3"

volumes:
app_sync_volume:
external: true
services:
app:
build: ./
ports:
- "3000:3000"
environment:
REDIS_HOST: kvs
MYSQL_HOST: db
MYSQL_ROOT_PASSWORD: password
MYSQL_DATABASE: app_development
MYSQL_USER: user
MYSQL_PASSWORD: password
volumes:
- app_sync_volume:/app
depends_on:
- kvs
- db
tty: true
kvs:
build: ./containers/kvs
db:
build: ./containers/db
command: mysqld --character-set-server=utf8 --collation-server=utf8_unicode_ci
ports:
- "127.0.0.1:3306:3306"
environment:
MYSQL_ROOT_PASSWORD: password
MYSQL_DATABASE: app_development
MYSQL_USER: user
MYSQL_PASSWORD: password

Docker for Macのファイル同期が耐えられないくらい遅かったので、docker-syncを使って双方向同期させています。


DockerSyncの設定

インストールはgemとbrewでサクッと。

$ gem install docker-sync

$ brew install fswatch
$ brew install unison


docker-sync.yml

syncs:

app_sync_volume:
sync_excludes: ['.gitignore', '.git/', 'tmp/*']
src: './'
dest: '/app'
sync_strategy: 'unison'

これで docker-sync start コマンドでファイルの同期が開始します。反映に多少のタイムラグはありますが(rsync程度)、Railsの動作は高速になりました。ただ、docker-syncは時々ファイルの同期がなにをやっても反映しない時があり、色々ゴニョゴニョしたり再起動したりしてハマる瞬間があります…。それさえ乗り越えられれば…😣

(*) Spring頑張って使って高速化させるテクニックとかあったけど、微妙に上手く行かなかった…🤔

以上で、Rails + Dockerな開発環境が出来上がりました。


Railsの操作

とりあえず起動をしてみます。

# 初回もしくは構成変更したときのみ

$ docker-compose build

# docker-sync用のボリュームを作成
$ docker volume create --name=app_sync_volume

$ docker-compose up
$ docker-sync start

その後、Railsコンテナに入って、rails serverrails console とかをやりたいのでコンテナ一覧を見ます。

$ docker ps

CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
8e3827185fd6 app "irb" 12 hours ago Up 39 seconds 0.0.0.0:3000->3000/tcp app_1
becb6ded7e39 db "docker-entrypoint..." 6 days ago Up 40 seconds 127.0.0.1:3306->3306/tcp db_1
5008880a8417 kvs "docker-entrypoint..." 9 days ago Up 40 seconds 6379/tcp kvs_1

RailsコンテナIDが 8e3827185fd6 ということがわかったので中にはいってみます。

$ docker exec -it 8e3827185fd6 bash

これでいつものようにRailsコマンドが叩き放題です。


ECSの初期設定

Dockerの準備が整ったのでECSを設定していきます。

ECSが出た当時、とりあえずさわってみるかーとコンソールから操作した時の理解出来なさで非常に苦労した思い出があり、なるべくコンソールをさわらずにTerraformなどでコード化したいなーと思っていじっていたんですが、ECSの概念を理解せずにコード化すると、もっと意味不明になるので気合い入れてコンソールからECSを設定してみました。

やってみると、はっきりいって超簡単でした。改善されたというのもありますが、ほんの数ステップでVPCからクラスタ・ALBへのヒモ付、EC2インスタンスの構築まで全部やってくれます。


クラスタを作る

まずはクラスタを作ります。ここでDockerを実際に稼働させるEC2インスタンスも一緒に作ります。このEC2インスタンスは、ECSに最適化されたイメージから起動されます。具体的に言うとDockerコンテナを管理するECSコンテナエージェントがインストールされています。

cl2.png

VPCも一緒に作ります。既存のVPCを利用することもできます。特別な設定は特にないです

cl3.png

これでクラスタは完成です。


ALBを作る

次はALBを予め作っておきます。特別な設定はないですが、ターゲットにEC2インスタンスは登録しないでおきます。後でサービスとALBを紐付けることで自動的にEC2インスタンスがALBに登録されるようにします。


RDSとElastiCacheを作る

データベースをキャッシュサーバは外部を利用するので、ECSの時に作ったVPCに所属する形で作っておきます。特別な設定などはないです。


本番用のDockerイメージを作る


環境変数の管理

考慮しなければいけないのはデータベース情報とかの環境変数です。環境変数の管理を考えるのが面倒くさかったので僕は yaml_vaultAWS KMS を利用しています。

全部 config/secrets.yml に隠したい情報を書いてGit管理下から外し、yaml_vaultで暗号化した config/encrypted_secrets.yml をGit管理しています。そしてDockerビルド時に復号化して読み込むようにさせています。

使い方は公式マニュアルを読めば分かるくらい簡単です😎 @joker1007さんありがとうございます。


本番用のDockerfile

基本的には開発用と同じですが、assets:precompileをしてアセットファイルを内包するようにしています。

FROM ruby:2.4.0-alpine

ENV LANG ja_JP.UTF-8
ENV PAGER busybox less

RUN apk update && \
apk upgrade && \
apk add --update\
bash \
build-base \
curl-dev \
git \
libxml2-dev \
libxslt-dev \
linux-headers \
mysql-dev \
nodejs \
openssh \
ruby-dev \
ruby-json \
tzdata \
yaml \
yaml-dev \
zlib-dev \
imagemagick \
jq

RUN gem install bundler

ARG RAILS_ENV
ARG RACK_ENV
ARG AWS_ACCESS_KEY_ID
ARG AWS_SECRET_ACCESS_KEY

RUN mkdir /app
WORKDIR /app
ADD Gemfile /app/Gemfile
ADD Gemfile.lock /app/Gemfile.lock

RUN bundle config build.nokogiri --use-system-libraries && \
bundle config build.mysql2 --use-system-libraries && \
bundle install --jobs 20 --retry 5

ADD . /app

EXPOSE 3000

# encrypted_secrets.ymlを復号化してsecrets.ymlにする
RUN yaml_vault decrypt config/encrypted_secrets.yml -o config/secrets.yml \
--cryptor=aws-kms \
-k default,development,test,production,staging \
--aws-region=ap-northeast-1 \
--aws-access-key-id=${AWS_ACCESS_KEY_ID} \
--aws-secret-access-key=${AWS_SECRET_ACCESS_KEY}

# secrets.ymlにあるkey-valを環境変数にセットする
RUN eval $(RAILS_ENV=${RAILS_ENV} ruby yaml_to_env.rb | jq -r 'to_entries[] | "export \(.key)=\(.value);"')

RUN npm install
RUN RAILS_ENV=${RAILS_ENV} bundle exec rake assets:precompile
CMD ["bundle", "exec", "rails", "s", "puma", "-b", "0.0.0.0", "-p", "3000", "-e", "${RAILS_ENV}"]

あとは、ECRでリポジトリを作成して、そこで示されたビルドコマンドなどを叩けば、ECRへのpushは完了です。

ecr.png


タスクを作る

タスクはコンテナをまとめて一つのグループにする感じです。docker-compose.ymlに近いといえばイメージが付きやすいかもしれません。docker-composeのlink機能と同じもので、linksという機能があり、それを利用することで他のコンテナに接続できるようになります。ただし、同一タスク内でないと接続できないので、RailsタスクとMySQLタスクを分けて通信するというのはできません。

まぁ、今回はRDSなどを使うのでRailsコンテナだけなので単独です。

というわけでコンテナの追加で、Railsコンテナの設定をします。

項目
設定値

コンテナ名
適当な名前

イメージ
000.dkr.ecr.ap-northeast-1.amazonaws.com/app:latest

メモリ制限
ハード : 1024

ポートマッピング
0 : 3000(TCP)

CPUユニット数
200

動的ポートマッピングを利用したいので、ポートマッピングの項目では 0 と 3000 を指定します。0にしておくことでECSの方でよしなにポートを割り当ててコンテナを起動してくれます。これがあることで1つのECSインスタンス内に同一のコンテナをデプロイできます。リソースフル活用。良いですね。

コンテナのログを追いたい場合はCloudWatchのログを設定できます。下記の記事を参考にやってみてください。簡単です。

タスクの作成はこれで完了です。


サービスを作る

次にサービスを作ります。ここでタスクとALBのヒモ付を行います。

項目
設定値

タスク定義
作ったタスクを指定

クラスタ
作ったクラスタを指定

サービス名
適当な名前

タスクの数
1(起動させたいコンテナの数)

最小ヘルス率
50

最大率
200

最小ヘルス率と最大率はデプロイ時に、ローリングデプロイにするのかブルーグリーンデプロイにするのかなどの閾値を設定できます。以下の記事が非常に分かりやすかったです。

そして、先程作ったALBとヒモ付を行います。基本的に全部選択してけば大丈夫です。

ecs+alb.png

以上、サービスを作成するとあとは自動的にDockerコンテナが展開され、正常にRailsコンテナが立ち上がればALBに紐づけされて目出度くHelloWorld的な感じになります😀 ALBのヘルスチェックが通らず永遠とコンテナを起動→破棄→起動を繰り返している場合は、CloudWatchのログで何が原因で落ちているのかを探ると良いと思います。


CircleCIの設定

CircleCIを通じてビルド・デプロイを自動化します。

現時点のCircleCIでDockerビルドするには弱点が2つあります。


  • インストール済みのdockerが古い(確か1.10未満)

  • ビルドキャッシュが利用できない

前者はdocker-compose構成でのビルドを諦めたりすれば何とかなります。もしくは頑張って新しいdockerをインストールするか(僕は諦めました)。

後者は時間が許せば(5分ちょっと暗い)…。もしくは今ベータ版の2.0では改善されているらしいので、そちらで試してみても良いかもしれません。

ちなみに僕はビルド時間に耐えられずDrone.io OSS版のビルドサーバに乗り換えました。CircleCI2.0が正式化して良さそうならまた使ってみようかと思っています。


circle.yml

database.ymlをゴニョゴニョしているのは、テストはCircleCIのdocker上で行ったため勝手にdatabase.ymlを書き換えられてしまい、dockerビルドしてしまうと不都合が生じてしまうため仕方なくオリジナルファイルを退避させています。

docker-compose環境が簡単に作れればビルドしたDockerに対してテストできるんですが…。

また、DockerfileをDockerfile.erbにしてブランチ毎に中身を微妙に書き換えていたりします。ステージングや本番環境の切り替えなどですね。

version: 2

machine:
services:
- docker
ruby:
version: 2.4.0
timezone: Asia/Tokyo

deployment:
staging:
branch: develop
commands:
- erb Dockerfile.erb > Dockerfile
- docker build -t app-$CIRCLE_BRANCH:$CIRCLE_SHA1 .
- bash ./deploy_production.sh

database:
pre:
- cp config/database.yml config/database.yml.org

test:
post:
- cp config/database.yml.org config/database.yml


deploy_production.sh

デプロイは主に3段階あります。


  • ビルドイメージをECRにpushする

  • ESC RunTaskAPIを叩いて db:migrate する

  • ecs-deployスクリプトを使って新しいタスクをサービスに適用する

#!/bin/sh

set -ex

eval $(aws ecr get-login --region ${AWS_DEFAULT_REGION})
docker tag app-$CIRCLE_BRANCH:$CIRCLE_SHA1 000000.dkr.ecr.ap-northeast-1.amazonaws.com/app:latest
docker push 000000.dkr.ecr.ap-northeast-1.amazonaws.com/app:latest

aws ecs run-task \
--region ap-northeast-1\
--cluster sample \
--task-definition sample-task \
--overrides file://run_task_db_migrate.json

./ecs-deploy -c sample -n sample-service \
-i 000000.dkr.ecr.ap-northeast-1.amazonaws.com/app:latest


run_task_db_migrate.json

{

"containerOverrides": [
{
"name": "app",
"command": ["bundle", "exec", "rails", "db:migrate"]
}
]
}

これで、めでたくAWS ECS + Docker + CircleCIでRailsアプリの本番環境が作れると思います!! たぶん、1発では成功しない苦難の道だとは思いますが、一度でもやってみると意外と大したことないという感覚になるので是非やってみてください。Dockerでの開発はとても楽で良い感じです。


解決できていないこと


MySQLなどのポート競合

ステージング環境とかで、RDSやElastiCacheを持つほどじゃない場合、Railsコンテナと一緒にタスクにしたいのだけど、デプロイ時にMySQLやRedisのポートが競合して1台のECSインスタンスではデプロイができない…。minimumHealthyPercent を0%に、maximumPercent を100%にすると、古いコンテナを全部殺してから、新しいコンテナを立ち上げるため1台のECSインスタンスで使い回しが可能。だけど、それなりのダウンタイムが発生する。

ステージング環境をいっぱい同時に作りたい場合のベストプラクティスが思いつかないです…。できればプルリク単位で気軽にデプロイできたら動作確認が楽なのになーと思っています。