<?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>Bare-Metal on vishctl</title><link>https://vishctl.dev/tags/bare-metal/</link><description>Recent content in Bare-Metal on vishctl</description><generator>Hugo</generator><language>en-us</language><lastBuildDate>Sun, 01 Nov 2026 14:00:00 +0800</lastBuildDate><atom:link href="https://vishctl.dev/tags/bare-metal/index.xml" rel="self" type="application/rss+xml"/><item><title>vCluster on Bare Metal: A GPU Tenant from One YAML File in Six Minutes</title><link>https://vishctl.dev/posts/vcluster-bare-metal-gpu-tenant-metal3/</link><pubDate>Sun, 01 Nov 2026 14:00:00 +0800</pubDate><guid>https://vishctl.dev/posts/vcluster-bare-metal-gpu-tenant-metal3/</guid><category>kubernetes</category><category>vcluster</category><category>bare-metal</category><category>metal3</category><category>gpu</category><category>multi-tenancy</category><description>A diagram-first walkthrough of vCluster Platform private nodes and auto nodes on simulated bare metal: one request, Metal3 powers on two servers, writes Ubuntu, and a tenant cluster schedules pods onto a GPU node.</description><content:encoded><![CDATA[<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Old way  = a ticket, then someone images and joins the servers
</span></span><span class="line"><span class="cl">This way = one object; the Platform claims, images and joins them
</span></span></code></pre></div><p>A GPU cloud sells <strong>a Kubernetes cluster per customer</strong>, not a namespace. Each customer wants their own API server, their own nodes and their own GPUs, and they want it in minutes.</p>
<p>In this post I run that flow end to end on a lab VM: one YAML file, <strong>vCluster Platform</strong> asks <strong>Metal3</strong> for two bare-metal servers, Ubuntu is written to their disks, and a brand new tenant cluster schedules a pod onto the GPU node. Every output below is copied from the run on November 1, 2026.</p>
<p>If vCluster is new to you, start with <a href="https://vishctl.dev/posts/what-is-vcluster-virtual-kubernetes-clusters/">what vCluster is</a> and <a href="https://vishctl.dev/posts/vcluster-private-nodes-auto-nodes-bare-metal/">how private nodes and Auto Nodes work</a>.</p>
<p>The servers and GPUs are simulated. The provisioning flow (Redfish power control, PXE, Ironic writing the image, kubeadm join) is the real one.</p>
<h2 id="the-result-first">The result first</h2>
<div class="table-scroll">
  <table>
    <thead>
        <tr>
            <th scope="col">Time after <code>kubectl apply</code></th>
            <th scope="col">What happened</th>
        </tr>
    </thead>
    <tbody>
        <tr>
            <td><strong>+30 s</strong></td>
            <td>Tenant control plane is <code>Ready</code>. The API exists before any node does.</td>
        </tr>
        <tr>
            <td><strong>+41 s</strong></td>
            <td>Two <code>NodeClaims</code> created, both servers start <code>provisioning</code></td>
        </tr>
        <tr>
            <td><strong>about +4 min 45 s</strong></td>
            <td>Both servers <code>provisioned</code> (Ubuntu written, rebooted)</td>
        </tr>
        <tr>
            <td><strong>+5 min 26 s</strong></td>
            <td>GPU node <code>bare-metal-2</code> is <code>Ready</code> in the tenant</td>
        </tr>
        <tr>
            <td><strong>+6 min 10 s</strong></td>
            <td>CPU node <code>bare-metal-1</code> is <code>Ready</code> in the tenant</td>
        </tr>
        <tr>
            <td><strong>+11 min</strong></td>
            <td>Fake GPU operator installed, a 1 GPU pod lands on the GPU node, a 5 GPU pod stays <code>Pending</code></td>
        </tr>
    </tbody>
  </table>
</div>
<p>Most of the six minutes is writing an OS image to disk. A real server would spend the same time there. A second, identical run a few minutes later had both claims <code>Available</code> at +5 min 11 s.</p>
<h2 id="what-vcluster-private-nodes-are">What vCluster private nodes are</h2>
<p>Plain vCluster runs a tenant&rsquo;s control plane as a pod and <strong>shares the host cluster&rsquo;s nodes</strong>. Tenant pods are synced down and run next to everyone else&rsquo;s.</p>
<p><strong>Private nodes</strong> change that: the tenant gets its own real machines, each with its own kubelet, CNI and container runtime, joined straight to the tenant&rsquo;s API server. <strong>Auto nodes</strong> add the last piece: nobody joins those machines by hand, the Platform orders them from a node provider.</p>
<figure class="mermaid-figure">
  <div class="mermaid">flowchart TD
    subgraph SHARED["Shared nodes"]
        direction LR
        S1["Tenant A<br/>control plane pod"] --> S3["Host nodes<br/>A and B pods mixed"]
        S2["Tenant B<br/>control plane pod"] --> S3
    end
    subgraph PRIVATE["Private nodes"]
        direction LR
        P1["Tenant A<br/>control plane pod"] p1@--> P2["Server 1<br/>only tenant A"]
        P1 p2@--> P3["Server 2<br/>only tenant A"]
    end
    SHARED ~~~ PRIVATE
    p1@{ animate: true }
    p2@{ animate: true }
    classDef cp fill:#281d48,stroke:#a78bfa,color:#f1edff,stroke-width:2px;
    classDef node fill:#173f59,stroke:#22d3ee,color:#ecfeff,stroke-width:2px;
    classDef mixed fill:#302410,stroke:#fbbf24,color:#fff3cf,stroke-width:2px;
    class S1,S2,P1 cp;
    class P2,P3 node;
    class S3 mixed;</div>
  <figcaption>Shared nodes versus private nodes. Only the control plane lives on the host in the private model.</figcaption>
</figure>

<p>For GPUs this matters: a GPU node is a whole machine with drivers, device plugins and a fabric. You want it to belong to one customer.</p>
<h2 id="the-lab-in-one-picture">The lab in one picture</h2>
<p>Everything runs on <strong>one Ubuntu VM</strong>. That VM runs a Kubernetes cluster inside Docker (vind: vCluster in Docker). Inside that cluster:</p>
<ul>
<li><strong>vCluster Platform 4.12.2</strong>, the control tower that creates tenants and hands out nodes.</li>
<li><strong>KubeVirt</strong> VMs that play the role of two physical servers.</li>
<li>A small <strong>BMC pod per server</strong> that speaks Redfish, so Metal3 can power it on and off like a real server.</li>
<li><strong>Metal3 (with Ironic)</strong> and a <strong>DHCP proxy</strong> that network boot the servers and write Ubuntu to their disks.</li>
<li>A bridge network <strong><code>br0</code> (192.168.100.0/24)</strong> that stands in for the rack network.</li>
</ul>
<figure class="mermaid-figure">
  <div class="mermaid">flowchart TD
    VM["Lab VM<br/>Ubuntu, Docker"] --> HOST["Host cluster<br/>bare-metal-fun"]
    HOST --> PLAT["vCluster Platform<br/>4.12.2"]
    HOST --> M3["Metal3 plus Ironic<br/>DHCP proxy"]
    HOST --> BMC["BMC pods<br/>Redfish"]
    HOST --> KV["KubeVirt VMs<br/>bare-metal-1, bare-metal-2"]
    M3 e1@--> BMC
    BMC e2@--> KV
    PLAT e3@--> M3
    KV --- BR["br0 rack network<br/>192.168.100.0/24"]
    e1@{ animate: true }
    e2@{ animate: true }
    e3@{ animate: true }
    classDef plat fill:#281d48,stroke:#a78bfa,color:#f1edff,stroke-width:2px;
    classDef srv fill:#173f59,stroke:#22d3ee,color:#ecfeff,stroke-width:2px;
    classDef prov fill:#302410,stroke:#fbbf24,color:#fff3cf,stroke-width:2px;
    classDef base fill:#13264a,stroke:#38bdf8,color:#e6f6ff,stroke-width:2px;
    class PLAT plat;
    class KV,BR srv;
    class M3,BMC prov;
    class VM,HOST base;</div>
  <figcaption>The lab stack. Purple is the Platform and tenant control plane, cyan is what plays a server, amber is the provisioning machinery.</figcaption>
</figure>

<p>The host cluster is a single Docker container plus a load balancer for the Platform:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">$ docker ps --format &#34;table {{.Names}}\t{{.Image}}\t{{.Status}}&#34;
</span></span><span class="line"><span class="cl">NAMES                                               IMAGE                          STATUS
</span></span><span class="line"><span class="cl">vcluster.lb.bare-metal-fun.loft.vcluster-platform   haproxy:3.3-alpine             Up 3 weeks
</span></span><span class="line"><span class="cl">vcluster.cp.bare-metal-fun                          ghcr.io/loft-sh/vm-container   Up 3 weeks
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">$ kubectl get nodes -o wide
</span></span><span class="line"><span class="cl">NAME             STATUS   ROLES                  AGE   VERSION   INTERNAL-IP   ...
</span></span><span class="line"><span class="cl">bare-metal-fun   Ready    control-plane,master   23d   v1.36.5   172.18.0.2    ...
</span></span></code></pre></div><p>Next to the Metal3 pod sit the BMC pods and the DHCP proxy. The <code>virt-launcher</code> pods are the servers themselves, and they only exist while a server is powered on:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">$ kubectl get pods -n default
</span></span><span class="line"><span class="cl">NAME                               READY   STATUS    AGE
</span></span><span class="line"><span class="cl">bare-metal-1-virtbmc-0             2/2     Running   23d
</span></span><span class="line"><span class="cl">bare-metal-2-virtbmc-0             2/2     Running   23d
</span></span><span class="line"><span class="cl">dhcp-proxy-0                       1/1     Running   23d
</span></span><span class="line"><span class="cl">kube-multus-ds-x9dps               1/1     Running   23d
</span></span><span class="line"><span class="cl">metal3-0                           4/4     Running   23d
</span></span><span class="line"><span class="cl">virt-launcher-bare-metal-1-jfpzp   2/2     Running   9m16s
</span></span><span class="line"><span class="cl">virt-launcher-bare-metal-2-gldhw   2/2     Running   9m13s
</span></span></code></pre></div><h2 id="before-what-the-platform-already-knows">Before: what the Platform already knows</h2>
<p>Three objects teach the Platform where nodes come from. This is built once and shared by every tenant. (The lab also has a second, unrelated provider; I trimmed it from the outputs below.)</p>
<div class="table-scroll">
  <table>
    <thead>
        <tr>
            <th scope="col">Object</th>
            <th scope="col">Question it answers</th>
            <th scope="col">In this lab</th>
        </tr>
    </thead>
    <tbody>
        <tr>
            <td><code>NodeProvider</code></td>
            <td>Where do machines come from?</td>
            <td><code>metal3</code> (bare metal)</td>
        </tr>
        <tr>
            <td><code>NodeType</code></td>
            <td>What can a tenant order?</td>
            <td><code>gpu-server</code> and <code>cpu-server</code>, one of each</td>
        </tr>
        <tr>
            <td><code>BareMetalHost</code></td>
            <td>Which physical servers exist?</td>
            <td><code>bare-metal-1</code> (<code>role=cpu</code>), <code>bare-metal-2</code> (<code>role=gpu</code>)</td>
        </tr>
    </tbody>
  </table>
</div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">$ kubectl get nodeproviders.management.loft.sh
</span></span><span class="line"><span class="cl">NAME      CREATED AT
</span></span><span class="line"><span class="cl">metal3    2026-10-08T21:39:44Z
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">$ kubectl get nodetypes.management.loft.sh
</span></span><span class="line"><span class="cl">NAME                     TOTAL   CLAIMED   PROVIDER   COST   PHASE       CREATED AT
</span></span><span class="line"><span class="cl">metal3.cpu-server        1       0         metal3     0      Available   2026-10-26T09:42:54Z
</span></span><span class="line"><span class="cl">metal3.gpu-server        1       0         metal3     0      Available   2026-10-26T09:42:54Z
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">$ kubectl get bmh -A --show-labels
</span></span><span class="line"><span class="cl">NAMESPACE   NAME           STATE       ONLINE   AGE   LABELS
</span></span><span class="line"><span class="cl">default     bare-metal-1   available   false    23d   ...,role=cpu
</span></span><span class="line"><span class="cl">default     bare-metal-2   available   false    23d   ...,role=gpu
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">$ kubectl get vm -n default
</span></span><span class="line"><span class="cl">NAME           AGE   STATUS    READY
</span></span><span class="line"><span class="cl">bare-metal-1   23d   Stopped   False
</span></span><span class="line"><span class="cl">bare-metal-2   23d   Stopped   False
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">$ kubectl get virtualclusterinstances.management.loft.sh -A
</span></span><span class="line"><span class="cl">No resources found
</span></span></code></pre></div><p><code>available</code> means Metal3 has inspected the server and it is free. <code>Stopped</code> means the machine is powered off. No tenants yet.</p>
<p>The link between a node type and a server is just a <strong>label selector</strong> in the provider:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">nodeTypes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl">- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">gpu-server</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">bareMetalHosts</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">selector</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">matchLabels</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">role</span><span class="p">:</span><span class="w"> </span><span class="l">gpu</span><span class="w">
</span></span></span><span class="line"><span class="cl">- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">cpu-server</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">bareMetalHosts</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">selector</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">matchLabels</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">role</span><span class="p">:</span><span class="w"> </span><span class="l">cpu</span><span class="w">
</span></span></span></code></pre></div><figure class="mermaid-figure">
  <div class="mermaid">flowchart LR
    POOL["Tenant pool gpu<br/>node-type gpu-server"] s1@--> NT["NodeType<br/>gpu-server"]
    NT s2@--> SEL["Selector<br/>role equals gpu"]
    SEL s3@--> BMH["BareMetalHost<br/>bare-metal-2"]
    s1@{ animate: true }
    s2@{ animate: true }
    s3@{ animate: true }
    classDef req fill:#13264a,stroke:#38bdf8,color:#e6f6ff,stroke-width:2px;
    classDef plat fill:#281d48,stroke:#a78bfa,color:#f1edff,stroke-width:2px;
    classDef srv fill:#173f59,stroke:#22d3ee,color:#ecfeff,stroke-width:2px;
    class POOL req;
    class NT,SEL plat;
    class BMH srv;</div>
  <figcaption>How a pool finds its server: pool asks for a node type, the node type selects servers by label.</figcaption>
</figure>

<p>In this lab the servers are identical VMs, so the label stands in for hardware discovery. On real hardware you would label by what the server is, for example a GPU model.</p>
<p>The Platform UI shows the same starting point:</p>
<figure class="post-screenshot">
    <img loading="lazy" src="https://vishctl.dev/images/posts/vcluster-bare-metal/ui-01-tenant-list-empty.png"
         alt="vCluster Platform Tenant Clusters page with no tenants and a Create your first tenant cluster prompt"/> <figcaption>
            <p>Tenant Clusters: empty. Nothing has been ordered yet.</p>
        </figcaption>
</figure>

<figure class="post-screenshot">
    <img loading="lazy" src="https://vishctl.dev/images/posts/vcluster-bare-metal/ui-02-node-types-available.png"
         alt="Nodes and Providers page with the Metal3 provider expanded into cpu-server and gpu-server node types"/> <figcaption>
            <p>Nodes and Providers: the Metal3 provider offers two node types, each backed by one server.</p>
        </figcaption>
</figure>

<figure class="post-screenshot">
    <img loading="lazy" src="https://vishctl.dev/images/posts/vcluster-bare-metal/ui-03-servers-available.png"
         alt="Bare Metal Servers page listing bare-metal-1 and bare-metal-2 as Available, powered off, with IPs 192.168.100.10 and .11"/> <figcaption>
            <p>Bare Metal Servers: both Available, power Off, not provisioned.</p>
        </figcaption>
</figure>

<h2 id="the-request-one-yaml-file">The request: one YAML file</h2>
<p>This is the whole request for a tenant. It has two parts: a network attachment, and a <code>VirtualClusterInstance</code> holding the vCluster settings.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">apiVersion</span><span class="p">:</span><span class="w"> </span><span class="l">k8s.cni.cncf.io/v1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">kind</span><span class="p">:</span><span class="w"> </span><span class="l">NetworkAttachmentDefinition</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">metadata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">br0-vcluster-one</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">namespace</span><span class="p">:</span><span class="w"> </span><span class="l">p-default</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">config</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">    { &#34;cniVersion&#34;: &#34;0.3.1&#34;, &#34;type&#34;: &#34;bridge&#34;, &#34;bridge&#34;: &#34;br0&#34;,
</span></span></span><span class="line"><span class="cl"><span class="sd">      &#34;isDefaultGateway&#34;: false, &#34;ipam&#34;: {} }</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nn">---</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">apiVersion</span><span class="p">:</span><span class="w"> </span><span class="l">management.loft.sh/v1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">kind</span><span class="p">:</span><span class="w"> </span><span class="l">VirtualClusterInstance</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">metadata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">one</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">namespace</span><span class="p">:</span><span class="w"> </span><span class="l">p-default</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">template</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">helmRelease</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">chart</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">version</span><span class="p">:</span><span class="w"> </span><span class="m">0.37.3</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">values</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">        controlPlane:
</span></span></span><span class="line"><span class="cl"><span class="sd">          backingStore:
</span></span></span><span class="line"><span class="cl"><span class="sd">            etcd:
</span></span></span><span class="line"><span class="cl"><span class="sd">              embedded:
</span></span></span><span class="line"><span class="cl"><span class="sd">                enabled: true
</span></span></span><span class="line"><span class="cl"><span class="sd">          endpoint: 192.168.100.2:8443
</span></span></span><span class="line"><span class="cl"><span class="sd">          advanced:
</span></span></span><span class="line"><span class="cl"><span class="sd">            kubeVip:
</span></span></span><span class="line"><span class="cl"><span class="sd">              enabled: true
</span></span></span><span class="line"><span class="cl"><span class="sd">              interface: net1
</span></span></span><span class="line"><span class="cl"><span class="sd">              gateway: 192.168.100.1/24
</span></span></span><span class="line"><span class="cl"><span class="sd">          statefulSet:
</span></span></span><span class="line"><span class="cl"><span class="sd">            pods:
</span></span></span><span class="line"><span class="cl"><span class="sd">              annotations:
</span></span></span><span class="line"><span class="cl"><span class="sd">                k8s.v1.cni.cncf.io/networks: p-default/br0-vcluster-one
</span></span></span><span class="line"><span class="cl"><span class="sd">            env:
</span></span></span><span class="line"><span class="cl"><span class="sd">              - name: VCLUSTER_NODECLAIM_WAIT_TIMEOUT
</span></span></span><span class="line"><span class="cl"><span class="sd">                value: 1h
</span></span></span><span class="line"><span class="cl"><span class="sd">        privateNodes:
</span></span></span><span class="line"><span class="cl"><span class="sd">          enabled: true
</span></span></span><span class="line"><span class="cl"><span class="sd">          autoNodes:
</span></span></span><span class="line"><span class="cl"><span class="sd">            - provider: metal3
</span></span></span><span class="line"><span class="cl"><span class="sd">              static:
</span></span></span><span class="line"><span class="cl"><span class="sd">                - name: gpu
</span></span></span><span class="line"><span class="cl"><span class="sd">                  quantity: 1
</span></span></span><span class="line"><span class="cl"><span class="sd">                  nodeTypeSelector:
</span></span></span><span class="line"><span class="cl"><span class="sd">                    - property: vcluster.com/node-type
</span></span></span><span class="line"><span class="cl"><span class="sd">                      value: gpu-server
</span></span></span><span class="line"><span class="cl"><span class="sd">                  nodeLabels:
</span></span></span><span class="line"><span class="cl"><span class="sd">                    run.ai/simulated-gpu-node-pool: default
</span></span></span><span class="line"><span class="cl"><span class="sd">                    workload: gpu
</span></span></span><span class="line"><span class="cl"><span class="sd">                - name: cpu
</span></span></span><span class="line"><span class="cl"><span class="sd">                  quantity: 1
</span></span></span><span class="line"><span class="cl"><span class="sd">                  nodeTypeSelector:
</span></span></span><span class="line"><span class="cl"><span class="sd">                    - property: vcluster.com/node-type
</span></span></span><span class="line"><span class="cl"><span class="sd">                      value: cpu-server
</span></span></span><span class="line"><span class="cl"><span class="sd">                  nodeLabels:
</span></span></span><span class="line"><span class="cl"><span class="sd">                    workload: cpu
</span></span></span><span class="line"><span class="cl"><span class="sd">              properties:
</span></span></span><span class="line"><span class="cl"><span class="sd">                vcluster.com/os-image: ubuntu2404-20260926</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">pro</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">enabled</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">clusterRef</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">cluster</span><span class="p">:</span><span class="w"> </span><span class="l">loft-cluster</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">namespace</span><span class="p">:</span><span class="w"> </span><span class="l">loft-default-v-one</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">virtualCluster</span><span class="p">:</span><span class="w"> </span><span class="l">one</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">owner</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">user</span><span class="p">:</span><span class="w"> </span><span class="l">admin</span><span class="w">
</span></span></span></code></pre></div><p>Read it top to bottom:</p>
<div class="table-scroll">
  <table>
    <thead>
        <tr>
            <th scope="col">Block</th>
            <th scope="col">What it does</th>
        </tr>
    </thead>
    <tbody>
        <tr>
            <td><code>NetworkAttachmentDefinition</code></td>
            <td>A second network card on <code>br0</code>, the rack network. <code>ipam</code> is empty on purpose.</td>
        </tr>
        <tr>
            <td><code>backingStore.etcd.embedded</code></td>
            <td>The tenant&rsquo;s etcd lives inside its own control plane pod.</td>
        </tr>
        <tr>
            <td><code>endpoint</code> plus <code>kubeVip</code></td>
            <td>Gives the control plane a floating IP <code>192.168.100.2</code> on that second card (<code>net1</code>). This is how servers on the rack network reach the tenant API.</td>
        </tr>
        <tr>
            <td><code>networks</code> annotation</td>
            <td>Multus plugs the attachment into the control plane pod.</td>
        </tr>
        <tr>
            <td><code>NODECLAIM_WAIT_TIMEOUT: 1h</code></td>
            <td>Writing an OS takes minutes, so give the claims time.</td>
        </tr>
        <tr>
            <td><code>privateNodes.enabled</code></td>
            <td>The tenant gets its own machines.</td>
        </tr>
        <tr>
            <td><code>autoNodes</code></td>
            <td>Two fixed pools, <code>gpu</code> and <code>cpu</code>, one node each, ordered from the <code>metal3</code> provider.</td>
        </tr>
        <tr>
            <td><code>nodeLabels</code></td>
            <td>Applied when the node joins. The fake GPU operator looks for <code>run.ai/simulated-gpu-node-pool</code>.</td>
        </tr>
        <tr>
            <td><code>os-image</code></td>
            <td>Which Ubuntu image Metal3 writes to disk.</td>
        </tr>
    </tbody>
  </table>
</div>
<p>The networking is the part that usually trips people up. The control plane is a pod inside the host cluster, and the servers are on a different network. kube-vip bridges that gap:</p>
<figure class="mermaid-figure">
  <div class="mermaid">flowchart TD
    subgraph HOSTNET["Host cluster pod network"]
        direction LR
        CP["Tenant control plane<br/>pod one-0"] --> ETH["eth0<br/>pod IP"]
    end
    subgraph RACK["br0 rack network"]
        direction LR
        NET1["net1 via Multus<br/>kube-vip 192.168.100.2"] k1@--> S1["bare-metal-2<br/>192.168.100.11"]
        NET1 k2@--> S2["bare-metal-1<br/>192.168.100.10"]
    end
    HOSTNET ~~~ RACK
    k1@{ animate: true }
    k2@{ animate: true }
    classDef cp fill:#281d48,stroke:#a78bfa,color:#f1edff,stroke-width:2px;
    classDef srv fill:#173f59,stroke:#22d3ee,color:#ecfeff,stroke-width:2px;
    classDef idle fill:#101e27,stroke:#324950,color:#8aa0a8;
    class CP,NET1 cp;
    class S1,S2 srv;
    class ETH idle;</div>
  <figcaption>How a freshly booted server finds the tenant API. The control plane pod has two cards; kube-vip announces the API address on the rack side.</figcaption>
</figure>

<h2 id="apply-it-and-watch">Apply it and watch</h2>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">$ date -u +&#34;start: %H:%M:%S UTC&#34;; make create-vcluster
</span></span><span class="line"><span class="cl">start: 06:05:19 UTC
</span></span><span class="line"><span class="cl">kubectl apply -f manifests/vcluster.yaml
</span></span><span class="line"><span class="cl">networkattachmentdefinition.k8s.cni.cncf.io/br0-vcluster-one created
</span></span><span class="line"><span class="cl">virtualclusterinstance.management.loft.sh/one created
</span></span></code></pre></div><p>A loop printed the tenant, its node claims and the servers every 10 seconds. Here are the moments that changed something:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">06:05:25  VCI one created
</span></span><span class="line"><span class="cl">06:06:09  one-cqhsw   Pending     metal3.gpu-server
</span></span><span class="line"><span class="cl">          one-r8zq6   Pending     metal3.cpu-server
</span></span><span class="line"><span class="cl">          bare-metal-1   provisioning   true
</span></span><span class="line"><span class="cl">          bare-metal-2   provisioning   true
</span></span><span class="line"><span class="cl">06:10:04  bare-metal-1   provisioned    true
</span></span><span class="line"><span class="cl">          bare-metal-2   provisioned    true
</span></span><span class="line"><span class="cl">06:10:37  one-cqhsw   Available   metal3.gpu-server
</span></span><span class="line"><span class="cl">06:11:21  one-r8zq6   Available   metal3.cpu-server
</span></span></code></pre></div><p>The same moment in the Platform UI (these two shots are from a second, identical run a few minutes later, so the claim names differ):</p>
<figure class="post-screenshot">
    <img loading="lazy" src="https://vishctl.dev/images/posts/vcluster-bare-metal/ui-04-nodeclaims-pending.png"
         alt="Tenant one Node Claims tab showing two Static Pool claims, one cpu-server and one gpu-server, both Pending"/> <figcaption>
            <p>About 44 seconds in: the tenant is Running and its two node claims are Pending.</p>
        </figcaption>
</figure>

<figure class="post-screenshot">
    <img loading="lazy" src="https://vishctl.dev/images/posts/vcluster-bare-metal/ui-05-servers-provisioning.png"
         alt="Bare Metal Servers page with both servers Provisioning for 52 seconds and power state Powering, each assigned to a claim"/> <figcaption>
            <p>Metal3 has powered both servers on and is provisioning them.</p>
        </figcaption>
</figure>

<p>What is happening behind those states:</p>
<figure class="mermaid-figure">
  <div class="mermaid">sequenceDiagram
    participant P as vCluster Platform
    participant M as Metal3 and Ironic
    participant B as BMC pod
    participant S as Server
    participant T as Tenant API
    P->>M: NodeClaim, give me a gpu-server
    M->>B: Redfish power on, boot from network
    B->>S: start VM
    S->>M: PXE boot ramdisk, DHCP from proxy
    M->>S: write Ubuntu image to disk
    M->>B: power cycle
    S->>S: boot from disk, cloud-init
    S->>T: kubeadm join via 192.168.100.2
    T-->>P: node Ready, claim Available</div>
  <figcaption>One NodeClaim, from Pending to a Ready node. Every arrow is a controller, not a person.</figcaption>
</figure>

<p>If you watch the host&rsquo;s pods during this, the BMC and <code>virt-launcher</code> pods are created, deleted and created again. That is the reboot: first into the ramdisk that writes the image, then into the freshly written disk.</p>
<p>The Platform keeps a timestamp for each condition, which gives exact numbers:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">VirtualClusterDeployed   True   06:05:21Z
</span></span><span class="line"><span class="cl">VirtualClusterOnline     True   06:05:48Z
</span></span><span class="line"><span class="cl">VirtualClusterReady      True   06:05:50Z
</span></span><span class="line"><span class="cl">NodesProvisioned         True   06:11:20Z
</span></span><span class="line"><span class="cl">bare-metal-2   created 06:10:29Z   Ready since 06:10:46Z
</span></span><span class="line"><span class="cl">bare-metal-1   created 06:11:20Z   Ready since 06:11:30Z
</span></span></code></pre></div><figure class="mermaid-figure">
  <div class="mermaid">flowchart LR
    A["Apply<br/>0 s"] t1@--> B["Control plane Ready<br/>+30 s"]
    B t2@--> C["Claims and power on<br/>+41 s"]
    C t3@--> D["Ubuntu written<br/>about +4 min 45 s"]
    D t4@--> E["GPU node Ready<br/>+5 min 26 s"]
    E t5@--> F["CPU node Ready<br/>+6 min 10 s"]
    t1@{ animate: true }
    t2@{ animate: true }
    t3@{ animate: true }
    t4@{ animate: true }
    t5@{ animate: true }
    classDef fast fill:#173f59,stroke:#22d3ee,color:#ecfeff,stroke-width:2px;
    classDef slow fill:#302410,stroke:#fbbf24,color:#fff3cf,stroke-width:2px;
    class A,B,C,E,F fast;
    class D slow;</div>
  <figcaption>Time budget from apply to two Ready nodes. The control plane is 30 seconds; the disk write dominates.</figcaption>
</figure>

<p>Final state on the host:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">$ kubectl get virtualclusterinstances.management.loft.sh -A -o custom-columns=NAME:.metadata.name,PHASE:.status.phase
</span></span><span class="line"><span class="cl">NAME   PHASE
</span></span><span class="line"><span class="cl">one    Ready
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">$ kubectl get nodeclaims.management.loft.sh -A
</span></span><span class="line"><span class="cl">NAMESPACE   NAME        STATUS      VCLUSTER   TYPE                CREATED AT
</span></span><span class="line"><span class="cl">p-default   one-cqhsw   Available   one        metal3.gpu-server   2026-11-01T06:06:01Z
</span></span><span class="line"><span class="cl">p-default   one-r8zq6   Available   one        metal3.cpu-server   2026-11-01T06:06:01Z
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">$ kubectl get bmh -A
</span></span><span class="line"><span class="cl">NAMESPACE   NAME           STATE         ONLINE   AGE
</span></span><span class="line"><span class="cl">default     bare-metal-1   provisioned   true     23d
</span></span><span class="line"><span class="cl">default     bare-metal-2   provisioned   true     23d
</span></span></code></pre></div><p>And in the UI:</p>
<figure class="post-screenshot">
    <img loading="lazy" src="https://vishctl.dev/images/posts/vcluster-bare-metal/ui-06-nodeclaims-available.png"
         alt="Tenant one Node Claims tab with both claims Available"/> <figcaption>
            <p>Both claims Available.</p>
        </figcaption>
</figure>

<figure class="post-screenshot">
    <img loading="lazy" src="https://vishctl.dev/images/posts/vcluster-bare-metal/ui-07-servers-provisioned.png"
         alt="Bare Metal Servers page with both servers Provisioned and powered On, linked to machines one-r8zq6 and one-cqhsw"/> <figcaption>
            <p>Both servers Provisioned and On, each bound to a tenant machine.</p>
        </figcaption>
</figure>

<figure class="post-screenshot">
    <img loading="lazy" src="https://vishctl.dev/images/posts/vcluster-bare-metal/ui-08-tenant-nodes-ready.png"
         alt="Tenant one Nodes tab listing bare-metal-1 from metal3.cpu-server and bare-metal-2 from metal3.gpu-server, both Ready workers"/> <figcaption>
            <p>Tenant one: two Ready worker nodes, each traced back to the node type that supplied it.</p>
        </figcaption>
</figure>

<h2 id="inside-the-tenant">Inside the tenant</h2>
<p>The tenant&rsquo;s kubeconfig is a secret in its host namespace. The API lives inside the host cluster, so I forward a local port to it:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">kubectl --kubeconfig ~/sandbox/kubeconfig get secret vc-one -n loft-default-v-one <span class="se">\
</span></span></span><span class="line"><span class="cl">  -o <span class="nv">jsonpath</span><span class="o">=</span><span class="s1">&#39;{.data.config}&#39;</span> <span class="p">|</span> base64 -d <span class="se">\
</span></span></span><span class="line"><span class="cl">  <span class="p">|</span> sed <span class="s1">&#39;s#server: .*#server: https://127.0.0.1:18443#&#39;</span> &gt; ~/tenant-one.kubeconfig
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">nohup kubectl --kubeconfig ~/sandbox/kubeconfig port-forward -n loft-default-v-one <span class="se">\
</span></span></span><span class="line"><span class="cl">  svc/one 18443:443 --address 127.0.0.1 &gt;~/pf.log 2&gt;<span class="p">&amp;</span><span class="m">1</span> <span class="p">&amp;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">KUBECONFIG</span><span class="o">=</span>~/tenant-one.kubeconfig
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">$ kubectl get nodes -o wide -L run.ai/simulated-gpu-node-pool,workload
</span></span><span class="line"><span class="cl">NAME           STATUS   AGE    VERSION   INTERNAL-IP      OS-IMAGE             KERNEL-VERSION      SIMULATED-GPU-NODE-POOL   WORKLOAD
</span></span><span class="line"><span class="cl">bare-metal-1   Ready    55s    v1.36.0   192.168.100.10   Ubuntu 24.04.5 LTS   6.8.0-142-generic                             cpu
</span></span><span class="line"><span class="cl">bare-metal-2   Ready    106s   v1.36.0   192.168.100.11   Ubuntu 24.04.5 LTS   6.8.0-142-generic   default                   gpu
</span></span></code></pre></div><p>Compare that with the host: the host node runs kernel <code>7.0.0-30</code> and is called <code>bare-metal-fun</code>. These two nodes run the freshly written Ubuntu with kernel <code>6.8.0-142</code>. They are different machines.</p>
<p>The tenant is a clean cluster with its own system pods:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">$ kubectl get pods -A
</span></span><span class="line"><span class="cl">NAMESPACE            NAME                                      READY   STATUS    AGE
</span></span><span class="line"><span class="cl">kube-flannel         kube-flannel-ds-b8r96                     1/1     Running   111s
</span></span><span class="line"><span class="cl">kube-flannel         kube-flannel-ds-tdflh                     1/1     Running   2m42s
</span></span><span class="line"><span class="cl">kube-system          coredns-df8c87f55-zfdpp                   1/1     Running   7m23s
</span></span><span class="line"><span class="cl">kube-system          konnectivity-agent-599bbdf4c8-ndb7w       1/1     Running   7m22s
</span></span><span class="line"><span class="cl">kube-system          kube-proxy-lqdwd                          1/1     Running   2m43s
</span></span><span class="line"><span class="cl">kube-system          kube-proxy-wjsls                          1/1     Running   112s
</span></span><span class="line"><span class="cl">local-path-storage   local-path-provisioner-6d484fd799-zv66r   1/1     Running   7m22s
</span></span></code></pre></div><p>And on the host, the tenant&rsquo;s namespace holds exactly <strong>one</strong> pod, the control plane:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">$ kubectl get pods -n loft-default-v-one
</span></span><span class="line"><span class="cl">NAME    READY   STATUS    RESTARTS   AGE
</span></span><span class="line"><span class="cl">one-0   1/1     Running   0          7m56s
</span></span></code></pre></div><figure class="mermaid-figure">
  <div class="mermaid">flowchart TD
    subgraph H["Host cluster"]
        direction LR
        PL["Platform"] --> CP["one-0<br/>API, etcd, controllers"]
    end
    subgraph T["Tenant one nodes"]
        direction LR
        G["bare-metal-2<br/>workload gpu"] ~~~ C["bare-metal-1<br/>workload cpu"]
    end
    CP w1@--> G
    CP w2@--> C
    w1@{ animate: true }
    w2@{ animate: true }
    classDef plat fill:#281d48,stroke:#a78bfa,color:#f1edff,stroke-width:2px;
    classDef gpu fill:#173f59,stroke:#22d3ee,color:#ecfeff,stroke-width:2px;
    classDef cpu fill:#13264a,stroke:#38bdf8,color:#e6f6ff,stroke-width:2px;
    class PL,CP plat;
    class G gpu;
    class C cpu;</div>
  <figcaption>Where things run. The host only carries the tenant&#39;s control plane; workloads stay on the tenant&#39;s own servers.</figcaption>
</figure>

<h2 id="give-the-gpu-node-some-gpus">Give the GPU node some GPUs</h2>
<p>There is no real GPU in this lab, so I install Run:ai&rsquo;s <strong>fake GPU operator</strong> inside the tenant. It only targets nodes labelled <code>run.ai/simulated-gpu-node-pool=default</code>, which the <code>gpu</code> pool set at join time.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># gpu/gpu-values.yaml</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">topology</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">nodePools</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">default</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">gpuProduct</span><span class="p">:</span><span class="w"> </span><span class="l">Tesla-K80</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">gpuCount</span><span class="p">:</span><span class="w"> </span><span class="m">4</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">gpuMemory</span><span class="p">:</span><span class="w"> </span><span class="m">11441</span><span class="w">
</span></span></span></code></pre></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">$ make install-gpu-operator
</span></span><span class="line"><span class="cl">helm --kubeconfig ~/tenant-one.kubeconfig upgrade --install gpu-operator \
</span></span><span class="line"><span class="cl">  oci://ghcr.io/run-ai/fake-gpu-operator/fake-gpu-operator \
</span></span><span class="line"><span class="cl">  --version 0.0.72 --namespace gpu-operator -f gpu/gpu-values.yaml --wait --timeout 5m
</span></span><span class="line"><span class="cl">Release &#34;gpu-operator&#34; does not exist. Installing it now.
</span></span><span class="line"><span class="cl">STATUS: deployed
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">$ kubectl get nodes -o custom-columns=&#39;NAME:.metadata.name,GPU:.status.allocatable.nvidia\.com/gpu&#39;
</span></span><span class="line"><span class="cl">NAME           GPU
</span></span><span class="line"><span class="cl">bare-metal-1   &lt;none&gt;
</span></span><span class="line"><span class="cl">bare-metal-2   4
</span></span></code></pre></div><p>The device plugin only landed on the labelled node:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">$ kubectl get pods -n gpu-operator -o wide
</span></span><span class="line"><span class="cl">NAME                                        READY   STATUS    NODE
</span></span><span class="line"><span class="cl">computedomain-controller-59f965747d-q6ttl   1/1     Running   bare-metal-1
</span></span><span class="line"><span class="cl">device-plugin-cxd5w                         1/1     Running   bare-metal-2
</span></span><span class="line"><span class="cl">kwok-gpu-device-plugin-596cd78494-9lz9n     1/1     Running   bare-metal-1
</span></span><span class="line"><span class="cl">nvidia-dcgm-exporter-kwok-db645795f-k9r7h   1/1     Running   bare-metal-1
</span></span><span class="line"><span class="cl">nvidia-dcgm-exporter-m4rr4                  1/1     Running   bare-metal-2
</span></span><span class="line"><span class="cl">status-updater-f447d96d9-cccwt              1/1     Running   bare-metal-2
</span></span><span class="line"><span class="cl">topology-server-56cd64dbcf-5dbwh            1/1     Running   bare-metal-1
</span></span></code></pre></div><h2 id="prove-the-scheduling">Prove the scheduling</h2>
<p>Four test pods:</p>
<div class="table-scroll">
  <table>
    <thead>
        <tr>
            <th scope="col">Pod</th>
            <th scope="col">Asks for</th>
            <th scope="col">Expected</th>
        </tr>
    </thead>
    <tbody>
        <tr>
            <td><code>gpu-ok</code></td>
            <td>1 GPU</td>
            <td>Runs on the GPU node</td>
        </tr>
        <tr>
            <td><code>gpu-smi</code></td>
            <td>1 GPU, runs <code>nvidia-smi</code></td>
            <td>Completes on the GPU node</td>
        </tr>
        <tr>
            <td><code>cpu-only</code></td>
            <td>no GPU, <code>workload: cpu</code></td>
            <td>Runs on the CPU node</td>
        </tr>
        <tr>
            <td><code>gpu-too-big</code></td>
            <td>5 GPUs</td>
            <td>Stays <code>Pending</code>, only 4 exist</td>
        </tr>
    </tbody>
  </table>
</div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">$ kubectl apply -f ~/sandbox/gpu/gpu-test-pods.yaml
</span></span><span class="line"><span class="cl">pod/gpu-ok created
</span></span><span class="line"><span class="cl">pod/gpu-too-big created
</span></span><span class="line"><span class="cl">pod/cpu-only created
</span></span><span class="line"><span class="cl">pod/gpu-smi created
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">$ kubectl get pods -o wide
</span></span><span class="line"><span class="cl">NAME          READY   STATUS      RESTARTS   AGE   IP           NODE
</span></span><span class="line"><span class="cl">cpu-only      1/1     Running     0          15s   10.244.1.6   bare-metal-1
</span></span><span class="line"><span class="cl">gpu-ok        1/1     Running     0          15s   10.244.0.8   bare-metal-2
</span></span><span class="line"><span class="cl">gpu-smi       0/1     Completed   0          15s   10.244.0.9   bare-metal-2
</span></span><span class="line"><span class="cl">gpu-too-big   0/1     Pending     0          15s   &lt;none&gt;       &lt;none&gt;
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">$ kubectl logs gpu-smi
</span></span><span class="line"><span class="cl">+------------------------------------------------------------------------------+
</span></span><span class="line"><span class="cl">| NVIDIA-SMI 470.129.06   Driver Version: 470.129.06   CUDA Version: 11.4      |
</span></span><span class="line"><span class="cl">+--------------------------------+----------------------+----------------------+
</span></span><span class="line"><span class="cl">|   1  Tesla-K80             Off | 00000001:00:00.0 Off |                  Off |
</span></span><span class="line"><span class="cl">| N/A   33C    P8    11W /  70W  |  11441MiB / 11441MiB |     100%     Default |
</span></span><span class="line"><span class="cl">+--------------------------------+----------------------+----------------------+
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">$ kubectl describe pod gpu-too-big | tail -2
</span></span><span class="line"><span class="cl">  Warning  FailedScheduling  15s   default-scheduler  0/2 nodes are available: 2 Insufficient nvidia.com/gpu. ...
</span></span></code></pre></div><figure class="mermaid-figure">
  <div class="mermaid">flowchart TD
    subgraph ASK["Requests"]
        direction LR
        R1["gpu-ok<br/>1 GPU"] ~~~ R2["gpu-smi<br/>1 GPU"] ~~~ R3["cpu-only<br/>0 GPU"] ~~~ R4["gpu-too-big<br/>5 GPU"]
    end
    subgraph NODES["Tenant nodes"]
        direction LR
        G["bare-metal-2<br/>4 GPUs"] ~~~ C["bare-metal-1<br/>0 GPUs"] ~~~ X["Pending<br/>Insufficient GPU"]
    end
    R1 g1@--> G
    R2 g2@--> G
    R3 g3@--> C
    R4 -.-> X
    ASK ~~~ NODES
    g1@{ animate: true }
    g2@{ animate: true }
    g3@{ animate: true }
    classDef req fill:#13264a,stroke:#38bdf8,color:#e6f6ff,stroke-width:2px;
    classDef gpu fill:#173f59,stroke:#22d3ee,color:#ecfeff,stroke-width:2px;
    classDef cpu fill:#101e27,stroke:#324950,color:#8aa0a8;
    classDef warn fill:#302410,stroke:#fbbf24,color:#fff3cf,stroke-width:2px;
    class R1,R2,R3,R4 req;
    class G gpu;
    class C cpu;
    class X warn;</div>
  <figcaption>Scheduler decisions inside the tenant. GPUs are a countable resource, so nothing is oversold.</figcaption>
</figure>

<p>The GPUs are simulated, so this shows resource accounting and placement, not performance. If you want the full story on <code>Pending</code> GPU pods, see <a href="https://vishctl.dev/posts/kubernetes-gpu-pod-pending-troubleshooting/">why a GPU pod stays Pending</a>.</p>
<h2 id="clean-up-servers-go-back-to-the-pool">Clean up: servers go back to the pool</h2>
<p>Deleting the tenant releases its servers. The delete took 45 seconds, and both servers were back to <code>available</code> and powered off a few seconds later, ready for the next customer.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">$ date -u +&#34;start: %H:%M:%S UTC&#34;; kubectl delete virtualclusterinstances.management.loft.sh one -n p-default; date -u +&#34;done: %H:%M:%S UTC&#34;
</span></span><span class="line"><span class="cl">start: 06:27:51 UTC
</span></span><span class="line"><span class="cl">virtualclusterinstance.management.loft.sh &#34;one&#34; deleted from p-default namespace
</span></span><span class="line"><span class="cl">done: 06:28:36 UTC
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">$ kubectl get bmh -A
</span></span><span class="line"><span class="cl">NAMESPACE   NAME           STATE       ONLINE   AGE
</span></span><span class="line"><span class="cl">default     bare-metal-1   available   false    23d
</span></span><span class="line"><span class="cl">default     bare-metal-2   available   false    23d
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">$ kubectl get vm -n default
</span></span><span class="line"><span class="cl">NAME           AGE   STATUS    READY
</span></span><span class="line"><span class="cl">bare-metal-1   23d   Stopped   False
</span></span><span class="line"><span class="cl">bare-metal-2   23d   Stopped   False
</span></span></code></pre></div><figure class="mermaid-figure">
  <div class="mermaid">flowchart LR
    A["available<br/>powered off"] l1@--> B["provisioning<br/>image written"]
    B l2@--> C["provisioned<br/>node in tenant"]
    C l3@--> D["deprovisioning<br/>released, powered off"]
    D l4@--> A
    l1@{ animate: true }
    l2@{ animate: true }
    l3@{ animate: true }
    l4@{ animate: true }
    classDef idle fill:#101e27,stroke:#324950,color:#8aa0a8;
    classDef busy fill:#302410,stroke:#fbbf24,color:#fff3cf,stroke-width:2px;
    classDef live fill:#173f59,stroke:#22d3ee,color:#ecfeff,stroke-width:2px;
    class A idle;
    class B,D busy;
    class C live;</div>
  <figcaption>A server&#39;s lifecycle across tenants.</figcaption>
</figure>

<p>One lab gotcha: deleting a tenant also strips three annotations from each <code>BareMetalHost</code> (IP, gateway, DNS) that the DHCP proxy reads. Without them the next tenant&rsquo;s servers boot with no network. The lab&rsquo;s <code>make fix-bmh</code> puts them back:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">$ make fix-bmh
</span></span><span class="line"><span class="cl">kubectl annotate bmh bare-metal-1 -n default --overwrite metal3.vcluster.com/ip-address=192.168.100.10/24 metal3.vcluster.com/gateway=192.168.100.1 metal3.vcluster.com/dns-servers=44.128.0.4
</span></span><span class="line"><span class="cl">baremetalhost.metal3.io/bare-metal-1 annotated
</span></span><span class="line"><span class="cl">kubectl annotate bmh bare-metal-2 -n default --overwrite metal3.vcluster.com/ip-address=192.168.100.11/24 metal3.vcluster.com/gateway=192.168.100.1 metal3.vcluster.com/dns-servers=44.128.0.4
</span></span><span class="line"><span class="cl">baremetalhost.metal3.io/bare-metal-2 annotated
</span></span></code></pre></div><h2 id="what-is-real-and-what-is-simulated">What is real and what is simulated</h2>
<div class="table-scroll">
  <table>
    <thead>
        <tr>
            <th scope="col">Real</th>
            <th scope="col">Simulated</th>
        </tr>
    </thead>
    <tbody>
        <tr>
            <td>vCluster Platform, private nodes, auto nodes</td>
            <td>The servers (KubeVirt VMs)</td>
        </tr>
        <tr>
            <td>Metal3, Ironic, Redfish power control</td>
            <td>The BMCs (a pod per server)</td>
        </tr>
        <tr>
            <td>DHCP, PXE, writing Ubuntu to disk</td>
            <td>The GPUs (fake GPU operator)</td>
        </tr>
        <tr>
            <td>kubeadm join, kube-vip, Multus, flannel</td>
            <td>Hardware discovery (a <code>role</code> label)</td>
        </tr>
        <tr>
            <td>Kubernetes scheduling of <code>nvidia.com/gpu</code></td>
            <td>GPU performance, NVLink, RDMA</td>
        </tr>
    </tbody>
  </table>
</div>
<p>The flow is the one you would run on a real rack. What this lab cannot tell you is how real GPU servers behave: driver install time, fabric topology, firmware, and how you safely wipe a server between customers. That is what a proof of concept on real hardware is for.</p>
<h2 id="cheat-sheet">Cheat sheet</h2>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">vCluster              = a Kubernetes control plane that runs as a pod in a host cluster
</span></span><span class="line"><span class="cl">vCluster Platform     = the management layer that creates tenants and hands out nodes
</span></span><span class="line"><span class="cl">Private nodes         = the tenant gets its own machines, not shared host nodes
</span></span><span class="line"><span class="cl">Auto nodes            = the Platform orders those machines from a node provider
</span></span><span class="line"><span class="cl">VirtualClusterInstance= the Platform object for one tenant; your portal can create it via API
</span></span><span class="line"><span class="cl">NodeProvider          = where machines come from (metal3, OpenTofu templates, KubeVirt)
</span></span><span class="line"><span class="cl">NodeType              = an orderable machine shape, selects servers by label
</span></span><span class="line"><span class="cl">NodeClaim             = one request for one node; Pending until a server joins
</span></span><span class="line"><span class="cl">BareMetalHost         = Metal3&#39;s record of one physical server and its state
</span></span><span class="line"><span class="cl">Metal3 / Ironic       = power on via BMC, PXE boot, write the OS image to disk
</span></span><span class="line"><span class="cl">kube-vip              = floating IP so servers on the rack network reach the tenant API
</span></span><span class="line"><span class="cl">Multus                = gives the control plane pod a second card on the rack network
</span></span><span class="line"><span class="cl">fake-gpu-operator     = advertises nvidia.com/gpu on labelled nodes, no hardware needed
</span></span></code></pre></div><p>Related posts:</p>
<ul>
<li><a href="https://vishctl.dev/posts/what-is-vcluster-virtual-kubernetes-clusters/">vCluster: virtualise clusters, not machines</a></li>
<li><a href="https://vishctl.dev/posts/vcluster-private-nodes-auto-nodes-bare-metal/">vCluster private nodes and Auto Nodes, explained</a></li>
<li><a href="https://vishctl.dev/posts/simulate-gpu-vllm-llm-d-on-laptop/">Simulate GPUs, vLLM and llm-d on a laptop</a></li>
<li><a href="https://vishctl.dev/posts/kubernetes-gpu-platform-engineering/">Kubernetes GPU platform engineering</a></li>
<li><a href="https://vishctl.dev/posts/kubernetes-gpu-pod-pending-troubleshooting/">Why a GPU pod stays Pending</a></li>
<li><a href="https://vishctl.dev/posts/karmada-kubernetes-multi-cluster/">Karmada: Kubernetes across clusters</a></li>
</ul>
]]></content:encoded></item><item><title>vCluster Private Nodes and Auto Nodes: How a Tenant Gets Its Own Servers</title><link>https://vishctl.dev/posts/vcluster-private-nodes-auto-nodes-bare-metal/</link><pubDate>Sun, 01 Nov 2026 11:00:00 +0800</pubDate><guid>https://vishctl.dev/posts/vcluster-private-nodes-auto-nodes-bare-metal/</guid><category>kubernetes</category><category>vcluster</category><category>bare-metal</category><category>metal3</category><category>multi-tenancy</category><category>gpu</category><description>How vCluster Platform turns one tenant request into real machines: node providers, node types, NodeClaims, and the Metal3 and Ironic flow that writes an OS to a bare-metal server and joins it.</description><content:encoded><![CDATA[<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Private nodes = the tenant owns real machines, joined straight to its own API
</span></span><span class="line"><span class="cl">Auto Nodes    = the Platform orders, installs and joins those machines for you
</span></span></code></pre></div><p>The <a href="https://vishctl.dev/posts/what-is-vcluster-virtual-kubernetes-clusters/">first post</a> ended on private nodes: machines that join a tenant&rsquo;s own control plane, with nothing shared at the node level.</p>
<p>That raises the practical question. Who finds the machine, installs it, and joins it?</p>
<p>This post follows one request from &ldquo;tenant wants a GPU server&rdquo; to &ldquo;node is Ready&rdquo;. The <a href="https://vishctl.dev/posts/vcluster-bare-metal-gpu-tenant-metal3/">lab post</a> shows the same flow running, with real output.</p>
<h2 id="private-nodes-in-one-picture">Private nodes, in one picture</h2>
<p>With private nodes, the tenant&rsquo;s control plane still runs as a pod in the host cluster (the Control Plane Cluster). The workers are separate machines. Each one runs its own kubelet, its own CNI and its own CSI, and talks only to its tenant&rsquo;s API.</p>
<figure class="mermaid-figure">
  <div class="mermaid">flowchart TD
    PL["vCluster Platform<br/>on the Control Plane Cluster"] p1@--> CA["Tenant A<br/>control plane pod"]
    PL p2@--> CB["Tenant B<br/>control plane pod"]
    CA e1@--> M1["Server 1<br/>private node of A"]
    CA e2@--> M2["Server 2<br/>private node of A"]
    CB e3@--> M3["Server 3<br/>private node of B"]
    PL -.-> M4["Server 4<br/>free pool"]
    p1@{ animate: true }
    p2@{ animate: true }
    e1@{ animate: true }
    e2@{ animate: true }
    e3@{ animate: true }
    classDef input fill:#13264a,stroke:#38bdf8,color:#e6f6ff,stroke-width:2px;
    classDef router fill:#281d48,stroke:#a78bfa,color:#f1edff,stroke-width:2px;
    classDef gpu fill:#173f59,stroke:#22d3ee,color:#ecfeff,stroke-width:2px;
    classDef idle fill:#101e27,stroke:#324950,color:#8aa0a8;
    class PL input;
    class CA,CB router;
    class M1,M2,M3 gpu;
    class M4 idle;</div>
  <figcaption>The host cluster only runs control planes. Each server belongs to one tenant, or waits in the free pool.</figcaption>
</figure>

<p>You can join a private node by hand. You create a join token in the tenant, run the join script on the machine, and it appears in <code>kubectl get nodes</code>. That works for a lab. It does not work for a product where customers order clusters at 2 a.m.</p>
<h2 id="auto-nodes-a-node-is-a-request">Auto Nodes: a node is a request</h2>
<p><strong>Auto Nodes</strong> makes node creation automatic. It is built on Karpenter&rsquo;s ideas: a <strong>pool</strong> says what nodes a tenant needs, and each missing node becomes a <strong>NodeClaim</strong>, which is one request for one machine. A <strong>node provider</strong> fills the claim.</p>
<div class="table-scroll">
  <table>
    <thead>
        <tr>
            <th scope="col">Pool type</th>
            <th scope="col">How claims are created</th>
            <th scope="col">Example</th>
        </tr>
    </thead>
    <tbody>
        <tr>
            <td>Static</td>
            <td>A fixed <code>quantity</code> per pool</td>
            <td>&ldquo;This tenant always has 1 GPU server and 1 CPU server&rdquo;</td>
        </tr>
        <tr>
            <td>Dynamic</td>
            <td>When pods are unschedulable, within limits</td>
            <td>&ldquo;Add GPU servers when training jobs are Pending, up to 4&rdquo;</td>
        </tr>
    </tbody>
  </table>
</div>
<p>Here is a static pool on a tenant. It is the <code>privateNodes</code> part of the tenant&rsquo;s vCluster config:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">privateNodes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">enabled</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">autoNodes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="nt">provider</span><span class="p">:</span><span class="w"> </span><span class="l">metal3</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">static</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">gpu</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">quantity</span><span class="p">:</span><span class="w"> </span><span class="m">1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">nodeTypeSelector</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span>- <span class="nt">property</span><span class="p">:</span><span class="w"> </span><span class="l">vcluster.com/node-type</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span><span class="nt">value</span><span class="p">:</span><span class="w"> </span><span class="l">gpu-server</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">nodeLabels</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">workload</span><span class="p">:</span><span class="w"> </span><span class="l">gpu</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">cpu</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">quantity</span><span class="p">:</span><span class="w"> </span><span class="m">1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">nodeTypeSelector</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span>- <span class="nt">property</span><span class="p">:</span><span class="w"> </span><span class="l">vcluster.com/node-type</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span><span class="nt">value</span><span class="p">:</span><span class="w"> </span><span class="l">cpu-server</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">nodeLabels</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">workload</span><span class="p">:</span><span class="w"> </span><span class="l">cpu</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">properties</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">vcluster.com/os-image</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu2404-20260926</span><span class="w">
</span></span></span></code></pre></div><p>To add a node, change <code>quantity</code> and apply. The Platform creates one more claim.</p>
<h2 id="the-objects-the-platform-needs-to-know-about">The objects the Platform needs to know about</h2>
<p>Before a claim can be filled, the Platform has to be taught where machines come from and what can be ordered. These are cluster objects in the <code>management.loft.sh</code> API group.</p>
<div class="table-scroll">
  <table>
    <thead>
        <tr>
            <th scope="col">Object</th>
            <th scope="col">Question it answers</th>
            <th scope="col">Example</th>
        </tr>
    </thead>
    <tbody>
        <tr>
            <td><strong>NodeProvider</strong></td>
            <td>Where do machines come from?</td>
            <td><code>metal3</code> for bare metal, an OpenTofu template for VMs, KubeVirt</td>
        </tr>
        <tr>
            <td><strong>NodeType</strong></td>
            <td>What can be ordered, and how many exist?</td>
            <td><code>gpu-server</code> selects hosts labelled <code>role=gpu</code></td>
        </tr>
        <tr>
            <td><strong>NodeEnvironment</strong></td>
            <td>Which network and settings do nodes get?</td>
            <td>IP range, gateway and DNS of the server network</td>
        </tr>
        <tr>
            <td><strong>OSImage</strong></td>
            <td>What gets written to disk?</td>
            <td>Ubuntu 24.04 cloud image</td>
        </tr>
        <tr>
            <td><strong>NodeClaim</strong></td>
            <td>One request for one node</td>
            <td><code>one-vc4vs  Pending  metal3</code></td>
        </tr>
        <tr>
            <td><strong>VirtualClusterInstance</strong></td>
            <td>One tenant</td>
            <td><code>one</code> in project namespace <code>p-default</code></td>
        </tr>
    </tbody>
  </table>
</div>
<p>A good way to remember it: the provider is the shop, the node type is the item on the menu, and the claim is the order.</p>
<figure class="mermaid-figure">
  <div class="mermaid">flowchart TD
    VCI["VirtualClusterInstance<br/>pool gpu, quantity 1"] e1@--> NC["NodeClaim<br/>Pending"]
    NC e2@--> NT["NodeType gpu-server<br/>pick a free matching host"]
    NT e3@--> PRV["NodeProvider metal3<br/>install the machine"]
    PRV e4@--> J["Machine boots<br/>runs join script"]
    J e5@--> N["Node Ready<br/>in the tenant"]
    e1@{ animate: true }
    e2@{ animate: true }
    e3@{ animate: true }
    e4@{ animate: true }
    e5@{ animate: true }
    classDef input fill:#13264a,stroke:#38bdf8,color:#e6f6ff,stroke-width:2px;
    classDef router fill:#281d48,stroke:#a78bfa,color:#f1edff,stroke-width:2px;
    classDef warn fill:#302410,stroke:#fbbf24,color:#fff3cf,stroke-width:2px;
    classDef gpu fill:#173f59,stroke:#22d3ee,color:#ecfeff,stroke-width:2px;
    class VCI input;
    class NC,NT router;
    class PRV,J warn;
    class N gpu;</div>
  <figcaption>From a pool in the tenant config to a node that joined. Each arrow is a controller doing its part.</figcaption>
</figure>

<h2 id="bare-metal-metal3-and-ironic-do-the-heavy-lifting">Bare metal: Metal3 and Ironic do the heavy lifting</h2>
<p>For VMs, a provider can just call an API: &ldquo;clone this template&rdquo;. Bare metal is harder. Nobody can SSH into a server that has no OS yet.</p>
<p>vCluster&rsquo;s bare-metal story (the docs call it <strong>vMetal</strong>) uses two well-known open source projects:</p>
<ul>
<li><strong>Metal3</strong> brings bare metal into Kubernetes. Each server is a <strong>BareMetalHost</strong> object with its BMC address, credentials and boot MAC.</li>
<li><strong>Ironic</strong> does the physical work. It talks to the server&rsquo;s BMC (the out-of-band management controller) over Redfish or IPMI, powers it on, and boots it over the network.</li>
</ul>
<p>A BareMetalHost moves through a small set of states:</p>
<div class="table-scroll">
  <table>
    <thead>
        <tr>
            <th scope="col">State</th>
            <th scope="col">Meaning</th>
        </tr>
    </thead>
    <tbody>
        <tr>
            <td><code>registering</code> / <code>inspecting</code></td>
            <td>Ironic boots the server once to read its CPUs, RAM, disks and NICs</td>
        </tr>
        <tr>
            <td><code>available</code></td>
            <td>Inspected and free. This is the pool</td>
        </tr>
        <tr>
            <td><code>provisioning</code></td>
            <td>The OS image is being written to disk</td>
        </tr>
        <tr>
            <td><code>provisioned</code></td>
            <td>Running the new OS, handed to a tenant</td>
        </tr>
        <tr>
            <td><code>deprovisioning</code></td>
            <td>Released by a tenant, being cleaned before it goes back to <code>available</code></td>
        </tr>
    </tbody>
  </table>
</div>
<figure class="mermaid-figure">
  <div class="mermaid">flowchart TD
    R["registering"] s1@--> I["inspecting<br/>read CPUs, RAM, disks"]
    I s2@--> A["available<br/>the free pool"]
    A s3@--> P["provisioning<br/>write the OS image"]
    P s4@--> D["provisioned<br/>node in a tenant"]
    D s5@--> X["deprovisioning<br/>power off, clean disks"]
    X s6@--> A
    s1@{ animate: true }
    s2@{ animate: true }
    s3@{ animate: true }
    s4@{ animate: true }
    s5@{ animate: true }
    s6@{ animate: true }
    classDef idle fill:#101e27,stroke:#324950,color:#8aa0a8;
    classDef input fill:#13264a,stroke:#38bdf8,color:#e6f6ff,stroke-width:2px;
    classDef warn fill:#302410,stroke:#fbbf24,color:#fff3cf,stroke-width:2px;
    classDef gpu fill:#173f59,stroke:#22d3ee,color:#ecfeff,stroke-width:2px;
    class R,I idle;
    class A input;
    class P,X warn;
    class D gpu;</div>
  <figcaption>The BareMetalHost life cycle. The loop at the bottom is what lets one server serve many tenants.</figcaption>
</figure>

<h3 id="one-claim-step-by-step">One claim, step by step</h3>
<figure class="mermaid-figure">
  <div class="mermaid">sequenceDiagram
    participant P as Platform
    participant M as Metal3
    participant I as Ironic
    participant B as Server BMC
    participant S as Server
    participant T as Tenant API
    P->>M: claim needs a gpu-server
    M->>M: pick an available BareMetalHost
    M->>I: provision host with OS image
    I->>B: Redfish: set PXE boot, power on
    S->>I: PXE boot, DHCP, load agent ramdisk
    I->>S: agent writes the OS image to disk
    I->>B: Redfish: boot from disk, power cycle
    S->>S: first boot, cloud-init runs join script
    S->>T: kubelet joins with a token
    T-->>P: node Ready, claim Available</div>
  <figcaption>The whole bare-metal path. Only the BMC conversation is out-of-band; everything else happens on the provisioning network.</figcaption>
</figure>

<div class="table-scroll">
  <table>
    <thead>
        <tr>
            <th scope="col">Step</th>
            <th scope="col">What happens</th>
            <th scope="col">Where the time goes</th>
        </tr>
    </thead>
    <tbody>
        <tr>
            <td>1</td>
            <td>The Platform creates a NodeClaim and Metal3 picks a free host that matches the node type</td>
            <td>Seconds</td>
        </tr>
        <tr>
            <td>2</td>
            <td>Ironic tells the BMC to boot from the network and power on</td>
            <td>Server POST: seconds in a VM, minutes on a real GPU server</td>
        </tr>
        <tr>
            <td>3</td>
            <td>The server PXE boots the Ironic Python Agent ramdisk</td>
            <td>Seconds</td>
        </tr>
        <tr>
            <td>4</td>
            <td>The agent downloads the OS image and writes it to disk</td>
            <td>The longest part</td>
        </tr>
        <tr>
            <td>5</td>
            <td>The server reboots into the new OS; cloud-init runs the vCluster join script</td>
            <td>A minute or so</td>
        </tr>
        <tr>
            <td>6</td>
            <td>The kubelet registers with the tenant API and the node goes Ready</td>
            <td>Seconds</td>
        </tr>
    </tbody>
  </table>
</div>
<p>In my lab, with VMs pretending to be servers, a full provision took about 4 minutes, and almost all of it was step 4.</p>
<h3 id="what-the-network-has-to-allow">What the network has to allow</h3>
<p>Bare metal brings networking requirements that cloud users never see:</p>
<ul>
<li><strong>BMC network.</strong> Ironic must reach every server&rsquo;s BMC (Redfish over HTTPS).</li>
<li><strong>Provisioning network.</strong> PXE uses DHCP, which is a broadcast. The DHCP and boot server must sit on the servers&rsquo; network, or you need a DHCP relay.</li>
<li><strong>A path to the tenant API.</strong> A private node joins its tenant&rsquo;s API server. That API lives in a pod on the Control Plane Cluster, so it needs an address the servers can reach. In my lab, kube-vip gave each tenant API a floating IP on the server network. In production you would use a load balancer VIP or MetalLB.</li>
</ul>
<figure class="mermaid-figure">
  <div class="mermaid">flowchart TD
    IR["Ironic"] e1@-->|"BMC network: Redfish"| BMC["Server BMC"]
    BMC e2@-->|"power on, boot from network"| NIC["Server NIC"]
    DH["DHCP and PXE server"] e3@-->|"provisioning network"| NIC
    NIC e4@-->|"kubelet joins the VIP"| TA["Tenant API pod<br/>with a floating IP"]
    e1@{ animate: true }
    e2@{ animate: true }
    e3@{ animate: true }
    e4@{ animate: true }
    classDef warn fill:#302410,stroke:#fbbf24,color:#fff3cf,stroke-width:2px;
    classDef cp fill:#281d48,stroke:#a78bfa,color:#f1edff,stroke-width:2px;
    classDef gpu fill:#173f59,stroke:#22d3ee,color:#ecfeff,stroke-width:2px;
    class IR,DH warn;
    class TA cp;
    class BMC,NIC gpu;</div>
  <figcaption>Three networks, three jobs. The tenant API VIP is the piece people forget.</figcaption>
</figure>

<h2 id="giving-a-server-back">Giving a server back</h2>
<p>When a pool shrinks or a tenant is deleted, its claims are released. Metal3 deprovisions the host: powers it off, optionally cleans the disks, and returns it to <code>available</code>. The next claim can take it, for any tenant.</p>
<p>That is how one server pool serves many tenants over time. The GPU that tenant A stopped using on Monday can be tenant B&rsquo;s on Tuesday.</p>
<figure class="mermaid-figure">
  <div class="mermaid">flowchart TD
    subgraph OUT["Tenant A shrinks"]
        direction LR
        A1["Pool quantity<br/>1 to 0"] a1@--> A2["Claim released"]
        A2 a2@--> A3["Server<br/>deprovisioned"]
    end
    subgraph IN["Tenant B grows"]
        direction LR
        B1["New claim<br/>Pending"] b1@--> B2["Same server<br/>re-imaged"]
        B2 b2@--> B3["Node Ready<br/>in tenant B"]
    end
    OUT ~~~ IN
    a1@{ animate: true }
    a2@{ animate: true }
    b1@{ animate: true }
    b2@{ animate: true }
    classDef input fill:#13264a,stroke:#38bdf8,color:#e6f6ff,stroke-width:2px;
    classDef warn fill:#302410,stroke:#fbbf24,color:#fff3cf,stroke-width:2px;
    classDef gpu fill:#173f59,stroke:#22d3ee,color:#ecfeff,stroke-width:2px;
    class A1,B1 input;
    class A2,A3,B2 warn;
    class B3 gpu;</div>
  <figcaption>Moving a server from tenant A to tenant B. The server is re-imaged, not live-migrated: running jobs do not move.</figcaption>
</figure>

<p>Two things I would always add for real tenants:</p>
<ol>
<li><strong>Cleaning that is tested, not assumed.</strong> Ironic supports metadata cleaning and full disk erase. Local NVMe needs a secure erase. GPU memory is cleared by a reset. Test that a new tenant cannot read anything the previous one left.</li>
<li><strong>A health check before handover.</strong> For GPU servers that means something like <code>dcgmi diag</code> and an NCCL test, so a broken server goes to a repair pool instead of to a customer.</li>
</ol>
<h2 id="why-whole-servers-for-gpus">Why whole servers for GPUs</h2>
<p>For big GPU servers, private nodes match how the hardware is built.</p>
<p>Inside an 8-GPU HGX server, all GPUs talk to each other over NVLink through NVSwitch. That is a private, very fast domain. If the whole server belongs to one tenant, that domain is isolated without any extra work.</p>
<div class="table-scroll">
  <table>
    <thead>
        <tr>
            <th scope="col">Tenant needs</th>
            <th scope="col">Unit to hand out</th>
            <th scope="col">How</th>
        </tr>
    </thead>
    <tbody>
        <tr>
            <td>Training on full servers</td>
            <td>Whole server</td>
            <td>Private node, one server per claim</td>
        </tr>
        <tr>
            <td>Part of one GPU</td>
            <td>A slice of a GPU</td>
            <td>MIG on A100/H100, inside one tenant&rsquo;s server</td>
        </tr>
        <tr>
            <td>Many servers for one job</td>
            <td>A group of servers on the same fabric</td>
            <td>Pools per rack or rail group, plus a gang scheduler</td>
        </tr>
    </tbody>
  </table>
</div>
<p>The rule of thumb: <strong>the unit you hand out follows the fastest link</strong>.</p>
<figure class="mermaid-figure">
  <div class="mermaid">flowchart TD
    subgraph S1["Server 1: tenant A"]
        direction LR
        G1["GPU 0 to 3"] n1@-->|"NVLink"| G2["GPU 4 to 7"]
    end
    subgraph S2["Server 2: tenant B"]
        direction LR
        G3["GPU 0 to 3"] n2@-->|"NVLink"| G4["GPU 4 to 7"]
    end
    S1 ~~~ S2
    S2 ~~~ F["Fabric between servers<br/>InfiniBand or RoCE<br/>partitioned per tenant"]
    n1@{ animate: true }
    n2@{ animate: true }
    classDef gpu fill:#173f59,stroke:#22d3ee,color:#ecfeff,stroke-width:2px;
    classDef cp fill:#281d48,stroke:#a78bfa,color:#f1edff,stroke-width:2px;
    classDef warn fill:#302410,stroke:#fbbf24,color:#fff3cf,stroke-width:2px;
    class G1,G2 gpu;
    class G3,G4 cp;
    class F warn;</div>
  <figcaption>NVLink stays inside the server, so the server is the natural tenant boundary. The fabric between servers needs its own partitioning.</figcaption>
</figure>

<p>With private nodes, the NVIDIA GPU Operator runs inside each tenant cluster, not on the host. One tenant changing its MIG layout or upgrading its operator cannot affect another tenant.</p>
<p>What vCluster does not do: configure your switches. Per-tenant VLANs, InfiniBand partitions or RoCE settings come from the network side. vCluster integrates with Netris for Ethernet fabrics; InfiniBand is usually managed by NVIDIA UFM.</p>
<h2 id="what-still-runs-in-one-place">What still runs in one place</h2>
<p>Private nodes remove sharing at the node level. Some things are still shared, and that is fine as long as you plan for them:</p>
<div class="table-scroll">
  <table>
    <thead>
        <tr>
            <th scope="col">Shared piece</th>
            <th scope="col">Risk</th>
            <th scope="col">What to do</th>
        </tr>
    </thead>
    <tbody>
        <tr>
            <td>Control Plane Cluster</td>
            <td>If it is down, tenant APIs are down. Running pods keep running, but nothing heals or scales</td>
            <td>Run it HA (three machines across racks) and test restores</td>
        </tr>
        <tr>
            <td>Platform admin access</td>
            <td>Admins can reach every tenant</td>
            <td>Tight RBAC, SSO and audit logs</td>
        </tr>
        <tr>
            <td>Physical network</td>
            <td>A flat network leaks between tenants</td>
            <td>Per-tenant VLANs or partitions</td>
        </tr>
    </tbody>
  </table>
</div>
<h2 id="cheat-sheet">Cheat sheet</h2>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Private nodes        = machines that join a tenant&#39;s own API; own kubelet, CNI and CSI
</span></span><span class="line"><span class="cl">Auto Nodes           = Platform feature that orders, installs and joins private nodes
</span></span><span class="line"><span class="cl">Pool                 = what nodes a tenant needs; static (fixed count) or dynamic (on demand)
</span></span><span class="line"><span class="cl">NodeClaim            = one request for one node: Pending, then Available
</span></span><span class="line"><span class="cl">NodeProvider         = where machines come from: metal3, OpenTofu templates, KubeVirt
</span></span><span class="line"><span class="cl">NodeType             = what can be ordered; selects hosts by label, knows how many exist
</span></span><span class="line"><span class="cl">NodeEnvironment      = the network settings nodes get from a provider
</span></span><span class="line"><span class="cl">OSImage              = the disk image written to a bare-metal server
</span></span><span class="line"><span class="cl">vMetal               = vCluster&#39;s bare-metal provisioning, built on Metal3 and Ironic
</span></span><span class="line"><span class="cl">BareMetalHost        = Metal3&#39;s record of one server: BMC, credentials, boot MAC, state
</span></span><span class="line"><span class="cl">Ironic               = powers servers via the BMC, PXE boots them, writes the image
</span></span><span class="line"><span class="cl">Ironic Python Agent  = the ramdisk that writes the OS image to the server&#39;s disk
</span></span><span class="line"><span class="cl">Deprovisioning       = a released server is cleaned and goes back to available
</span></span></code></pre></div><p>Previous: <a href="https://vishctl.dev/posts/what-is-vcluster-virtual-kubernetes-clusters/">what vCluster is</a>. Next: <a href="https://vishctl.dev/posts/vcluster-bare-metal-gpu-tenant-metal3/">the lab, with real output</a>. Related: <a href="https://vishctl.dev/posts/kubernetes-gpu-sharing-time-slicing-mps-mig/">GPU sharing with time-slicing, MPS and MIG</a>.</p>
]]></content:encoded></item></channel></rss>