Docker容器的本质(一):资源隔离

一个运行时的Docker容器是宿主机上的一个进程,并通过linux nampspace实现隔离。

进程与隔离

首先启动一个容器docker run --rm --name alpine -it alpine:3.8 /bin/sh,并执行sleep 123123命令。
在宿主机重新开启一个终端,查看进程树pstree -ap |grep sleep -C 10可看到进程关系:

1
2
3
4
5
dockerd,1080
|-docker-containe,1376 --config /var/run/docker/containerd/containerd.toml
|-docker-containe,14876 -namespace moby -workdir...
|-sh,14893
`-sleep,15114 123123

pid是14893的sh就是/bin/sh,也就是该容器的ENTRYPOINT。sleep进程是sh的子进程。可以看到,一个运行的Docker容器就是宿主机上的一个进程。ENTRYPOINT是容器的入口进程,其他的进程都是该进程的子进程,该命令退出,容器就退出了。并且ENTRYPOINT只能是一个进程。

在容器中终止sleep命令, 用ps -ef查看容器内进程

1
2
PID   USER     TIME  COMMAND
1 root 0:00 /bin/sh

/bash/sh的pid为1,在容器内看不到容器外的进程。而在宿主机却看到容器内的进程和其子进程,这就是进程隔离。Linux 用 PID namespace实现进程的隔离。

linux namespace

Linux 内核从版本 2.4.19 开始陆续引入了 namespace 的概念。其目的是将某个特定的全局系统资源通过抽象方法使得namespace中的进程看起来拥有它们自己的隔离的全局系统资源实例。

预先了一个alpine容器,进程14893是容器的ENTRYPOINT进程, 15114是其子进程。
在宿主机使用 ls -lh /proc/14893/ns /proc/15114/ns 看到他们的namespace如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/proc/14893/ns:
总用量 0
lrwxrwxrwx. 1 root root 0 12月 29 22:38 ipc -> ipc:[4026532563]
lrwxrwxrwx. 1 root root 0 12月 29 22:38 mnt -> mnt:[4026532561]
lrwxrwxrwx. 1 root root 0 12月 29 22:23 net -> net:[4026532566]
lrwxrwxrwx. 1 root root 0 12月 29 22:38 pid -> pid:[4026532564]
lrwxrwxrwx. 1 root root 0 12月 29 22:38 user -> user:[4026531837]
lrwxrwxrwx. 1 root root 0 12月 29 22:38 uts -> uts:[4026532562]

/proc/15114/ns:
总用量 0
lrwxrwxrwx. 1 root root 0 12月 29 22:38 ipc -> ipc:[4026532563]
lrwxrwxrwx. 1 root root 0 12月 29 22:38 mnt -> mnt:[4026532561]
lrwxrwxrwx. 1 root root 0 12月 29 22:38 net -> net:[4026532566]
lrwxrwxrwx. 1 root root 0 12月 29 22:38 pid -> pid:[4026532564]
lrwxrwxrwx. 1 root root 0 12月 29 22:38 user -> user:[4026531837]
lrwxrwxrwx. 1 root root 0 12月 29 22:38 uts -> uts:[4026532562]

alpine容器使用到了6种namespace。并且容器的入口进程和其子进程持有相同的namespace。容器内进程与容器外进程有不同的namespace(根据docker run的配置不同,可存在相同的namespace)。

可通过clone调用并指定具体的namespace flag来创建新的子进程。新创建的进程会根据flags的不同决定是否开启新的namespace。

1
int clone(int (*fn)(void *), void *child_stack, int flags, void *arg);
命名空间 作用 标志位 内核完全支持的起始版本 缩写(仅限于本文)
Mount namespaces 隔离文件挂载 CLONE_NEWNS 2.4.19 M
UTS namespaces 隔离hostname和NIS域名 CLONE_NEWUTS 2.6.19 U
IPC namespaces 隔离进程间通信 CLONE_NEWIPC 2.6.19 I
PID namespaces 隔离进程 CLONE_NEWPID 2.6.24 P
Network namespaces 隔离网络 CLONE_NEWNET 2.6.29 N
User namespaces 隔离用户 CLONE_NEWUSER 3.8 S

下面用一段C程序来演示下各个namespace的作用。该程序会根据传入的第一个字符串确定开启哪些namespace,并启动一个sh进程。
nstest.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
#define _GNU_SOURCE
#include <sys/wait.h>
#include <stdio.h>
#include <sched.h>
#include <string.h>

#define errExit(msg) do { printf("err: %s\n", msg); return -1; } while (0);

#define STACK_SIZE (1024 * 1024)
static char stack[STACK_SIZE];

int child_main(void *cmd) {
printf("[child] pid: %d, ppid: %d\n", getpid(), getppid());

char* const args[] = {"/bin/sh", NULL};
// execv函数用于替换进程镜像,新进程的PID与原来相同。所需的参数以char *arg[]形式给出且arg最后一个元素必须是NULL
execv(args[0], args);

return 1;
}

int main(int argc, char * argv[]) {
if (argc != 2)
errExit("arg invalid");

int i, flags = SIGCHLD;
char *flagStr = argv[1];
for (i = 0; i < strlen(flagStr); i++) {
switch (flagStr[i]) {
case 'M':
flags |= CLONE_NEWNS;
break;
case 'U':
flags |= CLONE_NEWUTS;
break;
case 'I':
flags |= CLONE_NEWIPC;
break;
case 'P':
flags |= CLONE_NEWPID;
break;
case 'N':
flags |= CLONE_NEWNET;
break;
case 'S':
flags |= CLONE_NEWUSER;
break;
default:
errExit("invalid flag");
}
}

int child_pid = clone(child_main, stack + STACK_SIZE, flags, NULL);
printf("[parent] child_pid: %d\n", child_pid);
waitpid(child_pid, NULL, 0);
return 0;
}

可用gcc nstest.c -o nstest编译

PID namespace

运行./nstest P 我们可以看到

1
2
[parent] child_pid: 1962
[child] pid: 1, ppid: 0

clone出来的子进程在一个新的PID namespace里面,它看到自己的进程ID为1
可以用以下这段小程序获取新启动进程的pid和其父pid。
getpid.c

1
2
3
4
5
6
7
8
9
#define _GNU_SOURCE
#include <stdio.h>
#include <sched.h>

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

执行./getpid,可以看到,从该sh程序启动的其他程序也复用其新创建的PID namespace。

1
2
PID: 2
PPID: 1

UTS namespace

执行./nstest U,启动一个开启UTS namespace的sh程序。
可通过以下这段程序来设置新的hostname

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#define _GNU_SOURCE
#include <stdio.h>
#include <sched.h>
#include <string.h>

int main(int argc, char *argv[])
{
if(argc != 2){
printf("arg error"); return -1;
}

sethostname(argv[1], strlen(argv[1]));
return 0;
}

执行

1
2
3
sh-4.2# ./sethostname container1
sh-4.2# hostname
container1

在宿主机运行, hostname还是docker。所以子进程的hostname改变不会影响到父进程。

Mount namespace

注意,如果用的是虚拟机,可能会存在开启Mount namespace无效的情况。
可用mount --make-private /命令将根挂载改为私有的(已在VirtualBox下验证过,并且重启后会还原)
可通过findmnt -o TARGET,PROPAGATION /命令来确保根路径是私有的

1
2
TARGET PROPAGATION
/ private

执行./nstest M命令,开启床架你新Mount namespace的sh。新的namespace会复制宿主机的mount状态。 但是如果有新的改动,则不会相互影响。换句话讲就是容器mount改变不会影响宿主机, 宿主机mount改变不会影响容器。 并且在主机已经挂载的分区Busy的时候,容器里还可以umount,反之亦然。

在容器里执行 mount -o size=12M -t tmpfs Ramdisk /tmp 将/tmp目录挂载到虚拟内存盘
在容器里用mount -l |grep Ramdisk 可以看到,Ramdisk的挂载。 在宿主机则看不到该挂载。

IPC namespace

运行./nstest I开启IPC namespace。
然后在新开启的sh里执行创建IPC消息队列的命令ipcmk -Q,并执行查询命令ipcs -q

1
2
3
------ Message Queues --------
key msqid owner perms used-bytes messages
0x1a95fc10 0 root 644 0 0

而在宿主机执行则获取不到该队列

1
2
------ Message Queues --------
key msqid owner perms used-bytes messages

Network namespace

network namespace可以让新创建的进程拥有独立的网络配置,包括网络接口,路由表和防火墙。

图片来自此处
如图所示,docker首先会创建一个网桥,容器通过虚拟eth对的方式连接到网桥进行通信。如果需要连接外网,也是需要通过docker0网桥

执行./nstest N,创建新的network namespace,记下进程的PID。
将新创建子进程的network namespace符号链接到/var/run目录, 以便ip命令进行操作。
新建宿主机终端, 执行下列命令

1
2
3
4
5
6
7
pid=2327

mkdir -p /var/run/netns
ln -sf /proc/$pid/ns/net /var/run/netns/testns

# 确保testns存在
ip netns list

开启loopback, ip netns exec testns用来指定namespace,后面跟的指令是需要在testns命名空间下执行的命令

1
2
ip netns exec testns ip link set dev lo up
ip netns exec testns ip addr

增加veth网卡, veth总是一对,从一端进,另一端出。就像一个管道

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 该指令会创建一对网卡 veth-testns 和 testnsbr0.1
ip link add veth-testns type veth peer name testnsbr0.1

# 宿主机一端:开启testnsbr0.1
ip link set testnsbr0.1 up

# 容器一端:
# 将veth-testns的namespace改为testns。 (更改过后,该网卡只能在testns中看到,不能在默认命名空间看到了)
ip link set veth-testns netns testns
# 将testns中的网卡改名为eth0
ip netns exec testns ip link set dev veth-testns name eth0
# 设置ip并启用
ip netns exec testns ip addr add 192.168.66.5/24 dev eth0
ip netns exec testns ip link set eth0 up

创建网桥, 模仿docker0。并把testnsbr0.1接到网桥上

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 创建网桥
brctl addbr testnsbr0
brctl stp testnsbr0 off

# 设置ip并开启
ip addr add 192.168.66.1/24 dev testnsbr0
ip link set testnsbr0 up

# 将testnsbr0.1接到网桥上
brctl addif testnsbr0 testnsbr0.1

# 设置设置默认路由到网桥
ip netns exec testns ip route add default via 192.168.66.1

# 验证是否可以ping通
ip netns exec testns ping 192.168.66.1

切回到 ./nstest N 创建的终端, 执行ip addr show

1
2
3
4
5
6
7
8
9
10
11
12
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
inet 127.0.0.1/8 scope host lo
valid_lft forever preferred_lft forever
inet6 ::1/128 scope host
valid_lft forever preferred_lft forever
6: eth0@if5: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000
link/ether 66:37:67:90:b1:cd brd ff:ff:ff:ff:ff:ff link-netnsid 0
inet 192.168.66.5/24 scope global eth0
valid_lft forever preferred_lft forever
inet6 fe80::6437:67ff:fe90:b1cd/64 scope link
valid_lft forever preferred_lft forever

子进程的网络已经与宿主机隔离开了。
eth0@if5中if5就是创建veth pair的时候在默认命名空间下的虚拟eth的网络接口号

User namespace

在容器外运行id

1
uid=0(root) gid=0(root) groups=0(root)

创建新的User namespace并执行./testns S

1
uid=65534 gid=65534 groups=65534

可见子进程的用户和组与父进程不同。 由于子进程中用户不存在,所以id为65534。可在/proc/<pid>/uid_map/proc/<pid>/gid_map 来处理映射关系,本文不在详细展开。

centos系统可能创建User namespace失败,可参考此处

模拟一个简易容器

下面模拟一个简易的容器,能够实现进程, 文件系统和hostname的隔离

1. 准备工作

rootfs目录,一个最小化的linux文件系统, 是从alpine中提取的,可以执行以下命令获得
wget ss.tswblog.com/rootfs.tar.gz && tar -zxf rootfs.tar.gz

config目录,用于存放公共配置信息。
data目录,模拟用户自定义挂载的目录,相当于docker 的 -v 参数

1
2
3
4
5
6
7
8
9
# 外部配置
mkdir config
echo container >config/hostname
echo 127.0.0.1 container >config/hosts
echo nameserver 114.114.114.114 >resolv.conf

# 用户自定义目录
mkdir data
echo hello >data/file1

2. 启动容器

本例只需要用到Mount namespace, PID namespace 和 UTS namespace

1
./nstest PMU

目录挂载

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 挂载系统目录
mount -t proc proc rootfs/proc
mount -t sysfs sys rootfs/sys
mount -t tmpfs tmp rootfs/tmp
mount -t devtmpfs udev rootfs/dev
mount -t devpts devops rootfs/dev/pts
mount -t tmpfs shm rootfs/dev/shm
mount -t tmpfs run rootfs/run

# 挂载配置
mount -o bind config/hostname rootfs/etc/hostname
mount -o bind config/hosts rootfs/etc/hosts
mount -o bind config/resolv.conf rootfs/etc/resolv.conf

# 挂载自定义目录
mkdir rootfs/mnt/data
mount -o bind data rootfs/mnt/data

如果此时执行ps -ef,查询到的进程信息还是宿主机的。原因是ps是通过读取/proc目录来获取进程信息的,虽然已经开启了Mount namespace, Pid namespace,并挂载了新的proc目录, 但是/proc目录还是宿主机系统的。 接下来需要用chroot命令将rootfs目录作为容器的根目录

1
2
chroot rootfs /bin/sh
PATH=/bin:/usr/bin

再执行ps -ef就只能看到容器内的进程了,这一点的体验已经与docker十分类似。

加入已存在namespace

如果需要加入已经存在的namespace(例如docker exec的实现)。只需要打开namespace文件,并且调用setns函数即可。
例如示例程序joinns.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#define _GNU_SOURCE
#include <fcntl.h>
#include <sched.h>
#include <stdlib.h>

#define errExit(msg) do { perror(msg); exit(EXIT_FAILURE);} while (0)

int main(int argc, char *argv[]) {
int fd;

fd = open(argv[1], O_RDONLY);
if (setns(fd, 0) == -1) {
errExit("setns");
}
execvp(argv[2], &argv[2]);
errExit("execvp");
}

例如对刚刚运行的alpine容器,加入其network namespace并执行ip a命令:

1
2
pid=$(docker inspect --format '{{.State.Pid}}' alpine)
./joinns /proc/$pid/ns/net ip a

可以看到打印的网络信息已经是该容器的网络信息。

如果了解了namespace的概念再来看docker run的几个参数就很容易理解了
–mount
–pid
–uts
–ipc
–network
–userns
例如--network=host其实就是与宿主机共享了一个network namespace

其他

查看当前系统支持哪些namespace

grep "CONFIG_.*_NS\b" /boot/config-$(uname -r)

1
2
3
4
5
CONFIG_UTS_NS=y
CONFIG_IPC_NS=y
CONFIG_USER_NS=y
CONFIG_PID_NS=y
CONFIG_NET_NS=y

参考资料

https://www.systutorials.com/docs/linux/man/2-clone/
https://code.woboq.org/kde/include/sys/utsname.h.html#32
https://blog.yadutaf.fr/2014/01/05/introduction-to-linux-namespaces-part-3-pid/
https://platform9.com/blog/container-namespaces-deep-dive-container-networking/
https://blog.scottlowe.org/2013/09/04/introducing-linux-network-namespaces/
https://coolshell.cn/articles/17010.html
https://www.cnblogs.com/sammyliu/p/5878973.html

0%