☸️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 very 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, no lab.env here:
./_k8s/platform-up.sh# from either lab's root (submodule layout)
./_k8s/install.shplatform# same thing, via the entry point
./install.shkubeadmplatform# from this repo's root (sibling layout)
📖 Browsable documentation: https://ops-nc.github.io/k8s-playground/ — a single
self-contained page, bilingual (EN/FR) with a dark/light theme, rebuilt from these very
README.md / LISEZ-MOI.md files on every push to main (make docs to build it locally).
Both labs remain responsible for bootstrapping the cluster (VMs, OS, kubeadm init /
talosctl bootstrap). This repository only 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.
This repository is mounted there as the _k8s/ submodule.
# 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
💡 Nothing to declare, and that is the whole point. 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. LAB_DIR is no longer needed, and neither is the
talos/kubeadm argument. KUBECONFIG is derived the same way (<lab>/kubeconfig); export
it yourself only for your ownkubectl calls, not for the scripts.
Passing the distribution explicitly still works, and still wins — useful to be sure of what you
are running, or in the sibling layout below:
./_k8s/install.shkubeadmplatform
Variant — the two repositories side by side (the pre-submodule layout, still supported)
cd../Vagrant-Talos&&./talos/cluster-up.sh# orcd../Vagrant-KubeADM&&./kubeadm/cluster-up.sh
cd../k8s-playground# this repo, sibling of the lab
./install.shtalosplatform# one lab next door ⇒ the argument is optional here too
Here the lab repository sits next to this one, 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. It follows that with both
labs cloned side by side the choice is ambiguous — the scripts then refuse to guess and you
pass talos/kubeadm. That is the only difference between the two layouts.
Every component is still runnable on its own, bare in the submodule layout:
./_k8s/longhorn/longhorn-up.sh# from the lab root (submodule layout)
./observability/observability-up.shtalos# from this repo root (sibling layout)
🎓 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.
Both labs mount this repository as a git submodule on _k8s/, at their root. That is the
normal layout, the one every lab command assumes. Keeping the two repositories side by
side — what existed before — still works, and stays documented here as the variant.
Submodule (recommended)
Sibling repositories (variant)
On disk
Vagrant-KubeADM/_k8s/is this repo
Vagrant-KubeADM/ and k8s-playground/ in the same parent directory
Where you run the scripts
from the lab root: ./_k8s/install.sh …
from this repo's root: ./install.sh <distro> …
LAB_DIR
not needed: the parent carries a Vagrantfile ⇒ rule 2 below
not needed either: rule 3 below finds ../<lab repo> on its own
Distribution argument
not needed: read off the lab's structure
not needed with a single lab next door (../Vagrant-Talos / ../Vagrant-KubeADM are probed directly); needed if both are there, or if the directory was renamed
Version of the application layer
pinned by the lab to one commit ⇒ the lab is reproducible
whatever happens to be checked out next door
Getting it
git clone --recurse-submodules …, or git submodule update --init --recursive
one git clone per repository
Updating it
git submodule update --remote _k8s, then commit the moved pointer
git pull here
Naming constraint
none: _k8s/ is imposed by the labs, and both use it
the lab directory must be named exactly Vagrant-Talos / Vagrant-KubeADM (LAB_REPO_NAME), otherwise LAB_DIR again
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, and 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 single 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, not to
the lab, and a git commit run inside _k8s/ commits here. The route is therefore: PR
on this repository → merge → in the lab, git submodule update --remote _k8s, then commit
the bumped pointer (git status shows modified: _k8s (new commits)). That commit is
the application layer's version bump, and it is what keeps a lab reproducible.
The scripts store nothing. lab.env (the intent: domain, TLS mode, CNI, VM sizes) and
_out/ (the facts: talosconfig, cluster.env, the local CA in _out/self-signed/,
_out/vault-init.json…) live in the lab repository, with the kubeconfig right beside
them at its root. 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 one 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, so the lab is simply one level up
3
<root of this repo>/../$LAB_REPO_NAME — Vagrant-Talos or Vagrant-KubeADM, set by the distribution profile
that directory exists ⇒ sibling layout (only once the profile is loaded)
4
the root of this repository
a lab.env file or an _out/ directory sits here (a symlink counts) — standalone use
5
fallback: the root of this repository
nothing above matched
KUBECONFIG follows the same directory: unless it is already exported, it becomes
<lab dir>/kubeconfig.
ℹ️ Why the test is Vagrantfile, and why it comes before rule 4. 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 — where the parent is just some
working directory. Ordering it before the "root of this repo" rule is deliberate: a
leftover _out/ (or a lab.env dropped here for a one-off test) must never shadow the real
lab sitting right above. Explicit LAB_DIR/LAB_ENV stays above everything, because an
override that can be outvoted is not an override.
⚠️ This is exactly the failure the parent rule removes. Before it, the submodule layout
resolved <root>/../Vagrant-KubeADM to Vagrant-KubeADM/Vagrant-KubeADM — a path that does
not exist — and fell through to this repository itself, which carries neither lab.env nor
_out/. Nothing failed loudly: the scripts ran on the profile defaults —
<distro>.lab.example.io instead of your LAB_DOMAIN (hence a wildcard TLS Secret under a
name no add-on would look for), CNI=cilium instead of the CNI you picked, guessed
POD_CIDR / HOSTONLY_IF / KUBE_PROXY_REPLACEMENT instead of the ones cluster-up.sh
detected, and a KUBECONFIG pointing at a file that does not exist. If you ever see that
shape of symptom again, it is a resolution problem: read the summary line first.
💡 One signal does fire, 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)". Two readings: either the bootstrap really did not finish, or the lab was not the one
resolved. The warning is non-blocking, and on Talos there is no equivalent (that lab has
no cluster.env).
Every script prints its resolution before touching anything — one line, worth reading:
lab.env absent (défauts) on a lab that does have a lab.env, or a domaine that is not
the one you set: the resolution missed the lab — force it with LAB_DIR.
Overriding it all by hand stays possible — useful for a lab kept somewhere else entirely, or
to exercise this repository in isolation:
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 dirLAB_ENV=~/labs/my-lab/lab.envKUBECONFIG=~/labs/my-lab/kubeconfig./install.shtalosplatform
⚠️ Do not create a lab.env at this repo's root. Rule 4 accepts one, and it is there for
exercising this repo without any lab — but a second lab.env is a second truth, drifting
from the lab's the moment either changes. That is precisely why there is no
lab.env.example here: the model to copy lives in the lab.
Sources 5 to 8 are _detect_distro(), four families of signals ordered by how early they
become available:
Signal
Available from
Cost
structure — the bootstrap script of the distribution the lab implements
the git clone, before any vagrant up
none: a file test
artefacts — what the bootstrap wrote into _out/
after cluster-up.sh
none: a file test. Covers a lab whose directories were renamed
neighbours — the same test run on ../Vagrant-Talos and ../Vagrant-KubeADM
the lab's git clone
none, but refuses to decide when both are there (see below)
probe — kubectl get nodes -o jsonpath=…osImage
a cluster that is up and a KUBECONFIG that already points at it
last resort only
ℹ️ Source 7 hard-codes the two names, on purpose.LAB_REPO_NAME would have been the
natural way to name the neighbour, but the profile sets it — and the profile is what we are
trying to choose. So the two candidates are tested directly. If both are present next
door, nothing is decided and the scripts ask: guessing there would mean a one-in-two chance
of deploying onto the wrong cluster. Pass talos/kubeadm in that case.
ℹ️ Why the probe is last. It needs a KUBECONFIG that is already correct — but
KUBECONFIG is derived from the lab directory, which comes from the profile, which is
loaded only once the distribution is known. By construction the probe is the least available
signal at the exact moment it is needed, so it only ever answers for an ambient kubectl
context. Sources 5 and 6 read the lab directly and have no such dependency.
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
that gets created while no pod ever starts, for instance.
ℹ️ 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 those
three stay silent and source 7 takes over — which is why ./install.sh platform works bare
from here too, as long as a single lab sits next door. Two labs side by side, or a
renamed directory: pass the distribution, or export LAB_DIR. An explicit argument
short-circuits the whole question and stays the surest way to know what you ran.
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
always installed by the bootstrap, not replaceable here
optional: replaceable by Cilium in eBPF (KUBE_PROXY_REPLACEMENT=true, the lab default)
KUBE_PROXY_REPLACEABLE
Cilium — IPAM
ipam.mode=kubernetes (podCIDRs come from kube-controller-manager)
ipam.mode=cluster-pool (the Cilium operator carves the pod CIDR)
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/ — ./_k8s/install.sh,
./_k8s/longhorn/longhorn-up.sh, and so on. Nothing else moves: 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, and what keeps a
stray second lab.env from ever competing with the real one. See
Where lab.env and _out/ are found.
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.
Alongside it, two more neutral markers. 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 — so from the lab root, whichever
layout you are in:
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
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
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.
./install.sh list prints the same list, always up to date.
ℹ️ The commands below spell out <distro> to stay unambiguous, but it is optional in both
layouts: ./_k8s/install.sh longhorn from a lab root, ./install.sh longhorn from here with
a single lab next door.
⚠️ 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 → 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.
Both labs cloned side by side, and no distribution argument. The neighbour signal then
points two ways at once: the scripts stop and ask rather than pick. Say which one —
./install.sh talos … — 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: a Vagrant-KubeADM-v2/ next
door is found by neither, and resolution falls back to this repo — silently, with the probe
on whatever kubectl context happens to be ambient. LAB_DIR is the fix. In submodule
layout the directory name is irrelevant.
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.
A git pull in a lab does not move _k8s/. The submodule stays pinned to its previous
commit: 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 announcement). With calico, flannel or
none, platform-up.sh installs metallb/ right after the CNI, on the
same range — so every CNI now 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.
Both labs mount this repository as their _k8s/ submodule; each one documents its own
bootstrap, its lab.env and its lifecycle. The relative paths that used to point at
../Vagrant-Talos/ and ../Vagrant-KubeADM/ are gone: they resolved to nothing on the
published site, and to two different places depending on the layout.
LISEZ-MOI.md
☸️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/ puis encore dans Vagrant-KubeADM/_k8s/. Elle vit
maintenant ici, une seule fois — et les deux labs montent ce dépôt à ce même
emplacement _k8s/, en sous-module git. Monté ainsi, le lab et sa distribution sont
trouvés tout seuls — pas d'argument, pas de variable d'environnement, pas de lab.env ici :
./_k8s/platform-up.sh# depuis la racine de l'un ou l'autre lab (sous-module)
./_k8s/install.shplatform# la même chose, par le point d'entrée
./install.shkubeadmplatform# depuis la racine de ce dépôt (dépôts voisins)
📖 Documentation navigable : https://ops-nc.github.io/k8s-playground/ — une page
unique et autonome, bilingue (EN/FR) avec thème sombre/clair, régénérée depuis ces mêmes
README.md / LISEZ-MOI.md à chaque push sur main (make docs pour la construire en local).
Les deux labs restent responsables du bootstrap du cluster (VM, OS, kubeadm init /
talosctl bootstrap). Ce dépôt ne s'occupe que de ce qui vient après, avec kubectl et
helm depuis l'hôte — y compris le CNI, car aucun des deux bootstraps ne laisse un réseau
pod utilisable.
On ne démarre jamais ici : on démarre dans un lab, là où vivent le cluster et son état. Ce
dépôt y est monté en sous-module, sur _k8s/.
# 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, 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 → wildcard TLS# 3. Les addons, à la carte
./_k8s/install.shlonghornvaultargocd
./_k8s/install.shlist# le catalogue complet
./_k8s/install.shall# plateforme + tous les addons, dans le bon ordre
💡 Rien à déclarer, et c'est tout l'intérêt. 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. LAB_DIR n'est plus nécessaire, l'argument talos/kubeadm non plus.
KUBECONFIG est dérivé de la même façon (<lab>/kubeconfig) ; ne l'exporte que pour tes
propres appels kubectl, pas pour les scripts.
Passer la distribution explicitement reste possible, et reste prioritaire — utile pour être sûr
de ce qu'on lance, ou en disposition voisine ci-dessous :
./_k8s/install.shkubeadmplatform
Variante — les deux dépôts côte à côte (la disposition d'avant, toujours supportée)
cd../Vagrant-Talos&&./talos/cluster-up.sh# oucd../Vagrant-KubeADM&&./kubeadm/cluster-up.sh
cd../k8s-playground# ce dépôt, voisin du lab
./install.shtalosplatform# un seul lab à côté ⇒ l'argument est facultatif ici aussi
Ici le dépôt du lab est à côté de celui-ci, pas au-dessus : la règle du
parent-Vagrantfile ne se déclenche donc pas. Le dossier du lab est trouvé par
LAB_REPO_NAME (../Vagrant-Talos, ../Vagrant-KubeADM), que pose le profil de
distribution, et la distribution elle-même se lit sur ces deux mêmes dossiers candidats,
testés par leur nom. Corollaire : avec les deux labs clonés côte à côte, le choix est
ambigu — les scripts refusent alors de deviner et tu passes talos/kubeadm. C'est la
seule différence entre les deux dispositions.
Chaque composant reste lançable seul, nu en disposition sous-module :
./_k8s/longhorn/longhorn-up.sh# depuis la racine du lab (sous-module)
./observability/observability-up.shtalos# depuis la racine d'ici (dépôts voisins)
🎓 Mode formation. Chaque dossier a un README.md (EN) / LISEZ-MOI.md (FR) avec la
section « Pas à pas guidé » : la même installation, mais commande par commande, avec
ce qu'il faut observer à chaque étape et les variantes propres à chaque distribution. Le
script « tout-en-un » et le pas-à-pas font strictement la même chose.
🧩 Deux dispositions : sous-module ou dépôts voisins#
Les deux labs montent ce dépôt en sous-module git, sur _k8s/, à leur racine. C'est la
disposition normale, celle que supposent toutes les commandes des labs. Garder les deux
dépôts côte à côte — ce qui existait avant — fonctionne toujours et reste documenté ici
comme la variante.
Sous-module (recommandée)
Dépôts voisins (variante)
Sur le disque
Vagrant-KubeADM/_k8s/est ce dépôt
Vagrant-KubeADM/ et k8s-playground/ dans le même dossier parent
Où l'on lance les scripts
depuis la racine du lab : ./_k8s/install.sh …
depuis la racine de ce dépôt : ./install.sh <distro> …
LAB_DIR
inutile : le parent porte un Vagrantfile ⇒ règle 2 ci-dessous
inutile aussi : la règle 3 ci-dessous trouve ../<dépôt du lab> toute seule
Argument de distribution
inutile : lu sur la structure du lab
inutile avec un seul lab à côté (../Vagrant-Talos / ../Vagrant-KubeADM sont testés directement) ; nécessaire si les deux sont là, ou si le dossier a été renommé
Version de la couche applicative
épinglée par le lab sur un commit ⇒ le lab est reproductible
celle qui traîne à côté, quelle qu'elle soit
Comment l'obtenir
git clone --recurse-submodules …, ou git submodule update --init --recursive
un git clone par dépôt
Comment la mettre à jour
git submodule update --remote _k8s, puis committer le pointeur déplacé
git pull ici
Contrainte de nommage
aucune : _k8s/ est imposé par les labs, et les deux l'utilisent
le dossier du lab doit s'appeler exactement Vagrant-Talos / Vagrant-KubeADM (LAB_REPO_NAME), sinon LAB_DIR de nouveau
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# amène _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 l'on jouerait alors les commandes
documentées contre une couche applicative plus ancienne. Enchaîne chaque pull avec
git submodule update --init --recursive. Et un _k8s/ vide — ./_k8s/install.sh: No such file or directory — ne veut jamais dire qu'une chose : le sous-module n'a jamais été
initialisé.
💡 Modifier cette couche, c'est modifier ici. Vu depuis un lab, _k8s/ est une copie
de travail de ce dépôt sur un HEAD détaché : les fichiers qu'on y édite appartiennent à
ce dépôt, pas au lab, et un git commit lancé dans _k8s/ committe ici. Le chemin est
donc : PR sur ce dépôt → merge → dans le lab, git submodule update --remote _k8s, puis
commit du pointeur bumpé (git status affiche modified: _k8s (new commits)). Ce commit
est la montée de version de la couche applicative, et c'est lui qui garde le lab
reproductible.
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 dans _out/self-signed/,
_out/vault-init.json…) vivent dans le dépôt du lab, avec le kubeconfig juste à côté, à
sa racine. Il n'y a qu'une source de vérité pour la topologie, celle du lab — c'est
pourquoi ce dépôt ne porte ni lab.env, ni modèle de lab.env. _resolve_lab_dir(), dans
lib/common.sh, cherche ce dossier dans cet ordre :
#
Candidat
S'applique quand
1
$LAB_DIR, à défaut le dossier qui porte $LAB_ENV
l'une des deux est exportée — surcharge explicite, gagne toujours
2
le dossier parent de ce dépôt, s'il porte un Vagrantfile
disposition sous-module : ce dépôt est<lab>/_k8s, le lab est donc juste au-dessus
3
<racine de ce dépôt>/../$LAB_REPO_NAME — Vagrant-Talos ou Vagrant-KubeADM, posé par le profil de distribution
ce dossier existe ⇒ dépôts voisins (seulement une fois le profil chargé)
4
la racine de ce dépôt
un fichier lab.env ou un dossier _out/ s'y trouve (un lien symbolique compte) — usage autonome
5
repli : la racine de ce dépôt
rien de ce qui précède n'a matché
KUBECONFIG suit le même dossier : s'il n'est pas déjà exporté, il devient
<dossier du lab>/kubeconfig.
ℹ️ Pourquoi le test porte sur Vagrantfile, et pourquoi il passe avant la règle 4. 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 en disposition voisine — où le
parent n'est qu'un dossier de travail quelconque. L'ordonner avant la règle « racine de
ce dépôt » est délibéré : un _out/ résiduel (ou un lab.env déposé ici pour un test
ponctuel) ne doit jamais masquer le vrai lab qui se trouve juste au-dessus. LAB_DIR /
LAB_ENV restent au-dessus de tout : une surcharge qu'on peut mettre en minorité n'est pas
une surcharge.
⚠️ C'est exactement la panne que la règle du parent supprime. Avant elle, la disposition
sous-module résolvait <racine>/../Vagrant-KubeADM en Vagrant-KubeADM/Vagrant-KubeADM — un
chemin qui n'existe pas — et retombait sur ce dépôt lui-même, qui ne porte ni lab.env ni
_out/. Rien ne cassait bruyamment : les scripts tournaient avec les défauts du profil —
<distro>.lab.example.io au lieu de ton LAB_DOMAIN (donc un Secret TLS wildcard sous un
nom qu'aucun addon n'irait chercher), CNI=cilium au lieu du CNI choisi, POD_CIDR /
HOSTONLY_IF / KUBE_PROXY_REPLACEMENT DEVINÉS au lieu de ceux détectés par
cluster-up.sh, et un KUBECONFIG pointant sur un fichier inexistant. Si tu revois cette
forme de symptôme, c'est un problème de résolution : lis d'abord la ligne de résumé.
💡 Un signal se déclenche quand même, sur kubeadm seulement.platform-up.sh avertit
quand _out/cluster.env est absent — « ./kubeadm/cluster-up.sh n'a pas (ou pas jusqu'au
bout) été lancé ». Deux lectures : soit le bootstrap n'est effectivement pas allé au bout,
soit ce n'est pas le bon lab qui a été résolu. L'avertissement est non bloquant, et sur
Talos il n'a pas d'équivalent (ce lab n'a pas de cluster.env).
Chaque script affiche sa résolution avant de toucher à quoi que ce soit — une ligne, à lire :
lab.env absent (défauts) sur un lab qui a pourtant un lab.env, ou un domaine qui n'est
pas celui que tu as posé : la résolution a manqué le lab — force-la avec LAB_DIR.
Tout surcharger à la main reste possible — utile pour un lab rangé ailleurs, ou pour exercer ce
dépôt isolément :
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 labLAB_ENV=~/labs/mon-lab/lab.envKUBECONFIG=~/labs/mon-lab/kubeconfig./install.shtalosplatform
⚠️ Ne crée pas de lab.env à la racine de ce dépôt. La règle 4 en accepte un, et il est
là pour exercer ce dépôt sans aucun lab — mais un second lab.env est une seconde vérité,
qui diverge de celle du lab dès que l'une des deux bouge. C'est précisément 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(), quatre familles de signaux classées par la
précocité avec laquelle elles deviennent disponibles :
Signal
Disponible dès
Coût
structure — le script de bootstrap de la distribution que le lab implémente
le git clone, avant tout vagrant up
nul : un test de fichier
artefacts — ce que le bootstrap a écrit dans _out/
après cluster-up.sh
nul : un test de fichier. Couvre un lab dont les dossiers ont été renommés
voisins — le même test appliqué à ../Vagrant-Talos et ../Vagrant-KubeADM
le git clone du lab
nul, mais refuse de trancher si les deux sont là (cf. ci-dessous)
sondage — kubectl get nodes -o jsonpath=…osImage
un cluster debout et un KUBECONFIG qui pointe déjà dessus
dernier recours seulement
ℹ️ La source 7 code les deux noms en dur, volontairement.LAB_REPO_NAME aurait été la
façon naturelle de nommer le voisin, mais c'est le profil qui le pose — et le profil est
précisément ce qu'on cherche à choisir. Les deux candidats sont donc testés directement. Si
les deux sont présents à côté, rien n'est tranché et les scripts redemandent : deviner
reviendrait à une chance sur deux de déployer sur le mauvais cluster. Dans ce cas, passe
talos/kubeadm.
ℹ️ Pourquoi le sondage est en dernier. Il exige un KUBECONFIG déjà correct — or
KUBECONFIG est dérivé du dossier du lab, qui vient du profil, qui n'est chargé qu'une
fois la distribution connue. Par construction, le sondage est donc le signal le moins
disponible à l'instant précis où on en a besoin : il ne répond jamais que pour un contexte
kubectl ambiant. Les sources 5 et 6 lisent le lab directement et n'ont pas cette
dépendance.
Sans aucune de ces sources, les scripts refusent de démarrer : appliquer un manifeste
pensé pour Talos sur Debian (ou l'inverse) ne produit pas une erreur franche mais une panne
silencieuse — un Deployment créé dont aucun pod ne démarre, par exemple.
ℹ️ Les sources 4 à 6 exigent que le lab soit localisé d'abord — et c'est là que la
disposition compte. En sous-module, la règle du parent-Vagrantfile s'applique avant
tout chargement de profil : les trois fonctionnent donc nues, et c'est ce qui rend
./_k8s/platform-up.sh autosuffisant. En dépôts voisins, aucun lab n'est localisé à cet
instant (LAB_REPO_NAME vient du profil) : ces trois-là restent muettes et la source 7 prend
le relais — c'est pourquoi ./install.sh platform marche nu depuis ici aussi, tant qu'il n'y
a qu'un seul lab à côté. Deux labs côte à côte, ou un dossier renommé : passe la
distribution, ou exporte LAB_DIR. Un argument explicite court-circuite toute la question et
reste le moyen le plus sûr de savoir ce qu'on a lancé.
Tout est concentré dans lib/profiles/talos.sh et lib/profiles/kubeadm.sh : les
scripts d'installation ne testent jamais la distribution à coups de if dispersés, ils lisent
des variables.
Sujet
Talos Linux
Debian 13 + kubeadm
Variable du profil
Domaine par défaut des UI
talos.lab.example.io
kubeadm.lab.example.io
DEFAULT_LAB_DOMAIN
PodSecurity (niveau cluster)
baselineappliqué → un pod privilégié exige un namespace étiqueté privileged, sinon échec silencieux
aucun niveau appliqué → les mêmes labels ne débloquent rien, ils 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)
extensioniscsi-tools cuite dans l'image d'install (irrécupérable à chaud) + montage kubelet rshared via talosctl patch mc
paquetopen-iscsi posé par provision.sh ; /var/lib/longhorn est un dossier ordinaire
LONGHORN_PREP_REQUIRED
kube-proxy
toujours posé par le bootstrap, non remplaçable ici
optionnel : remplaçable par Cilium en eBPF (KUBE_PROXY_REPLACEMENT=true, défaut du lab)
KUBE_PROXY_REPLACEABLE
Cilium — IPAM
ipam.mode=kubernetes (podCIDR posés par le kube-controller-manager)
ipam.mode=cluster-pool (l'opérateur Cilium découpe le CIDR pod)
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 commun : résolution de la distro, lecture 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 la page unique depuis tous les README (make docs)
Makefile docs, docs-check, validate — tout ce qui tourne sans cluster
En sous-module, tout cet arbre est sous le _k8s/ du lab — ./_k8s/install.sh,
./_k8s/longhorn/longhorn-up.sh, etc. Rien d'autre ne bouge : les chemins internes à ce
dépôt sont les mêmes dans les deux dispositions, et c'est pour ça que chaque pas-à-pas est
écrit depuis la racine de ce dépôt.
Il n'y a ni Vagrantfile, ni _out/, ni lab.env ici, et c'est voulu : ce dépôt porte
les manifestes, le lab porte le cluster et son état. Cette absence est structurante — c'est
elle qui fait de « le parent porte un Vagrantfile » un repère non ambigu, et qui empêche un
second lab.env de venir concurrencer le vrai. Cf.
Où sont trouvés lab.env et _out/.
Chaque maillon suppose le précédent : pas d'IP LoadBalancer sans annonceur L2, pas de HTTPS
sans Gateway, pas d'UI sans certificat sur l'écouteur :443.
cluster bootstrapé (lab Talos ou lab kubeadm — nodes NotReady, pas encore 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/ — UNIQUEMENT si le CNI n'est pas cilium, sur la MÊME plage
│ (sauté avec METALLB=false : plus aucune IP LoadBalancer)
├─ 2. envoy-gateway/ contrôleur Envoy + main-gateway (écouteurs :80 et :443)
├─ 3. metric-server API metrics.k8s.io (kubectl top, HPA)
└─ 4. wildcard TLS *.<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 en tirets>-tls) : aucun addon n'a jamais
à savoir lequel a été choisi.
À ses côtés, deux autres marqueurs neutres. Le dépôt est public : aucun manifeste ne porte de vraie valeur. Trois marqueurs neutres
sont substitués à la volée (fonction 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)
env, puis lab.env
lab-example-io
$LAB_DOMAIN_DASH — nom du Secret TLS wildcard
dérivé 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 — donc depuis la racine du lab,
quelle que soit la disposition :
echo'LAB_DOMAIN=k8s.mon-domaine.tld'>>lab.env# dans Vagrant-Talos/ ou Vagrant-KubeADM/
⚠️ Un domaine resté à <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 passer par un *-up.sh) ne bénéficient pas
de la substitution : wordpress-example/wordpress-mariadb.yaml,
vault-secret-operator/k8s/*.yaml, cert-manager/04-gateway-https-example.yaml. Passe-les
dans le même sed :
Audit du 1er août 2026 : tout est à 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
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
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é (dernière : v0.28.8)
platform-up.sh
FLANNEL_VERSION
ℹ️ Les images des démos (WordPress, MariaDB, nginx, alpine, busybox, PostgreSQL, MinIO)
sont épinglées volontairement et ne font pas partie de cet audit : les mettre à jour n'a
d'intérêt que si la démo casse.
ℹ️ Les commandes ci-dessous explicitent <distro> pour rester sans ambiguïté, mais il est
facultatif dans les deux dispositions : ./_k8s/install.sh longhorn depuis la racine d'un
lab, ./install.sh longhorn depuis ici avec un seul lab à côté.
⚠️ Rester sur le /32 (ou l'encadrer par une ACL) : un /24 exposerait aussi l'API
Kubernetes (:6443) et le SSH de chaque node.
Nom + TLS — 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. Le TLS est donc terminé par Envoy, pas par Cloudflare → la Gateway doit
porter un certificat publiquement trusté (Let's Encrypt, cf.
cert-manager/). Un certificat Cloudflare Origin CA serait
refusé par les navigateurs.
💡 Avec le défaut SELF_SIGNED=true, rien de tout ça n'est nécessaire : une ligne
/etc/hosts pointant sur 192.168.56.200 suffit, et le domaine n'a jamais besoin de
résoudre publiquement.
Les deux labs clonés côte à côte, sans argument de distribution. Le signal « voisin »
pointe alors dans les deux sens à la fois : les scripts s'arrêtent et redemandent plutôt que
de choisir. Dis lequel — ./install.sh talos … — ou lance depuis le lab visé, en
sous-module.
Le dossier du lab renommé, en dépôts voisins. La recherche du lab (LAB_REPO_NAME) comme
la détection par voisinage matchent ces deux noms au pied de la lettre : un
Vagrant-KubeADM-v2/ à côté n'est trouvé par aucune des deux, et la résolution retombe sur
ce dépôt — silencieusement, avec le sondage du contexte kubectl ambiant. LAB_DIR est le
correctif. En sous-module, le nom du dossier n'a aucune importance.
Lire 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 que prévu.
Un git pull dans un lab ne déplace pas _k8s/. Le sous-module reste épinglé sur son
commit précédent : 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 l'annotation
is-default-class: "true". Les deux addons installés ⇒ un PVC sans storageClassName
explicite atterrit sur la SC créée en dernier, de façon non déterministe. Nomme toujours
ta SC.
Cette couche a besoin d'un Service LoadBalancer qui obtienne réellement une IP, et
Cilium est le seul CNI ici à en fournir une tout seul (annonce L2/ARP). Avec calico,
flannel ou none, platform-up.sh installe metallb/ juste après
le CNI, sur la même plage — tous les CNI aboutissent donc désormais à 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 en y laissant la
loadBalancerClass Cilium.
Jamais MetalLB et Cilium. Deux annonceurs sur une plage, ce sont deux nodes qui
répondent à l'ARP pour .200 : le point d'entrée marche alors « une fois sur deux », sans
qu'aucun log ne le dise. metallb-up.sh refuse de s'installer sur un cluster Cilium, et
platform ne le choisit jamais là.
Changer de CNI à chaud n'est pas supporté : remets le cluster à plat depuis le lab
(./kubeadm/cluster-reset.sh, ou vagrant destroy), puis rebootstrape.
Les policies Kyverno du dépôt sont violées par le dépôt lui-même
(require-requests-limits exige un limits.cpu que les manifestes maison ne posent pas,
volontairement). Le rapport est bruyant par construction — cf.
kyverno/.
Les émetteurs de métriques sont coupés par défaut (serviceMonitor/podMonitor à
false dans trivy-operator, CloudNativePG, node-problem-detector) : Prometheus ne scrute
rien tant que tu ne les rallumes pas après avoir installé observability.
Ne descends jamais CP_MEM sous 3072 dans le lab.env du lab : empiler ces addons
sur des control planes à 2 Go affame etcd. observability demande 4096.
Les deux labs montent ce dépôt en sous-module _k8s/ ; chacun documente son propre bootstrap,
son lab.env et son cycle de vie. Les chemins relatifs qui pointaient vers
../Vagrant-Talos/ et ../Vagrant-KubeADM/ ont disparu : ils ne menaient nulle part sur le
site publié, et vers deux endroits différents selon la disposition.
cilium/README.md
🐝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.
🎓 Two paths, same result: the all-in-one script below, or the "Guided
walkthrough" section further down — the same commands, one at a time, for training.
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
false (forced)
KUBE_PROXY_REPLACEMENT, default true
Talos always installs kube-proxy. On kubeadm, kubeadm init may have run with --skip-phases=addon/kube-proxy: Cilium must then take over in eBPF, and getting it 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: the pod CIDR is in the generated machine config
grep-A2podSubnets../Vagrant-Talos/_out/controlplane.yaml
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
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 (default): kubeadm init ran with --skip-phases=addon/kube-proxy, 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: nothing provisions the apiserver ClusterIP any more, so the agent cannot bootstrap through kubernetes.default. We point at the keepalived VIP, not at cp1's own IP: the VIP survives losing cp1, and it is the address already baked into the certificates
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 there is no kube-proxy DaemonSet in kube-system, and
that is the expected state — cilium-dbg status must report KubeProxyReplacement: True.
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.KUBE_PROXY_REPLACEMENT=true makes
kubeadm/cluster-up.sh run kubeadm init --skip-phases=addon/kube-proxy; this script then
reads the same value back from _out/cluster.env. Flipping only one of the two loses every
Service in the cluster. Changing your mind means rebuilding: ./kubeadm/cluster-reset.sh then
./kubeadm/cluster-up.sh.
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.
🎓 Deux chemins, même résultat : le script tout-en-un ci-dessous, ou la section
« Pas à pas guidé » plus bas — les mêmes commandes, une par une, pour une formation.
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
false (forcé)
KUBE_PROXY_REPLACEMENT, défaut true
Talos pose toujours kube-proxy. Sur kubeadm, kubeadm init a pu tourner avec --skip-phases=addon/kube-proxy : Cilium doit alors le remplacer en eBPF, et 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 : le CIDR pod est dans la config machine générée
grep-A2podSubnets../Vagrant-Talos/_out/controlplane.yaml
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
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) : kubeadm init a tourné avec --skip-phases=addon/kube-proxy, il n'y a 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 : 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, 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
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 il n'y a aucun DaemonSet kube-proxy dans kube-system,
et c'est l'état attendu — cilium-dbg status doit afficher KubeProxyReplacement: True.
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.KUBE_PROXY_REPLACEMENT=true
fait lancer à kubeadm/cluster-up.sh un kubeadm init --skip-phases=addon/kube-proxy ; ce
script relit ensuite la même valeur dans _out/cluster.env. N'en changer qu'un des deux fait
perdre TOUS les Services du cluster. Changer d'avis = reconstruire :
./kubeadm/cluster-reset.sh puis ./kubeadm/cluster-up.sh.
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
⚠️ only Cilium can replace kube-proxy here. kubeadm/cluster-up.sh refuses the calico + true pair outright, and this script re-checks it: without kube-proxy and without a replacement, no ClusterIP answers at all
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
🎓 Two paths, same result: the all-in-one script below, or the "Guided
walkthrough" section further down — the same commands, one at a time, for training.
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), 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 installed by kubeadm init — hence the mandatory KUBE_PROXY_REPLACEMENT=false
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
⚠️ seul Cilium sait remplacer kube-proxy ici. kubeadm/cluster-up.sh refuse net le couple calico + true, et ce script le revérifie : sans kube-proxy ET sans remplaçant, plus aucune ClusterIP ne répond
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
🎓 Deux chemins, même résultat : le script tout-en-un ci-dessous, ou la section
« Pas à pas guidé » plus bas — les mêmes commandes, une par une, pour une formation.
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), 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 kubeadm init — d'où le KUBE_PROXY_REPLACEMENT=false obligatoire
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/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 DNS record 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.
🎓 Two paths, same result: the all-in-one script below, or the "Guided walkthrough"
section further down — the same commands, one at a time, for training.
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 training:
Chart metallb/metallb0.16.1, read from lab.env (METALLB_VERSION) and 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 announcement itself is identical: same chart, same pool, same interface, same node
selection. One thing genuinely differs, and it is admission.
Point
Talos
kubeadm
Consequence
PodSecurity default
baselineenforced cluster-wide
no level enforced
The speaker runs on hostNetwork and adds NET_RAW — baseline forbids both. namespace.yaml is therefore mandatory on Talos and documentation on kubeadm.
Host-only interface
always enp0s8
eth1 or enp0s8 depending on the box → detected into _out/cluster.env
HOSTONLY_IF covers both; the script substitutes it into metallb-l2.yml.
kube-proxy
always installed by Talos
optional, but KUBE_PROXY_REPLACEMENT=true forces CNI=cilium
So MetalLB always finds a kube-proxy in front of it, on both labs.
Likely CNI next to it
calico, or flannelpre-installed at bootstrap
calico, or flannel installed by platform-up.sh
No effect on MetalLB: it only ever sees Ready nodes and Services.
ℹ️ No profile variable was added for this component: nothing here reads
lib/profiles/<distro>.sh beyond DEFAULT_HOSTONLY_IF, which already existed. The PodSecurity
divergence is carried by a namespace manifest applied in both cases — the repository rule:
label the namespace everywhere, even where it unlocks nothing today.
The same thing as the script, one command at a time. Set your variables first:
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"
kubectlgetnodes# all Ready — the CNI is already there
kubectlgetciliuml2announcementpolicies.cilium.io2>/dev/null# MUST be empty / no CRD
kubectl-nenvoy-gateway-systemgetsvc# EXTERNAL-IP <pending>, if the Gateway is up
# The versioned values are the lab defaults: substitute yours, exactly as the script does
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-
⚠️ If this 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 exactly what the script's loop does.
kubectl-nmetallb-systemgetipaddresspool,l2advertisement
kubectl-nenvoy-gateway-systemgetsvc# EXTERNAL-IP = 192.168.56.200
kubectlgetservicel2status-A-owide# which node announces which IP
refuses if Cilium announces, or if CNI=cilium; refuses if no node is Ready
two ARP announcers on one range is the hardest failure of this lab to read; and MetalLB on a CNI-less cluster hangs Pending for 5 min before saying anything
[1/3]
namespace.yamlthen the chart
helm --create-namespace sets no PodSecurity label — and its absence fails silently (see below)
[2/3]
rollout status on the controller and the DaemonSet
the controller allocates, the speakers announce: an IP with no speaker is assigned and unreachable
[3/3]
metallb-l2.yml rendered, applied with retry
the validating webhook is served by the controller and takes a few seconds to present a trusted certificate
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 is left at the chart default on purpose — in particular
speaker.tolerateMaster=true, which runs a speaker on the control planes too. That is harmless:
what decides who announces is the nodeSelectors of the L2Advertisement, exactly like the
Cilium policy.
IPAddressPool — the range, written as a single start-stop string (Cilium uses two
fields). autoAssign: true: the Gateway asks for no particular address and gets the first
free one.
L2Advertisement — who announces, and on which card. ipAddressPools names the pool
(without it the advertisement would cover every pool), interfaces pins the host-only card
and nodeSelectors keeps the control planes out.
kubectl-nmetallb-systemgetpods# 1 controller + 1 speaker per node
kubectl-nmetallb-systemgetipaddresspool-owide# the range
kubectl-nmetallb-systemgetl2advertisement-oyaml|grep-A4interfaces
# The Gateway must have taken the FIRST IP of the pool
kubectl-nenvoy-gateway-systemgetsvc
# Who is announcing what (MetalLB's equivalent of Cilium's leases)
kubectlgetservicel2status-A-owide
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# Take that node's speaker away
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) — do not work around it,
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 — 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 kubectl -n metallb-system 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 — otherwise the pool is
valid and no one announces it.
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. A failover takes a few seconds (the time to re-ARP).
kube-proxy in IPVS mode needs strictARP: true. Neither lab uses IPVS (both are in
iptables mode), 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. MetalLB only
makes sense on a cluster bootstrapped with the right CNI from the start.
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 cloud provider manquant. Cilium est le seul CNI de ce lab qui attribue une vraie IP aux
Services type: LoadBalancer et l'annonce en ARP. Avec Calico, flannel ou
CNI=none, personne ne le fait — le Service de l'Envoy Gateway 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.
Attribue une IP de la plage host-only 192.168.56.200-230 à chaque Service type: LoadBalancer (le Deployment controller).
L'annonce en ARP depuis un node worker, pour que l'hôte — et tout ce qui est routé
vers l'hôte, Tailscale compris — puisse l'atteindre (le DaemonSet speaker).
Rien d'autre. MetalLB n'est pas un CNI : il lui faut un réseau pod fonctionnel pour
tourner.
metallb/metallb-up.sh lit les mêmes clés de lab.env que Cilium et pose les deux mêmes
objets, traduits dans l'API de MetalLB — pour que changer de CNI change le CNI, pas
l'adresse vers laquelle pointe l'enregistrement DNS wildcard *.<LAB_DOMAIN>.
🎓 Deux chemins, même résultat : le script tout-en-un ci-dessous, ou la section « Pas à
pas guidé » plus bas — les mêmes commandes, une par une, pour la formation.
En pratique tu ne le lances pas à 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 la
formation :
Chart metallb/metallb0.16.1, lu depuis lab.env (METALLB_VERSION) et surchargeable
au coup par coup (METALLB_VERSION=0.16.0 ./metallb/metallb-up.sh). Idempotent (helm upgrade --install + kubectl apply).
ℹ️ metallb est volontairement exclu de ./install.sh <distro> all : sur le CNI=cilium
par défaut il refuse de s'installer, et un all inconditionnel s'arrêterait donc toujours là.
C'est platform qui 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. Une seule chose diverge vraiment, et c'est l'admission.
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.
kube-proxy
toujours installé par Talos
optionnel, mais KUBE_PROXY_REPLACEMENT=true force CNI=cilium
MetalLB trouve donc toujours un kube-proxy devant lui, sur les deux labs.
CNI probable à côté
calico, ou flannelpré-installé au bootstrap
calico, ou flannel installé par platform-up.sh
Aucun effet sur MetalLB : il ne voit que des nodes Ready et des Services.
ℹ️ Aucune variable de profil n'a été ajoutée pour ce composant : rien ici ne lit
lib/profiles/<distro>.sh au-delà de DEFAULT_HOSTONLY_IF, qui existait déjà. La divergence
PodSecurity est portée par un manifeste de namespace appliqué dans les deux cas — la règle
du dépôt : étiqueter le namespace partout, même là où ça ne débloque rien aujourd'hui.
La même chose que le script, une commande à la fois. Pose d'abord tes variables :
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"
kubectlgetnodes# tous Ready — le CNI est déjà là
kubectlgetciliuml2announcementpolicies.cilium.io2>/dev/null# DOIT être vide / pas de CRD
kubectl-nenvoy-gateway-systemgetsvc# EXTERNAL-IP <pending>, si la Gateway est debout
# Les valeurs versionnées sont les défauts du lab : substitue les tiennes, comme le fait le script
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-
⚠️ Si ça échoue en connection refused ou sur une erreur x509 visant
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 exactement ce que fait la boucle du script.
kubectl-nmetallb-systemgetipaddresspool,l2advertisement
kubectl-nenvoy-gateway-systemgetsvc# EXTERNAL-IP = 192.168.56.200
kubectlgetservicel2status-A-owide# quel node annonce quelle IP
refuse si Cilium annonce, ou si CNI=cilium ; refuse si aucun node n'est Ready
deux annonceurs ARP sur une plage, c'est la panne la plus illisible de ce lab ; et MetalLB sur un cluster sans CNI reste Pending 5 min avant de dire quoi que ce soit
[1/3]
namespace.yamlpuis le chart
helm --create-namespace ne pose aucun label PodSecurity — et leur absence échoue en silence (voir plus bas)
[2/3]
rollout status sur le controller et le DaemonSet
le controller attribue, les speakers annoncent : une IP sans speaker est attribuée et injoignable
[3/3]
metallb-l2.yml rendu, appliqué avec réessai
le webhook de validation est servi par le controller et met quelques secondes à présenter un certificat de confiance
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 annonce en L2 uniquement (pas de routeur pair sur un réseau host-only VirtualBox) — du poids mort.
speaker.frr.enabled
false
L'autre manière, dépréciée, de faire entrer FRR dans le pod speaker. Le chart refuse les deux à la fois : on les coupe donc 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, volontairement — en particulier
speaker.tolerateMaster=true, qui fait tourner un speaker sur les control planes aussi. C'est
sans conséquence : ce qui décide qui annonce, c'est le nodeSelectors de la L2Advertisement,
exactement comme la policy Cilium.
IPAddressPool — la plage, écrite en une seule chaîne start-stop (Cilium utilise deux
champs). autoAssign: true : la Gateway ne demande aucune adresse en particulier et reçoit la
première libre.
L2Advertisement — qui annonce, et sur quelle carte. ipAddressPools nomme le pool (sans
lui l'annonce couvrirait tous les pools), interfaces épingle la carte host-only et
nodeSelectors écarte les control planes.
kubectl-nmetallb-systemgetpods# 1 controller + 1 speaker par node
kubectl-nmetallb-systemgetipaddresspool-owide# la plage
kubectl-nmetallb-systemgetl2advertisement-oyaml|grep-A4interfaces
# La Gateway doit avoir pris la PREMIÈRE IP du pool
kubectl-nenvoy-gateway-systemgetsvc
# Qui annonce quoi (l'équivalent MetalLB des leases Cilium)
kubectlgetservicel2status-A-owide
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 au 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
l'achemine. L'ICMP vers le VIP ne reçoit donc rien, alors qu'un ping sur 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# Retire le speaker de ce node
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 AUTRE MAC — la même IP
Les quelques secondes de coupure, c'est le ré-ARP : attendu, pas un incident.
Jamais MetalLB et l'annonce L2 de Cilium. Deux speakers répondant à l'ARP pour
192.168.56.200 font osciller le cache ARP de l'hôte entre deux MAC : le point d'entrée
marche « une fois sur deux », et aucun log ne le dit. Le script refuse sur deux signaux
indépendants (les objets cilium.io vivants, et CNI qui résout vers cilium) — ne le
contourne pas, la bonne réaction est de choisir un CNI.
interfaces: est une liste de noms bruts, pas de regex. La policy 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 l'emporte sur n'importe quel pool. Tant que
../envoy-gateway/Envoy-Proxy.yml épingle
loadBalancerClass: io.cilium/l2-announcer, MetalLB ignore le Service — une
loadBalancerClass signifie « seul ce contrôleur a le droit de le servir ». platform-up.sh
retire la ligne quand CNI != cilium ; appliquer ce manifeste à la main, non.
Les labels 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é — tu obtiens donc une adresse qui a l'air posée
et qui ne répond à rien. Recoupe toujours get svc avec kubectl -n metallb-system get pods.
WORKERS=0 ne laisse personne pour annoncer. Les nodeSelectors écartent les control
planes, volontairement. Sur un lab sans worker, retire-les de metallb-l2.yml — sinon le pool
est valide et personne ne l'annonce.
Un node par IP, ce n'est pas de la répartition de charge. Comme
CiliumL2AnnouncementPolicy, le mode L2 élit un seul speaker par adresse : tout le trafic du
VIP entre par ce node, puis kube-proxy le répartit sur les endpoints. Une bascule prend
quelques secondes (le temps du ré-ARP).
kube-proxy en mode IPVS exige strictARP: true. Aucun des deux labs n'utilise IPVS
(tous deux sont en mode iptables), 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 du VIP.
Changer de CNI n'est pas une bascule à chaud. Passer de Cilium à Calico impose de
reconstruire le cluster (./kubeadm/cluster-reset.sh, ou vagrant destroy), pas un
helm uninstall. MetalLB n'a de sens que sur un cluster bootstrapé avec le bon CNI dès le
départ.
⚠️ Tous les Services LoadBalancer retombent en EXTERNAL-IP <pending> et toutes les UI
deviennent injoignables — la Gateway comprise. 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/....
🎓 Two paths, same result: the all-in-one script below, or the "Guided
walkthrough" section further down — the same commands, one at a time, for training.
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 — nothing to change 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 — mind 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/....
🎓 Deux chemins, même résultat : le script tout-en-un ci-dessous, ou la section
« Pas à pas guidé » plus bas — les mêmes commandes, une par une, pour une formation.
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 — attention au 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 — 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.
🎓 Two paths, same result: the all-in-one script below, or the "Guided
walkthrough" section further down — the same commands, one at a time, for training.
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 — 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 — 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 — 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.
🎓 Deux chemins, même résultat : le script tout-en-un ci-dessous, ou la section
« Pas à pas guidé » plus bas — les mêmes commandes, une par une, pour une formation.
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 — 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.
🎓 Two paths, same result: the all-in-one script below, or the "Guided
walkthrough" section further down — the same commands, one at a time, for training.
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.
🎓 Deux chemins, même résultat : le script tout-en-un ci-dessous, ou la section
« Pas à pas guidé » plus bas — les mêmes commandes, une par une, pour une formation.
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.
🎓 Two paths, same result: the all-in-one script below, or the "Guided
walkthrough" section further down — the same commands, one at a time, for training.
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.
🎓 Deux chemins, même résultat : le script tout-en-un ci-dessous, ou la section
« Pas à pas guidé » plus bas — les mêmes commandes, une par une, pour une formation.
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).
🎓 Two paths, same result: the all-in-one script below, or the "Guided
walkthrough" section further down — the same commands, one at a time, for training.
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).
🎓 Deux chemins, même résultat : le script tout-en-un ci-dessous, ou la section
« Pas à pas guidé » plus bas — les mêmes commandes, une par une, pour une formation.
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").
🎓 Two paths, same result: the all-in-one script below, or the "Guided
walkthrough" section further down — the same commands, one at a time, for training.
⚠️ 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 »).
🎓 Deux chemins, même résultat : le script tout-en-un ci-dessous, ou la section
« Pas à pas guidé » plus bas — les mêmes commandes, une par une, pour une formation.
⚠️ 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.
🎓 Two paths, same result: the all-in-one script below, or the "Guided
walkthrough" section further down — the same commands, one at a time, for training.
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.
🎓 Deux chemins, même résultat : le script tout-en-un ci-dessous, ou la section
« Pas à pas guidé » plus bas — les mêmes commandes, une par une, pour une formation.
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.
🌐 Unlike most add-ons here, nothing in this directory carries the neutral domain: Velero
exposes no UI and reaches MinIO through the in-cluster Service. There is nothing to
substitute, and kubectl apply -f velero/schedule.yaml by hand is exactly what the script
does.
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.
The distinction above is not academic: it 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.
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
ℹ️ No LoadBalancer IP, no Gateway, no DNS record. Velero talks to
minio.<ns>.svc.cluster.local:9000, a ClusterIP: this add-on 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.
🎓 Two paths, same result: the all-in-one script below, or the "Guided walkthrough"
section further down — the same commands, one at a time, for training.
Idempotent: helm upgrade --install + kubectl apply, and the MinIO user keeps its existing
access key (re-minting it on every run would silently 403 every upload).
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
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 4 steps, the same chart, 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
4
4
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 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 + 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:
🧪 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 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-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
⛵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.
🌐 Contrairement à la plupart des addons d'ici, aucun fichier de ce dossier ne porte le
domaine neutre : Velero n'expose pas d'UI et joint MinIO par le Service interne au cluster.
Il n'y a rien à substituer, et un kubectl apply -f velero/schedule.yaml à la main fait
exactement ce que fait le script.
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.
La distinction ci-dessus n'est pas théorique : c'est pour ça qu'il y a deux schedules et non
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.
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
ℹ️ Pas d'IP LoadBalancer, pas de Gateway, pas d'enregistrement DNS. Velero parle à
minio.<ns>.svc.cluster.local:9000, une ClusterIP : cet addon se comporte à l'identique que
les IP LoadBalancer du lab viennent de l'annonceur L2 de Cilium ou de
../metallb/, et il continue de fonctionner quand aucun des deux
n'est installé.
🎓 Deux chemins, même résultat : le script tout-en-un ci-dessous, ou la section
« Pas à pas guidé » plus bas — les mêmes commandes, une par une, pour une formation.
Idempotent : helm upgrade --install + kubectl apply, et 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).
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
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 4 étapes, le même chart, 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
4
4
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#
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 + 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
🧪 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.
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.
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-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
🐘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).
🎓 Two paths, same result: the all-in-one script below, or the "Guided
walkthrough" section further down — the same commands, one at a time, for training.
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).
🎓 Deux chemins, même résultat : le script tout-en-un ci-dessous, ou la section
« Pas à pas guidé » plus bas — les mêmes commandes, une par une, pour une formation.
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.
🎓 Two paths, same result: the all-in-one script below, or the "Guided walkthrough"
section further down — the same commands, one at a time, for training.
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 — a genuinely 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.
🎓 Deux chemins, même résultat : le script tout-en-un ci-dessous, ou la section
« Pas à pas guidé » plus bas — les mêmes commandes, une par une, pour une formation.
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 vraiment 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.
🎓 Two paths, same result: the all-in-one script below, or the "Guided walkthrough"
section further down — the same commands, one at a time, for training.
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.
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:
# 1. merge the apiServer block of dex/apiserver-oidc.kubeadm.yaml into ClusterConfiguration
kubectl-nkube-systemeditconfigmapkubeadm-config
# 2. on EACH control plane, regenerate the static pod manifest from that ConfigMap
vagrantsshk8s-cp1-c' 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
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.
🎓 Deux chemins, même résultat : le script tout-en-un ci-dessous, ou la section
« Pas à pas guidé » plus bas — les mêmes commandes, une par une, pour une formation.
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 — c'est 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.
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é :
# 1. fusionner le bloc apiServer de dex/apiserver-oidc.kubeadm.yaml dans ClusterConfiguration
kubectl-nkube-systemeditconfigmapkubeadm-config
# 2. sur CHAQUE control plane, régénérer le manifeste du pod statique depuis cette ConfigMap
vagrantsshk8s-cp1-c' 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
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
🎓 Two paths, same result: the all-in-one script below, or the "Guided
walkthrough" section further down — the same commands, one at a time, for training.
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 — 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 — mind 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
🎓 Deux chemins, même résultat : le script tout-en-un ci-dessous, ou la section
« Pas à pas guidé » plus bas — les mêmes commandes, une par une, pour une formation.
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é — attention au 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.
🎓 Two paths, same result: the all-in-one script below, or the "Guided
walkthrough" section further down — the same commands, one at a time, for training.
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.
🎓 Deux chemins, même résultat : le script tout-en-un ci-dessous, ou la section
« Pas à pas guidé » plus bas — les mêmes commandes, une par une, pour une formation.
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 in order 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.
🎓 Two paths, same result: the all-in-one script below, or the "Guided
walkthrough" section further down — the same commands, one at a time, for training.
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.
🎓 Deux chemins, même résultat : le script tout-en-un ci-dessous, ou la section
« Pas à pas guidé » plus bas — les mêmes commandes, une par une, pour une formation.
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).
🎓 Two paths, same result: the all-in-one script below, or the "Guided
walkthrough" section further down — the same commands, one at a time, for training.
The directory holds two sets of scripts that do not serve the same purpose. Do not mix them:
they use different mounts, namespaces and role names.
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).
🎓 Deux chemins, même résultat : le script tout-en-un ci-dessous, ou la section
« Pas à pas guidé » plus bas — les mêmes commandes, une par une, pour une formation.
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.
🎓 Two paths, same result: the all-in-one script below, or the "Guided
walkthrough" section further down — the same commands, one at a time, for training.
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
either replaced by Cilium (eBPF), 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)"
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: KUBE_PROXY_REPLACEMENT=true (the lab.env default) runs kubeadm init --skip-phases=addon/kube-proxy and Cilium handles Services in eBPF. 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.
🎓 Deux chemins, même résultat : le script tout-en-un ci-dessous, ou la section
« Pas à pas guidé » plus bas — les mêmes commandes, une par une, pour une formation.
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é
soit remplacé par Cilium (eBPF), 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 et kube-proxy coupés (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 : KUBE_PROXY_REPLACEMENT=true (défaut de lab.env) lance kubeadm init --skip-phases=addon/kube-proxy et Cilium prend les Services en eBPF. 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é :
🩺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).
🎓 Two paths, same result: the all-in-one script below, or the "Guided
walkthrough" section further down — the same commands, one at a time, for training.
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).
🎓 Deux chemins, même résultat : le script tout-en-un ci-dessous, ou la section
« Pas à pas guidé » plus bas — les mêmes commandes, une par une, pour une formation.
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 — that is the tool's job.
🎓 Two paths, same result: the all-in-one script below, or the "Guided
walkthrough" section further down — the same commands, one at a time, for training.
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 — 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 becomes load-bearing.
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 — c'est le métier de l'outil.
🎓 Deux chemins, même résultat : le script tout-en-un ci-dessous, ou la section
« Pas à pas guidé » plus bas — les mêmes commandes, une par une, pour une formation.
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 — c'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 devient structurant.
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.
🎓 Two paths, same result: the all-in-one script below, or the "Guided
walkthrough" section further down — the same commands, one at a time, for training.
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.
🎓 Deux chemins, même résultat : le script tout-en-un ci-dessous, ou la section
« Pas à pas guidé » plus bas — les mêmes commandes, une par une, pour une formation.
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.
🎓 Two paths, same result: the all-in-one script below, or the "Guided
walkthrough" section further down — the same commands, one at a time, for training.
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.
🎓 Deux chemins, même résultat : le script tout-en-un ci-dessous, ou la section
« Pas à pas guidé » plus bas — les mêmes commandes, une par une, pour une formation.
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.
🎓 Two paths, same result: the all-in-one script below, or the "Guided
walkthrough" section further down — the same commands, one at a time, for training.
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.
🎓 Deux chemins, même résultat : le script tout-en-un ci-dessous, ou la section
« Pas à pas guidé » plus bas — les mêmes commandes, une par une, pour une formation.
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/).
🎓 Two paths, same result: the all-in-one script below, or the "Guided
walkthrough" section further down — the same commands, one at a time, for training.
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/).
🎓 Deux chemins, même résultat : le script tout-en-un ci-dessous, ou la section
« Pas à pas guidé » plus bas — les mêmes commandes, une par une, pour une formation.
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.