---
title: "Booter Nodefony en Docker / Kubernetes (cloud-native)"
source: docs/guides/docker-cloud-native.md
url: https://nodefony.github.io/nodefony-core/docs/guides/docker-cloud-native/
---


📍 [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 (`SIGTERM` au `docker 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` (ou `NF_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`](../../modules/core/syslog/)).

## 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.tpl`
  dans 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 `node` sur le `PATH`.

### Les deux règles qui survivent à tout changement de base

- **Forme exec** `CMD ["…", "…"]`, jamais `CMD npx nodefony production` : en
  forme shell, `/bin/sh` devient PID 1, ne transmet pas `SIGTERM`, 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) :

```bash
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 `.dockerignore` gé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_KEYSET` refuse 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`](../kubernetes/#secret-et-configmap--ce-qui-va-où).

**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).

```yaml
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
  `initialDelaySeconds` en 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 un `CMD` en forme shell fait perdre les signaux.** Le process Node n'est alors plus
  le PID 1 : `docker stop` parle au shell, Node ne reçoit rien, et l'orchestrateur finit par tuer
  brutalement — au milieu des requêtes en cours.
- **Un `terminationGracePeriodSeconds` trop court annule l'arrêt gracieux.** Il doit dépasser la
  durée réelle de fermeture, sinon le `SIGKILL` arrive 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
  `/livez` et `/readyz` existent : 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 `/readyz`
  fait, et qu'un `return 200` ne 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
> `terminationGracePeriodSeconds` et 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`](../persistence/)
- 🗝️ **Les sessions en cluster** (pourquoi `memory` ne convient plus) :
  [`session-storage.md`](../session-storage/)
- ⚙️ **Surcharger la configuration sans reconstruire l'image** :
  [`configuration.md`](../configuration/)
- 🔄 **Ce qui se passe pendant le boot que vous bornez** :
  [cycle de boot du Kernel](../../architecture/cycle-boot-kernel/)
- 📖 [Lexique général](../../lexique/) du framework.
