ECS を利用したオフラインジョブの実行環境

技術部の鈴木 (id:eagletmt) です。 クックパッドでは以前からアプリケーションの実行環境として Docker を利用していましたが、最近は徐々に Amazon EC2 Container Service (ECS) を利用し始めています。 去年の時点での Web アプリケーションのデプロイ手法 *1 や、最近 ECS を利用してどう Web アプリケーションをデプロイしているか *2 については紹介したことがあるので、今回は定期的なバッチ処理やジョブキューを介して非同期に実行されるようなオフラインの処理について、どのような環境を構築しているか紹介したいと思います。

Docker を使う前

Docker を利用し始めるより前から社内では kuroko2 *3 というジョブ管理システムが稼動しており、複数のアプリケーションから利用されていました。 kuroko2 は定期的にジョブを実行するために必要な機能を十分そなえている一方で、複数のアプリケーション向けにワーカインスタンスをプロビジョニングする必要があること、ジョブを実行するワーカを柔軟に増減できないこと、といった欠点がありました。

Docker の導入

Docker を利用することにより、複数のアプリケーションから共通で使われつつもインスタンス側に必要なプロビジョニングを最低限に抑えることができるようになりました。 インスタンス側の構成はシンプルになり、新しくアプリケーションを追加するときに必要な手間も非常に小さく済みます。 プロビジョニングの手間だけでなく、API キーやパスワードのような秘匿すべきクレデンシャルがインスタンスに直接置かれることを防ぎ、ワーカインスタンスのインスタンスプロファイルにすべてのアプリケーションに必要な権限をつける必要がなくなり、共通のインスタンスを複数のアプリケーションで使い回しつつも権限が適切に分離できるようになりました。

社内では定期的なジョブを実行するときは kuroko2 を使うのが標準となっていたため、kuroko2 から Docker を利用するための機能追加やインスタンスの整備なども行いました。 これにより Web アプリケーションを動かすための Docker イメージを作ってあれば自動的に kuroko2 で実行する環境も手に入るようになり、アプリケーション開発者にとってオフラインのジョブ実行をする際の障壁が大きく軽減されました。

ECS の導入

Web アプリケーションのデプロイに ECS を利用し始めたのと同時に、オフラインジョブの実行にも ECS を利用し始めました。 Web アプリケーションのデプロイに使っているツールである hako を使って、kuroko2 から hako oneshot awesome-app.yml -- bundle exec rake some:heavy:task のようなコマンドを実行するようにしています。 hako oneshot は YAML の定義に従って ECS の RunTask API を呼び出すコマンドで、オフラインのジョブや bin/rails db:migrate のような単発のコマンドを実行するために使います。

直接 Docker を使っていたときと比較してよくなった点の一つに IAM ロールを利用できるようになったことがあります。 ECS ではインスタンス単位ではなくタスク単位で IAM ロールを利用でき、これにより AWS のアクセスキーを発行することなく AWS の API を利用したジョブを実行できるようになりました。

ジョブキューからの利用

先日の RubyKaigi 2016 で紹介されたように、現在 barbeque というジョブキューのしくみが整備されています。 barbeque からジョブを実行するときには社内では hako oneshot が使われています。 https://speakerdeck.com/k0kubun/scalable-job-queue-system-built-with-docker

barbeque へのエンキューは Web アプリケーションへのリクエスト起因であることが多いため、ジョブの実行回数、つまりジョブの実行に必要なリソースを事前に予想することが難しく、また時間帯によって刻々と変化するという特徴があります。 したがって、常に安定的にジョブを実行しつつコストを抑えるためには、オートスケールが重要になってきます。

オートスケーリング

ECS の導入によって、ワーカインスタンスのスケーリングが容易になりました。 ワーカインスタンスとして使うための ECS クラスタを作成し、そのクラスタのインスタンスはすべて AutoScaling グループで管理するようにしました。 hako oneshot は RunTask API を使っていますが、この API はもし実行に必要なリソースがクラスタ内に不足していた場合にリソース不足を知らせるエラーを返すので、そのエラーが発生した場合は AutoScaling グループの desired capacity を引き上げ、再度実行を試みるようにしています。 この挙動は hako の定義ファイルで autoscaling_group_for_oneshot に AutoScaling グループを指定することで有効化できます。 https://github.com/eagletmt/hako/blob/v0.20.0/examples/hello-autoscaling-group.yml

一方、スケールインの実行は CloudWatch のメトリクスを定期的にチェックすることで行っています。 ECS はクラスタ毎にどれくらい CPU、メモリが使われているかの割合を自動的に CloudWatch に保存しているため、直近の実績値を参照することで現在必要なリソース量を見積もることができます。 クラスタが提供するリソースのうち P % を利用しているような状況を維持したい場合、現在のインスタンス数を N とすると n 台減らせるかどうかの閾値は (N - n) * 100 / N * P になります。 リソース量の見積もりと閾値の計算を毎時実行し、見積もりが閾値を下回っていたら AutoScaling グループの desired capacity を下げることでスケールインを実現しています。

スケールインするときには、実行中のタスクが中断されないように注意しなければなりません。 AutoScaling にはライフサイクルフックというしくみがあり、AutoScaling によってあるインスタンスが terminating 状態になってから実際に terminate されるまでの間に終了処理を行えるようになっています。 ライフサイクルフックによって terminate が決定したときは AWS Lambda 経由でそのインスタンスに特別なタグをつけるようにしています。 各インスタンスは定期的に自分自身のタグをチェックし、もし特殊なタグがついていれば自分自身を DeregisterContainerInstance してサービスアウトし、その後ライフサイクルを継続させます。 もしそのインスタンス内でタスクが実行中であれば DeregisterContainerInstance は失敗するので、成功するまで DeregisterContainerInstance を実行し続けるようにしています。

今後の課題

ECS を利用しているとコンテナインスタンスのスケールアウトはしやすい一方で、スケールインをするには現状いくつかの工夫が必要になっています。 比較的短時間で終了するタスク専用のクラスタであれば DeregisterContainerInstance を成功するまで叩き続けるという方法でスケールインできるようになりましたが、 長時間実行が続くようなタスクが実行されてる場合や ECS の service を使っている場合など、すべての場合で自動的にスケールインすることはまだできていません。 このようなケースでもスケールインする方法を考える必要があります。

また、スケールアウトをするタイミングが現状は実際にリソースが不足したときになっており、一時的にジョブの実行が遅れることになります。 AutoScaling グループの desired capacity を変えてからインスタンスが起動してコンテナインスタンスとして登録されるまで3分ほどかかっています。 ジョブの実行ができるだけ遅れないようにするためには、インスタンスの起動を高速化する他に、リソースの使用状況の履歴から必要そうなときにあらかじめスケールアウトしておく方法があります。 しばらくはどういう状況のときに実際にリソースが不足してスケールアウトしているかを観察し、その結果をもとにスケールアウトする基準をどうするかを決めようと考えています。

まとめ

Docker や ECS をオフラインのジョブで利用するメリットと、実際にどう利用しているかについて紹介しました。 今後も ECS 自体の改善を注視しつつ、アプリケーション実行環境の利便性を高めながらもできるだけコストを抑えられるように改善していきたいと思っています。