↓メインコンテンツへスキップ
  1. Blogs/

標準出力に1行JSONを書くだけなら、GKEではFluentdサイドカーが要らなかった

2 分
Terraform GoogleCloud GKE CloudLogging Fluentd
0222-nnn
著者
0222-nnn
猫が好き
目次
Terraform-GoogleCloud - この記事は連載の一部です
パート 37: この記事

概要
#

構造化ログを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順なので推し量れない

参考資料
#

Terraform-GoogleCloud - この記事は連載の一部です
パート 37: この記事

関連記事

GKEのdefault_compute_class_enabledを有効にしても何も起きなかったので調べた
3 分
Terraform GoogleCloud GKE
GKEノードプールのSURGEアップグレードをBlue/Greenと同じ条件で測って比べてみた
3 分
Terraform GoogleCloud GKE
GKEノードプールをBlue/Greenでアップグレードして、soak期間中にロールバックしてみた
5 分
Terraform GoogleCloud GKE CloudKMS
GKEノードのブートディスクをCMEKで暗号化して鍵をローテーションしてみた
6 分
Terraform GoogleCloud GKE CloudKMS CMEK
TerraformでGKEのPodにSecret Managerの値をファイルとしてマウントしてみた
3 分
Terraform GoogleCloud GKE SecretManager
TerraformでGKEのスタンドアロンNEGを消したらLBのバックエンドが戻らなかった話
3 分
Terraform GoogleCloud GKE LoadBalancing