Vagrant-KubeADM
README.md

🏠 ☸️Vagrant-KubeADM

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 before kubeadm 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:

git clone --recurse-submodules https://github.com/OPS-NC/Vagrant-kubeadm.git
cd Vagrant-kubeadm
cp lab.env.example lab.env      # pick the topology
vagrant up                      # 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
📖 Browsable docs ops-nc.github.io/Vagrant-kubeadm — EN/FR switch, light/dark theme, offline copy with make docs
📦 Application layer ops-nc.github.io/k8s-playground — its own repo, mounted here as the _k8s/ submodule
⬆️ Kubernetes upgrades kubeadm/UPGRADE.md
🚑 Something broken? TROUBLESHOOTING.md

⚠️ --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.


🧰 1. Prerequisites (on the host)#

Tool Purpose Install
VirtualBox 7 hypervisor https://www.virtualbox.org/
Vagrant VM creation https://developer.hashicorp.com/vagrant
git the repo and its _k8s/ submodule https://git-scm.com/
kubectl using the cluster https://kubernetes.io/docs/tasks/tools/
helm _k8s/ addons https://helm.sh/docs/intro/install/
uv (optional) make docs https://docs.astral.sh/uv/

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:

git submodule update --init --recursive     # fills _k8s/ on an existing clone
git submodule update --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-only 192.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.


⚙️ 3. Pick the topology — lab.env#

The topology lives in lab.env, the single source read by the Vagrantfile, by kubeadm/cluster-up.sh and by the _k8s/*-up.sh scripts. Start from the versioned template (lab.env.example; lab.env itself is gitignored):

cp lab.env.example lab.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
CONTAINERD_SOURCE docker dockercontainerd.io 2.x (Docker repo) · debian → containerd 1.7 (see §9)
REGISTRY_MIRROR (empty) pull-through mirror (Harbor…) → /etc/containerd/certs.d/docker.io/hosts.toml
CONTROL_PLANES 1 1 = single, 3 = HA. Even numbers are refused
WORKERS 2 number of workers; 0 is valid (see UNTAINT_CP)
CP_MEM / CP_CPU 3072 / 2 control plane resources (never below 3072: etcd)
WK_MEM / WK_CPU 2048 / 2 worker resources
BOX bento/debian-13 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.podSubnetthe 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 Vagrantfile and 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.


🚀 4. Start the cluster#

vagrant up                      # 5-10 min: VMs + packages + containerd + kubeadm + keepalived
./kubeadm/cluster-up.sh         # 3-5 min: init + joins + kubeconfig

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 sshnode-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.

Then, from the host:

export KUBECONFIG="$PWD/kubeconfig"
kubectl get nodes -o wide

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:

CONTROL_PLANES=3 WORKERS=3 vagrant up
CONTROL_PLANES=3 WORKERS=3 ./kubeadm/cluster-up.sh

🎓 5. Doing it by hand#

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.

5.1 What is already in place after vagrant up#

vagrant ssh k8s-cp1
sudo -i
kubeadm version -o short                 # v1.36.3, held by apt-mark
containerd --version                     # 2.x when CONTAINERD_SOURCE=docker
crictl ps                                # talks to /run/containerd/containerd.sock
ip -4 addr show | grep 192.168.56.5      # the VIP is ALREADY there, before any init
systemctl status keepalived
cat /etc/kubeadm-lab/node.env            # NODE_IP, HOSTONLY_IF, VIP…

ℹ️ The VIP being up before kubeadm init is the whole reason keepalived is used here rather than kube-vip — the reasoning is in §9.

5.2 kubeadm init on the first control plane#

The repo's way, which is also the shortest — cluster-up.sh already rendered the config into _out/, visible from the VM through the synced folder:

sudo kubeadm init --config /vagrant/_out/kubeadm-init.yaml --upload-certs \
     --skip-phases=addon/kube-proxy          # only when KUBE_PROXY_REPLACEMENT=true

The flag-only equivalent, if you want to see it without a config file:

sudo kubeadm init \
  --control-plane-endpoint 192.168.56.5:6443 \
  --apiserver-advertise-address 192.168.56.10 \
  --pod-network-cidr 10.244.0.0/16 \
  --service-cidr 10.96.0.0/12 \
  --cri-socket unix:///run/containerd/containerd.sock \
  --apiserver-cert-extra-sans 192.168.56.5,192.168.56.10,192.168.56.20,192.168.56.30 \
  --upload-certs \
  --skip-phases=addon/kube-proxy

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.

5.3 Getting a kubeconfig#

In the VM:

mkdir -p "$HOME/.kube"
sudo install -o "$(id -u)" -g "$(id -g)" -m 0600 /etc/kubernetes/admin.conf "$HOME/.kube/config"
kubectl get nodes

On the host — no scp, the synced folder is right there:

vagrant ssh k8s-cp1 -c 'sudo cat /etc/kubernetes/admin.conf' > kubeconfig
chmod 0600 kubeconfig && export KUBECONFIG="$PWD/kubeconfig"

It works unmodified because server: points at the VIP, which the host can reach.

5.4 Joining a worker#

# on the control plane — prints a ready-to-paste command, token valid 24 h
sudo kubeadm token create --print-join-command
# kubeadm join 192.168.56.5:6443 --token <t> --discovery-token-ca-cert-hash sha256:<h>
# on the worker
sudo kubeadm join 192.168.56.5:6443 --token <t> --discovery-token-ca-cert-hash sha256:<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.

5.5 Joining a second control plane#

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
sudo kubeadm init phase upload-certs --upload-certs

# one-liner producing the complete join command
sudo kubeadm token create --print-join-command \
  --certificate-key "$(sudo kubeadm init phase upload-certs --upload-certs | tail -n1)"
# on cp2
sudo kubeadm join 192.168.56.5:6443 --token <t> --discovery-token-ca-cert-hash sha256:<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.certificateKeynot 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.

5.6 Where this differs from older kubeadm notes#

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
apt-get install -y kubelet kubeadm kubectl (unpinned) pin the exact version (kubelet=1.36.3-*) then apt-mark hold: an accidental apt upgrade otherwise breaks the kubelet/apiserver skew
apt-get install containerd (Debian, 1.7.x) containerd 2.x from the Docker repo — 1.7 has no CRI RuntimeConfig and is a dead end (§9)
sandbox_image = "registry.k8s.io/pause:3.10.2" hard-coded ask the tool: kubeadm config images list — and in containerd 2.x the key is sandbox under [plugins.'io.containerd.cri.v1.images'.pinned_images]
kubeadm init --apiserver-advertise-address 192.168.56.10 (no VIP) --control-plane-endpoint <VIP>:6443 from the start, even with a single CP (§9)

📦 6. What comes next: the application layer#

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/.

./kubeadm/cluster-up.sh                     # 1. the cluster (NotReady: no CNI yet)

./_k8s/platform-up.sh                       # 2. CNI → Envoy Gateway → metrics-server → TLS
./_k8s/install.sh longhorn vault argocd     # 3. opt-in addons

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.

6.1 The two manual prerequisites#

Nothing in the cluster can do these for you.

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 -n envoy-gateway-system get svc -o wide | grep LoadBalancer   # 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.


♻️ 7. Lifecycle#

vagrant status                 # VM state
vagrant halt                   # power off (the cluster comes back on the next `up`)
vagrant up                     # power back on
vagrant destroy -f             # delete every VM

After a destroy, also clear the host-side state before rebuilding:

rm -rf _out kubeconfig

Keeping the repo current is two commands, not one — git pull moves this repo only, and leaves _k8s/ on the previously pinned commit:

git pull                                  # this repo (Vagrantfile, kubeadm/, docs)
git submodule update --init --recursive   # _k8s/ back onto the commit this repo pins
git submodule update --remote _k8s        # or: jump to the latest k8s-playground

7.1 Growing the lab#

cluster-up.sh is idempotent, and that is the procedure for adding nodes:

  1. raise WORKERS (or CONTROL_PLANES, keeping it odd) in lab.env;
  2. vagrant up — only the new VMs get created and provisioned;
  3. ./kubeadm/cluster-up.sh — it skips everything already in place and joins only the new nodes, with freshly generated credentials.

No certificate regeneration is needed: the certSANs already cover 5 control-plane IPs (§5.2).

To remove a worker, drain it first so the cluster stops scheduling on a machine that is about to vanish:

kubectl drain k8s-w3 --ignore-daemonsets --delete-emptydir-data
vagrant destroy -f k8s-w3
kubectl delete node k8s-w3

then lower WORKERS in lab.env.

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.


🚑 8. Troubleshooting#

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-IP 10.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. How it works (under the hood)#

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.

9.4 node-ip forced on every node#

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>}].

9.5 kubeadm API v1beta4#

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.

# v1beta3:  extraArgs: {bind-address: "0.0.0.0"}
# v1beta4:  extraArgs: [{name: bind-address, value: "0.0.0.0"}]

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.

🌐 10. CNI: Cilium, Calico or Flannel#

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).

CNI= Who installs it LoadBalancer IP _k8s/ layer usable
cilium (default) platform-up.shk8s-playground cilium/ ✅ pool + L2/ARP announcement ✅ yes
calico platform-up.shk8s-playground calico/ ❌ BGP only ⚠️ needs MetalLB on top
flannel platform-up.sh ❌ no
none you depends on what you 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.


🛠️ 11. Validating a change#

Everything can be validated without booting a cluster:

make validate       # shell + YAML + Vagrantfile + kubeadm templates + doc links
make docs           # regenerates docs/index.html from every README (EN + FR)
make help           # 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 validate if 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 validate must 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.


📄 12. License#

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 :

git clone --recurse-submodules https://github.com/OPS-NC/Vagrant-kubeadm.git
cd Vagrant-kubeadm
cp lab.env.example lab.env      # choisir la topologie
vagrant up                      # 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
📖 Documentation navigable ops-nc.github.io/Vagrant-kubeadm — bascule EN/FR, thème clair/sombre, copie hors ligne avec make docs
📦 Couche applicative ops-nc.github.io/k8s-playground — dépôt à part, monté ici comme sous-module _k8s/
⬆️ Montée de version Kubernetes kubeadm/MISE-A-JOUR.md
🚑 Quelque chose casse ? DEPANNAGE.md

⚠️ --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.


🧰 1. Prérequis (sur l'hôte)#

Outil Rôle Installation
VirtualBox 7 hyperviseur https://www.virtualbox.org/
Vagrant création des VM https://developer.hashicorp.com/vagrant
git le dépôt et son sous-module _k8s/ https://git-scm.com/
kubectl utilisation du cluster https://kubernetes.io/docs/tasks/tools/
helm addons _k8s/ https://helm.sh/docs/intro/install/
uv (optionnel) make docs https://docs.astral.sh/uv/

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 :

git submodule update --init --recursive     # remplit _k8s/ sur un clone existant
git submodule update --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-only 192.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.


⚙️ 3. Choisir la topologie — lab.env#

La topologie vit dans lab.env, source unique lue par le Vagrantfile, par kubeadm/cluster-up.sh et par les scripts _k8s/*-up.sh. Partir du modèle versionné (lab.env.example ; lab.env est gitignoré) :

cp lab.env.example lab.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.iodoit correspondre à K8S_VERSION
CONTAINERD_SOURCE docker dockercontainerd.io 2.x (dépôt Docker) · debian → containerd 1.7 (cf. §9)
REGISTRY_MIRROR (vide) miroir pull-through (Harbor…) → /etc/containerd/certs.d/docker.io/hosts.toml
CONTROL_PLANES 1 1 = simple, 3 = HA. Un nombre pair est refusé
WORKERS 2 nombre de workers ; 0 est valide (cf. UNTAINT_CP)
CP_MEM / CP_CPU 3072 / 2 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 Vagrantfile et 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é.


🚀 4. Démarrer le cluster#

vagrant up                      # 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 sshnode-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.

Ensuite, depuis l'hôte :

export KUBECONFIG="$PWD/kubeconfig"
kubectl get nodes -o wide

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 :

CONTROL_PLANES=3 WORKERS=3 vagrant up
CONTROL_PLANES=3 WORKERS=3 ./kubeadm/cluster-up.sh

🎓 5. Faire la même chose à la main#

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.

5.1 Ce qui est déjà en place après vagrant up#

vagrant ssh k8s-cp1
sudo -i
kubeadm version -o short                 # v1.36.3, gelé par apt-mark
containerd --version                     # 2.x quand CONTAINERD_SOURCE=docker
crictl ps                                # parle à /run/containerd/containerd.sock
ip -4 addr show | grep 192.168.56.5      # la VIP est DÉJÀ là, avant tout init
systemctl status keepalived
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.

5.2 kubeadm init sur le premier control plane#

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é :

sudo kubeadm init --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 :

sudo kubeadm init \
  --control-plane-endpoint 192.168.56.5:6443 \
  --apiserver-advertise-address 192.168.56.10 \
  --pod-network-cidr 10.244.0.0/16 \
  --service-cidr 10.96.0.0/12 \
  --cri-socket unix:///run/containerd/containerd.sock \
  --apiserver-cert-extra-sans 192.168.56.5,192.168.56.10,192.168.56.20,192.168.56.30 \
  --upload-certs \
  --skip-phases=addon/kube-proxy

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.

5.3 Récupérer un kubeconfig#

Dans la VM :

mkdir -p "$HOME/.kube"
sudo install -o "$(id -u)" -g "$(id -g)" -m 0600 /etc/kubernetes/admin.conf "$HOME/.kube/config"
kubectl get nodes

Sur l'hôte — aucun scp, le dossier synchronisé est là :

vagrant ssh k8s-cp1 -c 'sudo cat /etc/kubernetes/admin.conf' > kubeconfig
chmod 0600 kubeconfig && export KUBECONFIG="$PWD/kubeconfig"

Il fonctionne tel quel parce que server: pointe sur la VIP, que l'hôte sait joindre.

5.4 Joindre un worker#

# sur le control plane — imprime une commande prête à coller, token valable 24 h
sudo kubeadm token create --print-join-command
# kubeadm join 192.168.56.5:6443 --token <t> --discovery-token-ca-cert-hash sha256:<h>
# sur le worker
sudo kubeadm join 192.168.56.5:6443 --token <t> --discovery-token-ca-cert-hash sha256:<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.

5.5 Joindre un second control plane#

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
sudo kubeadm init phase upload-certs --upload-certs

# en une commande, la ligne de jonction complète
sudo kubeadm token create --print-join-command \
  --certificate-key "$(sudo kubeadm init phase upload-certs --upload-certs | tail -n1)"
# sur cp2
sudo kubeadm join 192.168.56.5:6443 --token <t> --discovery-token-ca-cert-hash sha256:<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
apt-get install -y kubelet kubeadm kubectl (sans épinglage) épingler la version exacte (kubelet=1.36.3-*) puis apt-mark hold : sinon un apt upgrade involontaire casse le skew kubelet/apiserver
apt-get install containerd (Debian, 1.7.x) containerd 2.x du dépôt Docker — la 1.7 n'a pas la méthode CRI RuntimeConfig et c'est une impasse (§9)
sandbox_image = "registry.k8s.io/pause:3.10.2" codé en dur demander à l'outil : kubeadm config images list — et en containerd 2.x la clé est sandbox sous [plugins.'io.containerd.cri.v1.images'.pinned_images]
kubeadm init --apiserver-advertise-address 192.168.56.10 (sans VIP) --control-plane-endpoint <VIP>:6443 dès le départ, même avec un seul CP (§9)

📦 6. La suite : la couche applicative#

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.sh longhorn vault argocd     # 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.

6.1 Les deux prérequis manuels#

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 :

kubectl -n envoy-gateway-system get svc -o wide | grep LoadBalancer   # l'IP réellement posée
# /etc/hosts
# 192.168.56.200  argo.kubeadm.lab.example.io grafana.kubeadm.lab.example.io

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.


♻️ 7. Cycle de vie#

vagrant status                 # état des VM
vagrant halt                   # extinction (le cluster revient au `up` suivant)
vagrant up                     # rallumage
vagrant destroy -f             # suppression de toutes les VM

Après un destroy, nettoyer aussi l'état côté hôte avant de reconstruire :

rm -rf _out kubeconfig

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 :

git pull                                  # ce dépôt (Vagrantfile, kubeadm/, docs)
git submodule update --init --recursive   # _k8s/ ramené sur le commit épinglé ici
git submodule update --remote _k8s        # ou : sauter au dernier k8s-playground

7.1 Agrandir le lab#

cluster-up.sh est idempotent, et c'est la procédure d'ajout de nodes :

  1. augmenter WORKERS (ou CONTROL_PLANES, en le gardant impair) dans lab.env ;
  2. vagrant up — seules les nouvelles VM sont créées et préparées ;
  3. ./kubeadm/cluster-up.sh — il saute tout ce qui est en place et ne joint que les nouveaux nodes, avec des éléments de jonction fraîchement générés.

Aucune régénération de certificat n'est nécessaire : les certSANs couvrent déjà 5 IP de control plane (§5.2).

Pour retirer un worker, le drainer d'abord, afin que le cluster cesse de planifier sur une machine sur le point de disparaître :

kubectl drain k8s-w3 --ignore-daemonsets --delete-emptydir-data
vagrant destroy -f k8s-w3
kubectl delete node k8s-w3

puis baisser WORKERS dans lab.env.

7.2 Défaire le cluster sans détruire les VM#

./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.


🚑 8. Dépannage#

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-IP 10.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. Comment ça marche sous le capot#

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.

9.4 node-ip forcé sur chaque node#

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>}].

9.5 API kubeadm v1beta4#

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.

# v1beta3 :  extraArgs: {bind-address: "0.0.0.0"}
# v1beta4 :  extraArgs: [{name: bind-address, value: "0.0.0.0"}]

make validate-kubeadm attrape exactement ça, en CI, sans cluster.

9.6 Le dossier synchronisé /vagrant est un rouage, pas un confort#

cluster-up.sh ne 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.

🌐 10. CNI : Cilium, Calico ou Flannel#

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).

CNI= Qui l'installe IP LoadBalancer Couche _k8s/ utilisable
cilium (défaut) platform-up.shcilium/ de k8s-playground ✅ pool + annonce L2/ARP ✅ oui
calico platform-up.shcalico/ de k8s-playground ❌ BGP uniquement ⚠️ MetalLB en plus
flannel platform-up.sh ❌ non
none toi dépend de ce que tu installes

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.


🛠️ 11. Valider une modification#

Tout se valide sans monter de cluster :

make validate       # shell + YAML + Vagrantfile + modèles kubeadm + liens de la doc
make docs           # régénère docs/index.html depuis tous les README (EN + FR)
make help           # 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 validate si 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 validate doit ê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é.


📄 12. Licence#

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.


🎯 1. The two rules you cannot bend#

One MINOR version at a time#

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).

The kubelet must never be ahead of the apiserver#

Component Allowed relative to kube-apiserver
kube-apiserver (HA, several control planes) within 1 minor of each other
kubelet up to 3 minors oldernever newer
kubectl 1 minor either side

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#

apt-mark hold is deliberate#

kubeadm/provision.sh ends its package step with:

apt-mark hold kubelet kubeadm kubectl

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:

vagrant ssh k8s-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.env
K8S_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.


🧭 3. The lab shortcut: destroy and rebuild#

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
vagrant destroy -f
vagrant up
./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.


⚡ 4. The real procedure, on a running cluster#

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#

export KUBECONFIG="$PWD/kubeconfig"
kubectl get nodes -o wide                 # every node Ready, all on the same version
kubectl get pods -A | grep -v Running     # nothing broken before you start
kubectl get --raw='/healthz/etcd'         # etcd healthy
vagrant ssh k8s-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.

4.2 First control plane (k8s-cp1)#

vagrant ssh k8s-cp1
# 1. Point apt at the NEW minor's repository
echo "deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] \
https://pkgs.k8s.io/core:/stable:/v1.37/deb/ /" \
  | sudo tee /etc/apt/sources.list.d/kubernetes.list

# 2. Upgrade kubeadm ONLY
sudo apt-mark unhold kubeadm && \
sudo apt-get update && sudo apt-get install -y kubeadm='1.37.x-*' && \
sudo apt-mark hold kubeadm
kubeadm version

# 3. What would happen?
sudo kubeadm upgrade plan

# 4. Apply — this is the step that upgrades the control plane components
sudo kubeadm upgrade apply v1.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)
kubectl drain k8s-cp1 --ignore-daemonsets

# 6. Upgrade kubelet and kubectl
sudo apt-mark unhold kubelet kubectl && \
sudo apt-get update && sudo apt-get install -y kubelet='1.37.x-*' kubectl='1.37.x-*' && \
sudo apt-mark hold kubelet kubectl

# 7. Restart the kubelet
sudo systemctl daemon-reload
sudo systemctl restart kubelet

# 8. Put the node back into service
kubectl uncordon k8s-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:

kubectl get nodes            # k8s-cp1 Ready, VERSION v1.37.x
kubectl get --raw='/healthz/etcd'

4.3 The other control planes (k8s-cp2, k8s-cp3)#

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 upgrade
echo "deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] \
https://pkgs.k8s.io/core:/stable:/v1.37/deb/ /" \
  | sudo tee /etc/apt/sources.list.d/kubernetes.list
sudo apt-mark unhold kubeadm && \
sudo apt-get update && sudo apt-get install -y kubeadm='1.37.x-*' && \
sudo apt-mark hold kubeadm

# 2. Upgrade this node's control plane components
sudo kubeadm upgrade node

# 3. Drain, kubelet, restart, uncordon — identical to §4.2
kubectl drain k8s-cp2 --ignore-daemonsets
sudo apt-mark unhold kubelet kubectl && \
sudo apt-get update && sudo apt-get install -y kubelet='1.37.x-*' kubectl='1.37.x-*' && \
sudo apt-mark hold kubelet kubectl
sudo systemctl daemon-reload
sudo systemctl restart kubelet
kubectl uncordon k8s-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:

while true; do curl -sk -o /dev/null -w '%{http_code} ' https://192.168.56.5:6443/livez; sleep 1; done

4.4 The workers (k8s-w1, k8s-w2, …)#

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/ /" \
  | sudo tee /etc/apt/sources.list.d/kubernetes.list
sudo apt-mark unhold kubeadm && \
sudo apt-get update && sudo apt-get install -y kubeadm='1.37.x-*' && \
sudo apt-mark hold kubeadm

sudo kubeadm upgrade node       # on a worker this only updates the local kubelet config

kubectl drain k8s-w1 --ignore-daemonsets
sudo apt-mark unhold kubelet kubectl && \
sudo apt-get update && sudo apt-get install -y kubelet='1.37.x-*' kubectl='1.37.x-*' && \
sudo apt-mark hold kubelet kubectl
sudo systemctl daemon-reload
sudo systemctl restart kubelet
kubectl uncordon k8s-w1

4.5 After the upgrade#

kubectl get nodes -o wide            # every node Ready, all on v1.37.x
kubectl get pods -A | grep -v Running
kubectl version

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.x and K8S_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.


🔐 5. Certificates#

kubeadm issues client and serving certificates valid for 1 year, signed by a CA valid for 10 years.

vagrant ssh k8s-cp1 -c "sudo kubeadm certs check-expiration"

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:

vagrant ssh k8s-cp1
sudo kubeadm certs renew all
sudo systemctl restart kubelet    # 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:

vagrant ssh k8s-cp1 -c "sudo cp /etc/kubernetes/admin.conf /vagrant/_out/admin.conf"
cp -f _out/admin.conf kubeconfig && chmod 0600 kubeconfig

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#

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.

kubectl drain k8s-w1 --ignore-daemonsets
vagrant ssh k8s-w1 -c "sudo apt-get update && sudo apt-get install -y --only-upgrade containerd.io"
vagrant ssh k8s-w1 -c "containerd --version"
kubectl uncordon k8s-w1

⚠️ 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.

Cilium#

# lab.env: CILIUM_VERSION=1.2x.y
./_k8s/cilium/cilium-up.sh

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 -n kube-system exec ds/cilium -- cilium-dbg status --verbose
kubectl -n envoy-gateway-system get svc      # the Gateway must keep its EXTERNAL-IP

Everything else in the VMs#

Debian packages (keepalived included) follow a plain apt upgrade. That is safe precisely because kubelet/kubeadm/kubectl are held:

vagrant ssh k8s-cp1 -c "sudo apt-get update && sudo apt-get -y upgrade"

📚 References#

kubeadm/MISE-A-JOUR.md

⬆️Monter Kubernetes de version

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.


🎯 1. Les deux règles qu'on ne contourne pas#

Une MINEURE à la fois#

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).

Le kubelet ne doit jamais dépasser l'apiserver#

Composant Autorisé par rapport à kube-apiserver
kube-apiserver (HA, plusieurs control planes) à 1 mineure les uns des autres
kubelet jusqu'à 3 mineures en retardjamais en avance
kubectl 1 mineure de part et d'autre

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#

apt-mark hold est délibéré#

kubeadm/provision.sh termine son étape paquets par :

apt-mark hold kubelet kubeadm kubectl

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 :

vagrant ssh k8s-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.env
K8S_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.


🧭 3. La voie du lab : détruire et reconstruire#

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
vagrant destroy -f
vagrant up
./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.


⚡ 4. La vraie procédure, sur un cluster en route#

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é#

export KUBECONFIG="$PWD/kubeconfig"
kubectl get nodes -o wide                 # tous les nodes Ready, tous sur la même version
kubectl get pods -A | grep -v Running     # rien de cassé avant de commencer
kubectl get --raw='/healthz/etcd'         # etcd en bonne santé
vagrant ssh k8s-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.

4.2 Premier control plane (k8s-cp1)#

vagrant ssh k8s-cp1
# 1. Pointer apt sur le dépôt de la NOUVELLE mineure
echo "deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] \
https://pkgs.k8s.io/core:/stable:/v1.37/deb/ /" \
  | sudo tee /etc/apt/sources.list.d/kubernetes.list

# 2. Monter kubeadm SEUL
sudo apt-mark unhold kubeadm && \
sudo apt-get update && sudo apt-get install -y kubeadm='1.37.x-*' && \
sudo apt-mark hold kubeadm
kubeadm version

# 3. Que se passerait-il ?
sudo kubeadm upgrade plan

# 4. Appliquer — c'est l'étape qui monte les composants du control plane
sudo kubeadm upgrade apply v1.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)
kubectl drain k8s-cp1 --ignore-daemonsets

# 6. Monter kubelet et kubectl
sudo apt-mark unhold kubelet kubectl && \
sudo apt-get update && sudo apt-get install -y kubelet='1.37.x-*' kubectl='1.37.x-*' && \
sudo apt-mark hold kubelet kubectl

# 7. Redémarrer le kubelet
sudo systemctl daemon-reload
sudo systemctl restart kubelet

# 8. Remettre le node en service
kubectl uncordon k8s-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 :

kubectl get nodes            # k8s-cp1 Ready, VERSION v1.37.x
kubectl get --raw='/healthz/etcd'

4.3 Les autres control planes (k8s-cp2, k8s-cp3)#

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 kubeadm
echo "deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] \
https://pkgs.k8s.io/core:/stable:/v1.37/deb/ /" \
  | sudo tee /etc/apt/sources.list.d/kubernetes.list
sudo apt-mark unhold kubeadm && \
sudo apt-get update && sudo apt-get install -y kubeadm='1.37.x-*' && \
sudo apt-mark hold kubeadm

# 2. Monter les composants de control plane de CE node
sudo kubeadm upgrade node

# 3. Drain, kubelet, redémarrage, uncordon — identique au §4.2
kubectl drain k8s-cp2 --ignore-daemonsets
sudo apt-mark unhold kubelet kubectl && \
sudo apt-get update && sudo apt-get install -y kubelet='1.37.x-*' kubectl='1.37.x-*' && \
sudo apt-mark hold kubelet kubectl
sudo systemctl daemon-reload
sudo systemctl restart kubelet
kubectl uncordon k8s-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 :

while true; do curl -sk -o /dev/null -w '%{http_code} ' https://192.168.56.5:6443/livez; sleep 1; done

4.4 Les workers (k8s-w1, k8s-w2, …)#

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/ /" \
  | sudo tee /etc/apt/sources.list.d/kubernetes.list
sudo apt-mark unhold kubeadm && \
sudo apt-get update && sudo apt-get install -y kubeadm='1.37.x-*' && \
sudo apt-mark hold kubeadm

sudo kubeadm upgrade node       # sur un worker, ne met à jour que la config locale du kubelet

kubectl drain k8s-w1 --ignore-daemonsets
sudo apt-mark unhold kubelet kubectl && \
sudo apt-get update && sudo apt-get install -y kubelet='1.37.x-*' kubectl='1.37.x-*' && \
sudo apt-mark hold kubelet kubectl
sudo systemctl daemon-reload
sudo systemctl restart kubelet
kubectl uncordon k8s-w1

4.5 Après la montée de version#

kubectl get nodes -o wide            # tous les nodes Ready, tous en v1.37.x
kubectl get pods -A | grep -v Running
kubectl version

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.x et K8S_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.


🔐 5. Les certificats#

kubeadm émet des certificats client et serveur valables 1 an, signés par une AC valable 10 ans.

vagrant ssh k8s-cp1 -c "sudo kubeadm certs check-expiration"

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 :

vagrant ssh k8s-cp1
sudo kubeadm certs renew all
sudo systemctl restart kubelet    # 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 :

vagrant ssh k8s-cp1 -c "sudo cp /etc/kubernetes/admin.conf /vagrant/_out/admin.conf"
cp -f _out/admin.conf kubeconfig && chmod 0600 kubeconfig

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#

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.

kubectl drain k8s-w1 --ignore-daemonsets
vagrant ssh k8s-w1 -c "sudo apt-get update && sudo apt-get install -y --only-upgrade containerd.io"
vagrant ssh k8s-w1 -c "containerd --version"
kubectl uncordon k8s-w1

⚠️ 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.

Cilium#

# lab.env : CILIUM_VERSION=1.2x.y
./_k8s/cilium/cilium-up.sh

À 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 -n kube-system exec ds/cilium -- cilium-dbg status --verbose
kubectl -n envoy-gateway-system get svc      # le Gateway doit garder son EXTERNAL-IP

Tout le reste dans les VM#

Les paquets Debian (keepalived compris) suivent un simple apt upgrade. C'est sans risque précisément parce que kubelet/kubeadm/kubectl sont en hold :

vagrant ssh k8s-cp1 -c "sudo apt-get update && sudo apt-get -y upgrade"

📚 Références#

TROUBLESHOOTING.md

🚑Troubleshooting

Organised by observed symptom, because that is what you actually have: an error message, not a theory. Install path: README.md · application layer: https://ops-nc.github.io/k8s-playground/ · version bumps: kubeadm/UPGRADE.md.

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:

export KUBECONFIG="$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.


🖥️ 1. Host, repository and VirtualBox#

vagrant up dies on VERR_VMX_IN_VMX_ROOT_MODE#

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 | grep kvm

# 2. Unload it (fails if a KVM/libvirt VM is still running — stop it first)
sudo modprobe -r kvm_intel kvm      # 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:

echo -e "blacklist kvm_intel\nblacklist kvm" | sudo tee /etc/modprobe.d/disable-kvm.conf

To revert: delete that file and reboot.

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.

git submodule update --init --recursive     # fills _k8s/
git -C _k8s log --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:

ls Vagrantfile lab.env kubeadm/cluster-up.sh   # the lab: marker, config, distro signature
ls -d _k8s/lib                                 # _k8s/ really is INSIDE the lab
git rev-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_DIR=/path/to/Vagrant-kubeadm ./_k8s/platform-up.sh

💡 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.env and the default KUBECONFIG at once. Neither is needed in the normal submodule layout.


🌐 2. The API VIP and keepalived#

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 /readyz through 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.

vagrant ssh k8s-cp1 -c "ip -4 addr show | grep 192.168.56.5"
vagrant ssh k8s-cp1 -c "sudo systemctl status keepalived"
vagrant ssh k8s-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:

vagrant ssh k8s-cp1 -c "cat /etc/kubeadm-lab/node.env"
vagrant ssh k8s-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.

vagrant ssh k8s-cp1 -c "sudo crictl ps -a | grep apiserver"
vagrant ssh k8s-cp1 -c "sudo journalctl -u kubelet -n 50 --no-pager"
vagrant ssh k8s-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:

for n in k8s-cp1 k8s-cp2 k8s-cp3; do
  echo -n "$n: " ; vagrant ssh "$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
vagrant ssh k8s-cp1 -c "sudo sed -n '/unicast/,/}/p' /etc/keepalived/keepalived.conf"

# The router ID must be IDENTICAL on all control planes
for n in k8s-cp1 k8s-cp2 k8s-cp3; do
  vagrant ssh "$n" -c "sudo sed -n 's/.*virtual_router_id //p' /etc/keepalived/keepalived.conf" -- -q
done

vagrant ssh k8s-cp2 -c "sudo journalctl -u keepalived -n 80 --no-pager"

Three real causes, in order of likelihood:

  1. 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.
  2. Divergent VRRP_ROUTER_ID — the nodes were provisioned with different lab.env values. All control planes of one cluster must share the same ID.
  3. 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.


☸️ 3. Nodes and kubeadm#

The nodes stay NotReady#

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.

Confirm the diagnosis rather than guessing:

kubectl describe node k8s-cp1 | sed -n '/Conditions:/,/Addresses:/p'

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 still NotReady after the CNI install:

kubectl -n kube-system get pods -l k8s-app=cilium -o wide
kubectl -n kube-system logs ds/cilium --tail=50
kubectl -n kube-system logs deploy/cilium-operator --tail=50

CoreDNS stays Pending#

kube-system   coredns-xxxxxxxxx-aaaaa   0/1   Pending   0   3m
kube-system   coredns-xxxxxxxxx-bbbbb   0/1   Pending   0   3m

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 -n kube-system describe pod -l k8s-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 Pending after the nodes are Ready, then look at real scheduling constraints (WORKERS=0 with UNTAINT_CP=false, for instance, leaves nowhere to schedule).

Every node shows the same IP, 10.0.2.15#

kubectl get nodes -o wide
# NAME      INTERNAL-IP   …
# k8s-cp1   10.0.2.15     …
# k8s-w1    10.0.2.15     …
# k8s-w2    10.0.2.15     …

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
vagrant ssh k8s-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:

./kubeadm/cluster-reset.sh && ./kubeadm/cluster-up.sh

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) 2 h
error decoding certificate key / decryption failure the certificate key does not match the Secret 2 h

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.sh regenerates 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:

vagrant ssh k8s-cp1 -c "sudo kubeadm init phase upload-certs --upload-certs \\
     --config /vagrant/_out/kubeadm-init.yaml"                                  # new certificate key
vagrant ssh k8s-cp1 -c "sudo kubeadm token create --print-join-command"        # new token + CA hash

Both commands are safe to replay on a running cluster.

upload-certs fails with x509: … not 10.0.2.15#

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.AdvertiseAddressnot 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:

vagrant ssh k8s-cp1 -c "free -m ; swapon --show ; systemctl list-unit-files --type=swap"
vagrant provision k8s-cp1

ℹ️ 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
vagrant reload k8s-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).

vagrant ssh k8s-cp1 -c "containerd --version"
vagrant ssh k8s-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.


🔌 4. Pod network and Services#

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:

vagrant ssh k8s-w1 -c "ip -o link show | grep -E 'cilium|lxc|flannel|cali|cni0'"
vagrant ssh k8s-w1 -c "sudo ls /sys/fs/bpf/tc/globals/ 2>/dev/null"
vagrant ssh k8s-w1 -c "sudo iptables-save | grep -cE 'KUBE-|CILIUM_|cali-'"
vagrant ssh k8s-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:

vagrant ssh k8s-w1 -c "sudo bash /vagrant/kubeadm/node-reset.sh"

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.

A LoadBalancer Service stays <pending>#

kubectl -n envoy-gateway-system get svc
# 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.

kubectl get ciliumloadbalancerippool
kubectl get ciliuml2announcementpolicy
kubectl -n kube-system logs deploy/cilium-operator --tail=50
sed -n 's/^HOSTONLY_IF=//p' _out/cluster.env

The pool is 192.168.56.200.230 by default (LB_POOL_START/LB_POOL_END), and the announcement interface comes from the detected HOSTONLY_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>.

Changing the pool is a re-run away:

./_k8s/cilium/cilium-up.sh

🗄️ 5. etcd and cluster performance#

etcd loses its leader, or the whole cluster crawls#

Symptoms.

etcdserver: request timed out
apply request took too long
waiting for ReadIndex response took too long, retrying
leader changed

kubectl takes seconds to answer, pods stay Pending, the apiserver restarts on its own.

kubectl get --raw='/healthz/etcd'
kubectl -n kube-system logs -l component=etcd --tail=50
vagrant ssh k8s-cp1 -c "sudo crictl logs \$(sudo crictl ps -q --name etcd | head -1) 2>&1 | tail -40"
vagrant ssh k8s-cp1 -c "free -m ; uptime"

Causes, in order of frequency on this lab:

  1. 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.
  2. 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.
  3. 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:
    vagrant reload k8s-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.


🔐 6. Lab UIs over HTTPS#

An HTTPS UI is unreachable#

Work down the chain, in this order — each step assumes the previous one.

1. Does the Gateway have an IP?

kubectl -n envoy-gateway-system get gateway main-gateway -o jsonpath='{.status.addresses[0].value}'; echo

Empty or <pending> → this is a LoadBalancer problem, see section 4. The expected address is the first IP of the pool, 192.168.56.200 by default.

2. Does the name resolve to that IP?

The lab domain (LAB_DOMAIN, kubeadm.lab.example.io by default) has no reason to resolve on your machine. platform-up.sh prints the line to add:

# /etc/hosts on the HOST
192.168.56.200  argo.kubeadm.lab.example.io grafana.kubeadm.lab.example.io vault.kubeadm.lab.example.io

…or a wildcard A record *.<LAB_DOMAIN> → 192.168.56.200 if you own a DNS zone. Check:

getent hosts argo.kubeadm.lab.example.io
ping -c1 192.168.56.200

⚠️ On the ACME path (SELF_SIGNED=false) behind Cloudflare, the record must be DNS-only (grey cloud): the Cloudflare proxy cannot reach a private IP.

3. Is there an HTTPRoute for that hostname?

kubectl get httproute -A
kubectl -n <ns> describe httproute <name> | sed -n '/Status:/,$p'   # Accepted / ResolvedRefs

4. Is the TLS mode the one you think it is?

sed -n 's/^SELF_SIGNED=//p' lab.env
kubectl -n envoy-gateway-system get secret | grep wildcard
Mode Expected behaviour
SELF_SIGNED=true (default) a local CA signs the wildcard; the browser warns until you import _out/self-signed/ca.crt. No cert-manager is installed.
SELF_SIGNED=false, LAB_ACME_ISSUER=staging Let's Encrypt staging: the certificate is real but not trusted — a browser warning is expected.
SELF_SIGNED=false, LAB_ACME_ISSUER=prod publicly trusted — but limited to 5 certificates per week for a given *.<LAB_DOMAIN>.

A browser warning is therefore normal in two of the three modes. To trust the local CA:

sudo cp _out/self-signed/ca.crt /usr/local/share/ca-certificates/vagrant-kubeadm-lab.crt
sudo update-ca-certificates

5. Still nothing? Look at the proxy itself:

kubectl -n envoy-gateway-system get pods
kubectl -n envoy-gateway-system logs deploy/envoy-gateway --tail=50

🧰 7. Toolbox#

From the host#

vagrant status                       # which VMs exist and are running
vagrant ssh k8s-cp1                  # interactive shell
vagrant ssh k8s-cp1 -c "<command>" -- -q -o LogLevel=ERROR   # one shot, quiet (what the scripts use)
vagrant provision k8s-cp1            # replay provision.sh (idempotent)
vagrant reload k8s-cp1               # restart, applying new CPU/RAM from lab.env

export KUBECONFIG="$PWD/kubeconfig"
kubectl get nodes -o wide
kubectl get pods -A -o wide
kubectl get events -A --sort-by=.lastTimestamp | tail -30
kubectl get --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.

Inside a VM#

cat /etc/kubeadm-lab/node.env                 # role, node IP, detected host-only interface
ip -4 addr show                               # is the VIP here?
sudo systemctl status kubelet containerd keepalived

sudo journalctl -u kubelet -f                 # follow live
sudo journalctl -u kubelet -n 100 --no-pager
sudo journalctl -u containerd -n 50 --no-pager
sudo journalctl -u keepalived -n 50 --no-pager

sudo crictl ps -a                             # containers, including dead ones
sudo crictl pods                              # sandboxes
sudo crictl logs <container-id>
sudo crictl images

sudo kubeadm certs check-expiration           # control planes only
sudo kubeadm config images list --kubernetes-version v1.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 change POD_CIDR, SERVICE_CIDR, the CNI or the VIP
vagrant destroy -f && vagrant up everything any doubt about system-level residue

📚 References#

DEPANNAGE.md

🚑Dépannage

Organisé par symptôme observé, parce que c'est ce qu'on a sous les yeux : un message d'erreur, pas une théorie. Parcours d'installation : LISEZ-MOI.md · couche applicative : https://ops-nc.github.io/k8s-playground/ · montées de version : kubeadm/MISE-A-JOUR.md.

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 :

export KUBECONFIG="$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.


🖥️ 1. Hôte, dépôt et VirtualBox#

vagrant up meurt sur VERR_VMX_IN_VMX_ROOT_MODE#

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 | grep kvm

# 2. Le décharger (échoue si une VM KVM/libvirt tourne encore — l'arrêter d'abord)
sudo modprobe -r kvm_intel kvm      # 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 :

echo -e "blacklist kvm_intel\nblacklist kvm" | sudo tee /etc/modprobe.d/disable-kvm.conf

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.

git submodule update --init --recursive     # remplit _k8s/
git -C _k8s log --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 :

ls Vagrantfile lab.env kubeadm/cluster-up.sh   # le lab : marqueur, config, signature distro
ls -d _k8s/lib                                 # _k8s/ est bien DANS le lab
git rev-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_DIR=/chemin/vers/Vagrant-kubeadm ./_k8s/platform-up.sh

💡 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.env et le KUBECONFIG par défaut d'un coup. Ni l'une ni l'autre n'est utile dans la disposition en sous-module.


🌐 2. La VIP de l'API et keepalived#

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.

vagrant ssh k8s-cp1 -c "ip -4 addr show | grep 192.168.56.5"
vagrant ssh k8s-cp1 -c "sudo systemctl status keepalived"
vagrant ssh k8s-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é :

vagrant ssh k8s-cp1 -c "cat /etc/kubeadm-lab/node.env"
vagrant ssh k8s-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.

vagrant ssh k8s-cp1 -c "sudo crictl ps -a | grep apiserver"
vagrant ssh k8s-cp1 -c "sudo journalctl -u kubelet -n 50 --no-pager"
vagrant ssh k8s-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 :

for n in k8s-cp1 k8s-cp2 k8s-cp3; do
  echo -n "$n : " ; vagrant ssh "$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
vagrant ssh k8s-cp1 -c "sudo sed -n '/unicast/,/}/p' /etc/keepalived/keepalived.conf"

# Le router ID doit être IDENTIQUE sur tous les control planes
for n in k8s-cp1 k8s-cp2 k8s-cp3; do
  vagrant ssh "$n" -c "sudo sed -n 's/.*virtual_router_id //p' /etc/keepalived/keepalived.conf" -- -q
done

vagrant ssh k8s-cp2 -c "sudo journalctl -u keepalived -n 80 --no-pager"

Trois causes réelles, par ordre de probabilité :

  1. 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.
  2. 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.
  3. 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.


☸️ 3. Nodes et kubeadm#

Les nodes restent NotReady#

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 :

kubectl describe node k8s-cp1 | sed -n '/Conditions:/,/Addresses:/p'

La condition Ready vaut False, avec pour raison KubeletNotReady et un message contenant :

container runtime network not ready: NetworkReady=false reason:NetworkPluginNotReady
message:Network plugin returns error: cni plugin not initialized

Correctif : lance l'étape suivante.

./_k8s/platform-up.sh

Avec CNI=none, personne n'installera jamais de réseau — c'est le sens du réglage, et cluster-up.sh affiche alors un message de fin différent.

Si les nodes sont toujours NotReady après l'installation du CNI :

kubectl -n kube-system get pods -l k8s-app=cilium -o wide
kubectl -n kube-system logs ds/cilium --tail=50
kubectl -n kube-system logs deploy/cilium-operator --tail=50

CoreDNS reste Pending#

kube-system   coredns-xxxxxxxxx-aaaaa   0/1   Pending   0   3m
kube-system   coredns-xxxxxxxxx-bbbbb   0/1   Pending   0   3m

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 -n kube-system describe pod -l k8s-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 Pending aprè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).

Tous les nodes ont la même IP, 10.0.2.15#

kubectl get nodes -o wide
# NAME      INTERNAL-IP   …
# k8s-cp1   10.0.2.15     …
# k8s-w1    10.0.2.15     …
# k8s-w2    10.0.2.15     …

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
vagrant ssh k8s-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 :

./kubeadm/cluster-reset.sh && ./kubeadm/cluster-up.sh

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.sh ré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 :

vagrant ssh k8s-cp1 -c "sudo kubeadm init phase upload-certs --upload-certs \\
     --config /vagrant/_out/kubeadm-init.yaml"                                  # nouvelle clé de certificats
vagrant ssh k8s-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.

upload-certs échoue sur x509: … not 10.0.2.15#

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 :

vagrant ssh k8s-cp1 -c "free -m ; swapon --show ; systemctl list-unit-files --type=swap"
vagrant provision k8s-cp1

ℹ️ 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
vagrant reload k8s-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).

vagrant ssh k8s-cp1 -c "containerd --version"
vagrant ssh k8s-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.


🔌 4. Réseau des pods et Services#

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 :

vagrant ssh k8s-w1 -c "ip -o link show | grep -E 'cilium|lxc|flannel|cali|cni0'"
vagrant ssh k8s-w1 -c "sudo ls /sys/fs/bpf/tc/globals/ 2>/dev/null"
vagrant ssh k8s-w1 -c "sudo iptables-save | grep -cE 'KUBE-|CILIUM_|cali-'"
vagrant ssh k8s-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 :

vagrant ssh k8s-w1 -c "sudo bash /vagrant/kubeadm/node-reset.sh"

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.

Un Service LoadBalancer reste <pending>#

kubectl -n envoy-gateway-system get svc
# 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.

kubectl get ciliumloadbalancerippool
kubectl get ciliuml2announcementpolicy
kubectl -n kube-system logs deploy/cilium-operator --tail=50
sed -n 's/^HOSTONLY_IF=//p' _out/cluster.env

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_IF dé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.

Changer le pool tient en une relance :

./_k8s/cilium/cilium-up.sh

🗄️ 5. etcd et performances du cluster#

etcd perd son leader, ou tout le cluster rame#

Symptômes.

etcdserver: request timed out
apply request took too long
waiting for ReadIndex response took too long, retrying
leader changed

kubectl met des secondes à répondre, les pods restent Pending, l'apiserver redémarre tout seul.

kubectl get --raw='/healthz/etcd'
kubectl -n kube-system logs -l component=etcd --tail=50
vagrant ssh k8s-cp1 -c "sudo crictl logs \$(sudo crictl ps -q --name etcd | head -1) 2>&1 | tail -40"
vagrant ssh k8s-cp1 -c "free -m ; uptime"

Causes, par fréquence sur ce lab :

  1. 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.
  2. 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.
  3. 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 :
    vagrant reload k8s-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.


🔐 6. Les UI du lab en HTTPS#

Une UI HTTPS est injoignable#

Descends la chaîne dans cet ordre — chaque étape suppose la précédente.

1. Le Gateway a-t-il une IP ?

kubectl -n envoy-gateway-system get gateway main-gateway -o jsonpath='{.status.addresses[0].value}'; echo

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ÔTE
192.168.56.200  argo.kubeadm.lab.example.io grafana.kubeadm.lab.example.io vault.kubeadm.lab.example.io

… ou un enregistrement A wildcard *.<LAB_DOMAIN> → 192.168.56.200 si tu as une zone DNS. Vérification :

getent hosts argo.kubeadm.lab.example.io
ping -c1 192.168.56.200

⚠️ 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.

3. Existe-t-il une HTTPRoute pour ce nom d'hôte ?

kubectl get httproute -A
kubectl -n <ns> describe httproute <nom> | sed -n '/Status:/,$p'   # Accepted / ResolvedRefs

4. Le mode TLS est-il celui que tu crois ?

sed -n 's/^SELF_SIGNED=//p' lab.env
kubectl -n envoy-gateway-system get secret | grep wildcard
Mode Comportement attendu
SELF_SIGNED=true (défaut) une AC locale signe le wildcard ; le navigateur avertit tant que _out/self-signed/ca.crt n'est pas importée. cert-manager n'est pas installé.
SELF_SIGNED=false, LAB_ACME_ISSUER=staging Let's Encrypt staging : le certificat est réel mais non trusté — l'avertissement du navigateur est attendu.
SELF_SIGNED=false, LAB_ACME_ISSUER=prod publiquement trusté — mais limité à 5 certificats par semaine pour un *.<LAB_DOMAIN> donné.

Un avertissement de navigateur est donc normal dans deux modes sur trois. Pour faire confiance à l'AC locale :

sudo cp _out/self-signed/ca.crt /usr/local/share/ca-certificates/vagrant-kubeadm-lab.crt
sudo update-ca-certificates

5. Toujours rien ? Regarde le proxy lui-même :

kubectl -n envoy-gateway-system get pods
kubectl -n envoy-gateway-system logs deploy/envoy-gateway --tail=50

🧰 7. Boîte à outils#

Depuis l'hôte#

vagrant status                       # quelles VM existent et tournent
vagrant ssh k8s-cp1                  # shell interactif
vagrant ssh k8s-cp1 -c "<commande>" -- -q -o LogLevel=ERROR   # un coup, silencieux (ce qu'utilisent les scripts)
vagrant provision k8s-cp1            # rejoue provision.sh (idempotent)
vagrant reload k8s-cp1               # redémarre en appliquant les CPU/RAM de lab.env

export KUBECONFIG="$PWD/kubeconfig"
kubectl get nodes -o wide
kubectl get pods -A -o wide
kubectl get events -A --sort-by=.lastTimestamp | tail -30
kubectl get --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.

Dans une VM#

cat /etc/kubeadm-lab/node.env                 # rôle, IP du node, interface host-only détectée
ip -4 addr show                               # la VIP est-elle ici ?
sudo systemctl status kubelet containerd keepalived

sudo journalctl -u kubelet -f                 # suivre en direct
sudo journalctl -u kubelet -n 100 --no-pager
sudo journalctl -u containerd -n 50 --no-pager
sudo journalctl -u keepalived -n 50 --no-pager

sudo crictl ps -a                             # conteneurs, y compris morts
sudo crictl pods                              # sandboxes
sudo crictl logs <id-conteneur>
sudo crictl images

sudo kubeadm certs check-expiration           # control planes uniquement
sudo kubeadm config images list --kubernetes-version v1.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 changer POD_CIDR, SERVICE_CIDR, le CNI ou la VIP
vagrant destroy -f && vagrant up tout le moindre doute sur un résidu système

📚 Références#

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.

🚫 _k8s/ is a SUBMODULE — never edit it from here#

_k8s/ is not a directory of this repository. It is a pinned checkout of OPS-NC/k8s-playground, the application layer shared with the Talos sibling lab: one source, one place to maintain it. Its documentation is published separately at https://ops-nc.github.io/k8s-playground/.

  • 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.

🚀 Order of work#

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.shkubeadm/node-reset.sh in every VM (workers first, so they deregister while the API still answers).

What lives where#

Path Role
lab.env.examplelab.env 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.
kubeadm/cluster-reset.sh / node-reset.sh undo the cluster, keep the VMs.
kubeadm/templates/*.yaml.tpl InitConfiguration / JoinConfiguration (v1beta4), @MARKER@ substitution.
_k8s/ git submodulek8s-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 same make targets. Never duplicate a check's definition in a workflow.

The files that carry state between layers#

File Written by Read by
_out/kubeadm-init.yaml, _out/join-<node>.yaml, _out/certsans.txt cluster-up.sh (host) 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)
CONTROL_PLANES, WORKERS, NODE_PREFIX Vagrantfile, kubeadm/cluster-up.sh, kubeadm/cluster-reset.sh
NETWORK, VIP, CP_IP_*, WK_IP_* Vagrantfile, kubeadm/cluster-up.sh, kubeadm/provision.sh
POD_CIDR, SERVICE_CIDR, CNI, KUBE_PROXY_REPLACEMENT kubeadm/cluster-up.shand, in k8s-playground, platform-up.sh, cilium/cilium-up.sh
LB_POOL_START / LB_POOL_END, CILIUM_VERSION k8s-playground only: platform-up.sh, cilium/cilium-up.sh
LAB_DOMAIN, SELF_SIGNED, LAB_ACME_ISSUER 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 silentlymake 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)#

make validate      # shell syntax + YAML parse + Vagrantfile + kubeadm templates + doc links
make docs          # regenerates docs/index.html (needs uv)
Target What it proves
validate-shell bash -n on every git-tracked *.shthe _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.

⚠️ Design pitfalls — do NOT reintroduce these#

Shell#

  • 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.

kubeadm#

  • 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.
    # v1beta3 — invalid now
    extraArgs: {bind-address: "0.0.0.0"}
    # v1beta4 — correct
    extraArgs:
      - name: bind-address
        value: "0.0.0.0"
    
    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 before kubeadm 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 _k8s/ submodule#

  • 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.

🔐 Secrets#

  • 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.

📝 Conventions#

  • 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.

Adding a component = propagating it EVERYWHERE#

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.

🧭 What is deliberately absent from this repo#

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.

🚫 _k8s/ is a SUBMODULE — never edit it from here#

_k8s/ is not a directory of this repository. It is a pinned checkout of OPS-NC/k8s-playground, the application layer shared with the Talos sibling lab: one source, one place to maintain it. Its documentation is published separately at https://ops-nc.github.io/k8s-playground/.

  • 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.

🚀 Order of work#

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.shkubeadm/node-reset.sh in every VM (workers first, so they deregister while the API still answers).

What lives where#

Path Role
lab.env.examplelab.env 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.
kubeadm/cluster-reset.sh / node-reset.sh undo the cluster, keep the VMs.
kubeadm/templates/*.yaml.tpl InitConfiguration / JoinConfiguration (v1beta4), @MARKER@ substitution.
_k8s/ git submodulek8s-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 same make targets. Never duplicate a check's definition in a workflow.

The files that carry state between layers#

File Written by Read by
_out/kubeadm-init.yaml, _out/join-<node>.yaml, _out/certsans.txt cluster-up.sh (host) 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)
CONTROL_PLANES, WORKERS, NODE_PREFIX Vagrantfile, kubeadm/cluster-up.sh, kubeadm/cluster-reset.sh
NETWORK, VIP, CP_IP_*, WK_IP_* Vagrantfile, kubeadm/cluster-up.sh, kubeadm/provision.sh
POD_CIDR, SERVICE_CIDR, CNI, KUBE_PROXY_REPLACEMENT kubeadm/cluster-up.shand, in k8s-playground, platform-up.sh, cilium/cilium-up.sh
LB_POOL_START / LB_POOL_END, CILIUM_VERSION k8s-playground only: platform-up.sh, cilium/cilium-up.sh
LAB_DOMAIN, SELF_SIGNED, LAB_ACME_ISSUER 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 silentlymake 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)#

make validate      # shell syntax + YAML parse + Vagrantfile + kubeadm templates + doc links
make docs          # regenerates docs/index.html (needs uv)
Target What it proves
validate-shell bash -n on every git-tracked *.shthe _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.

⚠️ Design pitfalls — do NOT reintroduce these#

Shell#

  • 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.

kubeadm#

  • 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.
    # v1beta3 — invalid now
    extraArgs: {bind-address: "0.0.0.0"}
    # v1beta4 — correct
    extraArgs:
      - name: bind-address
        value: "0.0.0.0"
    
    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 before kubeadm 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 _k8s/ submodule#

  • 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.

🔐 Secrets#

  • 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.

📝 Conventions#

  • 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.

Adding a component = propagating it EVERYWHERE#

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.

🧭 What is deliberately absent from this repo#

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.