既存Railsプロダクトのdocker化

どうも、プログラマとして入社したのになぜか最近コードあまり書いてないぽんこつです。ここ数ヶ月で既存プロジェクトを頑張ってdocker化してECSにデプロイしたのでまとめます。

ただ社内でもあまりノウハウが蓄積されているわけではなく、試行錯誤の連続で、一旦公開することにしましたが、今後も随時変更されていくと思いますし、是非「弊社はこうしたぜ〜」とかあれば参考にしたいので、教えていただけると幸いです。

設計

今回Docker化したプロジェクトは、unicornとsidekiqを使ったサービスで、静的ファイルはnginxが管理しているようなサービスです。一般的なよくあるRailsの構成だとおもいます。

これをdocker化してECSのサービスとして載せるにあたり、ECSのサービスとしてはweb-appとsidekiqの2つ、web-appは内部でnginxとunicornのコンテナをそれぞれ持つようにしています。sidekiqとunicornのイメージは同一で、commandの指定でそれぞれunicorn、sidekiqを起動しています。

既にあるALBからのリクエストを切り替える感じになります。

Terraform

ECSの構成が複雑になることが予想されていたので、terraformで構成管理をすることにしました。terraformは既存の構成をtfファイルに落とすような使い方は難しそうだったので、新規で構成した部分のみの対応です。インフラの変更をGitHubのPullRequestベースで管理できるようになったのは大きな進歩だと思っています。

静的ファイル

非常に悩んだのですが、静的ファイルは結局nginxのコンテナに載せることにしました。速度的にunicorn管理下だと辛いと思ったのが理由なのですが、結果的にnginxのimageを管理する手間が1つ増えることになりました。

後でこの話を別の人にしたら、前段にCloudFront置いてunicorn管理下に置いてるという話を聞いて、そっちの方が良かったかも、と今では思ってます。

ちなみにS3管理案も考えたのですが、切り戻しを考えると面倒が多いのではないかという気がします。

Docker imageの作成

Railsは既に開発環境だけdocker-composeに移行していたので、本番でも動くように若干の修正をくわえただけです。nginxはassets:precompileした後にそのassetsをコピーして設定ファイル封じこめてdocker-compose buildみたいなシェルを書きました。強引。

ちなみにUUIDでもなんでもいいのでタグ付けは必須です。latestだと切り戻しできなくなります。注意!

Capistranoの置換

RailsのDocker化最大の難所はCapistranoの再実装です。半分以上はECSが肩代わりしてくれますが、なかなかそうもいかない箇所もあります。ECSが全部やってくれないものとしては以下のようなものがあります。

DBのマイグレーション

自前でやる必要があります。悩んだ結果、db:migrate用のタスクを単体でECSで実行することにしました。新しいマイグレーションファイルのあるimageでcommandを弄ればできます。

当初はunicornが起動するタスクのcommandにdb:migrateを書いてしまおうと思ったのですが、appサーバの台数分同時にdb:migrateが走ったときに何が起こるのが想像が付かなかったのでやめました。PostgreSQLならALTER TABLEでtransactionが有効になってるので問題無い気がしますが、MySQL系はALTER TABLEでのTransactionが効きません。

Deployまわり

既存のRailsプロジェクトのdeployはCircleCIでcapistranoを叩くような形式になっていました。デグレしないためにはこれを再現する必要があります。

色々悩みましたが、最終的にこれはシェルスクリプトでやるしかないという結論になりました。circleci.ymlに直で書いても良かったのですが、手元でもdeployができる状態を維持したかったのでシェルスクリプトを作り、それをCircleCIで叩く方法になっています。

シェルスクリプトもそんな複雑なことはしておらず、

  • docker-composeでimage作成
  • imageにタグ付け
  • taskのイメージを変更してterraform apply
  • db:migrateタスクの起動

を順番に実行するだけです。

Wheneverの置き換え

地味に面倒くさかったのが、Wheneverの置き換えです。Wheneverは自動でrakeタスクを実行するようにcronを設定する仕組みですが、多くの場合複数のマシンで動かすのは困るので1台だけで実行する必要があります。また、えてしてcronとdockerは相性がよくありません。

方法の1つはシンプルにDBマイグレーションでやったようにECSのタスクとして実行する方法ですが、こと我々のプロジェクトに限っては非常にコストが掛かる方法でした。なぜかというと1分毎に実行しているpollingタスクがあったからです。

結果的に私が選んだのは、rakeタスクをcontrollerから実行できるエンドポイントを作り、lambdaから定期的にHTTPリクエストを投げるという方法です。HTTPアクセスはserverlessのプロジェクトを作ることで実現し、これは当初不安定でしたが今は問題なく動いているように見えます。

rakeタスクをcontrollerから叩くのは以下のようなコードで実現できます。

Rails.application.load_tasks

Rake::Task[task_path].execute

Rake::Task[task_path].clear

しかしこのコードは一方で「rakeタスクを実行するたびにtaskのツリーを全部読みにいっている」ようです。実際これは実行に数秒かかるため、Controller直下ではなく、sidekiqで実行するようにしています。このあたりが原因かは分かりませんが、ひどく不安定で、今同じことをやるなら回避します。今更ですが、きちんとcron用のECSサービスを作り、wheneverをそのまま使って展開した方が良かったかもしれないです。

現状の問題点

このように既存のRailsプロジェクトをDocker化してECSに載せた訳ですが、現在分かっている問題の1つにdeploy時間があります。

CircleCI上でassets:precompileをかけ、2つのdocker imageを、キャッシュの効かないCircleCI上で動かしているので問題があります。これはキャッシュが効かないというよりキャッシュを活用してない方に問題があります。