↓ Aller au contenu
  1. Posts/

Perfect Homelab [A07] : offrir à Garage un socle SSD en miroir

Fabien ALLAMANCHE
Auteur
Fabien ALLAMANCHE
Géomaticien @ Vienne Condrieu Agglomération
Sommaire
Perfect Homelab Annexe - Cet article fait partie d'une série.
Partie 7: Cet article

Perfect Homelab

Introduction
#

Dans l’annexe A02, nous avons déployé une instance Garage S3 sur la LXC 121 (storage) du nœud Morpheus, avec Docker Compose. À ce stade, les deux répertoires d’état de Garage — garage/meta (la base SQLite) et garage/data (les blocs d’objets) — sont des dossiers ordinaires sous /opt/docker/storage-stack/, donc sur le disque racine du conteneur.

Ce disque racine, c’est rpool/datastore/subvol-121-disk-0 : environ 17 Go, dont 13 encore libres, sur les mêmes disques que Proxmox lui-même. Suffisant pour des backups Proxmox modestes et quelques tests. Trop court dès que le stockage objet devient un vrai service — et c’est précisément ce qui arrive : à la suite de la série QFieldCloud auto-hébergé, un bucket de production doit atterrir sur cette instance.

Or Morpheus a deux SSD SATA qui dorment : un Samsung 840 EVO de 250 Go et un Transcend de 240 Go, montés en ext4, chargés de cent gigaoctets dont plus rien ne dépend. De quoi faire mieux qu’un simple dossier ailleurs : un pool ZFS en miroir, sur son propre matériel. Et l’occasion est bonne de reprendre la politique de stockage dans son ensemble : l’annexe A02 de QFieldCloud posait un quota de 5 Go sur un nœud qui n’en déclarait que 4, et l’A02 de cette série une capacité calquée sur un disque racine qu’on quitte justement. Trois chiffres décidés à trois moments différents, jamais confrontés entre eux — le genre d’incohérence qui ne se voit qu’au moment où le disque se remplit.

Nous allons réunir ces deux SSD en un pool ZFS miroir, y créer un dataset dédié à Garage, le monter dans la LXC 121 par un point de montage Proxmox, et brancher Garage dessus via un simple interrupteur dans le .env. Le déploiement Garage de l’annexe A02 de Perfect Homelab reste intact — même garage.toml, même réseau Docker, mêmes ports — et la configuration S3 de QFieldCloud posée à l’annexe A02 de QFieldCloud auto-hébergé n’est pas davantage touchée : ni son endpoint_url, ni ses clés, ni le sous-module Git du dépôt amont qu’elle surcharge par docker-compose.override.yml. Et l’opération est entièrement réversible : retirer la variable remet le stockage sur le disque racine.


Info : Rien ici ne modifie la configuration garage.toml ni le réseau de l’A02. On change uniquement où vivent les octets. La bascule passe par une variable d’environnement : la retirer remet le stockage sur le disque racine, à l’identique.

Seule différence avec le docker-compose.yml publié en A02 : l’image, passée entre-temps de dxflrs/garage:v2.1.0 à dxflrs/garage:v2.3.0 au fil des mises à jour ordinaires de la stack. Cette montée de version est indépendante du déplacement du stockage, et les extraits ci-dessous reflètent l’état courant.

Table des matières
#


Pourquoi un pool dédié, et pas seulement un dataset
#

Le disque racine d’un conteneur n’est pas fait pour porter un service de stockage. Un dataset ZFS séparé règle la place ; un pool séparé règle aussi la panne.

Quatre raisons, dans l’ordre d’importance.

Le plafond. subvol-121-disk-0 fait 17 Go. On peut l’agrandir (pct resize 121 rootfs +50G), mais on empile alors backups Proxmox, base SQLite, blocs d’objets et journaux du conteneur sur le même volume — celui dont la saturation fige tout le conteneur d’un coup.

Le domaine de panne. C’est ce qui justifie d’aller jusqu’au pool. Un dataset rpool/tank/garage aurait résolu la place, mais il vivrait sur les mêmes disques que Proxmox lui-même. Un pool distinct, sur son propre matériel, sépare vraiment : Garage peut saturer, se fragmenter ou perdre un disque sans que l’hyperviseur s’en aperçoive.

La redondance. Les objets d’un stockage S3 à nœud unique n’ont, par définition, aucune copie ailleurs — replication_factor = 1. La seule redondance possible est celle du support. Un miroir ZFS la fournit, et fait mieux : il détecte la corruption silencieuse par checksum et la répare depuis l’autre membre. Un ext4 sur disque unique, lui, sert un bloc corrompu sans un mot.

L’unité de sauvegarde. Un dataset est ce que ZFS sait snapshoter, envoyer et recevoir. zfs snapshot objects/garage@… fige meta et data au même instant : exactement ce qu’il faut pour un cliché cohérent. zfs list -o space objects/garage répond au passage en une ligne à « combien pèse Garage, snapshots compris » — un chiffre qui n’existe pas quand tout est noyé dans le disque racine.

Attention : un miroir n’est pas une sauvegarde. Il protège de la panne d’un disque, pas d’une suppression, d’un chiffrement malveillant ou d’un bucket delete malheureux — ces trois-là se répliquent instantanément sur les deux membres. Le miroir tient le service debout ; ce sont les snapshots et leur envoi hors-site qui récupèrent les données.

Les deux SSD, et ce qu’ils valent vraiment
#

Avant de bâtir sur du matériel de récupération, il faut savoir ce qu’on a sous la main. Deux commandes le disent — et la réponse change parfois le plan.

Morpheus porte deux SSD SATA montés en ext4, déclarés dans /etc/fstab et rien d’autre :

[root@morpheus:~] $ grep fast /etc/fstab
/dev/disk/by-id/ata-Samsung_SSD_840_EVO_250GB_S1DBNSBDC58612P /mnt/fast1 ext4 defaults 0 0
/dev/disk/by-id/ata-TS240GSSD220S_661182A4E34961210374        /mnt/fast2 ext4 defaults 0 0

1. Ce qu’ils contiennent
#

[root@morpheus:~] $ du -xh --max-depth=2 /mnt/fast1 /mnt/fast2 | sort -h | tail
19G     /mnt/fast2/backups/restic
41G     /mnt/fast1/geostore/geo
41G     /mnt/fast1/geostore/geoserver
82G     /mnt/fast1

# Qui les déclare, côté Proxmox ?
[root@morpheus:~] $ grep -rn 'fast[12]' /etc/pve/storage.cfg /etc/pve/lxc/*.conf /etc/fstab
/etc/fstab:13:…/mnt/fast1 ext4 defaults 0 0
/etc/fstab:14:…/mnt/fast2 ext4 defaults 0 0

# Qui les ouvre, maintenant ?
[root@morpheus:~] $ lsof /mnt/fast1 /mnt/fast2

Cent-un gigaoctets, aucune référence dans storage.cfg ni dans une configuration de conteneur, et aucun processus ne les ouvre. Ce sont des reliquats : un ancien entrepôt GeoServer et un dépôt restic dont plus rien ne dépend. Ils partent.

Erreur à éviter : ne franchissez cette étape que si les trois commandes ci-dessus donnent le même verdict chez vous. du dit combien, grep dit qui le déclare, lsof dit qui l’utilise à cet instant — et aucune des trois ne détecte un service arrêté qui reprendra demain. Un dépôt restic, en particulier, ne s’ouvre que pendant ses sauvegardes : vérifiez le timer ou la tâche qui l’alimente (systemctl list-timers | grep -i restic) avant de conclure qu’il est mort.

2. Ce qu’ils ont vécu
#

L’état SMART se lit avant, pas après. Deux attributs comptent : les secteurs réalloués (le disque cache-t-il déjà des défauts ?) et l’usure (combien de vie reste-t-il ?).

[root@morpheus:~] $ smartctl -a /dev/sda | grep -Ei 'model|firmware|Power_On_Hours|Wear|Total_LBAs_Written|Reallocated'
Device Model:     Samsung SSD 840 EVO 250GB
Firmware Version: EXT0BB6Q
  5 Reallocated_Sector_Ct   100   100   010   Pre-fail   -   0
  9 Power_On_Hours          094   094   000   Old_age    -   26340
177 Wear_Leveling_Count     086   086   000   Pre-fail   -   160
241 Total_LBAs_Written      099   099   000   Old_age    -   65488942022

[root@morpheus:~] $ smartctl -a /dev/sdb | grep -Ei 'model|firmware|Power_On_Hours|Wear|Reallocated'
Device Model:     TS240GSSD220S
Firmware Version: R0123A0
  5 Reallocated_Sector_Ct   100   100   050   Old_age    -   0
  9 Power_On_Hours          100   100   050   Old_age    -   47950
177 Wear_Leveling_Count     100   100   050   Old_age    -   0
Samsung 840 EVO (sda) Transcend TS240 (sdb)
Capacité 250 Go (228 Go utiles) 240 Go (219 Go utiles)
Sous tension 26 340 h ≈ 3 ans 47 950 h ≈ 5,5 ans
Secteurs réalloués 0 0
Usure déclarée 86/100 — 160 cycles P/E non renseignée
Écrit au total 33,5 To attribut absent

Les 33,5 To se calculent : 65 488 942 022 × 512 octets. Rapportés aux 72 To de TBW garantis par Samsung pour ce modèle, le disque a consommé environ la moitié de son endurance nominale en trois ans de service. C’est beaucoup pour un disque, très peu pour ce qu’on va lui demander — un stockage objet de homelab écrit quelques gigaoctets par mois.

Le Transcend, lui, ne dit rien d’exploitable : son contrôleur Silicon Motion laisse les compteurs d’usure à zéro et n’expose pas les LBA écrits. C’est le disque dont on ignore l’état.

Astuce : cette asymétrie est un argument pour le miroir, pas contre. Deux disques de marques, d’âges et de contrôleurs différents ont des modes de défaillance décorrélés — bien plus sûr que deux jumeaux du même lot, qui vieillissent au même rythme et lâchent souvent à quelques semaines d’écart. Et ZFS n’a pas besoin que SMART parle : le checksum de chaque bloc lui dit lequel des deux membres a menti.

Attention : le firmware du 840 EVO est ici EXT0BB6Q. Ce modèle a connu un défaut célèbre — un effondrement des débits en lecture sur les données écrites depuis longtemps et jamais réécrites — corrigé par un firmware ultérieur (EXT0DB6Q, à vérifier sur le site Samsung pour votre référence exacte). Le profil est exactement celui d’un stockage objet : des blocs écrits une fois, relus des mois plus tard. La mise à jour se fait par un support amorçable Samsung et avant de construire le pool — après, il faudra exporter, mettre à jour, réimporter.

Construire le pool objects en miroir
#

Deux disques, une commande, et un socle qui survit à la perte de l’un des deux. Le seul point délicat est irréversible : ashift.

1. Libérer les disques
#

# Retirer les deux lignes de /etc/fstab AVANT de démonter,
# sinon le prochain reboot tente de remonter des systèmes de fichiers disparus.
[root@morpheus:~] $ sed -i '/mnt\/fast[12]/d' /etc/fstab
[root@morpheus:~] $ umount /mnt/fast1 /mnt/fast2
[root@morpheus:~] $ rmdir /mnt/fast1 /mnt/fast2

# Effacer les signatures ext4 : ZFS refuse un disque qui porte encore
# un système de fichiers reconnaissable.
[root@morpheus:~] $ wipefs -a /dev/disk/by-id/ata-Samsung_SSD_840_EVO_250GB_S1DBNSBDC58612P
[root@morpheus:~] $ wipefs -a /dev/disk/by-id/ata-TS240GSSD220S_661182A4E34961210374
Erreur à éviter : wipefs -a est instantané et sans confirmation. Relisez le chemin by-id caractère par caractère avant d’appuyer sur Entrée — un /dev/sdX qui a glissé d’une lettre au dernier reboot, et c’est le disque du système qu’on efface. C’est précisément pour cela qu’on n’utilise jamais /dev/sda ici, mais /dev/disk/by-id/…, qui nomme le disque par son modèle et son numéro de série.

2. Créer le pool
#

[root@morpheus:~] $ zpool create -o ashift=12 -o autotrim=on \
    -O compression=lz4 -O atime=off -O xattr=sa -O mountpoint=/mnt/objects \
    objects mirror \
    /dev/disk/by-id/ata-Samsung_SSD_840_EVO_250GB_S1DBNSBDC58612P \
    /dev/disk/by-id/ata-TS240GSSD220S_661182A4E34961210374
invalid vdev specification
use '-f' to override the following errors:
mirror contains devices of different sizes

Refus attendu, et c’est une bonne nouvelle : ZFS ne construit pas en silence un miroir dont une partie de la capacité sera perdue. Il demande une confirmation explicite. On relit la liste d’erreurs — une seule ici, et c’est celle qu’on assume — puis on repasse la commande avec -f :

[root@morpheus:~] $ zpool create -f -o ashift=12 -o autotrim=on \
    -O compression=lz4 -O atime=off -O xattr=sa -O mountpoint=/mnt/objects \
    objects mirror \
    /dev/disk/by-id/ata-Samsung_SSD_840_EVO_250GB_S1DBNSBDC58612P \
    /dev/disk/by-id/ata-TS240GSSD220S_661182A4E34961210374

[root@morpheus:~] $ zpool status objects
  pool: objects
 state: ONLINE
config:

    NAME                                               STATE     READ WRITE CKSUM
    objects                                            ONLINE       0     0     0
      mirror-0                                         ONLINE       0     0     0
        ata-Samsung_SSD_840_EVO_250GB_S1DBNSBDC58612P  ONLINE       0     0     0
        ata-TS240GSSD220S_661182A4E34961210374         ONLINE       0     0     0

errors: No known data errors

Les disques apparaissent sous leur nom by-id complet, modèle et numéro de série inclus : c’est ce qui permettra, le jour d’un remplacement, de savoir lequel des deux débrancher sans jouer aux devinettes derrière la machine.

Option Pourquoi celle-là
ashift=12 Blocs de 4 Kio. Définitif : un pool créé en ashift=9 ne se corrige pas, il se recrée. Tous les SSD modernes mentent sur leur taille de secteur ; 12 est le choix sûr.
autotrim=on Informe les SSD des blocs libérés, en continu. Sans lui, le ramasse-miettes interne travaille à l’aveugle et les performances d’écriture s’effondrent à mesure que le disque se remplit.
compression=lz4 Ne gagne rien sur des JPEG, mord bien sur un GeoPackage, et coûte quasi rien : le CPU abandonne dès qu’un bloc résiste.
atime=off Aucune raison de réécrire une date d’accès à chaque lecture de bloc.
xattr=sa Attributs étendus stockés dans l’inode plutôt que dans un fichier caché — moins d’I/O, recommandé pour tout ce qui touche aux conteneurs.
mountpoint=/mnt/objects Le pool et ses enfants apparaissent sous /mnt/objects, à côté de /mnt/tank et /mnt/storage. Cohérent avec l’existant.

Astuce : le nom du pool mérite trente secondes de réflexion — il se lira dans zpool status, dans tous les chemins, dans mp0, dans sanoid.conf et dans le .env pendant des années. Les deux anciens montages s’appelaient fast1 et fast2 ; reprendre fast aurait été tentant, et faux : un SSD SATA de 2013 n’est « rapide » que par rapport à un disque mécanique, et cette référence change au premier disque ajouté. Nommez un pool par son rôle ou son support, jamais par une performance relative. objects dit ce qu’il porte, à côté de tank, parity et storage qui suivent la même logique.

Deux contraintes de forme : ZFS refuse les noms réservés (mirror, raidz, draid, log, spare, dedup) et ceux qui commencent par c suivi d’un chiffre. Et un renommage reste possible plus tard — zpool export objects puis zpool import objects <nouveau> — au prix d’une reprise de tous les chemins qui le mentionnent.


Info : un miroir prend la taille de son plus petit membre. Ici, les 228 Go du Samsung sont ramenés aux 219 Go du Transcend : neuf gigaoctets inutilisables, le prix de la dissymétrie — et la raison du refus ci-dessus. Ils redeviendront disponibles le jour où le Transcend sera remplacé par un disque de taille égale ou supérieure, sans rien casser : ZFS agrandit le miroir tout seul (autoexpand=on).

Attention : -f ne cible pas une erreur en particulier, il les force toutes. Si la liste avait mentionné, en plus de la différence de taille, un disque portant encore une table de partition ou appartenant à un pool exporté, -f serait passé outre sans distinguer. Lisez la liste entière avant d’ajouter le drapeau — c’est là que se cache le disque qu’on croyait libre.

Alternative : plutôt que forcer, on peut partitionner le plus grand disque à la taille du plus petit (sgdisk -n1:0:+219G) et construire le miroir sur les partitions. Deux avantages : plus de -f, et une petite réserve qui permettra d’accueillir un jour un disque de remplacement légèrement plus petit — les « 240 Go » de deux fabricants ne font jamais exactement le même nombre de secteurs. L’inconvénient est réel aussi : des chemins by-id à rallonge (…-part1) dans toutes les commandes, et une étape de plus à refaire à chaque remplacement. Pour un pool de homelab à deux membres, -f assumé reste le meilleur rapport clarté/bénéfice.

3. Vérifier la santé, tout de suite
#

Un scrub sur un pool vide ne coûte rien et valide la chaîne complète — câbles SATA compris.

[root@morpheus:~] $ zpool scrub objects
[root@morpheus:~] $ zpool status -v objects
  scan: scrub repaired 0B in 00:00:00 with 0 errors on Tue Sep  8 22:51:00 2026
errors: No known data errors

[root@morpheus:~] $ zpool list -o name,size,free,ashift,health objects
NAME      SIZE   FREE  ASHIFT    HEALTH
objects   222G   222G      12    ONLINE

00:00:00 : un scrub sur un pool vide n’a rien à lire. L’intérêt n’est pas le temps passé, c’est que la commande aboutisse sans erreur d’entrée/sortie — les câbles SATA, les deux contrôleurs et l’alimentation viennent d’être validés.

ashift 12 confirme le choix irréversible du départ. Quant aux 222G, c’est la taille brute du vdev vue par zpool ; zfs list en annoncera 215, ZFS réservant quelques pour cent pour ne jamais se retrouver à zéro octet libre — un pool plein est un pool où l’on ne peut même plus effacer. C’est cette seconde valeur, l’espace réellement allouable, qui sert de base au dimensionnement.

Le scrub périodique, lui, n’est pas à mettre en place : Debian — et donc Proxmox — s’en charge déjà pour tous les pools. La section suivante le vérifie plutôt que de le supposer.

Vivre avec deux pools sur la même machine
#

Deux pools ZFS sur un hyperviseur, c’est banal. Trois détails méritent pourtant d’être réglés le jour de la création, pas le jour de la panne.

1. Le pool qui n’est pas monté au démarrage
#

C’est le seul de ces points qui puisse coûter des données, et il est vicieux. Si objects n’est pas importé — ou importé mais dataset non monté — au moment où la LXC 121 démarre, le point de montage mp0 pointe sur un répertoire vide. Docker Compose recrée alors gentiment meta/ et data/, Garage démarre sur un stockage vierge et se réamorce sous une nouvelle identité. Aucune erreur, aucun avertissement : juste un stockage objet qui a tout oublié.

flowchart TD
    BOOT["Démarrage de Morpheus"] --> IMP{"pool objects
importé ?"} IMP -->|Non| VIDE["/mnt/objects/garage absent
ou vide"] IMP -->|Oui| MNT{"dataset monté ?"} MNT -->|Non| VIDE MNT -->|Oui| OK["LXC 121 → /mnt/garage peuplé
Garage retrouve son ID"] VIDE --> CHP{"create_host_path
false ?"} CHP -->|Oui| STOP["Le conteneur refuse
de démarrer — on est prévenu"] CHP -->|Non, défaut| PERTE["Docker crée les dossiers
Garage se réamorce vierge"] style PERTE fill:#ffcdd2,stroke:#c62828,stroke-width:2px,color:#000 style STOP fill:#ffe0b2,stroke:#e65100,stroke-width:2px,color:#000 style OK fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px,color:#000

La parade est une option de Compose, posée plus bas dans l’article : create_host_path: false. Elle transforme un incident silencieux en refus de démarrage bruyant — exactement ce qu’on veut d’une panne.

Côté hôte, deux vérifications suffisent à s’assurer que l’import est bien automatique :

[root@morpheus:~] $ systemctl is-enabled zfs-import-cache zfs-mount zfs.target
enabled
enabled
enabled

[root@morpheus:~] $ zpool get cachefile objects
NAME     PROPERTY   VALUE      SOURCE
objects  cachefile  -          default

Trois unités et une propriété à lire ensemble. zfs-import-cache importe au démarrage les pools listés dans /etc/zfs/zpool.cache, zfs-mount monte leurs datasets, et zfs.target ordonne le tout avant les services qui en dépendent. La valeur - avec la source default dit que objects utilise bien ce fichier de cache — c’est le comportement normal d’un pool créé par zpool create, et c’est ce qui évite un scan de tous les disques à chaque boot.

# Vérification directe : quels pools le fichier de cache contient-il ?
[root@morpheus:~] $ zdb -C | grep -E "^[[:space:]]+name:"
    name: 'objects'
    name: 'rpool'

zdb -C lit ce fichier de cache et rien d’autre : les deux pools y figurent, ils seront donc importés au démarrage sans que ZFS ait à parcourir les disques.

Un cachefile positionné à none, en revanche, exclurait le pool de l’import automatique — c’est ce qu’on fait pour un pool amovible, jamais pour celui qui porte un service.

Astuce : redémarrez Morpheus une fois, volontairement, avant de considérer le chantier terminé. Un pool qui ne remonte pas au boot se découvre bien mieux un jour où vous l’avez décidé qu’un mardi matin après une coupure de courant. Contrôle en une ligne au retour : zfs get -o name,value mounted objects/garage.

2. L’ARC est commun, pas cumulatif
#

ZFS n’a qu’un seul cache de lecture pour toute la machine, borné par zfs_arc_max. Ajouter un pool n’ajoute pas de consommation mémoire : les deux se partagent le même ARC, et le plus sollicité en prend la plus grosse part.

[root@morpheus:~] $ arc_summary | head -14
ZFS Subsystem Report                            Tue Sep 08 23:04:04 2026
Linux 7.0.14-14-pve                                           2.4.4-pve1

ARC status:
        Total memory size:                                      62.6 GiB
        Min target size:                                3.1 %    2.0 GiB
        Max target size:                               25.6 %   16.0 GiB
        Target size (adaptive):                        95.0 %   16.0 GiB
        Current size:                                  95.0 %   15.2 GiB
        Free memory size:                                       20.9 GiB

[root@morpheus:~] $ cat /sys/module/zfs/parameters/zfs_arc_max
17179869184

Quatre nombres à lire dans cet ordre. 62,6 GiB de mémoire totale, dont l’ARC ne prendra jamais plus de 16 GiB — une borne posée explicitement, comme le confirme le paramètre du module (17179869184 octets, soit 16 GiB ; un 0 signifierait « laisse le module décider »). L’ARC occupe aujourd’hui 15,2 GiB, c’est-à-dire 95 % de sa cible : un cache qui se remplit jusqu’à sa limite est un cache qui travaille, pas un cache qui fuit. Il restitue la mémoire dès qu’une VM en réclame.

Le point qui nous intéresse : ces 16 GiB ne bougeront pas en ajoutant objects. Les deux pools puiseront dans le même cache, et le plus sollicité en prendra la plus grosse part — un stockage objet écrit surtout, et lit peu ; il ne devrait pas déloger grand-chose.

Info : la règle du « 1 Go de RAM par To de stockage » est un héritage de la déduplication, qui maintient une table entière en mémoire. Sans dedup — et vous ne l’activez pas — elle ne s’applique pas. Ce qui compte sur un hyperviseur, c’est que l’ARC laisse assez de RAM aux VM : zfs_arc_max se règle dans /etc/modprobe.d/zfs.conf, pas au petit bonheur.

3. L’entretien périodique, que vous n’avez rien à activer
#

Le réflexe serait d’armer un timer systemd pour le nouveau pool. Ce serait une erreur : sur Debian, le paquet zfsutils-linux installe un cron qui traite tous les pools importés, et le nouveau en fait partie dès sa création.

[root@morpheus:~] $ cat /etc/cron.d/zfsutils-linux
# TRIM the first Sunday of every month.
24 0 1-7 * * root if [ $(date +\%w) -eq 0 ] && [ -x /usr/lib/zfs-linux/trim ]; then /usr/lib/zfs-linux/trim; fi

# Scrub the second Sunday of every month.
24 0 8-14 * * root if [ $(date +\%w) -eq 0 ] && [ -x /usr/lib/zfs-linux/scrub ]; then /usr/lib/zfs-linux/scrub; fi

La construction 8-14 puis date +%w -eq 0 est une astuce de calendrier : cron ne sait pas exprimer « le deuxième dimanche du mois », alors on lui demande de se réveiller chaque jour de la deuxième semaine et de ne rien faire sauf le dimanche.

La preuve que le mécanisme fonctionne se lit sur le pool existant :

[root@morpheus:~] $ zpool status rpool | grep scan
  scan: scrub repaired 0B in 00:11:43 with 0 errors on Sun Aug  9 00:35:44 2026

[root@morpheus:~] $ systemctl list-unit-files 'zfs-scrub*'
UNIT FILE                STATE    PRESET
zfs-scrub@.service       static   -
zfs-scrub-monthly@.timer disabled enabled
zfs-scrub-weekly@.timer  disabled enabled

Les unités systemd existent, mais restent désactivées : c’est le cron qui travaille. En activer une par-dessus donnerait deux scrubs mensuels sur le même pool — sans danger, mais sans intérêt non plus.

Astuce : avant d’ajouter un automatisme, cherchez celui que votre distribution a déjà posé. systemctl list-timers --all et ls /etc/cron.d/ répondent en deux secondes, et évitent la couche de configuration en trop — celle que personne ne se rappellera avoir mise, et qui fera doublon pendant des années.

Les deux pools seront donc scrubés le même dimanche matin, à quelques secondes d’intervalle. Sur des SSD, c’est indolore : rpool y passe onze minutes, et objects avec ses quelques gigaoctets de blocs en demandera moins d’une. Si la concurrence devenait gênante — sur des disques mécaniques, par exemple — un systemctl edit zfs-scrub-monthly@objects.timer avec un OnCalendar=Sun *-*-15..21 02:00:00 décalerait le nouveau pool d’une semaine, à condition d’exclure ce pool du script cron.

Info : le même cron lance un zpool trim le premier dimanche du mois. Avec autotrim=on posé à la création, les blocs libérés sont déjà signalés au fil de l’eau : le TRIM mensuel devient une session de rattrapage, pas le mécanisme principal. Les deux cohabitent sans se gêner — c’est même la configuration recommandée pour des SSD grand public, dont le ramasse-miettes interne apprécie qu’on lui parle souvent.

Deux derniers points, sans commande associée : les feature flags du nouveau pool sont ceux de votre version d’OpenZFS — un live-CD plus ancien saura peut-être importer rpool sans savoir importer objects, ce qui vaut la peine d’être su avant une session de secours. Et ne déclarez pas objects dans /etc/pve/storage.cfg : tant qu’il n’y figure pas, l’interface Proxmox n’y créera jamais de disque de VM. Ce pool a un seul locataire.

La chaîne de montage, bout à bout
#

Six maillons séparent les deux SSD du répertoire que Garage ouvre en écriture. Chacun peut casser la chaîne ; autant les avoir sous les yeux.
graph TB
    subgraph MORPHEUS["Nœud Morpheus (PVE)"]
        SUBVOL[("rpool/…/subvol-121-disk-0
disque racine LXC · 17 Go")] POOL[("pool objects · miroir
2 SSD SATA · 215 Go utiles")] DS[("objects/garage
dataset dédié · refquota 25 Gio")] HOSTMP["/mnt/objects/garage (hôte)"] end subgraph CT121["LXC 121 · storage"] CTMP["/mnt/garage (conteneur)"] VOL["/var/lib/garage/{meta,data}"] GARAGE["conteneur garage"] end POOL -->|"zfs create"| DS DS -->|"mountpoint hérité"| HOSTMP HOSTMP -->|"pct set 121 -mp0 …,mp=/mnt/garage"| CTMP CTMP -->|"bind Docker Compose"| VOL VOL -->|"data_dir / metadata_dir (garage.toml)"| GARAGE SUBVOL -.->|"avant : ./meta ./data
sur le disque racine"| GARAGE style POOL fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px,color:#000 style DS fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px,color:#000 style SUBVOL fill:#ffcdd2,stroke:#c62828,stroke-width:2px,color:#000 style GARAGE fill:#ffe0b2,stroke:#e65100,stroke-width:2px,color:#000
# Maillon Où Défini par
1 Pool objects (miroir de 2 SSD) Morpheus zpool create … mirror
2 Dataset objects/garage Morpheus zfs create
3 Point de montage /mnt/objects/garage Morpheus propriété mountpoint (héritée du pool)
4 Point de montage /mnt/garage LXC 121 pct set 121 -mp0 …
5 Bind /var/lib/garage/{data,meta} conteneur garage volumes: du Compose (via .env)
6 data_dir / metadata_dir processus Garage garage.toml (inchangé)

Créer le dataset objects/garage
#

Un zfs create, quelques propriétés choisies pour un stockage d’objets, et le point de montage tombe tout seul au bon endroit.
[root@morpheus:~] $ zfs create objects/garage

# Le pool est monté sur /mnt/objects → l'enfant hérite de /mnt/objects/garage
[root@morpheus:~] $ zfs list -o name,used,avail,refer,mountpoint objects objects/garage
NAME             USED  AVAIL  REFER  MOUNTPOINT
objects          660K   215G    96K  /mnt/objects
objects/garage    96K   215G    96K  /mnt/objects/garage

Les deux lignes annoncent le même AVAIL : tant qu’aucun quota n’est posé, parent et enfant se partagent la totalité du pool — le premier qui écrit prend la place. C’est exactement ce qu’on corrigera en fin d’article.

compression=lz4 et atime=off ont été posés à la création du pool : le dataset en hérite. Reste une propriété qui lui est propre :

# Blocs alignés sur la taille de bloc par défaut de Garage (1 Mio)
[root@morpheus:~] $ zfs set recordsize=1M objects/garage
Astuce : recordsize=1M est idéal pour data/ (gros blocs) mais peu flatteur pour meta/ (SQLite, petites écritures aléatoires). À l’échelle d’un homelab, meta/ pèse quelques dizaines de Mo et l’écart est invisible. S’il venait à grossir, créez un enfant dédié — zfs create -o recordsize=16K objects/garage/meta — qui apparaîtra sous le même point de montage sans rien changer au reste.

Les quotas (refquota, quota) viendront plus bas, une fois le besoin réel chiffré : les poser au doigt mouillé maintenant serait reproduire l’erreur qu’on cherche justement à corriger.

Apprendre le nouveau pool à Sanoid
#

La politique de snapshots de l’annexe A01 couvre rpool. Un pool neuf lui est parfaitement invisible — et personne ne vous préviendra.

C’est le piège de ce chantier. Tant que Garage vivait sous rpool/tank, il héritait de la politique posée à l’annexe A01 sans une ligne de configuration. En changeant de pool, on sort du périmètre de Sanoid : plus un seul snapshot automatique, et aucune erreur pour le signaler. On s’en aperçoit le jour où on cherche à restaurer.

Un bloc dans /etc/sanoid/sanoid.conf répare cela. Il se place avec les autres datasets, avant le bandeau des templates — l’ordre n’a pas d’incidence technique, Sanoid lit tout le fichier avant d’agir, mais un fichier de configuration se relit à deux ans de distance :

# Données personnelles - rétention modérée
[rpool/tank]
  use_template = data
  recursive = yes
  process_children_only = yes

# Stockage objet Garage — même rétention que les autres données
[objects/garage]
  use_template = data
  recursive = yes

#############################
# Templates                 #
#############################

Pas de process_children_only ici, contrairement à [rpool/tank]. Cette option évite de snapshoter un dataset parent qui ne porte rien lui-même, ce qui est le cas de tank — mais pas celui d’objects/garage, où c’est précisément ce dataset qu’on veut figer, meta/ et data/ d’un seul coup. Le recursive = yes ne sert qu’à couvrir d’avance un éventuel enfant.

[root@morpheus:~] $ sanoid --cron --verbose        # forcer un premier passage
INFO: taking snapshots...
INFO: pruning snapshots...

[root@morpheus:~] $ zfs list -t snapshot objects/garage
NAME                                                USED  AVAIL  REFER  MOUNTPOINT
objects/garage@autosnap_2026-09-08_21:15:05_daily     0B      -    96K  -

Ne cherchez pas la confirmation dans les messages de Sanoid : --verbose annonce qu’il prend des snapshots, jamais lesquels, et --readonly reste tout aussi muet sur le détail. La preuve est le snapshot lui-même — s’il apparaît dans zfs list, la section a été lue ; s’il n’apparaît pas, elle ne l’a pas été. Le suffixe _daily confirme au passage que c’est bien le template data qui s’applique.

REFER 96K : le dataset est vide, ce snapshot ne coûte rien. Il vient de figer un répertoire vide, ce qui est parfaitement inutile aujourd’hui et rassurant pour la suite — la mécanique tourne avant que les données arrivent.

Le template data de l’A01 conserve 8 snapshots quotidiens, soit environ un mois d’historique. Pour un stockage objet dont les objets ne sont jamais réécrits, la place consommée par ces snapshots reste marginale — c’est ce que mesure la colonne usedsnap du tableau de bord, plus bas.

Info : recursive = yes couvre d’avance l’enfant objects/garage/meta si vous le créez un jour. Et si vous ajoutez d’autres services sur ce pool, préférez une entrée par dataset à une entrée [objects] récursive : les rétentions d’un stockage objet et d’un dépôt de sauvegarde n’ont aucune raison d’être les mêmes.

Monter le dataset dans la LXC 121
#

Un point de montage Proxmox, un redémarrage du conteneur — et une question de droits dont la réponse dépend d’une ligne absente du fichier de configuration.

Nous voici enfin à ce pour quoi cet article avait été commencé : donner à Garage un peu de place. Six sections plus tôt, il s’agissait de déplacer deux répertoires ; depuis, nous avons audité deux SSD, bâti un pool, discuté nommage, scrubs et caches ARC. C’est souvent ainsi que se déroulent les chantiers d’infrastructure — la partie qu’on avait prévue tient en deux commandes, et tout le reste est ce qu’on a découvert en chemin.

1. Déclarer le point de montage
#

On déclare le dataset comme point de montage de la LXC 121. Ici le slot mp0 est libre — adaptez le numéro si un mpX est déjà pris sur votre conteneur :

[root@morpheus:~] $ pct config 121 | grep -E '^mp[0-9]|unprivileged'

[root@morpheus:~] $ pct set 121 -mp0 /mnt/objects/garage,mp=/mnt/garage,backup=0
[root@morpheus:~] $ pct reboot 121

Le grep ne renvoie rien, et ce silence dit deux choses. Aucun mpX n’est déclaré : le slot mp0 est libre. Et aucune ligne unprivileged n’apparaît, ce qui signifie que le conteneur est privilégié — c’est le piège de cette configuration, sur lequel la section suivante s’attarde.

Attention : backup=0 n’est pas cosmétique. Sans lui, vzdump (et donc PBS) tenterait d’avaler l’intégralité des objets Garage à chaque sauvegarde du conteneur — potentiellement des centaines de Go. Les objets se sauvegardent au niveau du dataset ZFS, pas du conteneur.

2. Régler les droits, selon le type de conteneur
#

C’est ici que tout se joue, et la première chose à établir est le type du conteneur. Il ne se lit pas où on l’attend :

[root@morpheus:~] $ cat /etc/pve/lxc/121.conf
arch: amd64
cores: 2
features: fuse=1,nesting=1
hostname: storage
memory: 2048
…
Attention : il n’existe pas de ligne unprivileged: 0. Proxmox n’écrit unprivileged: 1 que pour les conteneurs non privilégiés ; l’absence de cette ligne signifie donc « privilégié ». Un grep unprivileged qui ne renvoie rien n’est pas un échec de commande, c’est une réponse — et la confondre avec l’autre cas mène droit à un chown sur le mauvais UID, puis à des Permission denied qu’on cherchera longtemps.

Cas 1 — conteneur privilégié (celui de la LXC 121). Aucun décalage : le root du conteneur est l’UID 0 de l’hôte. Le dataset créé par zfs create appartient déjà à root:root, et le conteneur garage tourne en root — l’image dxflrs/garage ne déclare pas d’utilisateur, docker exec garage id le confirme. Il n’y a donc rien à faire, sinon vérifier :

[root@morpheus:~] $ ls -ld /mnt/objects/garage
drwxr-xr-x 2 root root 2 Sep  8 22:47 /mnt/objects/garage

Si Garage tournait sous un utilisateur applicatif dédié, il faudrait donner le dataset à cet UID — le même des deux côtés, puisqu’il n’y a pas de translation.

Cas 2 — conteneur non privilégié (unprivileged: 1). Les UID sont décalés de 100000 : le root du conteneur correspond à l’UID 100000 sur l’hôte, et l’utilisateur 1000 à 101000. Le dataset, qui appartient à root:root de l’hôte, apparaît alors en nobody:nogroup vu du conteneur et n’est pas inscriptible. Il faut le donner au root du conteneur :

# Sur Morpheus, AVANT de rebrancher Garage
[root@morpheus:~] $ chown -R 100000:100000 /mnt/objects/garage
Alternative : plutôt que de décaler la propriété du dataset, on peut décaler la vue du conteneur avec un lxc.idmap dans /etc/pve/lxc/121.conf (mapper l’UID 0 du conteneur sur l’UID 0 de l’hôte pour ce sous-arbre). Plus fin, mais plus verbeux, et à réserver au cas où le dataset est partagé avec plusieurs conteneurs aux UID différents. Pour un dataset dédié à une seule LXC, le chown suffit.
Architecture : la simplicité du cas 1 a une contrepartie qu’il faut avoir en tête. Dans un conteneur privilégié, root est le root de l’hôte : une évasion depuis le conteneur donne les clés de Morpheus, et un chown maladroit lancé dedans s’applique aux vrais fichiers de l’hôte. Ce choix se défend pour une LXC de confiance qui fait tourner Docker — c’est le compromis retenu ici — mais il se sait. Un conteneur non privilégié aurait le décalage d’UID pour lui, au prix des quelques lignes du cas 2.

3. Vérifier depuis le conteneur
#

[allfab@storage:~] $ df -h /mnt/garage
Filesystem      Size  Used Avail Use% Mounted on
objects/garage  216G  1.0M  216G   1% /mnt/garage

[allfab@storage:~] $ sudo -u '#0' touch /mnt/garage/.rw-test && echo OK && sudo rm /mnt/garage/.rw-test
OK

Deux choses à noter. Le dataset traverse bien la chaîne de montage jusqu’au conteneur, et il est inscriptible par l’UID 0 — ce qui, dans un conteneur privilégié, est exactement l’utilisateur sous lequel tournera Garage. Quant à l’écart avec les 215 Go annoncés par zfs list sur Morpheus, il n’a rien d’inquiétant : df et zfs list ne comptabilisent pas la réserve interne du pool de la même façon. C’est le chiffre de zfs list, plus conservateur, qui sert de base au dimensionnement.

Câbler Garage sur le dataset
#

Une variable dans le .env, une interpolation dans le Compose. Absente, le comportement de l’A02 est conservé au bit près.

1. L’interrupteur, dans .env
#

Le .env de la stack (/opt/docker/storage-stack/.env) reçoit une variable :

# /opt/docker/storage-stack/.env
COMPOSE_PROJECT_NAME=garage

# --- STOCKAGE ---
# Racine des répertoires meta/ et data/ de Garage.
# Vide ou absente  -> "." (dossier garage/), comportement A02, disque racine LXC.
# /mnt/garage      -> dataset ZFS objects/garage monté dans la LXC.
GARAGE_STORAGE_ROOT=/mnt/garage

# GARAGE
RPC_SECRET = "…"
ADMIN_TOKEN = "…"
METRICS_TOKEN = "…"
# … (inchangé)

2. L’interpolation, dans garage/docker-compose.yml
#

# /opt/docker/storage-stack/garage/docker-compose.yml
---
services:
  garage:
    container_name: garage
    image: dxflrs/garage:v2.3.0
    hostname: allfabox
    restart: unless-stopped
    ports:
      - 3900:3900
      - 3901:3901
      - 3902:3902
      - 3903:3903
    volumes:
      - ./config/garage.toml:/etc/garage.toml
      # Syntaxe longue : create_host_path=false interdit à Docker de créer
      # le répertoire s'il manque. Si le dataset n'est pas monté, le conteneur
      # refuse de démarrer au lieu de repartir sur un stockage vierge.
      - type: bind                                    # <- était ./meta
        source: ${GARAGE_STORAGE_ROOT:-.}/meta
        target: /var/lib/garage/meta
        bind:
          create_host_path: false
      - type: bind                                    # <- était ./data
        source: ${GARAGE_STORAGE_ROOT:-.}/data
        target: /var/lib/garage/data
        bind:
          create_host_path: false
    networks:
      - storage
  # garage-webui : inchangé

${GARAGE_STORAGE_ROOT:-.} se lit « la valeur de GARAGE_STORAGE_ROOT, ou . si elle est vide ou absente ». Compose interpole depuis le .env du dossier courant. Le . se résout relativement à l’emplacement du fichier Compose (garage/) : le défaut reste donc garage/meta et garage/data, à l’identique de l’A02.

La syntaxe longue (type: bind) n’est pas un caprice de style : c’est la seule qui accepte create_host_path. Par défaut — et c’est le comportement de la forme courte source:target — Docker crée le répertoire hôte manquant, sans un mot. Passé à false, un chemin absent devient une erreur au démarrage :

$ docker compose up -d
Error response from daemon: invalid mount config: bind source path does not exist: /mnt/garage/meta

Un message désagréable, et infiniment préférable à un Garage qui redémarre vierge parce que le pool n’était pas monté. Le prix à payer est mince : les répertoires doivent exister avant le premier up — ce qui est déjà le cas dans les deux configurations, garage/meta héritée de l’A02 comme /mnt/garage/meta créée à la migration.

Info : ne lancez pas encore docker compose up. La configuration est prête, mais les données sont toujours dans garage/data et garage/meta. Passer à /mnt/garage maintenant démarrerait Garage sur un dataset vide — et le nœud se réamorcerait sous une nouvelle identité. La migration d’abord.

Migrer les données existantes
#

Attention : à partir d’ici, tout se passe dans la LXC 121, pas sur Morpheus. Le prompt le dit ([root@storage:~] ou [allfab@storage:storage-stack]), mais l’erreur est facile après une série de commandes sur l’hôte :

$ rsync … /opt/docker/storage-stack/garage/meta/ /mnt/garage/meta/
rsync: [sender] change_dir "/opt/docker/storage-stack/garage/meta" failed: No such file or directory (2)
rsync: [Receiver] mkdir "/mnt/garage/meta" failed: No such file or directory (2)

Deux erreurs, une par extrémité : ni le dépôt de la stack ni le point de montage n’existent sous ces noms sur l’hôte — le dataset s’y appelle /mnt/objects/garage. On entre dans le conteneur d’abord : pct enter 121.

Les octets d’abord, la bascule ensuite. Jamais l’inverse — c’est la même discipline que pour le déménagement du stockage QFieldCloud.

1. Arrêter Garage
#

meta/ est une base SQLite : elle doit être au repos pendant la copie.

$ docker compose stop garage garage-webui
[+] stop 2/2
 ✔ Container garage       Stopped                    0.8s
 ✔ Container garage-webui Stopped                    0.1s

garage-webui s’arrête aussi : elle interroge l’API d’administration en continu et n’a rien à faire pendant que la base se déplace.

2. Recopier meta/ puis data/
#

Premier obstacle, et il surprend : les templates Debian de Proxmox sont minimalistes, rsync n’y est pas.

$ rsync -aHAX --numeric-ids /opt/docker/storage-stack/garage/meta/ /mnt/garage/meta/
-bash: rsync: command not found

$ apt update && apt install -y rsync

Il vaut largement son mégaoctet ici : -aHAX préserve attributs étendus et ACL, et -n --itemize-changes permet la passe à blanc qui prouve que la copie est complète. cp ne sait rien faire de tel — au mieux un diff -r a posteriori, qui relit tout et ne dit rien des métadonnées.

Depuis la LXC 121, en root (--numeric-ids préserve les UID tels quels, ce qui compte si vous rejouez cette copie depuis l’hôte d’un conteneur non privilégié) :

[root@storage:~] $ rsync -aHAX --numeric-ids --info=progress2 \
    /opt/docker/storage-stack/garage/meta/  /mnt/garage/meta/
      7,199,735 100%  624.13MB/s    0:00:00 (xfr#12, to-chk=0/16)

[root@storage:~] $ rsync -aHAX --numeric-ids --info=progress2 \
    /opt/docker/storage-stack/garage/data/  /mnt/garage/data/
        220,376 100%   42.02MB/s    0:00:00 (xfr#7, to-chk=0/744)

Les volumes disent bien ce qu’est cette instance à ce stade : 7 Mo de base et 220 Ko de blocs, soit quelques secondes de copie. La même opération sur une instance chargée demanderait de compter en heures — raison de plus pour l’avoir faite avant que le bucket de production n’arrive.

Puis la passe à blanc, sur les deux répertoires. -n simule, --itemize-changes détaille : une sortie vide signifie que rsync ne voit plus rien à transférer.

[root@storage:~] $ rsync -naHAX --numeric-ids --itemize-changes \
    /opt/docker/storage-stack/garage/data/  /mnt/garage/data/
[root@storage:~] $ rsync -naHAX --numeric-ids --itemize-changes \
    /opt/docker/storage-stack/garage/meta/  /mnt/garage/meta/

Deux silences : c’est le résultat attendu, et le seul acceptable avant de rebrancher. Ne vous contentez pas de vérifier data/ — les blocs se recopient toujours, tandis qu’un meta/ incomplet donne un nœud qui a perdu la mémoire.

3. Rebrancher et vérifier l’identité du nœud
#

[allfab@storage:storage-stack] $ docker compose up -d
[+] up 2/2
 ✔ Container garage       Started                    0.2s
 ✔ Container garage-webui Started                    0.1s

# Le nœud doit reparaître avec le MÊME ID qu'avant la migration
[allfab@storage:storage-stack] $ docker exec -ti garage /garage status
INFO garage_net::netapp: Connection established to 0f6796f7b25ec365
==== HEALTHY NODES ====
ID                Hostname  Address           Tags  Zone      Capacity  DataAvail           Version
0f6796f7b25ec365  allfabox  10.10.20.21:3901  []    allfabox  3.7 GiB   215.1 GiB (100.0%)  v2.3.0

DataAvail a bondi : Garage voit désormais l’espace du dataset. Et cette ligne affiche crûment l’incohérence qu’il reste à corriger — 3,7 GiB de capacité déclarée pour 215 GiB disponibles. C’est l’héritage du -c 4G posé à l’A02, quand le stockage tenait sur le disque racine ; la dernière section de cet article s’en occupe. L’ID 0f6796f7b25ec365 est inchangé — c’est la preuve que meta/ a bien suivi. Un ID différent signifierait que seul data/ a été copié : dans ce cas, on arrête tout, on recopie meta/, on repart. Surtout, on n’applique aucun layout dans cet état.

# Contrôle de lecture rapide
[allfab@storage:storage-stack] $ docker exec -ti garage /garage bucket list
[allfab@storage:storage-stack] $ mc ls garage/

4. Que faire de l’ancien répertoire
#

Les données d’origine sont toujours là, sur le disque racine du conteneur. Trois façons de s’en occuper, du plus prudent au plus expéditif — et le disque racine ne fait que 17 Go, ce qui pousse à ne pas éterniser le premier choix.

  • Renommer (repli rapide)
  • Archiver hors du conteneur
  • Détruire directement

Le plus sûr, et le choix par défaut. Les données restent à portée de main, mais sous un nom qui interdit à Compose de les remonter par accident.

[allfab@storage:storage-stack] $ mv garage/data garage/data.old
[allfab@storage:storage-stack] $ mv garage/meta garage/meta.old
[allfab@storage:storage-stack] $ ls garage/
config  data.old  docker-compose.yml  meta.old

Il ne coûte que de la place, et il vaut cher le jour où quelque chose a été oublié. On le supprime une fois la nouvelle configuration éprouvée — mettez-vous un rappel, c’est le genre de .old qu’on retrouve deux ans plus tard.

Si le disque racine est déjà tendu, sortez la copie de repli du conteneur plutôt que de la garder à côté.

[allfab@storage:storage-stack] $ tar -C garage -czf /tmp/garage-avant-migration.tar.gz meta data
[allfab@storage:storage-stack] $ ls -lh /tmp/garage-avant-migration.tar.gz

# Depuis Morpheus, récupérer l'archive puis effacer la source
[root@morpheus:~] $ pct pull 121 /tmp/garage-avant-migration.tar.gz \
    /mnt/objects/backups/garage-avant-migration.tar.gz
[allfab@storage:storage-stack] $ rm -rf garage/meta garage/data

L’archive vit alors sur le pool miroir, snapshotée par Sanoid — nettement plus solide que le disque racine qu’on cherche justement à soulager.

Défendable uniquement quand les trois contrôles de l’étape précédente sont passés : garage status rend le même ID de nœud, bucket list liste les mêmes buckets, et une lecture d’objet aboutit.

[allfab@storage:storage-stack] $ docker exec -ti garage /garage status     # même ID
[allfab@storage:storage-stack] $ docker exec -ti garage /garage bucket list
[allfab@storage:storage-stack] $ mc cat garage/<bucket>/<un-objet-connu> | head -c 100

# Seulement ensuite :
[allfab@storage:storage-stack] $ rm -rf garage/meta garage/data

C’est le choix rationnel si vous avez déjà une sauvegarde par ailleurs — et l’unique cas où je le recommande. Sans elle, vous venez de supprimer le seul exemplaire de la base meta/, celle qui contient la carte de tous les objets.

Info : ce renommage a une conséquence sur la réversibilité annoncée en introduction. Retirer GARAGE_STORAGE_ROOT du .env ne suffit plus à revenir en arrière : Compose chercherait garage/meta et garage/data, qui ne portent plus ces noms, et refuserait de démarrer — create_host_path: false faisant son office. Le retour complet, c’est donc la variable retirée et les deux mv inverses. C’est plus sûr ainsi : un repli qu’on peut déclencher par mégarde n’est pas un repli.
Astuce : quelle que soit l’option, meta/ mérite plus d’égards que data/. Les blocs d’objets se reconstruisent depuis n’importe quelle copie ; la base meta/, elle, est ce qui relie un nom de bucket et une clé d’objet aux blocs correspondants. La perdre, c’est garder tous les octets sans savoir à quoi ils appartiennent.
sequenceDiagram
    participant A as Admin (LXC 121)
    participant D as Docker Compose
    participant R as rsync
    participant G as Garage
    A->>D: docker compose stop garage garage-webui
    A->>R: rsync meta/ puis data/ → /mnt/garage
    R-->>A: 2ᵉ passe à blanc : 0 transfert
    A->>A: .env : GARAGE_STORAGE_ROOT=/mnt/garage
    A->>D: docker compose up -d
    D->>G: montage /mnt/garage/{meta,data}
    G-->>A: garage status → même ID de nœud
    A->>A: mv garage/data → data.old (repli)

Repartir d’une instance propre
#

Deux buckets historiques, 478 Ko à eux deux. Les garder coûterait plus d’explications qu’ils ne valent d’octets.

L’instance porte deux buckets hérités : proxmox, et qfieldcloud — celui des tests de l’annexe A02 de QFieldCloud. Aucun des deux ne porte de donnée qu’on regretterait, et tous deux traînent une configuration décidée avant que le stockage n’ait sa place définitive. Les chiffres exacts, on va les lire plus bas — ils tiennent en deux fiches.

Attention : bucket delete est définitif et sans corbeille. Vérifiez le contenu avant, et exportez ce que vous voulez garder. Ici, mc ls --recursive tient sur un écran — c’est ce qui rend la table rase légitime. Sur un bucket réellement peuplé, la démarche ne serait pas celle-là.

Premier constat, en interrogeant Garage lui-même :

[allfab@storage:storage-stack] $ docker exec -ti garage /garage bucket list
ID                Created     Global aliases  Local aliases
648b571ced3df278  2025-11-25  proxmox
ba74be37dfcf7044  2026-09-04  qfieldcloud

Les dates racontent l’histoire : proxmox date du déploiement de l’A02, qfieldcloud des essais de la série QFieldCloud.

1. Se donner les moyens de regarder
#

Garage n’offre aucune commande d’administration pour lister ou supprimer des objets : le CLI gère les buckets, les clés et le layout, jamais leur contenu. Il faut donc un client S3 — mc, dont l’installation est détaillée à l’annexe A02 — et une clé qui ait le droit d’écrire dans ces deux buckets. Les clés applicatives existantes ne conviennent pas : chacune ne voit que son propre bucket. On en crée une pour l’occasion, qu’on jettera à la fin.

$ wget -q https://dl.min.io/client/mc/release/linux-amd64/mc
$ chmod +x mc && sudo mv mc /usr/local/bin/

Puis la clé de ménage. Notez Can create buckets: false — une clé Garage ne peut créer de bucket que si on le lui accorde explicitement, et ce n’est pas ce qu’on lui demande ici :

$ docker exec -ti garage /garage key create menage-temp
==== ACCESS KEY INFORMATION ====
Key ID:              GKbef20c0c388d98e5aa7a3cc9
Key name:            menage-temp
Secret key:          c6e45c90cf722e2691d3bfba8eb1004dde7e0e0e6ef556756b2b88ef549683cf
Created:             2026-09-08 21:59:31.526 +00:00
Validity:            valid
Expiration:          never

Can create buckets:  false

==== BUCKETS FOR THIS KEY ====
Permissions  ID  Global aliases  Local aliases

La clé existe mais ne voit rien. On l’autorise sur les deux buckets — et chaque bucket allow répond par la fiche complète du bucket, ce qui fait office d’inventaire :

$ docker exec -ti garage /garage bucket allow --read --write --owner proxmox --key menage-temp
==== BUCKET INFORMATION ====
Bucket:          648b571ced3df278…
Created:         2025-11-25 13:28:58.285 +00:00
Size:            159 B (159 B)
Objects:         1
Website access:  false
Global alias:    proxmox

==== KEYS FOR THIS BUCKET ====
Permissions  Access key                               Local aliases
RWO          GK6a2d6b1efd4f41d174975602  proxmox
RWO          GKbef20c0c388d98e5aa7a3cc9  menage-temp

$ docker exec -ti garage /garage bucket allow --read --write --owner qfieldcloud --key menage-temp
==== BUCKET INFORMATION ====
Bucket:          ba74be37dfcf7044…
Created:         2026-09-04 14:17:15.229 +00:00
Size:            489.5 kB (478.1 KiB)
Objects:         8
Website access:  false

Quotas:          enabled
  maximum size:  5.0 GB (4.7 GiB)

Global alias:    qfieldcloud

==== KEYS FOR THIS BUCKET ====
Permissions  Access key                                   Local aliases
RWO          GK20c7ce2347b9d7c025f6664e  qfieldcloud-app
RWO          GKbef20c0c388d98e5aa7a3cc9  menage-temp

Ces deux fiches valent mieux qu’un long discours sur l’état des lieux :

  • proxmox porte un objet de 159 octets — un essai de datastore PBS jamais poursuivi ;
  • qfieldcloud porte 8 objets pour 478 Kio, et surtout un Quotas: enabled — maximum size: 5.0 GB. C’est le quota de l’annexe A02 de QFieldCloud, posé sur un nœud qui n’en déclarait que 4 : l’incohérence qui a motivé tout cet article, lisible en une ligne ;
  • RWO signifie Read, Write, Owner — les trois permissions de Garage. menage-temp les a désormais sur les deux buckets, aux côtés des clés applicatives d’origine.

Reste à donner ces identifiants à mc :

$ docker exec -ti garage /garage key info --show-secret menage-temp
Key ID:              GKbef20c0c388d98e5aa7a3cc9
Secret key:          c6e45c90cf722e2691d3bfba8eb1004dde7e0e0e6ef556756b2b88ef549683cf

$ mc alias set garage http://10.10.20.21:3900 \
    GKbef20c0c388d98e5aa7a3cc9 \
    c6e45c90cf722e2691d3bfba8eb1004dde7e0e0e6ef556756b2b88ef549683cf --api S3v4
Added `garage` successfully.

Attention : ces identifiants sont affichés ici parce que la clé menage-temp est détruite à la fin de cette section — elle n’existe plus au moment où vous lisez ces lignes. Ne reproduisez pas l’exercice avec une clé destinée à durer : mc alias set inscrit le secret en clair dans votre historique de shell et dans ~/.mc/config.json, et garage key info --show-secret est le seul endroit où un secret Garage se relit — un key list ne l’affiche jamais.

Le ménage complet, une fois les nouveaux buckets créés :

[allfab@storage:~] $ docker exec -ti garage /garage key delete --yes GKbef20c0c388d98e5aa7a3cc9
[allfab@storage:~] $ mc alias remove garage
Removed `garage` successfully.

Supprimez par identifiant, pas par nom : garage key delete --yes menage-temp répond NoSuchAccessKey (404). Le nom sert à s’y retrouver dans un key list, l’ID à désigner la clé.

Astuce : une clé créée pour une opération ponctuelle se supprime à la fin de cette opération, pas « un de ces jours ». Nommez-la de façon à ce qu’elle dénonce elle-même son oubli — menage-temp se remarque dans un key list, admin2 non.

2. Inventorier, puis détruire
#

$ mc ls --recursive garage/proxmox
[2025-11-27 11:04:58 UTC]   159B STANDARD garage-s3/.chunks/bb9f/bb9f8df614…

$ mc ls --recursive garage/qfieldcloud
[2026-09-07 08:45:57 UTC] 184KiB STANDARD account/orga-demo/avatars/acca.jpg
[2026-09-07 07:41:18 UTC]  24KiB STANDARD account/portalcheck/avatars/allfab.jpg
[2026-09-04 14:22:22 UTC]  33KiB STANDARD projects/497a2200-…/files/chantier.qgs/v20260903150809-9f654330
[2026-09-04 14:22:22 UTC]  33KiB STANDARD projects/497a2200-…/files/chantier.qgs/v20260903151031-4bf024d5
[2026-09-04 14:22:22 UTC]  96KiB STANDARD projects/497a2200-…/files/releves.gpkg/v20260903150809-01af4fd3
[2026-09-04 14:22:22 UTC]   603B STANDARD projects/497a2200-…/meta/thumbnail_v20260903171035_d47e8851.png
[2026-09-04 14:25:05 UTC] 8.3KiB STANDARD projects/497a2200-…/packages/…/chantier_qfield.qgz
[2026-09-04 14:25:05 UTC] 100KiB STANDARD projects/497a2200-…/packages/…/data.gpkg

Prenez le temps de lire cette liste : elle vaut démonstration. On y voit l’arborescence de QFieldCloud — account/ pour les avatars, projects/<uuid>/files/ pour les fichiers du projet, packages/ pour les paquetages destinés à QField — et surtout chantier.qgs en deux exemplaires, v20260903150809 et v20260903151031. Deux enregistrements du même projet à vingt-deux minutes d’intervalle, deux copies entières de 33 Kio. C’est très exactement le mécanisme dont la section dimensionnement dira qu’il est le poste à surveiller : chaque versement écrit une version complète, jamais un différentiel.

Sur un GeoPackage de 60 Mo poussé deux fois par semaine, la même mécanique produit 6 Go par an. Ici, sur des fichiers de démonstration, elle ne coûte que quelques kilo-octets — mais elle est déjà là, visible.

$ mc rm --recursive --force garage/proxmox
Removed `garage/proxmox/garage-s3/.chunks/bb9f/bb9f8df614…` (versionId=049d8da2…).

$ mc rm --recursive --force garage/qfieldcloud
Removed `garage/qfieldcloud/account/orga-demo/avatars/acca.jpg` (versionId=81ac8d07…).
… (8 objets)

$ docker exec -ti garage /garage bucket delete --yes proxmox
Bucket 648b571ced3df278… has been deleted.

$ docker exec -ti garage /garage bucket delete --yes qfieldcloud
Bucket ba74be37dfcf7044… has been deleted.

L’ordre n’est pas négociable : Garage refuse de supprimer un bucket non vide, et il a raison — c’est la dernière barrière avant l’irréparable.

Restent les clés, qui survivent aux buckets. Une clé orpheline n’a plus aucun droit sur rien, mais elle reste un identifiant valide sur l’instance : autant ne pas la laisser traîner.

$ docker exec -ti garage /garage key list
ID                          Created     Name             Expiration
GK6a2d6b1efd4f41d174975602  2025-11-25  proxmox          never
GK20c7ce2347b9d7c025f6664e  2026-09-04  qfieldcloud-app  never
GKbef20c0c388d98e5aa7a3cc9  2026-09-08  menage-temp      never

$ docker exec -ti garage /garage key delete --yes GK6a2d6b1efd4f41d174975602
$ docker exec -ti garage /garage key delete --yes GK20c7ce2347b9d7c025f6664e
$ docker exec -ti garage /garage key delete --yes GKbef20c0c388d98e5aa7a3cc9

$ docker exec -ti garage /garage key list
ID  Created  Name  Expiration

Un key list vide : c’est le but. menage-temp part avec les autres — sa raison d’être vient de disparaître, et les identifiants publiés plus haut ne valent désormais plus rien.

Info : l’alias mc configuré à l’étape précédente est mort avec la clé. Toute commande mc répondra désormais par un 403 Forbidden parfaitement mérité. Il sera reconfiguré avec les nouvelles clés applicatives, une fois les buckets recréés — c’est l’objet de la section suivante.

L’instance est vide, le dataset est neuf : c’est le bon moment pour décider combien on veut y mettre, avant d’y remettre quoi que ce soit.

Dimensionner le stockage, pour de bon
#

Deux cent quinze gigaoctets disponibles ne sont pas une invitation à en consommer 215. Un stockage objet sans plafond réfléchi finit toujours par manger le pool qui l’héberge.

L’A02 posait -c 1G pour démarrer, en promettant de le corriger un jour vers la taille du disque racine — ce qui n’a jamais été fait : le nœud en est resté à 3,7 Gio déclarés. L’annexe A02 de QFieldCloud accordait de son côté un quota de 5 Go à son bucket, sur ce nœud qui en déclarait moins de 4. Deux chiffres décidés séparément, chacun défendable, incohérents ensemble. Le déménagement est l’occasion de les reprendre à partir de l’usage réel, pas à partir de la place disponible.

1. Ce qui va réellement atterrir dedans
#

Un seul consommateur pour l’instant : l’instance QFieldCloud de production. Elle écrit dans deux buckets distincts, qui n’ont ni le même contenu ni la même dynamique de croissance.

Bucket Ce qu’il contient Écrit par Croissance
qfieldcloud Le projet QGIS central, son GeoPackage, les paquetages et les deltas, versionnés QFieldCloud (Django/django-storages) par à-coups, à chaque push
qfieldcloud-attachments Les photos de terrain, en stockage externe QGIS (widget ExternalResource, URL absolue) QGIS / QField, directement linéaire, au rythme des relevés

Le public : une douzaine d’utilisateurs, dont la moitié verse effectivement des photos. Un projet central, pas une flotte de projets. Ces deux phrases suffisent à chiffrer.

Info : replication_factor = 1 (rappel du garage.toml de l’A02, nœud unique) : un octet écrit par un client est un octet sur le dataset. Pas de facteur 3 à provisionner. Ajoutez-y compression_level = 2 côté Garage, qui ne gagne rien sur des JPEG déjà compressés mais mord bien sur un GeoPackage.

2. Le calcul, hypothèses affichées
#

Aucun de ces nombres n’est une mesure : ce sont des hypothèses assumées, qu’on écrit pour pouvoir les corriger dans six mois plutôt que les redécouvrir.

Poste Hypothèse Par an
Photos de terrain 6 contributeurs actifs × 15 photos/mois × 3 Mo (JPEG 12 Mpx) ≈ 3,2 Go
Projet + GeoPackage versionnés GeoPackage ≈ 60 Mo, 2 versements/semaine, rétention des anciennes versions ≈ 6 Go brut
Métadonnées Garage (meta/) base SQLite, quelques milliers d’objets < 1 Go

Trois ans d’usage à ce rythme, sans jamais purger : environ 10 Go de photos et un versionnement qu’il faudra de toute façon borner bien avant. Le budget tient largement sous 25 Gio de données utiles — et c’est le chiffre à partir duquel tout le reste se déduit.

Astuce : le poste qui dérape n’est pas les photos, c’est le versionnement. Chaque push QFieldCloud d’un GeoPackage écrit une version complète du fichier, pas un différentiel. Un projet actif peut ainsi produire plusieurs fois le poids de sa donnée par mois. La bonne réponse est une rétention côté QFieldCloud, pas un quota plus large côté Garage.

3. Trois étages de plafond, et un seul qui doit servir
#

graph TB
    subgraph ZFS["Étage 1 — ZFS (filet de sécurité)"]
        Q["quota=35G
données + snapshots"] RQ["refquota=25G
données vivantes"] end subgraph GAR["Étage 2 — Garage (déclaratif)"] LAY["layout -c 25GiB
capacité annoncée du nœud"] end subgraph BUCK["Étage 3 — Buckets (régulation réelle)"] B1["qfieldcloud
max-size 12GiB"] B2["qfieldcloud-attachments
max-size 8GiB"] end B1 --> LAY B2 --> LAY LAY --> RQ RQ --> Q style B1 fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px,color:#000 style B2 fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px,color:#000 style RQ fill:#ffe0b2,stroke:#e65100,stroke-width:2px,color:#000 style Q fill:#ffcdd2,stroke:#c62828,stroke-width:2px,color:#000

L’idée tient en une phrase : seul l’étage 3 doit jamais se déclencher. Un quota de bucket atteint, c’est un client S3 qui reçoit un QuotaExceeded propre, un utilisateur qui voit une erreur explicite, et un administrateur qui décide. Un quota ZFS atteint, c’est un ENOSPC en plein milieu d’une écriture de bloc, un Garage qui refuse des PUT avec des erreurs opaques, et potentiellement une base meta/ malmenée. D’où la marge : 20 Gio de quotas de buckets sous 25 Gio de refquota.

Étage 1 — ZFS, sur Morpheus. Deux propriétés, et la distinction entre elles est le point à retenir :

# refquota : plafonne les données VIVANTES du dataset (snapshots exclus)
[root@morpheus:~] $ zfs set refquota=25G objects/garage

# quota : plafonne le dataset ET tout ce que ses snapshots retiennent
[root@morpheus:~] $ zfs set quota=35G objects/garage

[root@morpheus:~] $ zfs list -o name,used,usedsnap,refquota,quota,avail objects/garage
NAME             USED  USEDSNAP  REFQUOTA  QUOTA  AVAIL
objects/garage  2.18M      164K       25G    35G  25.0G

AVAIL passe de 215 Gio à 25,0 Gio dans la seconde : c’est la seule ligne à regarder. ZFS ne réserve rien, ne déplace rien, n’alloue rien — il change ce qu’il répond quand on lui demande la place restante. Les 164 Kio de USEDSNAP sont déjà là : le premier snapshot Sanoid est passé.

L’effet ne s’arrête pas au dataset. Garage lit l’espace disponible du système de fichiers pour alimenter le DataAvail de son garage status — et refquota est précisément ce que le système de fichiers lui répond désormais. Les 215 Go qu’il annonçait après la migration vont donc tomber d’eux-mêmes, sans qu’on ait rien à lui déclarer. On le vérifiera une fois les trois étages posés.

Les 10 Gio d’écart sont la place laissée aux snapshots Sanoid de l’annexe A01. Sans quota, un dataset dont on efface 20 Go ne libère rien tant que les snapshots quotidiens les retiennent : c’est la panne de place la plus déroutante qui soit, parce que du et zfs list ne racontent pas la même histoire.

Étage 2 — le layout Garage. Rappel de l’A02 : -c n’est ni un quota ni une réservation, c’est une clé de répartition proportionnelle entre nœuds. Sur un nœud unique, elle n’a aucun effet observable. Elle doit néanmoins rester honnête, pour que la répartition soit juste le jour où un second nœud arrive :

[allfab@storage:storage-stack] $ docker exec -ti garage /garage layout assign -c 25G 0f6796f7b25ec365
INFO garage_net::netapp: Connected to 10.10.20.21:3901, negotiating handshake...
INFO garage_net::netapp: Connection established to 0f6796f7b25ec365
Role changes are staged but not yet committed.
Use `garage layout show` to view staged role changes,
and `garage layout apply` to enact staged changes.

(Ces deux lignes INFO garage_net précèdent chaque commande garage — le binaire se connecte au nœud avant de parler. On les omet dans la suite.)

assign ne fait rien : il met en attente. C’est délibéré, et c’est le comportement qui déroute le plus au premier contact avec Garage. Rien ne change tant qu’un apply n’a pas eu lieu, et layout show est le seul endroit qui dit ce qui est en jeu :

[allfab@storage:storage-stack] $ docker exec -ti garage /garage layout show
==== CURRENT CLUSTER LAYOUT ====
ID                Tags  Zone      Capacity  Usable capacity
0f6796f7b25ec365  []    allfabox  3.7 GiB   3.7 GiB (100.0%)

Current cluster layout version: 1

==== STAGED ROLE CHANGES ====
ID                Tags  Zone      Capacity
0f6796f7b25ec365  []    allfabox  23.3 GiB

==== COMPUTATION OF A NEW PARTITION ASSIGNATION ====
Optimal partition size:                     93.1 MiB (14.9 MiB in previous layout)
Effective capacity (replication factor 1):  23.3 GiB
A total of 0 new copies of partitions need to be transferred.

To enact the staged role changes, type:

    garage layout apply --version 2

Trois enseignements dans cette sortie, et aucun n’est anecdotique.

3.7 GiB : voilà ce que le nœud déclarait réellement jusqu’ici, en version 1 de layout. Pas les 14 Go que l’A02 donnait en exemple de correction — celle-là n’a jamais été appliquée. Un nœud annonçant 3,7 Gio, sous un bucket à qui l’A02 de QFieldCloud accordait 5 Go de quota : le quota dépassait la capacité déclarée. Inoffensif sur un nœud unique, révélateur de deux chiffres posés sans se regarder.

23.3 GiB pour un -c 25G demandé : Garage lit le suffixe G en gigaoctets décimaux (25 × 10⁹), puis affiche en gibioctets. ZFS, lui, lit le 25G de refquota comme 25 Gio. Deux commandes tapées avec le même chiffre, deux valeurs différentes de 7 %. On y revient dans un instant — c’est réparable.

0 new copies of partitions need to be transferred : la confirmation que l’opération est gratuite. Un seul nœud, nulle part où déplacer quoi que ce soit.

Reste à valider. Et c’est ici qu’on se prend les pieds dans le tapis :

[allfab@storage:storage-stack] $ docker exec -ti garage /garage layout apply --version 3
Error: ApplyClusterLayout returned InternalError (500): Internal error: Invalid new layout version

Le numéro attendu n’est pas celui qu’on croit. --version ne désigne pas une version qu’on choisit : c’est un verrou de concurrence, le numéro exact que le layout aura après l’application. La version courante étant 1, la suivante est 2 — jamais 3. Garage refuse tout autre chiffre, et il a raison : c’est ce qui empêche deux administrateurs de valider chacun un jeu de changements en écrasant celui de l’autre.

[allfab@storage:storage-stack] $ docker exec -ti garage /garage layout apply --version 2
allfabox            Tags  Partitions        Capacity  Usable capacity
  0f6796f7b25ec365  []    256 (0 new)       23.3 GiB  23.3 GiB (100.0%)
  TOTAL                   256 (256 unique)  23.3 GiB  23.3 GiB (100.0%)

New cluster layout with updated role assignment has been applied in cluster.
Data will now be moved around between nodes accordingly.

Ne vous laissez pas impressionner par la dernière ligne : Data will now be moved around between nodes est un message générique, et le 256 (0 new) juste au-dessus dit la vérité — les 256 partitions restent exactement où elles sont. Sur un nœud unique, il n’y a pas d’« entre les nœuds ».

Reste l’écart de 7 % entre les 23,3 Gio du layout et les 25 Gio du refquota. Il est sans conséquence — un layout ne limite rien — mais il oblige à se souvenir d’une subtilité d’unités à chaque relecture. Autant l’éliminer : le parseur de Garage accepte les unités IEC explicites.

[allfab@storage:storage-stack] $ docker exec -ti garage /garage layout assign -c 25GiB 0f6796f7b25ec365
[allfab@storage:storage-stack] $ docker exec -ti garage /garage layout show
==== CURRENT CLUSTER LAYOUT ====
ID                Tags  Zone      Capacity  Usable capacity
0f6796f7b25ec365  []    allfabox  23.3 GiB  23.3 GiB (100.0%)

Current cluster layout version: 2

==== STAGED ROLE CHANGES ====
ID                Tags  Zone      Capacity
0f6796f7b25ec365  []    allfabox  25.0 GiB

==== COMPUTATION OF A NEW PARTITION ASSIGNATION ====
Optimal partition size:                     100.0 MiB (93.1 MiB in previous layout)

To enact the staged role changes, type:

    garage layout apply --version 3

[allfab@storage:storage-stack] $ docker exec -ti garage /garage layout apply --version 3
allfabox            Tags  Partitions        Capacity  Usable capacity
  0f6796f7b25ec365  []    256 (0 new)       25.0 GiB  25.0 GiB (100.0%)

New cluster layout with updated role assignment has been applied in cluster.

25.0 GiB, et cette fois le --version 3 est le bon — le verrou a avancé d’un cran, exactement comme annoncé. La confirmation la plus jolie est ailleurs : Optimal partition size passe à 100,0 Mio tout rond. Garage découpe l’espace en 256 partitions, et 25 Gio ÷ 256 = 100 Mio exactement. Un nombre rond en sortie d’un calcul est rarement un hasard : c’est le signe que la capacité déclarée est désormais une vraie puissance de deux, comme celle que ZFS manipule.

Astuce : 25G et 25GB valent tous deux 25 × 10⁹ pour Garage ; seul 25GiB vaut 25 × 2³⁰. La tentation inverse — aligner ZFS sur le décimal — est une impasse : ZFS n’a pas de suffixe décimal, 25G et 25GB y désignent la même valeur binaire, et il faudrait écrire refquota=25000000000 pour obtenir des gigaoctets décimaux. Un plafond à onze chiffres dans une documentation ne se relit pas. Écrivez GiB côté Garage, et les trois étages parlent enfin la même langue.
Astuce : ne calculez jamais ce numéro de tête. layout show le donne en toutes lettres dans sa dernière ligne — To enact the staged role changes, type: garage layout apply --version N. Copiez-le. L’erreur Invalid new layout version ne casse rien, mais elle laisse les changements en attente : tant qu’aucun apply n’a réussi, le nœud tourne toujours sur son ancienne capacité, et un layout show continue d’afficher un bloc STAGED ROLE CHANGES qu’on finit par croire appliqué.

Étage 3 — les buckets, l’étage qui régule vraiment. Même précaution d’unités que pour le layout : set-quotas lit 12G en gigaoctets décimaux, 12GiB en gibioctets. Écrivez GiB ici aussi, et les quotas de buckets s’additionnent dans la même unité que le refquota — 12 + 8 = 20 Gio sous 25 Gio, une soustraction qu’on peut faire de tête.

# Le bucket applicatif QFieldCloud
[allfab@storage:storage-stack] $ docker exec -ti garage /garage bucket create qfieldcloud
[allfab@storage:storage-stack] $ docker exec -ti garage /garage key create qfieldcloud-app
[allfab@storage:storage-stack] $ docker exec -ti garage /garage bucket allow \
    --read --write --owner qfieldcloud --key qfieldcloud-app
[allfab@storage:storage-stack] $ docker exec -ti garage /garage bucket set-quotas qfieldcloud --max-size 12GiB

# Le bucket des photos, écrit directement par QGIS/QField
[allfab@storage:storage-stack] $ docker exec -ti garage /garage bucket create qfieldcloud-attachments
[allfab@storage:storage-stack] $ docker exec -ti garage /garage key create qfieldcloud-attachments-app
[allfab@storage:storage-stack] $ docker exec -ti garage /garage bucket allow \
    --read --write --owner qfieldcloud-attachments --key qfieldcloud-attachments-app
[allfab@storage:storage-stack] $ docker exec -ti garage /garage bucket set-quotas qfieldcloud-attachments --max-size 8GiB

Chaque commande réaffiche la fiche complète du bucket, ce qui évite d’avoir à la redemander. Deux extraits suffisent. Le premier, à la création de la clé — c’est le seul moment où le secret s’affiche spontanément :

[allfab@storage:storage-stack] $ docker exec -ti garage /garage key create qfieldcloud-app
==== ACCESS KEY INFORMATION ====
Key ID:              GKe8877a…
Key name:            qfieldcloud-app
Secret key:          a85859fd…
Validity:            valid
Expiration:          never

Can create buckets:  false

Le second, après le set-quotas, et c’est la récompense du détour par les unités :

[allfab@storage:storage-stack] $ docker exec -ti garage /garage bucket set-quotas qfieldcloud --max-size 12GiB
Size:            0 B (0 B)
Objects:         0

Quotas:          enabled
  maximum size:  12.9 GB (12.0 GiB)

Global alias:    qfieldcloud

==== KEYS FOR THIS BUCKET ====
Permissions  Access key  Local aliases
RWO          GKe8877a…   qfieldcloud-app

12.9 GB (12.0 GiB) : Garage affiche les deux lectures côte à côte, et le chiffre rond est du côté binaire. C’est la preuve que le suffixe a été compris comme voulu — avec un --max-size 12G, la parenthèse aurait annoncé 11.2 GiB. Le RWO de la dernière ligne se lit Read, Write, Owner : les trois droits accordés par le bucket allow, owner étant celui qui autorise la gestion du bucket lui-même, pas seulement de son contenu.

Bucket Usage Quota Clé
qfieldcloud Projets, GeoPackage, deltas, versions 12 Gio qfieldcloud-app
qfieldcloud-attachments Photos de terrain (stockage externe QGIS) 8 Gio qfieldcloud-attachments-app

Deux clés distinctes plutôt qu’une : les photos sont écrites par les postes QGIS et les clients QField eux-mêmes, pas par le serveur. Cette clé-là circule sur des machines qu’on ne maîtrise pas entièrement, et l’annexe A05 rappelle qu’elle est diffusée dans une configuration d’authentification QGIS. La révoquer un jour ne doit pas couper l’application.

Attention : côté QFieldCloud, ces deux buckets ne se configurent pas au même endroit. qfieldcloud est l’affaire du bloc STORAGES de l’instance (A02 QFC). qfieldcloud-attachments, lui, est écrit dans la donnée : le StorageUrl du projet QGIS et les URL absolues des attributs photo. Changer son nom ou son endpoint, c’est toucher au GeoPackage synchronisé sur chaque terminal — exactement le piège documenté à l’annexe A05. Ce nom-là se choisit une fois.

Côté QFieldCloud, l’endpoint_url du bloc STORAGES ne bouge pas : http://10.10.20.21:3900. Rien, dans le stockage, ne mémorise l’adresse par laquelle on l’a atteint — le dataset sous-jacent, le pool, le miroir sont invisibles pour le client S3.

Attention : les clés, elles, ont changé. La table rase a supprimé qfieldcloud-app en même temps que son bucket ; les nouvelles valeurs doivent être reportées dans le .env de l’instance QFieldCloud (STORAGE_ACCESS_KEY_ID, STORAGE_SECRET_ACCESS_KEY), puis l’application redémarrée. Tant que ce n’est pas fait, QFieldCloud reçoit des 403 sur chaque accès au stockage — une panne parfaitement logique et néanmoins déroutante, puisque l’endpoint répond et que le bucket existe.

Le secret s’affiche à la création, et à ce moment-là uniquement : notez-le tout de suite. Si vous l’avez laissé filer, il n’est pas perdu — garage key info --show-secret qfieldcloud-app le redonne, mais il faut le demander explicitement, un key list ne montre jamais que les identifiants.

Et si un autre service arrive ?

Aucun de ces chiffres n’est gravé. Un quota de bucket se pose aussi bien après la création qu’au moment du create, dans les deux sens, sans arrêt de service et sans déplacer un octet : ce n’est qu’un nombre comparé à un compteur. Et refquota est une propriété ZFS, pas une géométrie de disque — l’élargir ne demande ni démontage ni reformatage.

Supposons donc un bucket geoserver demain, pour servir des données géographiques à une instance distante. Les 20 Gio de quotas de buckets sous 25 Gio de refquota laissent 5 Gio non attribués : c’est le premier levier, et il ne coûte rien.

# 1. Puiser dans la marge non allouée
[allfab@storage:storage-stack] $ docker exec -ti garage /garage bucket create geoserver
[allfab@storage:storage-stack] $ docker exec -ti garage /garage bucket set-quotas geoserver --max-size 4GiB

# 2. Redistribuer, si un bucket existant est large pour son usage réel
[allfab@storage:storage-stack] $ docker exec -ti garage /garage bucket set-quotas qfieldcloud-attachments --max-size 5GiB

# 3. Élargir le budget lui-même, sur Morpheus
[root@morpheus:~] $ zfs set refquota=60G objects/garage
[root@morpheus:~] $ zfs set quota=80G objects/garage
Attention : les deux propriétés ZFS se relèvent ensemble. Monter refquota à 60 Go en laissant quota à 35 rendrait le second contraignant avant le premier — et vous récupéreriez très exactement l’ENOSPC que ces trois étages cherchent à éviter. Gardez le rapport de l’étage 1 : quota ≈ refquota × 1,4, la marge étant la place des snapshots Sanoid.

Le layout, lui, ne limite toujours rien : vous pourriez déclarer dix buckets totalisant 200 Go de quotas sous un -c 25GiB, Garage écrirait sans broncher jusqu’à ce que ZFS dise stop. Il se met à jour par honnêteté, pas par nécessité — un layout assign -c 60GiB le jour où le refquota monte, pour qu’un futur second nœud ne reçoive pas une répartition calculée sur un chiffre périmé.

Astuce : ce raisonnement en trois leviers vaut pour un service de plus du même ordre de grandeur. GeoServer sait effectivement lire du S3 — COG via /vsis3/ de GDAL, ou le module s3-geotiff — mais une orthophoto départementale en COG pèse à elle seule des dizaines de gigaoctets, contre 3,2 Go par an de photos de terrain. Ce n’est pas un bucket de plus dans le budget, c’est un budget différent : le pool de 215 Go redevient le sujet, et la lecture aléatoire intensive sur des rasters interroge des SSD dont la section précédente a montré qu’ils avaient déjà bien vécu. Une décision d’architecture, pas un set-quotas.

4. Le tableau de bord, en trois commandes
#

Un dimensionnement qui ne se relit jamais est un dimensionnement faux. Trois commandes suffisent à savoir où on en est :

# Sur Morpheus — le réel : données vivantes, part des snapshots, marge restante
[root@morpheus:~] $ zfs list -o name,used,usedsnap,refer,refquota,avail objects/garage

# Dans la LXC — la consommation vue par Garage, bucket par bucket
[allfab@storage:storage-stack] $ docker exec -ti garage /garage bucket info qfieldcloud
[allfab@storage:storage-stack] $ docker exec -ti garage /garage bucket info qfieldcloud-attachments

bucket info affiche la taille et le nombre d’objets à côté du quota : c’est le rapport à relire deux fois par an. Le jour où qfieldcloud-attachments approche des 8 Gio, la question n’est pas « faut-il agrandir » mais « qu’est-ce qui grossit » — trois ans de photos légitimes, ou un versionnement qui ne purge rien.

Astuce : relever used et usedsnap au moment de la mise en service donne la ligne de base. Six mois plus tard, l’écart entre les deux relevés est une vitesse — la seule donnée qui permette de prévoir plutôt que de constater. Deux valeurs notées dans un fichier valent mieux qu’un outil de métrologie qu’on n’installera jamais.

Les trois étages, vus d’en haut. Une seule commande dit si l’ensemble se tient :

[allfab@storage:storage-stack] $ docker exec -ti garage /garage status
==== HEALTHY NODES ====
ID                Hostname  Address           Tags  Zone      Capacity  DataAvail          Version
0f6796f7b25ec365  allfabox  10.10.20.21:3901  []    allfabox  25.0 GiB  25.0 GiB (100.0%)  v2.3.0

Capacity et DataAvail affichent le même 25,0 Gio, et le (100.0%) qui suit n’est que le rapport des deux. Souvenez-vous d’où l’on part : ce nœud annonçait 3,7 Gio de capacité pour 215 Gio disponibles, deux chiffres qui n’avaient aucun rapport l’un avec l’autre. Ils coïncident maintenant, et par deux chemins indépendants — la capacité vient du layout qu’on a déclaré, la disponibilité vient du refquota que ZFS impose. Que les deux tombent sur la même valeur n’est pas une redondance : c’est la vérification que ce qu’on a promis à Garage est exactement ce que le système de fichiers lui accordera.

Succès : DataAvail à 100 % est une photo de départ, pas un état permanent — le pourcentage baissera au rythme des versements. C’est justement pourquoi il fait un bon indicateur : la valeur du jour n’apprend rien, l’écart avec celle du mois dernier apprend tout.
QFieldCloud [A02] — sortir le stockage objet de la machine

Troubleshooting
#

flowchart TD
    START["Garage ne démarre pas
ou /mnt/garage est vide"] --> Q1{"/mnt/garage visible
dans la LXC ?"} Q1 -->|Non| F1["pct config 121 : mp0 présent ?
pct reboot 121"] Q1 -->|Oui, mais nobody:nogroup| F2["chown sur l'hôte :
0:0 si privilégié
100000:100000 sinon"] Q1 -->|Oui, accessible| Q2{"garage status :
même ID de nœud ?"} Q2 -->|Non, ID nouveau| F3["meta/ non migré →
stop, rsync meta/, up
NE PAS apply de layout"] Q2 -->|Oui| Q3{"Erreurs
Permission denied
os error 13 ?"} Q3 -->|Oui| F2 Q3 -->|Non| OK["Chaîne OK"] style F2 fill:#ffe0b2,stroke:#e65100,stroke-width:2px,color:#000 style F3 fill:#ffcdd2,stroke:#c62828,stroke-width:2px,color:#000 style OK fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px,color:#000

Garage démarre avec un nœud « inconnu »
#

Symptôme : garage status liste un ID différent, bucket list est vide. Deux causes possibles : le dataset a été branché avec data/ mais sans meta/ (Garage a créé un nœud neuf), ou le pool objects n’était pas monté au démarrage et Garage a écrit dans un répertoire vide — vérifier d’abord zfs get -o name,value mounted objects/garage avant toute autre chose. Solution : docker compose stop garage, rsync -aHAX --numeric-ids /opt/docker/storage-stack/garage/meta.old/ /mnt/garage/meta/, docker compose up -d. Vérifier le retour de l’ID d’origine avant toute commande layout.

Permission denied (os error 13) sur /var/lib/garage/data
#

Vérifiez d’abord le type de conteneur (grep unprivileged /etc/pve/lxc/121.conf — pas de ligne = privilégié). Conteneur privilégié : le dataset doit appartenir à root:root, donc chown -R 0:0 /mnt/objects/garage. Non privilégié : le décalage n’a pas été appliqué, ou l’a été après le premier démarrage, donc chown -R 100000:100000 /mnt/objects/garage. Puis docker compose restart garage.

Le pool objects a disparu après un reboot
#

Symptôme : /mnt/objects est vide, zpool list ne connaît que rpool, la LXC 121 démarre avec un /mnt/garage désert. Cause habituelle : un pool créé avec des noms /dev/sdX, réordonnés au démarrage. Vérifier et réimporter par identifiant stable :

[root@morpheus:~] $ zpool import                    # liste les pools importables
[root@morpheus:~] $ zpool import -d /dev/disk/by-id objects

Si zpool status affiche un membre UNAVAIL alors que l’autre est ONLINE, le pool fonctionne en mode dégradé : le service tourne toujours, mais la redondance est perdue. Identifier le disque défaillant (zpool status -v), le remplacer, puis zpool replace objects <ancien> <nouveau>.

La sauvegarde du conteneur 121 explose en taille
#

Le point de montage a été ajouté sans backup=0. Corriger : pct set 121 -mp0 /mnt/objects/garage,mp=/mnt/garage,backup=0.

Vérifications
#

# --- Sur Morpheus ---
[root@morpheus:~] $ zfs list -o name,used,avail,mountpoint,compression,recordsize objects/garage
[root@morpheus:~] $ zfs list -t snapshot objects/garage        # Sanoid connaît le nouveau pool
[root@morpheus:~] $ zpool status -x                        # all pools are healthy
[root@morpheus:~] $ pct config 121 | grep ^mp0
mp0: /mnt/objects/garage,mp=/mnt/garage,backup=0

# --- Dans la LXC 121 ---
[allfab@storage:storage-stack] $ df -h /mnt/garage
[allfab@storage:storage-stack] $ docker inspect garage -f '{{ range .Mounts }}{{ .Source }} -> {{ .Destination }}{{ "\n" }}{{ end }}'
/mnt/garage/meta -> /var/lib/garage/meta
/mnt/garage/data -> /var/lib/garage/data

# --- Garage ---
[allfab@storage:storage-stack] $ docker exec -ti garage /garage status      # ID de nœud inchangé
[allfab@storage:storage-stack] $ docker exec -ti garage /garage bucket list  # les deux buckets QFieldCloud
[allfab@storage:storage-stack] $ docker exec -ti garage /garage bucket info qfieldcloud  # taille vs quota

Test de bout en bout — écrire et relire un objet :

[allfab@storage:storage-stack] $ echo "dataset ok $(date)" > /tmp/probe.txt
[allfab@storage:storage-stack] $ mc cp /tmp/probe.txt garage/qfieldcloud/
[allfab@storage:storage-stack] $ mc cat garage/qfieldcloud/probe.txt
[allfab@storage:storage-stack] $ mc rm garage/qfieldcloud/probe.txt

Conclusion
#

Garage écrit désormais sur deux SSD en miroir, dans un dataset plafonné là où l’usage réel le justifie et snapshoté automatiquement — sans qu’une ligne de garage.toml ait bougé.

Ce que vous avez mis en place :

  • Deux SSD SATA de récupération audités (SMART, firmware, endurance) puis réunis en pool miroir objects — ashift=12, autotrim=on, et l’entretien mensuel de Debian qui l’a adopté sans rien demander.
  • Un dataset objects/garage avec recordsize=1M, compression=lz4, atime=off, monté dans la LXC 121 par un point de montage Proxmox backup=0.
  • Les binds Docker durcis par create_host_path: false — un pool non monté fait échouer le démarrage au lieu de vider silencieusement le stockage.
  • Le nouveau pool déclaré à Sanoid — l’oubli qui aurait supprimé tout snapshot automatique sans le moindre message d’erreur.
  • La question des droits tranchée selon le type de conteneur — rien à faire en privilégié, décalage de 100000 sinon, et le moyen de savoir dans quel cas on est.
  • Une bascule opt-in dans le .env (GARAGE_STORAGE_ROOT) : la retirer restaure le comportement de l’A02.
  • Une migration rsync qui préserve l’ID de nœud Garage — meta d’abord, data ensuite, contrôle avant tout layout.
  • Une instance repartie propre : les deux buckets d’essai supprimés, leurs clés avec.
  • Une politique de stockage à trois étages, chiffrée sur l’usage réel (un projet, douze utilisateurs, six contributeurs actifs) : refquota=25G / quota=35G côté ZFS, -c 25GiB déclarés au layout, 12 Gio et 8 Gio de quotas sur les deux buckets QFieldCloud.
  • Une clé S3 distincte pour les photos, révocable sans couper l’application.
Astuce : la sauvegarde de Garage devient triviale — zfs snapshot objects/garage@… fige meta et data au même instant, et zfs send l’expédie hors-site en incrémental. C’est le complément indispensable du miroir, qui ne protège que de la panne d’un disque. Et le jour où un second nœud rejoint le cluster, la capacité -c déclarée ici cesse d’être décorative : elle décide de la part des données qui atterrit sur chaque nœud.

Ressources
#

Perfect Homelab Annexe - Cet article fait partie d'une série.
Partie 7: Cet article

Articles connexes