À qui s’adresse cette annexe #
QFieldCloud auto-hébergé [E05] : l'instance de base, prête pour la production
Les fichiers qu’on touche #
Cinq, et un seul est un fichier Compose. Chaque bloc de code plus bas rappelle en première ligne le fichier auquel il appartient.
| Fichier | Ce qu’on y met |
|---|---|
docker-compose.override.yml |
le volume ../backups et les étiquettes Ofelia sur db |
scripts/backup-storage.sh |
le miroir du bucket et la copie du .env |
scripts/replicate-storage.sh |
la copie du bucket vers l’instance Garage distante |
Makefile |
les cibles backup, replicate et restore-test, seules adresses de ces scripts |
| la crontab de la machine | deux lignes, qui appellent make et jamais les scripts |
Les trois choses, et rien d’autre #
- la base — comptes, projets, jobs, et surtout l’index des versions de fichiers ;
- le bucket — les octets eux-mêmes ;
- le
.env— sansSECRET_KEYniSALT_KEY, les champs chiffrés de la base restituée sont illisibles. Une sauvegarde des deux premiers sans le troisième ne vaut rien.
Ne sont volontairement pas sauvegardés : les grilles PROJ (~850 Mo, retéléchargeables par le service mirror_transformation_grids) et les images Docker, y compris les deux images QGIS, reconstructibles depuis le tag du sous-module.
Le dump de la base, par l’ordonnanceur déjà présent #
Inutile d’ajouter un cron : la stack embarque ofelia, qui fait déjà tourner runcrons toutes les minutes. On lui accroche un job par des labels, dans notre override :
# docker-compose.override.yml
db:
# Les chemins relatifs d'un fichier Compose sont résolus depuis le
# RÉPERTOIRE DE PROJET (src/, celui du premier fichier de COMPOSE_FILE),
# pas depuis le fichier qui les écrit. D'où le `../` : sans lui, les
# sauvegardes atterriraient dans le sous-module.
volumes:
- ../backups:/backups
labels:
ofelia.enabled: "true"
ofelia.job-exec.backup-db.schedule: "0 30 2 * * *"
ofelia.job-exec.backup-db.no-overlap: "true"
ofelia.job-exec.backup-db.command: >-
sh -c 'set -e;
pg_dump -U "$$POSTGRES_USER" -d "$$POSTGRES_DB" -Fc
-f "/backups/db/qfieldcloud-$$(date +%Y%m%d-%H%M).dump";
find /backups/db -name "qfieldcloud-*.dump" -mtime +14 -delete'docker compose se lancent depuis src/, le répertoire du sous-module — c’est de là que COMPOSE_FILE est résolu — et avec --env-file ../.env. Chaque bloc le rappelle par son cd src, à ne faire qu’une fois par session. Le $ en début de ligne est le symbole du terminal, il ne se copie pas.
Le commentaire sur les chemins relatifs n’est pas une précaution de style : c’est le genre d’erreur qui salit le sous-module et fait mentir la promesse de toute la série. On vérifie donc plutôt que de croire :
$ cd src && docker compose --env-file ../.env config | grep -A2 'type: bind'
type: bind
source: /opt/docker/qfieldcloud-stack/backups
target: /backups
Deux détails qui coûtent une soirée si on les ignore : l’horaire d’ofelia compte six champs, secondes en tête (0 30 2 * * * = 02:30:00), et le $$ est la façon d’échapper un $ dans une valeur Compose, pour que la variable soit évaluée dans le conteneur et non au moment de la fusion des fichiers.
Contrôle au démarrage :
$ cd src && docker compose --env-file ../.env logs ofelia | grep registered
NOTICE ▶ New job registered "backup-db" - "sh -c 'set -e; pg_dump …'" - "0 30 2 * * *"
NOTICE ▶ New job registered "runcrons" - "python manage.py runcrons" - "@every 1m"
Le piège : ce qu’ofelia ne sait pas faire #
Le miroir du bucket demande mc, qu’aucun conteneur de la stack n’embarque. Ofelia prévoit exactement ce cas : job-run crée un conteneur neuf à partir d’une image. Sauf que la version livrée par l’upstream, 0.3.18, ne l’enregistre pas quand il est déclaré par des labels :
$ docker inspect qfieldcloud-db-1 --format '{{json .Config.Labels}}' | grep job-run
"ofelia.job-run.miroir-stockage.image": "minio/mc:latest",
"ofelia.job-run.miroir-stockage.schedule": "0 45 2 * * *",
…
$ cd src && docker compose --env-file ../.env logs ofelia | grep registered
NOTICE ▶ New job registered "backup-db" …
NOTICE ▶ New job registered "runcrons" …
DEBUG ▶ Starting scheduler with 2 jobs
Les labels sont posés, ofelia les lit, et le job n’existe pas. Aucune erreur, aucun avertissement — deux jobs au lieu de trois, et il faut compter pour s’en apercevoir. C’est la pire catégorie de panne pour une sauvegarde : celle qui ne se voit qu’au moment où on en a besoin. Le seul contrôle fiable est de compter les New job registered.
Découvert au passage : ofelia ne lit pas ses propres labels. Un job déclaré sur le conteneur ofelia lui-même n’est jamais enregistré.
Attention : un second problème condamnait de toute façon cette piste. Les labels sont lisibles par docker inspect, et la commande de miroir contient la clé secrète du stockage objet :
"ofelia.job-run.miroir-stockage.command": "-c 'mc alias set garage http://10.10.20.21:3900 GK20c7ce… 91f63d37… …'"
Un secret dans un label est un secret publié. Ne mettez jamais de clé dans une commande ofelia déclarée par label.
Le miroir, par un script et la crontab #
L’extrait ci-dessous est réduit à l’essentiel — STACK_DIR et BACKUP_DIR s’y déduisent de l’emplacement du script, et le fichier complet vit dans le dépôt template, sous scripts/backup-storage.sh :
# scripts/backup-storage.sh
#!/usr/bin/env bash
set -euo pipefail
STACK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # la racine, pas scripts/
BACKUP_DIR="${STACK_DIR}/backups"
set -a; source "${STACK_DIR}/.env"; set +a
# 1. Le bucket. `--remove` fait du miroir un reflet fidèle : un objet supprimé
# en amont disparaît de la copie. C'est voulu — la protection contre la
# suppression accidentelle, c'est la sauvegarde du conteneur qui l'assure,
# avec son historique.
docker run --rm -v "${BACKUP_DIR}/storage:/mirror" --entrypoint sh minio/mc:latest -c "
mc alias set backup '${S3_BACKUP_ENDPOINT}' '${S3_BACKUP_ACCESS_KEY}' '${S3_BACKUP_SECRET_KEY}' --api S3v4 >/dev/null
mc mirror --overwrite --remove 'backup/${S3_BACKUP_BUCKET}' /mirror
"
# 2. Le .env, sans lequel les deux autres ne valent rien.
install -m 600 "${STACK_DIR}/.env" "${BACKUP_DIR}/env/env-$(date +%Y%m%d-%H%M)"Deux ordonnanceurs, donc, et pour une raison qui se documente en une ligne :
| Quoi | Par qui | Quand |
|---|---|---|
pg_dump -Fc + purge à 14 jours |
ofelia, job-exec sur db |
02:30 |
miroir du bucket + copie du .env |
crontab utilisateur, make backup |
02:45 |
La ligne de crontab, en chemin absolu puisque cron ne se place nulle part :
# crontab -e, sur la machine
45 2 * * * make -C /opt/docker/qfieldcloud-stack backup >> /opt/docker/qfieldcloud-stack/backups/backup-storage.log 2>&1Elle passe par le Makefile et non par le chemin du script : l’emplacement de scripts/ reste ainsi un détail interne, qu’on peut déplacer sans casser une ligne de crontab qui, elle, ne préviendrait personne — elle échouerait à 2 h 45 dans un fichier de log que personne ne lit. Cron n’ayant ni votre PATH ni votre environnement de session, le contrôle qui vaut la peine est de la rejouer dépouillée :
$ env -i PATH=/usr/bin:/bin HOME="$HOME" make -C /opt/docker/qfieldcloud-stack backup
backups/ est ajouté au .gitignore avant le premier lancement du script. Le répertoire contient des copies horodatées du .env : un git add -A distrait publierait l’intégralité des secrets de l’instance dans le dépôt.
Le quart d’heure d’écart, et pourquoi il est bénin #
Base à 02:30, objets à 02:45. Une version de fichier créée entre les deux existe dans le miroir mais pas dans le dump. Faut-il s’en inquiéter ?
Non, et la raison tient à un choix de conception vu à l’épisode 4 : le versionnement des fichiers est applicatif, une clé S3 par version, projects/<uuid>/files/<nom>/v<horodatage>-<empreinte>. Aucun objet n’est jamais écrasé. Une base restaurée à 02:30 ne référence que des objets antérieurs à 02:30, tous présents dans un miroir pris à 02:45. L’inverse — des objets orphelins que la base ignore — coûte de la place, rien de plus.
L’ordre importe donc : la base d’abord, les objets ensuite. Dans l’autre sens, une base plus récente que le miroir pointerait vers des objets absents, et là, ce sont des fichiers de projet manquants.
La copie hors-site, dans un second script #
C’est une limite, pas un défaut : les deux dispositifs ne répondent pas à la même question. Le premier restaure un objet supprimé par erreur, le second sert quand la LXC n’existe plus. La destination retenue ici est une seconde instance Garage, sur un serveur Proxmox distant.
replication_factor et layout — jamais du bucket : tout le contenu partirait sur le site distant, pas seulement celui-ci. Et à deux nœuds, le quorum d’écriture vaut 2 : une coupure du lien met le stockage en lecture seule. Pour un seul bucket, la copie applicative est plus simple et plus sûre.
Le script est le jumeau du précédent, à ceci près que la destination n’est plus un répertoire mais un second endpoint S3 — donc deux alias, et aucun volume monté :
# scripts/replicate-storage.sh
#!/usr/bin/env bash
set -euo pipefail
STACK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
set -a; source "${STACK_DIR}/.env"; set +a
# Bucket à bucket, sans passer par le disque local. Pas de `--remove` : voir plus bas.
docker run --rm --entrypoint sh minio/mc:latest -c "
mc alias set source '${S3_BACKUP_ENDPOINT}' '${S3_BACKUP_ACCESS_KEY}' '${S3_BACKUP_SECRET_KEY}' --api S3v4 >/dev/null
mc alias set distant '${S3_REMOTE_ENDPOINT}' '${S3_REMOTE_ACCESS_KEY}' '${S3_REMOTE_SECRET_KEY}' --api S3v4 >/dev/null
mc mirror --overwrite 'source/${S3_BACKUP_BUCKET}' 'distant/${S3_REMOTE_BUCKET}'
"Le --api S3v4 n’est pas plus décoratif ici qu’ailleurs : sans lui, mc négocie parfois une signature que Garage refuse, avec un message qui parle de tout sauf de ça.
Trois variables s’ajoutent au .env, et la clé distante n’a besoin que d’écrire :
| Variable | Ce qu’elle vaut |
|---|---|
S3_REMOTE_ENDPOINT |
l’URL de l’instance Garage distante |
S3_REMOTE_ACCESS_KEY / S3_REMOTE_SECRET_KEY |
une clé dédiée à la réplication, jamais celle de l’application |
S3_REMOTE_BUCKET |
le bucket créé côté distant, avec garage bucket allow --read --write |
La cible du Makefile, pour que la crontab n’ait toujours pas à connaître scripts/ :
# Makefile
replicate:
@scripts/replicate-storage.sh
Et la seconde ligne de crontab, à 03:00 :
# crontab -e, sur la machine
0 3 * * * make -C /opt/docker/qfieldcloud-stack replicate >> /opt/docker/qfieldcloud-stack/backups/replicate-storage.log 2>&1Le quart d’heure qui la sépare du miroir local n’est pas une précaution de principe : les deux jobs lisent le même bucket de bout en bout, et les faire se chevaucher n’apporte rien qu’une instance Garage qui travaille deux fois plus pour le même résultat.
--remove est délibérée, et c’est le seul écart avec backup-storage.sh. Le miroir local assume la suppression parce qu’un autre dispositif — la sauvegarde du conteneur, avec son historique — rattrape les regrets. La copie hors-site, elle, est la dernière ligne : rien ne la rattrape. Un objet effacé en amont y reste, et c’est exactement ce qu’on veut.
La contrepartie se mesure : la copie distante n’est plus un reflet fidèle, et sa taille dérive au-dessus de celle de la source. Le versionnement applicatif vu à l’épisode 4 borne cette dérive — aucun objet n’est jamais écrasé, donc l’écart ne vient que des projets réellement supprimés, pas du travail quotidien.
mc diff source/<bucket> distant/<bucket> doit ne rien renvoyer, et la restauration doit être tentée depuis la copie distante, pas depuis le miroir local.
La restauration, testée pour de bon #
Une sauvegarde dont on n’a jamais tenté la restauration n’est pas une sauvegarde : c’est un fichier. Le dépôt embarque un scripts/restore-test.sh, appelé par le Makefile, qui restaure le dernier dump dans une base jetable, compare les effectifs table par table avec la production, puis supprime la base :
$ make restore-test
Dump testé : qfieldcloud-20260904-1430.dump
table production restaurée écart
core_user 1 1 OK
project_project 1 1 OK
core_job 4 4 OK
filestorage_file 4 4 OK
filestorage_fileversion 5 5 OK
PostGIS dans la base restaurée : 3.5.7
Base jetable supprimée.
Le contrôle sur pg_extension n’est pas décoratif : un dump restauré dans une base dépourvue de PostGIS s’importe partiellement, sans erreur fatale, et l’application tombe bien plus tard.
Ce qu’il faut retenir #
- L’ordonnanceur est déjà dans la stack :
ofeliasuffit pour le dump, sans cron hôte. - Les chemins relatifs d’un fichier Compose sont résolus depuis le répertoire de projet, pas depuis le fichier qui les écrit.
-
job-rundéclaré par label n’est jamais enregistré par ofelia 0.3.18, silencieusement. Comptez lesNew job registered. - Un secret dans un label Docker est lisible par
docker inspect. - La base d’abord, les objets ensuite.
- Une sauvegarde dont la restauration n’a jamais été tentée est un fichier.
- Un miroir posé sur le disque qu’il sauvegarde ne protège pas de la perte de la machine.
-
--removese justifie sur une copie qu’un autre dispositif protège, jamais sur la dernière.
Image de couverture : Scott Blake sur Unsplash.