概要 #
構造化ログをCloud Loggingに送りたいとき、Fluentdをサイドカーに置く構成があります。GKEでそれが要るのかを確かめました。
さらに、fluent.confに書いたtagやrecord_transformerが、Cloud Logging上のどのフィールドになるのかも対応が分かりません。サイドカーを入れたのに期待したキーで検索できない、という状態になりがちです。
実際に測ってみると、GKEの収集エージェントが既にJSONを解釈していました。 アプリが標準出力に1行JSONを書くなら、サイドカーは要りません。しかも挟み方を誤ると、逆に構造を失います。
ファイルに書く場合は別です。 GKEの標準収集が拾うのはコンテナの標準出力と標準エラーなので、ファイル出力を届けるには何かが要ります。
対象読者は、GKEでPodを動かしたことがあり、Cloud Loggingでログを見たことがある人です。Kubernetesの入門は扱いません。
Log Routerによる転送、Log-based metrics、Autopilotクラスタは扱いません。
検証すること #
- 素のテキストとJSONで、入るフィールドが変わるか
- 構造化ログにFluentdサイドカーは必要か
fluent.confの設定がCloud Loggingのどのフィールドになるか- スタックトレースが1エントリにまとまるか
前提環境 #
- Google Cloud CLI、Terraform
- Billingが有効な検証用Google Cloud Project
- GKE、Compute Engineを操作できるGoogleアカウント
- 検証時のバージョン:Terraform 1.14.5、google 7.46.0、GKE 1.35.7-gke.1027000、fluentd v1.16-debian-1
使用するTerraformコード #
READMEの手順でTerraformを適用し、k8s/配下の4つのマニフェストを当てます。クラスタはプライベートエンドポイント構成なので、以下のkubectlは踏み台VMから実行します。
3つの経路を同じクラスタに並べる #
別々に測ると条件が揃わないので、3つを同時に動かします。
flowchart LR
subgraph Pod1["Pod: plain-stdout"]
A1["アプリ"]
end
subgraph Pod2["Pod: fluentd-sidecar"]
A2["アプリ"] -->|"ファイル"| F["Fluentd"]
end
subgraph Pod3["Pod: multiline"]
A3["アプリ"]
end
A1 -->|"標準出力"| Agent["GKE 収集エージェント"]
F -->|"標準出力"| Agent
A3 -->|"標準出力"| Agent
Agent --> CL["Cloud Logging"]
真ん中だけ2ホップです。アプリがファイルに書き、Fluentdがそれを読んで自分の標準出力に出し、それをエージェントが拾います。
アプリとFluentdはresource.labels.container_nameで分かれます。ただし今回のアプリはファイルにだけ書くので、Cloud Logging に出るのは加工後だけです。 加工前と比べるならファイルを直接見ます。
kubectl exec deployment/fluentd-sidecar -c app -- cat /var/log/app/app.log
ログ収集の設定 #
logging_config {
enable_components = ["SYSTEM_COMPONENTS", "WORKLOADS"]
}
既定値ですが明示しています。サイドカーなしでもコンテナの標準出力が届く理由がここにあり、比較の土台になるためです。
fluent.conf は1設定に1つの問いを対応させる #
まとめて書くと、どの設定がどのフィールドになったのか分からなくなります。
<source>
@type tail
path /var/log/app/app.log
tag app.log ← tag はエントリのどこかに現れるか
<parse>
@type none
</parse>
</source>
<filter app.log>
@type record_transformer
<record>
service order-api ← 足したキーは jsonPayload のキーになるか
env verification
severity ERROR ← エントリの severity に昇格するか
fluentd_tag ${tag}
</record>
</filter>
<match app.log>
@type stdout
<format>
@type json
</format>
</match>
severityをINFOにすると既定値と区別できないので、ERRORにしています。
素のテキストは textPayload に入る #
以下のgcloud logging readに出てくる...は共通フィルタの省略です。resource.type="k8s_container" AND resource.labels.cluster_name="CLUSTER_NAME"に置き換え、--project=YOUR_PROJECT_IDで対象プロジェクトを指定します。
gcloud logging read '... AND textPayload:"PLAIN"' --format="value(severity,textPayload)"
INFO PLAIN this is an ordinary line
severityはINFOです。指定していないので既定値が入ります。
logNameはprojects/YOUR_PROJECT_ID/logs/stdoutでした。標準出力かどうかで決まり、アプリ名は入りません。
JSON行は自動で構造化される #
ここが今回いちばんの発見でした。サイドカーは無く、アプリが標準出力にJSONを1行書いただけです。
gcloud logging read '... AND jsonPayload.msg:"JSON"' --format="value(severity,jsonPayload)"
ERROR msg=JSON with severity;order_id=1235
INFO msg=JSON without severity;order_id=1234
GKEの収集エージェントがJSONを解釈しています。
textPayloadではなくjsonPayloadに入るので、jsonPayload.order_idで検索できます。標準出力に1行JSONを書く限り、Fluentdサイドカーは要りません。
ただし常にjsonPayloadになるわけではありません。Structured loggingによると、特別扱いのフィールドを移したあとmessageだけが残り、かつdetect_jsonが無効ならtextPayloadに入ります。detect_jsonはGKEのようなマネージド環境には適用されないので、GKEはこの条件に当たります。今回はmsgとorder_idが残るためjsonPayloadでした。messageだけのJSONは試していません。
JSON内の severity はエントリに昇格する #
上の出力をJSONで見ます。
{
"severity": "ERROR",
"jsonPayload": { "msg": "JSON with severity", "order_id": 1235 }
}
アプリが書いたのは{"severity":"ERROR","msg":"JSON with severity","order_id":1235}です。
severityキーはjsonPayloadに残らず、エントリのseverityになっています。指定しなかった側はINFOのままで、jsonPayloadは{"msg":..., "order_id":1234}でした。
コンソールでエラーだけ絞り込みたいとき、これが効きます。アプリ側でJSONにseverityを入れるだけで済みます。
Fluentdが足したキーは jsonPayload のキーになる #
gcloud logging read '... AND resource.labels.container_name="fluentd"' \
--format="value(severity,jsonPayload)"
ERROR env=verification;fluentd_tag=app.log;message={"msg":"APP json line to file","order_id":2345};service=order-api
record_transformerで足したservice / env / fluentd_tagが、そのままjsonPayloadのキーになっています。
severity ERRORも昇格しました。サイドカーは自分の標準出力にJSONを書いているだけですが、それを拾うエージェントが同じ解釈をしています。2ホップでも扱いは変わりません。
tag は自動では現れない #
gcloud logging read '...' --format="value(logName)" | sort -u
projects/YOUR_PROJECT_ID/logs/stdout
logNameはstdoutのままです。今回の<format> @type jsonでは、Fluentdのtag app.logが出力に現れません。
上の出力にfluentd_tag=app.logがあるのは、record_transformerでfluentd_tag ${tag}と明示的に入れたからです。
なおstdout出力の既定のフォーマッタは時刻とtagも出します。JSON形式に変えたことで落ちています。
この設定ではアプリのJSONが文字列になる #
これが実務でいちばん効きます。
{
"env": "verification",
"fluentd_tag": "app.log",
"service": "order-api",
"message": "{\"msg\":\"APP json line to file\",\"order_id\":2345}"
}
アプリが書いたJSONが、文字列のままmessageに入っています。 jsonPayload.order_id=2345という構造化フィールドの検索はできません。文字列の部分一致なら引けます。
原因はfluent.confのこの部分です。
<source>
@type tail
<parse>
@type none ← 行を解釈せず、生のテキストとして message に詰める
</parse>
</source>
サイドカーなしならjsonPayload.order_idで引けたものが、この設定では引けなくなりました。
原因は@type noneの指定です。 行をそのまま1つのフィールドに入れるパーサなので、JSONも文字列になります。
ただし@type jsonに変えるだけでは足りません。今回のマニフェストは通常のテキスト行とJSON行を同じファイルに書いています。
in_tailのemit_unmatched_linesは既定でfalseなので、JSONでない行が転送されなくなります。
アプリが標準出力にJSONを出しているなら、そもそもサイドカーを挟まないほうが早いという結論です。
複数行がどう入るかは確かめきれなかった #
gcloud logging read '... AND labels."k8s-pod/app"="multiline"' --format="value(textPayload)"
MULTILINE-END
MULTILINE-START java.lang.NullPointerException: order is null
at com.example.OrderService.process(OrderService.java:42)
at com.example.OrderController.post(OrderController.java:18)
MULTILINE-END
この出力からエントリの境界は読み取れません。value(textPayload)は各エントリを改行で繋ぐだけなので、4行が1件でも4件でも同じ見た目になります。
表示順から推し量ることもできません。降順はtimestampに基づき、同一時刻のエントリはinsertId順になるので、アプリが出した順との対応が取れないためです。
確かめていません。 環境を削除した後に気づいたため、測り直せていません。次に立てたときは、対象Podと時間範囲を絞って--format=jsonで取得し、各エントリのtimestamp・insertId・textPayloadを見て、STARTとスタックトレースが同じtextPayloadに入るかを判定します。
Cloud Loggingでの絞り込み #
| 目的 | クエリ |
|---|---|
| コンテナ単位 | resource.type="k8s_container" AND resource.labels.container_name="app" |
| Podのラベル | labels."k8s-pod/app"="plain-stdout" |
| クラスタ単位 | resource.labels.cluster_name="CLUSTER" |
アプリとサイドカーはcontainer_nameで分かれるので、同じPod内でも別々に見られます。ただし今回のアプリは標準出力に何も書いていないので、Cloud Logging に出るのはfluentdのほうだけです。
後片付け #
terraform destroy
Destroy complete! Resources: 22 destroyed.
GKE・踏み台VM・Cloud NATは利用中に料金が発生します。Cloud Loggingの取り込みにも課金があります。 検証用のワークロードは20〜30秒ごとにログを出し続けるので、放置しません。
まとめ #
- 素のテキストは
textPayload、JSONはjsonPayload。 収集エージェントが判別する - 構造化ログを出すだけならFluentdサイドカーは要らない。 GKEのエージェントが既にJSONを解釈している
- JSON内の
severityはエントリに昇格し、jsonPayloadからは消える - Fluentdが
record_transformerで足したキーはjsonPayloadのキーになる。severityも昇格する - 今回のJSON出力設定ではtagが自動で付かない。
record_transformerでfluentd_tag ${tag}を足して保持した。既定のstdoutフォーマッタなら時刻とtagも出る - 原因はサイドカーではなくパーサの指定。
@type noneだと行がそのまま1フィールドに入るので、アプリのJSONが文字列としてmessageに入る。@type jsonなら解析される - 複数行の扱いは未確認。
value(textPayload)はエントリを改行で繋ぐだけで境界を示さず、表示順もtimestamp→insertId順なので推し量れない