はじめに
AWS FireLens は、Amazon ECS で動作するコンテナが出力するログを、FluentBit または Fluentd を使って柔軟にルーティングするための仕組みです。特に、タスク定義のみでログルーティングの設定が可能なため、構成管理をシンプルにできる点が特徴となっています。
しかし、大量のログを欠損なく扱うために設定の調整を行おうと思っても、現状の機能では一筋縄ではいかないことがあります。
この記事では FireLens の利便性を活かしたまま調整を可能にする方法をご紹介します。
FireLens の構成
FireLens は以下の2つの要素から構成されているといえます。
- awsfirelens ログドライバー
- FluentBit・Fluentd の config 生成機能
awsfirelens ログドライバー
タスク定義中に、以下のように logDriver として "awsfirelens" を指定します。これは本物のログドライバーではなく、実際には Docker Fluentd ログドライバーを使って、そのコンテナの標準出力・標準エラー出力を FluentBit・Fluentd コンテナに UNIX Domain Socket (/var/run/fluent.sock)経由で送ります。
また options として、そのログを送りたい先の設定を記述しますが、これは次節の機能で参照されます。
"logConfiguration": { "logDriver": "awsfirelens", "options": { "Name": "s3", "region": "ap-northeast-1", "bucket": "...", ... } }
FluentBit・Fluentd の config 生成機能
FireLens 自体は FluentBit・Fluentd は提供しません。代わりにタスク定義に FluentBit・Fluentd コンテナを含めておく必要があります。そして以下の設定を追加すると、そのコンテナが FireLens の送り先となります。
FireLens は、前節の例のような他のコンテナの logConfiguration.options に従って、ログを振り分けるための FluentBit・Fluentd の設定ファイルを生成し、それを FluentBit・Fluendコンテナ内のデフォルト設定ファイルのパスに read-only マウントします。
この時、以下のように options で S3 やコンテナ内のファイルから追加の config を読み込ませる( FluentBit なら @INCLUDE ディレクティブが追加生成される )ことが可能です。これによって、特定条件にマッチするログを抽出して別のところに送信したり、アプリケーション独自で TCP port 24224 に送ったログを一緒にルーティングさせることができます。
"firelensConfiguration": { "type": "fluentbit" "options": { "config-file-type": "s3", "config-file-value": "arn:aws:s3:::mybucket/fluent.conf" } }
上記の設定の場合、 FireLens は以下のような FluentBit 設定ファイルを生成します。
[INPUT] Name tcp Listen 127.0.0.1 Port 8877 Tag firelens-healthcheck [INPUT] Name forward unix_path /var/run/fluent.sock [INPUT] Name forward Listen 127.0.0.1 Port 24224 @INCLUDE /fluent-bit/etc/extra.conf [OUTPUT] Name null Match firelens-healthcheck [OUTPUT] Name s3 Match <コンテナ名>-firelens* bucket ... region ap-northeast-1 ...
extra.conf として S3 から取得した追加設定を読み込んでいます。この機能のおかげで事前に FluentBit・Fluentd コンテナイメージに設定を格納しておく必要がなくなり、 FluentBit イメージとして Docker Hub 等で配布されているオフィシャルのものや AWS が用意している FireLens 用イメージをそのまま使用できます。
ログ損失の回避策の課題
以下では FluentBit を使うことを前提とします。
FireLens の生成する INPUT にはオプションは特に設定されておらず、 FluentBit のデフォルト設定が使用されます。この場合、受け取ったログは一度メモリ上のバッファに格納され、一定時間ごとに出力先に送信(Flush)されます。このため極めて大量のログを受け取るとメモリ不足になることがあり、ログの一部が欠損することがあります。
以下の AWS ブログ でも、大量のログを処理する場合にログが欠損する例があり、Flush 間隔を調整で改善を試みています。
aws.amazon.com
この中で、より根本的な改善のために、INPUT にメモリバッファサイズを制限する Mem_Buf_Limit オプションを追加設定したいというリクエストが出されています。しかし、現状 FireLens 自体にはそのための設定方法がありません。
そこで、完全に fluent-bit.conf を置き換えることでオプション指定を行う方法の例が AWS の GitHub で公開されています。
github.com
ただ、この方法では FireLens が生成する設定ファイルを無視させているため、タスク定義だけでログルーティングができるという機能が失われてしまう欠点があります。
特に、ゲームの開発環境では似たような環境(ECSタスク)が複数あり、かつ環境ごとにログの出力先も分けたいというニーズがあるため、環境別の FluentBit イメージを用意しなければならないとなると運用が大変になるという課題がありました*1。
init を利用した INPUT の設定のカスタマイズによる解決策
既存機能でできそうな INPUT のカスタマイズ方法として、追加設定ファイルに INPUT セクションを書いておくというものが考えられます。
[INPUT] Name forward unix_path /var/run/fluent.sock # 追加 storage.type filesystem [INPUT] Name forward Listen 127.0.0.1 Port 24224 # 追加 storage.type filesystem ...(その他のカスタムログルーティング設定などが続く)
しかし INPUT を追記するだけでは、FireLensが生成した INPUT とのTCPポート重複により、エラーで FluentBit が正常に起動しなくなってしまいます。
これを解決するため、コンテナの init プロセスを利用して設定ファイルを加工するという方法を紹介します。
AWS の FireLens 用コンテナのソースを見ると、init プロセスを利用して複数の追加設定を S3 から取得する機能を追加したイメージがあります。これを参考に、以下のような処理をする init プロセスを Go で作成しました。
- 設定ファイルに @INCLUDE があり、その中で forward INPUT を定義している場合、元の設定ファイルからそれと重複する port や unix_path を持つ INPUT 設定を削除する *2
- 更新した設定ファイルを指定して FluentBit を exec で起動する *3
要は、重複でエラーになるなら、その部分を削除して一つにしてやれば良いということですね。
なお Go を選んだのは FluentBit イメージの中には /bin/sh すら含まれていないため、シングルバイナリで動作する必要があることも理由の一つです*4。 ソースコードは本記事の末尾にあります。
あとはこれをビルドして Docker イメージに追加し、 ENTRYPOINT として指定すれば準備完了です。
ビルド:
# aarch64 アーキテクチャ用とする場合 GOOS=linux GOARCH=arm64 go build -o init init.go
Dockerfile:
FROM fluent/fluent-bit:3.1 COPY fluent-bit/init/init /opt/init ENTRYPOINT ["/opt/init"]
これで FireLens が生成した INPUT が追加設定に上書きされるようになったので、上記の例のように INPUT セクションを追加設定に書くことでオプションもカスタマイズできるようになりました。
おわりに
本記事では、FireLens のタスク定義によるログルーティングを生かしたまま、 FluentBit コンテナ起動時に init で FireLens が生成した設定ファイルを加工することで INPUT の設定をカスタマイズできるようにした方法を紹介しました。
AWS の GitHub に示された方法ではカスタマイズのためにせっかくの FireLens の設定ファイル生成機能を無効化してしまっていましたが、今回の方法であれば併用が可能です。(ニーズは認識されていそうなので、今後 FireLens の標準機能で INPUT のオプションが設定できるようになると良いのですが…。)
なお余談ですが、少し前に ECS に RestartPolicy が追加され、FluentBit のコンテナが異常終了した場合にタスク全体は起動したままコンテナ単体で再起動させることが可能になりました。この RestartPolicy を有効化し、今回の方法で INPUT に filesystem storage を利用してバッファを Volume に置けば、再起動後にログを再送させることができ、よりログの信頼性を向上させられそうです。
カスタム init のソースコード
package main // Init for FireLens Fluent Bit container // This removes `forward` input sections from the main Fluent Bit configuration file // if they are already defined in additional config files specified by @INCLUDE directives. // This is a workaround for the limitation that FireLens does not currently support customizing input options. import ( "fmt" "io/ioutil" "os" "strings" "syscall" ) const ( defaultConfigPath = "/fluent-bit/etc/fluent-bit.conf" defaultBinaryPath = "/fluent-bit/bin/fluent-bit" ) func nextSection(lines []string) ([]string, []string) { for i, line := range lines[1:] { if strings.HasPrefix(line, "[") || strings.HasPrefix(line, "@") { return lines[:i+1], lines[i+1:] } } return lines, nil } func getForwardBindInSection(section []string) string { if len(section) > 0 && strings.TrimSpace(section[0]) == "[INPUT]" { isForward := false bind := "" for _, line := range section[1:] { fields := strings.Fields(strings.ToLower(line)) if len(fields) < 2 { continue } if fields[0] == "name" && fields[1] == "forward" { isForward = true } if fields[0] == "port" || fields[0] == "unix_path" { bind = fields[1] } } if isForward { return bind } } return "" } func addIncludedForwards(includedFile string, includedForwards map[string]string) { contentBytes, err := ioutil.ReadFile(includedFile) if err != nil { panic(err) } content := string(contentBytes) lines := strings.Split(content, "\n") for lines != nil { var section []string section, lines = nextSection(lines) bind := getForwardBindInSection(section) if bind != "" { includedForwards[bind] = includedFile } } } func updateConfigFile(configPath string) { contentBytes, err := ioutil.ReadFile(configPath) if err != nil { panic(err) } content := string(contentBytes) lines := strings.Split(content, "\n") configLines := make([]string, 0) includedForwards := make(map[string]string) for _, line := range lines { if strings.HasPrefix(strings.ToLower(line), "@include ") { includedFile := strings.TrimSpace(line[9:]) addIncludedForwards(includedFile, includedForwards) } } for lines != nil { var section []string section, lines = nextSection(lines) bind := getForwardBindInSection(section) if bind != "" && includedForwards[bind] != "" { fmt.Printf("Skipping forward input for '%s' because it is already included in '%s'\n", bind, includedForwards[bind]) continue } configLines = append(configLines, section...) } file, err := os.OpenFile(configPath+".new", os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) if err != nil { panic(err) } defer file.Close() file.WriteString(strings.Join(configLines, "\n")) fmt.Println("Generated new config file", configPath+".new") } func executeFluentBit(configPath string, binaryPath string) { args := []string{binaryPath, "-c", configPath + ".new"} err := syscall.Exec(binaryPath, args, os.Environ()) if err != nil { panic(err) } } func main() { updateConfigFile(defaultBinaryPath) executeFluentBit(defaultBinaryPath, defaultBinaryPath) }
*1:環境変数で頑張る方法も考えられそうですが、環境間でコンテナ構成等に多少違いがあったりするため、FireLens よりも複雑度は上がりそうです。
*2:先の説明のように元の設定ファイルは read only マウントされており書き換えることはできませんので、新しいファイルを作成してそこに書き出しています。
*3:exec することで fluent-bit プロセスを PID 1 として、コンテナ停止時にTERMシグナルを正しく受け取れるようにします。
*4:このためデバッグの際は他バイナリを含む debug がタグに含まれるイメージを使うとか、 Docker Debug を使うといった必要があります。