Conteneurs Linux dans quelques lignes de code

Dans le prolongement de l' article précédent sur KVM, nous publions une nouvelle traduction et comprenons le fonctionnement des conteneurs en utilisant l'exemple de l'exécution d'une image Docker busybox.


Cet article sur les conteneurs est une continuation de l' article précédent sur KVM. J'aimerais vous montrer exactement comment les conteneurs fonctionnent en exécutant une image Docker busybox dans notre propre petit conteneur.



Contrairement à la machine virtuelle, le terme conteneur est très vague et vague. Ce que nous appelons généralement un conteneur est un package de code autonome avec toutes les dépendances requises qui peuvent être expédiées ensemble et exécutées dans un environnement isolé à l'intérieur du système d'exploitation hôte. Si vous pensez qu'il s'agit d'une description d'une machine virtuelle, approfondissons le sujet et voyons comment les conteneurs sont implémentés.



BusyBox Docker



Notre objectif principal sera d'exécuter une image busybox régulière pour Docker, mais sans Docker. Docker utilise btrfs comme système de fichiers pour ses images. Essayons de télécharger l'image et de la décompresser dans un répertoire:



mkdir rootfs
docker export $(docker create busybox) | tar -C rootfs -xvf -


Nous avons maintenant le système de fichiers image busybox décompressé dans le dossier rootfs . Bien sûr, vous pouvez exécuter ./rootfs/bin/sh et obtenir un shell fonctionnel, mais si nous regardons la liste des processus, des fichiers ou des interfaces réseau, nous pouvons voir que nous avons accès à l'ensemble du système d'exploitation.



Essayons donc de créer un environnement isolé.



Cloner



Puisque nous voulons contrôler à quoi le processus enfant a accès, nous utiliserons clone (2) au lieu de fork (2) . Clone fait à peu près la même chose, mais permet de passer des indicateurs, indiquant les ressources que vous souhaitez partager (avec l'hôte).



Les indicateurs suivants sont autorisés:



  • CLONE_NEWNET - périphériques réseau isolés
  • CLONE_NEWUTS - nom d'hôte et de domaine (système de temps partagé UNIX)
  • CLONE_NEWIPC - Objets IPC
  • CLONE_NEWPID - identificateurs de processus (PID)
  • CLONE_NEWNS - points de montage (systèmes de fichiers)
  • CLONE_NEWUSER - utilisateurs et groupes.


Dans notre expérience, nous essaierons d'isoler les processus, l'IPC, le réseau et les systèmes de fichiers. Alors commençons:



static char child_stack[1024 * 1024];

int child_main(void *arg) {
  printf("Hello from child! PID=%d\n", getpid());
  return 0;
}

int main(int argc, char *argv[]) {
  int flags =
      CLONE_NEWNS | CLONE_NEWUTS | CLONE_NEWPID | CLONE_NEWIPC | CLONE_NEWNET;
  int pid = clone(child_main, child_stack + sizeof(child_stack),
                  flags | SIGCHLD, argv + 1);
  if (pid < 0) {
    fprintf(stderr, "clone failed: %d\n", errno);
    return 1;
  }
  waitpid(pid, NULL, 0);
  return 0;
}


Le code doit être exécuté avec les privilèges de superutilisateur, sinon le clonage échouera.



L'expérience donne un résultat intéressant: le PID enfant est 1. Nous sommes bien conscients que le processus d'initialisation a généralement le PID 1. Mais dans ce cas, le processus enfant obtient sa propre liste de processus isolée, où il est devenu le premier processus.



Coque de travail



Pour faciliter l'apprentissage d'un nouvel environnement, commençons un shell dans le processus enfant. Exécutons des commandes arbitraires comme docker run :



int child_main(void *arg) {
  char **argv = (char **)arg;
  execvp(argv[0], argv);
  return 0;
}


Le lancement de notre application avec l'argument / bin / sh ouvre un vrai shell dans lequel nous pouvons entrer des commandes. Ce résultat prouve à quel point nous nous trompions lorsque nous parlions d'isolement:



# echo $$
1
# ps
  PID TTY          TIME CMD
 5998 pts/31   00:00:00 sudo
 5999 pts/31   00:00:00 main
 6001 pts/31   00:00:00 sh
 6004 pts/31   00:00:00 ps


Comme nous pouvons le voir, le processus shell lui-même a un PID de 1, mais, en fait, il peut voir et accéder à tous les autres processus du système d'exploitation principal. La raison en est que la liste des processus est lue à partir de procfs , qui est toujours héritée.



Alors, démontez procfs :



umount2("/proc", MNT_DETACH);




Désormais, les commandes ps , mount et autres sont interrompues au démarrage du shell , car procfs n'est pas monté. Cependant, c'est toujours mieux que la fuite procfs parent.



Chroot



Généralement, chroot est utilisé pour créer le répertoire racine , mais nous utiliserons l'alternative pivot_root . Cet appel système déplace la racine système actuelle vers un sous-répertoire et affecte un répertoire différent à la racine:



int child_main(void *arg) {
  /* Unmount procfs */
  umount2("/proc", MNT_DETACH);
  /* Pivot root */
  mount("./rootfs", "./rootfs", "bind", MS_BIND | MS_REC, "");
  mkdir("./rootfs/oldrootfs", 0755);
  syscall(SYS_pivot_root, "./rootfs", "./rootfs/oldrootfs");
  chdir("/");
  umount2("/oldrootfs", MNT_DETACH);
  rmdir("/oldrootfs");
  /* Re-mount procfs */
  mount("proc", "/proc", "proc", 0, NULL);
  /* Run the process */
  char **argv = (char **)arg;
  execvp(argv[0], argv);
  return 0;
}


Il est logique de monter tmpfs sur / tmp , sysfs sur / sys et de créer un système de fichiers / dev valide , mais je vais sauter cette étape par souci de concision.



Maintenant, nous ne voyons que les fichiers de l'image busybox, comme si nous utilisions un chroot :



/ # ls
bin   dev   etc   home  proc  root  sys   tmp   usr   var

/ # mount
/dev/sda2 on / type ext4 (rw,relatime,data=ordered)
proc on /proc type proc (rw,relatime)

/ # ps
PID   USER     TIME  COMMAND
    1 root      0:00 /bin/sh
    4 root      0:00 ps

/ # ps ax
PID   USER     TIME  COMMAND
    1 root      0:00 /bin/sh
    5 root      0:00 ps ax


Pour le moment, le conteneur semble assez isolé, peut-être même trop. Nous ne pouvons rien cingler et le réseau ne semble pas fonctionner du tout.



Réseau



La création d'un nouvel espace de noms réseau n'était que le début! Vous devez lui attribuer des interfaces réseau et les configurer pour transmettre correctement les paquets.



Si vous n'avez pas l'interface br0, vous devez la créer manuellement (brctl fait partie du paquet bridge-utils dans Ubuntu):



brctl addbr br0
ip addr add dev br0 172.16.0.100/24
ip link set br0 up
sudo iptables -A FORWARD -i wlp3s0  -o br0 -j ACCEPT
sudo iptables -A FORWARD -o wlp3s0 -i br0 -j ACCEPT
sudo iptables -t nat -A POSTROUTING -s 172.16.0.0/16 -j MASQUERADE


Dans mon cas, wlp3s0 était l'interface principale du réseau WiFi et 172.16.xx était le réseau du conteneur.



Notre lanceur de conteneurs doit créer une paire d'interfaces, veth0 et veth1, les associer à br0 et configurer le routage dans le conteneur.



Dans la fonction main () , nous exécuterons ces commandes avant le clonage:



system("ip link add veth0 type veth peer name veth1");
system("ip link set veth0 up");
system("brctl addif br0 veth0");


Lorsque l'appel à clone () est terminé, nous ajouterons veth1 au nouvel espace de noms enfant:



char ip_link_set[4096];
snprintf(ip_link_set, sizeof(ip_link_set) - 1, "ip link set veth1 netns %d",
         pid);
system(ip_link_set);


Maintenant, si nous exécutons ip link dans un shell de conteneur, nous verrons une interface de bouclage et une interface veth1 @ xxxx. Mais le réseau ne fonctionne toujours pas. Définissons un nom d'hôte unique dans le conteneur et configurons les routes:



int child_main(void *arg) {

  ....

  sethostname("example", 7);
  system("ip link set veth1 up");

  char ip_addr_add[4096];
  snprintf(ip_addr_add, sizeof(ip_addr_add),
           "ip addr add 172.16.0.101/24 dev veth1");
  system(ip_addr_add);
  system("route add default gw 172.16.0.100 veth1");

  char **argv = (char **)arg;
  execvp(argv[0], argv);
  return 0;
}


Voyons à quoi ça ressemble:



/ # ip link
1: lo: <LOOPBACK> mtu 65536 qdisc noop qlen 1
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
47: veth1@if48: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 1500 qdisc noqueue qlen 1000
    link/ether 72:0a:f0:91:d5:11 brd ff:ff:ff:ff:ff:ff

/ # hostname
example

/ # ping 1.1.1.1
PING 1.1.1.1 (1.1.1.1): 56 data bytes
64 bytes from 1.1.1.1: seq=0 ttl=57 time=27.161 ms
64 bytes from 1.1.1.1: seq=1 ttl=57 time=26.048 ms
64 bytes from 1.1.1.1: seq=2 ttl=57 time=26.980 ms
...


Travaux!



Conclusion



Le code source complet est disponible ici . Si vous trouvez un bug ou avez une suggestion, laissez un commentaire!



Bien sûr, Docker peut faire beaucoup plus! Mais c'est incroyable le nombre d'API appropriées dont dispose le noyau Linux et la facilité avec laquelle il est de les utiliser pour réaliser une virtualisation au niveau du système d'exploitation.



J'espère que vous avez apprécié l'article. Vous pouvez retrouver les projets de l'auteur sur Github et suivre Twitter pour suivre l'actualité, ainsi que via rss .



All Articles