4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Kubernetesのログ入門

4
Last updated at Posted at 2026-10-08

はじめに

Kubernetesを普段使っていても、kubectl logsでPodのログが見られることは分かるものの、実際にどんな種類のログがあるのか、どういう形で管理されているのか、保存期間はどれくらいなのか、長期間保存するにはどうすればいいのか、といったところまではなかなか分からないと思います。

そこでこの記事では、Podのコンテナログとkubelet・コンテナランタイムなどのシステムコンポーネントログについて、ディレクトリ構成・確認方法・ローテーションの仕組みを中心に整理しました。EventとAudit Logについては、別の記事で扱っているのでそちらを参照してください。

対象コンポーネントとバージョン

コンポーネント バージョン リポジトリ 本記事での役割
Kubernetes v1.37.1 kubernetes/kubernetes kubeletのコンテナログ管理・ローテーション・klog連携の実装をソースコードで確認
kind v0.33.0 kubernetes-sigs/kind 検証環境。ノードイメージはkindest/node:v1.37.0
containerd v2.3.4 containerd/containerd 検証環境のCRIランタイム(kindest/node:v1.37.0に同梱。ノード上でcontainerd --versionを実行して確認)
eventrouter commit 0d28dde(2026-09-16) openshift/eventrouter Eventを長期保存する実装例として、Event APIの監視方法と転送方式をソースコードで確認
Fluent Bit v5.1.2 fluent/fluent-bit ノードレベルロギングエージェントの実装例として、in_tail・filter_kubernetes・out_lokiの実装をソースコードで確認
OpenTelemetry Collector Contrib v0.162.0 open-telemetry/opentelemetry-collector-contrib Fluent Bitと並ぶノードレベルロギングエージェントの実装例として、filelog receiver・container operator・k8sattributesprocessorの実装をソースコードで確認
OpenTelemetry Collector v0.162.0 open-telemetry/opentelemetry-collector otlphttpexporterの実装をソースコードで確認
Vector v0.58.0 vectordotdev/vector Fluent Bit・OpenTelemetry Collectorと並ぶノードレベルロギングエージェントの実装例として、kubernetes_logs source・loki sinkの実装をソースコードで確認
Grafana Loki v3.7.8 grafana/loki Fluent Bit・OpenTelemetry Collector・Vectorからのログ転送先として、実際にクラスタ外で動かして検証
stern v1.34.0 stern/stern 複数Pod横断でのログ確認ツールの実装例として、Podの追跡方式をソースコードで確認

TL;DR

  • コンテナログ・システムコンポーネントログ・Event・監査ログという4分類でログの全体像を整理できます。
  • コンテナログは/var/log/pods/<namespace>_<name>_<uid>/<container>/<restartCount>.logに書き込まれ、/var/log/containers/に互換用のシンボリックリンクが張られます。
  • コンテナログのローテーションは、kubelet自身がファイルをtruncateするのではなく、CRIランタイムのReopenContainerLogを呼んで新しいファイルへの書き込みを再開させる方式です。
  • stdout/stderrを分離して取得できるPodLogsQuerySplitStreamsというFeature Gateもあります。
  • kubeletやコンテナランタイムなど「コンテナの外で動くコンポーネント」自身のログには、Kubernetes側のローテーション機構がありません。
  • kubectl get --rawでノードのkubeletログやその他のシステムサービスのログを直接取得できるLog Query機能がv1.36で安定版になりました。
  • DaemonSetで動くログ収集エージェントは、kubeletのローテーション方式(ファイルのrename)に対応するため、パスを監視しつつファイルの実体を見分ける必要があり、Fluent Bitはinode比較、OpenTelemetry CollectorとVectorはファイル内容のチェックサムと、実装方式が異なります。
  • Event(デフォルト1時間で消える)を長期間残したい場合は、Event APIをwatchして別の場所へ出力する専用のコンポーネントを立てます。

第1章: Kubernetesで扱うログの全体像

まず、この記事とEvent・監査ログの記事で扱う範囲を整理します。コンテナログ・システムコンポーネントログ・Event・監査ログという4種類のログについて、それぞれの位置づけを確認します。

1.1 4種類のログの位置づけ

公式ドキュメントのLogging Architectureは、ノード上のログをどう扱い・集約するかを説明するページで、Kubernetesのログ種別を網羅的に分類するものではありません。見出しも次の3つで、対象はノード上のログに限定されています。

  • Pod and container logs
  • System component logs
  • Cluster-level logging architectures

EventとAudit Logがここに登場しないのは、ノード上のファイルではなくAPIオブジェクトとして記録される別の仕組みだからです。

この記事では、ノード上のログ(コンテナログ+システムコンポーネントログ)とAPIオブジェクトのログ(Event、Audit Log)という区別で、それぞれ次の章で扱います。

系統 種類 記録される場所 この記事での扱い
ノード上のログ コンテナログ ノードのファイルシステム(/var/log/pods等) 第2章で詳説
ノード上のログ システムコンポーネントログ(kubelet・コンテナランタイム・static Pod) journald、/var/log配下のファイル 第3章で詳説
APIオブジェクトのログ Event kube-apiserver(デフォルト--event-ttlで1時間保持) 第4章では概要のみ。詳細はKubernetesのeventログの一覧に記載
APIオブジェクトのログ Audit Log Policyで指定したファイル/webhookバックエンド 第4章では概要のみ。詳細はKubernetesの監査ログ(Audit Log)入門に記載

第2章: Podとコンテナのログ

Podのコンテナがstdout/stderrに書いたログを、kubeletがどのようにノード上のファイルへ落とし込み、どうローテーションしているかを確認します。

2.1 ディレクトリ構成とファイルパスの決定

コンテナログの絶対パスは、Pod単位のディレクトリとコンテナ単位のサブディレクトリの組み合わせで決まります。

// BuildPodLogsDirectory builds absolute log directory path for a pod sandbox.
func BuildPodLogsDirectory(podLogsDir, podNamespace, podName string, podUID types.UID) string {
	return filepath.Join(podLogsDir, strings.Join([]string{podNamespace, podName,
		string(podUID)}, logPathDelimiter))
}

(参考: pkg/kubelet/kuberuntime/helpers.go#L209-L226)

podLogsDirのデフォルト値は/var/log/podsで、KubeletConfigurationのpodLogsDirフィールド(デフォルトは空文字列扱いで、起動時にこのデフォルトが補完されます)で変更できます。

DefaultPodLogsDir = "/var/log/pods"

(参考: pkg/kubelet/apis/config/v1beta1/defaults.go#L42、フィールド定義はstaging/src/k8s.io/kubelet/config/v1beta1/types.go#L132-L137)

変更する場合は、KubeletConfiguration(--configで指定するファイル、kindクラスタでは/var/lib/kubelet/config.yaml)に次のように指定します。

apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
podLogsDir: /mnt/log/pods

コンテナ単位のファイル名は<コンテナ名>/<再起動回数>.logで、このパスがCRIのContainerConfig.LogPathとしてランタイムに渡され、Pod Sandbox単位で設定されるLogDirectoryと結合されて最終的な絶対パスになります。

containerLogsPath := buildContainerLogsPath(container.Name, restartCount)
// ...
config := &runtimeapi.ContainerConfig{
	// ...
	LogPath: containerLogsPath,
	// ...
}

(参考: pkg/kubelet/kuberuntime/kuberuntime_container.go#L360-L382、Sandbox側のLogDirectory設定はpkg/kubelet/kuberuntime/kuberuntime_sandbox.go#L117-L118)

実際にkindクラスタ上でPodを起動して確認すると、/var/log以下は次のような階層になっていました。

$ tree /var/log/pods/default_demo-logtest_7f337cdd-9b2c-4cdd-839d-8336181f06a4
/var/log/pods/default_demo-logtest_7f337cdd-9b2c-4cdd-839d-8336181f06a4
`-- app
    |-- 0.log
    |-- 0.log.20261007-065856.gz
    |-- 0.log.20261007-065907.gz
    |-- 0.log.20261007-065917.gz
    `-- 0.log.20261007-065927

2 directories, 5 files

pods/の下が<namespace>_<name>_<uid>、その下が<コンテナ名>というディレクトリになっており、ソースコードの実装どおりの階層が再現されています。

2.2 /var/log/containers/へのシンボリックリンク

/var/log/containers/配下にも、コンテナログのシンボリックリンクが作られます。これはCRI導入前、kubeletがdockerdを直接操作していた頃に使われていたコンテナログの配置をそのまま残したものです。関数名もlegacyLogSymlinkと、ソースコード上はっきり「legacy」と位置づけられています。

// legacyLogSymlink composes the legacy container log path. It is only used for legacy cluster
// logging support.
func legacyLogSymlink(containerID string, containerName, podName, podNamespace string) string {
	return logSymlink(legacyContainerLogsDir, kubecontainer.BuildPodFullName(podName, podNamespace),
		containerName, containerID)
}

(参考: pkg/kubelet/kuberuntime/legacy.go#L31-L75)

シンボリックリンクの作成自体は、コンテナ起動が成功した直後、実際のログファイルが存在することを確認してから行われます。

// Symlink container logs to the legacy container log location for cluster logging
// support.
// TODO(random-liu): Remove this after cluster logging supports CRI container log path.

(参考: pkg/kubelet/kuberuntime/kuberuntime_container.go#L300-L317)

上記のTODOコメントにあるとおり、このシンボリックリンクはいずれ削除される可能性があります。

実際の検証環境では、次のようなシンボリックリンクが張られていました。

$ ls -l /var/log/containers/ | grep demo-logtest
lrwxrwxrwx 1 root root 81 Oct  7 06:58 demo-logtest_default_app-a33e8752ef7403a4970f2531374c62e9a657e5b945b3df05f1a8604da9f7d222.log -> /var/log/pods/default_demo-logtest_7f337cdd-9b2c-4cdd-839d-8336181f06a4/app/0.log

2.3 CRIログフォーマットとkubectl logs

コンテナランタイムがファイルに書き込むログの各行は、CRIで定められた形式に従います。この形式はkubelet側のk8s.io/cri-clientモジュールでパースされており、ソースコード中のコメントに例が示されています。

// parseCRILog parses logs in CRI log format. CRI Log format example:
//
//	2016-10-06T00:17:09.669794202Z stdout P log content 1
//	2016-10-06T00:17:09.669794203Z stderr F log content 2

(参考: staging/src/k8s.io/cri-client/pkg/logs/logs.go#L123-L168)

タイムスタンプ・ストリーム種別(stdout/stderr)に続く1文字は、そのログ行が1行の出力として完結しているか(F = Full)、まだ続きがあるか(P = Partial)を示すタグです。

LogTagPartial LogTag = "P"
LogTagFull LogTag = "F"

(参考: staging/src/k8s.io/cri-api/pkg/apis/runtime/v1/constants.go#L49-L52)

このタグは、アプリケーションが改行区切りで書いた複数行のログ(スタックトレース等)を結合するためのものではありません。CRIランタイム側の1行あたりの読み取りバッファ上限(containerdではMaxContainerLogLineSize、デフォルト16KB)を、アプリケーションが1回の書き込みで超えた場合に、その1行を複数の物理行に分割して書き込むための仕組みです。分割された行のうち、最後の断片だけがFになります。

if maxLen > 0 && length > maxLen {
	// ...
	writeLineBuffer(partial, buf)
	// ...
}

(参考: internal/cri/io/logger.go#L114-L195、バッファ上限の設定はinternal/cri/config/config_unix.go#L102)

検証環境のkube-apiserverのログを見ると、実際にこの形式で書き込まれていることが確認できます。

$ docker exec k8s-logging-demo-control-plane tail -c 300 /var/log/pods/kube-system_kube-apiserver-*/kube-apiserver/0.log
2026-09-30T06:07:44.320806626Z stderr F I0930 06:07:44.320423       1 controller.go:667] quota admission added evaluator for: controllerrevisions.apps
2026-09-30T06:07:44.563889999Z stderr F I0930 06:07:44.563773       1 controller.go:667] quota admission added evaluator for: replicasets.apps

kubectl logsはこのCRI形式のファイルを直接読み、タグとタイムスタンプを除いた本文だけをクライアントに返します。

CRIログ形式がstdout/stderrをストリーム種別として行ごとに記録していることを利用して、Pod APIからstdout・stderrのどちらか一方だけを取得できる機能もあります。PodLogsQuerySplitStreamsというFeature Gateで、v1.32でアルファとして導入され、v1.37時点でもデフォルトで無効です(参考: pkg/features/kube_features.go#L899-L902、デフォルト状態は同ファイルL1978-L1980)。

kubelet側の実装では、PodLogOptionsのstreamフィールドの値に応じて、CRIログのstdout行・stderr行のどちらを応答に書き込むかを切り替えているだけです。

switch *wantedStream {
case v1.LogStreamStdout:
	stdout, stderr = fw, nil
case v1.LogStreamStderr:
	stdout, stderr = nil, fw
case v1.LogStreamAll:
	stdout, stderr = fw, fw

(参考: pkg/kubelet/server/server.go#L888-L901)

Feature Gateを有効にして実際に試すと、streamクエリパラメータでstdout/stderrを分離して取得できました。

$ kubectl get --raw "/api/v1/namespaces/default/pods/demo-streams/log?stream=Stdout"
stdout-line-1
stdout-line-2
done

$ kubectl get --raw "/api/v1/namespaces/default/pods/demo-streams/log?stream=Stderr"
stderr-line-1
stderr-line-2

普段のkubectl logsはstreamを指定しない(All)ため、これまでどおり両方のストリームがまとめて返ってきます。

2.4 ログローテーション

コンテナログのローテーションはpkg/kubelet/logs/container_log_manager.goが担当します。デフォルト値は次のとおりです。

設定項目 デフォルト値 役割
containerLogMaxSize 10Mi 1ファイルあたりの上限サイズ
containerLogMaxFiles 5 コンテナあたりの最大ファイル数(現行+ローテーション済み合計)
containerLogMaxWorkers 1 ローテーションを並行実行するワーカー数
containerLogMonitorInterval 10秒 ログサイズを監視する間隔

(参考: pkg/kubelet/apis/config/v1beta1/defaults.go#L256-L266)

これらもKubeletConfigurationで変更できます。たとえばログの量が多いワークロードが多いノードで、1ファイルあたりの上限を大きくしつつ保持ファイル数を減らす場合は次のようになります。

apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
containerLogMaxSize: 50Mi
containerLogMaxFiles: 3
containerLogMaxWorkers: 2
containerLogMonitorInterval: 10s

ローテーションの実体は、containerLogMonitorIntervalごとに実行中の全コンテナを列挙し、ログファイルのサイズがcontainerLogMaxSize以上ならローテーション処理をキューに積む、というシンプルなポーリングです。

func (c *containerLogManager) processContainer(ctx context.Context, worker int) (ok bool) {
	// ...
	if info.Size() < c.policy.MaxSize {
		// rotate不要
		return
	}
	if err := c.rotateLog(ctx, id, path); err != nil {
		// ...
	}
	return
}

(参考: pkg/kubelet/logs/container_log_manager.go#L218-L270)

ここで重要なのは、kubeletがファイルをtruncateして使い回すのではなく、次の手順を踏む点です。

  1. 現在のログファイルをタイムスタンプ付きの名前にrenameする(この時点ではまだ無圧縮)。これはkubelet自身が行う処理で、ファイルの中身ごと退避させるだけです。
  2. CRIランタイムのReopenContainerLogを呼ぶ。ここから先はkubeletではなくランタイム側の処理です。
    1. リネームによって空になった元のパスに、新しいログファイルを作成する。
    2. コンテナのstdout/stderrから受け取ったバイトの書き込み先を、今作った新しいファイルに差し替える。
    3. それまで書き込み先として使っていた古いファイルハンドル(リネームされた方のファイルを握っていたハンドル)を閉じる。
  3. ReopenContainerLogが失敗したらリネームを元に戻し、次のローテーション周期(デフォルト10秒)で再試行する(ログを失わないための保険)。

コンテナプロセス自身は、自分のstdoutの書き込み先ファイルが新しいファイルに切り替わったことを一切意識しません。ファイルの実体を直接触っているのはコンテナプロセスではなくランタイム側であり、コンテナはあくまでランタイムが用意したパイプに書き込み続けているだけだからです。

(参考: containerd側のReopenContainerLog実装はinternal/cri/server/container_log_reopen.go#L29-L51、新しいログファイルをO_CREATEで作成している箇所はinternal/cri/server/helpers_linux.go#L58-L62)

func (c *containerLogManager) rotateLatestLog(ctx context.Context, id, log string) error {
	// ...
	timestamp := c.clock.Now().Format(timestampFormat)
	rotated := fmt.Sprintf("%s.%s", log, timestamp)
	if err := c.osInterface.Rename(log, rotated); err != nil {
		return fmt.Errorf("failed to rotate log %q to %q: %w", log, rotated, err)
	}
	if err := c.runtimeService.ReopenContainerLog(ctx, id); err != nil {
		// rename back...
	}
	return nil
}

(参考: pkg/kubelet/logs/container_log_manager.go#L414-L436)

リネーム後、まだ圧縮されていないローテーション済みファイルはgzipで非同期に圧縮され、圧縮後は元ファイルが削除されます。ファイル数がcontainerLogMaxFilesを超える場合は、古い順に削除されます(「現在のファイル」と「ローテーション中の1ファイル」を除いたMaxFiles - 2個までが、圧縮済みログとして保持される計算です)。

// removeExcessLogs removes old logs to make sure there are only at most MaxFiles log files.
func (c *containerLogManager) removeExcessLogs(logs []string) ([]string, error) {
	sort.Strings(logs)
	maxRotatedFiles := c.policy.MaxFiles - 2
	// ...
}

(参考: pkg/kubelet/logs/container_log_manager.go#L272-L371、gzip圧縮の実装は同ファイルL373-L411)

実際にローテーションを起こしてみる

kindクラスタ上でstdoutに大量の行を書き続けるだけのPodを動かし、ローテーションの様子を観察しました。開始直後は、ローテーション済みファイルがまだ圧縮されていない状態が見えます。

$ tree -h /var/log/pods/default_demo-logtest_.../app
/var/log/pods/default_demo-logtest_.../app
|-- [ 17M]  0.log
`-- [ 11M]  0.log.20260930-062154

1 directory, 2 files

しばらく待つと、ローテーション済みファイルが.gzに置き換わり、さらに新しいローテーションも発生しています。

$ tree -h /var/log/pods/default_demo-logtest_.../app
/var/log/pods/default_demo-logtest_.../app
|-- [ 11M]  0.log
|-- [1005K]  0.log.20260930-062154.gz
`-- [ 17M]  0.log.20260930-062214

1 directory, 3 files

さらにローテーションが繰り返されると、圧縮済み3ファイル+ローテーション中の1ファイル+現在のファイルの合計5ファイルで安定し、6ファイル目が作られるタイミングで最も古いファイルが削除されました。

$ tree -h /var/log/pods/default_demo-logtest_.../app
/var/log/pods/default_demo-logtest_.../app
|-- [2.9M]  0.log
|-- [1005K]  0.log.20260930-062154.gz
|-- [1.5M]  0.log.20260930-062214.gz
|-- [1.4M]  0.log.20260930-062234.gz
`-- [ 17M]  0.log.20260930-062254

1 directory, 5 files

containerLogMaxFiles: 5が「現在のファイルを含めて合計5ファイル」を意味することが、実際の挙動としても確認できました。

kubectl logsで読めるのは現在(最新)のログファイルだけです。ローテーション済みの.log.<timestamp>や.gzファイルの内容はkubectl logsでは取得できません。ノード上のファイルを直接見るか、ログ収集エージェント側でローテーション済みファイルまで拾う設計にしておく必要があります(参考: Logging Architecture#Log rotation)。

第3章: システムコンポーネントのログ

Podのコンテナとは別に、kubeletやコンテナランタイム自身、そして制御コンポーネントにもログがあります。kubeletとコンテナランタイムはPodを管理する側なので、常にノードのOS上のプロセスとして動きます。一方、kube-apiserverなどの制御コンポーネントをsystemdのunitとして動かすかstatic Podとして動かすかは、クラスタの構築方法次第です。動かし方によってログの出力先・ローテーション方式が次のように変わります。

パターン 実体 ログの出力先 ローテーション 確認コマンド
systemdのunit ノードOS上で直接動くプロセス journald Kubernetes側の機構はなし。journald/OS側に依存(3.4節) journalctl -u <unit名>
static Pod kubeletが管理する「ただのコンテナ」 通常のPodと同じ/var/log/pods(stdout) 通常のコンテナログと同じcontainerLogMaxSize/MaxFiles(第2章2.4) kubectl logs

この章では、それぞれどこに・どんな形式で出力されるかを以下で詳しく確認します。この記事の検証環境(kind)では、次のように動いています。

  • systemd
    • kubelet
    • コンテナランタイム(containerd)
  • static Pod
    • kube-apiserver
    • kube-controller-manager
    • kube-scheduler
    • etcd

3.1 systemdで管理されるコンポーネント

kubeletとコンテナランタイムは、通常コンテナの中では動かず、ノードのOS上で直接動くプロセスです。ノードの初期化・サービス管理の仕組みはsystemdであることが多いため、この記事もsystemdを前提に話を進めます。systemd環境では、デフォルトでjournaldにログを書き込みます。

今回の検証で用いたkindでも、kubeletはsystemdのunitとして動いています。

$ docker exec k8s-logging-demo-control-plane systemctl status kubelet --no-pager | head -8
● kubelet.service - kubelet: The Kubernetes Node Agent
     Loaded: loaded (/etc/systemd/system/kubelet.service; enabled; preset: enabled)
     Active: active (running) since Wed 2026-09-30 06:07:36 UTC; 14min ago
   Main PID: 766 (kubelet)
$ docker exec k8s-logging-demo-control-plane journalctl -u kubelet --no-pager | tail -3
Sep 30 06:21:41 k8s-logging-demo-control-plane kubelet[766]: I0930 06:21:41.349103     766 pod_startup_latency_tracker.go:144] "Observed pod startup duration" pod="default/demo-logtest" ...

journalctl -u kubeletでkubelet自身のログが直接読めます。

3.2 static Podとして動くコンポーネント

static Podの元になるマニフェストの置き場所は、kubeletのKubeletConfigurationではstaticPodPathというフィールドで指定します。

// staticPodPath is the path to the directory containing local (static) pods to
// run, or the path to a single static pod file.
// Default: ""
StaticPodPath string `json:"staticPodPath,omitempty"`

(参考: staging/src/k8s.io/kubelet/config/v1beta1/types.go#L127-L131)

デフォルト値は空文字列で、kubelet自体は/etc/kubernetes/manifestsをハードコードしていません。この慣習的なパスはkubeadmなど、kubeletを実際にセットアップするツール側の取り決めで、実際にkubeadmが生成するKubeletConfigurationには次のように明示的に書き込まれています。

apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
staticPodPath: /etc/kubernetes/manifests

監視の実装はpkg/kubelet/config/file.goのNewSourceFileで、指定したディレクトリをファイルシステム通知で監視し、追加・削除されたマニフェストに応じてPodを起動・停止します。static Podは通常のAPIオブジェクトとしては作成されないため、kubeletはpkg/kubelet/pod/mirror_client.go経由で対応する「mirror Pod」をAPIサーバー上に作り、kubectl get pod等から見えるようにしています。

重要なのは、これらのコンポーネントがただのコンテナとして動いているという点です。static Podとして動くkube-apiserver等がstdoutに書いたログは、通常のPodのコンテナと全く同じ扱い(第2章のCRIログ管理・/var/log/pods・containerLogMaxSize/MaxFilesによるローテーション)を受けます。実際、検証環境でも次のとおり通常のPodと同じディレクトリ構成でした。

$ tree --matchdirs -P '*kube-apiserver*' --prune /var/log/pods
/var/log/pods
`-- kube-system_kube-apiserver-k8s-logging-demo-control-plane_aec712fda8c04555eaada4085f170287
    `-- kube-apiserver

3 directories, 0 files

3.3 ログのフォーマットとklog

システムコンポーネントのログは、同じ内容でも次の2つのフォーマットのどちらかで出力されます。

値 出力形式
text(デフォルト) 人間が読みやすいキーバリュー形式
json 構造化されたJSON形式

実際にkube-schedulerのリーダー選出のログを、フォーマットを切り替えて比較してみます。text形式(デフォルト)では次のように出力されます。

I1008 08:56:19.685458       1 leaderelection.go:258] "Attempting to acquire leader lease..." lock="kube-system/kube-scheduler"
I1008 08:56:19.688682       1 leaderelection.go:272] "Successfully acquired lease" lock="kube-system/kube-scheduler"

同じイベントをjson形式に切り替えて出力させると、次のようになります。

{"ts":1791449829380.4631,"caller":"leaderelection/leaderelection.go:258","msg":"Attempting to acquire leader lease...","v":0,"lock":"kube-system/kube-scheduler"}
{"ts":1791449829383.8164,"caller":"leaderelection/leaderelection.go:272","msg":"Successfully acquired lease","v":0,"lock":"kube-system/kube-scheduler"}

lockフィールドの中身は同じですが、text形式はメッセージと埋め込まれたキーバリューをそのまま1行に並べた形式、json形式はts/caller/msg/vといったキーを持つJSONオブジェクトです。このフォーマットの出し分けを担っているのが、kubeletを含むKubernetesのコンポーネントが共通で使っているロギングライブラリklogです。klogは出力形式だけでなく出力先についても規定しており、公式ドキュメントには次のように明記されています。

Output will always be written to stderr, regardless of the output format. Output redirection is expected to be handled by the component which invokes a Kubernetes component. This can be a POSIX shell or a tool like systemd.

(参考: System Logs#Klog)

出力形式に関わらず、出力先は常にstderr固定であり、リダイレクトはKubernetesコンポーネントを起動する側(POSIXシェルやsystemdなど)の責任だと明記されています。

この--logging-formatフラグは、次の4コンポーネントで利用できます。いずれもデフォルトはtext形式で、json形式にするには明示的な設定が必要です。

  • kube-apiserver
  • kube-controller-manager
  • kube-scheduler
  • kubelet

実際、検証環境のstatic Podマニフェストを見ても--logging-formatは指定されておらず、ログはklogのテキスト形式のままでした。

$ docker exec k8s-logging-demo-control-plane grep -i logging-format /etc/kubernetes/manifests/*.yaml
(該当なし)

有効にする場合、static Podとして動くものはマニフェストのcommandに以下のように引数を追加します。

apiVersion: v1
kind: Pod
metadata:
  name: kube-apiserver
  namespace: kube-system
spec:
  containers:
  - name: kube-apiserver
    command:
    - kube-apiserver
    - --logging-format=json
    # ...(他の引数は省略)

kubelet自身は起動引数ではなくKubeletConfigurationのlogging.formatフィールドで指定します。

apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
logging:
  format: json

JSON形式に加えて、Contextual LoggingとStructured Loggingという2つの仕組みも押さえておくとよいと思います。どちらも「有効かどうか」だけでなく、ログ出力箇所のコードが実際にそのAPIを使っているかによって見え方が変わります。kube-schedulerの実装を例に見てみます。

func (sched *Scheduler) scheduleOnePod(ctx context.Context, podInfo *framework.QueuedPodInfo) {
	logger := klog.FromContext(ctx)
	pod := podInfo.Pod
	// TODO(knelasevero): Remove duplicated keys from log entry calls
	// When contextualized logging hits GA
	// https://github.com/kubernetes/kubernetes/issues/111672
	logger = klog.LoggerWithValues(logger, "pod", klog.KObj(pod))
	ctx = klog.NewContext(ctx, logger)
	logger.V(4).Info("About to try and schedule pod", "pod", klog.KObj(pod))

(参考: pkg/scheduler/schedule_one.go#L93-L101)

  • Contextual Logging
    • 次の部分がこれです。

      klog.LoggerWithValues(logger, "pod", klog.KObj(pod))
      

      このPod用に作ったloggerにpodフィールドを紐付け、ctx = klog.NewContext(ctx, logger)でcontextに載せています。これ以降ctxを受け取って動く別の関数がklog.FromContext(ctx)でロガーを取り出すだけで、都度"pod", klog.KObj(pod)を書き直さなくてもpodフィールドが自動的に付いてきます。

  • Structured Logging
    • 次の"pod", klog.KObj(pod)という部分がこれです。

      logger.V(4).Info("About to try and schedule pod", "pod", klog.KObj(pod))
      

      メッセージ文字列とは別に、キーと値の組をログ行に付与します。--logging-format=text(デフォルト)なら次のようにキーバリュー形式で、--logging-format=jsonならJSONのフィールドとして出力されます。

実際にkindクラスタのkube-scheduler(-v=4)でこの行を出力させてみると、次のようになりました。

I1007 09:47:56.151071       1 schedule_one.go:101] "About to try and schedule pod" pod="default/demo-schedule-test"

コード中には"pod", klog.KObj(pod)という呼び出し側の明示的なキーがあります。loggerは直前のklog.LoggerWithValuesで既にpodフィールドを持っているので、本来この呼び出し側での指定は冗長なはずです。実際、klogの内部実装(internal/serialize/keyvalues.go)には、同じキーが複数回渡された場合に重複を取り除く処理があり、値が一致していれば後から渡された分は捨てられます。

if bytes.Compare(data[existing[i].start:existing[i].end], data[e.start:e.end]) == 0 {
	// The new entry gets obsoleted because it's identical.
	// ...
	obsolete = append(obsolete, e.interval)
}

(参考: vendor/k8s.io/klog/v2/internal/serialize/keyvalues.go#L100-L109)

試しに--feature-gates=ContextualLogging=falseでContextual Logging自体を無効化し、同じ行を出力させても、結果は変わりませんでした。

I1007 09:49:45.058261       1 schedule_one.go:101] "About to try and schedule pod" pod="default/demo-schedule-test3"

これは、Contextual Loggingが無効ならklog.LoggerWithValuesが実質何もしなくなる一方で、呼び出し側が同じpodキーを明示的にも渡しているため、そちらだけでpodフィールドが出力され続けるからです。コード中のTODOコメントが示すとおり、

// TODO(knelasevero): Remove duplicated keys from log entry calls
// When contextualized logging hits GA

(参考: pkg/scheduler/schedule_one.go#L96-L97)

これは「Contextual LoggingがGAになるまでの間、無効化されても出力が変わらないようにするための意図的な二重指定」だと考えられます。GA後にこの冗長な呼び出し側の指定が取り除かれれば、podフィールドが出力されるかどうかはContextual Loggingの有効/無効に完全に依存するようになると思います。

3.4 システムコンポーネントログのローテーション

第2章で見たコンテナログのローテーションは、あくまでkubeletが管理するContainerLogMaxSize/MaxFilesの仕組みです。journaldに書かれるkubelet・コンテナランタイム自身のログには、この仕組みは適用されません。k8s.io/component-base/logsパッケージ自体にも、サイズや時間を基準にしたローテーション処理は実装されていません。公式ドキュメントも次のように説明しています。

Similar to the container logs, you should rotate system component logs in the /var/log directory. In Kubernetes clusters created by the kube-up.sh script, log rotation is configured by the logrotate tool. The logrotate tool rotates logs daily, or once the log size is greater than 100MB.

(参考: System Logs#Log location)

static Podとして動く制御コンポーネントについても、公式ドキュメントは次のように注意を促しています。

If you deploy Kubernetes cluster components (such as the scheduler) to log to a volume shared from the parent node, you need to consider and ensure that those logs are rotated. Kubernetes does not manage that log rotation.

(参考: Logging Architecture)

これは、stdoutに書く(第2章のCRI管理下に入る)のではなく、あえて/var/logをhostPathでマウントしてそこにファイルを書かせるような構成を取った場合の注意で、stdoutに書く一般的な構成であれば、通常のコンテナログと同じくkubeletがローテーションを管理してくれます。

3.5 Log Queryでノードのシステムログを直接取得する

比較的新しい機能として、Log Queryがあります。v1.27でアルファ機能として導入され、v1.36で安定版に昇格しました(参考: System Logs#Log query)。ノードにSSHしたりjournalctlを叩いたりしなくても、kube-apiserver経由でノード上のシステムログを取得できます。

利用にはkubeletのenableSystemLogHandler(デフォルトfalse)を有効にする必要があります。検証環境で実際に有効化して試してみました。

# /var/lib/kubelet/config.yaml に追記
enableSystemLogHandler: true
enableSystemLogQuery: true

kubelet再起動後、次のようにkubectl get --rawでkubeletのログを直接取得できました。

$ kubectl get --raw "/api/v1/nodes/k8s-logging-demo-control-plane/proxy/logs/?query=kubelet&tailLines=5"
Sep 30 06:29:29.628900 k8s-logging-demo-control-plane kubelet[1866838]: I0930 06:29:29.628821 ... "Finished populating initial desired state of world"
Sep 30 06:29:29.644381 k8s-logging-demo-control-plane kubelet[1866838]: I0930 06:29:29.644327 ... "operationExecutor.VerifyControllerAttachedVolume started for volume \"xtables-lock\"..." pod="kube-system/kube-proxy-jqk52"

patternクエリパラメータで正規表現による絞り込みもできます。

$ kubectl get --raw "/api/v1/nodes/k8s-logging-demo-control-plane/proxy/logs/?query=kubelet&pattern=broadcasted&tailLines=200"
Sep 30 06:07:32.248053 k8s-logging-demo-control-plane kubelet[244]: I0930 06:07:32.248014 244 server.go:177] "Pod update broadcasted" podUID="aec712fda8c04555eaada4085f170287" type="ADDED"

queryにはサービス名だけでなく/var/log配下のファイル名も指定でき、kubeletはjournaldを優先的に探し、無ければ/var/log/<servicename>等を探すヒューリスティックで動作します(参考: System Logs#Log query)。

nodes/proxyへのget権限は、Log Query以外にもノード上で任意のコンテナに対してコマンドを実行できる強力なkubelet APIへのアクセスも同時に許可してしまいます。権限を付与する際はKubelet認証・認可のドキュメントにある注意点を確認しておくのが良いと思います。

第4章: EventとAudit Log

EventとAudit Logは、それぞれ別の記事で扱っているのでそちらを参照してください。

4.1 Event

Podのスケジューリング失敗やヘルスチェック失敗など、Kubernetesの各コンポーネントが状態変化に応じて発行するAPIオブジェクトです。kubectl describe podやkubectl get eventsで確認できますが、デフォルトで1時間(--event-ttl)しか保持されません。Reasonの種類ごとの詳しい整理は、以前書いた記事を参照してください。

参考: Kubernetesのeventログの一覧

Eventを長期間残したい場合は、Event APIをwatchして別の場所へ出力する専用のコンポーネントを用意します。その実装例として、eventrouterを見てみます。手元で確認したクローンは、Deploymentを使ってクラスタ全体で1つだけPodを用意し、そのPod内のプロセスがSharedInformerでEvent APIをwatchする実装でした。

func NewEventRouter(kubeClient kubernetes.Interface, eventsInformer coreinformers.EventInformer) *EventRouter {
	er := &EventRouter{
		kubeClient: kubeClient,
		eSink:      sinks.ManufactureSink(),
	}
	eventsInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
		AddFunc:    er.addEvent,
		UpdateFunc: er.updateEvent,
		DeleteFunc: er.deleteEvent,
	})
	er.eLister = eventsInformer.Lister()
	er.eListerSynched = eventsInformer.Informer().HasSynced
	return er
}

(参考: eventrouter.go#L50-L63、Informer自体の生成元はmain.go#L112-L113)

受け取ったEventの転送先(sink)は、設定で切り替えられる作りになっています。

func ManufactureSink() (e EventSinkInterface) {
	s := viper.GetString("sink")
	switch s {
	case "glog":
		e = NewGlogSink()
	case "stdout":
		e = NewStdoutSink()
	case "http":
		// ...
	case "kafka":
		// ...
	}
	return e
}

(参考: sinks/interfaces.go#L35-L82)

リポジトリに同梱されているKubernetesマニフェストでは、このsinkがstdoutに設定されています。

apiVersion: v1
data:
  config.json: |-
    {
      "sink": "stdout"
    }
kind: ConfigMap

(参考: yaml/eventrouter.yaml)

stdout sinkの実装は、EventをJSONにシリアライズして標準出力に書き出すだけです。コード中のコメントに、その狙いが明記されています。

// StdoutSink is the other basic sink
// By default, Fluentd/ElasticSearch won't index glog formatted lines
// By logging raw JSON to stdout, we will get automated indexing which
// can be queried in Kibana.

(参考: sinks/stdoutsink.go#L27-L46)

つまりEvent専用の転送手段を別途実装するのではなく、第5章・第6章で見たコンテナログ収集と同じ「標準出力に書く→ノードレベルエージェントが拾う」という仕組みにそのまま乗せる設計です。eventrouterのPod自体が標準出力にJSONを書き出すだけで、既存のFluent BitなどのDaemonSetがいつも通り収集し、Eventの長期保存が実現できます。

4.2 Audit Log

「誰が・いつ・何を・どうしようとしたか」というAPIリクエストそのものを記録する、kube-apiserverの仕組みです。Eventとは異なるaudit.k8s.ioという専用のAPI群で表現され、Policyでログの粒度や出力先(ファイル/webhook)を細かく制御できます。設定方法や実際に記録される内容については、以前書いた記事を参照してください。

参考: Kubernetesの監査ログ(Audit Log)入門

第5章: ログの収集方法

ノード上に散らばったログを、クラスタ全体で一元的に扱うための収集方法を整理します。

5.1 3つのアーキテクチャパターン

公式ドキュメントは、クラスタレベルのログ収集について3つのアプローチを挙げています。

  • アプリケーションから直接バックエンドへログを送る。
  • アプリケーションPodに専用のsidecarコンテナを追加する。
  • ノードごとに動くノードレベルのロギングエージェントを使う。
パターン 概要 向いているケース
アプリケーションから直接送信 Kubernetesの管轄外 アプリケーション側で送信ライブラリを組み込める場合
sidecar(専用エージェント) Fluentd等のロギングエージェント自体をPodにsidecarコンテナとして追加 ノードレベルエージェントでは要件を満たせない場合。stdoutに書かない場合はkubectl logsでは読めない点に注意
ノードレベルエージェント(DaemonSet) ノードごとに1つのエージェントが、そのノード上の全コンテナのログファイルを収集 アプリケーション側の変更が不要。最も一般的

5.2 アプリケーションから直接送信する

アプリケーション自身がログ送信用のライブラリを組み込み、ノードのエージェントを経由せず直接バックエンドへ送信する方法です。Kubernetes自体の管轄外の構成になります。

Exposing logs directly from the application

アプリケーションがノードやKubernetesを経由せず、直接ログバックエンドへ送信する構成

(参考: Logging Architecture#Exposing logs directly from the application)

5.3 専用のロギングエージェントをPodにsidecarとして追加する

ノードレベルのエージェントでは要件を満たせない場合、Fluentd等のロギングエージェントをPodにsidecarコンテナとして追加し、アプリケーションコンテナのログを直接処理させる方法もあります。

Sidecar container with a logging agent

専用のロギングエージェントをPodにsidecarコンテナとして追加し、直接バックエンドへ転送する構成

(参考: Logging Architecture#Sidecar container with a logging agent)

5.4 ノードレベルエージェントによるDaemonSetでの収集

最も一般的なのは、Fluent BitやOpenTelemetry CollectorのようなロギングエージェントをDaemonSetとしてすべてのノードにデプロイし、各ノードの/var/log/podsをhostPathでマウントして読む方法です。

Using a node logging agent

ノードごとに1つのロギングエージェントを配置し、そのノード上の全コンテナのログファイルを収集する構成

(参考: Logging Architecture#Using a node logging agent)

DaemonSetにする理由は単純で、ノードごとに1つのエージェントだけを常駐させれば、そのノード上で動く全Podのログを横断的に拾えるためです。アプリケーション側のマニフェストを変更する必要がありません。

個人的には、まずこの方法から始めるのが良いと思います。アプリケーション側に手を入れる必要がなく、クラスタ側にDaemonSetを1つ用意するだけで、既存のワークロードも含めて横断的にログを収集できるためです。

第6章: ログ収集の実装例: Fluent BitとOpenTelemetry Collector

6.1 ログを収集して、蓄積する場合の例

ログを収集して蓄積する一例として、ここではノードレベルエージェントにFluent Bit・OpenTelemetry Collector・Vector、転送先にGrafana Lokiを使う構成を見ていきます。

  • Fluent Bit (C言語, Apache License 2.0)
    • ロギングエージェント。CNCF(Cloud Native Computing Foundation)のプロジェクトで、複数のコントリビューターが開発しています(参考: README.md#Authors)。in_tail・filter_kubernetes・out_lokiを使います。
  • OpenTelemetry Collector (Go言語, Apache License 2.0)
    • ロギングエージェント。同じくCNCFのプロジェクトです(参考: OpenTelemetry)。Collectorのメンテナーは特定の1社が全体の4分の1を超えて占めないようにする規約があり、複数ベンダーのコントリビューターがバランスを取りながら開発しています(参考: README.md#No Over-Representation)。filelog receiver・container operator・k8sattributesprocessor・otlphttp exporterを使います。
  • Vector (Rust言語, Mozilla Public License 2.0)
    • ロギングエージェント。主にDatadogが開発しています(参考: README.md#What is Vector?)。設定ファイル自体にvector testでユニットテストを書けるように設計されているのが特徴で(参考: Unit tests)、Kubernetes向けに専用化されたkubernetes_logs sourceと、Loki向けのloki sinkを使います。
  • Grafana Loki (Go言語, AGPL-3.0-only)
    • 収集したログの転送先・蓄積先(一部ファイルはApache License 2.0の例外あり、参考: README.md#License)。主にGrafana Labsが開発しています。Prometheusと同じラベル(key-value)でログの集まり(ストリーム)を識別します。

ロギングエージェントの実装には、大きく分けて次の工程があります。

  • ログローテーションへの追従
  • CRIログの再構成
  • Podメタデータの付与
  • Grafana Lokiへの転送
  • チェックポイントの永続化

このうち最初の3つは第2章・第3章の内容と直接関わってきます。それぞれについて、Fluent Bit・OpenTelemetry Collector・Vectorがどういう考え方で実装しているかをソースコードを確認しながら見ていきます。続いて転送先であるGrafana Lokiへの送り方の違いを確認し、最後にDaemonSetの更新などでPodが入れ替わっても読み取り位置を引き継げるようにする、チェックポイントの永続化の仕組みも見ていきます。

6.2 ログローテーションへの追従

第2章2.4で見たとおり、kubeletは現在のログファイルをrenameしてから、CRIランタイムに元のパスへ新しいファイルを開き直させます。ログエージェントが単純にファイルパスだけをポーリングしていると、リネームの前後で「今開いているファイル」と「そのパスにある実際のファイル」がずれてしまい、ログを取りこぼす可能性があります。

Fluent Bitの場合

Fluent Bitのin_tailは、この問題をinode比較で解決しています。監視中のパスを定期的にstatし、記録しているinodeと食い違っていればローテーションされていると判断します。

/* Returns FLB_TRUE if a file has been rotated, otherwise FLB_FALSE */
int flb_tail_file_is_rotated(struct flb_tail_config *ctx,
                             struct flb_tail_file *file)

(参考: plugins/in_tail/tail_file.c#L2154-L2226、ローテーション後も元のファイルディスクリプタで読み切ってから新しいファイルを追加で監視する処理は同ファイルL2432-L2496)

パスのinodeが変わらないまま存在し続けるファイルは同一ファイルとして読み続け、リネーム後に元のパスに現れたinodeの異なるファイルは新規ファイルとして扱われます。「ファイルパスだけでは同一性を保証できない」というkubeletのローテーション方式に対応するための設計で、inodeをそのまま使う直接的なアプローチです。

OpenTelemetry Collectorの場合

filelog receiverの実体はpkg/stanza/fileconsumerパッケージです。ログローテーションへの追従方式はFluent Bitのinode比較とは異なり、ファイル冒頭1000バイト(デフォルト)のチェックサムをフィンガープリントとして使っています。

// Fingerprint is used to identify a file
// A file's fingerprint is the first N bytes of the file
type Fingerprint struct {
	firstBytes []byte
	key        string
}

(参考: pkg/stanza/fileconsumer/internal/fingerprint/fingerprint.go#L20-L29)

フィンガープリントが完全一致すれば同一ファイル、新しいフィンガープリントが古いフィンガープリントで始まっていれば「書き込みが続いている同じファイル」と判定します。

func (f Fingerprint) StartsWith(old *Fingerprint) bool {
	l0 := len(old.firstBytes)
	// ...
	return bytes.Equal(old.firstBytes[:l0], f.firstBytes[:l0])
}

(参考: 同ファイルL93-L113、ポーリングループとフィンガープリントの突き合わせはpkg/stanza/fileconsumer/file.go#L139-L256)

inodeはPOSIX固有の概念のため、Windowsでは使えません。Windowsでも共通の仕組みで動作するように、この方式になっているようです(参考: design.md#Potential failure to consume files when file rotation via move/create is used on Windows)。

Vectorの場合

Vectorも中身はフィンガープリント方式ですが、DevInode(デバイス番号+inode)とFirstLinesChecksum(先頭数行のCRC64チェックサム)の2方式を切り替え可能な設定として両方持っています。

#[derive(Debug, Clone)]
pub enum FingerprintStrategy {
    FirstLinesChecksum {
        ignored_header_bytes: usize,
        lines: usize,
    },
    DevInode,
}

(参考: lib/file-source-common/src/fingerprinter.rs#L46-L53)

そのうえで、Kubernetesのコンテナログを読むkubernetes_logs sourceはFirstLinesChecksumの方を明示的に選んでいます。コメントにその理由が書かれており、「Kubernetesのログファイルの形は決まっているので、専用に調整したフィンガープリンターを使う」としています。

// The shape of the log files is well-known in the Kubernetes
// environment, so we pick the a specially crafted fingerprinter
// for the log files.
fingerprinter: Fingerprinter::new(
    FingerprintStrategy::FirstLinesChecksum {
        // Max line length to expect during fingerprinting, see the
        // explanation above.
        ignored_header_bytes: 0,
        lines: fingerprint_lines,
    },
    resolved_max_line_bytes,
    true,
),

(参考: src/sources/kubernetes_logs/mod.rs#L885-L895、fingerprint_linesのデフォルトは1行分です。同ファイルL1125-L1127)

「専用に調整した」の中身は、FirstLinesChecksumという汎用の仕組みに渡している2つの値です。

  • ignored_header_bytes: 0: 読み飛ばすヘッダーが無いという指定です。CRIログの各行はヘッダーを持たず、いきなりタイムスタンプから始まるため、0で固定できます。
  • lines: fingerprint_lines(デフォルト1行): 指紋に使う行数です。CRIログは1行1エントリの形式だと分かっているため、1行だけで十分に識別できます。

実際の読み取りは、ファイルの先頭からlines回目(デフォルトでは1回目)の改行が見つかった時点で打ち切り、そこまでのバイト列をCRC64でハッシュ化したものをフィンガープリントとします。

for (pos, &c) in buf[..read].iter().enumerate() {
    if c == delim {
        if count <= 1 {
            total_read += pos + 1;
            break 'main;
        } else {
            count -= 1;
        }
    }
}

(参考: fingerprinter.rs#L247-L274)

さらに、1行目を読み込む最大サイズ(resolved_max_line_bytes)もデフォルトで32KBです(設定で変更可能)。CRIログの各行は必ずタイムスタンプから始まるため、たとえ1行目が32KBで打ち切られても、ファイルを識別する指紋としては十分な情報が確保できます。

このフィンガープリントは、ファイルパスとは別に、監視対象を管理するハッシュマップのキーとして使われます。ポーリングのたびに見つけたファイルのフィンガープリントを計算し、既存のキーと一致すれば同じファイル、一致しなければ新しいファイルとして扱います。OpenTelemetry CollectorのStartsWith(前方一致)のような特別な比較ロジックは無く、単純なハッシュマップの検索で済んでいます。改行を見つけた時点でハッシュが確定し、その後ファイルがどれだけ成長しても1行目の内容が変わらない限りフィンガープリントも変わらないためです。

if let Some(watcher) = fp_map.get_mut(&file_id) {
    // file fingerprint matches a watched file
    // ...
} else {
    // untracked file fingerprint
    self.watch_new_file(path, file_id, &mut fp_map, &checkpoints, false).await;
}

(参考: file_server.rs#L189-L234)

OpenTelemetry Collectorが「inodeのようなOS依存の概念は避ける」という一般方針のもと、ファイルの中身によらず固定の1000バイトという汎用的な値をフィンガープリントに使っているのに対し、Vectorは汎用のfile sourceではinode方式も選べるようにしたうえで、「Kubernetesのログ形式は決まっている」という前提が成り立つ場合に限り、行数・行長をCRI形式に合わせて決め打ったチェックサム方式を選んでいる、という違いがあります。

6.3 CRIログの再構成

第2章2.3で見たP(partial)/F(full)タグは、エージェント側で正しく結合しないと1つの長い行が複数行に分断されたまま扱われてしまいます。

Fluent Bitの場合

Fluent Bitはこれを専用のCマルチラインパーサーとして実装しており、_pフィールドがFになるまでlogフィールドの内容を連結し続けます。利用する側はmultiline.parser criを明示的に指定する必要があります。

#define FLB_ML_CRI_REGEX \
  "^(?<time>.+?) (?<stream>stdout|stderr) (?<_p>F|P) (?<log>.*)$"

(参考: src/multiline/flb_ml_parser_cri.c#L24-L74)

汎用のマルチラインパーサー機構にCRI用の定義を1つ追加する、という組み込み方です。

OpenTelemetry Collectorの場合

filelog receiverではtype: containerというoperatorを指定するだけで、ログの中身からDocker JSON・cri-o・containerdのどの形式かを自動判定します。

switch {
case dockerMatcher.MatchString(raw):
	return dockerFormat, nil
case crioMatcher.MatchString(raw):
	return crioFormat, nil
case containerdMatcher.MatchString(raw):
	return containerdFormat, nil
}

(参考: pkg/stanza/operator/parser/container/parser.go#L292-L311)

P(partial)/F(full)タグの再結合も、logtag == 'F'になるまで束ねるrecombineという内部トランスフォーマーが自動的に動くため、追加設定は不要です。

recombineSourceIdentifier = attrs.LogFilePath
recombineIsLastEntry      = "attributes.logtag == 'F'"

(参考: pkg/stanza/operator/parser/container/config.go#L23-L24)

CRIログを扱うための専用operatorとして、形式判定から再結合までを1つにまとめている点が、汎用パーサーにCRI向けの定義を追加するFluent Bitとの違いです。

Vectorの場合

kubernetes_logs sourceも、まずログの先頭1バイトだけを見て{ならDocker JSON形式、そうでなければCRI形式と判定します。一度判定すると、以降はその形式用のパーサーに固定されます。

self.state = if bytes.len() > 1 && bytes[0] == b'{' {
    ParserState::Docker(docker::Docker::new(self.log_namespace))
} else {
    ParserState::Cri(cri::Cri::new(self.log_namespace))
};

(参考: src/sources/kubernetes_logs/parser/mod.rs#L66-L70)

P/Fタグの判定と、実際の結合は別の2つのコンポーネントに分かれています。CRI用パーサーはPタグの行に汎用的な_partialフィールドを立てるだけで、

// MULTILINE_TAG
// If the MULTILINE_TAG is 'P' (partial), insert our generic `_partial` key.
if parsed_log.multiline_tag[0] == b'P' {
    self.log_namespace.insert_source_metadata(
        Config::NAME,
        log,
        Some(LegacyKey::Overwrite(path!(event::PARTIAL))),
        path!(event::PARTIAL),
        true,
    );
}

(参考: src/sources/kubernetes_logs/parser/cri.rs#L77-L89)

実際の結合は後段のpartial_events_mergerが、ファイルごとのバケツにメッセージを貯めてバイト列として連結する形で行います(肥大化防止の上限・切り詰め処理つき)。

(参考: src/sources/kubernetes_logs/partial_events_merger.rs#L28-L80)

OpenTelemetry Collectorが形式判定と再結合を1つのoperatorにまとめているのに対し、Vectorは「タグを見て印を付ける」パーサーと「印に応じて結合する」マージャーに役割を分けている点が対照的です。

6.4 Podメタデータの付与

/var/log/podsのディレクトリ名(<namespace>_<name>_<uid>)や、/var/log/containersのシンボリックリンク名(<pod名>_<namespace>_<コンテナ名>-<コンテナID>.log)からは、Podのnamespace・名前・コンテナ名を読み取れますが、ラベルやannotationはここには含まれません。

Fluent Bitの場合

filter_kubernetesは、まずこのファイル名を正規表現でパースし、その後kube-apiserverに問い合わせてPodのメタデータを取得します(設定でkubeletへの問い合わせに切り替えることもできますが、namespaceの情報はAPIサーバーからしか取得できません)。ログ行1件ごとに問い合わせるとAPIサーバーへの負荷が大きくなるため、namespace・Pod名・コンテナ名から作ったキャッシュキーで結果を再利用します。

(参考: ファイル名の正規表現はplugins/filter_kubernetes/kube_regex.h#L25、APIサーバーへの問い合わせはplugins/filter_kubernetes/kube_meta.c#L607-L637、キャッシュキーの構成は同ファイルL1910-L1995)

ファイル名パースとAPIサーバーへの問い合わせを1つのfilterプラグインの中にまとめて持たせる、フィルタチェーンの1段として組み込む設計です。

OpenTelemetry Collectorの場合

container operatorは、include_file_path: trueでログの実ファイルパスを取得できる状態にしておけば、デフォルト(add_metadata_from_filepath: true)でファイルパスからk8s.namespace.name/k8s.pod.name/k8s.container.name/k8s.pod.uidをその場で抽出します。Fluent Bitのfilter_kubernetesと違い、この時点ではAPIサーバーへの問い合わせが一切発生しません。

k8sMetadataMapping = map[string]string{
	"container_name": "k8s.container.name",
	"namespace":      "k8s.namespace.name",
	"pod_name":       "k8s.pod.name",
	"restart_count":  "k8s.container.restart_count",
	"uid":            "k8s.pod.uid",
}

(参考: pkg/stanza/operator/parser/container/parser.go#L51-L57、デフォルト値はconfig.go#L42)

このファイルパスからの抽出は、正規表現が/var/log/pods/<namespace>_<name>_<uid>/<コンテナ名>/<再起動回数>.logという第2章2.1で見た構成を前提にしています。/var/log/containers/側のシンボリックリンクを指定すると、パースに失敗してメタデータが一切付与されません。

ただしcontainer operator単体で取れるのはファイルパス由来の基本属性だけで、Podのラベルやannotationは含まれません。実運用ではラベルでログを絞り込みたい場面が多く、ラベルが無いと検索基盤側での利用に支障が出るため、k8sattributesprocessorを組み合わせるのがほぼ標準的な構成になります。こちらはFluent Bitのfilter_kubernetesと同じく、APIサーバーをSharedInformerでwatchしてローカルにPodを同期させる方式です。

informer := cache.NewSharedInformer(
	&cache.ListWatch{
		ListWithContextFunc:  informerListFuncWithSelectors(client, namespace, ls, fs),
		WatchFuncWithContext: informerWatchFuncWithSelectors(client, namespace, ls, fs),
	},
	// ...
)

(参考: processor/k8sattributesprocessor/internal/kube/informer.go#L54-L63、Podとの紐付けはk8s.pod.uid等のresource属性をキーに行います。pod_association.go#L20-L69)

まずファイルパスという軽量な情報源から基本属性を抽出し、APIサーバーへの問い合わせが要るリッチな情報は別のprocessorに任せる、という段階的な構成です。receiverとプロセッサーを分けてパイプラインを組み立てるOpenTelemetry Collectorらしい役割分担だと言えます。

Vectorの場合

kubernetes_logs sourceは、ファイルパスのパースとAPIサーバーからの情報付与を1つの処理の中で両方行います。まず/var/log/pods/<namespace>_<name>_<uid>/<コンテナ名>/...というパスをパースして基本情報を取り出し、

pub(super) fn parse_log_file_path(path: &str) -> Option<LogFileInfo<'_>> {
    let mut components = path.rsplit(std::path::MAIN_SEPARATOR);

    let _log_file_name = components.next()?;
    let container_name = components.next()?;
    let pod_dir = components.next()?;

    let mut pod_dir_components = pod_dir.rsplit(LOG_PATH_DELIMITER);

    let pod_uid = pod_dir_components.next()?;
    let pod_name = pod_dir_components.next()?;
    let pod_namespace = pod_dir_components.next()?;
    // ...
}

(参考: src/sources/kubernetes_logs/path_helpers.rs#L32-L52)

そのnamespace/Pod名をキーに、kube crateのreflector(APIサーバーをwatchしてローカルに同期させる、SharedInformerに相当する仕組み)が保持するPodのキャッシュを引きます。見つかればラベル・annotation・Pod spec/statusまで含めてフィールドを追加し、見つからなければ警告ログを出したうえでファイルパス由来の情報だけで処理を続行します。

pub fn annotate<'a>(&self, event: &mut Event, file: &'a str) -> Option<LogFileInfo<'a>> {
    let file_info = parse_log_file_path(file)?;
    let obj = ObjectRef::<Pod>::new(file_info.pod_name).within(file_info.pod_namespace);
    let resource = self.pods_state_reader.get(&obj);

    annotate_from_file_info(log, &self.fields_spec, &file_info, self.log_namespace);

    let Some(resource) = resource else {
        warn!(message = "Pod not found in API store, annotating from file path only.", ...);
        annotate_from_file_path(log, &self.fields_spec, &file_info, self.log_namespace);
        return Some(file_info);
    };
    // ラベル・annotation・Pod spec/statusまで付与
    // ...
}

(参考: src/sources/kubernetes_logs/pod_metadata_annotator.rs#L206-L238)

Fluent BitとOpenTelemetry Collectorがそれぞれ「ファイルパス解析」と「APIサーバー問い合わせ」を別々のプラグイン・processorとして組み合わせる設計なのに対し、Vectorは1つのkubernetes_logs sourceの中に両方を組み込み、APIサーバー側の情報がまだ無い場合はファイルパス由来の情報だけで処理を続ける、というフォールバックまで内蔵しています。

6.5 Grafana Lokiへの転送

収集したログの転送先には、ログ専用のデータベースであるGrafana Lokiがよく使われます。LokiはPrometheusと同じラベル(key-value)でログの集まり(ストリーム)を識別する設計です。

Fluent Bitの場合

out_lokiという出力プラグインがFluent Bitに標準で組み込まれています。label_keysで指定したフィールドをLokiのラベルとして送り、それ以外のフィールドはログ本文(JSON)としてそのまま送ります。filter_kubernetesが付与したkubernetes.namespace_name/pod_name/container_nameをラベルに使う設定は次のようになります。

[INPUT]
    Name             tail
    Path             /var/log/containers/*.log
    multiline.parser cri
    Tag              kube.*

[FILTER]
    Name   kubernetes
    Match  kube.*

[OUTPUT]
    Name       loki
    Match      kube.*
    Host       loki.example.com
    Port       3100
    label_keys $kubernetes['namespace_name'],$kubernetes['pod_name'],$kubernetes['container_name']

(参考: label_keysの定義はplugins/out_loki/loki.c#L2331-L2335、Loki側へのPOST先/loki/api/v1/pushはplugins/out_loki/loki.h#L32)

auto_kubernetes_labelsという設定もありますが、これはkubernetes.labels(Podに付けたラベル)だけをLokiのラベルにするオプションで、namespace/pod/container名はラベルになりません。namespace/pod/container名をラベルにしたい場合は、上記のようにlabel_keysで明示的に指定する必要があります(参考: plugins/out_loki/loki.c#L976-L983)。

実際にkindクラスタ上でFluent Bit(v5.1.2)とLoki(v3.7.8)を動かし、上記の設定でログを転送してみました。namespace・Pod名・コンテナ名でログを検索できることが確認できます。

$ curl -s -G "http://localhost:3100/loki/api/v1/query_range" \
    --data-urlencode 'query={kubernetes_pod_name="demo-app"}'
{
  "stream": {
    "kubernetes_container_name": "demo-app",
    "kubernetes_namespace_name": "default",
    "kubernetes_pod_name": "demo-app"
  },
  "values": [
    ["...", "{\"time\":\"...\",\"stream\":\"stdout\",\"_p\":\"F\",\"log\":\"hello from demo-app to loki\",\"kubernetes\":{...}}"],
    ["...", "{\"time\":\"...\",\"stream\":\"stdout\",\"_p\":\"F\",\"log\":\"second line\",\"kubernetes\":{...}}"],
    ["...", "{\"time\":\"...\",\"stream\":\"stdout\",\"_p\":\"F\",\"log\":\"done\",\"kubernetes\":{...}}"]
  ]
}

Lokiのラベル(kubernetes_pod_name等)が、$kubernetes['pod_name']のように[や'を含む指定から、Loki側の制約に合わせて_区切りの名前に変換されている点にも注目です。ログ本文の方には、第2章2.3で見たCRIログのタグ(_p)や、filter_kubernetesが付与したkubernetesオブジェクトがJSONのままそっくり残っています。

OpenTelemetry Collectorの場合

Fluent Bitにはout_lokiという専用の出力プラグインがありましたが、OpenTelemetry Collectorには専用のLoki出力プラグインが無く、代わりにLokiが備えるOTLP Logsのネイティブな取り込みエンドポイント(/otlp/v1/logs)へ、汎用のotlphttpエクスポーターで送ります。

receivers:
  filelog:
    include:
      - /var/log/pods/*/*/*.log
    include_file_path: true
    operators:
      - type: container

exporters:
  otlphttp:
    logs_endpoint: http://loki.example.com:3100/otlp/v1/logs
    tls:
      insecure: true

service:
  pipelines:
    logs:
      receivers: [filelog]
      exporters: [otlphttp]

(参考: logs_endpointの定義はexporter/otlphttpexporter/config.go#L60、Loki側の受け口はpkg/loki/modules.go#L415)

実際にkindクラスタ上でこの構成(パスを/var/log/pods/*/*/*.logに修正した版)を試すと、k8s.namespace.name等のresource属性がそのままLokiのラベルとして使えました。

$ curl -s "http://localhost:3100/loki/api/v1/labels"
{"status":"success","data":["k8s_container_name","k8s_namespace_name","k8s_pod_name","service_name"]}

Fluent Bitのout_lokiではlabel_keysで送信側が明示的にラベルを選ぶ必要がありましたが、Lokiはk8s.namespace.name/k8s.pod.name/k8s.container.nameなどをOTLP受信時にラベルへ昇格させるデフォルト設定をあらかじめ持っています。

cfg.DefaultOTLPResourceAttributesAsIndexLabels = []string{
	"service.name",
	// ...
	"k8s.namespace.name",
	"k8s.pod.name",
	"k8s.container.name",
	// ...
}

(参考: pkg/loghttp/push/otlplabels/config.go#L59-L77)

そのため、OpenTelemetry Collector側では送信側のラベル設定をいじらなくても、ファイルパスから正しくPodメタデータを抽出できてさえいれば、Loki側のデフォルトだけでnamespace・Pod名・コンテナ名によるログ検索ができるようになりました。送信側で個々にラベルを指定するFluent Bitのlabel_keys方式に対し、「意味づけされたリソース属性さえ正しく付ければ、ラベルへの昇格は受け手(Loki)側のデフォルト設定に任せる」という役割分担になっています。

Vectorの場合

Vectorのloki sinkは、OTLPではなくFluent Bitと同じくLokiのPush API(/loki/api/v1/push)へ直接送ります。

fn default_loki_path() -> String {
    "/loki/api/v1/push".to_string()
}

(参考: src/sinks/loki/config.rs#L25-L27)

ラベルもFluent Bitのlabel_keysと同様に、送信側でlabelsとして明示的に指定する必要があります。こちらはテンプレート形式で、キー名の末尾に*を付けることでオブジェクトを複数ラベルへ展開する機能も持っています。

sinks:
  loki:
    type: loki
    endpoint: http://loki.example.com:3100
    labels:
      pod_namespace: "{{ kubernetes.pod_namespace }}"
      pod_name: "{{ kubernetes.pod_name }}"
      container_name: "{{ kubernetes.container_name }}"
      "pod_labels_*": "{{ kubernetes.pod_labels }}"

(参考: src/sinks/loki/config.rs#L58-L73)

OpenTelemetry Collectorが「OTLPのresource attributesとしてPodメタデータを渡し、ラベルへの昇格はLoki側のデフォルト設定に任せる」方向なのに対し、VectorはFluent Bitと同じく「送信側で明示的にラベルを指定する」方式を取っています。LokiのPush APIを使う以上、OTLP側の自動昇格の恩恵は受けられません。

6.6 チェックポイントの永続化

DaemonSetをローリングアップデートすると、各ノード上の古いPodが削除され、同じノードに新しいPodが作られます。このとき、エージェントが「どのファイルのどこまで読んだか」という位置情報(チェックポイント)を引き継げないと、再起動のたびにログを読み落としたり、二重に送ったりすることになります。3つのエージェントとも、この位置情報をノードのローカルディスクに書き出しておき、新しいPodでも同じパスをhostPath等でマウントして読み直す、という発想は共通していますが、デフォルトの挙動と実装方法が異なります。

Fluent Bitの場合

in_tailのdbパラメータ(デフォルトはNULL)でSQLiteファイルのパスを指定すると、in_tail_filesテーブルにinode・offset等を記録するようになります。

"CREATE TABLE IF NOT EXISTS in_tail_files ("
"  id      INTEGER PRIMARY KEY,"
"  name    TEXT NOT NULL,"
"  offset  INTEGER,"
"  inode   INTEGER,"
"  created INTEGER,"
"  rotated INTEGER DEFAULT 0,"
"  offset_marker INTEGER DEFAULT 0,"
"  offset_marker_size INTEGER DEFAULT 0"
");"

(参考: plugins/in_tail/tail_sql.h#L30-L39、dbパラメータのデフォルト値はplugins/in_tail/tail.c#L764)

新しいPodのin_tailは、監視対象ファイルのinodeをこのテーブルから探し、見つかったoffsetから読み取りを再開します。dbを設定しなければ状態は常にメモリ上だけに存在し、Pod再作成のたびに失われます。

OpenTelemetry Collectorの場合

filelog receiver自体はチェックポイントの永続化機構を持たず、外部のstorage extension(storage:設定)を挿した場合にだけ、挿されたPersister経由で読み取り位置をロードします。

if persister != nil {
	m.persister = persister
	offsets, err := checkpoint.Load(ctx, m.persister, m.set.Logger)
	// ...
	if len(offsets) > 0 {
		m.set.Logger.Info("Resuming from previously known offset(s). 'start_at' setting is not applicable.")
		m.readerFactory.FromBeginning = true
		m.tracker.LoadMetadata(offsets)
	}
} else if m.pollsToArchive > 0 {
	m.set.Logger.Error("archiving is not supported in memory, please use a storage extension")
}

(参考: pkg/stanza/fileconsumer/file.go#L60-L82)

保存される内容は、Fluent Bitのinodeに相当するFingerprint(ファイル内容のチェックサム)とOffsetだけではありません。CRIのP/Fタグ再結合(recombine)やPodメタデータ抽出の途中状態まで含めた、かなり多めの情報を1ファイル分のメタデータとして持っています。

type Metadata struct {
	Fingerprint      *fingerprint.Fingerprint
	Offset           int64
	RecordNum        int64
	FileAttributes   map[string]any
	HeaderFinalized  bool
	FlushState       flush.State
	TokenLenState    tokenlen.State
	FileType         string
	TruncateSkipping bool
	// ...
}

(参考: pkg/stanza/fileconsumer/internal/reader/reader.go#L31-L50)

file_storage extensionを使い、デフォルトでは/var/lib/otelcol/file_storage配下にBoltDB形式のファイルとして永続化します。

func getDefaultDirectory() string {
	return "/var/lib/otelcol/file_storage"
}

(参考: extension/storage/filestorage/default_others.go)

3つの中で唯一、チェックポイントの永続化そのものがfilelog receiverから切り離され、別のextensionとして差し替え可能な設計になっている点が特徴です。storageを設定し忘れると、気づかないままメモリ上だけの状態になります。この仕組み自体はfilelog専用ではなく、cloudwatchreceiver・sqlqueryreceiverやexporter側の永続化キューなど、他の多くのコンポーネントからも共通で使われています。

CRIのP/Fタグ再結合を行うrecombineは、このチェックポイントの対象外です。recombineのStart関数はPersisterを受け取っても使っておらず、結合途中のバッファは再起動のたびに失われます。

func (t *Transformer) Start(_ operator.Persister) error {

(参考: pkg/stanza/operator/transformer/recombine/transformer.go#L56)

P/Fタグがまたがる1行の途中でPodが再作成されると、その行が欠落する可能性があります。

Vectorの場合

kubernetes_logs sourceはdata_dir(未指定ならグローバルのdata_dir、デフォルト/var/lib/vector/)配下に、フィンガープリントごとの読み取り位置をcheckpoints.jsonというJSONファイルとして書き出します。

#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd)]
struct Checkpoint {
    fingerprint: FileFingerprint,
    position: FilePosition,
    modified: DateTime<Utc>,
}

(参考: lib/file-source-common/src/checkpointer.rs#L20-L40、data_dirのデフォルトはlib/vector-core/src/lib.rs#L59-L61)

Fluent BitやOpenTelemetry Collectorと異なり、Vectorは永続化先のパスがグローバル設定として最初からデフォルト値を持っているため、追加のextensionや明示的なパス指定なしでもチェックポイントが残ります。ただし、そのパス自体をhostPathなどで永続化しておかなければ、Pod再作成時に引き継がれないのは3つとも同じです。

記録しているファイル識別子は、6.2節で見たログローテーションへの追従方式がそのままチェックポイントのキーにも使われています。Fluent Bitはinode+offset、OpenTelemetry CollectorとVectorはinodeの代わりにFingerprint(ファイル内容のチェックサム)+offsetです。そのうえでOpenTelemetry Collectorだけは、CRIタグの再結合やPodメタデータ抽出といった「ファイルの途中状態」まで一緒に保存しており、単なる読み取り位置以上の情報を引き継ぐ設計になっています。

第7章: kubectl logsとsternでログを確認する

ここまではログの保存・収集の仕組みを見てきましたが、日常のトラブルシュートでは、収集基盤を構築する前にまずkubectl logsやサードパーティツールでその場のログを素早く確認することが多いと思います。この章では、その際に押さえておくと便利なTipsを紹介します。

7.1 kubectl logsのTips

公式のkubectlチートシートには、次のようなログ確認コマンドが載っています。

kubectl logs my-pod                                 # Podのログをダンプします(stdout)
kubectl logs -l name=myLabel                        # name=myLabelラベルの持つPodのログをダンプします(stdout)
kubectl logs my-pod --previous                      # 以前に存在したコンテナのPodログをダンプします(stdout)
kubectl logs my-pod -c my-container                 # 複数コンテナがあるPodで、特定のコンテナのログをダンプします(stdout)
kubectl logs -f my-pod                              # Podのログをストリームで確認します(stdout)
kubectl logs -f -l name=myLabel --all-containers    # name=myLabelラベルを持つすべてのコンテナのログをストリームで確認します(stdout)

(参考: kubectlチートシート#Interacting with running Pods)

特に押さえておきたいのは次の点です。

  • Pod名を直接指定する代わりに-lでラベルセレクタを指定できます。Deploymentが生成するハッシュ付きの名前を正確に覚えていなくても、ラベルで絞り込めます。
  • --previousは、ローテーションされた過去のログファイルを読むものではありません。PodのContainerStatus.LastTerminationStateという、直前に終了したコンテナのインスタンスを1つだけ保持するAPIフィールドを参照しており、--previousはそのコンテナIDに対応するログファイルを読みます(参考: kubelet_pods.go#L1543-L1547、previous指定時の解決ロジックは同ファイルL1564-L1568)。ローテーション済みファイル(.log.<timestamp>や.gz)はkubectl logsでは読めない、という第2章2.4の制約とは別の話です。
  • -cは複数コンテナのPodでは必須です。省略すると、単一コンテナのPodでない限りエラーになります。

7.2 複数Pod・複数コンテナをまとめて見る: stern

kubectl logs -fは基本的に1回の呼び出しで指定したPod(またはラベルで絞り込んだ複数Pod)のログしか見られず、複数Podをまとめて追った場合もPod名・コンテナ名の見分けが付きにくいという難点があります。sternは、正規表現や<リソース種別>/<名前>でPodを横断的に指定し、複数Pod・複数コンテナのログを色分けしてまとめてtailできるツールです。

$ stern "web-.*"                 # 正規表現でマッチする複数Podをまとめてtail
$ stern deployment/my-dep        # Deploymentに属する全Podをまとめてtail
$ stern my-pod -c my-container   # コンテナ名で絞り込み(正規表現)

ソースコードを確認すると、Podの追跡はAPIサーバーに対するwatch.Added/watch.Modified/watch.Deletedイベントの監視として実装されています。マッチするPodが新しく作られれば自動的にtail対象に追加され、削除されれば自動的に対象から外れます。

func WatchTargets(ctx context.Context, i v1.PodInterface, labelSelector labels.Selector, fieldSelector fields.Selector, filter *targetFilter) (added, deleted chan *Target, err error)

(参考: stern/watch.go#L34)

実務で使う機会が多そうなオプションを挙げます。

オプション 役割
--since 相対時間(10m等)より新しいログだけに絞る
--tail 末尾から指定行数だけ表示(デフォルトは全件)
--timestamps タイムスタンプを付与(short/defaultから選択)
--output raw(本文のみ、jq等に流しやすい)・json・extjson等の出力形式を選択
--include/--exclude ログ本文を正規表現でフィルタ
--no-follow 既存ログを出力したら終了(-fを使わない一括取得)
-A/--all-namespaces 全Namespace横断でtail

(参考: stern README#cli-flags)

kubectl logs --previousに相当するオプションはsternのソースコード上には見当たりませんでした。クラッシュしたコンテナの直前のログを見たい場合は、引き続きkubectl logs --previousを使う必要があります。

おわりに

以前、CloudNative Days Tokyo 2019で登壇した際に調べてKubernetes Logging入門という資料を作成したのですが、久しぶりに見返して記事としてまとめ直しました。概要をつかむだけなら当時の資料でも十分ですが、細かい部分は変わっているところもあったため、アップデートしています。

調べていて一番驚いたのはロギングエージェントです。各プロダクトで実装言語が異なるだけでなく、実装の考え方自体がかなり違っていました。小規模な環境であれば好みで選んで問題ないと思いますが、ログ量が多い環境で使う場合は、事前に性能試験をして確認しておいた方がいいと思います。

不特定多数に向けて技術的な情報を発信するときは、SIer時代に働いていた頃を基準として想定するようにしています。その基準で考えると、OpenTelemetry Collectorは難しく感じる人も多いんじゃないかなと思いました。プラグインの種類が豊富な分色々なことができますが、あるユースケースを実現しようとしたときに、プラグイン毎の機能を把握して組み立てるところに難しさを感じそうです。

例えば、チェックポイントを永続化するためにfile_storage extensionを使う、といった設定は見落としやすそうに思います。要件を整理したうえできちんとテストしておいた方がいいと思います。

初めて触るのであれば、Vectorのように入力と出力を1つずつ選ぶだけで済む設計の方が、とっつきやすいと思います。

  • 入力: kubernetes_logs source
  • 出力: loki sink

Kubernetesのログの仕組みは一度覚えると長く使える知識なので、一度勉強してみるのはおすすめです (そもそもこれを理解していないと、トラブルシューティングもままならないという面もあります)。

参考

本文で登場する順に並べています。

参考リンク

4
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?