- Docker
- VPS
- Ops
- Caddy
Cinq produits en production sur une seule machine, pour moins cher qu'un environnement managé — si l'on comprend pourquoi l'OOM killer tue la mauvaise victime.
J'exploite cinq produits en production. Ils tournent sur un nombre de machines très inférieur à cinq. Ce n'est pas de l'héroïsme : sur des charges de démarrage, une pile Node correctement bornée consomme quelques centaines de mégaoctets, et payer un environnement managé par produit reviendrait à financer du vide.
Mais poser une application de plus sur une machine déjà en service est un exercice différent d'un déploiement sur machine neuve. Le risque n'est plus de mal configurer un serveur : c'est de casser les applications qui tournent déjà.
Mesurer avant de promettre
Ne jamais répondre « ça va tenir » sans chiffres. Une seule commande donne l'essentiel :
ssh -p PORT user@IP 'free -m; nproc; df -h /; \
docker stats --no-stream --format "{{.Name}} {{.MemUsage}} {{.CPUPerc}}"'Trois lectures, dans cet ordre :
- La colonne
available, pasfree. Linux garde le cache disque enbuff/cacheet le rend à la demande. Une machine annoncée « à 865 Mo libres » peut avoir 8 Go réellement disponibles. Se fier àfreeconduit à refuser des déploiements parfaitement tenables. - La colonne de droite de
docker stats.61 MiB / 1 GiBsignifie borné.61 MiB / 11.4 GiBsignifie aucun plafond : ce conteneur peut prendre toute la machine. - Le disque. Compter environ 2 Go par pile Node en images, plus la croissance des bases.
Pourquoi l'OOM killer tue toujours le voisin
C'est le point qu'on ne comprend qu'une fois. Quand la mémoire de l'hôte est épuisée, le noyau Linux déclenche l'OOM killer. Celui-ci ne tue pas le processus fautif. Il tue, en gros, le plus gourmand — c'est-à-dire, très souvent, une base de données. Et une base de données est rarement celle de l'application qui fuit ; c'est celle du voisin, qui n'a rien demandé.
D'où la conséquence contre-intuitive : le premier symptôme d'un manque de RAM sur machine partagée n'est pas une lenteur. C'est un service tiers qui tombe brutalement, sans rapport apparent avec la mise en production du jour.
La seule protection est de borner chaque conteneur. Avec des ancres YAML, c'est trois lignes :
x-mem-api: &mem-api { mem_limit: 768m }
x-mem-web: &mem-web { mem_limit: 512m }
x-mem-db: &mem-db { mem_limit: 512m }
services:
api:
<<: [*mem-api]Calibrer sur la mesure réelle, avec trois à cinq fois de marge : assez pour absorber une pointe, assez bas pour qu'une fuite s'arrête avant de gêner les autres.
Et surtout, vérifier que c'est appliqué — la syntaxe des ancres YAML se trompe en silence :
docker compose -f docker-compose.prod.yml config | grep -E "^ [a-z-]+:|mem_limit"Le reverse proxy appartient à quelqu'un d'autre
Un seul processus détient les ports 80 et 443. Démarrer un second proxy dans sa propre pile ne marche pas : soit il refuse de démarrer, soit il vole les ports au prochain redémarrage de la machine — et coupe tout le monde.
La bonne pratique tient en trois règles :
- Déposer son vhost dans le
conf.d/du proxy existant, sans toucher au fichier principal. - Recharger sans redémarrer :
docker exec caddy caddy reload --config /etc/caddy/Caddyfile. Unrestartcoupe toutes les autres applications le temps du redémarrage. - Rejoindre le réseau Docker du proxy par un fichier d'override dédié (
docker-compose.caddy.yml), jamais en modifiant le fichier Compose du voisin.
Migrations : sauvegarder d'abord, rebuilder explicitement
Deux règles, apprises à la dure.
Un dump avant toute migration. Toujours. Même « pour une petite colonne ».
docker exec postgres pg_dump -U user base | gzip > ~/sauvegardes/avant-$(date +%F).sql.gzUn rebuild explicite des services concernés. Sans lui, le conteneur de migration tourne sur une copie périmée du dossier de migrations. L'application démarre alors sur une base sans les nouvelles colonnes — et échoue non pas au démarrage, mais à la première requête qui les utilise. Le décalage entre la cause et le symptôme est ce qui coûte l'après-midi.
docker compose -f docker-compose.prod.yml -f docker-compose.caddy.yml build migrate api web
docker compose -f docker-compose.prod.yml -f docker-compose.caddy.yml up -dVérifier de l'extérieur, jamais depuis la machine
Un curl localhost depuis le serveur ne prouve rien : il court-circuite le DNS, le CDN, le certificat et le proxy — exactement les quatre couches qui cassent.
curl -sS -o /dev/null -w "%{http_code}\n" https://domaine/
curl -sS https://api.domaine/api/v1/health/ready # DB et cache, pas juste « je réponds »
docker compose ps # tous healthy, aucun restart en boucle
docker stats --no-stream # sous les plafonds posésPuis — et c'est le point qu'on oublie — vérifier les voisins. Un curl sur chaque autre domaine hébergé. Un déploiement qui fonctionne mais qui a fait tomber l'application d'à côté est un échec, pas un succès partiel.
Une sonde sur /health/ready, pas sur la page d'accueil
Dernier réflexe, souvent négligé : la sonde de disponibilité doit interroger un point de terminaison qui touche réellement la base et le cache. La page d'accueil d'un site statique répond encore parfaitement quand la base de données est morte. Elle surveille le serveur web, pas le service.
Le fichier qui fait la différence
Tenir un deploy/SERVER.md versionné dans le dépôt : chaque commande, chaque piège rencontré, l'emplacement de chaque secret — jamais sa valeur. Écrit au fur et à mesure, pas après. Ce qu'on ne note pas se redécouvre au prochain déploiement, généralement un dimanche.