0%
Déploiement d'applications complexes

Applications complexes

Assembler les briques : un point d'entrée, des quotas, de l'élasticité.

15-20 min

Déploiement d’applications complexes

Vous savez maintenant écrire un Deployment, l’exposer, lui injecter sa configuration et lui donner un volume. Une application réelle, elle, se compose de plusieurs services qui doivent se parler, être joignables depuis Internet sous un seul nom de domaine, ne pas s’affamer mutuellement, et absorber une hausse de trafic sans intervention humaine.

Trois problèmes nouveaux apparaissent à cette échelle. Le premier est le point d’entrée : créer un Service de type LoadBalancer par microservice signifie autant d’adresses publiques facturées, sans routage par chemin ni certificat mutualisé. Le deuxième est le partage des ressources : sans contrainte déclarée, un service qui fuit en mémoire peut faire tomber des Pods voisins sur le même nœud. Le troisième est l’élasticité : dimensionner pour le pic revient à payer le pic en permanence.

Le quatrième problème n’est pas technique mais organisationnel, et c’est souvent celui qui pique le plus vite. Une application de cinq services représente une trentaine de manifestes, à dupliquer pour chaque environnement en changeant trois valeurs. Le copier-coller devient alors une source majeure de pannes : quelqu’un modifie le fichier de préproduction et oublie celui de production.

Cette leçon assemble les briques sur un exemple complet, puis répond à ce dernier point avec un aperçu de Helm. L’objectif n’est pas d’ajouter des concepts, mais de montrer comment ceux que vous connaissez tiennent ensemble.

L’application de référence

Trois composants dans un namespace dédié : une base de données PostgreSQL, une API, une interface web. Le namespace n’est pas décoratif — il porte les quotas, les règles d’accès, et permet de tout supprimer d’un geste.

apiVersion: v1
kind: Namespace
metadata:
  name: boutique
  labels:
    environnement: production

La base de données

Un StatefulSet avec son Service headless, exactement selon le modèle de la leçon précédente : serviceName: postgres, clusterIP: None, un volumeClaimTemplates de 10 Gio. Deux ajouts propres à un déploiement complet, à insérer dans le conteneur :

          # Une réservation généreuse : une base ordonnancée sur un nœud
          # déjà chargé sera lente sans qu'aucune alerte ne se déclenche.
          resources:
            requests:
              cpu: "250m"
              memory: "512Mi"
            limits:
              memory: "1Gi"
          # pg_isready évite que l'API reçoive du trafic avant l'initialisation.
          readinessProbe:
            exec:
              command: ["pg_isready", "-U", "boutique", "-d", "boutique"]
            initialDelaySeconds: 10
            periodSeconds: 5

L’API

C’est le manifeste le plus instructif : il rassemble configuration, secret, sondes et ressources.

apiVersion: v1
kind: ConfigMap
metadata:
  name: api-config
  namespace: boutique
data:
  APP_ENV: "production"
  LOG_LEVEL: "info"
  # Le nom du Service suffit : le DNS interne le résout dans le namespace.
  DB_HOST: "postgres"
  DB_PORT: "5432"
  DB_NAME: "boutique"
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
  namespace: boutique
  labels:
    app: api
    tier: backend
spec:
  replicas: 3
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 0     # la capacité ne baisse jamais pendant la bascule
      maxSurge: 1
  selector:
    matchLabels:
      app: api
  template:
    metadata:
      labels:
        app: api
        tier: backend
    spec:
      containers:
        - name: api
          image: exemple/boutique-api:1.4.0
          ports:
            - containerPort: 8080
              name: http
          envFrom:
            - configMapRef:
                name: api-config
            - secretRef:
                name: db-credentials
          resources:
            requests:
              cpu: "100m"
              memory: "128Mi"
            limits:
              memory: "256Mi"
          readinessProbe:
            httpGet:
              path: /ready
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 5
          livenessProbe:
            httpGet:
              path: /healthz
              port: 8080
            initialDelaySeconds: 20
            periodSeconds: 20
---
apiVersion: v1
kind: Service
metadata:
  name: api
  namespace: boutique
spec:
  type: ClusterIP
  selector:
    app: api
  ports:
    - port: 8080
      targetPort: 8080
      name: http

L’interface web

Même structure : un Deployment à deux répliques et un Service ClusterIP sur le port 80. Un seul point réclame de l’attention, et il piège souvent :

          env:
            # Le navigateur n'est PAS dans le cluster : il ne résout pas
            # http://api:8080. L'URL doit être un chemin public de l'Ingress.
            - name: API_BASE_URL
              value: "/api"

Seul le code exécuté dans le cluster bénéficie du DNS interne. Le JavaScript du navigateur, lui, passe obligatoirement par le point d’entrée public — d’où la section suivante.

L’Ingress

Un Ingress est une règle de routage HTTP : un nom de domaine, des chemins, des Services cibles. Un seul point d’entrée dessert toute l’application.

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: boutique
  namespace: boutique
  annotations:
    # Réécrit /api/produits en /produits avant transmission.
    # Cette annotation est SPÉCIFIQUE à ingress-nginx.
    nginx.ingress.kubernetes.io/rewrite-target: /$2
spec:
  ingressClassName: nginx
  tls:
    - hosts:
        - boutique.example.com
      secretName: boutique-tls     # Secret de type kubernetes.io/tls
  rules:
    - host: boutique.example.com
      http:
        paths:
          - path: /api(/|$)(.*)
            pathType: ImplementationSpecific
            backend:
              service:
                name: api
                port:
                  number: 8080
          - path: /
            pathType: Prefix
            backend:
              service:
                name: web
                port:
                  number: 80

Le point à comprendre avant de déboguer pendant une heure : l’objet Ingress ne fait rien à lui seul. C’est une déclaration inerte tant qu’un contrôleur d’entrée ne l’a pas lue. Sans contrôleur installé, apply réussit et rien ne route.

minikube addons enable ingress
kubectl get pods -n ingress-nginx     # le contrôleur tourne-t-il ?
kubectl get ingress -n boutique       # ADDRESS vide = règle non prise en charge
minikube ip                           # l'adresse à mettre dans /etc/hosts

Deuxième réserve, plus structurelle : les annotations sont propres à chaque contrôleur. nginx.ingress.kubernetes.io/rewrite-target n’a aucun sens pour Traefik, HAProxy ou l’AWS Load Balancer Controller, qui ont leur propre préfixe et leur propre syntaxe. Seule la structure rules est normalisée ; réécriture, délais, limitation de débit et redirection HTTPS sont du dialecte. C’est cette divergence qui a motivé la Gateway API, appelée à remplacer Ingress à terme.

Trois valeurs de pathType : Exact (correspondance stricte), Prefix (par segments de chemin, le cas courant) et ImplementationSpecific, délégué au contrôleur et requis pour les motifs à capture comme celui ci-dessus.

Requêtes et limites de ressources

Deux valeurs, deux rôles distincts. Les confondre produit des pannes difficiles à interpréter.

          resources:
            requests:            # RÉSERVATION : sert à l'ordonnancement
              cpu: "100m"        # 100 millicores = 0,1 cœur
              memory: "128Mi"
            limits:              # PLAFOND : sert à contenir les dérives
              memory: "256Mi"

requests est ce que l’ordonnanceur utilise pour choisir un nœud : il additionne les requêtes des Pods déjà présents et ne place un nouveau Pod que si la somme reste sous la capacité. Un Pod sans requests est placé n’importe où, y compris sur un nœud déjà saturé.

limits est le plafond appliqué à l’exécution, et CPU et mémoire n’y réagissent pas du tout de la même façon :

  • Dépasser la limite CPU provoque un throttling : le conteneur est ralenti. L’application devient lente, sans erreur ni redémarrage — symptôme sournois, souvent imputé à tort au réseau ou à la base de données.
  • Dépasser la limite mémoire provoque un OOMKilled : le conteneur est tué immédiatement. La mémoire n’est pas compressible.
kubectl describe pod api-7d4b8c9f5-xk2mp | grep -A5 "Last State"
kubectl top pods -n boutique     # consommation réelle, si metrics-server est là
kubectl top nodes

D’où l’usage recommandé, contre-intuitif au premier abord : une limite mémoire, mais pas de limite CPU — c’est le choix fait dans les manifestes ci-dessus. La limite mémoire protège le nœud d’un voisin qui fuit, car une saturation mémoire provoque des évictions massives. La limite CPU, elle, laisse du processeur inutilisé alors qu’une application pourrait s’en servir : la requête garantit déjà sa part sous contention.

Le namespace peut imposer des garde-fous, plutôt que de dépendre de la discipline de chaque auteur de manifeste :

apiVersion: v1
kind: LimitRange
metadata:
  name: valeurs-par-defaut
  namespace: boutique
spec:
  limits:
    - type: Container
      default:                 # limites appliquées si absentes du Pod
        memory: "256Mi"
      defaultRequest:          # requêtes appliquées si absentes du Pod
        cpu: "100m"
        memory: "128Mi"
---
apiVersion: v1
kind: ResourceQuota
metadata:
  name: quota-boutique
  namespace: boutique
spec:
  hard:
    requests.cpu: "4"
    requests.memory: "8Gi"
    limits.memory: "16Gi"
    persistentvolumeclaims: "10"

Conséquence à anticiper : dès qu’un ResourceQuota porte sur une ressource de calcul, tout Pod du namespace doit déclarer la valeur correspondante, sinon sa création est refusée. Le LimitRange est précisément ce qui évite que cette règle casse les déploiements existants.

L’autoscaling horizontal

Le HorizontalPodAutoscaler ajuste le nombre de répliques selon une métrique observée.

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: api
  namespace: boutique
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: api
  minReplicas: 2
  maxReplicas: 10
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70     # 70 % de la REQUÊTE, pas de la limite
  behavior:
    scaleUp:                         # monter vite pour absorber un pic
      stabilizationWindowSeconds: 0
      policies:
        - type: Percent
          value: 100
          periodSeconds: 30
    scaleDown:                       # redescendre lentement, sinon ça oscille
      stabilizationWindowSeconds: 300
      policies:
        - type: Percent
          value: 50
          periodSeconds: 60

Le détail décisif est dans le commentaire : averageUtilization: 70 porte sur la requête, pas sur la limite. Avec requests.cpu: 100m, la mise à l’échelle se déclenche à 70 millicores de consommation moyenne. Une requête mal calibrée rend donc l’autoscaling erratique : trop basse, il ajoute des répliques en permanence ; trop haute, il ne réagit jamais.

Deux prérequis stricts, qui expliquent la quasi-totalité des HPA qui « ne fonctionnent pas » : metrics-server doit être installé, et les conteneurs visés doivent déclarer requests pour la métrique choisie — sans requête CPU, le pourcentage est incalculable.

minikube addons enable metrics-server
kubectl get hpa -n boutique       # TARGETS à <unknown> = métriques absentes
kubectl describe hpa api -n boutique

Notez enfin que le HPA ajoute des Pods mais ne crée pas de nœuds : si le cluster est plein, les nouveaux Pods restent en Pending. Ajouter de la capacité machine relève du Cluster Autoscaler ou de Karpenter, spécifiques au fournisseur cloud.

Déployer et vérifier l’ensemble

L’ordre de création n’a pas besoin d’être orchestré : les boucles de réconciliation convergent, et un Pod qui ne trouve pas encore sa dépendance sera relancé.

kubectl apply -f k8s/ --recursive
kubectl rollout status deployment/api -n boutique
kubectl rollout status statefulset/postgres -n boutique
kubectl get all -n boutique
kubectl get events -n boutique --sort-by=.metadata.creationTimestamp

Le diagnostic d’une application multi-services suit toujours le même chemin, de l’extérieur vers l’intérieur :

kubectl describe ingress boutique -n boutique   # 1. la règle est-elle prise en charge ?
kubectl get endpoints -n boutique               # 2. les Services pointent-ils sur des Pods ?
kubectl get pods -n boutique                    # 3. READY 3/3, pas seulement Running ?
kubectl port-forward -n boutique service/api 8080:8080   # 4. court-circuiter l'Ingress

Cette progression isole la panne en quatre commandes. Si port-forward fonctionne mais pas l’Ingress, le problème est dans le routage et non dans l’application — une heure de lecture de logs applicatifs économisée.

Un aperçu de Helm

Ces manifestes fonctionnent. Le problème arrive au deuxième environnement : la même chose avec deux répliques, un autre nom de domaine et une image de préproduction. Le copier-coller crée deux vérités qui divergeront.

Helm est un gestionnaire de paquets pour Kubernetes. Un chart regroupe des manifestes paramétrés dont les valeurs sont extraites dans un fichier séparé : Chart.yaml décrit le paquet, values.yaml porte les valeurs par défaut, et templates/ contient les manifestes.

# values.yaml
api:
  image:
    repository: exemple/boutique-api
    tag: "1.4.0"
  replicas: 3
ingress:
  host: boutique.example.com
# templates/deployment-api.yaml — extrait
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ .Release.Name }}-api
  namespace: {{ .Release.Namespace }}
spec:
  replicas: {{ .Values.api.replicas }}
  template:
    spec:
      containers:
        - name: api
          image: "{{ .Values.api.image.repository }}:{{ .Values.api.image.tag }}"
# Vérifier le rendu SANS rien envoyer au cluster : à faire systématiquement
helm template boutique ./boutique -f values-production.yaml

helm install boutique ./boutique -n boutique --create-namespace
helm upgrade boutique ./boutique -n boutique -f values-production.yaml
helm history boutique -n boutique
helm rollback boutique 1 -n boutique

Ce qu’apporte Helm : un jeu de manifestes pour plusieurs environnements, un versionnement des livraisons, et un retour arrière à l’échelle de l’application entière — là où kubectl rollout undo ne traite qu’un objet à la fois.

Ce que cela coûte, et il faut le dire : la templatisation dégrade la lisibilité. Un chart très paramétré devient un exercice de logique de gabarit où l’on ne voit plus le YAML produit. C’est pourquoi des alternatives comme Kustomize, qui superpose des correctifs sur du YAML resté lisible, ont trouvé leur public. Prenez le réflexe de helm template avant tout helm upgrade : vous verrez ce que le cluster va réellement recevoir.

À retenir

  • L’Ingress mutualise un point d’entrée HTTP pour tous vos Services. Il est inerte sans contrôleur d’entrée installé, et ses annotations sont propres à chaque contrôleur : seule la structure rules est portable.
  • requests sert à l’ordonnancement, limits à l’exécution. Dépasser la limite CPU ralentit silencieusement (throttling) ; dépasser la limite mémoire tue le conteneur (OOMKilled). D’où l’usage courant : toujours une limite mémoire, rarement une limite CPU.
  • Le HPA raisonne en pourcentage de la requête, pas de la limite. Il exige metrics-server et des requests déclarées, et il ajoute des Pods sans créer de nœuds.
  • Diagnostiquez de l’extérieur vers l’intérieur : Ingress, puis endpoints, puis état des Pods, puis port-forward pour court-circuiter le routage et isoler la couche fautive.
  • Helm évite la duplication des manifestes entre environnements et apporte un retour arrière global. Le prix est la lisibilité : vérifiez toujours le rendu avec helm template avant d’appliquer.

Pour aller plus loin : les sujets qui prolongent naturellement ce cours sont le RBAC, les NetworkPolicy, l’observabilité (Prometheus, Grafana) et le déploiement continu par GitOps. La documentation officielle kubernetes.io/fr/docs/concepts/ reste la référence à consulter en premier.

Commentaires

Les commentaires sont alimentés par GitHub Discussions

Connectez-vous avec GitHub pour participer à la discussion

Lien copié !