Jobs, CronJobs, DaemonSets et StatefulSets
Pourquoi
Jusqu'ici, tout ce qui tournait dans le cluster était censé tourner pour toujours. Un Deployment maintient un nombre de répliques identiques et interchangeables ; si l'une s'arrête, même proprement, avec le code de sortie 0, il la relance. C'est exactement ce que l'on veut pour l'API de Signalements, et exactement ce que l'on ne veut pas pour trois autres besoins de l'application :
- La migration du schéma. Avant que la version 1.2.0 démarre, la table
signalementsdoit avoir sa colonnepriorite. C'est une tâche qui s'exécute une fois, doit se terminer, et dont on veut savoir si elle a réussi. Un Deployment la relancerait en boucle. - La purge nocturne. Chaque nuit, les signalements de plus de deux ans doivent être supprimés, une obligation de durée de conservation négociée avec les mairies. C'est la même tâche qui se termine, mais à heure fixe.
- La base de données de formation. PostgreSQL n'est pas une réplique interchangeable : il a un disque, et ce disque doit le suivre s'il est replanifié sur un autre nœud. Ses clients doivent le trouver sous un nom stable.
Et un quatrième besoin, qui n'est pas celui de l'application mais de l'équipe qui l'exploite : un agent de supervision qui relève les métriques de chaque nœud, et doit donc tourner exactement une fois par nœud, y compris sur ceux qui rejoindront le cluster demain.
Kubernetes répond à ces quatre besoins par quatre contrôleurs : le Job, le CronJob, le StatefulSet et le DaemonSet. Ils reposent tous sur le même principe que le Deployment (un état voulu, un contrôleur qui en rapproche la réalité), mais leur définition de « la réalité voulue » diffère. Sans eux, on retombe sur les solutions d'avant les conteneurs : une migration lancée à la main depuis un poste, un cron sur une machine que tout le monde a oubliée, des agents installés nœud par nœud.
Les concepts
Le Job : une tâche qui se termine
Un Job (en français, une tâche) crée un ou plusieurs pods et les surveille jusqu'à ce qu'un nombre donné d'entre eux se termine avec succès. Là où le Deployment veut « N pods en marche », le Job veut « N terminaisons réussies ». Une fois ce nombre atteint, il s'arrête, et ses pods restent là, terminés, avec leurs journaux, jusqu'à ce qu'on les nettoie.
Quelques champs règlent ce comportement. Ils sont tous dans la documentation du Job, et chacun répond à une question concrète :
| Champ | Question | Valeur par défaut |
|---|---|---|
completions | Combien de terminaisons réussies faut-il ? | 1 |
parallelism | Combien de pods peuvent tourner en même temps ? | 1 |
backoffLimit | Combien d'échecs tolère-t-on avant de déclarer le Job en échec ? | 6 |
activeDeadlineSeconds | Combien de temps, au plus, pour tout le Job ? | aucune limite |
ttlSecondsAfterFinished | Combien de temps garder le Job une fois fini ? | jamais supprimé |
Trois précisions de la documentation méritent d'être retenues.
Les réessais s'espacent. Après un échec, le contrôleur recrée le pod avec un délai exponentiel, « 10s, 20s, 40s ... », plafonné à six minutes. Un Job dont la base n'est pas encore prête ne martèle donc pas la base ; il attend de plus en plus longtemps. Avec backoffLimit: 3, on a donc quatre tentatives en un peu plus d'une minute.
La limite de durée l'emporte sur la limite d'échecs. La documentation est explicite : activeDeadlineSeconds « takes precedence over » backoffLimit. Une fois le délai atteint, tous les pods du Job sont arrêtés et le Job passe en échec, même s'il lui restait des réessais. C'est le garde-fou contre la migration qui reste bloquée sur un verrou de table.
La politique de redémarrage s'applique au pod, pas au Job. Un pod de Job doit avoir restartPolicy: Never ou OnFailure (Always, valeur par défaut des pods, est refusé). Avec OnFailure, le kubelet relance le conteneur dans le même pod ; avec Never, le contrôleur du Job crée un nouveau pod à chaque tentative. La documentation recommande Never pour déboguer : avec OnFailure, le pod est supprimé quand la limite est atteinte, et ses journaux avec lui. Avec Never, chaque tentative laisse un pod terminé, dont on peut lire les journaux.
Les échecs qui ne méritent pas de réessai
Par défaut, tout échec compte pour backoffLimit, quelle qu'en soit la cause. C'est maladroit dans les deux sens. Une migration qui échoue sur une erreur de SQL échouera de la même façon à la quatrième tentative : réessayer fait perdre une minute et noie le diagnostic. À l'inverse, un pod évincé parce que l'on vide un nœud pour maintenance n'a rien fait de mal : compter cet échec rapproche le Job de son abandon sans raison.
La politique d'échec des pods (podFailurePolicy), stable depuis Kubernetes 1.31, permet de distinguer ces cas. Elle contient des règles, évaluées dans l'ordre ; la première qui correspond décide, avec l'une de ces actions :
FailJob: le Job échoue immédiatement, et ses pods en cours sont arrêtés ;Ignore: l'échec ne compte pas pourbackoffLimit, et un pod de remplacement est créé ;Count: le comportement par défaut, l'échec compte ;FailIndex: pour les Jobs indexés, l'index concerné échoue sans réessai.
Une règle porte soit sur les codes de sortie d'un conteneur, soit sur les conditions du pod, comme DisruptionTarget, que Kubernetes pose sur un pod dont l'arrêt vient d'une perturbation (éviction, préemption). La politique exige restartPolicy: Never.
Le CronJob : un Job à heure fixe
Un CronJob crée un Job selon un calendrier, écrit dans la syntaxe de cron : cinq champs (minute, heure, jour du mois, mois, jour de la semaine), ou une macro comme @daily. Il ne fait rien d'autre ; tout le travail est fait par le Job qu'il crée, à partir de son jobTemplate.
Les réglages qui comptent :
timeZone, stable depuis Kubernetes 1.27. Sans lui, le calendrier est interprété dans le fuseau dukube-controller-manager, souvent UTC, et votre purge « de 3 h 30 » tourne à 5 h 30 l'été. AvectimeZone: Europe/Paris, le calendrier suit l'heure légale. Écrire le fuseau dans le calendrier (CRON_TZ=...) n'est pas pris en charge : la validation refuse l'objet.concurrencyPolicy:Allow(par défaut) laisse tourner deux Jobs en même temps si le précédent n'a pas fini,Forbidsaute l'occurrence,Replacearrête le précédent pour lancer le nouveau. Pour une purge ou une sauvegarde,Forbidest presque toujours le bon choix.startingDeadlineSeconds: le retard maximal toléré pour lancer une occurrence manquée (contrôleur arrêté, cluster en maintenance). Au-delà, l'occurrence est sautée, les suivantes restent planifiées. Sans ce champ, le contrôleur compte les occurrences manquées depuis la dernière exécution, et s'il en trouve plus de 100, il ne lance rien et journalise « too many missed start times ». Le régler évite ce piège, et dit clairement ce que l'on préfère : une purge en retard d'une heure, oui ; une purge en plein après-midi, non.successfulJobsHistoryLimitetfailedJobsHistoryLimit: combien de Jobs terminés garder, 3 et 1 par défaut. Garder plus d'un Job en échec aide à voir si un échec est isolé ou répété.suspend: met le CronJob en pause sans le supprimer, utile pendant une intervention sur la base.
La documentation insiste sur un point : un CronJob peut, dans certaines circonstances, créer deux Jobs pour la même occurrence, ou n'en créer aucun. Les tâches planifiées doivent donc être idempotentes : lancée deux fois, la purge supprime deux fois les mêmes signalements, ce qui ne change rien. Une limite pratique enfin : le nom d'un CronJob ne dépasse pas 52 caractères, parce que le contrôleur y ajoute 11 caractères pour nommer ses Jobs, eux-mêmes limités à 63.
Le DaemonSet : un pod par nœud
Un DaemonSet garantit qu'un pod tourne sur chaque nœud (ou sur chaque nœud qui correspond à un sélecteur). Quand un nœud rejoint le cluster, le DaemonSet y crée son pod ; quand il le quitte, le pod disparaît avec lui. Il n'a pas de nombre de répliques : le nombre de nœuds en tient lieu.
La documentation cite trois usages typiques : un démon de stockage sur chaque nœud, un collecteur de journaux, un agent de supervision. Vous en avez déjà sur votre cluster sans les avoir écrits : kube-proxy, le plugin réseau (CNI) et souvent le pilote de stockage (CSI) tournent en DaemonSet dans le namespace kube-system.
Deux particularités le distinguent d'un Deployment :
- Il ajoute des tolérances automatiquement. Ses pods tolèrent par exemple les nœuds
not-readyetunschedulable: un agent réseau doit pouvoir démarrer sur un nœud que le reste du cluster considère encore comme pas prêt, sans quoi le nœud ne deviendrait jamais prêt. - Ses mises à jour se font nœud par nœud (
RollingUpdate, avecmaxUnavailable), ou seulement quand on supprime soi-même le pod (OnDelete).
Un DaemonSet n'est pas le bon outil pour une application : Signalements n'a aucune raison d'avoir une copie par nœud. Le jour où le cluster passe de trois à dix nœuds, l'API n'a pas besoin de dix répliques.
Le StatefulSet : une identité et un stockage stables
Un Deployment traite ses pods comme du bétail : ils portent des noms aléatoires (signalements-7d9f...-x2k4p), sont interchangeables, et un nouveau pod n'hérite de rien de l'ancien. Un StatefulSet donne au contraire à chaque pod une identité qui survit à son remplacement :
- Un numéro d'ordre : les pods s'appellent
postgresql-0,postgresql-1, et ainsi de suite. Sipostgresql-0est supprimé, le nouveau pod s'appelle encorepostgresql-0. - Un nom réseau stable, à condition de créer un Service headless (
clusterIP: None) dont le StatefulSet donne le nom dansserviceName. Chaque pod est alors joignable souspostgresql-0.postgresql.signalements.svc.cluster.local, même si son adresse IP change. C'est à vous de créer ce Service ; le StatefulSet ne le fait pas. - Un volume par pod, créé à partir des
volumeClaimTemplates:postgresql-0reçoit la PersistentVolumeClaimdonnees-postgresql-0, et la retrouve s'il est recréé, sur ce nœud ou ailleurs.
Les opérations suivent l'ordre des numéros. La documentation résume les garanties : création de 0 à N-1, chaque pod attendant que ses prédécesseurs soient en marche et prêts ; suppression dans l'ordre inverse ; mise à jour progressive du plus grand numéro au plus petit. Le mode Parallel (podManagementPolicy) lève cet ordre quand il est inutile.
Ce que le StatefulSet ne fait pas
C'est ici que naissent la plupart des déceptions. Un StatefulSet donne à une base de données les conditions pour fonctionner (un nom stable, un disque qui la suit), mais il ne sait rien de la base :
- Il ne réplique pas les données. Trois répliques d'un StatefulSet PostgreSQL, ce sont trois serveurs PostgreSQL indépendants, chacun avec son disque vide, pas un primaire et deux secondaires. La réplication, la bascule et la reconstruction d'un secondaire relèvent de la base elle-même et d'un outil qui la pilote.
- Il ne sauvegarde rien.
- Il ne supprime pas les volumes quand on le supprime ou qu'on réduit son nombre de répliques, par défaut : la documentation l'assume, « data safety [...] is generally more valuable than an automatic purge ». La politique
persistentVolumeClaimRetentionPolicy(stable depuis 1.32) permet de choisirDeleteouRetain(par défaut) pour les caswhenDeletedetwhenScaled. - Il peut se bloquer lors d'une mise à jour ratée. Si le nouveau modèle ne devient jamais prêt, le StatefulSet s'arrête et attend. Revenir à l'ancien modèle ne suffit pas, à cause d'un défaut connu cité par la documentation : il faut aussi supprimer à la main les pods déjà créés avec le mauvais modèle.
C'est pourquoi, en production, on confie PostgreSQL soit à une base managée (le choix de Lyneko et du cours Le cloud : les fondamentaux), soit à un opérateur : un contrôleur spécialisé qui connaît la base, crée ses StatefulSets ou ses pods, et sait faire une bascule, une sauvegarde, une montée de version. Pour PostgreSQL, CloudNativePG, projet de la CNCF (version 1.30 au 5 octobre 2026), est la référence. Les opérateurs sont le sujet du cours Kubernetes : workloads avancés, CRD et opérateurs.
En pratique
Les manifestes de cette section ont été validés contre la référence de l'API de Kubernetes 1.36 ; les commandes qui demandent un cluster sont expliquées, et ce que vous devez observer est décrit. Elles supposent le namespace signalements et deux Secrets créés à la leçon suivante : signalements (qui contient DATABASE_URL) et postgresql (le mot de passe de la base). Si vous suivez les leçons dans l'ordre, créez-les dès maintenant comme indiqué à la leçon 11.
Le Job de migration
Le Job reprend la migration du cours GitOps avec Argo CD, dans un Job autonome :
apiVersion: batch/v1
kind: Job
metadata:
name: migration-1-2-0
labels:
app.kubernetes.io/name: signalements
app.kubernetes.io/component: migration
spec:
backoffLimit: 3
activeDeadlineSeconds: 300
ttlSecondsAfterFinished: 86400
template:
metadata:
labels:
app.kubernetes.io/name: signalements
app.kubernetes.io/component: migration
spec:
restartPolicy: Never
securityContext:
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
containers:
- name: migration
image: ghcr.io/lyneko-formation/signalements:1.2.0
command:
- python
- -c
- |
import os, psycopg
with psycopg.connect(os.environ["DATABASE_URL"], connect_timeout=5) as cnx:
cnx.execute("CREATE TABLE IF NOT EXISTS signalements ("
" id serial PRIMARY KEY, lieu text NOT NULL,"
" description text NOT NULL, cree_le timestamptz NOT NULL DEFAULT now())")
cnx.execute("ALTER TABLE signalements ADD COLUMN IF NOT EXISTS priorite smallint NOT NULL DEFAULT 2")
print("migration appliquée")
envFrom:
- secretRef:
name: signalements
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
memory: 128Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]Les choix, champ par champ :
- Le nom contient la version (
migration-1-2-0). Le gabarit de pod d'un Job est non modifiable une fois le Job créé : pour la version suivante, on crée un nouveau Job,migration-1-3-0, plutôt que de modifier celui-ci. Unkubectl applyqui changerait l'image de ce Job serait refusé. - L'image est celle de l'application, à la même étiquette : le schéma et le code qui l'attend voyagent ensemble.
- La migration est idempotente (
IF NOT EXISTS) : relancée par un réessai, elle ne casse rien. backoffLimit: 3etactiveDeadlineSeconds: 300: quatre tentatives espacées de 10, 20 et 40 secondes, et cinq minutes au plus. Si PostgreSQL démarre en même temps que le Job, les premières tentatives échouent sur la connexion, et une suivante réussit.ttlSecondsAfterFinished: 86400: le Job et ses pods sont supprimés un jour après la fin. La documentation recommande de toujours régler ce champ pour un Job créé directement : sans lui, il reste indéfiniment.- Le contexte de sécurité est celui que la leçon suivante impose à tout le namespace (profil
restricted).
Appliquez-le et suivez-le :
$ kubectl apply -n signalements -f migration.yaml
$ kubectl get job migration-1-2-0 -n signalements --watch
$ kubectl wait --for=condition=complete job/migration-1-2-0 -n signalements --timeout=5m
$ kubectl logs job/migration-1-2-0 -n signalements
kubectl get job affiche une colonne STATUS (Running, puis Complete ou Failed) et une colonne COMPLETIONS (0/1, puis 1/1). kubectl wait bloque jusqu'à la condition complete, et rend un code de sortie non nul à l'expiration du délai : c'est la commande à mettre dans un script de déploiement, avant le déploiement de l'application. kubectl logs job/... choisit un pod du Job et affiche ses journaux : la ligne migration appliquée.
Si le Job échoue, regardez tous ses pods, pas seulement le dernier :
$ kubectl get pods -n signalements -l job-name=migration-1-2-0
$ kubectl describe job migration-1-2-0 -n signalements
L'étiquette job-name est posée par le contrôleur sur chaque pod du Job. La section Events de describe indique la raison de l'échec du Job, par exemple BackoffLimitExceeded ou DeadlineExceeded.
Classer les échecs
Si la migration de Signalements était un vrai module Python qui sort avec le code 2 sur une erreur de SQL, on écrirait :
spec:
backoffLimit: 3
podFailurePolicy:
rules:
- action: FailJob # code 2 : erreur de SQL, réessayer ne sert à rien
onExitCodes:
containerName: migration
operator: In
values: [2]
- action: Ignore # pod évincé (nœud vidé) : ne compte pas comme un échec
onPodConditions:
- type: DisruptionTarget
template:
spec:
restartPolicy: Never
# ... le reste du gabarit ...Une erreur de SQL arrête tout de suite le Job, avec un message clair ; une éviction pendant la maintenance d'un nœud relance la migration sans entamer le budget de réessais ; tout le reste (base pas encore prête, réseau) est réessayé normalement. Le code de sortie est un contrat entre le programme et le Job : il faut que le programme le respecte.
La purge nocturne
apiVersion: batch/v1
kind: CronJob
metadata:
name: purge-signalements
spec:
schedule: "30 3 * * *"
timeZone: Europe/Paris
concurrencyPolicy: Forbid
startingDeadlineSeconds: 3600
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 3
jobTemplate:
spec:
backoffLimit: 2
activeDeadlineSeconds: 900
ttlSecondsAfterFinished: 604800
template:
spec:
restartPolicy: Never
securityContext:
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
containers:
- name: purge
image: ghcr.io/lyneko-formation/signalements:1.2.0
command:
- python
- -c
- |
import os, psycopg
with psycopg.connect(os.environ["DATABASE_URL"], connect_timeout=5) as cnx:
n = cnx.execute("DELETE FROM signalements"
" WHERE cree_le < now() - interval '2 years'").rowcount
print(f"{n} signalement(s) supprimé(s)")
envFrom:
- secretRef:
name: signalements
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
memory: 128Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]"30 3 * * *"avectimeZone: Europe/Paris: tous les jours à 3 h 30, heure de Paris. Évitez les heures entre 2 h et 3 h : lors des changements d'heure, elles n'existent pas (fin mars) ou existent deux fois (fin octobre), et la documentation de Kubernetes ne décrit pas le comportement du contrôleur dans ces cas.concurrencyPolicy: Forbid: si une purge dure plus de vingt-quatre heures (ce qui serait déjà un incident), la suivante est sautée plutôt que de se superposer.startingDeadlineSeconds: 3600: une purge peut partir jusqu'à une heure en retard, pas plus.failedJobsHistoryLimit: 3: trois échecs consécutifs restent visibles, pour distinguer une panne passagère d'une panne qui dure.- La purge est idempotente : la même requête relancée ne supprime rien de plus.
Pour tester sans attendre 3 h 30, créez un Job à partir du CronJob :
$ kubectl create job purge-essai --from=cronjob/purge-signalements -n signalements
$ kubectl logs job/purge-essai -n signalements
$ kubectl get cronjob purge-signalements -n signalements
kubectl create job --from=cronjob/... copie le jobTemplate dans un Job lancé immédiatement. kubectl get cronjob montre le calendrier, le fuseau, l'état de suspension, le nombre de Jobs actifs et la date de la dernière planification.
Tip
Les générateurs de kubectl ont leurs propres valeurs par défaut. Sur ce poste, kubectl create job essai --image=busybox:1.37 --dry-run=client -o yaml -- echo bonjour produit un pod avec restartPolicy: Never, alors que kubectl create cronjob purge --image=busybox:1.37 --schedule="30 3 * * *" --dry-run=client -o yaml -- echo purge produit restartPolicy: OnFailure. Un manifeste écrit à la main dit ce que vous voulez ; un manifeste généré dit ce que le générateur a choisi.
Un DaemonSet de supervision
L'agent qui expose les métriques de chaque nœud, Prometheus Node Exporter, est un DaemonSet typique :
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: node-exporter
namespace: supervision
spec:
selector:
matchLabels:
app.kubernetes.io/name: node-exporter
updateStrategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 1
template:
metadata:
labels:
app.kubernetes.io/name: node-exporter
spec:
hostNetwork: true
hostPID: true
tolerations:
- operator: Exists # aussi sur les nœuds du plan de contrôle
containers:
- name: node-exporter
image: quay.io/prometheus/node-exporter:v1.12.1
args:
- --path.rootfs=/host
ports:
- name: metrics
containerPort: 9100
resources:
requests:
cpu: 50m
memory: 50Mi
limits:
memory: 100Mi
volumeMounts:
- name: racine
mountPath: /host
readOnly: true
mountPropagation: HostToContainer
volumes:
- name: racine
hostPath:
path: /Il illustre ce qui fait d'un DaemonSet un objet à part, et dangereux : hostNetwork et hostPID donnent au pod le réseau et la table des processus du nœud, et le volume hostPath monte toute la racine du nœud, en lecture seule. C'est nécessaire pour mesurer le nœud, et c'est exactement ce que le profil de sécurité restricted interdit : ce DaemonSet vit donc dans son propre namespace, supervision, avec une politique de sécurité adaptée, et jamais dans celui d'une application. La tolérance operator: Exists sans clé tolère toutes les teintes, y compris celle du plan de contrôle, pour mesurer aussi ces nœuds.
$ kubectl create namespace supervision
$ kubectl apply -f node-exporter.yaml
$ kubectl get daemonset node-exporter -n supervision
$ kubectl get pods -n supervision -o wide
Sur le cluster kind formation, les colonnes DESIRED, CURRENT et READY de get daemonset doivent valoir 3 : un pod par nœud, plan de contrôle compris grâce à la tolérance. L'option -o wide de get pods ajoute la colonne NODE, qui montre un pod sur chacun des trois nœuds. Le cours Prometheus explique comment ces métriques sont collectées.
Un StatefulSet pour PostgreSQL de formation
La base de formation de la leçon suivante est un StatefulSet à une réplique, avec son Service headless. Le manifeste complet est à la leçon 11 ; retenez-en ici les trois éléments propres au StatefulSet :
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: postgresql
spec:
serviceName: postgresql # le Service headless, créé à part
replicas: 1
# ... sélecteur et gabarit de pod ...
volumeClaimTemplates:
- metadata:
name: donnees
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 1GiUne fois appliqué, observez l'identité stable :
$ kubectl get pods,pvc -n signalements -l app.kubernetes.io/name=postgresql
$ kubectl delete pod postgresql-0 -n signalements
$ kubectl get pods -n signalements -l app.kubernetes.io/name=postgresql --watch
Le pod s'appelle postgresql-0, et une PersistentVolumeClaim donnees-postgresql-0 est apparue (les PVC n'héritent pas des étiquettes du gabarit : kubectl get pvc -n signalements les montre toutes). Après la suppression, le contrôleur recrée un pod du même nom, rattaché à la même PVC : les données sont toujours là. Supprimez ensuite le StatefulSet, puis listez les PVC : donnees-postgresql-0 est toujours présente, conformément à la politique de rétention par défaut. Elle se supprime à la main.
Sous le capot
Comment un Job sait qu'il a fini. Le contrôleur des Jobs, dans le kube-controller-manager, observe les pods qui portent l'étiquette du Job. Pour ne pas perdre le compte d'un pod terminé puis supprimé (par le ramasse-miettes, ou par un humain), il pose sur chaque pod un finalizer de suivi : le pod ne peut pas disparaître de l'API tant que le contrôleur ne l'a pas comptabilisé dans le statut du Job, puis retiré ce finalizer. C'est ce mécanisme, stable depuis Kubernetes 1.26, qui rend les compteurs succeeded et failed fiables, même avec des milliers de pods. Le statut du Job porte ensuite des conditions (Complete, Failed, avec une raison) : c'est elles que kubectl wait --for=condition=complete attend.
Comment un CronJob crée ses Jobs. Le contrôleur des CronJobs se réveille régulièrement (la documentation indique un passage toutes les 10 secondes, d'où le conseil de ne pas régler startingDeadlineSeconds sous cette valeur), calcule les occurrences dues depuis la dernière exécution, et crée un Job dont le nom est celui du CronJob suivi d'un suffixe dérivé de l'heure planifiée. Ce nom déterministe est ce qui empêche, dans le cas normal, de créer deux fois le même Job : un second essai de création se heurte à un nom déjà pris. Les cas où deux Jobs apparaissent malgré tout tiennent aux délais et aux redémarrages du contrôleur, d'où l'exigence d'idempotence.
Comment un DaemonSet place ses pods. Depuis longtemps, le contrôleur des DaemonSets ne choisit plus lui-même le nœud : il crée chaque pod avec une affinité de nœud qui désigne un nœud précis, et c'est le planificateur ordinaire qui le place. Le pod bénéficie ainsi des mêmes règles que les autres (ressources disponibles, priorités), et les tolérances ajoutées automatiquement lui ouvrent les nœuds pas encore prêts.
Comment un StatefulSet retrouve son disque. Le nom de la PVC est calculé : <nom du gabarit de volume>-<nom du StatefulSet>-<numéro>. Quand le pod postgresql-0 est recréé, le contrôleur ne crée pas de nouvelle PVC, il réutilise celle qui porte ce nom. Le volume, lui, est attaché par le pilote de stockage au nœud qui reçoit le pod. Un volume ReadWriteOnce sur un stockage bloc ne peut être attaché qu'à un nœud à la fois, et souvent dans une seule zone de disponibilité : le planificateur en tient compte, et le pod reste Pending si aucun nœud de la bonne zone n'a de place. C'est la contrainte physique qui se cache derrière « le disque suit le pod ».
Pièges courants
Modifier un Job pour une nouvelle version. Le gabarit de pod d'un Job ne se modifie pas : kubectl apply refuse le changement d'image. Créez un nouveau Job, nommé d'après la version, ou supprimez l'ancien d'abord. Les outils de déploiement (hooks Argo CD, Helm) le font pour vous.
restartPolicy: OnFailure pendant le débogage. Le conteneur est relancé dans le même pod, et quand la limite est atteinte, le pod est supprimé avec ses journaux. Utilisez Never tant que la tâche n'est pas au point.
Un Job sans ttlSecondsAfterFinished. Les Jobs terminés s'accumulent, avec leurs pods. Sur un cluster qui lance des tâches toutes les minutes, cela finit par peser sur l'API. Les CronJobs ont leurs limites d'historique ; les Jobs créés à la main ou par un pipeline ont besoin du TTL.
Un CronJob sans timeZone. Il tourne à l'heure du contrôleur, souvent UTC : une heure ou deux d'écart avec ce que tout le monde croit, qui change deux fois par an.
concurrencyPolicy: Allow sur une tâche longue. Une sauvegarde qui dure plus longtemps que son intervalle finit par tourner en plusieurs exemplaires simultanés, qui se gênent et allongent encore la durée. Forbid pour les tâches qui ne doivent pas se superposer.
« Trois répliques de PostgreSQL, donc de la haute disponibilité. » Non : trois bases indépendantes, dont deux vides. Un StatefulSet ne réplique rien.
Le mauvais point de montage pour PostgreSQL 18. L'image officielle a changé en version 18 : la variable PGDATA vaut désormais /var/lib/postgresql/18/docker, et le volume déclaré est /var/lib/postgresql. Un volume monté sur l'ancien chemin /var/lib/postgresql/data ne contient plus les données : la base semble fonctionner, puis tout disparaît au premier redémarrage du pod. La documentation de l'image le signale ; montez le volume sur /var/lib/postgresql.
Une mise à jour de StatefulSet bloquée. Le nouveau pod ne devient jamais prêt, la mise à jour s'arrête, et revenir à l'ancien modèle ne débloque rien. Il faut aussi supprimer le pod déjà créé avec le mauvais modèle.
Sécurité
- Un Job hérite des secrets de l'application. La migration et la purge lisent
DATABASE_URL: elles ont les mêmes droits sur la base que l'API. En production, donnez à la migration un compte de base qui peut modifier le schéma, et à l'API et à la purge des comptes qui ne le peuvent pas (le cours Scaleway en pratique décrit cette séparation des rôles). - Un CronJob est un point d'exécution permanent. Quiconque peut modifier un CronJob peut faire exécuter le code de son choix, chaque nuit, avec les secrets du namespace. Les droits
createetupdatesurcronjobsetjobssont aussi sensibles que ceux surdeployments. - Un DaemonSet est souvent l'objet le plus privilégié d'un cluster.
hostNetwork,hostPID,hostPathet les tolérances universelles en font un accès direct à chaque nœud. Limitez qui peut en créer, gardez-les dans des namespaces dédiés, et lisez leurs manifestes avant de les installer depuis un chart tiers. - Les volumes d'un StatefulSet survivent à leur application. Une PVC orpheline contient des données, parfois personnelles : supprimez-la explicitement quand l'application disparaît, et inventoriez les PVC sans propriétaire.
En production
- Les migrations passent par l'outil de déploiement. Un Job lancé à la main avant chaque
applyfinit par être oublié. Le hookSyncd'Argo CD (GitOps avec Argo CD, leçon 5) ou un hook Helm ordonnent migration et déploiement à chaque fois. Et une migration se conçoit compatible avec la version précédente de l'application, qui tourne encore pendant la mise à jour progressive. - On ne lance pas la migration au démarrage de chaque pod. Ce serait la solution la plus simple, et elle est fausse : deux répliques qui démarrent ensemble lancent deux migrations concurrentes, et une migration lente retarde le démarrage au point de faire échouer la sonde de démarrage. Une seule exécution, dans un Job, avant le déploiement.
- Les tâches planifiées se surveillent. Une purge qui échoue en silence pendant trois mois est une non-conformité découverte trop tard. Alertez sur l'échec d'un Job, et sur l'absence de succès récent d'un CronJob (le statut
lastSuccessfulTimedu CronJob le permet), ce qui attrape aussi un CronJob suspendu et oublié. - Les DaemonSets se dimensionnent pour le plus petit nœud. Leur consommation se multiplie par le nombre de nœuds et se retire de la capacité de chacun. Sur Kapsule, les agents du fournisseur (réseau, stockage, supervision) en occupent déjà une partie.
- Les bases de données vont dans un service managé ou un opérateur. Chez Lyneko, les applications du cluster
lyneko-appsutilisent les bases PostgreSQL managées de Scaleway ; un StatefulSet de PostgreSQL n'y sert que pour des environnements jetables.
Exercices
1. Choisir le contrôleur (niveau 100). Pour chaque besoin, indiquez le contrôleur adapté et justifiez : (a) l'API de Signalements ; (b) l'import ponctuel de 50 000 signalements historiques depuis un fichier CSV ; (c) un collecteur de journaux qui lit les fichiers de chaque nœud ; (d) l'envoi chaque lundi à 8 h d'un rapport aux mairies ; (e) un cluster Redis de trois nœuds dont chaque membre doit retrouver ses données après un redémarrage.
Solution
(a) Deployment : des répliques interchangeables et sans état, qui tournent en permanence. (b) Job : une tâche qui doit se terminer ; si l'import est long, un Job indexé (completionMode: Indexed) peut découper le fichier en parts traitées en parallèle. (c) DaemonSet : exactement un pod par nœud, y compris sur les nœuds ajoutés plus tard. (d) CronJob, avec schedule: "0 8 * * 1", timeZone: Europe/Paris et concurrencyPolicy: Forbid. (e) StatefulSet : identité et volume stables par membre ; mais c'est Redis qui assure la réplication et la reconstitution du cluster, pas le StatefulSet, et un opérateur ou un service managé reste préférable en production.
2. Lire un échec (niveau 200). Le Job migration-1-3-0 est en échec. kubectl get pods -l job-name=migration-1-3-0 montre quatre pods en Error. Les journaux du premier contiennent connection refused, ceux des trois suivants column "priorite" of relation "signalements" already exists. Que s'est-il passé, et que changez-vous dans la migration et dans le Job ?
Solution
La première tentative a échoué parce que la base n'était pas encore prête : un échec passager, normal, que le réessai devait absorber. Mais la migration n'est pas idempotente : elle ajoute la colonne sans IF NOT EXISTS, et une tentative a dû l'ajouter avant d'échouer plus loin (ou la colonne existait déjà d'une exécution précédente). Les réessais suivants échouent donc tous, pour une raison qui ne disparaîtra pas. Deux corrections : rendre la migration idempotente (ADD COLUMN IF NOT EXISTS, ou un outil de migration qui tient le registre des migrations appliquées) ; et faire sortir le programme avec un code distinct sur une erreur de SQL, associé à une règle FailJob dans podFailurePolicy, pour que ce type d'erreur arrête le Job tout de suite au lieu d'épuiser les réessais.
3. Le CronJob silencieux (niveau 200). Le CronJob purge-signalements n'a rien exécuté depuis trois semaines, sans aucun Job en échec. Donnez trois causes possibles et la commande qui permet de vérifier chacune.
Solution
(1) Il est suspendu : kubectl get cronjob purge-signalements -n signalements montre la colonne SUSPEND à True, ou kubectl get cronjob purge-signalements -o jsonpath='{.spec.suspend}'. (2) Le contrôleur a manqué plus de 100 occurrences sans startingDeadlineSeconds, par exemple après une longue interruption, et refuse de rattraper : l'événement too many missed start times apparaît dans kubectl describe cronjob purge-signalements -n signalements ou kubectl events --for cronjob/purge-signalements -n signalements. (3) Les Jobs sont créés mais échouent et sont supprimés avant que l'on regarde, ou ne sont jamais créés parce qu'un quota du namespace l'empêche : describe montre les événements de création, et kubectl get jobs -n signalements la liste des Jobs encore présents. Dans tous les cas, l'alerte sur l'absence de succès récent (lastSuccessfulTime) aurait prévenu bien avant trois semaines.
4. Le DaemonSet partiel (niveau 200). Sur un cluster de cinq nœuds, kubectl get daemonset node-exporter -n supervision affiche DESIRED 3. Deux nœuds ont la teinte dedie=gpu:NoSchedule. Expliquez, puis corrigez sans retirer la teinte.
Solution
Un DaemonSet ne place ses pods que sur les nœuds dont ses pods tolèrent les teintes. Les tolérances ajoutées automatiquement couvrent des teintes système (not-ready, unschedulable...), pas les teintes que vous posez. Les deux nœuds teintés sont donc exclus, et DESIRED vaut 3. Correction : ajouter au gabarit une tolérance key: dedie, operator: Equal, value: gpu, effect: NoSchedule (ou, comme dans la leçon, operator: Exists sans clé, qui tolère tout, en connaissance de cause).
Récapitulatif
- Un Job veut un nombre de terminaisons réussies :
backoffLimit(6 par défaut) compte les échecs, réessayés avec un délai de 10 s, 20 s, 40 s plafonné à six minutes ;activeDeadlineSecondsl'emporte ;ttlSecondsAfterFinishednettoie. restartPolicy: Nevercrée un pod par tentative et garde les journaux ;OnFailurerelance dans le même pod.- La politique d'échec des pods (stable depuis 1.31) arrête le Job sur un échec définitif (
FailJob) et ignore les évictions (IgnoresurDisruptionTarget). - Un CronJob crée des Jobs selon un calendrier ; toujours
timeZone, presque toujoursconcurrencyPolicy: Forbid, unstartingDeadlineSeconds, et des tâches idempotentes. - Un DaemonSet met un pod sur chaque nœud, pour les agents de nœud ; c'est souvent l'objet le plus privilégié du cluster.
- Un StatefulSet donne à chaque pod un numéro, un nom DNS (via un Service headless) et un volume stables ; il ne réplique, ne sauvegarde et, par défaut, ne supprime rien.
- En production : migrations par l'outil de déploiement, tâches planifiées surveillées, bases de données managées ou pilotées par un opérateur.
Pour aller plus loin
- La page Jobs de la documentation, sections Indexed Jobs, Success policy et Pod replacement policy, pour les traitements parallèles.
- La tâche Handling retriable and non-retriable pod failures with Pod failure policy, qui déroule des exemples complets.
- La documentation de CloudNativePG, pour voir ce qu'un opérateur ajoute à un StatefulSet : réplication, bascule, sauvegarde continue.
- La leçon suivante, qui assemble la base, la migration et l'application.
Sources
- Kubernetes, Jobs
- Kubernetes, Handling retriable and non-retriable pod failures with Pod failure policy
- Kubernetes, CronJob
- Kubernetes, DaemonSet
- Kubernetes, StatefulSets
- Kubernetes, référence des feature gates (JobPodFailurePolicy, CronJobTimeZone, StatefulSetAutoDeletePVC...)
- Docker Official Images, postgres : variable PGDATA et volume en version 18
- CloudNativePG, documentation
- Brendan Burns et al., Borg, Omega, and Kubernetes (ACM Queue, 2016)