Anxiété de séparation : un didacticiel pour isoler votre système avec des espaces de noms Linux
Publié: 2022-03-11Avec l'avènement d'outils comme Docker, Linux Containers et autres, il est devenu très facile d'isoler les processus Linux dans leurs propres petits environnements système. Cela permet d'exécuter toute une gamme d'applications sur une seule machine Linux réelle et de s'assurer qu'aucune d'entre elles ne peut interférer l'une avec l'autre, sans avoir recours à l'utilisation de machines virtuelles. Ces outils ont été une aubaine pour les fournisseurs de PaaS. Mais que se passe-t-il exactement sous le capot ?
Ces outils reposent sur un certain nombre de fonctionnalités et de composants du noyau Linux. Certaines de ces fonctionnalités ont été introduites assez récemment, tandis que d'autres nécessitent encore que vous corrigiez le noyau lui-même. Mais l'un des composants clés, utilisant les espaces de noms Linux, est une fonctionnalité de Linux depuis la sortie de la version 2.6.24 en 2008.
Toute personne familière avec chroot
a déjà une idée de base de ce que les espaces de noms Linux peuvent faire et comment utiliser l'espace de noms en général. Tout comme chroot
permet aux processus de voir n'importe quel répertoire arbitraire comme la racine du système (indépendamment du reste des processus), les espaces de noms Linux permettent également de modifier indépendamment d'autres aspects du système d'exploitation. Cela inclut l'arborescence des processus, les interfaces réseau, les points de montage, les ressources de communication inter-processus, etc.
Pourquoi utiliser des espaces de noms pour l'isolation de processus ?
Dans un ordinateur mono-utilisateur, un environnement système unique peut suffire. Mais sur un serveur, où vous souhaitez exécuter plusieurs services, il est essentiel pour la sécurité et la stabilité que les services soient aussi isolés les uns des autres que possible. Imaginez un serveur exécutant plusieurs services, dont l'un est compromis par un intrus. Dans un tel cas, l'intrus peut être en mesure d'exploiter ce service et de se frayer un chemin vers les autres services, et peut même être en mesure de compromettre l'ensemble du serveur. L'isolation de l'espace de noms peut fournir un environnement sécurisé pour éliminer ce risque.
Par exemple, en utilisant l'espacement des noms, il est possible d'exécuter en toute sécurité des programmes arbitraires ou inconnus sur votre serveur. Récemment, il y a eu un nombre croissant de plateformes de concours de programmation et de «hackathon», telles que HackerRank, TopCoder, Codeforces et bien d'autres. Beaucoup d'entre eux utilisent des pipelines automatisés pour exécuter et valider les programmes soumis par les candidats. Il est souvent impossible de connaître à l'avance la véritable nature des programmes des concurrents, et certains peuvent même contenir des éléments malveillants. En exécutant ces programmes dans un espace de noms complètement isolé du reste du système, le logiciel peut être testé et validé sans mettre en danger le reste de la machine. De même, les services d'intégration continue en ligne, tels que Drone.io, récupèrent automatiquement votre référentiel de code et exécutent les scripts de test sur leurs propres serveurs. Encore une fois, l'isolation de l'espace de noms est ce qui permet de fournir ces services en toute sécurité.
Les outils d'espacement de noms comme Docker permettent également un meilleur contrôle de l'utilisation des ressources système par les processus, ce qui rend ces outils extrêmement populaires pour une utilisation par les fournisseurs PaaS. Des services comme Heroku et Google App Engine utilisent ces outils pour isoler et exécuter plusieurs applications de serveur Web sur le même matériel réel. Ces outils leur permettent d'exécuter chaque application (qui peut avoir été déployée par un certain nombre d'utilisateurs différents) sans se soucier du fait que l'une d'entre elles utilise trop de ressources système, ou interfère et/ou entre en conflit avec d'autres services déployés sur la même machine. Avec une telle isolation de processus, il est même possible d'avoir des piles de logiciels (et de versions) de dépendance entièrement différentes pour chaque environnement isolé !
Si vous avez utilisé des outils comme Docker, vous savez déjà que ces outils sont capables d'isoler les processus dans de petits "conteneurs". L'exécution de processus dans des conteneurs Docker revient à les exécuter dans des machines virtuelles, seuls ces conteneurs sont nettement plus légers que les machines virtuelles. Une machine virtuelle émule généralement une couche matérielle au-dessus de votre système d'exploitation, puis exécute un autre système d'exploitation par-dessus. Cela vous permet d'exécuter des processus à l'intérieur d'une machine virtuelle, complètement isolés de votre système d'exploitation réel. Mais les machines virtuelles sont lourdes ! Les conteneurs Docker, d'autre part, utilisent certaines fonctionnalités clés de votre système d'exploitation réel, y compris les espaces de noms, et garantissent un niveau d'isolation similaire, mais sans émuler le matériel et exécuter un autre système d'exploitation sur la même machine. Cela les rend très légers.
Espace de noms de processus
Historiquement, le noyau Linux a maintenu une arborescence de processus unique. L'arborescence contient une référence à chaque processus en cours d'exécution dans une hiérarchie parent-enfant. Un processus, s'il dispose de privilèges suffisants et satisfait à certaines conditions, peut inspecter un autre processus en lui attachant un traceur ou même être capable de le tuer.
Avec l'introduction des espaces de noms Linux, il est devenu possible d'avoir plusieurs arborescences de processus "imbriquées". Chaque arbre de processus peut avoir un ensemble de processus entièrement isolé. Cela peut garantir que les processus appartenant à un arbre de processus ne peuvent pas inspecter ou tuer - en fait, ne peuvent même pas connaître l'existence de - processus dans d'autres arbres de processus frères ou parents.
Chaque fois qu'un ordinateur avec Linux démarre, il démarre avec un seul processus, avec l'identificateur de processus (PID) 1. Ce processus est la racine de l'arborescence des processus et il lance le reste du système en effectuant les travaux de maintenance appropriés et en démarrant les bons démons/services. Tous les autres processus démarrent sous ce processus dans l'arborescence. L'espace de noms PID permet de créer un nouvel arbre, avec son propre processus PID 1. Le processus qui fait cela reste dans l'espace de noms parent, dans l'arborescence d'origine, mais fait de l'enfant la racine de sa propre arborescence de processus.
Avec l'isolement de l'espace de noms PID, les processus de l'espace de noms enfant n'ont aucun moyen de connaître l'existence du processus parent. Cependant, les processus de l'espace de noms parent ont une vue complète des processus de l'espace de noms enfant, comme s'il s'agissait de n'importe quel autre processus de l'espace de noms parent.
Il est possible de créer un ensemble imbriqué d'espaces de noms enfants : un processus démarre un processus enfant dans un nouvel espace de noms PID, et ce processus enfant génère un autre processus dans un nouvel espace de noms PID, et ainsi de suite.
Avec l'introduction des espaces de noms PID, un seul processus peut désormais être associé à plusieurs PID, un pour chaque espace de noms auquel il appartient. Dans le code source Linux, nous pouvons voir qu'une structure nommée pid
, qui gardait la trace d'un seul PID, suit désormais plusieurs PID grâce à l'utilisation d'une structure nommée upid
:
struct upid { int nr; // the PID value struct pid_namespace *ns; // namespace where this PID is relevant // ... }; struct pid { // ... int level; // number of upids struct upid numbers[0]; // array of upids };
Pour créer un nouvel espace de noms PID, il faut appeler l'appel système clone()
avec un drapeau spécial CLONE_NEWPID
. (C fournit un wrapper pour exposer cet appel système, ainsi que de nombreux autres langages populaires.) Alors que les autres espaces de noms décrits ci-dessous peuvent également être créés à l'aide de l'appel système unshare()
, un espace de noms PID ne peut être créé qu'au moment où un nouveau le processus est généré à l'aide de clone()
. Une fois que clone()
est appelé avec cet indicateur, le nouveau processus démarre immédiatement dans un nouvel espace de noms PID, sous une nouvelle arborescence de processus. Ceci peut être démontré avec un simple programme C :
#define _GNU_SOURCE #include <sched.h> #include <stdio.h> #include <stdlib.h> #include <sys/wait.h> #include <unistd.h> static char child_stack[1048576]; static int child_fn() { printf("PID: %ld\n", (long)getpid()); return 0; } int main() { pid_t child_pid = clone(child_fn, child_stack+1048576, CLONE_NEWPID | SIGCHLD, NULL); printf("clone() = %ld\n", (long)child_pid); waitpid(child_pid, NULL, 0); return 0; }
Compilez et exécutez ce programme avec les privilèges root et vous remarquerez une sortie qui ressemble à ceci :
clone() = 5304 PID: 1
Le PID, tel qu'il est imprimé depuis le child_fn
, sera 1
.
Même si ce code de didacticiel d'espace de noms ci-dessus n'est pas beaucoup plus long que "Hello, world" dans certaines langues, beaucoup de choses se sont passées dans les coulisses. La fonction clone()
, comme vous vous en doutez, a créé un nouveau processus en clonant l'actuel et a démarré l'exécution au début de la fonction child_fn()
. Cependant, ce faisant, il a détaché le nouveau processus de l'arborescence de processus d'origine et a créé une arborescence de processus distincte pour le nouveau processus.
Essayez de remplacer la fonction static int child_fn()
par ce qui suit, pour imprimer le PID parent du point de vue du processus isolé :
static int child_fn() { printf("Parent PID: %ld\n", (long)getppid()); return 0; }
L'exécution du programme cette fois-ci donne le résultat suivant :
clone() = 11449 Parent PID: 0
Remarquez comment le PID parent du point de vue du processus isolé est 0, indiquant qu'il n'y a pas de parent. Essayez à nouveau d'exécuter le même programme, mais cette fois, supprimez l'indicateur CLONE_NEWPID
de l'appel de la fonction clone()
:
pid_t child_pid = clone(child_fn, child_stack+1048576, SIGCHLD, NULL);
Cette fois, vous remarquerez que le PID parent n'est plus 0 :
clone() = 11561 Parent PID: 11560
Cependant, ce n'est que la première étape de notre tutoriel. Ces processus ont toujours un accès illimité à d'autres ressources communes ou partagées. Par exemple, l'interface réseau : si le processus enfant créé ci-dessus devait écouter sur le port 80, il empêcherait tous les autres processus du système de pouvoir l'écouter.
Espace de noms de réseau Linux
C'est là qu'un espace de noms réseau devient utile. Un espace de noms réseau permet à chacun de ces processus de voir un ensemble entièrement différent d'interfaces réseau. Même l'interface de bouclage est différente pour chaque espace de noms réseau.
Isoler un processus dans son propre espace de noms réseau implique d'introduire un autre indicateur dans l'appel de la fonction clone()
: CLONE_NEWNET
;
#define _GNU_SOURCE #include <sched.h> #include <stdio.h> #include <stdlib.h> #include <sys/wait.h> #include <unistd.h> static char child_stack[1048576]; static int child_fn() { printf("New `net` Namespace:\n"); system("ip link"); printf("\n\n"); return 0; } int main() { printf("Original `net` Namespace:\n"); system("ip link"); printf("\n\n"); pid_t child_pid = clone(child_fn, child_stack+1048576, CLONE_NEWPID | CLONE_NEWNET | SIGCHLD, NULL); waitpid(child_pid, NULL, 0); return 0; }
Sortir:

Original `net` Namespace: 1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00 2: enp4s0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP mode DEFAULT group default qlen 1000 link/ether 00:24:8c:a1:ac:e7 brd ff:ff:ff:ff:ff:ff New `net` Namespace: 1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN mode DEFAULT group default link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
Que se passe t-il ici? Le périphérique Ethernet physique enp4s0
appartient à l'espace de noms de réseau global, comme indiqué par l'outil "ip" exécuté à partir de cet espace de noms. Cependant, l'interface physique n'est pas disponible dans le nouvel espace de noms réseau. De plus, le périphérique de bouclage est actif dans l'espace de noms de réseau d'origine, mais est « en panne » dans l'espace de noms de réseau enfant.
Afin de fournir une interface réseau utilisable dans l'espace de noms enfant, il est nécessaire de configurer des interfaces réseau "virtuelles" supplémentaires qui s'étendent sur plusieurs espaces de noms. Une fois cela fait, il est alors possible de créer des ponts Ethernet, et même de router des paquets entre les espaces de noms. Enfin, pour que tout fonctionne, un "processus de routage" doit être en cours d'exécution dans l'espace de noms du réseau global pour recevoir le trafic de l'interface physique et l'acheminer via les interfaces virtuelles appropriées vers les espaces de noms de réseau enfants appropriés. Vous comprendrez peut-être pourquoi des outils comme Docker, qui font tout ce gros travail pour vous, sont si populaires !
Pour ce faire manuellement, vous pouvez créer une paire de connexions Ethernet virtuelles entre un espace de noms parent et un espace de noms enfant en exécutant une seule commande à partir de l'espace de noms parent :
ip link add name veth0 type veth peer name veth1 netns <pid>
Ici, <pid>
doit être remplacé par l'ID de processus du processus dans l'espace de noms enfant tel qu'observé par le parent. L'exécution de cette commande établit une connexion de type pipe entre ces deux espaces de noms. L'espace de noms parent conserve le périphérique veth0
et transmet le périphérique veth1
à l'espace de noms enfant. Tout ce qui entre par l'une des extrémités sort par l'autre extrémité, comme on peut s'y attendre d'une vraie connexion Ethernet entre deux nœuds réels. En conséquence, les deux côtés de cette connexion Ethernet virtuelle doivent se voir attribuer des adresses IP.
Monter l'espace de noms
Linux maintient également une structure de données pour tous les points de montage du système. Il inclut des informations telles que les partitions de disque montées, où elles sont montées, si elles sont en lecture seule, etc. Avec les espaces de noms Linux, on peut faire cloner cette structure de données, de sorte que les processus sous différents espaces de noms puissent changer les points de montage sans s'affecter.
La création d'un espace de noms de montage séparé a un effet similaire à celui d'un chroot()
. chroot()
est bon, mais il ne fournit pas une isolation complète et ses effets sont limités au point de montage racine uniquement. La création d'un espace de noms de montage séparé permet à chacun de ces processus isolés d'avoir une vue complètement différente de la structure de point de montage de l'ensemble du système par rapport à celle d'origine. Cela vous permet d'avoir une racine différente pour chaque processus isolé, ainsi que d'autres points de montage spécifiques à ces processus. Utilisé avec soin par ce didacticiel, vous pouvez éviter d'exposer des informations sur le système sous-jacent.
Le drapeau clone()
requis pour y parvenir est CLONE_NEWNS
:
clone(child_fn, child_stack+1048576, CLONE_NEWPID | CLONE_NEWNET | CLONE_NEWNS | SIGCHLD, NULL)
Initialement, le processus enfant voit exactement les mêmes points de montage que son processus parent. Cependant, étant sous un nouvel espace de noms de montage, le processus enfant peut monter ou démonter les points de terminaison de son choix, et la modification n'affectera ni l'espace de noms de son parent, ni aucun autre espace de noms de montage dans l'ensemble du système. Par exemple, si le processus parent a une partition de disque particulière montée à la racine, le processus isolé verra exactement la même partition de disque montée à la racine au début. Mais l'avantage d'isoler l'espace de noms de montage est évident lorsque le processus isolé essaie de changer la partition racine en quelque chose d'autre, car la modification n'affectera que l'espace de noms de montage isolé.
Fait intéressant, cela en fait une mauvaise idée de générer le processus enfant cible directement avec le drapeau CLONE_NEWNS
. Une meilleure approche consiste à démarrer un processus "init" spécial avec le drapeau CLONE_NEWNS
, à ce que le processus "init" modifie le "/", "/proc", "/dev" ou d'autres points de montage comme vous le souhaitez, puis démarrez le processus cible . Ceci est discuté un peu plus en détail vers la fin de ce didacticiel sur l'espace de noms.
Autres espaces de noms
Il existe d'autres espaces de noms dans lesquels ces processus peuvent être isolés, à savoir l'utilisateur, l'IPC et l'UTS. L'espace de noms d'utilisateur permet à un processus d'avoir des privilèges root dans l'espace de noms, sans lui donner cet accès aux processus en dehors de l'espace de noms. Isoler un processus par l'espace de noms IPC lui donne ses propres ressources de communication interprocessus, par exemple, les messages System V IPC et POSIX. L'espace de noms UTS isole deux identifiants spécifiques du système : nodename
et domainname
.
Un exemple rapide pour montrer comment l'espace de noms UTS est isolé est illustré ci-dessous :
#define _GNU_SOURCE #include <sched.h> #include <stdio.h> #include <stdlib.h> #include <sys/utsname.h> #include <sys/wait.h> #include <unistd.h> static char child_stack[1048576]; static void print_nodename() { struct utsname utsname; uname(&utsname); printf("%s\n", utsname.nodename); } static int child_fn() { printf("New UTS namespace nodename: "); print_nodename(); printf("Changing nodename inside new UTS namespace\n"); sethostname("GLaDOS", 6); printf("New UTS namespace nodename: "); print_nodename(); return 0; } int main() { printf("Original UTS namespace nodename: "); print_nodename(); pid_t child_pid = clone(child_fn, child_stack+1048576, CLONE_NEWUTS | SIGCHLD, NULL); sleep(1); printf("Original UTS namespace nodename: "); print_nodename(); waitpid(child_pid, NULL, 0); return 0; }
Ce programme donne la sortie suivante :
Original UTS namespace nodename: XT New UTS namespace nodename: XT Changing nodename inside new UTS namespace New UTS namespace nodename: GLaDOS Original UTS namespace nodename: XT
Ici, child_fn()
imprime le nodename
, le change en quelque chose d'autre et l'imprime à nouveau. Naturellement, le changement ne se produit qu'à l'intérieur du nouvel espace de noms UTS.
Plus d'informations sur ce que tous les espaces de noms fournissent et isolent peuvent être trouvées dans le tutoriel ici
Communication inter-espaces de noms
Il est souvent nécessaire d'établir une sorte de communication entre l'espace de noms parent et enfant. Cela peut être pour effectuer un travail de configuration dans un environnement isolé, ou simplement pour conserver la possibilité de jeter un coup d'œil sur l'état de cet environnement depuis l'extérieur. Une façon de faire est de garder un démon SSH en cours d'exécution dans cet environnement. Vous pouvez avoir un démon SSH distinct dans chaque espace de noms réseau. Cependant, l'exécution de plusieurs démons SSH utilise beaucoup de ressources précieuses comme la mémoire. C'est là qu'avoir un processus "init" spécial s'avère à nouveau être une bonne idée.
Le processus "init" peut établir un canal de communication entre l'espace de noms parent et l'espace de noms enfant. Ce canal peut être basé sur des sockets UNIX ou peut même utiliser TCP. Pour créer un socket UNIX qui s'étend sur deux espaces de noms de montage différents, vous devez d'abord créer le processus enfant, puis créer le socket UNIX, puis isoler l'enfant dans un espace de noms de montage distinct. Mais comment pouvons-nous d'abord créer le processus et l'isoler ensuite ? Linux fournit unshare()
. Cet appel système spécial permet à un processus de s'isoler de l'espace de noms d'origine, au lieu que le parent isole l'enfant en premier lieu. Par exemple, le code suivant a exactement le même effet que le code mentionné précédemment dans la section sur l'espace de noms réseau :
#define _GNU_SOURCE #include <sched.h> #include <stdio.h> #include <stdlib.h> #include <sys/wait.h> #include <unistd.h> static char child_stack[1048576]; static int child_fn() { // calling unshare() from inside the init process lets you create a new namespace after a new process has been spawned unshare(CLONE_NEWNET); printf("New `net` Namespace:\n"); system("ip link"); printf("\n\n"); return 0; } int main() { printf("Original `net` Namespace:\n"); system("ip link"); printf("\n\n"); pid_t child_pid = clone(child_fn, child_stack+1048576, CLONE_NEWPID | SIGCHLD, NULL); waitpid(child_pid, NULL, 0); return 0; }
Et puisque le processus "init" est quelque chose que vous avez conçu, vous pouvez d'abord lui faire faire tout le travail nécessaire, puis s'isoler du reste du système avant d'exécuter l'enfant cible.
Conclusion
Ce didacticiel n'est qu'un aperçu de l'utilisation des espaces de noms sous Linux. Cela devrait vous donner une idée de base de la façon dont un développeur Linux pourrait commencer à implémenter l'isolation du système, une partie intégrante de l'architecture d'outils comme Docker ou Linux Containers. Dans la plupart des cas, il serait préférable d'utiliser simplement l'un de ces outils existants, déjà bien connus et éprouvés. Mais dans certains cas, il peut être judicieux d'avoir votre propre mécanisme d'isolation de processus personnalisé, et dans ce cas, ce didacticiel sur l'espace de noms vous aidera énormément.
Il se passe beaucoup plus de choses sous le capot que ce que j'ai couvert dans cet article, et il existe d'autres façons de limiter vos processus cibles pour plus de sécurité et d'isolement. Mais, espérons-le, cela peut servir de point de départ utile pour quelqu'un qui souhaite en savoir plus sur le fonctionnement réel de l'isolation d'espace de noms avec Linux.