<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Ronak Nathani</title><link>https://ronaknathani.pages.dev/</link><atom:link href="https://ronaknathani.pages.dev/index.xml" rel="self" type="application/rss+xml"/><description>Ronak Nathani</description><generator>Source Themes Academic (https://sourcethemes.com/academic/)</generator><language>en-us</language><copyright>© 2026 Ronak Nathani</copyright><lastBuildDate>Fri, 19 Jun 2026 11:00:00 -0400</lastBuildDate><image><url>https://ronaknathani.pages.dev/img/avatar.jpg</url><title>Ronak Nathani</title><link>https://ronaknathani.pages.dev/</link></image><item><title>Kubernetes Has No "Done": Modeling Rollout Progress and Success</title><link>https://ronaknathani.pages.dev/blog/2026/06/kubernetes-has-no-done-modeling-rollout-progress-and-success/</link><pubDate>Fri, 19 Jun 2026 11:00:00 -0400</pubDate><guid>https://ronaknathani.pages.dev/blog/2026/06/kubernetes-has-no-done-modeling-rollout-progress-and-success/</guid><description>&lt;p>&amp;ldquo;When should a rollout be considered successful?&amp;rdquo;&lt;/p>
&lt;p>The answer is that Kubernetes gives you the signals to decide, not the decision itself. A controller is not an operation that completes. It is an always-converging loop.&lt;/p>
&lt;p>To a user, a rollout is a concrete event: &amp;ldquo;I deployed version X. Did it succeed or fail?&amp;rdquo; The controller does not see it that way. It moves current state towards desired state and keeps reconciling whenever they drift apart. The moments when they match are snapshots, not an ending.&lt;/p>
&lt;p>A release pipeline cannot wait on &amp;ldquo;the controller is converging&amp;rdquo; forever. At some point it needs an answer for this specific rollout. Is it making progress? Did it reach the point where the next stage can safely begin? Or has it failed?&lt;/p>
&lt;p>Platforms built on Kubernetes need to synthesize this answer. In practice, the contract has two parts:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Progress&lt;/strong>: is the rollout still moving towards desired state? It tells an orchestrator whether to keep waiting, nothing more.&lt;/li>
&lt;li>&lt;strong>Verdict&lt;/strong>: has the rollout reached a terminal outcome, succeeded or failed, that a pipeline can branch on?&lt;/li>
&lt;/ol>
&lt;p>Progress tells the platform whether to keep waiting. The verdict tells the pipeline whether it can continue. Success means the rollout reached the desired state and held it. Failure means progress stopped or the platform observed a terminal failure.&lt;/p>
&lt;h2 id="what-is-progress-really">What Is Progress, Really?&lt;/h2>
&lt;p>In a converging system, progress means that the current state is moving towards the desired state. For a rolling update (and most other cases), it usually comes down to these observable transitions:&lt;/p>
&lt;ul>
&lt;li>More replicas have been created from the new pod template.&lt;/li>
&lt;li>Old replicas have been deleted.&lt;/li>
&lt;li>More replicas from the new pod template have become available.&lt;/li>
&lt;/ul>
&lt;p>As long as one of these transitions keeps happening, the rollout is making progress. It may be slow, but it is not stuck. Each observed transition resets the clock on progress. The question is not how long the rollout has taken in total, but how long it has been since anything meaningful changed. For Deployment-style rolling updates, the pace of a rollout is shaped by &lt;code>maxSurge&lt;/code> and &lt;code>maxUnavailable&lt;/code>. What the platform watches for is whether any of these transitions is still happening at all.&lt;/p>
&lt;p>&lt;strong>In an always-converging system, progress is the primitive you can define. Failure is derived from the absence of progress.&lt;/strong>&lt;/p>
&lt;p>For a discrete rollout result, the platform eventually has to stop waiting and emit a failed verdict for that generation. The controller may keep reconciling, but &amp;ldquo;still reconciling&amp;rdquo; is no longer useful information for the upstream system waiting on a verdict.&lt;/p>
&lt;p>Conditions are how the platform exposes that contract:&lt;/p>
&lt;div class="rollout-condition-states">
&lt;style>
.rollout-condition-states {
margin: 1.5em 0;
width: 100vw;
position: relative;
left: 50%;
transform: translateX(-50%);
max-width: 1050px;
}
.rollout-condition-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
}
.rollout-condition-card {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-left-width: 6px;
border-radius: 8px;
padding: 14px 16px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06);
}
.rollout-condition-card.state-progress { border-left-color: #f59e0b; }
.rollout-condition-card.state-success { border-left-color: #22c55e; }
.rollout-condition-card.state-failure { border-left-color: #ef4444; }
.rollout-condition-title {
margin: 0 0 10px;
font-size: 13px;
font-weight: 700;
color: #1e293b;
letter-spacing: 0.01em;
}
.rollout-condition-kv {
margin: 0;
padding: 0;
background: transparent;
color: #334155;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 12px;
line-height: 1.55;
}
.rollout-condition-row {
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
column-gap: 8px;
}
.rollout-condition-key {
white-space: nowrap;
}
.rollout-condition-value {
min-width: 0;
overflow-wrap: anywhere;
}
.rollout-condition-caption {
margin-top: 10px;
text-align: center;
font-size: 12px;
color: #64748b;
}
@media (max-width: 800px) {
.rollout-condition-grid {
grid-template-columns: 1fr;
}
}
&lt;/style>
&lt;div class="rollout-condition-grid">
&lt;div class="rollout-condition-card state-progress">
&lt;p class="rollout-condition-title">Progress&lt;/p>
&lt;div class="rollout-condition-kv">
&lt;div class="rollout-condition-row">&lt;span class="rollout-condition-key">type:&lt;/span>&lt;span class="rollout-condition-value">Converged&lt;/span>&lt;/div>
&lt;div class="rollout-condition-row">&lt;span class="rollout-condition-key">status:&lt;/span>&lt;span class="rollout-condition-value">Unknown&lt;/span>&lt;/div>
&lt;div class="rollout-condition-row">&lt;span class="rollout-condition-key">observedGeneration:&lt;/span>&lt;span class="rollout-condition-value">N&lt;/span>&lt;/div>
&lt;div class="rollout-condition-row">&lt;span class="rollout-condition-key">reason:&lt;/span>&lt;span class="rollout-condition-value">Progressing&lt;/span>&lt;/div>
&lt;div class="rollout-condition-row">&lt;span class="rollout-condition-key">message:&lt;/span>&lt;span class="rollout-condition-value">progress observed&lt;/span>&lt;/div>
&lt;/div>
&lt;/div>
&lt;div class="rollout-condition-card state-success">
&lt;p class="rollout-condition-title">Success&lt;/p>
&lt;div class="rollout-condition-kv">
&lt;div class="rollout-condition-row">&lt;span class="rollout-condition-key">type:&lt;/span>&lt;span class="rollout-condition-value">Converged&lt;/span>&lt;/div>
&lt;div class="rollout-condition-row">&lt;span class="rollout-condition-key">status:&lt;/span>&lt;span class="rollout-condition-value">True&lt;/span>&lt;/div>
&lt;div class="rollout-condition-row">&lt;span class="rollout-condition-key">observedGeneration:&lt;/span>&lt;span class="rollout-condition-value">N&lt;/span>&lt;/div>
&lt;div class="rollout-condition-row">&lt;span class="rollout-condition-key">reason:&lt;/span>&lt;span class="rollout-condition-value">DesiredStateObserved&lt;/span>&lt;/div>
&lt;div class="rollout-condition-row">&lt;span class="rollout-condition-key">message:&lt;/span>&lt;span class="rollout-condition-value">desired state observed&lt;/span>&lt;/div>
&lt;/div>
&lt;/div>
&lt;div class="rollout-condition-card state-failure">
&lt;p class="rollout-condition-title">Failure&lt;/p>
&lt;div class="rollout-condition-kv">
&lt;div class="rollout-condition-row">&lt;span class="rollout-condition-key">type:&lt;/span>&lt;span class="rollout-condition-value">Converged&lt;/span>&lt;/div>
&lt;div class="rollout-condition-row">&lt;span class="rollout-condition-key">status:&lt;/span>&lt;span class="rollout-condition-value">False&lt;/span>&lt;/div>
&lt;div class="rollout-condition-row">&lt;span class="rollout-condition-key">observedGeneration:&lt;/span>&lt;span class="rollout-condition-value">N&lt;/span>&lt;/div>
&lt;div class="rollout-condition-row">&lt;span class="rollout-condition-key">reason:&lt;/span>&lt;span class="rollout-condition-value">NoProgressWithinBudget&lt;/span>&lt;/div>
&lt;div class="rollout-condition-row">&lt;span class="rollout-condition-key">message:&lt;/span>&lt;span class="rollout-condition-value">no progress within budget&lt;/span>&lt;/div>
&lt;/div>
&lt;/div>
&lt;/div>
&lt;div class="rollout-condition-caption">
Each condition is scoped to the rollout generation the upstream system is evaluating.
&lt;/div>
&lt;/div>
&lt;p>Kubernetes Deployments expose a similar idea through the &lt;code>Progressing&lt;/code> condition.&lt;/p>
&lt;p>The exact reason names are less important than the shape of the contract. The controller exposes a generation-scoped condition that upstream systems can trust.&lt;/p>
&lt;p>That does not mean the &lt;code>Converged&lt;/code> condition becomes sticky once it reaches &lt;code>True&lt;/code> or &lt;code>False&lt;/code>. It should reflect the controller&amp;rsquo;s current view of reality. When the controller observes new state, the condition should change with it. The terminal verdict is for the upstream system that needed a decision at that point in the rollout.&lt;/p>
&lt;p>A subtle but important detail: any orchestration system reading a condition needs to make sure it is looking at status for the current spec. When a condition carries &lt;code>observedGeneration&lt;/code>&lt;sup id="fnref:1">&lt;a href="#fn:1" class="footnote-ref" role="doc-noteref">1&lt;/a>&lt;/sup>, it should match the resource &lt;code>metadata.generation&lt;/code>. Otherwise the system may be reading a stale status capturing the result of a previous rollout.&lt;/p>
&lt;h2 id="the-progress-deadline-defines-failure-but-it-is-not-the-right-user-knob">The Progress Deadline Defines Failure, but It Is Not the Right User Knob&lt;/h2>
&lt;p>Kubernetes exposes this idea through
&lt;a href="https://kubernetes.io/docs/concepts/workloads/controllers/deployment/#progress-deadline-seconds" target="_blank" rel="noopener">&lt;code>progressDeadlineSeconds&lt;/code>&lt;/a> on &lt;code>Deployment&lt;/code> objects. It is the amount of time the Deployment controller waits for new progress before setting the &lt;code>Progressing&lt;/code> condition to &lt;code>False&lt;/code> with reason &lt;code>ProgressDeadlineExceeded&lt;/code>.&lt;/p>
&lt;p>The concept is a useful primitive for the platform, but not the right user-facing knob. What users need is &lt;strong>for the platform to apply a progress budget: how long the platform should wait without meaningful movement before classifying the rollout as failed for orchestration purposes&lt;/strong>.&lt;/p>
&lt;p>That budget is rarely something an application team can set well on its own. The application contributes important inputs, but it does not own the whole equation. A good budget combines what the workload declares with what the platform guarantees.&lt;/p>
&lt;p>Some parts come from the workload:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Startup timeout&lt;/strong>: how long the container may take to start.&lt;/li>
&lt;li>&lt;strong>Readiness behavior&lt;/strong>: how long the container may take before it can serve traffic.&lt;/li>
&lt;li>&lt;strong>&lt;code>minReadySeconds&lt;/code>&lt;/strong>: how long a pod should remain ready before counting as available.&lt;/li>
&lt;/ul>
&lt;p>Other parts come from the platform:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Scheduling latency&lt;/strong>: how long it takes to bind a pod to a node.&lt;/li>
&lt;li>&lt;strong>Pool autoscaling&lt;/strong>: how long it takes to add capacity, if needed, when the rollout triggers a scale-up.&lt;/li>
&lt;li>&lt;strong>Node provisioning&lt;/strong>: how long newly requested machines take to join the cluster.&lt;/li>
&lt;li>&lt;strong>Image distribution&lt;/strong>: image size, pull concurrency, and node cache state.&lt;/li>
&lt;/ul>
&lt;p>When we built a CRD for our workloads, exposing this as a user-configurable field seemed natural. Kubernetes has one, so ours should too. But that field made a bad claim: that application teams could choose the progress deadline correctly, even though they only had visibility into part of it.&lt;/p>
&lt;p>The failure mode is concrete. If the progress deadline is shorter than a container takes to start up, the rollout fails even though nothing is broken. A validation webhook can catch obvious mistakes, like a deadline shorter than the configured startup and readiness probes, but it cannot model the platform side of the budget.&lt;/p>
&lt;p>A better contract is to take the workload-owned inputs and let the platform calculate the deadline from the SLOs it holds for each phase of the rollout path. In practice you can start with percentile bounds for each phase and add them up: scheduling, capacity provisioning, image pull, container startup, readiness, and &lt;code>minReadySeconds&lt;/code>. It is not a perfect statistical model. &lt;strong>It is an operational budget for how long &amp;ldquo;no progress&amp;rdquo; should be tolerated before the rollout is classified as failed.&lt;/strong>&lt;/p>
&lt;p>The budget is a ceiling, not a fixed wait. Some failures are terminal, and waiting out the rest of the budget buys nothing. If the application container is crashing, no amount of additional scheduling, provisioning, or image-pull time will change the outcome, so we exit early and mark the rollout failed rather than burn the remaining budget.&lt;/p>
&lt;div class="rollout-lifecycle-embed">
&lt;style>
.rollout-lifecycle-embed { margin: 1.6em 0; }
.rollout-lifecycle-embed iframe {
width: 100%;
height: 490px;
border: 0;
display: block;
}
@media (max-width: 560px) {
.rollout-lifecycle-embed iframe { height: 620px; }
}
&lt;/style>
&lt;iframe src="https://ronaknathani.pages.dev/rollout-lifecycle.html" title="Interactive rollout lifecycle: progress refills the budget, and the verdict is success or failure" loading="lazy" scrolling="no">&lt;/iframe>
&lt;/div>
&lt;p>When the budget is exhausted, or a terminal failure is detected, the verdict should explain where the rollout stopped, not just that a deadline passed. We categorize failures based on pod lifecycle and conditions: things like waiting for scheduling, a platform init container failing, an unpullable image reference, or the application container crashing. That taxonomy and the observability around it is a post of its own. My colleague
&lt;a href="https://www.linkedin.com/in/vasudevbongale/" target="_blank" rel="noopener">Vasudev Bongale&lt;/a> gave an excellent
&lt;a href="https://www.youtube.com/watch?v=8QS3hmsDjNE" target="_blank" rel="noopener">talk at KubeCon NA 2025 on making rollouts observable&lt;/a>, and I&amp;rsquo;d highly recommend it if you&amp;rsquo;re building a compute platform.&lt;/p>
&lt;h2 id="success-is-a-snapshot-contract">Success Is a Snapshot Contract&lt;/h2>
&lt;p>A rollout also needs a success signal, but success is different from progress. Progress tells an upstream system that continued waiting is justified. Success tells it that this rollout has reached a snapshot it can safely branch on.&lt;/p>
&lt;p>A rollout is rarely the end of a pipeline. It may be followed by canary analysis, promotion to another stage, or rollout to the next production environment. Each of those steps needs a discrete answer before it can move.&lt;/p>
&lt;p>Live status is still useful. It lets users see where the rollout is right now. But a pipeline needs a decision: continue, fail, or promote.&lt;/p>
&lt;p>A useful definition of success is that the available replicas on the new version have reached the desired count. But a point-in-time match can be fragile. A pod can pass its readiness probe once and fail it a moment later. Treating that instant as success can promote a rollout that will end poorly.&lt;/p>
&lt;h3 id="use-minreadyseconds">Use &lt;code>minReadySeconds&lt;/code>&lt;/h3>
&lt;p>Kubernetes gives you &lt;code>minReadySeconds&lt;/code> for this. When configured, a pod must stay ready for that duration before it counts as available. The default is zero, so the default bar is still &amp;ldquo;ready once.&amp;rdquo; Setting it raises the bar to &amp;ldquo;ready and not flapping,&amp;rdquo; which is much closer to what a rollout success verdict should mean.&lt;/p>
&lt;p>Even then, success is not a permanent guarantee. Pods can become unhealthy later, nodes can fail, or the desired state can change again. That does not mean the rollout never succeeded.&lt;/p>
&lt;p>This distinction matters operationally. Rollout success is not the same as ongoing workload health. A rollout answers whether a specific version transition reached its success criteria. Workload health answers whether the running system is healthy now. A pod can become unhealthy after a successful rollout, and that should be handled by health monitoring, remediation, or a new rollout decision. It should not retroactively change the verdict for the rollout that already completed.&lt;/p>
&lt;h2 id="the-api-lesson">The API Lesson&lt;/h2>
&lt;p>The API design lesson we have learned several times:&lt;/p>
&lt;blockquote>
&lt;p>Don&amp;rsquo;t expose a knob just because the system underneath has one.&lt;/p>
&lt;/blockquote>
&lt;p>A good platform API should not be a faithful mirror of Kubernetes. It should expose the outcomes users and higher-level systems need, while the platform owns the machinery required to compute them.&lt;/p>
&lt;p>That is the job of a compute platform: turn a continuously reconciling substrate into contracts people can reason about.&lt;/p>
&lt;section class="footnotes" role="doc-endnotes">
&lt;hr>
&lt;ol>
&lt;li id="fn:1" role="doc-endnote">
&lt;p>Core Kubernetes &lt;code>Deployment&lt;/code> conditions do not actually carry an &lt;code>observedGeneration&lt;/code>. Only &lt;code>status.observedGeneration&lt;/code> at the top level does. That is not ideal, because a client reading a single condition cannot tell which generation produced it and can act on stale data. In our platform we put &lt;code>observedGeneration&lt;/code> on every condition and pay close attention to it, since the conditions are a large part of the contract that makes rollout state observable to clients. &lt;a href="#fnref:1" class="footnote-backref" role="doc-backlink">&amp;#x21a9;&amp;#xfe0e;&lt;/a>&lt;/p>
&lt;/li>
&lt;/ol>
&lt;/section></description></item><item><title>Keeping GPU Workloads NUMA-Local in Kubernetes</title><link>https://ronaknathani.pages.dev/blog/2026/05/keeping-gpu-workloads-numa-local-in-kubernetes/</link><pubDate>Tue, 26 May 2026 11:00:00 -0400</pubDate><guid>https://ronaknathani.pages.dev/blog/2026/05/keeping-gpu-workloads-numa-local-in-kubernetes/</guid><description>&lt;p>&amp;ldquo;NUMA alignment&amp;rdquo; comes up frequently in GPU infrastructure discussions, but concepts like NUMA nodes, topology policies, and CPU pinning are often assumed rather than well understood. Getting it right is as much the platform engineer&amp;rsquo;s job as the workload owner&amp;rsquo;s.&lt;/p>
&lt;p>This post isn&amp;rsquo;t a comprehensive guide to NUMA architecture. It&amp;rsquo;s a practical account of what happens when you align CPU and GPU resources on Kubernetes nodes: the levels of isolation Kubernetes offers, the gotchas, and what it takes to make it work. My experience is on AMD EPYC hardware. Intel has analogous concepts (Sub-NUMA Clustering instead of NPS, UPI instead of Infinity Fabric), but I haven&amp;rsquo;t worked with Intel in this context, so I&amp;rsquo;ll stick to what I know.&lt;/p>
&lt;h2 id="table-of-contents">Table of Contents&lt;/h2>
&lt;ul>
&lt;li>
&lt;a href="#key-terminology">Key Terminology&lt;/a>&lt;/li>
&lt;li>
&lt;a href="#what-is-numa">What is NUMA?&lt;/a>&lt;/li>
&lt;li>
&lt;a href="#why-numa-matters-for-gpu-workloads">Why NUMA Matters for GPU Workloads&lt;/a>&lt;/li>
&lt;li>
&lt;a href="#increasing-levels-of-cpu-isolation-and-numa-alignment">Levels of CPU Isolation and NUMA Alignment&lt;/a>&lt;/li>
&lt;li>
&lt;a href="#how-the-kubelet-allocates-cpus">How the Kubelet Allocates CPUs&lt;/a>&lt;/li>
&lt;li>
&lt;a href="#failure-modes-to-be-aware-of">Failure Modes&lt;/a>&lt;/li>
&lt;li>
&lt;a href="#topology-aware-scheduling">Topology-Aware Scheduling&lt;/a>&lt;/li>
&lt;li>
&lt;a href="#making-it-work-collaboration-between-platform-and-workload-teams">Making It Work&lt;/a>&lt;/li>
&lt;li>
&lt;a href="#dra-and-future-direction">DRA and Future Direction&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="key-terminology">Key Terminology&lt;/h2>
&lt;p>If you&amp;rsquo;re already familiar with CPU cache hierarchies, sockets, and PCIe, skip ahead. Otherwise, expand below for the shared vocabulary used throughout the post.&lt;/p>
&lt;details>
&lt;summary>Show terminology&lt;/summary>
&lt;p>&lt;strong>CPU Socket&lt;/strong>: The physical slot on a motherboard that holds a processor. Multi-socket servers (commonly 2-socket) have multiple processors, each with its own cores and local memory.&lt;/p>
&lt;p>&lt;strong>Physical Core vs. Logical Core&lt;/strong>: A physical core is a single processing unit on the CPU die. With
&lt;a href="https://en.wikipedia.org/wiki/Simultaneous_multithreading" target="_blank" rel="noopener">SMT&lt;/a> (&amp;ldquo;hyperthreading&amp;rdquo; on Intel), each physical core presents as 2 logical cores that share the core&amp;rsquo;s execution resources and caches.&lt;/p>
&lt;p>&lt;strong>L1/L2 Cache&lt;/strong>: Small, fast caches private to each physical core (L1 is smaller and faster than L2). Two containers sharing a physical core, one logical core each, compete for the same L1/L2 space.&lt;/p>
&lt;p>&lt;strong>L3 Cache (Last-Level Cache)&lt;/strong>: A larger cache shared among a group of cores. On AMD EPYC, it&amp;rsquo;s shared within a
&lt;a href="https://en.wikipedia.org/wiki/Chiplet#AMD" target="_blank" rel="noopener">Core Complex (CCD)&lt;/a> of typically 8 cores. Cores sharing an L3 cache can exchange data quickly through it.&lt;/p>
&lt;p>&lt;strong>Interconnect&lt;/strong>: The high-speed link between CPU sockets (
&lt;a href="https://en.wikipedia.org/wiki/Infinity_Fabric" target="_blank" rel="noopener">Infinity Fabric&lt;/a> on AMD,
&lt;a href="https://en.wikipedia.org/wiki/Intel_Ultra_Path_Interconnect" target="_blank" rel="noopener">UPI&lt;/a> on Intel). Accessing memory locally is faster than going cross-socket over the interconnect.&lt;/p>
&lt;p>&lt;strong>
&lt;a href="https://en.wikipedia.org/wiki/PCI_Express" target="_blank" rel="noopener">PCIe&lt;/a>&lt;/strong>: The bus connecting CPUs to devices like GPUs and NICs. Each PCIe root complex is wired to a specific CPU socket, so a GPU is physically closer to one socket than another.&lt;/p>
&lt;p>&lt;strong>
&lt;a href="https://en.wikipedia.org/wiki/Direct_memory_access" target="_blank" rel="noopener">DMA&lt;/a>&lt;/strong>: Lets devices like GPUs read from and write to system memory directly, without the CPU copying data byte-by-byte. If the data sits in memory attached to a different NUMA node than the GPU, the DMA read crosses the interconnect.&lt;/p>
&lt;/details>
&lt;h2 id="what-is-numa">What is NUMA?&lt;/h2>
&lt;p>NUMA (Non-Uniform Memory Access) describes a memory architecture where the time it takes a CPU core to access memory depends on where that memory physically sits relative to the core.&lt;/p>
&lt;p>In a 2-socket server, each socket has its own local memory. A core on socket 0 can access memory attached to socket 0 quickly (local access), but accessing memory attached to socket 1 requires crossing the interconnect, which is slower. On AMD EPYC hardware, cross-socket memory access can incur roughly 3x the latency of local access.&lt;/p>
&lt;p>NUMA doesn&amp;rsquo;t only exist across sockets, though. On AMD EPYC processors, a BIOS setting called NPS (Nodes Per Socket) controls how many NUMA domains a single socket is divided into:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>NPS1&lt;/strong>: Each socket is one NUMA node. A 2-socket machine has 2 NUMA nodes.&lt;/li>
&lt;li>&lt;strong>NPS2&lt;/strong>: Each socket is split into 2 NUMA nodes. A 2-socket machine has 4 NUMA nodes.&lt;/li>
&lt;li>&lt;strong>NPS4&lt;/strong>: Each socket is split into 4 NUMA nodes. A 2-socket machine has 8 NUMA nodes.&lt;/li>
&lt;/ul>
&lt;p>The interactive diagram below shows a simplified view of a 2-socket AMD EPYC machine. Toggle between NPS modes to see how the NUMA boundaries change. In NPS1, each socket is one NUMA node. In NPS2 and NPS4, each socket is further subdivided. The CCD and GPU placement is illustrative, not a promise about every SKU.&lt;/p>
&lt;div class="numa-topo-widget">
&lt;style>
.numa-topo-widget {
margin: 1.5em 0;
width: 100vw;
position: relative;
left: 50%;
transform: translateX(-50%);
max-width: 1100px;
}
.numa-topo-widget svg { width: 100%; height: auto; display: block; }
.numa-topo-controls {
display: flex;
gap: 12px;
margin-bottom: 16px;
justify-content: center;
flex-wrap: wrap;
}
.numa-topo-controls button {
padding: 6px 16px;
border: 2px solid #2563eb;
background: #fff;
color: #2563eb;
font-size: 13px;
font-weight: 600;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s;
font-family: inherit;
}
.numa-topo-controls button.active {
background: #2563eb;
color: #fff;
}
.numa-topo-controls button:hover:not(.active) {
background: #eff6ff;
}
&lt;/style>
&lt;div class="numa-topo-controls">
&lt;button class="active" onclick="numaTopoSetMode('nps1', this)">NPS1 (1 NUMA / socket)&lt;/button>
&lt;button onclick="numaTopoSetMode('nps2', this)">NPS2 (2 NUMA / socket)&lt;/button>
&lt;button onclick="numaTopoSetMode('nps4', this)">NPS4 (4 NUMA / socket)&lt;/button>
&lt;/div>
&lt;div id="numa-topo-diagram">&lt;/div>
&lt;/div>
&lt;script>
(function() {
var COLORS = {
numa: ['#dbeafe', '#fce7f3', '#d1fae5', '#fef3c7', '#e0e7ff', '#ede9fe', '#fce4ec', '#e8f5e9'],
numaBorder: ['#2563eb', '#db2777', '#059669', '#d97706', '#4f46e5', '#7c3aed', '#e91e63', '#2e7d32'],
socket: '#f8fafc',
socketBorder: '#94a3b8',
ccd: '#f1f5f9',
ccdBorder: '#cbd5e1',
gpu: '#1e293b',
gpuText: '#ffffff',
memory: '#e2e8f0',
memoryBorder: '#94a3b8',
interconnect: '#ef4444',
text: '#1e293b',
textMuted: '#64748b',
pcie: '#475569'
};
var currentMode = 'nps1';
window.numaTopoSetMode = function(mode, btn) {
currentMode = mode;
var buttons = document.querySelectorAll('.numa-topo-controls button');
for (var i = 0; i &lt; buttons.length; i++) buttons[i].classList.remove('active');
btn.classList.add('active');
render();
};
function render() {
var el = document.getElementById('numa-topo-diagram');
var socketW = 480, socketH = 420, gap = 100;
var padX = 30, padY = 30;
var svgW = socketW * 2 + gap + padX * 2;
var svgH = socketH + padY * 2 + 60;
var numaPerSocket = currentMode === 'nps1' ? 1 : currentMode === 'nps2' ? 2 : 4;
var ccdsPerSocket = 4, coresPerCCD = 8, gpusPerSocket = 4;
var h = '&lt;svg viewBox="0 0 ' + svgW + ' ' + svgH + '" xmlns="http://www.w3.org/2000/svg">';
h += '&lt;defs>&lt;filter id="nt-shadow" x="-2%" y="-2%" width="104%" height="104%">&lt;feDropShadow dx="0" dy="1" stdDeviation="2" flood-opacity="0.08"/>&lt;/filter>';
h += '&lt;marker id="nt-arrow" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">&lt;polygon points="0 0, 8 3, 0 6" fill="' + COLORS.interconnect + '"/>&lt;/marker>&lt;/defs>';
for (var sock = 0; sock &lt; 2; sock++) {
var sx = padX + sock * (socketW + gap);
var sy = padY;
h += '&lt;rect x="' + sx + '" y="' + sy + '" width="' + socketW + '" height="' + socketH + '" rx="12" fill="' + COLORS.socket + '" stroke="' + COLORS.socketBorder + '" stroke-width="2" filter="url(#nt-shadow)"/>';
h += '&lt;text x="' + (sx + socketW/2) + '" y="' + (sy + 24) + '" text-anchor="middle" font-size="15" font-weight="700" fill="' + COLORS.text + '">Socket ' + sock + '&lt;/text>';
var numaCols = numaPerSocket &lt;= 2 ? numaPerSocket : 2;
var numaRows = numaPerSocket &lt;= 2 ? 1 : 2;
var numaW = (socketW - 20) / numaCols;
var numaH = numaRows === 1 ? socketH - 85 : (socketH - 100) / 2;
for (var n = 0; n &lt; numaPerSocket; n++) {
var numaIdx = sock * numaPerSocket + n;
var col = n % numaCols;
var row = Math.floor(n / numaCols);
var nx = sx + 10 + col * numaW;
var ny = sy + 36 + row * (numaH + 8);
h += '&lt;rect x="' + nx + '" y="' + ny + '" width="' + (numaW - 4) + '" height="' + numaH + '" rx="8" fill="' + COLORS.numa[numaIdx % 8] + '" stroke="' + COLORS.numaBorder[numaIdx % 8] + '" stroke-width="1.5" stroke-dasharray="6,3"/>';
h += '&lt;text x="' + (nx + (numaW - 4)/2) + '" y="' + (ny + 18) + '" text-anchor="middle" font-size="12" font-weight="700" fill="' + COLORS.numaBorder[numaIdx % 8] + '">NUMA Node ' + numaIdx + '&lt;/text>';
var ccdsInNuma = ccdsPerSocket / numaPerSocket;
var ccdW = (numaW - 20) / ccdsInNuma;
var ccdH = numaRows === 1 ? 120 : 80;
for (var c = 0; c &lt; ccdsInNuma; c++) {
var cx = nx + 8 + c * ccdW;
var cy = ny + 28;
var globalCcdIdx = sock * ccdsPerSocket + n * ccdsInNuma + c;
h += '&lt;rect x="' + cx + '" y="' + cy + '" width="' + (ccdW - 4) + '" height="' + ccdH + '" rx="6" fill="' + COLORS.ccd + '" stroke="' + COLORS.ccdBorder + '" stroke-width="1"/>';
h += '&lt;text x="' + (cx + (ccdW-4)/2) + '" y="' + (cy + 16) + '" text-anchor="middle" font-size="10" font-weight="600" fill="' + COLORS.textMuted + '">CCD ' + globalCcdIdx + '&lt;/text>';
var coreR = coresPerCCD &lt;= 4 ? 1 : 2;
var coreC = Math.ceil(coresPerCCD / coreR);
var coreSize = Math.min((ccdW - 16) / coreC, (ccdH - 50) / coreR) - 2;
var coreStartX = cx + ((ccdW - 4) - coreC * (coreSize + 2)) / 2;
var coreStartY = cy + 22;
for (var cr = 0; cr &lt; coreR; cr++) {
for (var cc = 0; cc &lt; coreC; cc++) {
if (cr * coreC + cc >= coresPerCCD) break;
h += '&lt;rect x="' + (coreStartX + cc * (coreSize+2)) + '" y="' + (coreStartY + cr * (coreSize+2)) + '" width="' + coreSize + '" height="' + coreSize + '" rx="2" fill="#fff" stroke="' + COLORS.ccdBorder + '" stroke-width="0.75"/>';
}
}
h += '&lt;text x="' + (cx + (ccdW-4)/2) + '" y="' + (cy + ccdH - 8) + '" text-anchor="middle" font-size="9" fill="' + COLORS.textMuted + '">L3: 32MB&lt;/text>';
}
var memY = ny + 28 + ccdH + 8;
var memW = numaW - 20;
var memH = numaRows === 1 ? 28 : 22;
h += '&lt;rect x="' + (nx+8) + '" y="' + memY + '" width="' + memW + '" height="' + memH + '" rx="4" fill="' + COLORS.memory + '" stroke="' + COLORS.memoryBorder + '" stroke-width="1"/>';
h += '&lt;text x="' + (nx+8+memW/2) + '" y="' + (memY + (numaRows===1?18:15)) + '" text-anchor="middle" font-size="' + (numaRows===1?11:9) + '" font-weight="500" fill="' + COLORS.textMuted + '">Local Memory&lt;/text>';
var gpusInNuma = gpusPerSocket / numaPerSocket;
var gpuY = memY + (numaRows === 1 ? 36 : 28);
var gpuW = Math.min(40, (numaW - 20) / gpusInNuma - 4);
var gpuTotalW = gpusInNuma * (gpuW + 4) - 4;
var gpuStartX = nx + 8 + (memW - gpuTotalW) / 2;
h += '&lt;text x="' + (nx+8+memW/2) + '" y="' + (gpuY-4) + '" text-anchor="middle" font-size="9" fill="' + COLORS.pcie + '">PCIe&lt;/text>';
for (var g = 0; g &lt; gpusInNuma; g++) {
var gx = gpuStartX + g * (gpuW + 4);
var gpuIdx = sock * gpusPerSocket + n * gpusInNuma + g;
h += '&lt;rect x="' + gx + '" y="' + gpuY + '" width="' + gpuW + '" height="' + (numaRows===1?26:20) + '" rx="4" fill="' + COLORS.gpu + '"/>';
h += '&lt;text x="' + (gx+gpuW/2) + '" y="' + (gpuY+(numaRows===1?17:13)) + '" text-anchor="middle" font-size="' + (numaRows===1?10:8) + '" font-weight="600" fill="' + COLORS.gpuText + '">GPU ' + gpuIdx + '&lt;/text>';
}
if (numaIdx === 0 &amp;&amp; numaRows === 1) {
var legY = ny + 28 + ccdH + 8 + 28 + 8 + 26 + 16;
if (legY &lt; ny + numaH - 10) {
h += '&lt;text x="' + (nx+(numaW-4)/2) + '" y="' + legY + '" text-anchor="middle" font-size="9" fill="' + COLORS.textMuted + '">' + coresPerCCD + ' cores/CCD, 2 threads/core&lt;/text>';
}
}
}
var totalVCPUs = ccdsPerSocket * coresPerCCD * 2;
h += '&lt;text x="' + (sx+socketW/2) + '" y="' + (sy+socketH-8) + '" text-anchor="middle" font-size="11" fill="' + COLORS.textMuted + '">' + totalVCPUs + ' vCPUs (' + (ccdsPerSocket*coresPerCCD) + ' cores x 2 threads)&lt;/text>';
}
var interY = padY + socketH / 2;
var interX1 = padX + socketW + 4;
var interX2 = padX + socketW + gap - 4;
var interMid = (interX1 + interX2) / 2;
h += '&lt;line x1="' + interX1 + '" y1="' + (interY-6) + '" x2="' + interX2 + '" y2="' + (interY-6) + '" stroke="' + COLORS.interconnect + '" stroke-width="2.5" marker-end="url(#nt-arrow)"/>';
h += '&lt;line x1="' + interX2 + '" y1="' + (interY+6) + '" x2="' + interX1 + '" y2="' + (interY+6) + '" stroke="' + COLORS.interconnect + '" stroke-width="2.5" marker-end="url(#nt-arrow)"/>';
h += '&lt;text x="' + interMid + '" y="' + (interY-16) + '" text-anchor="middle" font-size="10" font-weight="600" fill="' + COLORS.interconnect + '">Interconnect&lt;/text>';
h += '&lt;text x="' + interMid + '" y="' + (interY+24) + '" text-anchor="middle" font-size="9" fill="' + COLORS.interconnect + '">(Infinity Fabric)&lt;/text>';
var totalNuma = numaPerSocket * 2;
var npsLabel = currentMode.toUpperCase();
h += '&lt;text x="' + (svgW/2) + '" y="' + (svgH-12) + '" text-anchor="middle" font-size="13" fill="' + COLORS.textMuted + '">2-socket AMD EPYC, ' + npsLabel + ' mode (' + totalNuma + ' NUMA nodes, 8 GPUs)&lt;/text>';
h += '&lt;/svg>';
el.innerHTML = h;
}
render();
})();
&lt;/script>
&lt;p>&lt;strong>The key point&lt;/strong>: NUMA topology is a function of both the hardware and how it&amp;rsquo;s configured. You can&amp;rsquo;t assume a fixed number of NUMA nodes across your fleet unless you control the BIOS settings. Different SKUs have different core counts, different numbers of CCDs, and different NPS configurations, all of which change the NUMA geometry.&lt;/p>
&lt;h2 id="why-numa-matters-for-gpu-workloads">Why NUMA Matters for GPU Workloads&lt;/h2>
&lt;p>GPU inference often follows a CPU-GPU pipeline: the CPU prepares requests, batches them, copies input data into the right format, feeds data to the GPU over PCIe, and then handles postprocessing on the GPU&amp;rsquo;s output. The GPU does the heavy computation, but it&amp;rsquo;s the CPU that keeps the pipeline fed.&lt;/p>
&lt;p>When a container&amp;rsquo;s CPUs are on a different NUMA node than its GPU, moving input data from CPU memory to GPU memory may cross a NUMA boundary. The GPU has to read data from memory attached to a farther-away CPU socket instead of memory attached to its local socket. This adds latency on the critical path.&lt;/p>
&lt;p>In one inference workload, we observed more than 30% higher p99 tail latency under load for pods whose CPUs spanned both sockets compared with pods whose CPUs stayed on the same socket. For a latency-sensitive service, that is enough to matter, and it happens silently unless someone is explicitly monitoring NUMA alignment. Nothing in Kubernetes surfaces it. The pod is running, serving traffic, and looking healthy, just consistently slower than its peers.&lt;/p>
&lt;p>Training workloads are affected too, though the impact profile is different. Data loading workers continuously preprocess batches on CPU and stage them for GPU consumption. Cross-NUMA data loaders contend for inter-socket bandwidth and add latency to every batch transfer.
&lt;a href="https://pytorch.org/tutorials/recipes/recipes/tuning_guide.html" target="_blank" rel="noopener">PyTorch&amp;rsquo;s own performance tuning guide&lt;/a> explicitly recommends binding training processes to a single NUMA node.&lt;/p>
&lt;p>For GPU workloads where the CPU is on the data path to the GPU, NUMA locality has a direct and measurable impact on performance.&lt;/p>
&lt;h2 id="increasing-levels-of-cpu-isolation-and-numa-alignment">Increasing Levels of CPU Isolation and NUMA Alignment&lt;/h2>
&lt;p>Kubernetes offers several knobs for CPU isolation, each providing stronger guarantees at the cost of more constraints.&lt;/p>
&lt;h3 id="level-1-logical-core-pinning-with-cpumanagerpolicy-static">Level 1: Logical Core Pinning with &lt;code>cpuManagerPolicy: static&lt;/code>&lt;/h3>
&lt;p>By default, Kubernetes lets the operating system&amp;rsquo;s CPU scheduler move a container&amp;rsquo;s processes across any available core. This is efficient for overall CPU utilization, but it means your container&amp;rsquo;s threads may move across cores, invalidating caches and sharing physical cores with other containers.&lt;/p>
&lt;p>Setting &lt;code>cpuManagerPolicy: static&lt;/code> in the kubelet config changes this. Containers in
&lt;a href="https://kubernetes.io/docs/concepts/workloads/pods/pod-qos/#guaranteed" target="_blank" rel="noopener">Guaranteed QoS&lt;/a> pods (where &lt;code>requests == limits&lt;/code>) with integer CPU requests get exclusive, pinned logical cores. Kubernetes won&amp;rsquo;t assign those exclusive CPUs to another container, and your processes stay put. Host daemons and kernel threads can still run there unless the platform also reserves or isolates CPUs for the OS.&lt;/p>
&lt;p>The way kubelet pins CPUs is by constraining the container&amp;rsquo;s cpuset cgroup to the assigned CPU list. The assigned cores can be seen in &lt;code>cpuset.cpus&lt;/code> on cgroup v1 and &lt;code>cpuset.cpus.effective&lt;/code> on cgroup v2.&lt;/p>
&lt;p>On the node, the exact path depends on the cgroup driver, runtime, QoS class, and pod UID formatting. With systemd-style kubepods slices, the files are roughly located here:&lt;/p>
&lt;pre>&lt;code class="language-bash">KUBEPODS=&amp;quot;&amp;lt;kubepods.slice/.../kubepods-pod&amp;lt;uid&amp;gt;.slice/&amp;lt;container&amp;gt;.scope&amp;gt;&amp;quot;
# cgroup v1
cat /sys/fs/cgroup/cpuset/$KUBEPODS/cpuset.cpus
# cgroup v2
cat /sys/fs/cgroup/$KUBEPODS/cpuset.cpus.effective
&lt;/code>&lt;/pre>
&lt;p>This alone improves performance consistency. Cache affinity improves because threads aren&amp;rsquo;t migrating across cores, and container-to-container CPU contention is reduced. But there&amp;rsquo;s a subtlety: you&amp;rsquo;re pinning logical cores (hyperthreads), not physical cores. Two containers can still end up sharing a physical core if one gets one hyperthread and the other gets the sibling. They&amp;rsquo;ll contend for that physical core&amp;rsquo;s L1 and L2 cache.&lt;/p>
&lt;p>&lt;strong>What it requires from workload owners:&lt;/strong> Set &lt;code>requests == limits&lt;/code> for CPU and memory on all containers (including init containers and sidecars) to get Guaranteed QoS. CPU requests must be integers for the containers where pinning is desired.&lt;/p>
&lt;h3 id="level-2-physical-core-pinning-with-full-pcpus-only">Level 2: Physical Core Pinning with &lt;code>full-pcpus-only&lt;/code>&lt;/h3>
&lt;p>The
&lt;a href="https://kubernetes.io/docs/tasks/administer-cluster/cpu-management-policies/#static-policy-options" target="_blank" rel="noopener">&lt;code>full-pcpus-only&lt;/code>&lt;/a> CPU manager policy option (&lt;code>cpuManagerPolicyOptions: full-pcpus-only=true&lt;/code>) takes isolation further. Instead of allocating individual logical cores, it allocates entire physical cores. Both hyperthreads of each core go to the same container.&lt;/p>
&lt;p>This eliminates L1/L2 cache contention between containers that would otherwise share a physical core.&lt;/p>
&lt;p>The trade-off: containers that receive exclusive CPUs must request a multiple of the SMT thread count, typically 2. A pinned container requesting 3 CPUs fails with an &lt;code>SMTAlignmentError&lt;/code> (covered in
&lt;a href="#smtalignmenterror">Failure Modes&lt;/a> below). Any existing pinned containers with odd CPU counts on the node need to be resized before you enable this option.&lt;/p>
&lt;p>&lt;strong>What it requires from workload owners:&lt;/strong> Even CPU request values. Audit all containers, including sidecars and init containers. Fractional CPU values on sidecars and init containers are fine as those containers use the shared CPU pool and don&amp;rsquo;t get pinned.&lt;/p>
&lt;h3 id="level-3-full-numa-alignment-with-single-numa-node">Level 3: Full NUMA Alignment with &lt;code>single-numa-node&lt;/code>&lt;/h3>
&lt;p>CPU pinning ensures your cores are dedicated, and &lt;code>full-pcpus-only&lt;/code> ensures the container gets full physical cores. Neither guarantees that all your cores come from the same NUMA node. With the default static policy options, the kubelet&amp;rsquo;s CPU manager uses a packed allocation strategy that fills one NUMA node before spilling to the next (more on this
&lt;a href="#how-the-kubelet-allocates-cpus">below&lt;/a>), but depending on node fragmentation, your container&amp;rsquo;s CPUs can still span NUMA boundaries.&lt;/p>
&lt;p>The
&lt;a href="https://kubernetes.io/docs/tasks/administer-cluster/topology-manager/" target="_blank" rel="noopener">&lt;code>topologyManagerPolicy: single-numa-node&lt;/code>&lt;/a> setting addresses this. The topology manager sits above the CPU manager, device manager, and memory manager, and coordinates resource allocation by collecting topology hints from each. With &lt;code>single-numa-node&lt;/code>, it requires that hinted resources can be satisfied from a single NUMA node. If they can&amp;rsquo;t, the pod is rejected at admission time with a &lt;code>TopologyAffinityError&lt;/code>.&lt;/p>
&lt;p>The default scope &lt;code>topologyManagerScope: container&lt;/code> computes alignment independently for each container. That&amp;rsquo;s usually fine when one main container owns the GPU and the exclusive CPUs, while sidecars are unrelated to the latency-critical path and use fractional CPU from the shared pool.&lt;/p>
&lt;p>&lt;code>topologyManagerScope: pod&lt;/code> is stricter: it asks whether the pod&amp;rsquo;s effective request fits on one NUMA node. Use it when multiple containers in the same pod are performance-coupled, not just because a logging or metrics sidecar exists.&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>Caveat:&lt;/strong> Topology Manager only enforces resources that report topology hints. CPU hints come from CPU Manager, GPU hints come through Device Manager from plugins such as the nvidia-device-plugin, and memory hints come from Memory Manager.&lt;/p>
&lt;p>If the GPU device plugin does not report NUMA &lt;code>TopologyInfo&lt;/code>, Topology Manager cannot force CPU-GPU locality.&lt;/p>
&lt;/blockquote>
&lt;p>For guaranteed NUMA alignment, set &lt;code>memoryManagerPolicy: Static&lt;/code> too. This makes requested memory part of topology admission along with CPU and GPU resources. The workload&amp;rsquo;s memory request must fit within the target NUMA node. Kubernetes also requires &lt;code>reservedMemory&lt;/code> when &lt;code>memoryManagerPolicy: Static&lt;/code> is enabled.&lt;/p>
&lt;p>This gives you the strongest isolation available. The container stays within one NUMA node and communicates with the GPU on that NUMA node without crossing the socket interconnect. CPU cache locality can still vary within that NUMA node (depending on NPS configuration), but in testing, we have found that a container occupying an entire NUMA node with no overlap from other workloads is materially less affected by cache-thrashing or CPU-intensive noisy neighbors.&lt;/p>
&lt;p>&lt;strong>What it requires from workload owners:&lt;/strong> The performance-critical container, or the effective pod request when using &lt;code>topologyManagerScope: pod&lt;/code>, must fit within a single NUMA node. That means understanding the machine topology and resizing the workload as hardware or its configuration changes.&lt;/p>
&lt;p>A minimal kubelet config for a dedicated NUMA-aligned GPU pool looks like:&lt;/p>
&lt;pre>&lt;code class="language-yaml">cpuManagerPolicy: static
cpuManagerPolicyOptions:
full-pcpus-only: &amp;quot;true&amp;quot;
topologyManagerPolicy: single-numa-node
# Default is container. Use pod only when the whole pod should fit on one NUMA node.
# topologyManagerScope: pod
memoryManagerPolicy: Static
# memoryManagerPolicy: Static requires reservedMemory to be configured.
&lt;/code>&lt;/pre>
&lt;p>Roll this out on dedicated, drained nodes. When changing CPU or memory manager policies, clear the kubelet state files before restarting kubelet: &lt;code>&amp;lt;kubelet-root-dir&amp;gt;/cpu_manager_state&lt;/code> and &lt;code>&amp;lt;kubelet-root-dir&amp;gt;/memory_manager_state&lt;/code>.&lt;/p>
&lt;h2 id="how-the-kubelet-allocates-cpus">How the Kubelet Allocates CPUs&lt;/h2>
&lt;p>Understanding the allocation algorithm helps explain how and when NUMA spillover happens.&lt;/p>
&lt;p>When &lt;code>cpuManagerPolicy: static&lt;/code> is enabled with the default policy options, the kubelet uses a packed (bin-pack) allocation strategy:
&lt;a href="https://sourcegraph.com/r/github.com/kubernetes/kubernetes@78994b5cf1fd09d94f8f1748fac83d15eb83c479/-/blob/pkg/kubelet/cm/cpumanager/cpu_assignment.go?L776" target="_blank" rel="noopener">&lt;code>takeByTopologyNUMAPacked&lt;/code>&lt;/a>. It works top-down through the topology:&lt;/p>
&lt;ol>
&lt;li>First, try to take full NUMA nodes (prefer smaller/more-used ones)&lt;/li>
&lt;li>Then, take full physical cores from partially-used NUMA nodes (prefer NUMA nodes with fewer free CPUs to pack them first)&lt;/li>
&lt;li>Finally, take individual logical cores if needed&lt;/li>
&lt;/ol>
&lt;p>The sort order is key: at every level, it prefers NUMA nodes with fewer remaining free CPUs. This packs nearly-exhausted NUMA nodes before touching less-used ones. The allocator usually keeps CPUs NUMA-local when there is room, but locality is not guaranteed.&lt;/p>
&lt;p>But &amp;ldquo;when possible&amp;rdquo; is doing a lot of work there. Consider a 2-socket machine with 48 cores per socket (96 vCPUs per socket with SMT). In NPS1 mode, this gives 2 NUMA nodes of 96 vCPUs each. After system and kube-reserved, suppose 90 vCPUs are allocatable per NUMA node, with 4 GPUs per NUMA node. Each pod requests 22 vCPUs.&lt;/p>
&lt;p>The first 4 pods land on NUMA 0: 4 x 22 = 88 vCPUs used, leaving only 2 allocatable vCPUs. The 5th pod requests 22 vCPUs, but only 2 remain on NUMA 0. The CPU manager takes those 2 from NUMA 0 and the remaining 20 from NUMA 1. The diagram below (credit to my colleague
&lt;a href="https://www.linkedin.com/in/rlishtaba/" target="_blank" rel="noopener">Roman Lishtaba&lt;/a> for identifying this pattern in our GPU inference workloads) shows exactly how this plays out:&lt;/p>
&lt;div class="cpu-spill-widget" style="margin: 1.5em 0; width: 100vw; position: relative; left: 50%; transform: translateX(-50%); max-width: 1100px;">
&lt;div id="cpu-spill-diagram">&lt;/div>
&lt;/div>
&lt;script>
(function() {
var C = {
socketBg: '#f8fafc', socketBorder: '#94a3b8',
sharedBg: '#f1f5f9', sharedBorder: '#cbd5e1',
text: '#1e293b', textMuted: '#64748b',
interconnect: '#64748b', ddr: '#2563eb',
pods: [
{ bg: '#dcfce7', border: '#22c55e', text: '#166534' },
{ bg: '#dbeafe', border: '#3b82f6', text: '#1e40af' },
{ bg: '#f3e8ff', border: '#a855f7', text: '#6b21a8' },
{ bg: '#fef3c7', border: '#f59e0b', text: '#92400e' },
{ bg: '#fecaca', border: '#ef4444', text: '#991b1b' }
]
};
var VCPUS_PER_SOCKET = 96;
var CCDS = 6;
var SYS_RESERVED = 6;
var ALLOC = VCPUS_PER_SOCKET - SYS_RESERVED;
var POD = 22;
var el = document.getElementById('cpu-spill-diagram');
var W = 1160, H = 720;
var socketW = 500, socketH = 340, socketGap = 100, socketY = 80;
var sx0 = 30, sx1 = sx0 + socketW + socketGap;
var s = '&lt;svg viewBox="0 0 ' + W + ' ' + H + '" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:auto;display:block;">';
s += '&lt;text x="' + (W/2) + '" y="28" text-anchor="middle" font-size="18" font-weight="700" fill="' + C.text + '">AMD EPYC System: NPS1&lt;/text>';
s += '&lt;text x="' + (W/2) + '" y="50" text-anchor="middle" font-size="12" fill="' + C.textMuted + '">Pod placement at full packing \u2014 8 \u00d7 ' + POD + ' vCPU pods, cpuManagerPolicy: static, no Topology Manager&lt;/text>';
s += '&lt;defs>&lt;marker id="cs-ah" markerWidth="6" markerHeight="5" refX="6" refY="2.5" orient="auto">&lt;polygon points="0 0, 6 2.5, 0 5" fill="' + C.interconnect + '"/>&lt;/marker>&lt;/defs>';
for (var sock = 0; sock &lt; 2; sock++) {
var sx = sock === 0 ? sx0 : sx1;
s += '&lt;rect x="' + sx + '" y="' + socketY + '" width="' + socketW + '" height="' + socketH + '" rx="8" fill="' + C.socketBg + '" stroke="' + C.socketBorder + '" stroke-width="2"/>';
s += '&lt;text x="' + (sx+12) + '" y="' + (socketY+22) + '" font-size="14" font-weight="700" fill="' + C.text + '">Socket ' + sock + ' (' + CCDS + ' CCDs)&lt;/text>';
s += '&lt;text x="' + (sx+12) + '" y="' + (socketY+38) + '" font-size="11" fill="' + C.textMuted + '">NUMA ' + sock + '&lt;/text>';
var ccdCols = 3, ccdRows = 2, ccdPadX = 12, ccdPadY = 48, ccdGap = 8;
var ccdW = (socketW - ccdPadX*2 - ccdGap*(ccdCols-1)) / ccdCols;
var ccdH = 100;
for (var r = 0; r &lt; ccdRows; r++) {
for (var c = 0; c &lt; ccdCols; c++) {
var ccdIdx = r * ccdCols + c;
var globalCcd = sock * CCDS + ccdIdx;
var cx = sx + ccdPadX + c * (ccdW + ccdGap);
var cy = socketY + ccdPadY + r * (ccdH + ccdGap);
var color = getCCDColor(sock, ccdIdx);
s += '&lt;rect x="' + cx + '" y="' + cy + '" width="' + ccdW + '" height="' + ccdH + '" rx="6" fill="' + color.bg + '" stroke="' + color.border + '" stroke-width="1.5"/>';
s += '&lt;text x="' + (cx+ccdW/2) + '" y="' + (cy+16) + '" text-anchor="middle" font-size="10" font-weight="600" fill="' + C.textMuted + '">CCD ' + globalCcd + '&lt;/text>';
var coreSize = 14, corePad = 3, coreCols = 4, coreRows = 2;
var coreBlockW = coreCols * (coreSize + corePad) - corePad;
var coreStartX = cx + (ccdW - coreBlockW) / 2;
var coreStartY = cy + 24;
for (var cr = 0; cr &lt; coreRows; cr++) {
for (var cc = 0; cc &lt; coreCols; cc++) {
var coreX = coreStartX + cc * (coreSize + corePad);
var coreY = coreStartY + cr * (coreSize + corePad);
s += '&lt;rect x="' + coreX + '" y="' + coreY + '" width="' + coreSize + '" height="' + coreSize + '" rx="2" fill="#fff" stroke="' + color.border + '" stroke-width="0.75"/>';
s += '&lt;text x="' + (coreX+coreSize/2) + '" y="' + (coreY+coreSize/2+3) + '" text-anchor="middle" font-size="6" fill="' + C.textMuted + '">C' + (cr*coreCols+cc+1) + '&lt;/text>';
}
}
s += '&lt;text x="' + (cx+ccdW/2) + '" y="' + (cy+ccdH-10) + '" text-anchor="middle" font-size="9" fill="' + C.textMuted + '">L3&lt;/text>';
}
}
var sharedX = sx + socketW - 90;
var sharedY = socketY + socketH - 30;
s += '&lt;rect x="' + sharedX + '" y="' + sharedY + '" width="78" height="20" rx="4" fill="' + C.sharedBg + '" stroke="' + C.sharedBorder + '" stroke-width="1" stroke-dasharray="3,2"/>';
s += '&lt;text x="' + (sharedX+39) + '" y="' + (sharedY+14) + '" text-anchor="middle" font-size="8" fill="' + C.textMuted + '">shared pool&lt;/text>';
}
var interX1 = sx0 + socketW + 4, interX2 = sx1 - 4;
var interY = socketY + socketH / 2;
s += '&lt;line x1="' + interX1 + '" y1="' + (interY-5) + '" x2="' + interX2 + '" y2="' + (interY-5) + '" stroke="' + C.interconnect + '" stroke-width="2" marker-end="url(#cs-ah)"/>';
s += '&lt;line x1="' + interX2 + '" y1="' + (interY+5) + '" x2="' + interX1 + '" y2="' + (interY+5) + '" stroke="' + C.interconnect + '" stroke-width="2" marker-end="url(#cs-ah)"/>';
s += '&lt;text x="' + ((interX1+interX2)/2) + '" y="' + (interY-14) + '" text-anchor="middle" font-size="10" font-weight="600" fill="' + C.interconnect + '">xGMI&lt;/text>';
var podBarY = socketY + socketH + 20;
var podH = 40;
var pxScale = socketW / VCPUS_PER_SOCKET;
for (var p = 0; p &lt; 4; p++) {
var px = sx0 + p * POD * pxScale;
var pw = POD * pxScale;
var pc = C.pods[p % 4];
s += '&lt;rect x="' + px + '" y="' + podBarY + '" width="' + pw + '" height="' + podH + '" rx="4" fill="' + pc.bg + '" stroke="' + pc.border + '" stroke-width="1.5"/>';
s += '&lt;text x="' + (px+pw/2) + '" y="' + (podBarY+16) + '" text-anchor="middle" font-size="11" font-weight="600" fill="' + pc.text + '">Pod ' + (p+1) + '&lt;/text>';
s += '&lt;text x="' + (px+pw/2) + '" y="' + (podBarY+30) + '" text-anchor="middle" font-size="9" fill="' + pc.text + '">' + POD + ' vCPU&lt;/text>';
}
var pod5Start = 4 * POD;
var pod5N0 = ALLOC - pod5Start;
var pod5N1 = POD - pod5N0;
var sc = C.pods[4];
var p5x0 = sx0 + pod5Start * pxScale;
var p5w0 = pod5N0 * pxScale;
s += '&lt;rect x="' + p5x0 + '" y="' + podBarY + '" width="' + p5w0 + '" height="' + podH + '" rx="4" fill="' + sc.bg + '" stroke="' + sc.border + '" stroke-width="2"/>';
s += '&lt;text x="' + (p5x0+p5w0/2) + '" y="' + (podBarY+16) + '" text-anchor="middle" font-size="9" font-weight="600" fill="' + sc.text + '">' + pod5N0 + '&lt;/text>';
s += '&lt;text x="' + (p5x0+p5w0/2) + '" y="' + (podBarY+30) + '" text-anchor="middle" font-size="8" fill="' + sc.text + '">vCPU&lt;/text>';
s += '&lt;text x="' + (p5x0+p5w0+4) + '" y="' + (podBarY+14) + '" font-size="9" fill="' + sc.text + '">\u2192 Pod 5&lt;/text>';
var reservedW = SYS_RESERVED * pxScale;
var reservedPx0 = sx0 + ALLOC * pxScale;
s += '&lt;rect x="' + reservedPx0 + '" y="' + podBarY + '" width="' + reservedW + '" height="' + podH + '" rx="4" fill="' + C.sharedBg + '" stroke="' + C.sharedBorder + '" stroke-width="1" stroke-dasharray="3,2"/>';
s += '&lt;text x="' + (reservedPx0+reservedW/2) + '" y="' + (podBarY+24) + '" text-anchor="middle" font-size="8" fill="' + C.textMuted + '">sys&lt;/text>';
var p5x1 = sx1;
var p5w1 = pod5N1 * pxScale;
s += '&lt;rect x="' + p5x1 + '" y="' + podBarY + '" width="' + p5w1 + '" height="' + podH + '" rx="4" fill="' + sc.bg + '" stroke="' + sc.border + '" stroke-width="2"/>';
s += '&lt;text x="' + (p5x1+p5w1/2) + '" y="' + (podBarY+16) + '" text-anchor="middle" font-size="9" font-weight="600" fill="' + sc.text + '">' + pod5N1 + '&lt;/text>';
s += '&lt;text x="' + (p5x1+p5w1/2) + '" y="' + (podBarY+30) + '" text-anchor="middle" font-size="8" fill="' + sc.text + '">vCPU&lt;/text>';
for (var p = 0; p &lt; 3; p++) {
var px = sx1 + pod5N1 * pxScale + p * POD * pxScale;
var pw = POD * pxScale;
var pc = C.pods[p % 4];
s += '&lt;rect x="' + px + '" y="' + podBarY + '" width="' + pw + '" height="' + podH + '" rx="4" fill="' + pc.bg + '" stroke="' + pc.border + '" stroke-width="1.5"/>';
s += '&lt;text x="' + (px+pw/2) + '" y="' + (podBarY+16) + '" text-anchor="middle" font-size="11" font-weight="600" fill="' + pc.text + '">Pod ' + (p+6) + '&lt;/text>';
s += '&lt;text x="' + (px+pw/2) + '" y="' + (podBarY+30) + '" text-anchor="middle" font-size="9" fill="' + pc.text + '">' + POD + ' vCPU&lt;/text>';
}
var usedN1 = pod5N1 + 3 * POD;
var sharedN1 = ALLOC - usedN1;
var sharedPx = sx1 + usedN1 * pxScale;
var sharedPw = sharedN1 * pxScale;
s += '&lt;rect x="' + sharedPx + '" y="' + podBarY + '" width="' + sharedPw + '" height="' + podH + '" rx="4" fill="' + C.sharedBg + '" stroke="' + C.sharedBorder + '" stroke-width="1" stroke-dasharray="3,2"/>';
s += '&lt;text x="' + (sharedPx+sharedPw/2) + '" y="' + (podBarY+18) + '" text-anchor="middle" font-size="8" fill="' + C.textMuted + '">shared&lt;/text>';
s += '&lt;text x="' + (sharedPx+sharedPw/2) + '" y="' + (podBarY+30) + '" text-anchor="middle" font-size="8" fill="' + C.textMuted + '">' + sharedN1 + ' vCPU&lt;/text>';
var reservedPx1 = sx1 + ALLOC * pxScale;
s += '&lt;rect x="' + reservedPx1 + '" y="' + podBarY + '" width="' + reservedW + '" height="' + podH + '" rx="4" fill="' + C.sharedBg + '" stroke="' + C.sharedBorder + '" stroke-width="1" stroke-dasharray="3,2"/>';
s += '&lt;text x="' + (reservedPx1+reservedW/2) + '" y="' + (podBarY+24) + '" text-anchor="middle" font-size="8" fill="' + C.textMuted + '">sys&lt;/text>';
var calloutY = podBarY + podH + 18;
var calloutX = (sx0 + socketW + sx1) / 2;
s += '&lt;rect x="' + (calloutX-100) + '" y="' + calloutY + '" width="200" height="40" rx="6" fill="' + sc.bg + '" stroke="' + sc.border + '" stroke-width="2"/>';
s += '&lt;text x="' + calloutX + '" y="' + (calloutY+16) + '" text-anchor="middle" font-size="11" font-weight="700" fill="' + sc.text + '">CROSS-SOCKET&lt;/text>';
s += '&lt;text x="' + calloutX + '" y="' + (calloutY+30) + '" text-anchor="middle" font-size="9" fill="' + sc.text + '">Pod 5: ' + pod5N0 + ' vCPU NUMA 0 + ' + pod5N1 + ' vCPU NUMA 1&lt;/text>';
s += '&lt;line x1="' + (p5x0+p5w0/2) + '" y1="' + (podBarY+podH) + '" x2="' + (calloutX-40) + '" y2="' + calloutY + '" stroke="' + sc.border + '" stroke-width="1" stroke-dasharray="4,3"/>';
s += '&lt;line x1="' + (p5x1+p5w1/2) + '" y1="' + (podBarY+podH) + '" x2="' + (calloutX+40) + '" y2="' + calloutY + '" stroke="' + sc.border + '" stroke-width="1" stroke-dasharray="4,3"/>';
var annoY = calloutY + 54;
s += '&lt;text x="' + (sx0+socketW/2) + '" y="' + annoY + '" text-anchor="middle" font-size="10" fill="' + C.textMuted + '">NUMA 0: 4 \u00d7 ' + POD + ' = ' + (4*POD) + ' pinned | ' + pod5N0 + ' \u2192 Pod 5 | ' + SYS_RESERVED + ' sys-reserved&lt;/text>';
s += '&lt;text x="' + (sx1+socketW/2) + '" y="' + annoY + '" text-anchor="middle" font-size="10" fill="' + C.textMuted + '">NUMA 1: ' + pod5N1 + ' (Pod 5) + 3 \u00d7 ' + POD + ' = ' + usedN1 + ' pinned | ' + sharedN1 + ' shared | ' + SYS_RESERVED + ' sys-reserved&lt;/text>';
var ddrY = annoY + 16;
s += '&lt;text x="' + (sx0+socketW/2) + '" y="' + (ddrY+20) + '" text-anchor="middle" font-size="13" font-weight="700" fill="' + C.ddr + '">\u2191 DDRs&lt;/text>';
s += '&lt;text x="' + (sx1+socketW/2) + '" y="' + (ddrY+20) + '" text-anchor="middle" font-size="13" font-weight="700" fill="' + C.ddr + '">\u2191 DDRs&lt;/text>';
var legY = ddrY + 40;
var legItems = [
{ c: C.pods[0], l: 'Pods 1, 6 \u2014 fully NUMA-local' },
{ c: C.pods[1], l: 'Pods 2, 7 \u2014 fully NUMA-local' },
{ c: C.pods[2], l: 'Pods 3, 8 \u2014 fully NUMA-local' },
{ c: C.pods[3], l: 'Pod 4 \u2014 fully NUMA-local' },
{ c: C.pods[4], l: 'Pod 5 \u2014 CROSS-SOCKET' }
];
var legItemW = 260;
for (var i = 0; i &lt; legItems.length; i++) {
var lx = sx0 + (i % 3) * legItemW;
var ly = legY + Math.floor(i / 3) * 22;
s += '&lt;rect x="' + lx + '" y="' + ly + '" width="14" height="14" rx="3" fill="' + legItems[i].c.bg + '" stroke="' + legItems[i].c.border + '" stroke-width="1.5"/>';
s += '&lt;text x="' + (lx+20) + '" y="' + (ly+12) + '" font-size="10" fill="' + C.text + '">' + legItems[i].l + '&lt;/text>';
}
var slx = sx0 + 2 * legItemW, sly = legY + 22;
s += '&lt;rect x="' + slx + '" y="' + sly + '" width="14" height="14" rx="3" fill="' + C.sharedBg + '" stroke="' + C.sharedBorder + '" stroke-width="1" stroke-dasharray="3,2"/>';
s += '&lt;text x="' + (slx+20) + '" y="' + (sly+12) + '" font-size="10" fill="' + C.text + '">Shared CPU pool (fractional sidecars + daemons)&lt;/text>';
var rcY = legY + 56;
s += '&lt;text x="' + (W/2) + '" y="' + rcY + '" text-anchor="middle" font-size="10" fill="' + C.textMuted + '">cpuManagerPolicy: static fills NUMA 0 sequentially. After 4 pods \u00d7 ' + POD + ' = ' + (4*POD) + ' vCPU, only ' + pod5N0 + ' pinnable vCPUs remain in NUMA 0.&lt;/text>';
s += '&lt;text x="' + (W/2) + '" y="' + (rcY+16) + '" text-anchor="middle" font-size="10" fill="' + C.textMuted + '">The 5th pod\u2019s ' + POD + ' vCPU allocation overflows into NUMA 1 (' + pod5N0 + ' + ' + pod5N1 + '). Without Topology Manager, there is no admission-time rejection.&lt;/text>';
s += '&lt;text x="' + (W/2) + '" y="' + (rcY+32) + '" text-anchor="middle" font-size="10" font-weight="600" fill="' + C.text + '">Fix: topologyManagerPolicy: single-numa-node&lt;/text>';
s += '&lt;/svg>';
el.innerHTML = s;
function getCCDColor(socket, ccdInSocket) {
if (socket === 0) {
if (ccdInSocket &lt; 1) return C.pods[0];
if (ccdInSocket &lt; 2) return C.pods[1];
if (ccdInSocket &lt; 3) return C.pods[1];
if (ccdInSocket &lt; 4) return C.pods[2];
if (ccdInSocket &lt; 5) return C.pods[3];
return C.pods[4];
} else {
if (ccdInSocket &lt; 1) return C.pods[4];
if (ccdInSocket &lt; 2) return C.pods[0];
if (ccdInSocket &lt; 3) return C.pods[1];
if (ccdInSocket &lt; 4) return C.pods[1];
if (ccdInSocket &lt; 5) return C.pods[2];
return C.pods[2];
}
}
})();
&lt;/script>
&lt;p>Without topology manager enforcement, the kubelet allocates CPUs from multiple NUMA nodes. Pod 5 runs fine, but its performance is degraded. Nothing in Kubernetes will tell you about this.&lt;/p>
&lt;p>With &lt;code>topologyManagerPolicy: single-numa-node&lt;/code>, the system keeps the allocation bounded within one NUMA node. In this scenario, NUMA 1 still has 90 vCPUs free, so Pod 5 would land there entirely. &lt;code>TopologyAffinityError&lt;/code> only fires when no single NUMA node can satisfy the request.&lt;/p>
&lt;h2 id="failure-modes-to-be-aware-of">Failure Modes to Be Aware Of&lt;/h2>
&lt;p>Both &lt;code>cpuManagerPolicyOptions: full-pcpus-only=true&lt;/code> and &lt;code>topologyManagerPolicy: single-numa-node&lt;/code> introduce hard failure modes that are worth understanding before enabling them.&lt;/p>
&lt;h3 id="smtalignmenterror">SMTAlignmentError&lt;/h3>
&lt;p>When &lt;code>full-pcpus-only&lt;/code> is enabled, the kubelet rejects any container that would receive exclusive CPUs but does not request a multiple of the SMT thread count, typically 2. The pod goes into &lt;code>Failed&lt;/code> state with an &lt;code>SMTAlignmentError&lt;/code> and stays there until someone deletes it. Workload controllers (Deployments, StatefulSets) will recreate the pod, but the replacement hits the same error on any node where &lt;code>full-pcpus-only&lt;/code> is in effect.&lt;/p>
&lt;h3 id="topologyaffinityerror">TopologyAffinityError&lt;/h3>
&lt;p>When &lt;code>topologyManagerPolicy: single-numa-node&lt;/code> is enabled, the kubelet rejects any pod whose containers&amp;rsquo; hinted resource requests can&amp;rsquo;t be satisfied from a single NUMA node. With &lt;code>topologyManagerScope: pod&lt;/code>, that check applies to the pod&amp;rsquo;s effective request. The sequence is:&lt;/p>
&lt;ol>
&lt;li>The scheduler picks a node based on aggregate resource availability&lt;/li>
&lt;li>The kubelet receives the pod and runs topology admission&lt;/li>
&lt;li>The topology manager collects hints from the CPU manager, device manager, and memory manager&lt;/li>
&lt;li>If no single NUMA node can satisfy all resources, the pod is rejected with &lt;code>TopologyAffinityError&lt;/code>&lt;/li>
&lt;/ol>
&lt;p>Same failure semantics as &lt;code>SMTAlignmentError&lt;/code>: the pod is &lt;code>Failed&lt;/code> and the scheduler won&amp;rsquo;t retry. The confusing part is that the node has sufficient aggregate capacity, but the pod still fails because no individual NUMA node has enough room. If you&amp;rsquo;re not thinking in NUMA terms, this is disorienting.&lt;/p>
&lt;p>&lt;code>cpuManagerPolicy: static&lt;/code> on its own doesn&amp;rsquo;t introduce these failure modes. They come from the additional constraints of &lt;code>full-pcpus-only&lt;/code> and &lt;code>single-numa-node&lt;/code>. Both are node-level kubelet settings that apply to every pod on the node, which means enabling &lt;code>single-numa-node&lt;/code> can break existing workloads that don&amp;rsquo;t fit in a single NUMA node. Dedicated node pools for NUMA-aligned workloads are a practical approach to mitigate this.&lt;/p>
&lt;h2 id="topology-aware-scheduling">Topology-Aware Scheduling&lt;/h2>
&lt;p>A practical problem with &lt;code>single-numa-node&lt;/code> is that the default Kubernetes scheduler sees only aggregate node resources. It doesn&amp;rsquo;t know that a node&amp;rsquo;s 60 free vCPUs are split 20/40 across two NUMA nodes. The scheduler can place a pod on a node, only for the kubelet to reject it at admission. The workload controller then creates a replacement, which may fail on the next node too.&lt;/p>
&lt;p>The
&lt;a href="https://github.com/kubernetes-sigs/scheduler-plugins/tree/master/pkg/noderesourcetopology" target="_blank" rel="noopener">&lt;code>NodeResourceTopologyMatch&lt;/code> scheduler plugin&lt;/a> reduces this gap. It gives the scheduler per-NUMA-node resource visibility, so it can filter out nodes that can&amp;rsquo;t satisfy the topology constraints before placing the pod.&lt;/p>
&lt;p>Deploying it requires:&lt;/p>
&lt;ul>
&lt;li>A cluster-scoped &lt;code>NodeResourceTopology&lt;/code> CRD and one &lt;code>NodeResourceTopology&lt;/code> custom resource per node&lt;/li>
&lt;li>A topology exporter DaemonSet (such as
&lt;a href="https://kubernetes-sigs.github.io/node-feature-discovery/stable/get-started/introduction.html" target="_blank" rel="noopener">NFD Topology Updater&lt;/a>) on every node, polling the kubelet&amp;rsquo;s PodResources API and publishing per-NUMA resource availability&lt;/li>
&lt;li>The &lt;code>NodeResourceTopologyMatch&lt;/code> scheduler plugin configured as a filter and scorer&lt;/li>
&lt;/ul>
&lt;p>That&amp;rsquo;s additional infrastructure for the platform team: a DaemonSet, a cluster-scoped CRD, one custom resource per node refreshed roughly every 60 seconds, and a scheduler plugin with its own cache. Without it, pods repeatedly fail on nodes that look like they have enough capacity.&lt;/p>
&lt;h2 id="making-it-work-collaboration-between-platform-and-workload-teams">Making It Work: Collaboration Between Platform and Workload Teams&lt;/h2>
&lt;p>Getting NUMA alignment right is not something either platform admins or workload owners can do alone. It requires collaboration and shared understanding.&lt;/p>
&lt;h3 id="platform-admins-publish-topology-and-sizing-guidance">Platform admins: publish topology and sizing guidance&lt;/h3>
&lt;p>With NUMA alignment, platform admins can&amp;rsquo;t just hand out node pools and let workload owners request whatever CPU/memory they want. They need to publish clear guidance:&lt;/p>
&lt;ul>
&lt;li>What SKU each node pool uses, and its NUMA geometry (cores per NUMA node, NPS mode)&lt;/li>
&lt;li>How many vCPUs are consumed by system-reserved and kube-reserved&lt;/li>
&lt;li>Recommended container sizes that align with NUMA boundaries&lt;/li>
&lt;li>What constraints are in effect (&lt;code>full-pcpus-only&lt;/code>, &lt;code>single-numa-node&lt;/code>, and any non-default topology manager scope) and what failure modes they introduce&lt;/li>
&lt;/ul>
&lt;p>For example, on a 2-socket machine with 48 cores per socket in NPS1 mode: each NUMA node has 96 vCPUs, about 90 allocatable after reservations, with 4 GPUs per NUMA node. The recommended CPU request per GPU might be 22 vCPUs (22 x 4 = 88, fitting within the 90 available). If the fleet has multiple SKUs with different core counts or NPS configurations, this becomes a matrix of recommendations.&lt;/p>
&lt;h3 id="workload-owners-understand-the-constraints-size-accordingly">Workload owners: understand the constraints, size accordingly&lt;/h3>
&lt;p>GPU workloads are inherently more hardware-aware than typical Kubernetes workloads. Unlike a stateless web service where you declare CPU and memory and let the platform figure out placement, GPU inference and training benefit from understanding the machine topology.&lt;/p>
&lt;p>This means:&lt;/p>
&lt;ul>
&lt;li>Sizing containers to fit within a NUMA node based on the platform&amp;rsquo;s published guidance (often it&amp;rsquo;s better to run multiple smaller pods, each NUMA-local, than one larger pod that spans NUMA nodes)&lt;/li>
&lt;li>Using even CPU values when &lt;code>full-pcpus-only&lt;/code> is in effect&lt;/li>
&lt;li>Ensuring the pod is Guaranteed QoS (requests == limits on all containers)&lt;/li>
&lt;li>Updating container sizes when the platform migrates to different SKUs&lt;/li>
&lt;/ul>
&lt;h3 id="verifying-alignment-in-practice">Verifying alignment in practice&lt;/h3>
&lt;p>At the node level, start by confirming the hardware topology:&lt;/p>
&lt;pre>&lt;code class="language-bash">lscpu -e=CPU,CORE,SOCKET,NODE
numactl -H
nvidia-smi topo -m # NVIDIA GPU nodes
&lt;/code>&lt;/pre>
&lt;p>Inside a running container, check the workload process&amp;rsquo;s CPU affinity and compare it with the node&amp;rsquo;s &lt;code>lscpu&lt;/code> output to confirm the allowed CPUs sit within the expected NUMA node:&lt;/p>
&lt;pre>&lt;code class="language-bash">kubectl exec &amp;lt;pod-name&amp;gt; -c &amp;lt;container-name&amp;gt; -- taskset -cp 1
# If taskset is unavailable:
kubectl exec &amp;lt;pod-name&amp;gt; -c &amp;lt;container-name&amp;gt; -- grep Cpus_allowed_list /proc/1/status
&lt;/code>&lt;/pre>
&lt;p>&lt;code>taskset -cp 1&lt;/code> checks PID 1 in the container. If your workload runs as a different PID, check that process instead.&lt;/p>
&lt;h2 id="appendix-related-cpu-manager-options">Appendix: Related CPU Manager Options&lt;/h2>
&lt;p>Kubernetes has several
&lt;a href="https://kubernetes.io/docs/concepts/workloads/resource-managers/#cpu-policy-static--options" target="_blank" rel="noopener">CPU manager policy options&lt;/a> adjacent to the path described above.&lt;/p>
&lt;ul>
&lt;li>&lt;code>strict-cpu-reservation&lt;/code> keeps regular workloads off CPUs reserved for the OS and Kubernetes daemons, which helps reduce system noise on pinned workloads.&lt;/li>
&lt;li>&lt;code>prefer-align-cpus-by-uncorecache&lt;/code> is a best-effort cache-locality option that tries to keep a container&amp;rsquo;s CPUs within the same L3 or uncore cache group.&lt;/li>
&lt;li>&lt;code>align-by-socket&lt;/code> is useful when a container is too large to fit in one NUMA node and must use multiple NUMA nodes. It asks CPU Manager to keep that allocation within one socket when possible.&lt;/li>
&lt;/ul>
&lt;p>These can improve isolation or latency, but they do not replace &lt;code>topologyManagerPolicy: single-numa-node&lt;/code> for keeping a GPU workload NUMA-local. &lt;code>align-by-socket&lt;/code> is also not compatible with &lt;code>single-numa-node&lt;/code>.&lt;/p>
&lt;h2 id="dra-and-future-direction">DRA and Future Direction&lt;/h2>
&lt;p>The Kubernetes
&lt;a href="https://github.com/kubernetes-sigs/dra-driver-cpu" target="_blank" rel="noopener">DRA (Dynamic Resource Allocation) CPU driver&lt;/a> is interesting because it may allow NUMA-aware CPU placement to happen through the scheduling layer, without some of the post-scheduling admission issues described above. I haven&amp;rsquo;t explored it deeply enough to recommend it here. I&amp;rsquo;ll write a follow-up after I spend more time with it.&lt;/p>
&lt;h2 id="wrapping-up">Wrapping Up&lt;/h2>
&lt;p>There&amp;rsquo;s a clear progression of CPU isolation in Kubernetes:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Level&lt;/th>
&lt;th>Config&lt;/th>
&lt;th>What you get&lt;/th>
&lt;th>What it requires&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>1&lt;/td>
&lt;td>&lt;code>cpuManagerPolicy: static&lt;/code>&lt;/td>
&lt;td>Dedicated logical cores, reduced CPU contention&lt;/td>
&lt;td>Guaranteed QoS, integer CPU requests&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2&lt;/td>
&lt;td>+ &lt;code>full-pcpus-only=true&lt;/code>&lt;/td>
&lt;td>Full physical cores, L1/L2 cache isolation&lt;/td>
&lt;td>Even CPU request values&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3&lt;/td>
&lt;td>+ &lt;code>topologyManagerPolicy: single-numa-node&lt;/code> and &lt;code>memoryManagerPolicy: Static&lt;/code>&lt;/td>
&lt;td>CPU, GPU, and memory admitted only if they fit one NUMA node&lt;/td>
&lt;td>Critical container fits in a NUMA node, device plugin topology hints, &lt;code>reservedMemory&lt;/code>, topology-aware scheduler, sizing guidance from platform team&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Each level introduces stricter constraints in exchange for stronger performance isolation. For GPU inference, where the CPU is directly on the data path to the GPU, best-effort alignment is not always good enough. If a pod is misaligned, Kubernetes will not tell you, but the workload may still show worse tail latency. For consistency, a hard failure like &lt;code>TopologyAffinityError&lt;/code> is often better than silently serving degraded traffic.&lt;/p>
&lt;p>Getting it right takes effort from both sides: platform teams publishing topology guidance and workload owners sizing containers to match. It is more work than treating compute as a black box, but GPU workloads typically need to be more aware of the underlying hardware than ordinary services.&lt;/p></description></item><item><title>Trading Peace for Pace: A Few Weeks with Claude Code</title><link>https://ronaknathani.pages.dev/blog/2026/02/trading-peace-for-pace-a-few-weeks-with-claude-code/</link><pubDate>Tue, 17 Feb 2026 23:00:00 -0500</pubDate><guid>https://ronaknathani.pages.dev/blog/2026/02/trading-peace-for-pace-a-few-weeks-with-claude-code/</guid><description>&lt;p>&lt;img src="coding-agents-the-new-workflow.png" alt="Coding agents the new workflow">&lt;/p>
&lt;p>After a few weeks with Claude Code, it has completely replaced my default workflow. It’s really good, and weirdly addictive! The productivity gains are undeniable. You ship faster, explore more ideas, and iterate constantly. But once the initial magic wears off, you start to feel a trade-off: you are trading peace for pace.&lt;/p>
&lt;p>Coding used to have a sense of calmness. Uninterrupted stretches where you&amp;rsquo;d just be in the zone. Shipping a feature felt like a milestone. Now, because execution is so easy, that emotional reward has moved. The joy shifts from writing code to getting the tool to do what you wanted, cleanly and autonomously. You need more volume to feel productive. There is less deep focus and more context switching. You orchestrate agents, shape prompts, and review output. You get more done, but the work is fragmented, reactive and parallel.&lt;/p>
&lt;p>This new pace introduces a subtle pressure: if nothing is running in the background, it feels like wasted potential. Like, agents must always be running! (yes, I realize how absurd that sounds.) You see people on X running multiple agents at once, showcasing what they built and the FOMO is inescapable.&lt;/p>
&lt;p>There&amp;rsquo;s an irony here. AI is supposed to free up time so we can do more with less, but in a culture that glorifies shipping, we just use the freed up time to build even more. It’s also incredibly easy to spend hours optimizing your setup. Refining workflows, writing new skills, installing the new trending plugins to make things just a little better. When the tool does the heavy lifting, you spend a lot of time sharpening the tool itself.&lt;/p>
&lt;p>The shift is real though and the leverage is incredibly empowering. We have moved from writing (and reviewing) code to expressing intent. You can take on more ambitious projects and build bespoke solutions for every &amp;ldquo;I wish this existed&amp;rdquo; thought. Because the cost of building has dropped so drastically, software is becoming disposable. Permanence starts to matter less. Fulfillment comes less from the effort of execution and more from taste, judgment, and deciding what is actually worth building.&lt;/p>
&lt;p>Agents are getting incredibly good at execution. They write clean code and make reasonable choices, often following the exact patterns I would. But as I readily accept their output and offload the thinking, I can&amp;rsquo;t help but wonder about the risk of my own coding skills atrophying.&lt;/p>
&lt;p>Coding agents aren&amp;rsquo;t a novelty anymore. They are the new baseline expectation for software engineers. And it takes deliberate practice to steer them effectively.&lt;/p></description></item><item><title>Understanding the Kubernetes Scale subresource</title><link>https://ronaknathani.pages.dev/blog/2024/01/understanding-the-kubernetes-scale-subresource/</link><pubDate>Sat, 13 Jan 2024 11:00:00 -0500</pubDate><guid>https://ronaknathani.pages.dev/blog/2024/01/understanding-the-kubernetes-scale-subresource/</guid><description>&lt;h2 id="understanding-the-scale-subresource-in-kubernetes">Understanding the &lt;code>scale&lt;/code> subresource in Kubernetes&lt;/h2>
&lt;p>Resources like &lt;code>Deployments&lt;/code> and &lt;code>Statefulsets&lt;/code> in Kubernetes have a
&lt;a href="https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/#scale-subresource" target="_blank" rel="noopener">&lt;code>scale&lt;/code> subresource&lt;/a> which captures three things:&lt;/p>
&lt;ol>
&lt;li>&lt;code>spec.replicas&lt;/code>: The desired number of replicas.&lt;/li>
&lt;li>&lt;code>status.replicas&lt;/code>: The actual, current number of replicas.&lt;/li>
&lt;li>&lt;code>status.labelSelector&lt;/code>: Identifies the pods managed by the resource.&lt;/li>
&lt;/ol>
&lt;p>Here&amp;rsquo;s an example of what a typical query response looks like when you explore the &lt;code>scale&lt;/code> subresource:&lt;/p>
&lt;pre>&lt;code class="language-bash"># Sample output from querying a Kubernetes deployment's scale settings.
➜ curl -s localhost:8001/apis/apps/v1/namespaces/kube-system/deployments/coredns/scale | jq .
{
&amp;quot;kind&amp;quot;: &amp;quot;Scale&amp;quot;,
&amp;quot;apiVersion&amp;quot;: &amp;quot;autoscaling/v1&amp;quot;,
&amp;quot;metadata&amp;quot;: {
&amp;quot;name&amp;quot;: &amp;quot;coredns&amp;quot;,
&amp;quot;namespace&amp;quot;: &amp;quot;kube-system&amp;quot;,
&amp;quot;uid&amp;quot;: &amp;quot;0f39b1dd-8cb4-4374-a95d-11d96c0b9d6a&amp;quot;,
&amp;quot;resourceVersion&amp;quot;: &amp;quot;3260769&amp;quot;,
&amp;quot;creationTimestamp&amp;quot;: &amp;quot;2023-08-10T15:55:24Z&amp;quot;
},
&amp;quot;spec&amp;quot;: {
&amp;quot;replicas&amp;quot;: 2
},
&amp;quot;status&amp;quot;: {
&amp;quot;replicas&amp;quot;: 2,
&amp;quot;selector&amp;quot;: &amp;quot;k8s-app=kube-dns&amp;quot;
}
}
&lt;/code>&lt;/pre>
&lt;h2 id="whys-the-scale-subresource-necessary">Why’s the &lt;code>scale&lt;/code> subresource Necessary?&lt;/h2>
&lt;p>When I first learned about the &lt;code>scale&lt;/code> subresource, I wondered why it&amp;rsquo;s needed when the replica information is already available in the &lt;code>spec&lt;/code>. As with many things in Kubernetes, it&amp;rsquo;s needed to support extensibility and flexibility.&lt;/p>
&lt;p>Kubernetes doesn’t enforce a uniform schema for representing desired and current replica counts in workload resources. The &lt;code>scale&lt;/code> subresource provides a unified interface, enabling different workload resources, both built-in and custom, to consistently expose their scaling settings. This uniform interface allows for seamless integration with the rest of the Kubernetes ecosystem.&lt;/p>
&lt;p>This is better understood by looking at how the &lt;code>scale&lt;/code> subresource is used.&lt;/p>
&lt;h2 id="uses-of-the-scale-subresource">Uses of the &lt;code>scale&lt;/code> subresource&lt;/h2>
&lt;p>The &lt;code>scale&lt;/code> subresource is used for the following cases:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>By the Horizontal Pod Autoscaler (HPA):&lt;/strong> HPA uses the &lt;code>scale&lt;/code> subresource to dynamically adjust the desired replica count based on some utilization metrics like CPU usage, QPS, etc.&lt;/li>
&lt;li>&lt;strong>By the Pod Disruption Budget (PDB) controller:&lt;/strong> When &lt;code>.spec.maxUnavailable&lt;/code> or &lt;code>.spec.minAvailable&lt;/code> in PDB configurations is specified as a percentage, the PDB controller queries the &lt;code>scale&lt;/code> subresource, of the resource managing the pod, to get the desired number of replicas.&lt;/li>
&lt;li>&lt;strong>Scaling pods through the &lt;code>kubectl scale&lt;/code> command:&lt;/strong> This command allows manually changing the replica count of resources that have the &lt;code>scale&lt;/code> subresource enabled.&lt;/li>
&lt;/ol>
&lt;h2 id="scale-subresource-and-custom-resources">Scale subresource and Custom Resources&lt;/h2>
&lt;p>If you’re working with custom resources that manage pods and want them to integrate seamlessly with HPA, PDB, or be compatible with the &lt;code>kubectl scale&lt;/code> command, you need to enable the &lt;code>scale&lt;/code> subresource in the Custom Resource Definition (CRD).&lt;/p>
&lt;p>If you are using &lt;code>kubebuilder&lt;/code> to write controllers, you can enable the &lt;code>scale&lt;/code> subresource for your custom resource as mentioned
&lt;a href="https://book.kubebuilder.io/reference/generating-crd#scale" target="_blank" rel="noopener">here&lt;/a>. Also, it seems that the scale subresource can be enabled&lt;/p>
&lt;h2 id="pdb-custom-resources-and-the-scale-subresource">PDB, Custom Resources, and the &lt;code>scale&lt;/code> subresource&lt;/h2>
&lt;p>For pods managed by a custom resource, PDB can be used without restrictions only if the custom resource supports the &lt;code>scale&lt;/code> subresource. This is because when a percentage value is specified for &lt;code>.spec.maxUnavailable&lt;/code> or &lt;code>.spec.minAvailable&lt;/code>, the PDB controller needs to know the total desired replicas in order to calculate the number of replicas that should be available during a disruption. And PDB controller gets the total desired replicas via the &lt;code>scale&lt;/code> subresource of the resource owning the pods.&lt;/p>
&lt;pre>&lt;code class="language-go">// if maxUnavailable is set as a percentage
desiredAvailableReplicas := desiredTotalReplicas - (desiredTotalReplicas * maxUnavailable / 100)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-go">// if minAvailable is set a percentage
desiredAvailableReplicas := desiredTotalReplicas * minAvailable / 100
&lt;/code>&lt;/pre>
&lt;p>Call paths for reference:&lt;/p>
&lt;p>
&lt;a href="https://sourcegraph.com/github.com/kubernetes/kubernetes/-/blob/pkg/controller/disruption/disruption.go?L792:42&amp;amp;popover=pinned" target="_blank" rel="noopener">getExpectedPodCount&lt;/a> →
&lt;a href="https://sourcegraph.com/github.com/kubernetes/kubernetes/-/blob/pkg/controller/disruption/disruption.go?L826:33&amp;amp;popover=pinned" target="_blank" rel="noopener">getExpectedScale&lt;/a> →
&lt;a href="https://sourcegraph.com/github.com/kubernetes/kubernetes/-/blob/pkg/controller/disruption/disruption.go?L346:33&amp;amp;popover=pinned" target="_blank" rel="noopener">getScaleController&lt;/a>&lt;/p>
&lt;p>If &lt;code>scale&lt;/code> subresource isn&amp;rsquo;t enabled for your custom resource, you can still use PDB, albeit with certain limitations - you can only use &lt;code>.spec.minAvailable&lt;/code> with an integer value, not percentages, as mentioned in the Kubernetes
&lt;a href="https://kubernetes.io/docs/tasks/run-application/configure-pdb/#arbitrary-controllers-and-selectors" target="_blank" rel="noopener">documentation&lt;/a>.&lt;/p></description></item><item><title>What I Wish I Knew About Incident Management</title><link>https://ronaknathani.pages.dev/blog/2020/11/what-i-wish-i-knew-about-incident-management/</link><pubDate>Sat, 07 Nov 2020 18:00:00 -0700</pubDate><guid>https://ronaknathani.pages.dev/blog/2020/11/what-i-wish-i-knew-about-incident-management/</guid><description>&lt;p>I gave this talk last year at LinkedIn’s internal SRE conference, thought I’d share it here as well.&lt;/p>
&lt;hr>
&lt;h2 id="why-i-am-writing-this-post">Why I am writing this post&lt;/h2>
&lt;p>Like every Software Engineer / SRE, I’ve had my share of troubleshooting software. However, I had never been oncall before I joined Linkedin and the impact of a system outage that affects thousands of engineers made the first week of oncall pretty overwhelming.&lt;/p>
&lt;p>&lt;img src="then-with-caption.gif" alt="first week of me handling production issues">&lt;/p>
&lt;p>But things got better overtime.&lt;/p>
&lt;p>In this post, I would like to share the incident management practices I have picked up over the years as an SRE at Linkedin that help me keep calm under pressure and effectively drive incidents to resolution.&lt;/p>
&lt;p>&lt;img src="now-with-caption.gif" alt="present me handling production issues">&lt;/p>
&lt;h2 id="what-this-post-is-not-about">What this post is not about&lt;/h2>
&lt;p>In this post, I am not going to talk about how to debug linux or distributed systems or the various debugging tools. (For stories from the frontlines,
&lt;a href="https://softwaremisadventures.com" target="_blank" rel="noopener">check out Software Misadventures Podcast&lt;/a>!)&lt;/p>
&lt;h2 id="first-oncall-week">First oncall week&lt;/h2>
&lt;p>My first few weeks at Linkedin - they were great! I was meeting smart engineers and learning new things. It wasn’t until my first oncall rotation that I started thinking &lt;em>what if there&amp;rsquo;s an outage and I need to fix it?&lt;/em>&lt;/p>
&lt;p>&lt;img src="anxious.gif" alt="anxiety before first week of oncall">&lt;/p>
&lt;p>Now, don’t get me wrong. LinkedIn has really good systems in place for monitoring/alerting, triaging issues and a very well defined process for incident response. Those are absolutely critical. And to prepare, I had shadowed our oncall the week prior and even had an experienced team member shadow me to guide and help me out, but still, I was anxious.&lt;/p>
&lt;p>Here’s what I wish I had known.&lt;/p>
&lt;ul>
&lt;li>
&lt;a href="#before-oncall-starts">Before oncall starts&lt;/a>
&lt;ul>
&lt;li>
&lt;a href="#oncall-handoff">Oncall handoff&lt;/a>&lt;/li>
&lt;li>
&lt;a href="#organizing-slack-during-an-oncall-week">Organizing Slack during an oncall week&lt;/a>&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>
&lt;a href="#signal-vs-noise">Signal vs Noise&lt;/a>
&lt;ul>
&lt;li>
&lt;a href="#trust-but-verify---not-all-alerts-are-created-equal">Trust, but verify - not all alerts are created equal&lt;/a>&lt;/li>
&lt;li>
&lt;a href="#declaring-an-incident">Declaring an incident&lt;/a>&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>
&lt;a href="#communication-during-an-incident">Communication during an incident&lt;/a>
&lt;ul>
&lt;li>
&lt;a href="#the-incident-title-and-scoping-the-impact">The incident title and scoping the impact&lt;/a>&lt;/li>
&lt;li>
&lt;a href="#establish-communication-channels-and-an-incident-lead">Establish communication channels and an incident lead&lt;/a>&lt;/li>
&lt;li>
&lt;a href="#communicate-changes-to-the-system">Communicate changes to the system&lt;/a>&lt;/li>
&lt;li>
&lt;a href="#provide-regular-updates">Provide regular updates&lt;/a>&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>
&lt;a href="#incident-response-is-a-collaborative-process">Incident response is a collaborative process&lt;/a>
&lt;ul>
&lt;li>
&lt;a href="#get-help-early">Get help early&lt;/a>&lt;/li>
&lt;li>
&lt;a href="#working-with-others">Working with others&lt;/a>&lt;/li>
&lt;li>
&lt;a href="#video-conferencing-is-your-friend">Video conferencing is your friend&lt;/a>&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>
&lt;a href="#towards-a-resolution">Towards a resolution&lt;/a>
&lt;ul>
&lt;li>
&lt;a href="#looking-at-changes">Looking at changes&lt;/a>&lt;/li>
&lt;li>
&lt;a href="#keep-calm-and-carry-on---one-step-at-a-time">Keep calm and carry on - one step at a time&lt;/a>&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>
&lt;a href="#learning-from-the-incident">Learning from the incident&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="before-oncall-starts">Before Oncall Starts&lt;/h2>
&lt;h3 id="oncall-handoff">Oncall handoff&lt;/h3>
&lt;p>Before an oncall week starts, I talk to the person who is currently oncall to get context on any incidents that happened during the week or any weird bugs that were discovered in our stack. It gives me perspectives on issues that could be getting carried over from the previous week and any critical changes I should be aware of.
Although you can’t plan for all that happens during an oncall week, a proper handoff helps you prepare for it.&lt;/p>
&lt;h3 id="organizing-slack-during-an-oncall-week">Organizing Slack during an oncall week&lt;/h3>
&lt;p>As an SRE on LinkedIn’s container scheduler and deployment infrastructure team, our users are engineers at LinkedIn. We use Slack for internal communication and we have certain channels where users share issues they are experiencing with the tooling or to get our oncall’s attention. During an oncall week, I star these channels and organize my sidebar so that I can easily notice messages from our users and distinguish them from the other messages I receive. As an optional tip - I also like to mute/leave channels that I am not actively participating in to reduce clutter.&lt;/p>
&lt;p>As compared to a normal week, I spend more time on Slack when I am oncall - responding to people, answering support requests, helping users with tooling. These Slack notifications can create a little bit of distraction, however, considering all our users are internal, a rise in messages on our channels can also indicate that something might be wrong with our system and our alerting hasn’t caught it yet.&lt;/p>
&lt;p>&lt;img src="slack-pings.gif" alt="slack pings during oncall">&lt;/p>
&lt;h2 id="signal-vs-noise">Signal vs Noise&lt;/h2>
&lt;h3 id="trust-but-verify---not-all-alerts-are-created-equal">Trust, but verify - not all alerts are created equal&lt;/h3>
&lt;p>&lt;img src="trust-but-verify-you-must.jpg" alt="trust-but-verify-you-must">&lt;/p>
&lt;p style="text-align: center;">&lt;i>This is something I learned during my interview at LinkedIn and it has been very applicable in my experience since.&lt;/i>&lt;/p>
&lt;p>In early days of oncall and incident management, it is very common to feel as if things are on fire when any alert gets triggered. Over time, I have realized that gaining context and verifying that the alert is actually indicating an issue helps with the right next steps. It is important to distinguish signal from noise because:&lt;/p>
&lt;ul>
&lt;li>An alert could be non-actionable as it was recently configured with a threshold that’s making it too noisy&lt;/li>
&lt;li>The monitoring stack is down and the alert is being triggered because the configuration treats a lack of data points as an issue&lt;/li>
&lt;li>Your service is operating perfectly fine, however, the traffic tier routing requests to your service had an issue&lt;/li>
&lt;li>Timer on a deliberately silenced alert expired and the alert started triggering&lt;/li>
&lt;/ul>
&lt;p>I have experienced all of the above at some point in time. Now when I either receive an alert or a user reports an issue, I check our services (metrics, logs, reproduce the reported problem etc.) to verify that the issue is real.&lt;/p>
&lt;h3 id="declaring-an-incident">Declaring an incident&lt;/h3>
&lt;p>Not all actionable alerts result in an incident. To be effective at identifying the ones that do, it is extremely crucial to think about the bigger picture of mitigating the issue than be overwhelmed by the technical task of resolving the alert.&lt;/p>
&lt;p>Some of the qualitative measures that help make this differentiation is to consider whether an issue requires coordinating the fix with other teams or whether the issue is impacting customers or violating an SLO. If any of the conditions are true, declare an incident. It is always better to declare an incident early in the process than waiting too long.&lt;/p>
&lt;p>At LinkedIn, we have defined guidelines for all teams about what warrants an incident along with different levels of severity. This takes guesswork out of the picture, and provides a shared understanding to every team member.&lt;/p>
&lt;h2 id="communication-during-an-incident">Communication during an incident&lt;/h2>
&lt;h3 id="the-incident-title-and-scoping-the-impact">The incident title and scoping the impact&lt;/h3>
&lt;p>Every incident gets a title. I didn’t realize it initially, but giving an incident a title forces one to define the problem and communicate it to the stakeholders very succinctly. When communicating to stakeholders, &lt;strong>scoping the incident is very important&lt;/strong>. What I mean by scoping is identifying how big the impact is - which environment is impacted, is the impact limited to a region or is it global, how many customers are impacted, etc. For instance, “the login feature on the site is not working&amp;rdquo; vs “the login feature on the site is not working for traffic originating from Asia Pacific&amp;rdquo; say two very different things.&lt;/p>
&lt;h3 id="establish-communication-channels-and-an-incident-lead">Establish communication channels and an incident lead&lt;/h3>
&lt;p>We heavily rely on Slack to communicate during an incident. A dedicated slack channel helps focus all the energy and inputs from everyone in one place. It helps the incident lead collect data about symptoms that the users are experiencing and also captures a log of considered/discarded hypotheses and any changes made to the system. Once the Slack channel is created, establish an incident lead and let everyone know who is driving the incident forward.&lt;/p>
&lt;p>Eslablishing explicit comms channels for reporting and identifying issues and establishing an incident lead reduce the delay in action and disambiguate any confusion.&lt;/p>
&lt;h3 id="communicate-changes-to-the-system">Communicate changes to the system&lt;/h3>
&lt;p>If there’s a fix you’d like to try out, let others know who are and encourage everyone to do the same. This ensures that the potential fix doesn’t make an already bad situation worse and helps catch any blind spots early. If you do end up making a change to the system after getting consensus, let others know and follow up on its effects.&lt;/p>
&lt;h3 id="provide-regular-updates">Provide regular updates&lt;/h3>
&lt;p>While working towards a resolution for the incident, it is very easy to get overwhelmed by the technical details and miss to communicate an update. This leads to angry leadership and annoyed customers who have no insight into what’s happening.&lt;/p>
&lt;p>An update provides visibility to the customers that the issue is being worked upon and lets the leadership identify if they can help out with anything. Depending on the severity of the incident, an update every 15-30 mins serves pretty well. The update doesn’t have to be extremely detailed, rather a brief summary describing the current state and immediate next steps is sufficient. An example update:&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>[UPDATE]&lt;/strong> Our hypothesis about the memory leak checks out and we have validated the fix in the staging environment. We have pushed out the change and as soon as it goes through the CI pipeline, we’ll canary the new release, monitor metrics and promote the change after verifying the fix.&lt;/p>
&lt;/blockquote>
&lt;h2 id="incident-response-is-a-collaborative-process">Incident response is a collaborative process&lt;/h2>
&lt;h3 id="get-help-early">Get help early&lt;/h3>
&lt;p>In my earlier days, I used to think that it was solely my responsibility to mitigate the issue, find the root cause, and roll out the fix. If I couldn’t do it, it wouldn&amp;rsquo;t reflect well on me. In reality, incident management, like much of software development, is a very collaborative process.&lt;/p>
&lt;p>One of the big differences in how I approach it now is I focus on actively pulling in other engineers who could help with debugging or resolving the issue early in the process. This change in perspective has relieved me of a lot of unnecessary stress and also made me more effective at resolving incidents.&lt;/p>
&lt;p>When requesting help, be specific about the task as well as the urgency. It helps others calibrate their response and manage things they might have at hand.&lt;/p>
&lt;h3 id="working-with-others">Working with others&lt;/h3>
&lt;p>One of the most important things while handling incidents is working with others. Considering the multi-faceted nature of an SRE role, it’s one of the most important while underrated skills.&lt;/p>
&lt;p>With multiple people involved, various possibilities get shared and one of the responsibilities of the incident lead is to guide these discussions in a productive direction while filtering out the noise. One should be cautious about going down any rabbit holes and focus on stopping the bleeding first. If there’s a probable cause, share it early - it helps rule out possibilities. And it’s okay to ask questions that seem obvious, but specific questions are more helpful.&lt;/p>
&lt;p>Being fearlessly curious, and keeping an open mind enables one to consider possibilities that could be overseen otherwise. Remember, you are working together as a team on sharing and ruling out hypotheses to solve a challenging problem.&lt;/p>
&lt;h3 id="video-conferencing-is-your-friend">Video Conferencing is your friend&lt;/h3>
&lt;p>We work with engineers in geographically distributed locations (even when it&amp;rsquo;s not a pandemic) and some discussions are much better conducted synchronously than async. In such scenarios, start a video call sooner in the process. Either a shared conference room or a video call has almost always helped figure out the path forward sooner and coordinate next steps.&lt;/p>
&lt;h2 id="towards-a-resolution">Towards a resolution&lt;/h2>
&lt;h3 id="looking-at-changes">Looking at changes&lt;/h3>
&lt;p>Things &lt;em>mostly&lt;/em> break when something changes. If we froze our code, configurations and infrastructure, we’d have way less outages than otherwise. Code or config deployments are two of the most common actions that result in change. So, rule those out first. A few things to check for change are:&lt;/p>
&lt;ul>
&lt;li>Code or config deployment targeting the application experiencing the issue&lt;/li>
&lt;li>Global configuration changes of the underlying infrastructure (network, OS, etc.)&lt;/li>
&lt;li>Anomalous traffic patterns&lt;/li>
&lt;/ul>
&lt;p>The intention here is to identify what is unusual about the system that’s resulting in an issue. I must mention that isn’t always a single change, rather a combination of changes that result in the perfect storm.&lt;/p>
&lt;h3 id="keep-calm-and-carry-on---one-step-at-a-time">Keep calm and carry on - one step at a time&lt;/h3>
&lt;p>While all of this is going on, it’s pretty common to be stressed knowing that users are unable to use your system as promised. If you start to feel you are panicking or overwhelmed, call for support. Panic results in a scrambled path forward which in turn takes longer for an incident to be resolved. And for many people, there’s a linear correlation in length of an incident and the stress they feel.&lt;/p>
&lt;p>In addition to calling for support, take a step back to re-evaluate the issue holistically and think about the upstreams/downstreams of your system to rule out possibilities in a structured way. Structuring your thoughts and reminding yourself that you are not alone in this helps keep calm and handle the pressure.&lt;/p>
&lt;h2 id="learning-from-the-incident">Learning from the incident&lt;/h2>
&lt;p>I have gone from dreading incidents to treating them as an opportunity to learn something new. I have learnt the most about our software and the underlying infrastructure when something breaks. It makes the task of incident response &lt;strong>exciting and fun&lt;/strong> - like a detective solving a difficult case. And when the incident is finally resolved, it brings a great sense of &lt;strong>satisfaction, accomplishment and pride&lt;/strong> for the team involved in resolving the incident.&lt;/p>
&lt;p>When you are not oncall, be available to help your teammates when there’s an incident. They would appreciate it and you’d learn something new in the process. Last but not the least, ensure that not just the postmortem but the incident resolution is blameless. It helps foster a culture of ownership, results in incidents being resolved faster and helps improve the entire team’s performance over time.&lt;/p>
&lt;hr>
&lt;p>Thanks to
&lt;a href="https://www.linkedin.com/in/gyang8/" target="_blank" rel="noopener">Guang Yang&lt;/a> and
&lt;a href="https://www.linkedin.com/in/aishaneeshah/" target="_blank" rel="noopener">Aishanee Shah&lt;/a> for reviewing this post and providing very valuable feedback.&lt;/p>
&lt;p>If you have any specific incident management practices that have helped you over time, I’d love to know. Feel free to comment on the post or reach out to me via
&lt;a href="https://twitter.com/RonakNathani" target="_blank" rel="noopener">Twitter&lt;/a> /
&lt;a href="mailto:hello@ronaknathani.com">Email&lt;/a>. I look forward to hearing from you!&lt;/p></description></item><item><title>How a Kubernetes Pod Gets an IP Address</title><link>https://ronaknathani.pages.dev/blog/2020/08/how-a-kubernetes-pod-gets-an-ip-address/</link><pubDate>Fri, 21 Aug 2020 13:04:27 -0700</pubDate><guid>https://ronaknathani.pages.dev/blog/2020/08/how-a-kubernetes-pod-gets-an-ip-address/</guid><description>&lt;p>One of the core requirements of the
&lt;a href="https://kubernetes.io/docs/concepts/cluster-administration/networking/#the-kubernetes-network-model" target="_blank" rel="noopener">Kubernetes networking model&lt;/a> is that every pod should get its own IP address and that every pod in the cluster should be able to talk to it using this IP address. There are several network providers (flannel, calico, canal, etc.) that implement this networking model.&lt;/p>
&lt;p>As I started working on Kubernetes, it wasn&amp;rsquo;t completely clear to me how every pod is assigned an IP address. I understood how various components worked independently, however, it wasn&amp;rsquo;t clear how these components fit together. For instance, I understood what CNI plugins were, however, I didn&amp;rsquo;t know how they were invoked. So, I wanted to write this post to share what I have learned about various networking components and how they are stitched together in a kubernetes cluster for every pod to receive an IP address.&lt;/p>
&lt;p>There are various ways of setting up networking in kubernetes and various options for a container runtime. For this post, I will use
&lt;a href="https://github.com/coreos/flannel" target="_blank" rel="noopener">Flannel&lt;/a> as the network provider and
&lt;a href="https://github.com/containerd/containerd" target="_blank" rel="noopener">Containerd&lt;/a> as the container runtime. Also, I am going to assume that you know how container networking works and only share a very brief overview below for context.&lt;/p>
&lt;h2 id="some-background-concepts">Some Background Concepts&lt;/h2>
&lt;h3 id="container-networking-a-very-brief-overview">Container Networking: A Very Brief Overview&lt;/h3>
&lt;p>There are some really good posts explaining how container networking works. For context, I will go over a very high level overview here with a single approach that involves linux bridge networking and packet encapsulation. I am skipping details here as container networking deserves a blog post of itself. Some of the posts that I have found to be very educational in this space are
&lt;a href="#container-networking">linked in the references below&lt;/a>.&lt;/p>
&lt;h4 id="containers-on-the-same-host">Containers on the same host&lt;/h4>
&lt;p>One of the ways containers running on the same host can talk to each other via their IP addresses is through a linux bridge. In the kubernetes (and docker) world, a
&lt;a href="https://man7.org/linux/man-pages/man4/veth.4.html" target="_blank" rel="noopener">veth (virtual ethernet)&lt;/a> device is created to achieve this. One end of this veth device is inserted into the container network namespace and the other end is connected to a
&lt;a href="https://wiki.archlinux.org/index.php/Network_bridge" target="_blank" rel="noopener">linux bridge&lt;/a> on the host network. All containers on the same host have one end of this veth pair connected to the linux bridge and they can talk to each other using their IP addresses via the bridge. The linux bridge is also assigned an IP address and it acts as a gateway for egress traffic from pods destined to different nodes.
&lt;img src="bridge-networking.png" alt="bridge networking">&lt;/p>
&lt;h4 id="containers-on-different-hosts">Containers on different hosts&lt;/h4>
&lt;p>One of the ways containers running on different hosts can talk to each other via their IP addresses is by using packet encapsulation. Flannel supports this through
&lt;a href="https://vincent.bernat.ch/en/blog/2017-vxlan-linux" target="_blank" rel="noopener">vxlan&lt;/a> which wraps the original packet inside a UDP packet and sends it to the destination.&lt;/p>
&lt;p>In a kubernetes cluster, flannel creates a vxlan device and some route table entries on each of the nodes. Every packet that&amp;rsquo;s destined for a container on a different host goes through the vxlan device and is encapsulated in a UDP packet. On the destination, the encapsulated packet is retrieved and the packet is routed through to the destined pod.
&lt;img src="flannel-networking.png" alt="flannel networking">&lt;/p>
&lt;p>&lt;em>NOTE: This is just one of the ways how networking between containers can be configured.&lt;/em>&lt;/p>
&lt;h3 id="what-is-cri">What Is CRI?&lt;/h3>
&lt;p>
&lt;a href="https://github.com/kubernetes/cri-api" target="_blank" rel="noopener">CRI (Container Runtime Interface)&lt;/a> is a plugin interface that allows kubelet to use different container runtimes. Various container runtimes implement the CRI API and this allows users to use the container runtime of their choice in their kubernetes installation.&lt;/p>
&lt;h3 id="what-is-cni">What is CNI?&lt;/h3>
&lt;p>
&lt;a href="https://github.com/containernetworking/cni" target="_blank" rel="noopener">CNI project&lt;/a> includes a
&lt;a href="https://github.com/containernetworking/cni/blob/master/SPEC.md" target="_blank" rel="noopener">spec&lt;/a> to provide a generic plugin-based networking solution for linux containers. It also consists of various
&lt;a href="https://github.com/containernetworking/plugins" target="_blank" rel="noopener">plugins&lt;/a> which perform different functions in configuring the pod network. A CNI plugin is an executable that follows the CNI spec and we&amp;rsquo;ll discuss some plugins in the post below.&lt;/p>
&lt;h2 id="assigning-subnets-to-nodes-for-pod-ip-addresses">Assigning Subnets To Nodes For Pod IP Addresses&lt;/h2>
&lt;p>If all pods are required to have an IP address, it&amp;rsquo;s important to ensure that all pods across the entire cluster have a unique IP address. This is achieved by assigning each node a unique subnet from which pods are assigned IP addresses on that node.&lt;/p>
&lt;h3 id="node-ipam-controller">Node IPAM Controller&lt;/h3>
&lt;p>When &lt;code>nodeipam&lt;/code> is passed as an option to the
&lt;a href="https://kubernetes.io/docs/reference/command-line-tools-reference/kube-controller-manager/" target="_blank" rel="noopener">kube-controller-manager&amp;rsquo;s&lt;/a> &lt;code>--controllers&lt;/code> command line flag, it allocates each node a dedicated subnet (podCIDR) from the cluster CIDR (IP range for the cluster network). Since these podCIDRs are disjoint subnets, it allows assigning each pod a unique IP address.&lt;/p>
&lt;p>A kubernetes node is assigned a podCIDR when the node first registers with the cluster. To change the podCIDR allocated to nodes in a cluster, nodes need to be de-registered and then re-registered with any configuration changes first applied to the kubernetes control plane. &lt;code>podCIDR&lt;/code> for a node can be listed using the following command.&lt;/p>
&lt;pre>&lt;code>$ kubectl get no &amp;lt;nodeName&amp;gt; -o json | jq '.spec.podCIDR'
10.244.0.0/24
&lt;/code>&lt;/pre>
&lt;h2 id="kubelet-container-runtime-and-cni-plugins---how-its-all-stitched-together">Kubelet, Container Runtime and CNI Plugins - how it&amp;rsquo;s all stitched together&lt;/h2>
&lt;p>When a pod is scheduled on a node, a lot of things happen to start up a pod. In this section, I&amp;rsquo;ll only focus on the interactions that relate to configuring network for the pod.&lt;/p>
&lt;p>Once a pod is scheduled on the node, the following interactions result in configuring the network and starting the application container.
&lt;img src="kubelet-cri-cni-flowchart.png" alt="kubelet-cri-cni-flowchart">&lt;/p>
&lt;p>Ref:
&lt;a href="https://github.com/containerd/cri/blob/v1.11.1/docs/architecture.md" target="_blank" rel="noopener">Containerd cri plugin architecture&lt;/a>&lt;/p>
&lt;h2 id="interactions-between-container-runtime-and-cni-plugins">Interactions between Container Runtime and CNI Plugins&lt;/h2>
&lt;p>Every network provider has a CNI plugin which is invoked by the container runtime to configure network for a pod as it&amp;rsquo;s started. With containerd as the container runtime,
&lt;a href="https://github.com/containerd/cri" target="_blank" rel="noopener">Containerd CRI plugin&lt;/a> invokes the CNI plugin. Every network provider also has an agent that&amp;rsquo;s installed on each of the kubernetes node to configure pod networking. When the network provider agent is installed, it either ships with the CNI config or it creates one on the node which is then used by the CRI plugin to figure out which CNI plugin to call.&lt;/p>
&lt;p>The location for the CNI config file is configurable and the default value is &lt;code>/etc/cni/net.d/&amp;lt;config-file&amp;gt;&lt;/code>. CNI plugins need to be shipped on every node by the cluster administrators. The location for CNI plugins is configurable as well and the default value is &lt;code>/opt/cni/bin&lt;/code>.&lt;/p>
&lt;p>In case of containerd as the container runtime, path for CNI configuration and CNI plugin binaries can be specified under &lt;code>[plugins.&amp;quot;io.containerd.grpc.v1.cri&amp;quot;.cni]&lt;/code> section of the
&lt;a href="https://github.com/containerd/cri/blob/master/docs/config.md" target="_blank" rel="noopener">containerd config&lt;/a>.&lt;/p>
&lt;p>Since we are referring to Flannel as the network provider here, I&amp;rsquo;ll talk a little about how Flannel is set up. Flanneld is the Flannel daemon and is typically installed on a kubernetes cluster as a daemonset with &lt;code>install-cni&lt;/code> as an
&lt;a href="https://github.com/coreos/flannel/blob/master/Documentation/kube-flannel.yml#L172" target="_blank" rel="noopener">init container&lt;/a>. The &lt;code>install-cni&lt;/code> container creates the
&lt;a href="https://gist.github.com/ronaknnathani/957a56210bd4fbd8e11120273c6b4ede" target="_blank" rel="noopener">CNI configuration file&lt;/a> - &lt;code>/etc/cni/net.d/10-flannel.conflist&lt;/code> - on each node. Flanneld creates a vxlan device, fetches networking metadata from the apiserver and watches for updates on pods. As pods are created, it distributes routes for all pods across the entire cluster and these routes allow pods to connect to each other via their IP addresses. For details on how flannel works, I recommend the
&lt;a href="#how-flannel-works">linked references below&lt;/a>.&lt;/p>
&lt;p>The interactions between Containerd CRI Plugin and CNI plugins can be visualized as follows:
&lt;img src="kubelet-cri-cni-interactions.png" alt="kubelet-cri-cni-interactions">&lt;/p>
&lt;p>As described above, kubelet calls the Containerd CRI plugin in order to create a pod and Containerd CRI plugin calls the CNI plugin to configure network for the pod. The network provider CNI plugin calls other base CNI plugins to configure the network. The interactions between CNI plugins are described below.&lt;/p>
&lt;h3 id="interactions-between-cni-plugins">Interactions Between CNI Plugins&lt;/h3>
&lt;p>There are various CNI plugins that help configure networking between containers on a host. For this post, we will refer to 3 plugins.&lt;/p>
&lt;h4 id="flannel-cni-plugin">Flannel CNI Plugin&lt;/h4>
&lt;p>When using Flannel as the network provider, the Containerd CRI plugin invokes the
&lt;a href="https://github.com/containernetworking/plugins/tree/master/plugins/meta/flannel" target="_blank" rel="noopener">Flannel CNI plugin&lt;/a> using the CNI configuration file - &lt;code>/etc/cni/net.d/10-flannel.conflist&lt;/code>.&lt;/p>
&lt;pre>&lt;code>$ cat /etc/cni/net.d/10-flannel.conflist
{
&amp;quot;name&amp;quot;: &amp;quot;cni0&amp;quot;,
&amp;quot;plugins&amp;quot;: [
{
&amp;quot;type&amp;quot;: &amp;quot;flannel&amp;quot;,
&amp;quot;delegate&amp;quot;: {
&amp;quot;ipMasq&amp;quot;: false,
&amp;quot;hairpinMode&amp;quot;: true,
&amp;quot;isDefaultGateway&amp;quot;: true
}
}
]
}
&lt;/code>&lt;/pre>
&lt;p>The Fannel CNI plugin works in conjunction with Flanneld. When Flanneld starts up, it fetches the podCIDR and other network related details from the apiserver and stores them in a file - &lt;code>/run/flannel/subnet.env&lt;/code>.&lt;/p>
&lt;pre>&lt;code>FLANNEL_NETWORK=10.244.0.0/16
FLANNEL_SUBNET=10.244.0.1/24
FLANNEL_MTU=1450
FLANNEL_IPMASQ=false
&lt;/code>&lt;/pre>
&lt;p>The Flannel CNI plugin uses the information in &lt;code>/run/flannel/subnet.env&lt;/code> to configure and invoke the bridge CNI plugin.&lt;/p>
&lt;h4 id="bridge-cni-plugin">Bridge CNI Plugin&lt;/h4>
&lt;p>Flannel CNI plugin calls the Bridge CNI plugin with the following configuration:&lt;/p>
&lt;pre>&lt;code>{
&amp;quot;name&amp;quot;: &amp;quot;cni0&amp;quot;,
&amp;quot;type&amp;quot;: &amp;quot;bridge&amp;quot;,
&amp;quot;mtu&amp;quot;: 1450,
&amp;quot;ipMasq&amp;quot;: false,
&amp;quot;isGateway&amp;quot;: true,
&amp;quot;ipam&amp;quot;: {
&amp;quot;type&amp;quot;: &amp;quot;host-local&amp;quot;,
&amp;quot;subnet&amp;quot;: &amp;quot;10.244.0.0/24&amp;quot;
}
}
&lt;/code>&lt;/pre>
&lt;p>When
&lt;a href="https://github.com/containernetworking/plugins/tree/master/plugins/main/bridge" target="_blank" rel="noopener">Bridge CNI plugin&lt;/a> is invoked for the first time, it creates a linux bridge with the &lt;code>&amp;quot;name&amp;quot;: &amp;quot;cni0&amp;quot;&lt;/code> specified in the config file. For every pod, it then creates a veth pair - one end of the pair is in the container&amp;rsquo;s network namespace and the other end is connected to the linux bridge on the host network. With Bridge CNI plugin, all containers on a host are connected to the linux bridge on the host network.&lt;/p>
&lt;p>After configuring the veth pair, Bridge plugin invokes the host-local IPAM CNI plugin. Which IPAM plugin to use can be configured in the CNI config CRI plugin uses to call the flannel CNI plugin.&lt;/p>
&lt;h4 id="host-local-ipam-cni-plugins">Host-local IPAM CNI plugins&lt;/h4>
&lt;p>The Bridge CNI plugin calls the
&lt;a href="https://github.com/containernetworking/plugins/tree/master/plugins/ipam/host-local" target="_blank" rel="noopener">host-local IPAM CNI plugin&lt;/a> with the following configuration:&lt;/p>
&lt;pre>&lt;code>{
&amp;quot;name&amp;quot;: &amp;quot;cni0&amp;quot;,
&amp;quot;ipam&amp;quot;: {
&amp;quot;type&amp;quot;: &amp;quot;host-local&amp;quot;,
&amp;quot;subnet&amp;quot;: &amp;quot;10.244.0.0/24&amp;quot;,
&amp;quot;dataDir&amp;quot;: &amp;quot;/var/lib/cni/networks&amp;quot;
}
}
&lt;/code>&lt;/pre>
&lt;p>Host-local IPAM (IP Address Management) plugin returns an IP address for the container from the &lt;code>subnet&lt;/code> and stores the allocated IP locally on the host under the directory specified under &lt;code>dataDir&lt;/code> - &lt;code>/var/lib/cni/networks/&amp;lt;network-name=cni0&amp;gt;/&amp;lt;ip&amp;gt;&lt;/code>. &lt;code>/var/lib/cni/networks/&amp;lt;network-name=cni0&amp;gt;/&amp;lt;ip&amp;gt;&lt;/code> file contains the container ID to which the IP is assigned.&lt;/p>
&lt;p>When invoked, the host-local IPAM plugin returns the following payload&lt;/p>
&lt;pre>&lt;code>{
&amp;quot;ip4&amp;quot;: {
&amp;quot;ip&amp;quot;: &amp;quot;10.244.4.2&amp;quot;,
&amp;quot;gateway&amp;quot;: &amp;quot;10.244.4.3&amp;quot;
},
&amp;quot;dns&amp;quot;: {}
}
&lt;/code>&lt;/pre>
&lt;h2 id="summary">Summary&lt;/h2>
&lt;p>Kube-controller-manager assigns a podCIDR to each node. Pods on a node are assigned an IP address from the subnet value in podCIDR. Because podCIDRs across all nodes are disjoint subnets, it allows assigning each pod a unique IP address.&lt;/p>
&lt;p>Kubernetes cluster administrator configures and installs kubelet, container runtime, network provider agent and distributes CNI plugins on each node. When network provider agent starts, it generates a CNI config. When a pod is scheduled on a node, kubelet calls the CRI plugin to create the pod. In containerd&amp;rsquo;s case, Containerd CRI plugin then calls the CNI plugin specified in the CNI config to configure the pod network. And all of this results in a pod getting an IP address.&lt;/p>
&lt;hr>
&lt;p>It took me a while to understand all the interactions and the details involved. I hope this helped you in improving your understanding of how kubernetes works. If you think I got something wrong, please let me know via
&lt;a href="https://twitter.com/RonakNathani" target="_blank" rel="noopener">twitter&lt;/a> or email me at
&lt;a href="mailto:hello@ronaknathani.com">hello@ronaknathani.com&lt;/a>. If you&amp;rsquo;d like to discuss something in this post or anything else, feel free to reach out. I&amp;rsquo;d love to hear from you!&lt;/p>
&lt;hr>
&lt;h2 id="references">References&lt;/h2>
&lt;h3 id="container-networking">Container Networking&lt;/h3>
&lt;ul>
&lt;li>
&lt;a href="https://jvns.ca/blog/2016/12/22/container-networking/" target="_blank" rel="noopener">A container networking overview&lt;/a>&lt;/li>
&lt;li>
&lt;a href="https://blog.mbrt.dev/2017-10-01-demystifying-container-networking/" target="_blank" rel="noopener">Demystifying container networking&lt;/a>&lt;/li>
&lt;/ul>
&lt;h3 id="how-flannel-works">How Flannel Works&lt;/h3>
&lt;ul>
&lt;li>
&lt;a href="https://msazure.club/flannel-networking-demystify/" target="_blank" rel="noopener">Flannel Networking Demistify&lt;/a>&lt;/li>
&lt;li>
&lt;a href="https://medium.com/@anilkreddyr/kubernetes-with-flannel-understanding-the-networking-part-2-78b53e5364c7" target="_blank" rel="noopener">Kubernetes With Flannel - Understanding The Networking&lt;/a>&lt;/li>
&lt;/ul>
&lt;h3 id="cri-and-cni">CRI and CNI&lt;/h3>
&lt;ul>
&lt;li>
&lt;a href="https://github.com/containerd/cri/blob/v1.11.1/docs/architecture.md" target="_blank" rel="noopener">CRI Plugin Architecture&lt;/a>&lt;/li>
&lt;li>
&lt;a href="https://github.com/containernetworking/cni/blob/master/SPEC.md" target="_blank" rel="noopener">CNI Spec&lt;/a>&lt;/li>
&lt;li>
&lt;a href="https://github.com/containernetworking/plugins" target="_blank" rel="noopener">CNI Plugins&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Kubernetes NodePort and iptables rules</title><link>https://ronaknathani.pages.dev/blog/2020/07/kubernetes-nodeport-and-iptables-rules/</link><pubDate>Wed, 08 Jul 2020 14:36:33 -0700</pubDate><guid>https://ronaknathani.pages.dev/blog/2020/07/kubernetes-nodeport-and-iptables-rules/</guid><description>&lt;p>I have been working on kubernetes over the last few months and having fun learning about the underlying systems. When I started using
&lt;a href="%28https://kubernetes.io/docs/concepts/services-networking/service/%29">kubernetes services&lt;/a>, I wanted to learn about the iptables rules that kube-proxy creates to enable them, however, I didn&amp;rsquo;t exactly know where to start. While there are some really good posts explaining
&lt;a href="https://www.stackrox.com/post/2020/01/kubernetes-networking-demystified/" target="_blank" rel="noopener">kubernetes networking&lt;/a> and how the concept of
&lt;a href="https://kubernetes.io/docs/concepts/services-networking/service/#proxy-mode-iptables" target="_blank" rel="noopener">services works&lt;/a>, I couldn&amp;rsquo;t easily find a starting point to explore these iptables except reading the code or trying to make sense of the output of &lt;code>iptables-save&lt;/code> command (which I can&amp;rsquo;t).&lt;/p>
&lt;p>In this post, I share some of what I have learned by digging a little deeper into the iptables rules for NodePort type services and share answers to the following questions I came across while working on kube-proxy:&lt;/p>
&lt;ul>
&lt;li>What happens when a non-kubernetes process starts using a port that&amp;rsquo;s allocated as a NodePort to a service?&lt;/li>
&lt;li>Does the service endpoint continue to route traffic to pods if kube-proxy (configured in iptables mode) process dies on the node?&lt;/li>
&lt;/ul>
&lt;h2 id="some-background">Some background&lt;/h2>
&lt;p>Kubernetes allows a
&lt;a href="https://kubernetes.io/docs/concepts/services-networking/service/#publishing-services-service-types" target="_blank" rel="noopener">few ways to expose applications&lt;/a> to the world outside the kubernetes cluster through the concept of
&lt;a href="https://kubernetes.io/docs/concepts/services-networking/service/" target="_blank" rel="noopener">services&lt;/a>. In our setup at work, we expose a few of our apps through
&lt;a href="https://kubernetes.io/docs/concepts/services-networking/service/#nodeport" target="_blank" rel="noopener">NodePort&lt;/a> type services. Also,
&lt;a href="https://kubernetes.io/docs/concepts/overview/components/#kube-proxy" target="_blank" rel="noopener">kube-proxy&lt;/a> is the kubernetes component that powers the
&lt;a href="https://kubernetes.io/docs/concepts/services-networking/service/" target="_blank" rel="noopener">services concept&lt;/a> and we run it in iptables mode (default).&lt;/p>
&lt;h2 id="a-little-about-nodeport">A little about NodePort&lt;/h2>
&lt;p>
&lt;a href="https://kubernetes.io/docs/concepts/services-networking/service/#nodeport" target="_blank" rel="noopener">NodePort&lt;/a> is one of the ways of exposing
&lt;a href="https://kubernetes.io/docs/concepts/services-networking/service/" target="_blank" rel="noopener">Kubernetes Services&lt;/a> to the world outside the kubernetes cluster. As per Kubernetes
&lt;a href="https://kubernetes.io/docs/concepts/services-networking/service/#nodeport" target="_blank" rel="noopener">docs&lt;/a>,&lt;/p>
&lt;blockquote>
&lt;p>If you set the type field to NodePort, the Kubernetes control plane allocates a port from a range specified by &lt;code>--service-node-port-range&lt;/code> flag &lt;code>(default: 30000-32767)&lt;/code>. Each node proxies that port (the same port number on every Node) into your Service.&lt;/p>
&lt;/blockquote>
&lt;p>This means that if we have a &lt;code>service&lt;/code> with NodePort &lt;code>30000&lt;/code>, a request to &lt;code>&amp;lt;kubernetes-node-ip&amp;gt;:30000&lt;/code> will get routed to our app. Under normal circumstances kube-proxy
&lt;a href="https://github.com/kubernetes/kubernetes/blob/7de3f938c82a7c0538bdfac5bb9b3cad50c30eae/pkg/proxy/iptables/proxier.go#L1638" target="_blank" rel="noopener">binds and listens&lt;/a> on all NodePorts to ensure these ports stay reserved and no other processes can use them.&lt;/p>
&lt;h3 id="erra-non-kubernetes-is-using-the-nodeport">err&amp;hellip;a non-kubernetes is using the NodePort!!&lt;/h3>
&lt;p>A few weeks back at work, one of our kubernetes nodes was rebooted and as it was added back to the cluster, we saw errors in kube-proxy logs - &lt;code>bind: address already in use&lt;/code>:&lt;/p>
&lt;pre>&lt;code class="language-bash">I0707 20:57:38.648179 1 proxier.go:701] Syncing iptables rules
E0707 20:57:38.679876 1 proxier.go:1072] can't open &amp;quot;nodePort for default/azure-vote-front:&amp;quot; (:30450/tcp), skipping this nodePort: listen tcp :30450: bind: address already in use
&lt;/code>&lt;/pre>
&lt;p>This error meant that a port allocated as a NodePort to a &lt;code>service&lt;/code> was already in use by another process. We found out that there was a non-kubernetes process using this NodePort as a client port to connect to a remote server. This happened due of a race condition post node reboot. This other process started using the NodePort before kube-proxy could bind and listen on the port to reserve it.&lt;/p>
&lt;h2 id="will-the-nodeport-on-this-node-still-route-traffic-to-the-pods">Will the NodePort on this node still route traffic to the pods?&lt;/h2>
&lt;p>This was the immediate question that popped up in our heads as the NodePort allocated to a &lt;code>service&lt;/code> was now being used by a non-kubernetes process. One of my colleagues,
&lt;a href="https://www.linkedin.com/in/alexanderdudko/" target="_blank" rel="noopener">Alex Dudko&lt;/a> pointed out that kube-proxy creates iptables rules in the &lt;strong>PREROUTING&lt;/strong> chain in &lt;strong>nat&lt;/strong> table.&lt;/p>
&lt;p>Because of these rules, the answer to the above question is - &lt;strong>Yes!&lt;/strong> Traffic sent to the NodePort on this node will still correctly be routed to the backend pods it targets.&lt;/p>
&lt;p>The NodePort would also continue to work if another process was listening on the port as a server and not just using it as a client. Though, would our &lt;code>HTTP GET&lt;/code> requests reach this server? More on this later.&lt;/p>
&lt;h2 id="kube-proxy-iptables-rules">Kube-proxy iptables rules&lt;/h2>
&lt;p>In iptables mode, kube-proxy creates iptables rules for kubernetes services which ensure that the request to the &lt;code>service&lt;/code> gets routed (and load balanced) to the appropriate pods.&lt;/p>
&lt;p>These iptables rules also help answer the second question mentioned above. As long as these iptables rules exist, requests to &lt;code>services&lt;/code> will get routed to the appropriate pods even if kube-proxy process dies on the node. Endpoints for new &lt;code>services&lt;/code> won&amp;rsquo;t work from this node, however, since kube-proxy process won&amp;rsquo;t create the iptables rules for it.&lt;/p>
&lt;h3 id="rules-in-the-prerouting-chain">Rules in the PREROUTING chain&lt;/h3>
&lt;p>As outlined in this
&lt;a href="https://docs.google.com/drawings/d/1MtWL8qRTs6PlnJrW4dh8135_S9e2SaawT410bJuoBPk/preview" target="_blank" rel="noopener">flow chart&lt;/a>, there are rules in the &lt;strong>PREROUTING&lt;/strong> chain of the &lt;strong>nat&lt;/strong> table for kubernetes services. As per
&lt;a href="https://upload.wikimedia.org/wikipedia/commons/3/37/Netfilter-packet-flow.svg" target="_blank" rel="noopener">iptables rules evaluation order&lt;/a>, rules in the &lt;strong>PREROUTING&lt;/strong> chain are the first ones to be consulted as a packet enters the linux kernel&amp;rsquo;s networking stack.&lt;/p>
&lt;p>Rules created by kube-proxy in the &lt;strong>PREROUTING&lt;/strong> chain help determine if the packet is meant for a local socket on the node or if it should be forwarded to a pod. It is these rules that ensure that the request to &lt;code>&amp;lt;kubernetes-node-ip&amp;gt;:&amp;lt;NodePort&amp;gt;&lt;/code> continue to get routed to pods even if the NodePort is in use by another process.&lt;/p>
&lt;p>Below is an over-simplified diagram that demonstrates this:&lt;/p>
&lt;p>&lt;img src="prerouting-rule.png" alt="prerouting-rule effect">&lt;/p>
&lt;p>In rest of this post, I walk through a setup that reproduces the above scenario and examine the iptables rules that make this work.&lt;/p>
&lt;h2 id="setting-up-a-kubernetes-cluster">Setting up a Kubernetes cluster&lt;/h2>
&lt;p>I created a kubernetes cluster using
&lt;a href="https://docs.microsoft.com/en-us/azure/aks/kubernetes-walkthrough" target="_blank" rel="noopener">Azure Kubernetes Service &lt;/a>.&lt;/p>
&lt;h3 id="creating-pods-and-a-nodeport-service">Creating pods and a NodePort Service&lt;/h3>
&lt;p>From the
&lt;a href="https://docs.microsoft.com/en-us/azure/aks/kubernetes-walkthrough#run-the-application" target="_blank" rel="noopener">AKS docs&lt;/a>, I also created a
&lt;a href="https://gist.github.com/ronaknnathani/49b54d7147e263d8dd3bb6016614f817" target="_blank" rel="noopener">deployment for an app with a frontend and a backend&lt;/a>. I updated the &lt;strong>azure-vote-front&lt;/strong> to &lt;strong>type: NodePort&lt;/strong> service.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>azure-vote-front&lt;/strong> pod in the cluster&lt;/li>
&lt;/ul>
&lt;pre>&lt;code class="language-bash">$ kubectl get po -l app=azure-vote-front --show-labels -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES LABELS
azure-vote-front-5bc759676c-x9bwg 1/1 Running 0 24d 10.244.0.11 aks-nodepool1-22391620-vmss000000 &amp;lt;none&amp;gt; &amp;lt;none&amp;gt; app=azure-vote-front,pod-template-hash=5bc759676c
&lt;/code>&lt;/pre>
&lt;ul>
&lt;li>&lt;strong>azure-vote-front&lt;/strong> service targeting the above pod&lt;/li>
&lt;/ul>
&lt;pre>&lt;code class="language-bash">$ kubectl get svc azure-vote-front -o wide
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE SELECTOR
azure-vote-front NodePort 10.0.237.3 &amp;lt;none&amp;gt; 80:30450/TCP 24d app=azure-vote-front
&lt;/code>&lt;/pre>
&lt;p>The above output shows that the port &lt;strong>30450&lt;/strong> is assigned as the NodePort to our service. And we can send HTTP requests to the app from host network:&lt;/p>
&lt;pre>&lt;code class="language-bash">$ curl -IX GET localhost:30450
HTTP/1.1 200 OK
Server: nginx/1.13.7
Date: Wed, 08 Jul 2020 00:02:08 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 950
Connection: keep-alive
$ curl -sSL localhost:30450 | grep title
&amp;lt;title&amp;gt;Azure Voting App&amp;lt;/title&amp;gt;
&lt;/code>&lt;/pre>
&lt;h3 id="examining-iptables-rules-for-a-nodeport-service">Examining iptables rules for a NodePort service&lt;/h3>
&lt;p>&lt;em>Note: AKS doesn&amp;rsquo;t allow ssh&amp;rsquo;ing into an AKS worker node directly, instead, a pod needs to be spun up to ssh into the worker node. Instructions
&lt;a href="https://docs.microsoft.com/en-us/azure/aks/ssh#configure-virtual-machine-scale-set-based-aks-clusters-for-ssh-access" target="_blank" rel="noopener">here&lt;/a>.&lt;/em>&lt;/p>
&lt;p>Let&amp;rsquo;s look at the iptables rules for the &lt;strong>azure-vote-front&lt;/strong> service.&lt;/p>
&lt;p>After ssh&amp;rsquo;ing into the kubernetes worker node, let&amp;rsquo;s look at the &lt;strong>PREROUTING&lt;/strong> chain in the &lt;strong>nat&lt;/strong> table. (&lt;code>PREROUTING&lt;/code>
&lt;a href="https://www.digitalocean.com/community/tutorials/a-deep-dive-into-iptables-and-netfilter-architecture#which-chains-are-implemented-in-each-table" target="_blank" rel="noopener">chain exists&lt;/a> in &lt;code>raw&lt;/code>, &lt;code>nat&lt;/code> and &lt;code>mangle&lt;/code> tables, however, kube-proxy only creates &lt;code>PREROUTING&lt;/code> chain rules in &lt;code>nat&lt;/code> table)&lt;/p>
&lt;pre>&lt;code class="language-bash">$ sudo iptables -t nat -L PREROUTING | column -t
Chain PREROUTING (policy ACCEPT)
target prot opt source destination
KUBE-SERVICES all -- anywhere anywhere /* kubernetes service portals */
DOCKER all -- anywhere anywhere ADDRTYPE match dst-type LOCAL
&lt;/code>&lt;/pre>
&lt;p>There&amp;rsquo;s a &lt;code>KUBE-SERVICES&lt;/code> chain in the target that&amp;rsquo;s created by kube-proxy. Let&amp;rsquo;s list the rules in that chain.&lt;/p>
&lt;pre>&lt;code class="language-bash">$ sudo iptables -t nat -L KUBE-SERVICES -n | column -t
Chain KUBE-SERVICES (2 references)
target prot opt source destination
KUBE-MARK-MASQ tcp -- !10.244.0.0/16 10.0.202.188 /* kube-system/healthmodel-replicaset-service: cluster IP */ tcp dpt:25227
KUBE-SVC-KA44REDDIFNWY4W2 tcp -- 0.0.0.0/0 10.0.202.188 /* kube-system/healthmodel-replicaset-service: cluster IP */ tcp dpt:25227
KUBE-MARK-MASQ udp -- !10.244.0.0/16 10.0.0.10 /* kube-system/kube-dns:dns cluster IP */ udp dpt:53
KUBE-SVC-TCOU7JCQXEZGVUNU udp -- 0.0.0.0/0 10.0.0.10 /* kube-system/kube-dns:dns cluster IP */ udp dpt:53
KUBE-MARK-MASQ tcp -- !10.244.0.0/16 10.0.0.10 /* kube-system/kube-dns:dns-tcp cluster IP */ tcp dpt:53
KUBE-SVC-ERIFXISQEP7F7OF4 tcp -- 0.0.0.0/0 10.0.0.10 /* kube-system/kube-dns:dns-tcp cluster IP */ tcp dpt:53
KUBE-MARK-MASQ tcp -- !10.244.0.0/16 10.0.190.160 /* kube-system/kubernetes-dashboard: cluster IP */ tcp dpt:80
KUBE-SVC-XGLOHA7QRQ3V22RZ tcp -- 0.0.0.0/0 10.0.190.160 /* kube-system/kubernetes-dashboard: cluster IP */ tcp dpt:80
KUBE-MARK-MASQ tcp -- !10.244.0.0/16 10.0.195.6 /* kube-system/metrics-server: cluster IP */ tcp dpt:443
KUBE-SVC-LC5QY66VUV2HJ6WZ tcp -- 0.0.0.0/0 10.0.195.6 /* kube-system/metrics-server: cluster IP */ tcp dpt:443
KUBE-MARK-MASQ tcp -- !10.244.0.0/16 10.0.129.119 /* default/azure-vote-back: cluster IP */ tcp dpt:6379
KUBE-SVC-JQ4VBJ2YWO22DDZW tcp -- 0.0.0.0/0 10.0.129.119 /* default/azure-vote-back: cluster IP */ tcp dpt:6379
KUBE-MARK-MASQ tcp -- !10.244.0.0/16 10.0.237.3 /* default/azure-vote-front: cluster IP */ tcp dpt:80
KUBE-SVC-GHSLGKVXVBRM4GZX tcp -- 0.0.0.0/0 10.0.237.3 /* default/azure-vote-front: cluster IP */ tcp dpt:80
KUBE-MARK-MASQ tcp -- !10.244.0.0/16 10.0.0.1 /* default/kubernetes:https cluster IP */ tcp dpt:443
KUBE-SVC-NPX46M4PTMTKRN6Y tcp -- 0.0.0.0/0 10.0.0.1 /* default/kubernetes:https cluster IP */ tcp dpt:443
KUBE-NODEPORTS all -- 0.0.0.0/0 0.0.0.0/0 /* kubernetes service nodeports; NOTE: this must be the last rule in this chain */ ADDRTYPE match dst-type LOCAL
&lt;/code>&lt;/pre>
&lt;p>The last terget in the &lt;code>KUBE-SERVICES&lt;/code> chain is the &lt;code>KUBE-NODEPORTS&lt;/code> chain. Since the service we created is of type &lt;code>NodePort&lt;/code>, let&amp;rsquo;s list the rules in &lt;code>KUBE-NODEPORTS&lt;/code> chain.&lt;/p>
&lt;pre>&lt;code class="language-bash">$ sudo iptables -t nat -L KUBE-NODEPORTS -n | column -t
Chain KUBE-NODEPORTS (1 references)
target prot opt source destination
KUBE-MARK-MASQ tcp -- 0.0.0.0/0 0.0.0.0/0 /* default/azure-vote-front: */ tcp dpt:30450
KUBE-SVC-GHSLGKVXVBRM4GZX tcp -- 0.0.0.0/0 0.0.0.0/0 /* default/azure-vote-front: */ tcp dpt:30450
&lt;/code>&lt;/pre>
&lt;p>We can see that the above targets are for packets destined to our NodePort &lt;strong>30450&lt;/strong>. The comments also show the namespace/pod name - &lt;code>default/azure-vote-front&lt;/code>. For requests originating from outside the cluster and destined to our app running as a pod, the &lt;code>KUBE-MARK-MASQ&lt;/code> rule marks the packet to be altered later in the &lt;code>POSTROUTING&lt;/code> chain to use SNAT (source network address translation) to rewrite the source IP as the node IP (so that other hosts outside the pod network can reply back).&lt;/p>
&lt;p>Since &lt;code>KUBE-MARK-MASQ&lt;/code> target is to
&lt;a href="https://www.frozentux.net/iptables-tutorial/chunkyhtml/x4422.html" target="_blank" rel="noopener">MASQUERADE&lt;/a> packets later, let&amp;rsquo;s follow the &lt;code>KUBE-SVC-GHSLGKVXVBRM4GZX&lt;/code> chain to further examine our service.&lt;/p>
&lt;pre>&lt;code class="language-bash">$ sudo iptables -t nat -L KUBE-SVC-GHSLGKVXVBRM4GZX -n | column -t
Chain KUBE-SVC-GHSLGKVXVBRM4GZX (2 references)
target prot opt source destination
KUBE-SEP-QXDNOBCCLOXLV7LV all -- 0.0.0.0/0 0.0.0.0/0
$ sudo iptables -t nat -L KUBE-SEP-QXDNOBCCLOXLV7LV -n | column -t
Chain KUBE-SEP-QXDNOBCCLOXLV7LV (1 references)
target prot opt source destination
KUBE-MARK-MASQ all -- 10.244.0.11 0.0.0.0/0
DNAT tcp -- 0.0.0.0/0 0.0.0.0/0 tcp to:10.244.0.11:80
&lt;/code>&lt;/pre>
&lt;p>Here we see the
&lt;a href="https://www.frozentux.net/iptables-tutorial/chunkyhtml/x4033.html" target="_blank" rel="noopener">DNAT (Destination Network Address Translation)&lt;/a> target is used to rewrite the destination of the packet destined to port &lt;strong>30450&lt;/strong> to our pod &lt;strong>10.244.0.11:80&lt;/strong>. We can verify the &lt;strong>podIP&lt;/strong> and &lt;strong>containerPort&lt;/strong> of our pod as follows:&lt;/p>
&lt;pre>&lt;code class="language-bash">$ kubectl get po azure-vote-front-5bc759676c-x9bwg -o json | jq -r '[.status.podIP, .spec.containers[0].ports[0].containerPort | tostring] | join(&amp;quot;:&amp;quot;)'
10.244.0.11:80
&lt;/code>&lt;/pre>
&lt;h3 id="verify-kube-proxy-is-listening-on-nodeport">Verify kube-proxy is listening on NodePort&lt;/h3>
&lt;p>Under normal circumstances kube-proxy binds and listens on all NodePorts to ensure these ports stay reserved and no other processes can use them. We can verify this on the above kubernetes node:&lt;/p>
&lt;pre>&lt;code class="language-bash">$ sudo lsof -i:30450
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
hyperkube 11558 root 9u IPv6 95841832 0t0 TCP *:30450 (LISTEN)
$ ps -aef | grep -v grep | grep 11558
root 11558 11539 0 Jul02 ? 00:06:37 /hyperkube kube-proxy --kubeconfig=/var/lib/kubelet/kubeconfig --cluster-cidr=10.244.0.0/16 --feature-gates=ExperimentalCriticalPodAnnotation=true --v=3
&lt;/code>&lt;/pre>
&lt;p>kube-proxy is listening on NodePort &lt;strong>30450&lt;/strong>.&lt;/p>
&lt;h3 id="create-a-non-kubernetes-process-use-the-nodeport">Create a non-kubernetes process use the NodePort&lt;/h3>
&lt;p>Now let&amp;rsquo;s kill kube-proxy process and start a server that listens on this NodePort instead.&lt;/p>
&lt;pre>&lt;code class="language-bash">$ kill -9 11558
$ python -m SimpleHTTPServer 30450 &amp;amp;
[1] 123578
$ sudo lsof -i:30450
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
python 123578 azureuser 3u IPv4 95854834 0t0 TCP *:30450 (LISTEN)
$ ps -aef | grep -v grep | grep 123578
azureus+ 123578 85107 0 00:49 pts/5 00:00:00 python -m SimpleHTTPServer 30450
&lt;/code>&lt;/pre>
&lt;h3 id="where-will-the-requests-to-nodeport-be-routed---pod-or-the-non-kubernetes-process">Where will the requests to NodePort be routed - pod or the non-kubernetes process?&lt;/h3>
&lt;p>While the python process is listening on port &lt;strong>30450&lt;/strong>, also allocated as a &lt;code>NodePort&lt;/code> to our kubernetes service, the iptables rules in the &lt;strong>PREROUTING&lt;/strong> chain will route all requests to port &lt;strong>30450&lt;/strong> to our pod. We can verify this as below:&lt;/p>
&lt;pre>&lt;code class="language-bash">$ curl -I -XGET localhost:30450
HTTP/1.1 200 OK
Server: nginx/1.13.7
Date: Wed, 08 Jul 2020 00:53:09 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 950
Connection: keep-alive
&lt;/code>&lt;/pre>
&lt;p>As we send a &lt;code>GET&lt;/code> request to port &lt;strong>30450&lt;/strong>, we receive a response from an nginx server hosting &lt;strong>Azure Voting App&lt;/strong>, like we saw when
&lt;a href="#creating-pods-and-a-nodeport-service">kube-proxy was listening on port &lt;strong>30450&lt;/strong>&lt;/a>.&lt;/p>
&lt;pre>&lt;code class="language-bash">$ curl -sSL localhost:30450 | grep title
&amp;lt;title&amp;gt;Azure Voting App&amp;lt;/title&amp;gt;
&lt;/code>&lt;/pre>
&lt;h2 id="summary">Summary&lt;/h2>
&lt;ul>
&lt;li>When kube-proxy is used in iptables mode, routing requests to &lt;strong>services&lt;/strong> continues to work for existing services even when the kube-proxy process dies on the node&lt;/li>
&lt;li>Kube-proxy binds and listens (on all k8s nodes) to all ports allocated as NodePorts to ensure these ports stay reserved and no other processes can use them&lt;/li>
&lt;li>Even if a process starts using NodePort, iptables rules (because they are in &lt;strong>PRESOUTING&lt;/strong> chain) ensure that the traffic sent to the &lt;code>NodePort&lt;/code> gets routed to the pods&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>
&lt;a href="https://twitter.com/thockin" target="_blank" rel="noopener">@thockin&amp;rsquo;s&lt;/a> tweet,
&lt;a href="https://twitter.com/thockin/status/1191766983735296000" target="_blank" rel="noopener">describing kube-proxy iptables rules&lt;/a> in a
&lt;a href="https://docs.google.com/drawings/d/1MtWL8qRTs6PlnJrW4dh8135_S9e2SaawT410bJuoBPk/preview" target="_blank" rel="noopener">flow chart&lt;/a>, is an excellent way to holistically follow a packet&amp;rsquo;s path through various iptables rules before it reaches the destined pod.&lt;/li>
&lt;li>
&lt;a href="https://www.stackrox.com/post/2020/01/kubernetes-networking-demystified/" target="_blank" rel="noopener">Kubernetes Networking Demystified: A Brief Guide&lt;/a> is a great post explaining how kubernetes services and kubernetes networking work.&lt;/li>
&lt;/ul></description></item></channel></rss>