Kubernetesのログ入門
はじめに
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)
:::note warn
上記の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)
:::note info
このタグは、アプリケーションが改行区切りで書いた複数行のログ(スタックトレース等)を結合するためのものではありません。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ファイル」を意味することが、実際の挙動としても確認できました。
:::note warn
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
- 次の部分がこれです。
```go
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)という部分がこれです。
```go
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)。
:::note warn
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)
:::note warn
このファイルパスからの抽出は、正規表現が/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という出力プラグインがF
4 likes
Mentions
VictoriaLogs 与 Loki、Elasticsearch、OpenSearch、SigNoz、ClickStack 的日志写入性能对比 > This blog post is also available in English: [VictoriaLogs vs Loki, Elasticsearch, OpenSearch, SigNoz and ClickStack on structured log ingestion]( victoriametrics.com) 在 VictoriaMetrics 内部,我们会持续地对自己的产品进行性能测试。这能帮助我们保持竞争力,让每一个版本都变得更好。随着越来越多的团队开始使用 VictoriaLogs ,也有越来越多的人问我们:它适合哪些场景?切换过来能获得什么?因此,我们把基准测试作为一个[公开 GitHub 仓库]( VictoriaMetrics/logs-benchmark)发布出来,任何人都可以运行它来复现我们的结果,我们也会继续以开源的方式维护它。 这不是一篇劝你在迁移到 VictoriaLogs 的广告。相反,如果你在采集结构化日志(无论来自 Kubernetes 还是其他地方),本文应该能帮你选择合适的工具,并了解适合你的权衡取舍。 对不同系统进行比较的基准测试总是充满争议,因为每个系统擅长的场景各不相同。本文只测试一个场景:写入结构化日志,即每一行都带有命名字段的日志,Kubernetes 上的许多服务都是这样输出日志的。每个系统都运行在一台相同规格的机器上。查询性能、集群模式和对象存储会在后续文章中介绍。 仅凭写入速度无法决定该选哪个系统,所以本文也会深入它们的内部实现: - 为什么 Loki 写入速度最快,但一个简单的 Dashboard 查询却要花上几分钟? - 为什么 SigNoz 和 ClickStack 落后于 VictoriaLogs ,尽管三者都以列式方式存储日志? - 为什么 Elasticsearch 和 OpenSearch 需要那么多磁盘空间? 这些问题的答案能帮助你规划硬件和成本,避免在日志量增长时遇到意外。 测试与环境 我们测试了截至 2026 年 10 月 6 个系统的最新版本,配置尽量接近默认值: - VictoriaLogs [v1.53.0]( github.com/VictoriaMetrics/VictoriaLogs/releases/…) - Loki [3.7.8]( github.com/grafana/loki/releases/tag/v3.7.8) - Elasticsearch [9.5.5]( github.com/elastic/elasticsearch/releases/tag/v9.…) - OpenSearch [3.9.0]( github.com/opensearch-project/OpenSearch/releases…) - SigNoz [v0.145.0]( github.com/SigNoz/signoz/releases/tag/v0.145.0) - ClickStack [2.39.1]( github.com/hyperdxio/hyperdx/releases/tag/%40hype…) 我们调整了少量配置,例如 Loki 的写入限制,以便每个系统都能在单节点上达到其设计上限。VictoriaLogs 不需要任何调整,因为它的设计目标就是开箱即用地提供最佳性能([文档]( docs.victoriametrics.com))。[仓库]( github.com/VictoriaMetrics/logs-benchmark/tree/ma…)中说明了每一项改动。 为了让测试保持简单,每个系统都以单节点模式运行在独立的 Kubernetes 节点上( GCP 的 n2d-standard-8 ,8 个 vCPU 、32 GB 内存)。SigNoz 和 ClickStack 将日志存储在 ClickHouse 中,而 ClickHouse 本身无法直接接收日志采集器发送的日志。因此每个技术栈都会在 ClickHouse 所在的节点上运行自己的 Collector ,这些 Collector 基于 OpenTelemetry Collector 构建。  我们同时向每个系统发送相同的日志。写入速率从 10k 行/秒开始,每 10 分钟增加 10k ,最高到 180k 行/秒。一个系统的最大写入速率,是指它在一个完整的 10 分钟阶梯内平均能成功存储的最高速率。如果系统发生崩溃,我们只统计它首次崩溃之前的阶梯,因为崩溃本身就是它的极限。  我们统计的是每个系统实际存储的行数,而不是它接收的行数。例如,SigNoz 和 ClickStack 在 Collector 收到日志后就会立即返回响应,因此 Collector 一旦崩溃,就会丢失已经被接收的日志。Loki 是个例外:它没有低成本的方法统计已存储的行数,但它会在返回响应之前把每一行写入 WAL ( Write-Ahead Log ,预写日志),所以对于 Loki ,我们统计的是接收的行数。 写入速率只是故事的一半。为了展示它与查询速度之间的关系,我们以 35k 行/秒的速率向 VictoriaLogs 、Elasticsearch 和 Loki 写入相同的日志,持续 4 小时,然后在冷缓存状态下对几条常见查询进行计时。我们会在后续文章中深入讨论查询。 每个系统接收的负载 日志可以是任何东西,从纯文本到包含数百个字段的 Wide Events (宽事件)。本测试选取了中间情况:每行包含 30 到 40 个字段的结构化日志,这在 Kubernetes 中很典型。其中约 20 到 30 个字段由应用写入,日志采集器再额外添加约 10 个字段,例如命名空间、节点和标签。 设想一个有 125 个节点的 Kubernetes 集群,每个节点运行 40 个属于不同应用的 Pod 。每个 Pod 的日志构成一个日志流,因此这个集群共有 5,000 个日志流。这是一个中等规模的集群,与我们在 VictoriaLogs 用户中见到的许多集群类似。  我们想看看每个系统基于自身设计能走多远。因此,每个系统都以它偏好的格式接收日志。例如,Loki 通常从 Grafana Alloy 接收 protobuf 格式。我们没有向所有系统发送同一种格式(如 OTLP )。如果那样做,我们测量的就不仅是各系统存储日志的能力,还包括它们转换该格式的效率。 在实际场景中,日志通常以 JSON 形式输出到 stdout 。因此日志采集器需要对其进行转换,而这同样会消耗 CPU 。如果有足够多的读者感兴趣,我们也可以用一种通用格式(如 JSON 或 OTLP )重新运行测试。 测试结果 主写入测试我们运行了 3 次,下面的结果取 3 次运行的中位数。完整的数据表格见[仓库]( github.com/VictoriaMetrics/logs-benchmark/tree/ma…)。 对于 SigNoz 和 ClickStack ,CPU 和内存包含了 Collector 、ClickHouse 及其辅助服务。 本文中提到的少数额外测试(例如查询测试)是仓库之外的一次性运行。 最大写入速率(行/秒)  - Loki 写入最多,达到 139k 行/秒。 - VictoriaLogs 位居第二,为 120k 。 - Elasticsearch 、OpenSearch 和 ClickStack 在 28k 到 48k 之间。 - SigNoz 达到 10k 行/秒,并在 20k 时崩溃。 一行日志约为 930 字节的 JSON ,因此 120k 行/秒约等于 106 MiB/s ,即每天约 9 TiB 的原始日志。 每个数字的背后都伴随着在查询速度、成本或日志类型上的权衡。我们会在[为什么会是这样的结果](#为什么会是这样的结果)中进行解释。 CPU 使用量(核) 各写入速率下的平均 CPU 核数。每条曲线终止于该系统的最大写入速率。  - 在 10k 行/秒(所有系统都能承受的唯一速率)下,VictoriaLogs 和 Loki 使用 0.4 核,ClickStack 0.9 核,Elasticsearch 1.0 核,OpenSearch 1.5 核,SigNoz 4.2 核。 - 差距随速率增长而扩大。在 40k 行/秒时,VictoriaLogs 和 Loki 使用 1.6 核,ClickStack 4.0 核,Elasticsearch 5.2 核。 - 在 120k 行/秒时,Loki 使用 6.1 核,VictoriaLogs 使用 7.2 核。 在本次测试中,CPU 是大多数系统的瓶颈。 内存使用量( GiB ) 各写入速率下的峰值内存,不含操作系统的 Page Cache 。每条曲线终止于该系统能跟上的最后一个速率。  - VictoriaLogs 在所有速率下内存占用都最少,在 40k 行/秒时比 Loki 少 14 倍。 - Elasticsearch 和 OpenSearch 无论速率如何都使用约 17 GiB ,因为它们在启动时会预留固定大小的堆内存。默认情况下,Elasticsearch 会将堆大小设为机器内存的一半([源码]( github.com/elastic/elasticsearch/blob/v9.5.5/dist…))。OpenSearch 不会自动设置堆大小,所以我们给它配置了相同的大小。 - ClickStack 在 10k 行/秒时使用 1.3 GiB ,在 40k 行/秒时使用 2.2 GiB 。 - SigNoz 在 10k 行/秒时使用 3.1 GiB ,这是它在图表中唯一的数据点。 内存决定了机器需要多大、成本多高,以及还剩多少内存可用于缓存和查询。 磁盘空间 下图展示了每个系统存储一百万行日志所需的磁盘空间。作为参考,一百万行日志以原始 JSON 形式约占 887 MiB 。SigNoz 标为 N/A ,因为它的 Collector 反复崩溃,存储的行数太少,且多次运行之间的数据波动太大,无法进行比较。  - VictoriaLogs 所需磁盘空间最少,其次是 ClickStack 和 Loki。 - Elasticsearch 所需空间几乎是 VictoriaLogs 的 5 倍,OpenSearch 则是 10 倍。 日志通常会保留数周,因此磁盘空间增长很快。例如,以 50k 行/秒的速率保存 30 天的日志,VictoriaLogs 约需 7 TiB ,Loki 约需 12 TiB ,Elasticsearch 约需 33 TiB 。 磁盘写入量 下表展示了每个系统每存储一行日志向磁盘写入的字节数。这个数值会大于最终的磁盘占用,因为每个系统都会不时重写数据,以保持数据紧凑并便于搜索。N/A 表示该系统无法跟上对应的速率。 | 系统 | 10k | 20k | 40k | 80k | 120k | 150k | | --- | --- | --- | --- | --- | --- | --- | | Loki (protobuf) | 1,108 | 1,234 | 1,411 | 1,251 | 1,210 | N/A | | VictoriaLogs | 323 | 284 | 226 | 223 | 163 | N/A | | Elasticsearch | 1,393 | 1,815 | 1,917 | N/A | N/A | N/A | | OpenSearch | 2,295 | 3,197 | N/A | N/A | N/A | N/A | | SigNoz | 1,420 | N/A | N/A | N/A | N/A | N/A | | ClickStack | 414 | 461 | 572 | N/A | N/A | N/A | VictoriaLogs 每行写入磁盘的数据量同样最少,而且在更高速率下写得更少,因为一次写入更多行时压缩效果更好。Loki 每行的写入量是它的 3 到 7 倍,具体取决于速率。 这一点很重要,因为每块磁盘每秒能写入的字节数都有上限,而更快的磁盘也更贵。在我们的测试中,使用的是 GCP 上 500 GiB 的 pd-balanced 磁盘,最高写入速度为 280 MiB/s ,所以没有任何系统在耗尽 CPU 之前触及这个上限。尽管如此,VictoriaLogs 能让你使用更便宜的磁盘;在自有硬件上,它对 SSD 的磨损也要慢得多。 为什么会是这样的结果 Loki Loki 的高写入速率让我们感到意外,但它的设计可以解释这一点。Loki 的设计目标是存储大量日志,之后只在较短的时间范围内读取少数几个日志流。因此它只为流标签(这里是节点和应用)建立索引,并原样存储每一行日志。相比之下,VictoriaLogs 会把每一行拆分为字段,并分别存储每个字段,从而让后续的查询更快。在 120k 行/秒时,这会比 Loki 多消耗约 18% 的 CPU 。 向 Loki 发送日志的方式也很重要。Grafana Alloy 和 Vector 默认发送 protobuf ,而 Fluent Bit 、Fluentd 和 Logstash 发送 JSON 。在另一次单独的测试中,使用 JSON 时 Loki 只达到约 99k 行/秒,低于 VictoriaLogs ,因为处理 JSON 比处理 protobuf 消耗的 CPU 多得多。所以如果你使用 Loki ,建议优先选择 Alloy 或 Vector 。 Loki 的设计有 3 个代价。第一个代价是内存。Loki 会把每个流最新的日志行保存在内存中,以便将它们一起压缩。这意味着它的内存会随着流的数量增长。集群越大,流就越多;任何集群在部署期间流也会增多,因为每个新 Pod 都会产生一个新的流。为了观察这会占用多少内存,我们还分别用 10,000 和 15,000 个流运行了测试。  在 15,000 个流时,Loki 在 60k 行/秒时因 OOM 被杀死。而 VictoriaLogs 在流数量增加到 3 倍的情况下,仅多用了 0.2 GiB 内存。更多的内存意味着更大的机器,也意味着可用于缓存查询数据的内存更少。 第二个代价是磁盘。Loki 把日志行当作纯文本进行压缩。它不像 VictoriaLogs 和 ClickHouse 那样利用行内的字段来获得更好的压缩效果,因此它所需的磁盘空间是 VictoriaLogs 的 1.8 倍。 第三个也是最重要的代价是查询时间。Loki 只能通过标签来缩小查询范围。除此之外的任何条件,比如一个单词或一个字段值,都需要对匹配流中的每一行进行解压和扫描。在相同的日志上,前文提到的查询耗时如下。 | 查询问题 | 时间范围 | VictoriaLogs (秒) | Loki (秒) | | --- | --- | --- | --- | | 统计某个应用的错误数 | 1 小时 | 0.06 | 3.5 | | 统计每个应用的错误数 | 1 小时 | 0.59 | 171 | | 包含某个常见词的最新 100 行 | 1 小时 | 2.5 | 32 | | 每个应用的 p99 延迟 | 1 小时 | 1.9 | 302 | | 前 10 个请求路径 | 1 小时 | 8.8 | 被拒绝,超过 500 个 Series | 这些都是排查故障、构建 Dashboard 和告警时的日常问题。除第一条外,Loki 在所有查询上都要花费半分钟甚至更久,或者直接拒绝执行。第一条查询只通过标签读取一个应用的数据,即便如此,它也比 VictoriaLogs 慢了近 60 倍。 Loki 正在增加更快搜索字段的方法,例如 Bloom Filter (布隆过滤器),但这些功能仍处于实验阶段,默认关闭,并且在单节点上不可用,所以我们没有启用它们。 这些缓慢的查询并不意味着 Loki 总体上是一个糟糕的日志方案。它的设计目标是把日志低成本地保存在对象存储中,并在需要执行大型查询时横向扩展到许多查询工作节点上。在我们的单台机器上,统计一小时内每个应用的错误数花了 171 秒,大约使用了 8 核中的一半。粗略估算,若要在约 2 秒内得到结果,同样的扫描大约需要 300 个核。所以,如果你想把大量日志低成本地存储在对象存储中,并且要么只读取其中一小部分,要么愿意为大量 CPU 核付费以实现快速搜索,那么 Loki 适合你。 如果这超出了你愿意承担的成本,而你仍然需要 Dashboard 和搜索在几秒内返回结果,请继续往下读。 Elasticsearch 和 OpenSearch OpenSearch 是 Elasticsearch 的一个 Fork ,所以本节主要介绍 Elasticsearch ,最后再说明 OpenSearch 的不同之处。 Loki 几乎不建索引,并在查询时为此付出代价。Elasticsearch 则恰恰相反。它以 3 种形式存储每一行日志: - 该行的原始 JSON。 - 倒排索引,一个将每个单词和值映射到包含它们的日志行的字典,因此搜索可以直接定位到匹配的行。 - 再以列的形式存储一遍字段( doc values ),用于排序和聚合。  这一切都让写入变得昂贵。在 40k 行/秒时,Elasticsearch 使用了 5.2 核,而 VictoriaLogs 和 Loki 只用了 1.6 核;它在约 48k 行/秒时耗尽了 CPU 。瓶颈在于 CPU ,而不是磁盘。Elasticsearch 会在返回响应前把每个请求写入磁盘,但我们在另一次单独测试中关闭了这一行为,写入速率几乎没有变化。 我们的测试使用了 [LogsDB]( elastic.co),这是 Elasticsearch 针对日志推出的更新、更紧凑的索引模式。但即便如此,Elasticsearch 使用的磁盘空间仍是 Loki 的 2.6 倍,几乎是 VictoriaLogs 的 5 倍。 作为回报,Elasticsearch 查找数据的速度比 Loki 快得多。在 Loki 需要几分钟的查询上,Elasticsearch 只需几秒甚至更短。下面是它与 VictoriaLogs 在几类常见查询上的对比。 | 查询问题 | 时间范围 | VictoriaLogs (秒) | Elasticsearch (秒) | | --- | --- | --- | --- | | 统计包含某个常见词的行数 | 1 小时 | 6.5 | 0.10 | | 统计每个应用的错误数 | 1 小时 | 0.59 | 0.46 | | 每个应用的 p99 延迟 | 1 小时 | 1.9 | 3.9 | | 前 10 个请求路径 | 1 小时 | 8.8 | 14.7 | | 统计包含某个双词短语的行数 | 1 小时 | 4.4 | 175 | Elasticsearch 查找单个词或值几乎是瞬间完成的,在简单计数(例如统计每个应用的错误数)上也更快。而一个短语,比如 "connection timeout",则花了它大约 3 分钟。 为了节省磁盘,它针对日志的默认配置不会存储每个单词在行中的位置,因此它必须重新读取日志行,才能判断两个词是否相邻。VictoriaLogs 在这类查询上更快,在百分位数和 Top 值查询上也更快。 Elasticsearch 之所以流行,是因为它能快速搜索任意字段。人们对它的抱怨通常集中在两点: - 成本。 为每个字段建立索引会消耗大量 CPU 、内存和磁盘。 - 运维。 运维大型集群需要投入大量精力。 Uber 同时遇到了这两个问题。索引成本高昂,而且 Uber 在每个区域运行了 20 多个集群,以确保一个集群故障不会影响全局([Uber]( uber.com))。Cloudflare 则不得不对部分日志进行采样,因为全部存储需要数百 TB 的空间([Cloudflare]( blog.cloudflare.com))。两家公司都把部分最大规模的日志负载迁移到了 ClickHouse 。 OpenSearch 的写入量略高于 Elasticsearch 的一半( 28k 对 48k 行/秒),磁盘占用约为后者的两倍。它与 Elasticsearch 在两项默认设置上有所不同: - 它没有类似 LogsDB 的模式,因此对每行原始 JSON 的压缩没那么紧凑([OpenSearch 文档]( docs.opensearch.org)),也不会预先排序。 - 它没有内置的日志模板,因此大多数字符串字段会被索引两次:一次按分词,一次按精确值。 OpenSearch 与 Elasticsearch 共享相同的内核,因此通过更强的压缩、排序以及完整的日志模板,你很可能可以弥补大部分差距。我们刻意让它保持接近默认配置,以展示开箱即用时的表现。 Loki 以牺牲查询速度为代价换取快速写入。Elasticsearch 以牺牲写入速度和存储为代价换取快速搜索。那么 VictoriaLogs 又提供了什么呢? VictoriaLogs 如今,许多公司都在输出结构化日志,每一行都带有命名字段,例如日志级别、HTTP 状态码或请求耗时。VictoriaLogs 利用了这类日志的两个特性,使写入和查询都能保持高速。 1. 同一个流中的日志看起来很相似。它们的字段几乎相同,而且许多字段几乎不变。每一行的命名空间都相同,日志级别也只有少数几个取值。VictoriaLogs 会把每个流的日志存放在一起,并把每个字段存储在独立的列中,这比存储整行日志的压缩效果好得多。 2. 许多查询只需要少数几个字段。例如,统计每个应用的错误数只需要应用和级别两个字段。VictoriaLogs 只读取查询所需的列。  这种存储方式本身就让 VictoriaLogs 在磁盘上非常紧凑,聚合也很快,所以它在写入时只需构建轻量的索引和少量元数据。这就是为什么它能以 120k 行/秒的速率写入,是 Elasticsearch 的 2.5 倍,同时磁盘占用少 4.7 倍。正如前文所述,只有 Loki 的写入速度更快。更多细节请参阅 [VictoriaLogs 如何存储你的日志]( victoriametrics.com)。 在查询方面,VictoriaLogs 在几秒内响应了所有查询,并且在每一条查询上都比 Loki 快。与 Elasticsearch 相比则互有胜负,我们会在关于查询的文章中详细分析。为了保持低成本的写入和存储,VictoriaLogs 不会构建完整的倒排索引。其代价是,搜索一个常见词(出现在大量行中的词)会比较慢。而查找一个罕见词(例如用户 ID )依然很快,因为 Bloom Filter 可以让它跳过不可能包含该词的数据。 VictoriaLogs 使用的内存也少得多。在 40k 行/秒时,Loki 使用的内存约为它的 14 倍( 11.4 GiB 对 0.8 GiB );在 120k 行/秒时约为 5 倍( 12.8 GiB 对 2.5 GiB )。这些空闲内存并不会被浪费。操作系统会用它来缓存最近写入或读取的数据,因此许多查询(尤其是针对近期日志的查询)可以直接从内存而不是磁盘读取数据。它也为大量并发查询留出了空间,我们计划在后续关于查询的文章中对此进行测试。 公平地说,本次基准测试使用的是结构化日志,这正是 VictoriaLogs 的优势所在。而针对这类日志的日常工作,很大一部分也是用于 Dashboard 、告警和分析的聚合,这正是列式存储最能发挥作用的地方。如果你的日志是非结构化的,例如老旧应用和网络设备输出的纯文本日志,VictoriaLogs 依然能很好地写入和搜索它们,但收益会比结构化日志小一些。 SigNoz 和 ClickStack SigNoz 和 ClickStack 比 Loki 更让我们意外。两者都把日志存储在 ClickHouse 中,而 ClickHouse 和 VictoriaLogs 一样是列式数据库,所以我们原本预期它们的表现会接近 VictoriaLogs 。但事实并非如此。ClickStack 达到 46k 行/秒,SigNoz 只有 10k ,而 VictoriaLogs 为 120k 。在磁盘空间方面,ClickStack 排名第二,但每百万行仍需要 82 MiB ,是 VictoriaLogs 的 1.5 倍。 较低的写入速率来自两个搭建在 ClickHouse 之上的方案,来自于它们的 Collector 。ClickHouse 是一个通用数据库,无法直接接收常见格式的日志,因此需要由一个 OpenTelemetry Collector 先把日志转换成行数据。这个 Collector 消耗的 CPU 比 ClickHouse 本身还多。在 SigNoz 中,10k 行/秒时 Collector 使用了 2.7 核,而 ClickHouse 为 1.5 核;在 ClickStack 中,达到最大速率时两者分别为 3.3 核和 2.2 核。  更大的磁盘占用可能源于两个技术栈存储字段的方式。它们把日志存储在一张固定列的 ClickHouse 表中,但 [OpenTelemetry 日志格式]( opentelemetry.io) 只有少数几个固定字段,外加可以容纳任意自定义数据的 Attributes 。固定字段(如 Timestamp 、SeverityText 和 TraceId )拥有独立的列,压缩效果很好。而 Attributes 则被放入少数几个 Map 列中,许多字段的值混杂在一起,因此压缩效果较差,查询也更慢。 VictoriaLogs 受到了 ClickHouse 的启发,但它是专门为日志而构建的([FAQ]( docs.victoriametrics.com))。它与 ClickHouse 有两点不同,这很可能解释了写入速率和磁盘空间上的大部分差距: - 无需 Collector 。 VictoriaLogs 可以直接接收日志采集器已经在发送的各种格式,例如 OpenTelemetry 、Loki 、Elasticsearch 、Datadog 和 Splunk 。 - 无需 Schema 。 日志行中的每个字段都有自己独立的列,而不仅仅是少数几个固定字段。 在本次测试中,基于相同设计的 SigNoz 使用的 CPU 几乎是 ClickStack 的 5 倍,主要消耗在其 Collector 上。Collector 把这些 CPU 花在了额外的元数据上,这些元数据可以加速查询并为其 UI 提供支持,我们会在关于查询的文章中探讨这一权衡。此外,它默认不设内存上限,而我们保留了这一默认配置。因此当它处理不过来时,会把日志行一直堆积在内存中,直到被 Kubernetes OOM Kill 。其他用户也报告过类似的 Collector 崩溃问题([GitHub issue]( signoz#6128))。 公平地说,SigNoz 和 ClickStack 是完整的可观测性技术栈,它们把日志、指标和链路追踪汇集在一处,并把每条链路关联到对应的日志。本次测试只衡量了其中的一部分:日志写入。两个技术栈也都建议随着数据量增长增加更多的 Collector 。ClickHouse 的文档按约 3 核处理 60k 事件/秒来规划单个 Collector ([ClickHouse 文档]( clickhouse.com)),而 SigNoz 自己的基准测试在一台 16 核机器上,于 ClickHouse 旁运行了 3 个 Collector ,达到约 55k 行/秒([SigNoz]( signoz.io))。在我们的测试中,为了对所有系统公平,每个系统都只分配了一台 8 核机器,所以 Collector 不得不与 ClickHouse 共享资源。集群测试中各个组件可以独立扩展,能够展示它们从更大规模的部署中能获得多少收益,这也在我们的计划之中。 关键结论 每个系统都根据自身的优先级和架构做出了不同的权衡。 - Loki 写入最多,因为它建立的索引最少。代价是查询缓慢,除非你为更多 CPU 核付费来加速查询。 - Elasticsearch 和 OpenSearch 为每一行的每一个词建立索引。这让它们能够非常快速地查找单词和精确值。但代价是写入时 CPU 消耗高,并且需要大量存储空间。 - SigNoz 和 ClickStack 把日志存储在 ClickHouse 中。ClickHouse 并非专为日志而构建,因此两个技术栈都需要额外的组件,这会拖慢写入速度并增加成本。如果你希望把 OTLP 日志、指标和链路追踪集中在一处,它们会比较适合你。 - VictoriaLogs 受到 ClickHouse 的启发,但它是直接为日志而构建的。它充分利用现代结构化日志的特点,实现快速写入、秒级查询响应,并且在内存和磁盘上的成本最低。如果你的日志是纯文本,收益会小一些。 本文在单台机器、普通云盘上测试了结构化日志。在后续文章中,我们计划深入介绍查询、集群模式,以及在 VictoriaLogs [支持对象存储]( VictoriaLogs#1155)后的对象存储测试。 你可以使用我们的[基准测试仓库]( VictoriaMetrics/logs-benchmark)复现主要的写入测试结果,也可以通过[快速入门指南]( docs.victoriametrics.com)试用 VictoriaLogs 。如果你发现我们的测试环境有错误,或者知道如何更好地调优某个系统,欢迎在[仓库]( github.com/VictoriaMetrics/logs-benchmark/issues)中提交 issue ,或在 [VictoriaMetrics 的 Slack]( slack.victoriametrics.com) 上联系我们。
Kubernetesのログ入門 はじめに 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) :::note warn 上記の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) :::note info このタグは、アプリケーションが改行区切りで書いた複数行のログ(スタックトレース等)を結合するためのものではありません。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ファイル」を意味することが、実際の挙動としても確認できました。 :::note warn 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 - 次の部分がこれです。 ```go 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)という部分がこれです。 ```go 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)。 :::note warn 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) :::note warn このファイルパスからの抽出は、正規表現が/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という出力プラグインがF