镜像与容器

镜像和容器作为 Docker 里最基础的概念,我们很有必要了解 Docker 对它们的很多定义以及其他与它们有关的知识。在这一小节里,我们就专门针对镜像与容器两个概念展开,细致的梳理与这两者有关的概念和定义。

Docker 镜像

如果进行形象的表述,我们可以将 Docker 镜像理解为包含应用程序以及其相关依赖的一个基础文件系统,在 Docker 容器启动的过程中,它以只读的方式被用于创建容器的运行环境。

从另一个角度看,在之前的小节里我们讲到了,Docker 镜像其实是由基于 UnionFS 文件系统的一组镜像层依次挂载而得,而每个镜像层包含的其实是对上一镜像层的修改,这些修改其实是发生在容器运行的过程中的。所以,我们也可以反过来理解,镜像是对容器运行环境进行持久化存储的结果。

深入镜像实现

与其他虚拟机的镜像管理不同,Docker 将镜像管理纳入到了自身设计之中,也就是说,所有的 Docker 镜像都是按照 Docker 所设定的逻辑打包的,也是受到 Docker Engine 所控制的。

这么说起来也许还不够具体,让我们来做一个比较。我们常见的虚拟机镜像,通常是由热心的提供者以他们自己熟悉的方式打包成镜像文件,被我们从网上下载或是其他方式获得后,恢复到虚拟机中的文件系统里的。而 Docker 的镜像我们必须通过 Docker 来打包,也必须通过 Docker 下载或导入后使用,不能单独直接恢复成容器中的文件系统。

虽然这么做失去了很多灵活性,但固定的格式意味着我们可以很轻松的在不同的服务器间传递 Docker 镜像,配合 Docker 自身对镜像的管理功能,让我们在不同的机器中传递和共享 Docker 变得非常方便。这也是 Docker 能够提升我们工作效率的一处体现。

对于每一个记录文件系统修改的镜像层来说,Docker 都会根据它们的信息生成了一个 Hash 码,这是一个 64 长度的字符串,足以保证全球唯一性。这种编码的形式在 Docker 很多地方都有体现,之后我们会经常见到。

由于镜像层都有唯一的编码,我们就能够区分不同的镜像层并能保证它们的内容与编码是一致的,这带来了另一项好处,就是允许我们在镜像之间共享镜像层。

举一个实际的例子,由 Docker 官方提供的两个镜像 elasticsearch 镜像和 jenkins 镜像都是在 openjdk 镜像之上修改而得,那么在我们实际使用的时候,这两个镜像是可以共用 openjdk 镜像内部的镜像层的。

这带来的一项好处就是让镜像可以共用一些存储空间,达到 1 + 1 < 2 的效果,为我们在同一台机器里存放众多镜像提供了可能。

事实上,这个优势是更为明显的。一个虚拟机镜像的占用空间往往用 GB 来衡量,在同一台物理机上存放几个就已经是了不起的事情了。而 Docker 管理之下的镜像,占用空间是以 MB 为单位进行衡量的,加之镜像之间还能够共享部分的镜像层,也就是共享存储空间,所以我们在常见的硬盘里放下几十、数百个镜像也不是什么难事。

在之后的小节里,我们会讲到如何导出镜像,在导出镜像的时候,我们可以更清晰的看到镜像层的体现,这个留至后面我们来讲解。

查看镜像

镜像是由 Docker 进行管理的,所以它们的存储位置和存储方式等我们并不需要过多的关心,我们只需要利用 Docker 所提供的一些接口或命令对它们进行控制即可。

如果要查看当前连接的 docker daemon 中存放和管理了哪些镜像,我们可以使用 docker images 这个命令 ( Linux、macOS 还是 Windows 上都是一致的 )。

$ docker images
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
php                 7-fpm               f214b5c48a25        9 days ago          368MB
redis               3.2                 2fef532eadb3        11 days ago         76MB
redis               4.0                 e1a73233e3be        11 days ago         83.4MB
cogset/cron         latest              c01d5ac6fc8a        15 months ago       125MB

docker images 命令的结果中,我们可以看到镜像的 ID ( IMAGE ID)构建时间 ( CREATED )占用空间 ( SIZE ) 等数据。

这里需要注意一点,我们发现在结果中镜像 ID 的长度只有 12 个字符,这和我们之前说的 64 个字符貌似不一致。其实为了避免屏幕的空间都被这些看似“乱码”的镜像 ID 所挤占,所以 Docker 只显示了镜像 ID 的前 12 个字符,大部分情况下,它们已经能够让我们在单一主机中识别出不同的镜像了。

镜像命名

镜像层的 ID 既可以识别每个镜像层,也可以用来直接识别镜像 ( 因为根据最上层镜像能够找出所有依赖的下层镜像,所以最上层进行的镜像层 ID 就能表示镜像的 ID ),但是使用这种无意义的超长哈希码显然是违背人性的,所以这里我们还要介绍镜像的命名,通过镜像名我们能够更容易的识别镜像。

docker images 命令打印出的内容中,我们还能看到两个与镜像命名有关的数据:REPOSITORYTAG,这两者其实就组成了 docker 对镜像的命名规则。

来看这个例子:

准确的来说,镜像的命名我们可以分成三个部分:usernamerepositorytag

对于 username 来说,在上面我们展示的 docker images 结果中,有的镜像有 username 这个部分,而有的镜像是没有的。没有 username 这个部分的镜像,表示镜像是由 Docker 官方所维护和提供的,所以就不单独标记用户了。

如果大家再多接触一些镜像,会发现 Docker 中镜像的 repository 部分通常采用的是软件名。这时候大家一定要注意了,镜像还是镜像,镜像名还是镜像名,其与软件命名其实是独立的。

之所以镜像通常直接采用软件名,这还要回归到 Docker 对容器的轻量化设计中。Docker 对容器的设计和定义是微型容器而不是庞大臃肿的完整环境 ( 这当然归功于容器技术在实现虚拟化过程中性能几乎无损 ),这就使得我们通常会只在一个容器中运行一个应用程序,这样的好处自然是能够大幅降低程序之间相互的影响,也有利于利用容器技术控制每个程序所使用的资源。

回过头来,既然我们推崇这种一个容器运行一个程序的做法,那么自然容器的镜像也会仅包含程序以及与它运行有关的一些依赖包,所以我们使用程序的名字直接套用在镜像之上,既祛除了镜像取名的麻烦,又能直接表达镜像中的内容。

在镜像命名中,还有一个非常重要的部分,也就是镜像的标签 ( tag )。镜像的标签是对同一种镜像进行更细层次区分的方法,也是最终识别镜像的关键部分。

通常来说,镜像的标签主要是为了区分同类镜像不同构建过程所产生的不同结果的。由于时间、空间等因素的不同,Docker 每次构建镜像的内容也就有所不同,具体体现就是镜像层以及它们的 ID 都会产生变化。而标签就是在镜像命名这个层面上区分这些镜像的方法。

与镜像的 repository 类似,镜像 tag 的命名方法也通常参考镜像所关联的应用程序。更确切的来说,我们通常会采用镜像内应用程序的版本号以及一些环境、构建方式等信息来作为镜像的 tag。

例如,我们之前示例的结果中就分别有包含 Redis 3.2 版本和 4.0 版本的两个镜像:redis:3.2redis:4.0

除了单纯使用应用程序版本来作为镜像的标签外,有时候我们也会在其中包含一些构建方式的区别。例如 php:7.2-cliphp:7.2-fpm 两个镜像分别表示只包含控制台命令的 PHP 镜像以及包含 PHP-FPM 功能的 PHP 镜像,而他们对应 PHP 版本都是 7.2。

通过组合应用程序和它的版本号来命名镜像,大大方便了我们在 Docker 区别和使用镜像的门槛,与其说我们在使用 Docker 进行来启动容器,这个过程倒更像我们在运行指定版本的应用程序。

另外,Docker 中还有一个约定,当我们在操作中没有具体给出镜像的 tag 时,Docker 会采用 latest 作为缺省 tag。这也就带来了一个共识,也就是绝大多数镜像提供者在提供镜像时,会在 latest 对应的镜像中包含软件最新的版本。这带来了一项小便利,就是我们在不需要了解应用程序迭代周期的情况下,可以利用 latest 镜像保持软件最新版本的使用。

容器的生命周期

要熟悉 Docker 容器,还有一个重要的概念,也就是容器的生命周期。

由于 Docker 揽下了大部分对容器管理的活,只提供给我们非常简单的操作接口,这就意味着 Docker 里对容器的一些运行细节会被更加严格的定义,这其中就包括了容器的生命周期。

这里有一张容器运行的状态流转图:

图中展示了几种常见对 Docker 容器的操作命令,以及执行它们之后容器运行状态的变化。这里我们撇开命令,着重看看容器的几个核心状态,也就是图中色块表示的:CreatedRunningPausedStoppedDeleted

在这几种状态中,Running 是最为关键的状态,在这种状态中的容器,就是真正正在运行的容器了。

主进程

如果单纯去看容器的生命周期会有一些难理解的地方,而 Docker 中对容器生命周期的定义其实并不是独立存在的。

在 Docker 的设计中,容器的生命周期其实与容器中 PID 为 1 这个进程有着密切的关系。更确切的说,它们其实是共患难,同生死的兄弟。容器的启动,本质上可以理解为这个进程的启动,而容器的停止也就意味着这个进程的停止,反过来理解亦然。

当我们启动容器时,Docker 其实会按照镜像中的定义,启动对应的程序,并将这个程序的主进程作为容器的主进程 ( 也就是 PID 为 1 的进程 )。而当我们控制容器停止时,Docker 会向主进程发送结束信号,通知程序退出。

而当容器中的主进程主动关闭时 ( 正常结束或出错停止 ),也会让容器随之停止。

通过之前提到的几个方面来看,Docker 不仅是从设计上推崇轻量化的容器,也是许多机制上是以此为原则去实现的。所以,我们最佳的 Docker 实践方法是遵循着它的逻辑,逐渐习惯这种容器即应用,应用即容器的虚拟化方式。虽然在 Docker 中我们也能够实现在同一个容器中运行多个不同类型的程序,但这么做的话,Docker 就无法跟踪不同应用的生命周期,有可能造成应用的非正常关闭,进而影响系统、数据的稳定性。

写时复制机制

写时复制 ( Copy on Write ) 这个词对于开发者来说应该并不陌生,在很多编程语言里,都隐藏了写时复制的实现。在编程里,写时复制常常用于对象或数组的拷贝中,当我们拷贝对象或数组时,复制的过程并不是马上发生在内存中,而只是先让两个变量同时指向同一个内存空间,并进行一些标记,当我们要对对象或数组进行修改时,才真正进行内存的拷贝。

Docker 的写时复制与编程中的相类似,也就是在通过镜像运行容器时,并不是马上就把镜像里的所有内容拷贝到容器所运行的沙盒文件系统中,而是利用 UnionFS 将镜像以只读的方式挂载到沙盒文件系统中。只有在容器中发生对文件的修改时,修改才会体现到沙盒环境上。

也就是说,容器在创建和启动的过程中,不需要进行任何的文件系统复制操作,也不需要为容器单独开辟大量的硬盘空间,与其他虚拟化方式对这个过程的操作进行对比,Docker 启动的速度可见一斑。

采用写时复制机制来设计的 Docker,既保证了镜像在生成为容器时,以及容器在运行过程中,不会对自身造成修改。又借助剔除常见虚拟化在初始化时需要从镜像中拷贝整个文件系统的过程,大幅提高了容器的创建和启动速度。可以说,Docker 容器能够实现秒级启动速度,写时复制机制在其中发挥了举足轻重的作用。

留言互动

在这一小节中,我们对 Docker 的镜像与容器相关的概念进行了进一步的梳理,通过掌握这些词汇,能够更好的帮助大家理解之后小节中的内容。这里给大家留一道思考题:

Docker 对镜像与容器的设计有什么独特之处,它们又给 Docker 带来了怎样的优势?

欢迎大家通过留言的方式说出你的看法。我会选出有代表性的优质留言,推荐给大家。

同时,如果你对镜像与容器相关的概念、知识还有不理解的地方,可以加入到这本小册的官方微信群中,参与对相关问题的讨论。