Rails アプリケーションを Fargate に移行した

これが

f:id:kirikiriyamama:20200630225315p:plain

こうなった

f:id:kirikiriyamama:20200704190805p:plain

インフラ構成

リバースプロキシをなくした

リバースプロキシを採用する場合、主な役割としては以下が想定される。

  1. SSL/TLS 終端
  2. リクエストのキューイング
  3. 静的ファイルの配信

このうち 1 と 2 については ALB で代替することができる。現に移行前の時点でこれらは CLB でも行われていた。また 3 については CDN のほうがよりうまく扱えるはずだ。

そこで、リバースプロキシ (今回は nginx) でやっていた処理を Rails に移植してリバースプロキシをなくすことにした。Rails でやることは増えるが、構成をシンプルにできるメリットのほうが大きいと判断した。また、複数のコンテナを協調させる難易度が高いということも理由として挙げられる。

移植した処理内容については後述する。

静的ファイルはコンテナに含める

静的ファイルを S3 等のストレージから配信する場合、ポータビリティの低下といった問題が挙げられる。アプリケーションが Docker イメージだけで完結しなくなり、またそれに伴いデバッグが複雑になってしまう。そこで、CloudFront を前段に配置し、静的ファイルは Rails のコンテナに持たせることにした。Rails で静的ファイルを配信することのパフォーマンス面については後述する。

CloudFront は Rails とは別ドメインにする。Railsexample.com とすると CloudFront が assets.example.com というような構成だ。静的ファイルは assets.example.com を介して、その他は example.com から取得する。このとき CloudFront のオリジンは example.com になる。全てのリクエストを CloudFront 経由にするパターンもあるが、CDN の設定をなるべくシンプルにしておきたいことから採用はしなかった。少し話が逸れてしまうがポータビリティの観点から CDN はコンテンツ配信に専念させたいと思っていて、アプリケーションの動作に CDN の機能が必須になってしまうような状況は避けたい。

一応、動的ページに CloudFront 経由でアクセスされることも想定されるが、RailsCache-Control: max-age=0, private, must-revalidate を返す (つまり CF にはキャッシュされない) ため特には問題にならないと考えている。この場合、検索エンジンにクロールされると重複コンテンツと判定される恐れがあるため view に <link rel="canonical"> を追加しておく。

また、静的ファイルを別ドメインから配信する場合は CORS を有効にする必要がある。今回は rack-cors を用いた。

リバースプロキシでやっていた処理の移植

静的ファイルの配信

CloudFront を前段に配置することを前提に、以下の要件で配信をしたい。

  • /assets 以下はキャッシュする (fingerprint があるため)
  • それ以外は更新がないか常に確認させる

Rails 標準の仕組み (config.public_file_server) ではリクエストパスに応じて Cache-Control を制御するといったことができず、要件を満たすことができない。そのため config.public_filer_server を参考に Rack middleware を作成した。

class Rack::MyStatic
  def initialize(app, root: 'public')
    @assets = ActionDispatch::Static.new(
      app, root, headers: { 'Cache-Control' => 'public, max-age=315360000' }
    )
    @public = ActionDispatch::Static.new(
      app, root, headers: { 'Cache-Control' => 'no-cache' }
    )
  end

  def call(env)
    req = Rack::Request.new(env)

    case
    when req.path.start_with?('/assets/')
      @assets.call(req.env)
    else
      @public.call(req.env)
    end
  end
end

Rails へのリクエスト数は増えるが、静的ファイルの大多数を占める /assets 以下は CloudFront でキャッシュすることができる。実際、この変更の前後に New Relic を確認したところ、特にパフォーマンスに影響はなかった。

また、移行前の構成では nginx が gzip 圧縮をしていたがこれは CloudFront で行う。

www ドメインを naked ドメインにリダイレクトする

www.example.comexample.com にリダイレクトするやつ。rack-rewrite を用いた。

Dockerfile

base と呼ばれる、依存パッケージや gem をインストールしたイメージを起点に、development や production のイメージを作成する。イメージを共通にしたいところではあるが、development と production は必要になるパッケージも異なるため完全に共通にすることは難しいと考えている。例えば development では E2E テストに使用するため Chrome が必要になる。

また、今回移行したアプリケーションには RAILS_ENV=staging の環境が存在しているのだが、staging と production でも Dockerfile を分けている。CloudFront から静的ファイルを取得するために config.action_controller.asset_host を指定していて、その値が静的ファイルに埋め込まれるためだ (つまりRAILS_ENV によって静的ファイルに差分がある)1。これはなんとかしたいと思っている。ジャストアイデアだが全てのリクエストが CloudFront を介するようにすれば asset_host を指定する必要もなくなるし RAILS_ENV=staging もなくせるのかもしれない。

CI/CD

CircleCI をつかっている

テスト

先述の development のイメージを用いてテストを実行している。都度イメージをビルドしているのだが、ここで考える必要があるのがキャッシュによるビルド高速化だ。今回は CircleCI の Docker Layer Caching を採用した。正直高いとは思うが、手っ取り早い。また、DLC に加えてビルドしたイメージを ECR に push/pull している。DLC は状況によっては効かない可能性があり (例えば大量のブランチを同時に push した場合など2)、保険としてビルド済みのイメージを --cache-from に渡している。イメージの push はブランチ単位で行い、pull はカレントブランチか存在しなければ master のイメージで行う。CircleCI の save_cache/restore_cache に近いイメージだ。

docker image push IMAGE_NAME:CURRENT_BRANCH_NAME
docker image pull IMAGE_NAME:CURRENT_BRANCH_NAME || docker image pull IMAGE_NAME:master
docker image build --cache-from IMAGE_NAME:CURRENT_BRANCH_NAME --cache-from IMAGE_NAME:master

このキャッシュ戦略はあくまでレイヤーキャッシュを用いているため、 以下のような Dockerfile で Gemfile に変更があった場合、以降のレイヤーではキャッシュを使うことができず bundle install をやり直すことになってしまう。

COPY Gemfile Gemfile.lock ./
RUN bundle install

この問題は、Buildkit の Cache Mount をつかって gem のキャッシュをすることで軽減できる気がしている。

デプロイ

https://github.com/eagletmt/hako を使っている。db:migrate のような処理は hako oneshot (RunTask) で実行する。

hako の環境は Docker イメージで用意していて3、これにリポジトリに存在する hako の definition file を渡して実行する。順当にやるとボリュームをマウントすることになると思うが CircleCI の docker executor では使えないため4、Dockerfile を用意して COPY するか docker cp をつかって回避する。

秘匿値

SSM Parameter Store で管理していて、Task Definition の secrets パラメータを用いて環境変数に展開している。

rails console

Fargate の場合、SSH ログインができないため rails console 等つかうことができない。そもそも本番環境で rails console なんてつかわないほうがいいとは思うが、やんごとなき理由で rails console が必要なときのために SSM Session Manager (エージェントを介してシェルアクセスを提供する機能) で Fargate インスタンスにログインできるようにした。大まかな流れは以下となる。

  1. SSM Activation を作成する
  2. hako oneshot で ECS Task を作成する
  3. aws ssm start-session でログインして作業をする
  4. 後処理
    • ECS Task の終了
    • SSM Managed Instance の削除
    • SSM Activation の削除

SSM Agent はイメージに含めていないためアドホックにインストールする。今回は ECS Task を作成する際に CMD に SSM Agent をインストール/起動するスクリプトを指定するようにした。

注意点として、SSM Activation は毎回作成する必要がある。Activation には有効期限を設定しなければならないのだが、設定できる値は最長で 30 日後となっている。つまり Activation を 30 日以上使いまわしたい場合は再作成の必要がある。期限の管理をするのも大変なので都度作成するようにしたが、もっといい方法がある気もしている。

余談だが Session Manager は Session の出力内容 (操作ログ) を CloudWatch Logs や S3 に転送することもできる。script(1) を使ってやっていたようなことが簡単に実現できて非常に便利だ。

ログ

CloudWatch Logs に転送している。ログを確認するときは CloudWatch Logs Insights や https://github.com/knqyf263/utern5 を使う。

終わりに

移行にあたっては、Route53 の Weighted Routing をつかって新環境に段階的にトラフィックを流すようにした。いわゆるカナリアリリースである。幸いにも移行は成功して、その後もトラブルなく運用できている。

Fargate というよりはほとんど Docker と ECS の話だったが、なにはともあれ Fargate によってインスタンスの管理から解放された。