☸️k8s-playground — the Kubernetes layer for the Talos and kubeadm labs
One repository of manifests and charts for two Vagrant labs. The application layer — CNI,
Gateway API, storage, secrets, observability, security — used to be duplicated in
Vagrant-Talos/_k8s/ and again in Vagrant-KubeADM/_k8s/. It now lives here once, and both labs
mount this repository at that same _k8s/ path as a git submodule. Mounted that way, the lab
and its distribution are found on their own — no argument, no environment variable:
./_k8s/platform-up.sh# from either lab's root
./_k8s/install.shlonghornvault# add-ons, opt-in
📖 Browsable documentation: https://ops-nc.github.io/k8s-playground/ — one self-contained page,
bilingual (EN/FR), dark/light, rebuilt from these README.md / LISEZ-MOI.md files on every push to
main (make docs builds it locally).
Both labs remain responsible for bootstrapping the cluster (VMs, OS, kubeadm init /
talosctl bootstrap). This repository covers what comes after, with kubectl and helm from the
host — including the CNI, because neither bootstrap leaves a usable pod network behind.
You never start here: you start in a lab, which is where the cluster and its state live.
# 1. The lab — its submodule included (Talos twin: OPS-NC/Vagrant-Talos)
gitclone--recurse-submoduleshttps://github.com/OPS-NC/Vagrant-kubeadm.git
cdVagrant-kubeadm
cplab.env.examplelab.env# the LAB's model: topology, domain, TLS mode, CNI
vagrantup&&./kubeadm/cluster-up.sh# nodes are NotReady: no CNI yet# 2. Lay down the base platform — nothing to declare
./_k8s/platform-up.sh# CNI → Envoy Gateway → metrics-server → wildcard TLS# 3. Add-ons, opt-in
./_k8s/install.shlonghornvaultargocd
./_k8s/install.shlist# the full catalogue
./_k8s/install.shall# platform + every add-on, in dependency order
From the lab root this repository is <lab>/_k8s: the lab is its parent, recognised by its
Vagrantfile, and the distribution is read off that lab's own structure —
kubeadm/cluster-up.sh here, talos/cluster-up.sh in the Talos twin. KUBECONFIG is derived the
same way (<lab>/kubeconfig); export it yourself only for your ownkubectl calls. Passing the
distribution explicitly still works and still wins, which is the surest way to know what you ran:
./_k8s/install.sh kubeadm platform.
Every component is also runnable on its own:
./_k8s/longhorn/longhorn-up.sh
🎓 Training mode. Every directory ships a README.md (EN) / LISEZ-MOI.md (FR) with a
"Guided walkthrough" section: the same installation, command by command, with what to observe
at each step and the per-distribution variants. The all-in-one script and the walkthrough do
exactly the same thing.
Variant — the two repositories side by side (the pre-submodule layout, still supported)
cd../Vagrant-Talos&&./talos/cluster-up.sh# or ../Vagrant-KubeADM && ./kubeadm/cluster-up.shcd../k8s-playground# this repo, sibling of the lab
./install.shtalosplatform# one lab next door ⇒ the argument is optional here too
Here the lab sits next to this repo, not above it, so the parent-Vagrantfile rule does not
fire: the lab directory is found through LAB_REPO_NAME (../Vagrant-Talos,
../Vagrant-KubeADM), which the distribution profile supplies, and the distribution itself is
read off those same two candidate directories, tested by name. With both labs cloned side by
side the choice is ambiguous, so the scripts refuse to guess and you pass talos/kubeadm. That is
the only difference between the two layouts — and the reason the submodule layout is the recommended
one: it pins the application layer to one commit per lab, which is what makes a lab reproducible.
Installing and updating the submodule, from the lab:
gitclone--recurse-submoduleshttps://github.com/OPS-NC/Vagrant-kubeadm.git
gitsubmoduleupdate--init--recursive# fills _k8s/ on a clone already made
gitsubmoduleupdate--remote_k8s# moves _k8s/ onto the latest commit of this repo
⚠️ git pull in the lab does NOT update the submodule. It moves the lab repository only;
_k8s/ stays on the commit pinned before, so you would be running the documented commands against
an older application layer. Follow every pull with
git submodule update --init --recursive. And an empty _k8s/ — ./_k8s/install.sh: No such file or directory — always means one thing: the submodule was never initialised.
💡 Changing this layer is a change made here. Seen from a lab, _k8s/ is a checkout of this
repository on a detached HEAD: files edited in it belong to this repo, and a git commit run
inside _k8s/ commits here. The route is PR on this repository → merge → in the lab,
git submodule update --remote _k8s, then commit the bumped pointer. That commit is the
application layer's version bump.
The scripts store nothing. lab.env (the intent: domain, TLS mode, CNI, VM sizes) and _out/
(the facts: talosconfig, cluster.env, the local CA, vault-init.json…) live in the lab
repository, with the kubeconfig beside them. There is exactly one source of truth for the topology
and it is the lab's — which is why this repository ships no lab.env and no model for one.
_resolve_lab_dir(), in lib/common.sh, looks for that directory in this order:
#
Candidate
Applies when
1
$LAB_DIR, failing that the directory holding $LAB_ENV
either is exported — explicit override, always wins
2
the parent directory of this repo, if it holds a Vagrantfile
submodule layout: this repo is<lab>/_k8s
3
<root of this repo>/../$LAB_REPO_NAME — Vagrant-Talos or Vagrant-KubeADM, set by the distribution profile
that directory exists ⇒ sibling layout
4
the root of this repository
a lab.env or an _out/ sits here — standalone use
5
fallback: the root of this repository
nothing above matched
KUBECONFIG follows the same directory: unless already exported, it becomes <lab dir>/kubeconfig.
A Vagrantfile is the one unambiguous mark of a lab: it is there from the clone, before any
vagrant up, and it never appears above this repo in the sibling layout. Rule 2 deliberately comes
before rule 4, so a leftover _out/ (or a lab.env dropped here for a one-off test) can never
shadow the real lab sitting right above.
Every script prints its resolution before touching anything, on one line:
lab.env absent (défauts) on a lab that does have a lab.env, or a domaine that is not the one
you set, means the resolution missed the lab. Force it:
LAB_DIR=~/labs/my-lab./install.shtalosplatform# lab.env + _out/ + kubeconfig, all thereLAB_ENV=~/labs/my-lab/lab.env./install.shtalosplatform# its directory becomes the lab dir
💡 One more signal fires, on kubeadm only: platform-up.sh warns when _out/cluster.env is
missing — "./kubeadm/cluster-up.sh has not been run (or not to the end)". Either the bootstrap
really did not finish, or the lab was not the one resolved. The warning is non-blocking, and Talos
has no equivalent (that lab has no cluster.env).
⚠️ Do not create a lab.env at this repo's root. Rule 4 accepts one, for exercising this repo
without any lab, but a second lab.env is a second truth that drifts from the lab's the moment
either changes. That is why there is no lab.env.example here: the model to copy lives in the
lab.
Sources 5 to 8 are _detect_distro(), ordered by how early each signal becomes available:
structure works from the git clone, before any vagrant up; artefacts cover a lab whose
directories were renamed; neighbours run the same test on ../Vagrant-Talos and
../Vagrant-KubeADM, and refuse to decide when both are there — guessing would be a
one-in-two chance of deploying onto the wrong cluster; the probe comes last because it needs a
KUBECONFIG that is already correct, which is derived from the lab directory, which comes from the
profile, which is what we are trying to choose.
With none of the above, the scripts refuse to run: applying a Talos-shaped manifest on Debian (or
the reverse) does not produce a clean error but a silent failure — a Deployment created while no pod
ever starts.
ℹ️ Sources 4 to 6 need the lab to be located first, which is where layout matters. In
submodule layout the parent-Vagrantfile rule fires before any profile is loaded, so all three
work bare — that is what makes ./_k8s/platform-up.sh self-sufficient. In sibling layout no
lab is located at that point (LAB_REPO_NAME comes from the profile), so source 7 takes over, and
./install.sh platform works bare from here too as long as a single lab sits next door.
Everything is concentrated in lib/profiles/talos.sh and lib/profiles/kubeadm.sh: the
install scripts never test the distribution through scattered ifs, they read variables.
Topic
Talos Linux
Debian 13 + kubeadm
Profile variable
Default UI domain
talos.lab.example.io
kubeadm.lab.example.io
DEFAULT_LAB_DOMAIN
PodSecurity (cluster level)
baselineenforced → a privileged pod needs a namespace labelled privileged, otherwise it fails silently
no level enforced → the same labels unblock nothing, they document intent
PODSECURITY_DEFAULT
Filesystem
immutable: / and /usr read-only, only /var is writable
ordinary, everything is writable
—
local-path-provisioner
/var/local-path-provisioner
/opt/local-path-provisioner (upstream path)
LOCAL_PATH_DIR
iSCSI prerequisite (Longhorn)
an extension (iscsi-tools) baked into the installer image (not fixable at runtime) + rshared kubelet mount via talosctl patch mc
a package (open-iscsi) installed by provision.sh; /var/lib/longhorn is an ordinary directory
LONGHORN_PREP_REQUIRED
kube-proxy
optional — cluster.proxy.disabled: true in the machine config
none: the chart defaults are the right ones, forcing them would be harmful
cilium_specific_sets()
Calico
flexVolumePath: None and CSI Nonemandatory (/usr is read-only)
same settings, but purely as a slim-down
(shared manifest)
flannel (CNI=flannel)
already installed by the Talos bootstrap → nothing to do
installed here via the flannel/flannel chart
FLANNEL_PRE_INSTALLED
Trivy — "node" scanners
disabled: the node-collector bind-mounts /etc/systemd → read-only file system
enabled: those paths exist and are readable
TRIVY_NODE_COLLECTOR
Prometheus — control plane
etcd/scheduler/controller-manager monitors off (not scrapable without dedicated TLS)
on: bind-address: 0.0.0.0 and listen-metrics-urls set at bootstrap
KPS_SCRAPE_CONTROL_PLANE
Host-only interface
enp0s8
eth1 or enp0s8 depending on the box → detected into _out/cluster.env
DEFAULT_HOSTONLY_IF
Cluster facts
none detected: lab.env is the source, only podSubnets is read back from _out/controlplane.yaml
_out/cluster.env — values detected on the cluster (CIDR, interface, kube-proxy)
—
Demo Vault KV mount
talos-lab/
kubeadm-lab/
VAULT_KV_MOUNT
Self-signed CA
O=Vagrant-Talos lab
O=Vagrant-KubeADM lab
CA_ORG, CA_FILE_NAME
API-server OIDC flags (dex/)
talosctl patch mc — the machine configuration is an API; extraArgs is a map
kubeadm-config ConfigMap + kubeadm init phaseon each control plane; extraArgs is a list of {name, value} (v1beta4)
APISERVER_OIDC_PATCH, apiserver_oidc_commands()
ℹ️ kube-proxy: same outcome, two mechanisms. Both labs default to
KUBE_PROXY_REPLACEMENT=true, so on both the reference cluster runs with no kube-proxy at all
and Cilium serves the Services in eBPF. Only the way the bootstrap drops it differs. The value is
decided at bootstrap — not a runtime toggle — and it requires CNI=cilium on both, since
nothing else here replaces kube-proxy.
Everything else — Argo CD, Kyverno, MinIO, CloudNativePG, Vault, Envoy Gateway, cert-manager,
chaoskube, node-problem-detector, WordPress — is strictly identical on both distributions.
install.sh entry point: ./install.sh [talos|kubeadm] <component...>
platform-up.sh the base platform (CNI → Gateway → metrics → TLS)
metric-server.yaml metrics-server (applied by platform-up.sh)
lib/
common.sh shared core: distro resolution, lab.env reading, helpers
profiles/talos.sh EVERYTHING specific to Talos
profiles/kubeadm.sh EVERYTHING specific to kubeadm/Debian
<component>/
<component>-up.sh the all-in-one install
values.yaml / *.yaml manifests and values (NEUTRAL values, substituted on the fly)
README.md / LISEZ-MOI.md EN/FR docs + guided walkthrough
docs/build.py builds the single-page site from every README (make docs)
Makefile docs, docs-check, validate — everything that runs without a cluster
In submodule layout that whole tree hangs under the lab's _k8s/. Paths inside this repository are
the same either way, which is why every walkthrough is written from this repository's root.
There is no Vagrantfile, no _out/ and no lab.env here, by design: this repo owns the
manifests, the lab owns the cluster and its state. That absence is load-bearing — it is what makes
"the parent holds a Vagrantfile" an unambiguous way to spot the lab.
Every link assumes the previous one: no LoadBalancer IP without an L2 announcer, no HTTPS without the
Gateway, no UI without a certificate on the :443 listener.
bootstrapped cluster (Talos lab or kubeadm lab — nodes NotReady, no CNI yet)
│
├─ 1. CNI cilium/ (default, + L2 pool → LoadBalancer IP .200)
│ or calico/ (CNI only) or flannel (CNI only) or nothing
│ + L2 announcer metallb/ — ONLY when the CNI is not cilium, on the SAME range
│ (skipped with METALLB=false: no LoadBalancer IP at all)
├─ 2. envoy-gateway/ Envoy controller + main-gateway (listeners :80 and :443)
├─ 3. metric-server metrics.k8s.io API (kubectl top, HPA)
└─ 4. wildcard TLS *.<LAB_DOMAIN> — two modes, per SELF_SIGNED
│ true (default) → self-signed/ openssl, local CA
│ false → cert-manager/ Let's Encrypt DNS-01 Cloudflare
│
└─ add-ons: storage → backup → databases → identity → secrets → observability
→ security
That is exactly the order of platform-up.sh ([1/4] → [4/4]). Both TLS modes fill the same
Secret (wildcard-<LAB_DOMAIN with dashes>-tls), so no add-on ever has to know which one you picked.
The repository is public: no manifest carries a real value. Three neutral markers are substituted
on the fly (the render helper in lib/common.sh), never rewriting a versioned file — git status stays clean:
Versioned marker
Replaced with
Comes from
lab.example.io
$LAB_DOMAIN (default <distro>.lab.example.io)
environment, then lab.env
lab-example-io
$LAB_DOMAIN_DASH — the wildcard TLS Secret name
derived from LAB_DOMAIN
lab-kv
$VAULT_KV_MOUNT (talos-lab / kubeadm-lab)
distribution profile
LAB_DOMAIN is set in the lab'slab.env, never here:
echo'LAB_DOMAIN=k8s.my-domain.tld'>>lab.env# in Vagrant-Talos/ or Vagrant-KubeADM/
⚠️ A domain that stays at <distro>.lab.example.io while lab.env says otherwise is the first
symptom of a lab.env that was never found: check which lab got resolved (the summary line) before
suspecting the substitution.
⚠️ Manifests applied by hand (without a *-up.sh) get no substitution:
wordpress-example/wordpress-mariadb.yaml, vault-secret-operator/k8s/*.yaml,
cert-manager/04-gateway-https-example.yaml. Pipe them through the same sed:
Audited on 1 August 2026: everything is on the latest stable release published at that date
(helm search repo <chart> --versions). Every version is overridable by environment variable.
Component
Chart / image
Version
Where
Variable
Cilium
cilium/cilium
1.20.0
cilium/cilium-up.sh
CILIUM_VERSION
Calico
projectcalico/tigera-operator
v3.32.1
calico/calico-up.sh
CALICO_VERSION
MetalLB
metallb/metallb
0.16.1
metallb/metallb-up.sh
METALLB_VERSION
Envoy Gateway
oci://docker.io/envoyproxy/gateway-helm
1.8.3
platform-up.sh
ENVOY_GW_VERSION
cert-manager
jetstack/cert-manager
v1.21.1
platform-up.sh
CERT_MANAGER_VERSION
metrics-server
image registry.k8s.io/…
v0.9.0
metric-server.yaml
—
Longhorn
longhorn/longhorn
1.12.0
longhorn/longhorn-up.sh
LONGHORN_VERSION
Velero
vmware-tanzu/velero
12.1.0 (app v1.18.1)
velero/velero-up.sh
VELERO_VERSION
velero-plugin-for-aws
image velero/velero-plugin-for-aws
v1.14.2
velero/velero-up.sh
VELERO_AWS_PLUGIN_VERSION
Velero UI
otwld/velero-ui
0.15.0 (app 0.10.2)
velero/velero-up.sh
VELERO_UI_VERSION
local-path-provisioner
image rancher/…
v0.0.36
local-path-storage/local-path-storage.yaml
—
CloudNativePG
cnpg/cloudnative-pg
0.29.0 (app 1.30.0)
cloudnative-pg/cloudnative-pg-up.sh
CNPG_VERSION
Keycloak
keycloak-k8s-resources (operator and server)
26.7.0
keycloak/keycloak-up.sh
KEYCLOAK_VERSION
Dex
dex/dex
0.24.1 (app v2.44.0)
dex/dex-up.sh
DEX_VERSION
kubelogin
host binary kubectl oidc-login
v1.36.3 (reference)
dex/README.md
—
Vault
hashicorp/vault
0.34.0
vault-cluster/vault-up.sh
VAULT_CHART_VERSION
Vault Secrets Operator
hashicorp/vault-secrets-operator
1.5.0
vault-secret-operator/vso-up.sh
VSO_VERSION
kube-prometheus-stack
prometheus-community/…
88.0.1 (op. v0.93.0)
observability/observability-up.sh
KPS_VERSION
Loki
grafana/loki
7.2.0 (app 3.6.11)
idem
LOKI_VERSION
Alloy
grafana/alloy
1.11.0 (app v1.18.0)
idem
ALLOY_VERSION
Mattermost Team Edition
mattermost/mattermost-team-edition
6.6.104 (app 11.9.0)
mattermost/mattermost-up.sh
MATTERMOST_CHART_VERSION
node-problem-detector
deliveryhero/…
2.3.14 (app v0.8.19)
node-problem-detector/…-up.sh
NPD_VERSION
Kyverno
kyverno/kyverno
3.8.2 (app v1.18.2)
kyverno/kyverno-up.sh
KYVERNO_VERSION
Policy Reporter
policy-reporter/policy-reporter
3.9.1
kyverno/, trivy-operator/
POLICY_REPORTER_VERSION
Trivy Operator
aqua/trivy-operator
0.34.0 (app 0.32.0)
trivy-operator/…-up.sh
TRIVY_OPERATOR_VERSION
Argo CD
argo/argo-cd
10.2.2 (app v3.4.6)
argocd/argocd-up.sh
ARGOCD_VERSION
chaoskube
chaoskube/chaoskube
0.6.0 (app 0.39.0)
chaos-kube/chaoskube-up.sh
CHAOSKUBE_VERSION
flannel
flannel/flannel
not pinned (latest: v0.28.8)
platform-up.sh
FLANNEL_VERSION
ℹ️ Demo images (WordPress, MariaDB, nginx, alpine, busybox, PostgreSQL, MinIO) are pinned on
purpose and are not part of this audit: bumping them only matters when a demo breaks.
⚠️ Stay on the /32 (or fence it with an ACL): a /24 would also expose the Kubernetes API
(:6443) and SSH on every node.
Name + TLS — a public Cloudflare wildcard *.<LAB_DOMAIN> → 192.168.56.200, in DNS-only
(grey cloud): the Cloudflare proxy cannot reach a private 192.168.56.x IP. TLS is therefore
terminated by Envoy, not by Cloudflare, so the Gateway must carry a publicly trusted
certificate (Let's Encrypt, see cert-manager/). A Cloudflare Origin
CA certificate would be rejected by browsers.
💡 With the default SELF_SIGNED=true, none of this is needed: an /etc/hosts line pointing at
192.168.56.200 is enough, and the domain never has to resolve publicly.
Read the summary line, every time. One line per script, printed before anything is touched:
profile, domain, and whether a lab.env was found. It is the cheapest way to catch a resolution
that went somewhere unexpected.
Both labs cloned side by side, and no distribution argument. The neighbour signal points two
ways at once, so the scripts stop and ask rather than pick. Say which one, or run from the lab you
mean, in submodule layout.
The lab directory renamed, in sibling layout. Both the lab lookup (LAB_REPO_NAME) and the
neighbour detection match those two names literally, so a Vagrant-KubeADM-v2/ next door is
found by neither and resolution falls back to this repo — silently. LAB_DIR is the fix. In
submodule layout the directory name is irrelevant.
A git pull in a lab does not move _k8s/: git submodule update --init --recursive after
every pull.
Two default StorageClasses.longhorn/values.yaml sets persistence.defaultClass: true and
local-path-storage.yaml sets the is-default-class: "true" annotation. With both add-ons
installed, a PVC without an explicit storageClassName lands on the most recently created SC,
non-deterministically. Always name your SC.
This layer needs a LoadBalancer Service that really gets an IP, and Cilium is the only CNI
here that provides one on its own (L2/ARP). With calico, flannel or none, platform-up.sh
installs metallb/ right after the CNI on the same range, so every CNI ends
up with a reachable .200. Two things still leave the Gateway at EXTERNAL-IP <pending>:
METALLB=false in lab.env, and applying envoy-gateway/Envoy-Proxy.ymlby hand with its
Cilium loadBalancerClass left in.
Never MetalLB and Cilium. Two announcers on one range means two nodes answering ARP for
.200: the entry point then works "one time in two", with nothing in any log to say so.
metallb-up.sh refuses to install on a Cilium cluster, and platform never picks it there.
Switching CNI on a live cluster is not supported: flatten the cluster from the lab
(./kubeadm/cluster-reset.sh, or vagrant destroy), then re-bootstrap.
The repo's own Kyverno policies are violated by the repo (require-requests-limits demands a
limits.cpu the in-house manifests deliberately never set). The report is noisy by construction —
see kyverno/.
Metric emitters are off by default (serviceMonitor/podMonitor are false in
trivy-operator, CloudNativePG and node-problem-detector): Prometheus scrapes nothing from them
until you flip them on after installing observability.
Never lower CP_MEM below 3072 in the lab's lab.env: stacking these add-ons on 2 GB
control planes starves etcd. observability needs 4096.
☸️k8s-playground — les ressources Kubernetes des labs Talos et kubeadm
Un seul dépôt de manifestes et de charts pour deux labs Vagrant. La couche applicative — CNI,
Gateway API, stockage, secrets, observabilité, sécurité — était dupliquée dans
Vagrant-Talos/_k8s/ et encore dans Vagrant-KubeADM/_k8s/. Elle vit maintenant ici une seule
fois, et les deux labs montent ce dépôt à ce même chemin _k8s/, en sous-module git. Monté
comme ça, le lab et sa distribution sont trouvés tout seuls — aucun argument, aucune variable
d'environnement :
./_k8s/platform-up.sh# depuis la racine de l'un ou l'autre lab
./_k8s/install.shlonghornvault# addons, opt-in
📖 Documentation navigable : https://ops-nc.github.io/k8s-playground/ — une page autonome,
bilingue (EN/FR), thème clair/sombre, reconstruite depuis ces fichiers README.md /
LISEZ-MOI.md à chaque push sur main (make docs la construit en local).
Les deux labs restent responsables du bootstrap du cluster (VM, OS, kubeadm init /
talosctl bootstrap). Ce dépôt couvre ce qui vient après, avec kubectl et helm depuis
l'hôte — le CNI compris, parce qu'aucun des deux bootstraps ne laisse un réseau de pods
utilisable.
On ne commence jamais ici : on commence dans un lab, là où vivent le cluster et son état.
# 1. Le lab — sous-module compris (jumeau Talos : OPS-NC/Vagrant-Talos)
gitclone--recurse-submoduleshttps://github.com/OPS-NC/Vagrant-kubeadm.git
cdVagrant-kubeadm
cplab.env.examplelab.env# le modèle du LAB : topologie, domaine, mode TLS, CNI
vagrantup&&./kubeadm/cluster-up.sh# les nodes sont NotReady : pas encore de CNI# 2. Poser la plateforme de base — rien à déclarer
./_k8s/platform-up.sh# CNI → Envoy Gateway → metrics-server → TLS wildcard# 3. Les addons, opt-in
./_k8s/install.shlonghornvaultargocd
./_k8s/install.shlist# le catalogue complet
./_k8s/install.shall# plateforme + tous les addons, dans l'ordre des dépendances
Depuis la racine du lab, ce dépôt est <lab>/_k8s : le lab est son parent, reconnu à son
Vagrantfile, et la distribution se lit sur la structure de ce lab — kubeadm/cluster-up.sh ici,
talos/cluster-up.sh chez le jumeau Talos. KUBECONFIG est déduit de la même façon
(<lab>/kubeconfig) ; ne l'exporte que pour tes propres appels kubectl. Passer la distribution
explicitement fonctionne toujours et gagne toujours, ce qui reste le moyen le plus sûr de savoir ce
qu'on a lancé : ./_k8s/install.sh kubeadm platform.
Chaque composant s'exécute aussi tout seul :
./_k8s/longhorn/longhorn-up.sh
🎓 Mode apprentissage. Chaque dossier livre un README.md (EN) / LISEZ-MOI.md (FR) avec une
section « Pas à pas guidé » : la même installation, commande par commande, avec ce qu'il faut
observer à chaque étape et les variantes par distribution. Le script tout-en-un et le pas à pas
font exactement la même chose.
Variante — les deux dépôts côte à côte (la disposition d'avant le sous-module, toujours supportée)
cd../Vagrant-Talos&&./talos/cluster-up.sh# ou ../Vagrant-KubeADM && ./kubeadm/cluster-up.shcd../k8s-playground# ce dépôt, voisin du lab
./install.shtalosplatform# un seul lab à côté ⇒ l'argument est optionnel ici aussi
Ici le lab est à côté de ce dépôt, pas au-dessus : la règle du Vagrantfile parent ne se
déclenche pas. Le dossier du lab est trouvé via LAB_REPO_NAME (../Vagrant-Talos,
../Vagrant-KubeADM), fourni par le profil de distribution, et la distribution elle-même se lit
sur ces deux mêmes dossiers candidats, testés par leur nom. Avec les deux labs clonés côte à côte
le choix est ambigu : les scripts refusent de deviner et tu passes talos/kubeadm. C'est la seule
différence entre les deux dispositions — et la raison pour laquelle celle en sous-module est
recommandée : elle épingle la couche applicative à un commit par lab, ce qui rend un lab
reproductible.
Installer et mettre à jour le sous-module, depuis le lab :
gitclone--recurse-submoduleshttps://github.com/OPS-NC/Vagrant-kubeadm.git
gitsubmoduleupdate--init--recursive# remplit _k8s/ sur un clone déjà fait
gitsubmoduleupdate--remote_k8s# déplace _k8s/ sur le dernier commit de ce dépôt
⚠️ Un git pull dans le lab ne met PAS le sous-module à jour. Il ne déplace que le dépôt du
lab ; _k8s/ reste sur le commit épinglé avant, et tu exécuterais les commandes documentées contre
une couche applicative plus ancienne. Fais suivre chaque pull de
git submodule update --init --recursive. Et un _k8s/ vide — ./_k8s/install.sh: No such file or directory — signifie toujours une seule chose : le sous-module n'a jamais été initialisé.
💡 Modifier cette couche est une modification faite ici. Vu depuis un lab, _k8s/ est un
checkout de ce dépôt sur un HEAD détaché : les fichiers qu'on y édite appartiennent à ce dépôt, et
un git commit lancé dans _k8s/ commite ici. Le chemin est donc : PR sur ce dépôt → merge →
dans le lab, git submodule update --remote _k8s, puis commit du pointeur déplacé. Ce commit
est la montée de version de la couche applicative.
Les scripts ne stockent rien. lab.env (l'intention : domaine, mode TLS, CNI, taille des VM) et
_out/ (les faits : talosconfig, cluster.env, l'AC locale, vault-init.json…) vivent dans le
dépôt du lab, avec le kubeconfig à côté. Il y a exactement une source de vérité pour la
topologie, et c'est celle du lab — d'où l'absence de lab.env et de modèle de lab.env ici.
_resolve_lab_dir(), dans lib/common.sh, cherche ce dossier dans cet ordre :
#
Candidat
S'applique quand
1
$LAB_DIR, à défaut le dossier contenant $LAB_ENV
l'un des deux est exporté — surcharge explicite, gagne toujours
2
le dossier parent de ce dépôt, s'il contient un Vagrantfile
disposition sous-module : ce dépôt est<lab>/_k8s
3
<racine de ce dépôt>/../$LAB_REPO_NAME — Vagrant-Talos ou Vagrant-KubeADM, posé par le profil
ce dossier existe ⇒ disposition voisine
4
la racine de ce dépôt
un lab.env ou un _out/ s'y trouve — usage autonome
5
repli : la racine de ce dépôt
rien de ce qui précède n'a correspondu
KUBECONFIG suit le même dossier : sauf s'il est déjà exporté, il devient
<dossier lab>/kubeconfig.
Un Vagrantfile est la marque non ambiguë d'un lab : il est là dès le clone, avant tout
vagrant up, et il n'apparaît jamais au-dessus de ce dépôt dans la disposition voisine. La règle 2
passe volontairement avant la règle 4, pour qu'un _out/ résiduel (ou un lab.env déposé ici
pour un test) ne puisse jamais masquer le vrai lab situé juste au-dessus.
Chaque script affiche sa résolution avant de toucher à quoi que ce soit, sur une ligne :
lab.env absent (défauts) sur un lab qui a un lab.env, ou un domaine qui n'est pas celui que tu
as posé, signifie que la résolution a raté le lab. Force-la :
LAB_DIR=~/labs/mon-lab./install.shtalosplatform# lab.env + _out/ + kubeconfig, tout est làLAB_ENV=~/labs/mon-lab/lab.env./install.shtalosplatform# son dossier devient le dossier du lab
💡 Un signal de plus se déclenche, sur kubeadm seulement : platform-up.sh avertit quand
_out/cluster.env manque — « ./kubeadm/cluster-up.sh n'a pas été lancé (ou pas jusqu'au
bout) ». Soit le bootstrap n'est vraiment pas allé au bout, soit le lab résolu n'était pas le bon.
L'avertissement est non bloquant, et Talos n'a pas d'équivalent (ce lab n'a pas de cluster.env).
⚠️ Ne crée pas de lab.env à la racine de ce dépôt. La règle 4 en accepte un, pour exercer ce
dépôt sans lab, mais un second lab.env est une seconde vérité qui dérive de celle du lab dès que
l'une des deux change. C'est pour ça qu'il n'y a pas de lab.env.example ici : le modèle à
copier vit dans le lab.
Les sources 5 à 8 sont _detect_distro(), ordonnées par la précocité du signal : la structure
fonctionne dès le git clone, avant tout vagrant up ; les artefacts couvrent un lab dont les
dossiers ont été renommés ; les voisins rejouent le même test sur ../Vagrant-Talos et
../Vagrant-KubeADM, et refusent de décider quand les deux sont là — deviner serait une chance
sur deux de déployer sur le mauvais cluster ; le sondage vient en dernier parce qu'il a besoin
d'un KUBECONFIG déjà correct, lequel est déduit du dossier du lab, lequel vient du profil, qui est
justement ce qu'on cherche à choisir.
Sans aucune de ces sources, les scripts refusent de tourner : appliquer un manifeste taillé pour
Talos sur Debian (ou l'inverse) ne produit pas une erreur propre mais une panne silencieuse — un
Deployment créé alors qu'aucun pod ne démarre jamais.
ℹ️ Les sources 4 à 6 ont besoin que le lab soit localisé d'abord, et c'est là que la disposition
compte. En sous-module, la règle du Vagrantfile parent se déclenche avant tout chargement de
profil, donc les trois fonctionnent nues — c'est ce qui rend ./_k8s/platform-up.sh autonome. En
disposition voisine, aucun lab n'est localisé à ce moment-là (LAB_REPO_NAME vient du profil) :
la source 7 prend le relais, et ./install.sh platform fonctionne nu depuis ici aussi tant qu'un
seul lab est à côté.
Tout est concentré dans lib/profiles/talos.sh et lib/profiles/kubeadm.sh : les scripts
d'installation ne testent jamais la distribution par des if éparpillés, ils lisent des variables.
Sujet
Talos Linux
Debian 13 + kubeadm
Variable de profil
Domaine des UI par défaut
talos.lab.example.io
kubeadm.lab.example.io
DEFAULT_LAB_DOMAIN
PodSecurity (niveau cluster)
baselineimposé → un pod privilégié exige un namespace étiqueté privileged, sinon il échoue en silence
aucun niveau imposé → les mêmes étiquettes ne débloquent rien, elles documentent l'intention
PODSECURITY_DEFAULT
Système de fichiers
immuable : / et /usr en lecture seule, seul /var est inscriptible
ordinaire, tout est inscriptible
—
local-path-provisioner
/var/local-path-provisioner
/opt/local-path-provisioner (chemin amont)
LOCAL_PATH_DIR
Prérequis iSCSI (Longhorn)
une extension (iscsi-tools) intégrée à l'image d'installation (impossible à corriger à chaud) + montage kubelet rshared via talosctl patch mc
un paquet (open-iscsi) installé par provision.sh ; /var/lib/longhorn est un dossier ordinaire
LONGHORN_PREP_REQUIRED
kube-proxy
optionnel — cluster.proxy.disabled: true dans la config machine
rien : les défauts du chart sont les bons, les forcer serait nuisible
cilium_specific_sets()
Calico
flexVolumePath: None et CSI Noneobligatoires (/usr en lecture seule)
mêmes réglages, mais purement pour alléger
(manifeste partagé)
flannel (CNI=flannel)
déjà installé par le bootstrap Talos → rien à faire
installé ici via le chart flannel/flannel
FLANNEL_PRE_INSTALLED
Trivy — scanners « node »
désactivés : le node-collector monte /etc/systemd → read-only file system
activés : ces chemins existent et sont lisibles
TRIVY_NODE_COLLECTOR
Prometheus — control plane
monitors etcd/scheduler/controller-manager off (non scrapables sans TLS dédié)
on : bind-address: 0.0.0.0 et listen-metrics-urls posés au bootstrap
KPS_SCRAPE_CONTROL_PLANE
Interface host-only
enp0s8
eth1 ou enp0s8 selon la box → détectée dans _out/cluster.env
DEFAULT_HOSTONLY_IF
Faits du cluster
rien de détecté : lab.env est la source, seul podSubnets est relu depuis _out/controlplane.yaml
_out/cluster.env — valeurs détectées sur le cluster (CIDR, interface, kube-proxy)
—
Montage KV Vault de démo
talos-lab/
kubeadm-lab/
VAULT_KV_MOUNT
AC auto-signée
O=Vagrant-Talos lab
O=Vagrant-KubeADM lab
CA_ORG, CA_FILE_NAME
Options OIDC de l'apiserver (dex/)
talosctl patch mc — la configuration machine est une API ; extraArgs est un dictionnaire
ConfigMap kubeadm-config + kubeadm init phasesur chaque control plane ; extraArgs est une liste de {name, value} (v1beta4)
APISERVER_OIDC_PATCH, apiserver_oidc_commands()
ℹ️ kube-proxy : même résultat, deux mécanismes. Les deux labs ont KUBE_PROXY_REPLACEMENT=true
par défaut, donc sur les deux le cluster de référence tourne sans aucun kube-proxy et Cilium
sert les Services en eBPF. Seule la façon dont le bootstrap s'en débarrasse diffère. La valeur se
décide au bootstrap — ce n'est pas un interrupteur à chaud — et elle exige CNI=cilium des deux
côtés, puisque rien d'autre ici ne remplace kube-proxy.
Tout le reste — Argo CD, Kyverno, MinIO, CloudNativePG, Vault, Envoy Gateway, cert-manager,
chaoskube, node-problem-detector, WordPress — est strictement identique sur les deux
distributions.
install.sh point d'entrée : ./install.sh [talos|kubeadm] <composant...>
platform-up.sh la plateforme de base (CNI → Gateway → metrics → TLS)
metric-server.yaml metrics-server (appliqué par platform-up.sh)
lib/
common.sh socle partagé : résolution de distro, lecture de lab.env, helpers
profiles/talos.sh TOUT ce qui est propre à Talos
profiles/kubeadm.sh TOUT ce qui est propre à kubeadm/Debian
<composant>/
<composant>-up.sh l'installation tout-en-un
values.yaml / *.yaml manifestes et values (valeurs NEUTRES, substituées à la volée)
README.md / LISEZ-MOI.md doc EN/FR + pas à pas guidé
docs/build.py construit le site en une page depuis tous les README (make docs)
Makefile docs, docs-check, validate — tout ce qui tourne sans cluster
En disposition sous-module, toute cette arborescence pend sous le _k8s/ du lab. Les chemins
internes à ce dépôt sont les mêmes dans les deux cas, ce qui explique que chaque pas à pas soit
écrit depuis la racine de ce dépôt.
Il n'y a ni Vagrantfile, ni _out/, ni lab.env ici, par construction : ce dépôt possède les
manifestes, le lab possède le cluster et son état. Cette absence porte quelque chose — c'est elle qui
fait de « le parent contient un Vagrantfile » un moyen non ambigu de repérer le lab.
Chaque maillon suppose le précédent : pas d'IP de LoadBalancer sans annonceur L2, pas de HTTPS sans le
Gateway, pas d'UI sans certificat sur le listener :443.
cluster bootstrapé (lab Talos ou lab kubeadm — nodes NotReady, pas de CNI)
│
├─ 1. CNI cilium/ (défaut, + pool L2 → IP LoadBalancer .200)
│ ou calico/ (CNI seul) ou flannel (CNI seul) ou rien
│ + annonceur L2 metallb/ — SEULEMENT si le CNI n'est pas cilium, sur la MÊME plage
│ (ignoré avec METALLB=false : aucune IP de LoadBalancer)
├─ 2. envoy-gateway/ contrôleur Envoy + main-gateway (listeners :80 et :443)
├─ 3. metric-server API metrics.k8s.io (kubectl top, HPA)
└─ 4. TLS wildcard *.<LAB_DOMAIN> — deux modes, selon SELF_SIGNED
│ true (défaut) → self-signed/ openssl, AC locale
│ false → cert-manager/ Let's Encrypt DNS-01 Cloudflare
│
└─ addons : stockage → sauvegarde → bases → identité → secrets → observabilité
→ sécurité
C'est exactement l'ordre de platform-up.sh ([1/4] → [4/4]). Les deux modes TLS remplissent le
même Secret (wildcard-<LAB_DOMAIN avec tirets>-tls), donc aucun addon n'a à savoir lequel tu as
choisi.
Le dépôt est public : aucun manifeste ne porte de vraie valeur. Trois marqueurs neutres sont
substitués à la volée (le helper render de lib/common.sh), sans jamais réécrire un fichier
versionné — git status reste propre :
Marqueur versionné
Remplacé par
Vient de
lab.example.io
$LAB_DOMAIN (défaut <distro>.lab.example.io)
l'environnement, puis lab.env
lab-example-io
$LAB_DOMAIN_DASH — le nom du Secret TLS wildcard
déduit de LAB_DOMAIN
lab-kv
$VAULT_KV_MOUNT (talos-lab / kubeadm-lab)
profil de distribution
LAB_DOMAIN se pose dans le lab.env du lab, jamais ici :
echo'LAB_DOMAIN=k8s.mon-domaine.tld'>>lab.env# dans Vagrant-Talos/ ou Vagrant-KubeADM/
⚠️ Un domaine qui reste à <distro>.lab.example.io alors que lab.env dit autre chose est le
premier symptôme d'un lab.env jamais trouvé : vérifie quel lab a été résolu (la ligne de
résumé) avant de soupçonner la substitution.
⚠️ Les manifestes appliqués à la main (sans *-up.sh) ne reçoivent aucune substitution :
wordpress-example/wordpress-mariadb.yaml, vault-secret-operator/k8s/*.yaml,
cert-manager/04-gateway-https-example.yaml. Passe-les par le même sed :
Auditées le 1er août 2026 : tout est sur la dernière version stable publiée à cette date
(helm search repo <chart> --versions). Chaque version est surchargeable par variable
d'environnement.
Composant
Chart / image
Version
Où
Variable
Cilium
cilium/cilium
1.20.0
cilium/cilium-up.sh
CILIUM_VERSION
Calico
projectcalico/tigera-operator
v3.32.1
calico/calico-up.sh
CALICO_VERSION
MetalLB
metallb/metallb
0.16.1
metallb/metallb-up.sh
METALLB_VERSION
Envoy Gateway
oci://docker.io/envoyproxy/gateway-helm
1.8.3
platform-up.sh
ENVOY_GW_VERSION
cert-manager
jetstack/cert-manager
v1.21.1
platform-up.sh
CERT_MANAGER_VERSION
metrics-server
image registry.k8s.io/…
v0.9.0
metric-server.yaml
—
Longhorn
longhorn/longhorn
1.12.0
longhorn/longhorn-up.sh
LONGHORN_VERSION
Velero
vmware-tanzu/velero
12.1.0 (app v1.18.1)
velero/velero-up.sh
VELERO_VERSION
velero-plugin-for-aws
image velero/velero-plugin-for-aws
v1.14.2
velero/velero-up.sh
VELERO_AWS_PLUGIN_VERSION
Velero UI
otwld/velero-ui
0.15.0 (app 0.10.2)
velero/velero-up.sh
VELERO_UI_VERSION
local-path-provisioner
image rancher/…
v0.0.36
local-path-storage/local-path-storage.yaml
—
CloudNativePG
cnpg/cloudnative-pg
0.29.0 (app 1.30.0)
cloudnative-pg/cloudnative-pg-up.sh
CNPG_VERSION
Keycloak
keycloak-k8s-resources (opérateur et serveur)
26.7.0
keycloak/keycloak-up.sh
KEYCLOAK_VERSION
Dex
dex/dex
0.24.1 (app v2.44.0)
dex/dex-up.sh
DEX_VERSION
kubelogin
binaire hôte kubectl oidc-login
v1.36.3 (référence)
dex/LISEZ-MOI.md
—
Vault
hashicorp/vault
0.34.0
vault-cluster/vault-up.sh
VAULT_CHART_VERSION
Vault Secrets Operator
hashicorp/vault-secrets-operator
1.5.0
vault-secret-operator/vso-up.sh
VSO_VERSION
kube-prometheus-stack
prometheus-community/…
88.0.1 (op. v0.93.0)
observability/observability-up.sh
KPS_VERSION
Loki
grafana/loki
7.2.0 (app 3.6.11)
idem
LOKI_VERSION
Alloy
grafana/alloy
1.11.0 (app v1.18.0)
idem
ALLOY_VERSION
Mattermost Team Edition
mattermost/mattermost-team-edition
6.6.104 (app 11.9.0)
mattermost/mattermost-up.sh
MATTERMOST_CHART_VERSION
node-problem-detector
deliveryhero/…
2.3.14 (app v0.8.19)
node-problem-detector/…-up.sh
NPD_VERSION
Kyverno
kyverno/kyverno
3.8.2 (app v1.18.2)
kyverno/kyverno-up.sh
KYVERNO_VERSION
Policy Reporter
policy-reporter/policy-reporter
3.9.1
kyverno/, trivy-operator/
POLICY_REPORTER_VERSION
Trivy Operator
aqua/trivy-operator
0.34.0 (app 0.32.0)
trivy-operator/…-up.sh
TRIVY_OPERATOR_VERSION
Argo CD
argo/argo-cd
10.2.2 (app v3.4.6)
argocd/argocd-up.sh
ARGOCD_VERSION
chaoskube
chaoskube/chaoskube
0.6.0 (app 0.39.0)
chaos-kube/chaoskube-up.sh
CHAOSKUBE_VERSION
flannel
flannel/flannel
non épinglée (dernière : v0.28.8)
platform-up.sh
FLANNEL_VERSION
ℹ️ Les images de démo (WordPress, MariaDB, nginx, alpine, busybox, PostgreSQL, MinIO) sont
épinglées à dessein et ne font pas partie de cet audit : les monter n'a d'intérêt que quand une démo
casse.
./install.sh list affiche la même liste, toujours à jour. <distro> est écrit ci-dessous pour
lever toute ambiguïté, mais il est optionnel dans les deux dispositions.
⚠️ Reste sur le /32 (ou barrière par ACL) : un /24 exposerait aussi l'API Kubernetes
(:6443) et SSH sur tous les nodes.
Nom + TLS — un wildcard Cloudflare public *.<LAB_DOMAIN> → 192.168.56.200, en DNS-only
(nuage gris) : le proxy Cloudflare ne peut pas joindre une IP privée 192.168.56.x. TLS est donc
terminé par Envoy, pas par Cloudflare, donc le Gateway doit porter un certificat reconnu
publiquement (Let's Encrypt, voir cert-manager/). Un certificat
Cloudflare Origin CA serait rejeté par les navigateurs.
💡 Avec le défaut SELF_SIGNED=true, rien de tout ça n'est nécessaire : une ligne /etc/hosts
pointant 192.168.56.200 suffit, et le domaine n'a jamais à résoudre publiquement.
Lis la ligne de résumé, à chaque fois. Une ligne par script, affichée avant de toucher à quoi
que ce soit : profil, domaine, et si un lab.env a été trouvé. C'est le moyen le moins cher
d'attraper une résolution partie ailleurs.
Les deux labs clonés côte à côte, sans argument de distribution. Le signal du voisin pointe dans
deux directions à la fois : les scripts s'arrêtent et demandent plutôt que de choisir. Dis lequel,
ou lance depuis le lab visé, en disposition sous-module.
Le dossier du lab renommé, en disposition voisine. La recherche du lab (LAB_REPO_NAME) et la
détection du voisin correspondent à ces deux noms littéralement : un Vagrant-KubeADM-v2/ à
côté n'est trouvé par aucun des deux, et la résolution retombe sur ce dépôt — en silence. LAB_DIR
est le remède. En sous-module, le nom du dossier n'a aucune importance.
Un git pull dans un lab ne déplace pas _k8s/ : git submodule update --init --recursive
après chaque pull.
Deux StorageClass par défaut.longhorn/values.yaml pose persistence.defaultClass: true et
local-path-storage.yaml pose l'annotation is-default-class: "true". Les deux addons installés,
un PVC sans storageClassName explicite atterrit sur la SC créée le plus récemment, de façon non
déterministe. Nomme toujours ta SC.
Cette couche a besoin d'un Service LoadBalancer qui obtienne vraiment une IP, et Cilium est le
seul CNI ici à en fournir une tout seul (L2/ARP). Avec calico, flannel ou none,
platform-up.sh installe metallb/ juste après le CNI, sur la même plage,
pour que tous les CNI finissent avec un .200 joignable. Deux choses laissent encore le Gateway en
EXTERNAL-IP <pending> : METALLB=false dans lab.env, et appliquer
envoy-gateway/Envoy-Proxy.ymlà la main avec son loadBalancerClass Cilium dedans.
Jamais MetalLB et Cilium. Deux annonceurs sur une plage, c'est deux nodes qui répondent à
l'ARP pour .200 : le point d'entrée fonctionne alors « une fois sur deux », sans rien dans aucun
log. metallb-up.sh refuse de s'installer sur un cluster Cilium, et platform ne le choisit jamais
là.
Changer de CNI sur un cluster vivant n'est pas supporté : rase le cluster depuis le lab
(./kubeadm/cluster-reset.sh, ou vagrant destroy), puis re-bootstrape.
Les politiques Kyverno du dépôt sont violées par le dépôt (require-requests-limits exige un
limits.cpu que les manifestes maison ne posent volontairement jamais). Le rapport est bruyant par
construction — voir kyverno/.
Les émetteurs de métriques sont désactivés par défaut (serviceMonitor/podMonitor à false
dans trivy-operator, CloudNativePG et node-problem-detector) : Prometheus ne scrape rien chez eux
avant que tu ne les actives, après l'installation d'observability.
Ne descends jamais CP_MEM sous 3072 dans le lab.env du lab : empiler ces addons sur des
control planes de 2 Go affame etcd. observability demande 4096.
🐝cilium/ — CNI, LoadBalancer IPs and L2 announcement (ARP)
The networking component of the lab. Cilium provides the CNI (without it the nodes stay
NotReady) and also plays the "cloud provider" role: it hands type: LoadBalancer Services a
real IP from the host-only network192.168.56.0/24 and announces it over ARP. That
mechanism is what produces the 192.168.56.200 VIP of the Envoy entry point — no MetalLB
involved.
CNI in VXLAN tunnel mode, pinned to the host-only interface (see ⚠️ Pitfalls).
LoadBalancer IPs: a .200-.230 pool stands in for the missing cloud provider.
L2 announcement (ARP): the IP becomes reachable from the host, hence over Tailscale
(see ../README.md, "Remote access" section).
Network observability: Hubble (relay + UI) is enabled and the UI is exposed at
hubble.<LAB_DOMAIN> — handy for showing flows, and with no authentication.
Cluster bootstrapped with CNI=cilium (./kubeadm/cluster-up.sh or ./talos/cluster-up.sh, the default of both — CNI=none is equivalent here)
neither bootstrap installs a CNI at all: Cilium takes that slot
kubectl get nodes → NotReadybefore the install, that is expected
_out/cluster.env present (kubeadm only)
it carries the detected facts the script reads: HOSTONLY_IF, POD_CIDR, VIP, KUBE_PROXY_REPLACEMENT. On Talos there is no equivalent: those come from lab.env
cat _out/cluster.env
A host-only interface (usually enp0s8)
source of the ARP announcement and of the VXLAN tunnels
Chart cilium/cilium1.20.0, read from lab.env (CILIUM_VERSION) and overridable per run
(CILIUM_VERSION=1.20.1 ./cilium/cilium-up.sh). Idempotent (helm upgrade --install +
kubectl apply). ../platform-up.sh calls it as step [1/4] — so there is nothing to run
here if you go through the full platform.
⚠️ Do not take §9 of the root README as the installation reference. It shows the "manual"
helm upgrade to explain who installs the CNI, but without --version (you get the latest
published release, not the one validated here) and without applying cilium-l2.yml — so
without an IP pool: the Gateway would stay at EXTERNAL-IP <pending>. The source of truth is
cilium-up.sh.
This is the most distribution-dependent component in the whole repository.
Helm value
Talos
kubeadm
Why
ipam.mode
kubernetes
cluster-pool
On Talos the kube-controller-manager already carves per-node podCIDRs and Cilium follows them. On kubeadm the Cilium operator owns the pool (hence clusterPoolIPv4PodCIDRList + clusterPoolIPv4MaskSize=24).
kubeProxyReplacement
KUBE_PROXY_REPLACEMENT, default true
KUBE_PROXY_REPLACEMENT, default true
Same variable, same default, on both. Only the bootstrap differs: cluster.proxy.disabled: true in the Talos machine config (talos/patch-no-kube-proxy.yaml), kubeadm init --skip-phases=addon/kube-proxy on kubeadm. Either way there is then no kube-proxy and Cilium must take over in eBPF — getting the value wrong breaks every Service (CoreDNS included).
cgroup.autoMount.enabled + cgroup.hostRoot
false + /sys/fs/cgroup (required)
not set
Talos already mounts cgroup2 and the pod cannot remount /sys/fs/cgroup (read-only). On Debian the chart handles it: forcing these would be harmful.
securityContext.capabilities.*
explicit lists (required)
not set
Talos rejects the chart's implicit privileged.
devices
enp0s8
eth1/enp0s8, detected in _out/cluster.env
Without pinning, Cilium picks the NAT NIC 10.0.2.15 — identical on every VM ⇒ broken cross-node traffic and DNS.
The commands below are exactly what the all-in-one script does, in order.
Set up your shell first (once per session):
exportKUBECONFIG=../Vagrant-Talos/kubeconfig# or ../Vagrant-KubeADM/kubeconfigexportLAB_DOMAIN=talos.lab.example.io# or your own (see the lab's lab.env)
kubectlgetnodes# NotReady everywhere: that is EXPECTED, there is no CNI yet
kubectlgetds-A|grep-Ei'cilium|flannel|calico'# must be EMPTY (one CNI per cluster)
# kubeadm: the FACTS live in _out/cluster.env (written by cluster-up.sh)
grep-E'HOSTONLY_IF|POD_CIDR|KUBE_PROXY_REPLACEMENT'../Vagrant-KubeADM/_out/cluster.env
# Talos: no cluster.env — lab.env is the source, and the machine config is the ground truth
grep-A2podSubnets../Vagrant-Talos/_out/controlplane.yaml
grep-A2'^ proxy:'../Vagrant-Talos/_out/controlplane.yaml# disabled: true => no kube-proxy
helmupgrade--installciliumcilium/cilium-nkube-system--create-namespace\--version1.20.0\--setenvoy.enabled=false\--setkubeProxyReplacement=true\--setk8sServiceHost=192.168.56.5--setk8sServicePort=6443\--setroutingMode=tunnel--settunnelProtocol=vxlan\--setipam.mode=cluster-pool\--setipam.operator.clusterPoolIPv4PodCIDRList='{10.244.0.0/16}'\--setipam.operator.clusterPoolIPv4MaskSize=24\--setl2announcements.enabled=true--setexternalIPs.enabled=true\--sethubble.enabled=true--sethubble.relay.enabled=true--sethubble.ui.enabled=true\--setbandwidthManager.enabled=true\--setdevices=eth1# ⚠️ use the value DETECTED in _out/cluster.env
Reads _out/cluster.env (then lab.env as a fallback): HOSTONLY_IF, POD_CIDR, VIP,
KUBE_PROXY_REPLACEMENT — detected facts on kubeadm, lab.env intents on Talos, which has
no cluster.env;
installs Cilium with Helm in kube-system with the values below;
waits for condition=Ready on every node (300 s max) — the CNI is what unblocks them;
applies cilium-l2.yml: LoadBalancer IP pool + ARP announcement policy, with the pool
range and the interface name substituted in.
the key one: pins the host-only NIC (read from _out/cluster.env, never hardcoded). Without it, Cilium picks the NIC carrying the default route (NAT 10.0.2.15, identical on every VM) → unusable VTEP and ARP
routingMode=tunnel + tunnelProtocol=vxlan
encapsulation between nodes. There is no router on the host-only network, so native routing would need a static route per node on the VirtualBox side: tunnelling avoids the whole problem
⚠️ the trap: the cluster-pool default is 10.0.0.0/8, completely independent from the podSubnet declared to kubeadm. Without passing POD_CIDR back explicitly, kubeadm and Cilium do not talk about the same network
kubeProxyReplacement=<KUBE_PROXY_REPLACEMENT>
true (the default of both labs): the bootstrap installed no kube-proxy — kubeadm init --skip-phases=addon/kube-proxy, or cluster.proxy.disabled: true in the Talos machine config — so there is no kube-proxy at all and Cilium serves the Services in eBPF. false: kube-proxy is there, Cilium sits on top
k8sServiceHost=<VIP> + k8sServicePort=6443
mandatory without kube-proxy, hence on both labs by default: nothing provisions the apiserver ClusterIP any more, so the agent cannot bootstrap through kubernetes.default. We point at the VIP (keepalived on kubeadm, native on Talos), not at cp1's own IP: the VIP survives losing cp1, and it is the address already baked into the certificates. On Talos, Cilium's own docs suggest KubePrism (localhost:7445) instead — the VIP is kept here so both labs share one code path
l2announcements.enabled=true
enables the controller that answers ARP; without it the CiliumL2AnnouncementPolicy is ignored
externalIPs.enabled=true
support for Service externalIPs
envoy.enabled=false
no need for Cilium's embedded Envoy: the lab uses the ../envoy-gateway/ controller, a separate component
hubble.* + bandwidthManager.enabled=true
flow observability + bandwidth management (demos)
⚠️ What is deliberately absent. The Cilium docs' Talos page recommends
cgroup.autoMount.enabled=false, cgroup.hostRoot=/sys/fs/cgroup and explicit
securityContext.capabilities.* lists. Those are Talos workarounds and they are actively
harmful on Debian: the chart mounts cgroup2 itself and computes the capabilities the agent
needs. Do not copy them back in.
reserves the .200 → .230 range; every LoadBalancer Service draws from it
CiliumL2AnnouncementPolicyl2-lb-workers
announces those IPs over ARP on the host-only NIC, from the workers only (control planes are excluded by the nodeSelector)
Why these choices:
.200-.230 range: clear of the node IPs (CP .10/.20/.30, workers .101+), of the API
VIP .5 and of the gateway .1. Keep it aligned if you change the IP plan in lab.env.
Host-only interface: the only NIC through which the host can reach the VMs. The manifest
ships ^enp0s8$ as the default; cilium-up.sh rewrites it from HOSTONLY_IF in
_out/cluster.env, so a box that names it eth1 works with no edit.
Workers only: keeps a control plane from answering ARP for the VIP. On a single-node
topology (no worker), you have to drop the nodeSelector, otherwise nobody announces anything.
kubectl-nkube-systemgetpods-lk8s-app=cilium# one agent per node, Running
kubectlgetnodes# all Ready
kubectlgetciliumloadbalancerippool# lb-pool-56, DISABLED=false, IPS AVAILABLE
kubectlgetciliuml2announcementpolicy# l2-lb-workers
kubectl-nenvoy-gateway-systemgetsvc# EXTERNAL-IP = 192.168.56.200
ping-c1192.168.56.200# from the host: ARP must answer# Agent-side diagnostics. ⚠️ the in-pod binary was RENAMED `cilium` -> `cilium-dbg` in# v1.15: `... -- cilium status` no longer exists.
kubectl-nkube-systemexecds/cilium--cilium-dbgstatus--verbose
kubectl-nkube-systemexecds/cilium--cilium-dbgservicelist# empty => no eBPF Services
With KUBE_PROXY_REPLACEMENT=true — the default of both labs — there is no kube-proxy
DaemonSet in kube-system, and that is the expected state: cilium-dbg status must report
KubeProxyReplacement: True.
Hubble (relay + UI) is enabled, and cilium-up.sh exposes it through the Gateway API at
https://hubble.<LAB_DOMAIN> — the hubbleHTTPRoute of httproute.yaml,
in kube-system, attached to the https listener of main-gateway. TLS is the
*.<LAB_DOMAIN> wildcard the listener already carries: nothing else to issue.
⚠️ The Hubble UI has no authentication, and it shows the flows of every namespace.
Behind this Gateway it is reachable by anyone who can reach the VIP. On a lab that is the
point; anywhere else, put an Envoy Gateway SecurityPolicy (Basic Auth / OIDC) in front of
it, or delete the route: kubectl -n kube-system delete httproute hubble.
Ordering, if you are reading the scripts: platform-up.sh calls cilium-up.sh at step
[1/4], and it is step [2/4] that installs Envoy Gateway — so on a fresh install the
Gateway API CRDs do not exist yet when Cilium goes in. cilium-up.sh therefore only applies
the route when it finds the httproutes CRD, and platform-up.sh applies it itself right
after main-gateway is up. Either way the route exists at the end of platform-up.sh.
Without exposure at all (or before the platform layer is up), the port-forward still works:
kubectl-nkube-systemport-forwardsvc/hubble-ui12000:80# then http://localhost:12000
Service stuck at EXTERNAL-IP: <pending> → missing pool (cilium-l2.yml not applied),
exhausted range, or l2announcements not enabled at install time (the typical case when you
followed §9 of the root README instead of cilium-up.sh).
VIP that answers ping from the host but not from a Tailscale peer → expected: ARP does
not cross a router. You need --advertise-routes on the host
(see ../README.md).
--set autoDirectNodeRoutes=true (or ipv4NativeRoutingCIDR) is forbidden here: those are
native routing options, incompatible with tunnel mode. The agent exits fatal
("auto-direct-node-routes cannot be used with tunneling") and loops in CrashLoopBackOff.
Replacing kube-proxy is decided at bootstrap, not here — on both labs.KUBE_PROXY_REPLACEMENT=true makes kubeadm/cluster-up.sh run
kubeadm init --skip-phases=addon/kube-proxy, and talos/cluster-up.sh add
talos/patch-no-kube-proxy.yaml (cluster.proxy.disabled: true) to the generated machine
config. This script then reads the same value back — from _out/cluster.env on kubeadm, from
lab.env on Talos, where nothing detects it. Flipping only one of the two loses every Service
in the cluster. Changing your mind means rebuilding the cluster, not re-running this script.
k8sServiceHost is not optional without kube-proxy. Nothing provisions the
10.96.0.1 apiserver ClusterIP any more, so an agent that only knows kubernetes.default
never connects and stays in CrashLoopBackOff on Unable to contact k8s api-server.
Pod CIDR mismatch.ipam.mode=cluster-pool defaults to 10.0.0.0/8 regardless of the
podSubnet given to kubeadm. Symptom: pods get 10.0.x.x addresses while
kubectl get node -o jsonpath='{.spec.podCIDR}' says 10.244.x.0/24. Always pass
POD_CIDR back (the script does).
Do not re-run the script to "refresh" a cluster that is running a demo without reading the
Helm diff: changing routingMode or devices cuts traffic while the agents redeploy.
The two objects do NOT share an apiVersion, and that asymmetry is deliberate — verified
against the Cilium 1.20.0 docs:
CiliumLoadBalancerIPPool is cilium.io/v2, while CiliumL2AnnouncementPolicy is still
cilium.io/v2alpha1. Aligning them "for consistency" is the mistake to avoid: the pool
silently stops being served on v2alpha1, and the Gateway goes back to <pending>.
If a future Cilium promotes the policy to v2, the symptom is an apiVersion rejection at
kubectl apply — check with
kubectl get crd ciliuml2announcementpolicies.cilium.io -o jsonpath='{.spec.versions[*].name}'.
🐝cilium/ — CNI, IP LoadBalancer et annonce L2 (ARP)
La brique réseau du lab. Cilium fournit le CNI (sans lui les nodes restent NotReady)
et joue en plus le rôle de « cloud provider » : il attribue aux Services type: LoadBalancer
une vraie IP du réseau host-only192.168.56.0/24 et l'annonce en ARP. C'est ce
mécanisme qui produit le VIP 192.168.56.200 du point d'entrée Envoy — sans MetalLB.
CNI en mode tunnel VXLAN, épinglé sur l'interface host-only (cf. ⚠️ Pièges).
IP LoadBalancer : un pool .200-.230 remplace le cloud provider absent.
Annonce L2 (ARP) : l'IP devient joignable depuis l'hôte, donc via Tailscale
(cf. ../LISEZ-MOI.md, section « Accès distant »).
Observabilité réseau : Hubble (relay + UI) est activé et l'UI est exposée sur
hubble.<LAB_DOMAIN> — pratique pour montrer les flux, et sans aucune authentification.
Cluster bootstrapé avec CNI=cilium (./kubeadm/cluster-up.sh ou ./talos/cluster-up.sh, le défaut des deux — CNI=none est équivalent ici)
aucun des deux bootstraps n'installe de CNI : c'est Cilium qui prend la place
kubectl get nodes → NotReadyavant l'install, c'est normal
_out/cluster.env présent (kubeadm seulement)
il porte les faits détectés que lit le script : HOSTONLY_IF, POD_CIDR, VIP, KUBE_PROXY_REPLACEMENT. Sur Talos il n'y a pas d'équivalent : ces valeurs viennent de lab.env
Chart cilium/cilium1.20.0, lu dans lab.env (CILIUM_VERSION) et surchargeable au coup
par coup (CILIUM_VERSION=1.20.1 ./cilium/cilium-up.sh). Idempotent (helm upgrade --install +
kubectl apply). ../platform-up.sh l'appelle en étape [1/4] — tu n'as donc rien à lancer
ici si tu déroules la plateforme complète.
⚠️ Ne prends pas le §9 du README racine comme référence d'installation. Il montre le
helm upgrade « à la main » pour expliquer qui installe le CNI, mais sans --version
(tu prends la dernière release publiée, pas celle validée ici) et sans appliquer
cilium-l2.yml — donc sans pool d'IP : la Gateway resterait en EXTERNAL-IP <pending>.
La source de vérité, c'est cilium-up.sh.
C'est le composant le plus dépendant de la distribution de tout le dépôt.
Valeur Helm
Talos
kubeadm
Pourquoi
ipam.mode
kubernetes
cluster-pool
Sur Talos, le kube-controller-manager découpe déjà les podCIDR par node et Cilium les suit. Sur kubeadm, l'opérateur Cilium gère le pool (d'où clusterPoolIPv4PodCIDRList + clusterPoolIPv4MaskSize=24).
kubeProxyReplacement
KUBE_PROXY_REPLACEMENT, défaut true
KUBE_PROXY_REPLACEMENT, défaut true
Même variable, même défaut, des deux côtés. Seul le bootstrap diffère : cluster.proxy.disabled: true dans la config machine Talos (talos/patch-no-kube-proxy.yaml), kubeadm init --skip-phases=addon/kube-proxy sur kubeadm. Dans les deux cas il n'y a plus aucun kube-proxy et Cilium doit le remplacer en eBPF — se tromper de valeur casse tous les Services (CoreDNS compris).
cgroup.autoMount.enabled + cgroup.hostRoot
false + /sys/fs/cgroup (exigés)
non posés
Talos monte déjà cgroup2, et le pod ne peut pas remonter /sys/fs/cgroup (lecture seule). Sur Debian le chart s'en charge : forcer ces valeurs y serait nuisible.
securityContext.capabilities.*
listes explicites (exigées)
non posées
Talos refuse le privileged implicite du chart.
devices
enp0s8
eth1/enp0s8, détecté dans _out/cluster.env
Sans épinglage, Cilium prend la carte NAT 10.0.2.15 — identique sur toutes les VM ⇒ trafic cross-node et DNS cassés.
Les commandes ci-dessous sont exactement ce que fait le script tout-en-un, dans
l'ordre. Prépare d'abord l'environnement (une fois par session) :
exportKUBECONFIG=../Vagrant-Talos/kubeconfig# ou ../Vagrant-KubeADM/kubeconfigexportLAB_DOMAIN=talos.lab.example.io# ou ton domaine (cf. lab.env du lab)
kubectlgetnodes# NotReady partout : c'est NORMAL, il n'y a pas de CNI
kubectlgetds-A|grep-Ei'cilium|flannel|calico'# doit être VIDE (un seul CNI par cluster)
# kubeadm : les FAITS sont dans _out/cluster.env (écrit par cluster-up.sh)
grep-E'HOSTONLY_IF|POD_CIDR|KUBE_PROXY_REPLACEMENT'../Vagrant-KubeADM/_out/cluster.env
# Talos : pas de cluster.env — lab.env est la source, et la config machine est la vérité
grep-A2podSubnets../Vagrant-Talos/_out/controlplane.yaml
grep-A2'^ proxy:'../Vagrant-Talos/_out/controlplane.yaml# disabled: true => pas de kube-proxy
helmupgrade--installciliumcilium/cilium-nkube-system--create-namespace\--version1.20.0\--setenvoy.enabled=false\--setkubeProxyReplacement=true\--setk8sServiceHost=192.168.56.5--setk8sServicePort=6443\--setroutingMode=tunnel--settunnelProtocol=vxlan\--setipam.mode=cluster-pool\--setipam.operator.clusterPoolIPv4PodCIDRList='{10.244.0.0/16}'\--setipam.operator.clusterPoolIPv4MaskSize=24\--setl2announcements.enabled=true--setexternalIPs.enabled=true\--sethubble.enabled=true--sethubble.relay.enabled=true--sethubble.ui.enabled=true\--setbandwidthManager.enabled=true\--setdevices=eth1# ⚠️ la valeur DÉTECTÉE dans _out/cluster.env
kubectl-nkube-systemexecds/cilium--cilium-dbgstatus--verbose|head-30
# kube-proxy remplacé ? (les deux labs avec KUBE_PROXY_REPLACEMENT=true : NotFound est normal)
kubectl-nkube-systemgetdskube-proxy2>&1|tail-1
Lit _out/cluster.env (puis lab.env en repli) : HOSTONLY_IF, POD_CIDR, VIP,
KUBE_PROXY_REPLACEMENT — des faits détectés sur kubeadm, des intentions de lab.env sur
Talos, qui n'a pas de cluster.env ;
installe Cilium en Helm dans kube-system avec les valeurs ci-dessous ;
attendcondition=Ready sur tous les nodes (300 s max) — c'est le CNI qui les débloque ;
applique cilium-l2.yml : pool d'IP LoadBalancer + politique d'annonce ARP, avec la plage
et le nom de l'interface substitués.
le point clé : épingle la carte host-only (lue dans _out/cluster.env, jamais codée en dur). Sans ça, Cilium prend la carte de la route par défaut (NAT 10.0.2.15, identique sur chaque VM) → VTEP et ARP inutilisables
routingMode=tunnel + tunnelProtocol=vxlan
encapsulation entre nodes. Il n'y a aucun routeur sur le réseau host-only : le routage natif exigerait une route statique par node côté VirtualBox, le tunnel supprime le problème
⚠️ le piège : le défaut de cluster-pool est 10.0.0.0/8, sans aucun rapport avec le podSubnet déclaré à kubeadm. Si on ne repasse pas POD_CIDR explicitement, kubeadm et Cilium ne parlent pas du même réseau
kubeProxyReplacement=<KUBE_PROXY_REPLACEMENT>
true (défaut des deux labs) : le bootstrap n'a posé aucun kube-proxy — kubeadm init --skip-phases=addon/kube-proxy, ou cluster.proxy.disabled: true dans la config machine Talos — il n'y a donc aucun kube-proxy et Cilium sert les Services en eBPF. false : kube-proxy est là, Cilium se pose par-dessus
k8sServiceHost=<VIP> + k8sServicePort=6443
obligatoire sans kube-proxy, donc sur les deux labs par défaut : plus rien ne provisionne la ClusterIP de l'apiserver, l'agent ne peut donc pas s'amorcer via kubernetes.default. On vise la VIP (keepalived sur kubeadm, native sur Talos), pas l'IP propre de cp1 : la VIP survit à la perte de cp1, et c'est l'adresse déjà figée dans les certificats. Sur Talos, la doc de Cilium suggère plutôt KubePrism (localhost:7445) — on garde la VIP pour que les deux labs partagent un seul chemin de code
l2announcements.enabled=true
active le contrôleur qui répond à l'ARP ; sans lui la CiliumL2AnnouncementPolicy est ignorée
externalIPs.enabled=true
prise en charge des externalIPs de Services
envoy.enabled=false
pas besoin de l'Envoy embarqué de Cilium : le lab utilise le contrôleur ../envoy-gateway/, un composant distinct
hubble.* + bandwidthManager.enabled=true
observabilité des flux + gestion de bande passante (démos)
⚠️ Ce qui est volontairement absent. La page « Talos » de la doc Cilium recommande
cgroup.autoMount.enabled=false, cgroup.hostRoot=/sys/fs/cgroup et des listes explicites de
securityContext.capabilities.*. Ce sont des contournements Talos, activement nuisibles sur
Debian : le chart monte lui-même le cgroup2 et calcule les capabilities dont l'agent a besoin.
Ne les remets pas.
réserve la plage .200 → .230 ; chaque Service LoadBalancer pioche dedans
CiliumL2AnnouncementPolicyl2-lb-workers
annonce en ARP ces IP sur la carte host-only, depuis les workers uniquement (les control planes sont exclus par le nodeSelector)
Pourquoi ces choix :
Plage .200-.230 : hors des IP de nodes (CP .10/.20/.30, workers .101+), de la VIP
d'API .5 et de la passerelle .1. À garder alignée si tu changes le plan d'adressage de
lab.env.
Interface host-only : la seule carte par laquelle l'hôte peut joindre les VMs. Le manifeste
porte ^enp0s8$ comme défaut ; cilium-up.sh le réécrit depuis HOSTONLY_IF de
_out/cluster.env, donc une box qui la nomme eth1 marche sans rien éditer.
Workers seulement : évite qu'un control plane réponde à l'ARP du VIP. Sur une topologie
single node (aucun worker), il faut retirer le nodeSelector, sinon plus personne n'annonce.
kubectl-nkube-systemgetpods-lk8s-app=cilium# un agent par node, Running
kubectlgetnodes# tous Ready
kubectlgetciliumloadbalancerippool# lb-pool-56, DISABLED=false, IPS AVAILABLE
kubectlgetciliuml2announcementpolicy# l2-lb-workers
kubectl-nenvoy-gateway-systemgetsvc# EXTERNAL-IP = 192.168.56.200
ping-c1192.168.56.200# depuis l'hôte : l'ARP doit répondre# Diagnostic côté agent. ⚠️ le binaire dans le pod a été RENOMMÉ `cilium` -> `cilium-dbg`# en v1.15 : `... -- cilium status` n'existe plus.
kubectl-nkube-systemexecds/cilium--cilium-dbgstatus--verbose
kubectl-nkube-systemexecds/cilium--cilium-dbgservicelist# vide => aucun Service eBPF
Avec KUBE_PROXY_REPLACEMENT=true — le défaut des deux labs — il n'y a aucun DaemonSet
kube-proxy dans kube-system, et c'est l'état attendu : cilium-dbg status doit afficher
KubeProxyReplacement: True.
Hubble (relay + UI) est activé, et cilium-up.sh l'expose via la Gateway API sur
https://hubble.<LAB_DOMAIN> — l'HTTPRoutehubble de httproute.yaml,
dans kube-system, rattachée au listener https de main-gateway. Le TLS est le wildcard
*.<LAB_DOMAIN> que le listener porte déjà : rien d'autre à émettre.
⚠️ L'UI Hubble n'a aucune authentification, et elle montre les flux de tous les
namespaces. Derrière cette Gateway, elle est joignable par quiconque atteint la VIP. Sur un
lab c'est justement l'intérêt ; ailleurs, mets une SecurityPolicy Envoy Gateway (Basic Auth
/ OIDC) devant, ou supprime la route :
kubectl -n kube-system delete httproute hubble.
L'ordre, si tu lis les scripts : platform-up.sh appelle cilium-up.sh à l'étape [1/4],
et c'est l'étape [2/4] qui installe Envoy Gateway — donc sur une install neuve, les CRD de
la Gateway API n'existent pas encore quand Cilium est posé. cilium-up.sh n'applique donc la
route que s'il trouve la CRD httproutes, et platform-up.sh l'applique lui-même juste après
que main-gateway soit debout. Dans les deux cas la route existe à la fin de platform-up.sh.
Sans exposition du tout (ou avant que la couche platform soit debout), le port-forward marche
toujours :
kubectl-nkube-systemport-forwardsvc/hubble-ui12000:80# puis http://localhost:12000
Service coincé en EXTERNAL-IP: <pending> → pool absent (cilium-l2.yml non appliqué),
plage épuisée, ou l2announcements non activé à l'install (cas typique quand on a suivi le
§9 du README racine au lieu de cilium-up.sh).
VIP qui répond au ping depuis l'hôte mais pas depuis un peer Tailscale → normal :
l'ARP ne traverse pas un routeur. Il faut --advertise-routes sur l'hôte
(cf. ../LISEZ-MOI.md).
--set autoDirectNodeRoutes=true (ou ipv4NativeRoutingCIDR) est interdit ici : ce sont
des options de routage natif, incompatibles avec le mode tunnel. L'agent sort en fatal
(« auto-direct-node-routes cannot be used with tunneling ») et boucle en CrashLoopBackOff.
Le remplacement de kube-proxy se décide au bootstrap, pas ici — sur les deux labs.KUBE_PROXY_REPLACEMENT=true fait lancer à kubeadm/cluster-up.sh un
kubeadm init --skip-phases=addon/kube-proxy, et fait ajouter à talos/cluster-up.sh le patch
talos/patch-no-kube-proxy.yaml (cluster.proxy.disabled: true) dans la config machine
générée. Ce script relit ensuite la même valeur — dans _out/cluster.env sur kubeadm, dans
lab.env sur Talos, où rien ne la détecte. N'en changer qu'un des deux fait perdre TOUS les
Services du cluster. Changer d'avis = reconstruire le cluster, pas relancer ce script.
k8sServiceHost n'est pas optionnel sans kube-proxy. Plus rien ne provisionne la ClusterIP
10.96.0.1 de l'apiserver : un agent qui ne connaît que kubernetes.default ne se connecte
jamais et boucle en CrashLoopBackOff sur Unable to contact k8s api-server.
CIDR pod divergent.ipam.mode=cluster-pool part par défaut sur 10.0.0.0/8, quoi qu'on
ait déclaré à kubeadm. Symptôme : les pods prennent des adresses 10.0.x.x alors que
kubectl get node -o jsonpath='{.spec.podCIDR}' annonce 10.244.x.0/24. Il faut toujours
repasser POD_CIDR (le script le fait).
Ne relance pas le script pour « rafraîchir » un cluster en production de démo sans lire
le diff Helm : un changement de routingMode ou de devices coupe le trafic le temps du
redéploiement des agents.
Les deux objets ne partagent PAS la même apiVersion, et cette asymétrie est voulue —
vérifiée sur la documentation Cilium 1.20.0 :
CiliumLoadBalancerIPPool est en cilium.io/v2, tandis que CiliumL2AnnouncementPolicy est
toujours en cilium.io/v2alpha1. Les aligner « par cohérence » est justement l'erreur à ne
pas faire : le pool cesse silencieusement d'être servi en v2alpha1, et le Gateway repasse
en <pending>.
Si une future version de Cilium promeut la policy en v2, le symptôme est un rejet
d'apiVersion au kubectl apply — à vérifier avec
kubectl get crd ciliuml2announcementpolicies.cilium.io -o jsonpath='{.spec.versions[*].name}'.
🐆calico/ — alternative CNI: pod network + NetworkPolicy, no LoadBalancer IPs
The lab's third CNI choice, next to flannel (bare-bones) and cilium (the default).
Calico is installed by the Tigera operator and covers the pod network, routing and
NetworkPolicy. It does not take over the "cloud provider" role that Cilium fills on top:
no LoadBalancer Service IP, hence no 192.168.56.200 VIP on its own — which is why
../metallb/ is installed alongside it, automatically. Read the 🎯
section before choosing.
CNI: pod network over VXLAN on 10.244.0.0/16, natOutgoing to reach the Internet.
It is what takes the nodes from NotReady to Ready.
NetworkPolicy: the standard networking.k8s.io/v1 ones and the
NetworkPolicy/GlobalNetworkPolicy of projectcalico.org/v3 (order, tiers, explicit deny,
HostEndpoint…). That is the real reason this directory exists: working on micro-segmentation
with the reference implementation.
projectcalico.org/v3 API: the calico-apiserver is enabled, so Calico objects are read
and written with kubectl, without installing calicoctl.
What Calico does not do — and why that blocks you#
⚠️ Calico does NOT announce LoadBalancer Service IPs on this lab. It can only do it over
BGP (serviceLoadBalancerIPs in a BGPConfiguration), which requires a peer router to
establish a session with. On a VirtualBox host-only network, that router does not exist. And
Calico has no equivalent of Cilium's L2/ARP announcement
(CiliumLoadBalancerIPPool + CiliumL2AnnouncementPolicy). That is why installation.yaml
sets bgp: Disabled: it is not an oversight, it is a fact.
Concrete, not theoretical consequence: with CNI=calicoand nothing else,
What happens
Visible effect
The Envoy Gateway Service never gets an external IP
In other words: the whole k8s-playground/ layer of the lab depends on that VIP. So
../platform-up.sh no longer leaves you there: as soon as CNI != cilium it installs
MetalLB right after this script, on the very same range and the same
interface Cilium would have used. See 🌐 Making the UIs reachable.
❌ none of its own (BGP only) → metallb/, installed automatically
kube-proxy replacement
✅ kubeProxyReplacement=true (documented)
⚠️ only with the eBPF dataplane, ruled out here (see ⚠️ Pitfalls)
Flow observability
✅ Hubble (relay + UI) installed
⚠️ Whisker + Goldmane shipped by the chart, disabled by default here
Ready to use in THIS lab
✅ platform-up.sh chains everything
✅ platform-up.sh chains everything too, MetalLB included
💡 Recommendation: keep Cilium as the lab default. Calico is here to compare CNIs and to
work on NetworkPolicy, not to light up a complete lab without extra work.
Cluster bootstrapped without a CNI (CNI=calico in lab.env, then ./kubeadm/cluster-up.sh or ./talos/cluster-up.sh)
kubeadm init never installs a pod network: Calico takes that slot
kubectl get nodes → all NotReadybefore the install, that is expected
No other CNI present
two CNIs fight over /etc/cni/net.d and the routes; there is no clean way back
the script refuses to run if it sees a cilium or flannel DaemonSet in any namespace
KUBE_PROXY_REPLACEMENT=false — and it is not the default
⚠️ only Cilium can replace kube-proxy here. Bothcluster-up.sh refuse the calico + true pair outright, and this script re-checks it: without kube-proxy and without a replacement, no ClusterIP answers at all. Since true is the default of both labs, CNI=calico means setting this to false explicitly
kubectl -n kube-system get ds/kube-proxy
kubeadm podSubnet == IPPool CIDR
otherwise the kubelet allocates pod IPs that Calico has not programmed
grep POD_CIDR _out/cluster.env → 10.244.0.0/16
A host-only address on every node
source of Calico's address autodetection
kubectl get nodes -o wide → INTERNAL-IP in 192.168.56.x
Chart projectcalico/tigera-operatorv3.32.1 (repo https://docs.tigera.io/calico/charts),
pinned in the script via CALICO_VERSION. Idempotent (helm upgrade --install +
kubectl apply), safe to re-run.
Overridable variables:
Variable
Default
Role
CALICO_VERSION
v3.32.1
version of the chart and of Calico (the chart aligns them)
NETWORK
NETWORK from lab.env, else 192.168.56
builds the host-only CIDR used by address autodetection
POD_CIDR
POD_CIDR from _out/cluster.env, else lab.env, else 10.244.0.0/16
IPPool CIDR — must stay equal to the kubeadm podSubnet
HOSTONLY_CIDR
${NETWORK}.0/24
only touch it if your host-only network is not a /24
Calico's eBPF dataplane stays off on both: it needs bpfNetworkBootstrap +
FelixConfiguration, and it is broken on some Talos releases (siderolabs/talos#12221). For
eBPF in this lab: use Cilium.
The commands below are exactly what the all-in-one script does, in order.
Set up your shell first (once per session):
exportKUBECONFIG=../Vagrant-Talos/kubeconfig# or ../Vagrant-KubeADM/kubeconfigexportLAB_DOMAIN=talos.lab.example.io# or your own (see the lab's lab.env)
1. Guardrails (what the script checks before touching the cluster)#
kubectlgetnodes# NotReady: expected, no CNI yet
kubectlgetds-A|grep-Ei'cilium|flannel'# must be EMPTY
kubectl-nkube-systemgetdskube-proxy# MUST exist (Calico won't replace it)
3. Tigera operator chart — all 4 chart CRs disabled#
The chart ships no crds/ directory: the operator creates the operator.tigera.io CRDs when
it starts. Any CR rendered by Helm would therefore fail on a fresh cluster ("no matches for
kind").
# The versioned CIDRs are the lab defaults: adjust if your lab.env differs
sed-e's#192\.168\.56\.0/24#192.168.56.0/24#g'\-e's#10\.244\.0\.0/16#10.244.0.0/16#g'\calico/installation.yaml|kubectlapply-f-
kubectlapply-fcalico/apiserver.yaml
kubectlgetippools.projectcalico.org# through the calico-apiserver, no calicoctl
kubectl-nenvoy-gateway-systemgetsvc# EXTERNAL-IP <pending>: EXPECTED with Calico
⚠️ Calico announces no LoadBalancer Service IP (BGP only). <pending> at this exact point
is therefore normal: it is ../metallb/, installed by
platform-up.sh right after this script, that fills the address in — see the 🌐 section.
Guardrails: binaries, /readyz, refusal if another CNI is already there, refusal if
kube-proxy is missing (KUBE_PROXY_REPLACEMENT=true, the default of both labs), and refusal if POD_CIDR diverges from
the POD_CIDR recorded in _out/cluster.env.
namespace.yaml — the tigera-operator namespace, created before
the chart because it carries the PodSecurity privileged labels. Helm's --create-namespace
sets no label. kubeadm enforces nothing cluster-wide by default, so this is insurance rather
than a hard requirement — but it is what keeps the operator working on a hardened cluster
(see ⚠️ Pitfalls).
tigera-operator chart in that namespace. The operator runs in hostNetwork: it starts
without a CNI, which is what makes bootstrapping possible.
Waits for the operator.tigera.io CRDs: the operator is started with -manage-crds=true,
so it is the one creating installationsandapiservers.operator.tigera.io. Applying
a CR before that fails with "no matches for kind …".
kubectl apply of installation.yaml, with both CIDRs substituted
on the fly (same mechanism as LAB_DOMAIN in ../platform-up.sh), then of
apiserver.yaml which deploys the calico-apiserver.
Bounded waits: the calico-system/calico-node DaemonSet created, rollout status
(600 s, the time of the first pull on 8 VMs), then all nodes Ready (300 s). Each one fails
with an error carrying the diagnostic command to run — no || true anywhere.
Summary + a yellow reminder of the two steps still missing for the lab UIs.
The chart renders four custom resources (Installation, APIServer, Goldmane, Whisker)
and ships no crds/ directory — the CRDs are created by the operator at runtime
(-manage-crds=true). So every one of them has to be disabled at install time, otherwise Helm
fails before it even creates the namespace:
Error: unable to build kubernetes objects from release manifest: resource mapping not found
for name: "default" ... no matches for kind "APIServer" in version "operator.tigera.io/v1"
ensure CRDs are installed first
--set
Why
installation.enabled=false
the chart can generate the Installation CR itself; we pull it out into installation.yaml to get one readable file and one owner of the object (not Helm andkubectl apply)
apiServer.enabled=false
same reason, CR moved to apiserver.yaml and applied once the CRDs exist. The calico-apiserveris deployed: it exposes projectcalico.org/v3 → Calico objects via kubectl, no calicoctl needed
goldmane.enabled=false + whisker.enabled=false
the flow aggregator + UI shipped by Calico 3.32, turned off to keep the lab light (VM RAM is counted, see lab.env). Turning them back on means extracting their CRs the same way — --set goldmane.enabled=true alone reintroduces the bootstrap failure above
THE key one: forces the host-only address (see ⚠️ Pitfalls)
ipPools[0].cidr
10.244.0.0/16
identical to the kubeadm podSubnet
ipPools[0].encapsulation
VXLAN
unconditional encapsulation; VXLANCrossSubnet would fall back to direct routing between nodes of the same /24, which assumes the host-only switch forwards packets with a "pod" source IP — unverified. Same choice as flannel and Cilium
calicoNetwork.bgp
Disabled
no BGP peer on a host-only network ⇒ BIRD is useless (and therefore no service IP announcement)
calicoNetwork.linuxDataplane
Iptables
we keep the kube-proxy the bootstrap installed — hence the mandatory KUBE_PROXY_REPLACEMENT=false, on both labs
calicoNetwork.mtu
1450
1500 (host-only) − 50 (IPv4 VXLAN headers)
kubeletVolumePluginPath
None
lab slimming: disables the Calico CSI driver (and its per-node csi-node-driver DaemonSet). It only serves flow-log ephemeral volumes, unused here
flexVolumePath
None
lab slimming: without it the operator adds a flexvol-driver init container mounting /usr/libexec/kubernetes/kubelet-plugins/volume/exec/. FlexVolume has been deprecated since Kubernetes 1.23 and only feeds Dikastes (L7 policy), unused here
kubectl-ntigera-operatorgetpods# tigera-operator Running
kubectlgettigerastatus# calico / apiserver: AVAILABLE=True
kubectl-ncalico-systemgetpods-owide# one calico-node per node + typha
kubectlgetnodes# all Ready
kubectlgetinstallationdefault-oyaml# the CR as the operator completed it
kubectlgetippools.projectcalico.orgdefault-ipv4-ippool-oyaml# cidr + vxlanMode Always
The check that really matters: the address picked by each node must be in 192.168.56.x,
never10.0.2.15.
Then a cross-node traffic test (this is where the NAT NIC pitfall shows up):
kubectlrunt1--image=busybox--restart=Never--command--sleep3600
kubectlrunt2--image=busybox--restart=Never--command--sleep3600
kubectlgetpods-owide# check they are on 2 different nodes
kubectlexect1--ping-c3"$(kubectlgetpodt2-ojsonpath='{.status.podIP}')"
kubectlexect1--nslookupkubernetes.default# DNS = CoreDNS, often on another node
kubectldeletepodt1t2
Calico provides no LoadBalancer IP — but you no longer have to do anything about it. Both
steps that used to be manual are now handled by ../platform-up.sh at step [1/4], right
after this script, as soon as CNI != cilium:
1. MetalLB is installed in L2 mode, by ../metallb/metallb-up.sh,
on the same range Cilium uses (192.168.56.200 → 192.168.56.230, with the first IP going
to main-gateway), on the same host-only interface and from the workers only. It reads the same
LB_POOL_START / LB_POOL_END / HOSTONLY_IF keys of lab.env, so the wildcard DNS record
keeps pointing at the same address whichever CNI you picked.
platform-up.sh strips that line before applying the manifest when CNI != cilium.
⚠️ It matters, and it is the #1 false lead here. A loadBalancerClass tells Kubernetes
"only this controller may handle this Service": left in place, MetalLB ignores the Service and
the IP stays <pending>even with a perfectly valid pool. If you apply
Envoy-Proxy.yml by hand rather than through platform-up.sh, delete the line yourself.
Result, with no extra step:
./platform-up.sh<distro># CNI=calico → Calico + MetalLB
kubectl-nenvoy-gateway-systemgetsvc# EXTERNAL-IP = 192.168.56.200
kubectlgetservicel2status-A-owide# which worker announces it
Installing it on its own — for a repair, or if you had opted out with METALLB=false:
./install.sh<distro>metallb
⚠️ Never on a Cilium cluster. Two announcers on one range means two nodes answering ARP
for .200. metallb-up.sh refuses outright — see ../metallb/README.md.
NAT NIC elected for the tunnels — the house pitfall, see CLAUDE.md. Every VM has
enp0s3 (NAT, 10.0.2.15, the same IP on all VMs, and the one carrying the default route)
and enp0s8 (host-only, 192.168.56.x). Calico's default autodetection (firstFound) follows
the default route ⇒ every node declares itself as 10.0.2.15, every VXLAN VTEP points at an
isolated NAT, and cross-node pod traffic + DNS are broken. Hence
nodeAddressAutodetectionV4.cidrs: ["192.168.56.0/24"]. Same problem, same countermeasure as
--iface-can-reach (flannel) and devices=<host-only> (Cilium).
A baseline PodSecurity level blocks the operator, and it fails SILENTLY. The operator
needs hostNetwork (that is what lets it start with no CNI at all) and a /var/lib/calico
hostPath. kubeadm enforces nothing cluster-wide, so this does not bite out of the box — but
it does on any cluster with a hardened default. helm --create-namespace sets no PSS label, so
without namespace.yaml the Deployment would be created while the
ReplicaSet cannot create a single pod. The trap is the symptom: kubectl -n tigera-operator get pods returns nothing at all — not a failing pod, zero pods — and the script dies on the
rollout status timeout. The cause is only visible in the ReplicaSet events:
kubectl -n tigera-operator describe rs. Same recipe as
../observability/namespace.yaml.
💡 If you already hit the failure, fixing the labels is not enough: the ReplicaSet is in
exponential backoff and can idle past the 300 s timeout. Kick it with
kubectl -n tigera-operator rollout restart deploy/tigera-operator, then re-run the script.
IPPool CIDR ≠ kubeadm podSubnet = silently broken pod network. The script refuses to
continue if it detects the mismatch in _out/cluster.env, but if you change one, change the
other.
Changing CNI is NOT a live switch. Going from Cilium to Calico (or the other way round) on
a live cluster leaves contradictory routes, iptables/eBPF rules and /etc/cni/net.d files. The
procedure is: ./kubeadm/cluster-reset.sh (or vagrant destroy) → CNI=calico in lab.env
→ ./kubeadm/cluster-up.sh → ./calico/calico-up.sh. The script's guardrail is there to
stop you doing it by mistake, not to make the operation possible.
No Cilium loadBalancerClass: see the 🌐 section above. It is the #1 cause of an
EXTERNAL-IP <pending> that persists after MetalLB is installed — and it only bites when
Envoy-Proxy.yml was applied by hand, since platform-up.sh strips the line for you.
eBPF dataplane: tempting, ruled out. It would require bpfNetworkBootstrap: Enabled,
kubeProxyManagement: Enabled and a FelixConfiguration (cgroupV2Path), and it would take
over kube-proxy — exactly what KUBE_PROXY_REPLACEMENT=false says is not happening here.
Too many moving parts for the lab's "comparison" CNI: if you want eBPF, take Cilium.
kubectl delete -f installation.yaml does not cleanly uninstall Calico: the operator
deletes calico-node and every node drops to NotReady at once, pods included. Use
helm uninstall (see 🧹) or, better, destroy the lab.
MetalLB L2: a single node answers ARP per IP. Like the CiliumL2AnnouncementPolicy, this
is not load balancing: one speaker is elected per address, all the VIP traffic enters through
that node, then kube-proxy spreads it. A speaker failover takes a few seconds (the time to
re-ARP) — expected, not an incident.
The chart ships a pre-delete hook (Job tigera-operator-uninstall) that cleans up the CR
before removing the operator:
helmuninstallcalico-ntigera-operator
⚠️ This cuts the CNI: every node goes back to NotReady and the pod network disappears.
Do not do it "just to see" on a lab that hosts anything. To get back to Cilium, destroy and
rebuild the cluster (./kubeadm/cluster-reset.sh → CNI=cilium → ./kubeadm/cluster-up.sh
→ ./platform-up.sh).
🐆calico/ — CNI alternatif : réseau pod + NetworkPolicy, sans IP LoadBalancer
Troisième choix de CNI du lab, à côté de flannel (minimaliste) et de cilium
(le défaut). Calico est installé par l'opérateur Tigera et couvre le réseau pod,
le routage et les NetworkPolicy. Il ne remplace pas le rôle de « cloud provider »
que Cilium assure en plus : aucune IP de Service LoadBalancer, donc aucun VIP
192.168.56.200 par lui-même — c'est pour ça que ../metallb/
est installé à côté, automatiquement. Lis la section 🎯 avant de choisir.
CNI : réseau pod en VXLAN sur 10.244.0.0/16, natOutgoing pour sortir vers
Internet. C'est lui qui fait passer les nodes de NotReady à Ready.
NetworkPolicy : les networking.k8s.io/v1 standard et les
NetworkPolicy/GlobalNetworkPolicy de projectcalico.org/v3 (ordre, tiers, deny
explicite, HostEndpoint…). C'est la vraie raison d'être de ce dossier : travailler la
micro-segmentation avec l'implémentation de référence.
API projectcalico.org/v3 : le calico-apiserver est activé, donc les objets Calico
se lisent et s'écrivent au kubectl, sans installer calicoctl.
Ce que Calico ne fait pas — et pourquoi c'est bloquant#
⚠️ Calico n'annonce PAS les IP de Service LoadBalancer sur ce lab. Il ne sait le
faire qu'en BGP (serviceLoadBalancerIPs d'une BGPConfiguration), ce qui suppose un
routeur pair avec qui établir une session. Sur un réseau host-only VirtualBox, ce
routeur n'existe pas. Et Calico n'a aucun équivalent de l'annonce L2/ARP de Cilium
(CiliumLoadBalancerIPPool + CiliumL2AnnouncementPolicy). C'est pour ça que
installation.yaml pose bgp: Disabled : ce n'est pas un oubli, c'est un constat.
Conséquence concrète, pas théorique : avec CNI=calicoet rien d'autre,
Ce qui se passe
Effet visible
Le Service du Gateway Envoy ne reçoit jamais d'IP externe
Autrement dit : toute la couche k8s-playground/ du lab dépend de ce VIP. Aussi
../platform-up.sh ne te laisse plus là : dès que CNI != cilium, il installe
MetalLB juste après ce script, sur exactement la même plage et la
même interface que Cilium aurait utilisées. Voir
🌐 Rendre les UI joignables.
❌ aucune en propre (BGP uniquement) → metallb/, installé automatiquement
Remplacement de kube-proxy
✅ kubeProxyReplacement=true (documenté)
⚠️ seulement en dataplane eBPF, écarté ici (cf. ⚠️ Pièges)
Observabilité des flux
✅ Hubble (relay + UI) installés
⚠️ Whisker + Goldmane livrés par le chart, désactivés par défaut ici
Prêt à l'emploi dans CE lab
✅ platform-up.sh enchaîne tout
✅ platform-up.sh enchaîne tout aussi, MetalLB compris
💡 Recommandation : garde Cilium comme défaut du lab. Calico est là pour comparer
les CNI et pour travailler les NetworkPolicy, pas pour allumer un lab complet sans
travail supplémentaire.
Cluster bootstrapé sans CNI (CNI=calico dans lab.env, puis ./kubeadm/cluster-up.sh ou ./talos/cluster-up.sh)
kubeadm init n'installe jamais de réseau pod : Calico prend la place
kubectl get nodes → tous NotReadyavant l'install, c'est normal
Aucun autre CNI présent
deux CNI se disputent /etc/cni/net.d et les routes ; pas de retour en arrière propre
le script refuse de tourner s'il voit un DaemonSet cilium ou flannel dans n'importe quel namespace
KUBE_PROXY_REPLACEMENT=false — et ce n'est pas le défaut
⚠️ seul Cilium sait remplacer kube-proxy ici. Les deuxcluster-up.sh refusent net le couple calico + true, et ce script le revérifie : sans kube-proxy ET sans remplaçant, plus aucune ClusterIP ne répond. Comme true est le défaut des deux labs, CNI=calico impose de poser explicitement false
kubectl -n kube-system get ds/kube-proxy
podSubnet de kubeadm == CIDR de l'IPPool
sinon kubelet alloue des IP de pod que Calico n'a pas programmées
grep POD_CIDR _out/cluster.env → 10.244.0.0/16
Adresse host-only sur chaque node
source de l'autodétection d'adresse Calico
kubectl get nodes -o wide → INTERNAL-IP en 192.168.56.x
Chart projectcalico/tigera-operatorv3.32.1 (repo https://docs.tigera.io/calico/charts),
épinglé dans le script via CALICO_VERSION. Idempotent (helm upgrade --install +
kubectl apply), relançable sans casse.
Variables surchargeables :
Variable
Défaut
Rôle
CALICO_VERSION
v3.32.1
version du chart et de Calico (le chart les aligne)
NETWORK
NETWORK de lab.env, sinon 192.168.56
construit le CIDR host-only de l'autodétection d'adresse
POD_CIDR
POD_CIDR de _out/cluster.env, sinon lab.env, sinon 10.244.0.0/16
CIDR de l'IPPool — doit rester égal au podSubnet de kubeadm
HOSTONLY_CIDR
${NETWORK}.0/24
à ne toucher que si ton host-only n'est pas un /24
Le manifeste installation.yaml est commun, mais deux de ses réglages n'ont pas le même
statut selon la distribution :
Réglage
Talos
kubeadm
flexVolumePath: None
OBLIGATOIRE : sans lui l'opérateur monte /usr/libexec/kubernetes/… en DirectoryOrCreate, or /usr est en LECTURE SEULE ⇒ le pod calico-node ne démarre jamais
simple allègement (FlexVolume est déprécié depuis K8s 1.23 et inutilisé ici)
Le dataplane eBPF de Calico reste coupé sur les deux : il exige bpfNetworkBootstrap +
FelixConfiguration, et il est cassé sur certaines versions de Talos
(siderolabs/talos#12221). Pour de l'eBPF dans ce lab : Cilium.
Les commandes ci-dessous sont exactement ce que fait le script tout-en-un, dans
l'ordre. Prépare d'abord l'environnement (une fois par session) :
exportKUBECONFIG=../Vagrant-Talos/kubeconfig# ou ../Vagrant-KubeADM/kubeconfigexportLAB_DOMAIN=talos.lab.example.io# ou ton domaine (cf. lab.env du lab)
1. Garde-fous (ce que le script vérifie avant de toucher au cluster)#
kubectlgetnodes# NotReady : normal, pas de CNI
kubectlgetds-A|grep-Ei'cilium|flannel'# doit être VIDE
kubectl-nkube-systemgetdskube-proxy# DOIT exister (Calico ne le remplace pas)
3. Chart de l'opérateur Tigera — les 4 CR du chart sont coupées#
Le chart ne livre pas de dossier crds/ : c'est l'opérateur qui crée les CRD
operator.tigera.io à son démarrage. Toute CR rendue par Helm échouerait donc sur un cluster
neuf (« no matches for kind »).
# Les CIDR versionnés sont les défauts du lab : adapte-les si ton lab.env diffère
sed-e's#192\.168\.56\.0/24#192.168.56.0/24#g'\-e's#10\.244\.0\.0/16#10.244.0.0/16#g'\calico/installation.yaml|kubectlapply-f-
kubectlapply-fcalico/apiserver.yaml
kubectlgetippools.projectcalico.org# via le calico-apiserver, sans calicoctl
kubectl-nenvoy-gateway-systemgetsvc# EXTERNAL-IP <pending> : ATTENDU avec Calico
⚠️ Calico n'annonce aucune IP de Service LoadBalancer (BGP uniquement). Un <pending> à
cet endroit précis est donc normal : c'est ../metallb/, installé
par platform-up.sh juste après ce script, qui remplit l'adresse — cf. la section 🌐.
Garde-fous : binaires, /readyz, refus si un autre CNI est déjà là, refus si kube-proxy
est absent (KUBE_PROXY_REPLACEMENT=true, le défaut des deux labs), et refus si POD_CIDR diverge du POD_CIDR
enregistré dans _out/cluster.env.
namespace.yaml — le namespace tigera-operator, créé avant le
chart parce qu'il porte les labels PodSecurity privileged. Le --create-namespace de Helm
ne pose aucun label. Sur kubeadm rien n'est appliqué au niveau cluster par défaut : c'est
donc une assurance plutôt qu'une obligation — mais c'est elle qui garde l'opérateur
fonctionnel sur un cluster durci (cf. ⚠️ Pièges).
Chart tigera-operator dans ce namespace. L'opérateur tourne en hostNetwork : il
démarre sans CNI, c'est ce qui rend l'amorçage possible.
Attente des CRD operator.tigera.io : l'opérateur est lancé avec -manage-crds=true,
c'est donc lui qui crée installationsetapiservers.operator.tigera.io. Appliquer
une CR avant ça échoue sur « no matches for kind … ».
kubectl apply de installation.yaml, avec les deux CIDR
substitués à la volée (même mécanique que LAB_DOMAIN dans ../platform-up.sh), puis de
apiserver.yaml qui déploie le calico-apiserver.
Attentes bornées : DaemonSet calico-system/calico-node créé, rollout status
(600 s, le temps du premier pull sur 8 VM), puis tous les nodes Ready (300 s).
Chacune échoue en erreur avec la commande de diagnostic à lancer — aucun || true.
Résumé + rappel en jaune des deux étapes manquantes pour les UI du lab.
Le chart rend quatre custom resources (Installation, APIServer, Goldmane, Whisker)
et ne livre aucun dossier crds/ — les CRD sont créées par l'opérateur à l'exécution
(-manage-crds=true). Les quatre doivent donc être coupées à l'installation, sinon Helm échoue
avant même de créer le namespace :
Error: unable to build kubernetes objects from release manifest: resource mapping not found
for name: "default" ... no matches for kind "APIServer" in version "operator.tigera.io/v1"
ensure CRDs are installed first
--set
Pourquoi
installation.enabled=false
le chart sait générer la CR Installation lui-même ; on la sort dans installation.yaml pour avoir un fichier relisible et un seul propriétaire de l'objet (pas Helm etkubectl apply)
apiServer.enabled=false
même raison, CR sortie dans apiserver.yaml et appliquée une fois les CRD présentes. Le calico-apiserverest bien déployé : il expose projectcalico.org/v3 → objets Calico au kubectl, pas besoin de calicoctl
goldmane.enabled=false + whisker.enabled=false
l'agrégateur de flux + l'UI livrés par Calico 3.32, coupés pour garder le lab léger (la RAM des VM est comptée, cf. lab.env). Les rallumer impose d'extraire leurs CR de la même façon — --set goldmane.enabled=true seul réintroduit l'échec d'amorçage ci-dessus
LE point clé : force l'adresse host-only (cf. ⚠️ Pièges)
ipPools[0].cidr
10.244.0.0/16
identique au podSubnet de kubeadm
ipPools[0].encapsulation
VXLAN
encapsulation inconditionnelle ; VXLANCrossSubnet retomberait sur du routage direct entre nodes du même /24, ce qui suppose que le commutateur host-only relaie des paquets à IP source « de pod » — non vérifié. Même choix que flannel et Cilium
calicoNetwork.bgp
Disabled
pas de pair BGP sur un host-only ⇒ BIRD ne sert à rien (et donc pas d'annonce d'IP de service)
calicoNetwork.linuxDataplane
Iptables
on garde le kube-proxy posé par le bootstrap — d'où le KUBE_PROXY_REPLACEMENT=false obligatoire, sur les deux labs
calicoNetwork.mtu
1450
1500 (host-only) − 50 (entêtes VXLAN IPv4)
kubeletVolumePluginPath
None
allègement du lab : coupe le driver CSI Calico (et son DaemonSet csi-node-driver par node). Il ne sert qu'aux volumes éphémères de flow logs, inutilisés ici
flexVolumePath
None
allègement du lab : sans ça l'opérateur ajoute un init-container flexvol-driver qui monte /usr/libexec/kubernetes/kubelet-plugins/volume/exec/. FlexVolume est déprécié depuis Kubernetes 1.23 et n'alimente ici que Dikastes (policy L7), inutilisé
Puis un test de trafic cross-node (c'est là que se voit le piège de la carte NAT) :
kubectlrunt1--image=busybox--restart=Never--command--sleep3600
kubectlrunt2--image=busybox--restart=Never--command--sleep3600
kubectlgetpods-owide# vérifie qu'ils sont sur 2 nodes différents
kubectlexect1--ping-c3"$(kubectlgetpodt2-ojsonpath='{.status.podIP}')"
kubectlexect1--nslookupkubernetes.default# DNS = CoreDNS, souvent sur un autre node
kubectldeletepodt1t2
Calico ne fournit pas d'IP de LoadBalancer — mais tu n'as plus rien à faire pour autant.
Les deux étapes autrefois manuelles sont désormais prises en charge par ../platform-up.sh à
l'étape [1/4], juste après ce script, dès que CNI != cilium :
1. MetalLB est installé en mode L2, par ../metallb/metallb-up.sh,
sur la même plage que celle qu'utilise Cilium (192.168.56.200 → 192.168.56.230, la
première IP revenant à main-gateway), sur la même interface host-only et depuis les
workers uniquement. Il lit les mêmes clés LB_POOL_START / LB_POOL_END / HOSTONLY_IF de
lab.env : l'enregistrement DNS wildcard continue donc de pointer vers la même adresse, quel
que soit le CNI retenu.
platform-up.sh supprime cette ligne avant d'appliquer le manifeste quand CNI != cilium.
⚠️ Ça compte, et c'est la fausse piste n°1 ici. Une loadBalancerClass dit à Kubernetes
« seul ce contrôleur a le droit de traiter ce Service » : laissée en place, MetalLB ignore le
Service et l'IP reste <pending>même avec un pool parfaitement valide. Si tu appliques
Envoy-Proxy.yml à la main plutôt que via platform-up.sh, retire la ligne toi-même.
L'installer seul — pour une réparation, ou si tu t'étais exclu avec METALLB=false :
./install.sh<distro>metallb
⚠️ Jamais sur un cluster Cilium. Deux annonceurs sur une plage, ce sont deux nodes qui
répondent à l'ARP pour .200. metallb-up.sh refuse net — cf.
../metallb/LISEZ-MOI.md.
Carte NAT élue pour les tunnels — le piège maison, cf. CLAUDE.md. Chaque VM a
enp0s3 (NAT, 10.0.2.15, la même IP sur toutes les VMs, et c'est elle qui porte la
route par défaut) et enp0s8 (host-only, 192.168.56.x). L'autodétection par défaut de
Calico (firstFound) suit la route par défaut ⇒ tous les nodes se déclarent en
10.0.2.15, tous les VTEP VXLAN pointent vers un NAT isolé, et le trafic pod cross-node
le DNS sont cassés. D'où nodeAddressAutodetectionV4.cidrs: ["192.168.56.0/24"]. Même
problème, même parade que --iface-can-reach (flannel) et devices=<host-only> (Cilium).
Un niveau PodSecurity baseline bloque l'opérateur, et ça échoue SILENCIEUSEMENT.
L'opérateur a besoin du hostNetwork (c'est ce qui lui permet de démarrer sans aucun CNI) et
d'un hostPath /var/lib/calico. kubeadm n'applique rien au niveau cluster : ça ne mord
donc pas d'origine — mais ça mord sur tout cluster au défaut durci. Le
helm --create-namespace ne pose aucun label PSS : sans namespace.yaml,
le Deployment serait bien créé mais le ReplicaSet n'arriverait à créer aucun pod. Le piège, c'est
le symptôme : kubectl -n tigera-operator get pods ne renvoie rien du tout — pas un pod
en erreur, zéro pod — et le script meurt sur le timeout du rollout status. La cause n'est
visible que dans les events du ReplicaSet : kubectl -n tigera-operator describe rs. Même
recette que ../observability/namespace.yaml.
💡 Si tu as déjà rencontré l'échec, corriger les labels ne suffit pas : le ReplicaSet est en
backoff exponentiel et peut rester inactif au-delà des 300 s de timeout. Relance-le avec
kubectl -n tigera-operator rollout restart deploy/tigera-operator, puis rejoue le script.
CIDR de l'IPPool ≠ podSubnet de kubeadm = réseau pod silencieusement cassé.
Le script refuse de continuer s'il détecte l'écart dans _out/cluster.env, mais si tu
changes l'un, change l'autre.
Changer de CNI n'est PAS une bascule à chaud. Passer de Cilium à Calico (ou l'inverse)
sur un cluster vivant laisse des routes, des règles iptables/eBPF et des /etc/cni/net.d
contradictoires. La procédure est : ./kubeadm/cluster-reset.sh (Talos : vagrant destroy) →
CNI=calico dans lab.env → ./kubeadm/cluster-up.sh (ou ./talos/cluster-up.sh) → ./calico/calico-up.sh. Le
garde-fou du script est là pour t'empêcher de le faire par erreur, pas pour rendre
l'opération possible.
Pas de loadBalancerClass Cilium : cf. la section 🌐 ci-dessus. C'est la cause n°1
d'un EXTERNAL-IP <pending> qui persiste après l'installation de MetalLB — et ça ne mord
que si Envoy-Proxy.yml a été appliqué à la main, puisque platform-up.sh retire la ligne
pour toi.
Dataplane eBPF : tentant, écarté. Il exigerait bpfNetworkBootstrap: Enabled,
kubeProxyManagement: Enabled et une FelixConfiguration (cgroupV2Path), et il prendrait
la main sur kube-proxy — exactement ce que KUBE_PROXY_REPLACEMENT=false dit qu'on ne fait
pas ici. Trop de pièces mobiles pour le CNI « de comparaison » du lab : si on veut de
l'eBPF, on prend Cilium.
kubectl delete -f installation.yaml ne désinstalle pas proprement Calico : l'opérateur
supprime calico-node et tous les nodes retombent NotReady d'un coup, pods compris.
Utilise helm uninstall (cf. 🧹) ou, mieux, détruis le lab.
MetalLB L2 : une seule node répond à l'ARP par IP. Comme la
CiliumL2AnnouncementPolicy, ce n'est pas de l'équilibrage : un speaker est élu par
adresse, tout le trafic du VIP entre par ce node, puis kube-proxy répartit. Une bascule de
speaker prend quelques secondes (le temps du ré-ARP) — normal, pas un incident.
Le chart embarque un hook pre-delete (Job tigera-operator-uninstall) qui nettoie la CR
avant de retirer l'opérateur :
helmuninstallcalico-ntigera-operator
⚠️ Ça coupe le CNI : tous les nodes repassent NotReady et le réseau pod disparaît.
Ne le fais pas « pour voir » sur un lab qui héberge quelque chose. Pour revenir à Cilium,
détruis et reconstruis le cluster (./kubeadm/cluster-reset.sh → CNI=cilium →
./kubeadm/cluster-up.sh → ./platform-up.sh).
📢metallb/ — LoadBalancer IPs and L2 announcement, when the CNI is not Cilium
The missing cloud provider. Cilium is the only CNI of this lab that hands type: LoadBalancer
Services a real IP and announces it over ARP. With Calico, flannel or CNI=none,
nothing does: the Envoy Gateway Service stays at EXTERNAL-IP <pending> and no lab UI is
reachable. MetalLB in layer 2 mode fills exactly that hole, on the same range, the same
interface and the same nodes as Cilium would.
MetalLB allocates an IP from the host-only range 192.168.56.200-230 to every
type: LoadBalancer Service (the controller Deployment) and announces it over ARP from a
worker node, so the host (and anything routed to it, Tailscale included) can reach it (the
speaker DaemonSet). Nothing else: MetalLB is not a CNI, it needs a working pod network to run.
metallb-up.sh reads the same lab.env keys as Cilium and lays down the same two objects,
translated into MetalLB's API, so switching CNI changes the CNI, not the address the
*.<LAB_DOMAIN> wildcard points at.
lab.env key
Cilium object
MetalLB object
LB_POOL_START / LB_POOL_END
CiliumLoadBalancerIPPool (start/stop)
IPAddressPool (addresses: ["start-stop"])
HOSTONLY_IF
CiliumL2AnnouncementPolicy.interfaces (a regex, ^enp0s8$)
L2Advertisement.interfaces (a plain name, enp0s8)
— (a lab choice)
nodeSelector: control-plane DoesNotExist
nodeSelectors: the same expression
The first IP of the range goes to main-gateway in both cases: 192.168.56.200.
../platform-up.sh decides at step [1/4], right after the CNI:
CNI in lab.env
L2 announcer
MetalLB installed?
cilium (the lab default)
Cilium itself
❌ never — see ⚠️ Pitfalls
calico · flannel · none
MetalLB
✅ (for none, once your own CNI has made the nodes Ready)
Cilium already announces these IPs; two announcers on one range is an ARP conflict
sed -n 's/^CNI=//p' lab.env
A working CNI, nodes Ready
MetalLB is an ordinary workload: with no pod network the controller never gets an IP
kubectl get nodes
A host-only interface (enp0s8, or eth1 on some kubeadm boxes)
source of the ARP announcement — never the NAT card
cat _out/cluster.env (kubeadm)
kubectl + helm, KUBECONFIG set
the script checks the binaries, then /readyz
helm version
kube-proxy is present by construction: KUBE_PROXY_REPLACEMENT=true implies CNI=cilium, which
rules MetalLB out, so MetalLB always finds a kube-proxy in front of it, on both labs.
In practice you do not run this by hand: ../platform-up.sh calls it at step [1/4] as soon
as CNI != cilium. The direct call is there for a repair, or for the walkthrough below:
./install.sh<distro>metallb# <distro> = talos | kubeadm
./metallb/metallb-up.sh<distro># the same thing, directly
Chart metallb/metallb0.16.1, overridable per run
(METALLB_VERSION=0.16.0 ./metallb/metallb-up.sh). Idempotent (helm upgrade --install +
kubectl apply).
ℹ️ metallb is deliberately excluded from ./install.sh <distro> all: on the default
CNI=cilium it refuses to install, and an unconditional all would always stop right there.
platform installs it when, and only when, the CNI calls for it.
The same thing as the script, one command at a time.
exportKUBECONFIG="$PWD/kubeconfig"# from the lab rootLB_POOL_START=$(sed-n's/^LB_POOL_START=//p'lab.env|head-1|tr-d' "')LB_POOL_END=$(sed-n's/^LB_POOL_END=//p'lab.env|head-1|tr-d' "')HOSTONLY_IF=$(sed-n's/^HOSTONLY_IF=//p'_out/cluster.env|head-1)# kubeadmHOSTONLY_IF=${HOSTONLY_IF:-enp0s8}# Talosecho"$LB_POOL_START-$LB_POOL_END on $HOSTONLY_IF"
# 1. Starting point: nodes Ready (the CNI is there), and nothing else announcing
kubectlgetnodes
kubectlgetciliuml2announcementpolicies.cilium.io2>/dev/null# MUST be empty / no CRD
kubectl-nenvoy-gateway-systemgetsvc# EXTERNAL-IP <pending># 2. The namespace, BEFORE the chart (see ⚠️ Pitfalls)
kubectlapply-fmetallb/namespace.yaml
kubectlgetnsmetallb-system--show-labels# pod-security.kubernetes.io/enforce=privileged# 3. The chart — L2 only, no FRR
helmrepoaddmetallbhttps://metallb.github.io/metallb&&helmrepoupdatemetallb
helmupgrade--installmetallbmetallb/metallb\--namespacemetallb-system--version0.16.1\--setfrrk8s.enabled=false--setspeaker.frr.enabled=false# 4. Controller AND speakers — the controller allocates, the speakers announce
kubectl-nmetallb-systemrolloutstatusdeploy/metallb-controller--timeout=300s
kubectl-nmetallb-systemrolloutstatusdaemonset/metallb-speaker--timeout=300s
# 5. The pool + the L2 announcement, with your values substituted in
sed-e"s/192\.168\.56\.200/${LB_POOL_START}/g"\-e"s/192\.168\.56\.230/${LB_POOL_END}/g"\-e"s/enp0s8/${HOSTONLY_IF}/g"\metallb/metallb-l2.yml|kubectlapply-f-
# 6. Verify
kubectl-nmetallb-systemgetipaddresspool,l2advertisement
kubectl-nenvoy-gateway-systemgetsvc# EXTERNAL-IP = 192.168.56.200
kubectlgetservicel2status-A-owide# which node announces which IP
⚠️ If step 5 fails with connection refused or an x509 error on metallb-webhook-service, you
are simply early: the controller injects its own certificate into the
ValidatingWebhookConfiguration, which takes a few seconds after the rollout. Retry; that is
what the script's loop (150 s) does.
The script adds guard rails to what would otherwise be a helm install: it refuses if Cilium
announces or if CNI=cilium (two ARP announcers on one range is the hardest failure of this lab to
read), and it refuses if no node is Ready (MetalLB on a CNI-less cluster hangs Pending for five
minutes before saying anything).
Two --set flags matter, and both disable the same thing two ways:
--set
Value
Why
frrk8s.enabled
false
Since chart 0.15 the FRR-K8s subchart is enabled by default: a full FRR routing daemon on every node, for BGP. This lab announces over L2 only (no peer router on a VirtualBox host-only network) — pure dead weight.
speaker.frr.enabled
false
The other, deprecated, way to get FRR into the speaker pod. The chart refuses both at once, so both are set explicitly rather than trusting which one defaults to false in the version of the day.
Everything else stays at the chart default, speaker.tolerateMaster=true included, which runs a
speaker on the control planes too. Harmless: what decides who announces is the nodeSelectors of
the L2Advertisement, exactly like the Cilium policy. In metallb-l2.yml, autoAssign: true on
the pool means the Gateway asks for no particular address and gets the first free one, and
ipAddressPools names the pool explicitly; without it the advertisement would cover every pool.
kubectl-nmetallb-systemgetpods# 1 controller + 1 speaker per node
kubectl-nmetallb-systemgetipaddresspool-owide
kubectl-nmetallb-systemgetl2advertisement-oyaml|grep-A4interfaces
kubectl-nenvoy-gateway-systemgetsvc# the Gateway took the FIRST IP of the pool
kubectlgetservicel2status-A-owide# who announces what
And above all, the IP must answer, which no kubectl get proves:
GWIP=$(kubectl-nenvoy-gateway-systemgetsvc\-ojsonpath='{.items[?(@.spec.type=="LoadBalancer")].status.loadBalancer.ingress[0].ip}')
curl-s-o/dev/null-w'%{http_code}\n'"http://$GWIP/"# 404 = Envoy answers (no route yet)
ipneighshow"$GWIP"# ARP resolved = L2 announcement OK
⚠️ Never test that IP with ping. No interface really carries the address: the elected
speaker only answers ARP to attract the traffic, then the node forwards it. ICMP to the VIP
therefore gets nothing, while ping on a node (.101) works, a very convincing false
negative. The proof of the announcement is the ARP entry resolving to a worker's MAC:
ipneighflush"$GWIP";curl-s-o/dev/null--max-time5"http://$GWIP/"
ipneighshow"$GWIP"# lladdr = MAC of the elected worker
The demo that justifies the component: the announcement survives losing a node.
kubectlgetservicel2status-A-owide# note the announcing node, e.g. k8s-w2
ipneighshow"$GWIP"# note the MAC
kubectl-nmetallb-systemdeletepod-lapp.kubernetes.io/component=speaker\--field-selectorspec.nodeName=k8s-w2
# A few seconds later, another worker has taken over
kubectlgetservicel2status-A-owide
ipneighflush"$GWIP";curl-s-o/dev/null-w'%{http_code}\n'"http://$GWIP/"
ipneighshow"$GWIP"# a DIFFERENT MAC — same IP
The few seconds of downtime are the re-ARP: expected, not an incident.
Never MetalLB and Cilium's L2 announcement. Two speakers answering ARP for
192.168.56.200 make the host's ARP cache flap between two MACs: the entry point works "one time
in two", and nothing in any log says so. The script refuses on two independent signals (the live
cilium.io objects, and CNI resolving to cilium): the right move is to pick one CNI.
interfaces: is a list of plain names, not regexes. Cilium's policy takes ^enp0s8$; MetalLB
takes enp0s8. Copying the Cilium syntax across matches no interface and the announcement is
dropped in silence: pool valid, IP assigned, ARP mute.
loadBalancerClass beats any pool. As long as
../envoy-gateway/Envoy-Proxy.yml pins
loadBalancerClass: io.cilium/l2-announcer, MetalLB ignores the Service, and a
loadBalancerClass means "only this controller may serve it". platform-up.sh removes the line
when CNI != cilium; applying that manifest by hand does not.
The privileged labels are not cosmetic, and their absence lies to you. The controller is
not privileged: it starts, allocates, and writes a perfectly normal EXTERNAL-IP into the
Service. Only the speaker is refused, so you get an address that looks assigned and answers
nothing. Always cross-check get svc with get pods.
WORKERS=0 leaves nobody to announce. The nodeSelectors keep control planes out on purpose.
On a lab with no worker, remove them from metallb-l2.yml.
One node per IP, not load balancing. Like CiliumL2AnnouncementPolicy, L2 mode elects a
single speaker per address: all the VIP traffic enters through that node, then kube-proxy spreads
it across the endpoints.
kube-proxy in IPVS mode needs strictARP: true. Neither lab uses IPVS, so this does not
bite here, but it is the first thing to check if you take this component to another cluster: in
IPVS mode without strictARP, every node answers ARP for the VIP.
Changing CNI is not a live switch. Going from Cilium to Calico means a rebuilt cluster
(./kubeadm/cluster-reset.sh, or vagrant destroy), not a helm uninstall.
kubectl-nmetallb-systemdelete-fmetallb/metallb-l2.yml# stop announcing first
helmuninstallmetallb-nmetallb-system
kubectldeletensmetallb-system
⚠️ Every LoadBalancer Service goes back to EXTERNAL-IP <pending> and every UI becomes
unreachable, the Gateway included. The CRDs are removed with the chart, so any leftover
IPAddressPool disappears with them.
📢metallb/ — IP LoadBalancer et annonce L2, quand le CNI n'est pas Cilium
Le fournisseur cloud manquant. Cilium est le seul CNI de ce lab qui donne une vraie IP aux
Services type: LoadBalancer et l'annonce en ARP. Avec Calico, flannel ou CNI=none,
personne ne le fait : le Service du Gateway Envoy reste en EXTERNAL-IP <pending> et aucune UI du
lab n'est joignable. MetalLB en mode layer 2 comble exactement ce trou, sur la même plage, la
même interface et les mêmes nodes que Cilium l'aurait fait.
MetalLB attribue une IP de la plage host-only 192.168.56.200-230 à chaque Service
type: LoadBalancer (le Deployment controller) et l'annonce en ARP depuis un worker, pour
que l'hôte (et tout ce qui est routé vers lui, Tailscale compris) puisse l'atteindre (le DaemonSet
speaker). Rien d'autre : MetalLB n'est pas un CNI, il a besoin d'un réseau de pods qui marche
pour tourner.
metallb-up.sh lit les mêmes clés lab.env que Cilium et pose les mêmes deux objets, traduits
dans l'API de MetalLB, donc changer de CNI change le CNI, pas l'adresse vers laquelle pointe le
wildcard *.<LAB_DOMAIN>.
Cilium annonce déjà ces IP ; deux annonceurs sur une plage, c'est un conflit ARP
sed -n 's/^CNI=//p' lab.env
Un CNI qui marche, nodes Ready
MetalLB est une charge de travail ordinaire : sans réseau de pods, le controller n'obtient jamais d'IP
kubectl get nodes
Une interface host-only (enp0s8, ou eth1 sur certaines box kubeadm)
source de l'annonce ARP — jamais la carte NAT
cat _out/cluster.env (kubeadm)
kubectl + helm, KUBECONFIG posé
le script vérifie les binaires, puis /readyz
helm version
kube-proxy est présent par construction : KUBE_PROXY_REPLACEMENT=true implique CNI=cilium, ce
qui exclut MetalLB, donc MetalLB trouve toujours un kube-proxy devant lui, sur les deux labs.
En pratique tu ne lances pas ça à la main : ../platform-up.sh l'appelle à l'étape [1/4] dès
que CNI != cilium. L'appel direct est là pour une réparation, ou pour le pas à pas ci-dessous :
./install.sh<distro>metallb# <distro> = talos | kubeadm
./metallb/metallb-up.sh<distro># la même chose, directement
ℹ️ metallb est volontairement exclu de ./install.sh <distro> all : sur le défaut
CNI=cilium il refuse de s'installer, et un all inconditionnel s'arrêterait toujours là.
platform l'installe quand, et seulement quand, le CNI l'exige.
L'annonce elle-même est identique : même chart, même pool, même interface, même sélection de nodes.
Deux choses diffèrent, et seule la première compte.
Point
Talos
kubeadm
Conséquence
Défaut PodSecurity
baselineimposé sur tout le cluster
aucun niveau imposé
Le speaker tourne en hostNetwork et ajoute NET_RAW — baseline interdit les deux. namespace.yaml est donc obligatoire sur Talos et documentaire sur kubeadm.
Interface host-only
toujours enp0s8
eth1 ou enp0s8 selon la box → détectée dans _out/cluster.env
HOSTONLY_IF couvre les deux ; le script la substitue dans metallb-l2.yml.
La même chose que le script, une commande à la fois.
exportKUBECONFIG="$PWD/kubeconfig"# depuis la racine du labLB_POOL_START=$(sed-n's/^LB_POOL_START=//p'lab.env|head-1|tr-d' "')LB_POOL_END=$(sed-n's/^LB_POOL_END=//p'lab.env|head-1|tr-d' "')HOSTONLY_IF=$(sed-n's/^HOSTONLY_IF=//p'_out/cluster.env|head-1)# kubeadmHOSTONLY_IF=${HOSTONLY_IF:-enp0s8}# Talosecho"$LB_POOL_START-$LB_POOL_END sur $HOSTONLY_IF"
# 1. Point de départ : nodes Ready (le CNI est là), et rien d'autre qui annonce
kubectlgetnodes
kubectlgetciliuml2announcementpolicies.cilium.io2>/dev/null# DOIT être vide / pas de CRD
kubectl-nenvoy-gateway-systemgetsvc# EXTERNAL-IP <pending># 2. Le namespace, AVANT le chart (voir ⚠️ Pièges)
kubectlapply-fmetallb/namespace.yaml
kubectlgetnsmetallb-system--show-labels# pod-security.kubernetes.io/enforce=privileged# 3. Le chart — L2 uniquement, sans FRR
helmrepoaddmetallbhttps://metallb.github.io/metallb&&helmrepoupdatemetallb
helmupgrade--installmetallbmetallb/metallb\--namespacemetallb-system--version0.16.1\--setfrrk8s.enabled=false--setspeaker.frr.enabled=false# 4. Le controller ET les speakers — le controller attribue, les speakers annoncent
kubectl-nmetallb-systemrolloutstatusdeploy/metallb-controller--timeout=300s
kubectl-nmetallb-systemrolloutstatusdaemonset/metallb-speaker--timeout=300s
# 5. Le pool + l'annonce L2, avec tes valeurs substituées
sed-e"s/192\.168\.56\.200/${LB_POOL_START}/g"\-e"s/192\.168\.56\.230/${LB_POOL_END}/g"\-e"s/enp0s8/${HOSTONLY_IF}/g"\metallb/metallb-l2.yml|kubectlapply-f-
# 6. Vérifier
kubectl-nmetallb-systemgetipaddresspool,l2advertisement
kubectl-nenvoy-gateway-systemgetsvc# EXTERNAL-IP = 192.168.56.200
kubectlgetservicel2status-A-owide# quel node annonce quelle IP
⚠️ Si l'étape 5 échoue sur connection refused ou une erreur x509 sur
metallb-webhook-service, tu es simplement en avance : le controller injecte son propre
certificat dans la ValidatingWebhookConfiguration, ce qui prend quelques secondes après le
rollout. Réessaie ; c'est ce que fait la boucle du script (150 s).
Le script ajoute des garde-fous à ce qui serait sinon un helm install : il refuse si Cilium annonce
ou si CNI=cilium (deux annonceurs ARP sur une plage est la panne la plus illisible de ce lab), et il
refuse si aucun node n'est Ready (MetalLB sur un cluster sans CNI reste Pending cinq minutes avant
de dire quoi que ce soit).
Deux --set comptent, et tous deux désactivent la même chose de deux façons :
--set
Valeur
Pourquoi
frrk8s.enabled
false
Depuis le chart 0.15, le sous-chart FRR-K8s est activé par défaut : un démon de routage FRR complet sur chaque node, pour BGP. Ce lab n'annonce qu'en L2 (aucun routeur pair sur un réseau host-only VirtualBox) — poids mort.
speaker.frr.enabled
false
L'autre façon, dépréciée, de mettre FRR dans le pod speaker. Le chart refuse les deux à la fois, donc les deux sont posés explicitement plutôt que de faire confiance à celui qui vaut false par défaut dans la version du jour.
Tout le reste reste au défaut du chart, speaker.tolerateMaster=true compris, qui fait tourner un
speaker sur les control planes aussi. Sans conséquence : ce qui décide qui annonce, c'est les
nodeSelectors de l'L2Advertisement, exactement comme la politique Cilium. Dans metallb-l2.yml,
autoAssign: true sur le pool signifie que le Gateway ne demande aucune adresse particulière et
reçoit la première libre, et ipAddressPools nomme le pool explicitement ; sans ça, l'annonce
couvrirait tous les pools.
kubectl-nmetallb-systemgetpods# 1 controller + 1 speaker par node
kubectl-nmetallb-systemgetipaddresspool-owide
kubectl-nmetallb-systemgetl2advertisement-oyaml|grep-A4interfaces
kubectl-nenvoy-gateway-systemgetsvc# le Gateway a pris la PREMIÈRE IP du pool
kubectlgetservicel2status-A-owide# qui annonce quoi
Et surtout, l'IP doit répondre, ce qu'aucun kubectl get ne prouve :
GWIP=$(kubectl-nenvoy-gateway-systemgetsvc\-ojsonpath='{.items[?(@.spec.type=="LoadBalancer")].status.loadBalancer.ingress[0].ip}')
curl-s-o/dev/null-w'%{http_code}\n'"http://$GWIP/"# 404 = Envoy répond (pas encore de route)
ipneighshow"$GWIP"# ARP résolu = annonce L2 OK
⚠️ Ne teste jamais cette IP avec ping. Aucune interface ne porte réellement l'adresse : le
speaker élu se contente de répondre à l'ARP pour attirer le trafic, puis le node le transmet.
L'ICMP vers la VIP n'obtient donc rien, alors que le ping d'un node (.101) fonctionne, un
faux négatif très convaincant. La preuve de l'annonce, c'est l'entrée ARP qui se résout vers la MAC
d'un worker :
ipneighflush"$GWIP";curl-s-o/dev/null--max-time5"http://$GWIP/"
ipneighshow"$GWIP"# lladdr = MAC du worker élu
La démo qui justifie le composant : l'annonce survit à la perte d'un node.
kubectlgetservicel2status-A-owide# note le node annonceur, p. ex. k8s-w2
ipneighshow"$GWIP"# note la MAC
kubectl-nmetallb-systemdeletepod-lapp.kubernetes.io/component=speaker\--field-selectorspec.nodeName=k8s-w2
# Quelques secondes plus tard, un autre worker a repris la main
kubectlgetservicel2status-A-owide
ipneighflush"$GWIP";curl-s-o/dev/null-w'%{http_code}\n'"http://$GWIP/"
ipneighshow"$GWIP"# une MAC DIFFÉRENTE — même IP
Les quelques secondes de coupure sont le ré-ARP : attendu, pas un incident.
Jamais MetalLB et l'annonce L2 de Cilium. Deux speakers qui répondent à l'ARP pour
192.168.56.200 font osciller le cache ARP de l'hôte entre deux MAC : le point d'entrée fonctionne
« une fois sur deux », et rien dans aucun log ne le dit. Le script refuse sur deux signaux
indépendants (les objets cilium.io vivants, et CNI résolu à cilium) : la bonne réaction est de
choisir un seul CNI.
interfaces: est une liste de noms simples, pas de regex. La politique Cilium prend
^enp0s8$ ; MetalLB prend enp0s8. Recopier la syntaxe Cilium ne correspond à aucune interface
et l'annonce est abandonnée en silence : pool valide, IP attribuée, ARP muet.
loadBalancerClass bat n'importe quel pool. Tant que
../envoy-gateway/Envoy-Proxy.yml épingle
loadBalancerClass: io.cilium/l2-announcer, MetalLB ignore le Service, et un loadBalancerClass
signifie « seul ce contrôleur peut le servir ». platform-up.sh retire la ligne quand
CNI != cilium ; appliquer ce manifeste à la main, non.
Les étiquettes privileged ne sont pas cosmétiques, et leur absence te ment. Le controller
n'est pas privilégié : il démarre, attribue, et écrit une EXTERNAL-IP parfaitement normale dans le
Service. Seul le speaker est refusé, donc tu obtiens une adresse qui a l'air attribuée et ne
répond à rien. Croise toujours get svc avec get pods.
WORKERS=0 ne laisse personne pour annoncer. Les nodeSelectors excluent les control planes à
dessein. Sur un lab sans worker, retire-les de metallb-l2.yml.
Un node par IP, pas de répartition de charge. Comme CiliumL2AnnouncementPolicy, le mode L2 élit
un seul speaker par adresse : tout le trafic de la VIP entre par ce node, puis kube-proxy le répartit
sur les endpoints.
kube-proxy en mode IPVS exige strictARP: true. Aucun des deux labs n'utilise IPVS, donc ça ne
mord pas ici, mais c'est la première chose à vérifier si tu emportes ce composant sur un autre
cluster : en IPVS sans strictARP, tous les nodes répondent à l'ARP pour la VIP.
Changer de CNI n'est pas une bascule à chaud. Passer de Cilium à Calico veut dire un cluster
reconstruit (./kubeadm/cluster-reset.sh, ou vagrant destroy), pas un helm uninstall.
⚠️ Tous les Services LoadBalancer repassent en EXTERNAL-IP <pending> et toutes les UI
deviennent injoignables, le Gateway compris. Les CRD partent avec le chart, donc tout
IPAddressPool résiduel disparaît avec elles.
🚪envoy-gateway/ — the cluster's HTTP(S) entry point
One VIP, two listeners, N applications.Envoy Gateway
(an implementation of the Gateway API) deploys an Envoy whose LoadBalancer Service picks
up the 192.168.56.200 VIP from the Cilium pool. The main-gatewayGateway exposes :80and:443 on it (wildcard TLS *.lab.example.io), and every component plugs in with
an HTTPRoute.
🌐 lab.example.io is the repo's NEUTRAL domain (it is public): platform-up.sh
replaces it with LAB_DOMAIN (lab.env): hostname of the https listener and name of the
TLS Secret. See ../README.md.
Share the exposure: one IP, one certificate, one configuration point for every lab UI
(Argo CD, Vault, Longhorn, Grafana, Policy Reporter, WordPress…).
Do the Gateway API for real: GatewayClass → Gateway → HTTPRoute, with
cross-namespace attachment, filters and routing by path or by hostname.
Terminate TLS at the cluster edge: the backends speak plain HTTP.
⚠️ Do not confuse this with the Envoy embedded in Cilium (disabled here:
envoy.enabled=false, see ../cilium/README.md). Here Envoy is driven
by the Envoy Gateway controller, a component in its own right.
it is what gives the .200 IP to the Gateway's Service
kubectl get ciliumloadbalancerippool
A wildcard TLS Secret, from either TLS mode
fills the wildcard-lab-example-io-tls Secret of the :443 listener
kubectl -n envoy-gateway-system get secret wildcard-…-tls
Name resolution for *.lab.example.io → 192.168.56.200
routes match by hostname
dig +short argo.lab.example.io
The TLS Secret comes from ../self-signed/ when
SELF_SIGNED=true (the default: a local CA, no domain and no token needed) or from
../cert-manager/ + a Cloudflare token when SELF_SIGNED=false.
The Gateway is the same either way: only the annotation differs. Likewise, resolution can be
an /etc/hosts line (self-signed) or a public DNS-only record (ACME).
HTTP (:80) works with neither TLS mode nor DNS: curl http://192.168.56.200/....
The controller is installed by the platform, step [2/4]:
./platform-up.sh<distro>
OCI chart oci://docker.io/envoyproxy/gateway-helm1.8.3, pinned in ../platform-up.sh
(ENVOY_GW_VERSION, overridable). The chart also installs the standard Gateway API CRDs,
which cert-manager depends on (config.enableGatewayAPI=true). The script then applies
Envoy-Proxy.yml and waits for the LoadBalancer IP (30 × 5 s).
Manual equivalent (if you only want to install this component)
No distribution-specific behaviour for this component: same charts, same manifests, same
values on both labs. The distribution argument only drives two things here: the default
domain (talos.lab.example.io / kubeadm.lab.example.io) and where the lab's lab.env
/ kubeconfig live (../Vagrant-Talos/ or ../Vagrant-KubeADM/).
ℹ️ One detail depends on the CNI (not on the distribution): loadBalancerClass: io.cilium/l2-announcer in Envoy-Proxy.yml. platform-up.sh STRIPS it when
CNI != cilium, otherwise the announcer that serves this Service in that case,
../metallb/, would ignore it and the IP would stay <pending>
with a perfectly valid pool next to it.
The commands below are exactly what the all-in-one script does, in order.
Set up your shell first (once per session):
exportKUBECONFIG=../Vagrant-Talos/kubeconfig# or ../Vagrant-KubeADM/kubeconfigexportLAB_DOMAIN=talos.lab.example.io# or your own (see the lab's lab.env)
1. Install the controller (OCI chart — no helm repo add)#
helmupgrade--installegoci://docker.io/envoyproxy/gateway-helm\--version1.8.3-nenvoy-gateway-system--create-namespace
kubectl-nenvoy-gateway-systemrolloutstatusdeploy/envoy-gateway--timeout=180s
kubectlgetcrd|grepgateway.networking.k8s.io# Gateway API CRDs ship with the chart
2. Apply the GatewayClass + main-gateway (listeners :80 and :443)#
The manifest carries the NEUTRAL domain lab.example.io and the
wildcard-lab-example-io-tls Secret name: both get substituted.
DASH="${LAB_DOMAIN//./-}"
sed-e"s/lab\.example\.io/${LAB_DOMAIN}/g"-e"s/lab-example-io/${DASH}/g"\envoy-gateway/Envoy-Proxy.yml\|sed'/loadBalancerClass:/d'\ # ← ONLY when the CNI is not Cilium|kubectlapply-f-
3. Wait for the LoadBalancer IP (Cilium L2 announcement)#
configures the Envoy infrastructure: type: LoadBalancer Service with loadBalancerClass: io.cilium/l2-announcer → the IP comes from the Cilium pool; and envoyDeployment.replicas: 2 for the data plane
GatewayClassenvoy
class managed by gateway.envoyproxy.io/gatewayclass-controller, pointing at the EnvoyProxy above
Gatewaymain-gateway (ns envoy-gateway-system)
the entry point: http:80 and https:443 listeners, allowedRoutes.namespaces.from: All
It is the EnvoyProxy's Service that triggers the Cilium L2 announcement → hence the .200 VIP.
Do not confuse the two Deployments in envoy-gateway-system:
Deployment
Role
Losing it
envoy-gateway
the controller: watches Gateways/HTTPRoutes and configures the proxies
no traffic impact, config just stops being reconciled
envoy-…-main-gateway-…
the data plane: the pods that actually carry the traffic
every UI in the lab is down
That second one is the single path for every UI (one LoadBalancer IP, .200), so
Envoy-Proxy.yml pins it at replicas: 2: the Service keeps a ready endpoint while a pod
is being rescheduled, a node reboots, or ../chaos-kube/ draws it
in its hourly lottery. Both pods share the same IP, so nothing changes in DNS or in any
HTTPRoute.
ℹ️ Nothing pins the two pods to different nodes: the scheduler spreads them on its own,
but that is not a guarantee. For a hard guarantee, add a podAntiAffinity under
envoyDeployment.pod.affinity: with a required rule, keep it under the number of workers
or the surplus pods stay Pending.
The two listeners (already wired, nothing to add)#
The cert-manager.io/cluster-issuer annotation on the Gateway is enough for cert-manager to
create the Certificate, solve the DNS-01 challenge and fill the Secret. The versioned
manifest carries letsencrypt-staging; platform-up.sh rewrites it from LAB_ACME_ISSUER
(staging by default, prod on demand, minding the 5 certificates/week production cap). The
mechanism is detailed in ../cert-manager/README.md.
ℹ️ With the default SELF_SIGNED=true, platform-up.sh strips that annotation entirely
and fills the very same Secret with an openssl-signed wildcard
(../self-signed/). The certificateRefs above do not change,
which is exactly why no addon has to care which TLS mode the lab runs.
This is the only work left for a new component: an HTTPRoute targeting the TLS listener.
spec:parentRefs:-name:main-gatewaynamespace:envoy-gateway-systemsectionName:https# targets the :443 listener (without it, BOTH listeners)hostnames:-my-app.lab.example.io# must match the wildcard *.lab.example.iorules:-backendRefs:-name:my-appport:80
The route can live in its own namespace (the Gateway accepts from: All); the backend, on
the other hand, must be in the same namespace as the route; otherwise you need a
ReferenceGrant.
kubectl-nenvoy-gateway-systemgetsvc# EXTERNAL-IP = 192.168.56.200 (otherwise → ../cilium/)
kubectlgetgateway-nenvoy-gateway-system# main-gateway, PROGRAMMED=True, ADDRESS=.200
kubectlgethttproute-A# every route in the lab# listeners + number of routes attached to each:
kubectl-nenvoy-gateway-systemgetgatewaymain-gateway\-ojsonpath='{range .status.listeners[*]}{.name}{" attached="}{.attachedRoutes}{"\n"}{end}'# the cert served for a hostname of the wildcard:echo|openssls_client-connect192.168.56.200:443-servernamedemo.lab.example.io2>/dev/null\|opensslx509-noout-subject-issuer
Two apps and their HTTPRoutes, using path-based routing:
App
Route
Backend
hello-nginx (nginxdemos/nginx-hello:plain-text)
/hello → rewritten to /
hello-nginx:80
echo-app (ealen/echo-server:latest)
/echo → rewritten to /
echo-app:80
kubectlapply-fenvoy-gateway/GW-Example.yml# namespace `default`
curl-sShttp://192.168.56.200/hello
curl-sShttp://192.168.56.200/echo
kubectldelete-fenvoy-gateway/GW-Example.yml# remove after the demo
ℹ️ These routes have neither hostnames nor sectionName: they therefore attach to both
listeners. Verified consequence: /hello also answers over HTTPS, under any subdomain of the
wildcard (https://foo.lab.example.io/hello → 200). On the other hand
https://hello.lab.example.io/ returns 404: the match is on the path, not on the
hostname.
Empty ADDRESS / <pending> → the problem is on the ../cilium/
side (missing pool or inactive L2 announcement), not here.
404 on a route → path/hostname matching nothing, sectionName missing or wrong, or a
hostname outside the wildcard (app.lab.example.io ✔, app.lab.example.io ✘: the
wildcard covers one level only).
Not every UI exposed behind this Gateway has authentication. The Longhorn UI
(../longhorn/httproute.yaml) has none; neither does the Policy Reporter UI (nothing is
configured in ../kyverno/policy-reporter-values.yaml). Published on the VIP, they are
reachable by anyone who reaches .200, so by every authorized Tailscale peer. To protect
them: an Envoy Gateway SecurityPolicy (Basic Auth / OIDC) targeting the route. Vault and
Argo CD do have their own authentication.
GW-Example.yml violates the repo's own policies: ealen/echo-server:latest is rejected by
disallow-latest-tag (../kyverno/), and
nginxdemos/nginx-hello:plain-text is a floating tag (it passes the policy but pins no
version). Both apps would also trip the restricted PodSecurity profile
(allowPrivilegeEscalation, capabilities, runAsNonRoot, seccompProfile), but on kubeadm
the PodSecurity admission enforces nothing cluster-wide by default, so nothing complains
until you label the namespace or install Kyverno. Ideal demo material for "here is what a
policy catches".
The demo apps land in default (no namespace in the manifest): delete them after the demo
so they do not pollute the Kyverno/Trivy reports.
A competing Gateway overwrites this one: ../cert-manager/04-gateway-https-example.yaml
redefines main-gateway with the same name/namespace. Do not apply it (see its README).
🚪envoy-gateway/ — le point d'entrée HTTP(S) du cluster
Un seul VIP, deux écouteurs, N applications.Envoy Gateway
(implémentation de la Gateway API) déploie un Envoy dont le Service LoadBalancer récupère
le VIP 192.168.56.200 du pool Cilium. Le Gatewaymain-gateway y expose :80et:443
(TLS wildcard *.lab.example.io), et chaque addon s'y branche avec une HTTPRoute.
🌐 lab.example.io est le domaine NEUTRE du dépôt (public) : platform-up.sh le
remplace par LAB_DOMAIN (lab.env) : hostname de l'écouteur https et nom du Secret TLS.
Cf. ../LISEZ-MOI.md.
Mutualiser l'exposition : une IP, un certificat, un point de configuration pour toutes les
UI du lab (Argo CD, Vault, Longhorn, Grafana, Policy Reporter, WordPress…).
Faire la Gateway API en vrai : GatewayClass → Gateway → HTTPRoute, avec
rattachement inter-namespace, filtres et routage par chemin ou par hostname.
Terminer le TLS au bord du cluster : les backends parlent HTTP en clair.
⚠️ Ne pas confondre avec l'Envoy embarqué dans Cilium (désactivé ici :
envoy.enabled=false, cf. ../cilium/LISEZ-MOI.md). Ici Envoy est
piloté par le contrôleur Envoy Gateway, un composant à part entière.
c'est lui qui donne l'IP .200 au Service du Gateway
kubectl get ciliumloadbalancerippool
Un Secret TLS wildcard, venu de l'un ou l'autre mode TLS
remplit le Secret wildcard-lab-example-io-tls de l'écouteur :443
kubectl -n envoy-gateway-system get secret wildcard-…-tls
Résolution de *.lab.example.io → 192.168.56.200
les routes matchent par hostname
dig +short argo.lab.example.io
Le Secret TLS vient de ../self-signed/ quand
SELF_SIGNED=true (le défaut : une AC locale, sans domaine ni token) ou de
../cert-manager/ + un token Cloudflare quand
SELF_SIGNED=false. Le Gateway est le même dans les deux cas : seule l'annotation change. De
même, la résolution peut être une ligne /etc/hosts (auto-signé) ou un enregistrement public
DNS-only (ACME).
Le HTTP (:80) fonctionne sans aucun des deux modes TLS et sans DNS :
curl http://192.168.56.200/....
Le contrôleur est installé par la plateforme, étape [2/4] :
./platform-up.sh<distro>
Chart OCI oci://docker.io/envoyproxy/gateway-helm1.8.3, épinglé dans
../platform-up.sh (ENVOY_GW_VERSION, surchargeable). Le chart installe aussi les CRD
Gateway API standard, dont dépend cert-manager (config.enableGatewayAPI=true). Le script
applique ensuite Envoy-Proxy.yml, puis attend l'IP LoadBalancer (30 × 5 s).
Équivalent manuel (si tu veux ne poser que cette brique)
Aucune spécificité de distribution pour ce composant : mêmes charts, mêmes manifestes,
mêmes valeurs sur les deux labs. La distribution passée en argument ne sert ici qu'à deux
choses : le domaine par défaut (talos.lab.example.io / kubeadm.lab.example.io) et la
localisation du lab.env / kubeconfig du lab (../Vagrant-Talos/ ou
../Vagrant-KubeADM/).
ℹ️ Un seul détail dépend du CNI (pas de la distribution) : loadBalancerClass: io.cilium/l2-announcer dans Envoy-Proxy.yml. platform-up.sh la RETIRE quand
CNI != cilium, sinon l'annonceur qui sert ce Service dans ce cas,
../metallb/, l'ignorerait et l'IP resterait <pending> avec un
pool parfaitement valide à côté.
Les commandes ci-dessous sont exactement ce que fait le script tout-en-un, dans
l'ordre. Prépare d'abord l'environnement (une fois par session) :
exportKUBECONFIG=../Vagrant-Talos/kubeconfig# ou ../Vagrant-KubeADM/kubeconfigexportLAB_DOMAIN=talos.lab.example.io# ou ton domaine (cf. lab.env du lab)
1. Installer le contrôleur (chart OCI, pas de helm repo add)#
helmupgrade--installegoci://docker.io/envoyproxy/gateway-helm\--version1.8.3-nenvoy-gateway-system--create-namespace
kubectl-nenvoy-gateway-systemrolloutstatusdeploy/envoy-gateway--timeout=180s
kubectlgetcrd|grepgateway.networking.k8s.io# les CRD Gateway API arrivent avec le chart
2. Poser la GatewayClass + main-gateway (écouteurs :80 et :443)#
Le manifeste porte le domaine NEUTRE lab.example.io et le Secret
wildcard-lab-example-io-tls : les deux sont substitués.
DASH="${LAB_DOMAIN//./-}"
sed-e"s/lab\.example\.io/${LAB_DOMAIN}/g"-e"s/lab-example-io/${DASH}/g"\envoy-gateway/Envoy-Proxy.yml\|sed'/loadBalancerClass:/d'\ # ← à faire UNIQUEMENT si le CNI n'est pas Cilium|kubectlapply-f-
3. Attendre l'IP LoadBalancer (annonce L2 de Cilium)#
paramètre l'infra Envoy : Service type: LoadBalancer avec loadBalancerClass: io.cilium/l2-announcer → l'IP vient du pool Cilium ; et envoyDeployment.replicas: 2 pour le plan de données
GatewayClassenvoy
classe gérée par gateway.envoyproxy.io/gatewayclass-controller, pointant l'EnvoyProxy ci-dessus
Gatewaymain-gateway (ns envoy-gateway-system)
le point d'entrée : écouteurs http:80 et https:443, allowedRoutes.namespaces.from: All
C'est le Service de l'EnvoyProxy qui déclenche l'annonce L2 Cilium → d'où le VIP .200.
Ne pas confondre les deux Deployments de envoy-gateway-system :
Deployment
Rôle
Le perdre
envoy-gateway
le contrôleur : observe Gateways/HTTPRoutes et configure les proxys
aucun impact trafic, la config cesse juste d'être réconciliée
envoy-…-main-gateway-…
le plan de données : les pods qui portent réellement le trafic
toutes les UI du lab tombent
Ce second Deployment est le passage unique de toutes les UI (une seule IP LoadBalancer, .200),
d'où le replicas: 2 figé dans Envoy-Proxy.yml : le Service garde un endpoint prêt pendant
qu'un pod est reprogrammé, qu'un node redémarre, ou que ../chaos-kube/
le tire dans sa loterie horaire. Les deux pods partagent la même IP : rien à changer côté DNS ni
dans les HTTPRoute.
ℹ️ Rien n'épingle les deux pods sur des nodes différents : le scheduler les répartit de
lui-même, mais ce n'est pas une garantie. Pour une garantie dure, ajouter un podAntiAffinity
sous envoyDeployment.pod.affinity : avec une règle required, rester sous le nombre de
workers sinon les pods en trop restent Pending.
L'annotation cert-manager.io/cluster-issuer sur le Gateway suffit à ce que cert-manager crée
le Certificate, résolve le challenge DNS-01 et remplisse le Secret. Le manifeste versionné
porte letsencrypt-staging ; platform-up.sh la réécrit depuis LAB_ACME_ISSUER (staging
par défaut, prod sur demande, en surveillant le plafond de 5 certificats/semaine en
production). Le mécanisme est détaillé dans
../cert-manager/LISEZ-MOI.md.
ℹ️ Avec le défaut SELF_SIGNED=true, platform-up.sh retire purement et simplement cette
annotation et remplit exactement le même Secret avec un wildcard signé par openssl
(../self-signed/). Les certificateRefs ci-dessus ne
changent pas, et c'est précisément pour ça qu'aucun addon n'a à savoir dans quel mode TLS
tourne le lab.
C'est le seul travail restant pour un nouvel addon : une HTTPRoute qui cible l'écouteur TLS.
spec:parentRefs:-name:main-gatewaynamespace:envoy-gateway-systemsectionName:https# cible l'écouteur :443 (sans ça, les DEUX écouteurs)hostnames:-mon-app.lab.example.io# doit matcher le wildcard *.lab.example.iorules:-backendRefs:-name:mon-appport:80
La route peut vivre dans son namespace (le Gateway accepte from: All) ; le backend doit,
lui, être dans le même namespace que la route ; sinon il faut un ReferenceGrant.
kubectl-nenvoy-gateway-systemgetsvc# EXTERNAL-IP = 192.168.56.200 (sinon → ../cilium/)
kubectlgetgateway-nenvoy-gateway-system# main-gateway, PROGRAMMED=True, ADDRESS=.200
kubectlgethttproute-A# toutes les routes du lab# écouteurs + nombre de routes attachées à chacun :
kubectl-nenvoy-gateway-systemgetgatewaymain-gateway\-ojsonpath='{range .status.listeners[*]}{.name}{" attached="}{.attachedRoutes}{"\n"}{end}'# le cert servi pour un hostname du wildcard :echo|openssls_client-connect192.168.56.200:443-servernamedemo.lab.example.io2>/dev/null\|opensslx509-noout-subject-issuer
Deux apps + leurs HTTPRoute, en routage par chemin :
App
Route
Backend
hello-nginx (nginxdemos/nginx-hello:plain-text)
/hello → réécrit /
hello-nginx:80
echo-app (ealen/echo-server:latest)
/echo → réécrit /
echo-app:80
kubectlapply-fenvoy-gateway/GW-Example.yml# namespace `default`
curl-sShttp://192.168.56.200/hello
curl-sShttp://192.168.56.200/echo
kubectldelete-fenvoy-gateway/GW-Example.yml# à retirer après la démo
ℹ️ Ces routes n'ont ni hostnames ni sectionName : elles s'attachent donc aux deux
écouteurs. Conséquence vérifiée : /hello répond aussi en HTTPS, sous n'importe quel
sous-domaine du wildcard (https://foo.lab.example.io/hello → 200). En revanche
https://hello.lab.example.io/ renvoie 404 : le match porte sur le chemin, pas sur
le nom d'hôte.
ADDRESS vide / <pending> → le problème est côté ../cilium/
(pool absent ou annonce L2 inactive), pas ici.
404 sur une route → chemin/hostname qui ne matche rien, sectionName absent ou faux, ou
hostname hors du wildcard (app.lab.example.io ✔, app.lab.example.io ✘ : le wildcard ne
couvre qu'un niveau).
Les UI exposées derrière ce Gateway n'ont pas toutes d'authentification. L'UI Longhorn
(../longhorn/httproute.yaml) n'en a aucune ; l'UI Policy Reporter non plus (rien n'est
configuré dans ../kyverno/policy-reporter-values.yaml). Publiées sur le VIP, elles sont
accessibles à quiconque atteint .200, donc à tout peer Tailscale autorisé. Pour les
protéger : SecurityPolicy Envoy Gateway (Basic Auth / OIDC) ciblant la route. Vault et
Argo CD, eux, ont leur propre authentification.
GW-Example.yml viole les policies du dépôt lui-même : ealen/echo-server:latest est
refusé par disallow-latest-tag (../kyverno/), et
nginxdemos/nginx-hello:plain-text est un tag flottant (il passe la policy mais ne fixe
aucune version). Les deux apps violeraient aussi le profil PodSecurity restricted
(allowPrivilegeEscalation, capabilities, runAsNonRoot, seccompProfile), mais sur
kubeadm l'admission PodSecurity n'applique rien au niveau cluster par défaut : personne ne
proteste tant qu'on n'a pas étiqueté le namespace ou installé Kyverno. Support de démo idéal
pour « voici ce qu'une policy attrape ».
Les apps de démo atterrissent dans default (aucun namespace dans le manifeste) : à
supprimer après la démo pour ne pas polluer les rapports Kyverno/Trivy.
Un Gateway concurrent écrase celui-ci : ../cert-manager/04-gateway-https-example.yaml
redéfinit main-gateway avec les mêmes name/namespace. Ne pas l'appliquer (cf. son README).
🔏self-signed/ — wildcard TLS without cert-manager (openssl)
HTTPS on every lab UI with no domain, no Cloudflare token and no Internet.
A local CA generated once on the host signs a *.<LAB_DOMAIN> wildcard, which lands in
exactly the Secret the Envoy :443 listener already expects. cert-manager is not
installed at all.
This is the default TLS mode of the lab (SELF_SIGNED=true in lab.env). It exists
because the other path has real prerequisites: the ACME route
(../cert-manager/) needs a domain you actually own, a
Cloudflare token, and it spends Let's Encrypt quota on every rebuild. For a
throwaway lab on a host-only network, that is a lot of setup for a certificate nobody
outside your machine will ever see.
The trade-off is the only one that matters here: the certificate is not publicly
trusted. Browsers warn until you import the CA once; see 🌐 Access.
A single difference, cosmetic but useful when both labs run side by side: the CA subject
and the suggested file name for the trust store import.
Talos
kubeadm
CA subject (CA_ORG)
O=Vagrant-Talos lab
O=Vagrant-KubeADM lab
Suggested file (CA_FILE_NAME)
vagrant-talos-lab.crt
vagrant-kubeadm-lab.crt
Default wildcard domain
*.talos.lab.example.io
*.kubeadm.lab.example.io
The CA and its key live in the lab's_out/self-signed/ (gitignored): they survive a
vagrant destroy, so the browser security exception does not have to be re-accepted on every
rebuild.
The commands below are exactly what the all-in-one script does, in order.
Set up your shell first (once per session):
exportKUBECONFIG=../Vagrant-Talos/kubeconfig# or ../Vagrant-KubeADM/kubeconfigexportLAB_DOMAIN=talos.lab.example.io# or your own (see the lab's lab.env)
1. Prepare the output directory (inside the lab repository)#
LAB=../Vagrant-Talos# or ../Vagrant-KubeADM
mkdir-p"$LAB/_out/self-signed"&&chmod700"$LAB/_out/self-signed"cd"$LAB/_out/self-signed"
2. The local CA — generated ONCE, reused afterwards (10 years)#
The name must be wildcard-<domain-with-dashes>-tls: that is what the https listener
references. cert-manager would fill the very same Secret, and the Gateway never knows which mode
you picked.
cd-# back into k8s-playground
kubectlcreatenamespaceenvoy-gateway-system--dry-run=client-oyaml|kubectlapply-f-
kubectl-nenvoy-gateway-systemcreatesecrettls"wildcard-${LAB_DOMAIN//./-}-tls"\--cert="$LAB/_out/self-signed/tls.crt"--key="$LAB/_out/self-signed/tls.key"\--dry-run=client-oyaml|kubectlapply-f-
_out/self-signed/ca.key + ca.crt local CA, 10 years, generated ONCE and reused
│ signs
▼
_out/self-signed/tls.key + tls.crt leaf, 825 days
│ SAN: DNS:*.<LAB_DOMAIN>, DNS:<LAB_DOMAIN> · extendedKeyUsage: serverAuth
▼
Secret wildcard-<LAB_DOMAIN with dashes>-tls (ns envoy-gateway-system, type kubernetes.io/tls)
│ tls.crt = leaf + CA (full chain)
▼
served by Envoy on :443 — the same Secret name cert-manager would have filled
Because the Secret name is identical on both paths, the Gateway manifest does not
change between modes: platform-up.sh only strips the
cert-manager.io/cluster-issuer annotation from main-gateway when SELF_SIGNED=true,
so nothing ever tries to take the Secret over.
_out/ is gitignored, so the CA private key can never end up in a commit. It also sits
on the host, not in etcd, so it survives vagrant destroy: you import the CA into
your trust store once and every future rebuild of the lab is trusted immediately. That is
the opposite of the ACME path, where each rebuild burns a fresh certificate.
_out/self-signed/tls.crt is missing — e.g. after you wiped _out/;
it expires in less than 30 days (RENEW_DAYS);
LAB_DOMAIN changed, so the SAN no longer covers the lab.
Overridable knobs: CA_DAYS (3650), CERT_DAYS (825), RENEW_DAYS (30). 825 days is the
ceiling browsers accept for a server certificate; do not raise CERT_DAYS above it.
kubectl-nenvoy-gateway-systemgetsecretwildcard-<domain-in-dashes>-tls# type kubernetes.io/tls
kubectl-nenvoy-gateway-systemgetgatewaymain-gateway# PROGRAMMED=True
opensslx509-in_out/self-signed/tls.crt-noout-subject-issuer-dates-extsubjectAltName
# Which certificate does Envoy actually serve?echo|openssls_client-connect192.168.56.200:443-servernamedemo.<LAB_DOMAIN>2>/dev/null\|opensslx509-noout-subject-issuer
# expected: subject=CN=*.<LAB_DOMAIN>, issuer=CN=Vagrant-KubeADM self-signed CA# End-to-end, validating against the local CA (needs a hostname carrying an HTTPRoute):
curl-sS-o/dev/null-w'%{http_code} verify=%{ssl_verify_result}\n'\--cacert_out/self-signed/ca.crt\--resolveargo.<LAB_DOMAIN>:443:192.168.56.200https://argo.<LAB_DOMAIN>/
# expected: 200 verify=0
192.168.56.200 is the Gateway's EXTERNAL-IP (LB_POOL_START). If you do own a DNS
zone, a wildcard A record is nicer; see
../README.md.
2. Trust the CA, once, and it holds across every rebuild:
# Linux (Debian/Ubuntu), system store
sudocp_out/self-signed/ca.crt/usr/local/share/ca-certificates/vagrant-kubeadm-lab.crt
sudoupdate-ca-certificates
# macOS
sudosecurityadd-trusted-cert-d-rtrustRoot\-k/Library/Keychains/System.keychain_out/self-signed/ca.crt
Firefox has its own store and ignores the system one: Settings → Privacy & Security →
Certificates → View Certificates → Authorities → Import → _out/self-signed/ca.crt.
Skipping this step is fine too; you just click through the browser warning on each UI.
Switching false → true on a live cluster leaves cert-manager behind, and its
Certificate object keeps reconciling the Secret. Re-running platform-up.sh removes
the Gateway annotation, which makes cert-manager drop the Certificate, but if the
object survives, delete it explicitly, otherwise it overwrites the self-signed Secret:
kubectl -n envoy-gateway-system delete certificate <wildcard>-tls.
Switching true → false does the reverse: delete the self-signed Secret so
cert-manager issues a fresh one
(kubectl -n envoy-gateway-system delete secret <wildcard>-tls).
Deleting _out/ throws the CA away. A new CA means re-importing it into every trust
store. _out/ is gitignored and never backed up: if you care about the CA, copy
ca.crtandca.key somewhere safe before a cleanup.
The CA private key is a real secret. Anyone holding _out/self-signed/ca.key can
forge a certificate for any domain that your trust store will accept, not just the
lab's. That is the price of importing a CA rather than a single certificate, so keep the
file at 600 (the script sets it) and do not copy it around.
No in-cluster renewal. Nothing watches the expiry: after 825 days, or after 795 with
the 30-day margin, you re-run selfsigned-up.sh. For a lab that is rebuilt regularly,
this never comes up.
A single wildcard level: *.<LAB_DOMAIN> covers argo.<LAB_DOMAIN>, not
a.b.<LAB_DOMAIN>. Same constraint as the ACME path.
curl without --cacert fails with unable to get local issuer certificate. That
is the certificate doing its job, not a bug: pass --cacert _out/self-signed/ca.crt,
or import the CA.
🔏self-signed/ — wildcard TLS sans cert-manager (openssl)
Du HTTPS sur toutes les UI du lab sans domaine, sans token Cloudflare et sans Internet.
Une AC locale, générée une fois sur l'hôte, signe un wildcard *.<LAB_DOMAIN> qui atterrit
exactement dans le Secret qu'attend déjà l'écouteur :443 d'Envoy. cert-manager n'est pas
installé du tout.
C'est le mode TLS par défaut du lab (SELF_SIGNED=true dans lab.env). Il existe parce
que l'autre chemin a de vrais prérequis : la voie ACME
(../cert-manager/) exige un domaine qui t'appartient
vraiment, un token Cloudflare, et elle consomme du quota Let's Encrypt à chaque
rebuild. Pour un lab jetable sur un réseau host-only, ça fait beaucoup de mise en place pour
un certificat que personne, hors de ta machine, ne verra jamais.
Le compromis est le seul qui compte ici : le certificat n'est pas publiquement trusté.
Le navigateur avertit tant que tu n'as pas importé l'AC une fois ; voir 🌐 Accès.
Aucune zone DNS, aucun token d'API, aucun port entrant. Le domaine n'a pas besoin
d'exister publiquement : il doit seulement résoudre sur la machine depuis laquelle tu
navigues.
Une seule différence, cosmétique mais utile quand les deux labs tournent côte à côte : le
sujet de l'AC et le nom de fichier suggéré à l'import dans le trousseau.
Talos
kubeadm
Sujet de l'AC (CA_ORG)
O=Vagrant-Talos lab
O=Vagrant-KubeADM lab
Fichier suggéré (CA_FILE_NAME)
vagrant-talos-lab.crt
vagrant-kubeadm-lab.crt
Domaine par défaut du wildcard
*.talos.lab.example.io
*.kubeadm.lab.example.io
L'AC et la clé vivent dans le _out/self-signed/du lab (gitignoré) : elles survivent à un
vagrant destroy, donc l'exception de sécurité du navigateur ne se rejoue pas à chaque rebuild.
Les commandes ci-dessous sont exactement ce que fait le script tout-en-un, dans
l'ordre. Prépare d'abord l'environnement (une fois par session) :
exportKUBECONFIG=../Vagrant-Talos/kubeconfig# ou ../Vagrant-KubeADM/kubeconfigexportLAB_DOMAIN=talos.lab.example.io# ou ton domaine (cf. lab.env du lab)
1. Préparer le dossier de sortie (dans le dépôt du lab)#
LAB=../Vagrant-Talos# ou ../Vagrant-KubeADM
mkdir-p"$LAB/_out/self-signed"&&chmod700"$LAB/_out/self-signed"cd"$LAB/_out/self-signed"
2. L'AC locale — générée UNE FOIS, réutilisée ensuite (10 ans)#
Le nom doit être wildcard-<domaine-en-tirets>-tls : c'est celui que référence l'écouteur
https. cert-manager remplirait le même Secret, et la Gateway n'a rien à savoir du mode choisi.
cd-# retour dans k8s-playground
kubectlcreatenamespaceenvoy-gateway-system--dry-run=client-oyaml|kubectlapply-f-
kubectl-nenvoy-gateway-systemcreatesecrettls"wildcard-${LAB_DOMAIN//./-}-tls"\--cert="$LAB/_out/self-signed/tls.crt"--key="$LAB/_out/self-signed/tls.key"\--dry-run=client-oyaml|kubectlapply-f-
_out/self-signed/ca.key + ca.crt AC locale, 10 ans, générée UNE FOIS puis réutilisée
│ signe
▼
_out/self-signed/tls.key + tls.crt feuille, 825 jours
│ SAN : DNS:*.<LAB_DOMAIN>, DNS:<LAB_DOMAIN> · extendedKeyUsage : serverAuth
▼
Secret wildcard-<LAB_DOMAIN en tirets>-tls (ns envoy-gateway-system, type kubernetes.io/tls)
│ tls.crt = feuille + AC (chaîne complète)
▼
servi par Envoy sur :443 — le même nom de Secret que cert-manager aurait rempli
Comme le nom du Secret est identique sur les deux chemins, le manifeste de la Gateway ne
change pas d'un mode à l'autre : platform-up.sh se contente de retirer l'annotation
cert-manager.io/cluster-issuer de main-gateway quand SELF_SIGNED=true, pour que rien ne
vienne jamais reprendre la main sur le Secret.
_out/ est gitignoré : la clé privée de l'AC ne peut pas se retrouver dans un commit.
Il vit aussi sur l'hôte, pas dans etcd, donc il survit à vagrant destroy : tu
importes l'AC une fois dans ton magasin de confiance et tous les rebuilds suivants sont
trustés d'emblée. C'est l'inverse du chemin ACME, où chaque rebuild brûle un certificat neuf.
Le script refabrique la feuille (jamais l'AC) quand :
_out/self-signed/tls.crt manque — p.ex. après avoir effacé _out/ ;
il expire dans moins de 30 jours (RENEW_DAYS) ;
LAB_DOMAIN a changé, donc le SAN ne couvre plus le lab.
Réglages surchargeables : CA_DAYS (3650), CERT_DAYS (825), RENEW_DAYS (30). 825 jours
est le plafond qu'acceptent les navigateurs pour un certificat serveur ; ne monte pas
CERT_DAYS au-delà.
kubectl-nenvoy-gateway-systemgetsecretwildcard-<domaine-en-tirets>-tls# type kubernetes.io/tls
kubectl-nenvoy-gateway-systemgetgatewaymain-gateway# PROGRAMMED=True
opensslx509-in_out/self-signed/tls.crt-noout-subject-issuer-dates-extsubjectAltName
# Quel certificat Envoy sert-il vraiment ?echo|openssls_client-connect192.168.56.200:443-servernamedemo.<LAB_DOMAIN>2>/dev/null\|opensslx509-noout-subject-issuer
# attendu : subject=CN=*.<LAB_DOMAIN>, issuer=CN=Vagrant-KubeADM self-signed CA# De bout en bout, en validant contre l'AC locale (il faut un hostname portant une HTTPRoute) :
curl-sS-o/dev/null-w'%{http_code} verify=%{ssl_verify_result}\n'\--cacert_out/self-signed/ca.crt\--resolveargo.<LAB_DOMAIN>:443:192.168.56.200https://argo.<LAB_DOMAIN>/
# attendu : 200 verify=0
192.168.56.200 est l'EXTERNAL-IP de la Gateway (LB_POOL_START). Si tu possèdes une zone
DNS, un enregistrement A wildcard est plus confortable ; voir
../LISEZ-MOI.md.
2. Faire confiance à l'AC, une fois, et ça tient pour tous les rebuilds :
# Linux (Debian/Ubuntu), magasin système
sudocp_out/self-signed/ca.crt/usr/local/share/ca-certificates/vagrant-kubeadm-lab.crt
sudoupdate-ca-certificates
# macOS
sudosecurityadd-trusted-cert-d-rtrustRoot\-k/Library/Keychains/System.keychain_out/self-signed/ca.crt
Firefox a son propre magasin et ignore celui du système : Paramètres → Vie privée et
sécurité → Certificats → Afficher les certificats → Autorités → Importer →
_out/self-signed/ca.crt.
Sauter cette étape est acceptable aussi : tu cliques simplement à travers l'avertissement du
navigateur sur chaque UI.
Passer de false à true sur un cluster vivant laisse cert-manager derrière, et son
objet Certificate continue de réconcilier le Secret. Relancer platform-up.sh retire
l'annotation de la Gateway, ce qui fait abandonner le Certificate à cert-manager, mais
si l'objet survit, supprime-le explicitement, sinon il écrase le Secret auto-signé :
kubectl -n envoy-gateway-system delete certificate <wildcard>-tls.
Passer de true à false demande l'inverse : supprimer le Secret auto-signé pour que
cert-manager en émette un neuf
(kubectl -n envoy-gateway-system delete secret <wildcard>-tls).
Effacer _out/ jette l'AC. Une nouvelle AC, c'est un ré-import dans chaque magasin de
confiance. _out/ est gitignoré et n'est jamais sauvegardé : si l'AC compte pour toi, copie
ca.crtetca.key ailleurs avant un nettoyage.
La clé privée de l'AC est un vrai secret. Qui détient _out/self-signed/ca.key peut
forger un certificat pour n'importe quel domaine que ton magasin de confiance
acceptera, pas seulement ceux du lab. C'est le prix d'importer une AC plutôt qu'un
certificat isolé : garde le fichier en 600 (le script s'en charge) et ne le promène pas.
Aucun renouvellement dans le cluster. Rien ne surveille l'expiration : au bout de
825 jours (795 avec la marge de 30 jours), tu relances selfsigned-up.sh. Pour un lab
reconstruit régulièrement, le cas ne se présente jamais.
Un seul niveau de wildcard : *.<LAB_DOMAIN> couvre argo.<LAB_DOMAIN>, pas
a.b.<LAB_DOMAIN>. Même contrainte que sur le chemin ACME.
curl sans --cacert échoue avec unable to get local issuer certificate. C'est le
certificat qui fait son travail, pas un bug : passe --cacert _out/self-signed/ca.crt, ou
importe l'AC.
A public *.lab.example.io certificate, issued and renewed with zero effort.
cert-manager watches main-gateway, reads an annotation on it, creates the Certificate,
proves to Let's Encrypt that you control the domain through a DNS TXT record at Cloudflare,
then fills the Secret that Envoy's :443 listener serves. No inbound port, no hand-written
Certificate.
⚠️ This is the opt-in TLS mode.platform-up.sh installs cert-manager only when
SELF_SIGNED=false in lab.env. The default (SELF_SIGNED=true) signs the same wildcard
locally with openssl and installs none of this — see
../self-signed/, which also compares the two modes side by
side. Everything below assumes you set SELF_SIGNED=false.
Every lab UI (argo., vault., longhorn., grafana., kyverno., wordpress.…) is served
over HTTPS trusted by browsers behind a private IP, with no security exception to click and
no home-made CA to distribute.
DNS-01: Let's Encrypt validates the domain through a
_acme-challenge.lab.example.io TXT record, written by cert-manager with the Cloudflare
token. No inbound connection required → it works behind a host-only network + Tailscale,
where HTTP-01 would fail.
Wildcard: only DNS-01 can issue *.lab.example.io (HTTP-01 cannot).
Let's Encrypt rather than Cloudflare Origin CA: since DNS is in DNS-only mode (grey
cloud), TLS is terminated by Envoy, not by the Cloudflare edge. So it is the browser that
validates Envoy's certificate → it must be publicly trusted. An Origin CA cert (trusted
only by the Cloudflare edge) would be rejected.
cert-manager discovers them at startup (installed by the Envoy Gateway chart)
kubectl get crd gateways.gateway.networking.k8s.io
example.io zone at Cloudflare, *.lab.example.io → 192.168.56.200 in DNS-only mode
the DNS-01 solver writes into that zone
dig +short TXT _acme-challenge.lab.example.io
Cloudflare API token (Zone/DNS/Edit + Zone/Zone/Read, scoped to example.io)
lets cert-manager write the TXT record
kubectl -n cert-manager get secret cloudflare-api-token
The token goes into lab.env (CLOUDFLARE_API_TOKEN=…, a gitignored file): that is where
platform-up.sh picks it up to create the Secret.
🌐 Neutral domain by default (the repo is public): the manifests carry
lab.example.io and the example.io zone. platform-up.sh substitutes on the fly, from
lab.env: LAB_DOMAIN (wildcard hostname), LAB_DNS_ZONE (the solver's dnsZones — default:
the last 2 labels of LAB_DOMAIN) and LAB_ACME_EMAIL (default admin@<zone>). The TLS
Certificate/Secret follows the domain: wildcard-<LAB_DOMAIN with dashes>-tls. Without the
substitution the solver would never match your zone and the certificate would stay pending. See
../README.md.
cert-manager is installed by the platform, step [4/4], provided SELF_SIGNED=false:
echo'SELF_SIGNED=false'>>lab.env# otherwise step [4/4] goes the self-signed route
./platform-up.sh<distro>
Chart jetstack/cert-managerv1.21.1, pinned in ../platform-up.sh
(CERT_MANAGER_VERSION). The script:
installs the chart with crds.enabled=true and config.enableGatewayAPI=true (Gateway API
integration, no longer behind a feature flag since cert-manager 1.15);
creates the cloudflare-api-token Secret from lab.env (it warns and continues if the token
is empty — the certificate then stays pending);
applies 02-clusterissuer-staging.yaml and 03-clusterissuer-prod.yaml;
waits for Ready=True on the wildcard-lab-example-io-tlsCertificate — a name
derived from LAB_DOMAIN (~1-2 min, 24 × 10 s).
Manual equivalent (installing only this component)
helmrepoaddjetstackhttps://charts.jetstack.io&&helmrepoupdate
# --version: keep the one from platform-up.sh (CERT_MANAGER_VERSION)
helmupgrade--installcert-managerjetstack/cert-manager\--namespacecert-manager--create-namespace\--versionv1.21.1\--setcrds.enabled=true\--setconfig.apiVersion="controller.config.cert-manager.io/v1alpha1"\--setconfig.kind="ControllerConfiguration"\--setconfig.enableGatewayAPI=true
kubectl-ncert-managerrolloutstatusdeploy/cert-manager
kubectlcreatesecretgenericcloudflare-api-token-ncert-manager\--from-literal=api-token='<YOUR_TOKEN>'
kubectlapply-fcert-manager/02-clusterissuer-staging.yaml\-fcert-manager/03-clusterissuer-prod.yaml
No distribution-specific behaviour for this component: same charts, same manifests, same
values on both labs. The distribution argument only drives two things here: the default
domain (talos.lab.example.io / kubeadm.lab.example.io) and where the lab's lab.env
/ kubeconfig live (../Vagrant-Talos/ or ../Vagrant-KubeADM/).
ℹ️ This path only kicks in when SELF_SIGNED=false in the lab's lab.env. The default mode
is the local CA (../self-signed/) — and both fill the samewildcard-<domain-with-dashes>-tls Secret.
The commands below are exactly what the all-in-one script does, in order.
Set up your shell first (once per session):
exportKUBECONFIG=../Vagrant-Talos/kubeconfig# or ../Vagrant-KubeADM/kubeconfigexportLAB_DOMAIN=talos.lab.example.io# or your own (see the lab's lab.env)
config.enableGatewayAPI=true is the key setting: without it the
cert-manager.io/cluster-issuer annotation on main-gateway is ignored and no Certificate is
ever created.
The annotation on main-gateway is enough: cert-manager creates the Certificate and fills the
Secret.
kubectl-nenvoy-gateway-systemannotategatewaymain-gateway\cert-manager.io/cluster-issuer=letsencrypt-staging--overwrite
kubectl-nenvoy-gateway-systemgetcertificate-w# Ready=True within 1-2 min
Gateway main-gateway
├─ annotation cert-manager.io/cluster-issuer: letsencrypt-staging (LAB_ACME_ISSUER)
└─ listener https (hostname *.lab.example.io, certificateRefs: wildcard-lab-example-io-tls)
│
▼ cert-manager (config.enableGatewayAPI=true) watches the Gateway
Certificate wildcard-lab-example-io-tls (dnsNames derived from the listener `hostname`)
│ Order ──► Challenge dns-01 ──► TXT _acme-challenge.lab.example.io (Cloudflare API)
▼
Secret wildcard-lab-example-io-tls (ns envoy-gateway-system) ──► served by Envoy on :443
The Certificateand the Secret are born in the Gateway's namespace
(envoy-gateway-system), not in cert-manager. Renewal is automatic (at ~2/3 of the lifetime).
💡 Without the Gateway API integration, you get the same result by hand: write a
Certificate (dnsNames: ["*.lab.example.io"], issuerRef: letsencrypt-staging,
secretName: wildcard-lab-example-io-tls) and let the listener reference it. Same
outcome, you just create the object instead of cert-manager.
Let's Encrypt prodClusterIssuer (trusted cert) — the one referenced by the Gateway
04-gateway-https-example.yaml
historical illustration, do NOT apply (see ⚠️ Pitfalls)
⚠️ 04-gateway-https-example.yaml must not be applied any more. It contains a full
main-gatewayGateway (same name/namespace): a kubectl apply would replace the
Gateway in place. The merge is already done in ../envoy-gateway/Envoy-Proxy.yml
(https:443 listener + wildcard hostname + certificateRefs + cluster-issuer annotation),
and ../platform-up.sh only applies 02- and 03-. Keep the file as reading material: it
shows the "HTTPS + cert-manager" part of the Gateway in isolation.
kubectlgetclusterissuer# both issuers, READY=True
kubectl-nenvoy-gateway-systemgetcertificate# wildcard-…-tls, READY=True
kubectl-nenvoy-gateway-systemdescribecertificatewildcard-lab-example-io-tls
# events: Order → Challenge → issued
kubectlgetchallenges-A# empty once validated# Which certificate does Envoy serve? (no HTTPRoute needed: we only test TLS)echo|openssls_client-connect192.168.56.200:443-servernamedemo.lab.example.io2>/dev/null\|opensslx509-noout-subject-issuer-dates
# expected: subject=CN=*.lab.example.io, issuer=Let's Encrypt (and not "STAGING")
End-to-end HTTPS test: you need a hostname that carries an HTTPRoute (the demo routes in
../envoy-gateway/GW-Example.yml match by path, not by hostname). For example, once the Argo CD
component is installed:
Challenge stuck in pending → Cloudflare token (permissions or zone), or slow TXT
propagation. kubectl describe challenge <name> gives the exact error from the Cloudflare API.
cloudflare-api-token Secret missing → CLOUDFLARE_API_TOKEN was empty in lab.env when
platform-up.sh ran (the script reports it without failing). Create the Secret, then
kubectl -n envoy-gateway-system delete challenge --all to retry (the Orders/Challenges
live in the Certificate's namespace, so in the Gateway's).
Certificate never created despite the annotation → cert-manager is not running with
config.enableGatewayAPI=true, or it started before the Gateway API CRDs:
kubectl -n cert-manager rollout restart deploy/cert-manager.
Browser refusing the certificate → you are on letsencrypt-staging, which is the
default. Set LAB_ACME_ISSUER=prod in lab.env, re-run platform-up.sh, then delete the
Secret to force a reissue:
kubectl -n envoy-gateway-system delete secret wildcard-<domain-in-dashes>-tls.
429 rateLimited in prod → the 5 certificates per week per identifier set cap is
reached. Nothing to fix, nothing to retry: the message carries the retry after timestamp and
the window is 168 h sliding. Note that every vagrant destroy burns a slot, because the
wildcard only lives in etcd. Go back to LAB_ACME_ISSUER=staging while you wait, or back the
Secret up before destroying (see ../README.md).
SELF_SIGNED=true (the default) skips this whole page. If kubectl get clusterissuer
answers no resources found and the Gateway carries no cert-manager.io/cluster-issuer
annotation, nothing is broken — you are simply on the self-signed path. Set
SELF_SIGNED=false in lab.env and re-run platform-up.sh, then delete the leftover
self-signed Secret so cert-manager issues its own:
kubectl -n envoy-gateway-system delete secret <wildcard>-tls.
Do not apply 04-gateway-https-example.yaml (see the callout above).
A single wildcard level: *.lab.example.io covers argo.lab.example.io, not
a.b.lab.example.io. A route with a hostname that is not covered will not attach to the
listener.
The committed ACME e-mail is neutral (admin@example.io): platform-up.sh replaces it with
LAB_ACME_EMAIL (default admin@<LAB_DNS_ZONE>). With a direct kubectl apply -f, you apply
the example address — and Let's Encrypt rejects some reserved domains.
DNS-only is mandatory on the Cloudflare side: in "orange proxy" mode the edge would try to
reach 192.168.56.200 and access would break (the DNS-01 challenge itself would still work).
Un certificat *.lab.example.io public, émis et renouvelé sans rien faire. cert-manager
surveille main-gateway, y lit une annotation, crée le Certificate, prouve à Let's Encrypt
que tu contrôles le domaine via un TXT DNS chez Cloudflare, puis remplit le Secret que
l'écouteur :443 d'Envoy sert. Aucun port entrant, aucun Certificate écrit à la main.
⚠️ C'est le mode TLS optionnel.platform-up.sh n'installe cert-manager que si
SELF_SIGNED=false dans lab.env. Le défaut (SELF_SIGNED=true) signe le même wildcard
localement avec openssl et n'installe rien de tout ceci — voir
../self-signed/, qui compare aussi les deux modes point par
point. Tout ce qui suit suppose que tu as posé SELF_SIGNED=false.
Toutes les UI du lab (argo., vault., longhorn., grafana., kyverno., wordpress.…)
sont servies en HTTPS trusté par les navigateurs derrière une IP privée, sans exception de
sécurité à cliquer et sans CA maison à distribuer.
DNS-01 : Let's Encrypt vérifie le domaine via un TXT _acme-challenge.lab.example.io,
posé par cert-manager avec le token Cloudflare. Aucune connexion entrante requise → ça
marche derrière un réseau host-only + Tailscale, là où HTTP-01 échouerait.
Wildcard : seul DNS-01 sait émettre *.lab.example.io (HTTP-01 ne peut pas).
Let's Encrypt plutôt que Cloudflare Origin CA : comme le DNS est en DNS-only (nuage
gris), le TLS est terminé par Envoy, pas par l'edge Cloudflare. C'est donc le
navigateur qui valide le certificat d'Envoy → il doit être publiquement trusté. Un cert
Origin CA (trusté seulement par l'edge Cloudflare) serait rejeté.
cert-manager les découvre au démarrage (installées par le chart Envoy Gateway)
kubectl get crd gateways.gateway.networking.k8s.io
Zone example.io chez Cloudflare, *.lab.example.io → 192.168.56.200 en DNS-only
le solveur DNS-01 écrit dans cette zone
dig +short TXT _acme-challenge.lab.example.io
Token API Cloudflare (Zone/DNS/Edit + Zone/Zone/Read, scopé example.io)
permet à cert-manager de poser le TXT
kubectl -n cert-manager get secret cloudflare-api-token
Le token se met dans lab.env (CLOUDFLARE_API_TOKEN=…, fichier gitignoré) : c'est là que
platform-up.sh va le chercher pour créer le Secret.
🌐 Domaine neutre par défaut (le dépôt est public) : les manifestes portent
lab.example.io et la zone example.io. platform-up.sh substitue à la volée, depuis
lab.env : LAB_DOMAIN (hostname du wildcard), LAB_DNS_ZONE (le dnsZones du solveur —
défaut : les 2 derniers labels de LAB_DOMAIN) et LAB_ACME_EMAIL (défaut admin@<zone>).
Le Certificate/Secret TLS suit le domaine : wildcard-<LAB_DOMAIN avec des tirets>-tls.
Sans substitution, le solveur ne matcherait jamais ta zone et le certificat resterait en
attente. Cf. ../LISEZ-MOI.md.
cert-manager est installé par la plateforme, étape [4/4], à condition que
SELF_SIGNED=false :
echo'SELF_SIGNED=false'>>lab.env# sinon l'étape [4/4] part en auto-signé
./platform-up.sh<distro>
Chart jetstack/cert-managerv1.21.1, épinglé dans ../platform-up.sh
(CERT_MANAGER_VERSION). Le script :
installe le chart avec crds.enabled=true et config.enableGatewayAPI=true (intégration
Gateway API, non gatée par un feature-flag depuis cert-manager 1.15) ;
crée le Secret cloudflare-api-token depuis lab.env (il avertit et continue si le token
est vide — le certificat restera alors en attente) ;
applique 02-clusterissuer-staging.yaml et 03-clusterissuer-prod.yaml ;
attend Ready=True sur le Certificatewildcard-lab-example-io-tls — nom dérivé de
LAB_DOMAIN (~1-2 min, 24 × 10 s).
Équivalent manuel (poser uniquement cette brique)
helmrepoaddjetstackhttps://charts.jetstack.io&&helmrepoupdate
# --version : garder celle de platform-up.sh (CERT_MANAGER_VERSION)
helmupgrade--installcert-managerjetstack/cert-manager\--namespacecert-manager--create-namespace\--versionv1.21.1\--setcrds.enabled=true\--setconfig.apiVersion="controller.config.cert-manager.io/v1alpha1"\--setconfig.kind="ControllerConfiguration"\--setconfig.enableGatewayAPI=true
kubectl-ncert-managerrolloutstatusdeploy/cert-manager
kubectlcreatesecretgenericcloudflare-api-token-ncert-manager\--from-literal=api-token='<TON_TOKEN>'
kubectlapply-fcert-manager/02-clusterissuer-staging.yaml\-fcert-manager/03-clusterissuer-prod.yaml
Aucune spécificité de distribution pour ce composant : mêmes charts, mêmes manifestes,
mêmes valeurs sur les deux labs. La distribution passée en argument ne sert ici qu'à deux
choses : le domaine par défaut (talos.lab.example.io / kubeadm.lab.example.io) et la
localisation du lab.env / kubeconfig du lab (../Vagrant-Talos/ ou
../Vagrant-KubeADM/).
ℹ️ Ce chemin ne s'active que si SELF_SIGNED=false dans le lab.env du lab. Le mode par
défaut est l'AC locale (../self-signed/) — et les deux
remplissent le même Secret wildcard-<domaine-en-tirets>-tls.
Les commandes ci-dessous sont exactement ce que fait le script tout-en-un, dans
l'ordre. Prépare d'abord l'environnement (une fois par session) :
exportKUBECONFIG=../Vagrant-Talos/kubeconfig# ou ../Vagrant-KubeADM/kubeconfigexportLAB_DOMAIN=talos.lab.example.io# ou ton domaine (cf. lab.env du lab)
1. Le chart (avec les CRD et le support Gateway API)#
config.enableGatewayAPI=true est la clé : sans elle, l'annotation
cert-manager.io/cluster-issuer posée sur main-gateway est ignorée et aucun Certificate
n'est créé.
2. Le Secret du token Cloudflare (solveur DNS-01)#
L'annotation sur main-gateway suffit : cert-manager crée le Certificate et remplit le Secret.
kubectl-nenvoy-gateway-systemannotategatewaymain-gateway\cert-manager.io/cluster-issuer=letsencrypt-staging--overwrite
kubectl-nenvoy-gateway-systemgetcertificate-w# Ready=True en 1-2 min
6. Passer en production (cert trusté par le navigateur)#
kubectl-nenvoy-gateway-systemannotategatewaymain-gateway\cert-manager.io/cluster-issuer=letsencrypt-prod--overwrite
kubectl-nenvoy-gateway-systemdeletesecret"wildcard-${LAB_DOMAIN//./-}-tls"# force la ré-émission
Gateway main-gateway
├─ annotation cert-manager.io/cluster-issuer: letsencrypt-staging (LAB_ACME_ISSUER)
└─ listener https (hostname *.lab.example.io, certificateRefs: wildcard-lab-example-io-tls)
│
▼ cert-manager (config.enableGatewayAPI=true) observe le Gateway
Certificate wildcard-lab-example-io-tls (dnsNames déduits du `hostname` de l'écouteur)
│ Order ──► Challenge dns-01 ──► TXT _acme-challenge.lab.example.io (API Cloudflare)
▼
Secret wildcard-lab-example-io-tls (ns envoy-gateway-system) ──► servi par Envoy sur :443
Le Certificateet le Secret naissent dans le namespace du Gateway
(envoy-gateway-system), pas dans cert-manager. Le renouvellement est automatique (à ~2/3 de
la durée de vie).
💡 Sans l'intégration Gateway API, le résultat s'obtient à la main : écrire un
Certificate (dnsNames: ["*.lab.example.io"], issuerRef: letsencrypt-staging,
secretName: wildcard-lab-example-io-tls) et laisser l'écouteur le référencer. Même
résultat, c'est juste toi qui crées l'objet au lieu de cert-manager.
gabarit du Secret token — ne jamais committer le vrai (préférer lab.env + platform-up.sh)
02-clusterissuer-staging.yaml
ClusterIssuer Let's Encrypt staging (quotas larges, cert non trusté)
03-clusterissuer-prod.yaml
ClusterIssuer Let's Encrypt prod (cert trusté) — celui référencé par le Gateway
04-gateway-https-example.yaml
illustration historique, à NE PAS appliquer (cf. ⚠️ Pièges)
⚠️ 04-gateway-https-example.yaml ne doit plus être appliqué. Il contient un Gatewaymain-gateway complet (mêmes name/namespace) : le kubectl applyremplacerait le
Gateway en place. La fusion est déjà faite dans ../envoy-gateway/Envoy-Proxy.yml
(écouteur https:443 + hostname wildcard + certificateRefs + annotation
cluster-issuer), et ../platform-up.sh n'applique que 02- et 03-. Garde ce fichier
comme support de lecture : il montre, isolée, la partie « HTTPS + cert-manager » du Gateway.
kubectlgetclusterissuer# les 2 émetteurs, READY=True
kubectl-nenvoy-gateway-systemgetcertificate# wildcard-…-tls, READY=True
kubectl-nenvoy-gateway-systemdescribecertificatewildcard-lab-example-io-tls
# events : Order → Challenge → issued
kubectlgetchallenges-A# vide une fois validé# Quel certificat Envoy sert-il ? (aucune HTTPRoute nécessaire : on ne teste que le TLS)echo|openssls_client-connect192.168.56.200:443-servernamedemo.lab.example.io2>/dev/null\|opensslx509-noout-subject-issuer-dates
# attendu : subject=CN=*.lab.example.io, issuer=Let's Encrypt (et non "STAGING")
Test HTTPS de bout en bout : il faut un hostname qui porte une HTTPRoute (les routes de
démo de ../envoy-gateway/GW-Example.yml matchent par chemin, pas par hostname). Par exemple,
une fois l'addon Argo CD installé :
Challenge bloqué en pending → token Cloudflare (permissions ou zone), ou propagation
TXT lente. kubectl describe challenge <name> donne l'erreur exacte de l'API Cloudflare.
Secret cloudflare-api-token absent → CLOUDFLARE_API_TOKEN vide dans lab.env au
moment du platform-up.sh (le script le signale sans échouer). Crée le Secret, puis
kubectl -n envoy-gateway-system delete challenge --all pour relancer (les Order/Challenge
vivent dans le namespace du Certificate, donc du Gateway).
Certificate jamais créé malgré l'annotation → cert-manager ne tourne pas avec
config.enableGatewayAPI=true, ou il a démarré avant les CRD Gateway API :
kubectl -n cert-manager rollout restart deploy/cert-manager.
Navigateur qui refuse le certificat → tu es sur letsencrypt-staging, qui est le
défaut. Mets LAB_ACME_ISSUER=prod dans lab.env, relance platform-up.sh, puis
supprime le Secret pour forcer une réémission :
kubectl -n envoy-gateway-system delete secret wildcard-<domaine-en-tirets>-tls.
429 rateLimited en prod → le plafond de 5 certificats par semaine et par jeu
d'identifiants est atteint. Rien à corriger, rien à retenter : le message porte l'heure de
retry after et la fenêtre de 168 h est glissante. À noter que chaque vagrant destroy en
brûle un, puisque le wildcard ne vit que dans etcd. Repasse en LAB_ACME_ISSUER=staging en
attendant, ou sauvegarde le Secret avant de détruire (cf. ../LISEZ-MOI.md).
SELF_SIGNED=true (le défaut) court-circuite toute cette page. Si
kubectl get clusterissuer répond no resources found et que la Gateway ne porte aucune
annotation cert-manager.io/cluster-issuer, rien n'est cassé : tu es simplement sur le
chemin auto-signé. Pose SELF_SIGNED=false dans lab.env, relance platform-up.sh, puis
supprime le Secret auto-signé résiduel pour que cert-manager émette le sien :
kubectl -n envoy-gateway-system delete secret <wildcard>-tls.
Ne pas appliquer 04-gateway-https-example.yaml (voir l'encart plus haut).
Un seul niveau de wildcard : *.lab.example.io couvre argo.lab.example.io, pas
a.b.lab.example.io. Une route avec un hostname non couvert ne s'attachera pas à l'écouteur.
L'e-mail ACME versionné est neutre (admin@example.io) : platform-up.sh le remplace par
LAB_ACME_EMAIL (défaut admin@<LAB_DNS_ZONE>). En kubectl apply -f direct, tu appliques
l'adresse d'exemple — Let's Encrypt refuse certains domaines réservés.
DNS-only obligatoire côté Cloudflare : en mode « proxy orange », l'edge tenterait de
joindre 192.168.56.200 et l'accès casserait (le challenge DNS-01, lui, marcherait quand même).
🐮longhorn/ — replicated block storage (Longhorn 1.12) on Talos and kubeadm
Provides PersistentVolumes replicated across workers (StorageClass longhorn) carved out
of the node disks, with no hardware and no cloud provider. It is the lab's only HA storage:
a volume survives the loss of a node, unlike ../local-path-storage/.
iSCSI on every node — kubeadm: open-iscsi + nfs-common packages, iscsid running, iscsi_tcp module, installed by kubeadm/provision.sh. Talos: iscsi-tools + util-linux-tools extensions BAKED into the installer image (see schematic.yaml), checked by longhorn-up.sh
longhorn-manager and the CSI plugin shell out to iscsiadm to attach volumes. Without it, the CSI pods CrashLoopBackOff with iscsiadm: not found
rshared kubelet mount on /var/lib/longhorn — Talos only (patch-longhorn.yaml, applied by longhorn-up.sh)
the Talos kubelet runs in a container, without bidirectional mount propagation
talosctl -n <ip> get mc -o yaml | grep /var/lib/longhorn
talosctl in PATH — Talos only
extension check + mount patch
talosctl version --client
helm in PATH
the chart
helm version
Namespace longhorn-system with PodSecurity privileged
Longhorn pods are privileged (iSCSI, hostPath) — applied by longhorn-up.sh
kubectl get ns longhorn-system --show-labels
../envoy-gateway/ + ../cert-manager/ (optional)
only to expose the UI over HTTPS
kubectl get gateway -n envoy-gateway-system
ℹ️ Both heavy prerequisites exist on Talos only — which is what makes this the most
distribution-dependent add-on in the repository:
Talos
kubeadm / Debian
iscsi-tools + util-linux-toolssystem extensions, baked into the installer image. A node without them cannot be fixed live — it must be re-installed or upgraded to a new Image Factory ref. longhorn-up.sh can only check (talosctl get extensions) and refuse to go further.
Two apt packages. kubeadm/provision.sh runs apt-get install -y open-iscsi nfs-common, systemctl enable --now iscsid and loads iscsi_tcp (/etc/modules-load.d/iscsi.conf) on every node, at provisioning time. Nothing to check, nothing to bake.
rshared kubelet mount on /var/lib/longhorn, applied through talosctl patch mc (hot, no reboot): the Talos kubelet runs in a container and lacks bidirectional mount propagation.
/var/lib/longhorn is an ordinary directory on the root filesystem and the kubelet runs directly on the host: mount propagation is already correct. Nothing to patch.
Consequence: on Talos, longhorn-up.sh requires talosctl and runs 5 steps; on kubeadm it
runs 3. patch-longhorn.yaml and schematic.yaml are Talos-only files.
Idempotent: re-runnable without breaking anything (helm upgrade --install). It covers both
steps below. It counts the schedulable worker nodes on the live cluster (rather than trusting
WORKERS in lab.env, which only states an intent) and aligns the block replica count with it,
capped at 3. REPLICAS=… forces the count, LONGHORN_VERSION=… overrides the chart version.
ℹ️ With WORKERS=0 the control planes are untainted (UNTAINT_CP=auto) and become the only
storage nodes: the script counts them instead of bailing out.
1. Namespace + Pod Security — automated by longhorn-up.sh#
ℹ️ A kubeadm cluster enforces no PodSecurity level by default, so these labels change
nothing today. They are kept because they document the intent and keep the namespace
working if the cluster is hardened later (--admission-control-config-file on the apiserver).
2. Helm chart + baseline StorageClass — automated by longhorn-up.sh#
helmrepoaddlonghornhttps://charts.longhorn.io&&helmrepoupdate
# --version: pin it; check the latest on charts.longhorn.io
helminstalllonghornlonghorn/longhorn\--namespacelonghorn-system\--version1.12.0\-flonghorn/values.yaml
kubectl-nlonghorn-systemrolloutstatusdeploy/longhorn-driver-deployer
kubectlapply-flonghorn/longhorn-r1-storageclass.yaml
The iSCSI prerequisite does not live in the same place on both distributions — that is THE
structural difference of this component (LONGHORN_PREP_REQUIRED in the profiles).
Talos
kubeadm
iSCSI (iscsiadm)
a system extension, iscsi-tools + util-linux-tools, BAKED into the installer image (longhorn/schematic.yaml → factory image, INSTALLER_IMAGE in lab.env). A node without them cannot be fixed at runtime: CSI pods go CrashLoopBackOff (iscsiadm: not found)
a package: apt-get install -y open-iscsi nfs-common + systemctl enable --now iscsid + the iscsi_tcp module, installed by kubeadm/provision.sh at provisioning time
Mount propagation
an rshared kubelet mount on /var/lib/longhorn must be applied (longhorn/patch-longhorn.yaml, talosctl patch mc, hot, no reboot) — the Talos kubelet runs in a container
nothing to do: the kubelet runs on the host and /var/lib/longhorn is an ordinary directory
The commands below are exactly what the all-in-one script does, in order.
Set up your shell first (once per session):
exportKUBECONFIG=../Vagrant-Talos/kubeconfig# or ../Vagrant-KubeADM/kubeconfigexportLAB_DOMAIN=talos.lab.example.io# or your own (see the lab's lab.env)
1. (Talos only) Check the iSCSI extensions — before anything else#
An extension is baked into the installer: if it is missing, no kubectl will save you, the
node must be reinstalled or upgraded.
exportTALOSCONFIG=../Vagrant-Talos/_out/talosconfig
foripin192.168.56.101192.168.56.102192.168.56.103;doecho"== $ip";talosctl-n"$ip"getextensions|grep-E'iscsi-tools|util-linux-tools'done# Missing? Generate the factory image from longhorn/schematic.yaml, then:# talosctl -n <ip> upgrade --image <factory-image> --preserve
talosctl-n192.168.56.101getmc-oyaml|grep-q/var/lib/longhorn\||talosctl-n192.168.56.101patchmc--patch@longhorn/patch-longhorn.yaml
# … repeat on every worker. Applied hot, no reboot.
On kubeadm, steps 1 and 2 do not exist: open-iscsi is already installed and running on
every node. Optional check:
vagrant ssh k8s-w1 -c 'systemctl is-active iscsid; lsmod | grep iscsi_tcp'
kubectlgetsc# longhorn (default) + longhorn-r1
kubectl-nlonghorn-systemgetnodes.longhorn.io# all "Ready" and schedulable
kubectl-nlonghorn-systemgetpods|grep-vRunning# should list nothing abnormal
curl--resolve"longhorn.${LAB_DOMAIN}:443:192.168.56.200""https://longhorn.${LAB_DOMAIN}/"-kSI|head-1
⚠️ The Longhorn UI has no authentication and can delete volumes: only expose it on a
trusted network.
The Vagrantfile attaches no extra disk: Longhorn shares the box's single virtual disk with
the OS, the container images and etcd. Stacking 3-replica volumes on it triggers
ReplicaSchedulingFailure (and DiskPressure evictions before that). longhorn-r1 cuts
consumption by ~3 for the cases where block replication is pointless: rebuildable data
(Prometheus, Loki) or data already replicated by the application (CloudNativePG, 3 instances).
Defined once here, consumed elsewhere.
ℹ️ For a critical database, stay on longhorn (3 replicas) or explicitly delegate
resilience to the application.
Longhorn 1.10+ recommends a dedicated disk. Here, by default, we stay on /var/lib/longhorn
(the box's single disk). To do it properly:
VirtualBox: attach one extra .vdi per worker (SATA controller, next port) — this needs
an addition to the Vagrantfile (a vb.customize ["createhd", …] / ["storageattach", …]
block).
Debian: partition, format and mount it persistently, then point defaultDataPath at it:
ℹ️ Longhorn ships an environment check script that audits every node (iSCSI, NFS,
multipathd, kernel modules) — the fastest way to confirm the Debian prerequisites:
curl -sSfL https://raw.githubusercontent.com/longhorn/longhorn/v1.12.0/scripts/environment_check.sh | bash
longhorn-up.sh already applied the HTTPRoute (its step [3/3]). To re-apply it alone:
kubectlapply-flonghorn/httproute.yaml
🌐 Domain: the manifest carries the neutral domain lab.example.io (public repo).
longhorn-up.sh substitutes LAB_DOMAIN on the fly; applying the file by hand, as above,
keeps the neutral domain. Substitute it yourself:
The wildcard cert *.lab.example.io is already carried by the https listener: nothing
to issue here, whichever TLS mode the lab runs (self-signed by default, or cert-manager — the
Secret has the same name either way, see ../self-signed/README.md).
⚠️ The Longhorn UI has no authentication whatsoever. Exposed like this, it is reachable by
anyone who can reach the VIP (over Tailscale) — and it lets them delete volumes. To protect it:
an Envoy Gateway SecurityPolicy (Basic Auth / OIDC) targeting this HTTPRoute.
defaultReplicaCount > number of storage nodes → volumes stuck Degraded, forever.
longhorn-up.sh aligns it on the nodes it counts; if you install the chart by hand, set it
yourself (with 1 worker, 1).
Two default StorageClasses if ../local-path-storage/ is installed too: values.yaml
sets persistence.defaultClass: true (⇒ longhorn) and local-path-storage.yaml annotates
local-path with is-default-class: "true". A PVC without storageClassName then becomes
non-deterministic. Pick a single default:
kubectlannotatestorageclasslocal-pathstorageclass.kubernetes.io/is-default-class-
# or, the other way around: helm upgrade ... --set persistence.defaultClass=false
iscsid stopped (or a node built outside kubeadm/provision.sh) → CSI pods in
CrashLoopBackOff, iscsiadm: not found / Failed to execute iscsiadm. Fix on the node:
sudo apt-get install -y open-iscsi && sudo systemctl enable --now iscsid.
multipath-tools (multipathd): Debian 13 does not install it, and that is exactly
why Longhorn works out of the box here. If you install it for something else, multipathd
grabs Longhorn's block devices and volumes fail to attach (failed to get devicemapper).
Blacklist them in /etc/multipath.conf (devices { device { vendor "IET" ... } }) or leave
the package out.
Shared disk: Longhorn on /var/lib/longhorn eats the same filesystem as the OS, the
container images and etcd → watch for DiskPressure, prefer longhorn-r1, or move to a
dedicated disk (above).
vagrant destroy of a worker destroys its replicas. On longhorn (3 replicas) Longhorn
rebuilds elsewhere; on longhorn-r1 (1 replica) the data is gone. Drain and let Longhorn
rebuild before removing a node that stores anything you care about.
Uninstall: flip the Longhorn setting deleting-confirmation-flag to true before
helm uninstall, otherwise the deletion hangs forever.
kubeadm/provision.sh — where open-iscsi, nfs-common and the iscsi_tcp module are set up.
longhorn/LISEZ-MOI.md
🐮longhorn/ — stockage bloc répliqué (Longhorn 1.12) sur Talos et kubeadm
Fournit des PersistentVolumerépliqués entre workers (StorageClass longhorn) à partir
du disque des nodes, sans matériel ni cloud provider. C'est le seul stockage HA du lab :
un volume survit à la perte d'un node, contrairement à ../local-path-storage/.
iSCSI sur chaque node — kubeadm : paquets open-iscsi + nfs-common, iscsid actif, module iscsi_tcp, posés par kubeadm/provision.sh. Talos : extensions iscsi-tools + util-linux-tools CUITES dans l'image d'installation (cf. schematic.yaml), vérifiées par longhorn-up.sh
longhorn-manager et le plugin CSI appellent iscsiadm pour attacher les volumes. Sans lui, les pods CSI partent en CrashLoopBackOff avec iscsiadm: not found
Montage kubelet rshared sur /var/lib/longhorn — Talos uniquement (patch-longhorn.yaml, appliqué par longhorn-up.sh)
le kubelet Talos tourne dans un conteneur, sans propagation de montage bidirectionnelle
talosctl -n <ip> get mc -o yaml | grep /var/lib/longhorn
talosctl dans le PATH — Talos uniquement
vérification des extensions + patch du montage
talosctl version --client
helm dans le PATH
le chart
helm version
Namespace longhorn-system en PodSecurity privileged
les pods Longhorn sont privilégiés (iSCSI, hostPath) — posé par longhorn-up.sh
kubectl get ns longhorn-system --show-labels
../envoy-gateway/ + ../cert-manager/ (optionnel)
uniquement pour exposer l'UI en HTTPS
kubectl get gateway -n envoy-gateway-system
ℹ️ Les deux prérequis lourds n'existent que sur Talos — c'est ce qui fait de cet addon le
plus dépendant de la distribution du dépôt :
Talos
kubeadm / Debian
Extensions systèmeiscsi-tools + util-linux-tools, cuites dans l'image de l'installeur. Un node sans elles n'est pas réparable à chaud — il faut le réinstaller ou l'upgrader vers une nouvelle ref Image Factory. longhorn-up.sh ne peut que vérifier (talosctl get extensions) et refuser d'aller plus loin.
Deux paquets apt. kubeadm/provision.sh fait apt-get install -y open-iscsi nfs-common, systemctl enable --now iscsid et charge iscsi_tcp (/etc/modules-load.d/iscsi.conf) sur chaque node, au provisioning. Rien à vérifier, rien à cuire.
Montage kubelet rshared sur /var/lib/longhorn, appliqué par talosctl patch mc (à chaud, sans reboot) : le kubelet Talos tourne dans un conteneur et n'a pas la propagation de montage bidirectionnelle.
/var/lib/longhorn est un dossier ordinaire du système de fichiers racine, et le kubelet tourne directement sur l'hôte : la propagation de montage est déjà bonne. Rien à patcher.
Conséquence : sur Talos, longhorn-up.sh exige talosctl et fait 5 étapes ; sur kubeadm,
il en fait 3. Les fichiers patch-longhorn.yaml et schematic.yaml ne servent que sur
Talos.
Idempotent : relançable sans casse (helm upgrade --install). Il couvre les deux étapes
ci-dessous. Il compte les workers planifiables sur le cluster réel (plutôt que de faire
confiance à WORKERS de lab.env, qui n'exprime qu'une intention) et aligne le nombre de
réplicas bloc dessus, plafonné à 3. REPLICAS=… force la valeur, LONGHORN_VERSION=… surcharge
la version du chart.
ℹ️ Avec WORKERS=0, les control planes sont déteintés (UNTAINT_CP=auto) et deviennent les
seuls nodes de stockage : le script les compte au lieu de s'arrêter.
1. Namespace + Pod Security — automatisé par longhorn-up.sh#
ℹ️ Un cluster kubeadm n'applique aucun niveau PodSecurity par défaut : ces étiquettes ne
changent rien aujourd'hui. On les garde parce qu'elles documentent l'intention et gardent
le namespace fonctionnel si le cluster est durci plus tard
(--admission-control-config-file sur l'apiserver).
2. Chart Helm + StorageClass socle — automatisé par longhorn-up.sh#
helmrepoaddlonghornhttps://charts.longhorn.io&&helmrepoupdate
# --version : épingle ; vérifier la dernière sur charts.longhorn.io
helminstalllonghornlonghorn/longhorn\--namespacelonghorn-system\--version1.12.0\-flonghorn/values.yaml
kubectl-nlonghorn-systemrolloutstatusdeploy/longhorn-driver-deployer
kubectlapply-flonghorn/longhorn-r1-storageclass.yaml
Le prérequis iSCSI n'est pas au même endroit selon la distribution — c'est LA différence
structurante de ce composant (LONGHORN_PREP_REQUIRED dans les profils).
Talos
kubeadm
iSCSI (iscsiadm)
extension systèmeiscsi-tools + util-linux-tools, CUITE dans l'image d'installation (longhorn/schematic.yaml → image factory, INSTALLER_IMAGE dans lab.env). Un node sans elles est irrécupérable à chaud : les pods CSI partent en CrashLoopBackOff (iscsiadm: not found)
paquet : apt-get install -y open-iscsi nfs-common + systemctl enable --now iscsid + module iscsi_tcp, posés par kubeadm/provision.sh au provisioning
Propagation de montage
montage kubelet rshared sur /var/lib/longhorn à appliquer (longhorn/patch-longhorn.yaml, talosctl patch mc, à chaud, sans reboot) — le kubelet Talos tourne dans un conteneur
rien à faire : le kubelet tourne sur l'hôte, /var/lib/longhorn est un dossier ordinaire
Les commandes ci-dessous sont exactement ce que fait le script tout-en-un, dans
l'ordre. Prépare d'abord l'environnement (une fois par session) :
exportKUBECONFIG=../Vagrant-Talos/kubeconfig# ou ../Vagrant-KubeADM/kubeconfigexportLAB_DOMAIN=talos.lab.example.io# ou ton domaine (cf. lab.env du lab)
1. (Talos uniquement) Vérifier les extensions iSCSI — avant tout le reste#
Une extension est cuite dans l'installeur : si elle manque, aucun kubectl ne rattrapera le
coup, il faut réinstaller ou upgrader le node.
exportTALOSCONFIG=../Vagrant-Talos/_out/talosconfig
foripin192.168.56.101192.168.56.102192.168.56.103;doecho"== $ip";talosctl-n"$ip"getextensions|grep-E'iscsi-tools|util-linux-tools'done# Manquantes ? générer l'image factory depuis longhorn/schematic.yaml, puis :# talosctl -n <ip> upgrade --image <image-factory> --preserve
2. (Talos uniquement) Appliquer le montage kubelet rshared#
talosctl-n192.168.56.101getmc-oyaml|grep-q/var/lib/longhorn\||talosctl-n192.168.56.101patchmc--patch@longhorn/patch-longhorn.yaml
# … à répéter sur chaque worker. Appliqué à chaud, sans reboot.
Sur kubeadm, les étapes 1 et 2 n'existent pas : open-iscsi est déjà installé et actif
sur chaque node. Vérification facultative :
vagrant ssh k8s-w1 -c 'systemctl is-active iscsid; lsmod | grep iscsi_tcp'
Le Vagrantfile n'attache aucun disque supplémentaire : Longhorn partage le disque unique
de la box avec l'OS, les images conteneurs et etcd. Empiler des volumes 3-réplicas y déclenche
des ReplicaSchedulingFailure (et des évictions DiskPressure avant ça). longhorn-r1 divise
la conso par ~3 pour les cas où la réplication bloc est superflue : donnée reconstructible
(Prometheus, Loki) ou déjà répliquée par l'appli (CloudNativePG, 3 instances). Définie une
seule fois ici, consommée ailleurs.
ℹ️ Sur une base critique, rester sur longhorn (3 réplicas) ou déléguer explicitement
la résilience à l'application.
Longhorn 1.10+ recommande un disque dédié. Ici, par défaut, on reste sur /var/lib/longhorn
(disque unique de la box). Pour faire propre :
VirtualBox : attacher un .vdi supplémentaire par worker (contrôleur SATA, port
suivant) — nécessite un ajout dans le Vagrantfile (bloc
vb.customize ["createhd", …] / ["storageattach", …]).
Debian : partitionner, formater et monter de façon persistante, puis pointer
defaultDataPath dessus :
vagrantsshk8s-w1-c'systemctl is-active iscsid'# active
vagrantsshk8s-w1-c'lsmod | grep iscsi_tcp'# module chargé
kubectl-nlonghorn-systemgetpods# instance-manager, manager, csi-* Running
kubectlgetstorageclass# longhorn (default) + longhorn-r1
kubectl-nlonghorn-systemgetnodes.longhorn.io# chaque node "Schedulable", disque Ready# Test rapide : un PVC doit se lier
kubectlapply-f-<<'EOF'apiVersion: v1kind: PersistentVolumeClaimmetadata: { name: test-longhorn }spec: accessModes: ["ReadWriteOnce"] storageClassName: longhorn resources: { requests: { storage: 1Gi } }EOF
kubectlgetpvctest-longhorn# Bound
kubectldeletepvctest-longhorn
ℹ️ Longhorn livre un script de vérification d'environnement qui audite chaque node (iSCSI,
NFS, multipathd, modules noyau) — le moyen le plus rapide de confirmer les prérequis
Debian :
curl -sSfL https://raw.githubusercontent.com/longhorn/longhorn/v1.12.0/scripts/environment_check.sh | bash
longhorn-up.sh a déjà appliqué l'HTTPRoute (son étape [3/3]). Pour la réappliquer seule :
kubectlapply-flonghorn/httproute.yaml
🌐 Domaine : le manifeste porte le domaine neutre lab.example.io (dépôt public).
longhorn-up.sh y substitue LAB_DOMAIN à la volée ; appliqué à la main comme ci-dessus, le
domaine neutre reste. Substitue-le toi-même :
Cert wildcard *.lab.example.io déjà porté par l'écouteur https : rien à émettre ici,
quel que soit le mode TLS du lab (auto-signé par défaut, ou cert-manager — le Secret porte le
même nom dans les deux cas, cf. ../self-signed/LISEZ-MOI.md).
⚠️ L'UI Longhorn n'a aucune authentification. Exposée ainsi, elle est accessible à
quiconque atteint le VIP (via Tailscale) — et elle permet de supprimer des volumes. Pour la
protéger : SecurityPolicy Envoy Gateway (Basic Auth / OIDC) ciblant cette HTTPRoute.
defaultReplicaCount > nombre de nodes de stockage → volumes coincés en Degraded, à
vie. longhorn-up.sh l'aligne sur les nodes qu'il compte ; en installant le chart à la main,
le faire soi-même (à 1 worker, mettre 1).
Deux StorageClass par défaut si ../local-path-storage/ est aussi installé :
values.yaml pose persistence.defaultClass: true (⇒ longhorn) et
local-path-storage.yaml annote local-path avec is-default-class: "true". Un PVC sans
storageClassName devient alors non déterministe. Choisir un seul défaut :
kubectlannotatestorageclasslocal-pathstorageclass.kubernetes.io/is-default-class-
# ou, dans l'autre sens : helm upgrade ... --set persistence.defaultClass=false
iscsid arrêté (ou un node monté hors de kubeadm/provision.sh) → pods CSI en
CrashLoopBackOff, erreurs iscsiadm: not found / Failed to execute iscsiadm. Sur le node :
sudo apt-get install -y open-iscsi && sudo systemctl enable --now iscsid.
multipath-tools (multipathd) : Debian 13 ne l'installe pas, et c'est précisément
pour ça que Longhorn fonctionne d'emblée ici. Si tu l'installes pour autre chose, multipathd
s'approprie les devices bloc de Longhorn et les volumes ne s'attachent plus
(failed to get devicemapper). Les blacklister dans /etc/multipath.conf
(devices { device { vendor "IET" ... } }), ou ne pas installer le paquet.
Disque partagé : Longhorn sur /var/lib/longhorn consomme le même système de fichiers que
l'OS, les images conteneurs et etcd → surveiller DiskPressure, préférer longhorn-r1, ou
passer au disque dédié (ci-dessus).
vagrant destroy d'un worker détruit ses réplicas. Sur longhorn (3 réplicas) Longhorn
reconstruit ailleurs ; sur longhorn-r1 (1 réplica) la donnée est perdue. Drainer et
laisser Longhorn reconstruire avant de retirer un node qui stocke quelque chose d'important.
Désinstallation : passer le setting Longhorn deleting-confirmation-flag à true
avant helm uninstall, sinon la suppression reste bloquée.
kubeadm/provision.sh — là où open-iscsi, nfs-common et le module iscsi_tcp sont posés.
local-path-storage/README.md
📁local-path-storage/ — dynamic local storage (no Longhorn)
Deploys Rancher local-path-provisionerv0.0.36 and a default local-path StorageClass: PVs carved out of the worker's disk
(/opt/local-path-provisioner). This is the lab's "no Longhorn" alternative — zero CSI
driver, zero extra package on the nodes, two resources and provisioning just works.
A kubeadm cluster ships no storage provisioner at all: kubectl get storageclass returns nothing and
every PVC stays Pending. Components that require a PVC (CloudNativePG does not support
emptyDir for PGDATA, MinIO wants a /data) never start at all. This provisioner fills the gap
with no external dependency.
⚠️ NODE-LOCAL storage, not replicated. A PV lives on one single worker. It survives
a pod restart / reschedule (as long as the pod comes back on the same node), but it is lost
if that node dies. No HA at the storage level: keep it for rebuildable data or for
"knowingly ephemeral" use cases. For replicated storage, see ../longhorn/.
Who uses it in this lab:
Addon
Usage
../minio-s3/
1 PVC, 10 Gi (standalone)
../minio-s3/cluster/
4 PVCs, 10 Gi — MinIO does its own resilience (erasure coding) on top
ℹ️ ../cloudnative-pg/ has no local-path variant: cluster-demo.yaml mandates
storageClass: longhorn-r1 and cloudnative-pg-up.sh bails out if that StorageClass is
missing. A "3 PostgreSQL nodes on local-path" variant is an unimplemented idea.
Same for ../databasement/, which stays on emptyDir (values.yaml).
One difference only, but a blocking one if ignored: the provisioning path
(LOCAL_PATH_DIR in the profiles).
Talos
kubeadm
PV path
/var/local-path-provisioner
/opt/local-path-provisioner (upstream path)
Why
/ and /etc are READ-ONLY, only /var is writable: a helper pod can create NOTHING under /opt
/opt is writable, the helper pod creates it without asking
privileged PodSecurity labels
required (cluster default baseline; helper pods mount hostPath)
intent documentation
The versioned manifest carries the upstream path; local-path-up.sh substitutes it on the fly
(sed) from the profile. Check afterwards: kubectl -n local-path-storage get cm local-path-config -o yaml | grep paths.
The commands below are exactly what the all-in-one script does, in order.
Set up your shell first (once per session):
exportKUBECONFIG=../Vagrant-Talos/kubeconfig# or ../Vagrant-KubeADM/kubeconfigexportLAB_DOMAIN=talos.lab.example.io# or your own (see the lab's lab.env)
The vendored manifest (local-path-storage.yaml) starts from
upstream v0.0.36 and keeps its /opt/local-path-provisioner path — on Debian 13 that
directory is writable, so there is nothing to move:
#
Change
Why
1
Namespace local-path-storage in PodSecurity privileged
The helper pods (creating/deleting the PV directories) mount hostPath. kubeadm enforces nothing at cluster level by default, so this label unblocks nothing today — it is kept because it states the intent and keeps the component working the day admission is hardened (Kyverno, a default AdmissionConfiguration, a managed cluster).
You can map a path per node ("node":"k8s-w1"), for instance onto an extra disk mounted on
that worker (/etc/fstab inside the VM — the nodes are plain Debian). After editing:
kubectl -n local-path-storage rollout restart deploy/local-path-provisioner.
reclaimPolicy: Delete — the PV directory is deleted along with the PVC. Use Retain
to keep the data.
volumeBindingMode: WaitForFirstConsumer — the PV is only provisioned when the pod is
scheduled (storage follows the pod onto its node). Keep it.
The size requested by a PVC is NOT enforced. A local-path PV is a plain hostPath
directory: requests.storage: 10Gi is purely declarative, nothing caps the writes. A workload
can fill the worker's /var partition up to DiskPressure (and pod eviction). Measured on
this lab: ~16.9 GB of allocatable ephemeral-storage per node for a 20 GB disk
(Vagrantfile, DISK_SIZE_MB = 20480) — two 10 Gi PVCs "fit" side by side on paper, not in
reality. Watch it:
Two default StorageClasses if ../longhorn/ is installed alongside: local-path is
annotated is-default-class: "true" and longhorn/values.yaml sets
persistence.defaultClass: true. A PVC without storageClassName becomes non-deterministic.
Drop one of the two defaults:
The helper pod runs image: busybox with no tag (so :latest, see
local-path-storage.yaml). That violates the disallow-latest-tag policy of
../kyverno/policies/02-disallow-latest-tag.yaml: both of its rules (tag present, tag ≠
latest) will surface a failing PolicyReport on helper-pod. The policy runs in Audit
mode → nothing is blocked, but it is an expected "offender" in the Policy Reporter UI.
PVs stuck after uninstall: already provisioned PVs (and their directories under
/opt/local-path-provisioner) are not cleaned up while PVCs still reference them.
Delete the consuming workloads/PVCs first.
📁local-path-storage/ — stockage local dynamique (sans Longhorn)
Déploie Rancher local-path-provisionerv0.0.36 et une StorageClass local-path par défaut : des PV taillés dans le disque du
worker (/opt/local-path-provisioner). C'est l'alternative « sans Longhorn » du lab —
zéro pilote CSI, zéro paquet supplémentaire sur les nodes, deux ressources et c'est provisionné.
Un cluster kubeadm n'embarque aucun provisioner de stockage : kubectl get storageclass renvoie vide et
tout PVC reste Pending. Les addons qui exigent un PVC (CloudNativePG ne supporte pas
emptyDir pour PGDATA, MinIO veut un /data) ne démarrent pas du tout. Ce provisioner comble
ce manque sans dépendance externe.
⚠️ Stockage NODE-LOCAL, non répliqué. Un PV vit sur un seul worker. Il survit au
redémarrage / reschedule d'un pod (tant qu'il revient sur le même node), mais il est perdu
si ce node meurt. Aucune HA au niveau stockage : à réserver aux données reconstructibles ou
aux usages « éphémères assumés ». Pour du répliqué, voir ../longhorn/.
Qui l'utilise dans ce lab :
Addon
Usage
../minio-s3/
1 PVC 10 Gi (standalone)
../minio-s3/cluster/
4 PVC 10 Gi — MinIO fait sa propre résilience (erasure coding) par-dessus
ℹ️ ../cloudnative-pg/n'a pas de variante local-path : cluster-demo.yaml impose
storageClass: longhorn-r1 et cloudnative-pg-up.sh s'arrête si cette StorageClass est
absente. Une variante « 3 nœuds PostgreSQL sur local-path » est une piste non implémentée.
Idem ../databasement/, qui reste sur emptyDir (values.yaml).
Une seule différence, mais bloquante si elle est ignorée : le chemin de provisionnement
(LOCAL_PATH_DIR dans les profils).
Talos
kubeadm
Chemin des PV
/var/local-path-provisioner
/opt/local-path-provisioner (chemin de l'amont)
Pourquoi
/ et /etc sont en LECTURE SEULE, seul /var est inscriptible : un helper-pod ne peut RIEN créer sous /opt
/opt est inscriptible, le helper-pod le crée sans rien demander
Labels PodSecurity privileged
indispensables (défaut cluster baseline ; les helper-pods montent du hostPath)
documentation d'intention
Le manifeste versionné porte le chemin amont ; local-path-up.sh le substitue à la volée
(sed) selon le profil. Vérifier après coup : kubectl -n local-path-storage get cm local-path-config -o yaml | grep paths.
Les commandes ci-dessous sont exactement ce que fait le script tout-en-un, dans
l'ordre. Prépare d'abord l'environnement (une fois par session) :
exportKUBECONFIG=../Vagrant-Talos/kubeconfig# ou ../Vagrant-KubeADM/kubeconfigexportLAB_DOMAIN=talos.lab.example.io# ou ton domaine (cf. lab.env du lab)
Le manifeste vendorisé (local-path-storage.yaml) part de
l'upstream v0.0.36 et garde son chemin /opt/local-path-provisioner — sur Debian 13 ce
dossier est inscriptible, il n'y a donc rien à déplacer :
#
Modification
Pourquoi
1
Namespace local-path-storage en PodSecurity privileged
Les helper-pods (création/suppression des dossiers de PV) montent du hostPath. kubeadm n'applique rien au niveau cluster par défaut : ce label ne débloque donc rien aujourd'hui — on le garde parce qu'il documente l'intention et qu'il garde le composant fonctionnel le jour où l'admission est durcie (Kyverno, un AdmissionConfiguration par défaut, un cluster managé).
2
StorageClass local-path marquée par défaut (is-default-class)
Les PVC sans storageClassName l'utilisent automatiquement.
On peut mapper un chemin par node ("node":"k8s-w1"), par exemple vers un disque
supplémentaire monté sur ce worker (/etc/fstab dans la VM — les nodes sont du Debian
standard). Après édition :
kubectl -n local-path-storage rollout restart deploy/local-path-provisioner.
reclaimPolicy: Delete — le dossier du PV est supprimé avec le PVC. Retain pour
conserver les données.
volumeBindingMode: WaitForFirstConsumer — le PV n'est provisionné qu'au scheduling du
pod (le stockage suit le pod sur son node). À conserver.
La taille demandée par un PVC n'est PAS appliquée. Un PV local-path est un simple dossier
hostPath : requests.storage: 10Gi est purement déclaratif, rien ne borne l'écriture. Un
workload peut remplir la partition /var du worker jusqu'au DiskPressure (et l'éviction des
pods). Mesuré sur ce lab : ~16,9 Go d'ephemeral-storage allocatable par node pour un
disque de 20 Go (Vagrantfile, DISK_SIZE_MB = 20480) — deux PVC de 10 Gi « tiennent » côte
à côte sur le papier, pas dans la réalité. Surveiller :
Deux StorageClass par défaut si ../longhorn/ est installé en parallèle : local-path
est annotée is-default-class: "true" et longhorn/values.yaml pose
persistence.defaultClass: true. Un PVC sans storageClassName devient non déterministe.
Retirer un des deux défauts :
Le helper-pod tourne sur image: busybox sans tag (donc :latest, cf.
local-path-storage.yaml). Ça viole la policy disallow-latest-tag de
../kyverno/policies/02-disallow-latest-tag.yaml : ses deux règles (tag présent, tag ≠
latest) remonteront un PolicyReport en échec sur helper-pod. La policy est en mode
Audit → rien n'est bloqué, mais c'est un « coupable » attendu dans l'UI Policy Reporter.
PV coincés après désinstallation : les PV déjà provisionnés (et leurs dossiers sous
/opt/local-path-provisioner) ne sont pas nettoyés si des PVC les référencent encore.
Supprimer d'abord les workloads/PVC consommateurs.
An S3-compatible endpoint inside the cluster, in a single pod, with a full admin
console (Pigsty fork). This is the simple version: one PVC, one replica.
The distributed version lives in cluster/.
The PVC's StorageClass is MINIO_SC (default local-path). Set MINIO_SC=longhorn to
put the bucket on replicated storage — see
Which StorageClass? below.
Get a local S3 to test backups, SDKs, mc, bucket policies — with no cloud account. Two
hostnames exposed over HTTPS via main-gateway (wildcard *.lab.example.io):
Service
URL
Container port
S3 API
https://minio.lab.example.io
9000
Admin console
https://minio-console.lab.example.io
9001
Inside the cluster: http://minio.minio-s3.svc.cluster.local:9000.
ℹ️ The lab backups no longer target this standalone. Since the move to the MinIO cluster,
../cloudnative-pg/pg-backup-up.sh and pg-app-backup-cnpg-up.sh point at
http://minio.minio-cluster.svc.cluster.local:9000. This directory remains the simple sandbox
(and the teaching component for "before/after erasure coding").
⚠️ A backup that dies with its node is not a backup. Velero writes both the object
tarballs and the PV data here, so on local-path the loss of that single worker takes the
cluster's only restore point with it. MINIO_SC=longhorn is the right default for a Velero
target — accepting that Longhorn itself lives on those same workers, which is why an
off-cluster copy stays the real answer.
ℹ️ storageClassName is immutable. Changing MINIO_SC on an already-installed MinIO is
refused by the script with the two ways out (keep the current class, or delete
deploy/minio + pvc/minio-data and lose the bucket content) — it is not silently ignored.
No distribution-specific behaviour for this component: same charts, same manifests, same
values on both labs. The distribution argument only drives two things here: the default
domain (talos.lab.example.io / kubeadm.lab.example.io) and where the lab's lab.env
/ kubeconfig live (../Vagrant-Talos/ or ../Vagrant-KubeADM/).
ℹ️ The only prerequisite is the StorageClass named by MINIO_SC — and if you leave it on
local-path, its path does depend on the distribution (see
../local-path-storage/).
The commands below are exactly what the all-in-one script does, in order.
Set up your shell first (once per session):
exportKUBECONFIG=../Vagrant-Talos/kubeconfig# or ../Vagrant-KubeADM/kubeconfigexportLAB_DOMAIN=talos.lab.example.io# or your own (see the lab's lab.env)
3. Deployment + Service + HTTPRoutes (domain substituted)#
sed-e"s/lab\.example\.io/${LAB_DOMAIN}/g"\-e"s#^\( *storageClassName: \).*#\1${MINIO_SC}#"minio-s3/minio-s3.yaml|kubectlapply-f-
kubectl-nminio-s3rolloutstatusdeploy/minio--timeout=180s
kubectl-nminio-s3getpvcminio-data# Bound, on the class you asked for
kubectl-nminio-s3gethttproute
curl--resolve"minio.${LAB_DOMAIN}:443:192.168.56.200""https://minio.${LAB_DOMAIN}/minio/health/live"-kSI|head-1
# mc client (from the host):
mcaliassetlab"https://minio.${LAB_DOMAIN}"<user><pass>--insecure
mcmblab/demo--insecure&&mclslab--insecure
Checks kubectl, the apiserver and the presence of the ${MINIO_SC:-local-path} StorageClass,
then that an existing minio-data PVC is not on a different class (immutable field).
Creates the minio-s3 namespace and the minio-creds Secret (root-user / root-password) —
never overwritten if it exists: re-running the script does not change the password.
Applies minio-s3.yaml with the domain and the StorageClass substituted: 10 Gi PVC,
Deployment (strategy: Recreate, since the RWO volume cannot take two pods), ClusterIP
Service, two HTTPRoutes.
Waits for the rollout (180 s) then prints the URLs and the root credentials in clear text
on stdout (see Pitfalls).
Bitnami (bitnami/minio) has relied since August 2025 on frozen images
(bitnamilegacy/*, no longer updated).
Upstream minio/miniogutted the community console around
RELEASE.2025-05-24 (only an object browser is left: no more user / bucket / policy /
lifecycle management from the web), then the repo was archived as
"no longer maintained" (Feb 2026).
Pigsty rebuilds the MinIO server and restores the full admin console → a recent AND
manageable image. It is the most active fork (see the "MinIO is Dead, Long Live MinIO"
post).
Other options: upstream pinned at RELEASE.2025-04-22T22-12-26Z (the last release with the
official admin console, but frozen), other console forks (huncrys/minio-console,
georgmangold/console), the paid AIStor edition, or simply the mc CLI.
minio-up.sh prints the root user AND password in clear text on stdout (end of the run).
That ends up in your shell history, CI logs, a screenshot… Prefer reading them back from the
Secret (table above), and remember to scrub the output if you share it.
On local-path (the default) there is no resilience at all. A 1-replica Deployment + 1
node-local PVC: if the worker hosting the PV dies, the objects are gone. Three ways out,
by increasing cost: MINIO_SC=longhorn (replicated volume, still one MinIO pod),
cluster/ (4 drives, EC:2 — MinIO replicates on its own), or a copy
off-cluster.
MINIO_SC=longhorn protects the volume, not the whole failure domain. Longhorn replicas
live on the same workers as everything else, so a vagrant destroy still takes the bucket —
including any Velero backup stored in it. Treat it as protection against one node dying,
not as an off-site backup.
The 10 Gi of the PVC is a real limit on longhorn, but not on local-path.local-path
provisions a hostPath directory: nothing stops MinIO from filling the worker's /var
partition. The allocatable ephemeral-storage measured on this lab is ~16.9 GB/node
(20 GB disk shared with the OS and the container images) → filling a bucket triggers
DiskPressure and pod eviction on that node. Watch
kubectl describe node <worker> | grep -i pressure. Longhorn, by contrast, enforces the
10 Gi: MinIO gets ENOSPC and returns S3 errors instead of hurting the node. So when this
MinIO is a Velero target, keep an eye on the bucket against the retention you configured
(ttl in ../velero/schedule.yaml) — and raise the storage:
request in minio-s3.yaml before it bites (Longhorn allows expansion).
The minio-creds Secret is not in git (the script creates it). Losing it means losing root
access: kubectl -n minio-s3 delete secret minio-creds then re-running the script regenerates
a password, but MinIO keeps the old one until the pod is recreated.
Console behind a separate hostname: MINIO_BROWSER_REDIRECT_URL carries
https://minio-console.lab.example.io in minio-s3.yaml — the neutral domain of the
public repo. minio-up.sh substitutes it, along with the HTTPRoute hostnames, from LAB_DOMAIN
(lab.env). A directkubectl apply -f minio-s3.yaml keeps the example domain and breaks
the login redirects. See ../README.md.
Un endpoint compatible S3 dans le cluster, en un seul pod, avec une console
d'administration complète (fork Pigsty). C'est la version simple : un PVC, un replica.
La version distribuée est dans cluster/.
La StorageClass du PVC est MINIO_SC (défaut local-path). Mets MINIO_SC=longhorn pour
poser le bucket sur du stockage répliqué — cf.
Quelle StorageClass ? plus bas.
Avoir un S3 local pour tester des backups, des SDK, mc, des politiques de buckets — sans
compte cloud. Deux hostnames exposés en HTTPS via main-gateway (wildcard *.lab.example.io) :
Service
URL
Port conteneur
API S3
https://minio.lab.example.io
9000
Console admin
https://minio-console.lab.example.io
9001
En interne au cluster : http://minio.minio-s3.svc.cluster.local:9000.
nulle sur local-path ; au niveau du volume sur longhorn
tolère ~2 nœuds/drives down
Workers requis
1
4 (anti-affinité stricte)
Namespace
minio-s3
minio-cluster (les deux coexistent)
Hostnames
minio / minio-console
minio-cluster / minio-cluster-console
ℹ️ Les backups du lab ne visent plus ce standalone. Depuis le passage au cluster MinIO,
../cloudnative-pg/pg-backup-up.sh et pg-app-backup-cnpg-up.sh pointent
http://minio.minio-cluster.svc.cluster.local:9000. Ce dossier reste le bac à sable simple
(et la brique pédagogique « avant/après erasure coding »).
⚠️ Un backup qui meurt avec son node n'est pas un backup. Velero écrit ici à la fois les
tarballs d'objets et les données des PV : sur local-path, la perte de cet unique worker
emporte le seul point de restauration du cluster. MINIO_SC=longhorn est le bon défaut pour
une cible Velero — en acceptant que Longhorn vive lui aussi sur ces mêmes workers, ce qui fait
qu'une copie hors cluster reste la vraie réponse.
ℹ️ storageClassName est immuable. Changer MINIO_SC sur un MinIO déjà installé est
refusé par le script, avec les deux sorties possibles (garder la classe actuelle, ou
supprimer deploy/minio + pvc/minio-data et perdre le contenu du bucket) — ce n'est pas
ignoré en silence.
Aucune spécificité de distribution pour ce composant : mêmes charts, mêmes manifestes,
mêmes valeurs sur les deux labs. La distribution passée en argument ne sert ici qu'à deux
choses : le domaine par défaut (talos.lab.example.io / kubeadm.lab.example.io) et la
localisation du lab.env / kubeconfig du lab (../Vagrant-Talos/ ou
../Vagrant-KubeADM/).
ℹ️ Le seul prérequis est la StorageClass nommée par MINIO_SC — et si tu la laisses sur
local-path, son chemin, lui, dépend de la distribution (cf.
../local-path-storage/).
Les commandes ci-dessous sont exactement ce que fait le script tout-en-un, dans
l'ordre. Prépare d'abord l'environnement (une fois par session) :
exportKUBECONFIG=../Vagrant-Talos/kubeconfig# ou ../Vagrant-KubeADM/kubeconfigexportLAB_DOMAIN=talos.lab.example.io# ou ton domaine (cf. lab.env du lab)
3. Déploiement + Service + HTTPRoutes (domaine et StorageClass substitués)#
sed-e"s/lab\.example\.io/${LAB_DOMAIN}/g"\-e"s#^\( *storageClassName: \).*#\1${MINIO_SC}#"minio-s3/minio-s3.yaml|kubectlapply-f-
kubectl-nminio-s3rolloutstatusdeploy/minio--timeout=180s
kubectl-nminio-s3getpvcminio-data# Bound, sur la classe demandée
Vérifie kubectl, l'apiserver et la présence de la StorageClass ${MINIO_SC:-local-path},
puis qu'un PVC minio-data existant n'est pas sur une autre classe (champ immuable).
Crée le namespace minio-s3 et le Secret minio-creds (root-user / root-password) —
jamais écrasé s'il existe : relancer le script ne change pas le mot de passe.
Applique minio-s3.yaml avec le domaine et la StorageClass substitués : PVC 10 Gi,
Deployment (strategy: Recreate, car le volume RWO n'accepte pas deux pods), Service
ClusterIP, deux HTTPRoute.
Attend le rollout (180 s) puis affiche les URL et les identifiants root en clair sur
stdout (cf. Pièges).
Bitnami (bitnami/minio) s'appuie depuis août 2025 sur des images gelées
(bitnamilegacy/*, plus mises à jour).
Upstream minio/minio a amputé la console communautaire vers
RELEASE.2025-05-24 (il ne reste qu'un navigateur d'objets : plus de gestion users /
buckets / policies / lifecycle depuis le web), puis le dépôt a été archivé
« no longer maintained » (fév. 2026).
Pigsty rebuild le serveur MinIO et restaure la console d'admin complète → image
récente ET administrable. C'est le fork le plus actif (billet « MinIO is Dead, Long
Live MinIO »).
Alternatives possibles : upstream épinglé RELEASE.2025-04-22T22-12-26Z (dernière release avec
la console admin officielle, mais figé), autres forks de console (huncrys/minio-console,
georgmangold/console), édition payante AIStor, ou simplement le CLI mc.
Le cert wildcard est émis par Let's Encrypt staging → avertissement TLS à accepter dans le
navigateur, et --insecure pour mc (cf. ../cert-manager/).
mcaliassetlabhttps://minio.lab.example.io<user><pass>--insecure
mcmblab/mon-bucket--insecure# créer un bucket
mcadminuseraddlabbob<mot-de-passe>--insecure# gérer les users
mclslab--insecure
minio-up.sh affiche l'utilisateur ET le mot de passe root en clair sur stdout (fin de
run). Ça finit dans l'historique du terminal, les logs de CI, une capture d'écran… Préférer
les relire depuis le Secret (tableau ci-dessus) et penser à nettoyer la sortie si tu la
partages.
Sur local-path (le défaut), aucune résilience. Deployment 1 replica + 1 PVC node-local :
si le worker qui héberge le PV meurt, les objets sont perdus. Trois sorties, par coût
croissant : MINIO_SC=longhorn (volume répliqué, toujours un seul pod MinIO),
cluster/ (4 drives, EC:2 — MinIO se réplique tout seul), ou une copie hors
cluster.
MINIO_SC=longhorn protège le volume, pas le domaine de panne. Les replicas Longhorn
vivent sur les mêmes workers que le reste : un vagrant destroy emporte quand même le bucket
— y compris le backup Velero qui s'y trouve. À considérer comme une protection contre la mort
d'un node, pas comme une sauvegarde hors site.
Les 10 Gi du PVC sont une vraie limite sur longhorn, mais pas sur local-path.local-path provisionne un dossier hostPath : rien n'empêche MinIO de remplir la partition
/var du worker. L'ephemeral-storage allocatable mesuré sur ce lab est de ~16,9 Go/node
(disque de 20 Go partagé avec l'OS et les images conteneurs) → remplir un bucket déclenche du
DiskPressure et l'éviction de pods sur ce node. Surveiller
kubectl describe node <worker> | grep -i pressure. Longhorn, à l'inverse, fait respecter les
10 Gi : MinIO reçoit ENOSPC et renvoie des erreurs S3 au lieu d'abîmer le node. Quand ce
MinIO est une cible Velero, garde donc un œil sur le bucket face à la rétention configurée
(ttl dans ../velero/schedule.yaml) — et augmente la requête
storage: de minio-s3.yaml avant que ça morde (Longhorn autorise l'expansion).
Le Secret minio-creds n'est pas dans git (créé par le script). Le perdre = perdre
l'accès root : kubectl -n minio-s3 delete secret minio-creds puis relancer le script
régénère un mot de passe, mais MinIO garde l'ancien tant que le pod n'est pas recréé.
Console derrière un hostname distinct : MINIO_BROWSER_REDIRECT_URL porte
https://minio-console.lab.example.io dans minio-s3.yaml — domaine neutre du dépôt
public. minio-up.sh la substitue avec les hostnames des HTTPRoute depuis LAB_DOMAIN
(lab.env). Un kubectl apply -f minio-s3.yamldirect garde le domaine d'exemple et casse
les redirections de login. Cf. ../LISEZ-MOI.md.
cluster/ — la variante distribuée 4 nœuds (erasure coding).
minio-s3/cluster/README.md
🧺minio-s3/cluster/ — distributed 4-node MinIO (erasure coding) on local-path
The resilient variant of the standalone MinIO (../): a 4-pod StatefulSet, 1 drive
(local-path PVC) per pod, 1 pod per worker. MinIO erasure-codes objects across the 4
drives → object storage survives node losses without Longhorn, exactly the way
CloudNativePG handles Postgres replication itself.
⚠️ BLOCKING prerequisite: 4 Ready workers. The default shipped by both labs (their
lab.env.example) is
WORKERS=3 → with that topology this component cannot start at all. See Prerequisites.
This is the lab's "for real" S3: the target of the PostgreSQL backups
(../../cloudnative-pg/pg-backup-up.sh and pg-app-backup-cnpg-up.sh point at
http://minio.minio-cluster.svc.cluster.local:9000), and the teaching demo of erasure coding
against the standalone.
Standalone (../)
Cluster (here)
Workload
Deployment, 1 replica
StatefulSet, 4 pods
Drives
1 (local-path 10 Gi)
4 (1 PVC 10 Gi/pod, 1/worker)
Erasure coding
❌
✅ EC:2 (2 parity blocks)
Resilience
none (losing the node = losing the data)
tolerates ~2 nodes/drives down
Workers required
1
4
Namespace
minio-s3
minio-cluster (they coexist)
Exposure (HTTPS via main-gateway, wildcard *.lab.example.io):
Service
URL
Port
S3 API
https://minio-cluster.lab.example.io
9000
Admin console
https://minio-cluster-console.lab.example.io
9001
Inside the cluster: http://minio.minio-cluster.svc.cluster.local:9000.
replicas: 4 + requiredDuringScheduling anti-affinity on kubernetes.io/hostname (max 1 pod per node); control planes are tainted NoSchedule so they do not count
kubectl get nodes -l '!node-role.kubernetes.io/control-plane'
the 4 PVCs of the volumeClaimTemplates; the script bails out without it
kubectl get storageclass local-path
main-gateway + https listener + wildcard cert
both HTTPRoutes
kubectl get gateway -n envoy-gateway-system
DNS minio-cluster + minio-cluster-console → 192.168.56.200
to reach the Envoy VIP
getent hosts minio-cluster.lab.example.io
⚠️ Set WORKERS=4 (or more) in lab.env before building the cluster.
The lab's lab.env.example ships WORKERS=3, and minio-cluster-up.sh only warns (a plain
echo, not an exit) when there are fewer than 4 workers. It applies the manifest anyway:
the 4th pod stays Pending forever (strict anti-affinity, no eligible node left), so the
rollout status --timeout=300sfails after 5 minutes and the deployment starts at best
degraded from day one (3 drives out of 4, zero margin: one more failure and the erasure set
loses its write quorum). Changing WORKERS requires a vagrant destroy and a cluster rebuild
(see CLAUDE.md): decide beforehand.
No distribution-specific behaviour for this component: same charts, same manifests, same
values on both labs. The distribution argument only drives two things here: the default
domain (talos.lab.example.io / kubeadm.lab.example.io) and where the lab's lab.env
/ kubeconfig live (../Vagrant-Talos/ or ../Vagrant-KubeADM/).
ℹ️ A topology constraint, not a distribution one: anti-affinity pins 1 pod per node, so
4 Ready workers are required (otherwise pods stay Pending).
The commands below are exactly what the all-in-one script does, in order.
Set up your shell first (once per session):
exportKUBECONFIG=../Vagrant-Talos/kubeconfig# or ../Vagrant-KubeADM/kubeconfigexportLAB_DOMAIN=talos.lab.example.io# or your own (see the lab's lab.env)
1. Check the prerequisites (storage + worker count)#
sed"s/lab\.example\.io/${LAB_DOMAIN}/g"minio-s3/cluster/minio-cluster.yaml|kubectlapply-f-
kubectl-nminio-clusterrolloutstatusstatefulset/minio--timeout=300s
kubectl-nminio-clustergetpods-owide# one pod per node
StatefulSet minio (podManagementPolicy: Parallel — the 4 pods boot TOGETHER and wait for each other)
minio-0 @ worker A ─ PVC data-minio-0 (local-path 10Gi) ┐
minio-1 @ worker B ─ PVC data-minio-1 ├─ 1 pool, 1 erasure set of 4, EC:2
minio-2 @ worker C ─ PVC data-minio-2 │
minio-3 @ worker D ─ PVC data-minio-3 ┘
▲ peer discovery via the HEADLESS Service minio-hl:
server http://minio-{0...3}.minio-hl.minio-cluster.svc.cluster.local:9000/data
Service minio (ClusterIP) ── balances across the 4 pods ── HTTPRoutes (API + console)
Key points of the manifest:
podManagementPolicy: Parallel (mandatory): with OrderedReady, minio-0 would never be
"ready" without its peers → deadlock. In parallel, all 4 boot and form the quorum.
Headless Service minio-hl (clusterIP: None, publishNotReadyAddresses: true): stable
per-pod DNS, resolved before the pods are ready.
Then repoint the backup jobs (MINIO_ENDPOINT / endpointURL) at
http://minio.minio-cluster.svc.cluster.local:9000 — already the case for the scripts in
../../cloudnative-pg/ — and decommission the standalone.
Fewer than 4 workers = an install that never converges: the 4th pod stays Pending
(required… anti-affinity), rollout status fails after 5 min, and the erasure set runs at
3/4 drives right from the start. The script does not fail at that point (just an echo
warning, minio-cluster-up.sh): do not read its quiet start as proof the topology is right.
minio-cluster-up.sh prints the root user and password in clear text on stdout.
Read them back from the Secret instead (Access table).
The 4 × 10 Gi are not quotas.local-path = a hostPath directory, the PVC size is never
enforced. The allocatable ephemeral-storage measured here is ~16.9 GB per node (20 GB disk
shared with the OS and the images): filling the buckets causes DiskPressure and pod
eviction on the affected workers — hence potentially the simultaneous loss of several MinIO
drives. Watch it:
Node-local drives: losing a worker = losing a drive. EC:2 absorbs 2 losses, no more; a
vagrant destroy absorbs 4 (and the data with them).
Scaling: MinIO grows by adding server pools (≥ 4 drives per pool), never by adding a
lone drive. To grow, declare a 2nd pool in the args
(… /data http://minio2-{0...3}…/data).
Performance: 1 drive/pod on a node-local disk shared with the OS — fine for a lab, not for
real load.
Console behind a separate hostname: MINIO_BROWSER_REDIRECT_URL carries
https://minio-cluster-console.lab.example.io in the manifest — the neutral domain of
the public repo, substituted by minio-cluster-up.sh from LAB_DOMAIN (lab.env) along with
the HTTPRoute hostnames. A direct kubectl apply keeps the example domain and breaks the
login redirects. See ../../README.md.
🧺minio-s3/cluster/ — MinIO distribué 4 nœuds (erasure coding) sur local-path
Variante résiliente du MinIO standalone (../) : un StatefulSet de 4 pods, 1 drive
(PVC local-path) par pod, 1 pod par worker. MinIO erasure-code les objets sur les 4
drives → le stockage objet survit à la perte de nœuds sans Longhorn, exactement comme
CloudNativePG assure lui-même la réplication de Postgres.
⚠️ Prérequis BLOQUANT : 4 workers Ready. Le défaut livré par les deux labs (leur
lab.env.example) est
WORKERS=3 → avec cette topologie l'addon ne peut pas démarrer du tout. Voir Prérequis.
C'est le S3 « pour de vrai » du lab : la cible des sauvegardes PostgreSQL
(../../cloudnative-pg/pg-backup-up.sh et pg-app-backup-cnpg-up.sh pointent
http://minio.minio-cluster.svc.cluster.local:9000), et la démo pédagogique de l'erasure
coding face au standalone.
Standalone (../)
Cluster (ici)
Workload
Deployment 1 replica
StatefulSet 4 pods
Drives
1 (local-path 10 Gi)
4 (1 PVC 10 Gi/pod, 1/worker)
Erasure coding
❌
✅ EC:2 (2 parités)
Résilience
aucune (perte du node = perte data)
tolère ~2 nœuds/drives down
Workers requis
1
4
Namespace
minio-s3
minio-cluster (coexistent)
Exposition (HTTPS via main-gateway, wildcard *.lab.example.io) :
Service
URL
Port
API S3
https://minio-cluster.lab.example.io
9000
Console admin
https://minio-cluster-console.lab.example.io
9001
En interne : http://minio.minio-cluster.svc.cluster.local:9000.
replicas: 4 + anti-affinité requiredDuringScheduling sur kubernetes.io/hostname (1 pod max par node) ; les control planes sont taintés NoSchedule donc ne comptent pas
kubectl get nodes -l '!node-role.kubernetes.io/control-plane'
les 4 PVC du volumeClaimTemplates ; le script s'arrête sans elle
kubectl get storageclass local-path
main-gateway + écouteur https + cert wildcard
les deux HTTPRoute
kubectl get gateway -n envoy-gateway-system
DNS minio-cluster + minio-cluster-console → 192.168.56.200
atteindre le VIP Envoy
getent hosts minio-cluster.lab.example.io
⚠️ Passer WORKERS=4 (ou plus) dans lab.env avant de monter le cluster.
Le lab.env.example du lab livre WORKERS=3, et minio-cluster-up.sh ne fait qu'avertir (un
simple echo, pas un exit) s'il y a moins de 4 workers. Il applique quand même le
manifeste : le 4ᵉ pod reste Pending pour toujours (anti-affinité stricte, plus aucun node
éligible), donc le rollout status --timeout=300séchoue au bout de 5 minutes et le
déploiement démarre au mieux dégradé dès le premier jour (3 drives sur 4, zéro marge : une
panne de plus et l'erasure set perd son quorum d'écriture). Changer WORKERS demande un
vagrant destroy + remontée du cluster (cf. CLAUDE.md) : décide avant.
Aucune spécificité de distribution pour ce composant : mêmes charts, mêmes manifestes,
mêmes valeurs sur les deux labs. La distribution passée en argument ne sert ici qu'à deux
choses : le domaine par défaut (talos.lab.example.io / kubeadm.lab.example.io) et la
localisation du lab.env / kubeconfig du lab (../Vagrant-Talos/ ou
../Vagrant-KubeADM/).
ℹ️ Contrainte de topologie, pas de distribution : l'anti-affinité impose 1 pod par node,
donc 4 workers Ready sont nécessaires (sinon des pods restent Pending).
Les commandes ci-dessous sont exactement ce que fait le script tout-en-un, dans
l'ordre. Prépare d'abord l'environnement (une fois par session) :
exportKUBECONFIG=../Vagrant-Talos/kubeconfig# ou ../Vagrant-KubeADM/kubeconfigexportLAB_DOMAIN=talos.lab.example.io# ou ton domaine (cf. lab.env du lab)
1. Vérifier les prérequis (stockage + nombre de workers)#
sed"s/lab\.example\.io/${LAB_DOMAIN}/g"minio-s3/cluster/minio-cluster.yaml|kubectlapply-f-
kubectl-nminio-clusterrolloutstatusstatefulset/minio--timeout=300s
kubectl-nminio-clustergetpods-owide# 1 pod par node
MinIO exige un minimum de 4 drives par erasure set, répartis uniformément entre les
nœuds. Avec 1 drive par pod (le pattern K8s propre sur local-path) :
3 nœuds × 1 drive = 3 drives → sous le minimum et non uniforme → refusé.
4 nœuds × 1 drive = 4 drives → 1 erasure set de 4, EC:2 → le minimum naturel.
3 nœuds ne redevient possible qu'avec ≥ 2 drives/nœud (3 × 2 = 6 drives = 1 set de 6),
c.-à-d. 2 PVC par pod : plus complexe, non retenu ici.
ℹ️ EC:2 sur 4 drives = 2 données + 2 parités. On peut perdre jusqu'à 2 drives/nœuds en
conservant la lecture ; l'écriture demande un quorum (≥ moitié + 1 des drives).
StatefulSet minio (podManagementPolicy: Parallel — les 4 pods démarrent ENSEMBLE et s'attendent)
minio-0 @ worker A ─ PVC data-minio-0 (local-path 10Gi) ┐
minio-1 @ worker B ─ PVC data-minio-1 ├─ 1 pool, 1 erasure set de 4, EC:2
minio-2 @ worker C ─ PVC data-minio-2 │
minio-3 @ worker D ─ PVC data-minio-3 ┘
▲ découverte des pairs via le Service HEADLESS minio-hl :
server http://minio-{0...3}.minio-hl.minio-cluster.svc.cluster.local:9000/data
Service minio (ClusterIP) ── équilibre sur les 4 pods ── HTTPRoutes (API + console)
Points clés du manifeste :
podManagementPolicy: Parallel (obligatoire) : en OrderedReady, minio-0 ne serait
jamais « ready » sans ses pairs → interblocage. En parallèle, les 4 bootent et forment le quorum.
Service headless minio-hl (clusterIP: None, publishNotReadyAddresses: true) : DNS
stable par pod, résolu avant que les pods soient ready.
Puis repointer les jobs de backup (MINIO_ENDPOINT / endpointURL) vers
http://minio.minio-cluster.svc.cluster.local:9000 — c'est déjà le cas des scripts de
../../cloudnative-pg/ — et décommissionner le standalone.
Moins de 4 workers = installation qui ne converge jamais : le 4ᵉ pod reste Pending
(anti-affinité required…), rollout status échoue après 5 min, et l'erasure set tourne
d'emblée à 3/4 drives. Le script n'échoue pas à ce stade (simple echo d'avertissement,
minio-cluster-up.sh) : ne pas conclure de son démarrage silencieux que la topologie est bonne.
minio-cluster-up.sh affiche l'utilisateur et le mot de passe root en clair sur stdout.
Les relire plutôt depuis le Secret (tableau Accès).
Les 4 × 10 Gi ne sont pas des quotas.local-path = dossier hostPath, la taille du PVC
n'est jamais appliquée. L'ephemeral-storage allocatable mesuré ici est de ~16,9 Go par
node (disque de 20 Go partagé avec l'OS et les images) : remplir les buckets provoque du
DiskPressure et l'éviction de pods sur les workers concernés — donc potentiellement la
perte simultanée de plusieurs drives MinIO. Surveiller :
Drives node-local : perdre un worker = perdre un drive. EC:2 encaisse 2 pertes, pas plus ;
un vagrant destroy en encaisse 4 (et les données avec).
Scaling : MinIO grossit par ajout de server pools (≥ 4 drives par pool), jamais par
ajout d'un drive isolé. Pour agrandir, déclarer un 2ᵉ pool dans les args
(… /data http://minio2-{0...3}…/data).
Perf : 1 drive/pod sur un disque node-local partagé avec l'OS — suffisant pour un lab,
pas pour de la charge réelle.
Console derrière un hostname distinct : MINIO_BROWSER_REDIRECT_URL porte
https://minio-cluster-console.lab.example.io dans le manifeste — domaine neutre du
dépôt public, substitué par minio-cluster-up.sh depuis LAB_DOMAIN (lab.env) en même temps
que les hostnames des HTTPRoute. Un kubectl apply direct garde le domaine d'exemple et
casse les redirections de login. Cf. ../../LISEZ-MOI.md.
⛵velero/ — backup and restore of the cluster (objects and Longhorn PV data) to MinIO
Velero writes two things into the same S3 bucket: the Kubernetes objects (every manifest,
namespaced and cluster-scoped) and the content of the persistent volumes — Longhorn
included — through File System Backup. A namespace deleted by mistake, PVC and data alike,
comes back with a single velero restore create — or with three clicks in the UI installed
alongside, at velero.<LAB_DOMAIN>.
🌐 Two halves, two exposure models. Backups never leave the pod network: Velero reaches
MinIO through the in-cluster Service, so schedule.yaml carries no domain and applying it by
hand is exactly what the script does. The UI does carry the neutral domain
(ui-httproute.yaml), substituted on the fly like everywhere else.
Backing up a Kubernetes cluster is two problems that people keep conflating:
What
Where it lives
How Velero takes it
The objects — Deployments, Secrets, PVC definitions, CRDs, ClusterRoles…
etcd
listed through the API server, written as one tarball per backup into the bucket
The data — the bytes inside a Longhorn (or local-path) volume
the workers' disks
File System Backup: the node-agent DaemonSet reads the volume where the kubelet already mounted it and uploads it to the same bucket with kopia
Restoring only the first gives you a cluster full of empty PVCs. This component does both, and
does them in one place — the velero bucket of the lab's MinIO.
That distinction is why there are two schedules rather than one.
Schedule
Cron
What it writes
TTL
Cost per tick
hourly-objects
0 * * * *
the objects only
48h
one tarball, a few MB, seconds
daily-full
0 2 * * *
objects + every pod volume
168h
the node-agent re-reads every volume
So: a ~1-hour RPO on "someone deleted a Deployment / a Secret / a whole namespace", and a
~24-hour RPO on "the bytes inside a PVC are wrong". Backing the manifests up hourly is
nearly free; doing the same with the volume data would keep the node-agent busy around the
clock for a lab that changes a few files a day.
⚠️ An hourly-objects backup contains the PVC and PV definitions, not their contents.
Restoring from one recreates the PVCs empty (Longhorn provisions fresh volumes). It is the
right choice for "undo my last kubectl apply", and the wrong one for "get my data back" —
for that, restore the latest daily-full. velero backup describe <name> spells out which
kind you are looking at, and the volume count in kubectl -n velero get podvolumebackups
is zero for every hourly one.
velero-ui (otwld) ships with the component and lands in the
same namespace, at https://velero.<LAB_DOMAIN>. It is not decoration: without it, reading a
backup means velero backup describe --details, and that command does not work from your
workstation in this lab (it mints a presigned URL against an in-cluster DNS name — see the
pitfalls). The UI runs inside the cluster, so it has no such problem.
CLI
UI
Browse backups, schedules, PodVolumeBackups
yes
yes, with their status live
Read the errors of a PartiallyFailed backup
❌ broken from the host (presigned URL)
✅ it is in-cluster
Create a backup / launch a restore
yes
yes, form-driven
Scripting, CI
✅
❌
⚠️ Its ServiceAccount is cluster-admin. A tool that restores arbitrary objects into
arbitrary namespaces cannot be anything else — Velero's own ServiceAccount has the same rights.
But it means the dashboard is a full cluster-takeover path behind a single password. Fine on a
lab; VELERO_UI=false if you would rather not.
deployNodeAgent: true + defaultVolumesToFsBackup: true: every pod volume is backed up
without any per-workload annotation, and the object store is
http://minio.minio-cluster.svc.cluster.local:9000, reached through the pod network only.
inside Longhorn, on the very worker disks being protected
in MinIO, i.e. outside the volume being protected
Survives vagrant destroy
❌ no
✅ yes
Extra prerequisites
the external-snapshotter controller + a VolumeSnapshotClass — neither is installed by this lab
none beyond the node-agent DaemonSet
Storage classes covered
those with a CSI driver that supports snapshots
all of them — longhorn, longhorn-r1, local-path
Cost
instant (copy-on-write)
reads and uploads the bytes, deduplicated by kopia
A snapshot is a rollback mechanism, a backup is a copy elsewhere mechanism. On a lab whose
whole point is that it gets destroyed and rebuilt, only the second one is worth the name.
ℹ️ Longhorn also has its own S3 backup target, configured in its UI. It is a fine tool
and it covers Longhorn volumes only — not the manifests, not local-path. The two coexist
without conflict; this component deliberately covers the whole cluster instead.
A MinIO in the cluster — ../minio-s3/cluster/ preferred, ../minio-s3/ accepted
the object store. velero-up.sh picks minio-cluster first, falls back to minio-s3, and VELERO_MINIO_NS=… overrides both
kubectl -n minio-cluster get svc minio
Namespace velero with PodSecurity privileged
the node-agent mounts the kubelet's pod directory (hostPath), which baseline forbids — applied by velero-up.sh
kubectl get ns velero --show-labels
helm, curl, openssl in PATH
the chart, the mc download, the generated access key
helm version
mc (MinIO client)
creates the bucket and the scoped user — downloaded automatically if missing
mc --version
velero CLI — optional
everything here is plain CRDs, but a restore without the CLI is painful
velero version --client-only
A storage add-on, if you want volume data to back up
with no PVC in the cluster, Velero backs up objects only — which is not a failure, just an empty half
kubectl get pvc -A
The platform (main-gateway + wildcard TLS) — only for the UI
the dashboard's HTTPRoute. Missing it costs you the route and nothing else: the script warns, skips it and finishes
kubectl -n envoy-gateway-system get gateway main-gateway
ℹ️ The backups need no LoadBalancer IP, no Gateway and no DNS record. Velero talks to
minio.<ns>.svc.cluster.local:9000, a ClusterIP: that half behaves identically whether the
LoadBalancer IPs of the lab come from Cilium's L2 announcer or from ../metallb/,
and it keeps working when neither is installed. Only the UI needs the Gateway — and it
degrades to a port-forward rather than failing the install.
Idempotent: helm upgrade --install + kubectl apply; the MinIO user keeps its existing
access key (re-minting it on every run would silently 403 every upload) and the UI keeps its
password for the same reason — one you regenerate every run is one nobody can write down.
Variable
Default
Effect
VELERO_VERSION
12.1.0
chart version
VELERO_AWS_PLUGIN_VERSION
v1.14.2
object store plugin — must match the Velero minor (v1.14.x ↔ v1.18.x)
VELERO_MINIO_NS
detected
which MinIO to target (minio-cluster, then minio-s3)
VELERO_BUCKET
velero
bucket name — created if absent
VELERO_S3_USER
velero
MinIO user, scoped to that bucket alone
VELERO_NS
velero
Velero's own namespace — the UI lands there too
VELERO_UI
true
false skips the dashboard entirely (no chart, no Secret, no route)
VELERO_UI_VERSION
0.15.0
otwld/velero-ui chart version
VELERO_UI_USER
admin
the dashboard's basic-auth login
MC_ALIAS
labminio
the transient mc alias for the port-forward. Must start with a letter — mc rejects the name otherwise (this is why the older _lab broke the install), and it deliberately avoids plain lab so it never clobbers your own alias
No divergence in the install: the same 5 steps, the same charts, the same values on both
labs. The single line that would break on Talos if it were left implicit is carried by a
profile variable, VELERO_POD_VOLUME_PATH.
Talos
kubeadm
node-agent hostPath
/var/lib/kubelet/pods — works because the kubelet root dir sits under /var, the only writable filesystem (/ and /usr are read-only)
/var/lib/kubelet/pods — the upstream path on an ordinary filesystem, nothing special about it
privileged PodSecurity label on velero
required: baseline is enforced cluster-wide and forbids hostPath volumes. Without the label the DaemonSet exists and creates no pod — silently
documents the need; enforces nothing today
Host tooling
kubectl, helm, curl, openssl (mc auto-downloaded)
identical — no talosctl, Velero never touches the node configuration
Script steps
5
5
The UI
plain Deployment, no hostPath, no privilege — installs identically
identical
What ends up in the backup
identical: the objects, plus every pod volume that is mounted
identical
Why a variable for a value that is the same on both sides: it turns "the chart default happens
to work on Talos" into a checked fact. An installer image that moved the kubelet root
(--root-dir) would need exactly one line changed, in lib/profiles/talos.sh, and not a single
if in this component — the rule this repository applies to every divergence
(see ../README.md).
⚠️ On Talos, skipping this label is the classic silent failure: kubectl -n velero get ds
shows DESIRED 6 / READY 0 and no pod at all. The reason is in the ReplicaSet/DaemonSet
events, nowhere else: violates PodSecurity "baseline": hostPath volumes.
helmrepoaddvmware-tanzuhttps://vmware-tanzu.github.io/helm-charts&&helmrepoupdatevmware-tanzu
# values.yaml defaults to minio-cluster; point it at yours if it lives elsewhere:
sed"s#minio\.minio-cluster\.svc#minio.${MINIO_NS}.svc#"velero/values.yaml>/tmp/velero-values.yaml
helmupgrade--installvelerovmware-tanzu/velero\--namespacevelero\--version12.1.0\--values/tmp/velero-values.yaml\--setnodeAgent.podVolumePath=/var/lib/kubelet/pods\--wait--timeout10m
kubectl-nvelerorolloutstatusdeploy/velero
kubectl-nvelerorolloutstatusds/node-agent
⚠️ The chart creates a JWT passphrase Secret and never mounts it. On 0.15.0,
helm template shows no AUTH_SECRET_PASSPHRASE anywhere in the Deployment: the app falls
back to its documented default (this is not a secret passphrase) and signs predictable
tokens, while a velero-ui-passphrase Secret sits in the namespace suggesting otherwise.
ui-values.yaml turns that mechanism off and wires the variable through env: instead.
Re-check it after a chart bump:
namespace velero + the three privileged PodSecurity labels
[2/5]
MinIO: bucket velero, policy velero-rw, user velero scoped to it, then the velero-s3 Secret
[3/5]
Helm chart with the resolved endpoint, then waits for BackupStorageLocation: Available
[4/5]
kubectl apply -f schedule.yaml — both Schedules (hourly-objects, daily-full)
[5/5]
velero-ui-auth Secret (generated once), the UI chart, and its HTTPRoute — skipped with VELERO_UI=false, route alone skipped when the Gateway API is absent
the Velero image ships no provider plugin; without it the server loops on unable to locate ObjectStore plugin for aws
config.s3ForcePathStyle
"true"
MinIO serves a bucket as a path (minio:9000/velero), not as a subdomain
config.region
us-east-1
MinIO ignores it, the AWS SDK refuses to sign without one
deployNodeAgent
true
no DaemonSet, no volume data — objects only
defaultVolumesToFsBackup
true
covers every pod volume with no per-workload annotation — and it is a server-level default, which is exactly why hourly-objects has to set it back to false explicitly
uploaderType
kopia
deduplicated and compressed; restic is legacy
snapshotsEnabled
false
no VolumeSnapshotLocation: there is no snapshot controller in this lab, and an AWS-provider VSL would only produce errors
defaultBackupTTL
168h
7 days — every byte lands on the workers' shared disk
kubectl-nvelerogetbackupstoragelocationdefault# PHASE=Available
kubectl-nvelerogetpods# velero + velero-ui + one node-agent PER NODE
kubectl-nvelerogetschedules# hourly-objects + daily-full# The real proof: a backup that completes, with its volumes
velerobackupcreatesmoke--wait
velerobackupdescribesmoke--details|sed-n'/Phase/p;/Item/p'
kubectl-nvelerogetpodvolumebackups# one line per mounted volume, Completed
Check that the two cadences really differ — the count of PodVolumeBackups is the tell:
# objects only: expect ZERO PodVolumeBackups for this backup
velerobackupcreateobjs-only--snapshot-volumes=false--default-volumes-to-fs-backup=false--wait
kubectl-nvelerogetpodvolumebackups-lvelero.io/backup-name=objs-only# "No resources found"# objects + data: expect one PodVolumeBackup per mounted volume
velerobackupcreatefull-now--default-volumes-to-fs-backup--wait
kubectl-nvelerogetpodvolumebackups-lvelero.io/backup-name=full-now# all Completed
Without the CLI, the same thing with kubectl only:
The UI, without depending on your DNS resolving the wildcard:
GWIP=192.168.56.200
PASS=$(kubectl-nvelerogetsecretvelero-ui-auth-ojsonpath='{.data.password}'|base64-d)
curl-sk-o/dev/null-w'%{http_code}\n'\--resolve"velero.$LAB_DOMAIN:443:$GWIP""https://velero.$LAB_DOMAIN/"# 200# The real proof — the API answers with a token, and that token reads Velero's objects:TOKEN=$(curl-sk--resolve"velero.$LAB_DOMAIN:443:$GWIP"\-XPOST"https://velero.$LAB_DOMAIN/api/auth/login"-H'Content-Type: application/json'\-d"{\"username\":\"admin\",\"password\":\"$PASS\"}"|sed-n's/.*"access_token":"\([^"]*\)".*/\1/p')
curl-sk-o/dev/null-w'%{http_code}\n'--resolve"velero.$LAB_DOMAIN:443:$GWIP"\-H"Authorization: Bearer $TOKEN""https://velero.$LAB_DOMAIN/api/backups"# 200
⚠️ --resolve is not optional. The https listener is scoped to *.<LAB_DOMAIN>, so a
request to the bare IP sends no SNI and Envoy has no listener to hand it to: curl -k https://192.168.56.200/ returns 000, not a 404. Nothing is broken — the request never
reached a route.
🧪 Scenario — delete a namespace with its Longhorn data, and get it back#
The demo that justifies the component. It needs a storage class
(../longhorn/; local-path works too).
# 1. An application with a volume, and a byte we can recognise
kubectlcreatenamespacedemo-backup
kubectlapply-ndemo-backup-f-<<'EOF'apiVersion: v1kind: PersistentVolumeClaimmetadata: { name: data }spec: accessModes: ["ReadWriteOnce"] storageClassName: longhorn resources: { requests: { storage: 1Gi } }---apiVersion: apps/v1kind: Deploymentmetadata: { name: writer }spec: replicas: 1 selector: { matchLabels: { app: writer } } template: metadata: { labels: { app: writer } } spec: containers: - name: sh image: busybox:1.37 command: ["sh", "-c", "sleep infinity"] volumeMounts: [{ name: data, mountPath: /data }] volumes: - { name: data, persistentVolumeClaim: { claimName: data } }EOF
kubectl-ndemo-backuprolloutstatusdeploy/writer
kubectl-ndemo-backupexecdeploy/writer--sh-c'echo "precious" > /data/proof.txt'# 2. Back it up — the PVC definition AND the bytes
velerobackupcreatedemo-backup-1--include-namespacesdemo-backup--wait
kubectl-nvelerogetpodvolumebackups# one Completed line for the `data` volume# 3. The disaster
kubectldeletenamespacedemo-backup
kubectlgetpvc-ndemo-backup# gone, volume included# 4. The restore
velerorestorecreate--from-backupdemo-backup-1--wait
kubectl-ndemo-backuprolloutstatusdeploy/writer
kubectl-ndemo-backupexecdeploy/writer--cat/data/proof.txt# precious# 5. Clean up
kubectldeletenamespacedemo-backup
ℹ️ Step 4 is where FSB shows itself: Velero recreates the PVC, then the node-agent injects
a restore-wait init container into the pod, which blocks startup until the data has been
written back. A pod stuck in Init:0/1 for a while is normal — watch
kubectl -n velero get podvolumerestores.
FSB only backs up a volume that a pod is mounting. A PVC bound to nothing — or bound to a
pod that is scaled to zero — has its definition backed up and its content skipped, with
no error. Scale the workload up before backing up data you care about, and check
kubectl -n velero get podvolumebackups rather than trusting Phase: Completed on the
Backup alone.
hostPath volumes are never backed up by FSB (by design). In this lab that mostly means
the system DaemonSets, which is fine — but do not expect local-pathnode directories to
come back through anything other than their PVC.
defaultVolumesToFsBackup: true also picks up emptyDirs. Prometheus' WAL and similar
scratch volumes will be uploaded. Opt a pod out volume by volume:
Do not count on Velero to restore MinIO itself. The backup of the minio-cluster
namespace lives inminio-cluster: if MinIO is gone, so is the backup. MinIO's own
resilience is its erasure coding, not this component.
A restored LoadBalancer Service does not necessarily get the same IP. The announcer
(Cilium L2 or MetalLB) re-allocates from the pool. If a DNS record points at .200, check
where the Gateway landed after a full-cluster restore.
A full-cluster restore includes the velero namespace unless you exclude it. Into a
fresh cluster, use velero restore create --from-backup <b> --exclude-namespaces velero,
otherwise the restore fights the Velero that is running it.
The Schedule cron is read in the server's timezone (UTC in this lab), not your
workstation's. 0 2 * * * is 13:00 in Nouméa, and hourly-objects fires on the UTC hour.
Restoring the newest backup is not always what you want. With two schedules the latest
backup is almost always an hourly-objects one, which carries no volume data. Pick
deliberately: kubectl -n velero get backups.velero.io --sort-by=.metadata.creationTimestamp then
restore from the last daily-full-* if you need the PVC contents back.
kubectl get backups is AMBIGUOUS in this lab and silently answers about Longhorn.
Three installed CRDs claim the Backup kind — longhorn.io, velero.io and
postgresql.cnpg.io — and kubectl resolves the bare plural to Longhorn's. So
kubectl -n velero get backups prints No resources found in velero namespace. while
Velero's backups sit right there: a perfectly convincing false negative. Always qualify
it: kubectl -n velero get backups.velero.io. podvolumebackups and schedules are
unambiguous, which is what makes the trap so easy to walk into.
velero backup logs / describe --details fail from your workstation. They ask the
API server for a presigned URL, which is minted against the BackupStorageLocation's
endpoint — http://minio.<ns>.svc.cluster.local:9000, an in-cluster DNS name your host
cannot resolve (dial tcp: lookup … no such host). The backup itself is fine. To read the
logs or the error list, go through the bucket instead:
velero backup delete removes the objects from the bucket too; kubectl delete backup
removes only the Kubernetes object and leaves the tarball orphaned in MinIO. The UI's delete
button is the velero one — it empties the bucket entry as well.
The UI is cluster-admin behind one password. It restores arbitrary objects into
arbitrary namespaces, so it cannot be less; that is the same reason Velero's own
ServiceAccount has those rights. What is worth remembering is the consequence: whoever reaches
velero.<LAB_DOMAIN> and guesses the password owns the cluster. The password is generated
(24 random characters), never admin/admin — the chart's documented default, which this
component overrides. VELERO_UI=false removes the surface entirely.
Deleting the velero-ui-auth Secret does not log anyone out. Issued JWTs stay valid until
they expire (1 h by default), because they are verified against the passphrase, not against
the Secret. To really cut access, rotate both — delete the Secret and re-run the script, and
the new passphrase invalidates every outstanding token.
The TTLs are 48 h (hourly) and 7 days (daily). A lab left off for a fortnight comes back
with an empty bucket — the GC runs on Velero's clock, not on how much you needed that backup.
24 hourly ticks a day is 24 more Backup objects a day. At 48h TTL that settles around
~50 objects; kubectl -n velero get backups.velero.io gets noisy long before it gets expensive. Filter
with -l velero.io/schedule-name=daily-full when you only care about the restorable-with-data
ones.
kubectldelete-fvelero/schedule.yaml
helmuninstallvelero-ui-nvelero# the UI alone, to keep backing up without a dashboard
helmuninstallvelero-nvelero
kubectldeletenamespacevelero# also drops the CRs; the CRDs survive
kubectlgetcrd|sed-n'/velero.io/p'|awk'{print $1}'|xargs-rkubectldeletecrd
# The bucket is NOT deleted: mc rb --force labminio/velero
ℹ️ The UI's cluster-admin binding is a cluster-scoped object: it is not swept away by
deleting the namespace. helm uninstall does remove it — check it really went, since that is
the object the escalation-path pitfall is about:
kubectl get clusterrolebinding velero-ui.
⛵velero/ — sauvegarde et restauration du cluster (objets et données des PV Longhorn) vers MinIO
Velero écrit deux choses dans le même bucket S3 : les objets Kubernetes (tous les
manifestes, namespacés et cluster-scoped) et le contenu des volumes persistants — Longhorn
compris — via le File System Backup. Un namespace supprimé par erreur, PVC et données
incluses, revient avec un seul velero restore create — ou en trois clics dans l'UI installée
avec, sur velero.<LAB_DOMAIN>.
🌐 Deux moitiés, deux modes d'exposition. Les sauvegardes ne quittent jamais le réseau de
pods : Velero joint MinIO par le Service interne, donc schedule.yaml ne porte aucun domaine
et un kubectl apply à la main fait exactement ce que fait le script. L'UI, elle, porte
bien le domaine neutre (ui-httproute.yaml), substitué à la volée comme partout ailleurs.
Sauvegarder un cluster Kubernetes, ce sont deux problèmes qu'on confond en permanence :
Quoi
Où ça vit
Comment Velero le prend
Les objets — Deployments, Secrets, définitions de PVC, CRD, ClusterRole…
etcd
listés via l'API server, écrits sous forme d'un tarball par sauvegarde dans le bucket
Les données — les octets à l'intérieur d'un volume Longhorn (ou local-path)
le disque des workers
File System Backup : le DaemonSet node-agent lit le volume là où le kubelet l'a déjà monté et l'envoie dans le même bucket avec kopia
Ne restaurer que le premier, c'est obtenir un cluster plein de PVC vides. Cet addon fait les
deux, et les fait au même endroit — le bucket velero du MinIO du lab.
C'est cette distinction qui impose deux schedules plutôt qu'un seul.
Schedule
Cron
Ce qu'il écrit
TTL
Coût par tick
hourly-objects
0 * * * *
les objets seulement
48h
un tarball, quelques Mo, des secondes
daily-full
0 2 * * *
les objets + tous les volumes de pods
168h
le node-agent relit chaque volume
Donc : un RPO d'environ 1 heure sur « quelqu'un a supprimé un Deployment / un Secret / un
namespace entier », et un RPO d'environ 24 heures sur « les octets dans un PVC sont faux ».
Sauvegarder les manifestes chaque heure est quasi gratuit ; faire pareil avec les données des
volumes occuperait le node-agent en permanence pour un lab qui change quelques fichiers par
jour.
⚠️ Une sauvegarde hourly-objects contient les définitions de PVC et de PV, pas leur
contenu. Restaurer depuis l'une d'elles recrée les PVC vides (Longhorn provisionne des
volumes neufs). C'est le bon choix pour « annuler mon dernier kubectl apply », et le mauvais
pour « récupérer mes données » — pour ça, restaure le dernier daily-full.
velero backup describe <nom> dit de quel type il s'agit, et le nombre de
PodVolumeBackup (kubectl -n velero get podvolumebackups) vaut zéro pour toutes les
horaires.
velero-ui (otwld) est livrée avec l'addon et atterrit dans
le même namespace, sur https://velero.<LAB_DOMAIN>. Ce n'est pas de la décoration : sans elle,
lire une sauvegarde passe par velero backup describe --details, et cette commande ne
fonctionne pas depuis votre poste dans ce lab (elle fabrique une URL présignée contre un nom DNS
interne au cluster — voir les pièges). L'UI, elle, tourne dans le cluster : le problème ne se
pose pas.
CLI
UI
Parcourir sauvegardes, schedules, PodVolumeBackup
oui
oui, avec leur état en direct
Lire les erreurs d'une sauvegarde PartiallyFailed
❌ cassé depuis l'hôte (URL présignée)
✅ elle est dans le cluster
Créer une sauvegarde / lancer une restauration
oui
oui, par formulaire
Scripting, CI
✅
❌
⚠️ Son ServiceAccount est cluster-admin. Un outil qui restaure des objets arbitraires
dans des namespaces arbitraires ne peut pas être moins que ça — le ServiceAccount de Velero
lui-même a les mêmes droits. Mais ça veut dire que le tableau de bord est un chemin de prise de
contrôle complet derrière un seul mot de passe. Acceptable sur un lab ; VELERO_UI=false si
vous préférez vous en passer.
deployNodeAgent: true + defaultVolumesToFsBackup: true : chaque volume de pod est sauvegardé
sans aucune annotation par application, et le magasin d'objets est
http://minio.minio-cluster.svc.cluster.local:9000, joint uniquement par le réseau de pods.
Pourquoi le File System Backup plutôt que les snapshots CSI#
Snapshot CSI
File System Backup (ce qu'on utilise)
Où atterrit la copie
dans Longhorn, sur les disques des workers qu'on protège
dans MinIO, donc en dehors du volume protégé
Survit à un vagrant destroy
❌ non
✅ oui
Prérequis supplémentaires
le contrôleur external-snapshotter + une VolumeSnapshotClass — aucun des deux n'est installé par ce lab
rien de plus que le DaemonSet node-agent
StorageClass couvertes
celles dont le driver CSI gère les snapshots
toutes — longhorn, longhorn-r1, local-path
Coût
instantané (copy-on-write)
lit et envoie les octets, dédupliqués par kopia
Un snapshot est un mécanisme de retour arrière, une sauvegarde est un mécanisme de copie
ailleurs. Sur un lab dont tout l'intérêt est d'être détruit et reconstruit, seul le second
mérite le nom.
ℹ️ Longhorn a aussi sa propre cible de sauvegarde S3, configurable dans son UI. C'est un
bon outil, et il ne couvre que les volumes Longhorn — ni les manifestes, ni local-path. Les
deux cohabitent sans conflit ; cet addon couvre délibérément tout le cluster à la place.
Un MinIO dans le cluster — ../minio-s3/cluster/ de préférence, ../minio-s3/ accepté
le magasin d'objets. velero-up.sh prend minio-cluster d'abord, se rabat sur minio-s3, et VELERO_MINIO_NS=… force les deux
kubectl -n minio-cluster get svc minio
Namespace velero en PodSecurity privileged
le node-agent monte le dossier de pods du kubelet (hostPath), ce que baseline interdit — posé par velero-up.sh
kubectl get ns velero --show-labels
helm, curl, openssl dans le PATH
le chart, le téléchargement de mc, la clé d'accès générée
helm version
mc (client MinIO)
crée le bucket et l'utilisateur restreint — téléchargé automatiquement s'il manque
mc --version
CLI velero — optionnelle
tout ici est en CRD pures, mais une restauration sans la CLI est pénible
velero version --client-only
Un addon de stockage, si vous voulez des données de volume à sauvegarder
sans PVC dans le cluster, Velero ne sauvegarde que les objets — ce n'est pas une panne, juste une moitié vide
kubectl get pvc -A
La plateforme (main-gateway + TLS wildcard) — pour l'UI seulement
la HTTPRoute du tableau de bord. S'en passer coûte la route et rien d'autre : le script prévient, la saute et va au bout
kubectl -n envoy-gateway-system get gateway main-gateway
ℹ️ Les sauvegardes n'ont besoin ni d'IP LoadBalancer, ni de Gateway, ni d'enregistrement
DNS. Velero parle à minio.<ns>.svc.cluster.local:9000, une ClusterIP : cette moitié se
comporte à l'identique que les IP LoadBalancer du lab viennent de l'annonceur L2 de Cilium ou
de ../metallb/, et elle continue de fonctionner quand aucun des
deux n'est installé. Seule l'UI a besoin de la Gateway — et elle se rabat sur un
port-forward au lieu de faire échouer l'installation.
L'UI vient avec, sur https://velero.<LAB_DOMAIN>, utilisateur admin. Le mot de passe est
généré à la première installation et conservé d'un passage à l'autre :
Idempotent : helm upgrade --install + kubectl apply ; l'utilisateur MinIO garde sa clé
d'accès existante (en régénérer une à chaque passage ferait silencieusement échouer tous les
envois en 403) et l'UI garde son mot de passe pour la même raison — un mot de passe régénéré à
chaque exécution est un mot de passe que personne ne peut noter.
Variable
Défaut
Effet
VELERO_VERSION
12.1.0
version du chart
VELERO_AWS_PLUGIN_VERSION
v1.14.2
plugin de magasin d'objets — doit correspondre à la mineure de Velero (v1.14.x ↔ v1.18.x)
VELERO_MINIO_NS
détecté
quel MinIO viser (minio-cluster, puis minio-s3)
VELERO_BUCKET
velero
nom du bucket — créé s'il manque
VELERO_S3_USER
velero
utilisateur MinIO, restreint à ce seul bucket
VELERO_NS
velero
namespace de Velero lui-même — l'UI y atterrit aussi
VELERO_UI
true
false saute complètement le tableau de bord (ni chart, ni Secret, ni route)
VELERO_UI_VERSION
0.15.0
version du chart otwld/velero-ui
VELERO_UI_USER
admin
l'identifiant basic-auth du tableau de bord
MC_ALIAS
labminio
l'alias mc temporaire du port-forward. Doit commencer par une lettre — sinon mc refuse le nom (c'est ce qui cassait l'install avec l'ancien _lab), et il évite volontairement lab tout court pour ne jamais écraser votre propre alias
Aucune divergence à l'installation : les mêmes 5 étapes, les mêmes charts, les mêmes valeurs
sur les deux labs. La seule ligne qui casserait sur Talos si on la laissait implicite est portée
par une variable de profil, VELERO_POD_VOLUME_PATH.
Talos
kubeadm
hostPath du node-agent
/var/lib/kubelet/pods — ça marche parce que le dossier racine du kubelet est sous /var, le seul système de fichiers inscriptible (/ et /usr sont en lecture seule)
/var/lib/kubelet/pods — le chemin upstream sur un système de fichiers ordinaire, rien de particulier
Label PodSecurity privileged sur velero
obligatoire : baseline est imposé sur tout le cluster et interdit les volumes hostPath. Sans le label, le DaemonSet existe et ne crée aucun pod — en silence
identique — pas de talosctl, Velero ne touche jamais à la configuration des nodes
Étapes du script
5
5
L'UI
Deployment ordinaire, sans hostPath ni privilège — s'installe à l'identique
identique
Ce qui finit dans la sauvegarde
identique : les objets, plus tout volume de pod monté
identique
Pourquoi une variable pour une valeur identique des deux côtés : ça transforme « le défaut du
chart tombe juste sur Talos » en fait vérifié. Une image d'installation qui déplacerait la
racine du kubelet (--root-dir) demanderait exactement une ligne de changement, dans
lib/profiles/talos.sh, et pas un seul if dans cet addon — la règle que ce dépôt applique à
toute divergence (voir ../LISEZ-MOI.md).
⚠️ Sur Talos, oublier ce label est la panne silencieuse classique :
kubectl -n velero get ds affiche DESIRED 6 / READY 0 et aucun pod. La raison est dans les
événements du DaemonSet, nulle part ailleurs : violates PodSecurity "baseline": hostPath volumes.
On ne donne jamais les credentials root de MinIO à un agent cluster-admin.
ROOTPW=$(kubectl-n"$MINIO_NS"getsecretminio-creds-ojsonpath='{.data.root-password}'|base64-d)
kubectl-n"$MINIO_NS"port-forwardsvc/minio19010:9000&
mcaliassetlabminiohttp://127.0.0.1:19010admin"$ROOTPW"
mcmb--ignore-existinglabminio/velero
cat>/tmp/velero-policy.json<<'JSON'{ "Version":"2012-10-17","Statement":[ {"Effect":"Allow","Action":["s3:*"],"Resource":["arn:aws:s3:::velero","arn:aws:s3:::velero/*"]} ]}JSON
mcadminpolicycreatelabminiovelero-rw/tmp/velero-policy.json
SK=$(opensslrand-base6421|tr-d'/+='|head-c28)
mcadminuseraddlabminiovelero"$SK"
mcadminpolicyattachlabminiovelero-rw--uservelero
kill%1# le port-forward a fait son travail
3. Le Secret de credentials — une clé cloud, un fichier de credentials AWS#
⚠️ Le chart crée un Secret de passphrase JWT et ne le monte jamais. En 0.15.0,
helm template ne montre aucun AUTH_SECRET_PASSPHRASE dans le Deployment : l'application
retombe sur son défaut documenté (this is not a secret passphrase) et signe des jetons
prévisibles, pendant qu'un Secret velero-ui-passphrase traîne dans le namespace en laissant
croire le contraire. ui-values.yaml coupe ce mécanisme et câble la variable via env:.
À revérifier après une montée de version du chart :
namespace velero + les trois labels PodSecurity privileged
[2/5]
MinIO : bucket velero, policy velero-rw, utilisateur velero restreint, puis le Secret velero-s3
[3/5]
chart Helm avec l'endpoint résolu, puis attente de BackupStorageLocation: Available
[4/5]
kubectl apply -f schedule.yaml — les deux Schedule (hourly-objects, daily-full)
[5/5]
Secret velero-ui-auth (généré une fois), le chart de l'UI et sa HTTPRoute — sauté avec VELERO_UI=false, route seule sautée si la Gateway API est absente
l'image Velero n'embarque aucun plugin de provider ; sans lui le serveur boucle sur unable to locate ObjectStore plugin for aws
config.s3ForcePathStyle
"true"
MinIO sert un bucket comme un chemin (minio:9000/velero), pas comme un sous-domaine
config.region
us-east-1
MinIO l'ignore, le SDK AWS refuse de signer sans
deployNodeAgent
true
pas de DaemonSet, pas de données de volume — les objets seuls
defaultVolumesToFsBackup
true
couvre chaque volume de pod sans annotation par application — et c'est un défaut au niveau du serveur, ce qui est précisément pourquoi hourly-objects doit le repasser explicitement à false
uploaderType
kopia
dédupliqué et compressé ; restic est l'ancien monde
snapshotsEnabled
false
pas de VolumeSnapshotLocation : il n'y a pas de contrôleur de snapshot dans ce lab, et une VSL en provider AWS ne produirait que des erreurs
defaultBackupTTL
168h
7 jours — chaque octet atterrit sur le disque partagé des workers
kubectl-nvelerogetbackupstoragelocationdefault# PHASE=Available
kubectl-nvelerogetpods# velero + velero-ui + un node-agent PAR NODE
kubectl-nvelerogetschedules# hourly-objects + daily-full# La vraie preuve : une sauvegarde qui va au bout, avec ses volumes
velerobackupcreatesmoke--wait
velerobackupdescribesmoke--details|sed-n'/Phase/p;/Item/p'
kubectl-nvelerogetpodvolumebackups# une ligne par volume monté, Completed
Vérifier que les deux cadences diffèrent vraiment — le compte de PodVolumeBackup est le
révélateur :
# objets seuls : on attend ZÉRO PodVolumeBackup pour cette sauvegarde
velerobackupcreateobjs-only--snapshot-volumes=false--default-volumes-to-fs-backup=false--wait
kubectl-nvelerogetpodvolumebackups-lvelero.io/backup-name=objs-only# « No resources found »# objets + données : on attend un PodVolumeBackup par volume monté
velerobackupcreatefull-now--default-volumes-to-fs-backup--wait
kubectl-nvelerogetpodvolumebackups-lvelero.io/backup-name=full-now# tous Completed
L'UI, sans dépendre de la résolution du wildcard par votre DNS :
GWIP=192.168.56.200
PASS=$(kubectl-nvelerogetsecretvelero-ui-auth-ojsonpath='{.data.password}'|base64-d)
curl-sk-o/dev/null-w'%{http_code}\n'\--resolve"velero.$LAB_DOMAIN:443:$GWIP""https://velero.$LAB_DOMAIN/"# 200# La vraie preuve — l'API rend un jeton, et ce jeton lit les objets de Velero :TOKEN=$(curl-sk--resolve"velero.$LAB_DOMAIN:443:$GWIP"\-XPOST"https://velero.$LAB_DOMAIN/api/auth/login"-H'Content-Type: application/json'\-d"{\"username\":\"admin\",\"password\":\"$PASS\"}"|sed-n's/.*"access_token":"\([^"]*\)".*/\1/p')
curl-sk-o/dev/null-w'%{http_code}\n'--resolve"velero.$LAB_DOMAIN:443:$GWIP"\-H"Authorization: Bearer $TOKEN""https://velero.$LAB_DOMAIN/api/backups"# 200
⚠️ Le --resolve n'est pas optionnel. Le listener https est cadré sur
*.<LAB_DOMAIN> : une requête vers l'IP nue n'envoie aucun SNI et Envoy n'a aucun listener à
qui la donner — curl -k https://192.168.56.200/ rend 000, pas un 404. Rien n'est cassé,
la requête n'a simplement jamais atteint une route.
🧪 Scénario — supprimer un namespace avec ses données Longhorn, et le récupérer#
La démo qui justifie l'addon. Elle demande une StorageClass
(../longhorn/ ; local-path marche aussi).
# 1. Une application avec un volume, et un octet reconnaissable
kubectlcreatenamespacedemo-backup
kubectlapply-ndemo-backup-f-<<'EOF'apiVersion: v1kind: PersistentVolumeClaimmetadata: { name: data }spec: accessModes: ["ReadWriteOnce"] storageClassName: longhorn resources: { requests: { storage: 1Gi } }---apiVersion: apps/v1kind: Deploymentmetadata: { name: writer }spec: replicas: 1 selector: { matchLabels: { app: writer } } template: metadata: { labels: { app: writer } } spec: containers: - name: sh image: busybox:1.37 command: ["sh", "-c", "sleep infinity"] volumeMounts: [{ name: data, mountPath: /data }] volumes: - { name: data, persistentVolumeClaim: { claimName: data } }EOF
kubectl-ndemo-backuprolloutstatusdeploy/writer
kubectl-ndemo-backupexecdeploy/writer--sh-c'echo "precieux" > /data/proof.txt'# 2. On sauvegarde — la définition du PVC ET les octets
velerobackupcreatedemo-backup-1--include-namespacesdemo-backup--wait
kubectl-nvelerogetpodvolumebackups# une ligne Completed pour le volume `data`# 3. Le sinistre
kubectldeletenamespacedemo-backup
kubectlgetpvc-ndemo-backup# disparu, volume compris# 4. La restauration
velerorestorecreate--from-backupdemo-backup-1--wait
kubectl-ndemo-backuprolloutstatusdeploy/writer
kubectl-ndemo-backupexecdeploy/writer--cat/data/proof.txt# precieux# 5. Nettoyage
kubectldeletenamespacedemo-backup
ℹ️ L'étape 4 est celle où le FSB se montre : Velero recrée le PVC, puis le node-agent
injecte un init container restore-wait dans le pod, qui bloque le démarrage tant que les
données ne sont pas réécrites. Un pod bloqué en Init:0/1 un moment est normal — surveillez
kubectl -n velero get podvolumerestores.
Sauvegarde en PartiallyFailed, un PodVolumeBackup en Failed
un volume que l'agent ne peut pas lire (hostPath, volume non monté)
kubectl -n velero get podvolumebackups -o wide, puis les logs du pod concerné
Tout tombe en 403 après une réinstallation de MinIO
MinIO a perdu l'utilisateur velero, le Secret porte encore l'ancienne clé
supprimer le Secret et relancer : kubectl -n velero delete secret velero-s3 && ./velero/velero-up.sh <distro>
L'envoi échoue sur une erreur de checksum/signature
certaines implémentations S3 rejettent le checksum par défaut du SDK
ajouter checksumAlgorithm: "" au bloc config: de values.yaml (README du plugin)
velero: command not found
la CLI est optionnelle et pas installée
utiliser les formes kubectl ci-dessus, l'UI, ou l'installer (voir Références)
Le login de l'UI rend 500, pas 401
le mot de passe est faux. La basic auth l'a rejeté, puis l'application est retombée sur sa stratégie LDAP, non configurée (LDAP server URL not defined)
relire le mot de passe : kubectl -n velero get secret velero-ui-auth -o jsonpath='{.data.password}' | base64 -d
https://velero.<LAB_DOMAIN> expire ou rend 000
pas de SNI (IP nue), ou le wildcard ne résout pas chez vous
Le FSB ne sauvegarde qu'un volume monté par un pod. Un PVC lié à rien — ou lié à un pod
scalé à zéro — voit sa définition sauvegardée et son contenu ignoré, sans erreur. Remontez
l'application avant de sauvegarder des données qui comptent, et regardez
kubectl -n velero get podvolumebackups plutôt que de faire confiance au seul
Phase: Completed du Backup.
Les volumes hostPath ne sont jamais sauvegardés par le FSB (par conception). Dans ce lab
ça ne concerne guère que les DaemonSets système, ce qui va bien — mais n'attendez pas que les
dossiers node de local-path reviennent autrement que par leur PVC.
defaultVolumesToFsBackup: true embarque aussi les emptyDir. Le WAL de Prometheus et
autres volumes de travail seront envoyés. Excluez volume par volume :
Ne comptez pas sur Velero pour restaurer MinIO lui-même. La sauvegarde du namespace
minio-cluster vit dansminio-cluster : si MinIO n'est plus là, la sauvegarde non plus.
La résilience de MinIO, c'est son erasure coding, pas cet addon.
Un Service LoadBalancer restauré ne reprend pas forcément la même IP. L'annonceur
(Cilium L2 ou MetalLB) réalloue depuis le pool. Si un enregistrement DNS pointe sur .200,
vérifiez où le Gateway est retombé après une restauration complète.
Une restauration de tout le cluster inclut le namespace velero sauf si on l'exclut. Dans
un cluster neuf, utilisez velero restore create --from-backup <b> --exclude-namespaces velero,
sinon la restauration se bat contre le Velero qui l'exécute.
Le cron du Schedule est lu dans le fuseau du serveur (UTC dans ce lab), pas celui de
votre poste. 0 2 * * *, c'est 13:00 à Nouméa, et hourly-objects part à l'heure UTC pile.
Restaurer la sauvegarde la plus récente n'est pas toujours ce qu'on veut. Avec deux
schedules, la dernière sauvegarde est presque toujours une hourly-objects, qui ne porte
aucune donnée de volume. Choisissez délibérément :
kubectl -n velero get backups.velero.io --sort-by=.metadata.creationTimestamp puis restaurez depuis la
dernière daily-full-* s'il faut récupérer le contenu des PVC.
kubectl get backups est AMBIGU dans ce lab et répond en silence à propos de Longhorn.
Trois CRD installées revendiquent le kind Backup — longhorn.io, velero.io et
postgresql.cnpg.io — et kubectl résout le pluriel nu vers celle de Longhorn. Donc
kubectl -n velero get backups affiche No resources found in velero namespace. alors que
les sauvegardes Velero sont juste là : un faux négatif parfaitement convaincant.
Qualifiez toujours : kubectl -n velero get backups.velero.io. podvolumebackups et
schedules, eux, ne sont pas ambigus — c'est ce qui rend le piège si facile.
velero backup logs / describe --details échouent depuis votre poste. Ils demandent à
l'API server une URL présignée, forgée contre l'endpoint de la BackupStorageLocation —
http://minio.<ns>.svc.cluster.local:9000, un nom DNS interne au cluster que votre hôte ne
peut pas résoudre (dial tcp: lookup … no such host). La sauvegarde elle-même est saine. Pour
lire les logs ou la liste des erreurs, passez par le bucket :
velero backup delete supprime aussi les objets du bucket ; kubectl delete backup ne
supprime que l'objet Kubernetes et laisse le tarball orphelin dans MinIO. Le bouton de
suppression de l'UI est celui de velero — il vide l'entrée du bucket aussi.
L'UI est cluster-admin derrière un seul mot de passe. Elle restaure des objets
arbitraires dans des namespaces arbitraires, donc elle ne peut pas l'être moins ; c'est la même
raison qui donne ces droits au ServiceAccount de Velero. Ce qu'il faut retenir, c'est la
conséquence : qui atteint velero.<LAB_DOMAIN> et devine le mot de passe possède le cluster.
Le mot de passe est généré (24 caractères aléatoires), jamais admin/admin — le défaut
documenté du chart, que cet addon écrase. VELERO_UI=false supprime la surface entièrement.
Supprimer le Secret velero-ui-auth ne déconnecte personne. Les JWT déjà émis restent
valides jusqu'à leur expiration (1 h par défaut), parce qu'ils sont vérifiés contre la
passphrase, pas contre le Secret. Pour vraiment couper l'accès, faites tourner les deux :
supprimez le Secret et relancez le script — la nouvelle passphrase invalide tous les jetons en
circulation.
Les TTL sont de 48 h (horaire) et 7 jours (quotidien). Un lab éteint quinze jours revient
avec un bucket vide — le GC tourne sur l'horloge de Velero, pas sur le besoin qu'on avait de
cette sauvegarde.
24 ticks horaires par jour, ce sont 24 objets Backup de plus par jour. À 48h de TTL ça
se stabilise vers ~50 objets ; kubectl -n velero get backups.velero.io devient bruyant bien avant de
devenir coûteux. Filtrez avec -l velero.io/schedule-name=daily-full quand seules les
sauvegardes restaurables-avec-données vous intéressent.
kubectldelete-fvelero/schedule.yaml
helmuninstallvelero-ui-nvelero# l'UI seule, pour continuer à sauvegarder sans tableau de bord
helmuninstallvelero-nvelero
kubectldeletenamespacevelero# emporte les CR ; les CRD survivent
kubectlgetcrd|sed-n'/velero.io/p'|awk'{print $1}'|xargs-rkubectldeletecrd
# Le bucket n'est PAS supprimé : mc rb --force labminio/velero
ℹ️ Le binding cluster-admin de l'UI est un objet cluster-scoped : supprimer le namespace
ne l'emporte pas. helm uninstall le retire bien — vérifiez qu'il est parti, c'est l'objet
dont parle le piège sur le chemin d'escalade :
kubectl get clusterrolebinding velero-ui.
🐘cloudnative-pg/ — declarative PostgreSQL HA (CloudNativePG operator)
One Cluster CRD = one complete PostgreSQL HA setup. The operator watches that object and
reconciles the actual state: it creates the pods, mounts the PVCs, elects a primary, attaches
streaming replicas, and fails over on its own if the primary dies. The textbook case of the
operator pattern.
Demonstrate the operator pattern: you describe the desired state, the controller does the
rest (provisioning, replication, automatic failover, rolling updates, backups).
Show a live failover — a 30 s demo (see 🧪 Scenarios).
Give the other components a real database: credentials rotated by Vault
(../vault-secret-operator/), S3 backups to MinIO (../minio-s3/cluster/).
What gets deployed: the operator (1 pod, ns cnpg-system) plus a demo cluster pg-demo
(ns cnpg-demo, 3 instances = 1 primary + 2 replicas, 1Gi RWO PVCs on longhorn-r1).
Two layers of resilience (keep them apart when teaching)#
PostgreSQL replication (logical): the primary streams its WAL to 2 replicas →
application-level failover if the primary is lost.
Longhorn replication (block): a PVC can be replicated by Longhorn across several
nodes → survives the loss of a disk.
These are two independent mechanisms. This lab's choice: 1 Longhorn replica for the
database PVCs (dedicated longhorn-r1 StorageClass), because PostgreSQL already replicates at
the application level — stacking 3 block replicas × 3 instances = 9 copies of the same dataset,
which fills up the shared OS disk (~20 GB). If a node dies, CNPG rebuilds the lost instance
from the primary. That is the recommended pattern for a database operator on Longhorn, and a good
hook for explaining "application replication vs storage replication" — and when not to double
up.
storage for the 3 instances; the script aborts (exit 1) if it is missing
kubectl get sc longhorn-r1
3 workers
the default anti-affinity places 1 instance per worker
kubectl get nodes
ℹ️ longhorn-r1 is not created by this component.cluster-demo.yaml only
references it; it is defined in longhorn/longhorn-r1-storageclass.yaml.
With fewer than 3 workers, lower instances in cluster-demo.yaml (otherwise one pod stays
Pending).
kubectlapply-flonghorn/longhorn-r1-storageclass.yaml# if not already done
./cloudnative-pg/cloudnative-pg-up.sh<distro>
Version pinned in the script: chart cnpg/cloudnative-pg 0.29.0 (app v1.30.0),
overridable with CNPG_VERSION=…. Idempotent (helm upgrade --install + kubectl apply).
No distribution-specific behaviour for this component: same charts, same manifests, same
values on both labs. The distribution argument only drives two things here: the default
domain (talos.lab.example.io / kubeadm.lab.example.io) and where the lab's lab.env
/ kubeconfig live (../Vagrant-Talos/ or ../Vagrant-KubeADM/).
ℹ️ The prerequisite is the longhorn-r1 StorageClass: Longhorn is where the distribution
differences live (see ../longhorn/).
The commands below are exactly what the all-in-one script does, in order.
Set up your shell first (once per session):
exportKUBECONFIG=../Vagrant-Talos/kubeconfig# or ../Vagrant-KubeADM/kubeconfigexportLAB_DOMAIN=talos.lab.example.io# or your own (see the lab's lab.env)
5. Watch an automatic failover (the heart of the demo)#
kubectl-ncnpg-demogetpods-lcnpg.io/instanceRole=primary# who is primary?
kubectl-ncnpg-demodeletepod<the-primary>
kubectl-ncnpg-demogetclusterpg-demo-w# a replica gets promoted
./install.sh<distro>minio-cluster# the S3 target
./cloudnative-pg/pg-backup-up.sh<distro># WAL + base backup to S3
./cloudnative-pg/pg-app-backup-cnpg-up.sh<distro># application-level backup
checks kubectl/helm, the apiserver, and that the longhorn-r1 SC is present;
installs the operator in cnpg-system with values.yaml, then waits for the rollout;
applies cluster-demo.yaml (namespace cnpg-demo + Clusterpg-demo) and waits for
condition=Ready (300 s max, without failing if the deadline is exceeded).
kubectl-ncnpg-demogetclusterpg-demo-ojsonpath='{.status.currentPrimary}';echo# e.g. pg-demo-1
kubectl-ncnpg-demodeletepodpg-demo-1# kill the primary
watchkubectl-ncnpg-demogetclusterpg-demo# a replica is promoted within seconds
The operator promotes a replica and recreates the old primary as a replica, with no intervention.
Write some data, delete a pod: the PVC is reattached, the data survives. Delete the node (VM):
CNPG rebuilds the instance from the primary (with longhorn-r1 the block is not replicated
elsewhere — PostgreSQL is what catches up, see the two layers above).
The Secret pg-demo-app holds a ready-to-use uri. Ideal for wiring up a demo app, or for
pairing with Vault/VSO for rotated credentials (see ../vault-secret-operator/).
Both push to MinIO and therefore require the ../minio-s3/cluster/ component (namespace
minio-cluster, Secret minio-creds: both scripts read the MinIO root password and create a
bucket plus a dedicated user through a port-forward).
A. Hourly logical backup through the Vault credentials#
pg_dump backup of the vault database, every hour, pushed to the pg-backups bucket —
connecting to PostgreSQL with the credentials rotated by Vault.
CronJob pg-backup-vault-s3 (ns pg-rotate-demo, schedule "0 * * * *")
│ pg_dump "$DATABASE_URL" (Vault creds from Secret pg-rotate-creds, sslmode=require)
▼ vault-<timestamp>.sql.gz
└─ mc cp ──► MinIO bucket pg-backups (dedicated MinIO user pg-backup, scoped to the bucket)
Prerequisites on top of MinIO: Vault rotation already in place (Secret pg-rotate-creds in
pg-rotate-demo — the script refuses to go on without it) and the pg-demo cluster UP.
./cloudnative-pg/pg-backup-up.sh<distro># MinIO bucket + user + Secret minio-backup-creds + CronJob# Trigger an immediate backup to check:
kubectl-npg-rotate-democreatejobpg-backup-now--from=cronjob/pg-backup-vault-s3
kubectl-npg-rotate-demologsjob/pg-backup-now
⚠️ This backup does NOT go through the CloudNativePG CRDs. No Backup/ScheduledBackup,
no barmanObjectStore: it is a pg_dump carried by a plain CronJob, chosen on purpose so
it can use the Vault creds — something the native CNPG backup cannot do.
B. Native CloudNativePG backup (CRD → MinIO, with PITR)#
Physical backup of the whole pg-demo instance (app database included) + continuous WAL
archiving, pushed by barman-cloud. Enables point-in-time restore.
Cluster pg-demo ── spec.backup.barmanObjectStore ──► MinIO bucket cnpg-backups/pg-demo/
├─ base/<timestamp>/data.tar.gz (base backup, triggered by Backup/ScheduledBackup)
└─ wals/.../*.gz (CONTINUOUS WAL archiving => PITR)
ScheduledBackup pg-demo-hourly ── "0 0 * * * *" (6-field cron: sec min h …) ──► base backups
💡 Restore (PITR): you create a newCluster with spec.bootstrap.recovery pointing at
the same barmanObjectStore (+ recoveryTarget for a point in time). You do not restore
"into" the existing cluster. See the CNPG "Recovery" docs.
Cluster stuck at Creating a new replica → normal provisioning (bootstrap + join);
allow 2-5 min. Otherwise check the PVCs (kubectl -n cnpg-demo get pvc) and Longhorn.
A replica stays Pending → the anti-affinity wants 1 instance per worker: not enough
workers. Lower instances or add a worker (WORKERS in lab.env).
PVC Pending → longhorn-r1 StorageClass missing, or Longhorn down (see ../longhorn/).
Volumes Degraded on the Longhorn side → Longhorn defaultReplicaCount > number of
workers.
pg-backup-up.sh: "secret pg-rotate-creds missing" → Vault rotation is not in place: run
through ../vault-secret-operator/ (rotation section) first.
CNPG backup stuck in running/failed → check the archiving condition:
kubectl -n cnpg-demo get cluster pg-demo -o jsonpath='{.status.conditions}'
(ContinuousArchiving must be True), then the logs of the primary instance's sidecar.
Longhorn faulted / ReplicaSchedulingFailure: insufficient storage — already hit on
this lab: with default-replica-count=3 and the shared OS disk (~20 GB), 3 block replicas
× 3 instances do not fit. Hence longhorn-r1. Diagnosis:
kubectl -n longhorn-system get volume <pvc> -o jsonpath='{.status.conditions}'.
No pg-demo-superuser Secret by default. Since CNPG 1.21, enableSuperuserAccess
defaults to false and cluster-demo.yaml does not set it back: the Secret therefore does
not exist. But ../vault-secret-operator/vault/pg-dynamic-rotate.sh reads it to configure
Vault's database/ engine → it fails on the first run. Do this first:
The pg-backups bucket has NO expiration rule at all. The CronJob is hourly → ~8,760
objects a year, on a MinIO bucket sitting on local-path (4 × 10Gi, EC:2) and with no quota.
Nothing prunes it, neither the script nor the manifest. In a lab: keep an eye on it, or add a
lifecycle rule by hand on the MinIO side (mc ilm rule add, see the MinIO docs). The
native backup, on the other hand, is bounded (retentionPolicy: "7d" set by
pg-app-backup-cnpg-up.sh).
The CronJob downloads mc from the Internet on EVERY run.pg-backup-vault-s3.yaml does a wget https://dl.min.io/…/mc inside the container, with no
pinned version and no checksum verification: with no outbound access (or if dl.min.io
moves), the hourly backup fails. Your rescue path therefore depends on the Internet —
acceptable in a lab, to be replaced by an image that ships mc for real.
In-tree barmanObjectStore is deprecated. On CNPG 1.30 it still works but its removal
is announced for 1.31.0. Migration path: the Barman Cloud Plugin (CNPG-I, an
ObjectStore object + plugin on the Cluster). The principle (base + WAL → S3/MinIO, PITR)
is identical; only the declaration changes.
No metrics in Prometheus: that is expected, everything is off by default. After installing
../observability/, set monitoring.enablePodMonitor: true in cluster-demo.yaml (instance
metrics) andmonitoring.podMonitorEnabled: true in values.yaml (operator metrics), then
re-run the script. The PodMonitor CRD only exists after kube-prometheus-stack — hence the
order.
🐘cloudnative-pg/ — PostgreSQL HA déclaratif (opérateur CloudNativePG)
Un CRD Cluster = un PostgreSQL HA complet. L'opérateur observe cet objet et réconcilie
l'état réel : il crée les pods, monte les PVC, élit un primaire, attache les réplicas en
streaming, et bascule tout seul si le primaire tombe. Le cas d'école du pattern operator.
Démontrer le pattern operator : on décrit l'état voulu, le contrôleur fait le reste
(provisioning, réplication, failover automatique, rolling updates, backups).
Montrer un failover en direct — 30 s de démo (cf. 🧪 Scénarios).
Fournir une vraie base aux autres addons : identifiants rotés par Vault
(../vault-secret-operator/), sauvegardes S3 vers MinIO (../minio-s3/cluster/).
Ce qui est déployé : l'opérateur (1 pod, ns cnpg-system) + un cluster de démo pg-demo
(ns cnpg-demo, 3 instances = 1 primaire + 2 réplicas, PVC 1Gi RWO sur longhorn-r1).
Deux couches de résilience (à bien distinguer en formation)#
Réplication PostgreSQL (logique) : le primaire streame ses WAL vers 2 réplicas →
bascule applicative en cas de perte du primaire.
Réplication Longhorn (bloc) : un PVC peut être répliqué par Longhorn sur plusieurs
nodes → survie à la perte d'un disque.
Ce sont deux mécanismes indépendants. Choix de ce lab : 1 réplica Longhorn pour les PVC
de la base (StorageClass dédiée longhorn-r1), car PostgreSQL réplique déjà au niveau
applicatif — empiler 3 réplicas bloc × 3 instances = 9 copies du même jeu de données, ce qui
sature le disque OS partagé (~20 Go). Si un node meurt, CNPG reconstruit l'instance perdue
depuis le primaire. C'est le pattern recommandé pour un opérateur de BDD sur Longhorn, et un
bon support pour expliquer « réplication applicative vs réplication stockage » — et quand ne
pas doubler.
stockage des 3 instances ; le script s'arrête (exit 1) si elle manque
kubectl get sc longhorn-r1
3 workers
l'anti-affinité par défaut place 1 instance par worker
kubectl get nodes
ℹ️ longhorn-r1 n'est pas créée par cet addon.cluster-demo.yaml ne fait que la
référencer ; elle est définie dans longhorn/longhorn-r1-storageclass.yaml.
Avec moins de 3 workers, réduire instances dans cluster-demo.yaml (sinon un pod reste
Pending).
Aucune spécificité de distribution pour ce composant : mêmes charts, mêmes manifestes,
mêmes valeurs sur les deux labs. La distribution passée en argument ne sert ici qu'à deux
choses : le domaine par défaut (talos.lab.example.io / kubeadm.lab.example.io) et la
localisation du lab.env / kubeconfig du lab (../Vagrant-Talos/ ou
../Vagrant-KubeADM/).
ℹ️ Le prérequis est la StorageClass longhorn-r1 : c'est Longhorn qui porte les
différences de distribution (cf. ../longhorn/).
Les commandes ci-dessous sont exactement ce que fait le script tout-en-un, dans
l'ordre. Prépare d'abord l'environnement (une fois par session) :
exportKUBECONFIG=../Vagrant-Talos/kubeconfig# ou ../Vagrant-KubeADM/kubeconfigexportLAB_DOMAIN=talos.lab.example.io# ou ton domaine (cf. lab.env du lab)
5. Observer une bascule automatique (le cœur de la démo)#
kubectl-ncnpg-demogetpods-lcnpg.io/instanceRole=primary# qui est primaire ?
kubectl-ncnpg-demodeletepod<le-primaire>
kubectl-ncnpg-demogetclusterpg-demo-w# un réplica est promu
./install.sh<distro>minio-cluster# la cible S3
./cloudnative-pg/pg-backup-up.sh<distro># sauvegarde WAL + base vers S3
./cloudnative-pg/pg-app-backup-cnpg-up.sh<distro># sauvegarde applicative
kubectl-ncnpg-demogetclusterpg-demo# READY 3/3, "Cluster in healthy state"
kubectl-ncnpg-demogetpods-lcnpg.io/cluster=pg-demo# pg-demo-1/2/3 Running, 1 par worker
kubectl-ncnpg-demogetpvc# 3 PVC Bound, 1Gi longhorn-r1# Se connecter et lire (via le pod primaire)
kubectl-ncnpg-demoexec-itpg-demo-1--psql-c'\l'# liste les bases (dont `app`)
Le plugin kubectl-cnpg donne une vue riche (à installer côté hôte, optionnel) :
kubectl-ncnpg-demogetclusterpg-demo-ojsonpath='{.status.currentPrimary}';echo# ex: pg-demo-1
kubectl-ncnpg-demodeletepodpg-demo-1# on tue le primaire
watchkubectl-ncnpg-demogetclusterpg-demo# un réplica est promu en quelques s
L'opérateur promeut un réplica, recrée l'ancien primaire en réplica, sans intervention.
Écris des données, supprime un pod : le PVC est réattaché, les données survivent. Supprime le
node (VM) : CNPG reconstruit l'instance depuis le primaire (avec longhorn-r1, le bloc n'est
pas répliqué ailleurs — c'est PostgreSQL qui rattrape, cf. les deux couches plus haut).
Le Secret pg-demo-app contient une uri prête à l'emploi. Idéal pour brancher une appli de
démo, ou coupler avec Vault/VSO pour des identifiants rotés (cf. ../vault-secret-operator/).
Les deux poussent vers MinIO et exigent donc l'addon ../minio-s3/cluster/ (namespace
minio-cluster, Secret minio-creds : les deux scripts lisent le mot de passe root MinIO et
créent bucket + utilisateur dédié via un port-forward).
A. Backup logique horaire via les identifiants Vault#
Sauvegarde pg_dump de la base vault, toutes les heures, poussée dans le bucket
pg-backups — en se connectant à PostgreSQL avec les identifiants rotés par Vault.
Prérequis en plus de MinIO : la rotation Vault en place (Secret pg-rotate-creds dans
pg-rotate-demo — le script refuse de continuer sans lui) et le cluster pg-demo UP.
./cloudnative-pg/pg-backup-up.sh<distro># bucket + user MinIO + Secret minio-backup-creds + CronJob# Déclencher un backup immédiat pour vérifier :
kubectl-npg-rotate-democreatejobpg-backup-now--from=cronjob/pg-backup-vault-s3
kubectl-npg-rotate-demologsjob/pg-backup-now
⚠️ Ce backup NE passe PAS par les CRD de CloudNativePG. Ni Backup/ScheduledBackup,
ni barmanObjectStore : c'est un pg_dump porté par un CronJob standard, choisi exprès
pour utiliser les creds Vault — ce que le backup natif CNPG ne sait pas faire.
B. Backup natif CloudNativePG (CRD → MinIO, avec PITR)#
Backup physique de toute l'instance pg-demo (base app incluse) + archivage WAL
continu, poussé par barman-cloud. Permet la restauration à un instant T.
💡 Restauration (PITR) : on crée un nouveauCluster avec spec.bootstrap.recovery
pointant sur le même barmanObjectStore (+ recoveryTarget pour un instant T). On ne
restaure pas « dans » le cluster existant. Voir la doc CNPG « Recovery ».
Cluster bloqué en Creating a new replica → provisioning normal (bootstrap + join) ;
compter 2-5 min. Sinon vérifier les PVC (kubectl -n cnpg-demo get pvc) et Longhorn.
Un réplica reste Pending → l'anti-affinité veut 1 instance/worker : pas assez de
workers. Réduire instances ou ajouter un worker (WORKERS de lab.env).
PVC Pending → StorageClass longhorn-r1 absente ou Longhorn KO (voir ../longhorn/).
Volumes Degraded côté Longhorn → defaultReplicaCount Longhorn > nombre de workers.
pg-backup-up.sh : « secret pg-rotate-creds absent » → la rotation Vault n'est pas en
place : dérouler ../vault-secret-operator/ (section rotation) d'abord.
Backup CNPG qui reste running/failed → vérifier la condition d'archivage :
kubectl -n cnpg-demo get cluster pg-demo -o jsonpath='{.status.conditions}'
(ContinuousArchiving doit être True), puis les logs du sidecar de l'instance primaire.
Longhorn faulted / ReplicaSchedulingFailure: insufficient storage — déjà rencontré
sur ce lab : avec default-replica-count=3 et le disque OS partagé (~20 Go), 3 réplicas
bloc × 3 instances ne rentrent pas. D'où longhorn-r1. Diagnostic :
kubectl -n longhorn-system get volume <pvc> -o jsonpath='{.status.conditions}'.
Pas de Secret pg-demo-superuser par défaut. Depuis CNPG 1.21,
enableSuperuserAccess vaut false et cluster-demo.yaml ne le repositionne pas : le
Secret n'existe donc pas. Or ../vault-secret-operator/vault/pg-dynamic-rotate.sh le lit
pour configurer le moteur database/ de Vault → il échoue au premier lancement. À faire
avant :
Le bucket pg-backups n'a AUCUNE règle d'expiration. Le CronJob est horaire → ~8 760
objets par an, sur un bucket MinIO posé sur local-path (4 × 10Gi, EC:2) et sans quota.
Rien ne purge, ni le script ni le manifeste. En lab : surveiller, ou poser une règle de cycle
de vie à la main côté MinIO (mc ilm rule add, cf. doc MinIO). Le backup natif,
lui, est borné (retentionPolicy: "7d" posé par pg-app-backup-cnpg-up.sh).
Le CronJob télécharge mc depuis Internet à CHAQUE exécution.pg-backup-vault-s3.yaml fait wget https://dl.min.io/…/mc dans le conteneur, sans
version épinglée ni vérification de checksum : sans accès sortant (ou si dl.min.io
bouge), le backup horaire échoue. Le sauvetage dépend donc d'Internet — acceptable en lab,
à remplacer par une image contenant mc en vrai.
barmanObjectStore in-tree est déprécié. Sur CNPG 1.30 il fonctionne mais son
retrait est annoncé en 1.31.0. Migration : le Barman Cloud Plugin (CNPG-I, objet
ObjectStore + plugin sur le Cluster). Le principe (base + WAL → S3/MinIO, PITR) est
identique ; seule la déclaration change.
Métriques absentes de Prometheus : c'est normal, tout est coupé par défaut. Après
l'install de ../observability/, passer monitoring.enablePodMonitor: true dans
cluster-demo.yaml (métriques des instances) etmonitoring.podMonitorEnabled: true
dans values.yaml (métriques de l'opérateur), puis relancer le script. Le CRD PodMonitor
n'existe qu'après kube-prometheus-stack — d'où l'ordre.
Addons liés : ../longhorn/ (SC longhorn-r1) · ../minio-s3/cluster/ (cible des backups) ·
../vault-secret-operator/ (rotation des identifiants) · ../observability/ (métriques)
keycloak/README.md
🛂keycloak/ — Keycloak through its operator, exposed with the Gateway API
An identity provider the cluster can rebuild from a file. Keycloak is deployed by its
operator: a thirty-line Keycloak CR replaces the StatefulSet, the Service and the whole
server configuration, and a KeycloakRealmImport CR makes the lab realm reproducible.
The admin console and the OIDC endpoints are published at keycloak.lab.example.io behind
the same main-gateway as the rest of the lab, on the *.lab.example.io wildcard that is
already issued — nothing new on the certificate side.
🌐 lab.example.io is the repo's NEUTRAL domain (public): keycloak-up.sh swaps it for
LAB_DOMAIN (lab.env) in the Keycloak CR, the realm and the HTTPRoute. See
../README.md.
Give the lab a real identity provider: realms, users, groups, OIDC and SAML clients.
Show a CRD-driven deployment: you declare what you want (Keycloak,
KeycloakRealmImport), the operator derives the how and keeps reconciling it.
Serve as the upstream IdP for anything that authenticates afterwards — starting with
kubectl itself, through the Dex add-on.
ℹ️ Standalone add-on: Keycloak is not installed by ../platform-up.sh (which only lays
down the CNI + Envoy Gateway + metrics-server + the wildcard TLS). It installs on demand, like
../longhorn/, ../vault-cluster/, ../argocd/…
Envoy terminates TLS, Keycloak speaks plaintext — and is told so.http.httpEnabled: true
makes the pod listen on plain 8080; proxy.headers: xforwarded makes Keycloak trust the
X-Forwarded-* headers Envoy sets; hostname.hostname pins the public URL. Miss the second
one and Keycloak builds its redirects from its internal name — the browser loops and OAuth
breaks. Miss the third and the issuer advertised in the discovery document does not match the
URL clients actually call, so every token is rejected.
A Helm chart hands you a StatefulSet and leaves you with the keystore, the proxy options, the
schema migration on every upgrade and the Infinispan cache. The operator takes a Keycloak
object and derives all of it. More importantly it gives you the thing that matters in a lab: a
declared realm you can version, therefore reproduce — and, for downstream components, an
OIDC client declared as a CR instead of a kcadm.sh script.
Keycloak 26.7.0 (operator and server: the operator manifests carry the server image),
pinned in the script via KEYCLOAK_VERSION (can be overridden). Idempotent (kubectl apply
everywhere; the demo password is generated only if its Secret is missing).
Manual equivalent
V=26.7.0
B=https://raw.githubusercontent.com/keycloak/keycloak-k8s-resources/$V/kubernetes
kubectlcreatenamespacekeycloak
kubectlapply-fkeycloak/01-postgres.yaml
kubectl-nkeycloakwait--for=condition=Readycluster/keycloak-db--timeout=300s
# --server-side is mandatory: the keycloaks CRD is larger than the 262 144-byte cap on the# last-applied-configuration annotation that a client-side apply would write.forcinkeycloakskeycloakrealmimportskeycloakoidcclientskeycloaksamlclients;dokubectlapply--server-side-f"$B/$c.k8s.keycloak.org-v1.yml"done
kubectlapply-nkeycloak-f"$B/kubernetes.yml"
kubectl-nkeycloakrolloutstatusdeploy/keycloak-operator
kubectlapply-fkeycloak/02-keycloak.yaml# after substituting the domain
kubectl-nkeycloakcreatesecretgenerickeycloak-demo-user\--from-literal=password="$(opensslrand-base6418)"
kubectlapply-fkeycloak/03-realm-lab.yaml
kubectlapply-fkeycloak/httproute.yaml
No distribution-specific behaviour for this component. Same manifests, same CRs, same
versions on both labs — and, unusually for this repository, not even a privileged namespace
label: the Keycloak and PostgreSQL pods use no hostPath, no hostNetwork, no privilege, so
they already satisfy the baseline PodSecurity level Talos enforces cluster-wide.
The distribution argument only drives two things here: the default domain
(talos.lab.example.io / kubeadm.lab.example.io) and where the lab's lab.env /
kubeconfig live (../Vagrant-Talos/ or ../Vagrant-KubeADM/).
ℹ️ The one thing that does differ per lab is the trust in the certificate, and it only
bites downstream. With SELF_SIGNED=true (the repo default) the issuer
https://keycloak.<LAB_DOMAIN> is signed by the lab's local CA: a browser warns, and any OIDC
client — including the Kubernetes apiserver — must be handed that CA explicitly. See
../self-signed/README.md.
The commands below are exactly what the all-in-one script does, in order.
Set up your shell first (once per session):
exportKUBECONFIG=../Vagrant-Talos/kubeconfig# or ../Vagrant-KubeADM/kubeconfigexportLAB_DOMAIN=talos.lab.example.io# or your own (see the lab's lab.env)
The keycloak-db-app Secret is produced by CloudNativePG, not by us: that is the whole point of
going through the operator rather than writing a postgres:17 Deployment by hand.
Two details worth knowing: the CRDs must go in with --server-side (the keycloaks one is
larger than the 262 144-byte annotation cap of a client-side apply), and the operator must
live in the keycloak namespace (its ClusterRoleBinding names that namespace in its subject).
sed"s/lab\.example\.io/${LAB_DOMAIN}/g"keycloak/02-keycloak.yaml|kubectlapply-f-
kubectl-nkeycloakgetstatefulset,svc# created by the operator, not by you
kubectl-nkeycloakwait--for=condition=Readykeycloak/keycloak--timeout=600s
First start takes a couple of minutes: Keycloak creates its schema. Watch it with
kubectl -n keycloak logs -f keycloak-0.
kubectl-nkeycloakcreatesecretgenerickeycloak-demo-user\--from-literal=password="$(opensslrand-base6418)"
sed"s/lab\.example\.io/${LAB_DOMAIN}/g"keycloak/03-realm-lab.yaml|kubectlapply-f-
kubectl-nkeycloakwait--for=condition=Donekeycloakrealmimport/lab--timeout=300s
kubectl-nkeycloakgetjobs# the import job, run once
spec.placeholders exposes the Secret key as an environment variable of the import job, and
Keycloak substitutes ${DEMO_PASSWORD} while importing. The repository is public: no
credential is ever versioned.
The issuer must read exactly https://keycloak.<LAB_DOMAIN>/realms/lab. If it shows an
internal name instead, proxy.headers or hostname.hostname is wrong — see 🚑 below.
--resolve bypasses DNS: handy for testing before you create the record.
verify=0 only holds with a publicly trusted certificate; with SELF_SIGNED=true, add -k or
pass the lab CA with --cacert.
The point of a declared realm is that it comes back. Destroy it and let the operator rebuild it:
# 1. Note what exists
kubectl-nkeycloakgetkeycloakrealmimportlab-ojsonpath='{.status.conditions}'|jq
# 2. Blow the whole deployment away — CR only, the database survives
kubectl-nkeycloakdeletekeycloakkeycloak
kubectl-nkeycloakgetpods-w# the operator rebuilds the StatefulSet# 3. Re-apply: same URL, same realm, same users — the state was in PostgreSQL all along
kubectlapply-fkeycloak/02-keycloak.yaml
curl-skhttps://keycloak.lab.example.io/realms/lab|jq.realm
Now the other direction, an empty database:
kubectl-nkeycloakdeletekeycloakrealmimportlab
kubectl-nkeycloakdeleteclusterkeycloak-db# ⚠️ destroys the data
./keycloak/keycloak-up.sh<distro># everything comes back from the files
Second run, the demo password is the same: the script does not regenerate a Secret that
already exists.
issuer shows an internal name, or the browser loops on login → proxy.headers is not
xforwarded, or hostname.hostname does not match the HTTPRoute hostname:
kubectl -n keycloak get keycloak keycloak -o jsonpath='{.spec.hostname}{"\n"}{.spec.proxy}'.
metadata.annotations: Too long while applying a CRD → you used a client-side apply. The
keycloaks CRD is over 500 KiB; use kubectl apply --server-side.
The operator starts but reconciles nothing → it is not in the keycloak namespace, so its
ClusterRoleBinding (which names that namespace explicitly) grants it nothing:
kubectl -n keycloak logs deploy/keycloak-operator.
keycloak-0 in CrashLoopBackOff at first start → almost always the database. Check
kubectl -n keycloak get cluster keycloak-db and the credentials in keycloak-db-app. An
OOMKill during the Liquibase migration looks the same: raise spec.resources.limits.memory.
The realm import job never finishes → kubectl -n keycloak get keycloakrealmimport lab -o yaml
then the job's logs. A missing placeholder Secret fails the import with an obscure message.
You edited 03-realm-lab.yaml and nothing changed → expected. The import does not
overwrite an existing realm. Delete the CR and the realm, then re-apply.
404 on the route → kubectl -n keycloak describe httproute keycloak; sectionName: https
must exist on main-gateway and the hostname must match the wildcard.
proxy.headers: xforwarded is only safe behind a proxy you control. It makes Keycloak
trust X-Forwarded-Host. If the Service were reachable directly, anyone could poison the
links in password-reset emails. Keep it a ClusterIP behind the Gateway.
keycloak-initial-admin is a full admin credential in plaintext for as long as you keep
it: readable by anything holding get secrets in the namespace.
The IdP is published on the VIP: every authorized Tailscale peer can reach the login page.
Brute-force protection is on in the realm, but a real password is still mandatory.
The realm import is one-shot. It documents the initial state, not the current one. Any
change made in the console drifts silently from this file — this is not GitOps.
Destroying the keycloak-db cluster destroys every identity. The Keycloak CR holds no
state; PostgreSQL holds all of it, and this lab takes no backup of it (see
../cloudnative-pg/ for S3 backups + PITR).
Stacking this component on control planes that are too tight on RAM ends up starving etcd:
Keycloak asks for 768 MiB and peaks higher on first start. lab.env (WK_MEM) is the knob.
🛂keycloak/ — Keycloak par son opérateur, exposé via la Gateway API
Un fournisseur d'identité que le cluster sait reconstruire depuis un fichier. Keycloak est
déployé par son opérateur : un CR Keycloak de trente lignes remplace le StatefulSet, le
Service et toute la configuration serveur, et un CR KeycloakRealmImport rend le realm labreproductible. La console d'admin et les endpoints OIDC sont publiés sous
keycloak.lab.example.io derrière le même main-gateway que le reste du lab, avec le
wildcard *.lab.example.io déjà émis — rien de neuf côté certificat.
🌐 lab.example.io est le domaine NEUTRE du dépôt (public) : keycloak-up.sh le remplace
par LAB_DOMAIN (lab.env) dans le CR Keycloak, le realm et l'HTTPRoute. Cf.
../LISEZ-MOI.md.
Donner au lab un vrai fournisseur d'identité : realms, utilisateurs, groupes, clients OIDC
et SAML.
Montrer un déploiement piloté par CRD : on déclare ce qu'on veut (Keycloak,
KeycloakRealmImport), l'opérateur en dérive le comment et le reconcilie en continu.
Servir d'IdP amont à tout ce qui s'authentifie ensuite — à commencer par kubectl
lui-même, via l'addon Dex.
ℹ️ Addon à part : Keycloak n'est pas installé par ../platform-up.sh (qui ne pose que
le CNI + Envoy Gateway + metrics-server + le wildcard TLS). Il s'installe à la demande, comme
../longhorn/, ../vault-cluster/, ../argocd/…
Envoy termine le TLS, Keycloak parle en clair — et on le lui dit.http.httpEnabled: true
fait écouter le pod en clair sur 8080 ; proxy.headers: xforwarded fait confiance aux en-têtes
X-Forwarded-* posés par Envoy ; hostname.hostname fige l'URL publique. Sans le deuxième,
Keycloak fabrique ses redirections depuis son nom interne — le navigateur boucle et OAuth casse.
Sans le troisième, l'issuer annoncé dans le document de découverte ne correspond pas à l'URL
réellement appelée, et tous les jetons sont rejetés.
Un chart Helm vous donne un StatefulSet et vous laisse avec : la génération du keystore, les
options de proxy, la migration de schéma à chaque montée de version, le cache Infinispan.
L'opérateur, lui, prend un objet Keycloak et en dérive tout. Surtout, il apporte ce qui compte
dans un lab : un realm déclaré, donc versionnable, donc reproductible — et, pour les
composants d'aval, un client OIDC déclaré par un CR au lieu d'un script kcadm.sh.
Keycloak 26.7.0 (opérateur et serveur : les manifestes de l'opérateur portent l'image
du serveur), épinglé dans le script via KEYCLOAK_VERSION (surchargeable). Idempotent
(kubectl apply partout ; le mot de passe de démo n'est généré que si son Secret manque).
Équivalent manuel
V=26.7.0
B=https://raw.githubusercontent.com/keycloak/keycloak-k8s-resources/$V/kubernetes
kubectlcreatenamespacekeycloak
kubectlapply-fkeycloak/01-postgres.yaml
kubectl-nkeycloakwait--for=condition=Readycluster/keycloak-db--timeout=300s
# --server-side est obligatoire : la CRD keycloaks dépasse les 262 144 octets que peut# porter l'annotation last-applied-configuration écrite par un apply côté client.forcinkeycloakskeycloakrealmimportskeycloakoidcclientskeycloaksamlclients;dokubectlapply--server-side-f"$B/$c.k8s.keycloak.org-v1.yml"done
kubectlapply-nkeycloak-f"$B/kubernetes.yml"
kubectl-nkeycloakrolloutstatusdeploy/keycloak-operator
kubectlapply-fkeycloak/02-keycloak.yaml# après substitution du domaine
kubectl-nkeycloakcreatesecretgenerickeycloak-demo-user\--from-literal=password="$(opensslrand-base6418)"
kubectlapply-fkeycloak/03-realm-lab.yaml
kubectlapply-fkeycloak/httproute.yaml
Aucune spécificité de distribution pour ce composant. Mêmes manifestes, mêmes CR, mêmes
versions sur les deux labs — et, fait rare dans ce dépôt, pas même une étiquette de namespace
privileged : les pods Keycloak et PostgreSQL n'utilisent ni hostPath, ni hostNetwork, ni
privilège, ils satisfont donc déjà le niveau PodSecurity baseline que Talos applique au niveau
cluster.
La distribution passée en argument ne sert ici qu'à deux choses : le domaine par défaut
(talos.lab.example.io / kubeadm.lab.example.io) et la localisation du lab.env /
kubeconfig du lab (../Vagrant-Talos/ ou ../Vagrant-KubeADM/).
ℹ️ La seule chose qui diffère vraiment d'un lab à l'autre est la confiance dans le
certificat, et ça ne mord qu'en aval. Avec SELF_SIGNED=true (le défaut du dépôt), l'issuer
https://keycloak.<LAB_DOMAIN> est signé par l'AC locale du lab : le navigateur avertit, et
tout client OIDC — apiserver Kubernetes compris — doit recevoir cette AC explicitement. Cf.
../self-signed/LISEZ-MOI.md.
Le Secret keycloak-db-app est produit par CloudNativePG, pas par nous : c'est tout l'intérêt de
passer par l'opérateur plutôt que d'écrire un Deployment postgres:17 à la main.
Deux détails qui comptent : les CRD passent en --server-side (celle de keycloaks dépasse
le plafond de 262 144 octets de l'annotation d'un apply côté client), et l'opérateur doit
vivre dans le namespace keycloak (son ClusterRoleBinding y désigne son ServiceAccount en dur).
sed"s/lab\.example\.io/${LAB_DOMAIN}/g"keycloak/02-keycloak.yaml|kubectlapply-f-
kubectl-nkeycloakgetstatefulset,svc# créés par l'opérateur, pas par toi
kubectl-nkeycloakwait--for=condition=Readykeycloak/keycloak--timeout=600s
Le premier démarrage prend deux bonnes minutes : Keycloak crée son schéma. À suivre avec
kubectl -n keycloak logs -f keycloak-0.
4. Le realm lab, sans un seul mot de passe dans git#
kubectl-nkeycloakcreatesecretgenerickeycloak-demo-user\--from-literal=password="$(opensslrand-base6418)"
sed"s/lab\.example\.io/${LAB_DOMAIN}/g"keycloak/03-realm-lab.yaml|kubectlapply-f-
kubectl-nkeycloakwait--for=condition=Donekeycloakrealmimport/lab--timeout=300s
kubectl-nkeycloakgetjobs# le Job d'import, exécuté une fois
spec.placeholders expose la clé du Secret comme variable d'environnement du Job d'import, et
Keycloak substitue ${DEMO_PASSWORD} pendant l'import. Le dépôt est public : aucun
identifiant n'est jamais versionné.
L'issuer doit valoir exactement https://keycloak.<LAB_DOMAIN>/realms/lab. S'il affiche un nom
interne, c'est proxy.headers ou hostname.hostname qui est faux — cf. 🚑 plus bas.
Cluster CloudNativePG keycloak-db — 1 instance, 2Gi sur longhorn-r1, base et propriétaire keycloak
02-keycloak.yaml
le CR Keycloak : base, httpEnabled, proxy.headers: xforwarded, hostname public, Ingress de l'opérateur désactivé
03-realm-lab.yaml
KeycloakRealmImport : realm lab, groupes k8s-admins / k8s-viewers, scope groups, utilisateurs demo et viewer dont les mots de passe viennent de Secrets
kubectl-nkeycloakgetkeycloak,keycloakrealmimport# Ready=True, Done=True
kubectl-nkeycloakgetpods# keycloak-0, keycloak-db-1, opérateur
kubectl-nkeycloakgethttproutekeycloak# Accepted + ResolvedRefs = True# test de bout en bout, TLS servi par Envoy :
curl-sS-o/dev/null-w'%{http_code} verify=%{ssl_verify_result}\n'\--resolvekeycloak.lab.example.io:443:192.168.56.200\https://keycloak.lab.example.io/realms/lab# attendu : 200 verify=0
--resolve court-circuite le DNS : pratique pour tester avant de créer l'enregistrement.
verify=0 ne tient qu'avec un certificat publiquement trusté ; avec SELF_SIGNED=true, ajoute
-k ou passe l'AC du lab avec --cacert.
L'intérêt d'un realm déclaré, c'est qu'il revient. Détruis-le et laisse l'opérateur reconstruire :
# 1. Constater l'existant
kubectl-nkeycloakgetkeycloakrealmimportlab-ojsonpath='{.status.conditions}'|jq
# 2. Supprimer tout le déploiement — le CR seulement, la base survit
kubectl-nkeycloakdeletekeycloakkeycloak
kubectl-nkeycloakgetpods-w# l'opérateur reconstruit le StatefulSet# 3. Réappliquer : même URL, même realm, mêmes utilisateurs — l'état était dans PostgreSQL
kubectlapply-fkeycloak/02-keycloak.yaml
curl-skhttps://keycloak.lab.example.io/realms/lab|jq.realm
Puis l'autre sens, une base vide :
kubectl-nkeycloakdeletekeycloakrealmimportlab
kubectl-nkeycloakdeleteclusterkeycloak-db# ⚠️ détruit les données
./keycloak/keycloak-up.sh<distro># tout revient depuis les fichiers
Au second passage, le mot de passe de demo est le même : le script ne régénère pas un Secret
qui existe déjà.
L'issuer affiche un nom interne, ou le navigateur boucle à la connexion → proxy.headers
n'est pas xforwarded, ou hostname.hostname ne correspond pas au hostname de l'HTTPRoute :
kubectl -n keycloak get keycloak keycloak -o jsonpath='{.spec.hostname}{"\n"}{.spec.proxy}'.
metadata.annotations: Too long en appliquant une CRD → apply côté client. La CRD
keycloaks dépasse 500 Kio : utiliser kubectl apply --server-side.
L'opérateur démarre mais ne reconcilie rien → il n'est pas dans le namespace keycloak, et
son ClusterRoleBinding (qui y désigne son ServiceAccount en dur) ne lui donne alors aucun
droit : kubectl -n keycloak logs deploy/keycloak-operator.
keycloak-0 en CrashLoopBackOff au premier démarrage → presque toujours la base.
Vérifier kubectl -n keycloak get cluster keycloak-db et les identifiants de keycloak-db-app.
Un OOMKill pendant la migration Liquibase ressemble exactement à ça : monter
spec.resources.limits.memory.
Le Job d'import du realm ne finit jamais → kubectl -n keycloak get keycloakrealmimport lab -o yaml
puis les logs du Job. Un Secret de placeholder manquant fait échouer l'import avec un message
obscur.
Tu as modifié 03-realm-lab.yaml et rien ne change → c'est normal. L'import n'écrase
pas un realm existant. Supprimer le CR et le realm, puis réappliquer.
404 sur la route → kubectl -n keycloak describe httproute keycloak ; sectionName: https
doit exister sur main-gateway et le hostname matcher le wildcard.
proxy.headers: xforwarded n'est sûr que derrière un proxy que l'on maîtrise. Il fait
confiance à X-Forwarded-Host. Si le Service était joignable directement, n'importe qui
pourrait empoisonner les liens des e-mails de réinitialisation. Il reste en ClusterIP
derrière la Gateway.
keycloak-initial-admin est un identifiant admin COMPLET, en clair tant qu'on le garde :
lisible par tout ce qui a get secrets dans le namespace.
L'IdP est publié sur la VIP : tous les pairs Tailscale autorisés atteignent la page de
connexion. La protection anti-brute-force est active dans le realm, mais un vrai mot de passe
reste obligatoire.
L'import de realm est à sens unique. Il documente l'état initial, pas l'état courant.
Toute modification faite dans la console dérive silencieusement de ce fichier — ce n'est pas du
GitOps.
Détruire le cluster keycloak-db détruit toutes les identités. Le CR Keycloak ne porte
aucun état ; PostgreSQL les porte tous, et ce lab n'en fait aucune sauvegarde (cf.
../cloudnative-pg/ pour les sauvegardes S3 + PITR).
Empiler ce composant sur des control planes trop justes en RAM finit par affamer etcd :
Keycloak demande 768 Mio et monte plus haut au premier démarrage. lab.env (WK_MEM) est le
bouton.
🪪dex/ — Dex in front of Keycloak: kubectl login without a single certificate
The lab's kubeconfig stops being a credential. Dex publishes itself at
dex.lab.example.io as the one OIDC issuer the Kubernetes apiserver trusts, and goes
looking for the actual identity in the Keycloak lab realm. kubectl then logs in through
a browser (kubectl oidc-login), and your rights come from a Keycloak group, not from a
client certificate copied around.
🌐 lab.example.io is the repo's NEUTRAL domain (public): dex-up.sh swaps it for
LAB_DOMAIN (lab.env) in the Dex values, the Keycloak client and the HTTPRoute. See
../README.md.
Show the only authentication mechanism Kubernetes offers that scales: OIDC. No user
database in the cluster, no certificate to revoke, no kubeconfig to hand out.
Demonstrate that authorisation follows a group: put a user in k8s-admins in Keycloak,
and cluster-admin follows; remove them and it is gone at the next token.
Explain, on a live cluster, why the API server is the hardest part of this chain to
change, and how that plays out very differently on Talos and on kubeadm.
ℹ️ Standalone add-on, and it needs ../keycloak/ first: it uses
the lab realm, its groups client scope and its demo user.
Because the apiserver only knows one issuer, frozen on its command line, and changing that
value restarts the control plane. Dex is the indirection: the apiserver only ever knows
https://dex.lab.example.io, and everything that moves (adding a second directory, switching
realm, rotating a client secret) happens in Dex's config, never on the control plane. That is
exactly what a managed cluster does behind its SSO.
Three URLs must match, character for character: config.issuer in values.yaml, the
hostnames: of the HTTPRoute, and --oidc-issuer-url on the apiserver. That string is signed
into every token; a trailing slash is enough to have every token rejected with
oidc: id token issued by a different provider.
Chart dex/dex0.24.1 (app v2.44.0), pinned in the script via DEX_VERSION (can be
overridden). Idempotent, with one deliberate exception: the client secrets are generated
only if missing, because regenerating them would silently invalidate the client the Keycloak
operator has already created.
⚠️ The script does not touch the API server. Wiring it to Dex restarts the control plane,
and an unreachable issuer stops it from restarting at all. An add-on has no business doing
that quietly. The script prints the exact commands for your distribution instead; see
Wiring the API server.
Manual equivalent
kubectlcreatenamespacedex
S=$(opensslrand-hex32)
kubectl-nkeycloakcreatesecretgenericdex-keycloak-client--from-literal=client-secret="$S"
kubectl-ndexcreatesecretgenericdex-keycloak-client--from-literal=client-secret="$S"
kubectl-ndexcreatesecretgenericdex-kubernetes-client\--from-literal=client-secret="$(opensslrand-hex32)"
dex/dex-up.sh<distro># step 2 creates the client with kcadm
helmrepoadddexhttps://charts.dexidp.io&&helmrepoupdatedex
helmupgrade--installdexdex/dex-ndex--version0.24.1--valuesdex/values.yaml
kubectlapply-fdex/httproute.yaml
kubectlapply-fdex/rbac.yaml
This is the component where the two labs diverge the most, and the divergence is not in
Dex, which is identical on both. It is in the one thing this repository does not own: the
Kubernetes API server.
Talos
Debian 13 + kubeadm
What the apiserver is
a static pod whose manifest Talos generates from the machine configuration
a static pod whose manifest lives on disk, generated by kubeadm
How you change its flags
talosctl patch mc — the configuration is an API
edit the kubeadm-config ConfigMap, then re-run kubeadm init phaseon each node
Access needed
none beyond talosctl: no SSH exists on Talos
a shell on every control plane (vagrant ssh k8s-cpN)
Shape of extraArgs
a map (key: value), Talos v1alpha1
a list of {name, value}, kubeadm v1beta4 (Kubernetes ≥ 1.31)
Restart
Talos rewrites the manifest and restarts the apiserver itself
kubeadm rewrites the manifest, the kubelet notices and restarts the pod
Where the OIDC CA goes (SELF_SIGNED=true)
a machine.files entry plus a cluster.apiServer.extraVolumes mount: / is read-only and the static pod sees nothing you have not mounted
a plain cp into /etc/kubernetes/pki/, already mounted into the pod — nothing else to declare
The commands below are exactly what the all-in-one script does, in order.
Set up your shell first (once per session):
exportKUBECONFIG=../Vagrant-Talos/kubeconfig# or ../Vagrant-KubeADM/kubeconfigexportLAB_DOMAIN=talos.lab.example.io# or your own (see the lab's lab.env)
The dex client secret has to exist in two namespaces: Keycloak reads it in keycloak (the
KeycloakOIDCClient CR lives there), Dex reads it in dex. Secrets do not cross namespaces.
# kcadm lives INSIDE the Keycloak image: no DNS, no CA, no curl on the hostKC="kubectl -n keycloak exec -i keycloak-0 -- /opt/keycloak/bin/kcadm.sh"$KCconfigcredentials--serverhttp://localhost:8080--realmmaster\--user"$(kubectl-nkeycloakgetsecretkeycloak-initial-admin-ojsonpath='{.data.username}'|base64-d)"\--password"$(kubectl-nkeycloakgetsecretkeycloak-initial-admin-ojsonpath='{.data.password}'|base64-d)"$KCcreateclients-rlab-f-<<EOF{ "clientId": "dex", "enabled": true, "publicClient": false, "secret": "$(kubectl -n dex get secret dex-keycloak-client -o jsonpath='{.data.client-secret}' | base64 -d)", "standardFlowEnabled": true, "directAccessGrantsEnabled": false, "implicitFlowEnabled": false, "serviceAccountsEnabled": false, "redirectUris": [ "https://dex.${LAB_DOMAIN}/callback" ] }EOF$KCgetclients-rlab-qclientId=dex--fieldsclientId,enabled,redirectUris
The clientId must match clientID: dex in the Dex connector exactly.
⚠️ Why not the KeycloakOIDCClient CRD. Keycloak 26.7 ships one, and it would be the
natural way to declare this client. It does not work, and it fails silently: the CR
applies, kubectl apply prints configured, the object exists, and it is never reconciled,
so the client never appears in the realm. The whole thing only surfaces at login, as
invalid_client. Two independent blockers, measured on 26.7.0:
the server needs the client-admin-api:v2 feature (see ../keycloak/02-keycloak.yaml),
and the operator caches the server's feature list: it keeps reporting the feature as
missing until the operator itself is restarted
(kubectl -n keycloak rollout restart deploy/keycloak-operator);
past that, KeycloakClientBaseController looks up a Secret named
<keycloak-cr-name>-admin, while the operator's own bootstrap Secret is
keycloak-initial-admin, a different name. Creating keycloak-admin with
username/password is not enough either: the controller then dies on
Cannot invoke "String.getBytes(...)" because "src" is null, so it expects other keys
(most likely a service account, client-id/client-secret).
A working client that nothing reconciles beats a declarative one that is never created. When
upstream fixes the CRD, dex-up.sh step 2 goes back to a kubectl apply.
That last command opens a browser: Dex offers the Keycloak connector, Keycloak asks for
demo and its password, and kubectl comes back with a token. Nothing was copied, nothing was
signed by hand.
💡 With SELF_SIGNED=true, add --exec-arg=--certificate-authority=<path to the lab CA>
so kubelogin trusts dex.<LAB_DOMAIN> too. --insecure-skip-tls-verify works, and teaches
the wrong reflex.
This is the only step of the repository that changes the control plane, so it is the only
one left in your hands. Read this before running anything:
It adds an authenticator, it removes none. The lab's kubeconfig and every component's
client certificate keep working, which is what makes the operation reversible.
A wrong --oidc-issuer-url (unreachable, or with an untrusted certificate) stops the
apiserver from starting. Do one control plane at a time and check
kubectl get --raw=/readyz in between; as long as one control plane is healthy the cluster
stays administrable.
To roll back: remove the flags the same way you added them.
# Substitute the neutral domain FIRST: the patch ships the repository's public placeholder.
sed"s|dex\.lab\.example\.io|dex.${LAB_DOMAIN}|"dex/apiserver-oidc.talos.yaml\>/tmp/oidc-issuer.talos.yaml
foripin$(kubectlgetnodes-lnode-role.kubernetes.io/control-plane\-ojsonpath='{range .items[*]}{.status.addresses[?(@.type=="InternalIP")].address}{" "}{end}');dotalosctl-n"$ip"patchmc--patch@/tmp/oidc-issuer.talos.yaml
kubectlget--raw=/readyz&&echodone
Untrusted wildcard (SELF_SIGNED=trueor ACME staging): also hand the apiserver the lab CA
/ is read-only on Talos and the static pod sees only what you mount. Two additions therefore:
the file, and the volume that exposes it.
# The wildcard Secret works in EVERY mode, and that is the point: `tls.crt` always carries its# own trust anchor — leaf + local CA when self-signed (selfsigned-up.sh concatenates them on# purpose), leaf + intermediate (+ root) with ACME. `_out/self-signed/ca.crt` only exists in one# of the three modes, so do not reach for it.
kubectl-nenvoy-gateway-systemgetsecret"wildcard-$(echo"$LAB_DOMAIN"|tr.-)-tls"\-ojsonpath='{.data.tls\.crt}'|base64-d>/tmp/lab-ca.crt
CA=$(sed's/^/ /'/tmp/lab-ca.crt)# indent the PEM
cat>/tmp/oidc-ca.talos.yaml<<EOFmachine: files: - path: /var/lib/oidc/ca.crt permissions: 0o644 op: create content: |$CAcluster: apiServer: extraArgs: oidc-ca-file: /etc/kubernetes/oidc/ca.crt extraVolumes: - hostPath: /var/lib/oidc mountPath: /etc/kubernetes/oidc readonly: trueEOF
talosctl-n<control-planeIP>patchmc--patch@/tmp/oidc-ca.talos.yaml
op: create refuses to overwrite: on a second run, use overwrite.
The apiserver manifest is a file on each control plane, regenerated by kubeadm from the
kubeadm-config ConfigMap. Editing the manifest directly works, until the next
kubeadm upgrade silently overwrites it. So the ConfigMap is the source of truth:
⚠️ The InitConfiguration document below is not optional.advertiseAddress lives in
the InitConfiguration, and the kubeadm-config ConfigMap stores only the
ClusterConfiguration. Hand kubeadm the ClusterConfiguration alone and it falls back to
the default-route interface: on a Vagrant VM that is eth0, the VirtualBox NAT address
10.0.2.15, identical on every VM and routable from none. The kubernetes Service then
advertises that address and every pod loses the API (dial tcp 10.96.0.1:443: connect: connection refused). The symptom is deceptive: kubectl from the host keeps working (it goes
through the VIP) and /readyz still answers ok.
# 1. substitute the neutral domain, then merge the apiServer block into ClusterConfiguration.# An issuer that differs by one character = every token rejected.
sed"s|dex\.lab\.example\.io|dex.${LAB_DOMAIN}|"dex/apiserver-oidc.kubeadm.yaml
kubectl-nkube-systemeditconfigmapkubeadm-config
# 2. on EACH control plane, regenerate the static pod manifest from that ConfigMap.# The advertise address is read back from the manifest currently in place.
vagrantsshk8s-cp1-c' ADDR=$(sudo sed -n "s/.*--advertise-address=//p" /etc/kubernetes/manifests/kube-apiserver.yaml) { printf "apiVersion: kubeadm.k8s.io/v1beta4\nkind: InitConfiguration\nlocalAPIEndpoint:\n advertiseAddress: %s\n bindPort: 6443\n---\n" "$ADDR" kubectl -n kube-system get cm kubeadm-config -o jsonpath="{.data.ClusterConfiguration}" } | sudo tee /tmp/kubeadm.yaml >/dev/null sudo kubeadm init phase control-plane apiserver --config /tmp/kubeadm.yaml'
kubectlget--raw=/readyz&&echo# 3. prove the endpoint did NOT flip to the NAT address
kubectlgetendpointslice-lkubernetes.io/service-name=kubernetes\-ojsonpath='{.items[*].endpoints[*].addresses[*]}';echo# expects the host-only IP
Untrusted wildcard (SELF_SIGNED=trueor ACME staging): also hand the apiserver the lab CA
/etc/kubernetes/pki is already mounted into the static pod, so the file is all it takes:
vagrantsshk8s-cp1-c'sudo tee /etc/kubernetes/pki/oidc-ca.crt >/dev/null'\</tmp/lab-ca.crt# extracted from the wildcard Secret, see the Talos box above# then add to the ConfigMap, alongside the other flags:# - name: oidc-ca-file# value: /etc/kubernetes/pki/oidc-ca.crt
💡 The simplest path is to have no CA problem at all: with SELF_SIGNED=false the wildcard is
issued by Let's Encrypt, publicly trusted, and neither the apiserver nor kubelogin needs
anything. See ../cert-manager/README.md.
the dex client is created by dex-up.sh step 2 with kcadm — STANDARD flow only, one exact redirect URI. The KeycloakOIDCClient CRD is not usable, see above
values.yaml
Dex: public issuer, Kubernetes storage, oidc connector to the realm, kubernetes static client; both secrets come from environment variables
kubectl-ndexgetpods,httproute# dex Running, route Accepted
kubectl-nkeycloakexec-ikeycloak-0--/opt/keycloak/bin/kcadm.sh\getclients-rlab-qclientId=dex--fieldsclientId,enabled,redirectUris# the client exists
curl-skhttps://dex.lab.example.io/.well-known/openid-configuration|jq.issuer
# the apiserver really sees the flags (Talos and kubeadm alike):
kubectl-nkube-systemgetpod-lcomponent=kube-apiserver\-ojsonpath='{.items[0].spec.containers[0].command}'|tr',''\n'|grepoidc
# the whole chain, in one command:
kubectl--context=oidcauthwhoami# oidc:demo@lab.example.io, oidc:k8s-admins
kubectl--context=oidcauthcan-i'*''*'--all-namespaces# yes
🧪 The two accounts — the same login, two sets of rights#
The realm ships two users, and the second one is what makes the demonstration
falsifiable: if both could do the same things, nothing would prove the groups claim is
actually being read.
Not one line of rbac.yaml names a person: the subjects are groups
(kind: Group, oidc:k8s-admins / oidc:k8s-viewers). Adding a colleague is a group
membership in Keycloak, never a kubectl command.
A second context, so both can coexist: same cluster, same issuer, another account. The
--oidc-use-pkce and cache separation matter: without a distinct --token-cache-dir,
kubelogin reuses demo's cached token and the "viewer" context silently stays admin.
kubectl--context=oidc-viewerauthwhoami# oidc:viewer@lab.example.io, oidc:k8s-viewers
kubectl--context=oidc-viewerauthcan-ilistpods# yes
kubectl--context=oidc-viewerauthcan-icreatepods# no
kubectl--context=oidc-viewerauthcan-igetsecrets# no <- `view` excludes Secrets
⚠️ view is not "read everything". It grants nothing on cluster-scoped objects, so
kubectl --context=oidc-viewer get nodes is Forbidden, the ClusterRole behaving
correctly, not a broken login. Test with get pods, which view does allow.
Two caches keep you logged in, and the second one is the one nobody thinks of.
# 1. kubelogin's token cache — per context, hence the distinct --token-cache-dir above
rm-rf~/.kube/cache/oidc-login~/.kube/cache/oidc-login-viewer
# 2. Keycloak's SSO session — without this, the browser flashes open and closes, and you are# silently logged back in as the PREVIOUS user. Nothing says so.
kubectl-nkeycloakexec-ikeycloak-0--/opt/keycloak/bin/kcadm.sh\configcredentials--serverhttp://localhost:8080--realmmaster\--user"$(kubectl-nkeycloakgetsecretkeycloak-initial-admin-ojsonpath='{.data.username}'|base64-d)"\--password"$(kubectl-nkeycloakgetsecretkeycloak-initial-admin-ojsonpath='{.data.password}'|base64-d)"
kubectl-nkeycloakexec-ikeycloak-0--/opt/keycloak/bin/kcadm.sh\createrealms/lab/logout-all# closes every session of the realm
A private browser window achieves the same thing without touching any state, and is the
quickest way to hold both identities at once.
⚠️ A logout does not revoke a token that has already been issued. The apiserver checks a
signature and an expiry, not a session: after logout-all, a token sitting in kubelogin's
cache keeps working until it expires. To actually cut an account off, disable the user
(enabled: false), or delete the local cache. logout-all only stops the NEXT login from
being silent.
💡 auth whoami returning the previous user is the symptom of a live SSO session, not of
a misconfigured context. That is the check that tells the two apart.
🧪 Scenario — authorisation lives in Keycloak, not in the cluster#
# 1. demo is in k8s-admins: full rights
kubectl--context=oidcauthcan-ideletenodes# yes# 2. In the Keycloak console (https://keycloak.<LAB_DOMAIN>/admin/), realm `lab`:# Users -> demo -> Groups -> leave k8s-admins, join k8s-viewers# 3. Force a new token — the old one is cached and still valid until it expires
rm-rf~/.kube/cache/oidc-login
kubectl--context=oidcauthwhoami# oidc:k8s-viewers
kubectl--context=oidcauthcan-ideletenodes# no
kubectl--context=oidcgetpods-A# still works: `view`
Not one kubectl command was needed to change those rights, and nothing was revoked in the
cluster. That is the whole point, and the cache is also the whole caveat: a token already
issued keeps its groups until it expires. OIDC de-provisioning is never instant.
oidc: id token issued by a different provider → the three URLs disagree. Compare
config.issuer (kubectl -n dex get secret dex -o jsonpath='{.data.config\.yaml}' | base64 -d),
the HTTPRoute hostname, and --oidc-issuer-url on the apiserver. A trailing / is enough.
The apiserver will not restart after the patch → almost always the issuer: unreachable, or
a certificate it does not trust. On Talos, talosctl -n <ip> logs kubelet; on kubeadm,
sudo crictl logs $(sudo crictl ps -a --name kube-apiserver -q | head -1). Undo the flags.
Login works but every command is Forbidden → the groups are missing from the token.
Check in order: the groups scope requested by Dex (values.yaml), insecureEnableGroups: true on the connector, the groups client scope in the realm
(../keycloak/), and the oidc: prefix in rbac.yaml.
kubectl auth whoami shows the right user but no group → same causes, and check that the
Keycloak client really was assigned the groups scope: the realm assigns it to new
clients (defaultDefaultClientScopes); a client created before that setting does not have it.
The browser never comes back / redirect_uri_mismatch → kubelogin listens on :8000.
The URI must appear in both staticClients[].redirectURIs (Dex) and the Keycloak client's
redirectUris: the latter only lists https://dex.<LAB_DOMAIN>/callback, which is Dex's own
callback, not kubectl's.
x509: certificate signed by unknown authority → SELF_SIGNED=true and someone in the
chain lacks the lab CA: the apiserver (oidc-ca-file), kubelogin
(--certificate-authority), or Dex when it calls Keycloak.
The Dex pod restarts in a loop → kubectl -n dex logs deploy/dex. A missing secret, or an
unexpanded $KEYCLOAK_CLIENT_SECRET, shows up as a connector failing to start.
cluster-admin is granted to a group, so anyone Keycloak puts in it is cluster admin. The
IdP is now part of the cluster's trust boundary; whoever administers the realm administers
Kubernetes.
Revocation is not immediate. A token already handed out stays valid until it expires,
whatever you change in Keycloak. Kubernetes does not check anything on the IdP side per
request.
The oidc: prefix is load-bearing. Drop it and a directory that declares a
system:masters group takes over the cluster. If you change it, change rbac.yaml too.
Never remove the certificate authenticators. OIDC adds itself to them; a cluster whose
only door is an IdP is a cluster you cannot get back into when the IdP is down, and here the
IdP runs inside that cluster.
insecureEnableGroups is badly named but required: without it, Dex's generic OIDC
connector drops upstream groups on the floor and nothing says so.
The client secrets are generated once. Re-running the script does not rotate them, on
purpose: regenerating the dex secret on one side only would break the client with no error
in sight. Rotate deliberately: both namespaces, then restart Dex.
Editing /etc/kubernetes/manifests/kube-apiserver.yaml by hand on kubeadm survives until
the next kubeadm upgrade, which regenerates it from kubeadm-config.
🪪dex/ — Dex devant Keycloak : kubectl sans le moindre certificat
Le kubeconfig du lab cesse d'être un identifiant. Dex se publie sous
dex.lab.example.io comme l'unique émetteur OIDC auquel le serveur d'API fait confiance, et
va chercher l'identité réelle dans le realm Keycloak lab. kubectl se connecte alors par un
navigateur (kubectl oidc-login), et tes droits viennent d'un groupe Keycloak, plus d'un
certificat client recopié de machine en machine.
🌐 lab.example.io est le domaine NEUTRE du dépôt (public) : dex-up.sh le remplace par
LAB_DOMAIN (lab.env) dans les values Dex, le client Keycloak et l'HTTPRoute. Cf.
../LISEZ-MOI.md.
Montrer le seul mécanisme d'authentification de Kubernetes qui passe à l'échelle : OIDC.
Aucune base d'utilisateurs dans le cluster, aucun certificat à révoquer, aucun kubeconfig à
distribuer.
Démontrer que l'autorisation suit un groupe : mets un utilisateur dans k8s-admins côté
Keycloak et cluster-admin suit ; sors-l'en et c'est fini au jeton suivant.
Expliquer, sur un cluster vivant, pourquoi le serveur d'API est le maillon le plus dur à
modifier de cette chaîne, et à quel point ça se joue différemment sur Talos et sur kubeadm.
ℹ️ Addon à part, et il exige ../keycloak/ d'abord : il
s'appuie sur le realm lab, son scope groups et son utilisateur demo.
Parce que l'apiserver ne connaît qu'un seul émetteur, figé dans sa ligne de commande, et que
changer cette valeur redémarre le control plane. Dex est le point d'indirection : l'apiserver ne
connaît jamais que https://dex.lab.example.io, et tout ce qui bouge (ajouter un second
annuaire, changer de realm, faire tourner un secret de client) se fait dans la configuration de
Dex, jamais sur le control plane. C'est exactement ce que fait un cluster managé derrière son SSO.
Trois URL doivent coïncider au caractère près : config.issuer de values.yaml, le
hostnames: de l'HTTPRoute, et --oidc-issuer-url de l'apiserver. Cette chaîne est signée
dans chaque jeton ; un / final suffit à les faire tous rejeter avec
oidc: id token issued by a different provider.
Chart dex/dex0.24.1 (app v2.44.0), épinglé dans le script via DEX_VERSION
(surchargeable). Idempotent, avec une exception assumée : les secrets de client ne sont générés
que s'ils manquent, parce que les régénérer invaliderait en silence le client que l'opérateur
Keycloak a déjà créé.
⚠️ Le script ne touche pas au serveur d'API. Le brancher sur Dex redémarre le control
plane, et un émetteur injoignable l'empêche carrément de redémarrer. Un addon n'a pas à faire
ça en douce. Le script affiche à la place les commandes exactes pour ta distribution ; cf.
Câbler le serveur d'API.
Équivalent manuel
kubectlcreatenamespacedex
S=$(opensslrand-hex32)
kubectl-nkeycloakcreatesecretgenericdex-keycloak-client--from-literal=client-secret="$S"
kubectl-ndexcreatesecretgenericdex-keycloak-client--from-literal=client-secret="$S"
kubectl-ndexcreatesecretgenericdex-kubernetes-client\--from-literal=client-secret="$(opensslrand-hex32)"
dex/dex-up.sh<distro># l'étape 2 crée le client avec kcadm
helmrepoadddexhttps://charts.dexidp.io&&helmrepoupdatedex
helmupgrade--installdexdex/dex-ndex--version0.24.1--valuesdex/values.yaml
kubectlapply-fdex/httproute.yaml
kubectlapply-fdex/rbac.yaml
C'est le composant où les deux labs divergent le plus, et la divergence n'est pas dans Dex,
identique des deux côtés. Elle est dans la seule chose que ce dépôt ne possède pas : le
serveur d'API Kubernetes.
Talos
Debian 13 + kubeadm
Ce qu'est l'apiserver
un pod statique dont Talos génère le manifeste depuis la configuration machine
un pod statique dont le manifeste est un fichier sur le disque, généré par kubeadm
Comment changer ses flags
talosctl patch mc — la configuration est une API
éditer la ConfigMap kubeadm-config, puis relancer kubeadm init phasesur chaque node
Accès nécessaire
rien de plus que talosctl : il n'y a pas de SSH sur Talos
une session sur chaque control plane (vagrant ssh k8s-cpN)
Forme de extraArgs
une map (clé: valeur), Talos v1alpha1
une liste de {name, value}, kubeadm v1beta4 (Kubernetes ≥ 1.31)
Redémarrage
Talos réécrit le manifeste et redémarre l'apiserver lui-même
kubeadm réécrit le manifeste, le kubelet le voit et redémarre le pod
Où va l'AC OIDC (SELF_SIGNED=true)
une entrée machine.filesplus un montage cluster.apiServer.extraVolumes : / est en lecture seule et le pod statique ne voit que ce qu'on lui monte
un simple cp dans /etc/kubernetes/pki/, déjà monté dans le pod — rien d'autre à déclarer
Le secret du client dex doit exister dans deux namespaces : Keycloak le lit dans keycloak
(le CR KeycloakOIDCClient y vit), Dex le lit dans dex. Un Secret ne franchit pas les
namespaces.
# kcadm vit DANS l'image Keycloak : ni DNS, ni AC, ni curl sur l'hôteKC="kubectl -n keycloak exec -i keycloak-0 -- /opt/keycloak/bin/kcadm.sh"$KCconfigcredentials--serverhttp://localhost:8080--realmmaster\--user"$(kubectl-nkeycloakgetsecretkeycloak-initial-admin-ojsonpath='{.data.username}'|base64-d)"\--password"$(kubectl-nkeycloakgetsecretkeycloak-initial-admin-ojsonpath='{.data.password}'|base64-d)"$KCcreateclients-rlab-f-<<EOF{ "clientId": "dex", "enabled": true, "publicClient": false, "secret": "$(kubectl -n dex get secret dex-keycloak-client -o jsonpath='{.data.client-secret}' | base64 -d)", "standardFlowEnabled": true, "directAccessGrantsEnabled": false, "implicitFlowEnabled": false, "serviceAccountsEnabled": false, "redirectUris": [ "https://dex.${LAB_DOMAIN}/callback" ] }EOF$KCgetclients-rlab-qclientId=dex--fieldsclientId,enabled,redirectUris
Le clientId doit correspondre exactement au clientID: dex du connecteur Dex.
⚠️ Pourquoi pas le CRD KeycloakOIDCClient. Keycloak 26.7 en fournit un, et ce serait la
façon naturelle de déclarer ce client. Il ne fonctionne pas, et il échoue en silence : le
CR s'applique, kubectl apply affiche configured, l'objet existe, et il n'est jamais
réconcilié, donc le client n'apparaît jamais dans le realm. Tout ne se voit qu'au login, sous
la forme d'un invalid_client. Deux blocages indépendants, mesurés sur 26.7.0 :
le serveur exige la feature client-admin-api:v2 (cf. ../keycloak/02-keycloak.yaml),
et l'opérateur met en cache la liste des features du serveur : il continue de la
signaler absente jusqu'à ce qu'on redémarre l'opérateur lui-même
(kubectl -n keycloak rollout restart deploy/keycloak-operator) ;
ensuite, KeycloakClientBaseController cherche un Secret nommé
<nom-du-CR-keycloak>-admin, alors que le Secret d'amorçage de l'opérateur s'appelle
keycloak-initial-admin, un autre nom. Créer keycloak-admin avec username/password
ne suffit pas non plus : le contrôleur meurt alors sur
Cannot invoke "String.getBytes(...)" because "src" is null, il attend donc d'autres clés
(vraisemblablement un service account, client-id/client-secret).
Un client qui fonctionne sans être réconcilié vaut mieux qu'un client déclaratif qui n'est
jamais créé. Quand l'amont corrigera le CRD, l'étape 2 de dex-up.sh redeviendra un
kubectl apply.
Cette dernière commande ouvre un navigateur : Dex propose le connecteur Keycloak, Keycloak
demande demo et son mot de passe, et kubectl revient avec un jeton. Rien n'a été recopié,
rien n'a été signé à la main.
💡 Avec SELF_SIGNED=true, ajoute --exec-arg=--certificate-authority=<chemin de l'AC du lab>
pour que kubelogin fasse confiance à dex.<LAB_DOMAIN> lui aussi.
--insecure-skip-tls-verify marche, et enseigne le mauvais réflexe.
C'est la seule étape du dépôt qui modifie le control plane, donc la seule qu'on te laisse en
main. À lire avant de lancer quoi que ce soit :
Elle ajoute un authentifieur, elle n'en retire aucun. Le kubeconfig du lab et les
certificats client des composants continuent de marcher, ce qui rend l'opération
réversible.
Un --oidc-issuer-url faux (injoignable, ou avec un certificat non trusté) empêche
l'apiserver de démarrer. Un control plane à la fois, en vérifiant
kubectl get --raw=/readyz entre chaque ; tant qu'un control plane est sain, le cluster reste
administrable.
Pour revenir en arrière : retirer les flags de la même façon qu'on les a posés.
# Substituer le domaine neutre D'ABORD : le patch porte le placeholder public du dépôt.
sed"s|dex\.lab\.example\.io|dex.${LAB_DOMAIN}|"dex/apiserver-oidc.talos.yaml\>/tmp/oidc-issuer.talos.yaml
foripin$(kubectlgetnodes-lnode-role.kubernetes.io/control-plane\-ojsonpath='{range .items[*]}{.status.addresses[?(@.type=="InternalIP")].address}{" "}{end}');dotalosctl-n"$ip"patchmc--patch@/tmp/oidc-issuer.talos.yaml
kubectlget--raw=/readyz&&echodone
Wildcard non trusté (SELF_SIGNED=trueou ACME staging) : donner aussi l'AC du lab à l'apiserver
/ est en lecture seule sur Talos et le pod statique ne voit que ce qu'on lui monte. Deux
ajouts, donc : le fichier, et le volume qui l'expose.
# Le Secret wildcard marche dans TOUS les modes, et c'est tout l'intérêt : `tls.crt` porte# toujours son propre point d'ancrage — feuille + AC locale en self-signed (selfsigned-up.sh les# concatène exprès), feuille + intermédiaire (+ racine) en ACME. `_out/self-signed/ca.crt`# n'existe que dans un des trois modes : ne pas s'en servir.
kubectl-nenvoy-gateway-systemgetsecret"wildcard-$(echo"$LAB_DOMAIN"|tr.-)-tls"\-ojsonpath='{.data.tls\.crt}'|base64-d>/tmp/lab-ca.crt
CA=$(sed's/^/ /'/tmp/lab-ca.crt)# indenter le PEM
cat>/tmp/oidc-ca.talos.yaml<<EOFmachine: files: - path: /var/lib/oidc/ca.crt permissions: 0o644 op: create content: |$CAcluster: apiServer: extraArgs: oidc-ca-file: /etc/kubernetes/oidc/ca.crt extraVolumes: - hostPath: /var/lib/oidc mountPath: /etc/kubernetes/oidc readonly: trueEOF
talosctl-n<IPducontrolplane>patchmc--patch@/tmp/oidc-ca.talos.yaml
op: create refuse d'écraser : au second passage, utiliser overwrite.
Le manifeste de l'apiserver est un fichier sur chaque control plane, régénéré par kubeadm
depuis la ConfigMap kubeadm-config. Éditer le manifeste directement marche, jusqu'au
prochain kubeadm upgrade qui l'écrase en silence. La ConfigMap est donc la source de vérité :
⚠️ Le document InitConfiguration ci-dessous n'est pas optionnel.advertiseAddress vit
dans l'InitConfiguration, et la ConfigMap kubeadm-config ne stocke que la
ClusterConfiguration. Donner la seule ClusterConfiguration à kubeadm le fait redéfaut
sur l'interface de route par défaut : sur une VM Vagrant c'est eth0, l'adresse NAT
VirtualBox 10.0.2.15, identique sur toutes les VM et routable depuis aucune. Le Service
kubernetes annonce alors cette adresse et tous les pods perdent l'API (dial tcp 10.96.0.1:443: connect: connection refused). Le symptôme est trompeur : kubectl depuis
l'hôte continue de marcher (il passe par la VIP) et /readyz répond toujours ok.
# 1. substituer le domaine neutre, puis fusionner le bloc apiServer dans ClusterConfiguration.# Un issuer qui diffère d'un caractère = tous les tokens rejetés.
sed"s|dex\.lab\.example\.io|dex.${LAB_DOMAIN}|"dex/apiserver-oidc.kubeadm.yaml
kubectl-nkube-systemeditconfigmapkubeadm-config
# 2. sur CHAQUE control plane, régénérer le manifeste du pod statique depuis cette ConfigMap.# L'adresse d'annonce est relue depuis le manifeste actuellement en place.
vagrantsshk8s-cp1-c' ADDR=$(sudo sed -n "s/.*--advertise-address=//p" /etc/kubernetes/manifests/kube-apiserver.yaml) { printf "apiVersion: kubeadm.k8s.io/v1beta4\nkind: InitConfiguration\nlocalAPIEndpoint:\n advertiseAddress: %s\n bindPort: 6443\n---\n" "$ADDR" kubectl -n kube-system get cm kubeadm-config -o jsonpath="{.data.ClusterConfiguration}" } | sudo tee /tmp/kubeadm.yaml >/dev/null sudo kubeadm init phase control-plane apiserver --config /tmp/kubeadm.yaml'
kubectlget--raw=/readyz&&echo# 3. prouver que l'endpoint n'a PAS basculé sur l'adresse NAT
kubectlgetendpointslice-lkubernetes.io/service-name=kubernetes\-ojsonpath='{.items[*].endpoints[*].addresses[*]}';echo# attendu : l'IP host-only
Wildcard non trusté (SELF_SIGNED=trueou ACME staging) : donner aussi l'AC du lab à l'apiserver
/etc/kubernetes/pki est déjà monté dans le pod statique : le fichier suffit.
vagrantsshk8s-cp1-c'sudo tee /etc/kubernetes/pki/oidc-ca.crt >/dev/null'\</tmp/lab-ca.crt# extrait du Secret wildcard, cf. l'encart Talos ci-dessus# puis ajouter dans la ConfigMap, à côté des autres flags :# - name: oidc-ca-file# value: /etc/kubernetes/pki/oidc-ca.crt
💡 Le chemin le plus simple est de n'avoir aucun problème d'AC : avec SELF_SIGNED=false, le
wildcard est émis par Let's Encrypt, publiquement trusté, et ni l'apiserver ni kubelogin n'ont
besoin de quoi que ce soit. Cf. ../cert-manager/LISEZ-MOI.md.
le client dex est créé par l'étape 2 de dex-up.sh avec kcadm — flux STANDARD seul, une URL de redirection exacte. Le CRD KeycloakOIDCClient est inutilisable, cf. ci-dessus
values.yaml
Dex : issuer public, stockage Kubernetes, connecteur oidc vers le realm, client statique kubernetes ; les deux secrets viennent de variables d'environnement
kubectl-ndexgetpods,httproute# dex Running, route Accepted
kubectl-nkeycloakexec-ikeycloak-0--/opt/keycloak/bin/kcadm.sh\getclients-rlab-qclientId=dex--fieldsclientId,enabled,redirectUris# le client existe
curl-skhttps://dex.lab.example.io/.well-known/openid-configuration|jq.issuer
# l'apiserver voit réellement les flags (Talos comme kubeadm) :
kubectl-nkube-systemgetpod-lcomponent=kube-apiserver\-ojsonpath='{.items[0].spec.containers[0].command}'|tr',''\n'|grepoidc
# toute la chaîne, en une commande :
kubectl--context=oidcauthwhoami# oidc:demo@lab.example.io, oidc:k8s-admins
kubectl--context=oidcauthcan-i'*''*'--all-namespaces# yes
🧪 Les deux comptes — même login, deux jeux de droits#
Le realm livre deux utilisateurs, et c'est le second qui rend la démonstration
réfutable : si les deux pouvaient faire la même chose, rien ne prouverait que le claim
groups est réellement lu.
Aucune ligne de rbac.yaml ne nomme une personne : les sujets sont des groupes
(kind: Group, oidc:k8s-admins / oidc:k8s-viewers). Ajouter un collègue est une
appartenance de groupe dans Keycloak, jamais une commande kubectl.
Un second contexte, pour que les deux coexistent : même cluster, même issuer, autre compte.
La séparation du cache n'est pas un détail : sans --token-cache-dir distinct, kubelogin
réutilise le token de demo en cache et le contexte « viewer » reste silencieusement admin.
Le contrôle qui compte est celui qui doit échouer :
kubectl--context=oidc-viewerauthwhoami# oidc:viewer@lab.example.io, oidc:k8s-viewers
kubectl--context=oidc-viewerauthcan-ilistpods# yes
kubectl--context=oidc-viewerauthcan-icreatepods# no
kubectl--context=oidc-viewerauthcan-igetsecrets# no <- `view` exclut les Secrets
⚠️ view n'est pas « tout lire ». Il n'accorde rien sur les objets à portée cluster :
kubectl --context=oidc-viewer get nodes est donc Forbidden : c'est le ClusterRole qui
fonctionne, pas le login qui casse. Teste avec get pods, que view autorise bien.
Deux caches te maintiennent connecté, et c'est au second que personne ne pense.
# 1. le cache de token de kubelogin — par contexte, d'où le --token-cache-dir distinct ci-dessus
rm-rf~/.kube/cache/oidc-login~/.kube/cache/oidc-login-viewer
# 2. la session SSO Keycloak — sans ça, le navigateur s'ouvre et se referme aussitôt, et tu es# silencieusement reconnecté avec l'utilisateur PRÉCÉDENT. Rien ne le signale.
kubectl-nkeycloakexec-ikeycloak-0--/opt/keycloak/bin/kcadm.sh\configcredentials--serverhttp://localhost:8080--realmmaster\--user"$(kubectl-nkeycloakgetsecretkeycloak-initial-admin-ojsonpath='{.data.username}'|base64-d)"\--password"$(kubectl-nkeycloakgetsecretkeycloak-initial-admin-ojsonpath='{.data.password}'|base64-d)"
kubectl-nkeycloakexec-ikeycloak-0--/opt/keycloak/bin/kcadm.sh\createrealms/lab/logout-all# ferme toutes les sessions du realm
Une fenêtre de navigation privée obtient le même résultat sans toucher à aucun état, et c'est
le plus rapide pour tenir les deux identités en parallèle.
⚠️ Un logout ne révoque pas un token déjà émis. L'apiserver valide une signature et une
expiration, pas un état de session : après logout-all, un token présent dans le cache de
kubelogin reste utilisable jusqu'à son expiration. Pour réellement couper l'accès d'un compte,
désactive l'utilisateur (enabled: false), ou vide le cache local. logout-all empêche
seulement le PROCHAIN login d'être silencieux.
💡 Un auth whoami qui renvoie l'utilisateur précédent est le symptôme d'une session SSO
vivante, pas d'un contexte mal configuré. C'est le contrôle qui distingue les deux.
🧪 Scénario — l'autorisation vit dans Keycloak, pas dans le cluster#
# 1. demo est dans k8s-admins : tous les droits
kubectl--context=oidcauthcan-ideletenodes# yes# 2. Dans la console Keycloak (https://keycloak.<LAB_DOMAIN>/admin/), realm `lab` :# Users -> demo -> Groups -> quitter k8s-admins, rejoindre k8s-viewers# 3. Forcer un jeton neuf — l'ancien est en cache et reste valide jusqu'à expiration
rm-rf~/.kube/cache/oidc-login
kubectl--context=oidcauthwhoami# oidc:k8s-viewers
kubectl--context=oidcauthcan-ideletenodes# no
kubectl--context=oidcgetpods-A# marche encore : `view`
Pas une commande kubectl n'a été nécessaire pour changer ces droits, et rien n'a été révoqué
dans le cluster. C'est tout l'intérêt, et le cache est tout le bémol : un jeton déjà émis
garde ses groupes jusqu'à son expiration. Le déprovisionnement OIDC n'est jamais instantané.
oidc: id token issued by a different provider → les trois URL ne coïncident pas.
Comparer config.issuer
(kubectl -n dex get secret dex -o jsonpath='{.data.config\.yaml}' | base64 -d), le hostname
de l'HTTPRoute et --oidc-issuer-url de l'apiserver. Un / final suffit.
L'apiserver ne redémarre plus après le patch → presque toujours l'émetteur : injoignable,
ou certificat non trusté. Sur Talos, talosctl -n <ip> logs kubelet ; sur kubeadm,
sudo crictl logs $(sudo crictl ps -a --name kube-apiserver -q | head -1). Défaire les flags.
La connexion marche mais tout est Forbidden → les groupes manquent dans le jeton.
Vérifier dans l'ordre : le scope groups demandé par Dex (values.yaml),
insecureEnableGroups: true sur le connecteur, le client scope groups du realm
(../keycloak/), et le préfixe oidc: de rbac.yaml.
kubectl auth whoami affiche le bon utilisateur mais aucun groupe → mêmes causes, et
vérifier que le client Keycloak a bien reçu le scope groups : le realm l'attribue aux
nouveaux clients (defaultDefaultClientScopes) ; un client créé avant ce réglage ne l'a pas.
Le navigateur ne revient jamais / redirect_uri_mismatch → kubelogin écoute sur :8000.
L'URI doit figurer dans staticClients[].redirectURIs (Dex) ; côté Keycloak, le client ne
liste que https://dex.<LAB_DOMAIN>/callback, qui est le callback de Dex, pas celui de kubectl.
x509: certificate signed by unknown authority → SELF_SIGNED=true et quelqu'un dans la
chaîne n'a pas l'AC du lab : l'apiserver (oidc-ca-file), kubelogin
(--certificate-authority), ou Dex quand il appelle Keycloak.
Le pod Dex redémarre en boucle → kubectl -n dex logs deploy/dex. Un Secret manquant, ou
un $KEYCLOAK_CLIENT_SECRET non substitué, se voit comme un connecteur qui refuse de démarrer.
cluster-admin est donné à un groupe : quiconque Keycloak y met devient admin du cluster.
L'IdP fait désormais partie du périmètre de confiance du cluster ; qui administre le realm
administre Kubernetes.
La révocation n'est pas immédiate. Un jeton déjà délivré reste valide jusqu'à son
expiration, quoi qu'on change dans Keycloak. Kubernetes ne vérifie rien côté IdP à chaque
requête.
Le préfixe oidc: est porteur. Sans lui, un annuaire qui déclare un groupe
system:masters prend le cluster. Si tu le changes, change aussi rbac.yaml.
Ne jamais retirer les authentifieurs par certificat. OIDC s'y ajoute ; un cluster dont la
seule porte est un IdP est un cluster où l'on ne rentre plus quand l'IdP est tombé, et ici,
l'IdP tourne dans ce cluster.
insecureEnableGroups est mal nommé mais obligatoire : sans lui, le connecteur OIDC
générique de Dex jette les groupes de l'amont, sans rien dire.
Les secrets de client sont générés une fois. Relancer le script ne les fait pas tourner,
volontairement : régénérer celui de dex d'un seul côté casserait le client sans la moindre
erreur visible. Les faire tourner délibérément : les deux namespaces, puis redémarrer Dex.
Éditer /etc/kubernetes/manifests/kube-apiserver.yaml à la main sur kubeadm tient jusqu'au
prochain kubeadm upgrade, qui le régénère depuis kubeadm-config.
🔒vault-cluster/ — HashiCorp Vault HA (Raft), UI exposed over HTTPS
The lab's Vault server: 3 nodes on integrated Raft storage, 1 Longhorn PV per node,
UI + API over HTTPS at vault.lab.example.io. Not to be confused with
../vault-secret-operator/, which is the client (it syncs Vault secrets into Kubernetes
Secret objects).
A central vault for everything the lab must not store in cleartext: application secrets (KV-v2),
PostgreSQL passwords rotated automatically (database engine), internal certificates (pki
engine). Consumers never talk to Vault directly; that is the VSO's job.
What gets set up here:
Component
Lab choice
Where it is decided
High availability
3 replicas, integrated Raft (no Consul), per-node anti-affinity
values.yaml (server.ha)
Storage
one 2Gi RWO Longhorn PVC per pod (data-vault-0/1/2)
values.yaml (server.dataStorage)
TLS
terminated by Envoy; Vault listens over HTTP internally (tls_disable)
values.yaml + httproute.yaml
disable_mlock
true — harmless on both labs (Talos has no swap; kubeadm requires swap off and kubeadm/provision.sh disables it) and avoids requiring IPC_LOCK
values.yaml (raft.config)
Agent Injector
disabled: we go through the VSO, not through sidecars
Installs the chart, initializes Vault, unseals the 3 pods and applies the HTTPRoute.
Idempotent: it only initializes if Vault is not already initialized, and only unseals the pods
that are actually sealed, so it is also the command to re-run after a reboot, which always
brings the pods back sealed (§🔐). VAULT_CHART_VERSION=… overrides the chart version.
🔐 The unseal keys and the root token land in _out/vault-init.json (mode 0600, and
_out/ is gitignored). The script never prints them. That file is the only copy: lose it
and Vault is unrecoverable, so back it up outside the repo. See §🔐 below.
No distribution-specific behaviour for this component: same charts, same manifests, same
values on both labs. The distribution argument only drives two things here: the default
domain (talos.lab.example.io / kubeadm.lab.example.io) and where the lab's lab.env
/ kubeconfig live (../Vagrant-Talos/ or ../Vagrant-KubeADM/).
ℹ️ disable_mlock=true is safe on both labs: Talos has no swap, and the kubeadm lab
disables then masks it at provisioning time. The longhorn prerequisite is where the
distribution differences live.
The commands below are exactly what the all-in-one script does, in order.
Set up your shell first (once per session):
exportKUBECONFIG=../Vagrant-Talos/kubeconfig# or ../Vagrant-KubeADM/kubeconfigexportLAB_DOMAIN=talos.lab.example.io# or your own (see the lab's lab.env)
kubectlgetsclonghorn# 3 Raft PVCs: without it the pods stay Pending
2. The chart in HA mode (integrated Raft, 3 replicas)#
helmrepoaddhashicorphttps://helm.releases.hashicorp.com&&helmrepoupdatehashicorp
helmupgrade--installvaulthashicorp/vault-nvault--create-namespace\--version0.34.0\--valuesvault-cluster/values.yaml
kubectl-nvaultgetpods-w# the 3 pods come up NOT READY: they are SEALED
3. Initialise — once for the lifetime of the cluster#
The unseal keys and root token are printed HERE and nowhere else. Lose them and Vault is
unrecoverable.
vault-up.sh already did this; the commands below are the manual equivalent, useful to
understand or to recover by hand. 5 unseal keys, threshold of 3.
⚠️ vault-1 and vault-2 cannot be unsealed straight away. With integrated Raft they
start uninitialized and only join through retry_join once the leader is unsealed;
unsealing them too early fails with 400 — Vault is not initialized. vault-up.sh waits for
initialized=true on each pod before unsealing it.
# Init on pod 0 — KEEP THE OUTPUT SOMEWHERE SAFE (keys + root token).
kubectl-nvaultexecvault-0--vaultoperatorinit\-key-shares=5-key-threshold=3-format=json>vault-init.json
# Unseal vault-0 (3 distinct keys): it becomes the leaderforiin012;dokubectl-nvaultexecvault-0--vaultoperatorunseal\"$(jq-r".unseal_keys_b64[$i]"vault-init.json)"done# vault-1 and vault-2 join the Raft (retry_join), then unseal in turnforpinvault-1vault-2;doforiin012;dokubectl-nvaultexec$p--vaultoperatorunseal\"$(jq-r".unseal_keys_b64[$i]"vault-init.json)"done;done# Root token:
jq-r.root_tokenvault-init.json
⚠️ vault-init.json holds the 5 unseal keys AND the root token. Never commit it:
vault-up.sh writes it to _out/vault-init.json precisely because _out/ is gitignored.
Every pod restart (chart upgrade, node down, node reboot / vagrant halt) brings it back sealed:
re-run ./vault-cluster/vault-up.sh (or unseal by hand with 3 of the 5 keys). A real deployment would use auto-unseal (the
Transit engine of another Vault, or a cloud KMS), out of scope for the lab.
The UI and the API are exposed by the script's step [4/4]. To re-apply that route alone:
kubectlapply-fvault-cluster/httproute.yaml
🌐 Domain: the manifest carries the neutral domain lab.example.io (public repo) and
is not applied by any *-up.sh: edit the hostname, or substitute your own domain on the fly:
The VSO (../vault-secret-operator/) is already wired to
http://vault.vault.svc.cluster.local:8200 through the defaultVaultConnection from its
values.yaml. On the Vault side, what is left is enabling Kubernetes auth, the secrets engines,
the policies and the roles: it is all in ../vault-secret-operator/vault/README.md.
The HTTPRoute points at the vault-active service (the leader only): no 307 redirect
emitted by a standby. The certificate is the wildcard carried by the https listener of
main-gateway (annotation cert-manager.io/cluster-issuer, see
../envoy-gateway/Envoy-Proxy.yml). With the default LAB_ACME_ISSUER=staging it is not
publicly trusted: expect a browser warning, or use curl -k. Set LAB_ACME_ISSUER=prod for a
trusted cert, minding the 5 certificates/week cap.
⚠️ The UI is only reachable once Vault is unsealed.vault-active has no endpoint until a
leader is elected: the route then answers 503. After a cluster reboot you must unseal
every pod manually (3 keys out of 5) before the UI comes back.
longhorn (3 replicas) under Raft = 9 copies for 3 logical nodes.values.yaml:26 uses
the longhorn StorageClass, which replicates 3× at the block level, while Vault Raft
already replicates 3× at the application level. This is exactly the case
../longhorn/longhorn-r1-storageclass.yaml tells you to avoid ("replication already handled at
the application level"), and that CloudNativePG gets right with longhorn-r1. On the shared OS
disk (~20 GB/node) it feeds ReplicaSchedulingFailure. To fix it: set
server.dataStorage.storageClass to longhorn-r1. Careful, a PVC's StorageClass is
immutable: you have to delete the 3 data-vault-* PVCs, hence reinitialize Vault; do it
before you put anything in there.
Manual unsealing, after every reboot. No auto-unseal in this lab (see §🔐). A pod that
restarts is an unusable pod until 3 keys have been presented to it.
../chaos-kube/ excludes this namespace, and must keep doing so.
chaoskube deletes a random pod every hour; Raft survives the loss, but the pod comes back
sealed. Un-exclude vault and within a few hours all 3 are sealed and Vault is down;
recovery is vault-up.sh after every single kill.
Putting VAULT_ADDR / VAULT_TOKEN / VAULT_UNSEAL_KEY_* in lab.env does NOTHING. No
script in the repo reads those keys from lab.env; the only field picked out of that file is
CLOUDFLARE_API_TOKEN, by an explicit grep in ../platform-up.sh (line 30). The scripts in
../vault-secret-operator/vault/ only read the environment. To really load lab.env into
your shell:
set-a;../lab.env;set+a# exports everything the file defines
vaultstatus# must answer Sealed=false
lab.env is gitignored, but storing unseal keys in it makes it as sensitive as
vault-init.json: same precautions.
The data does not survive a PVC purge. It survives reboots (Longhorn partition), but a
helm uninstall followed by kubectl delete pvc destroys the Raft, and with it the whole
content of the vault, including everything the VSO references.
vault status returns "standby" on 2 pods out of 3: that is how HA Raft normally works (a
single leader). Write commands must target vault-active, not vault-0.
🔒vault-cluster/ — HashiCorp Vault HA (Raft), UI exposée en HTTPS
Le serveur Vault du lab : 3 nœuds en stockage Raft intégré, 1 PV Longhorn par nœud,
UI + API en HTTPS sous vault.lab.example.io. À ne pas confondre avec
../vault-secret-operator/, qui est le client (il synchronise des secrets Vault en
Secret Kubernetes).
Un coffre-fort central pour tout ce que le lab ne doit pas stocker en clair : secrets applicatifs
(KV-v2), mots de passe PostgreSQL rotés automatiquement (moteur database), certificats internes
(moteur pki). Les consommateurs ne parlent jamais à Vault directement ; c'est le rôle du VSO.
Ce qui est monté ici :
Brique
Choix du lab
Où c'est décidé
Haute dispo
3 réplicas, Raft intégré (pas de Consul), anti-affinité par node
values.yaml (server.ha)
Stockage
1 PVC Longhorn 2Gi RWO par pod (data-vault-0/1/2)
values.yaml (server.dataStorage)
TLS
terminé par Envoy ; Vault écoute en HTTP en interne (tls_disable)
values.yaml + httproute.yaml
disable_mlock
true — sans risque sur les deux labs (Talos n'a pas de swap ; kubeadm exige le swap coupé et kubeadm/provision.sh le coupe) et évite d'exiger IPC_LOCK
values.yaml (raft.config)
Agent Injector
désactivé : on passe par le VSO, pas par des sidecars
Installe le chart, initialise Vault, descelle les 3 pods et applique l'HTTPRoute.
Idempotent : n'initialise que si Vault ne l'est pas, ne descelle que les pods réellement
scellés, c'est donc aussi la commande à relancer après un reboot, qui ramène toujours les
pods scellés (§🔐). VAULT_CHART_VERSION=… surcharge la version du chart.
🔐 Les clés de descellement et le token root atterrissent dans _out/vault-init.json
(mode 0600, et _out/ est gitignoré). Le script ne les affiche jamais. Ce fichier est le
seul exemplaire : le perdre rend Vault définitivement inaccessible : sauvegarde-le hors du
dépôt. Cf. §🔐 ci-dessous.
Aucune spécificité de distribution pour ce composant : mêmes charts, mêmes manifestes,
mêmes valeurs sur les deux labs. La distribution passée en argument ne sert ici qu'à deux
choses : le domaine par défaut (talos.lab.example.io / kubeadm.lab.example.io) et la
localisation du lab.env / kubeconfig du lab (../Vagrant-Talos/ ou
../Vagrant-KubeADM/).
ℹ️ disable_mlock=true est sûr sur les deux labs : Talos n'a pas de swap, et le lab
kubeadm le coupe puis le masque au provisioning. Le prérequis longhorn porte, lui, les
différences de distribution.
Les commandes ci-dessous sont exactement ce que fait le script tout-en-un, dans
l'ordre. Prépare d'abord l'environnement (une fois par session) :
exportKUBECONFIG=../Vagrant-Talos/kubeconfig# ou ../Vagrant-KubeADM/kubeconfigexportLAB_DOMAIN=talos.lab.example.io# ou ton domaine (cf. lab.env du lab)
kubectlgetsclonghorn# 3 PVC Raft : sans elle les pods restent Pending
2. Le chart en mode HA (Raft intégré, 3 réplicas)#
helmrepoaddhashicorphttps://helm.releases.hashicorp.com&&helmrepoupdatehashicorp
helmupgrade--installvaulthashicorp/vault-nvault--create-namespace\--version0.34.0\--valuesvault-cluster/values.yaml
kubectl-nvaultgetpods-w# les 3 pods montent, NOT READY : ils sont SCELLÉS
3. Initialiser — une seule fois pour la vie du cluster#
Les clés de descellement et le root token ne sont donnés qu'ICI. Perdus = Vault
irrécupérable.
⚠️ À REFAIRE après chaque redémarrage de pod : un Vault qui redémarre repart SCELLÉ.
C'est exactement ce que vault-up.sh refait pour toi à chaque passage.
vault-up.sh l'a déjà fait ; les commandes ci-dessous sont l'équivalent manuel, utile pour
comprendre ou pour rattraper à la main. 5 clés de descellement, seuil de 3.
⚠️ vault-1 et vault-2 ne se descellent pas tout de suite. Avec le Raft intégré ils
démarrent non initialisés et ne rejoignent le cluster que par retry_join, une fois le
leader descellé ; les desceller trop tôt échoue en 400 — Vault is not initialized.
vault-up.sh attend initialized=true sur chaque pod avant de le desceller.
# Init sur le pod 0 — GARDE LA SORTIE EN LIEU SÛR (clés + root token).
kubectl-nvaultexecvault-0--vaultoperatorinit\-key-shares=5-key-threshold=3-format=json>vault-init.json
# Descelle vault-0 (3 clés distinctes) : il devient leaderforiin012;dokubectl-nvaultexecvault-0--vaultoperatorunseal\"$(jq-r".unseal_keys_b64[$i]"vault-init.json)"done# vault-1 et vault-2 rejoignent le Raft (retry_join) puis se descellent à leur tourforpinvault-1vault-2;doforiin012;dokubectl-nvaultexec$p--vaultoperatorunseal\"$(jq-r".unseal_keys_b64[$i]"vault-init.json)"done;done# Root token :
jq-r.root_tokenvault-init.json
⚠️ vault-init.json contient les 5 clés de descellement ET le root token. Ne jamais le
committer : vault-up.sh l'écrit dans _out/vault-init.json justement parce que _out/ est
gitignoré. Chaque redémarrage de pod (upgrade du chart, node down, reboot du node / vagrant halt) le fait
revenir scellé : relancer ./vault-cluster/vault-up.sh (ou desceller à la main avec
3 des 5 clés). Un vrai déploiement
utiliserait un auto-unseal (Transit d'un autre Vault, ou KMS cloud), hors scope du lab.
L'UI et l'API sont exposées par l'étape [4/4] du script. Pour réappliquer cette route seule :
kubectlapply-fvault-cluster/httproute.yaml
🌐 Domaine : le manifeste porte le domaine neutre lab.example.io (dépôt public)
et n'est pas passé par un *-up.sh : édite le hostname, ou substitue ton domaine à la volée :
Le VSO (../vault-secret-operator/) est déjà câblé sur http://vault.vault.svc.cluster.local:8200
via le VaultConnection « default » de son values.yaml. Côté Vault, il reste à activer l'auth
Kubernetes, les moteurs de secrets, les policies et les roles : tout est dans
../vault-secret-operator/vault/LISEZ-MOI.md.
Le HTTPRoute pointe le service vault-active (le leader uniquement) : pas de redirection
307 émise par un standby. Le certificat est le wildcard porté par l'écouteur https de
main-gateway (annotation cert-manager.io/cluster-issuer, cf.
../envoy-gateway/Envoy-Proxy.yml). Avec le défaut LAB_ACME_ISSUER=staging, il n'est pas
publiquement reconnu : avertissement navigateur attendu, ou curl -k. Mets
LAB_ACME_ISSUER=prod pour un cert trusté, en surveillant le plafond de 5 certificats/semaine.
⚠️ L'UI n'est accessible que si Vault est descellé.vault-active n'a aucun endpoint tant
qu'aucun leader n'est élu : la route répond alors 503. Après un reboot du cluster, il faut
redesceller manuellement chaque pod (3 clés sur 5) avant que l'UI revienne.
longhorn (3 réplicas) sous Raft = 9 copies pour 3 nœuds logiques.values.yaml:26 utilise
la StorageClass longhorn, qui réplique 3× au niveau bloc, alors que Vault Raft réplique
déjà 3× au niveau applicatif. C'est exactement le cas d'usage que
../longhorn/longhorn-r1-storageclass.yaml dit d'éviter (« réplication déjà faite au niveau
applicatif »), et que CloudNativePG traite correctement avec longhorn-r1. Sur le disque OS
partagé (~20 Go/node) ça alimente les ReplicaSchedulingFailure. Pour corriger : passer
server.dataStorage.storageClass à longhorn-r1. Attention, la StorageClass d'un PVC est
immuable : il faut supprimer les 3 PVC data-vault-*, donc réinitialiser Vault ; à faire
avant de mettre quoi que ce soit dedans.
Descellement manuel, à chaque reboot. Pas d'auto-unseal dans ce lab (cf. §🔐). Un pod qui
redémarre est un pod inutilisable jusqu'à ce que 3 clés lui soient présentées.
../chaos-kube/ exclut ce namespace, et doit continuer.
chaoskube supprime un pod au hasard toutes les heures ; le Raft survit à la perte, mais le pod
revient scellé. Retire vault de l'exclusion et en quelques heures les 3 sont scellés et
Vault tombe ; s'en sortir demande un vault-up.sh après chaque kill.
Mettre VAULT_ADDR / VAULT_TOKEN / VAULT_UNSEAL_KEY_* dans lab.env ne fait RIEN.
Aucun script du dépôt ne lit ces clés depuis lab.env ; le seul champ pioché dans ce fichier est
CLOUDFLARE_API_TOKEN, par grep explicite dans ../platform-up.sh (ligne 30). Les scripts de
../vault-secret-operator/vault/ lisent uniquement l'environnement. Pour vraiment charger
lab.env dans ton shell :
set-a;../lab.env;set+a# exporte tout ce que le fichier définit
vaultstatus# doit répondre Sealed=false
lab.env est gitignoré, mais y stocker des clés de descellement le rend aussi sensible que
vault-init.json : même précaution.
Les données ne survivent pas à une purge des PVC. Elles survivent aux reboots (partition
Longhorn), mais un helm uninstall suivi d'un kubectl delete pvc détruit le Raft, donc tout
le contenu du coffre, y compris ce que le VSO référence.
vault status renvoie « standby » sur 2 pods sur 3 : c'est le fonctionnement normal du HA
Raft (un seul leader). Les commandes d'écriture doivent viser vault-active, pas vault-0.
🔑vault-secret-operator/ — Vault secrets synced into native K8s Secret objects
The Vault Secrets Operator (VSO), HashiCorp's official operator: it surfaces Vault secrets
as standard Kubernetes Secret objects, declaratively (CRDs, not scripts). An app consumes
a plain Secret (envFrom, valueFrom, volume) and never needs to talk to Vault. The
Vault server itself lives in ../vault-cluster/.
Three Vault ↔ Kubernetes integrations exist. This directory sets up the recommended one:
Integration
Model
2026 verdict
Vault Secrets Operator (VSO)
CRD → native K8s Secret, rotation + rollout
✅ recommended (this directory)
Vault CSI Provider
mounted volume, no K8s Secret created
fine if you refuse any secret in etcd
Agent Injector (sidecar)
annotations + one sidecar per pod
⚠️ legacy / maintenance
VSO wins because it is GitOps-friendly (CRDs are versionable, values are not), because it
covers static / dynamic / PKI with a single operator, because it detects drift (drift →
resync), because it renews the leases of dynamic creds and because it restarts the
workloads that cannot reload a Secret live.
The integration is wired on both sides: an identity proven on the K8s side must match an
authorized identity on the Vault side. Each subdirectory documents its own half.
The trust link is the K8s ServiceAccount: VSO presents its JWT token, Vault validates it
through the cluster's TokenReview API, and if the SA + the namespace + the audience match the
configured role, Vault returns a token carrying the policy — so precise read rights, and
nothing more.
Order matters: Vault first (the identity must exist before a client tries to log in), then
the operator, then the CRs.
# 1. Vault side: kubernetes auth + engines + policies + roles -> see vault/README.mdexportVAULT_ADDR=https://vault.lab.example.io
exportVAULT_TOKEN=<root-token># see ../vault-cluster/README.md
./vault-secret-operator/vault/lab-kv.sh
# 2. The operator (chart pinned to 1.5.0)
./install.sh<distro>vso# or ./vault-secret-operator/vso-up.sh <distro># 3. The CRs on the cluster side -> see k8s/README.md
kubectlapply-fvault-secret-operator/k8s/nginx-test-vault/nginx-test-vault.yaml
Chart 1.5.0 = app version 1.5.0.
ℹ️ vso-up.sh installs step 2 and nothing else, on purpose. Step 1 needs an admin
VAULT_TOKEN — a credential that has no business in a script install.sh all runs in a
loop — and step 3 is inert until step 1 has created the matching role: VSO connects, Vault
refuses, and the Secret is never filled. The operator alone is harmless: it waits for CRs.
One difference only, and it is cosmetic by choice: the name of the demo KV-v2 mount, so both
labs can coexist inside a single Vault.
Talos
kubeadm
KV-v2 mount (VAULT_KV_MOUNT)
talos-lab/
kubeadm-lab/
Derived policy
talos-lab-nginx-test-vault
kubeadm-lab-nginx-test-vault
Versioned files carry the NEUTRAL marker lab-kv; it is substituted on the fly, exactly
like the domain (the render helper in lib/common.sh, and a sed in vault/lab-kv.sh).
Everything else — VSO, VaultConnection, VaultAuth, the vso-* policies, PostgreSQL rotation,
PKI — is identical on both distributions.
The commands below are exactly what the all-in-one script does, in order.
Set up your shell first (once per session):
exportKUBECONFIG=../Vagrant-Talos/kubeconfig# or ../Vagrant-KubeADM/kubeconfigexportLAB_DOMAIN=talos.lab.example.io# or your own (see the lab's lab.env)
kubectl-nvaultexecvault-0--vaultstatus|grep-E'Sealed|HA Mode'exportVAULT_ADDR="https://vault.${LAB_DOMAIN}"# or http://127.0.0.1:8200 via port-forwardexportVAULT_TOKEN=$(jq-r.root_token../Vagrant-Talos/_out/vault-init.json)
2. VAULT side: engines, Kubernetes auth, policies and roles#
sed"s/lab-kv/talos-lab/g"vault-secret-operator/k8s/nginx-test-vault/nginx-test-vault.yaml\|kubectlapply-f-# or kubeadm-lab, per distribution
kubectl-nnginx-test-vaultgetsecretnginx-test-vault-config-ojsonpath='{.data.APP_GREETING}'|base64-d;echo
# change the value in Vault…
vaultkvputtalos-lab/nginx-test-vault/configAPP_GREETING="New value"APP_COLOR=redAPP_SECRET_TOKEN=s3cr3t-v2
# …the K8s Secret follows on its own (refreshAfter), and the pod is restarted when rolloutRestartTargets is set
kubectl-nnginx-test-vaultgetsecretnginx-test-vault-config-ojsonpath='{.data.APP_GREETING}'|base64-d;echo
kubectl-nvault-secrets-operatorlogsdeploy/vault-secrets-operator-controller-manager--tail=20
creates oneVaultConnection named default on http://vault.vault.svc.cluster.local:8200: the CRs no longer have to repeat the address
defaultVaultConnection.skipTLSVerify
false
no TLS bypass (we speak HTTP internally, see ../vault-cluster/)
defaultAuthMethod.enabled
false
we prefer an explicit per-namespace VaultAuth (k8s/02-vaultauth.yaml): more readable, multi-tenant
clientCache.persistenceModel
none
cache in RAM, no dependency on the Transit engine. Enough for static secrets
clientCache.storageEncryption.enabled
false
no encrypted cache (follows from the previous point)
telemetry.serviceMonitor.enabled
false
switch to true once the Prometheus operator and its CRDs are installed (see ../observability/)
The day real dynamic creds get synced, switch back to
persistenceModel: direct-encrypted + storageEncryption.enabled: true (+ the Transit engine and
the vso-transit role, already prepared in vault/) — see the storageEncryption pitfall below.
kubectl-nvault-secrets-operatorgetpods
kubectl-nvault-secrets-operatorlogsdeploy/vault-secrets-operator-controller-manager-f
kubectlgetvaultconnection-A# the "default" created by values.yaml
kubectlgetvaultauth,vaultstaticsecret,vaultdynamicsecret,vaultpkisecret-A
The per-scenario checks live in k8s/README.md, the server-side ones in vault/README.md.
The concrete, tested path: a KV-v2 engine lab-kv/ with one subdirectory per app, and
an nginx demo that proves the whole loop.
./vault-secret-operator/vault/lab-kv.sh# Vault config
kubectlapply-fvault-secret-operator/k8s/nginx-test-vault/nginx-test-vault.yaml
# Rotation, live: change the value in Vault…
vaultkvputlab-kv/nginx-test-vault/config\APP_GREETING="Hello from Vault"APP_COLOR=greenAPP_SECRET_TOKEN=v2
# …VSO resyncs (refreshAfter 30s) -> Secret updated -> rolloutRestartTargets restarts the Deployment
kubectl-nnginx-test-vaultrolloutstatusdeploy/nginx-test-vault
Details of the objects created: k8s/README.md. Details of the Vault config: vault/README.md.
2. PostgreSQL password rotation by Vault (static role)#
Vault manages and rotates the password of an existing PostgreSQL user, and the consuming
workload is restarted automatically on every rotation. The PG server is the CloudNativePG
cluster pg-demo (see ../cloudnative-pg/).
ℹ️ Static role ≠ dynamic role. A dynamic role (<mount>/creds/<role>) has Vault
create an ephemeral user with a random name, revoked when the lease expires — the username
changes every time. A static role (<mount>/static-roles/<role>) takes over an
existing, fixed user and rotates only its password: the connection string stays stable.
It is that second mode that is set up here.
Vault (postgres admin) ──rotate password──► PG user "vault-rotate"
│ database/static-creds/vault-rotate (username + current password + ttl)
▼
VSO (VaultDynamicSecret allowStaticCreds) ──SecretTransformation──► K8s Secret pg-rotate-creds
│ DATABASE_URL + PGHOST/PGPORT/PGDATABASE/PGUSER/PGPASSWORD
▼
alpine Deployment (envFrom) ── rolloutRestartTargets ──► RESTARTED on every rotation
Scenario-specific prerequisites (in order):
⚠️ The PostgreSQL cluster must be UP. The whole loop depends on it: Vault connects to it as
admin to rotate the password, and the app connects to it with the creds. If pg-demo is missing
or stopped, writing database/config/… fails, rotations error out and the Secret is not
(re)generated.
kubectl-ncnpg-demogetclusterpg-demo# "Cluster in healthy state", 3/3 instances
# a. Superuser access (Vault connects as admin "postgres" to rotate the password)
kubectl-ncnpg-demopatchclusterpg-demo--type=merge\-p'{"spec":{"enableSuperuserAccess":true}}'# b. Database "vault" + user "vault-rotate", created once in PGPRIMARY=$(kubectl-ncnpg-demogetpods\-lcnpg.io/cluster=pg-demo,cnpg.io/instanceRole=primary-ojsonpath='{.items[0].metadata.name}')# bootstrap password: Vault replaces it right away
kubectl-ncnpg-demoexec"$PRIMARY"-cpostgres--psql-c\"CREATE ROLE \"vault-rotate\" WITH LOGIN PASSWORD 'bootstrap-temp-pw';"
kubectl-ncnpg-demoexec"$PRIMARY"-cpostgres--psql-c\"CREATE DATABASE vault OWNER \"vault-rotate\";"
Bringing it up:
exportVAULT_ADDR=http://127.0.0.1:8200# kubectl -n vault port-forward svc/vault-active 8200:8200exportVAULT_TOKEN=<root-token>
# ROTATION_PERIOD is tunable (default 3h; 2m to watch the loop live)
./vault-secret-operator/vault/pg-dynamic-rotate.sh
kubectlapply-fvault-secret-operator/k8s/pg-dynamic-rotate/pg-dynamic-rotate.yaml
Watching the rotation → the restart:
vaultreaddatabase/static-creds/vault-rotate# fixed username, password + ttl keep moving
vaultwrite-fdatabase/rotate-role/vault-rotate# force an immediate rotation
kubectl-npg-rotate-demogetdeploypg-rotate-demo-ojsonpath='{.metadata.generation}';echo
kubectl-npg-rotate-demogetpods-lapp=pg-rotate-demo-w
What the script writes into Vault: vault/README.md. The K8s objects: k8s/README.md.
⚠️ Every rotation = a rollout of the consumer. With ROTATION_PERIOD=2m, the alpine pod
restarts every 2 minutes; raise the period back up after the demo.
ℹ️ Security / lab: Vault connects as superuser postgres, the simplest option. In
production, prefer a dedicated admin role with reduced privileges (just enough to
ALTER ROLE … PASSWORD), and consider database/rotate-root so Vault also rotates its own
admin password.
How to authenticate (method, mount, role, SA, audience)
k8s/02-vaultauth.yaml
VaultAuthGlobal
VaultAuthshared across namespaces (DRY, multi-tenant)
k8s/03-vaultauthglobal.yaml
VaultStaticSecret
Syncs a KV secret (v1/v2) → Secret
k8s/10-static-kv.yaml
VaultDynamicSecret
Ephemeral creds (DB, cloud…) + lease renewal; also static roles
k8s/20-dynamic-db.yaml
VaultPKISecret
Issues and renews a TLS certificate (pki engine)
k8s/30-pki-tls.yaml
SecretTransformation
Templating: reshapes the data before the Secret is written
k8s/40-secrettransformation.yaml
HCPAuth / HCPVaultSecretsApp
HCP Vault Secrets (SaaS) variant — outside the lab
—
Least privilege, one policy at a time: one policy = one use (kv / db / pki), scoped to the
exact path. No mount wildcard. See vault/policies/.
One Vault role per app, bound to precise bound_service_account_names/_namespaces (never
*), with a dedicated audience (vault) and a short token_ttl (15m here): a stolen
token expires fast and is only good for Vault.
refreshAfter proportional to sensitivity (static) and renewalPercent (dynamic), to
renew before the lease expires.
rolloutRestartTargets everywhere: without it, a rotated secret never reaches the process.
GitOps: version the CRs, never the values. VSO writes the Secret objects, git never
sees them.
RBAC on VaultAuth: it is a door into Vault. In multi-tenant setups, VaultAuthGlobal +
allowedNamespaces frame who may use it.
storageEncryption.role sits at the wrong level in values.yaml (line 55). There it is a
sibling of method/mount/transitMount/keyName, while the vault-secrets-operator 1.5.0
chart expects it under controller.manager.clientCache.storageEncryption.**kubernetes**.role.
As it stands, the value is silently ignored and the role is "". No consequence today
(storageEncryption.enabled: false), but blocking as soon as the encrypted cache is turned on:
fix it before switching to direct-encrypted.
helmshowvalueshashicorp/vault-secrets-operator--version1.5.0# the reference structure
Login refused (permission denied / 403): the pod's SA or namespace does not match the
Vault role (bound_service_account_*), or the audience does not match
(VaultAuth.spec.kubernetes.audiences must be among the role's audience). That is 90% of the
cases — the dry-run login test in vault/README.md isolates it in a single command.
Vault cannot validate the JWT: auth/kubernetes/config is filled in wrong. Vault
in-cluster: its SA needs system:auth-delegator (the chart does it via
server.authDelegator.enabled=true). Vault external: kubernetes_host +
kubernetes_ca_cert + token_reviewer_jwt are all three mandatory. And watch out for the
vault/01-kubernetes-auth.sh bug documented in vault/README.md.
disable_iss_validation: true by default since Vault 1.9 — do not set it back to false
with short projected tokens (iss varies), or every login breaks.
Secret never updated inside the pod: rolloutRestartTargets is missing. The K8s Secret is
up to date (kubectl get secret proves it); it is the process that keeps the old value.
Orphaned dynamic creds after an operator crash: the client cache is in memory
(persistenceModel: none), so the leases are lost on restart and Vault keeps active creds that
nobody claims any more. Acceptable for static secrets, not for dynamic ones.
Vault TLS CA: over HTTPS with a private CA, give caCertSecretRef to the
VaultConnection, otherwise x509: certificate signed by unknown authority.
skipTLSVerify: true = lab only.
The k8s/20-dynamic-db.yaml demo is broken by a mount mismatch (db vs database):
details and fix in vault/README.md.
🔑vault-secret-operator/ — secrets Vault synchronisés en Secret K8s natifs
Le Vault Secrets Operator (VSO), opérateur officiel HashiCorp : il fait remonter des secrets
Vault dans des Secret Kubernetes standards, en déclaratif (des CRD, pas des scripts). Une
app consomme un Secret normal (envFrom, valueFrom, volume) et n'a jamais besoin de parler
à Vault. Le serveur Vault, lui, est dans ../vault-cluster/.
Trois intégrations Vault ↔ Kubernetes existent. Ce dossier monte celle qui est recommandée :
Intégration
Modèle
Verdict 2026
Vault Secrets Operator (VSO)
CRD → Secret K8s natif, rotation + rollout
✅ recommandé (ce dossier)
Vault CSI Provider
volume monté, aucun Secret K8s créé
ok si on refuse tout secret dans etcd
Agent Injector (sidecar)
annotations + un sidecar par pod
⚠️ legacy / maintenance
VSO gagne parce qu'il est GitOps-friendly (les CRD sont versionnables, les valeurs non), qu'il
couvre static / dynamic / PKI avec un seul opérateur, qu'il détecte la dérive (drift →
resync), qu'il renouvelle les leases des creds dynamiques et qu'il redémarre les workloads
incapables de recharger un Secret à chaud.
L'intégration se câble des deux côtés : une identité prouvée côté K8s doit correspondre à une
identité autorisée côté Vault. Chaque sous-dossier documente sa moitié.
Le maillon de confiance est le ServiceAccount K8s : VSO présente son token JWT, Vault le
valide via l'API TokenReview du cluster, et si le SA + le namespace + l'audience correspondent
au role configuré, Vault renvoie un token porteur de la policy — donc des droits de lecture
précis, et rien de plus.
L'ordre compte : Vault d'abord (l'identité doit exister avant qu'un client tente de se logger),
puis l'opérateur, puis les CR.
# 1. Côté Vault : auth kubernetes + moteurs + policies + roles -> voir vault/LISEZ-MOI.mdexportVAULT_ADDR=https://vault.lab.example.io
exportVAULT_TOKEN=<root-token># cf. ../vault-cluster/LISEZ-MOI.md
./vault-secret-operator/vault/lab-kv.sh
# 2. L'opérateur (chart épinglé en 1.5.0)
./install.sh<distro>vso# ou ./vault-secret-operator/vso-up.sh <distro># 3. Les CR côté cluster -> voir k8s/LISEZ-MOI.md
kubectlapply-fvault-secret-operator/k8s/nginx-test-vault/nginx-test-vault.yaml
Chart 1.5.0 = app version 1.5.0.
ℹ️ vso-up.sh pose l'étape 2 et rien d'autre, volontairement. L'étape 1 exige un
VAULT_TOKEN d'admin — un secret qui n'a rien à faire dans un script que install.sh all
enchaîne en boucle — et l'étape 3 est inerte tant que l'étape 1 n'a pas créé le role
correspondant : VSO se connecte, Vault refuse, et le Secret n'est jamais rempli.
L'opérateur seul est inoffensif : il attend des CR.
Une seule différence, et elle est cosmétique par choix : le nom du moteur KV-v2 de
démonstration, pour que les deux labs puissent coexister dans un même Vault.
Talos
kubeadm
Moteur KV-v2 (VAULT_KV_MOUNT)
talos-lab/
kubeadm-lab/
Policy dérivée
talos-lab-nginx-test-vault
kubeadm-lab-nginx-test-vault
Les fichiers versionnés portent le marqueur NEUTRE lab-kv ; il est substitué à la volée,
exactement comme le domaine (fonction render de lib/common.sh, et sed dans
vault/lab-kv.sh). Tout le reste — VSO, VaultConnection, VaultAuth, policies vso-*, rotation
PostgreSQL, PKI — est identique sur les deux distributions.
Les commandes ci-dessous sont exactement ce que fait le script tout-en-un, dans
l'ordre. Prépare d'abord l'environnement (une fois par session) :
exportKUBECONFIG=../Vagrant-Talos/kubeconfig# ou ../Vagrant-KubeADM/kubeconfigexportLAB_DOMAIN=talos.lab.example.io# ou ton domaine (cf. lab.env du lab)
kubectl-nvaultexecvault-0--vaultstatus|grep-E'Sealed|HA Mode'exportVAULT_ADDR="https://vault.${LAB_DOMAIN}"# ou http://127.0.0.1:8200 en port-forwardexportVAULT_TOKEN=$(jq-r.root_token../Vagrant-Talos/_out/vault-init.json)
2. Côté VAULT : moteurs, auth Kubernetes, policies et roles#
sed"s/lab-kv/talos-lab/g"vault-secret-operator/k8s/nginx-test-vault/nginx-test-vault.yaml\|kubectlapply-f-# ou kubeadm-lab selon la distro
kubectl-nnginx-test-vaultgetsecretnginx-test-vault-config-ojsonpath='{.data.APP_GREETING}'|base64-d;echo
# on change la valeur dans Vault…
vaultkvputtalos-lab/nginx-test-vault/configAPP_GREETING="Nouvelle valeur"APP_COLOR=redAPP_SECRET_TOKEN=s3cr3t-v2
# …le Secret K8s suit tout seul (refreshAfter), et le pod est redémarré si rolloutRestartTargets est posé
kubectl-nnginx-test-vaultgetsecretnginx-test-vault-config-ojsonpath='{.data.APP_GREETING}'|base64-d;echo
kubectl-nvault-secrets-operatorlogsdeploy/vault-secrets-operator-controller-manager--tail=20
pose unVaultConnection nommé default sur http://vault.vault.svc.cluster.local:8200 : les CR n'ont plus à répéter l'adresse
defaultVaultConnection.skipTLSVerify
false
pas de contournement TLS (on parle en HTTP en interne, cf. ../vault-cluster/)
defaultAuthMethod.enabled
false
on préfère un VaultAuth explicite par namespace (k8s/02-vaultauth.yaml) : plus lisible, multi-tenant
clientCache.persistenceModel
none
cache en RAM, aucune dépendance au moteur Transit. Suffisant pour du statique
clientCache.storageEncryption.enabled
false
pas de cache chiffré (découle du point précédent)
telemetry.serviceMonitor.enabled
false
à passer à true si l'opérateur Prometheus et ses CRD sont installés (cf. ../observability/)
Le jour où on synchronise de vrais creds dynamiques, repasser en
persistenceModel: direct-encrypted + storageEncryption.enabled: true (+ moteur Transit et role
vso-transit, déjà préparés dans vault/) — voir le piège sur storageEncryption plus bas.
kubectl-nvault-secrets-operatorgetpods
kubectl-nvault-secrets-operatorlogsdeploy/vault-secrets-operator-controller-manager-f
kubectlgetvaultconnection-A# le "default" posé par values.yaml
kubectlgetvaultauth,vaultstaticsecret,vaultdynamicsecret,vaultpkisecret-A
Les vérifications par scénario sont dans k8s/LISEZ-MOI.md, celles côté serveur dans
vault/LISEZ-MOI.md.
1. Secret KV du lab → variables d'env → redémarrage auto (nginx-test-vault)#
Le chemin concret et testé : un moteur KV-v2 lab-kv/ avec un sous-dossier par appli, et
une démo nginx qui prouve la boucle complète.
./vault-secret-operator/vault/lab-kv.sh# config Vault
kubectlapply-fvault-secret-operator/k8s/nginx-test-vault/nginx-test-vault.yaml
# La rotation, en direct : on change la valeur dans Vault…
vaultkvputlab-kv/nginx-test-vault/config\APP_GREETING="Hello from Vault"APP_COLOR=greenAPP_SECRET_TOKEN=v2
# …VSO resync (refreshAfter 30s) -> Secret mis à jour -> rolloutRestartTargets relance le Deployment
kubectl-nnginx-test-vaultrolloutstatusdeploy/nginx-test-vault
Détail des objets créés : k8s/LISEZ-MOI.md. Détail de la config Vault : vault/LISEZ-MOI.md.
2. Rotation du mot de passe PostgreSQL par Vault (static role)#
Vault gère et fait tourner le mot de passe d'un utilisateur PostgreSQL existant, et le workload
consommateur est redémarré automatiquement à chaque rotation. Le serveur PG est le cluster
CloudNativePG pg-demo (cf. ../cloudnative-pg/).
ℹ️ Static role ≠ dynamic role. Un dynamic role (<mount>/creds/<role>) fait créer par
Vault un user éphémère au nom aléatoire, révoqué à l'expiration du lease — le username change à
chaque fois. Un static role (<mount>/static-roles/<role>) prend en gestion un user
existant et fixe et n'en rotate que le mot de passe : la chaîne de connexion reste stable.
C'est ce second mode qui est monté ici.
⚠️ Le cluster PostgreSQL doit être UP. Toute la boucle en dépend : Vault s'y connecte en admin
pour rotater le mot de passe, et l'app s'y connecte avec les creds. Si pg-demo est absent ou
arrêté, l'écriture de database/config/… échoue, les rotations tombent en erreur et le Secret
n'est pas (re)généré.
kubectl-ncnpg-demogetclusterpg-demo# "Cluster in healthy state", 3/3 instances
# a. Accès superuser (Vault se connecte en admin "postgres" pour rotater le password)
kubectl-ncnpg-demopatchclusterpg-demo--type=merge\-p'{"spec":{"enableSuperuserAccess":true}}'# b. Base "vault" + user "vault-rotate" créés une fois dans PGPRIMARY=$(kubectl-ncnpg-demogetpods\-lcnpg.io/cluster=pg-demo,cnpg.io/instanceRole=primary-ojsonpath='{.items[0].metadata.name}')# mot de passe bootstrap : il sera immédiatement remplacé par Vault
kubectl-ncnpg-demoexec"$PRIMARY"-cpostgres--psql-c\"CREATE ROLE \"vault-rotate\" WITH LOGIN PASSWORD 'bootstrap-temp-pw';"
kubectl-ncnpg-demoexec"$PRIMARY"-cpostgres--psql-c\"CREATE DATABASE vault OWNER \"vault-rotate\";"
Mise en route :
exportVAULT_ADDR=http://127.0.0.1:8200# kubectl -n vault port-forward svc/vault-active 8200:8200exportVAULT_TOKEN=<root-token>
# ROTATION_PERIOD réglable (défaut 3h ; 2m pour observer la boucle en direct)
./vault-secret-operator/vault/pg-dynamic-rotate.sh
kubectlapply-fvault-secret-operator/k8s/pg-dynamic-rotate/pg-dynamic-rotate.yaml
Observer la rotation → le redémarrage :
vaultreaddatabase/static-creds/vault-rotate# username fixe, password + ttl qui bougent
vaultwrite-fdatabase/rotate-role/vault-rotate# forcer une rotation immédiate
kubectl-npg-rotate-demogetdeploypg-rotate-demo-ojsonpath='{.metadata.generation}';echo
kubectl-npg-rotate-demogetpods-lapp=pg-rotate-demo-w
Ce que le script écrit dans Vault : vault/LISEZ-MOI.md. Les objets K8s : k8s/LISEZ-MOI.md.
⚠️ Chaque rotation = un rollout du consommateur. Avec ROTATION_PERIOD=2m, le pod alpine
redémarre toutes les 2 minutes ; remonter la période après la démo.
ℹ️ Sécurité / lab : Vault se connecte en superuser postgres, le plus simple. En prod,
préférer un role d'admin dédié à privilèges réduits (juste de quoi ALTER ROLE … PASSWORD), et
envisager database/rotate-root pour que Vault rotate aussi son propre mot de passe admin.
Comment s'authentifier (méthode, mount, role, SA, audience)
k8s/02-vaultauth.yaml
VaultAuthGlobal
VaultAuthmutualisé entre namespaces (DRY, multi-tenant)
k8s/03-vaultauthglobal.yaml
VaultStaticSecret
Synchronise un secret KV (v1/v2) → Secret
k8s/10-static-kv.yaml
VaultDynamicSecret
Creds éphémères (DB, cloud…) + renouvellement de lease ; aussi les static roles
k8s/20-dynamic-db.yaml
VaultPKISecret
Émet et renouvelle un certificat TLS (moteur pki)
k8s/30-pki-tls.yaml
SecretTransformation
Templating : reformate les données avant écriture du Secret
k8s/40-secrettransformation.yaml
HCPAuth / HCPVaultSecretsApp
Variante HCP Vault Secrets (SaaS) — hors lab
—
Moindre privilège par policy : une policy = un usage (kv / db / pki), scopée au chemin exact.
Aucun wildcard de mount. Cf. vault/policies/.
Un role Vault par app, bindé à des bound_service_account_names/_namespaces précis (jamais
*), avec une audience dédiée (vault) et un token_ttl court (15m ici) : un token volé
expire vite et ne vaut que pour Vault.
refreshAfter proportionnel à la sensibilité (static) et renewalPercent (dynamic), pour
renouveler avant expiration du lease.
rolloutRestartTargets systématique : sans lui, un secret roté n'atteint jamais le process.
GitOps : versionner les CR, jamais les valeurs. VSO écrit les Secret, git ne les voit
pas.
RBAC sur les VaultAuth : c'est une porte d'entrée vers Vault. En multi-tenant,
VaultAuthGlobal + allowedNamespaces cadrent qui peut s'en servir.
storageEncryption.role est au mauvais niveau dans values.yaml (ligne 55). Il y est frère
de method/mount/transitMount/keyName, alors que le chart vault-secrets-operator 1.5.0
l'attend sous controller.manager.clientCache.storageEncryption.**kubernetes**.role. Tel quel, la
valeur est ignorée en silence et le role vaut "". Sans conséquence aujourd'hui
(storageEncryption.enabled: false), mais bloquant dès qu'on active le cache chiffré : à corriger
avant de passer en direct-encrypted.
helmshowvalueshashicorp/vault-secrets-operator--version1.5.0# la structure de référence
Login refusé (permission denied / 403) : le SA ou le namespace du pod ne correspond pas au
role Vault (bound_service_account_*), ou l'audience ne matche pas
(VaultAuth.spec.kubernetes.audiences doit être dans les audience du role). C'est 90 % des
cas — le test de login « à blanc » de vault/LISEZ-MOI.md l'isole en une commande.
Vault ne peut pas valider le JWT : auth/kubernetes/config mal renseigné. Vault
in-cluster : son SA doit avoir system:auth-delegator (le chart le fait via
server.authDelegator.enabled=true). Vault externe : kubernetes_host +
kubernetes_ca_cert + token_reviewer_jwt sont tous les trois obligatoires. Et attention au bug
de vault/01-kubernetes-auth.sh documenté dans vault/LISEZ-MOI.md.
disable_iss_validation : true par défaut depuis Vault 1.9 — ne pas le repasser à false
avec des tokens projetés courts (l'iss varie), sinon tous les logins cassent.
Secret jamais mis à jour dans le pod : il manque rolloutRestartTargets. Le Secret K8s est
bien à jour (kubectl get secret le prouve) ; c'est le process qui garde l'ancienne valeur.
Creds dynamiques orphelins après un crash de l'opérateur : le cache client est en mémoire
(persistenceModel: none), donc les leases sont perdus au redémarrage et Vault garde des creds
actifs que plus personne ne réclame. Acceptable pour du statique, pas pour du dynamique.
CA TLS de Vault : en HTTPS avec une CA privée, fournir caCertSecretRef au VaultConnection,
sinon x509: certificate signed by unknown authority. skipTLSVerify: true = lab uniquement.
La démo k8s/20-dynamic-db.yaml est cassée par un décalage de mount (db vs database) :
détail et correctif dans vault/LISEZ-MOI.md.
The cluster half of the VSO wiring: the declarative resources the operator watches to
produce Kubernetes Secret objects. It is all kubectl from here. The server-side counterpart
(auth, engines, policies, roles) lives in ../vault/.
Vault*Secret ──spec.vaultAuthRef──► VaultAuth ──► VaultConnection (or the "default" from values.yaml)
│
└─► Vault role ──► Vault policy
The VaultAuth carries the Vault role; the role carries the policy. One broken link (role
name, SA, namespace, audience, mount) gives SecretSynced: false in the CR's events.
Path B — the numbered teaching CRs (namespace demo)#
kubectlapply-f00-namespace-rbac.yaml# ns "demo" + ServiceAccount "vso-app"
kubectlapply-f01-vaultconnection.yaml# optional if defaultVaultConnection is on
kubectlapply-f02-vaultauth.yaml# 3 VaultAuth: static / dynamic / pki# 03 = multi-tenant variant (VaultAuthGlobal), INSTEAD OF 02
kubectlapply-f10-static-kv.yaml# KV-v2 -> Secret "static-kv"
kubectlapply-f20-dynamic-db.yaml# ephemeral DB creds (see ⚠️ Pitfalls: broken mount)
kubectlapply-f30-pki-tls.yaml# TLS certificate -> Secret "pki-tls"
kubectlapply-f40-secrettransformation.yaml# templating -> Secret "app-env"
kubectlapply-f50-demo-deployment.yaml# app consuming the 3 Secrets + taking the rollouts
🌐 Domain: 30-pki-tls.yaml requests the CN demo-app.lab.example.io (the public
repo's neutral domain). It must stay inside the allowed_domains of the PKI role, itself
derived from LAB_DOMAIN by ../vault/00-secrets-engines.sh. If you have your own domain:
sed 's/lab\.example\.io/kubeadm.lab.my-domain.tld/g' 30-pki-tls.yaml | kubectl apply -f -
(see ../../README.md).
One difference only, and it is cosmetic by choice: the name of the demo KV-v2 mount, so both
labs can coexist inside a single Vault.
Talos
kubeadm
KV-v2 mount (VAULT_KV_MOUNT)
talos-lab/
kubeadm-lab/
Derived policy
talos-lab-nginx-test-vault
kubeadm-lab-nginx-test-vault
Versioned files carry the NEUTRAL marker lab-kv; it is substituted on the fly, exactly
like the domain (the render helper in lib/common.sh, and a sed in vault/lab-kv.sh).
Everything else — VSO, VaultConnection, VaultAuth, the vso-* policies, PostgreSQL rotation,
PKI — is identical on both distributions.
The commands below are exactly what the all-in-one script does, in order.
Set up your shell first (once per session):
exportKUBECONFIG=../Vagrant-Talos/kubeconfig# or ../Vagrant-KubeADM/kubeconfigexportLAB_DOMAIN=talos.lab.example.io# or your own (see the lab's lab.env)
These manifests are applied by hand, so they do NOT get automatic substitution. Two
markers to replace for your lab: lab.example.io (domain) and lab-kv (KV mount).
kubectlapply-f20-dynamic-db.yaml
kubectl-ndemogetvaultdynamicsecret
kubectl-ndemogetsecretdynamic-db-demo-ojsonpath='{.data.username}'|base64-d;echo# read it again in a few minutes: the user has CHANGED (ephemeral creds)
kubectlapply-f40-secrettransformation.yaml# rename/reshape the keys
kubectlapply-f50-demo-deployment.yaml# an app consuming the Secret
kubectl-ndemologsdeploy/vso-demo--tail=10
the app-env transformation (DATABASE_URL, APP_PASSWORD, excludeRaw: true) + the static-kv-templated CR that uses it
50-demo-deployment.yaml
Deployment
busybox:1.38: envFrom on static-kv, env key by key on dynamic-db, volume mounted from pki-tls
nginx-test-vault/ — lab KV secret → env vars → rollout#
The complete loop, and the easiest one to watch. Objects created (all in the
nginx-test-vault ns):
Object
Detail
Namespace + ServiceAccount nginx-test-vault
the identity the Vault role of the same name expects
VaultAuth nginx-test-vault
mount kubernetes, role nginx-test-vault, audiences: [vault]
VaultStaticSecret nginx-test-vault-config
type: kv-v2, mount: lab-kv, path: nginx-test-vault/config, refreshAfter: 30s, hmacSecretData: true (detects drift without logging the values), rolloutRestartTargets → the Deployment
Deployment nginx-test-vault
nginx:1.30-alpine, 2 replicas, envFrom on the Secret → APP_GREETING / APP_COLOR / APP_SECRET_TOKEN
pg-dynamic-rotate/ — PostgreSQL password rotated by Vault#
Static role: the username stays fixed, only the password changes; the consumer is restarted on
every rotation. The matching Vault config is detailed in ../vault/README.md, the full scenario
(PostgreSQL prerequisites included) in ../README.md.
Object
Detail
Namespace + ServiceAccount pg-rotate
identity bound to the Vault role pg-rotate-demo
VaultAuth pg-rotate
mount kubernetes, role pg-rotate-demo, audiences: [vault]
# Path B (ns demo)
kubectl-ndemogetvaultauth,vaultstaticsecret,vaultdynamicsecret,vaultpkisecret
kubectl-ndemodescribevaultstaticsecretstatic-kv# events: "Secret synced"
kubectl-ndemogetsecret# static-kv, dynamic-db, pki-tls, app-env
kubectl-ndemogetsecretstatic-kv-ojsonpath='{.data.password}'|base64-d;echo
kubectl-ndemologsdeploy/demo-app# the injected DB_/APP_ variables# Path A — nginx: the secret -> env -> rollout loop
kubectl-nnginx-test-vaultgetvaultstaticsecretnginx-test-vault-config# SecretSynced=TruePOD=$(kubectl-nnginx-test-vaultgetpod-lapp=nginx-test-vault-ojsonpath='{.items[0].metadata.name}')
kubectl-nnginx-test-vaultexec"$POD"--env|grep'^APP_'# Path A — PostgreSQL: the rendered DSN + the proof of the restart
kubectl-npg-rotate-demogetsecretpg-rotate-creds-ojsonpath='{.data.DATABASE_URL}'|base64-d;echo
kubectl-npg-rotate-demogetdeploypg-rotate-demo-ojsonpath='{.metadata.generation}';echo# On a sync problem, the source of truth remains the operator logs:
kubectl-nvault-secrets-operatorlogsdeploy/vault-secrets-operator-controller-manager-f
20-dynamic-db.yaml cannot sync as it stands. It asks for mount: db, but
../vault/00-secrets-engines.sh:19 mounts the database engine on database/ (no
-path=db), and the vso-dynamic-db.hcl policy only allows db/creds/demo-app. Result: a
systematic 403/404. Fix on the Vault side (vault secrets enable -path=db database) and full
explanation in ../vault/README.md. The database engine also needs a connection and a
creds/demo-app role pointing at a real database — both left commented out in the script.
50-demo-deployment.yaml will not start if one of the 3 Secrets is missing. No reference is
marked optional: true: without dynamic-db (see the previous pitfall) the pod stays in
CreateContainerConfigError, and without pki-tls it stays stuck in ContainerCreating (volume
not found). The diagnosis is in kubectl -n demo describe pod, not in the logs.
02 and 03 are two alternatives, not two steps. Applying both creates two VaultAuth for
the same role — pointless, and a source of confusion about which one a CR actually uses.
SecretSynced: false: read the CR's event (kubectl describe). In practice it is either a
refused login (role / SA / namespace / audience — see ../vault/README.md), or a wrong
mount/path.
The Secret changes but the pod does not: rolloutRestartTargets is missing. The K8s
Secret is up to date (kubectl get secret proves it), but the process keeps the old value in
memory.
VaultPKISecret refused: commonName outside the allowed_domains of the PKI role, or a
requested ttl larger than the role's max_ttl (72h here).
excludes + transformationRefs: without excludes: [".*"] (or excludeRaw: true), the
rendered Secret also contains Vault's raw keys (username, password, ttl…). Handy for
debugging, best avoided when you want a clean Secret.
La moitié cluster du câblage VSO : les ressources déclaratives que l'opérateur surveille pour
produire des Secret Kubernetes. Tout se joue au kubectl. Le pendant côté serveur (auth,
moteurs, policies, roles) est dans ../vault/.
Vault*Secret ──spec.vaultAuthRef──► VaultAuth ──► VaultConnection (ou le « default » de values.yaml)
│
└─► role Vault ──► policy Vault
Le VaultAuth porte le role Vault ; le role porte la policy. Un maillon cassé (nom de
role, SA, namespace, audience, mount) donne SecretSynced: false dans les events du CR.
Parcours B — les CR pédagogiques numérotés (namespace demo)#
kubectlapply-f00-namespace-rbac.yaml# ns "demo" + ServiceAccount "vso-app"
kubectlapply-f01-vaultconnection.yaml# optionnel si defaultVaultConnection est actif
kubectlapply-f02-vaultauth.yaml# 3 VaultAuth : static / dynamic / pki# 03 = variante multi-tenant (VaultAuthGlobal), À LA PLACE de 02
kubectlapply-f10-static-kv.yaml# KV-v2 -> Secret "static-kv"
kubectlapply-f20-dynamic-db.yaml# creds DB éphémères (voir ⚠️ Pièges : mount cassé)
kubectlapply-f30-pki-tls.yaml# certificat TLS -> Secret "pki-tls"
kubectlapply-f40-secrettransformation.yaml# templating -> Secret "app-env"
kubectlapply-f50-demo-deployment.yaml# app qui consomme les 3 Secret + reçoit les rollouts
🌐 Domaine : 30-pki-tls.yaml demande un CN demo-app.lab.example.io (domaine
neutre du dépôt public). Il doit rester dans l'allowed_domains du role PKI, lui-même
posé depuis LAB_DOMAIN par ../vault/00-secrets-engines.sh. Si tu as ton propre domaine :
sed 's/lab\.example\.io/kubeadm.lab.mon-domaine.tld/g' 30-pki-tls.yaml | kubectl apply -f -
(cf. ../../LISEZ-MOI.md).
Une seule différence, et elle est cosmétique par choix : le nom du moteur KV-v2 de
démonstration, pour que les deux labs puissent coexister dans un même Vault.
Talos
kubeadm
Moteur KV-v2 (VAULT_KV_MOUNT)
talos-lab/
kubeadm-lab/
Policy dérivée
talos-lab-nginx-test-vault
kubeadm-lab-nginx-test-vault
Les fichiers versionnés portent le marqueur NEUTRE lab-kv ; il est substitué à la volée,
exactement comme le domaine (fonction render de lib/common.sh, et sed dans
vault/lab-kv.sh). Tout le reste — VSO, VaultConnection, VaultAuth, policies vso-*, rotation
PostgreSQL, PKI — est identique sur les deux distributions.
Les commandes ci-dessous sont exactement ce que fait le script tout-en-un, dans
l'ordre. Prépare d'abord l'environnement (une fois par session) :
exportKUBECONFIG=../Vagrant-Talos/kubeconfig# ou ../Vagrant-KubeADM/kubeconfigexportLAB_DOMAIN=talos.lab.example.io# ou ton domaine (cf. lab.env du lab)
Ces manifestes s'appliquent à la main : ils ne bénéficient donc PAS de la substitution
automatique. Deux marqueurs à remplacer selon ton lab : lab.example.io (domaine) et
lab-kv (moteur KV).
kubectlapply-f20-dynamic-db.yaml
kubectl-ndemogetvaultdynamicsecret
kubectl-ndemogetsecretdynamic-db-demo-ojsonpath='{.data.username}'|base64-d;echo# relire dans quelques minutes : l'utilisateur a CHANGÉ (creds éphémères)
7. Transformation de secret + application de démo#
kubectlapply-f40-secrettransformation.yaml# renommer/reformater les clés
kubectlapply-f50-demo-deployment.yaml# une app qui consomme le Secret
kubectl-ndemologsdeploy/vso-demo--tail=10
La boucle complète, la plus simple à observer. Objets créés (tous dans le ns
nginx-test-vault) :
Objet
Détail
Namespace + ServiceAccount nginx-test-vault
l'identité attendue par le role Vault du même nom
VaultAuth nginx-test-vault
mount kubernetes, role nginx-test-vault, audiences: [vault]
VaultStaticSecret nginx-test-vault-config
type: kv-v2, mount: lab-kv, path: nginx-test-vault/config, refreshAfter: 30s, hmacSecretData: true (détecte la dérive sans logguer les valeurs), rolloutRestartTargets → le Deployment
Deployment nginx-test-vault
nginx:1.30-alpine, 2 réplicas, envFrom sur le Secret → APP_GREETING / APP_COLOR / APP_SECRET_TOKEN
pg-dynamic-rotate/ — mot de passe PostgreSQL roté par Vault#
Static role : le username reste fixe, seul le mot de passe change ; le consommateur est
relancé à chaque rotation. La config Vault correspondante est détaillée dans ../vault/LISEZ-MOI.md,
le scénario complet (prérequis PostgreSQL inclus) dans ../LISEZ-MOI.md.
Objet
Détail
Namespace + ServiceAccount pg-rotate
identité bindée au role Vault pg-rotate-demo
VaultAuth pg-rotate
mount kubernetes, role pg-rotate-demo, audiences: [vault]
mount: database, path: static-creds/vault-rotate, allowStaticCreds: true ; excludes: [".*"] pour ne garder que les clés templatées → Secret pg-rotate-creds ; rolloutRestartTargets → le Deployment
Deployment pg-rotate-demo
alpine:3.23, envFrom sur pg-rotate-creds : la DSN arrive dans ses variables d'env
# Parcours B (ns demo)
kubectl-ndemogetvaultauth,vaultstaticsecret,vaultdynamicsecret,vaultpkisecret
kubectl-ndemodescribevaultstaticsecretstatic-kv# events : "Secret synced"
kubectl-ndemogetsecret# static-kv, dynamic-db, pki-tls, app-env
kubectl-ndemogetsecretstatic-kv-ojsonpath='{.data.password}'|base64-d;echo
kubectl-ndemologsdeploy/demo-app# les variables DB_/APP_ injectées# Parcours A — nginx : la boucle secret -> env -> rollout
kubectl-nnginx-test-vaultgetvaultstaticsecretnginx-test-vault-config# SecretSynced=TruePOD=$(kubectl-nnginx-test-vaultgetpod-lapp=nginx-test-vault-ojsonpath='{.items[0].metadata.name}')
kubectl-nnginx-test-vaultexec"$POD"--env|grep'^APP_'# Parcours A — PostgreSQL : la DSN rendue + la preuve du redémarrage
kubectl-npg-rotate-demogetsecretpg-rotate-creds-ojsonpath='{.data.DATABASE_URL}'|base64-d;echo
kubectl-npg-rotate-demogetdeploypg-rotate-demo-ojsonpath='{.metadata.generation}';echo# Sur un problème de synchro, la source de vérité reste les logs de l'opérateur :
kubectl-nvault-secrets-operatorlogsdeploy/vault-secrets-operator-controller-manager-f
20-dynamic-db.yaml ne peut pas se synchroniser en l'état. Il demande mount: db, mais
../vault/00-secrets-engines.sh:19 monte le moteur database sur database/ (pas de
-path=db), et la policy vso-dynamic-db.hcl n'autorise que db/creds/demo-app. Résultat :
403/404 systématique. Correction côté Vault (vault secrets enable -path=db database) et
explication complète dans ../vault/LISEZ-MOI.md. Le moteur database a aussi besoin d'une
connexion et d'un role creds/demo-app pointant une vraie base — laissés en commentaire dans le
script.
50-demo-deployment.yaml ne démarre pas si un des 3 Secret manque. Aucune référence n'est
marquée optional: true : sans dynamic-db (cf. piège précédent) le pod reste en
CreateContainerConfigError, et sans pki-tls il reste bloqué en ContainerCreating (volume
introuvable). Le diagnostic est dans kubectl -n demo describe pod, pas dans les logs.
02 et 03 sont deux alternatives, pas deux étapes. Appliquer les deux crée deux VaultAuth
pour le même role — inutile, et source de confusion sur lequel un CR utilise réellement.
SecretSynced: false : lire l'event du CR (kubectl describe). En pratique soit un login
refusé (role / SA / namespace / audience — cf. ../vault/LISEZ-MOI.md), soit un mount/path faux.
Le Secret change mais pas le pod : il manque rolloutRestartTargets. Le Secret K8s est
bien à jour (kubectl get secret le prouve), mais le process garde l'ancienne valeur en mémoire.
VaultPKISecret refusé : commonName hors des allowed_domains du role PKI, ou ttl
demandé supérieur au max_ttl du role (72h ici).
excludes + transformationRefs : sans excludes: [".*"] (ou excludeRaw: true), le Secret
rendu contient en plus les clés brutes de Vault (username, password, ttl…). Pratique
pour déboguer, à éviter quand on veut un Secret propre.
The server half of the VSO wiring. These scripts create everything the VSO is going to
consume: the Kubernetes auth method, the secrets engines, the policies (least privilege) and
the roles that tie a K8s identity to a policy. The cluster-side counterpart is in ../k8s/.
VSO presents the JWT token of the app's ServiceAccount. Vault validates it through the
cluster's TokenReview API, then checks that it matches a role
(bound_service_account_names + _namespaces + audience). If it does, Vault returns a token
carrying the role's policies — so precise read rights, and nothing else.
SA JWT (audience "vault") ─► auth/kubernetes/config (TokenReview) ─► role ─► policy
│
kvv2/ · database/ · pki/ · transit/ ◄─────────────┘
Break a single link (SA name, namespace, audience, policy path, mount name) and you get a 403 on
the VSO side and a SecretSynced: false on the CR. That is the overwhelming majority of failures.
exportVAULT_ADDR="https://vault.lab.example.io"# or http://127.0.0.1:8200 via port-forwardexportVAULT_TOKEN="<root-token>"# see ../../vault-cluster/README.md
vaultstatus# must answer Sealed=false
Port-forward if Vault is not exposed:
kubectl -n vault port-forward svc/vault-active 8200:8200.
⚠️ VAULT_ADDR/VAULT_TOKEN set in lab.env have NO effect: no script reads that file. You
have to export them (or set -a; . ./lab.env; set +a). Details and precautions in
../../vault-cluster/README.md (Pitfalls section).
This is the path that actually runs: KV-v2 mount lab-kv/ (one subdirectory per app) and
rotation of a PostgreSQL password through a static role. The matching K8s demos are
../k8s/nginx-test-vault/ and ../k8s/pg-dynamic-rotate/.
Path B — the teaching demo (mounts kvv2/, pki/, transit/)#
cdvault-secret-operator/vault
bash00-secrets-engines.sh# engines + a demo secret + PKI CA + transit keyMODE=inclusterbash01-kubernetes-auth.sh# auth/kubernetes (see the pitfall below!)
bash02-roles.sh# policies + roles vso-static/-dynamic/-pki/-transit
It serves as reading material for the numbered CRs in ../k8s/ (10-, 20-, 30-, 40-). Two
of its three scripts have flaws documented under ⚠️ Pitfalls — read them first.
One difference only, and it is cosmetic by choice: the name of the demo KV-v2 mount, so both
labs can coexist inside a single Vault.
Talos
kubeadm
KV-v2 mount (VAULT_KV_MOUNT)
talos-lab/
kubeadm-lab/
Derived policy
talos-lab-nginx-test-vault
kubeadm-lab-nginx-test-vault
Versioned files carry the NEUTRAL marker lab-kv; it is substituted on the fly, exactly
like the domain (the render helper in lib/common.sh, and a sed in vault/lab-kv.sh).
Everything else — VSO, VaultConnection, VaultAuth, the vso-* policies, PostgreSQL rotation,
PKI — is identical on both distributions.
The commands below are exactly what the all-in-one script does, in order.
Set up your shell first (once per session):
exportKUBECONFIG=../Vagrant-Talos/kubeconfig# or ../Vagrant-KubeADM/kubeconfigexportLAB_DOMAIN=talos.lab.example.io# or your own (see the lab's lab.env)
0. The environment (the scripts refuse to run without it)#
The connection and the role of the database engine are left commented out in the script (they
depend on your database). Path A, on the other hand, writes them for real
(pg-dynamic-rotate.sh).
Two modes, depending on where Vault runs. This is the step that blocks people the most.
MODE=incluster (our case) — Vault calls TokenReview with the token of its own pod: its
ServiceAccount must carry the system:auth-delegator ClusterRole, which the hashicorp/vault
chart does by default (server.authDelegator.enabled=true). The config then boils down to
kubernetes_host; token_reviewer_jwt and kubernetes_ca_cert stay empty (Vault uses the CA
mounted in its container). No issuer= is set: disable_iss_validation stays true (the
default since Vault 1.9), so the token's iss claim is never compared — which is what you want
here, since kubeadm mints ServiceAccount tokens with the issuer
https://kubernetes.default.svc.cluster.local while kubernetes_host is
https://kubernetes.default.svc.
MODE=external — Vault sits outside the cluster and can infer nothing. You have to give it:
a delegatorServiceAccount on the K8s side (system:auth-delegator);
its long-lived token (token_reviewer_jwt), with which Vault will validate the apps' JWTs;
the API endpoint (KUBE_HOST, by default the lab's VIP https://192.168.56.5:6443,
carried by keepalived on kubeadm, by Talos otherwise) and the CA of that API (SA_CA_CRT).
The kubectl commands that produce SA_JWT / SA_CA_CRT are commented out in the script.
⚠️ Do not pass --audience to kubectl create token for the reviewer JWT: kubeadm leaves
--api-audiences at its default (the issuer, and nothing else), so a token requested on another
audience is rejected at authentication and Vault's TokenReview returns 401.
Loads the 4 files from policies/, then creates 4 auth/kubernetes roles. All with
token_ttl=15m and audience=vault (which must match VaultAuth.spec.kubernetes.audiences
on the K8s side).
Kubernetes auth (kubernetes_host=https://kubernetes.default.svc), KV-v2 engine
lab-kv/, secret lab-kv/nginx-test-vault/config (3 APP_* keys), policy
lab-kv-nginx-test-vault (read on lab-kv/data|metadata/nginx-test-vault/* only) and role
nginx-test-vault bound to the SA/ns nginx-test-vault.
Adding an app = a lab-kv/<app>/… subdirectory, a policy scoped to that subdirectory, a role
dedicated to the app's SA/ns. This script is the template to copy.
Vault takes over a fixed PG user (vault-rotate) and rotates its password only (static
role) — the app's connection string stays stable. Scenario overview and PostgreSQL prerequisites:
../README.md.
vaultreaddatabase/static-creds/vault-rotate# username (fixed) + current password + remaining ttl
vaultwrite-fdatabase/rotate-role/vault-rotate# force an immediate rotation
vaultstatus# Sealed=false
vaultsecretslist# kvv2/, database/, pki/, transit/, lab-kv/
vaultauthlist# kubernetes/ present
vaultreadauth/kubernetes/config# kubernetes_host MUST be a real URL
vaultlistauth/kubernetes/role# vso-*, nginx-test-vault, pg-rotate-demo
vaultpolicylist
vaultkvgetkvv2/demo/app# demo secret (path B)
vaultkvgetlab-kv/nginx-test-vault/config# lab secret (path A)
vaultlistpki/issuers# exactly ONE CA expected (see Pitfalls)# Dry-run login test, without going through VSO — isolates identity problems:JWT=$(kubectl-ndemocreatetokenvso-app--audience=vault)
vaultwriteauth/kubernetes/loginrole=vso-staticjwt="$JWT"# must return a token + its policy
The "dynamic DB" demo cannot work as it stands — mount mismatch.00-secrets-engines.sh:19 runs vault secrets enable database, without -path: the engine
is therefore mounted on database/. But ../k8s/20-dynamic-db.yaml:11 asks for mount: db
and policies/vso-dynamic-db.hcl:5 only allows db/creds/demo-app. The VaultDynamicSecret
can only return a 403 or a 404, whatever else you do. To line everything up on db/, mount
the engine in the right place:
(Path A is not affected: pg-dynamic-rotate.sh writes and reads database/ consistently.)
01-kubernetes-auth.sh in incluster mode breaks every login. Line 26 writes
kubernetes_host="https://\$KUBERNETES_PORT_443_TCP_ADDR:443": the \$ is a literal$ in
bash, and Vault performs no environment substitution on config values. Vault therefore stores
the string as-is, and any authentication through auth/kubernetes fails.
The sibling script gets it right with kubernetes_host="https://kubernetes.default.svc"
(lab-kv.sh:22). Diagnosis:
vaultreadauth/kubernetes/config# if kubernetes_host contains a "$", this is the bug
vaultwriteauth/kubernetes/configkubernetes_host="https://kubernetes.default.svc"# fix
The 00-/01-/02- scripts are NOT idempotent, contrary to what their header claims.
Three distinct reasons:
00-secrets-engines.sh:16 (vault kv put kvv2/demo/app …) and lab-kv.sh:31-34
(APP_SECRET_TOKEN) overwrite the value and create a new KV version on every run. If
you rotated the secret to watch the VSO resync, re-running the script silently puts the
original value back.
00-secrets-engines.sh:35-36: since Vault 1.11 (multi-issuers), pki/root/generate/internalno longer fails on an already configured mount — it simply adds a new issuer.
The || echo "(CA racine déjà générée)" guard therefore never fires, and every run stacks
one more root CA. Check with vault list pki/issuers and delete the duplicates
(vault delete pki/issuer/<id>).
pg-dynamic-rotate.sh rewrites database/config/pg-demo and the static role on every pass.
That is the intended mechanism for changing ROTATION_PERIOD, but it is not a no-op.
No environment guard in 00-/01-/02-, unlike lab-kv.sh:14-15 and
pg-dynamic-rotate.sh:21-22, which refuse to start without VAULT_ADDR/VAULT_TOKEN. Without
VAULT_ADDR, the CLI silently hits https://127.0.0.1:8200; the script only stops at line 38 of
00- (${VAULT_ADDR} under set -u → unbound variable), so after it has already tried to
write. Export both variables before running anything.
00-secrets-engines.sh:11 hides every Vault error. The helper
enable() { vault secrets enable "$@" 2>/dev/null || echo " (déjà activé : $*)"; } swallows
stderr: a Vault is sealed, a permission denied or a connection refused all show up as
"(déjà activé)". The script will indeed stop at the next command (set -e), but with a
misleading diagnosis. When in doubt, re-run the command by hand without 2>/dev/null.
permission denied at login: the pod's SA/namespace does not match
bound_service_account_names/_namespaces, or the JWT's audience ≠ the role's audience.
The dry-run login test in the ✅ section isolates the problem without involving VSO.
error validating token: … 403: the reviewer lacks system:auth-delegator (in-cluster), or
the token_reviewer_jwt is wrong/expired (external).
Do not set disable_iss_validation=false again: the default has been true since Vault 1.9,
and the iss of projected tokens varies → broken logins.
Demo secrets in cleartext in the scripts.00-secrets-engines.sh and lab-kv.sh write
dummy values, versioned in git. That is accepted for a lab; in production, values are injected
outside git (vault kv put … @- from a pipe, or a real provisioning pipeline).
La moitié serveur du câblage VSO. Ces scripts créent tout ce que le VSO va consommer :
la méthode d'auth Kubernetes, les moteurs de secrets, les policies (moindre privilège) et les
roles qui relient une identité K8s à une policy. Le pendant côté cluster est dans ../k8s/.
VSO présente le token JWT du ServiceAccount de l'app. Vault le valide via l'API
TokenReview du cluster, puis vérifie qu'il correspond à un role
(bound_service_account_names + _namespaces + audience). Si oui, Vault renvoie un token
porteur des policies du role — donc des droits de lecture précis, et rien d'autre.
JWT du SA (audience "vault") ─► auth/kubernetes/config (TokenReview) ─► role ─► policy
│
kvv2/ · database/ · pki/ · transit/ ◄─────────────┘
Casser un seul maillon (nom du SA, namespace, audience, chemin de policy, nom de mount) donne un
403 côté VSO et un SecretSynced: false sur le CR. C'est l'écrasante majorité des pannes.
exportVAULT_ADDR="https://vault.lab.example.io"# ou http://127.0.0.1:8200 en port-forwardexportVAULT_TOKEN="<root-token>"# cf. ../../vault-cluster/LISEZ-MOI.md
vaultstatus# doit répondre Sealed=false
Port-forward si Vault n'est pas exposé :
kubectl -n vault port-forward svc/vault-active 8200:8200.
⚠️ VAULT_ADDR/VAULT_TOKEN posés dans lab.env n'ont AUCUN effet : aucun script ne lit ce
fichier. Il faut les exporter (ou set -a; . ./lab.env; set +a). Détail et précautions dans
../../vault-cluster/LISEZ-MOI.md (section Pièges).
Le dossier contient deux jeux de scripts qui ne servent pas la même chose. Ne pas les mélanger :
ils utilisent des mounts, des namespaces et des noms de role différents.
C'est le chemin qui tourne vraiment : mount KV-v2 lab-kv/ (un sous-dossier par appli) et
rotation d'un mot de passe PostgreSQL par static role. Les démos K8s correspondantes sont
../k8s/nginx-test-vault/ et ../k8s/pg-dynamic-rotate/.
Parcours B — la démo pédagogique (mounts kvv2/, pki/, transit/)#
cdvault-secret-operator/vault
bash00-secrets-engines.sh# moteurs + un secret de démo + CA PKI + clé transitMODE=inclusterbash01-kubernetes-auth.sh# auth/kubernetes (voir le piège plus bas !)
bash02-roles.sh# policies + roles vso-static/-dynamic/-pki/-transit
Elle sert de support de lecture pour les CR numérotés de ../k8s/ (10-, 20-, 30-, 40-).
Deux de ses trois scripts ont des défauts documentés en ⚠️ Pièges — les lire avant.
Une seule différence, et elle est cosmétique par choix : le nom du moteur KV-v2 de
démonstration, pour que les deux labs puissent coexister dans un même Vault.
Talos
kubeadm
Moteur KV-v2 (VAULT_KV_MOUNT)
talos-lab/
kubeadm-lab/
Policy dérivée
talos-lab-nginx-test-vault
kubeadm-lab-nginx-test-vault
Les fichiers versionnés portent le marqueur NEUTRE lab-kv ; il est substitué à la volée,
exactement comme le domaine (fonction render de lib/common.sh, et sed dans
vault/lab-kv.sh). Tout le reste — VSO, VaultConnection, VaultAuth, policies vso-*, rotation
PostgreSQL, PKI — est identique sur les deux distributions.
Les commandes ci-dessous sont exactement ce que fait le script tout-en-un, dans
l'ordre. Prépare d'abord l'environnement (une fois par session) :
exportKUBECONFIG=../Vagrant-Talos/kubeconfig# ou ../Vagrant-KubeADM/kubeconfigexportLAB_DOMAIN=talos.lab.example.io# ou ton domaine (cf. lab.env du lab)
0. L'environnement (les scripts refusent de démarrer sans)#
exportVAULT_ADDR="https://vault.${LAB_DOMAIN}"# ou : kubectl -n vault port-forward svc/vault-active 8200:8200exportVAULT_TOKEN=$(jq-r.root_token../Vagrant-Talos/_out/vault-init.json)
vaultstatus
La connexion et le role du moteur database sont laissés en commentaire dans le script (ils
dépendent de ta base). Le parcours A, lui, les écrit pour de vrai (pg-dynamic-rotate.sh).
Deux modes, selon où tourne Vault. C'est le point qui bloque le plus.
MODE=incluster (notre cas) — Vault appelle TokenReview avec le token de son propre pod :
son ServiceAccount doit porter le ClusterRole system:auth-delegator, ce que le chart
hashicorp/vault fait par défaut (server.authDelegator.enabled=true). La config se réduit alors
à kubernetes_host ; token_reviewer_jwt et kubernetes_ca_cert restent vides (Vault utilise le
CA monté dans son conteneur). Aucun issuer= n'est posé : disable_iss_validation reste à
true (défaut ≥ Vault 1.9), la revendication iss du token n'est donc jamais comparée — c'est
ce qu'on veut ici, kubeadm émettant les tokens de ServiceAccount avec l'issuer
https://kubernetes.default.svc.cluster.local alors que kubernetes_host vaut
https://kubernetes.default.svc.
MODE=external — Vault est hors du cluster, il ne peut rien déduire. Il faut lui fournir :
un ServiceAccountdélégateur côté K8s (system:auth-delegator) ;
son token long (token_reviewer_jwt), avec lequel Vault validera les JWT des apps ;
l'endpoint de l'API (KUBE_HOST, par défaut la VIP du lab https://192.168.56.5:6443,
portée par keepalived sur kubeadm, par Talos sinon) et le CA de cette API (SA_CA_CRT).
Les commandes kubectl qui produisent SA_JWT / SA_CA_CRT sont en commentaire dans le script.
⚠️ Ne pas passer --audience à kubectl create token pour le JWT du reviewer : kubeadm laisse
--api-audiences à son défaut (l'issuer, et rien d'autre), un token demandé sur une autre
audience est donc rejeté à l'authentification et le TokenReview de Vault renvoie 401.
Charge les 4 fichiers de policies/, puis crée 4 roles auth/kubernetes. Tous en token_ttl=15m
et audience=vault (qui doit matcher VaultAuth.spec.kubernetes.audiences côté K8s).
Auth Kubernetes (kubernetes_host=https://kubernetes.default.svc), moteur KV-v2 lab-kv/,
secret lab-kv/nginx-test-vault/config (3 clés APP_*), policy
lab-kv-nginx-test-vault (lecture de lab-kv/data|metadata/nginx-test-vault/* seulement) et
role nginx-test-vault bindé au SA/ns nginx-test-vault.
Ajouter une appli = un sous-dossier lab-kv/<appli>/…, une policy scopée à ce sous-dossier, un
role dédié au SA/ns de l'appli. Ce script est le gabarit à copier.
pg-dynamic-rotate.sh — rotation du mot de passe PostgreSQL#
Vault prend en gestion un user PG fixe (vault-rotate) et n'en rotate que le mot de passe
(static role) — la chaîne de connexion de l'app reste stable. Vue d'ensemble du scénario et
prérequis PostgreSQL : ../LISEZ-MOI.md.
Une policy = un usage, scopée au chemin exact. Aucun wildcard de mount.
Fichier
Autorise
policies/vso-static-kv.hcl
read sur kvv2/data/demo/app + kvv2/metadata/demo/app
policies/vso-dynamic-db.hcl
read sur db/creds/demo-app (⚠️ mount db/ — cf. Pièges) + update sur sys/leases/renew|revoke
policies/vso-pki.hcl
create/update sur pki/issue/demo et pki/revoke
policies/vso-transit-cache.hcl
encrypt/decrypt de la clé vso-client-cache (opérateur)
policies/lab-kv-nginx-test-vault.hcl
read sur lab-kv/data|metadata/nginx-test-vault/*
ℹ️ KV-v2 : le chemin de policy est <mount>/data/<path> pour les données et
<mount>/metadata/<path> pour les versions — pas<mount>/<path>. Erreur classique.
vaultstatus# Sealed=false
vaultsecretslist# kvv2/, database/, pki/, transit/, lab-kv/
vaultauthlist# kubernetes/ présent
vaultreadauth/kubernetes/config# kubernetes_host DOIT être une vraie URL
vaultlistauth/kubernetes/role# vso-*, nginx-test-vault, pg-rotate-demo
vaultpolicylist
vaultkvgetkvv2/demo/app# secret de démo (parcours B)
vaultkvgetlab-kv/nginx-test-vault/config# secret du lab (parcours A)
vaultlistpki/issuers# UNE seule CA attendue (cf. Pièges)# Test de login « à blanc », sans passer par VSO — isole les problèmes d'identité :JWT=$(kubectl-ndemocreatetokenvso-app--audience=vault)
vaultwriteauth/kubernetes/loginrole=vso-staticjwt="$JWT"# doit renvoyer un token + sa policy
La démo « dynamic DB » ne peut pas fonctionner en l'état — décalage de mount.00-secrets-engines.sh:19 fait vault secrets enable database, sans -path : le moteur est
donc monté sur database/. Or ../k8s/20-dynamic-db.yaml:11 demande mount: db et
policies/vso-dynamic-db.hcl:5 n'autorise que db/creds/demo-app. Le VaultDynamicSecret ne
peut renvoyer qu'un 403 ou un 404, quoi qu'on fasse par ailleurs. Pour aligner le tout sur
db/, il faut monter le moteur au bon endroit :
vaultsecretsenable-path=dbdatabase# au lieu de : vault secrets enable database
(Le parcours A n'est pas concerné : pg-dynamic-rotate.sh écrit et lit cohéremment database/.)
01-kubernetes-auth.sh en mode incluster casse tous les logins. La ligne 26 écrit
kubernetes_host="https://\$KUBERNETES_PORT_443_TCP_ADDR:443" : le \$ est un $littéral
en bash, et Vault ne fait aucune substitution d'environnement sur les valeurs de config. Vault
stocke donc la chaîne telle quelle, et toute authentification via auth/kubernetes échoue.
Le script frère fait correctement kubernetes_host="https://kubernetes.default.svc"
(lab-kv.sh:22). Diagnostic :
vaultreadauth/kubernetes/config# si kubernetes_host contient un "$", c'est ce bug
vaultwriteauth/kubernetes/configkubernetes_host="https://kubernetes.default.svc"# correctif
Les scripts 00-/01-/02- ne sont PAS idempotents, contrairement à ce que dit leur
en-tête. Trois raisons distinctes :
00-secrets-engines.sh:16 (vault kv put kvv2/demo/app …) et lab-kv.sh:31-34
(APP_SECRET_TOKEN) réécrasent la valeur et créent une nouvelle version KV à chaque
relance. Si tu as fait tourner le secret pour observer la resync du VSO, relancer le script
remet silencieusement la valeur d'origine.
00-secrets-engines.sh:35-36 : depuis Vault 1.11 (multi-issuers), pki/root/generate/internaln'échoue plus sur un mount déjà configuré — il ajoute simplement un nouvel émetteur.
Le garde-fou || echo "(CA racine déjà générée)" ne se déclenche donc jamais, et chaque
relance empile une CA racine de plus. Contrôler avec vault list pki/issuers et supprimer
les doublons (vault delete pki/issuer/<id>).
pg-dynamic-rotate.sh réécrit database/config/pg-demo et le static role à chaque passage.
C'est le mécanisme prévu pour changer ROTATION_PERIOD, mais ce n'est pas neutre.
Aucune garde d'environnement dans 00-/01-/02-, contrairement à lab-kv.sh:14-15 et
pg-dynamic-rotate.sh:21-22 qui refusent de démarrer sans VAULT_ADDR/VAULT_TOKEN. Sans
VAULT_ADDR, le CLI tape silencieusement https://127.0.0.1:8200 ; le script ne s'arrête qu'à
la ligne 38 de 00- (${VAULT_ADDR} sous set -u → unbound variable), donc après avoir
déjà tenté d'écrire. Exporter les deux variables avant de lancer quoi que ce soit.
00-secrets-engines.sh:11 masque toutes les erreurs Vault. Le helper
enable() { vault secrets enable "$@" 2>/dev/null || echo " (déjà activé : $*)"; } avale
stderr : un Vault is sealed, un permission denied ou un connection refused s'affichent
comme « (déjà activé) ». Le script s'interrompra bien à la commande suivante (set -e), mais
avec un diagnostic trompeur. En cas de doute, relancer la commande à la main sans 2>/dev/null.
permission denied au login : le SA/namespace du pod ne matche pas
bound_service_account_names/_namespaces, ou l'audience du JWT ≠ audience du role. Le
test de login « à blanc » de la section ✅ isole le problème sans impliquer VSO.
error validating token: … 403 : le reviewer n'a pas system:auth-delegator (in-cluster), ou
le token_reviewer_jwt est faux/expiré (externe).
Ne pas remettre disable_iss_validation=false : le défaut est true depuis Vault 1.9, et
l'iss des tokens projetés varie → logins cassés.
Secrets de démo en clair dans les scripts.00-secrets-engines.sh et lab-kv.sh posent
des valeurs bidon, versionnées dans git. C'est assumé pour un lab ; en prod, les valeurs
s'injectent hors git (vault kv put … @- depuis un pipe, ou un vrai flux d'approvisionnement).
The lab's observability stack, in one command: kube-prometheus-stack (Prometheus,
Grafana, Alertmanager, node-exporter, kube-state-metrics) + Loki (logs) + Grafana
Alloy (collection). Three UIs over HTTPS behind main-gateway.
🌐 lab.example.io is the repo's NEUTRAL (public) domain: observability-up.sh
replaces it with LAB_DOMAIN (lab.env) in the Helm values and in the HTTPRoutes. See
../README.md.
Alloy in file mode (not API). Reading logs through loki.source.kubernetes (k8s API)
pushes every log line through the kube-apiserver → huge load (it contributed to this lab's
CP incident). Here Alloy reads /var/log/pods directly on each node (one DaemonSet, each node
handling its own share); discovery.kubernetes is only used for labelling (a lightweight
metadata watch).
longhorn-r1 storage (1 block replica). Metrics and logs are rebuildable: no need to
replicate the blocks 3×, that would fill up the shared OS disk.
PVCs for Prometheus (3Gi), Loki (3Gi), Grafana (1Gi); the script aborts without it
kubectl get sc longhorn-r1
4 GB control planes (CP_MEM=4096 in lab.env)
this stack loads the apiserver (scrapes + watches)
vagrant ssh k8s-cp1 -c 'free -h'
⚠️ Control-plane RAM — the lab's lab.env.example ships CP_MEM=3072, which is NOT enough
for this stack. Raise it to CP_MEM=4096 in the lab's lab.envbefore installing, then
vagrant reload the CPs one at a time.
On 3 GB CPs, stacking this pile on top of the rest of the lab saturates etcd/apiserver
(lived through it: OOM loop, API unreachable). At 4 GB, the stack sits at ~50 % of CP
memory. 2 GB already starve etcd on their own.
One difference, but it changes what Prometheus can see (KPS_SCRAPE_CONTROL_PLANE in the
profiles):
Chart monitor
Talos
kubeadm
Why
kubeControllerManager
disabled
enabled (:10257, HTTPS, insecureSkipVerify)
kubeadm sets bind-address: 0.0.0.0 on the static pod; on Talos the component is not scrapable without dedicated TLS
kubeScheduler
disabled
enabled (:10259)
same
kubeEtcd
disabled
enabled (:2381, scheme: http)
the kubeadm lab passes listen-metrics-urls: http://0.0.0.0:2381 at bootstrap; by default that endpoint is loopback-only
kubeProxy
disabled
disabled
on both: either replaced by Cilium (eBPF, the default), or metrics bound to 127.0.0.1:10249
The values file is shared (it encodes the kubeadm case): observability-up.sh adds the
--set …enabled=false flags on Talos. Without that, Prometheus would show unexplained "down"
targets — the worst possible outcome in a training session.
Alloy reads /var/log/pods: identical on both (containerd), with the monitoring namespace
labelled privileged (required on Talos, intent documentation on kubeadm).
The commands below are exactly what the all-in-one script does, in order.
Set up your shell first (once per session):
exportKUBECONFIG=../Vagrant-Talos/kubeconfig# or ../Vagrant-KubeADM/kubeconfigexportLAB_DOMAIN=talos.lab.example.io# or your own (see the lab's lab.env)
kubectlgetsclonghorn-r1# Prometheus/Loki PVCs (1 block replica)
kubectltopnodes# metrics-server in place (platform)
free-g# CP ≥ 4 GB: this stack is the hungriest
2. The monitoring namespace (privileged PodSecurity: node-exporter + Alloy)#
kubectlapply-fobservability/namespace.yaml
3. kube-prometheus-stack — the --set flags differ per distribution#
helmrepoaddprometheus-communityhttps://prometheus-community.github.io/helm-charts
helmrepoupdateprometheus-community
# values rendered with the domain (Grafana domain/root_url, externalUrl)
sed"s/lab\.example\.io/${LAB_DOMAIN}/g"observability/kube-prometheus-stack-values.yaml>/tmp/kps.yaml
# Talos: disable the control-plane monitors (not scrapable)
helmupgrade--installkube-prometheus-stackprometheus-community/kube-prometheus-stack\-nmonitoring--create-namespace--version88.0.1--values/tmp/kps.yaml\--setkubeControllerManager.enabled=false\--setkubeScheduler.enabled=false\--setkubeEtcd.enabled=false# kubeadm: keep the values as they are (all three monitors enabled)
helmupgrade--installkube-prometheus-stackprometheus-community/kube-prometheus-stack\-nmonitoring--create-namespace--version88.0.1--values/tmp/kps.yaml
kubectl-nmonitoringrolloutstatusdeploy/kube-prometheus-stack-grafana--timeout=300s
# No "down" target: on Talos that is the whole point of step 3's --set flags
kubectl-nmonitoringexecsts/prometheus-kube-prometheus-stack-prometheus-cprometheus--\wget-qO-localhost:9090/api/v1/targets|tr',''\n'|grep-c'"health":"up"'
kubectl-nmonitoringgetservicemonitors
curl--resolve"grafana.${LAB_DOMAIN}:443:192.168.56.200""https://grafana.${LAB_DOMAIN}/login"-kSI|head-1
echo"Grafana: https://grafana.${LAB_DOMAIN} (admin / prom-operator — CHANGE IT)"
Prometheus (retention: 2d, 3Gi PVC on longhorn-r1) + Grafana (1Gi PVC + Loki datasource) + Alertmanager (emptyDir); controller-manager & scheduler scraped, etcd off and kube-proxy off — it does not exist (see below); scrapes every ServiceMonitor/PodMonitor
loki-values.yaml
Loki SingleBinary + filesystem on a 3Gi longhorn-r1 PVC; memcached caches turned off (otherwise ~9 GB of RAM requested)
alloy-values.yaml
Alloy DaemonSet, file mode (/var/log/pods) → Loki; does NOT load the apiserver
httproutes.yaml
3 HTTPS HTTPRoutes on main-gateway (wildcard TLS already carried by the listener)
The Talos lab disabled all four control-plane monitors, because those components only
listened on loopback there. On kubeadm the situation differs component by component, so they are
enabled one by one — never a dead target:
Monitor
State
Why
kubeControllerManager
on, :10257 HTTPS
kubeadm/templates/kubeadm-init.yaml.tpl sets bind-address: 0.0.0.0 on it. insecureSkipVerify: true: the serving cert is signed by the cluster CA but carries no DNS name for the Service.
kubeScheduler
on, :10259 HTTPS
same thing.
kubeEtcd
off
etcd is a stacked static pod and does expose :2381, but kubeadm generates its manifest with --listen-metrics-urls=http://127.0.0.1:2381 — loopback only, it serves the pod's liveness probe. To open it for real: add listen-metrics-urls: http://0.0.0.0:2381 to etcd.local.extraArgs in kubeadm/templates/kubeadm-init.yaml.tpl, then flip kubeEtcd.enabled: true with service.port: 2381 and serviceMonitor.scheme: http.
kubeProxy
off
there is no kube-proxy, on either lab: KUBE_PROXY_REPLACEMENT=true (the lab.env default of both) runs kubeadm init --skip-phases=addon/kube-proxy on kubeadm, and sets cluster.proxy.disabled: true in the machine config on Talos. Cilium handles Services in eBPF, and the equivalent metrics come from Cilium.
Both static pods carry the component: kube-controller-manager / component: kube-scheduler
labels that the chart's headless Service selects on: nothing else to wire up.
kubectl-nmonitoringgetpods# all Running (including 1 alloy per node)
kubectl-nmonitoringgethttproute# grafana/prometheus/alertmanager# Control-plane targets actually UP (one line per control plane, twice):
kubectl-nmonitoringexecsts/prometheus-kube-prometheus-stack-prometheus-cprometheus--\wget-qO-'http://localhost:9090/api/v1/targets?state=active'\|grep-o'"job":"kube-[a-z-]*"'|sort-u# kube-controller-manager, kube-scheduler# Endpoints (wildcard cert; --resolve bypasses DNS). -k if the cert is staging.forhingrafanaprometheusalertmanager;docurl-sk-o/dev/null-w"$h -> %{http_code}\n"\--resolve$h.lab.example.io:443:192.168.56.200https://$h.lab.example.io/
done# expected: grafana 302, prometheus 302, alertmanager 200# Logs actually landing in Loki (labels set by Alloy):
kubectl-nmonitoringexecdeploy/loki-gateway--\wget-qO-http://localhost:8080/loki/api/v1/labels# app, container, namespace, pod…
404 on the UIs right after the install → Envoy still propagating the HTTPRoutes; retry
after ~30 s.
CPs saturating / apiserver flapping → undersized CPs: move to 4 GB
(CP_MEM, then vagrant reload the CPs one at a time).
PVC Pending / ReplicaSchedulingFailure → longhorn-r1 missing, or disk full
(lower the retention or the PVC sizes).
No logs in Loki → is there one Alloy per node in 2/2? kubectl -n monitoring get ds alloy.
Then check loki.write in Alloy's logs.
A pod "with no logs" → usually just too narrow a time range: healthy pods
(prometheus, node-exporter…) log at startup then go quiet. Widen the window (12-24 h).
Control-plane logs (apiserver/scheduler/controller-manager/etcd) → these are static
pods: their /var/log/pods directory is named <ns>_<pod>_<HASH> (config hash), not the
API <uid>. Alloy's __path__ matches on <ns>_<pod>_* to cover both cases.
Unlike the Talos lab — where etcd was a Talos service, invisible to Loki — here etcd is a
regular static pod: its logs do land in Loki ({pod=~"etcd-.*"}). Only its metrics
endpoint stays out of reach (see "Control-plane targets" above).
Loki retention relies on the compactor, not on retention_period. In Loki,
limits_config.retention_period only states the limit: the deletion itself is the
compactor's job, and its retention_enabled defaults to false. A configuration that
only sets retention_period therefore lets logs pile up until the disk is full.
loki-values.yaml enables both (24 h retention, retention_enabled: true,
delete_request_store: filesystem, effective purge after retention_delete_delay: 2h).
Check that the block really is rendered:
Prometheus has no retentionSize. There is only retention: 2d, which bounds the age
of the series, not the volume they take: a cardinality spike (new ServiceMonitors, churning
pods) can fill the 3 Gi before the 2 days are up, and Prometheus then goes into a write error.
A retentionSize: 2GiB in prometheusSpec would bound both.
Grafana keeps the chart's default admin password (documented in a comment in
kube-prometheus-stack-values.yaml, and printed in clear text by observability-up.sh at
the end of its run) — while the UI is exposed over public HTTPS with a prod Let's
Encrypt certificate (so a resolvable name and a trusted cert). A lab, yes, but a reachable
one: change the password on the first login, or go through
grafana.admin.existingSecret.
Prometheus and Alertmanager are exposed with NO authentication at all (no filter on
the HTTPRoutes): anyone who reaches the Gateway can read every metric and silence
alerts.
Nothing emits application metrics by default.serviceMonitorSelectorNilUsesHelmValues: false does make Prometheus scrape every
ServiceMonitor/PodMonitor in the cluster… but every emitter in the lab is switched off.
Flip them to trueafter this install (the ServiceMonitor/PodMonitor CRDs only exist
afterwards), then re-run the *-up.sh of the component concerned:
La pile d'observabilité du lab, en une commande : kube-prometheus-stack (Prometheus,
Grafana, Alertmanager, node-exporter, kube-state-metrics) + Loki (logs) + Grafana
Alloy (collecte). Trois UI en HTTPS derrière main-gateway.
🌐 lab.example.io est le domaine NEUTRE du dépôt (public) : observability-up.sh
le remplace par LAB_DOMAIN (lab.env) dans les values Helm et les HTTPRoute. Cf.
../LISEZ-MOI.md.
Alloy en mode fichier (pas API). Lire les logs via loki.source.kubernetes (API k8s)
fait transiter tous les logs à travers le kube-apiserver → charge énorme (a contribué à
l'incident CP de ce lab). Ici Alloy lit directement /var/log/pods sur chaque node (un
DaemonSet, une part par node) ; discovery.kubernetes ne sert qu'à étiqueter (watch
léger de métadonnées).
Stockage longhorn-r1 (1 réplica bloc). Métriques et logs sont reconstructibles : pas
besoin de répliquer les blocs 3×, ça saturerait le disque OS partagé.
PVC de Prometheus (3Gi), Loki (3Gi), Grafana (1Gi) ; le script s'arrête sans elle
kubectl get sc longhorn-r1
CP à 4 Go (CP_MEM=4096 dans lab.env)
cette pile charge l'apiserver (scrapes + watches)
vagrant ssh k8s-cp1 -c 'free -h'
⚠️ RAM des control-plane — le lab.env.example du lab livre CP_MEM=3072, ce qui NE SUFFIT
PAS pour cette pile. Monte-le à CP_MEM=4096 dans le lab.env du lab avant
d'installer, puis
vagrant reload des CP un par un.
Sur des CP à 3 Go, empiler cette pile sur le reste du lab sature etcd/apiserver
(incident vécu : OOM en boucle, API injoignable). À 4 Go, la pile tient à ~50 % de la
mémoire CP. 2 Go affament déjà etcd tout seuls.
Une seule différence, mais elle change ce que Prometheus voit
(KPS_SCRAPE_CONTROL_PLANE dans les profils) :
Moniteur du chart
Talos
kubeadm
Pourquoi
kubeControllerManager
désactivé
activé (:10257, HTTPS, insecureSkipVerify)
kubeadm pose bind-address: 0.0.0.0 sur le pod statique ; sur Talos le composant n'est pas scrutable sans TLS dédié
kubeScheduler
désactivé
activé (:10259)
idem
kubeEtcd
désactivé
activé (:2381, scheme: http)
le lab kubeadm passe listen-metrics-urls: http://0.0.0.0:2381 au bootstrap ; par défaut l'endpoint n'existe qu'en loopback
kubeProxy
désactivé
désactivé
sur les deux : soit remplacé par Cilium (eBPF, le défaut), soit métriques en 127.0.0.1:10249
Le fichier de values est commun (il porte le cas kubeadm) : observability-up.sh ajoute
les --set …enabled=false sur Talos. Sans ce réglage, Prometheus afficherait des cibles
« down » inexplicables — le pire des scénarios en formation.
Alloy lit /var/log/pods : identique sur les deux (containerd), avec le namespace monitoring
étiqueté privileged (indispensable sur Talos, documentation d'intention sur kubeadm).
Les commandes ci-dessous sont exactement ce que fait le script tout-en-un, dans
l'ordre. Prépare d'abord l'environnement (une fois par session) :
exportKUBECONFIG=../Vagrant-Talos/kubeconfig# ou ../Vagrant-KubeADM/kubeconfigexportLAB_DOMAIN=talos.lab.example.io# ou ton domaine (cf. lab.env du lab)
kubectlgetsclonghorn-r1# PVC Prometheus/Loki (1 réplica bloc)
kubectltopnodes# metrics-server en place (platform)
free-g# CP ≥ 4 Go : cette pile est la plus gourmande
3. kube-prometheus-stack — les --set diffèrent selon la distribution#
helmrepoaddprometheus-communityhttps://prometheus-community.github.io/helm-charts
helmrepoupdateprometheus-community
# values rendues avec le domaine (Grafana domain/root_url, externalUrl)
sed"s/lab\.example\.io/${LAB_DOMAIN}/g"observability/kube-prometheus-stack-values.yaml>/tmp/kps.yaml
# Talos : on coupe les moniteurs du control plane (non scrutables)
helmupgrade--installkube-prometheus-stackprometheus-community/kube-prometheus-stack\-nmonitoring--create-namespace--version88.0.1--values/tmp/kps.yaml\--setkubeControllerManager.enabled=false\--setkubeScheduler.enabled=false\--setkubeEtcd.enabled=false# kubeadm : on garde les values telles quelles (les 3 moniteurs sont activés)
helmupgrade--installkube-prometheus-stackprometheus-community/kube-prometheus-stack\-nmonitoring--create-namespace--version88.0.1--values/tmp/kps.yaml
kubectl-nmonitoringrolloutstatusdeploy/kube-prometheus-stack-grafana--timeout=300s
# Aucune cible « down » : sur Talos c'est le sens des --set de l'étape 3
kubectl-nmonitoringexecsts/prometheus-kube-prometheus-stack-prometheus-cprometheus--\wget-qO-localhost:9090/api/v1/targets|tr',''\n'|grep-c'"health":"up"'
kubectl-nmonitoringgetservicemonitors
curl--resolve"grafana.${LAB_DOMAIN}:443:192.168.56.200""https://grafana.${LAB_DOMAIN}/login"-kSI|head-1
echo"Grafana : https://grafana.${LAB_DOMAIN} (admin / prom-operator — À CHANGER)"
Prometheus (retention: 2d, PVC 3Gi longhorn-r1) + Grafana (PVC 1Gi + datasource Loki) + Alertmanager (emptyDir) ; controller-manager et scheduler scrutés, etcd coupé et kube-proxy coupé — il n'existe pas (cf. ci-dessous) ; scrape tous les ServiceMonitor/PodMonitor
loki-values.yaml
Loki SingleBinary + filesystem sur PVC 3Gi longhorn-r1 ; caches memcached coupés (sinon ~9 Go de RAM demandés)
alloy-values.yaml
Alloy DaemonSet, mode fichier (/var/log/pods) → Loki ; ne charge PAS l'apiserver
httproutes.yaml
3 HTTPRoute HTTPS sur main-gateway (TLS wildcard déjà porté par l'écouteur)
observability-up.sh
Installe tout dans l'ordre (idempotent)
Cibles du control plane : deux allumées, deux éteintes#
Le lab Talos désactivait les quatre moniteurs du control plane, parce que ces composants
n'y écoutaient qu'en loopback. Sur kubeadm la situation diffère composant par composant : on les
active donc un par un, jamais en bloc — pas question de livrer une cible morte.
Moniteur
État
Pourquoi
kubeControllerManager
on, :10257 HTTPS
kubeadm/templates/kubeadm-init.yaml.tpl lui pose bind-address: 0.0.0.0. insecureSkipVerify: true : le certificat servi est signé par la CA du cluster mais ne porte pas le nom DNS du Service.
kubeScheduler
on, :10259 HTTPS
idem.
kubeEtcd
off
etcd est bien un pod statique empilé et expose bien:2381, mais kubeadm génère son manifeste avec --listen-metrics-urls=http://127.0.0.1:2381 — loopback uniquement, ça sert la probe de liveness du pod. Pour l'ouvrir vraiment : ajouter listen-metrics-urls: http://0.0.0.0:2381 à etcd.local.extraArgs dans kubeadm/templates/kubeadm-init.yaml.tpl, puis repasser kubeEtcd.enabled: true avec service.port: 2381 et serviceMonitor.scheme: http.
kubeProxy
off
il n'y a pas de kube-proxy, sur aucun des deux labs : KUBE_PROXY_REPLACEMENT=true (défaut de lab.env des deux) lance kubeadm init --skip-phases=addon/kube-proxy sur kubeadm, et pose cluster.proxy.disabled: true dans la config machine sur Talos. Cilium prend les Services en eBPF, et les métriques équivalentes viennent de Cilium.
Les deux pods statiques portent les labels component: kube-controller-manager /
component: kube-scheduler sur lesquels le Service headless du chart sélectionne : rien
d'autre à câbler.
kubectl-nmonitoringgetpods# tout Running (dont 1 alloy par node)
kubectl-nmonitoringgethttproute# grafana/prometheus/alertmanager# Cibles du control plane réellement UP (une ligne par control plane, deux fois) :
kubectl-nmonitoringexecsts/prometheus-kube-prometheus-stack-prometheus-cprometheus--\wget-qO-'http://localhost:9090/api/v1/targets?state=active'\|grep-o'"job":"kube-[a-z-]*"'|sort-u# kube-controller-manager, kube-scheduler# Endpoints (cert wildcard ; --resolve court-circuite le DNS). -k si cert staging.forhingrafanaprometheusalertmanager;docurl-sk-o/dev/null-w"$h -> %{http_code}\n"\--resolve$h.lab.example.io:443:192.168.56.200https://$h.lab.example.io/
done# attendu : grafana 302, prometheus 302, alertmanager 200# Logs qui arrivent dans Loki (labels posés par Alloy) :
kubectl-nmonitoringexecdeploy/loki-gateway--\wget-qO-http://localhost:8080/loki/api/v1/labels# app, container, namespace, pod…
404 sur les UI juste après l'install → propagation Envoy des HTTPRoute ; réessayer après ~30 s.
CP qui saturent / apiserver qui flappe → CP sous-dimensionnés : passer à 4 Go
(CP_MEM, puis vagrant reload des CP un par un).
PVC Pending / ReplicaSchedulingFailure → longhorn-r1 absente, ou disque plein
(baisser la rétention ou les tailles de PVC).
Pas de logs dans Loki → un Alloy par node en 2/2 ? kubectl -n monitoring get ds alloy.
Puis vérifier loki.write dans les logs d'Alloy.
Un pod « sans logs » → souvent juste une plage temporelle trop courte : les pods sains
(prometheus, node-exporter…) loguent au démarrage puis se taisent. Élargir la fenêtre (12-24 h).
Logs du control-plane (apiserver/scheduler/controller-manager/etcd) → ce sont des static
pods : leur dossier /var/log/pods est nommé <ns>_<pod>_<HASH> (hash de config), pas
<uid> API. Le __path__ d'Alloy matche par <ns>_<pod>_* pour couvrir les deux cas.
Contrairement au lab Talos — où etcd était un service Talos, invisible pour Loki — ici
etcd est un pod statique ordinaire : ses logs arrivent bien dans Loki
({pod=~"etcd-.*"}). Seul son endpoint de métriques reste hors de portée (cf. « Cibles du
control plane » ci-dessus).
La rétention Loki repose sur le compactor, pas sur retention_period. Dans Loki,
limits_config.retention_period ne fait qu'exprimer la limite : la suppression est le
travail du compactor, dont retention_enabled vaut false par défaut. Une configuration
qui ne pose que retention_period laisse donc les logs s'accumuler jusqu'au disque plein.
loki-values.yaml active les deux (rétention 24 h, retention_enabled: true,
delete_request_store: filesystem, purge effective après retention_delete_delay: 2h).
Vérifier que le bloc est bien rendu :
Prometheus n'a pas de retentionSize. Il n'y a que retention: 2d, qui borne l'âge
des séries, pas le volume occupé : un pic de cardinalité (ajout de ServiceMonitors, pods
qui churnent) peut remplir les 3 Gi avant les 2 jours, et Prometheus se met alors en erreur
d'écriture. Un retentionSize: 2GiB dans prometheusSpec bornerait les deux.
Grafana garde le mot de passe admin par défaut du chart (documenté en commentaire dans
kube-prometheus-stack-values.yaml, et affiché en clair par observability-up.sh en fin
d'exécution) — alors que l'UI est exposée en HTTPS public avec un certificat Let's
Encrypt prod (donc un nom résolvable et un cert trusté). Un lab, oui, mais joignable :
changer le mot de passe dès la première connexion, ou passer par
grafana.admin.existingSecret.
Prometheus et Alertmanager sont exposés SANS aucune authentification (aucun filtre sur
les HTTPRoute) : quiconque atteint la Gateway peut lire toutes les métriques et silencer
des alertes.
Rien n'émet de métriques applicatives par défaut.serviceMonitorSelectorNilUsesHelmValues: false fait bien que Prometheus scrape tous les
ServiceMonitor/PodMonitor du cluster… mais tous les émetteurs du lab sont coupés. À
basculer à trueaprès cette install (les CRD ServiceMonitor/PodMonitor n'existent
qu'ensuite), puis relancer le *-up.sh de l'addon concerné :
💬mattermost/ — Mattermost (chat) + Alertmanager alerts in a channel
Self-hosted chat, and the lab's alerting endpoint. Mattermost Team Edition (the free
community edition) on PostgreSQL, exposed over HTTPS through Envoy Gateway, plus two CRDs that
turn Prometheus alerts into messages in a dedicated channel — PrometheusRule for the rules,
AlertmanagerConfig for the routing. The same routing block works for Slack and
Microsoft Teams (see Other destinations).
🌐 lab.example.io is this repository's NEUTRAL (public) domain: mattermost-up.sh replaces it
with LAB_DOMAIN (lab.env) in the Helm values and in the HTTPRoute. See
../README.md.
PostgreSQL, not the chart's bundled MySQL. Mattermost v11 removed the MySQL driver from
its codebase. The chart still defaults to mysql.enabled: true, which is broken out of the
box on this app version (see ⚠️ Pitfalls). The database is a
CloudNativePG cluster, like Keycloak's.
Slack-compatible webhook, no plugin. Mattermost's incoming webhooks accept Slack's payload
format, so Alertmanager's stock slackConfigs receiver posts to it directly. Nothing to
install on either side.
Bootstrap through mmctl --local. Creating the admin, the team, the channel and the webhook
happens over a unix socket inside the pod, so it needs neither DNS, nor the ingress, nor
TLS, nor a password on a command line.
Alertmanager + the PrometheusRule/AlertmanagerConfig CRDs
kubectl -n monitoring get alertmanager
ℹ️ Alertmanager is the only soft prerequisite: without it, steps 4 and 5 are skipped with a
warning and Mattermost is installed on its own. Re-run the script after installing
observability to wire the alerting up.
No distribution-specific behaviour: same chart, same manifests, same values on both labs. The
distribution only decides the domain (mattermost.talos.lab.example.io /
mattermost.kubeadm.lab.example.io) and where the lab's lab.env / kubeconfig live.
The commands below are exactly what the script does, in order.
Set up your shell first (once per session):
exportKUBECONFIG=../Vagrant-KubeADM/kubeconfig# or ../Vagrant-Talos/kubeconfigexportLAB_DOMAIN=kubeadm.lab.example.io# your own (see the lab's lab.env)
mmctl --local talks to the server over a unix socket in the pod — no authentication needed.
mm(){kubectl-nmattermostexecdeploy/mattermost-mattermost-team-edition\-cmattermost-team-edition--mmctl--local"$@";}# NOT `tr -dc … | head -c 24`: head closes the pipe, tr takes a SIGPIPE and the# script dies on 141 under `set -o pipefail`. Bound the read, not the write.ADMIN_PASS=$(head-c256/dev/urandom|LC_ALL=Ctr-dc'A-Za-z0-9'|cut-c1-24)
kubectl-nmattermostcreatesecretgenericmattermost-admin\--from-literal=username=admin--from-literal=password="$ADMIN_PASS"
mmusercreate--email"admin@${LAB_DOMAIN}"--usernameadmin--password"$ADMIN_PASS"--system-admin
mmteamcreate--namelab--display-name"Lab k8s"
mmteamusersaddlab"admin@${LAB_DOMAIN}"
mmchannelcreate--teamlab--namealertes-k8s--display-name"Alertes k8s"# `--channel` and `--user` accept team:channel and an email, despite what --help says.# The answer's first line is "Id: <26-char id>".
mmwebhookcreate-incoming--channellab:alertes-k8s--user"admin@${LAB_DOMAIN}"\--display-nameAlertmanager--lock-to-channel
Then store the webhook URL where Alertmanager will read it. The in-cluster URL on purpose:
delivering an alert must not depend on the public DNS, on the Gateway or on the certificate.
# Without this, NODE alerts are never delivered — see the ⚠️ below.
kubectl-nmonitoringpatchalertmanagerkube-prometheus-stack-alertmanager--type=merge\-p'{"spec":{"alertmanagerConfigMatcherStrategy":{"type":"None"}}}'
kubectlapply-fmattermost/prometheusrule-lab-alerts.yaml
kubectlapply-fmattermost/alertmanagerconfig.yaml
PostgreSQL — applies the namespace and the CloudNativePG Cluster, then waits for
condition=Ready (initdb + first start).
Mattermost — reads the password CloudNativePG generated, renders the values (domain +
password) into a temp file, helm upgrade --install, waits for the rollout.
HTTPRoute — renders and applies the exposure.
Bootstrap — through mmctl --local: admin (password generated into a Secret only once
the account is really created), team, channel, and one incoming webhook, reused across
runs. Stores its in-cluster URL in the mattermost-webhook Secret.
Alerting — patches alertmanagerConfigMatcherStrategy, applies the PrometheusRule and
the AlertmanagerConfig.
Steps 4 and 5 are skipped with a warning if there is no Alertmanager: Mattermost alone still
works.
routing to the Mattermost webhook + severity: none dropped
mattermost-up.sh
the whole thing, idempotent
The six alerts, and why they exist next to the bundled ones#
kube-prometheus-stack already ships ~150 kubernetes-mixin rules, KubePodCrashLooping,
KubeNodeNotReady, NodeCPUHighUsage and NodeMemoryHighUtilization included. They are
calibrated for production: for: 15m to 30m, and NodeCPUHighUsage sits at
severity: info. In a lab nobody waits 15 minutes to see whether alerting works. The Lab
prefix means both sets live side by side.
kubectl-nmattermostgetpods,pvc# 2 pods Running, 3 PVCs Bound on longhorn-r1
curl-s"https://mattermost.${LAB_DOMAIN}/api/v4/system/ping"# {"status":"OK"}# The rules are loaded and healthy (6 of them, health=ok):
curl-sk"https://prometheus.${LAB_DOMAIN}/api/v1/rules"\|grep-o'"name":"Lab[A-Za-z]*"'|sort-u
# The routing is live in Alertmanager (the receiver must be listed):
curl-sk"https://alertmanager.${LAB_DOMAIN}/api/v2/receivers"
The only test that proves anything: provoke a real failure and wait for the message.
kubectlcreatedeploymentcrashtest--image=busybox:1.36--sh-c'exit 1'# ~4 min later (2 min of CrashLoopBackOff + `for: 2m` + 30s groupWait) a# 🔴 [FIRING:1] LabPodCrashLooping message lands in ~alertes-k8s
kubectldeletedeploymentcrashtest
# and a ✅ [RESOLVED] follows within ~2 min (sendResolved: true)
ℹ️ Give it four minutes, not one.kube_pod_container_status_waiting_reason is a sparse
series: kube-state-metrics only emits it while the container actually sits in Waiting. A
container that restarts fast is often scraped in terminated instead, and the alert only fires
once the restart backoff has grown enough for a scrape to land in the CrashLoopBackOff
window. That is exactly why the rule uses max_over_time(...[5m]) and not an instant query.
Identical to Mattermost, which is the whole point of the Slack-compatible webhook: create an
incoming webhook on your workspace, then only the
Secret's content changes.
slackConfigs:-apiURL:{ name:slack-webhook, key:url}channel:"#alerts-k8s"# with the leading # on SlacksendResolved:truetitle:'...'# same templatestext:'...'
Two differences to keep in mind:
The channel name takes a # on Slack; Mattermost accepts it with or without.
Slack renders *bold* and `code` the same way, but not_italics_ inside an
attachment field the way Mattermost does. Harmless — only the styling differs.
Teams does not understand Slack payloads: it expects a MessageCard / Adaptive Card. Two
supported routes, depending on your prometheus-operator version:
1. msteamsv2Configs (Power Automate Workflows — the current path). Microsoft
retired Office 365 connectors;
the replacement is a Workflow that gives you an HTTP POST URL. Requires
prometheus-operator ≥ v0.79 and Alertmanager ≥ v0.28 — the versions this repo pins
(operator v0.93.0, Alertmanager v0.33.1) both support it.
msteamsv2Configs:-webhookURL:{ name:msteams-webhook, key:url}sendResolved:truetitle:'{{ifeq.Status"firing"}}🔴{{else}}✅{{end}}[{{.Status|toUpper}}]{{.CommonLabels.alertname}}'text:|-{{ range .Alerts -}}**{{ .Annotations.summary }}**{{ .Annotations.description }}{{ end }}
2. msteamsConfigs — the older field, for the legacy webhook.office.com connector URLs.
Same shape, webhookUrl instead of webhookURL. Only worth using on an already-wired tenant.
⚠️ Teams is stricter about the payload than Mattermost or Slack.title must stay short and
Markdown is limited to bold and line breaks — no _italics_, no `code` spans. Reuse
the templates above rather than the Mattermost ones, otherwise the card shows raw backticks.
💡 Check what your cluster actually supports before writing the manifest — the field is silently
dropped if the CRD does not know it:
Add the receivers and split with matchers — for instance criticals to Teams, everything to
Mattermost:
route:receiver:mattermostroutes:-receiver:"null"matchers:[{ name:severity, value:none}]-receiver:msteamsmatchers:[{ name:severity, value:critical}]continue:true# WITHOUT this, Mattermost never sees the criticals
⚠️ continue: true is the trap. Alertmanager stops at the first matching route. A
critical route placed before the catch-all silently steals every critical alert from the
channel below it.
kubectl -n monitoring logs sts/alertmanager-kube-prometheus-stack-alertmanager | grep -i mattermost — a 4xx means the webhook URL is wrong or the hook was deleted
Pod alerts arrive, node alerts do not
kubectl -n monitoring get alertmanager kube-prometheus-stack-alertmanager -o jsonpath='{.spec.alertmanagerConfigMatcherStrategy.type}' must print None
The receiver is missing
curl -sk https://alertmanager.<LAB_DOMAIN>/api/v2/receivers — if monitoring/mattermost/mattermost is absent, the operator has not reloaded the CRD yet (wait ~30s)
Rules absent from Prometheus
kubectl -n monitoring get prometheusrule lab-alerts then curl -sk https://prometheus.<LAB_DOMAIN>/api/v1/rules | grep Lab
Mattermost restarts in a loop
kubectl -n mattermost logs deploy/mattermost-mattermost-team-edition --previous — almost always the database (see the MySQL pitfall)
mmctl answers nothing
local mode is off: check MM_SERVICESETTINGS_ENABLELOCALMODE in the values
Mattermost v11 removed MySQL — the chart's default is broken.mysql.enabled: true (the
chart's default) builds MM_CONFIG=mysql://…; v11 no longer recognises that as a database DSN,
treats it as a file path and dies on
could not create config file: open /mattermost/config/mysql:/mattermost:<pwd>@tcp(...).
The mangled path (mysql:/ with a single slash, from path cleaning) is the tell. Hence
mysql.enabled: false + externalDB on PostgreSQL.
deploymentStrategy: Recreate is mandatory with RWO Longhorn volumes. With the chart's
default RollingUpdate, the new pod tries to attach a volume the old one still holds →
Multi-Attach error for volume … Volume is already used by pod(s) … and the rollout hangs
forever. Same trap as ../wordpress-example/.
⚠️ Switching an already-deployed release to Recreate fails twice over. First on
spec.strategy.rollingUpdate: Forbidden: may not be specified when strategy type is 'Recreate'
(the leftover field). And if you clear it with kubectl patch, the nexthelm upgrade fails
on conflict with "kubectl-patch" using apps/v1: .spec.strategy.type — the patch took
ownership of the field away from Helm, and server-side apply refuses to steal it back. Delete
the Deployment instead and let Helm recreate it cleanly; the PVCs are separate objects, so
nothing is lost:
(Helm 4 also accepts --force-conflicts, but that silences every conflict, including the
ones you need to see.)
Node alerts never arrive → alertmanagerConfigMatcherStrategy is still the default
OnNamespace. prometheus-operator then injects a namespace="monitoring" matcher into the
route generated from the CRD, and node alerts (LabNodeNotReady, LabNodeCPUHigh…) carry no
namespace label. The failure is silent and looks like success, because the pod alerts do
arrive. type: None is the fix.
The channel drowns in Watchdog / InfoInhibitor → these are Alertmanager's own plumbing,
always firing by design. Route severity: none to a null receiver (this is what
alertmanagerconfig.yaml does); matching the severity rather than the two alert names also
covers any future one.
Every notification arrives twice → two incoming webhooks point at the channel. The script
reuses the one named Alertmanager; a hook created by hand under another name adds a second
delivery. mmctl --local webhook list lab.
MM_SERVICESETTINGS_SITEURL must be the https:// URL. Mattermost builds its own links
and its webhook URLs from it; with a wrong value the webhooks answer but the permalinks in
the messages point somewhere unreachable.
No shell in the image.kubectl exec … -- sh fails with
exec: "sh": executable file not found. Call mmctl directly, without a shell wrapper.
The chart's containers have no CPU limit, which trips the lab's Kyverno
require-requests-limits policy. It is in Audit mode, so it only produces a
PolicyViolation warning — nothing is blocked. This repo caps RAM and deliberately does not
throttle CPU.
kubectl-nmonitoringdeletealertmanagerconfigmattermost
kubectl-nmonitoringdeleteprometheusrulelab-alerts
kubectl-nmonitoringdeletesecretmattermost-webhook
helmuninstallmattermost-nmattermost
kubectldeletensmattermost# ⚠️ deletes the PVCs, so the messages and the database
💬mattermost/ — Mattermost (chat) + alertes Alertmanager dans un canal
Un chat auto-hébergé, et le point d'arrivée des alertes du lab. Mattermost Team Edition
(l'édition communautaire gratuite) sur PostgreSQL, exposé en HTTPS derrière Envoy Gateway, plus
deux CRD qui transforment les alertes Prometheus en messages dans un canal dédié :
PrometheusRule pour les règles, AlertmanagerConfig pour le routage. Le même bloc de routage
fonctionne pour Slack et Microsoft Teams (voir
Autres destinations).
🌐 lab.example.io est le domaine NEUTRE (public) de ce dépôt : mattermost-up.sh le remplace
par LAB_DOMAIN (lab.env) dans les values Helm et dans l'HTTPRoute. Cf.
../LISEZ-MOI.md.
Donner au lab une destination de notification qui lui appartient : aucun SaaS externe, aucun
webhook sortant vers internet, aucun jeton à faire tourner.
Montrer la chaîne d'alerte de bout en bout : une règle dans Prometheus → une route dans
Alertmanager → un message dans un canal, le tout déclaré en CRD Kubernetes.
Servir de support concret à la question « et comment je me fais alerter ? », qui suit toujours le
module observability/.
PostgreSQL, pas le MySQL embarqué du chart. Mattermost v11 a retiré le driver MySQL de son
code. Le chart reste par défaut sur mysql.enabled: true, ce qui est cassé d'origine sur
cette version de l'application (voir ⚠️ Pièges). La base est un cluster
CloudNativePG, comme celle de Keycloak.
Webhook compatible Slack, aucun plugin. Les webhooks entrants de Mattermost acceptent le
format de charge utile de Slack : le receiver slackConfigs standard d'Alertmanager y poste
directement. Rien à installer d'un côté ni de l'autre.
Amorçage par mmctl --local. La création de l'admin, de l'équipe, du canal et du webhook
passe par un socket unix dans le pod : ni DNS, ni ingress, ni TLS, ni mot de passe sur une
ligne de commande.
Alertmanager + les CRD PrometheusRule/AlertmanagerConfig
kubectl -n monitoring get alertmanager
ℹ️ Alertmanager est le seul prérequis souple : sans lui, les étapes 4 et 5 sont ignorées avec
un avertissement et Mattermost s'installe seul. Relance le script après avoir installé
observability pour câbler l'alerting.
Aucun comportement propre à une distribution : même chart, mêmes manifestes, mêmes values sur
les deux labs. La distribution ne décide que du domaine
(mattermost.talos.lab.example.io / mattermost.kubeadm.lab.example.io) et de l'emplacement du
lab.env / kubeconfig du lab.
mmctl --local parle au serveur par un socket unix dans le pod — aucune authentification requise.
mm(){kubectl-nmattermostexecdeploy/mattermost-mattermost-team-edition\-cmattermost-team-edition--mmctl--local"$@";}# PAS `tr -dc … | head -c 24` : head ferme le tube, tr prend un SIGPIPE et le# script meurt en 141 sous `set -o pipefail`. On borne la lecture, pas l'écriture.ADMIN_PASS=$(head-c256/dev/urandom|LC_ALL=Ctr-dc'A-Za-z0-9'|cut-c1-24)
kubectl-nmattermostcreatesecretgenericmattermost-admin\--from-literal=username=admin--from-literal=password="$ADMIN_PASS"
mmusercreate--email"admin@${LAB_DOMAIN}"--usernameadmin--password"$ADMIN_PASS"--system-admin
mmteamcreate--namelab--display-name"Lab k8s"
mmteamusersaddlab"admin@${LAB_DOMAIN}"
mmchannelcreate--teamlab--namealertes-k8s--display-name"Alertes k8s"# `--channel` et `--user` acceptent team:canal et un email, malgré ce que dit --help.# La première ligne de la réponse est "Id: <identifiant de 26 caractères>".
mmwebhookcreate-incoming--channellab:alertes-k8s--user"admin@${LAB_DOMAIN}"\--display-nameAlertmanager--lock-to-channel
Puis on range l'URL du webhook là où Alertmanager la lira. Volontairement l'URL interne au
cluster : livrer une alerte ne doit dépendre ni du DNS public, ni de la Gateway, ni du
certificat.
# Sans ça, les alertes de NODE ne sont jamais livrées — voir le ⚠️ plus bas.
kubectl-nmonitoringpatchalertmanagerkube-prometheus-stack-alertmanager--type=merge\-p'{"spec":{"alertmanagerConfigMatcherStrategy":{"type":"None"}}}'
kubectlapply-fmattermost/prometheusrule-lab-alerts.yaml
kubectlapply-fmattermost/alertmanagerconfig.yaml
PostgreSQL — applique le namespace et le Cluster CloudNativePG, puis attend
condition=Ready (initdb + premier démarrage).
Mattermost — lit le mot de passe généré par CloudNativePG, rend les values (domaine +
mot de passe) dans un fichier temporaire, helm upgrade --install, attend le rollout.
HTTPRoute — rend et applique l'exposition.
Amorçage — par mmctl --local : admin (mot de passe généré dans un Secret seulement
une fois le compte réellement créé), équipe, canal, et un webhook entrant, réutilisé
d'un passage à l'autre. Range son URL interne dans le Secret mattermost-webhook.
Alerting — patche alertmanagerConfigMatcherStrategy, applique le PrometheusRule et
l'AlertmanagerConfig.
Les étapes 4 et 5 sont ignorées avec un avertissement s'il n'y a pas d'Alertmanager : Mattermost
seul fonctionne quand même.
routage vers le webhook Mattermost + severity: none jeté
mattermost-up.sh
l'ensemble, idempotent
Les six alertes, et pourquoi elles côtoient celles du stack#
kube-prometheus-stack livre déjà ~150 règles kubernetes-mixin, dont KubePodCrashLooping,
KubeNodeNotReady, NodeCPUHighUsage et NodeMemoryHighUtilization. Elles sont calibrées pour
la production : for: 15m à 30m, et NodeCPUHighUsage est en severity: info. Sur un lab,
personne n'attend 15 minutes pour savoir si l'alerting marche. Le préfixe Lab fait cohabiter les
deux jeux.
kubectl-nmattermostgetpods,pvc# 2 pods Running, 3 PVC Bound sur longhorn-r1
curl-s"https://mattermost.${LAB_DOMAIN}/api/v4/system/ping"# {"status":"OK"}# Les règles sont chargées et saines (6, health=ok) :
curl-sk"https://prometheus.${LAB_DOMAIN}/api/v1/rules"\|grep-o'"name":"Lab[A-Za-z]*"'|sort-u
# Le routage est vivant dans Alertmanager (le receiver doit être listé) :
curl-sk"https://alertmanager.${LAB_DOMAIN}/api/v2/receivers"
Le seul test qui prouve quelque chose : provoquer une vraie panne et attendre le message.
kubectlcreatedeploymentcrashtest--image=busybox:1.36--sh-c'exit 1'# ~4 min plus tard (2 min de CrashLoopBackOff + `for: 2m` + 30s de groupWait) un message# 🔴 [FIRING:1] LabPodCrashLooping arrive dans ~alertes-k8s
kubectldeletedeploymentcrashtest
# et un ✅ [RESOLVED] suit sous ~2 min (sendResolved: true)
ℹ️ Laisse-lui quatre minutes, pas une.kube_pod_container_status_waiting_reason est une
série éparse : kube-state-metrics ne l'émet que pendant que le conteneur est réellement en
Waiting. Un conteneur qui redémarre vite est souvent scruté en terminated à la place, et
l'alerte ne part qu'une fois le backoff de redémarrage assez grand pour qu'un scrape tombe dans
la fenêtre CrashLoopBackOff. C'est exactement pour ça que la règle utilise
max_over_time(...[5m]) et non une requête instantanée.
Identique à Mattermost, et c'est tout l'intérêt du webhook compatible Slack : crée un
webhook entrant sur ton espace de travail, puis seul
le contenu du Secret change.
slackConfigs:-apiURL:{ name:slack-webhook, key:url}channel:"#alerts-k8s"# avec le # sur SlacksendResolved:truetitle:'...'# mêmes templatestext:'...'
Deux différences à garder en tête :
Le nom du canal prend un # sur Slack ; Mattermost l'accepte avec ou sans.
Slack rend *gras* et `code` de la même façon, mais pas_italique_ dans un champ
d'attachement comme le fait Mattermost. Sans conséquence : seul le style diffère.
Teams ne comprend pas les charges utiles Slack : il attend une MessageCard / Adaptive
Card. Deux voies supportées, selon la version de prometheus-operator :
1. msteamsv2Configs (Power Automate Workflows — la voie actuelle). Microsoft a
retiré les connecteurs Office 365 ;
le remplacement est un Workflow qui te donne une URL de POST HTTP. Exige
prometheus-operator ≥ v0.79 et Alertmanager ≥ v0.28 — les versions épinglées par ce dépôt
(opérateur v0.93.0, Alertmanager v0.33.1) le supportent toutes les deux.
msteamsv2Configs:-webhookURL:{ name:msteams-webhook, key:url}sendResolved:truetitle:'{{ifeq.Status"firing"}}🔴{{else}}✅{{end}}[{{.Status|toUpper}}]{{.CommonLabels.alertname}}'text:|-{{ range .Alerts -}}**{{ .Annotations.summary }}**{{ .Annotations.description }}{{ end }}
2. msteamsConfigs — l'ancien champ, pour les URL de connecteur webhook.office.com
historiques. Même forme, mais webhookUrl au lieu de webhookURL. À n'utiliser que sur un tenant
déjà câblé.
⚠️ Teams est plus strict sur la charge utile que Mattermost ou Slack. Le title doit rester
court et le Markdown se limite au gras et aux retours à la ligne — pas d'_italique_, pas de
`code`. Reprends les templates ci-dessus plutôt que ceux de Mattermost, sinon la carte
affiche les backticks bruts.
💡 Vérifie ce que ton cluster supporte vraiment avant d'écrire le manifeste — le champ est
ignoré en silence si le CRD ne le connaît pas :
Ajoute les receivers et sépare par matchers — par exemple les critiques vers Teams, tout vers
Mattermost :
route:receiver:mattermostroutes:-receiver:"null"matchers:[{ name:severity, value:none}]-receiver:msteamsmatchers:[{ name:severity, value:critical}]continue:true# SANS ça, Mattermost ne voit jamais les critiques
⚠️ continue: true est le piège. Alertmanager s'arrête à la première route qui matche.
Une route critical placée avant l'attrape-tout vole en silence toutes les alertes critiques au
canal du dessous.
kubectl -n monitoring logs sts/alertmanager-kube-prometheus-stack-alertmanager | grep -i mattermost — un 4xx = URL de webhook fausse ou hook supprimé
Les alertes de pod arrivent, celles de node non
kubectl -n monitoring get alertmanager kube-prometheus-stack-alertmanager -o jsonpath='{.spec.alertmanagerConfigMatcherStrategy.type}' doit afficher None
Le receiver manque
curl -sk https://alertmanager.<LAB_DOMAIN>/api/v2/receivers — si monitoring/mattermost/mattermost est absent, l'opérateur n'a pas encore rechargé le CRD (~30s)
Règles absentes de Prometheus
kubectl -n monitoring get prometheusrule lab-alerts puis curl -sk https://prometheus.<LAB_DOMAIN>/api/v1/rules | grep Lab
Mattermost redémarre en boucle
kubectl -n mattermost logs deploy/mattermost-mattermost-team-edition --previous — presque toujours la base (cf. le piège MySQL)
mmctl ne répond rien
le mode local est coupé : vérifie MM_SERVICESETTINGS_ENABLELOCALMODE dans les values
Mattermost v11 a retiré MySQL — le défaut du chart est cassé.mysql.enabled: true (défaut
du chart) construit MM_CONFIG=mysql://… ; la v11 ne reconnaît plus ça comme un DSN de base, le
prend pour un chemin de fichier et meurt sur
could not create config file: open /mattermost/config/mysql:/mattermost:<mdp>@tcp(...).
Le chemin déformé (mysql:/ avec un seul slash, à cause du nettoyage de chemin) est l'indice.
D'où mysql.enabled: false + externalDB sur PostgreSQL.
deploymentStrategy: Recreate est obligatoire avec des volumes Longhorn RWO. Avec le
RollingUpdate par défaut du chart, le nouveau pod tente d'attacher un volume que l'ancien
détient encore → Multi-Attach error for volume … Volume is already used by pod(s) … et le
rollout se bloque indéfiniment. Même piège que
../wordpress-example/.
⚠️ Basculer une release déjà déployée vers Recreate échoue deux fois. D'abord sur
spec.strategy.rollingUpdate: Forbidden: may not be specified when strategy type is 'Recreate'
(le champ résiduel). Et si on le retire au kubectl patch, le helm upgradesuivant échoue
sur conflict with "kubectl-patch" using apps/v1: .spec.strategy.type — le patch a pris la
propriété du champ à Helm, et le server-side apply refuse de la lui reprendre. Supprime plutôt
le Deployment et laisse Helm le recréer proprement ; les PVC sont des objets distincts, donc
rien n'est perdu :
(Helm 4 accepte aussi --force-conflicts, mais ça fait taire tous les conflits, y compris
ceux qu'il faut lire.)
Les alertes de node n'arrivent jamais → alertmanagerConfigMatcherStrategy est resté sur son
défaut OnNamespace. prometheus-operator injecte alors un matcher namespace="monitoring" dans
la route générée depuis le CRD, et les alertes de node (LabNodeNotReady, LabNodeCPUHigh…) ne
portent aucun label namespace. La panne est silencieuse et ressemble à un succès, parce que
les alertes de pod, elles, arrivent. type: None est le correctif.
Le canal se noie sous Watchdog / InfoInhibitor → ce sont les rouages internes
d'Alertmanager, toujours actifs par conception. Route severity: none vers un receiver vide
(c'est ce que fait alertmanagerconfig.yaml) ; matcher sur la sévérité plutôt que sur les deux
noms couvre aussi les futures.
Chaque notification arrive deux fois → deux webhooks entrants pointent sur le canal. Le
script réutilise celui nommé Alertmanager ; un hook créé à la main sous un autre nom ajoute une
seconde livraison. mmctl --local webhook list lab.
MM_SERVICESETTINGS_SITEURL doit être l'URL en https://. Mattermost en dérive ses propres
liens et les URL de ses webhooks ; avec une mauvaise valeur les webhooks répondent mais les
permaliens des messages pointent dans le vide.
Aucun shell dans l'image.kubectl exec … -- sh échoue sur
exec: "sh": executable file not found. Appelle mmctl directement, sans enrobage shell.
Les conteneurs du chart n'ont pas de limite CPU, ce qui déclenche la policy Kyverno
require-requests-limits du lab. Elle est en Audit : cela ne produit qu'un avertissement
PolicyViolation, rien n'est bloqué. Ce dépôt plafonne la RAM et ne throttle volontairement
pas le CPU.
kubectl-nmonitoringdeletealertmanagerconfigmattermost
kubectl-nmonitoringdeleteprometheusrulelab-alerts
kubectl-nmonitoringdeletesecretmattermost-webhook
helmuninstallmattermost-nmattermost
kubectldeletensmattermost# ⚠️ supprime les PVC, donc les messages et la base
🩺node-problem-detector/ — node health (NodeConditions + Events)
node-problem-detector (NPD): a
DaemonSet that runs on every node (control planes included), reads the kernel log and
reports low-level problems to Kubernetes as NodeConditions and Events.
Directly motivated by the cp2 incident (frozen guest): NPD would have produced a
TaskHung/OOMKilling event visible through kubectl, instead of an opaque bare NotReady.
NPD keeps these two conditions continuously up to date on every node (KernelDeadlock,
ReadonlyFilesystem, False in normal times): that is what you alert on.
kernel-monitor only (/config/kernel-monitor.json, reading /dev/kmsg). The chart loads
kernel-monitor + docker-monitor by default: the runtime here is containerd (kubeadm),
there is no Docker socket → that monitor fails. systemd-monitor reads journald; systemd does
exist on Debian 13, but Debian keeps the journal volatile (/run/log/journal) until
/var/log/journal is created, while the chart only mounts /var/log → it would see nothing.
So values.yaml reduces settings.log_monitors to kernel-monitor alone.
Namespace in PodSecurity privileged: NPD runs privileged (access to /dev/kmsg).
kubeadm enforces no level cluster-wide by default, but the script labels the namespace anyway:
it documents the intent and keeps working if admission is hardened.
NoSchedule/Exists tolerations → NPD runs on the control planes too (cp1/2/3).
Pinned version: chart deliveryhero/node-problem-detector 2.3.14 (app v0.8.19),
overridable with NPD_VERSION=…. No prerequisites: no storage, no Gateway, no CRD.
The commands below are exactly what the all-in-one script does, in order.
Set up your shell first (once per session):
exportKUBECONFIG=../Vagrant-Talos/kubeconfig# or ../Vagrant-KubeADM/kubeconfigexportLAB_DOMAIN=talos.lab.example.io# or your own (see the lab's lab.env)
NPD exposes Prometheus metrics on :20257 (metrics.enabled: true), but the ServiceMonitor is
off: the CRD only exists after ../observability/. Once that component is installed, set
metrics.serviceMonitor.enabled: true in values.yaml and re-run the script →
problem_counter / problem_gauge counters per problem type.
settings.custom_monitors: [] in values.yaml does NOTHING. That key does not exist
in chart 2.3.14: the real keys are settings.custom_monitor_definitions (a map of config files
mounted into /custom-config) and settings.custom_plugin_monitors (a list, empty by
default). Helm silently accepts an unknown value → no error, no effect. The comment in the file
makes it look like an explicit opt-out; in reality the plugin monitors are already empty by
default, and it is log_monitors (very much real) that does all the adaptation work.
The KernelDeadlock condition will (almost) never go True on this lab. In
kernel-monitor.json (v0.8.19) the only permanent rule that raises it is the pattern
task docker:\w+ blocked for more than \w+ seconds — a docker process, which does not
exist here either (containerd). A real hang shows up as a TaskHung event (temporary rule,
any process), not as a node condition: alert on the Events, not only on KernelDeadlock.
ReadonlyFilesystem, on the other hand, works normally
(Remounting filesystem read-only).
NPD fixes nothing: it makes things visible. The remedy (cordon/drain, reschedule, reboot,
auto-remediation) is left to the operator, or to a tool like Draino / Descheduler.
A "total" freeze can slip under the radar. As on cp2, a guest that freezes without writing
anything to kmsg leaves no trace: NPD mainly helps with OOM / I/O / FS / task-hung
failures, which do leave a kernel line behind.
Writing to /dev/kmsg needs root on the node — trivial here (vagrant ssh + sudo),
but the write must happen on the node whose condition you are watching: a line injected on
k8s-w1 will never show up against k8s-cp1.
Related addon: ../observability/ (scraping the NPD metrics, alerting on the NodeConditions)
node-problem-detector/LISEZ-MOI.md
🩺node-problem-detector/ — santé des nodes (NodeConditions + Events)
node-problem-detector (NPD) : un
DaemonSet qui tourne sur chaque node (control-plane inclus), lit le journal du noyau et
remonte les problèmes bas-niveau à Kubernetes en NodeConditions et Events.
Directement motivé par l'incident cp2 (guest figé) : NPD aurait produit un event
TaskHung/OOMKillingvisible via kubectl, au lieu d'un simple NotReady opaque.
NPD maintient en continu ces deux conditions sur chaque node (KernelDeadlock,
ReadonlyFilesystem, à False en temps normal) : c'est dessus qu'on alerte.
kernel-monitor seulement (/config/kernel-monitor.json, lecture de /dev/kmsg). Le
chart charge par défaut kernel-monitor + docker-monitor : le runtime est ici
containerd (kubeadm), il n'y a pas de socket Docker → ce moniteur échoue. Le
systemd-monitor, lui, lit journald ; systemd existe bien sur Debian 13, mais Debian garde
le journal en volatile (/run/log/journal) tant que /var/log/journal n'est pas créé,
alors que le chart ne monte que /var/log → il ne verrait rien. values.yaml réduit donc
settings.log_monitors au seul kernel-monitor.
Namespace en PodSecurity privileged : NPD tourne en privileged (accès /dev/kmsg).
kubeadm n'applique aucun niveau au niveau cluster par défaut, mais le script étiquette quand
même le namespace : ça documente l'intention et ça tient si on durcit l'admission.
TolérationsNoSchedule/Exists → NPD tourne aussi sur les control-plane (cp1/2/3).
Version épinglée : chart deliveryhero/node-problem-detector 2.3.14 (app v0.8.19),
surchargeable par NPD_VERSION=…. Aucun prérequis : ni stockage, ni Gateway, ni CRD.
Les commandes ci-dessous sont exactement ce que fait le script tout-en-un, dans
l'ordre. Prépare d'abord l'environnement (une fois par session) :
exportKUBECONFIG=../Vagrant-Talos/kubeconfig# ou ../Vagrant-KubeADM/kubeconfigexportLAB_DOMAIN=talos.lab.example.io# ou ton domaine (cf. lab.env du lab)
NPD expose des métriques Prometheus sur :20257 (metrics.enabled: true), mais le
ServiceMonitor est coupé : le CRD n'existe qu'après ../observability/. Une fois cet addon
installé, passer metrics.serviceMonitor.enabled: true dans values.yaml et relancer le
script → compteurs problem_counter / problem_gauge par type de problème.
settings.custom_monitors: [] dans values.yaml ne fait RIEN. Cette clé n'existe pas
dans le chart 2.3.14 : les vraies clés sont settings.custom_monitor_definitions (map de
fichiers de config montés dans /custom-config) et settings.custom_plugin_monitors (liste,
vide par défaut). Helm accepte silencieusement une valeur inconnue → aucune erreur, aucun
effet. Le commentaire du fichier laisse croire à une désactivation explicite ; en réalité les
plugin-monitors sont déjà vides par défaut, et c'est log_monitors (bien réel) qui fait
tout le travail d'adaptation.
La condition KernelDeadlock ne passera (presque) jamais à True sur ce lab. Dans
kernel-monitor.json (v0.8.19) la seule règle permanente qui la déclenche est le motif
task docker:\w+ blocked for more than \w+ seconds — un processus docker, qui n'existe
pas non plus ici (containerd). Un hang réel remontera en event TaskHung (règle temporaire,
tous processus confondus), pas en condition de node : alerter sur les Events, pas
seulement sur KernelDeadlock. ReadonlyFilesystem, lui, fonctionne normalement
(Remounting filesystem read-only).
NPD ne corrige rien : il rend visible. Le remède (cordon/drain, reschedule, reboot,
auto-remédiation) reste à l'opérateur, ou à un outil type Draino / Descheduler.
Un gel « total » peut passer sous le radar. Comme sur cp2, un guest qui fige sans rien
écrire dans kmsg ne produit aucune trace : NPD aide surtout sur les pannes OOM / I/O / FS /
task-hung, qui, elles, laissent une ligne noyau.
Écrire dans /dev/kmsg demande root sur le node — trivial ici (vagrant ssh + sudo),
mais l'écriture doit se faire sur le node dont on regarde la condition : une ligne
injectée sur k8s-w1 ne remontera jamais sur k8s-cp1.
Addon lié : ../observability/ (scrape des métriques NPD, alerting sur les NodeConditions)
chaos-kube/README.md
🐒chaos-kube/ — chaos engineering (chaoskube 0.39)
Deletes one random pod per hour, everywhere except kube-system,
longhorn-system, vault and cnpg-demo. The point is not to break things: it is to prove that what the lab runs
comes back on its own. Anything that does not come back was never really highly
available.
The lightest addon of the lab: no storage, no Gateway, no certificate.
Prerequisite
Why
Verify
Cluster Ready, KUBECONFIG set
the script probes /readyz
kubectl get nodes
helm in PATH
install goes through the upstream chart
helm version
Something worth killing
on an empty cluster chaoskube has nothing to prove
kubectl get deploy -A
ℹ️ The chart creates its own ServiceAccount + ClusterRole (pods: list, delete and
events: create, cluster-wide). Broad by nature, and that is the tool's job.
Idempotent (helm upgrade --install), re-runnable. Two knobs:
CHAOS_DRY_RUN=1./chaos-kube/chaoskube-up.sh<distro># observe only, deletes nothingCHAOSKUBE_VERSION=0.6.0./…# pin another chart version
The script ends by reading the flags back out of the Deployment rather than echoing
values.yaml: that is the only proof the exclusions and --no-dry-run actually landed in the
pod.
No distribution-specific behaviour for this component: same charts, same manifests, same
values on both labs. The distribution argument only drives two things here: the default
domain (talos.lab.example.io / kubeadm.lab.example.io) and where the lab's lab.env
/ kubeconfig live (../Vagrant-Talos/ or ../Vagrant-KubeADM/).
⚠️ One consequence to keep in mind, identical on both labs: every Vault pod that gets killed
comes back SEALED. After chaoskube hits the vault namespace, run
./vault-cluster/vault-up.sh <distro> to unseal again. That is why values.yaml already
excludes vault and cnpg-demo.
The commands below are exactly what the all-in-one script does, in order.
Set up your shell first (once per session):
exportKUBECONFIG=../Vagrant-Talos/kubeconfig# or ../Vagrant-KubeADM/kubeconfigexportLAB_DOMAIN=talos.lab.example.io# or your own (see the lab's lab.env)
--namespaces takes a selector-like list where !ns means exclude, comma-separated. The four
exclusions of this lab are not arbitrary:
kube-system — with a single control plane, killing kube-apiserver, etcd, coredns
or the Cilium agent takes down the cluster, not an application. There is no HA to test
there.
longhorn-system — the CSI carries the volumes of everything else. Killing an
instance-manager yanks volumes that are still mounted elsewhere; the damage lands on
innocent bystanders instead of the pod under test.
vault — Raft survives losing a pod, but the pod comes back sealed and this lab has
no auto-unseal. After a few hours all 3 are sealed and Vault is down: that stops being a
resilience test and becomes a chore (../vault-cluster/vault-up.sh, every time).
cnpg-demo — the demo Postgres cluster (Cluster/pg-demo, see
../cloudnative-pg/cluster-demo.yaml). Note this is the cluster's namespace, not the
operator's: cnpg-system stays fair game, since killing the operator interrupts no data path.
Everything else is a target, including envoy-gateway-system: since #62 the data plane runs
2 replicas, so a kill there costs nothing (the controller restarts, the other envoy keeps
serving).
chaoskube ships dry-run on: without --no-dry-run it logs would kill … forever and
deletes nothing. values.yaml therefore carries no-dry-run: "", an empty value, because the
chart renders --<key> for any key whose value is empty.
Going back to dry-run means removing that key, not setting it to false: the chart template
emits --<key> for any falsy value, so --set chaoskube.args.no-dry-run=null leaves the flag in
place (verified with helm template), and --no-dry-run=false is not a chaoskube flag. That is
why CHAOS_DRY_RUN=1 renders the values into a temporary file with the line stripped.
# dryRun=false is the line that matters — dryRun=true means it is only talking
kubectl-nchaos-kubelogsdeploy/chaoskube|head-5
# the parsed filters, as chaoskube understood them (not as you wrote them)
kubectl-nchaos-kubelogsdeploy/chaoskube|grep'setting pod filter'# the body count, most recent last
kubectlgetevents-A--field-selectorreason=Killing--sort-by=.lastTimestamp
Expected on startup: dryRun=false interval=1h0m0s, then
namespaces="!cnpg-demo,!kube-system,!longhorn-system,!vault" (chaoskube sorts them, so the
order will not match values.yaml), then a first terminating pod: it kills once at startup,
then every hour.
No UI, no HTTPRoute, no domain: chaoskube is a control loop, it exposes nothing. It is
observed through its logs and the Killing Events it writes on its victims.
Interface
Command
Live log
kubectl -n chaos-kube logs -f deploy/chaoskube
Victims
kubectl get events -A --field-selector reason=Killing --sort-by=.lastTimestamp
Pause it without uninstalling, the fastest way to stop the bleeding during a debug session:
Un-excluding vault seals it. It is excluded by default for that reason: Vault survives
losing a pod (Raft, 3 replicas), but the restarted pod is sealed and stays 0/1: there is no
auto-unseal here. Drop !vault from the list and within a few hours all three are sealed and
Vault is down; recovering means re-running ../vault-cluster/vault-up.sh after every kill.
Excluding a namespace that does not exist is silently fine.cnpg-demo is only there once
../cloudnative-pg/ is installed; chaoskube just filters on a name and never complains. So
the exclusion can be pre-loaded before the addon exists, which is exactly the case here.
Dry-run is the upstream default — the #1 way to believe chaos is running when nothing is
being deleted. Check dryRun=false in the logs, not the manifest.
A bare pod never comes back. chaoskube deletes pods, it does not care what owns them.
Anything created with a plain kubectl run / a Pod manifest is gone for good, which is
precisely the finding, not a bug.
chaoskube can kill itself: its own namespace is not excluded. The Deployment recreates it,
but the hourly timer restarts from zero, so a self-kill silently skips a round.
Single control plane: kube-system is excluded for that reason. Do not remove that
exclusion on this topology (CONTROL_PLANES=1 in lab.env): there is no second API server
to take over.
minimum-age is not set, so a pod that has just started is a valid target, including one
in the middle of a rollout. Set minimum-age: "1h" in values.yaml to only ever hit
workloads that have settled.
The timezone is Pacific/Noumea, matching the lab's LAB_DOMAIN (*.ops.nc). It only
affects log timestamps as long as no excluded-weekdays / excluded-times-of-day is set;
add either of those and the timezone starts to matter.
Supprime un pod au hasard par heure, partout sauf kube-system,
longhorn-system, vault et cnpg-demo. Le but n'est pas de casser : c'est de prouver que ce que fait tourner le
lab revient tout seul. Ce qui ne revient pas n'a jamais été vraiment hautement
disponible.
L'addon le plus léger du lab : pas de stockage, pas de Gateway, pas de certificat.
Prérequis
Pourquoi
Vérifier
Cluster Ready, KUBECONFIG posé
le script teste /readyz
kubectl get nodes
helm dans le PATH
l'install passe par le chart amont
helm version
Quelque chose à tuer
sur un cluster vide, chaoskube n'a rien à prouver
kubectl get deploy -A
ℹ️ Le chart crée son ServiceAccount + son ClusterRole (pods : list, delete et
events : create, à l'échelle du cluster). Large par nature, et c'est le métier de l'outil.
Idempotent (helm upgrade --install), relançable. Deux molettes :
CHAOS_DRY_RUN=1./chaos-kube/chaoskube-up.sh<distro># observation seule, ne supprime rienCHAOSKUBE_VERSION=0.6.0./…# épingler une autre version de chart
Le script termine en relisant les flags depuis le Deployment plutôt qu'en réaffichant
values.yaml : c'est la seule preuve que les exclusions et --no-dry-run ont bien atterri dans
le pod.
Aucune spécificité de distribution pour ce composant : mêmes charts, mêmes manifestes,
mêmes valeurs sur les deux labs. La distribution passée en argument ne sert ici qu'à deux
choses : le domaine par défaut (talos.lab.example.io / kubeadm.lab.example.io) et la
localisation du lab.env / kubeconfig du lab (../Vagrant-Talos/ ou
../Vagrant-KubeADM/).
⚠️ Une conséquence à connaître, la même sur les deux labs : chaque pod Vault tué repart
SCELLÉ. Après un passage de chaoskube sur le namespace vault, relance
./vault-cluster/vault-up.sh <distro> pour redesceller. C'est pourquoi values.yaml exclut
déjà vault et cnpg-demo.
Les commandes ci-dessous sont exactement ce que fait le script tout-en-un, dans
l'ordre. Prépare d'abord l'environnement (une fois par session) :
exportKUBECONFIG=../Vagrant-Talos/kubeconfig# ou ../Vagrant-KubeADM/kubeconfigexportLAB_DOMAIN=talos.lab.example.io# ou ton domaine (cf. lab.env du lab)
--namespaces prend une liste façon sélecteur où !ns veut dire exclure, séparée par des
virgules. Les quatre exclusions de ce lab ne sont pas arbitraires :
kube-system — avec un seul control plane, tuer kube-apiserver, etcd, coredns ou
l'agent Cilium fait tomber le cluster, pas une application. Il n'y a aucune HA à tester là.
longhorn-system — le CSI porte les volumes de tout le reste. Tuer un instance-manager
arrache des volumes encore montés ailleurs ; les dégâts tombent sur des innocents plutôt que
sur le pod testé.
vault — le Raft survit à la perte d'un pod, mais le pod revient scellé et ce lab n'a
pas d'auto-unseal. En quelques heures les 3 sont scellés et Vault tombe : ça ne teste plus la
résilience, ça devient une corvée (../vault-cluster/vault-up.sh, à chaque fois).
cnpg-demo — le cluster Postgres de démo (Cluster/pg-demo, cf.
../cloudnative-pg/cluster-demo.yaml). C'est bien le namespace du cluster, pas celui de
l'opérateur : cnpg-system reste du gibier, tuer l'opérateur ne coupe aucun chemin de donnée.
Tout le reste est une cible, y compris envoy-gateway-system : depuis #62 le plan de données
tourne à 2 réplicas, donc un kill n'y coûte rien (le contrôleur redémarre, l'autre envoy
continue de servir).
chaoskube est livré dry-run activé : sans --no-dry-run il logue would kill … à l'infini
et ne supprime rien. values.yaml porte donc no-dry-run: "", une valeur vide, parce que le
chart rend --<clé> pour toute clé dont la valeur est vide.
Repasser en dry-run demande de retirer la clé, pas de la mettre à false : le template du
chart émet --<clé> dès que la valeur est fausse, donc --set chaoskube.args.no-dry-run=null
laisse le flag en place (vérifié au helm template), et --no-dry-run=false n'est pas un flag
chaoskube. D'où le CHAOS_DRY_RUN=1 qui rend les values dans un fichier temporaire, la ligne en
moins.
# dryRun=false est LA ligne qui compte — dryRun=true veut dire qu'il ne fait que parler
kubectl-nchaos-kubelogsdeploy/chaoskube|head-5
# les filtres tels que chaoskube les a COMPRIS (pas tels que tu les as écrits)
kubectl-nchaos-kubelogsdeploy/chaoskube|grep'setting pod filter'# le tableau de chasse, le plus récent en dernier
kubectlgetevents-A--field-selectorreason=Killing--sort-by=.lastTimestamp
Attendu au démarrage : dryRun=false interval=1h0m0s, puis
namespaces="!cnpg-demo,!kube-system,!longhorn-system,!vault" (chaoskube les trie, l'ordre ne
correspondra donc pas à values.yaml), puis un premier terminating pod : il tue une fois au
démarrage, puis toutes les heures.
Pas d'UI, pas d'HTTPRoute, pas de domaine : chaoskube est une boucle de contrôle, il
n'expose rien. On l'observe par ses logs et par les Events Killing qu'il écrit sur ses
victimes.
Interface
Commande
Log en direct
kubectl -n chaos-kube logs -f deploy/chaoskube
Victimes
kubectl get events -A --field-selector reason=Killing --sort-by=.lastTimestamp
Le mettre en pause sans désinstaller, le moyen le plus rapide d'arrêter l'hémorragie pendant
une session de debug :
Retirer vault de l'exclusion le scelle. Il est exclu par défaut pour cette raison :
Vault survit à la perte d'un pod (Raft, 3 réplicas), mais le pod redémarré est scellé et reste
0/1 : pas d'auto-unseal ici. Enlève !vault de la liste et en quelques heures les trois sont
scellés et Vault tombe ; s'en sortir demande de relancer ../vault-cluster/vault-up.sh après
chaque kill.
Exclure un namespace qui n'existe pas ne pose aucun problème.cnpg-demo n'apparaît
qu'une fois ../cloudnative-pg/ installé ; chaoskube ne filtre que sur un nom et ne se plaint
jamais. L'exclusion peut donc être posée avant l'addon, ce qui est exactement le cas ici.
Le dry-run est le défaut amont — la première façon de croire que le chaos tourne alors que
rien n'est supprimé. Vérifier dryRun=false dans les logs, pas dans le manifeste.
Un pod nu ne revient jamais. chaoskube supprime des pods, il ne regarde pas qui les
possède. Tout ce qui a été créé avec un simple kubectl run / un manifeste Pod est perdu,
ce qui est précisément le constat, pas un bug.
chaoskube peut se tuer lui-même : son propre namespace n'est pas exclu. Le Deployment le
recrée, mais le minuteur horaire repart de zéro : un auto-kill saute donc silencieusement un
tour.
Un seul control plane : c'est la raison de l'exclusion de kube-system. Ne pas retirer
cette exclusion sur cette topologie (CONTROL_PLANES=1 dans lab.env) : il n'y a pas de
second API server pour prendre le relais.
minimum-age n'est pas posé, donc un pod qui vient de démarrer est une cible valide, y
compris en plein rollout. Mettre minimum-age: "1h" dans values.yaml pour ne toucher que
des charges stabilisées.
Le fuseau est Pacific/Noumea, aligné sur le LAB_DOMAIN du lab (*.ops.nc). Il n'agit
que sur l'horodatage des logs tant qu'aucun excluded-weekdays / excluded-times-of-day
n'est posé ; ajoute l'un des deux et le fuseau se met à compter.
Kyverno = an admission webhook + background controllers. Three verbs: validate
(accept / reject / audit), mutate (rewrite the incoming resource), generate (create a
derived resource). Verdicts land in PolicyReport objects, aggregated by Policy Reporter
into a web UI at kyverno.lab.example.io.
🌐 lab.example.io is the repo's NEUTRAL domain (public): kyverno-up.sh swaps it
for LAB_DOMAIN (lab.env) at kubectl apply time. See
../README.md.
Versions pinned in the script: chart kyverno/kyverno3.8.2 (app v1.18.2) and
policy-reporter/policy-reporter3.9.1 (KYVERNO_VERSION / POLICY_REPORTER_VERSION
can be overridden). Idempotent (helm upgrade --install + kubectl apply).
No distribution-specific behaviour for this component: same charts, same manifests, same
values on both labs. The distribution argument only drives two things here: the default
domain (talos.lab.example.io / kubeadm.lab.example.io) and where the lab's lab.env
/ kubeconfig live (../Vagrant-Talos/ or ../Vagrant-KubeADM/).
ℹ️ One teaching nuance: the 04-disallow-privileged policy overlaps the baseline
PodSecurity level, which is enforced cluster-wide on Talos but not on kubeadm. On
kubeadm, Kyverno is therefore what actually provides the guardrail; on Talos it doubles the
existing admission (and shows it in Audit mode, which is instructive).
The commands below are exactly what the all-in-one script does, in order.
Set up your shell first (once per session):
exportKUBECONFIG=../Vagrant-Talos/kubeconfig# or ../Vagrant-KubeADM/kubeconfigexportLAB_DOMAIN=talos.lab.example.io# or your own (see the lab's lab.env)
# mutate: the label is added automatically
kubectlrundemo--image=docker.io/library/busybox--restart=Never--sleep60
kubectlgetpoddemo-ojsonpath='{.metadata.labels}';echo# lab.k8s/managed-by=kyverno# validate (Audit): the pod IS admitted, but the violation is reported
kubectlrunbad--image=nginx:latest--restart=Never--sleep60# :latest tag disallowed
kubectlgetpolicyreport-owide|grepbad
# generate: a default NetworkPolicy appears in a brand-new namespace
kubectlcreatensgen-test&&kubectl-ngen-testgetnetpol
kubectldeletepoddemobad--ignore-not-found;kubectldeletensgen-test
echo"UI: https://kyverno.${LAB_DOMAIN}"
The decisive choice in this lab: evaluation is fail-open#
values.yaml sets features.forceFailurePolicyIgnore.enabled: true. The webhooks that
evaluate your resources therefore move to failurePolicy: Ignore — you can read it in their
names: validate.kyverno.svc-ignore, mutate.kyverno.svc-ignore.
Situation
Consequence
Kyverno unreachable (pod down, timeout)
the request goes through anyway — no "Kyverno down ⇒ no pod ever starts" deadlock
Kyverno reachable, policy in Audit
the request goes through, a fail is written to the PolicyReport
Kyverno reachable, policy in Enforce
the request is rejected (fail-open does not disarm blocking)
ℹ️ Kyverno's internal webhooks (validation of ClusterPolicy and PolicyException,
cleanup) stay on failurePolicy: Fail: only the evaluation of your own resources is fail-open.
Check it yourself:
The background scan evaluates what already runs as soon as it is installed. In the UI:
violations per namespace, per policy, per severity. Nothing was blocked — this is audit. Start
with require-requests-limits and require-labels: they are the noisiest, and that is
instructive (see ⚠️ Pitfalls, "the repo violates its own policies").
kubectlpatchclusterpolicydisallow-latest-tag--typemerge\-p'{"spec":{"validationFailureAction":"Enforce"}}'
kubectlrunbad--image=nginx:latest# REJECTED by the Kyverno webhook
kubectlpatchclusterpolicydisallow-latest-tag--typemerge\-p'{"spec":{"validationFailureAction":"Audit"}}'
⚠️ spec.validationFailureAction is deprecated on this Kyverno (v1.18.2):
kubectl explain clusterpolicy.spec.validationFailureAction answers "Deprecated, use
validationFailureAction under the validate rule instead". The field still works (the repo's 4
policies use it at the spec level), but the modern form is
spec.rules[].validate.failureAction. Nothing to fix for the demo; worth knowing before a
major Kyverno version bump.
longhorn-system MUST run privileged → it shows up as fail on
disallow-privileged-containers. In the real world you exempt it cleanly. PolicyException
objects are disabled by default in the chart; for the demo, reinstall with
--set features.policyExceptions.enabled=true --set features.policyExceptions.namespace=kyverno,
then create a PolicyException targeting longhorn-system. Otherwise, just show the fail in
the UI as an illustration of the need.
Nothing in the UI → aggregation takes ~30 s. kubectl get policyreport -A should already
list rows; if not, check that policy-reporter-ui and policy-reporter-kyverno-plugin are
Running (kubectl -n kyverno get pods).
404 / route not attached → kubectl -n kyverno describe httproute policy-reporter-ui
(sectionName: https on main-gateway, hostname covered by the wildcard).
clusterpolicy stuck at READY=False → kubectl describe clusterpolicy <name>: usually a
pattern error, or a CRD missing on the generator side.
"System components are never subject to policies" is FALSE. The chart does exclude
kube-system, kube-public, kube-node-lease and kyverno via config.resourceFilters
— but that filter applies to admission. The background scan evaluates those resources
anyway and produces reports: on this lab, dozens of fail results in kube-system
(cilium, kube-proxy…) and inside kyverno itself. Check it:
kubectl -n kube-system get policyreport. Practical conclusion: no risk of blocking a
system component, but the reports do include them. (The comment in values.yaml is too
categorical on this point.)
The repo violates its own policies — deliberately, but it is noisy:
03-require-requests-limits requires limits.cpu, which no manifest in the repo sets
(deliberate choice: cap memory, do not throttle CPU). Result: by far the most-failing
policy, and it drowns the others in the UI.
01-require-labels requires app.kubernetes.io/name while every in-house manifest uses
app: → a systematic fail on the lab demos.
02-disallow-latest-tag only inspects spec.containers: a :latest inside
initContainers slips through unnoticed. A good policy-extension exercise.
UI with no authentication. Policy Reporter is published over HTTPS with no auth: anyone who
reaches VIP .200 (any authorized Tailscale peer) reads the cluster's inventory of weaknesses.
To protect it: an Envoy Gateway SecurityPolicy (Basic Auth / OIDC) on the route.
A legitimate Pod rejected after a switch to Enforce → put the policy back to Audit
(scenario 2) or create a PolicyException. Never leave a badly calibrated Enforce policy on
a shared cluster.
1 replica for the admission-controller = an accepted SPOF (lab). Fail-open avoids the
deadlock, but during a restart the policies simply stop applying. For robustness:
admissionController.replicas: 3 in values.yaml (costs RAM).
kubectldelete-fkyverno/httproute.yaml
kubectldelete-fkyverno/policies/
helm-nkyvernouninstallpolicy-reporter
helm-nkyvernouninstallkyverno# also removes the CRDs → deletes the PolicyReports
kubectldeletenskyverno
⚠️ If ../trivy-operator/ is installed, it loses its UI:
Policy Reporter (namespace kyverno) is what hosts it.
Kyverno = un webhook d'admission + des contrôleurs de fond. Trois verbes : validate
(accepter / refuser / auditer), mutate (réécrire la ressource entrante), generate (créer
une ressource dérivée). Les verdicts atterrissent dans des PolicyReport, agrégés par
Policy Reporter dans une UI web sous kyverno.lab.example.io.
🌐 lab.example.io est le domaine NEUTRE du dépôt (public) : kyverno-up.sh le
remplace par LAB_DOMAIN (lab.env) au moment du kubectl apply. Cf.
../LISEZ-MOI.md.
Aucune spécificité de distribution pour ce composant : mêmes charts, mêmes manifestes,
mêmes valeurs sur les deux labs. La distribution passée en argument ne sert ici qu'à deux
choses : le domaine par défaut (talos.lab.example.io / kubeadm.lab.example.io) et la
localisation du lab.env / kubeconfig du lab (../Vagrant-Talos/ ou
../Vagrant-KubeADM/).
ℹ️ Une nuance pédagogique : la policy 04-disallow-privileged recoupe le niveau
PodSecurity baseline, qui est appliqué au niveau cluster sur Talos mais pas sur
kubeadm. Sur kubeadm, c'est donc Kyverno qui apporte réellement le garde-fou ; sur Talos,
il double l'admission existante (et le montre en Audit, ce qui est instructif).
Les commandes ci-dessous sont exactement ce que fait le script tout-en-un, dans
l'ordre. Prépare d'abord l'environnement (une fois par session) :
exportKUBECONFIG=../Vagrant-Talos/kubeconfig# ou ../Vagrant-KubeADM/kubeconfigexportLAB_DOMAIN=talos.lab.example.io# ou ton domaine (cf. lab.env du lab)
# mutate : le label est ajouté automatiquement
kubectlrundemo--image=docker.io/library/busybox--restart=Never--sleep60
kubectlgetpoddemo-ojsonpath='{.metadata.labels}';echo# lab.k8s/managed-by=kyverno# validate (Audit) : le pod PASSE, mais la violation est rapportée
kubectlrunbad--image=nginx:latest--restart=Never--sleep60# tag :latest interdit
kubectlgetpolicyreport-owide|grepbad
# generate : une NetworkPolicy par défaut apparaît dans un namespace neuf
kubectlcreatensgen-test&&kubectl-ngen-testgetnetpol
kubectldeletepoddemobad--ignore-not-found;kubectldeletensgen-test
echo"UI : https://kyverno.${LAB_DOMAIN}"
Le choix décisif de ce lab : l'évaluation est fail-open#
values.yaml pose features.forceFailurePolicyIgnore.enabled: true. Les webhooks qui
évaluent tes ressources passent donc en failurePolicy: Ignore — on le voit dans leurs
noms : validate.kyverno.svc-ignore, mutate.kyverno.svc-ignore.
Situation
Conséquence
Kyverno injoignable (pod down, timeout)
la requête passe quand même — pas de deadlock « Kyverno KO ⇒ plus aucun pod ne démarre »
Kyverno joignable, policy en Audit
la requête passe, un fail est écrit dans le PolicyReport
Kyverno joignable, policy en Enforce
la requête est refusée (le fail-open ne désarme pas le blocage)
ℹ️ Les webhooks internes de Kyverno (validation des ClusterPolicy, des
PolicyException, du cleanup) restent en failurePolicy: Fail : seule l'évaluation de tes
ressources est fail-open. À vérifier soi-même :
Le background scan évalue l'existant dès l'installation. Dans l'UI : violations par
namespace, par policy, par sévérité. Rien n'a été bloqué — c'est de l'audit. Commence par
require-requests-limits et require-labels : ce sont les plus bruyantes, et c'est
instructif (cf. ⚠️ Pièges, « le dépôt viole ses propres policies »).
kubectlpatchclusterpolicydisallow-latest-tag--typemerge\-p'{"spec":{"validationFailureAction":"Enforce"}}'
kubectlrunbad--image=nginx:latest# REFUSÉ par le webhook Kyverno
kubectlpatchclusterpolicydisallow-latest-tag--typemerge\-p'{"spec":{"validationFailureAction":"Audit"}}'
⚠️ spec.validationFailureAction est déprécié sur ce Kyverno (v1.18.2) :
kubectl explain clusterpolicy.spec.validationFailureAction répond « Deprecated, use
validationFailureAction under the validate rule instead ». Le champ fonctionne encore (les 4
policies du dépôt l'utilisent au niveau spec), mais la forme moderne est
spec.rules[].validate.failureAction. Rien à corriger pour la démo ; à savoir avant de
monter Kyverno de version majeure.
longhorn-system DOIT tourner en privilégié → il apparaît en fail sur
disallow-privileged-containers. En vrai, on l'exempte proprement. Les PolicyException sont
désactivées par défaut dans le chart ; pour la démo, réinstalle avec
--set features.policyExceptions.enabled=true --set features.policyExceptions.namespace=kyverno,
puis crée une PolicyException ciblant longhorn-system. Sinon, montre simplement le fail
dans l'UI comme illustration du besoin.
Rien dans l'UI → l'agrégation prend ~30 s. kubectl get policyreport -A doit déjà lister
des lignes ; sinon vérifie que policy-reporter-ui et policy-reporter-kyverno-plugin sont
Running (kubectl -n kyverno get pods).
404 / route non rattachée → kubectl -n kyverno describe httproute policy-reporter-ui
(sectionName: https sur main-gateway, hostname couvert par le wildcard).
clusterpolicy en READY=False → kubectl describe clusterpolicy <nom> : souvent une
erreur de pattern ou une CRD manquante côté générateur.
« Les composants système ne sont jamais soumis aux policies » est FAUX. Le chart exclut
bien kube-system, kube-public, kube-node-lease et kyverno via config.resourceFilters
— mais ce filtre porte sur l'admission. Le scan de fond évalue quand même ces
ressources et produit des rapports : sur ce lab, des dizaines de fail en kube-system
(cilium, kube-proxy…) et dans kyverno lui-même. Vérifie-le :
kubectl -n kube-system get policyreport. Conclusion pratique : aucun risque de bloquer
un composant système, mais les rapports, eux, les incluent. (Le commentaire de values.yaml
est trop catégorique sur ce point.)
Le dépôt viole ses propres policies — c'est assumé, mais ça fait du bruit :
03-require-requests-limits exige limits.cpu, qu'aucun manifeste du dépôt ne pose
(choix délibéré : on borne la mémoire, on ne throttle pas le CPU). Résultat : c'est de
très loin la policy la plus en échec, et elle noie les autres dans l'UI.
01-require-labels exige app.kubernetes.io/name alors que tous les manifestes maison
utilisent app: → fail systématique sur les démos du lab.
02-disallow-latest-tag ne contrôle que spec.containers : un :latest dans un
initContainers passe inaperçu. Bon exercice d'extension de policy.
UI sans authentification. Policy Reporter est publié en HTTPS sans auth : quiconque
atteint le VIP .200 (tout peer Tailscale autorisé) lit l'inventaire des faiblesses du
cluster. Pour la protéger : SecurityPolicy Envoy Gateway (Basic Auth / OIDC) sur la route.
Un Pod légitime refusé après un passage en Enforce → repasse la policy en Audit
(scénario 2) ou crée une PolicyException. Ne laisse jamais une policy Enforce mal calibrée
sur un cluster partagé.
1 replica pour l'admission-controller = SPOF assumé (lab). Le fail-open évite le blocage,
mais pendant un redémarrage les policies ne s'appliquent simplement plus. Pour de la
robustesse : admissionController.replicas: 3 dans values.yaml (coûte de la RAM).
kubectldelete-fkyverno/httproute.yaml
kubectldelete-fkyverno/policies/
helm-nkyvernouninstallpolicy-reporter
helm-nkyvernouninstallkyverno# retire aussi les CRD → supprime les PolicyReport
kubectldeletenskyverno
⚠️ Si ../trivy-operator/ est installé, il perd son UI :
c'est Policy Reporter (namespace kyverno) qui l'héberge.
The detective side of the lab's security. Trivy Operator scans what runs, in a loop
(images, configs, secrets, RBAC), and writes its findings into report CRDs. Policy
Reporter's trivy plugin surfaces them in the same UI as Kyverno → a single security
dashboard.
⚠️ The node-collector needs a privileged pod — THE thing to know here. The last two
scanners go through a node-collector pod that bind-mounts /etc/systemd, /lib/systemd,
/etc/kubernetes and requires hostPID. Those paths exist and are readable on the Debian 13
nodes, so both scanners are enabled in values.yaml
(infraAssessmentScannerEnabled: true, clusterComplianceEnabled: true). But that pod is
not admissible under PodSecurity baseline/restricted: kubeadm enforces no level
cluster-wide by default, so it runs as-is — if you harden admission, label the namespace
privileged. On very small VMs, turning both back to false is a legitimate trade-off; the
image / config / secret / RBAC scans are not affected either way.
Versions pinned in the script: chart aqua/trivy-operator0.34.0 (app v0.32.0) and
policy-reporter3.9.1 (TRIVY_OPERATOR_VERSION / POLICY_REPORTER_VERSION can be
overridden). Idempotent.
One difference, driven by TRIVY_NODE_COLLECTOR (profiles):
Talos
kubeadm
operator.infraAssessmentScannerEnabled
false
true
operator.clusterComplianceEnabled
false
true
Why
the node-collector pod bind-mounts /etc/systemd, /lib/systemd, /etc/kubernetes: Talos has no systemd and / + /etc are read-only ⇒ CreateContainerError: mkdir /etc/systemd: read-only file system (and a baseline PodSecurity rejection on hostPID before that)
those paths exist and are readable; the pod runs as-is (no PodSecurity level enforced)
Consequence
no "node" reports (infra assessment, cluster compliance) — image / config / secret / RBAC scans keep working
full reports, at the cost of one collector pod per node per cycle
values.yaml is shared and encodes the kubeadm case; the script overrides both keys on Talos.
On very small VMs, TRIVY_NODE_COLLECTOR=false remains a legitimate trade-off even on kubeadm.
The commands below are exactly what the all-in-one script does, in order.
Set up your shell first (once per session):
exportKUBECONFIG=../Vagrant-Talos/kubeconfig# or ../Vagrant-KubeADM/kubeconfigexportLAB_DOMAIN=talos.lab.example.io# or your own (see the lab's lab.env)
1. Prerequisite: the UI comes from the kyverno add-on#
installs Trivy Operator in trivy-system with values.yaml, then waits for the rollout;
if the policy-reporter release exists in kyverno, re-applies it to enable the trivy
plugin (already declared in ../kyverno/policy-reporter-values.yaml); otherwise it says so
and carries on.
After a few minutes, the UI ("trivy" source) or the command above lists the fixable
HIGH/CRITICAL CVEs per image. Move on to the question that matters: which image to update first,
and to which tag.
2. Close the loop, preventive + detective (Kyverno × Trivy)#
Trivy detects an image on :latest or carrying CVEs; Kyverno can prevent its admission
(disallow-latest-tag, or Cosign signature verification). A clean demo of "I observe → I
prevent". The demo apps in ../envoy-gateway/GW-Example.yml make perfect guinea pigs (one of
them is on :latest).
Next to the cluster-wide ClusterComplianceReport, ConfigAuditReport objects give the
per-workload view: they carry the Pod Security-style checks on every workload.
💡 The "CIS compliance scan" scenario works here.kubectl get clustercompliancereport
lists the k8s-cis-*, k8s-nsa-*, k8s-pss-* definitions shipped by the chart, and their
statusis populated: the compliance controller is on and the node-collector can read
/etc/kubernetes and the systemd units of the Debian nodes. Expect a few minutes and one
collector pod per node before the first status.summary shows up.
📈 Prometheus integration (after the observability addon)#
Trivy Operator exposes metrics (vulnerability counters per workload). Once
kube-prometheus-stack is installed (the ServiceMonitor CRD is present), set
serviceMonitor.enabled: true in values.yaml and re-run the script: the counters become
scrapable and alertable. See ../observability/.
Ghost reports after turning the node-collector off. If you set
infraAssessmentScannerEnabled/clusterComplianceEnabled back to false, the
InfraAssessmentReport and ClusterComplianceReport objects already written stay stored,
frozen. They give the illusion of an active scan. Clean them up if you want an honest state:
kubectl delete infraassessmentreports -A --all.
Scan jobs Pending / OOM → concurrency is already at 1; set
trivy.builtInTrivyServer: true (shared trivy server, cached CVE database) or add RAM.
No reports after 10 min → kubectl -n trivy-system logs deploy/trivy-operator; it is
almost always a job failing to download the CVE database (network, registry, Docker Hub
rate-limit).
Too much noise → trivy.severity and trivy.ignoreUnfixed are the two knobs; conversely,
set severity: "LOW,MEDIUM,HIGH,CRITICAL" for a "show everything" demo.
Scanning eats network and CPU in waves (scannerReportTTL: 30m). On a loaded lab, stretch
the TTL (24h) rather than disabling the operator.
🔎trivy-operator/ — scanner de sécurité continu (Aqua Trivy Operator)
Le volet détectif de la sécurité du lab. Trivy Operator scanne en boucle ce qui tourne
(images, configs, secrets, RBAC) et écrit ses conclusions dans des CRD de rapport. Le
plugin trivy de Policy Reporter les remonte dans la même UI que Kyverno → un seul
tableau de bord sécurité.
⚠️ Le node-collector demande un pod privilégié — c'est LE point à connaître ici. Les
deux derniers scanners passent par un pod node-collector qui bind-monte /etc/systemd,
/lib/systemd, /etc/kubernetes et exige hostPID. Ces chemins existent et sont lisibles
sur les nodes Debian 13 : les deux scanners sont donc activés dans values.yaml
(infraAssessmentScannerEnabled: true, clusterComplianceEnabled: true). Mais ce pod n'est
pas admissible sous PodSecurity baseline/restricted : kubeadm n'applique aucun niveau
au niveau cluster par défaut, il passe donc tel quel — si tu durcis l'admission, étiquette le
namespace en privileged. Sur des VM très modestes, les repasser à false reste un
arbitrage légitime ; les scans images / config / secrets / RBAC ne sont pas affectés.
Une différence, portée par TRIVY_NODE_COLLECTOR (profils) :
Talos
kubeadm
operator.infraAssessmentScannerEnabled
false
true
operator.clusterComplianceEnabled
false
true
Pourquoi
le pod node-collector bind-monte /etc/systemd, /lib/systemd, /etc/kubernetes : Talos n'a pas de systemd et / + /etc sont en lecture seule ⇒ CreateContainerError: mkdir /etc/systemd: read-only file system (et refus PodSecurity baseline sur hostPID avant même ça)
ces chemins existent et sont lisibles ; le pod passe tel quel (aucun niveau PodSecurity appliqué)
Conséquence
pas de rapports « node » (infra assessment, cluster compliance) — les scans images / config / secrets / RBAC continuent normalement
rapports complets, au prix d'un pod de collecte par node à chaque cycle
values.yaml est commun et porte le cas kubeadm ; le script surcharge les deux clés sur Talos.
Sur des VM très modestes, TRIVY_NODE_COLLECTOR=false reste un arbitrage légitime même sur
kubeadm.
Les commandes ci-dessous sont exactement ce que fait le script tout-en-un, dans
l'ordre. Prépare d'abord l'environnement (une fois par session) :
exportKUBECONFIG=../Vagrant-Talos/kubeconfig# ou ../Vagrant-KubeADM/kubeconfigexportLAB_DOMAIN=talos.lab.example.io# ou ton domaine (cf. lab.env du lab)
installe Trivy Operator dans trivy-system avec values.yaml, puis attend le rollout ;
si la release policy-reporter existe dans kyverno, la réapplique pour activer le
plugin trivy (déjà déclaré dans ../kyverno/policy-reporter-values.yaml) ; sinon il
l'annonce et continue.
Après quelques minutes, l'UI (source « trivy ») ou la commande ci-dessus listent les CVE
HIGH/CRITICAL corrigeables par image. Enchaîne sur la question qui compte : quelle image
mettre à jour en premier, et à quel tag.
Trivy détecte une image en :latest ou porteuse de CVE ; Kyverno peut empêcher son
admission (disallow-latest-tag, ou vérification de signature Cosign). Démonstration nette du
« je constate → j'empêche ». Les apps de démo de ../envoy-gateway/GW-Example.yml font de
parfaits cobayes (l'une est en :latest).
À côté du ClusterComplianceReport (vue cluster), les ConfigAuditReport donnent la vue
par workload : ils portent les contrôles de type Pod Security sur chaque workload.
💡 Le scénario « scan de conformité CIS » fonctionne ici.kubectl get clustercompliancereport liste les définitions k8s-cis-*, k8s-nsa-*,
k8s-pss-* livrées par le chart, et leur statusest alimenté : le contrôleur de
conformité est actif et le node-collector peut lire /etc/kubernetes et les unités systemd
des nodes Debian. Compte quelques minutes et un pod collecteur par node avant le premier
status.summary.
Trivy Operator expose des métriques (compteurs de vulnérabilités par workload). Une fois
kube-prometheus-stack installé (CRD ServiceMonitor présent), passe
serviceMonitor.enabled: true dans values.yaml puis relance le script : les compteurs
deviennent scrapables et alertables. Voir ../observability/.
Rapports fantômes après avoir coupé le node-collector. Si tu repasses
infraAssessmentScannerEnabled/clusterComplianceEnabled à false, les
InfraAssessmentReport et les ClusterComplianceReport déjà écrits restent en base, figés.
Ils donnent l'illusion d'un scan actif. À nettoyer si tu veux un état honnête :
kubectl delete infraassessmentreports -A --all.
Jobs de scan en Pending / OOM → la concurrence est déjà à 1 ; passe
trivy.builtInTrivyServer: true (serveur trivy partagé, base CVE en cache) ou ajoute de la RAM.
Pas de rapports après 10 min → kubectl -n trivy-system logs deploy/trivy-operator ;
c'est presque toujours un job qui n'arrive pas à télécharger la base de CVE (réseau, registre,
rate-limit Docker Hub).
Bruit trop important → trivy.severity et trivy.ignoreUnfixed sont les deux
molettes ; à l'inverse, mettre severity: "LOW,MEDIUM,HIGH,CRITICAL" pour une démo « tout voir ».
Le scan consomme du réseau et du CPU par vagues (scannerReportTTL: 30m). Sur un lab
chargé, allonger le TTL (24h) plutôt que de désactiver l'operator.
🐙argocd/ — Argo CD (GitOps) exposed through the Gateway API
The lab's GitOps, over HTTPS, in one command. Argo CD reconciles cluster state with Git
manifests; its UI/API are published at argo.lab.example.io behind the same
main-gateway as the rest of the lab, with the *.lab.example.io wildcard already
issued by cert-manager — nothing new on the certificate side.
🌐 lab.example.io is the repo's NEUTRAL domain (public): argocd-up.sh swaps it
for LAB_DOMAIN (lab.env) in the Helm values and in the HTTPRoute. See
../README.md.
Run a full GitOps cycle: repo → Application → sync → drift detected → resync.
Show one more cross-namespace Gateway API attachment (route in argocd, Gateway in
envoy-gateway-system).
Serve as a playground for deploying the other addons through Git instead of kubectl.
ℹ️ Standalone addon: Argo CD is not installed by ../platform-up.sh (which only
lays down Cilium + Envoy Gateway + metrics-server + cert-manager). It installs on demand, like
../longhorn/, ../vault-cluster/, ../kyverno/…
Envoy terminates TLS, argocd-server speaks plaintext. We set server.insecure=true: HTTPS
in front (wildcard cert), HTTP behind. Without it, argocd-server would issue its own
307 http→https redirect while the proxy already terminates TLS → redirect loop. This is
the recommended mode behind an ingress/gateway that handles TLS.
Chart argo/argo-cd10.2.2 (app v3.4.6), pinned in the script via ARGOCD_VERSION
(can be overridden). Idempotent (helm upgrade --install + kubectl apply).
Manual equivalent
helmrepoaddargohttps://argoproj.github.io/argo-helm&&helmrepoupdate
# --version: keep the one from the script (ARGOCD_VERSION)
helmupgrade--installargocdargo/argo-cd\--namespaceargocd--create-namespace\--version10.2.2\--valuesargocd/values.yaml
kubectl-nargocdrolloutstatusdeploy/argocd-server
kubectlapply-fargocd/httproute.yaml
No distribution-specific behaviour for this component: same charts, same manifests, same
values on both labs. The distribution argument only drives two things here: the default
domain (talos.lab.example.io / kubeadm.lab.example.io) and where the lab's lab.env
/ kubeconfig live (../Vagrant-Talos/ or ../Vagrant-KubeADM/).
The commands below are exactly what the all-in-one script does, in order.
Set up your shell first (once per session):
exportKUBECONFIG=../Vagrant-Talos/kubeconfig# or ../Vagrant-KubeADM/kubeconfigexportLAB_DOMAIN=talos.lab.example.io# or your own (see the lab's lab.env)
1. The chart, with the domain substituted in the values#
values.yaml carries global.domain and configs.cm.url: we render a temporary file rather
than editing the versioned one.
The route lives in argocd and attaches to main-gateway (ns envoy-gateway-system) thanks to
allowedRoutes.namespaces.from: All on the Gateway side; since the backend sits in the same
namespace as the route, no ReferenceGrant is needed.
Redirect loop / too many redirects → server.insecure is not active:
kubectl -n argocd get cm argocd-cmd-params-cm -o jsonpath='{.data.server\.insecure}' must
return "true", then kubectl -n argocd rollout restart deploy/argocd-server.
404 / route not attached → kubectl -n argocd describe httproute argocd-server:
sectionName: https must exist on main-gateway, and the hostname must match the wildcard.
Untrusted certificate → does the https listener really serve
wildcard-lab-example-io-tls? (see ../cert-manager/README.md).
UI fine but argocd login failing → add --grpc-web.
The argocd-initial-admin-secret Secret stays in the cluster in plaintext as long as you
have not deleted it: it is a full admin credential, readable by anything holding get secrets
in argocd.
UI published on the VIP: Argo CD has its own authentication (unlike the Longhorn UI), but it
stays reachable by every authorized Tailscale peer. A strong password is mandatory.
Dex disabled = no SSO: only the local admin exists. Re-enable dex.enabled if you want to
wire up an IdP (costs a pod).
Argo CD can fight with kubectl: hand a component over to an Application with
selfHeal: true and every manual kubectl edit gets reverted. Pick your deployment mode.
Stacking this component on control planes that are too tight on RAM ends up starving etcd.
lab.env (CP_MEM) is the knob; see the pitfalls in ../README.md.
🐙argocd/ — Argo CD (GitOps) exposé via la Gateway API
Le GitOps du lab, en HTTPS, en une commande. Argo CD réconcilie l'état du cluster avec des
manifestes Git ; son UI/API sont publiées sous argo.lab.example.io derrière le même
main-gateway que le reste du lab, avec le wildcard *.lab.example.io déjà émis par
cert-manager — rien de neuf côté certificat.
🌐 lab.example.io est le domaine NEUTRE du dépôt (public) : argocd-up.sh le
remplace par LAB_DOMAIN (lab.env) dans les values Helm et l'HTTPRoute. Cf.
../LISEZ-MOI.md.
Dérouler un cycle GitOps complet : dépôt → Application → sync → drift détecté → resync.
Montrer un rattachement Gateway API inter-namespace de plus (route dans argocd, Gateway
dans envoy-gateway-system).
Servir de terrain de jeu pour déployer les autres addons par Git au lieu de kubectl.
ℹ️ Addon à part : Argo CD n'est pas installé par ../platform-up.sh (qui ne pose que
Cilium + Envoy Gateway + metrics-server + cert-manager). Il s'installe à la demande, comme
../longhorn/, ../vault-cluster/, ../kyverno/…
Envoy termine le TLS, argocd-server parle en clair. On règle server.insecure=true : HTTPS
devant (cert wildcard), HTTP derrière. Sans ça, argocd-server ferait sa propre redirection
307 http→https alors que le proxy termine déjà le TLS → boucle de redirection. C'est le
mode recommandé derrière un ingress/gateway qui gère le TLS.
Aucune spécificité de distribution pour ce composant : mêmes charts, mêmes manifestes,
mêmes valeurs sur les deux labs. La distribution passée en argument ne sert ici qu'à deux
choses : le domaine par défaut (talos.lab.example.io / kubeadm.lab.example.io) et la
localisation du lab.env / kubeconfig du lab (../Vagrant-Talos/ ou
../Vagrant-KubeADM/).
Les commandes ci-dessous sont exactement ce que fait le script tout-en-un, dans
l'ordre. Prépare d'abord l'environnement (une fois par session) :
exportKUBECONFIG=../Vagrant-Talos/kubeconfig# ou ../Vagrant-KubeADM/kubeconfigexportLAB_DOMAIN=talos.lab.example.io# ou ton domaine (cf. lab.env du lab)
1. Le chart, avec le domaine substitué dans les values#
values.yaml porte global.domain et configs.cm.url : on rend un fichier temporaire plutôt
que de modifier le fichier versionné.
La route vit dans argocd et s'attache à main-gateway (ns envoy-gateway-system) grâce à
allowedRoutes.namespaces.from: All côté Gateway ; le backend étant dans le même namespace que
la route, aucun ReferenceGrant n'est nécessaire.
Boucle de redirection / too many redirects → server.insecure n'est pas actif :
kubectl -n argocd get cm argocd-cmd-params-cm -o jsonpath='{.data.server\.insecure}' doit
valoir "true", puis kubectl -n argocd rollout restart deploy/argocd-server.
404 / route non rattachée → kubectl -n argocd describe httproute argocd-server :
sectionName: https doit exister sur main-gateway, et le hostname matcher le wildcard.
Certificat non trusté → l'écouteur https sert-il bien wildcard-lab-example-io-tls ?
(cf. ../cert-manager/LISEZ-MOI.md).
Le Secret argocd-initial-admin-secret reste en clair dans le cluster tant que tu ne l'as
pas supprimé : c'est un identifiant admin complet, lisible par tout ce qui a le droit get secrets dans argocd.
UI publiée sur le VIP : Argo CD a sa propre authentification (contrairement à l'UI
Longhorn), mais reste joignable par tout peer Tailscale autorisé. Mot de passe fort obligatoire.
Dex désactivé = pas de SSO : seul l'admin local existe. Réactiver dex.enabled si tu veux
brancher un IdP (coûte un pod).
Argo CD peut se battre avec kubectl : si tu confies un addon à une Application avec
selfHeal: true, tout kubectl edit manuel sera annulé. Choisis ton mode de déploiement.
Empiler cet addon sur des control planes trop justes en RAM finit par affamer etcd.
lab.env (CP_MEM) est la molette ; voir les pièges de ../LISEZ-MOI.md.
The demo that exercises Longhorn end to end. Two 2Gi PVCs (RWO, StorageClass
longhorn) for MariaDB (/var/lib/mysql) and WordPress (/var/www/html), exposed over
HTTPS through Envoy Gateway at wordpress.lab.example.io. One manifest, no script.
DNS wordpress.lab.example.io → 192.168.56.200 (DNS-only)
hostname of the route
curl --resolve otherwise (see ✅)
⚠️ No safety net: there is no *-up.sh here. The manifest references the longhorn
StorageClass without checking that it exists. Without Longhorn (or with only local-path), the
kubectl applysucceeds and the PVCs stay silently Pending, with the pods stuck in
Pending too. Check kubectl get scfirst. For a demo without Longhorn, replace
storageClassName: longhorn with local-path in both PVCs — accepting that a local-path PV
is node-local and not replicated (see
../local-path-storage/).
Everything sits in this single file, namespace wordpress-test included.
🌐 Domain: the manifest carries the neutral domain lab.example.io (public repo) and
does not go through a *-up.sh: edit the hostname, or substitute your own domain on the fly:
Three places to cover in this manifest: the hostname of the HTTPRouteandWP_HOME/WP_SITEURL (WORDPRESS_CONFIG_EXTRA) — WordPress builds its URLs from those
two constants, and a wrong domain breaks the CSS and the install redirect.
No distribution-specific behaviour for this component: same charts, same manifests, same
values on both labs. The distribution argument only drives two things here: the default
domain (talos.lab.example.io / kubeadm.lab.example.io) and where the lab's lab.env
/ kubeconfig live (../Vagrant-Talos/ or ../Vagrant-KubeADM/).
ℹ️ This manifest is applied by hand, so it does NOT get the automatic domain
substitution. The sed in step 2 is what does it.
The commands below are exactly what the all-in-one script does, in order.
Set up your shell first (once per session):
exportKUBECONFIG=../Vagrant-Talos/kubeconfig# or ../Vagrant-KubeADM/kubeconfigexportLAB_DOMAIN=talos.lab.example.io# or your own (see the lab's lab.env)
strategy: Recreate — Longhorn volumes are RWO (single-attach): the old pod has to
release the volume before the new one can attach it. With RollingUpdate (the default), the new
pod would stay stuck on multi-attach.
subPath — the database and the site live in a subdirectory of the volume, to avoid the
lost+found that ext4 creates at the root (MariaDB refuses a "non-empty" datadir).
TLS terminated by Envoy — WordPress only ever sees HTTP. We force detection through
HTTP_X_FORWARDED_PROTO and freezeWP_HOME/WP_SITEURL to https://… in
WORDPRESS_CONFIG_EXTRA; otherwise WordPress generates http URLs and loops on redirects.
kubectl-nwordpress-testgetpvc,pods# PVC Bound, pods Running 1/1
curl-sS-o/dev/null-w'%{http_code}\n'\--resolvewordpress.lab.example.io:443:192.168.56.200\https://wordpress.lab.example.io/# 302 → /wp-admin/install.php (fresh WP)# then finish the install in the browser: https://wordpress.lab.example.io/
# 1. Finish the WordPress install in the browser, publish a post.# 2. Kill both pods: their PVCs are reattached on restart.
kubectl-nwordpress-testdeletepod--all
kubectl-nwordpress-testgetpods-w# Recreate: the old one releases, the new one attaches# 3. Reload the page: the post is still there (the data lives in the Longhorn volumes).
On the Longhorn side (UI longhorn.lab.example.io, or kubectl -n longhorn-system get volumes), you watch both volumes detach then reattach — and see which node they are attached to.
Plaintext passwords in the manifest. The mariadb Secret is written with stringData and
example values committed to the repo: this is training material, not a template. Outside
the lab, use a Secret created outside Git (or even
../vault-secret-operator/, which does exactly that).
Changing those values after the first start is not enough: MariaDB only initializes its
credentials when the datadir is created.
PVC Pending with no visible error → the longhorn StorageClass is missing (see
📋 Prerequisites) or Longhorn is down. kubectl -n wordpress-test describe pvc mariadb-data
gives the real reason.
Multi-Attach error → a RollingUpdate slipped in instead of Recreate, or the old pod is
stuck in Terminating (lost node). Force-delete the old pod.
The domain is hardcoded in WORDPRESS_CONFIG_EXTRA (WP_HOME/WP_SITEURL) and in the
HTTPRoute. If you change domain, you have to edit both — otherwise WordPress redirects to the
old name.
wordpress:7.0-php8.3-apache and mariadb:11.8 are release-series tags, not digests: the
content can move under the same tag. Good enough for a lab, not enough for strict
reproducibility.
kubectldelete-fwordpress-example/wordpress-mariadb.yaml# deletes the namespace + the PVCs
ℹ️ Deleting the PVCs releases the Longhorn volumes (reclaimPolicy: Delete on the
StorageClass): the data is lost, including the posts published during the demo.
📝wordpress-example/ — WordPress + MariaDB (démo de stockage persistant)
La démo qui exerce Longhorn de bout en bout. Deux PVC de 2Gi (RWO, StorageClass
longhorn) pour MariaDB (/var/lib/mysql) et WordPress (/var/www/html), exposés en HTTPS
via Envoy Gateway sous wordpress.lab.example.io. Un seul manifeste, aucun script.
DNS wordpress.lab.example.io → 192.168.56.200 (DNS-only)
hostname de la route
curl --resolve sinon (cf. ✅)
⚠️ Aucun garde-fou : il n'y a pas de *-up.sh ici. Le manifeste référence la StorageClass
longhorn sans vérifier qu'elle existe. Sans Longhorn (ou avec seulement local-path), le
kubectl applyréussit et les PVC restent silencieusement Pending, pods bloqués en
Pending eux aussi. Vérifie kubectl get scavant. Pour une démo sans Longhorn, remplace
storageClassName: longhorn par local-path dans les deux PVC — en assumant qu'un PV
local-path est node-local et non répliqué (cf.
../local-path-storage/).
Tout est dans ce seul fichier, namespace wordpress-test inclus.
🌐 Domaine : le manifeste porte le domaine neutre lab.example.io (dépôt public)
et n'est pas passé par un *-up.sh : édite le hostname, ou substitue ton domaine à la volée :
Trois endroits à couvrir dans ce manifeste : le hostname de l'HTTPRouteetWP_HOME/WP_SITEURL (WORDPRESS_CONFIG_EXTRA) — WordPress génère ses URL depuis
ces deux constantes, un domaine faux casse le CSS et la redirection d'install.
Aucune spécificité de distribution pour ce composant : mêmes charts, mêmes manifestes,
mêmes valeurs sur les deux labs. La distribution passée en argument ne sert ici qu'à deux
choses : le domaine par défaut (talos.lab.example.io / kubeadm.lab.example.io) et la
localisation du lab.env / kubeconfig du lab (../Vagrant-Talos/ ou
../Vagrant-KubeADM/).
ℹ️ Le manifeste est appliqué à la main : il ne bénéficie donc PAS de la substitution
automatique du domaine. C'est le sed de l'étape 2 qui s'en charge.
Les commandes ci-dessous sont exactement ce que fait le script tout-en-un, dans
l'ordre. Prépare d'abord l'environnement (une fois par session) :
exportKUBECONFIG=../Vagrant-Talos/kubeconfig# ou ../Vagrant-KubeADM/kubeconfigexportLAB_DOMAIN=talos.lab.example.io# ou ton domaine (cf. lab.env du lab)
front, Recreate, subPath: wp, sonde sur /wp-login.php
Service mariadb / wordpress
ClusterIP (3306 / 80)
HTTPRoute wordpress
wordpress.lab.example.io → wordpress:80, sectionName: https de main-gateway
Les deux conteneurs déclarent requests (cpu + memory) et une limite mémoire seulement —
pas de limite CPU (choix du dépôt : borner la RAM, ne pas throttler le CPU).
strategy: Recreate — les volumes Longhorn sont RWO (mono-attach) : l'ancien pod doit
libérer le volume avant que le nouveau l'attache. Avec RollingUpdate (le défaut), le nouveau
pod resterait bloqué en multi-attach.
subPath — la base et le site vivent dans un sous-dossier du volume, pour éviter le
lost+found créé par ext4 à la racine (MariaDB refuse un datadir « non vide »).
TLS terminé par Envoy — WordPress ne voit que du HTTP. On force la détection via
HTTP_X_FORWARDED_PROTO et on figeWP_HOME/WP_SITEURL en https://… dans
WORDPRESS_CONFIG_EXTRA ; sinon WordPress génère des URLs en http et boucle en redirection.
# 1. Termine l'installation WordPress dans le navigateur, publie un article.# 2. Tue les deux pods : leurs PVC sont réattachés au redémarrage.
kubectl-nwordpress-testdeletepod--all
kubectl-nwordpress-testgetpods-w# Recreate : l'ancien libère, le nouveau attache# 3. Recharge la page : l'article est toujours là (les données vivent dans les volumes Longhorn).
Côté Longhorn (UI longhorn.lab.example.io, ou kubectl -n longhorn-system get volumes), on
voit les deux volumes se détacher puis se rattacher — et sur quel node ils sont attachés.
Des mots de passe en clair dans le manifeste. Le Secret mariadb est écrit en
stringData avec des valeurs d'exemple versionnées dans le dépôt : c'est un support de
formation, pas un modèle. Hors lab, passer par un Secret créé hors Git (voire par
../vault-secret-operator/, qui fait exactement ça).
Changer ces valeurs après le premier démarrage ne suffit pas : MariaDB n'initialise ses
identifiants qu'à la création du datadir.
PVC Pending sans erreur visible → StorageClass longhorn absente (voir 📋 Prérequis) ou
Longhorn en panne. kubectl -n wordpress-test describe pvc mariadb-data donne la vraie raison.
Multi-Attach error → un RollingUpdate s'est glissé à la place de Recreate, ou l'ancien
pod est coincé en Terminating (node perdu). Forcer la suppression du vieux pod.
Le domaine est figé en dur dans WORDPRESS_CONFIG_EXTRA (WP_HOME/WP_SITEURL) et
dans l'HTTPRoute. Si tu changes de domaine, il faut modifier les deux — sinon WordPress
redirige vers l'ancien nom.
wordpress:7.0-php8.3-apache et mariadb:11.8 sont des tags de série, pas des digests :
le contenu peut bouger sous le même tag. C'est suffisant pour un lab, insuffisant pour de la
reproductibilité stricte.
kubectldelete-fwordpress-example/wordpress-mariadb.yaml# supprime le namespace + les PVC
ℹ️ Supprimer les PVC libère les volumes Longhorn (reclaimPolicy: Delete de la StorageClass) :
les données sont perdues, y compris les articles publiés pendant la démo.