Kubernetes 解体新書Part 3 ランタイム

ノードでランタイムを解剖する

1 つのコンテナを選び、実行設定、プロセス、メモリ上限をたどります。学んだ仕組みをノード上で見つける実習です。

  • L2 ノードの中
  • 更新 2026年10月6日

1 つのコンテナを、Kubernetes、ランタイム、Linux の順に見てみましょう。 表示される名前や ID は違っていても、同じアプリを指しています。

この章では、動いている CoreDNS のコンテナを選び、その ID を手がかりに設定ファイル、プロセス、メモリの上限を探します。 ここまで学んだ仕組みが、ノード上のどこにあるかを確かめる実習です。

GOAL この章のゴール

  • コンテナの ID から、ノード上のプロセスを見つけられる
  • 同じコンテナの実行設定とメモリ上限を確認できる
  • イメージの層がどこで重ねられているか確認できる

ノードの中の地図

ノードの中でランタイム関連のものがどこにあるか: ソケット、設定、状態ディレクトリ、cgroupkubelet は /var/lib/kubelet/config.yaml を読み、/run/containerd/containerd.sock に CRI で話す。containerd は /etc/containerd/config.toml を読み、イメージと snapshot を /var/lib/containerd に、実行中コンテナの bundle を /run/containerd/io.containerd.runtime.v2.task/k8s.io に置く。runc の状態は /run/containerd/runc/k8s.io。cgroup は /sys/fs/cgroup/kubepods.slice。ノード (Ubuntu 26.04)kubelet/var/lib/kubelet/config.yamlCRIcontainerd/etc/containerd/config.tomlsocket: /run/containerd/containerd.sockイメージと snapshot/var/lib/containerd/ io.containerd.snapshotter.v1.overlayfs/ io.containerd.content.v1.content/実行中の bundle (shim の作業場)/run/containerd/ io.containerd.runtime.v2.task/k8s.io/ <cid>/config.json, rootfs/runc の状態/run/containerd/runc/k8s.io/ <cid>/state.jsonrunc --root … list で見るcgroup v2/sys/fs/cgroup/kubepods.slice/ kubepods-burstable.slice/…/cri-containerd-<cid>.scope/proc/<pid>/cgroup から辿れるPod の volume と log/var/lib/kubelet/pods/<pod uid>/volumes//var/log/pods/<ns>_<pod>_<uid>/<container>/0.logkubectl logs はこのファイルを読むshim → runcPod ごとに 1 shim
図 1ノードの中の地図。この章で全部の場所を訪ねる。
ノードの中に入るノードの中で実行
kubectl debug node/$(kubectl get node -o jsonpath='{.items[0].metadata.name}') -it \
  --profile=sysadmin --image=busybox:1.37 -- chroot /host nsenter -t 1 -m -u -i -n -p -- bash

抜けるときは exit。デバッグ用 Pod は自動では消えないので、終わったら kubectl delete pod -l app.kubernetes.io/managed-by=kubectl-debug かkubectl get pods で確認して消してください。

バージョンと設定

まず、ノードに入っている containerd、runc、crictl のバージョンを確かめます。

バージョンを確認するノードの中で実行
containerd --version
runc --version | head -1
export CONTAINER_RUNTIME_ENDPOINT=unix:///run/containerd/containerd.sock
crictl version

出力例

containerd github.com/containerd/containerd/v2 v2.4.1 …
runc version 1.5.2
Version:  0.1.0
RuntimeName:  containerd
RuntimeVersion:  v2.4.1
RuntimeApiVersion:  v1

RuntimeApiVersion: v1 が CRI のバージョンです。 次に、kubelet がどのソケットに繋いでいるかを kubelet の設定で見ます。

kubelet の設定からランタイム関連を抜くノードの中で実行
grep -E 'containerRuntimeEndpoint|cgroupDriver|cgroupRoot' /var/lib/kubelet/config.yaml

出力例

cgroupDriver: systemd
containerRuntimeEndpoint: unix:///run/containerd/containerd.sock

cgroupDriver: systemd は「cgroup の作成を systemd に頼む」という意味で、この設定を cgroup driver と呼びます。 Kubernetes のドキュメントによると、driver は cgroupfs (kubelet と containerd が /sys/fs/cgroup に直接書く。kubelet の既定) と systemd の 2 つで、kubelet と containerd は同じ driver に揃っていなければなりません。 同じドキュメントは、systemd を init に使うホストでは systemd driver を使い、cgroup v2 では cgroupfs の代わりに systemd driver を使うよう求めています。 理由は、systemd がシステムに cgroup の管理者が 1 つだけいることを前提にしていて、管理者が 2 つあると資源の見え方が 2 通りになり、負荷がかかったときにノードが不安定になるからです。 containerd のドキュメントは同じことを、cgroup の「single-writer」の規則と呼んでいます。 containerd 側の対応する設定が、runc の options の SystemdCgroup = true です。

containerd 2.x の /etc/containerd/config.toml の骨格 (version = 3)CRI は images plugin と runtime plugin に分かれている。runtime plugin の containerd テーブルに default_runtime_name と runtimes.<handler> があり、runc の options に SystemdCgroup = true を書く。cni テーブルに CNI の場所がある。version = 3[plugins.'io.containerd.cri.v1.images'] snapshotter = "overlayfs" [plugins.'io.containerd.cri.v1.images'.pinned_images] sandbox = "registry.k8s.io/pause:3.10.1"[plugins.'io.containerd.cri.v1.runtime'] [plugins.'io.containerd.cri.v1.runtime'.cni] bin_dirs = ["/opt/cni/bin"] conf_dir = "/etc/cni/net.d" [plugins.'io.containerd.cri.v1.runtime'.containerd] default_runtime_name = "runc" [plugins.'io.containerd.cri.v1.runtime'.containerd.runtimes.runc] runtime_type = "io.containerd.runc.v2" [plugins.'io.containerd.cri.v1.runtime'.containerd.runtimes.runc.options] SystemdCgroup = true強調: version、既定の runtime、shim の種類、cgroup driver。RuntimeClass の handler はここに runtimes.<名前> を足す。
図 2containerd 2.x の config.toml の骨格。CRI が images と runtime の 2 plugin に分かれ、runtimes.runc の下に shim の種類と cgroup driver がある。
containerd の設定を見るノードの中で実行
grep -vE '^\s*(#|$)' /etc/containerd/config.toml | head -40

出力例

version = 3
[plugins.'io.containerd.cri.v1.runtime'.containerd]
  default_runtime_name = 'runc'
[plugins.'io.containerd.cri.v1.runtime'.containerd.runtimes.runc]
  runtime_type = 'io.containerd.runc.v2'
[plugins.'io.containerd.cri.v1.runtime'.containerd.runtimes.runc.options]
  SystemdCgroup = true

ファイルが短ければ、書いてない項目は既定値です。 全部の既定値は containerd config default が TOML で出してくれます。 version = 3 が containerd 2.x の形式で、1.x の version = 2 では plugin 名が io.containerd.grpc.v1.cri という 1 つにまとまっていました。

コンテナを 1 つ選ぶ

crictl inspect の出力のどのフィールドが、ノード上のどの場所に対応するかpid は /proc、runtimeSpec は bundle の config.json、cgroupsPath は /sys/fs/cgroup、root.path は OverlayFS の merged、logPath は /var/log/pods、sandboxID は Pod の sandbox に対応する。crictl inspect <cid> | jqノード上の場所そこで見えるもの.info.pid/proc/<pid>/ns/、cgroup、mountinfo、root.info.runtimeTypeio.containerd.runc.v2どの shim か.info.runtimeSpec<bundle>/config.jsonrunc に渡した OCI spec.info.runtimeSpec.linux.cgroupsPath/sys/fs/cgroup/kubepods.slice/…cpu.max / memory.max.info.runtimeSpec.root.path<bundle>/rootfs (overlay の merged)mount | grep overlay.status.logPath/var/log/pods/…/0.logkubectl logs が読むファイル.info.sandboxIDcrictl inspectp <id>Pod の netns と IPkubelet の目線 (CRI) からカーネルの目線 (/proc、/sys) へ降りる地図
図 3crictl inspect のフィールドと、ノード上の場所の対応。この地図に沿って降りていく。

ここからは CoreDNS のコンテナを 1 つ追いかけます。

コンテナ ID と PID を取るノードの中で実行
CID=$(crictl ps --name coredns -q | head -1); echo $CID
crictl inspect $CID | jq '{pid: .info.pid, runtime: .info.runtimeType, sandbox: .info.sandboxID, cgroup: .info.runtimeSpec.linux.cgroupsPath, log: .status.logPath}'
PID=$(crictl inspect $CID | jq -r .info.pid); echo $PID

出力例

7d6c5b4a3f2e…
{
  "pid": 1301,
  "runtime": "io.containerd.runc.v2",
  "sandbox": "9f8e7d6c5b4a…",
  "cgroup": "kubepods-burstable-pod1a2b3c4d_….slice:cri-containerd:7d6c5b4a3f2e…",
  "log": "/var/log/pods/kube-system_coredns-…/coredns/0.log"
}
1301

crictl inspect の .info は containerd が kubelet に返す付加情報で、runc に渡した OCI spec がまるごと入っています。 cgroupsPath が 親.slice:プレフィックス:ID の形なのは systemd driver の記法です。

containerd の目線: bundle と config.json

containerd のドキュメントによると、/var/lib/containerd は再起動後も残るデータ (イメージの content と snapshot) の置き場で、/run/containerd はソケット、PID、実行中の状態など再起動で消えてよいデータの置き場です。 shim が runc に渡した bundle は後者の下にあります。

bundle を見るノードの中で実行
B=/run/containerd/io.containerd.runtime.v2.task/k8s.io/$CID
ls $B
jq '{args: .process.args, root: .root.path, ns: [.linux.namespaces[] | .type + (.path // "")], cgroup: .linux.cgroupsPath}' $B/config.json

出力例

address  config.json  init.pid  log  log.json  options.json  rootfs  runtime  shim-binary-path  work
{
  "args": ["/coredns", "-conf", "/etc/coredns/Corefile"],
  "root": "rootfs",
  "ns": ["pid", "ipc/proc/1260/ns/ipc", "uts/proc/1260/ns/uts", "mount", "network/proc/1260/ns/net"],
  "cgroup": "kubepods-burstable-pod1a2b3c4d_….slice:cri-containerd:7d6c5b4a3f2e…"
}

namespaces の配列を見ると、pid と mount は path が空で、ipc、uts、network は /proc/1260/ns/… という path 付きです。 OCI Runtime Spec は、path があればその既存の namespace に参加し、無ければ新しく作る、と定めています。 1260 は pause の PID です。 Pod 内のコンテナが同じ IP を持つのは、config.json のこの 3 行で pause の namespace を指しているからで、CRI の章で見た「sandbox が先」という順序がここで JSON になっています。

pause と namespace の番号を比べるノードの中で実行
PAUSE=$(jq -r '.linux.namespaces[] | select(.type=="network") | .path' $B/config.json | cut -d/ -f3)
for ns in net ipc uts pid mnt; do printf "%-4s pause=%s coredns=%s\n" $ns $(readlink /proc/$PAUSE/ns/$ns) $(readlink /proc/$PID/ns/$ns); done

出力例

net  pause=net:[4026532716] coredns=net:[4026532716]
ipc  pause=ipc:[4026532714] coredns=ipc:[4026532714]
uts  pause=uts:[4026532713] coredns=uts:[4026532713]
pid  pause=pid:[4026532717] coredns=pid:[4026532801]
mnt  pause=mnt:[4026532712] coredns=mnt:[4026532799]

net / ipc / uts は同じ番号、pid / mnt は別の番号です。 手作りコンテナの章で見た「番号が同じなら同じ namespace」を、本物の Pod で確かめました。

runc の目線: state

runc が知っているコンテナ一覧ノードの中で実行
runc --root /run/containerd/runc/k8s.io list | head -5
runc --root /run/containerd/runc/k8s.io state $CID | jq '{status, pid, bundle}'

出力例

ID             PID    STATUS   BUNDLE                                                    CREATED
7d6c5b4a3f2e…  1301   running  /run/containerd/io.containerd.runtime.v2.task/k8s.io/7d6c…  2026-…
{ "status": "running", "pid": 1301, "bundle": "/run/containerd/io.containerd.runtime.v2.task/k8s.io/7d6c…" }

--root は runc が状態を置く場所で、containerd は /run/containerd/runc/<containerd の namespace> を使います。 runc のプロセス自体は起動後に終了していますが、state.json にコンテナの PID と bundle を残していて、runc kill や runc exec はこれを読んで動きます。 state の出力 (status、pid、bundle) の形式は OCI Runtime Spec で決まっています。

カーネルの目線: cgroup と mount

cgroup を辿って上限を読むノードの中で実行
CG=/sys/fs/cgroup$(cut -d: -f3 /proc/$PID/cgroup); echo $CG
cat $CG/memory.max $CG/cpu.max $CG/pids.max
cat $CG/cgroup.procs
cat $CG/memory.current

出力例

/sys/fs/cgroup/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod1a2b3c4d_….slice/cri-containerd-7d6c5b4a3f2e….scope
178257920
max 100000
max
1301
24117248

kubeadm が作る CoreDNS の Deployment は limits.memory: 170Mi なので、memory.max が 178257920 (= 170 × 1024 × 1024) です。 cpu.max が max 100000 なのは CPU の limit が無いからで、requests は親の cpu.weight に効いています。 memory.current が今の使用量で、kubectl top の数字の出どころはここです。

mount コマンドが出す overlay の 1 行の読み方overlay on <rootfs> type overlay (rw, lowerdir=スナップショット群:…, upperdir=…/fs, workdir=…/work)。lowerdir はイメージの層で番号は snapshot の id、upperdir はこのコンテナの書き込み層。overlay on /run/containerd/io.containerd.runtime.v2.task/k8s.io/7d6c…/rootfs type overlay (rw, lowerdir=/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/41/fs:…/snapshots/12/fs:…/snapshots/3/fs, upperdir=/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/58/fs, workdir=/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/58/work)merged (on …/rootfs)コンテナの / になる場所bundle の rootfs/コンテナが消えるとumount されるlowerdir (左が上)イメージの層 1 つ = snapshot 1 つ41 = 一番上の層、3 = ベース読み取り専用同じイメージの他コンテナと同じ番号を共有するupperdir / workdirこのコンテナ専用の snapshot(58) の fs/ と work/書き込みは全部ここコンテナ削除で消える= 「中のファイルは消える」
図 4mount が出す overlay の 1 行。lowerdir がイメージの層、upperdir がこのコンテナの書き込み層。
コンテナの rootfs がどう mount されているかノードの中で実行
mount | grep "$CID/rootfs" | tr ',' '\n'

出力例

overlay on /run/containerd/io.containerd.runtime.v2.task/k8s.io/7d6c…/rootfs type overlay (rw
relatime
lowerdir=/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/41/fs:/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/12/fs
upperdir=/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/58/fs
workdir=/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/58/work)

lowerdir の数はイメージの層の数です。 upperdir の snapshot 番号はこのコンテナ専用で、コンテナを消すと消えます。 中からファイルを 1 つ書くと、upperdir に現れます。

copy-up を目撃するノードの中で実行
UP=$(mount | grep "$CID/rootfs" | sed 's/.*upperdir=\([^,]*\).*/\1/')
nsenter -t $PID -m -p sh -c 'echo hello > /tmp/from-inside' 2>/dev/null || nsenter -t $PID -m sh -c 'echo hello > /tmp/from-inside'
ls -la $UP/tmp/

出力例

-rw-r--r-- 1 root root 6 … from-inside

nsenterエヌエスエンター指定したプロセスの namespace に入ってコマンドを実行する util-linux のコマンド。-t <pid> で対象、-m -n -p などで入る namespace を選ぶ。用語集で見る でコンテナの mount namespace に入って書いたファイルが、ホスト側の upperdir に現れました。 イメージ (lowerdir) は変わっていません。

kubectl logs が読むファイル

ログファイルを直接読むノードの中で実行
tail -2 $(crictl inspect $CID | jq -r .status.logPath)

出力例

2026-10-06T10:00:00.123456789Z stderr F [INFO] 10.244.0.5:51234 - 1234 "A IN kubernetes.default.svc.cluster.local. udp 54 false 512" NOERROR qr,aa,rd 106 0.000123s

kubectl logs は、kubelet がこのファイルを読んで返しているだけです。 行の形式 (RFC 3339 の時刻、stdout / stderr の別、行が完結しているかの F / P、本文) は Kubernetes の CRI ログ設計で決まっていて、書くのはランタイム側 (shim と containerd の CRI plugin)、読むのは kubelet です。

これで 1 つのコンテナを、kubelet (crictl)、containerd (bundle)、runc (state)、カーネル (cgroup、mount、/proc) の 4 つの目線で見ました。 どの層でも同じ ID と PID で繋がっています。

片付けノードの中で実行
rm -f $UP/tmp/from-inside
exit

ふりかえり

Qkubelet の cgroupDriver: systemd に対応する containerd 側の設定は?

両方が systemd を指していないと、cgroup の作成で食い違って Pod が起動しません。

Qconfig.json の namespaces で path が付いているものの意味は?

pause の /proc/<pid>/ns/net などを指して「ここに入れ」と言っています。Pod 内で IP が共有される仕組みです。

Qkubectl top pod のメモリ使用量の元になるファイルは?

kubelet が cgroup の統計を読み、metrics-server 経由で kubectl top に出ています。

参考

読み切ると称号を獲得

カーネルの代理人

この Part を読み切りました。

称号一覧