<?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>Why slow Kubernetes pod startups undermine autoscaling, CI/CD, and incident response, and why Events and kubelet telemetry cannot identify the delayed phase.</description><pubDate>Sat, 18 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Suppose 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;, an alpha histogram for Kubernetes&apos; narrower startup SLI: it excludes image pulls and init-container execution. SIG-Scalability&apos;s official stateless-pod SLO uses that signal: the 99th percentile, measured over the last five minutes, must stay at or below 5 seconds for a cluster-day. 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. Consider a scale-up that completes after demand has already outrun the existing pods. The autoscaler technically did its job. It decided to add capacity. But the capacity arrived too late to protect the service: existing pods absorb the overflow, latency degrades, and the new pod can arrive after the pressure has already subsided or triggered 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 can spend more time starting pods than doing short 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 can be fast. The captured &lt;code&gt;TaskRun&lt;/code&gt; pod shows its &lt;code&gt;step-hello&lt;/code&gt; container starting at 16:35:55Z and finishing at 16:35:57Z—under two seconds of recorded wall time. That single short-task example illustrates the dynamic: when the startup overhead around a pod runs for several seconds, it is not a rounding error next to the work inside it. A pipeline that feels slow can be one where the build and test commands run fine but the pods wrapping each &lt;code&gt;Task&lt;/code&gt; are the bottleneck, and a typical CI dashboard does not separate those two costs.&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 displayed event times—or, when querying the API directly, Event timestamps—to estimate an image-pull interval, while knowing Events are best-effort records rather than a precise phase clock. If the answer isn&apos;t obvious from the event list, there&apos;s nowhere else to look. In this project&apos;s induced &lt;code&gt;connect()&lt;/code&gt; stall, the Event record captured lifecycle transitions but did not say that the init container was blocked in &lt;code&gt;connect()&lt;/code&gt;. Events record controller-observed transitions, not the syscall that occupied the interval between them. 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 Event objects have their own bounded retention window—one hour by default at the API server—and can outlive the Pod they refer to. Once the Pod is deleted, &lt;code&gt;kubectl describe pod&lt;/code&gt; no longer works; once the Event TTL expires, the associated chronology is gone too. &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;Each kubelet exposes an unlabeled histogram for the pods it observes. A monitoring system can aggregate those per-node series into a cluster-wide p99, but that roll-up still has no per-pod label, phase attribution, or trace back to the pod that populated a bucket. No timestamp points at a specific second and says where the time went. 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 stock interface covers that middle ground on its own. That gap is the one this series sets out to investigate.&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 borrows the step container&apos;s cgroup as dockerd&apos;s --cgroup-parent, placing the containers it creates under the step&apos;s memory limit.</description><pubDate>Sat, 25 Jul 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;If the step and the sidecar each need a 4 GiB peak at different times, separate limits reserve 8 GiB against node capacity even though the combined workload peak is 4 GiB. A shared ceiling would fit that usage.&lt;/p&gt;
&lt;p&gt;The step and the sidecar sit in sibling cgroups under the same pod slice. The entrypoint swaps the sidecar hash for the step hash, then passes the resulting path to dockerd as &lt;code&gt;--cgroup-parent&lt;/code&gt;. dockerd remains in the sidecar cgroup; containers it creates start beneath the step cgroup. Their memory is charged to the step&apos;s limit, and the kernel holds that limit through 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. In 1.36, &lt;code&gt;InPlacePodLevelResourcesVerticalScaling&lt;/code&gt; graduates to beta and is enabled by default. Two pod-level-resource bug fixes, &lt;code&gt;PodLevelResourcesFixUpdateDefaulting&lt;/code&gt; and &lt;code&gt;PodLevelResourcesFixKubeletQOSClass&lt;/code&gt;, are beta and enabled by default in 1.36.&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; accepts a fixed subset of Pod-spec fields, and that subset excludes &lt;code&gt;resources&lt;/code&gt;. Tekton&apos;s &lt;code&gt;computeResources&lt;/code&gt; field configures individual containers; it cannot set &lt;code&gt;spec.resources&lt;/code&gt; on the resulting Pod.&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; set to the step container&apos;s scope, so containers that do not override that setting are 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. Of the four designs considered, it is the one this prototype validates inside a normal pod while targeting build children 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 the 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, after the step has started and its container ID is available:&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: 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 with --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 to it and its step ancestor
&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 that the sidecar reads from procfs, while dockerd inside the sidecar mounts &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 to &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 can distribute a resource to child cgroups only when it enables that controller in &lt;code&gt;cgroup.subtree_control&lt;/code&gt;. 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. It is not a memory-accounting boundary, so memory instantiated by its processes is accounted at the step&apos;s &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 relationship is stable: the observed working-set increase was about one-thirtieth of the step cgroup&apos;s &lt;code&gt;memory.current&lt;/code&gt;.&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;, &lt;code&gt;file 200101888&lt;/code&gt;, and &lt;code&gt;inactive_file 200101888&lt;/code&gt;: nearly all of the charge was inactive file cache. A memory-limited cgroup can reclaim those pages before it kills a task, although they still count toward the limit.&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: 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 of which 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 makes the decision. 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 races that GC to read a file that is about to be removed. containerd&apos;s own source comments name this race.&lt;/p&gt;
&lt;p&gt;Repeat the same OOM six times; the reason comes back as &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;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 200 megabytes per step. This cluster had no metrics-server installed, so it did not test whether &lt;code&gt;kubectl top&lt;/code&gt; reproduces the gap. Any downstream system that consumes this cAdvisor per-container working-set series without compensating for the nested charge could inherit it; no autoscaler or sizing recommender was tested here. 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 distinguishes them. 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 example in 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. Those measurements cover only this idle and single-child test; a real build&apos;s sidecar limit requires measurements of its image pulls, layer extraction, and BuildKit activity.&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 to 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;. The load was generated by &lt;code&gt;kubectl exec&lt;/code&gt; into the sidecar and by running &lt;code&gt;docker run&lt;/code&gt; against its own dockerd on a disposable local cluster, with nothing resembling production.&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 when the feature is enabled on a Kubernetes 1.34-or-later cluster. It puts the limit on the pod and keeps the kubelet&apos;s accounting and the kernel&apos;s accounting pointed at the same object. &lt;code&gt;PodLevelResourcesFixKubeletQOSClass&lt;/code&gt; and &lt;code&gt;PodLevelResourcesFixUpdateDefaulting&lt;/code&gt; are beta and enabled by default in 1.36. Read the release notes and feature-gate reference before making a platform commitment. 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. A mutating admission webhook could patch &lt;code&gt;spec.resources&lt;/code&gt; onto the resulting pod.&lt;/p&gt;
&lt;p&gt;A node-level agent could set &lt;code&gt;memory.max&lt;/code&gt; on the pod cgroup where the path and controller state are verified. It needs node access and depends on the kubelet&apos;s slice naming, which varies with the cgroup driver and &lt;code&gt;cgroupRoot&lt;/code&gt;. This prototype did not test that alternative. It would charge the whole pod, sidecar included, which for a dind pod is often the desired boundary.&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 distributes a resource. In this kind/containerd configuration, the kubelet did not delegate &lt;code&gt;memory&lt;/code&gt; below the container scope. That made enforcement and observability separable: the kernel held the step to a budget that ordinary per-container memory metrics could not fully explain.&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>Why existing eBPF tools, continuous profilers, and APM platforms can&apos;t provide a durable pod-startup timeline that joins lifecycle records to kernel evidence.</description><pubDate>Thu, 30 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 per-kubelet aggregate telemetry with no per-pod label. Neither provides a per-pod startup timeline with syscall attribution. 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. Its base installation streams process-execution and process-exit events with Kubernetes identity, node, and time metadata. With a tracing policy configured for a relevant kernel function, it can also report process and syscall-adjacent events. That&apos;s real, fine-grained, per-process attribution, and it is exactly the kind of signal you&apos;d want during a slow init container: which operation, which process, which pod, down to the timestamp.&lt;/p&gt;
&lt;p&gt;What Tetragon doesn&apos;t document is a pod-startup 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 uses Tetragon&apos;s &lt;code&gt;exec_id&lt;/code&gt; and &lt;code&gt;parent_exec_id&lt;/code&gt; relationships; a host PID is also present, but &lt;code&gt;exec_id&lt;/code&gt; is the documented process identifier for correlation. With a suitable tracing policy, Tetragon can report a &lt;code&gt;connect()&lt;/code&gt;-related event with pod identity and a timestamp. It cannot place that event in an init-container or main-container startup interval without a phase model from somewhere else. Tetragon operates at the process and syscall layer; the Kubernetes lifecycle model remains a separate join. 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 collects, stores, and queries telemetry in-cluster; its telemetry tables use bounded in-memory storage, so retained data is intentionally short-lived.&lt;/p&gt;
&lt;p&gt;Pixie&apos;s value proposition is instant visibility into application traffic: request/response bodies, query latencies, who&apos;s calling whom. An init container can generate protocol traffic or CPU behavior that Pixie observes while it runs. But those observations do not define schedule-to-ready intervals, distinguish a Kubernetes startup phase, or show whether an observed operation dominated that phase. Pixie can show useful fragments of a startup; its public documentation does not describe a per-pod lifecycle model that assembles schedule, pull, attach, mount, init, and readiness into one timeline.&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-profiling systems, but their collection paths are not identical. Parca Agent is an eBPF sampling profiler: it captures user- and kernel-space stacks 19 times per second and discovers targets across Kubernetes and systemd automatically, with no code changes or restarts required. Grafana Pyroscope is a continuous-profiling backend that aggregates profiles collected through language SDKs, auto-instrumentation, and eBPF-based collectors. It supports language-specific profiling integrations for Go, Java, Python, Ruby, Node.js, .NET, and Rust, and correlates profiles with metrics, logs, and traces.&lt;/p&gt;
&lt;p&gt;Both tools can answer where a process spent CPU time and how that changed over time. A continuous profile may capture startup work, especially when a process runs long enough to receive samples, but it does not by itself provide Kubernetes phase boundaries or tie sampled CPU stacks to the wall-clock delay between lifecycle events. Startup is a one-shot sequence of distinct intervals—for example scheduling, image pulling, volume work, container initialization, and readiness. Neither product&apos;s public documentation describes a Kubernetes lifecycle query that attributes a &lt;code&gt;Created&lt;/code&gt;-to-&lt;code&gt;Started&lt;/code&gt; interval to a named startup phase. A profiler may still collect samples while the container executes; it does not supply the missing phase boundary or the surrounding pull, attach, and mount timeline.&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, continuous CPU/memory profiling, Kubernetes events, resource data, and deployment context in one installation. That&apos;s a real, wide net. The public product material reviewed for this project does not describe 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 is primarily a unified logs, metrics, traces, and RUM backend. It can also deploy OpenTelemetry eBPF instrumentation that captures application traces and RED metrics without application code changes. Its public documentation does not describe a purpose-built pod-startup lifecycle model, so collecting or querying those signals alone does not produce a phase-attributed startup timeline.&lt;/p&gt;
&lt;p&gt;Across the public documentation reviewed here, broad signal coverage does not amount to a dedicated pod-startup phase model: a per-pod schedule-to-ready timeline that attaches an observation to a named lifecycle phase. These tools are strong at explaining particular signals. The missing question is which startup phase was slow, and why.&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 process and kernel evidence but no phase model to hang it on. Pixie has deep runtime tracing but no documented lifecycle timeline. Parca and Pyroscope can show CPU behavior, including some startup work, but do not supply Kubernetes phase boundaries. The generic platforms provide breadth without a documented startup-specific model. Several could supply valuable inputs to a timeline: Events, logs, traces, profiles, and kernel observations. But turning those fragments into a durable per-pod forensic timeline still requires a lifecycle model, storage after a pod disappears, and joins that connect each fragment to a named startup interval. 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: Joining Lifecycle Events to Syscall Evidence</title><link>https://bhawanisingh.dev/blog/pod-startup-forensics-lifecycle-events-syscall-evidence</link><guid isPermaLink="true">https://bhawanisingh.dev/blog/pod-startup-forensics-lifecycle-events-syscall-evidence</guid><description>A design for retaining pod-startup evidence after deletion by putting Kubernetes lifecycle records and eBPF syscall observations on the same time axis.</description><pubDate>Sun, 02 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Tetragon can record configured syscall activity, while the Kubernetes API records lifecycle transitions. Neither record alone tells an operator which startup interval was slow and what the process did during it. The missing capability is the correlation between the two: a durable per-pod timeline that places lifecycle evidence and kernel observations on the same time axis.&lt;/p&gt;
&lt;h2&gt;Lifecycle records and syscall observations answer different questions&lt;/h2&gt;
&lt;p&gt;The Kubernetes API supplies two useful, imperfect records of startup. PodStatus records container states such as &lt;code&gt;waiting&lt;/code&gt;, &lt;code&gt;running&lt;/code&gt;, and &lt;code&gt;terminated&lt;/code&gt;; Kubernetes Events may record actions such as scheduling, pulling, and starting. The components that perform those actions commonly report them, but Events are informative, best-effort data and their timestamps are not a precise phase clock. A forensic timeline has to retain the raw records it receives and make its inference rules explicit.&lt;/p&gt;
&lt;p&gt;But an Event says that an action occurred; it does not show what a process did during the interval. In the captured slow-init run, the init-container interval lasted eight seconds, while the lifecycle records still said nothing about the process activity inside it. Kubernetes records kubelet- and control-plane-observed state; it does not watch syscalls.&lt;/p&gt;
&lt;p&gt;eBPF observes the other side of the problem. A tracepoint on &lt;code&gt;sys_enter_connect&lt;/code&gt; and &lt;code&gt;sys_exit_connect&lt;/code&gt; can record that a task entered and returned from &lt;code&gt;connect()&lt;/code&gt;, along with its timing and result. That does not make the event Kubernetes-aware. A syscall trace identifies kernel activity for a task; pod and lifecycle context live outside that event and must be resolved from container or cgroup metadata plus Kubernetes records. On its own, a trace is a wall of task identifiers and timestamps, with no indication that the activity belonged to a particular startup interval.&lt;/p&gt;
&lt;p&gt;Neither source needs to be made into the other. Kubernetes supplies lifecycle records; eBPF supplies process activity. The missing piece is a join keyed on pod identity, with the timeline treating lifecycle boundaries as evidence of an interval and kernel observations as evidence that occurred alongside it. The comparison is useful, but it must not turn coexistence into a causal verdict: a long &lt;code&gt;connect()&lt;/code&gt; beside a slow interval is a lead for an investigator, not proof that the call caused the delay.&lt;/p&gt;
&lt;p&gt;The division of responsibility is specific. Kubernetes supplies the observed lifecycle records; the tracer supplies activity from selected kernel hooks. A useful output can say that a container-start interval lasted N seconds and show the &lt;code&gt;connect()&lt;/code&gt; activity observed for the same pod during the overlapping window. Assigning that activity to a named phase, or treating it as the cause of the delay, requires explicit identity and attribution rules beyond timestamp overlap alone.&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 does not come from the tooling gap directly. &lt;a href=&quot;/blog/pod-startup-forensics-the-problem&quot;&gt;Part 1&lt;/a&gt; showed a captured short Tekton &lt;code&gt;TaskRun&lt;/code&gt;: its &lt;code&gt;step-hello&lt;/code&gt; container ran for about two seconds at the available timestamp precision. Any design that answers &quot;which pod, which phase, why&quot; only by querying live cluster state is blind to short-lived workloads after the fact, because by the time a human goes looking, the Pod object—and the status fields a live lookup would read—may already be gone.&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 result in memory or in a dashboard, and answers questions only against whatever is currently running. That shape works for a pod that is slow in front of you. It fails a pod that was slow earlier and has since been deleted. Kubernetes Events also have retention independent of the Pod lifecycle, so the raw material for a manual reconstruction eventually disappears on its own clock.&lt;/p&gt;
&lt;p&gt;So durability cannot be an afterthought. The collector has to persist the lifecycle, Event, readiness, and kernel evidence it observes while the Pod still exists, keyed to a durable Pod record. That record is bounded by what the collector saw and by what the API retained, but it lets a later lookup avoid depending on a live Pod object. A live-query tool and a durable forensic record answer different operational questions.&lt;/p&gt;
&lt;h2&gt;Four requirements for a durable startup investigation&lt;/h2&gt;
&lt;p&gt;Put the two constraints together and a complete solution needs four capabilities, wired together by pod identity: lifecycle collection, selected kernel tracing, identity-aware correlation, and durable storage. Here, identity means a Pod UID, with namespace and name for display; process membership has to be resolved through container or cgroup metadata rather than inferred from a timestamp.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Keep the lifecycle record.&lt;/strong&gt; A collector should retain the PodStatus and Event records it receives rather than rely on a fixed polling interval. It must distinguish observed status facts from inferred intervals, because it is not receiving a lossless, semantic feed of every startup transition. The next part shows the CLI, watcher, and tracer that implement this collection; &lt;a href=&quot;/blog/pod-startup-forensics-surviving-deletion&quot;&gt;Part 5&lt;/a&gt; covers how the record survives deletion.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Capture kernel activity while it happens.&lt;/strong&gt; A tracer must capture the syscall classes the investigation supports while a Pod is starting; it cannot recover an event after the fact. The capture policy has to be explicit about what it observes, the overhead it accepts, and evidence it may lose under load. This project traces &lt;code&gt;openat&lt;/code&gt;, &lt;code&gt;connect&lt;/code&gt;, &lt;code&gt;mount&lt;/code&gt;, &lt;code&gt;read&lt;/code&gt;, &lt;code&gt;bind&lt;/code&gt;, &lt;code&gt;listen&lt;/code&gt;, and &lt;code&gt;accept4&lt;/code&gt;, plus process exec—not every syscall. A timeline can then present the observations relevant to the investigator&apos;s hypothesis without claiming to explain every kind of startup delay.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Match activity to the right Pod.&lt;/strong&gt; A timestamp overlap between an API record and a kernel event is not enough on a real node: several Pods can be starting at once for unrelated reasons. The correlation step has to resolve which Pod owns a process and retain that resolution long enough to join the evidence safely. Without that, the right output is an honest unknown, not a confident association based only on simultaneous timestamps.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Keep the answer after deletion.&lt;/strong&gt; The output cannot live only in memory or behind a live query, because the Pod may be gone before anyone asks about it. The collector should retain the observed lifecycle and kernel evidence against the Pod record so a later lookup has somewhere to go even when &lt;code&gt;kubectl&lt;/code&gt; no longer knows the Pod.&lt;/p&gt;
&lt;p&gt;None of these four capabilities is individually new. Kubernetes collectors retain lifecycle records. Tetragon, Pixie, and Parca can contribute different kinds of kernel or runtime evidence. Distributed systems persist observations beyond the object that produced them. The tooling in &lt;a href=&quot;/blog/pod-startup-forensics-the-tooling-gap&quot;&gt;Part 2&lt;/a&gt; can supply individual records, but the investigator still needs a retained per-pod record that connects them without inventing causation. Part 4 shows the collector and tracer that implement that record, including the correlation limits they still have.&lt;/p&gt;
</content:encoded><author>Bhawani Singh</author></item><item><title>Pod Startup Forensics: The Architecture</title><link>https://bhawanisingh.dev/blog/pod-startup-forensics-the-architecture</link><guid isPermaLink="true">https://bhawanisingh.dev/blog/pod-startup-forensics-the-architecture</guid><description>How the CLI resolves pod owners and phases, the eBPF tracer captures syscall evidence, and the exporter joins both into a verified startup timeline.</description><pubDate>Wed, 05 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;a href=&quot;/blog/pod-startup-forensics-lifecycle-events-syscall-evidence&quot;&gt;Part 3&lt;/a&gt; ended on a design: two sources of truth joined on pod identity, written before the pod disappears. This part shows what that design became when run against a real cluster: a command run by hand, a background watcher that never stops, and a tracer that talks directly to the kernel. Every claim below comes from a command actually run and checked against the tool&apos;s behavior.&lt;/p&gt;
&lt;h2&gt;One lookup path for a pod, and for anything that owns a pod&lt;/h2&gt;
&lt;p&gt;The core CLI path is Kubernetes-native: give it a Pod name and namespace, and it builds that Pod&apos;s timeline directly. The timeline builder does not know or care whether the Pod came from a Deployment, Job, CI system, or a hand-written manifest.&lt;/p&gt;
&lt;p&gt;The resolver can be extended for resources that own Pods without changing that core path. Tekton &lt;code&gt;PipelineRun&lt;/code&gt; is one example, chosen here because it produces short-lived workload Pods. The extension finds the &lt;code&gt;TaskRun&lt;/code&gt; Pods carrying the PipelineRun label and passes them to the same timeline builder used for a direct Pod lookup.&lt;/p&gt;
&lt;p&gt;Tekton changes only how the CLI finds the Pod. Once it has one, the timeline path is identical. The two captures below show the direct Kubernetes path and the extension path feeding that same timeline model.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ ./target/release/profiler-cli lookup --kind v1.pods --name baseline-demo --namespace default
Pod startup timeline: default/baseline-demo
  image_pull                          6.000s
  init_container (init-setup)         1.000s
  sidecar (sidecar-logger)            1.000s
  main_container (main)               0.000s
  TOTAL                               8.000s
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That&apos;s the direct path: a Pod with an init container, a native Kubernetes sidecar, and a main container. The CLI renders each interval to whole seconds; this capture reports a six-second image-pull interval. The extension path renders the same kind of phase timeline for a Pod found through a &lt;code&gt;PipelineRun&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Pod startup timeline: default/demo-pipeline-run-run-demo-task-pod
────────────────────────────────────────────────────────────
  init_container (prepare)            0.000s
  main_container (step-hello)         1.000s
────────────────────────────────────────────────────────────
  TOTAL                               2.000s
  (pass --metrics-url http://&amp;lt;tracer-pod-ip-or-port-forward&amp;gt;:9091/metrics for eBPF root cause)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The resolver changes discovery, not timeline construction. Once it finds one or more Pods, the same Kubernetes-native timeline builder handles them all.&lt;/p&gt;
&lt;p&gt;For this capture, Kubernetes supplied only whole-second status and Event timestamps, so the CLI&apos;s one-second display is the appropriate precision.&lt;/p&gt;
&lt;h2&gt;A container&apos;s status field has more shapes than &quot;running&quot; or &quot;terminated&quot;&lt;/h2&gt;
&lt;p&gt;Getting that exact match required noticing something &lt;code&gt;kubectl describe pod&lt;/code&gt; doesn&apos;t explicitly call out: a regular init container and a native Kubernetes sidecar report their status differently, even though Kubernetes lists them together. After a regular init container completes, its current status is &lt;code&gt;terminated&lt;/code&gt;. A native sidecar—an &lt;code&gt;initContainers&lt;/code&gt; entry with &lt;code&gt;restartPolicy: Always&lt;/code&gt;, stable in Kubernetes 1.33—is normally &lt;code&gt;running&lt;/code&gt; while it stays up alongside the main containers, although it can later restart or terminate. A tool that only looks for &lt;code&gt;terminated&lt;/code&gt; silently drops every sidecar from the timeline: no crash, no error, just a container that never shows up in the output. The fix is checking its &lt;code&gt;restartPolicy&lt;/code&gt; before deciding which status to trust.&lt;/p&gt;
&lt;p&gt;A separate problem arises in calculating phase durations. Container-status timestamps are absolute wall-clock times. For a still-running sidecar or main container, the implementation represents the gap from the latest earlier phase end to its &lt;code&gt;startedAt&lt;/code&gt;; otherwise it keeps a terminated container&apos;s own &lt;code&gt;startedAt&lt;/code&gt;-to-&lt;code&gt;finishedAt&lt;/code&gt; duration. This prevents overlapping status times from being rendered as duplicated startup time.&lt;/p&gt;
&lt;h2&gt;A tracer that watches syscalls, not a wrapper around a log line&lt;/h2&gt;
&lt;p&gt;Looking up a Pod&apos;s history and building its phase timeline can answer &quot;which phase was slow.&quot; Neither can answer the syscall-level &quot;why&quot;: Kubernetes can report a container state, reason, or message, but it does not record what a process was doing while blocked. That&apos;s the other half of &lt;a href=&quot;/blog/pod-startup-forensics-lifecycle-events-syscall-evidence&quot;&gt;Part 3&lt;/a&gt;&apos;s design: a real tracer that watches the kernel directly, not a script that reads log output and guesses.&lt;/p&gt;
&lt;p&gt;It&apos;s built with Aya, a Rust framework for writing eBPF programs, and runs on every node as its own background process with the permissions needed to watch the kernel. Fifteen tracepoints observe &lt;code&gt;sched_process_exec&lt;/code&gt; plus enter and exit events for seven selected syscalls. Each syscall class was chosen to test a specific hypothesis about why a container might be stuck: opening a file, making a network connection, mounting a filesystem, reading data, or serving traffic through a socket. &lt;code&gt;bind&lt;/code&gt;, &lt;code&gt;listen&lt;/code&gt;, and &lt;code&gt;accept4&lt;/code&gt; are server-side socket operations; an outbound client commonly calls &lt;code&gt;connect()&lt;/code&gt; instead. Paired enter and exit events let the tracer measure the duration of each selected syscall.&lt;/p&gt;
&lt;p&gt;Once built, the finished program is copied onto a cluster node and loaded there, a point where many projects like this only work on paper. The captured run showed that the eBPF object attached successfully and emitted duration metrics for all seven traced syscalls. Here is what the tracer reported across several kinds of activity at once:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ curl -s http://127.0.0.1:19191/metrics | grep pod_startup_blocking_syscall_duration_seconds_count
...,pod=&quot;readiness-slow-listen-demo&quot;,syscall=&quot;bind&quot;} 1
...,pod=&quot;readiness-slow-listen-demo&quot;,syscall=&quot;connect&quot;} 2
...,pod=&quot;readiness-slow-listen-demo&quot;,syscall=&quot;listen&quot;} 1
...,pod=&quot;readiness-slow-listen-demo&quot;,syscall=&quot;mount&quot;} 77
...,pod=&quot;readiness-slow-listen-demo&quot;,syscall=&quot;openat&quot;} 280
...,pod=&quot;readiness-slow-listen-demo&quot;,syscall=&quot;read&quot;} 144
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Those counts prove the tracer is capturing syscall durations. They do not, by themselves, show which calls belong to the startup interval under investigation. That correlation boundary is the next problem.&lt;/p&gt;
&lt;h2&gt;Three programs, three jobs&lt;/h2&gt;
&lt;p&gt;The CLI, exporter, and tracer are separate binaries with different jobs. The CLI provides a live query and a historical query. The exporter watches and stores lifecycle evidence, then serves the historical query. The tracer captures selected syscall events and sends them to the metrics and storage paths.&lt;/p&gt;
&lt;p&gt;For a live query, the CLI resolves a Pod from the Kubernetes API, builds its timeline, prints it, and exits. For a historical query, it instead asks the exporter for the persisted timeline of a Pod; this works after the Pods are deleted. The exporter runs continuously. It watches Pod create, update, and delete events; on a Pod update, it derives the timeline and queries retained Pod and referenced-PVC Events for persistence. The tracer runs on each node, pairs the enter and exit of its selected syscalls, and makes those events available to the metrics and storage paths.&lt;/p&gt;
&lt;p&gt;The separation follows from their lifetimes. The captured short-lived Pod completed in about two seconds, leaving little time for an ad-hoc live lookup; once its API record is gone, only the exporter-backed historical path can reconstruct it. The exporter and tracer must keep collecting without an operator waiting at a terminal. Combining them with the one-off CLI would couple an interactive command to two always-on services with different availability and storage needs.&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>Implementing and validating node-local disk-I/O prioritization for Kubernetes Pods with cgroup v2 io.weight.</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 Pod-level resource semantics for runtime 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; and &lt;code&gt;limits.ephemeral-storage&lt;/code&gt;, but that is disk &lt;em&gt;capacity&lt;/em&gt;, not disk &lt;em&gt;performance&lt;/em&gt;. A backup job can stay inside its storage quota and still consume service from the same device as a database. Neither the scheduler nor &lt;code&gt;ResourceQuota&lt;/code&gt; arbitrates that contention.&lt;/p&gt;
&lt;p&gt;The Linux cgroup v2 I/O controller can. This prototype uses &lt;code&gt;io.weight&lt;/code&gt; to give two Pods an explicit, node-local priority policy: low tier gets &lt;code&gt;10&lt;/code&gt;; high tier gets &lt;code&gt;500&lt;/code&gt;. It answers a narrow question: when device service is constrained, which workload should be favored? It does not reserve bandwidth or guarantee a latency target.&lt;/p&gt;
&lt;h2&gt;The control I started with was not available&lt;/h2&gt;
&lt;p&gt;The first mechanism I checked was &lt;code&gt;io.latency&lt;/code&gt;. It accepts a target completion latency for a cgroup and protects it by throttling peer cgroups whose own target is higher. That is closer to the usual database-versus-backup problem: protect a latency-sensitive workload from bulk I/O. It is still not a hard SLA, but it is a latency-oriented control.&lt;/p&gt;
&lt;p&gt;It was absent from this node hierarchy. &lt;code&gt;find /sys/fs/cgroup -name io.latency&lt;/code&gt; returned no result anywhere on the kind node, so this implementation could not use it. &lt;code&gt;io.latency&lt;/code&gt; and iocost are separate kernel facilities; enabling iocost does not make &lt;code&gt;io.latency&lt;/code&gt; appear.&lt;/p&gt;
&lt;p&gt;That left &lt;code&gt;io.weight&lt;/code&gt;. This article validates its control path and active iocost accounting. The conclusion is deliberately narrower than a benchmark claim: &lt;code&gt;io.weight&lt;/code&gt; expresses relative I/O priority, not a reservation.&lt;/p&gt;
&lt;h2&gt;What &lt;code&gt;io.weight&lt;/code&gt; actually is&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;io.weight&lt;/code&gt; is a relative share. Pod cgroups expose it only when their parent enables &lt;code&gt;io&lt;/code&gt; in &lt;code&gt;cgroup.subtree_control&lt;/code&gt;. The default is &lt;code&gt;100&lt;/code&gt;; valid values are &lt;code&gt;1&lt;/code&gt; through &lt;code&gt;10000&lt;/code&gt;. The interface accepts a cgroup-wide default value and reports the effective value on readback:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ cat .../pod-&amp;lt;uid&amp;gt;.slice/io.weight
default 100
$ printf &apos;default 500\n&apos; &amp;gt; .../pod-&amp;lt;uid&amp;gt;.slice/io.weight
$ cat .../pod-&amp;lt;uid&amp;gt;.slice/io.weight
default 500
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A 500:10 policy favors the high-tier cgroup over the low-tier cgroup when both compete through the same active I/O controller. It does not promise 500 IOPS, a bandwidth floor, or a completion-latency bound. The workload, request mix, backing device, and device model still determine the observed result.&lt;/p&gt;
&lt;p&gt;The controller has separate mechanisms for separate policies:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;io.weight&lt;/code&gt; is proportional arbitration.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;io.max&lt;/code&gt; is a per-device rate limit. Temporary bursts are allowed.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;io.latency&lt;/code&gt; is the latency-target mechanism.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Those distinctions are operational, not semantic trivia. A 50:1 weight ratio changes relative priority. It does not give a bulk writer a fixed latency bound. Check &lt;code&gt;io.latency&lt;/code&gt; on the actual node image before designing around it; it is not universally available.&lt;/p&gt;
&lt;p&gt;For iocost-backed weighting to operate on a device, the root cgroup must report that device in &lt;code&gt;io.cost.qos&lt;/code&gt; with &lt;code&gt;enable=1&lt;/code&gt;. Writing &lt;code&gt;io.weight&lt;/code&gt; proves configuration. Reading &lt;code&gt;io.cost.qos&lt;/code&gt; for &lt;code&gt;enable=1&lt;/code&gt; and cgroup &lt;code&gt;io.stat&lt;/code&gt; for cost fields verifies active accounting; inspect &lt;code&gt;io.cost.model&lt;/code&gt; to understand the model it is using.&lt;/p&gt;
&lt;h2&gt;The cgroup path is configuration, not an API&lt;/h2&gt;
&lt;p&gt;The common examples use &lt;code&gt;/sys/fs/cgroup/kubepods.slice/...&lt;/code&gt;. That was not the path on this node. The kind node used the systemd cgroup driver with kubelet &lt;code&gt;cgroupRoot: /kubelet&lt;/code&gt;, producing:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/sys/fs/cgroup/kubelet.slice/
  kubelet-kubepods.slice/
    kubelet-kubepods-burstable.slice/
      kubelet-kubepods-burstable-pod&amp;lt;uid_with_underscores&amp;gt;.slice
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Guaranteed Pods are direct children of &lt;code&gt;kubelet-kubepods.slice&lt;/code&gt;. Burstable and BestEffort Pods have an extra QoS-class slice. The Pod UID is part of the leaf name, with dashes replaced by underscores.&lt;/p&gt;
&lt;p&gt;The exact path is not portable. It depends on the cgroup driver, kubelet configuration, and Pod QoS class. A cgroupfs-driver cluster uses a different directory layout. That is why the daemon accepts &lt;code&gt;cgroupRoot&lt;/code&gt; as configuration instead of hardcoding a path.&lt;/p&gt;
&lt;p&gt;This helper builds the target path for the systemd layout:&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;:
		return fmt.Sprintf(&quot;%s/kubelet-kubepods-pod%s.slice&quot;, base, normalizedUID), 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;A per-device &lt;code&gt;io.weight&lt;/code&gt; write was rejected&lt;/h2&gt;
&lt;p&gt;The first write form I tried was the per-device form documented by the interface:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ printf &apos;253:16 200\n&apos; &amp;gt; .../low-priority.../io.weight
sh: 7: printf: I/O error
$ cat .../low-priority.../io.weight
default 10
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The write failed and the file retained its prior value. The cgroup-wide form succeeded on the same cgroup:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ printf &apos;default 150\n&apos; &amp;gt; .../pod-&amp;lt;uid&amp;gt;.slice/io.weight
$ cat .../pod-&amp;lt;uid&amp;gt;.slice/io.weight
default 150
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The daemon therefore writes only the cgroup-wide form. The node used &lt;code&gt;mq-deadline&lt;/code&gt;, not BFQ, but that alone does not explain why the kernel returned EIO. The useful fact is simpler: on this node and device, per-device syntax was rejected while the cgroup-wide syntax applied and read back correctly.&lt;/p&gt;
&lt;h2&gt;The daemon&lt;/h2&gt;
&lt;p&gt;The prototype polls two named Pods. It reads an &lt;code&gt;io-qos.demo/tier&lt;/code&gt; annotation (&lt;code&gt;high&lt;/code&gt; or &lt;code&gt;low&lt;/code&gt;), maps it to a weight, resolves the cgroup path, writes the value, and reads it back.&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)
	}
	log.Printf(&quot;qosd: %s/%s uid=%s tier=%s qos=%s path=%s wrote=%q readback=%q&quot;,
		t.Namespace, t.Name, pod.UID, tier, pod.Status.QOSClass, slicePath+&quot;/io.weight&quot;,
		trimNL(written), trimNL(readback))
	d.applied[string(pod.UID)] = weight
	return nil
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The write helper is intentionally boring. The readback matters because a successful-looking control loop is useless if the kernel rejects the requested form.&lt;/p&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;The live daemon runs inside the kind control-plane node. That placement gives it access to the node cgroup hierarchy and the Kubernetes API credentials it needs to resolve Pods. It is not a DaemonSet or a general cluster controller; this is a focused one-node prototype.&lt;/p&gt;
&lt;h2&gt;Validating the policy path&lt;/h2&gt;
&lt;p&gt;The test used two Burstable Pods with direct-I/O fio jobs against a shared &lt;code&gt;hostPath&lt;/code&gt; on the kind node. Low tier wrote sequential 1 MiB requests with four jobs. High tier issued 4 KiB random writes with one job. The request sizes differ by 256:1, so their IOPS and bandwidth are not directly comparable. They are different workload shapes sharing the same target.&lt;/p&gt;
&lt;p&gt;The test sequence was equal weights, weighted policy, equal weights again, then weighted policy again. Before and after each arm it captured the two &lt;code&gt;io.weight&lt;/code&gt; files and &lt;code&gt;io.stat&lt;/code&gt;. The test VM root cgroup reported iocost enabled for the device.&lt;/p&gt;
&lt;p&gt;Here, charged-cost share means the high-tier cgroup&apos;s &lt;code&gt;cost.usage&lt;/code&gt; delta divided by the combined high-tier and low-tier deltas. I use it because this test validates controller behavior, not storage performance. It is controller accounting, not bandwidth, IOPS, or latency.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Arm&lt;/th&gt;
&lt;th&gt;High / low &lt;code&gt;io.weight&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;High-tier charged-cost share&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Equal weights, pair 1&lt;/td&gt;
&lt;td&gt;100 / 100&lt;/td&gt;
&lt;td&gt;48.90%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Weighted, pair 1&lt;/td&gt;
&lt;td&gt;500 / 10&lt;/td&gt;
&lt;td&gt;60.10%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Equal weights, pair 2&lt;/td&gt;
&lt;td&gt;100 / 100&lt;/td&gt;
&lt;td&gt;5.87%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Weighted, pair 2&lt;/td&gt;
&lt;td&gt;500 / 10&lt;/td&gt;
&lt;td&gt;17.52%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Across the two ordered pairs, the high-tier share moved upward after the 500:10 setting. That is consistent with the configured priority policy. It is not a throughput or latency result.&lt;/p&gt;
&lt;p&gt;The test found the live Pod cgroups, applied each weight, read it back, and captured iocost accounting before and after the workload. The daemon log separately records the same resolution and write/readback path. That distinction matters: the workload capture shows the cgroup control state; the daemon log shows the daemon&apos;s own reconciliation path.&lt;/p&gt;
&lt;p&gt;The diagram summarizes configured policy, not measured throughput:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sequenceDiagram
    participant Low as low-tier Pod&amp;lt;br/&amp;gt;(1 MiB sequential write)
    participant Dev as shared block device
    participant High as high-tier Pod&amp;lt;br/&amp;gt;(4 KiB random write)

    Note over Low,High: equal weights: 100 / 100
    Low-&amp;gt;&amp;gt;Dev: bulk I/O
    High-&amp;gt;&amp;gt;Dev: competing I/O

    Note over Low,High: weighted policy: 10 / 500
    Low-&amp;gt;&amp;gt;Dev: lower configured share
    High-&amp;gt;&amp;gt;Dev: proportionally favored when constrained
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The point of the test is the control boundary. &lt;code&gt;io.weight&lt;/code&gt; changes relative priority. It does not establish a throughput number that transfers to another device, filesystem, workload mix, or iocost model. A capacity decision needs repeated randomized arms, synchronized starts, a calibrated model, and the storage stack where the workload will run.&lt;/p&gt;
&lt;h2&gt;&lt;code&gt;io.max&lt;/code&gt; is a second lever&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;io.max&lt;/code&gt; handles a different policy. It takes a backing device major:minor and BPS or IOPS limits:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ printf &apos;253:16 wiops=200\n&apos; &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;&lt;code&gt;253:16&lt;/code&gt; is the device identifier from this node. Derive it from the workload storage path before applying a cap on another node.&lt;/p&gt;
&lt;p&gt;This is useful for a known offender, such as a bulk job whose write rate must be contained. It can be combined with &lt;code&gt;io.weight&lt;/code&gt;: use a rate limit to contain the bulk job, then use weights to arbitrate remaining contention. Neither one creates a latency guarantee.&lt;/p&gt;
&lt;h2&gt;Scope and production requirements&lt;/h2&gt;
&lt;p&gt;This prototype runs on one &lt;code&gt;kind&lt;/code&gt; node, one virtual block device, and two Pods. It does not coordinate Pods across nodes. It does not establish what a network filesystem, NFS share, or iSCSI volume will do. Tier assignment is annotation-driven and targets two named Pods, not a cluster-wide policy API.&lt;/p&gt;
&lt;p&gt;Turning this into a platform feature requires more than moving the code into a DaemonSet:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Verify cgroup v2 and &lt;code&gt;io&lt;/code&gt; delegation on every node type.&lt;/li&gt;
&lt;li&gt;Resolve the actual Pod cgroup layout instead of assuming this systemd path.&lt;/li&gt;
&lt;li&gt;Map the workload storage path to its backing device.&lt;/li&gt;
&lt;li&gt;Check that iocost is active for that device where proportional enforcement is required.&lt;/li&gt;
&lt;li&gt;Select the control by policy: arbitration (&lt;code&gt;io.weight&lt;/code&gt;), rate limit (&lt;code&gt;io.max&lt;/code&gt;), or latency target (&lt;code&gt;io.latency&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Validate the chosen policy against the real storage stack and workload mix.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The useful conclusion is narrow. Kubernetes does not provide disk-I/O QoS as a Pod resource, but a node agent can use cgroup v2 &lt;code&gt;io.weight&lt;/code&gt; to impose explicit proportional priority on live Pod cgroups. Use it for arbitration. Use &lt;code&gt;io.max&lt;/code&gt; when a workload needs a rate limit. Use &lt;code&gt;io.latency&lt;/code&gt; only on nodes where that latency control exists.&lt;/p&gt;
</content:encoded><author>Bhawani Singh</author></item><item><title>Pod Startup Forensics: Surviving Deletion</title><link>https://bhawanisingh.dev/blog/pod-startup-forensics-surviving-deletion</link><guid isPermaLink="true">https://bhawanisingh.dev/blog/pod-startup-forensics-surviving-deletion</guid><description>How the profiler retains pod-startup evidence after deletion, verified by reconstructing a deleted three-pod Tekton PipelineRun with `--timeline`.</description><pubDate>Sat, 15 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;A pod that started slowly is often gone by the time anyone asks why. &lt;code&gt;kubectl describe&lt;/code&gt; returns &lt;code&gt;NotFound&lt;/code&gt;; its Events may linger briefly, but on a retention schedule unrelated to the pod itself. The evidence needed to explain the startup has become a race against deletion and garbage collection.&lt;/p&gt;
&lt;p&gt;To answer after deletion, this exporter has to capture the evidence while the pod exists and reconstruct it from durable storage later. This part shows the storage design behind that reconstruction and the &lt;code&gt;--timeline&lt;/code&gt; command that reads it back.&lt;/p&gt;
&lt;h2&gt;Capture it before deletion&lt;/h2&gt;
&lt;p&gt;The exporter writes the observations it needs to reconstruct a startup to a database. Postgres is the deployment choice for shared, durable history; SQLite offers the same &lt;code&gt;--timeline&lt;/code&gt; behavior in one local file for development or a single-node demo. With either storage option, the owner, child, pod, phase, and event records form one hierarchy, so a deleted pod can still be found under its original &lt;code&gt;PipelineRun&lt;/code&gt;, &lt;code&gt;Job&lt;/code&gt;, or &lt;code&gt;CronJob&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;What actually gets stored&lt;/h2&gt;
&lt;p&gt;For the deletion reconstruction in this part, two stored record types carry the core answer: phase records for duration and an ordered log of Kubernetes Events for explanation. Phase records retain timings that Events can&apos;t provide, especially container start and finish times. Events retain the verbatim reasons, messages, and retry history that explain delays outside those phase boundaries. &lt;code&gt;--timeline&lt;/code&gt; joins both when it reconstructs a pod.&lt;/p&gt;
&lt;p&gt;The output preserves the precision it actually captured. A populated Event &lt;code&gt;eventTime&lt;/code&gt; can carry microseconds, while the older &lt;code&gt;firstTimestamp&lt;/code&gt; and &lt;code&gt;lastTimestamp&lt;/code&gt; fields are whole-second values; &lt;code&gt;--timeline&lt;/code&gt; doesn&apos;t invent fractional precision for the latter.&lt;/p&gt;
&lt;h2&gt;Reconstructing a PipelineRun after its pods are deleted&lt;/h2&gt;
&lt;p&gt;The payoff is a timeline that remains readable after the pods are gone. A three-&lt;code&gt;TaskRun&lt;/code&gt; Tekton &lt;code&gt;PipelineRun&lt;/code&gt; produced three pods on a kind cluster. One deliberately blocked for about thirteen seconds against a non-routable address before giving up, giving the timeline a known slow phase to find. All three pods were then deleted directly:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ kubectl delete pod -l tekton.dev/pipelineRun=multi-taskrun-run-slow
$ kubectl get pods -l tekton.dev/pipelineRun=multi-taskrun-run-slow
No resources found in default namespace.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;kubectl&lt;/code&gt; has nothing left to say about any of these three pods. A direct query against Postgres confirms the same thing from the storage side: all three pod rows carry a non-null &lt;code&gt;deleted_at&lt;/code&gt;, with their phases and events still attached:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;         owner          |                   child                    |                      pod                       | deleted | phases | events 
------------------------+--------------------------------------------+------------------------------------------------+---------+--------+--------
 multi-taskrun-run-slow | multi-taskrun-run-slow-first-task-run      | multi-taskrun-run-slow-first-task-run-pod      | t       |      3 |      7
 multi-taskrun-run-slow | multi-taskrun-run-slow-second-task-run     | multi-taskrun-run-slow-second-task-run-pod     | t       |      4 |     18
 multi-taskrun-run-slow | multi-taskrun-run-slow-third-task-run-slow | multi-taskrun-run-slow-third-task-run-slow-pod | t       |      3 |      7
(3 rows)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With no matching live pods in the PipelineRun&apos;s namespace, &lt;code&gt;--timeline&lt;/code&gt; reconstructs the stored history alone:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ profiler-cli lookup --kind tekton.dev/v1.pipelinerun \
    --name multi-taskrun-run-slow --namespace default --timeline \
    --exporter-url http://127.0.0.1:19090

Owner historical timeline: default/multi-taskrun-run-slow
(reconstructed from durable storage — works even if every pod below is now deleted)
══════════════════════════════════════════════════════════════════════

Child: multi-taskrun-run-slow-third-task-run-slow
  Pod: multi-taskrun-run-slow-third-task-run-slow-pod  [deleted]  node=pod-startup-profiler-control-plane
  ──────────────────────────────────────────────────────────────────
    schedule                            0.335s
    init_container (prepare)            0.000s
    main_container (step-blocking-step)   13.000s &amp;lt;-- SLOWEST PHASE IN PIPELINE
  ──────────────────────────────────────────────────────────────────
    TOTAL                              14.000s
    ...

══════════════════════════════════════════════════════════════════════
FLAGGED SLOW: main_container (step-blocking-step) in Child multi-taskrun-run-slow-third-task-run-slow / pod multi-taskrun-run-slow-third-task-run-slow-pod took 13.000s (longest phase across the whole owner chain)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;All three &lt;code&gt;TaskRun&lt;/code&gt;s reconstruct with their pods labeled &lt;code&gt;[deleted]&lt;/code&gt;; the excerpt shows the deliberately blocked 13-second step as the owner chain&apos;s longest phase. A second read returned the same bytes, and a fresh three-pod run reproduced the same slow-phase flag after deletion.&lt;/p&gt;
&lt;h2&gt;When no container ever starts&lt;/h2&gt;
&lt;p&gt;The deleted PipelineRun shows that completed startup history survives deletion. A pod blocked on an unbound &lt;code&gt;PersistentVolumeClaim&lt;/code&gt; never records a container phase, so there is no &lt;code&gt;started&lt;/code&gt;/&lt;code&gt;finished&lt;/code&gt; pair to time. The exporter used to drop that shape entirely. Now &lt;code&gt;--timeline&lt;/code&gt; renders &lt;code&gt;no phases recorded&lt;/code&gt; and shows the captured Event log.&lt;/p&gt;
&lt;p&gt;No container started, so this timeline has no syscall or probe records. The CLI keeps its standard merged-timeline heading, but only captured Events follow:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Pod: pvc-failure-demo  [exists]  node=?
──────────────────────────────────────────────────────────────────
  no phases recorded — this pod never reached container start; see the event log below for why
──────────────────────────────────────────────────────────────────
  TOTAL                               0.000s

  MERGED TIMELINE (K8s Events + eBPF syscalls/probes, interleaved by real timestamp)
  2026-08-30T04:13:26Z  T+  0.000s  [K8S EVENT]  ExternalProvisioning   PersistentVolumeClaim/rook-ceph-stuck-pvc (x2 repeats)
      Waiting for a volume to be created either by the external provisioner
      &apos;rook-ceph.rbd.csi.ceph.com&apos; or manually by the system administrator. If volume creation
      is delayed, please verify that the provisioner is running and correctly registered.
  2026-08-30T04:13:26.572306Z  T+  0.572s  [K8S EVENT]  FailedScheduling       Pod/pvc-failure-demo
      0/1 nodes are available: pod has unbound immediate PersistentVolumeClaims. not found
  2026-08-30T04:13:26.578279Z  T+  0.578s  [K8S EVENT]  FailedScheduling       Pod/pvc-failure-demo
      0/1 nodes are available: pod has unbound immediate PersistentVolumeClaims. not found
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That output names the provisioner handling the claim and records the scheduler&apos;s immediate blocker: an unbound immediate PersistentVolumeClaim. It identifies the next component to inspect and explains why the pod can&apos;t be scheduled. A phase-durations-only view has no row for this pod, regardless of how long it waits.&lt;/p&gt;
&lt;p&gt;This capture stores only two repeats. A stored count can understate a persistent failure because the exporter refreshes the Event set only when it observes the pod; later Event changes can be missed if no further pod observation occurs. This run alone doesn&apos;t show that happened here.&lt;/p&gt;
&lt;p&gt;The previous case retained phase data; this pod never reached container start. The same &lt;code&gt;lookup --timeline&lt;/code&gt; interface covers both shapes.&lt;/p&gt;
&lt;h2&gt;Open questions&lt;/h2&gt;
&lt;p&gt;This test doesn&apos;t answer whether the database keeps up under sustained production load.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;/blog/pod-startup-forensics-closing-the-gap&quot;&gt;Part 6&lt;/a&gt; follows a flagged slow phase into eBPF syscall evidence, then fixes a bug in assigning those syscalls to the right pod.&lt;/p&gt;
</content:encoded><author>Bhawani Singh</author></item><item><title>Pod Startup Forensics: Closing the Gap</title><link>https://bhawanisingh.dev/blog/pod-startup-forensics-closing-the-gap</link><guid isPermaLink="true">https://bhawanisingh.dev/blog/pod-startup-forensics-closing-the-gap</guid><description>Correlating socket syscalls and kubelet probe attempts into the merged timeline, then fixing a real cgroup-attribution bug that left most of those events unresolved.</description><pubDate>Sun, 16 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;a href=&quot;/blog/pod-startup-forensics-surviving-deletion&quot;&gt;Part 5&lt;/a&gt; closed the durability gap: evidence captured while a pod exists, readable after it&apos;s gone. This part closes a different gap. In the slow-start capture, &lt;code&gt;Pulled&lt;/code&gt; and &lt;code&gt;Started&lt;/code&gt; appear, followed by one coalesced &lt;code&gt;Unhealthy&lt;/code&gt; record before &lt;code&gt;Ready&lt;/code&gt;. The event log has little to say about what happened between those records.&lt;/p&gt;
&lt;h2&gt;Kubernetes&apos; blind spot&lt;/h2&gt;
&lt;p&gt;A crash-looping container produces Events such as &lt;code&gt;BackOff&lt;/code&gt;. A container that will become healthy but is slow to start may produce only a coalesced &lt;code&gt;Unhealthy&lt;/code&gt; Event: the API records a repeat count instead of one Event for every failed readiness probe. kubelet continues polling throughout. Event aggregation, not kubelet going quiet, is why the timeline looks sparse.&lt;/p&gt;
&lt;p&gt;The eBPF tracer can see work in that gap, but its evidence was not yet part of the timeline. It captures syscalls whether or not Kubernetes emits an Event. Closing the gap meant merging those socket syscalls and kubelet&apos;s probe attempts with the Kubernetes record.&lt;/p&gt;
&lt;h2&gt;Filling the gap&lt;/h2&gt;
&lt;p&gt;This part explains how the tracer correlates and renders those syscalls, rather than how it hooks them; &lt;a href=&quot;/blog/pod-startup-forensics-the-architecture&quot;&gt;Part 4&lt;/a&gt; covers the tracepoints. Three pieces make up the work:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What the socket syscalls mean for a readiness probe.&lt;/strong&gt; For a server that listens on a TCP port, &lt;code&gt;listen()&lt;/code&gt; after &lt;code&gt;bind()&lt;/code&gt; marks the point at which a TCP-based readiness probe can succeed.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;kubelet probe correlation.&lt;/strong&gt; The correlation path covers TCP or HTTP readiness probes against the pod&apos;s own IP. A probe with an overridden host and every exec probe are out of scope. kubelet makes the observed &lt;code&gt;connect()&lt;/code&gt; call from its own process, so the tracer identifies kubelet calls and joins their destination address to a pod IP instead of attempting cgroup attribution.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A merged, filtered rendering.&lt;/strong&gt; The timeline interleaves syscalls, probe attempts, and Kubernetes Events by timestamp. Its default view collapses sub-10-millisecond &lt;code&gt;openat()&lt;/code&gt;/&lt;code&gt;mount()&lt;/code&gt;/&lt;code&gt;read()&lt;/code&gt; bookkeeping into summary lines; &lt;code&gt;--raw&lt;/code&gt; retains every event.&lt;/p&gt;
&lt;h2&gt;&lt;code&gt;connect()&lt;/code&gt; isn&apos;t readiness&lt;/h2&gt;
&lt;p&gt;Building probe correlation surfaced a real mistake in how a &lt;code&gt;connect()&lt;/code&gt; return code was being interpreted. kubelet&apos;s HTTP and TCP readiness probes use Go&apos;s non-blocking socket dial, so the &lt;code&gt;connect()&lt;/code&gt; syscall itself can return &lt;code&gt;EINPROGRESS&lt;/code&gt; (&quot;in progress, ask again later&quot;) regardless of whether the probe eventually succeeds or fails. Treating an &lt;code&gt;EINPROGRESS&lt;/code&gt; return as &lt;code&gt;succeeded = true&lt;/code&gt;, on the reasoning that a non-blocking call proceeding normally counts as success, doesn&apos;t hold up.&lt;/p&gt;
&lt;p&gt;A cluster capture falsified that interpretation: all 21 observed HTTP/TCP readiness-probe &lt;code&gt;connect()&lt;/code&gt; calls returned &lt;code&gt;EINPROGRESS&lt;/code&gt;, including probes that Kubernetes recorded as &lt;code&gt;Unhealthy&lt;/code&gt;. The syscall therefore cannot say whether the probe later succeeded or failed; that outcome is determined by a follow-up call the tracer does not hook.&lt;/p&gt;
&lt;p&gt;The fix treats &lt;code&gt;succeeded&lt;/code&gt; as true only for an immediate &lt;code&gt;0&lt;/code&gt;, &lt;code&gt;failed&lt;/code&gt; only for a real error code other than &lt;code&gt;EINPROGRESS&lt;/code&gt;, and everything else, the common case, as pending. The authoritative signal for whether a pod actually became ready stays exactly where it belongs: Kubernetes&apos; own &lt;code&gt;Ready&lt;/code&gt; status condition, fetched directly from the API rather than re-derived from the probe attempts the tracer observed.&lt;/p&gt;
&lt;h2&gt;The timeline&lt;/h2&gt;
&lt;p&gt;The test container sleeps for twelve seconds before opening a listening socket, standing in for real startup work. Its readiness probe begins after one second and runs every two seconds. The container itself never crashes and the image never fails to pull; its readiness probe does fail, repeatedly, until the socket actually opens, which is expected. That shape, slow to become ready but never crashing, is different from every crash or stuck-image case the series has covered so far.&lt;/p&gt;
&lt;p&gt;Each row below shows a wall-clock timestamp, an elapsed-time offset from pod creation, a source tag (a Kubernetes Event, the pod&apos;s &lt;code&gt;Ready&lt;/code&gt; condition, an eBPF-observed kubelet probe attempt, or an eBPF-observed syscall), and what happened:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;2026-08-22T20:08:12Z          T+  1.000s  [K8S EVENT]   Started      Container started
2026-08-22T20:08:14Z          T+  3.000s  [K8S EVENT]   Unhealthy    (x5 repeats over 8.000s)
    Readiness probe failed: dial tcp 10.244.0.21:8080: connect: connection refused
2026-08-22T20:08:16.988402Z   T+  5.988s  [EBPF PROBE]  kubelet probe connect() -&amp;gt; 10.244.0.21:8080 FAILED [EINPROGRESS]
2026-08-22T20:08:18.987952Z   T+  7.987s  [EBPF PROBE]  kubelet probe connect() -&amp;gt; 10.244.0.21:8080 FAILED [EINPROGRESS]
2026-08-22T20:08:20.988120Z   T+  9.988s  [EBPF PROBE]  kubelet probe connect() -&amp;gt; 10.244.0.21:8080 FAILED [EINPROGRESS]
2026-08-22T20:08:22.988318Z   T+ 11.988s  [EBPF PROBE]  kubelet probe connect() -&amp;gt; 10.244.0.21:8080 FAILED [EINPROGRESS]
2026-08-22T20:08:24Z          T+ 13.000s  [K8S READY]   Ready=True (from pod status, not derived from probe attempts above)
2026-08-22T20:08:24.228734Z   T+ 13.228s  [EBPF SYSCALL] bind()  -&amp;gt; [::]:8080  duration=0.000s
2026-08-22T20:08:24.228750Z   T+ 13.228s  [EBPF SYSCALL] listen()             duration=0.000s
2026-08-22T20:08:24.988520Z   T+ 13.988s  [EBPF PROBE]  kubelet probe connect() -&amp;gt; 10.244.0.21:8080 FAILED [EINPROGRESS]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This capture predates the correction, so its &lt;code&gt;FAILED&lt;/code&gt; label is itself evidence of the bug; the corrected build renders those events as &lt;code&gt;PENDING&lt;/code&gt;. The probe rows fill in attempts that Kubernetes coalesced into one &lt;code&gt;Unhealthy&lt;/code&gt; Event. The API records &lt;code&gt;Ready=True&lt;/code&gt; only to whole-second precision. Its displayed T+13.000 value therefore cannot order it relative to the microsecond eBPF rows. The later &lt;code&gt;FAILED&lt;/code&gt; probe is likewise a pre-fix rendering error: that &lt;code&gt;EINPROGRESS&lt;/code&gt; result was pending, not a failed readiness check.&lt;/p&gt;
&lt;p&gt;Filtering makes the timeline usable: a comparable slow-start capture fell from 857 lines to 225, collapsing 643 routine syscalls into eleven summary lines while retaining every Kubernetes Event, probe attempt, and socket syscall.&lt;/p&gt;
&lt;h2&gt;Attribution race&lt;/h2&gt;
&lt;p&gt;Correlating socket syscalls exposed a cgroup-attribution race. Only 22.2% of &lt;code&gt;bind()&lt;/code&gt; calls resolved to a pod cluster-wide; the cgroup index refreshes every three seconds, but a lookup miss was finalized as &lt;code&gt;unknown&lt;/code&gt; immediately. Startup-time socket calls are rare and early, so a call can arrive before the index catches up and, before this change, never get another chance to resolve.&lt;/p&gt;
&lt;h2&gt;Retry on misses&lt;/h2&gt;
&lt;p&gt;On a lookup miss, the tracer now holds the event and retries after each index refresh for up to twelve seconds, then uses the existing &lt;code&gt;unknown&lt;/code&gt; path. Four refresh cycles let an event survive a missed refresh without retaining it indefinitely. System processes and static control-plane pods still remain unresolved; no retry can join their cgroups to a workload pod.&lt;/p&gt;
&lt;h2&gt;Results&lt;/h2&gt;
&lt;p&gt;An unfixed reproduction captured a successful &lt;code&gt;bind()&lt;/code&gt; for a cgroup known to belong to the test pod, yet stored it as &lt;code&gt;unknown&lt;/code&gt;; it remained unresolved forever. After the retry change, every captured &lt;code&gt;bind()&lt;/code&gt; and &lt;code&gt;listen()&lt;/code&gt; event for a resolvable workload-pod cgroup received an identity: 195 &lt;code&gt;bind()&lt;/code&gt; events and 17 &lt;code&gt;listen()&lt;/code&gt; events. The unfiltered cluster-wide &lt;code&gt;bind()&lt;/code&gt; rate rose more modestly, from 22.2% to 30.0%, because system, stale, and static-control-plane cgroups are not resolvable by this mechanism.&lt;/p&gt;
&lt;h2&gt;Isolation test&lt;/h2&gt;
&lt;p&gt;A retry-and-hold buffer introduces a new failure mode worth checking for directly: if two pods are racing through startup at the same time, could a delayed resolution attribute one pod&apos;s syscall to the other? The isolation test started two pods in the same second with deliberately different syscall signatures: one ran a file-read loop; the other ran a socket-connect loop that blocked for about a second per attempt.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;    pod_name        | syscall | count |    sum(duration)
---------------------+---------+-------+-----------------------
 isolation-pod-a     | connect |     2 |          3.2709e-05
 isolation-pod-a     | openat  |  2048 |          0.026864375
 isolation-pod-a     | read    |  1034 |          0.032389957
 isolation-pod-b     | bind    |    13 |          0.000282291
 isolation-pod-b     | connect |    14 |          12.000398686
 isolation-pod-b     | openat  |   251 |          0.022781171
 isolation-pod-b     | read    |   129 |          0.019244945
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Neither pod&apos;s syscalls appeared under the other&apos;s identity. The capture verifies final attribution, not an uninterrupted event-by-event trace: the tracer restarted after reaching its configured memory limit during the test.&lt;/p&gt;
&lt;h2&gt;Remaining limits&lt;/h2&gt;
&lt;p&gt;One concrete gap remains: the correlator cannot match static control-plane pods to their Kubernetes mirror objects because their identifiers differ.&lt;/p&gt;
&lt;p&gt;The slow-start capture exposes the previously opaque interval and keeps probe outcomes separate from &lt;code&gt;connect()&lt;/code&gt; observations. In the two-pod isolation test, retry resolution didn&apos;t cross-attribute the concurrently starting pods.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;/blog/pod-startup-forensics-closure-and-future&quot;&gt;Part 7&lt;/a&gt; closes the series by assessing the remaining production questions, including sustained database writes and the tracer&apos;s memory ceiling.&lt;/p&gt;
</content:encoded><author>Bhawani Singh</author></item><item><title>Pod Startup Forensics: Closure and What&apos;s Next</title><link>https://bhawanisingh.dev/blog/pod-startup-forensics-closure-and-future</link><guid isPermaLink="true">https://bhawanisingh.dev/blog/pod-startup-forensics-closure-and-future</guid><description>Closing the series: what six parts proved, the two scaling questions still untested, and the remaining exec-probe and production-kernel boundaries.</description><pubDate>Thu, 20 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;a href=&quot;/blog/pod-startup-forensics-closing-the-gap&quot;&gt;Part 6&lt;/a&gt; fixed the last correctness bug this series set out to find: a cgroup-lookup race that left most socket syscalls unattributed to a pod. This part closes the series itself: what six parts of building actually proved, and what remains an open question rather than a finding.&lt;/p&gt;
&lt;h2&gt;What got built&lt;/h2&gt;
&lt;p&gt;The problem, back in &lt;a href=&quot;/blog/pod-startup-forensics-the-problem&quot;&gt;Part 1&lt;/a&gt;, was that a slow pod startup produces almost no explanation of where the time went. Kubernetes Events are coarse, and their retention is controlled by the cluster rather than by a durable forensic store; kubelet&apos;s own telemetry doesn&apos;t say why a probe kept failing. &lt;a href=&quot;/blog/pod-startup-forensics-the-tooling-gap&quot;&gt;Part 2&lt;/a&gt; checked whether an existing tool already closed that gap and found that eBPF observability platforms, continuous profilers, and generic APM tools each solve a related but different problem, none of them putting pod lifecycle records and kernel-level syscall evidence on one shared timeline.&lt;/p&gt;
&lt;p&gt;The design that followed, in &lt;a href=&quot;/blog/pod-startup-forensics-lifecycle-events-syscall-evidence&quot;&gt;Part 3&lt;/a&gt;, was to capture both kinds of evidence and join them by pod identity and timestamp rather than treat them as separate systems a person has to correlate by hand. &lt;a href=&quot;/blog/pod-startup-forensics-the-architecture&quot;&gt;Part 4&lt;/a&gt; split that design into three cooperating programs: a CLI that queries stored evidence and renders a timeline, an exporter that watches the Kubernetes API and writes lifecycle records, and an eBPF tracer that hooks socket syscalls in the kernel and streams what it observes.&lt;/p&gt;
&lt;p&gt;Two problems remained once those pieces existed. The first was durability: a pod that has already been deleted is exactly the pod someone is most likely to ask about, and by then &lt;code&gt;kubectl describe&lt;/code&gt; returns nothing. &lt;a href=&quot;/blog/pod-startup-forensics-surviving-deletion&quot;&gt;Part 5&lt;/a&gt; added persistent storage and a &lt;code&gt;--timeline&lt;/code&gt; command that reconstructs a deleted pod&apos;s full startup history from Postgres or SQLite alone, verified by deleting a real three-pod pipeline&apos;s pods and rebuilding their timeline afterward with no gaps.&lt;/p&gt;
&lt;p&gt;The second was correctness in the merge itself. Building the correlation path that joins eBPF syscalls to the HTTP and TCP probe attempts visible through kubelet&apos;s own &lt;code&gt;connect()&lt;/code&gt; calls surfaced two real bugs rather than confirming the design worked on the first try: a &lt;code&gt;connect()&lt;/code&gt; return code that was being read as a success signal when it was actually a non-blocking call still in progress, and a cgroup-lookup race that left the large majority of syscalls unattributed to any pod because a lookup miss was treated as final instead of retried. &lt;a href=&quot;/blog/pod-startup-forensics-closing-the-gap&quot;&gt;Part 6&lt;/a&gt; fixed both: in the fixed run, every captured &lt;code&gt;bind()&lt;/code&gt; and &lt;code&gt;listen()&lt;/code&gt; event for a resolvable workload-pod cgroup received an identity, 195 &lt;code&gt;bind()&lt;/code&gt; events and 17 &lt;code&gt;listen()&lt;/code&gt; events, up from a cluster-wide pre-fix rate where most such events landed as unattributed. A concurrent two-pod test then verified the fix doesn&apos;t cross-attribute one pod&apos;s syscalls to another racing alongside it.&lt;/p&gt;
&lt;p&gt;That&apos;s what six parts actually established: a working phase-attribution pipeline that matches Kubernetes&apos; own lifecycle ground truth, a syscall-to-pod correlation path verified isolated under concurrent load, and a durable storage layer that survives pod deletion, each backed by a captured before-and-after run rather than a description of the intended behavior.&lt;/p&gt;
&lt;h2&gt;What was never claimed&lt;/h2&gt;
&lt;p&gt;None of that&apos;s the same as knowing how the system behaves at production scale. Every test in this series ran against a single-node kind cluster with a handful of pods at a time. Two scaling questions matter enough to name directly instead of leaving implicit, and neither one has been answered by anything this series actually did.&lt;/p&gt;
&lt;h3&gt;Postgres under sustained write load&lt;/h3&gt;
&lt;p&gt;The tracer records each correlated syscall event to a local buffer on the node and forwards it to the exporter, which attempts to persist it to Postgres or SQLite. That local buffer exists precisely because the forwarding path isn&apos;t guaranteed to keep up: on a busy node during this series&apos; earlier storage testing, unrelated background syscall traffic drove that local backlog past 60,000 unsent rows, and replaying all of it in one allocation on reconnect contributed to a real OOM kill, a separate incident from the memory ceiling discussed below. The fix bounded the replay to 2,000-row chunks rather than one unbounded read. The code&apos;s own comment on that fix names the exporter&apos;s Postgres pool as a suspected contributor to why the backlog built up, but nothing in this series independently measured Postgres itself while that happened: no pool metrics, no connection counts, no insert-latency capture. The confirmed finding is the local buffer&apos;s own unbounded-replay bug, now fixed; whether Postgres write throughput was actually a bottleneck that day, as opposed to just downstream of a noisy node, was never isolated and measured on its own.&lt;/p&gt;
&lt;p&gt;That distinction matters for what&apos;s still genuinely untested: sustained insert throughput, connection pool behavior, and query latency under deliberate, sustained concurrent write pressure, the kind a cluster running hundreds of pods starting at once would produce. Every test in this series that touched Postgres checked correctness, not load: did the right rows land, did a &lt;code&gt;--timeline&lt;/code&gt; reconstruction after deletion match what actually happened, did a regression run produce output identical in shape to the original. None of them measured the database under sustained write volume. A platform team adopting this design for that kind of cluster would be the first to find out whether the write path holds up, because nothing here ever generated that load on purpose or measured it directly. Postgres write-volume behavior under sustained high-frequency ingestion remains untested as its own question, separate from the local-buffer bug this series already found and fixed.&lt;/p&gt;
&lt;h3&gt;The tracer&apos;s syscall-rate ceiling&lt;/h3&gt;
&lt;p&gt;The eBPF tracer, unlike the Postgres question, does have a real finding behind it: the tracer restarted mid-test after hitting its configured memory limit during Part 6&apos;s two-pod isolation test, and that restart is worth the detail here as a real, measured limit rather than a footnote.&lt;/p&gt;
&lt;p&gt;That test needed two pods generating deliberately different, high-volume syscall patterns at the same time, to prove the cgroup-retry fix wouldn&apos;t cross-attribute one pod&apos;s events to the other. The first attempt ran both workloads as fast as the shell would allow, with no pacing. The tracer was OOMKilled, confirmed on the node with &lt;code&gt;dmesg&lt;/code&gt;, showing the tracer process killed for exceeding a 384Mi memory limit set on its container, with anonymous resident memory around 391MB at the moment of the kill (the quoted &lt;code&gt;dmesg&lt;/code&gt; line survives in this project&apos;s running build notes; no separate raw capture file was saved for it, unlike the numeric captures elsewhere in this series). The test had destroyed its own instrument before it could produce a result.&lt;/p&gt;
&lt;p&gt;No allocation profiler ran during the OOM, so what actually consumed the memory was never measured directly. What&apos;s known is that the run exceeded its budget. The kernel ring buffer that delivers raw events is fixed in size, and the outbound gRPC stream is bounded too, so a higher event rate fills those and causes stalling or dropped events rather than growing their own allocation without limit. The more plausible candidates are the parts of the pipeline that aren&apos;t capacity-bounded: the unbounded in-process handoff that moves each event from the ring-buffer reader to the correlator, and the correlator&apos;s own in-memory state, the map of syscalls entered but not yet matched to their exit, and the retry buffer holding events whose cgroup lookup hasn&apos;t resolved yet, both of which can grow with however many events are in flight at once rather than draining at a fixed rate. The correlator also holds a separate in-memory index mapping every pod and container cgroup on the node to its identity, refreshed every few seconds, so pod and container count are candidate contributors too, not just event rate, and this test didn&apos;t isolate one from the other.&lt;/p&gt;
&lt;p&gt;The two unthrottled loops in this test were deliberately artificial rather than production-shaped; this series never measured how their syscall rate compares with a real pod startup&apos;s. The response was to pace the test workloads rather than raise the memory limit again to make the symptom go away. With pacing, the same two-pod test completed correctly, with no cross-attribution between the two pods, which is the finding &lt;a href=&quot;/blog/pod-startup-forensics-closing-the-gap&quot;&gt;Part 6&lt;/a&gt; actually reported; the tracer still restarted once mid-test after hitting the same limit, which is why that capture verifies final attribution rather than an uninterrupted trace.&lt;/p&gt;
&lt;p&gt;The honest framing of that result is narrower than &quot;the tracer has a memory problem,&quot; and narrower still than a general operating threshold. For this specific artificial workload against this specific container&apos;s 384Mi budget, the tracer hit a real ceiling, not evidence that its memory usage is unbounded or unpredictable in ordinary operation. What it doesn&apos;t establish is where that ceiling sits for a production-shaped workload, or against a different memory budget, because the workload that hit it was constructed deliberately to be unrealistic, not sampled from a real deployment. Where the safe operating envelope actually is, and whether 384Mi is a reasonable default cgroup limit for a real cluster&apos;s pod-startup traffic rather than just this test&apos;s own two artificial loops, is future validation work, not something this series measured.&lt;/p&gt;
&lt;h2&gt;Other open questions, named plainly&lt;/h2&gt;
&lt;p&gt;A few smaller items surfaced during the build and were deliberately left alone, because fixing them was out of scope for the specific problem each part was solving.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Static and mirror control-plane pods still don&apos;t resolve.&lt;/strong&gt; kube-apiserver, kube-controller-manager, kube-scheduler, and etcd, when run as static pods, get a cgroup slice path derived from a kubelet-computed hash rather than the UID the Kubernetes API reports for the same pod. The correctness fix in &lt;a href=&quot;/blog/pod-startup-forensics-closing-the-gap&quot;&gt;Part 6&lt;/a&gt; retries a cgroup lookup that initially misses; it doesn&apos;t help here, because the mismatch isn&apos;t a timing problem retrying would fix. It&apos;s two different identifiers for the same pod that never converge no matter how long the tracer waits. The race &lt;a href=&quot;/blog/pod-startup-forensics-closing-the-gap&quot;&gt;Part 6&lt;/a&gt; closed and this identifier mismatch are different bugs entirely.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Reacting to pod-create events directly, instead of relying on a periodic refresh, was considered and set aside.&lt;/strong&gt; The cgroup-to-pod index that the correlator consults refreshes on a timer rather than in response to the Kubernetes API announcing a new pod. The exporter already watches the Kubernetes API for lifecycle records, but the cgroup index is the tracer&apos;s own separate structure with no watch of its own. Wiring a second, tracer-side watch stream specifically for that index was a real alternative, and was set aside because it adds its own reconnect and backoff handling for a latency win that isn&apos;t actually guaranteed: the cgroup directory a new pod&apos;s syscalls need to resolve against can still lag behind the watch event announcing the pod, so the added complexity might not even close the gap it&apos;s meant to close. In this series&apos; own single-node testing, the timer-based refresh interval narrowed that lag enough that the retry fix (see &lt;a href=&quot;/blog/pod-startup-forensics-closing-the-gap&quot;&gt;Part 6&lt;/a&gt;) resolved every captured pod-scoped &lt;code&gt;bind()&lt;/code&gt;, &lt;code&gt;listen()&lt;/code&gt;, and &lt;code&gt;accept4()&lt;/code&gt; event; a watch-based design stays a real option that wasn&apos;t pursued rather than a known necessity that was skipped.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The default noise-filtering threshold has only been checked against one workload&apos;s timing.&lt;/strong&gt; Part 6&apos;s filtering pass chose 10 milliseconds as the cutoff below which routine bookkeeping syscalls collapse into summary lines. That number wasn&apos;t arbitrary: the one test scenario it was checked against showed 643 calls landing under the threshold and exactly two calls landing above it, at 10.16ms and 14.28ms, so 10ms cleanly separated the two groups for that capture. Whether that same threshold cleanly separates noise from signal for a container doing heavier synchronous I/O during startup, where ordinary reads might legitimately take several milliseconds without being noise, is untested. The threshold is exposed as a command-line flag specifically so a workload that needs a different value doesn&apos;t require a code change, which is a reasonable hedge against the untested case, not a claim that the default generalizes.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Exec probes don&apos;t fit the tracer&apos;s existing correlation signal.&lt;/strong&gt; Every probe correlation this series built keys off kubelet dialing the pod over a socket, so its &lt;code&gt;connect()&lt;/code&gt; shows up as a kernel event the tracer can already observe and match to a probe attempt. An exec probe runs a command inside the container instead, so that particular kubelet-side &lt;code&gt;connect()&lt;/code&gt; signal doesn&apos;t exist for it. That&apos;s not the same as saying no host-side signal could ever work; it means the existing correlation mechanism doesn&apos;t cover this case, and building one that does, most likely tracing the probe process&apos;s own execution inside the container, is separate work nothing in this series attempted.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The tracer has only been exercised on one kernel.&lt;/strong&gt; Every capture ran on Docker Desktop&apos;s Linux VM kernel through kind, not on a production worker-node kernel. The hooks use tracepoint formats validated on that kernel, but tracepoints aren&apos;t a stable Linux ABI; attachment points, fields, and offsets may differ elsewhere. Compatibility with production nodes such as EKS or Amazon Linux 2023 remains untested.&lt;/p&gt;
&lt;h2&gt;What this series was and wasn&apos;t&lt;/h2&gt;
&lt;p&gt;What six parts actually produced is a working mechanism for joining Kubernetes lifecycle records to kernel-level syscall evidence, proven correct at the scale it was tested at, because each fix was caught by testing against a real cluster instead of trusted on paper. The questions that only show up at a different scale, or on infrastructure this series never touched, are named here rather than left implicit: sustained database write volume, the tracer&apos;s own memory ceiling, exec-probe correlation, and eBPF portability to a production node kernel.&lt;/p&gt;
&lt;p&gt;The next work is validation: load-test sustained database writes and tracer memory under realistic multi-pod, multi-node startup traffic; verify the tracer on production kernels; and add a correlation path for exec probes. Those tests should report observed behavior, not assumed design intent.&lt;/p&gt;
&lt;p&gt;I’ll continue evolving the tool and, when it’s ready for production use, I plan to release it under an open-source license so others can evaluate it, adapt it to their clusters, and contribute improvements.&lt;/p&gt;
</content:encoded><author>Bhawani Singh</author></item></channel></rss>