docker 资源隔离

介绍

docker不是一项新技术,docker是老旧技术的组合,为了更方便使用容器技术,docker提供了简单方便的UI。docker隔离资源主要用到了两种技术namespace、cgroup。

Namespace

namespace是linux内核提供的隔离技术,它包括六种资源隔离,UTS(主机名与域名)、IPC(信号量、消息队列和共享内存)、PID(进程编号)、NET(网络设备、网络栈、端口…)、Mount(文件系统、挂载点)、User(用户和用户组)。

Type Sys Params ker ver
UTS CLONE_NEWUTS 2.6.19
IPC CLONE_NEWIPC 2.6.19
PID CLONE_NEWPID 2.6.24
NET CLONE_NEWNET 2.6.29
Mount CLONE_NEWNS 2.4.19
User CLONE_NEWUSER 3.8

操作系统调用接口:

  • clone()
    创建一个独立的进程独立的namespace
  • setns()
    使用已有的一个namespace
  • unshare()
    不启动新进程,在原进程上进行namespace隔离

docker run中提供了使用namespace的接口:

1
2
3
4
5
# docker run --help | grep -i namespace
--ipc string IPC namespace to use
--pid string PID namespace to use
--userns string User namespace to use
--uts string UTS namespace to use

UTS

提供了主机名和域名的隔离,这样每个容器就可以拥有了独立的主机名和域名,在网络上可以被视作一个独立的节点而非宿主机上的一个进程。
docker在run或create时,使用-h--hostname指定hostname

IPC

IPC是Unix/linux下进程间通讯的一种方式,包括信号量、消息队列、共享内存。容器内部进程间通信对宿主机来说,实际上是具有相同PID namespace中的进程间通信,因此需要一个唯一的标识符来进行区别。申请IPC资源就申请了这样一个全局唯一的32位ID,所以IPC namespace中实际上包含了系统IPC标识符以及实现POSIX消息队列的文件系统。在同一个IPC namespace下的进程彼此可见,而与其他的IPC namespace下的进程则互相不可见。

在宿主机上创建IPC(以消息队列为例):

1
2
# ipcmk -Q
消息队列 id:32768

在宿主机上查询IPC:

1
2
3
4
5
# ipcs

--------- 消息队列 -----------
键 msqid 拥有者 权限 已用字节数 消息
0xabba8164 32768 root 644 0 0

在容器中查询IPC:

1
2
3
4
5
6
7
8
9
10
# docker exec -it net_5 ipcs

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

------ Shared Memory Segments --------
key shmid owner perms bytes nattch status

------ Semaphore Arrays --------
key semid owner perms nsems

PID

它对进程PID重新标号,即两个不同namespace下的进程可以有同一个PID。每个PID namespace都有自己的计数程序。内核为所有的PID namespace维护了一个树状结构,最顶层的是系统初始时创建的,我们称之为root namespace。他创建的新PID namespace就称之为child namespace(树的子节点),而原先的PID namespace就是新创建的PID namespace的parent namespace(树的父节点)。通过这种方式,不同的PID namespaces会形成一个等级体系。所属的父节点可以看到子节点中的进程,并可以通过信号等方式对子节点中的进程产生影响。反过来,子节点不能看到父节点PID namespace中的任何内容。

* 每个PID namespace中的第一个进程“PID 1“,都会像传统Linux中的init进程一样拥有特权,起特殊作用。
* 一个namespace中的进程,不可能通过kill或ptrace影响父节点或者兄弟节点中的进程,因为其他节点的PID在这个namespace中没有任何意义。
* 如果你在新的PID namespace中重新挂载/proc文件系统,会发现其下只显示同属一个PID namespace中的其他进程。(挂载/proc 文件系统尤为重要)
* 在root namespace中可以看到所有的进程,并且递归包含所有子节点中的进程。

docker-resource-separation-pid

以容器的ceph-mon节点为例:

1
2
3
4
5
6
7
8
9
10
11
# 宿主机上查看 ceph-mon 进程
# ps -ef| grep 9184
root 9184 3122 0 1月20 ? 00:00:04 docker-containerd-shim be0583fade06df3f6510dd629bde4a636e68c755aa8b0733db6702493b1d0c38 /var/run/docker/libcontainerd/be0583fade06df3f6510dd629bde4a636e68c755aa8b0733db6702493b1d0c38 docker-runc
64045 9200 9184 0 1月20 ? 00:01:03 /usr/bin/ceph-mon --cluster ceph -d -i rhel82 --public-addr 192.168.1.82:6789 --setuser ceph --setgroup ceph
root 21893 9855 0 16:15 pts/5 00:00:00 grep --color=auto 9184

# 容器中查看 ceph-mon 进程
# docker exec -it ceph-mon ps -ef
UID PID PPID C STIME TTY TIME CMD
ceph 1 0 0 Jan20 ? 00:01:03 /usr/bin/ceph-mon --cluster ceph
root 26 0 0 08:15 ? 00:00:00 ps -ef

NET

Pv4和IPv6协议栈、IP路由表、防火墙、/proc/net目录、/sys/class/net目录、端口(socket)等等。一个物理的网络设备最多存在在一个network namespace中,你可以通过创建veth pair(虚拟网络设备对:有两端,类似管道,如果数据从一端传入另一端也能接收到,反之亦然)在不同的network namespace间创建通道,以此达到通信的目的。

一般情况下,物理网络设备都分配在最初的root namespace(表示系统默认的namespace,在PID namespace中已经提及)中。但是如果你有多块物理网卡,也可以把其中一块或多块分配给新创建的network namespace。需要注意的是,当新创建的network namespace被释放时(所有内部的进程都终止并且namespace文件没有被挂载或打开),在这个namespace中的物理网卡会返回到root namespace而非创建该进程的父进程所在的network namespace。

当我们说到network namespace时,其实我们指的未必是真正的网络隔离,而是把网络独立出来,给外部用户一种透明的感觉,仿佛跟另外一个网络实体在进行通信。为了达到这个目的,容器的经典做法就是创建一个veth pair,一端放置在新的namespace中,通常命名为eth0,一端放在原先的namespace中连接物理网络设备,再通过网桥把别的设备连接进来或者进行路由转发,以此网络实现通信的目的。

docker-resource-separation-network

可通过ip netnsbrctl管理Network Namespace,docker创建的netns路径为/proc/{进程ID}/ns/netip netns访问的默认路径为/var/run/netns/
若需要访问其Network Namespace内部,先创建软连接链至ip netns访问路径,然后使用ip netns exec访问该网络内部。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 在ip netns访问路径下创建network namespace的软链接
# ln -s /proc/7013/ns/net /var/run/netns/net_5

# 使用 ip netns exec 访问指定network namespace的网络
# ip netns exec net_5 ip addr
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default
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
15: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default
link/ether 02:42:ac:11:00:03 brd ff:ff:ff:ff:ff:ff
inet 172.17.0.3/16 scope global eth0
valid_lft forever preferred_lft forever
inet6 fe80::42:acff:fe11:3/64 scope link
valid_lft forever preferred_lft forever

Mount

隔离后,不同mount namespace中的文件结构发生变化也互不影响。你可以通过/proc/[pid]/mounts查看到所有挂载在当前namespace中的文件系统,还可以通过/proc/[pid]/mountstats看到mount namespace中文件设备的统计信息,包括挂载文件的名字、文件系统类型、挂载位置等等。(此处用到了mount propagation 技术)

docker-resource-separation-mount.png
上图mount挂载方式有待确认。

USER

主要隔离了安全相关的标识符(identifiers)和属性(attributes),包括用户ID、用户组ID、root目录、key(指密钥)以及特殊权限。说得通俗一点,一个普通用户的进程通过clone()创建的新进程在新user namespace中可以拥有不同的用户和用户组。这意味着一个进程在容器外属于一个没有特权的普通用户,但是他创建的容器进程却属于拥有所有权限的超级用户,这个技术为容器提供了极大的自由。

docker通过/proc/{进程ID}/uid_map/proc/{进程ID}/gid_map把容器中的uid、gid和真实系统的uid、gid给映射在一起,格式为:ID-inside-ns ID-outside-ns length

  • ID-inside-ns 表示在容器内显示的uid或gid
  • ID-outside-ns 表示在容器外映射的真实的uid或gid
  • length 表示映射范围,一般为1,表示一一对应(把ID-outside-ns ~(ID-outside-ns+length) 映射到 ID-inside-ns ~(ID-inside-ns+length)上)

cgroups

Control Groups(cgroups),是Linux 内核提供的一种可以限制、记录、隔离进程组所使用的物理资源(如 cpu、memory、磁盘IO等等) 的机制,被LXC、docker等很多项目用于实现进程资源控制。

cgroups子系统:

  • cpu
    使用调度程序提供对 CPU 的 cgroup 任务访问
  • cpuset
    为cgroup中的任务分配独立CPU(在多核系统)和内存节点
  • devices
    可允许或者拒绝 cgroup 中的任务访问设备
  • blkio
    为块设备设定输入/输出限制,比如物理设备(磁盘,固态硬盘,USB 等等)
  • freezer
    挂起或者恢复 cgroup 中的任务
  • memory
    设定 cgroup 中任务使用的内存限制,并自动生成由那些任务使用的内存资源报告
  • net_cls
    使用等级识别符(classid)标记网络数据包,可允许 Linux 流量控制程序(tc)识别从具体 cgroup 中生成的数据包

CPU

cpu-shares

docker create/run容器时可以通过--cpu-shares参数来指定容器所使用的CPU加权值。默认情况下,每个docker容器的cpu-shares值都是1024。单独一个容器的cpu-shares是没有意义的,只在多个容器分配的资源紧缺时,也就是说在需要对容器使用的资源进行限制时,才会生效。配之后,可通过./cgroup/cpu/docker/<容器ID>/cpu.shares查看。

cpu-period & cpu-quota

  • cpu-period 用来指定容器对CPU的使用要在多长时间内做一次重新分配
  • cpu-quota 用来指定在这个周期内,最多可以有多少时间用来跑这个容器
    docker create/run时使用,参数为--cpu-period--cpu-quota单位为微秒,cpu-period的默认值为0.1秒(100000 微秒),cpu-quota的默认值为-1(表示不控制)。配置后,可通过./cgroup/cpu/docker/<容器ID>/cpu.cfs_period_us./cgroup/cpu/docker/<容器ID>/cpu.cfs_quota_us查看。

cpuset

docker可使用--cpuset-cpus--cpuset-mems参数控制容器运行限定使用哪些cpu和内存节点。配之后,可通过./cgroup/cpuset/docker/<容器ID>/cpuset.cpus./cgroup/cpuset/docker/<容器ID>/cpuset.mems查看。

注:对于具有NUMA的服务器很重要

Memory

docker create/run时,可以对内存资源加以限制。

  • kernel-memory
    使用参数--kernel-memory,限制内核内存,该内存不会被交换到swap上。
  • memory
    使用参数--memory,设置容器使用的最大内存上限。默认单位为byte,可以使用K、G、M等带单位的字符串。
  • memory-reservation
    使用参数--memory-reservation,启用弹性的内存共享,当宿主机资源充足时,允许容器尽量多地使用内存,当检测到内存竞争或者低内存时,强制将容器的内存降低到memory-reservation所指定的内存大小。不设置此选项时,有可能出现某些容器长时间占用大量内存,导致性能上的损失。
  • memory-swap
    使用参数--memory-swap,设置总内存大小,相当于内存和swap大小的总和,设置-1时,表示swap分区大小是无限的。默认单位为byte,可以使用K、G、M等带单位的字符串。
  • memory-swappiness
    使用参数--memory-swappiness,设置控制进程将物理内存交换到swap分区的倾向,系数越小,就越倾向于使用物理内存。值范围为0-100。当值为100时,表示尽量使用swap分区;当值为0时,表示禁用容器 swap 功能(这点不同于宿主机,宿主机 swappiness 设置为 0 也不保证 swap 不会被使用)

Block Device

I/O

  • device-read-bps
    限制此设备上的读速度(bytes per second),单位可以是kb、mb或者gb
  • device-read-iops
    通过每秒读IO次数来限制指定设备的读速度
  • device-write-bps
    限制此设备上的写速度(bytes per second),单位可以是kb、mb或者gb
  • device-write-iops
    通过每秒写IO次数来限制指定设备的写速度
  • blkio-weight
    容器默认磁盘IO的加权值,有效值范围为10-100。要使-–blkio-weight生效,需要保证IO的调度算法为CFQ
    echo "cfq" > /sys/block/<设备名>/queue/scheduler
  • blkio-weight-device
    针对特定设备的IO加权控制。其格式为DEVICE_NAME:WEIGHT

Volume

使用参数--storage-opt,传入dm.basesize=<容量大小>可以设置rootfs大小。如果不设置dm.basesize,默认值为10G,若要使dm.basesize生效,storage driver 必须是 device mapper。
设置rootfs大小后,需要重启docker服务,并且--storage-opts参数需要在启动docker服务时使用。
以RHEL7.2为例,需要修改/etc/systemd/system/multi-user.target.wants/docker.service/usr/bin/dockerd的参数。

参考&鸣谢