Booter Nodefony en Docker / Kubernetes (cloud-native)
stablemis à jour 2026-10-06
📍 Documentation › Guides › Docker & cloud-native
Le modèle — un process au premier plan, et l’orchestrateur au-dessus#
Un conteneur n’est pas une machine : c’est un process, et l’orchestrateur ne connaît que celui-là. Tout le reste — redémarrage, arrêt, sondes de vie, montée en charge — se décide sur ce process unique. D’où le principe qui gouverne toute cette page : Nodefony démarre au premier plan, reçoit les signaux directement, et ne se met jamais en arrière-plan.
C’est aussi pourquoi la mise à l’échelle ne se fait pas dans le conteneur mais au-dessus : un process par pod, et l’orchestrateur en lance autant qu’il faut.
Règle d’or : au premier plan, jamais détaché#
En Docker et Kubernetes, le process Node démarre au premier plan. Jamais en
arrière-plan (&), jamais de mise en démon (PM2 a été retiré du framework).
Pourquoi : l’orchestrateur (Docker, k8s, systemd) pilote le cycle de vie du container via ce process foreground :
- s’il sort → le container est considéré comme terminé (restart policy / pod restart) ;
- détacher ferait croire au container qu’il a fini → il s’arrête aussitôt ;
- les signaux (
SIGTERMaudocker stop/ arrêt de pod) doivent atteindre directement le process Node pour un arrêt propre.
nodefony production est déjà foreground : le Kernel boote, les serveurs HTTP/WS
écoutent (ce sont leurs handles qui gardent le process vivant), aucune daemonisation.
Ce que ça donne, mesuré#
nodefony production --workers 1 démarre au premier plan, serveurs à l’écoute. Un SIGTERM —
c’est ce qu’envoient docker stop et l’arrêt d’un pod — déclenche l’arrêt gracieux : serveurs
HTTP et HTTPS fermés proprement, sortie en code 0, en quelques centaines de millisecondes. Aucun
SIGKILL n’est nécessaire.
Les quatre signaux d’arrêt sont attachés au même endroit (Cli.ts#Cli.handleSignals), et un second signal
pendant l’arrêt coupe court plutôt que d’attendre (Cli.ts#Cli.constructor) — c’est ce qui évite un pod
bloqué en fin de vie.
Trois régimes de boot, trois objets Docker/k8s#
| Mode | Commande | Type | Objet k8s | Garde le process vivant via |
|---|---|---|---|---|
| Serveur | nodefony production |
SERVER | Deployment + Service |
handles des serveurs HTTP/WS |
| Batch one-shot | nodefony <module>:<cmd> (CONSOLE) |
CONSOLE | Job / CronJob |
— (exécute puis exit 0) |
| Daemon (worker queue, consumer, agent) | commande CONSOLE qui park | CONSOLE | Deployment (sans Service) |
un handle explicite (socket/consumer/timer) |
⚠️ Daemon CONSOLE : un
await new Promise(() => {})ne suffit PAS à garder Node vivant (une Promise pending n’est pas un handle d’event loop — Node sort dès l’event loop vide). Un vrai daemon tient un handle actif (consumer Kafka/Redis, socket, timer). Vérifié en test.
Topologie : 1 process = 1 pod (défaut) vs cluster#
- Petit pod k8s + HPA (recommandé cloud-native) :
nodefony production= 1 process Node par pod. Le scaling horizontal est délégué à l’orchestrateur (k8s HPA, Cloud Run, Fargate). - Serveur dédié multi-cœurs :
nodefony cluster --workers N(ouNF_WORKERS, qui tient compte des limites cgroup —ProdCommand.ts#Prod) = un superviseur et N ouvriers dans le même conteneur.
Logs#
Vers stdout/stderr, collectés par Docker puis par un collecteur centralisé (Grafana Alloy, Fluent Bit, Vector, l’agent Datadog…). C’est la voie recommandée : le collecteur a déjà pris une ligne écrite quand le process meurt, il ne coûte rien aux requêtes, et changer d’outil ne touche pas au code. Ne pas écrire dans des fichiers depuis le container (éphémère).
Les drivers loki et opensearch du Log Backplane savent aussi envoyer les journaux
directement depuis l’application : c’est une option, pour une démo ou un serveur sans collecteur.
Ce qu’ils apportent surtout, c’est la relecture par la console d’administration — mais
aujourd’hui une destination déclarée fait les deux à la fois (détail :
syslog.md).
Le Dockerfile — il est GÉNÉRÉ, on ne le recopie pas#
nodefony create app écrit le Dockerfile et le .dockerignore de
l’application, doctrine commentée ligne à ligne : multi-stage, installation sans
script, nettoyage de la matière cryptographique dans l’étage de construction,
USER 1000:1000 numérique, sonde sur /readyz, forme exec du CMD.
Cette page n’en porte donc pas de copie : un exemple recopié ici dériverait
du fichier réel sans que rien ne le dise — et c’est exactement ce qui était
arrivé, l’exemple de ce guide ayant perdu en route l’utilisateur non-root, la
sonde et la forme exec du CMD. La source est le gabarit :
- le fichier tel qu’il est livré —
src/nodefony/templates/app/base/Dockerfile.tpldans le dépôt,Dockerfileà la racine de l’application générée - ce que chaque ligne garantit, et ce qui casse sans elle — les commentaires du fichier lui-même, qui voyagent avec l’application
- le contrôle qui refuse une régression —
src/nodefony/src/tests/create.test.ts, section « Docker — la doctrine cloud-native naît AVEC l’application »
La base de l’image#
L’image part de node:24-alpine : zéro vulnérabilité critique contre 2 sur
node:24-slim, 4 hautes contre 14, et 81 Mo de moins — mesuré sur les images
FINALES, application comprise. L’objection historique (« musl et les paquets
natifs ») est tombée : better-sqlite3 et @node-rs/argon2 publient leurs
binaires musl et s’exécutent après une installation --ignore-scripts.
Les candidates, leurs chiffres et les deux lignes FROM à remplacer pour en
changer sont en tête du Dockerfile généré, et sur la page publique de l’image
(docker/hub-overview.md dans le dépôt).
Deux points qui ne se devinent pas : les deux étages doivent partager la même libc (un binaire natif installé à la construction ne se charge pas sinon, sur un message qui ne parle pas de libc), et distroless n’est pas un remplacement d’une ligne — ni shell, ni
nodesur lePATH.
Les deux règles qui survivent à tout changement de base#
- Forme exec
CMD ["…", "…"], jamaisCMD npx nodefony production: en forme shell,/bin/shdevient PID 1, ne transmet pasSIGTERM, et il n’y a plus aucun arrêt gracieux — l’image marche parfaitement, et chaque déploiement tue les requêtes en vol sans la moindre trace. - Un process au premier plan, jamais de superviseur dans l’image : la montée
en charge vient des répliques de l’orchestrateur. Pour un VPS multi-cœurs sans
orchestrateur,
CMD ["node_modules/.bin/nodefony","cluster","-w","4"]reste le seul cas où plusieurs process partagent un conteneur.
Configurer l’image — les secrets que la production exige#
Une image Nodefony tourne en NODE_ENV=production, et une application en production
refuse de démarrer sans ses secrets : elle s’arrête en code 78 (EX_CONFIG) en nommant
la variable qui manque. C’est voulu. Un secret tiré au hasard au démarrage invaliderait tout ce
qu’il a signé au redémarrage suivant, et différerait d’un exemplaire à l’autre — une connexion
acceptée par un pod serait refusée par son voisin. S’arrêter en le disant vaut mieux qu’une
panne intermittente découverte en production.
| Variable | Ce qu’elle protège | Sans elle, en production |
|---|---|---|
NF_CSRF_SECRET |
le jeton anti-falsification des formulaires | démarrage refusé (78) |
NF_JWT_KEYSET |
la signature des jetons de connexion (paire Ed25519, en JSON) | démarrage refusé si l’application en émet |
NF_TOTP_KEY |
le secret de double authentification de chaque compte, chiffré en base | démarre, double authentification désactivée |
NF_WEBHOOK_KEY |
les secrets de signature des webhooks, chiffrés en base | démarre, webhooks désactivés |
NF_ADMIN_PASSWORD |
le compte admin créé au premier démarrage |
démarre, aucun compte — console inaccessible |
Les générer — une fois, et depuis l’image elle-même (aucun outil sur le poste) :
docker run --rm mon-app:1.0 node_modules/.bin/nodefony security:secrets --env > nodefony.env
echo "NF_ADMIN_PASSWORD=…" >> nodefony.env
docker run -p 5151:5151 --env-file nodefony.env mon-app:1.0
--env écrit une ligne CLÉ=valeur par secret, sans guillemets : le format que
--env-file lit littéralement. Trois règles, et chacune évite une panne :
- Les mêmes valeurs pour tous les exemplaires. Générées une fois, rangées dans le gestionnaire de secrets (Secret Kubernetes, coffre-fort), injectées en variables d’environnement — jamais régénérées au déploiement.
- Jamais dans l’image. Le
.dockerignoregénéré écarte.env: un secret entré dans une couche y reste lisible, même effacé par la couche suivante. - Stables dans le temps. Changer
NF_JWT_KEYSETrefuse les jetons en vol ; une rotation ajoute la nouvelle clé au jeu, la rend active, et garde l’ancienne le temps qu’elle expire.
En Kubernetes, ces valeurs vont dans un Secret :
kubernetes.md.
La liste COMPLÈTE n’est pas recopiée ici — elle dépend des modules de l’application, et une
copie se périmerait. Elle est générée par l’application elle-même : .env.example (par section,
chaque variable avec son exigence et sa place en production), et npx nodefony env, qui dit ce
qui est posé, d’où vient chaque valeur, et sort en 78 s’il manque une variable requise.
Kubernetes — probes & timeouts#
Le framework expose deux sondes, actives par défaut — rien à écrire :
| Sonde | Chemin | Ce qu’elle répond |
|---|---|---|
| Vivacité | /livez |
200 tant que le process sert — y compris pendant l’arrêt : un pod qui vide ses connexions n’est pas un pod mort, le tuer couperait les requêtes en cours |
| Disponibilité | /readyz |
200 quand le pod peut recevoir du trafic, 503 dès le début de l’arrêt — l’équilibreur le retire avant que la première connexion tombe |
Elles sont montées avant la limitation de débit (http-kernel.ts#HttpKernel.setErrorRenderer) : un kubelet qui sonde
toutes les secondes ne doit pas se faire limiter, puis déclarer le pod mort. Les chemins et
l’activation se règlent dans la configuration du module http (config.ts, enabled à true par
défaut).
livenessProbe:
{ httpGet: { path: /livez, port: 5151 }, initialDelaySeconds: 10 }
readinessProbe: { httpGet: { path: /readyz, port: 5151 }, periodSeconds: 5 }
terminationGracePeriodSeconds: 30 # > durée du graceful shutdown (~mesurée < 1 s)
NF_BOOT_TIMEOUT_MS(reservedEnv.ts#RESERVED_ENV) borne le temps de boot : si un module reste suspendu, le pod échoue vite au lieu de rester à moitié vivant.- Le boot d’une application est dominé par l’import et l’instanciation de ses modules : un pod
réel démarre nettement plus vite que l’application de démonstration du dépôt. Ajustez
initialDelaySecondsen conséquence plutôt que de recopier une valeur.
📖 Lexique#
| Terme | Ce que c’est |
|---|---|
| Premier plan (foreground) | Le process reste le process principal du conteneur. C’est lui que l’orchestrateur surveille et signale. |
| Arrêt gracieux | Fermer les serveurs, laisser finir les requêtes en cours, puis sortir — au lieu d’être tué. Déclenché par SIGTERM. |
| Sonde de vivacité (liveness) | Ce qui dit « ce pod est-il encore vivant ? ». S’il échoue, le pod est redémarré. |
| Sonde de disponibilité (readiness) | Ce qui dit « ce pod peut-il recevoir du trafic ? ». S’il échoue, il est retiré du service sans être tué. |
| Forme exec | CMD ["a", "b"] plutôt que CMD a b : sans shell intermédiaire, donc les signaux atteignent Node. |
| Init | Un petit process parent (--init, tini) qui récolte les process orphelins et relaie les signaux. |
⚠️ Pièges#
- Un
&ou unCMDen forme shell fait perdre les signaux. Le process Node n’est alors plus le PID 1 :docker stopparle au shell, Node ne reçoit rien, et l’orchestrateur finit par tuer brutalement — au milieu des requêtes en cours. - Un
terminationGracePeriodSecondstrop court annule l’arrêt gracieux. Il doit dépasser la durée réelle de fermeture, sinon leSIGKILLarrive avant la fin et le bénéfice est perdu. - Ne pointez pas vos sondes sur une route applicative que vous écrivez vous-même alors que
/livezet/readyzexistent : la vôtre ne saura pas répondre 503 pendant l’arrêt, et le pod continuera de recevoir du trafic qu’il ne peut plus servir. C’est exactement ce que/readyzfait, et qu’unreturn 200ne fait pas. - Écrire des journaux dans des fichiers, dans un conteneur, revient à les jeter. Le système de fichiers est éphémère : la sortie standard est le seul chemin qui atteigne un collecteur.
- Un démon qui n’a aucun handle actif sort tout seul.
await new Promise(() => {})ne retient pas Node : il faut un vrai handle — un consommateur, une socket, un minuteur.
🧪 Tests & couverture#
Les chiffres exacts vivent dans la carte de l’aperçu, régénérée en comptant — jamais figés ici.
| Type | Où | Ce qui est prouvé |
|---|---|---|
| Unitaires (signaux, CLI) | nodefony Cli.test.ts |
les signaux attachés, le second signal qui coupe court, les codes de sortie |
| Unitaires (cluster) | nodefony ClusterManager.test.ts, ClusterProbeAggregator.test.ts, ClusterRelay.test.ts |
la supervision des ouvriers, l’agrégation des sondes, le relais entre process |
| Intégration (CLI réelle) | nodefony CliIntegration.test.ts, detachedStart.test.ts |
le démarrage effectif, et ce que le détachement change |
Ce que ces tests ne couvrent pas : le comportement d’un orchestrateur réel. Le contrat « SIGTERM → arrêt propre » est vérifié sur le process ; ce que Kubernetes en fait dépend de vos
terminationGracePeriodSecondset de vos sondes.
🔗 Pour aller plus loin#
- ⬆️ Retour au hub : Guides · Toute la documentation
- 🗄️ Ce que devient la persistance quand il y a plusieurs pods :
persistence.md - 🗝️ Les sessions en cluster (pourquoi
memoryne convient plus) :session-storage.md - ⚙️ Surcharger la configuration sans reconstruire l’image :
configuration.md - 🔄 Ce qui se passe pendant le boot que vous bornez : cycle de boot du Kernel
- 📖 Lexique général du framework.