Backup de volumes dans Kubernetes avec K8up

K8up est un Kubernetes Operator (c’est-à-dire qu’il ajoute des CRDs) conçu pour faire des backups de volumes dans Kubernetes vers une API S3, et leur restauration dans le sens inverse. L’outil est donc utile pour automatiser les backups, mais s’avère également très pratique pour certaines opérations telles que déplacer une application qu’on aurait déployé dans un namespace (comme celui par défaut « default » de Kubernetes), et qu’on voudrait mettre dans autre namespace (en fait on ne déplace pas, on crée des ressources, on copie des données, puis on supprime les anciennes ressources).

K8up est également capable de faire (une sorte de) backups de bases de données, via ce qu’il appelle des « PreBackupPod » et des « Application-Aware Backups ».

Contexte d’utilisation

Le sujet des backups est à la fois simple et compliqué car il est très dépendant des cas d’usage et des spécificités des applications à sauvegarder, ainsi que des critères (contraintes) que l’on vise.

Dans mon cas, il s’agit d’applications hébergées sur un cluster Kubernetes d’un seul noeud. Elles utilisent pour la plupart des volumes (via des PVCs), avec parfois des bases de données soit en mode fichier comme SQLite, soit en mode client-serveur comme PostgreSQL (qui est mon choix préféré quand il est supporté par l’application). Mon serveur PostgreSQL est hébergé hors Kubernetes.

Ce qui était un projet démarré, il y a quelques années, comme un hobby pour faire de la veille technologique s’est transformé en un ensemble de services et de données sur lesquels je m’appuie au quotidien sans trop y faire attention, mais qui me manquerait en cas de défaillance. J’y stocke mes photos (Immich), mes documents (Paperless), mes « spams » sur ma boîte Gmail (que je synchronise sur Dovecot pour ne plus passer par l’UI de gmail et ne pas atteindre sa limite de stockage), des bookmarks (Shiori), mes dépôts Git personnels et leurs artefacts (Forgejo) et d’autres encore.

Avant d’utiliser K8up, ma stratégie de backup était en grande partie manuelle et épisodique avec l’outil Borg au niveau du système de fichier de l’hôte de Kubernetes.

Les CRDs de K8up que j’utilise et que je décris sont:

  • Schedule: c’est le type d’objet principal qui permet de planifier un backup (c’est à dire un ensemble de créations de snapshots sur un dépôt Restic, à partir des différents PVCs au sein d’un namespace Kubernetes) et la gestion des snapshots (ne conserver que les N derniers). On peut le voir comme un CronJob spécialisé.
  • Backup: effectue seulement la tâche de backup, immédiatement. Comparable à un Job spécialisé. Utile pour des tests ou pour opérations ponctuelles comme déplacer des volumes entre deux namespaces Kubernetes.
  • PreBackupPod: c’est une forme de hook (point d’extension optionnel). Si présent, K8up lancera les containers spécifiés dans cet objet avant de lancer le job de backup proprement dit, puis les arrêtera à la fin du backup. On peut l’utiliser pour démarrer un container qui contient les outils nécessaires au dump d’une base de données, tel que pg_dump.
  • Restore: on peut se douter que c’est l’inverse de Backup, pour restaurer un volume à partir d’un snapshot Restic.

K8up contient d’autres CRDs, tel que « Archive », qui permet de copier le dernier snapshot d’un dépôt Restic vers un autre dépôt Restic. L’idée est d’effectuer les backups courants en local (ou à proximité), et d’envoyer périodiquement la dernière copie à distance pour un stockage de long terme. Je n’utilise pas ce mécanisme pour le moment.

Alternatives à K8up

Il existe des alternatives. Il semble que celle qui revient le plus souvent dans la communauté open source est Velero, mais il y en a d’autres et je ne prétends pas que K8up se démarque d’une façon ou d’une autre. Ma préférence a été pour K8up car il m’a semblé assez simple d’utilisation, basique et efficace. K8up est essentiellement un wrapper écrit en Go autour de Restic et on a assez vite fait le tour de sa documentation.

Pré-requis

K8up ne fonctionne qu’avec un backend S3. Si le but est vraiment de faire des backups, il s’agira donc typiquement d’un service payant tel que Hetzner, Backblaze, Amazon Glacier, etc. En revanche, pour des opérations de copie interne au cluster, on pourra utiliser Garage, qui expose une API compatible S3 au dessus d’un stockage a priori local (devant un volume Kubernetes).

Les paramètres demandés par K8up pour le backend S3 sont:

  • le endpoint de l’API S3
  • un nom de bucket et optionnellement un sous-chemin si on souhaite organiser ses backups en différents dépôts Restic (repositories), ce qui me semble être à conseiller (à moins d’isoler au niveau des buckets)
  • un couple d’Access Key ID et Access Key Secret (accès au S3)
  • un mot de passe pour le dépôt chiffré Restic

Pour l’illustration, je propose d’installer Garage, mais cette étape peut être ignorée si vous disposez déjà d’un backend S3 prêt à l’emploi.

Il est également possible de publier des métriques vers Prometheus. Il est pour cela nécessaire de déployer également Prometheus Pushgateway qui agit comme un cache de métriques que Prometheus doit collecter. K8up pourra ainsi publier ses métriques vers Prometheus Pushgateway à chaque exécution de tâche. Ce composant est toutefois optionnel.

Installation de Garage

Garage se présente essentiellement comme un serveur exposant une API HTTP compatible S3. Son déploiement dans Kubernetes est donc très classique. La documentation est ici.

Dans mon cas, j’utilise Garage pour des copies temporaires, au sein d’un cluster composé d’un seul noeud, avec des manifestes semblables à ceux-ci:


apiVersion: v1
kind: Namespace
metadata:
  name: garage
  labels:
    app.kubernetes.io/name: garage
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: garage-data
  namespace: garage
  labels:
    app.kubernetes.io/name: garage
spec:
  accessModes:
  - ReadWriteOnce
  storageClassName: local-path
  resources:
    requests:
      storage: 10Mi
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: garage-meta
  namespace: garage
  labels:
    app.kubernetes.io/name: garage
spec:
  accessModes:
  - ReadWriteOnce
  storageClassName: local-path
  resources:
    requests:
      storage: 10Gi
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: garage-config
  namespace: garage
  labels:
    app.kubernetes.io/name: garage
data:
  garage.toml: |
    metadata_dir = "/var/lib/garage/meta"
    data_dir = "/var/lib/garage/data"
    db_engine = "sqlite"
    metadata_auto_snapshot_interval = "6h"

    replication_factor = 1
    data_fsync = true
    compression_level = 2
    rpc_bind_addr = "[::]:3901"
    rpc_public_addr = "127.0.0.1:3901"
    # rpc_secret is not secret because there are no RPC in single node
    rpc_secret = "42531fe1c82ac55a6c73b7f91dd3caefb0685a3e944c335ad3738a24353f69d0"

    [s3_api]
    s3_region = "garage"
    api_bind_addr = "[::]:3900"

    [s3_web]
    bind_addr = "[::]:3902"
    root_domain = ".web.garage"
    index = "index.html"

    [admin]
    api_bind_addr = "[::]:3903"
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: garage-deployment
  namespace: garage
  labels:
    app.kubernetes.io/name: garage
spec:
  replicas: 1
  selector:
    matchLabels:
      app.kubernetes.io/name: garage
  template:
    metadata:
      labels:
        app.kubernetes.io/name: garage
    spec:
      volumes:
      - name: garage-config
        configMap:
          name: garage-config
      - name: data-volume
        persistentVolumeClaim:
          claimName: garage-data
      - name: meta-volume
        persistentVolumeClaim:
          claimName: garage-meta
      containers:
      - name: garage
        image: dxflrs/garage:v2.2.0
        ports:
        - containerPort: 3900
          name: s3api
        - containerPort: 3902
          name: s3website
        - containerPort: 3903
          name: webadmin
        volumeMounts:
        - mountPath: /etc/garage.toml
          name: garage-config
          subPath: garage.toml
        - name: data-volume
          mountPath: /var/lib/garage/data
        - name: meta-volume
          mountPath: /var/lib/garage/meta
---
apiVersion: v1
kind: Service
metadata:
  name: s3
  namespace: garage
  labels:
    app.kubernetes.io/name: garage
spec:
  selector:
    app.kubernetes.io/name: garage
  type: ClusterIP
  ports:
  - name: s3api
    port: 3900
    protocol: TCP
    targetPort: 3900
---
apiVersion: v1
kind: Service
metadata:
  name: admin
  namespace: garage
  labels:
    app.kubernetes.io/name: garage
spec:
  selector:
    app.kubernetes.io/name: garage
  type: ClusterIP
  ports:
  - name: webadmin
    port: 3903
    protocol: TCP
    targetPort: 3903

Une fois déployé, il faut configurer Garage avec au moins un bucket S3 et un compte d’accès à ce bucket. Cela se fait en exécutant une ligne de commande à l’intérieur du pod déployé.

On commence par identifier le pod avec:


kubectl get pods -n garage

Puis on peut exécuter des commandes depuis le pod identifié ainsi:


kubectl exec --stdin --tty -n garage garage-deployment-8755d6dd7-v4l6k -- ./garage status

Ce qui donne quelque chose tel que:


INFO garage_net::netapp: Connected to 127.0.0.1:3901, negotiating handshake...
INFO garage_net::netapp: Connection established to <GARAGE_ID>
==== HEALTHY NODES ====
ID                Hostname                           Address         Tags  Zone  Capacity          DataAvail  Version
<GARAGE_ID> garage-deployment-8755d6dd7-v4l6k  127.0.0.1:3901              NO ROLE ASSIGNED             v2.2.0

La première étape est de créer un « cluster layout », qui consiste à indiquer à Garage qu’il fonctionne sur un seul noeud:


kubectl get pods -n garage
kubectl exec --stdin --tty -n garage garage-deployment-8755d6dd7-v4l6k -- ./garage layout assign -z garage -c 1G <GARAGE_ID>
kubectl exec --stdin --tty -n garage garage-deployment-8755d6dd7-v4l6k -- ./garage layout apply --version 1

La commande est à adapter avec l’ID de votre instance Garage. L’option -c 1G définit une capacité de 1Go mais celle-ci est ignorée dans le cas d’un cluster d’un seul noeud.

On crée ensuite un bucket dédié à K8up:


kubectl get pods -n garage
kubectl exec --stdin --tty -n garage garage-deployment-8755d6dd7-v4l6k -- ./garage bucket create my-k8up-bucket

Puis sa clé d’accès destinée à notre client K8up:


kubectl get pods -n garage
kubectl exec --stdin --tty -n garage garage-deployment-8755d6dd7-v4l6k -- ./garage key create k8up-app-key
kubectl exec --stdin --tty -n garage garage-deployment-8755d6dd7-v4l6k -- ./garage bucket allow --read --write --owner my-k8up-bucket --key k8up-app-key

Garage est maintenant prêt à être utilisé par K8up.

Installation de K8up

L’installation est classique via Helm. La documentation est ici.


helm repo add k8up-io https://k8up-io.github.io/k8up
kubectl create namespace k8up
helm install k8up k8up-io/k8up --namespace k8up --set k8up.skipWithoutAnnotation=true

L’option --set k8up.skipWithoutAnnotation=true sert à personnaliser une valeur par défaut du Helm chart afin de désactiver le backup de tous les PVCs d’un namespace, sauf s’ils sont explicitement marqués avec une annotation k8up.io/backup: 'true'.

Noter que la documentation est parfois contradictoire sur la gestion des CRDs: c’est parce que celle-ci a bougé plusieurs fois entre une intégration au Helm chart et une installation manuelle. Au moment de rédiger cet article, il est dit qu’ils sont intégrés au Helm chart.

Plus tard, s’il faut mettre à jour le helm chart, il faudra faire (option --set à adapter si vous personnalisez d’autres valeurs du Helm chart):


helm repo update
helm upgrade k8up k8up-io/k8up --namespace k8up --set k8up.skipWithoutAnnotation=true

Une fois déployé, l’utilisation de K8up se fait en déployant des manifestes de type « Backup », « Schedule » ou « Restore ».

Installation de Prometheus Pushgateway

Comme indiqué avant, ce composant est optionnel mais pratique si on souhaite collecter des métriques de backups via Prometheus.

Il s’agit d’un serveur qui expose une API, donc très classique à déployer:


apiVersion: v1
kind: Namespace
metadata:
  name: prom-pushgateway
  labels:
    app.kubernetes.io/name: prom-pushgateway
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: pushgateway-deployment
  namespace: prom-pushgateway
  labels:
    app.kubernetes.io/name: pushgateway
spec:
  replicas: 1
  selector:
    matchLabels:
      app: pushgateway
  template:
    metadata:
      labels:
        app.kubernetes.io/name: pushgateway
    spec:
      containers:
      # https://github.com/prometheus/pushgateway
      - name: pushgateway
        image: prom/pushgateway:v1.11.2
        ports:
        - containerPort: 9091
          name: webapi
        startupProbe:
          httpGet:
            path: /-/ready
            port: webapi
          initialDelaySeconds: 3
          periodSeconds: 5
          failureThreshold: 30
        readinessProbe:
          httpGet:
            path: /-/ready
            port: webapi
          initialDelaySeconds: 3
          periodSeconds: 60
          failureThreshold: 1
        livenessProbe:
          httpGet:
            path: /-/healthy
            port: webapi
          initialDelaySeconds: 3
          periodSeconds: 60
          failureThreshold: 2
---
apiVersion: v1
kind: Service
metadata:
  name: pushgateway-service
  namespace: prom-pushgateway
  labels:
    app.kubernetes.io/name: pushgateway
spec:
  type: ClusterIP
  ports:
  - name: http
    port: 9091
    protocol: TCP
    targetPort: 9091
  selector:
    app: pushgateway

Une fois déployé, Prometheus Pushgateway est accessible à l’intérieur du cluster K8s à l’adresse http://pushgateway-service.prom-pushgateway:9091. Cette valeur pourra être utilisée avec le paramètre promURL d’un manifeste de K8up (pour les types « Backup », « Schedule » ou « Check »).

Il reste à modifier le fichier de configuration de Prometheus pour ajouter cette « target ». L’exemple ci-dessous contient seulement la partie concernée de ce fichier. De plus, il faudra l’adapter à votre cas pour l’URL.


scrape_configs:
  - job_name: "pushgateway"
    scheme: http
    honor_labels: true
    # honor_labels: must be true for prometheus pushgateway (special)
    static_configs:
      - targets: ["<URL_pushgateway>"]

Important: si Prometheus est déployé dans K8s, l’URL est simplement celle du service de type « ClusterIP ». S’il est à l’extérieur, il faudra exposer un service de type « NodePort » ou bien configurer un ingress tel que Traefik que je ne documente pas ici.

Une fois configuré, Prometheus peut être redémarré et la nouvelle « target » doit être visible rapidement dans la page de statut (« http://<URL_prometheus>:9090/targets »).

Il existe un template de dashboard pour Grafana dans le dépôt GitHub de K8up, ainsi qu’une version publiée mais non maintenue sur le site de Grafana.

Backups planifiés

Pour planifier le backup des PVCs d’un namespace on commencera par ajouter une annotation k8up.io/backup: 'true' aux PVCs et on déploiera un manifeste de type « Schedule ». L’annotation n’est pas nécessaire si vous n’avez pas utilisé l’option --set k8up.skipWithoutAnnotation=true lors du déploiement de K8up. Pour ma part, je préfère que les backups de PVCs soient explicites. Pour certaines applications, je ne souhaite sauver que certains volumes et pas d’autres.

Par exemple, j’ai un namespace « dovecot » qui contient un PVC « dovecot-data ». Le template « Schedule » ci-dessous va planifier un backup vers Garage (en réalité, on voudra probablement faire un backup distant, mais c’est pour l’illustration).


apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: dovecot-data
  namespace: dovecot
  labels:
    app.kubernetes.io/name: dovecot
  annotations:
    k8up.io/backup: 'true'
spec:
  accessModes:
  - ReadWriteOnce
  storageClassName: local-path
  resources:
    requests:
      storage: 15Gi
---
apiVersion: k8up.io/v1
kind: Schedule
metadata:
  name: dovecot-backup-pv
  namespace: dovecot
spec:
  podSecurityContext:
  # DO omit podSecurityContext section if app is writing data with root user account.
  # Else, you need to adapt these values with the ones set in the app deployment.
    runAsUser: 1000
    runAsGroup: 1000
    fsGroup: 65534
  backend:
    s3:
      endpoint: http://s3.garage.svc:3900
      bucket: my-k8up-bucket/dovecot
      accessKeyIDSecretRef:
        name: dovecot-secrets
        key: S3_ID
      secretAccessKeySecretRef:
        name: dovecot-secrets
        key: S3_SECRET
    repoPasswordSecretRef:
      name: dovecot-secrets
      key: K8UP_REPO_PASSWORD
  backup:
    schedule: '@daily-random'
    failedJobsHistoryLimit: 2
    successfulJobsHistoryLimit: 2
    promURL: http://pushgateway-service.prom-pushgateway:9091
  check:
    schedule: '@weekly-random'
    promURL: http://pushgateway-service.prom-pushgateway:9091
  prune:
    schedule: '@weekly-random'
    retention:
      keepLast: 5
      keepDaily: 14

Noter la présence de secrets que je ne documente pas ici. Quelle que soit la méthode que vous utilisiez pour déployer ces secrets (par exemple External Secrets Operator), du point de vue de ce manifeste, il s’agit de secrets standards Kubernetes et l’utilisation reste la même.

Un point d’attention particulier est la section podSecurityContext: dans la plupart des cas, cette section peut être omise, sauf si l’application est basée sur un container non root auquel cas il faut réutiliser les mêmes valeurs de manière cohérente entre le pod de l’application, le backup et le restore.

Le template d’exemple ci-dessus planifie un backup quotidien à une heure aléatoire gérée par K8up (mais fixe chaque jour). On peut connaître l’heure exacte via la commande suivante:


kubectl describe schedule dovecot-backup-pv -n dovecot

On peut voir les heures planifiées tout en bas (notation cron, https://crontab.guru/):


  Effective Schedules:
    Generated Schedule:  15 15 * * 3
    Job Type:            prune
    Generated Schedule:  7 7 * * *
    Job Type:            backup
    Generated Schedule:  48 12 * * 0
    Job Type:            check

Au moment de son exécution, K8up déploiera un manifeste de type « Backup » qui est essentiellement une ligne de commande (comparable à un Job) basée sur Restic.

Backup de base de données via une commande

K8up a le concept de PreBackupPod et de Application-Aware Backups, qui consiste à spécifier une ligne de commande à exécuter pour, par exemple, créer un dump de base de données. Cette partie de documentation de K8up n’est malheureusement pas toujours claire et va probablement évoluer. En attendant, ce qui a bien fonctionné pour moi est d’utiliser « PreBackupPod » afin de lancer un container avec l’outil pg_dump de PostgreSQL.

PreBackupPod avec pg_dump (PostgreSQL)

Voici un exemple pour l’application Forgejo, où je souhaite faire un backup des volumes et de la base PostgreSQL:


apiVersion: k8up.io/v1
kind: PreBackupPod
metadata:
  name: forgejo-backup-db
  namespace: forgejo
spec:
  backupCommand: sh -c 'PGHOST="${PGHOST}" PGPORT="${PGPORT}" PGDATABASE="${FORGEJO__database__NAME}" PGUSER="${FORGEJO__database__USER}" PGPASSWORD="${FORGEJO__database__PASSWD}" pg_dump'
  fileExtension: ".sql"
  pod:
    spec:
      containers:
        - env:
          - name: PGHOST
            valueFrom:
              configMapKeyRef:
                name: forgejo-configmap
                key: PGHOST
          - name: PGPORT
            valueFrom:
              configMapKeyRef:
                name: forgejo-configmap
                key: PGPORT
          - name: FORGEJO__database__NAME
            valueFrom:
              configMapKeyRef:
                name: forgejo-configmap
                key: FORGEJO__database__NAME
          - name: FORGEJO__database__USER
            valueFrom:
              configMapKeyRef:
                name: forgejo-configmap
                key: FORGEJO__database__USER
          - name: FORGEJO__database__PASSWD
            valueFrom:
              secretKeyRef:
                name: forgejo-secrets
                key: DB_PASSWORD
          imagePullPolicy: IfNotPresent
          image: docker.io/postgres:17
          name: postgres
          command:
            - 'sleep'
            - 'infinity'
---
apiVersion: k8up.io/v1
kind: Schedule
metadata:
  name: forgejo-backup-pv
  namespace: forgejo
spec:
  podSecurityContext:
    runAsUser: 1000
    runAsGroup: 1000
    fsGroup: 65534
  backend:
    s3:
      endpoint: http://s3.garage.svc:3900
      bucket: my-k8up-bucket/forgejo
      accessKeyIDSecretRef:
        name: forgejo-secrets
        key: S3_ID
      secretAccessKeySecretRef:
        name: forgejo-secrets
        key: S3_SECRET
    repoPasswordSecretRef:
      name: forgejo-secrets
      key: K8UP_REPO_PASSWORD
  backup:
    schedule: '@daily-random'
    failedJobsHistoryLimit: 2
    successfulJobsHistoryLimit: 2
    promURL: http://pushgateway-service.prom-pushgateway:9091
  check:
    schedule: '@weekly-random'
    promURL: http://pushgateway-service.prom-pushgateway:9091
  prune:
    schedule: '@weekly-random'
    retention:
      keepLast: 5
      keepDaily: 14

Noter que le « PreBackupPod » lance un container basé sur l’image postgres. Cette image est plus lourde que nécessaire car elle contient tout le moteur de serveur de bases de données PostgreSQL alors que nous n’utilisons que l’outil pg_dump. Une alternative est d’utiliser une image non officielle avec uniquement les outils clients pour PostgreSQL.

On notera également la commande sleep infinity qui sert à remplacer le point d’entrée par défaut de l’image qui, dans le cas de postgres, tenterait de démarrer un serveur PostgreSQL et échouerait car la configuration est incomplète.

Le snapshot créé dans Restic portera le chemin « /forgejo-postgres.sql » qui correspond au namespace « forgejo » et au nom du container dans le « PreBackupPod » (le snapshot contient donc un seul fichier). Tandis que les backups de PVC sont stockés dans « /data/<PVC_NAME> » (leur contenu est donc vu comme un répertoire). Il est ainsi facile de retrouver ses fichiers dans les snapshots (décrit plus bas dans cet article). Il faudra cependant noter que K8up restaure très facilement les backups de PVC, tandis que pour les dumps de base de données, il semble plus prudent de le gérer soit même.

Backup et restauration manuelle de PVC pour déplacer une application dans un namespace

Si on souhaite déplacer une application dans un namespace, on doit en fait effectuer un nouveau déploiement. Cela pose problème avec les volumes car ils ne sont pas « déplaçables ». Il faut donc faire un backup, créer les nouvelles ressources dont les PVCs, et restaurer le backup. K8up et Garage sont donc bien adaptés à cette tâche.

Cela peut être un peu fastidieux mais c’est assez simple à réaliser (il faut évidemment faire très attention pour ne pas perdre de données personnelles).

Je reprendrai dovecot comme exemple car je l’avais initialement installé dans le namespace « default » et j’ai souhaité le déplacer dans un namespace dédié « dovecot ».

En premier, j’ai arrêté l’application avant de lancer le backup manuel, afin de réduire les risques d’écriture pendant le backup. J’ai effectué le backup du volume (après lui avoir appliqué un label utilisé par le backup pour le sélectionner), j’ai créé le nouveau PVC dans le nouveau namespace « dovecot », puis j’ai restauré les données dans ce nouveau volume. Enfin j’ai créé les autres ressources composant le déploiement de dovecot dans son nouveau namespace. Le serveur a pu redémarrer depuis son nouvel emplacement, avec toutes ses données.

Arrêter l’application avant de lancer le backup

Dans Kubernetes, on n’arrête pas une application, on la « scale » à 0:


kubectl scale --replicas=0 deployment/dovecot-deployment -n default

Label du volume et backup manuel

Pour effectuer la backup d’un groupe de volumes particulier, on peut utiliser les labels. Cela permet de sélectionner le ou les volumes au cas où il y en ai d’autres à ignorer dans le même namespace.

Pour le PVC, il suffit de redéployer son manifeste (pour l’exemple on suppose que seule la section « labels » a été ajoutée au manifeste ci-dessous):


apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: dovecot-data
  namespace: default
  labels:
    app.kubernetes.io/name: dovecot
  annotations:
    k8up.io/backup: 'true'
spec:
  accessModes:
  - ReadWriteOnce
  storageClassName: local-path
  resources:
    requests:
      storage: 15Gi

N’étant pas certain que cela suffise, j’ai voulu m’assurer que le label était aussi appliqué au volume existant, et comme il avait déjà été créé, je l’ai appliqué manuellement via cette commande:


kubectl get pv,pvc -n default
kubectl label pv pvc-46bfad0f-46ad-4bde-b8d7-f509ceb02e9c app.kubernetes.io/name=dovecot

Pour lancer le backup vers Garage, j’ai déployé ce manifeste:


apiVersion: k8up.io/v1
kind: Backup
metadata:
  #https://docs.k8up.io/k8up/2.13/how-tos/backup.html#_target_specific_pvcs_or_prebackuppods
  name: dovecot-backup-garage
  namespace: default
spec:
  podSecurityContext:
  # DO omit podSecurityContext section if app is writing data with root user account.
  # Else, you need to adapt these values with the ones set in the app deployment.
    runAsUser: 1000
    runAsGroup: 1000
    fsGroup: 65534
  labelSelectors:
    - matchExpressions:
      - key: app.kubernetes.io/name
        operator: In
        values:
          - dovecot
  backend:
    s3:
      endpoint: http://s3.garage.svc:3900
      bucket: my-k8up-bucket/dovecot
      accessKeyIDSecretRef:
        name: dovecot-secrets
        key: S3_ID
      secretAccessKeySecretRef:
        name: dovecot-secrets
        key: S3_SECRET
    repoPasswordSecretRef:
      name: dovecot-secrets
      key: K8UP_REPO_PASSWORD

Une fois déployé, le backup devrait démarrer immédiatement (pour autant que le label mentionné ait bien été appliqué au PVC et au PV). Toutefois, si ce n’est pas le cas, il faut vérifier qu’il n’y a pas d’erreur ou d’oubli au niveau du filtre sur le label et de l’annotation k8up.io/backup: 'true'; puis il faut supprimer la tâche de backup avec la commande kubectl delete backup dovecot-backup-garage -n default avant de la recréer. On pourra aussi aller voir les logs du pod K8up qui peut contenir des pistes.

Restauration manuelle dans un autre volume

Une fois le backup terminé (à surveiller dans les logs du backup exécuté plus tôt), on peut créer le nouveau namespace et un PVC à l’intérieur de celui-ci:


apiVersion: v1
kind: Namespace
metadata:
  name: dovecot
  labels:
    app.kubernetes.io/name: dovecot
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: dovecot-data
  namespace: dovecot
  labels:
    app.kubernetes.io/name: dovecot
  annotations:
    k8up.io/backup: 'true'
spec:
  accessModes:
  - ReadWriteOnce
  storageClassName: local-path
  resources:
    requests:
      storage: 15Gi

Le manifeste de la commande de restauration est alors le suivant:


apiVersion: k8up.io/v1
kind: Restore
metadata:
  name: dovecot-restore-garage
  namespace: dovecot
spec:
  podSecurityContext:
  # DO omit podSecurityContext section if app is writing data with root user account.
  # Else, you need to adapt these values with the ones set in the app deployment.
    runAsUser: 1000
    runAsGroup: 1000
    fsGroup: 65534
  restoreMethod:
    folder:
      claimName: dovecot-data
  paths: ["/data/dovecot-data"]
  backend:
    repoPasswordSecretRef:
      name: dovecot-secrets
      key: K8UP_REPO_PASSWORD
    s3:
      endpoint: http://s3.garage.svc:3900
      bucket: my-k8up-bucket/dovecot
      accessKeyIDSecretRef:
        name: dovecot-secrets
        key: S3_ID
      secretAccessKeySecretRef:
        name: dovecot-secrets
        key: S3_SECRET

La restauration sera lancée immédiatement après le déploiement de ce manifeste.
Les paramètres importants du manifeste sont folder.claimName et paths. Le premier sert à identifier le PVC vers lequel restaurer (dans le même namespace que le manifeste). Le second sert à filtrer le snapshot Restic à utiliser pour la restauration. Dans le cas où il n’y a qu’un seul volume dans le dépôt Restic, ce n’est pas indispensable. Mais s’il y en a plusieurs, K8up sélectionnera le plus récent sans faire de distinction s’il y a plusieurs volumes (ce problème est expliqué dans cette issue GitHub). Le chemin correspond à celui créé par le backup, à savoir le nom du PVC dans le dossier « /data ». Si le PVC initialement sauvé est nommé « dovecot-data », le chemin est « /data/dovecot-data ».

Une alternative plus précise est d’identifier le snapshot ID à restaurer et d’utiliser le paramètre snapshot. Un exemple basé sur un snapshot ID est dans la documentation de K8up.

Une fois déployé, on vérifiera dans les logs de la restauration que tout s’est bien déroulé (le nombre de fichiers et le poids total sont indiqués, comme lors du backup).

Déploiement de l’application dans le nouveau namespace

Je ne vais pas détailler le déploiement ici car l’exemple suppose qu’on disposait déjà d’une application fonctionnelle. Le nouveau déploiement consiste à créer une copie des anciens manifestes et d’adapter le nom du namespace cible. Si tout se passe bien, l’application redéployée retrouvera bien ses données dans le nouveau volume du namespace de destination.

On pourra supprimer l’ancien déploiement dans le namespace d’origine.

Explorer son dépôt Restic

Configuration d’accès

La documentation officielle est ici.

Il suffit de télécharger le CLI Restic, préparer les informations d’accès, et utiliser les bonnes commandes.

Pour rappel, il nous faut connaître ces informations d’accès:

  • le endpoint de l’API S3
  • un nom de bucket et optionnellement le chemin dans lequel on a organisé ses dépôts
  • un couple d’Access Key ID et Access Key Secret (accès au S3)
  • un mot de passe pour le dépôt chiffré Restic

Restic permet différentes méthodes pour définir ses paramètres, soit en ligne de commande, soit en variables d’environnement, soit en fichiers (non exclusif).

Dans mon cas j’ai préféré stocker tous les paramètres dans leur fichier respectif, par exemple:

Le fichier « repository » avec l’URL du dépôt au format de Restic, c’est-à-dire avec le préfixe « s3: ». Dans cet exemple, il s’agit d’un S3 publique (si on souhaite explorer le S3 de Garage, il faudra exposer un service à l’extérieur du cluster, avec un ingress tel que Traefik):


s3:https://...your-objectstorage.com/my-k8up-bucket/dovecot

Le fichier « s3_secret »:


# AWS_SHARED_CREDENTIALS_FILE
[default]
aws_access_key_id = <KEY_ID>
aws_secret_access_key = <KEY_SECRET>

Le fichier « password »:


<PASSWORD>

A partir de là, on peut charger ces paramètres dans des variables d’environnement utilisées par Restic, au sein d’une session Powershell:


$env:RESTIC_REPOSITORY_FILE = "repository"
$env:AWS_SHARED_CREDENTIALS_FILE = "s3_secret"
$env:RESTIC_PASSWORD_FILE = "password"

Dans la même session Powershell, il est possible d’explorer ses snapshots avec des commandes simples.

Lister le contenu


.\restic.exe snapshots

repository REDACTED opened (version 2, compression level auto)
created new cache in REDACTED
ID        Time                 Host        Tags        Paths
-------------------------------------------------------------------------
cc0a692e  2026-04-04 23:33:22  default                 /data/dovecot-data
e1b10424  2026-04-05 00:06:35  default                 /data/dovecot-data
-------------------------------------------------------------------------
2 snapshots

Si on avait effectué un backup de plusieurs PVCs dans le même dépôt, on aurait un snapshot par PVC avec le nom du PVC en chemin. Dans l’exemple plus haut, « dovecot-data » est le nom du PVC. L’hôte correspondra au nom du namespace de K8s qui contenait le PVC, dans mon cas il s’agissait de « default ».

Pour lister le contenu d’un snapshot (attention, le résultat peut être énorme selon le contenu, tous les fichiers seront listés):


.\restic.exe ls --long e1b10424
.\restic.exe ls --long latest
.\restic.exe ls --long latest /data/dovecot-data/REDACTED/mail/

Contrôle d’intégrité

Pour vérifier l’intégrité du dépôt:


.\restic.exe check

Noter que cela se limite à quelques métadonnées. Pour vérifier le contenu des données, voir les différentes options --read-data et --read-data-subset dans la documentation de Restic (cela entraîne des téléchargements qui peuvent être facturés sur un dépôt S3 distant, selon le service utilisé).

Restauration locale

On peut extraire le contenu d’un snapshot dans un chemin local avec ce type de commande:


.\restic.exe restore latest --target $env:TEMP\test

Pour tester la restauration sans l’exécuter, on peut ajouter l’option --dry-run (peut être utile pour vérifier le volume de données que l’on va devoir télécharger.

Il est possible de filtrer les fichiers à restaurer, pour cela se référer à la documentation.

Supprimer un snapshot

Noter que K8up le gère dans le manifeste de type « Schedule ». Pour le faire manuellement, on peut supprimer physiquement tous les snapshots, au delà d’un certain nombre des plus récents:


.\restic.exe forget --keep-last 1 --prune