0%
Armazenamento persistente

Armazenamento

Fazer dados sobreviverem a Pods projetados para desaparecer.

15-20 min

Armazenamento persistente

Tudo o que você aprendeu até aqui se baseia em uma hipótese conveniente: os Pods são intercambiáveis e descartáveis. Essa hipótese é correta para uma API sem estado, e desmorona assim que há dados a conservar. O sistema de arquivos de um contêiner morre com ele: destrua o Pod de um banco PostgreSQL implantado de forma ingênua, e as tabelas se perdem. Não ficam corrompidas — ficam perdidas.

Não basta “montar um volume”. A dificuldade é que, em um cluster, disco e computação são separados: um Pod pode ser reprogramado em outro nó, e um diretório local do nó de origem passa a ficar inacessível. O armazenamento precisa, portanto, ser um recurso do cluster, não do nó, e esse recurso precisa acompanhar o Pod.

O Kubernetes responde com um desacoplamento em três objetos que desconcerta à primeira vista, mas tem uma lógica clara. O PersistentVolume representa um pedaço de armazenamento real. O PersistentVolumeClaim é uma solicitação feita pela aplicação: “preciso de 10 Gio em leitura e escrita”. A StorageClass descreve como esses volumes são fabricados sob demanda. O desenvolvedor escreve apenas a solicitação; ele não sabe — e não precisa saber — se vai obter um disco EBS, um volume Ceph ou um diretório local.

Depois vem uma pergunta que não dá para contornar: mesmo com um volume que sobrevive, um Deployment continua inadequado para um banco de dados. Seus Pods são anônimos, criados em paralelo, e compartilhariam o mesmo volume. O StatefulSet existe para esse caso preciso, e esta lição explica por que ele não é simplesmente um Deployment melhorado.

Os volumes efêmeros e seu bom uso

Antes do armazenamento persistente, uma palavra sobre o emptyDir, frequentemente mal compreendido. Ele cria um diretório vazio na inicialização do Pod, compartilhado entre seus contêineres, e destruído quando o Pod desaparece.

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

Isso não é um paliativo: é a ferramenta certa para um cache, um diretório temporário ou um espaço de troca entre dois contêineres do mesmo Pod. O emptyDir só se torna um erro quando se confiam a ele dados que precisam sobreviver. Adicione medium: Memory para obter um tmpfs na RAM — rápido, mas contabilizado no limite de memória do Pod.

PersistentVolume e PersistentVolumeClaim

O volume, do lado da infraestrutura

Um PersistentVolume é um objeto do cluster, independente de qualquer Pod. Ele tem seu próprio ciclo de vida.

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

O hostPath merece um aviso franco: ele aponta para um diretório do nó, portanto o volume só pode ser usado por um Pod alocado nesse nó. Serve para um cluster local de um único nó, para entender o mecanismo, e deve ser evitado em qualquer outro lugar. Em produção, o campo de tipo é substituído por um driver CSI que conversa com o armazenamento real do provedor.

A solicitação, do lado da aplicação

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é

O Kubernetes procura um PersistentVolume compatível — capacidade suficiente, modo de acesso compatível, mesmo storageClassName — e faz a ligação (binding). Um PV vinculado a um PVC fica reservado exclusivamente: nenhum outro PVC o obterá.

kubectl get pv
kubectl get pvc -n atelier

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

Um PVC travado em Pending quase sempre tem uma destas três causas: nenhum PV com capacidade suficiente, um storageClassName que não corresponde a nada, ou um modo de acesso que o armazenamento não suporta. Os eventos do describe dizem isso explicitamente — é um dos raros lugares em que a mensagem de erro do Kubernetes é diretamente aproveitável.

Usar o PVC em um 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

O contêiner enxerga um diretório comum. Toda a mecânica de ligação lhe é invisível, e é justamente esse o objetivo do desacoplamento.

Os modos de acesso

É o campo mais frequentemente mal compreendido, porque não depende do Kubernetes, e sim da tecnologia de armazenamento subjacente.

ModoAbreviaçãoSignificado
ReadWriteOnceRWOmontável em leitura e escrita por um único nó
ReadOnlyManyROXmontável somente leitura por vários nós
ReadWriteManyRWXmontável em leitura e escrita por vários nós
ReadWriteOncePodRWOPum único Pod, mesmo no mesmo nó

O que seu armazenamento realmente aceita se lê nos volumes existentes, nunca em uma documentação genérica:

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

Dois esclarecimentos decisivos. Primeiro, ReadWriteOnce se aplica ao nó, não ao Pod: vários Pods de um mesmo nó podem compartilhar um volume RWO. É o ReadWriteOncePod que garante exclusividade ao Pod, o que um banco de dados exige.

Segundo, e é o que trava os iniciantes: a grande maioria dos armazenamentos em blocos oferece apenas RWO. Os discos EBS da AWS, os Persistent Disks do Google e os Managed Disks do Azure só se anexam a uma máquina por vez. O ReadWriteMany exige um sistema de arquivos de rede — NFS, AWS EFS, Azure Files, CephFS — frequentemente mais lento e sempre provisionado à parte.

Consequência prática imediata: um Deployment com replicas: 3 e um PVC em ReadWriteOnce não funcionará de forma confiável. Se os Pods forem parar em nós diferentes, aqueles que não estão no nó dono do volume permanecem em Pending ou em ContainerCreating. Isso não é uma esquisitice do Kubernetes, é a natureza do armazenamento em blocos.

As StorageClass e o provisionamento dinâmico

Criar os PersistentVolumes manualmente não escala. Uma StorageClass descreve um tipo de armazenamento, e um provisionador fabrica os volumes sob demanda, no momento em que um PVC chega.

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

Três campos a entender, em vez de simplesmente copiar:

  • provisioner designa o driver que cria o armazenamento. Ele é específico do provedor: ebs.csi.aws.com na AWS, pd.csi.storage.gke.io no Google Cloud, disk.csi.azure.com no Azure, rancher.io/local-path com o provisionador local do kind.
  • volumeBindingMode: WaitForFirstConsumer adia a criação do volume até que um Pod seja agendado. Quase sempre é a configuração correta: o volume é então criado na mesma zona de disponibilidade do Pod. Com Immediate, um volume pode nascer em uma zona onde o Pod jamais poderá ser colocado, e o Pod fica travado.
  • allowVolumeExpansion: true autoriza o aumento posterior do PVC. Sem esse campo, o único caminho é backup, novo volume e restauração.

Com uma StorageClass, o PVC fica mais curto, e nenhum PV é escrito manualmente:

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

Deixemos claro: o que você obtém depende inteiramente do provedor. A mesma StorageClass não existe de um cluster para outro, um manifesto com storageClassName: gp3 falha em qualquer lugar que não seja a AWS, e não existe um nome universal. É o ponto em que a portabilidade do Kubernetes termina, e é melhor saber disso antes de prometer uma implantação multicloud.

Omitir storageClassName faz com que seja aplicada a classe marcada como padrão, se o cluster tiver uma. É conveniente em desenvolvimento, e frágil assim que um manifesto passa a circular entre ambientes.

A política de recuperação

Ela decide o destino do armazenamento real quando o PVC é excluído:

  • Delete: o volume e os dados são excluídos. É o valor padrão do provisionamento dinâmico.
  • Retain: o volume permanece com seus dados, no status Released. É preciso intervir manualmente para reutilizá-lo.

Para qualquer dado que importe, use Retain. É sua única proteção contra um kubectl delete pvc infeliz — e seus backups, que continuam indispensáveis: o Kubernetes não faz backup de nada.

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

Por que um Deployment não serve para um banco de dados

Reunamos as razões, porque é a pergunta mais frequente e merece uma resposta precisa.

  1. Os Pods de um Deployment são anônimos. O nome deles contém um sufixo aleatório que muda a cada recriação. Uma replicação PostgreSQL precisa endereçar uma instância específica, de forma estável.
  2. Eles compartilham o mesmo PVC. O template de Pod é único, portanto o claimName é o mesmo para todos. Três réplicas escreveriam no mesmo volume, o que corrompe um banco — quando o armazenamento permite, o que o RWO geralmente não permite.
  3. Eles são criados e destruídos em paralelo, em ordem indefinida. Um banco com um primário e secundários exige que o primário esteja pronto antes dos secundários.
  4. A exclusão destrói tudo. Nenhuma noção de identidade estável sobrevive.

O sintoma é imediato em um Deployment de três réplicas compartilhando um PVC em 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

O StatefulSet

O StatefulSet responde a essa lista ponto por ponto.

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

O que este manifesto produz, e que nenhum Deployment produz:

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

Quatro garantias concretas:

  • Identidade estável. Um postgres-1 recriado continua se chamando postgres-1 e reencontra donnees-postgres-1, com os mesmos dados.
  • Volume dedicado por réplica, graças ao volumeClaimTemplates. Chega de compartilhamento, chega de corrupção.
  • Ordem garantida. Criação do 0, depois do 1, depois do 2, com cada Pod aguardando o anterior ficar pronto. Exclusão na ordem inversa.
  • DNS individual. O Service headless dá a cada Pod um nome alcançável: postgres-0.postgres.atelier.svc.cluster.local. É isso que permite configurar uma replicação.

Dois pontos de atenção. Primeiro, os PVCs criados por volumeClaimTemplates não são excluídos junto com o StatefulSet: é uma proteção intencional, e isso significa que uma limpeza completa exige uma exclusão explícita.

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

Em segundo lugar, uma observação honesta sobre a operação: um StatefulSet fornece uma identidade e um volume estáveis, nada além disso. Ele não configura a replicação, não gerencia as trocas de primário, não faz backup de nada. Para um banco em produção, usa-se um operador dedicado (CloudNativePG, Zalando Postgres Operator) que traz essa lógica, ou um serviço gerenciado fora do cluster. O StatefulSet é a fundação, não a solução completa.

Aumentar um volume

Se a StorageClass tiver allowVolumeExpansion: true, basta aumentar a solicitação:

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

kubectl get pvc donnees-postgres-0

Três limites que você precisa conhecer: a redução de tamanho é impossível, alguns drivers exigem a reinicialização do Pod para que o sistema de arquivos seja expandido, e a operação não funciona de forma alguma em um PV provisionado manualmente. Portanto, dimensione com uma margem desde o início.

Pontos-chave

  • O sistema de arquivos de um contêiner morre junto com ele. O emptyDir serve para caches e para trocas entre contêineres de um Pod, nunca para dados que precisam ser mantidos.
  • O tripé PV / PVC / StorageClass desacopla a infraestrutura da aplicação: a pessoa desenvolvedora escreve apenas uma solicitação (claim), e o provisionador cria o volume. Um PVC em Pending quase sempre se explica por uma classe inexistente ou por um modo de acesso não suportado.
  • O ReadWriteOnce se aplica ao nó, e é o único modo oferecido pela maioria dos armazenamentos em bloco. O ReadWriteMany exige um sistema de arquivos de rede, provisionado separadamente — um Deployment com várias réplicas sobre um PVC RWO não vai funcionar.
  • A StorageClass depende do provedor: provisionador, nomes das classes e políticas de recuperação mudam de uma nuvem para outra. Use Retain no que é importante e faça backup: o Kubernetes não faz backup de nada.
  • Para um banco de dados, é preciso um StatefulSet: nomes ordinais estáveis, um PVC por réplica via volumeClaimTemplates, inicialização ordenada e DNS individual por meio de um Service headless. Ele fornece a base, não a replicação nem os failovers — isso é papel de um operador ou de um serviço gerenciado.

Dica: pratique excluindo um Pod de um StatefulSet (kubectl delete pod postgres-1) e verificando se ele volta com o mesmo nome e os mesmos dados. É a demonstração do que o Deployment não consegue oferecer.

Comentários

Os comentários são alimentados pelo GitHub Discussions

Conecte-se com o GitHub para participar da discussão

Link copiado!