Fargate のログをFireLens(fluentbit)経由で BigQuery に連携する話

この記事は、[株式会社うるる（ULURU） Advent Calendar 2023](https://qiita.com/advent-calendar/2023/uluru) の15日目の記事です。

サービスは AWS で動いているものの、各種ログや、それらを基にしたダッシュボードは Google の BigQuery で参照・集計したい、という需要はそれなりにあるように思います。

今年、うるるの入札情報サービス NJSS のバックエンドでも、上記の仕組みを導入しましたので、構成や設定について簡単に書き残してみます。


# 構成図

![firelens.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2989197/b24b33ae-dd62-b5ce-2a2f-4ca1db125eeb.png)



# FireLens について
最初は、「FireLens」というのが何を指す言葉なのかよくわかっていませんでした。[公式ブログ](https://aws.amazon.com/jp/blogs/news/under-the-hood-firelens-for-amazon-ecs-tasks/)にある「シンタックスシュガー」という表現が一番しっくりくるように思います。
> awsfirelens ログドライバーは、タスク定義用のシンタックスシュガーであり、Fluentd または FluentBit のアウトプットプラグインの設定を指定できます。

内部では、以下のようなことをやっているのだと思います。
* fluentbit(or fluend)のコンテナを起動して24224ポートで待ち受け
* 出力元コンテナは（おそらく）log driverにfluentdを指定したような動作になる

一瞬、単に「fargateでもlogdriverにfluentdが選べるようになりました」じゃダメなのかな？とも思ったのですが、driverにfluentdを選んだらそれを受け取るfluentbit(かfluentd)は絶対に必要になるし、その場合はINPUTの定義は固定でよくて、FILTERとOUPUTが制御できればOKなので、その辺をひとかたまりにしてAWS側が用意してくれた仕組みがFireLens、という理解です。


## タスク定義
[こちら](https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/userguide/firelens-taskdef.html) を参考に作成しました。

#### `image`
FireLensの基本的な使い方としてよく紹介されているのは、以下のようなパターンです。
* AWSの提供するイメージをそのまま使う
* ユーザ側で個別に設定したいものがある場合は、S3に配置してタスク定義で `config-file-value` で指定する

ただ、この方法だと、以下2点の対応ができなかったため、今回は公式イメージを基に自前でdocker imageを buildしてECRにpushしたものを使いました。
* `Mem_Buf_Limit` の設定
* GCPの認証を通すための環境変数の引き渡し


それぞれ、詳細は後述します。

#### `name` （コンテナ名）
これは `log_router` という名前にしないとうまく動きませんでした。
（最初、適当な名前をつけてデプロイしたらエラーになってしまいました）



# fluentbit 設定

## INPUT
過去にfluendを運用していたことたあるのですが、その際「変なログを投げつけられてfluendで処理が詰まってしまい、fluendがメモリを食い潰してしまう」といったトラブルに悩まされたことがあり、「fluentbitでも`Mem_Buf_Limit`（[参考](https://docs.fluentbit.io/manual/administration/backpressure)）の設定を入れたい！」という思いがありました。

ただ、この設定はINPUTセクションにしか記述できません。そして、AWSの公開イメージをそのまま使ってユーザ定義を`config-file-value`で指定する場合は、INPUTセクションは編集できない（ユーザ定義でINPUTセクションを記述しても無視される）ため、INPUT含めて自分で記述したconfigを使っています。

これが、fluentbitコンテナは自前でbuildしたものを使っている理由の1つです。

なお、ユーザ定義ファイルを指定した場合に、firelensとして起動したfluentbitコンテナ（log_routerコンテナ）の中で設定ファイルがどうなっているかは、以下の記事が参考になります。
* [FireLens（Fluent Bit）の設定ファイルをローカル実行時とFireLensで起動したときの違いを見比べてみる](https://dev.classmethod.jp/articles/check-fluent-bit-conf/)

ローカルとAWS上でコンテナ内部から見た `/fluent-bit/etc/fluent-bit.conf` の中身が変わっている理由が最初わからなくて戸惑ったのですが、実際動かして`df`で見たら`/fluent-bit/etc/fluent-bit.conf`に外部ボリュームか何かがマウントされていました。


## ログの分類
今回は、fluentbitにログを渡す経路は以下2種類になります。
* アプリケーションからfluentドライバを使って直接`24224`ポートに出力
* 標準出力/標準エラー出力が logdriver 経由で出力

前者のパターンでは、出力時に任意のタグをセットすることができます。
一方、標準出力/標準エラー出力の場合は、内容に応じてタグを付与するといったことは（自分の知る限り）できません。
ただ、例えばnginxのログであれば、普通のリクエストログと起動時のログは分けて扱いたく、ただしそれらは全部標準出力として出力され、同じコンテナから出力されたものは全部同じタグがついてfluentbitに到達するので、そこをどうやって区分けしようかな、ということで、今回は以下のように対応しました。

###### nginx設定(抜粋）
```
# nginx設定
log_format main 'NGINX_ACCESS_LOG $msec $remote_addr $request_method $request_uri $status $body_bytes_sent $http_referer $http_user_agent $request_time $http_x_forwarded_for';
```

###### fluenbit設定(抜粋）
```
[FILTER]
    Name rewrite_tag
    Match *-nginx
    Rule $log (NGINX_ACCESS_LOG) nginx-access-log false
```

* log_formatで固定の文字列を埋め込み
* fluentbitの`rewrite_tag`で、`$log`本文と上で埋め込んだ文字列でマッチング
* hitしたら`nginx-access-log`のタグを付与
* 以降はこのタグとの`Match`でハンドリング

このあたりは、もしかしたらもう少しうまいやり方があるのかもしれません。


## Parse
時刻は`TIMESTAMP`型、request_timeは`NUMERIC`型でテーブルを作りたいですが、テーブルと一致する型でないとレコードが登録されません。
[Type Converter](https://docs.fluentbit.io/manual/pipeline/filters/type-converter)や[Lua](https://docs.fluentbit.io/manual/pipeline/filters/lua)のFilter Pluginで変換するといった方法もありそうですが、今回はParserでTypesを指定して対応しました。

上で `nginx-access-log`タグを付与したログにParser Filterを適用。
```
[FILTER]
    Name parser
    Match nginx-access-log
    Key_Name log
    Parser nginx_access_log
```

対応するparserの定義は以下の通り。
```
[PARSER]
    Name nginx_access_log
    Format regex
    Regex ^[^ ]* (?<timestamp>[^ ]*) (?<remote_addr>[^ ]*) (?<method>[^ ]*) (?<path>[^ ]*) (?<status>[^ ]*) (?<body_bytes>[^ ]*) (?<referer>[^ ]*) (?<ua>[^\"]*) (?<request_time>[^ ]*) (?<x_forwarded_for>[^\"]*)
    Time_Key timestamp
    Types status:integer body_bytes:integer request_time:float
```


## Output
[Bigquery output plugin](https://docs.fluentbit.io/manual/pipeline/outputs/bigquery)を使用します。

ここで一つ困ったのが、GoogleのCredentialの設定でした。
AWSの[パラメータストア](https://docs.aws.amazon.com/ja_jp/systems-manager/latest/userguide/systems-manager-parameter-store.html)に格納して読み込ませようとしたのですが、構築時点（この記事執筆時点でも）では、`$GOOGLE_SERVICE_CREDENTIALS`には「credntialの中身」ではなく「credentialファイルのパス」を指定する必要があります。
イメージの中にcrednetialファイルを埋め込むのは避けたいので、ちょっと悩んだ結果、`entrypoint.sh`で`fluentbit`プロセス起動前に以下を追加して対応しました。
* パラメータストアの中身をファイルに出力
* ファイルパスを環境変数にセット

```
echo ${GCP_CREDENTIAL_VALUE} > /fluent-bit/etc/gcp.json
export GOOGLE_SERVICE_CREDENTIALS=/fluent-bit/etc/gcp.json
```
（`GCP_CREDENTIAL_VALUE`はタスク定義の`secrets`でパラメータストアと紐付け）

これが、Buildし直したイメージを使う理由の2つ目です。


# おわりに
もう少し楽に書けるかと思ったのですが、色々思い出したり確認し直したりで意外と時間がかかってしまいました。
誰かのお役に立てば幸いです。