containerd

Stars and mentions

Last 30 days, Oct 5 – Nov 3

GitHub stars73
mentions7
02468Oct 5Oct 19Nov 3
UTC days · repositories containerd owns4 repositories

Package downloads

No package download history available yet.

Top repositories

containerd’s repositories ranked by new stars, as on trending.

Loading

Contributes to

Repositories outside containerd’s account where containerd opened or reviewed pull requests in the past 365 days.

Who noticed containerd

Everyone who starred or forked containerd’s repositories since the archive began in 2011.

Notable accounts

Ordered by the stars their own repositories have received.

Biggest fans

Accounts that starred the most of these repositories.

Breakout weeks

Each repository’s best week of new stars, with the release published around it.

Mentions

7posts
Nov 40 posts
  1. Four Kubernetes node bugs I found by reading CI failures to the end --- title: "Four Kubernetes node bugs I found by reading CI failures to the end" published: true description: "A flake nobody had root-caused, a test that hung nodes for hours, a 3.9 GB image that never finished pulling, and a kubelet memory regression. How each one went from a red CI run to a patch." tags: kubernetes, go, sre, opensource --- Kubernetes' node tests fail a lot. Most of the time someone re-runs them, they go green, and everyone moves on. Over the past week I picked four failures in SIG Node territory and refused to move on — I kept reading the logs until the timeline explained the failure. Three were CI flakes. The fourth was a second, separate bug hiding inside a reported kubelet 1.37 memory regression. In every case, the bug wasn't where the error message pointed. Status, to be upfront: as of October 10, 2026, all four of my fixes are open pull requests, triaged or awaiting review, not merged. This post is about the investigations — which is the part I'd want to read anyway. How I read a failing CI run Every Kubernetes CI run on Prow keeps its artifacts: the build log, the JUnit results, the kubelet and containerd logs from the node, and for node tests, the VM's serial console. TestGrid shows the history, so you can tell a one-off from a pattern. My loop for each flake: 1. Find the pattern in TestGrid. Which job, how often, and — most useful — which similar jobs don't fail. 2. Build a timeline across logs. Test log, kubelet log and serial console on one clock, to the second. 3. Explain the difference. If only one job fails, something about that job's environment is the clue. 4. Reproduce before fixing. Either in a unit test that fails on master, or on a machine set up like the CI node. Bug 1: the image record that kept disappearing PR: kubernetes/kubernetes#142801 · fixes #140942 The symptom Tests for KubeletEnsureSecretPulledImages — the feature that makes the kubelet re-check credentials before reusing a private image — kept failing in one job, ci-kubernetes-node-swap-ubuntu-serial: 3 of the last 10 runs, 6 of 20 over a longer window. A test helper waits up to five seconds for the kubelet to write an ImagePulledRecord for an image, and the record never appeared. This had already been through two issues. One was closed by a PR that only added debug logging. The working theory in the open issue was that the kubelet doesn't write a fresh record when an image is already cached from an earlier test. The timeline From the failing run (prow job 2080994746846679040): !Timeline of the failing run: at 14:49:41 the test pod pulls the pause image and the record is written; at 14:49:42 the test restarts the kubelet and image GC runs at 85% disk usage, exactly the high threshold, and the post-GC hook deletes the record; at 14:49:47 the test times out waiting for a record that no longer exists The record was written. Then it was deleted one second later. The cached-image theory was wrong. Why only this job That job's image filesystem sat at about 85% for the whole run — exactly the kubelet's default high threshold for image garbage collection. Every kubelet restart in that job triggered a GC pass. Other jobs never got there. The root cause After a GC pass, the kubelet calls post-GC hooks with the list of images that "remain". The ImagePullManager's hook, PruneUnknownRecords, deletes every pulled-record for an image that isn't in that list. The list was built inside freeSpace(), from the images it evaluated. But images in use by a running container, and pinned images, are filtered out before freeSpace() ever sees them. And freeSpace() stops early once it has freed enough bytes, so images after that point were never added either: // imagesInEvictionOrder, simplified for image, record := range im.imageRecords { if isImageUsed(image, imagesInUse) { continue // in-use images never reach freeSpace()... } if record.pinned { continue // ...and neither do pinned ones } images = append(images, evictionInfo{id: image, imageRecord: *record}) } So the list of "remaining" images was really "the eviction candidates GC looked at and kept". The image the test pod was running wasn't on it, and its record got pruned. It fails safe — a later pod just has to re-verify credentials — but it's wrong, and it's invisible: the deletions weren't logged. The fix Pass the hooks every image the GC manager still tracks, not just the candidates it evaluated: // remainingImages returns the IDs of all images still known after a GC pass. // This must include in-use and pinned images, not just the eviction candidates // that survived: post-GC hooks treat anything missing from the list as gone. func (im *realImageGCManager) remainingImages() []string { im.imageRecordsLock.Lock() defer im.imageRecordsLock.Unlock() // The same image can be tracked once per runtime handler. imageIDs := sets.New[string]() for imageKey := range im.imageRecords { imageIDs.Insert(getImageIDFromTuple(imageKey)) } return sets.List(imageIDs) } Both GarbageCollect and DeleteUnusedImages now call im.runPostGCHooks(ctx, im.remainingImages(), freeTime), and pruned records are logged at V(4). The new TestPostGCHooksReceiveAllRemainingImages fails on master — the hook receives an empty list — and passes with the change. Status (Oct 10, 2026): open, awaiting triage and review from SIG Node. Bug 2: the swap test that hung nodes for hours PR: kubernetes/kubernetes#142850 · related to #138226 The symptom A swap e2e test, "should be able to use more than the node memory capacity", sometimes passed, sometimes failed, and sometimes produced a run that ended in exit status 255 with no JUnit results at all — the node had stopped responding. The numbers didn't add up The test allocates memory equal to the node's full capacity — 8 GiB on the CI node, which also has 1 GiB of swap. The system needs some of that RAM too, so part of the pod's allocation can't fit in memory and has to go to swap. In my reproduction below, when the pod was allowed to finish, about 761 MiB of it ended up in swap. Under LimitedSwap, a container's swap limit is proportional to its memory request: > swap limit = (memory request ÷ node memory) × node swap | Request | Swap limit | Against ~761 MiB of overflow | | --- | ---: | :--- | | 30% (before) | ~307 MiB | too small | | 90% (after) | ~921 MiB | fits | So in every run, the pod hit its swap limit with memory still left to place. What happened next varied — which is why the test flaked instead of failing every time: - The OOM killer stepped in quickly. The run recovered, and sometimes the test passed. - It didn't. The kernel tried to reclaim memory elsewhere. On this image system.slice and user.slice can't swap, so it thrashed the page cache instead — and the node hung for hours. Those are the exit status 255 runs with no JUnit results. The serial console of one failed run showed exactly this: the pod pinned at its swap limit, with roughly 700 MB of swap still free on the node. Reproducing it On a VM set up like the CI node (8 GiB RAM, 1 GiB swap, Ubuntu 24.04, the same slice swap settings): at 30% the pod hit its swap limit and was OOM-killed. At 90% it finished allocating with about 761 MiB in swap, with no OOM and no stalls. The fix Three changes, all test-only: 1. Request 90% of memory, so the pod's swap limit can hold the overflow. 2. Lower the memory.available hard-eviction threshold to 100 Mi for these tests. Under node-level pressure, swap only starts a few seconds before the default 250 Mi threshold would evict the pod — before the test can see any swap usage. 3. Delete the pod with DeleteSync, so it's gone before the kubelet restarts in AfterEach. Nothing kills the pod while the kubelet is down. Status (Oct 10, 2026): open, triaged as important-longterm, with the swap tests' original author asked to review. Bug 3: the 3.9 GB image that never finished pulling PR: kubernetes/kubernetes#142802 · related to #141200 The symptom The GPU sanity test (nvidia-smi plus a CUDA demo) timed out after 30 minutes with the pod stuck in ErrImagePull. Even passing runs spent up to 24 minutes pulling the image. The containerd log cancel pulling image docker.io/nvidia/cuda:12.5.0-devel-ubuntu22.04 because of no progress in 5m0s stop pulling image ...: active requests=1, bytes read=3918316892 failed to extract layer (... sha256:496b16dd...) to overlayfs as "extract-...": context canceled The image is about 3.9 GB, and its largest layer is about 2.5 GB. The log shows 3.9 GB had been read before the pull was cancelled during layer extraction. So the expensive part wasn't the network — it was unpacking the big layer, which reported no "progress" to containerd's five-minute watchdog. The cancelled pull didn't reuse what it had already fetched, so each kubelet retry started the download again. That's containerd/containerd#13909, with a proposed fix upstream. The fix Use a smaller image. On amd64, the test only needs nvidia-smi (which comes from the driver) and the cuda-demo-suite-12-5 apt package, whose binaries link the CUDA runtime statically. The ~90 MB nvidia/cuda:12.5.0-base-ubuntu22.04 image has the same CUDA apt repository, keyring and environment. Other architectures build samples with nvcc, so they keep the devel image, and the pod is pinned to the GPU node's architecture so the image always matches the script's uname -m branch. I left arm64 alone because I couldn't test it on arm64 GPU nodes. It can still hit the containerd behavior. Status (Oct 10, 2026): open, triaged as important-longterm, awaiting /ok-to-test and review. Bug 4: a second bug inside the 1.37 kubelet memory regression PR: google/cadvisor#3946 · issue kubernetes/kubernetes#142874 This one isn't a flake. A SIG Node contributor reported 20–30% higher kubelet memory in Kubernetes 1.37, traced to cAdvisor — the library the kubelet uses to collect container stats — and wrote the main fix. My part is a separate, smaller bug I found while reproducing it. To keep the credit straight: | | What it fixes | Author | | --- | --- | --- | | google/cadvisor#3944 | The reported cause: changing counters that stop housekeeping from ever backing off | the reporter | | google/cadvisor#3946 | A back-off reset bug that makes the interval oscillate even when stats are unchanged | me | The reported cause cAdvisor polls each container's stats on a "housekeeping" interval. When stats haven't changed, it backs off — doubling the interval up to a maximum. When they change, it drops back to the baseline. MemoryQoS, default-on in 1.37, sets memory.high on containers. The kernel then reclaims memory continuously, which keeps advancing counters in memory.stat. cAdvisor compared whole stats structs, so they never looked unchanged, and housekeeping never backed off. The reporter's fix, google/cadvisor#3944, excludes those counters from the comparison. The second bug underneath I reproduced the regression and confirmed #3944 fixes the counters. But while simulating it, I found the back-off had a bug of its own: if stats[0].StatsEq(stats[1]) && (cd.housekeepingInterval < cd.maxHousekeepingInterval) { cd.housekeepingInterval *= 2 // ... cap at max ... } else if cd.housekeepingInterval != *HousekeepingInterval { // Lower interval back to the baseline. cd.housekeepingInterval = *HousekeepingInterval } Once the interval reaches the maximum, interval < max is false — so even with unchanged stats, execution falls into the else branch and resets to the baseline. Instead of staying backed off, the interval cycles: with cAdvisor's defaults, 1s → 2s → 4s → … → 1m → 1s; with the kubelet's settings, 10s → 15s → 10s → 15s. !Chart of the housekeeping interval over successive ticks with unchanged stats: the original code climbs to the maximum and then drops back to the baseline in a repeating sawtooth, while the fixed code climbs to the maximum and stays there The fix separates the two conditions, so only changed stats reset the interval: if stats[0].StatsEq(stats[1]) { if cd.housekeepingInterval < cd.maxHousekeepingInterval { cd.housekeepingInterval *= 2 // ... cap at max ... } } else if cd.housekeepingInterval != *HousekeepingInterval { cd.housekeepingInterval = *HousekeepingInterval } The new TestNextHousekeepingIntervalDynamic steps through unchanged and changed samples. Run against the original code, it fails exactly where the sawtooth starts: !Terminal recording: the new test fails on the original cAdvisor code, expecting 4s but getting 1s at step 3 and 2s at step 4, then passes with the fix In my simulation of a 10-minute run with the kubelet's settings and MemoryQoS-style advancing counters, a container got 60 housekeeping ticks on the current code, 48 with #3944, and 41 with both fixes. Most of the improvement comes from #3944; mine removes the oscillation that remains. Neither patch alone gets the kubelet to back off as intended, and the simulation measures housekeeping ticks, not the production memory figure in the report. Status (Oct 10, 2026): #3946 is open; the issue is assigned to me, and the reporter called the patch a good one. What the four had in common Each bug lived at an edge that normal runs never reach: - A list that silently meant "some of everything". "Remaining images" excluded in-use and pinned images, and nothing downstream knew. - A number that couldn't work. A swap limit smaller than the overflow it had to hold. - A timeout that measured the wrong thing. A "no progress" watchdog applied to an unpack that reports no progress — and the fetched data not reused. - An else branch that fired at the boundary. Correct below the maximum, wrong exactly at it. And each one was explained by a difference: the one job at 85% disk, the one test whose request didn't match its allocation, the one image that was 40 times bigger than it needed to be, the one comparison that never settled. Flakes aren't noise. They're bugs that only show up under one environment's conditions. The job that fails is telling you which conditions. What the evidence does and doesn't establish | Evidence | Establishes | Does not establish | | --- | --- | --- | | Prow timeline for bug 1 | The record was written, then pruned by GC one second later, in that run | That every historical failure of these tests had this cause | | TestPostGCHooksReceiveAllRemainingImages | The hook now receives in-use and pinned images | That the fix has been reviewed or accepted | | VM reproduction for bug 2 | 30% fails and 90% holds on a CI-like machine | Behavior on every node image the swap tests run on | | containerd log for bug 3 | 3.9 GB had been read before the pull was cancelled during layer extraction | That every layer had fully downloaded, or that the smaller image never hits the same timeout | | cAdvisor test, failing on original and passing with the fix | The back-off reset bug exists and the change removes it | The production memory savings | | 60 / 48 / 41 tick simulation | The relative effect of #3944 and #3946 in my model | The 20–30% memory figure, which comes from the reporter's environment | Check each one yourself Bug 1 — kubelet image GC. Check out the PR branch and run the new unit test. Without the fix, the hook receives an empty list: go test ./pkg/kubelet/images/ -run TestPostGCHooksReceiveAllRemainingImages -v Bug 2 — swap test. This is a node e2e test, so it needs a Linux node with swap enabled. The PR describes the VM I used (8 GiB RAM, 1 GiB swap, Ubuntu 24.04). From a Kubernetes checkout: make test-e2e-node FOCUS="should be able to use more than the node memory capacity" Bug 3 — GPU image. The full test needs a GPU node; in CI it's the pull-kubernetes-e2e-gce-device-plugin-gpu job. The size difference behind the fix is easy to see anywhere crictl runs: crictl pull nvidia/cuda:12.5.0-devel-ubuntu22.04 # ~3.9 GB, ~2.5 GB largest layer crictl pull nvidia/cuda:12.5.0-base-ubuntu22.04 # ~90 MB The cancellation itself is containerd's image_pull_progress_timeout (five minutes by default) firing during extraction; containerd/containerd#13909 has the details. Bug 4 — cAdvisor back-off. git clone https://github.com/google/cadvisor && cd cadvisor/lib # add TestNextHousekeepingIntervalDynamic from the PR, then: go test ./manager/ -run TestNextHousekeepingIntervalDynamic -v On the original code it fails at step 3; with the fix applied, it passes. --- I'm Sachin, an SRE / platform engineer working on Kubernetes, observability and distributed systems — currently open to new roles. LinkedIn · GitHub

    1 reaction
  2. I Replaced runc with a Rust Container Runtime (youki) for a Week The Boring Layer That Matters Most Every container you've ever run — docker run hello-world included — went through a tiny binary called runc before it became a process. It creates the namespaces, sets up the cgroups, drops privileges, and hands off to your entrypoint. You never think about it. And that's the problem. runc is written in Go + C (yes, C — for the nsenter and pivot_root bits that Go can't handle cleanly). It's been the reference implementation since 2014. It's also the layer where CVE-2024-21626 (a container escape) lived. What if that layer was memory-safe by construction? Enter youki — a container runtime written entirely in Rust, now a CNCF Sandbox project. --- What Is youki? youki (Japanese for "container") is an OCI runtime-spec implementation in Rust. It's a drop-in replacement for runc: same CLI, same spec, same integration path through containerd, Podman, or Docker. | Fact | Detail | |---|---| | Language | Rust (pure, except a libseccomp-rs FFI shim being replaced) | | CNCF status | Sandbox (accepted Oct 17, 2024) | | GitHub | youki-dev/youki | | Stars | ~7,300 | | License | Apache 2.0 | | Adopters | Azure Kubernetes Service, Docker, containerd/runwasi | | Contributing orgs | Microsoft, Docker, Red Hat | The key differentiator vs. runc: no Go runtime startup, no C nsenter binary, no mixed-language memory safety boundary. The entire create → start → delete lifecycle lives in one Rust binary with compile-time safety guarantees. --- The Benchmarks (Honest Version) The headline number from youki's own repo: Runtime Time (mean ± σ) Range (min … max) vs youki youki 111.5 ms ± 11.6 ms 84.0 ms … 142.5 ms 100% runc 224.6 ms ± 12.0 ms 190.5 ms … 255.4 ms 200% crun 47.3 ms ± 2.8 ms 42.4 ms … 56.2 ms 42% Youki is ~2× faster than runc for create → start → delete. But crun is ~2.5× faster than youki. Here's the honest context: | Scenario | Does it matter? | |---|---| | Single long-lived container | No. 100ms of startup is noise over a 7-day uptime. | | Serverless: 10k containers/min | Yes. A 100ms savings × 10k = 1,000 CPU-seconds per minute reclaimed. | | K8s pod churn (CI/CD, spot nodes) | Moderately. Image pull + CNI + scheduling dominate; runtime is ~5–10% of total. | | Edge/embedded (tight RAM) | Yes. youki's binary is smaller and RSS is lower per instance. | The real win isn't raw speed — it's the Rust safety guarantee. You're not betting your node's security on a C memcpy in nsenter. --- Setup: Replacing runc on a K8s Node Prerequisites - Kubernetes 1.28+ with containerd (not Docker Engine) - Linux kernel 5.10+ (youki drops older kernel support to use modern features) - cargo / just for building from source (or use a pre-built binary) Step 1: Build youki git clone https://github.com/youki-dev/youki.git cd youki just build # Binary: ./target/release/youki Or grab a release binary from GitHub Releases. Step 2: Register with containerd Edit /etc/containerd/config.toml (or use containerd config path): [plugins."io.containerd.grpc.v1.cri".containerd.runtimes.youki] runtimeType = "io.containerd.youki.v1" Then install the shim (youki ships a containerd-compatible shim): sudo cp target/release/youki /usr/local/bin/ sudo cp target/release/containerd-shim-youki-v1 /usr/local/bin/ Restart containerd: sudo systemctl restart containerd Step 3: Create a RuntimeClass apiVersion: node.k8s.io/v1 kind: RuntimeClass metadata: name: youki handler: youki scheduling: nodeSelector: kubernetes.io/arch: amd64 kubectl apply -f runtimeclass-youki.yaml Step 4: Run a Pod with youki apiVersion: v1 kind: Pod metadata: name: youki-test spec: runtimeClassName: youki containers: - name: app image: nginx:alpine resources: requests: memory: "32Mi" cpu: "10m" kubectl apply -f youki-pod.yaml kubectl get pod youki-test -o wide # youki-test 1/1 Running 0 2s Verify it's actually using youki: kubectl exec youki-test -- cat /proc/1/cgroup # Confirms cgroup v2 hierarchy managed by youki --- Step 5: Run It for a Week (What I Noticed) I ran a 3-node k3s cluster with a mix of workloads: - 20 long-lived API pods (Go, ~50 MB each) - A CI pipeline spawning 50–200 short-lived pods/day - A cron job that scales a batch processor 0 → 10 → 0 every hour What went well: 1. Zero crashes, zero OOMs in the runtime itself. The Rust error handling is visible in logs — when something goes wrong, you get a structured Error with context, not a segfault. 2. Short-lived pods were noticeably snappier. My CI pods (build → test → delete) came up ~30–50ms faster than runc. Not dramatic per-pod, but across 200 pods/day it added up. 3. Rootless mode was cleaner. youki create with --rootless worked without the newuidmap/subuid gymnastics that runc sometimes needs. 4. Binary size. youki: ~8 MB. runc: ~12 MB. crun: ~3 MB. On an edge node with 512 MB RAM, every MB counts. What was rough: 1. cgroups v1 support is there but v2 is the path of least resistance. If your distro still defaults to v1 (looking at you, CentOS 7), you'll hit edge cases. 2. No --checkpoint/--restore (CRIU) yet. If you rely on live migration or pause/resume, you're stuck on runc. 3. Docs are thin. The user docs cover the basics but there's no "migrating a production cluster from runc to youki" guide. You're on your own. 4. One C dependency remains. libseccomp-rs is an FFI wrapper around the C libseccomp. The team is working on a pure-Rust replacement, but it's not done. --- youki vs. runc vs. crun: Where Each Wins | Dimension | runc | crun | youki | |---|---|---|---| | Language | Go + C | C | Rust | | Memory safety | ❌ (C portion) | ❌ | ✅ (99% — one FFI shim) | | Create→delete (benchmark) | 224 ms | 47 ms | 111 ms | | Binary size | ~12 MB | ~3 MB | ~8 MB | | cgroups v2 | ✅ | ✅ | ✅ | | Rootless | ✅ | ✅ | ✅ (cleanest) | | CRIU (checkpoint/restore) | ✅ | ✅ | ❌ (roadmap) | | Wasm support | ❌ | ❌ | ✅ (first-class) | | CNCF governance | ❌ (OCI) | ✅ (Sandbox) | ✅ (Sandbox) | | Production battle-testing | 10+ years | 5+ years | ~3 years | | Best for | Default, compatibility | Max performance, minimal footprint | Safety + Wasm + future-proofing | The honest takeaway: If you need maximum raw speed today, crun wins. If you need the safest runtime with a path to Wasm workloads, youki is the pick. runc remains the "it just works" default. --- The Wasm Angle (Why This Matters More Than You Think) Here's the part that excites me: youki is the low-level runtime that powers runwasi (the containerd shim for Wasm workloads) and, by extension, SpinKube. The architecture: containerd └── containerd-shim-runwasi (Rust) └── youki libcontainer (Rust) ← shared library └── Wasmtime / Wasmer / WasmEdge Youki's libcontainer crate is used as a library by other Rust container tools. That means the same memory-safe, OCI-compliant primitives that run your nginx:alpine pod also run your Wasm modules. One codebase, two workload types, same security guarantees. This is the "Rust container stack" taking shape: | Layer | Rust project | Role | |---|---|---| | Runtime | youki | OCI container lifecycle | | Shim | runwasi | Wasm on containerd | | Orchestrator | SpinKube | Wasm on Kubernetes | | Image tooling | naftiko | Build OCI/Wasm images | | Spec | oci-spec (Rust crate) | The spec itself, in Rust | Youki is the foundation. If it's not solid, nothing above it is. --- What's Missing (and Where Funding Helps) youki is in CNCF Sandbox — the earliest maturity level. The project is healthy (active commits, 80+ contributors, backing from Microsoft/Docker/Red Hat) but under-resourced for what it needs to do: - Pure-Rust seccomp (eliminate the last C dependency) — significant engineering effort - SELinux in Rust — needed for RHEL/CentOS production adoption - CRIU support — checkpoint/restore is table-stakes for production - OCI conformance test suite — a dedicated, automated compliance harness - Docs and migration guides — "how do I move a 500-node cluster from runc to youki?" - Security audit — a formal audit would be a major trust signal for enterprise adoption - Conference presence — KubeCon, RustConf, containersc — the project needs more visibility No LFX Crowdfunding campaign is active yet. If you use or evaluate youki, the highest-impact things you can do: 1. Contribute — even docs, test cases, or bug reports. GitHub 2. Run it in production and report what breaks (or doesn't) 3. Write about it — this article is a start, but we need more "I ran youki in a real cluster" content 4. Watch for an LFX Crowdfunding launch — CNCF funding page --- TL;DR | Question | Answer | |---|---| | Is youki production-ready? | For new, non-critical workloads: yes. For a 500-node cluster: not yet. | | Is it faster than runc? | ~2× for create/delete. Yes, but crun is faster still. | | Is it safer than runc? | Yes — memory-safe Rust eliminates the C nsenter attack surface. | | Should I switch today? | If you're running Wasm workloads or want to be early on the Rust container stack: yes. Otherwise: watch this space. | | What's the risk? | It's a Sandbox project. The API could shift. CRIU is missing. Docs are thin. | --- Final Thought The container runtime is the most underappreciated layer in the stack. It's the thing that's always running, that touches every syscall, that sits between your workload and the kernel. For a decade, that layer was a Go binary with a C sidecar. Now there's a Rust option with CNCF governance, major-company backing, and a clear path to being the foundation for the next generation of container workloads (including Wasm). I ran it for a week. It didn't break. It was faster. And the Rust error messages in the logs were genuinely pleasant to read. That's about as good as "boring infrastructure" gets. --- Have you tried youki in production? What broke? What didn't? Drop your experience in the comments — I'm especially curious about anyone running it alongside runwasi or SpinKube. If you want to support the project, the best things right now are contributing code/docs and writing about your experience. The CNCF funding infrastructure is ready when the project is.

    1 reaction
  3. Kubernetes ノードのディスク領域と Disk Pressure Eviction について はじめに ノードのディスク領域がよくわからないのと、Pod が意図せずに evict される場合は、OOM か Disk Pressure の場合が多いですが、Disk Pressure についてはよくわからないという話をよく聞くので記事にしました。 OOM の仕組みについては以前記事にしたので、そちらを参考にしてください。 参考: Kubernetes のメモリ使用量と cgroup v2 の関係: OOM / Eviction の切り分けと Request の適正化 自分が経験した範囲では、Disk Pressure による Pod の Eviction の多くは、次のような流れで発生し、原因と対策は以下の通りです。 - 流れ 1. ディスクの空き容量が nodefs.available<10% を下回り、Disk Pressure と判定される 2. kubelet が各種 GC で空き容量の確保を試みるが、それでも解消しない 3. Pod が Eviction される - 原因: 多くのケースで ephemeral-storage の request が未設定か過小に設定されていて、スケジューラが機能していません。 - 対策: 実際の消費量に見合った request を設定しておくことで回避できるケースが多いです。 Kubernetes をただ使うだけであれば、Disk Pressure が起きた際に上記の知識だけで十分対応できるケースも多いと思いますが、クラスタの構築や詳細なトラブルシュートを実施しようとすると、それだけでは知識が足りません。 この記事では、ノードのディスク領域の構成から ephemeral-storage の内訳、Disk Pressure の発生条件・挙動、対策までを順番に整理します。 対象コンポーネントとバージョン | コンポーネント | バージョン | リポジトリ | 本記事での役割 | |:---|:---|:---|:---| | Kubernetes | v1.37.1 | kubernetes/kubernetes | kubelet の Eviction Manager、Image GC Manager、Container Log Manager、emptyDir ボリュームプラグインの実装 | | node_exporter | v1.12.1 | prometheus/node_exporter | nodefs/imagefs の空き容量・inode 使用率を監視する際の filesystem メトリクス | | kube-state-metrics | v2.20.0 | kubernetes/kube-state-metrics | DiskPressure Node Condition を監視する際の kube_node_status_condition メトリクス | | containerd | v2.4.1 | containerd/containerd | 専用 imagefs/containerfs を構成する際の config、CRI 経由の ImageFsInfo 実装 | TL;DR - ノードのディスクは nodefs (root filesystem) と imagefs (image filesystem) の 2 つに分けて考えます。 - コンテナログのローテーションは kubelet 自身が担当しますが (ContainerLogMaxSize デフォルト 10Mi、ContainerLogMaxFiles デフォルト 5)、コンテナ単位の上限でしかありません。ノード全体のログ量には上限がなく、コンテナ数が多い環境ではログだけで nodefs を圧迫できます。 - ephemeral-storage として計測されるのは コンテナの Writable Layer・ログ・disk-backed な emptyDir・/etc/hosts などです。 - hard threshold を下回ると Disk Pressure と判定されます。 デフォルト値は nodefs.available<10%、imagefs.available<15%、nodefs.inodesFree<5%、imagefs.inodesFree<5% です。 - kubelet はまず Pod を退避せずに 「ノードレベルの回収」 を試みます。不要なコンテナ・Pod の GC、そして Image GC (未使用イメージの削除) です。これで閾値を下回れば Pod Eviction は起こりません。 - Image GC は 5 分おきの定期実行 に加えて、Disk Pressure 発生時 にも呼び出されます。後者は閾値に関係なく、削除できるイメージを LRU 順にすべて削除しようとします。 - Disk Pressure での Pod 退避の優先順位は次の順で決まります。request を設定していない Pod は真っ先に「超過している」扱いになり、優先的に狙われます。 1. ephemeral-storage の request を超過しているか 2. Priority 3. request を超えた使用量 - QoS クラスは CPU・Memory だけで決まり、ephemeral-storage は関係しません。Disk Pressure で QoS は機能しません。 ノードのディスク領域 Disk Pressure の判定に使われる nodefs/imagefs の空き容量がそれぞれ何を指すのか、まずノードのディスク領域そのものを整理します。 第1章: ディスクの中身 — nodefs / imagefs / containerfs まず nodefs・imagefs・containerfs という 3 つの用語の位置づけを、概要レベルで表にまとめます。nodefs/imagefs の詳細な判定方法や具体例は 1.1 で、containerfs は 1.2 で説明します。それぞれの領域の GC の方法は第2章でまとめて説明します。 | 用語 | 概要 | 主に含まれるもの | |:---|:---|:---| | nodefs | ノードの基本ファイルシステム | ログ、disk-backed な emptyDir、kubelet の作業ディレクトリなど | | imagefs | イメージ用ファイルシステム (nodefs から分離できる) | コンテナイメージ、コンテナの Writable Layer | | containerfs | Writable Layer 用ファイルシステム (imagefs から分離できる) | コンテナの Writable Layer のみ | 具体的にどのデータがどこに格納されるかを、nodefs・imagefs・containerfs を最大限分離した場合で一覧にすると、次のようになります。 | 種類 | nodefs | imagefs | containerfs | |:---|:---:|:---:|:---:| | kubelet の作業ディレクトリ (/var/lib/kubelet など) ホスト側ファイル | ○ | - | - | | コンテナログ (/var/log/pods 配下) | ○ | - | - | | disk-backed な emptyDir | ○ | - | - | | コンテナイメージ | △ | ○ | - | | コンテナの Writable Layer | △ | △ | ○ | - ○: 専用に分離した場合、実際に格納される場所 - △: 専用領域を用意しない場合に吸収される上位の区分 - -: 対象外 この表のとおり imagefs を専用化しなければ △ の行は nodefs に吸収され、containerfs を専用化しなければさらに imagefs (または nodefs) に吸収されます。この判定の詳細は 1.1 で、containerfs 固有の事情は 1.2 で説明します。 1.1 nodefs と imagefs kubelet はノードのディスクを nodefs と imagefs という 2 つの論理的なファイルシステムとして扱います。 > nodefs: The node's main filesystem, used for local disk volumes, emptyDir volumes not backed by memory, log storage, ephemeral storage, and more. For example, nodefs contains /var/lib/kubelet. > > imagefs: An optional filesystem that container runtimes use to store container images and container writable layers. (参考: node-pressure-eviction.md#L122-L131) imagefs は「専用のディスクを別に用意した場合だけ意味を持つ」ものです。専用 imagefs を用意していなければ、imagefs は nodefs と同じデバイスを指し、実質的に nodefs のみになります。 この判定は自動で行われます。kubelet はコンテナランタイムが報告する imagefs のデバイスと、root filesystem のデバイスを比較し、同じであれば「専用ではない」と判断します。 device, err := p.containerStatsProvider.ImageFsDevice(ctx) ... rootFsInfo, err := p.cadvisor.RootFsInfo() ... if device == rootFsInfo.Device { ... } return device != rootFsInfo.Device, nil (参考: pkg/kubelet/stats/provider.go#L179-L201) この判定結果は Eviction Manager 起動時に一度だけキャッシュされます。 hasImageFs, imageFsErr := diskInfoProvider.HasDedicatedImageFs(ctx) ... m.dedicatedImageFs = &hasImageFs (参考: pkg/kubelet/eviction/eviction_manager.go#L267-L275) 実際のノード上でこれらがどう見えるか、containerd + overlayfs を使う単一ファイルシステム構成 (最も一般的なパターン) を例に確認してみます。以下は代表的なディレクトリ構成の一例です (実際のパスはディストリビューション・ランタイムの設定次第で変わります)。 # / と /var/lib/containerd が同じデバイスを指している = 専用 imagefs ではない $ df -h / /var/lib/containerd Filesystem Size Used Avail Use% Mounted on /dev/sda1 50G 18G 30G 38% / /dev/sda1 50G 18G 30G 38% /var/lib/containerd # nodefs 側: kubelet の作業ディレクトリ (Pod の emptyDir など) $ tree -L 2 /var/lib/kubelet/pods/<pod-uid>/volumes/ /var/lib/kubelet/pods/<pod-uid>/volumes/ ├── kubernetes.io~empty-dir │ └── cache-volume ├── kubernetes.io~projected │ └── kube-api-access-xxxxx └── kubernetes.io~secret └── my-secret # nodefs 側: Pod のログ (/var/log/pods/<namespace>_<pod名>_<pod-uid>/<コンテナ名>/) $ tree /var/log/pods/default_myapp-6f9d8c9c9b-abcde_1b2c3d4e-.../myapp/ /var/log/pods/default_myapp-6f9d8c9c9b-abcde_1b2c3d4e-.../myapp/ └── 0.log # imagefs 側: イメージ本体とコンテナの Writable Layer (containerd の snapshotter 配下) $ tree -L 1 /var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/ /var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/ ├── 1 ├── 2 ├── 3 └── 4 専用 imagefs を切っている構成 (/var/lib/containerd を別ディスクにマウントするなど) では、df の出力上でデバイスが分かれます。 $ df -h / /var/lib/containerd Filesystem Size Used Avail Use% Mounted on /dev/sda1 20G 9G 10G 48% / /dev/sdb1 200G 140G 50G 74% /var/lib/containerd kubelet はこの 2 つのマウントポイントのデバイス番号を比較して「専用かどうか」を自動判定しています (1.1 で見た HasDedicatedImageFs の判定ロジックそのものです)。 ここで重要なのは、imagefs を専用化するための設定は kubelet 側にはない という点です。kubelet は単にコンテナランタイムが実際に使っているディレクトリを検出しているだけなので、実際に設定するのはコンテナランタイム側 (containerd であれば root) です。 version = 2 root = "/mnt/imagefs/containerd" state = "/run/containerd" (参考: cmd/containerd/server/config/config.go#L58-L65) root がコンテナイメージ・Writable Layer (snapshotter) の格納先で、これを別ディスクのマウントポイントに向けることで「専用 imagefs」になります。state はソケットなど一時データ用で、imagefs/nodefs の区分とは別物です。 imagefs からさらにコンテナの Writable Layer 用のファイルシステムを分離する containerfs という 3 つ目の区分もありますが、これは次の 1.2 で別に説明します。 1.2 containerfs containerfs は「imagefs からさらに Writable Layer 用のファイルシステムを分離した場合だけ意味を持つ」ものです (KEP-4191、KubeletSeparateDiskGC feature gate)。分離していなければ、containerfs は単一ファイルシステム構成では nodefs と、専用 imagefs がある構成では imagefs と同じデバイスを指します。 // In case of a single filesystem, containerfs=nodefs. // In case of a image filesystem, containerfs=imagefs. // We will override user settings and set to either imagefs or nodefs depending on configuration. (参考: pkg/kubelet/eviction/api/types.go#L31-L52) KubeletSeparateDiskGC は 1.29 で Alpha、1.31 で Beta・デフォルト有効になっています。 {Version: version.MustParse("1.31"), Default: true, PreRelease: featuregate.Beta} (参考: pkg/features/kube_features.go#L1803-L1806) containerfs の閾値はユーザーが個別に設定することはできません。単一ファイルシステム構成では nodefs と同じ値、専用イメージ用ファイルシステムがある構成では imagefs と同じ値に、kubelet 側で強制的に上書きされます (参考: pkg/kubelet/eviction/helpers.go#L186-L253)。 ただし、この containerfs は containerd 構成では実際には使えません。containerd は ContainerFilesystems フィールドを実装しておらず、kubelet 側の実装にも対応コードが無いためです。設計の経緯と技術的な詳細は第8章で改めて説明します。 第2章: ノードの GC Disk Pressure の閾値に達する前に、kubelet はノードのディスク使用量を抑えるためにいくつかの GC の仕組みを持っています。対象領域・対象・実行タイミングをまず表にまとめ、それぞれの詳細を 2.1〜2.3 で順に説明します。 | GC | 対象領域 | 対象 | 実行タイミング | |:---|:---|:---|:---| | コンテナログのローテーション | nodefs | コンテナログ (/var/log/pods 配下) | 10 秒おきにチェック | | Image GC | imagefs | 未使用のコンテナイメージ | 5 分おきの定期実行 + Disk Pressure 発生時 | | Container GC | imagefs (専用 containerfs があれば containerfs) | 停止済みコンテナ・Pod sandbox | 1 分おきの定期実行 + Disk Pressure 発生時 | 2.1 コンテナログのローテーション (nodefs 側) nodefs 側で唯一の自動的な GC と呼べる仕組みが、コンテナログのローテーションです。disk-backed な emptyDir 自体には GC の仕組みはなく、Pod が削除されれば一緒に消えるだけです (サイズ超過時の扱いは第3章で扱います)。 コンテナログのローテーションは containerd などのランタイム側の機能ではなく、kubelet 自身が実装しています。 kubelet が起動時にログ管理用のコンポーネントをセットアップします。 // setup containerLogManager for CRI container runtime containerLogManager, err := logs.NewContainerLogManager( (参考: pkg/kubelet/kubelet.go#L749-L761) ローテーション自体は「ファイルをリネームしてから CRI 経由でランタイムにログファイルの再オープンを依頼する」という手順です。 rotated := fmt.Sprintf("%s.%s", log, timestamp) if err := c.osInterface.Rename(log, rotated); ... if err := c.runtimeService.ReopenContainerLog(ctx, id); err != nil { (参考: pkg/kubelet/logs/container_log_manager.go#L414-L436) デフォルト値は次のとおりです。 obj.ContainerLogMaxSize = "10Mi" obj.ContainerLogMaxFiles = ptr.To[int32](5) obj.ContainerLogMaxWorkers = ptr.To[int32](1) obj.ContainerLogMonitorInterval = &metav1.Duration{Duration: 10 * time.Second} (参考: defaults.go#L256-L267) | 設定 | デフォルト値 | 意味 | |:---|:---|:---| | containerLogMaxSize | 10Mi | 1 ファイルあたりの上限サイズ | | containerLogMaxFiles | 5 | コンテナごとに保持するログファイル数 | | containerLogMaxWorkers | 1 | ローテーションを並行実行するワーカー数 | | containerLogMonitorInterval | 10s | ローテーション対象をチェックする間隔 | ここが log rotation を過信できない理由です。ローテーションのチェックは 10 秒おきに、実行中の各コンテナのログファイルサイズを個別に確認する形で行われます。 path := resp.GetStatus().GetLogPath() info, err := c.osInterface.Stat(path) ... if info.Size() < c.policy.MaxSize { ... return } (参考: pkg/kubelet/logs/container_log_manager.go#L240-L263) KubeletConfiguration の中を見ても、「ノード全体で保持できるログの合計サイズ」に相当する設定項目はありません。containerLogMaxWorkers は並行度の設定であって、容量の上限ではありません。つまり、1 コンテナあたりの上限は MaxSize × (MaxFiles - 2) 程度に抑えられていても、コンテナの数が増えれば増えるほど、ノード全体でのログ使用量は際限なく増えていきます。 // Other than those 2 files, we can have at most MaxFiles-2 rotated log files. maxRotatedFiles := c.policy.MaxFiles - 2 (参考: pkg/kubelet/logs/container_log_manager.go#L351-L371) さらに、チェックは 10 秒に 1 回のポーリングなので、その間に MaxSize を大きく超えて書き込まれる余地もあります。書き込み時点でブロックするような、ハードなキャップではありません。 大量の短命 Pod (Job や CronJob) を高頻度に流す環境では、1 コンテナあたりのログは小さくても、コンテナの総数が多いためにログだけで nodefs を圧迫する、という事象が起こり得ます。Job 中心の運用ではこの圧迫に気づきにくいので、注意が必要です。 Disk Pressure によるコンテナ削除で kubectl logs が失敗するケースもあり、その原因調査については以下で記事を書いたので、そちらも参考にしてください。 参考: Kubernetes での "unable to retrieve container logs" エラーの原因調査 なお、Pod が evict された場合はログも一緒に失われます。 > If a pod is evicted from the node, all corresponding containers are also evicted, along with their logs. (参考: logging.md#L121-L122) Disk Pressure のトラブルシュート中に kubectl logs でエビデンスを取ろうとしても、対象の Pod がすでに evict されていて取得できない、という事態が起こりやすいのはこのためです。ログを長期保持したい場合は、Loki などの外部ロギングバックエンドへ転送しておくことが実質的な対策になります。 ログローテーションの仕組みやログ収集エージェント (Fluent Bit、OpenTelemetry Collector、Vector など) の実装の違いについては、以前別の記事にまとめているので、そちらも参考にしてください。 参考: Kubernetesのログ入門 2.2 Image GC (imagefs 側) 公式ドキュメントでも Garbage collection of unused containers and images としてまとめられています。 reclaim function としての Image GC は、次のシンプルなインターフェースを実装しています。 // ImageGC is responsible for performing garbage collection of unused images. type ImageGC interface { // DeleteUnusedImages deletes unused images. DeleteUnusedImages(ctx context.Context) error } (参考: pkg/kubelet/eviction/types.go#L83-L87) Image GC は、5 分おきの定期実行によって imagefs の使用量を普段から抑えています。 // ImageGCPeriod is the period for performing image garbage collection. ImageGCPeriod = 5 * time.Minute (参考: pkg/kubelet/kubelet.go#L229-L232) 定期実行は閾値ベースです。使用率が HighThresholdPercent (デフォルト 85%) を超えたら、LowThresholdPercent (デフォルト 80%) まで減らそうとします。 usagePercent := 100 - int(available*100/capacity) if usagePercent >= im.policy.HighThresholdPercent { amountToFree := capacity*int64(100-im.policy.LowThresholdPercent)/100 - available ... if freed < amountToFree { // This usually means the disk is full for reasons other than container images, such as logs, volumes, or other files. (参考: pkg/kubelet/images/image_gc_manager.go#L349-L418) if obj.ImageGCHighThresholdPercent == nil { // default is below docker's default dm.min_free_space of 90% obj.ImageGCHighThresholdPercent = ptr.To[int32](85) } if obj.ImageGCLowThresholdPercent == nil { obj.ImageGCLowThresholdPercent = ptr.To[int32](80) } (参考: defaults.go#L155-L161) 削除対象は最終使用時刻の古い順 (LRU) です。実行中コンテナが使っているイメージや、ランタイム側で pin されたイメージは除外されます。 // Queries all of the image records and arranges them in a slice of evictionInfo, // sorted based on last time used, ignoring images pinned by the runtime. ... if isImageUsed(image, imagesInUse) { ... continue } // Check if image is pinned, prevent garbage collection if record.pinned { ... continue (参考: pkg/kubelet/images/image_gc_manager.go#L548-L593) 削除には ImageMinimumGCAge (デフォルト 2 分) の猶予もあります。「最近 pull したばかりのイメージがすぐに使われる可能性」を考慮したものです。 obj.ImageMinimumGCAge = metav1.Duration{Duration: 2 * time.Minute} (参考: defaults.go#L152-L154) さらに ImageMaximumGCAge という「使われていなくても一定期間で削除する」設定もあります。デフォルトは 0s (無効) です。 > imageMaximumGCAge is the maximum age an image can be unused before it is garbage collected. The default of this field is "0s", which disables this field--meaning images won't be garbage collected based on being unused for too long. (参考: types.go#L365-L370) CI/CD やバッチジョブのように、git SHA ごとのユニークタグなど 再利用されないイメージ を大量に生成するクラスタでは、imageMaximumGCAge を設定して「使われていないが HighThresholdPercent に達していないので居座り続けているイメージ」を定期的に掃除する、という運用が選択肢になります。効くかどうかを左右するのはコンテナの寿命そのものではなく、生成されたイメージタグがその後再利用されるかどうかです。 ここまでの Image GC の設定は、いずれも KubeletConfiguration のフィールドとして指定できます。 apiVersion: kubelet.config.k8s.io/v1beta1 kind: KubeletConfiguration imageGCHighThresholdPercent: 85 imageGCLowThresholdPercent: 80 imageMinimumGCAge: 2m imageMaximumGCAge: 0s なお Image GC には、この定期実行とは別に Disk Pressure 発生時に閾値を無視して全件削除を試みる経路 もあります。これは Disk Pressure 発生時特有の挙動なので、第5章で改めて説明します。 2.3 Container GC (imagefs/containerfs 側) reclaim function としての Container GC は、次のインターフェースを実装しています。 // ContainerGC is responsible for performing garbage collection of unused containers. type ContainerGC interface { // DeleteAllUnusedContainers deletes all unused containers, even those that belong to pods that are terminated, but not deleted. DeleteAllUnusedContainers(ctx context.Context) error } (参考: pkg/kubelet/eviction/types.go#L89-L93) Container GC は、Image GC とは別に 1 分おきの定期実行でも動いています。これは Disk Pressure の有無とは無関係な、kubelet 起動時からの独立したループです。 // ContainerGCPeriod is the period for performing container garbage collection. ContainerGCPeriod = time.Minute go wait.UntilWithContext(ctx, func(ctx context.Context) { if err := kl.containerGC.GarbageCollect(ctx); err != nil { ... } }, ContainerGCPeriod) (参考: pkg/kubelet/kubelet.go#L229、pkg/kubelet/kubelet.go#L1722-L1736) この定期実行 (GarbageCollect) と、Disk Pressure 発生時にも呼ばれる DeleteAllUnusedContainers は、同じ内部処理を呼びますが、最後の引数 (evictNonDeletedPods) だけが異なります。 func (cgc *realContainerGC) GarbageCollect(ctx context.Context) error { return cgc.runtime.GarbageCollect(ctx, cgc.policy, cgc.sourcesReadyProvider.AllReady(), false) } func (cgc *realContainerGC) DeleteAllUnusedContainers(ctx context.Context) error { ... return cgc.runtime.GarbageCollect(ctx, cgc.policy, cgc.sourcesReadyProvider.AllReady(), true) } (参考: pkg/kubelet/container/container_gc.go#L81-L88) evictNonDeletedPods が false の通常の定期実行は、すでに完全に削除された Pod に属するコンテナ・sandbox だけを対象にします。一方 true になる DeleteAllUnusedContainers は、まだ API サーバー上では削除しきれていないが Pod のランタイム (コンテナ) 自体は終了させてよい状態の Pod も対象に含めるため、より踏み込んだ削除になります。 if cgc.podStateProvider.ShouldPodContentBeRemoved(key.uid) || (evictNonDeletedPods && cgc.podStateProvider.ShouldPodRuntimeBeRemoved(key.uid)) { (参考: pkg/kubelet/kuberuntime/kuberuntime_gc.go#L239) Container GC 自体の挙動 (MinAge/MaxPerPodContainer/MaxContainers) には、KubeletConfiguration に対応するフィールドがありません。設定できるのは --minimum-container-ttl-duration/--maximum-dead-containers-per-container/--maximum-dead-containers という非推奨の CLI フラグだけで、いずれも「代わりに --eviction-hard/--eviction-soft を使ってください」という非推奨メッセージ付きで将来的に削除される予定です。 fs.DurationVar(&f.MinimumGCAge.Duration, "minimum-container-ttl-duration", f.MinimumGCAge.Duration, "Minimum age for a finished container before it is garbage collected. Examples: '300ms', '10s' or '2h45m'") fs.MarkDeprecated("minimum-container-ttl-duration", "Use --eviction-hard or --eviction-soft instead. Will be removed in a future version.") (参考: cmd/kubelet/app/options/options.go#L310-L315) つまり Container GC は、Image GC のように細かく調整するものではなく、eviction の hard/soft threshold 側で全体のディスク使用量を制御する という設計になっています。Container GC 自体のチューニングを試みるより、第4章・第7章で説明する eviction threshold の設定を見直すほうが筋が良い、ということになります。 ephemeral-storage ここまでで見た nodefs/imagefs の使用量のうち、Pod 単位で管理されるのが ephemeral-storage です。その内訳と、各領域の詳細を見ていきます。 第3章: ephemeral-storage として計測されるもの Pod の ephemeral-storage は、次の要素の合計として計算されます。 > * Writing to the container's writable layer (rootfs), container images, or both. > * Writing to local emptyDir volumes. > * The Pod's own logs (usually stored under /var/log/pods). > * System files managed by Kubernetes that are mapped into the Pod, such as /etc/hosts. (参考: manage-resources-containers.md#L409-L418) 実装側でも、この合計は 1 か所の関数 calcEphemeralStorage にまとまっています。 for _, container := range containers { addContainerUsage(result, &container, isCRIStatsProvider) } for _, volume := range volumes { result.UsedBytes = addUsage(result.UsedBytes, volume.FsStats.UsedBytes) ... } if podLogStats != nil { ... } if etcHostsStats != nil { ... } (参考: pkg/kubelet/stats/helper.go#L434-L462) コンテナ側は Writable Layer (rootfs) とログを足し合わせます。 if rootFs := container.Rootfs; rootFs != nil { ... stat.UsedBytes = addUsage(stat.UsedBytes, rootFs.UsedBytes) if logs := container.Logs; logs != nil { stat.UsedBytes = addUsage(stat.UsedBytes, logs.UsedBytes) (参考: pkg/kubelet/stats/helper.go#L464-L477) ここで重要なのは、この合計に渡される volume の一覧が「local ephemeral storage」に分類されたものだけという点です。emptyDir はデフォルトの medium: "" (disk-backed) の場合だけこの分類に入り、medium: Memory (tmpfs) は入りません。 return volume.GitRepo != nil || (volume.EmptyDir != nil && volume.EmptyDir.Medium == v1.StorageMediumDefault) || volume.ConfigMap != nil (参考: pkg/volume/util/util.go#L494-L498) 公式ドキュメントも同じことを明記しています。 > The kubelet tracks tmpfs emptyDir volumes as container memory use, rather than as local ephemeral storage. (参考: ephemeral-storage.md#L104-L107) つまり「emptyDir を tmpfs にすれば ephemeral-storage の枠を消費しない」というのは事実ですが、その代わりコンテナのメモリ使用量を圧迫することになります。ディスクの逼迫をメモリの逼迫にすり替えているだけ、という理解が正確です。 ここまでの内容を、Pod の ephemeral-storage 使用量の対象になるかどうかで一覧にすると次のとおりです。 | 種類 | ephemeral-storage に含まれるか | 計測方法 | 備考 | |:---|:---:|:---|:---| | コンテナの Writable Layer (rootfs) | ○ | du (rootfs 使用量) | イメージ本体ではなく、コンテナが書き込んだ差分のみ。専用 imagefs 構成では imagefs 側の使用量として扱われる | | コンテナのログ | ○ | ログファイルのサイズ | 第2章 2.1 のローテーション設定 (containerLogMaxSize/containerLogMaxFiles) の対象 | | emptyDir (medium 未指定、disk-backed) | ○ | du (1 分ごとに集計) | IsLocalEphemeralVolume で local ephemeral storage に分類される | | emptyDir (medium: Memory、tmpfs) | ✕ (memory として計上) | tmpfs の使用量 = コンテナのメモリ使用量 | IsLocalEphemeralVolume の判定から除外される。カーネルの size= によるハードな上限は付くが、ephemeral-storage の request/limit には反映されない | | Pod のログディレクトリ (/var/log/pods 配下) | ○ | ログディレクトリの du | calcEphemeralStorage が個別に加算 | | /etc/hosts などシステムが Pod にマップするファイル | ○ | ファイルサイズ | calcEphemeralStorage が個別に加算 | | configMap ボリューム | ○ | du | IsLocalEphemeralVolume の対象。実際のデータ量はごく小さいことがほとんど | | PersistentVolumeClaim (PVC) 経由のボリューム | ✕ | (PV 側で管理) | IsLocalEphemeralVolume の対象外。ノードのローカルディスクを消費しない (hostPath 等を除く) | | コンテナイメージ本体 | ✕ (別枠で管理) | Image GC 側で管理 | Pod 単位の ephemeral-storage 使用量には計上されない。ノード全体では imagefs の空き容量を圧迫する | 「○」の行が pkg/kubelet/stats/helper.go の calcEphemeralStorage および pkg/volume/util.IsLocalEphemeralVolume (参考: pkg/volume/util/util.go#L494-L498) で加算・分類される対象、「✕」がそこから明示的に除外されるか、そもそも対象外の項目です。 3.1 計測方法は同じ、上限の強さが違う どちらも実体はホスト上のディレクトリで、使用量は du 相当の方法で計測されます。emptyDir のメトリクスプロバイダは明示的に du ベースです。 MetricsProvider: volume.NewMetricsDu(getPath(pod.UID, spec.Name(), plugin.host)) (参考: pkg/volume/emptydir/empty_dir.go#L182) この集計はデフォルトで 1 分ごとに行われます。 obj.VolumeStatsAggPeriod = metav1.Duration{Duration: time.Minute} (参考: defaults.go#L162-L163) つまり disk-backed な emptyDir や Writable Layer に sizeLimit (あるいは ephemeral-storage の limit) を設定していても、それは カーネルレベルで書き込みをブロックするものではありません。kubelet が定期的に使用量を測定し、超過を見つけたら Pod を eviction する、という「事後的な」仕組みです。ソースコードのコメントにもその旨がはっきり書かれています。 // If setting up the quota fails, just log a message but don't actually error out. // We'll use the old du mechanism in this case, at least until we support // enforcement. (参考: pkg/volume/emptydir/empty_dir.go#L300-L306) 実際に超過を検知した場合の Eviction Manager 側の処理は次のとおりです。 size := source.EmptyDir.SizeLimit used := podVolumeUsed[pod.Spec.Volumes[i].Name] if used != nil && size != nil && size.Sign() == 1 && used.Cmp(*size) > 0 { // the emptyDir usage exceeds the size limit, evict the pod if m.evictPod(logger, pod, immediateEvictionGracePeriodSeconds, ... (参考: pkg/kubelet/eviction/eviction_manager.go#L542-L569) 一方、medium: Memory (tmpfs) の emptyDir は挙動が異なります。まずマウント自体が tmpfs として作られます。 case ed.medium == v1.StorageMediumMemory: err = ed.setupTmpfs(dir) (参考: pkg/volume/emptydir/empty_dir.go#L286-L295) sizeLimit はそのまま tmpfs マウントオプションの size= になります。これは Linux カーネルが強制する ハードな上限 です。 if ed.sizeLimit != nil && ed.sizeLimit.Value() > 0 { options = append(options, fmt.Sprintf("size=%d", ed.sizeLimit.Value())) } if noswapSupported { options = append(options, swap.TmpfsNoswapOption) } (参考: pkg/volume/emptydir/empty_dir.go#L601-L612) 3.2 sizeLimit を設定しなかったら何が上限になるのか disk-backed な emptyDir で sizeLimit を省略した場合、実質的な上限は「Pod の ephemeral-storage limit (設定していれば)」か「ノードのディスク容量そのもの」になります。 これに対し medium: Memory の場合は少し事情が違います。sizeLimit を省略しても、tmpfs のマウントサイズは必ず計算されます。デフォルトではノードの allocatable memory、Pod にメモリ limit が設定されていればその値との小さいほうです。 // size limit defaults to node allocatable (pods can't consume more memory than all pods) sizeLimit := nodeAllocatableMemory ... podMemoryLimit ... if podMemoryLimit.Cmp(zero) > 0 && podMemoryLimit.Cmp(*sizeLimit) < 1 { sizeLimit = podMemoryLimit } // volume local size is used if and only if less than what pod could consume (参考: pkg/volume/emptydir/empty_dir.go#L128-L168) 公式ドキュメントも同様の注意を書いています。 > If you do not specify a sizeLimit for an emptyDir volume, that volume may consume up to that pod's memory limit ... If you do not set a memory limit, the pod has no upper bound on memory consumption, and can consume all available memory on the node. ... It is possible to create any number of emptyDirs that could potentially consume all available memory on the node (参考: manage-resources-containers.md#L360-L371) つまり sizeLimit は「メモリ limit または node allocatable memory を超えない」という意味では上限が付きますが、tmpfs emptyDir を複数個作ると、それぞれが独立に (メモリ limit まで) 確保できてしまうため、合計ではノードのメモリを使い切る可能性があります。1 個あたりの上限であって、Pod 全体・ノード全体の上限ではない点に注意が必要です。 3.3 medium: Memory でも eviction の対象から完全に外れるわけではない ここは調査前の想定と違っていた点です。「tmpfs はカーネルが上限を強制するのだから、kubelet 側の polled チェックはスキップされるはず」と考えていましたが、実際にそのスキップが有効になるのは InPlacePodVerticalScalingMemoryBackedVolumes という Alpha feature gate が有効な場合だけで、これはデフォルトで無効です。 if utilfeature.DefaultFeatureGate.Enabled(features.InPlacePodVerticalScalingMemoryBackedVolumes) && source.EmptyDir.Medium == v1.StorageMediumMemory { // Memory-backed emptyDir volumes are backed by tmpfs, which has its size limit // enforced by the kernel. Eviction manager enforcement is not needed. continue } (参考: pkg/kubelet/eviction/eviction_manager.go#L549-L553) {Version: version.MustParse("1.37"), Default: false, PreRelease: featuregate.Alpha} (参考: pkg/features/kube_features.go#L1717-L1719) つまりデフォルトの設定では、medium: Memory の emptyDir は「tmpfs の size= によるカーネルレベルの上限」と「kubelet の 1 分おきの polled チェックによる eviction」の両方の対象になります。実務上はどちらにしても sizeLimit を明示しておくべき、という結論になります。 3.4 コンテナのログ コンテナのログと Pod のログディレクトリも、前掲の表のとおり ephemeral-storage に計上されます。ただしその上限や自動的な削除 (ローテーション) の仕組みは、ディスクの区分そのものと合わせて第2章 2.1 で説明したとおりです。ここでは ephemeral-storage の集計対象になる、という点だけ押さえておけば十分です。 3.5 kubelet の Summary API で実際の使用量を見る ここまでの計算結果は、kubelet 自身が /stats/summary という HTTP エンドポイントで公開しています。Eviction Manager が内部で参照しているのと同じ Summary Provider の出力で、3 で見た calcEphemeralStorage の計算結果がそのまま含まれています。 endpoints := []struct { path string handler restful.RouteFunction }{ {"/summary", h.handleSummary}, } (参考: pkg/kubelet/server/stats/handler.go#L110-L121) Pod ごとの ephemeral-storage 使用量は PodStats.EphemeralStorage フィールドとして返されます。 // EphemeralStorage reports the total filesystem usage for the containers and emptyDir-backed volumes in the measured Pod. // +optional EphemeralStorage *FsStats `json:"ephemeral-storage,omitempty"` (参考: staging/src/k8s.io/kubelet/pkg/apis/stats/v1alpha1/types.go#L145-L147) このフィールドには、3 で見た calcEphemeralStorage の戻り値がそのまま代入されます。 s.EphemeralStorage = calcEphemeralStorage(s.Containers, ephemeralStats, rootFsInfo, logStats, etcHostsStats, isCRIStatsProvider) (参考: pkg/kubelet/stats/helper.go#L497-L520) 実際に確認するには、kube-apiserver の node proxy 経由で叩くのが手軽です。 $ kubectl get --raw "/api/v1/nodes/<ノード名>/proxy/stats/summary" \ | jq '.pods[] | select(.podRef.name=="<Pod名>") | {name: .podRef.name, ephemeralStorage: .["ephemeral-storage"]}' { "name": "myapp-6f9d8c9c9b-abcde", "ephemeralStorage": { "time": "2026-10-13T12:00:00Z", "availableBytes": 32212254720, "capacityBytes": 53687091200, "usedBytes": 10485760, "inodesFree": 3276500, "inodes": 3276800, "inodesUsed": 300 } } usedBytes が、3 で説明した「コンテナの Writable Layer + ログ + disk-backed emptyDir + /etc/hosts 等」の合計値です。Pod の ephemeral-storage request/limit と見比べることで、request を超過している Pod をノード全体の Eviction より前に特定できます。 Disk Pressure について ノードのディスク領域と ephemeral-storage の内訳を踏まえて、ここからは実際に Disk Pressure が発生したときに何が起こるかを見ていきます。判定条件から Pod Eviction に至るまでの実際の処理順を、閾値判定 (第4章) → ノードレベルの回収 (第5章) → Pod の eviction 優先順位と退避 (第6章) の順に追っていきます。 第4章: Disk Pressure の判定閾値 Disk Pressure が発生してから Pod が evict されるまでの判定条件と処理順を、詳細に入る前に一枚の表にまとめます。各ステップの詳細は本章から第6章で順に説明します。 | 順序 | フェーズ | 判定条件 | 処理内容 | |:---:|:---|:---|:---| | 1 | 監視 | 10 秒ごと (固定値) | 6 つの disk signal (nodefs.available/inodesFree、imagefs.available/inodesFree、containerfs.available/inodesFree) を評価 | | 2 | 閾値判定 | signal が hard/soft threshold を下回る (デフォルト hard: nodefs.available<10%、imagefs.available<15%、nodefs.inodesFree<5%、imagefs.inodesFree<5%。soft はデフォルトなし) | 該当する threshold を eviction priority 順にソート | | 3 | NodeCondition 反映 | 閾値超過が PressureTransitionPeriod (デフォルト 5 分) の間観測され続ける | Node の DiskPressure Condition を True に設定 | | 4 | Taint 付与・Admission 拒否 | DiskPressure=True | NodeLifecycleController が node.kubernetes.io/disk-pressure (NoSchedule) を付与 (kubelet 自身ではない)。kubelet は非 Critical Pod の Admission をその場で拒否 | | 5 | ノードレベル回収 | 閾値を満たした signal がある | レイアウト依存の reclaim function (Container GC → Image GC など、詳細は第5章) を実行し、統計を再取得して再評価 | | 6a | 回収で解消 | 再評価で閾値を下回った | Pod Eviction は行わず終了 (次の 10 秒サイクルへ) | | 6b | 回収でも解消しない | 再評価でもまだ閾値超過 | Pod Eviction ステップへ進む | | 7 | Eviction 優先順位の決定 | — | (1) ephemeral-storage の request を超過しているか (2) Priority (3) request を超えた使用量、の順でソート (詳細は第6章) | | 8 | Eviction 実行 | — | 優先順位が最も高い Pod 1 つだけ を evict。grace period は hard threshold なら 1 秒、soft threshold なら MaxPodGracePeriodSeconds か Pod 自身の terminationGracePeriodSeconds の小さいほう | | (別経路) | Pod/コンテナ単位の limit 違反 | Pod の ephemeral-storage limit 超過、または emptyDir の sizeLimit 超過 | 上記のノード全体の閾値判定より 先に 評価され、条件を満たせば同じループで複数 Pod を evict できる (grace period は 1 秒) | 4.1 ディスク関連の eviction signal kubelet はこのバージョンで 6 つのディスク関連 signal を持っています。 SignalNodeFsAvailable Signal = "nodefs.available" SignalNodeFsInodesFree Signal = "nodefs.inodesFree" SignalImageFsAvailable Signal = "imagefs.available" SignalImageFsInodesFree Signal = "imagefs.inodesFree" SignalContainerFsAvailable Signal = "containerfs.available" SignalContainerFsInodesFree Signal = "containerfs.inodesFree" (参考: pkg/kubelet/eviction/api/types.go#L31-L52) 4.2 デフォルトの hard threshold Linux ノードのデフォルト値は次のとおりです。 var DefaultEvictionHard = map[string]string{ "memory.available": "100Mi", "nodefs.available": "10%", "nodefs.inodesFree": "5%", "imagefs.available": "15%", "imagefs.inodesFree": "5%", } (参考: pkg/kubelet/eviction/defaults_linux.go#L21-L28) | Signal | Linux デフォルト値 | |:---|:---| | memory.available | 100Mi | | nodefs.available | 10% | | nodefs.inodesFree | 5% | | imagefs.available | 15% | | imagefs.inodesFree | 5% | containerfs.* は前述のとおりユーザーが直接設定できず、レイアウトに応じて nodefs か imagefs の値がそのまま適用されます。 なお KubeletConfiguration の API ドキュメントコメントには imagefs.inodesFree が抜けています。 > Default: memory.available: "100Mi" nodefs.available: "10%" nodefs.inodesFree: "5%" imagefs.available: "15%" (参考: types.go#L574-L583) 実際のコード上のデフォルト値には imagefs.inodesFree: "5%" が含まれているので、ドキュメントコメントが更新漏れになっていると考えられます。 これらのデフォルト値は SetDefaults ではなく cmd/kubelet/app/server.go 側で、EvictionHard が未設定のときにまとめて適用されます。個別に値を設定した場合、MergeDefaultEvictionSettings (デフォルト false) が true でない限り、指定していない signal は 0 扱い、つまり実質無効になります。 if kc.EvictionHard == nil { kc.EvictionHard = eviction.DefaultEvictionHard } else if kc.MergeDefaultEvictionSettings { for k, v := range eviction.DefaultEvictionHard { if _, exists := kc.EvictionHard[k]; !exists { kc.EvictionHard[k] = v (参考: cmd/kubelet/app/server.go#L469-L480) --eviction-hard を一部だけ上書きしたつもりが他の signal を無効化してしまう、というのはよくある事故のパターンです。個別上書きをする場合は MergeDefaultEvictionSettings: true を検討してください。 4.3 soft threshold はデフォルトなし soft threshold・grace period・minimum reclaim には、いずれもデフォルト値がありません。 > EvictionSoft Default: nil > EvictionSoftGracePeriod Default: nil > EvictionPressureTransitionPeriod Default: "5m" > EvictionMaxPodGracePeriod Default: 0 > EvictionMinimumReclaim Default: nil (参考: types.go#L584-L612) soft threshold には grace period の指定が必須で、指定しないと kubelet が起動時にエラーを返します。 return nil, fmt.Errorf("grace period must be specified for the soft eviction threshold %v", signal) (参考: pkg/kubelet/eviction/helpers.go#L147-L154) grace period が 0 の threshold が hard threshold として扱われます。 // isHardEvictionThreshold returns true if eviction should immediately occur func isHardEvictionThreshold(threshold evictionapi.Threshold) bool { return threshold.GracePeriod == time.Duration(0) } (参考: pkg/kubelet/eviction/helpers.go#L1141-L1144) 4.4 監視間隔と NodeCondition への反映 Disk Pressure の判定は 10 秒ごとに行われます。この値は KubeletConfiguration のフラグではなく、コード上の固定値です。 // Period for performing eviction monitoring. // ensure this is kept in sync with internal cadvisor housekeeping. evictionMonitoringPeriod = time.Second * 10 (参考: pkg/kubelet/kubelet.go#L195-L197) :::note warn 公式ドキュメントは「housekeeping-interval という設定可能な値で、デフォルト 10s」と説明していますが、実際にはコード上の固定 10 秒です (参考: node-pressure-eviction.md#L265-L268)。設定で変更できるものではない、という点は覚えておいたほうがよさそうです。この点については修正 PR (kubernetes/website#58036) を提出しています。 ::: すべてのディスク関連 signal は NodeDiskPressure にマッピングされています。 signalToNodeCondition[evictionapi.SignalImageFsAvailable] = v1.NodeDiskPressure signalToNodeCondition[evictionapi.SignalContainerFsAvailable] = v1.NodeDiskPressure signalToNodeCondition[evictionapi.SignalNodeFsAvailable] = v1.NodeDiskPressure signalToNodeCondition[evictionapi.SignalImageFsInodesFree] = v1.NodeDiskPressure signalToNodeCondition[evictionapi.SignalNodeFsInodesFree] = v1.NodeDiskPressure signalToNodeCondition[evictionapi.SignalContainerFsInodesFree] = v1.NodeDiskPressure (参考: pkg/kubelet/eviction/helpers.go#L78-L95) 判定された Condition は EvictionPressureTransitionPeriod (デフォルト 5 分) の間保持されます。フラッピングを防ぐための仕組みです。 // node conditions report true if it has been observed within the transition period window nodeConditions = nodeConditionsObservedSince(nodeConditionsLastObservedAt, m.config.PressureTransitionPeriod, now) (参考: pkg/kubelet/eviction/eviction_manager.go#L337-L353) 4.5 DiskPressure が True になると何が起こるか DiskPressure が True になると、大きく 2 つのことが起こります。 1点目は、kubelet 自身がその場で「Critical でない Pod」の Admission を拒否するようになることです。Admission とは、スケジューラが Node を決めた後、kubelet が実際にコンテナを起動する直前に行う「本当にこの Pod をこのノードで動かしていいか」の最終チェックです。スケジューラの判断時点とkubeletが処理する時点の間にノードの状態が変わっていないかを、ノード上であらためて確認する役割を持っています。 // Admit Critical pods even under resource pressure since they are required for system stability. ... // Conditions other than memory pressure reject all pods ... return lifecycle.PodAdmitResult{ Admit: false, Reason: Reason, Message: fmt.Sprintf(nodeConditionMessageFmt, m.nodeConditions), (参考: pkg/kubelet/eviction/eviction_manager.go#L155-L183) 2点目は、node.kubernetes.io/disk-pressure (NoSchedule) という Taint が付与されることです。この Taint を付けているのは kubelet ではなく、kube-controller-manager 内の NodeLifecycleController です。 // TaintNodeDiskPressure will be added when node has disk pressure // and removed when node has enough disk. TaintNodeDiskPressure = "node.kubernetes.io/disk-pressure" (参考: well_known_taints.go#L37-L39) nodeConditionToTaintKeyStatusMap = map[v1.NodeConditionType]map[v1.ConditionStatus]string{ ... v1.NodeDiskPressure: { v1.ConditionTrue: v1.TaintNodeDiskPressure, }, (参考: pkg/controller/nodelifecycle/node_lifecycle_controller.go#L87-L101) kubelet は Node Status に Condition を書き込むだけで、Taint への変換は kube-controller-manager 側の別コンポーネントの責務です。役割が分かれていることは、トラブルシュートの際に「kubelet のログだけ見ていても Taint が付いた理由が分からない」ことにつながるので覚えておくとよいです。 第5章: 閾値超過後の挙動 — ノードレベル回収 → Pod Eviction 5.1 Pod を eviction する前に、まずノードレベルの回収を試みる synchronize ループの中心的な処理は次のとおりです。閾値を満たす signal を優先度順にソートしたあと、まずノードレベルの回収を試み、それで十分ならそこで終了します。 sort.Sort(byEvictionPriority(thresholds)) ... m.recorder.Eventf(m.nodeRef, v1.EventTypeWarning, "EvictionThresholdMet", "Attempting to reclaim %s", resourceToReclaim) // check if there are node-level resources we can reclaim to reduce pressure before evicting end-user pods. if m.reclaimNodeLevelResources(ctx, thresholdToReclaim.Signal, resourceToReclaim) { logger.Info("Eviction manager: able to reduce resource pressure without evicting pods.", ...) return nil, nil } logger.Info("Eviction manager: must evict pod(s) to reclaim", ...) (参考: pkg/kubelet/eviction/eviction_manager.go#L383-L400) reclaimNodeLevelResources は、signal ごとに登録されたreclaim functionを順番に実行し、そのあと改めて統計を取得し直して閾値を再評価します。1 回でも閾値を下回れば Pod Eviction はスキップされます。 // reclaimNodeLevelResources attempts to reclaim node level resources. returns true if thresholds were satisfied and no pod eviction is required. ... for _, nodeReclaimFunc := range nodeReclaimFuncs { // attempt to reclaim the pressured resource. if err := nodeReclaimFunc(ctx); err != nil { ... if len(nodeReclaimFuncs) > 0 { summary, err := m.summaryProvider.Get(ctx, true) ... thresholds := thresholdsMet(logger, m.config.Thresholds, observations, true) ... if len(thresholds) == 0 { return true } ... return false (参考: pkg/kubelet/eviction/eviction_manager.go#L480-L512) reclaim functionの実体は、第2章 2.2・2.3 で見た Image GC Manager (ImageGC.DeleteUnusedImages) と Container GC (ContainerGC.DeleteAllUnusedContainers) です。 つまり 「Disk Pressure = 即 Pod Eviction」ではなく、まず Image GC・Container GC で回収を試みる というのが基本の流れです。冒頭の TL;DR にも書きましたが、ここが本記事のいちばんの要点です。 このうち Image GC は、第2章 2.2 で見た 5 分おきの定期実行とは別に、Disk Pressure 発生時にも呼び出されます。この経路では閾値を無視して、削除できる不要イメージを可能な限り全部削除しようとします。 func (im *realImageGCManager) DeleteUnusedImages(ctx context.Context) error { logger.Info("Attempting to delete unused images") ... remainingImages, _, err := im.freeSpace(ctx, math.MaxInt64, freeTime, images) (参考: pkg/kubelet/images/image_gc_manager.go#L458-L475) 「Image GC・Container GC はもともと定期実行されているのだから、Disk Pressure 専用の呼び出しは不要では」と思うかもしれませんが、実際には定期実行では拾えない範囲を埋める役割があります。 Container GC は、定期実行 (GarbageCollect) と Disk Pressure 時 (DeleteAllUnusedContainers) で、削除対象の判定条件そのものが異なります。 func (p *podWorkers) ShouldPodRuntimeBeRemoved(uid types.UID) bool { if status, ok := p.podSyncStatuses[uid]; ok { return status.IsTerminated() } ... } func (p *podWorkers) ShouldPodContentBeRemoved(uid types.UID) bool { if status, ok := p.podSyncStatuses[uid]; ok { return status.IsEvicted() || (status.IsDeleted() && status.IsTerminated()) } ... } (参考: pkg/kubelet/pod_workers.go#L704-L723) 定期実行が使う ShouldPodContentBeRemoved は「API サーバー上で Pod が削除されている」ことまで要求しますが、Disk Pressure 時だけ追加で効く ShouldPodRuntimeBeRemoved は「コンテナが終了している」だけで true になります。つまり、Job が完了したのに TTL 未設定で誰も削除していない Pod のように、「コンテナは終了しているが Pod 自体は API サーバー上に残っている」ケースのコンテナは、1 分おきの定期実行では永遠に掃除されません。Disk Pressure 時の呼び出しだけが、この分のディスクを回収できます。 Image GC も、定期実行と Disk Pressure 時で削除する量とタイミングが異なります。 // 定期実行: 低閾値まで戻すのに必要な分だけ削除 amountToFree := capacity*int64(100-im.policy.LowThresholdPercent)/100 - available remainingImages, freed, err := im.freeSpace(ctx, amountToFree, freeTime, images) (参考: pkg/kubelet/images/image_gc_manager.go#L395-L404) 定期実行は「HighThresholdPercent を LowThresholdPercent まで戻す」という決まった量しか削除しません。一方 Disk Pressure 時は math.MaxInt64 を指定し、削除可能なイメージを量の上限なく削除しようとします (前掲のコード)。タイミングも、定期実行は最大 5 分待たされるのに対し、Disk Pressure 時は 10 秒おきの eviction monitoring サイクルの中で即座に動きます。ディスクが急速に埋まる状況では、この「量の上限を設けない」「5 分待たずにすぐ動く」という 2 点が、Pod Eviction まで進まずに済むかどうかを左右します。 5.2 どの signal で、どのreclaim functionが動くか (レイアウト依存) ただし、この「回収を試みる」処理はファイルシステムのレイアウトによって内容が変わり、専用 imagefs がある構成では nodefs に対するreclaim functionが空になります。 signalToReclaimFunc := map[evictionapi.Signal]nodeReclaimFuncs{} (挙動の分岐は pkg/kubelet/eviction/helpers.go#L1201-L1237 に実装されています) | レイアウト | nodefs.* のreclaim function | imagefs.* のreclaim function | containerfs.* のreclaim function | |:---|:---|:---|:---| | 単一ファイルシステム | Container GC → Image GC | (nodefs と同じ扱い) | (nodefs と同じ扱い) | | imagefs を分離 (containerfs 分離なし) | なし (空リスト) | Container GC → Image GC | imagefs と同じ | | imagefs と containerfs を分離 | Container GC のみ | Image GC のみ | Container GC のみ | 専用 imagefs 構成でのコード上のコメントは次のとおりです。 // with an imagefs, nodefs pressure should just delete logs signalToReclaimFunc[evictionapi.SignalNodeFsAvailable] = nodeReclaimFuncs{} ... // with an imagefs, imagefs pressure should delete unused images signalToReclaimFunc[evictionapi.SignalImageFsAvailable] = nodeReclaimFuncs{containerGC.DeleteAllUnusedContainers, imageGC.DeleteUnusedImages} (参考: pkg/kubelet/eviction/helpers.go#L1205-L1213) コメントには "nodefs pressure should just delete logs" とありますが、実際に登録されるreclaim functionは空リストです。つまり 専用 imagefs 環境で nodefs (ログや emptyDir が積もった場合) が閾値を超えると、kubelet は何の自動回収も試みずにいきなり Pod Eviction に進みます。 :::note warn 公式ドキュメントはこのケースについて次のように説明しています。 > If the nodefs filesystem meets the eviction thresholds, the kubelet garbage collects dead pods and containers. If the imagefs filesystem meets the eviction thresholds, the kubelet deletes all unused images. (参考: node-pressure-eviction.md#L302-L336) しかし実装を見る限り、専用 imagefs 構成での nodefs 側にはreclaim functionが登録されていません。ドキュメントの記述と実装が一致していないように見えます。専用 imagefs を使っている環境では、nodefs 側 (ログ・emptyDir) の余裕を Image GC 頼みにせず、ログローテーションの設定や emptyDir の sizeLimit で自衛する必要がある、という結論になります。 ただし、全く何も起きないわけではありません。Container GC は ContainerGCPeriod (デフォルト 1 分) ごとに、Disk Pressure とは無関係な独立したループでも実行されており、そちらはすでに削除された Pod のログディレクトリ (/var/log/pods 配下) を掃除します。あくまで nodefs の閾値超過をきっかけにした追加の回収が行われない だけで、この定期清掃自体は専用 imagefs 構成でも変わらず動いています。 ::: 第6章: どの Pod が退避されるか — eviction の優先順位と reques

  4. This wandered me into a quick rabbit hole. Seems like both CRI-O github.com/cri-o/cri-o/security/advisories/GHSA-p… and Containerd github.com/containerd/containerd/security/advisor… have similar issues!

    7 likes
  5. Kubernetes 1.34 end of support: a pre-flight checklist for 1.35 --- title: "Kubernetes 1.34 end of support: a pre-flight checklist for 1.35" published: true description: "Kubernetes 1.34 goes end of life on 27 October 2026. A practical pre-flight checklist for 1.35 and 1.36: cgroup v2, containerd 2.x, APIs, ingress, skew policy and etcd backups." tags: kubernetes, devops, containers, sre canonical_url: agency42.se --- By Johan Kuylenstierna, Agency 42. Commands verified on a kind lab cluster (Kubernetes v1.34.0) on 11 Oct 2026. Drafted with AI assistance; reviewed and approved by Johan Kuylenstierna. On 27 October 2026, Kubernetes 1.34 reaches end of life. It has been in maintenance mode since 27 August, which already meant CVE fixes only. After the 27th it gets nothing at all. Unlike the Vogons' demolition notice, this one wasn't hidden in a filing cabinet on Alpha Centauri. The 1.35 release notes spelled out the hard parts back in December. In practice, the minor version bump itself is rarely what hurts. What hurts is everything around it that nobody has looked at since the cluster was built: a node image from 2021, a pinned containerd, an ingress controller that was "temporary". One more thing before the checklist. Kubernetes 1.35 is itself end of life on 28 February 2027. Treat 1.35 as a waypoint and 1.36 (supported until 28 June 2027) as the destination. That matters, because 1.36 is where containerd 1.x support ends. Pre-flight 1: know what your nodes actually run Agent Le'man doesn't trust inventory spreadsheets. He reads nodeInfo: kubectl get nodes -o custom-columns=NODE:.metadata.name,KUBELET:.status.nodeInfo.kubeletVersion,RUNTIME:.status.nodeInfo.containerRuntimeVersion,KERNEL:.status.nodeInfo.kernelVersion Every row tells you something. containerd://1.7.x means a runtime upgrade belongs in the plan. Kubelets more than one minor apart mean someone stopped halfway last time. And a 4.x kernel is a hint for the next check. On the kind lab cluster used for this article it looks like this: NODE KUBELET RUNTIME KERNEL lab-control-plane v1.34.0 containerd://2.1.3 6.12.94+ Pre-flight 2: cgroup v2 on every Linux node On each node: stat -fc %T /sys/fs/cgroup/ cgroup2fs means cgroup v2. tmpfs means cgroup v1. On the lab node: $ stat -fc %T /sys/fs/cgroup/ cgroup2fs cgroup v1 is deprecated as of 1.35, and the kubelet's failCgroupV1 setting now defaults to true. With default config, a 1.35 kubelet will not start on a cgroup v1 node. You can set failCgroupV1: false in the kubelet config to buy time, but it is a temporary escape hatch and full removal follows the deprecation policy. kubeadm is stricter still: its SystemVerification preflight check errors out on cgroup v1 during init, join and upgrade with a 1.35 kubelet. Moving to cgroup v2 needs kernel 5.8 or later and usually a newer OS image. Also check the software that reads cgroups directly. Old JVMs, Node.js before 20.3.0 and standalone cAdvisor before v0.43.0 can misread memory limits under v2. The upstream cgroup v2 page has the version list. Pre-flight 3: containerd 2.x before you go past 1.35 SIG Node designated 1.35 as the last Kubernetes release to support containerd 1.x. containerd 1.7 has also reached its own end of life (30 September 2026), so it no longer gets patches either. To see which nodes are affected, look for the kubelet_cri_losing_support metric. Alert on it existing at all, not on a specific version label: kubectl get --raw "/api/v1/nodes/<node>/proxy/metrics" | grep kubelet_cri_losing_support On a containerd 2.x node this returns nothing (grep exits 1). Empty output is the result you want. According to containerd's Kubernetes support matrix, Kubernetes 1.35 is tested with containerd 2.2.0+, 2.1.5+ and 1.7.28+, and 1.36 with 2.3.0+ and 2.2.0+. Note that containerd 2.2 goes end of life on 6 November 2026, while 2.3 is an LTS release supported until April 2028. If you're touching containerd anyway, 2.3 is the longer-lived target. Things that bite during the containerd 2 move: - Config. Old config.toml files are migrated automatically on startup. containerd config migrate writes the new format, but don't commit to it until you're sure you won't roll back. - Removed features. CRI v1alpha2, the AUFS snapshotter and the runc v1 shims are gone. Pulling Docker Schema 1 images is disabled by default. - A free warning list. On containerd 1.7.12 and later, ctr deprecations list tells you what the node uses that 2.x will complain about. Run it before the upgrade, not after. One more trap: a 1.36 or 1.37 kubelet will still boot on containerd 1.7, because the kubelet falls back to its own cgroup driver setting. That fallback is scheduled for removal in 1.38. "It still boots" is not the same as "supported". Pre-flight 4: deprecated APIs, mostly the ones you didn't write Good news: the official deprecated API migration guide lists no built-in API removals after 1.32. For a 1.34 → 1.35 → 1.36 path, the risk sits in CRDs, operators and old Helm charts. The API server tells you who is still calling deprecated APIs: kubectl get --raw /metrics | prom2json | jq '.[] | select(.name=="apiserver_requested_deprecated_apis").metrics[].labels' On a fresh 1.34 lab cluster with nothing but Calico installed, it already found something: { "group": "", "removed_release": "", "resource": "endpoints", "subresource": "", "version": "v1" } That's core v1 Endpoints, deprecated since 1.33 in favour of EndpointSlices. No removal release is set, so it isn't an upgrade blocker, but it shows how the metric works: some client in your cluster is still calling it, and the metric tells you before a removal does. This covers only the API server instance you hit and only since its last restart, so check every instance and let it run for a while. For manifests and Helm releases, Pluto is actively maintained. kubent still works, but its last release was in 2024, so its rules may lag. Pre-flight 5: kube-proxy, ingress and the rest of the add-ons kube-proxy in ipvs mode is deprecated as of 1.35. It is scheduled to be disabled by default in 1.40 and removed in 1.43, so this isn't a fire, but start testing nftables mode now (it needs kernel 5.13+). On kubeadm clusters: kubectl -n kube-system get cm kube-proxy -o jsonpath='{.data.config\.conf}' | grep '^mode' Ingress NGINX (the Kubernetes community controller) was retired in March 2026. The repository is archived and the last release was controller-v1.15.1. It keeps working, but nobody will patch the next CVE. Check whether you run it: kubectl get pods --all-namespaces --selector app.kubernetes.io/name=ingress-nginx If you do, Gateway API is the recommended path and Ingress2Gateway will convert most of your Ingress objects for you. Then go through the rest of the list: CNI, CSI drivers, cert-manager, service mesh, monitoring agents. Each has a compatibility matrix, and the target Kubernetes version should be in it before you start. Pre-flight 6: PodDisruptionBudgets that block drains A PDB with zero allowed disruptions will hang kubectl drain indefinitely, usually at 23:40 on a Friday: kubectl get pdb -A -o custom-columns=NS:.metadata.namespace,NAME:.metadata.name,ALLOWED:.status.disruptionsAllowed Fix the zeros (single replicas, minAvailable: 100%) before the maintenance window, not during it. Managed and distribution clusters On managed Kubernetes, the upstream date isn't your deadline. Your vendor's is. | Platform | 1.34 standard support ends | Extended support | |---|---|---| | Amazon EKS | 2 Dec 2026 | until 2 Dec 2027, at extra cost | | Azure AKS | Nov 2026 | LTS until Nov 2027, if you enable it | | Google GKE | 25 Jan 2027 | until 25 Nov 2027 | | RKE2 (SUSE) | maintenance ended 27 Aug 2026 | EOL 27 Feb 2027 | Extended support is a bridge, not a destination. The cgroup, containerd and ingress work above still has to happen. It just happens later, and you pay for the wait. OpenShift 4.21 is built on Kubernetes 1.34 and uses CRI-O, so the containerd section doesn't apply. cgroup v1 has been gone since OpenShift 4.19, and Red Hat's lifecycle policy (including EUS on even-numbered releases) sets your calendar. RKE2 bundles its own containerd: v1.34.12+rke2r1 already ships containerd 2.2.7, so check the bundled versions in the release notes rather than the host packages. Upgrade order: the skew policy is the map The version skew policy sets the order: 1. kube-apiserver first. In HA setups, all instances must stay within one minor version of each other. 2. kube-controller-manager, kube-scheduler, cloud-controller-manager next. They must never be newer than the API server. 3. kubelets, node by node: drain, upgrade, uncordon. A kubelet must never be newer than the API server, and may be up to three minors older. 4. kube-proxy last, under the same rule. The control plane moves one minor at a time. kubeadm doesn't support skipping minor versions, so 1.34 → 1.36 means two upgrades. Upstream recommends going to the latest patch of your current minor first, then to the latest patch of the target. On kubeadm with pkgs.k8s.io, the package repository is per minor version, so switch the repo before kubeadm upgrade plan. And upgrade the CNI by hand. kubeadm won't do it for you. Backup and rollback: be honest about "rollback" Kubernetes doesn't really do in-place minor downgrades. In practice, rollback means a restore. So take a snapshot you have actually tested: sudo mkdir -p /var/backups sudo etcdctl --endpoints=https://127.0.0.1:2379 \ --cacert=/etc/kubernetes/pki/etcd/ca.crt \ --cert=/etc/kubernetes/pki/etcd/server.crt \ --key=/etc/kubernetes/pki/etcd/server.key \ snapshot save /var/backups/etcd-pre-1.35.db sudo etcdutl --write-out=table snapshot status /var/backups/etcd-pre-1.35.db # "Tested" means restored: restore into a scratch dir and check it opens sudo etcdutl snapshot restore /var/backups/etcd-pre-1.35.db --data-dir /tmp/etcd-restore-test The status check on the lab snapshot: +----------+----------+------------+------------+---------+ | HASH | REVISION | TOTAL KEYS | TOTAL SIZE | VERSION | +----------+----------+------------+------------+---------+ | 13f43fbd | 1636 | 457 | 6.1 MB | 3.6.0 | +----------+----------+------------+------------+---------+ ETCDCTL_API=3 is no longer needed: v3 has been the default since etcd 3.4, and etcdctl 3.6 warns unrecognized environment variable if you set it. kubeadm also writes etcd and manifest backups to /etc/kubernetes/tmp during an upgrade. That's a nice extra, but it's not a backup strategy. If your etcd is still on 3.5, move every member to 3.5.26 or later before going to 3.6 to avoid the zombie-member bug. On EKS, an in-place upgrade can be rolled back within 7 days. Elsewhere, staging is your rollback rehearsal. And if you're in NIS2 scope, "we ran an unsupported Kubernetes version" is not a sentence you want in an incident report. The copy-paste checklist [ ] Inventory: kubelet, runtime and kernel version per node [ ] cgroup v2 on every Linux node (stat -fc %T /sys/fs/cgroup/ = cgroup2fs) [ ] cgroup-aware app versions (JVM, Node.js >= 20.3.0, cAdvisor >= 0.43.0) [ ] containerd 2.x planned before 1.36 (2.3 LTS recommended); ctr deprecations list run [ ] kubelet_cri_losing_support absent on all nodes [ ] Deprecated API calls checked (apiserver metric + Pluto on manifests/Helm) [ ] kube-proxy mode known; nftables tested if you're on ipvs [ ] Ingress NGINX found and migration planned (Gateway API / Ingress2Gateway) [ ] CNI, CSI, cert-manager, mesh, agents: target version in their compatibility matrix [ ] PDBs with 0 allowed disruptions fixed [ ] Vendor support dates checked (EKS/AKS/GKE/OpenShift/RKE2) [ ] etcd snapshot taken AND test-restored [ ] Staging upgraded first; control plane -> nodes, one minor at a time [ ] Plan 1.35 -> 1.36 before 28 Feb 2027 Don't panic. Under three weeks is plenty, if you start today. More checklists live at Agency-42/kubernetes-best-practices, and if you'd rather not do this alone, Agency 42 can help with Kubernetes operations and upgrades. Sources - Kubernetes patch releases and EOL dates: kubernetes.io - Kubernetes v1.35 release blog (cgroup v1, containerd 1.x, ipvs, Ingress NGINX): kubernetes.io - Kubernetes v1.35 sneak peek (containerd 1.x final call): kubernetes.io - Cgroup driver autoconfiguration GA and containerd 1.y timeline: kubernetes.io - About cgroup v2 (stat check, failCgroupV1, app versions): kubernetes.io - The shift to cgroup v2 (kubeadm preflight, 1.38 fallback): kubernetes.io - Container runtimes (1.38 fallback removal): kubernetes.io - containerd releases and Kubernetes support matrix: github.com/containerd/containerd/blob/main/RELEAS… - containerd 2.0 changes: github.com/containerd/containerd/blob/main/docs/c… - Deprecated API migration guide: kubernetes.io - Deprecated API metric: kubernetes.io - kube-proxy modes (ipvs timeline, nftables): kubernetes.io - Ingress NGINX retirement: kubernetes.io - Version skew policy: kubernetes.io - Upgrading kubeadm clusters: kubernetes.io - Operating etcd (snapshot and verify): kubernetes.io - etcd v3.6 zombie members: kubernetes.io - Amazon EKS version calendar: docs.aws.amazon.com - AKS supported versions: learn.microsoft.com - GKE release schedule: cloud.google.com - OpenShift 4.21 release notes: docs.redhat.com - OpenShift 4.19 release notes (cgroup v1 removed): docs.redhat.com - SUSE product lifecycle (RKE2): suse.com - RKE2 v1.34 support matrix: suse.com --- Originally published at agency42.se. Want to automate the API-visible parts of this checklist? k8s-preflight is a small read-only script that runs them for you.

  6. Kubernetes Memory on cgroup v2: Debugging OOM Kills and Evictions A container is OOMKilled, but kubectl top never showed it reaching its limit. Or a Pod is evicted while your node dashboard still shows free memory. In both cases, the first question is which memory value the decision actually used. This guide separates cgroup-limit OOM, node-side OOM and kubelet eviction, then shows how to inspect the evidence and review memory requests and limits. It assumes familiarity with Pod resources and basic PromQL. TL;DR - The working set is a heuristic, not a measurement of unreclaimable memory. It subtracts inactive_file from usage but still includes reclaimable active page cache and slab. - Distinguish memcg OOM from global OOM using kernel logs. A container can be below its own limit when an ancestor cgroup reaches its limit or a node-side allocation fails. OOMKilled and SystemOOM alone cannot establish the cause. - Eviction uses the kubelet's memory.available, not Linux MemAvailable. Check the kubelet's statistics and configured thresholds. - Review requests and limits separately. Requests influence placement and victim selection. To address a cgroup limit, reduce usage or adjust the limit after checking node capacity. - Check Memory QoS configuration as well as its feature gate. In v1.37, the gate defaults to enabled, but request-based protection and memory.high remain disabled by default. This article targets cgroup v2, Kubernetes v1.37.1 and containerd. See [Components and versions](#components-and-versions) for exact versions. The kind examples use a v1.37.0 node image. Use the [investigation workflow](#investigation-workflow) for an incident, the [metric map](#quick-reference-contradictions-explained-and-a-metric-map) to understand a dashboard, or [requests and limits](#what-to-do-about-it) for prevention. The [kind examples](#hands-on-verification-with-kind) explore reclaim and Memory QoS in more detail. Identify the failure before choosing a metric | Observation | Mechanism | Evidence to check | |:---|:---|:---| | Container termination reason is OOMKilled | The kernel killed a process; this alone does not distinguish the OOM source | Kernel logs, container and ancestor cgroup limits, node allocation constraints | | Pod is Evicted with The node was low on resource: memory. | The kubelet responded to node memory pressure | Kubelet memory.available, configured thresholds and eviction message | | Exit code is 137 without an OOM reason | The process received SIGKILL; the exit code alone does not establish OOM | Termination details, events and kernel logs | memcg OOM means an allocation cannot fit within a cgroup limit after reclaim. Global OOM means a node-side allocation cannot be satisfied after reclaim, potentially within NUMA or cpuset constraints. A container below its own limit can be affected by either an ancestor limit or a node-side failure. Investigating OOM and eviction Investigation workflow Start with the observed termination or eviction, then compare metrics from the same time. Scrapes can miss the peak; kernel logs are stronger evidence of the OOM source than a sampled usage graph. 1. When a Pod was OOMKilled - Check whether container_memory_usage_bytes is close to the limit. memcg OOM is evaluated against this value - Compare with container_memory_rss to tell whether anonymous pages are the main cause. An OOM can occur even when RSS is low - If the gap between the working set or usage and RSS is large, page cache, shmem or kernel memory may be pushing usage up - If OOMKilled occurs despite headroom under the limit, suspect global OOM. Correlate SystemOOM with kernel logs and inspect ancestor limits. A missing event does not establish memcg OOM - If you see no spike in the metrics, suspect a surge shorter than the scrape interval. See [My working set is below the limit, but the container was OOMKilled](#my-working-set-is-below-the-limit-but-the-container-was-oomkilled) for what to check 2. When a Pod was evicted - If the message is The node was low on resource: memory., node-wide memory pressure is the cause - Use the [node-utilization query](#why-node-exporter-and-kubelet-usage-disagree) to check the kubelet-view node memory utilization. The node-exporter-based utilization alone is not enough to judge - Check whether a Pod with a small (or unset) memory request relative to actual usage is on the same node. Compare each Pod's container_memory_working_set_bytes (cAdvisor) against kube_pod_resource_request{resource="memory"} (kube-scheduler). Note that Pods with large requests make the scheduler keep placement density low, so they are unlikely to be the cause of pressure 3. To prevent recurrence - Set a memory request informed by workload history to avoid excessive placement density. PromQL for checking is collected in [Align memory request with actual usage](#align-memory-request-with-actual-usage) - For containers where memcg OOM occurs, also review the limit - Control placement with Pod anti-affinity or topology spread constraints so that Pods with large memory usage do not gather on the same node Telling which path occurred Using Kubernetes information Start by narrowing down with what you can check using kubectl. | Where to look | What it tells you | |:---|:---| | Last State in kubectl describe pod, or lastState.terminated in kubectl get pod -o yaml | That an OOM kill happened (reason: OOMKilled). It cannot distinguish the path | | Events in kubectl describe node <node name> | Use SystemOOM as a clue and verify the source in kernel logs. If there is no record, that does not make it a memcg OOM; it just means you cannot tell | There is no OOM-specific event on the Pod side. You only see things like BackOff accompanying the restart. Check it as follows. SystemOOM is an event attached to the Node, and since Node is not a namespaced object, the event itself is recorded in the default namespace. # 1. Find which node the OOMKilled Pod is on kubectl get pod -n <namespace> <Pod name> -o wide Output: NAME READY STATUS RESTARTS AGE IP NODE myapp-... 1/1 Running 5 (2m ago) 1h 10.0.0.1 node-1 # 2. Check whether that node's events contain SystemOOM kubectl describe node node-1 Output: ... Events: Type Reason Age From Message ---- ------ ---- ---- ------- Warning SystemOOM 3m kubelet System OOM encountered, victim process: java, pid: 12345 # To list only events kubectl get events -n default \ --field-selector reason=SystemOOM,involvedObject.name=node-1 If SystemOOM exists, inspect kernel messages from the same time: constraint, oom_memcg, task_memcg and the killed PID. A parsing failure can also produce this event. Match the event and kernel log to the container's termination time (lastState.terminated.finishedAt). Not finding SystemOOM is not evidence of memcg OOM. The same state results from event TTL expiry, a kubelet restart, or a missed event. The event retention period is set by kube-apiserver's --event-ttl, and the default is 1 hour (options.go). So an old SystemOOM may no longer be available through kubectl. If you forward events to an external logging platform, check there. On the node, inspect the kernel log: sudo journalctl -k -b -o short-iso \ | rg 'oom-kill:|Out of memory:|Memory cgroup out of memory:|Killed process' CONSTRAINT_MEMCG and oom_memcg identify the limiting cgroup; task_memcg identifies the victim's cgroup. If these differ, inspect the Pod and ancestor limits. CONSTRAINT_CPUSET and CONSTRAINT_MEMORY_POLICY call for checking NUMA and cpuset restrictions. Align timestamps with the container's finishedAt. If logs are unavailable, use the following as supporting evidence rather than proof: - Whether container_memory_usage_bytes was close to the limit at that time (if so, memcg OOM is likely) - Whether [the kubelet-view node memory utilization](#why-node-exporter-and-kubelet-usage-disagree) was high at that time (if so, global OOM is likely) Why OOMKilled cannot distinguish the paths When a task exits with code 137, the container runtime (containerd) checks oom_kill in the cgroup's memory.events, and if it is 1 or more, it updates the termination reason to OOMKilled (events.go). The check is oomMetricsEventOccurred() in the same file. switch v := taskMetricsAny.(type) { case *cg1.Metrics: return v.GetMemoryOomControl().GetOomKill() > 0, nil case *cg2.Metrics: return v.GetMemoryEvents().GetOomKill() > 0, nil The definition of oom_kill in memory.events is "The number of processes belonging to this cgroup killed by any kind of OOM killer". So a process chosen and killed by the kernel in a global OOM is counted too, and both paths produce OOMKilled. {% details How the kubelet parser produces SystemOOM %} When SystemOOM is recorded SystemOOM is an event the kubelet records on the Node object. The kubelet parses OOM messages in the kernel log and records it only when the cgroup where the OOM happened is judged to be the root (/) (oom_watcher_linux.go). for event := range outStream { // Count every OOM kill per container to back the // container_oom_events_total metric. recordOOMKill(event.ContainerName) if event.VictimContainerName == recordEventContainerName { ... ow.recorder.WithLogger(logger).Eventf(ref, v1.EventTypeWarning, systemOOMEvent, "%s", eventMsg) } } The usual association with global OOM comes from the combination of the kernel log format and the parser. For an OOM caused by a memcg limit, the kernel prints oom_memcg=<cgroup path>, whereas for a global OOM it prints ,global_oom instead (mem_cgroup_print_oom_context() in mm/memcontrol.c). if (memcg) { pr_cont(",oom_memcg="); pr_cont_cgroup_path(memcg->css.cgroup); } else pr_cont(",global_oom"); The modern-format regular expression in the kubelet's forked parser matches the format containing oom_memcg= (oomparser_linux.go). containerRegexp = regexp.MustCompile(`oom-kill:constraint=(.*),nodemask=(.*),cpuset=(.*),mems_allowed=(.*),oom_memcg=(.*),task_memcg=(.*),task=(.*),pid=(.*),uid=(.*)`) So no cgroup can be extracted from a global OOM message, and the initial value / of OomInstance is left as is (StreamOoms in oomparser_linux.go). oom := &OomInstance{ ContainerName: "/", VictimContainerName: "/", TimeOfDeath: msg.Timestamp, } In other words, the presence of SystemOOM is not "the result of directly determining the path" but "the result of whether the kernel log could be parsed". Still, in practice it is enough to understand that a recorded event is a reason to inspect node-side OOM, while neither presence nor absence establishes the source. {% enddetails %} How it appears in metrics Metrics do not distinguish the path with a label. The kernel's __oom_kill_process() records the same event regardless of the path. | Metric | Emitted by | memcg OOM | global OOM | |:---|:---|:---|:---| | node_vmstat_oom_kill | node-exporter (oom_kill in /proc/vmstat) | Increases | Increases | | container_oom_events_total | kubelet (result of kernel log parsing) | The series of the container the killed process belonged to increases | The id="/" series increases, not a container series | | oom_kill in cgroup v2 memory.events | (a cgroup file, not a metric; containerd reads it) | Increases | Increases | Only container_oom_events_total behaves differently because, as described earlier, the parser cannot extract a cgroup in a global OOM and treats it as the root (/). This is only a result of whether the log could be parsed, so do not use it to determine the path; confirm the source from constraint and oom_memcg in the kernel log. > Note: Although container_oom_events_total has container_ in its name, in v1.37 it is counted by the kubelet, not cAdvisor (recordOOMKill() in oom_counter.go). The meaning of the value does not change, but you have to look elsewhere when reading the code. What to look at after narrowing down - If you judge it to be a global OOM, inspect node-side memory pressure and any NUMA or cpuset constraints. Compare the memory requests and actual usage of the Pods placed on the same node, and check whether any Pod has too small a request - If you judge it to be a memcg OOM, review that container's memory limit and its actual usage. If the container seems to have headroom under its limit, it may have reached the limit of the Pod as a whole or of /kubepods.slice - If you cannot decide, keep both possibilities open and check both the container's limit and usage and the node's memory utilization {% details Kernel OOM behavior and victim selection %} The two OOM kill paths There are two paths by which a container's process gets OOM killed. Both are SIGKILLs from the kernel's OOM killer, but the conditions under which they occur and the scope they affect differ. As [defined in the terminology notes](#how-this-article-names-the-two-oom-kill-paths), memcg OOM enforces a configured limit and global OOM protects the node, so their purposes differ at the root. | Path | Condition | What gets killed | Node event | |:---|:---|:---|:---| | memcg OOM | memory.max exceeded for a container, Pod or /kubepods.slice | Processes inside the cgroup that hit its limit | Not recorded | | global OOM | A node-side allocation cannot be satisfied after reclaim, possibly within NUMA or cpuset constraints | Chosen from eligible processes under the allocation constraints | SystemOOM is recorded when the kubelet parses the log and treats the triggering cgroup as / | Even if your container is under its limit, it can be OOM killed because of another Pod on the same node. That is global OOM. SystemOOM is a kubelet-generated clue to investigate, not a definitive classification of the OOM source. Events expire according to the API server's --event-ttl (one hour by default), so a missing event does not rule out global OOM. OOM kill from exceeding a cgroup limit (memcg OOM) resources.limits.memory is set as the container cgroup's memory.max. When a cgroup's memory.current reaches memory.max, this is what happens. As described above, limits are set not only on containers but also on Pods and /kubepods.slice, and the same flow applies whichever level reaches its limit. 1. The kernel tries to reclaim memory inside that cgroup. Page cache is basically reclaimed here. 2. If usage still does not fit under memory.max after reclaim, the cgroup's OOM killer SIGKILLs processes in that cgroup. 3. With cgroup v2, the kubelet defaults to singleProcessOOMKill: false and sets memory.oom.group=1 for the container. A memcg OOM therefore normally kills the container's processes together. With singleProcessOOMKill: true, a child process can be killed while the container keeps running. Container restarts follow restartPolicy. See the implementation. The implementation is try_charge_memcg() in mm/memcontrol.c. It is a goto loop returning to the retry: label, and proceeds in this order. | Step | Location | What happens | |:---|:---|:---| | 1. Try to charge | L2177-L2178 | If page_counter_try_charge() succeeds, the allocation completes and it returns | | 2. Reclaim | L2211-L2216 | On failure it reclaims with try_to_free_mem_cgroup_pages(), and if mem_cgroup_margin() satisfies the request, goes back to 1 | | 3. Count the retry | L2244-L2245 | If reclaim is not enough, it decrements nr_retries and goes back to 1. The initial value is MAX_RECLAIM_RETRIES (= 16; mm/internal.h) | | 4. Invoke the OOM killer | L2259-L2264 | Only after the retries are used up does it call mem_cgroup_oom() | MAX_RECLAIM_RETRIES is a retry budget, not a guarantee of exactly 16 reclaim attempts. Allocation flags and reclaim progress change the control flow. The check in step 2 looks like this in code. nr_reclaimed = try_to_free_mem_cgroup_pages(mem_over_limit, nr_pages, gfp_mask, reclaim_options, NULL); psi_memstall_leave(&pflags); if (mem_cgroup_margin(mem_over_limit) >= nr_pages) goto retry; The kernel documentation for memory.max says the same. > Memory usage hard limit. ... If a cgroup's memory usage reaches this limit and can't be reduced, the OOM killer is invoked in the cgroup. > Warning: memory.current reaching memory.max does not by itself cause an OOM. A cgroup sitting at its limit with page cache is a normal state. For the same reason, the working set reaching memory.max is not an OOM condition either. Only inactive_file is subtracted from the working set, and reclaimable memory remains in the rest: > > - active_file: demoted to inactive under pressure and then reclaimed > - slab_reclaimable (dentry / inode caches): in memcg reclaim too, shrink_slab() is called after shrink_lruvec() (shrink_node_memcgs() in mm/vmscan.c) > > So there is no threshold of "OOM once either value reaches the limit". The actual condition is when the charge fails and reclaim cannot make room. What cannot be reclaimed is mainly anonymous pages (without swap) and shmem, so that is where to look when narrowing down. That said, what the kernel compares against memory.max in a memcg OOM is the whole of memory.current, and it is not decided by any single metric. memory.current includes, besides anonymous pages, kernel memory, shmem (tmpfs and shared memory), and page cache that cannot be reclaimed, or cannot be reclaimed right away. container_memory_rss is an indicator for determining whether anonymous pages are the main cause. If cgroup RSS is close to the limit, anonymous pages are a likely major contributor, but RSS being below the limit is no evidence that an OOM will not occur. When the gap between RSS and the working set is large, check container_memory_usage_bytes (the value of memory.current) together with the breakdown in memory.stat on the node: anon, file, shmem, slab and so on. OOM kill from node memory exhaustion (global OOM) When a node-side allocation cannot be satisfied after reclaim, the kernel evaluates eligible processes using oom_badness(). cpusets, NUMA policies and oom_score_adj=-1000 can restrict the candidates. A container need not exceed its own limit to be selected. There is no guaranteed ordering between kubelet eviction and the kernel's OOM killer. The kubelet evicts Pods to protect the node, and its selection puts "Pods whose usage exceeds their memory request" first (rankMemoryPressure() in helpers.go). // rankMemoryPressure orders the input pods for eviction in response to memory pressure. // It ranks by whether or not the pod's usage exceeds its requests, then by priority, and // finally by memory usage above requests. func rankMemoryPressure(pods []*v1.Pod, stats statsFunc) { orderedBy(exceedMemoryRequests(stats), priority, memory(stats)).Sort(pods) } A Pod with a zero memory request and positive usage belongs to the "exceeds request" group. Within that group, priority and then usage above request determine the order, so it is not necessarily evicted first. If eviction acts in time, the result is Evicted, not OOMKilled. Global OOM happens first when eviction cannot reclaim memory as fast as memory grows. There are several reasons it cannot keep up. | Factor | Value | Effect | |:---|:---|:---| | Evaluation interval | 10 seconds (evictionMonitoringPeriod) | Detection can be up to 10 seconds late | | Pods evicted per evaluation | Only one ("we kill at most a single pod during each eviction interval" in eviction_manager.go) | At most one Pod is evicted per synchronization; notifications and cleanup waits mean this is not a fixed rate | | Waiting for cleanup | Up to 30 seconds (podCleanupTimeout) | The next evaluation may be delayed further | | Default threshold | memory.available<100Mi | Extremely thin on large nodes | | How memory.available is computed | From the root cgroup's anon + file | Contains no kernel memory, so it [overestimates free memory](#values-the-kubelet-uses-for-eviction-decisions) | The official Kubernetes documentation also says (Node out of memory behavior): > If the node experiences an out of memory (OOM) event prior to the kubelet being able to reclaim memory, the node depends on the oom_killer to respond. It is more likely when applications that allocate a lot of memory right at startup, or in response to a sudden surge in requests, share a node. > Warning: Eviction protects the node; it does not protect individual Pods from OOM. Containers without limits have no memory.max backstop at all, so eviction is merely the last line of defense. > > Moreover, rankMemoryPressure() looks at Pod priority before usage size. If the offending Pod has high priority, low-priority Pods with small usage get evicted first, and global OOM can occur without the pressure being resolved. The proper countermeasure is to set limits and requests rather than relying on eviction. Which process gets killed The kernel evaluates eligible processes using resident pages, swap, page tables and oom_score_adj; the adjustment alone does not establish a fixed kill order. The kubelet sets oom_score_adj (a bonus added to oom_score) according to the container's QoS class, so the QoS class and the memory request setting directly become how likely a container is to be killed. You can check the QoS class with kubectl get pod <Pod name> -o jsonpath='{.status.qosClass}', and how it is determined is described in Pod Quality of Service Classes. | Condition | oom_score_adj | Likelihood of being killed | |:---|:---|:---| | system-node-critical (evaluated before the QoS class) | -997 | Least likely | | Guaranteed | -997 | Least likely | | Burstable | 1000 - 1000 × memory request / node memory capacity (clamped to 3–999) | The smaller the request, the more likely | | BestEffort | 1000 | Most likely | The implementation is GetContainerOOMScoreAdjust() in Kubernetes' policy.go. system-node-critical is evaluated before the QoS class decision. if types.IsNodeCriticalPod(pod) { // Only node critical pod should be the last to get killed. return guaranteedOOMScoreAdj } The formula above is the container-level baseline. Pod-level requests and restartable init containers can introduce adjustments. An omitted memory request can also default from the limit, so inspect the applied Pod spec. The Burstable clamp is implemented with a lower bound of 1000 + guaranteedOOMScoreAdj (= 3) and an upper bound of besteffortOOMScoreAdj - 1 (= 999). A Pod whose request is set smaller than its actual usage is more likely to be targeted in both eviction and global OOM. > Warning: PriorityClass works differently for eviction and for global OOM. It is a misreading to think that raising priority protects you from both. > > | PriorityClass | Eviction | global OOM (oom_score_adj) | > |:---|:---|:---| > | User defined (up to 1000000000) | The higher the value, the later it is evicted | Determined by QoS class and request | > | system-cluster-critical | Exempt from node-pressure eviction | Determined by QoS class and request (not protected) | > | system-node-critical | Exempt from node-pressure eviction | Fixed at -997 | > | static / mirror Pod | Exempt from node-pressure eviction | Determined by QoS class and request | > > The three exempt rows identify critical Pods for which IsCriticalPod() is true; the kubelet checks static and mirror Pods as well as system-critical priority classes. > > What oom_score_adj protects is only system-node-critical, for which IsNodeCriticalPod() is true. If a system-cluster-critical Pod is BestEffort, its oom_score_adj is 1000, and it is more likely to be selected in a global OOM. "I raised the priority but it was still OOMKilled" comes from this path. > Warning: Neither an OOM kill nor node-pressure eviction directly deletes the Pod API object. What happens next depends on restartPolicy. > > - Always or OnFailure: the container is recreated on the same node. The Pod stays on the same node, so if the cause is not resolved, OOM kills will happen again > - Never: the container is not recreated and the Pod remains Failed. If a controller such as a Job creates a successor Pod, it may be placed on a different node > > If OOMKilled repeats even though there is headroom under the limit, suspect this path. > Note: Because the OOM kill frees memory, the pressure may already be gone when the kubelet next evaluates free memory. In that case the node may get no MemoryPressure condition and no node.kubernetes.io/memory-pressure:NoSchedule taint, and scheduling of new Pods does not stop. Conversely, if the pressure continues, a later evaluation sets the condition and taint, and eviction also occurs. Note that it is decided by memory.available at evaluation time, not by whether an OOM kill happened. {% enddetails %} Checking with PSI (Pressure Stall Information) This is an auxiliary indicator for the case where things are slow although usage has not reached the limit. It shows how long processes were actually stalled waiting for memory reclaim. {% details How to read PSI and PromQL %} You can obtain the time a container was stalled waiting for memory allocation as PSI. Even when usage has not reached the limit, you can detect being made to wait by memory reclaim. | Metric | Emitted by | Description | |:---|:---|:---| | container_pressure_memory_waiting_seconds_total | cAdvisor (embedded in kubelet) | Time some processes were waiting for memory (total of some in memory.pressure) | | container_pressure_memory_stalled_seconds_total | cAdvisor (embedded in kubelet) | Time all processes were stalled waiting for memory (total of full in memory.pressure) | Note: These metrics are controlled by the KubeletPSI feature gate, which went GA in v1.36 and is enabled by default (it cannot be disabled). The kernel must support PSI, though, and the kubelet decides this by whether cpu.pressure exists at the cgroup root (cadvisor_linux.go). If the metrics do not appear, check whether the kernel was built with CONFIG_PSI=y and whether psi=0 is set as a kernel parameter. memory.pressure also has avg10 / avg60 / avg300, "the average over the last N seconds (%)", but only the cumulative value (total) is exposed as a Prometheus metric. So use rate() to see a ratio. The unit is seconds, so the result of rate() is "how many seconds of waiting per second", a ratio from 0 to 1. Multiply by 100 to get a percentage directly. <!-- PromQL uses Python fencing for syntax highlighting only. --> # Per Pod, the share of time (%) all processes were stalled waiting for memory 100 * max by (namespace, pod) ( rate(container_pressure_memory_stalled_seconds_total{container="", image="", pod!=""}[5m]) ) # Per Pod, the share of time (%) some processes were waiting for memory 100 * max by (namespace, pod) ( rate(container_pressure_memory_waiting_seconds_total{container="", image="", pod!=""}[5m]) ) When there is plenty of memory, neither increases, so rate() stays at 0. Under pressure, waiting (some) rises first, and if it gets worse stalled (full) follows. waiting counts even when only some processes waited, so to judge severity, use stalled. It is the time all processes in the container were stopped, so if it shows a value, throughput is directly affected. To see memory pressure on the whole node, use the root cgroup series. # Share of time (%) the whole node was completely stalled waiting for memory 100 * max by (node) ( rate(container_pressure_memory_stalled_seconds_total{id="/"}[5m]) * on(instance) group_left(node) kubelet_node_name ) Containers that are struggling although they are below their limit can be found by combining PSI with utilization. container_spec_memory_limit_bytes is the container limit exposed by cAdvisor. # Containers whose usage is under 80 % of the limit but that are stalled waiting for memory ( 100 * rate(container_pressure_memory_stalled_seconds_total{container!="", image!=""}[5m]) > 1 ) and ( container_memory_usage_bytes{container!="", image!=""} / container_spec_memory_limit_bytes{container!="", image!=""} < 0.8 ) and keeps only series whose label sets match exactly on both sides. A container with no limit has container_spec_memory_limit_bytes of 0, so the division gives +Inf, which does not satisfy < 0.8, and it is excluded automatically. Here is an example alert. It catches stalled that keeps occurring. - alert: ContainerMemoryStalled # Fires when the 5-minute average of fully stalled time is 5 % or more for 10 minutes expr: | 100 * max by (namespace, pod) ( rate(container_pressure_memory_stalled_seconds_total{container="", image="", pod!=""}[5m]) ) > 5 for: 10m labels: severity: warning annotations: summary: "A Pod is stalled waiting for memory allocation (add the namespace and pod labels with your own template)" description: "It may be made to wait by reclaim even though it has not reached its memory limit. Check the gap between usage and RSS and the amount of page cache." The right threshold of 5 % depends on the environment. First look at the distribution of values without an alert to understand the normal level, then decide. For details on PSI see Understand Pressure Stall Information (PSI) Metrics. {% enddetails %} Quick reference: contradictions explained and a metric map The working set, cgroup usage, process RSS and node availability answer different questions. Use this map to identify the value behind the dashboard before comparing it with a limit or threshold. For a non-root cgroup, working set is max(memory.current - inactive_file, 0). It still includes some reclaimable memory. | Scope | Metric | Emitted by | Backing value in cgroup v2 / /proc | Page cache handling | Main use | |:---|:---|:---|:---|:---|:---| | Container | container_memory_working_set_bytes | cAdvisor (embedded in kubelet) | max(memory.current − inactive_file, 0) | Active pages count as in use | kubectl top pod, usage visualization | | Container | container_memory_rss | cAdvisor (embedded in kubelet) | anon in memory.stat | Not included (anonymous pages only) | Understanding what the app allocated | | Container | container_memory_usage_bytes | cAdvisor (embedded in kubelet) | memory.current | Everything included | What memcg OOM is evaluated against | | Node | container_memory_working_set_bytes{id="/"} | cAdvisor (embedded in kubelet) | anon + file − inactive_file in the root cgroup's memory.stat (see below) | Active pages count as in use | Eviction decisions, kubectl top node | | Node | node_memory_MemAvailable_bytes | node-exporter | MemAvailable in /proc/meminfo | Reclaimable pages count as free | Visualizing node memory utilization | | Node | kube_node_status_capacity{resource="memory"} | kube-state-metrics | (status.capacity of the Node object) | (capacity, not usage) | Denominator when converting to utilization | Looking at the same node, utilization computed from node_memory_MemAvailable_bytes does not match the utilization the kubelet uses for eviction decisions. With workloads that do a lot of file I/O or logging, the difference can reach several GiB. Different emitters also mean different labels on the metrics. cAdvisor metrics carry no label identifying the node, so when you compute per-node utilization you need to align labels with kubelet_node_name, as shown later. Kubernetes memory usage and cgroup v2 Why the numbers differ A process RSS measurement includes its resident mappings, including shared and file-backed pages. Cgroup accounting charges memory to a group, while working set subtracts one category of file cache from usage. An application's heap metric covers a different subset again. Reclaimability is a separate question: clean file cache can often be reclaimed, dirty pages may need writeback, and anonymous memory normally needs swap or explicit release by the process. Calling free does not necessarily return allocator-held memory to the OS. These differences explain why heap size, process RSS, cgroup RSS and working set need not match. The following sections connect those values to cgroup v2 accounting. Memory breakdown in cgroup v2 Container memory usage is managed in a per-container cgroup. In cgroup v2, memory.current is the current usage and memory.stat is its breakdown. # Example values in a container's cgroup (checked on the node) cat /sys/fs/cgroup/<container cgroup>/memory.current Output: 5368709120 cat /sys/fs/cgroup/<container cgroup>/memory.stat Output: anon 2147483648 # anonymous pages (heap, stack: memory not backed by a file) file 3087007744 # page cache (including tmpfs and shared memory) shmem 268435456 # the part of file that is tmpfs or shared memory ... inactive_anon 134217728 # anon LRU: not accessed for a while active_anon 2013265920 # anon LRU: recently accessed inactive_file 1073741824 # file LRU: not accessed for a while active_file 1744830464 # file LRU: recently accessed unevictable 268435456 # pages that cannot be reclaimed (here, the tmpfs part) ... The following relationships hold in this example. Note that inactive_file + active_file does not equal file; it equals file - shmem. The next section explains why. anon + file + kernel memory = 5368709120 (memory.current) inactive_file + active_file = 2818572288 = file - shmem inactive_anon + active_anon = 2147483648 = anon (shmem is not in here) unevictable = 268435456 = shmem (tmpfs without swap) The main fields of memory.stat are: anon (anonymous pages) - Memory not backed by a file. Regions the application allocated with malloc and similar belong here. - Unlike page cache, the kernel cannot reclaim it independently of the process. With swap it can write pages out and free up space, but on nodes that do not use swap it is memory the kernel cannot reclaim under pressure. When swap is unavailable, the kernel sets scan_balance = SCAN_FILE and excludes the anon LRU from scanning (get_scan_count() in mm/vmscan.c). - On the other hand, this value drops if the process itself returns memory to the OS with munmap or madvise(MADV_DONTNEED). Note that calling free does not reduce it unless the allocator or language runtime returns the memory to the OS. file (page cache) - Contents of files held in memory by the kernel during reads and writes. The application did not allocate it explicitly. - When memory runs short the kernel discards (reclaims) it automatically, so it is basically reclaimable memory. - Cache of files on disk is split into active_file (recently accessed) and inactive_file (not accessed for a while). Reclaim takes inactive_file first. - active_file + inactive_file does not equal file. These are totals of the LRU lists, and as described below shmem is on the anon LRU rather than the file LRU. Besides shmem, pages that fall off the LRU, such as mlocked pages, also show up as a difference. - It includes not only the cache of files on disk but also shmem (tmpfs and shared memory), because Linux treats these as page cache too. shmem is written out to swap rather than to a file, so on nodes without swap it cannot be reclaimed. The breakdown is visible as shmem in memory.stat. > Warning: shmem is counted in file, but it is not on the file LRU. The kernel classifies swap-backed pages as anon, and tmpfs pages are among them. folio_is_file_lru() in mm_inline.h is defined as "0 if @folio is a normal anonymous folio, a tmpfs folio or otherwise ram or swap backed folio". > > The counters they are charged to are different, though. A tmpfs page is added to both NR_FILE_PAGES (= file) and NR_SHMEM (= shmem) (mm/shmem.c): > > ```c > __lruvec_stat_mod_folio(folio, NR_FILE_PAGES, nr); > __lruvec_stat_mod_folio(folio, NR_SHMEM, nr); > ``` > > Furthermore, for a Kubernetes emptyDir with medium: Memory, the kubelet mounts tmpfs with the noswap option when the node supports it (generateTmpfsMountOptions() in empty_dir.go). The kernel makes the inode of a noswap tmpfs unevictable (shmem_get_inode() in mm/shmem.c), so these pages sit on the unevictable LRU, not the anon LRU. > > ```c > if (sbinfo->noswap) > mapping_set_unevictable(inode->i_mapping); > ``` > > So shmem is accounted as follows: > > | mount | LRU | Where it shows up in memory.stat | > |:---|:---|:---| > | emptyDir with medium: Memory (noswap) | unevictable | unevictable | > | Any other tmpfs / shared memory | anon LRU | inactive_anon / active_anon | > > In neither case does it enter inactive_file / active_file. That matters in two ways later: > > - inactive_file + active_file generally corresponds to file - shmem; unevictable pages and asynchronous statistics can cause differences > - Only inactive_file is subtracted from the working set, so shmem stays in the working set in full With workloads that write a lot of logs or read and write large files, the page cache can grow to several GiB even though the application did not intend that. > Note: memory.current includes memory the kernel itself uses (slab, socket buffers and so on) in addition to anon and file. So adding anon and file alone does not give memory.current. How cgroup v2 memory controls map to Pod settings cgroup v2 memory control interface cgroup v2 offers four levels of control over memory usage. memory.min and memory.low are protections ("do not reclaim up to here"), and memory.high and memory.max are limits ("do not allow use beyond here"). | File | Type | Behavior | |:---|:---|:---| | memory.min | Protection (hard) | Protects memory within the effective boundary determined by ancestors and usage. It does not preallocate physical memory | | memory.low | Protection (soft) | Best-effort avoidance of reclaim up to this value. It is reclaimed if nothing else can be | | memory.high | Limit (soft) | Exceeding it applies reclaim pressure and throttling. Crossing memory.high does not itself invoke the OOM killer; memory.max and global OOM still apply | | memory.max | Limit (hard) | Exceeding it triggers reclaim, and if usage still does not fit, the cgroup's OOM killer runs | Other files that report state: - memory.current: current usage (what container_memory_usage_bytes is backed by) - memory.stat: usage breakdown - memory.events: counts of low, high, max, oom, oom_kill and oom_group_kill - memory.swap.max: swap limit - memory.pressure: PSI for waiting on memory allocation For exact definitions of each file see the kernel's Memory Interface Files, and for how Kubernetes treats cgroup v2 see About cgroup v2. How Pod settings are reflected Pod settings are reflected into cgroup v2 as follows. | Pod setting | Target in cgroup v2 | |:---|:---| | resources.limits.memory | memory.max | | resources.requests.memory | Not reflected (with the default kubelet configuration in v1.37; you can reflect it by changing settings → [KEP-2570](#kep-2570-support-memory-qos-with-cgroups-v2)) | | resources.limits.cpu | cpu.max | | resources.requests.cpu | cpu.weight | cgroup v2 is a directory tree. Its top is the root cgroup (/sys/fs/cgroup), where any process that is in no child cgroup belongs, so it contains not only Pods but every process on the node, such as the kubelet and containerd. Kubernetes creates the hierarchy below it: "all Pods on the node → Pod → container". # With the systemd cgroup driver (the kubeadm default; names differ with the cgroupfs driver, see below) /sys/fs/cgroup ← root cgroup (id="/") │ contains every process on the node │ the value used for eviction decisions │ ├── system.slice/ kubelet, containerd, sshd, etc. │ ├── kubelet.service (processes that are not Pods) │ └── containerd.service │ └── kubepods.slice/ ← all Pods on the node │ (id="/kubepods.slice") ├── kubepods-pod<UID>.slice/ ← Pod (Guaranteed) │ ├── cri-containerd-<ID>.scope ← container │ └── cri-containerd-<pause ID>.scope ← pause container │ ├── kubepods-burstable.slice/ (intermediate slice per QoS class) │ └── kubepods-burstable-pod<UID>.slice/ ← Pod (Burstable) │ └── cri-containerd-<ID>.scope ← container │ └── kubepods-besteffort.slice/ └── kubepods-besteffort-pod<UID>.slice/ ← Pod (BestEffort) └── cri-containerd-<ID>.scope ← container When filtering metrics, the root cgroup is id="/", all Pods on the node are id="/kubepods.slice", a Pod is container="", image="", pod!="", and a container is container!="", image!="". What the kubelet uses for eviction decisions is the working set of the topmost root cgroup ([see below](#values-the-kubelet-uses-for-eviction-decisions)). That is a different level from memcg OOM caused by a container or Pod exceeding its limit. Memory limits are set not only on containers but also on higher levels. | Level | cgroup | Memory limit | |:---|:---|:---| | Whole node (root cgroup) | / | Not set (the root has no memory.max → [see below](#values-the-kubelet-uses-for-eviction-decisions)) | | All Pods on the node | /kubepods.slice | Capacity - kube-reserved - system-reserved when Pod allocatable enforcement is enabled | | Pod | Differs by QoS class (see below) | The Pod's effective limit, including Pod-level spec.resources.limits.memory when configured | | Container | cri-containerd-<ID>.scope under the Pod's cgroup | That container's limits.memory | The examples here use container-level resources and omit Pod-level resources. Without a Pod-level limit, the required container limits must be present for a finite Pod limit to be set. A Pod's effective limit is not simply the sum of its regular containers' limits.memory. If there are init containers, their maximum is also considered, and spec.overhead is added if set. For the calculation see Resource sharing within containers and Pod Overhead. So even if a container has not reached its own limit, an OOM kill can occur when the Pod as a whole or /kubepods.slice hits its limit. > Note: The paths above assume the kubelet uses the systemd cgroup driver. With the cgroupfs driver, paths look like /kubepods/burstable/pod<UID>/<container ID>, with no .slice or .scope and no cri-containerd- prefix on the container. > > The kubelet's own default is cgroupfs, but kubeadm sets systemd when the value is empty (defaults.go, kubelet.go). In v1.37, a supported runtime's CRI RuntimeConfig response takes precedence through driver auto-detection. Check the effective driver with: > > ```bash > $ kubectl get --raw "/api/v1/nodes/<node name>/proxy/configz" \ | jq '.kubeletconfig.cgroupDriver' > "systemd" > ``` > > The id label of cAdvisor metrics also changes with the cgroup driver, so when filtering in PromQL, check the value in your own cluster with this query: > > ```bash > group by (id) (container_memory_working_set_bytes{id=~"/kubepods|/kubepods.slice"}) > ``` > > A Pod's cgroup path depends on its QoS class. As the tree above shows, Burstable and BestEffort go through an intermediate slice per QoS class. > > In the <UID> part, systemd escaping replaces the hyphens of the Pod UID with underscores (example: 6983351e-4ede-... → pod6983351e_4ede_...). The UID of a static Pod (mirror Pod) is a hash with no hyphens, so it looks like pod1ff70b81885169da4e9e32c9530b77c9.slice. > > The QoS class and UID of a Pod can be read from the Pod resource, and once you have those two you can assemble the cgroup path. > > ```bash > $ kubectl get pod -n <namespace> <Pod name> -o jsonpath='{.status.qosClass}{"\n"}{.metadata.uid}{"\n"}' > Burstable > 6983351e-4ede-4611-8bab-b986614af18d > > # To list them > $ kubectl get pods -n <namespace> -o custom-columns='NAME:.metadata.name,QOS:.status.qosClass' > ``` Container metrics The cAdvisor built into the kubelet exposes the cgroup values above as Prometheus metrics. They are served from the kubelet's /metrics/cadvisor endpoint, and you do not need to run a separate cAdvisor Pod or DaemonSet. | Metric | Emitted by | Backing value in cgroup v2 | Description | |:---|:---|:---|:---| | container_memory_usage_bytes | cAdvisor (embedded in kubelet) | memory.current | Usage including anonymous pages, page cache and kernel memory. The value memcg OOM is evaluated against | | container_memory_working_set_bytes | cAdvisor (embedded in kubelet) | memory.current - inactive_file | Usage minus inactive_file, clamped to zero. A heuristic that still includes some reclaimable memory | | container_memory_rss | cAdvisor (embedded in kubelet) | anon in memory.stat | Amount of anonymous pages. Unlike true RSS, it does not include mmaped file pages (see below) | | container_memory_cache | cAdvisor (embedded in kubelet) | file in memory.stat | Page cache size. Includes shmem (tmpfs and shared memory) | | container_memory_total_active_file_bytes | cAdvisor (embedded in kubelet) | active_file in memory.stat | Active page cache | | container_memory_total_inactive_file_bytes | cAdvisor (embedded in kubelet) | inactive_file in memory.stat | Inactive page cache | | container_memory_mapped_file | cAdvisor (embedded in kubelet) | file_mapped in memory.stat | Amount of mmaped files | | `cont

    2 comments
  7. 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
Trending awards, every completed day and week
Gold
01st place finishes
Silver
02nd place finishes
Bronze
03rd place finishes
Awarded repositories
0Owned by containerd now

No trending awards yet

None of the repositories containerd owns has finished in the top three of a completed trending day or week, by stars, forks or PR authors. See who is trending now.

A repository earns an award for finishing first, second or third in a completed trending day or week, ranked by new stars, new forks or pull request authors. Awards follow a repository through renames and transfers and count toward its current owner. Weeks run Monday to Sunday, UTC.