<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Kernel-Headers on The Nested Lab</title>
    <link>https://thenestedlab.com/tags/kernel-headers/</link>
    <description>Recent content in Kernel-Headers on The Nested Lab</description>
    <generator>Hugo</generator>
    <language>en-gb</language>
    <lastBuildDate>Wed, 21 Oct 2026 00:00:00 +0100</lastBuildDate>
    <atom:link href="https://thenestedlab.com/tags/kernel-headers/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>VKS and the GPU Operator in a dark site: the whole airlock</title>
      <link>https://thenestedlab.com/posts/vks-gpu-operator-dark-site/</link>
      <pubDate>Wed, 21 Oct 2026 00:00:00 +0100</pubDate>
      <guid>https://thenestedlab.com/posts/vks-gpu-operator-dark-site/</guid>
      <description>Upgrading VKS, Kubernetes and the NVIDIA GPU Operator with no internet means five supply chains arriving intact. Each has a trap that passes every check until activation. This is the map.</description>
      <content:encoded><![CDATA[<p>A dark-site upgrade of VKS, a Kubernetes release and the GPU Operator looks
like one project. It&rsquo;s actually five supply chains. Every one of them has a
failure mode that <strong>passes every reasonable check and then fails at
activation</strong>.</p>
<p>I&rsquo;ve now been through all five. This is the long post; the short version
is the table at the end. I&rsquo;d recommend the table over the experience.</p>
<h2 id="the-five-things-that-cross-the-airlock">The five things that cross the airlock</h2>
<table>
	<thead>
			<tr>
					<th>#</th>
					<th>Payload</th>
					<th>Format</th>
					<th>Consumer</th>
					<th>The trap</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>1</td>
					<td>VKS supervisor-service bundle</td>
					<td>imgpkg tar</td>
					<td>Supervisor Service framework</td>
					<td><strong>cosign signatures dropped</strong> by default</td>
			</tr>
			<tr>
					<td>2</td>
					<td>GPU Operator + driver images</td>
					<td>OCI archive / imgpkg tar</td>
					<td>Helm on the workload cluster</td>
					<td><a href="/posts/imgpkg-tar-vs-oci-archive/">tar layout mismatch</a>; driver <strong>tag composition</strong></td>
			</tr>
			<tr>
					<td>3</td>
					<td>Kubernetes release node OVAs</td>
					<td>OVA → Content Library</td>
					<td>VKS</td>
					<td>version gate on the <em>running</em> supervisor, not the release</td>
			</tr>
			<tr>
					<td>4</td>
					<td>Kernel headers (+ image + modules)</td>
					<td>.deb → side repo</td>
					<td>driver container&rsquo;s <code>apt</code></td>
					<td>apt-mirror&rsquo;s <code>clean.sh</code> <strong>deletes side repos</strong></td>
			</tr>
			<tr>
					<td>5</td>
					<td>vGPU licences</td>
					<td>DLS appliance</td>
					<td>driver</td>
					<td><code>.local</code> site domain → <strong>mDNS</strong>; needs a hosts injector</td>
			</tr>
	</tbody>
</table>
<p><img alt="Five supply chains through the airlock: the VKS bundle and the GPU Operator images into Harbor, Kubernetes release OVAs into a content library, kernel headers into a side repo, vGPU licences into a DLS appliance, each with the trap that passes every check" loading="lazy" src="/images/diagrams/gpu-airlock-five-chains.svg">
<em>Five payloads, five landing places, and five ways to pass every check and still fail at activation.</em></p>
<h2 id="1-the-signatures-you-didnt-know-you-dropped">1. The signatures you didn&rsquo;t know you dropped</h2>
<p>VCF 9.0.1+ withholds control-plane-VM trust for VKS versions above 3.4.0
from a private registry, <strong>unless the bundle&rsquo;s cosign signatures are in the
registry</strong>. The <code>.sig</code> artifacts are separate tags (<code>sha256-&lt;digest&gt;.sig</code>).
And <code>imgpkg copy</code> drops them by default at <em>every</em> copy, pull and push
alike.</p>
<p>The failure looks like this. Everything checks out:</p>
<ul>
<li>the digest matches the vendor&rsquo;s;</li>
<li><code>imgpkg describe</code> walks all 39 refs clean;</li>
<li>the tag list is complete;</li>
<li>activation proceeds.</li>
</ul>
<p>And then every new pod in <code>svc-tkg-domain-cXX</code> is denied by the webhook
<code>validate.cpvmpod.appplatform.vmware.com</code>: <em>running pod in an untrusted
namespace on the control plane VM is not allowed</em>. The package install
flips <code>Reconciling</code>/<code>failed</code> forever.</p>
<p>Old-version pods keep running, because Deployment surge protects the
service. So it isn&rsquo;t an outage. It&rsquo;s a stuck upgrade.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl">imgpkg copy -b &lt;bundle&gt; --to-tar vks.tar --cosign-signatures
</span></span><span class="line"><span class="cl">imgpkg copy --tar vks.tar --to-repo supHarbor.example.com/vks/vsphere-kubernetes-service --cosign-signatures
</span></span><span class="line"><span class="cl">kubectl rollout restart deployment -n vmware-system-appplatform-operator-system
</span></span><span class="line"><span class="cl"># then force a fresh attempt, now the signatures are in
</span></span><span class="line"><span class="cl">kubectl -n svc-tkg-domain-&lt;cluster&gt; get deploy -o name | xargs -I{} kubectl -n svc-tkg-domain-&lt;cluster&gt; rollout restart {}
</span></span></code></pre></div><p>The operator-namespace restart cycles that namespace&rsquo;s Deployments.
<a href="https://knowledge.broadcom.com/external/article/431379/vsphere-kubernetes-service-upgrade-faile.html">KB 431379</a>
restarts the operator&rsquo;s StatefulSet instead:
<code>kubectl rollout restart sts -n vmware-system-appplatform-operator-system vmware-system-appplatform-operator-mgr</code>.</p>
<p>Adding signatures doesn&rsquo;t change the bundle digest, so there&rsquo;s no
re-registration. And if the content is already in the registry, the
signatures alone are about 96 KB gzipped (27 of them for VKS 3.6.3):
small enough to base64 through email.</p>
<p>Lab tolerance lesson: the 9.1 lab never enforced this, which was very kind
of it, and no use whatsoever. A rehearsal that can&rsquo;t hit the gate can&rsquo;t
prove you&rsquo;re through it.</p>
<h2 id="2-images-and-one-composed-tag">2. Images, and one composed tag</h2>
<p>The <a href="/posts/imgpkg-tar-vs-oci-archive/">tar-format problem</a> has its own
post. The trap unique to the GPU Operator is that <strong>the driver image tag is
composed at runtime</strong>, as <code>&lt;driver.version&gt;-&lt;os&gt;</code>. Values say <code>580.105.08</code>;
the pullable artifact is <code>580.105.08-ubuntu22.04</code>.</p>
<p>Stage the composed tag, and keep the bare version in values. Get it
backwards and the driver pod <code>ImagePullBackOff</code>s with a tag that looks
right, which is the most irritating way for a tag to be wrong.</p>
<p>Four gates run before every Helm upgrade, and each one caught something
real:</p>
<ol>
<li><strong>Values leaf-diff</strong> against the live release. It found a phantom
<code>driver.nodeSelector</code> targeting a label the nodes didn&rsquo;t carry. That
would have matched zero nodes and removed all 14 driver pods.</li>
<li><strong>An assertion script</strong> over the merged values. It fails on the
presence of that selector, and checks the drain timeout matches live.</li>
<li><strong>An image pull test</strong>: a DaemonSet with every image the new chart
references, selected on <code>gpu.deploy.driver=true</code>. <code>DESIRED</code> must equal
the GPU node count. It read 0 the first time: right context, phantom
selector.</li>
<li><strong>A render diff</strong>: <code>helm template</code> old versus new, with eyes on every
changed line.</li>
</ol>
<p>Also new in 25.10+: <code>cdi.enabled</code> flips its default, so pin it.</p>
<p>And a fleet with multi-replica LLMs and no PodDisruptionBudgets (PDBs) went
fully down mid-roll, because the reload was slower than the drain cadence.
It self-healed. Add PDBs <em>before</em> the node roll.</p>
<h2 id="3-the-version-gate-is-on-the-running-supervisor">3. The version gate is on the running supervisor</h2>
<p>VKS service YAMLs carry <code>kubernetesVersionSelection.constraints</code>, and 3.7.1
says <code>&gt;=1.32.0</code>. The statement &ldquo;VCF 9.0.1 ships Kubernetes 1.32&rdquo; is true
about what the release <em>makes available</em>. It&rsquo;s false about what a given
supervisor is <em>running</em>.</p>
<p>Ours was <code>1.31.6</code>, at the ceiling for its vSphere version, and the build
string encodes it (<code>...-vsc9.0.1.0</code>). So the real prerequisite for that VKS
version was a VCF fleet upgrade, not a download. A slightly larger job,
then.</p>
<p>Measure it: <code>GET /api/vcenter/namespace-management/software/clusters</code>.
Then pick the highest VKS whose constraint the <em>running</em> version satisfies
(3.6.3 for us), and take the Kubernetes release hops that VKS supports.</p>
<h2 id="4-kernel-headers-the-side-repo-that-vanishes">4. Kernel headers: the side repo that vanishes</h2>
<p>The NVIDIA driver container compiles against the node kernel. It needs
<code>linux-headers-&lt;ver&gt;</code> from an apt source, plus, it turned out, the kernel
<strong>image</strong> and <strong>modules</strong> packages.</p>
<p>The dark-site Ubuntu mirror was <code>apt-mirror</code> served by Apache. Its indexes
are upstream-verbatim and Ubuntu-signed, so they <strong>cannot be edited</strong> to
add packages. Hence a flat side repo:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-gdscript3" data-lang="gdscript3"><span class="line"><span class="cl"><span class="n">Alias</span> <span class="o">/</span><span class="n">nvhdrs</span> <span class="o">/</span><span class="k">var</span><span class="o">/</span><span class="n">www</span><span class="o">/</span><span class="n">html</span><span class="o">/</span><span class="n">nvhdrs</span>        <span class="c1"># OUTSIDE the mirror&#39;s DocumentRoot</span>
</span></span></code></pre></div><p>Outside on purpose: apt-mirror&rsquo;s generated <code>clean.sh</code> deletes anything
under its spool that isn&rsquo;t in the upstream index. A side repo placed
inside would serve correctly, then silently vanish on the next mirror run,
tidied away by a script that was only trying to help.</p>
<p>The mirror host was Photon, with no <code>dpkg-deb</code> and no <code>apt-ftparchive</code>.
So we assembled the <code>Packages</code> index from the upstream stanzas with
<code>sed 's|^Filename: pool/main/l/linux/|Filename: ./|'</code>. We built a <code>Release</code>
file with <code>sha256sum</code>, <code>stat</code> and <code>date -Ru</code>. It has to exist: apt 2.4
rejects a flat repo without <code>Release</code>, even with <code>[trusted=yes]</code>.</p>
<p>Verify from a <em>jammy</em> host or the driver pod. Never verify from the Photon
server, which has no apt.</p>
<p>One timing fact: the driver container keeps <code>archive.ubuntu.com</code> in its own
<code>sources.list</code>. So every <code>apt-get update</code> times out for about 40 s before
falling back. Serially, per node, that&rsquo;s about 10 minutes per 14-node hop.
Harmless, but budget for it.</p>
<h2 id="5-node-internals-nobody-warns-you-about">5. Node internals nobody warns you about</h2>
<p>Three findings from the node roll:</p>
<ul>
<li><strong>The container toolkit resets containerd&rsquo;s sandbox image.</strong> On
containerd 2.x nodes, the GPU Operator&rsquo;s toolkit rewrites the config and
drops the air-gap pin, defaulting to <code>registry.k8s.io/pause:3.10.1</code>.
Every post-toolkit pod then fails sandbox creation. A guard DaemonSet
runs permanently, until a fresh node&rsquo;s guard log shows no repair. It
uses nsenter from the driver image, puts the pause ref back to the
node-local registry with <code>sed</code>, and never touches <code>sandboxer</code>.
<a href="https://knowledge.broadcom.com/external/article/429604/nvidia-gpuoperator-pods-not-starting-in.html">KB 429604</a>
fixes the same problem at install time instead, with a Helm setting we
haven&rsquo;t tried: <code>RUNTIME_CONFIG_SOURCE=file=/etc/containerd/config.toml</code>
in <code>toolkit.env</code>.</li>
<li><strong><code>.local</code> is mDNS.</strong> With a site domain ending <code>.local</code>, the node
resolver multicasts for the registry, the licence server and the vSAN
file-service endpoints. A per-release hosts-injector DaemonSet writes
seven <code>/etc/hosts</code> entries every 60 s. It&rsquo;s pinned to the
<code>run.tanzu.vmware.com/tkr</code> label, and pulls its images from the
node-local <code>localhost:5000</code>.</li>
<li><strong>Pace by the workloads, not the platform.</strong> Every node replacement is
fast. The LLM replicas moved off it take 12–20 minutes to pass health
checks (<code>:8000/health</code> refuses while loading). Do one GPU machine at a
time, and pause the Cluster (<code>spec.paused</code>) in between: about 25–30 min
per node.</li>
</ul>
<h2 id="the-map-compressed">The map, compressed</h2>
<table>
	<thead>
			<tr>
					<th>Check that passes</th>
					<th>Failure it hides</th>
					<th>Prove it with</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>bundle digest = vendor</td>
					<td>signatures missing</td>
					<td><code>imgpkg tag list</code> shows <code>.sig</code> tags</td>
			</tr>
			<tr>
					<td>tag list complete</td>
					<td>driver tag composed differently</td>
					<td>pull-test DS with the <em>composed</em> tag</td>
			</tr>
			<tr>
					<td><code>DESIRED 0</code> looks like &ldquo;no work&rdquo;</td>
					<td>nodeSelector matches nothing</td>
					<td>expect <code>DESIRED</code> = GPU node count</td>
			</tr>
			<tr>
					<td>&ldquo;9.0.1 ships 1.32&rdquo;</td>
					<td>supervisor runs 1.31</td>
					<td><code>GET .../software/clusters</code></td>
			</tr>
			<tr>
					<td>side repo serves 200</td>
					<td><code>clean.sh</code> will delete it</td>
					<td>put it outside DocumentRoot</td>
			</tr>
			<tr>
					<td>toolkit pod Running</td>
					<td>containerd pause ref reset</td>
					<td>guard DS log</td>
			</tr>
			<tr>
					<td>DNS &ldquo;works&rdquo;</td>
					<td><code>.local</code> → mDNS</td>
					<td>hosts injector</td>
			</tr>
	</tbody>
</table>
<h2 id="why-this-matters-outside-the-lab">Why this matters outside the lab</h2>
<p>For regulated and disconnected estates, the takeaway is that air-gapped
platform upgrades are entirely achievable. But they&rsquo;re a supply-chain
exercise, not a software one.</p>
<p>Every dependency has to be identified, staged, verified and rehearsed
before the change window. The gates that catch problems have to be built
in advance, because there&rsquo;s no internet to fall back on.</p>
<p>Organisations running GPU and AI workloads in these environments are
exactly who this is for. The platform can be kept current without ever
opening the network, provided the preparation is treated as the real work.</p>
<h2 id="rules-learned">Rules learned</h2>
<ul>
<li>Five supply chains, not one. List them before you start.</li>
<li>Use <code>--cosign-signatures</code> on <strong>both</strong> ends of every imgpkg copy that
feeds a trust-checking consumer.</li>
<li>Stage the <strong>composed</strong> driver tag; keep the bare version in values.</li>
<li>Gate every values change four ways: leaf-diff, assert, pull-test,
render-diff. Each caught something.</li>
<li>The version gate is on the <em>running</em> supervisor. Measure; don&rsquo;t infer
from the release notes.</li>
<li>Side repos live <strong>outside</strong> the mirror&rsquo;s DocumentRoot.</li>
<li>On containerd 2.x with the GPU Operator, guard the sandbox image. On
<code>.local</code> domains, inject hosts.</li>
<li>Roll GPU nodes at the pace the <em>workloads</em> recover. Add PDBs first.</li>
<li>A lab that can&rsquo;t hit the prod gate proves nothing about the gate. Know
which gates your rehearsal <em>cannot</em> exercise, and say so in the runbook.</li>
</ul>
<h2 id="broadcom-documentation">Broadcom documentation</h2>
<ul>
<li><a href="https://techdocs.broadcom.com/us/en/vmware-cis/vcf/vcf-service-administration-and-development/9-0/managing-vsphere-kubernetes-service/installing-and-upgrading-the-tkg-service/upgrade-tkg-service-from-a-private-registry.html">Upgrade VKS from a Private Registry</a>: <code>imgpkg copy</code> with <code>--cosign-signatures</code> on both copies, then register the new version</li>
<li><a href="https://knowledge.broadcom.com/external/article/429547/supervisor-upgrade-stuck-after-upgrading.html">Supervisor upgrade stuck after upgrading to 9.0.2 error &ldquo;vmware-system-vks-public user=system:serviceaccount:svc-tkg-domain-:runtime-extension- pkg-sa: prohibited operation on system namespace&rdquo;</a>: on VCF 9.0.1 and 9.0.2, VKS above 3.4.0 needs signed packages in the private registry</li>
<li><a href="https://knowledge.broadcom.com/external/article/431379/vsphere-kubernetes-service-upgrade-faile.html">vSphere kubernetes service upgrade failed with &ldquo;kapp: error waiting on reconcile packageinstall&rdquo;</a>: the untrusted-namespace webhook denial, and re-copying with signatures</li>
<li><a href="https://techdocs.broadcom.com/us/en/vmware-cis/vcf/vcf-service-administration-and-development/9-1/release-notes/vks-release-notes/vmware-tanzu-kubernetes-grid-service-37-release-notes.html">VMware vSphere Kubernetes Service 3.7 Release Notes</a>: VKS 3.7 needs Supervisor Kubernetes 1.32 or later</li>
<li><a href="https://techdocs.broadcom.com/us/en/vmware-cis/private-ai/foundation-with-nvidia/9-0/private-ai-foundation-9-x/deploying-private-ai-foundation-with-nvidia/finalizing-the-setup-for-deep-learning-vms-and-vks-cluster-with-gpu/provide-an-ubuntu-package-repository--mirror-and-vgpu-drivers-for---deep-learning-vms-and-vks-clusters-with-gpu-in-a-disconnected-environment.html">Provide an Ubuntu Package Repository Mirror and vGPU Drivers for Deep Learning VMs and VKS Clusters with GPU in a Disconnected Environment</a>: the local Ubuntu repository the driver build compiles against</li>
<li><a href="https://knowledge.broadcom.com/external/article/429604/nvidia-gpuoperator-pods-not-starting-in.html">NVIDIA gpu-operator pods not starting in air-gapped environment with VKr 1.33 (or higher) showing error &ldquo;failed to get sandbox image &ldquo;registry.k8s.io/pause:3.10&rdquo;: failed to pull image &ldquo;registry.k8s.io/pause:3.10&rdquo;: &ldquo;</a>: the GPU Operator&rsquo;s containerd drop-in losing the air-gapped sandbox image, and a toolkit setting that avoids it</li>
</ul>
<hr>
<p><em>Techniques from a real air-gapped engagement, generalised; identifiers
removed. Opinions my own.</em></p>
]]></content:encoded>
    </item>
  </channel>
</rss>
