Des articles approfondis sur les technologies qui façonnent l'avenir.

Sandboxes VM en moins d'1 ms grâce au Copy-on-Write

Comment le fork de mémoire copy-on-write permet des sandboxes VM qui démarrent en moins d'une milliseconde, et change l'isolation serverless et la sécurité.

Une bulle de savon qui se divise en plusieurs petites bulles, chacune contenant une minuscule pièce de verre

Lancer un conteneur Docker prend environ 500 millisecondes. Une microVM Firecracker en prend environ 125, et un isolat V8 environ 5. Mais une nouvelle génération de sandboxes légères, utilisant le fork de mémoire copy-on-write, peut créer un environnement d'exécution isolé en moins de 1 milliseconde, souvent entre 50 et 200 microsecondes. C'est assez rapide pour créer un sandbox neuf à chaque appel de fonction.

Ce n'est pas qu'une amélioration incrémentale. C'est un changement qualitatif dans ce que le sandboxing permet de faire. Quand la création d'un sandbox coûte 500 ms, on les crée avec parcimonie et on les réutilise. Quand elle coûte 50 µs, on en crée un pour chaque entrée non fiable, chaque invocation de plugin, chaque requête utilisateur. Le modèle de sécurité passe de « isoler les locataires » à « isoler chaque opération individuelle ».

Ce que le Copy-on-Write signifie vraiment

Le copy-on-write (CoW) est une technique de système d'exploitation qui permet de créer une « copie » d'une zone mémoire sans copier réellement les données. L'original et la copie pointent vers les mêmes pages physiques, marquées en lecture seule. Ils sont indiscernables : les deux voient exactement les mêmes données. La copie réelle n'a lieu que lorsqu'un des deux tente d'écrire dans une page. À ce moment, le noyau intercepte l'écriture, copie uniquement cette page et laisse l'écriture se faire sur la copie.

L'appel système fork() d'Unix utilise cela depuis les années 1990. Quand on fork un processus, l'enfant reçoit une copie complète de la mémoire du parent, mais grâce au CoW, aucune donnée n'est réellement copiée. Si l'enfant appelle immédiatement exec() (ce qui est typique), il remplace entièrement sa mémoire et les pages CoW sont simplement libérées. Le fork ne coûtait presque rien.

// Classic fork() — copy-on-write in action
pid_t pid = fork();
// At this point:
// - Parent and child share ALL physical memory pages
// - Both have identical virtual address spaces
// - Pages are marked read-only in both
// - Zero data has been copied
if (pid == 0) {
// Child process
// Reading shared_data is free — same physical page
int value = shared_data[0];
// Writing triggers CoW — only THIS page gets copied
shared_data[0] = 42;
// Now child has its own copy of this one page
// Parent's page is unchanged
}

Du fork au sandbox

L'idée centrale des sandboxes CoW : au lieu de démarrer une VM ou un conteneur neuf à partir de zéro, on pré-démarre un environnement « modèle » (avec le runtime, les bibliothèques et l'état initial chargés), puis on le fork en CoW pour créer des copies instantanées. Chaque copie démarre exactement dans l'état où le modèle s'était arrêté (entièrement initialisée, prête à exécuter), mais s'exécute dans son propre espace mémoire isolé.

La différence de performance est énorme. Le démarrage classique d'une VM implique : charger un noyau, initialiser le matériel, monter les systèmes de fichiers, lancer le système d'init, charger le code applicatif, initialiser le runtime. Même avec une optimisation poussée (Firecracker simplifie fortement tout cela), on passe encore des centaines de millisecondes à initialiser.

Le fork CoW évite tout cela. Le modèle a déjà fait l'initialisation. Le fork crée une copie pré-initialisée en quelques microsecondes. Le « coût de démarrage » se résume au travail du noyau pour créer un nouvel espace d'adressage et dupliquer les entrées de la table des pages : quelques milliers d'opérations, quelle que soit la quantité de mémoire utilisée par le modèle.

Où cela change la donne

Fonctions serverless

Les démarrages à froid sont le fléau du serverless. AWS Lambda met 100 à 500 ms pour un démarrage à froid, davantage pour les runtimes basés sur la JVM. C'est inacceptable pour les charges sensibles à la latence, ce qui pousse les utilisateurs à garder des instances chaudes (ce qui annule l'intérêt du serverless) ou à accepter une latence imprévisible.

Avec les sandboxes CoW, les démarrages à froid tombent sous la milliseconde. Chaque invocation peut être un « démarrage à froid », car ceux-ci sont pratiquement gratuits. Plus besoin de pools d'instances chaudes, plus de mémoire gaspillée par des instances inactives, plus d'état résiduel entre les invocations. Chaque exécution de fonction obtient un environnement isolé et vierge sans payer le coût d'initialisation.

Systèmes de plugins et d'extensions

Exécuter des plugins non fiables en toute sécurité est l'un des problèmes les plus difficiles du logiciel. Les navigateurs l'ont résolu pour JavaScript avec les isolats V8. Mais pour du code arbitraire (extensions compilées, langages de script, plugins binaires), les options d'isolation se limitaient aux conteneurs (trop lents pour une isolation par requête) ou à WebAssembly (écosystème et support des langages restreints).

Les sandboxes CoW offrent un entre-deux : exécuter du code arbitraire dans une copie isolée de l'environnement hôte, avec une mise en place et un nettoyage en moins d'une milliseconde. Le plugin voit un environnement OS complet (système de fichiers, réseau, bibliothèques), mais ses modifications restent confinées : à la sortie du sandbox, tous les changements disparaissent. C'est idéal pour le code soumis par des utilisateurs dans les systèmes CI, les environnements de notebooks et les outils de build.

Isolation de sécurité

Lorsqu'on traite une entrée non fiable (analyser un PDF uploadé, rendre du HTML fourni par l'utilisateur, exécuter une requête de base de données), lancer l'opération dans un sandbox isolé limite la portée de tout exploit. Si l'analyseur PDF présente un dépassement de tampon, l'attaquant prend le contrôle d'un sandbox jetable qui va être détruit, et non du serveur applicatif.

Cette approche, l'isolation de processus pour chaque opération non fiable, était jusqu'ici impraticable avec les sandboxes traditionnels, car le surcoût dépassait le temps de traitement. Si analyser un PDF prend 10 ms, dépenser 500 ms pour créer un conteneur n'a aucun sens. Mais dépenser 100 µs pour créer un sandbox CoW est dérisoire.

Les détails d'implémentation

Construire un système de sandboxes CoW utilisable demande de résoudre plusieurs problèmes au-delà du simple appel à fork().

  • Comptabilité mémoire. Le CoW rend l'usage mémoire ambigu. Si un modèle utilise 1 Go et qu'on fork 100 copies qui modifient chacune 10 Mo, l'usage physique est d'environ 2 Go (1 Go partagé + 100 × 10 Mo uniques), et non 100 Go. Le noyau suit les pages partagées et privées, mais obtenir un usage précis par sandbox demande d'analyser /proc/[pid]/smaps.
  • Isolation du système de fichiers. Le CoW gère la mémoire, mais les écritures sur le système de fichiers nécessitent une isolation distincte. Les systèmes de fichiers en superposition (overlayfs) offrent une sémantique CoW pour les fichiers : le sandbox voit le système de fichiers du modèle, mais les écritures vont dans une couche séparée. À la sortie du sandbox, cette couche est supprimée.
  • Isolation réseau. Chaque sandbox a besoin de son propre namespace réseau pour éviter les interférences. Les namespaces Linux le permettent, mais leur création a un surcoût mesurable. Certains systèmes réutilisent un pool de namespaces pré-créés.
  • Limites de ressources. Un sandbox qui alloue une mémoire illimitée ou consomme un CPU sans restriction est un vecteur de déni de service. Les cgroups fournissent des limites de ressources (mémoire, CPU, E/S), mais leur création et destruction ajoutent du surcoût. Là encore, la mise en pool aide.
  • Nettoyage déterministe. À la sortie d'un sandbox, toutes ses ressources (mémoire, descripteurs de fichiers, connexions réseau, objets IPC) doivent être nettoyées de façon fiable. Les namespaces PID aident : il suffit de tuer le processus init du namespace pour que tous ses descendants soient tués.

CoW contre sandboxes WebAssembly

WebAssembly (Wasm) est l'autre grande technologie de sandboxing léger. Elle mérite d'être comparée, car les deux font des compromis fondamentalement différents.

Les sandboxes Wasm exécutent le code dans une machine virtuelle sûre sur le plan mémoire, avec un modèle de mémoire linéaire. Le sandbox ne peut accéder à rien en dehors de sa mémoire linéaire : pas de système de fichiers, pas de réseau, pas d'appels système (sauf si WASI les fournit explicitement). C'est extrêmement sûr, mais contraignant : le code existant doit être recompilé en Wasm, et tous les langages ne se compilent pas bien vers Wasm.

Les sandboxes CoW exécutent du code natif dans un environnement OS isolé. Le sandbox accède à une interface OS complète (éventuellement restreinte par des filtres seccomp), peut exécuter n'importe quel binaire et utilise les bibliothèques système habituelles. C'est moins restrictif, mais moins sûr : la frontière d'isolation est le modèle de processus de l'OS, qui présente une surface d'attaque plus large que la VM minimale de Wasm.

Choisissez Wasm quand : vous maîtrisez le code isolé, votre charge de travail se compile proprement en Wasm et vous avez besoin de la plus forte isolation possible. Choisissez les sandboxes CoW quand : vous devez exécuter des binaires existants arbitraires, votre charge a besoin de capacités au niveau OS (système de fichiers, réseau, processus enfants) et vous privilégiez la compatibilité à une surface d'attaque minimale.

Le piège : fork dans les programmes multithreadés

Il existe un piège bien connu avec fork() : il ne copie que le thread appelant. Si le parent a 20 threads, l'enfant n'en obtient qu'un. Les mutex tenus par les 19 autres threads restent marqués comme verrouillés dans la mémoire de l'enfant, mais les threads qui les détenaient n'existent plus. L'enfant se bloque donc à la première tentative d'acquérir l'un de ces mutex.

Les systèmes de sandboxes CoW contournent ce problème en s'assurant que le processus modèle est monothread au moment du fork. Concrètement : on initialise tout dans le modèle (chargement des bibliothèques, configuration du runtime, préparation de l'état initial), puis on arrête tous les threads sauf le principal, on fork, et chaque enfant recrée ses threads au besoin. Le coût d'initialisation n'est payé qu'une fois, et le fork évite le piège des threads.

Certaines approches récentes utilisent userfaultfd ou des gestionnaires de fautes de page personnalisés pour implémenter une sémantique proche du CoW sans s'appuyer du tout sur fork(). Elles évitent le problème du multithreading, mais ajoutent de la complexité et exigent une coordination plus poussée au niveau noyau.

Ce qu'il faut suivre

Le sandboxing en moins d'une milliseconde en est encore à ses débuts, mais les briques de base sont solides (fork, namespaces, cgroups et overlayfs sont tous matures). Les systèmes bâtis dessus, pour le serverless, la CI/CD et l'exécution de code sécurisée, prouvent que l'isolation par opération est praticable à grande échelle. À mesure que ces outils mûrissent, l'idée que le sandboxing coûte cher deviendra aussi datée que l'idée que le ramasse-miettes est trop lent pour le temps réel. Le surcoût disparaît, et les bénéfices du « tout sandboxer » deviennent difficiles à ignorer.