Kubernetes
Pods, deployments, services, ingress, configmaps.
Vérifié en septembre 2026 · outils aux versions citées dans le cours · environ 17 min
Kubernetes fait tourner des conteneurs sur un ensemble de machines, les nœuds. On ne lui dit pas quoi faire, on lui décrit l'état voulu — trois instances de l'API, joignables sous tel nom, avec telle configuration — et il y ramène sans cesse la réalité : il relance ce qui tombe, remplace ce qu'un nœud perdu emportait, passe d'une version à la suivante sans couper le service. Pour une API .NET et un front Angular, c'est l'étape d'après les images du cours « Docker », quand une machine ne suffit plus ou qu'une livraison ne doit plus interrompre personne, et les objets qu'un développeur y écrit imposent chacun quelque chose à l'application.
Un état voulu, des contrôleurs, et le pod
Tout ce que Kubernetes gère est un objet de son API, décrit en YAML, le plus souvent par quatre champs : apiVersion et kind disent de quel type il s'agit, metadata le nomme, spec dit ce qu'on veut ; une ConfigMap ou un Secret portent data à la place. Le serveur d'API stocke l'objet ; des contrôleurs le lisent, comparent la réalité à la spec, agissent pour réduire l'écart, puis écrivent ce qu'ils observent dans status. Cette boucle ne s'arrête jamais : un objet n'est pas une commande exécutée une fois, c'est une promesse que le cluster tient en permanence.
L'unité de base est le pod : un ou plusieurs conteneurs placés ensemble sur un même nœud, qui partagent une adresse IP et des volumes, et se joignent entre eux par localhost. Un pod est jetable. Il reçoit une nouvelle IP à chaque création, et un pod écrit seul n'est suivi par aucun contrôleur : le kubelet de son nœud relance ses conteneurs qui plantent, mais si le pod lui-même disparaît, supprimé ou emporté par son nœud, rien ne le recrée. Les labels de ses métadonnées, des paires clé-valeur libres, sont ce par quoi les autres objets le retrouveront. Le namespace range les objets d'une application et sépare leurs noms de ceux des autres.
# k8s/pod-essai.yaml : un pod seul, pour voir l'objet de base. On n'en
# ecrit pas en production : si son noeud disparait, personne ne le recree.
apiVersion: v1
kind: Pod
metadata:
name: api-essai
namespace: boutique
labels:
app.kubernetes.io/name: api
spec:
containers:
- name: api
image: registre.example.com/boutique-api:1.4.0
ports:
- name: http
containerPort: 8080 # le port des images ASP.NET Core depuis .NET 8kubectl create namespace boutique
# Creer ou mettre a jour chaque objet decrit : c'est l'etat voulu qui est envoye
kubectl apply -f k8s/pod-essai.yaml
kubectl apply -f k8s/ # tous les manifestes du dossier
kubectl -n boutique get pods -o wide # etat, redemarrages, IP et noeud de chaque pod
kubectl -n boutique describe pod api-essai # la spec, l'etat, et les evenements (image, sondes...)
kubectl -n boutique logs api-essai # la sortie standard du conteneur
kubectl -n boutique port-forward pod/api-essai 8080:8080 # joindre le pod depuis le poste
kubectl -n boutique delete pod api-essai # un pod seul : il ne reviendra pas
# Sans cluster : un generateur de kubectl ecrit un manifeste de depart
kubectl create deployment api --image=registre.example.com/boutique-api:1.4.0 \
--replicas=3 --port=8080 --dry-run=client -o yamlkubectl apply envoie l'état voulu, pas une suite d'actions : appliquer deux fois le même dossier ne change rien la seconde fois. Malgré son nom, kubectl apply --dry-run=client ne se passe pas de cluster : kubectl 1.36 interroge le serveur d'API pour connaître les types d'objets et télécharger le schéma de validation, et échoue sans lui. Les générateurs comme kubectl create deployment travaillent, eux, hors ligne.
Deployment : des réplicas et une mise à jour progressive
On n'écrit pas de pods, on écrit un Deployment. Il porte un modèle de pod, template, et un nombre de réplicas. Le contrôleur de Deployment crée un ReplicaSet par version du modèle, et c'est le ReplicaSet qui maintient le nombre de pods : un pod supprimé ou perdu avec son nœud est aussitôt remplacé. Le selector dit quels pods lui appartiennent ; il doit correspondre aux labels du modèle et ne peut plus changer après la création.
Un déploiement progressif démarre si et seulement si spec.template change. Le contrôleur crée alors un nouveau ReplicaSet, qu'il fait monter pendant qu'il fait descendre l'ancien. maxSurge borne les pods en surplus, maxUnavailable ceux qui peuvent manquer ; tous deux valent 25 % par défaut, arrondis vers le haut pour le premier et vers le bas pour le second : à trois réplicas, un pod de plus et aucun de moins. Un nouveau pod ne compte qu'une fois prêt au sens de sa sonde de disponibilité : une version qui ne le devient jamais bloque le déploiement, et les anciens pods continuent de servir. Au bout de progressDeadlineSeconds, 600 secondes par défaut, le Deployment est déclaré en échec, sans retour arrière automatique : c'est à la chaîne de livraison de décider.
Cette règle rend fautif le manifeste le plus répandu des débuts : une image étiquetée latest, repoussée à chaque livraison sous le même nom.
# k8s/api.yaml, applique tel quel a chaque livraison, apres un docker push.
apiVersion: apps/v1
kind: Deployment
metadata:
name: api
namespace: boutique
spec:
replicas: 3
selector:
matchLabels:
app.kubernetes.io/name: api
template:
metadata:
labels:
app.kubernetes.io/name: api
app.kubernetes.io/part-of: boutique
spec:
containers:
- name: api
# Le texte ne change jamais : kubectl apply ne voit aucune difference
# dans le template, et aucun deploiement progressif ne demarre.
image: registre.example.com/boutique-api:latest
ports:
- name: http
containerPort: 8080 Le modèle ne change pas, donc rien ne se passe. Pire, pour latest, la politique de téléchargement par défaut est Always : chaque pod recréé plus tard prend la dernière image poussée, et les réplicas finissent par exécuter des versions différentes sans que personne l'ait décidé. Aucun retour arrière n'est possible : l'ancienne version n'a pas de nom. Le modèle juste porte une étiquette unique, ou le digest de l'image : chaque livraison change le texte, et chaque ReplicaSet désigne une image précise.
# k8s/api.yaml : la chaine de livraison y ecrit le tag de l'image qu'elle
# vient de pousser (ou son digest @sha256:...), jamais latest.
apiVersion: apps/v1
kind: Deployment
metadata:
name: api
namespace: boutique
spec:
replicas: 3
# Immuable apres creation, et doit correspondre aux labels du template.
selector:
matchLabels:
app.kubernetes.io/name: api
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1 # au plus 4 pods pendant la mise a jour
maxUnavailable: 0 # jamais moins de 3 pods prets
template:
metadata:
labels:
app.kubernetes.io/name: api
app.kubernetes.io/part-of: boutique
spec:
containers:
- name: api
image: registre.example.com/boutique-api:1.4.0
ports:
- name: http
containerPort: 8080# Livrer 1.4.1 : le template change, un nouveau ReplicaSet nait
kubectl apply -f k8s/api.yaml
# Attend la fin ; code de sortie non nul si progressDeadlineSeconds (600 s) est depasse
kubectl -n boutique rollout status deployment/api
kubectl -n boutique get replicasets -l app.kubernetes.io/name=api # l'ancien et le nouveau
kubectl -n boutique rollout history deployment/api
kubectl -n boutique rollout undo deployment/api # reprend le template precedent, progressivement
kubectl -n boutique scale deployment/api --replicas=5 # aucun nouveau ReplicaSet : le template est le meme
kubectl -n boutique rollout restart deployment/api # recree tous les pods, progressivement Écrire maxSurge: 1 et maxUnavailable: 0, que les défauts donnent déjà à trois réplicas, fige ce choix : à dix réplicas, les défauts deviendraient trois et deux. rollout undo n'est qu'un nouveau changement du modèle, vers une version conservée parmi les dix anciens ReplicaSets que garde revisionHistoryLimit.
Service : une adresse stable devant des pods éphémères
Les pods naissent et meurent, leurs adresses avec eux. Un Service leur donne une adresse fixe : une IP virtuelle et un nom DNS, api dans son namespace, api.boutique depuis un autre, et en entier api.boutique.svc.cluster.local dans le domaine usuel. Son selector désigne des pods par leurs labels, le plan de contrôle en tient la liste dans des EndpointSlices, et seuls les pods prêts reçoivent du trafic. Le Service ne regarde que les labels : il ignore quel Deployment a créé quel pod. Un sélecteur trop large capte donc les pods d'un autre composant.
# Les pods de l'API (port 8080) et ceux du front nginx (port 80)
# portent tous le label app.kubernetes.io/part-of: boutique.
apiVersion: v1
kind: Service
metadata:
name: api
namespace: boutique
spec:
selector:
app.kubernetes.io/part-of: boutique # retient aussi les pods du front
ports:
- port: 80
targetPort: 8080Le label commun à toute l'application retient aussi les pods nginx du front. Une partie des appels atterrit sur un pod qui n'écoute pas sur 8080 et les refuse : des échecs intermittents, que les journaux de l'API ne montreront jamais. Le sélecteur juste vise le label propre au composant, et nomme le port du conteneur, pour que changer ce port ne touche que le Deployment.
apiVersion: v1
kind: Service
metadata:
name: api
namespace: boutique
spec:
type: ClusterIP # le defaut : une IP virtuelle joignable depuis le cluster seulement
selector:
app.kubernetes.io/name: api # le label qui distingue les pods de l'API, et eux seuls
ports:
- name: http
port: 80 # ce qu'appellent les clients : http://api, ou http://api.boutique
targetPort: http # le port nomme du conteneur, 8080 dans le Deployment| Type | Joignable depuis | Ce qu'il ajoute |
|---|---|---|
ClusterIP (défaut) | l'intérieur du cluster | une IP virtuelle et un nom DNS |
NodePort | chaque nœud, sur un port de 30000 à 32767 par défaut | un port ouvert sur tous les nœuds, en plus de la ClusterIP |
LoadBalancer | l'extérieur | un répartiteur de charge externe, que fournit le cloud ou un contrôleur installé |
Pour une application web, aucun n'est la bonne porte d'entrée : un LoadBalancer par Service multiplie adresses et coûts, et ignore HTTP. Le front Angular, lui, tourne dans le navigateur, hors du cluster, et ne peut pas résoudre api.boutique. Il appelle /api/... sur l'origine qui l'a servi, et la couche suivante aiguille : front et API partagent une origine, sans CORS à configurer (voir « Qualité d'API »).
Ingress, et la Gateway API qui lui succède
Un Ingress décrit des règles de routage HTTP : tel hôte, tel chemin, vers tel Service. Il ne fait rien seul : il faut un contrôleur d'Ingress installé dans le cluster, désigné par ingressClassName, qui lit ces règles et configure un proxy en conséquence.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: boutique
namespace: boutique
spec:
ingressClassName: exemple # l'IngressClass du controleur installe dans le cluster
tls:
- hosts:
- boutique.example.com
secretName: boutique-tls # un Secret qui contient tls.crt et tls.key
rules:
- host: boutique.example.com
http:
paths:
# Prefix compare element par element : /api, /api/commandes, pas /apidoc.
# Le chemin arrive intact : l'API recoit /api/commandes et doit le router.
- path: /api
pathType: Prefix
backend:
service:
name: api
port:
name: http
# Le chemin le plus long l'emporte : tout ce qui n'est pas /api va au front.
- path: /
pathType: Prefix
backend:
service:
name: front
port:
name: http Quand plusieurs chemins correspondent, le plus long l'emporte, puis Exact passe avant Prefix. L'Ingress ne réécrit pas le chemin — les contrôleurs qui le font passent par des annotations propres à chacun — : les routes ASP.NET Core de l'API commencent donc par /api. Le contrôleur termine TLS avec le Secret désigné et transmet en HTTP.
L'API Ingress, stable depuis Kubernetes 1.19, n'évoluera plus : la documentation la dit gelée et recommande Gateway, sans projet de la retirer. Surtout, ingress-nginx, le contrôleur le plus répandu, a été retiré : son dépôt est archivé depuis le 24 mars 2026, sans plus de correctifs de sécurité. Un cluster qui l'utilise encore expose à Internet un composant abandonné, et un nouveau projet part sur la Gateway API.
Celle-ci n'est pas intégrée à Kubernetes : c'est un module de définitions de ressources à installer, dont la version 1.6 date de fin juin 2026. Elle répartit ce que l'Ingress mélangeait selon qui l'écrit : la GatewayClass vient de l'implémentation ; la Gateway, point d'entrée avec ses ports et ses certificats, de l'équipe qui exploite le cluster ; les routes, de chaque équipe, dans son namespace.
# Le point d'entree, ecrit par l'equipe qui exploite le cluster.
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: public
namespace: infra
spec:
gatewayClassName: exemple # fournie par l'implementation installee
listeners:
- name: https
protocol: HTTPS
port: 443
hostname: boutique.example.com
tls:
mode: Terminate # le defaut : la Gateway dechiffre, les pods recoivent du HTTP
certificateRefs:
- kind: Secret
name: boutique-tls # dans le namespace infra, celui de la Gateway
allowedRoutes:
namespaces:
from: All # le defaut, Same, n'accepterait que les routes du namespace infra
---
# Les routes, ecrites par l'equipe de l'application, dans son namespace.
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: boutique
namespace: boutique
spec:
parentRefs:
- name: public
namespace: infra
hostnames:
- boutique.example.com
rules:
- matches:
- path:
type: PathPrefix
value: /api
backendRefs:
- name: api
port: 80 # un numero : obligatoire vers un Service, et pas de port nomme ici
# Sans matches, une regle vaut PathPrefix / : tout le reste.
- backendRefs:
- name: front
port: 80 Ce que l'Ingress confiait aux annotations entre dans l'API : la répartition pondérée entre Services, par weight, appartient au noyau que toute implémentation fournit, la réécriture de chemin au niveau étendu, facultatif. La Gateway choisit les namespaces dont les routes peuvent s'y attacher ; une route qui viserait un Service d'un autre namespace, ou une Gateway qui lirait un certificat ailleurs, exige en plus un ReferenceGrant dans le namespace visé.
ConfigMap et Secret
La même image passe de la recette à la production : la configuration d'un environnement vit hors d'elle. Une ConfigMap la porte en paires clé-valeur, 1 Mio au plus. Le pod la reçoit en variables d'environnement, qu'ASP.NET Core lit en remplaçant __ par :, ou en fichiers dans un volume. La différence compte à la modification : les variables sont lues au démarrage du conteneur, les fichiers d'un volume sont mis à jour par le kubelet après un délai, sauf montés avec subPath. Un pod qui référence une ConfigMap absente ne démarre pas, sauf référence marquée optional.
apiVersion: v1
kind: ConfigMap
metadata:
name: api-config
namespace: boutique
data:
# Des cles de configuration ASP.NET Core : "__" tient lieu de ":".
# Toute valeur est une chaine, d'ou les guillemets autour de true et de 300.
Cache__Active: "true"
Cache__DureeSecondes: "300"
Boutique__UrlPublique: https://boutique.example.comLes valeurs d'une ConfigMap, comme celles des variables d'environnement, sont des chaînes. YAML, lui, devine le type d'une valeur nue (voir le cours « YAML ») : ce Deployment écrit un booléen et un entier là où l'API attend du texte.
apiVersion: apps/v1
kind: Deployment
metadata:
name: api
namespace: boutique
spec:
replicas: 3
selector:
matchLabels:
app.kubernetes.io/name: api
template:
metadata:
labels:
app.kubernetes.io/name: api
spec:
containers:
- name: api
image: registre.example.com/boutique-api:1.4.0
env:
- name: Cache__Active
value: true # un booleen pour YAML, la ou l'API attend une chaine
- name: Cache__DureeSecondes
value: 300 # un entier, meme probleme Le serveur d'API refuse le manifeste entier, faute de pouvoir le décoder ; la vraie validation, kubectl apply --dry-run=server, exige donc un cluster. Sans cluster, kubectl set image --local -f décode le fichier dans les types de l'API : il ne vérifie que les types, et efface sans rien dire un champ inconnu. Ici, il bute sur l'erreur : cannot unmarshal bool into Go struct field EnvVar.spec.template.spec.containers.env.value of type string. Des guillemets suffisent à corriger ; mieux, les valeurs passent dans la ConfigMap, et le secret dans un Secret.
apiVersion: apps/v1
kind: Deployment
metadata:
name: api
namespace: boutique
spec:
replicas: 3
selector:
matchLabels:
app.kubernetes.io/name: api
template:
metadata:
labels:
app.kubernetes.io/name: api
spec:
containers:
- name: api
image: registre.example.com/boutique-api:1.4.0
# Chaque cle de la ConfigMap devient une variable d'environnement, lue au
# demarrage du conteneur : modifier la ConfigMap ne change rien aux pods en cours.
envFrom:
- configMapRef:
name: api-config
env:
- name: ConnectionStrings__Boutique
valueFrom:
secretKeyRef:
name: api-secrets
key: ConnectionStrings__BoutiqueUn Secret n'est pas chiffré. Ses valeurs sont encodées en base64, ce qui les rend transportables, pas illisibles.
# Le manifeste d'un Secret, genere sans cluster (kubectl 1.36)
kubectl create secret generic api-secrets -n boutique \
--from-literal=ConnectionStrings__Boutique='Host=db;Database=boutique;Username=boutique;Password=s3cr3t' \
--dry-run=client -o yaml
# apiVersion: v1
# data:
# ConnectionStrings__Boutique: SG9zdD1kYjtEYXRhYmFzZT1ib3V0aXF1ZTtVc2VybmFtZT1ib3V0aXF1ZTtQYXNzd29yZD1zM2NyM3Q=
# kind: Secret
# metadata:
# name: api-secrets
# namespace: boutique
# Ce n'est pas un chiffrement : n'importe qui le relit
echo 'SG9zdD1kYjtEYXRhYmFzZT1ib3V0aXF1ZTtVc2VybmFtZT1ib3V0aXF1ZTtQYXNzd29yZD1zM2NyM3Q=' | base64 -d
# Host=db;Database=boutique;Username=boutique;Password=s3cr3t Par défaut dans Kubernetes, les Secrets sont stockés en clair dans etcd, la base du cluster — certaines offres managées les chiffrent, c'est à vérifier sur la sienne —, et quiconque peut créer un pod ou un Deployment dans un namespace peut y monter n'importe quel Secret et le lire. Un Secret apporte un type à part : des droits d'accès distincts de ceux des ConfigMaps, un contenu que kubectl describe n'affiche pas. Le reste se configure : chiffrement au repos d'etcd, règles RBAC étroites, gestionnaire de secrets externe si l'exigence le demande. Un manifeste de Secret ne se commite pas : la chaîne de livraison le crée au déploiement à partir de ses variables secrètes, dont les cours « GitLab CI » et « Azure DevOps » décrivent le fonctionnement.
Sondes, arrêt et ressources
Le Deployment complet dit à Kubernetes quand l'application est prête, quand elle est bloquée, comment l'arrêter et ce qu'elle consomme.
# k8s/api.yaml, dans sa forme complete. Les URL de sante sont celles de l'API
# du cours Resilience et performance.
apiVersion: apps/v1
kind: Deployment
metadata:
name: api
namespace: boutique
spec:
replicas: 3
selector:
matchLabels:
app.kubernetes.io/name: api
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
template:
metadata:
labels:
app.kubernetes.io/name: api
app.kubernetes.io/part-of: boutique
spec:
# Couvre le preStop (5 s) puis l'arret de l'hote .NET (30 s au plus par defaut).
terminationGracePeriodSeconds: 45
securityContext:
runAsNonRoot: true # l'image fixe USER $APP_UID, un UID numerique
containers:
- name: api
image: registre.example.com/boutique-api:1.4.0
ports:
- name: http
containerPort: 8080
envFrom:
- configMapRef:
name: api-config
env:
- name: ConnectionStrings__Boutique
valueFrom:
secretKeyRef:
name: api-secrets
key: ConnectionStrings__Boutique
# Tant qu'elle n'a pas reussi, les deux autres sondes ne tournent pas.
# 30 echecs x 2 s : une minute pour que Kestrel reponde.
startupProbe:
httpGet:
path: /sante/vivant
port: http
periodSeconds: 2
failureThreshold: 30
# Echec : le pod est marque non pret dans les EndpointSlices et ne recoit plus
# de trafic. Il n'est pas redemarre.
readinessProbe:
httpGet:
path: /sante/pret
port: http
periodSeconds: 5
timeoutSeconds: 3 # le controle de la base se donne 2 s ; le defaut est 1 s
# Trois echecs de suite (le defaut) : le conteneur est tue puis redemarre.
livenessProbe:
httpGet:
path: /sante/vivant
port: http
periodSeconds: 10
timeoutSeconds: 2
lifecycle:
preStop:
sleep:
seconds: 5 # le temps que Services et repartiteurs oublient ce pod
resources:
requests: # ce que le planificateur reserve sur le noeud
cpu: 250m
memory: 256Mi
limits:
memory: 512Mi # au-dela, le noyau tue le conteneur (OOMKilled)Trois sondes
Pourquoi la sonde de vie ne dépend de rien d'extérieur, et celle de disponibilité de tout ce qui conditionne le service, est expliqué dans la section « Health checks : vivant ou prêt » du cours « Résilience et performance ». L'échec de livenessProbe redémarre le conteneur ; celui de readinessProbe, interrogée toute la vie du pod, le retire du Service sans le redémarrer. startupProbe protège le démarrage : tant qu'elle n'a pas réussi, les deux autres ne sont pas interrogées, et une application lente à démarrer n'est pas tuée par une sonde de vie impatiente. Une réponse de 200 à 399 est un succès.
Les défauts sont serrés : une interrogation toutes les 10 secondes, une seconde pour répondre, un échec après trois ratés de suite. Cette seconde piège l'API de « Résilience et performance », dont le contrôle de la base se donne deux secondes : une base un peu lente ferait échouer la sonde avant la réponse du contrôle. timeoutSeconds doit dépasser le plus long des contrôles qu'elle déclenche.
Un arrêt sans requêtes perdues
Le cours « Docker » décrit ce que SIGTERM déclenche dans ASP.NET Core. Sous Kubernetes, le retrait du pod des destinations du Service et le début de son arrêt se font en parallèle : un répartiteur peut encore lui envoyer des requêtes un court instant, alors que Kestrel a cessé d'en accepter. Le crochet preStop s'exécute avant SIGTERM : quelques secondes d'attente laissent le cluster oublier le pod. L'action sleep, stable depuis Kubernetes 1.34, n'exige aucun programme sleep dans l'image, ce qui convient aux images chiseled. terminationGracePeriodSeconds, 30 secondes par défaut, couvre le crochet et l'arrêt réunis : cinq secondes plus les trente de HostOptions.ShutdownTimeout n'y tiennent pas, d'où les 45 du manifeste. Au-delà, le kubelet envoie SIGKILL.
Requests et limits
Les requests servent au placement : le planificateur ne pose un pod sur un nœud que si la somme des requests y tient, quelle que soit la consommation réelle. Les limits jouent à l'exécution. La limite CPU freine : au-delà, le noyau ralentit le conteneur, même sur un nœud inoccupé. La limite mémoire tue : le conteneur qui la dépasse est arrêté, OOMKilled, puis redémarré. Une limite sans request fixe la request à sa valeur. Sans aucune request ni limite, un pod est BestEffort, le premier expulsé quand un nœud manque de ressources ; avec des requests égales aux limits, CPU et mémoire, il est Guaranteed, le dernier ; entre les deux, Burstable, comme ici.
.NET lit ces limites. Sous une limite mémoire, le tas du ramasse-miettes se borne par défaut à 75 % de celle-ci, et au moins 20 Mo : 384 Mio ici, le reste allant au code natif et aux piles. Environment.ProcessorCount renvoie le nombre de cœurs logiques, ramené à la limite CPU arrondie à l'entier supérieur s'il y en a une, et le minimum de threads du pool s'y aligne. Ce manifeste ne pose pas de limite CPU, pour profiter des cœurs inoccupés du nœud : c'est un choix, pas une règle. Sous une limite de 500m, l'application se verrait sur un seul processeur ; DOTNET_PROCESSOR_COUNT impose un autre nombre.