1. 镜像与容器
1.1 镜像
Docker镜像类似于未运行的exe应用程序,或者停止运行的VM。当使用docker run命令基于镜像启动容器时,容器应用便能为外部提供服务。
镜像实际上就是这个用来为容器进程提供隔离后执行环境的文件系统。我们也称之为根文件系统(Rootfs)。(注意,rootfs 只是一个操作系统所包含的文件、配置和目录,并不包括操作系统内核。同一台机器上的所有容器,都共享宿主机操作系统的内核。)
由于 rootfs 里封装的不仅仅是应用,还包括它运行所需要的所有依赖。这就赋予了容器的强一致性:无论在本地、云端,还是其他任何地方,用户只需要解压打包好的容器镜像,这个应用运行所需要的完整的执行环境就被重现出来了。
我们可以将 Docker 镜像理解为包含应用程序以及其相关依赖的一个基础文件系统,在 Docker 容器启动的过程中,它以只读的方式被用于创建容器的运行环境。
1.1.1 镜像分层
但还有一个不容忽视的问题,例如如果我需要一个在CentOS环境中跑的apache应用,我可以将它打包成一个apache镜像;如果我还需要一个在CentOS环境中跑的mysql应用,我又将它打包成一个mysql镜像……
这几个镜像中都有全部的CentOS的全部环境,将造成大量空间占用问题及碎片化问题。
Docker的解决方法是: 在镜像的设计中,引入了层(layer)的概念。即: 用户制作镜像的每一步操作,都生成一个层,也就是一个增量 rootfs。
Docker 镜像其实是由基于 UnionFS 文件系统的一组镜像层依次挂载而得,而每个镜像层包含的其实是对上一镜像层的修改,这些修改其实是发生在容器运行的过程中的。所以,我们也可以反过来理解,镜像是对容器运行环境进行持久化存储的结果。
1.1.1 镜像的实现
1.1.1.1 Docker 是如何构建并且存储镜像的
Docker 中的每一个镜像都是由一系列只读的层组成的,Dockerfile 中的每一个命令都会在已有的只读层上创建一个新的层,容器中的每一层都只对当前容器进行了非常小的修改
当镜像被 docker run 命令创建时就会在镜像的最上层添加一个可写的层,也就是容器层,所有对于运行时容器的修改其实都是对这个容器读写层的修改。
上面的这张图片非常好的展示了组装的过程,每一个镜像层都是建立在另一个镜像层之上的,同时所有的镜像层都是只读的,只有每个容器最顶层的容器层才可以被用户直接读写,所有的容器都建立在一些底层服务(Kernel)上,包括命名空间、控制组、rootfs 等等,这种容器的组装方式提供了非常大的灵活性,只读的镜像层通过共享也能够减少磁盘的占用。
1.1.1.2镜像概述
所有的 Docker 镜像都是按照 Docker 所设定的逻辑打包的,也是受到 Docker Engine 所控制的。
我们常见的虚拟机镜像,通常是由热心的提供者以他们自己熟悉的方式打包成镜像文件,被我们从网上下载或是其他方式获得后,恢复到虚拟机中的文件系统里的。而 Docker 的镜像我们必须通过 Docker 来打包,也必须通过 Docker 下载或导入后使用,不能单独直接恢复成容器中的文件系统。
虽然这么做失去了很多灵活性,但固定的格式意味着我们可以很轻松的在不同的服务器间传递 Docker 镜像,配合 Docker 自身对镜像的管理功能,让我们在不同的机器中传递和共享 Docker 变得非常方便。
对于每一个记录文件系统修改的镜像层来说,Docker 都会根据它们的信息生成了一个 Hash 码,这是一个 64 长度的字符串,足以保证全球唯一性。
由于镜像层都有唯一的编码,我们就能够区分不同的镜像层并能保证它们的内容与编码是一致的,这带来了另一项好处,就是允许我们在镜像之间共享镜像层。
举一个实际的例子,由 Docker 官方提供的两个镜像 elasticsearch 镜像和 jenkins 镜像都是在 openjdk 镜像之上修改而得,那么在我们实际使用的时候,这两个镜像是可以共用 openjdk 镜像内部的镜像层的。
1.1.2 查看镜像
如果要查看当前连接的 docker daemon 中存放和管理了哪些镜像,我们可以使用 docker images 这个命令 ( Linux、macOS 还是 Windows 上都是一致的 )。
在 docker images 命令的结果中,我们可以看到镜像的 ID ( IMAGE ID)、构建时间 ( CREATED )、占用空间 ( SIZE ) 等数据。
1.1.3 镜像命名
镜像层的 ID 既可以识别每个镜像层,也可以用来直接识别镜像 ( 因为根据最上层镜像能够找出所有依赖的下层镜像,所以最上层进行的镜像层 ID 就能表示镜像的 ID ),但是使用这种无意义的超长哈希码显然是违背人性的,通过镜像名我们能够更容易的识别镜像。
准确的来说,镜像的命名我们可以分成三个部分:username、repository 和 tag。
- username: 主要用于识别上传镜像的不同用户,与 GitHub 中的用户空间类似。
对于 username 来说,在上面我们展示的 docker images 结果中,有的镜像有 username 这个部分,而有的镜像是没有的。没有 username 这个部分的镜像,表示镜像是由 Docker 官方所维护和提供的,所以就不单独标记用户了。
-
repository:主要用于识别进行的内容,形成对镜像的表意描述。
Docker 中镜像的 repository 部分通常采用的是软件名。我们推崇一个容器运行一个程序的做法,那么自然容器的镜像也会仅包含程序以及与它运行有关的一些依赖包,所以我们使用程序的名字直接套用在镜像之上,既祛除了镜像取名的麻烦,又能直接表达镜像中的内容。 -
tag:主要用户表示镜像的版本,方便区分进行内容的不同细节
在镜像命名中,还有一个非常重要的部分,也就是镜像的标签 ( tag )。镜像的标签是对同一种镜像进行更细层次区分的方法,也是最终识别镜像的关键部分。
通常来说,镜像的标签主要是为了区分同类镜像不同构建过程所产生的不同结果的。由于时间、空间等因素的不同,Docker 每次构建镜像的内容也就有所不同,具体体现就是镜像层以及它们的 ID 都会产生变化。而标签就是在镜像命名这个层面上区分这些镜像的方法。
与镜像的 repository 类似,镜像 tag 的命名方法也通常参考镜像所关联的应用程序。更确切的来说,我们通常会采用镜像内应用程序的版本号以及一些环境、构建方式等信息来作为镜像的 tag。
1.2 容器
容器就是将软件打包成标准化单元,以用于开发、交付和部署。
- 容器镜像是轻量的、可执行的独立软件包 ,包含软件运行所需的所有内容:代码、运行时环境、系统工具、系统库和设置。
- 容器化软件适用于基于Linux和Windows的应用,在任何环境中都能够始终如一地运行。
- 容器赋予了软件独立性,使其免受外在环境差异(例如,开发和预演环境的差异)的影响,从而有助于减少团队间在相同基础设施上运行不同软件时的冲突。
1.2.1 容器的生命周期
由于 Docker 揽下了大部分对容器管理的活,只提供给我们非常简单的操作接口,这就意味着 Docker 里对容器的一些运行细节会被更加严格的定义,这其中就包括了容器的生命周期。
这里有一张容器运行的状态流转图:
1.2.2 主进程
当我们启动容器时,Docker 其实会按照镜像中的定义,启动对应的程序,并将这个程序的主进程作为容器的主进程 ( 也就是 PID 为 1 的进程 )。而当我们控制容器停止时,Docker 会向主进程发送结束信号,通知程序退出。
而当容器中的主进程主动关闭时 ( 正常结束或出错停止 ),也会让容器随之停止。
1.2.3 写时复制
Docker 的写时复制与编程中的相类似,也就是在通过镜像运行容器时,并不是马上就把镜像里的所有内容拷贝到容器所运行的沙盒文件系统中,而是利用 UnionFS 将镜像以只读的方式挂载到沙盒文件系统中。只有在容器中发生对文件的修改时,修改才会体现到沙盒环境上。
也就是说,容器在创建和启动的过程中,不需要进行任何的文件系统复制操作,也不需要为容器单独开辟大量的硬盘空间,与其他虚拟化方式对这个过程的操作进行对比,Docker 启动的速度可见一斑。
Docker的容器是一个多层的结构。如果对镜像做history操作,我们可以看见他里面每一次dockerfile的命令都会创建一个新的层次。
[root@ip-172-16-1-4 ec2-user]# docker image history nginx
IMAGE CREATED CREATED BY SIZE COMMENT
8cf1bfb43ff5 6 days ago /bin/sh -c #(nop) CMD ["nginx" "-g" "daemon… 0B
<missing> 6 days ago /bin/sh -c #(nop) STOPSIGNAL SIGTERM 0B
<missing> 6 days ago /bin/sh -c #(nop) EXPOSE 80 0B
<missing> 6 days ago /bin/sh -c #(nop) ENTRYPOINT ["/docker-entr… 0B
<missing> 6 days ago /bin/sh -c #(nop) COPY file:0fd5fca330dcd6a7… 1.04kB
<missing> 6 days ago /bin/sh -c #(nop) COPY file:1d0a4127e78a26c1… 1.96kB
<missing> 6 days ago /bin/sh -c #(nop) COPY file:e7e183879c35719c… 1.2kB
<missing> 6 days ago /bin/sh -c set -x && addgroup --system -… 63.3MB
<missing> 6 days ago /bin/sh -c #(nop) ENV PKG_RELEASE=1~buster 0B
<missing> 6 days ago /bin/sh -c #(nop) ENV NJS_VERSION=0.4.2 0B
<missing> 6 days ago /bin/sh -c #(nop) ENV NGINX_VERSION=1.19.1 0B
<missing> 6 days ago /bin/sh -c #(nop) LABEL maintainer=NGINX Do… 0B
<missing> 6 days ago /bin/sh -c #(nop) CMD ["bash"] 0B
<missing> 6 days ago /bin/sh -c #(nop) ADD file:6ccb3bbcc69b0d44c… 69.2MB
Docker里面有一个重要的概念叫做 Storage driver,他可以帮助我们实现对容器的分层和读写。目前,docker的默认storage driver 是overlay2。所有的容器相关的文件都保存在/var/lib/docker这个目录下。我们可以看见在overlay2里面有很多不同的文件
[root@ip-172-16-1-4 ec2-user]# ls /var/lib/docker/overlay2/
12597d435b78d470bed7cf3a4cc7d60691432e74f12c00fd44def7ecf6ab659f 6945f4530a5212bc3a8aa598dc88839d39b763799ccdfbd18a5f04180e3b676e d1f1bcc388595272f11f4ace02f9e6976dc96fd80cce8216d3626484ba114aad
145e76991ae57691ed94de5dd2ea950ff7b50dc26729605e711cd0f35f275e84 7c4d732c22b84e0df8c0d317df825623958d4eff59055827e6b2c76cbe8b0350 d1f1bcc388595272f11f4ace02f9e6976dc96fd80cce8216d3626484ba114aad-init
16da856b3acb71eea396f529a80cf728d664a4bf3eb042f828cb9651d82e90bf 9b6749a9a76ad9f76d7795ebbf8e47595dada7a06b9126f5d9c6e1084f1d0c02 f089b3fd763c560e1c398c9b432100cea56ad4dae3a6b760de42b45e856ce693
16da856b3acb71eea396f529a80cf728d664a4bf3eb042f828cb9651d82e90bf-init a60b187ea615a59402ad2fe6687a4dc87f1c037eafea6880167795c6e1b7f900 f089b3fd763c560e1c398c9b432100cea56ad4dae3a6b760de42b45e856ce693-init
658b1eab364b4523a8c7274e9b8a2bdc55095e9bd56dcb697f6456b4064abe66 a60b187ea615a59402ad2fe6687a4dc87f1c037eafea6880167795c6e1b7f900-init l
658b1eab364b4523a8c7274e9b8a2bdc55095e9bd56dcb697f6456b4064abe66-init backingFsBlockDev
一个典型的场景如下所示,一个镜像文件里面,里面分了多层,最下面的是基础镜像,这个基础镜像不包括内核文件,执行的时候他会直接调用宿主机的内核,因此他的空间并不大。在基础镜像上面,又分了很多层,每一层代表在dockerfile里面执行的一行命令。这整个镜像文件都是只读的。每个容器通过镜像创建自己的容器层,而容器层是可以读写的,修改的内容他们会保存在自己的目录下面。因此每个容器对自己的修改 不会影响到其他容器。
在docker里面,我们通过storage driver来进行所谓的copy on write ( 写时复制)的操作。storage driver有很多种,目前默认的是overlay2
overlay的基本工作原理如下
我们通过镜像创建的容器包括了三层。最下面的是一个只读的镜像层,第二层是容器层,在他上面最上面的容器挂载层。最上层显示的是我们在容器上直接看见的内容,他通过UnionFS,或者说类似软连接的方式,文件的路径指向了容器层或者是镜像层。当我们尝试读取,修改或者创建一个新的文件时,我们总是从上到下进行搜索,如果在容器层找到了,那么就直接打开;如果容器层没有,那就从镜像层打开。如果是一个新建的文档,那么就从镜像层拷贝到容器层,再打开操作。
参考资料
《开发者必备的 Docker 实践指南》