production環境でRailsアプリをdockerコンテナとしてECSで運用するために考えたこと

  • 207
    いいね
  • 0
    コメント
この記事は最終更新日から1年以上が経過しています。

実際には、まだ本当の本番環境では運用できてなくて開発用のステージングで運用が開始できたぐらいで、他にもやること一杯あるんだけど、ある程度知見が溜まったのでまとめておく。
ちなみに、規模はそんなに大きくないがデータ量は多いアプリケーションで運用環境はAWSのECSを想定しており、現時点で普通のEC2ノードとコンテナで並行稼動している。
docker-swarmなりで自前でコンテナプールを構築してもいいのだが、そうするとサービスディスカバリとか考えなければいけないことが増えるので、後回しにしている。

(注: かなりサービス固有の事情が含まれるため、もし参考にされる方が居たとしても、そのままの形では適用できない可能性が高い)

追記:
RailsアプリのためのDockerfileとdocker-compose.ymlのサンプル - Qiita

コンテナ化のモチベーション

  • CentOSのお守りからの脱却
  • chefだとミドルウェアを細かくバージョンアップするのがめんどい
  • ECSによる簡単なblue-greenデプロイメントフローの構築
  • EC2ノード数の節約

ECSの良い点と悪い点

まず、Rails固有の話に入る前にECSについて少し触れておく。
ECSは、実質的にはdocker管理のためのエージェントが入ったただのEC2ノードと、コンテナ定義の管理サービスの組み合わせで動くものだ。
ECSのコンソールでクラスタを定義し、ecs-agentが同梱されて最適化されたAmazonLinuxのAMIを使って起動する(起動時に設定を突っ込む必要はある)とクラスタに勝手にジョインする。
このクラスタにECSで定義したTask Definitionを展開することでdockerコンテナが動作する。
Task Definitionとは複数のコンテナをセットにしたもので、同じTask Definitionに含まれるコンテナは同一ノードで動くことが保証される。

継続的に運用するコンテナのためにServiceという概念があり、ServiceはどのTask Definitionをいくつ起動するか、どのELBと関連しているかを設定できる。
Serviceは起動しておきたいTask Definitionの数を維持しようと自動的に動作する。足りなくなれば自動で起動する。
そして、関連しているELBに対して、そのServiceがTaskを起動したEC2ノードを自動で登録する。Taskが終了した場合は、自動でELBから外してくれる。

Serviceをアップデートすると、Deployment Configurationに従って、コンテナの差し替えが自動で行われる。
これはコンテナを差し替えする時に、最大で希望しているTask数を何割越えて追加で起動するか、最低でもいくつのTaskの数を維持し続けておくかを設定できるもの。
ECSはデフォルトで、希望している数のTaskを維持する設定になっているので、これを利用することで簡易的なblue-green deploymentをほぼ意識することなく実施できる。

悪い点としては、コンテナ間のリンクが同じTask Definitionに限定されていること。
多分、細かい粒度でコンテナを使い回そうとするとかなり困難を強いられる。
外からアクセスがあるサービスについてはELBと密接に結び付くことになる。ELBの限界があって同一ポートを複数サービスで使い回すことが難しい。

また、現時点でAutoscalingとECSのServiceに設定されている希望Task数が連動していない。
数を柔軟にコントロールしたい場合は、AWS LambdaとCloud Watch等を組み合わせて、上手く調整する必要があるかもしれない。

2016-5-24 追記
AWSにApplication Autoscalingなる概念がいつの間にか登場していて、これでECSのserviceがAutoscaleできるようになるらしい。
USでは既に利用できるようだが、JPはまだしばらく先になりそうだ。ECRと同時に早い内に来てくれると良いなあ。

RailsをDockerで動かす際の問題点

DockerでRailsアプリを動かしてみたという記事は色々あるのだが、余り本番環境で運用することまで見越して書かれたものは無い。
正直、ただRailsアプリを手元で動かすだけなら別に何も問題はない。docker-composeでも使えばすぐ済む。
しかし、本番環境で運用するためには色々と考えなければいけないことがある。

  • Railsアプリとはいえ、動くものがRailsのプロセスとは限らない
    • sidekiq その他の非同期処理プロセス
    • cronによるバッチ処理
  • assets:precompile
  • db:migrate
  • 各RAILS_ENV毎の設定
    • 各サービスのトークン情報
    • 接続先ミドルウェアの情報
  • チームメンバーの習熟度
  • deployスクリプト
  • どのタイミングでイメージを作るか
  • ログの取得

ざっくりとこれぐらいの問題は解決しなければならない。
これらを解決するために、何をやったのかについて紹介していく。

Railsのプロセス以外も動作できるようにする

各起動プロセスのためにDockerイメージを作るか、ENTRYPOINTを工夫することで必要なプロセスを起動できるようにする方法がある。
複数種類のイメージをビルド・管理する手間を考えたくなかったので後者の手段を取った。
結論としては、EntryKitを使うと便利。

progrium/entrykit

別に自分でシェルスクリプト書いてラップしても良いけど、どっちにしろ一つのENTRYPOINTで色々やれるようにしておく必要がある。
この時、何だかんだでshellは起動できるように設定しておく方が良い。もちろん、docker execで接続できるならそれでも良い。
結局中入ってコンテナの状況を確認しつつ逐次処理した方がイメージ作成時にハマらなくて済む。

各RAILS_ENV毎の設定

EntryKitのprehookを利用して、コンテナ起動時に強制的に実行される事前処理を用意し、そこで各環境毎の設定ファイルを準備する。
設定ファイルはerbでテンプレート化しておき、事前処理の中でテンプレートをレンダリングする、もしくはS3,etcd等の外部ストレージから取得する形を取る。
各環境毎にDockerイメージを作ることも可能だが、同じくイメージのビルドと管理の簡易化のために起動時に処理する方式を採用した。

秘匿しておきたいトークン情報等に関しては、joker1007/yaml_vaultを利用して暗号化した設定ファイルごとイメージを構築している。
起動時に環境変数で鍵を渡して、トークン情報を復元している。
この辺りは、本格的に管理する場合、もっと工夫の余地がある気がするのだが、管理の複雑さが増すのでこれ以上は後回しにしている。

雑なサンプルだとこういう感じになる。

# docker/setup.sh

erb docker/config/database.yml.erb > config/database.yml
erb docker/config/sidekiq.yml.erb > config/sidekiq.yml
bundle exec yaml_vault decrypt "config/secrets.yml" -o "config/secrets.yml" -k "production.vault"
# Dockerfile

# ...

ENTRYPOINT [ \
  "prehook", "ruby -v", "--", \
  "prehook", "sh ./docker/setup.sh", "--", \
  "bundle", "exec", "unicorn_rails", "-c", "config/unicorn.rb" ]

ビルドサーバーを用意する

変にCIサービスだけで完結しようとすると、キャッシュの問題で悩んだり実行時間で困ったりする。
ビルドサーバーをちゃんと用意することでいくつかの問題が解決できる。
最新のDockerじゃなくて良いなら、AmazonLinuxを適当なノードで起動してcloudinitとyumでdockerをinstallするなり、簡単なchefを書けばそれで用意できる。
自分の環境では、Dockerのプライベートレジストリを構築しているので、そこと兼用している。

ビルドサーバーを用意したら、capistrano(sshkit)を使って、cap taskを実行したら現在のコミットのSHA1を利用してビルドサーバー上でコンテナをビルドするような処理を書く。
ビルドサーバーがあることで、各チームメンバーがdockerをインストールしたり動きを覚えてもらう必要が無くなり、インフラの面倒を見る有識者が知っていれば、とりあえず運用ができるようになる。
開発者が任意のコミットをステージングにdeployして動きを確認したい時も、今まで通りcapを叩けば自動で処理されるようになる。

assets:precompileのタイミング

assets:precompileはイメージビルドのタイミングで実施して、必要なファイルを生成しておく。
その後の保存先は状況によって変わってくるが、自分はS3に生成物を保持している。

流れは以下の様になる。

  1. docker buildを実行する際、必要な全ての環境分のassets:precompile (assets:sync)を済ませてしまう
  2. 生成したmanifest.jsonを対象となるRAILS_ENVとコミットのSHA1が分かるようにリネームしてS3等に保存しておく
  3. 自身でアセットをホストする必要が無いなら、コンパイルした結果は無駄なので削除してもよい
  4. コンテナ起動時の事前処理の中で、起動したいRAILS_ENVと対応するSHA1からmanifest.jsonを取得するようにしておく

これで、イメージは共通化しつつ、どのRAILS_ENVでも起動できるようになる。
もし、development環境で起動したい場合は、単にmanifest.jsonの取得をバイパスすれば良い。

こういった形にした場合、docker buildの度にassets:precompileが走ることになる。
その時間を短縮するため、docker buildした後、docker cpコマンドを使ってコンテナ内からtmp/cache/assetsを抽出してビルドサーバー上に保管しておく。
次回、docker build実行時に、それを展開して配置しておくことで、assets:precompileがかなり高速化される。
何かキャッシュが悪さした時のために、キャッシュの利用をスイッチングできるような処理は書いておいたが良いと思う。

この方法は、bundle installの高速化にも応用できるのだが、今の所そこまではやってない。

ビルドサーバーでの処理全般をまとめると、大体以下の様な感じになる。
(サービス固有の事情が結構含まれている上に継ぎ接ぎで書かれているので見辛いが……)

https://gist.github.com/joker1007/8f1ab3dce906296f6655d43e53834b00

db:migrate

現時点では未実施だが(並行稼動中のため後回し)、db:migrateを実行するTask DefinitionをECSに登録して、capの中でコンテナを実行すれば良いと思っている。
そもそも一定規模以上のアプリケーションでdb:migrateを使うべきか?という問題もある。

イメージ作成のタイミング

assets:precompileを含めてもイメージの作成にそれ程時間がかからないので、今はデプロイのタイミングで一緒にビルドしている。
各開発者が任意のタイミングでステージング環境にデプロイする場合は、必要な時に必要なイメージだけビルドされるので無駄は少ないが、リリースまでのリードタイムを考えると検討の余地がある。

deployスクリプト

aws-sdkを使ってECSのTask DefinitionとServiceを更新し、デプロイ状態を待ち受けるスクリプトを書いた。一応汎用的に使えるように書いたが、作りが雑なのと楽さが足りない感があるのでgem化等はしていない。

https://gist.github.com/joker1007/7158adaf70c92e040741298251f9ec87

ログの取得

「ルーク、fluentd log driverを使え」って感じ。

その他のハマりポイント

C拡張を利用するgemを利用している際、強制的にmarch=native等のコンパイルオプションが付与されている場合がある。
こういったgemが紛れていると、Dockerイメージをビルドした環境と実行する環境でCPUアーキテクチャに違いがあるといきなりSegmentation Faultが発生したりして焦るので気を付けなければいけない。
自分の例だと、EC2のt2.large等でビルドしてm3.mediumで起動したらセグフォという事象があった。
正直、ビルドした環境のCPUを気にしてコンテナを起動するのは嫌なので、forkしてコンパイルオプションを修正するという、残念な対応を取る羽目になった。

まとめ

コンテナ管理の面倒な部分は大体ECSに任せて、Railsを複数のRAILS_ENVで起動させるために必要なハックにフォーカスした。
その際重視したのは、環境毎の差異は起動時に強制的に走る事前処理を挟んでそこで吸収するようにしたこと。
これにより、イメージのビルドと管理が一本化できた。
しかし、事前処理として用意しているスクリプトが肥大化する危険もあって、一長一短だと思う。
そもそも環境毎に変な差を生み出さないような設計をしておくのが大事。
前提条件としてRAILS_ENVを使い回して複数環境を準備するのは止めた方が良いと思う。
設定ファイルを一元化できなくて管理がややこしくなる。
必要だったら、ちゃんとstagingなりpre_releaseなりRAILS_ENVを作った方が良い。

全体的に、もうちょっとスマートにやれる方法があると良いのだが、余り良い解決策が無い。
RAILS_ENVや各環境の用途毎に設定に差が生じるのはどうしようもないことで、ここを何とかするのがとても面倒臭い。
マイクロサービスとして構築されたシンプルなアプリケーションを個別にコンテナ化する方が親和性高いのは間違いないなあというのは実感した。

未解決の課題

AutoscalingとECSの起動タスク数を自動で調整する仕組み作りを、本当の本番運用に入るまでに解決しなければならない。
CloudWatchのアラームを使って、AWS LambdaでAutoscalingとECSのサービスをまとめて制御しなければいけない気がする。
AWSで何とかして欲しい……。