在上一篇有关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,启动外壳程序时ps,mount和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上找到作者的项目,并通过Twitter和rss来关注新闻。