Build a Kubernetes 1.36 cluster the hard way — with kubeadm, on Debian 13 VMs running
on VirtualBox. vagrant up prepares the machines, one script chains the kubeadm
commands, and a full application layer (Cilium, Envoy Gateway, Longhorn, Vault,
PostgreSQL…) comes on top. Single control plane or HA with 3 CPs behind a keepalived VIP.
This lab is deliberately not a turnkey installer. Every VM is an ordinary Debian box with
SSH and apt; every step the scripts take is a kubeadm command you could type yourself, and
§5 shows exactly which ones. What the repo adds is the boring,
error-prone part: the VIP that must exist beforekubeadm init, the node-ip every Vagrant
lab gets wrong, the containerd 2.x config, the certificate SANs you cannot add afterwards.
The whole path, in four steps:
gitclone--recurse-submoduleshttps://github.com/OPS-NC/Vagrant-kubeadm.git
cdVagrant-kubeadm
cplab.env.examplelab.env# pick the topology
vagrantup# creates and PREPARES the VMs (no cluster yet)
./kubeadm/cluster-up.sh# kubeadm init + join + kubeconfig
./_k8s/platform-up.sh# CNI, Envoy Gateway, metrics-server, wildcard TLS
⚠️ --recurse-submodules is not optional._k8s/ is a git submodule pointing at
k8s-playground; a plain git clone leaves the
directory empty and ./_k8s/platform-up.sh returns No such file or directory. On a clone
already made: git submodule update --init --recursive (§1).
ℹ️ There is a twin repo, Vagrant-Talos. Same
lab, same IP plan, and literally the same application layer — both repos mount the same
k8s-playground submodule under _k8s/. What is opposite is the OS and the operating model:
Talos is immutable, has no SSH and no package manager, and is driven entirely through an API
from the host. Here you get a normal distribution and you drive kubeadm yourself: more
moving parts, and that is the point — this repo is where you see what an installer usually
hides.
That is the complete list. There is no talosctl here, and no cluster-specific binary to
install on your machine: kubeadm, kubelet, kubectl and containerd live inside the
VMs, installed by kubeadm/provision.sh during vagrant up. The
Debian box (bento/debian-13) is downloaded by Vagrant on first use; no plugin is required.
kubeadm/cluster-up.sh checks for exactly two of these up front (vagrant, kubectl) and
refuses to start without them.
The application layer is a submodule, and it needs one command of its own._k8s/ is not a
directory of this repo: it is a pinned checkout of
OPS-NC/k8s-playground, the repository shared with
the Talos twin lab. Cloning without --recurse-submodules leaves it empty:
gitsubmoduleupdate--init--recursive# fills _k8s/ on an existing clone
gitsubmoduleupdate--remote_k8s# move it to the latest upstream commit
⚠️ git pull does NOT update the submodule. It only moves this repo, and the _k8s/
checkout stays on the commit that was pinned before. After any pull, run
git submodule update --init --recursive — otherwise you run the documented commands against
an older application layer. git status showing modified: _k8s (new commits) means the
checkout no longer matches the pin, nothing more.
💡 kubectl on the host should be within one minor of the cluster (1.35 → 1.37 for a
1.36 cluster). If yours is older, you can always fall back to the in-VM one:
vagrant ssh k8s-cp1 -c 'kubectl get nodes -o wide' — node-init.sh installs a kubeconfig
for both root and vagrant.
⚠️ VirtualBox and KVM cannot share VT-x. If the KVM module is loaded, vagrant up dies
on VERR_VMX_IN_VMX_ROOT_MODE. Unload it (sudo modprobe -r kvm_intel kvm /
kvm_amd) before starting — details in TROUBLESHOOTING.md.
🗺️ 2. IP plan (host-only network 192.168.56.0/24)#
Item
IP
Host (host-only gateway)
192.168.56.1
VirtualBox DHCP server
192.168.56.2
Kubernetes API VIP (keepalived)
192.168.56.5
k8s-cp1 / cp2 / cp3
192.168.56.10 / .20 / .30
k8s-w1 / w2 / w3 …
192.168.56.101 / .102 / .103 …
VirtualBox default host-only DHCP (reserved)
192.168.56.100
LoadBalancer range (Cilium L2 announcement)
192.168.56.200 – .230
Envoy Gateway IP (wildcard DNS target)
192.168.56.200 — the 1st of the range
Pod network 10.244.0.0/16, Service network 10.96.0.0/12.
The node IPs are static, assigned by the Vagrantfile (private_network), not by DHCP.
The Vagrantfile refuses to start if a computed node IP lands on .1, .2, .100 or on the
VIP, and refuses duplicates — those produce labs that break in extremely obscure ways.
Every VM has 2 NICs: NIC1 = VirtualBox NAT (Internet, same 10.0.2.15 on every VM)
and NIC2 = host-only192.168.56.x (cluster, API, etcd, pod traffic). The default route
goes through the NAT — that is deliberate, it is how the VMs reach apt and the registries.
What must be host-only is the node's identity, never its default route: see the node-ip
discussion in §9.
ℹ️ The host-only interface name is never hard-coded. Debian 13 normally names it
enp0s8, but some box builds still expose eth1. provision.sh finds it by looking for
the interface that carries the node's IP (falling back to the route for
192.168.56.0/24), writes it to /etc/kubeadm-lab/node.env, and cluster-up.sh copies it
into _out/cluster.env as HOSTONLY_IF. keepalived binds VRRP to it, and the _k8s/
scripts hand it to Cilium for L2 announcement — a wrong name there means a VIP that never
comes up and Services that never answer.
ℹ️ Name resolution does not depend on DNS or on boot order: the Vagrantfile pushes an
identical /etc/hosts block to every node (all node names, plus kubernetes-api for the
VIP), and provision.sh deletes Debian's 127.0.1.1 <hostname> line — left in place, the
kubelet resolves its own name to loopback and the node registers as unreachable.
The topology lives in lab.env, the single source read by the Vagrantfile, by
kubeadm/cluster-up.shand by the _k8s/*-up.sh scripts. Start from the versioned
template (lab.env.example; lab.env itself is gitignored):
cplab.env.examplelab.env
Format is strict: one KEY=value per line, no spaces around =. A real environment variable
always wins, which makes one-off overrides possible: WORKERS=5 vagrant up.
Variable
Template default
Purpose
K8S_VERSION
1.36.3
version installed (kubelet/kubeadm/kubectl, pinned then apt-mark hold)
K8S_APT_MINOR
v1.36
pkgs.k8s.io repository minor — must match K8S_VERSION
Vagrant box — the lab is written and tested for Debian 13
NODE_PREFIX
k8s
VM/node names: k8s-cp1, k8s-w1…
CLUSTER_NAME
kubeadm-lab
kubeadm clusterName + kubeconfig context
NETWORK
192.168.56
host-only network (first 3 octets)
VIP
192.168.56.5
API VIP = controlPlaneEndpoint, carried by keepalived
CP_IP_START / CP_IP_STEP
10 / 10
→ .10, .20, .30
WK_IP_START / WK_IP_STEP
101 / 1
→ .101, .102, .103
POD_CIDR
10.244.0.0/16
kubeadm networking.podSubnet — the CNI must announce the same one
SERVICE_CIDR
10.96.0.0/12
kubeadm networking.serviceSubnet
LB_POOL_START / LB_POOL_END
192.168.56.200 / .230
LoadBalancer IP range; the 1st one is the Gateway's, the wildcard DNS target
VRRP_ROUTER_ID
51
keepalived VRRP group (1-255); change it only to coexist with another keepalived lab
CNI
cilium
cilium, calico, flannel or none (see §10)
CILIUM_VERSION
1.20.0
Cilium chart version (ignored unless CNI=cilium)
KUBE_PROXY_REPLACEMENT
true
eBPF replacement of kube-proxy — requires CNI=cilium
UNTAINT_CP
auto
remove the control-plane taint: auto (only if WORKERS=0), true, false
LAB_DOMAIN
kubeadm.lab.example.io
UI domain (*.<domain>: wildcard TLS + HTTPRoute)
SELF_SIGNED
true
TLS mode: true = wildcard signed by a local CA (openssl, no domain, no token), false = cert-manager + Let's Encrypt
LAB_DNS_ZONE
(empty → last 2 labels)
DNS zone of the ACME DNS-01 solver — SELF_SIGNED=false only
LAB_ACME_EMAIL
(empty → admin@<zone>)
Let's Encrypt account (expiry notices) — SELF_SIGNED=false only
LAB_ACME_ISSUER
staging
ACME issuer: staging (untrusted, huge quota) or prod (trusted, 5 certs/week) — SELF_SIGNED=false only
CLOUDFLARE_API_TOKEN
(empty)
cert-manager DNS-01 — SELF_SIGNED=false only, and never in the versioned template
Read by cluster-up.sh but absent from the template (both have a default): OUT (_out, the
directory rendered configs go to) and WAIT_API (600, seconds to wait for the apiserver on
the VIP).
💰 What each topology costs. Default (1 CP + 2 workers): 7 GB of RAM, 6 vCPU.
Full HA (CONTROL_PLANES=3, WORKERS=3): 3 × 3072 + 3 × 2048 = 15.4 GB, 12 vCPU. VM
disks are linked clones, so the box is stored roughly once.
⚠️ An even number of control planes is refused, by the Vagrantfileand by
cluster-up.sh. etcd holds quorum at (n/2)+1: with 2 members, losing a single node
freezes the API — twice the cost of a single CP, and strictly less availability. Stay on
1, 3 or 5. (CI has a test asserting this guard actually fires.)
⚠️ Do not lower CP_MEM below 3072. The kubeadm preflight demands 2 vCPU and
~1700 MiB; 2048 passes but leaves ~350 MiB of headroom for a stacked etcd, which collapses
as soon as the _k8s/ addons pile up. _k8s/observability/ explicitly wants 4096.
⚠️ K8S_VERSION and K8S_APT_MINOR must agree. The pkgs.k8s.io repositories are
per-minor: a v1.36 repo cannot serve a 1.35.x package, and apt then fails with a
version-not-found error that says nothing about the mismatch. That pair is what you bump for
an upgrade — see kubeadm/UPGRADE.md.
💡 Create lab.env anyway. Without it the Vagrantfile and cluster-up.sh each fall
back to their internal defaults. They are kept aligned on purpose — make validate-defaults
enforces it key by key — but they remain two separate copies: the day they drift, you get
1.36 packages configured for 1.35. One file, one truth.
vagrant up bootstraps nothing. It creates the VMs and runs
kubeadm/provision.sh in each one, which lays down, in order:
/etc/hosts · swap off + kernel modules + sysctl · base packages (conntrack, socat,
ethtool, open-iscsi, nfs-common…) · containerd with SystemdCgroup = true ·
kubelet/kubeadm/kubectl pinned and held · pre-pulled images · and, on control planes
only, keepalived carrying the VIP. At the end of vagrant up each VM is ready to receive
a kubeadm init or join, and nothing more.
cluster-up.sh then prints five steps:
Step
What happens
[1/5]
renders the kubeadm configs into _out/on the host (certsans.txt, kubeadm-init.yaml) from kubeadm/templates/
[2/5]
kubeadm init on the 1st CP via vagrant ssh → node-init.sh; copies admin.conf to ./kubeconfig; waits for https://<VIP>:6443/readyz
[3/5]
joins the secondary control planes, one at a time (etcd accepts one membership change at a time)
[4/5]
joins the workers
[5/5]
untaints per UNTAINT_CP, labels the workers node-role.kubernetes.io/worker=, writes _out/cluster.env (including the detected HOSTONLY_IF)
Before touching anything it validates the config (CNI value, the
KUBE_PROXY_REPLACEMENT/CNI pair, odd number of CPs) and checks that all the expected
VMs are running — diagnosing that up front costs a second, diagnosing it later means a
vagrant ssh timing out in the middle of a half-finished join.
The kubeconfig needs no editing: its server: is already the VIP, reachable from the host over
the host-only network.
⚠️ The nodes will be NotReady, and that is NORMAL. This is the number-one question of
anyone discovering kubeadm: kubeadm never installs a CNI. Until a pod network is in
place, the kubelet reports NetworkReady=false / cni plugin not initialized, CoreDNS
stays Pending, and the nodes stay NotReady. The next command is what fixes it:
./_k8s/platform-up.sh (§6).
💡 cluster-up.sh is idempotent and safe to re-run: node-init.sh refuses to re-run
kubeadm init if /etc/kubernetes/admin.conf exists, node-join.sh skips any node that
already has /etc/kubernetes/kubelet.conf. Re-running it is also how you grow the lab
(§7.1).
ℹ️ Join credentials are regenerated on every run — the bootstrap token created by
init expires after 24 h and the certificate key after 2 h. A cluster-up.sh run three
days after the initial init therefore just works, instead of failing with an opaque
discovery error.
For another topology, edit lab.env — or override on the spot, passing the variable to both
commands, since each re-reads its own environment:
This is what the lab is for. Everything cluster-up.sh does is a kubeadm command you can
type yourself; the scripts exist so you do not have to retype them at every rebuild, not to
hide them. Below is the same path, by hand, on a lab that has been vagrant up-ed.
vagrantsshk8s-cp1
sudo-i
kubeadmversion-oshort# v1.36.3, held by apt-mark
containerd--version# 2.x when CONTAINERD_SOURCE=docker
crictlps# talks to /run/containerd/containerd.sock
ip-4addrshow|grep192.168.56.5# the VIP is ALREADY there, before any init
systemctlstatuskeepalived
cat/etc/kubeadm-lab/node.env# NODE_IP, HOSTONLY_IF, VIP…
ℹ️ The VIP being up beforekubeadm init is the whole reason keepalived is used here
rather than kube-vip — the reasoning is in §9.
Note the two addresses, which are not the same thing: --apiserver-advertise-address is
the real IP this apiserver listens on, --control-plane-endpoint is the shared VIP that
gets baked into the certificates and every kubeconfig.
⚠️ This flag form cannot set node-ip, and that is why the repo uses --config. With
flags alone the kubelet picks the default-route NIC — the NAT, 10.0.2.15, identical on
every VM. All nodes then register with the same address: kubectl get nodes -o wide looks
plausible, while logs, kubectl exec, probes and inter-node traffic all go to the wrong
place. kubeadm init/join has no equivalent flag; the setting only exists as
nodeRegistration.kubeletExtraArgs.
⚠️ --upload-certs is what makes HA possible later. It stores the cluster CAs in the
kubeadm-certs Secret, encrypted with the certificate key. Without it, a second control
plane can only join after you copy /etc/kubernetes/pki by hand.
⚠️ certSANs cannot be added afterwards — not without regenerating the API certificate.
That is why the lab lists 5 control-plane IPs up front, including nodes that do not
exist yet: growing the cluster later never requires touching the PKI.
# on the control plane — prints a ready-to-paste command, token valid 24 h
sudokubeadmtokencreate--print-join-command
# kubeadm join 192.168.56.5:6443 --token <t> --discovery-token-ca-cert-hash sha256:<h>
# on the worker
sudokubeadmjoin192.168.56.5:6443--token<t>--discovery-token-ca-cert-hashsha256:<h>
⚠️ That printed line is exactly what this lab does not use. It cannot carry
node-ip (see 5.2), so a node joined this way registers with 10.0.2.15. The repo renders
a JoinConfiguration file instead — kubeadm-join-worker.yaml.tpl
— and runs kubeadm join --config /vagrant/_out/join-<node>.yaml. If you join by hand and
then see every node with the same INTERNAL-IP, this is why.
Two extra ingredients: the certificate key, which decrypts the kubeadm-certs Secret, and
--control-plane.
# on cp1 — re-encrypts the Secret and prints a NEW key on the last line
sudokubeadminitphaseupload-certs--upload-certs
# one-liner producing the complete join command
sudokubeadmtokencreate--print-join-command\--certificate-key"$(sudokubeadminitphaseupload-certs--upload-certs|tail-n1)"
# on cp2
sudokubeadmjoin192.168.56.5:6443--token<t>--discovery-token-ca-cert-hashsha256:<h>\--control-plane--certificate-key<key>
⚠️ The certificate key expires after 2 hours, the token after 24. Both are cheap to
regenerate (the two commands above); a stale one gives a decryption error that does not
mention expiry at all.
⚠️ --config and --certificate-key are mutually exclusive. With a config file the key
goes under controlPlane.certificateKey — not at the document root, unlike
InitConfiguration. See kubeadm-join-cp.yaml.tpl.
⚠️ Join control planes one at a time. Each join adds an etcd member, and etcd accepts a
single membership change at a time. Run two in parallel and the second fails with a quorum
error that is hard to read and easy to misdiagnose.
The repo grew out of a hand-written walkthrough
(README-installkubeadm.md, kept for archaeology). Four things in
notes of that vintage are no longer right:
Old habit
What to do now
GPG key from the v1.35 repo, sources.list pointing at v1.34
key and repo must be the same minor, and both must match K8S_VERSION — that mismatch is why the pair lives in lab.env
A bare cluster does nothing useful — and here it is not even Ready. Everything else —
Cilium, Envoy Gateway, cert-manager, metrics-server, Longhorn, Vault, CloudNativePG,
Prometheus/Loki, Kyverno, Trivy, MinIO, Argo CD… — comes from a separate repository,
k8s-playground, mounted here as the _k8s/
submodule.
That layer used to be duplicated in this repo and in the Talos twin. It is now maintained
once, in a repo that mounts itself under _k8s/ in either lab and works out what it is
looking at on its own — nothing to declare, nothing to export. Its documentation is published
on its own: https://ops-nc.github.io/k8s-playground/.
Useful variants — no distribution to pass anywhere:
Command
What it does
./_k8s/install.sh list
the full catalogue of addons
./_k8s/install.sh all
platform + every addon, in dependency order
./_k8s/longhorn/longhorn-up.sh
one addon on its own
How it finds its bearings, in two steps, both automatic:
The lab is the directory that contains_k8s/ — the one carrying the Vagrantfile,
and therefore lab.env, _out/ and kubeconfig. That is exactly this repo.
The distribution is read off the lab's contents: a kubeadm/cluster-up.sh next to the
Vagrantfile means the kubeadm lab (a talos/cluster-up.sh means the Talos twin). It is
decided from the clone alone, before any vagrant up.
An explicit argument (./_k8s/install.sh kubeadm platform, --distro=kubeadm, or the
K8S_DISTRO environment variable) is still accepted and wins over detection — useful when you drive a
cluster from somewhere else, needless here.
platform-up.sh installs the CNI first; the nodes go Ready within a minute or two of that
step. Full dependency chain, addon list and per-addon pitfalls:
https://ops-nc.github.io/k8s-playground/.
💡 LAB_DIR is the escape hatch, not a step. Point it at the lab directory when the
layout is unusual — _k8s/ checked out somewhere else, a lab driven from another path:
LAB_DIR=/path/to/Vagrant-kubeadm ./_k8s/platform-up.sh. LAB_ENV does the same for a
single lab.env. In the normal submodule layout neither is needed.
⚠️ If _k8s/ is empty, the submodule was never initialised:
git submodule update --init --recursive (§1). To pull a newer application layer:
git submodule update --remote _k8s.
⚠️ This layer assumes CNI=cilium (the default). It needs a LoadBalancer Service that
actually gets an IP, which on a host-only network only Cilium's L2/ARP announcement
provides. With calico, flannel or none the Gateway stays at EXTERNAL-IP <pending>
and no UI is reachable. Details in §10.
a) Make *.<LAB_DOMAIN> resolve to the Gateway IP. Every lab UI is served through one
entry point — Envoy's LoadBalancer Service, which takes the first IP of LB_POOL_START,
192.168.56.200 by default. With SELF_SIGNED=true (the default) an /etc/hosts line on the
host is enough, and no public DNS record is needed:
kubectl-nenvoy-gateway-systemgetsvc-owide|grepLoadBalancer# the actual IP# /etc/hosts# 192.168.56.200 argo.kubeadm.lab.example.io grafana.kubeadm.lab.example.io
With SELF_SIGNED=false you need a real wildcard A record *.<LAB_DOMAIN> → the Gateway
IP, DNS-only (a CDN proxy cannot reach a private 192.168.56.x origin).
b) Choose the TLS mode, with SELF_SIGNED in lab.env. true: platform-up.sh builds a
local CA and a wildcard certificate with openssl, installs no cert-manager, needs no token
and no public domain — the browser warns until you import _out/self-signed/ca.crt. false:
cert-manager + Let's Encrypt over ACME DNS-01, which requires a real domain,
CLOUDFLARE_API_TOKEN, and respect for the 5 certificates per week production quota
(LAB_ACME_ISSUER=staging is the default for exactly that reason). Both paths fill the same
wildcard-<LAB_DOMAIN with dashes>-tls Secret, so no addon has to know which one you picked.
vagrantstatus# VM state
vagranthalt# power off (the cluster comes back on the next `up`)
vagrantup# power back on
vagrantdestroy-f# delete every VM
After a destroy, also clear the host-side state before rebuilding:
rm-rf_outkubeconfig
Keeping the repo current is two commands, not one — git pull moves this repo only, and
leaves _k8s/ on the previously pinned commit:
gitpull# this repo (Vagrantfile, kubeadm/, docs)
gitsubmoduleupdate--init--recursive# _k8s/ back onto the commit this repo pins
gitsubmoduleupdate--remote_k8s# or: jump to the latest k8s-playground
7.2 Undoing the cluster without destroying the VMs#
./kubeadm/cluster-reset.sh# asks for confirmation
./kubeadm/cluster-reset.sh--yes# unattended
It runs kubeadm reset on every node (workers first, so they deregister while the API
still answers), then removes _out/ and kubeconfig on the host. The VMs keep running with
their packages, containerd and keepalived intact — a rebuild is then ./kubeadm/cluster-up.sh
alone, minutes instead of a full vagrant up.
Prefer it to vagrant destroy when you want to replay a failed bootstrap, or to change
POD_CIDR, SERVICE_CIDR, the CNI or the VIP — all four are frozen at kubeadm init and
cannot be changed on a live cluster.
⚠️ Destructive: etcd, the certificates and every workload are lost, including anything
in a PersistentVolume backed by a node's disk.
ℹ️ Why a dedicated reset and not just kubeadm reset: kubeadm reset deliberately
leaves behind what it did not create — CNI interfaces (cilium_host, lxc*, flannel.1,
cali*), Cilium's pinned eBPF programs under /sys/fs/bpf (which survive the DaemonSet
and keep intercepting traffic for a cluster that no longer exists), and kube-proxy's
iptables/ipvs rules. node-reset.sh cleans all of it; without that
pass, the next init inherits a ghost datapath and the pod network misbehaves in ways no
log explains.
Symptoms and fixes have their own page so this one stays about installing:
TROUBLESHOOTING.md. The ones you are most likely to hit:
All nodes NotReady, CoreDNS Pending → no CNI yet. Expected until
./_k8s/platform-up.sh — kubeadm never installs one.
./_k8s/platform-up.sh: No such file or directory → the _k8s/ submodule was never
initialised: git submodule update --init --recursive.
The _k8s/ scripts run on the wrong domain, or find no kubeconfig → the lab was not
located; run them from a _k8s/ that really sits inside the lab, or set LAB_DIR.
cluster-up.sh stops on "the apiserver does not answer on the VIP" → either keepalived
is not carrying the VIP (vagrant ssh k8s-cp1 -c "ip -4 addr show | grep 192.168.56.5",
sudo systemctl status keepalived), or the apiserver itself is not starting
(sudo crictl ps -a | grep apiserver, sudo journalctl -u kubelet -n 50).
Every node shows the same INTERNAL-IP10.0.2.15 → a node joined without node-ip,
i.e. with the printed kubeadm join line instead of the rendered JoinConfiguration
(§5.4).
vagrant up dies on VERR_VMX_IN_VMX_ROOT_MODE → the KVM module holds VT-x; unload it.
Gateway stuck at EXTERNAL-IP <pending> → CNI is not cilium, so nothing announces
LoadBalancer IPs on the host-only network (§10).
Addon-specific problems are documented with the addons themselves, in the ⚠️ pitfalls and 🚑
troubleshooting sections of the k8s-playground pages:
https://ops-nc.github.io/k8s-playground/.
9.1 The VIP is carried by keepalived, not by kube-vip#
This is the most structural decision in the repo.
controlPlaneEndpoint points at the VIP, and it is frozen into the certificates and into
every kubeconfig at kubeadm init time. The VIP must therefore exist before the init.
kube-vip, the usual answer in kubeadm HA guides, runs as a static pod and elects its leader
through the Kubernetes API — that is, through the very VIP it is supposed to carry.
Chicken and egg. The documented way out is to point it at
--k8sConfigPath /etc/kubernetes/super-admin.conf, itself fragile since Kubernetes 1.29 moved
admin.conf out of the system:masters group (kube-vip#684,
still open).
keepalived has none of that: it is a plain VRRP daemon, it knows nothing about Kubernetes, it
brings the VIP up at VM boot, and the circular dependency disappears. Its configuration is
written by provision.sh:
Unicast VRRP (unicast_src_ip + unicast_peer), not multicast: on a VirtualBox
host-only switch multicast is the first thing to behave strangely, and we know every
control-plane IP anyway.
Priorities cp1 = 100, cp2 = 90, cp3 = 80.
vrrp_script chk_apiserver polls https://127.0.0.1:6443/livez/ping every 3 s with
weight -30: a control plane whose apiserver is dead drops to 70 and falls behind a healthy
cp2 at 90, which takes the VIP over.
/livez/ping is readable anonymously thanks to the system:public-info-viewer
ClusterRoleBinding kubeadm creates — no credential to distribute to a health script.
While no cluster exists, the check fails on every CP: they each lose 30 points, the
relative order is preserved, and the VIP is carried anyway. Which is exactly what
kubeadm init needs.
No authentication block: VRRPv2 sends its password in clear text and buys nothing. The
trust boundary here is the host-only network. To coexist with another keepalived lab on the
same network, change VRRP_ROUTER_ID.
ℹ️ kube-vip remains a perfectly good option once the cluster is running (--services
mode, for LoadBalancer Services). It is the bootstrap role that does not work out here.
9.2 The VIP is used even with a single control plane#
Because controlPlaneEndpoint is frozen at init. Pointing it at the VIP from the very first
run makes going from 1 to 3 control planes a plain join; pointing it at cp1's real IP would
mean regenerating every certificate and redistributing every kubeconfig.
9.3 containerd 2.x from the Docker repo, not the Debian package#
Debian 13 ships containerd 1.7.24. Only the 2.x branch implements the CRI RuntimeConfig
method, which kubeadm uses to read the runtime's cgroup driver. On 1.36 its absence is a
preflight warning; the fallback disappears in 1.37, and the backport to the 1.7 branch
was refused (containerd#11346,
closed without merge). 1.7 is a dead end. CONTAINERD_SOURCE=debian stays available for an
offline lab.
SystemdCgroup = true matters more than the kubelet's cgroupDriver field: Debian 13 is
cgroup v2 with systemd as the manager, and leaving containerd on cgroupfs puts two managers
on the same hierarchy — nodes then get unstable under load.
⚠️ The 1.7 → 2.x migration trap: the pause image key changed name and location.
Config v2 has sandbox_image = "..." under [plugins."io.containerd.grpc.v1.cri"]; config
v3 has sandbox = '...' under [plugins.'io.containerd.cri.v1.images'.pinned_images]. A
config copied over as-is silently loses the setting. provision.sh regenerates the file
from containerd config default on every run and patches whichever key is present.
ℹ️ The pause tag itself is never hard-coded: it comes from
kubeadm config images list. A mismatch between containerd's pause and kubeadm's is
invisible while you are online (it just re-pulls) and fatal offline.
The number-one trap of any Vagrant-based Kubernetes lab, described in
§5.2: the NAT NIC is 10.0.2.15 on every VM.
Because neither kubeadm init nor kubeadm join has a flag for it, the lab drives both from
InitConfiguration/JoinConfiguration files carrying
nodeRegistration.kubeletExtraArgs: [{name: node-ip, value: <host-only IP>}].
Current and default since Kubernetes 1.31; v1beta3 is deprecated.
⚠️ The breaking change to know: extraArgs and kubeletExtraArgs are no longer
maps but lists of {name, value} — so a flag can be repeated. Any file written
before 1.31 is invalid as-is, and the error kubeadm returns does not point at the shape.
make validate-kubeadm catches exactly this, in CI, without a cluster.
9.6 The /vagrant synced folder is a mechanism, not a convenience#
cluster-up.sh does nothing inside the VMs itself. It renders the kubeadm configurations
into _out/ on the host, then calls node-init.sh and
node-join.sh through vagrant ssh; the VMs read those files at
/vagrant/_out/. No scp, no secret passed on a command line (where it would land in shell
history and process listings), and the logic stays in versioned files you can read in a diff
instead of in an escaping nightmare inside vagrant ssh -c.
⚠️ _out/join.env holds the bootstrap token and the certificate key. The directory is
gitignored, but it is readable from every VM through the synced folder. It is a lab: fine
here, not a pattern to carry into production.
9.7 What kubeadm does not do, and cluster-up.sh does#
Worker roles: kubeadm sets no role label, so kubectl get nodes shows <none> under
ROLES and node-role.kubernetes.io/worker selectors match nothing. cluster-up.sh applies
the label.
Control-plane taint: UNTAINT_CP=auto removes it only when WORKERS=0 — otherwise
nothing could be scheduled anywhere. That is what makes a 1-VM lab usable.
Control-plane metrics: controllerManager and scheduler get bind-address: 0.0.0.0;
by default they listen on loopback only and Prometheus shows two DOWN targets with no
explanation. Acceptable because the host-only network is isolated.
Pre-pulled images: done during vagrant up, in parallel across VMs, so kubeadm init
downloads nothing — the single biggest source of bootstrap timeouts. Workers only pull
pause and kube-proxy, saving ~500 MiB each.
Swap: turned off and masked (including systemd swap units, which /etc/fstab does not
describe). NodeSwap is GA since 1.34 but failSwapOn still defaults to true.
kubeadm installs no CNI, ever. Unlike the Talos twin repo — where flannel can be laid down
by the OS at bootstrap — here the pod network is always installed afterwards, by
./_k8s/platform-up.sh. CNI in lab.env is read by cluster-up.sh (for the
kube-proxy decision and _out/cluster.env) and by the platform step (which chart to install).
In practice: keep cilium. It is the only value that makes the lab work end to end,
because it is the only one that gives Services an EXTERNAL-IP on a host-only network — and
therefore the only one that gets you the HTTPS UIs. calico is there to compare CNIs and work
on NetworkPolicy; flannel for a deliberately bare cluster.
⚠️ KUBE_PROXY_REPLACEMENT=true requires CNI=cilium, and cluster-up.sh refuses to
start with any other combination. With --skip-phases=addon/kube-proxy and no replacement,
no ClusterIP answers at all — not even CoreDNS reaching the API. The error message
offers the two ways out: CNI=cilium, or KUBE_PROXY_REPLACEMENT=false.
ℹ️ Why Cilium needs k8sServiceHost/k8sServicePort when kube-proxy is gone: nothing
provisions the apiserver's ClusterIP, so the agent cannot bootstrap through
kubernetes.default. The lab points it at the VIP — which also means the agents survive
the loss of any single control plane.
ℹ️ The lab uses the --skip-phases=addon/kube-proxy flag rather than v1beta4's declarative
proxy.disabled field: identical result, but the flag is battle-tested across versions and
is the one Cilium's own documentation uses.
⚠️ POD_CIDR must be the CIDR the CNI really announces. Cilium in cluster-pool mode
defaults to 10.0.0.0/8, unrelated to what kubeadm was told; k8s-playground's
cilium/cilium-up.sh passes POD_CIDR back to it explicitly. Two divergent values give you a broken pod network
that looks configured.
⚠️ Switching CNI on an existing cluster is not supported../kubeadm/cluster-reset.sh
(or vagrant destroy) first — two CNIs fight over the pod network, and the leftover
datapath is exactly what node-reset.sh exists to clean.
Everything can be validated without booting a cluster:
makevalidate# shell + YAML + Vagrantfile + kubeadm templates + doc links
makedocs# regenerates docs/index.html from every README (EN + FR)
makehelp# lists the targets
Target
What it covers
validate-shell
bash -n on every *.sh tracked by git
validate-yaml
parses every *.yaml / *.yml tracked by git (PyYAML, fetched by uv)
validate-vagrant
vagrant validate; locally it also checks the provider config
validate-defaults
asserts that the fallback defaults in the Vagrantfile and in cluster-up.sh still match lab.env.example, key by key
validate-kubeadm
renders the 3 templates with dummy values in a throwaway dir, parses them, then runs kubeadm config validateif kubeadm is in your PATH
validate-docs
builds the docs into a throwaway file and fails on any dead *.md link or unknown cross-page anchor
validate-kubeadm is the one with the most value: it is what catches a real v1beta4 schema
error — a extraArgs left in v1beta3 map form — instead of discovering it ten minutes into a
vagrant up. On CI, where kubeadm is installed for the job, the schema check always runs.
On every pull request the ci workflow re-runs shell, defaults, YAML, kubeadm and
Vagrantfile validation by calling the very same make targets, so a check cannot pass in CI
and fail on your machine. It also asserts that the guard actually fires: CONTROL_PLANES=2 vagrant validatemust be rejected. vagrant validate runs there with --ignore-provider, since a
runner has no VirtualBox; validate-docs is covered by the docs workflow.
ℹ️ Nothing in the Makefile touches a running cluster, and nothing regenerates secrets.
make validate is safe on a lab that is up.
This project is licensed under the Apache License 2.0 — see
LICENSE.
In short: use it, modify it, redistribute it, including commercially, as long as you keep the
copyright notice and state your changes. It comes with no warranty: this is a lab, do not
run it in production.
The license covers what this repo actually contains — the Vagrantfile, the kubeadm/
scripts, the templates, the manifests and the documentation. It does not extend to the
third-party components those scripts download (Kubernetes, containerd, keepalived, Cilium,
Envoy Gateway, Longhorn, Vault…), each of which keeps its own license, nor to the _k8s/
submodule: k8s-playground is a separate repository
and carries its own LICENSE.
LISEZ-MOI.md
🏠 ☸️Vagrant-KubeADM
Monte un cluster Kubernetes 1.36 à la main — avec kubeadm, sur Debian 13 dans des VM
VirtualBox. vagrant up prépare les machines, un script enchaîne les commandes
kubeadm, et une couche applicative complète (Cilium, Envoy Gateway, Longhorn, Vault,
PostgreSQL…) vient par-dessus. Control plane unique ou HA à 3 CP derrière une VIP
keepalived.
Ce lab n'est volontairement pas un installeur clé en main. Chaque VM est une Debian
ordinaire, avec SSH et apt ; chaque étape des scripts est une commande kubeadm que tu
pourrais taper toi-même, et §5 montre exactement lesquelles. Ce que le dépôt apporte, c'est la
partie ingrate : la VIP qui doit exister avant le kubeadm init, le node-ip que tout lab
Vagrant rate, la configuration containerd 2.x, les SAN de certificat qu'on ne peut plus
ajouter après coup.
Le parcours complet, en quatre étapes :
gitclone--recurse-submoduleshttps://github.com/OPS-NC/Vagrant-kubeadm.git
cdVagrant-kubeadm
cplab.env.examplelab.env# choisir la topologie
vagrantup# crée et PRÉPARE les VM (aucun cluster monté)
./kubeadm/cluster-up.sh# kubeadm init + join + kubeconfig
./_k8s/platform-up.sh# CNI, Envoy Gateway, metrics-server, TLS wildcard
⚠️ --recurse-submodules n'est pas optionnel._k8s/ est un sous-module git qui
pointe sur k8s-playground ; un git clone simple
laisse le dossier vide et ./_k8s/platform-up.sh répond No such file or directory. Sur un
clone déjà fait : git submodule update --init --recursive (§1).
ℹ️ Il existe un dépôt jumeau, Vagrant-Talos.
Même lab, même plan d'adressage, et littéralement la même couche applicative — les deux
dépôts montent le même sous-module k8s-playground sous _k8s/. Ce qui est opposé, c'est l'OS
et le modèle de pilotage : Talos est immuable, sans SSH ni gestionnaire de paquets, et se
pilote entièrement par API depuis l'hôte. Ici on assume une distribution classique et on
conduit kubeadm soi-même : plus de pièces mobiles, et c'est justement l'objectif — ce dépôt
sert à voir ce qu'un installeur cache d'habitude.
C'est la liste complète. Il n'y a pas de talosctl ici, et aucun binaire spécifique au
cluster à installer sur ton poste : kubeadm, kubelet, kubectl et containerd vivent
dans les VM, posés par kubeadm/provision.sh pendant le
vagrant up. La box Debian (bento/debian-13) est téléchargée par Vagrant à la première
utilisation ; aucun plugin n'est nécessaire.
kubeadm/cluster-up.sh n'en vérifie que deux au démarrage (vagrant, kubectl) et refuse de
partir sans eux.
La couche applicative est un sous-module, et elle réclame une commande à elle._k8s/
n'est pas un dossier de ce dépôt : c'est une copie épinglée de
OPS-NC/k8s-playground, le dépôt partagé avec le lab
jumeau Talos. Un clone sans --recurse-submodules le laisse vide :
gitsubmoduleupdate--init--recursive# remplit _k8s/ sur un clone existant
gitsubmoduleupdate--remote_k8s# l'amène au dernier commit amont
⚠️ Un git pull ne met PAS le sous-module à jour. Il ne déplace que ce dépôt, et la
copie _k8s/ reste sur le commit épinglé auparavant. Après chaque pull, lancer
git submodule update --init --recursive — sinon on exécute les commandes documentées contre
une couche applicative plus ancienne. Un git status qui affiche
modified: _k8s (new commits) signifie simplement que la copie ne correspond plus à
l'épinglage.
💡 Garde kubectl à une mineure près du cluster (1.35 → 1.37 pour un cluster 1.36). Si
le tien est plus ancien, celui des VM reste disponible :
vagrant ssh k8s-cp1 -c 'kubectl get nodes -o wide' — node-init.sh installe un kubeconfig
pour root comme pour vagrant.
⚠️ VirtualBox et KVM ne peuvent pas partager VT-x. Si le module KVM est chargé,
vagrant up meurt sur VERR_VMX_IN_VMX_ROOT_MODE. Le décharger d'abord
(sudo modprobe -r kvm_intel kvm / kvm_amd) — détails dans
DEPANNAGE.md.
🗺️ 2. Plan d'adressage (réseau host-only 192.168.56.0/24)#
Élément
IP
Hôte (passerelle host-only)
192.168.56.1
Serveur DHCP VirtualBox
192.168.56.2
VIP de l'API Kubernetes (keepalived)
192.168.56.5
k8s-cp1 / cp2 / cp3
192.168.56.10 / .20 / .30
k8s-w1 / w2 / w3 …
192.168.56.101 / .102 / .103 …
DHCP host-only par défaut de VirtualBox (réservée)
192.168.56.100
Plage LoadBalancer (annonce L2 Cilium)
192.168.56.200 – .230
IP du Gateway Envoy (cible du DNS wildcard)
192.168.56.200 — la 1re de la plage
Réseau des pods 10.244.0.0/16, réseau des Services 10.96.0.0/12.
Les IP des nodes sont statiques, posées par le Vagrantfile (private_network) et non
par DHCP. Le Vagrantfile refuse de démarrer si une IP calculée tombe sur .1, .2, .100
ou sur la VIP, et refuse les doublons — ces cas produisent des labs cassés de façon très
obscure.
Chaque VM a 2 cartes : NIC1 = NAT VirtualBox (Internet, le même 10.0.2.15 sur
toutes les VM) et NIC2 = host-only192.168.56.x (cluster, API, etcd, trafic des pods).
La route par défaut passe par le NAT — c'est délibéré, c'est ainsi que les VM atteignent apt
et les registries. Ce qui doit être host-only, c'est l'identité du node, jamais sa route par
défaut : voir la discussion sur node-ip en §9.
ℹ️ Le nom de l'interface host-only n'est jamais codé en dur. Debian 13 la nomme en
principe enp0s8, mais certaines box exposent encore eth1. provision.sh la détecte en
cherchant l'interface qui porte l'IP du node (à défaut, la route vers 192.168.56.0/24),
l'écrit dans /etc/kubeadm-lab/node.env, et cluster-up.sh la recopie dans
_out/cluster.env sous HOSTONLY_IF. keepalived y attache VRRP, et les scripts _k8s/ la
passent à Cilium pour l'annonce L2 — un mauvais nom là, c'est une VIP qui ne monte jamais et
des Services qui ne répondent pas.
ℹ️ La résolution des noms ne dépend ni du DNS, ni de l'ordre de démarrage : le Vagrantfile
pousse un bloc /etc/hosts identique sur tous les nodes (tous les noms, plus
kubernetes-api pour la VIP), et provision.sh supprime la ligne 127.0.1.1 <hostname> de
Debian — laissée en place, le kubelet résout son propre nom en loopback et le node
s'enregistre injoignable.
La topologie vit dans lab.env, source unique lue par le Vagrantfile, par
kubeadm/cluster-up.shet par les scripts _k8s/*-up.sh. Partir du modèle versionné
(lab.env.example ; lab.env est gitignoré) :
cplab.env.examplelab.env
Le format est strict : une affectation KEY=value par ligne, sans espace autour du =. Une
vraie variable d'environnement reste prioritaire, ce qui permet les surcharges ponctuelles :
WORKERS=5 vagrant up.
Variable
Défaut du modèle
Rôle
K8S_VERSION
1.36.3
version installée (kubelet/kubeadm/kubectl, épinglés puis apt-mark hold)
K8S_APT_MINOR
v1.36
mineure du dépôt pkgs.k8s.io — doit correspondre à K8S_VERSION
ressources des control planes (jamais sous 3072 : etcd)
WK_MEM / WK_CPU
2048 / 2
ressources des workers
BOX
bento/debian-13
box Vagrant — le lab est écrit et testé pour Debian 13
NODE_PREFIX
k8s
noms des VM/nodes : k8s-cp1, k8s-w1…
CLUSTER_NAME
kubeadm-lab
clusterName kubeadm + contexte kubeconfig
NETWORK
192.168.56
réseau host-only (3 premiers octets)
VIP
192.168.56.5
VIP de l'API = controlPlaneEndpoint, portée par keepalived
CP_IP_START / CP_IP_STEP
10 / 10
→ .10, .20, .30
WK_IP_START / WK_IP_STEP
101 / 1
→ .101, .102, .103
POD_CIDR
10.244.0.0/16
networking.podSubnet kubeadm — le CNI doit annoncer le même
SERVICE_CIDR
10.96.0.0/12
networking.serviceSubnet kubeadm
LB_POOL_START / LB_POOL_END
192.168.56.200 / .230
plage des IP LoadBalancer ; la 1re est celle du Gateway, cible du DNS wildcard
VRRP_ROUTER_ID
51
groupe VRRP keepalived (1-255) ; à changer seulement pour cohabiter avec un autre lab keepalived
CNI
cilium
cilium, calico, flannel ou none (cf. §10)
CILIUM_VERSION
1.20.0
version du chart Cilium (ignorée si CNI != cilium)
KUBE_PROXY_REPLACEMENT
true
remplacement eBPF de kube-proxy — exige CNI=cilium
UNTAINT_CP
auto
retirer le taint control-plane : auto (seulement si WORKERS=0), true, false
LAB_DOMAIN
kubeadm.lab.example.io
domaine des UI (*.<domaine> : wildcard TLS + HTTPRoute)
SELF_SIGNED
true
mode TLS : true = wildcard signé par une AC locale (openssl, sans domaine ni token), false = cert-manager + Let's Encrypt
LAB_DNS_ZONE
(vide → 2 derniers labels)
zone DNS du solveur ACME DNS-01 — SELF_SIGNED=false seulement
LAB_ACME_EMAIL
(vide → admin@<zone>)
compte Let's Encrypt (avis d'expiration) — SELF_SIGNED=false seulement
LAB_ACME_ISSUER
staging
émetteur ACME : staging (non trusté, quota énorme) ou prod (trusté, 5 certs/semaine) — SELF_SIGNED=false seulement
CLOUDFLARE_API_TOKEN
(vide)
DNS-01 de cert-manager — SELF_SIGNED=false seulement, et jamais dans le modèle versionné
Lues par cluster-up.sh mais absentes du modèle (toutes deux ont un défaut) : OUT (_out,
le dossier où sont rendues les configurations) et WAIT_API (600, secondes d'attente de
l'apiserver sur la VIP).
💰 Ce que coûte chaque topologie. Par défaut (1 CP + 2 workers) : 7 Go de RAM,
6 vCPU. HA complète (CONTROL_PLANES=3, WORKERS=3) : 3 × 3072 + 3 × 2048 = 15,4 Go,
12 vCPU. Les disques des VM sont des clones liés : la box n'est stockée qu'une fois ou
presque.
⚠️ Un nombre PAIR de control planes est refusé, par le Vagrantfileet par
cluster-up.sh. etcd tient le quorum à (n/2)+1 : avec 2 membres, la perte d'un seul node
fige l'API — deux fois le coût d'un CP unique, pour strictement moins de disponibilité.
Reste sur 1, 3 ou 5. (La CI teste que ce garde-fou se déclenche vraiment.)
⚠️ Ne descends pas CP_MEM sous 3072. Le preflight kubeadm exige 2 vCPU et
~1700 Mio ; 2048 passe mais ne laisse que ~350 Mio de marge à un etcd empilé, qui s'effondre
dès qu'on empile les addons _k8s/. _k8s/observability/ réclame explicitement 4096.
⚠️ K8S_VERSION et K8S_APT_MINOR doivent concorder. Les dépôts pkgs.k8s.io sont
par mineure : un dépôt v1.36 ne peut pas servir un paquet 1.35.x, et apt échoue alors
sur une erreur de version introuvable qui ne dit rien du décalage. C'est ce couple qu'on
incrémente pour une montée de version — cf. kubeadm/MISE-A-JOUR.md.
💡 Crée quand même lab.env. Sans lui, le Vagrantfile et cluster-up.sh retombent
chacun sur leurs défauts internes. Ils sont maintenus alignés volontairement — make validate-defaults le vérifie clé par clé — mais ce sont deux copies distinctes : le jour où
elles divergent, tu obtiens des paquets 1.36 configurés pour 1.35. Un seul fichier, une
seule vérité.
vagrantup# 5-10 min : VM + paquets + containerd + kubeadm + keepalived
./kubeadm/cluster-up.sh# 3-5 min : init + jonctions + kubeconfig
vagrant up ne bootstrape rien. Il crée les VM et exécute dans chacune
kubeadm/provision.sh, qui pose, dans l'ordre : /etc/hosts · swap
coupé + modules noyau + sysctl · paquets de base (conntrack, socat, ethtool,
open-iscsi, nfs-common…) · containerd avec SystemdCgroup = true ·
kubelet/kubeadm/kubectl épinglés et gelés · images pré-tirées · et, sur les control
planes uniquement, keepalived qui porte la VIP. À la fin du vagrant up, chaque VM est
prête à recevoir un kubeadm init ou join, et rien de plus.
cluster-up.sh affiche ensuite cinq étapes :
Étape
Ce qui se passe
[1/5]
rend les configurations kubeadm dans _out/sur l'hôte (certsans.txt, kubeadm-init.yaml) depuis kubeadm/templates/
[2/5]
kubeadm init sur le 1er CP via vagrant ssh → node-init.sh ; copie admin.conf vers ./kubeconfig ; attend https://<VIP>:6443/readyz
[3/5]
joint les control planes secondaires, un à la fois (etcd n'accepte qu'un changement d'appartenance à la fois)
[4/5]
joint les workers
[5/5]
déteinte selon UNTAINT_CP, étiquette les workers node-role.kubernetes.io/worker=, écrit _out/cluster.env (dont le HOSTONLY_IF détecté)
Avant de toucher à quoi que ce soit, il valide la configuration (valeur de CNI, couple
KUBE_PROXY_REPLACEMENT/CNI, nombre impair de CP) et vérifie que toutes les VM
attendues sont running — diagnostiquer ça d'emblée coûte une seconde, le diagnostiquer plus
tard, c'est un vagrant ssh qui part en timeout au milieu d'un join à moitié fait.
Le kubeconfig n'a rien à retoucher : son server: est déjà la VIP, joignable depuis l'hôte
par le réseau host-only.
⚠️ Les nodes seront NotReady, et c'est NORMAL. C'est la question numéro un de qui
découvre kubeadm : kubeadm n'installe jamais de CNI. Tant qu'aucun réseau de pods n'est
posé, le kubelet remonte NetworkReady=false / cni plugin not initialized, CoreDNS reste
Pending, et les nodes restent NotReady. La commande suivante est celle qui corrige ça :
./_k8s/platform-up.sh (§6).
💡 cluster-up.sh est idempotent et se relance sans risque : node-init.sh refuse de
rejouer kubeadm init si /etc/kubernetes/admin.conf existe, node-join.sh saute tout
node qui a déjà /etc/kubernetes/kubelet.conf. Le relancer est d'ailleurs la façon
d'agrandir le lab (§7.1).
ℹ️ Les éléments de jonction sont régénérés à chaque exécution — le token créé par
init expire en 24 h et la clé de certificats en 2 h. Un cluster-up.sh lancé trois jours
après le init initial fonctionne donc, au lieu d'échouer sur une erreur de découverte
opaque.
Pour une autre topologie, édite lab.env — ou surcharge à la volée, en passant la variable
aux deux commandes, chacune relisant son propre environnement :
C'est à ça que sert ce lab. Tout ce que fait cluster-up.sh est une commande kubeadm que
tu peux taper toi-même ; les scripts existent pour éviter de les retaper à chaque
reconstruction, pas pour les cacher. Voici le même parcours, à la main, sur un lab déjà passé
par vagrant up.
vagrantsshk8s-cp1
sudo-i
kubeadmversion-oshort# v1.36.3, gelé par apt-mark
containerd--version# 2.x quand CONTAINERD_SOURCE=docker
crictlps# parle à /run/containerd/containerd.sock
ip-4addrshow|grep192.168.56.5# la VIP est DÉJÀ là, avant tout init
systemctlstatuskeepalived
cat/etc/kubeadm-lab/node.env# NODE_IP, HOSTONLY_IF, VIP…
ℹ️ Que la VIP soit levée avant le kubeadm init est toute la raison d'utiliser
keepalived ici plutôt que kube-vip — le raisonnement est en §9.
La façon du dépôt, qui est aussi la plus courte — cluster-up.sh a déjà rendu la
configuration dans _out/, visible depuis la VM par le dossier synchronisé :
sudokubeadminit--config/vagrant/_out/kubeadm-init.yaml--upload-certs\--skip-phases=addon/kube-proxy# seulement si KUBE_PROXY_REPLACEMENT=true
L'équivalent en drapeaux, pour voir la chose sans fichier de configuration :
Noter les deux adresses, qui ne sont pas la même chose :
--apiserver-advertise-address est l'IP réelle sur laquelle CET apiserver écoute,
--control-plane-endpoint est la VIP partagée, gravée dans les certificats et dans tous les
kubeconfig.
⚠️ Cette forme en drapeaux ne sait pas poser node-ip, et c'est pour ça que le dépôt
passe par --config. Avec les seuls drapeaux, le kubelet prend la carte de la route par
défaut — le NAT, 10.0.2.15, identique sur toutes les VM. Tous les nodes s'enregistrent
alors avec la même adresse : kubectl get nodes -o wide semble plausible, pendant que les
logs, kubectl exec, les probes et le trafic inter-node partent au mauvais endroit.
kubeadm init/join n'a aucun drapeau équivalent ; le réglage n'existe que sous
nodeRegistration.kubeletExtraArgs.
⚠️ --upload-certs est ce qui rendra la HA possible plus tard. Il dépose les CA du
cluster dans le Secret kubeadm-certs, chiffré par la clé de certificats. Sans lui, un
second control plane ne peut rejoindre qu'après recopie manuelle de /etc/kubernetes/pki.
⚠️ Les certSANs ne s'ajoutent pas après coup — pas sans régénérer le certificat de
l'API. C'est pourquoi le lab déclare d'avance 5 IP de control plane, y compris de nodes
qui n'existent pas encore : agrandir le cluster ne demandera jamais de toucher à la PKI.
# sur le control plane — imprime une commande prête à coller, token valable 24 h
sudokubeadmtokencreate--print-join-command
# kubeadm join 192.168.56.5:6443 --token <t> --discovery-token-ca-cert-hash sha256:<h>
# sur le worker
sudokubeadmjoin192.168.56.5:6443--token<t>--discovery-token-ca-cert-hashsha256:<h>
⚠️ Cette ligne imprimée est précisément ce que ce lab n'utilise PAS. Elle ne sait pas
transporter node-ip (cf. 5.2) : un node joint ainsi s'enregistre avec 10.0.2.15. Le
dépôt rend à la place un fichier JoinConfiguration
(kubeadm-join-worker.yaml.tpl) et lance
kubeadm join --config /vagrant/_out/join-<node>.yaml. Si tu joins à la main et que tous
les nodes affichent la même INTERNAL-IP, c'est là qu'il faut regarder.
Deux ingrédients de plus : la clé de certificats, qui déchiffre le Secret kubeadm-certs,
et --control-plane.
# sur cp1 — rechiffre le Secret et imprime une NOUVELLE clé en dernière ligne
sudokubeadminitphaseupload-certs--upload-certs
# en une commande, la ligne de jonction complète
sudokubeadmtokencreate--print-join-command\--certificate-key"$(sudokubeadminitphaseupload-certs--upload-certs|tail-n1)"
# sur cp2
sudokubeadmjoin192.168.56.5:6443--token<t>--discovery-token-ca-cert-hashsha256:<h>\--control-plane--certificate-key<clé>
⚠️ La clé de certificats expire en 2 heures, le token en 24. Les deux se régénèrent sans
risque (commandes ci-dessus) ; un élément périmé produit une erreur de déchiffrement qui ne
parle jamais d'expiration.
⚠️ --config et --certificate-key sont incompatibles. Avec un fichier de
configuration, la clé va sous controlPlane.certificateKey — et non à la racine du
document, contrairement à InitConfiguration. Cf.
kubeadm-join-cp.yaml.tpl.
⚠️ Joins les control planes un à la fois. Chaque jonction ajoute un membre etcd, et etcd
n'accepte qu'un changement d'appartenance à la fois. En parallèle, le second échoue sur une
erreur de quorum difficile à lire et facile à mal diagnostiquer.
5.6 Ce qui a changé par rapport aux anciennes notes kubeadm#
Le dépôt est né d'un pas-à-pas manuscrit
(README-installkubeadm.md, conservé pour l'archéologie). Quatre
habitudes de cette époque ne sont plus justes :
Ancienne habitude
Ce qu'il faut faire aujourd'hui
clé GPG du dépôt v1.35, sources.list pointant sur v1.34
la clé et le dépôt doivent être de la même mineure, et cette mineure doit correspondre à K8S_VERSION — c'est pour ce décalage que le couple vit dans lab.env
Un cluster nu ne sert à rien — et ici il n'est même pas Ready. Tout le reste — Cilium, Envoy
Gateway, cert-manager, metrics-server, Longhorn, Vault, CloudNativePG, Prometheus/Loki,
Kyverno, Trivy, MinIO, Argo CD… — vient d'un dépôt séparé,
k8s-playground, monté ici comme sous-module
_k8s/.
Cette couche était auparavant dupliquée dans ce dépôt et dans le jumeau Talos. Elle est
désormais maintenue une seule fois, dans un dépôt qui se monte sous _k8s/ dans l'un ou
l'autre lab et détermine tout seul ce qu'il a sous les yeux — rien à déclarer, rien à
exporter. Sa documentation est publiée à part : https://ops-nc.github.io/k8s-playground/.
./kubeadm/cluster-up.sh# 1. le cluster (NotReady : pas encore de CNI)
./_k8s/platform-up.sh# 2. CNI → Envoy Gateway → metrics-server → TLS
./_k8s/install.shlonghornvaultargocd# 3. addons optionnels
Variantes utiles — aucune distribution à passer nulle part :
Commande
Ce qu'elle fait
./_k8s/install.sh list
le catalogue complet des addons
./_k8s/install.sh all
la plateforme + tous les addons, dans l'ordre des dépendances
./_k8s/longhorn/longhorn-up.sh
un addon tout seul
Comment il se repère, en deux temps, tous les deux automatiques :
Le lab, c'est le dossier qui contient_k8s/ — celui qui porte le Vagrantfile, donc
lab.env, _out/ et kubeconfig. C'est exactement ce dépôt.
La distribution se lit dans le contenu du lab : un kubeadm/cluster-up.sh à côté du
Vagrantfile signe le lab kubeadm (un talos/cluster-up.sh signe le jumeau Talos). C'est
tranché dès le clone, avant tout vagrant up.
Un argument explicite (./_k8s/install.sh kubeadm platform, --distro=kubeadm, ou la
variable d'environnement K8S_DISTRO) reste accepté et prime sur la détection — utile quand on pilote un
cluster depuis ailleurs, inutile ici.
platform-up.sh installe le CNI en premier ; les nodes passent Ready une à deux minutes
après cette étape. Chaîne de dépendances complète, liste des addons et pièges propres à
chacun : https://ops-nc.github.io/k8s-playground/.
💡 LAB_DIR est l'échappatoire, pas une étape. Pointe-le sur le dossier du lab quand la
disposition sort de l'ordinaire — _k8s/ cloné ailleurs, lab piloté depuis un autre chemin :
LAB_DIR=/chemin/vers/Vagrant-kubeadm ./_k8s/platform-up.sh. LAB_ENV fait la même chose
pour un lab.env isolé. Dans la disposition en sous-module, ni l'un ni l'autre n'est utile.
⚠️ Si _k8s/ est vide, le sous-module n'a jamais été initialisé :
git submodule update --init --recursive (§1). Pour récupérer une couche applicative plus
récente : git submodule update --remote _k8s.
⚠️ Cette couche suppose CNI=cilium (le défaut). Elle a besoin d'un Service
LoadBalancer qui obtient réellement une IP, ce que seule l'annonce L2/ARP de Cilium
fournit sur un réseau host-only. Avec calico, flannel ou none, le Gateway reste en
EXTERNAL-IP <pending> et aucune UI n'est joignable. Détails en §10.
Rien dans le cluster ne peut les faire à ta place.
a) Faire résoudre *.<LAB_DOMAIN> vers l'IP du Gateway. Toutes les UI du lab passent par
un point d'entrée unique — le Service LoadBalancer d'Envoy, qui prend la première IP de
LB_POOL_START, 192.168.56.200 par défaut. Avec SELF_SIGNED=true (le défaut), une ligne
dans le /etc/hosts de l'hôte suffit, aucun enregistrement DNS public n'est nécessaire :
Avec SELF_SIGNED=false, il faut un vrai enregistrement A wildcard *.<LAB_DOMAIN> vers
l'IP du Gateway, en DNS-only (un proxy CDN ne peut pas joindre une origine privée en
192.168.56.x).
b) Choisir le mode TLS, avec SELF_SIGNED dans lab.env. true : platform-up.sh
fabrique une AC locale et un certificat wildcard avec openssl, n'installe pas cert-manager,
n'exige ni token ni domaine public — le navigateur avertit tant que _out/self-signed/ca.crt
n'est pas importée. false : cert-manager + Let's Encrypt en ACME DNS-01, ce qui suppose un
domaine réel, CLOUDFLARE_API_TOKEN, et de respecter le quota de production de 5
certificats par semaine (LAB_ACME_ISSUER=staging est le défaut pour exactement cette
raison). Les deux chemins remplissent le même Secret
wildcard-<LAB_DOMAIN avec des tirets>-tls : aucun addon n'a donc à savoir lequel tu as
choisi.
vagrantstatus# état des VM
vagranthalt# extinction (le cluster revient au `up` suivant)
vagrantup# rallumage
vagrantdestroy-f# suppression de toutes les VM
Après un destroy, nettoyer aussi l'état côté hôte avant de reconstruire :
rm-rf_outkubeconfig
Garder le dépôt à jour, c'est deux commandes et non une — git pull ne déplace que ce
dépôt et laisse _k8s/ sur le commit épinglé auparavant :
gitpull# ce dépôt (Vagrantfile, kubeadm/, docs)
gitsubmoduleupdate--init--recursive# _k8s/ ramené sur le commit épinglé ici
gitsubmoduleupdate--remote_k8s# ou : sauter au dernier k8s-playground
./kubeadm/cluster-reset.sh# demande confirmation
./kubeadm/cluster-reset.sh--yes# sans confirmation
Il lance kubeadm reset sur tous les nodes (les workers d'abord, pour qu'ils se
désinscrivent pendant que l'API répond encore), puis supprime _out/ et kubeconfig sur
l'hôte. Les VM continuent de tourner, avec leurs paquets, containerd et keepalived intacts —
la reconstruction se résume alors à ./kubeadm/cluster-up.sh, quelques minutes au lieu d'un
vagrant up complet.
À préférer à vagrant destroy pour rejouer un bootstrap raté, ou pour changer POD_CIDR,
SERVICE_CIDR, le CNI ou la VIP — tous les quatre sont figés au kubeadm init et ne se
changent pas sur un cluster en route.
⚠️ Destructif : etcd, les certificats et toutes les charges sont perdus, y compris ce
qui vit dans un PersistentVolume adossé au disque d'un node.
ℹ️ Pourquoi un reset dédié et pas seulement kubeadm reset : kubeadm reset laisse
volontairement derrière lui ce qu'il n'a pas posé — les interfaces CNI (cilium_host,
lxc*, flannel.1, cali*), les programmes eBPF épinglés de Cilium sous /sys/fs/bpf
(qui survivent au DaemonSet et continuent d'intercepter le trafic d'un cluster qui n'existe
plus), et les règles iptables/ipvs de kube-proxy. node-reset.sh
fait ce ménage ; sans lui, le init suivant hérite d'un datapath fantôme et le réseau pod
se comporte de façon qu'aucun log n'explique.
Les symptômes et leurs correctifs ont leur propre page, pour que celle-ci reste une page
d'installation : DEPANNAGE.md. Les cas les plus probables :
Tous les nodes NotReady, CoreDNS Pending → aucun CNI. Attendu jusqu'à
./_k8s/platform-up.sh — kubeadm n'en installe jamais.
./_k8s/platform-up.sh: No such file or directory → le sous-module _k8s/ n'a jamais
été initialisé : git submodule update --init --recursive.
Les scripts _k8s/ tournent sur le mauvais domaine, ou ne trouvent pas de kubeconfig →
le lab n'a pas été localisé ; lance-les depuis un _k8s/ réellement placé dans le lab, ou
renseigne LAB_DIR.
cluster-up.sh s'arrête sur « l'apiserver ne répond pas sur la VIP » → soit keepalived
ne porte pas la VIP (vagrant ssh k8s-cp1 -c "ip -4 addr show | grep 192.168.56.5",
sudo systemctl status keepalived), soit l'apiserver lui-même ne démarre pas
(sudo crictl ps -a | grep apiserver, sudo journalctl -u kubelet -n 50).
Tous les nodes affichent la même INTERNAL-IP10.0.2.15 → un node joint sans
node-ip, c'est-à-dire avec la ligne kubeadm join imprimée au lieu du
JoinConfiguration rendu (§5.4).
vagrant up meurt sur VERR_VMX_IN_VMX_ROOT_MODE → le module KVM tient VT-x ; le
décharger.
Le Gateway reste en EXTERNAL-IP <pending> → CNI n'est pas cilium, donc personne
n'annonce les IP LoadBalancer sur le réseau host-only (§10).
Les problèmes propres à un addon sont documentés avec les addons eux-mêmes, dans les sections
⚠️ pièges et 🚑 dépannage des pages k8s-playground :
https://ops-nc.github.io/k8s-playground/.
9.1 La VIP est portée par keepalived, pas par kube-vip#
C'est la décision la plus structurante du dépôt.
controlPlaneEndpoint pointe sur la VIP, et il est figé dans les certificats et dans tous
les kubeconfig au moment du kubeadm init. La VIP doit donc exister avant le init.
kube-vip, la réponse habituelle des guides kubeadm HA, tourne en pod statique et fait son
élection de leader à travers l'API Kubernetes — c'est-à-dire à travers la VIP qu'il est
censé porter. Œuf et poule. La sortie documentée consiste à le pointer sur
--k8sConfigPath /etc/kubernetes/super-admin.conf, elle-même fragile depuis que Kubernetes
1.29 a sorti admin.conf du groupe system:masters
(kube-vip#684, toujours ouvert).
keepalived n'a rien de tout ça : c'est un simple démon VRRP, il ne connaît pas Kubernetes, il
lève la VIP dès le boot de la VM, et la dépendance circulaire disparaît. Sa configuration est
écrite par provision.sh :
VRRP en unicast (unicast_src_ip + unicast_peer), pas en multicast : sur un switch
virtuel host-only VirtualBox, le multicast est la première chose à se comporter bizarrement,
et on connaît de toute façon toutes les IP de control plane.
Priorités cp1 = 100, cp2 = 90, cp3 = 80.
vrrp_script chk_apiserver interroge https://127.0.0.1:6443/livez/ping toutes les 3 s avec
weight -30 : un control plane dont l'apiserver est mort tombe à 70 et passe derrière un
cp2 sain à 90, qui reprend la VIP.
/livez/ping est lisible en anonyme grâce au ClusterRoleBinding system:public-info-viewer
posé par kubeadm — aucun credential à distribuer à un script de santé.
Tant qu'aucun cluster n'existe, le test échoue sur tous les CP : chacun perd 30 points,
l'ordre relatif est préservé, et la VIP est quand même portée. Exactement ce dont
kubeadm init a besoin.
Pas de bloc authentication : VRRPv2 transmet son mot de passe en clair et n'apporte
rien. La frontière de confiance est ici le réseau host-only. Pour cohabiter avec un autre
lab keepalived sur le même réseau, on change VRRP_ROUTER_ID.
ℹ️ kube-vip reste une option parfaitement valable une fois le cluster en route (mode
--services, pour les Services LoadBalancer). C'est le rôle au bootstrap qui ne passe
pas ici.
9.2 La VIP est utilisée même avec un seul control plane#
Parce que controlPlaneEndpoint est figé au init. En le pointant sur la VIP dès la première
exécution, passer de 1 à 3 control planes devient un simple join ; le pointer sur l'IP
réelle de cp1 imposerait de régénérer tous les certificats et de redistribuer tous les
kubeconfig.
9.3 containerd 2.x depuis le dépôt Docker, pas le paquet Debian#
Debian 13 livre containerd 1.7.24. Seule la branche 2.x implémente la méthode CRI
RuntimeConfig, dont kubeadm se sert pour lire le cgroup driver du runtime. En 1.36 son
absence n'est qu'un avertissement de preflight ; le repli disparaît en 1.37, et le
backport vers la branche 1.7 a été refusé
(containerd#11346, fermé sans merge).
La 1.7 est une impasse. CONTAINERD_SOURCE=debian reste disponible pour un lab hors ligne.
SystemdCgroup = true compte davantage que le champ cgroupDriver du kubelet : Debian 13 est
en cgroup v2 avec systemd comme gestionnaire, et laisser containerd sur cgroupfs met deux
gestionnaires sur la même hiérarchie — les nodes deviennent alors instables sous charge.
⚠️ Le piège de la migration 1.7 → 2.x : la clé de l'image pause a changé de nom et
d'emplacement. La config v2 a sandbox_image = "..." sous
[plugins."io.containerd.grpc.v1.cri"] ; la v3 a sandbox = '...' sous
[plugins.'io.containerd.cri.v1.images'.pinned_images]. Une config recopiée telle quelle
perd silencieusement le réglage. provision.sh régénère le fichier depuis
containerd config default à chaque passage et corrige la clé réellement présente.
ℹ️ Le tag de pause lui-même n'est jamais codé en dur : il vient de
kubeadm config images list. Un décalage entre le pause de containerd et celui qu'attend
kubeadm est invisible en ligne (l'image est simplement retéléchargée) et fatal hors ligne.
Le piège numéro un de tout lab Kubernetes sous Vagrant, décrit en §5.2 : la carte NAT est en
10.0.2.15 sur toutes les VM. Comme ni kubeadm init ni kubeadm join n'ont de drapeau pour
ça, le lab pilote les deux par des fichiers InitConfiguration/JoinConfiguration portant
nodeRegistration.kubeletExtraArgs: [{name: node-ip, value: <IP host-only>}].
Version courante et par défaut depuis Kubernetes 1.31 ; v1beta3 est dépréciée.
⚠️ La rupture à connaître : extraArgs et kubeletExtraArgs ne sont plus des
dictionnaires mais des listes de {name, value} — pour autoriser un même drapeau
plusieurs fois. Tout fichier écrit avant 1.31 est invalide tel quel, et l'erreur renvoyée
par kubeadm ne désigne pas la forme fautive.
make validate-kubeadm attrape exactement ça, en CI, sans cluster.
9.6 Le dossier synchronisé /vagrant est un rouage, pas un confort#
cluster-up.shne fait rien lui-même dans les VM. Il rend les configurations kubeadm dans
_out/ sur l'hôte, puis appelle node-init.sh et
node-join.sh par vagrant ssh ; les VM lisent ces fichiers dans
/vagrant/_out/. Aucun scp, aucun secret passé en ligne de commande (où il finirait dans
l'historique du shell et dans la liste des processus), et la logique reste dans des fichiers
versionnés qui se relisent en diff, au lieu d'un enfer d'échappement dans un
vagrant ssh -c.
⚠️ _out/join.env contient le token de jonction et la clé de certificats. Le dossier
est gitignoré, mais il est lisible depuis toutes les VM par le dossier synchronisé. C'est un
lab : acceptable ici, à ne pas reproduire en production.
9.7 Ce que kubeadm ne fait pas, et que cluster-up.sh rattrape#
Rôle des workers : kubeadm ne pose aucun label de rôle ; kubectl get nodes affiche
<none> dans la colonne ROLES et les sélecteurs node-role.kubernetes.io/worker ne
matchent rien. cluster-up.sh pose le label.
Taint des control planes : UNTAINT_CP=auto ne le retire que si WORKERS=0 — sinon
plus rien ne pourrait se planifier nulle part. C'est ce qui rend un lab à une seule VM
utilisable.
Métriques du control plane : controllerManager et scheduler reçoivent
bind-address: 0.0.0.0 ; par défaut ils n'écoutent qu'en loopback et Prometheus affiche
deux cibles DOWN sans explication. Acceptable parce que le réseau host-only est isolé.
Images pré-tirées : pendant le vagrant up, en parallèle sur toutes les VM, pour que
kubeadm init n'ait plus rien à télécharger — la plus grosse source de timeouts au
bootstrap. Les workers ne tirent que pause et kube-proxy, ce qui économise ~500 Mio
chacun.
Swap : coupé et masqué (y compris les unités systemd de swap, que /etc/fstab ne
décrit pas). NodeSwap est GA depuis 1.34, mais failSwapOn vaut toujours true par défaut.
kubeadm n'installe jamais de CNI. Contrairement au dépôt jumeau Talos — où flannel peut
être posé par l'OS au bootstrap —, ici le réseau des pods est toujours installé après coup,
par ./_k8s/platform-up.sh. CNI dans lab.env est lu par cluster-up.sh (pour
la décision kube-proxy et _out/cluster.env) et par l'étape plateforme (quel chart installer).
En pratique : garde cilium. C'est la seule valeur qui rend le lab utilisable de bout en
bout, parce que c'est la seule qui donne une EXTERNAL-IP aux Services sur un réseau
host-only — donc la seule qui te donne les UI HTTPS. calico est là pour comparer les CNI et
travailler les NetworkPolicy ; flannel pour un cluster volontairement nu.
⚠️ KUBE_PROXY_REPLACEMENT=true exige CNI=cilium, et cluster-up.sh refuse de
démarrer avec toute autre combinaison. Avec --skip-phases=addon/kube-proxy et aucun
remplaçant, plus aucune ClusterIP ne répond — pas même CoreDNS joignant l'API. Le
message d'erreur propose les deux issues : CNI=cilium, ou KUBE_PROXY_REPLACEMENT=false.
ℹ️ Pourquoi Cilium a besoin de k8sServiceHost/k8sServicePort quand kube-proxy
disparaît : plus rien ne provisionne la ClusterIP de l'apiserver, et l'agent ne peut donc
pas s'amorcer via kubernetes.default. Le lab l'y pointe sur la VIP — ce qui fait aussi
que les agents survivent à la perte de n'importe quel control plane.
ℹ️ Le lab utilise le drapeau --skip-phases=addon/kube-proxy plutôt que le champ
déclaratif proxy.disabled de v1beta4 : résultat identique, mais le drapeau est éprouvé sur
toutes les versions et c'est celui que documente Cilium.
⚠️ POD_CIDR doit être le CIDR que le CNI annonce réellement. Cilium en mode
cluster-pool part par défaut sur 10.0.0.0/8, sans rapport avec ce qu'on a déclaré à
kubeadm ; le cilium/cilium-up.sh de k8s-playground lui repasse POD_CIDR explicitement.
Deux valeurs divergentes donnent un réseau pod cassé qui a l'air configuré.
⚠️ Changer de CNI sur un cluster existant n'est pas supporté../kubeadm/cluster-reset.sh (ou vagrant destroy) d'abord — deux CNI se battent pour le
réseau des pods, et le datapath résiduel est précisément ce que node-reset.sh existe pour
nettoyer.
makevalidate# shell + YAML + Vagrantfile + modèles kubeadm + liens de la doc
makedocs# régénère docs/index.html depuis tous les README (EN + FR)
makehelp# liste les cibles
Cible
Ce qu'elle couvre
validate-shell
bash -n sur tous les *.sh suivis par git
validate-yaml
parse tous les *.yaml / *.yml suivis par git (PyYAML, fourni par uv)
validate-vagrant
vagrant validate ; en local, valide en plus la configuration du provider
validate-defaults
vérifie que les défauts de repli du Vagrantfile et de cluster-up.sh correspondent toujours, clé par clé, à ceux de lab.env.example
validate-kubeadm
rend les 3 modèles avec des valeurs factices dans un dossier jetable, les parse, puis lance kubeadm config validatesi kubeadm est dans le PATH
validate-docs
construit la doc dans un fichier jetable et échoue sur tout lien *.md mort ou ancre inter-page inconnue
validate-kubeadm est la cible qui a le plus de valeur : c'est elle qui attrape une vraie
erreur de schéma v1beta4 — un extraArgs resté sous forme de dictionnaire v1beta3 — au lieu
de la découvrir dix minutes après le début d'un vagrant up. En CI, où kubeadm est installé
pour le job, la vérification de schéma tourne systématiquement.
À chaque pull request, le workflow ci rejoue les validations shell, défauts, YAML,
kubeadm et Vagrantfile en appelant exactement les mêmes cibles make : une vérification ne peut donc pas
passer en CI et échouer sur ton poste. Il vérifie aussi que le garde-fou se déclenche
vraiment : CONTROL_PLANES=2 vagrant validatedoit être rejeté. vagrant validate y
tourne avec --ignore-provider, faute de VirtualBox sur un runner ; validate-docs est
couvert par le workflow docs.
ℹ️ Rien dans le Makefile ne touche à un cluster en route, et rien ne régénère de secret.
make validate est sans risque sur un lab démarré.
Ce projet est sous licence Apache 2.0 — cf.
LICENSE.
En résumé : utilisation, modification et redistribution libres, y compris commerciales, tant
que l'avis de copyright est conservé et les modifications signalées. Le tout sans aucune
garantie : c'est un lab, pas de production.
La licence couvre ce que ce dépôt contient réellement — le Vagrantfile, les scripts
kubeadm/, les modèles, les manifestes et la documentation. Elle ne s'étend pas aux
composants tiers que ces scripts téléchargent (Kubernetes, containerd, keepalived, Cilium,
Envoy Gateway, Longhorn, Vault…), chacun conservant sa propre licence, ni au sous-module
_k8s/ : k8s-playground est un dépôt séparé et
porte son propre LICENSE.
kubeadm/UPGRADE.md
⬆️Upgrading Kubernetes
How to move this lab from one Kubernetes version to the next with kubeadm, the way you
would on a real cluster. Install path: ../README.md · symptoms and fixes:
../TROUBLESHOOTING.md.
Reference at the time of writing: Kubernetes 1.36.3, apt repository v1.36, containerd
2.2.6, Cilium 1.20.0, CNI=cilium. Adapt the node names and IPs to your topology
(lab.env); the repo default is 1 control plane + 2 workers.
⚠️ Unlike the Talos sibling of this lab, the procedure below has not been timed on a live
run. It is the upstream kubeadm procedure, transposed to this repo's variables and scripts.
Every command is quoted from the upstream documentation linked in §7.
Skipping a minor version is unsupported.1.36 → 1.37 → 1.38, never 1.36 → 1.38. This is
not a kubeadm quirk: the API deprecation policy requires kube-apiserver not to skip minor
versions, even on a single-instance cluster. kubeadm upgrade apply refuses a target more than
one minor above the current version.
Patch versions inside a minor are free (1.36.3 → 1.36.7).
That is what dictates the order of the whole procedure: control plane first, kubelet last.
Upgrading a node's kubelet package before kubeadm upgrade apply has run puts a 1.37 kubelet
in front of a 1.36 apiserver — an unsupported combination that fails in ugly ways.
⚠️ Never run vagrant provision to "upgrade" the lab.kubeadm/provision.sh unholds the
packages and installs kubelet, kubeadm and kubectl at K8S_VERSION with
--allow-change-held-packages, on every node at once, without ever calling
kubeadm upgrade. Bumping lab.env and re-provisioning would therefore jump every kubelet
to the new minor while the control plane is still on the old one. vagrant provision is for
a fresh VM, not for an upgrade.
📦 2. Why the packages are held, and why the apt repository is per MINOR#
An upgrade must be a deliberate act, never the side effect of an apt upgrade inside a VM
— which would silently break the kubelet/apiserver skew. The first step of any upgrade is
therefore apt-mark unhold, and the last one is apt-mark hold again.
Check what is held:
vagrantsshk8s-cp1-c"apt-mark showhold"
The pkgs.k8s.io repository is per minor version — this is the step people miss#
There is one apt repository per Kubernetes minor:
https://pkgs.k8s.io/core:/stable:/v1.36/deb/
The v1.36 repository will never offer 1.37. Staying on it means apt-get install kubeadm=1.37.x-* returns "Version '1.37.x-' for 'kubeadm' was not found"*, and people then
conclude the release does not exist.
In this lab the repository file is generated by provision.sh from K8S_APT_MINOR, and the
package version from K8S_VERSION. Both live in lab.env and both must move together:
# lab.envK8S_VERSION=1.37.0
K8S_APT_MINOR=v1.37
⚠️ Those two variables also have fallback defaults duplicated in the Vagrantfile
(K8S_VERSION, K8S_APT_MINOR) and in kubeadm/cluster-up.sh (K8S_VERSION). They exist so
a lab brought up without a lab.env still works. Bump them in the same commit as
lab.env.example, otherwise a lab built without lab.env restarts on the old version.
Let's be honest about it: on a disposable lab, the fastest and safest path is not the
upgrade at all.
# lab.env: K8S_VERSION=1.37.0 and K8S_APT_MINOR=v1.37
vagrantdestroy-f
vagrantup
./kubeadm/cluster-up.sh
./_k8s/platform-up.sh
You get a clean cluster on the target version, with no half-upgraded state, in roughly the time
a careful rolling upgrade would take on three nodes — and with none of the risk.
So why document the real procedure? Because learning it is the entire point of running a
kubeadm lab. A managed cluster upgrades itself; a kubeadm cluster does not, and the day you
have to do it on something you cannot destroy, this is the sequence you will need. Do it here
first, where a mistake costs a vagrant destroy.
Use §4 when you want to practise the upgrade. Use this section when you just want the new
version.
Everything below runs inside the VMs (vagrant ssh <node>), except the kubectl commands
which run from the host with KUBECONFIG=$PWD/kubeconfig.
Throughout, 1.37.x stands for the exact target patch version (for instance 1.37.0-1.1's
1.37.0). The -* suffix in the apt-get install lines is intentional: the Debian revision is
not always -1.1.
4.1 Pre-flight — never start from a degraded cluster#
exportKUBECONFIG="$PWD/kubeconfig"
kubectlgetnodes-owide# every node Ready, all on the same version
kubectlgetpods-A|grep-vRunning# nothing broken before you start
kubectlget--raw='/healthz/etcd'# etcd healthy
vagrantsshk8s-cp1-c"sudo kubeadm certs check-expiration"
Read the target release's changelog first, and check
two lab-specific constraints:
Constraint
Why it matters here
containerd 2.x
the CRI RuntimeConfig fallback disappears in 1.37. A lab built with CONTAINERD_SOURCE=debian (containerd 1.7) turns its 1.36 warning into a 1.37 failure. Check containerd --version before upgrading.
Cilium ↔ Kubernetes
Cilium supports a bounded set of Kubernetes versions. Check the Cilium release notes and plan §6 accordingly.
Swap must be off
already handled by provision.sh, but a manually re-enabled swap makes the preflight fail.
💡 kubeadm upgrade pulls new control plane images. With REGISTRY_MIRROR set, they come
from the mirror; otherwise the node needs Internet access through its NAT NIC.
# 1. Point apt at the NEW minor's repositoryecho"deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] \https://pkgs.k8s.io/core:/stable:/v1.37/deb/ /"\|sudotee/etc/apt/sources.list.d/kubernetes.list
# 2. Upgrade kubeadm ONLY
sudoapt-markunholdkubeadm&&\
sudoapt-getupdate&&sudoapt-getinstall-ykubeadm='1.37.x-*'&&\
sudoapt-markholdkubeadm
kubeadmversion
# 3. What would happen?
sudokubeadmupgradeplan
# 4. Apply — this is the step that upgrades the control plane components
sudokubeadmupgradeapplyv1.37.x
kubeadm upgrade apply rewrites the static pod manifests for kube-apiserver,
kube-controller-manager, kube-scheduler and etcd, and renews the certificates it
manages on this node (see §5).
⚠️ With CONTROL_PLANES=1 the API is unavailable while the static pods roll. That is
expected on a single control plane, and it is the best argument for practising this on a 3-CP
topology.
Then, and only then, the kubelet:
# 5. Drain the node (from the HOST, or from inside the VM — the kubeconfig is there too)
kubectldraink8s-cp1--ignore-daemonsets
# 6. Upgrade kubelet and kubectl
sudoapt-markunholdkubeletkubectl&&\
sudoapt-getupdate&&sudoapt-getinstall-ykubelet='1.37.x-*'kubectl='1.37.x-*'&&\
sudoapt-markholdkubeletkubectl
# 7. Restart the kubelet
sudosystemctldaemon-reload
sudosystemctlrestartkubelet
# 8. Put the node back into service
kubectluncordonk8s-cp1
💡 If the drain stalls on a pod with an emptyDir, add --delete-emptydir-data. If it stalls
on a PodDisruptionBudget (Longhorn is the usual suspect), fix the PDB rather than forcing —
and on a lab, --disable-eviction is the blunt instrument of last resort.
Check before moving on:
kubectlgetnodes# k8s-cp1 Ready, VERSION v1.37.x
kubectlget--raw='/healthz/etcd'
One node at a time, checking etcd between each. With 3 control planes the quorum is 2:
losing two at once freezes the API.
The only difference with §4.2 is step 3–4 — kubeadm upgrade node replaces
plan + apply:
# 1. Same repository change, same kubeadm upgradeecho"deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] \https://pkgs.k8s.io/core:/stable:/v1.37/deb/ /"\|sudotee/etc/apt/sources.list.d/kubernetes.list
sudoapt-markunholdkubeadm&&\
sudoapt-getupdate&&sudoapt-getinstall-ykubeadm='1.37.x-*'&&\
sudoapt-markholdkubeadm
# 2. Upgrade this node's control plane components
sudokubeadmupgradenode
# 3. Drain, kubelet, restart, uncordon — identical to §4.2
kubectldraink8s-cp2--ignore-daemonsets
sudoapt-markunholdkubeletkubectl&&\
sudoapt-getupdate&&sudoapt-getinstall-ykubelet='1.37.x-*'kubectl='1.37.x-*'&&\
sudoapt-markholdkubeletkubectl
sudosystemctldaemon-reload
sudosystemctlrestartkubelet
kubectluncordonk8s-cp2
⚠️ The 192.168.56.5 VIP moves on its own while a control plane restarts: keepalived's health
check (/livez/ping every 3 s, weight -30) drops the restarting node behind a healthy peer. That
is exactly the failover this lab exists to demonstrate — watch it happen:
Same as §4.3, one node at a time. Workers hold no etcd member, so nothing here can break the
quorum — but draining them all at once would take every workload down.
echo"deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] \https://pkgs.k8s.io/core:/stable:/v1.37/deb/ /"\|sudotee/etc/apt/sources.list.d/kubernetes.list
sudoapt-markunholdkubeadm&&\
sudoapt-getupdate&&sudoapt-getinstall-ykubeadm='1.37.x-*'&&\
sudoapt-markholdkubeadm
sudokubeadmupgradenode# on a worker this only updates the local kubelet config
kubectldraink8s-w1--ignore-daemonsets
sudoapt-markunholdkubeletkubectl&&\
sudoapt-getupdate&&sudoapt-getinstall-ykubelet='1.37.x-*'kubectl='1.37.x-*'&&\
sudoapt-markholdkubeletkubectl
sudosystemctldaemon-reload
sudosystemctlrestartkubelet
kubectluncordonk8s-w1
kubectlgetnodes-owide# every node Ready, all on v1.37.x
kubectlgetpods-A|grep-vRunning
kubectlversion
Then write the new version back into the repo, so a future rebuild starts where you left
off:
File
What to change
lab.env
K8S_VERSION=1.37.xandK8S_APT_MINOR=v1.37
lab.env.example
the same two lines (this is the versioned template)
Vagrantfile
the K8S_VERSION / K8S_APT_MINOR fallback defaults
kubeadm/cluster-up.sh
the K8S_VERSION fallback default
⚠️ Three of those four files carry a duplicated default on purpose (safety net when
lab.env is missing). Two defaults that diverge produce an incoherent lab: packages from one
minor, generated configuration for another.
An upgrade renews them for you.kubeadm upgrade (both apply and node) automatically
renews the certificates it manages on that node — --certificate-renewal=false opts out. A
cluster upgraded at least once a year therefore never sees an expired certificate, which is
precisely why the yearly expiry rarely bites in production and always bites on a lab VM left
suspended for months.
Manual renewal, when no upgrade is due:
vagrantsshk8s-cp1
sudokubeadmcertsrenewall
sudosystemctlrestartkubelet# reloads the control plane static pods
⚠️ Renewing also renews admin.conf, which the host's kubeconfig was copied from. Refresh
it, or kubectl keeps presenting the old client certificate:
Two things kubeadm does not renew: the CA itself (10 years — well beyond any lab's life),
and the kubelet's own client certificate, which rotates automatically under
/var/lib/kubelet/pki.
ℹ️ Do not confuse these with the two short-lived items used for joining a node: the
bootstrap token (24 h) and the certificate key (2 h). kubeadm/node-init.sh regenerates both
on every cluster-up.sh run — see ../TROUBLESHOOTING.md.
🧩 6. containerd and Cilium upgrade on their own cycles#
Kubernetes, the container runtime and the CNI are three independent release trains. Bumping
them together is how a simple upgrade turns into an unsolvable puzzle: upgrade one thing at a
time, and check the cluster in between.
containerd.io is not held by provision.sh — only kubelet, kubeadm and kubectl are.
It therefore moves with a plain apt upgrade inside a VM, which is usually harmless but always
restarts every container on that node.
⚠️ The 1.7 → 2.x migration trap. The pause image key changed name and location
between the two config formats:
v2 (containerd 1.7): sandbox_image = "…" under [plugins."io.containerd.grpc.v1.cri"]
v3 (containerd 2.x): sandbox = '…' under
[plugins.'io.containerd.cri.v1.images'.pinned_images]
A config copied across as-is loses the setting silently. provision.sh sidesteps this by
regenerating /etc/containerd/config.toml from containerd config default on every run and
patching both key names — which is also why you should not hand-edit that file and expect
it to survive.
Going the other way, from CONTAINERD_SOURCE=docker to debian, is a downgrade to a dead
end: containerd 1.7 will never implement RuntimeConfig and cannot carry you past 1.36.
Run it from the repository root: _k8s/ is the k8s-playground
submodule, and it finds its bearings on its own — the lab is the directory that contains
_k8s/ and carries the Vagrantfile, which is where lab.env, _out/ and kubeconfig are;
the distribution is read off it. Set LAB_DIR only if you run it from outside that layout.
The script is a helm upgrade --install, so it is the same command whether you are installing
or upgrading. Read the Cilium upgrade notes first: a minor bump can require a one-off
pre-flight step, and this lab depends on two Cilium features that must keep working —
kubeProxyReplacement (there is no kube-proxy to fall back to when
KUBE_PROXY_REPLACEMENT=true) and the L2 announcement that gives the Envoy Gateway its IP.
kubectl-nkube-systemexecds/cilium--cilium-dbgstatus--verbose
kubectl-nenvoy-gateway-systemgetsvc# the Gateway must keep its EXTERNAL-IP
Comment faire passer ce lab d'une version de Kubernetes à la suivante avec kubeadm, comme
sur un vrai cluster. Parcours d'installation : ../LISEZ-MOI.md ·
symptômes et correctifs : ../DEPANNAGE.md.
Référence au moment de l'écriture : Kubernetes 1.36.3, dépôt apt v1.36, containerd
2.2.6, Cilium 1.20.0, CNI=cilium. Adapte les noms de nodes et les IP à ta topologie
(lab.env) ; le défaut du dépôt est 1 control plane + 2 workers.
⚠️ Contrairement au dépôt jumeau Talos de ce lab, la procédure ci-dessous n'a pas été
chronométrée sur une exécution réelle. C'est la procédure kubeadm amont, transposée aux
variables et aux scripts de ce dépôt. Chaque commande est reprise de la documentation amont
citée en §7.
Sauter une mineure n'est pas supporté.1.36 → 1.37 → 1.38, jamais 1.36 → 1.38. Ce n'est
pas une lubie de kubeadm : la politique de dépréciation de l'API interdit à kube-apiserver de
sauter une mineure, même sur un cluster à instance unique. kubeadm upgrade apply refuse une
cible à plus d'une mineure de la version courante.
Les versions de patch à l'intérieur d'une mineure sont libres (1.36.3 → 1.36.7).
C'est ce qui dicte l'ordre de toute la procédure : le control plane d'abord, le kubelet en
dernier. Monter le paquet kubelet d'un node avant d'avoir lancé kubeadm upgrade apply
place un kubelet 1.37 devant un apiserver 1.36 — combinaison non supportée, qui échoue de façon
laide.
⚠️ Ne lance jamais vagrant provision pour « monter » le lab.kubeadm/provision.sh lève
le hold et installe kubelet, kubeadm et kubectl en K8S_VERSION avec
--allow-change-held-packages, sur tous les nodes d'un coup, sans jamais appeler
kubeadm upgrade. Bumper lab.env puis reprovisionner ferait donc sauter tous les kubelet à
la nouvelle mineure alors que le control plane est encore sur l'ancienne. vagrant provision
est fait pour une VM neuve, pas pour une montée de version.
📦 2. Pourquoi les paquets sont en hold, et pourquoi le dépôt apt est par MINEURE#
kubeadm/provision.sh termine son étape paquets par :
apt-markholdkubeletkubeadmkubectl
Une montée de version doit être un geste délibéré, jamais l'effet de bord d'un apt upgrade
lancé dans une VM — qui casserait silencieusement le skew kubelet/apiserver. La première étape
de toute montée est donc apt-mark unhold, et la dernière un apt-mark hold de nouveau.
Vérifier ce qui est retenu :
vagrantsshk8s-cp1-c"apt-mark showhold"
Le dépôt pkgs.k8s.io est par mineure — c'est l'étape que tout le monde rate#
Il y a un dépôt apt par mineure Kubernetes :
https://pkgs.k8s.io/core:/stable:/v1.36/deb/
Le dépôt v1.36 ne proposera jamais la 1.37. Y rester, c'est obtenir de
apt-get install kubeadm=1.37.x-* un « Version '1.37.x-' for 'kubeadm' was not found »* — et
en conclure que la version n'existe pas.
Dans ce lab, le fichier de dépôt est généré par provision.sh à partir de K8S_APT_MINOR,
et la version du paquet à partir de K8S_VERSION. Les deux vivent dans lab.env et les
deux doivent bouger ensemble :
# lab.envK8S_VERSION=1.37.0
K8S_APT_MINOR=v1.37
⚠️ Ces deux variables ont aussi des défauts dupliqués dans le Vagrantfile
(K8S_VERSION, K8S_APT_MINOR) et dans kubeadm/cluster-up.sh (K8S_VERSION). Ils existent
pour qu'un lab monté sans lab.env fonctionne quand même. Bumpe-les dans le même commit
que lab.env.example, sinon un lab construit sans lab.env repart sur l'ancienne version.
Soyons francs : sur un lab jetable, le chemin le plus rapide et le plus sûr n'est pas la
montée de version.
# lab.env : K8S_VERSION=1.37.0 et K8S_APT_MINOR=v1.37
vagrantdestroy-f
vagrantup
./kubeadm/cluster-up.sh
./_k8s/platform-up.sh
On obtient un cluster propre sur la version cible, sans état à moitié migré, en à peu près le
temps que prendrait une montée roulante prudente sur trois nodes — et sans aucun de ses risques.
Alors pourquoi documenter la vraie procédure ? Parce que l'apprendre est tout l'intérêt d'un
lab kubeadm. Un cluster managé se met à jour tout seul ; un cluster kubeadm non, et le jour où
il faudra le faire sur quelque chose qu'on ne peut pas détruire, c'est cette séquence-là qu'il
faudra connaître. Fais-la ici d'abord, là où une erreur coûte un vagrant destroy.
Utilise le §4 quand tu veux t'entraîner à la montée de version. Utilise cette section quand
tu veux juste la nouvelle version.
Tout ce qui suit se lance dans les VM (vagrant ssh <node>), sauf les commandes kubectl
qui se lancent depuis l'hôte avec KUBECONFIG=$PWD/kubeconfig.
Partout, 1.37.x désigne la version de patch cible exacte (par exemple le 1.37.0 de
1.37.0-1.1). Le suffixe -* des lignes apt-get install est volontaire : la révision Debian
n'est pas toujours -1.1.
4.1 Pré-vol — ne jamais partir d'un cluster déjà dégradé#
exportKUBECONFIG="$PWD/kubeconfig"
kubectlgetnodes-owide# tous les nodes Ready, tous sur la même version
kubectlgetpods-A|grep-vRunning# rien de cassé avant de commencer
kubectlget--raw='/healthz/etcd'# etcd en bonne santé
vagrantsshk8s-cp1-c"sudo kubeadm certs check-expiration"
Lis d'abord le changelog de la version cible, et
vérifie deux contraintes propres à ce lab :
Contrainte
Pourquoi c'est important ici
containerd 2.x
le repli CRI RuntimeConfig disparaît en 1.37. Un lab monté avec CONTAINERD_SOURCE=debian (containerd 1.7) transforme son avertissement 1.36 en échec 1.37. Vérifie containerd --version avant de monter.
Cilium ↔ Kubernetes
Cilium ne supporte qu'un ensemble borné de versions Kubernetes. Consulte ses notes de version et planifie le §6 en conséquence.
Le swap doit être coupé
déjà géré par provision.sh, mais un swap réactivé à la main fait échouer le preflight.
💡 kubeadm upgrade tire de nouvelles images de control plane. Avec REGISTRY_MIRROR
renseigné elles viennent du miroir ; sinon le node a besoin d'Internet par sa carte NAT.
# 1. Pointer apt sur le dépôt de la NOUVELLE mineureecho"deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] \https://pkgs.k8s.io/core:/stable:/v1.37/deb/ /"\|sudotee/etc/apt/sources.list.d/kubernetes.list
# 2. Monter kubeadm SEUL
sudoapt-markunholdkubeadm&&\
sudoapt-getupdate&&sudoapt-getinstall-ykubeadm='1.37.x-*'&&\
sudoapt-markholdkubeadm
kubeadmversion
# 3. Que se passerait-il ?
sudokubeadmupgradeplan
# 4. Appliquer — c'est l'étape qui monte les composants du control plane
sudokubeadmupgradeapplyv1.37.x
kubeadm upgrade apply réécrit les manifestes de pods statiques de kube-apiserver,
kube-controller-manager, kube-scheduler et etcd, et renouvelle les certificats qu'il
gère sur ce node (cf. §5).
⚠️ Avec CONTROL_PLANES=1, l'API est indisponible le temps que les pods statiques
redémarrent. C'est attendu sur un control plane unique, et c'est le meilleur argument pour
s'entraîner sur une topologie à 3 CP.
Ensuite seulement, le kubelet :
# 5. Drainer le node (depuis l'HÔTE, ou dans la VM — le kubeconfig y est aussi)
kubectldraink8s-cp1--ignore-daemonsets
# 6. Monter kubelet et kubectl
sudoapt-markunholdkubeletkubectl&&\
sudoapt-getupdate&&sudoapt-getinstall-ykubelet='1.37.x-*'kubectl='1.37.x-*'&&\
sudoapt-markholdkubeletkubectl
# 7. Redémarrer le kubelet
sudosystemctldaemon-reload
sudosystemctlrestartkubelet
# 8. Remettre le node en service
kubectluncordonk8s-cp1
💡 Si le drain bloque sur un pod à emptyDir, ajoute --delete-emptydir-data. S'il bloque
sur un PodDisruptionBudget (Longhorn est le suspect habituel), corrige le PDB plutôt que de
forcer — et sur un lab, --disable-eviction est l'outil brutal de dernier recours.
Vérifie avant de continuer :
kubectlgetnodes# k8s-cp1 Ready, VERSION v1.37.x
kubectlget--raw='/healthz/etcd'
Un node à la fois, avec un contrôle d'etcd entre chaque. Avec 3 control planes le quorum est
à 2 : en perdre deux d'un coup fige l'API.
La seule différence avec le §4.2 tient aux étapes 3–4 — kubeadm upgrade node remplace
plan + apply :
# 1. Même changement de dépôt, même montée de kubeadmecho"deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] \https://pkgs.k8s.io/core:/stable:/v1.37/deb/ /"\|sudotee/etc/apt/sources.list.d/kubernetes.list
sudoapt-markunholdkubeadm&&\
sudoapt-getupdate&&sudoapt-getinstall-ykubeadm='1.37.x-*'&&\
sudoapt-markholdkubeadm
# 2. Monter les composants de control plane de CE node
sudokubeadmupgradenode
# 3. Drain, kubelet, redémarrage, uncordon — identique au §4.2
kubectldraink8s-cp2--ignore-daemonsets
sudoapt-markunholdkubeletkubectl&&\
sudoapt-getupdate&&sudoapt-getinstall-ykubelet='1.37.x-*'kubectl='1.37.x-*'&&\
sudoapt-markholdkubeletkubectl
sudosystemctldaemon-reload
sudosystemctlrestartkubelet
kubectluncordonk8s-cp2
⚠️ La VIP 192.168.56.5 se déplace toute seule pendant qu'un control plane redémarre : le
test de santé de keepalived (/livez/ping toutes les 3 s, weight -30) fait passer le node en
cours de redémarrage derrière un pair sain. C'est exactement la bascule que ce lab existe pour
démontrer — regarde-la se produire :
Comme au §4.3, un node à la fois. Les workers n'hébergent aucun membre etcd : rien ici ne peut
casser le quorum — mais les drainer tous d'un coup couperait toutes les charges.
echo"deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] \https://pkgs.k8s.io/core:/stable:/v1.37/deb/ /"\|sudotee/etc/apt/sources.list.d/kubernetes.list
sudoapt-markunholdkubeadm&&\
sudoapt-getupdate&&sudoapt-getinstall-ykubeadm='1.37.x-*'&&\
sudoapt-markholdkubeadm
sudokubeadmupgradenode# sur un worker, ne met à jour que la config locale du kubelet
kubectldraink8s-w1--ignore-daemonsets
sudoapt-markunholdkubeletkubectl&&\
sudoapt-getupdate&&sudoapt-getinstall-ykubelet='1.37.x-*'kubectl='1.37.x-*'&&\
sudoapt-markholdkubeletkubectl
sudosystemctldaemon-reload
sudosystemctlrestartkubelet
kubectluncordonk8s-w1
kubectlgetnodes-owide# tous les nodes Ready, tous en v1.37.x
kubectlgetpods-A|grep-vRunning
kubectlversion
Puis réécris la nouvelle version dans le dépôt, pour qu'une reconstruction future reparte
d'où tu t'es arrêté :
Fichier
Ce qu'il faut changer
lab.env
K8S_VERSION=1.37.xetK8S_APT_MINOR=v1.37
lab.env.example
les deux mêmes lignes (c'est le modèle versionné)
Vagrantfile
les défauts de repli K8S_VERSION / K8S_APT_MINOR
kubeadm/cluster-up.sh
le défaut de repli K8S_VERSION
⚠️ Trois de ces quatre fichiers portent un défaut dupliqué exprès (filet de sécurité quand
lab.env manque). Deux défauts divergents produisent un lab incohérent : des paquets d'une
mineure, une configuration générée pour une autre.
Une montée de version les renouvelle pour toi.kubeadm upgrade (aussi bien apply que
node) renouvelle automatiquement les certificats qu'il gère sur ce node —
--certificate-renewal=false permet de s'y soustraire. Un cluster monté de version au moins une
fois par an ne voit donc jamais de certificat expiré : c'est précisément pour ça que
l'expiration annuelle mord rarement en production, et systématiquement sur une VM de lab laissée
suspendue pendant des mois.
Renouvellement manuel, quand aucune montée de version n'est prévue :
vagrantsshk8s-cp1
sudokubeadmcertsrenewall
sudosystemctlrestartkubelet# recharge les pods statiques du control plane
⚠️ Le renouvellement touche aussi admin.conf, dont le kubeconfig de l'hôte a été copié.
Rafraîchis-le, sinon kubectl continue de présenter l'ancien certificat client :
Deux choses que kubeadm ne renouvelle pas : l'AC elle-même (10 ans — bien au-delà de la vie
d'un lab), et le certificat client du kubelet, qui tourne automatiquement sous
/var/lib/kubelet/pki.
ℹ️ À ne pas confondre avec les deux éléments à courte durée de vie qui servent à joindre
un node : le token de bootstrap (24 h) et la clé de certificats (2 h).
kubeadm/node-init.sh régénère les deux à chaque passage de cluster-up.sh — cf.
../DEPANNAGE.md.
🧩 6. containerd et Cilium montent sur leurs propres cycles#
Kubernetes, le runtime de conteneurs et le CNI sont trois trains de versions indépendants.
Les bouger ensemble, c'est transformer une simple montée de version en casse-tête insoluble :
monte une chose à la fois, et vérifie le cluster entre chaque.
containerd.io n'est pas retenu par provision.sh — seuls kubelet, kubeadm et
kubectl le sont. Il bouge donc avec un simple apt upgrade dans une VM, ce qui est
généralement sans conséquence mais redémarre toujours tous les conteneurs de ce node.
⚠️ Le piège de la migration 1.7 → 2.x. La clé de l'image pause a changé de nom et
d'emplacement entre les deux formats de configuration :
v2 (containerd 1.7) : sandbox_image = "…" sous [plugins."io.containerd.grpc.v1.cri"]
v3 (containerd 2.x) : sandbox = '…' sous
[plugins.'io.containerd.cri.v1.images'.pinned_images]
Une config recopiée telle quelle perd le réglage silencieusement. provision.sh contourne le
problème en régénérant /etc/containerd/config.toml depuis containerd config default à
chaque passage et en patchant les deux noms de clé — c'est aussi pour ça qu'il ne faut pas
éditer ce fichier à la main en espérant que ça survive.
Dans l'autre sens, passer de CONTAINERD_SOURCE=docker à debian est une régression vers une
impasse : containerd 1.7 n'implémentera jamais RuntimeConfig et ne peut pas t'emmener
au-delà de la 1.36.
À lancer depuis la racine du dépôt : _k8s/ est le sous-module k8s-playground,
et il se repère tout seul — le lab, c'est le dossier qui contient _k8s/ et porte le
Vagrantfile, donc là où vivent lab.env, _out/ et kubeconfig ; la distribution s'y lit.
LAB_DIR n'est à poser que si on le lance en dehors de cette disposition.
Le script est un helm upgrade --install : c'est donc la même commande qu'on installe ou qu'on
monte de version. Lis d'abord les notes de montée de version de Cilium : un saut de mineure peut
imposer une étape de pré-vol ponctuelle, et ce lab dépend de deux fonctions Cilium qui doivent
continuer de marcher — kubeProxyReplacement (il n'y a aucun kube-proxy sur lequel se
rabattre quand KUBE_PROXY_REPLACEMENT=true) et l'annonce L2 qui donne son IP au Gateway Envoy.
kubectl-nkube-systemexecds/cilium--cilium-dbgstatus--verbose
kubectl-nenvoy-gateway-systemgetsvc# le Gateway doit garder son EXTERNAL-IP
The application layer lives in its own repository
(k8s-playground, mounted here as the _k8s/
submodule) and each of its addons carries its own pitfalls section, on its own site
(Longhorn, Vault, Calico…). This page covers the lab itself: the host, VirtualBox, keepalived,
kubeadm and the Debian nodes.
Unless stated otherwise, every command runs from the repository root, with:
exportKUBECONFIG="$PWD/kubeconfig"
The _k8s/ scripts need nothing else: they locate the lab themselves (the directory that
contains _k8s/ and carries the Vagrantfile) and read the distribution off it. When that
goes wrong, see section 1.
VBoxManage: error: VT-x is being used by another hypervisor (VERR_VMX_IN_VMX_ROOT_MODE).
VBoxManage: error: VirtualBox can't operate in VMX root mode.
Cause. VirtualBox and KVM cannot hold VT-x at the same time. If the KVM kernel module
is loaded — and on most Linux distributions it is loaded at boot — VirtualBox cannot start a
single VM.
# 1. Is KVM loaded? (Intel: kvm_intel — AMD: kvm_amd)
lsmod|grepkvm
# 2. Unload it (fails if a KVM/libvirt VM is still running — stop it first)
sudomodprobe-rkvm_intelkvm# AMD: sudo modprobe -r kvm_amd kvm
💡 KVM comes back on every boot. If this host is never used for KVM/libvirt, blacklist it
once:
VirtualBox refuses the 192.168.56.0/24 host-only network#
VirtualBox 7 only allows host-only addresses that are explicitly permitted. Allow the range:
# /etc/vbox/networks.conf
* 192.168.56.0/21
The whole lab lives in that /24 (nodes, the 192.168.56.5 VIP, the .200–.230
LoadBalancer pool), so nothing works until VirtualBox accepts it.
vagrant up refuses to start on an even number of control planes#
Vagrant-KubeADM: CONTROL_PLANES=2 is EVEN — etcd requires an odd number to hold a
useful quorum (1, 3, 5). With 2 members, losing a single node freezes the API.
This is a guard rail, not a bug. etcd holds quorum at (n/2)+1: two members tolerate
zero failures while costing twice as much as one. Use CONTROL_PLANES=1, 3 or 5 in
lab.env. kubeadm/cluster-up.sh refuses the same value, on purpose — the two checks are
deliberately redundant.
The Vagrantfile also refuses a node IP that collides with 192.168.56.1 (host-only gateway),
.2 (VirtualBox DHCP), .100 (VirtualBox's default host-only DHCP range) or the VIP, and it
refuses two nodes on the same IP. Those errors all name the offending variable
(CP_IP_START/CP_IP_STEP, WK_IP_START/WK_IP_STEP).
_k8s/ is empty, or ./_k8s/platform-up.sh: No such file or directory#
ls_k8s/# nothing, or only an empty directory
./_k8s/platform-up.sh
# bash: ./_k8s/platform-up.sh: No such file or directory
Cause._k8s/ is a git submodule pointing at
k8s-playground — the application layer shared with
the Talos twin lab. A plain git clone records the submodule but does not check it out, so
the directory stays empty.
gitsubmoduleupdate--init--recursive# fills _k8s/
git-C_k8slog--oneline-1# sanity check: there is a commit in there
Cloning correctly in the first place: git clone --recurse-submodules <url>.
⚠️ git pull does not update the submodule either. It only moves this repo; _k8s/
stays on the previously checked-out commit, and you end up running the documented commands
against an older application layer. Re-run git submodule update --init --recursive after
every pull, or git submodule update --remote _k8s to jump to the latest upstream commit.
The _k8s/ scripts find neither lab.env nor the kubeconfig#
Symptoms: the addons install into the wrong domain (lab.example.io instead of your
LAB_DOMAIN), the wrong CNI is chosen, or every kubectl call inside the scripts fails
with connection refused / no configuration has been provided. The banner the scripts print
at start-up shows lab.env: absent (defaults).
Cause. The lab was not located. k8s-playground has no Vagrantfile of its own: it takes
the directory that contains_k8s/ as the lab, provided that directory carries a
Vagrantfile — that is where lab.env, _out/ and kubeconfig live. If the resolution comes
up empty, the scripts run on their built-in defaults, without a kubeconfig. The same walk is
what decides the distribution (kubeadm/cluster-up.sh next to the Vagrantfile = kubeadm lab,
talos/cluster-up.sh = Talos lab), so a lab that is not found also means a distribution that
is not detected.
Check. From the repository root, the three things the walk relies on:
lsVagrantfilelab.envkubeadm/cluster-up.sh# the lab: marker, config, distro signature
ls-d_k8s/lib# _k8s/ really is INSIDE the lab
gitrev-parse--show-toplevel# ... and the lab is this repo, not a copy
Typical causes: _k8s/ cloned on its own somewhere else, a lab.env never created from
lab.env.example, or scripts invoked through a symlink that lands outside the lab.
Escape hatch. Name the lab explicitly — the pointer always wins over detection:
💡 LAB_ENV=/path/to/lab.env does the same job when the file is not named lab.env or not
at the lab's root. LAB_DIR is the one to remember: it drives lab.env, _out/cluster.envand the default KUBECONFIG at once. Neither is needed in the normal submodule layout.
cluster-up.sh fails on "the apiserver does not answer on the VIP"#
- waiting for https://192.168.56.5:6443 ....................... FAILED (600s)
ERROR: the apiserver does not answer on the VIP 192.168.56.5 after 600s.
kubeadm init has already run at that point: the script is waiting for /readyzthrough the
VIP, which is the address every other node will use to join. The script itself lists the two
causes, by frequency.
Cause 1 — keepalived is not carrying the VIP.
vagrantsshk8s-cp1-c"ip -4 addr show | grep 192.168.56.5"
vagrantsshk8s-cp1-c"sudo systemctl status keepalived"
vagrantsshk8s-cp1-c"sudo journalctl -u keepalived -n 50 --no-pager"
What to look for:
Observation
Meaning
ip -4 addr show prints nothing for .5
no node holds the VIP
keepalived.service: failed, Cant find interface in the journal
keepalived was configured on the wrong interface
Entering BACKUP STATE on every control plane
the peers see each other but nobody promotes
The interface is detected, never hard-coded: kubeadm/provision.sh looks for the interface
that carries the node's IP and writes it to /etc/kubeadm-lab/node.env. Check what it found:
vagrantsshk8s-cp1-c"cat /etc/kubeadm-lab/node.env"
vagrantsshk8s-cp1-c"sudo sed -n '/vrrp_instance/,\$p' /etc/keepalived/keepalived.conf"
If HOSTONLY_IF fell back to eth1 while the VM really uses enp0s8, keepalived binds to an
interface that does not exist. Re-run vagrant provision k8s-cp1 once the VM has its host-only
address.
Cause 2 — the apiserver itself does not start.
vagrantsshk8s-cp1-c"sudo crictl ps -a | grep apiserver"
vagrantsshk8s-cp1-c"sudo journalctl -u kubelet -n 50 --no-pager"
vagrantsshk8s-cp1-c"sudo crictl logs \$(sudo crictl ps -a -q --name kube-apiserver | head -1)"
A CrashLoopBackOff apiserver is almost always etcd underneath it: check crictl ps -a | grep etcd and section 5 below. Note that keepalived's health check
(/etc/keepalived/check-apiserver.sh, hitting https://127.0.0.1:6443/livez/ping every 3 s) only
subtracts 30 points from the priority — it never removes the VIP entirely, so the VIP being up
proves nothing about the apiserver.
The VIP is held by TWO nodes at once (VRRP split-brain)#
Symptom.kubectl behaves erratically — one request succeeds, the next times out — and:
fornink8s-cp1k8s-cp2k8s-cp3;doecho-n"$n: ";vagrantssh"$n"-c"ip -4 -o addr show | grep -c 192.168.56.5"---q
done# healthy: exactly one node answers 1, the others 0
The journal shows Entering MASTER STATE on two nodes.
Cause. VRRP here is unicast (unicast_src_ip + unicast_peer), not multicast, because
multicast is the first thing to misbehave on a VirtualBox host-only switch. If a control plane
does not see its peers, it believes it is alone and promotes itself.
# The peer list must contain every OTHER control plane IP
vagrantsshk8s-cp1-c"sudo sed -n '/unicast/,/}/p' /etc/keepalived/keepalived.conf"# The router ID must be IDENTICAL on all control planesfornink8s-cp1k8s-cp2k8s-cp3;dovagrantssh"$n"-c"sudo sed -n 's/.*virtual_router_id //p' /etc/keepalived/keepalived.conf"---q
done
vagrantsshk8s-cp2-c"sudo journalctl -u keepalived -n 80 --no-pager"
Three real causes, in order of likelihood:
A missing unicast_peer block — the node fell back to multicast. This used to happen
when a control plane was provisioned while CONTROL_PLANES was still 1: with no peer to
list, keepalived does not reject the config, it silently reverts to multicast. Growing
the lab afterwards then left cp1 in multicast while cp2/cp3 spoke unicast — and the two
modes are mutually deaf, so both sides believed they were alone and both took the VIP.
provision.sh now always lists the five control-plane IPs the addressing plan allows,
so a config written for one CP is already correct for three. A node still missing the block
was provisioned by an older revision of the repo: vagrant provision <node> rewrites it.
Divergent VRRP_ROUTER_ID — the nodes were provisioned with different lab.env values.
All control planes of one cluster must share the same ID.
Another keepalived lab on the same host-only network using the same ID (default 51).
Two groups with the same virtual_router_id fight over the VIP. Change VRRP_ROUTER_ID in
lab.env, then vagrant provision.
ℹ️ There is no VRRP password on purpose: VRRPv2 authentication sends it in clear text and
buys nothing. The trust boundary is the host-only network; the isolation knob is
VRRP_ROUTER_ID.
NAME STATUS ROLES AGE VERSION
k8s-cp1 NotReady control-plane 2m v1.36.3
k8s-w1 NotReady worker 1m v1.36.3
This is NORMAL between kubeadm/cluster-up.sh and the platform step. kubeadm installs
no CNI, and a node with no pod network never reports Ready. cluster-up.sh says so itself in
its closing banner.
The Ready condition reads False, with reason KubeletNotReady and a message containing:
container runtime network not ready: NetworkReady=false reason:NetworkPluginNotReady
message:Network plugin returns error: cni plugin not initialized
Fix: run the next step.
./_k8s/platform-up.sh
With CNI=none nothing will ever install a network — that is the meaning of the setting, and
cluster-up.sh prints a different closing message in that case.
If the nodes are stillNotReady after the CNI install:
Same cause: there is no CNI yet. Every node carries the node.kubernetes.io/not-ready
taint, which CoreDNS does not tolerate, so the scheduler has nowhere to put it.
kubectl-nkube-systemdescribepod-lk8s-app=kube-dns|sed-n'/Events:/,$p'# FailedScheduling … node(s) had untolerated taint {node.kubernetes.io/not-ready: }
CoreDNS schedules itself as soon as the first node turns Ready. Nothing to fix: run
./_k8s/platform-up.sh. If CoreDNS is still Pendingafter the nodes are
Ready, then look at real scheduling constraints (WORKERS=0 with UNTAINT_CP=false, for
instance, leaves nowhere to schedule).
Cause. Each VM has two NICs: NIC1 = VirtualBox NAT (always 10.0.2.15, identical on
every VM) and NIC2 = host-only (the real cluster address). Without
kubeletExtraArgs: node-ip, the kubelet picks the default-route interface — the NAT one — and
every node registers with the same address. kubectl get nodes looks plausible, but logs,
kubectl exec, probes and cross-node traffic all go to the wrong place.
This lab sets node-ip in all three kubeadm templates, so you only hit this if a node was
joined by hand with the printed kubeadm join line — that line cannot carry node-ip, and
kubeadm join has no equivalent flag. That is precisely why the lab joins nodes through
JoinConfiguration files.
# What the kubelet was actually given
vagrantsshk8s-w1-c"cat /var/lib/kubelet/kubeadm-flags.env"# expected: KUBELET_KUBEADM_ARGS="… --node-ip=192.168.56.101 …"
Fix. The supported path is to redo the join through the repo:
To repair a single node without touching the rest: edit
/var/lib/kubelet/kubeadm-flags.env to add --node-ip=<host-only IP>, then
sudo systemctl restart kubelet. If INTERNAL-IP does not change, delete the Node object
(kubectl delete node k8s-w1) so the kubelet re-registers from scratch.
kubeadm join fails on an expired token or an invalid certificate key#
Three distinct messages, three lifetimes:
Message (excerpt)
What expired
Lifetime
couldn't validate the identity of the API Server: could not find a JWS signature in the cluster-info ConfigMap for token ID
the bootstrap token
24 h
error downloading certs: … Secret "kubeadm-certs" was not found in the "kube-system" Namespace
the certificate key (the Secret is garbage-collected with it)
Cause.kubeadm init prints a token valid for 24 hours and a certificate key valid for
two hours only. Any join attempt after that window fails with an opaque error.
Fix — the easy one. Re-run the bootstrap script: it is idempotent, and
kubeadm/node-init.shregenerates both elements on every run before rewriting
_out/join.env. Joining a node hours or days after the initial init is therefore a supported
path.
./kubeadm/cluster-up.sh
Fix — by hand, if you are driving kubeadm yourself:
vagrantsshk8s-cp1-c"sudo kubeadm init phase upload-certs --upload-certs \\ --config /vagrant/_out/kubeadm-init.yaml"# new certificate key
vagrantsshk8s-cp1-c"sudo kubeadm token create --print-join-command"# new token + CA hash
Both commands are safe to replay on a running cluster.
error execution phase upload-certs: could not bootstrap the admin user in file admin.conf:
unable to create ClusterRoleBinding: Post "https://10.0.2.15:6443/apis/rbac.authorization.k8s.io/v1/…":
tls: failed to verify certificate: x509: certificate is valid for 10.96.0.1, 192.168.56.10,
192.168.56.5, …, 127.0.0.1, not 10.0.2.15
Symptom.kubeadm init succeeds, the join command is printed, then cluster-up.sh dies on
"unable to extract the join material".
Cause.kubeadm init phase upload-certs builds its API client from
InitConfiguration.LocalAPIEndpoint.AdvertiseAddress — not from controlPlaneEndpoint, and
not from admin.conf. Run without --config, kubeadm defaults that address by detecting the
default route, which in any Vagrant VM leaves through the NAT NIC: 10.0.2.15, identical on
every machine. The client then dials an address that is legitimately absent from the certificate
SANs, and TLS verification fails.
This is the node-ip trap of §9 in another disguise.
Fix. Already applied: node-init.sh passes --config so the endpoint is the node's real
host-only IP. If you hit this, your checkout predates the fix — git pull, then re-run
./kubeadm/cluster-up.sh (it skips the completed init and only redoes what is missing).
⚠️ Do not "fix" this by adding 10.0.2.15 to certSANs. That address is shared by every
VM in the lab, so it identifies no node at all; you would be papering over a client aimed at
the wrong interface. The endpoint is what must be corrected, never the certificate.
kubeadm preflight complains about swap, CPU count or memory#
[ERROR Swap]: swap is enabled; production deployments should disable swap …
[ERROR NumCPU]: the number of available CPUs 1 is less than the required 2
[ERROR Mem]: the system RAM (1024 MB) is less than the minimum 1700 MB
Swap.kubeadm/provision.sh already handles it: swapoff -a, commenting out the swap line
in /etc/fstab, and masking any systemd swap unit (Debian 13 can provide swap through a
unit that /etc/fstab never mentions — that is how swap comes back after a reboot). If the
error shows up anyway, provisioning did not run or did not finish:
ℹ️ NodeSwap is GA since 1.34, but failSwapOn still defaults to true: the kubelet refuses
to start with swap on until you configure it explicitly. On a lab, disabling swap is the
shortest and best-tested path.
CPU and memory. The thresholds are kubeadm's own: 2 vCPU and ~1700 MiB on a control
plane. The repo defaults (CP_MEM=3072, CP_CPU=2, WK_MEM=2048, WK_CPU=2) clear them —
this only bites after lowering them in lab.env. 2048 boots but leaves a stacked etcd about
350 MiB of headroom; _k8s/observability/ wants 4096.
# after editing lab.env, resources only change on a VM restart
vagrantreloadk8s-cp1
A preflight warning about RuntimeConfig or the cgroup driver#
A warning (not an error) telling you kubeadm could not read the cgroup driver from the
container runtime and is falling back to the cgroupDriver field of KubeletConfiguration.
Cause. Only containerd 2.x implements the CRI RuntimeConfig method that kubeadm uses
to ask the runtime which cgroup driver it uses. Debian 13 ships containerd 1.7.24, which
never will: the backport was refused upstream (containerd#11346, closed without merge).
vagrantsshk8s-cp1-c"containerd --version"
vagrantsshk8s-cp1-c"sudo grep SystemdCgroup /etc/containerd/config.toml"# must be true
With CONTAINERD_SOURCE=docker (the default) you get containerd 2.x from the Docker
repository and the warning disappears.
With CONTAINERD_SOURCE=debian you get containerd 1.7 and the warning is expected. It is
harmless in 1.36, because the cgroupDriver fallback still exists and the lab sets it to
systemd. That fallback is removed in 1.37 — the same setup then fails instead of
warning, and the cgroupDriver field itself goes away in 1.38. CONTAINERD_SOURCE=debian is
for an offline lab, and it is a dead end for upgrades.
⚠️ What really matters is SystemdCgroup = true in /etc/containerd/config.toml. Debian 13
is cgroup v2 with systemd as the manager; leaving containerd on cgroupfs makes two managers
fight over the same hierarchy and the nodes go unstable under load.
After a cluster-reset.sh, the pod network behaves inexplicably#
Symptoms. Pods get IPs but cross-node traffic dies; DNS fails while ping 1.1.1.1 works;
the Cilium agent complains about pre-existing BPF maps or a datapath it did not create.
Cause.kubeadm reset deliberately leaves behind what it did not lay down: CNI interfaces,
pinned eBPF programs, and kube-proxy's iptables rules. A later kubeadm init then inherits
a ghost datapath.
kubeadm/node-reset.sh is the cleanup, and cluster-reset.sh runs it on every node. It:
removes /etc/cni/net.d/*;
deletes cilium_host, cilium_net, cilium_vxlan, flannel.1, cni0, vxlan.calico,
kube-ipvs0, plus every lxc* and cali* interface;
removes the pinned eBPF programs under /sys/fs/bpf/tc/globals/cilium_*;
flushes the KUBE-/CILIUM_/cali- iptables chains and clears IPVS;
wipes /var/lib/etcd, /var/lib/cni, /run/flannel and restarts containerd.
Check by hand what is left on a suspect node:
vagrantsshk8s-w1-c"ip -o link show | grep -E 'cilium|lxc|flannel|cali|cni0'"
vagrantsshk8s-w1-c"sudo ls /sys/fs/bpf/tc/globals/ 2>/dev/null"
vagrantsshk8s-w1-c"sudo iptables-save | grep -cE 'KUBE-|CILIUM_|cali-'"
vagrantsshk8s-w1-c"ls -la /etc/cni/net.d/"
Anything non-empty on a supposedly reset node means the cleanup did not complete — the script
prints partial reset on <node> — carrying on and carries on rather than stopping. Re-run it on
that node alone:
If in doubt, vagrant destroy -f && vagrant up is the guaranteed clean slate — a fresh VM has
no residue by construction.
ℹ️ cluster-reset.sh is also the correct tool when you want to change POD_CIDR,
SERVICE_CIDR, the CNI or the VIP: all four are frozen at kubeadm init time and cannot be
changed on a live cluster.
kubectl-nenvoy-gateway-systemgetsvc
# TYPE EXTERNAL-IP …# LoadBalancer <pending> …
Cause 1 — the CNI is not Cilium. In this lab only Cilium hands out Service IPs (L2/ARP
announcement). Calico can only do it over BGP, and there is no peer router on a host-only
network (MetalLB required); flannel and none do nothing at all. platform-up.sh
says so explicitly when it runs, and it strips the Cilium-specific
loadBalancerClass: io.cilium/l2-announcer so another announcer could take over.
sed-n's/^CNI=//p'_out/cluster.env# what the cluster was actually built with
⚠️ _out/cluster.env is the truth (written at bootstrap); lab.env is only an intent and
may have been edited afterwards.
Cause 2 — the L2 pool is missing, exhausted or announced on the wrong interface.
The pool is 192.168.56.200–.230 by default (LB_POOL_START/LB_POOL_END), and the
announcement interface comes from the detectedHOSTONLY_IF, not a hard-coded name. A pool
that overlaps the node range, or an announcement policy pinned to an interface that does not
exist, both produce a permanent <pending>.
fsync latency. etcd commits every write to disk before acknowledging it. On VirtualBox,
a VM disk on a spinning drive — or on an SSD already saturated by the host — pushes fsync
past etcd's tolerance and the leader election starts flapping. Keep the VM disks on an
SSD, and do not run a 3-control-plane topology next to a heavy build.
CP_MEM too low. A stacked etcd on a 2048 MiB control plane has roughly 350 MiB of
headroom; the first _k8s/ addons eat it. 3072 is the real floor,
_k8s/observability/ wants 4096.
Clock drift. etcd is very sensitive to it. The Vagrantfile already lowers the guest
additions' time-sync threshold to 1000 ms, which covers a suspend/resume cycle — but a VM
left suspended for a long time is better off reloaded:
vagrantreloadk8s-cp1
⚠️ With 3 control planes, etcd tolerates one failure. Do not stop two of them at the same
time (including during an upgrade — see kubeadm/UPGRADE.md): the API
freezes until quorum is back.
vagrantstatus# which VMs exist and are running
vagrantsshk8s-cp1# interactive shell
vagrantsshk8s-cp1-c"<command>"---q-oLogLevel=ERROR# one shot, quiet (what the scripts use)
vagrantprovisionk8s-cp1# replay provision.sh (idempotent)
vagrantreloadk8s-cp1# restart, applying new CPU/RAM from lab.envexportKUBECONFIG="$PWD/kubeconfig"
kubectlgetnodes-owide
kubectlgetpods-A-owide
kubectlgetevents-A--sort-by=.lastTimestamp|tail-30
kubectlget--raw='/readyz?verbose'
cat_out/cluster.env# what the cluster was REALLY built with
⚠️ _out/join.env holds the join token and the certificate key. _out/ is gitignored,
but it is readable by every VM through the /vagrant synced folder. Never paste its contents
anywhere.
cat/etc/kubeadm-lab/node.env# role, node IP, detected host-only interface
ip-4addrshow# is the VIP here?
sudosystemctlstatuskubeletcontainerdkeepalived
sudojournalctl-ukubelet-f# follow live
sudojournalctl-ukubelet-n100--no-pager
sudojournalctl-ucontainerd-n50--no-pager
sudojournalctl-ukeepalived-n50--no-pager
sudocrictlps-a# containers, including dead ones
sudocrictlpods# sandboxes
sudocrictllogs<container-id>
sudocrictlimages
sudokubeadmcertscheck-expiration# control planes only
sudokubeadmconfigimageslist--kubernetes-versionv1.36.3
💡 crictl talks to the same socket as the kubelet thanks to /etc/crictl.yaml, written by
provision.sh. Without it, crictl goes looking for dockershim and prints confusing errors.
The nuclear options, from least to most destructive#
Command
What it destroys
When
vagrant provision <node>
nothing
re-apply system prerequisites
./kubeadm/cluster-up.sh
nothing (idempotent)
replay a partial bootstrap, add nodes
./kubeadm/cluster-reset.sh
etcd, certificates, every workload — keeps the VMs
La couche applicative vit dans son propre dépôt
(k8s-playground, monté ici comme sous-module
_k8s/) et chacun de ses addons porte ses propres pièges, sur son propre site (Longhorn,
Vault, Calico…). Cette page couvre le lab lui-même : l'hôte, VirtualBox, keepalived, kubeadm et
les nodes Debian.
Sauf mention contraire, toutes les commandes se lancent depuis la racine du dépôt, avec :
exportKUBECONFIG="$PWD/kubeconfig"
Les scripts _k8s/ n'ont besoin de rien d'autre : ils localisent le lab tout seuls (le dossier
qui contient _k8s/ et porte le Vagrantfile) et y lisent la distribution. Quand ça se passe
mal, voir la section 1.
VBoxManage: error: VT-x is being used by another hypervisor (VERR_VMX_IN_VMX_ROOT_MODE).
VBoxManage: error: VirtualBox can't operate in VMX root mode.
Cause. VirtualBox et KVM ne peuvent pas tenir VT-x en même temps. Si le module noyau
KVM est chargé — et sur la plupart des distributions Linux il l'est dès le démarrage —
VirtualBox ne peut lancer aucune VM.
# 1. KVM est-il chargé ? (Intel : kvm_intel — AMD : kvm_amd)
lsmod|grepkvm
# 2. Le décharger (échoue si une VM KVM/libvirt tourne encore — l'arrêter d'abord)
sudomodprobe-rkvm_intelkvm# AMD : sudo modprobe -r kvm_amd kvm
💡 KVM revient à chaque démarrage. Si cet hôte ne sert jamais à KVM/libvirt, blackliste-le
une bonne fois :
Pour revenir en arrière : supprimer ce fichier et redémarrer.
VirtualBox refuse le réseau host-only 192.168.56.0/24#
VirtualBox 7 n'autorise que les adresses host-only explicitement permises. Autoriser la plage :
# /etc/vbox/networks.conf
* 192.168.56.0/21
Tout le lab vit dans ce /24 (nodes, VIP 192.168.56.5, plage LoadBalancer .200–.230) :
rien ne fonctionne tant que VirtualBox ne l'accepte pas.
vagrant up refuse un nombre pair de control planes#
Vagrant-KubeADM: CONTROL_PLANES=2 is EVEN — etcd requires an odd number to hold a
useful quorum (1, 3, 5). With 2 members, losing a single node freezes the API.
C'est un garde-fou, pas un bug. etcd tient le quorum à (n/2)+1 : deux membres ne tolèrent
aucune panne tout en coûtant deux fois plus cher qu'un seul. Mets CONTROL_PLANES=1, 3 ou
5 dans lab.env. kubeadm/cluster-up.sh refuse la même valeur, volontairement — les deux
contrôles sont redondants exprès.
Le Vagrantfile refuse aussi une IP de node qui tombe sur 192.168.56.1 (passerelle
host-only), .2 (DHCP VirtualBox), .100 (plage DHCP host-only par défaut de VirtualBox) ou la
VIP, ainsi que deux nodes sur la même IP. Ces erreurs nomment toutes la variable en cause
(CP_IP_START/CP_IP_STEP, WK_IP_START/WK_IP_STEP).
_k8s/ est vide, ou ./_k8s/platform-up.sh: No such file or directory#
ls_k8s/# rien, ou un dossier vide
./_k8s/platform-up.sh
# bash: ./_k8s/platform-up.sh: No such file or directory
Cause._k8s/ est un sous-module git qui pointe sur
k8s-playground — la couche applicative partagée
avec le lab jumeau Talos. Un git clone simple enregistre le sous-module mais ne le sort
pas : le dossier reste vide.
gitsubmoduleupdate--init--recursive# remplit _k8s/
git-C_k8slog--oneline-1# vérification : il y a bien un commit dedans
Pour cloner correctement dès le départ : git clone --recurse-submodules <url>.
⚠️ Un git pull ne met pas le sous-module à jour non plus. Il ne déplace que ce dépôt ;
_k8s/ reste sur le commit sorti précédemment, et on exécute alors les commandes documentées
contre une couche applicative plus ancienne. Relancer git submodule update --init --recursive après chaque pull, ou git submodule update --remote _k8s pour sauter au
dernier commit amont.
Les scripts _k8s/ ne trouvent ni lab.env ni le kubeconfig#
Symptômes : les addons s'installent sur le mauvais domaine (lab.example.io au lieu de ton
LAB_DOMAIN), le mauvais CNI est retenu, ou chaque appel kubectl interne aux scripts
échoue en connection refused / no configuration has been provided. La bannière affichée au
démarrage des scripts indique lab.env : absent (défauts).
Cause. Le lab n'a pas été localisé. k8s-playground n'a pas de Vagrantfile à lui : il
prend pour lab le dossier qui contient_k8s/, à condition que ce dossier porte un
Vagrantfile — c'est là que vivent lab.env, _out/ et kubeconfig. Si la résolution ne
donne rien, les scripts tournent sur leurs valeurs par défaut, et sans kubeconfig. C'est le
même parcours qui tranche la distribution (kubeadm/cluster-up.sh à côté du Vagrantfile =
lab kubeadm, talos/cluster-up.sh = lab Talos) : un lab non trouvé, c'est aussi une
distribution non détectée.
Vérifier. Depuis la racine du dépôt, les trois appuis du parcours :
lsVagrantfilelab.envkubeadm/cluster-up.sh# le lab : marqueur, config, signature distro
ls-d_k8s/lib# _k8s/ est bien DANS le lab
gitrev-parse--show-toplevel# ... et le lab est ce dépôt, pas une copie
Causes typiques : _k8s/ cloné tout seul ailleurs, un lab.env jamais créé depuis
lab.env.example, ou des scripts appelés via un lien symbolique qui sort du lab.
Échappatoire. Nommer le lab explicitement — le pointeur prime toujours sur la détection :
💡 LAB_ENV=/chemin/vers/lab.env rend le même service quand le fichier ne s'appelle pas
lab.env ou ne vit pas à la racine du lab. LAB_DIR est celle à retenir : elle pilote
lab.env, _out/cluster.envet le KUBECONFIG par défaut d'un coup. Ni l'une ni l'autre
n'est utile dans la disposition en sous-module.
cluster-up.sh échoue sur « the apiserver does not answer on the VIP »#
- waiting for https://192.168.56.5:6443 ....................... FAILED (600s)
ERROR: the apiserver does not answer on the VIP 192.168.56.5 after 600s.
À ce stade kubeadm init a déjà tourné : le script attend /readyzà travers la VIP, qui
est l'adresse que tous les autres nodes utiliseront pour rejoindre. Le script liste lui-même les
deux causes, par fréquence.
Cause 1 — keepalived ne porte pas la VIP.
vagrantsshk8s-cp1-c"ip -4 addr show | grep 192.168.56.5"
vagrantsshk8s-cp1-c"sudo systemctl status keepalived"
vagrantsshk8s-cp1-c"sudo journalctl -u keepalived -n 50 --no-pager"
Ce qu'il faut regarder :
Observation
Signification
ip -4 addr show n'affiche rien pour .5
aucun node ne porte la VIP
keepalived.service: failed, Cant find interface dans le journal
keepalived a été configuré sur la mauvaise interface
Entering BACKUP STATE sur tous les control planes
les pairs se voient mais personne ne promeut
L'interface est détectée, jamais codée en dur : kubeadm/provision.sh cherche celle qui
porte l'IP du node et l'écrit dans /etc/kubeadm-lab/node.env. Vérifie ce qu'il a trouvé :
vagrantsshk8s-cp1-c"cat /etc/kubeadm-lab/node.env"
vagrantsshk8s-cp1-c"sudo sed -n '/vrrp_instance/,\$p' /etc/keepalived/keepalived.conf"
Si HOSTONLY_IF est retombé sur eth1 alors que la VM utilise réellement enp0s8, keepalived
s'accroche à une interface inexistante. Relance vagrant provision k8s-cp1 une fois que la VM a
bien son adresse host-only.
Cause 2 — l'apiserver lui-même ne démarre pas.
vagrantsshk8s-cp1-c"sudo crictl ps -a | grep apiserver"
vagrantsshk8s-cp1-c"sudo journalctl -u kubelet -n 50 --no-pager"
vagrantsshk8s-cp1-c"sudo crictl logs \$(sudo crictl ps -a -q --name kube-apiserver | head -1)"
Un apiserver en CrashLoopBackOff, c'est presque toujours etcd en dessous : regarde
crictl ps -a | grep etcd et la section 5 plus bas. À noter : le test de santé de keepalived
(/etc/keepalived/check-apiserver.sh, qui interroge https://127.0.0.1:6443/livez/ping toutes les
3 s) ne retire que 30 points de priorité — il ne retire jamais la VIP. La VIP présente ne prouve
donc rien sur l'apiserver.
La VIP est portée par DEUX nodes à la fois (split-brain VRRP)#
Symptôme.kubectl se comporte de façon erratique — une requête passe, la suivante part en
timeout — et :
fornink8s-cp1k8s-cp2k8s-cp3;doecho-n"$n : ";vagrantssh"$n"-c"ip -4 -o addr show | grep -c 192.168.56.5"---q
done# sain : exactement un node répond 1, les autres 0
Le journal affiche Entering MASTER STATE sur deux nodes.
Cause. Le VRRP est ici en unicast (unicast_src_ip + unicast_peer), pas en multicast,
parce que le multicast est la première chose à se comporter bizarrement sur un switch host-only
VirtualBox. Un control plane qui ne voit pas ses pairs se croit seul et se promeut.
# La liste de pairs doit contenir toutes les AUTRES IP de control plane
vagrantsshk8s-cp1-c"sudo sed -n '/unicast/,/}/p' /etc/keepalived/keepalived.conf"# Le router ID doit être IDENTIQUE sur tous les control planesfornink8s-cp1k8s-cp2k8s-cp3;dovagrantssh"$n"-c"sudo sed -n 's/.*virtual_router_id //p' /etc/keepalived/keepalived.conf"---q
done
vagrantsshk8s-cp2-c"sudo journalctl -u keepalived -n 80 --no-pager"
Trois causes réelles, par ordre de probabilité :
Bloc unicast_peer absent — le node est retombé en multicast. Cela arrivait quand un
control plane était provisionné alors que CONTROL_PLANES valait encore 1 : sans aucun
pair à lister, keepalived ne refuse pas la configuration, il repasse silencieusement en
multicast. Agrandir le lab ensuite laissait donc cp1 en multicast pendant que cp2/cp3
parlaient unicast — et les deux modes sont mutuellement sourds, si bien que chaque camp se
croyait seul et prenait la VIP.
provision.sh liste désormais toujours les cinq IP de control plane que permet le plan
d'adressage : une configuration écrite pour un seul CP est déjà la bonne pour trois. Un node
à qui il manque encore le bloc a été provisionné par une révision antérieure du dépôt :
vagrant provision <node> la réécrit.
VRRP_ROUTER_ID divergent — les nodes ont été provisionnés avec des lab.env
différents. Tous les control planes d'un même cluster doivent partager le même ID.
Un autre lab keepalived sur le même réseau host-only avec le même ID (défaut 51). Deux
groupes de même virtual_router_id se battent pour la VIP. Change VRRP_ROUTER_ID dans
lab.env, puis vagrant provision.
ℹ️ Il n'y a aucun mot de passe VRRP, et c'est volontaire : l'authentification VRRPv2 le
transmet en clair et n'apporte rien. La frontière de confiance est le réseau host-only ; le
bouton d'isolation, c'est VRRP_ROUTER_ID.
NAME STATUS ROLES AGE VERSION
k8s-cp1 NotReady control-plane 2m v1.36.3
k8s-w1 NotReady worker 1m v1.36.3
C'est NORMAL entre kubeadm/cluster-up.sh et l'étape plateforme. kubeadm n'installe
aucun CNI, et un node sans réseau pod ne passe jamais Ready. cluster-up.sh le dit lui-même
dans sa bannière de fin.
Confirme le diagnostic plutôt que de le supposer :
Même cause : il n'y a pas encore de CNI. Tous les nodes portent le taint
node.kubernetes.io/not-ready, que CoreDNS ne tolère pas : le scheduler n'a nulle part où le
poser.
kubectl-nkube-systemdescribepod-lk8s-app=kube-dns|sed-n'/Events:/,$p'# FailedScheduling … node(s) had untolerated taint {node.kubernetes.io/not-ready: }
CoreDNS se planifie dès que le premier node passe Ready. Rien à corriger : lance
./_k8s/platform-up.sh. Si CoreDNS reste Pendingaprès que les nodes soient
Ready,
cherche alors de vraies contraintes de placement (WORKERS=0 avec UNTAINT_CP=false, par
exemple, ne laisse aucun endroit où planifier).
Cause. Chaque VM a deux cartes : NIC1 = NAT VirtualBox (toujours 10.0.2.15,
identique sur toutes les VM) et NIC2 = host-only (la vraie adresse du cluster). Sans
kubeletExtraArgs: node-ip, le kubelet prend l'interface de la route par défaut — la NAT — et
tous les nodes s'enregistrent avec la même adresse. kubectl get nodes a l'air correct, mais
les logs, kubectl exec, les probes et le trafic inter-node partent au mauvais endroit.
Ce lab pose node-ip dans les trois modèles kubeadm : tu ne rencontres donc ce cas que si un
node a été joint à la main avec la ligne kubeadm join imprimée — cette ligne ne sait pas
transporter node-ip, et kubeadm join n'a pas de drapeau équivalent. C'est exactement pour ça
que le lab joint ses nodes par des fichiers JoinConfiguration.
# Ce que le kubelet a réellement reçu
vagrantsshk8s-w1-c"cat /var/lib/kubelet/kubeadm-flags.env"# attendu : KUBELET_KUBEADM_ARGS="… --node-ip=192.168.56.101 …"
Correctif. Le chemin supporté est de refaire la jonction par le dépôt :
Pour réparer un seul node sans toucher au reste : édite
/var/lib/kubelet/kubeadm-flags.env pour y ajouter --node-ip=<IP host-only>, puis
sudo systemctl restart kubelet. Si INTERNAL-IP ne change pas, supprime l'objet Node
(kubectl delete node k8s-w1) pour que le kubelet se réenregistre de zéro.
kubeadm join échoue sur un token expiré ou une clé de certificats invalide#
Trois messages distincts, trois durées de vie :
Message (extrait)
Ce qui a expiré
Durée de vie
couldn't validate the identity of the API Server: could not find a JWS signature in the cluster-info ConfigMap for token ID
le token de bootstrap
24 h
error downloading certs: … Secret "kubeadm-certs" was not found in the "kube-system" Namespace
la clé de certificats (le Secret disparaît avec elle)
2 h
error decoding certificate key / échec de déchiffrement
la clé de certificats ne correspond pas au Secret
2 h
Cause.kubeadm init imprime un token valable 24 heures et une clé de certificats valable
deux heures seulement. Toute jonction après cette fenêtre échoue avec une erreur opaque.
Correctif — le simple. Relance le script de bootstrap : il est idempotent, et
kubeadm/node-init.shrégénère systématiquement les deux éléments avant de réécrire
_out/join.env. Joindre un node des heures ou des jours après le init initial est donc un
chemin supporté.
./kubeadm/cluster-up.sh
Correctif — à la main, si tu pilotes kubeadm toi-même :
vagrantsshk8s-cp1-c"sudo kubeadm init phase upload-certs --upload-certs \\ --config /vagrant/_out/kubeadm-init.yaml"# nouvelle clé de certificats
vagrantsshk8s-cp1-c"sudo kubeadm token create --print-join-command"# nouveau token + empreinte CA
Les deux commandes sont rejouables sans risque sur un cluster en route.
error execution phase upload-certs: could not bootstrap the admin user in file admin.conf:
unable to create ClusterRoleBinding: Post "https://10.0.2.15:6443/apis/rbac.authorization.k8s.io/v1/…":
tls: failed to verify certificate: x509: certificate is valid for 10.96.0.1, 192.168.56.10,
192.168.56.5, …, 127.0.0.1, not 10.0.2.15
Symptôme.kubeadm init réussit, la commande de jonction s'affiche, puis cluster-up.sh
meurt sur « unable to extract the join material ».
Cause.kubeadm init phase upload-certs construit son client API à partir de
InitConfiguration.LocalAPIEndpoint.AdvertiseAddress — et non du controlPlaneEndpoint, ni
de admin.conf. Lancée sans --config, la commande applique les défauts de kubeadm, qui
détectent cette adresse depuis la route par défaut : dans une VM Vagrant, celle-ci sort par la
carte NAT, soit 10.0.2.15, la même sur toutes les machines. Le client vise donc une adresse
légitimement absente des SAN du certificat, et la vérification TLS échoue.
C'est le piège node-ip du §9, sous un autre déguisement.
Correctif. Déjà appliqué : node-init.sh passe --config, si bien que l'endpoint est la
vraie IP host-only du node. Si tu rencontres cette erreur, ta copie est antérieure au correctif —
git pull, puis relance ./kubeadm/cluster-up.sh (il saute le init déjà fait et ne refait
que ce qui manque).
⚠️ Ne « corrige » surtout pas en ajoutant 10.0.2.15 aux certSANs. Cette adresse est
partagée par toutes les VM du lab : elle n'identifie aucun node. Ce serait masquer un client
pointé sur la mauvaise interface. C'est l'endpoint qu'il faut corriger, jamais le certificat.
Le preflight kubeadm se plaint du swap, du nombre de CPU ou de la mémoire#
[ERROR Swap]: swap is enabled; production deployments should disable swap …
[ERROR NumCPU]: the number of available CPUs 1 is less than the required 2
[ERROR Mem]: the system RAM (1024 MB) is less than the minimum 1700 MB
Swap.kubeadm/provision.sh s'en occupe déjà : swapoff -a, mise en commentaire de la
ligne de swap dans /etc/fstab, et masquage de toute unité systemd de swap (Debian 13 peut
fournir du swap par une unité que /etc/fstab ne décrit pas — c'est comme ça qu'il revient
après un reboot). Si l'erreur apparaît quand même, le provisioning n'a pas tourné, ou pas fini :
ℹ️ NodeSwap est GA depuis 1.34, mais failSwapOn vaut toujours true par défaut : le kubelet
refuse de démarrer avec du swap actif tant qu'on ne le configure pas explicitement. Sur un
lab, couper le swap est le chemin le plus court et le mieux testé.
CPU et mémoire. Les seuils sont ceux de kubeadm : 2 vCPU et ~1700 Mio sur un control
plane. Les défauts du dépôt (CP_MEM=3072, CP_CPU=2, WK_MEM=2048, WK_CPU=2) les
franchissent — on ne tombe donc là-dessus qu'après les avoir baissés dans lab.env. 2048
démarre mais ne laisse qu'environ 350 Mio de marge à un etcd empilé ; _k8s/observability/
réclame 4096.
# après édition de lab.env, les ressources ne changent qu'au redémarrage de la VM
vagrantreloadk8s-cp1
Un avertissement de preflight sur RuntimeConfig ou le cgroup driver#
Un avertissement (pas une erreur) t'annonçant que kubeadm n'a pas pu lire le cgroup driver
auprès du runtime de conteneurs et se rabat sur le champ cgroupDriver de
KubeletConfiguration.
Cause. Seule la branche containerd 2.x implémente la méthode CRI RuntimeConfig dont
kubeadm se sert pour demander au runtime quel cgroup driver il utilise. Debian 13 fournit
containerd 1.7.24, qui ne l'aura jamais : le backport a été refusé en amont
(containerd#11346, fermé sans merge).
vagrantsshk8s-cp1-c"containerd --version"
vagrantsshk8s-cp1-c"sudo grep SystemdCgroup /etc/containerd/config.toml"# doit valoir true
Avec CONTAINERD_SOURCE=docker (le défaut), tu obtiens containerd 2.x du dépôt Docker et
l'avertissement disparaît.
Avec CONTAINERD_SOURCE=debian, tu obtiens containerd 1.7 et l'avertissement est attendu. Il
est sans conséquence en 1.36, parce que le repli cgroupDriver existe encore et que le
lab le règle sur systemd. Ce repli disparaît en 1.37 — le même montage échoue alors au
lieu d'avertir — et le champ cgroupDriver lui-même est retiré en 1.38.
CONTAINERD_SOURCE=debian est fait pour un lab hors-ligne, et c'est une impasse pour les
montées de version.
⚠️ Ce qui compte vraiment, c'est SystemdCgroup = true dans /etc/containerd/config.toml.
Debian 13 est en cgroup v2 avec systemd comme gestionnaire ; laisser containerd sur
cgroupfs, c'est deux gestionnaires qui se disputent la même hiérarchie et des nodes
instables sous charge.
Après un cluster-reset.sh, le réseau pod se comporte de façon inexplicable#
Symptômes. Les pods obtiennent des IP mais le trafic inter-node meurt ; le DNS échoue alors
que ping 1.1.1.1 marche ; l'agent Cilium se plaint de maps BPF préexistantes ou d'un datapath
qu'il n'a pas créé.
Cause.kubeadm reset laisse volontairement derrière lui ce qu'il n'a pas posé : les
interfaces CNI, les programmes eBPF épinglés, et les règles iptables de kube-proxy. Un
kubeadm init suivant hérite alors d'un datapath fantôme.
kubeadm/node-reset.sh est ce ménage, et cluster-reset.sh le lance sur chaque node. Il :
supprime /etc/cni/net.d/* ;
détruit cilium_host, cilium_net, cilium_vxlan, flannel.1, cni0, vxlan.calico,
kube-ipvs0, plus toutes les interfaces lxc* et cali* ;
retire les programmes eBPF épinglés sous /sys/fs/bpf/tc/globals/cilium_* ;
vide les chaînes iptables KUBE-/CILIUM_/cali- et purge IPVS ;
efface /var/lib/etcd, /var/lib/cni, /run/flannel et redémarre containerd.
Vérifie à la main ce qui reste sur un node suspect :
vagrantsshk8s-w1-c"ip -o link show | grep -E 'cilium|lxc|flannel|cali|cni0'"
vagrantsshk8s-w1-c"sudo ls /sys/fs/bpf/tc/globals/ 2>/dev/null"
vagrantsshk8s-w1-c"sudo iptables-save | grep -cE 'KUBE-|CILIUM_|cali-'"
vagrantsshk8s-w1-c"ls -la /etc/cni/net.d/"
Quoi que ce soit de non vide sur un node censé être remis à zéro signifie que le ménage n'est
pas allé au bout — le script affiche partial reset on <node> — carrying on et continue au lieu
de s'arrêter. Relance-le sur ce node seul :
Dans le doute, vagrant destroy -f && vagrant up est la table rase garantie : une VM neuve n'a
aucun résidu par construction.
ℹ️ cluster-reset.sh est aussi le bon outil quand on veut changer POD_CIDR,
SERVICE_CIDR, le CNI ou la VIP : tous les quatre sont figés au kubeadm init et ne se
changent pas sur un cluster en route.
kubectl-nenvoy-gateway-systemgetsvc
# TYPE EXTERNAL-IP …# LoadBalancer <pending> …
Cause 1 — le CNI n'est pas Cilium. Dans ce lab, seul Cilium distribue des IP de Service
(annonce L2/ARP). Calico ne sait le faire qu'en BGP, et il n'y a aucun routeur pair sur un
réseau host-only (MetalLB requis) ; flannel et none ne font rien du tout.
platform-up.sh le dit explicitement à l'exécution, et il retire le
loadBalancerClass: io.cilium/l2-announcer propre à Cilium pour qu'un autre annonceur puisse
prendre le relais.
sed-n's/^CNI=//p'_out/cluster.env# avec quoi le cluster a RÉELLEMENT été monté
⚠️ _out/cluster.env dit la vérité (écrit au bootstrap) ; lab.env n'exprime qu'une
intention et a pu être édité après coup.
Cause 2 — le pool L2 est absent, épuisé, ou annoncé sur la mauvaise interface.
Le pool va de 192.168.56.200 à .230 par défaut (LB_POOL_START/LB_POOL_END), et
l'interface d'annonce vient du HOSTONLY_IFdétecté, pas d'un nom codé en dur. Un pool qui
chevauche la plage des nodes, ou une politique d'annonce épinglée sur une interface
inexistante, produisent tous deux un <pending> définitif.
Latence de fsync. etcd écrit chaque transaction sur disque avant de l'acquitter. Sur
VirtualBox, un disque de VM sur un plateau mécanique — ou sur un SSD déjà saturé par l'hôte —
pousse le fsync au-delà de ce qu'etcd tolère, et l'élection de leader se met à osciller.
Garde les disques des VM sur un SSD, et ne lance pas une topologie à 3 control planes à
côté d'un gros build.
CP_MEM trop bas. Un etcd empilé sur un control plane à 2048 Mio dispose d'environ
350 Mio de marge ; les premiers addons _k8s/ la mangent. 3072 est le vrai plancher,
_k8s/observability/ réclame 4096.
Dérive d'horloge. etcd y est très sensible. Le Vagrantfile abaisse déjà le seuil de
resynchronisation des additions invité à 1000 ms, ce qui couvre un cycle
suspension/reprise — mais une VM restée suspendue longtemps gagne à être relancée :
vagrantreloadk8s-cp1
⚠️ Avec 3 control planes, etcd tolère une panne. N'en arrête pas deux en même temps (y
compris pendant une montée de version — cf. kubeadm/MISE-A-JOUR.md) :
l'API se fige jusqu'au retour du quorum.
Vide ou <pending> → c'est un problème de LoadBalancer, cf. section 4. L'adresse attendue est
la première IP de la plage, 192.168.56.200 par défaut.
2. Le nom résout-il vers cette IP ?
Le domaine du lab (LAB_DOMAIN, kubeadm.lab.example.io par défaut) n'a aucune raison de
résoudre sur ta machine. platform-up.sh affiche la ligne à ajouter :
# /etc/hosts sur l'HÔTE192.168.56.200argo.kubeadm.lab.example.iografana.kubeadm.lab.example.iovault.kubeadm.lab.example.io
… ou un enregistrement A wildcard *.<LAB_DOMAIN> → 192.168.56.200 si tu as une zone DNS.
Vérification :
⚠️ Sur le chemin ACME (SELF_SIGNED=false) derrière Cloudflare, l'enregistrement doit être en
DNS-only (nuage GRIS) : le proxy Cloudflare ne peut pas joindre une IP privée.
vagrantstatus# quelles VM existent et tournent
vagrantsshk8s-cp1# shell interactif
vagrantsshk8s-cp1-c"<commande>"---q-oLogLevel=ERROR# un coup, silencieux (ce qu'utilisent les scripts)
vagrantprovisionk8s-cp1# rejoue provision.sh (idempotent)
vagrantreloadk8s-cp1# redémarre en appliquant les CPU/RAM de lab.envexportKUBECONFIG="$PWD/kubeconfig"
kubectlgetnodes-owide
kubectlgetpods-A-owide
kubectlgetevents-A--sort-by=.lastTimestamp|tail-30
kubectlget--raw='/readyz?verbose'
cat_out/cluster.env# avec quoi le cluster a RÉELLEMENT été monté
⚠️ _out/join.env contient le token de jonction et la clé de certificats. _out/ est
gitignoré, mais il est lisible par toutes les VM à travers le dossier synchronisé /vagrant.
N'en recopie jamais le contenu nulle part.
cat/etc/kubeadm-lab/node.env# rôle, IP du node, interface host-only détectée
ip-4addrshow# la VIP est-elle ici ?
sudosystemctlstatuskubeletcontainerdkeepalived
sudojournalctl-ukubelet-f# suivre en direct
sudojournalctl-ukubelet-n100--no-pager
sudojournalctl-ucontainerd-n50--no-pager
sudojournalctl-ukeepalived-n50--no-pager
sudocrictlps-a# conteneurs, y compris morts
sudocrictlpods# sandboxes
sudocrictllogs<id-conteneur>
sudocrictlimages
sudokubeadmcertscheck-expiration# control planes uniquement
sudokubeadmconfigimageslist--kubernetes-versionv1.36.3
💡 crictl parle au même socket que le kubelet grâce à /etc/crictl.yaml, écrit par
provision.sh. Sans lui, crictl part chercher dockershim et affiche des erreurs
déroutantes.
Les options radicales, de la plus douce à la plus destructrice#
Commande
Ce qu'elle détruit
Quand
vagrant provision <node>
rien
réappliquer les prérequis système
./kubeadm/cluster-up.sh
rien (idempotent)
rejouer un bootstrap partiel, ajouter des nodes
./kubeadm/cluster-reset.sh
etcd, les certificats, toutes les charges — garde les VM
Kubernetes built with kubeadm on Debian 13 VMs, on VirtualBox, driven by Vagrant. Unlike
the Talos sibling of this lab, the nodes are ordinary
Linux boxes: SSH, apt, systemd, journalctl all work, and every step is a kubeadm
command you could type by hand. User docs: README.md · application layer:
https://ops-nc.github.io/k8s-playground/ · symptoms:
TROUBLESHOOTING.md · version bumps:
kubeadm/UPGRADE.md.
🚫 There is NO cluster, and you must not try to build one#
No agent working in this repository runs vagrant, kubectl, helm or talosctl. There
is no running lab attached to your session, kubeconfig does not exist, and vagrant up would
spend fifteen minutes failing. Every claim you make must be backed by reading the code, not
by running it. The one thing you may and should run is make validate — see below.
If a change can only be proven by a live cluster, say so explicitly and hand the verification
back to the human, with the exact commands to run.
Read it freely to understand how the layer behaves — it is checked out on disk.
Never write to it. Editing a file under _k8s/ dirties another repository's working
tree and produces a commit that does not belong here. Addon changes are made in
k8s-playground, then this repo bumps the pointer.
Never link to _k8s/…*.md from a Markdown file of this repo. Those pages are not part of
this documentation set and make validate-docs fails on the dead link. Point at
https://ops-nc.github.io/k8s-playground/, or at the file on GitHub
(https://github.com/OPS-NC/k8s-playground/blob/main/<dir>/README.md — the directories sit at
the root of that repo, with no _k8s/ prefix).
Paths on disk (./_k8s/platform-up.sh, _k8s/cilium/cilium-up.sh) stay correct and must not be
rewritten: the submodule really does mount there.
If _k8s/ is empty on the machine you work on, the submodule was never initialised:
git submodule update --init --recursive. git pull alone does not update it.
lab.env ──────────────► Vagrantfile ──► kubeadm/provision.sh (in each VM, at `vagrant up`)
│ (single source) │
│ └─ 8 steps: /etc/hosts · system upgrade (SYSTEM_UPGRADE) ·
│ kernel prereqs · base packages · containerd ·
│ kubelet/kubeadm/kubectl (+ hold) · image pull ·
│ keepalived (control planes only)
│
├──────────────────► kubeadm/cluster-up.sh (on the HOST)
│ ├─ renders kubeadm/templates/*.tpl into _out/
│ ├─ vagrant ssh cp1 → kubeadm/node-init.sh (kubeadm init)
│ ├─ vagrant ssh others → kubeadm/node-join.sh (kubeadm join)
│ └─ writes ./kubeconfig and _out/cluster.env
│
└──────────────────► _k8s/platform-up.sh (on the HOST, SUBMODULE)
├─ [1/4] CNI → _k8s/cilium/cilium-up.sh (or calico/flannel/none)
├─ [2/4] Envoy Gateway + main-gateway
├─ [3/4] metrics-server
└─ [4/4] wildcard TLS → _k8s/self-signed/ or cert-manager
then opt-in addons: ./_k8s/install.sh <addon>…
The application-layer entry point takes no distribution and no environment: it locates the
lab (the directory that contains _k8s/ and carries the Vagrantfile) and reads the
distribution off it — see the section below. The full sequence from the host:
./_k8s/platform-up.sh
Undo without destroying the VMs: kubeadm/cluster-reset.sh → kubeadm/node-reset.sh in every
VM (workers first, so they deregister while the API still answers).
the single source of topology, versions, addressing, CNI, TLS. lab.env is gitignored.
Vagrantfile
creates/prepares the VMs. Bootstraps no cluster. Holds topology guard rails (odd CP count, reserved IPs, duplicate IPs).
kubeadm/provision.sh
in-VM system preparation, idempotent, replayable with vagrant provision.
kubeadm/cluster-up.sh
host-side orchestrator. Does nothing inside the VMs itself: it renders configs and calls the two node scripts over vagrant ssh. Idempotent — and this is also how you grow the lab.
kubeadm/node-init.sh / node-join.sh
the actual kubeadm init / kubeadm join, in-VM. Both refuse to act on an already-initialised/joined node.
git submodule → k8s-playground, the application layer shared with the Talos lab. Entry points platform-up.sh (the base) and install.sh <addon>…; every other directory is an opt-in addon. Read-only from here — its code, its docs and its issues live in that repo.
.gitmodules
declares that submodule (path _k8s, url …/k8s-playground.git). Changing the pointer = git add _k8s, a normal commit of this repo.
docs/build.py
generates the single-page bilingual docs/index.html.
Makefile
make validate, make docs. Nothing here ever touches a running cluster.
.github/workflows/
CI calls the samemake targets. Never duplicate a check's definition in a workflow.
node-init.sh / node-join.sh, in the VM through /vagrant/_out/
_out/join.env (token + certificate key)
node-init.sh (VM)
cluster-up.sh (host)
_out/admin.conf → ./kubeconfig
node-init.sh
everything on the host
_out/cluster.env
cluster-up.sh
every _k8s/*-up.sh — these are detected facts
/etc/kubeadm-lab/node.env
provision.sh
cluster-up.sh (reads HOSTONLY_IF back out)
The synced folder /vagrant is a mechanism, not a convenience: it is what removes every
scp and every secret passed on a command line. Keep it that way.
🔑 The golden rule: lab.env is the single source, and its defaults are DUPLICATED#
Precedence, everywhere: real environment variable > lab.env > in-file fallback default.
That is why WORKERS=5 vagrant up works, and why lab.env never has to be export-ed.
The fallback defaults exist so a freshly cloned repo works without a lab.env. They are
deliberately copied into several files:
Default
Also lives in
K8S_VERSION, K8S_APT_MINOR
Vagrantfile, kubeadm/provision.sh, kubeadm/cluster-up.sh (no K8S_APT_MINOR there — it needs none)
k8s-playground only: platform-up.sh (lib/profiles/kubeadm.sh for the per-distro default), self-signed/selfsigned-up.sh
⚠️ Two defaults that diverge produce an incoherent lab — packages from one minor,
generated configuration for another; a pod CIDR declared to kubeadm that the CNI does not
announce; a wildcard Secret name the Gateway does not look for. Changing a default means
changing it everywhere in the same commit, lab.env.example included.
⚠️ The last three rows straddle two repositories. Their consumers live in
k8s-playground, which this repo only pins. A default changed here and not there (or the
reverse) diverges silently — make validate-defaults only compares lab.env.example,
the Vagrantfile and kubeadm/cluster-up.sh, and cannot see across the submodule. Changing
one of those keys means a PR in both repos, and bumping the pointer here.
The k8s-playground *-up.sh scripts add one more layer, and the order matters:
_out/cluster.env (facts about the running cluster) wins over lab.env (a mere intent,
possibly edited after the bootstrap). cilium/cilium-up.sh implements this in lire_param.
Both files are found in the lab directory, resolved automatically — see the section below.
✅ Validating a change WITHOUT a cluster (do this every time)#
bash -n on every git-tracked *.sh — the _k8s/ submodule is not tracked file by file, so none of its scripts are checked here
validate-yaml
every git-tracked *.yaml/*.yml parses (PyYAML pulled in by uv) — same submodule caveat
validate-vagrant
vagrant validate. In CI: VAGRANT_VALIDATE_FLAGS=--ignore-provider (runners have no VirtualBox)
validate-kubeadm
renders the three templates with dummy values into an mktemp -d, parses them, and runs kubeadm config validate if the binary is present. This is the target that catches a v1beta4 schema mistake.
validate-docs
builds the docs into a throwaway file with --strict and fails on the first unresolved *.md link or anchor
make docs also lists, at the end of the build, every link and cross-file anchor that does not
resolve. Run it after renaming any heading.
grep under set -e + pipefail kills the script. A grep with no match exits 1, and
in a pipeline under pipefail that becomes the script's exit status — silently, long before
the interesting part. The repo reads key/value files with sed -n 's/^KEY=//p' and a
trailing || true (for the case where the file does not exist at all, where sed exits 2).
Look at lire_lab_env / lire_cluster_env and copy them; never introduce a
grep … | head -1 in that role.
Backticks inside a double-quoted string are command substitution. Writing
echo "use `kubeadm init`" runs kubeadm init. This repo's prose is full of
backtick-quoted identifiers, so the risk is constant in echo/printf messages and in
unquoted heredocs (<<EOF). Use '…', a quoted heredoc (<<'EOF'), or simply no backticks
in shell output.
./script.sh; echo "EXIT=$?" reports echo's status, not the script's. Check
${PIPESTATUS[0]} or the exit line inside the log.
lab.env is parsed, not sourced. Strict KEY=value, no spaces around =, no ;. The
key name is validated against ^[A-Za-z_][A-Za-z0-9_]*$before any eval: a hand-edited
lab.env must not be able to execute arbitrary code. Keep that check if you touch the parser.
node-ip is mandatory on every node. Each VM has a NAT NIC at 10.0.2.15, identical on
every VM. Without kubeletExtraArgs: node-ip, every node registers with that address and
logs, exec, probes and cross-node traffic all go to the wrong place. This is the reason
the lab joins nodes through JoinConfiguration files instead of the printed kubeadm join
line: that line cannot carry node-ip, and kubeadm join has no equivalent flag. Never
"simplify" the join back to the printed command.
v1beta4: extraArgs and kubeletExtraArgs are LISTS, not dictionaries.
The change exists so a flag can be repeated. Any pre-1.31 snippet copied from the internet is
invalid, and the error message does not say so. make validate-kubeadm catches it.
The host-only interface name is never hard-coded.provision.sh finds the interface that
carries the node's IP and writes it to /etc/kubeadm-lab/node.env; cluster-up.sh copies
it into _out/cluster.env as HOSTONLY_IF; Cilium's L2 announcement and keepalived both use
it. Debian 13 usually gives enp0s8, some boxes still give eth1. Writing either literally
anywhere is a bug.
The pause image is never hard-coded either.provision.sh asks
kubeadm config images list for it. A mismatch between containerd's pinned image and the one
kubeadm expects is invisible online and fatal offline.
controlPlaneEndpoint is the VIP even with one control plane. It is frozen in the
certificates and in every kubeconfig at kubeadm init time; pointing it at cp1's real IP
would make "1 CP → 3 CP" a full certificate regeneration instead of a join.
certSANs pre-declares five control-plane IPs, including nodes that do not exist yet.
A forgotten SAN can only be added by regenerating the certificates. Do not trim that list.
--skip-phases=addon/kube-proxy is preferred to v1beta4's declarative proxy.disabled —
identical result, but the flag is proven across versions and is what Cilium documents.
KUBE_PROXY_REPLACEMENT=true requires CNI=cilium, and cluster-up.sh refuses any other
combination. Without kube-proxy and without a replacement, no ClusterIP answers at all — not
even CoreDNS reaching the API. Keep the refusal; do not downgrade it to a warning.
Cilium needs k8sServiceHost/k8sServicePort = the VIP when kube-proxy is gone: nothing
provisions the apiserver ClusterIP, so the agent cannot bootstrap through it. And Cilium's
cluster-pool IPAM defaults to 10.0.0.0/8, unrelated to what kubeadm was told — that is why
cilium-up.sh passes POD_CIDR explicitly.
kubeadm reset leaves the CNI datapath behind — interfaces, pinned eBPF programs under
/sys/fs/bpf, kube-proxy iptables rules. node-reset.sh is that cleanup; without it a later
init inherits a ghost datapath. Do not slim it down.
apt-mark hold on kubelet/kubeadm/kubectl is deliberate, and vagrant provision is
not an upgrade path: it would jump every node's kubelet to a new minor at once, ahead of
the control plane. See kubeadm/UPGRADE.md.
Control planes must be odd, and the check exists in two places (Vagrantfile and
cluster-up.sh) on purpose. CI asserts that the Vagrantfile really refuses
CONTROL_PLANES=2 — a test that checks an error happens beats a comment claiming it does.
containerd's config is regenerated from containerd config default on every provision, so
the file format follows the installed binary. The pause key changed name and location
between formats (sandbox_image under [plugins."io.containerd.grpc.v1.cri"] in v2,
sandbox under [plugins.'io.containerd.cri.v1.images'.pinned_images] in v3) — both are
patched. Never hand-edit that file and expect it to survive.
keepalived, not kube-vip, and no authentication block. The VIP must exist beforekubeadm init; kube-vip elects its leader through the API it is meant to front. VRRP is
unicast (multicast misbehaves on a VirtualBox host-only switch), and the isolation knob is
VRRP_ROUTER_ID, not a cleartext VRRPv2 password.
The lab is found on its own — no LAB_DIR in the examples. k8s-playground takes the
directory that contains_k8s/ as the lab, provided it carries a Vagrantfile; that is
where lab.env, _out/ and kubeconfig live. In the submodule layout that is this repo, so
documented commands are bare: ./_k8s/platform-up.sh. LAB_DIR (or LAB_ENV) stays
documented as an escape hatch for an unusual layout, never as a required step — do not
re-add it to the normal path.
The distribution is detected, not passed. A kubeadm/cluster-up.sh next to the
Vagrantfile identifies the kubeadm lab (talos/cluster-up.sh the Talos twin), from the
clone alone, before any vagrant up. An explicit kubeadm argument (or --distro= /
K8S_DISTRO) is still accepted and wins, but it is a possibility to mention, not the
documented invocation. There is no DISTRO key in lab.env any more.
Do not edit anything under _k8s/ from this repo, and do not link to its *.md files.
See the dedicated section at the top of this file.
make validate-shell / validate-yaml only cover files tracked by this repo. The
submodule's scripts and manifests are validated in k8s-playground's own CI, not here. A green
make validate says nothing about the application layer.
lab.env is gitignored and may hold real secrets (CLOUDFLARE_API_TOKEN). Never commit
it, never copy a value from it into a README, a commit message, a report or terminal output.
_out/join.env holds the join token and the certificate key; _out/admin.conf and
./kubeconfig hold admin credentials; _out/self-signed/ca.key is a private CA key. _out/
is gitignored but readable by every VM through /vagrant.
The repo is public: every versioned default must be neutral (kubeadm.lab.example.io,
empty CLOUDFLARE_API_TOKEN, empty REGISTRY_MIRROR).
Before committing: git status. No secret file may appear.
Bilingual docs, English first. Every page exists twice in the same directory: English
carries the canonical name, French its mirror.
English
French
README.md
LISEZ-MOI.md
TROUBLESHOOTING.md
DEPANNAGE.md
UPGRADE.md
MISE-A-JOUR.md
Both versions change in the same commit: an English page whose mirror did not follow is a
documentation bug. This file is the exception — English only, on purpose, because it
addresses coding agents (it is listed in WITHOUT_MIRROR in docs/build.py, so it carries no
"not translated" badge).
Every page starts with the i18n banner, which docs/build.py strips at build time (the
HTML page has its own switcher). Keep it in the files, and put nothing else between the
markers:
EN and FR must share the exact same heading structure, in the same order: the site's
language switcher keeps the current anchor when it toggles. Slugs derive from headings, so
FR anchors differ from EN anchors by construction — which means renaming a heading breaks
every link that targeted it, and make validate-docs is what tells you.
docs/build.py discovers pages on its own: every *.md in the repo is picked up. Adding
a page needs no code change; only its menu group (GROUPS) and its emoji (EMOJIS) are
declared, and an unknown directory falls into "Other". Pages are grouped per directory through
MIRRORS; a page with no mirror is shown in English inside the French menu with an EN
badge — that badge is the symptom of a forgotten translation.
Everything that is not a French documentation page is in English. Code comments,
identifiers, script output, error messages, Makefile, CI workflows, .gitignore,
Vagrantfile, lab.env.example, the kubeadm/templates/*.tpl and docs/build.py — all
English. The repo used to keep its comments in French; that is no longer the case, so do not
"restore" French in a script you touch.
The only French left is the FR documentation mirrors (LISEZ-MOI.md, DEPANNAGE.md,
kubeadm/MISE-A-JOUR.md) — their prose, not the output they quote. Three deliberate
exceptions inside otherwise English code, all in docs/build.py:
the fr values of LABELS (they are the French UI);
the FR menu titles of GROUPS and of OTHER, same reason;
the French markers of the CALLOUTS table ("attention", "jamais", "astuce",
"conseil", "remarque"…). These are not labels, they parse the French pages to pick a
callout's colour. Translating them silently turns every French callout grey — the kind of
breakage no test catches. The callout kinds (danger/tip/info) are English because
they become CSS classes (.callout-tip).
When a French page quotes script output, quote the English string the script now prints.
A French page documenting an English-output tool is the expected result, not an oversight.
⚠️ .github/workflows/ci.yml greps the WORDING of a Vagrantfile error. The
CONTROL_PLANES=2 guard-rail test matches 'is EVEN'. Reword that message and the test
still passes while proving nothing — change both together.
Commit messages in English, conventional (fix(...), feat(...), docs: ...). Branch
from main, one feature per PR, squash merge.
Every page of this repo follows the same skeleton (one emoji per ##, ⚠️/💡/ℹ️
callouts, a pitfalls section where it applies). Stick to plain CommonMark + GitHub tables so
the generator renders it. The addon pages follow the same convention in k8s-playground,
where they are written and published.
No Markdown link may point into _k8s/.docs/build.py --strict resolves *.md links
and anchors, the submodule's pages are not part of this documentation set, and
make validate-docs fails on them. Use https://ops-nc.github.io/k8s-playground/ or a GitHub
URL instead.
A variable, an option or an addon is only "done" once it appears at every level. One
isolated mention is a documentation bug — the reader will never find it.
Where
What to update
k8s-playground (separate repo)
the addon's own page and the index table — not editable from here; open a PR there, then bump the _k8s pointer in this repo
README.md (root)
only if it touches the install path, lab.env or the CNI choice
lab.env.example
every new variable, commented, with a neutral default
every file carrying a duplicated fallback default
see the golden rule above
CLAUDE.md
every newly earned pitfall, every new validation command
TROUBLESHOOTING.md
if the component has a failure mode a reader will meet
kubeadm/UPGRADE.md
if it constrains a version or has its own release cycle
docs/build.py
the page emoji in EMOJIS, its placement in GROUPS
the FR mirror of every page touched
same structure, same content, same commit
Then make docs, then make validate, before committing.
Knowing what is not here saves you from "adding" it back.
No talosctl, no immutable OS, no API-driven machine config. The nodes are plain Debian;
that is the entire point of this repo next to its Talos sibling.
No kube-vip. keepalived carries the VIP because the VIP must pre-date kubeadm init.
kube-vip stays a legitimate option once the cluster is up (--services mode) — worth
mentioning, never the default path.
No MetalLB. Cilium's L2/ARP announcement gives LoadBalancer Services their IP. MetalLB is
only relevant on the CNI=calico branch, and that is documented with the
calico/ addon in
k8s-playground.
No cluster bootstrap inside vagrant up. The Vagrantfile prepares VMs and stops there.
Bootstrapping is a separate, re-runnable script — that separation is what makes growing the
lab a re-run instead of a rebuild.
No external etcd. Stacked etcd on the control planes: kubeadm's default, and the right
call for a lab.
No ingress-nginx. Gateway API through Envoy Gateway.
No cert-manager by default.SELF_SIGNED=true builds a local CA with openssl, works
offline, and burns no Let's Encrypt quota. Both TLS modes fill the same Secret, so no addon
ever branches on the TLS mode — keep it that way.
No CI that boots a VM or talks to a cluster. Everything CI does is a make validate-*
target that also runs on a laptop. A check that passes in CI and fails locally is a broken
check.
No committed lab.env, _out/, kubeconfig or docs/index.html. All generated, all
gitignored.
CLAUDE.md
🤖CLAUDE.md
Kubernetes built with kubeadm on Debian 13 VMs, on VirtualBox, driven by Vagrant. Unlike
the Talos sibling of this lab, the nodes are ordinary
Linux boxes: SSH, apt, systemd, journalctl all work, and every step is a kubeadm
command you could type by hand. User docs: README.md · application layer:
https://ops-nc.github.io/k8s-playground/ · symptoms:
TROUBLESHOOTING.md · version bumps:
kubeadm/UPGRADE.md.
🚫 There is NO cluster, and you must not try to build one#
No agent working in this repository runs vagrant, kubectl, helm or talosctl. There
is no running lab attached to your session, kubeconfig does not exist, and vagrant up would
spend fifteen minutes failing. Every claim you make must be backed by reading the code, not
by running it. The one thing you may and should run is make validate — see below.
If a change can only be proven by a live cluster, say so explicitly and hand the verification
back to the human, with the exact commands to run.
Read it freely to understand how the layer behaves — it is checked out on disk.
Never write to it. Editing a file under _k8s/ dirties another repository's working
tree and produces a commit that does not belong here. Addon changes are made in
k8s-playground, then this repo bumps the pointer.
Never link to _k8s/…*.md from a Markdown file of this repo. Those pages are not part of
this documentation set and make validate-docs fails on the dead link. Point at
https://ops-nc.github.io/k8s-playground/, or at the file on GitHub
(https://github.com/OPS-NC/k8s-playground/blob/main/<dir>/README.md — the directories sit at
the root of that repo, with no _k8s/ prefix).
Paths on disk (./_k8s/platform-up.sh, _k8s/cilium/cilium-up.sh) stay correct and must not be
rewritten: the submodule really does mount there.
If _k8s/ is empty on the machine you work on, the submodule was never initialised:
git submodule update --init --recursive. git pull alone does not update it.
lab.env ──────────────► Vagrantfile ──► kubeadm/provision.sh (in each VM, at `vagrant up`)
│ (single source) │
│ └─ 8 steps: /etc/hosts · system upgrade (SYSTEM_UPGRADE) ·
│ kernel prereqs · base packages · containerd ·
│ kubelet/kubeadm/kubectl (+ hold) · image pull ·
│ keepalived (control planes only)
│
├──────────────────► kubeadm/cluster-up.sh (on the HOST)
│ ├─ renders kubeadm/templates/*.tpl into _out/
│ ├─ vagrant ssh cp1 → kubeadm/node-init.sh (kubeadm init)
│ ├─ vagrant ssh others → kubeadm/node-join.sh (kubeadm join)
│ └─ writes ./kubeconfig and _out/cluster.env
│
└──────────────────► _k8s/platform-up.sh (on the HOST, SUBMODULE)
├─ [1/4] CNI → _k8s/cilium/cilium-up.sh (or calico/flannel/none)
├─ [2/4] Envoy Gateway + main-gateway
├─ [3/4] metrics-server
└─ [4/4] wildcard TLS → _k8s/self-signed/ or cert-manager
then opt-in addons: ./_k8s/install.sh <addon>…
The application-layer entry point takes no distribution and no environment: it locates the
lab (the directory that contains _k8s/ and carries the Vagrantfile) and reads the
distribution off it — see the section below. The full sequence from the host:
./_k8s/platform-up.sh
Undo without destroying the VMs: kubeadm/cluster-reset.sh → kubeadm/node-reset.sh in every
VM (workers first, so they deregister while the API still answers).
the single source of topology, versions, addressing, CNI, TLS. lab.env is gitignored.
Vagrantfile
creates/prepares the VMs. Bootstraps no cluster. Holds topology guard rails (odd CP count, reserved IPs, duplicate IPs).
kubeadm/provision.sh
in-VM system preparation, idempotent, replayable with vagrant provision.
kubeadm/cluster-up.sh
host-side orchestrator. Does nothing inside the VMs itself: it renders configs and calls the two node scripts over vagrant ssh. Idempotent — and this is also how you grow the lab.
kubeadm/node-init.sh / node-join.sh
the actual kubeadm init / kubeadm join, in-VM. Both refuse to act on an already-initialised/joined node.
git submodule → k8s-playground, the application layer shared with the Talos lab. Entry points platform-up.sh (the base) and install.sh <addon>…; every other directory is an opt-in addon. Read-only from here — its code, its docs and its issues live in that repo.
.gitmodules
declares that submodule (path _k8s, url …/k8s-playground.git). Changing the pointer = git add _k8s, a normal commit of this repo.
docs/build.py
generates the single-page bilingual docs/index.html.
Makefile
make validate, make docs. Nothing here ever touches a running cluster.
.github/workflows/
CI calls the samemake targets. Never duplicate a check's definition in a workflow.
node-init.sh / node-join.sh, in the VM through /vagrant/_out/
_out/join.env (token + certificate key)
node-init.sh (VM)
cluster-up.sh (host)
_out/admin.conf → ./kubeconfig
node-init.sh
everything on the host
_out/cluster.env
cluster-up.sh
every _k8s/*-up.sh — these are detected facts
/etc/kubeadm-lab/node.env
provision.sh
cluster-up.sh (reads HOSTONLY_IF back out)
The synced folder /vagrant is a mechanism, not a convenience: it is what removes every
scp and every secret passed on a command line. Keep it that way.
🔑 The golden rule: lab.env is the single source, and its defaults are DUPLICATED#
Precedence, everywhere: real environment variable > lab.env > in-file fallback default.
That is why WORKERS=5 vagrant up works, and why lab.env never has to be export-ed.
The fallback defaults exist so a freshly cloned repo works without a lab.env. They are
deliberately copied into several files:
Default
Also lives in
K8S_VERSION, K8S_APT_MINOR
Vagrantfile, kubeadm/provision.sh, kubeadm/cluster-up.sh (no K8S_APT_MINOR there — it needs none)
k8s-playground only: platform-up.sh (lib/profiles/kubeadm.sh for the per-distro default), self-signed/selfsigned-up.sh
⚠️ Two defaults that diverge produce an incoherent lab — packages from one minor,
generated configuration for another; a pod CIDR declared to kubeadm that the CNI does not
announce; a wildcard Secret name the Gateway does not look for. Changing a default means
changing it everywhere in the same commit, lab.env.example included.
⚠️ The last three rows straddle two repositories. Their consumers live in
k8s-playground, which this repo only pins. A default changed here and not there (or the
reverse) diverges silently — make validate-defaults only compares lab.env.example,
the Vagrantfile and kubeadm/cluster-up.sh, and cannot see across the submodule. Changing
one of those keys means a PR in both repos, and bumping the pointer here.
The k8s-playground *-up.sh scripts add one more layer, and the order matters:
_out/cluster.env (facts about the running cluster) wins over lab.env (a mere intent,
possibly edited after the bootstrap). cilium/cilium-up.sh implements this in lire_param.
Both files are found in the lab directory, resolved automatically — see the section below.
✅ Validating a change WITHOUT a cluster (do this every time)#
bash -n on every git-tracked *.sh — the _k8s/ submodule is not tracked file by file, so none of its scripts are checked here
validate-yaml
every git-tracked *.yaml/*.yml parses (PyYAML pulled in by uv) — same submodule caveat
validate-vagrant
vagrant validate. In CI: VAGRANT_VALIDATE_FLAGS=--ignore-provider (runners have no VirtualBox)
validate-kubeadm
renders the three templates with dummy values into an mktemp -d, parses them, and runs kubeadm config validate if the binary is present. This is the target that catches a v1beta4 schema mistake.
validate-docs
builds the docs into a throwaway file with --strict and fails on the first unresolved *.md link or anchor
make docs also lists, at the end of the build, every link and cross-file anchor that does not
resolve. Run it after renaming any heading.
grep under set -e + pipefail kills the script. A grep with no match exits 1, and
in a pipeline under pipefail that becomes the script's exit status — silently, long before
the interesting part. The repo reads key/value files with sed -n 's/^KEY=//p' and a
trailing || true (for the case where the file does not exist at all, where sed exits 2).
Look at lire_lab_env / lire_cluster_env and copy them; never introduce a
grep … | head -1 in that role.
Backticks inside a double-quoted string are command substitution. Writing
echo "use `kubeadm init`" runs kubeadm init. This repo's prose is full of
backtick-quoted identifiers, so the risk is constant in echo/printf messages and in
unquoted heredocs (<<EOF). Use '…', a quoted heredoc (<<'EOF'), or simply no backticks
in shell output.
./script.sh; echo "EXIT=$?" reports echo's status, not the script's. Check
${PIPESTATUS[0]} or the exit line inside the log.
lab.env is parsed, not sourced. Strict KEY=value, no spaces around =, no ;. The
key name is validated against ^[A-Za-z_][A-Za-z0-9_]*$before any eval: a hand-edited
lab.env must not be able to execute arbitrary code. Keep that check if you touch the parser.
node-ip is mandatory on every node. Each VM has a NAT NIC at 10.0.2.15, identical on
every VM. Without kubeletExtraArgs: node-ip, every node registers with that address and
logs, exec, probes and cross-node traffic all go to the wrong place. This is the reason
the lab joins nodes through JoinConfiguration files instead of the printed kubeadm join
line: that line cannot carry node-ip, and kubeadm join has no equivalent flag. Never
"simplify" the join back to the printed command.
v1beta4: extraArgs and kubeletExtraArgs are LISTS, not dictionaries.
The change exists so a flag can be repeated. Any pre-1.31 snippet copied from the internet is
invalid, and the error message does not say so. make validate-kubeadm catches it.
The host-only interface name is never hard-coded.provision.sh finds the interface that
carries the node's IP and writes it to /etc/kubeadm-lab/node.env; cluster-up.sh copies
it into _out/cluster.env as HOSTONLY_IF; Cilium's L2 announcement and keepalived both use
it. Debian 13 usually gives enp0s8, some boxes still give eth1. Writing either literally
anywhere is a bug.
The pause image is never hard-coded either.provision.sh asks
kubeadm config images list for it. A mismatch between containerd's pinned image and the one
kubeadm expects is invisible online and fatal offline.
controlPlaneEndpoint is the VIP even with one control plane. It is frozen in the
certificates and in every kubeconfig at kubeadm init time; pointing it at cp1's real IP
would make "1 CP → 3 CP" a full certificate regeneration instead of a join.
certSANs pre-declares five control-plane IPs, including nodes that do not exist yet.
A forgotten SAN can only be added by regenerating the certificates. Do not trim that list.
--skip-phases=addon/kube-proxy is preferred to v1beta4's declarative proxy.disabled —
identical result, but the flag is proven across versions and is what Cilium documents.
KUBE_PROXY_REPLACEMENT=true requires CNI=cilium, and cluster-up.sh refuses any other
combination. Without kube-proxy and without a replacement, no ClusterIP answers at all — not
even CoreDNS reaching the API. Keep the refusal; do not downgrade it to a warning.
Cilium needs k8sServiceHost/k8sServicePort = the VIP when kube-proxy is gone: nothing
provisions the apiserver ClusterIP, so the agent cannot bootstrap through it. And Cilium's
cluster-pool IPAM defaults to 10.0.0.0/8, unrelated to what kubeadm was told — that is why
cilium-up.sh passes POD_CIDR explicitly.
kubeadm reset leaves the CNI datapath behind — interfaces, pinned eBPF programs under
/sys/fs/bpf, kube-proxy iptables rules. node-reset.sh is that cleanup; without it a later
init inherits a ghost datapath. Do not slim it down.
apt-mark hold on kubelet/kubeadm/kubectl is deliberate, and vagrant provision is
not an upgrade path: it would jump every node's kubelet to a new minor at once, ahead of
the control plane. See kubeadm/UPGRADE.md.
Control planes must be odd, and the check exists in two places (Vagrantfile and
cluster-up.sh) on purpose. CI asserts that the Vagrantfile really refuses
CONTROL_PLANES=2 — a test that checks an error happens beats a comment claiming it does.
containerd's config is regenerated from containerd config default on every provision, so
the file format follows the installed binary. The pause key changed name and location
between formats (sandbox_image under [plugins."io.containerd.grpc.v1.cri"] in v2,
sandbox under [plugins.'io.containerd.cri.v1.images'.pinned_images] in v3) — both are
patched. Never hand-edit that file and expect it to survive.
keepalived, not kube-vip, and no authentication block. The VIP must exist beforekubeadm init; kube-vip elects its leader through the API it is meant to front. VRRP is
unicast (multicast misbehaves on a VirtualBox host-only switch), and the isolation knob is
VRRP_ROUTER_ID, not a cleartext VRRPv2 password.
The lab is found on its own — no LAB_DIR in the examples. k8s-playground takes the
directory that contains_k8s/ as the lab, provided it carries a Vagrantfile; that is
where lab.env, _out/ and kubeconfig live. In the submodule layout that is this repo, so
documented commands are bare: ./_k8s/platform-up.sh. LAB_DIR (or LAB_ENV) stays
documented as an escape hatch for an unusual layout, never as a required step — do not
re-add it to the normal path.
The distribution is detected, not passed. A kubeadm/cluster-up.sh next to the
Vagrantfile identifies the kubeadm lab (talos/cluster-up.sh the Talos twin), from the
clone alone, before any vagrant up. An explicit kubeadm argument (or --distro= /
K8S_DISTRO) is still accepted and wins, but it is a possibility to mention, not the
documented invocation. There is no DISTRO key in lab.env any more.
Do not edit anything under _k8s/ from this repo, and do not link to its *.md files.
See the dedicated section at the top of this file.
make validate-shell / validate-yaml only cover files tracked by this repo. The
submodule's scripts and manifests are validated in k8s-playground's own CI, not here. A green
make validate says nothing about the application layer.
lab.env is gitignored and may hold real secrets (CLOUDFLARE_API_TOKEN). Never commit
it, never copy a value from it into a README, a commit message, a report or terminal output.
_out/join.env holds the join token and the certificate key; _out/admin.conf and
./kubeconfig hold admin credentials; _out/self-signed/ca.key is a private CA key. _out/
is gitignored but readable by every VM through /vagrant.
The repo is public: every versioned default must be neutral (kubeadm.lab.example.io,
empty CLOUDFLARE_API_TOKEN, empty REGISTRY_MIRROR).
Before committing: git status. No secret file may appear.
Bilingual docs, English first. Every page exists twice in the same directory: English
carries the canonical name, French its mirror.
English
French
README.md
LISEZ-MOI.md
TROUBLESHOOTING.md
DEPANNAGE.md
UPGRADE.md
MISE-A-JOUR.md
Both versions change in the same commit: an English page whose mirror did not follow is a
documentation bug. This file is the exception — English only, on purpose, because it
addresses coding agents (it is listed in WITHOUT_MIRROR in docs/build.py, so it carries no
"not translated" badge).
Every page starts with the i18n banner, which docs/build.py strips at build time (the
HTML page has its own switcher). Keep it in the files, and put nothing else between the
markers:
EN and FR must share the exact same heading structure, in the same order: the site's
language switcher keeps the current anchor when it toggles. Slugs derive from headings, so
FR anchors differ from EN anchors by construction — which means renaming a heading breaks
every link that targeted it, and make validate-docs is what tells you.
docs/build.py discovers pages on its own: every *.md in the repo is picked up. Adding
a page needs no code change; only its menu group (GROUPS) and its emoji (EMOJIS) are
declared, and an unknown directory falls into "Other". Pages are grouped per directory through
MIRRORS; a page with no mirror is shown in English inside the French menu with an EN
badge — that badge is the symptom of a forgotten translation.
Everything that is not a French documentation page is in English. Code comments,
identifiers, script output, error messages, Makefile, CI workflows, .gitignore,
Vagrantfile, lab.env.example, the kubeadm/templates/*.tpl and docs/build.py — all
English. The repo used to keep its comments in French; that is no longer the case, so do not
"restore" French in a script you touch.
The only French left is the FR documentation mirrors (LISEZ-MOI.md, DEPANNAGE.md,
kubeadm/MISE-A-JOUR.md) — their prose, not the output they quote. Three deliberate
exceptions inside otherwise English code, all in docs/build.py:
the fr values of LABELS (they are the French UI);
the FR menu titles of GROUPS and of OTHER, same reason;
the French markers of the CALLOUTS table ("attention", "jamais", "astuce",
"conseil", "remarque"…). These are not labels, they parse the French pages to pick a
callout's colour. Translating them silently turns every French callout grey — the kind of
breakage no test catches. The callout kinds (danger/tip/info) are English because
they become CSS classes (.callout-tip).
When a French page quotes script output, quote the English string the script now prints.
A French page documenting an English-output tool is the expected result, not an oversight.
⚠️ .github/workflows/ci.yml greps the WORDING of a Vagrantfile error. The
CONTROL_PLANES=2 guard-rail test matches 'is EVEN'. Reword that message and the test
still passes while proving nothing — change both together.
Commit messages in English, conventional (fix(...), feat(...), docs: ...). Branch
from main, one feature per PR, squash merge.
Every page of this repo follows the same skeleton (one emoji per ##, ⚠️/💡/ℹ️
callouts, a pitfalls section where it applies). Stick to plain CommonMark + GitHub tables so
the generator renders it. The addon pages follow the same convention in k8s-playground,
where they are written and published.
No Markdown link may point into _k8s/.docs/build.py --strict resolves *.md links
and anchors, the submodule's pages are not part of this documentation set, and
make validate-docs fails on them. Use https://ops-nc.github.io/k8s-playground/ or a GitHub
URL instead.
A variable, an option or an addon is only "done" once it appears at every level. One
isolated mention is a documentation bug — the reader will never find it.
Where
What to update
k8s-playground (separate repo)
the addon's own page and the index table — not editable from here; open a PR there, then bump the _k8s pointer in this repo
README.md (root)
only if it touches the install path, lab.env or the CNI choice
lab.env.example
every new variable, commented, with a neutral default
every file carrying a duplicated fallback default
see the golden rule above
CLAUDE.md
every newly earned pitfall, every new validation command
TROUBLESHOOTING.md
if the component has a failure mode a reader will meet
kubeadm/UPGRADE.md
if it constrains a version or has its own release cycle
docs/build.py
the page emoji in EMOJIS, its placement in GROUPS
the FR mirror of every page touched
same structure, same content, same commit
Then make docs, then make validate, before committing.
Knowing what is not here saves you from "adding" it back.
No talosctl, no immutable OS, no API-driven machine config. The nodes are plain Debian;
that is the entire point of this repo next to its Talos sibling.
No kube-vip. keepalived carries the VIP because the VIP must pre-date kubeadm init.
kube-vip stays a legitimate option once the cluster is up (--services mode) — worth
mentioning, never the default path.
No MetalLB. Cilium's L2/ARP announcement gives LoadBalancer Services their IP. MetalLB is
only relevant on the CNI=calico branch, and that is documented with the
calico/ addon in
k8s-playground.
No cluster bootstrap inside vagrant up. The Vagrantfile prepares VMs and stops there.
Bootstrapping is a separate, re-runnable script — that separation is what makes growing the
lab a re-run instead of a rebuild.
No external etcd. Stacked etcd on the control planes: kubeadm's default, and the right
call for a lab.
No ingress-nginx. Gateway API through Envoy Gateway.
No cert-manager by default.SELF_SIGNED=true builds a local CA with openssl, works
offline, and burns no Let's Encrypt quota. Both TLS modes fill the same Secret, so no addon
ever branches on the TLS mode — keep it that way.
No CI that boots a VM or talks to a cluster. Everything CI does is a make validate-*
target that also runs on a laptop. A check that passes in CI and fails locally is a broken
check.
No committed lab.env, _out/, kubeconfig or docs/index.html. All generated, all
gitignored.