amakanの本番環境をDockerに移行した
https://amakan.net/ のこの辺の改善の続き。
- amakanをUnicornからPumaに移行した - ✘╹◡╹✘
- amakanでyarnを使うようにした - ✘╹◡╹✘
- amakanでRuby 2.3.3を使うようにした - ✘╹◡╹✘
- amakanを Ruby 2.3.3 から 2.4.0-preview3 に移行した - ✘╹◡╹✘
- amakanのフロントエンドを色々改善した - ✘╹◡╹✘
- amakanをSidekiqに移行した - ✘╹◡╹✘
- amakanの開発環境をDockerに移行した - ✘╹◡╹✘
本番環境で使うDockerイメージ
これまで開発環境でのみDockerを動かしていたが、本番環境でDockerを動かすには、本番環境で利用できるようなDockerイメージを用意する必要がある。そこでamakanでは、こういう方法を取った。
- 開発環境と本番環境で同じDockerイメージを使う
- 本番環境に必要な全てのファイルを一旦イメージに含める
- 開発環境で変更されるファイルはマウントして上書きする
Rails用のDockerfileは次のような感じで、Gemやソースコードを全てイメージに含めていることが分かる。
FROM ruby:2.4.0-preview3 RUN apt-get update \ && apt-get install --yes --no-install-recommends mysql-client \ && rm -rf /var/lib/apt/lists/\* WORKDIR /app COPY \ Gemfile \ Gemfile.lock \ /app/ RUN bundle install --jobs=4 --path=/bundle \ && mkdir -p /app/tmp/cache \ && mkdir -p /app/tmp/pids \ && mkdir -p /app/tmp/sockets COPY . /app
開発環境で利用する docker-compose.yml は次のような感じで、変更される可能性のある部分がホスト側からマウントされていることが分かる。
version: "2"services:puma:build:context: . dockerfile: ./docker/rails/Dockerfile command: bundle exec puma --config config/puma.rb --environment development volumes:- ./app:/app/app - ./bin:/app/bin - ./config:/app/config - ./config.ru:/app/config.ru - ./db:/app/db - ./Gemfile:/app/Gemfile - ./Gemfile.lock:/app/Gemfile.lock - ./lib:/app/lib - ./public:/app/public - ./Rakefile:/app/Rakefile - ./scripts:/app/scripts - bundle:/bundlevolumes:bundle:
ソースコードだけでなく、Gemも頻繁に変更される部分なので、ボリュームを /bundle にマウントしている。これは以下のような影響を及ぼす。
- 開発環境では環境構築時に
docker-compose run --rm puma bundle install
を実行する必要がある - Gemfileを変更したときに全てのGemを再インストールする必要はない (上記のコマンドで差分だけ入る)
CSSとJavaScriptのコンパイル
amakanではSSRを行う都合から、Railsを動かす環境にコンパイル済みのJavaScriptのファイルが存在している必要がある。デプロイ時にコンパイルを行う方法を取る場合、仮にRailsのWebサーバを動かす環境が複数台存在したとして、デプロイ時に全ての環境でコンパイルするか、少なくとも1つの環境でコンパイルしたあと、全ての環境にそれを配布するなどの工夫が必要になる。
そこでRails用のDockerイメージの中には、コンパイル済みのCSSやJavaScriptも含めることにした。最初からイメージの中にコンパイル済みのファイルを含めておけば、前述した苦労は回避できる。また、問題が確認されたときに本番環境と同じ環境ですぐにデバッグを行えるなど、開発環境と本番環境との乖離を避けるという観点でも都合が良い。
コンパイルにはNode.jsの環境が必要になるが、これは別途Node.js用のコンテナを用意して、コンパイルされた成果物をRails用のコンテナに渡している。Node.js用イメージのDockerfileは次のような感じで、前述したイメージと同様に、必要な全てのファイルをイメージに含めつつ、開発環境では変更されるソースコードや依存ライブラリ用のディレクトリをマウントして運用している。
FROM node:6.9.1 ENV PATH /root/.yarn/bin:$PATH RUN curl -o- -L https://yarnpkg.com/install.sh | bash -s -- --version 0.18.1 WORKDIR /app COPY \ package.json \ yarn.lock \ /app/ RUN yarn install COPY . /app
結果的に、イメージをビルドするときはこういうコードが走ることになる。
docker build --file ./docker/node/Dockerfile --tag ${DOCKER\_TAG\_NODE} . docker run --volume $(pwd)/public/assets:/app/public/assets ${DOCKER\_TAG\_NODE} yarn run build docker build --file ./docker/rails/Dockerfile --tag ${DOCKER\_TAG\_RAILS} .
静的ファイルの配信
コンパイル済みのassetsがイメージに含まれるようになったことに関連して、静的ファイルをS3から配信する運用もやめることにした。これまでは asset_sync を利用して、デプロイ時に静的ファイルをS3にアップロードした後、CDNを通して配信していた。しかし Please Do Not Use Asset Sync | Heroku Dev Center という記事にもある通り、この方法には次のように色々と不都合が多い。
- デバッグしにくい
- ロールバックしにくい
- 環境による差異が出やすい
- デプロイに時間が掛かる
代わりに、Webサーバから直接静的ファイルを配信しつつ、前段にCDNを置いてキャッシュさせることにした。具体的には、CloudFrontに /assets/*
に対するGETリクエストとHEADリクエストをキャッシュさせている。以下はHEADリクエストを送ってみた例。
$ curl https://amakan.net/assets/client-383ee728fc39c4060b0f.js -I HTTP/1.1 200 OK Content-Type: application/javascript Content-Length: 617037 Connection: keep-alive Date: Sun, 25 Dec 2016 18:50:27 GMT Last-Modified: Sun, 25 Dec 2016 16:05:31 GMT Strict-Transport-Security: max-age=15552000 Vary: Accept-Encoding X-Cache: Miss from cloudfront Via: 1.1 4218e6d02a86bf6e99855f3eddfab7a8.cloudfront.net (CloudFront) X-Amz-Cf-Id: DtSOAjXZ4OP8cVw23LZhEJ3JVdS0z4TudfOi2pDfPsGXtkPCAhqekA==
X-Cacheヘッダなどで分かるように、1度目のリクエストはCloudFrontにキャッシュが無く、Webサーバにリクエストが飛んでいる。
$ curl https://amakan.net/assets/client-383ee728fc39c4060b0f.js -I HTTP/1.1 200 OK Content-Type: application/javascript Content-Length: 617037 Connection: keep-alive Date: Sun, 25 Dec 2016 18:50:27 GMT Last-Modified: Sun, 25 Dec 2016 16:05:31 GMT Strict-Transport-Security: max-age=15552000 Vary: Accept-Encoding Age: 6 X-Cache: Hit from cloudfront Via: 1.1 ebc77629c4b26fbccc1b57ff75631a1c.cloudfront.net (CloudFront) X-Amz-Cf-Id: JBIQSgCoiBex6eGEONFnvD4K0R43DlDbd8dbYdXT2fXSq9aM3WUi5A==
2度目のリクエストでは、CloudFrontがキャッシュを返している。ちなみにCloudFrontのcompressオプションを有効化してあるので、リクエストするときに Accept-Encoding: gzip を付ければ、自動的にgzip圧縮されたファイルが配信される。
リバースプロキシを排除
前述したようにWebサーバの前段にCloudFrontを配置するようになった訳だが、これまでNginxが担当することになっていた静的ファイルの配信も、今回からRailsに任せることにした。今までamakanがNginxを利用していたのは、次のような理由からだった。
- 静的ファイルを配信するため
- Let's EncryptのSSL証明書を利用するため
- スロークライアント対策のため
これは以下のような理由で不要になったため、Nginxの必要性が薄まった。
- Railsに任せることにした + 前段にCDNを設置した
- AWS Certificate Managerの証明書を利用することにした
- UnicornからPumaに移行した
更に今回のDocker化に際して、Nginx用のコンテナを管理し、Rails用のコンテナと協調させるのも大変だったので、この機会にNginxは使わないことにした。The Twelve-Factor App に倣うと、アプリケーションの動作に必要な静的ファイルの配信も、アプリケーション自体が責任を持つべきである。まあそこまで深く考えてなくて、その方が楽だったから変えた。
イメージのビルド
CIでは毎回イメージをビルドし直すようにしていて、都度ビルドされたイメージを利用してテストが実行される。masterブランチでテストが通った場合は、そのイメージがレジストリに登録され、のちのデプロイに利用される。逆に言うと、デプロイするとmasterブランチでCIが通った最新のイメージがデプロイされるということになる。
CIにはCircleCIを使っている。参考までに、circle.yml は次のような感じ。プライベートなDockerイメージレジストリとして、Amazon ECRを使っている。
machine:services:- dockerdatabase:override:- echo "Skip database phase"dependencies:override:- docker build --file ./docker/node/Dockerfile --tag ${DOCKER\_TAG\_NODE} . - docker run --volume $(pwd)/public/assets:/app/public/assets ${DOCKER\_TAG\_NODE} yarn run build - docker build --file ./docker/rails/Dockerfile --tag ${DOCKER\_TAG\_RAILS} .deployment:docker:branch: master commands:- eval $(aws ecr get-login --region ap-northeast-1) - docker push ${DOCKER\_TAG\_RAILS}test:override:- \> if [["${CIRCLE\_BRANCH}" != "production"]]; then docker run --rm ${DOCKER\_TAG\_NODE} yarn run test docker run --rm ${DOCKER\_TAG\_RAILS} bundle exec rspec fi
ECS
本番環境でDockerを動かすために、Amazon ECSを利用した。Amazon ECSを使うと、ざっくり言うと「このイメージを利用して3つほどコンテナを起動しておいてくれ」「ロードバランサから適当に繋ぐんでよろしく頼む」ということができる。
amakanでは、amakan_pumaとamakan_sidekiqというコンテナをそれぞれ1つずつ起動してもらっている。ECSでは複数の仕事をクラスタという単位にまとめて管理するようになっている。以下の図では、amakanというクラスタがあり、サービスというのが2つ動いており、CPUはほとんど使ってないわりにメモリを沢山使っていることが分かる。
amakanクラスタの詳細を表示すると、amakan_pumaとamakan_sidekiqというサービスが動いているということが分かる。
amakan_pumaサービスの詳細を表示すると、amakan_pumaというタスク定義のv1を元にしたタスクが動作中で、Dockerコンテナが3000番ポートを露出させており、これがamakanロードバランサに紐付いていることが分かる。
タスク定義というのは「amakan_railsというDockerイメージで bundle exec puma
コマンドを動かしてくれ」というのが定義してあるもので、サービスには対応するタスク定義を設定することになっている。「amakan_pumaサービスではamakan_pumaというタスク定義を元にコンテナが常に1つ起動されるようにしておいてください」という設定がなされている。実際に起動されるコンテナは、タスク定義を元に動作するタスクという単位で管理されている。
タスクは、実際にはクラスタに登録されたEC2インスタンス上で実行される。EC2インスタンスでecs-agentを動かして所属すべきクラスタの名前を教えてあげると、クラスタを見つけてそこに所属してくれる。amakanだとオートスケーリングを利用してEC2インスタンスが自動的に起動するようにしてあって、ecs-agent が最初から入っているAMIを利用してインスタンスを起動し、起動時に所属すべきクラスタ名を教えている。
Terraform
ECS導入にあたりVPCを利用する必要があったので、この機会にVPCを積極的に使うような構成に移行することに。これまではRoute53、EC2、RDSを触るだけで規模も大きくなかったので手作業で登録していたが、扱うリソースも増えてきたのでterraformを利用することにした。
例えばECSのところで説明した設定は、terraformのコードでこういう風に書かれている。
resource "aws\_launch\_configuration" "amakan" { name = "amakan" instance\_type = "t2.micro" image\_id = "ami-08f7956f" iam\_instance\_profile = "${aws\_iam\_instance\_profile.amakan.id}" associate\_public\_ip\_address = true security\_groups = ["${aws\_security\_group.amakan\_web.id}"] key\_name = "amakan" user\_data = "#!/bin/bash\necho ECS\_CLUSTER='${aws\_ecs\_cluster.amakan.name}' \>\> /etc/ecs/ecs.config" }
定義されている全てのリソースを洗い出すと、こういう感じ。規模感としては、全体で500行程度のコードになっている。
aws\_alb\_listener.amakan aws\_alb\_target\_group.amakan aws\_autoscaling\_group.amakan aws\_cloudfront\_distribution.amakan aws\_db\_instance.amakan aws\_db\_parameter\_group.amakan aws\_db\_subnet\_group.amakan aws\_ecr\_repository.amakan\_rails aws\_ecs\_cluster.amakan aws\_ecs\_service.amakan\_puma aws\_ecs\_service.amakan\_sidekiq aws\_ecs\_task\_definition.amakan\_puma aws\_ecs\_task\_definition.amakan\_sidekiq aws\_elasticache\_cluster.amakan aws\_elasticache\_subnet\_group.amakan aws\_iam\_instance\_profile.amakan aws\_iam\_policy\_attachment.amakan\_ecs\_ec2\_instance aws\_iam\_policy\_attachment.amakan\_ecs\_service aws\_iam\_role.amakan\_ec2 aws\_iam\_role.amakan\_ecs aws\_iam\_user\_policy.amakan\_circle\_ci aws\_iam\_user.amakan\_circle\_ci aws\_internet\_gateway.amakan aws\_launch\_configuration.amakan aws\_route\_table\_association.amakan\_a aws\_route\_table\_association.amakan\_c aws\_route\_table.amakan aws\_route53\_record.amakan\_root\_a aws\_route53\_zone.amakan aws\_security\_group.amakan\_alb aws\_security\_group.amakan\_elasticache aws\_security\_group.amakan\_rds aws\_security\_group.amakan\_web aws\_subnet.amakan\_private\_a aws\_subnet.amakan\_private\_c aws\_subnet.amakan\_public\_a aws\_subnet.amakan\_public\_c aws\_vpc.amakan
VPC環境への引越しのとき、DBだけは状態を持っているのでデータをコピーする必要があった。これはこういう感じで処理した。
- terraformを利用して空のRDSのインスタンスを作る
- コンソール上からそれを削除する
- 稼働中の方のRDSのインスタンスからスナップショットを取る
- 取得したスナップショットを元にVPC上に再度RDSインスタンスを作る
- 接続先のRDSインスタンスをVPC上に作成したものに切り替える
- terraform import を利用してterraformの状態を合わせる
おわり
長くなってしまったけど、本番環境のDocker移行でやった作業は以上。長くなりそうなので触れなかった話題としてこの辺りの話があるが、また後日。
- ログ
- デプロイ
- スケーリング