Comment Docker, Node.js et Bun gèrent la mémoire — Guide complet
Un guide pédagogique pour comprendre pourquoi une image Docker de 150 MB ne consomme pas 150 MB de RAM, pourquoi Bun est plus petit sur disque mais plus gros en RAM que Node.js, et comment faire tourner 200 sites Astro.js sans exploser ton serveur.
Table des matières
- Docker et la mémoire : le modèle mental
- Pourquoi Node.js utilise ~50 MB de RAM
- Pourquoi Bun est plus petit sur disque mais plus gros en RAM
- Docker vs hors Docker : y a-t-il une différence ?
- 200 sites Astro SSR : les options
- Sources
1. Docker et la mémoire : le modèle mental
1.1 Ce qu’une “image Docker” est vraiment
Une image Docker n’est pas un fichier monolithique. C’est un empilement de layers (couches) en lecture seule, chacune représentant un diff du système de fichiers.
Image node:22-alpine (~150 MB)
┌─────────────────────────────┐
│ Layer 4: COPY app/ + npm ci │ <- ton code + node_modules
├─────────────────────────────┤
│ Layer 3: Node.js binaire │ <- ~80 MB (node + V8 + ICU)
├─────────────────────────────┤
│ Layer 2: Alpine packages │ <- ~5 MB
├─────────────────────────────┤
│ Layer 1: Alpine base │ <- ~5 MB
└─────────────────────────────┘
Point clé : quand tu lances 10 containers depuis la même image, les layers en lecture seule existent une seule fois sur disque. Chaque container ajoute seulement une fine couche R/W (read-write) par-dessus, qui est vide au départ.
1.2 Copy-on-Write (CoW) — Le mécanisme fondamental
Docker utilise le filesystem overlay2 qui fonctionne en Copy-on-Write :
- Lecture d’un fichier : le kernel lit directement depuis la couche inférieure (partagée). Aucune copie.
- Écriture dans un fichier existant : le fichier est d’abord copié dans la couche R/W du container, PUIS modifié. Les autres containers ne voient pas le changement.
- Création d’un fichier : il est créé directement dans la couche R/W.
Container A Container B Container C
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Couche │ │ Couche │ │ Couche │
│ R/W │ │ R/W │ │ R/W │
│ (vide) │ │ (vide) │ │ (vide) │
└────┬─────┘ └────┬─────┘ └────┬─────┘
│ │ │
└───────────┬───────┘───────────────────┘
│
┌────────────┴────────────┐
│ Layers partagées │ <- UNE SEULE COPIE
│ en lecture seule │ sur disque et en RAM
│ (node, alpine, app) │
└─────────────────────────┘
1.3 Et pour la RAM ? Le Page Cache
C’est ici que ça devient intéressant. Le disque et la RAM sont liés par le page cache du kernel Linux.
Quand un processus lit un fichier (par exemple le binaire node), le kernel charge les pages de ce fichier en RAM dans le page cache. Si un autre processus (dans un autre container) lit le même fichier depuis la même couche overlay2, le kernel ne le recharge pas — il réutilise les mêmes pages physiques en RAM.
Concrètement : si tu lances 200 containers Node.js Alpine, le binaire node (~80 MB) n’est chargé qu’une seule fois dans le page cache. Les 200 processus partagent ces mêmes pages physiques.
1.4 Ce qui est partagé vs ce qui ne l’est pas
| Type de mémoire | Partagé entre containers ? | Taille typique |
|---|---|---|
Code du binaire (node, libc, etc.) | Oui (page cache) | ~50 MB (Node.js + Alpine libs) |
| Fichiers de node_modules sur disque | Oui (page cache, si même layer) | ~20-50 MB |
| Heap JavaScript (objets, variables, AST) | Non — chaque process a le sien | 15-40 MB par container |
| Code JIT-compilé (V8 TurboFan/Maglev output) | Non — compilé par process | 5-10 MB par container |
| Stacks, buffers, metadata | Non | 5-10 MB par container |
1.5 RSS, PSS, VSZ — Les métriques qui trompent
Quand tu regardes la mémoire d’un processus, tu vois trois chiffres différents :
VSZ (Virtual Size) — L’espace d’adressage virtuel total. Inclut de la mémoire mappée mais jamais utilisée. Inutile pour estimer la consommation réelle. Un processus Node.js peut montrer 1 GB+ de VSZ tout en utilisant 50 MB réels.
RSS (Resident Set Size) — La mémoire physique effectivement en RAM. Mais attention : RSS compte les pages partagées dans chaque processus. Si node (80 MB en RAM) est partagé entre 200 containers, chacun rapportera ~80 MB dans son RSS alors que cette mémoire n’existe qu’une fois.
PSS (Proportional Set Size) — La métrique la plus honnête. Les pages partagées sont divisées proportionnellement entre les processus qui les partagent. Si 200 processus partagent 80 MB, chacun rapporte 80/200 = 0.4 MB de PSS pour cette portion.
Exemple avec 3 containers identiques :
RSS de chaque container : ~80 MB
Réalité physique :
Mémoire partagée (node binary + libs) : 50 MB (une seule copie)
Mémoire privée par container : 30 MB x 3 = 90 MB
────────────────────
Total réel en RAM : 140 MB
Total RSS additionné (trompeur) : 240 MB
Total PSS additionné (correct) : 140 MB
1.6 Ce que docker stats affiche vraiment
docker stats ne montre ni RSS ni PSS. Il utilise les métriques cgroups du kernel :
Formule (cgroup v2) :
USAGE = memory.usage_in_bytes - inactive_file
Formule (cgroup v1) :
USAGE = memory.usage_in_bytes - total_inactive_file
memory.usage_in_bytes inclut le RSS du processus PLUS le page cache des fichiers lus par le container. En soustrayant inactive_file (le cache fichier inactif, récupérable), Docker donne une estimation de la mémoire “réellement utilisée”.
C’est mieux que RSS mais pas parfait — ça ne distingue toujours pas la mémoire partagée de la mémoire privée.
2. Pourquoi Node.js utilise ~50 MB de RAM
2.1 Anatomie d’un processus Node.js
Quand tu démarres node server.js, voici ce qui se passe en mémoire :
Processus Node.js en RAM (~50 MB pour un serveur HTTP simple)
┌─────────────────────────────────────────────────┐
│ │
│ Binaire node + V8 (code pages) ~30 MB * │ <- Partagé si même image
│ ───────────────────────────────────────────── │
│ Heap V8 - Old Space (objets JS) ~10-20 MB │ <- Privé par processus
│ Heap V8 - New Space (young gen) ~1-8 MB │ <- Privé
│ Heap V8 - Code Space (JIT) ~3-5 MB │ <- Privé
│ Heap V8 - Map Space (metadata) ~1-2 MB │ <- Privé
│ ───────────────────────────────────────────── │
│ Stacks des threads ~1-2 MB │ <- Privé
│ Buffers natifs (libuv, TLS, etc.) ~2-5 MB │ <- Privé
│ Shared libraries (libc, etc.) ~5 MB * │ <- Partagé
│ │
└─────────────────────────────────────────────────┘
* Partagé entre containers utilisant la même image
2.2 Le Heap V8 en détail
V8 (le moteur JS de Node.js/Chrome) découpe son heap en espaces spécialisés :
New Space (~1-8 MB) : Les objets fraîchement créés atterrissent ici. Il est divisé en deux “semi-spaces” (From et To). Le GC “Scavenge” copie les survivants de From vers To, puis swap. C’est rapide (~1-2 ms) mais limité en taille.
- Node.js 18 : 16 MB par semi-space par défaut (32 MB total)
- Node.js 20+ : dimensionnement dynamique, commence à ~1 MB, monte jusqu’à ~8 MB selon la mémoire disponible
Old Space (~10-200 MB+) : Les objets qui survivent 2+ cycles de GC dans New Space sont promus ici. C’est le gros du heap. Collecté par Mark-Sweep-Compact, qui est plus lent mais gère de plus grands volumes.
- Défaut : 50% de la mémoire du container (Node.js 20+), plafonné à 2 GB
- Configurable via
--max-old-space-size
Code Space : Le code machine compilé par TurboFan/Maglev (les compilateurs JIT de V8).
Map Space : Les “maps” (hidden classes) qui décrivent la structure des objets JS. Non déplaçables par le GC.
Large Object Space : Objets > 256 KB. Chacun obtient sa propre région mmap. Jamais déplacé par le GC.
2.3 Pourquoi un serveur vide consomme déjà ~25-50 MB
Même un node -e "require('http').createServer((q,s)=>s.end('ok')).listen(3000)" utilise ~25-30 MB parce que :
- V8 bootstrap : le moteur pré-alloue les semi-spaces, initialise les builtins, compile les fonctions natives → ~5-10 MB
- Node.js internals :
require(), le module system, les bindings C++ (libuv, crypto, fs, etc.) → ~5-10 MB - ICU data : les données d’internationalisation Unicode (~5-10 MB dans la version full, ~1 MB dans Alpine/small-icu)
- Le code pages du binaire : ~30 MB mappés depuis le disque (partagé via page cache, mais compté dans RSS)
3. Pourquoi Bun est plus petit sur disque mais plus gros en RAM
3.1 Le paradoxe Bun
| Métrique | Node.js 22 | Bun |
|---|---|---|
| Taille du binaire | ~80-90 MB | ~30-35 MB |
| Image Docker Alpine | ~150 MB | ~55 MB |
| RSS idle (serveur HTTP) | ~25-50 MB | ~110-150 MB |
Comment un binaire 2.5x plus petit peut-il utiliser 3-4x plus de RAM ?
3.2 Pourquoi le binaire Bun est plus petit
Le binaire Node.js est gros parce qu’il embarque :
- V8 (~40 MB) : le moteur JS avec compilateurs JIT, GC, etc.
- ICU (~25 MB en full, ~5 MB en small-icu) : données Unicode complètes
- libuv, OpenSSL, etc. (~15 MB) : I/O async, TLS
Le binaire Bun est plus petit parce que :
- JavaScriptCore (JSC) est plus compact que V8 (~15-20 MB compilé)
- Pas de full ICU : Bun embarque un subset minimal
- Zig au lieu de C++ : le code natif de Bun est compilé en Zig, qui produit des binaires plus compacts
- Tout-en-un : pas de binaires séparés pour npm, npx, etc.
3.3 Pourquoi Bun utilise plus de RAM — Les 3 raisons
Raison 1 : mimalloc pré-alloue agressivement
Bun utilise mimalloc (allocateur mémoire de Microsoft) au lieu de malloc/jemalloc. mimalloc est conçu pour la performance (allocation rapide, bonne localité de cache) mais il a un comportement spécifique : il committe (réserve physiquement) des pages mémoire plus tôt que nécessaire.
Concrètement (issue #3065) :
- Le heap JavaScript (mesuré par
heapSize) reste stable - Mais le RSS augmente quand même, parce que mimalloc réserve des pages de mémoire physique “au cas où”
- Ces pages sont commitées au niveau OS mais pas nécessairement utilisées par le code JS
Node.js (V8 + jemalloc/system malloc) :
heapSize: 10 MB RSS: 25 MB <- RSS proche du heap + overhead fixe
Bun (JSC + mimalloc) :
heapSize: 10 MB RSS: 110 MB <- RSS déconnecté du heap !
Raison 2 : les “moats” de JavaScriptCore
JSC utilise des “moats” de mémoire virtuelle — de grands espaces d’adressage réservés entre les types d’objets pour servir de barrières de sécurité. Si un buffer overflow se produit dans un type d’objet, il traverse un “moat” (zone non mappée) et provoque un crash immédiat au lieu de corrompre silencieusement un autre type.
Ces moats :
- Réservent de la mémoire virtuelle (VSZ, pas RSS) — gratuit en théorie
- Mais les allocateurs et le kernel peuvent committer des pages à proximité, gonflant le RSS
- Sur Linux, le RSS inclut parfois des pages “touchées” dans ces régions même si elles ne contiennent rien d’utile
Raison 3 : la croissance idle
Un comportement spécifique de Bun sous Linux (issue #21560) : le RSS d’un process idle monte progressivement de ~110 MB à ~145 MB sans aucune requête. Ce phénomène n’est pas observé avec Node.js.
Cause probable : mimalloc fait du “eager decommit” mais avec un seuil de réutilisation élevé — il garde des pages commitées anticipant de futures allocations. Sur macOS, le comportement est différent (RSS ~60-75 MB) car le kernel macOS gère la pression mémoire différemment.
3.4 En résumé : taille disque != taille RAM
DISQUE RAM (RSS)
┌──────────────┐ ┌──────────────────┐
Node.js │ ██████████ │ 80 MB │ ████ │ ~30 MB (privé)
│ (V8+ICU) │ │ (heap + overhead)│
└──────────────┘ └──────────────────┘
Bun │ ████ │ 30 MB │ ██████████████ │ ~120 MB (RSS)
│ (JSC+Zig) │ │(mimalloc + moats)│
└──────────────┘ └──────────────────┘
La taille du binaire reflète la taille du code compilé. La consommation RAM reflète le comportement de l’allocateur mémoire à l’exécution. Ce sont deux choses indépendantes.
4. Docker vs hors Docker : y a-t-il une différence ?
4.1 Overhead de Docker lui-même
Docker ajoute un overhead quasi-nul en mémoire :
- Pas de VM : Docker utilise des namespaces et cgroups Linux, pas de la virtualisation
- Pas de kernel séparé : tous les containers partagent le même kernel
- Overhead réel : ~1-2 MB par container pour les metadata cgroups/namespaces
Un processus Node.js dans Docker consomme exactement la même quantité de mémoire qu’en dehors de Docker. La différence est dans la mesure : Docker ajoute le page cache des fichiers overlay2 dans ses métriques.
4.2 Node.js container-aware (v20+)
Depuis Node.js 20, V8 détecte automatiquement les limites cgroups du container :
Container avec --memory=256m :
V8 lit /sys/fs/cgroup/memory/memory.max → 256 MB
V8 fixe max_old_space_size à 50% → 128 MB
Hors Docker, V8 utilise la RAM physique totale de la machine. Sur un serveur de 32 GB, V8 pourrait allouer un heap de 2 GB par défaut. Dans un container de 256 MB, il se limite à 128 MB.
C’est le seul vrai impact de Docker sur le comportement mémoire de Node.js — mais c’est un impact positif : il empêche un processus de consommer toute la RAM de la machine.
4.3 Le piège de la mémoire rapportée
Scénario : 3 containers Node.js sur une machine de 8 GB
$ docker stats
CONTAINER MEM USAGE
node-1 78 MB ← semble utiliser 78 MB
node-2 75 MB ← semble utiliser 75 MB
node-3 80 MB ← semble utiliser 80 MB
TOTAL: 233 MB ?
Réalité physique :
Pages partagées (node binary, libs) : 50 MB (une copie)
Mémoire privée : 3 x ~30 MB = 90 MB
Total réel : ~140 MB
La différence (233 - 140 = ~93 MB) est de la mémoire comptée en double.
5. 200 sites Astro SSR : les options
5.1 L’approche naïve : 200 containers séparés
200 containers x ~60 MB RSS chacun = ~12 GB (RSS additionné)
Mais en réalité :
Pages partagées (même image) : ~75 MB (une copie)
Mémoire privée : 200 x ~30 MB = ~6 GB
Total réel : ~6 GB
Avec KSM (Kernel Same-Page Merging) activé, le kernel peut détecter les pages de heap identiques entre containers (même framework, mêmes structures V8) et les dédupliquer. Économie supplémentaire : 30-50%, ramenant à ~3-4 GB. Mais KSM consomme du CPU.
5.2 La question qui change tout : combien de sites ont VRAIMENT besoin de SSR ?
Avant d’optimiser l’infrastructure, audite tes sites :
| Type de page | Besoin SSR ? | Alternative |
|---|---|---|
| Pages de contenu (blog, docs, marketing) | Non | prerender: true (statique) |
| Formulaire de contact | Non | Statique + API externe |
| Page de login | Peut-être | Statique + auth côté client |
| Dashboard personnalisé | Oui | prerender: false (SSR) |
| Résultats de recherche dynamiques | Oui | SSR |
| Pages avec données temps réel | Oui | SSR |
Avec Astro 5, tu peux mixer : output: 'static' par défaut + export const prerender = false sur les pages qui en ont besoin. La plupart des “200 sites SSR” peuvent probablement devenir “20 SSR + 180 statiques”.
5.3 Comparatif des architectures
| Approche | RAM totale (200 sites) | Complexité | Isolation | Cold start |
|---|---|---|---|---|
| 200 containers séparés | 5-12 GB | Faible | Excellente | Aucun |
| PM2 dans 1 container | 5-10 GB | Faible | Bonne (process) | Aucun |
| Worker threads (1 process) | ~4 GB | Haute | Faible | Aucun |
| K8s + scale-to-zero | 1-4 GB (variable) | Haute | Excellente | 2-5 sec |
| workerd (V8 isolates) | ~700 MB | Moyenne | Modérée | < 5 ms |
| Hybride : nginx statique + quelques SSR | 2-4 GB | Moyenne | Bonne | Aucun |
| 200 sites statiques sur nginx | ~15-20 MB | Faible | N/A | Aucun |
5.4 Option A : PM2 dans un gros container
PM2 gère 200 processus Node.js dans un seul container. Chaque processus est isolé (crash individuel) mais partage les couches overlay2.
# ecosystem.config.js
module.exports = {
apps: Array.from({ length: 200 }, (_, i) => ({
name: `site-${i}`,
script: `./sites/site-${i}/dist/server/entry.mjs`,
max_memory_restart: '80M',
env: { PORT: 3001 + i }
}))
};
Attention : le daemon PM2 lui-même peut consommer 100-230 MB. Sur 200 processus, c’est un overhead acceptable (~1 MB par site managé). PM2 offre --max-memory-restart pour redémarrer automatiquement les processus qui fuient.
5.5 Option B : Kubernetes + scale-to-zero (KEDA / Knative)
Si tes 200 sites ont un trafic inégal (80% reçoivent < 10 requêtes/heure) :
Sites sans trafic : 0 pods, 0 MB
Sites actifs : 1 pod chacun, ~60 MB
Si 20 sites actifs à un instant donné :
20 x 60 MB = 1.2 GB (au lieu de 12 GB)
Knative est le meilleur choix pour du scale-to-zero HTTP-aware : un “activator” retient les requêtes pendant le cold start (2-5 sec pour Node.js).
5.6 Option C : V8 Isolates avec workerd (la plus efficace en RAM)
workerd est le runtime open-source de Cloudflare Workers. Au lieu d’un processus Node.js complet par site, il utilise des V8 isolates : des contextes JavaScript légers qui partagent le même moteur V8.
Node.js : 1 processus = 1 V8 complet = ~40-80 MB
workerd : 1 isolate = 1 contexte JS dans un V8 partagé = ~1-3 MB
Pour 200 sites : 200 x 3 MB + ~100 MB (workerd) = ~700 MB total.
C’est un ordre de grandeur de moins que l’approche container. Cloudflare fait tourner des centaines de milliers de Workers sur une seule machine avec cette technique.
Condition : les sites doivent être buildés avec @astrojs/cloudflare au lieu de @astrojs/node. L’API Workers est plus limitée que Node.js (pas de filesystem, pas de child_process, etc.).
5.7 Option D : L’architecture hybride (recommandée)
La stratégie la plus pragmatique combine statique et SSR :
[Traefik]
/ \
/ \
┌─────────────┐ ┌──────────────────────┐
│ nginx │ │ Cluster SSR │
│ 180 sites │ │ 20 sites Astro │
│ statiques │ │ (containers ou PM2) │
│ │ │ │
│ ~15 MB │ │ ~1-2 GB │
└─────────────┘ └──────────────────────┘
Total : ~1-2 GB au lieu de 12 GB
Étapes :
- Auditer les 200 sites : identifier lesquels ont vraiment besoin de SSR
- Convertir le maximum en statique (Astro 5 :
prerender: truepar défaut) - Regrouper tous les sites statiques dans un seul nginx (une config server_name par site)
- Garder les sites SSR en containers individuels (ou PM2, ou workerd)
- Si trafic inégal : ajouter scale-to-zero sur les sites SSR
5.8 Aller plus loin : KSM pour les containers restants
Si tu gardes beaucoup de containers identiques, active KSM (Kernel Same-Page Merging) sur l’hôte :
echo 1 > /sys/kernel/mm/ksm/run
echo 1000 > /sys/kernel/mm/ksm/sleep_millisecs # scan toutes les 1s
echo 200 > /sys/kernel/mm/ksm/pages_to_scan # pages par scan
KSM scanne la mémoire physique, détecte les pages identiques entre processus (y compris entre containers), et les fusionne en une seule copie physique avec Copy-on-Write. Pour 200 containers du même framework Astro, les pages identiques (bytecode V8 du framework, structures internes) peuvent représenter 30-50% du heap.
Trade-off : KSM consomme du CPU (~1-5% selon la fréquence de scan). Utile pour de l’hébergement haute densité, pas nécessaire si tu as assez de RAM.
6. Sources
Docker et mémoire
- Docker OverlayFS storage driver
- Docker Runtime metrics
- Docker Misleading Containers Memory Usage
- OverlayFS — Linux Kernel documentation
- Sharing pages between mappings (LWN)
- Docker memory de-duplication discussion (moby #7950)
- Kernel Same-Page Merging (Linux Kernel Docs)
Node.js / V8 mémoire
- Visualizing memory management in V8
- V8 Heap: How Node.js Organises Memory
- Node.js Understanding and Tuning Memory
- Uncovering Node.js Internals: Default Heap Sizing
- Node.js 20+ memory management in containers (Red Hat)
- Node.js 20 HEAP issues with Kubernetes (Deezer)
Bun / JavaScriptCore mémoire
- Bun Issue #3065: Memory increases while heapSize unchanged
- Bun Issue #21560: RSS grows while idle
- Bun Issue #19254: 3x more memory than Node for simple scripts
- Bun Issue #17723: Container CPU/memory spike
- Bun Debugging Memory Leaks
- Bun memory_allocator.zig (source)
Architectures multi-sites
- Cloud Computing without Containers (Cloudflare Blog)
- workerd — Cloudflare Workers runtime (GitHub)
- Loading 100s of workers in workerd (Discussion #351)
- Knative scale-to-zero
- PM2 Memory Management
- Fly.io min memory for Node.js Express
- OpenResty nginx memory optimization for 10K virtual hosts
Annexe A : Le partage de mémoire entre containers — Preuves et vérification
Cette annexe examine en détail les preuves qui confirment (ou nuancent) les affirmations de ce document concernant le partage de pages mémoire entre containers Docker.
A.1 Rappel des affirmations à vérifier
Ce document affirme que :
- Les layers en lecture seule d’une image Docker n’existent qu’une seule fois sur disque
- Le page cache du kernel sert les mêmes pages physiques à tous les containers lisant le même fichier depuis les mêmes layers overlay2
- Les shared libraries (.so) mappées via mmap depuis la même layer sont partagées en RAM physique
- Le binaire
nodechargé depuis une layer partagée n’est en RAM physique qu’une seule fois, même pour 200 containers
A.2 Affirmation 1 : Les layers sont partagées sur disque
CONFIRMÉ — Bien documenté.
Source primaire : Documentation kernel Linux
La documentation officielle du kernel sur OverlayFS indique :
“Lower layers may be shared among several overlay mounts and that is indeed a very common practice.”
C’est par design : overlayfs est explicitement conçu pour que plusieurs mounts overlay (= plusieurs containers) partagent les mêmes répertoires de layers inférieures.
Source primaire : Documentation Docker
La documentation Docker sur le driver overlay2 explique que quand on crée un container, les layers de l’image deviennent les lowerdir (en lecture seule, partagés) et un nouveau répertoire vide devient le upperdir (écriture, propre au container).
A.3 Affirmation 2 : Le page cache est partagé entre containers
CONFIRMÉ — Par la documentation officielle Docker, la documentation kernel, des articles LWN.net, et des preuves empiriques.
C’est l’affirmation la plus importante et celle qui mérite le plus de preuves.
Source 1 : Documentation officielle Docker
La documentation Docker du driver OverlayFS déclare explicitement :
“OverlayFS supports page cache sharing. Multiple containers accessing the same file share a single page cache entry (or entries). This makes the overlay2 drivers efficient with memory and a good option for high-density use cases such as PaaS.”
Source 2 : LWN.net — “Overlayfs issues and experiences” (2015)
LWN.net Article 636943, couvrant le LSFMM Summit 2015 (conférence des développeurs kernel), explique pourquoi Docker a migré vers overlayfs :
“The big reason that Docker has switched to overlayfs is to gain the memory efficiency that comes from pages in the page cache being shared between the containers.”
Et décrit le problème qui existait avant overlayfs, avec les storage drivers par blocs (devicemapper, btrfs) :
“There might be a hundred containers running on a system all based on a snapshot of a single root filesystem, which means there will be a hundred copies of glibc in the page cache because they come from different namespaces with different inodes, so there is no sharing of the data.”
Cet article est écrit par Jonathan Corbet, le fondateur de LWN.net et contributeur kernel.
Source 3 : Documentation kernel — Le mécanisme technique
La documentation kernel d’OverlayFS explique le comportement en lecture :
“If a file that is not yet copied up is mmap’d and read, page cache for both lower fs and ovl fs is populated. This does not happen for normal reads, as ovl can avoid page cache while redirecting the read call to lower fs.”
C’est la clé technique : pour les appels read() normaux, overlayfs bypasse son propre page cache et redirige la lecture directement vers le système de fichiers inférieur. Puisque tous les containers partagent les mêmes inodes du lower filesystem, ils partagent les mêmes entrées de page cache.
Source 4 : Code source du kernel Linux
Le fichier fs/overlayfs/file.c contient la fonction ovl_read_iter() qui ouvre le “real file” de la layer inférieure et lui délègue la lecture. L’I/O passe par l’address_space de l’inode inférieur — partagé entre tous les mounts overlay.
La fonction ovl_d_real() dans fs/overlayfs/inode.c retourne le dentry/inode de la layer inférieure pour les fichiers non copiés. Le page cache du kernel est indexé par (address_space, offset) — l’address_space est lié à l’inode. Même inode = même page cache.
Source 5 : Red Hat Developer Blog (2016)
L’article Docker project: Can you have overlay2 speed and density with devicemapper? de Red Hat explique avec des benchmarks :
“dm-ro and overlay2 modes do I/O at the very beginning to start the first container, but then no I/O after that — exactly what we want: the behavior of a shared page-cache.”
Et confirme que le partage repose sur l’identité des inodes :
“A union filesystem such as overlay will maintain inode numbers between the base image and container […] btrfs and devicemapper will not — this is what leads to the difference in page-cache sharing behavior.”
Source 6 : Preuve empirique — Comparaison d’adresses physiques
Dans le forum Docker et dans containers/storage#996, des utilisateurs ont vérifié empiriquement :
- En utilisant
/proc/PID/pagemappour extraire les PFN (Page Frame Numbers) de deux containers - Les adresses physiques sont identiques pour les pages de librairies lues depuis la même layer
- Le PSS diminue quand plus de containers partagent la même page : un fichier de 24 MB montre un PSS de 24 576 kB avec 1 container, et 12 288 kB avec 2 containers (divisé par 2)
- Testé sur docker-ce Ubuntu 18.04 et podman RHEL 8 (overlay2) — mais ne fonctionne PAS sur RHEL 7.5 avec devicemapper
A.4 Affirmation 3 : Les shared libraries sont partagées en RAM
CONFIRMÉ — Avec un caveat important sur le double-caching mmap.
Le mécanisme est le même que pour l’affirmation 2 : les .so sont lues depuis la même layer inférieure, donc le même page cache.
Caveat mmap : la documentation kernel note que pour les fichiers mappés via mmap() (ce qui est le cas des shared libraries), il y a un double-caching : les pages existent à la fois dans le page cache de la layer inférieure ET dans celui de l’overlay. La layer inférieure est toujours partagée, mais l’overlay ajoute une copie par container.
A.5 Affirmation 4 : Le binaire node n’est en RAM qu’une fois
CONFIRMÉ en principe, avec la même nuance mmap.
Le binaire node est chargé via execve() qui utilise mmap() pour mapper les segments de code. Le même mécanisme de partage via le page cache s’applique. Les pages de code en lecture seule (segment .text) sont partagées entre tous les containers.
A.6 Caveats et limitations importantes
Caveat 1 : Le copy-up casse le partage
Quand un container écrit dans un fichier, overlayfs effectue un “copy-up” : le fichier est copié dans la couche R/W du container. Après copy-up, le fichier a un nouvel inode et n’est plus partagé.
L’article LWN.net sur metacopy (kernel 4.19+) décrit comment chown() peut casser le partage :
“people end up doing chown() on whole image directory tree based on container mappings, and this chown copies up everything, breaking sharing of page cache between containers.”
La feature metacopy (kernel 4.19+) atténue ce problème en ne copiant que les métadonnées pour les opérations comme chown, préservant le partage de données.
Caveat 2 : fuse-overlayfs (containers rootless) ne partage PAS
Documenté dans containers/fuse-overlayfs#254 : la version FUSE d’overlayfs (utilisée pour les containers rootless avant kernel 5.11) ne supporte pas le partage de page cache car FUSE gère ses propres inodes en espace utilisateur.
Caveat 3 : Seul overlay/overlay2/aufs partagent le page cache
La documentation Docker est claire :
“A union filesystem such as aufs or overlay will maintain inode numbers between the base image and container, while btrfs and devicemapper will not — this is what leads to the difference in page-cache sharing behavior.”
Caveat 4 : Même contenu ≠ même layer
Démontré dans containers/storage#996 : si deux images sont reconstruites séparément (même contenu, mais docker build sans cache), les layers ont des répertoires et inodes différents sur disque. Le partage ne fonctionne qu’avec la même layer exacte (même digest), pas simplement le même contenu de fichiers.
Caveat 5 : La comptabilité cgroups cache le partage
Selon Viacheslav Biriukov, les pages partagées dans le page cache sont facturées au premier cgroup qui y accède (“first touch”). Le premier container à lire une page partagée se voit imputer toute la charge. C’est pourquoi docker stats ne montre pas le partage — et pourquoi PSS (/proc/PID/smaps_rollup) est la meilleure métrique.
Caveat 6 : Double-caching pour les fichiers mmap’d
Pour les fichiers lus normalement (read()), overlayfs bypasse son propre cache et utilise directement celui de la layer inférieure. Mais pour les fichiers mappés via mmap() (binaires, .so), les pages existent en double : dans le page cache inférieur ET dans celui de l’overlay. Les pages inférieures sont partagées, mais celles de l’overlay ajoutent un surcoût.
A.7 Tableau récapitulatif des preuves
| Affirmation | Confirmé ? | Sources primaires |
|---|---|---|
| Layers partagées sur disque | Oui | Kernel docs, Docker docs |
| Page cache partagé (read) | Oui | Docker docs, LWN 636943, Kernel docs, Red Hat |
| Shared libraries partagées (mmap) | Partiellement | Kernel docs (double-caching mmap), Docker Forum (preuve empirique) |
| Binaire node en RAM une fois | Oui (pour les pages .text) | LWN 636943, Docker docs, containers/storage#996 |
A.8 Conditions nécessaires pour que le partage fonctionne
| Condition | Requis ? |
|---|---|
| Storage driver utilisant kernel overlayfs (overlay2, containerd overlayfs snapshotter) | Oui — devicemapper et btrfs ne partagent pas |
| Même image Docker (même layer digest) | Oui — même contenu ne suffit pas |
| Fichier non modifié (pas de copy-up) | Oui — l’écriture casse le partage pour ce container |
| Kernel overlayfs (pas fuse-overlayfs) | Oui — les containers rootless avec fuse ne partagent pas |
| Docker for Mac/Windows | Non — les containers tournent dans une VM, les mesures host ne s’appliquent pas |
A.9 Docker Engine 29.0+ et le containerd snapshotter — Est-ce que ça change quelque chose ?
Non. Le partage de page cache fonctionne toujours.
Depuis Docker Engine 29.0 (2025), le “containerd image store” avec le overlayfs containerd snapshotter remplace le legacy overlay2 graphdriver comme backend par défaut. La documentation Docker indique :
“The overlay2 driver is a legacy storage driver that is superseded by the overlayfs containerd snapshotter.”
Cela peut sembler inquiétant, mais le changement est uniquement au niveau de la couche de gestion (userspace), pas au niveau du kernel :
| Aspect | Legacy graphdriver overlay2 | containerd overlayfs snapshotter |
|---|---|---|
| Mount kernel | mount -t overlay | mount -t overlay (identique) |
| Stockage des layers | /var/lib/docker/overlay2/<hash>/diff | /var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/<N>/fs |
| Nommage des layers | Hash de contenu | ID numérique (1, 2, 3…) |
| API de gestion | Docker graphdriver | containerd snapshotter API |
docker info affiche | Storage Driver: overlay2 | Storage Driver: overlayfs + driver-type: io.containerd.snapshotter.v1 |
La raison pour laquelle le partage est préservé :
- Le containerd snapshotter crée des “committed snapshots” pour chaque layer d’image — ces snapshots sont immuables et créés une seule fois
- Quand un container est créé, containerd appelle
Prepare()avec le snapshot parent, créant un nouveauupperdirmais réutilisant les mêmes cheminslowerdir - Le code source du snapshotter confirme que les
parentPathssont résolus vers les mêmes répertoires (filepath.Join(o.root, "snapshots", id, "fs")) - Mêmes chemins → mêmes inodes sur le filesystem sous-jacent → même
address_space→ même page cache
Un mount typique sous containerd ressemble à :
overlay /rootfs overlay rw,lowerdir=.../snapshots/5/fs:.../snapshots/4/fs:.../snapshots/3/fs,upperdir=.../snapshots/77/fs,workdir=...
Deux containers depuis la même image auront les mêmes lowerdir (snapshots 5, 4, 3) mais des upperdir différents — exactement le même schéma qu’avec le legacy overlay2.
La documentation containerd confirme :
“The same 6 committed layers exist, but only 2 new active snapshots are created, one for each container. Both have the parent of the top committed snapshot.”
En résumé : le renommage de “overlay2” en “overlayfs containerd snapshotter” est un changement d’architecture logicielle (meilleure séparation des responsabilités via l’API snapshotter de containerd), pas un changement de mécanisme filesystem. Le kernel overlayfs fonctionne exactement pareil, et le partage de page cache est intact.
Sources
- containerd image store — Docker Docs
- Docker Engine v29 release notes
- containerd overlayfs snapshotter source — overlay.go
- containerd content-flow.md
- containerd snapshotters README
Annexe B : Protocole de test — Vérifier le partage par soi-même
Un script de test complet est disponible dans
docker-shared-memory-test/. Cette annexe explique la démarche et les outils.
B.1 Prérequis
- Linux natif (pas Docker for Mac/Windows qui tourne dans une VM)
- Docker avec un driver overlayfs (
docker info | grep "Storage Driver"— doit afficheroverlay2ouoverlayfs) - Accès root (pour
/proc/*/pagemapet/proc/kpagecount) - Packages :
util-linux>= 2.32 (pourfincore),python3, optionnel :smem
B.2 Le principe du test
1. Démarrer N containers depuis la même image
2. Mesurer la mémoire à 3 niveaux :
- Host : delta de MemAvailable (/proc/meminfo)
- Process : RSS vs PSS (/proc/PID/smaps_rollup)
- Physique : PFN identiques (/proc/PID/pagemap)
3. Si le partage existe :
- Sum(RSS) croît linéairement (chaque container compte les pages partagées)
- Sum(PSS) croît sous-linéairement (les pages partagées sont divisées)
- Les PFN sont identiques pour les segments read-only entre containers
B.3 Outils clés
/proc/PID/smaps_rollup — Le plus rapide
# Trouver le PID du container
PID=$(docker inspect --format '{{.State.Pid}}' mon-container)
# Lire les métriques mémoire
cat /proc/$PID/smaps_rollup
| Champ | Signification | Ce qu’il faut regarder |
|---|---|---|
Rss | Mémoire physique totale (compte les pages partagées en entier) | Reste ~constant par container |
Pss | Proportionnel (pages partagées ÷ nombre de processus qui les partagent) | Diminue quand plus de containers partagent |
Shared_Clean | Pages lecture seule partagées avec d’autres processus | C’est le partage — devrait être élevé pour node, libc, etc. |
Private_Dirty | Pages anonymes (heap, stack) | Coût unique par container |
/proc/PID/pagemap — La preuve définitive
Chaque page virtuelle a une entrée de 64 bits dans pagemap. Le bit 63 indique si la page est en RAM, et les bits 0-54 contiennent le PFN (Page Frame Number) — l’adresse physique de la page.
Si deux containers ont le même PFN pour la même bibliothèque, ils partagent littéralement la même page physique en RAM.
# Requiert root (CAP_SYS_ADMIN depuis Linux 4.0)
sudo python3 docker-shared-memory-test/compare_pagemap.py $PID_A $PID_B "node"
Référence : Examining Process Page Tables — Linux Kernel docs
fincore — Cache status des fichiers
# Quel pourcentage du binaire node est en page cache ?
MERGED=$(docker inspect --format '{{.GraphDriver.Data.MergedDir}}' mon-container)
fincore "${MERGED}/usr/local/bin/node"
Si le binaire est “100% cached”, il est en RAM et partagé entre tous les containers.
pmap -x PID — Détail par mapping
pmap -x $PID # RSS par région mémoire
pmap -XX $PID # Shared_Clean/Shared_Dirty/Private_Clean/Private_Dirty par mapping
B.4 Script automatisé
Un script complet est fourni dans docker-shared-memory-test/test-shared-pages.sh. Il :
- Build une image Node.js avec un serveur HTTP (~10 MB de mémoire privée intentionnelle)
- Lance 1, 5, 10, 50 containers successivement
- Pour chaque N, mesure MemAvailable, Sum(RSS), Sum(PSS), Shared_Clean, Private_Dirty
- Affiche un tableau montrant le scaling sous-linéaire
- Avec
--deep-dive: compare les PFN via pagemap entre 2 containers
# Test de base
sudo bash docker-shared-memory-test/test-shared-pages.sh
# Avec preuves pagemap
sudo bash docker-shared-memory-test/test-shared-pages.sh --deep-dive
# Compteurs personnalisés
sudo bash docker-shared-memory-test/test-shared-pages.sh --counts "1 2 5 10 20"
B.5 Résultats attendus
Pour 10 containers Node.js identiques (chacun allouant ~10 MB privé) :
| Métrique | 1 container | 10 containers | Interprétation |
|---|---|---|---|
| RSS par container | ~50 MB | ~50 MB | Constant (pages partagées comptées en entier) |
| PSS par container | ~50 MB | ~20 MB | Baisse : pages partagées divisées par 10 |
| Sum(RSS) | 50 MB | 500 MB | Trompeur — surcompte les pages partagées |
| Sum(PSS) | 50 MB | ~200 MB | Réaliste — reflète le coût réel |
| Host MemAvailable delta | ~55 MB | ~160 MB | Proche de Sum(PSS) |
| Pagemap sharing rate (binaire node) | N/A | ~100% | Mêmes PFN pour les segments r-x |
La preuve : le delta mémoire host pour 10 containers n’est PAS 10× le coût d’un seul container. Il est proche de (privé × 10) + (partagé × 1).
B.6 Référence des outils
| Outil | Ce qu’il montre | Limites |
|---|---|---|
/proc/PID/smaps_rollup | RSS, PSS, Shared/Private par processus | Ne distingue pas les sources du partage |
/proc/PID/pagemap | PFN physiques par page virtuelle | Requiert root depuis Linux 4.0 |
/proc/kpagecount | Nombre de processus partageant chaque page physique | Requiert root |
fincore | % d’un fichier en page cache | Requiert util-linux >= 2.32 |
pmap -XX | Détail Shared/Private par mapping mémoire | Lecture depuis smaps |
smem | Rapport PSS/USS/RSS pour tous les processus | apt install smem ou pip install smem2 |
perf stat -e page-faults | Major/minor faults au démarrage | Le 1er container : major faults. Les suivants : minor (pages déjà en cache) |
docker stats | Mémoire cgroup par container | Ne montre PAS le partage (first-touch accounting) |
B.7 Sources de l’annexe
Documentation primaire (kernel, Docker)
- Overlay Filesystem — Linux Kernel documentation
- OverlayFS storage driver — Docker Docs
- Storage drivers overview — Docker Docs
- Runtime metrics — Docker Docs
- Linux kernel
fs/overlayfs/file.c(source) - Linux kernel
fs/overlayfs/inode.c(source) - Examining Process Page Tables — Linux Kernel docs
- Memory Resource Controller — Linux Kernel docs
Articles techniques (LWN.net, Red Hat)
- Overlayfs issues and experiences (LSFMM 2015) — LWN.net
- Sharing pages between mappings (LSFMM 2017) — LWN.net
- overlayfs: stack file operations — LWN.net
- overlayfs: Delayed copy up of data (metacopy) — LWN.net
- Docker project: overlay2 speed and density — Red Hat Developer
- Overview of Storage Scalability in Docker (2014) — Red Hat Developer
- Overlayfs and Containers (Vault 2017) — Goyal & Szeredi (PDF)
Preuves empiriques
- Shared library in containers — Docker Forum (pagemap comparison)
- Sharing memory between containers — containers/storage#996 (vmtouch + PSS test)
- Does Docker/LXC de-duplicate memory? — moby/moby#7950
Caveats
- Page cache sharing in fuse-overlayfs — fuse-overlayfs#254
- cgroup v2 and Page Cache (first-touch accounting) — Biriukov
- Overlayfs non-standard behavior — Amir Goldstein wiki