Déploiement sur un serveur Docker partagé
Quand l'utiliser
- Le serveur n'est pas dédié : il exécute déjà les déploiements d'autres personnes ou d'autres projets.
- L'application a une dépendance native (
better-sqlite3,sharp,canvas) ou un navigateur (Chromium). - Un proxy inverse (Caddy ou nginx) existe déjà et publie plusieurs domaines.
- Toute erreur
EADDRINUSE, OOM ou disque plein affecterait les autres stacks.
Règle d'or : la nouvelle application ne peut pas être la cause de l'arrêt d'une autre. C'est pourquoi tout est délimité et mesuré au préalable.
0. Avant de construire : mesurer et libérer le disque
Une construction avec Chromium télécharge environ 2 Go d'image. Si le disque est à 78%, la construction échoue ou laisse le serveur à la limite, et le prochain prune supprime les images de production.
ssh SERVEUR 'docker system df; df -h /'
Ce qui traîne généralement et peut être libéré en toute sécurité :
docker builder prune -af # cache de build (souvent le plus volumineux)
docker image prune -af # images inutilisées
docker container prune -f # conteneurs arrêtés
Avant et après, compter les conteneurs pour confirmer que rien n'est tombé :
docker ps -q | wc -l # sauvegarder le nombre, comparer après
Mesuré dans un cas réel : 78% → 32% récupérés, avec les 41 conteneurs de production intacts.
1. Modules natifs et version de Node
Un fichier .node compilé pour une version majeure différente de Node ne produit pas une erreur claire : il interrompt le processus avec une assertion interne et une trace de pile native inutile.
Symptôme typique :
# node.exe[61084]: void __cdecl node::RemoveEnvironmentCleanupHook(Isolate *, CleanupHook, void *)
# Assertion failed: (env) != nullptr
----- Native stack trace -----
Cause : better-sqlite3@11 avec Node 24. Le support arrive dans better-sqlite3@12.
Règles :
- Vérifier la version du module natif par rapport à la version majeure de Node avant de l'intégrer.
- Dans le
Dockerfilede construction, installer la chaîne d'outils (python3 make g++) pour qu'un binaire précompilé qui n'existe pas puisse être compilé à la volée. - Au moment de l'exécution, ne pas supposer que la chaîne d'outils est nécessaire : préférer une image finale plus petite.
- Si le module est importé depuis plusieurs paquets de l'espace de travail, le tester dans un processus qui ne fait rien d'autre : un crash dû à un ramasse-miettes différé peut apparaître des heures plus tard.
2. Séparer le processus lourd de celui qui répond
Chromium et un module natif dans le même processus que le serveur HTTP provoquent un plantage et, en plus, bloquent la boucle d'événements pendant les tâches longues.
api : HTTP + SSE, mémoire faible, healthcheck HTTP
worker : file d'attente + navigateur, mémoire élevée, sans ports, sans healthcheck HTTP
Ils communiquent via un stockage partagé (SQLite, Redis, Postgres). L'API n'a pas besoin de savoir si le worker est actif.
Le healthcheck est défini uniquement sur celui qui sert HTTP. L'hériter depuis le Dockerfile dans le worker le laisse unhealthy pour toujours, car il n'ouvre jamais de port :
worker:
healthcheck:
disable: true
3. Délimiter la mémoire par service
api:
mem_limit: 400m
worker:
mem_limit: 1600m
mem_reservation: 400m
volumes:
- /dev/shm:/dev/shm:rw,size=256m # Chromium utilise /dev/shm ; 64 Mo le casse
tmpfs:
- /tmp:size=256m
security_opt:
- no-new-privileges:true
De plus, dans le code, réduire la charge : le scraper devrait avoir un DETAIL_MAX / MAX_PAGES plus bas sur le serveur qu'en local. Si un jour la RAM vient à manquer sur le serveur, le coupable est presque toujours ce nombre, pas Docker.
Mesurer avant et après une recherche réelle :
docker stats --no-stream --format '{{.Name}}\t{{.MemUsage}}\t{{.CPUPerc}}' <api> <worker>
free -m | head -2
4. Construction : étages et caches qui fonctionnent
FROM mcr.microsoft.com/playwright:v1.63.0-noble AS build # version fixée au paquet npm
...
FROM mcr.microsoft.com/playwright:v1.63.0-noble AS runtime
- Ficher la version de l'image à celle du paquet npm installé. Si elles ne correspondent pas, le navigateur n'est pas là où Playwright le cherche.
- Copier d'abord les
package.json, puis le code, afin queCOPY . .n'invalide pas la coucheinstall. - Au moment de l'exécution,
pnpm/npmsont nécessaires si les scripts de démarrage les utilisent →corepack enable. - Si
node_modulesest copié depuis la construction et que les permissions ne correspondent pas, pnpm essaie de le "réparer" à chaque démarrage et échoue. On le désactive avecverify-deps-before-run=falsedans un.npmrcde l'image. - Utilisateur non root au moment de l'exécution. Les images Playwright fournissent déjà
pwuser.
pnpm-lock.yaml doit être commité et l'installation de la construction doit se faire avec --frozen-lockfile.
5. Publier via un proxy qui existe déjà
Si un Caddy existe déjà et publie plusieurs domaines, ne pas lancer un autre proxy ni ouvrir un autre port public : on ajoute un bloc au Caddyfile existant et on se connecte au réseau Docker.
services:
api:
networks: [edge_proxy] # réseau externe, déjà créé
networks:
edge_proxy:
external: true
name: edge_proxy
Découvrir le réseau et où se trouve le Caddyfile :
docker inspect <proxy> --format '{{range .Mounts}}{{.Source}} -> {{.Destination}} rw={{.RW}}{{println}}{{end}}'
Modifier le fichier sur l'hôte : si le montage lié est rw=false, écrire à l'intérieur du conteneur échoue avec Resource busy ou est perdu au redémarrage.
Procédure sécurisée (ne jamais modifier à l'aveugle) :
cp "$CADDYFILE" "$CADDYFILE.bak-$(date +%Y%m%d-%H%M%S)" # 1. sauvegarde
# 2. ajouter le bloc
docker exec <proxy> caddy validate --config /etc/caddy/Caddyfile # 3. valider
docker exec <proxy> caddy reload --config /etc/caddy/Caddyfile # 4. recharger (sans interruption)
Si validate échoue, restaurer la sauvegarde. Le rechargement ne coupe pas les connexions existantes.
Authentification de base (basic auth)
prospector.exemple.dev {
basic_auth {
leo $2a$14$... # bcrypt, JAMAIS en clair
}
reverse_proxy api:3000 {
flush_interval -1 # indispensable pour SSE
}
encode zstd gzip
header {
X-Frame-Options DENY
-Server
}
}
- Générer le hash sur le serveur :
docker exec <proxy> caddy hash-password --plaintext '...'. - Sauvegarder le mot de passe en clair uniquement dans un fichier root (
/root/<app>-password.txt, 600) pour pouvoir le récupérer ou le faire tourner, et jamais dans le dépôt. - Ne pas utiliser le proxy Cloudflare devant si l'application utilise SSE : le délai d'expiration de 100 s et la mise en mémoire tampon coupent la progression. DNS-only (nuage gris) permet à l'origine d'émettre le vrai certificat.
basic_authprotège uniquement ce qui passe par le proxy. Si l'application est également exposée sur un autre port, elle reste sans protection : le documenter.
6. Déployer depuis Windows
rsync n'existe pas sur Windows. Ce qui fonctionne et est multiplateforme, c'est tar via un tube SSH :
const tar = spawn('tar', ['-cf', '-', '--exclude', './node_modules', ...excludes, '.'], { cwd: ROOT });
const ssh = spawn('ssh', [HOST, `mkdir -p ${DIR} && tar -xf - -C ${DIR}`], { stdio: ['pipe', 'inherit', 'inherit'] });
tar.stdout.pipe(ssh.stdin);
spawn avec shell: true est obligatoire pour résoudre pnpm sur Windows (c'est un .cmd), mais alors on ne peut pas lui passer de descripteurs de fichier pour rediriger la sortie : utiliser la variable d'environnement LOG_FILE et laisser l'application elle-même écrire le journal.
7. Avant de toucher au serveur partagé
-
docker ps -q | wc -lsauvegardé, pour comparer à la fin - Espace disque libre suffisant après la libération
-
mem_limitsur tous les nouveaux services - Le processus lourd dans son propre conteneur, sans ports
- Healthcheck uniquement sur celui qui sert HTTP
- Réseau externe du proxy déclaré, pas un nouveau réseau
- Sauvegarde du Caddyfile et
caddy validateavant le rechargement - Journal dans un fichier (le processus reste sans console)
- Script de déploiement idempotent :
check / push / build / up / logs / stats / backup - Volume avec sauvegarde testée et restaurée, pas seulement créée
Diagnostic rapide
docker stats --no-stream --format '{{.Name}} {{.MemUsage}} {{.CPUPerc}}' <api> <worker>
docker logs --tail 80 <worker>
docker exec <api> node -e "fetch('http://127.0.0.1:3000/api/health').then(r=>r.text()).then(console.log)"
free -m && df -h /