Les namespaces network et PID

Spécialité(s)


Résumé

Dans cet avant-dernier article de la série, nous passons en revue les namespaces network et PID pour lesquels la documentation de Linux est plutôt succincte, au regard des fonctionnalités disponibles.


Body

Les namespaces network (net_ns) et PID (pid_ns) ont déjà occupé beaucoup de lignes dans les articles précédents de cette série. Mais ils recèlent encore quelques secrets que nous allons découvrir dans cet article.

Le code de cet article est disponible sur https://github.com/Rachid-Koucha/linux_ns.git.

1. Le namespace network

Le namespace network isole la pile réseau et les structures de données afférentes (les interfaces, la table de routage, les filtres firewall, etc.). Il isole aussi les sockets abstraites du domaine UNIX (cf. man 7 unix). Ces dernières ont la particularité d’utiliser un chemin de fichier (c.-à-d. le champ sun_path dans la structure sockaddr_un) qui n’est pas visible dans le système de fichiers. Pour cela, il faut positionner le premier octet à 0 (c.-à-d. sun_path[0] = ‘\0’). Nous prenons la peine de citer ce point de détail, car LXC est un grand utilisateur des sockets abstraites : la communication entre les outils utilisateur comme lxc-attach ou lxc-console (pour ne citer qu’eux) et le moniteur du conteneur (le processus [lxc monitor] déjà évoqué dans le premier opus de cette série d’articles [1]) se fait par le truchement d’une telle socket. Elle est nommée : /var/lib/lxc/<nom_conteneur>/command.

Une entrée est dédiée à ce namespace dans le manuel en ligne de Linux : man 7 network_namespaces. Il faut cependant reconnaître qu’il est assez succinct.

1.1 L’interface loopback

À la création, un net_ns ne possède qu’une interface réseau : loopback. D’ailleurs, un champ dans la structure du contexte du net_ns pointe dessus. Il s’agit du champ loopback_dev [2].

Notre programme client-serveur simple basé sur UDP utilise l’interface loopback pour communiquer. Le serveur udpsrv reçoit en argument le numéro de port sur lequel il réceptionne un message du client pour l’afficher à l’écran avant de se terminer :

int main(int ac, char *av[])
{
int   opt;
int   rc;
int   sd;
int   port;
struct sockaddr_in addr;
char buffer[128];
[...]
  sd = socket(AF_INET, SOCK_DGRAM, 0);
[...]
  // Address of loopback interface
  addr.sin_family = AF_INET;
  addr.sin_port = htons(port);
  addr.sin_addr.s_addr = inet_addr("127.0.0.1"); // Loopback address
 
  rc = bind(sd, (struct sockaddr *)&addr, sizeof(addr));
[...]
  printf("Listening on port %d\n", port);
 
  rc = read(sd, buffer, sizeof(buffer) - 1);
  if (rc > 0)
  {
    buffer[rc] = '\0';
    printf("Received '%s'\n", buffer);
  }
 
  close(sd);
 
  return 0;
} // main

Le client udpcli reçoit en paramètres le numéro de port du serveur suivi du message à envoyer :

int main(int ac, char *av[])
{
int   opt;
int   rc;
int   sd;
int   port;
struct sockaddr_in addr;
[...]
  sd = socket(AF_INET, SOCK_DGRAM, 0);
[...]
  // Address of loopback interface
  addr.sin_family = AF_INET;
  addr.sin_port = htons(port);
  addr.sin_addr.s_addr = inet_addr("127.0.0.1"); // Loopback address
 
  rc = sendto(sd, av[optind], strlen(av[optind]), 0, (struct sockaddr *)&addr, sizeof(addr));
[...]
  close(sd);
 
  return 0;
} // main

Si on lance le serveur en arrière-plan sur hôte avec le numéro de port 12345 par exemple et que nous lançons le client, la communication s’opère avec succès :

$ ./udpsrv -p 12345 &
[2] 5748
Listening on port 12345
$ ./udpcli -p 12345 "msg from client"
Received 'msg from client'
[2]+ Done                    ./udpsrv -p 12345
$

Si nous lançons deux serveurs en arrière-plan sur le même numéro de port, le second se termine en erreur EADDRINUSE, car le port 12345 est déjà utilisé par le premier sur l’interface loopback :

$ ./udpsrv -p 12345 &
Listening on port 12345
$ ./udpsrv -p 12345 &
ERROR(udpsrv.c#96)>>> bind(): 'Address already in use' (98)

Par contre, le lancement d’un second serveur dans un nouveau net_ns à l’aide de la commande unshare démarre sans problème :

# ./udpsrv -p 12345 &
[1] 11505
Listening on port 12345
# unshare -n ./udpsrv -p 12345 &
[2] 11506
Listening on port 12345

Les deux serveurs ne sont pas en concurrence sur le même port UDP, car chacun est sur sa propre pile réseau et son interface loopback. Ils sont respectivement dans le namespace initial et celui créé par la commande unshare. On note l’identifiant du processus du second serveur (c.-à-d. 11506), car cela va nous servir pour exécuter des commandes dans son net_ns.

En lançant les clients dans chaque namespace, ces derniers atteindront les serveurs respectifs. Mais au préalable, il faut activer l’interface loopback dans le nouveau net_ns. En effet, à la création du namespace, l’interface est dans l’état « down ». Pour le vérifier, on utilise la commande ip via nsenter auquel on passe le PID du second udpsrv avec l’option -t et l’option -n pour entrer dans le net_ns associé :

# nsenter -t 11506 -n -- ip link list
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN mode DEFAULT group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00

On procède de même pour activer l’interface et vérifier le changement :

# nsenter -t 11506 -n -- ip link set lo up
# nsenter -t 11506 -n -- ip link list
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00

Maintenant, il est possible de lancer udpcli dans chaque namespace pour avoir les réponses de la part des serveurs :

# ./udpcli -p 12345 "Message from initial ns"
Received 'Message from initial ns'
[1]- Done                    ./udpsrv -p 12345
# nsenter -t 11506 -n -- ./udpcli -p 12345 "Message from new ns"
Received 'Message from new ns'
[2]+ Done                    unshare -n ./udpsrv -p 12345

Le schéma de la figure 1 résume le mécanisme.

figure 01 udpsrv ns-s

Fig. 1 : Serveurs UDP sur même port, mais net_ns différents.

1.2 Migration des interfaces

On a vu que l’interface loopback est répliquée dans tous les net_ns. Mais il s’agit d’interfaces différentes pour chaque namespace, car toute interface réseau ne peut appartenir qu’à un net_ns à un moment donné. Par défaut, les interfaces autres que loopback sont assignées au namespace initial. On a vu lors de la description de la commande ip [3] qu’il est possible de les faire migrer d’un net_ns à l’autre : l’interface Ethernet eno1 a été transférée dans un conteneur. Cependant, ce n’est pas vrai pour toutes les interfaces. Par exemple, une interface Wi-Fi ne peut pas migrer dans un namespace autre que le namespace initial. De même, il n’est pas possible de migrer les interfaces loopback assignées par défaut à chaque net_ns.

Une astuce [4] permet de savoir si une interface peut changer de namespace ou non. Dans le driver, c’est contrôlé par le drapeau NETIF_F_NETNS_LOCAL. Par exemple dans le source de l’interface loopback (drivers/net/loopback.c) de Linux 5.3.0 :

    dev->features        = NETIF_F_SG | NETIF_F_FRAGLIST
        | NETIF_F_GSO_SOFTWARE
[...]
        | NETIF_F_LLTX
        | NETIF_F_NETNS_LOCAL
        | NETIF_F_VLAN_CHALLENGED
        | NETIF_F_LOOPBACK;

Comme il n’est pas facile de lire le code source de Linux et de savoir quel driver gère les interfaces sur nos systèmes, il y a heureusement l’outil ethtool. Il affiche différents paramètres des interfaces et notamment « netns-local » qui reflète la valeur du drapeau dans le code source. Pour un exemple d’interface Ethernet, la valeur « off » (drapeau non positionné) signifie que l’interface peut migrer d’un namespace à l’autre :

# ethtool -k eno1 | grep netns
netns-local: off [fixed]

Il en est de même pour l’interface Ethernet virtuelle utilisée par les conteneurs LXC vu que le principe consiste à créer les interfaces des deux extrémités du tunnel, puis à en migrer une dans le net_ns du conteneur :

$ ethtool -k vethI42T56 | grep netns
netns-local: off [fixed]

Pour un exemple d’interface Wi-Fi et d’interface loopback, la valeur « on » (drapeau positionné) indique au contraire qu’elles ne peuvent pas migrer :

# ethtool -k wlp6s0 | grep netns
netns-local: on [fixed]
# ethtool -k lo | grep netns
netns-local: on [fixed]

1.3 PROCFS

Le manuel mentionne que l’arborescence /proc/net est virtualisée dans le sens où son contenu reflète le net_ns du processus qui le consulte. Par exemple, le fichier /proc/net/dev contient des statistiques de trafic sur les interfaces actives :

$ cat /proc/net/dev
Inter-|    Receive                                          | Transmit
face  |    bytes      packets  errs  drop  fifo  frame [...]| bytes     packets  errs  drop fifo  colls [...]
  eno1: 96094960   87484    0     1     0     0            8637932   48743    0     0    0     0
    lo: 1109677    10218    0     0     0     0            1109677   10218    0     0    0     0
wlp6s0: 0          0        0     0     0     0            0         0        0     0    0     0

Si on lance un shell dans un nouveau net_ns avec notre outil shns, le même fichier ne montre par conséquent que l’interface loopback, car c’est la seule attachée par défaut à un net_ns non initial :

# ./shns net
New namespace 'net'
# cat /proc/net/dev
Inter-|   Receive                                    | Transmit
face  |   bytes    packets errs drop fifo frame [...]|bytes    packets errs drop fifo colls [...]
    lo: 0       0       0    0    0     0          0         0      0    0    0    0

1.4 SYSFS

Le système de fichiers virtuel SYSFS [5], généralement monté sur /sys, fournit la visibilité sur des structures de données du noyau (cf. man 5 sysfs). On y trouve notamment les interfaces réseau dans /sys/class/net :

$ ls -l /sys/class/net
total 0
drwxr-xr-x 2 root root 0 mars   23 09:30 ./
drwxr-xr-x 69 root root 0 mars  23 09:30 ../
lrwxrwxrwx 1 root root 0 mars   23 09:30 eno1 -> ../../devices/pci0000:00/0000:00:19.0/net/eno1/
lrwxrwxrwx 1 root root 0 mars   23 09:30 lo -> ../../devices/virtual/net/lo/
lrwxrwxrwx 1 root root 0 mars   23 10:06 lxcbr0 -> ../../devices/virtual/net/lxcbr0/
lrwxrwxrwx 1 root root 0 mars   23 13:13 vethEOEFBV -> ../../devices/virtual/net/vethEOEFBV/
lrwxrwxrwx 1 root root 0 mars   23 09:30 wlp6s0 -> ../../devices/pci0000:00/0000:00:1c.6/0000:06:00.0/net/wlp6s0/

Le manuel des net_ns (c.-à-d. man 7 network_namespaces) précise que ce répertoire est virtualisé de sorte à ne montrer que les interfaces réseau liées au net_ns du processus appelant. À l’aide de notre outil shns, effectuons l’essai en créant un shell dans un nouveau net_ns. Affichons le contenu de ce répertoire à partir de ce nouveau shell :

# ./shns net
New namespace 'net'
# ls -l /sys/class/net
total 0
lrwxrwxrwx 1 root root 0 mars   23 09:30 eno1 -> ../../devices/pci0000:00/0000:00:19.0/net/eno1
lrwxrwxrwx 1 root root 0 mars   23 09:30 lo -> ../../devices/virtual/net/lo
lrwxrwxrwx 1 root root 0 mars   23 10:06 lxcbr0 -> ../../devices/virtual/net/lxcbr0
lrwxrwxrwx 1 root root 0 mars   23 13:13 vethEOEFBV -> ../../devices/virtual/net/vethEOEFBV
lrwxrwxrwx 1 root root 0 mars   23 09:30 wlp6s0 -> ../../devices/pci0000:00/0000:00:1c.6/0000:06:00.0/net/wlp6s0

Le résultat ne correspond pas à ce qu’on attendait, car un nouveau net_ns n’a que l’interface loopback par défaut. Ici, on continue à voir les interfaces du net_ns initial. La documentation du noyau Documentation/filesystems/sysfs-tagging.txt explique qu’un système d’étiquettes a été mis en place dans SYSFS pour le rendre « namespace aware ». Sans entrer dans les détails, une même étiquette est attribuée à tout objet de SYSFS qui concerne un namespace donné. Un parcours dans l’arborescence n’affiche que les nœuds ayant la même étiquette que le point de montage courant. Il faut donc avoir un point de montage associé au net_ns courant pour voir les nœuds associés. Dans notre cas de figure, /sys est donc un point de montage associé au mount_ns initial. Un remontage de /sys est nécessaire pour avoir le reflet des objets réseau du noyau associé à notre nouveau net_ns. Il est aussi conseillé de positionner le type de propagation MS_SLAVE afin de ne pas polluer le mount_ns initial (sur les distributions Ubuntu, les points de montage sont en MS_SHARED par défaut). Relançons donc notre shell avec les paramètres net et mnt pour créer les nouveaux namespaces et effectuons les actions adéquates. Nous ne voyons plus que l’interface loopback par défaut de notre net_ns :

# ./shns net mnt
New namespace 'net'
New namespace 'mnt'
# ls -l /sys/class/net
total 0
lrwxrwxrwx 1 root root 0 mars   23 09:30 eno1 -> ../../devices/pci0000:00/0000:00:19.0/net/eno1
lrwxrwxrwx 1 root root 0 mars   23 09:30 lo -> ../../devices/virtual/net/lo
lrwxrwxrwx 1 root root 0 mars   23 10:06 lxcbr0 -> ../../devices/virtual/net/lxcbr0
lrwxrwxrwx 1 root root 0 mars   23 13:13 vethEOEFBV -> ../../devices/virtual/net/vethEOEFBV
lrwxrwxrwx 1 root root 0 mars   23 09:30 wlp6s0 -> ../../devices/pci0000:00/0000:00:1c.6/0000:06:00.0/net/wlp6s0
# mount --make-rslave /
# mount -t sysfs none /sys
# ls -l /sys/class/net
total 0
lrwxrwxrwx 1 root root 0 mars   23 20:18 lo -> ../../devices/virtual/net/lo

1.5 Terminaison

Un net_ns disparaît lorsqu’il n’y a plus de processus qui lui est associé. Si une interface est locale à son namespace (par exemple loopback), elle disparaît avec le namespace. Si l’interface a été migrée dans le namespace alors elle est réaffectée au namespace initial. Nous avons vu cela lors de l’étude [3] de la commande ip : la terminaison du conteneur LXC dans lequel nous avions migré l’interface eno1 (Ethernet) a provoqué une réapparition de cette dernière dans le net_ns initial.

1.6 SR-IOV

Nous avons vu que les interfaces réseau ne sont pas virtualisées dans la mesure où une même interface n’est pas partagée par plusieurs net_ns. Elles sont généralement dédiées au namespace initial et certaines peuvent migrer d’un net_ns à l’autre. Mais de nouveaux équipements ont fait leur apparition sur le marché pour notamment répondre à la spécification SR-IOV ou « Single-Root Input/Output Virtualization » [6]. Les fonctionnalités des interfaces PCI Express sont étendues en plusieurs interfaces virtuelles appelées « Physical Functions » (PF), elles-mêmes divisées en « Virtual Functions » (VF) pouvant être distribuées à différents net_ns et donc par extension à plusieurs machines virtuelles ou conteneurs comme on peut le voir dans l’illustration de la figure 2.

figure 02 sr iov-s

Fig. 2 : Principe du SR-IOV.

Le noyau Linux supporte SR-IOV [7]. Les fournisseurs de cartes réseau proposent de nombreuses solutions maintenant. Le document [8] présente une expérimentation réalisée par le CERN consistant à utiliser du matériel Intel conforme à la spécification SR-IOV dans des conteneurs LXC. L’étude se base non seulement sur des drivers dédiés pour le matériel, mais aussi sur la commande ip pour configurer les interfaces avec des options dédiées au SR-IOV :

$ man ip-link
[...]
ip link set { DEVICE | group GROUP }
[...]
        [ vf NUM [ mac LLADDR ]
                 [ VFVLAN-LIST ]
                 [ rate TXRATE ]
[...]
       vf NUM specify a Virtual Function device to be configured. The associated PF device must be     
       specified using the dev parameter.
 
                      mac LLADDRESS - change the station address for the specified VF. The vf
                      parameter must be specified.
 
                      vlan VLANID - change the assigned VLAN for the specified VF. When specified,
                      all traffic sent from the VF will be tagged with the specified VLAN ID.
                      Incoming traffic will be filtered for the specified VLAN ID, and will have
                      all VLAN tags stripped before being passed to the VF. Setting this parame-
                      ter to 0 disables VLAN tagging and filtering. The vf parameter must be
                      specified.
[...]

2. Le namespace PID

Le pid_ns isole les identifiants de processus permettant ainsi d’avoir des processus avec le même identifiant sur le système. Une entrée est dédiée à ce namespace dans le manuel en ligne de Linux : man 7 pid_namespaces.

Tout processus créé a un identifiant dans son namespace courant, mais aussi dans le pid_ns à partir duquel son namespace a été créé et ainsi de suite jusqu’au namespace initial. Par conséquent, dans ce dernier, nous voyons tous les processus du système. Dans chacun des namespaces, le PID d’un même processus peut être différent (c’est en général le cas !), mais il est bien entendu toujours unique.

La figure 3, confuse au premier abord, montre un système hypothétique où tournent 10 processus.

figure 03 nested pid ns-s

Fig. 3 : Hiérarchie de pid_ns.

Tous visibles du pid_ns initial, seuls les processus d’identifiants 1, 2, 4, 5 et 34 sont associés à ce namespace. Les processus 12, 13 et 56 sont attachés au pid_ns#1 avec respectivement les identifiants 1, 2 et 7. Les processus de PID 17 et 19 sont attachés au pid_ns#2 avec les identifiants respectifs 1 et 2 visibles dans le pid_ns#1 avec les PID respectifs 6 et 5. Voici un scénario possible pour arriver à cet état :

  1. Le processus #1 du pid_ns initial a appelé unshare(CLONE_NEWPID) pour créer le pid_ns#1 et a ensuite appelé fork() pour créer le processus #12. Ce dernier a hérité du nouveau pid_ns#1 et est devenu le premier processus d’identifiant 1.
  2. Le processus #1 du pid_ns#1 a créé le processus #2 (visible avec l’identifiant 13 dans le pid_ns initial) qui a appelé à son tour clone(CLONE_NEWPID) pour créer le pid_ns#2 et le processus #6 qui devient le processus #1 dans le pid_ns#2 et est visible avec le PID 17 dans le pid_ns initial.
  3. Le processus #1 dans le pid_ns#1 a appelé setns() pour entrer dans le pid_ns du processus #6 (c.-à-d. pid_ns#2) et a appelé fork() pour créer le processus #5 (d’identifiant 15 dans le namespace initial) qui a hérité du pid_ns#2 dans lequel il est visible avec l’identifiant 2. Ce dernier a l’identifiant 19 dans le namespace initial.
  4. Le processus #34 du pid_ns initial a appelé setns() pour entrer dans le pid_ns du processus #13 (c.-à-d. pid_ns#1) et a appelé fork() pour créer le processus #56 qui a hérité du pid_ns#1 avec l’identifiant 7.
  5. Quand le père d’un processus se situe en dehors du pid_ns auquel il est associé (c.-à-d. premier processus d’un pid_ns suite à l’appel clone() ou unshare() ou migration via l’appel setns()), getppid() retourne 0. C’est le cas pour les processus 12 et 17 (premiers processus de leur pid_ns suite à l’appel unshare() de leur père) et pour les processus 19 et 56 (arrivés dans leur pid_ns suite à l’appel setns() de leur père).

2.1 Création

À la création, le premier processus du pid_ns a l’identifiant numéro 1 et il a le rôle de faucheur (« reaper » en anglais) des processus orphelins dans le namespace. Fonction essentielle pour les conteneurs destinés à faire tourner des distributions Linux où le premier processus (init) a justement ce rôle [9].

Pour étayer le propos, notre programme pid affiche l’identifiant du processus courant ainsi que celui de son père :

int main(void)
{
  printf("PID#%d, PPID#%d\n", getpid(), getppid());
  return 0;
} // main

Si nous lançons notre programme shns pour exécuter un sous-shell dans un nouveau pid_ns, nous constatons bien qu’il a l’identifiant 1 :

# PS1="SHNS# " ./shns pid
New namespace 'pid'
SHNS# echo $$
1

Le lancement de pid dans le sous-shell montre que son identifiant est 2 (deuxième processus créé et que le père est le sous-shell d’identifiant 1 à partir duquel il a été lancé) :

SHNS# ./pid
PID#2, PPID#1

Pour le premier processus d’un pid_ns ainsi que tout processus entrant dans un pid_ns, le processus père est en dehors du namespace, l’identifiant vu du pid_ns est 0 (on l’a vu lors de la description de la figure 3). Pour s’en convaincre, exécutons une nouvelle fois pid, mais par l’intermédiaire de execns qui rappelons-le, appelle setns() pour entrer dans les namespaces d’un processus donné, puis exécute la commande passée en paramètre. D’abord, on repère le PID du sous-shell dans le namespace initial :

# pidof shns
19625
# ps -ef | grep 19625 # Look for child of shns
root     19625 19624 0 15:26 pts/1    00:00:00 ./shns pid
root     19626 19625 0 15:26 pts/1    00:00:00 /bin/sh

Puis toujours du namespace initial, on lance le programme pid dans le pid_ns du sous-shell :

# ./execns 19626:pid ./pid
Moved into namespace pid
PID#3, PPID#0
program's status: 0 (0x0)

On vérifie bien que pid s’exécute avec l’identifiant 3, car seulement deux processus ont été lancés avant lui dans le pid_ns (le sous-shell et la commande pid). Le père a l’identifiant 0, car execns tourne dans le pid_ns initial donc extérieur à celui du sous-shell.

Avec le drapeau CLONE_NEWPID, clone() permet de créer un processus dans un nouveau pid_ns. Les appels unshare() et setns() permettent respectivement de créer un nouveau namespace et d’entrer dans un namespace existant pour l’appelant. Cependant, il y a une spécificité quand il s’agit d’un pid_ns : seuls les processus fils du processus courant changent de pid_ns. Cette manière de faire, surprenante de prime abord, préserve le fonctionnement de nombreuses applications et librairies qui subitement se retrouveraient avec un retour différent de l’appel système getpid(). Par exemple, lorsque le résultat de ce dernier sert à identifier des contextes créés avant le changement de namespace, des incohérences et des plantages pourraient apparaître. L’implémentation choisie privilégie donc la compatibilité ascendante.

Exécutons un shell dans un nouveau pid_ns à l’aide de l’option -p de l’utilitaire unshare. Rappelons que unshare, dans sa plus simple expression, appelle unshare(CLONE_NEWPID) et exécute (sans créer de processus fils !) la commande demandée. Ici, le shell exécuté est donc toujours dans le pid_ns initial (d’où l’identifiant différent de 1 affiché par la built-in echo du shell). Par contre, si l’on exécute un processus fils dans ce shell (le programme pid), on constate que ce dernier a l’identifiant 1 et celui de son père est 0, car le sous-shell fait d’abord un fork() qui a pour conséquence de mettre le processus fils dans le nouveau pid_ns avec l’identifiant 1, suivi d’un exec() pour exécuter la commande :

# unshare -p /bin/sh
# echo $$
20160
# ./pid    # A fork/exec is done here ==> program is run in the new pid_ns
PID#1, PPID#0

Nous pouvons faire la même chose avec l’option -f (c.-à-d. --fork !) de unshare qui provoque l’exécution du programme demandé dans un processus fils juste après l’appel système à unshare(). Le programme ainsi lancé est bien le premier dans un nouveau pid_ns (PID 1 et PPID 0 !) :

# unshare -p -f ./pid
PID#1, PPID#0

Si un processus peut entrer dans un pid_ns existant via setns(), le chemin inverse est impossible. On peut aller dans le sens descendant de la hiérarchie, mais jamais dans le sens montant. D’ailleurs, la hiérarchie des pid_ns fait qu’à un niveau donné, les processus ne voient que les identifiants des processus situés dans les pid_ns fils. Testons le programme enter_pidns_up suivant qui ouvre le lien symbolique du pid_ns courant, effectue un unshare(CLONE_NEWPID) avant de créer un processus fils qui va d’une part se retrouver dans un nouveau pid_ns et d’autre part hériter du descripteur de fichier sur la cible du lien symbolique ouvert par son père. Voyons ce que provoque un appel à setns() par le fils sur ce descripteur de fichier (en d’autres termes, une tentative de faire ensuite entrer ses propres fils dans le pid_ns de son père) :

int main(void)
{
[...]
  // Build the pathname of the pid_ns
  snprintf(path, sizeof(path), "/proc/%d/ns/pid", getpid());
 
  // Open the namespace symbolic link
  fd = open(path, O_RDONLY);
 
  // Create a new pid_ns for child processes
  rc = unshare(CLONE_NEWPID);
[...]
  // Fork a child process
  child = fork();
  if (!child) {
 
    // Child process
 
    // Enter into father's pid_ns
    rc = setns(fd, 0);
 
    if (rc != 0) {
      ERR("setns(father's pid_ns): '%m' (%d)\n", errno);
      exit(1);
    }
 
    exit(0);
 
  } else {
 
    // Father process
 
    int status;
 
    // Wait for the end of the child process
    rc = waitpid(child, &status, 0);
 
    if (rc < 0) {
      fprintf(stderr, "waitpid(%d): %m (%d)", child, errno);
      return 1;
    }
 
    printf("program's status: %d\n", WEXITSTATUS(status));
  }
 
  return 0;
} // main

L’exécution de ce programme aboutit à un retour erreur EINVAL de la part de setns() appelé dans le fils. Ce qui correspond bien à la description dans le manuel : « EINVAL The caller tried to join an ancestor (parent, grandparent, and so on) PID namespace » :

# ./enter_pidns_up
ERROR@main#56: setns(father's pid_ns): 'Invalid argument' (22)
program's status: 1

2.2 PROCFS

Alors que nous connaissons le fichier /proc/<pid>/ns/pid, lien symbolique sur le pid_ns du processus d’identifiant pid, il y a un autre fichier qui attire notre attention dans le même répertoire. Il s’agit de /proc/<pid>/ns/pid_for_children. C’est aussi un lien symbolique pointant sur le pid_ns du processus d’identifiant pid. Il intervient lorsque le processus crée un nouveau namespace ou entre dans le namespace d’un autre processus. Nous avons vu que cela ne concernera en réalité que les fils de ce processus. Dans le laps de temps qui sépare le setns()/unshare() et la création du processus fils, ce lien symbolique est vide, puis lorsque le premier processus est créé, le lien symbolique référence son pid_ns.

Lançons un shell via la commande unshare en demandant un nouveau pid_ns, mais sans fork() :

# PS1="SH# " unshare -p /bin/sh
SH# echo $$
8206

À ce moment-là, nous sommes dans l’état où un unshare(CLONE_NEWPID) a été appelé et un exec(/bin/sh) a été fait sans création de processus fils. Donc le shell ainsi créé est dans le pid_ns de son père. Listons le contenu de son répertoire à partir des namespace initiaux (c.-à-d. à partir d’un autre terminal) :

# ls -l /proc/8206/ns
ls: cannot read symbolic link '/proc/8206/ns/pid_for_children': No such file or directory
[...]
rwxrwxrwx 1 root root 0 févr. 7 14:11 pid -> 'pid:[4026531836]'
lrwxrwxrwx 1 root root 0 févr. 7 14:11 pid_for_children
[...]

La commande ls affiche une erreur, car comme nous sommes dans le laps de temps où un processus a fait un unshare(CLONE_NEWPID), mais avant de créer un fils qui entrera dans le nouveau pid_ns, la cible de son lien symbolique pid_for_children est inexistante.

Du côté du sous-shell, lançons un autre shell. Cela va provoquer un fork() qui mettra le fils dans le nouveau pid_ns (son identifiant est 1) suivi d’un exec() pour le nouveau shell :

SH# PS1="SUB-SH# " /bin/sh
SUB-SH# echo $$
1

Du côté namespaces initiaux, la commande ls montre que pid_for_children pointe désormais sur le namespace du fils :

# ls -l /proc/8206/ns
[...]
lrwxrwxrwx 1 root root 0 févr. 7 14:11 pid -> 'pid:[4026531836]'
lrwxrwxrwx 1 root root 0 févr. 7 14:11 pid_for_children -> 'pid:[4026532880]'
[...]

Terminons le processus fils :

SUB-SH# exit
SH# echo $$
8206      # Back into the father shell

Du côté namespaces initiaux, la commande ls montre que pid_for_children pointe toujours sur le nouveau namespace, alors que ce dernier n’existe plus suite à la terminaison du sous-shell fils :

# ls -l /proc/8206/ns
[...]
lrwxrwxrwx 1 root root 0 févr. 7 14:11 pid -> 'pid:[4026531836]'
lrwxrwxrwx 1 root root 0 févr. 7 14:11 pid_for_children -> 'pid:[4026532880]'
[...]

Il n’est plus possible de lancer un nouveau processus dans le sous-shell :

SH# /bin/sh
/bin/sh: 5: Cannot fork

Le résumé de tout ceci est qu’à partir du moment où un processus appelle unshare(CLONE_NEWPID), il pourra créer autant de processus fils qu’il le désire, tant que le premier processus fils n’est pas terminé. Tous les processus ainsi créés s’exécuteront bien sûr dans le même pid_ns que le premier processus. À partir du moment où ce dernier est fini, tous les processus en cours dans le pid_ns sont tués et il n’est plus possible pour le processus père de créer de nouveaux processus (le fork() retourne une erreur ENOMEM comme ci-dessus). Le manuel de fork() est d’ailleurs explicite sur le sujet :

« ENOMEM An attempt was made to create a child process in a PID namespace whose "init" process has terminated ».

Le fichier /proc/sys/kernel/ns_last_pid contient le dernier identifiant de processus alloué dans le pid_ns courant. Avec les droits suffisants (c.-à-d. CAP_SYS_ADMIN), il est même possible de le modifier afin de contrôler la valeur de l’identifiant du prochain processus créé.

Abordons maintenant l’arborescence /proc/<pid> qui peut prêter à confusion lorsqu’on manipule les namespaces. Dans un nouveau pid_ns, nous savons maintenant que la numérotation des processus recommence à 1. Dans l’exemple suivant, lançons un sous-shell dans un nouveau pid_ns. On utilise l’option -f (c.-à-d. --fork) pour s’assurer que le sous-shell s’exécute lui-même dans le nouveau pid_ns et pas seulement ses fils (son identifiant est donc 1 !) :

# PS1="SH# " unshare -p -f /bin/sh
SH# echo $$
1

Pourtant lorsqu’on lance la commande ps, l’identifiant du shell est celui du pid_ns initial (c.-à-d. 445) :

SH# ps
  PID TTY          TIME CMD
  444 pts/0    00:00:00 unshare
  445 pts/0    00:00:00 sh
  459 pts/0    00:00:00 ps

Cette incohérence est due au fait que des commandes comme ps vont chercher leurs informations dans l’arborescence /proc/<pid> pour tous les processus affichés. Or, le manuel précise que ces arborescences sont l’image des processus dans le pid_ns qui a effectué le montage de /proc. Comme ce dernier a été monté dans le pid_ns initial, cela explique que ps affiche des identifiants correspondant au namespace initial. Dans ce cas de figure, un processus peut donc lire le lien symbolique /proc/self pour connaître son PID dans le pid_ns parent.

Les applications comme les conteneurs LXC remontent /proc à partir du nouveau pid_ns. Mais il faut aussi utiliser un nouveau mount_ns pour ne pas perturber l’environnement initial (à partir duquel le conteneur est créé). Avec la commande unshare, c’est exactement ce que font les options de -m (nouveau mount_ns) et --mount-proc (remontage de /proc dans le nouveau mount_ns). En d’autres termes, en relançant le shell précédent comme suit, la commande ps est cohérente :

# PS1="SH# " unshare -p -f -m --mount-proc /bin/sh
SH# echo $$
1
SH# ps
  PID TTY          TIME CMD
    1 pts/0    00:00:00 sh
    2 pts/0    00:00:00 ps

En interne, unshare a effectué les actions suivantes :

  1. Création de nouveaux mount_ns et pid_ns (options -m et -p) : unshare(CLONE_NEWNS|CLONE_NEWPID).
  2. Création d’un processus fils qui sera effectivement dans le nouveau pid_ns avec l’identifiant numéro 1 (option -f) : fork().
  3. Le processus fils remonte toute l’arborescence / de manière récursive avec le type de propagation privé afin de ne pas répercuter les futurs montages/démontages dans le mount_ns initial : mount("none", "/", NULL, MS_REC|MS_PRIVATE, NULL).
  4. Le processus fils remonte /proc : mount("proc", "/proc", "proc", MS_NOSUID|MS_NODEV|MS_NOEXEC, NULL).
  5. Le processus fils exécute le shell : execve("/bin/sh", ...).

2.3 Conversion d’identifiant

Les pid_ns sont hiérarchiques. Un processus a un identifiant différent du pid_ns courant jusqu’au pid_ns initial. À un niveau donné de la hiérarchie, on connaît l’identifiant d’un processus dans le pid_ns courant, mais on ne voit pas l’identifiant dans les pid_ns descendants ou montants. Rien d’anormal à tout cela, c’est inhérent à l’isolation. Cependant, une fonctionnalité de Linux laconiquement présentée dans la rubrique « Miscellaneous » du manuel des pid_ns indique :

« When a process ID is passed over a UNIX domain socket to a process in a different PID namespace (see the description of SCM_CREDENTIALS in unix(7)), it is translated into the corresponding PID value in the receiving process's PID namespace ».

La même fonctionnalité pour les identifiants d’utilisateur et groupe est évoquée tout aussi discrètement dans le manuel des user_ns, comme on l’a déjà souligné dans l’article dédié [10].

En clair, les appels système sendmsg() et recvmsg() donnent la possibilité d’accompagner les messages de données auxiliaires (c.-à-d. « ancillary data » en anglais). Ces données sont typées. SCM_CREDENTIALS est le type qui nous intéresse ici. Le format du message associé est la structure ucred :

struct ucred {
  pid_t pid;    /* Process ID of the sending process */
  uid_t uid;    /* User ID of the sending process */
  gid_t gid;    /* Group ID of the sending process */
};

Les informations d’identification (PID, UID et GID), stockées dans les champs de cette structure par le processus émetteur du message, sont vérifiées par le noyau. Seul un processus privilégié peut mettre des informations qui ne lui correspondent pas. L’envoi de ce message d’un processus à un autre provoque au sein du noyau une conversion du champ pid, de sa valeur dans le pid_ns du processus émetteur à sa valeur dans le pid_ns du processus récepteur. Il en est de même pour les champs uid et gid, lors du transfert d’un user_ns à un autre.

Le programme getnsids ci-dessous met en application cette fonctionnalité en traduisant les identifiants de processus, de groupe ou d’utilisateur vu du pid_ns et du user_ns courants dans les namespaces auxquels est associé un processus. Le programme reçoit en paramètre l’identifiant du processus cible vu du pid_ns courant (option -t pour « target ») et les identifiants (PID, UID et GID respectivement avec les options -p, -u et -g) vus des namespaces courants à convertir en identifiants vus des namespaces cibles. Le synopsis est le suivant :

Usage: getnsids -t pid [-p pid] [-u uid] [-g gid]

Il crée une socket UNIX, entre dans le pid_ns (si l’option -p est passée) et le user_ns (si l’option -u ou -g est passée) du processus cible (option -t) via l’appel à setns(). Puis il crée un processus fils avec le service fork(), qui s’exécute dans le pid_ns et/ou le user_ns cible (par héritage).

La fonction main() analyse les options sur la ligne de commande avec la fonction getopt() et appelle la fonction interne getnsids() qui effectue la conversion des identifiants pour enfin afficher le résultat à l’écran :

  while ((opt = getopt(ac, av, ":t:p:u:g:")) != EOF) {
[...]
  rc = getnsids(target, id_mask, &tpid, &tuid, &tgid);
  if (rc != -1) {
 
    // Display the translated ids
    if (id_mask & ID_MASK_PID) {
      printf("pid %d --> %d\n", pid, tpid);
    }
 
    if (id_mask & ID_MASK_UID) {
      printf("uid %d --> %d\n", uid, tuid);
    }
 
    if (id_mask & ID_MASK_GID) {
      printf("gid %d --> %d\n", gid, tgid);

La fonction getnsids() crée la socket UNIX dans /tmp :

  // Socket pathname
  snprintf(sk_path, sizeof(sk_path), "/tmp/getnsids.%d", getpid());
[...]
  // Open the server socket
  sd = open_socket(sk_path, 1);

Le processus s’associe au pid_ns et/user_ns du processus cible en fonction de ce qui a été demandé :

   // pid_ns file of the target process
  if ((id_mask & ID_MASK_PID) &&
      !cmp_ns(getpid(), target, "pid")) {
 
    rc = snprintf(ns_path, sizeof(ns_path), "/proc/%d/ns/pid", target);
[...]
    // Open the pid_ns of the target process
    fd = open(ns_path, O_RDONLY);
[...]
    // Enter into the pid_ns of the target process
    rc = setns(fd, CLONE_NEWPID);
[...]
  // User_ns of the target process
  if ((id_mask & (ID_MASK_UID | ID_MASK_GID)) &&
      !cmp_ns(getpid(), target, "user")) {
 
    rc = snprintf(ns_path, sizeof(ns_path), "/proc/%d/ns/user", target);
[...]
    // Open the user_ns of the target process
    fd = open(ns_path, O_RDONLY);
[...]
    // Enter into the user_ns of the target process
    rc = setns(fd, CLONE_NEWUSER);

Ensuite, il crée un processus fils :

  // Fork a process
  child = fork();

Le fils exécute la fonction child_getids() qui se met en attente de connexion sur la socket ouverte par son père (select()), accepte la demande de connexion du père (accept()), positionne l’option SO_PASSCRED avec setsockopt() sur la socket allouée (étape préalable pour recevoir des données auxiliaires de type SCM_CREDENTIALS conformément aux indications de man 7 unix), attend le message avec le PID, UID et GID reçus dans les données auxiliaires et convertis par le noyau aux valeurs dans le pid_ns et/ou user_ns cible, pour ensuite envoyer le résultat de cette conversion au processus père dans les données normales d’un message :

static int child_getids(int srv_sd)
{
[...]
  // Wait for the connection on the server socket
[...]
  rc = select(nfds, &fdset, 0, 0, 0);
  switch(rc) {
[...]
    // Connection request ?
    default: {
 
      if (FD_ISSET(srv_sd, &fdset)) {
[...]
        // Accept the connection
        laddr = sizeof(addr);
        sd1 = accept(srv_sd, (struct sockaddr *)&addr, &laddr);
[...]
        // According to "man 7 unix", to receive a struct ucred message the
        // SO_PASSCRED option must be enabled on the socket
        opt = 1;
        rc = setsockopt(sd1, SOL_SOCKET, SO_PASSCRED, &opt, sizeof(opt));
[...]
        // Receive the credentials (with the translated target ids)
        
[...]
        // Send back the translated ids to the father in a data message
        rc = write(sd1, &ucreds, sizeof(ucreds));
[...]
      } // End if a bit in fdset
[...]
} // child_getids

Après le fork(), le père se connecte au fils via la socket sur laquelle le fils est en attente (connect()), envoie un message avec le PID, l’UID et le GID cible dans les données auxiliaires (SCM_CREDENTIALS), attend la réponse du fils avec le PID, l’UID et le GID convertis dans les données normales du message et attend la fin du processus fils (waitpid()) :

    // Father process
    default: {
[...]
      // Connect to the server socket
      sd1 = open_socket(sk_path, 0);
[...]
      // Send the credentials with the ids
      ucreds.pid = (id_mask & ID_MASK_PID ? *pid : getpid());
      ucreds.uid = (id_mask & ID_MASK_UID ? *uid : getuid());
      ucreds.gid = (id_mask & ID_MASK_GID ? *gid : getgid());
      rc = send_ids(sd1, &ucreds);
[...]
      // Wait for the translated ids in the data of the message
      rc = read(sd1, &ucreds, sizeof(struct ucred));
[...]
      // Wait for the end of the child the process
      rc = waitpid(child, &status, 0);

La figure 4 résume le fonctionnement du programme lorsqu’on l’appelle avec l’option -p pour convertir un PID.

figure 04 getnsids-s

Fig. 4 : Fonctionnement de getnsids.

En guise d’exemple, lançons un conteneur LXC et affichons le PID de son processus init dans le pid_ns initial (c.-à-d. côté hôte) avec notre outil lxc-pid :

# ./lxc-start2 bbox
# lxc-ls -f
NAME    STATE   AUTOSTART GROUPS IPV4       IPV6
bbox    RUNNING 0         -      10.0.3.203 -
# ./lxc-pid bbox
12281

Cet identifiant est bien entendu l’identifiant du premier processus (identifiant numéro 1) dans le pid_ns du conteneur. Vérifions cela avec notre nouvel outil :

# ./getnsids -t 12281 -p 12281
pid 12281 --> 1

Un conteneur LXC de type busybox fait aussi tourner d’autres daemons [11] comme udhcpc. Récupérons son identifiant côté hôte et utilisons l’outil pour voir sa valeur dans le conteneur :

# pidof udhcpc
12312
# ./getnsids -t 12281 -p 12312
pid 12312 --> 14

Nous pouvons vérifier le résultat des conversions avec la commande ps côté conteneur :

# lxc-console -n bbox -t 0
[...]
bbox# pidof udhcpc
14

Données auxiliaires dans LXC

LXC a recours aux données auxiliaires suivantes :

  • le type SCM_CREDENTIALS est utilisé par toute commande utilisateur interagissant avec les conteneurs pour vérifier l’identité de l’appelant ;
  • le type SCM_RIGHTS est utilisé lors du démarrage des conteneurs (dans lxc-start et lxc-execute) et par certaines commandes comme lxc-console pour le partage de descripteurs de fichiers (p. ex. terminaux) entre le conteneur et le système hôte.

2.4 Terminaison

2.4.1 Le reaper

Le processus init (c.-à-d. premier processus) d’un pid_ns est le reaper [9] dans le sens où tous les processus orphelins du namespace lui sont reparentés. Pour information, dans le noyau, le champ child_reaper de la structure pid_namespace pointe sur le descripteur task_struct de ce processus [2].

Notre programme reaper crée un processus fils qui exécute un shell, tandis que le programme principal (le processus père) capture les signaux passés en paramètre et se met en attente infinie dans une boucle qui appelle pause(). Le handler de signal sig_chld() gère le signal SIGCHLD en appelant waitpid() pour récupérer le PID et le statut de terminaison du processus terminé. Si c’est le processus shell, alors le programme se termine aussi. Pour les autres signaux, le handler affiche le type de signal et le PID du processus émetteur :

static void sig_hdl(int sig, siginfo_t *info, void *p)
{
[...]
  switch(sig) {
 
    case SIGCHLD: {
 
      // Reap all the childs
      while(1) {
 
        pid = waitpid(-1, &status, WNOHANG);
[...]
        // If it is our shell, exit...
        if (pid_sh == pid) {
          printf("%s: Exiting as the main shell#%d exited with status 0x%x (%d)\n",
                 REAPER_NAME,
                 pid,
                 status, status);
 
          exit(0);
 
        } else {
 
          printf("%s: Process#%d died with status 0x%x (%d)\n",
                 REAPER_NAME,
                 pid,
                 status, status);
 
        }
      } // End while
    }
    break;
 
    default: {
      printf("Received signal %s (%d) from process#%d\n", strsignal(sig), sig, info->si_pid);
    }
    break;
 
  } // End switch
 
} // sig_hdl
 
int main(int ac, char *av[])
{
[...]
  for (i = 1; av[i]; i ++) {
    sig = sig_str2type(av[i]);
    if (sig > 0) {
      sigemptyset(&sigset);
      action.sa_sigaction = sig_hdl;
      action.sa_mask = sigset;
      action.sa_flags = SA_SIGINFO;
      printf("Capturing signal '%s' (%d)\n", av[i], sig);
      rc = sigaction(sig, &action, NULL);
[...]
  } // End for
 
  printf("%s#%d: forking a shell...\n", REAPER_NAME, getpid());
 
  // Create a child process for the shell
  pid_sh = fork();
 
  switch(pid_sh) {
[...]
    // Child
    case 0: {
      char *avs[2] = {
        "/bin/sh",
        NULL
      };
[...]
      execve(avs[0], avs, environ);
      _exit(1);
    }
    break;
 
    // Father
    default : {
 
      // Main loop
      while(1) {
 
        pause();
 
      } // End while
 
    }
    break;
 
  } // End switch
[...]
} // main

Si on lance ce programme dans un nouveau pid_ns, il devient le processus init du namespace tandis que son fils qui exécute le shell est le processus numéro 2 :

# PS1='REAPER_SH# ' unshare -p -f ./reaper CHLD
Capturing signal 'CHLD' (17)
Reaper#1: forking a shell...
REAPER_SH# echo $PPID
1
REAPER_SH# echo $$
2
REAPER_SH#

Notre programme orphan lance autant de processus fils que le nombre passé en paramètre, puis se termine. Comme il disparaît avant ses fils, ces derniers deviennent orphelins et sont donc rattachés au processus init. Chaque fils s’endort pendant un nombre aléatoire de secondes avant de se terminer.

int main(int ac, char *av[])
{
[...]
  nb = atoi(av[1]);
[...]
  for (i = 0; i < nb; i ++) {
    t = rand() % 20;
    if (0 == fork()) {
      printf("Process#%d: Sleeping %d seconds...\n", getpid(), t);
      sleep(t);
      break;
    }
  } // End fork
[...]
} // main

Si on lance ce programme dans le shell du reaper ci-dessus, les orphelins sont reparentés au processus reaper dans lequel le handler se déclenche à chaque terminaison de processus :

REAPER_SH# ./orphan 5
Process#4: Sleeping 4 seconds...
Process#5: Sleeping 0 seconds...
Process#6: Sleeping 18 seconds...
Process#7: Sleeping 14 seconds...
Process#8: Sleeping 4 seconds...
Reaper: Process#5 died with status 0x0 (0)
REAPER_SH# Reaper: Process#4 died with status 0x0 (0)
Reaper: Process#8 died with status 0x0 (0)
Reaper: Process#7 died with status 0x0 (0)
Reaper: Process#6 died with status 0x0 (0)

Si l’on termine le shell du reaper, ce dernier se termine aussi, car c’est prévu dans le handler. Comme tout namespace, le pid_ns associé disparaît lorsqu’il n’y a plus aucun processus qui lui est associé.

REAPER_SH# exit
/bin/sh: 4: Cannot set tty process group (No such process)
Reaper: Exiting as the main shell#2 exited with status 0x0 (0)

2.4.2 Signaux de terminaison

Le pid_ns disparaît à partir du moment où son premier processus disparaît et cela entraîne aussi la terminaison de tout autre processus s’exécutant dans le même pid_ns (le noyau leur envoie le signal SIGKILL). Illustrons cela par un autre exemple ludique. Nous savons que le lancement de la commande unshare sans l’option -f exécute la commande demandée dans le processus courant. Un appel à unshare() est fait, mais comme aucun processus fils n’est créé, la commande reste dans le pid_ns initial. Quand la commande ainsi exécutée est un shell, toute commande lancée par ce dernier entraîne un fork()/exec() d’un fils dans le nouveau pid_ns. Mais comme on l’a déjà vu, après la première commande (d’identifiant 1), il n’est plus possible d’exécuter une nouvelle commande, car la fin du premier processus d’un pid_ns entraîne sa terminaison. Par contre, tant que le premier processus tourne, il est possible d’exécuter d’autres processus. Donc si nous utilisons la commande sleep en arrière-plan comme première commande, il sera possible d’exécuter des commandes jusqu’à échéance de la temporisation :

# unshare -p /bin/sh
# sleep 60 &       # The first command will end after 60 seconds
# date
Mon Feb 10 09:21:51 CET 2020
# uname
Linux
[...]
# After 60 seconds, the first process (sleep) ends
# date
/bin/sh: 6: Cannot fork

Le processus d’identifiant 1 dans un pid_ns ne reçoit que les signaux pour lesquels il a installé un gestionnaire. Cela s’applique à tout processus émetteur, privilégié ou non, qu’il soit dans le pid_ns courant ou les pid_ns parents. Cela protège de la destruction accidentelle des pid_ns. Toutefois, pour ne pas rendre impossible la destruction des pid_ns, un processus situé dans un pid_ns parent pourra quand même envoyer le signal SIGKILL ou SIGSTOP au processus init afin de provoquer respectivement une terminaison ou un arrêt du processus cible. Dans la structure siginfo_t passée au gestionnaire de signal, le champ si_pid est renseigné avec l’identifiant du processus émetteur du signal. Si ce dernier réside dans un autre pid_ns, la valeur est mise à 0. Illustrons le propos avec notre programme reaper. Lançons-le comme premier processus dans un pid_ns. Son gestionnaire de signal est déclenché uniquement par les signaux qu’il a capturés, les autres sont ignorés (même SIGKILL !) :

# PS1='REAPER_SH# ' unshare -p -f ./reaper CHLD HUP TERM INT USR1
Capturing signal 'CHLD' (17)
Capturing signal 'HUP' (1)
Capturing signal 'TERM' (15)
Capturing signal 'INT' (2)
Capturing signal 'USR1' (10)
Reaper#1: forking a shell...
REAPER_SH# kill -HUP 1         
Received signal Hangup (1) from process#2
REAPER_SH# kill -USR1 1
Received signal User defined signal 1 (10) from process#2
REAPER_SH# kill -USR2 1 # Signal ignored (not captured)
REAPER_SH# kill -KILL 1 # Signal ignored (not captured)

Quand on envoie un signal de la part d’un processus extérieur au pid_ns fils, le comportement est le même, sauf pour l’identifiant du processus émetteur positionné à 0 dans le champ si_pid et le traitement de SIGKILL qui entraîne la terminaison du processus reaper et par conséquent du pid_ns fils. Dans un autre terminal, repérons le PID du reaper dans le pid_ns père. Et envoyons-lui le signal SIGTERM, puis le signal SIGKILL :

# pidof reaper
26379
# kill -TERM 26379
# kill -KILL 26379

De retour dans le terminal du reaper, on constate que l'identifiant du processus émetteur du signal SIGTERM est mis à 0 dans le champ si_pid et que le signal SIGKILL provoque bien la terminaison du reaper et de tous les processus du pid_ns fils entraînant ainsi sa disparition.

REAPER_SH#
Received signal Terminated (15) from process#0
REAPER_SH# Killed

Pour être complet, précisons aussi que le signal SIGSTOP envoyé par un processus extérieur au pid_ns provoque l’arrêt du processus init et le champ si_pid est aussi positionné à 0.

2.4.3 Reboot

L’appel système reboot() a été adapté aux namespaces (cf. man 2 reboot). Lorsqu’il est invoqué à partir d’un pid_ns autre que l’initial, son comportement est le suivant en fonction du paramètre passé en argument :

  • RB_AUTOBOOT termine le premier processus du pid_ns avec un statut de terminaison contenant l’identifiant du signal SIGHUP ;
  • RB_POWER_OFF ou RB_HALT_SYSTEM termine le premier processus du pid_ns avec un statut de terminaison contenant l’identifiant du signal SIGINT ;
  • toute autre valeur aboutit au code erreur EINVAL.

Notre programme rebootns reçoit en paramètre le type à passer à l’appel système reboot() :

# Usage: rebootns reboot_param
 
reboot param is one of:
 
        CAD_OFF
[...]
        AUTOBOOT
        SW_SUSPEND

Lançons de nouveau le reaper en tant que premier processus dans un nouveau pid_ns et utilisons notre programme rebootns pour le terminer. Le premier essai avec le paramètre SW_SUSPEND aboutit à l’erreur EINVAL conformément au manuel. Le second essai avec AUTOBOOT envoie bien le signal SIGHUP au processus init :

# PS1='REAPER_SH# ' unshare -p -f ./reaper TERM
Capturing signal 'TERM' (15)
Reaper#1: forking a shell...
REAPER_SH# ./rebootns SW_SUSPEND
reboot(SW_SUSPEND): 'Invalid argument' (22)
REAPER_SH# ./rebootns AUTOBOOT
Hangup

Conclusion

Les net_ns et pid_ns nous ont encore montré de nombreuses fonctionnalités. Et malgré cela, nous avons pu voir, notamment en évoquant l’état de l’art en matière de virtualisation des interfaces réseau, que le terrain est encore propice à des évolutions futures. D’ailleurs, les besoins sont tels que de nouveaux namespaces vont bientôt arriver et certains sont à l’étude, comme nous le verrons dans le prochain et dernier opus de cette série consacrée aux namespaces de Linux.

Références

[1] R. KOUCHA, « Les namespaces ou l’art de se démultiplier », GNU/Linux Magazine n°239, juillet/août 2020 : https://connect.ed-diamond.com/GNU-Linux-Magazine/GLMF-239/Les-namespaces-ou-l-art-de-se-demultiplier

[2] R. KOUCHA, « Les structures de données des namespaces dans le noyau », GNU/Linux Magazine n°243, décembre 2020 : https://connect.ed-diamond.com/GNU-Linux-Magazine/GLMF-243/les-structures-de-donnees-des-namespaces-dans-le-noyau

[3] R. KOUCHA, « Les utilitaires relatifs aux namespaces », GNU/Linux Magazine n°240, septembre 2020 : https://connect.ed-diamond.com/GNU-Linux-Magazine/GLMF-240/Les-utilitaires-relatifs-aux-namespaces

[4] R. ROSEN, « Linux Kernel Networking: Implementation and Theory », Apress, 2014.

[5] Le système de fichiers sysfs : https://fr.wikipedia.org/wiki/Sysfs

[6] Single-root input/output virtualization :
https://en.wikipedia.org/wiki/Single-root_input/output_virtualization

[7] PCI Express I/O Virtualization Howto : https://www.kernel.org/doc/html/latest/PCI/pci-iov-howto.html

[8] SR-IOV with Linux Containers : https://software.intel.com/en-us/articles/single-root-inputoutput-virtualization-sr-iov-with-linux-containers

[9] Zombie process : https://en.wikipedia.org/wiki/Zombie_process

[10] R. KOUCHA, « Identité multiple avec le namespace user », GNU/Linux Magazine n°246, mars 2021 : https://connect.ed-diamond.com/GNU-Linux-Magazine/GLMF-246/Identite-multiple-avec-le-namespace-user

[11] Daemons : https://en.wikipedia.org/wiki/Daemon_(computing)



Article rédigé par

Abonnez-vous maintenant

et profitez de tous les contenus en illimité

Je découvre les offres

Déjà abonné ? Connectez-vous