谈一手:细说Docker容器

332 阅读9分钟

之前的分享中我们分别从 Linux Namespace 的隔离能力、Linux Cgroups 的限制能力,以及基于 rootfs 的文件系统三个角度,剖析了一个 Linux 容器的核心实现原理。

备注:之所以要强调 Linux 容器,是因为比如 Docker on Mac,以及 Windows Docker(Hyper-V 实现),实际上是基于虚拟化技术实现的,跟我们这个专栏着重介绍的 Linux 容器完全不同。笔者使用的Mac,用Parallels Desktop 装的虚拟机😅

在这篇文章中,我会通过一个实际案例,对之前讲的关于容器基础的所有内容做一次深入的总结和扩展,所以你需要准备一台 Linux 机器,并安装 Docker。

我用golang写了一个web应用,代码非常简单。它唯一的功能是:如果当前环境中有“NAME”这个环境变量,就把它打印在“hello”之后,否则就打印“hello world”

package main

import (
	"net/http"
	"os"
)

func main() {
	name := os.Getenv("NAME")
	if name == "" {
		name = "world"
	}
	http.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte("hello:" + name))
	})

	http.ListenAndServe(":8080", nil)
}

而将这样一个应用容器化的第一步,是制作容器镜像。

相较于之前介绍的制作 rootfs 的过程,Docker 为你提供了一种更便捷的方式,叫作 Dockerfile

# 官方提供的golang开发镜像
FROM golang

# 署名
MAINTAINER Chiron

# 将工作目录设置为go/src
WORKDIR /go/src

# 把当前目录下go文件放到容器镜像的工作目录下
ADD hello_world.go /go/src

# 运行 go build hello_world.go
RUN go build hello_world.go

# 允许外界访问容器的80端口
EXPOSE 8080

# 设置环境变量
ENV NAME WORLD

#设置容器进程为:./hello_world
CMD ["./hello_world"]

通过这个文件的内容,你可以看到 Dockerfile 的设计思想,是使用一些标准的原语(即大写高亮的词语),描述我们所要构建的 Docker 镜像。并且这些原语,都是按顺序处理的。

比如FROM原语,就是指定了“golang”这个官方维护的基础镜像,从未免去了安装go语音的过程。

RUN 原语就是在容器里执行 shell 命令的意思。

WORKDIR,意思是在这一句之后,Dockerfile 后面的操作都以这一句指定的 /go/src 目录作为当前目录。

到了最后的 CMD,意思是 Dockerfile 指定 ./hello_world 为这个容器的进程,这里hello_world的实际路径是/go/src/hello_world

在使用 Dockerfile 时,你可能还会看到一个叫作 ENTRYPOINT 的原语。实际上,它和 CMD 都是 Docker 容器进程启动所必需的参数,完整执行格式是:“ENTRYPOINT CMD”。

默认情况下,Docker 会为你提供一个隐含的 ENTRYPOINT,即:/bin/sh -c。所以,在不指定 ENTRYPOINT 时,比如在我们这个例子里,实际上运行在容器里的完整进程是:/bin/sh -c "./hello_world",即 CMD 的内容就是 ENTRYPOINT 的参数

备注:基于以上原因,我们后面会统一称 Docker 容器的启动进程为 ENTRYPOINT,而不是 CMD。

需要注意的是,Dockerfile 里的原语并不都是指对容器内部的操作。就比如 ADD,它指的是把当前目录(即 Dockerfile 所在的目录)里的文件,复制到指定容器内的目录当中。

读懂这个Dockerfile后,我把上述内容保存到当前目录下的Dockerfile文件中

接下来,我就可以让 Docker 制作这个镜像了,在当前目录执行:

$ docker build -t helloworld .

-t是给这个镜像加一个tag,即取一个好记的名字,docker build 会自动加载当前目录下的Dockerfile文件,然后按照顺序依次执行其中的原语。这个过程,实际上可以等同于 Docker 使用基础镜像启动了一个容器,然后在容器中依次执行 Dockerfile 中的原语。

**需要注意的是,Dockerfile 中的每个原语执行后,都会生成一个对应的镜像层。**即使原语本身并没有明显地修改文件的操作(比如,ENV 原语),它对应的层也会存在。只不过在外界看来,这个层是空的。

执行完之后通过 docker images 命令查看结果

docker images
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
helloworld          latest              ea52727d278f        45 minutes ago      761MB

接下来,我使用这个镜像,通过 docker run 命令启动容器

docker run -p 4000:8080 helloworld

通过 -p 4000:8080 告诉了 Docker,请把容器内的 8080 端口映射在宿主机的 4000 端口上。这样做的目的是,只要访问宿主机的 4000 端口,我就可以看到容器里应用返回的结果,否则,我就得先用 docker inspect 命令查看容器的 IP 地址,然后访问“http://< 容器 IP 地址 >:8080”才可以看到容器内应用的返回。

至此,我们已经使用容器完成了一个应用的开发与测试,现在可以把这个容器的镜像上传到 DockerHub 上分享给更多的人,首先需要注册一个 Docker Hub 账号,然后使用 docker login 命令登录。接下来要用 docker tag 命令给容器镜像起一个完整的名字,最后执行docker push,这样就把一个完整镜像上传到 Docker Hub 上了。

此外,还可以用docker commit命令,把一个正在运行的容器,直接提交为一个镜像,一般来说,需要这么操作原因是:这个容器运行起来后,我又在里面做了一些操作,并且要把操作结果保存到镜像里

fyf-mac@ubuntu:~$ docker exec -it 084674f11102 /bin/sh
# ls
hello-world  hello-world.go
# touch test.txt
# exit
fyf-mac@ubuntu:~$ docker commit 084674f11102 zhangsan/helloworld:v2

其中zhangsan是你自己的 Docker Hub 用户名

这里,我使用了 docker exec 命令进入到了容器当中。在了解了 Linux Namespace 的隔离机制后,docker exec 是怎么做到进入容器里的呢?

实际上,Linux Namespace创建的隔离空间虽然看不见摸不着,但一个进程的Namespace信息在宿主机上是确确实实存在的,并且是以一个文件的方式存在。 通过下面的命令可以查看当前正在运行的docker容器的进程号

fyf-mac@ubuntu:~$ docker inspect --format '{{ .State.Pid }}' 084674f11102 
8490

还可以痛过查看宿主机的proc文件,查看8490这个进程的所以Namespace对应的文件

fyf-mac@ubuntu:~$ sudo ls -l /proc/8490/ns
total 0
lrwxrwxrwx 1 root root 0 Oct 18 14:41 cgroup -> cgroup:[4026531835]
lrwxrwxrwx 1 root root 0 Oct 18 14:15 ipc -> ipc:[4026532584]
lrwxrwxrwx 1 root root 0 Oct 18 14:15 mnt -> mnt:[4026532582]
lrwxrwxrwx 1 root root 0 Oct 18 14:14 net -> net:[4026532587]
lrwxrwxrwx 1 root root 0 Oct 18 14:15 pid -> pid:[4026532585]
lrwxrwxrwx 1 root root 0 Oct 18 14:41 pid_for_children -> pid:[4026532585]
lrwxrwxrwx 1 root root 0 Oct 18 14:41 user -> user:[4026531837]
lrwxrwxrwx 1 root root 0 Oct 18 14:15 uts -> uts:[4026532583]

可以看到,一个进程的每种 Linux Namespace,都在它对应的 /proc/[进程号]/ns 下有一个对应的虚拟文件,并且链接到一个真实的 Namespace 文件上。

有了这样一个可以“hold 住”所有 Linux Namespace 的文件,我们就可以对 Namespace 做一些很有意义事情了,比如:加入到一个已经存在的 Namespace 当中。

这也就意味着:一个进程,可以选择加入到某个进程已有的 Namespace 当中,从而达到“进入”这个进程所在容器的目的,这正是 docker exec 的实现原理。

这个操作依赖的,是一个名叫setns()的Linux系统调用。它的调用方法,用如下一段小程序说明:

#define _GNU_SOURCE
#include <fcntl.h>
#include <sched.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>

#define errExit(msg) do { perror(msg); exit(EXIT_FAILURE);} while (0)

int main(int argc, char *argv[]) {
    int fd;
    
    fd = open(argv[1], O_RDONLY);
    if (setns(fd, 0) == -1) {
        errExit("setns");
    }
    execvp(argv[2], &argv[2]); 
    errExit("execvp");
}

这段代码功能非常简单:它一共接收两个参数,第一个参数是 argv[1],即当前进程要加入的 Namespace 文件的路径,比如 /proc/8490/ns/net;而第二个参数,则是你要在这个 Namespace 里运行的进程,比如 /bin/bash。

这段代码的核心操作,则是通过 open() 系统调用打开了指定的 Namespace 文件,并把这个文件的描述符 fd 交给 setns() 使用。在 setns() 执行后,当前进程就加入了这个文件对应的 Linux Namespace 当中了。

你可以编译执行一下上面这个程序,加入到容器进程(PID=8490)的 Network Namespace 中:

fyf-mac@ubuntu:~$ gcc -o set_ns set_ns.c
fyf-mac@ubuntu:~$ sudo ./set_ns /proc/8490/ns/net /bin/bash
root@ubuntu:~# ifconfig
eth0      Link encap:Ethernet  HWaddr 02:42:ac:11:00:02  
          inet addr:172.17.0.2  Bcast:172.17.255.255  Mask:255.255.0.0
          UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1
          RX packets:94 errors:0 dropped:0 overruns:0 frame:0
          TX packets:38 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:0 
          RX bytes:9629 (9.6 KB)  TX bytes:2852 (2.8 KB)

lo        Link encap:Local Loopback  
          inet addr:127.0.0.1  Mask:255.0.0.0
          UP LOOPBACK RUNNING  MTU:65536  Metric:1
          RX packets:0 errors:0 dropped:0 overruns:0 frame:0
          TX packets:0 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:1000 
          RX bytes:0 (0.0 B)  TX bytes:0 (0.0 B)

当我们执行 ifconfig 命令查看网络设备时,我会发现能看到的网卡“变少”了:只有两个。而我的宿主机则至少有四个网卡。这是怎么回事呢?

实际上,在 setns() 之后我看到的这两个网卡,正是我在前面启动的 Docker 容器里的网卡。也就是说,我新创建的这个 /bin/bash 进程,由于加入了该容器进程(PID=8490)的 Network Namepace,它看到的网络设备与这个容器里是一样的,即:/bin/bash 进程的网络设备视图,也被修改了(只能看到docker里的两个网卡)。

一旦一个进程加入到另一个Namespace当中,在宿主机的Namespace 文件上也有体现,在宿主机上,用 ps 指令找到这个 set_ns 程序执行的 /bin/bash 进程,其真实的 PID 是 24809:

root@ubuntu:~# ps aux | grep /bin/bash
root     24809  0.0  0.2   7020  4676 pts/18   S    15:40   0:00 /bin/bash

此时,我们查一下PID = 24799 的进程的Namespace:

root@ubuntu:~# ls -l /proc/24809/ns/net
lrwxrwxrwx 1 root root 0 Oct 18 16:03 /proc/24809/ns/net -> net:[4026532587]

root@ubuntu:~# ls -l /proc/8490/ns/net
lrwxrwxrwx 1 root root 0 Oct 18 14:14 /proc/8490/ns/net -> net:[4026532587]

在 /proc/[PID]/ns/net 目录下,这个 PID=24809 进程,与我们前面的 Docker 容器进程(PID=8490)指向的 Network Namespace 文件完全一样。这说明这两个进程,共享了这个名叫 net:[4026532587]的 Network Namespace。

此外,Docker 还专门提供了一个参数,可以让你启动一个容器并“加入”到另一个容器的 Network Namespace 里,这个参数就是 -net,比如:

root@ubuntu:~#  docker run -it --net container:084674f11102 busybox ifconfig

这样,我们新启动的这个容器,就会直接加入到 ID=084674f11102 的容器,此时ifconfig返回的网卡信息就跟我们之前那个小程序返回的一模一样。

如果我指定–net=host,就意味着这个容器不会为进程启用 Network Namespace。这就意味着,这个容器拆除了 Network Namespace 的“隔离墙”,所以,它会和宿主机上的其他普通进程一样,直接共享宿主机的网络栈。这就为容器直接操作和使用宿主机网络提供了一个渠道。

说了这么多,其实是解读了 docker exec 这个操作背后,Linux Namespace 更具体的工作原理,这种通过操作系统进程相关的知识,逐步剖析 Docker 容器的方法,是理解容器的一个关键思路。

现在我们再说回 docker commit

docker commit,实际上就是在容器运行起来后,**把最上层的“可读写层”,加上原先容器镜像的只读层(没有init层),打包组成了一个新的镜像。**当然,下面这些只读层在宿主机上是共享的,不会占用额外的空间。

而由于使用了联合文件系统,你在容器里对 镜像rootfs 做的任何修改,都会被操作系统先复制到这个可读写层,然后再修改。这就是所谓的:Copy-on-Write。

正如之前所说,Init 层的存在,就是为了避免你执行 docker commit 时,把 Docker 自己对 /etc/hosts 等文件做的修改,也一起提交掉。

Docker 项目另一个重要的内容:Volume(数据卷)。

容器技术使用了 rootfs 机制(系统文件和目录,大地)和 Mount Namespace(高墙),构建出了一个同宿主机完全隔离开的文件系统环境。这时候,我们就需要考虑这样两个问题:

  • 容器里进程新建的文件,怎么才能让宿主机获取到?
  • 宿主机上的文件和目录,怎么才能让容器里的进程访问到?

这正是 Docker Volume 要解决的问题:Volume 机制,允许你将宿主机上指定的目录或者文件,挂载到容器里面进行读取和修改操作。

在 Docker 项目里,它支持两种 Volume 声明方式,可以把宿主机目录挂载进容器的 /test 目录当中:

$ docker run -v /test ...
$ docker run -v /home:/test ...

而这两种声明方式的本质,实际上是相同的:都是把一个宿主机的目录挂载进了容器的 /test 目录。

只不过,在第一种情况下,由于你并没有显示声明宿主机目录,那么 Docker 就会默认在宿主机上创建一个临时目录 /var/lib/docker/volumes/[VOLUME_ID]/_data,然后把它挂载到容器的 /test 目录上。

而在第二种情况下,Docker 就直接把宿主机的 /home 目录挂载到容器的 /test 目录上。

之前的文章介绍过,当容器进程被创建后,尽管开启了 Mount Namespace,但是在它执行 chroot(或者 pivot_root)之前,容器进程一直可以看到宿主机上的整个文件系统。

而宿主机上的文件系统,也自然包括了我们要使用的容器镜像。这个镜像的各个层,保存在 /var/lib/docker/aufs/diff 目录下,在容器进程启动后,它们会被联合挂载在 /var/lib/docker/aufs/mnt/ 目录中,这样容器所需的 rootfs 就准备好了。

18.06.0版本以上 aufs已经被overlay2代替,

所以,我们只需要在 rootfs 准备好之后,在执行 chroot 之前,把 Volume 指定的宿主机目录(比如 /home 目录),挂载到指定的容器目录(比如 /test 目录)在宿主机上对应的目录(即 /var/lib/docker/aufs/mnt/[可读写层 ID]/test)上,这个 Volume 的挂载工作就完成了。

更重要的是,由于执行这个挂载操作时,“容器进程”已经创建了,也就意味着此时 Mount Namespace 已经开启了。所以,这个挂载事件只在这个容器里可见。你在宿主机上,是看不见容器内部的这个挂载点的。这就保证了容器的隔离性不会被 Volume 打破。

这里提到的"容器进程",是 Docker 创建的一个容器初始化进程 (dockerinit),而不是应用进程 (ENTRYPOINT + CMD)。dockerinit 会负责完成根目录的准备、挂载设备和目录、配置 hostname 等一系列需要在容器内进行的初始化操作。最后,它通过 execv() 系统调用,让应用进程取代自己,成为容器里的 PID=1 的进程。

而这里要使用到的挂载技术,就是 Linux 的绑定挂载(bind mount)机制。 它的主要作用就是,允许你将一个目录或者文件,而不是整个设备,挂载到一个指定的目录上。并且,这时你在该挂载点上进行的任何操作,只是发生在被挂载的目录或者文件上,而原挂载点的内容则会被隐藏起来且不受影响。

如果你了解 Linux 内核的话,就会明白,绑定挂载实际上是一个 inode 替换的过程。 在 Linux 操作系统中,inode 可以理解为存放文件内容的“对象”, 而 dentry,也叫目录项,就是访问这个 inode 所使用的“指针”。

正如上图所示,mount --bind /home /test,会将 /home 挂载到 /test 上。其实相当于将 /test 的 dentry,重定向到了 /home 的 inode。这样当我们修改 /test 目录时,实际修改的是 /home 目录的 inode。这也就是为何,一旦执行 umount 命令,/test 目录原先的内容就会恢复:因为修改真正发生在的,是 /home 目录里。

所以,在一个正确的时机,进行一次绑定挂载,Docker 就可以成功地将一个宿主机上的目录或文件,不动声色地挂载到容器中。

进程在容器里对这个 /test 目录进行的所有操作,都实际发生在宿主机的对应目录(比如,/home,或者 /var/lib/docker/volumes/[VOLUME_ID]/_data)里,而不会影响容器镜像的内容。

并且这个 /test 目录里的内容,虽然挂载在容器 rootfs 的可读写层,但它不会被 docker commit 提交掉。原因如下:

容器的镜像操作,比如 docker commit,都是发生在宿主机空间的。 而由于 Mount Namespace 的隔离作用,宿主机并不知道这个绑定挂载的存在。所以,在宿主机看来,容器中可读写层的 /test 目录(/var/lib/docker/aufs/mnt/[可读写层 ID]/test),始终是空的。

跟着我一起来亲自试验一下

首先,启动一个 helloworld 容器,给它声明一个 Volume,挂载在容器里的 /test 目录上:

➜  ~ docker run -d -v /test helloworld
593590ce78146c3485753864025a53d932d06d9bf2009028bfff8aebb7c29cb7

容器启动之后,我们来查看一下这个 Volume 的 ID:

➜  ~ docker volume ls
DRIVER              VOLUME NAME
local               1ca7ba28596560b4e8d3e2e85b5df0cc7e40ae328c36449f954171be3e671ca8

然后,使用这个 ID,可以找到它在 Docker 工作目录下的 volumes 路径:

root@ubuntu:~# ls /var/lib/docker/volumes/1ca7ba28596560b4e8d3e2e85b5df0cc7e40ae328c36449f954171be3e671ca8/_data

这个 _data 文件夹,就是这个容器的 Volume 在宿主机上对应的临时目录了。接下来,我们在容器的 Volume 里,添加一个文件 text.txt:

docker exec -it 593590ce7814 /bin/sh
# cd /test
# touch text.txt

这时,我们再回到宿主机,就会发现 text.txt 已经出现在了宿主机上对应的临时目录里:

root@ubuntu:/var/lib/docker/volumes/1ca7ba28596560b4e8d3e2e85b5df0cc7e40ae328c36449f954171be3e671ca8/_data# ls
text.txt

可是,如果你在宿主机上查看该容器的可读写层,虽然可以看到这个 /test 目录,但其内容是空的

可以确认,容器 Volume 里的信息,并不会被 docker commit 提交掉;但这个挂载点目录 /test 本身,则会出现在新的镜像当中。

总结

容器进程 “./hello_world” ,运行在由 Linux Namespace 和 Cgroups 构成的隔离环境里;而它运行所需要的各种文件,比如 golang,hello_world,以及整个操作系统文件,则由多个联合挂载在一起的 rootfs 层提供。

这些 rootfs 层的最下层,是来自 Docker 镜像的只读层。在只读层之上,是 Docker 自己添加的 Init 层,用来存放被临时修改过的 /etc/hosts 等文件。而 rootfs 的最上层是一个可读写层,它以 Copy-on-Write 的方式存放任何对只读层的修改,容器声明的 Volume 的挂载点,也出现在这一层。