Skip to content

Build a K8s-Ready Node Template

The Kubernetes Engine's Auto-provision node source (see Deploy a Kubernetes Cluster) clones a VM template for every control-plane/worker node it creates, then drives the whole install over SSH — join the cluster, mount storage, everything. That SSH step needs three things from the template that a bare OS install doesn't give you for free: it has to be dnf-based, it has to boot with cloud-init enabled, and it should carry the guest agent. Skip any of these and cluster creation stalls or times out with no obvious cause from the Kubernetes tab itself — the fix belongs on the template, not in a support ticket.

This is the general golden-image flow, K8s-flavored

Everything here follows Create a Golden Image Template. This page exists because K8s nodes have stricter requirements than a general-purpose template — read that page first if you haven't built a template before.

Why each requirement matters

Requirement Why K8s needs it
dnf-based (Rocky/RHEL/Alma family) LINSTOR CSI's satellite bootstrap (core/linstor_node_setup.py) runs command -v dnf as a hard precondition and installs kmod-drbd9x via ELRepo — it has no apt/apk path. Even if you're not using LINSTOR storage, pick a dnf-based template so the option stays open.
cloud-init installed and enabled The engine's per-node seed ISO (NoCloud datasource) is how the node gets its hostname, the k8s SSH user, and your master's SSH key. No cloud-init on the template = no SSH user ever gets created = every node create times out waiting for SSH or waiting for a guest IP, even though the VM itself boots fine.
qemu-guest-agent installed and enabled MFCloud's guest-IP detection prefers the agent channel over ARP/DHCP-lease scanning. Without it, IP detection is slower and less reliable, especially on VLANs with no libvirt-managed DHCP.

Step-by-step

1. Deploy a plain base VM

Inventory → Deploy VM, any Rocky/RHEL/Alma ISO or existing minimal image, on the host you want the template to live on (templates are node-local — see Create a Golden Image Template). Give it enough disk for the largest node you'll ever clone from it — clones can only grow the disk, not shrink it below the template's own size.

2. SSH in and install the three packages

dnf install -y cloud-init cloud-utils-growpart qemu-guest-agent

cloud-utils-growpart isn't strictly required but every K8s node profile lets the operator pick disk_gb at deploy time — without growpart the extra space just sits unpartitioned.

3. Enable the boot-time services

systemctl enable cloud-init-local cloud-init cloud-config cloud-final qemu-guest-agent

All four cloud-init units matter — cloud-init-local and cloud-init handle network/hostname/datasource detection early in boot, cloud-config and cloud-final run the users:/runcmd: sections of the seed data (this is where the k8s user and its SSH key actually get created). Enabling only cloud-init.service is a common half-fix that leaves user/SSH-key injection broken while hostname/network still appear to work.

4. Confirm nothing blocks NoCloud detection

grep -i datasource_list /etc/cloud/cloud.cfg /etc/cloud/cloud.cfg.d/*.cfg 2>/dev/null

Stock cloud-init packages ship with no override here, which is what you want — ds-identify runs its default detection at boot and finds the cidata- labeled seed ISO MFCloud attaches automatically. If a previous provisioning tool (a cloud vendor's own image customization, for example) left a datasource_list override pointing at something else, remove it or the NoCloud seed will never be read.

5. Check for a hardcoded network profile

cat /etc/NetworkManager/system-connections/*.nmconnection

You want method=auto (DHCP) under [ipv4], not a static address — a static IP baked into the template means every clone fights over the same address. MFCloud doesn't set a static network-config in the seed data, so DHCP is what every node profile expects.

6. Generalize and clean up

cloud-init clean --logs --seed
rm -f /etc/NetworkManager/system-connections/*.nmconnection
rm -f /etc/ssh/ssh_host_*
truncate -s 0 /etc/machine-id
rm -f /root/.ssh/authorized_keys /root/.bash_history
history -c
  • cloud-init clean clears cached instance/datasource state so the template looks like a never-booted instance — without this, cloud-init may see a stale "already ran" marker and skip re-running on the clone.
  • Clearing SSH host keys and machine-id stops every clone from sharing the same host identity (host keys regenerate automatically on next boot on Rocky/RHEL; machine-id regenerates via systemd).
  • Remove any SSH key you added to root/authorized_keys for provisioning access before converting — whatever's left in the template's authorized_keys gets cloned onto every future node.

7. Shut down and convert

shutdown -h now

Then VM action menu → Convert to Template (Golden Image) — same flow as any other template. See Create a Golden Image Template for the storage-tier placement rules and the Ceph/LINSTOR-backed-VM caveat (network disks can't be converted in place).

Verifying it worked

Deploy one throwaway node from the new template (either a plain VM deploy, or a single-control-plane K8s cluster with no storage/networking extras) and check:

  • GET /api/kubernetes/{id} — or the Kubernetes tab — reaches ProvisioningActive without stalling at "waiting for guest IP" or "waiting for SSH."
  • The node's hostname is the VM name, not localhost — the single clearest sign cloud-init actually ran. A virsh screenshot of the console showing localhost login: on a VM that's been up for several minutes means cloud-init never fired; go back to steps 2–4.

See also