blog

DeNAのエンジニアが考えていることや、担当しているサービスについて情報発信しています

2022.08.12 技術記事

AWS ECS on Fargate + FireLens で大きなログが扱いやすくなった話

by ebiebievidence

#aws #ecs #fargate #firelens #log

はじめに

コンテナオーケストレーションサービスの一つである AWS ECS on Fargate (以下 ECS on Fargate) では、FireLens を利用することで、コンテナが出力するログを簡単に任意のログ基盤へ送信できます。

しかし、FireLens を通じてコンテナのログをルーティングする場合、16 KB 以上のログは分割された状態でログルーティング用のコンテナに到達します。構造化ログを実現するためにアプリケーションが JSON などの形式でログを出力している場合、ログを分割される前の状態に復元する必要があります。

この記事では、FireLens とは何かをおさらいした上で、上記の問題の背景を解説します。また、この問題の解決策についてこれまで知られてきた方法と、最近の ECS on Fargate のアップデートにより利用できるようになった方法を解説します。それにより、読者が ECS on Fargate および FireLens の仕組みを理解し、FireLens を運用する上で上記問題につまずかなくなることを目指します。

対象となる読者

  • ECS on Fargate の運用をしている・したい方
  • Fargate および FireLens の内部の仕組みを知りたい方
  • ECS on Fargate から Datadog や Google Cloud Logging などのログ基盤へログを送信したい方

FireLens とは

ECS on Fargate のおさらい

まず、ECS on Fargate についておさらいしましょう。AWS ECS は、コンテナ化されたアプリケーションをかんたんに起動・管理・スケールできる、いわゆるコンテナオーケストレーションサービスの一つです。その中でも ECS on Fargate は、コンテナの実行基盤 (ホストマシン)を AWS がすべて管理してくれるすぐれものです。

ECS on Fargate は ECS on EC2 と比較してユーザが管理するべき領域が狭い

ECS on Fargate は ECS on EC2 と比較してユーザが管理するべき領域が狭い

ECS on Fargate でデプロイしスケールさせることができる最小単位は ECS タスクと呼ばれ、一つまたは複数のコンテナを実行することができます。例えば、Go アプリケーションのコンテナを一つのタスクで実行することも、Ruby on Rails アプリケーションのコンテナと nginx のコンテナを一つのタスクで実行することもできます。

ECS タスク上では一つまたは複数のコンテナを動作させることができる

ECS タスク上では一つまたは複数のコンテナを動作させることができる

FireLens が解決したい課題

ECS でアプリケーションを運用する上で、アプリケーションログを収集し、ログを集計または検索したくなると思います。ECS では、AWS によって提供されている ECS ログドライバ をタスクに設定することができます。現在は、この後紹介する awsfirelens ログドライバの他に、CloudWatch Logs に転送する awslogs ログドライバ、Splunk に転送する splunk ログドライバが提供されています。しかし、CloudWatch Logs や Splunk 以外に転送したいこともあります。S3 にログを集積する場合もあれば、Datadog や Google Cloud Logging などのログ基盤に送信する場合もあると思います。

ECS ログドライバを利用して CloudWatch Logs や Splunk へ簡単にログを送信できる

ECS ログドライバを利用して CloudWatch Logs や Splunk へ簡単にログを送信できる

ここからは、CloudWatch Logs や Splunk 以外のログ基盤に送信する場合を考えてみましょう。FireLens を利用しない場合、どのように任意のログ基盤にログを送信するでしょうか?例えば Fluentd や FluentBit によるログルーティングを行うコンテナ (以下 ログルーティングコンテナ) を立てて、メインのアプリケーションが動作するコンテナ(以下 メインコンテナ)からログルーティングコンテナを経由してログ基盤に送信するのではないでしょうか。しかし、メインコンテナからログルーティングコンテナへログを送信するしくみをアプリケーションに導入するのは面倒です。非同期的にログの送信を行ったり、ログをまとめて送信したりしたくありませんし、専用のライブラリを導入するのは面倒です。

メインコンテナからログルーティングコンテナへどのようにログを送信するのか?

メインコンテナからログルーティングコンテナへどのようにログを送信するのか?

The Twelve-Factor App には次のように書かれています。

Twelve-Factor Appはアプリケーションの出力ストリームの送り先やストレージについて一切関知しない。 アプリケーションはログファイルに書き込んだり管理しようとするべきではない。代わりに、それぞれの実行中のプロセスはイベントストリームをstdout(標準出力)にバッファリングせずに書きだす。ローカルでの開発中、開発者はこのストリームをターミナルのフォアグラウンドで見ることで、アプリケーションの挙動を観察する。

このように、アプリケーションは標準出力にログを吐くだけで勝手にログがルーティングされると嬉しいということが知られています。

他にも、以下のような面倒事がありそうです。

  • ECS メタデータの取得が面倒
    例えば「ログがどの ECS タスクのどのコンテナから送信されたのか」、「そのタスクはどの ECS サービス上にあるのか」など、ログの送信元に関する情報は障害発生時などにとても重要になると思います。
  • Fluentd or FluentBit の設定の反映が面倒
    ECS タスク上で Fluentd を実行する場合、設定ファイルはコンテナイメージに含められるかと思います。つまり、Fluentd の設定ファイルを更新する度に、コンテナイメージをコンテナレジストラに上げる必要があります。これはとても煩雑です。

FireLens の機能

FireLens を利用することで、上記の面倒事を解決することができます。

私も以前していた勘違いですが、FireLens とは「Fluentd または FluentBit のコンテナそのもの」ではありません。FireLens は、前述の ECS ログドライバの 1 つである awsfirelens ログドライバとして提供されている、「ECS タスク上のコンテナのログをかんたんにログルーティングコンテナへ転送できるようにする仕組み」です。

awsfirelens ログドライバは、以下の機能を提供します。

  • ECS タスクのコンテナ内の標準出力/標準エラー出力を収集してログルーティングコンテナに転送する
  • ECS タスクのコンテナに、Fluentd ロガーライブラリの設定に必要な環境変数を与える
  • Fluentd/FluentBit の設定ファイルを自動生成して、ECS タスク内のログルーティングコンテナに読み込ませる

AWS ECS on Fargate で FireLens を使う場合、メインコンテナからのログの出力方法は以下の 2 つの方法から選ぶことができます。

  1. 標準出力/標準エラー出力へログを出力する
    Fargate データプレーンを経由し、Fluentd Foward Protocol メッセージとしてログルーティングコンテナに送信されます。
  2. Fluentd ロガーライブラリを使用する
    TCP で ログルーティングコンテナに直接送信されます。
    Fluentd ロガーライブラリを使うために必要な設定は、すべて AWS 側がメインコンテナに環境変数として渡してくれます。

つまり、アプリケーションは標準出力と標準エラー出力にログを書き込むだけで、ログメッセージを Fluentd/FluentBit コンテナで受信することができます。 加えて、自分で何も設定をせずに Fluentd ロガーライブラリを使うことができます。

また、FireLens は以下のような Fluentd or FluentBit の設定ファイルを自動生成し、ログルーティングコンテナに自動で適用されます。

  • ログの読み込み
    Fargate データプレーンから UNIX ドメインソケットで転送されるログを受け取るための設定です。
  • ECS メタデータの追加
    ログのレコードに ECS メタデータを追加するための設定です。
  • ユーザが定義した Fluentd or FluentBit の設定ファイルのインクルード
    コンテナ内のファイルの他に、S3 上のファイルを指定できます。

これにより、ユーザは「ログをどのように受け取るか」を考える必要がなくなり、「ログをどのように変換するか」「ログをどのように出力するか」だけを考えて設定ファイルを記述できます。 また、ユーザが定義した Fluentd or FluentBit の設定ファイルを S3 から読み込むことができるので、設定を変更する時にわざわざイメージを再プッシュする必要はありません。

運用上で発生した問題: 16KB 以上のログが送信されない

さて、ここからは実際に ECS on Fargate で FireLens を利用した場合に発生した問題について見ていきます。私達のチームでは、FireLens を利用して、Fluentd コンテナにログを転送した上で、S3 または Google Cloud Logging に送信しています。

アプリケーションが出力するログは「振り分け先の識別子 + JSON 形式の文字列」というフォーマットで出力しており、この振り分け先の識別子を元に、Fluentd でログを転送しています。

gcl - { "jsonPayload": { "foo": "bar" }}
s3 - { "event": "click" }

まず確認された事象は、Java アプリケーションの例外ログだけが Google Cloud Logging に表示されないという問題でした。Fluentd コンテナのエラーログを CloudWatch Logs から確認した結果、Fluentd コンテナに送信されるログのうち、一部のログが途中で途切れていることが分かりました。JSON 形式の文字列が一部途切れてしまうことにより、Google Cloud Logging へ送信される情報が正常にパースできずに例外ログが送られなくなっていました。さらに、途切れてしまうログは常に 16 KB 以上であることがわかりました。

さて、メインコンテナから Fluentd コンテナに到達するまでの間に、16KB 以上のログが途切れて送られてしまうことは分かりました。では、具体的には何が原因なのでしょう?これを理解するためには、FireLens の仕組みを理解する必要があります。

問題の原因

この問題は、コンテナとログルーティングコンテナのログを中継する shim-logger というコンポーネントの仕様によって発生します。本章では、Fargate および FireLens のしくみを詳解した上で、本問題の原因である shim-logger の仕様について解説します。

Fargate (v1.4~) のしくみ

FireLens のしくみを説明するために、まずは Fargate v1.4 以降のしくみを説明します。

各コンテナの作成・起動・停止などのライフサイクルは、高レベルランタイム Containerd によって管理されています。Containerd はコンテナを起動する時、コンテナごとに containerd-shim と呼ばれるプロセスを作成します。conatinerd-shim はコンテナのプロセスと各 runtime プラグインのプロセスの親プロセスとなります。また、コンテナの起動には低レベルランタイム runc が使用されます。そして、Fargate では conatinerd-shim として runc 向けの runtime である conatinerd-shim-runc が使われています。

なお、Fargate v1.3 までは Containerd の代わりに Docker Engine が利用されていました。よって、古いブログ記事では ECS on Fargate が Docker Engine ベースで動作していることを前提とする記事もあることに注意して下さい。

shim logger と FireLens のしくみ

メインコンテナの標準出力と標準エラー出力は、shim logger によってログルーティングコンテナに送信されます。

shim logger は containerd-shim が起動する Containerd runtime プラグインの一つです。ECS ログドライバごとに実装が分かれていて、awslogs ログドライバ、splunk ログドライバ、awsfirelens ログドライバ向けの実装が存在します。ソースコードは https://github.com/aws/amazon-ecs-shim-loggers-for-containerd にて公開されています。

shim logger は、以下のようにログを転送してくれます。

  1. 各コンテナは shim logger に標準出力と標準エラー出力をそれぞれ pipe(2) で転送する
    標準出力と標準エラー出力はそれぞれ異なるパイプで転送されます。
  2. shim logger は Task Definition で指定したログドライバを使用して各所へ転送する

awsfirelens ログドライバを使用している場合、shim logger は同コンテナ内のログルータコンテナへ UNIX ドメインソケットで転送されます。

awsfirelens ログドライバを使用している場合の shim logger の挙動

awsfirelens ログドライバを使用している場合の shim logger の挙動

Docker ロガーの仕様と shim logger での仕様の踏襲

ECS の shim logger は、Docker ロガーの実装を強く参考にしています。よって、この問題を理解するためには、まず Docker ロガーの仕様を理解する必要があります。

Docker Docker 1.13 からは Docker ロガーがログを 16KB ごとに分割して送信するように変更されました。この時、Docker は同時に、分割したログを復元できるメタデータ partial metadata をログに付与する機能を追加しました ( moby/moby#22982 )。分割されたログには partial_id, partial_ordinal, partial_last が付与されるようになりました。つまり、分割されたログは partial metadata を用いることで結合できます。

例えば、 abcdefghijkl というログメッセージが 3 つに分割された場合を見てみましょう。分割される前のログを識別するための partial_id が割り振られ、partial_ordinal によって分割の順番が示され、分割された終端のログであるかどうかが partial_last によって示されます。

{ "partial_id":"3be6...", "partial_ordinal":"1", "partial_last": "false", "log":"abcd", ...}
{ "partial_id":"3be6...", "partial_ordinal":"2", "partial_last": "false", "log":"efgh", ...}
{ "partial_id":"3be6...", "partial_ordinal":"3", "partial_last": "true", "log":"ijkl", ...}

この Docker ロガーの仕様は、shim logger でも踏襲されており、16KB ごとにログを分割する仕組みになっています。

つまり、今回の「ログが 16KB ごとに分割されてしまう」という問題は、shim logger の仕様によるものでした。

ただし、ごく最近 (2022/04) まで、 shim logger は partial metadata を付与していませんでした。そのため、それまでは人々は shim logger を迂回したり、ログルーティングコンテナ上で頑張って結合したりしていました。

既存の回避策① TCP でメインコンテナからログルーティングコンテナへログを送信する方法

メインコンテナからログルーティングコンテナへ、TCP によってログを送信する方法です。 この場合、Fluentd ロガーライブラリなどを利用することが一般的です。

この方法のデメリットとして、「アプリケーションは標準出力にログを吐けばいい」という 12Twelve-Factor App の世界観を満たさなくなってしまいます。これにより、アプリケーションの可搬性が低下し、例えば Fluentd から FluentBit への移行などのインフラレイヤーの変更がアプリケーションにも影響を及ぼすなど、インフラレイヤーの変更の難易度を高めてしまいます。

既存の回避策② ファイルシステムへログを出力する方法

メインコンテナにマウントされたファイルシステムへログを書き込み、ログルーティングコンテナから tail する方法です。

この方法もまた、TCP によりコンテナ間通信をする方法と同様の問題を抱えています。

既存の回避策③ ログ終端に目印を書いた上でログルーティングコンテナ上で結合する方法

アプリケーション側でログの終端を表現する文字列をログの末尾に付与した上で、これを目印にログルーティングコンテナ側で結合する方法です。

// 分割される前
123abc123abc...123abc123abc\tEND
 
// 分割された後
123abc123abc...
123abc123abc\tEND <- 「\tEND」が来るまで結合する

この場合、各コンテナ shim logger が stdout と stderr ごとにログルーティングコンテナへ接続してログを送信するので、結合する側でクライアントごとにバッファを分けて結合しないと混線してしまいます。加えて、終端を表現する文字列がログ内に含まれていないという前提も必要であることに注意すべきです。

解決策

2022/04 以降、分割されたログに partial metadata を付与する機能が shim logger に追加されました。 これにより、ログルーティングコンテナ上での結合が格段に簡単になりました。

ここからは、2022/08 現在に利用できる新たな解決策を示します。

Fluentd の Concat Plugin を利用する

概要

我々が採用した方法を紹介します。

fluent-plugin-concat を利用すると、ログルーティングコンテナ側で複数のログレコードを結合することが可能です。fluent-plugin-concat を利用して、ログルーティングコンテナ側で複数のログレコードを結合します。また、fluent-plugin-concat は、partial metadata を用いてログを結合する機能を提供しています。

これらを利用して、以下のように shim logger が分割したログを結合することができます。

アプリケーションが出力したログ
abcdabcdabcdabcd...abcd/efghefghefghefgh...efgh/ijklijklijklijkl...ijkl
shim logger が送信するログ
{ "partial_id":"3be6...", "partial_ordinal":"1", "partial_last": "false", "log":"abcdabcd...abcd/", ...}
{ "partial_id":"3be6...", "partial_ordinal":"2", "partial_last": "false", "log":"efghefgh...efgh/", ...}
{ "partial_id":"3be6...", "partial_ordinal":"3", "partial_last": "true", "log":"ijklijkl...ijkl", ...}
fluent-plugin-concat が結合したログ
{ "log":"abcdabcdabcdabcd...abcd/efghefghefghefgh...efgh/ijklijklijklijkl...ijkl", ...}

メリット

この対応方法では、ファイルシステムを経由する方法やコンテナ間通信をする方法に比べて、アプリケーションは標準出力にログを吐くだけでログがルーティングされていく世界観を守ることができます。

デメリット

サードパーティのプラグインを導入する必要があります。

FluentBit を使う

2022/05 より、partial metadata を用いてログを結合する機能が FluentBit の Multiline フィルタ に追加されました。

また、2022/05 より、AWS が提供する FluentBit イメージでも上記機能が取り込まれました。 https://github.com/aws/aws-for-fluent-bit/releases/tag/v2.24.0

これにより、FluentBit の Multiline フィルタを使えば、特にサードパーティのプラグインを導入せずにログの結合ができるようになりました。

メリット

サードパーティのプラグインの導入が不要です。 Fluentd よりも軽量な FluentBit を利用できます。

デメリット

FluentBit はプラグインがまだまだ充実していません。また、自前で実装するにも、C で実装する必要があります。 実現したい機能が FluentBit で実現可能であれば FluentBit を利用し、それ以外の場合は Fluentd を利用して concat plugin で結合するのが丸そうです。

まとめ

本記事では、まず最初に、ECS on Fargate 上で FireLens を利用することで簡単に任意のログ基盤へログを送信できるようになることを述べました。そして、ECS on Fargate + FireLens 環境にて 16KB 以上のログを送信した場合にログが分割されるという問題とその背景について、ECS on Fargate および FireLens のしくみを含めて解説しました。最後に、これまで利用されてきた回避策とその問題点を示し、2022/08 現在に利用できる解決策を示しました。

本問題の調査および本記事の執筆にあたって、 @foresta3_t さん、 @karupanerura さん、 Hirofumi Narita さん、 @koropicot さん (順不同) に共同で進めて頂きました。心より感謝いたします。

参考にした記事

本問題の調査にあたって、以下の記事を参考にしました。

最後まで読んでいただき、ありがとうございます!
この記事をシェアしていただける方はこちらからお願いします。

recruit

DeNAでは、失敗を恐れず常に挑戦し続けるエンジニアを募集しています。