0%
Stockage persistant

Stockage

Faire survivre des données à des Pods conçus pour disparaître.

15-20 min

Stockage persistant

Tout ce que vous avez appris jusqu’ici repose sur une hypothèse commode : les Pods sont interchangeables et jetables. Cette hypothèse est exacte pour une API sans état, et elle s’effondre dès qu’il y a des données à conserver. Le système de fichiers d’un conteneur meurt avec lui : détruisez le Pod d’une base PostgreSQL déployée naïvement, et les tables sont perdues. Pas corrompues — perdues.

Il ne suffit pas de « monter un volume ». La difficulté est que dans un cluster, le disque et le calcul sont séparés : un Pod peut être reprogrammé sur un autre nœud, et un répertoire local du nœud d’origine devient alors inaccessible. Le stockage doit donc être une ressource du cluster, pas du nœud, et cette ressource doit suivre le Pod.

Kubernetes répond par un découplage en trois objets qui déroute au premier abord mais qui a une logique nette. Le PersistentVolume représente un morceau de stockage réel. Le PersistentVolumeClaim est une demande formulée par l’application : « il me faut 10 Gio en lecture-écriture ». La StorageClass décrit comment ces volumes sont fabriqués à la demande. Le développeur n’écrit que la réclamation ; il ne sait pas — et n’a pas besoin de savoir — s’il obtiendra un disque EBS, un volume Ceph ou un répertoire local.

Puis vient une question qu’on ne peut pas éluder : même avec un volume qui survit, un Deployment reste inadapté à une base de données. Ses Pods sont anonymes, créés en parallèle, et se partageraient le même volume. Le StatefulSet existe pour ce cas précis, et cette leçon explique pourquoi il n’est pas un simple Deployment amélioré.

Les volumes éphémères, et leur bon usage

Avant le stockage persistant, un mot sur emptyDir, souvent mal compris. Il crée un répertoire vide au démarrage du Pod, partagé entre ses conteneurs, et détruit à la disparition du Pod.

apiVersion: v1
kind: Pod
metadata:
  name: pod-avec-cache
spec:
  containers:
    - name: app
      image: mon-api:1.0.0
      volumeMounts:
        - name: cache
          mountPath: /var/cache/app
    - name: collecteur-logs
      image: busybox:1.36
      command: ["sh", "-c", "tail -F /var/cache/app/app.log"]
      volumeMounts:
        - name: cache
          mountPath: /var/cache/app
  volumes:
    - name: cache
      emptyDir:
        sizeLimit: 500Mi

Ce n’est pas un pis-aller : c’est le bon outil pour un cache, un répertoire temporaire, ou un espace d’échange entre deux conteneurs du même Pod. emptyDir devient une erreur seulement quand on lui confie des données qui doivent survivre. Ajoutez medium: Memory pour un tmpfs en RAM — rapide, mais décompté de la limite mémoire du Pod.

PersistentVolume et PersistentVolumeClaim

Le volume, côté infrastructure

Un PersistentVolume est un objet du cluster, indépendant de tout Pod. Il a son propre cycle de vie.

apiVersion: v1
kind: PersistentVolume
metadata:
  name: pv-donnees-01
spec:
  capacity:
    storage: 5Gi
  accessModes:
    - ReadWriteOnce
  persistentVolumeReclaimPolicy: Retain
  storageClassName: manuel
  hostPath:
    path: /mnt/donnees/pv-01     # démonstration locale uniquement

Le hostPath mérite un avertissement franc : il pointe un répertoire du nœud, donc le volume n’est utilisable que par un Pod placé sur ce nœud. Il convient à un cluster local à un nœud pour comprendre le mécanisme, et il est à proscrire ailleurs. En production, le champ de type est remplacé par un pilote CSI qui parle au stockage réel du fournisseur.

La réclamation, côté application

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: donnees-app
  namespace: atelier
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 5Gi
  storageClassName: manuel     # doit correspondre à celui du PV visé

Kubernetes cherche un PersistentVolume compatible — capacité suffisante, mode d’accès compatible, même storageClassName — et effectue la liaison. Un PV lié à un PVC est réservé exclusivement : aucun autre PVC ne l’obtiendra.

kubectl get pv
kubectl get pvc -n atelier

# STATUS = Pending ? La cause est dans les événements.
kubectl describe pvc donnees-app -n atelier

Un PVC bloqué en Pending a presque toujours l’une de ces trois causes : aucun PV avec assez de capacité, un storageClassName qui ne correspond à rien, ou un mode d’accès que le stockage ne prend pas en charge. Les événements de describe le disent explicitement — c’est un des rares endroits où le message d’erreur de Kubernetes est directement exploitable.

Utiliser le PVC dans un Pod

apiVersion: apps/v1
kind: Deployment
metadata:
  name: app-avec-donnees
spec:
  replicas: 1              # voir plus bas : ReadWriteOnce impose 1 réplique
  selector:
    matchLabels:
      app: app-donnees
  template:
    metadata:
      labels:
        app: app-donnees
    spec:
      containers:
        - name: app
          image: mon-api:1.0.0
          volumeMounts:
            - name: donnees
              mountPath: /var/lib/app/donnees
      volumes:
        - name: donnees
          persistentVolumeClaim:
            claimName: donnees-app

Le conteneur voit un répertoire ordinaire. Toute la mécanique de liaison lui est invisible, et c’est précisément l’objectif du découplage.

Les modes d’accès

C’est le champ le plus souvent mal compris, parce qu’il ne dépend pas de Kubernetes mais de la technologie de stockage sous-jacente.

ModeAbréviationSignification
ReadWriteOnceRWOmontable en lecture-écriture par un seul nœud
ReadOnlyManyROXmontable en lecture seule par plusieurs nœuds
ReadWriteManyRWXmontable en lecture-écriture par plusieurs nœuds
ReadWriteOncePodRWOPun seul Pod, même sur le même nœud

Ce que votre stockage accepte réellement se lit sur les volumes existants, jamais dans une documentation générique :

kubectl get pv -o custom-columns=NOM:.metadata.name,MODES:.spec.accessModes,CLASSE:.spec.storageClassName

Deux précisions décisives. D’abord, ReadWriteOnce porte sur le nœud, pas sur le Pod : plusieurs Pods d’un même nœud peuvent partager un volume RWO. C’est ReadWriteOncePod qui garantit l’exclusivité au Pod, ce que veut une base de données.

Ensuite, et c’est ce qui bloque les débutants : la grande majorité des stockages par blocs ne fournit que RWO. Les disques EBS d’AWS, les Persistent Disks de Google, les Managed Disks d’Azure ne s’attachent qu’à une machine à la fois. ReadWriteMany exige un système de fichiers réseau — NFS, AWS EFS, Azure Files, CephFS — souvent plus lent et toujours à provisionner séparément.

Conséquence pratique immédiate : un Deployment avec replicas: 3 et un PVC en ReadWriteOnce ne fonctionnera pas de façon fiable. Si les Pods atterrissent sur des nœuds différents, ceux qui ne sont pas sur le nœud propriétaire du volume restent en Pending ou en ContainerCreating. Ce n’est pas une bizarrerie de Kubernetes, c’est la nature du stockage par blocs.

Les StorageClass et le provisionnement dynamique

Créer les PersistentVolumes à la main ne passe pas l’échelle. Une StorageClass décrit un type de stockage, et un provisionneur fabrique les volumes à la demande, au moment où un PVC arrive.

apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: rapide-ssd
provisioner: kubernetes.io/no-provisioner   # à remplacer par le pilote réel
volumeBindingMode: WaitForFirstConsumer
reclaimPolicy: Delete
allowVolumeExpansion: true

Trois champs à comprendre plutôt qu’à recopier :

  • provisioner désigne le pilote qui crée le stockage. Il est spécifique au fournisseur : ebs.csi.aws.com sur AWS, pd.csi.storage.gke.io sur Google Cloud, disk.csi.azure.com sur Azure, rancher.io/local-path avec le provisionneur local de kind.
  • volumeBindingMode: WaitForFirstConsumer retarde la création du volume jusqu’à ce qu’un Pod soit ordonnancé. C’est presque toujours le bon réglage : le volume est alors créé dans la même zone de disponibilité que le Pod. Avec Immediate, un volume peut naître dans une zone où le Pod ne pourra jamais être placé, et le Pod reste bloqué.
  • allowVolumeExpansion: true autorise l’agrandissement ultérieur du PVC. Sans ce champ, la seule voie est sauvegarde, nouveau volume, restauration.

Avec une StorageClass, le PVC devient plus court, et aucun PV n’est écrit à la main :

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: donnees-postgres
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 20Gi
  storageClassName: rapide-ssd
# Les classes disponibles ; (default) marque celle utilisée si on omet le champ
kubectl get storageclass

# Le PV a été créé automatiquement au moment de la liaison
kubectl get pv

Dites-le clairement : ce que vous obtenez dépend entièrement du fournisseur. La même StorageClass n’existe pas d’un cluster à l’autre, un manifeste avec storageClassName: gp3 échoue partout ailleurs que sur AWS, et il n’y a pas de nom universel. C’est le point où la portabilité de Kubernetes s’arrête, et il vaut mieux le savoir avant de promettre un déploiement multi-cloud.

Omettre storageClassName fait appliquer la classe marquée par défaut, si le cluster en a une. C’est commode en développement, et fragile dès qu’un manifeste voyage.

La politique de récupération

Elle décide du sort du stockage réel quand le PVC est supprimé :

  • Delete : le volume et les données sont supprimés. C’est la valeur par défaut du provisionnement dynamique.
  • Retain : le volume subsiste avec ses données, en statut Released. Il faut intervenir à la main pour le réutiliser.

Pour toute donnée qui compte, mettez Retain. C’est votre seule protection contre un kubectl delete pvc malheureux — et vos sauvegardes, qui restent indispensables : Kubernetes ne sauvegarde rien.

# Basculer un PV existant en Retain
kubectl patch pv pv-donnees-01 \
  -p '{"spec":{"persistentVolumeReclaimPolicy":"Retain"}}'

Pourquoi un Deployment ne convient pas à une base de données

Rassemblons les raisons, parce que c’est la question la plus fréquente et qu’elle mérite une réponse précise.

  1. Les Pods d’un Deployment sont anonymes. Leur nom contient un suffixe aléatoire qui change à chaque recréation. Une réplication PostgreSQL a besoin d’adresser une instance précise, de façon stable.
  2. Ils partagent le même PVC. Le template de Pod est unique, donc claimName est le même pour tous. Trois répliques écriraient dans le même volume, ce qui corrompt une base — quand le stockage l’autorise, ce que RWO n’autorise généralement pas.
  3. Ils sont créés et détruits en parallèle, dans un ordre indéfini. Une base avec un primaire et des secondaires exige que le primaire soit prêt avant les secondaires.
  4. La suppression détruit tout. Aucune notion d’identité stable ne survit.

Le symptôme est immédiat sur un Deployment à trois répliques partageant un PVC en ReadWriteOnce :

kubectl get pods -l app=postgres
# postgres-6b9f7c4d8-2xk4m   1/1   Running             (sur le nœud du volume)
# postgres-6b9f7c4d8-7fd2p   0/1   ContainerCreating   (volume déjà attaché ailleurs)
# postgres-6b9f7c4d8-q8v1t   0/1   ContainerCreating

Le StatefulSet

Le StatefulSet répond point par point à cette liste.

apiVersion: v1
kind: Service
metadata:
  name: postgres
  labels:
    app: postgres
spec:
  clusterIP: None          # Service « headless » : requis par le StatefulSet
  selector:
    app: postgres
  ports:
    - port: 5432
      name: postgres
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: postgres
spec:
  serviceName: postgres    # doit désigner le Service headless ci-dessus
  replicas: 3
  selector:
    matchLabels:
      app: postgres
  template:
    metadata:
      labels:
        app: postgres
    spec:
      containers:
        - name: postgres
          image: postgres:16-alpine
          ports:
            - containerPort: 5432
              name: postgres
          env:
            - name: POSTGRES_DB
              value: "appdb"
            - name: POSTGRES_USER
              valueFrom:
                secretKeyRef:
                  name: db-credentials
                  key: DB_USER
            - name: POSTGRES_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: db-credentials
                  key: DB_PASSWORD
            # PostgreSQL exige un sous-répertoire quand le point de montage
            # contient un lost+found : sans PGDATA, l'initialisation échoue.
            - name: PGDATA
              value: /var/lib/postgresql/data/pgdata
          volumeMounts:
            - name: donnees
              mountPath: /var/lib/postgresql/data
          readinessProbe:
            exec:
              command: ["pg_isready", "-U", "app_user", "-d", "appdb"]
            initialDelaySeconds: 10
            periodSeconds: 5
  volumeClaimTemplates:
    # Un PVC distinct est créé pour CHAQUE réplique, automatiquement.
    - metadata:
        name: donnees
      spec:
        accessModes:
          - ReadWriteOnce
        resources:
          requests:
            storage: 10Gi

Ce que ce manifeste produit, et qu’aucun Deployment ne produit :

kubectl get pods -l app=postgres
# postgres-0, postgres-1, postgres-2 — noms stables, ordinaux, prévisibles

kubectl get pvc
# donnees-postgres-0, donnees-postgres-1, donnees-postgres-2

Quatre garanties concrètes :

  • Identité stable. postgres-1 recréé s’appelle encore postgres-1 et retrouve donnees-postgres-1, avec les mêmes données.
  • Volume dédié par réplique, grâce à volumeClaimTemplates. Plus de partage, plus de corruption.
  • Ordre garanti. Création de 0, puis 1, puis 2, chaque Pod attendant que le précédent soit prêt. Suppression dans l’ordre inverse.
  • DNS individuel. Le Service headless donne à chaque Pod un nom joignable : postgres-0.postgres.atelier.svc.cluster.local. C’est ce qui permet de configurer une réplication.

Deux points de vigilance. D’abord, les PVC créés par volumeClaimTemplates ne sont pas supprimés avec le StatefulSet : c’est une protection voulue, et cela signifie qu’un nettoyage complet demande une suppression explicite.

kubectl delete statefulset postgres
kubectl get pvc                       # les PVC sont toujours là, données intactes
kubectl delete pvc donnees-postgres-0 # suppression explicite si voulue

Ensuite, une remarque d’honnêteté sur l’exploitation : un StatefulSet fournit une identité et un volume stables, rien de plus. Il ne configure pas la réplication, ne gère pas les bascules de primaire, ne sauvegarde rien. Pour une base en production, on utilise un opérateur dédié (CloudNativePG, Zalando Postgres Operator) qui apporte cette logique, ou un service managé hors du cluster. Le StatefulSet est la fondation, pas la solution complète.

Agrandir un volume

Si la StorageClass porte allowVolumeExpansion: true, il suffit d’augmenter la demande :

kubectl patch pvc donnees-postgres-0 \
  -p '{"spec":{"resources":{"requests":{"storage":"20Gi"}}}}'

kubectl get pvc donnees-postgres-0

Trois limites à connaître : le rétrécissement est impossible, certains pilotes exigent un redémarrage du Pod pour que le système de fichiers soit étendu, et l’opération ne fonctionne pas du tout sur un PV provisionné à la main. Dimensionnez donc avec une marge dès le départ.

À retenir

  • Le système de fichiers d’un conteneur meurt avec lui. emptyDir est fait pour les caches et les échanges entre conteneurs d’un Pod, jamais pour des données à conserver.
  • Le triptyque PV / PVC / StorageClass découple l’infrastructure de l’application : le développeur n’écrit qu’une réclamation, le provisionneur fabrique le volume. Un PVC en Pending s’explique presque toujours par une classe inexistante ou un mode d’accès non pris en charge.
  • ReadWriteOnce porte sur le nœud et c’est le seul mode qu’offrent la plupart des stockages par blocs. ReadWriteMany exige un système de fichiers réseau à provisionner séparément — un Deployment multi-réplique sur un PVC RWO ne marchera pas.
  • La StorageClass dépend du fournisseur : provisionneur, noms de classes et politiques de récupération changent d’un cloud à l’autre. Mettez Retain sur ce qui compte, et sauvegardez : Kubernetes ne sauvegarde rien.
  • Pour une base de données, il faut un StatefulSet : noms ordinaux stables, un PVC par réplique via volumeClaimTemplates, démarrage ordonné et DNS individuel par Service headless. Il fournit la fondation, pas la réplication ni les bascules — cela relève d’un opérateur ou d’un service managé.

Conseil : exercez-vous à supprimer un Pod de StatefulSet (kubectl delete pod postgres-1) et à vérifier qu’il revient avec le même nom et les mêmes données. C’est la démonstration de ce que le Deployment ne peut pas offrir.

Commentaires

Les commentaires sont alimentés par GitHub Discussions

Connectez-vous avec GitHub pour participer à la discussion

Lien copié !