<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://harche.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://harche.github.io/" rel="alternate" type="text/html" /><updated>2025-10-23T11:10:21+00:00</updated><id>https://harche.github.io/feed.xml</id><title type="html">Harshal’s Notes</title><subtitle>Deep dives and demos from the lab</subtitle><author><name>Harshal Patil</name></author><entry><title type="html">Kubernetes CUDA IPC: The Permissions I Wish I Knew</title><link href="https://harche.github.io/2025/10/15/kubernetes-cuda-ipc-permissions.html" rel="alternate" type="text/html" title="Kubernetes CUDA IPC: The Permissions I Wish I Knew" /><published>2025-10-15T00:00:00+00:00</published><updated>2025-10-15T00:00:00+00:00</updated><id>https://harche.github.io/2025/10/15/kubernetes-cuda-ipc-permissions</id><content type="html" xml:base="https://harche.github.io/2025/10/15/kubernetes-cuda-ipc-permissions.html"><![CDATA[<p><em>Author: <a href="https://github.com/harche" target="_blank" rel="noopener">Harshal Patil</a></em></p>

<p>I went down a rabbit hole trying to get CUDA Inter-Process Communication (IPC) working reliably in Kubernetes, without throwing security out the window. I tested four real setups, flipped every permission I could, broke things on purpose, and kept notes on what actually mattered.</p>

<p>This post is the write-up I wish I had at the start. It keeps all the gritty details, but tells the story like a human who has had <code class="language-plaintext highlighter-rouge">cudaIpcOpenMemHandle()</code> stare back with “invalid argument” at 2 a.m.</p>

<h2 id="tldr">TL;DR</h2>

<ul>
  <li>hostIPC is not required if you pass IPC handles via a shared file or volume.</li>
  <li>DRA (Dynamic Resource Allocation, Kubernetes 1.31+) removes the need for privileged containers.</li>
  <li>For single-pod setups, shareProcessNamespace is a safer substitute for hostPID.</li>
  <li>The most secure working setup: single pod + DRA + shareProcessNamespace, with both hostPID and privileged turned off.</li>
</ul>

<p>If you just want working YAML, jump to Configuration #4.</p>

<h2 id="how-the-repo-is-laid-out">How the repo is laid out</h2>

<p>This work lives in the repo: <a href="https://github.com/harche/cuda-ipc-debugging">cuda-ipc-debugging</a>. There are four self-contained examples with code and manifests:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>cuda-ipc-debugging/
|-- shared-volume-example/          # #1 Multi-Pod, traditional device plugin
|-- dra-shared-gpu-example/         # #2 Multi-Pod, DRA
|-- single-pod-example/             # #3 Single Pod, traditional device plugin
`-- single-pod-dra-example/         # #4 Single Pod, DRA (best security)
</code></pre></div></div>

<p>Each directory has a README and runnable YAMLs. All findings below come from actually running these on Kubernetes and toggling permissions until things either worked or failed with specific errors.</p>

<h2 id="a-quick-mental-model-of-the-knobs">A quick mental model of the knobs</h2>

<ul>
  <li>hostIPC: Lets you join the host IPC namespace. For CUDA IPC we do not need this if handles are exchanged via files.</li>
  <li>hostPID: Lets a container see processes in the host PID namespace. CUDA IPC cares about process visibility; without it, the consumer cannot open the producer’s handle in multi-pod setups.</li>
  <li>privileged: Drops most isolation. With the traditional NVIDIA device plugin, it is often used to let multiple containers touch the same physical GPU devices. It is a big security hammer.</li>
  <li>shareProcessNamespace: Shares a PID namespace across containers in the same pod. Crucially, this gives CUDA IPC the process visibility it needs without exposing the whole host.</li>
  <li>DRA: The newer Kubernetes (1.31+) way to allocate GPUs via ResourceClaims. It solves many of the device-sharing problems that used to force us into privileged mode.</li>
</ul>

<p>With that out of the way, here is what I actually saw, configuration by configuration.</p>

<hr />

<h2 id="configuration-1-multi-pod-traditional-device-plugin">Configuration #1: Multi-Pod, traditional device plugin</h2>

<p>Two pods (producer and consumer), a hostPath volume to drop the handle file, and the classic NVIDIA device plugin managing GPU allocation.</p>

<h3 id="what-worked-in-practice">What worked in practice</h3>

<ul>
  <li><code class="language-plaintext highlighter-rouge">hostPID</code> must be true; without it, the consumer cannot see the producer’s process context and you will hit “invalid device context”.</li>
  <li><code class="language-plaintext highlighter-rouge">hostIPC</code> is not needed.</li>
  <li><code class="language-plaintext highlighter-rouge">privileged</code> unfortunately is required. Without it, the two pods end up with access to different physical GPUs, and <code class="language-plaintext highlighter-rouge">cudaIpcOpenMemHandle()</code> returns “invalid argument”. Privileged lets both pods see the device nodes they need to coordinate on the same physical GPU.</li>
</ul>

<h3 id="why-privileged-really-matters-here">Why privileged really matters here</h3>

<p>The device plugin isolates GPUs per pod. I verified with <code class="language-plaintext highlighter-rouge">strace</code>, Bus-Id checks, and by toggling capabilities (SYS_ADMIN, MKNOD, DAC_OVERRIDE, SYS_RAWIO); none of those capabilities alone fixed it. Privileged did, because it bypasses the device isolation enough for both pods to land on the same card.</p>

<h3 id="producer-pod-example">Producer pod (example)</h3>

<p>The full manifests live in the repo: <a href="https://github.com/harche/cuda-ipc-debugging/blob/main/shared-volume-example/producer-pod.yaml">shared-volume-example/producer-pod.yaml</a> and the matching <a href="https://github.com/harche/cuda-ipc-debugging/blob/main/shared-volume-example/consumer-pod.yaml">consumer-pod.yaml</a>.</p>

<h3 id="when-i-would-reach-for-this">When I would reach for this</h3>

<ul>
  <li>Older clusters without DRA.</li>
  <li>Quick experiments or local testing.</li>
</ul>

<h3 id="security-notes">Security notes</h3>

<p>This is the least secure option (privileged plus hostPID). Use it only when you must, and keep it isolated.</p>

<hr />

<h2 id="configuration-2-multi-pod-with-dra">Configuration #2: Multi-Pod with DRA</h2>

<p>Still two pods and a shared volume for the handle file, but GPUs are allocated via a shared ResourceClaim using Kubernetes DRA.</p>

<h3 id="what-changed-with-dra">What changed with DRA</h3>

<ul>
  <li><code class="language-plaintext highlighter-rouge">hostPID</code> still needs to be true for the producer and consumer to see each other’s process context across pods. Disabling it produced “invalid device context”.</li>
  <li><code class="language-plaintext highlighter-rouge">hostIPC</code> continues to be unnecessary.</li>
  <li><code class="language-plaintext highlighter-rouge">privileged</code> can be false. This is the big win. DRA gets both pods coordinated access to the same underlying GPU resources without smashing the isolation wall.</li>
</ul>

<h3 id="gpu-selection-that-stays-sane">GPU selection that stays sane</h3>

<p>I used <code class="language-plaintext highlighter-rouge">CUDA_VISIBLE_DEVICES</code> so each pod sees only the GPU it is meant to touch:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">env</span><span class="pi">:</span>
<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">CUDA_VISIBLE_DEVICES</span>
  <span class="na">value</span><span class="pi">:</span> <span class="s2">"</span><span class="s">0"</span>
</code></pre></div></div>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">env</span><span class="pi">:</span>
<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">CUDA_VISIBLE_DEVICES</span>
  <span class="na">value</span><span class="pi">:</span> <span class="s2">"</span><span class="s">1"</span>
</code></pre></div></div>

<p>Both containers can still use straightforward code like <code class="language-plaintext highlighter-rouge">cudaSetDevice(0)</code> internally.</p>

<h3 id="resourceclaim-example">ResourceClaim (example)</h3>

<p>Full YAML: <a href="https://github.com/harche/cuda-ipc-debugging/blob/main/dra-shared-gpu-example/resource-claim.yaml">dra-shared-gpu-example/resource-claim.yaml</a>.</p>

<h3 id="producer-pod-sketch">Producer pod (sketch)</h3>

<p>Full manifest: <a href="https://github.com/harche/cuda-ipc-debugging/blob/main/dra-shared-gpu-example/producer-pod.yaml">dra-shared-gpu-example/producer-pod.yaml</a>. The consumer pod for this scenario is adjacent in the same directory.</p>

<h3 id="when-i-would-use-this">When I would use this</h3>

<ul>
  <li>Production pipelines that need separate pods.</li>
  <li>Places where privileged is a non-starter but cross-pod GPU IPC is required.</li>
</ul>

<h3 id="security-notes-1">Security notes</h3>

<p>Much better than Configuration #1. You still need <code class="language-plaintext highlighter-rouge">hostPID</code>, but you can keep <code class="language-plaintext highlighter-rouge">privileged</code> off.</p>

<hr />

<h2 id="configuration-3-single-pod-traditional-device-plugin">Configuration #3: Single pod, traditional device plugin</h2>

<p>One pod with two containers. The handle file lives in an <code class="language-plaintext highlighter-rouge">emptyDir</code>. This is where <code class="language-plaintext highlighter-rouge">shareProcessNamespace</code> shines.</p>

<h3 id="what-mattered">What mattered</h3>

<ul>
  <li>You need process visibility between containers. That can be <code class="language-plaintext highlighter-rouge">hostPID: true</code>, or (better) <code class="language-plaintext highlighter-rouge">shareProcessNamespace: true</code>.</li>
  <li>You still need <code class="language-plaintext highlighter-rouge">privileged: true</code> with the traditional device plugin; otherwise you will hit “invalid argument” opening the IPC handle.</li>
  <li><code class="language-plaintext highlighter-rouge">hostIPC</code> is not required.</li>
</ul>

<h3 id="two-minimal-shapes-that-worked">Two minimal shapes that worked</h3>

<p>Option A: hostPID</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">spec</span><span class="pi">:</span>
  <span class="na">hostIPC</span><span class="pi">:</span> <span class="no">false</span>
  <span class="na">hostPID</span><span class="pi">:</span> <span class="no">true</span>
  <span class="na">containers</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">producer</span>
    <span class="na">securityContext</span><span class="pi">:</span>
      <span class="na">privileged</span><span class="pi">:</span> <span class="no">true</span>
</code></pre></div></div>

<p>Option B: shareProcessNamespace (preferred)</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">spec</span><span class="pi">:</span>
  <span class="na">hostIPC</span><span class="pi">:</span> <span class="no">false</span>
  <span class="na">shareProcessNamespace</span><span class="pi">:</span> <span class="no">true</span>
  <span class="na">containers</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">producer</span>
    <span class="na">securityContext</span><span class="pi">:</span>
      <span class="na">privileged</span><span class="pi">:</span> <span class="no">true</span>
</code></pre></div></div>

<h3 id="why-shareprocessnamespace-is-a-big-deal">Why shareProcessNamespace is a big deal</h3>

<p>It gives each container in the pod the process visibility CUDA IPC needs, without exposing host processes. In single-pod scenarios it is strictly safer than <code class="language-plaintext highlighter-rouge">hostPID</code> and worked reliably in my tests.</p>

<h3 id="sketch-yaml">Sketch YAML</h3>

<p>For the full pod spec, see <a href="https://github.com/harche/cuda-ipc-debugging/blob/main/single-pod-example/cuda-ipc-pod-concurrent.yaml">single-pod-example/cuda-ipc-pod-concurrent.yaml</a>.</p>

<h3 id="when-i-would-use-this-1">When I would use this</h3>

<ul>
  <li>Tightly coupled stages that should colocate.</li>
  <li>Simpler scheduling and resource guarantees in one pod.</li>
</ul>

<h3 id="security-notes-2">Security notes</h3>

<p>Better with <code class="language-plaintext highlighter-rouge">shareProcessNamespace</code> than with <code class="language-plaintext highlighter-rouge">hostPID</code>, but still uses <code class="language-plaintext highlighter-rouge">privileged</code> due to the device plugin. If you can, jump to DRA.</p>

<hr />

<h2 id="configuration-4-single-pod-with-dra-best-security">Configuration #4: Single pod with DRA (best security)</h2>

<p>This is the setup I recommend if you can use Kubernetes 1.31+ and the NVIDIA GPU DRA driver. One pod, two containers, <code class="language-plaintext highlighter-rouge">emptyDir</code> for the handle file, and DRA to coordinate GPU access.</p>

<h3 id="what-makes-this-great">What makes this great</h3>

<ul>
  <li><code class="language-plaintext highlighter-rouge">hostPID: false</code> (default) - not needed.</li>
  <li><code class="language-plaintext highlighter-rouge">hostIPC: false</code> (default) - not needed.</li>
  <li><code class="language-plaintext highlighter-rouge">shareProcessNamespace: true</code> - gives the CUDA IPC process visibility, pod-scoped.</li>
  <li><code class="language-plaintext highlighter-rouge">privileged: false</code> (default) - DRA means you do not need it.</li>
</ul>

<h3 id="minimal-shape">Minimal shape</h3>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">spec</span><span class="pi">:</span>
  <span class="na">hostIPC</span><span class="pi">:</span> <span class="no">false</span>
  <span class="na">hostPID</span><span class="pi">:</span> <span class="no">false</span>
  <span class="na">shareProcessNamespace</span><span class="pi">:</span> <span class="no">true</span>
  <span class="na">containers</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">producer</span>
    <span class="na">securityContext</span><span class="pi">:</span>
      <span class="na">privileged</span><span class="pi">:</span> <span class="no">false</span>
      <span class="na">capabilities</span><span class="pi">:</span>
        <span class="na">add</span><span class="pi">:</span> <span class="pi">[</span><span class="s2">"</span><span class="s">IPC_LOCK"</span><span class="pi">]</span>
</code></pre></div></div>

<h3 id="gpu-selection-without-surprises">GPU selection without surprises</h3>

<p>Both containers can see two devices but pick different ones. I used environment variables for clarity:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">env</span><span class="pi">:</span>
<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">CUDA_VISIBLE_DEVICES</span>
  <span class="na">value</span><span class="pi">:</span> <span class="s2">"</span><span class="s">0,1"</span>
</code></pre></div></div>

<p>Then in code:</p>

<ul>
  <li>Producer: <code class="language-plaintext highlighter-rouge">cudaSetDevice(0)</code> allocates and exports.</li>
  <li>Consumer: <code class="language-plaintext highlighter-rouge">cudaSetDevice(1)</code> imports and operates.</li>
</ul>

<h3 id="resourceclaim-and-pod-example">ResourceClaim and pod (example)</h3>

<p>The full manifests are here:</p>

<ul>
  <li><a href="https://github.com/harche/cuda-ipc-debugging/blob/main/single-pod-dra-example/resource-claim.yaml">single-pod-dra-example/resource-claim.yaml</a></li>
  <li><a href="https://github.com/harche/cuda-ipc-debugging/blob/main/single-pod-dra-example/single-pod-dra.yaml">single-pod-dra-example/single-pod-dra.yaml</a></li>
</ul>

<h3 id="when-i-would-use-this-2">When I would use this</h3>

<ul>
  <li>Production workloads in multi-tenant or regulated environments.</li>
  <li>Anywhere <code class="language-plaintext highlighter-rouge">privileged</code> is off-limits (which should be most places).</li>
</ul>

<h3 id="security-notes-3">Security notes</h3>

<p>This hits the sweet spot: no host namespaces, no privileged containers, only pod-scoped process sharing.</p>

<hr />

<h2 id="cheat-sheet">Cheat sheet</h2>

<ul>
  <li>Multi-Pod, traditional: <code class="language-plaintext highlighter-rouge">hostPID=true</code>, <code class="language-plaintext highlighter-rouge">privileged=true</code>, <code class="language-plaintext highlighter-rouge">hostIPC=false</code>.</li>
  <li>Multi-Pod, DRA: <code class="language-plaintext highlighter-rouge">hostPID=true</code>, <code class="language-plaintext highlighter-rouge">privileged=false</code>, <code class="language-plaintext highlighter-rouge">hostIPC=false</code>.</li>
  <li>Single pod, traditional: <code class="language-plaintext highlighter-rouge">shareProcessNamespace=true</code> (or <code class="language-plaintext highlighter-rouge">hostPID=true</code>), <code class="language-plaintext highlighter-rouge">privileged=true</code>, <code class="language-plaintext highlighter-rouge">hostIPC=false</code>.</li>
  <li>Single pod, DRA: <code class="language-plaintext highlighter-rouge">shareProcessNamespace=true</code>, <code class="language-plaintext highlighter-rouge">privileged=false</code>, <code class="language-plaintext highlighter-rouge">hostPID=false</code>, <code class="language-plaintext highlighter-rouge">hostIPC=false</code>.</li>
</ul>

<p>Or, in table form:</p>

<table>
  <thead>
    <tr>
      <th>Configuration</th>
      <th>hostIPC</th>
      <th>hostPID</th>
      <th>shareProcessNamespace</th>
      <th>privileged</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>#1 Multi-Pod, traditional</td>
      <td>No</td>
      <td>Yes</td>
      <td>N/A</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td>#2 Multi-Pod, DRA</td>
      <td>No</td>
      <td>Yes</td>
      <td>N/A</td>
      <td>No</td>
    </tr>
    <tr>
      <td>#3 Single pod, traditional</td>
      <td>No</td>
      <td>Yes or No</td>
      <td>Yes</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td>#4 Single pod, DRA</td>
      <td>No</td>
      <td>No</td>
      <td>Yes</td>
      <td>No</td>
    </tr>
  </tbody>
</table>

<p>Notes:</p>

<ul>
  <li>When I disabled <code class="language-plaintext highlighter-rouge">hostPID</code> in the multi-pod cases, I consistently saw “invalid device context”.</li>
  <li>When I disabled <code class="language-plaintext highlighter-rouge">privileged</code> under the traditional device plugin, I saw “invalid argument” from <code class="language-plaintext highlighter-rouge">cudaIpcOpenMemHandle()</code>.</li>
  <li><code class="language-plaintext highlighter-rouge">hostIPC</code> never mattered when the handle moved through a file or volume.</li>
</ul>

<h2 id="recommendations">Recommendations</h2>

<ul>
  <li>If you can, adopt DRA (Kubernetes 1.31+) and aim for Configuration #4. It is the cleanest and most secure.</li>
  <li>In single-pod scenarios, prefer <code class="language-plaintext highlighter-rouge">shareProcessNamespace</code> over <code class="language-plaintext highlighter-rouge">hostPID</code>.</li>
  <li>Avoid <code class="language-plaintext highlighter-rouge">privileged</code> unless you are stuck with the traditional device plugin and truly have no alternative.</li>
  <li>Treat <code class="language-plaintext highlighter-rouge">hostPID</code> as a last resort, and only where cross-pod visibility is unavoidable.</li>
</ul>

<h2 id="resources">Resources</h2>

<ul>
  <li>NVIDIA GPU DRA Driver: <a href="https://github.com/NVIDIA/k8s-dra-driver">https://github.com/NVIDIA/k8s-dra-driver</a></li>
  <li>Kubernetes DRA docs: <a href="https://kubernetes.io/docs/concepts/scheduling-eviction/dynamic-resource-allocation/">https://kubernetes.io/docs/concepts/scheduling-eviction/dynamic-resource-allocation/</a></li>
  <li>Pod Security Standards: <a href="https://kubernetes.io/docs/concepts/security/pod-security-standards/">https://kubernetes.io/docs/concepts/security/pod-security-standards/</a></li>
</ul>

<hr />

<p>This post is based on hands-on testing of all four setups. The successes and failures (and their exact errors) are from real runs, not theory. If you spot something different in your cluster, I would love to hear about it.</p>]]></content><author><name>Harshal Patil</name></author><summary type="html"><![CDATA[Field-tested configurations and security tradeoffs for CUDA IPC on Kubernetes]]></summary></entry><entry><title type="html">Dynamic GPU Slicing with Red Hat OpenShift and NVIDIA MIG</title><link href="https://harche.github.io/2025/10/14/dynamic-gpu-slicing-openshift.html" rel="alternate" type="text/html" title="Dynamic GPU Slicing with Red Hat OpenShift and NVIDIA MIG" /><published>2025-10-14T00:00:00+00:00</published><updated>2025-10-14T00:00:00+00:00</updated><id>https://harche.github.io/2025/10/14/dynamic-gpu-slicing-openshift</id><content type="html" xml:base="https://harche.github.io/2025/10/14/dynamic-gpu-slicing-openshift.html"><![CDATA[<p><em>Author: <a href="https://github.com/harche" target="_blank" rel="noopener">Harshal Patil</a></em></p>

<p><em>This article was originally published on <a href="https://developers.redhat.com/articles/2025/10/14/dynamic-gpu-slicing-red-hat-openshift-and-nvidia-mig" target="_blank" rel="noopener">Red Hat Developer</a>.</em></p>

<hr />

<p>NVIDIA Multi-Instance GPU (MIG) allows dynamic GPU resource allocation in Kubernetes, enabling more efficient and flexible GPU usage across different AI/ML workloads. The dynamic accelerator slicer operator in Red Hat OpenShift brings this power to developers in an on-demand, developer-friendly way.</p>

<h2 id="the-challenge">The Challenge</h2>

<p>Running multiple AI models with vastly different resource needs on the same GPU infrastructure creates a dilemma: do you provision for the largest model and waste resources when running smaller ones, or do you manage separate GPU pools for different model sizes? Neither approach is ideal.</p>

<h2 id="the-solution">The Solution</h2>

<p>MIG turns one GPU into many independent slices, each with isolated memory and compute resources. Combined with the dynamic accelerator slicer operator in OpenShift, you get:</p>

<ul>
  <li><strong>Flexible allocation</strong>: Create GPU slices on-demand based on workload requirements</li>
  <li><strong>Better utilization</strong>: Run seven tiny models, two medium models, or one large model on the same physical GPU</li>
  <li><strong>Kubernetes-native</strong>: Managed through standard Kubernetes resources and workflows</li>
</ul>

<h2 id="real-world-examples">Real-World Examples</h2>

<p>The article demonstrates three GPU slicing scenarios using vLLM:</p>

<ol>
  <li><strong>Seven tiny models</strong> (Gemma 3 270M) on 1g.5gb slices</li>
  <li><strong>Two medium models</strong> (Qwen2-7B-Instruct) on 3g.20gb slices</li>
  <li><strong>One large model</strong> (GPT-OSS 20B) on a 7g.40gb slice</li>
</ol>

<p>Each scenario shows how the same physical GPU can be dynamically reconfigured to match the exact needs of different AI workloads, eliminating resource waste and improving overall cluster utilization.</p>

<hr />

<p><strong>Read the full article on Red Hat Developer</strong>: <a href="https://developers.redhat.com/articles/2025/10/14/dynamic-gpu-slicing-red-hat-openshift-and-nvidia-mig" target="_blank" rel="noopener">Dynamic GPU Slicing with Red Hat OpenShift and NVIDIA MIG</a></p>]]></content><author><name>Harshal Patil</name></author><summary type="html"><![CDATA[On-demand GPU resource allocation for flexible AI/ML workloads]]></summary></entry><entry><title type="html">Keeping Distributed Jobs in Lockstep</title><link href="https://harche.github.io/2025/10/03/keeping-distributed-jobs-in-lockstep.html" rel="alternate" type="text/html" title="Keeping Distributed Jobs in Lockstep" /><published>2025-10-03T00:00:00+00:00</published><updated>2025-10-03T00:00:00+00:00</updated><id>https://harche.github.io/2025/10/03/keeping-distributed-jobs-in-lockstep</id><content type="html" xml:base="https://harche.github.io/2025/10/03/keeping-distributed-jobs-in-lockstep.html"><![CDATA[<p><em>Author: <a href="https://github.com/harche" target="_blank" rel="noopener">Harshal Patil</a></em></p>

<p>Distributed workloads love to act like a synchronized swim team: either everyone dives in together or someone belly flops alone. <strong>Gang scheduling</strong> is how you keep the choreography perfect. Using <a href="https://github.com/kubernetes-sigs/lws" target="_blank" rel="noopener">LeaderWorkerSet (LWS)</a> together with the <a href="https://github.com/volcano-sh/volcano" target="_blank" rel="noopener">Volcano scheduler</a>, we can see exactly how that works across a few real scenarios. The manifests and scripts in this companion sandbox — <a href="https://github.com/harche/lws-gang-demo" target="_blank" rel="noopener"><code class="language-plaintext highlighter-rouge">lws-gang-demo</code></a> — simply give us a convenient stage.</p>

<hr />

<h2 id="why-gang-scheduling-exists">Why Gang Scheduling Exists</h2>

<p>Multi-pod jobs fall apart when only part of the team runs. Without coordination, Kubernetes may start one pod, leave three Pending, and your training run or inference pipeline stalls forever. Gang scheduling fixes that by treating the pods as one atomic unit. If every pod in the gang can be scheduled, they all launch together; if not, they all wait.</p>

<p>With LWS, we get leader/worker orchestration for stateful or distributed jobs. Pairing it with Volcano brings the “all-or-nothing” scheduling behaviour needed for reliability.</p>

<p><img src="/assets/images/gang-scheduling/overview-20251004.png" alt="Pods diving together to show synchronized scheduling" /></p>

<hr />

<h2 id="setting-the-stage">Setting the Stage</h2>

<p>The sandbox uses a small <a href="https://github.com/kubernetes-sigs/kind" target="_blank" rel="noopener">kind</a> cluster and a few setup steps:</p>

<ol>
  <li><strong>Create the environment</strong> – <code class="language-plaintext highlighter-rouge">scripts/setup-cluster.sh</code> builds a four-node kind cluster, installs Volcano, installs LWS v0.7.0, and patches the LWS config so <code class="language-plaintext highlighter-rouge">gangSchedulingManagement.schedulerProvider</code> is set to <code class="language-plaintext highlighter-rouge">volcano</code>.</li>
  <li><strong>Grant permissions</strong> – <code class="language-plaintext highlighter-rouge">manifests/setup/volcano-rbac.yaml</code> gives the LWS controller the right to create and manage Volcano PodGroups.</li>
  <li><strong>Apply workloads</strong> – each scenario is defined in <code class="language-plaintext highlighter-rouge">manifests/examples/*.yaml</code>, and <code class="language-plaintext highlighter-rouge">scripts/run-demo.sh</code> or <code class="language-plaintext highlighter-rouge">scripts/verify-gang-scheduling.sh</code> guide you through them step by step.</li>
</ol>

<p>That’s all scaffolding; the interesting part is watching what happens when pods try to schedule.</p>

<hr />

<h2 id="scenario-1--all-pods-ready-launch-together">Scenario 1 – All Pods Ready? Launch Together</h2>

<p><img src="/assets/images/gang-scheduling/scenario1-20251004.png" alt="Visualization of all pods launching together under gang scheduling" /></p>

<p>Manifest: <code class="language-plaintext highlighter-rouge">manifests/examples/gang-test.yaml</code></p>

<ul>
  <li>1 leader + 3 workers, each tagged with <code class="language-plaintext highlighter-rouge">schedulerName: volcano</code>.</li>
  <li>LWS automatically creates a PodGroup with <code class="language-plaintext highlighter-rouge">minMember = 4</code>.</li>
  <li>Volcano keeps the entire set “Inqueue” until four slots open, then flips every pod to Running in one shot.</li>
</ul>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>kubectl get podgroups <span class="nt">-n</span> gang-demo
NAME                      STATUS    MINMEMBER   RUNNINGS
gang-test-0-6c67cc9968    Running   4           4
</code></pre></div></div>

<p>Takeaway: the gang scheduler is invisible when capacity exists — everything still starts instantly, just with guardrails in place.</p>

<hr />

<h2 id="scenario-2--resource-pinch-zero-partial-launches">Scenario 2 – Resource Pinch, Zero Partial Launches</h2>

<p><img src="/assets/images/gang-scheduling/scenario2-20251004.png" alt="Illustration of pods waiting because resources are constrained" /></p>

<p>Manifest: <code class="language-plaintext highlighter-rouge">manifests/examples/gang-constrained.yaml</code></p>

<ul>
  <li>Pods request 4 CPUs each; the script taints two worker nodes so only one remains usable.</li>
  <li>LWS still creates the PodGroup (<code class="language-plaintext highlighter-rouge">minMember = 4</code>, <code class="language-plaintext highlighter-rouge">minResources</code> equal to the summed requests).</li>
  <li>Volcano sees the gang cannot fit, so every pod stays Pending. Events show the PodGroup stuck with <code class="language-plaintext highlighter-rouge">Unschedulable</code> until more nodes free up.</li>
  <li>The moment a taint is removed, Volcano schedules all four pods atomically.</li>
</ul>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>kubectl get pods <span class="nt">-n</span> gang-demo <span class="nt">-l</span> leaderworkerset.sigs.k8s.io/name<span class="o">=</span>gang-constrained
NAME                   READY   STATUS    RESTARTS   AGE
gang-constrained-0     0/1     Pending   0          24s
gang-constrained-0-1   0/1     Pending   0          24s
gang-constrained-0-2   0/1     Pending   0          24s
gang-constrained-0-3   0/1     Pending   0          24s
</code></pre></div></div>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>kubectl get podgroups <span class="nt">-n</span> gang-demo
NAME                           STATUS    MINMEMBER   RUNNINGS
&lt;pod-group-name&gt;              Inqueue   4           &lt;none&gt;
</code></pre></div></div>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>kubectl get events <span class="nt">--field-selector</span> involvedObject.kind<span class="o">=</span>PodGroup <span class="nt">-n</span> gang-demo
TYPE      REASON          MESSAGE
Warning   Unschedulable   4/4 tasks <span class="k">in </span>gang unschedulable: pod group is not ready, 4 Pending, 4 minAvailable<span class="p">;</span>
                             Pending: 1 Unschedulable, 3 Schedulable.
                             Origin reason is gang-constrained-0-3: 0/4 nodes are unavailable:
                             1 Insufficient cpu, 1 node<span class="o">(</span>s<span class="o">)</span> had untolerated taint <span class="o">{</span>node-role.kubernetes.io/control-plane: <span class="o">}</span>,
                             2 node<span class="o">(</span>s<span class="o">)</span> had untolerated taint <span class="o">{</span><span class="nb">test</span>: blocked<span class="o">}</span><span class="nb">.</span>
</code></pre></div></div>

<p>When you free one of the tainted nodes, the entire gang launches together:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>kubectl taint nodes llm-d-demo-worker2 <span class="nb">test</span><span class="o">=</span>blocked:NoSchedule-
<span class="nv">$ </span>kubectl get podgroups <span class="nt">-n</span> gang-demo
NAME                           STATUS    MINMEMBER   RUNNINGS
&lt;pod-group-name&gt;              Running   4           4
</code></pre></div></div>

<p>Takeaway: gang scheduling prevents the classic deadlock where one leader runs, waits forever for its workers, and hogs resources along the way.</p>

<hr />

<h2 id="scenario-3--default-scheduler-and-the-sad-trombone">Scenario 3 – Default Scheduler and the Sad Trombone</h2>

<p><img src="/assets/images/gang-scheduling/scenario3-20251004.png" alt="Pods out of sync to represent partial deployment" /></p>

<p>Manifest: <code class="language-plaintext highlighter-rouge">manifests/examples/no-gang.yaml</code></p>

<ul>
  <li>Same resource profile, but this time it’s a plain Deployment using the default scheduler.</li>
  <li>One lucky pod finds room and starts Running; the rest sit Pending.</li>
  <li>If you compare this output to Scenario 2, the difference is obvious: no PodGroup, no atomicity, and a partially launched workload that can’t make progress.</li>
</ul>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>kubectl get pods <span class="nt">-n</span> gang-demo <span class="nt">-l</span> <span class="nv">app</span><span class="o">=</span>no-gang
NAME                            READY   STATUS    RESTARTS   AGE
no-gang-test-6686c8476b-28n8p   1/1     Running   0          5s
no-gang-test-6686c8476b-4jmdw   0/1     Pending   0          5s
no-gang-test-6686c8476b-9vnwv   0/1     Pending   0          5s
no-gang-test-6686c8476b-fq92q   0/1     Pending   0          5s
</code></pre></div></div>

<p>Takeaway: gang scheduling isn’t a luxury — it’s the difference between a healthy rollout and a half-deployed mess.</p>

<hr />

<h2 id="try-it-yourself">Try It Yourself</h2>

<ol>
  <li><code class="language-plaintext highlighter-rouge">./scripts/setup-cluster.sh</code></li>
  <li><code class="language-plaintext highlighter-rouge">./scripts/run-demo.sh</code> (walks through the three scenarios interactively)</li>
  <li><code class="language-plaintext highlighter-rouge">./scripts/verify-gang-scheduling.sh</code> (grabs PodGroup fields and events for receipts)</li>
  <li>Optional cleanup: <code class="language-plaintext highlighter-rouge">./scripts/cleanup.sh</code></li>
</ol>

<p>You can also poke through the live resources with <code class="language-plaintext highlighter-rouge">kubectl get podgroups -n gang-demo</code> or <code class="language-plaintext highlighter-rouge">kubectl describe podgroup &lt;pod-group-name&gt; -n gang-demo</code> to watch Volcano flip PodGroups from <code class="language-plaintext highlighter-rouge">Inqueue</code> to <code class="language-plaintext highlighter-rouge">Running</code>.</p>

<hr />

<h2 id="what-to-remember">What to Remember</h2>

<ul>
  <li>Gang scheduling is all about <strong>all-or-nothing</strong> deployments; anything less invites deadlocks.</li>
  <li>LWS v0.7.0+ adds the tooling you need to pair with a gang scheduler without heavy lifting.</li>
  <li>Volcano acts as the enforcement engine, respecting <code class="language-plaintext highlighter-rouge">minMember</code> and <code class="language-plaintext highlighter-rouge">minResources</code> before letting pods bind.</li>
  <li>The moment you remove gang scheduling, the safety net goes away and partial rollouts come back.</li>
</ul>

<p>Keep that mental model handy next time someone wonders why their distributed workload got itself stuck in <code class="language-plaintext highlighter-rouge">Pending</code>. Gang scheduling, done right, keeps the whole crew moving together.</p>]]></content><author><name>Harshal Patil</name></author><summary type="html"><![CDATA[How LWS and Volcano enforce all-or-nothing scheduling]]></summary></entry></feed>