docker详解
简介
Docker 引擎是用于运行和编排容器的基础设施工具。有 VMware 管理经验的读者可以将其类比为 ESXi。ESXi 是运行虚拟机的核心管理程序,而 Docker 引擎是运行容器的核心容器运行时。其他 Docker 公司或第三方的产品都是围绕 Docker 引擎进行开发和集成的。如下图所示,Docker 引擎位于中心,其他产品基于 Docker 引擎的核心功能进行集成。
Docker 引擎可以从 Docker 网站下载,也可以基于 GitHub 上的源码进行构建。无论是开源版本还是商业版本
Docker 引擎主要有两个版本:企业版(EE)和社区版(CE)
多数项目及其工具都是基于 Golang 编写的,这是谷歌推出的一种新的系统级编程语言,又叫 Go 语言。使用 Go 语言的读者,将更容易为该项目贡献代码。
容器生态
Docker 公司的一个核心哲学通常被称为“含电池,但可拆卸”(Batteries included but removable)
意思是许多 Docker 内置的组件都可以替换为第三方的组件,网络技术栈就是一个很好的例子。Docker 核心产品内置有网络解决方案。但是网络技术栈是可插拔的,这意味着 Docker 内置的网络方案可以被替换为第三方的方案。许多人都会这样使用。
早期的时候,经常出现第三方插件比 Docker 提供的内置组件更好的情况。然而这会对 Docker 公司的商业模式造成冲击。毕竟,Docker 公司需要依靠盈利来维持基业长青。
因此,“内置的电池”变得越来越好用了。这也导致了生态内部的紧张关系和竞争的加剧。
简单来说,Docker 内置的“电池”仍然是可插拔的,然而越来越不需要将它们移除了。
尽管如此,容器生态在一种良性的合作与竞争的平衡中还是得以繁荣发展。
在谈及容器生态时,人们经常使用到诸如“co-opetition”(意即合作与竞争,英文中 co-operation 与 competition 合并的词)与“frenemy”(英文中朋友 friend 与敌人 enemy 合并的词)这样的字眼。这是一个好现象!因为良性的竞争是创新之母
开放容器计划
如果不谈及开放容器计划(The Open Container Initiative, OCI)的话,对 Docker 和容器生态的探讨总是不完整的。下图所示为 OCI 的Logo。
OCI 是一个旨在对容器基础架构中的基础组件(如镜像格式与容器运行时)进行标准化的管理委员会。
同样,如果不谈历史的话,对 OCI 的探讨也是不完整的。
一个名为 CoreOS 的公司不喜欢 Docker 的某些行事方式。因此它就创建了一个新的开源标准,称作“appc”,该标准涉及诸如镜像格式和容器运行时等方面。
此外它还开发了一个名为 rkt(发音“rocket”)的实现。
两个处于竞争状态的标准将容器生态置于一种尴尬的境地。
这使容器生态陷入了分裂的危险中,同时也令用户和消费者陷入两难。虽然竞争是一件好事,但是标准的竞争通常不是。因为它会导致困扰,降低用户接受度,对谁都无益。
考虑到这一点,所有相关方都尽力用成熟的方式处理此事,共同成立了 OCI ——一个旨在管理容器标准的轻量级的、敏捷型的委员会。
OCI 已经发布了两份规范(标准):镜像规范和运行时规范。
提到这两项标准时,经常用到的比喻就是铁轨。它们就像对铁轨的尺寸和相关属性达成一致,让所有人都能自由地建造更好的火车、更好的车厢、更好的信号系统、更好的车站等。
只要各方都遵循标准就是安全的。没人会希望在铁轨尺寸问题上存在两个相互竞争的标准!
公平地说,这两个 OCI 规范对 Docker 的架构和核心产品设计产生了显著影响。Docker 1.11 版本中,Docker 引擎架构已经遵循 OCI 运行时规范了。
到目前为止,OCI 已经取得了不错的成效,将容器生态团结起来。然而,标准总是会减慢创新的步伐!尤其是对于超快速发展的新技术来说更是如此。
这在容器社区引起了热烈的讨论。这应该算是好事!容器技术正在重塑世界,走在技术前列的人们有热情、有想法,这很正常。
OCI 在 Linux 基金会的支持下运作,Docker 公司和 CoreOS 公司都是主要贡献者。
Win 安装docker
好像是要企业版本或者专业版
http://c.biancheng.net/view/3121.html
Docker Storage Driver:存储驱动
每个Docker容器都有一个本地存储空间,用于保存层叠的镜像层(Image Layer)以及挂载的容器文件系统。默认情况下,容器的所有读写操作都发生在其镜像层上或挂载的文件系统中,所以存储是每个容器的性能和稳定性不可或缺的一个环节。
以往,本地存储是通过存储驱动(storage driver)进行管理的,有时候也被称为Graph Driver。虽然存储驱动在上层抽象设计中都采用了栈式镜像层存储和写时复制(Copy-on-Writer)的设计思想,但是Docker在Linux底层支持几种不同的存储驱动的具体表现,每一个实现方式采用不同方法实现了镜像层和写时复制。虽然底层实现的差异不影响用户与 Docker 之间的交互,但是对 Docker 的性能和稳定性至关重要。
虽然底层实现的差异不影响用户与 Docker 之间的交互,但是对 Docker 的性能和稳定性至关重要。
在 Linux 上,Docker 可选择的一些存储驱动包括 AUFS(最原始也是最老的)、Overlay2(可能是未来的最佳选择)、Device Mapper、Btrfs 和 ZFS。Docker 在 Windows 操作系统上只支持一种存储驱动,即 Windows Filter。存储驱动的选择是节点级别的。这意味着每个 Docker 主机只能选择一种存储驱动,而不能为每个容器选择不同的存储驱动。在 Linux 上,读者可以通过修改 /etc/docker/daemon.json文件来修改存储引擎配置,修改完成之后需要重启 Docker 才能够生效。
下面的代码片段展示了如何将存储驱动设置为 overlay2。
1 | { "storage-driver": "overlay2" } |
提示:如果配置所在行不是文件的最后一行,则需要在行尾处增加逗号。
如果读者修改了正在运行 Docker 主机的存储引擎类型,则现有的镜像和容器在重启之后将不可用,这是因为每种存储驱动在主机上存储镜像层的位置是不同的(通常在 /var/lib/docker/storage-driver/… 目录下)
修改了存储驱动的类型,Docker 就无法找到原有的镜像和容器了。切换到原来的存储驱动,之前的镜像和容器就可以继续使用了。如果希望在切换存储引擎之后还能够继续使用之前的镜像和容器,需要将镜像保存为 Docker 格式,上传到某个镜像仓库,修改本地 Docker 存储引擎并重启,之后从镜像仓库将镜像拉取到本地,最后重启容器。
通过下面的命令来检查 Docker 当前的存储驱动类型。
1 | $ docker system info |
选择存储驱动并正确地配置在 Docker 环境中是一件重要的事情,特别是在生产环境中。
下面的清单可以作为一个参考指南,帮助我们选择合适的存储驱动。同时还可以参阅 Docker 官网上由 Linux 发行商提供的最新文档来做出选择。
- Red Hat Enterprise Linux:4.x版本内核或更高版本 + Docker 17.06 版本或更高版本,建议使用 Overlay2。
- Red Hat Enterprise Linux:低版本内核或低版本的 Docker,建议使用 Device Mapper。
- Ubuntu Linux:4.x 版本内核或更高版本,建议使用 Overlay2。
- Ubuntu Linux:更早的版本建议使用 AUFS。
- SUSE Linux Enterprise Server:Btrfs。
我们需要时刻关注 Docker 文档中关于存储驱动的最新支持和版本兼容列表。尤其是正在使用 Docker 企业版(EE),并且有售后支持合同的情况下,更有必要查阅最新文档
Device Mapper 配置
大部分 Linux 存储驱动不需要或需要很少的配置。但是,Device Mapper 通常需要合理配置之后才能表现出良好的性能。
默认情况下,Device Mapper 采用 loopback mounted sparse file
作为底层实现来为 Docker 提供存储支持。
如果需要的是开箱即用并且对性能没什么要求,那么这种方式是可行的。但这并不适用于生产环境。实际上,默认方式的性能很差,并不支持生产环境。
为了达到 Device Mapper 在生产环境中的最佳性能,读者需要将底层实现修改为 direct-lvm
模式。
这种模式下通过使用基于裸块设备(Raw Block Device)的 LVM 精简池(LVM thin pool)来获取更好的性能。
在 Docker 17.06 以及更高的版本中可以配置 direct-lvm
作为存储驱动。
其中最主要的一点是,这种方式只能配置一个块设备,并且只有在第一次安装后才能设置生效。未来可能会有改进,但就目前情况来看配置单一块设备这种方式在性能和可靠性上都有一定的风险。
让 Docker 自动设置 direct-lvm
下面的步骤会将 Docker 配置存储驱动为 Device Mapper,并使用 direct-lvm
模式。
1) 将下面的存储驱动配置添加到 /etc/docker/daemon.json
当中。
1 | { |
Device Mapper 和 LVM 是很复杂的知识点,下面简单介绍一下各配置项的含义。
dm.directlvm_device
:设置了块设备的位置。为了存储的最佳性能以及可用性,块设备应当位于高性能存储设备(如本地 SSD)或者外部 RAID 存储阵列之上。dm.thinp_percent=95
:设置了镜像和容器允许使用的最大存储空间占比,默认是 95%。dm.thinp_metapercent
:设置了元数据存储(MetaData Storage
)允许使用的存储空间大小。默认是 1%。dm.thinp_autoextend_threshold
:设置了 LVM 自动扩展精简池的阈值,默认是 80%。dm.thinp_autoextend_percent
:表示当触发精简池(thin pool)自动扩容机制的时候,扩容的大小应当占现有空间的比例。dm.directlvm_device_force
:允许用户决定是否将块设备格式化为新的文件系统。
2) 重启 Docker
3) 确认 Docker 已成功运行,并且块设备配置已被成功加载。
1 | $ docker version |
即使 Docker 在 direct-lvm 模式下只能设置单一块设备,其性能也会显著优于 loopback 模式。
手动配置 Device Mapper 的 direct-lvm
1) 块设备(Block Device)
在使用 direct-lvm 模式的时候,读者需要有可用的块设备。这些块设备应该位于高性能的存储设备之上,比如本地 SSD 或者外部高性能 LUN 存储。
如果 Docker 环境部署在企业私有云(On-Premise)之上,那么外部 LUN 存储可以使用 FC、iSCSI,或者其他支持块设备协议的存储阵列。
如果 Docker 环境部署在公有云之上,那么可以采用公有云厂商提供的任何高性能的块设备(通常基于 SSD)。
2) LVM配置
Docker 的 Device Mapper 存储驱动底层利用 LVM(Logical Volume Manager)来实现,因此需要配置 LVM 所需的物理设备、卷组、逻辑卷和精简池。
读者应当使用专用的物理卷并将其配置在相同的卷组当中。这个卷组不应当被 Docker 之外的工作负载所使用。
此外还需要配置额外两个逻辑卷,分别用于存储数据和源数据信息。另外,要创建 LVM 配置文件、指定 LVM 自动扩容的触发阈值,以及自动扩容的大小,并且为自动扩容配置相应的监控,保证自动扩容会被触发。
3) Docker 配置
修改 Docker 配置文件之前要先保存原始文件(etc/docker/daemon.json
),然后再进行修改。
环境中的 dm.thinpooldev
配置项对应值可能跟下面的示例内容有所不同,需要修改为合适的配置。
1 | { |
修改并保存配置后,读者可以重启 Docker daemon
运维人员看docker
从运维的角度来说,我们需要掌握Docker的镜像下载、运行新的容器、登录新容器、在容器内运行命令,以及销毁容器。
当我们安装 Docker 的时候,会涉及两个主要组件:Docker 客户端和 Docker daemon(有时也被称为“服务端”或者“引擎”)。
daemon 实现了 Docker 引擎的 API
使用 Linux 默认安装时,客户端与 daemon 之间的通信是通过本地 IPC/UNIX Socket 完成的(/var/run/docker.sock
);在 Windows 上是通过名为 npipe:////./pipe/docker_engine
的管道(pipe)完成的。
可以使用docker version
命令来检测客户端和服务端是否都已经成功运行,并且可以互相通信。
1 | > docker version |
如果能成功获取来自客户端和服务端的响应,那么可以继续后面的操作
如果在使用 Linux 的时候,服务端返回了异常响应,则可尝试在命令的前面加上 sudo 如:sudo docker version
。
如果加上 sudo 之后命令正常运行,那么我们需要将当前用户加入到 docker 用户组,或者给所有的命令都加上 sudo 前缀。
开发人员看Docker
容器即应用!
当成功将应用代码构建到了 Docker 镜像当中,然后以容器的方式启动该镜像,这个过程叫作“应用容器化”。
镜像
将 Docker 镜像理解为一个包含了 OS 文件系统和应用的对象会很有帮助
如果实际操作过,就会认为与虚拟机模板类似。虚拟机模板本质上是处于关机状态的虚拟机。在 Docker 世界中,镜像实际上等价于未运行的容器。如果作为一名开发者,则可以将镜像比作类(Class)。
在 Docker 主机上运行docker image ls
命令。
1 | $ docker image ls |
如果运行命令环境是刚完成 Docker 安装的主机,或者是 Play With Docker,那么 Docker 主机中应当没有任何镜像,命令输出内容会如上所示。
在 Docker 主机上获取镜像的操作被称为拉取(pulling)。如果使用 Linux,那么会拉取 ubuntu:latest 镜像;如果使用 Windows,则会拉取 microsoft/powershell:nanoserver 镜像。
再次运行docker image ls
命令来查看刚刚拉取的镜像。
1 | $ docker images |
镜像包含了基础操作系统,以及应用程序运行所需的代码和依赖包。刚才拉取的 ubuntu 镜像有一个精简版的 Ubuntu Linux 文件系统,其中包含部分 Ubuntu 常用工具。
而 Windows 示例中拉取的 microsoft/powershell 镜像,则包含了带有 PowerShell 的 Windows Nano Server 操作系统。
如果拉取了如 nginx 或者 microsoft/iis 这样的应用容器,就会得到一个包含操作系统的镜像,并且在镜像中还包括了运行 Nginx 或 IIS 所需的代码。
重要的是,Docker 的每个镜像都有自己的唯一 ID。用户可以通过引用镜像的 ID 或名称来使用镜像。
如果选择使用镜像 ID,通常只需要输入 ID 开头的几个字符即可——因为 ID 是唯一的,Docker 知道用户想引用的具体镜像是哪个。
容器
如果已经拥有一个拉取到本地的镜像,可以使用docker container run
命令从镜像来启动容器。
在 Linux 中启动容器的命令如下。
1 | $ docker container run -it ubuntu:latest /bin/bash |
仔细观察上面命令的输出内容,会注意到每个实例中的提示符都发生了变化。这是因为 -it
参数会将 Shell 切换到容器终端——现在已经位于容器内部了!
docker container run
告诉 Docker daemon 启动新的容器
其中
-it
参数告诉 Docker 开启容器的交互模式并将读者当前的 Shell 连接到容器终端。接下来,命令告诉 Docker,用户想基于ubuntu:latest
镜像启动容器(如果用户使用 Windows,则是基于microsoft/powershell:nanoserver
镜像)
最后,命令告诉 Docker,用户想要在容器内部运行哪个进程。对于 Linux 示例来说是运行 Bash Shell,对于 Windows 示例来说则是运行 PowerShell。
在容器内部运行ps
命令查看当前正在运行的全部进程。
Linux 示例如下。
1 | root@6dc20d508db0:/# ps -elf |
Linux 容器中仅包含两个进程。
- PID 1:代表 /bin/bash 进程,该进程是通过
docker container run
命令来通知容器运行的。 - PID 9:代表 ps -elf 进程,查看当前运行中进程所使用的命令/程序
命令输出中展示的ps -elf
进程存在一定的误导,因为这个程序在ps
命令退出后就结束了。这意味着容器内长期运行的进程其实只有 /bin/bash
按 Ctrl-PQ 组合键,可以在退出容器的同时还保持容器运行。这样 Shell 就会返回到 Docker 主机终端。可以通过查看 Shell 提示符来确认。
可以通过docker container ls
命令查看系统内全部处于运行状态的容器。
执行docker container exec
命令,可以将 Shell 连接到一个运行中的容器终端。因为之前示例中的容器仍在运行,所以下面的示例会创建到该容器的新连接。
1 | $ docker container exec -it suspicious_rubin bash |
示例中的容器名为“suspicious_rubin”。读者环境中的容器名称会不同,所以请记得将“suspicious_rubin”替换为自己 Docker 主机上运行中的容器名称或者 ID
docker container exec
命令的格式如下:
1 | docker container exec <options> <container-name or container-id> <command/app> |
在示例中,将本地 Shell 连接到容器是通过 -it 参数实现的。本例中使用名称引用容器,并且告诉 Docker 运行 Bash Shell
通过docker container stop
和docker container rm
命令来停止并杀死容器。
Docker引擎(engine)详解
Docker 引擎是用来运行和管理容器的核心软件。通常人们会简单地将其代指为 Docker 或 Docker 平台
基于开放容器计划(OCI)相关标准的要求,Docker 引擎采用了模块化的设计原则,其组件是可替换的。从多个角度来看,Docker 引擎就像汽车引擎——二者都是模块化的,并且由许多可交换的部件组成。汽车引擎由许多专用的部件协同工作,从而使汽车可以行驶,例如进气管、节气门、气缸、火花塞、排气管等。Docker 引擎由许多专用的工具协同工作,从而可以创建和运行容器,例如 API、执行驱动、运行时、shim 进程等。
Docker 引擎由如下主要的组件构成:Docker 客户端(Docker Client)、Docker 守护进程(Docker daemon)、containerd 以及 runc。它们共同负责容器的创建和运行。
Docker 首次发布时,Docker 引擎由两个核心组件构成:LXC 和 Docker daemon
Docker daemon 是单一的二进制文件,包含诸如 Docker 客户端、Docker API、容器运行时、镜像构建等。
LXC 提供了对诸如命名空间(Namespace)和控制组(CGroup)等基础工具的操作能力,它们是基于 Linux 内核的容器虚拟化技术。
下图阐释了在 Docker 旧版本中,Docker daemon、LXC 和操作系统之间的交互关系
摆脱 LXC
对 LXC 的依赖自始至终都是个问题。首先,LXC 是基于 Linux 的。这对于一个立志于跨平台的项目来说是个问题。其次,如此核心的组件依赖于外部工具,这会给项目带来巨大风险,甚至影响其发展。因此,Docker 公司开发了名为 Libcontainer 的自研工具,用于替代 LXC。Libcontainer 的目标是成为与平台无关的工具,可基于不同内核为 Docker 上层提供必要的容器交互功能。在 Docker 0.9 版本中,Libcontainer 取代 LXC 成为默认的执行驱动。
摒弃大而全的Docker daemon
随着时间的推移,Docker daemon 的整体性带来了越来越多的问题。难于变更、运行越来越慢。这并非生态(或Docker公司)所期望的。
Docker 公司意识到了这些问题,开始努力着手拆解这个大而全的 Docker daemon 进程,并将其模块化。
这项任务的目标是尽可能拆解出其中的功能特性,并用小而专的工具来实现它。这些小工具可以是可替换的,也可以被第三方拿去用于构建其他工具。
这一计划遵循了在 UNIX 中得以实践并验证过的一种软件哲学:小而专的工具可以组装为大型工具。
这项拆解和重构 Docker 引擎的工作仍在进行中。不过,所有容器执行和容器运行时的代码已经完全从 daemon 中移除,并重构为小而专的工具。
目前 Docker 引擎的架构示意图如下图所示,图中有简要的描述。
开放容器计划(OCI)的影响
当 Docker 公司正在进行 Docker daemon 进程的拆解和重构的时候,OCI 也正在着手定义两个容器相关的规范(或者说标准)。镜像规范和容器运行时规范,两个规范均于 2017 年 7 月发布了 1.0 版。Docker 公司参与了这些规范的制定工作,并贡献了许多的代码。
从 Docker 1.11 版本(2016 年初)开始,Docker 引擎尽可能实现了 OCI 的规范。例如,Docker daemon 不再包含任何容器运行时的代码——所有的容器运行代码在一个单独的 OCI 兼容层中实现。
默认情况下,Docker 使用 runc 来实现这一点。runc 是 OCI 容器运行时标准的参考实现。如上图中的 runc 容器运行时层。runc 项目的目标之一就是与 OCI 规范保持一致。目前 OCI 规范均为 1.0 版本,我们不希望它们频繁地迭代,毕竟稳定胜于一切。
除此之外,Docker 引擎中的 containerd 组件确保了 Docker 镜像能够以正确的 OCI Bundle 的格式传递给 runc。其实,在 OCI 规范以 1.0 版本正式发布之前,Docker 引擎就已经遵循该规范实现了部分功能。
runc
如前所述,runc 是 OCI 容器运行时规范的参考实现。Docker 公司参与了规范的制定以及 runc 的开发。
去粗取精,会发现 runc 实质上是一个轻量级的、针对 Libcontainer 进行了包装的命令行交互工具(Libcontainer 取代了早期 Docker 架构中的 LXC)。
runc 生来只有一个作用——创建容器,这一点它非常拿手,速度很快!不过它是一个 CLI 包装器,实质上就是一个独立的容器运行时工具。
因此直接下载它或基于源码编译二进制文件,即可拥有一个全功能的 runc。但它只是一个基础工具,并不提供类似 Docker 引擎所拥有的丰富功能。
有时也将 runc 所在的那一层称为“OCI 层”,如上图所示。关于 runc 的发布信息见 GitHub 中 opencontainers/runc 库的 release。
containerd
在对 Docker daemon 的功能进行拆解后,所有的容器执行逻辑被重构到一个新的名为 containerd(发音为 container-dee)的工具中。
它的主要任务是容器的生命周期管理——start | stop | pause | rm….
containerd 在 Linux 和 Windows 中以 daemon 的方式运行,从 1.11 版本之后 Docker 就开始在 Linux 上使用它。
Docker 引擎技术栈中,containerd 位于 daemon 和 runc 所在的 OCI 层之间。Kubernetes 也可以通过 cri-containerd 使用 containerd。
如前所述,containerd 最初被设计为轻量级的小型工具,仅用于容器的生命周期管理。然而,随着时间的推移,它被赋予了更多的功能,比如镜像管理。
其原因之一在于,这样便于在其他项目中使用它。比如,在 Kubernetes 中,containerd 就是一个很受欢迎的容器运行时。
然而在 Kubernetes 这样的项目中,如果 containerd 能够完成一些诸如 push 和 pull 镜像这样的操作就更好了。
因此,如今 containerd 还能够完成一些除容器生命周期管理之外的操作。不过,所有的额外功能都是模块化的、可选的,便于自行选择所需功能。
所以,Kubernetes 这样的项目在使用 containerd 时,可以仅包含所需的功能。
containerd 是由 Docker 公司开发的,并捐献给了云原生计算基金会(Cloud Native Computing Foundation, CNCF)。2017 年 12 月发布了 1.0 版本,具体的发布信息见 GitHub 中的 containerd/ containerd 库的 releases。
启动一个新的容器
常用的启动容器的方法就是使用 Docker 命令行工具。下面的docker container run
命令会基于 alpine:latest 镜像启动一个新容器。
1 | $ docker container run --name ctr1 -it alpine:latest sh |
当使用 Docker 命令行工具执行如上命令时,Docker 客户端会将其转换为合适的 API 格式,并发送到正确的 API 端点
API 是在 daemon 中实现的。这套功能丰富、基于版本的 REST API 已经成为 Docker 的标志,并且被行业接受成为事实上的容器 API
一旦 daemon 接收到创建新容器的命令,它就会向 containerd 发出调用。daemon 已经不再包含任何创建容器的代码了
daemon 使用一种 CRUD 风格的 API,通过 gRPC 与 containerd 进行通信。
虽然名叫 containerd,但是它并不负责创建容器,而是指挥 runc 去做
containerd 将 Docker 镜像转换为 OCI bundle,并让 runc 基于此创建一个新的容器。然后,runc 与操作系统内核接口进行通信,基于所有必要的工具(Namespace、CGroup等)来创建容器。容器进程作为 runc 的子进程启动,启动完毕后,runc 将会退出
此模型的优势
将所有的用于启动、管理容器的逻辑和代码从 daemon 中移除,意味着容器运行时与 Docker daemon 是解耦的,有时称之为“无守护进程的容器(daemonless container)”,如此,对 Docker daemon 的维护和升级工作不会影响到运行中的容器。
在旧模型中,所有容器运行时的逻辑都在 daemon 中实现,启动和停止 daemon 会导致宿主机上所有运行中的容器被杀掉。
这在生产环境中是一个大问题——想一想新版 Docker 的发布频次吧!每次 daemon 的升级都会杀掉宿主机上所有的容器,这太糟了!
Shim
shim 是实现无 daemon 的容器(用于将运行中的容器与 daemon 解耦,以便进行 daemon 升级等操作)不可或缺的工具。
前面提到,containerd 指挥 runc 来创建新容器。事实上,每次创建容器时它都会 fork 一个新的 runc 实例。
不过,一旦容器创建完毕,对应的 runc 进程就会退出。因此,即使运行上百个容器,也无须保持上百个运行中的 runc 实例。
一旦容器进程的父进程 runc 退出,相关联的 containerd-shim 进程就会成为容器的父进程。作为容器的父进程,shim 的部分职责如下。
- 保持所有 STDIN 和 STDOUT 流是开启状态,从而当 daemon 重启的时候,容器不会因为管道(pipe)的关闭而终止。
- 将容器的退出状态反馈给 daemon。
在 Linux 中的实现
在 Linux 系统中,前面谈到的组件由单独的二进制来实现,具体包括 dockerd(Docker daemon)、docker-containerd(containerd)、docker-containerd-shim (shim) 和 docker-runc (runc)。
通过在 Docker 宿主机的 Linux 系统中执行 ps 命令可以看到以上组件的进程。当然,有些进程只有在运行容器的时候才可见
daemon 的作用
当所有的执行逻辑和运行时代码都从 daemon 中剥离出来之后,问题出现了—— daemon 中还剩什么?显然,随着越来越多的功能从 daemon 中拆解出来并被模块化,这一问题的答案也会发生变化。
不过,daemon 的主要功能包括镜像管理、镜像构建、REST API、身份验证、安全、核心网络以及编排。
Docker 镜像(image)详解
如果曾经做过 VM 管理员,则可以把 Docker 镜像理解为 VM 模板,VM 模板就像停止运行的 VM,而 Docker 镜像就像停止运行的容器;而作为一名研发人员,则可以将镜像理解为类(Class)
首先需要先从镜像仓库服务中拉取镜像。常见的镜像仓库服务是 Docker Hub,但是也存在其他镜像仓库服务。拉取操作会将镜像下载到本地 Docker 主机,可以使用该镜像启动一个或者多个容器。镜像由多个层组成,每层叠加之后,从外部看来就如一个独立的对象。镜像内部是一个精简的操作系统(OS),同时还包含应用运行所必须的文件和依赖包。
因为容器的设计初衷就是快速和小巧,所以镜像通常都比较小。前面多次提到镜像就像停止运行的容器(类)。实际上,可以停止某个容器的运行,并从中创建新的镜像。
在该前提下,镜像可以理解为一种构建时(build-time)结构,而容器可以理解为一种运行时(run-time)结构,如下图所示。
镜像和容器
上图从顶层设计层面展示了镜像和容器间的关系。通常使用docker container run和docker service create命令从某个镜像启动一个或多个容器。一旦容器从镜像启动后,二者之间就变成了互相依赖的关系,并且在镜像上启动的容器全部停止之前,镜像是无法被删除的。尝试删除镜像而不停止或销毁使用它的容器,会导致出错。
镜像通常比较小
容器目的就是运行应用或者服务,这意味着容器的镜像中必须包含应用/服务运行所必需的操作系统和应用文件。但是,容器又追求快速和小巧,这意味着构建镜像的时候通常需要裁剪掉不必要的部分,保持较小的体积。
例如,Docker 镜像通常不会包含 6 个不同的 Shell 让读者选择——通常 Docker 镜像中只有一个精简的Shell,甚至没有 Shell。
镜像中还不包含内核——容器都是共享所在 Docker 主机的内核。所以有时会说容器仅包含必要的操作系统(通常只有操作系统文件和文件系统对象)
提示:Hyper-V 容器运行在专用的轻量级 VM 上,同时利用 VM 内部的操作系统内核。
Docker 官方镜像 Alpine Linux 大约只有 4MB,可以说是 Docker 镜像小巧这一特点的比较典型的例子。
但是,镜像更常见的状态是如 Ubuntu 官方的 Docker 镜像一般,大约有 110MB。这些镜像中都已裁剪掉大部分的无用内容。
Windows 镜像要比 Linux 镜像大一些,这与 Windows OS 工作原理相关。比如,未压缩的最新 Microsoft .NET 镜像(microsoft/dotnet:latest)超过 1.7GB。Windows Server 2016 Nano Server 镜像(microsoft/nanoserver:latest)在拉取并解压后,其体积略大于 1GB
拉取镜像
Docker 主机安装之后,本地并没有镜像。
docker image pull
是下载镜像的命令。镜像从远程镜像仓库服务的仓库中下载。
默认情况下,镜像会从 Docker Hub 的仓库中拉取。docker image pull alpine:latest
命令会从 Docker Hub 的 alpine 仓库中拉取标签为 latest 的镜像。
Linux Docker 主机本地镜像仓库通常位于 /var/lib/docker/storage-driver,Windows Docker 主机则是 C:\ProgramData\docker\windowsfilter。
可以使用以下命令检查 Docker 主机的本地仓库中是否包含镜像。
1 | $ docker image ls |
将镜像取到 Docker 主机本地的操作是拉取。所以,如果读者想在 Docker 主机使用最新的 Ubuntu 镜像,需要拉取它。通过下面的命令可以将镜像拉取到本地,并观察其大小。
提示:如果使用 Linux,并且还没有将当前用户加入到本地 Docker UNIX 组中,则需要在下面的命令前面添加 sudo。
镜像仓库服务
Docker 镜像存储在镜像仓库服务(Image Registry)当中。
Docker 客户端的镜像仓库服务是可配置的,默认使用 Docker Hub。
镜像仓库服务包含多个镜像仓库(Image Repository)。同样,一个镜像仓库中可以包含多个镜像。可能这听起来让人有些迷惑,所以下图展示了包含 3 个镜像仓库的镜像仓库服务,其中每个镜像仓库都包含一个或多个镜像。
官方和非官方镜像仓库
Docker Hub 也分为官方仓库(Official Repository)和非官方仓库(Unofficial Repository)
顾名思义,官方仓库中的镜像是由 Docker 公司审查的。这意味着其中的镜像会及时更新,由高质量的代码构成,这些代码是安全的,有完善的文档和最佳实践。
非官方仓库更像江湖侠客,其中的镜像不一定具备官方仓库的优点,但这并不意味着所有非官方仓库都是不好的!非官方仓库中也有一些很优秀的镜像。
在信任非官方仓库镜像代码之前需要我们保持谨慎。说实话,读者在使用任何从互联网上下载的软件之前,都要小心,甚至是使用那些来自官方仓库的镜像时也应如此。
大部分流行的操作系统和应用在 Docker Hub 的官方仓库中都有其对应镜像。这些镜像很容易找到,基本都在 Docker Hub 命名空间的顶层。
镜像命名和标签
只需要给出镜像的名字和标签,就能在官方仓库中定位一个镜像(采用“:”分隔)。从官方仓库拉取镜像时,docker image pull 命令的格式如下。
1 | docker image pull <repository>:<tag> |
在之前的 Linux 示例中,通过下面的两条命令完成 Alpine 和 Ubuntu 镜像的拉取。
1 | docker image pull alpine:latest |
这两条命令从 alpine 和 ubuntu 仓库拉取了标有“latest”标签的镜像。
下面来介绍一下如何从官方仓库拉取不同的镜像。
1 | $ docker image pull mongo:3.3.11 |
关于上述命令,需要注意以下几点。
首先,如果没有在仓库名称后指定具体的镜像标签,则 Docker 会假设用户希望拉取标签为 latest 的镜像。
其次,标签为 latest 的镜像没有什么特殊魔力!标有 latest 标签的镜像不保证这是仓库中最新的镜像!例如,Alpine 仓库中最新的镜像通常标签是 edge。通常来讲,使用 latest 标签时需要谨慎!
从非官方仓库拉取镜像也是类似的,读者只需要在仓库名称面前加上 Docker Hub 的用户名或者组织名称。
下面通过示例来展示如何从 tu-demo 仓库中拉取 v2 这个镜像,其中镜像的拥有者是 Docker Hub 账户 nigelpoulton,一个不应该被信任的账户
1 | $ docker image pull nigelpoulton/tu-demo:v2 |
如果希望从第三方镜像仓库服务获取镜像(非 Docker Hub),则需要在镜像仓库名称前加上第三方镜像仓库服务的 DNS 名称。
假设上面的示例中的镜像位于 Google 容器镜像仓库服务(GCR)中,则需要在仓库名称前面加上 gcr.io,如 docker pull gcr.io/nigelpoulton/tu-demo:v2
(这个仓库和镜像并不存在)
为镜像打多个标签
关于镜像有一点不得不提,一个镜像可以根据用户需要设置多个标签。这是因为标签是存放在镜像元数据中的任意数字或字符串。一起来看下面的示例。
在 docker image pull
命令中指定 -a
参数来拉取仓库中的全部镜像。接下来可以通过运行 docker image ls
查看已经拉取的镜像。
1 | $ docker image ls |
注意看 docker image ls
命令输出中的 IMAGE ID 这一列。发现只有两个不同的 Image ID。这是因为实际只下载了两个镜像,其中有两个标签指向了相同的镜像。
换句话说,其中一个镜像拥有两个标签。如果仔细观察会发现 v1 和 latest 标签指向了相同的 IMAGE ID,这意味着这两个标签属于相同的镜像。
这个示例也完美证明了前文中关于 latest 标签使用的警告。latest 标签指向了 v1 标签的镜像。这意味着 latest 实际指向了两个镜像中较早的那个版本,而不是最新的版本!latest 是一个非强制标签,不保证指向仓库中最新的镜像!
过滤 docker image ls
的输出内容
Docker 提供 --filter
参数来过滤 docker image ls
命令返回的镜像列表内容。
下面的示例只会返回悬虚(dangling)镜像。
1 | $ docker image ls --filter dangling=true |
那些没有标签的镜像被称为悬虚镜像,在列表中展示为
通常出现这种情况,是因为构建了一个新镜像,然后为该镜像打了一个已经存在的标签。当此情况出现,Docker 会构建新的镜像,然后发现已经有镜像包含相同的标签,接着 Docker 会移除旧镜像上面的标签,将该标签标在新的镜像之上。
例如,首先基于 alpine:3.4 构建一个新的镜像,并打上 dodge:challenger 标签。然后更新 Dockerfile,将 alpine:3.4 替换为 alpine:3.5,并且再次执行 docker image build
命令,该命令会构建一个新的镜像,并且标签为 dodge:challenger,同时移除了旧镜像上面对应的标签,旧镜像就变成了悬虚镜像。
可以通过 docker image prune
命令移除全部的悬虚镜像。如果添加了 -a 参数,Docker 会额外移除没有被使用的镜像(那些没有被任何容器使用的镜像)
Docker 目前支持如下的过滤器。
- dangling:可以指定 true 或者 false,仅返回悬虚镜像(true),或者非悬虚镜像(false)。
- before:需要镜像名称或者 ID 作为参数,返回在之前被创建的全部镜像。
- since:与 before 类似,不过返回的是指定镜像之后创建的全部镜像。
- label:根据标注(label)的名称或者值,对镜像进行过滤。docker image ls命令输出中不显示标注内容。
其他的过滤方式可以使用 reference。
下面就是使用 reference 完成过滤并且仅显示标签为 latest 的示例。
1 | $ docker image ls --filter=reference="*:latest" |
可以使用 –format 参数来通过 Go 模板对输出内容进行格式化。例如,下面的指令将只返回 Docker 主机上镜像的大小属性
1 | $ docker image ls --format "{{.Size}}" |
如果读者需要更复杂的过滤,可以使用 OS 或者 Shell 自带的工具,比如 Grep 或者 AWK
通过 CLI 方式搜索 Docker Hub
docker search
命令允许通过 CLI 的方式搜索 Docker Hub。可以通过“NAME”字段的内容进行匹配,并且基于返回内容中任意列的值进行过滤。
简单模式下,该命令会搜索所有“NAME”字段中包含特定字符串的仓库。例如,下面的命令会查找所有“NAME”包含“nigelpoulton”的仓库。
镜像和分层
Docker 镜像由一些松耦合的只读镜像层组成。如下图所示。
Docker 负责堆叠这些镜像层,并且将它们表示为单个统一的对象。查看镜像分层的方式可以通过 docker image inspect 命令。下面同样以 ubuntu:latest 镜像为例。
1 | $ docker image inspect ubuntu:latest |
docker history
命令显示了镜像的构建历史记录,但其并不是严格意义上的镜像分层。例如,有些 Dockerfile 中的指令并不会创建新的镜像层。比如 ENV、EXPOSE、CMD 以及 ENTRY- POINT。不过,这些命令会在镜像中添加元数据。
所有的 Docker 镜像都起始于一个基础镜像层,当进行修改或增加新的内容时,就会在当前镜像层之上,创建新的镜像层。
举一个简单的例子,假如基于 Ubuntu Linux 16.04 创建一个新的镜像,这就是新镜像的第一层;如果在该镜像中添加 Python 包,就会在基础镜像层之上创建第二个镜像层;如果继续添加一个安全补丁,就会创建第三个镜像层。
该镜像当前已经包含 3 个镜像层,如下图所示(这只是一个用于演示的很简单的例子)。
在添加额外的镜像层的同时,镜像始终保持是当前所有镜像的组合,理解这一点非常重要。下图中举了一个简单的例子,每个镜像层包含 3 个文件,而镜像包含了来自两个镜像层的 6 个文件
上图中的镜像层跟之前图中的略有区别,主要目的是便于展示文件。
下图中展示了一个稍微复杂的三层镜像,在外部看来整个镜像只有 6 个文件,这是因为最上层中的文件 7 是文件 5 的一个更新版本。
这种情况下,上层镜像层中的文件覆盖了底层镜像层中的文件。这样就使得文件的更新版本作为一个新镜像层添加到镜像当中。Docker 通过存储引擎(新版本采用快照机制)的方式来实现镜像层堆栈,并保证多镜像层对外展示为统一的文件系统。
Linux 上可用的存储引擎有 AUFS、Overlay2、Device Mapper、Btrfs 以及 ZFS。顾名思义,每种存储引擎都基于 Linux 中对应的文件系统或者块设备技术,并且每种存储引擎都有其独有的性能特点。
下图展示了与系统显示相同的三层镜像。所有镜像层堆叠并合并,对外提供统一的视图。