https://ruby-china.org/topics/22004
Docker镜像从这些基础的镜像创建,通过一种简单、具有描述性的步骤,我们称之为 指令(instructions)。 每一个指令会在镜像中创建一个新的层,指令可以包含这些动作:
- 运行一个命令。 RUN
- 增加文件或者文件夹。 ADD, COPY
- 创建一个环境变量。 ENV
- 当运行容器的时候哪些程序会运行。 EXEC, ENTRYPOINT
这些指令存储在Dockerfile
文件中。当你需要建立镜像的时候,Docker可以从Dockerfile
中读取这些指令并且运行,然后返回一个最终的镜像。
Docker容器是如何工作的
一个Docker容器包含了一个操作系统、用户添加的文件和元数据(meta-data)。
我们可以看到,每个容器都是从镜像建立的。镜像告诉Docker容器内包含了什么,当容器启动时运行什么程序,还有许多配置数据。
Docker镜像是只读的。当Docker运行一个从镜像建立的容器,它会在镜像顶部添加一个可读写的层,应用程序可以在这里运行。
当你运行docker容器时发生了什么
不论你使用docker
命令或者是RESTful API,Docker客户端都会告诉Docker守护进程运行一个容器。
$ sudo docker run -i -t ubuntu /bin/bash
让我们来分析这个命令。Docker客户端使用docker
命令来运行,run
参数表名客户端要运行一个新的容器。Docker客户端要运行一个容器需要告诉Docker守护进程的最小参数信息是:
- 这个容器从哪个镜像创建,这里是
ubuntu
,基础的Ubuntu镜像。 - 在容器中要运行的命令,这里是
/bin/bash
,在容器中运行Bash shell。
那么运行这个命令之后在底层发生了什么?
按照顺序,Docker做了这些事情:
- 拉取
ubuntu
镜像: Docker检查ubuntu
镜像是否存在,如果在本地没有该镜像,Docker会从Docker Hub下载。如果镜像已经存在,Docker会使用它来创建新的容器。 - 创建新的容器: 当Docker有了这个镜像之后,Docker会用它来创建一个新的容器。
- 分配文件系统并且挂载一个可读写的层: 容器会在这个文件系统中创建,并且一个可读写的层被添加到镜像中。
- 分配网络/桥接接口: 创建一个允许容器与本地主机通信的网络接口。
- 设置一个IP地址: 从池中寻找一个可用的IP地址并且附加到容器上。
- 运行你指定的程序: 运行指定的程序。
- 捕获并且提供应用输出: 连接并且记录标准输出、输入和错误让你可以看到你的程序是如何运行的。
你现在拥有了一个运行着的容器!从这里开始你可以管理你的容器,与应用交互,应用完成之后,可以停止或者删除你的容器。
命名空间(Namespaces)
Docker充分利用了一项称为namespaces
的技术来提供隔离的工作空间,我们称之为 container(容器)。当你运行一个容器的时候,Docker为该容器创建了一个命名空间集合。
这样提供了一个隔离层,每一个应用在它们自己的命名空间中运行而且不会访问到命名空间之外。
一些Docker使用到的命名空间有:
-
pid
命名空间: 使用在进程隔离(PID: Process ID)。 -
net
命名空间: 使用在管理网络接口(NET: Networking)。 -
ipc
命名空间: 使用在管理进程间通信资源 (IPC: InterProcess Communication)。 -
mnt
命名空间: 使用在管理挂载点 (MNT: Mount)。 -
uts
命名空间: 使用在隔离内核和版本标识 (UTS: Unix Timesharing System)。
群组控制
Docker还使用到了cgroups
技术来管理群组。使应用隔离运行的关键是让它们只使用你想要的资源。这样可以确保在机器上运行的容器都是良民(good multi-tenant citizens)。群组控制允许Docker分享或者限制容器使用硬件资源。例如,限制指定的容器的内容使用。
联合文件系统
联合文件系统(UnionFS)是用来操作创建层的,使它们轻巧快速。Docker使用UnionFS提供容器的构造块。Docker可以使用很多种类的UnionFS包括AUFS, btrfs, vfs, and DeviceMapper。
容器格式
Docker连接这些组建到一个包装中,称为一个 container format(容器格式)。默认的容器格式是libcontainer
。Docker同样支持传统的Linux容器使用LXC。在未来,Docker也许会支持其它的容器格式,例如与BSD Jails 或 Solaris Zone集成。
==================
Docker 的出现一定是因为目前的后端在开发和运维阶段确实需要一种虚拟化技术解决开发环境和生产环境环境一致的问题,通过 Docker 我们可以将程序运行的环境也纳入到版本控制中,排除因为环境造成不同运行结果的可能。
Namespaces
命名空间 (namespaces) 是 Linux 为我们提供的用于分离进程树、网络接口、挂载点以及进程间通信等资源的方法。
在日常使用 Linux 或者 macOS 时,我们并没有运行多个完全分离的服务器的需要,但是如果我们在服务器上启动了多个服务,这些服务其实会相互影响的,每一个服务都能看到其他服务的进程,也可以访问宿主机器上的任意文件,这是很多时候我们都不愿意看到的,我们更希望运行在同一台机器上的不同服务能做到完全隔离,就像运行在多台不同的机器上一样。
在这种情况下,一旦服务器上的某一个服务被入侵,那么入侵者就能够访问当前机器上的所有服务和文件,这也是我们不想看到的,而 Docker 其实就通过 Linux 的 Namespaces 对不同的容器实现了隔离。
Linux 的命名空间机制提供了以下七种不同的命名空间,包括 CLONE_NEWCGROUP
、CLONE_NEWIPC
、CLONE_NEWNET
、CLONE_NEWNS
、CLONE_NEWPID
、CLONE_NEWUSER
和 CLONE_NEWUTS
,
通过这七个选项我们能在创建新的进程时设置新进程应该在哪些资源上与宿主机器进行隔离。
进程
当前机器上有很多的进程正在执行,在上述进程中有两个非常特殊,一个是 pid
为 1 的 /sbin/init
进程,另一个是 pid
为 2 的 kthreadd
进程,这两个进程都是被 Linux 中的上帝进程 idle
创建出来的,其中前者负责执行内核的一部分初始化工作和系统配置,也会创建一些类似 getty
的注册进程,而后者负责管理和调度其他的内核进程。
docker exec :在运行的容器中执行命令
docker exec [OPTIONS] CONTAINER COMMAND [ARG...]
如果我们在当前的 Linux 操作系统下运行一个新的 Docker 容器,并通过 exec
进入其内部的 bash
并打印其中的全部进程,我们会得到以下的结果:
root@iZ255w13cy6Z:~# docker run -it -d ubuntu
b809a2eb3630e64c581561b08ac46154878ff1c61c6519848b4a29d412215e79
root@iZ255w13cy6Z:~# docker exec -it b809a2eb3630 /bin/bash
root@b809a2eb3630:/# ps -ef
UID PID PPID C STIME TTY TIME CMD
root 1 0 0 15:42 pts/0 00:00:00 /bin/bash
root 9 0 0 15:42 pts/1 00:00:00 /bin/bash
root 17 9 0 15:43 pts/1 00:00:00 ps -ef
在新的容器内部执行 ps
命令打印出了非常干净的进程列表,只有包含当前 ps -ef
在内的三个进程,在宿主机器上的几十个进程都已经消失不见了。
当前的 Docker 容器成功将容器内的进程与宿主机器中的进程隔离,如果我们在宿主机器上打印当前的全部进程时,会得到下面三条与 Docker 相关的结果:
UID PID PPID C STIME TTY TIME CMD
root 29407 1 0 Nov16 ? 00:08:38 /usr/bin/dockerd --raw-logs
root 1554 29407 0 Nov19 ? 00:03:28 docker-containerd -l unix:///var/run/docker/libcontainerd/docker-containerd.sock --metrics-interval=0 --start-timeout 2m --state-dir /var/run/docker/libcontainerd/containerd --shim docker-containerd-shim --runtime docker-runc
root 5006 1554 0 08:38 ? 00:00:00 docker-containerd-shim b809a2eb3630e64c581561b08ac46154878ff1c61c6519848b4a29d412215e79 /var/run/docker/libcontainerd/b809a2eb3630e64c581561b08ac46154878ff1c61c6519848b4a29d412215e79 docker-runc
在当前的宿主机器上,可能就存在由上述的不同进程构成的进程树:
这就是在使用 clone(2)
创建新进程时传入 CLONE_NEWPID
,Docker 容器内部的任意进程都对宿主机器的进程一无所知。
containerRouter.postContainersStart
└── daemon.ContainerStart
└── daemon.createSpec
└── setNamespaces
└── setNamespace
Docker 的容器就是使用上述技术实现与宿主机器的进程隔离,当我们每次运行 docker run
或者 docker start
时,都会在下面的方法中创建一个用于设置进程间隔离的 Spec:
func (daemon *Daemon) createSpec(c *container.Container) (*specs.Spec, error) {
s := oci.DefaultSpec()
// ...
if err := setNamespaces(daemon, &s, c); err != nil {
return nil, fmt.Errorf("linux spec namespaces: %v", err)
}
return &s, nil
}
在 setNamespaces
方法中不仅会设置进程相关的命名空间,还会设置与用户、网络、IPC 以及 UTS 相关的命名空间:
func setNamespaces(daemon *Daemon, s *specs.Spec, c *container.Container) error {
// user
// network
// ipc
// uts
// pid
if c.HostConfig.PidMode.IsContainer() {
ns := specs.LinuxNamespace{Type: "pid"}
pc, err := daemon.getPidContainer(c)
if err != nil {
return err
}
ns.Path = fmt.Sprintf("/proc/%d/ns/pid", pc.State.GetPID())
setNamespace(s, ns)
} else if c.HostConfig.PidMode.IsHost() {
oci.RemoveNamespace(s, specs.LinuxNamespaceType("pid"))
} else {
ns := specs.LinuxNamespace{Type: "pid"}
setNamespace(s, ns)
}
return nil
}
所有命名空间相关的设置 Spec
最后都会作为 Create
函数的入参在创建新的容器时进行设置:
daemon.containerd.Create(context.Background(), container.ID, spec, createOptions)
所有与命名空间的相关的设置都是在上述的两个函数中完成的,Docker 通过命名空间成功完成了与宿主机进程和网络的隔离。
网络
如果 Docker 的容器通过 Linux 的命名空间完成了与宿主机进程的网络隔离,但是却有没有办法通过宿主机的网络与整个互联网相连,就会产生很多限制,所以 Docker 虽然可以通过命名空间创建一个隔离的网络环境,但是 Docker 中的服务仍然需要与外界相连才能发挥作用。
每一个使用 docker run
启动的容器其实都具有单独的网络命名空间,Docker 为我们提供了四种不同的网络模式,Host、Container、None 和 Bridge 模式。
网桥是一种软件配置,用于连结两个或更多个不同网段。网桥的行为就像是一台虚拟的网络交换机,工作于透明模式(即其他机器不必关注网桥的存在与否)。任意的真实物理设备(例如 eth0
)和虚拟设备(例如 tap0
)都可以连接到网桥。
在这一部分,我们将介绍 Docker 默认的网络设置模式:网桥模式。
在这种模式下,除了分配隔离的网络命名空间之外,Docker 还会为所有的容器设置 IP 地址。当 Docker 服务器在主机上启动之后会创建新的虚拟网桥 docker0,随后在该主机上启动的全部服务在默认情况下都与该网桥相连。
[root@10.20.10.131 ~]# ifconfig
docker0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST> mtu 1500
inet 172.17.0.1 netmask 255.255.0.0 broadcast 0.0.0.0
inet6 fe80::42:89ff:fe30:a444 prefixlen 64 scopeid 0x20<link>
ether 02:42:89:30:a4:44 txqueuelen 0 (Ethernet)
RX packets 11183689 bytes 5377465390 (5.0 GiB)
RX errors 0 dropped 0 overruns 0 frame 0
TX packets 11931482 bytes 5614631872 (5.2 GiB)
TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0
veth4064526: flags=4163<UP,BROADCAST,RUNNING,MULTICAST> mtu 1500 //一个容器对应一个虚拟网卡
inet6 fe80::147f:7ff:feda:227 prefixlen 64 scopeid 0x20<link>
ether 16:7f:07:da:02:27 txqueuelen 0 (Ethernet)
RX packets 7 bytes 586 (586.0 B)
RX errors 0 dropped 0 overruns 0 frame 0
TX packets 14 bytes 1192 (1.1 KiB)
TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0
veth1d01cf9: flags=4163<UP,BROADCAST,RUNNING,MULTICAST> mtu 1500 //一个容器对应一个虚拟网卡
inet6 fe80::e0b2:5dff:fe75:54bb prefixlen 64 scopeid 0x20<link>
ether e2:b2:5d:75:54:bb txqueuelen 0 (Ethernet)
RX packets 5 bytes 426 (426.0 B)
RX errors 0 dropped 0 overruns 0 frame 0
TX packets 5 bytes 426 (426.0 B)
TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0
在默认情况下,每一个容器在创建时都会创建一对虚拟网卡,两个虚拟网卡组成了数据的通道,其中一个会放在创建的容器中,会加入到名为 docker0 网桥中。我们可以使用如下的命令来查看当前网桥的接口:
$ brctl show
bridge name bridge id STP enabled interfaces
docker0 8000.0242a6654980 no veth3e84d4f
veth9953b75
docker0 会为每一个容器分配一个新的 IP 地址并将 docker0 的 IP 地址设置为默认的网关。网桥 docker0 通过 iptables 中的配置与宿主机器上的网卡相连,所有符合条件的请求都会通过 iptables 转发到 docker0 并由网桥分发给对应的机器。
$ iptables -t nat -L
Chain PREROUTING (policy ACCEPT)
target prot opt source destination
DOCKER all -- anywhere anywhere ADDRTYPE match dst-type LOCAL
Chain DOCKER (2 references)
target prot opt source destination
RETURN all -- anywhere anywhere
我们在当前的机器上使用 docker run -d -p 6379:6379
命令启动了一个新的 Redis 容器,在这之后我们再查看当前 iptables
的 NAT 配置就会看到在 DOCKER
的链中出现了一条新的规则:
DNAT tcp -- anywhere anywhere tcp dpt:6379 to:192.168.0.4:6379
上述规则会将从任意源发送到当前机器 6379 端口的 TCP 包转发到 192.168.0.4:6379 所在的地址上。
这个地址其实也是 Docker 为 Redis 服务分配的 IP 地址,如果我们在当前机器上直接 ping 这个 IP 地址就会发现它是可以访问到的:
$ ping 192.168.0.4
PING 192.168.0.4 (192.168.0.4) 56(84) bytes of data.
64 bytes from 192.168.0.4: icmp_seq=1 ttl=64 time=0.069 ms
64 bytes from 192.168.0.4: icmp_seq=2 ttl=64 time=0.043 ms
^C
--- 192.168.0.4 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 999ms
rtt min/avg/max/mdev = 0.043/0.056/0.069/0.013 ms
从上述的一系列现象,我们就可以推测出 Docker 是如何将容器的内部的端口暴露出来并对数据包进行转发的了;
当有 Docker 的容器需要将服务暴露给宿主机器(-p 81:80),就会为容器分配一个 IP 地址,同时向 iptables 中追加一条新的规则。 通过iptables -t nat -L可查看
如果没显示暴露IP, 则iptables -t nat -L是查不到的
通过 Linux 的命名空间为新创建的进程隔离了文件系统、网络并与宿主机器之间的进程相互隔离,,但是命名空间并不能够为我们提供物理资源上的隔离,比如 CPU 或者内存
如何对多个容器的资源使用进行限制就成了解决进程虚拟资源隔离之后的主要问题,而 Control Groups(简称 CGroups)就是能够隔离宿主机器上的物理资源,例如 CPU、内存、磁盘 I/O 和网络带宽。
UnionFS
Linux 的命名空间和控制组分别解决了不同资源隔离的问题,前者解决了进程、网络以及文件系统的隔离,后者实现了 CPU、内存等资源的隔离,但是在 Docker 中还有另一个非常重要的问题需要解决 - 也就是镜像。
镜像到底是什么,它又是如何组成和组织的是作者使用 Docker 以来的一段时间内一直比较让作者感到困惑的问题,我们可以使用 docker run
非常轻松地从远程下载 Docker 的镜像并在本地运行。
Docker 镜像其实本质就是一个压缩包,我们可以使用下面的命令将一个 Docker 镜像中的文件导出:
$ docker export $(docker create busybox) | tar -C rootfs -xvf -
$ ls
bin dev etc home proc root sys tmp usr var
你可以看到这个 busybox 镜像中的目录结构与 Linux 操作系统的根目录中的内容并没有太多的区别,可以说 Docker 镜像就是一个文件。
存储驱动
Docker 使用了一系列不同的存储驱动管理镜像内的文件系统并运行容器,这些存储驱动与 Docker 卷(volume)有些不同,存储引擎管理着能够在多个容器之间共享的存储。
想要理解 Docker 使用的存储驱动,我们首先需要理解 Docker 是如何构建并且存储镜像的,也需要明白 Docker 的镜像是如何被每一个容器所使用的;Docker 中的每一个镜像都是由一系列只读的层组成的,Dockerfile 中的每一个命令都会在已有的只读层上创建一个新的层:
FROM ubuntu:15.04
COPY . /app
RUN make /app
CMD python /app/app.py
容器中的每一层都只对当前容器进行了非常小的修改,上述的 Dockerfile 文件会构建一个拥有四层 layer 的镜像:
当镜像被 docker run
命令创建时就会在镜像的最上层添加一个可写的层,也就是容器层,所有对于运行时容器的修改其实都是对这个容器读写层的修改。
容器和镜像的区别就在于,所有的镜像都是只读的,而每一个容器其实等于镜像加上一个可读写的层,也就是同一个镜像可以对应多个容器。
AUFS
UnionFS 其实是一种为 Linux 操作系统设计的用于把多个文件系统『联合』到同一个挂载点的文件系统服务。而 AUFS 即 Advanced UnionFS 其实就是 UnionFS 的升级版,它能够提供更优秀的性能和效率。
AUFS 作为联合文件系统,它能够将不同文件夹中的层联合(Union)到了同一个文件夹中,这些文件夹在 AUFS 中称作分支,整个『联合』的过程被称为联合挂载(Union Mount):
每一个镜像层或者容器层都是 /var/lib/docker/
目录下的一个子文件夹;在 Docker 中,所有镜像层和容器层的内容都存储在 /var/lib/docker/aufs/diff/
目录中:
$ ls /var/lib/docker/aufs/diff/00adcccc1a55a36a610a6ebb3e07cc35577f2f5a3b671be3dbc0e74db9ca691c 93604f232a831b22aeb372d5b11af8c8779feb96590a6dc36a80140e38e764d8
00adcccc1a55a36a610a6ebb3e07cc35577f2f5a3b671be3dbc0e74db9ca691c-init 93604f232a831b22aeb372d5b11af8c8779feb96590a6dc36a80140e38e764d8-init
019a8283e2ff6fca8d0a07884c78b41662979f848190f0658813bb6a9a464a90 93b06191602b7934fafc984fbacae02911b579769d0debd89cf2a032e7f35cfa
...
而 /var/lib/docker/aufs/layers/
中存储着镜像层的元数据,每一个文件都保存着镜像层的元数据,最后的 /var/lib/docker/aufs/mnt/
包含镜像或者容器层的挂载点,最终会被 Docker 通过联合的方式进行组装。
上面的这张图片非常好的展示了组装的过程,每一个镜像层都是建立在另一个镜像层之上的,同时所有的镜像层都是只读的,只有每个容器最顶层的容器层才可以被用户直接读写,所有的容器都建立在一些底层服务(Kernel)上,包括命名空间、控制组、rootfs 等等,这种容器的组装方式提供了非常大的灵活性,只读的镜像层通过共享也能够减少磁盘的占用。
其他存储驱动
AUFS 只是 Docker 使用的存储驱动的一种,除了 AUFS 之外,Docker 还支持了不同的存储驱动,包括 aufs
、devicemapper
、overlay2
、zfs
和 vfs
等等,在最新的 Docker 中,overlay2
取代了 aufs
成为了推荐的存储驱动,但是在没有 overlay2
驱动的机器上仍然会使用 aufs
作为 Docker 的默认驱动。
不同的存储驱动在存储镜像和容器文件时也有着完全不同的实现,有兴趣的读者可以在 Docker 的官方文档 Select a storage driver 中找到相应的内容。
想要查看当前系统的 Docker 上使用了哪种存储驱动只需要使用以下的命令就能得到相对应的信息:
$ docker info | grep Storage
Storage Driver: aufs
作者的这台 Ubuntu 上由于没有 overlay2
存储驱动,所以使用 aufs
作为 Docker 的默认存储驱动。
总结
Docker 目前已经成为了非常主流的技术,已经在很多成熟公司的生产环境中使用,但是 Docker 的核心技术其实已经有很多年的历史了,Linux 命名空间、控制组和 UnionFS 三大技术支撑了目前 Docker 的实现,也是 Docker 能够出现的最重要原因。
作者在学习 Docker 实现原理的过程中查阅了非常多的资料,从中也学习到了很多与 Linux 操作系统相关的知识,不过由于 Docker 目前的代码库实在是太过庞大,想要从源代码的角度完全理解 Docker 实现的细节已经是非常困难的了,但是如果各位读者真的对其实现细节感兴趣,可以从 Docker CE 的源代码开始了解 Docker 的原理。