几行代码中的Linux容器

上一篇有关KVM的文章的续篇中,我们发布了一个新的译文,并通过运行busybox Docker映像的示例了解了容器如何工作。


这篇关于容器的文章是前一篇关于KVM的文章的延续我想通过在我们自己的小容器中运行busybox Docker映像向您展示容器的确切工作方式。



与虚拟机不同,术语容器非常模糊。我们通常所说的容器是带有所有必需依赖项的独立代码包,这些代码可以一起运送并在主机操作系统内部的隔离环境中运行。如果您认为这是对虚拟机的描述,那么让我们更深入地了解容器是如何实现的。



BusyBox码头工人



我们的主要目标是为Docker运行常规的busybox映像,但不使用Docker。Docker使用btrfs作为其映像的文件系统。让我们尝试下载图像并将其解压缩到目录中:



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


现在,我们将busybox映像文件系统解压缩到rootfs文件夹中当然,您可以运行./rootfs/bin/sh并获得有效的Shell,但是如果我们查看进程,文件或网络接口的列表,则可以看到我们可以访问整个OS。



因此,让我们尝试创建一个隔离的环境。



克隆



由于我们要控制子进程有权访问的内容,因此我们将使用clone(2)而不是fork(2)克隆几乎做同样的事情,但是允许传递标志,指示您要(与主机)共享哪些资源。



允许以下标志:



  • CLONE_NEWNET-隔离的网络设备
  • CLONE_NEWUTS-主机名和域名(UNIX分时系统)
  • CLONE_NEWIPC -IPC对象
  • CLONE_NEWPID-进程标识符(PID)
  • CLONE_NEWNS-挂载点(文件系统)
  • CLONE_NEWUSER-用户和组。


在我们的实验中,我们将尝试隔离进程,IPC,网络和文件系统。因此,让我们开始:



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;
}


该代码必须以超级用户特权运行,否则克隆将失败。



实验给出了一个有趣的结果:子PID为1。我们很清楚,初始化进程通常具有PID 1。但是在这种情况下,子进程将获得其自己的隔离进程列表,该列表成为第一个进程。



工作壳



为了使学习新环境更加容易,让我们在子进程中启动一个shell。让我们运行docker run等任意命令



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


现在,使用/ bin / sh参数启动我们的应用程序,将打开一个实际的shell,我们可以在其中输入命令。这个结果证明了当我们谈论隔离时我们是多么的错误:



# 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


如我们所见,shell进程本身的PID为1,但实际上,它可以查看和访问主OS的所有其他进程。原因是进程列表是从仍继承的procfs中读取的



因此,卸载procfs



umount2("/proc", MNT_DETACH);




现在由于未挂载procfs,启动外壳程序时psmount和others命令会中断但是,这仍然比父级procfs泄漏更好。



Chroot



通常chroot用于创建根目录,但是我们将使用备用的pivot_root此系统调用将当前系统根目录移到子目录,并为根目录分配另一个目录:



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;
}


tmpfs挂载/ tmp,将sysfs挂载/ sys并创建有效的/ dev文件系统是有意义的,但是为了简洁起见,我将跳过此步骤。



现在,我们仅从busybox映像中看到文件,就好像我们在使用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


目前,该容器看起来很孤立,甚至可能太多。我们无法ping通任何东西,而且网络似乎根本无法正常工作。



网络



创建新的网络名称空间仅仅是个开始!您需要为其分配网络接口,并将其配置为正确转发数据包。



如果您没有br0接口,则需要手动创建它(brctl是Ubuntu中bridge-utils软件包的一部分):



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


就我而言,wlp3s0是主要的WiFi网络接口,而172.16.xx是容器的网络。



我们的容器启动器需要创建一对接口veth0和veth1,将它们与br0关联,并在容器内设置路由。



main()函数中,我们将在克隆之前运行以下命令:



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


完成对clone()的调用后,我们将veth1添加到新的子命名空间:



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);


现在,如果我们在容器外壳中运行ip link,我们将看到一个回送接口和一些veth1 @ xxxx接口。但是网络仍然无法正常工作。让我们在容器中设置唯一的主机名并配置路由:



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;
}


让我们看看它的外观:



/ # 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
...


作品!



结论



完整的源代码在这里如果您发现错误或有任何建议,请发表评论!



当然,Docker可以做的更多!但是令人惊讶的是,Linux内核拥有多少个合适的API,使用它们来实现OS级虚拟化有多么容易。



希望您喜欢这篇文章。您可以在Github上找到作者的项目,并通过Twitterrss来关注新闻



All Articles