Fluentd 集約ノードのオートスケール

こんにちは、技術部 SRE グループ アルバイトの小川です。この記事では、クックパッドでコンテナログの処理に利用している Fluentd ノードのオートスケール対応について紹介します。

クックパッドでは Amazon ECS を用いてコンテナ化されたアプリケーションをデプロイしています。クックパッドでの ECS の利用については過去の記事をご覧ください。

ECS 上で動くコンテナのログを閲覧するために、標準的には Amazon CloudWatch Logs を利用する方法があります。しかし、クックパッドではログ量やコストの問題で CloudWatch Logs は利用せず、独自のログ配送基盤を構築して運用しています。具体的には、ECS のコンテナインスタンスで実行している Fluentd から複数の Amazon EC2 インスタンスで構成される Fluentd 集約ノードにログを転送し、最後に Fluentd 集約ノードが Amazon S3 にログを圧縮して転送、最終的に Amazon Athena 経由でログが閲覧できるようになっています。この仕組みについては Cookpad Tech Kitchen #20 での Amazon ECS の安定運用についてのスライドが詳しいです。

ECS コンテナインスタンスと Fluentd 集約ノード

各コンテナインスタンスから直接 S3 にログを転送するのではなく集約ノードを挟んでいるもっとも大きな理由は、コンテナインスタンスを跨いだバッファリングが必要だからです。コンテナインスタンスから出力されるログの合計は平均して約 60MB/sec (150TB/month) にものぼり、これをそのまま S3 に格納することはコスト上の観点から好ましくありません。そのため gzip 圧縮をおこなっているのですが、各コンテナインスタンスの中で圧縮をして直接アップロードを行うと仮定するとログ配送が遅延するかコストが嵩むかのどちらかを選ぶ必要に迫られます。すなわち、各コンテナインスタンスでバッファを大きくとるとログ配送が遅延してしまいますし、バッファを小さくとると圧縮率が下がってストレージコストが嵩み、また集積率が下がってオブジェクト数が増えリクエストコストが嵩んでしまいます。集約ノードを用意してそこで圧縮とアップロードを行うことで、遅延の少ないログ配送とコスト削減を両立しています。現在ログの遅延は最大 2 分に、S3 のデータ増加は約 5MB/sec (14TB/month) に抑えられています。

さて、ここで問題になるのが Fluentd 集約ノードの負荷です。すべてのコンテナインスタンス*1からログが流入し、先に紹介したとおり負荷が大きくなるのに違いはないのですが、時間帯によってログ量が大きく異なっています。下にログ量のメトリクスを示します。サービスへのアクセスが増加する夕方は 150MB/sec ほどのログがありますが、夜間は 20MB/sec 程度までログ量が減っています。

Fluentd 集約ノードに流入するネットワークトラフィック量

これまで、Fluentd 集約ノードはピークタイムの負荷に耐えることができるよう 8 台構成で動いていました。しかし、夜間や早朝などピークタイムを外れると 8 台もインスタンスは必要ありません。こういった状況から、オートスケールによって必要のない時間帯でインスタンス数を減らすことで半分程度のコスト削減が見込まれます。Fluentd 集約ノードができた当時は sd_srv プラグイン(後述)がなかったためオートスケールが困難だったのですが、現在はそういった問題もないため、コスト削減のためにオートスケールを行うことにしました。

オートスケールのために必要なこと

前節の図からわかるとおり、複数ノードに対するログ転送におけるロードバランスは転送ノード(コンテナインスタンス)側で行われています。これは Fluentd の out_forward プラグインによって実現されていますが、そのままでは転送ノードの Fluentd 設定ファイルに直接転送先サーバーのリストを記述することになります。一方でオートスケールを行うためには転送先のサーバーのリストは転送ノードの外側から動的に管理する必要があります。Fluentd 集約ノードの仕組みが整備された当時はこの部分を上手くやる方法がなかったのですが、Fluentd v1.10.0 で登場した sd_srv プラグインを用いることで現在は便利にサーバーのリストを外部から管理できるようになりました。sd_srv プラグインでは、あるホスト名に対する DNS の SRV レコードを集約ノードのサーバーリストとして用いています。つまり、集約ノードを追加する場合(サービスイン)には該当ノードを指す SRV レコードを追加し、集約ノードを削除する場合(サービスアウト)には該当ノードを指す SRV レコードを削除するような仕組みを用意する必要があります。

さらにサービスアウト時、該当集約ノードのバッファに残ったログが空になるまでシャットダウンを待つ必要があります*2。もちろん集約ノードにログが流入し続けている状態ではバッファが空になることは期待できないので、まず SRV レコードを削除し、ログの流入を止めたのちにバッファが空になるのを待機します。

これらが実装できればサービスイン・サービスアウトを自動で実行することができます(i.e. 自動でインスタンス数を制御できる)。これは EC2 の Auto Scaling グループとしてインスタンス群を制御することができることを意味し、各種 CloudWatch メトリクスの値にしたがって簡単にインスタンス群をオートスケールさせることができるようになります。また、Auto Scaling グループとしてインスタンス群が制御できるとインスタンスの入れ替え作業が簡単になるため、特定のインスタンスに関する障害への対応が迅速に行えるようになるといったメリットもあります。

実装

全体として上図のような構成・処理の流れをとります。Auto Scaling グループでインスタンスがサービスイン・サービスアウトするときにイベントを発生させてサービスイン・サービスアウトを待機させることができる Lifecycle Hook という機能を用います。Lifecycle Hook の開始を知らせるイベントを Amazon EventBridge 経由で受け取り (1)、サービスイン・サービスアウトの処理を行います。

まず検討する必要があるのが SRV レコードを追加・削除する仕組みについてです。今回は AWS Cloud Map を利用して SRV レコードを更新することにしました。Cloud Map はサービスディスカバリのためのマネージドサービスで、インスタンスの登録・登録解除に合わせて DNS レコードを変更することができます。これは今回のユースケースにピッタリです。この Cloud Map に対する登録・登録解除は Lifecycle Hook のイベントに反応して EventBridge から呼び出される Lambda 関数で行います (2), (3)。

ただし Cloud Map に対するインスタンスの登録・登録解除は非同期に行われます。すなわち、Cloud Map に対する API コールはすぐにレスポンスが戻りますが登録が完了したか・その成否はわからず、そのレスポンスに含まれる ID を使って GetOperation API で問い合わせる必要があるということです。今回は登録時に SQS キューにメッセージを送信し (4)、登録完了を確認する Lambda 関数を SQS から呼び出す (5) ことでこの登録待ちを実現しました。登録が完了していなかった場合に Lambda 関数が失敗する (6) ことで、SQS の可視性タイムアウトののちに再度 Lambda 関数が実行され、登録が完了するまでのリトライが実現されます。登録解除についても同様です。

先に説明したバッファの掃き出し待ちは、社内で利用している Prometheus インスタンスにバッファ長を問い合わせることで行いました (7)。Fluentd 集約ノードのメトリクスは fluent-plugin-prometheus を用いて Prometheus からモニタリングをしています。普段はこれをアラーティングや状態の可視化に用いているのですが、今回の掃き出し待ちにも利用することにしました。こちらの掃き出し待ちも先に説明した SQS と Lambda を用いたリトライによって実現しました。

最後に、サービスイン・サービスアウトのための条件が整ったら、Lifecycle Hook の完了を Auto Scaling グループに通知します (8)。これで Auto Scaling グループ上でインスタンスが正常にサービスイン・サービスアウトされたことになります。

監視

社内の全クラスタのコンテナログが Fluentd 集約ノードを経由するため、Fluentd 集約ノードのキャパシティは非常に重要です。スケールアウトに失敗してピークタイムに十分な数のインスタンスが立ち上がっていないと、ログの配送が失敗したり遅れたりしてしまう可能性があります。

今回は、スケールイン・スケールアウトの失敗を監視するために、各 Lambda 関数の実行が一定回数失敗したら DLQ (Dead-Letter Queue) にメッセージが入るようにしました*3。そして、DLQ の長さにアラームを設定しておくことで、スケールイン・スケールアウトの失敗に気づくことができるようになっています。

Lifecycle Hook は一定期間完了されないとタイムアウトして自動的に該当インスタンスが終了*4されます。これは、サービスアウトに失敗して一定期間経過すると該当インスタンス内のログが消失する可能性があることを示しています。そのため、サービスアウト失敗時の対応については特に詳細な手順書 (Runbook) を用意し、万が一の事態に備えています。

動作

この仕組みの動作例として、Auto Scaling グループの Desired capacity を減らした際の動作の様子を下に示します。Desired capacity が 2 に減らされた直後に Auto Scaling グループがサービスアウトするインスタンスを決定し、Lifecycle Hook を発行します。その後今回実装した仕組みにより Cloud Map 経由で SRV レコードから該当インスタンスが外され、ログの流入が止まり、出力バッファ長が減少を始めます。ログ量によりますが、バッファ長が 0 になるまで(ログが掃き出されるまで)5 分ほど時間がかかります。さらに、今回の実装では念の為 5 分間連続してバッファ長が 0 になっていること確認してからサービスアウトに進んでいます。そのため、サービスアウトには大体 10 分程度の時間がかかっています。サービスアウトが完了すると Auto Scaling グループ内の該当インスタンスが終了され、インスタンス数が減少し、Desired capacity と一致するようになります。

オートスケール

Auto Scaling グループを利用したインスタンスの制御が実装できたので、あとはスケーリングポリシーを設定するだけです。今回はピークタイムのネットワークトラフィック量を基準に、ターゲット追跡スケーリングポリシーを設定してオートスケールを有効にしました。ターゲット追跡スケーリングポリシーを有効にすると、メトリクスがターゲット値に近い値を取るようにインスタンス数が自動で増減されます。

オートスケールの結果

オートスケール導入前の Fluentd 集約ノードのメトリクスを下に示します。やはり時間帯によって負荷に差があることが読み取れ、例えば CPU 使用率はピークタイムでは 40% 近くにのぼっていますが深夜帯は 10% にも満たないことがわかります。

次にオートスケール導入後のメトリクスを下に示します。狙い通り、全体的にピークタイム程度の負荷でメトリクスが平されている様子が見て取れます。

コスト

Fluentd 集約ノード用インスタンスのコストの変化を下に示します。5/13 に移行を開始し(移行中は旧構成と両立しているためコストが増えています)、6/7 あたりに移行を終えました。下のグラフから読み取れるとおり、Fluentd 集約ノードにかかるコストを約半分に削減することができました。

まとめ

AWS Cloud Map を利用して Fluentd 集約ノードをオートスケールさせる仕組みについて紹介しました。今回はコンテナログの集約に用いている集約ノードでオートスケールを実践しましたが、仕組み自体は汎用的なもので他にもオートスケールの必要がある集約ノードがあればすぐに応用が効くものになっています*5。Fluentd 集約ノードのオートスケール事例として参考にしていただければ幸いです。

最後になりますが、SRE グループでは一緒に働く仲間を募集しています。 カジュアル面談や学生インターンシップなども随時実施していますので、ぜひお気軽にご連絡ください。

info.cookpad.com

*1:プロダクションで動いているのは 300-500 台程度

*2:Fluentd の flush_at_shutdown オプションでは十分ではなく、例えばログ掃き出し中に問題が発生したとしてもすぐにインスタンスが強制的にシャットダウンされてしまい対処の猶予がなくなってしまうなどの問題があります

*3:上の図における “ハンドラ関数”は Lambda の非同期実行で実行されるため Lambda に DLQ を設定しており、”待ち関数” は SQS キューから実行されるため SQS キューに DLQ を設定しています

*4:DefaultResult=ABANDON の場合。また、サービスアウト時にはどちらにしろインスタンスは終了します

*5:実際、この仕組みの大部分は Terraform module として実装されており簡単に導入できるようになっています