对 Docker 的使用大部分都比较熟悉,但是说到 docker 的实现原理很多人还是一知半解。我把在团队内部做的一次 Docker 核心原理分享总结到文章里,以供参考。
Docker 的优势
Build once, Run anywhere
上面这句话很精辟的总结了 docker 的优点。我从下面几点具体描述 docker 带给开发者的能力
- 应用标准化
无论什么语言开发的应用,我们都能用 dockerfile 和构建脚本方便的进行应用构建打包,代码库 + 构建 + registry 统一了 CI/CD 流程,也提升了效率。
- 环境一致
由于应用和依赖全部构建成镜像,做到了一次构建多次交付,无论是开发,测试还是上线环境都是一致的。大大提高了开发效率
- 应用隔离
由于通过 docker 部署的应用,容器之间相互隔离,并且能按需分配资源。大大提高了运维效率和资源利用率
架构
Docker使用了 C/S 体系架构,Docker 客户端与 Docker 守护进程通信,Docker 守护进程负责构建,运行和分发 Docker 容器。Docker 客户端和守护进程可以在同一个系统上运行,也可以将Docker客户端连接到远程Docker守护进程。
我们日常在命令行的操作 docker build, docker push, docker pull, docke build
等等操作都是客户端通过 rest api 请求与 Docker 守护进程交互。
实现原理
下面我们就介绍一下 Docker 在实现隔离,资源控制,文件系统等关键部分所采用的的技术
Namespace
Linux manaul page 很好的介绍了 namespace 的作用。
namespace提供了一种内核级别隔离系统资源的方法,通过将系统的全局资源放在不同的 namespace 中,来实现资源隔离的目的。不同 namespace 的进程拥有相互隔离的系统资源。
这里指的资源隔离包含以下这些:
- Mount: 隔离文件系统挂载点
- UTS: 隔离主机名和域名信息
- IPC: 隔离进程间通信
- PID: 隔离进程的 ID
- Network: 隔离网络资源
- User: 隔离用户和用户组
通过下面的 clone
系统调用可以创建新的进程,参数 flags
控制创建的进程所属的 namespace, 多个 flags
可以同时创建多个 namespace。
1
|
int clone(int (*child_func)(void *), void *child_stack, int flags, void *arg);
|
我们知道容器本质上就是隔离的进程,Docker 在创建容器的时候就是使用 namespace 来实现了容器与容器,容器与宿主机的隔离。
每个进程都有一个 /proc/[pid]/ns 的目录,里面保存了该进程所在对应 namespace 的链接, 我们来查看某个容器也就是某一个进程对应的 namespace 文件描述
1
2
3
4
5
6
7
8
9
10
|
root@lxkaka-server:~# ls -l /proc/23204/ns
total 0
lrwxrwxrwx 1 root root 0 Jan 9 13:51 cgroup -> 'cgroup:[4026531835]'
lrwxrwxrwx 1 root root 0 Jan 20 12:11 ipc -> 'ipc:[4026532341]'
lrwxrwxrwx 1 root root 0 Jan 20 12:11 mnt -> 'mnt:[4026532339]'
lrwxrwxrwx 1 root root 0 Jul 9 2020 net -> 'net:[4026532344]'
lrwxrwxrwx 1 root root 0 Jan 20 12:11 pid -> 'pid:[4026532342]'
lrwxrwxrwx 1 root root 0 Jan 9 13:51 pid_for_children -> 'pid:[4026532342]'
lrwxrwxrwx 1 root root 0 Jan 9 13:51 user -> 'user:[4026531837]'
lrwxrwxrwx 1 root root 0 Jan 20 12:11 uts -> 'uts:[4026532340]'
|
每个文件都是对应 namespace 的文件描述符,方括号里面的值是 namespace 的 inode,如果两个进程所在的 namespace 一样,那么它们列出来的 inode 是一样的。
我们对比一下另外一个宿主机上的进程
1
2
3
4
5
6
7
8
9
10
|
root@lxkaka-server:~# ls -l /proc/20/ns
total 0
lrwxrwxrwx 1 root root 0 Jan 20 12:19 cgroup -> 'cgroup:[4026531835]'
lrwxrwxrwx 1 root root 0 Jan 20 12:19 ipc -> 'ipc:[4026531839]'
lrwxrwxrwx 1 root root 0 Jan 20 12:19 mnt -> 'mnt:[4026531840]'
lrwxrwxrwx 1 root root 0 Jan 20 12:19 net -> 'net:[4026531993]'
lrwxrwxrwx 1 root root 0 Jan 20 12:19 pid -> 'pid:[4026531836]'
lrwxrwxrwx 1 root root 0 Jan 20 12:19 pid_for_children -> 'pid:[4026531836]'
lrwxrwxrwx 1 root root 0 Jan 20 12:19 user -> 'user:[4026531837]'
lrwxrwxrwx 1 root root 0 Jan 20 12:19 uts -> 'uts:[4026531838]'
|
可以看到进程,文件系统,网络,进程通信,主机名都是不同的 namespace。
网络模式
Docker 虽然可以通过命名空间创建一个隔离的网络环境,但是如果我们的应用需要对外提供服务,不能与外界进行通信是没有意义的。Docker 提供了多种网络模式来实现容器和外部的通信。
我们重点介绍一下 docker 默认的网络模式 bridge
守护进程会创建一对对等虚拟设备接口 veth pair,将其中一个接口设置为容器的 eth0 接口(容器的网卡),另一个接口放置在宿主机的命名空间中,以类似 vethxxx 这样的名字命名,从而将宿主机上的所有容器都连接到这个内部网络上。虚拟网桥的工作方式和物理交换机类似,这样主机上的所有容器就通过交换机连在了一个二层网络中。
- 容器访问外部网络
请求通过 veth pair 到达 docker0 处,docker0 网桥开启了IP forwarding功能,将请求发送至宿主机eth0;
宿主机处理请求时,使用 SNAT 规则,将源地址替换成了宿主机 ip,然后把报文转发出去
- 外部访问容器
docker run -p 时,docker 实际是在 iptables 做了DNAT规则,实现端口转发功能。
1
2
3
4
5
|
# iptables -t nat -L
Chain DOCKER (2 references)
target prot opt source destination
RETURN all -- anywhere anywhere
DNAT tcp -- anywhere anywhere tcp dpt:8888 to:172.17.0.4:8888
|
外部请求访问访问地址为 宿主机ip:8888,网络包到达 eth0;
命中 iptables dnat 规则,把宿主机ip:8888 替换成 容器ip:8888;
宿主机把报文通过 veth pair 传递到容器 eth0
其他网络模式
- Host 模式
和宿主机共用一个 Network Namespace。容器将不会虚拟出自己的网卡,配置自己的 IP 等,而是使用宿主机的 IP 和端口。但是,容器的其他方面,如文件系统、进程列表等还是和宿主机隔离的
- Contanier 模式
这个模式指定新创建的容器和已经存在的一个容器共享一个 Network Namespace,而不是和宿主机共享。新创建的容器不会创建自己的网卡,配置自己的 IP,而是和一个指定的容器共享 IP、端口范围等
- None 模式
使用none模式,Docker 容器拥有自己的 Network Namespace,但是,并不为Docker 容器进行任何网络配置。也就是说,这个 Docker 容器没有网卡、IP、路由等信息。需要我们自己为 Docker 容器添加网卡、配置 IP 等
Cgroups
通过 Linux Namespace 为新创建的进程隔离了文件系统、网络并与宿主机器之间的进程相互隔离,但是 Namespace 并不能够为我们提供物理资源上的隔离,比如 CPU 或者内存。所以 Docker 还借助了 Linux Cgroups 来达到上述目的。
CGroup 全称 Linux Control Group, 是 Linux 内核的一个功能,用来限制,控制与分离一个进程组群的资源(如CPU、内存、磁盘输入输出等)
一组按照某种标准划分的进程,其表示了某进程组,Cgroups 中的资源控制都是以控制组为单位实现,一个进程可以加入到某个控制组。而资源的限制是定义在这个组上,简单点说,cgroup 的呈现就是一个目录带一系列的可配置文件。
理解 cgroups 的几个关键字
- cgroup 进程组
进程按照某种标准组织成一个控制组,资源的控制定义在这个组上,新加入的进程就继承该组的配置。比如 docker 启动的容器都加入 docker 这个进程组
- subsystem 子系统
cgroups 为每种可以控制的资源定义了一个子系统(即资源控制器)
1
2
3
4
5
6
7
8
|
root@lxkaka-server:~# lssubsys
cpuset # 分配单独的 cpu 节点或者内存节点
cpu,cpuacct # 限制进程的 cpu 使用率;cpu 使用统计
blkio # 限制进程的块设备 io
memory # 限制进程的 memory 使用量
devices # 控制进程能够访问某些设备
freezer # 挂起或者恢复 cgroups 中的进程。
net_cls,net_prio # 可以标记 cgroups 中进程的网络数据包,对数据包进行控制
|
- hierarch 层级关系
由一系列控制组以一个树状结构排列而成,hierarch 通过绑定对应的子系统进行资源调度。hierarch 中的 cgroup 节点可以包含零或多个子节点,子节点继承父节点的属性。整个系统可以有多个hierarchy。
配置示例
docker run -d --cpus=0.1 --memory=100MB busybox
1
2
3
4
5
|
root@lxkaka-server:/sys/fs/cgroup/cpu/docker/ee43bb16af9947ed9e8498ad42c859318e001365043e4cda410f68b4a0d79378# cat cpu.cfs_quota_us
10000
root@lxkaka-server:/sys/fs/cgroup/memory/docker/ee43bb16af9947ed9e8498ad42c859318e001365043e4cda410f68b4a0d79378# cat memory.limit_in_bytes
104857600
|
文件驱动
Docker 中的每一个镜像都是由一系列只读的层组成的,Dockerfile 中的每一个命令都会在已有的只读层上创建一个新的层。当镜像被 docker run 命令创建时就会在镜像的最上层添加一个可写的层,也就是容器层,所有对于运行时容器的修改其实都是对这个容器读写层的修改。容器和镜像的区别就在于,所有的镜像都是只读的,而每一个容器其实等于镜像加上一个可读写的层,也就是同一个镜像可以对应多个容器。
这种分层的逻辑是什么呢?
这就是docker 里文件驱动的职责,负责镜像和容器的文件系统组织。
目前 docker 默认的文件驱动是 overlay2
, 它是基于 Linux OverlayFS 的,下面这张图映射了 docker 容器的文件层级结构和 OverlayFS 的对应关系。
OverlayFS 是一种堆叠文件系统,它依赖并建立在其它的文件系统之上,不直接参与磁盘空间结构的划分,仅将原来文件系统中不同目录和文件进行 merge。用户看到就是这个 merged 目录。这些被处理的每个目录都被称为层,视图统一的过程则称为联合挂载。多个目录进行层叠,肯定具有上下层关系,OverlayFS 将下层的目录称为lowerdir,上层的目录称为upperdir,被暴露的统一视图目录称为 merged。
容器文件系统层级示例
1
2
3
4
5
6
7
8
9
10
11
12
|
root@lxkaka-server:~# docker inspect a2b1e73dda7f
...
"GraphDriver": {
"Data": {
"LowerDir": "/var/lib/docker/overlay2/77a2b28678d5406b8184e213a725bac51e4bb0132cb2c2c7928c9805bbeb57e3-init/diff:/var/lib/docker/overlay2/2c24c0bcddaf45a0442e2aa1f061e5a10ac0bb327519c4c7ddc11ecb85878bb1/diff:/var/lib/docker/overlay2/4c689dd9bd8baaff479edf0549e7888b754bf9a635d354e4a7f1531bf946f936/diff:/var/lib/docker/overlay2/84b84a32ceb721075356a9f9d4e8f8c3272a3a440f732a639993293a221236cd/diff:/var/lib/docker/overlay2/fd803c843f13047fd21fda7270a8bec065a4ba62310f95aa5524ec3156755e25/diff:/var/lib/docker/overlay2/fe43a13d9aa35f8b6038d26db6fa83bbd9a108fa59e9599e520505e9b028105d/diff:/var/lib/docker/overlay2/ec929233dc85f54a5b13e906dc1ebd20328fefdebfe2a411a06b6551c501928c/diff",
"MergedDir": "/var/lib/docker/overlay2/77a2b28678d5406b8184e213a725bac51e4bb0132cb2c2c7928c9805bbeb57e3/merged",
"UpperDir": "/var/lib/docker/overlay2/77a2b28678d5406b8184e213a725bac51e4bb0132cb2c2c7928c9805bbeb57e3/diff",
"WorkDir": "/var/lib/docker/overlay2/77a2b28678d5406b8184e213a725bac51e4bb0132cb2c2c7928c9805bbeb57e3/work"
},
"Name": "overlay2"
},
...
|
我们可以看到 lowerdir 包含了多层,每个镜像层目录中包含了一个文件 link,文件内容则是当前层对应的短标识符;lower 文件指向了其所有的父层,其文件内容则是父层id的短标识符;镜像层的内容则存放在 diff 目录
lowerdir 某层示例
1
2
3
4
5
6
7
|
root@lxkaka-server:/var/lib/docker/overlay2/2c24c0bcddaf45a0442e2aa1f061e5a10ac0bb327519c4c7ddc11ecb85878bb1# ls
diff link lower work
root@lxkaka-server:/var/lib/docker/overlay2/2c24c0bcddaf45a0442e2aa1f061e5a10ac0bb327519c4c7ddc11ecb85878bb1# ls
diff link lower work
root@lxkaka-server:/var/lib/docker/overlay2/2c24c0bcddaf45a0442e2aa1f061e5a10ac0bb327519c4c7ddc11ecb85878bb1# cat link
5NC3EXCFR4DKUVKSTJQNDVB3SWroot@lxkaka-server:/var/lib/docker/overlay2/2c24c0bcddaf45a0442e2aa1f061e5a10ac0bb327519c4c7ddc11ecb85878bb1# cat lower
l/XJFQUCXRFOKWIS2NCRBGZOQ573:l/AOHICFFE7WZQDQIZIGXPZSUVPN:l/4DLC3KZMRJUW4GUIU3PIACENJB:l/55TW2N2I52PI5IQD7U5PCZ5H4O:l/5R5RBP6MZNORLVMAOJHVTWMWRN
|
查看容器层
1
2
3
4
5
6
7
8
|
root@lxkaka-server:/var/lib/docker/overlay2/77a2b28678d5406b8184e213a725bac51e4bb0132cb2c2c7928c9805bbeb57e3# ls diff
root
root@lxkaka-server:/var/lib/docker/overlay2/77a2b28678d5406b8184e213a725bac51e4bb0132cb2c2c7928c9805bbeb57e3# ls merged/
bin boot data dev etc home lib lib64 media mnt opt proc root run sbin srv sys tmp usr var
# 进入到对应的容器
root@a2b1e73dda7f:/# ls
bin boot data dev etc home lib lib64 media mnt opt proc root run sbin srv sys tmp usr var
|
我们可以看到容器的目录结构和 merged
是一致的。
多阶段构建
在文章的最后我们介绍比较实用的优化构建的方法,多阶段构建
下面的 dockerfile 是一个简单的 golang 应用构建过程
1
2
3
4
5
6
7
8
|
FROM golang:1.15.6-alpine3.12
RUN mkdir -p /src
WORKDIR /src
COPY src/ .
RUN go build -o app .
EXPOSE 8000
ENTRYPOINT ["./app"]
|
对于这个例子来说我们其实只需要构建后的二进制包,其他文件包括 golang 我们都是不需要的。
我们可以把上述构建成两个阶段,可以大大减小构建后的镜像大小
1
2
3
4
5
6
7
8
9
10
11
12
|
FROM golang:1.15.6-alpine3.12 as builder
RUN mkdir -p /src
WORKDIR /src
COPY src/ .
RUN go build -o app .
FROM alpine:3.12
COPY --from=builder /src/ .
EXPOSE 8000
ENTRYPOINT ["./app"]
|
1
2
3
4
|
$ docker image ls
REPOSITORY TAG IMAGE ID CREATED SIZE
app-test v2 0fde852a1af1 4 seconds ago 13MB
app-test v1 9d528c34a9f9 15 seconds ago 306MB
|
v2 是多阶段构建出来的可以看到相比 v1 体积减小了20多倍。