Déployer Signalements de bout en bout
Pourquoi
Les leçons précédentes ont présenté chaque objet isolément : un pod pour comprendre le conteneur, un Deployment pour les répliques, un Service pour l'adresse stable, un Secret pour la configuration, une PVC pour le disque, un Job pour la migration. Une vraie application est un assemblage de ces objets, et la plupart des pannes ne viennent pas d'un objet mal écrit, mais d'objets qui s'accordent mal : un Service dont le sélecteur ne correspond plus aux étiquettes du Deployment, une migration qui passe après l'application, une sonde qui interroge le mauvais port, un arrêt qui coupe des requêtes en cours parce que personne n'a pensé à l'ordre des opérations.
Cette leçon déploie Signalements de bout en bout, comme on le ferait pour un client : une base, une migration, une API à deux répliques, une entrée HTTP. Chaque choix est justifié, et chaque manifeste a été validé contre la référence de l'API de Kubernetes 1.36 et le schéma de Gateway API 1.6.2. On termine en rassemblant le tout dans une kustomization, une seule commande pour tout appliquer, première étape vers l'automatisation que proposent Helm et Argo CD.
Sans cette discipline, on obtient ce que beaucoup d'équipes connaissent : une application qui « marche » après trois kubectl apply dans le bon ordre, connus d'une seule personne, et qui tombe à la première maintenance de nœud.
Les concepts
Ce que l'on va construire
flowchart LR
U["Navigateur<br/>signalements.formation.test"] --> LB["Service LoadBalancer<br/>de Traefik"]
subgraph T["namespace traefik"]
LB --> TR["Traefik<br/>(contrôleur Gateway API)"]
end
subgraph S["namespace signalements (profil restricted)"]
GW["Gateway<br/>signalements :8000"] -.-> TR
HR["HTTPRoute"] --> GW
HR --> SVC["Service signalements<br/>port 80"]
SVC --> P1["pod api"]
SVC --> P2["pod api"]
P1 --> DB["Service headless postgresql<br/>StatefulSet postgresql-0 + PVC"]
P2 --> DB
J["Job migration-1-2-0"] --> DB
end
Huit objets dans le namespace de l'application, plus le contrôleur d'entrée et ses définitions de ressources, installés une fois pour tout le cluster.
Le namespace, première ligne de sécurité
Un namespace peut porter des étiquettes d'admission de sécurité des pods (Pod Security Admission). Avec pod-security.kubernetes.io/enforce: restricted, l'API refuse tout pod qui ne respecte pas le profil restricted des Pod Security Standards, le plus strict des trois profils définis par Kubernetes (privileged, baseline, restricted). Concrètement, chaque conteneur doit :
- tourner sous un utilisateur non
root(runAsNonRoot: true) ; - interdire l'élévation de privilèges (
allowPrivilegeEscalation: false) ; - abandonner toutes les capacités Linux (
capabilities.drop: ["ALL"]) ; - utiliser un profil seccomp (
seccompProfile.type: RuntimeDefault) ; - n'utiliser que des types de volumes sans accès à l'hôte (pas de
hostPath).
Poser cette étiquette avant d'écrire les manifestes oblige à les écrire correctement dès le départ. La poser après coup, sur un namespace existant, révèle en général une liste de pods qui ne la respectent pas. L'étiquette enforce-version fige la version du profil, pour qu'une montée de version de Kubernetes ne change pas les règles à votre insu.
L'ordre : base, migration, application
L'application a besoin d'une base qui existe et d'un schéma à jour. Kubernetes n'ordonne rien de lui-même : un kubectl apply de tous les fichiers crée tous les objets presque en même temps. Deux mécanismes absorbent ce désordre :
- Les réessais. Le Job de migration réessaie avec un délai croissant tant que la base ne répond pas (leçon 10) ; les pods de l'API ne deviennent prêts que lorsque
/santerépond, c'est-à-dire quand la base est joignable. - L'ordre explicite. En formation, on applique les étapes une à une, et l'on attend chacune (
kubectl wait). En production, l'outil de déploiement s'en charge : vagues et hooks d'Argo CD, hooks de Helm.
Il reste un trou : une API prête parce que la base répond, mais avant que la migration ait ajouté la colonne qu'elle attend. C'est pourquoi la migration se lance avant de déployer la nouvelle version, et pourquoi une migration doit rester compatible avec la version précédente, qui tourne encore pendant la mise à jour progressive.
Arrêter sans couper de requêtes
Quand un pod de l'API est supprimé (mise à jour, maintenance de nœud), deux choses se passent en parallèle, d'après la documentation de la terminaison des pods : le kubelet lance l'arrêt du conteneur (le hook preStop, puis le signal SIGTERM), et le plan de contrôle retire le pod des EndpointSlices du Service. Le retrait des points de terminaison met un peu de temps à se propager jusqu'aux composants qui routent le trafic (kube-proxy sur chaque nœud, Traefik). Pendant ce court intervalle, des requêtes peuvent encore arriver sur un pod qui s'arrête.
La parade classique est d'attendre quelques secondes avant de commencer l'arrêt : un preStop qui dort. Depuis Kubernetes 1.34, l'action sleep est native dans les hooks de cycle de vie, sans avoir besoin d'une commande sleep dans l'image (une image distroless n'en a pas). Ensuite, Gunicorn reçoit SIGTERM, cesse d'accepter des connexions et laisse ses workers finir les requêtes en cours pendant graceful_timeout, 30 secondes par défaut. Le délai total accordé par Kubernetes, terminationGracePeriodSeconds, doit couvrir les deux : la documentation précise que le preStop s'exécute dans ce délai.
Répartir et protéger les répliques
Deux répliques sur le même nœud ne protègent de rien si ce nœud tombe. Une contrainte de répartition topologique (topologySpreadConstraints) demande au planificateur d'équilibrer les pods entre les valeurs d'une étiquette de nœud : kubernetes.io/hostname pour répartir entre nœuds, topology.kubernetes.io/zone pour répartir entre zones de disponibilité, sur un cluster qui en a plusieurs. Avec whenUnsatisfiable: ScheduleAnyway, c'est une préférence ; avec DoNotSchedule, une obligation, qui laisse un pod en Pending plutôt que de le placer au mauvais endroit.
Un PodDisruptionBudget (PDB) protège contre les perturbations volontaires : vidage d'un nœud pour maintenance (kubectl drain), mise à jour des nœuds par le fournisseur, réduction automatique du cluster. Avec minAvailable: 1, l'API d'éviction refuse de retirer un pod si cela ferait tomber l'application sous un pod disponible : les nœuds se vident un pod à la fois, en attendant que le remplaçant soit prêt. Un PDB ne protège pas d'une panne : un nœud qui s'éteint ne demande la permission à personne.
Exposer : Ingress, Gateway API, et la fin d'ingress-nginx
Le Service de l'application n'est joignable que depuis le cluster. Pour recevoir le trafic d'Internet, il faut un composant d'entrée qui termine HTTP (et TLS), choisit le bon Service selon le nom d'hôte et le chemin, et se configure par l'API de Kubernetes. Deux API existent.
Ingress est l'API historique, stable depuis Kubernetes 1.19. La documentation officielle est désormais explicite : « The Kubernetes project recommends using Gateway instead of Ingress », et « The Ingress API has been frozen ». Elle reste disponible, sans projet de retrait, mais n'évoluera plus. Ses limites sont connues : une seule ressource mêle ce qui relève de l'équipe plateforme (adresse, certificats) et de l'équipe applicative (routes), et tout ce qui dépasse le routage simple passe par des annotations propres à chaque contrôleur, donc non portables.
Le contrôleur Ingress le plus répandu, ingress-nginx, n'est plus maintenu. L'annonce du projet Kubernetes du 11 novembre 2025 fixait la fin de la maintenance en mars 2026, après quoi il n'y aurait « no further releases, no bugfixes, and no updates to resolve any security vulnerabilities ». Au 5 octobre 2026, son dépôt est archivé, et ses dernières versions publiées datent du 19 mars 2026. Les installations existantes continuent de fonctionner, mais toute faille découverte restera ouverte : un composant exposé à Internet ne peut pas rester dans cet état.
Gateway API est l'API qui lui succède, développée par le groupe réseau de Kubernetes et distribuée sous forme de définitions de ressources (CRD) à installer. Sa version 1.6.2 date du 3 septembre 2026. Elle découpe la configuration selon les rôles :
| Ressource | Qui la gère | Ce qu'elle décrit |
|---|---|---|
| GatewayClass | le fournisseur ou l'équipe plateforme | quel contrôleur implémente les Gateways (Traefik, Envoy Gateway, Cilium...) |
| Gateway | l'équipe plateforme | un point d'entrée : ports, protocoles, noms d'hôte, certificats, quels namespaces peuvent s'y rattacher |
| HTTPRoute | l'équipe applicative | les règles : noms d'hôte, chemins, en-têtes, et les Services de destination |
Ce que faisaient les annotations d'Ingress (redirections, réécritures, répartition pondérée entre deux versions, correspondance d'en-têtes) est dans la spécification, donc portable d'un contrôleur à l'autre. Par défaut, une Gateway n'accepte que les routes de son propre namespace : le rattachement d'une route d'un autre namespace doit être autorisé explicitement (allowedRoutes).
Lyneko utilise Traefik comme contrôleur d'entrée sur son cluster Kapsule. Traefik implémente Gateway API : sa documentation annonce la prise en charge du canal standard de la version 1.6.2 (HTTPRoute, GRPCRoute, TLSRoute, BackendTLSPolicy), et son contrôleur s'identifie par traefik.io/gateway-controller. Une contrainte propre à Traefik : les ports des listeners d'une Gateway doivent correspondre aux points d'entrée (entryPoints) configurés dans Traefik, faute de quoi la Gateway est refusée avec une erreur dans son statut. Le chart Helm officiel définit le point d'entrée web sur le port 8000 dans le pod, exposé sur le port 80 par son Service.
En pratique
Les manifestes de cette section ont été validés localement contre la référence de l'API 1.36 et le schéma de Gateway API 1.6.2, et la kustomization a été rendue avec kubectl kustomize. Les commandes qui demandent un cluster sont expliquées, et ce que vous devez observer est décrit.
On suppose le cluster kind formation de la leçon 3 (un plan de contrôle, deux nœuds) et un répertoire de travail signalements-k8s/, avec un fichier par objet.
1. Le contrôleur d'entrée
Installez d'abord les définitions de ressources de Gateway API, canal standard, à la version que Traefik prend en charge :
$ kubectl apply --server-side \
-f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.6.2/standard-install.yaml
$ kubectl get crd | grep gateway.networking.k8s.io
L'option --server-side est celle du guide de démarrage de Gateway API : ces définitions sont volumineuses, et l'application côté serveur évite la limite de taille de l'annotation qu'utilise kubectl apply classique. La seconde commande doit lister au moins gatewayclasses, gateways, httproutes, grpcroutes et referencegrants.
Puis Traefik, avec son chart Helm :
$ helm repo add traefik https://traefik.github.io/charts
$ helm install traefik traefik/traefik --version 41.6.1 \
--namespace traefik --create-namespace \
--set providers.kubernetesGateway.enabled=true \
--set gateway.enabled=false
$ kubectl get gatewayclass
$ kubectl get service traefik -n traefik
providers.kubernetesGateway.enabled=trueactive le fournisseur Gateway API de Traefik ; le chart crée alors une GatewayClass nomméetraefik.gateway.enabled=falseempêche le chart de créer sa propre Gateway par défaut (traefik-gateway) : nous écrirons la nôtre, dans le namespace de l'application.kubectl get gatewayclassdoit montrer la classetraefik, avec le contrôleurtraefik.io/gateway-controlleret la colonneACCEPTEDàTrue.- Le Service
traefikest de typeLoadBalancer, la valeur par défaut du chart. Sur kind, il reste avec une adresse externe<pending>tant qu'aucun fournisseur de répartiteurs n'est présent : c'est le rôle de cloud-provider-kind, un binaire que la documentation de kind recommande de lancer sur le poste, à côté du cluster. Il crée pour chaque ServiceLoadBalancerun conteneur qui sert de répartiteur, et lui attribue une adresse joignable depuis le poste. Lancez-le dans un autre terminal (sa documentation précise qu'il a besoin de droits pour ouvrir des ports et parler au moteur de conteneurs), puis relancez la dernière commande : la colonneEXTERNAL-IPse remplit.
Sur Kapsule, rien de tout cela n'est nécessaire : le Service LoadBalancer de Traefik crée un répartiteur de charge Scaleway, facturé comme tel. Le cours Kapsule : Kubernetes managé chez Scaleway détaille les annotations qui le règlent.
2. Le namespace et les Secrets
# namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
name: signalements
labels:
# Admission de sécurité des pods : refuser tout pod qui ne respecte pas le profil « restricted »
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/enforce-version: v1.36Les Secrets ne vont pas dans un fichier du dépôt. En formation, créez-les à la main, avec un mot de passe aléatoire qui ne passe ni par l'écran ni par l'historique :
$ kubectl apply -f namespace.yaml
$ MDP=$(openssl rand -hex 24)
$ kubectl create secret generic postgresql -n signalements \
--from-literal=mot-de-passe="$MDP"
$ kubectl create secret generic signalements -n signalements \
--from-literal=DATABASE_URL="postgresql://signalements:$MDP@postgresql:5432/signalements"
$ unset MDP
DATABASE_URL désigne la base par le nom du Service headless postgresql, dans le même namespace. Pour voir ce que contient réellement un Secret, la même commande avec --dry-run=client -o yaml affiche l'objet sans le créer. Avec une valeur d'exemple, sur ce poste :
$ kubectl create secret generic postgresql -n signalements \
--from-literal=mot-de-passe=exemple-a-ne-pas-utiliser --dry-run=client -o yaml
apiVersion: v1
data:
mot-de-passe: ZXhlbXBsZS1hLW5lLXBhcy11dGlsaXNlcg==
kind: Secret
metadata:
name: postgresql
namespace: signalements
La valeur est encodée en base64, pas chiffrée : echo ZXhlbXBsZS1hLW5lLXBhcy11dGlsaXNlcg== | base64 -d la rend lisible. C'est pourquoi un manifeste de Secret ne se commite jamais tel quel (leçon 7). En production, Lyneko fait créer ces Secrets dans le cluster par External Secrets Operator, à partir de Scaleway Secret Manager (GitOps avec Argo CD, leçon 8).
3. La base de formation
# postgresql.yaml
# PostgreSQL de formation : une seule réplique, un volume persistant.
# En production : la base managée de votre fournisseur, ou un opérateur.
apiVersion: v1
kind: Service
metadata:
name: postgresql
labels:
app.kubernetes.io/name: postgresql
spec:
clusterIP: None # Service headless : un nom DNS par pod
selector:
app.kubernetes.io/name: postgresql
ports:
- name: postgresql
port: 5432
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: postgresql
spec:
serviceName: postgresql
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: postgresql
template:
metadata:
labels:
app.kubernetes.io/name: postgresql
spec:
securityContext:
runAsNonRoot: true
runAsUser: 999 # l'utilisateur « postgres » de l'image officielle
runAsGroup: 999
fsGroup: 999 # le volume appartient au groupe 999, donc inscriptible
seccompProfile:
type: RuntimeDefault
containers:
- name: postgresql
image: postgres:18.6
ports:
- name: postgresql
containerPort: 5432
env:
- name: POSTGRES_USER
value: signalements
- name: POSTGRES_DB
value: signalements
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: postgresql
key: mot-de-passe
readinessProbe:
exec:
command: ["pg_isready", "-U", "signalements", "-d", "signalements"]
periodSeconds: 5
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
memory: 512Mi
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
volumeMounts:
- name: donnees
mountPath: /var/lib/postgresql # PostgreSQL 18 : PGDATA=/var/lib/postgresql/18/docker
volumeClaimTemplates:
- metadata:
name: donnees
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 1Gi- Le profil
restricteds'applique aussi à la base. L'image officielle de PostgreSQL démarre d'ordinaire enrootpour préparer ses répertoires, puis passe à l'utilisateurpostgres. Sa documentation indique qu'elle accepte de tourner directement sous un utilisateur arbitraire, à condition qu'il existe dans/etc/passwdde l'image pourinitdb: l'utilisateurpostgres(UID 999) convient.fsGroup: 999rend le volume inscriptible par ce groupe, pour les types de volumes qui prennent en charge ce changement de propriétaire. - Le point de montage est celui de PostgreSQL 18. Depuis la version 18, l'image place les données dans
/var/lib/postgresql/18/dockeret déclare le volume/var/lib/postgresql. Monter la PVC sur l'ancien chemin (/var/lib/postgresql/data) laisserait les données hors du volume persistant. - La sonde de disponibilité utilise
pg_isready: le pod n'est prêt que lorsque PostgreSQL accepte des connexions, ce qui ne se produit qu'après l'initialisation de la base au premier démarrage. - Pas de limite de processeur, une limite de mémoire. C'est le réglage discuté à la leçon 8 : la limite de processeur ralentit sans protéger, la limite de mémoire évite qu'un dépassement n'affecte le nœud.
$ kubectl apply -n signalements -f postgresql.yaml
$ kubectl rollout status statefulset/postgresql -n signalements --timeout=3m
rollout status attend que le StatefulSet ait toutes ses répliques prêtes. Au premier démarrage, l'initialisation de la base prend quelques secondes.
4. La migration
Appliquez le Job de la leçon 10 (migration.yaml, avec le contexte de sécurité complet) et attendez qu'il se termine :
$ kubectl apply -n signalements -f migration.yaml
$ kubectl wait --for=condition=complete job/migration-1-2-0 -n signalements --timeout=5m
$ kubectl logs job/migration-1-2-0 -n signalements
Les journaux doivent se terminer par migration appliquée. Si kubectl wait expire, la leçon 12 donne la marche à suivre.
5. L'API
# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: signalements
labels:
app.kubernetes.io/name: signalements
app.kubernetes.io/component: api
spec:
replicas: 2
revisionHistoryLimit: 5
selector:
matchLabels:
app.kubernetes.io/name: signalements
app.kubernetes.io/component: api
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
template:
metadata:
labels:
app.kubernetes.io/name: signalements
app.kubernetes.io/component: api
spec:
terminationGracePeriodSeconds: 40
securityContext:
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app.kubernetes.io/name: signalements
app.kubernetes.io/component: api
containers:
- name: api
image: ghcr.io/lyneko-formation/signalements:1.2.0
ports:
- name: http
containerPort: 8000
env:
- name: APP_VERSION
value: "1.2.0"
envFrom:
- secretRef:
name: signalements
startupProbe:
httpGet:
path: /sante
port: http
periodSeconds: 2
failureThreshold: 30
readinessProbe:
httpGet:
path: /sante
port: http
periodSeconds: 5
failureThreshold: 2
livenessProbe:
tcpSocket:
port: http
periodSeconds: 10
failureThreshold: 3
lifecycle:
preStop:
sleep:
seconds: 5
resources:
requests:
cpu: 100m
memory: 192Mi
limits:
memory: 256Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir:
medium: Memory
sizeLimit: 16MiChaque bloc répond à une question des leçons précédentes :
- Les étiquettes suivent les conventions recommandées par Kubernetes (
app.kubernetes.io/name,app.kubernetes.io/component). Le sélecteur en utilise deux : la migration porte aussiapp.kubernetes.io/name: signalements, et un sélecteur sur ce seul nom inclurait ses pods dans le Service. maxUnavailable: 0,maxSurge: 1: pendant une mise à jour, un pod neuf est créé et doit être prêt avant qu'un ancien soit retiré. Avec deux répliques, la capacité ne descend jamais sous deux pods prêts (leçon 5).- Trois sondes, trois rôles (leçon 4). La sonde de démarrage laisse jusqu'à une minute au démarrage (30 × 2 s). La sonde de disponibilité interroge
/sante, qui teste la base : un pod qui a perdu la base sort du Service, sans être redémarré. La sonde de vie se contente d'un test TCP sur le port : elle ne doit pas dépendre de la base, sinon une panne de PostgreSQL ferait redémarrer en boucle toutes les répliques de l'API, ce qui n'arrangerait rien. - L'arrêt gracieux : 5 secondes de
preStop(le temps que le pod disparaisse des points de terminaison), puis jusqu'à 30 secondes pour Gunicorn (graceful_timeout), soit 35 secondes, d'où un délai total de 40. - La répartition entre nœuds, en préférence (
ScheduleAnyway) : sur un cluster d'un seul nœud de travail, les deux pods démarrent quand même. Sur Kapsule avec plusieurs zones, ajoutez une seconde contrainte surtopology.kubernetes.io/zone. - Les ressources : une demande qui reflète la consommation observée, une limite de mémoire, pas de limite de processeur.
- Le contexte de sécurité du profil
restricted, plusreadOnlyRootFilesystem: true: un attaquant qui prendrait la main sur le processus ne pourrait pas modifier le code ni déposer d'outil sur le système de fichiers de l'image. L'image doit déclarer un utilisateur numérique (c'est le cas de celle du cours Construire des images de conteneurs) : avec un nom d'utilisateur, le kubelet ne peut pas vérifierrunAsNonRootet refuse de démarrer le conteneur. - Le volume
/tmpen mémoire : Gunicorn écrit dans le répertoire temporaire un fichier de pulsation par worker. Avec un système de fichiers en lecture seule, il lui faut un répertoire inscriptible ; sa documentation signale en outre que ce mécanisme peut bloquer un worker si le répertoire est sur disque. UnemptyDiren mémoire, plafonné à 16 Mio, répond aux deux.
6. Le Service et le budget de perturbation
# service.yaml
apiVersion: v1
kind: Service
metadata:
name: signalements
labels:
app.kubernetes.io/name: signalements
spec:
selector:
app.kubernetes.io/name: signalements
app.kubernetes.io/component: api
ports:
- name: http
port: 80
targetPort: http# pdb.yaml
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: signalements
spec:
minAvailable: 1
selector:
matchLabels:
app.kubernetes.io/name: signalements
app.kubernetes.io/component: apitargetPort: http désigne le port par son nom : si le port du conteneur change un jour, le Service suit. Appliquez, puis vérifiez que le Service a bien trouvé ses pods :
$ kubectl apply -n signalements -f deployment.yaml -f service.yaml -f pdb.yaml
$ kubectl rollout status deployment/signalements -n signalements
$ kubectl get endpointslices -n signalements -l kubernetes.io/service-name=signalements
$ kubectl get pdb signalements -n signalements
La liste des EndpointSlices doit montrer deux adresses, celles des deux pods. Le PDB affiche MIN AVAILABLE 1 et ALLOWED DISRUPTIONS 1 : avec deux pods prêts, un seul peut être évincé à la fois.
7. La Gateway et la route
# gateway.yaml
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: signalements
spec:
gatewayClassName: traefik
listeners:
- name: http
protocol: HTTP
port: 8000 # doit correspondre au point d'entrée « web » de Traefik
hostname: signalements.formation.test
allowedRoutes:
namespaces:
from: Same
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: signalements
spec:
parentRefs:
- name: signalements
sectionName: http
hostnames:
- signalements.formation.test
rules:
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: signalements
port: 80- Le nom d'hôte utilise le domaine
.test, réservé par la RFC 6761 aux essais : il ne sera jamais attribué sur Internet. port: 8000est le point d'entréewebde Traefik, pas le port public : le Service de Traefik expose ce point d'entrée sur le port 80.allowedRoutes.namespaces.from: Samereprend explicitement la valeur par défaut : seules les routes du namespacesignalementspeuvent se rattacher à cette Gateway.- La route désigne la Gateway et son listener (
sectionName), et envoie tout le trafic vers le port 80 du Service.
$ kubectl apply -n signalements -f gateway.yaml
$ kubectl get gateway,httproute -n signalements
$ IP=$(kubectl get service traefik -n traefik -o jsonpath='{.status.loadBalancer.ingress[0].ip}')
$ curl -s --resolve signalements.formation.test:80:"$IP" http://signalements.formation.test/sante
kubectl get gateway doit afficher la classe traefik et PROGRAMMED True ; s'il reste à False, kubectl describe gateway signalements -n signalements donne la raison dans les conditions du statut (un port qui ne correspond à aucun point d'entrée, par exemple). L'option --resolve de curl associe le nom à l'adresse du répartiteur sans toucher au DNS ni à /etc/hosts. La réponse est celle de /sante.
Sur Kapsule, la commande est la même ; l'adresse est celle du répartiteur Scaleway, et un vrai nom de domaine pointe dessus. Le passage en HTTPS (listener HTTPS avec certificateRefs, certificats par cert-manager) est traité dans le cours Kubernetes : réseau et exposition.
8. Tout en une commande : la kustomization
Une fois chaque étape comprise, rassemblez les fichiers avec une kustomization minimale, que kubectl sait lire nativement :
# kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: signalements
resources:
- namespace.yaml
- postgresql.yaml
- migration.yaml
- deployment.yaml
- service.yaml
- pdb.yaml
- gateway.yamlLe champ namespace place tous les objets dans signalements, sans avoir à l'écrire dans chaque fichier. Pour voir ce qui serait appliqué, rendez la kustomization sans cluster. Sur ce poste, avec les fichiers de la leçon, les types d'objets sortent dans cet ordre :
$ kubectl kustomize . | grep '^kind:'
kind: Namespace
kind: Service
kind: Service
kind: Deployment
kind: StatefulSet
kind: PodDisruptionBudget
kind: Job
kind: Gateway
kind: HTTPRoute
Kustomize trie les objets par type (le namespace d'abord, les Services avant les charges de travail), pas dans l'ordre des fichiers. Il n'attend pas non plus la base avant la migration : kubectl apply -k . crée tout d'un coup, et ce sont les réessais du Job et les sondes de l'API qui absorbent le désordre. Les Secrets, eux, ne sont pas dans la kustomization : ils doivent exister avant.
$ kubectl apply -k .
$ kubectl get all,pvc,pdb,gateway,httproute -n signalements
Une seconde exécution de kubectl apply -k . ne change rien : chaque objet est déjà dans l'état décrit. C'est le même modèle déclaratif que dans le cours GitOps, et c'est ce répertoire qu'Argo CD pourrait surveiller. Le cours Kustomize présente les surcouches par environnement, et le cours Helm l'empaquetage paramétrable.
9. Mettre à jour et vérifier l'arrêt gracieux
Pour éprouver la mise à jour sans coupure, lancez dans un terminal une boucle de requêtes, et dans un autre une nouvelle version :
$ while true; do curl -s -o /dev/null -w '%{http_code}\n' \
--resolve signalements.formation.test:80:"$IP" http://signalements.formation.test/sante; sleep 0.2; done
$ kubectl set image deployment/signalements api=ghcr.io/lyneko-formation/signalements:1.2.1 -n signalements
$ kubectl rollout status deployment/signalements -n signalements
La boucle doit n'afficher que des 200 pendant toute la mise à jour. Retirez le preStop et recommencez : il est possible de voir passer quelques 502 ou 503, au moment où Traefik envoie encore une requête à un pod qui s'arrête. (Pour une version 1.2.1 qui n'existe pas dans votre registre, le pod neuf reste en ImagePullBackOff, et grâce à maxUnavailable: 0, les anciens continuent de servir : une autre bonne démonstration, que la leçon 12 analyse.)
Sous le capot
Comment la requête arrive au pod. Le navigateur joint l'adresse du répartiteur, qui transmet au Service traefik ; kube-proxy envoie la connexion à un pod de Traefik. Traefik a construit sa table de routage en surveillant l'API : la Gateway (quels ports, quels noms), la HTTPRoute (quelles règles), et les EndpointSlices du Service signalements. Il envoie donc la requête directement à l'adresse d'un pod de l'API, sans repasser par l'adresse virtuelle du Service. C'est pourquoi la sortie d'un pod des EndpointSlices, lors d'un arrêt, doit atteindre Traefik avant que le pod cesse d'écouter, et pourquoi le preStop existe.
Comment Traefik rend compte de son travail. Gateway API est conçue pour que l'état soit lisible dans l'API : le contrôleur écrit dans le statut de la GatewayClass (Accepted), de la Gateway (Accepted, Programmed, et l'adresse attribuée) et de la HTTPRoute (pour chaque parent, Accepted et ResolvedRefs). Une route mal rattachée ou un Service introuvable se lisent dans kubectl describe httproute, sans aller lire les journaux du contrôleur. C'est une amélioration nette sur Ingress, dont le statut ne contient guère que l'adresse.
Comment l'admission refuse un pod. L'admission de sécurité des pods est un contrôleur d'admission intégré à l'API. À la création d'un pod (ou d'un objet qui crée des pods), il compare la spécification au profil exigé par les étiquettes du namespace. Un Deployment non conforme est accepté (avec un avertissement si l'étiquette warn est posée), mais ses pods sont refusés : le ReplicaSet n'arrive pas à les créer, et l'erreur apparaît dans ses événements, pas dans ceux du Deployment. Le piège est classique, et la leçon 12 y revient.
Pièges courants
Un sélecteur trop large. Si le Service sélectionne seulement app.kubernetes.io/name: signalements, il inclut aussi les pods de la migration et de la purge, qui n'écoutent pas sur le port 8000. Pendant que ces pods tournent, une partie des requêtes échoue. Deux étiquettes, dont le composant.
Une sonde de vie qui dépend de la base. PostgreSQL redémarre, /sante échoue, la sonde de vie redémarre toutes les répliques de l'API, qui reviennent avant la base et redémarrent encore. La disponibilité se teste par /sante (la sonde de disponibilité) ; la vie, par le processus lui-même.
Le Service LoadBalancer qui reste <pending> sur kind. Rien ne fournit d'adresse externe sans cloud-provider-kind (ou sans une redirection de ports extraPortMappings vers un NodePort, l'autre méthode décrite par la documentation de kind).
Une Gateway sur un port qui n'est pas un point d'entrée de Traefik. Écrire port: 80 dans le listener, par réflexe, alors que le point d'entrée web écoute sur 8000 : la Gateway n'est pas programmée, et son statut l'explique.
Les définitions de Gateway API absentes. kubectl apply -k . échoue sur la Gateway avec une erreur qui indique qu'aucune ressource de ce type n'existe (no matches for kind "Gateway"). Les CRD s'installent avant, une fois par cluster.
Des pods refusés par le profil restricted. Le Deployment existe, le ReplicaSet aussi, mais aucun pod : kubectl describe replicaset montre FailedCreate et la liste des règles enfreintes. Un securityContext incomplet ou une image qui déclare un utilisateur par son nom en sont les causes habituelles.
Le volume de PostgreSQL au mauvais endroit. Avec l'image 18, monter /var/lib/postgresql/data au lieu de /var/lib/postgresql laisse les données hors du volume persistant.
Sécurité
- Le profil
restrictedpartout où c'est possible. Il ne coûte presque rien à une application web bien construite, et il ferme une grande partie des chemins d'évasion d'un conteneur compromis. - Les Secrets hors du dépôt, et hors de l'écran. Création à la main en formation, External Secrets en production ; dans les deux cas, ni manifeste de Secret dans Git, ni mot de passe tapé en clair.
- Un composant d'entrée maintenu. Le contrôleur d'entrée est exposé à Internet et lit des objets écrits par toutes les équipes : ses failles sont parmi les plus graves d'un cluster. Un contrôleur qui ne reçoit plus de correctifs, comme ingress-nginx depuis mars 2026, doit être remplacé, pas seulement surveillé.
- Le cloisonnement des routes.
allowedRouteslimite les namespaces qui peuvent se rattacher à une Gateway : sans cela, une équipe pourrait publier une route sur le nom d'hôte d'une autre. Dans un cluster partagé, l'équipe plateforme possède les Gateways, les équipes applicatives leurs routes. - Ce qui reste à faire. Le trafic entre pods n'est pas filtré : tout pod du cluster peut joindre PostgreSQL sur le port 5432. Les politiques réseau (NetworkPolicy) qui le restreignent, et le TLS jusqu'au pod, font partie des cours Kubernetes : réseau et exposition et Sécurité de Kubernetes.
En production
- La base est managée. Le StatefulSet de formation disparaît ;
DATABASE_URLpointe vers la base PostgreSQL managée, par un réseau privé (Le cloud : les fondamentaux, leçon 6). - Le déploiement passe par Git. Ce répertoire, transformé en chart Helm ou en kustomization par environnement, est surveillé par Argo CD : la migration devient un hook, les Secrets viennent d'External Secrets, et plus personne ne lance
kubectl applyà la main. C'est l'organisation de Lyneko sur son cluster Kapsulelyneko-apps. - La répartition suit les zones. Sur un cluster multizone,
topology.kubernetes.io/zoneen plus dekubernetes.io/hostname, et des nœuds dans au moins deux zones. - Le nombre de répliques suit la charge. Deux répliques fixes conviennent à Signalements ; une application plus chargée utilise un HorizontalPodAutoscaler, sujet du cours Kubernetes : workloads avancés, CRD et opérateurs.
- L'entrée est partagée et sécurisée. Une Gateway de plateforme par cluster, gérée par l'équipe plateforme, avec HTTPS, des certificats renouvelés automatiquement, et des routes déléguées aux équipes.
- La migration de l'existant vers Gateway API se prépare. Le projet Kubernetes publie l'outil ingress2gateway (version 1.0 annoncée le 20 mars 2026) pour convertir des objets Ingress, et un billet du 27 février 2026 recense les comportements propres à ingress-nginx qu'une migration doit reproduire.
Exercices
1. Relire un déploiement (niveau 100). Pour chacun de ces réglages du Deployment de la leçon, dites ce qui se passerait s'il était retiré : (a) maxUnavailable: 0 ; (b) le preStop ; (c) le volume /tmp ; (d) app.kubernetes.io/component dans le sélecteur du Service.
Solution
(a) La valeur par défaut est 25 %, arrondie à l'inférieur pour l'indisponibilité : avec deux répliques, cela donne 0, donc rien ne change ici. Avec quatre répliques, un pod pourrait être retiré avant que son remplaçant soit prêt. L'écrire rend l'intention explicite et indépendante du nombre de répliques. (b) Des requêtes peuvent arriver sur un pod qui a déjà commencé à s'arrêter, pendant que son retrait des EndpointSlices se propage : quelques erreurs 502 ou 503 à chaque mise à jour. (c) Avec readOnlyRootFilesystem: true, Gunicorn ne peut pas créer son fichier de pulsation dans le répertoire temporaire, et les workers ne démarrent pas : le pod ne devient jamais prêt. (d) Le Service sélectionnerait aussi les pods du Job de migration et du CronJob de purge, qui portent app.kubernetes.io/name: signalements : des requêtes partiraient vers des pods qui n'écoutent pas sur le port 8000.
2. Une deuxième application (niveau 200). Une autre équipe veut publier son application cartes sur cartes.formation.test, depuis son propre namespace cartes, à travers la même Gateway. Qu'est-ce qui l'en empêche aujourd'hui, et que changez-vous, sans lui donner le droit de publier sur le nom d'hôte de Signalements ?
Solution
Deux obstacles : le listener n'accepte que les routes du namespace signalements (from: Same), et il est limité au nom d'hôte signalements.formation.test. La bonne organisation est une Gateway de plateforme, dans un namespace dédié (par exemple passerelles), avec un listener par application ou un listener à nom d'hôte générique, et allowedRoutes.namespaces.from: Selector avec un sélecteur d'étiquettes de namespace. Pour empêcher l'équipe cartes de publier sur le nom de Signalements, on donne à chaque listener son nom d'hôte précis et l'on n'autorise sur chacun que le namespace concerné : un listener cartes (nom d'hôte cartes.formation.test, routes du namespace cartes) et un listener signalements. Les ports de ces listeners restent ceux des points d'entrée de Traefik.
3. Vider un nœud (niveau 200). Sur le cluster kind, videz le nœud qui porte l'un des pods de l'API (kubectl drain <nœud> --ignore-daemonsets --delete-emptydir-data), tout en faisant tourner la boucle de curl de la section 9. Décrivez ce que fait le PDB, ce qu'il advient de PostgreSQL s'il est sur ce nœud, puis remettez le nœud en service.
Solution
kubectl drain marque le nœud non planifiable, puis demande l'éviction de chaque pod par l'API d'éviction, qui respecte les PDB. Le pod de l'API sur ce nœud est évincé (le PDB l'autorise : il resterait un pod disponible), un remplaçant est créé sur un autre nœud ; si l'autre pod de l'API était lui aussi sur ce nœud, son éviction serait refusée tant que le remplaçant du premier n'est pas prêt, et drain réessaie. La boucle de curl ne doit voir que des 200. --delete-emptydir-data est nécessaire à cause du volume /tmp en emptyDir, dont le contenu est perdu (ce qui est sans importance ici). Si postgresql-0 est sur ce nœud, il est évincé lui aussi (aucun PDB ne le protège) et recréé ailleurs ; avec le stockage local de kind, son volume est lié au nœud vidé, et le pod reste Pending : c'est la limite du stockage local, que n'a pas un volume bloc réseau sur Kapsule. Remise en service : kubectl uncordon <nœud>.
4. Préparer la sortie d'ingress-nginx (niveau 200). Un client de Lyneko expose encore une application avec cet Ingress, sur un cluster qui utilise ingress-nginx :
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: portail
annotations:
nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec:
ingressClassName: nginx
rules:
- host: portail.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: portail
port:
number: 80Écrivez l'équivalent en Gateway API pour Traefik (sans le TLS, traité ailleurs), et dites ce que devient l'annotation.
Solution
Une Gateway (classe traefik, listener HTTP sur le port 8000, nom d'hôte portail.example.com) et une HTTPRoute rattachée à ce listener, avec hostnames: [portail.example.com], une règle PathPrefix / et un backendRefs vers le Service portail sur le port 80. L'annotation ssl-redirect, propre à ingress-nginx, n'a pas d'équivalent en annotation : la redirection fait partie de la spécification. On ajoute un listener HTTPS (avec son certificat) et, sur le listener HTTP, une HTTPRoute dont la règle porte un filtre RequestRedirect avec scheme: https et statusCode: 301. L'outil ingress2gateway produit ce type de conversion, mais chaque annotation doit être revue, car beaucoup de comportements implicites d'ingress-nginx n'ont pas d'équivalent automatique.
Récapitulatif
- Un namespace étiqueté
pod-security.kubernetes.io/enforce: restrictedrefuse les pods qui tournent enroot, gardent des capacités ou n'ont pas de profil seccomp : on écrit les manifestes conformes dès le départ. - Ordre : contrôleur d'entrée et CRD (une fois par cluster), Secrets, base, migration (attendue avec
kubectl wait), application ; les réessais et les sondes absorbent le reste. - Un Deployment prêt pour la production : deux étiquettes dans le sélecteur,
maxUnavailable: 0, trois sondes aux rôles distincts (la sonde de vie ne dépend pas de la base), unpreStopsleep, un délai d'arrêt qui couvre Gunicorn, une répartition entre nœuds, des ressources,readOnlyRootFilesystemet un/tmpen mémoire. - Un PodDisruptionBudget protège des perturbations volontaires (vidage de nœud), pas des pannes.
- Ingress est gelé, ingress-nginx n'est plus maintenu depuis mars 2026 ; Gateway API (1.6.2) sépare GatewayClass, Gateway et HTTPRoute selon les rôles. Avec Traefik, le port du listener est celui du point d'entrée (8000).
- Une kustomization rassemble les manifestes ;
kubectl apply -k .est idempotent.
Pour aller plus loin
- Le guide Migrating from Ingress de Gateway API et l'outil ingress2gateway.
- La documentation des Pod Security Standards, qui détaille chaque règle des trois profils.
- Le tutoriel Pods And Endpoints Termination Flow de la documentation de Kubernetes, pour observer pas à pas l'arrêt d'un pod et le retrait de ses points de terminaison.
- La leçon suivante, qui part de ce déploiement pour diagnostiquer les pannes courantes.
- Les cours GitOps avec Argo CD et Helm, pour automatiser ce que cette leçon fait à la main.
Sources
- Kubernetes, Pod Security Standards et Pod Security Admission
- Kubernetes, Pod Lifecycle : Termination of Pods
- Kubernetes, Pod Topology Spread Constraints
- Kubernetes, Specifying a Disruption Budget for your Application
- Kubernetes, Ingress (API gelée) et Gateway API
- Kubernetes Blog, Ingress NGINX Retirement: What You Need to Know (11 novembre 2025)
- Gateway API, guide de démarrage et notes de version v1.6.2
- Traefik, fournisseur Kubernetes Gateway API
- traefik/traefik-helm-chart, values.yaml
- kind, Ingress et LoadBalancer (cloud-provider-kind)
- Gunicorn, référence des réglages (worker_tmp_dir, graceful_timeout)
- Docker Official Images, postgres