Introduction #
Les trois articles précédents ont raconté une instance montée à ma main, sa remise à niveau douloureuse, puis le plan que j’en ai tiré. Le troisième se terminait sur une promesse : « il me reste à appliquer ce plan à ma propre instance ». C’est fait, et cet article est le compte rendu de l’opération — dossier vide, git init, jusqu’au premier paquet QField sorti d’un conteneur QGIS éphémère.
Mais un simple journal de bord n’aurait pas mérité un quatrième article. Ce qui manque à la littérature francophone sur le sujet — et j’y inclus l’excellent tutoriel de Geotribu et le retour d’expérience de 3liz, qui m’ont tous deux servi — ce n’est pas la suite des commandes. C’est ce qu’il y a entre elles.
On y lit qu’il faut lancer docker compose up -d --build, puis migrate, puis collectstatic. On y voit la liste des conteneurs. On n’y trouve nulle part la réponse aux trois questions qu’un exploitant se pose le lendemain de la mise en service :
- quand un agent télécharge son projet depuis le terrain, par où passent les octets ?
- quand un job de packaging démarre, qui lance quoi, avec quelle mémoire, et pour combien de temps ?
Info : Un job de packaging, c’est quoi, et pourquoi ? — QField, sur le terrain, ne sait pas ouvrir un projet QGIS branché sur une base PostGIS distante ni sur une couche WMS : le téléphone est souvent hors réseau, et le format d’un projet de bureau n’est pas taillé pour ça. QFieldCloud fabrique donc, à la demande, une version embarquable du projet : un GeoPackage unique qui contient les données des couches, et un .qgz allégé qui pointe dessus. C’est ce couple que QField télécharge, et c’est ce qu’on appelle le paquet.
Cette fabrication ne peut pas se faire dans Django : elle exige un vrai QGIS, avec ses pilotes, ses grilles de reprojection et son moteur de rendu. D’où le mécanisme central de cette stack : un conteneur QGIS jetable, créé pour un job puis détruit. Il démarre dans trois cas — quand un agent demande à récupérer un projet (pull), quand un projet vient d’être téléversé et qu’il faut le lire pour en extraire ses couches et sa vignette (process_projectfile), et quand les modifications rapportées du terrain (les deltas) doivent être appliquées aux données (apply_deltas).
Autrement dit : le plan 2 décrit ci-dessous, c’est le produit. Une instance dont l’interface web répond parfaitement mais dont les jobs ne partent pas est une instance qui ne sert à rien.
- et quand ça casse, où est-ce que ça casse ?
Table des matières #
- Ce que vous déployez : trois plans, pas quinze services
- Le déploiement, du dossier vide au premier
up - Les quinze services, ce qu’ils font réellement
- Le chemin d’une requête
- Le chemin d’un job
- La panne qui prouve qu’on a compris
- Ce que la stack écrit sur le disque
- L’instance en état de marche
- Ce qui reste dehors
- Conclusion
Ce que vous déployez : trois plans, pas quinze services #
La première fois qu’on lance docker compose ps sur une instance QFieldCloud, on voit une liste. Une liste ne dit rien : elle met sur le même plan memcached et worker_wrapper, alors que l’un est une commodité et l’autre le cœur du produit.
La bonne lecture est différente. QFieldCloud est un logiciel à trois plans, et chaque service appartient à un seul d’entre eux.
graph TB
subgraph P1["PLAN 1 · LA REQUÊTE"]
direction TB
L1["synchrone · quelques millisecondes
ce que voit l'utilisateur"]
N["nginx
(porte d'entrée HTTPS)"] --> A["app
(Django + gunicorn)"]
A --> M["memcached
(cache)"]
end
subgraph P2["PLAN 2 · LE TRAVAIL"]
direction TB
L2["asynchrone · secondes à minutes
ce qui fabrique les paquets QField"]
W["worker_wrapper
(boucle de dépilage)"] -.->|"docker.sock"| Q["conteneur QGIS éphémère
(qgis3 / qgis4)"]
O["ofelia
(cron)"] -->|"runcrons
@every 1m"| A
end
subgraph P3["PLAN 3 · L'ÉTAT"]
direction TB
L3["persistant · survit à tout
ce qu'on sauvegarde"]
D[("db
PostGIS")]
S[("rustfs
S3")]
end
A --> D
A --> S
W --> D
Q -->|"API HTTP"| N
classDef requete fill:#42a5f533,stroke:#42a5f5,stroke-width:2px
classDef travail fill:#ffb30033,stroke:#ffb300,stroke-width:2px
classDef etat fill:#ff704333,stroke:#ff7043,stroke-width:2px
class N,A,M requete
class W,Q,O travail
class D,S etat
style L1 fill:none,stroke:#42a5f5,stroke-width:1px,stroke-dasharray:4 3
style L2 fill:none,stroke:#ffb300,stroke-width:1px,stroke-dasharray:4 3
style L3 fill:none,stroke:#ff7043,stroke-width:1px,stroke-dasharray:4 3
style P1 fill:none,stroke:#42a5f5,stroke-width:2px
style P2 fill:none,stroke:#ffb300,stroke-width:2px
style P3 fill:none,stroke:#ff7043,stroke-width:2px
Comment lire ce schéma : les flèches pleines sont des appels réseau, la flèche pointillée est un appel à l’API Docker — worker_wrapper ne parle pas au conteneur QGIS, il le crée. Et la flèche qui remonte du conteneur QGIS vers nginx n’est pas une erreur de dessin : le conteneur éphémère redescend chercher les fichiers du projet par l’API publique, comme le ferait n’importe quel client. Toute la section « La panne qui prouve qu’on a compris » tient dans cette seule flèche.
| Plan | Ce qui s’y passe | Ce qui casse quand il tombe |
|---|---|---|
| 1 · la requête | Authentification, droits, listing de fichiers, téléversement, administration Django | Tout est immédiatement hors service |
| 2 · le travail | Fabrication des paquets QField, application des deltas de retour, lecture des projets QGIS | L’interface répond, les jobs s’empilent en pending, rien ne se voit pendant des heures |
| 3 · l’état | Comptes, projets, jobs, versions de fichiers, objets | Tout est hors service, et c’est le seul plan qu’on sauvegarde |
GET /api/v1/status/. Le plan 3 aussi — c’est exactement ce que cette route teste. Le plan 2, lui, n’est couvert par aucune sonde HTTP : une instance dont tous les workers sont morts renvoie sereinement {"database":"ok","storage":"ok"}. Surveillez le nombre de jobs en pending de plus de dix minutes, pas la page d’accueil.
Deux traits de conception expliquent presque tout le reste, et aucun des deux n’est évident :
Il n’y a pas de courtier de messages. Pas de Redis, pas de RabbitMQ, pas de Celery. La file d’attente est une table PostgreSQL, et le dépilage se fait avec un SELECT … FOR UPDATE SKIP LOCKED. C’est un choix, et il est bon : une dépendance de moins, et une file qui est sauvegardée en même temps que le reste.
Le conteneur QGIS n’est pas un service. Il n’existe pas dans docker compose ps. Il est créé, utilisé, puis détruit pour chaque job, par un appel à l’API Docker. Les deux services qgis3 et qgis4 du fichier Compose ne servent qu’à construire les images, et s’arrêtent aussitôt.
Le déploiement, du dossier vide au premier up
#
Le socle : votre dépôt et le sous-module #
Alternative : Tout ce que cette section construit à la main — le sous-module, le .gitignore, le fichier d’override, le Makefile, le .env.template et les correctifs décrits plus bas — est disponible en un dépôt prêt à cloner : allfab/qfieldcloud-stack-template. Son README reprend la procédure de démarrage, les variables à changer et les pièges de cet article sous forme de mémo.
Si vous le clonez, repartez d’un historique vierge — rm -rf .git src, puis git init et vous rajoutez le sous-module : les commits de ce dépôt racontent mon instance, avec mon frontal et mes adresses. Le vôtre doit raconter la vôtre, c’est tout l’intérêt du montage. Lisez la suite quand même : le dépôt donne le résultat, l’article donne les raisons.
cd /opt/docker/qfieldcloud-stack # dossier vide
git init
printf '.env\n' > .gitignore
git submodule add -b release https://github.com/opengisch/QFieldCloud.git src
git -C src checkout v26.26$ git -C src describe --tags
v26.26
$ git -C src log -1 --format='%H %ad %s' --date=short
51ff29d79e44acdb3de18d6a8c9e43e620203984 2026-08-28 Merge branch 'master' into releasePuis les deux dossiers que Docker créerait en root si on ne les prenait pas de vitesse, et les deux fichiers qui m’appartiennent :
mkdir -p src/conf/certbot src/conf/nginx/config.d
cp src/.env.example .envLe .env : les vingt lignes qui changent
#
Sur les 83 variables que définit le .env.example, vingt ont été modifiées. Les voici toutes, regroupées par intention.
| Variable | Valeur retenue | Pourquoi |
|---|---|---|
DEBUG |
0 |
Défaut : 1.
impératif
|
ENVIRONMENT |
production |
Défaut : development |
QFIELDCLOUD_HOST |
qfieldcloud.webgis.allfabox.fr |
Sans schéma, sans port, sans slash |
DJANGO_ALLOWED_HOSTS |
+ le domaine et l’IP de l’hôte | Doit contenir QFIELDCLOUD_HOST |
SECRET_KEY, SALT_KEY |
64 caractères tirés au sort | Jamais les valeurs d’exemple |
POSTGRES_PASSWORD, OBJECT_STORAGE_ROOT_*, WEBDAV_PASSWORD |
idem | idem |
STORAGES |
access_key/secret_key alignés sur OBJECT_STORAGE_ROOT_* |
Sinon createbuckets échoue |
WEB_HTTP_PORT / WEB_HTTPS_PORT |
8480 / 8443 |
Un frontal occupera 80/443 plus tard |
SMTP4DEV_SMTP_PORT |
2525 |
Voir l’encadré ci-dessous |
QFIELDCLOUD_DEFAULT_LANGUAGE |
fr |
Défaut : en |
QFIELDCLOUD_DEFAULT_TIME_ZONE |
Europe/Paris |
Défaut : Europe/Zurich — QFieldCloud est un logiciel suisse. Ce fuseau est celui des horodatages affichés dans l’interface et l’administration : mettez le vôtre |
LETSENCRYPT_EMAIL |
mon adresse | LETSENCRYPT_STAGING reste à son défaut 1 : tant que le DNS public ne pointe pas ici, inutile de cogner les quotas |
COMPOSE_FILE |
voir plus bas | Le défaut est un profil de développement |
NGINX_ALLOW_INTERNAL_HTTP |
1 |
voir la section « panne » |
QFIELDCLOUD_ACCOUNT_ADAPTER |
…AccountAdapterSignUpClosed |
Défaut : …SignUpOpen, soit l’inscription ouverte à tous.
impératif
|
QFIELDCLOUD_WORKER_QFIELDCLOUD_URL |
http://qfieldcloud.webgis.allfabox.fr/api/v1/ |
voir la section « panne » |
COMPOSE_FILE=docker-compose.yml:docker-compose.override.standalone.yml:docker-compose.override.prod.yml:../docker-compose.override.ymlCette ligne est la clé de voûte de tout le montage, et elle mérite qu’on s’y arrête, parce que c’est elle qui rend possible tout ce que raconte l’article précédent. Docker Compose sait fusionner plusieurs fichiers : il lit le premier, puis chacun des suivants par-dessus, les derniers gagnant. COMPOSE_FILE est la variable qui déclare cette pile, séparée par des :. Ici, de bas en haut :
| Fichier | D’où il vient | Ce qu’il apporte |
|---|---|---|
docker-compose.yml |
sous-module src/ |
Le socle : les quinze services, leurs images, leurs volumes |
docker-compose.override.standalone.yml |
sous-module src/ |
Le profil « tout embarqué » : PostGIS, rustfs, webdav, smtp4dev |
docker-compose.override.prod.yml |
sous-module src/ |
Les ajustements de production : redémarrages, journalisation, certbot |
../docker-compose.override.yml |
mon dépôt, un cran au-dessus | Mes modifications à moi, et elles seules |
Les trois premiers appartiennent au sous-module : ils sont en lecture seule pour moi, et ils seront remplacés à l’identique au prochain git checkout v26.27. Le quatrième vit dans mon dépôt, d’où le ../ : Compose est lancé depuis src/, il faut donc remonter d’un niveau pour aller le chercher.
Architecture : C’est la ligne qui sépare ce déploiement d’un git clone bricolé. Sans elle, la seule façon de changer un port, une limite mémoire ou une politique de journaux est de modifier un fichier du dépôt upstream — et de porter cette modification à la main, de mémoire, à chaque montée de version. Avec elle, git -C src status reste vide en permanence : tout ce qui m’appartient est dans mon fichier, tout ce qui appartient à opengisch est intact. La section « La panne qui prouve qu’on a compris » en fera la démonstration grandeur nature : huit lignes dans mon override, zéro ligne touchée dans le sous-module.
Notez aussi ce que le défaut du dépôt charge à sa place : docker-compose.override.local.yml, c’est-à-dire un profil de développement — ports de débogage ouverts, rechargement automatique du code. Reprendre le COMPOSE_FILE livré tel quel, c’est mettre une instance de développement en production sans s’en rendre compte.
SMTP4DEV_SMTP_PORT vaut 25 par défaut, et smtp4dev publie ce port sur toutes les interfaces. Sur une machine où un agent de transport local écoute déjà sur 127.0.0.1:25 — c’est le cas de beaucoup de Debian —, le démarrage échoue avec un address already in use qui ne nomme pas le coupable. Vérifiez avant de lancer : ss -tlnp | grep ':25 '. Ici, 2525 a réglé la question.
Les deux derniers points de contrôle #
Le dépôt embarque son propre validateur, et il vaut la peine d’être lancé avant le premier up :
python3 src/scripts/check_envvars.py .env --docker-compose-dir srcEnvvar "DEBUG_QGIS_WORKER_HOST_PATH" is defined in the .env file, but not found in any docker-compose file.
Some envvars are not passed properly, 1 problem(s) found.Une seule variable orpheline, et c’est une variable de débogage sans emploi en v26.26. Il suffit de dire au validateur de l’ignorer, avec --ignored-varnames DEBUG_QGIS_WORKER_HOST_PATH — la variable reste dans le .env, et l’exclusion, écrite dans la commande, documente la décision au passage :
python3 src/scripts/check_envvars.py .env --docker-compose-dir src \
--ignored-varnames DEBUG_QGIS_WORKER_HOST_PATHAll envvars are ok.DEBUG_APP_DEBUGPY_PORT, DEBUG_WORKER_WRAPPER_DEBUGPY_PORT, DEBUG_QGIS_DEBUGPY_PORT — apparaissent bien dans docker-compose.override.local.yml, que le script lit même si votre COMPOSE_FILE ne le charge pas. Le script analyse le dossier, pas votre chaîne de fichiers : ne recopiez pas une liste d’exclusions trouvée ailleurs, faites-la tourner et lisez sa sortie.
Puis la configuration fusionnée, qui vérifie du même coup que le sous-dossier src/ n’a rien cassé :
Previously, in QFieldCloud… — dans l’article précédent, j’écrivais un Makefile de quinze lignes à la racine de mon dépôt, avec cette justification qui pouvait passer pour de la coquetterie : « pour ne plus jamais avoir à me souvenir d’où lancer Compose ni avec quel --env-file ». Le moment est venu de toucher les dividendes. La commande réelle, celle qu’il faudrait taper à la main à chaque fois, est en commentaire ; celle que je tape est à gauche du # :
make config # cd src && docker compose --env-file ../.env config -qTrois pièges évités d’un coup, et ce sont exactement les trois qui font perdre une soirée : Compose doit être lancé depuis src/ (sinon les chemins relatifs des build: ne tombent pas juste), avec le .env du dossier parent (celui qui m’appartient, pas celui du sous-module), et le -q demande de valider sans rien afficher — il ne parle que s’il y a un problème.
Et un quatrième piège, qui n’en est pas un mais que tout le monde se pose la première fois : non, make up ne vous laisse pas dans src/. make exécute chaque recette dans un sous-shell, le cd src meurt avec elle, et le terminal reste à la racine — make up puis make down s’enchaînent sans y penser. La contrepartie, c’est que le cd et le docker compose doivent tenir sur la même ligne de recette : sur deux lignes, la seconde repartirait de la racine. D’où le && de la variable COMPOSE.
Le Makefile du dépôt modèle déclare par ailleurs ses cibles en .PHONY :
.PHONY: up down config ps logs migrate checkSans cette ligne, make cherche un fichier portant le nom de la cible avant de lancer quoi que ce soit. Les noms sont courants — config, logs, check —, et le jour où l’un d’eux existe à la racine, même sous forme de dossier, la commande répond make: 'config' is up to date et ne valide plus rien. Le silence, ici, ressemble trop à un succès.
Revenons à nos moutons, la cd src && docker compose --env-file ../.env config sans le -q, nous retourne pleins de lignes et notamment :
...
context: /opt/docker/qfieldcloud-stack/src/docker-app
device: /opt/docker/qfieldcloud-stack/src/conf/ca
...Le device est le contrôle qui compte : c’est le volume des autorités de certification, défini avec ${PWD}. S’il pointait un cran trop haut, rien ne protesterait — et rien ne marcherait.
Les images, puis le démarrage #
Les deux images QGIS se construisent séparément, parce qu’elles sont énormes et qu’un disque plein en milieu de build est une mauvaise soirée :
cd src
alias dc='docker compose --env-file ../.env'
dc build qgis3 && docker builder prune -f && df -h /
dc build qgis4 && docker builder prune -f && df -h /Ces quatre lignes sont plus retorses qu’elles n’en ont l’air, décortiquons-les :
| Ligne | Ce qu’elle fait, et pourquoi |
|---|---|
cd src |
On se place dans le sous-module. C’est la contrainte évoquée juste au-dessus : les directives build: du fichier Compose désignent des dossiers relatifs à celui-ci (docker-app, docker-qgis…). Lancé d’ailleurs, Compose cherche des dossiers qui n’existent pas |
alias dc='docker compose --env-file ../.env' |
Un raccourci de session, rien de plus — mais il encapsule le --env-file ../.env, c’est-à-dire exactement ce que fait le Makefile, pour les commandes ponctuelles qu’on ne veut pas y inscrire. Le ../ désigne mon .env, un cran au-dessus de src/. Attention : un alias ne survit pas à la fermeture du terminal, et n’est pas connu de sudo |
dc build qgis3 |
On construit une seule image, nommément, au lieu de laisser make up tout enchaîner. C’est tout l’intérêt de l’opération : les deux images QGIS pèsent 3,1 et 2,3 Go, et un build qui remplit le disque en cours de route laisse un état à moitié écrit, difficile à diagnostiquer |
docker builder prune -f |
Vide le cache de construction de BuildKit — les couches intermédiaires, qui pèsent facilement autant que l’image finale. Le -f (force) évite la question « êtes-vous sûr ? ». On accepte de reconstruire plus lentement la prochaine fois, en échange d’un disque qui tient |
df -h / |
Le contrôle visuel entre les deux constructions. Si l’espace libre a fondu davantage que prévu, on s’arrête avant de lancer la seconde, plutôt que de la découvrir plantée à 80 % |
Et le && entre chaque commande n’est pas décoratif : il enchaîne seulement si la précédente a réussi. Si le build de qgis3 échoue, ni le nettoyage ni le df ne s’exécutent, et le message d’erreur reste la dernière chose affichée à l’écran.
| Image | Taille |
|---|---|
qfieldcloud-qgis3 |
3,14 Go |
qfieldcloud-qgis4 |
2,30 Go |
qfieldcloud-app |
795 Mo |
qfieldcloud-worker_wrapper |
795 Mo |
qfieldcloud-nginx |
170 Mo |
qfieldcloud-createbuckets |
186 Mo |
Puis la séquence de mise en service, qui n’a pas bougé d’un pouce par rapport à l’article précédent :
make up # up -d --build
dc exec app python manage.py migrate
dc run --rm app python manage.py collectstatic --noinput
dc exec --user root app python manage.py compilemessages
dc exec app python manage.py createsuperuser505 static files copied to '/usr/src/app/staticfiles', 505 post-processed.
processing file django.po in /usr/src/app/qfieldcloud/locale/fr/LC_MESSAGES
…
Creating regular subscription from 2026-09-03 15:07:29+00:00
Superuser created successfully.Creating regular subscription est la ligne à ne pas manquer. QFieldCloud attache un abonnement à chaque compte, et une instance auto-hébergée n’y échappe pas : le modèle est présent, seul le paiement ne l’est pas. C’est l’origine du fameux message « subscription inactive » que rapportent régulièrement les auto-hébergeurs. En v26.26, createsuperuser fabrique bien l’abonnement au passage, comme le montre la ligne ci-dessus — mais si le message apparaît un jour, c’est là qu’il faut regarder : administration Django, section Subscriptions.
La porte que personne ne pense à fermer #
Votre instance est debout, vous avez votre compte administrateur, et vous vous apprêtez à la publier. Allez voir votre page de connexion avant : elle affiche un lien « S’inscrire », et il fonctionne.
https://qfieldcloud.example.org/accounts/signup/Ce n’est pas un oubli du profil standalone, c’est le défaut du dépôt :
# DEFAULT: qfieldcloud.core.adapters.AccountAdapterSignUpOpen
QFIELDCLOUD_ACCOUNT_ADAPTER=qfieldcloud.core.adapters.AccountAdapterSignUpOpenIl se défend pour qfield.cloud, qui est un service commercial ouvert. Sur une instance auto-hébergée derrière un frontal public, il signifie que n’importe qui trouvant votre URL peut se créer un compte — et, l’abonnement étant créé automatiquement comme on vient de le voir, disposer d’un espace de travail chez vous. Une ligne suffit :
QFIELDCLOUD_ACCOUNT_ADAPTER=qfieldcloud.core.adapters.AccountAdapterSignUpClosedLe mot « closed » est trompeur, et il vaut la peine de lire ce que cet adaptateur fait réellement :
# qfieldcloud/core/adapters.py
def is_open_for_signup(self, request: HttpRequest) -> bool:
"""Allow signup only if the user has a valid invitation."""
if hasattr(request, "session"):
if request.session.get("account_verified_email"):
return True
return FalseCe n’est donc pas un verrou, c’est un passage sur invitation : le account_verified_email est posé en session par la vue qui traite les liens d’invitation. Trois conséquences pratiques :
| L’inscription libre | fermée — /accounts/signup/ répond toujours 200, mais sans formulaire, et le lien disparaît de la page de connexion |
| Les invitations | continuent de fonctionner, c’est le mode nominal |
| L’administration Django | crée toujours des comptes, comme le dit la docstring |
Vérification après bascule, sur l’instance :
$ curl -sk https://qfieldcloud.example.org/accounts/signup/ | grep -c '<form'
0
$ curl -sk https://qfieldcloud.example.org/accounts/login/ | grep -c 'accounts/signup'
0smtp4dev, qui n’envoie rien. Fermer l’inscription maintenant laisse donc l’administration Django comme unique moyen de créer un compte, jusqu’à ce qu’un vrai relais SMTP soit branché. C’est le bon ordre malgré tout : mieux vaut une instance dont vous êtes le seul utilisateur pendant une semaine qu’une instance ouverte à l’Internet pendant une nuit.
Le désordre du premier démarrage, et pourquoi il est normal #
Deux choses surprennent au premier up, et aucune n’est un problème.
Quatre services sont Exited.
$ dc ps -a
app Up 9 seconds 8000/tcp
certbot Up 9 seconds 80/tcp, 443/tcp
createbuckets Exited (0)
db Up 9 seconds 127.0.0.1:5433->5432/tcp
memcached Up 9 seconds 11211/tcp
mirror_transformation_grids Up 9 seconds
mkcert Up 9 seconds
nginx Up 6 seconds 127.0.0.1:8480->80/tcp, 127.0.0.1:8443->443/tcp
ofelia Up 8 seconds
qgis3 Exited (0)
qgis4 Exited (0)
rustfs Up 9 seconds (healthy) 0.0.0.0:8009->9000/tcp, 0.0.0.0:8010->9001/tcp
smtp4dev Up 9 seconds 0.0.0.0:143->143/tcp, 0.0.0.0:2525->25/tcp, 0.0.0.0:8012->80/tcp
webdav Up 9 seconds 0.0.0.0:8020->80/tcp
worker_wrapper Up 8 secondsqgis3 et qgis4 ont fait leur travail — construire l’image — et leur commande est un echo QGIS3 built. createbuckets a créé le bucket et rendu la main. mirror_transformation_grids sortira, lui, une fois son téléchargement terminé.
127.0.0.1: devant les ports de nginx, dans ce listing, ne viennent pas du dépôt upstream — c’est mon override qui les impose, parce que je prévois un frontal. Le profil standalone livré publie nginx sur 0.0.0.0. Et si votre frontal tourne sur une autre machine, ce binding loopback le rendra injoignable : il faut alors publier le port TLS sur l’IP de l’hôte — 10.10.20.70:8443:443 plutôt que 0.0.0.0:8443:443, pour ne pas l’ouvrir sur toutes les interfaces au passage. Le port clair, lui, peut rester sur loopback : personne n’a à l’atteindre.
Et worker_wrapper plante en boucle, avec une erreur qui a de quoi inquiéter :
django.db.utils.ProgrammingError: relation "project_project" does not existC’est un simple problème d’ordre. worker_wrapper attend que PostgreSQL réponde — ce qu’il fait consciencieusement, Postgres is ready! ✨ 💅 — puis interroge immédiatement la table des projets, qui n’existera qu’après le migrate lancé une commande plus loin. Son restart: unless-stopped le fait revenir, et il se cale tout seul dès que le schéma est en place.
migrate réussi, ce n’est plus un problème d’ordre : c’est que worker_wrapper ne parle pas à la même base que app, ou qu’il est connecté à un réplica en lecture seule — le code refuse explicitement ce cas avec un Expected worker_wrapper to be connected to the master DB node!.
Les quinze services, ce qu’ils font réellement #
standalone. Celui-ci décrit ce que chacun fait pendant que la stack tourne, chiffres à l’appui.
| Service | Plan | Rôle réel | Empreinte mesurée au repos |
|---|---|---|---|
nginx |
requête | Terminaison TLS, pages d’erreur, relais des téléchargements | 11,3 Mio |
app |
requête | Django sous gunicorn, 3 workers × 3 fils | 355 Mio |
memcached |
requête | Cache Django par défaut (memcached:11211) |
7,3 Mio |
worker_wrapper |
travail | Boucle de dépilage, lance les conteneurs QGIS | 110 Mio |
qgis3 / qgis4 |
travail | Ne tournent pas : ils fabriquent les images | — |
ofelia |
travail | Déclenche manage.py runcrons dans app, chaque minute |
43,6 Mio |
db |
état | PostgreSQL 17 / PostGIS 3.5 de l’application | 50,7 Mio |
rustfs |
état | Serveur S3 embarqué, 1.0.0-beta.11 |
79,5 Mio |
createbuckets |
mise en service | Crée le bucket, le versioning, le préfixe public | — |
mirror_transformation_grids |
mise en service | Recopie les grilles PROJ | — |
mkcert |
mise en service | Certificat auto-signé au premier démarrage | 3,8 Mio |
certbot |
entretien | Boucle de renouvellement, toutes les 12 h | 1,7 Mio |
smtp4dev |
dépendance | Piège à courriels + interface web | 76,4 Mio |
webdav |
dépendance | Backend de stockage alternatif | 5,6 Mio |
Total au repos : environ 1,5 Go de mémoire, sur les 8 Go de la machine. C’est le chiffre à retenir pour dimensionner : la stack au repos est modeste, ce sont les conteneurs QGIS éphémères qui coûtent, et on verra plus loin qu’ils sont plafonnés.
Les trois services qu’on comprend mal #
ofelia n’est pas un ordonnanceur de sauvegardes. C’est un cron qui lit les labels Docker. Le seul travail qu’on lui a confié est déclaré dans docker-compose.yml, sur le service app :
labels:
ofelia.enabled: "true"
ofelia.job-exec.runcrons.schedule: "@every 1m"
ofelia.job-exec.runcrons.command: python manage.py runcronsCe qui tourne alors, toutes les minutes, ce sont quatre tâches Django :
| Tâche | Ce qu’elle fait |
|---|---|
SendNotificationsJob |
Expédie les courriels de notification en attente |
ResendFailedInvitationsJob |
Renvoie les invitations dont l’envoi a échoué |
SetTerminatedWorkersToFinalStatusJob |
Rattrape les jobs dont le conteneur est mort sans rendre de verdict |
DeleteObsoleteProjectPackagesJob |
Fait le ménage des anciens paquets dans le stockage objet |
Les deux dernières sont les importantes. La troisième est votre filet de sécurité contre les jobs fantômes ; la quatrième est la raison pour laquelle votre bucket ne grossit pas indéfiniment à chaque pull. Si ofelia est arrêté, rien ne se voit pendant une semaine, puis le stockage se remplit.
mirror_transformation_grids télécharge beaucoup plus que vous ne croyez. Sa commande est un wget --mirror https://cdn.proj.org/, sans filtre. Sur cette instance :
$ docker exec qfieldcloud-mirror_transformation_grids-1 du -sh /transformation_grids
435.5M /transformation_grids ← après 1 minute
$ docker system df -v | grep transformation
qfieldcloud_transformation_grids 2 847MB ← à la fin847 Mo, en quatre minutes environ. Ce sont les grilles de transformation de coordonnées de PROJ, montées en lecture seule dans chaque conteneur QGIS : sans elles, les reprojections entre systèmes nationaux perdent en précision. C’est incrémental — les up suivants ne retéléchargent que les nouveautés — mais prévoyez-le sur un lien lent, et ne vous étonnez pas d’un premier démarrage qui « rame » pendant que le reste est déjà debout.
memcached n’est pas facultatif au sens où on l’entend. C’est le cache par défaut de Django, avec ignore_exc: True : s’il tombe, l’application continue de fonctionner, simplement plus lentement. En revanche, c’est aussi lui qui porte les compteurs de limitation de débit. Un cache vidé, ce sont des quotas de tentatives de connexion remis à zéro.
Le chemin d’une requête #
La réponse naïve serait : nginx relaie vers Django, Django lit l’objet dans S3 et le renvoie. Ce n’est pas du tout ce qui se passe, et c’est heureux : Django y passerait son temps et sa mémoire.
sequenceDiagram
participant C as QField
participant N as nginx
participant A as app (Django)
participant S as rustfs (S3)
C->>N: GET /api/v1/files/{projet}/photo.jpg/
N->>A: proxy_pass + X-Forwarded-For
A->>A: authentification, droits, FileVersion
A->>A: URL présignée, valable 600 s
A-->>N: 200 vide
X-Accel-Redirect: /storage-download/
redirect_uri: http://172.17.0.1:8009/…
Note over N: location interne,
Authorization et Cookie retirés,
proxy_buffering off
N->>S: GET sur l'URL présignée
S-->>N: flux de l'objet
N-->>C: flux relayé, sans écriture disque
Trois détails de mise en œuvre méritent d’être connus, parce que chacun est une panne en puissance.
1. Le déclencheur est un en-tête, pas un réglage #
Django ne choisit pas ce chemin en fonction d’ENVIRONMENT ni de DEBUG. Il regarde la présence de l’en-tête X-Forwarded-For :
# qfieldcloud/filestorage/view_helpers.py
if "X-Forwarded-For" in request.headers and not settings.IN_TEST_SUITE:
…
response = HttpResponse()
response["X-Accel-Redirect"] = "/storage-download/"
response["redirect_uri"] = urlEt si cet en-tête est absent, en dehors du mode débogage et des tests, il n’y a pas de repli :
raise Exception(
"Expected to either run behind nginx proxy, debug mode or within a test suite."
)DEBUG=0, tout client qui atteint app:8000 sans passer par nginx reçoit un 500 sur chaque téléchargement de fichier. Pas un 403, pas un message clair — une exception Python. Retenez-la : c’est exactement la panne de la section suivante, et c’est aussi ce qui arrive si vous placez un frontal qui n’ajoute pas X-Forwarded-For.
2. Le location est interne, et il résout par le DNS de Docker
#
location /storage-download/ {
internal;
set $redirect_uri "$upstream_http_redirect_uri";
resolver 127.0.0.11 ipv6=off;
proxy_max_temp_file_size 0;
proxy_buffering off;
proxy_set_header Authorization $webdav_auth;
proxy_set_header Cookie '';
proxy_pass $redirect_uri;
}internal signifie qu’aucun client ne peut appeler cette route directement : seule une redirection interne y mène. Les deux lignes proxy_max_temp_file_size 0 et proxy_buffering off garantissent que le fichier ne touche jamais le disque de nginx : il est relayé au fil de l’eau. Et Authorization est remplacé, Cookie effacé — le jeton QFieldCloud du client ne part jamais vers le stockage objet.
Enfin, resolver 127.0.0.11 est le serveur DNS interne de Docker. C’est la traduction précise de la note laconique du .env.example — « the endpoint_url must be a URL reachable from within docker and the host » : le nom d’hôte de votre stockage objet doit se résoudre dans le réseau Docker de la stack, pas seulement depuis votre poste.
3. Le stockage passe par la passerelle de l’hôte, et pas par le réseau Compose #
C’est le détail qui déroute le plus, et le .env.example le livre sans l’expliquer :
"endpoint_url": "http://172.17.0.1:8009"172.17.0.1, c’est l’adresse de docker0, la passerelle de l’hôte. Pourquoi pas http://rustfs:9000, le nom de service, tellement plus propre ?
Parce que cette URL n’est pas seulement lue par app. Elle est recopiée telle quelle dans l’URL présignée, qui sera ensuite utilisée par nginx, par les conteneurs QGIS éphémères, et potentiellement par un client. Elle doit donc désigner le même service depuis tous ces points de vue — d’où le passage par l’hôte, dénominateur commun.
rustfs écoute bien sur 0.0.0.0:8009 — API S3 — et 0.0.0.0:8010 — console web. Ce sont, avec smtp4dev (8012, 2525, 143) et webdav (8020), les seuls ports que le profil standalone expose au LAN sans vous le demander — par opposition à celui de nginx, que vous ouvrirez sciemment le jour où un frontal devra l’atteindre. Sur une machine partagée, c’est un pare-feu à écrire le jour même.
Le chemin d’un job #
sequenceDiagram
participant A as app (Django)
participant D as db (table core_job)
participant W as worker_wrapper
participant K as démon Docker
participant Q as conteneur QGIS éphémère
participant N as nginx
A->>D: INSERT job (pending)
loop toutes les 5 s
W->>D: SELECT … FOR UPDATE SKIP LOCKED
end
D-->>W: le job le plus ancien
W->>D: status = queued, puis started
W->>K: containers.run(image, /io/, jeton, mem_limit 1 Go)
K->>Q: démarrage
Q->>N: GET /api/v1/files/… (jeton du job)
N-->>Q: le projet, via X-Accel-Redirect
Q->>Q: QGIS ouvre, package, écrit /io/feedback.json
Q->>N: POST du paquet
Q-->>K: code de sortie
W->>Q: logs() puis stop() + remove()
W->>D: status = finished, output, feedback
La file d’attente est une table #
worker_wrapper exécute python manage.py dequeue, une boucle infinie dont voici le cœur, réellement exécuté toutes les cinq secondes (QFIELDCLOUD_WORKER_DEQUEUE_INTERVAL_S) :
jobs_qs = (
Job.objects.select_for_update(skip_locked=True)
.filter(status=Job.Status.PENDING)
.exclude(
Q(project_id__in=busy_projects_ids_qs)
| Q(project__locked_at__isnull=False),
)
.order_by("created_at")
)
queued_job = jobs_qs.first()Quatre propriétés découlent directement de ces quelques lignes, et elles gouvernent tout le comportement de votre instance en charge :
| Propriété | D’où elle vient | Ce que ça implique pour vous |
|---|---|---|
| Un seul job à la fois par projet | exclude(project_id__in=busy_projects_ids) |
Dix agents qui synchronisent le même projet sont sérialisés. Multiplier les workers n’y change rien |
| Plusieurs workers ne se marchent pas dessus | select_for_update(skip_locked=True) |
QFIELDCLOUD_WORKER_REPLICAS peut monter sans risque de double exécution |
| Aucun job pendant un transfert | project__locked_at__isnull=False |
Un téléversement en cours gèle la file de ce projet — c’est voulu |
| Ordre strictement chronologique | order_by("created_at") |
Pas de priorité : un gros packaging bloque les petits derrière lui, pour ce projet |
QFIELDCLOUD_WORKER_REPLICAS, c’est le nombre de projets distincts susceptibles d’être synchronisés en même temps, pas le nombre d’agents. Une collectivité avec trois projets de terrain n’a aucun intérêt à dépasser trois workers ; vingt agents sur un seul projet se contenteront très bien d’un.
Le conteneur éphémère, en détail #
Une fois le job décroché, worker_wrapper appelle l’API Docker — il a la socket montée — et crée un conteneur. Voici l’appel réel, dépouillé :
container = client.containers.run(
self.get_qgis_image(), # qfieldcloud-qgis3 ou -qgis4
command, # python3 entrypoint.py package <uuid>
environment=environment,
volumes=volumes,
network=settings.QFIELDCLOUD_DEFAULT_NETWORK,
detach=True,
mem_limit=config.WORKER_QGIS_MEMORY_LIMIT,
cpu_shares=config.WORKER_QGIS_CPU_SHARES,
labels={"app": …, "type": job.type, "job_id": …, "project_id": …},
)Sept faits en découlent, et pas un n’est documenté ailleurs :
1 — Le choix entre QGIS 3 et QGIS 4 se fait par projet. Il vient de project.qgis_version, une valeur que le job process_projectfile a lue dans le fichier .qgs lui-même. Si le projet n’a jamais été analysé, le repli est QGIS 3 — le code le dit explicitement : « the safe fallback is to use QGIS 3 until 4.2.x gets widely adopted ».
2 — Les limites de ressources sont réglables à chaud, et pas dans le .env. Elles viennent de django-constance, donc de la base, donc de l’administration Django :
WORKER_QGIS_MEMORY_LIMIT = 1000m
WORKER_QGIS_CPU_SHARES = 512Un gigaoctet par conteneur QGIS. C’est le chiffre qui doit gouverner votre dimensionnement : avec QFIELDCLOUD_WORKER_REPLICAS=3, prévoyez 3 Go pour les workers, en plus des 1,5 Go de la stack au repos. Et si un packaging échoue sur un gros projet raster sans message clair, c’est la première valeur à augmenter — dans l’admin, pas dans un fichier.
3 — L’échange de fichiers se fait par un dossier temporaire de l’hôte. Le wrapper crée un mkdtemp dans /tmp et le passe en volume au conteneur QGIS, monté sur /io/. D’où le montage qui intrigue dans docker-compose.yml :
worker_wrapper:
volumes:
- ${TMP_DIRECTORY}:/tmpCe n’est pas un cache, c’est une contrainte d’identité. Le chemin que worker_wrapper calcule dans son propre /tmp est transmis au démon Docker, qui l’interprète comme un chemin de l’hôte. Les deux doivent donc désigner le même dossier.
TMP_DIRECTORY pour /var/tmp/qfc sans réfléchir casse les jobs de façon silencieuse : le montage /var/tmp/qfc:/tmp reste correct côté wrapper, et le chemin /tmp/tmpXXXX passé au démon désigne alors le /tmp de l’hôte, qui est vide. Le conteneur QGIS démarre, ne trouve rien, et le job échoue avec un message qui ne parle pas de volumes. Si vous déplacez ce dossier — sur un disque plus rapide, par exemple —, c’est bien TMP_DIRECTORY qu’il faut changer, et rien d’autre : le montage est écrit pour rester cohérent.
4 — Le conteneur QGIS ne touche jamais la base. Il reçoit QFIELDCLOUD_URL et un jeton, et parle à l’application en HTTP, par l’API publique. C’est une frontière de sécurité réelle : un projet QGIS piégé s’exécute dans un conteneur qui n’a ni identifiants PostgreSQL, ni clés S3, ni accès à la socket Docker.
5 — Le jeton est jetable. Il est créé pour le job, de type WORKER, et expire à la fin du délai imparti plus soixante secondes :
token_expires_at = timezone.now() + timedelta(
seconds=self.container_timeout_secs + TOKEN_EXPIRATION_TIME_BUFFER_S
)6 — Les secrets du projet sont injectés là, et seulement là. Les secrets de type PGSERVICE sont concaténés dans PGSERVICE_FILE_CONTENTS, que l’entrypoint.py écrit dans ~/.pg_service.conf. C’est le mécanisme qui a remplacé l’ancien service geodb, décrit dans l’article précédent.
7 — Le conteneur est détruit, mais ses journaux sont conservés. container.logs() est lu avant le stop() et le remove(), puis rangé dans le champ output du job. C’est là qu’il faut chercher quand un packaging échoue : dans l’administration Django, pas dans docker logs.
Les sept étapes d’un packaging, telles qu’elles se sont déroulées #
Le conteneur écrit un feedback.json dans /io/, que le wrapper relit. Voici son contenu réel, pour le job de packaging de mon projet de test :
start_qgis_app Start QGIS Application
download_project_directory Download Project Directory
qgis_layers_data QGIS Layers Data
package_project Package Project
qfield_layer_data Packaged Layers Data
stop_qgis_app Stop QGIS Application
upload_packaged_project Upload Packaged ProjectL’étape 2 est celle qui traverse tout le chemin décrit à la section précédente : c’est le conteneur QGIS qui rappelle l’API pour récupérer le .qgs et ses données. Retenez-la, elle va nous occuper.
Et les durées, mesurées sur les champs docker_started_at / docker_finished_at du job :
process_projectfile finished total=2,07 s docker=2,02 s
package finished total=1,44 s docker=1,41 sDeux secondes pour un projet minuscule — une couche, un point. Le coût est presque entièrement dans le démarrage de QGIS ; il ne croît pas linéairement avec la taille du projet.
La panne qui prouve qu’on a compris #
Le symptôme #
Après avoir créé un projet et téléversé un .qgs fabriqué pour l’occasion, un job apparaît, tourne, et échoue :
$ curl … /api/v1/jobs/?project_id=…
process_projectfile failedLe conteneur QGIS, lui, s’est parfaitement exécuté — code de sortie 0, QGIS démarré :
Finished execution with code 0, logs:
15:08:11.760 root INFO Starting QGIS app version 34413 (2c8a7782a96)...
15:08:11.909 root INFO QGIS app started!C’est le feedback.json qui livre la cause :
"error_type": "API_INTERNAL_SERVER_ERROR",
"error_class": "QfcRequestException",
requests.exceptions.HTTPError: 500 Server Error: Internal Server Error for url:
http://app:8000/api/v1/files/497a2200-…/chantier.qgs/Et côté app, l’exception attendue :
"level":"ERROR",
"message":"Expected to either run behind nginx proxy, debug mode or within a test suite.",
"filename":"view_helpers.py","lineno":370Le diagnostic #
C’est exactement la panne annoncée deux sections plus haut. Le conteneur QGIS appelle l’application à l’adresse que lui donne QFIELDCLOUD_WORKER_QFIELDCLOUD_URL — dont la valeur livrée et documentée dans le .env.example est :
# QFieldCloud URL used within the worker as configuration for qfieldcloud-sdk.
# DEFAULT: "http://app:8000/api/v1/"
QFIELDCLOUD_WORKER_QFIELDCLOUD_URL=http://app:8000/api/v1/Cette adresse court-circuite nginx. Pas de nginx, pas de X-Forwarded-For ; pas de X-Forwarded-For et DEBUG=0, pas de repli : 500.
COMPOSE_FILE livré. En DEBUG=1 il fonctionne, parce que Django sert alors le fichier lui-même. Passez DEBUG=0 — ce que tout le monde vous dit de faire, et à juste titre — et tous les jobs qui lisent un fichier de projet échouent. C’est-à-dire tous.
Le correctif, et pourquoi il n’est pas évident #
Il faut faire passer les workers par nginx. Sauf que nginx ne se laisse pas faire, et c’est délibéré. Son fichier de configuration contient, dans les deux blocs server :
# prevent access by IP
if ($http_host !~ "${QFIELDCLOUD_HOST}(:${WEB_HTTPS_PORT})?") {
return 444;
}Un seul en-tête Host est accepté : celui de QFIELDCLOUD_HOST. Toute autre valeur reçoit un 444 — la connexion est fermée sans réponse. J’ai vérifié les trois candidats depuis un conteneur posé sur le réseau de la stack :
| URL essayée | Résultat |
|---|---|
http://nginx/api/v1/status/ |
rien — connexion fermée (444) |
https://nginx/api/v1/status/ |
rien — même cause, malgré server_name … nginx |
http://qfieldcloud.webgis.allfabox.fr/api/v1/status/ |
302 vers https://…:8443/ — inaccessible depuis le réseau Docker |
https://qfieldcloud.webgis.allfabox.fr/… (résolu vers l’IP de nginx) |
200 |
Le server_name ${QFIELDCLOUD_HOST} nginx; du bloc HTTPS est un faux ami : le test sur $http_host, quelques lignes plus bas, annule l’autorisation qu’il semblait accorder.
Il faut donc deux choses, et l’upstream a prévu les deux — sans les câbler ensemble :
Un — que le nom du domaine résolve dans le réseau Docker. C’est un alias réseau, et c’est précisément le genre de chose pour laquelle mon fichier d’override existe :
# /opt/docker/qfieldcloud-stack/docker-compose.override.yml
services:
nginx:
# Les conteneurs QGIS éphémères rappellent l'API pour télécharger les
# fichiers du projet, et doivent passer par nginx (X-Accel-Redirect).
# Or nginx n'accepte qu'un seul en-tête Host : QFIELDCLOUD_HOST.
# On lui donne ce nom sur le réseau.
networks:
default:
aliases:
- qfieldcloud.webgis.allfabox.frDeux — que nginx ne renvoie pas les requêtes internes vers HTTPS. Cette variable existe, elle est documentée, et elle a été écrite pour ce cas exact :
NGINX_ALLOW_INTERNAL_HTTP=1Sa mise en œuvre dans le modèle nginx est instructive :
geo $is_docker_internal {
default 0;
172.16.0.0/12 1;
}
map "$allow_internal_http:$is_docker_internal" $base_redirect_to_https {
"1:1" 0; # plain http autorisé, et requête venue du réseau Docker
default 1;
}L’autorisation n’est donnée que si les deux conditions sont réunies : le réglage est actif et la requête vient du réseau interne. Une requête publique en clair continue d’être redirigée vers HTTPS. C’est un compromis honnête, et il évite d’avoir à distribuer la CA de mkcert dans les conteneurs QGIS.
Il reste à pointer les workers vers ce chemin :
QFIELDCLOUD_WORKER_QFIELDCLOUD_URL=http://qfieldcloud.webgis.allfabox.fr/api/v1/La vérification #
make config && make upPuis un nouveau téléversement du même fichier, et un job de packaging déclenché à la main par l’API :
process_projectfile=failed | package=finished | process_projectfile=finishedLe premier échec est celui d’avant le correctif — je le laisse en base, il documente la panne. Les deux suivants sont passés.
package=finished. Un conteneur qfieldcloud-qgis3 est né, a téléchargé le projet à travers nginx, l’a ouvert dans QGIS 3.44.13, en a tiré un GeoPackage et un .qgz, les a téléversés, puis a été détruit. C’est le seul test qui compte : {"database":"ok","storage":"ok"} ne dit rien du plan 2.
Et le commit, parce que c’est tout l’intérêt d’avoir un dépôt :
$ git log --oneline
0660709 Les workers passent par nginx : alias réseau + NGINX_ALLOW_INTERNAL_HTTP
986efc5 Instance webgis sur QFieldCloud v26.26 (profil standalone).env et huit lignes de mon override ; pas une ligne du dépôt upstream n’a été touchée, et git -C src status est resté vide. Si j’avais recopié docker-compose.yml, j’aurais « corrigé » le fichier en place, et j’aurais rejoué ce raisonnement à chaque montée de version — ou, plus vraisemblablement, je l’aurais oublié et j’aurais réintroduit la panne.
Ce que la stack écrit sur le disque #
Le bucket, pour de vrai #
Voici le contenu intégral du stockage objet après un projet, deux téléversements du même fichier et un packaging :
33 Kio projects/497a2200-…/files/chantier.qgs/v20260903150809-9f654330
33 Kio projects/497a2200-…/files/chantier.qgs/v20260903151031-4bf024d5
96 Kio projects/497a2200-…/files/releves.gpkg/v20260903150809-01af4fd3
603 B projects/497a2200-…/meta/thumbnail_v20260903171035_d47e8851.png
8,3 Kio projects/497a2200-…/packages/f96dd322-…/chantier_qfield.qgz
100 Kio projects/497a2200-…/packages/f96dd322-…/data.gpkgTout est là, et tout s’explique :
| Préfixe | Contenu | Reconstructible ? |
|---|---|---|
files/<nom>/v<horodatage>-<empreinte> |
Les fichiers du projet, une clé par version | Non — c’est votre donnée |
meta/thumbnail_*.png |
La vignette du projet, produite par process_projectfile |
Oui |
packages/<uuid du job>/… |
Le paquet QField : data.gpkg + le .qgz allégé |
Oui, à chaque pull |
Deux enseignements, à ne pas manquer.
Le versionnement est applicatif, pas S3. Les deux versions de chantier.qgs sont deux clés distinctes, chacune horodatée et empreintée, avec une ligne correspondante dans la table FileVersion. Le versioning de bucket que createbuckets active par-dessus n’est qu’une ceinture supplémentaire pour le soft-delete — pas le mécanisme principal. C’est ce qui rend un Garage, dépourvu de versioning, viable pour une instance neuve : l’article précédent le disait, le listing le montre.
Le paquet est indexé par l’identifiant du job, pas par une version « courante ». Chaque packaging crée un nouveau dossier ; c’est DeleteObsoleteProjectPackagesJob, appelé par ofelia, qui supprime les anciens. Sur un projet branché sur PostGIS — qui est repackagé à chaque pull, comme l’expliquait l’article précédent —, cette tâche n’est pas un luxe.
Les neuf volumes, mesurés #
$ docker system df -v | grep qfieldcloud_
qfieldcloud_transformation_grids 847,0 Mo
qfieldcloud_postgres_data 118,3 Mo
qfieldcloud_static_volume 29,0 Mo
qfieldcloud_rustfs_data 309,7 ko
qfieldcloud_smtp4dev_data 98,3 ko
qfieldcloud_media_volume 0 B
qfieldcloud_certbot_www 0 B
qfieldcloud_webdav_data 0 B
qfieldcloud_custom_ca_certificates — (montage lié sur src/conf/ca)Sur une instance vide, les grilles PROJ représentent à elles seules 85 % du volume de données. Elles sont intégralement reconstructibles, et n’ont donc rien à faire dans votre sauvegarde. En pratique, votre plan de sauvegarde porte sur deux volumes — postgres_data par un dump logique, rustfs_data par un miroir S3 — et sur le .env, qui contient les clés sans lesquelles les champs chiffrés de la base sont perdus.
Les journaux, et la mauvaise surprise #
C’est le poste que personne ne regarde, et il est spectaculaire. Le docker-compose.yml définit deux politiques de journalisation : une ancre &default-logging à 100m × 10, et une politique locale plus généreuse sur app et nginx. En additionnant ce que Compose accorde réellement :
app 1000m × 10 = 9,8 Go
nginx 1000m × 10 = 9,8 Go
qgis3 100m × 10 = 1,0 Go
qgis4 100m × 10 = 1,0 Go
worker_wrapper 100m × 10 = 1,0 Go
────────
TOTAL borné 22,5 GoSauf que ce total ne compte que les services qui déclarent une limite. Les sept
autres — db, ofelia, memcached, smtp4dev, webdav, mkcert,
mirror_transformation_grids — n’en déclarent aucune et retombent sur le défaut du
démon Docker : json-file, sans limite. Ces 22,5 Go sont donc un plancher, pas
un plafond.
standalone lance PostgreSQL avec log_statement=all — toutes les requêtes, journalisées —, et vous avez la recette complète.
Les deux corrections tiennent, là encore, dans mon fichier d’override :
services:
db:
ports: !override
- "127.0.0.1:5433:5432"
# L'override standalone journalise TOUTES les requêtes (log_statement=all).
command:
- postgres
- -c
- log_statement=ddl
- -c
- log_destination=stderr
- -c
- shared_buffers=256MBPour les journaux Docker eux-mêmes, une clé logging: dans le même fichier ramène l’ensemble à quelque chose de raisonnable — mais elle doit couvrir tous les services, pas seulement app et nginx : ce sont ceux qui ne déclarent rien qui peuvent remplir le disque sans borne. Et même exhaustive, elle laisse un trou : les conteneurs QGIS éphémères sont créés par worker_wrapper via l’API Docker, pas par Compose, et ne connaissent que le défaut du démon. Le filet global se pose donc dans /etc/docker/daemon.json, sur la machine. Je l’ai laissé de côté ici : sur cette instance de test, je veux justement des journaux longs.
L’instance en état de marche #
1 — L’API répond, et elle voit ses deux dépendances.
$ curl -sk https://qfieldcloud.webgis.allfabox.fr:8443/api/v1/status/
{"database":"ok","storage":"ok","status_page_url":"https://status.qfield.cloud/",
"incident_message":null, …}2 — Les pages servies par nginx le sont bien par nginx.
/ 302 (redirection vers la connexion)
/admin/ 302
/api/v1/status/ 200
/pages/404.html 200 ← servie par nginx, sans toucher Django3 — Le sous-module est propre, et mon dépôt sait ce qu’il déploie.
$ git -C src status --short
← vide : zéro dette
$ git log --oneline
0660709 Les workers passent par nginx : alias réseau + NGINX_ALLOW_INTERNAL_HTTP
986efc5 Instance webgis sur QFieldCloud v26.26 (profil standalone)Le point remarquable, c’est que src/conf/nginx/certs/ contient désormais quatre fichiers créés par mkcert — dont une autorité racine — et que git -C src status reste vide malgré tout. Le .gitignore upstream les couvre. C’est ce qui rendra le prochain git checkout v26.27 sans histoire.
4 — Le .env est cohérent avec les fichiers Compose.
$ python3 src/scripts/check_envvars.py .env --docker-compose-dir src \
--ignored-varnames DEBUG_QGIS_WORKER_HOST_PATH
All envvars are ok.5 — Un job de bout en bout aboutit. C’est le seul contrôle qui teste le plan 2, et donc le seul qui ait vraiment de la valeur :
package=finishedPOST /api/v1/auth/login/ pour obtenir un jeton, POST /api/v1/projects/ pour créer un projet, POST /api/v1/files/<uuid>/<nom>/ pour y téléverser un .qgs. Le job process_projectfile part tout seul. C’est un test de recette de trois lignes de curl, parfait pour une sonde de supervision ou une vérification après mise à jour.
Et si vous n’avez pas de projet QGIS sous la main, l’image que vous venez de construire sait en fabriquer un — c’est la même version de QGIS que celle qui traitera vos jobs, donc un projet valide par construction :
# make_project.py — à poser dans un dossier vide
from qgis.core import (QgsApplication, QgsProject, QgsVectorLayer, QgsFields,
QgsField, QgsVectorFileWriter, QgsWkbTypes, QgsFeature,
QgsGeometry, QgsPointXY, QgsCoordinateReferenceSystem,
QgsCoordinateTransformContext)
from qgis.PyQt.QtCore import QVariant
QgsApplication.setPrefixPath("/usr", True)
app = QgsApplication([], False)
app.initQgis()
fields = QgsFields()
fields.append(QgsField("id", QVariant.Int))
fields.append(QgsField("nom", QVariant.String))
crs = QgsCoordinateReferenceSystem("EPSG:2154")
opts = QgsVectorFileWriter.SaveVectorOptions()
opts.driverName, opts.layerName = "GPKG", "releves"
w = QgsVectorFileWriter.create("/io/releves.gpkg", fields, QgsWkbTypes.Point,
crs, QgsCoordinateTransformContext(), opts)
f = QgsFeature(fields)
f.setGeometry(QgsGeometry.fromPointXY(QgsPointXY(843000, 6278000)))
f.setAttributes([1, "borne 1"])
w.addFeature(f)
del w
layer = QgsVectorLayer("/io/releves.gpkg|layername=releves", "releves", "ogr")
assert layer.isValid()
p = QgsProject.instance()
p.setCrs(crs)
p.addMapLayer(layer)
p.write("/io/chantier.qgs")
app.exitQgis()docker run --rm -v "$PWD:/io" -e QT_QPA_PLATFORM=offscreen \
qfieldcloud-qgis3 python3 /io/make_project.pyQGIS 3.44.13-Solothurnroot — le conteneur QGIS tourne sous cet utilisateur. Ils restent lisibles, donc le téléversement passe sans rien faire ; c’est le jour où vous voudrez les modifier ou les supprimer que le chown vous manquera.
Ce qui reste dehors #
| Ce qui manque | Pourquoi ça ne va pas en l’état | Article 5 |
|---|---|---|
| Certificat | mkcert produit un certificat auto-signé, que seul l’hôte reconnaît |
Traefik en frontal, certificat réel |
| Courriel | smtp4dev n’envoie rien : aucune vérification d’adresse, aucune réinitialisation de mot de passe n’arrive à son destinataire |
Relais SMTP |
| Stockage objet | rustfs est en 1.0.0-beta.11, et son port est exposé au LAN |
S3 externe (Garage), et le port qui disparaît |
| Base | db est très bien — mais elle vit dans le même LXC que tout le reste |
À discuter : garder ou externaliser |
| Sauvegardes | Aucune | Dump + miroir, et leur restauration testée |
| Journalisation | plafond non borné, log_statement à revoir |
Plafonds dans l’override et dans daemon.json |
| Pare-feu | Quatre services publient sur 0.0.0.0 |
Fermeture explicite |
worker_wrapper tourne en root et monte /var/run/docker.sock. C’est le contrôle complet du démon Docker, donc de la machine. Le fichier upstream le reconnaît dans un commentaire : « TODO change me to least privileged docker-capable user on the host ». Ce n’est pas un défaut de votre déploiement, c’est une propriété du produit — mais elle interdit de traiter ce conteneur comme un composant anodin. Le LXC dédié apporte ici un cloisonnement fonctionnel, pas une frontière de sécurité : s’il est privilégié — le cas par défaut de bien des installations, et celui de mon instance — root dans le conteneur reste root sur l’hôte, et la chaîne worker_wrapper → LXC → hôte se referme d’un bout à l’autre. La vraie parade est un LXC non privilégié (Docker y tourne, moyennant nesting=1 et keyctl=1, et une reprise des UID sur les volumes existants) ou une VM. Vérifiez le vôtre : cat /proc/self/uid_map doit montrer autre chose que l’identité.
Conclusion #
Ce que je retiens de ce déploiement :
- Trois plans, pas quinze services. La requête (
nginx,app,memcached), le travail (worker_wrapperet ses conteneurs éphémères,ofelia), l’état (db,rustfs). Chaque service appartient à un seul plan, et chaque panne se range dans un seul. -
{"database":"ok","storage":"ok"}ne teste que deux plans sur trois. Une instance dont tous les workers sont morts renvoie ce message. Surveillez les jobspending. - La file d’attente est une table PostgreSQL, dépilée par
SELECT … FOR UPDATE SKIP LOCKED. Pas de courtier, pas de Redis. Un seul job à la fois par projet : le nombre de workers utile, c’est le nombre de projets simultanés. - Les téléchargements ne passent pas par Django. Django signe une URL valable 600 secondes et rend la main à nginx par
X-Accel-Redirect. Le déclencheur de ce chemin est la présence deX-Forwarded-For, et enDEBUG=0il n’y a aucun repli. - Le défaut
QFIELDCLOUD_WORKER_QFIELDCLOUD_URL=http://app:8000/api/v1/casse toute instance enDEBUG=0. Le correctif :NGINX_ALLOW_INTERNAL_HTTP=1, un alias réseau portantQFIELDCLOUD_HOSTsurnginx, et l’URL du worker qui passe par ce nom. Nginx n’accepte qu’un seul en-têteHost. - Le conteneur QGIS est plafonné à 1 Go, par un réglage
django-constancemodifiable dans l’administration — pas dans le.env. C’est le chiffre de dimensionnement à multiplier par le nombre de workers. -
TMP_DIRECTORYest une contrainte d’identité entre l’hôte et le wrapper, pas un simple cache. Le chemin calculé dans le conteneur est interprété par le démon Docker comme un chemin de l’hôte. - Le versionnement des fichiers est applicatif : une clé S3 par version,
projects/<uuid>/files/<nom>/v<horodatage>-<empreinte>. Le versioning de bucket n’est qu’une ceinture de plus. - L’inscription est ouverte par défaut.
QFIELDCLOUD_ACCOUNT_ADAPTERvautAccountAdapterSignUpOpendans le dépôt : sur une instance publiée, n’importe qui peut se créer un compte.AccountAdapterSignUpClosedbascule en mode sur invitation, sans rien casser d’autre. - Le profil
standalonepublie quatre services sur0.0.0.0— stockage objet, console S3, interface et SMTP du piège à courriels, WebDAV — etSMTP4DEV_SMTP_PORTvaut25, ce qui entre en conflit avec le moindre agent de transport local. - 847 Mo de grilles PROJ écrits, et des journaux qu’aucune limite ne borne sur une instance vide. Les grilles occupent le disque tout de suite ; les journaux, eux, ne le remplissent qu’avec le temps — les 22,5 Go annoncés ne couvrent que cinq services sur douze. Ni l’un ni l’autre n’est dans la documentation, et les deux se remboursent sur votre disque.
- Le correctif d’une panne de conception a tenu dans deux variables et huit lignes d’override, sans toucher une ligne du dépôt upstream. C’était le pari de l’article précédent ; il est tenu.
Il reste à sortir cette instance du bac à sable : un vrai certificat, un vrai relais de courriel, un stockage objet qu’on n’a pas honte de laisser tourner, et des sauvegardes dont on a testé la restauration. C’est le programme du cinquième et dernier article.
Documentation self-hosted de QFieldCloudImage de couverture : Scott Blake sur Unsplash.