容器基础

Posted by 聪少 on 2019-04-18

容器基础

容器,其实是一种特殊的进程而已。
容器本身没有价值,有价值的是"容器编排"

容器到底是怎么一回事?
容器其实是一种沙盒技术,就想一个集装箱一样,把应用”装“起来的技术。应用和应用之间就有了边界,可以做到相互不干扰,同理被装入集装箱的容器就可以方便的被搬来搬去,这便是PaaS的核心功能之一。

容器边界实现手段

从进程开始

容器技术的核心功能就是通过约束和修改进程的动态表现,从而为其创造一个"边界"。在Linux中Cgroups技术用来制造约束的主要手段,Namespace则用来修改进程试图的主要方法。

1
2
3
4
5
$ docker run -it busybox /bin/sh
/ # ps
PID USER TIME COMMAND
1 root 0:00 /bin/sh
10 root 0:00 ps
可以看到容器的/bin/sh进程的进程号为1,这是docker使用的障眼法,使得这些进程只能看到重新计算过的进程编号,比如 PID=1。可实际上,他们在宿主机的操作系统里,还是原来的第 100 号。这就是Linux Namespace机制,在Linux中namespace只是系统调用clone的一个参数。
1
2
3
4
## 创建一个新进程
int pid = clone(main_function, stack_size, SIGCHLD, NULL);
## CLONE_NEWPID隔离进程的ID空间(Linux PID namespace最多可以嵌套32层由内核中的宏MAX_PID_NS_LEVEL来定义)
int pid = clone(main_function, stack_size, CLONE_NEWPID | SIGCHLD, NULL);

除了Pid namespace之外,Linux还提供了Mount、UTS、IPC、Network和User Namespace,对各种进程来进行“障眼法”的限制。

在了解了Namesapce的工作方式之后,就会明白Docker和虚拟机的不同,在使用Docker的时候,并没有一个真正的"Docker"容器运行在虚拟机上,而是在原来应用的基础上增加和修改了一些Namespace限制。

home_posts_tag-true

隔离与限制

Namespace 技术实际上修改了应用进程看待整个计算机“视图”,即它的“视线”被操作系统做了限制,只能“看到”某些指定的内容。

home_posts_tag-true

在这个对比图中,Docker应该放在应用同级别的一层,Docker在这里扮演的角色更多的是旁路式辅助和管理工作。

​ 根据实验,在不做任何优化的情况下运行一个CentOS KVM虚拟机,虚拟机本身需要占用200兆左右的内存。二在虚拟机中运行的程序执行的系统调用不可避免的需要进行软件化的拦截和处理,不可避免的有多了一层消耗。 而相比之下,容器化后的用户应用,却依然还是一个宿主机上的普通进程,这就意味着这些因为虚拟化而带来的性能损耗都是不存在的;而另一方面,使用 Namespace 作为隔离手段的容器并不需要单独的 Guest OS,这就使得容器额外的资源占用几乎可以忽略不计。

不过容器的隔离技术也有很多不足的地方:隔离不彻底。

  • 在Linux内核中,有很多资源对象是不能被namespace优化的,最典型的例子就是时间。如果容器程序使用settimeofday(2)系统调用修改时间,那么整个宿主机的时间就会被修改,而在虚拟机中随意折腾。
  • 共享宿主机内核

Linux Cgroups 就是 Linux 内核中用来为进程设置资源限制的一个重要功能。Linux Cgroups 的全称是 Linux Control Group。它最主要的作用,就是限制一个进程组能够使用的资源上限,包括 CPU、内存、磁盘、网络带宽等等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
[root@tyy cgroup]# mount -t cgroup
cgroup on /sys/fs/cgroup/systemd type cgroup (rw,nosuid,nodev,noexec,relatime,xattr,release_agent=/usr/lib/systemd/systemd-cgroups-agent,name=systemd)
cgroup on /sys/fs/cgroup/devices type cgroup (rw,nosuid,nodev,noexec,relatime,devices)
cgroup on /sys/fs/cgroup/pids type cgroup (rw,nosuid,nodev,noexec,relatime,pids)
cgroup on /sys/fs/cgroup/cpuset type cgroup (rw,nosuid,nodev,noexec,relatime,cpuset)
cgroup on /sys/fs/cgroup/hugetlb type cgroup (rw,nosuid,nodev,noexec,relatime,hugetlb)
cgroup on /sys/fs/cgroup/blkio type cgroup (rw,nosuid,nodev,noexec,relatime,blkio)
cgroup on /sys/fs/cgroup/cpu,cpuacct type cgroup (rw,nosuid,nodev,noexec,relatime,cpuacct,cpu)
....
lrwxrwxrwx 1 root root 11 9月 14 2018 cpu -> cpu,cpuacct
[root@tyy cpu]# cd /sys/fs/cgroup/cpu
### cfs_period 和 cfs_quota两个参数需要组合使用,可以用来限制进程在长度为cfs_period 的一段时间内,只能被分配到总量为cfs_quota 的 CPU 时间。
[root@tyy cpu]# ls
cgroup.clone_children cpuacct.usage_percpu cpu.stat
cgroup.event_control cpu.cfs_period_us notify_on_release
cgroup.procs cpu.cfs_quota_us release_agent
cgroup.sane_behavior cpu.rt_period_us tasks
cpuacct.stat cpu.rt_runtime_us
cpuacct.usage cpu.shares

CPU资源控制测试:

1
2
3
4
5
6
7
8
9
[root@tyy ~]# cd /sys/fs/cgroup/cpu
[root@tyy cpu]# mkdir container
[root@tyy cpu]# cd container/
[root@tyy container]# ls
cgroup.clone_children cpuacct.usage_percpu cpu.shares
cgroup.event_control cpu.cfs_period_us cpu.stat
cgroup.procs cpu.cfs_quota_us notify_on_release
cpuacct.stat cpu.rt_period_us tasks
cpuacct.usage cpu.rt_runtime_us

操作系统会自动在新创建的container目录下面创建子系统对应的资源限制文件。
查看默认cpu资源情况。

1
2
3
4
5
6
### -1为没有限制
$ cat /sys/fs/cgroup/cpu/container/cpu.cfs_quota_us
-1
### 在每100000单位us的时间内只能使用xx单位的cpu
$ cat /sys/fs/cgroup/cpu/container/cpu.cfs_period_us
100000

运行下面脚本

1
2
3
4
5
6
7
8
9
10
11
12
[root@tyy container]# while : ; do : ; done &
[1] 13058
Tasks: 68 total, 2 running, 66 sleeping, 0 stopped, 0 zombie
%Cpu(s):100.0 us, 0.0 sy, 0.0 ni, 0.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 s
KiB Mem : 1883492 total, 299088 free, 257752 used, 1326652 buff/cache
KiB Swap: 0 total, 0 free, 0 used. 1434644 avail Mem

PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
13058 root 20 0 115596 584 164 R 99.9 0.0 0:27.15 bash
19410 root 10 -10 32612 4340 2776 S 0.3 0.2 21:50.35 AliYunDun+
1 root 20 0 43288 3588 2344 S 0.0 0.2 1:43.66 systemd
2 root 20 0 0 0 0 S 0.0 0.0 0:00.03 kthreadd

后台执行上面的脚本,使用top命令查看,发现可以轻松的将cpu跑到100%。下面我们来修改一下cpu资源限制。

1
2
3
4
5
6
7
8
9
10
11
12
13
[root@tyy container]# echo 20000 > /sys/fs/cgroup/cpu/container/cpu.cfs_quota_us
[root@tyy container]# echo 13058 > /sys/fs/cgroup/cpu/container/tasks
[root@tyy container]# top
top - 16:05:28 up 204 days, 1:28, 1 user, load average: 1.29, 0.99, 0.56
Tasks: 68 total, 3 running, 65 sleeping, 0 stopped, 0 zombie
%Cpu(s): 20.2 us, 0.3 sy, 0.0 ni, 79.5 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 s
KiB Mem : 1883492 total, 298964 free, 257828 used, 1326700 buff/cache
KiB Swap: 0 total, 0 free, 0 used. 1434576 avail Mem

PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
13058 root 20 0 115596 584 164 R 20.2 0.0 2:54.37 bash
19462 root 0 -20 134120 17856 9260 S 0.3 0.9 318:35.75 AliYunDun
1 root 20 0 43288 3588 2344 S 0.0 0.2 1:43.66 systemd

执行top查看cpu的资源使用率下降到20%左右。

除此之外cgroups对其它子系统都有独有资源限制的能力

  • blkio 为块设备设置I/O限制,如硬盘等设备。
  • cpuset 为进程分配单独的CPU核和对应的内存节点。
  • memory 为进程设置内存使用限制。

​ 对于Linux下Docker容器项目来说,它只需要在每个子系统下面为每个容器创建一个控制组(即一个新文件夹),然后在容器启动之后将这个进程的PID写入tasks文件中便可以了。

Docker文件系统

下面代码演示了docker文件系统如何挂载

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
#define _GNU_SOURCE
#include <sys/mount.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <stdio.h>
#include <sched.h>
#include <signal.h>
#include <unistd.h>
#define STACK_SIZE (1024 * 1024)
static char container_stack[STACK_SIZE];
char* const container_args[] = {
"/bin/bash",
NULL
};

int container_main(void* arg)
{
printf("进入模拟容器!\n");
// 如果你的机器的根目录的挂载类型是 shared,那必须先重新挂载根目录
// mount("", "/", NULL, MS_PRIVATE, "");
// 在容器进程启动之前加上挂载语句。就这样,我告诉了容器以 tmpfs(内存盘)格式,重新挂载了 /tmp 目录。
mount("none", "/tmp", "tmpfs", 0, "");
execv(container_args[0], container_args);
printf("Something's wrong!\n");
return 1;
}

int main()
{
printf("准备启动一个容器!\n");
int container_pid = clone(container_main, container_stack+STACK_SIZE, CLONE_NEWNS |CLONE_NEWNET | SIGCHLD , NULL);
waitpid(container_pid, NULL, 0);
printf("退出容器\n");
return 0;
}

通常情况下我们期望看到的文件系统是一个独立的隔离空间,而不是继承宿主机的文件系统。所以我们只需要在容器进程启动之前重新挂载/目录。在Linux中使用chroot命令在shell中方便的完成这项工作。

1
2
3
4
5
6
7
8
T=$HOME/c/testroot
mkdir -p $HOME/c/testroot
mkdir -p $HOME/c/testroot/{bin,lib64,lib}
cd $T
cp -v /bin/{bash,ls} $HOME/c/testroot/bin
list="$(ldd /bin/ls | egrep -o '/lib.*\.[0-9]')"
for i in $list; do cp -v "$i" "${T}${i}"; done
chroot $HOME/c/testroot /bin/bash

三个步骤总结Docker工作

  • 启用Linux Namespace配置

  • 设置指定的Cgroups参数

  • 切换进程的根目录(Change Root)

    注意rootfs只是操作系统所包含的文件、配置和目录,并不会加载系统内核,在Linux系统中这两部分是分开存放的。之后在操作系统启动的时候才会去加载内核。Docker实际上是共享宿主机内核。如果你部署的应用程序需要配置内核参数、加载额外的内核模块,以及跟内核进行直接的交互,那么就要小心是否会影响到其他的容器。

Docker网络

Go网络隔离示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import (
"log"
"os"
"os/exec"
"syscall"
)

func main() {
// 指定被fork()出来的新进程内的初始化进程
cmd := exec.Command("sh")
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWNET,
}
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr

if err := cmd.Run(); err != nil {
log.Fatal(err)
}
os.Exit(-1)
}

Docker自身的4种网络工作方式

  • Host: 直接使用主机的宿主机的IP和端口。
  • Container:创建的容器不会创建自己的网卡,配置自己的IP,而是和一个指定的容器共享IP、端口范围。
  • None:该模式关闭了容器的网络功能。
  • Bridge:此模式会为每一个容器分配、设置IP等,并将容器连接到一个docker0虚拟网桥,通过docker0网桥以及Iptables nat表配置与宿主机通信。

Host模式

由于容器和宿主机共享同一个网络命名空间,换言之,容器的IP地址即为宿主机的IP地址。所以容器可以和宿主机一样,使用宿主机的任意网卡,实现和外界的通信。其网络模型可以参照下图:

home_posts_tag-true

采用host模式的容器,可以直接使用宿主机的IP地址与外界进行通信,若宿主机具有公有IP,那么容器也拥有这个公有IP。同时容器内服务的端口也可以使用宿主机的端口,无需额外进行NAT转换,而且由于容器通信时,不再需要通过linux bridge等方式转发或者数据包的拆封,性能上有很大优势。当然,这种模式有优势,也就有劣势

  • 容器不再拥有隔离、独立的网络栈
  • 容器内部将不再拥有所有的端口资源

bridge模式

bridge模式是docker默认的,也是开发者最常使用的网络模式。在这种模式下,docker为容器创建独立的网络栈,保证容器内的进程使用独立的网络环境,实现容器之间、容器与宿主机之间的网络栈隔离。同时,通过宿主机上的docker0网桥,容器可以与宿主机乃至外界进行网络通信。其网络模型可以参考下图:

home_posts_tag-true

从该网络模型可以看出,容器从原理上是可以与宿主机乃至外界的其他机器通信的。同一宿主机上,容器之间都是连接到docker0这个网桥上的,它可以作为虚拟交换机使容器可以相互通信。然而,由于宿主机的IP地址与容器veth pair的 IP地址均不在同一个网段,故仅仅依靠veth pair和namespace的技术,还不足以使宿主机以外的网络主动发现容器的存在。为了使外界可以方位容器中的进程,docker采用了端口绑定的方式,也就是通过iptables的NAT,将宿主机上的端口端口流量转发到容器内的端口上。

举一个简单的例子,使用下面的命令创建容器,并将宿主机的3306端口绑定到容器的3306端口:

1
docker run --name pwc-mysql -e MYSQL_ROOT_PASSWORD=123456 -p 3306:3306 -d mysql

在宿主机上,可以通过iptables -t nat -L -n,查到一条DNAT规则:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
[root@tyy ~]# iptables -t nat -L -n
Chain PREROUTING (policy ACCEPT)
target prot opt source destination
DOCKER all -- 0.0.0.0/0 0.0.0.0/0 ADDRTYPE match dst-type LOCAL

Chain INPUT (policy ACCEPT)
target prot opt source destination

Chain OUTPUT (policy ACCEPT)
target prot opt source destination
DOCKER all -- 0.0.0.0/0 !127.0.0.0/8 ADDRTYPE match dst-type LOCAL

Chain POSTROUTING (policy ACCEPT)
target prot opt source destination
MASQUERADE all -- 172.17.0.0/16 0.0.0.0/0
MASQUERADE tcp -- 172.17.0.2 172.17.0.2 tcp dpt:3306

Chain DOCKER (2 references)
target prot opt source destination
RETURN all -- 0.0.0.0/0 0.0.0.0/0
DNAT tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:3306 to:172.17.0.2:3306

上面的172.17.0.5即为bridge模式下,创建的容器IP。

snat,dnat就是对数据包的源地址和目的地址进行修改,并且保存修改前后的映射关系,并且根据需要进行还原操作。

snat:出去的时候改变原地址(snat),回来的时候改变目的地址(un_snat)
dnat:进来的时候改变目的地址(dnat),出去的时候改变源地址(un_dnat)

bridge模式的容器与外界通信时,必定会占用宿主机上的端口,从而与宿主机竞争端口资源,对宿主机端口的管理会是一个比较大的问题。同时,由于容器与外界通信是基于三层上iptables NAT,性能和效率上的损耗是可以预见的。

跨主机通信