0%
Pods, Services et Deployments

Pods et Services

Les trois ressources que vous écrirez tous les jours.

15-20 min

Pods, Services et Deployments

Un Pod a une adresse IP. Cette adresse change à chaque recréation du Pod, et un Pod est recréé souvent : à chaque déploiement, à chaque plantage, à chaque fois que l’ordonnanceur décide de le déplacer. Aucune application ne peut donc appeler une autre par son adresse. C’est le problème que résout cette leçon, et il est plus profond qu’il n’y paraît.

Les trois ressources qui suivent forment une chaîne dont chaque anneau a une raison d’être précise. Le Pod exécute. Le Deployment garantit qu’il existe le bon nombre de Pods de la bonne version, et sait passer d’une version à l’autre sans coupure. Le Service fournit un nom stable et une adresse stable devant un ensemble de Pods dont l’identité varie en permanence.

Ce qui relie ces trois objets n’est pas une référence directe, et c’est le point qui déroute au début : ils ne se connaissent pas par leur nom, mais par des labels. Un Service ne sait pas quels Pods il expose ; il déclare un critère de sélection, et le système lui associe en continu tous les Pods qui y répondent. Ce couplage indirect est ce qui permet à un Pod de disparaître et à son remplaçant d’être servi immédiatement, sans aucune reconfiguration.

Comprendre ce mécanisme de labels, c’est comprendre 80 % de Kubernetes. Un sélecteur mal écrit produit exactement le symptôme le plus opaque du débutant : un Service qui répond, mais avec une erreur de connexion, parce qu’il ne pointe sur rien.

Le Deployment plutôt que le Pod

On ne crée pas un Pod à la main en dehors d’un test jetable. Un Pod seul n’a aucune résilience : s’il meurt, il reste mort. On déclare un Deployment, qui prend cette responsabilité.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: web
  labels:
    app: web
spec:
  replicas: 3
  selector:
    matchLabels:
      app: web            # doit correspondre aux labels du template ci-dessous
  template:               # le moule à partir duquel les Pods sont fabriqués
    metadata:
      labels:
        app: web          # les labels que porteront les Pods créés
    spec:
      containers:
        - name: nginx
          image: nginx:1.27-alpine
          ports:
            - containerPort: 80

Deux pièges dans ce fichier, tous les deux liés au même endroit.

Le premier : spec.selector.matchLabels et spec.template.metadata.labels doivent correspondre. Le selector dit « les Pods dont je suis responsable ont ce label », le template dit « les Pods que je fabrique portent ce label ». S’ils divergent, l’API server rejette le manifeste, ce qui est une bonne nouvelle : l’erreur est détectée à l’écriture.

Le second : spec.selector est immuable après création. Pour le modifier, il faut supprimer et recréer le Deployment. Choisissez donc vos labels avec un peu d’attention.

kubectl apply -f deployment.yaml
kubectl get pods -l app=web -w   # observer la montée en charge en direct
kubectl get deployment web       # répliques prêtes sur répliques voulues

L’intermédiaire ReplicaSet

Un Deployment ne pilote pas les Pods directement. Il crée un ReplicaSet, et c’est ce ReplicaSet qui maintient le compte de Pods. Cette couche paraît gratuite jusqu’à ce qu’on comprenne à quoi elle sert :

kubectl get replicasets
# NAME             DESIRED   CURRENT   READY   AGE
# web-7d4b8c9f5    3         3         3       2m

Chaque version du template produit un ReplicaSet distinct. Lors d’une mise à jour, le Deployment fait décroître l’ancien ReplicaSet et croître le nouveau. L’ancien est conservé à zéro réplique — c’est exactement ce qui rend le retour arrière possible et instantané : la définition précédente est encore là, il suffit de la remonter.

Vérifiez la hiérarchie : supprimez un Pod à la main, il est recréé en quelques secondes par le ReplicaSet, qui a constaté que le compte ne correspondait plus.

kubectl delete pod web-7d4b8c9f5-xk2mp
kubectl get pods -l app=web     # un nouveau Pod, avec un nouveau suffixe

Les labels et les sélecteurs

Un label est une paire clé-valeur libre, apposée sur n’importe quel objet. Ce n’est pas de la documentation : c’est le mécanisme de liaison du système.

metadata:
  labels:
    app: web
    tier: frontend
    version: v1
    environnement: production

kubectl sait interroger sur ces labels, ce qui remplace avantageusement une lecture manuelle de listes :

kubectl get pods -l app=web                    # égalité
kubectl get pods -l app=web,tier=frontend      # conjonction
kubectl get pods -l 'app!=web'                 # négation
kubectl get pods -l 'environnement in (staging,production)'   # ensemble
kubectl get pods -l version                    # présence de la clé
kubectl get pods --show-labels                 # afficher les labels

Le conseil qui évite le plus de désordre : réservez les labels du sélecteur à l’identité de l’application (app, tier) et mettez tout ce qui varie souvent — un numéro de version, un identifiant de commit — dans des labels hors sélecteur ou dans des annotations. Un sélecteur qui contient version: v1 casse la continuité du Service à chaque livraison.

Le Service : une adresse qui ne bouge pas

Un Service crée une adresse IP virtuelle et un nom DNS stables, et répartit le trafic vers tous les Pods qui correspondent à son sélecteur.

ClusterIP, le cas par défaut

apiVersion: v1
kind: Service
metadata:
  name: web
spec:
  type: ClusterIP         # valeur par défaut : joignable depuis l'intérieur du cluster
  selector:
    app: web              # tous les Pods portant app=web deviennent des cibles
  ports:
    - name: http
      port: 80            # port du Service
      targetPort: 80      # port écouté dans le conteneur
      protocol: TCP

La distinction port / targetPort est constante et vaut d’être fixée : port est ce que les clients appellent, targetPort est ce que le conteneur écoute. Rien n’oblige à ce qu’ils soient égaux, et ils le sont rarement en pratique.

Le Service est joignable par son nom depuis n’importe quel Pod du cluster, grâce au DNS interne :

web                        # même namespace
web.atelier                # namespace explicite
web.atelier.svc.cluster.local   # nom complet

Vérifiez-le depuis un Pod éphémère, c’est le test qui lève le doute le plus vite :

kubectl run debug --image=busybox:1.36 --rm -it --restart=Never -- \
  wget -qO- http://web:80

Le diagnostic essentiel : les endpoints

Un Service qui ne trouve aucun Pod ne renvoie pas d’erreur explicite. Il existe, il a une adresse, et il refuse simplement les connexions. La cause est presque toujours un sélecteur qui ne correspond à rien.

kubectl get endpoints web   # les Pods réellement derrière le Service

# Liste vide ? Le sélecteur ne correspond à aucun Pod. Comparez les deux :
kubectl get service web -o jsonpath='{.spec.selector}'
kubectl get pods --show-labels

Retenez ce réflexe : connexion refusée sur un Service, on regarde les endpoints avant tout le reste. Cela transforme une demi-heure de tâtonnement en trente secondes.

NodePort

NodePort ouvre le même port sur tous les nœuds du cluster, dans la plage 30000-32767.

apiVersion: v1
kind: Service
metadata:
  name: web-nodeport
spec:
  type: NodePort
  selector:
    app: web
  ports:
    - port: 80
      targetPort: 80
      nodePort: 30080     # facultatif ; sinon Kubernetes en attribue un
# minikube donne l'URL directement joignable
minikube service web-nodeport --url

C’est pratique en développement et à éviter en production : le port est non standard, il n’y a ni TLS ni nom de domaine, et l’exposition porte sur tous les nœuds à la fois. Pour du trafic HTTP réel, la bonne réponse est un Ingress, vu en leçon 6.

LoadBalancer, et sa dépendance au fournisseur

apiVersion: v1
kind: Service
metadata:
  name: web-lb
spec:
  type: LoadBalancer
  selector:
    app: web
  ports:
    - port: 80
      targetPort: 80

Soyons précis, parce que ce point trompe beaucoup de monde : Kubernetes ne sait pas créer d’équilibreur de charge. Il émet une demande, et attend qu’un contrôleur externe la satisfasse. Sur AWS, Google Cloud ou Azure, ce contrôleur existe et provisionne une ressource facturée. Sur un cluster local ou une installation sans intégration cloud, personne ne répond, et le Service reste indéfiniment avec une adresse externe <pending>.

kubectl get service web-lb
# EXTERNAL-IP reste <pending> sans contrôleur : ce n'est pas un bug

# Avec minikube, une commande simule le comportement (à laisser tourner)
minikube tunnel
TypePortéeUsage réaliste
ClusterIPinterne au clustercommunication entre services : le cas courant
NodePortport ouvert sur chaque nœuddéveloppement, accès de dépannage
LoadBalanceradresse externe fournie par le cloudpoint d’entrée public, dépend du fournisseur

Les mises à jour progressives

C’est le service que Kubernetes rend le mieux, et il est presque gratuit. Changez l’image d’un Deployment : les Pods sont remplacés par lots, en maintenant le service disponible pendant toute la transition.

# Fragment à insérer dans le spec du Deployment, à côté de replicas.
spec:
  replicas: 4
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 1     # au plus 1 Pod indisponible pendant la bascule
      maxSurge: 1           # au plus 1 Pod en plus des 4 voulues

Avec ces valeurs, il y a toujours entre 3 et 5 Pods pendant la transition, dont au moins 3 disponibles. maxUnavailable: 0 garantit zéro perte de capacité, au prix d’un déploiement plus lent puisqu’il faut d’abord créer avant de détruire.

kubectl apply -f deployment.yaml    # après modification de l'image du manifeste

# Variante impérative, utile pour un correctif urgent
kubectl set image deployment/web nginx=nginx:1.27.1-alpine

kubectl rollout status deployment/web   # se termine quand la bascule est finie

Le retour arrière

Puisque les anciens ReplicaSets sont conservés, revenir en arrière ne demande aucune reconstruction :

kubectl rollout history deployment/web              # historique des révisions
kubectl rollout undo deployment/web                 # revenir à la précédente
kubectl rollout undo deployment/web --to-revision=2 # à une révision précise

# Suspendre puis reprendre une bascule en cours, si les erreurs augmentent
kubectl rollout pause deployment/web
kubectl rollout resume deployment/web

Une remarque honnête sur l’historique : par défaut il ne montre pas ce qui a changé entre deux révisions, seulement leur numéro. Pour rendre l’historique lisible, ajoutez une annotation à chaque livraison :

metadata:
  name: web
  annotations:
    kubernetes.io/change-cause: "Passage à nginx 1.27.1 — correctif CVE"

La mise à l’échelle

kubectl scale deployment/web --replicas=6   # impératif
kubectl apply -f deployment.yaml            # déclaratif, après édition du fichier

Préférez le déclaratif. Un kubectl scale est écrasé au prochain apply du manifeste, ce qui produit une régression silencieuse et difficile à expliquer.

Les sondes : liveness et readiness

Sans sonde, Kubernetes considère un conteneur comme sain dès que son processus principal tourne. C’est une approximation grossière : une application peut avoir démarré son serveur HTTP sans être capable de répondre — connexion à la base non établie, cache non chargé. Le Service enverra alors du trafic vers un Pod qui répondra par des erreurs.

Deux sondes répondent à deux questions distinctes, et les confondre a des conséquences sérieuses.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
spec:
  replicas: 3
  selector:
    matchLabels:
      app: api
  template:
    metadata:
      labels:
        app: api
    spec:
      containers:
        - name: api
          image: mon-api:1.0.0
          ports:
            - containerPort: 8080

          # « Ce Pod peut-il recevoir du trafic maintenant ? »
          # En échec : retiré des endpoints du Service, mais NON redémarré.
          readinessProbe:
            httpGet:
              path: /ready
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 5
            failureThreshold: 3

          # « Ce processus est-il définitivement bloqué ? »
          # En échec : le conteneur est TUÉ et redémarré.
          livenessProbe:
            httpGet:
              path: /healthz
              port: 8080
            initialDelaySeconds: 15
            periodSeconds: 20
            failureThreshold: 3

          # « Le démarrage est-il terminé ? »
          # Tant qu'elle échoue, la livenessProbe est suspendue.
          startupProbe:
            httpGet:
              path: /healthz
              port: 8080
            periodSeconds: 5
            failureThreshold: 30

Les erreurs classiques, à connaître avant de les commettre :

  • Faire pointer la liveness sur un endpoint qui vérifie la base de données. Si la base ralentit, tous les Pods échouent la sonde, sont tués en même temps, et une lenteur passagère devient une panne totale. La liveness ne doit tester que le processus lui-même ; les dépendances externes relèvent de la readiness.
  • Un initialDelaySeconds trop court sur la liveness. L’application est tuée avant d’avoir fini de démarrer, en boucle : c’est le CrashLoopBackOff inexplicable. La startupProbe est la réponse propre pour les démarrages lents.
  • Ne déclarer aucune readiness. Le trafic arrive sur un Pod qui n’est pas prêt, et chaque déploiement provoque une vague d’erreurs 502.

Trois formes de sondes existent, selon ce que l’application expose :

# HTTP : tout code de 200 à 399 est un succès
readinessProbe:
  httpGet: { path: /ready, port: 8080 }
---
# TCP : la connexion s'ouvre-t-elle ?
readinessProbe:
  tcpSocket: { port: 5432 }
---
# Commande : code de sortie 0 = succès
livenessProbe:
  exec: { command: ["sh", "-c", "test -f /tmp/sain"] }

Diagnostiquer un Pod

Les commandes à connaître par cœur, dans l’ordre où on les utilise :

# 1. Vue d'ensemble : STATUS et RESTARTS racontent l'essentiel
kubectl get pods -o wide

# 2. La section Events en fin de sortie donne la cause réelle
kubectl describe pod web-7d4b8c9f5-xk2mp

# 3. Les logs applicatifs
kubectl logs web-7d4b8c9f5-xk2mp
kubectl logs -f deployment/web              # suivi en direct
kubectl logs web-7d4b8c9f5-xk2mp --previous # logs du conteneur AVANT son crash

# 4. Entrer dans le conteneur
kubectl exec -it web-7d4b8c9f5-xk2mp -- sh

# 5. Atteindre le Pod depuis le poste, sans l'exposer
kubectl port-forward pod/web-7d4b8c9f5-xk2mp 8080:80

L’option --previous est celle qu’on découvre trop tard. Sur un Pod en CrashLoopBackOff, kubectl logs montre le conteneur courant, souvent vide parce qu’il vient de redémarrer. --previous montre l’instance qui a réellement planté, et donc le message d’erreur.

Les statuts les plus fréquents et leur signification :

StatutCause habituelle
Pendingl’ordonnanceur ne trouve pas de nœud : ressources insuffisantes ou volume non lié
ImagePullBackOffimage introuvable, tag erroné, ou registre privé sans identifiants
CrashLoopBackOffle conteneur démarre puis s’arrête en boucle : voir logs --previous
Running mais 0/1 READYla readinessProbe échoue
OOMKilleddépassement de la limite mémoire (voir leçon 6)

À retenir

  • On déclare un Deployment, jamais un Pod à la main. Il crée un ReplicaSet par version du template, et c’est la conservation des anciens ReplicaSets qui rend kubectl rollout undo instantané.
  • Les objets se lient par labels, pas par noms. Gardez les labels du sélecteur stables (app, tier) et sortez tout ce qui varie souvent — versions, commits — du sélecteur, qui est de toute façon immuable.
  • Un Service injoignable se diagnostique par kubectl get endpoints : une liste vide signifie un sélecteur qui ne correspond à aucun Pod.
  • ClusterIP pour l’interne, NodePort pour du développement, LoadBalancer uniquement là où un contrôleur cloud existe — sinon l’adresse externe reste <pending> indéfiniment.
  • readiness retire du trafic sans redémarrer, liveness tue et redémarre. Ne mettez jamais de dépendance externe dans une liveness : une lenteur de base de données deviendrait une panne générale.

Conseil : lancez kubectl get pods -w dans un terminal, puis déclenchez une mise à jour dans un autre. Voir les Pods apparaître et disparaître par lots ancre le mécanisme mieux que n’importe quelle explication.

Commentaires

Les commentaires sont alimentés par GitHub Discussions

Connectez-vous avec GitHub pour participer à la discussion

Lien copié !