解析容器化技术中的资源管理

虚拟化与Docker

虚拟化

虚拟化(virtualization)技术是一个通用的概念,在不同领域有不同的理解。在计算领域,一般指的是计算虚拟化(computing virtualization),或通常说的服务器虚拟化。维基百科上的定义如下:

在计算机技术中,虚拟化是一种资源管理技术,是将计算机的各种实体资源(CPU、内存、磁盘空间、网络适配器等),予以抽象、转换后呈现出来并可供分割、组合为一个或多个计算机配置环境。由此,打破实体结构间的不可切割的障碍,使用户可以比原本的配置更好的方式来应用这些计算机硬件资源。

可见,虚拟化的核心是对资源的抽象,目标往往是为了在同一个主机上同时运行多个系统或应用,从而提高系统资源的利用率,并且带来降低成本、方便管理和容错容灾等好处。

Docker

Docker 是虚拟化技术的一种,也是目前使用比较多的一种,采用 Go 语言来开发引擎。

Docker 利用”集装箱”(容器)的原理,将系统、开发软件包、依赖环境等统一打包到容器中,将整个容器部署至其他的平台或者服务器上。

Docker 架构

Docker 使用 C/S (客户端/服务器)体系的架构,Docker 客户端与 Docker 守护进程通信,Docker 守护进程负责构建,运行和分发 Docker 容器。Docker 客户端和守护进程可以在同一个系统上运行,也可以将 Docker 客户端连接到远程 Docker 守护进程。Docker 客户端和守护进程使用 REST API 通过UNIX套接字或网络接口进行通信。 如果你想和更多Docker技术专家交流,可以加我微信liyingjiese,备注『加群』。群里每周都有全球各大公司的最佳实践以及行业最新动态

Docker Daemon:Docker的服务端由多个服务配合完成:dockerd,用来监听 Docker API 的请求和管理 Docker 对象,比如镜像、容器、网络和 Volume;containerd 提供 gRPC 接口响应来自 dockerd 的请求,通过 container-shim 管理 runC 镜像和容器环境,runC 真正控制容器生命周期。containerd 是 containerd-shim 的父进程,contaienrd-shim 是容器进程的父进程。

Docker Client:docker,docker client 是我们和 Docker 进行交互的最主要的方式方法,比如我们可以通过 docker run 命令来运行一个容器,然后我们的这个 client 会把命令发送给上面的 dockerd,让他来做真正事情。

Docker Registry:用来存储 Docker 镜像的仓库,Docker Hub 是 Docker 官方提供的一个公共仓库,而且 Docker 默认也是从 Docker Hub 上查找镜像的,当然你也可以很方便的运行一个私有仓库,当我们使用 docker pull 或者 docker run 命令时,就会从我们配置的 Docker 镜像仓库中去拉取镜像,使用 docker push 命令时,会将我们构建的镜像推送到对应的镜像仓库中。

Images:镜像,镜像是一个只读模板,带有创建 Docker 容器的说明,一般来说的,镜像会基于另外的一些基础镜像并加上一些额外的自定义功能。比如,你可以构建一个基于 CentOS 的镜像,然后在这个基础镜像上面安装一个 Nginx 服务器,这样就可以构成一个属于我们自己的镜像了。

Containers:容器,容器是一个镜像的可运行的实例,可以使用 Docker REST API 或者 CLI 来操作容器,容器的实质是进程,但与直接在宿主执行的进程不同,容器进程运行于属于自己的独立的命名空间。因此容器可以拥有自己的 root 文件系统、自己的网络配置、自己的进程空间,甚至自己的用户 ID 空间。容器内的进程是运行在一个隔离的环境里,使用起来,就好像是在一个独立于宿主的系统下操作一样。这种特性使得容器封装的应用比直接在宿主运行更加安全。

Docker与虚拟机比较

从概念上来看 Docker 和我们传统的虚拟机比较类似,只是更加轻量级,更加方便使用,下图展示了传统虚拟机与容器在系统层次上的不同: 可以看到,传统方式是在硬件层面上实现虚拟化,需要有额外的虚拟机管理应用和虚拟机操作系统层,Docker容器是在操作系统层面上实现虚拟化,直接复用本地主机的操作系统,因此更加轻量级。

Linux命名空间

命名空间(namespace)是Linux内核的一个强大特性,为容器虚拟化的实现带来极大的便利。利用这一特性,每个容器都可以拥有自己单独的命名空间,运行在其中的应用都像是在独立的操作系统环境中一样。

命名空间机制保证了容器之间彼此互不影响。

在操作系统中,包括内核、文件系统、网络、进程号(PID)、用户号(UID)、进程间通信(IPC)等资源,所有的资源都是应用进程直接共享的。要想实现虚拟化,除了要实现内存、CPU、网络 IO、硬盘 IO、存储空间等的限制外,还要实现文件系统、网络、PID、UID、IPC 等的互相隔离。

进程命名空间

Linux 通过进程命名空间管理进程号,对于同一进程,在不同的命名空间中,看到的进程号不相同。进程命名空间是一个父子关系的结构,子空间中的进程对于父空间中的进程是可见的。新 fork 出的一个进程,在父命名空间和子命名空间将分别对应不同的进程号。

举个例子,新建一个 Ubuntu 容器,执行 sleep 命令。

$ docker run --name ubuntu -d ubuntu:16.04 sleep 9999
088ce1db029ebeba1af3a36d48e0193441c8abb920abfc0306dc30aab825ff61

根据我们上面的分析,当使用docker客户端执行run命令后,会使用RESTful接口向dockerd发起创建容器命令,然后dockerd会通过gRPC方式调用containerd,让他帮我们创建一个容器。此时,containerd进程作为父进程,会为每个容器启动一个containerd-shim进程,作为该容器内所有进程的根进程。

查看一下宿主机中 containerd 的进程号:

$ ps -ef | grep containerd
root      9165     1  0 3月08 ?       06:24:03 /usr/bin/containerd

然后查看一下进程树:

$ pstree -l -a -A 9165
containerd
  |-containerd-shim -namespace moby -workdir /var/lib/containerd/io.containerd.runtime.v1.linux/moby/088ce1db029ebeba1af3a36d48e0193441c8abb920abfc0306dc30aab825ff61 -address /run/containerd/containerd.sock -containerd-binary /usr/bin/containerd -runtime-root /var/run/docker/runtime-runc
  |   |-sleep 9999
  |   `-9*[{containerd-shim}]
  `-16*[{containerd}]

我们再进到容器里面看一下进程号:

$ docker exec -it 088ce1 bash -c 'ps -ef'
UID        PID  PPID  C STIME TTY          TIME CMD
root         1     0  0 13:16 ?        00:00:00 sleep 9999

可以看到,在容器内的进程空间中,把 containerd-shim 进程作为 0 号进程,并且只能看到 containerd-shim 进程往下的子进程空间,而无法获知宿主机上的进程信息。

网络命名空间

有了进程命名空间后,不同命名空间中的进程号可以互相隔离了,但是网络端口还是共享本地系统的端口。通过网络命名空间,可以实现网络隔离。一个网络命名空间为进程提供了一个完全独立的网络协议栈视图。包括网络设备、IPv4 和 IPv6 协议栈、IP 路由表、防火墙规则、sockets 等,这样每个容器的网络就隔离开来。

Docker 采用虚拟网络设备的方式,将不同命名空间的网络设备连接到一起。默认情况下,Docker 会在宿主机上创建一个 docker0 网桥,实际上是 Linux 的一个 bridge,可以理解为一个软件交换机。它会在挂载到它的网口之间进行转发。

同时,Docker 随机分配一个本地未占用的私有网段中的一个地址给 docker0 接口。比如典型的 172.18.0.1,掩码为 255.255.0.0。此后启动的容器内的网口也会自动分配一个同一网段(172.18.0.0/16)的地址。

创建一个 Docker 容器的时候,同时会创建了一对 veth pair 接口(当数据包 发送到一个接口时,另外一个接口也可以收到相同的数据包)。这对接口一端在容器内,即 eth0 ;另一端在本地并被挂载到 docker0 网桥,名称以 veth 开头(例如 veth82487d6)。通过这种方式,主机可以跟容器通信,容器之间也可以相互通信。Docker 就创建了在主机和所有容器之间一个虚拟共享网络。 例如我们创建一个容器:

$ docker run -d amouat/network-utils sleep 999
fe29bd04a38870d874b92dab68793ce5a834992d34f5293ed339cece8122a893

为了操纵容器的网络命名空间,首先查看容器中进程的PID:

$ docker top fe29bd04a38
UID    PID           PPID       C       STIME     TTY       TIME          CMD
root   58233      58213     0       11:43       ?            00:00:00   sleep 9999

看到是58233。

然后进入到该进程的命名空间目录:

$  ls -l /proc/58233/ns
lrwxrwxrwx 1 root root 0 5月  28 11:45 ipc -> ipc:[4026532201]
lrwxrwxrwx 1 root root 0 5月  28 11:45 mnt -> mnt:[4026532199]
lrwxrwxrwx 1 root root 0 5月  28 11:43 net -> net:[4026532204]
lrwxrwxrwx 1 root root 0 5月  28 11:45 pid -> pid:[4026532202]
lrwxrwxrwx 1 root root 0 5月  28 12:00 user -> user:[4026531837]
lrwxrwxrwx 1 root root 0 5月  28 11:45 uts -> uts:[4026532200]

可以看到该进程的网络命名空间编号是 4026532204。为了操作该命名空间,我们可以创建一个软连接:

$ ln -sf /proc/58233/ns/net "/var/run/netns/net_util_ns"

net_util_ns是我们给该网络命名空间起的名字,可以自定义。之后,我们就可以针对这个命名空间名字操作容器的网络了:

$ ip netns exec net_util_ns ip a
1: lo:  mtu 65536 qdisc noqueue state UNKNOWN
    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
247: eth0@if248:  mtu 1500 qdisc noqueue state UP
    link/ether 02:42:ac:12:00:02 brd ff:ff:ff:ff:ff:ff link-netnsid 0
    inet 172.18.0.2/16 brd 172.18.255.255 scope global eth0
       valid_lft forever preferred_lft forever

可以看到该命名空间中有一个 veth pair 端点 247: eth0@if248,我们再看下宿主机:

$ ip a
1: lo:  mtu 65536 qdisc noqueue state UNKNOWN
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
2: eth0:  mtu 1400 qdisc pfifo_fast state UP qlen 1000
    inet 10.xxx.xxx.xxx/x 
124: docker0:  mtu 1500 qdisc noqueue state UP
    link/ether 02:42:61:fa:42:fe brd ff:ff:ff:ff:ff:ff
    inet 172.18.0.1/16 scope global docker0
248: veth82487d6@if247:  mtu 1500 qdisc noqueue master docker0 state UP
    link/ether 16:41:63:ef:f5:74 brd ff:ff:ff:ff:ff:ff link-netnsid 0

其中包含了一个 veth pair 的端点,248: veth82487d6@if247,与容器中的 veth pair 端点对应。

查看宿主机网桥:

$ brctl show
bridge name     bridge id               STP enabled     interfaces
docker0         8000.024261fa42fe       no              veth82487d6

IPC 命名空间

容器中的进程交互还是采用了Linux常见的进程间交互方式(Interprocess Communication,IPC),包括信号量、消息队列和共享内存等方式。

PID 命名空间和 IPC 命名空间可以组合起来一起使用,同一个 IPC 命名空间内的进程可以彼此可见,允许进行交互;不同空间的进程则无法交互。

挂载命名空间

类似于 chroot,挂载(Mount,MNT)命名空间可以将一个进程的根文件系统限制到一个特定的目录下。

挂载命名空间允许不同命名空间的进程看到的本地文件位于宿主机中不同路径下,每个命名空间中的进程所看到的文件目录彼此是隔离的。

UTS 命名空间

UTS(UNIX Time-sharing System)命名空间允许每个容器拥有独立的主机名和域名,从而可以虚拟出一个有独立主机名和网络空间的环境,就跟网络上一台独立的主机一样。

如果没有手动指定主机名,Docker 容器的主机名就是返回的容器 ID 的前 6 字节前缀,否则为指定的主机名:

$ docker run --name ubuntu -d ubuntu:16.04 sleep 9999
d5d5522ab224e67d384446469c0442b5edb28fce39d743cec8e3575168b26b78
$ docker inspect -f {{".Config.Hostname"}} ubuntu
d5d5522ab224
$ docker run --hostname container_ubuntu --name ubuntu -d ubuntu:16.04 sleep 9999
4d82294fa2841378cb40cfa1f2a9ed21785a8a9cc0da6c6d18bc357f5544e779
$ docker inspect -f {{".Config.Hostname"}} ubuntu
container_ubuntu
$ docker exec -it 4d82294fa28 bash -c 'hostname'
container_ubuntu

用户命名空间

每个容器可以有不同的用户和组 ID,也就是说,可以在容器内使用特定的内部用户执行程序,而非本地系统上存在的用户。

每个容器内部都可以有最高权限的 root 账号,但跟宿主机不在一个命名空间。通过使用隔离的用户命名空间,可以提高安全性,避免容器内的进程获取到额外的权限;同事通过使用不同用户也可以进一步在容器内控制权限。

控制组

在使用 Docker 运行容器时,一台主机上可能会运行几百个容器,这些容器虽然互相隔离,但是底层却使用着相同的 CPU、内存和磁盘资源。如果不对容器使用的资源进行限制,那么容器之间会互相影响,小的来说会导致容器资源使用不公平;大的来说,可能会导致主机和集群资源耗尽,服务完全不可用。

正如使用内核的 namespace 来做容器之间的隔离,Docker 也是通过内核的 cgroups 来做容器的资源限制。

控制组是 Linux 内核的特性,主要用来对共享资源进行隔离、限制、审计等。

每个控制组是一组对资源的限制,支持层级化结构。我们查看一下系统支持的控制组:

$ cat /proc/cgroups
#subsys_name    hierarchy       num_cgroups     enabled
cpuset                  6                    62                        1
cpu                       7                    250                      1
cpuacct                7                    250                      1
memory                8                    251                      1
devices                10                  251                       1
freezer                 5                    62                        1
net_cls                 4                    62                        1
blkio                     9                    250                      1
perf_event           2                    62                        1
hugetlb                3                    62                        1
  • cpuset:如果是多核心的 CPU,这个子系统会为 cgroup 任务分配单独的 CPU 和内存。
  • CPU:使用调度程序为 cgroup 任务提供 CPU 的访问。
  • cpuacct:产生 cgroup 任务的 CPU 资源报告。
  • memory:设置每个 cgroup 的内存限制以及产生内存资源报告。
  • devices:允许或拒绝 cgroup 任务对设备的访问。
  • freezer: 暂停和恢复 cgroup 任务。
  • net_cls:标记每个网络包以供 cgroup 方便使用。
  • blkio:限制每个块设备的输入输出。例如:磁盘,光盘以及 usb 等等。
  • perf_event:增加了对每 group 的监测跟踪的能力,即可以监测属于某个特定的 group 的所有线程以及运行在特定 CPU 上的线程。
  • hugetlb:这个子系统主要针对于 HugeTLB 系统进行限制,这是一个大页文件系统。

下面我们将针对CPU、内存和IO限制进行举例说明。

测试环境

宿主机配置:

# 系统
$ uname -a
Linux docker 3.10.0-327.el7.x86_64 #1 SMP Thu Nov 19 22:10:57 UTC 2015 x86_64 x86_64 x86_64 GNU/Linux
# CPU
$ cat /proc/cpuinfo |grep "model name" && cat /proc/cpuinfo |grep "physical id"
model name      : Intel Core Processor (Broadwell)
model name      : Intel Core Processor (Broadwell)
model name      : Intel Core Processor (Broadwell)
model name      : Intel Core Processor (Broadwell)
physical id     : 0
physical id     : 1
physical id     : 2
physical id     : 3
# 内存
$ head -n 4 /proc/meminfo
MemTotal:        8003404 kB
MemFree:          733876 kB
MemAvailable:    4191768 kB
Buffers:          394644 kB

有4个CPU核心可用。

Docker版本:

$ docker version
Client:
 Version:           18.09.3
 API version:       1.39
 Go version:        go1.10.8
 Git commit:        774a1f4
 Built:             Thu Feb 28 06:33:21 2019
 OS/Arch:           linux/amd64
 Experimental:      false
Server: Docker Engine - Community
 Engine:
  Version:          18.09.3
  API version:      1.39 (minimum version 1.12)
  Go version:       go1.10.8
  Git commit:       774a1f4
  Built:            Thu Feb 28 06:02:24 2019
  OS/Arch:          linux/amd64
  Experimental:     false

测试时使用的容器为 polinux/stress,它提供一个压测任务,可以指定消耗的 CPU、内存的参数,基本使用方式如下:

docker run \
  -ti \
  --rm \
  polinux/stress stress \
    --cpu 1 \
    --io 1 \
    --vm 1 \
    --vm-bytes 128M \
    --timeout 1s \
    --verbose

查看资源工具使用了 htop,类似于 top 命令,不过可以在控制台更直观的输出资源占用情况。

还使用了Docker客户端提供的docker stats命令查看容器的资源使用情况。

Docker限制CPU资源

查看Docker创建容器时支持的 CPU 资源限制选项:

$ docker run --help | grep cpu
      --cpu-period int                 Limit CPU CFS (Completely Fair Scheduler) period
      --cpu-quota int                  Limit CPU CFS (Completely Fair Scheduler) quota
  -c, --cpu-shares int                 CPU shares (relative weight)
      --cpus decimal                   Number of CPUs
      --cpuset-cpus string             CPUs in which to allow execution (0-3, 0,1)
  • –cpu-shares 用来设置 CPU 在竞争时的权重比例,资源充足时没有效果
  • –cpuset-cpus 限制容器只能使用某几个核心
  • –cpu-period 和 –cpu-quota 一起使用来限制 CPU 使用上限,1.13 版本后,可以使用 —cpus 来代替

下面分别举例介绍一下。

限制容器CPU使用率

首先,我们尝试使用 —-cpus 来限制容器可用的 CPU 个数(个数这个单位可能不准确,因为 CPU 其实是按分片轮转的,这里可以理解为使用 CPU 百分比上限)。

假如不做限制,直接运行容器,配置使用 4 个 CPU,那么将把宿主机的 CPU 资源耗尽:

$ docker run --rm -it polinux/stress stress --cpu 4

使用 htop 工具查看 CPU 使用情况:

接下来使用 —-cpus 参数将容器的 CPU 限制在 1.5 个:

$ docker run --rm -it --cpus 1.5 polinux/stress stress --cpu 4

再次查看一下CPU使用情况:

4个CPU核心使用率求和约为150%,符合预期。

如果在 1.13 版本以前,需要还不支持 —-cpus 参数,那么需要使用 –cpu-period 和 –cpu-quota 配合来限制 CPU 使用率,他们的单位都是微妙,其中 –cpu-period 是调度周期,默认 100ms,–cpu-quota 为每隔 –cpu-period 分配给容器的 CPU 配额,那么上限可以用如下公式得到:

--cpu = --cpu-period / --cpu-quota

具体含义可以参考CFS Bandwidth Control。

为了实现150%限制,可以使用如下方式:

$ docker run --rm -it --cpu-period=100000 --cpu-quota=150000 polinux/stress stress --cpu 4

查看一下 CPU 使用情况:

4 个 CPU 核心使用率求和约为 150%,符合预期。

下面我们查看一下该容器对应的 cgroup 配置。与 Docker 的 CPU 相关的配置都在宿主机的 /sys/fs/cgroup/cpu/docker 目录下:

$ ls -l /sys/fs/cgroup/cpu/docker
drwxr-xr-x 2 root root 0 5月  28 20:08 1cc14f57a1ef4c04f9174f1d5c6b9d97b1a90a0d45de07371187065d95383071
-rw-r--r-- 1 root root 0 3月   8 17:27 cgroup.clone_children
--w--w--w- 1 root root 0 3月   8 17:27 cgroup.event_control
-rw-r--r-- 1 root root 0 3月   8 17:27 cgroup.procs
-r--r--r-- 1 root root 0 3月   8 17:27 cpuacct.stat
-rw-r--r-- 1 root root 0 3月   8 17:27 cpuacct.usage
-r--r--r-- 1 root root 0 3月   8 17:27 cpuacct.usage_percpu
-rw-r--r-- 1 root root 0 3月   8 17:27 cpu.cfs_period_us
-rw-r--r-- 1 root root 0 3月   8 17:27 cpu.cfs_quota_us
-rw-r--r-- 1 root root 0 3月   8 17:27 cpu.rt_period_us
-rw-r--r-- 1 root root 0 3月   8 17:27 cpu.rt_runtime_us
-rw-r--r-- 1 root root 0 3月   8 17:27 cpu.shares
-r--r--r-- 1 root root 0 3月   8 17:27 cpu.stat
-rw-r