<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Notes From The Platform Layer ]]></title><description><![CDATA[Notes from the platform layer - distributed systems, production K8s, and the architecture decisions and trade-offs learnt the hard way. Increasingly at intersec]]></description><link>https://abhishekpareek.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>Notes From The Platform Layer </title><link>https://abhishekpareek.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Fri, 31 Jul 2026 04:32:44 GMT</lastBuildDate><atom:link href="https://abhishekpareek.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Anatomy of a Silent 502: The Four Characters That Explained a 60-Second Timeout]]></title><description><![CDATA[A 502 Bad Gateway tells you that one proxy failed to get a valid response from another system. It does not tell you which layer failed, whether the application ever saw the request, or which timeout a]]></description><link>https://abhishekpareek.dev/anatomy-of-a-silent-502-the-four-characters-that-explained-a-60-second-timeout</link><guid isPermaLink="true">https://abhishekpareek.dev/anatomy-of-a-silent-502-the-four-characters-that-explained-a-60-second-timeout</guid><category><![CDATA[Haproxy]]></category><category><![CDATA[ingress-nginx]]></category><category><![CDATA[502]]></category><category><![CDATA[timeout]]></category><category><![CDATA[proxy]]></category><category><![CDATA[Kubernetes]]></category><category><![CDATA[Reverse Proxy]]></category><dc:creator><![CDATA[abhishek pareek]]></dc:creator><pubDate>Sat, 18 Jul 2026 00:30:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a25ff63980616b1c020fc7a/e5b03acb-3e1b-4453-81ef-3a7575f67d21.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>A <code>502 Bad Gateway</code> tells you that one proxy failed to get a valid response from another system. It does not tell you which layer failed, whether the application ever saw the request, or which timeout actually expired.</p>
<p>We learned that distinction during a production incident involving failed file uploads through a Kubernetes platform.</p>
<p>The request path looked like this:</p>
<pre><code class="language-text">Internet
   |
Firewall
   |
HAProxy
   |
ingress-nginx
   |
Apache APISIX
   |
Customer pod
</code></pre>
<p>Customers were seeing failures while uploading files such as PDFs and JPEGs. HAProxy was returning 502 responses, but the downstream evidence was strangely clean.</p>
<p>There were no corresponding requests in the ingress-nginx access logs.</p>
<p>Nothing appeared in APISIX.</p>
<p>Nothing reached the application.</p>
<p>For a while, it looked as though the traffic had simply vanished between HAProxy and Nginx.</p>
<p>It had not. The most useful evidence was hiding in four characters:</p>
<pre><code class="language-text">SH--
</code></pre>
<h2>The evidence in the HAProxy logs</h2>
<p>Two representative failures looked like this:</p>
<pre><code class="language-text">[17/Jul/2026:16:38:45.516]
54.196.250.55:36466
agw-oc-api-fe-prod~
k8s-prod-ny-route-https-agw-oc-api-fe-prod/ingress1
130/0/6/-1/60132/67/63/0
502 268 - - SH--
"PUT /v2/files/default/.../Item_3.Rxcdgh7.11.22_.pdf HTTP/1.1"
</code></pre>
<pre><code class="language-text">[17/Jul/2026:16:38:19.181]
44.213.63.202:40376
agw-oc-api-fe-prod~
k8s-prod-ny-route-https-agw-oc-api-fe-prod/ingress3
130/0/5/-1/60132/66/64/0
502 268 - - SH--
"PUT /v2/files/default/.../rGHjdsjf56TY_Letter.jpg HTTP/1.1"
</code></pre>
<p>These were different clients, different files, and different ingress-nginx instances.</p>
<p>Yet both transactions ended after exactly:</p>
<pre><code class="language-text">60132 ms
</code></pre>
<p>That was the first strong clue.</p>
<p>Random packet loss does not usually fail two unrelated requests at almost precisely the same millisecond. Configuration timers do.</p>
<h2>Decoding <code>SH--</code></h2>
<p>HAProxy termination-state codes describe how a session ended and which phase it had reached.</p>
<p>The capitalization matters.</p>
<p>In this case:</p>
<ul>
<li><p>Uppercase <code>S</code> means the server side aborted the connection.</p>
</li>
<li><p><code>H</code> means HAProxy was waiting for complete HTTP response headers.</p>
</li>
</ul>
<p>Together, <code>SH</code> indicates that the selected server—or something on that side of the connection—closed before HAProxy received a complete response header.</p>
<p>That is different from:</p>
<pre><code class="language-text">sH
</code></pre>
<p>A lowercase <code>s</code> indicates that HAProxy’s own server-side timeout expired while it was waiting for response headers.</p>
<p>Our logs showed uppercase <code>S</code>.</p>
<p>That told us HAProxy was not merely becoming impatient and closing an otherwise healthy connection. From HAProxy’s perspective, the ingress side closed first. HAProxy then generated the 502 returned to the client.</p>
<p>The custom timer sequence also included <code>-1</code>, indicating that one of the measured phases never completed, and both requests ended at approximately 60.132 seconds.</p>
<p>Because HAProxy log formats are configurable, it is dangerous to assign names to every slash-delimited field without checking the active <code>log-format</code>. But the broader sequence was clear:</p>
<ol>
<li><p>HAProxy selected an ingress server.</p>
</li>
<li><p>The backend connection was established quickly.</p>
</li>
<li><p>HAProxy never received complete response headers.</p>
</li>
<li><p>The server side closed at a repeatable 60-second boundary.</p>
</li>
</ol>
<h2>Why the downstream logs were empty</h2>
<p>The absence of an ingress-nginx access-log entry initially sent us in the wrong direction.</p>
<p>An access log is not a packet capture.</p>
<p>A TCP connection may reach Nginx and fail while Nginx is still reading or parsing the request. Depending on the configuration and failure stage, it may never produce the normal access-log entry an operator expects.</p>
<p>The same explains why APISIX and the application were silent. If ingress-nginx had not completed the client-side request-processing phase, the request might never have been forwarded downstream.</p>
<p>The evidence therefore did not show that traffic had failed to reach Nginx.</p>
<p>It showed that the request did not survive far enough to become visible through the logs we were checking.</p>
<h2>The misleading 20-minute settings</h2>
<p>The ingress-nginx configuration already contained generous proxy timeouts:</p>
<pre><code class="language-nginx">proxy_connect_timeout 1200s;
proxy_send_timeout    1200s;
proxy_read_timeout    1200s;
</code></pre>
<p>At first glance, those values appeared to rule out a timeout in Nginx.</p>
<p>They allowed 20 minutes. The requests failed after one.</p>
<p>The mistake was assuming that all Nginx timeouts govern the same connection.</p>
<p>In this topology, ingress-nginx is both a server and a client:</p>
<pre><code class="language-text">HAProxy  ---&gt;  ingress-nginx  ---&gt;  APISIX
 client        server/client       server
</code></pre>
<p>The <code>proxy_*</code> directives govern communication between Nginx and its proxied upstream—in this case, the connection from ingress-nginx toward APISIX.</p>
<p>They do not control how long Nginx allows its own client, HAProxy, to submit a request.</p>
<p>Nginx documents <code>proxy_send_timeout</code> and <code>proxy_read_timeout</code> as inactivity timers between successive write or read operations on the upstream connection, rather than total transaction-duration limits.</p>
<p>Our 20-minute configuration protected the wrong side of the Nginx process.</p>
<h2>The 60-second fingerprint</h2>
<p>Nginx has several directives with 60-second defaults. Two relevant ones are:</p>
<pre><code class="language-nginx">client_header_timeout 60s;
client_body_timeout   60s;
</code></pre>
<p>They control different request phases.</p>
<p><code>client_header_timeout</code> limits the time allowed to read the complete client request header.</p>
<p><code>client_body_timeout</code> applies while reading the request body. It is an inactivity timeout between successive reads, not a maximum duration for the whole upload.</p>
<p>Because the incident involved uploads, <code>client_body_timeout</code> would normally be an obvious suspect. File contents live in the request body, not in the headers.</p>
<p>However, the configuration change that resolved this incident was:</p>
<pre><code class="language-nginx">client_header_timeout 300s;
</code></pre>
<p>After the value was increased from 60 to 300 seconds for the affected ingress, the failures stopped.</p>
<p>That gives us a strong operational conclusion:</p>
<blockquote>
<p>ingress-nginx was closing the HAProxy-side connection at a repeatable 60-second boundary before returning response headers, and increasing the effective <code>client_header_timeout</code> to 300 seconds eliminated the failures.</p>
</blockquote>
<p>What the existing evidence does not prove is why the header-reading phase took that long.</p>
<p>Possible contributing factors include fragmented or delayed request transmission, buffering in another intermediary, connection-reuse behavior, or another interaction in the request path. Proving the exact mechanism would require an ingress error log, Nginx debug trace, or packet capture from the incident.</p>
<p>That distinction matters. A post-mortem should state what the evidence proves, not promote a plausible theory into a certainty.</p>
<h2>The targeted production fix</h2>
<p>This ingress controller was shared across the platform.</p>
<p>Changing <code>client_header_timeout</code> globally would have affected every application using it. During an outage involving a specific set of routes, expanding the change to every tenant would have created an unnecessary blast radius.</p>
<p>We therefore used a server-level snippet on the affected Ingress:</p>
<pre><code class="language-yaml">metadata:
  annotations:
    nginx.ingress.kubernetes.io/server-snippet: |
      client_header_timeout 300s;
</code></pre>
<p>The <code>server-snippet</code> annotation injects custom configuration into the Nginx <code>server</code> block generated for that Ingress host.</p>
<p>That gave us the narrow scope we needed: fix the affected virtual server without changing timeout behavior for every workload on the shared controller.</p>
<p>It was also a conscious security tradeoff.</p>
<h2>The security tradeoff</h2>
<p>Custom snippets are powerful because they allow raw Nginx configuration to be inserted into the generated configuration.</p>
<p>That same power makes them dangerous in shared or multi-tenant environments.</p>
<p><strong>CVE-2021-25742</strong> documented how a user with permission to create or update Ingress resources could abuse custom snippets to retrieve the ingress-nginx controller’s service-account token and Secrets available to the controller.</p>
<p>The decision was therefore not whether snippets were entirely safe.</p>
<p>They were not.</p>
<p>The practical choices during the outage were:</p>
<pre><code class="language-text">Change the shared controller globally
→ Affect every platform tenant

Leave the configuration unchanged
→ Continue the customer-facing outage

Apply a targeted server snippet
→ Accept a known, controlled security tradeoff
</code></pre>
<p>We chose the third option.</p>
<p>Sometimes production recovery requires using an escape hatch. The responsible approach is to limit who can use it, constrain its scope, review the generated configuration, document the exception, and create a path to remove it.</p>
<p>The snippet was treated as operator-controlled platform configuration—not as a self-service feature available to arbitrary tenants.</p>
<h2>The retirement problem</h2>
<p>This incident also occurred after the Kubernetes ingress-nginx project had been retired.</p>
<p>The Kubernetes project announced that maintenance would end in March 2026, with no further releases, bug fixes, or security updates after retirement. Existing deployments would continue to function, but would no longer receive upstream maintenance.</p>
<p>That did not cause the outage, but it changed the risk calculation.</p>
<p>We were applying an implementation-specific workaround to a shared controller that was no longer receiving long-term fixes.</p>
<p>The immediate remediation and the durable remediation were therefore different:</p>
<pre><code class="language-text">Immediate:
Apply a targeted 300-second timeout and restore uploads.

Durable:
Migrate away from the retired ingress-nginx controller.
</code></pre>
<p>The snippet ended the outage.</p>
<p>It was not a migration strategy.</p>
<h2>Lessons learned</h2>
<p>First, a 502 identifies the component reporting the failure, not necessarily the component that caused it.</p>
<p>Second, HAProxy termination flags are more useful than the status code alone. <code>SH</code> and <code>sH</code> point to different failure owners.</p>
<p>Third, missing access logs do not prove a connection never arrived. Requests can fail before normal access logging occurs.</p>
<p>Fourth, timeout names must always be mapped to connection direction. The <code>proxy_*</code> values on ingress-nginx controlled its upstream connection, not the request coming from HAProxy.</p>
<p>Finally, exact timing is a signature. Two unrelated requests failing at precisely 60.132 seconds strongly suggested a deterministic timer, not a random network event.</p>
<h2>Final takeaway</h2>
<p>The key clue was not the 502.</p>
<p>It was:</p>
<pre><code class="language-text">SH--
</code></pre>
<p>Those characters showed that HAProxy had connected to ingress-nginx, waited for response headers, and then observed the server side close.</p>
<p>The matching 60.132-second durations exposed a timeout boundary. A targeted <code>client_header_timeout 300s</code> server snippet restored uploads without changing the shared controller globally.</p>
<p>That fix carried a known security tradeoff, including the risk class documented by CVE-2021-25742. It was also applied to a retired ingress controller whose long-term answer was migration.</p>
<p>Sometimes the safest production decision is not risk-free.</p>
<p>It is the option with the smallest understood blast radius, applied deliberately, documented honestly, and removed when a durable replacement is ready.</p>
<hr />
<h2>References</h2>
<ol>
<li><p><a href="https://www.haproxy.com/documentation/haproxy-configuration-manual/latest/"><strong>HAProxy Configuration Manua</strong></a><strong>l — termination states and logging</strong>. HAProxy Technologies.</p>
</li>
<li><p><a href="https://www.haproxy.com/blog/introduction-to-haproxy-logging"><strong>Introduction to HAProxy Logging</strong></a><strong>: A Practical Guide</strong>. HAProxy Technologies.</p>
</li>
<li><p><strong>Nginx HTTP Core Module</strong> <strong>—</strong> <code>client_header_timeout</code> <strong>and</strong> <code>client_body_timeout</code>. Nginx.</p>
</li>
<li><p><a href="https://nginx.org/en/docs/http/ngx_http_core_module.html"><strong>Nginx HTTP Proxy Module</strong></a> <strong>— proxy timeout directives</strong>. Nginx.</p>
</li>
<li><p><a href="https://groups.google.com/g/kubernetes-security-announce/c/mT4JJxi9tQY"><strong>CVE-2021-25742</strong></a><strong>: ingress-nginx custom snippets security advisory</strong>. Kubernetes community.</p>
</li>
<li><p><a href="https://www.kubernetes.io/blog/2025/11/11/ingress-nginx-retirement/"><strong>Ingress NGINX Retirement</strong></a><strong>: What You Need to Know</strong>. Kubernetes Blog, November 11, 2025.</p>
</li>
<li><p><a href="https://www.kubernetes.io/blog/2026/01/29/ingress-nginx-statement/"><strong>Ingress NGINX</strong></a><strong>: Statement from the Kubernetes Steering Committee</strong>. Kubernetes Blog, January 29, 2026.</p>
</li>
</ol>
]]></content:encoded></item><item><title><![CDATA[From The Vault: How SNAT Turned UDP Into a Stateful Protocol]]></title><description><![CDATA[Most of our K8s clusters use the open-source Cilium CNI, and for a subset of workloads we route outbound traffic through a dedicated Cilium Egress Gateway. The motivation is pretty common: some extern]]></description><link>https://abhishekpareek.dev/from-the-vault-how-snat-turned-udp-into-a-stateful-protocol</link><guid isPermaLink="true">https://abhishekpareek.dev/from-the-vault-how-snat-turned-udp-into-a-stateful-protocol</guid><category><![CDATA[Kubernetes]]></category><category><![CDATA[cilium]]></category><category><![CDATA[eBPF]]></category><category><![CDATA[networking]]></category><category><![CDATA[Platform Engineering ]]></category><dc:creator><![CDATA[abhishek pareek]]></dc:creator><pubDate>Mon, 13 Jul 2026 20:45:14 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a25ff63980616b1c020fc7a/02f9d224-5fc2-4c29-98a8-ce0bcede83ed.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><strong>Most</strong> of our K8s clusters use the open-source Cilium CNI, and for a subset of workloads we route outbound traffic through a dedicated Cilium Egress Gateway. The motivation is pretty common: <strong>some external systems insist on traffic originating from a known, stable IP address, so SNATing egress traffic through a designated gateway node gives us that consistency</strong>. It's a setup that's been running in production for quite some time and, for the most part, had been remarkably uneventful, and we are not complaining.</p>
<h2>Something Broke!</h2>
<p>That, however, changed when one of our busiest production clusters started exhibiting intermittent DNS failures. The symptoms were frustratingly inconsistent - most lookups completed successfully, but every so often an application would fail to resolve an external hostname, only to succeed moments later. Unfortunately the failures were frequent enough to cause real production impact, yet sporadic enough they escaped a very thorough, focused investigation.</p>
<h2>DNS ?</h2>
<p>Our first instinct was to blame DNS as It was the most obvious suspect. We started with the usual suspects:</p>
<ul>
<li><p>CoreDNS</p>
</li>
<li><p>upstream resolvers</p>
</li>
<li><p>network latency</p>
</li>
<li><p>packet loss</p>
</li>
<li><p>firewall rules</p>
</li>
</ul>
<p>Essentially everythinh you would normally expect to investigate when name resolution starts misbehaving, but nothing stood out - everything just looked <em>reassuringly</em> boring. Yet the application errors kept trickling in time to time, and of course on our busiest PROD cluster!</p>
<h2>Observability To The Rescue</h2>
<p>As the DNS investigation was a dead-end we naturally shifted our attention away to look elsewhere. Next up was the layer underneath - the Cilium CNI as it happened to be sitting squarely in the middle of every outbound packet.</p>
<p>As I worked my way through the dashboards, one metric immediately stood out: <code>cilium_ct_any4_global</code> - steadily climbing until it was brushing up against its configured limit with the timestamp matching the outages. I personally - out of my ignorance/lack of knowledge - had always associated connection tracking with TCP, so I was a bit surprised to discover through the docs. that It tracked protocols like UDP and ICMP. This was interesting, and I though to myself - If UDP is stateless, why would it need a conntrack table?</p>
<h2>UDP Is Stateless, But ...</h2>
<p>To be fair, that assumption wasn't entirely wrong as UDP <em>is</em> <em>INDEED</em> a stateless protocol. Unlike TCP, there is no three-way handshake etc., and the sender transmits it and moves on, with no guarantee that it will ever arrive or that anyone will respond. So seeing a connection tracking table steadily filling up with what was almost entirely DNS traffic felt fundamentally at odds with everything I thought I knew about how UDP worked.</p>
<blockquote>
<p><strong>SNAT forces the network device to remember things.</strong></p>
</blockquote>
<p>Further research cleared out few things - every outbound connection matching our egress policy was being SNAT'ed through the Cilium Egress Gateway so that external systems would always see the same source IP. The moment a gw replaces a pod's source addr with its own, it takes on a new responsibility: when the reply eventually comes back, it somehow has to figure out which pod inside the cluster originally sent the packet. And unlike TCP, he gw has no way of knowing when it's safe to forget about that translation.</p>
<h2>Connection Tracking</h2>
<p>Every time the gw rewrites a DNS query, it creates a small piece of state that says, "I changed this packet's source address. If a reply comes back matching these details, send it back to this pod." The reply usually arrives a few milliseconds later, the entry is used once, and after a timeout it disappears.</p>
<pre><code class="language-plaintext">Packet Flow
───────────────────────────────────────

Pod ─────► Gateway ─────► DNS
             │
             │
             ▼
      Create CT Entry
             │
             ▼
DNS ◄───── Gateway ◄───── Reply
             │
             ▼
      Delete CT Entry Or After A Timeout
</code></pre>
<p>The problem, of course, is that production systems rarely do anything just once.</p>
<p>Modern applications are surprisingly chatty when it comes to DNS. A single service call can trigger one or more DNS queries. Now multiply that across hundreds of pods, each generating hundreds of lookups over relatively short periods, and suddenly the gateway isn't remembering a handful of translations anymor as it's trying to remember hundreds of thousands of them simultaneously. So the realization:</p>
<blockquote>
<p>The DNS infrastructure was never the bottleneck. The gateway's memory was.</p>
</blockquote>
<p>UDP hadn't magically become stateful - The gateway did.</p>
<p>In other words, the conntrack table wasn't simply tracking TCP connections at al, but it was tracking the gw's own state. Every time the gateway rewrote a packet, regardless of whether the protocol was TCP or UDP, it had to remember enough information to undo that rewrite when the reply returned.</p>
<h2>Long Term Fix</h2>
<p>There really wasn't a need for us t o track DNS traffic per customer egress IP. So in a nutshell we had effectively turned our busiest source of short-lived traffic into unnecessary work for the gateway.</p>
<p>So the solution was simply to stop doing that.</p>
<p>By excluding DNS traffic from our <code>CiliumEgressGatewayPolicy</code>, DNS queries bypassed the gateway entirely and exited through the node's normal networking stack instead thereby ensuring that the gw no longer had to allocate conn. tracking entries for every DNS lookup, while the application traffic that genuinely required a stable egress IP continued to flow through the gateway exactly as before.</p>
<p>The effect was almost immediate. The number of UDP entries in <code>cilium_ct_any4_global</code> dropped dramatically. Hubble showed DNS traffic leaving with the node's local IP while HTTPS traffic continued to use the gateway IP. More importantly, the intermittent DNS failures disappeared, and so did the BPF map exhaustion warnings that had quietly been building in the background.</p>
<h2><em>The protocol is stateless. The infrastructure around it probably isn't.</em></h2>
<p>Looking back, the most valuable lesson wasn't about Cilium, eBPF or even connection tracking. It was about abstractions.</p>
<p>We spend years learning that UDP is stateless, and that's absolutely true—from the protocol's perspective. But protocols don't exist in isolation. The moment you ask a device to rewrite packets, enforce policy or perform NAT, somebody has to remember what changed. That state has to live somewhere, and in Cilium's case it lives inside eBPF connection tracking maps. Once I stopped thinking about <code>cilium_ct_any4_global</code> as "UDP connection tracking" and started thinking of it as "the gateway's short-term memory," everything else fell neatly into place.</p>
]]></content:encoded></item><item><title><![CDATA[Surviving A Complete ETCD Outage, Part 2: Restoration]]></title><description><![CDATA[In Part 1 we got a verified ETCD snapshot back onto an Ansible control host, even with the cluster fully down.
I've come across many a blog posts describing ETCD restoration process on a single node, ]]></description><link>https://abhishekpareek.dev/surviving-a-complete-etcd-outage-part-2-restoration</link><guid isPermaLink="true">https://abhishekpareek.dev/surviving-a-complete-etcd-outage-part-2-restoration</guid><category><![CDATA[etcd]]></category><category><![CDATA[etcd-restore]]></category><category><![CDATA[k8s]]></category><category><![CDATA[Kubernetes]]></category><category><![CDATA[kubeadm]]></category><category><![CDATA[SRE]]></category><category><![CDATA[Devops]]></category><category><![CDATA[HA]]></category><dc:creator><![CDATA[abhishek pareek]]></dc:creator><pubDate>Fri, 03 Jul 2026 20:37:34 GMT</pubDate><content:encoded><![CDATA[<p>In <a href="https://abhishek-pareek.hashnode.dev/surviving-a-complete-etcd-outage-part-1-snapshots-and-getting-them-back-when-the-cluster-is-gone">Part 1</a> we got a verified <a href="https://etcd.io/">ETCD</a> snapshot back onto an <a href="https://ansible.com">Ansible</a> control host, even with the cluster fully down.</p>
<p>I've come across many a blog posts describing ETCD restoration process on a single node, but not on a full 3 master setup which is what everyone in production uses at the very least. This post is the failure catalog we wish we had read first.</p>
<h2>Firstly: Identify the 'Failure Mode'</h2>
<p>A restore rewinds the whole cluster to the snapshot instant, discarding every write since. That is fine when you have lost everything, but it might well be an unnecessary data loss you've inflicted on yourself in other cases.</p>
<p>A 3-member <strong>etcd cluster needs a majority, 2 of 3, to serve writes</strong>. So triage before you touch anything as getting it right is half the value:</p>
<ul>
<li><p><strong>1 master down.</strong> Quorum retained (2/3), cluster fully operational - Remove the dead member, add a fresh one.</p>
</li>
<li><p><strong>2 masters down.</strong> Quorum lost (1/3) - <strong>read-only,</strong> but the survivor still holds current data. Do NOT restore. Recover quorum on the survivor with <code>--force-new-cluster</code>, then re-add members.</p>
</li>
<li><p><strong>All 3 down.</strong> No surviving data. <strong>This, and only this, is a snapshot restore.</strong></p>
</li>
</ul>
<h2>The trap</h2>
<p>The obvious idea for all-3-down is, "restore the snapshot on each master". It is wrong, and it fails in a way that wastes an hour of your time eating into your precious RTO. <code>etcdutl snapshot restore</code> <strong>mints a brand new cluster ID every time it runs</strong> - Restore on all three and you get three separate one-member clusters, three different cluster IDs, <strong>that will never form a quorum together.</strong></p>
<blockquote>
<p>The symptom is master1 endlessly probing the other two as peers, "connection refused" or a cluster ID mismatch in the logs, and no leader, ever.</p>
</blockquote>
<h2>Correct shape: restore once, then add</h2>
<p>The procedure that actually works, and the one our DR plan mandates, is:</p>
<ol>
<li><p>Restore the snapshot exactly once, on <strong>master1</strong>, as a single-member cluster.</p>
</li>
<li><p>Add master2, then master3, as new members with <code>etcdctl member add</code>. They start with an empty data dir and replicate from master1.</p>
</li>
</ol>
<p>One restore followed by two live joins. Concretely, once master1 is up as a single healthy member, each joiner is a register-then-start, run from master1:</p>
<pre><code class="language-bash"># 1. register the joiner in the cluster (from master1)
etcdctl member add master2-&lt;cluster&gt; --peer-urls=https://10.0.0.2:2380 \
  --endpoints=https://10.0.0.1:2379 $CERTS
# 2. on master2: set --initial-cluster-state=existing and --initial-cluster to the
#    growing member list in its etcd manifest, empty /var/lib/etcd, then start etcd
# 3. confirm from master1 that master2 is 'started' before you touch master3
etcdctl member list -w table --endpoints=https://10.0.0.1:2379 $CERTS
</code></pre>
<p>We wrapped the whole sequence in an Ansible role, <code>etcd-restore</code>, so the real run is one command:</p>
<pre><code class="language-bash">ansible-playbook -i inventory/&lt;cluster&gt; etcd-restore.yaml \
  -u &lt;user&gt; -kK -b -e target_cluster=&lt;cluster&gt; -e etcd_snapshot_db=/root/etcd-snapshot.db
</code></pre>
<p>However the manual steps still matter, because break-glass is real, and because every one of the following issues is something the automation had to learn to survive.</p>
<pre><code class="language-bash">etcdutl snapshot restore /root/etcd-snapshot.db \
  --name master1-&lt;cluster&gt; \
  --initial-cluster master1-&lt;cluster&gt;=https://10.0.0.1:2380 \
  --initial-advertise-peer-urls https://10.0.0.1:2380 \
  --data-dir /var/lib/etcd/.restore-tmp
mv /var/lib/etcd/.restore-tmp/member /var/lib/etcd/member
rmdir /var/lib/etcd/.restore-tmp
</code></pre>
<p>On etcd 3.6, <code>etcdctl snapshot restore</code> was removed, so the offline restore must use <code>etcdutl</code>.</p>
<p><code>etcdctl</code> is still what you use for the online <code>member add</code> and <code>endpoint health</code> later. You need both binaries, matched to the cluster's etcd version.</p>
<h2>Landmine 1: the member name must match the manifest</h2>
<p>The <code>--name</code> and the <code>--initial-cluster</code> key you restore with have to exactly equal the <code>--name</code> in master1's etcd static Pod manifest, which is the node's full hostname, not the literal "default" that most examples use. Restore with the wrong name and the member never lines up with its peer URL mapping, and the cluster will not form. Read the real value straight from the manifest, do not guess.</p>
<h2>Landmine 2: --initial-cluster-state=existing must actually be present</h2>
<p>When you add master2 and master3, each joiner's manifest needs <code>--initial-cluster-state=existing</code> plus an <code>--initial-cluster</code> listing every member up to and including itself. Miss the <code>state=existing</code> line and etcd defaults to <code>new</code>, so the joiner bootstraps its own cluster instead of joining, and you are back to the cluster ID mismatch from the trap above.</p>
<h2>Landmine 3: you cannot add a member to an unhealthy cluster</h2>
<p>etcd refuses <code>member add</code> if the current cluster is not healthy, with "etcdserver: unhealthy cluster". After you add master2, it has to replicate the full keyspace before it is a healthy voter, and that can take tens of seconds for a large DB. Add master3 during that window and the request is rejected, because going from 2 voters to 3 while only 1 is healthy would break quorum. So you gate each join on the current cluster being fully healthy first, and you add members strictly one at a time.</p>
<p>The same rule bites during any restart. Never bounce an etcd member unless the other two are healthy, or you drop below quorum. We learned this the hard way when an optional "tidy the manifests" step restarted a member while another was still down, and froze the cluster.</p>
<h2>Landmine 4: Do not boot stale data</h2>
<p>If master1's old data dir survives, because a wipe was skipped or incomplete, the move-into-place step can be silently skipped and etcd boots the previous cluster's multi-member data, then hangs probing peers that no longer exist. Guarantee the data dir is empty before you restore, do not rely on a "create only if missing" guard to protect you.</p>
<h2>The revision-bump problem</h2>
<p>There is a subtle failure that survives everything above, and only the official etcd docs really mention it. It is about <a href="https://github.com/etcd-io/etcd/issues/16028">revisions</a>.</p>
<p>Every write to etcd bumps a global revision counter, and k8s resourceVersion is derived off of it. Controllers and operators do not poll, they use informers, which are watch-based local caches that track "I have seen up to RV X" and get notified on changes.</p>
<p>Here is the problem - a restore rewinds etcd's revision to the snapshot's value, which is lower than the RVs clients were already holding before the disaster. New writes then count up from that lower number, so fresh changes get RVs below what informers believe they have already seen. The watch caches do not refresh correctly, and controllers quietly act on stale state or miss post-restore changes entirely.</p>
<p>etcd added two <code>etcdutl snapshot restore</code> flags specifically for this, and they must be used <a href="https://github.com/etcd-io/etcd/issues/16160">together</a>:</p>
<ul>
<li><p><code>--bump-revision &lt;n&gt;</code>, add n to the restored keyspace's revision so revisions never move backward.</p>
</li>
<li><p><code>--mark-compacted</code>, <strong>mark that bumped revision as the compaction point, which terminates existing watches and makes etcd refuse to serve revisions after the snapshot, forcing every informer to re-list cleanly.</strong> It is required when <code>--bump-revision &gt; 0</code>, and disallowed otherwise.</p>
</li>
</ul>
<p>A common sizing, straight from the etcd docs, is to bump by 1,000,000,000, which covers a week-old snapshot at under 1500 writes per second.</p>
<pre><code class="language-bash">etcdutl snapshot restore /root/etcd-snapshot.db \
  --name master1-&lt;cluster&gt; \
  --initial-cluster master1-&lt;cluster&gt;=https://10.0.0.1:2380 \
  --initial-advertise-peer-urls https://10.0.0.1:2380 \
  --data-dir /var/lib/etcd/.restore-tmp \
  --bump-revision 1000000000 --mark-compacted
</code></pre>
<p><strong>What it looks like when you skip it.</strong> This is the part the docs do not cover. The cluster comes up, <code>kubectl get</code> returns correct data, so it looks healthy. But watch-driven components misbehave:</p>
<ul>
<li><p>Controllers and operators do not react to changes made after the restore. You edit a Deployment or a custom resource (CR) and nothing reconciles, no new ReplicaSet, no operator action.</p>
</li>
<li><p>Component logs fill with "too old resource version", "the server has received a request that reused a resource version", or repeated re-list loops.</p>
</li>
<li><p>Leader election, which rides on RVs in Lease objects, can flap.</p>
</li>
</ul>
<p><strong>How to diagnose it.</strong> The tell is that reads work but watches do not. So:</p>
<ul>
<li><p>Check etcd's current revision, <code>etcdctl endpoint status -w json</code>, and notice it is far below where the cluster was before the restore.</p>
</li>
<li><p>Grep kube-controller-manager, kube-scheduler, and operator logs for "too old resource version" and re-list churn.</p>
</li>
<li><p>Drop a marker, create a ConfigMap or bump an annotation, and watch whether its consumer notices. If a known-good reconcile does not fire while <code>kubectl get</code> shows the object fine, your informers are stale.</p>
</li>
</ul>
<p><strong>If you already restored without the flags:</strong> the only practical fix is to force every watch to re-list, restart kube-controller-manager, kube-scheduler, and any operators, and restart kubelets for the data plane. Doing the restore with <code>--bump-revision --mark-compacted</code> in the first place is far cleaner, because it invalidates the caches for you.</p>
<p>Think of it as the CP counterpart to the kubelet restart below. bump-revision fixes informer staleness for controllers and operators, kubelet restart fixes it for the node agents. You want both.</p>
<h2>After the restore, reconcile the data plane</h2>
<p>A restore rewinds the API state, but not the workloads actually running on nodes, and every kubelet's cached watch is now stale because etcd's revision jumped backward. So kubelets log errors and Pods drift from what etcd believes. In my tests the fix is a rolling <code>systemctl restart kubelet</code> across every node, CP and workers, one at a time, each gated on the node returning Ready, and for masters gated on etcd staying healthy between restarts. A kubelet restart does not kill running containers, it just forces kubelet to re-list, re-watch, and reconcile.</p>
<h2>Confirm you actually recovered</h2>
<p>Do not declare victory on "the Pods look up". Check the things that prove the cluster is genuinely whole:</p>
<ul>
<li><p><code>etcdctl endpoint status -w table</code> and <code>endpoint health</code> across all three endpoints, expect three members, one leader, all healthy, <code>IS LEARNER=false</code>.</p>
</li>
<li><p><code>kubectl get nodes</code>, every node Ready.</p>
</li>
<li><p>The marker test from the revision section, a fresh object actually gets reconciled, which proves watches are live, not just reads.</p>
</li>
</ul>
<p>That last check is the one people skip, and it is the one that catches the revision-bump problem before your users do.</p>
<h2>TLDR: The Summary</h2>
<ul>
<li><p>Triage first. Only all-3-down is a restore.</p>
</li>
<li><p>Restore once on master1, then <code>member add</code> master2 and master3, one at a time, each gated on cluster health.</p>
</li>
<li><p>Respect the mount, match the member name, force <code>state=existing</code>, never bounce a member without quorum, and never boot stale data.</p>
</li>
<li><p>Restore with <code>--bump-revision --mark-compacted</code> so controllers' informers do not go stale on the rewound revision.</p>
</li>
<li><p>Restart kubelet fleet-wide afterward to resync the data plane.</p>
</li>
<li><p>Confirm with member health plus a live reconcile test, not just <code>kubectl get</code>.</p>
</li>
<li><p>Time it end to end, snapshot fetch included.</p>
</li>
</ul>
<p>None of the individual pieces are exotic. <strong>The value is in the order, the gates, and the specific ways it breaks,</strong> which is the stuff you do not want to be discovering live at 3am with an outage bridge listening.</p>
<h2>Sources</h2>
<ul>
<li><p><a href="https://kubernetes.io/docs/tasks/administer-cluster/configure-upgrade-etcd/">Operating etcd clusters for Kubernetes, kubernetes.io</a></p>
</li>
<li><p><a href="https://etcd.io/docs/v3.5/op-guide/recovery/">Disaster recovery, etcd.io</a></p>
</li>
<li><p><a href="https://docs.redhat.com/en/documentation/openshift_container_platform/3.11/html/cluster_administration/assembly_restore-etcd-quorum">Restoring etcd quorum, Red Hat OpenShift docs</a></p>
</li>
<li><p><a href="https://medium.com/@tradingcontentdrive/restoring-a-kubeadm-kubernetes-cluster-from-an-etcd-backup-aec18ece152d">Restoring a kubeadm Kubernetes Cluster from an etcd Backup, Medium</a></p>
</li>
<li><p><a href="https://github.com/acidonper/ocp42-etcd-backup-restore-ansible">ocp42-etcd-backup-restore-ansible, GitHub</a></p>
</li>
<li><p><a href="https://github.com/etcd-io/etcd/issues/16028">Restoring etcd without breaking API guarantees (aka etcd restore for Kubernetes), etcd issue #16028</a></p>
</li>
<li><p><a href="https://github.com/etcd-io/etcd/blob/main/etcdutl/README.md">etcdutl README (snapshot restore flags), etcd-io/etcd</a></p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Surviving A Complete ETCD Outage, Part 1: Snapshots, And Getting Them Back When the Cluster Is Gone]]></title><description><![CDATA[ETCD is the single source of truth for a Kubernetes cluster. Every Node, Pod, Secret, ConfigMap, CRD, and RBAC rule lives there. Lose etcd on all control plane nodes and you have lost the cluster - th]]></description><link>https://abhishekpareek.dev/surviving-a-complete-etcd-outage-part-1-snapshots-and-getting-them-back-when-the-cluster-is-gone</link><guid isPermaLink="true">https://abhishekpareek.dev/surviving-a-complete-etcd-outage-part-1-snapshots-and-getting-them-back-when-the-cluster-is-gone</guid><category><![CDATA[etcd]]></category><category><![CDATA[#etcd-backup]]></category><category><![CDATA[Kubernetes]]></category><category><![CDATA[kubeadm]]></category><category><![CDATA[Disaster recovery]]></category><category><![CDATA[ansible]]></category><category><![CDATA[SRE]]></category><dc:creator><![CDATA[abhishek pareek]]></dc:creator><pubDate>Fri, 03 Jul 2026 19:58:28 GMT</pubDate><content:encoded><![CDATA[<p><a href="https://etcd.io/">ETCD</a> is the single source of truth for a <a href="https://kubernetes.io/">Kubernetes</a> cluster. Every Node, Pod, Secret, ConfigMap, CRD, and RBAC rule lives there. Lose etcd on all control plane nodes and you have lost the cluster - the entire desired state is gone. So on a K8s platform two things have to be true -</p>
<ul>
<li><p>the backup has to be boring and reliable, and</p>
</li>
<li><p>the retrieval has to work even when nothing else does.</p>
</li>
</ul>
<p>That second half is where most write-ups I've come across quietly wave their hands. <em><strong>They show you</strong></em> <code>kubectl cp</code> <em><strong>from a backup Pod, which is lovely right up until the day you actually need it, when the API server (API) is down and</strong></em> <code>kubectl</code> <em><strong>returns nothing but connection errors.</strong></em></p>
<p>This post is Part 1, how we take snapshots and how we get one back with the cluster fully dead. Part 2 covers the restore itself, which is where the real landmines are.</p>
<h2>The backup</h2>
<p>A k8s CronJob in a dedicated namespace runs every 1h and calls <code>etcdctl snapshot save</code> against the live cluster. This gives us a recovery point objective (RPO) of roughly 1h. That is a deliberate tradeoff based on our internal calculations; a tighter RPO means more frequent snapshots and more storage churn, and you should size it to how much state your cluster can afford to lose.</p>
<p>The snapshot lands on a NetApp TRIDENT NFS-backed PVC with a sidecar pruning anything older than 30 days.</p>
<ul>
<li><p>The PVC itself is normally reached only through a small validation Deployment that we keep scaled to 0 from within the cluster</p>
</li>
<li><p>Or NFS mountable on one of the master nodes for a more direct and traditional access.</p>
</li>
</ul>
<h3>Two facts about that <code>.db</code> file drives everything downstream:</h3>
<ol>
<li><p>It is a <strong>full, point-in-time (PIT) copy</strong> of the entire keyspace. Restoring it rewinds the whole cluster to that instant.</p>
</li>
<li><p>It contains every Secret in plaintext. etcd at rest is usually unencrypted, so the snapshot is exactly as sensitive as the cluster it came from. Treat every copy as mode 0600 and delete it the moment you are done.</p>
</li>
</ol>
<h2>Retrieval: part that actually matters</h2>
<p>Getting the snapshot back has two very different modes, and the gap between them is the whole point of this post.</p>
<p><strong>Mode A, the cluster is still up.</strong> This is the HAPPY path.</p>
<ul>
<li><p>Scale the validation Pod to 1</p>
</li>
<li><p><code>kubectl exec</code> in, pick the newest <code>.db</code></p>
</li>
<li><p><code>kubectl cp</code> it out,</p>
</li>
<li><p>scale back to 0.</p>
</li>
</ul>
<p><strong>Mode B, the cluster is down.</strong> The SAD path, because Mode A depends on a working API and <code>kubectl</code>, which is precisely what a real DR event takes away. The good news though is that the backup lives on an NFS export, and <strong>we skip k8s entirely and mount the export directly on a surviving master, which still has L3 reach to the storage data LIF.</strong></p>
<p><strong>Mode C, offsite object storage.</strong> We also ship every snapshot to blob storage, which is the cleanest retrieval when it works, one authenticated pull with a CLI</p>
<pre><code class="language-bash">&lt;cloud&gt; storage blob download --container etcd-backups \
  --name etcd-snapshot-&lt;...&gt;.db --file /root/etcd-snapshot.db
</code></pre>
<p>The catch is credentials**. In our outage the blob access keys themselves were "lost" due to the extent of the outage, which is exactly why we fell back to the NFS mount in Mode B.**</p>
<p>The blob failure taught a real lesson - one retrieval path at times may not be enough. So we promoted the NFS mount from a manual break-glass step to a first-class, automated fetch mode that sits alongside the simple blob download - two independent paths.</p>
<h2>Mounting the backup export on a master</h2>
<p>You need two coordinates, the NFS server address and the export path. Record them now, while the cluster is healthy, because you cannot ask the PV for them once the API is gone:</p>
<pre><code class="language-bash">PV=$(kubectl -n ckp-cluster-jobs get pvc ckp-cluster-jobs-etcd-backup-data -o jsonpath='{.spec.volumeName}')
kubectl get pv "$PV" -o jsonpath='{.spec.nfs.server}{" "}{.spec.nfs.path}{"\n"}'
</code></pre>
<p>Stash those in the Ansible per-cluster inventory (group_vars) so they are already on disk before you need them. During DR, the manual form is just a <strong>read-only</strong> mount, copy the newest file off, unmount:</p>
<pre><code class="language-bash">mkdir -p /mnt/etcd-backup
mount -t nfs -o ro,nfsvers=4.1 &lt;server&gt;:&lt;export&gt; /mnt/etcd-backup
ls -ltr /mnt/etcd-backup
cp /mnt/etcd-backup/etcd-snapshot-&lt;...&gt;.db /root/etcd-snapshot.db
umount /mnt/etcd-backup
</code></pre>
<blockquote>
<p>Read-only is a safety guardrail - the mount physically cannot modify or delete your backups, which is exactly the guarantee you want when you are already having the worst day of your quarter.</p>
</blockquote>
<h2>Verify before you trust</h2>
<p>A snapshot you have not checked is a guess. Before you build a restore around it, confirm it is intact and confirm it is the point in time you think it is:</p>
<pre><code class="language-bash">etcdutl snapshot status /root/etcd-snapshot.db -w table
</code></pre>
<p>That prints the hash, total keys, and the revision. <strong>The revision matters, it tells you roughly how recent the snapshot is, and it is what a PIT restore lands on.</strong></p>
<blockquote>
<p>If this command errors, the file is truncated or corrupt, and you want to know that now, not halfway through a restore.</p>
</blockquote>
<h2>Automation: the etcd-snapshot-fetch role</h2>
<p>Doing the mount dance by hand at 3am invites mistakes, so we wrapped it in an Ansible role, <code>etcd-snapshot-fetch</code>, driven by a playbook that targets one master (master1 by default):</p>
<pre><code class="language-bash">ansible-playbook -i inventory/&lt;cluster&gt; etcd-snapshot-fetch.yaml \
  -u &lt;user&gt; -kK -b -e target_cluster=&lt;cluster&gt;
</code></pre>
<p>The flow is:</p>
<ol>
<li><p>Assert the target host actually belongs to the named cluster becasue the last thing we'd want in this scenario is to have 2 outages instead of one :-)</p>
</li>
<li><p>Mount the export read-only on the master.</p>
</li>
<li><p>Find the newest <code>etcd-snapshot-*.db</code>, or a specific one you name for a PIT restore.</p>
</li>
<li><p>Copy it off the read-only mount to local disk on the master, then <code>fetch</code> it to the Ansible control host.</p>
</li>
<li><p>Always unmount and clean up, in an <code>always:</code> block, so the export is never left mounted even if a step fails.</p>
</li>
</ol>
<p>The output is a verified snapshot on your control host at 0600, ready to hand to the restore. Notice the security posture end to end.</p>
<h2>What Part 1 buys you</h2>
<p>You now have a verified snapshot in hand, retrieved through a path that does not depend on the cluster being alive. That independence is the entire point. A backup you can only reach through the thing that just died is not a backup, it is a hope.</p>
<p>There is one more habit worth building - rehearse it. A backup nobody has ever restored is <a href="https://www.ministryoftesting.com/software-testing-glossary/schrodinger-s-back-up">Schroedinger's backup,</a> simultaneously fine and useless until you open the box, which is a good segue into Part 2 as that is where we open the box, and where restoring onto a 3-master cluster goes wrong in ways that surprised us.</p>
<h2>Sources</h2>
<ul>
<li><p><a href="https://kubernetes.io/docs/tasks/administer-cluster/configure-upgrade-etcd/">Operating etcd clusters for Kubernetes, kubernetes.io</a></p>
</li>
<li><p><a href="https://etcd.io/docs/v3.5/op-guide/recovery/">Disaster recovery, etcd.io</a></p>
</li>
<li><p><a href="https://docs.okd.io/latest/backup_and_restore/control_plane_backup_and_restore/backing-up-etcd.html">Backing up etcd data, OKD docs</a></p>
</li>
<li><p><a href="https://dev.to/shingaiz/from-disaster-to-recovery-a-practical-case-study-on-kubernetes-etcd-backups-52l6">From Disaster to Recovery, a Practical Case Study on Kubernetes etcd Backups, dev.to</a></p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Onboarding Claude Code Harness: The Hard Way]]></title><description><![CDATA[I've been running Claude Code daily for exploratory and experimental work driver for a little while now, and the single biggest realization I've had is this:

Claude model is not the product. The harn]]></description><link>https://abhishekpareek.dev/onboarding-claude-code-harness-the-hard-way</link><guid isPermaLink="true">https://abhishekpareek.dev/onboarding-claude-code-harness-the-hard-way</guid><category><![CDATA[claude-code]]></category><category><![CDATA[Harness]]></category><category><![CDATA[claude]]></category><category><![CDATA[ai agents]]></category><category><![CDATA[agentic-coding]]></category><category><![CDATA[claude-md]]></category><dc:creator><![CDATA[abhishek pareek]]></dc:creator><pubDate>Wed, 24 Jun 2026 22:30:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a25ff63980616b1c020fc7a/372d839b-8601-44ea-a050-3f60e1d73abe.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I've been running Claude Code daily for exploratory and experimental work driver for a little while now, and the single biggest realization I've had is this:</p>
<blockquote>
<p>Claude model is not the product. The harness is.</p>
</blockquote>
<p>The model you get is the same as everyone else. What <em>possibly</em> makes it feel like a _seasoned_ senior colleague is the machinery config. around the model: <strong>what</strong> loads into context - always or as-required, <strong>when</strong> it loads, and <strong>what gets enforced rather than merely suggested.</strong></p>
<p>The best mental model I've found is <strong>onboarding</strong>. You can't drop a new hire into a codebase with zero docs. and expect them to be productive from day one.</p>
<blockquote>
<p>This post is my WIP notes on how to onboard it properly, written partly so I could come back and refresh my own memory, and <em>possibly</em> for other enggineers who are new and are looking for technical hygiene before they start.</p>
</blockquote>
<img src="https://cdn.hashnode.com/uploads/covers/6a25ff63980616b1c020fc7a/a86115b1-8cc8-48ed-9ae2-6bb413b72810.png" alt="" style="display:block;margin:0 auto" />

<h3>CLAUDE.md is NOT the system prompt</h3>
<p>A lot of content out there incorrectly (personally) describes memory files as "the system prompt" for your agent. According to the <a href="https://code.claude.com/docs/en/glossary#claude-md">docs</a>, CLAUDE.md content is <strong>delivered as a user message <em>after</em> the system prompt, not as part of the system prompt itself</strong>. This matters -</p>
<ul>
<li><p>it explains why compliance is probabilistic. Claude reads your CLAUDE.md and tries to follow it, but there is <strong>no guarantee of strict adherence</strong>, especially for vague or contradictory instructions. Claude's own docs are blunt about this. For example -</p>
</li>
<li><p>Second, it tells you where the real system prompt hook is. If you genuinely need instructions at the system prompt level, pass them at startup.</p>
</li>
</ul>
<img src="https://cdn.hashnode.com/uploads/covers/6a25ff63980616b1c020fc7a/a791a210-7f61-40b4-aaa8-6b4bee807a41.png" alt="" style="display:block;margin:0 auto" />

<p>So CLAUDE.md is essentially context that shapes behavior.</p>
<h2>Important Concept: memory hierarchy</h2>
<blockquote>
<p>I've done my best - as of June 2026 - to arrange up-to-date info., but it may get out-dated by the time you're reading this blog.</p>
</blockquote>
<p>Claude Code <a href="https://code.claude.com/docs/en/memory#choose-where-to-put-claude-md-files">reads</a> memory files from several locations, each with a different scope:</p>
<table>
<thead>
<tr>
<th>Scope</th>
<th>Location</th>
<th>Purpose</th>
<th>Use case examples</th>
<th>Shared with</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Managed policy</strong></td>
<td>• macOS: <code>/Library/Application Support/ClaudeCode/</code><a href="http://CLAUDE.md"><code>CLAUDE.md</code></a>,• Linux and WSL: <code>/etc/claude-code/</code><a href="http://CLAUDE.md"><code>CLAUDE.md</code></a></td>
<td></td>
<td></td>
<td></td>
</tr>
<tr>
<td>• Windows: <code>C:\Program Files\ClaudeCode\</code><a href="http://CLAUDE.md"><code>CLAUDE.md</code></a></td>
<td>Organization-wide instructions managed by IT/DevOps</td>
<td>Company coding standards, security policies, compliance requirements</td>
<td>All users in organization</td>
<td></td>
</tr>
<tr>
<td><strong>User instructions</strong></td>
<td><code>~/.claude/</code><a href="http://CLAUDE.md"><code>CLAUDE.md</code></a></td>
<td>Personal preferences for all projects</td>
<td>Code styling preferences, personal tooling shortcuts</td>
<td>Just you (all projects)</td>
</tr>
<tr>
<td><strong>Project instructions</strong></td>
<td><code>./</code><a href="http://CLAUDE.md"><code>CLAUDE.md</code></a> or <code>./.claude/</code><a href="http://CLAUDE.md"><code>CLAUDE.md</code></a></td>
<td>Team-shared instructions for the project</td>
<td>Project architecture, coding standards, common workflows</td>
<td>Team members via source control</td>
</tr>
<tr>
<td><strong>Local instructions</strong></td>
<td><code>./</code><a href="http://CLAUDE.local.md"><code>CLAUDE.local.md</code></a></td>
<td>Personal project-specific preferences; add to <code>.gitignore</code></td>
<td>Your sandbox URLs, preferred test data</td>
<td>Just you (current project)</td>
</tr>
</tbody></table>
<p>The two-tier "global plus project" advice I've seen in YT videos is a tad-bit over-simplification.</p>
<ul>
<li><p>Your user-level file at <code>~/.claude/CLAUDE.md</code> is where personal preferences live:</p>
<ul>
<li><p>formatting quirks you hate,</p>
</li>
<li><p>tooling shortcuts,</p>
</li>
<li><p>style opinions that follow you across every repo.</p>
</li>
</ul>
</li>
<li><p>The project file is where the team's collective knowledge lives: build commands, architecture, terminology, testing conventions.</p>
</li>
</ul>
<p>The official <a href="https://code.claude.com/docs/en/memory#claude-md-vs-auto-memory">guidance</a> gives you the actual target size for <a href="http://CLAUDE.md">CLAUDE.md</a>: keep under 200 lines. Longer files consume more context on every single request and, more importantly, potentially reduce adherence.</p>
<blockquote>
<p>A bloated memory file doesn't just cost tokens; it makes the model worse at following rules - instructions may get lost in the noise.</p>
</blockquote>
<h3>How Context loading works?</h3>
<p>Claude Code discovers memory files by walking <em>up</em> the directory tree from your working directory to the fs root, collecting every <code>CLAUDE.md</code> and <code>CLAUDE.local.md</code> along the way, and that gets loaded in full at launch.</p>
<p>What I really found interesting was to learn that the files don't override each other, instead <strong>they are concatenated into context, ordered from the root down to your working directory</strong>.</p>
<blockquote>
<p>so the instructions "closest" to you are the last thing the model reads.</p>
</blockquote>
<p>Within your project dir., CLAUDE.local.md is appended after CLAUDE.md. This also suggest <em>lower-level</em> instructions like <a href="http://CLAUDE.local.md"><code>CLAUDE.local.md</code></a> might get dropped first it the context fills up.</p>
<p>Files in subdirs. <em>below</em> the working directory behave differently: <strong>they load lazily</strong>, only when Claude actually reads a file in that subdirectory consuming 0 tokens before the agent walks into it.</p>
<h3>Path-scoped rules: the middle tier</h3>
<p>Between "always loaded" CLAUDE.md and "loaded on demand" skills sits <code>.claude/rules/</code>, and I personally didn't find a lot of content on them as of June 2026. For example -</p>
<pre><code class="language-markdown">---
paths:
  - "src/api/**/*.{ts,tsx}"
---

# API rules
- All endpoints must include input validation
- Use the standard error response format
</code></pre>
<p>A rule with <code>paths</code> loads only when Claude reads files matching the glob.</p>
<p>So the way I've understood is that a fact that is needed every session goes in CLAUDE.md. Where as a convention tied to a file type or directory goes in a path-scoped rule. A multi-step procedure goes in a skill.</p>
<h3>The collective intelligence loop</h3>
<p>Treat the project CLAUDE.md as the teams' accumulated corrections.</p>
<blockquote>
<p>Add an entry when Claude makes the same mistake a second time</p>
</blockquote>
<p>For example, when code review catches something it should have known about this codebase, or when you notice yourself typing the same clarification you typed last session.</p>
<blockquote>
<p>Every correction you document is a mistake the whole team stops paying for.</p>
</blockquote>
<p>Newer Claude Code versions also close this loop automatically. <a href="https://code.claude.com/docs/en/memory#auto-memory">Auto memory</a> lets Claude write notes to itself in <code>~/.claude/projects/&lt;project&gt;/memory/</code>, keyed to the git repo so all worktrees share it.</p>
<h3>What survives compaction</h3>
<p>Long sessions eventually hit auto-compaction so knowing what survives is worth a lot of debugging time: your project-root CLAUDE.md is re-read from disk and re-injected after <code>/compact</code>. Nested CLAUDE.md files are not; they reload only the next time Claude touches a file in their subdir.</p>
<blockquote>
<p>Instructions given purely in conversation are the most fragile thing you have. If something mattered enough to say twice, it belongs in a file.</p>
</blockquote>
<h2>Skills and progressive disclosure</h2>
<p>Everything in CLAUDE.md is a tax on every request, whether or not it's relevant to the task. For example, an e2e testing protocol is 100 lines that matter in maybe 1 session out 20. Skills solve this with progressive disclosure.</p>
<p>A skill is a directory containing a <code>SKILL.md</code> with YAML frontmatter and markdown instructions:</p>
<pre><code class="language-yaml">---
description: Run the full e2e suite against a staging deploy. Use when the
  user asks to verify a release candidate or run end-to-end tests.
---

1. Build with `pnpm build:staging`
2. Deploy the preview with `./scripts/preview.sh`
3. Run `pnpm e2e --env staging`, retry flaky tags once
4. Post the summary; never mark green with skipped suites
</code></pre>
<p><strong>At session start, only the</strong> <code>description</code> <strong>of each skill enters context so Claude knows what's available</strong>.. The full body loads only if/when required - Claude matched your request to the description or you invoked it directly as <code>/skill-name</code>.</p>
<p>Anthropic's guideline is to keep SKILL.md under 500 lines and push heavy reference material into supporting files in the same directory, which Claude reads on demand with its normal file tools.</p>
<p>Skills live in <code>~/.claude/skills/&lt;name&gt;/SKILL.md</code> for personal ones and <code>.claude/skills/&lt;name&gt;/SKILL.md</code> for project ones, with plugin and enterprise tiers above that.</p>
<h3>The frontmatter is where the power is</h3>
<p>A handful of fields turn skills from "saved prompts" into real workflow machinery.</p>
<ul>
<li><p><code>disable-model-invocation: true</code> makes a skill user-only. Claude can't trigger it, and its description doesn't even load into context, so it costs zero tokens until you type the slash command.</p>
</li>
<li><p><code>allowed-tools</code> pre-approves specific tools while the skill is active which kills the permission-prompt friction for well-scoped workflows. <code>context: fork</code> runs the skill in a forked subagent context.</p>
</li>
</ul>
<h3>Skill lifecycle under compaction</h3>
<p>Once invoked, a skill's content enters the conversation as a message and stays there, so mid-session edits to an already-loaded skill don't take effect until you re-invoke it. Under auto-compaction, Claude Code re-attaches the most recent invocation of each skill after the summary.</p>
<h2>Context economics, briefly</h2>
<p>It helps to see the whole extension surface priced in context terms, because the features form a spectrum from always-on to zero-cost:</p>
<table>
<thead>
<tr>
<th>Feature</th>
<th>Loads</th>
<th>Steady-state cost</th>
</tr>
</thead>
<tbody><tr>
<td>CLAUDE.md</td>
<td>Session start, full content</td>
<td>Every request</td>
</tr>
<tr>
<td>Rules (path-scoped)</td>
<td>When matching files are read</td>
<td>Only in relevant sessions</td>
</tr>
<tr>
<td>Skills</td>
<td>Descriptions at start, body on use</td>
<td>Low until invoked</td>
</tr>
<tr>
<td>MCP servers</td>
<td>Tool names at start, schemas deferred</td>
<td>Low until a tool is used</td>
</tr>
<tr>
<td>Subagents</td>
<td>Spawned with fresh context</td>
<td>Isolated from your window</td>
</tr>
<tr>
<td>Hooks</td>
<td>Run externally on events</td>
<td>Zero unless they return output</td>
</tr>
</tbody></table>
<p>My key takeaway - when a side task will read bunch of files and you only care about the conclusion, route it through a subagent so the research happens in a disposable context window and only the summary lands in yours.</p>
<h2>Vetting third-party skills: the threat model</h2>
<p>Skill registries have exploded. The <code>npx skills</code> CLI is the one referenced in most talks, but to some this may set off some alarms due to the spectre of supply chain attacks.</p>
<p>The dynamic injection syntax means shell commands in a SKILL.md execute on your machine the moment the skill loads, before the model sees anything potentially leading to bad things.</p>
<p>So a SKILL.md is short. Read it, and read every script it ships, before it ever loads in a session.</p>
<p>There's a second, quieter failure mode: <strong>performance</strong>. More starts signals nothing about quality, and some widely-shared skills might make your agent worse, padding context with verbose instructions that dilute your own conventions while producing no better output - overlapping descriptions make claude load the wrong skill or miss the right one.</p>
<p>So my standing rule, which I happily borrowed from some of the YT <a href="https://www.youtube.com/watch?v=iQyg-KypKAA&amp;t=1446s">talks</a>: never install a skill that hasn't published evidence it works, and when in doubt, write your own after taking inspiration from a 3rd paty one, if required.</p>
<h2>Enforcement</h2>
<p>If you're worried about what any skill, or the model itself, might do, don't encode the boundary in prose. A "never do X" line in CLAUDE.md is a request. A <code>PreToolUse</code> hook that blocks the tool call, or a <code>permissions.deny</code> rule in settings, is a guarantee, enforced by the harness.</p>
<blockquote>
<p>Prose for guidance, hooks and permissions for guardrails.</p>
</blockquote>
<h2>The onboarding loop, condensed</h2>
<p>If I compress everything above into a ramp-up procedure for a new repo, it looks like this.</p>
<ul>
<li><p>Run <code>/init</code> and let it draft a project CLAUDE.md, then prune it toward the 200-line target, keeping only facts every session needs.</p>
</li>
<li><p>Keep your <code>~/.claude/CLAUDE.md</code> short and purely personal.</p>
</li>
<li><p>When corrections recur, write them down: repeated mistakes go into CLAUDE.md, file-type conventions into path-scoped rules, and any section that has grown into a procedure gets extracted into a skill.</p>
</li>
<li><p>Mark side-effecting skills <code>disable-model-invocation: true</code>.</p>
</li>
<li><p>Move hard boundaries out of prose and into hooks and permission rules.</p>
</li>
<li><p>Audit context occasionally with <code>/memory</code> and <code>/context</code>, and</p>
</li>
<li><p>treat every third-party skill as unreviewed code running with your creds.</p>
</li>
</ul>
<p>None of this is glamorous. Documentation hygiene done right to a colleague who forgets everything overnight but reads at superhuman speed every morning!</p>
<p><em>References:</em></p>
<ul>
<li><p><a href="https://code.claude.com/docs/en/memory"><em>Manage Claude's memory</em></a></p>
</li>
<li><p><a href="https://code.claude.com/docs/en/skills"><em>Extend Claude with skills</em></a></p>
</li>
<li><p><a href="https://code.claude.com/docs/en/features-overview"><em>Extend Claude Code</em></a></p>
</li>
<li><p><a href="https://code.claude.com/docs/en/best-practices"><em>Best practices</em></a><em>, and</em></p>
</li>
<li><p><em>the</em> <a href="https://github.com/vercel-labs/skills"><em>vercel-labs/skills</em></a> <em>CLI.</em></p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Underneath (Micro)VMs: A Primer in H/W Virtualisation]]></title><description><![CDATA[I tend to do deep dives into technologies that I don't fully understand yet to clear up the concepts and build a better mental model of them. That's exactly what this blog is all about.
Surprisingly, ]]></description><link>https://abhishekpareek.dev/underneath-micro-vms-a-primer-in-h-w-virtualisation</link><guid isPermaLink="true">https://abhishekpareek.dev/underneath-micro-vms-a-primer-in-h-w-virtualisation</guid><category><![CDATA[microvms]]></category><category><![CDATA[hardware virtualization]]></category><category><![CDATA[KVM]]></category><category><![CDATA[firecracker]]></category><dc:creator><![CDATA[abhishek pareek]]></dc:creator><pubDate>Wed, 18 Mar 2026 20:45:00 GMT</pubDate><content:encoded><![CDATA[<p>I tend to do deep dives into technologies that I don't fully understand yet to clear up the concepts and build a better mental model of them. That's exactly what this blog is all about.</p>
<p>Surprisingly, I realized recently that I have a much better grasp on containers than vms. If you ask most platform engineers or devops folks, they can talk confidently about namespaces, cgroups, and container runtimes, but when it comes to what a vm or a microvm is actually doing down at the silicon level, things get a bit fuzzy.</p>
<p>Here is my breakdown of how vms actually work underneath, why they can feel slow, and how modern microvms fix those bottlenecks.</p>
<hr />
<h3>The Privilege Problem: Rings of Trust</h3>
<img src="https://cdn.hashnode.com/uploads/covers/6a25ff63980616b1c020fc7a/07318bd9-fdd0-4a6d-b6ff-f41d0158ae29.png" alt="" style="display:block;margin-left:auto" />

<p>To understand vms, we have to look at how cpus handle security. Processors use privilege levels called "rings" to protect the system.</p>
<ul>
<li><p>Ring 0 is the most privileged mode—reserved for the os kernel so it can interact directly with physical h/w, memory, and devices.</p>
</li>
<li><p>Ring 3 is restricted, meant for user space apps.</p>
</li>
</ul>
<p>In a normal bare-metal setup, your host os owns Ring 0. But when you introduce a traditional vm, the guest os inside that vm <em>also naturally</em> expects to run in Ring 0.</p>
<p>A bit of throwback first - before h/w virtualization existed, this caused a major conflict. The hypervisor had to force the guest os down into Ring 1 or Ring 3. Every time the guest os tried to execute a privileged kernel command, the cpu would throw an exception. The hypervisor had to trap that error, translate the instruction on the fly, and emulate the response. This "trap-and-emulate" cycle is exactly why old-school vms felt brutally slow.</p>
<hr />
<h3>Enter Hardware Virtualization (VT-x and AMD-V)</h3>
<p>To fix this performance bottleneck, cpu manufacturers added specialized hardware extensions (Intel VT-x and AMD-V). They introduced a completely new execution dimension: <strong>Root Mode</strong> and <strong>Non-Root Mode</strong>.</p>
<ul>
<li><p><strong>Root Mode:</strong> Where your host os and hypervisor (like KVM) live.</p>
</li>
<li><p><strong>Non-Root Mode:</strong> A separate sandbox containerized by the cpu hardware for the guest vm.</p>
</li>
</ul>
<p>Inside Non-Root Mode, the guest os gets its own complete set of rings, including its own Ring 0. It thinks it has absolute control over the machine.</p>
<p>This splits the lifecycle of an instruction inside a vm into two distinct paths:</p>
<h4>1. The Fast Path (99% of work)</h4>
<img src="https://cdn.hashnode.com/uploads/covers/6a25ff63980616b1c020fc7a/b5a78487-2039-4a27-8cc8-ae165295f429.png" alt="" style="display:block;margin:0 auto" />

<p>When an app inside the vm does basic math, runs loops, or processes data, it fires standard instructions. The physical cpu checks the state, sees it's safe, and executes it <strong>directly on the physical silicon</strong> at bare-metal speed. The hypervisor isn't involved at all.</p>
<h4>2. The Hypervisor Path (The 1%)</h4>
<img src="https://cdn.hashnode.com/uploads/covers/6a25ff63980616b1c020fc7a/43a61449-ac95-40c8-94a9-50aa38451da0.png" alt="" style="display:block;margin:0 auto" />

<p>When the app needs to write a file to disk or send a packet over the network, the guest os fires a privileged hardware instruction. The physical cpu flags this, pauses the vm, and executes a hardware-level context switch called a <strong>vm-exit</strong>.</p>
<p>Control is handed back to KVM in Root Mode to safely perform the real physical operation. Once done, it triggers a <strong>vm-entry</strong> to unpause the vm and hand control back to the guest. Because this context switching is baked directly into the cpu silicon, it happens in nanoseconds, keeping the overhead minimal.</p>
<hr />
<h3>The Catch: Why Traditional VMs Still Feel Heavy</h3>
<p>Even with h/w virtualization, standard vms take time to boot and consume significant resources. This isn't because of instruction execution; it's because of <strong>hardware emulation bloat</strong>.</p>
<p>Traditional hypervisors (like QEMU) emulate decades of legacy hardware to support any random os you might install. They emulate virtual IDE controllers, floppy drives, PCI buses, and ancient graphics cards. Booting a traditional vm means waiting for a full, bloated guest kernel to initialize all these virtual devices.</p>
<hr />
<h3>Shifting to MicroVMs</h3>
<p>If you are running modern workloads—like serverless functions or untrusted AI agent code—you need the absolute isolation of a vm kernel boundary, but you can't afford a 10-second boot time or hundreds of megabytes of baseline memory overhead.</p>
<p>This is where <strong>microvms</strong> (like Firecracker) come into play. A microvm strips away almost all legacy emulation. It throws out the floppy drives and video cards, providing a hyper-minimal device model (usually just <code>virtio</code> for basic block storage and network interfaces).</p>
<p>Because the guest kernel is stripped down to the bare essentials, a microvm can boot in less than 200 milliseconds and uses a fraction of the ram of a standard vm.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a25ff63980616b1c020fc7a/21161855-0b8e-40c5-bd23-a9ee3cfcc86b.png" alt="" style="display:block;margin:0 auto" />

<h3>The Under-the-Hood Stack</h3>
<p>What makes a microvm fascinating is that it combines both hardware virtualization and traditional container primitives:</p>
<ul>
<li><p><strong>Hardware Boundary:</strong> The code inside the microvm is secured via KVM using the hardware's non-root mode, ensuring a compromised app or agent cannot escape to the host kernel.</p>
</li>
<li><p><strong>Host Boundary:</strong> On the host side, the Virtual Machine Manager (VMM) process running the microvm is wrapped in a "jailer" that uses standard Linux <strong>namespaces (ns)</strong>, <strong>control groups (cgroups)</strong>, and <strong>seccomp filters</strong>.</p>
</li>
</ul>
<p>This gives you a defense-in-depth architecture. Even if an untrusted workload manages to break the guest kernel, it's still trapped by the hypervisor. And if it somehow breaks the hypervisor process, it hits the cgroups and namespaces boundary on the host node.</p>
<p>Next up would be a detailed breakdown of 2 of the sandboxes that I intend to trial for securing ai agent related workloads in ther cloud - <a href="https://justbash.dev/">just-bash</a> vs <a href="https://github.com/superradcompany/microsandbox">microsandbox</a>!</p>
<p>Stay tuned!</p>
]]></content:encoded></item><item><title><![CDATA[
Why etcd Disk I/O Stalls Crash Your K8s Pods]]></title><description><![CDATA[One morning our production k8s cluster started acting strange. pods were dying at random. Not in one app, not in one ns - different pods, in different places, going down and coming back, again and aga]]></description><link>https://abhishekpareek.dev/why-etcd-disk-i-o-stalls-crash-your-k8s-pods</link><guid isPermaLink="true">https://abhishekpareek.dev/why-etcd-disk-i-o-stalls-crash-your-k8s-pods</guid><category><![CDATA[Kubernetes]]></category><category><![CDATA[etcd]]></category><category><![CDATA[k8s]]></category><category><![CDATA[etcd performance]]></category><category><![CDATA[diskio]]></category><category><![CDATA[fsync latency]]></category><dc:creator><![CDATA[abhishek pareek]]></dc:creator><pubDate>Wed, 04 Feb 2026 18:58:00 GMT</pubDate><content:encoded><![CDATA[<p>One morning our production k8s cluster started acting strange. pods were dying at random. Not in one app, not in one ns - different pods, in different places, going down and coming back, again and again and again. No new deploy had gone out. Nothing in any of the app logs explained it. For a while it just looked like pure, unexplainable chaos.</p>
<h2>The strange part: running pods do not need etcd</h2>
<p>Before the cause, one fact that our team (half, atleast) was well aware is worth saying first - a pod that is already running does <strong>not</strong> talk to etcd. It does not even talk to the API server to keep running. Once the kubelet has started a container, the app just runs and serves traffic. You can take the whole control plane offline and <strong>your running pods will keep working.</strong></p>
<p>This knowledge precisely made things a lot harder to debug.</p>
<h2>The cause: a slow disk breaks the heartbeat of the cluster</h2>
<p>etcd is the database that holds all k8s state. It is built for strong consistency, and it pays for that with one strict rule: every write must be saved to disk before it counts. Technically, etcd writes to a write-ahead log (WAL) and calls <code>fsync</code> to force the bytes onto the disk. Only after that <code>fsync</code> returns does the write commit.</p>
<p>etcd also runs as a small cluster of members that agree using the Raft algorithm. Every write goes through the leader and must be confirmed by a quorum, and each member must <code>fsync</code> it. This means one thing that becomes very important later: <strong>if the leader's disk is slow, every write in the whole cluster slows down at once.</strong></p>
<p>Now connect this to pods. Many parts of k8s stay alive by renewing a "lease" before a deadline. A lease is just a small record that says "I am still here," and renewing it is a <strong>write</strong> - which means a write to etcd, which means an <code>fsync</code>. There are two leases that matter here:</p>
<ul>
<li><p><strong>Leader election leases.</strong> Control-plane components like the scheduler, the controller-manager, and many operators run more than one copy, but only one is active at a time. The active one must renew its leader lease every few seconds. If it cannot renew in time, it can no longer prove it is the leader. These programs are written to do the safe thing in that case: they <strong>exit on purpose</strong>, so that two leaders never act at once.</p>
</li>
<li><p><strong>Node leases.</strong> Each node's kubelet renews a node lease about every 10 seconds to say "this node is alive." If those renewals stop arriving, the control plane marks the node <code>NotReady</code>, and after a grace period it starts evicting the pods on it.</p>
</li>
</ul>
<p>Here is the full chain in one line:</p>
<blockquote>
<p>disk I/O stall → <code>fsync</code> is slow → etcd WAL commit is slow → Raft writes back up → leases cannot renew in time → leader components exit and nodes go <code>NotReady</code> → pods restart or get evicted.</p>
</blockquote>
<p>That is why our pods "died." A leader-elected pod killed itself because it could not renew its lease in time. An ordinary pod was evicted because its node missed too many heartbeats. In both cases, the thing that failed was a small write that could not <code>fsync</code> through a disk-stalled etcd. Our disk had short latency spikes every few minutes, and those spikes were printing themselves onto the pod crash schedule.</p>
<p>One more detail made it worse. The API server's own health check includes an etcd check. When etcd was slow, the API server itself started returning errors. So a single slow disk did not fail gently - it knocked over the one dependency that every lease and heartbeat runs through, all at the same time.</p>
<h2>The debugging: looking in the wrong place first</h2>
<p>The hard part of this kind of incident is that the symptom and the cause are far apart. We lost a few hours looking at the wrong layers. Here is the path we took, including the dead ends, because the dead ends are useful.</p>
<p><strong>First we blamed the apps.</strong> The pods that died had no pattern at the app level. Different images, different teams, different ns. That told us it was not an app bug. If it were, it would hit one app, not all of them.</p>
<p><strong>Then we blamed networking and resources.</strong> We checked CPU and memory limits, OOM kills, and network policies. Nothing lined up. The crashes did not match any resource graph.</p>
<p><strong>Then we saw etcd's metrics rising - and talked ourselves out of it.</strong> Somewhere in here we did glance at etcd. The <code>fsync</code> and WAL commit times were climbing, But we waved it off. etcd sits far below the apps in the stack, and the pods that were dying were many links away from it. so we filed the etcd numbers under "probably related but unrelated" and kept looking elsewhere.</p>
<p><strong>Then we noticed the timing.</strong> The crashes came in waves, every five to ten minutes. Random pods, but a regular beat. Random-but-periodic is a strong clue. It means something shared and low-level is failing on a cycle, not one app misbehaving.</p>
<p><strong>Then we read the control-plane logs</strong>. This is where it turned. The API server logs showed timeouts talking to etcd. The etcd logs showed leader elections happening again and again. A healthy etcd almost never changes leader. Frequent leader elections almost always mean the disk or the network is too slow.</p>
<p><strong>Then we went back to the etcd metrics we had dismissed</strong>. Once the logs pointed at etcd, we stopped waving off those numbers and actually read them. Two metrics tell the story directly:</p>
<p><code>etcd_disk_wal_fsync_duration_seconds etcd_disk_backend_commit_duration_seconds</code></p>
<p>On a healthy cluster the p99 of these should be a few milliseconds. Ours were spiking past 100ms, again on that same five-to-ten-minute beat. A good rule of thumb: if p99 of the WAL fsync is consistently above 100ms, you have a storage problem, not a k8s problem. These were the same numbers we had seen an hour earlier and ignored — only this time we let ourselves believe them.</p>
<p><strong>Then we confirmed it at the disk</strong> as the metrics showed high latency and a disk pinned near 100% busy, in the same five-to-ten-minute waves. We could also see other workloads driving I/O on that same device at those exact moments. That was the final answer: etcd was sharing a disk with noisy I/O, and that disk could not keep <code>fsync</code> fast and steady.</p>
<h2>The resolution: give etcd a disk it does not have to share</h2>
<p>The fix was not in k8s settings. It was in storage.</p>
<p>The root problem was that etcd's disk was shared with other heavy I/O. So our first and most important change was simple: <strong>give etcd its own dedicated, fast, low-latency disk</strong> - a local NVMe SSD - and keep all other heavy I/O off it. No container storage, no logs, no batch jobs on the same device.</p>
<p>There are storage tricks that make <code>fsync</code> return instantly by not really waiting for the write to land (for example, disabling sync writes at the filesystem). These make the symptom disappear, but they break etcd's one job: durability.</p>
<p>With a rolling upgrade we moved the entire etcd cluster to dedicated NVMe, and immeidately witnessed the <code>fsync</code> p99 dropped back to a few milliseconds, the leader elections stopped, and the random pod crashes stopped with them.</p>
<h2>The action: so it never surprises us again</h2>
<p>Fixing the disk ended the fire. These changes make sure it does not start again, and that if it does, we see it early.</p>
<ul>
<li><p><strong>Alert on the real metric.</strong> We now alert when p99 of <code>etcd_disk_wal_fsync_duration_seconds</code> goes above a safe threshold. This catches the problem while etcd is still slow, before it becomes pod chaos.</p>
</li>
<li><p><strong>Watch leader elections.</strong> A rising etcd leader-change rate is an early warning of disk or network trouble. We graph it and alert on it.</p>
</li>
<li><p><strong>Benchmark storage first.</strong> Before etcd goes on any machine, we run <code>fio</code> and check the latency profile. We test the disk before we trust it.</p>
</li>
<li><p><strong>Keep etcd's data small.</strong> A smaller, healthier database means faster maintenance. We make sure history compaction runs, we defrag on a schedule, and we keep an eye on object count and churn. High-churn objects like events can be moved to a separate etcd to protect the main one.</p>
</li>
<li><p><strong>Write a runbook.</strong> We wrote down the exact chain and the exact commands to check <code>fsync</code> latency, leader elections, and db size. Next time, the on-call engineer starts at the disk, not at the apps.</p>
</li>
</ul>
<h2>The lesson</h2>
<p>The real lesson is about where to look. The symptom was at the top of the stack — random pods dying. The cause was at the very bottom - a disk that could not <code>fsync</code> fast enough. Everything in between, etcd, Raft, leases, the API server, was just faithfully passing the slowness up the chain.</p>
<p>In a distributed system, "is this thing alive?" almost always means "can it renew a small claim before a deadline?" And every one of those claims, in k8s, is a write that must reach etcd's disk. So when pods start dying for no reason and the timing looks oddly regular, do not stare at the data plane, and start at the disk under etcd first.</p>
]]></content:encoded></item><item><title><![CDATA[Opinion: K8s IS Data-Oriented Programming. We Just Call It YAML.]]></title><description><![CDATA[Opinion: K8s IS Data-Oriented Programming. We Just Call It YAML.
Few days back I came across this excellent book on Data-Oriented Programming which I've since read to a reasonable degree, and as it ju]]></description><link>https://abhishekpareek.dev/opinion-k8s-is-data-oriented-programming-we-just-call-it-yaml</link><guid isPermaLink="true">https://abhishekpareek.dev/opinion-k8s-is-data-oriented-programming-we-just-call-it-yaml</guid><category><![CDATA[Kubernetes]]></category><category><![CDATA[Functional Programming]]></category><category><![CDATA[software architecture]]></category><category><![CDATA[Devops]]></category><category><![CDATA[Platform Engineering ]]></category><category><![CDATA[Programming paradigms]]></category><category><![CDATA[data-oriented-programming]]></category><dc:creator><![CDATA[abhishek pareek]]></dc:creator><pubDate>Fri, 26 Dec 2025 08:04:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a25ff63980616b1c020fc7a/eaa9522c-4d6c-4b08-9bab-03491e678bb0.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1>Opinion: K8s IS Data-Oriented Programming. We Just Call It YAML.</h1>
<p>Few days back I came across <a href="https://www.manning.com/books/data-oriented-programming">this</a> excellent book on <a href="https://www.reddit.com/r/C_Programming/comments/j90okg/what_is_data_oriented_programming/">Data-Oriented Programming</a> which I've since read to a reasonable degree, and as it just so happens from time to time reading about something helps you tie it in with your past experiences where you might've come across the same technology in some shape or form but couldn't recognise it at that point in time...</p>
<p>You can write K8s <a href="https://k8s.io/docs/concepts/architecture/controller/">controllers</a> for long before you notice that you've been practicing a fairly known, realtviely old programming paradigm. We call our half of it "the K8s way" and leave it there. But the thing you're doing when you author a CRD, keep your controller “thin”, and let etcd hold the truth has a name outside our world: it's <em>data-oriented programming</em>, and seeing K8s through that lens explains a surprising amount of why it's shaped the way it is.</p>
<h2>A two-minute detour into what data-oriented actually means</h2>
<p>First, a quick disambiguation: this isn't <a href="https://neil3d.github.io/assets/img/ecs/DOD-Cpp.pdf">Mike Acton’s <em>Data-Oriented Design</em></a>, famous in C++ game engines for squezing out hardware performance via cache locality. This is Data-Oriented <em>Programming</em> (DOP).</p>
<p>DOP is, at heart, a reaction against one specific habit of classical object-oriented design: welding behavior to data. In classical OOP, an object owns its fields and exposes methods that act on them - the data and the code that touches it travel together, and mutable state hides behind an interface. That bundling is the thing DOP refuses.</p>
<p>It pulls the two apart and rests on a few principles. Separate code from data: logic lives in stateless functions that take data as an argument, not in objects that own it. Represent data with generic, immutable structures - maps, records, plain values - rather than a bespoke class per concept. And keep the data at the center: it's inert, transparent, and the same shape everywhere, so anything can read it without going through a custom API.</p>
<p>The paradigm grew up in the functional, and its whole pitch is reducing complexity. Inert data is easy to reason about. Functions over data are easy to test. Nothing hides.</p>
<p>Now hold that next to K8s.</p>
<h2>The core mapping</h2>
<p><strong>Your spec is data. Full stop.</strong> We call it <a href="https://en.wikipedia.org/wiki/YAML">YAML</a>, but YAML is just the messy, untyped human facade. Once it hits the API server, that YAML is strictly validated against OpenAPI schemas into rigid, structured data. A manifest is inert. It has no methods, no behavior, no opinion about how to make itself real. It's a record - <code>apiVersion</code>, <code>kind</code>, <code>metadata</code>, <code>spec</code>, <code>status</code> - sitting in etcd as plain serialized data. When you <code>kubectl apply</code>, you are not invoking a Deployment's <code>deploy()</code> method. You are writing data to a store. The object cannot act on itself, and that's by design (I think).</p>
<p><strong>Controllers are the behavior, kept rigorously separate.</strong> A controller is a stateless <a href="https://medium.com/@inchararlingappa/kubernetes-reconciliation-loop-74d3f38e382f">reconciliation</a> loop. Strip away the machinery and it's a function:</p>
<pre><code class="language-text">reconcile(desired, observed) → actions
</code></pre>
<p>It reads data it does not own, compares the world it wants to the world it has, and emits the steps to close the gap. The logic lives entirely outside the data it operates on. If that sounds like "stateless functions that take data as an argument," that's because it is exactly that, expressed as a control loop instead of a call stack.</p>
<p><strong>The data is generic and uniform.</strong> Every resource in the cluster - a pod, a service, your own <code>PostgresCluster</code> CRD - shares the same envelope. There is no special-cased class hierarchy of resource types; there is one data shape, parameterized by <code>kind</code>. The <code>spec</code>/<code>status</code> split is itself a small modeling discipline: <code>spec</code> is desired state, <code>status</code> is observed state, and keeping them separate is what makes the whole system legible. Desired and observed never get conflated into one mutable blob.</p>
<p><a href="https://etcd.io/"><strong>etcd</strong></a> <strong>is the data at the center.</strong> Everything is CRUD over a data store plus watchers reacting to changes. The API server is, architecturally, a typed, validated gateway in front of that data. The control plane is data at rest plus functions that watch it. That is the data-oriented topology, drawn at the scale of a distributed system.</p>
<h2>Isn’t this just a cute analogy ?</h2>
<p>The reason the interpretation earns its keep is that the properties we prize in K8s <em>fall out</em> of the paradigm rather than being bolted on.</p>
<p><strong>The "Declarative" Engine:</strong> If you ask 10 engineers to describe K8s, 8-9 will use the word <em>declarative</em>.</p>
<blockquote>
<p>Data-oriented programming is the engine that makes that declarative promise actually work.</p>
</blockquote>
<p>A YAML manifest simply states a desired reality; the DOP architecture (transparent data sitting in etcd, watched by pure-ish reconciliation functions) is what physically forces a distributed system to match it.</p>
<p><a href="https://hackernoon.com/level-triggering-and-reconciliation-in-kubernetes-1f17fe30333d"><strong>Level-triggered reconciliation</strong></a> is natural once behavior is a pure-ish function of current data. A controller doesn't need a reliable event stream telling it what changed; it can re-derive the right action from the data as it stands right now. Lose an event, restart a process, miss a webhook - it doesn't matter, because the next reconcile reads the data and recomputes. Idempotency stops being a discipline you impose and becomes the default consequence of computing actions from state rather than from history.</p>
<p><a href="https://en.wikipedia.org/wiki/Composability"><strong>Composability</strong></a> comes from the uniform data shape. Because every resource is the same kind of thing, tools that operate on resources - <code>kubectl</code>, Argo CD, Kustomize, policy engines, your own scripts - work on all of them without special-casing. Generic data structures buy you generic tooling, exactly as they do in a Clojure codebase where everything is a map.</p>
<p><a href="https://en.wikipedia.org/wiki/Testability"><strong>Testability</strong></a> is the quiet win. A reconcile function is a function of data, so you test it with data fixtures: hand it a desired spec and an observed status, assert on the actions. No live cluster required for the core logic. That's only possible because the behavior was never entangled with the data in the first place.</p>
<p><a href="https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/">Extensibility via CRD</a><strong>s</strong> is the cleanest tell of all. When you extend K8s, you don't subclass anything. You declare a new <em>data shape</em> and write a function over it. Adding a <code>kind</code> is adding a data type, not adding a node to an inheritance tree. That's a data-oriented move to its bones.</p>
<h2>The honest caveats</h2>
<p>The analogy is strong, but overselling it would be its own kind of dishonesty, so here's where it strains.</p>
<p>Reconciliation isn't <em>pure</em>. The whole job of a controller is to have side effects on the world - start containers, attach volumes, call cloud APIs. The function is pure in how it decides; it is emphatically impure in what it does next. And writing <code>status</code> back is a mutation, so the "immutable data" story is really "immutable desired state, mutable observed state”.</p>
<p>And while we disambiguated this from hardware-level Data-Oriented Design earlier, there is one honest bridge to make: the <a href="https://medium.com/@dhruvbhl/informers-listers-workqueues-the-brain-behind-your-controller-f5b0967026de">informer/lister pattern</a> in <code>client-go</code>. Controllers keep a local cached copy of objects and read from that instead of hammering the API server (more on this in another blog post). That <em>is</em> data-locality thinking - keep the data you'll touch close and read it cheaply - just operating at the distributed-systems scale rather than the CPU-cache scale.</p>
<h2>What the lens changes for me</h2>
<p>Naming the thing you already do is not a trick. Once you see K8s as data-oriented, a few design instincts sharpen.</p>
<p>This lens demystifies <a href="https://about.gitlab.com/topics/gitops/">GitOps</a>. Tools like <a href="https://argo-cd.readthedocs.io/en/stable/">ArgoCD</a> aren't deployment scripts running commands; they are simply <mark class="bg-yellow-200 dark:bg-yellow-500/30">data synchronizers</mark>. They take the declarative, inert data shapes stored in Git and write them into the etcd database. Viewing it this way makes orchestrating multi-cluster rollouts far more predictable, because you are just replicating static data, not coordinating complex execution pipelines.</p>
<p>Model your CRD's data well, because the data <em>is</em> the interface - a clean <code>spec</code>/<code>status</code> split is doing more work than any clever controller code will, and Keep controllers thin: the inertness of a manifest is exactly what lets you version it in Git, diff it, lint it, validate it, and reason about it without running anything.</p>
<p>You weren't just writing YAML. You were doing data-oriented programming with a very large, very opinionated runtime. It's worth knowing the name of the thing you're good at.</p>
]]></content:encoded></item><item><title><![CDATA[How  Invisible Default Setting Can Break Production- War Story]]></title><description><![CDATA[tail -f /var/log/cassandra/system.log
...
INFO  [main] 2026-06-21 17:44:42 Cassandra starting up...
WARN  [Thread-1] 2026-06-21 17:44:43 High memory usage detected
ERROR [Thread-2] 2026-06-21 17:44:44]]></description><link>https://abhishekpareek.dev/how-invisible-default-setting-can-break-production-war-story</link><guid isPermaLink="true">https://abhishekpareek.dev/how-invisible-default-setting-can-break-production-war-story</guid><category><![CDATA[Kubernetes]]></category><category><![CDATA[podman]]></category><category><![CDATA[Cassandra]]></category><category><![CDATA[SRE]]></category><category><![CDATA[incident response]]></category><category><![CDATA[debugging]]></category><dc:creator><![CDATA[abhishek pareek]]></dc:creator><pubDate>Tue, 16 Dec 2025 21:22:00 GMT</pubDate><content:encoded><![CDATA[<pre><code class="language-plaintext">tail -f /var/log/cassandra/system.log
...
INFO  [main] 2026-06-21 17:44:42 Cassandra starting up...
WARN  [Thread-1] 2026-06-21 17:44:43 High memory usage detected
ERROR [Thread-2] 2026-06-21 17:44:44.408 java.lang.OutOfMemoryError: unable to create new native thread
</code></pre>
<p>As a senior engineer, you get used to things breaking. But sometimes, the root cause is hiding a few layers deep in the infrastructure. Recently, our company messenger app went down completely. I was looped into the incident bridge to figure it out.</p>
<p>Here is a quick write-up of the investigation, how we traced it from K8s all the way down to a container runtime default, and what we learned.</p>
<h3>The Outage: Starting at the Top</h3>
<p>Our app stack is mostly running on a K8s platform. Naturally, that is the first place I looked when the alerts started firing.</p>
<p>I opened the logs for the main messenger workload. Immediately, I saw that the application was throwing <code>503 Service Unavailable</code> errors when trying to call one of its downstream services.</p>
<p>In our architecture, the communication to this downstream service goes through an HAProxy load balancer layer. Some people always want to blame the load balancer first. To verify, we checked the HAProxy logs.</p>
<p>HAProxy was showing connectivity issues to the backend with <code>504 Gateway Timeout</code>. This actually ruled HAProxy out. It was doing its job, trying to pass traffic, but the backend was not responding in time.</p>
<h3>Moving to the Downstream Service</h3>
<p>Next, we checked the downstream backend service in K8s. The pods for this service were in a <code>CrashLoopBackOff</code> state.</p>
<p>We looked at the logs right before the crash. We figured out that the service was trying to talk to our Cassandra database cluster, but the response latency had increased massively. The BE app. was basically timing out waiting for the database, exhausting its connections, and crashing.</p>
<p>So, the backend was just a victim. The real problem was in the database layer.</p>
<h3>The Database Layer: The Real Culprit</h3>
<p>We moved our investigation to the Cassandra nodes. For this setup, Cassandra is not running inside K8s. It is running on instances using Podman containers.</p>
<p>When we checked the cluster status, we saw that 3 to 5 nodes had been OOM'ing (Out of Memory) since the morning. They were constantly getting killed and restarted.</p>
<p>We went into Grafana and pulled the Loki logs for the Cassandra containers. We found this exact error spamming the logs:</p>
<pre><code class="language-text">2026-06-21 17:44:44.408 java.lang.OutOfMemoryError: unable to create new native thread
</code></pre>
<h3>The "Aha!" Moment</h3>
<p>When you see an `OOM Error` in Java, the first thought is usually heap exhaustion. But the <code>unable to create new native thread</code> part is very specific.</p>
<p>This error doesn't mean the JVM ran out of RAM. It means the operating system (or the container runtime) is refusing to let the JVM spawn any more threads. Cassandra is a very heavy Java application; it relies on creating a lot of native threads for connection pooling, compaction, and request handling.</p>
<p>We investigated the host and the container configs. That is when we found it: <strong>the Podman default thread limit (pids-limit).</strong></p>
<p>By default, Podman sets a limit on the number of processes/threads a container can create to prevent fork bombs. During high traffic that morning, Cassandra tried to scale up its thread count to handle the load. It hit the Podman default PIDs limit limit. Because the JVM was blocked from creating the threads it needed to operate, it panicked, threw the OOM error, and crashed the node.</p>
<p>This caused a chain reaction:</p>
<ol>
<li><p>Cassandra nodes hit thread limit and crash.</p>
</li>
<li><p>Database latency spikes for surviving nodes.</p>
</li>
<li><p>Downstream K8s service times out waiting for DB, then crashloops.</p>
</li>
<li><p>HAProxy throws 504s.</p>
</li>
<li><p>Frontend app gets 503s and goes down.</p>
</li>
</ol>
<h3>The Immediate Fix</h3>
<p>To resolve this, we had to update the container configuration for the Cassandra nodes to increase or remove the <code>pids-limit</code> restriction, allowing the JVM to spawn the threads it actually needed for our production scale. Once we restarted the Podman containers with the new limits, the database stabilized, the downstream service recovered, and the messenger app came back online.</p>
<h3>Bigger Picture: Architectural Changes</h3>
<p>Fixing the Podman thread limit was the immediate band-aid to stop the bleeding. But incident reviews are about finding long-term structural fixes.</p>
<p>Our single massive production database had too large of a blast radius. The biggest takeaway from this outage was the decision to split our single production instance into two separate environments: Large Prod and Small Prod, each tailored to different audienses..</p>
<ol>
<li><p><strong>Moving Large Prod to k8s:</strong> For our main, larger prod. workload, we are migrating Cassandra into our k8s platform. Yes, I know running heavy stateful databases in K8s is a controversial topic. But for us, having our database on the same control plane as our application stack brings operational consistency. It gives us better automated distribution, easier pod anti-affinity rules, and faster recovery times when a node dies, compared to managing standalone container instances.</p>
</li>
<li><p><strong>Moving Small Prod to Bare Metal</strong> For the smaller production cluster, we are going the opposite direction: pure bare metal. Yes, maintaining bare metal adds a different layer of operational complexity because we have to manage the physical nodes directly using our automation playbooks instead of K8s manifests. However, removing the container runtime, the virtualized networking layer, and the kernel isolation overhead gives us the absolute maximum raw disk I/O and compute performance for our most latency-sensitive workloads.</p>
</li>
</ol>
<p>Sometimes, the best architectural decision is knowing when to use the shiny orchestration tool, and when to just let a database talk directly to the hardware.</p>
<h3>Takeaway</h3>
<p>When you are managing infrastructure, you monitor CPU and memory limits very closely. But resource limits like process/thread counts (PIDs) are easy to forget until they break production. Always check the default limits of your container runtime (Docker, containerd, Podman) because their defaults are usually meant for generic lightweight web apps, not heavy enterprise databases.</p>
]]></content:encoded></item><item><title><![CDATA[The Fastest Operation Is the One You Never Perform]]></title><description><![CDATA[As k8s engineers, we're constantly looking for ways to make our platforms faster, scalable, reliable, and more efficient.
We tune resource requests, optimize autoscaling, benchmark storage backends, a]]></description><link>https://abhishekpareek.dev/the-fastest-operation-is-the-one-you-never-perform</link><guid isPermaLink="true">https://abhishekpareek.dev/the-fastest-operation-is-the-one-you-never-perform</guid><category><![CDATA[Linux]]></category><category><![CDATA[Kubernetes]]></category><category><![CDATA[eBPF]]></category><category><![CDATA[networking]]></category><category><![CDATA[performance]]></category><dc:creator><![CDATA[abhishek pareek]]></dc:creator><pubDate>Sat, 29 Nov 2025 07:23:00 GMT</pubDate><content:encoded><![CDATA[<p>As k8s engineers, we're constantly looking for ways to make our platforms faster, scalable, reliable, and more efficient.</p>
<p>We tune resource requests, optimize autoscaling, benchmark storage backends, and chase n/w bottlenecks. Yet over the course of managing various platforms over the years I've noticed that the most impactful performance wins rarely come from simply adding more CPU, mem. or storage - they come from reducing the amount of work the OS has to do in the first place.</p>
<p>Few days back chasing a production bug sent me down an interesting rabbit hole. It started with investigation that lead to a Linux syscall called <a href="https://man7.org/linux/man-pages/man2/sendfile.2.html"><code>sendfile</code></a>, took me through zero-copy networking and DMA, and eventually helped me understand why <a href="https://ebpf.io/what-is-ebpf/">eBPF</a>, <a href="https://en.wikipedia.org/wiki/Express_Data_Path">XDP</a>, and <a href="https://en.wikipedia.org/wiki/Cilium_(computing)">Cilium</a> have become such important building blocks for modern k8s platforms.</p>
<p>These technologies solve different problems, but they share one philosophy:</p>
<blockquote>
<p>The fastest operation is the one you never have to perform.</p>
</blockquote>
<hr />
<h2>A Pattern Hidden Throughout Linux</h2>
<p>When we hit a performance issue, our instinct is to ask:</p>
<blockquote>
<p>How can I make this operation faster?</p>
</blockquote>
<p>Kernel developers tend to ask something different:</p>
<blockquote>
<p>Can I avoid doing this operation altogether?</p>
</blockquote>
<p>That mindset shows up everywhere in the kernel. Instead of copying data, traversing networking layers, context switching between kernel and userspace, or eval'ing thousands of rules per packet, Linux keeps evolving toward doing <em>less</em>.</p>
<p>Once you see the pattern, technologies like <code>sendfile()</code>, eBPF, and Cilium stop looking like unrelated tools and start looking like the same idea applied in different places.</p>
<hr />
<h2>The Cost of Moving Data</h2>
<img src="https://cdn.hashnode.com/uploads/covers/6a25ff63980616b1c020fc7a/833c444f-42fb-4355-80d0-34786c71cc14.svg" alt="" style="display:block;margin:0 auto" />

<p>Consider a traditional app serving a file over the network. A normal implementation that I've comre across most generally -</p>
<pre><code class="language-c">read(file_fd, buffer, size);
write(socket_fd, buffer, size);
</code></pre>
<p>The data path looks roughly like:</p>
<pre><code class="language-plaintext">Disk
 ↓  (DMA copy)
Kernel Page Cache
 ↓  (CPU copy)
Userspace Buffer
 ↓  (CPU copy)
Kernel Socket Buffer
 ↓  (DMA copy)
NIC
</code></pre>
<p>That's 4 copies of the same bytes before they leave the machine — two of them burning CPU and polluting its caches - plus 4 context switches, since <code>read()</code> and <code>write()</code> each cross into the kernel and back.</p>
<p>Each copy costs CPU cycles, memory bandwidth, and cache capacity. At small scale it's negligible. At large scale it's <strong>expensive</strong>.</p>
<hr />
<h2>How <code>sendfile()</code> Changes the Equation</h2>
<p>Linux added <code>sendfile()</code> to cut out the copies that route through userspace. Instead of <code>read()</code> then <code>write()</code>, the app calls:</p>
<pre><code class="language-c">sendfile(socket_fd, file_fd, NULL, size);
</code></pre>
<p>In the best case the path collapses to:</p>
<pre><code class="language-text">Disk
 ↓  (DMA copy)
Kernel Page Cache
 ↓  (DMA gather copy → refs., no cpu copy)
NIC
</code></pre>
<p>The app never touches the payload, and we drop from four context switches to two.</p>
<p>One honest caveat worth keeping: this <em>true</em> zero-copy path only happens when the NIC supports scatter-gather DMA and checksum offload. There, the kernel hands the NIC references to the page-cache pages and the hardware gathers them directly. Without that hardware support, <code>sendfile()</code> still saves you the two userspace copies, but there's one remaining kernel-side copy from the page cache into the socket buffer. So <code>sendfile()</code> is "fewer copies" always, and "zero-copy" when the hardware cooperates.</p>
<p>Either way, the lesson isn't the syscall. It's the design principle behind it:</p>
<blockquote>
<p>Avoid moving data across boundaries <em>unless</em> the data has changed.</p>
</blockquote>
<hr />
<h2>DMA: The Foundation Underneath</h2>
<p>That zero-copy path doesn't work by magic. It leans on something much older than <code>sendfile()</code> — <a href="https://en.wikipedia.org/wiki/Direct_memory_access">DMA</a> (Direct Memory Access).</p>
<p>DMA predates all of this by decades, and it's precisely <em>why</em> the page-cache-to-NIC hop above can skip the CPU. Modern hardware can move data directly between devices and memory without the CPU touching every byte:</p>
<pre><code class="language-text">NVMe SSD ↔ RAM
NIC      ↔ RAM
GPU      ↔ RAM
</code></pre>
<p>The CPU's job shrinks to:</p>
<ol>
<li><p>Configure the transfer</p>
</li>
<li><p>Initiate it</p>
</li>
<li><p>Get out of the way</p>
</li>
</ol>
<p><code>sendfile()</code> is really just the kernel arranging for DMA to do the heavy lifting. Same theme, one layer down:</p>
<blockquote>
<p>Reduce unnecessary CPU involvement.</p>
</blockquote>
<hr />
<h2>The Same Problem Appears in k8s Networking</h2>
<p>Take a packet entering a cluster that uses iptables-based svc routing. A simplified path:</p>
<pre><code class="language-text">NIC
 ↓
Kernel Networking Stack
 ↓
Conntrack
 ↓
iptables Rules (NAT + forwarding)
 ↓
Pod
</code></pre>
<p>Every layer does work. Every lookup costs cycles. Every packet traverses multiple subsystems before it reaches its destination.</p>
<p>At moderate scale this is fine. At large scale — service meshes, API gateways, observability pipelines, high-throughput microservices, AI inference workloads — the networking stack itself becomes a meaningful CPU consumer.</p>
<hr />
<h2>The Traditional <a href="https://kubernetes.io/docs/reference/networking/virtual-ips/">kube-proxy</a> Challenge</h2>
<p>Historically, k8s svc load balancing leaned heavily on iptables. <a href="https://en.wikipedia.org/wiki/Iptables">iptables</a> is wonderfully flexible, but it wasn't designed for cloud-native environments slinging massive east-west traffic at scale.</p>
<p>The packet inspection work is done by the kernel's netfilter. And The real problem is how those rules are evaluated. In iptables mode, rules are a sequential list, so match time scales roughly <strong>linearly (O(n))</strong> with the number of svc and endpoints. For every packet the kernel may walk through:</p>
<pre><code class="language-text">Packet
 ↓
Rule Matching (linear scan)
 ↓
NAT Processing
 ↓
Conntrack Lookup
 ↓
Forwarding Decision
</code></pre>
<p>As svc count grows, so does the chain you scan per packet. This is exactly why kube-proxy later gained an <a href="https://kubernetes.io/docs/reference/networking/virtual-ips/"><strong>IPVS</strong></a> <strong>mode</strong>, which uses hash-table lookups (closer to O(1)) instead of a linear scan — a meaningful improvement that predates eBPF entirely.</p>
<p>So to conclude iptables was traditionally just not designed for such large volume of work. It's that, for this workload, the system was doing more work than necessary.</p>
<hr />
<h2>eBPF: Bringing Logic Closer to the Data</h2>
<p>This is where eBPF gets interesting.</p>
<p>Worth saying up front: eBPF is much bigger than networking. It powers tracing, observability, and security (seccomp-BPF, LSM hooks, and more). But in <em>this</em> story, the relevant trick is networking.</p>
<p>eBPF lets you run sandboxed programs directly inside the kernel. Instead of pushing packets through layer after layer, your forwarding logic can run much earlier in the packet's journey.</p>
<p>Traditional path:</p>
<pre><code class="language-text">Packet
 ↓
Networking Stack
 ↓
iptables (linear scan)
 ↓
Additional Processing
 ↓
Destination
</code></pre>
<p>eBPF path:</p>
<pre><code class="language-text">Packet
 ↓
eBPF Program
 ↓
Destination
</code></pre>
<p>The payload is identical. What's reduced is the work needed to make a forwarding decision.</p>
<hr />
<h2>Why Cilium (and other eBPF based solutions) Matters</h2>
<p>When I first started working with Cilium, I realized it embodies the same principles that motivated <code>sendfile()</code>.</p>
<p>Cilium uses eBPF to move networking decisions closer to where packets enter the kernel. Instead of relying primarily on large iptables rule sets, svc handling can run as efficient kernel-resident eBPF programs. (In full <strong>kube-proxy replacement</strong> mode it removes the iptables-based svc path entirely; some configurations still keep iptables around for parts of the stack.)</p>
<p>The result is often:</p>
<ul>
<li><p>Lower latency</p>
</li>
<li><p>Reduced CPU consumption</p>
</li>
<li><p>Faster svc lookups (hash/map-based instead of linear)</p>
</li>
<li><p>Better scalability at large cluster sizes</p>
</li>
</ul>
<p>The goal isn't to make packet processing faster. It's to do <em>less</em> packet processing.</p>
<hr />
<h2>A Common Thread Across Linux Innovation</h2>
<p>Step back and a lot of Linux performance work follows one pattern.</p>
<h3><code>sendfile()</code></h3>
<p>Avoid unnecessary data copies.</p>
<h3>DMA</h3>
<p>Avoid unnecessary CPU involvement. (And it's the foundation <code>sendfile()</code> stands on.)</p>
<h3><code>io_uring</code></h3>
<p>Avoid unnecessary syscall overhead, via shared submission/completion rings instead of a syscall per op.</p>
<h3>eBPF</h3>
<p>Avoid unnecessary networking-stack traversal.</p>
<h3>XDP</h3>
<p>Avoid unnecessary processing by running at the driver level, <em>before</em> the kernel even allocates an <code>sk_buff</code>.</p>
<h3>Cilium</h3>
<p>Avoid unnecessary svc-routing overhead inside k8s.</p>
<p>Different subsystems. Same philosophy.</p>
<hr />
<h2>What This Changed for Me as a k8s Engineer</h2>
<p>Studying <code>sendfile()</code>, DMA, eBPF, and Cilium changed how I think about performance. Now when I chase a bottleneck, I ask:</p>
<h3><code>How many times is the data being copied?</code></h3>
<h3><code>How many components are touching this packet?</code></h3>
<h3><code>How many layers are involved in this request?</code></h3>
<h3><code>Can some of this work be eliminated entirely?</code></h3>
<p>Those questions surface opportunities that CPU graphs and memory dashboards never might.</p>
<hr />
<h2>Final Thoughts</h2>
<p>The most valuable thing I took from this wasn't a syscall or a CNI. It was a recurring pattern in Linux design: the kernel community keeps finding ways to eliminate work.</p>
<p>Less copying. Less context switching. Less packet processing. Less CPU involvement.</p>
<p>Whether it's <code>sendfile()</code>, DMA, <code>io_uring</code>, eBPF, XDP, the principle holds:</p>
<blockquote>
<p>The best optimization is often removing work rather than accelerating it.</p>
</blockquote>
<p>As k8s engineers, that mindset is worth carrying into our own platforms. Next time you're debugging a performance issue, don't only ask:</p>
<blockquote>
<p>How can I make this faster?</p>
</blockquote>
<p>Ask:</p>
<blockquote>
<p>What work can I avoid doing altogether?</p>
</blockquote>
]]></content:encoded></item><item><title><![CDATA[From the Vault: Designing a Hybrid Cloud Render Farm Under Time Constraints]]></title><description><![CDATA[A few years back, our on-prem. render farm ran short of capacity at the worst possible time: multiple projects were rendering simultaneously, and a large new batch of shots had just landed on top of i]]></description><link>https://abhishekpareek.dev/from-the-vault-designing-a-hybrid-cloud-render-farm-under-time-constraints</link><guid isPermaLink="true">https://abhishekpareek.dev/from-the-vault-designing-a-hybrid-cloud-render-farm-under-time-constraints</guid><category><![CDATA[Hybrid Cloud Architecture]]></category><category><![CDATA[AWS render farm]]></category><category><![CDATA[Spot instance architecture]]></category><category><![CDATA[distributed systems design]]></category><category><![CDATA[scheduler]]></category><category><![CDATA[high availability]]></category><category><![CDATA[PostgreSQL job queue]]></category><category><![CDATA[fsx-for-lustre]]></category><dc:creator><![CDATA[abhishek pareek]]></dc:creator><pubDate>Wed, 22 Oct 2025 23:30:00 GMT</pubDate><content:encoded><![CDATA[<p>A few years back, our on-prem. render farm ran short of capacity at the worst possible time: multiple projects were rendering simultaneously, and a large new batch of shots had just landed on top of it. We needed an urgent way to spill overflow rendering into another datacenter, and obviously the cloud emerged as the obvious choice, without blowing past the deadline.</p>
<p>An honest admission first up - we had no real prior experience running workloads in the cloud, so it was a genuine step into the unknown, and it was going to be hard. But the mindset going in was that we <em>had</em> to make it work, and that whatever we learned along the way would outlast the immediate deadline, and that turned out to be true.</p>
<p>I'll say upfront that the architecture that follows isn't perfect, and has rough edges. We had about two months to land a workable solution, so there are rough seams in here we had no time ot work over.</p>
<h2>Rendering Is Bursty, and Bursty Is Hard</h2>
<p>Render farms have a shape that's familiar to anyone who's run capacity for a spiky workload: baseline demand most of the year, and punishing peaks before a deadline. And here is a rough walk-through of the proposed shape of the solution:</p>
<ul>
<li><p>Artists submit render jobs on-prem.</p>
</li>
<li><p>Scheduler decomposes each job into independent per-frame tasks.</p>
</li>
<li><p>During peak load, compute bursts into AWS.</p>
</li>
<li><p>Most cloud workers run on <strong>Spot instances</strong> to control cost.</p>
</li>
<li><p>Assets live in S3.</p>
</li>
<li><p><a href="https://aws.amazon.com/fsx/lustre/"><strong>FSx for Lustre</strong></a> sits in front of s3 as a shared, high-throughput fs so hundreds of workers aren't all hammering S3 for the same textures at once.</p>
</li>
<li><p>Finished frames go back to S3, and a sync service pulls them home to the studio.</p>
</li>
</ul>
<h2>Why Rendering Is One of the Best Workloads for Spot</h2>
<p>Spot instances are cheap because AWS can reclaim them with few minutes' notice. That might be a dealbreaker for a lot of workloads - but rendering is close to the ideal case for it, and for three reasons:</p>
<ol>
<li><p><strong>Embarrassingly parallel.</strong> Each frame renders independently.</p>
</li>
<li><p><strong>Stateless.</strong> A worker holds no state that matters beyond the frame it's currently on.</p>
</li>
<li><p><strong>Idempotent.</strong> If a frame gets interrupted mid-render, the worst case is that it gets re-rendered.</p>
</li>
</ol>
<p>Workers poll the instance metadata endpoint (<code>169.254.169.254</code>) at a fixed cadence for interruption notices. On a warning, a worker <em>attempts</em> to</p>
<ul>
<li><p>stop requesting new frames</p>
</li>
<li><p>uploads whatever it has to the persistent storage, and</p>
</li>
<li><p>finishes the one it's on if time allows, and</p>
</li>
<li><p>terminates cleanly if possible</p>
</li>
</ul>
<p>A small baseline of On-Demand ec2 instances keeps the farm going.</p>
<blockquote>
<p>The fleet itself was built with CloudFormation and Auto Scaling Groups, using <strong>Packer-built golden AMIs</strong> - bake as much as possible into the image, let <code>cloud-init</code> handle registration, and workers are render-ready within minutes. Fast, and easy to reason about - which is exactly what you want for something that scales up and down constantly.</p>
</blockquote>
<h2>The Scheduler: Where the First Iteration Fell Apart</h2>
<p>The scheduler is the control plane: job submission, batch decomposition, frame assignment, retries, completion tracking.</p>
<p>Workers are the data plane: they render and push to S3. Separating these cleanly matters, because it means the scheduler can hiccup for a few seconds without a single worker noticing.</p>
<p><mark class="bg-yellow-200 dark:bg-yellow-500/30">But the first version of this design had a problem that's easy to miss until someone points it out: </mark> <strong><mark class="bg-yellow-200 dark:bg-yellow-500/30">making the scheduler highly available doesn't help if all the state lives in the scheduler's memory.</mark></strong> <mark class="bg-yellow-200 dark:bg-yellow-500/30">All you've done is move the single point of failure somewhere less visible.</mark></p>
<p>The fix was to push state into the database and treat the scheduler as disposable:</p>
<ul>
<li><p><strong>PostgreSQL on RDS Multi-AZ</strong> became the source of truth.</p>
</li>
<li><p>Jobs, Frames, and Workers as first-class tables.</p>
</li>
<li><p>Frames move through an explicit lifecycle: <code>PENDING → RUNNING → COMPLETED / FAILED → PERMANENTLY_FAILED</code>.</p>
</li>
</ul>
<p>Once frame state lives in the database instead of in a process's memory, the scheduler itself becomes stateless and replaceable - which is what actually makes HA possible.</p>
<h3>The Queue Bottleneck Nobody Notices at Small Scale</h3>
<p>The way our scheduler was handing out frames:</p>
<pre><code class="language-sql">SELECT * FROM frames WHERE status = 'PENDING' LIMIT 1 FOR UPDATE;
</code></pre>
<p>This works fine with ten workers. With hundreds, it becomes a lock-contention nightmare - workers queue up behind each other just to claim a single row, and the scheduler's effective throughput craters exactly when you need it most.</p>
<p>The fix is <code>FOR UPDATE SKIP LOCKED</code> (available since Postgres 9.5), combined with <strong>batched claims</strong> - a worker asks for 5–10 frames at a time instead of one, which cuts the number of round-trips by an order of magnitude. Beyond a certain fleet size, even this stops being enough, and the honest answer is to move the queue to something built for it - Redis, SQS, or Kafka. Postgres-as-a-queue is a fine choice until it isn't - but we never got to re-implement this portion.</p>
<h3>Leader Election Has a Split-Brain Problem Too</h3>
<p>Multiple scheduler instances need to agree on who's actually in charge, which is a classic use case for a <strong>Postgres advisory lock</strong>. But leader election alone doesn't prevent split-brain: if a leader stalls (GC pause, network blip) and a new one is elected, the <em>old</em> leader can wake up and keep assigning frames, unaware it's been replaced.</p>
<p>The fix is <strong>epoch fencing</strong>. Every elected leader gets a monotonically increasing epoch number, and every frame assignment carries that epoch. A stale leader with an old epoch simply can't overwrite assignments made by a newer one. It's a small mechanism, but it's the difference between "we have leader election" and "we have <em>correct</em> leader election."</p>
<h2>The Control Loops We Missed</h2>
<p><mark class="bg-yellow-200 dark:bg-yellow-500/30">Initially we only (and embarrassingly) only designed the "happy-path" - the case where everything succeeds, and learnt it the hard way that a real system needs to answer: </mark> <em><mark class="bg-yellow-200 dark:bg-yellow-500/30">what happens when it doesn't?</mark></em></p>
<p><strong>The Frame Reaper.</strong> A worker can die mid-frame - Spot reclamation, a kernel panic, a network partition - and if nothing is watching, that frame sits in <code>RUNNING</code> forever, invisibly stuck. A reaper loop runs every 30 seconds, checks worker heartbeats, and flips stale <code>RUNNING</code> frames back to <code>PENDING</code> with an incremented retry count.</p>
<p><strong>Poison frame handling.</strong> Some frames are just bad - a corrupt texture, a scene file that crashes the renderer. Without a cap, a poison frame retries forever and quietly eats capacity. <code>max_retries = 5</code>, then <code>PERMANENTLY_FAILED</code> and a notification to the artist who submitted it.</p>
<p><strong>Priority that's actually enforced.</strong> Priority existed early on as a column with no teeth - nothing in the scheduler actually respected it. Fixing that meant an explicit <code>ORDER BY priority</code> in the claim query, plus a reserved fraction of workers for background jobs so low-priority work doesn't starve indefinitely.</p>
<p><mark class="bg-yellow-200 dark:bg-yellow-500/30">None of these are exciting to design. All three are the difference between a system that survives a bad week and one that needs a human babysitting it.</mark></p>
<h2>Storage: Know What Your Source of Truth Actually Is</h2>
<p>S3 is durable storage. <strong>FSx for Lustre is not</strong> - it's a performance cache sitting in front of S3, and conflating the two is a fast way to lose data. Workers writing to <code>/mnt/lustre/fs/job/seq/scn/assets/&lt;hash&gt;/</code> does <em>not</em> mean that data exists in S3 unless it's explicitly exported. The production-safe pattern: workers write outputs <strong>directly to S3</strong>, and Lustre is used almost exclusively for reading input assets, avoiding the failure mode where 200 workers each try to pull the same 30 GB asset set independently.</p>
<p><strong>Prewarming</strong> followed the same "define it operationally or it's not real" pattern. "Prewarm the cache" isn't a plan - it became: the scheduler builds an asset manifest for a job, a dedicated prewarm worker walks that manifest, and simply <em>reading</em> each file forces Lustre to pull it from S3 ahead of time. Job state gained an explicit <code>PREWARMING</code> stage so the system's state machine matches what's actually happening physically.</p>
<h2>Worker Identity: Simpler Beat Fancier</h2>
<p>The first pass at worker authentication was mTLS - which sounds appropriately serious for a distributed fleet, until you have to answer "how do we issue, rotate, and revoke certificates for autoscaling workers that live for twenty minutes?" That's a real operational burden for a workload that doesn't need it.</p>
<p><mark class="bg-yellow-200 dark:bg-yellow-500/30">The simpler and more realistic answer: workers authenticate using their </mark> <strong><mark class="bg-yellow-200 dark:bg-yellow-500/30">EC2 Instance Identity Document</mark></strong><mark class="bg-yellow-200 dark:bg-yellow-500/30">, </mark> which the scheduler validates against AWS's signature, with TLS handling transport security. It's a good example of a broader principle - the "more secure-looking" option isn't automatically the right one if it introduces lifecycle management you don't actually need.</p>
<h2>Hybrid Networking and the Cost of Forgetting Bandwidth</h2>
<p>The studio connects to AWS over Direct Connect with VPN as a fallback, and results flow AWS → S3 → a studio-side sync service (workers never write directly to studio storage). The detail that's easy to miss: a large enough result-sync job can <strong>saturate Direct Connect</strong> and start starving control-plane traffic - the very traffic your scheduler and workers need to keep functioning. The fix is unglamorous but necessary: bandwidth limits and bulk sync windows, with control-plane traffic always prioritized.</p>
<h2>Autoscaling, Boot Storms, and Why 99.9% Doesn't Compose</h2>
<p>At fleet scale, synchronized behavior becomes its own failure mode - hundreds of workers booting at once and hammering the same registration endpoint or polling loop at the same moment. Random startup jitter and frame batching smooth this out. Scaling itself needs hysteresis and cooldowns, or you end up oscillating capacity up and down in response to noise.</p>
<p>The broader lesson, and probably the most transferable one in the whole exercise, is about availability math. Five services each running at 99.9% don't combine to 99.9% - they compose multiplicatively:</p>
<pre><code class="language-plaintext">0.999^5 ≈ 99.5%
</code></pre>
<p>Every synchronous dependency you add is a tax on your overall availability, and microservice architectures pay this tax constantly through added network hops, retry storms, and fan-out amplification. Circuit breakers exist specifically to stop retry storms from compounding a small failure into a large one - instead of hammering a struggling downstream with retries, the circuit opens and requests fail fast, protecting the thing that's already in trouble.</p>
<p>It's also, frankly, a solid argument for <strong>modular monoliths</strong> as a starting point: clear module boundaries, no network calls between them, simpler deployments, and - critically - higher baseline availability, because you haven't yet introduced the dependencies that erode it. Split into microservices when there's a real reason to, not by default.</p>
<h2>What This Exercise Was Actually Teaching</h2>
<p>Strip away the render-farm specifics and a handful of principles remain, and I think they generalize to basically any distributed system:</p>
<ol>
<li><p><strong>Separate control plane from data plane.</strong> If task state is durable, the scheduler can fail for a few seconds and nothing downstream notices.</p>
</li>
<li><p><strong>Know your source of truth, explicitly.</strong> S3 was durable; FSx was a cache. Confusing a performance layer for a durability layer is how data quietly disappears.</p>
</li>
<li><p><strong>Every control loop matters.</strong> The happy path is not a design - it's a slide. Reapers, retry limits, poison-record handling, and bandwidth governors are what make a system survive contact with reality.</p>
</li>
<li><p><strong>Distributed systems are a series of tradeoffs, not a series of best practices.</strong> Performance vs. resilience, cost vs. availability, simplicity vs. scale - the right answer changes with fleet size, and a good design says <em>when</em> it changes, not just <em>what</em> the current answer is.</p>
</li>
<li><p><strong>Availability is architectural, not aspirational.</strong> You don't get high availability by choosing highly-available components. You get it by reducing synchronous dependencies, placing redundancy where it actually matters, and designing explicitly for degradation instead of hoping it never happens.</p>
</li>
</ol>
<p>What started as "how do I phrase a resume bullet about a hybrid render farm" turned into something closer to a staff-level design review - not because the technology got more exotic, but because the questions got sharper. Every fix above came from someone asking <em>"okay, but what happens when this fails?"</em> one level deeper than the previous draft had gone. That's really the whole job.</p>
]]></content:encoded></item><item><title><![CDATA[Hidden Kubernetes Scheduling Quirks: Resource Allocation Traps Silently Breaking  Deployments]]></title><description><![CDATA[While managing our internal K8s platform I've come across multiple instances where the onboarding team(s) haven't really figured out the workload resource allocation math correctly thereby leading to ]]></description><link>https://abhishekpareek.dev/hidden-kubernetes-scheduling-quirks-resource-allocation-traps-silently-breaking-deployments</link><guid isPermaLink="true">https://abhishekpareek.dev/hidden-kubernetes-scheduling-quirks-resource-allocation-traps-silently-breaking-deployments</guid><category><![CDATA[Kubernetes]]></category><category><![CDATA[k8s]]></category><category><![CDATA[memory]]></category><category><![CDATA[cpu]]></category><category><![CDATA[Kubernetes resource limits]]></category><category><![CDATA[Platform Engineering ]]></category><dc:creator><![CDATA[abhishek pareek]]></dc:creator><pubDate>Fri, 17 Oct 2025 17:30:00 GMT</pubDate><content:encoded><![CDATA[<img src="https://cdn.hashnode.com/uploads/covers/6a25ff63980616b1c020fc7a/bedbf6cf-be93-4050-9bdc-0dde99485c83.png" alt="" style="display:block;margin:0 auto" />

<p>While managing our internal K8s platform I've come across multiple instances where the onboarding team(s) haven't really figured out the workload resource allocation math correctly thereby leading to unexpected results.</p>
<p>For instance when we deploy an app. to K8s, we might think we have the resource calculations perfectly figured out. If we have a 2Gi memory quota in our ns, and the deployment needs 1.8Gi of memory, everything should work smoothly, right? Unfortunately and possibly no as K8s has a few hidden math traps that frequently break deployments and cause pods to get stuck. Here is exactly how K8s handles memory limits and scheduling behind the scenes.</p>
<h2>The Rolling Update Issue</h2>
<p>The very first mistake people make is looking at a deployment's steady state. If you multiply your pod's memory limits by the number of replicas, you get your normal footprint. However, K8s rarely runs exactly that number of replicas during an update. By default, deployments use a rolling update strategy with a setting called <code>maxSurge</code>. This means it would temporarily spins up extra new pods before killing the old ones to prevent downtime. If your resource quota is tuned exactly to your normal running capacity, the rolling update will instantly hit the limit wall, and your new pods will be blocked. YOu might have to stream the ns specific events to figure this one out -<br /><code>kubectl get events --sort-by=.lastTimestamp</code></p>
<h2>Init Containers Twist the Math</h2>
<p>Things could also get more interesting when you add init containers into the mix. Because init containers run <em>Serially</em> and finish before the main app. starts, they never run at the same time as your app. Because of this, K8s calculates your pod’s memory requirements using an "effective request" formula. The scheduler compares the sum of all your regular app containers against the single highest resource request among your init containers. Whichever number is bigger becomes the official memory requirement that the scheduler uses to find a node.</p>
<h2>The Init Container Excessive Reservation Problem</h2>
<p>This scheduling logic creates a strange side effect that might be called a "ghost reservation." If you have a heavy init container that needs 1Gi of memory to run a quick database migration, and an app container that only needs 100Mi, the scheduler permanently logs that pod as needing 1Gi of memory. Even after the init container finishes and hence is not using that memory, the scheduler does not give that space back to the node. That 1Gi remains blocked off for the entire lifecycle of the pod, which can easily cause your cluster nodes to look fuller when they are actually sitting idle. Take a look at <a href="https://github.com/kubernetes/kubernetes/issues/124282">this</a> discussion for more information.</p>
<h2>Memory Backed volumes count towards Memory Limit</h2>
<blockquote>
<p>The memory limit for the Pod or container can also apply to pages in memory backed volumes, such as an emptyDir. The kubelet tracks tmpfs emptyDir volumes as container memory use, rather than as local ephemeral storage.</p>
</blockquote>
<p>Only came across this once one of our clients suffered a major outage unintentionally due to the fact (more to follow later in a separate blog post).</p>
<p>As <code>medium: Memory</code> leverages <code>tmpfs</code> (RAM) under the hood, writing to that directory allocates pages in the the page cache whose usage is is tracked by the <code>kubelet</code> under the container memory cgroup, it <em>might</em> create a series of <strong>catastrophic blind spot.</strong></p>
<h2>Limits vs Requests for the Scheduler</h2>
<p>This often has been a source of constant confusion on our platform. yet another piece of puzzle is understanding <a href="https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/#how-pods-with-resource-requests-are-scheduled"><em><strong>what</strong></em></a> the scheduler actually cares about. The <code>kube-scheduler</code> completely ignores your memory <em>limits</em> when deciding where to place a pod; it only looks at memory <em>requests</em>.</p>
<blockquote>
<p><em><strong>The scheduler ensures that, for each resource type, the sum of the resource requests of the scheduled containers is less than the capacity of the node.</strong></em></p>
</blockquote>
<p>However, if you specify a memory limit for your container but forget to define a request, k8s automatically sets the request equla to your limit. In that specific scenario, your limit indirectly controls your scheduling, which can accidentally trigger <em>more</em> reservation than actually being used leading to wasted resources due to reduced allocatable capacity, or push your deployment past your ns hard resource quota silently.</p>
<h2>Silent Danger of CPU Throttling</h2>
<p>Another issue we've observed sneaking up silently on dev. teams is this one. While breaking a memory quota results in an immediate, loud <code>OOMKilled</code> error, misconfiguring your CPU limits creates a much more frustrating, silent failure.</p>
<blockquote>
<p><a href="https://www.datadoghq.com/blog/kubernetes-cpu-requests-limits/#linux-scheduling-in-kubernetes"><em><strong>CPU is a compressible resource.</strong></em></a></p>
</blockquote>
<p>When your app. hits its CPU limit, K8s doesn't kill the pod; it throttles it by slowing down its processing time. This causes your application to become incredibly slow and unresponsive. Because the app stops responding in time, its own <code>livenessProbe</code> or <code>readinessProbe</code> health checks will begin to fail. K8s will then assume the container is dead and trigger a cascading loop of unneeded restarts, all because a strict CPU quota starved the application of the processing power it needed to answer its own health checks.</p>
]]></content:encoded></item><item><title><![CDATA[nftables vs iptables: A Kubernetes Platform Engineer's Perspective]]></title><description><![CDATA[K8s networking conversations these days seem to be dominated by discussions around Service meshes, and eBPF, yet many clusters today still rely on kube-proxy, and underneath kube-proxy lies a less gla]]></description><link>https://abhishekpareek.dev/nftables-vs-iptables-a-kubernetes-platform-engineer-s-perspective</link><guid isPermaLink="true">https://abhishekpareek.dev/nftables-vs-iptables-a-kubernetes-platform-engineer-s-perspective</guid><category><![CDATA[Kubernetes-networking]]></category><category><![CDATA[Devops]]></category><category><![CDATA[Linux]]></category><category><![CDATA[cloud native]]></category><category><![CDATA[kube-proxy]]></category><dc:creator><![CDATA[abhishek pareek]]></dc:creator><pubDate>Fri, 11 Jul 2025 16:00:00 GMT</pubDate><content:encoded><![CDATA[<img src="https://cdn.hashnode.com/uploads/covers/6a25ff63980616b1c020fc7a/3307f37e-271d-4fe5-82a5-1e30a79ee8fd.png" alt="" style="display:block;margin:0 auto" />

<p>K8s networking conversations these days seem to be dominated by discussions around Service meshes, and eBPF, yet many clusters today still rely on kube-proxy, and underneath kube-proxy lies a less glamorous but equally important question:</p>
<h2><strong>Should your cluster be using iptables or nftables?</strong></h2>
<p>If you're a platform engineer running Kubernetes at any reasonable scale, this isn't merely an implementation detail. It directly affects how efficiently your cluster handles Svc routing, endpoint updates, rolling deploys, and autoscaling events.</p>
<p>What follows is my understanding of the concepts learnt the hard way while dealing with running on-prem k8s platforms at scale -</p>
<hr />
<h2>A Simple Kubernetes Service</h2>
<p>Imagine a Service with three backend Pods:</p>
<pre><code class="language-text">Svc A (10.96.0.10:80)

    ├── Pod 1 (10.244.1.10)
    ├── Pod 2 (10.244.2.10)
    └── Pod 3 (10.244.3.10)
</code></pre>
<p>When a client sends traffic to the Service VIP:</p>
<pre><code class="language-text">client
   |
   v
10.96.0.10:80
</code></pre>
<p>the kernel must answer a simple question:</p>
<blockquote>
<p>Which backend pod should receive this connection?</p>
</blockquote>
<p>The answer depends on how kube-proxy programs the node.</p>
<hr />
<h2>How iptables Solves the Problem</h2>
<p>Historically, kube-proxy implemented svc load balancing using iptables.</p>
<p>For every svc, kube-proxy creates a chain:</p>
<pre><code class="language-text">KUBE-SERVICES
    |
    └── KUBE-SVC-ABC123
</code></pre>
<p>Inside that chain are endpoint selection rules:</p>
<pre><code class="language-text">Rule 1 -&gt; Pod 1
rule 2 -&gt; Pod 2
Rule 3 -&gt; Pod 3
</code></pre>
<p>In reality, these rules use probabilistic matching to distribute connections across backends.</p>
<p>Conceptually:</p>
<pre><code class="language-text">Connection arrives
      |
      v
Check Rule 1
      |
      +--&gt; Match -&gt; Pod 1

Otherwise

Check Rule 2
      |
      +--&gt; Match -&gt; Pod 2

Otherwise

Pod 3
</code></pre>
<p>The important detail is that iptables represents backend selection as a collection of rules.</p>
<p>That works well when you have a handful of svc's and endpoints.</p>
<hr />
<h2>Where Things Start Getting Interesting</h2>
<p>Imagine:</p>
<pre><code class="language-text">1,000 Services
50 Endpoints per Service
</code></pre>
<p>Now kube-proxy may be managing tens of thousands of iptables rules.</p>
<p>For a packet destined to a Service, the kernel must first locate the matching Service rule and then evaluate randomised endpoint selection rules within that svc chain.</p>
<p>The lookup is more like:</p>
<pre><code class="language-text">Find Service
      +
Find Endpoint
</code></pre>
<p>However, both of those operations become increasingly expensive as the number of Services and endpoints grows.</p>
<hr />
<h2>The Bigger Problem Isn't Packet Forwarding</h2>
<p>One of the most common misconceptions is that iptables struggles because packet forwarding becomes unbearably slow.</p>
<p>In practice, packet forwarding is rarely the primary issue as in a K8s clusters things are constantly changing:</p>
<ul>
<li><p>pods are created and destroyed</p>
</li>
<li><p>Deployments roll forward</p>
</li>
<li><p>HPA scales workloads up and down</p>
</li>
<li><p>Nodes join and leave the cluster</p>
</li>
</ul>
<p>Every endpoint change forces kube-proxy to reprogram iptables.</p>
<p>As endpoints are added and removed, kube-proxy repeatedly regenerates and reapplies large portions of its rule set.</p>
<p>This leads to:</p>
<ul>
<li><p>Longer synchronization times</p>
</li>
<li><p>Higher kube-proxy CPU usage</p>
</li>
<li><p>Large iptables-restore ops</p>
</li>
<li><p>Slower propagation of endpoint updates</p>
</li>
</ul>
<p>In large clusters, these effects become very noticeable.</p>
<hr />
<h2>Enter nftables</h2>
<p>nftables was designed as the successor to iptables.</p>
<p>The key architectural difference is subtle but important.</p>
<p>iptables treats networking state as rules.</p>
<p>nftables treats networking state as data.</p>
<p>Instead of representing backend selection as a series of rules, nftables can store information using efficient kernel data structures such as sets and maps.</p>
<p>Conceptually:</p>
<pre><code class="language-text">Service A
    |
    └── Backend Set
          ├── Pod 1
          ├── Pod 2
          └── Pod 3
</code></pre>
<p>Rather than traversing chains of rules, the kernel can perform direct lookups into optimized structures.</p>
<p>Think of it like the difference between:</p>
<pre><code class="language-text">Searching a list
</code></pre>
<p>and</p>
<pre><code class="language-text">Looking up an entry in a hash table
</code></pre>
<p>The exact implementation <em>apparently</em> is more nuanced, but the above mental model more or less holds</p>
<hr />
<h2>Why This Matters for K8s ?</h2>
<p>The biggest benefit of nftables isn't just that packets suddenly move faster - It is operational efficiency.</p>
<p>Suppose a new Pod becomes available:</p>
<pre><code class="language-text">Pod 4 added
</code></pre>
<p>With iptables, kube-proxy may need to regenerate portions of the ruleset.</p>
<p>With nftables, the kernel can often update a set or map entry instead.</p>
<p>Conceptually:</p>
<pre><code class="language-text">Before:
[Pod1, Pod2, Pod3]

After:
[Pod1, Pod2, Pod3, Pod4]
</code></pre>
<p>The packet processing logic remains unchanged.</p>
<p>Only the data changes.</p>
<p>This makes endpoint updates significantly cheaper.</p>
<hr />
<h2>Scaling Characteristics</h2>
<p>From my experience, the distinction becomes more important as cluster size grows. Consider two environments.</p>
<h3>Small Cluster like DEV/QA</h3>
<pre><code class="language-text">50 Nodes
200 Services
</code></pre>
<p>I personally have not found very any meaningful difference as imo iptables remains perfectly serviceable.</p>
<h3>Large Platform like STG/PROD/PERF</h3>
<pre><code class="language-text">1,000+ Nodes
10,000+ Services
Tens of thousands of Endpoints
</code></pre>
<p>Now the operational overhead of maintaining large iptables rule sets becomes much more apparent. This is where nftables begins to show its strengths:</p>
<ul>
<li>Faster synchronization</li>
</ul>
<p>More efficient updatesnftables vs iptables: A Kubernetes Platform Engineer's Perspective</p>
<p>Kubernetes networking conversations these days seem to be dominated by discussions around Service Meshes, eBPF, and XDP. Yet many production clusters today still rely on kube-proxy, and underneath kube-proxy lies a less glamorous but equally important question:</p>
<blockquote>
<p>Should your cluster be using iptables or nftables?</p>
</blockquote>
<p>If you're a platform engineer running Kubernetes at any reasonable scale, this isn't merely an implementation detail. It directly affects how efficiently your cluster handles Service routing, endpoint updates, rolling deployments, and autoscaling events.</p>
<p>What follows is my understanding of these concepts, learned the hard way while operating large on-prem Kubernetes platforms.</p>
<hr />
<h1>A Simple Kubernetes Service</h1>
<p>Imagine a Service with three backend Pods:</p>
<pre><code class="language-text">Service A (10.96.0.10:80)

    ├── Pod 1 (10.244.1.10)
    ├── Pod 2 (10.244.2.10)
    └── Pod 3 (10.244.3.10)
</code></pre>
<p>When a client sends traffic to the Service VIP:</p>
<pre><code class="language-text">Client
   |
   v
10.96.0.10:80
</code></pre>
<p>the kernel must answer a simple question:</p>
<blockquote>
<p>Which backend Pod should receive this connection?</p>
</blockquote>
<p>The answer depends on how kube-proxy programs the node.</p>
<hr />
<h1>How iptables Solves the Problem</h1>
<p>Historically, kube-proxy implemented Service load balancing using iptables.</p>
<p>For every Service, kube-proxy creates a dedicated chain:</p>
<pre><code class="language-text">KUBE-SERVICES
    |
    └── KUBE-SVC-ABC123
</code></pre>
<p>Inside that chain are endpoint selection rules:</p>
<pre><code class="language-text">Rule 1 -&gt; Pod 1
Rule 2 -&gt; Pod 2
Rule 3 -&gt; Pod 3
</code></pre>
<p>In reality, these rules use probabilistic matching to distribute new connections across backends.</p>
<p>Conceptually:</p>
<pre><code class="language-text">Connection arrives
      |
      v
Check Rule 1
      |
      +--&gt; Match -&gt; Pod 1

Otherwise

Check Rule 2
      |
      +--&gt; Match -&gt; Pod 2

Otherwise

Pod 3
</code></pre>
<p>The important detail is that iptables represents backend selection as a collection of rules.</p>
<p>That works perfectly fine when you have a handful of Services and endpoints.</p>
<hr />
<h1>Where Things Start Getting Interesting</h1>
<p>Now imagine a larger environment:</p>
<pre><code class="language-text">1,000 Services
50 Endpoints per Service
</code></pre>
<p>At this point, kube-proxy may be managing tens of thousands of iptables rules.</p>
<p>For a packet destined to a Service, the kernel must first locate the matching Service rule and then evaluate endpoint selection rules within that Service chain.</p>
<p>The lookup is conceptually:</p>
<pre><code class="language-text">Find Service
      +
Find Endpoint
</code></pre>
<p>Contrary to what some engineers assume, the kernel is not traversing every endpoint rule in the cluster. However, both Service lookups and endpoint selection become increasingly expensive as the number of Services and endpoints grows.</p>
<hr />
<h1>The Bigger Problem Isn't Packet Forwarding</h1>
<p>One of the most common misconceptions is that iptables struggles because packet forwarding becomes unbearably slow.</p>
<p>In practice, packet forwarding is rarely the primary issue.</p>
<p>The real challenge is change.</p>
<p>A Kubernetes cluster is a constantly moving target:</p>
<ul>
<li><p>Pods are created and destroyed</p>
</li>
<li><p>Deployments roll forward</p>
</li>
<li><p>HPAs scale workloads up and down</p>
</li>
<li><p>Nodes join and leave the cluster</p>
</li>
</ul>
<p>Every endpoint change forces kube-proxy to reprogram iptables.</p>
<p>As endpoints are added and removed, kube-proxy repeatedly regenerates and reapplies large portions of its rule set.</p>
<p>This leads to:</p>
<ul>
<li><p>Longer synchronization times</p>
</li>
<li><p>Higher kube-proxy CPU utilization</p>
</li>
<li><p>Large <code>iptables-restore</code> operations</p>
</li>
<li><p>Slower propagation of endpoint updates</p>
</li>
</ul>
<p>In sufficiently large clusters, these effects become very noticeable.</p>
<hr />
<h1>Enter nftables</h1>
<p>nftables was designed as the successor to iptables.</p>
<p>The key architectural difference is subtle but important.</p>
<p><strong>iptables treats networking state as rules.</strong></p>
<p><strong>nftables treats networking state as data.</strong></p>
<p>Instead of representing backend selection as a series of rules, nftables can store information using efficient kernel data structures such as sets and maps.</p>
<p>Conceptually:</p>
<pre><code class="language-text">Service A
    |
    └── Backend Set
          ├── Pod 1
          ├── Pod 2
          └── Pod 3
</code></pre>
<p>Rather than traversing chains of rules, the kernel can perform direct lookups into optimized structures.</p>
<p>A useful mental model is:</p>
<pre><code class="language-text">iptables  -&gt; Search a list
nftables  -&gt; Lookup in a hash table
</code></pre>
<p>The actual implementation is more nuanced than that, but the comparison is directionally accurate.</p>
<hr />
<h1>Why This Matters for Kubernetes</h1>
<p>The biggest benefit of nftables isn't that packets suddenly move faster.</p>
<p>The bigger win is operational efficiency.</p>
<p>Suppose a new Pod becomes available:</p>
<pre><code class="language-text">Pod 4 added
</code></pre>
<p>With iptables, kube-proxy may need to regenerate portions of the ruleset.</p>
<p>With nftables, the kernel can often update a set or map entry instead.</p>
<p>Conceptually:</p>
<pre><code class="language-text">Before:
[Pod1, Pod2, Pod3]

After:
[Pod1, Pod2, Pod3, Pod4]
</code></pre>
<p>The packet processing logic remains unchanged.</p>
<p>Only the underlying data changes.</p>
<p>This makes endpoint updates significantly cheaper and reduces the amount of work required during periods of high endpoint churn.</p>
<hr />
<h1>Scaling Characteristics</h1>
<p>From my experience, the distinction becomes more important as cluster size grows.</p>
<h2>Small Clusters (Dev / QA)</h2>
<pre><code class="language-text">50 Nodes
200 Services
</code></pre>
<p>I personally haven't observed a meaningful difference in day-to-day operations.</p>
<p>In environments of this size, iptables remains perfectly serviceable.</p>
<h2>Large Platforms (Stage / Perf / Production)</h2>
<pre><code class="language-text">1,000+ Nodes
10,000+ Services
Tens of Thousands of Endpoints
</code></pre>
<p>Now the operational overhead of maintaining large iptables rule sets becomes much more apparent.</p>
<p>This is where nftables begins to show its strengths:</p>
<ul>
<li><p>Faster synchronization</p>
</li>
<li><p>More efficient updates</p>
</li>
<li><p>Better scaling characteristics</p>
</li>
<li><p>Reduced rule management complexity</p>
</li>
</ul>
<hr />
<h2>The Real Takeaway</h2>
<p>As platform engineers, it's mostly easy to get distracted by datapath benchmarks and packet-per-second numbers.</p>
<p>The more important question is:</p>
<blockquote>
<p>how efficiently can my cluster adapt to change?</p>
</blockquote>
<p>Modern Kubernetes environments are dynamic systems. Pods come and go constantly. Deployments roll all the time. Autoscalers react to traffic spikes, so basically - <strong>Endpoint churn is normal.</strong></p>
<p>Viewed through that lens, nftables offers a cleaner and more scalable foundation than traditional iptables.</p>
<blockquote>
<p>iptables treats Service routing as a collection of rules (if-elif-else construct) where as nftables treats svc routing as data (hash lookups)</p>
</blockquote>
<p>That shift may sound subtle, but at k8s scale, it can make a significant difference.</p>
<p>And while kernel based xDP and eBPF-based solutions are increasingly becoming the industry's long-term direction, understanding the evolution from iptables to nftables remains essential for anyone building and operating modern Kubernetes platforms.</p>
]]></content:encoded></item><item><title><![CDATA[Silent Node Killer: How Mem-Backed emptyDir Vols. degraded our k8s Perf.  Cluster]]></title><description><![CDATA[When you operate an on-prem K8s platform with a static node count that hasn't changed for months, a single dead node could have a ripple effect on the entire cluster, and the workloads being run causi]]></description><link>https://abhishekpareek.dev/silent-node-killer-how-mem-backed-emptydir-vols-degraded-our-k8s-perf-cluster</link><guid isPermaLink="true">https://abhishekpareek.dev/silent-node-killer-how-mem-backed-emptydir-vols-degraded-our-k8s-perf-cluster</guid><category><![CDATA[k8s]]></category><category><![CDATA[Kubernetes]]></category><category><![CDATA[memory]]></category><category><![CDATA[Emptydir]]></category><category><![CDATA[volume]]></category><category><![CDATA[node]]></category><category><![CDATA[worker node]]></category><dc:creator><![CDATA[abhishek pareek]]></dc:creator><pubDate>Mon, 23 Jun 2025 06:45:00 GMT</pubDate><content:encoded><![CDATA[<img src="https://cdn.hashnode.com/uploads/covers/6a25ff63980616b1c020fc7a/c2ae8162-0f45-4d57-ab12-baf63ce7e6e7.png" alt="AI generated" style="display:block;margin:0 auto" />

<p>When you operate an on-prem K8s platform with a static node count that hasn't changed for months, a single dead node could have a ripple effect on the entire cluster, and the workloads being run causing a massive headache. Unlike the cloud managed K8s clusters, at tmies we don't have the luxury of auto-scaling groups silently spinning up fresh instances in the background. If a node drops, we lose hard capacity until someone physically intervenes ,and in case the clsuter is already under duress it only amplifies.</p>
<p>This is exactly what was experienced on the primary <em><strong>Perf</strong></em> cluster very recently. One of the dev teams deployed a distributed workload for QA with several enhancements including using memory backed <code>emptyDir</code> vols. as a ephemeral cache, which resulted in a cascading failure where the cluster performance was significantly degraded. There was no <code>NotReady</code> transition or graceful pod eviction - instantaneous lockups. Couldn't even SSH into some of the boxes.</p>
<p>Here is how a single volume mount config exploited a K8s accounting loophole (Issue #119611) to starve our cluster, and the logical steps we took to debug the app.</p>
<h3>The Investigation: Step-by-Step</h3>
<p><strong>Step 1: The Hard Reset and Kernel Autopsy</strong> Since <code>sshd</code> was completely unresponsive, we had to hit the nodes via out-of-band mgmt to issue a hard power cycle. Once the node rejoined the cluster, my first instinct was to query <a href="https://grafana.com/oss/loki/">Loki (via Grafana)</a> for the <code>kubelet</code> logs just prior to the crash. We fully expected to see the standard K8s resource starvation chain-of-events -</p>
<ul>
<li><p><code>kubelet</code> crossing its <code>evictionHard</code> threshold</p>
</li>
<li><p>tainting node with <code>MemoryPressure</code>, and</p>
</li>
<li><p>gracefully terminating <code>BestEffort</code> or <code>Burstable</code> pods first</p>
</li>
</ul>
<p>Instead, the Loki streams had completely dropped the last few minutes before the crash—a classic indicator that the logging daemon itself was starved of CPU/memory. Pulling <code>dmesg</code> and <code>journalctl -k</code> directly from the local nodes revealed system-level chaos - kernel's OOMkiller had been invoked, but it wasn't just reaping misbehaving containers; it was indiscriminately killing core OS processes. The node didn't gracefully handle the memory pressure; the <code>kubelet</code> was completely ambushed before it could even calculate the usage and trigger an eviction loop.</p>
<p><strong>Step 2: Isolating the Suspect Workload</strong> As the load-testing event was already previously advertised before hand we knew where the smoking gun might be pointing at. The workload deployment manifest included:</p>
<pre><code class="language-yaml">volumes:
  - name: cache-vol
    emptyDir:
      medium: Memory
</code></pre>
<p><strong>Step 3: The Shared Quota Trap</strong> An <code>emptyDir</code> with <code>medium: Memory</code> tells K8s to mount a <code>tmpfs</code> (RAM disk) inside the container. Here is the critical detail that caught me off guard: <code>tmpfs</code> <strong>usage counts directly toward your container's mem</strong> <a href="https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/#how-pods-with-resource-limits-are-run"><strong>limit</strong></a><strong>.</strong></p>
<blockquote>
<ul>
<li>The memory limit for the Pod or container can also apply to pages in memory backed volumes, such as an <code>emptyDir</code>. The kubelet tracks <code>tmpfs</code> emptyDir volumes as container memory use, rather than as local <a href="https://kubernetes.io/docs/concepts/storage/ephemeral-storage/">ephemeral storage</a>. When using memory backed <code>emptyDir</code>, be sure to check the notes <a href="https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/#memory-backed-emptydir">b</a>elow</li>
</ul>
</blockquote>
<p>It acts as a shared quota. FOr example, If you assign a container a mem limit of <code>1Gi</code>, that must accommodate both the app's RSS/heap <em>and</em> any files written to the <code>tmpfs</code> volume. If the sum of the app mem and the <code>tmpfs</code> data exceeds the defined limit, the container might get OOMKilled.</p>
<p><strong>Step 4: Discovering the Cgroup Loophole (The Root Cause)</strong> The devs hadn't set a <code>sizeLimit</code> on the volume. As the app was aggressively writing cache files, the shared quota was quickly breached, and the app was OOMKilled. Standard K8s behavior, right?</p>
<p>But here is where the K8s accounting loophole triggered the outage - when a container is OOMKilled and restarts, the pod remains on the node, and the data previously written to the <code>tmpfs</code> <em>persists</em>. However, when the new container spins up, the Linux kernel (possibly) often fails to account for that pre-existing <code>tmpfs</code> data in the <em>new</em> container's cgroup.</p>
<p>This data becomes "ghost mem." It consumes RAM but is invisible to the new cgroup limit. The app restarts, writes more files to the <code>tmpfs</code>, consumes its full mem limit again, crashes, and restarts. This loop continued until the entire node ran out of RAM and locked up. Now how did the <code>kubelet</code> not step in earlier to stop this madness is beyond me.</p>
<h3>Learnings</h3>
<p>K8s is incredibly powerful, but sometimes it expects you to provide strict guardrails. When you don't, features like mem-backed volumes become loaded weapons.</p>
<ul>
<li><p>It is advisable to enforce size limits for <code>emptyDir</code> volumes in new workloads (pods) using a policy mechanism such as <a href="https://kubernetes.io/docs/reference/access-authn-authz/validating-admission-policy/">ValidationAdmissionPolicy</a>.</p>
</li>
<li><p><strong>Set a</strong> <code>sizeLimit</code> <strong>on mem-backed volumes.</strong> If you use <code>medium: Memory</code>, cap it. If a volume hits its <code>sizeLimit</code>, the <code>kubelet</code> gracefully evicts the pod instead of triggering an abrupt OOMKill.</p>
</li>
</ul>
<pre><code class="language-yaml">emptyDir:
  medium: Memory
  sizeLimit: 500Mi
</code></pre>
<ul>
<li><p><strong>Enforce hard mem limits.</strong> Set <code>resources.limits.memory</code> on the pod to ensure the <code>kubelet</code> and container runtime have a hard ceiling to enforce.</p>
</li>
<li><p><strong>Use Resourcequotas.</strong> At the platform level, enforce a <code>ResourceQuota</code> in every ns that mandates limits. This prevents teams from deploying unbounded apps in the first place.</p>
</li>
<li><p><strong>DON'T Rely on</strong> <code>LimitRange</code> <strong>for</strong> <code>emptyDir</code> <strong>sizing.</strong> While a <code>LimitRange</code> might apply default sizes to containers, it does not effectively prevent the mem overcommit scenario caused by unbound <code>tmpfs</code> volumes.</p>
</li>
<li><p><strong>DON'T Treat</strong> <code>tmpfs</code> <strong>like disk storage.</strong> Educate the devs that <code>medium: Memory</code> is RAM. It shares the same budget as the app's heap size. If an app needs a massive scratchpad, write to standard disk-backed storage instead.</p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Python3 - 'GIL'T Free parallel processing is finally here]]></title><description><![CDATA[Ever since the free-threaded Python came out in (v3.13) and became official in Oct 2025 (v3.14), I have always really wanted to test-drive it. Since I don't write Python much these days, this was also]]></description><link>https://abhishekpareek.dev/python3-gil-t-free-parallel-processing-is-finally-here</link><guid isPermaLink="true">https://abhishekpareek.dev/python3-gil-t-free-parallel-processing-is-finally-here</guid><category><![CDATA[Python]]></category><category><![CDATA[python-free-threaded]]></category><category><![CDATA[k8s]]></category><category><![CDATA[concurrency]]></category><category><![CDATA[backend]]></category><dc:creator><![CDATA[abhishek pareek]]></dc:creator><pubDate>Fri, 03 Jan 2025 22:30:00 GMT</pubDate><content:encoded><![CDATA[<p>Ever since the free-threaded Python came out in (v3.13) and became official in Oct 2025 (v3.14), I have always really wanted to test-drive it. Since I don't write Python much these days, this was also a good way to play around and sharpen my concurrency skills.</p>
<p>Seeing is believing, so I wanted to build a demo to compare the GIL vs No-GIL impact on a concurrent setup. As i've been working on K8s controllers a bit those days, I made a multi-threaded benchmark that mimics a CPU-bound <strong>k8s Controller</strong>.</p>
<h3>Why does the GIL exist anyway?</h3>
<p>If you ever wrote multi-threaded Python, you have hit the <strong>Global Interpreter Lock (GIL)</strong>.</p>
<p>It exists for one main reason: <strong>memory safety</strong>. CPython uses <strong>reference counting</strong> to manage memory. Every var., list, or dict tracks how many parts of your code point to it. When that count hits zero, Python end up frees the memory.</p>
<p>But if multiple threads try to update this counter at the same time, you get a <strong>race condition</strong>. This can crash your app or leak memory. To stop this, the creators of CPython added the GIL: a single giant lock. Only the thread holding the lock can run Python code.</p>
<p>This kept Python simple, but it created a big limit: even on a 32-core workstation, your multi-threaded Python runs on <strong>one core at a time</strong>.</p>
<h3>PEP 703: The No-GIL Fix</h3>
<p>To remove the GIL safely, PEP 703 made 3 big upgrades:</p>
<ol>
<li><p><strong>Biased Ref Counting:</strong> Objects are tracked differently depending on which thread made them. This stops CPU cache problems.</p>
</li>
<li><p><strong>Thread-Safe Allocator:</strong> It uses <code>mimalloc</code>, a very fast concurrent memory allocator.</p>
</li>
<li><p><strong>Fine-Grained Locks:</strong> It locks individual dicts and collections instead of locking the whole app.</p>
</li>
</ol>
<h3>The k8s Use Case</h3>
<p>In the cloud-native world, many custom operators and controllers are written in Python (using tools like <code>Kopf</code>). They use threads to handle events from the k8s API server.</p>
<p>If your operator gets too many events at once, it starts threads to handle them. If those threads do CPU work—like parsing JSON, changing config keys, or making crypto hashes—they fight for the GIL. On standard Python, this lock fight can make your multi-threaded code <strong>slower</strong> than a simple sequential loop!</p>
<p>To prove this, I wrote a script to simulate this exact workload.</p>
<h3>The Benchmark: <code>k8s_bench.py</code></h3>
<p>This script starts 4 threads to run 80k mock k8s tasks.</p>
<p>In each task, the worker must:</p>
<ol>
<li><p>Decode a raw JSON string of a Pod manifest.</p>
</li>
<li><p>Mutate a key (injecting a unique <code>uid</code>).</p>
</li>
<li><p>Serialize it back to bytes.</p>
</li>
<li><p>Calculate a SHA-256 crypto hash to simulate config-drift checks.</p>
</li>
</ol>
<pre><code class="language-plaintext">import sys
import time
import threading
import json
import hashlib

def get_gil_status():
    try:
        enabled = sys._is_gil_enabled()
        return "ENABLED" if enabled else "DISABLED (No-GIL!)"
    except AttributeError:
        return "ENABLED (Legacy Interpreter)"

def simulate_k8s_reconciliation(worker_id, num_items):
    mock_pod = {
        "apiVersion": "v1",
        "kind": "Pod",
        "metadata": {"name": "nginx-ingress", "namespace": "ingress-nginx"},
        "spec": {
            "containers": [{"name": "nginx", "image": "nginx:1.25", "resources": {"requests": {"cpu": "100m"}}}]
        }
    }
    raw_json = json.dumps(mock_pod)
    
    hash_accumulator = 0
    for _ in range(num_items):
        parsed = json.loads(raw_json)
        parsed["metadata"]["uid"] = f"worker-{worker_id}-{_}"
        serialized = json.dumps(parsed).encode('utf-8')
        
        # CPU work: calculate SHA-256 hash
        hasher = hashlib.sha256()
        hasher.update(serialized)
        hash_accumulator += int(hasher.hexdigest()[:8], 16) % 1000
        
    return hash_accumulator

def run_benchmark():
    TOTAL_PODS = 80000
    NUM_WORKERS = 4
    PODS_PER_WORKER = TOTAL_PODS // NUM_WORKERS
    
    print("================================================================")
    print(f" PYTHON RUNTIME: {sys.version.split()[0]}")
    print(f" GIL STATUS:     {get_gil_status()}")
    print(f" WORKLOAD:       Reconciling {TOTAL_PODS:,} Pods via {NUM_WORKERS} tasks")
    print("================================================================\n")

    # Sequential Run
    print("Running Sequential (1 Thread)...")
    start_time = time.time()
    start_cpu = time.process_time()
    
    seq_acc = 0
    for i in range(NUM_WORKERS):
        seq_acc += simulate_k8s_reconciliation(worker_id=i, num_items=PODS_PER_WORKER)
        
    seq_real_time = time.time() - start_time
    seq_cpu_time = time.process_time() - start_cpu
    
    print(f"--&gt; [SEQ] Real Time: {seq_real_time:.4f}s | CPU Time: {seq_cpu_time:.4f}s (Sum: {seq_acc})\n")

    # Multi-Threaded Run
    print(f"Running Multi-Threaded ({NUM_WORKERS} Threads)...")
    threads = []
    
    start_time = time.time()
    start_cpu = time.process_time()
    
    for i in range(NUM_WORKERS):
        t = threading.Thread(target=simulate_k8s_reconciliation, args=(i, PODS_PER_WORKER))
        threads.append(t)
        t.start()
        
    for t in threads:
        t.join()
        
    multi_real_time = time.time() - start_time
    multi_cpu_time = time.process_time() - start_cpu
    
    print(f"--&gt; [MUT] Real Time: {multi_real_time:.4f}s | CPU Time: {multi_cpu_time:.4f}s")
    print(f"--&gt; [MUT] Speedup: {seq_real_time / multi_real_time:.2f}x\n")

if __name__ == "__main__":
    run_benchmark()
</code></pre>
<h3>Analyzing the Results</h3>
<p>When you run this on standard Python vs. No-GIL Python, the numbers show a huge difference.</p>
<h4><strong>Standard Python (GIL Enabled)</strong></h4>
<ul>
<li><p><strong>Real Time:</strong> The sequential run takes about 10s. The multi-threaded run <em>also</em> takes around ~10s (sometimes slower due to lock overhead).</p>
</li>
<li><p><strong>OS CPU Time:</strong> Both runs show almost the same CPU time as Real Time. Only one core worked at any second. Threads were just taking turns.</p>
</li>
<li><p><strong>Speedup:</strong> 1x</p>
</li>
</ul>
<h4>Free-Threaded Python (GIL Disabled)</h4>
<ul>
<li><p><strong>Real Time:</strong> The sequential run takes 10s, but the multi-threaded run finishes in just under 3s - hooray it works!</p>
</li>
<li><p><strong>OS CPU Time:</strong> Here is the math proof—the CPU time spikes to almost 11s even though the clock only ticked for 3s. This is because the OS adds up CPU cycles of all 4 cores working at the exact same time.</p>
</li>
<li><p><strong>Speedup:</strong> ~=3.5 - 3.8x (almost perfect scaling on a 4-core CPU).</p>
</li>
</ul>
<h3>What about Network (I/O) tasks?</h3>
<p>While testing, there was an additional thought:</p>
<blockquote>
<p><em>What if our controller was mostly waiting on API n/w calls?</em></p>
</blockquote>
<p>As it turns out, <strong>n/w-bound apps don't really need No-GIL.</strong></p>
<p><em><strong>Standard Python automatically releases the GIL</strong></em> whenever a thread is waiting on n/w I/O (like waiting for a response from <code>etcd</code>). While Thread A waits for the n/w, Thread B grabs the GIL and runs. Since the CPU is mostly waiting, standard Python handles thousands of concurrent threads just fine.</p>
<p>But once the n/w data arrives, you still have to parse the JSON. In large scale clusters where you parse megabytes of JSON every second, that parsing step is heavy CPU work. In that hybrid scenario, a No-GIL build helps your threads parse those payloads in parallel across all your CPU cores.</p>
<h3>Wrap Up</h3>
<p>Testing the free-threaded interpreter was a great way to refresh my concurrency skills. Python is no longer locked to a single track; it can finally use modern multi-core CPUs natively- but of-course with strings attached :-)</p>
<p>If you build high-throughput tools, k8s operators, or data pipelines in Python, the No-GIL build is a massive step forward.</p>
]]></content:encoded></item><item><title><![CDATA[Synchronos Replication Does NOT Solve read-after-write consistency challenges]]></title><description><![CDATA[Owing to a bit of free time, I've been digging into my personal notebook from over the years, and putting some of my prior experiences on to paper. And today's one one is again straight outta the arch]]></description><link>https://abhishekpareek.dev/synchronos-replication-does-not-solve-read-after-write-consistency-challenges</link><guid isPermaLink="true">https://abhishekpareek.dev/synchronos-replication-does-not-solve-read-after-write-consistency-challenges</guid><category><![CDATA[database replication]]></category><category><![CDATA[PostgreSQL]]></category><category><![CDATA[System Design]]></category><category><![CDATA[wal]]></category><category><![CDATA[distributed systems]]></category><category><![CDATA[high availability]]></category><dc:creator><![CDATA[abhishek pareek]]></dc:creator><pubDate>Thu, 18 Apr 2024 21:55:00 GMT</pubDate><content:encoded><![CDATA[<p>Owing to a bit of free time, I've been digging into my personal notebook from over the years, and putting some of my prior experiences on to paper. And today's one one is again straight outta the archives!</p>
<p>When dealing with read-your-own-writes (RYOW) issues in a read-heavy app, the first suggestion is almost always to just turn on synchronous replication.</p>
<p>There is a (wide-spread?) misconception that If the primary db waits to ack the write until all replicas confirm they have saved the data, the RYOW problem gets solved. <strong>BUT, it is a massive trap, and</strong> exposes a fundamental misunderstanding of what "synchronous" actually means to the db engine.</p>
<p>In standard sync replication, the primary only waits for the replica to confirm it has written the transaction to its <strong>Write-Ahead Log (WAL)</strong> on disk.</p>
<blockquote>
<p>It guarantees durability, not visibility.</p>
</blockquote>
<p>The replica has safely saved the data, but it has not necessarily applied it to the actual tables in memory yet.</p>
<p>If a read query hits that replica 1ms after the ack returns, the user will still see stale data because the replica's local apply process hasn't caught up.</p>
<p>To guarantee true visibility, you have to force the primary to wait until the replica has fully applied the transaction to memory (like using <a href="https://www.postgresql.org/docs/18/runtime-config-wal.html#GUC-SYNCHRONOUS-COMMIT">remote_apply</a> in Postgres).</p>
<p>Configuring this extreme level of strict sync repl. not just adds latency penalties to the WRITE OP., it also ties write availability directly to the slowest read replica. If a single replica goes down, or the node crashes, the primary db literally stops accepting writes.</p>
<blockquote>
<p>A minor read staleness issue suddenly transforms into a complete system outage.</p>
</blockquote>
<p>Solving this at the db replication layer is the wrong approach. The routing layer is where this needs to be handled. There are two primary architectural patterns to fix this without tanking write performance.</p>
<h3>1. Sticky routing (simpler)</h3>
<p>When a user makes a write, a timestamp is dropped in their session cache. For a short window (say, 5 seconds), the app routes all of that specific user's reads directly to the primary db. Everyone else keeps hitting the replicas.</p>
<pre><code class="language-python"># Write operation
def update_profile(user_id, user_data):
    primary_db.write(user_data)
    # Drop a flag in cache that expires in 5 seconds
    cache.set(f"ryow_flag_{user_id}", True, ttl=5) 

# Read operation
def get_profile(user_id):
    # Check if the user recently wrote data
    if cache.get(f"ryow_flag_{user_id}"):
        return primary_db.read(user_id) # Safe fallback
        
    return replica_db.read(user_id) # Standard path
</code></pre>
<p><strong>Pros</strong>:</p>
<ul>
<li><p>Incredibly easy to implement.</p>
</li>
<li><p>Most frameworks support it out of the box or with simple middleware,</p>
</li>
<li><p>requiring zero changes to db engine internals.</p>
</li>
</ul>
<p><strong>Cons</strong>:</p>
<ul>
<li><p>It relies on arbitrary time windows, which is essentially just hoping for the best, and If repl. lag spikes past the defined window, the issue returns.</p>
</li>
<li><p>It also puts unnecessary read load on the primary db for those 5 seconds, even if the replicas caught up in 10ms.</p>
</li>
<li><p>gets worse with the number of WRITE ops scaling.</p>
</li>
<li><p>caching SPOF</p>
</li>
</ul>
<h3>2. LSN tracking (complicated, but precise way)</h3>
<p>Every write generates a log sequence number (<a href="https://www.postgresql.org/docs/current/wal-internals.html#WAL-INTERNALS">LSN</a>). The app stores that LSN in a cache, and queries it first in case of a read operation, essentially baking in the logic not to answer the query from a RR until it has processed the WAL up to this specific LSN. If the replica is too far behind, the app falls back to the primary db.</p>
<pre><code class="language-python"># Write operation
def update_profile(user_id, user_data):
    # Db returns the exact log position of the write
    lsn = primary_db.write_and_get_lsn(user_data) 
    cache.set(f"user_lsn_{user_id}", lsn)

# Read operation
def get_profile(user_id):
    required_lsn = cache.get(f"user_lsn_{user_id}")
    replica_lsn = replica_db.get_current_lsn()

    # Mathematically guarantee the replica has applied the WAL
    if replica_lsn &gt;= required_lsn:
        return replica_db.read(user_id)
        
    return primary_db.read(user_id) # Fallback if replica is lagging
</code></pre>
<p><strong>Pros</strong>:</p>
<ul>
<li><p>Mathematically perfect consistency.</p>
</li>
<li><p>Stale data is never served, and replica usage is maximized because routing to the primary db only happens if absolutely necessary.</p>
</li>
</ul>
<p><strong>Cons</strong>:</p>
<ul>
<li><p>Massive engineering effort.</p>
</li>
<li><p>It tightly couples app code to specific db engine internals,</p>
</li>
<li><p>and almost no standard ORMs support this natively.</p>
</li>
<li><p>the queries to the RR doubles.</p>
</li>
<li><p>caching SPOF</p>
</li>
</ul>
<p><strong>NOTE</strong>: There might be other (efficient) approaches too in addition but these 2 are the ones that I personally have come across.</p>
<h3>The takeaway</h3>
<p>Sticky routing often wins simply because the engineering overhead of building custom LSN trackers is rarely worth it for majority of RYOW apps.</p>
<p>To me accepting a slight bump in primary db load to keep app code simple is usually the right trade-off while keeping the primary db highly available.</p>
<p>Next time the db layer scales out, remember the golden rule of infra: <strong>never let read capacity compromise write availability.</strong></p>
]]></content:encoded></item><item><title><![CDATA[Why does time command may report more CPU time than the program actually ran?]]></title><description><![CDATA[I was cleaning up some old notes recently and came across one I'd written a few years ago while trying to understand Linux process accounting.
Back then I was doing a lot of perf-related work and benc]]></description><link>https://abhishekpareek.dev/why-does-time-command-may-report-more-cpu-time-than-the-program-actually-ran</link><guid isPermaLink="true">https://abhishekpareek.dev/why-does-time-command-may-report-more-cpu-time-than-the-program-actually-ran</guid><category><![CDATA[Linux]]></category><category><![CDATA[cpu]]></category><category><![CDATA[process_time]]></category><category><![CDATA[Linux Performance]]></category><category><![CDATA[Benchmark]]></category><category><![CDATA[os]]></category><dc:creator><![CDATA[abhishek pareek]]></dc:creator><pubDate>Fri, 23 Jun 2023 17:30:00 GMT</pubDate><content:encoded><![CDATA[<p>I was cleaning up some old notes recently and came across one I'd written a few years ago while trying to understand Linux process accounting.</p>
<p>Back then I was doing a lot of perf-related work and benchmarking, and I remember being confused by something I'd seen countless times:</p>
<pre><code class="language-bash">$ time ./my_program

real    2.1s
user    6.4s
sys     0.3s
</code></pre>
<p>How could a program that finished in just over <strong>2 seconds</strong> spend <strong>6 seconds</strong> on the CPU?</p>
<p>I dug into it at the time and made a few notes. Reading them again was a nice refresher, so I cleaned them up a bit and thought I'd share them here. Maybe they'll save someone else a trip down the same rabbit hole, or at the very least serve as a refresher to aa future me :-)</p>
<hr />
<h2>What does <code>time</code> actually measure?</h2>
<p>The linux <code>time</code> command prints three values:</p>
<pre><code class="language-text">real
user
sys
</code></pre>
<p>I used to look at them without thinking much about what each one actually meant.</p>
<h3><code>real</code></h3>
<p>This one's straightforward as It's just the wall-clock time.</p>
<blockquote>
<p>start the program -&gt; RUN -&gt; stop the program.</p>
</blockquote>
<p>And whatever a stopwatch would show is <code>real</code>.</p>
<p>It includes everything:</p>
<ul>
<li><p>CPU execution</p>
</li>
<li><p>waiting on disk</p>
</li>
<li><p>waiting on the network</p>
</li>
<li><p>sleeping</p>
</li>
<li><p>scheduler delays</p>
</li>
<li><p>lock contention</p>
</li>
</ul>
<p>Basically, the total time the process existed.</p>
<hr />
<h3><code>user</code></h3>
<p>This is where things started making more sense.</p>
<blockquote>
<p><code>user</code> is the amount of <strong>CPU time spent executing your application's code</strong>.</p>
</blockquote>
<p>Think any runtrime like Go, Rust, Python, Java, C++, etc. But do notice I said <strong>CPU time</strong>, not elapsed time.</p>
<hr />
<h3><code>sys</code></h3>
<blockquote>
<p><code>sys</code> is also CPU time, but it's time spent inside the Linux kernel on behalf of your process.</p>
</blockquote>
<p>Typical examples are:</p>
<ul>
<li><p><code>read()</code></p>
</li>
<li><p><code>write()</code></p>
</li>
<li><p><code>send()</code></p>
</li>
<li><p><code>recv()</code></p>
</li>
<li><p>page faults</p>
</li>
<li><p>filesystem work</p>
</li>
</ul>
<hr />
<h2>So why can <code>user</code> be larger than <code>real</code>?</h2>
<p>This was the part that finally made it click for me.</p>
<p>Imagine your machine has four CPU cores.</p>
<p>Your program spins up two worker threads and both stay busy for two seconds.</p>
<pre><code class="language-plaintext">Core 0  ████████ 2 sec

Core 1  ████████ 2 sec

Core 2  idle

Core 3  idle
</code></pre>
<p>The program still finishes after two seconds.</p>
<p>So:</p>
<pre><code class="language-plaintext">real = 2 sec
</code></pre>
<p>But Linux accounts CPU usage separately for each core.</p>
<pre><code class="language-plaintext">Core 0 = 2 sec
Core 1 = 2 sec

Total CPU time = 4 sec
</code></pre>
<p>Which means this is perfectly valid:</p>
<pre><code class="language-plaintext">real    2 sec
user    4 sec
</code></pre>
<p>CPU time is accumulated across every CPU executing your process.</p>
<hr />
<h2>What about everything else running on the machine?</h2>
<p>Another question I had was whether <code>time</code> includes CPU used by other applications.</p>
<p>Suppose your workstation has 32 cores.</p>
<p>Chrome is open.</p>
<p>Docker is busy.</p>
<p>Maybe a few k8s clusters are running locally.</p>
<p>None of that matters.</p>
<p><code>time</code> only reports CPU consumed by <strong>your process</strong> (and usually any child processes that it creates and waits for).</p>
<p>Everything else is ignored.</p>
<hr />
<h2>Python's <code>process_time()</code></h2>
<p>Python has something very similar.</p>
<pre><code class="language-python">import time
start = time.process_time()
do_work()
end = time.process_time()
</code></pre>
<p>This measures CPU time for the current process.</p>
<p>For example:</p>
<pre><code class="language-python">time.sleep(5)
</code></pre>
<p>will roughly look like this:</p>
<pre><code class="language-plaintext">Wall time ≈ 5 sec
CPU time ≈ 0 sec
</code></pre>
<p>because the process is sleeping, not executing instructions.The same idea applies to network I/O. f an HTTP request takes 500 ms but your process only spends 4 ms actually executing code, then:</p>
<pre><code class="language-plaintext">Wall time = 500 ms
CPU time = 4 ms
</code></pre>
<hr />
<h2>Which timer should I use?</h2>
<p>Use the timer that matches the question you're asking. For example, if you're trying to benchmark how much CPU your code consumes:</p>
<pre><code class="language-python">time.process_time()
</code></pre>
<p>makes sense.</p>
<p>If you're measuring request latency, API response times, or anything the user experiences:</p>
<pre><code class="language-python">time.perf_counter()
</code></pre>
<p>is usually the better choice as they're measuring two different things.</p>
<hr />
<h2>Final thoughts</h2>
<p>Looking back, this wasn't a particularly difficult concept. I just never had a reason to stop and think about what Linux was actually reporting.</p>
<blockquote>
<p>It's funny how some tools become muscle memory. we use them almost every day, but don't always understand what they're doing underneath.</p>
</blockquote>
<p>Anyway, I figured these old notes were worth keeping around. If you've ever looked at output like this:</p>
<pre><code class="language-text">real    2.0
user    7.3
sys     0.2
</code></pre>
<p>and wondered if something was broken, it probably isn't.</p>
<p>It usually just means your program managed to keep multiple CPU cores busy at the same time.</p>
]]></content:encoded></item></channel></rss>