Spur-Managed Kubernetes (k0s)#
Spur can provision and own a Kubernetes cluster across its own nodes using
k0s. This is the inverse of Kubernetes Deployment (running
Spur inside an existing cluster): here spur k8s up builds the cluster and a
spurd-owned systemd unit keeps k0s running on each node.
One command assigns roles, mesh IPs and pod CIDRs, installs a pinned k0s on every node, brings up the control plane, mints join tokens, joins the workers, and (optionally) programs a WireGuard-native CNI — all driven by the existing spurctld/spurd control plane.
Note
This feature is gated on [cluster].enabled. With it off (the default),
spurd never touches systemd or k0s, and nothing here applies.
Overview#
spurctld (on the head node) owns the cluster lifecycle: role/IP/CIDR assignment and phase, replicated through Raft so it survives a restart.
spurd (on every node) owns that node’s k0s systemd unit: it installs k0s if missing, writes the config/join-token, and reconciles the unit. k0s is never a Spur job, so it survives a spurd restart (spurd re-adopts it on startup).
Roles are assigned automatically: a single node becomes an all-in-one
controller --single; with two or more nodes the control-plane node becomes acontrollerand the rest becomeworkers.
For Administrators#
Prerequisites#
A working native-host Spur deployment —
spurctldon the head node andspurdon every node, all registered. See Manual Deployment (systemd).spurdmust run as root (it manages systemd units).Outbound HTTPS to
github.comon each node for the k0s download (or pre-stage the binary — see Installing k0s).For the mesh-native CNI only: a WireGuard mesh (
spur0) already established across the nodes viaspur net join/spur net mesh.
Configure the cluster#
Add a [cluster] section to spur.conf on every node (spurd reads
k0s_version / cni from it; spurctld reads the CIDRs and control-plane
choice):
[network]
wg_cidr = "10.44.0.0/16" # mesh CIDR; node mesh IPs are allocated from here
[cluster]
enabled = true
control_plane_node = "head-node" # hostname of the k0s control plane (else: first node)
pod_cidr = "10.42.0.0/16" # per-node /24s are carved from this
service_cidr = "10.43.0.0/16"
cni = "kuberouter" # "kuberouter" (default) or "calico" (see Networking)
cni_mtu = 1450 # Calico MTU; headroom for WireGuard overhead on the mesh
storage_provisioner = "local-path" # default StorageClass for PVCs; or "none"
# local_path_dir = "/mnt/scratch/local-path" # point local-path at a big disk (default /var/lib)
k0s_version = "v1.36.2+k0s.0" # pinned; or "latest"
Installing k0s#
spur k8s up auto-installs the pinned k0s on any node that is missing it, so
usually you do nothing. To pre-stage it (e.g. for an air-gapped or
network-restricted node), run on that node as root:
sudo spur k8s install-k0s # the pinned version -> /usr/local/bin/k0s
sudo spur k8s install-k0s --version latest # newest k0s release
sudo spur k8s install-k0s --version v1.36.2+k0s.0 --path /opt/bin/k0s --force
The binary is downloaded from the official k0s GitHub release and SHA-256 verified before it is installed.
Bring the cluster up#
From the head node (or any host that can reach spurctld):
spur k8s up --controller http://localhost:6817
This is idempotent and asynchronous — spurctld reconciles toward Ready:
control plane first, then workers join with freshly minted tokens. A fresh
cluster typically reaches Ready in one to two minutes (mostly the k0s
download + control-plane bootstrap). spur k8s up requires cluster admin
(root, or an accounting admin — see Access control).
Scope the cluster to a subset of nodes#
By default spur k8s up enrolls every registered node. To build a smaller
cluster, scope it with --nodes (a hostlist), --partition, and/or
--selector (repeatable key=value, ANDed) — the three are unioned
together and resolved once, at up-time:
spur k8s up --nodes "gpu[01-08]"
spur k8s up --partition batch
spur k8s up --selector zone=z1 --selector gpu=mi300
A scoped cluster’s membership is frozen until you grow or shrink it with
add-nodes / remove-nodes (see Adding and removing worker nodes).
High-availability control plane#
By default the cluster runs a single control-plane node (control_plane_node
in spur.conf, or the first node). For HA, request 3 or 5 control planes:
spur k8s up --replicas 3 # lowest-named 3 nodes
spur k8s up --control-plane-nodes cp-1,cp-2,cp-3 # explicit set; overrides --replicas
--control-plane-nodes (or a single --control-plane-node) always wins
over --replicas. The first control-plane node is the etcd bootstrap.
Important
Every control-plane node — whether picked automatically, named with
--control-plane-node, or listed in --control-plane-nodes — must be
part of the cluster’s node scope. If you also pass --nodes,
--partition, or --selector, make sure the control-plane node(s) are
included in that selection; otherwise spur k8s up is rejected with
control-plane node <name> is not a registered node (explicit list) or
control-plane node <name> is not among the selected cluster nodes
(auto-picked). Leave --nodes/--partition/--selector unset to
scope the cluster to the whole inventory, which trivially satisfies this —
any registered node is then a valid control-plane choice.
On a multi-control-plane mesh cluster there is no floating VIP — a VRRP address
cannot follow WireGuard cryptokey routing — so spur k8s up enables k0s
node-local load balancing (nodeLocalLoadBalancing with EnvoyProxy) on
every control plane. This gives each node a local Envoy that round-robins across
all controllers, so konnectivity has a cluster-wide :8132 endpoint instead of
pinning every agent to a single controller. It is on automatically for any
multi-control-plane count (today 3 or 5) and off for a single control plane (no
balancing needed).
Note
k0s does not hot-reload node-local load balancing, and Spur only writes a
controller’s k0s.yaml while bringing that controller up — an
already-active control plane is never rewritten in place. A fresh
spur k8s up --replicas 3 is therefore unaffected (controllers render the
setting before any worker joins), but an existing HA cluster cannot pick
up the fix by restarting workers alone: reprovision the control plane with
spur k8s down --reset followed by spur k8s up.
Check status#
spur k8s status
# phase: ready
# control-plane: head-node
# head-node controller active enabled=true
# node-2 worker active enabled=true
# ...
phase moves down -> provisioning -> ready. Per-node component_state is
queried live from each agent.
Networking / CNI#
kuberouter (default) — the built-in k0s CNI. The control-plane API is advertised
on the node’s primary interface and workers join over it. No mesh required.
pod_cidr/service_cidr are applied the same as under calico. SPUR runs
kube-router with overlay-type=full, so every pod packet travels in the IPIP tunnel
with the node address on the outside. kube-router’s own default (subnet) sends pod
packets unencapsulated between nodes of the same subnet, and a cloud NIC (OCI VNIC, AWS
ENI, …) drops a packet whose source is a pod address unless source/destination checking
is disabled on the interface. Full overlay works on any underlay, at the cost of the IPIP
header between same-subnet nodes. The tunnel is IP protocol 4 (IPIP) between nodes, and
kube-router peers over BGP (TCP 179); open both if a host or cloud firewall (OCI security
list, AWS security group, …) default-denies them.
Note
Clusters built by a SPUR release before 0.12 with cni = "kuberouter" run on the k0s
default CIDRs (10.244.0.0/16 pods, 10.96.0.0/12 services) in subnet overlay
mode. The k0s config is rendered once per control plane when it first starts, so those
nodes keep running as they are. A control plane added or re-joined later receives the
[cluster] CIDRs, and k0s requires the same CIDRs on every control plane. To move such
a cluster to the pinned CIDRs and full overlay, reprovision it (spur k8s down --reset
then spur k8s up), or set pod_cidr = "10.244.0.0/16" and
service_cidr = "10.96.0.0/12" before adding control planes.
calico (cni = "calico") — with the WireGuard mesh enabled
(network.wg_enabled = true), spur k8s up generates a k0s config that
advertises the API on the control-plane’s mesh IP and runs Calico in
bird (BGP, no overlay) mode, setting each worker’s kubelet --node-ip to
its mesh IP so pod traffic routes over the WireGuard mesh. This requires the
spur0 mesh to be up first (spur net join); membership reconciliation
only maintains the peer set + AllowedIPs, it does not create the tunnel.
The controller continuously reconciles the full-mesh membership to every node (pruning peers for departed nodes), so a reboot, a WireGuard restart, or a control-plane failover self-heals.
With the mesh disabled (network.wg_enabled = false), Calico instead
advertises the API on each node’s real underlay address and runs in vxlan
mode — Calico’s own overlay, requiring no mesh. Standard VXLAN (UDP 4789)
between nodes; open that port if a host firewall default-denies it.
Setting or behavior |
|
|
|---|---|---|
|
Passed to k0s |
Passed to k0s |
|
Ignored |
Emitted in the Calico configuration |
API address and SANs |
k0s default interface selection |
Configured after an advertised address is available |
Kubelet |
k0s default interface selection |
Pinned to the advertised address |
|
No CNI configuration effect |
Selects |
Note
Like nodeLocalLoadBalancing above, the generated network config is
rendered once per control plane as it is brought up and does not
hot-reload. This applies to either CNI: an existing cluster picks up
neither a changed pod_cidr/service_cidr nor a
network.wg_enabled toggle (which would otherwise switch Calico between
bird and vxlan) on upgrade alone. Reprovision with
spur k8s down --reset followed by spur k8s up to pick up the change.
Storage#
k0s bundles no storage, so a plain cluster has no StorageClass and any
PersistentVolumeClaim stays Pending. By default Spur ships the
local-path-provisioner
(storage_provisioner = "local-path") as the cluster’s default
StorageClass — RWO, node-local — so PVC workloads bind out of the box. The
control-plane agent writes the manifest into the k0s manifest-deployer directory,
which k0s applies automatically (no in-cluster client).
Local-path stores volumes under local_path_dir (default
/var/lib/local-path-provisioner, on the root filesystem). If PVCs will hold
much data — model caches, datasets — point it at a large scratch disk:
[cluster]
local_path_dir = "/mnt/scratch/local-path"
Set storage_provisioner = "none" to bring your own storage.
Adding and removing worker nodes#
How a worker joins depends on how the cluster was scoped at spur k8s up:
Whole-inventory cluster (spur k8s up with no scope flags) — every
registered node is a member. Start spurd on the new node (registered to the
same controller) and, if using the mesh CNI, join it to the mesh first. On the
next reconcile tick it is assigned a role + mesh IP + pod CIDR and joins
automatically. No further command is needed.
Scoped cluster (spur k8s up --nodes/--partition/--selector) — membership
is frozen at up-time, so a newly-registered node stays outside the cluster until
you add it explicitly. Grow the cluster online, no down/--reset needed:
spur k8s add-nodes --nodes gpu[09-12] # or --partition <p> / --selector k=v
spur k8s status # the new workers converge to active
Added nodes are workers; they are unioned into the member set and enrolled by the reconcile loop exactly as an in-scope node is. Adding a node already in the cluster is a no-op.
Remove a worker gracefully — cordon, drain (evict pods, PDB-aware), then stop and
k0s reset the node:
spur k8s remove-nodes --nodes gpu12
spur k8s remove-nodes --nodes gpu12 --drain-timeout 180
spur k8s remove-nodes --nodes gpu12 --force # proceed past running jobs / a blocked drain
Warning
remove-nodes is destructive: it runs k0s reset on the departing
node, wiping its k0s state (etcd/kine data, pulled images, containerd state,
certs). Re-adding the same node later re-downloads k0s and re-seeds state
(~262 MB plus an image re-pull). For a temporary “stop scheduling here”, use
spur node drain (the SPUR-scheduling layer) instead — it does not touch k0s.
--force only skips the running-jobs guard (the jobs keep running) and lets
the drain proceed past a PodDisruptionBudget or its timeout — it can evict
pods that would otherwise block. remove-nodes refuses a control-plane node,
the last remaining worker (would empty the member set), and any node not in a
scoped cluster.
spur k8s remove-nodes is distinct from spur node remove: the former is the
graceful, k0s-aware path for shrinking a running cluster (drain + reset); the
latter is inventory-only (it does not drain pods or stop k0s) and is for
decommissioning a host from SPUR entirely. Use k8s remove-nodes first, then
node remove if the host is also leaving SPUR.
Tear down#
spur k8s down # stop + disable the k0s unit on every node
spur k8s down --reset # also `k0s reset` (destructive: wipes cluster state)
--reset removes /var/lib/k0s on every node, along with the spurd-owned
systemd unit and cached join token, but leaves the WireGuard mesh (spur0)
intact. Purging the join token matters: a token minted against the torn-down
cluster’s CA would fail the next join with a kubernetes-ca verification
error. To switch the CNI, tear down with --reset and bring the cluster back
up with the new cni setting.
For Users#
Users do not need Spur access — they interact with the cluster through the standard Kubernetes tooling.
Get a kubeconfig#
spur k8s kubeconfig > mine.conf
export KUBECONFIG=$PWD/mine.conf
kubectl get nodes
A bare spur k8s kubeconfig mints a ServiceAccount kubeconfig scoped to
your own SPUR user/account namespace — no admin access required. It prints
to stdout so it can be redirected to a file.
Access control#
Command |
Who |
Result |
|---|---|---|
|
anyone |
Own scoped kubeconfig (namespace = own SPUR account). |
|
cluster admin |
|
|
cluster admin |
The k0s cluster-admin kubeconfig (full access). Mutually exclusive with |
“Cluster admin” is root, or a user the accounting layer marks as an admin
association (see Accounting, Accounts, Users, and QOS). --admin additionally
requires [cluster] allow_admin_kubeconfig = true in spur.conf — it is
false by default, since the admin check on caller is not yet backed by
authenticated identity. With it off, get the cluster-admin kubeconfig directly
on the control-plane node instead: k0s kubeconfig admin.
Run a workload#
Use kubectl normally:
kubectl get nodes -o wide
kubectl create deployment web --image=nginx
kubectl run -it --rm probe --image=busybox -- sh
Request GPUs#
GPU worker nodes advertise amd.com/gpu (containerd injects the devices from a
CDI spec spurd writes on join). Request them in a pod spec:
apiVersion: v1
kind: Pod
metadata:
name: gpu-probe
spec:
restartPolicy: Never
containers:
- name: rocm
image: rocm/dev-ubuntu-24.04:latest
command: ["rocm-smi"]
resources:
limits:
amd.com/gpu: 1
Command reference#
Command |
Purpose |
|---|---|
|
Provision + start the cluster (idempotent). Admin only. Control-plane
node(s) must lie within the |
|
Add worker nodes to a running scoped cluster (no down/reset). Admin only. |
|
Drain + |
|
Cluster phase + per-node component state. |
|
Print a kubeconfig (redirect to a file). Bare = own scope; |
|
Stop the cluster; |
|
Install the k0s binary on this node (local; run as root). |
Configuration reference ([cluster])#
Key |
Default |
Meaning |
|---|---|---|
|
|
Enable Spur-managed k0s. When off, spurd never touches systemd/k0s. |
|
|
Kubernetes distribution SPUR manages. Only |
|
(first node) |
Hostname of the k0s control plane. |
|
|
HA control-plane count (1, 3, or 5). Overridden per-invocation by |
|
|
Pod network; per-node /24s are carved from it. |
|
|
Service network. |
|
|
|
|
|
Calico MTU emitted into the generated k0s config (leaves WireGuard headroom); ignored by |
|
|
Storage Spur ships as the default StorageClass ( |
|
|
On-node directory local-path stores PVs in; point at a big disk for data-heavy PVCs. |
|
pinned |
k0s release to install/run (a tag or |
|
|
Install path for the k0s binary. |
|
|
Seconds a node may stay non- |
|
|
Allow |