Amazon ECSの安定運用

鈴木康平氏:「Amazon ECSの安定運用」というタイトルで発表したいと思います。今回のアウトラインとしては、「ECSをどう使うか」みたいな話ではなくて、そのECSを運用していく上でこんなことやっていますよということを話していければなと思います。

内容としては、コンテナインスタンスをどう管理してるのかと、あと各アプリケーションログをどう配送して保存してるのかと、最後にモニタリングについて話そうと思っています。

最初に規模感を共有しておこうと思います。

現在クックパッドでは、ほとんどのアプリケーションがECS上で動いています。「hako」という自作ツールを使ってアプリケーションのデプロイとか、あるいはバッチジョブの起動を行っています。

ECSクラスタ数はいま全部で40くらいあって、ECSのサービス数で言うと500くらいです。1日当たりのRunTask数、だいたいバッチジョブの起動回数だと思っていいはずですが、それが8万くらいという規模感で運用をしています。

では、まずコンテナインスタンスの管理について話していきます。

コンテナインスタンスとしては、オンデマンドインスタンスとスポットインスタンスの両方を使っています。オンデマンドのほうはAutoScaling Groupを使ってインスタンスの起動を行っていて、スポットインスタンスのほうはSpot Fleetを使っています。

いまオンデマンドとスポット比はだいたい全体だと1:4くらいになっています。これは海外のサービスとかも含めてこうなっているんですけど、日本のサービスに限るとオンデマンドの割合が10パーセントを切るくらいの割合になってます。

Fargateを使うケース、使わないケース

「コンテナインスタンスの管理」というと、おそらく「Fargateを使ったらいいんじゃない?」みたいな話が出てくると思いますし、実際にFargateを使っているところはあるのですが、基本的にいまは自前管理のインスタンスを使っています。

というのも、主な理由としては2つあります。1つはスポットインスタンスを使ったほうがなんだかんだ安いということ。あとは、Fargateが出てきたばかりなのでしょうがないところもあるかもしれませんが、起動が遅かったり、起動までの時間が安定しないという問題があることです。

Webアプリではそんなに頻繁に起動するわけじゃないので困らないのですが、そこそこの頻度で起動するバッチジョブでこういうことが起こるとけっこうつらいです。あんまり積極的には採用してません。起動後にENIをアタッチするまでに、5分くらい待ってタイムアウトして終わる、みたいなのがたまに発生していて、そういうのがあるとけっこうつらいので基本的にFargateは使っていません。

ただ、逆にFargateを使っているケースはどういうのかというと、ECSクラスタ自体を操作するジョブですね。この後に話しますけど、例えば、オートスケールを行うようなジョブ。ECSクラスタ自体のオートスケールを行うようなジョブはFargateを使って動かしていますし、特別に大きなCPUとかメモリリソースを必要とするジョブは、それだけのためにクラスタ作るのもめんどくさいだけなのでFargateでサクッと動かすというふうな使い方はしています。

オンデマンドクラスタの管理

オンデマンドクラスタのほうはどういうふうに管理してるかというと、全クラスタで共通のAMIから起動するようにしています。オートスケールはAutoScaling Group自体に「CloudWatchと連携してオートスケールする機能」があって、それは一切使っていません。全部自前でオートスケールしています。

なので、AutoScaling Groupの役割ですが、desired capacityというアトリビュートを上下することによってECSクラスタの数が増減できる状態にするところと、EC2のインスタンス障害が普通にあるので勝手に代わりのインスタンスを立ててくれるといったオートヒールとして使っています。

ECSのコンテナインスタンスを管理するときにスケールアウトするのは簡単です。ただ単に新しいインスタンスを起動してECSのエージェントを起動するだけでいいんですけれど、スケールインするときには事前にサービスアウトをしておく必要があります。

AutoScaling Groupの場合はlifecycle hookがあるので、それで一旦Terminateが決まったインスタンスはDRAINING状態にして、lifecycle hookを進める前にサービスアウトしきってからTerminateさせています。

おそらく、AutoScaling GroupでECSクラスタを作るときはだいたいどこでも同じようなことになるじゃないかなと思っています。

一方、スポットインスタンスはSpot Fleetで管理していて、こちらにもCloudWatchと連携してオートスケールする機能はありますが、それは一切使わずに自分でオートスケールをしています。

なので、こちらの役割もAutoScaling Groupと同様に、target capacityというアトリビュートを上下するだけでECSクラスタの増減ができるようにすることですね。あとはインスタンス障害ももちろんありますが、スポットインスタンスの場合はそのスポット価格が上昇すると勝手にTerminateされてしまうので、代わりのインスタンスを自動的に確保してくれるようにSpot FleetというAWSのサービスを利用しています。

Spot Fleetでのサービスアウト

こちらのサービスアウトも同じようにできればいいのですが、スポットインスタンス特有の問題があります。スポット価格の上昇で勝手にTerminateされてしまうんですよね。interruption noticeという通知がくるので、それを利用してサービスアウトを行っています。

interruption noticeを受け取る方法はいくつかあります。クックパッドではCloudWatch Eventsから受け取って、SQSキューにそれを通知して、デーモンがそのメッセージを受け取ってサービスアウトの処理を行うという流れにしています。

それと、スポットインスタンス特有の問題として、通知が来てから2分以内にサービスアウトをする必要があるんですね。2分後にほんとに勝手に、どうがんばってもTerminateされてしまって、AutoScaling Groupのlifecycle hookのように「サービスアウトするまで待ってくれ」みたいなことが一切できないのでそこをどうにかする必要があります。バッチジョブの場合は諦めてStopTask API、つまりアプリケーションにSIGTERMを送って止めています。

なので、スポットインスタンスでバッチジョブを動かすときは、アプリケーション側に冪等性を要求するというデザインにしました。普通のWebアプリをサービスアウトするときは、コンテナインスタンスをDRAINING状態にすればサービスアウトはされるんですが、それだけだと2分以内という時間制限に引っかかることがあるんですね。

そこを解決するために「Terminateされるよ」という通知が来たインスタンスをELBから先に自分で外してしまうようにしています。その後に、一定時間が経ってからStopTaskでそのタスクを止めるようにしています。

こういうことをすると当然、突然一部のタスクが停止しているように見えるので、キャパシティが一時的に足りなくなってしまうんですね。そうなってもちゃんと要求キャパシティを満たせるように、ふだんからやや過剰気味なキャパシティを常に確保しておくという戦略でやっています。

そうするとタスク数が増えてインスタンス数も増えてしまいますが、そうなったとしてもスポットインスタンスのほうがずっと安いので、こういう方針にしています。

簡単な図で説明するとこんな感じですね。Terminateされるときはinterruption noticeがCloudWatch Eventsから通知されるので、それをTerminatorと呼ばれるデーモンが受け取ります。バッチジョブの場合はまずDRAINING状態にして、新しいタスクが起動してこないようにしてから、StopTaskでそのタスクを殺すようにしています。

ELBがあった場合は間に処理が入ります。最初にDRAINING状態にして新規のタスクが来ないようにするところまでは一緒ですが、その後に自分でさっさとELBから外してしまって、ELBから外れたことを確認したら、StopTaskでタスクを止めてしまうようにしています。

オートスケーリングはどうやっているか?

こんな感じでクラスタをオートスケールするために、スケールイン時にこういった処理を行ってるんですが、そのオートスケーリング自体はどうやってやってるかっていうのをここから話していこうと思います。

オートスケーリングはオンデマンドクラスタとスポットクラスタの2つあるんですけども、その間にとくに戦略の差はありません。同じような仕組みでオートスケールを自前で実現しています。

AutoScaling Groupの場合は、desired capacityを調整すればよくて、Spot Fleetの場合はterget capacityという値を調整するかどうかの違いがあるだけで、戦略自体はどちらも一緒です。

それで、3種類のスケールアウトと1種類のスケールインを組み合わせて行っているので、それについて話していこうと思います。まずスケートアウトのほうですね。スケールアウトのほうは、この3つを行っています。それぞれについて詳しく見ていこうと思います。

まず1個目ですね。これは非常に単純で、CloudWatchにCPUReservationとかMemoryReservationという、「ECSクラスタをどれだけ使っているか」のメトリクスが最初から存在しています。それを定期的にチェックして、ある閾値を超えていたらスケールアウト。例えば80パーセント超えてたらもうちょっと増やしておくみたいなことをやっています。

スポットインスタンスの場合は、スポット価格の上昇によって勝手に死んでしまうことがあります。スポットクラスタの場合はオンデマンドより閾値をやや低くして、常にインスタンスが少しだけ余っている状態を維持しています。

次は2つ目ですね。各ECSのサービスの、desired_countとかそのへんの値を定期的にチェックして、足りていなさそうだったらスケールアウトするというのをやっています。ECSの場合は、ふだんだったらそのdesired_countという「これぐらいタスクを起動してほしい」値と、running_countという「実際に動いてるタスクの数」が一致しています。

一方で、「ECSサービスがオートスケールしてちょっとタスク数が増えた」、つまりdesired_countが上がったときに、ふだんだったらpending_count、つまり「まだタスクは起動してないけど既にコンテナインスタンスにはアサインされている」状態の値が上がります。

ですが、コンテナインスタンスが足りていない場合はpending_countも増えず、そういった状況になっていたら、コンテナインスタンスが足りない可能性があるので、さっさとスケールアウトしてキャパシティを確保しにいきます。

3つ目は、主にバッチジョブ用のクラスターのオートスケールの話です。「hako oneshot」という、最初に簡単に触れた「hako」というコマンドラインツールでECSの上でアプリケーション動かしているんですが、hako oneshotでバッチジョブを起動したときに、リソース不足でそのAPIが失敗したときにはSNSトピックに「リソース不足だよ」と通知するようにしているので、その通知を受け取って必要に応じてスケールアウトしています。

図にするとこんな感じです。最初にhakoっていうコマンドラインツールがバッチジョブを起動するためにRunTaskというAPIを呼んで、これが失敗したとします。その場合は、まずSNSに失敗を通知して、それをAutoScalerという社内で動いているデーモンが受け取ります。もしスケールアウトする必要があるんだったら、オンデマンドインスタンスの場合はAutoScaling Groupを操作したり、スポットインスタンスの場合はSpot Fleetを操作したりします。

hakoというコマンドラインツール自体は、RunTaskに失敗してSNSにパブリッシュした後も、RunTask APIを定期的にリトライし続けます。その間にAutoScalerがおそらくスケールアウトして、新しいリソースが確保できるので、そうなったらようやくRunTaskが通って、無事バッチジョブが起動できるというかたちにしています。

ここまではスケールアウトのほうで、スケールインのほうは単純に1つだけやっています。

スケールアウトのときも出てきましたが、CPUReservationとかMemoryReservationというメトリクスがふつうに最初からあるので、一定の閾値を下回ったらスケールインというふうにしています。

こっちの場合も、スポットインスタンスの場合は同じように突然停止する可能性があります。なので、オンデマンドクラスタよりも閾値を低くして、なるべくあんまりスケールインが起きないようにしています。

ログの取扱について

ここまでがコンテナインスタンスの話でした。ここから先は、各アプリケーションから出たログをどうしてるかという話をしようと思います。

各コンテナの標準出力とか標準エラー出力に出たログをどう保存するかという話で、最初に結論だけ書いておくと、現在のクックパッドではfluentdを経由してAmazon S3に保存して、Amazon Athenaという「S3上に置いたファイルにSQLで問い合わせができる」サービスがあって、それを使って簡易検索ができるようにしています。

これについて、ここから詳しく話していこうと思います。おそらくECSで動かすとなると、最初のログの保存先として出てくるのはCloudWatch Logsかなと思います。CloudWatch Logsを使えば完全にマネージドな環境でログの保存や検索、閲覧ができるんですね。

実際、クックパッドでもCloudWatch Logsを使っていました。ですが、ログの量が多すぎてピークタイムだとCloudWatch Logsにリアルタイムに入りきらないという問題があったので、仕方なくここから別の方法を探すことになりました。

一応入るっちゃ入るんですけど、入った後に検索したり取り出したりするのが遅かったり、CloudWatch Logsはログのストレージ料金だけではなく、ログを入れるときにも料金がかかったりします。それだけでもかなり高価になってしまって、いったんCloudWatch Logsに入れて、その後に古いログをアーカイブするだけでは解決できそうになかったので完全に別の方法を取ることにしました。

なので、閲覧できるまでのラグや検索性、CloudWatch Logsが便利だったところはやや犠牲にしつつも、スケーラブルで安価なS3をログストレージとして使うことにしました。

全体の流れはこんな感じになっています。最初の部分について話します。まずコンテナインスタンスのホスト側にfluentdを起動して、Dockerのlogging driverの設定でいったん送信します。

そこからコンテナインスタンスのfluentdが受け取ったログは、そのままfluentdの集約用のノードに転送します。こうなると集約ノードには大量のログが送信されてきますが、fluentd v1.0からは複数プロセスでログを処理できるようになっているので、それを利用してがんばって処理しきるようにしています。いまはピークタイムにあわせておそらく20並列ほどでがんばって処理していますね。

この集約ノードが受け取ったログは次にS3に置くことになります。この集約ノードのfluentdが1分ごとにservice-logsというバケットに、サービスとコンテナ名と日付をプレフィックスにして、ログは一旦ここに置きます。

こうすることによって、あるサービスのこのコンテナのログは特定のプレフィックス以下を見れば全部集まっている状態になるので、最初はこういうかたちでログを置いています。

これだけでは終わらなくて、一旦このservice-logsというバケットに置いたログは、S3 Eventの通知を経由して、ecs-logs-routerに「新しいログが来た」という通知が行きます。

こいつは何をやっているかっていうと、このservice-logsというバケットに置かれたログをタスクIDごとに分解して、また別のバケットにサービス名とコンテナ名と、あとタスクIDで切ったところに置いています。

こっちはタスク単位で閲覧するためのログで、例えば「あるタスクが異常終了したので原因を調査したい」といったことがよく発生すると思いますが、そういったときはこっちのログを見に行けば、最初からタスクIDごとに分解された状態でログを見れるようになっています。

ログ検索の仕組み

ログを置くだけだとあんまり役に立ちません。せめてgrepで検索できるようにならないとなかなかログを扱うのが難しいと思います。それを達成するために、Amazon Athenaで検索できるようにしています。このサービスを使うと、日付でパーティションを切りつつもSQLを使って検索ができるようになります。

例えば「my-awesome-app」というアプリケーションのappコンテナの今日のログから、「ERROR」という行を探したいときには、こんな感じでselect文を書いてAthenaに投げれば結果が返ってくるようになっています。

ただ、AWSのManagement Consoleからこのあたりを全部やるとさすがに厳しいです。なので、先ほどの発表でも何度か出てきたと思いますが、hako-consoleという社内で作ったコンソールからS3に置いてあるログを閲覧できたり、あるいはバックエンドのAthena経由でログを検索できてその結果を見たりができるような環境を整えています。

モニタリングについて

最後はモニタリングについて話します。

CloudWatchのほうにサービス単位のメトリクスは存在してるんですけれども、タスク単位や、あるいはコンテナ単位というメトリクスが存在していない問題があります。これはサービスのメトリクスなので、RunTaskで起動したバッチジョブの場合は一切メトリクスがないという状況になっています。

サービス単位で全部まとまってしまうと、もう1つ問題があります。アプリケーション開発者にとって、主に見たいのはアプリケーションコンテナのメトリクスだけなんですよね。

ECSで動かすときはサイドカーとしてfluentdを起動したり、あるいは最初の発表であったようなEnvoyを起動したりしますが、そういったコンテナとメトリクスが混ざった状態で見られてもあんまり嬉しくないという問題があるんです。このあたりをなんとかするために、自分たちでモニタリング用のメトリクスを取るようにしました。

とは言っても、実際にはcAdvisorというツールが既に存在しているので、これでメトリクスを取得します。あとは社内に既にPrometheusの環境があったので、PrometheusにcAdvisorから取ったメトリクスを入れて、Grafanaのダッシュボードで可視化するようにしています。

このcAdvisor自体は、ECSにdaemon schedulingという各コンテナインスタンスに1台起動するオプションが追加されているので、それで各コンテナインスタンスに配備して、Prometheusからそのメトリクスを取りにいくというかたちにしています。

そうするとだいたい、Grafanaで見るとこんな感じになっています。例えばいま何台起動してるのかとか、あるいは1秒間あたりのCPUtimeをどれだけ使っているとかがcAdvisor経由で取れたりします。

あとよく見るのはメモリですかね。メモリがリミットに対してどれくらい使われているかとか、そういったものをタスク単位・サービスのコンテナ単位で見られるようにしています。

まとめと今後について

というわけで、最後にまとめです。ECSでさまざまなサービスを動かすためにやっていることの一部を紹介してきました。

今回、時間の関係で話せなかったんですけど、ECSのレートリミットを回避するための工夫とか、あるいはコンテナインスタンス側の問題を調査するためにいろんなログを入れてたりしています。もしこのあたりに興味あったら、後のQAとかで聞いてもらえばなと思います。

最後、今後についてです。

いまちょうどre:Inventの期間なので、AWSにさまざまな新サービスや新機能が発表されているところで、センシティブな値を環境変数に入れる機能がようやく来ました。これを使うように移行していきたいと思っています。

あとはスポットインスタンスの管理をSpot Fleetでやっていますが、このSpot Fleetがかなり扱いが面倒くさくて、いろいろ機能が足りてなかったり、名前をつけることすらできなかったりしました。そのあたりがAutoScaling Groupでサポートされたっぽいので、こっちを使うようにしていきたいなと思っています。以上です、ありがとうございました。資料を公開しているので、よろしければご覧ください。