<?xml version="1.0" encoding="UTF-8"?><?xml-stylesheet href="/rss.xsl" type="text/xsl"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Bhawani Singh</title><description>Articles, notes, and interesting reads on software engineering.</description><link>https://bhawanisingh.dev</link><item><title>Pod Startup Forensics: The Problem</title><link>https://bhawanisingh.dev/blog/pod-startup-forensics-the-problem</link><guid isPermaLink="true">https://bhawanisingh.dev/blog/pod-startup-forensics-the-problem</guid><description>What a slow Kubernetes pod startup actually costs in autoscaling, CI/CD, and on-call time, and why kubectl events and kubelet&apos;s own latency metric can&apos;t answer which pod, which second, or why.</description><pubDate>Sat, 18 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;A pod takes 40 seconds to start instead of 4. It doesn&apos;t look like an incident. Nothing pages. &lt;code&gt;kubectl get pods&lt;/code&gt; eventually shows &lt;code&gt;Running&lt;/code&gt;. The deploy finishes. The only trace is a vague sense that something felt slow, and by the time anyone goes looking, the pod that was actually slow is usually gone.&lt;/p&gt;
&lt;p&gt;Kubernetes has a real metric for this. &lt;code&gt;kubelet&lt;/code&gt; exposes &lt;code&gt;kubelet_pod_start_sli_duration_seconds&lt;/code&gt;, a live histogram, and SIG-Scalability maintains an SLO that the 99th percentile of pod startup latency, per cluster-day, stays under 5 seconds, built on exactly that signal. So the instinct is to assume the problem is already solved.&lt;/p&gt;
&lt;p&gt;It isn&apos;t. That metric tells you the cluster-wide p99 crept up this week. It can&apos;t tell you which pod, which phase, or why.&lt;/p&gt;
&lt;h2&gt;Autoscaling only works if the scale-up is fast&lt;/h2&gt;
&lt;p&gt;The entire value of a Horizontal Pod Autoscaler or Cluster Autoscaler reacting to load is that new capacity shows up before the old capacity falls over. Imagine a scale-up event that takes 45 seconds to go from &quot;decision made&quot; to &quot;pod serving traffic,&quot; against a load spike that doubles request volume in under a minute. The autoscaler technically did its job. It decided to add capacity. The outcome is the same as not having an autoscaler at all: the existing pods absorb the overflow, latency degrades, and the new pod shows up in a system that&apos;s already stopped hurting on its own, or has already tripped a different alarm.&lt;/p&gt;
&lt;p&gt;Pod startup latency isn&apos;t one number. It&apos;s schedule delay, image pull, PVC attach and mount as two separate operations, init containers running in sequence, sidecars starting alongside or before the main container, and the main container itself becoming ready. Every one of those phases is a place autoscaling responsiveness can quietly evaporate, and none of them show up as a single knob to tune. Picture a cluster that scales in a few seconds on a warm node and takes a minute or more on a node that has to pull a fresh image: that cluster doesn&apos;t have one autoscaling latency. It has a distribution, and the tail of that distribution is exactly the case autoscaling exists to handle: sudden load, no warm capacity, has to happen fast.&lt;/p&gt;
&lt;h2&gt;CI/CD pipelines spend most of their wall clock waiting on pods, not running work&lt;/h2&gt;
&lt;p&gt;Tekton, and any CI system built the same way, one pod per unit of work, pays the pod startup cost on every &lt;code&gt;Task&lt;/code&gt; in a pipeline. A &lt;code&gt;PipelineRun&lt;/code&gt; with five sequential &lt;code&gt;Task&lt;/code&gt;s doesn&apos;t pay pod startup once. It pays it five times, because each &lt;code&gt;Task&lt;/code&gt; runs as its own pod with its own schedule, pull, init, and ready sequence. The individual &lt;code&gt;Steps&lt;/code&gt; inside a &lt;code&gt;Task&lt;/code&gt; run as containers sequenced within that one pod, so they share the pod&apos;s startup cost rather than each paying it separately.&lt;/p&gt;
&lt;p&gt;This matters more than it looks like it should, because the actual work inside a Step is frequently fast. In this project&apos;s own testing, a Tekton &lt;code&gt;TaskRun&lt;/code&gt; pod&apos;s step containers finished in under two seconds, fast enough that five separate one-shot lookups run right after the pod was deployed never once caught a step container still &lt;code&gt;running&lt;/code&gt;; they&apos;d already gone &lt;code&gt;terminated&lt;/code&gt; in the time it took to issue the command. That&apos;s one data point, not a universal one, but it illustrates a real dynamic: if the startup overhead wrapped around that Task&apos;s pod, schedule plus image pull plus init container plus container create, itself runs several seconds, the overhead isn&apos;t a rounding error next to the work happening inside it. A pipeline that feels slow is often a pipeline where the build and test commands run fine and the pods wrapping each &lt;code&gt;Task&lt;/code&gt; are the bottleneck, and nothing in a typical CI dashboard separates those two costs from each other.&lt;/p&gt;
&lt;h2&gt;The on-call cost: Guessing with kubectl describe pod&lt;/h2&gt;
&lt;p&gt;When a pod is slow to start in production, not failing, not crash-looping, just slow, the standard move is &lt;code&gt;kubectl describe pod&lt;/code&gt; followed by reading the &lt;code&gt;Events&lt;/code&gt; section top to bottom. That gives a chronological list: &lt;code&gt;Scheduled&lt;/code&gt;, &lt;code&gt;Pulling&lt;/code&gt;, &lt;code&gt;Pulled&lt;/code&gt;, &lt;code&gt;Created&lt;/code&gt;, &lt;code&gt;Started&lt;/code&gt;, maybe a &lt;code&gt;FailedMount&lt;/code&gt; or a repeated &lt;code&gt;Unhealthy&lt;/code&gt; if something is actually wrong. What it doesn&apos;t give is any indication of why a phase took as long as it did.&lt;/p&gt;
&lt;p&gt;So the on-call engineer does arithmetic by hand: subtracting &lt;code&gt;firstTimestamp&lt;/code&gt; values between events to guess how long image pull took, squinting at whether the gap between &lt;code&gt;Pulled&lt;/code&gt; and &lt;code&gt;Started&lt;/code&gt; looks unusually large. If the answer isn&apos;t obvious from the event list, there&apos;s nowhere else to look. If the slow phase is an init container blocking on a &lt;code&gt;connect()&lt;/code&gt; call to a service that&apos;s still coming up, the Event stream shows nothing at all during that block. No event fires while a process is just waiting on a syscall to return. The engineer either already knows this failure mode from having seen it before, or starts guessing: the CNI, DNS, the CSI driver, the registry. That guessing has a real cost: time spent per incident, and because nothing captures the answer, the same ambiguity can get re-diagnosed from scratch the next time it happens.&lt;/p&gt;
&lt;p&gt;A fair fraction of the pods worth interrogating are already gone by the time anyone looks. Kubernetes garbage-collects completed pods, CI systems delete &lt;code&gt;TaskRun&lt;/code&gt; pods after a retention window, and a pod that failed to schedule and got evicted leaves no long-lived object to describe. &lt;code&gt;kubectl describe pod&lt;/code&gt; only works on a pod that still exists. Kubernetes&apos; own Event objects carry a bounded retention window independent of the pod&apos;s own lifecycle, so the moment a pod is deleted, its events go with it on their own separate clock. &quot;Why was that slow&quot; becomes permanently unanswerable through the tools most people reach for first, and for a Tekton &lt;code&gt;Task&lt;/code&gt; pod whose steps finish in a couple of seconds, that clock starts running almost immediately.&lt;/p&gt;
&lt;h2&gt;Why kubectl get events isn&apos;t a phase breakdown&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;kubectl get events&lt;/code&gt;, or &lt;code&gt;describe pod&lt;/code&gt;&apos;s embedded events section, gives the raw material a phase breakdown would be built from, not the breakdown itself. It&apos;s a flat, chronological list of whatever the kubelet and the various controllers happened to report, in the order they happened to report it. There&apos;s no concept of a phase in that stream. No line says &quot;image pull took 10.4 seconds&quot; or &quot;this was the slowest part of startup.&quot; &lt;code&gt;Pulling&lt;/code&gt; shows up at one timestamp and &lt;code&gt;Pulled&lt;/code&gt; at another, and it&apos;s on the reader to subtract them, notice which containers were init containers versus sidecars versus the main container (which, depending on Kubernetes version, can report state in genuinely different shapes: a native sidecar with &lt;code&gt;restartPolicy: Always&lt;/code&gt; shows up as &lt;code&gt;running&lt;/code&gt; in the same status array where an ordinary init container shows &lt;code&gt;terminated&lt;/code&gt;), and reconstruct the ordering by hand. There&apos;s no root cause in that stream by construction, either. An Event is a report that something happened, not an explanation of what the process was doing while it was blocked. If a container spent eight seconds inside a &lt;code&gt;connect()&lt;/code&gt; call, no Event says so, because Events cover what Kubernetes&apos; own controllers observed, and no controller is watching syscalls.&lt;/p&gt;
&lt;h2&gt;Why kubelet&apos;s own latency metric doesn&apos;t answer this either&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;kubelet_pod_start_sli_duration_seconds&lt;/code&gt; is a genuinely real, useful signal for a specific question, which makes it tempting to assume it solves this one too. The gap is structural, not a matter of granularity.&lt;/p&gt;
&lt;p&gt;That histogram answers &quot;is pod startup latency across this cluster within SLO, in aggregate, over time.&quot; It&apos;s a single histogram per node, rolled up again across every node in the cluster to produce the SIG-Scalability SLO number: a fast-starting sidecar-free stateless pod and a slow multi-init-container stateful pod both feed the same buckets. No per-pod breakout, no phase attribution within a single pod&apos;s startup, no timestamp to point at and say &quot;this second is where the time went.&quot; A cluster-wide p99 can trend upward on a dashboard for a week straight with no way to go from that trend to a single pod name worth investigating.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;kubectl get events&lt;/code&gt; gives raw, unstructured chronology for one pod at a time, with no phase model and no root cause. kubelet&apos;s own metric gives an aggregate trend across the whole cluster, with no per-pod resolution at all. Between a single pod&apos;s raw chronology and the cluster&apos;s aggregate trend sits the actual question: which pod, which second, why. Neither tool covers that middle ground, and nothing else currently does either.&lt;/p&gt;
</content:encoded><author>Bhawani Singh</author></item><item><title>Pod Startup Forensics: The Tooling Gap</title><link>https://bhawanisingh.dev/blog/pod-startup-forensics-the-tooling-gap</link><guid isPermaLink="true">https://bhawanisingh.dev/blog/pod-startup-forensics-the-tooling-gap</guid><description>A survey of what eBPF observability tools, continuous profilers, and generic APM platforms actually do today, and why none of them attribute pod startup latency to a phase or a syscall.</description><pubDate>Wed, 22 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;code&gt;kubectl get events&lt;/code&gt; gives raw chronology with no phase model. &lt;code&gt;kubelet_pod_start_sli_duration_seconds&lt;/code&gt; gives a cluster-wide aggregate with no per-pod resolution. Nothing sits between them. Kubernetes observability isn&apos;t short on eBPF-based tooling, though, and eBPF is exactly the mechanism you&apos;d reach for to explain why a phase blocked. So it&apos;s worth checking, tool by tool, what each one actually does today, not what its name suggests it might do.&lt;/p&gt;
&lt;h2&gt;Tetragon: Real syscall visibility, no concept of a phase&lt;/h2&gt;
&lt;p&gt;Tetragon is Cilium&apos;s eBPF-based runtime security and observability tool. It hooks kernel functions directly and streams events for process execution, system call activity, and file/network I/O. Every event carries a &lt;code&gt;process_exec&lt;/code&gt; block identifying the process that generated it, a &lt;code&gt;node_name&lt;/code&gt; and &lt;code&gt;time&lt;/code&gt;, and, because Tetragon is Kubernetes-aware, the pod, namespace, container, and labels the process belongs to. That&apos;s real, fine-grained, per-process attribution, and it&apos;s exactly the kind of signal you&apos;d want during a slow init container: which syscall, which process, which pod, down to the timestamp.&lt;/p&gt;
&lt;p&gt;What Tetragon doesn&apos;t have is any notion of a phase to attach that signal to. Each event type, &lt;code&gt;process_exec&lt;/code&gt;, &lt;code&gt;process_exit&lt;/code&gt;, &lt;code&gt;process_kprobe&lt;/code&gt;, &lt;code&gt;process_uprobe&lt;/code&gt;, &lt;code&gt;process_tracepoint&lt;/code&gt;, is a standalone record. Correlation across events happens through shared process IDs and parent/child &lt;code&gt;exec_id&lt;/code&gt; relationships, not through any built-in lifecycle or phase concept, and nothing in the documentation describes or gestures at one. Tetragon will tell you a process inside a given pod called &lt;code&gt;connect()&lt;/code&gt; and give you the pod identity and a timestamp. It won&apos;t tell you that call happened during the init-container phase rather than the main-container phase, because it has no documented model of &quot;phase&quot; at all. That&apos;s a Kubernetes pod-lifecycle concept, and Tetragon operates one layer below it, at the process and syscall level. Getting from a Tetragon event stream to &quot;this pod&apos;s startup was slow because of X&quot; requires bringing in a phase model from somewhere else, Tetragon has none, and hand-correlating a syscall trace against it. Right primitive, missing the other half.&lt;/p&gt;
&lt;h2&gt;Pixie: Deep runtime tracing, built for services that are already running&lt;/h2&gt;
&lt;p&gt;Pixie (px.dev) is a CNCF sandbox project, also eBPF-based, that automatically instruments HTTP, gRPC, DNS, and SQL traffic without code changes, builds service maps from the traffic it observes, and produces CPU flame graphs and resource profiles broken down by pod, node, and namespace. It stores everything it collects locally in-cluster, in memory, which keeps it fast and avoids exporting data externally. That also means its data is short-lived by design, not a long-term store.&lt;/p&gt;
&lt;p&gt;Pixie&apos;s entire value proposition is built around instant visibility into services that are already handling traffic: request/response bodies, query latencies, who&apos;s calling whom. None of that maps onto pod startup. A pod that&apos;s still in its init-container phase isn&apos;t serving HTTP, issuing SQL queries, or generating the kind of application-layer traffic Pixie&apos;s protocol parsers are built to detect automatically, so there&apos;s nothing for its tracers to attach to yet. Its resource-profiling and flame-graph features apply to a pod&apos;s CPU behavior once the main container is up and running, not to the sequence of schedule, image pull, PVC attach and mount, and init-container execution that happens before that point. Pixie is a strong answer to &quot;why is this running service slow right now&quot; and has no documented model of the pod-startup path, because that was never the problem it set out to solve.&lt;/p&gt;
&lt;h2&gt;Parca and Pyroscope: Continuous profilers, sampling code that&apos;s already executing&lt;/h2&gt;
&lt;p&gt;Parca and Grafana Pyroscope are both continuous profilers, and both work on the same basic principle: sample stack traces from running processes at a fixed rate and aggregate them into flame graphs over time. Parca&apos;s agent, also eBPF-based, samples user-space and kernel-space stack traces 19 times per second and discovers targets across Kubernetes and systemd automatically, with no code changes or restarts required. Pyroscope does the same continuous aggregation across a longer language list, Go, Java, Python, Ruby, Node.js, .NET, Rust, plus eBPF-based system-level profiling, and correlates its profiles with a project&apos;s existing metrics, logs, and traces.&lt;/p&gt;
&lt;p&gt;Both tools answer &quot;where&apos;s this process spending its CPU time right now, and how has that changed over the last hour.&quot; Sampling stack traces at a fixed frequency only produces a meaningful flame graph once a process has run long enough to accumulate samples across representative code paths. That&apos;s a statistical technique, and pod startup is a one-shot sequence of distinct phases (schedule, pull, attach, mount, init, ready), not a steady-state workload to sample against. Neither tool&apos;s documentation mentions process startup, container initialization, or anything resembling a &quot;pod startup phase.&quot; That tracks: a continuous profiler answers questions about ongoing CPU cost. It has no documented mechanism for asking why a container took four seconds to go from &lt;code&gt;Created&lt;/code&gt; to &lt;code&gt;Started&lt;/code&gt;, because that question isn&apos;t about steady-state CPU time. It&apos;s about a specific, short, one-time sequence of blocking operations.&lt;/p&gt;
&lt;h2&gt;The generic platforms: Broad coverage, no startup-specific model&lt;/h2&gt;
&lt;p&gt;The remaining category is the generic Kubernetes observability platform: the kind that bundles logs, metrics, traces, and often eBPF-based profiling into one product and sells breadth. Metoro is representative. It combines eBPF-based kernel telemetry, APM-style dashboards and service maps, log collection, and continuous CPU/memory profiling into what it describes as seven integrated signals (logs, metrics, traces, profiling, Kubernetes events, resources, deployment context), installed with a single Helm chart. That&apos;s a real, wide net, and it explicitly counts Kubernetes events as one of its signal types. It&apos;s still a production-monitoring and incident-detection platform at its core, built around dashboards, alerting, and automated root-cause analysis for services under load, not a dedicated model of the schedule-to-ready sequence a pod goes through before it starts serving anything.&lt;/p&gt;
&lt;p&gt;OpenObserve sits a layer further out: a unified logs/metrics/traces/RUM store, OpenTelemetry-native, built and marketed primarily on cost and query performance rather than any Kubernetes-specific diagnostic feature, and that focus makes it a genuinely strong place to centralize telemetry cheaply at scale. It&apos;s infrastructure you&apos;d point other tools&apos; telemetry at, not a pod-lifecycle tool by itself.&lt;/p&gt;
&lt;p&gt;The shape repeats across this category: broad signal coverage, real engineering behind each individual signal, and no dedicated concept of a pod-startup phase anywhere in the product. They&apos;re built to answer &quot;what&apos;s wrong with my running system,&quot; which is a different question, asked at a different point in a pod&apos;s life, than &quot;which of these six startup phases was slow, and why.&quot;&lt;/p&gt;
&lt;h2&gt;What&apos;s actually missing&lt;/h2&gt;
&lt;p&gt;Line these up and the shape of the gap gets sharper, not vaguer. Tetragon has the syscall-level ground truth but no phase model to hang it on. Pixie has deep runtime tracing but nothing to trace before a pod&apos;s main container is serving traffic. Parca and Pyroscope have continuous sampling profilers built for steady-state code, a different shape of problem entirely from a one-shot startup sequence. The generic platforms have breadth without a startup-specific model anywhere in that breadth. Every one of them is a legitimately strong tool at what it was built for, and every one of them was built for a question other than which pod, which second, why. That&apos;s not four small gaps between four adjacent categories of tool. It&apos;s one gap, looked at from four angles.&lt;/p&gt;
</content:encoded><author>Bhawani Singh</author></item><item><title>Pod Startup Forensics: The Idea</title><link>https://bhawanisingh.dev/blog/pod-startup-forensics-the-idea</link><guid isPermaLink="true">https://bhawanisingh.dev/blog/pod-startup-forensics-the-idea</guid><description>A design thesis for closing the pod-startup gap: combine the Kubernetes API&apos;s phase boundaries with eBPF&apos;s syscall-level evidence, correlated by pod identity and persisted before the pod disappears.</description><pubDate>Sun, 26 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Tetragon has the syscall trace. It doesn&apos;t have a phase. The Kubernetes API has the phase. It doesn&apos;t have the syscall trace. Every tool in the last part&apos;s survey sits on one side of that line, and no amount of squinting at any single one of them closes the gap, because the gap isn&apos;t a missing feature. It&apos;s a missing join.&lt;/p&gt;
&lt;h2&gt;Two sources, two different kinds of authority&lt;/h2&gt;
&lt;p&gt;The Kubernetes API knows, with certainty, when the scheduler binds a pod to a node, when the kubelet started pulling an image and when it finished, when a container&apos;s status flipped from &lt;code&gt;waiting&lt;/code&gt; to &lt;code&gt;running&lt;/code&gt; to &lt;code&gt;terminated&lt;/code&gt;. That certainty comes from a specific place: every one of those transitions is reported by a controller that owns the transition. The scheduler reports scheduling. The kubelet reports image pull and container state. Nothing downstream has to guess, because the component doing the work is the same component filing the event.&lt;/p&gt;
&lt;p&gt;That authority has a sharp edge. An Event is a report that something happened, not an account of what the process was doing while it happened. The gap between &lt;code&gt;Pulled&lt;/code&gt; and the next event can be eight seconds of an init container blocked inside &lt;code&gt;connect()&lt;/code&gt;, and the Event stream says nothing about it, because no controller is watching syscalls. It can&apos;t say more than it knows, and what it knows stops at the boundary between phases, not at what happens inside one.&lt;/p&gt;
&lt;p&gt;eBPF sits on the opposite side of that boundary. A tracepoint on &lt;code&gt;sys_enter_connect&lt;/code&gt; and &lt;code&gt;sys_exit_connect&lt;/code&gt; doesn&apos;t know what a &quot;phase&quot; is, doesn&apos;t know what a Tekton &lt;code&gt;TaskRun&lt;/code&gt; is, doesn&apos;t know that the process it&apos;s watching belongs to a pod at all until something tells it. What it knows, with just as much certainty as the Kubernetes API knows its own transitions, is the syscall: which process called &lt;code&gt;connect()&lt;/code&gt;, when it entered the kernel, when it returned, what the return code was. That&apos;s exactly the evidence missing from the Event stream. It&apos;s also evidence with no label. A syscall trace on its own is a wall of &lt;code&gt;pid&lt;/code&gt;, &lt;code&gt;comm&lt;/code&gt;, and timestamps with nothing in it that says &quot;startup&quot; or &quot;init container&quot; or &quot;this belongs to the pod that&apos;s currently 8 seconds into a phase someone should be looking at.&quot;&lt;/p&gt;
&lt;p&gt;Neither source can be patched to cover the other&apos;s blind spot, because the blind spot isn&apos;t a bug in either one. The Kubernetes API doesn&apos;t lack syscall visibility by oversight; it lacks it because no controller in the chain from scheduler to kubelet operates at that level, and adding syscall tracing to a controller would be a different, much stranger design than the one Kubernetes has. eBPF doesn&apos;t lack a phase model by oversight either; a tracepoint fires on a kernel event, not a Kubernetes concept, and there&apos;s no phase field in a &lt;code&gt;sys_enter_connect&lt;/code&gt; argument list to read one from. The right design isn&apos;t &quot;make the API smarter about syscalls&quot; or &quot;make eBPF smarter about Kubernetes.&quot; It&apos;s to keep each source doing what it&apos;s actually good at, and build the missing piece neither one provides on its own: a join keyed on pod identity, with the API supplying the authoritative phase labels and eBPF supplying the syscall-level evidence for what happened during a phase flagged as slow.&lt;/p&gt;
&lt;p&gt;That&apos;s a design decision with real content, not a truism. It means the API is never asked to explain &lt;em&gt;why&lt;/em&gt; a phase was slow, and eBPF is never asked to say &lt;em&gt;which&lt;/em&gt; phase anything happened in. Each source answers only the question it&apos;s actually positioned to answer, and the tool&apos;s job is entirely in the join: watch for a phase that&apos;s taking too long, and when one is flagged, attach the syscall evidence from that pod&apos;s cgroup during that exact window. The output isn&apos;t &quot;container start took N seconds.&quot; It&apos;s &quot;container start took N seconds, and the process spent nearly all of it blocked inside &lt;code&gt;connect()&lt;/code&gt; to an address that never answered.&quot; One source says which seconds mattered. The other says what filled them.&lt;/p&gt;
&lt;h2&gt;The pod that&apos;s already gone by the time you&apos;d query it&lt;/h2&gt;
&lt;p&gt;There&apos;s a second design constraint that doesn&apos;t come from the tooling gap directly. It comes from a fact Part 1 already established: a Tekton &lt;code&gt;TaskRun&lt;/code&gt; pod can run its steps and terminate in under two seconds. Any design that answers &quot;which pod, which phase, why&quot; by querying live cluster state is structurally blind to exactly the pods most worth investigating after the fact, because by the time a human goes looking, the pod that mattered is gone and so is everything about it.&lt;/p&gt;
&lt;p&gt;This rules out an entire category of otherwise-reasonable design: a tool that watches the API and the kernel live, holds the correlated result in memory or in a dashboard, and answers questions against whatever&apos;s currently running. That shape works for the pod that&apos;s slow right now, in front of you, while you&apos;re watching. It fails the pod that was slow ten minutes ago and no longer exists, which is the more common case for anything that resembles CI. Kubernetes&apos; own Event objects carry a retention window independent of the pod&apos;s lifecycle, so even the raw material for a manual reconstruction disappears on its own clock, faster than most people think to look.&lt;/p&gt;
&lt;p&gt;So durability isn&apos;t a feature to add once the phase-and-syscall correlation is working. It has to be there from the first design decision, because the alternative isn&apos;t a smaller version of the same tool. It&apos;s a tool that answers a different, less useful question: not &quot;what happened to that pod,&quot; but &quot;what&apos;s happening to pods in general right now.&quot; Those look similar from a distance and aren&apos;t the same product. The design has to capture and persist the correlated result as it happens, continuously, for every pod, not on demand for whichever pod someone happens to ask about while it&apos;s still alive. That single constraint decides most of the shape below: an on-demand query design and an always-watching, always-persisting design are different architectures, not different configurations of the same one.&lt;/p&gt;
&lt;h2&gt;The shape the idea takes&lt;/h2&gt;
&lt;p&gt;Put the two constraints together and the design isn&apos;t a single new tool bolted where the gap is. It&apos;s four pieces, each answering to one of the constraints above, wired together by pod identity: a watcher on the Kubernetes API, a kernel-level tracer, a correlation step, and durable storage.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A continuous watcher on the Kubernetes API.&lt;/strong&gt; Not a poller that checks in on a fixed interval, because a fixed interval can miss a phase that starts and ends inside the gap between checks, exactly the failure mode that makes a fast Tekton step invisible to anything slower than the step itself. A watch reacts to each transition as the API reports it: pod scheduled, image pull started and finished, each init and sidecar and main container&apos;s state change, in order, as they happen. This is the source of authoritative phase boundaries and labels, and it runs whether or not anything downstream is currently interesting, because the pod that turns out to matter isn&apos;t known in advance.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A kernel-level tracer that runs continuously, and a phase timeline that decides what to keep looking at.&lt;/strong&gt; The tracer itself has to watch every syscall on every pod, all the time, because there&apos;s no way to go back and capture a syscall after it already happened; nothing about the design can afford to be selective at the point of capture. What&apos;s selective is the read side. The watcher&apos;s own phase timeline is what decides which stretch of that stream is worth pulling: when a phase crosses a threshold that marks it as unusually slow, that&apos;s the signal for the correlation step to pull the syscall evidence already captured for that pod&apos;s processes during that window, instead of surfacing all of it undifferentiated. The syscall-level tracer&apos;s job isn&apos;t to explain everything a container does. It&apos;s to have already captured the specific stretch of time the phase-level view goes on to flag as worth explaining.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A correlation step keyed on pod identity, not on time alone.&lt;/strong&gt; A timestamp overlap between an API event and a kernel event isn&apos;t enough on a real node, because a real node runs more than one pod&apos;s processes concurrently, and two pods can be inside their own slow phases at the same moment for entirely unrelated reasons. Attributing a syscall to the wrong pod because they happened to overlap in time would produce a confident, wrong answer, which is worse than an honest &quot;unknown.&quot; The join has to resolve which pod a given process actually belongs to, and hold that resolution stable across a phase boundary that spans multiple wake-ups of the correlator, not just check it once and assume it still holds.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Durable storage that survives the pod&apos;s own deletion.&lt;/strong&gt; The output of the watch-and-correlate pipeline can&apos;t live only in memory or only behind a live query, for the reason the pod-lifecycle constraint above already argued: the moment matters, but by the time anyone asks about it, the pod producing that moment is frequently gone. Every phase transition and every correlated syscall gets written down as it&apos;s observed, keyed on the pod&apos;s identity rather than on the pod object still existing, so a later lookup against a pod that &lt;code&gt;kubectl&lt;/code&gt; has never heard of still has somewhere to go.&lt;/p&gt;
&lt;p&gt;None of these four pieces is individually new. Watching the Kubernetes API for state transitions is what every controller in the system already does. Kernel-level tracing on syscalls is the mechanism Tetragon, Pixie, and Parca all already build on. Persisting observed state so it outlives the object it describes isn&apos;t a novel idea in distributed systems. What&apos;s missing from every existing tool surveyed against this problem isn&apos;t any one of these four pieces. It&apos;s the combination: authoritative phase labels from one source, syscall-level evidence from another, joined on identity rather than assumed from timing, and captured continuously rather than queried on demand. That combination is the actual shape of an answer to &quot;which pod, which second, why,&quot; for a pod that might not exist by the time anyone asks the question.&lt;/p&gt;
</content:encoded><author>Bhawani Singh</author></item><item><title>Disk I/O QoS for Kubernetes with cgroup v2 io.weight</title><link>https://bhawanisingh.dev/blog/kubernetes-disk-io-qos-cgroup-v2</link><guid isPermaLink="true">https://bhawanisingh.dev/blog/kubernetes-disk-io-qos-cgroup-v2</guid><description>A Go daemon maps pod priority to cgroup v2 io.weight, tested against real I/O contention on a kind cluster. io.latency wasn&apos;t available, so this measures what proportional I/O weighting delivers.</description><pubDate>Sun, 09 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Kubernetes can request and limit CPU, memory, and ephemeral-storage capacity. It has no native resource model for disk I/O bandwidth, IOPS, or latency QoS.&lt;/p&gt;
&lt;p&gt;CPU has &lt;code&gt;requests&lt;/code&gt; and &lt;code&gt;limits&lt;/code&gt;. Memory has &lt;code&gt;requests&lt;/code&gt;, &lt;code&gt;limits&lt;/code&gt;, and OOM eviction. &lt;code&gt;ResourceQuota&lt;/code&gt; can account for &lt;code&gt;requests.ephemeral-storage&lt;/code&gt;/&lt;code&gt;limits.ephemeral-storage&lt;/code&gt;, but that&apos;s disk &lt;em&gt;capacity&lt;/em&gt;, not disk &lt;em&gt;performance&lt;/em&gt;. A backup job writing two gigabytes to the same device your database reads from can stay well inside its storage quota while still saturating the shared device, and neither the scheduler nor &lt;code&gt;ResourceQuota&lt;/code&gt; sees that contention at all. Kubernetes&apos; own resource-management docs describe capacity accounting for storage; nothing performance-related. cgroup v2 has had mechanisms in this area for years; Kubernetes itself has not exposed them as native Pod resource semantics.&lt;/p&gt;
&lt;p&gt;The mechanism I went in planning to use was &lt;code&gt;io.latency&lt;/code&gt;: a cgroup v2 interface that takes a target completion latency for one cgroup and, once that cgroup misses its target, throttles peer cgroups whose own latency target is higher (via queue-depth throttling and, for some I/O, artificial delay). That&apos;s a mechanism for protecting a workload toward a configured latency target, not a hard SLA, but it maps almost directly onto &quot;protect the database from the backup job.&quot;&lt;/p&gt;
&lt;p&gt;It wasn&apos;t there. Not disabled, not zero-valued: &lt;code&gt;find /sys/fs/cgroup -name &quot;io.latency&quot;&lt;/code&gt; across every cgroup on the node returned nothing at all. The node runs Debian 13, kernel &lt;code&gt;6.8.0-117-generic&lt;/code&gt;, inside Docker Desktop&apos;s Linux VM on macOS, and that VM&apos;s kernel doesn&apos;t build in &lt;code&gt;CONFIG_BLK_CGROUP_IOLATENCY&lt;/code&gt; support. (&lt;code&gt;io.latency&lt;/code&gt;&apos;s config option is separate from iocost&apos;s &lt;code&gt;CONFIG_BLK_CGROUP_IOCOST&lt;/code&gt;, a different facility, not tested here either.)&lt;/p&gt;
&lt;p&gt;So this post measures the fallback. I built a daemon around &lt;code&gt;io.weight&lt;/code&gt;, ran two fio jobs against the same block device with and without it running, and recovered 52% of the protected workload&apos;s lost throughput. That number is real, and it&apos;s also the weaker half of the story, because &lt;code&gt;io.weight&lt;/code&gt; is a ratio rather than a reservation and the benchmark shows exactly what that costs.&lt;/p&gt;
&lt;h2&gt;What io.weight actually is&lt;/h2&gt;
&lt;p&gt;Every non-root cgroup v2 group with the &lt;code&gt;io&lt;/code&gt; controller enabled exposes &lt;code&gt;io.weight&lt;/code&gt; as a plain text file, default &lt;code&gt;100&lt;/code&gt;, range 1 to 10000. Writing &lt;code&gt;default 500&lt;/code&gt; into it sets a proportional share of I/O service time against every device the cgroup touches; under contention, I/O service is distributed roughly by weight ratio between the competing cgroups, and the resulting bandwidth/IOPS split depends on request size, workload, and device. The two workloads below differ by 256x in request size specifically to surface that.&lt;/p&gt;
&lt;p&gt;That answers &quot;who gets more when everyone&apos;s competing.&quot; It doesn&apos;t answer &quot;can this workload maintain a target latency under contention,&quot; which is the problem &lt;code&gt;io.latency&lt;/code&gt; is designed to address. The substitution isn&apos;t cosmetic: &lt;code&gt;io.weight&lt;/code&gt; shifts a ratio and doesn&apos;t reserve a floor, so everything measured below is a proportional-share mechanism standing in for a target-latency one. If you&apos;re building this for real, check &lt;code&gt;io.latency&lt;/code&gt; on your actual node image before you design around it. It isn&apos;t universal, even now.&lt;/p&gt;
&lt;h2&gt;The cgroup path is not where the docs suggest&lt;/h2&gt;
&lt;p&gt;My starting assumption was the commonly cited &lt;code&gt;/sys/fs/cgroup/kubepods.slice/...&lt;/code&gt;. On this cluster no such directory exists at that level. With the systemd cgroup driver and kubelet&apos;s &lt;code&gt;cgroupRoot&lt;/code&gt; set to &lt;code&gt;/kubelet&lt;/code&gt;, the live hierarchy is:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/sys/fs/cgroup/kubelet.slice/
  kubelet.service
  kubelet-kubepods.slice/
    io.max, io.weight, io.stat, io.prio.class, cgroup.subtree_control
    kubelet-kubepods-besteffort.slice/
      kubelet-kubepods-besteffort-pod&amp;lt;uid_with_underscores&amp;gt;.slice/
    kubelet-kubepods-burstable.slice/
      kubelet-kubepods-burstable-pod&amp;lt;uid_with_underscores&amp;gt;.slice/   &amp;lt;- write target
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Guaranteed pods sit directly under &lt;code&gt;kubelet-kubepods.slice/&lt;/code&gt;, while Burstable and BestEffort pods get an extra QoS-class slice layer in between. UID dashes become underscores in the leaf name.&lt;/p&gt;
&lt;p&gt;The important part isn&apos;t the exact path. It&apos;s that the path is derived from &lt;code&gt;cgroupRoot&lt;/code&gt;, QoS class, and pod UID, which is why it&apos;s a flag in the daemon rather than a hardcoded constant: this depends on the cgroup driver and kubelet&apos;s configuration, and a cgroupfs-driver cluster wouldn&apos;t follow this systemd slice naming at all.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;func PodSlicePath(cgroupRoot, qosClass, uid string) (string, error) {
	if uid == &quot;&quot; {
		return &quot;&quot;, fmt.Errorf(&quot;cgroup: empty pod UID&quot;)
	}
	base, err := kubepodsBase(cgroupRoot)
	if err != nil {
		return &quot;&quot;, err
	}

	normalizedUID := strings.ReplaceAll(uid, &quot;-&quot;, &quot;_&quot;)
	lowerQOS := strings.ToLower(qosClass)

	switch lowerQOS {
	case &quot;guaranteed&quot;:
		leaf := fmt.Sprintf(&quot;kubelet-kubepods-pod%s.slice&quot;, normalizedUID)
		return base + &quot;/&quot; + leaf, nil
	case &quot;burstable&quot;, &quot;besteffort&quot;:
		qosSlice := fmt.Sprintf(&quot;kubelet-kubepods-%s.slice&quot;, lowerQOS)
		leaf := fmt.Sprintf(&quot;kubelet-kubepods-%s-pod%s.slice&quot;, lowerQOS, normalizedUID)
		return base + &quot;/&quot; + qosSlice + &quot;/&quot; + leaf, nil
	default:
		return &quot;&quot;, fmt.Errorf(&quot;cgroup: unknown QOS class %q&quot;, qosClass)
	}
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Per-device weight returns EIO&lt;/h2&gt;
&lt;p&gt;The first write form I tried was per-device, one ratio per block device, which is the form the interface documentation describes:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ echo &quot;253:16 200&quot; &amp;gt; .../low-priority.../io.weight
sh: 7: echo: echo: I/O error
exit code: 1
$ cat .../low-priority.../io.weight
default 10
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The shell reports &lt;code&gt;I/O error&lt;/code&gt; and the file retains its previous value, so the write was rejected outright rather than silently ignored. The cgroup-wide form, on the same file, in the same cgroup, seconds later:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ echo 150 &amp;gt; .../kubelet-kubepods-burstable-podX.../io.weight
$ cat    .../kubelet-kubepods-burstable-podX.../io.weight
default 150
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The scheduler is the wrong place to look. In cgroup v2 the file named &lt;code&gt;io.weight&lt;/code&gt; is registered by the iocost controller (&lt;code&gt;CONFIG_BLK_CGROUP_IOCOST&lt;/code&gt;); BFQ exposes its own separate file, &lt;code&gt;io.bfq.weight&lt;/code&gt;. So &lt;code&gt;vdb&lt;/code&gt; running &lt;code&gt;mq-deadline&lt;/code&gt; rather than BFQ (&lt;code&gt;/sys/block/vdb/queue/scheduler&lt;/code&gt; shows &lt;code&gt;none [mq-deadline]&lt;/code&gt;) doesn&apos;t by itself explain the rejection. The likelier candidate is &lt;code&gt;-EOPNOTSUPP&lt;/code&gt;, which the per-device path returns when the policy isn&apos;t enabled for that device: &lt;code&gt;blk-iocost.c&lt;/code&gt; has no &lt;code&gt;EIO&lt;/code&gt; return at all, and &lt;code&gt;sh: echo: I/O error&lt;/code&gt; is the shell&apos;s strerror output rather than a captured errno. I didn&apos;t capture the raw errno, so I can&apos;t map it to a specific return path.&lt;/p&gt;
&lt;p&gt;What I can say is what reproduced: the per-device write fails and the cgroup-wide write succeeds, twice, in separate sessions. The daemon writes the cgroup-wide form exclusively, because it&apos;s the only form I was able to apply successfully on this kernel and device.&lt;/p&gt;
&lt;h2&gt;The daemon&lt;/h2&gt;
&lt;p&gt;It polls two pods, reads an &lt;code&gt;io-qos.demo/tier&lt;/code&gt; annotation (&lt;code&gt;high&lt;/code&gt;/&lt;code&gt;low&lt;/code&gt;, no PriorityClass wiring), maps tier to weight, and writes it.&lt;/p&gt;
&lt;p&gt;The write-then-verify pattern below exists directly because of the EIO above: a write can fail depending on form, so the daemon reads the value back rather than trusting that the write call succeeded.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;func (d *Daemon) reconcileOne(ctx context.Context, t Target) error {
	pod, err := d.Client.CoreV1().Pods(t.Namespace).Get(ctx, t.Name, metav1.GetOptions{})
	if err != nil {
		return fmt.Errorf(&quot;get pod: %w&quot;, err)
	}

	if pod.Status.Phase != corev1.PodRunning {
		log.Printf(&quot;qosd: %s/%s: phase=%s, waiting&quot;, t.Namespace, t.Name, pod.Status.Phase)
		return nil
	}

	tier, ok := pod.Annotations[TierAnnotation]
	if !ok {
		return fmt.Errorf(&quot;missing annotation %s&quot;, TierAnnotation)
	}
	weight, ok := TierWeight[tier]
	if !ok {
		return fmt.Errorf(&quot;unknown tier %q&quot;, tier)
	}

	if applied, ok := d.applied[string(pod.UID)]; ok &amp;amp;&amp;amp; applied == weight {
		return nil
	}

	slicePath, err := cgroup.PodSlicePath(d.CgroupRoot, string(pod.Status.QOSClass), string(pod.UID))
	if err != nil {
		return fmt.Errorf(&quot;resolve cgroup path: %w&quot;, err)
	}

	written, readback, err := cgroup.WriteWeight(slicePath, weight)
	if err != nil {
		return fmt.Errorf(&quot;write weight: %w&quot;, err)
	}
	...
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;func WriteWeight(slicePath string, weight int) (written string, readback string, err error) {
	line, err := WeightLine(weight)
	if err != nil {
		return &quot;&quot;, &quot;&quot;, err
	}

	weightFile := slicePath + &quot;/io.weight&quot;
	if err := os.WriteFile(weightFile, []byte(line), 0644); err != nil {
		return line, &quot;&quot;, fmt.Errorf(&quot;cgroup: write %s: %w&quot;, weightFile, err)
	}

	data, err := os.ReadFile(weightFile)
	if err != nil {
		return line, &quot;&quot;, fmt.Errorf(&quot;cgroup: read back %s: %w&quot;, weightFile, err)
	}

	return line, string(data), nil
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;go test ./...&lt;/code&gt; covers path construction for all three QoS classes, weight formatting at the range boundaries, and write/read-back against a temp dir. It ran as a plain process inside &lt;code&gt;nginx-lab-control-plane&lt;/code&gt;, cross-compiled for &lt;code&gt;linux/arm64&lt;/code&gt; and &lt;code&gt;docker cp&lt;/code&gt;&apos;d in, pointed at &lt;code&gt;/etc/kubernetes/admin.conf&lt;/code&gt;. No DaemonSet, no image build.&lt;/p&gt;
&lt;h2&gt;The test&lt;/h2&gt;
&lt;p&gt;Two Burstable pods, &lt;code&gt;low-priority-writer&lt;/code&gt; and &lt;code&gt;high-priority-reader&lt;/code&gt;, running &lt;code&gt;fio&lt;/code&gt; inside Alpine containers against a &lt;code&gt;hostPath&lt;/code&gt; on the same device (&lt;code&gt;/dev/vdb1&lt;/code&gt;). Low priority: &lt;code&gt;--rw=write --bs=1M --size=2G --numjobs=4&lt;/code&gt;, heavy sequential. High priority: &lt;code&gt;--rw=randwrite --bs=4k --numjobs=1 --size=256M&lt;/code&gt;, database-shaped.&lt;/p&gt;
&lt;p&gt;fio has to run inside the pods rather than on the node, and that&apos;s not a stylistic choice. I/O issued from the node&apos;s own shell doesn&apos;t run inside any pod&apos;s cgroup, so the weights under test wouldn&apos;t apply to it at all. That meant a separate Alpine image with fio built into it, loaded into the kind cluster.&lt;/p&gt;
&lt;p&gt;All three stages use &lt;code&gt;--ioengine=psync&lt;/code&gt; rather than a queued engine. Same engine across all three runs, so it moves the absolute numbers, not the comparison.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kubectl exec high-priority-reader -- fio --name=highprio --directory=/data \
  --rw=randwrite --bs=4k --ioengine=psync --numjobs=1 --size=256M \
  --time_based --runtime=60s --output-format=json --group_reporting
&lt;/code&gt;&lt;/pre&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th&gt;IOPS&lt;/th&gt;
&lt;th&gt;Bandwidth (KB/s)&lt;/th&gt;
&lt;th&gt;clat mean (µs)&lt;/th&gt;
&lt;th&gt;clat p99 (µs)&lt;/th&gt;
&lt;th&gt;% of solo ceiling&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1. Solo baseline&lt;/td&gt;
&lt;td&gt;163,061&lt;/td&gt;
&lt;td&gt;652,244&lt;/td&gt;
&lt;td&gt;4.5&lt;/td&gt;
&lt;td&gt;9.0&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2. Contended, no daemon&lt;/td&gt;
&lt;td&gt;71,255&lt;/td&gt;
&lt;td&gt;285,019&lt;/td&gt;
&lt;td&gt;11.4&lt;/td&gt;
&lt;td&gt;7.6&lt;/td&gt;
&lt;td&gt;43.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3. Protected, daemon running&lt;/td&gt;
&lt;td&gt;118,970&lt;/td&gt;
&lt;td&gt;475,879&lt;/td&gt;
&lt;td&gt;6.5&lt;/td&gt;
&lt;td&gt;6.9&lt;/td&gt;
&lt;td&gt;73.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Before the daemon, both pods sat at the kernel default:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ cat .../kubelet-kubepods-burstable-pod938cca59_..._.slice/io.weight
default 100
$ cat .../kubelet-kubepods-burstable-pode24d2cb2_..._.slice/io.weight
default 100
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Daemon log, after reconciling both:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;2026/08/09 04:15:39 qosd: default/low-priority-writer uid=938cca59-498a-41ef-86d0-80c39c1d8a83 tier=low qos=Burstable path=/sys/fs/cgroup/kubelet.slice/kubelet-kubepods.slice/kubelet-kubepods-burstable.slice/kubelet-kubepods-burstable-pod938cca59_498a_41ef_86d0_80c39c1d8a83.slice/io.weight wrote=&quot;default 10&quot; readback=&quot;default 10&quot;
2026/08/09 04:15:39 qosd: default/high-priority-reader uid=e24d2cb2-c787-4862-8a73-7f3e4d93ba92 tier=high qos=Burstable path=/sys/fs/cgroup/kubelet.slice/kubelet-kubepods.slice/kubelet-kubepods-burstable.slice/kubelet-kubepods-burstable-pode24d2cb2_c787_4862_8a73_7f3e4d93ba92.slice/io.weight wrote=&quot;default 500&quot; readback=&quot;default 500&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;sequenceDiagram
    participant Low as low-priority-writer&amp;lt;br/&amp;gt;(2G sequential write)
    participant Dev as /dev/vdb1
    participant High as high-priority-reader&amp;lt;br/&amp;gt;(4k randwrite)

    Note over Low,High: weight 100/100
    Low-&amp;gt;&amp;gt;Dev: heavy sequential I/O
    High-&amp;gt;&amp;gt;Dev: competes equally
    Note over High: 71,255 IOPS

    Note over Low,High: weight 10/500
    Low-&amp;gt;&amp;gt;Dev: throttled share
    High-&amp;gt;&amp;gt;Dev: proportionally favored
    Note over High: 118,970 IOPS
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;What the numbers say, and what they don&apos;t&lt;/h2&gt;
&lt;p&gt;Distance from the solo ceiling went from 56.3 points contended to 27.0 points protected, which is 52% of the contention-induced gap closed. That&apos;s real. It&apos;s also half.&lt;/p&gt;
&lt;p&gt;The remaining 27 points are the cost of the substitution. A 50:1 weight ratio changes who wins the contest for bandwidth without guaranteeing anyone a floor, so under sustained sequential writes from the low-priority pod the high-priority pod still pays real tax even with 50 times the competing weight. That&apos;s what a proportional-share mechanism does when the workload underneath it doesn&apos;t stop, and it&apos;s the specific failure mode &lt;code&gt;io.latency&lt;/code&gt; addresses by targeting a number instead of a ratio.&lt;/p&gt;
&lt;p&gt;Latency moved further, relatively, than IOPS: 4.5µs solo, 11.4µs contended, 6.5µs protected. The contended row&apos;s own p99 (7.6µs) sits below its mean, which looks like an error until you check the tail: p50 for that run is 1.18µs and max is 740ms, an extremely heavy-tailed distribution where a handful of massive outliers (queued behind the low-priority pod&apos;s sequential writes) drag the mean far above the 99th-percentile bucket. Both numbers are real, straight from the same fio run; the mean is dominated by rare, huge stalls that the percentile itself doesn&apos;t capture.&lt;/p&gt;
&lt;p&gt;The p99 column has a second oddity the table makes plain: the solo run has the worst tail of the three, 9.0µs against 7.6µs contended and 6.9µs protected. p99 gets better as contention is added. With one run per stage and a heavy tail in all three, the 99th percentile here isn&apos;t measuring what the mean is measuring, and I wouldn&apos;t argue anything from that column. The mean is the number that tracks the weight change.&lt;/p&gt;
&lt;p&gt;The low-priority pod didn&apos;t lose anything under weight 10. It improved on every axis: 614 to 820 write IOPS, 629 to 840 MB/s, mean latency 6.5ms down to 4.8ms. Both metrics move together because its block size is fixed at 1M, which makes IOPS and bandwidth the same measurement in different units. Aggregate throughput across both pods went from 914 to 1,316 MB/s, up 44%. A proportional-share mechanism redistributes a roughly fixed amount of service; it doesn&apos;t hand both competitors a third more of everything, and that the supposedly-starved pod came out ahead is a reason to treat the recovery figure as observational rather than as arbitration doing its job.&lt;/p&gt;
&lt;p&gt;All three runs are &lt;code&gt;ext4&lt;/code&gt; buffered writeback on a virtio block device inside Docker Desktop&apos;s VM, and 163k IOPS for 4K random writes is writeback cache absorption rather than physical device throughput. None of the absolute numbers transfer to real storage. The workload, device, filesystem, and fio parameters were held constant between stages 2 and 3; the daemon changed the cgroup weights. The runs were sequential rather than randomized or repeated, so this establishes an observed 52% recovery in this experiment, not an isolated, controlled measurement of the weight change as the sole variable: page cache and writeback state carry over between sequential runs on the same device. That&apos;s still the number worth trusting more than the six-figure IOPS counts around it, which are cache-absorption artifacts first and device behavior second.&lt;/p&gt;
&lt;h2&gt;io.max as a second lever&lt;/h2&gt;
&lt;p&gt;The same controller exposes &lt;code&gt;io.max&lt;/code&gt;, keyed by &lt;code&gt;$MAJ:$MIN&lt;/code&gt; with &lt;code&gt;rbps&lt;/code&gt;/&lt;code&gt;wbps&lt;/code&gt;/&lt;code&gt;riops&lt;/code&gt;/&lt;code&gt;wiops&lt;/code&gt; fields, which caps a cgroup outright instead of ranking it against others:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ echo &quot;253:16 wiops=200&quot; &amp;gt; .../low-priority.../io.max
$ cat .../low-priority.../io.max
253:16 rbps=max wbps=max riops=max wiops=200
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It applied cleanly, which makes it the only per-device write form on this kernel that worked at all. A production version could combine both, using &lt;code&gt;io.max&lt;/code&gt; as a hard ceiling on device I/O for known offenders and &lt;code&gt;io.weight&lt;/code&gt; to arbitrate everything else, with the cap sized against the workload&apos;s own latency sensitivity, since the kernel documents that I/O is delayed once a cgroup hits its &lt;code&gt;io.max&lt;/code&gt; limit, and a tight cap on a latency-sensitive workload trades one failure mode for another. A cap and a ratio solve different problems, and neither one alone gives you both.&lt;/p&gt;
&lt;h2&gt;Scope&lt;/h2&gt;
&lt;p&gt;Single kind node, no cross-node coordination tested. Neither &lt;code&gt;io.latency&lt;/code&gt; nor iocost was benchmarked, since both are absent on this kernel. No network-attached storage; whether any of this reaches an NFS or iSCSI volume is a separate question this doesn&apos;t touch. Tier assignment is annotation-driven, reading a fixed annotation off two named pods rather than watching &lt;code&gt;scheduling.k8s.io/v1&lt;/code&gt; cluster-wide.&lt;/p&gt;
&lt;p&gt;This is an observational experiment, not a controlled benchmark: one node, one virtual block device, one workload pair, one sequential execution order, one measurement per stage. It demonstrates that changing &lt;code&gt;io.weight&lt;/code&gt; coincided with a substantial improvement for this workload on this kernel and device. It doesn&apos;t quantify the general performance benefit of &lt;code&gt;io.weight&lt;/code&gt;, and it doesn&apos;t rule out sequential-run cache/writeback drift as a partial contributor to the specific numbers above.&lt;/p&gt;
&lt;h2&gt;What it would take to go further&lt;/h2&gt;
&lt;p&gt;The path resolution logic already treats &lt;code&gt;cgroupRoot&lt;/code&gt; as a flag rather than a constant, which was the right call given how much it varies. Making tier assignment PriorityClass-driven instead of annotation-driven is a smaller change than it looks, since the daemon already reads &lt;code&gt;pod.Status.QOSClass&lt;/code&gt; off the live object, so adding a PriorityClass watch is additive rather than a redesign.&lt;/p&gt;
&lt;p&gt;The bigger open question is &lt;code&gt;io.latency&lt;/code&gt;. Any real deployment needs a capability check for &lt;code&gt;CONFIG_BLK_CGROUP_IOLATENCY&lt;/code&gt; before committing to it, because some nodes will have it built in and some won&apos;t. A daemon that assumes universal availability fails the same way this build&apos;s first attempt at per-device weights did: quietly, and only on specific kernels. (iocost, &lt;code&gt;CONFIG_BLK_CGROUP_IOCOST&lt;/code&gt;, is a separate mechanism and shouldn&apos;t be treated as interchangeable with &lt;code&gt;io.latency&lt;/code&gt;.)&lt;/p&gt;
&lt;p&gt;Which is the actual lesson here, and it&apos;s about interface names rather than about my benchmark. &lt;code&gt;io.weight&lt;/code&gt; gives you arbitration, &lt;code&gt;io.max&lt;/code&gt; gives you a ceiling, and &lt;code&gt;io.latency&lt;/code&gt; gives you a target. Those are three different control semantics wearing one label, and calling all of them &quot;I/O QoS&quot; hides the distinction until a benchmark forces it back out. A production controller shouldn&apos;t start by picking a policy. It should start by checking which of the three the node underneath it can actually make.&lt;/p&gt;
</content:encoded><author>Bhawani Singh</author></item><item><title>One cgroup budget for a Tekton taskrun&apos;s step and its dind sidecar</title><link>https://bhawanisingh.dev/blog/tekton-sidecar-shared-memory-budget</link><guid isPermaLink="true">https://bhawanisingh.dev/blog/tekton-sidecar-shared-memory-budget</guid><description>A Tekton dind sidecar rewrites its cgroup path so step and build share one memory ceiling, enforced down to a real OOM kill. The same controller leaves the build container with no memory metrics.</description><pubDate>Tue, 18 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;A Tekton &lt;code&gt;TaskRun&lt;/code&gt; that builds container images runs as one pod holding one or more step containers, alongside a privileged Docker-in-Docker sidecar. A step issues &lt;code&gt;docker build&lt;/code&gt; and &lt;code&gt;docker run&lt;/code&gt; against the sidecar&apos;s daemon socket. The dockerd process inside the sidecar is what actually does the work: pulling layers, running &lt;code&gt;RUN&lt;/code&gt; instructions, spawning child containers. During a build, the sidecar is the busiest thing in the pod.&lt;/p&gt;
&lt;p&gt;The two limits have to be sized independently, so the pod requests the sum of two peaks that never coincide: 4 GiB for the step, 4 GiB for the sidecar, 8 GiB reserved against node capacity for a workload that never exceeds 4. One shared ceiling would fit the actual usage.&lt;/p&gt;
&lt;p&gt;The step and the sidecar sit in sibling cgroups under the same pod slice. Their paths are identical except for a container hash, so a one-line substitution in the sidecar&apos;s entrypoint is enough to point dockerd at the step&apos;s cgroup instead of its own. Everything the build creates is then charged to the step&apos;s limit, and the kernel holds that limit all the way to an OOM kill. The same nesting that makes it work is what erases the build container from the metrics every memory dashboard is built on.&lt;/p&gt;
&lt;h2&gt;The problem&lt;/h2&gt;
&lt;p&gt;The unit of budget you want here is the pod: give this whole &lt;code&gt;TaskRun&lt;/code&gt; 4 GiB, let the step and the build children draw from it, and stop paying for a peak that never happens. Kubernetes has that concept. &lt;code&gt;PodLevelResources&lt;/code&gt;, &lt;a href=&quot;https://github.com/kubernetes/enhancements/blob/master/keps/sig-node/2837-pod-level-resource-spec/README.md&quot;&gt;KEP-2837&lt;/a&gt;, sets &lt;code&gt;spec.resources&lt;/code&gt; on the pod itself. It went beta and default-on in 1.34, and is still beta in 1.36, which graduates &lt;code&gt;InPlacePodLevelResourcesVerticalScaling&lt;/code&gt; to beta on by default. Two further gates around pod-level resources, &lt;code&gt;PodLevelResourcesFixDefaulting&lt;/code&gt; and &lt;code&gt;PodLevelResourcesFixKubeletQOSClass&lt;/code&gt;, land in 1.37.&lt;/p&gt;
&lt;p&gt;But Tekton can&apos;t use it. A &lt;code&gt;TaskRun&lt;/code&gt;&apos;s &lt;code&gt;podTemplate&lt;/code&gt; is a fixed allowlist of pod-spec fields: &lt;code&gt;env&lt;/code&gt;, &lt;code&gt;nodeSelector&lt;/code&gt;, &lt;code&gt;tolerations&lt;/code&gt;, &lt;code&gt;affinity&lt;/code&gt;, &lt;code&gt;securityContext&lt;/code&gt;, &lt;code&gt;volumes&lt;/code&gt;, &lt;code&gt;runtimeClassName&lt;/code&gt;, &lt;code&gt;automountServiceAccountToken&lt;/code&gt;, &lt;code&gt;dnsPolicy&lt;/code&gt;, &lt;code&gt;dnsConfig&lt;/code&gt;, &lt;code&gt;enableServiceLinks&lt;/code&gt;, &lt;code&gt;priorityClassName&lt;/code&gt;, &lt;code&gt;schedulerName&lt;/code&gt;, &lt;code&gt;imagePullSecrets&lt;/code&gt;, &lt;code&gt;hostNetwork&lt;/code&gt;, &lt;code&gt;hostUsers&lt;/code&gt;, &lt;code&gt;hostAliases&lt;/code&gt;, and &lt;code&gt;topologySpreadConstraints&lt;/code&gt;. There&apos;s no &lt;code&gt;resources&lt;/code&gt;. The other lever, &lt;code&gt;computeResources&lt;/code&gt;, rewrites the &lt;code&gt;resources&lt;/code&gt; block of individual containers, which is per-container by construction and says nothing about the pod as a whole.&lt;/p&gt;
&lt;p&gt;So a platform team that wants one number per &lt;code&gt;TaskRun&lt;/code&gt; pod is stuck between a Kubernetes feature that&apos;s still beta and a Tekton API surface that doesn&apos;t expose it.&lt;/p&gt;
&lt;h2&gt;The options&lt;/h2&gt;
&lt;p&gt;Four candidates get the step and the build sharing one ceiling.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;PodLevelResources.&lt;/strong&gt; Set the budget on the pod and let the kernel divide it. Correct, but unavailable in Tekton&apos;s &lt;code&gt;podTemplate&lt;/code&gt; and still beta.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;PID migration via &lt;code&gt;cgroup.procs&lt;/code&gt;.&lt;/strong&gt; Write the sidecar&apos;s own dockerd PID into the step container&apos;s &lt;code&gt;cgroup.procs&lt;/code&gt;, moving the daemon itself under the step&apos;s budget. This charges dockerd&apos;s own footprint, including image pulls and layer extraction, to the step, which is a different and much blunter thing than charging the build&apos;s children.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;--cgroup-parent&lt;/code&gt; borrowing.&lt;/strong&gt; Start dockerd with &lt;code&gt;--cgroup-parent&lt;/code&gt; pointed at the step container&apos;s scope, so every container dockerd creates is nested inside the step&apos;s cgroup while dockerd itself stays where the kubelet put it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Writing &lt;code&gt;memory.max&lt;/code&gt; into the pod slice.&lt;/strong&gt; Have something with node access compute a budget and write it directly into &lt;code&gt;kubelet-kubepods-*-pod&amp;lt;uid&amp;gt;.slice/memory.max&lt;/code&gt;, bypassing the API entirely.&lt;/p&gt;
&lt;p&gt;I tested &lt;code&gt;--cgroup-parent&lt;/code&gt; borrowing. It&apos;s the only one of the four that both works inside a normal pod today and targets the build&apos;s children specifically rather than the daemon that spawns them.&lt;/p&gt;
&lt;h2&gt;The chosen method&lt;/h2&gt;
&lt;p&gt;The sidecar&apos;s entrypoint runs before dockerd and does four things: read its own cgroup path, identify its own pod, ask the API server for the step container&apos;s containerd ID, and substitute that ID into the path it read.&lt;/p&gt;
&lt;p&gt;The critical property is that dockerd&apos;s own PID never moves. The daemon stays in the sidecar&apos;s cgroup, where the kubelet put it and where the kubelet&apos;s own accounting expects it. Only the containers it goes on to create land under the step.&lt;/p&gt;
&lt;p&gt;Step one reads the sidecar&apos;s own cgroup from procfs, which under cgroup v2 unified hierarchy is a single &lt;code&gt;0::&lt;/code&gt; line:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ awk -F: &apos;/^0::/ {print $3}&apos; /proc/self/cgroup
/kubelet.slice/kubelet-kubepods.slice/kubelet-kubepods-besteffort.slice/kubelet-kubepods-besteffort-podcd57a787_d809_4a55_b6f8_ab38de73aeb8.slice/cri-containerd-4354439d2f1668c37baaabaa5a33b5ce71a1b24c182e4ebe0d16455889c14617.scope
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Steps two and three read the pod name from &lt;code&gt;/etc/hostname&lt;/code&gt; and the namespace from &lt;code&gt;/var/run/secrets/kubernetes.io/serviceaccount/namespace&lt;/code&gt;, then poll the API server for the step container&apos;s ID, filtering out the sidecar&apos;s own entry:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kubectl get pod &quot;$podname&quot; -n &quot;$namespace&quot; -o json | \
  jq -r &apos;first(.status.containerStatuses[] | select(.name != &quot;sidecar-dind&quot; and .started == true) | .containerID)&apos; | \
  awk -F&apos;://&apos; &apos;{print $2}&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Step four is the whole trick. The sidecar&apos;s cgroup path and the step&apos;s cgroup path are siblings under the same pod slice, differing only in the container hash, so one substitution converts one into the other:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;NEW_CG_PATH=$(echo &quot;$CGROUP_PATH&quot; | sed -E &quot;s/cri-containerd-[a-f0-9]+\.scope/cri-containerd-$CONTAINERD_HASH.scope/&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then dockerd starts with that path as its parent:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;exec /usr/local/bin/dockerd-entrypoint.sh \
  --cgroup-parent=&quot;$NEW_CG_PATH&quot; \
  &quot;$@&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The sequence, with the step&apos;s own commands arriving after dockerd is already reparented:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sequenceDiagram
    participant Init as sidecar entrypoint
    participant API as kube-apiserver
    participant D as dockerd (sidecar cgroup)
    participant Step as step container
    participant CG as step .scope cgroup

    Init-&amp;gt;&amp;gt;Init: read /proc/self/cgroup
    Init-&amp;gt;&amp;gt;API: get pod, read step containerID
    API--&amp;gt;&amp;gt;Init: cri-containerd-&amp;lt;step hash&amp;gt;
    Init-&amp;gt;&amp;gt;Init: sed sidecar hash -&amp;gt; step hash
    Init-&amp;gt;&amp;gt;D: exec dockerd --cgroup-parent=&amp;lt;step scope&amp;gt;
    Step-&amp;gt;&amp;gt;D: docker run / docker build
    D-&amp;gt;&amp;gt;CG: create child cgroup under step scope
    Note over CG: child&apos;s memory charges here
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One detail makes the substitution viable, and it&apos;s worth checking before copying any of this. The two sides of the pod run different cgroup drivers:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ kubectl exec &amp;lt;pod&amp;gt; -c sidecar-dind -- docker info --format &apos;{{.CgroupDriver}} / v{{.CgroupVersion}}&apos;
cgroupfs / v2
$ kubectl get --raw /api/v1/nodes/&amp;lt;node&amp;gt;/proxy/configz | jq -r .kubeletconfig.cgroupDriver
systemd
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The kubelet builds the systemd-style &lt;code&gt;.slice&lt;/code&gt;/&lt;code&gt;.scope&lt;/code&gt; paths the sidecar reads out of procfs, while dockerd inside the sidecar runs &lt;code&gt;cgroupfs&lt;/code&gt; and treats &lt;code&gt;--cgroup-parent&lt;/code&gt; as a literal directory to create beneath. That mismatch is what lets a path lifted from the kubelet&apos;s hierarchy be handed to dockerd unchanged. A dind daemon configured with the systemd driver would expect a slice name and derive its own scope instead, and the borrowed path wouldn&apos;t nest the same way.&lt;/p&gt;
&lt;p&gt;Two prerequisites beyond that. The sidecar&apos;s service account needs &lt;code&gt;get&lt;/code&gt; on &lt;code&gt;pods&lt;/code&gt; in its own namespace, because the container ID is only available through the API. And the sidecar needs &lt;code&gt;privileged: true&lt;/code&gt;, both for dockerd&apos;s normal reasons and because without a host cgroup namespace the container reads only the namespace-relative &lt;code&gt;0::/&lt;/code&gt; and has no host path to rewrite.&lt;/p&gt;
&lt;h2&gt;What the kernel does with it&lt;/h2&gt;
&lt;p&gt;The nesting is literal. After the step tells dockerd to run a container that writes a 200 MB file, that container&apos;s cgroup appears as a subdirectory of the step&apos;s scope on the node:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ ls .../cri-containerd-84c3e193...abe1.scope/
4c4095d78188d1ba957135bd72bf672eec1de8a40ed8e011e3b30fb1a811d247
cgroup.controllers
cgroup.subtree_control
...
memory.current
memory.max
...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The kernel charges it to the step. Reading the step scope&apos;s &lt;code&gt;memory.current&lt;/code&gt; directly on the node, with a 200 MB child running:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ cat .../cri-containerd-84c3e193...abe1.scope/memory.current
213598208
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That&apos;s roughly 203.7 MiB against a step container whose own resident footprint before the child started was about 7 MB. The charge landed exactly where the mechanism aimed it.&lt;/p&gt;
&lt;p&gt;That happens because of one file. A cgroup only accounts for a resource if its parent enabled that controller in &lt;code&gt;cgroup.subtree_control&lt;/code&gt;, and the step&apos;s scope lists &lt;code&gt;memory&lt;/code&gt; as available without enabling it:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ cat .../cri-containerd-84c3e193...abe1.scope/cgroup.controllers
cpuset cpu io memory hugetlb pids rdma misc
$ cat .../cri-containerd-84c3e193...abe1.scope/cgroup.subtree_control
cpuset cpu pids
$ cat .../4c4095d78188...247/cgroup.controllers
cpuset cpu pids
$ cat .../4c4095d78188...247/memory.current
cat: ...: No such file or directory
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;So the child gets no &lt;code&gt;memory.*&lt;/code&gt; files at all, which makes it no boundary for memory accounting: the pages its processes instantiate are charged to the nearest enclosing cgroup that is one, and that&apos;s the step&apos;s own &lt;code&gt;.scope&lt;/code&gt;. The mechanism works precisely because the child is not a memcg.&lt;/p&gt;
&lt;p&gt;There&apos;s a second reason, and it&apos;s the stronger one. Reading the child&apos;s &lt;code&gt;cgroup.procs&lt;/code&gt; returns &lt;code&gt;Operation not supported&lt;/code&gt;, which the kernel documents for threaded cgroups, and &lt;code&gt;cgroup.type&lt;/code&gt; confirms it. Probing a separate run for those values: the child reads &lt;code&gt;threaded&lt;/code&gt;, the step scope reads &lt;code&gt;domain threaded&lt;/code&gt;, and a step scope with no dind child beneath it yet reads a plain &lt;code&gt;domain&lt;/code&gt;. Threaded cgroups carry only the threaded controllers, &lt;code&gt;cpu&lt;/code&gt;, &lt;code&gt;cpuset&lt;/code&gt;, &lt;code&gt;perf_event&lt;/code&gt;, and &lt;code&gt;pids&lt;/code&gt;, and &lt;code&gt;memory&lt;/code&gt; isn&apos;t among them. The two facts are the same one seen from either end: delegating only threaded controllers is what let the child become threaded at all, and once threaded it can never carry a &lt;code&gt;memory.current&lt;/code&gt;. Its processes show up in &lt;code&gt;cgroup.threads&lt;/code&gt; instead.&lt;/p&gt;
&lt;p&gt;That&apos;s the whole finding, and everything an operator would reach for follows from it. There is no per-child memory object, so nothing reading cgroup memory files can report one. cadvisor&apos;s line for the child reads zero on working set, usage, and RSS alike:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;container_memory_working_set_bytes{container=&quot;&quot;,id=&quot;.../cri-containerd-84c3e193...abe1.scope/4c4095d78188...247&quot;,...} 0
container_memory_usage_bytes{container=&quot;&quot;,id=&quot;.../4c4095d78188...247&quot;,...} 0
container_memory_rss{container=&quot;&quot;,id=&quot;.../4c4095d78188...247&quot;,...} 0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;docker stats&lt;/code&gt; inside the sidecar, looking at the same container through the daemon that created it, agrees:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;CONTAINER ID   NAME      CPU %     MEM USAGE / LIMIT   MEM %     NET I/O         BLOCK I/O   PIDS
4c4095d78188   memhog    0.00%     0B / 0B             0.00%     1.32kB / 126B   0B / 0B     1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Neither is wrong. There&apos;s no &lt;code&gt;memory.current&lt;/code&gt; at the child to read, so both correctly report the absence as zero.&lt;/p&gt;
&lt;p&gt;The step&apos;s own line is the one that misleads, because it does move, just nowhere near enough. cadvisor&apos;s &lt;code&gt;container_memory_working_set_bytes&lt;/code&gt; for &lt;code&gt;step-step&lt;/code&gt; went from about 7.2 MB before the child to about 13.9 MB with it running: roughly 6.4 MiB of movement against a kernel charge of roughly 204 MiB. Those are sampled gauges, so the individual readings drift a few hundred kB between runs, but the ratio is stable at around thirty. The metric captures about a thirtieth of the charge.&lt;/p&gt;
&lt;p&gt;One footnote on where the 200 MB went. The step scope&apos;s &lt;code&gt;memory.stat&lt;/code&gt; shows &lt;code&gt;anon 6541312&lt;/code&gt; and &lt;code&gt;file 200101888&lt;/code&gt;, all of it &lt;code&gt;inactive_file&lt;/code&gt;. A container writing a blob to disk charges the step as reclaimable page cache, not anonymous memory, which is why that particular workload never triggers a kill.&lt;/p&gt;
&lt;h2&gt;What happens when it OOMs&lt;/h2&gt;
&lt;p&gt;Forcing a kill needs anonymous memory. With the step capped at 64Mi via &lt;code&gt;stepSpecs.computeResources&lt;/code&gt; and a child allocating 500 MiB of anon, the kernel does exactly what the budget says.&lt;/p&gt;
&lt;p&gt;The kill is a real memcg kill, not a node-pressure eviction, and the cgroup it names is the step&apos;s:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;oom-kill:constraint=CONSTRAINT_MEMCG,...,oom_memcg=/docker/b132849958c0.../kubelet.slice/.../kubelet-kubepods-burstable-podee4ed01f_c5b6_401d_84e6_cd45e7a94890.slice/cri-containerd-d5a62de9d463d23595850010701c41f8cd07c14626727281560162300d0f7d98.scope,task_memcg=...,task=entrypoint,pid=79431,uid=0
memory: usage 65536kB, limit 65536kB, failcnt 59
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;oom_memcg&lt;/code&gt; path carries this run&apos;s pod UID (&lt;code&gt;ee4ed01f_c5b6_401d_84e6_cd45e7a94890&lt;/code&gt;) and this run&apos;s step container hash, both matched against what &lt;code&gt;kubectl&lt;/code&gt; reported for the same pod. &lt;code&gt;usage 65536kB, limit 65536kB&lt;/code&gt; is the 64Mi cap hit exactly, with 59 prior failed charge attempts.&lt;/p&gt;
&lt;p&gt;The kernel then killed five processes, not one, because &lt;code&gt;memory.oom.group&lt;/code&gt; is set on the step scope:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;process&lt;/th&gt;
&lt;th&gt;pid&lt;/th&gt;
&lt;th&gt;total-vm&lt;/th&gt;
&lt;th&gt;anon-rss&lt;/th&gt;
&lt;th&gt;oom_score_adj&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;entrypoint&lt;/td&gt;
&lt;td&gt;79431&lt;/td&gt;
&lt;td&gt;1301884 kB&lt;/td&gt;
&lt;td&gt;5760 kB&lt;/td&gt;
&lt;td&gt;996&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;script-0-fp877&lt;/td&gt;
&lt;td&gt;80150&lt;/td&gt;
&lt;td&gt;1720 kB&lt;/td&gt;
&lt;td&gt;0 kB&lt;/td&gt;
&lt;td&gt;996&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;sleep&lt;/td&gt;
&lt;td&gt;80152&lt;/td&gt;
&lt;td&gt;1704 kB&lt;/td&gt;
&lt;td&gt;0 kB&lt;/td&gt;
&lt;td&gt;996&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;entrypoint&lt;/td&gt;
&lt;td&gt;79452&lt;/td&gt;
&lt;td&gt;1301884 kB&lt;/td&gt;
&lt;td&gt;5760 kB&lt;/td&gt;
&lt;td&gt;996&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;python3&lt;/td&gt;
&lt;td&gt;80829&lt;/td&gt;
&lt;td&gt;523528 kB&lt;/td&gt;
&lt;td&gt;58084 kB&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The &lt;code&gt;python3&lt;/code&gt; at &lt;code&gt;oom_score_adj: 0&lt;/code&gt; is the child inside dind, the process that actually invoked the killer. Everything at &lt;code&gt;996&lt;/code&gt; is Tekton&apos;s own step machinery: two &lt;code&gt;entrypoint&lt;/code&gt; processes, the generated step script, and its &lt;code&gt;sleep&lt;/code&gt;. The kernel log states the rule plainly, that tasks in the step scope &quot;are going to be killed due to &lt;code&gt;memory.oom.group&lt;/code&gt; set.&quot; The step is billed for the child&apos;s overrun and executed for it.&lt;/p&gt;
&lt;p&gt;Note that the killer only got 58084 kB of anon resident before hitting the wall, against a &lt;code&gt;total-vm&lt;/code&gt; reservation of 523528 kB. The 500 MiB is what it asked for, not what it held.&lt;/p&gt;
&lt;p&gt;Tekton usually reports the failure, but not as an OOM:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ kubectl get taskrun cgroup-budget-run-oom -o jsonpath=&apos;{.status.conditions}&apos;
[{&quot;lastTransitionTime&quot;:&quot;2026-08-18T08:32:12Z&quot;,&quot;message&quot;:&quot;\&quot;step-step\&quot; exited with code 137: Error&quot;,&quot;reason&quot;:&quot;StepFailed&quot;,&quot;status&quot;:&quot;False&quot;,&quot;type&quot;:&quot;Succeeded&quot;}]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is the nuance worth getting right, because the obvious reading is wrong. Tekton does have OOM-specific reasons: &lt;code&gt;TaskRunReasonStepOOM&lt;/code&gt; (&quot;StepOOM&quot;), &lt;code&gt;TaskRunReasonSidecarOOM&lt;/code&gt;, and &lt;code&gt;TaskRunReasonInitContainerOOM&lt;/code&gt; all exist in the v1 &lt;code&gt;TaskRun&lt;/code&gt; types. Tekton&apos;s &lt;code&gt;getFailureInfo()&lt;/code&gt; calls &lt;code&gt;isOOMKilled()&lt;/code&gt;, which tests &lt;code&gt;s.State.Terminated.Reason == &quot;OOMKilled&quot;&lt;/code&gt; and nothing else. It never looks at the exit code. The step&apos;s terminated state here was &lt;code&gt;exitCode: 137&lt;/code&gt; with &lt;code&gt;reason: &quot;Error&quot;&lt;/code&gt;, so the check correctly declined to fire.&lt;/p&gt;
&lt;p&gt;The interesting part is one layer down, because Tekton isn&apos;t the component that decides. containerd writes that string, and it reaches the opposite conclusion from the opposite evidence: on a 137 exit it checks whether the cgroup&apos;s &lt;code&gt;memory.events&lt;/code&gt; counter shows an &lt;code&gt;oom_kill&lt;/code&gt;, and only then sets the reason. So containerd gates on the exit code Tekton ignores, and Tekton gates on the string containerd may or may not get around to writing.&lt;/p&gt;
&lt;p&gt;It usually doesn&apos;t. Polling the step scope&apos;s own &lt;code&gt;memory.events&lt;/code&gt; in a busy loop through the kill catches the counter arriving and the cgroup disappearing almost simultaneously:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;09:17:12.938 | oom_kill 5 | oom_group_kill 1 | current=774144
09:17:12.940 | directory gone
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two milliseconds. At a 20 ms sampling interval the directory was already gone. The kernel&apos;s accounting is correct and hierarchical throughout: the pod slice&apos;s &lt;code&gt;memory.events&lt;/code&gt; reads &lt;code&gt;oom_kill 5&lt;/code&gt; while its &lt;code&gt;memory.events.local&lt;/code&gt; stays at &lt;code&gt;0&lt;/code&gt;, which is exactly what a kill charged to a descendant should look like. The counter is there to be read. Under the systemd cgroup driver, the scope unit is garbage-collected as soon as its last process exits, and containerd is racing that GC to read a file that is about to stop existing. containerd&apos;s own source comments name this race.&lt;/p&gt;
&lt;p&gt;Repeat the same OOM six times and the reason comes back &lt;code&gt;Error&lt;/code&gt; five times and &lt;code&gt;OOMKilled&lt;/code&gt; once, with &lt;code&gt;exitCode: 137&lt;/code&gt; every time. So &lt;code&gt;StepOOM&lt;/code&gt; isn&apos;t dead code and this isn&apos;t a Tekton bug: it&apos;s a race one layer below Tekton that Tekton faithfully reports the losing side of. An operator sees the same generic failure most of the time and the correct one occasionally, which is worse than either being consistent.&lt;/p&gt;
&lt;p&gt;There&apos;s a trap in verifying any of this. The kernel ring buffer inside a kind node belongs to the host VM, not the cluster, and it survives cluster deletion. Create a fresh cluster and &lt;code&gt;dmesg&lt;/code&gt; on its node already holds OOM lines from clusters that no longer exist: 20 of them here, including a &lt;code&gt;python3&lt;/code&gt; kill reading &lt;code&gt;anon-rss:57964kB&lt;/code&gt;, close enough to the live number &lt;code&gt;58084kB&lt;/code&gt; to pass a careless eyeball check. Grepping &lt;code&gt;dmesg | grep -i oom&lt;/code&gt; and reading whatever comes back will happily &quot;confirm&quot; a several-hour-old event from a deleted cluster. Three distinct &lt;code&gt;oom_memcg&lt;/code&gt; values sat in that buffer and only one belonged to the pod under test. Extract &lt;code&gt;oom_memcg&lt;/code&gt; and match it against the current pod UID and step container hash, or the number you quote is fiction.&lt;/p&gt;
&lt;p&gt;The remaining tests:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Test&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Path substitution&lt;/td&gt;
&lt;td&gt;The rewritten path&apos;s pod UID and 64-char container hash match what &lt;code&gt;kubectl&lt;/code&gt; reports and what appears on the node&apos;s cgroupfs. Not a byte-for-byte string match: &lt;code&gt;crictl&lt;/code&gt; returns runc&apos;s colon form (&lt;code&gt;...slice:cri-containerd:&amp;lt;hash&amp;gt;&lt;/code&gt;) while the script builds the systemd-unit form (&lt;code&gt;.../cri-containerd-&amp;lt;hash&amp;gt;.scope&lt;/code&gt;).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RBAC denied&lt;/td&gt;
&lt;td&gt;The sidecar&apos;s first &lt;code&gt;kubectl get pod&lt;/code&gt; returns &lt;code&gt;Forbidden&lt;/code&gt; and the script dies immediately. &lt;code&gt;set -eo pipefail&lt;/code&gt; on line 2 plus a piped assignment means the non-zero exit propagates out of the pipeline and terminates the script on the first iteration, before the &quot;retrying&quot; message. Retry count is 0 despite &lt;code&gt;max_retries=50&lt;/code&gt;. The sidecar exits 1 after about a second. The &lt;code&gt;TaskRun&lt;/code&gt; reports &lt;code&gt;Succeeded&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unprivileged sidecar&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/proc/self/cgroup&lt;/code&gt; reads a bare &lt;code&gt;0::/&lt;/code&gt;, the namespaced view, so the &lt;code&gt;sed&lt;/code&gt; finds no &lt;code&gt;cri-containerd-&amp;lt;hash&amp;gt;.scope&lt;/code&gt; to replace and silently no-ops. The script logs &lt;code&gt;New cgroup path: /&lt;/code&gt; and proceeds. That silent no-op was never observed to matter, because dockerd dies first on &lt;code&gt;mount: permission denied (are you root?)&lt;/code&gt; and &lt;code&gt;Could not mount /sys/kernel/security.&lt;/code&gt;. Sidecar exits 1, &lt;code&gt;TaskRun&lt;/code&gt; reports &lt;code&gt;Succeeded&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lifecycle inversion&lt;/td&gt;
&lt;td&gt;Doesn&apos;t happen. dockerd&apos;s own PID never leaves the sidecar&apos;s cgroup, so Tekton&apos;s ordinary nop image-swap stops the sidecar exactly as it would any other, a few seconds after the step exits.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;Why not to run this&lt;/h2&gt;
&lt;p&gt;The enforcement is real, and the operability isn&apos;t, and those are separable properties.&lt;/p&gt;
&lt;p&gt;Nothing downstream of the kernel sees the charge at the granularity it happened. Dashboards built on &lt;code&gt;container_memory_working_set_bytes&lt;/code&gt; stay roughly flat while the kernel accumulates two hundred megabytes against the step, and &lt;code&gt;kubectl top&lt;/code&gt; consumes the same cadvisor-derived series, though this cluster had no metrics-server installed so that path wasn&apos;t exercised directly. Anything keyed on that per-container series would inherit the same gap: an autoscaler or a sizing recommender reading step memory sees a number roughly thirty times too small. Neither was deployed here, only the cadvisor series underneath them. Recovering the truth means reading &lt;code&gt;memory.current&lt;/code&gt; on the node, which means node access and a scrape path that doesn&apos;t exist by default.&lt;/p&gt;
&lt;p&gt;Tekton&apos;s status surface can&apos;t distinguish a step that overran its own budget from a step that was killed for a child&apos;s. Both usually arrive as &lt;code&gt;StepFailed&lt;/code&gt; with &lt;code&gt;exited with code 137: Error&lt;/code&gt;, and &lt;code&gt;memory.oom.group&lt;/code&gt; means the same set of Tekton processes dies either way, so nothing in the process-exit shape tells them apart. The occasional run that does win the race and report &lt;code&gt;StepOOM&lt;/code&gt; is no better for this purpose: it correctly says the step was OOM-killed, which is still the wrong container to go looking at. An on-call engineer reading only the &lt;code&gt;TaskRun&lt;/code&gt; has no signal pointing at the build container, and an intermittent reason string is harder to build an alert on than a consistently wrong one.&lt;/p&gt;
&lt;p&gt;The pod also still reserves two numbers, not one. dockerd&apos;s own PID stays in the sidecar&apos;s cgroup under the sidecar&apos;s own limit, so the 8 GiB of the opening never collapses all the way to 4. What changes is their size: the sidecar&apos;s cadvisor line read 26.9 MB idle and 42.8 MB while a 200 MB child ran, because the child&apos;s memory is charged elsewhere. The second reservation covers a daemon&apos;s own footprint instead of a peak build, and can be sized in tens of megabytes rather than gigabytes.&lt;/p&gt;
&lt;p&gt;You can&apos;t drop &lt;code&gt;privileged: true&lt;/code&gt; later as a hardening pass. Without it the sidecar reads &lt;code&gt;0::/&lt;/code&gt; instead of a real cgroup path, the substitution has nothing to match, and the daemon would start with &lt;code&gt;--cgroup-parent=/&lt;/code&gt;. dockerd never gets that far, dying on a mount failure first, but nothing in the script detects or reports the broken path either way.&lt;/p&gt;
&lt;p&gt;Both failure modes leave a dead sidecar inside a green &lt;code&gt;TaskRun&lt;/code&gt;. RBAC denial and the unprivileged case both end with the sidecar at exit code 1 and the &lt;code&gt;TaskRun&lt;/code&gt; at &lt;code&gt;Succeeded&lt;/code&gt; / &lt;code&gt;All Steps have completed executing&lt;/code&gt;. The step ran, passed, and was never subject to the budget the pipeline thought it had. There is no annotation, condition, or event distinguishing an enforced run from an unenforced one.&lt;/p&gt;
&lt;p&gt;There&apos;s a related Docker issue, and it&apos;s worth being precise about how it differs. &lt;a href=&quot;https://github.com/moby/moby/issues/45378&quot;&gt;moby/moby#45378&lt;/a&gt;, &lt;code&gt;DinD cgroupv2 problem inside K8s&lt;/code&gt;, filed &lt;code&gt;2023-04-21&lt;/code&gt; and still open, reports containers inside DinD exceeding the pod memory limit without being OOM-killed, an enforcement failure not seen on cgroup v1. That&apos;s the opposite half of what shows up here, where enforcement works and attribution is missing. Both point at nested cgroup accounting under DinD being fragile, in different directions.&lt;/p&gt;
&lt;p&gt;Everything above ran on a single-node kind cluster, &lt;code&gt;kind v0.32.0&lt;/code&gt;, node image &lt;code&gt;kindest/node:v1.36.1&lt;/code&gt;, Kubernetes v1.36.1 on Debian 13 with containerd 2.3.1, kernel &lt;code&gt;6.8.0-117-generic&lt;/code&gt; on aarch64, cgroup v2 unified hierarchy, and Tekton Pipelines v1.15.x. The sidecar is &lt;code&gt;docker:28-dind&lt;/code&gt; (Docker 28.5.2, Alpine 3.22) with &lt;code&gt;apk add bash kubectl jq&lt;/code&gt; on top, since that base image ships no bash and the entrypoint needs it. The step is &lt;code&gt;alpine:3.20&lt;/code&gt;. Load was generated by &lt;code&gt;kubectl exec&lt;/code&gt; into the sidecar and running &lt;code&gt;docker run&lt;/code&gt; against its own dockerd, on a disposable local cluster and nothing resembling production. That deliberately tests the plain container path: &lt;code&gt;docker build&lt;/code&gt; reaches the same daemon, but BuildKit places its workers on its own terms, so treat the placement result here as proven for containers dockerd creates directly and unverified for every build topology.&lt;/p&gt;
&lt;h2&gt;What to use instead&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;PodLevelResources&lt;/code&gt; is the answer as soon as your cluster is on 1.34 or later, where it&apos;s beta and enabled by default. It puts the limit on the pod, and it keeps the kubelet&apos;s accounting and the kernel&apos;s accounting pointing at the same object. It&apos;s still beta in 1.36, with two fix gates landing in 1.37, so read the release notes before you build a platform commitment on it. What it leaves unsolved is delivery: &lt;code&gt;spec.resources&lt;/code&gt; still has to reach the pod, and the &lt;code&gt;TaskRun&lt;/code&gt;&apos;s &lt;code&gt;podTemplate&lt;/code&gt; allowlist won&apos;t carry it. Use mutating admission webhook to patch &lt;code&gt;spec.resources&lt;/code&gt; on the pod and you have the shared budget.&lt;/p&gt;
&lt;p&gt;Writing &lt;code&gt;memory.max&lt;/code&gt; into the pod slice from a node-level agent is the conservative fallback. It needs node access and hardcodes the kubelet&apos;s slice naming, which varies with the cgroup driver and &lt;code&gt;cgroupRoot&lt;/code&gt;, but it works on any version and needs no API changes. It also charges the whole pod, sidecar included, which for a dind pod is usually what you wanted anyway.&lt;/p&gt;
&lt;p&gt;None of this is really about Tekton or Docker. &lt;code&gt;cgroup.subtree_control&lt;/code&gt; decides which level owns a resource, and Kubernetes doesn&apos;t delegate &lt;code&gt;memory&lt;/code&gt; below the container cgroup. That makes enforcement and observability separable, which is the part worth remembering: the kernel will happily hold you to a budget nobody can see you spending.&lt;/p&gt;
</content:encoded><author>Bhawani Singh</author></item></channel></rss>