Docker-实战第二版-全-

Docker 实战第二版(全)

原文:Docker in Action 2e

译者:飞龙

协议:CC BY-NC-SA 4.0

第一章。欢迎使用 Docker

本章涵盖

  • Docker 是什么

  • 示例:“Hello, World”

  • 容器简介

  • Docker 如何解决大多数人容忍的软件问题

  • 何时、何地以及为什么应该使用 Docker

最佳实践是对你的产品或系统的一种可选投资,它应该在将来产生更好的结果。最佳实践可以增强安全性、防止冲突、提高可维护性或延长使用寿命。最佳实践通常需要倡导者,因为证明即时成本是困难的。特别是在系统或产品的未来不确定时更是如此。Docker 是一种工具,它使采用软件打包、分发和利用最佳实践变得既便宜又合理。它是通过提供对进程容器的完整愿景以及构建和使用它们的简单工具来做到这一点的。

如果你所在的团队运营具有动态扩展需求的软件服务,使用 Docker 部署软件可以帮助减少对客户的影响。容器启动更快,比虚拟机消耗的资源更少。

使用持续集成和持续部署技术的团队,如果使用 Docker,可以构建更具有表达力的管道并创建更健壮的功能测试环境。正在测试的容器包含将进入生产的相同软件。结果是更高的生产变更信心,更紧密的生产变更控制,以及更快的迭代。

如果你的团队使用 Docker 来模拟本地开发环境,你会减少成员的入职时间并消除那些减慢你速度的不一致性。这些相同的开发环境可以与软件一起进行版本控制,并随着软件需求的变化而更新。

软件作者通常知道如何使用合理的默认值和必需依赖项安装和配置他们的软件。如果你编写软件,使用 Docker 分发该软件将使你的用户更容易安装和运行它。他们可以利用你包含的默认配置和辅助材料。如果你使用 Docker,你可以将你的产品“安装指南”简化为一条命令和一个可携带的依赖项。

虽然软件作者理解依赖关系、安装和打包,但理解软件将运行的系统的系统管理员才真正了解这些系统。Docker 提供了一种在容器中运行软件的表达性语言。这种语言允许系统管理员注入特定环境的配置并严格控制对系统资源的访问。这种相同的语言,结合内置的包管理、工具和分发基础设施,使得部署声明性、可重复和可信。它促进了可丢弃的系统范式、持久状态隔离以及其他有助于系统管理员专注于更高价值活动的最佳实践。

Docker 于 2013 年 3 月推出,与你的操作系统协作来打包、运输和运行软件。你可以把 Docker 视为一个软件物流提供商,它将为你节省时间,让你专注于核心能力。你可以使用 Docker 与网络应用程序(如 Web 服务器、数据库和邮件服务器)以及终端应用程序(包括文本编辑器、编译器、网络分析工具和脚本)一起使用;在某些情况下,它甚至用于运行 GUI 应用程序,如 Web 浏览器和生产力软件。

Docker 在大多数系统上运行 Linux 软件。Docker for Mac 和 Docker for Windows 与常见的虚拟机(VM)技术集成,以在 Windows 和 macOS 上创建可移植性。但 Docker 也可以在现代 Windows 服务器机器上运行本机 Windows 应用程序。

Docker 不是一个编程语言,也不是构建软件的框架。Docker 是一个帮助解决常见问题(如安装、删除、升级、分发、信任和运行软件)的工具。它是开放源代码的 Linux 软件,这意味着任何人都可以为其做出贡献,并且它已经从各种观点中受益。公司赞助开源项目的发展是很常见的。在这种情况下,Docker Inc. 是主要赞助商。你可以在 docker.com/company/ 上了解更多关于 Docker Inc. 的信息。

1.1. 什么是 Docker?

如果你正在阅读这本书,你可能已经听说过 Docker。Docker 是一个用于构建、运输和运行程序的开放源代码项目。它是一个命令行程序,一个后台进程,以及一组远程服务,这些服务采用物流方法来解决常见的软件问题,并简化你安装、运行、发布和删除软件的体验。它是通过使用名为容器的一种操作系统技术来实现的。

1.1.1. “Hello, World”

通过一个具体的例子来学习这个主题会更容易。按照传统,我们将使用 “Hello, World。” 在开始之前,请下载并安装适用于你系统的 Docker。每个可用的系统的详细说明都保存在 docs.docker.com/install/ 上。一旦你安装了 Docker 并有一个活跃的互联网连接,前往你的命令提示符并输入以下内容:

docker run dockerinaction/hello_world

在这样做之后,Docker 将会启动。它将开始下载各种组件,最终打印出 "hello world"。如果你再次运行它,它只会打印出 "hello world"。在这个例子中,有几个事情正在发生,而这个命令本身有几个不同的部分。

首先,你使用 docker run 命令。这告诉 Docker 你想要触发一个序列(如图 1.1 所示),该序列会在容器内安装并运行一个程序。

图 1.1. 运行 docker run 后会发生什么

第二部分指定了 Docker 在容器中要运行的程序。在这个例子中,这个程序是 dockerinaction/hello_world。这被称为镜像(或仓库)名称。目前,你可以将镜像名称视为你想要安装或运行的程序的名称。镜像本身是一组文件和元数据。这些元数据包括要执行的特定程序和其他相关配置细节。

注意

这个仓库以及几个其他仓库是专门为支持本书中的示例而创建的。到 第二部分 结束时,你应该能够舒适地检查这些开源示例。

第一次运行此命令时,Docker 需要确定 docker-inaction/hello_world 镜像是否已经被下载。如果它无法在你的电脑上找到它(因为你第一次使用 Docker),Docker 会调用 Docker Hub。Docker Hub 是 Docker Inc. 提供的一个公共注册表。Docker Hub 回应运行在你电脑上的 Docker,指示镜像(docker-inaction/hello_world)的位置,然后 Docker 开始下载。

一旦镜像安装完成,Docker 将创建一个新的容器并运行一个单独的命令。在这种情况下,命令很简单:

echo "hello world"

echo 命令将 "hello world" 打印到终端后,程序退出,容器被标记为停止。理解到容器的运行状态直接与容器内单个运行程序的状态相关联。如果一个程序正在运行,容器也在运行。如果一个程序停止,容器也会停止。重新启动容器将再次运行程序。

当你第二次执行命令时,Docker 将再次检查 docker-inaction/hello_world 是否已安装。这次它将在本地机器上找到镜像,并可以立即构建另一个容器并执行它。我们想强调一个重要细节。当你第二次使用 docker run 时,它会从相同的仓库创建第二个容器(图 1.2 说明了这一点)。这意味着如果你反复使用 docker run 并创建了一堆容器,你需要获取你创建的容器列表,并在某个时候可能需要销毁它们。与容器一起工作就像创建它们一样简单,这两个主题都在 第二章 中进行了介绍。

图 1.2. 第二次运行 docker run。因为镜像已经安装,Docker 可以立即启动新的容器。

恭喜!您现在是一名官方的 Docker 用户。使用 Docker 非常简单。但它可以测试您对正在运行的应用程序的理解。考虑在一个容器中运行一个 Web 应用程序。如果您不知道它是一个长时间运行的应用程序,它监听 TCP 端口 80 上的传入网络通信,您可能不知道应该使用什么 Docker 命令来启动该容器。这些是人们在迁移到容器时遇到的问题。

尽管这本书不能针对您特定的应用需求进行评论,但它确实确定了常见用例,并帮助教授大多数相关的 Docker 使用模式。到第一部分结束时,您应该对使用 Docker 的容器有很强的掌握。

1.1.2. 容器

从历史上看,UNIX 风格的操作系统使用术语“监狱”来描述一个修改后的运行时环境,该环境限制了被监禁的程序可以访问的资源范围。监狱功能可以追溯到 1979 年,并且自那时以来一直在发展。2005 年,随着 Sun 的 Solaris 10 和 Solaris 容器的发布,容器已成为此类运行时环境的首选术语。目标已从限制文件系统范围扩展到隔离进程,除了明确允许的资源外,从所有资源中隔离进程。

使用容器已经是一项最佳实践很长时间了。但手动构建容器可能具有挑战性,并且很容易出错。这个挑战使一些人望而却步。其他人使用配置错误的容器,却陷入了一种虚假的安全感。这是一个亟待解决的问题,而 Docker 帮助解决了这个问题。任何使用 Docker 运行的软件都是在容器内运行的。Docker 使用现有的容器引擎来提供根据最佳实践构建的一致容器。这使得每个人都能获得更强的安全性。

使用 Docker,用户可以以更低的成本获得容器。在第 1.1.1 节中运行示例使用了一个容器,并且不需要任何特殊知识。随着 Docker 及其容器引擎的改进,您将获得最新和最强大的隔离功能。您不必跟上快速发展和高度技术化的构建强大容器的世界,而是可以让 Docker 为您处理大部分工作。

1.1.3. 容器不是虚拟化

在这个云原生时代,人们倾向于将虚拟机视为部署单元,其中部署单个进程意味着创建一个整个网络连接的虚拟机。虚拟机提供虚拟硬件(或可以安装操作系统和其他程序的硬件)。它们创建需要很长时间(通常是几分钟),并且由于它们除了您想要使用的软件外还运行整个操作系统,因此需要大量的资源开销。虚拟机在一切运行正常后可以表现出最佳性能,但启动延迟使它们不适合即时或反应式部署场景。

与虚拟机不同,Docker 容器不使用任何硬件虚拟化。Docker 容器内运行的程序直接与宿主机的 Linux 内核接口。许多程序可以在不运行冗余操作系统或遭受完整引导序列延迟的情况下独立运行。这是一个重要的区别。Docker 不是硬件虚拟化技术。相反,它帮助您使用已经内置到操作系统内核中的容器技术。

虚拟机提供硬件抽象,以便您可以在其中运行操作系统。容器是操作系统的一个特性。因此,如果机器运行的是现代 Linux 内核,您始终可以在虚拟机中运行 Docker。对于 Mac 和 Windows 用户,以及几乎所有云计算用户,他们都会在虚拟机中运行 Docker。因此,这些实际上是互补的技术。

1.1.4. 在容器中运行软件以实现隔离

容器和隔离特性已经存在了几十年。Docker 使用 Linux 命名空间和 cgroups,这些自 2007 年以来一直是 Linux 的一部分。Docker 不提供容器技术,但它特别简化了其使用。为了了解系统上的容器看起来是什么样子,让我们首先建立一个基线。图 1.3 显示了一个在简化的计算机系统架构上运行的基本示例。

图 1.3. 一个基本的计算机堆栈运行了从命令行启动的两个程序

图片

注意,命令行界面(CLI)运行在所谓的用户空间内存中,就像运行在操作系统之上的其他程序一样。理想情况下,运行在用户空间中的程序不能修改内核空间内存。广义上讲,操作系统是所有用户程序与计算机运行的硬件之间的接口。

您可以在图 1.4 中看到,运行 Docker 意味着在用户空间中运行两个程序。第一个是 Docker 引擎。如果安装正确,此进程应始终运行。第二个是 Docker CLI。这是用户与之交互的 Docker 程序。如果您想启动、停止或安装软件,您将通过 Docker 程序发出命令。

图 1.4. Docker 在基本的 Linux 计算机系统上运行三个容器

图片

图 1.4 也显示了三个正在运行的容器。每个容器都是作为 Docker 引擎的子进程运行的,被容器封装,并且代表进程在其用户空间的独立内存子空间中运行。容器内运行的程序只能访问由容器定义的自己的内存和资源。

Docker 使用 10 个主要系统特性来构建容器。本书的第一部分使用 Docker 命令来说明如何修改这些特性以满足容器内软件的需求,并适应容器运行的环境。具体特性如下:

  • PID 命名空间— 进程标识符和能力

  • UTS 命名空间— 主机和域名

  • MNT 命名空间— 文件系统访问和结构

  • IPC 命名空间— 通过共享内存的进程通信

  • NET 命名空间— 网络访问和结构

  • USR 命名空间— 用户名和标识符

  • chroot 系统调用— 控制文件系统根的位置

  • cgroups— 资源保护

  • CAP 降级— 操作系统功能限制

  • 安全模块— 强制访问控制

Docker 使用这些来在运行时构建容器,但它使用另一套技术来打包和分发容器。

1.1.5. 运输集装箱

你可以将 Docker 容器想象成一个物理的运输集装箱。它是一个存放并运行应用程序及其所有依赖项(不包括正在运行的操作系统内核)的盒子。正如起重机、卡车、火车和船只可以轻松地与运输集装箱协同工作一样,Docker 也可以轻松地运行、复制和分发容器。Docker 通过包括软件打包和分发的方式,完成了传统集装箱隐喻的完整性。在运输集装箱中起作用的组件被称为镜像。

第 1.1.1 节 中的示例使用了一个名为 dockerinaction/hello_world 的镜像。这个镜像包含单个文件:一个小型的可执行 Linux 程序。更普遍地说,Docker 镜像是一个捆绑的快照,包含了在容器内运行的程序应该可用的所有文件。你可以从一个镜像创建任意数量的容器。但是当你这样做时,从同一镜像启动的容器不会共享它们文件系统的更改。当你使用 Docker 分发软件时,你分发这些镜像,接收的计算机从它们创建容器。镜像是在 Docker 生态系统中的可运输单元。

Docker 提供了一套基础设施组件,简化了 Docker 镜像的分发。这些组件是注册表和索引。你可以使用 Docker Inc. 提供的公共基础设施,其他托管公司提供的,或者你自己的注册表和索引。

1.2. DOCKER 解决了哪些问题?

使用软件是复杂的。在安装之前,你必须考虑你使用的操作系统,软件需要的资源,已经安装的其他软件,以及它依赖的其他软件。你需要决定它应该安装在哪里。然后你需要知道如何安装它。今天安装过程的差异之大令人惊讶。考虑的事项列表很长且不容忍。安装软件至多是不一致和过度复杂的。如果你想要确保几台机器在一段时间内使用一致的软件集合,这个问题只会变得更糟。

包管理器如 APT、Homebrew、YUM 和 npm 试图管理这个问题,但其中很少有提供任何程度的隔离。大多数计算机安装并运行了不止一个应用程序。大多数应用程序都依赖于其他软件。当你想用的应用程序不能很好地协同工作时会发生什么?灾难!当应用程序共享依赖项时,事情只会变得更加复杂:

  • 如果一个应用程序需要升级依赖项,而另一个不需要,会发生什么?

  • 当你移除一个应用程序时会发生什么?它真的消失了?

  • 你能移除旧的依赖项吗?

  • 你能记得你为安装现在想要移除的软件所做的所有更改吗?

事实是,你使用的软件越多,管理起来就越困难。即使你能花时间和精力来弄清楚安装和运行应用程序,你对安全性的信心又能有多大?开源和闭源程序持续发布安全更新,而意识到所有问题往往是无法实现的。你运行的软件越多,它受到攻击的风险就越大。

即使是面向企业的服务软件也必须在依赖项中部署。这些项目通常与数百甚至数千个文件和其他程序一起发货并部署到机器上。每个都为冲突、漏洞或许可责任创造了新的机会。

所有这些问题都可以通过仔细的核算、资源管理和物流来解决,但这些是平凡且令人不愉快的事情。你最好把时间花在安装、升级或发布你正在尝试使用的软件上。构建 Docker 的人认识到了这一点,多亏了他们的辛勤工作,你几乎可以毫不费力地迅速找到解决方案。

很可能,今天大多数人觉得这些问题是可以接受的。也许它们看起来微不足道,因为你已经习惯了它们。在阅读了 Docker 如何使这些问题变得可接近之后,你可能注意到你的观点发生了转变。

1.2.1. 整理

没有 Docker,一台计算机最终可能看起来像是一个杂乱无章的抽屉。应用程序有各种各样的依赖项。一些应用程序依赖于特定的系统库来处理像声音、网络、图形等常见事物。其他应用程序依赖于它们所使用的语言的库。一些应用程序依赖于其他应用程序,例如 Java 程序依赖于 Java 虚拟机,或者一个网络应用程序可能依赖于数据库。一个正在运行的程序通常需要独占访问稀缺资源,如网络连接或文件。

今天,如果没有 Docker,应用程序会散布在文件系统中,最终形成一个混乱的交互网络。图 1.5 展示了在没有 Docker 的情况下,示例应用程序如何依赖于示例库。

图 1.5. 示例程序的依赖关系

相比之下,第 1.1.1 节中的示例自动安装了所需的软件,并且可以使用单个命令可靠地删除该软件。Docker 通过使用容器和镜像将一切隔离来保持事物的有序。

图 1.6 说明了这些相同的应用程序及其依赖项在容器内运行的情况。由于链接被断开,每个应用程序都被整洁地包含在内,理解系统成为一个可接近的任务。起初,这似乎会通过创建如 gcc 等常见依赖项的冗余副本来引入存储开销。第三章描述了 Docker 打包系统通常如何减少存储开销。

图 1.6. 在容器内运行并复制其依赖项的示例程序

图片

1.2.2. 提高可移植性

另一个软件问题是,应用程序的依赖项通常包括特定的操作系统。操作系统之间的可移植性是软件用户面临的一个主要问题。尽管 Linux 软件和 macOS 之间可能存在兼容性,但在 Windows 上使用相同的软件可能更困难。这样做可能需要构建整个移植版本的应用程序。即使这样,也只有在 Windows 存在合适的替代依赖项的情况下才可能。这对应用程序的维护者来说是一项重大努力,而且通常会被跳过。不幸的是,对于用户来说,大量强大的软件在他们的系统上使用起来太困难或不可能。

目前,Docker 在 Linux 上原生运行,并为 macOS 和 Windows 环境提供了一个单独的虚拟机。这种在 Linux 上的统一意味着在 Docker 容器中运行的软件只需针对一组一致的依赖项编写一次。你可能刚刚在想,“等等,你刚才不是告诉我 Docker 比虚拟机更好吗?”这是正确的,但它们是互补的技术。使用虚拟机来包含单个程序是浪费的。当你在一台计算机上运行多个虚拟机时,这种情况尤其如此。在 macOS 和 Windows 上,Docker 使用单个小型虚拟机来运行所有容器。通过采取这种方法,运行虚拟机的开销是固定的,而容器的数量可以扩展。

这种新的可移植性以几种方式帮助用户。首先,它解锁了一个之前无法访问的软件世界。其次,现在可以在任何系统上运行相同的软件——确切地说,是相同的软件。这意味着你的桌面、你的开发环境、你公司的服务器以及你公司的云都可以运行相同的程序。运行一致的环境很重要。这样做有助于最小化与采用新技术相关的任何学习曲线。它有助于软件开发者更好地理解将运行其程序的系统。这意味着更少的意外。第三,当软件维护者可以专注于为单个平台和一组依赖项编写程序时,这对他们来说节省了大量时间,对他们的客户来说是一个巨大的胜利。

没有 Docker 或虚拟机,通常通过基于通用工具的软件来实现单个程序的可移植性。例如,Java 允许程序员编写一个可以在多个操作系统上大部分正常工作的单一程序,因为程序依赖于一个名为 Java 虚拟机(JVM)的程序。虽然这在编写软件时是一个足够的方法,但其他公司的人编写了我们使用的绝大多数软件。例如,如果我们想使用一个流行的、不是用 Java 或其他类似可移植语言编写的 Web 服务器,我们怀疑作者会花时间为我们重写它。除了这个缺点之外,语言解释器和软件库正是造成依赖问题的东西。Docker 提高了无论用何种语言编写的程序、为哪种操作系统设计的程序,还是在何种环境中运行的程序的可移植性。

1.2.3. 保护你的电脑

我们之前提到的大部分问题都是从使用软件的角度出发,以及从容器外部使用软件的好处。但容器也保护我们免受容器内运行的软件的影响。程序可能会以各种方式表现不当或带来安全风险:

  • 一个程序可能是专门为攻击者编写的。

  • 好心的开发者可能会编写带有有害错误的程序。

  • 一个程序可能会因为其输入处理中的漏洞而意外地执行攻击者的指令。

无论从哪个角度看,运行软件都会使你的电脑安全处于风险之中。因为运行软件是拥有电脑的全部目的,所以采取实际的风险缓解措施是明智的。

就像物理牢房一样,容器内的任何东西只能访问它内部的东西。这个规则有例外,但只有当用户明确创建时。容器限制了程序对其他运行程序、它可以访问的数据和系统资源的影响范围。图 1.7 说明了在容器外和容器内运行软件之间的差异。

图 1.7. 左:直接访问敏感资源的恶意程序。右:容器内的恶意程序。

图片

这对你或你的业务意味着,与运行特定应用程序相关的任何安全威胁的范围仅限于应用程序本身的范围。创建强大的应用程序容器很复杂,是任何深入防御策略的关键组成部分。它通常被忽视或半心半意地实施。

1.3. 为什么 Docker 很重要?

Docker 提供了一个抽象。抽象允许你以简化的术语处理复杂的事物。因此,在 Docker 的情况下,我们不需要关注与安装应用程序相关的所有复杂性和具体细节,我们只需要考虑我们想要安装的软件即可。

就像起重机将一个货柜装上船一样,使用 Docker 安装任何软件的过程与其他方式相同。货柜内部物品的形状或大小可能不同,但起重机提起货柜的方式始终如一。所有工具都可以用于任何货柜。

这也适用于应用程序的移除。当你想要移除软件时,你只需告诉 Docker 要移除哪个软件。由于所有这些都被 Docker 包含并记录在案,因此不会有任何残留的碎片。你的电脑将和安装软件之前一样干净。

容器抽象和 Docker 提供的用于处理容器的工具已经改变了系统管理和软件开发领域。Docker 之所以重要,是因为它让容器对每个人来说都变得可用。使用它可以节省时间、金钱和能源。

Docker 之所以重要的第二个原因是,软件社区中有很大的推动力要采用容器和 Docker。这种推动力非常强烈,以至于包括亚马逊、微软和谷歌在内的公司都共同合作,为其开发做出贡献,并在他们自己的云服务中采用它。这些通常存在分歧的公司聚集在一起,支持一个开源项目,而不是开发并发布他们自己的解决方案。

Docker 之所以重要的第三个原因是,它为计算机完成了应用商店为移动设备所做的事情。它使软件安装、分区和移除变得简单。更好的是,Docker 以跨平台和开放的方式完成这些。想象一下,如果所有的主要智能手机都使用同一个应用商店,那将是一件大事。有了这项技术,操作系统之间的界限可能最终开始变得模糊,第三方产品在选择操作系统时的影响力将减小。

第四,我们终于开始看到操作系统一些更高级隔离功能的更好采用。这看起来可能微不足道,但相当多的人正在尝试通过操作系统级别的隔离来使计算机更加安全。他们的辛勤工作如此长时间才得到大规模采用,这确实令人遗憾。容器以某种形式存在了几十年。Docker 帮助我们利用这些功能,而不必承受所有复杂性,这真是太好了。

1.4. 何时何地使用 DOCKER

Docker 可以在大多数工作和家庭计算机上使用。实际上,应该将其扩展到什么程度?

Docker 几乎可以在任何地方运行,但这并不意味着您一定会想这样做。例如,目前 Docker 只能运行在 Linux 操作系统上运行的应用程序,或者在 Windows Server 上运行的 Windows 应用程序。如果您想在您的桌面上运行 macOS 或 Windows 原生应用程序,您目前还不能使用 Docker 来做到这一点。

通过将对话缩小到通常在 Linux 服务器或桌面运行的软件,可以有力地论证几乎任何应用程序都可以在容器内运行。这包括服务器应用程序,如 Web 服务器、邮件服务器、数据库、代理等。桌面软件,如网络浏览器、文字处理器、电子邮件客户端或其他工具也非常适合。即使是受信任的程序,如果它们与用户提供的或网络数据交互,其危险性也如同从互联网下载的程序一样。将这些程序在容器中以具有较低权限的用户身份运行,将有助于保护您的系统免受攻击。

除了增加的深入防御优势外,使用 DOCKER 进行日常任务有助于保持您的计算机整洁。保持计算机整洁可以防止您遇到共享资源问题,并简化软件的安装和卸载。同样的安装、卸载和分发便利性简化了计算机群的管理,并可能彻底改变公司对维护的看法。

最重要的是要记住,有时容器并不适用。容器对需要完全访问机器的程序的安全性帮助不大。在撰写本文时,这样做是可能的,但很复杂。容器不是安全问题的全面解决方案,但它们可以用来防止许多类型的攻击。记住,您不应该使用来自不可信来源的软件。如果该软件需要管理员权限,这一点尤其正确。这意味着盲目在协同定位环境中运行客户提供的容器是一个糟糕的主意。

1.5. 在更大生态系统中的 DOCKER

今天,更大的容器生态系统拥有丰富的工具,可以解决新的或更高层次的问题。这些问题包括容器编排、高可用性集群、微服务生命周期管理和可见性。在没有依赖关键词关联的情况下,在这个市场中导航可能会很棘手。理解 Docker 和那些产品如何协同工作甚至更困难。

这些产品以插件的形式与 Docker 一起工作,或提供某种更高层次的功能,并依赖于 Docker。一些工具使用 Docker 的子组件。这些子组件是独立的项目,例如 runc、libcontainerd 和 notary。

Kubernetes 是生态系统中最引人注目的项目,除了 Docker 本身之外。Kubernetes 为在集群环境中编排服务作为容器提供了一个可扩展的平台。它正在成长为一个“数据中心操作系统”。像 Linux 内核一样,云提供商和平台公司正在打包 Kubernetes。Kubernetes 依赖于容器引擎,如 Docker,因此您在笔记本电脑上构建的容器和镜像将在 Kubernetes 中运行。

在选择任何工具时,您需要考虑几个权衡。Kubernetes 从其可扩展性中汲取力量,但这是以学习曲线和持续支持工作为代价的。今天,构建、定制或扩展 Kubernetes 集群是一项全职工作。但使用现有的 Kubernetes 集群来部署您的应用程序却很简单,只需进行最少的研究。大多数查看 Kubernetes 的读者在构建自己的集群之前应该考虑采用主要公共云提供商的托管服务。本书专注于并教授使用 Docker 解决更高层次问题的解决方案。一旦您了解了问题是什么以及如何使用一个工具来解决它们,您更有可能成功地掌握更复杂的工具。

1.6. 使用 Docker 命令行程序获取帮助

您将在本书的其余部分使用 docker 命令行程序。为了让您开始使用它,我们想向您展示如何从 docker 程序本身获取有关命令的信息。这样,您就会了解如何使用您计算机上的确切版本的 Docker。打开终端或命令提示符,并运行以下命令:

docker help

运行 docker help 将显示有关使用 docker 命令行程序的基本语法以及程序版本的完整命令列表。试一试,并花点时间欣赏您能做的事情。

docker help 只会提供关于可用命令的高级信息。要获取特定命令的详细信息,请将命令包含在 <COMMAND> 参数中。例如,您可能输入以下命令来了解如何将容器内部位置上的文件复制到主机机器上的位置:

docker help cp

这将显示 docker cp 的使用模式,该命令的一般描述以及其参数的详细分解。我们相信,现在你知道了如何查找帮助,如果你需要的话,你将能够愉快地完成本书中介绍的其他命令。

摘要

本章简要介绍了 Docker 以及它帮助系统管理员、开发人员和其他软件用户解决的问题。在本章中,你了解到:

  • Docker 采用物流方法来解决常见的软件问题,并简化了你在安装、运行、发布和删除软件时的体验。它是一个命令行程序、一个后台引擎进程以及一系列远程服务。它与 Docker Inc. 提供的社区工具集成。

  • 容器抽象是其物流方法的核心。

  • 使用容器而不是软件创建了一个一致的接口,并使得更复杂的工具的开发成为可能。

  • 容器有助于保持你的计算机整洁,因为容器内的软件无法与容器外部的东西交互,也无法形成共享依赖。

  • 由于 Docker 可在 Linux、macOS 和 Windows 上使用并得到支持,因此大多数打包在 Docker 镜像中的软件都可以在任何计算机上使用。

  • Docker 不提供容器技术;它隐藏了直接与容器软件工作的复杂性,并将最佳实践转化为合理的默认设置。

  • Docker 与更大的容器生态系统协同工作;该生态系统拥有丰富的工具,可以解决新的和更高级别的问题。

  • 如果你在使用命令时需要帮助,你可以始终咨询 docker help 子命令。

第一部分. 进程隔离和环境无关计算

隔离是许多计算模式、资源管理策略和一般会计实践的核心概念,以至于很难开始列出。了解 Linux 容器如何为运行程序提供隔离以及如何使用 Docker 来控制这种隔离的人可以完成惊人的重用、资源效率和系统简化壮举。

学习如何应用容器最困难的部分在于将您试图隔离的软件的需求进行转换。不同的程序有不同的需求。Web 服务与文本编辑器、软件包管理器、编译器或数据库不同。为这些程序创建的容器将需要不同的配置。

本部分涵盖容器配置和操作的基本原理。它扩展到更详细的容器配置,以展示其全部功能范围。因此,我们建议您尽量抵制跳过前面的内容的冲动。找到您心中具体问题的答案可能需要一些时间,但我们相信,在这个过程中您将会有许多启示。

第二章. 在容器中运行软件

本章节涵盖

  • 在容器中运行交互式和守护进程终端程序

  • 基本 Docker 操作和命令

  • 将程序相互隔离并注入配置

  • 在容器中运行多个程序

  • 可持久容器和容器生命周期

  • 清理

在本章结束之前,您将了解与容器工作相关的所有基础知识以及如何使用 Docker 控制基本进程隔离。本书中的大多数示例都使用了真实软件。实际示例将有助于介绍 Docker 功能并说明您如何在日常活动中使用它们。使用现成的镜像也有助于降低新用户的学习曲线。如果您有希望容器化的软件并且急于完成,那么第二部分可能会回答您更多直接的问题。

在本章中,您将安装一个名为 NGINX 的 Web 服务器。Web 服务器是使网站文件和程序通过网络对 Web 浏览器可访问的程序。您不会构建网站,但您将使用 Docker 安装并启动一个 Web 服务器。

2.1. 控制容器:构建网站监控器

假设一位新客户走进你的办公室,并提出了一个无理的要求,要求你为他们构建一个新网站:他们想要一个密切监控的网站。这位特定的客户希望运行自己的操作,因此他们希望你提供的解决方案在服务器宕机时向他们的团队发送电子邮件。他们还听说过这个流行的 Web 服务器软件 NGINX,并特别要求你使用它。在阅读了关于与 Docker 合作的优点后,你决定为这个项目使用 Docker。图 2.1 显示了你的项目计划架构。

图 2.1. 你将在本例中构建的三个容器

本例使用三个容器。第一个将运行 NGINX;第二个将运行一个名为邮件发送程序的程序。这两个都将作为独立容器运行。独立意味着容器将在后台运行,不连接到任何输入或输出流。还有一个名为watcher的第三个程序将在交互式容器中作为监控代理运行。邮件发送程序和监控代理都是为这个例子创建的小脚本。在本节中,你将学习以下内容:

  • 创建独立和交互式容器

  • 列出系统上的容器

  • 查看容器日志

  • 停止和重启容器

  • 将终端重新连接到容器

  • 从连接的容器断开连接

不再拖延,让我们开始为客户填写订单。

2.1.1. 创建并启动新容器

Docker 将运行软件程序所需的所有文件和指令的集合称为镜像。当我们使用 Docker 安装软件时,我们实际上是在使用 Docker 下载或创建一个镜像。安装镜像有不同的方法,并且有多个镜像来源。镜像将在第三章中详细介绍,但就现在而言,你可以将它们视为用于在世界各地运输物理商品的运输集装箱。Docker 镜像包含了计算机运行软件所需的一切。

在本例中,我们将从 Docker Hub 下载并安装 NGINX 的镜像。记住,Docker Hub 是由 Docker Inc.提供的公共注册库。NGINX 镜像是 Docker Inc.所说的可信仓库。通常,发布软件的个人或基金会控制该软件的可信仓库。运行以下命令将下载、安装并启动一个运行 NGINX 的容器:

docker run --detach \ 1 --name web nginx:latest

  • 1 注意到独立标志。

当你运行此命令时,Docker 将从 Docker Hub 上托管的 NGINX 仓库(在第三章中介绍)安装nginx:latest并运行该软件。Docker 安装并启动运行 NGINX 后,终端将写入一行看似随机的字符。它看起来可能像这样:

7cb5d2b9a7eab87f07182b5bf58936c9947890995b1b94f412912fa822a9ecb5

这串字符是刚刚创建用于运行 NGINX 的容器的唯一标识符。每次你运行docker run并创建一个新的容器时,该新容器都会获得一个唯一的标识符。用户通常会将此输出捕获到变量中,以便在其他命令中使用。在这个示例中,你不需要这样做。

在显示标识符后,可能看起来好像没有发生任何事情。这是因为你使用了--detach选项,并在后台启动了程序。这意味着程序已经启动,但没有连接到你的终端。以这种方式启动 NGINX 是有意义的,因为我们将要运行几个程序。服务器软件通常在独立容器中运行,因为软件很少依赖于连接的终端。

运行独立容器非常适合那些安静地运行在后台的程序。这类程序被称为守护进程或服务。守护进程通常通过网络或其他通信渠道与其他程序或人类进行交互。当你在一个想要在后台运行的容器中启动守护进程或其他程序时,请记住使用--detach标志或其简写形式-d

在这个示例中,你的客户端还需要另一个守护进程,即邮件发送程序。邮件发送程序等待来自调用者的连接,然后发送电子邮件。以下命令安装并运行了一个邮件发送程序,该程序适用于本示例:

docker run -d \ 1 --name mailer \ dockerinaction/ch2_mailer

  • 1 开始独立运行

此命令使用--detach标志的简写形式在后台启动了一个名为mailer的新容器。到目前为止,你已经运行了两个命令,并交付了客户端所需系统的三分之二。最后一个组件,称为代理,非常适合交互式容器。

2.1.2. 运行交互式容器

基于终端的文本编辑器是要求连接终端的程序的一个很好的例子。它通过键盘(可能还有鼠标)从用户那里获取输入,并在终端上显示输出。它在输入和输出流上是交互式的。在 Docker 中运行交互式程序需要将你的终端的一部分绑定到正在运行的容器的输入或输出。

要开始使用交互式容器,请运行以下命令:

docker run --interactive --tty \ 1 --link web:web \ --name web_test \ busybox:1.29 /bin/sh

  • 1 创建虚拟终端并绑定 stdin

该命令在run命令中使用两个标志:--interactive(或-i)和--tty(或-t)。首先,--interactive选项告诉 Docker 即使在没有终端连接的情况下,也要保持容器标准输入流(stdin)打开。其次,--tty选项告诉 Docker 为容器分配一个虚拟终端,这将允许你向容器传递信号。这通常是交互式命令行程序所期望的。当你运行如交互式容器中的 shell 这样的交互式程序时,你通常会使用这两个选项。

与交互式标志一样重要的是,当你启动这个容器时,你指定了容器内要运行的程序。在这种情况下,你运行了一个名为sh的 shell 程序。你可以在容器内运行任何可用的程序。

交互式容器中的命令创建了一个容器,启动了一个 UNIX shell,并将其链接到运行 NGINX 的容器。从这个 shell 中,你可以运行命令来验证你的 Web 服务器是否正常运行:

wget -O - http://web:80/

这使用了一个名为wget的程序向 Web 服务器(你之前在容器中启动的 NGINX 服务器)发起 HTTP 请求,然后在你的终端上显示网页内容。在其他行中,应该有一条消息类似于Welcome to NGINX!。如果你看到这条消息,那么一切正常,你可以继续通过输入exit来关闭这个交互式容器。这将终止 shell 程序并停止容器。

有可能创建一个交互式容器,手动在该容器内启动一个进程,然后断开你的终端。你可以通过按住 Ctrl(或控制)键并按 P 然后按 Q 来实现。这只有在使用了--tty选项的情况下才会生效。

为了完成你客户的工作,你需要启动一个代理。这是一个监控代理,它将像前面的例子那样测试 Web 服务器,并在 Web 服务器停止时通过邮件发送消息。这个命令将通过使用简写标志在交互式容器中启动代理:

docker run -it \ 1 --name agent \     --link web:insideweb \     --link mailer:insidemailer \     dockerinaction/ch2_agent

  • 1 创建一个虚拟终端并绑定 stdin

在运行时,容器将每秒测试一次 Web 容器,并打印出如下信息:

System up.

现在你已经看到了它的工作方式,请从容器中断开你的终端。具体来说,当你启动容器并开始写入System up时,按住 Ctrl(或控制)键然后按 P 再按 Q。这样做之后,你将返回到宿主计算机的 shell。不要停止程序;否则,监视器将停止检查 Web 服务器。

虽然你通常会在你的网络服务器上部署软件时使用分离或守护进程容器,但交互式容器对于在桌面或服务器上手动工作很有用。到目前为止,你已经启动了客户端需要的所有三个容器中的应用程序。在你自信地声称完成之前,你应该测试系统。

2.1.3. 列出、停止、重启和查看容器输出

测试你当前设置的第一件事是使用docker ps命令检查当前正在运行的容器:

docker ps

运行该命令将显示每个正在运行的容器的以下信息:

  • 容器的 ID

  • 使用的镜像

  • 容器中执行的命令

  • 容器创建的时间

  • 容器运行的时间

  • 容器暴露的网络端口

  • 容器的名称

到目前为止,你应该有三个正在运行的容器,名称分别为:webmaileragent。如果任何一个容器缺失,但到目前为止你已按照示例操作,那么它可能被错误地停止了。这不是问题,因为 Docker 有一个命令可以重新启动容器。接下来的三个命令将通过容器名称重新启动每个容器。选择适当的命令来重新启动列表中缺失的容器:

docker restart web docker restart mailer docker restart agent

现在三个容器都已经启动,你需要测试系统是否正常运行。最好的方法是检查每个容器的日志。从web容器开始:

docker logs web

这应该会显示一个包含以下子字符串的长日志,包含多行:

"GET / HTTP/1.0" 200

这意味着 Web 服务器正在运行,代理正在测试网站。每次代理测试网站时,其中一行将被写入日志。docker logs命令在这种情况下可能很有用,但依赖它是有风险的。程序写入 stdout 或 stderr 输出流中的任何内容都将记录在此日志中。这种模式的问题在于日志默认不会旋转或截断,因此写入容器日志的数据将保持并随着容器的存在而增长。这种长期持久性可能对长期运行的过程造成问题。更好的方法是使用卷来处理日志数据,这在第四章(index_split_037.html#filepos379268)中有讨论。

你可以通过检查web的日志来了解代理是否正在监控 Web 服务器。为了完整性,你应该检查maileragent的日志输出:

docker logs mailer docker logs agent

mailer的日志应该看起来像这样:

CH2 Example Mailer has started.

agent的日志应该包含几行类似于你在启动容器时观察到的日志:

System up.

小贴士

docker logs命令有一个标志--follow-f,它将显示日志,然后继续监视并随着日志的更改更新显示。完成时,按 Ctrl-C(或 Command-C)中断logs命令。

现在您已经验证了容器正在运行,并且代理可以访问 Web 服务器,您应该测试代理是否会在 Web 容器停止时注意到。当发生这种情况时,代理应该触发对邮件器的调用,并且该事件应该记录在agentmailer的日志中。docker stop命令告诉容器中 PID 为 1 的程序停止。在以下命令中使用它来测试系统:

docker stop web 1 docker logs mailer 2

  • 1 通过停止容器停止 Web 服务器

  • 2 等待几秒钟并检查邮件器日志

在邮件器日志的末尾查找类似以下内容的行:

发送邮件:收件人:admin@work 信息:服务已中断!

这行意味着代理成功检测到名为web的容器中的 NGINX 服务器已停止。恭喜!您的客户会感到高兴,您已经使用容器和 Docker 构建了您的第一个真实系统。

学习 Docker 的基本功能是一回事,但理解它们为什么有用以及如何使用它们来定制隔离是另一项完全不同的任务。

2.2. 解决问题和 PID 命名空间

在 Linux 机器上运行的每个程序或进程都有一个唯一的数字,称为进程标识符(PID)。PID 命名空间是一组唯一的数字,用于标识进程。Linux 提供了创建多个 PID 命名空间的工具。每个命名空间都包含一组可能的 PID。这意味着每个 PID 命名空间将包含其自己的 PID 1, 2, 3,等等。

大多数程序不需要访问其他正在运行的过程或能够列出系统上其他正在运行的过程。因此,Docker 默认为每个容器创建一个新的 PID 命名空间。容器的 PID 命名空间将隔离该容器中的进程与其他容器中的进程。

从一个具有其命名空间的容器的进程的角度来看,PID 1 可能指的是 init 系统进程,如runitsupervisord。在另一个容器中,PID 1 可能指的是命令 shell,如 bash。运行以下命令以查看其实际效果:

docker run -d --name namespaceA \ busybox:1.29 /bin/sh -c "sleep 30000" docker run -d --name namespaceB \ busybox:1.29 /bin/sh -c "nc -l 0.0.0.0 -p 80" docker exec namespaceA ps 1 docker exec namespaceB ps 2

命令 1 应生成一个类似于以下的过程列表:

PID   用户     时间  命令   1   root     0:00  sleep 30000   8   root     0:00  ps

命令 2 应生成一个略有不同的过程列表:

PID   用户     时间  命令   1   root     0:00  nc -l 0.0.0.0 -p 80   9   root     0:00  ps

在这个例子中,你使用docker exec命令在运行中的容器中运行额外的进程。在这种情况下,你使用的命令被称为ps,它显示了所有运行中的进程及其 PID。从输出中,你可以清楚地看到每个容器都有一个 PID 为 1 的进程。

没有 PID 命名空间,容器内运行的进程将与其他容器或主机上的进程共享相同的 ID 空间。容器中的进程能够确定主机机器上正在运行的其他进程。更糟糕的是,一个容器中的进程可能能够控制其他容器中的进程。无法引用其命名空间外任何进程的进程在执行针对性攻击的能力上受到限制。

与大多数 Docker 隔离功能一样,你可以选择性地创建没有自己的 PID 命名空间的容器。如果你正在使用一个程序执行需要从容器内部进行进程枚举的系统管理任务,这是至关重要的。你可以通过在docker createdocker run上设置--pid标志并将值设置为host来自行尝试。使用运行 BusyBox Linux 的容器和ps Linux 命令来尝试它:

docker run --pid host busybox:1.29 ps 1

  • 1 应列出计算机上运行的所有进程

因为所有容器都有自己的 PID 命名空间,它们都不能从检查中获得有意义的见解,并且可能会对其产生更多的静态依赖。假设一个容器运行了两个进程:一个服务器和一个本地进程监控器。那个监控器可以严格依赖于服务器的预期 PID,并使用它来监控和控制服务器。这是一个环境独立性的例子。

考虑之前的网络监控示例。假设你没有使用 Docker,而是直接在你的计算机上运行 NGINX。现在假设你忘记了你已经为另一个项目启动了 NGINX。当你再次启动 NGINX 时,第二个进程将无法访问它所需的资源,因为第一个进程已经占有了它们。这是一个基本的软件冲突示例。你可以通过尝试在同一容器中运行两个 NGINX 副本来看到它的实际效果:

docker run -d --name webConflict nginx:latest docker logs webConflict 1 docker exec webConflict nginx -g 'daemon off;' 2

  • 1 输出应该是空的。

  • 2 在同一容器中启动第二个 NGINX 进程

最后一条命令应该显示如下输出:

2015/03/29 22:04:35 [emerg] 10#0: bind() to 0.0.0.0:80 failed (98: Address already in use) nginx: [emerg] bind() to 0.0.0.0:80 failed (98: Address already in use) ...

第二个进程无法正确启动,并报告它需要的地址已被占用。这被称为端口冲突,这是在现实世界中常见的冲突问题,其中多个进程在同一台计算机上运行或多个人贡献于同一环境。这是一个很好的例子,说明 Docker 简化并解决了冲突问题。将每个进程运行在不同的容器中,如下所示:

docker run -d --name webA nginx:latest 1 docker logs webA 2 docker run -d --name webB nginx:latest 3 docker logs webB 4

  • 1 启动第一个 NGINX 实例

  • 2 验证其是否正常工作;应该是空的。

  • 3 启动第二个实例

  • 4 验证其是否正常工作;应该是空的

环境独立性提供了配置依赖于稀缺系统资源的软件的自由,而不考虑其他位于同一位置的具有冲突要求的软件。以下是一些常见的冲突问题:

  • 两个程序想要绑定到相同的网络端口。

  • 两个程序使用相同的临时文件名,文件锁阻止了这种情况。

  • 两个程序想要使用全局安装的库的不同版本。

  • 两个进程想要使用相同的 PID 文件。

  • 你安装的第二个程序修改了一个其他程序使用的环境变量。现在第一个程序崩溃了。

  • 多个进程正在竞争内存或 CPU 时间。

所有这些冲突都是在一个或多个程序有一个共同的依赖关系,但无法同意共享或有不同的需求时出现的。就像之前的端口冲突例子一样,Docker 通过 Linux 命名空间、资源限制、文件系统根和虚拟化网络组件等工具来解决软件冲突。所有这些工具都用于在 Docker 容器内隔离软件。

2.3. 消除元冲突:构建网站农场

在前面的章节中,你看到了 Docker 如何帮助你通过进程隔离避免软件冲突。但如果你不小心,你可能会构建出创建元冲突的系统,即 Docker 层中容器之间的冲突。

考虑另一个例子:一位客户要求你构建一个系统,在这个系统上你可以为他们的客户托管可变数量的网站。他们还希望使用你在此章早期构建的相同监控技术。扩展你之前构建的系统是完成这项工作的最简单方法,而不需要为 NGINX 定制配置。在这个例子中,你将构建一个包含多个运行 Web 服务器的容器和每个 Web 服务器的监控监视器的系统。该系统将类似于图 2.2 中描述的架构。

图 2.2. 一支由 Web 服务器容器和相关监控代理组成的舰队

你的第一反应可能是简单地启动更多的 Web 容器。但这并不像看起来那么简单。随着容器数量的增加,识别容器会变得复杂。

2.3.1. 灵活的容器标识

要找出为什么仅仅复制之前示例中使用的 NGINX 容器的更多副本是一个糟糕的想法,最好的方法就是亲自尝试:

docker run -d --name webid nginx 1 docker run -d --name webid nginx 2

  • 1 创建一个名为 "webid" 的容器

  • 2 创建另一个名为 "webid" 的容器

这里第二个命令将因冲突错误而失败:

FATA[0000] 错误响应来自守护进程:冲突。名称 "webid" 已经被容器 2b5958ba6a00 使用。您必须删除(或重命名)该容器才能重用该名称。

使用固定容器名称,如 web,对于实验和文档很有用,但在具有多个容器的系统中,使用这样的固定名称可能会引起冲突。默认情况下,Docker 为其创建的每个容器分配一个唯一的(对人类友好的)名称。--name 标志使用已知值覆盖该过程。如果出现需要更改容器名称的情况,您始终可以使用 docker rename 命令重命名容器:

docker rename webid webid-old 1 docker run -d --name webid nginx 2

  • 1 将当前 web 容器重命名为 "webid-old"

  • 2 创建另一个名为 "webid" 的容器

重命名容器可以帮助缓解一次性命名冲突,但对于避免最初的问题帮助不大。除了名称外,Docker 还分配了一个唯一的标识符,这在第一个示例中已经提到。这些是十六进制编码的 1024 位数字,看起来像这样:

7cb5d2b9a7eab87f07182b5bf58936c9947890995b1b94f412912fa822a9ecb5

当以分离模式启动容器时,其标识符将被打印到终端。您可以在需要识别特定容器的任何命令中使用这些标识符代替容器名称。例如,您可以使用之前的 ID 与 stopexec 命令一起使用:

docker exec \ \ \ 7cb5d2b9a7eab87f07182b5bf58936c9947890995b1b94f412912fa822a9ecb5 \ echo hello docker stop \ \ \ 7cb5d2b9a7eab87f07182b5bf58936c9947890995b1b94f412912fa822a9ecb5

生成的 ID 具有高度的唯一性,这意味着几乎不可能与该 ID 发生冲突。在某种程度上,甚至不太可能在该计算机上发生该 ID 的前 12 个字符的冲突。因此,在大多数 Docker 接口中,您会看到容器 ID 截短为其前 12 个字符。这使得生成的 ID 更易于用户使用。您可以在需要容器标识符的地方使用它们。前两个命令可以写成这样:

docker exec 7cb5d2b9a7ea ps docker stop 7cb5d2b9a7ea

这两个 ID 都不太适合人类使用。但它们与脚本和自动化技术配合得很好。Docker 有几种获取容器 ID 的方法,以便实现自动化。在这些情况下,将使用完整或截断的数字 ID。

获取容器数字 ID 的第一种方法是通过简单地启动或创建一个新的容器,并将命令的结果分配给 shell 变量。如您之前所见,当以分离模式启动新容器时,容器 ID 将被写入终端(stdout)。如果这是获取创建时容器 ID 的唯一方法,您将无法使用此方法与交互式容器一起使用。幸运的是,您可以使用另一个命令创建一个容器而不启动它。docker create命令类似于docker run,主要区别在于容器是在停止状态下创建的:

docker create nginx

结果应该像这样的一行:

b26a631e536d3caae348e9fd36e7661254a11511eb2274fb55f9f7c788721b0d

如果您使用的是 Linux 命令 shell,如 sh 或 bash,您可以将该结果分配给 shell 变量,并在以后再次使用它:

CID=$(docker create nginx:latest) 1 echo $CID

  • 1 这将在 POSIX 兼容的 shell 上工作。

Shell 变量创建了一个新的冲突机会,但这种冲突的范围仅限于启动脚本的终端会话或当前处理环境。这些冲突应该是容易避免的,因为只有一个使用或程序在管理该环境。这种方法的问题在于,如果多个用户或自动化进程需要共享该信息,则这种方法不会有所帮助。在这些情况下,您可以使用容器 ID(CID)文件。

docker rundocker create命令都提供了一个额外的标志,可以将新容器的 ID 写入一个已知的文件:

docker create --cidfile /tmp/web.cid nginx 1 cat /tmp/web.cid 2

  • 1 创建一个新的停止容器

  • 2 检查文件

与 Shell 变量的使用一样,这个特性增加了冲突的机会。CID 文件的名称(在--cidfile之后提供)必须是已知的或具有某种已知结构。就像手动容器命名一样,这种方法在全局(Docker-wide)命名空间中使用已知名称。好消息是,如果提供的 CID 文件已经存在,Docker 不会使用该文件创建一个新的容器。命令将失败,就像您创建两个具有相同名称的容器时一样。

使用 CID 文件而不是名称的一个原因是可以轻松地将 CID 文件与容器共享,并为该容器重命名。这使用了一个名为卷的 Docker 功能,该功能在第四章中介绍。

小贴士

处理 CID 文件命名冲突的一种策略是通过使用已知或可预测的路径约定来分区命名空间。例如,在这个场景中,您可能使用一个包含所有 Web 容器的已知目录的路径,并通过客户 ID 进一步分区该目录。这将导致一个路径,如/containers/web/customer1/web.cid 或/containers/web/customer8/web.cid。

在其他情况下,您可以使用其他命令,如 docker ps 来获取容器的 ID。例如,如果您想获取最后创建的容器的截断 ID,可以使用以下命令:

CID=$(docker ps --latest --quiet) 1 echo $CID CID=$(docker ps -l -q) 2 echo $CID

  • 1 这将在符合 POSIX 标准的 shell 上工作。

  • 2 再次使用简短形式的标志运行。

提示

如果您想获取完整的容器 ID,可以在 docker ps 命令中使用 --no-trunc 选项。

自动化案例涵盖了您迄今为止所看到的特性。尽管截断有所帮助,但这些容器 ID 很少容易阅读或记忆。因此,Docker 还为每个容器生成了可读性强的名称。

命名约定使用一个个人形容词;一个下划线;以及一位有影响力的科学家、工程师、发明家或其他类似思想领袖的姓氏。生成的名称示例有 compassionate_swartzhungry_goodalldistracted_turing。这些名称似乎在可读性和记忆性方面达到了一个完美的平衡。当你直接使用 docker 工具时,你总是可以使用 docker ps 来查找人性化的名称。

容器识别可能很复杂,但您可以通过使用 Docker 的 ID 和名称生成功能来管理这个问题。

2.3.2 容器状态和依赖关系

带着这些新知识,新的系统可能看起来像这样:

MAILER_CID=$(docker run -d dockerinaction/ch2_mailer) 1 WEB_CID=$(docker create nginx) AGENT_CID=$(docker create --link $WEB_CID:insideweb --link $MAILER_CID:insidemailer dockerinaction/ch2_agent)

  • 1 确保第一个示例中的邮件发送器正在运行。

这段代码可以用来启动一个新的 NGINX 和代理实例,为您的每位客户的客户。您可以使用 docker ps 来查看它们是否已创建:

docker ps

NGINX 或代理未包含在输出中的原因与容器状态有关。Docker 容器将处于 图 2.3 中所示的状态之一。在 Docker 容器管理命令之间移动状态时,会注释每个转换。

图 2.3. Docker 容器的状态转换图

您启动的两个新容器都没有出现在容器列表中,因为 docker ps 默认只显示正在运行的容器。这些容器是专门使用 docker create 创建的,从未启动(处于创建状态)。要查看所有容器(包括处于创建状态的容器),请使用 -a 选项:

docker ps -a

新容器的状态应该是 "Created"docker ps 命令使用灰色显示的“友好”名称显示容器状态(例如,图 2.3)。docker inspect 命令使用每个状态下半部分的名称(例如,created)。restartingremovingdead(未展示)状态是 Docker 内部的,用于跟踪docker ps中可见状态之间的转换。

现在你已经验证了两个容器都已创建,你需要启动它们。为此,你可以使用 docker start 命令:

docker start $AGENT_CID docker start $WEB_CID

运行这些命令会导致错误。容器需要按照其依赖链的相反顺序启动。因为你尝试在启动 web 容器之前启动 agent 容器,Docker 报告了如下信息:

Error response from daemon: Cannot start container 03e65e3c6ee34e714665a8dc4e33fb19257d11402b151380ed4c0a5e38779d0a: Cannot link to a non running container: /clever_wright AS /modest_hopper/insideweb FATA[0000] Error: failed to start one or more containers

在这个例子中,agent 容器依赖于 web 容器。你需要先启动 web 容器:

docker start $WEB_CID docker start $AGENT_CID

当你考虑到其中的机制时,这就有意义了。链接机制将 IP 地址注入依赖的容器中,而未运行的容器没有 IP 地址。如果你尝试启动一个依赖于未运行容器的容器,Docker 将没有 IP 地址可以注入。在第五章中,你将学习如何使用用户定义的桥接网络连接容器,以避免这种特定的依赖问题。这里的关键点是,Docker 会在创建或启动容器之前尝试解决容器的依赖关系,以避免应用程序运行时失败。

容器网络链接的传统

你可能会注意到,Docker 文档将网络链接描述为传统功能。网络链接是早期且流行的一种连接容器的方法。链接从一个容器创建到同一主机上其他容器的单向网络连接。容器生态系统的大部分内容都要求容器之间有完全对等的双向连接。Docker 通过第五章中描述的用户定义网络提供这一点。这些网络也可以像第十三章中描述的那样跨越主机集群。网络链接和用户定义网络不等同,但 Docker 建议迁移到用户定义网络。

容器网络链接功能是否会被移除还不确定。许多有用的工具和单向通信模式依赖于链接,正如本节中用于检查和监视 Web 和邮件组件的容器所示。

无论你使用docker run还是docker create,生成的容器需要按照它们的依赖链的相反顺序启动。这意味着使用 Docker 容器关系构建循环依赖是不可能的。

在这个阶段,你可以将所有内容整合成一个简洁的脚本,如下所示:

MAILER_CID=$(docker run -d dockerinaction/ch2_mailer) WEB_CID=$(docker run -d nginx) AGENT_CID=$(docker run -d \ --link $WEB_CID:insideweb \ --link $MAILER_CID:insidemailer \ dockerinaction/ch2_agent)

现在,你确信这个脚本可以在每次你的客户需要部署新站点时无例外地运行。你的客户回来了,感谢你迄今为止完成的网站和监控工作,但情况已经发生了变化。

他们决定专注于使用 WordPress(一个流行的开源内容管理和博客程序)来构建他们的网站。幸运的是,WordPress 通过 Docker Hub 发布在名为wordpress的仓库中。你所需要提供的只是一组命令,用于部署一个具有相同监控和警报功能的新的 WordPress 网站。

内容管理系统和其他有状态系统的一个有趣之处在于,它们处理的数据使得每个运行程序都变得专业化。亚当的 WordPress 博客与贝蒂的 WordPress 博客不同,即使它们运行的是相同的软件。只有内容是不同的。即使内容相同,它们也是不同的,因为它们运行在不同的网站上。

如果你构建的系统或软件对其环境了解得太多——例如,依赖服务的地址或固定位置——那么改变该环境或重用该软件就变得困难。在合同完成之前,你需要提供一个最小化环境依赖的系统。

2.4. 构建环境无关的系统

与安装软件或维护计算机群相关的许多工作都在于处理计算环境的专门化。这些专门化表现为全局范围的依赖(例如,已知的宿主文件系统位置)、硬编码的部署架构(代码或配置中的环境检查)或数据局部性(在部署架构之外的特定计算机上存储的数据)。了解这一点后,如果你的目标是构建低维护系统,你应该努力最小化这些因素。

Docker 有三个特定的功能来帮助构建环境无关的系统:

  • 只读文件系统

  • 环境变量注入

与卷一起工作是一个很大的主题,也是第四章卷的主题。要了解前两个功能,请考虑本章其余部分使用的示例情况的需求变更:WordPress 使用一个名为 MySQL 的数据库程序来存储大部分数据,因此为运行 WordPress 的容器提供一个只读文件系统以确保数据只写入数据库是一个好主意。

2.4.1. 只读文件系统

使用只读文件系统可以完成两件好事。首先,您可以确信容器不会因为包含的文件的变化而变得特殊化。其次,您对攻击者无法破坏容器中的文件有了更大的信心。

要开始为客户系统工作,请使用 --read-only 标志从 WordPress 镜像创建并启动一个容器:

docker run -d --name wp --read-only \ wordpress:5.0.0-php7.2-apache

当这完成时,请检查容器是否正在运行。您可以使用之前介绍的任何方法,或者直接检查容器元数据。以下命令将打印 true 如果名为 wp 的容器正在运行,否则打印 false

docker inspect --format "{{.State.Running}}" wp

docker inspect 命令将显示 Docker 为容器维护的所有元数据(一个 JSON 文档)。格式选项将转换这些元数据,在这种情况下,它过滤掉除了表示容器运行状态的字段之外的所有内容。这个命令应该简单地输出 false

在这种情况下,容器没有运行。要确定原因,请检查容器的日志:

docker logs wp

那个命令应该输出类似以下内容:

WordPress not found in /var/www/html - copying now... Complete! WordPress has been successfully copied to /var/www/html ... skip output ... Wed Dec 12 15:17:36 2018 (1): Fatal Error Unable to create lock file: Bad file descriptor (9)

当使用只读文件系统运行 WordPress 时,Apache 网络服务器进程报告它无法创建锁文件。不幸的是,它没有报告它试图创建的文件的位置。如果我们有这些位置,我们可以为它们创建例外。让我们运行一个具有可写文件系统的 WordPress 容器,这样 Apache 就可以自由地写入它想要的地方:

docker run -d --name wp_writable wordpress:5.0.0-php7.2-apache

现在让我们使用 docker diff 命令检查 Apache 在哪里更改了容器的文件系统:

docker container diff wp_writable C /run C /run/apache2 A /run/apache2/apache2.pid

我们将在第三章 diff 命令中更详细地解释 diff 命令以及 Docker 如何知道文件系统上发生了什么变化。现在,只需知道输出指示 Apache 创建了 /run/apache2 目录并在其中添加了 apache2.pid 文件就足够了。

由于这是正常应用程序操作的一部分,我们将对只读文件系统做出例外。我们将允许容器通过从主机挂载的可写卷写入/run/apache2,同时我们还将向容器提供位于/tmp 的临时内存文件系统,因为 Apache 需要可写的临时目录,如下所示:

docker run -d --name wp2 \ --read-only \ 1 -v /run/apache2/ \ 2 --tmpfs /tmp \ 3 wordpress:5.0.0-php7.2-apache

  • 1 将容器的根文件系统设置为只读

  • 2 从主机挂载一个可写目录

  • 3 为容器提供一个内存临时文件系统

该命令应该记录类似这样的成功消息:

docker logs wp2 WordPress not found in /var/www/html - copying now... Complete! WordPress has been successfully copied to /var/www/html ... skip output ... [Wed Dec 12 16:25:40.776359 2018] [mpm_prefork:notice] [pid 1] AH00163: Apache/2.4.25 (Debian) PHP/7.2.13 configured -- resuming normal operations [Wed Dec 12 16:25:40.776517 2018] [core:notice] [pid 1] AH00094: Command line: 'apache2 -D FOREGROUND'

WordPress 还依赖于 MySQL 数据库。数据库是一个程序,它以可检索和可搜索的方式存储数据。好消息是你可以使用 Docker 安装 MySQL,就像 WordPress 一样:

docker run -d --name wpdb \ -e MYSQL_ROOT_PASSWORD=ch2demo \ mysql:5.7

一旦启动,创建一个连接到这个新数据库容器的不同 WordPress 容器:

docker run -d --name wp3 \ 1 --link wpdb:mysql \ 2 -p 8000:80 \ 3 --read-only \ -v /run/apache2/ \ --tmpfs /tmp \ wordpress:5.0.0-php7.2-apache

  • 1 使用一个唯一名称

  • 2 创建到数据库的链接

  • 3 将主机端口 8000 的流量导向容器端口 80

再次检查 WordPress 是否运行正确:

docker inspect --format "{{.State.Running}}" wp3

输出现在应该是true。如果你想使用你的新 WordPress 安装,你可以通过 Web 浏览器访问 http://127.0.0.1:8000.

你正在工作的脚本更新版本应该看起来像这样:

#!/bin/sh DB_CID=$(docker create -e MYSQL_ROOT_PASSWORD=ch2demo mysql:5.7) docker start $DB_CID MAILER_CID=$(docker create dockerinaction/ch2_mailer) docker start $MAILER_CID WP_CID=$(docker create --link $DB_CID:mysql -p 80 \ --read-only -v /run/apache2/ --tmpfs /tmp \ wordpress:5.0.0-php7.2-apache) docker start $WP_CID AGENT_CID=$(docker create --link $WP_CID:insideweb \ --link $MAILER_CID:insidemailer \ dockerinaction/ch2_agent) docker start $AGENT_CID

恭喜——在这个阶段,你应该有一个正在运行的 WordPress 容器!通过使用只读文件系统和将 WordPress 链接到运行数据库的另一个容器,你可以确保运行 WordPress 镜像的容器永远不会改变。这意味着如果运行客户 WordPress 博客的计算机出现任何问题,你应该能够在其他地方启动该容器的另一个副本而不会出现问题。

但这种设计有两个问题。首先,数据库在运行 WordPress 容器的同一台计算机上的容器中运行。其次,WordPress 正在使用数据库名称、管理员用户、管理员密码、数据库盐等重要设置的默认值。

为了解决这个问题,你可以创建 WordPress 软件的几个版本,每个版本都为客户配置了特殊配置。这样做会将你的简单配置脚本变成一个怪物,它会创建镜像并写入文件。更好的注入配置的方法是使用环境变量。

2.4.2. 环境变量注入

环境变量是通过程序的执行上下文提供给程序的键/值对。它们允许你更改程序配置,而无需修改任何文件或更改启动程序所使用的命令。

Docker 使用环境变量来传递有关依赖容器、容器的主机名以及其他方便程序在容器中运行的信息。Docker 还提供了一个机制,允许用户将环境变量注入到新的容器中。知道通过环境变量期望重要信息的程序可以在容器创建时进行配置。幸运的是,对于你和你的客户来说,WordPress 就是这样一种程序。

在深入研究 WordPress 的具体内容之前,尝试自己注入并查看环境变量。UNIX 命令env显示当前执行上下文(你的终端)中的所有环境变量。要查看环境变量注入的实际操作,请使用以下命令:

docker run --env MY_ENVIRONMENT_VAR="这是一个测试" \ 1 busybox:1.29 \ env 2

  • 1 注入环境变量

  • 2 在容器内执行 env 命令

可以使用--env标志(或简称-e)注入任何环境变量。如果变量已被镜像或 Docker 设置,则值将被覆盖。这样,容器内运行的程序可以依赖变量始终被设置。WordPress 观察以下环境变量:

  • WORDPRESS_DB_HOST

  • WORDPRESS_DB_USER

  • WORDPRESS_DB_PASSWORD

  • WORDPRESS_DB_NAME

  • WORDPRESS_AUTH_KEY

  • WORDPRESS_SECURE_AUTH_KEY

  • WORDPRESS_LOGGED_IN_KEY

  • WORDPRESS_NONCE_KEY

  • WORDPRESS_AUTH_SALT

  • WORDPRESS_SECURE_AUTH_SALT

  • WORDPRESS_LOGGED_IN_SALT

  • WORDPRESS_NONCE_SALT

小贴士

这个例子忽略了 KEYSALT 变量,但任何真正的生产系统都应该绝对设置这些值。

要开始,你应该解决数据库在运行 WordPress 容器的同一台计算机上的问题。与其使用链接来满足 WordPress 的数据库依赖,不如注入 WORDPRESS_DB_HOST 变量的值:

docker create --env WORDPRESS_DB_HOST=<my database hostname> \ wordpress: 5.0.0-php7.2-apache

这个例子将创建(而不是启动)一个 WordPress 容器,该容器将尝试连接到你在 <my database hostname> 中指定的 MySQL 数据库。因为远程数据库可能不会使用任何默认的用户名或密码,所以你还需要注入这些设置的值。假设数据库管理员是一位猫爱好者,并且讨厌强密码:

docker create \ --env WORDPRESS_DB_HOST=<my database hostname> \ --env WORDPRESS_DB_USER=site_admin \ --env WORDPRESS_DB_PASSWORD=MeowMix42 \ wordpress:5.0.0-php7.2-apache

使用这种方式的环境变量注入可以帮助你分离 WordPress 容器和 MySQL 容器之间的物理联系。即使你想要在同一台机器上托管数据库和客户 WordPress 网站,你仍然需要解决前面提到的第二个问题。所有网站都使用相同的默认数据库名称,这意味着不同的客户将共享单个数据库。你需要使用环境变量注入来通过指定 WORDPRESS_DB_NAME 变量设置每个独立网站的数据库名称:

docker create --link wpdb:mysql \ -e WORDPRESS_DB_NAME=client_a_wp \ 1 wordpress:5.0.0-php7.2-apache docker create --link wpdb:mysql \ -e WORDPRESS_DB_NAME=client_b_wp \ 2 wordpress:5.0.0-php7.2-apache

  • 1 对于客户 A

  • 2 对于客户 B

现在你已经了解了如何将配置注入到 WordPress 应用程序中并将其连接到协作进程,让我们来调整配置脚本。首先,让我们启动数据库和邮件容器,这些容器将由我们的客户共享,并将容器 ID 存储在环境变量中:

export DB_CID=$(docker run -d -e MYSQL_ROOT_PASSWORD=ch2demo mysql:5.7) export MAILER_CID=$(docker run -d dockerinaction/ch2_mailer)

现在更新客户端站点配置脚本以从环境变量中读取数据库容器 ID、邮件容器 ID 和新的 CLIENT_ID

#!/bin/sh if [ ! -n "$CLIENT_ID" ]; then 1 echo "Client ID not set" exit 1 fi WP_CID=$(docker create \ --link $DB_CID:mysql \ 2 --name wp_$CLIENT_ID \ -p 80 \ --read-only -v /run/apache2/ --tmpfs /tmp \ -e WORDPRESS_DB_NAME=$CLIENT_ID \ --read-only wordpress:5.0.0-php7.2-apache) docker start $WP_CID AGENT_CID=$(docker create \ --name agent_$CLIENT_ID \ --link $WP_CID:insideweb \ --link $MAILER_CID:insidemailer \ dockerinaction/ch2_agent) docker start $AGENT_CID

  • 1 假设$CLIENT_ID 变量已设置为脚本的输入

  • 2 使用 DB_CID 创建链接

如果你将此脚本保存为名为 start-wp-for-client.sh 的文件,你可以使用如下命令为dockerinaction客户端配置 WordPress:

CLIENT_ID=dockerinaction ./start-wp-multiple-clients.sh

这个新脚本将为每个客户启动一个 WordPress 实例和监控代理,并将这些容器相互连接,以及连接到一个单独的邮件发送程序和 MySQL 数据库。WordPress 容器可以被销毁、重启和升级,而无需担心数据丢失。图 2.4 显示了这种架构。

图 2.4。每个 WordPress 和代理容器使用相同的数据库和邮件发送程序。

客户应该对所提供的内容感到满意。但可能有一件事让你感到烦恼。在早期的测试中,你发现监控代理在网站不可用时正确地通知了邮件发送程序,但重启网站和代理需要手动操作。当检测到故障时,如果系统能够自动恢复会更好。Docker 提供了重启策略来帮助处理这种情况,但你可能需要更健壮的解决方案。

2.5. 构建持久容器

软件可能在罕见的情况下失败,这些情况本质上是暂时的。尽管在出现这些情况时通知是很重要的,但通常至少同样重要的是尽可能快地恢复服务。本章中构建的监控系统对于让系统所有者了解系统问题是一个很好的开始,但它对恢复服务没有帮助。

当容器中的所有进程都已退出时,该容器将进入已退出的状态。记住,Docker 容器可以处于以下六种状态之一:

  • Created

  • Running

  • Restarting

  • Paused

  • Removing

  • Exited(也用于容器从未启动过的情况)

从临时故障中恢复的基本策略是在进程退出或失败时自动重启该进程。Docker 提供了一些监控和重启容器的选项。

2.5.1. 自动重启容器

Docker 通过重启策略提供此功能。在容器创建时使用--restart标志,你可以告诉 Docker 执行以下任何一项操作:

  • 永不重启(默认)

  • 当检测到故障时尝试重启

  • 当检测到故障时,尝试在预定时间内重启

  • 不论条件如何,始终重启容器

Docker 并不总是立即尝试重启容器。如果它这样做,可能会造成更多问题而不是解决问题。想象一个容器除了打印时间和退出什么也不做。如果该容器被配置为始终重启,并且 Docker 总是立即重启它,系统将只会重启该容器。相反,Docker 使用指数退避策略来安排重启尝试的时间。

回退策略决定了连续重启尝试之间应该经过的时间。指数回退策略会在每次连续尝试中等待的时间翻倍。例如,如果容器第一次需要重启时 Docker 等待了 1 秒,那么第二次尝试时它将等待 2 秒,第三次尝试时等待 4 秒,第四次尝试时等待 8 秒,以此类推。具有低初始等待时间的指数回退策略是常见的服务恢复技术。你可以通过构建一个总是重启并简单地打印时间的容器来亲自看到 Docker 使用这种策略:

docker run -d --name backoff-detector --restart always busybox:1.29 date

然后,几秒钟后,使用尾部日志功能来观察它回退并重启:

docker logs -f backoff-detector

日志将显示它已经被重启的所有时间,并等待下一次重启,打印当前时间,然后退出。将此单个标志添加到监控系统和您一直在工作的 WordPress 容器中,就可以解决恢复问题。

你可能不希望直接采用这种策略的唯一原因是,在回退期间,容器不会运行。等待重启的容器处于重启状态。为了演示,尝试在 backoff-detector 容器中运行另一个进程:

docker exec backoff-detector echo Just a Test

运行该命令应该会显示错误信息:

容器 <ID> 正在重启,等待容器运行

这意味着你不能做任何需要容器处于运行状态的事情,比如在容器中执行额外的命令。如果你需要在损坏的容器中运行诊断程序,这可能会成为一个问题。一个更完整的策略是使用启动轻量级初始化系统的容器。

2.5.2. 使用 PID 1 和初始化系统

初始化系统是一种用于启动和维护其他程序状态的程序。Linux 内核将任何 PID 为 1 的进程视为初始化进程(即使它技术上不是初始化系统)。除了其他关键功能外,初始化系统还会启动其他进程,在它们失败时重启它们,转换并转发操作系统发送的信号,并防止资源泄漏。当容器将运行多个进程或运行的程序使用子进程时,在容器内部使用真实的初始化系统是一种常见的做法。

在容器内部可以使用几种这样的初始化系统。其中最受欢迎的有 runitYelp/dumb-inittinisupervisordtianon/gosu。使用这些程序的软件的发布内容在第八章中有详细说明。现在,让我们看看一个使用 supervisord 的容器。

Docker 提供了一个包含单个容器内完整 LAMP(Linux、Apache、MySQL PHP)堆栈的镜像。以这种方式创建的容器使用supervisord确保所有相关进程都保持运行。启动一个示例容器:

docker run -d -p 80:80 --name lamp-test tutum/lamp

您可以使用docker top命令查看容器内正在运行哪些进程:

docker top lamp-test

top子命令将显示容器中每个进程的主 PID。您将看到supervisordmysqlapache包含在运行程序列表中。现在容器正在运行,您可以通过手动停止容器内的一个进程来测试supervisord重启功能。

问题在于,要从容器内部杀死容器内的进程,您需要知道容器 PID 命名空间中的 PID。要获取该列表,请运行以下exec子命令:

docker exec lamp-test ps

生成的进程列表将在 CMD 列中列出apache2

PID TTY          TIME CMD   1 ?        00:00:00 supervisord 433 ?        00:00:00 mysqld_safe 835 ?        00:00:00 apache2 842 ?        00:00:00 ps

当您运行命令时,PID 列中的值将不同。找到apache2所在行的 PID,然后在以下命令中的<PID>处插入该值:

docker exec lamp-test kill <PID>

运行此命令将在lamp-test容器内运行 Linux 的kill程序,并指示apache2进程关闭。当apache2停止时,supervisord进程将记录事件并重新启动进程。容器日志将清楚地显示这些事件:

... ... exited: apache2 (exit status 0; expected) ... spawned: 'apache2' with pid 820 ... success: apache2 entered RUNNING state, process has stayed up for > than 1 seconds (startsecs)

使用 init 系统的一个常见替代方案是使用启动脚本,该脚本至少检查成功启动容器内软件的先决条件。这些有时被用作容器的默认命令。例如,您创建的 WordPress 容器通过运行一个脚本来验证并设置默认环境变量,然后启动 WordPress 进程。您可以通过覆盖默认命令来查看此脚本的内容,以查看启动脚本:

docker run wordpress:5.0.0-php7.2-apache \ cat /usr/local/bin/docker-entrypoint.sh

Docker 容器在执行命令之前会运行一个名为 entrypoint 的程序。Entrypoints 是放置验证容器预条件的代码的完美位置。尽管这一点在本书的第二部分([index_split_062.html#filepos666610])中有详细讨论,但你仍需要知道如何在命令行上覆盖或特别设置容器的 entrypoint。尝试再次运行最后一个命令,但这次使用--entrypoint标志来指定要运行的程序,并使用命令部分来传递参数:

docker run --entrypoint="cat" \ 1 wordpress:5.0.0-php7.2-apache \ /usr/local/bin/docker-entrypoint.sh 2

  • 1 使用“cat”作为 entrypoint

  • 2 将默认 entrypoint 脚本的完整路径作为参数传递给 cat

如果你运行显示的脚本,你会看到它是如何验证环境变量与软件依赖项的,并设置默认值。一旦脚本验证 WordPress 可以执行,它将启动请求的或默认命令。

启动脚本是构建耐用容器的重要部分,并且总是可以与 Docker 重启策略结合使用,以利用各自的优势。由于 MySQL 和 WordPress 容器已经使用了启动脚本,因此为每个容器在示例脚本的更新版本中设置重启策略是合适的。

当启动脚本未能满足 Linux 对 init 系统的期望时,以 PID 1 运行启动脚本是有问题的。根据你的使用情况,你可能发现一种方法或混合方法效果最好。

经过最后的修改,你已经构建了一个完整的 WordPress 站点配置系统,并学习了使用 Docker 进行容器管理的基础知识。这需要相当多的实验。你的电脑上可能充斥着几个你不再需要的容器。为了回收这些容器所使用的资源,你需要停止它们并将它们从系统中移除。

2.6. 清理工作

清理的简便性是使用容器和 Docker 的最强理由之一。容器提供的隔离性简化了停止进程和删除文件所需的任何步骤。使用 Docker,整个清理过程可以简化为几个简单的命令之一。在任何清理任务中,你必须首先确定你想要停止和/或删除的容器。记住,要列出你电脑上的所有容器,请使用docker ps命令:

docker ps -a

由于本章示例中创建的容器将不再使用,你应该能够安全地停止和删除所有列出的容器。确保你注意清理的容器,如果你为个人活动创建了任何容器的话。

所有容器都使用硬盘空间来存储日志、容器元数据和写入容器文件系统的文件。所有容器也消耗全局命名空间中的资源,例如容器名称和主机端口映射。在大多数情况下,不再使用的容器应该被移除。

要从您的计算机中移除容器,请使用 docker rm 命令。例如,要删除名为 wp 的已停止容器,您将运行以下命令:

docker rm wp

您应该遍历通过运行 docker ps -a 生成的所有容器列表,并移除所有处于退出状态的容器。如果您尝试移除一个正在运行、暂停或重新启动的容器,Docker 将显示如下消息:

错误响应来自守护进程:冲突,您不能移除一个正在运行的容器。在尝试移除之前停止容器或使用 -f FATA[0000] 错误:无法移除一个或多个容器

在移除容器中的文件之前,应该停止容器中运行的进程。您可以使用 docker stop 命令或通过在 docker rm 上使用 -f 标志来实现。关键区别在于,当您使用 -f 标志停止进程时,Docker 会发送 SIG_KILL 信号,该信号会立即终止接收到的进程。相比之下,使用 docker stop 会发送 SIG_HUP 信号。SIG_HUP 的接收者有时间执行最终化和清理任务。SIG_KILL 信号不提供此类允许,可能会导致文件损坏或网络体验不佳。您可以使用 docker kill 命令直接向容器发送 SIG_KILL 信号。但您应该只在必须将容器停止时间少于标准的 30 秒最大停止时间时使用 docker killdocker rm -f

在未来,如果您正在尝试使用短暂容器,您可以通过在命令中指定 --rm 来避免清理负担。这样做将在容器进入退出状态时自动移除容器。例如,以下命令将在新的 BusyBox 容器中向屏幕写入消息,并且容器将在退出后立即被移除:

docker run --rm --name auto-exit-test busybox:1.29 echo Hello World docker ps -a

在这种情况下,您可以使用 docker stopdocker rm 来正确清理,或者使用单步 docker rm -f 命令也是合适的。您还应该使用 -v 标志,原因将在第四章中介绍。docker CLI 使得快速组合清理命令变得容易:

docker rm -vf $(docker ps -a -q)

这就完成了在容器中运行软件的基础知识。第一部分 剩余的每一章都将专注于容器工作的一个特定方面。下一章将重点介绍安装和卸载镜像,了解镜像与容器之间的关系,以及与容器文件系统的工作。

摘要

Docker 项目的核心目标是使用户能够在容器中运行软件。本章展示了如何使用 Docker 达到这个目的。涵盖的想法和特性包括以下内容:

  • 容器可以以附加到用户 shell 的虚拟终端或分离模式运行。

  • 默认情况下,每个 Docker 容器都有自己的 PID 命名空间,为每个容器隔离进程信息。

  • Docker 通过生成的容器 ID、简化的容器 ID 或其友好的名称来标识每个容器。

  • 所有容器都处于以下六个不同的状态之一:已创建、正在运行、重新启动、暂停、正在删除或已退出。

  • 可以使用 docker exec 命令在运行的容器内运行额外的进程。

  • 用户可以通过在容器创建时指定环境变量,向容器中的进程传递输入或提供额外的配置。

  • 在容器创建时使用 --read-only 标志将容器文件系统挂载为只读,并防止容器专业化。

  • 容器重启策略,在容器创建时通过 --restart 标志设置,将帮助系统在发生故障时自动恢复。

  • Docker 使得使用 docker rm 命令清理容器变得与创建它们一样简单。

第三章. 软件安装简化

本章涵盖

  • 识别软件

  • 使用 Docker Hub 查找和安装软件

  • 从其他来源安装软件

  • 理解文件系统隔离

  • 与镜像和层一起工作

第一章 和 第二章 介绍了 Docker 提供的新概念和抽象。本章深入探讨了容器文件系统和软件安装。它将软件安装分解为三个步骤,如图 3.1 所示。

图 3.1. 本章涵盖的主题流程

图片

安装任何软件的第一步是确定你想要安装的软件。你知道软件是通过镜像进行分发的,但你需要知道如何告诉 Docker 你想要安装的确切镜像。我们已经提到,仓库包含镜像,但本章展示了如何使用仓库和标签来识别镜像,以便安装你想要的软件。

本章详细介绍了安装 Docker 镜像的三个主要方法:

  • 使用 Docker 仓库

  • 使用 docker savedocker load 命令使用镜像文件

  • 使用 Dockerfile 构建镜像

在阅读这份材料的过程中,你将了解 Docker 如何隔离已安装的软件,并接触到一个新的术语,层。层是处理镜像时的重要概念,提供了多个重要特性。本章以关于镜像工作原理的部分结束。这些知识将帮助你评估镜像质量,并为本书第二部分 part 2 建立一个基本技能集。

3.1. 识别软件

假设您想安装一个名为 TotallyAwesomeBlog 2.0 的程序。您会如何告诉 Docker 您想安装什么?您需要一个方法来命名程序,指定您想要使用的版本,以及指定您想要从中安装它的来源。学习如何识别特定软件是软件安装的第一步,如图 3.2 所示。

图 3.2. 步骤 1—软件识别

图片

您已经了解到 Docker 从图像创建容器。图像是一个文件。它包含将可用于从它创建的容器中的文件以及关于图像的元数据。这些元数据包含标签、环境变量、默认执行上下文、图像的命令历史记录等。

每个图像都有一个全局唯一的标识符。您可以使用该标识符与图像和容器命令一起使用,但在实践中,实际上与原始图像标识符一起工作的很少。它们是长串的字母和数字的唯一序列。每次对图像进行更改时,图像标识符都会更改。图像标识符难以处理,因为它们是不可预测的。相反,用户使用命名仓库。

3.1.1. 什么是命名仓库?

命名仓库是图像的命名桶。名称类似于 URL。仓库的名称由图像所在的主机名称、拥有图像的用户账户和简短名称组成,如图 3.3 所示。例如,在本章的后面,您将从名为docker.io/dockerinaction/ch3_hello_registry的仓库中安装一个图像。

图 3.3. Docker 图像仓库名称

图片

正如软件可以有多个版本一样,仓库可以包含多个图像。仓库中的每个图像都通过标签唯一标识。如果您要发布docker.io/dockerinaction/ch3_hello_registry的新版本,您可能会将其标记为v2,而将旧版本标记为v1。如果您想下载旧版本,您可以通过其v1标签具体标识该图像。

在第二章中,您从 Docker Hub 上的 NGINX 仓库安装了一个带有latest标签的图像。仓库名称和标签组成一个复合键,或由非唯一组件组合而成的唯一引用。在示例中,该图像通过nginx:latest进行标识。尽管以这种方式构建的标识符有时可能比原始图像标识符更长,但它们是可预测的,并且传达了图像的意图。

3.1.2. 使用标签

标签既是唯一标识图像的重要方式,也是创建有用别名的便捷方式。虽然标签只能应用于仓库中的单个图像,但单个图像可以有多个标签。这允许仓库所有者创建有用的版本或功能标签。

例如,Docker Hub 上的 Java 仓库维护以下标签:11-stretch11-jdk-stretch11.0-stretch11.0-jdk-stretch11.0.4-stretch11.0.4-jdk-stretch。所有这些标签都应用于相同的镜像。这个镜像是通过将当前的 Java 11 开发工具包(JDK)安装到 Debian Stretch 基础镜像中构建的。随着 Java 11 的当前补丁版本的增加,维护者发布了 Java 11.0.5,这个集合中的11.0.4标签将被替换为11.0.5。如果你关心你正在运行的 Java 11 的次要或补丁版本,你必须跟上这些标签的变化。如果你只想确保你始终运行 Java 11 的最新版本,请使用带有11-stretch标签的镜像。它应该始终分配给 Java 11 的最新发布版本。这些标签为用户提供了极大的灵活性。

对于具有不同软件配置的镜像,也常见到不同的标签。例如,我们为名为 freegeoip 的开源程序发布了两个镜像。这是一个可以用来获取与网络地址相关的大致地理位置的 Web 应用程序。一个镜像配置为使用软件的默认配置。它旨在通过直接链接到世界来独立运行。第二个配置为在 Web 负载均衡器后面运行。每个镜像都有一个独特的标签,使用户能够轻松地识别具有所需功能的镜像。

小贴士

当你在寻找要安装的软件时,总是要仔细注意仓库提供的标签。许多仓库发布了它们软件的多个版本,有时在多个操作系统上或在完整或精简版本中,以支持不同的用例。请参阅仓库的文档,了解仓库标签的具体含义和镜像发布实践。

那就是使用 Docker 识别软件的全部内容。有了这些知识,你就可以开始寻找和安装使用 Docker 的软件了。

3.2. 软件的查找和安装

你可以通过仓库名称来识别软件,但你是如何找到你想要安装的仓库的呢?发现可信赖的软件是复杂的,它是学习如何使用 Docker 安装软件的第二步,如图 3.4 所示。

图 3.4. 步骤 2——定位仓库

图片

找到镜像的最简单方法是使用索引。索引是目录仓库的搜索引擎。有几个公共 Docker 索引,但默认情况下docker与名为 Docker Hub 的索引集成。

Docker Hub 是由 Docker Inc.运营的具有 Web 用户界面的注册和索引。它是docker默认使用的注册和索引,位于主机docker.io上。当你发出不带指定替代注册表的docker pulldocker run命令时,Docker 将默认在 Docker Hub 上查找仓库。Docker Hub 使 Docker 更加实用。

Docker Inc. 已努力确保 Docker 是一个开放的生态系统。它发布了一个公共镜像以运行自己的注册表,并且 docker 命令行工具可以轻松配置以使用替代注册表。在本章的后面部分,我们将介绍 Docker 包含的替代镜像安装和分发工具。但首先,下一节将介绍如何使用 Docker Hub,以便你可以充分利用默认的工具集。

3.2.1. 从命令行使用 Docker 注册表

图像作者可以通过两种方式将图像发布到注册表,例如 Docker Hub:

  • 使用命令行推送他们独立且在自己的系统上构建的镜像。

  • 将 Dockerfile 公开并使用持续构建系统发布镜像。Dockerfile 是用于构建镜像的脚本。从这些自动化构建中创建的镜像更受欢迎,因为可以在安装镜像之前检查 Dockerfile。

大多数注册表都会要求图像作者在发布之前进行身份验证,并在他们更新的仓库上执行授权检查。在这些情况下,你可以使用 docker login 命令登录到特定的注册表服务器,如 Docker Hub。一旦登录,你将能够从私有仓库拉取,对你的仓库中的图像进行标记,并将图像推送到你控制的任何仓库。第七章 介绍了标记和推送图像。

运行 docker login 将提示你输入你的 Docker.com 凭据。一旦你提供了它们,你的命令行客户端将进行身份验证,你将能够访问你的私有仓库。当你完成与账户的工作后,可以使用 docker logout 命令登出。如果你使用的是不同的注册表,你可以将服务器名称作为 docker logindocker logout 子命令的参数指定。

3.2.2. 使用替代注册表

Docker 使注册表软件对任何人都可以运行。包括 AWS 和 Google 在内的云公司提供私有注册表,使用 Docker EE 或使用流行的 Artifactory 项目的公司已经拥有私有注册表。在 第八章 中介绍了使用开源组件运行注册表,但重要的是你早期就学会如何使用它们。

使用替代注册表很简单。它不需要额外的配置。你只需要注册表的地址。以下命令将从替代注册表下载另一个“Hello, World”类型的示例:

docker pull quay.io/dockerinaction/ch3_hello_registry:latest

注册表地址是 第 3.1 节 中涵盖的完整仓库规范的一部分。完整的模式如下:

[REGISTRYHOST:PORT/][USERNAME/]NAME[:TAG]

Docker 知道如何与 Docker 仓库通信,所以唯一的区别是你指定了仓库主机。在某些情况下,与仓库一起工作可能需要认证步骤。如果你遇到这种情况,请查阅文档或配置了仓库的团队以获取更多信息。当你完成安装的 hello-registry 镜像后,使用以下命令删除它:

docker rmi quay.io/dockerinaction/ch3_hello_registry

仓库非常强大。它们使用户能够放弃对镜像存储和传输的控制。但是运行自己的仓库可能很复杂,可能会为你的部署基础设施创建一个潜在的单一故障点。如果你的用例中运行自定义仓库听起来有点复杂,而且第三方分发工具又不可行,你可能考虑直接从文件加载镜像。

3.2.3. 将镜像作为文件处理

Docker 提供了一个命令,可以将镜像从文件加载到 Docker 中。使用这个工具,你可以加载通过其他渠道获取的镜像。也许你的公司选择通过中央文件服务器或某种版本控制系统来分发镜像。也许镜像足够小,你的朋友只是通过电子邮件发送给你,或者通过闪存驱动器共享。无论你是如何获得这个文件的,你都可以使用 docker load 命令将其加载到 Docker 中。

在我们向你展示 docker load 命令之前,你需要一个镜像文件。由于你不太可能有现成的镜像文件,我们将向你展示如何从已加载的镜像中保存一个。为了这个例子的目的,你将拉取 busybox:latest。这个镜像很小,易于处理。要将该镜像保存到文件中,请使用 docker save 命令。图 3.5 通过从 BusyBox 创建文件来演示 docker save

图 3.5. pullsave 子命令的部分

在这个例子中,我们使用了 .tar 文件名后缀,因为 docker save 命令创建 TAR 归档文件。你可以使用任何你想要的文件名。如果你省略了 –o 标志,生成的文件将流式传输到终端。

小贴士

其他使用 TAR 归档进行打包的生态系统定义了自定义文件扩展名。例如,Java 使用 .jar、.war 和 .ear。在这些情况下,使用自定义文件扩展名可以帮助暗示归档的目的和内容。尽管 Docker 没有设置默认值,也没有官方的指导,但如果你经常处理这些文件,使用自定义扩展名可能很有用。

在运行 save 命令后,docker 程序将无礼地终止。通过列出你的当前工作目录的内容来检查它是否工作。如果指定的文件在那里,使用以下命令从 Docker 中删除镜像:

docker rmi busybox

删除镜像后,使用 docker load 命令再次从您创建的文件中加载它。与 docker save 类似,如果您不使用 –i 命令运行 docker load,Docker 将使用标准输入流而不是从文件中读取存档:

docker load –i myfile.tar

运行 docker load 命令后,镜像应该被加载。您可以通过再次运行 docker images 命令来验证这一点。如果一切正常,BusyBox 应该包含在列表中。

将镜像作为文件处理与处理注册表一样简单,但您会错过注册表提供的所有良好分发设施。如果您想构建自己的分发工具,或者您已经有所准备,那么通过使用这些命令与 Docker 集成应该非常简单。

另一种流行的项目分发模式是使用带有安装脚本的文件包。这种方法在开源项目中很受欢迎,这些项目使用公共版本控制仓库进行分发。在这些情况下,您处理的是一个文件,但这个文件不是一个镜像;它是一个 Dockerfile。

3.2.4. 从 Dockerfile 安装

Dockerfile 是一个脚本,描述了 Docker 构建新镜像所需的步骤。这些文件与作者希望放入镜像中的软件一起分发。在这种情况下,您实际上并没有安装镜像,而是遵循指示来构建镜像。与 Dockerfile 一起工作的内容在 第七章 中有详细说明。

分发 Dockerfile 与分发镜像文件类似。您需要自行选择分发机制。一种常见的模式是将 Dockerfile 与来自 Git 或 Mercurial 等常见版本控制系统的软件一起分发。如果您已安装 Git,可以尝试从公共仓库运行一个示例:

git clone https://github.com/dockerinaction/ch3_dockerfile.git docker build -t dia_ch3/dockerfile:latest ch3_dockerfile

在这个示例中,您将项目从公共源代码库复制到您的计算机上,然后通过使用该项目包含的 Dockerfile 来构建和安装 Docker 镜像。提供给 docker build 命令 -t 选项的值是您想要安装镜像的仓库。从 Dockerfile 构建镜像是一种轻量级的方法,适合现有的工作流程。

这种方法有两个缺点。首先,根据项目的具体细节,构建过程可能需要一些时间。其次,Dockerfile 编写的时间和在用户的计算机上构建镜像的时间之间,依赖项可能会发生变化。这些问题使得分发构建文件对用户来说不是一个理想的体验。尽管如此,它仍然因为这些缺点而保持流行。

完成此示例后,请确保清理您的开发空间:

docker rmi dia_ch3/dockerfile rm -rf ch3_dockerfile

3.2.5. 在网站上使用 Docker Hub

如果你浏览 Docker 网站时还没有遇到它,你应该花点时间查看hub.docker.com。Docker Hub 允许你搜索存储库、组织或特定用户。用户和组织个人资料页面列出了账户维护的存储库、账户上的最近活动以及账户标记的存储库。在存储库页面上,你可以看到以下信息:

  • 关于图像发布者提供的图像的一般信息

  • 存储库中可用的标签列表

  • 存储库创建的日期

  • 下载次数

  • 注册用户的评论

Docker Hub 免费加入,你将在本书的后面部分需要一个账户。当你登录后,你可以对存储库进行标记和评论。你可以创建和管理自己的存储库。我们将在第二部分中这样做。现在,只需感受一下网站及其提供的功能。

活动:Docker Hub 寻宝游戏

通过使用你在第二章中学到的技能在 Docker Hub 上寻找软件是很好的练习。这个活动旨在鼓励你使用 Docker Hub 并练习创建容器。你还将了解到docker run命令上的三个新选项。

在这个活动中,你将从 Docker Hub 上可用的两个图像创建容器。第一个来自dockerinaction/ch3_ex2_hunt存储库。在这个图像中,你会找到一个提示你输入密码的小程序。你只能通过找到并运行 Docker Hub 上的第二个神秘存储库中的容器来找到密码。要使用这些图像中的程序,你需要将你的终端连接到容器,以便你的终端的输入和输出直接连接到正在运行的容器。以下命令演示了如何做到这一点并运行一个在停止时自动删除的容器:

docker run -it --rm dockerinaction/ch3_ex2_hunt

当你运行此命令时,寻宝游戏程序将提示你输入密码。如果你已经知道答案,请现在输入。如果不知道,随便输入什么,它将给你一个提示。此时,你应该拥有完成活动所需的所有工具。以下图表说明了从这一点开始你需要做什么。

图片

仍然卡住了?我们可以再给你一个提示。神秘存储库是为这本书创建的。也许你应该尝试搜索这本书的 Docker Hub 存储库。记住,存储库使用用户名/存储库模式命名。

当你得到答案后,给自己鼓掌,并使用docker rmi命令删除图像。你运行的命令应该看起来像这样:

docker rmi dockerinaction/ch3_ex2_hunt docker rmi <mystery repository>

如果您正在遵循示例并使用 docker run 命令中的 --rm 选项,您应该没有需要清理的容器。在这个示例中,您学到了很多。您在 Docker Hub 上找到了一个新的镜像,并以新的方式使用了 docker run 命令。关于运行交互式容器有很多东西要了解。下一节将更详细地介绍这一点。

Docker Hub 绝对不是软件的唯一来源。根据软件发布者的目标和视角,Docker Hub 可能不是一个合适的分发点。闭源或专有项目可能不想通过第三方风险发布他们的软件。您可以通过以下三种其他方式安装软件:

  • 您可以使用替代的仓库注册表或运行自己的注册表。

  • 您可以手动从文件加载镜像。

  • 您可以从其他来源下载一个项目,并通过提供的 Dockerfile 构建镜像。

所有三种选项都适用于私有项目或企业基础设施。接下来的几个小节将介绍如何从每个替代来源安装软件。第九章详细介绍了 Docker 镜像的分布。阅读本节后,您应该对使用 Docker 安装软件的选项有一个完整的了解。当您安装软件时,您应该对软件包的内容以及计算机上所做的更改有所了解。

3.3. 安装文件和隔离

理解镜像是如何被标识、发现和安装的,这是 Docker 用户的基本技能。如果您了解实际上安装了哪些文件以及这些文件是如何在运行时构建和隔离的,您将能够回答随着经验积累而出现的更难的问题,例如这些:

  • 哪些镜像属性会影响下载和安装速度?

  • 当我使用 docker images 命令时,列出的所有这些未命名的镜像是什么?

  • 为什么 docker pull 命令的输出包括拉取依赖层的信息?

  • 我写入容器文件系统的文件在哪里?

学习这些材料是理解 Docker 软件安装的第三步和最后一步,如图 3.6 所示。figure 3.6。

图 3.6. 第 3 步——理解软件的安装过程

到目前为止,当我们提到安装软件时,我们使用了术语“镜像”。这是为了推断您将要使用的软件位于单个镜像中,并且镜像包含在一个单独的文件中。尽管这偶尔可能是准确的,但大多数时候我们所说的镜像实际上是一系列镜像层。

一层是一组文件和文件元数据,它作为一个原子单元打包和分发。在内部,Docker 将每一层视为一个镜像,层通常被称为中间镜像。您甚至可以通过标记来提升一个层为镜像。大多数层通过在父层上应用文件系统更改来构建在父层之上。例如,一个层可能通过使用例如 Debian 的apt-get update包管理器来更新镜像中的软件。生成的镜像包含从父层和添加的层中合并的文件集。当您看到它们在行动中时,更容易理解层。

3.3.1. 镜像层在行动中

在这个例子中,您将安装两个镜像。它们都依赖于 Java 11。应用程序本身是简单的“Hello, World”风格的程序。我们希望您注意 Docker 在安装每个应用程序时所做的操作。您应该注意到安装第一个应用程序所需的时间与安装第二个应用程序所需的时间相比有多长,并阅读docker pull命令打印到终端的内容。当镜像正在安装时,您可以观察 Docker 确定它需要下载哪些依赖项,然后查看单个镜像层下载的进度。Java 非常适合这个例子,因为层相当大,这将给您一个真正看到 Docker 在行动中的机会。

您将要安装的两个镜像分别是dockerinaction/ch3_myappdocker-in-action/ch3_myotherapp。您只需使用docker pull命令即可,因为您只需要看到镜像安装,而不是从它们启动容器。以下是您应该运行的命令:

docker pull dockerinaction/ch3_myapp docker pull dockerinaction/ch3_myotherapp

您看到了吗?除非您的网络连接比我好得多,或者您已经将 OpenJDK 11.0.4(精简版)作为其他镜像的依赖项安装,否则dockerinaction/ch3_myapp的下载速度应该比docker-in-action/ch3_myotherapp慢得多。

当您安装ch3_myapp时,Docker 确定它需要安装openjdk:11.0.4-jdk-slim镜像,因为它是请求的镜像的直接依赖(父层)。当 Docker 去安装这个依赖项时,它发现了该层的依赖项并首先下载了它们。一旦安装了层的所有依赖项,该层就被安装了。最后,安装了openjdk:11.0.4-jdk-slim,然后安装了微小的ch3_myapp层。

当您发出安装ch3_myotherapp的命令时,Docker 确定openjdk:11.0.4-jdk-slim已经安装,并立即安装了ch3_myotherapp的镜像。由于第二个应用程序几乎与第一个应用程序共享了所有的镜像层,Docker 需要做的就少多了。安装ch3_myotherapp的独特层非常快,因为数据传输量不到一兆字节。但再次强调,对用户来说,这是一个相同的过程。

从用户的角度来看,这种能力很好,但你不想为了优化它而尝试。只需在它们起作用的地方享受这些好处。从软件或镜像作者的角度来看,这种能力应该在你的镜像设计中扮演一个重要因素。第七章 将更详细地介绍这一点。

如果你现在运行 docker images,你会看到以下仓库被列出:

  • dockerinaction/ch3_myapp

  • dockerinaction/ch3_myotherapp

默认情况下,docker images 命令将仅显示仓库。与其他命令一样,如果你指定了 -a 标志,列表将包括所有安装的中间镜像或层。运行 docker images -a 将显示一个包括多个仓库的列表,也可能包括一些标记为 <none> 的列表。未命名的镜像可能存在多个原因,例如构建一个未标记的镜像。唯一引用这些镜像的方法是使用 IMAGE ID 列中的值。

在这个例子中,你安装了两个镜像。现在让我们清理它们。如果你使用压缩的 docker rmi 语法,这将更容易完成:

docker rmi \ dockerinaction/ch3_myapp \ dockerinaction/ch3_myotherapp

docker rmi 命令允许你指定一个由空格分隔的镜像列表以进行删除。当你需要在示例之后删除一小组镜像时,这非常有用。我们将在本书的其余示例中适当使用此命令。

3.3.2. 层关系

镜像保持父/子关系。在这些关系中,它们从父级构建并形成层。容器可用的文件是容器创建时使用的镜像世系中所有层的并集。镜像可以与其他任何镜像建立关系,包括来自不同仓库、不同所有者的镜像。在 3.3.1 节 中的两个应用程序镜像使用 OpenJDK 11.0.4 镜像作为它们的父级。OpenJDK 镜像的父级是 Debian Linux Buster 操作系统发布的最小版本。图 3.7 展示了两个镜像的完整镜像世系以及每个镜像中包含的层。

图 3.7. 两个 Docker 镜像在 3.3.1 节 中使用的完整世系

图 3.7 中的镜像和层显示,应用程序镜像将从 openjdk:11.0.4-jdk-slim 继承三个层,并从 debian:buster-slim 继承一个额外的层。来自 OpenJDK 的三个层包含 Java 11 软件的公共库和依赖项,而 Debian 镜像贡献了一个最小化的操作系统工具链。

当一个镜像被作者标记并发布时,它就有了一个名字。用户可以创建别名,就像你在第二章中通过使用docker tag命令所做的那样。在镜像被标记之前,唯一引用它的方式是使用在镜像构建时生成的唯一标识符(ID)。在图 3.7 中,OpenJDK 11.0.4 镜像的父镜像是一个 ID 为83ed3c583403的 Debian Buster 操作系统。Debian 镜像的作者标记并发布了这个镜像为debian:buster-slim。图中用这些镜像的 ID 的前 12 位来标记这些镜像,这是一个常见的约定。Docker 将 ID 从 65(十六进制)位截断到 12 位,以便在常见命令的输出中为人类用户提供便利。在内部和通过 API 访问时,Docker 使用完整的 65 位。

即使这些“精简”的 Java 镜像也相当大,并且被选择来阐述一个观点。在撰写本文时,openjdk:11.0.4-jdk-slim镜像大小为 401 MB。当你使用仅运行时镜像时,你可以节省一些空间,但即使是openjdk:11.0.4-jre-slim-buster也有 204 MB。因为 Docker 能够唯一标识镜像和层,所以它能够识别应用程序之间的共享镜像依赖关系,并避免再次下载这些依赖关系。这是在运行时不需要任何协调的情况下完成的,只在构建时进行。第十章深入讨论了镜像构建管道。让我们继续通过检查容器文件系统来探讨。

3.3.3. 容器文件系统抽象和隔离

在容器内运行的程序对镜像层一无所知。从容器内部看,文件系统就像它没有在容器中运行或操作镜像一样运行。从容器的角度来看,它拥有由镜像提供的文件的独家副本。这是通过一种称为联合文件系统(UFS)的技术实现的。

Docker 使用多种联合文件系统,并将选择最适合您系统的文件系统。联合文件系统如何工作的细节超出了您使用 Docker 有效所需了解的内容。联合文件系统是创建有效文件系统隔离的一组关键工具之一。其他工具是 MNT 命名空间和chroot系统调用。

文件系统用于在宿主机的文件系统中创建挂载点,以抽象层的使用。创建的层被打包进 Docker 镜像层中。同样,当安装 Docker 镜像时,其层会被解包并适当地配置,以便为系统选择的特定文件系统提供者使用。

Linux 内核为 MNT 系统提供了一个命名空间。当 Docker 创建一个容器时,这个新容器将拥有自己的 MNT 命名空间,并为容器创建一个新的挂载点到镜像。

最后,chroot用于将图像文件系统的根设置为容器上下文中的根。这防止了容器内运行的任何内容引用主机文件系统的任何其他部分。

使用chroot和 MNT 命名空间对于容器技术来说是常见的。通过在配方中添加联合文件系统,Docker 容器具有几个好处。

3.3.4. 此工具集和文件系统结构的优点

这种方法的第一个也许是最重要的好处是,常见的层只需要安装一次。如果你安装了任意数量的图像,并且它们都依赖于一个共同的层,那么这个共同的层以及它的所有父层只需要下载或安装一次。这意味着你可能能够安装一个程序的多个专业版本,而无需在电脑上存储冗余文件或下载冗余层。相比之下,大多数虚拟机技术会在电脑上存储与冗余虚拟机数量相同的相同文件。

其次,层提供了一个粗略的工具来管理依赖关系和分离关注点。这对于软件作者来说特别有用,第七章将更多地讨论这一点。从用户的角度来看,这个好处将帮助你通过检查你使用的图像和层来快速识别你正在运行的软件。

最后,当你可以在基本图像上叠加少量更改以创建软件专业版本时,创建软件专业版本变得很容易。这是第七章详细讨论的另一个主题。提供专业图像有助于用户以最小的定制从软件中获得他们所需的一切。这是使用 Docker 的最好理由之一。

3.3.5. 联合文件系统的弱点

Docker 在启动时选择合理的默认值,但没有一个实现适合所有工作负载。实际上,在某些特定用例中,你应该暂停并考虑使用另一个 Docker 功能。

不同的文件系统对文件属性、大小、名称和字符有不同的规则。联合文件系统处于一个位置,它们经常需要在不同文件系统的规则之间进行转换。在最好的情况下,它们能够提供可接受的转换。在最坏的情况下,功能会被省略。例如,Btrfs 和 OverlayFS 都不提供支持 SELinux 工作的扩展属性。

联合文件系统使用一种称为写时复制的模式,这使得实现内存映射文件(mmap系统调用)变得困难。一些联合文件系统提供了在适当条件下工作的实现,但避免从图像中映射内存可能是一个更好的主意。

后端文件系统是 Docker 的另一个可插拔功能。您可以使用info子命令确定您的安装正在使用哪个文件系统。如果您想特别告诉 Docker 使用哪个文件系统,可以在启动 Docker 守护进程时使用--storage-driver-s选项。大多数与写入联合文件系统相关的问题都可以在不更改存储提供者的情况下解决。这些问题可以通过卷来解决,这是第四章的主题第四章。

摘要

在计算机上安装和管理软件的任务带来了一系列独特的挑战。本章解释了如何使用 Docker 来应对这些挑战。本章涵盖的核心思想和功能如下:

  • Docker 的用户使用存储库名称来告知 Docker 他们希望安装哪种软件。

  • Docker Hub 是默认的 Docker 注册库。您可以通过网站或docker命令行程序在 Docker Hub 上找到软件。

  • docker命令行程序使得安装通过替代注册库或其他形式分发的软件变得简单。

  • 图片存储库规范包括一个注册主机字段。

  • docker loaddocker save命令可以用来从 TAR 存档中加载和保存镜像。

  • 将 Dockerfile 与项目一起分发简化了用户机器上的镜像构建。

  • 镜像通常与其他镜像以父/子关系相关联。这些关系形成层。当我们说我们安装了一个镜像时,我们是在说我们安装了一个目标镜像及其世系中的每个镜像层。

  • 通过层结构镜像可以启用层重用,并在分发和存储过程中节省带宽,同时减少您计算机和镜像分发服务器上的存储空间。

第四章:与存储和卷一起工作

本章涵盖

  • 介绍挂载点

  • 如何在主机和容器之间共享数据

  • 如何在容器之间共享数据

  • 使用临时、内存文件系统

  • 使用卷管理数据

  • 使用卷插件的高级存储

到这本书的这一部分,你已经安装并运行了一些程序。你看到了一些玩具示例,但还没有运行过任何类似真实世界的程序。第一、二、三章中的示例与真实世界之间的区别在于,在真实世界中,程序与数据一起工作。本章介绍了 Docker 卷和策略,这些策略将用于管理容器中的数据。

考虑在容器内运行数据库程序可能的样子。您会将与镜像打包的软件,当您启动容器时,它可能会初始化一个空数据库。当程序连接到数据库并输入数据时,这些数据存储在哪里?是在容器内的文件中吗?当您停止容器或删除它时,这些数据会发生什么?如果您想升级数据库程序,您会如何移动数据?当云机器终止时,该存储会发生什么?

考虑另一种情况,你正在不同的容器中运行几个不同的网络应用程序。你会在哪里写入日志文件,以便它们能够比容器存活得更久?你将如何访问这些日志来解决问题?其他程序,如日志摘要工具,如何访问这些文件?

联合文件系统不适合处理长期数据或容器之间、容器与主机之间的数据共享。所有这些问题的答案都涉及到管理容器文件系统和挂载点。

4.1. 文件树和挂载点

与其他操作系统不同,Linux 将所有存储统一到一个单一的树中。例如,磁盘分区或 USB 磁盘分区被连接到该树中的特定位置。这些位置被称为挂载点。挂载点定义了树中的位置,该位置数据的访问属性(例如,可写性),以及在该位置挂载的数据源(例如,特定的硬盘、USB 设备或内存支持的虚拟磁盘)。图 4.1 展示了由多个存储设备构成的文件系统,每个设备都挂载到特定的位置和访问级别。

图 4.1. 挂载点处连接到文件系统树的存储设备

图片

挂载点允许软件和用户在 Linux 环境中使用文件树,而无需确切知道该树是如何映射到特定存储设备的。这在容器环境中尤其有用。

每个容器都有一个称为 MNT 命名空间和唯一的文件树根。这将在第六章文件位置 544857 中详细讨论。现在,只需理解从该容器创建的镜像被挂载在该容器的文件树根,或者挂载在 / 点,并且每个容器都有一个不同的挂载点集合即可。

如果不同的存储设备可以在文件树的各个位置挂载,我们可以在容器文件树的其它位置挂载非镜像相关的存储。这正是容器如何访问主机文件系统上的存储并在容器之间共享存储的方式。

本章的其余部分将详细阐述如何管理容器中的存储和挂载点。最好的开始方式是理解三种最常见的被挂载到容器中的存储类型:

  • 绑定挂载

  • 内存存储

  • Docker 卷

这些存储类型可以以多种方式使用。图 4.2 展示了从镜像文件开始,添加内存中的tmpfs/tmp,从主机绑定挂载配置文件,并将日志写入主机 Docker 卷的容器文件系统示例。

图 4.2. 常见容器存储挂载示例

图片

所有三种类型的挂载点都可以使用docker rundocker create子命令上的--mount标志创建。

4.2. 绑定挂载

绑定挂载是用于将文件系统树的一部分重新挂载到其他位置的挂载点。当与容器一起工作时,绑定挂载将主机文件系统中的一个用户指定位置连接到容器文件树中的特定点。绑定挂载在主机提供容器中运行的程序所需的文件或目录时非常有用,或者当容器化程序产生的文件或日志被容器外运行的用户或程序处理时。

考虑图 4.3 中的示例。假设你正在运行一个依赖于主机敏感配置并需要通过你的日志传输系统转发访问日志的 Web 服务器。你可以使用 Docker 在容器中启动 Web 服务器,并将配置位置以及你希望 Web 服务器写入日志的位置绑定挂载。

图 4.3. 作为绑定挂载卷共享的主机文件

你可以亲自尝试。创建一个占位符日志文件,并创建一个名为 example.conf 的特殊 NGINX 配置文件。运行以下命令来创建和填充文件:

touch ~/example.log cat >~/example.conf <<EOF server {   listen 80;   server_name localhost;   access_log /var/log/nginx/custom.host.access.log main;   location / {     root /usr/share/nginx/html;     index index.html index.htm;   } } EOF

一旦使用此配置文件启动服务器,它将在 http://localhost/提供 NGINX 默认站点,并且该站点的访问日志将写入容器中的文件/var/log/nginx/custom.host.access.log。以下命令将在容器中启动 NGINX HTTP 服务器,其中你的新配置绑定挂载到服务器的配置根:

CONF_SRC=~/example.conf; \ CONF_DST=/etc/nginx/conf.d/default.conf; \ LOG_SRC=~/example.log; \ LOG_DST=/var/log/nginx/custom.host.access.log; \ docker run -d --name diaweb \   --mount type=bind,src=${CONF_SRC},dst=${CONF_DST} \   --mount type=bind,src=${LOG_SRC},dst=${LOG_DST} \   -p 80:80 \   nginx:latest

当这个容器运行时,你应该能够将你的网络浏览器指向 http://localhost/并看到 NGINX 的 hello-world 页面,并且你将不会在容器日志流中看到任何访问日志:docker logs diaweb。然而,如果你检查家目录中的 example.log 文件,你将能够看到这些日志:cat ~/example.log

在这个示例中,你使用了带有type=bind选项的--mount选项。其他两个挂载参数srcdst定义了主机文件树中的源位置和容器文件树中的目标位置。你必须指定带有绝对路径的位置,但在这个示例中,我们使用了 shell 扩展和 shell 变量来使命令更容易阅读。

这个示例涉及到卷的一个重要特性。当你在一个容器文件系统上挂载一个卷时,它会替换掉在该位置由镜像提供的内容。默认情况下,nginx:latest 镜像在 /etc/nginx/conf.d/default.conf 提供了一些默认配置,但当你创建了一个指向该路径的绑定挂载时,镜像提供的内容被主机上的内容覆盖了。这种行为是本章后面讨论的多态容器模式的基础。

在扩展这个用例的基础上,假设你想确保 NGINX 网络服务器不能更改配置卷的内容。即使是受信任的软件也可能包含漏洞,因此最好尽量减少攻击对您网站的影响。幸运的是,Linux 提供了一种机制来使挂载点只读。你可以通过在挂载指定中添加 readonly=true 参数来实现这一点。在示例中,你应该将 run 命令更改为以下类似的内容:

docker rm -f diaweb CONF_SRC=~/example.conf; \ CONF_DST=/etc/nginx/conf.d/default.conf; \ LOG_SRC=~/example.log; \ LOG_DST=/var/log/nginx/custom.host.access.log; \ docker run -d --name diaweb \ --mount type=bind,src=${CONF_SRC},dst=${CONF_DST},readonly=true \ 1 --mount type=bind,src=${LOG_SRC},dst=${LOG_DST} \ --mount type=bind,src=${CONF_SRC},dst=${CONF_DST},readonly=true \ -p 80:80 \ nginx:latest

  • 1 注意只读标志。

通过创建只读挂载,你可以防止容器内的任何进程修改卷的内容。你可以通过运行一个快速测试来看到这个效果:

docker exec diaweb \ sed -i "s/listen 80/listen 8080/" /etc/nginx/conf.d/default.conf

这个命令在 diaweb 容器内执行了一个 sed 命令并尝试修改配置文件。命令失败,因为文件被挂载为只读。

绑定挂载的第一个问题是,它们将原本可移植的容器描述与特定主机的文件系统绑定在一起。如果一个容器描述依赖于主机文件系统上的特定位置的内容,那么这个描述就不适用于内容不可用或在其他位置可用的主机。

接下来的一个大问题是,它们为与其他容器的冲突创造了机会。启动多个使用相同主机位置作为数据存储绑定挂载的 Cassandra 实例是个坏主意。在这种情况下,每个实例都会竞争同一组文件。如果没有其他工具如文件锁,这很可能会导致数据库损坏。

绑定挂载是适用于工作站、具有特定关注点的机器或与更传统的配置管理工具结合使用的系统中的适当工具。在通用平台或硬件池中最好避免这些类型的特定绑定。

4.3. 内存存储

大多数服务软件和 Web 应用程序使用私有密钥文件、数据库密码、API 密钥文件或其他敏感配置文件,并需要上传缓冲空间。在这些情况下,您绝对不应将这些类型的文件包含在镜像中或将它们写入磁盘。相反,您应使用内存存储。您可以通过特殊类型的挂载将内存存储添加到容器中。

mount 标志上的 type 选项设置为 tmpfs。这是将基于内存的文件系统挂载到容器文件树的最简单方法。考虑以下命令:

docker run --rm \ --mount type=tmpfs,dst=/tmp \ --entrypoint mount \ alpine:latest -v

此命令创建一个空的 tmpfs 设备并将其附加到新容器文件树中的 /tmp。在此文件树下创建的任何文件都将写入内存而不是磁盘。不仅如此,挂载点使用适用于通用工作负载的合理默认值创建。运行此命令将显示容器所有挂载点的列表。列表将包括以下行:

tmpfs on /tmp type tmpfs (rw,nosuid,nodev,noexec,relatime)

这一行描述了挂载点配置。从左到右,它表示以下内容:

  • tmpfs 设备已挂载到 /tmp 树。

  • 该设备具有 tmpfs 文件系统。

  • 该树具有读写能力。

  • 在此树中的所有文件上都将忽略 suid 位。

  • 此树中的文件不会被解释为特殊设备。

  • 此树中的文件将不可执行。

  • 如果它们的访问时间比当前的修改或更改时间旧,则将更新文件访问时间。

此外,tmpfs 设备默认没有大小限制,并且可由世界范围内的用户写入(八进制文件权限为 1777)。您可以使用两个附加选项 tmpfs-sizetmpfs-mode 添加大小限制并更改文件模式:

docker run --rm \ --mount type=tmpfs,dst=/tmp,tmpfs-size=16k,tmpfs-mode=1770 \ --entrypoint mount \ alpine:latest -v

此命令将 /tmp 上挂载的 tmpfs 设备限制为 16 KB,并且容器内的 other 用户无法读取。

4.4. DOCKER 卷

Docker 卷是由 Docker 管理的命名文件系统树。它们可以使用主机文件系统上的磁盘存储,或使用云存储等其他更复杂的后端。可以使用 docker volume 子命令集完成对 Docker 卷的所有操作。使用卷是将存储与文件系统上可能通过绑定挂载指定的专用位置解耦的方法。

如果要将 Web 服务器和日志转发容器示例从 第 4.2 节 转换为使用卷来共享对日志的访问权限,这对可以运行在任何机器上,无需考虑可能与其他软件在磁盘上的静态位置冲突的其他软件。该示例将类似于 图 4.4,容器将通过 location-example 卷读取和写入日志。

图 4.4. 使用卷在容器之间共享文件

图片

你可以使用docker volume createdocker volume inspect子命令来创建和检查卷。默认情况下,Docker 使用local卷插件创建卷。默认行为将在 Docker 引擎控制的宿主机文件系统的一部分创建一个目录来存储卷的内容。例如,以下两个命令将创建一个名为location-example的卷并显示卷宿主机文件系统树的位置:

docker volume create 1 --driver local 2 --label example=location 3 location-example docker volume inspect 4 --format "{{json .Mountpoint}}" 5 location-example

  • 1 创建卷

  • 2 指定“本地”插件

  • 3 添加卷标签

  • 4 检查卷

  • 5 选择主机上的位置

如果你手动在桌面上构建或链接工具,Docker 卷可能看起来难以操作,但对于数据特定位置性不那么重要的更大系统,卷是组织数据的一种更有效的方法。使用它们将卷从系统的其他潜在关注点中解耦。通过使用 Docker 卷,你只是在说,“我需要一个地方来存放我正在处理的一些数据。”这是一个 Docker 可以在任何安装了 Docker 的机器上满足的要求。

此外,当你完成对卷的使用并要求 Docker 为你清理时,Docker 可以自信地删除任何不再被容器使用的目录或文件。以这种方式使用卷有助于管理杂乱。随着 Docker 中间件或插件的演变,卷用户将能够采用更高级的功能。

共享数据访问是卷的关键特性。如果你已经将卷从文件系统上的已知位置解耦,你需要知道如何在容器之间共享卷,而不会暴露管理容器的确切位置。下一节将描述两种使用卷在容器之间共享数据的方法。

4.4.1. 卷提供容器独立的数据管理

从语义上讲,卷是一个用于分割和共享数据的工具,其范围或生命周期独立于单个容器。这使得卷成为任何共享或写入文件的容器化系统设计中不可或缺的一部分。以下是一些与容器在范围或访问权限上存在差异的数据示例:

  • 数据库软件与数据库数据

  • 网络应用程序与日志数据

  • 数据处理应用程序与输入和输出数据

  • 网络服务器与静态内容

  • 产品与支持工具

卷允许关注点的分离,并为架构组件创建模块化。这种模块化有助于你更容易地理解、构建、支持和重用大型系统中的各个部分。

这样想吧:图像适合用于打包和分发相对静态的文件,例如程序;卷则用于存储动态数据或特殊化。这种区别使得图像可重用,数据易于共享。这种相对静态和动态文件空间的分离,使得应用程序或图像的作者能够实现诸如多态和可组合工具等高级模式。

多态工具是指保持一致接口但可能有几个实现,这些实现执行不同的操作。考虑一个像通用应用程序服务器这样的应用程序。例如,Apache Tomcat 是一个提供网络 HTTP 接口并将接收到的任何请求分派给可插拔程序的应用程序。Tomcat 具有多态行为。使用卷,你可以在不修改镜像的情况下将行为注入容器。或者,考虑一个像 MongoDB 或 MySQL 这样的数据库程序。数据库的价值由其包含的数据定义。数据库程序始终提供相同的接口,但根据可以注入卷的数据而具有完全不同的价值。多态容器模式是第 4.5.1 节的主题。

更根本的是,卷使得应用程序和主机关注点的分离成为可能。在某个时刻,一个镜像将被加载到主机上,并从中创建一个容器。Docker 对其运行的主机了解不多,只能对容器应该可用的文件做出断言。Docker 本身无法利用主机特定的设施,如挂载的网络存储或混合的机械硬盘和固态硬盘。但一个了解主机的人可以使用卷将容器中的目录映射到主机上的适当存储。

现在你已经熟悉了卷是什么以及为什么它们很重要,你可以在一个真实世界的例子中开始使用它们。

4.4.2. 使用卷与 NoSQL 数据库

Apache Cassandra 项目提供了一个具有内置集群、最终一致性和线性写入可伸缩性的列数据库。它是现代系统设计中的热门选择,Docker Hub 上提供了官方镜像。Cassandra 与其他数据库类似,它在磁盘上的文件中存储数据。在本节中,你将使用官方 Cassandra 镜像创建一个单节点 Cassandra 集群,创建一个键空间,删除容器,然后在另一个容器中的新节点上恢复该键空间。

通过创建将存储 Cassandra 数据库文件的卷开始。这个卷使用本地机器上的磁盘空间以及由 Docker 引擎管理的文件系统部分:

docker volume create \ --driver local \ --label example=cassandra \ cass-shared

此卷与任何容器无关;它只是一个可以被容器访问的命名磁盘块。您刚刚创建的卷名为 cass-shared。在这种情况下,您向卷添加了一个键为 example、值为 cassandra 的标签。向您的卷添加标签元数据可以帮助您在以后组织和清理卷。您将在创建运行 Cassandra 的新容器时使用此卷:

docker run -d \ --volume cass-shared:/var/lib/cassandra/data \ 1 --name cass1 \ cassandra:2.2

  • 1 将卷挂载到容器中

在 Docker 从 Docker Hub 拉取 cassandra:2.2 镜像后,它创建了一个新的容器,并将 cass-shared 卷挂载到 /var/lib/cassandra/data。接下来,从 cassandra:2.2 镜像启动一个容器,但运行 Cassandra 客户端工具(CQLSH)并连接到您的运行中的服务器:

docker run -it --rm \ --link cass1:cass \ cassandra:2.2 cqlsh cass

现在,您可以从 CQLSH 命令行检查或修改您的 Cassandra 数据库。首先,查找名为 docker_hello_world 的键空间:

select * from system.schema_keyspaces where keyspace_name = 'docker_hello_world';

Cassandra 应该返回一个空列表。这意味着数据库没有被示例修改。接下来,使用以下命令创建该键空间:

create keyspace docker_hello_world with replication = { 'class' : 'SimpleStrategy', 'replication_factor': 1 };

现在您已经修改了数据库,您应该能够再次发出相同的查询以查看结果并验证您的更改是否被接受。以下命令与您之前运行的命令相同:

select * from system.schema_keyspaces where keyspace_name = 'docker_hello_world';

这次,Cassandra 应该返回一个条目,其属性与您创建键空间时指定的属性相同。如果您满意已连接并修改了您的 Cassandra 节点,请退出 CQLSH 程序以停止客户端容器:

# 离开并停止当前容器 quit

客户端容器使用 --rm 标志创建,并在命令停止时自动删除。继续清理本例的第一部分,通过停止和删除您创建的 Cassandra 节点:

docker stop cass1 docker rm -vf cass1

在运行这些命令后,您创建的 Cassandra 客户端和服务器将被删除。如果您所做的修改已持久化,它们唯一可能保留的地方是 cass-shared 卷。您可以通过重复这些步骤来测试这一点。创建一个新的 Cassandra 节点,附加一个客户端,并查询键空间。图 4.5 展示了系统和您将构建的内容。

图 4.5. 使用 Cassandra 将数据持久化到卷中的关键步骤

下面的三个命令测试数据的恢复:

docker run -d \ --volume cass-shared:/var/lib/cassandra/data \ --name cass2 \ cassandra:2.2 docker run -it --rm \ --link cass2:cass \ cassandra:2.2 \ cqlsh cass select * from system.schema_keyspaces where keyspace_name = 'docker_hello_world';

这组命令中的最后一个命令返回一个条目,并且它与你在上一个容器中创建的键空间相匹配。这证实了之前的说法,并展示了如何使用卷来创建持久系统。在继续之前,退出 CQLSH 程序并清理你的工作空间。确保也要删除该卷容器:

quit docker rm -vf cass2 cass-shared

这个例子演示了使用卷的一种方法,而不深入探讨它们的工作原理、使用的模式或如何管理卷的生命周期。本章的其余部分将更深入地探讨卷的各个方面,从可用的类型开始。

4.5. 共享挂载点和文件共享

在多个容器之间共享对同一组文件的访问是卷的价值最明显的地方。比较绑定挂载和基于卷的方法。

绑定挂载是共享容器之间磁盘空间的最明显方式。你可以在以下示例中看到它的实际应用:

LOG_SRC=~/web-logs-example mkdir ${LOG_SRC} 1 docker run --name plath -d \ 2 --mount type=bind,src=${LOG_SRC},dst=/data \ 2 dockerinaction/ch4_writer_a 2 docker run --rm \ --mount type=bind,src=${LOG_SRC},dst=/data \ 3 alpine:latest \ 3 head /data/logA 3 cat ${LOG_SRC}/logA 4 docker rm -f plath 5

  • 1 设置一个已知位置

  • 2 将位置绑定到日志写入容器中

  • 3 将相同位置绑定到容器中进行读取

  • 4 查看主机日志

  • 5 停止写入器

在这个例子中,你创建了两个容器:一个名为plath的容器将行写入文件,另一个容器查看文件的顶部部分。这些容器共享一个共同的绑定挂载定义。在任何容器外部,你可以通过列出你创建的目录内容或查看新文件来看到这些更改。这种方法的主要缺点是所有涉及的容器都必须同意主机文件路径上的确切位置,并且可能与也打算在相同位置读取或操作文件的其它容器发生冲突。

现在将这个绑定挂载示例与使用卷的示例进行比较。以下命令与前面的示例等效,但没有主机特定的依赖项:

docker volume create \ 1 --driver local \ 1 logging-example 1 docker run --name plath -d \ 2 --mount type=volume,src=logging-example,dst=/data \ 2 dockerinaction/ch4_writer_a 2 docker run --rm \ --mount type=volume,src=logging-example,dst=/data \ 3 alpine:latest \ 3 head /data/logA 3 cat "$(docker volume inspect \ --format "{{json .Mountpoint}}" logging-example)"/logA 4 docker stop plath 5

  • 1 设置一个命名卷

  • 2 将卷装载到日志写入容器中

  • 3 将相同的卷装载到容器中进行读取

  • 4 查看主机日志

  • 5 停止作者

与基于绑定挂载的共享不同,命名卷允许容器在无需了解底层主机文件系统的情况下共享文件。除非卷需要使用特定的设置或插件,否则它不需要在第一个容器挂载它之前存在。Docker 会自动根据runcreate命令中的默认值创建名为卷的卷。然而,重要的是要记住,存在于主机上的命名卷将被重用并由具有相同卷依赖关系的任何其他容器共享。

通过使用匿名卷和容器之间的挂载点定义继承,可以解决此名称冲突问题。

4.5.1 匿名卷和卷来源标志

匿名卷在没有名称的情况下创建,无论是使用docker volume create子命令之前,还是使用docker rundocker create命令的默认值即时创建。当卷创建时,它被分配一个唯一的标识符,如1b3364a8debb5f653d1ecb9b190000622549ee2f812a4fb4ec8a83c43d87531b,而不是一个友好的名称。如果您需要手动拼接依赖项,这些标识符更难处理,但它们在需要消除潜在的卷命名冲突时很有用。Docker 命令行提供了另一种指定挂载依赖项的方法,而不是通过名称引用卷。

docker run命令提供了一个标志--volumes-from,它将从一个或多个容器中复制挂载定义到新容器中。它可以多次设置以指定多个源容器。通过结合此标志和匿名卷,您可以以主机无关的方式构建丰富的共享状态关系。考虑以下示例:

docker run --name fowler \ --mount type=volume,dst=/library/PoEAA \ --mount type=bind,src=/tmp,dst=/library/DSL \ alpine:latest \ echo "Fowler collection created." docker run --name knuth \ --mount type=volume,dst=/library/TAoCP.vol1 \ --mount type=volume,dst=/library/TAoCP.vol2 \ --mount type=volume,dst=/library/TAoCP.vol3 \ --mount type=volume,dst=/library/TAoCP.vol4.a \ alpine:latest \ echo "Knuth collection created" docker run --name reader \ --volumes-from fowler ` --volumes-from knuth ` alpine:latest ls -l /library/ docker inspect --format "{{json .Mounts}}" reader`

  • 1 列出所有卷,它们被复制到新容器中

  • 2 检查读者卷列表

在这个示例中,你创建了两个容器,它们定义了匿名卷以及一个绑定挂载卷。要使用 --volumes-from 标志与第三个容器共享这些卷,你需要检查之前创建的容器,然后为 Docker 管理的主机目录创建绑定挂载卷。当你使用 --volumes-from 标志时,Docker 会代表你完成所有这些操作。它将引用源容器上所有存在的挂载点定义复制到新容器中。在这种情况下,名为 reader 的容器复制了 fowlerknuth 定义的挂载点。

你可以直接或间接地复制卷。这意味着如果你从另一个容器复制卷,你也会复制它从另一个容器中复制的卷。使用前一个示例中创建的容器,可以得到以下结果:

docker run --name aggregator \ 1 --volumes-from fowler \ --volumes-from knuth \ alpine:latest \ echo "Collection Created." docker run --rm \ 2 --volumes-from aggregator \ alpine:latest \ ls -l /library/

  • 1 创建一个聚合

  • 2 从单个源消费卷并列出它们

复制的卷总是具有相同的挂载点。这意味着在以下三种情况下,你不能使用 --volumes-from

在第一种情况下,如果你构建的容器需要一个挂载到不同位置的共享卷,你不能使用 --volumes-from。它不提供重新映射挂载点的工具。它只会复制和合并由指定容器指示的挂载点。例如,如果前一个示例中的学生想要将图书馆挂载到类似 /school/library 的位置,他们将无法做到这一点。

第二种情况发生在卷源相互冲突或存在新的卷指定时。如果一个或多个源创建了一个具有相同挂载点的管理卷,那么两个卷的消费者将只收到其中一个卷的定义:

docker run --name chomsky --volume /library/ss \ alpine:latest echo "Chomsky collection created." docker run --name lamport --volume /library/ss \ alpine:latest echo "Lamport collection created." docker run --name student \ --volumes-from chomsky --volumes-from lamport \ alpine:latest ls -l /library/ docker inspect -f "{{json .Mounts}}" student

当你运行示例时,docker inspect 的输出将显示最后一个容器在 /library/ss 下只列出了一个卷,其值与另外两个中的一个相同。每个源容器定义了相同的挂载点,你通过将两者都复制到新容器中创建了一个竞争条件。这两个复制操作中只有一个可以成功。

这种限制的现实世界例子可能发生在你将几个网络服务器的卷复制到一个单独的容器中进行检查时。如果这些服务器都在运行相同的软件或共享常见的配置(在容器化系统中这种情况更为常见),所有这些服务器可能会使用相同的挂载点。在这种情况下,挂载点将发生冲突,你只能访问所需数据的一部分。

你不能使用 --volumes-from 的第三种情况是当你需要更改卷的写权限时。这是因为 --volumes-from 会复制完整的卷定义。例如,如果你的源卷是以读写访问挂载的,而你想要与一个只应有读访问权限的容器共享,使用 --volumes-from 将不会起作用。

使用 --volumes-from 标志共享卷是构建可移植应用程序架构的重要工具,但它确实引入了一些限制。其中最具挑战性的是管理文件权限。

使用卷将容器与主机机的数据和文件系统结构解耦,这对于大多数生产环境至关重要。Docker 为管理卷创建的文件和目录仍然需要被计入和维护。下一节将向你展示如何保持 Docker 环境整洁。

4.6. 清理卷

到这一章节为止,你应该有相当多的容器和卷需要清理。你可以通过运行 docker volume list 子命令来查看系统上所有现有的卷。输出将列出每个卷的类型和名称。任何带有名称创建的卷将以该名称列出。任何匿名卷将以其标识符列出。

匿名卷可以通过两种方式清理。首先,当为它们创建的容器自动清理时,匿名卷会自动删除。这发生在通过 docker run --rmdocker rm -v 标志删除容器时。其次,可以通过发出 docker volume remove 命令手动删除:

docker volume create --driver=local # 输出:# 462d0bb7970e47512cd5ebbbb283ed53d5f674b9069b013019ff18ccee37d75d docker volume remove \    462d0bb7970e47512cd5ebbbb283ed53d5f674b9069b013019ff18ccee37d75d # 输出:# 462d0bb7970e47512cd5ebbbb283ed53d5f674b9069b013019ff18ccee37d75d

与匿名卷不同,命名卷必须始终手动删除。当容器运行收集分区数据或周期性数据的作业时,这种行为可能很有帮助。考虑以下情况:

for i in amazon google microsoft; \ do \ docker run --rm \    --mount type=volume,src=$i,dst=/tmp \    --entrypoint /bin/sh \    alpine:latest -c "nslookup $i.com > /tmp/results.txt"; \ done

此命令在三个独立的容器中对 amazon.comgoogle.commicrosoft.com 进行 DNS 查找,并将结果记录在三个不同的卷中。这些卷分别命名为 amazongooglemicrosoft。尽管容器正在自动清理,但命名的卷将保留。如果你运行此命令,你应该能够在运行 docker volume list 时看到新卷。

删除这些命名的卷的唯一方法是在 docker volume remove 命令中指定它们的名称:

docker volume remove \ amazon google microsoft

remove 子命令支持一个列表参数,用于指定卷名和标识符。前面的命令将删除所有三个命名的卷。

删除卷只有一个约束:当前正在使用的卷不能被删除。更具体地说,任何状态下的容器所附加的卷都不能被删除。如果你尝试这样做,Docker 命令将响应一条消息,指出“卷正在使用中”,并显示使用该卷的容器标识符。

如果你只想删除所有或部分可以删除的卷,确定哪些卷是删除候选可能会很烦人。这种情况经常作为定期维护的一部分发生。docker volume prune 命令就是为了这种情况而构建的。

不带选项运行 docker volume prune 将会提示你确认,并删除所有可以删除的卷。你可以通过提供卷标签来过滤候选集:

docker volume prune --filter example=cassandra

此命令将提示确认并删除你在 Cassandra 示例中创建的卷。如果你正在自动化这些清理程序,你可能想抑制确认步骤。在这种情况下,使用 --force-f 标志:

docker volume prune --filter example=location --force

理解卷对于与实际容器工作至关重要,但在许多情况下,在本地磁盘上使用卷可能会造成问题。如果你在一组机器上运行软件,该软件存储在卷中的数据将保留在写入它的磁盘上。如果容器被移动到另一台机器,它将失去对旧卷中数据的访问。你可以通过卷插件来解决你组织中的这个问题。

4.7. 使用卷插件进行高级存储

Docker 提供了一个卷插件接口,作为社区扩展默认引擎功能的一种方式。这已被几个存储管理工具实现,如今用户可以自由使用所有类型的后端存储,包括专有云块存储、网络文件系统挂载、专用存储硬件以及本地云解决方案,如 Ceph 和 vSphere 存储。

这些社区和供应商插件将帮助您解决与在单台机器上写入文件并从另一台机器依赖它们相关的难题。它们使用适当的docker plugin子命令安装、配置和删除都很简单。

本文本中没有详细说明 Docker 插件。它们总是环境特定的,并且没有使用付费资源或支持特定云提供商的情况下很难演示。选择插件取决于您想要集成的存储基础设施,尽管一些项目在某种程度上简化了这一点。REX-Ray (github.com/rexray/rexray) 是一个流行的开源项目,它为几个云和本地存储平台提供卷。如果您已经到达了容器之旅中需要更复杂的卷后端的位置,您应该查看 Docker Hub 上的最新产品,并了解 REX-Ray 的当前状态。

摘要

学习如何使用 Docker 的第一个主要障碍之一是理解如何处理不属于镜像且可能与其他容器或主机共享的文件。本章深入探讨了挂载点,包括以下内容:

  • 挂载点允许来自多个设备的多个文件系统附加到单个文件树。每个容器都有自己的文件树。

  • 容器可以使用绑定挂载将主机文件系统的一部分附加到容器中。

  • 可以将内存文件系统附加到容器文件树中,以便敏感或临时数据不会写入磁盘。

  • Docker 提供了匿名或命名的存储引用,称为卷。

  • 可以使用适当的docker volume子命令创建、列出和删除卷。

  • 卷是主机文件系统的一部分,Docker 将其挂载到容器中指定的位置。

  • 卷有其自己的生命周期,可能需要定期清理。

  • 如果安装了适当的卷插件,Docker 可以提供由网络存储或其他更复杂的工具支持的卷。

第五章. 单主机网络

本章涵盖

  • 网络背景

  • 创建 Docker 容器网络

  • 无网络和主机模式容器

  • 在入口网络上发布服务

  • 容器网络注意事项

网络是计算的一个完整领域,因此本章只能通过涵盖容器网络所需的特定挑战、结构和工具来触及表面。如果您想运行网站、数据库、电子邮件服务器或任何依赖网络的软件,例如 Docker 容器内的网络浏览器,您需要了解如何将容器连接到网络。阅读本章后,您将能够创建适合您运行的应用的具有网络暴露的容器,从一个容器中使用网络软件到另一个容器,并了解容器如何与主机及其网络交互。

5.1. 网络背景(针对初学者)

对相关网络概念的快速概述将有助于理解本章的主题。本节仅包含高级细节;如果你是专家,可以自由地跳过。

网络通信主要涉及可能或可能不共享相同本地资源的进程之间的通信。为了理解本章的内容,你需要考虑一些常用的基本网络抽象概念。你对网络的了解越深入,你将越能了解其工作原理。但深入理解并不是使用 Docker 提供的工具所必需的。相反,本书中的材料应该促使你独立研究出现的选定主题。这些基本抽象包括协议、网络接口和端口。

5.1.1. 基础:协议、接口和端口

关于通信和网络的一个协议就像是一种语言。两个同意使用该协议的当事人可以理解对方正在传达的信息。这是有效沟通的关键。超文本传输协议(HTTP)是许多人所熟知的一种流行网络协议。它是提供万维网服务的协议。许多网络协议和多个通信层都是由这些协议创建的。目前,重要的是你要知道什么是协议,这样你才能理解网络接口和端口。

网络接口有一个地址并代表一个位置。你可以将接口视为类似于现实世界中的地址位置。网络接口就像一个邮箱。消息被送到该地址的收件人邮箱,然后从邮箱中取出以发送到其他地方。

与邮箱的邮政地址类似,网络接口有一个 IP 地址,这是由互联网协议定义的。IP 的细节很有趣,但超出了本书的范围。关于 IP 地址的重要信息是,它们在其网络中是唯一的,并且包含有关它们在网络中的位置信息。

计算机通常有两种接口:以太网接口和回环接口。以太网接口是你最熟悉的。它用于连接到其他接口和进程。回环接口不连接到任何其他接口。起初这可能会显得无用,但能够使用网络协议与同一台计算机上的其他程序通信通常很有用。在这些情况下,回环是一个很好的解决方案。

按照邮箱的隐喻,端口就像收件人或发件人。可能有多个人在同一个地址接收消息。例如,一个地址可能接收 Wendy Web 服务器、Deborah 数据库和 Casey 缓存的消息,如图 5.1 所示。每个收件人应该只打开自己的消息。

图 5.1. 进程使用相同的接口,并且以与多人可能使用同一个邮箱相同的方式唯一标识。

实际上,端口只是数字,并作为传输控制协议(TCP)或用户数据报协议(UDP)的一部分进行定义。再次强调,协议的细节超出了本书的范围,但我们鼓励你在某个时候阅读有关内容。创建协议标准的人或拥有特定产品的公司决定应该为特定目的使用哪个端口号。例如,默认情况下,Web 服务器在端口 80 上提供 HTTP 服务。MySQL 数据库产品默认在其端口 3306 上提供服务。Memcached 快速缓存技术在其端口 11211 上提供其协议。端口就像名字写在信封上一样,被写在 TCP 消息中。

接口、协议和端口都是软件和用户直接关心的问题。通过了解这些内容,您将更好地理解程序如何通信,以及您的计算机如何融入更大的图景。

5.1.2. 更大的图景:网络、NAT 和端口转发

接口是更大网络中的单个点。网络是通过接口之间的连接方式来定义的,而这种连接方式决定了接口的 IP 地址。

有时,一条消息有一个接收者,该接口并没有直接连接到,因此它被发送到一个知道如何路由消息以进行投递的中介。回到邮件隐喻,这类似于现实世界中的邮件投递员的工作方式。

当您将消息放入您的发件箱时,邮递员会取走它并将其递送到本地路由设施。该设施本身也是一个接口。它将接收消息并将其发送到下一个路由站,直到目的地。邮递员的本地路由设施可能会将消息转发到区域设施,然后转发到目的地的本地设施,最后转发给接收者。网络路由通常遵循类似的模式。图 5.2 展示了描述的路径,并绘制了物理消息路由和网络路由之间的关系。

图 5.2. 邮政系统和计算机网络中消息的路径

本章关注的是单个计算机上存在的接口,因此我们考虑的网络和路由不会非常复杂。实际上,本章是关于两个特定网络以及容器如何连接到这些网络的方式。第一个网络是您的计算机连接到的网络。第二个是一个 Docker 创建的虚拟网络,用于将所有正在运行的容器连接到计算机连接到的网络。第二个网络被称为桥接。

网桥是一种连接多个网络,使它们可以作为一个单一网络运行的接口,如图 5.3 所示。图 5.3。网桥通过根据另一种类型的网络地址选择性地转发连接网络之间的流量来工作。为了理解本章的内容,您只需要熟悉这个抽象概念。

图 5.3. 连接两个不同网络的网桥接口

图片

这只是一个对一些细微主题的非常粗略的介绍。解释只是触及了表面,以便帮助您了解如何使用 Docker 以及它简化的网络功能。

5.2. DOCKER 容器网络

Docker 将底层主机附加的网络从容器中抽象出来。这样做为应用程序提供了一定程度的运行时环境无关性,并允许基础设施管理员根据操作环境调整实现方式。连接到 Docker 网络的容器将获得一个唯一的可路由 IP 地址,该地址可以从连接到同一 Docker 网络的其它容器中路由。

这种方法的主要问题是,任何在容器内运行的软件都没有简单的方法来确定容器运行的主机的 IP 地址。这阻碍了容器向容器网络外的其他服务广告其服务端点。第 5.5 节介绍了处理这种边缘情况的一些方法。

Docker 也将网络视为一等实体。这意味着它们有自己的生命周期,并且不受任何其他对象的约束。您可以直接使用docker network子命令来定义和管理它们。

要开始使用 Docker 中的网络,请检查每个 Docker 安装中可用的默认网络。运行docker network ls将在终端打印出所有网络的表格。生成的表格应如下所示:

网络 ID          名称                驱动程序              范围 63d93214524b        bridge              bridge              本地 6eeb489baff0        host                host                本地 3254d02034ed        none                null                本地

默认情况下,Docker 包括三个网络,每个网络由不同的驱动程序提供。名为bridge的网络是默认网络,由bridge驱动程序提供。bridge驱动程序为在同一台机器上运行的容器提供容器间连接。host网络由host驱动程序提供,该驱动程序指示 Docker 不要为附加容器创建任何特殊的网络命名空间或资源。host网络上的容器与主机的网络堆栈交互,就像无容器进程一样。最后,none网络使用null驱动程序。连接到none网络的容器将不会与自身之外的网络有任何网络连接。

网络的作用域可以取三个值:本地全局蜂群。这表示网络是否被限制在存在网络的机器上(本地),应该在集群中的每个节点上创建但不在它们之间路由(全局),或者无缝跨越参与 Docker 蜂群的所有主机(多主机或集群范围)。正如您所看到的,所有默认网络都具有本地作用域,并且无法直接在不同机器上运行的容器之间路由流量。

默认的桥接网络与旧版 Docker 保持兼容,无法利用现代 Docker 功能,包括服务发现或负载均衡。使用它不被推荐。因此,您应该做的第一件事是创建自己的桥接网络。

5.2.1. 创建用户定义的桥接网络

Docker 桥接网络驱动程序使用 Linux 命名空间、虚拟以太网设备和 Linux 防火墙来构建一个特定且可定制的虚拟网络拓扑,称为桥接。生成的虚拟网络是 Docker 安装的机器上的本地网络,并在参与容器和连接的主机更广泛的网络之间创建路由。图 5.4 说明了连接到桥接网络及其组件的两个容器。

图 5.4. 默认本地 Docker 网络拓扑和两个附加的容器

图片

容器有自己的私有回环接口和连接到主机命名空间中另一个虚拟接口的单独虚拟以太网接口。这两个链接接口在主机的网络和容器之间形成连接。就像典型的家庭网络一样,每个容器都被分配了一个唯一的私有 IP 地址,该地址不能从外部网络直接访问。连接通过另一个 Docker 网络进行路由,该网络在容器之间路由流量,并且可能连接到主机的网络以形成一个桥接。

使用单个命令构建新的网络:

docker network create \ --driver bridge \ --label project=dockerinaction \ --label chapter=5 \ --attachable \ --scope local \ --subnet 10.0.42.0/24 \ --ip-range 10.0.42.128/25 \ user-network

此命令创建了一个名为user-network的新本地桥接网络。向网络添加标签元数据有助于稍后识别资源。将新网络标记为可附加允许您在任何时候将容器附加到或从网络中分离。在这里,您已手动指定了网络的作用域属性并将其设置为该驱动程序的默认值。最后,为该网络定义了一个自定义子网和可分配的地址范围,10.0.42.0/24,从最后一个八位的上半部分(10.0.42.128/25)分配。这意味着随着您向此网络添加容器,它们将接收从 10.0.42.128 到 10.0.42.255 范围内的 IP 地址。

您可以像检查其他一等 Docker 实体一样检查网络。下一节将演示如何使用带有用户网络的容器并检查结果网络配置。

5.2.2. 探索桥接网络

如果您打算在容器网络中运行网络软件,您应该对容器内部该网络的外观有一个扎实的理解。通过创建一个连接到该网络的新容器来开始探索您的新桥接网络:

docker run -it \ --network user-network \ --name network-explorer \ alpine:3.8 \ sh

通过在终端中运行以下命令,从容器中获取可用的 IPv4 地址列表(此时终端已连接到正在运行的容器):

ip -f inet -4 -o addr

结果应该看起来像这样:

1: lo    inet 127.0.0.1/8 scope host lo\ ... 18: eth0    inet 10.0.42.129/24 brd 10.0.42.255 scope global eth0\ ...

您可以从此列表中看到,容器有两个具有 IPv4 地址的网络设备。这些是回环接口(或本地主机)和 eth0(一个虚拟以太网设备),它们连接到桥接网络。此外,您可以看到 eth0 具有用户-network 配置指定的 IP 地址范围和子网内的 IP 地址(从 10.0.42.128 到 10.0.42.255)。该 IP 地址是任何其他在此桥接网络上运行的容器用来与您在此容器中运行的服务通信的 IP 地址。回环接口只能用于容器内部通信。

接下来,创建另一个桥接网络并将正在运行的network-explorer容器连接到这两个网络。首先,从运行中的容器断开终端连接(按 Ctrl-P 然后按 Ctrl-Q),然后创建第二个桥接网络:

docker network create \ --driver bridge \ --label project=dockerinaction \ --label chapter=5 \ --attachable \ --scope local \ --subnet 10.0.43.0/24 \ --ip-range 10.0.43.128/25 \ user-network2

一旦创建了第二个网络,您可以将network-explorer容器(仍在运行)连接到该网络:

docker network connect \ user-network2 \ 1 network-explorer 2

  • 1 网络名称(或 ID)

  • 2 目标容器名称(或 ID)

在容器连接到第二个网络后,重新连接您的终端以继续探索:

docker attach network-explorer

现在,回到容器中,再次检查网络接口配置将显示如下:

1: lo    inet 127.0.0.1/8 scope host lo\ ... 18: eth0    inet 10.0.42.129/24 brd 10.0.42.255 scope global eth0\ ... 20: eth1    inet 10.0.43.129/24 brd 10.0.43.255 scope global eth1\ ...

如您所预期,此输出显示network-explorer容器连接到两个用户定义的桥接网络。

网络完全是关于多方之间的通信,仅使用一个运行的容器来检查网络可能会有些无聊。但是,默认情况下是否有其他设备连接到桥接网络?需要另一个工具来继续探索。通过以下命令在运行的容器中安装 nmap 软件包:

apk update && apk add nmap

Nmap 是一款强大的网络检查工具,可用于扫描网络地址范围中的运行中的机器,识别这些机器,并确定它们正在运行的服务。对于我们来说,我们只想确定在桥接网络上还有哪些其他容器或其他网络设备可用。运行以下命令来扫描我们为桥接网络定义的 10.0.42.0/24 子网:

nmap -sn 10.0.42.* -sn 10.0.43.* -oG /dev/stdout | grep Status

命令应输出类似以下内容:

主机: 10.0.42.128 ()       状态: 启用 主机: 10.0.42.129 (7c2c161261cb)   状态: 启用 主机: 10.0.43.128 ()       状态: 启用 主机: 10.0.43.129 (7c2c161261cb)   状态: 启用

这表明每个桥接网络中只连接了两个设备:由桥接网络驱动程序创建的网关适配器和当前正在运行的容器。在两个桥接网络之一上创建另一个容器以获得更有趣的结果。

再次从终端断开(Ctrl-P, Ctrl-Q)并启动另一个连接到 user-network2 的容器。运行以下命令:

docker run -d \ --name lighthouse \ --network user-network2 \ alpine:3.8 \ sleep 1d

lighthouse 容器启动后,重新连接到你的 network-explorer 容器:

docker attach network-explorer

然后在容器的 shell 中再次运行网络扫描。结果显示,lighthouse 容器正在运行,并且可以通过其连接到 user-network2 的方式从 network-explorer 容器访问。输出应类似于以下内容:

主机: 10.0.42.128 ()       状态: 启用 主机: 10.0.42.129 (7c2c161261cb)   状态: 启用 主机: 10.0.43.128 ()       状态: 启用 主机: 10.0.43.130 (lighthouse.user-network2)       状态: 启用 主机: 10.0.43.129 (7c2c161261cb)    状态: 启用

在网络上发现 lighthouse 容器确认了网络连接按预期工作,并演示了基于 DNS 的服务发现系统是如何工作的。当你扫描网络时,你通过其 IP 地址发现了新的节点,nmap 能够解析该 IP 地址。这意味着你可以(或你的代码)根据名称在网络中查找单个容器。通过在容器内运行 nslookup lighthouse 来尝试自己这样做。容器主机名基于容器名称,或者可以在创建容器时通过指定 --hostname 标志手动设置。

这次探索展示了你调整桥接网络以适应环境的能力,将运行中的容器附加到多个网络上的能力,以及这些网络在附加容器内运行的软件看来是什么样子。但是桥接网络仅在单个机器上工作。它们不具备集群感知性,并且容器 IP 地址不能从该机器外部路由。

5.2.3. 超越桥接网络

根据你的用例,桥接网络可能已经足够。例如,桥接网络通常非常适合单服务器部署,如运行内容管理系统或大多数本地开发任务的 LAMP 堆栈。但是,如果你正在运行一个设计为容忍机器故障的多服务器环境,你需要能够无缝地在不同机器上的容器之间路由流量。桥接网络无法做到这一点。

Docker 提供了一些选项来处理这种用例。最佳选项取决于你构建网络的环境。如果你在 Linux 主机上使用 Docker 并且控制主机网络,你可以使用由macvlanipvlan网络驱动程序提供的底层网络。底层网络为每个容器创建第一类网络地址。这些身份是可发现的并且可以从主机附加的网络中进行路由。在机器上运行的每个容器看起来就像网络上的一个独立节点。

如果你正在运行 Docker for Mac 或 Docker for Windows,或者在一个托管云环境中运行,这些选项将不起作用。此外,底层网络配置依赖于主机网络,因此定义很少是可移植的。更受欢迎的跨主机容器网络选项是 overlay 网络。

在启用了 swarm 模式的 Docker 引擎上,overlay 网络驱动程序可用。overlay 网络在结构上与桥接网络相似,但逻辑桥接组件是跨主机感知的,并且可以在 swarm 中的每个节点之间路由容器间的连接。

就像在桥接网络上一样,overlay 网络上的容器不能从集群外部直接路由。但容器间通信简单,网络定义在很大程度上独立于主机网络环境。

在某些情况下,你可能会有底层或 overlay 网络无法覆盖的特殊网络需求。也许你需要能够调整主机网络配置,或者确保容器以完全网络隔离的方式运行。在这些情况下,你应该使用特殊容器网络之一。

5.3. 特殊容器网络:主机和 none

当你使用docker network list列出可用的网络时,结果将包括两个特殊条目:hostnone。这些实际上不是网络;相反,它们是具有特殊意义的网络附加类型。

当你在docker run命令中指定--network host选项时,你是在告诉 Docker 创建一个没有特殊网络适配器或网络命名空间的新的容器。在结果容器内运行的任何软件都将具有与容器外部运行相同的访问主机网络的程度。由于没有网络命名空间,所有用于调整网络堆栈的内核工具都可用于修改(只要修改进程有权这样做)。

在主机网络上运行容器可以访问运行在 localhost 上的主机服务,并且可以查看并绑定到主机网络接口的任何接口。以下命令通过列出主机网络上容器内所有可用的网络接口来演示这一点:

docker run --rm \ --network host \ alpine:3.8 ip -o addr

在主机网络上运行很有用,适用于系统服务或其他基础设施组件。但在多租户环境中不合适,并且应禁止第三方容器使用。沿着这些思路,你通常会希望不将容器连接到网络。本着构建最小权限系统的精神,你应该尽可能使用none网络。

none网络上创建容器指示 Docker 不为新容器提供任何连接的虚拟以太网适配器。它将有自己的网络命名空间,因此它将是隔离的,但由于没有跨越命名空间边界的适配器连接,它将无法使用网络与容器外部通信。以这种方式配置的容器仍然有自己的回环接口,因此多进程容器仍然可以使用连接到 localhost 的连接进行进程间通信。

你可以通过检查自己的网络配置来验证这一点。运行以下命令以列出none网络中容器内的可用接口:

docker run --rm \ --network none \ alpine:3.8 ip -o addr

运行此示例,你可以看到唯一可用的网络接口是回环接口,绑定到地址 127.0.0.1。这种配置意味着三件事:

  • 容器中运行的任何程序都可以连接到该接口或等待连接。

  • 容器外部无法连接到该接口。

  • 容器内部运行的任何程序都无法连接到容器外部。

最后一点很重要,并且很容易证明。如果你连接到了互联网,尝试连接到一个应该始终可用的流行服务。在这种情况下,尝试连接到 Cloudflare 的公共 DNS 服务:

docker run --rm \ --network none \ 1 alpine:3.8 \ ping -w 2 1.1.1.1 2

  • 1 创建一个封闭的容器

  • 2 向 Cloudflare 发送 ping 请求

在这个例子中,你创建了一个网络隔离的容器,并尝试测试你的容器与 Cloudflare 提供的公共 DNS 服务器之间的速度。这次尝试应该会失败,并显示类似ping: send-to: Network is unreachable的消息。这很合理,因为我们知道该容器没有通往更大网络的路径。

何时使用封闭容器

当需要最高级别的网络隔离,或者程序不需要网络访问时,应使用none网络。例如,运行终端文本编辑器不应需要网络访问。运行生成随机密码的程序应在没有网络访问的容器内运行,以防止该秘密被盗。

none网络上的容器彼此之间以及与世界其他部分都是隔离的,但请记住,即使在bridge网络上的容器也不是可以从运行 Docker 引擎的主机外部直接路由的。

桥接网络使用网络地址转换(NAT)来使所有目标在桥接网络之外的外出容器流量看起来像是从主机本身发出的。这意味着你在容器中运行的服务的软件与世界其他部分以及客户和客户主要所在的网络部分是隔离的。下一节将描述如何弥合这一差距。

5.4. 使用 NODEPORT 发布处理入站流量

Docker 容器网络主要关注容器之间的简单连接和路由。将运行在容器中的服务与外部网络客户端连接需要额外的一步。由于容器网络通过网络地址转换连接到更广泛的网络,你必须明确告诉 Docker 如何从外部网络接口转发流量。你需要在主机接口上指定一个 TCP 或 UDP 端口以及目标容器和容器端口,类似于在家网络中通过 NAT 屏障转发流量。

在这里,我们使用 NodePort 发布这个术语来匹配 Docker 和其他生态系统项目。Node 部分是对主机的一种推断,因为在更大的机器集群中,节点通常指的是主机。

端口发布配置在容器创建时提供,以后不能更改。docker rundocker create命令提供了一个-p--publish列表选项。与其他选项一样,-p选项接受冒号分隔的字符串参数。该参数指定了主机接口、要转发的宿主端口、目标端口和端口协议。以下所有参数都是等效的:

  • 0.0.0.0:8080:8080/tcp

  • 8080:8080/tcp

  • 8080:8080

这些选项中的每一个都会将所有主机接口上的 TCP 端口 8080 转发到新容器中的 TCP 端口 8080。第一个参数是完整形式。为了将语法放在更完整的上下文中,考虑以下示例命令:

docker run --rm \ -p 8080 \ alpine:3.8 echo "forward ephemeral TCP -> container TCP 8080" docker run --rm \ -p 8088:8080/udp \ alpine:3.8 echo "host UDP 8088 -> container UDP 8080" docker run --rm \ -p 127.0.0.1:8080:8080/tcp \ -p 127.0.0.1:3000:3000/tcp \ alpine:3.8 echo "forward multiple TCP ports from localhost"

这些命令执行不同的操作,展示了语法的灵活性。新用户遇到的第一问题可能是假设第一个示例会将宿主机的 8080 端口映射到容器的 8080 端口。实际上,宿主机操作系统将选择一个随机的主机端口,并且流量将被路由到容器的 8080 端口。这种设计和默认行为的好处是端口是稀缺资源,选择随机端口可以使软件和工具避免潜在的冲突。但是,容器内运行的程序无法知道它们是在容器内运行的,它们绑定到容器网络,或者哪个端口是从宿主机转发过来的。

Docker 提供了一种查找端口映射的机制。当您让操作系统选择端口时,这个功能至关重要。运行 docker port 子命令以查看转发到任何给定容器的端口:

docker run -d -p 8080 --name listener alpine:3.8 sleep 300 docker port listener

此信息也可以通过 docker ps 子命令以摘要形式获取,但从表中挑选特定的映射可能会很麻烦,并且与其他命令的兼容性不佳。docker port 子命令还允许您通过指定容器端口和协议来缩小查找查询。这在多个端口已发布时特别有用:

docker run -d \ -p 8080 \ 1 -p 3000 \ 1 -p 7500 \ 1 --name multi-listener \ alpine:3.8 sleep 300 docker port multi-listener 3000 2

  • 1 发布多个端口

  • 2 查找映射到容器端口 3000 的主机端口

使用本节中介绍的工具,您应该能够管理将任何传入流量路由到在您的宿主机上运行的正确容器。但是,还有几种其他方式来自定义容器网络配置和与 Docker 网络一起使用的注意事项。这些内容将在下一节中介绍。

5.5. 容器网络注意事项和自定义

网络被各种应用程序和许多环境所使用。有些需求目前无法满足,或者可能需要进一步的网络自定义。本节涵盖了一些任何用户在采用容器用于网络应用程序时应熟悉的简短主题列表。

5.5.1. 没有防火墙或网络策略

今天,Docker 容器网络在容器之间不提供任何访问控制或防火墙机制。Docker 网络设计遵循 Docker 其他许多地方使用的命名空间模型。命名空间模型通过将资源访问控制问题转化为可寻址问题来解决资源访问控制问题。这种想法是,位于同一容器网络中的两个容器中的软件应该能够通信。在实践中,这远远不是事实,只有应用程序级的身份验证和授权才能保护同一网络上的容器彼此不受侵害。记住,不同的应用程序携带不同的漏洞,可能在不同主机上的容器中运行,具有不同的安全态势。一个被破坏的应用程序在打开网络连接之前不需要提升权限。防火墙不能保护您。

这个设计决策影响了我们构建互联网服务依赖关系和模型常见服务部署的方式。简而言之,始终使用适当的应用程序级访问控制机制来部署容器,因为同一容器网络上的容器将具有相互(双向)无限制的网络访问。

5.5.2. 自定义 DNS 配置

域名系统(DNS)是一种将主机名映射到 IP 地址的协议。这种映射使得客户端可以从对特定主机 IP 的依赖中解耦,转而依赖于由已知名称引用的任何主机。改变出站通信的最基本方法之一是为 IP 地址创建名称。

通常,桥接网络上的容器和您网络上的其他计算机具有私有 IP 地址,这些地址不能公开路由。这意味着除非您运行自己的 DNS 服务器,否则您不能通过名称来引用它们。Docker 为自定义新容器的 DNS 配置提供了不同的选项。

首先,docker run 命令有一个 --hostname 标志,您可以使用它来设置新容器的主机名。此标志会在容器内部的 DNS 覆盖系统中添加一个条目。该条目将提供的主机名映射到容器的桥接 IP 地址:

docker run --rm \ --hostname barker \ 1 alpine:3.8 \ nslookup barker 2

  • 1 设置容器主机名

  • 2 解析主机名到 IP 地址

此示例创建了一个具有主机名 barker 的新容器,并运行了一个程序来查找相同名称的 IP 地址。运行此示例将生成类似以下输出的输出:

Server:    10.0.2.3 Address 1: 10.0.2.3 Name:      barker Address 1: 172.17.0.22 barker

最后行上的 IP 地址是新容器的桥接 IP 地址。标有 Server 的行上提供的地址是提供映射的服务器地址。

设置容器的主机名对于容器内运行的程序需要查找自己的 IP 地址或必须自我识别时很有用。因为其他容器不知道这个主机名,所以它的用途有限。但是,如果你使用外部 DNS 服务器,你可以共享这些主机名。

修改容器 DNS 配置的第二个选项是能够指定一个或多个要使用的 DNS 服务器。为了演示,以下示例创建了一个新的容器,并将该容器的 DNS 服务器设置为谷歌的公共 DNS 服务:

docker run --rm \ --dns 8.8.8.8 \ 1 alpine:3.8 \ --dns 8.8.8.8 \ 2

  • 1 设置主 DNS 服务器

  • 2 解析docker.com的 IP 地址

如果你在一个笔记本电脑上运行 Docker 并且经常在不同的互联网服务提供商之间移动,使用特定的 DNS 服务器可以提供一致性。这对于构建服务和网络的人来说是一个关键工具。在设置自己的 DNS 服务器时,有一些重要的注意事项:

  • 值必须是 IP 地址。如果你这么想,原因很明显:容器需要一个 DNS 服务器来执行名称查找。

  • --dns=[]标志可以设置多次以设置多个 DNS 服务器(以防一个或多个不可达)。

  • 当你在后台运行 Docker 引擎时,可以设置--dns=[]标志。当你这样做时,默认情况下,这些 DNS 服务器将设置在每个容器上。但是,如果你在容器运行时停止引擎,并在重启引擎时更改默认设置,运行中的容器仍然会保留旧的 DNS 设置。你需要重新启动这些容器才能使更改生效。

第三个与 DNS 相关的选项--dns-search=[]允许你指定一个 DNS 搜索域,类似于默认的主机名后缀。设置后,任何没有已知顶级域名(例如,.com 或.net)的主机名都将使用指定的后缀进行搜索:

docker run --rm \ --dns-search docker.com \ 1 alpine:3.8 \ --dns 8.8.8.8 \ 2

此命令将解析为 hub.docker.com 的 IP 地址,因为提供的 DNS 搜索域将完成主机名。它是通过操作/etc/resolv.conf 文件来实现的,该文件用于配置常见的名称解析库。以下命令显示了这些 DNS 操作选项如何影响文件:

docker run --rm \ --dns-search docker.com \ 1 --dns 1.1.1.1 \ 2 alpine:3.8 cat /etc/resolv.conf # 将显示类似的内容:# search docker.com # nameserver 1.1.1.1

  • 1 设置搜索域

  • 2 设置主 DNS 服务器

此功能最常用于诸如内部企业网络的快捷名称等琐事。例如,你的公司可能维护一个内部文档维基,你可以简单地通过wiki/来引用。但这可以更强大。

假设你为你的开发和测试环境维护一个单独的 DNS 服务器。与其构建环境感知的软件(例如使用硬编码的环境特定名称,如 myservice.dev.mycompany.com),你可能会考虑使用 DNS 搜索域并使用环境无关的名称(例如,myservice):

docker run --rm \ --dns-search dev.mycompany \ 1 alpine:3.8 \ nslookup myservice 2 docker run --rm \ --dns-search test.mycompany \ 3 alpine:3.8 \ nslookup myservice 4

  • 1 注意开发前缀。

  • 2 解析为 myservice.dev.mycompany

  • 3 注意测试前缀。

  • 4 解析为 myservice.test.mycompany

使用这种模式,唯一的变化是程序运行的上下文。与提供自定义 DNS 服务器一样,你可以为同一容器提供几个自定义搜索域。只需将标志设置为与搜索域数量相同即可。例如:

docker run --rm \ --dns-search mycompany \ --dns-search myothercompany ...

此标志也可以在启动 Docker 引擎时设置,为创建的每个容器提供默认值。再次提醒,这些选项仅在容器创建时为容器设置。如果你在容器运行时更改默认值,该容器将保持旧值。

最后要考虑的 DNS 功能提供了覆盖 DNS 系统的能力。这使用与 --hostname 标志相同的系统。docker run 命令上的 --add-host=[] 标志让你可以为 IP 地址和主机名对提供自定义映射:

docker run --rm \ --add-host test:10.10.10.255 \ 1 alpine:3.8 \ nslookup test 2

  • 1 添加主机条目

  • 2 解析为 10.10.10.255

--dns--dns-search 一样,此选项可以指定多次。但与那些其他选项不同,此标志不能在引擎启动时设置为默认值。

这个功能是一种名称解析手术刀。为单个容器提供特定的名称映射是可能的最细粒度定制。你可以通过将它们映射到已知的 IP 地址(如 127.0.0.1)来有效地阻止目标主机名。你可以用它来将特定目的地的流量通过代理路由。这通常用于将不安全的流量通过安全的通道(如 SSH 隧道)路由。添加这些覆盖是多年来运行自己本地副本的网页开发者使用的一种技巧。如果你花点时间思考名称到 IP 地址映射提供的接口,我们相信你可以想出各种各样的用途。

所有自定义映射都存储在容器内 /etc/hosts 文件中。如果你想查看已设置的覆盖,只需检查该文件即可。编辑和解析此文件的规则可以在网上找到,但这超出了本书的范围:

docker run --rm \    --hostname mycontainer \ 1 --add-host docker.com:127.0.0.1 \ 2 --add-host test:10.10.10.2 \ 3 alpine:3.8 \    cat /etc/hosts 4

  • 1 设置主机名

  • 2 创建主机条目

  • 3 创建另一个主机条目

  • 4 查看所有条目

这应该会产生类似以下内容的输出:

172.17.0.45  mycontainer 127.0.0.1    localhost ::1          localhost ip6-localhost ip6-loopback fe00::0      ip6-localnet ff00::0      ip6-mcastprefix ff02::1      ip6-allnodes ff02::2      ip6-allrouters 10.10.10.2   test 127.0.0.1    docker.com

DNS 是一个强大的系统,可以改变行为。名称到 IP 地址的映射提供了一个简单的接口,人们和程序可以使用它来将自己与特定的网络地址解耦。如果 DNS 是您改变出站流量行为的最佳工具,那么防火墙和网络拓扑是您控制入站流量的最佳工具。

5.5.3. 外部化网络管理

最后,一些组织、基础设施或产品需要直接管理容器网络配置、服务发现和其他网络相关资源。在这些情况下,您或您使用的容器编排器将使用 Docker none 网络创建容器。然后使用其他容器感知工具来创建和管理容器网络接口,管理 NodePort 发布,将容器注册到服务发现系统中,并与上游负载均衡系统集成。

Kubernetes 拥有一个完整的网络提供商生态系统,根据您如何使用 Kubernetes(作为项目、产品化发行版或托管服务),您可能或可能没有对您使用的提供商有任何发言权。关于 Kubernetes 的网络选项可以写整本书。我不会在这里尝试总结它们,以免对它们造成不公。

在网络提供商层之上,一系列服务发现工具使用 Linux 和容器技术的各种功能。服务发现不是一个已解决的问题,因此解决方案领域变化很快。如果您发现 Docker 网络结构不足以解决您的集成和管理问题,请调查该领域。每个工具都有自己的文档和实现模式,您需要查阅这些指南才能有效地将它们与 Docker 集成。

当您外部化网络管理时,Docker 仍然负责为容器创建网络命名空间,但它不会创建或管理任何网络接口。您将无法使用任何 Docker 工具来检查网络配置或端口映射。如果您在一个混合环境中运行,其中一些容器网络已被外部化,则内置的服务发现机制不能用于将 Docker 管理的容器流量路由到外部化容器。混合环境很少见,应避免。

摘要

网络是一个广泛的主题,需要几本书才能全面覆盖。本章旨在帮助对网络基础知识有基本理解的读者采用 Docker 提供的单主机网络功能。在阅读本材料时,你学习了以下内容:

  • Docker 网络是一等实体,可以像容器、卷和镜像一样创建、列出和删除。

  • 桥接网络是一种特殊类型的网络,它允许通过内置的容器名称解析直接进行容器间的网络通信。

  • Docker 默认提供另外两个特殊网络:hostnone

  • 使用none驱动程序创建的网络将隔离附加的容器与网络。

  • 在主机网络上的容器将完全访问主机上的网络设施和接口。

  • 将网络流量转发到主机端口,进入目标容器的端口,并使用 NodePort 发布。

  • Docker 桥接网络不提供任何网络防火墙或访问控制功能。

  • 网络名称解析堆栈可以针对每个容器进行自定义。可以定义自定义 DNS 服务器、搜索域和静态主机。

  • 可以通过第三方工具和使用 Docker 的none网络将网络管理外部化。

第六章. 使用资源控制限制风险

本章涵盖以下内容:

  • 设置资源限制

  • 共享容器内存

  • 设置用户、权限和管理权限

  • 授予访问特定 Linux 功能

  • 与 SELinux 和 AppArmor 协同工作

容器提供隔离的进程上下文,而不是整个系统虚拟化。语义上的差异可能看起来很微妙,但影响是巨大的。第一章简要提到了这些差异。第二章到第五章分别涵盖了 Docker 容器不同的隔离功能集。本章涵盖了剩余的四个,并包括有关增强系统安全性的信息。

本章介绍的功能主要集中在管理或限制运行软件的风险。这些功能可以防止软件因错误或攻击而表现不佳,因为它们可能会消耗可能导致你的计算机无响应的资源。容器可以帮助确保软件只使用你期望的计算资源并访问数据。你将学习如何为容器分配资源限制、访问共享内存、以特定用户身份运行程序、控制容器可以对你的计算机做出的更改类型,以及与其他 Linux 隔离工具集成。其中一些主题涉及本书范围之外的 Linux 功能。在这些情况下,我们试图给你一个关于它们目的和基本使用示例的想法,以及如何将它们与 Docker 集成。图 6.1 显示了用于构建 Docker 容器的八个命名空间和功能。

图 6.1. 八边形容器

最后提醒一次:Docker 及其使用的技术是不断发展的项目。本章中的示例适用于 Docker 1.13 及以后的版本。一旦你学会了本章中介绍的工具,记得在构建有价值的东西时检查发展、增强和新最佳实践。

6.1. 设置资源限制

物理系统资源,如内存和 CPU 上的时间,是稀缺的。如果计算机上进程的资源消耗超过了可用的物理资源,进程将遇到性能问题,并且可能停止运行。构建一个强大隔离的系统的一部分包括为单个容器提供资源限制。

如果你想要确保一个程序不会耗尽你电脑上的其他程序,最简单的事情就是设置它可以使用的资源限制。你可以使用 Docker 管理内存、CPU 和设备资源限制。默认情况下,Docker 容器可能使用无限 CPU、内存和设备 I/O 资源。docker container createrun 命令提供了管理容器可用资源的标志。

6.1.1. 内存限制

内存限制是对容器可以使用的内存的最基本限制。它们限制了容器内进程可以使用的内存量。内存限制对于确保一个容器不能分配所有系统内存,从而让其他程序因缺少内存而无法运行非常有用。你可以通过在 docker container rundocker container create 命令中使用 -m--memory 标志来设置限制。该标志接受一个值和一个单位。格式如下:

<数字><可选单位> 其中单位 = b, k, m 或 g

在这些命令的上下文中,b 代表字节,k 代表千字节,m 代表兆字节,而 g 代表吉字节。将这项新知识应用于实践,启动一个你将在其他示例中使用的数据库应用程序:

docker container run -d --name ch6_mariadb \   --memory 256m \ 1 --cpu-shares 1024 \   --cap-drop net_raw \   -e MYSQL_ROOT_PASSWORD=test \   mariadb:5.5

  • 1 设置内存限制

使用此命令,你将安装名为 MariaDB 的数据库软件,并启动一个内存限制为 256 兆字节的容器。你可能已经注意到了这个命令上的一些额外标志。本章将涵盖这些标志中的每一个,但你可能已经能够猜出它们的作用。还有一点需要注意,你不会暴露任何端口或将端口绑定到主机的接口。通过从主机上的另一个容器链接到这个数据库,将是最容易连接到这个数据库的方式。在我们到达那里之前,我们想确保你对这里发生的事情以及如何使用内存限制有一个全面的理解。

关于内存限制,最重要的理解是它们并不是预留。它们不能保证指定数量的内存将可用。它们只是防止过度消耗的一种保护措施。此外,Linux 内核对内存会计和限制执行的实现非常高效,因此你不需要担心这个特性的运行时开销。

在实施内存配额之前,你应该考虑两件事。首先,你运行的软件是否可以在提议的内存配额下运行?其次,你运行的系统是否可以支持这个配额?

第一个问题通常很难回答。如今,很少看到开源软件会公布最低要求。即使公布了,你也必须理解软件的内存需求是如何根据你要处理的数据大小进行缩放的。无论是好是坏,人们往往会高估并根据试错进行调整。一个选择是在具有实际工作负载的容器中运行软件,并使用docker stats命令查看容器在实际中使用了多少内存。对于刚刚启动的mariadb容器,docker stats ch6_mariadb显示容器使用了大约 100 兆字节的内存,很好地适应了其 256 兆字节的限制。在内存敏感的工具,如数据库的情况下,数据库管理员等熟练的专业人士可以做出更明智的估计和建议。即便如此,问题通常由另一个问题回答:你有多少内存?这引出了第二个问题。

你运行的系统是否可以支持这个配额?可以设置一个比系统上可用内存更大的内存配额。在具有交换空间(扩展到磁盘的虚拟内存)的主机上,容器可以实现这个配额。可以指定一个比任何物理内存资源更大的配额。在这些情况下,系统的限制将始终限制容器,运行时行为将与没有指定配额时相似。

最后,了解如果软件耗尽可用内存,软件可能会以几种方式失败。一些程序可能会因为内存访问错误而失败,而其他程序可能会开始将内存不足错误写入它们的日志。Docker 既检测不到这个问题,也不会尝试减轻这个问题。它能做的最好的事情是应用你可能通过--restart标志指定的重启逻辑,该标志在第二章中描述。

6.1.2. CPU

处理时间与内存一样稀缺,但饥饿效应是性能下降而不是失败。等待 CPU 时间的暂停进程仍在正确工作。但如果它运行的是一个重要的延迟敏感型数据处理程序、一个盈利的 Web 应用程序或您的应用程序的后端服务,则慢速进程可能比失败的进程更糟糕。Docker 允许您以两种方式限制容器的 CPU 资源。

首先,您可以指定容器相对于其他容器的相对权重。Linux 使用此来决定容器相对于其他运行容器应使用的 CPU 时间百分比。这个百分比是针对容器可用的所有处理器的计算周期总和。

要设置容器的 CPU 分享并建立其相对权重,docker container rundocker container create 都提供了 --cpu-shares 标志。提供的值应该是一个整数(这意味着您不应该引用它)。启动另一个容器以查看 CPU 分享如何工作:

docker container run -d -P --name ch6_wordpress \ --memory 512m \ --cpu-shares 512 \ 1 --cap-drop net_raw \ --link ch6_mariadb:mysql \ -e WORDPRESS_DB_PASSWORD=test \ wordpress:5.0.0-php7.2-apache

  • 1 设置相对进程权重

此命令将下载并启动 WordPress 版本 5.0。它用 PHP 编写,是软件适应安全风险挑战的一个很好的例子。在这里,我们启动它时采取了一些额外的预防措施。如果您想在您的计算机上看到它运行,请使用 docker port ch6_wordpress 获取服务正在运行的端口号(我们将称之为 <端口号>),然后在您的网页浏览器中打开 http://localhost:<端口号>。如果您使用 Docker Machine,您需要使用 docker-machine ip 来确定 Docker 运行的虚拟机的 IP 地址。当您有了这个信息后,将此值替换为先前 URL 中的 localhost。

当您启动 MariaDB 容器时,您将其相对权重(cpu-shares)设置为 1024,并将 WordPress 的相对权重设置为 512。这些设置创建了一个系统,其中 MariaDB 容器每获得一个 WordPress 循环就获得两个 CPU 循环。如果您启动了第三个容器并将其 --cpu-shares 值设置为 2048,则它将获得一半的 CPU 循环,MariaDB 和 WordPress 将以前相同的比例分割另一半。图 6.2 展示了根据系统总权重如何改变部分。

图 6.2. 相对权重和 CPU 分享

图片

CPU 份额与内存限制不同,因为它们只在 CPU 时间竞争时强制执行。如果其他进程和容器处于空闲状态,容器可能会超出其限制。这种方法确保 CPU 时间不会被浪费,并且当其他进程需要 CPU 时,有限的进程会释放。此工具的目的是防止一个或一组进程压倒计算机,而不是阻碍这些进程的性能。默认设置不会限制容器,如果机器其他时间空闲,它将能够使用 100%的 CPU。

现在您已经了解了cpu-shares如何按比例分配 CPU,我们将介绍cpus选项,它提供了一种限制容器使用的总 CPU 数量的方法。cpus选项通过配置 Linux 完全公平调度器(CFS)为容器分配 CPU 资源配额。Docker 允许将配额表示为容器应该能够使用的 CPU 核心数。默认情况下,CPU 配额每 100 毫秒分配、强制执行和刷新一次。如果一个容器使用了所有的 CPU 配额,其 CPU 使用率将被限制,直到下一个测量周期开始。以下命令将允许之前的 WordPress 示例消耗最多 0.75 个 CPU 核心:

docker container run -d -P --name ch6_wordpress \ --memory 512m \ --cpus 0.75 \ 1 --cap-drop net_raw \ --link ch6_mariadb:mysql \ -e WORDPRESS_DB_PASSWORD=test \ wordpress:5.0.0-php7.2-apache

  • 1 使用最多 0.75 个 CPU

Docker 还暴露了将容器分配到特定 CPU 集的能力。大多数现代硬件都使用多核 CPU。粗略地说,CPU 可以并行处理与核心数量相等的指令。这在您在同一台计算机上运行多个进程时特别有用。

上下文切换是将执行一个进程的任务切换到执行另一个进程。上下文切换代价高昂,可能会对系统的性能产生明显的影响。在某些情况下,通过确保关键进程永远不会在相同的 CPU 核心集上执行,可以减少关键进程的上下文切换。您可以使用docker container rundocker container create命令中的--cpuset-cpus标志来限制容器只执行在特定的 CPU 核心集上。

您可以通过对机器的一个核心进行压力测试并检查 CPU 工作负载来看到 CPU 集限制的实际效果:

# 启动一个仅限于单个 CPU 的容器并运行负载生成器 docker container run -d \ --cpuset-cpus 0 \ 1 --name ch6_stresser # 启动一个容器来监控负载下的 CPU docker container run -it --rm dockerinaction/ch6_htop

  • 1 限制为 CPU 编号 0

当你运行第二个命令时,你会看到htop显示正在运行的过程和可用 CPU 的工作负载。ch6_stresser容器将在 30 秒后停止运行,因此当你运行这个实验时不要延迟。使用完htop后,按 Q 键退出。在继续之前,请记住关闭并删除名为ch6_stresser的容器:

docker rm -vf ch6_stresser

我们第一次使用它时觉得这很令人兴奋。为了获得最佳体验,请通过使用不同的--cpuset-cpus标志值重复此实验几次。如果你这样做,你会看到分配给不同核心或不同核心集的过程。值可以是列表或范围:

  • 0,1,2— 包含 CPU 的前三个核心的列表

  • 0-2— 包含 CPU 前三个核心的范围

6.1.3. 设备访问

设备是我们将要讨论的最后一类资源。控制对设备的访问与内存和 CPU 限制不同。在容器内提供对主机设备的访问更像是资源授权控制,而不是限制。

Linux 系统拥有各种设备,包括硬盘、光驱、USB 驱动器、鼠标、键盘、音频设备和网络摄像头。容器默认可以访问主机的一些设备,而 Docker 为每个容器创建其他特定设备。这类似于虚拟终端为用户提供专用输入和输出设备的方式。

有时,在主机和特定容器之间共享其他设备可能很重要。比如说,你正在运行需要访问网络摄像头的计算机视觉软件。在这种情况下,你需要授予运行你的软件的容器对系统上连接的网络摄像头设备的访问权限;你可以使用--device标志来指定要挂载到新容器中的一组设备。以下示例将你的网络摄像头在/dev/video0映射到新容器内的相同位置。运行此示例仅在你有一个在/dev/video0的网络摄像头时有效:

docker container run -it --rm --device /dev/video0:/dev/video0 \ 1 ubuntu:16.04 ls -al /dev

  • 1 挂载 video0

提供的值必须是在主机操作系统上的设备文件与容器内部位置之间的映射。设备标志可以设置多次,以授予对不同设备的访问权限。

在具有定制硬件或专有驱动程序的情况下,人们会发现这种设备访问方式很有用。与修改主机操作系统相比,这是一种更可取的方法。

6.2. 共享内存

Linux 为同一计算机上运行的进程之间共享内存提供了一些工具。这种基于共享内存的进程间通信(IPC)以内存速度运行。当网络或基于管道的 IPC 的延迟导致软件性能低于要求时,通常会使用这种形式的 IPC。基于共享内存的 IPC 使用的最佳例子是在科学计算和一些流行的数据库技术,如 PostgreSQL。

Docker 默认为每个容器创建一个唯一的 IPC 命名空间。Linux IPC 命名空间分区共享内存原语,如命名共享内存块和信号量,以及消息队列。如果你不确定这些是什么,只需知道它们是 Linux 程序用来协调处理的一些工具。IPC 命名空间防止一个容器中的进程访问主机或其他容器上的内存。

6.2.1. 在容器之间共享 IPC 原语

我们创建了一个名为 dockerinactionch6_ipc 的镜像,其中包含生产者和消费者。它们使用共享内存进行通信。以下内容将帮助你理解在单独的容器中运行这些程序的问题:

docker container run -d -u nobody --name ch6_ipc_producer \ 1 --ipc shareable \ dockerinaction/ch6_ipc -producer docker container run -d -u nobody --name ch6_ipc_consumer \ 2 dockerinaction/ch6_ipc -consumer

  • 1 启动生产者

  • 2 启动消费者

这些命令启动了两个容器。第一个创建了一个消息队列,并开始在它上面广播消息。第二个应该从消息队列中提取消息并将其写入日志。你可以通过以下命令检查每个容器的日志来了解它们各自在做什么:

docker logs ch6_ipc_producer docker logs ch6_ipc_consumer

注意到你所启动的容器中存在问题。消费者从未在队列中看到任何消息。每个进程使用相同的密钥来识别共享内存资源,但它们引用的是不同的内存。原因是每个容器都有自己的共享内存命名空间。

如果你需要运行与不同容器中共享内存通信的程序,那么你需要使用 --ipc 标志将它们的 IPC 命名空间联合起来。--ipc 标志有一个容器模式,它将在与另一个目标容器相同的 IPC 命名空间中创建一个新的容器。这就像第五章中提到的 --network 标志一样。图 6.3 展示了容器及其命名空间共享内存池之间的关系。

图 6.3. 三个容器及其共享内存池;producerconsumer 共享单个池。

使用以下命令自行测试联合 IPC 命名空间:

docker container rm -v ch6_ipc_consumer 1 docker container run -d --name ch6_ipc_consumer \ 2 --ipc container:ch6_ipc_producer \ 3 dockerinaction/ch6_ipc -consumer

  • 1 移除原始消费者

  • 2 启动新消费者

  • 3 联合 IPC 命名空间

这些命令重新构建了消费者容器,并重用了 ch6_ipc_producer 容器的 IPC 命名空间。这次,消费者应该能够访问服务器正在写入的相同内存位置。您可以通过以下命令检查每个容器的日志来查看此功能是否正常工作:

docker logs ch6_ipc_producer docker logs ch6_ipc_consumer

在继续之前,请记得清理您的运行中的容器:

docker rm -vf ch6_ipc_producer ch6_ipc_consumer

  • 使用 v 选项将清理卷。

  • 使用 f 选项如果容器正在运行,则会将其终止。

  • rm 命令接受容器列表。

重复使用容器共享内存命名空间具有明显的安全影响。但如果需要,此选项是可用的。在容器之间共享内存比与主机共享内存更安全。可以使用 --ipc=host 选项与主机共享内存。然而,在现代 Docker 发行版中,共享主机内存是困难的,因为这违反了 Docker 对容器默认安全策略。

随意查看此示例的源代码。这是一个丑陋但简单的 C 程序。您可以通过查看 Docker Hub 上镜像页面的源代码仓库来找到它。

6.3. 理解用户

Docker 默认以由镜像元数据指定的用户启动容器,通常是 root 用户。root 用户几乎可以完全访问容器的状态。以该用户身份运行的任何进程都会继承这些权限。因此,如果这些进程中的一个存在错误,可能会损坏容器。有方法可以限制损害,但最有效的防止此类问题的方式是不使用 root 用户。

存在合理的例外;有时使用 root 用户可能是最佳或唯一的选择。您使用 root 用户来构建镜像,在运行时没有其他选择时。类似地,有时您可能想在容器内运行系统管理软件。在这些情况下,进程需要不仅对容器而且对主机操作系统的特权访问。本节涵盖了这些问题的各种解决方案。

6.3.1. 使用 run-as 用户工作

在创建容器之前,能够知道默认将使用哪个用户名(以及用户 ID)将很好。默认值由镜像指定。目前还没有方法可以检查镜像以发现诸如 Docker Hub 中的默认用户之类的属性。您可以使用 docker inspect 命令检查镜像元数据。如果您在第二章中错过了它,inspect 子命令显示特定容器或镜像的元数据。一旦您已拉取或创建了一个镜像,您可以使用以下命令获取容器正在使用的默认用户名:

docker image pull busybox:1.29 docker image inspect busybox:1.29 1 docker inspect --``format "{{.Config.User}}" busybox:1.29 2

  • 1 显示了 busybox 的所有元数据

  • 2 仅显示由 busybox 镜像定义的运行用户

如果结果是空的,容器将默认以 root 用户运行。如果结果不为空,则可能是镜像作者特别指定了默认运行用户,或者你在创建容器时设置了特定的运行用户。第二个命令中使用的--format-f选项允许你指定一个模板来渲染输出。在这种情况下,你选择了文档的Config属性的User字段。值可以是任何有效的 Golang 模板,所以如果你愿意,你可以用结果来发挥创意。

这种方法有一个问题。运行用户可能会被镜像使用的入口点或命令更改。这些有时被称为引导或 init 脚本。docker inspect返回的元数据仅包括容器启动时的配置。因此,如果用户更改,它将不会反映在那里。

目前,修复这个问题的唯一方法就是查看镜像内部。你可以在下载镜像文件后扩展它们,并手动检查元数据和 init 脚本,但这样做既耗时又容易出错。目前,进行一个简单的实验来确定默认用户可能更好。这将解决第一个问题,但不会解决第二个:

docker container run --rm --entrypoint "" busybox:1.29 whoami 1 docker container run --rm --entrypoint "" busybox:1.29 id 2

  • 1 输出:root

  • 2 输出:uid=0(root) gid=0(root) groups=10(wheel)

这展示了两个你可能使用的命令来确定镜像的默认用户(在本例中为busybox:1.29)。whoamiid命令在 Linux 发行版中很常见,因此它们很可能在任何给定的镜像中都可用。第二个命令更优越,因为它显示了运行用户的名字和 ID 详情。这两个命令都小心地取消了容器的入口点。这将确保在镜像名称之后指定的命令是容器执行的命令。这些虽然不能替代一流的形象元数据工具,但它们能完成任务。考虑两个 root 用户之间的简短交流,见图 6.4。

图 6.4. 根用户对根用户——一场安全剧

如果你创建容器时更改运行用户,你可以完全避免默认用户问题。使用这个方法的怪癖是,用户名必须存在于你使用的镜像上。不同的 Linux 发行版带有预定义的不同用户,有些镜像作者会减少或增加这个集合。你可以使用以下命令来获取镜像中可用的用户列表:

docker container run --rm busybox:1.29 awk -F: '$0=$1' /etc/passwd

在这里我们不会深入细节,但 Linux 用户数据库存储在位于 /etc/passwd 的文件中。此命令将从容器文件系统中读取该文件并提取用户名列表。一旦你确定了想要使用的用户,你可以创建一个新的容器并指定运行用户。Docker 在 docker container rundocker container create 命令中提供了 --user-u 标志来设置用户。这将用户设置为 nobody

docker container run --rm \ --user nobody 1 busybox:1.29 id 2

  • 1 设置运行用户为 nobody

  • 2 输出:uid=65534(nobody) gid=65534(nogroup)

此命令使用了 nobody 用户。该用户是常见的,并旨在用于受限权限场景,例如运行应用程序。这只是其中一个例子。你可以使用在此图像中定义的任何用户名,包括 root。这仅仅触及了 -u--user 标志所能做到的一小部分。该值可以接受任何用户或组对。当你通过名称指定用户时,该名称将被解析为容器 passwd 文件中指定的用户 ID (UID)。然后命令将使用该 UID 运行。这导致另一个特性。--user 标志也接受用户和组名称或 ID。当你使用 ID 而不是名称时,选项开始增多:

docker container run --rm \ -u nobody:nogroup 1 busybox:1.29 id 2 docker container run --rm \ -u 10000:20000 3 busybox:1.29 id 4

  • 1 设置运行用户为 nobody 和组为 nogroup

  • 2 输出:uid=65534(nobody) gid=65534(nogroup)

  • 3 设置 UID 和 GID

  • 4 输出:uid=10000 gid=20000

第二个命令启动了一个新的容器,将运行用户和组设置为容器中不存在的用户和组。当这种情况发生时,ID 不会解析为用户或组名称,但所有文件权限都将像用户和组确实存在一样工作。根据容器中打包的软件的配置方式,更改运行用户可能会引起问题。否则,这是一个强大的功能,可以简化以有限权限运行应用程序并解决文件权限问题。

在你的运行时配置中建立信心最好的方式是从受信任的来源拉取镜像或者构建你自己的镜像。与任何标准的 Linux 发行版一样,有可能进行恶意操作,例如通过使用启用了suid的程序将默认的非 root 用户转变为 root 用户,或者在不进行身份验证的情况下开放对 root 账户的访问。通过使用在第 6.6 节中描述的自定义容器安全选项可以减轻suid示例中的威胁,特别是--security-opt no-new-privileges选项。然而,这已经是交付过程中的后期来解决这个问题。就像完整的 Linux 主机一样,镜像应该使用最小权限原则进行分析和加固。幸运的是,Docker 镜像可以被专门构建来支持需要运行的应用程序,同时将其他所有内容排除在外。第七章、第八章和第十章介绍了如何创建最小化应用程序镜像。

6.3.2. 用户和卷

现在你已经了解了容器内的用户与主机系统上的用户共享相同的用户 ID 空间,你需要了解这两者之间可能如何交互。这种交互的主要原因是在卷中的文件权限。例如,如果你正在运行 Linux 终端,你应该能够直接使用这些命令;否则,你需要使用docker-machine ssh命令在你的 Docker Machine 虚拟机中获取 shell:

echo "e=mc²" > garbage 1 chmod 600 garbage 2 sudo chown root garbage 3 docker container run --rm -v "$(pwd)"/garbage:/test/garbage \ -u nobody \ ubuntu:16.04 cat /test/garbage 4 docker container run --rm -v "$(pwd)"/garbage:/test/garbage \ -u root ubuntu:16.04 cat /test/garbage 5 # 输出: "e=mc²" # 清理垃圾 sudo rm -f garbage

  • 1 在你的主机上创建新文件

  • 2 使文件只对文件所有者可读

  • 3 使文件属于 root(假设你有 sudo 访问权限)

  • 4 尝试以 nobody 读取文件

  • 5 尝试以“容器 root”读取文件

第二个最后的docker命令应该失败并显示类似Permission denied的错误消息。但是最后一个docker命令应该成功并显示你第一个命令中创建的文件的内容。这意味着在卷中的文件权限在容器内部是被尊重的。但这同时也反映出用户 ID 空间是共享的。主机上的 root 和容器中的 root 都有用户 ID 0。因此,尽管容器中的nobody用户(ID 为 65534)无法访问主机上 root 拥有的文件,但容器中的 root 用户可以。

如果你不希望文件对容器可访问,不要将该文件通过卷挂载到该容器中。

关于这个例子的好消息是,你已经看到了文件权限是如何被尊重的,并且可以解决一些更平凡但实用的操作问题。例如,你如何处理写入卷的日志文件?

最理想的方式是使用卷,如第四章所述。但即使如此,你也需要考虑文件所有权和权限问题。如果日志文件是由以用户 1001 身份运行的进程写入卷的,而另一个容器试图以用户 1002 的身份访问该文件,那么文件权限可能会阻止操作。

克服这个障碍的一种方法就是专门管理运行用户的用户 ID。你可以通过在运行容器之前编辑镜像来设置你将要运行的用户的用户 ID,或者你可以使用所需的用户和组 ID(GID):

mkdir logFiles sudo chown 2000:2000 logFiles 1 docker container run --rm -v "$(pwd)"/logFiles:/logFiles \ 2 -u 2000:2000 ubuntu:16.04 \ 3 /bin/bash -c "echo This is important info > /logFiles/important.log" docker container run --rm -v "$(pwd)"/logFiles:/logFiles \ 4 -u 2000:2000 ubuntu:16.04 \ 5 /bin/bash -c "echo More info >> /logFiles/important.log" sudo rm –r logFiles

  • 将目录的所有权设置为所需的用户和组

  • 2 写入重要日志文件

  • 3 将 UID:GID 设置为 2000:2000

  • 4 从另一个容器追加日志

  • 5 还将 UID:GID 设置为 2000:2000

运行这个例子后,你会看到文件可以写入由用户 2000 拥有的目录。不仅如此,任何使用具有对该目录写入访问权限的用户或组的容器都可以在该目录中写入文件,或者如果权限允许,可以写入相同的文件。这个技巧适用于读取、写入和执行文件。

有一个 UID 和文件系统交互需要特别提及。默认情况下,Docker 守护进程 API 可以通过位于主机上的/ var/run/docker.sock 的 UNIX 域套接字访问。域套接字通过文件系统权限受到保护,确保只有 root 用户和docker组的成员可以发送命令或从 Docker 守护进程检索数据。

Docker API 的力量

docker 命令行程序几乎完全通过 API 与 Docker 守护进程交互,这应该让你感受到 API 的强大。任何可以读取和写入 Docker API 的程序都可以做docker能做的任何事情,前提是受 Docker 授权插件系统的限制。

管理或监控容器的程序通常需要能够读取甚至写入 Docker 守护进程的端点。读取或写入 Docker API 的能力通常是通过以具有读取或写入docker.sock权限的用户或组运行管理程序,并将/var/run/docker.sock挂载到容器中来提供的:

docker container run --rm -it –v /var/run/docker.sock:/var/run/docker.sock:ro 1 -u root  monitoringtool 2

  • 1 将主机上的 docker.sock 绑定到容器中作为只读文件

  • 2 容器以 root 用户运行,与宿主机的文件权限相匹配

上述示例说明了特权程序作者相对常见的请求。你应该小心对待系统中哪些用户或程序可以控制你的 Docker 守护进程。如果用户或程序控制了你的 Docker 守护进程,那么它实际上控制了宿主机的 root 账户,可以运行任何程序或删除任何文件。

6.3.3. Linux 用户命名空间和 UID 重映射简介

Linux 的 USR 用户命名空间将一个命名空间中的用户映射到另一个命名空间中的用户。用户命名空间的操作类似于进程标识符(PID)命名空间,容器 UID 和 GID 与主机的默认标识分离。

默认情况下,Docker 容器不使用 USR 命名空间。这意味着运行与主机机器上用户 ID(数字,而非名称)相同的用户 ID 的容器具有与该用户相同的宿主文件权限。这并不是一个问题。容器内可用的文件系统已经被挂载,以便在容器内所做的更改将保留在容器文件系统中。但这确实会影响在容器之间或与主机共享文件的卷。

当为容器启用用户命名空间时,容器的 UID 会被映射到主机上的一组非特权 UID。操作员通过在 Linux 中为主机定义subuidsubgid映射以及配置 Docker 守护进程的userns-remap选项来激活用户命名空间重映射。映射确定主机上的用户 ID 如何对应于容器命名空间中的用户 ID。例如,UID 重映射可以配置为将容器 UID 映射到从主机 UID 5000 开始的 1000 个 UID 范围内。结果是,容器中的 UID 0 会被映射到主机 UID 5000,容器 UID 1 映射到主机 UID 5001,以此类推,直到 1000 个 UID。由于从 Linux 的角度来看,UID 5000 是一个非特权用户,没有权限修改宿主系统文件,因此在容器中以uid=0运行的风险大大降低。即使容器化的进程从主机获取了文件或其他资源,该进程也将以重映射的 UID 运行,没有权限对该资源进行任何操作,除非操作员明确授予它这样做。

用户命名空间重映射对于解决诸如在卷中读写等情况下文件权限问题特别有用。让我们通过一个示例来了解在用户命名空间启用的情况下,容器之间共享文件系统的情况。在我们的示例中,我们将假设 Docker 使用以下内容:

  • (默认的)dockremap用户用于重映射容器 UID 和 GID 范围

  • /etc/subuid中的dockremap:5000:10000条目,提供从 5000 开始的 10,000 个 UID 范围

  • /etc/subgid中的dockremap:5000:10000条目,提供从 5000 开始的 10,000 个 GID 范围

首先,让我们检查主机上dockeremap用户的用户 ID 和组 ID。然后,我们将创建一个由重映射容器 UID 0、主机 UID 5000 拥有的共享目录。

# id dockremap 1 uid=997(dockremap) gid=993(dockremap) groups=993(dockremap) # cat /etc/subuid dockremap:5000:10000 # cat /etc/subgid dockremap:5000:10000 # mkdir /tmp/shared # chown -R 5000:5000 /tmp/shared 2

  • 1 检查主机上 dockremap 用户的用户 ID 和组 ID

  • 2 将“shared”目录的所有权更改为用于重映射容器 UID 0 的 UID

现在以容器的 root 用户运行容器:

# docker run -it --rm --user root -v /tmp/shared:/shared -v /:/host alpine ash / # touch /host/afile 1 touch: /host/afile: Permission denied / # echo "hello from $(id) in $(hostname)" >> /shared/afile / # exit # 回到主机 shell # ls -la /tmp/shared/afile -rw-r--r--. 1 5000 5000 157 Apr 16 00:13 /tmp/shared/afile # cat /tmp/shared/afile 2 hello from uid=0(root) gid=0(root) groups=0(root),1(bin),2(daemon), 3 3(sys),4(adm),6(disk),10(wheel),11(floppy),20(dialout),26(tape), 3 27(video) in d3b497ac0d34 3

  • 1 主机挂载点由主机的 UID 和 GID 拥有:0:0,因此不允许写入

  • 2 /tmp/shared由主机的非特权 UID 和 GID 拥有:5000:5000,因此允许写入

  • 3 容器中 root 的 UID 为 0

此示例演示了在使用用户命名空间时对文件系统访问的影响。用户命名空间对于以特权用户运行或在不同容器之间共享数据的应用程序加强安全性非常有用。可以在创建或运行容器时根据容器禁用用户命名空间重映射,使其更容易成为默认执行模式。请注意,用户命名空间与某些可选功能(如 SELinux 或使用特权容器)不兼容。有关设计和实现利用用户命名空间重映射的 Docker 配置的详细信息,请参阅 Docker 网站上的安全文档。

6.4. 使用能力调整操作系统功能访问

Docker 可以调整容器的授权以使用单个操作系统功能。在 Linux 中,这些功能授权被称为能力,但随着原生支持扩展到其他操作系统,将需要提供其他后端实现。每当进程尝试执行受限制的系统调用(如打开网络套接字)时,都会检查该进程的能力以确定所需的能力。如果进程具有所需的能力,则调用将成功,否则将失败。

当你创建一个新的容器时,Docker 会移除除了运行大多数应用程序所必需和安全的显式能力列表之外的所有能力。这进一步隔离了运行进程与操作系统的管理功能。以下是一个移除的 37 个能力的示例,你可能能够猜测到它们被移除的原因:

  • SYS_MODULE— 插入/移除内核模块

  • SYS_RAWIO— 修改内核内存

  • SYS_NICE— 修改进程的优先级

  • SYS_RESOURCE— 覆盖资源限制

  • SYS_TIME— 修改系统时钟

  • AUDIT_CONTROL— 配置审计子系统

  • MAC_ADMIN— 配置 MAC 配置

  • SYSLOG— 修改内核打印行为

  • NET_ADMIN— 配置网络

  • SYS_ADMIN— 管理功能的总称

Docker 容器默认提供的功能集提供了合理的功能缩减,但在某些时候你可能需要进一步添加或减少这个集合。例如,能力 NET_RAW 可能很危险。如果你想比默认配置更加小心,你可以从能力列表中移除 NET_RAW。你可以通过在docker container createdocker container run中使用--cap-drop标志来从容器中移除能力。首先,打印出在你的机器上运行的容器化进程的默认能力,并注意net_raw在能力列表中:

docker container run --rm -u nobody \ ubuntu:16.04 \ /bin/bash -c "capsh --print | grep net_raw"

现在,在启动容器时移除net_raw能力。Grep 无法找到字符串net_raw,因为能力已经被移除,所以没有可见的输出:

docker container run --rm -u nobody \ --cap-drop net_raw \ 1 ubuntu:16.04 \ /bin/bash -c "capsh --print | grep net_raw"

  • 1 移除 NET_RAW 能力

在 Linux 文档中,你经常会看到以全部大写字母命名并带有 CAP_ 前缀的能力,但如果你将其提供给能力管理选项,这个前缀将不起作用。为了获得最佳结果,请使用不带前缀的小写名称。

--cap-drop标志类似,--cap-add标志可以添加能力。如果你需要出于某种原因添加 SYS_ADMIN 能力,你可以使用以下命令:

docker container run --rm -u nobody \ ubuntu:16.04 \ /bin/bash -c "capsh --print | grep sys_admin" 1 docker container run --rm -u nobody \ --cap-add sys_admin \ 2 ubuntu:16.04 \ /bin/bash -c "capsh --print | grep sys_admin"

  • 1 SYS_ADMIN 不包括在内。

  • 2 添加 SYS_ADMIN

与其他容器创建选项一样,--cap-add--cap-drop 都可以多次指定,分别用于添加或删除多个能力。这些标志可以用来构建容器,使进程能够执行恰好且仅是正确操作所必需的操作。例如,您可能能够以 nobody 用户身份运行网络管理守护进程,并给它 NET_ADMIN 能力,而不是直接在主机上以 root 用户身份或作为特权容器运行。如果您想知道容器中是否添加或删除了任何能力,您可以检查容器并打印输出中的 .HostConfig.CapAdd.HostConfig.CapDrop 成员。

6.5. 以完全权限运行容器

当您需要在容器内运行系统管理任务时,您可以授予该容器对您的计算机的特权访问权限。特权容器保持其文件系统和网络隔离,但可以完全访问共享内存和设备,并拥有完整的系统能力。您可以使用特权容器执行多个有趣的任务,包括在容器内运行 Docker。

特权容器的大部分用途是管理性的。例如,在一个根文件系统为只读的环境、在容器外安装软件被禁止或您无法直接访问主机上的 shell 的情况下。如果您想运行一个程序来调整操作系统(例如负载均衡),并且您有权在该主机上运行容器,那么您只需在特权容器中运行该程序即可。

如果您发现只有通过特权容器的减少隔离才能解决的问题,请在 docker container createdocker container run 中使用 --privileged 标志来启用此模式:

docker container run --rm --privileged ubuntu:16.04 id 1 docker container run --rm --privileged ubuntu:16.04 capsh --print 2 docker container run --rm --privileged ubuntu:16.04 ls /dev 3 docker container run --rm --privileged ubuntu:16.04 networkctl 4

  • 1 检查 ID

  • 2 检查 Linux 能力

  • 3 检查挂载设备列表

  • 4 检查网络配置

特权容器仍然部分隔离。例如,网络命名空间仍然有效。如果您需要拆除该命名空间,您需要将其与 --net host 结合使用。

6.6. 使用增强工具加强容器

Docker 使用合理的默认值和“包含电池”的工具集来简化采用并促进最佳实践。大多数现代 Linux 内核都启用了 seccomp,Docker 的默认 seccomp 配置文件阻止了超过 40 个大多数程序不需要的内核系统调用(syscalls)。如果您带来额外的工具,可以增强 Docker 构建的容器。您可以使用自定义 seccomp 配置文件、AppArmor 和 SELinux 等工具来加固容器。

关于这些工具,已经写下了整本书。它们带来了自己的细微差别、优势和所需技能集。它们的使用可能比努力更有价值。对每个工具的支持因 Linux 发行版而异,所以你可能需要做一点工作。但一旦你调整了主机配置,Docker 的集成就会变得简单。

安全研究

信息安全领域复杂且不断演变。在阅读 InfoSec 专业人员之间的公开对话时,很容易感到不知所措。这些人通常技能高超,记忆力强,与开发人员或普通用户有着非常不同的背景。如果你能从公开的 InfoSec 对话中汲取任何东西,那就是平衡系统安全与用户需求是复杂的。

如果你刚开始接触这个领域,最好的做法是在参与对话之前先从文章、论文、博客和书籍开始。这将给你一个机会消化一个观点,并在转向不同观点之前获得更深入的见解。当你有机会形成自己的见解和观点时,这些对话就会变得更有价值。

阅读一篇论文或学习一项内容,并了解构建加固解决方案的最佳方式是很困难的。无论你的情况如何,系统都会通过包含来自多个来源的改进而不断发展。你能做的最好的事情就是单独学习每个工具。不要因为某些工具需要深入理解而感到害怕。这种努力是值得的,而且你会对你使用的系统有更深入的了解。

Docker 并非完美的解决方案。有些人甚至认为它不是一种安全工具。但它提供的改进远比放弃任何隔离以节省成本的替代方案要好。如果你已经读到这儿,也许你愿意进一步探讨这些辅助主题。

6.6.1. 指定额外的安全选项

Docker 提供了一个单独的 --security-opt 标志来指定配置 Linux 的 seccomp 和 Linux 安全模块(LSM)功能的选项。安全选项可以提供给 docker container rundocker container create 命令。此标志可以设置多次以传递多个值。

Seccomp 配置进程可以调用的 Linux 系统调用。Docker 的默认 seccomp 配置默认阻止所有系统调用,然后明确允许超过 260 个系统调用作为大多数程序的安全使用。被阻止的 44 个系统调用是不必要的,或者对于正常程序来说是不安全的(例如,unshare,用于创建新的命名空间)或者不能进行命名空间化(例如,clock_settime,用于设置机器的时间)。不建议更改 Docker 的默认 seccomp 配置。如果默认的 seccomp 配置过于严格或过于宽松,可以指定一个自定义配置作为安全选项:

docker container run --rm -it \ --security-opt seccomp=<FULL_PATH_TO_PROFILE> \ ubuntu:16.04 sh

<FULL_PATH_TO_PROFILE> 是定义容器允许的系统调用的 seccomp 配置文件的完整路径。GitHub 上的 Moby 项目包含 Docker 的默认 seccomp 配置文件,位于 profiles/seccomp/default.json,可以作为自定义配置文件的起点。使用特殊值 unconfined 来禁用容器对 seccomp 的使用。

Linux 安全模块(Linux Security Modules)是 Linux 采纳的一个框架,作为操作系统和安全提供者之间的接口层。AppArmor 和 SELinux 是 LSM 提供者。两者都提供强制访问控制,或 MAC(系统定义访问规则),并替换了标准的 Linux 自主访问控制(文件所有者定义访问规则)。

LSM 安全选项值指定在以下七种格式之一中:

  • 要防止容器在启动后获得新的权限,使用 no-new-privileges

  • 要设置 SELinux 用户标签,使用形式 label=user:<USERNAME>,其中 <USERNAME> 是你想要用于标签的用户名。

  • 要设置 SELinux 角色标签,使用形式 label=role:<ROLE>,其中 <ROLE> 是你想要应用于容器中进程的角色名称。

  • 要设置 SELinux 类型标签,使用形式 label=type:<TYPE>,其中 <TYPE> 是容器中进程的类型名称。

  • 要设置 SELinux 级别的标签,使用形式 label:level:<LEVEL>,其中 <LEVEL> 是容器中进程应运行的级别。级别指定为低-高对。如果仅缩写为低级别,SELinux 将解释范围为单个级别。

  • 要禁用容器的 SELinux 标签约束,使用形式 label= disable

  • 要在容器上应用 AppArmor 配置文件,使用形式 apparmor= <PROFILE>,其中 <PROFILE> 是要使用的 AppArmor 配置文件名称。

如您从这些选项中可以猜到的,SELinux 是一个标签系统。一组标签,称为上下文,应用于每个文件和系统对象。类似的一组标签应用于每个用户和进程。在运行时,当进程尝试与文件或系统资源交互时,标签集将根据一组允许的规则进行评估。该评估的结果将确定交互是否被允许或阻止。

最后一个选项将设置 AppArmor 配置文件。AppArmor 经常被 SELinux 替代,因为它与文件路径而不是标签一起工作,并且有一个训练模式,可以用来根据观察到的应用程序行为被动地构建配置文件。这些差异通常被引用为 AppArmor 更容易采用和维护的原因。

有免费和商业工具可以监控程序的执行并生成针对应用程序定制的配置文件。这些工具帮助操作员使用测试和生产环境中实际程序行为的信息来创建一个有效的配置文件。

6.7. 构建适用于用例的容器

容器是一个跨领域的关注点。人们使用它们的原因和方式比我们能列举的要多。所以,当你使用 Docker 构建容器来满足自己的需求时,花时间以适合你运行软件的方式去做是很重要的。

实现这一点的最安全策略是从你可以构建的最隔离的容器开始,并合理地解释为什么需要放宽这些限制。现实中,人们往往比主动更倾向于反应。因此,我们认为 Docker 在默认容器构建方面找到了一个甜蜜点。它提供了合理的默认设置,同时又不阻碍用户的效率。

Docker 容器默认情况下并不是最隔离的。Docker 并不要求你增强那些默认设置。如果你愿意,它会让你在生产环境中做一些愚蠢的事情。这使得 Docker 看起来更像是一个工具,而不是负担,人们更愿意使用而不是感觉不得不使用。对于那些不愿意在生产环境中做愚蠢事情的人来说,Docker 提供了一个简单的接口来增强容器隔离。

6.7.1 应用程序

应用程序是我们使用计算机的全部原因。大多数应用程序是其他人编写的程序,它们可能处理恶意数据。考虑一下你的网络浏览器。

网络浏览器是一种几乎安装在每台计算机上的应用程序。它与网页、图片、脚本、嵌入式视频、Flash 文档、Java 应用程序以及任何其他内容交互。你当然没有创建所有这些内容,大多数人也不是网络浏览器项目的贡献者。你怎么能信任你的网络浏览器正确处理所有这些内容呢?

一些更加轻率的读者可能会忽略这个问题。毕竟,最糟糕的事情会是什么?好吧,如果攻击者控制了你的网络浏览器(或其他应用程序),他们将获得该应用程序的所有功能和运行该应用程序的用户权限。他们可能会破坏你的计算机,删除你的文件,安装其他恶意软件,甚至从你的计算机上对其他计算机发起攻击。所以,这不是可以忽视的好事情。问题仍然是:当你需要承担这种风险时,你该如何保护自己?

最好的做法是隔离程序运行的风险。首先,确保应用程序以有限权限的用户身份运行。这样,如果出现问题,它将无法更改你计算机上的文件。其次,限制浏览器的系统功能。这样做可以确保你的系统配置更安全。第三,限制应用程序可以使用的 CPU 和内存量。限制有助于保留资源以保持系统响应。最后,具体列出它可以访问的设备也是一个好主意。这将防止窥探者访问你的网络摄像头、USB 等设备。

6.7.2 高级系统服务

高级系统服务与应用程序略有不同。它们不是操作系统的一部分,但您的计算机确保它们被启动并保持运行。这些工具通常位于操作系统之外的应用程序旁边,但它们通常需要特权访问权限才能正确运行。它们为系统上的用户和其他软件提供重要的功能。例如包括cronsyslogddnsmasqsshddocker

如果您不熟悉这些工具(希望不是全部),没关系。它们执行的任务包括保持系统日志、运行计划命令,以及提供从网络获取系统安全外壳的方法,而docker管理容器。

虽然以 root 身份运行服务很常见,但其中很少需要完整的特权访问。考虑将服务容器化,并使用能力调整它们的访问权限,以满足它们需要的特定功能。

6.7.3. 低级系统服务

低级服务控制诸如设备或系统网络堆栈之类的功能。它们需要对其提供的系统组件具有特权访问权限(例如,防火墙软件需要网络堆栈的行政访问权限)。

在容器内运行这些任务很少见。例如,文件系统管理、设备管理和网络管理都是主机核心关注点。大多数在容器中运行的软件都期望是可移植的。因此,像这些特定于机器的任务并不适合一般的容器使用场景。

最好的例外是短运行配置容器。例如,在一个所有部署都使用 Docker 镜像和容器进行的环境中,您可能希望以推送软件的方式推送网络堆栈更改。在这种情况下,您可能将带有配置的镜像推送到主机,并使用特权容器进行更改。在这种情况下,风险降低,因为您编写了要推送的配置,容器不是长时间运行的,并且像这样的更改很容易审计。

摘要

本章介绍了 Linux 提供的隔离功能,并讨论了 Docker 如何使用这些功能来构建可配置的容器。有了这些知识,您将能够自定义容器隔离并使用 Docker 处理任何使用场景。本章涵盖了以下要点:

  • Docker 使用 cgroups,允许用户设置内存限制、CPU 权重、限制和核心限制,以及限制对特定设备的访问。

  • Docker 容器各自都有自己的 IPC 命名空间,可以与其他容器或主机共享,以便通过共享内存促进通信。

  • Docker 支持隔离 USR 命名空间。默认情况下,容器内的用户和组 ID 与主机机器上的相同 ID 等效。当启用用户命名空间时,容器内的用户和组 ID 被重新映射到主机上不存在的 ID。

  • 你可以使用并应该使用 docker container rundocker container create 中的 -u 选项来以非 root 用户运行容器。

  • 在可能的情况下,尽量避免以特权模式运行容器。

  • Linux 能力提供了操作系统功能授权。Docker 会取消某些能力,以提供合理的隔离默认设置。

  • 可以使用 --cap-add--cap-drop 标志来设置任何容器所授予的能力。

  • Docker 提供了与增强隔离技术(如 seccomp、SELinux 和 AppArmor)轻松集成的工具。这些是安全意识强的 Docker 用户应该调查的强大工具。

第二部分. 打包软件以进行分发

不可避免地,Docker 用户将需要创建一个镜像。有时您需要的软件没有打包在镜像中。有时您可能需要一个在可用镜像中尚未启用的功能。本部分的四章将帮助您了解如何创建、定制和专门化您打算使用 Docker 部署或共享的镜像。

第七章. 在镜像中打包软件

本章涵盖

  • 手动镜像构建和实践

  • 从打包的角度看镜像

  • 与平面镜像一起工作

  • 镜像版本控制最佳实践

本章的目标是帮助您了解镜像设计的相关问题,学习构建镜像的工具,并发现高级镜像模式。您将通过一个详尽的现实世界示例来完成这些任务。在开始之前,您应该对本书中第一部分(index_split_023.html#filepos151791)中的概念有牢固的掌握。

您可以通过修改容器内现有的镜像或定义并执行一个名为 Dockerfile 的构建脚本来创建一个 Docker 镜像。本章重点介绍手动更改镜像的过程、镜像操作的基本原理以及产生的工件。Dockerfile 和构建自动化将在第八章(index_split_069.html#filepos755104)中介绍。

7.1. 从容器构建 Docker 镜像

如果您已经熟悉使用容器,那么开始构建镜像会很容易。记住,联合文件系统(UFS)挂载提供了一个容器的文件系统。您对容器内文件系统所做的任何更改都将作为新层写入,这些层由创建它们的容器拥有。

在您开始处理真实软件之前,下一节将详细说明使用“Hello, World”示例的典型工作流程。

7.1.1. 打包“Hello, World”

从容器构建镜像的基本工作流程包括三个步骤。首先,您从现有镜像创建一个容器。您根据希望包含在新完成的镜像中的内容以及您需要用于更改的工具来选择镜像。

第二步是修改容器的文件系统。这些更改将被写入容器联合文件系统的新层。我们将在本章后面重新探讨镜像、层和仓库之间的关系。

一旦进行了更改,最后一步就是提交这些更改。然后您将能够从生成的镜像创建新的容器。图 7.1 展示了这个工作流程。

图 7.1. 从容器构建镜像

图片

在考虑这些步骤的基础上,执行以下命令以创建一个名为hw_image的新镜像:

docker container run --name hw_container \ ubuntu:latest \ touch /HelloWorld 1 docker container commit hw_container hw_image 2 docker container rm -vf hw_container 3 docker container run --rm \ hw_image \ ls -l /HelloWorld 4

  • 1 修改容器中的文件

  • 2 将更改提交到新镜像

  • 3 删除已更改的容器

  • 4 检查新容器中的文件

如果这看起来非常简单,你应该知道,随着你生成的镜像变得更加复杂,这个过程确实会变得稍微复杂一些,但基本步骤始终是相同的。现在你已经对工作流程有了概念,你应该尝试使用真实软件构建一个新的镜像。在这种情况下,你将打包一个名为 Git 的程序。

7.1.2. 准备 Git 打包

Git 是一个流行的分布式版本控制工具。关于这个主题已经写了很多本书。如果你不熟悉它,我们建议你花些时间学习如何使用 Git。不过,目前你只需要知道它是一个你将要安装到 Ubuntu 镜像中的程序。

要开始构建你自己的镜像,你需要首先从一个合适的基镜像创建一个容器:

docker container run -it --name image-dev ubuntu:latest /bin/bash

这将启动一个新的容器,运行 bash shell。从这个提示符开始,你可以发出命令来自定义你的容器。Ubuntu 随带了一个名为 apt-get 的 Linux 工具,用于软件安装。这对于获取你想要打包到 Docker 镜像中的软件非常有用。现在你应该有一个交互式 shell 在你的容器中运行。接下来,你需要在这个容器中安装 Git。通过运行以下命令来完成:

apt-get update apt-get -y install git

这将告诉 APT 在容器的文件系统中下载并安装 Git 及其所有依赖项。完成后,你可以通过运行 git 程序来测试安装:

git version # 输出类似:# git version 2.7.4

包含 apt-get 这样的包管理工具使得安装和卸载软件比手动操作要容易得多。但它们并没有为该软件提供隔离,并且依赖冲突经常发生。你可以确信,在这个容器外部安装的其他软件不会影响你在容器内安装的 Git 版本。

现在 Git 已经安装在你的 Ubuntu 容器中,你可以简单地退出容器:

exit

容器应该已经停止,但仍然存在于你的电脑上。Git 已经在 ubuntu:latest 镜像的新层上安装。如果你现在离开这个例子,几天后再回来,你将如何知道确切发生了哪些更改?在打包软件时,审查容器中修改的文件列表通常非常有用,而 Docker 有一个用于此的命令。

7.1.3. 审查文件系统更改

Docker 有一个命令可以显示容器内部所做的所有文件系统更改。这些更改包括添加、更改或删除的文件和目录。要审查你使用 APT 安装 Git 时所做的更改,请运行 diff 子命令:

docker container diff image-dev 1

  • 1 输出文件更改的长列表

A 开头的行表示添加了文件。以 C 开头的表示文件已更改。最后,以 D 开头的表示文件被删除。以这种方式使用 APT 安装 Git 导致了多项更改。因此,最好通过一些具体的例子来观察其工作情况:

docker container run --name tweak-a busybox:latest touch /HelloWorld 1 docker container diff tweak-a # Output: #    A /HelloWorld 2 docker container run --name tweak-d busybox:latest rm /bin/vi 3 docker container diff tweak-d # Output: #    C /bin #    D /bin/vi 4 docker container run --name tweak-c busybox:latest touch /bin/vi 5 docker container diff tweak-c # Output: #    C /bin #    C /bin/busybox

  • 1 向 busybox 添加新文件

  • 2 从 busybox 中删除现有文件

  • 3 在 busybox 中现有文件更改

总是记得清理您的 workspace,如下所示:

docker container rm -vf tweak-a docker container rm -vf tweak-d docker container rm -vf tweak-c

现在,您已经看到了对文件系统所做的更改,您准备将这些更改提交到一个新镜像中。与其他大多数事情一样,这涉及一个执行多项操作的单一命令。

7.1.4. 提交新镜像

您可以使用 docker container commit 命令从一个修改过的容器创建一个镜像。使用 -a 标志为镜像签名一个作者字符串是一个最佳实践。您还应该始终使用 -m 标志,它设置了一个提交信息。从您安装 Git 的 image-dev 容器创建并签名一个名为 ubuntu-git 的新镜像:

docker container commit -a "@dockerinaction" -m "Added git" \ image-dev ubuntu-git # 输出新的唯一镜像标识符,例如:# bbf1d5d430cdf541a72ad74dfa54f6faec41d2c1e4200778e9d4302035e5d143

一旦您提交了镜像,它应该出现在您计算机上安装的镜像列表中。运行 docker images 应该包括如下一行:

REPOSITORY    TAG      IMAGE ID      CREATED        VIRTUAL SIZE ubuntu-git    latest   bbf1d5d430cd  5 seconds ago  248 MB

确保它正常工作,通过从该镜像创建的容器测试 Git:

docker container run --rm ubuntu-git git version

现在,您已经基于 Ubuntu 镜像创建了一个新镜像并安装了 Git。这是一个很好的开始,但您认为如果您省略了命令覆盖会发生什么?试一试来找出答案:

docker container run --rm ubuntu-git

当您运行该命令时,似乎没有任何反应。这是因为您启动原始容器时使用的命令已经与新镜像一起提交。您用于启动创建该镜像的容器的命令是 /bin/bash。当您使用默认命令从这个镜像创建容器时,它将启动一个 shell 并立即退出。这不是一个非常有用的默认命令。

我怀疑任何名为ubuntu-git的镜像的用户都不会期望每次都需要手动调用 Git。最好在镜像上设置入口点为git。入口点是容器启动时将执行的程序。如果没有设置入口点,将直接执行默认命令。如果设置了入口点,默认命令及其参数将作为参数传递给入口点。

要设置入口点,你需要创建一个新的容器,并设置--entrypoint标志,然后从该容器创建一个新的镜像:

docker container run --name cmd-git --entrypoint git ubuntu-git 1 docker container commit -m "Set CMD git" \ -a "@dockerinaction" cmd-git ubuntu-git 2 docker container rm -vf cmd-git 3 docker container run --name cmd-git ubuntu-git version 4

  • 1 显示标准 git 帮助并退出

  • 2 将新镜像提交到相同名称

  • 3 清理

  • 4 测试

现在入口点已设置为git,用户不再需要输入命令。在这个例子中,这可能看起来节省不大,但许多人使用的工具并不那么简洁。设置入口点只是你可以做的使镜像更容易被用户使用并集成到他们的项目中的一件事。

7.1.5. 配置镜像属性

当你使用docker container commit时,你将一个新的层提交到镜像中。文件系统快照并不是这个提交中唯一包含的东西。每一层还包括描述执行上下文的元数据。在创建容器时可以设置的参数中,以下所有参数都将随从容器创建的镜像一起传递:

  • 所有环境变量

  • 工作目录

  • 暴露的端口集合

  • 所有卷定义

  • 容器入口点

  • 命令和参数

如果这些值没有为容器特别设置,它们将继承自原始镜像。第一部分的这本书涵盖了这些内容,所以我们在这里不会重新介绍它们。但是,检查两个详细示例可能很有价值。首先,考虑一个引入两个环境变量特殊化的容器:

docker container run --name rich-image-example \ -e ENV_EXAMPLE1=Rich -e ENV_EXAMPLE2=Example \ 1 busybox:latest docker container commit rich-image-example rie 2 docker container run --rm rie \ /bin/sh -c "echo \$ENV_EXAMPLE1 \$ENV_EXAMPLE2" 3

  • 1 创建环境变量专业化

  • 2 提交镜像

  • 3 输出:丰富示例

接下来,考虑一个在前面示例之上引入入口点和命令特殊化的容器:

docker container run --name rich-image-example-2 \ --entrypoint "/bin/sh" \ 1 rie \ -c "echo \$ENV_EXAMPLE1 \$ENV_EXAMPLE2" 2 docker container commit rich-image-example-2 rie 3 docker container run --rm rie 4

  • 1 设置默认入口点

  • 2 设置默认命令

  • 3 提交镜像

  • 4 使用相同输出的不同命令

此示例在 BusyBox 上构建了两个额外的层。在两种情况下都没有更改文件,但由于上下文元数据已更改,行为发生了变化。这些更改包括在第一个新层中添加两个新的环境变量。这些环境变量显然被第二个新层继承,该层设置入口点和默认命令以显示它们的值。最后一个命令使用最终镜像而不指定任何替代行为,但很明显,之前定义的行为已被继承。

现在你已经了解了如何修改镜像,花些时间深入了解镜像和层的机制。这样做将帮助你在实际情况下制作高质量的镜像。

7.2. 深入了解 Docker 镜像和层

到此为止,你已经构建了一些镜像。在这些示例中,你首先从一个镜像(如 ubuntu:latestbusybox:latest)创建了一个容器。然后你在该容器内对文件系统或上下文进行了更改。最后,当你使用 docker container commit 命令创建新镜像时,一切似乎都正常工作。了解容器文件系统的工作原理以及 docker container commit 命令实际上做什么将帮助你成为一个更好的镜像作者。本节深入探讨这个主题,并展示了它对作者的影响。

7.2.1. 探索联合文件系统

对于镜像作者来说,理解联合文件系统的细节非常重要,原因有两个:

  • 作者需要了解添加、更改和删除文件对结果镜像的影响。

  • 作者需要有一个坚实的理解,了解层与层之间的关系,以及层如何与镜像、存储库和标签相关联。

首先,考虑一个简单的例子。假设你想要对现有的镜像进行单个更改。在这种情况下,镜像为 ubuntu:latest,你想要在根目录中添加一个名为 mychange 的文件。你应该使用以下命令来完成此操作:

docker container run --name mod_ubuntu ubuntu:latest touch /mychange

生成的容器(命名为 mod_ubuntu)将被停止,但会将其文件系统中的单个更改写入。如第三章和 4 章所述,根文件系统由启动容器的镜像提供。该文件系统使用联合文件系统实现。

联合文件系统由层组成。每次对联合文件系统进行更改时,该更改都会记录在所有其他层之上的新层上。所有这些层的联合,或从上到下的视图,是容器(和用户)在访问文件系统时看到的。图 7.2 说明了此例的两个视角。

图 [7.2]. 从两个视角看联合文件系统上的简单文件写入示例

图片

当您从联合文件系统读取文件时,该文件将从它存在的最顶层读取。如果一个文件在顶层没有被创建或更改,读取将穿过层直到它到达存在该文件的层。这如图图 7.3 所示。

图 7.3. 读取位于不同层的文件

图片

所有这些层功能都被联合文件系统隐藏。在容器中运行的软件无需采取特殊操作即可利用这些功能。理解文件添加的层涵盖了三种类型的文件系统写入之一。其他两种是删除和文件更改。

与添加一样,文件更改和删除都是通过修改顶层来实现的。当文件被删除时,会在顶层写入删除记录,这会隐藏该文件在底层版本。当文件被更改时,更改会被写入顶层,这同样会隐藏该文件在底层版本。容器文件系统所做的更改可以通过您在章节中使用的 docker container diff 命令列出:

docker container diff mod_ubuntu

此命令将生成以下输出:

A /mychange

在这种情况下,A 表示文件被添加。运行以下两个命令以查看文件删除是如何记录的:

docker container run --name mod_busybox_delete busybox:latest rm /etc/passwd docker container diff mod_busybox_delete

这次,输出将包含两行:

C /etc D /etc/passwd

D 表示删除,但这次还包括文件的父文件夹。C 表示它已被更改。以下两个命令演示了文件更改:

docker container run --name mod_busybox_change busybox:latest touch \ /etc/passwd docker container diff mod_busybox_change

diff 子命令将显示两个更改:

C /etc C /etc/passwd

再次强调,C 表示一个变化,这两个项目是文件及其所在文件夹。如果嵌套五层深度的文件被更改,则树中的每一层都会有一行。

文件系统属性(如文件所有权和权限)的更改与文件更改的记录方式相同。在大量文件上修改文件系统属性时要小心,因为这些文件可能会被复制到执行更改的层。理解层文件添加是了解联合文件系统最重要的内容之一,我们将在下一部分稍作深入探讨。

大多数联合文件系统使用一种称为写时复制(copy-on-write)的技术,如果你将其视为更改时复制,则更容易理解。当一个只读层(非顶层)中的文件被修改时,在做出更改之前,整个文件首先从只读层复制到可写层。这会对运行时性能和镜像大小产生负面影响。第 7.2.3 节介绍了这种影响应该如何影响你的镜像设计。

请花一点时间,通过检查图 7.4 中更全面的场景集来巩固你对系统的理解。在这个示例中,文件被添加、更改、删除,并在三个层次范围内再次添加。

图 7.4. 在三层镜像上的各种文件添加、更改和删除组合

图片

了解文件系统更改是如何记录的,你就可以开始理解当你使用docker container commit命令创建新镜像时会发生什么。

7.2.2. 重新介绍镜像、层、仓库和标签

你通过使用docker container commit命令创建了一个镜像,并且你理解它将顶层更改提交到镜像中。但我们还没有定义提交。

记住,联合文件系统由一系列层组成,新层被添加到堆栈的顶部。这些层作为该层更改的集合及其元数据单独存储。当你将容器对文件系统的更改提交时,你以可识别的方式保存了该顶层的一个副本。

当你提交一个层时,为其生成一个新的 ID,并保存所有文件更改的副本。了解这一过程的具体细节不如了解一般方法重要。层的元数据包括生成的标识符、其下层的标识符(父层)以及从该层创建的容器的执行上下文。层标识符和元数据构成了 Docker 和 UFS 用来构建镜像的图。

镜像是通过从给定的顶层开始,然后遵循每个层元数据中定义的父 ID 的所有链接得到的层堆栈。如图图 7.5 所示。

图 7.5. 镜像是通过从顶层遍历父图产生的层集合。

图片

镜像是通过遍历从起始层开始的层依赖图构建的层堆栈。遍历开始的层是堆栈的顶部。这意味着层的 ID 也是它及其依赖项形成的镜像的 ID。花点时间看看你之前创建的mod_ubuntu容器提交时的实际效果:

docker container commit mod_ubuntu

commit子命令将生成包含类似以下新镜像 ID 的输出:

6528255cda2f9774a11a6b82be46c86a66b5feff913f5bb3e09536a54b08234d

您可以使用显示给您的镜像 ID 创建一个新的容器。与容器一样,层 ID 是难以直接处理的大十六进制数字。因此,Docker 提供了仓库。

在第三章中,仓库被粗略地定义为图像的命名桶。更具体地说,仓库是位置/名称对,指向一组特定的层标识符。每个仓库至少包含一个指向特定层标识符的标签,从而定义了镜像。让我们回顾一下第三章中使用的示例:

图片

这个仓库位于 Docker Hub 注册表中,但我们使用了完全限定的注册表主机名docker.io。它以用户名(dockerinaction)和一个独特的简短名称(ch3_hello_registry)命名。如果您不指定标签而拉取此仓库,Docker 将尝试拉取标记为latest的镜像。您可以通过在拉取命令中添加--all-tags选项来拉取仓库中的所有标记镜像。在这个例子中,只有一个标签:latest。该标签指向具有短形式 ID 4203899414c0的层,如图 7.6 所示。

图 7.6. 仓库的视觉表示

图片

使用docker tagdocker container commitdocker build命令创建仓库和标签。再次查看mod_ubuntu容器,并将其与标签一起放入仓库中:

docker container commit mod_ubuntu myuser/myfirstrepo:mytag # 输出:# 82ec7d2c57952bf57ab1ffdf40d5374c4c68228e3e923633734e68a11f9a2b59

显示的 ID 将不同,因为创建了层的另一个副本。使用这个新的友好名称,从您的镜像创建容器需要很少的努力。如果您想复制一个镜像,您只需从现有的镜像创建一个新的标签或仓库。您可以使用docker tag命令做到这一点。每个仓库默认都包含一个latest标签。如果省略了标签,将使用该标签,如前一个命令所示:

docker tag myuser/myfirstrepo:mytag myuser/mod_ubuntu

到目前为止,您应该对基本的 UFS 基础知识和 Docker 如何创建和管理层、镜像和仓库有了深刻的理解。考虑到这些,让我们考虑它们可能对镜像设计产生的影响。

为容器创建的可写层下面的所有层都是不可变的,这意味着它们永远不能被修改。这个特性使得可以共享对镜像的访问,而不是为每个容器创建独立的副本。这也使得单个层非常易于重用。这个特性的另一方面是,每次你对镜像进行更改时,都需要添加一个新的层,而旧的层永远不会被删除。既然知道镜像不可避免地需要更改,就需要了解任何镜像的限制,并记住更改如何影响镜像大小。

7.2.3. 管理镜像大小和层限制

如果镜像像大多数人管理文件系统一样发展,Docker 镜像很快就会变得不可用。例如,假设你想要制作本章前面创建的 ubuntu-git 镜像的不同版本。修改那个 ubuntu-git 镜像可能看起来很自然。在你这样做之前,为你的 ubuntu-git 镜像创建一个新的标签。你将重新分配最新标签:

docker image tag ubuntu-git:latest ubuntu-git:2.7 1

  • 1 创建新标签:2.7

在构建新镜像时,你首先会移除你安装的 Git 版本:

docker container run --name image-dev2 \ --entrypoint /bin/bash \ 1 ubuntu-git:latest -c "apt-get remove -y git" 2 docker container commit image-dev2 ubuntu-git:removed 3 docker image tag ubuntu-git:removed ubuntu-git:latest 4 docker image ls 5

  • 1 执行 bash 命令

  • 2 移除 Git

  • 3 提交镜像

  • 4 重新分配最新标签

  • 5 检查镜像大小

报告的镜像列表和大小看起来可能如下所示:

REPOSITORY   TAG        IMAGE ID        CREATED           VIRTUAL SIZE ubuntu-git   latest     826c66145a59    10 seconds ago    226.6 MB ubuntu-git   removed    826c66145a59    10 seconds ago    226.6 MB ubuntu-git   2.7        3e356394c14e    41 hours ago      226 MB ...

注意,即使你移除了 Git,镜像的实际大小实际上增加了。虽然你可以使用 docker container diff 来检查具体的变化,但你应该迅速意识到增加的原因与联合文件系统有关。

记住,UFS 会通过实际上向顶层添加文件来标记文件为已删除。原始文件和存在于其他层的任何副本仍然存在于镜像中。为了使将消费你的镜像的人和系统受益,最小化镜像大小是很重要的。如果你可以通过智能创建镜像来避免造成长时间的下载时间和显著的磁盘使用,你的消费者将受益。在 Docker 的早期阶段,镜像作者有时会最小化镜像中的层数,因为镜像存储驱动程序的限制。现代 Docker 镜像存储驱动程序没有普通用户会遇到的镜像层限制,因此设计时考虑其他属性,如大小和缓存性。

你可以使用docker image history命令检查镜像中的所有层。它将显示以下内容:

  • 简化的层 ID

  • 层的年龄

  • 创建容器的初始命令

  • 那一层的总文件大小

通过检查ubuntu-git:removed镜像的历史,你可以看到已经在原始ubuntu:latest镜像的顶部添加了三个层:

docker image history ubuntu-git:removed

输出类似于以下内容:

IMAGE          CREATED          CREATED BY                    SIZE 826c66145a59   24 分钟前   /bin/bash -c apt-get remove   662 kB 3e356394c14e   42 小时前     git                           0 B bbf1d5d430cd   42 小时前     /bin/bash                     37.68 MB b39b81afc8ca   3 个月前     /bin/sh -c #(nop) CMD [/bin   0 B 615c102e2290   3 个月前     /bin/sh -c sed -i 's/^#\s*\   1.895 kB 837339b91538   3 个月前     /bin/sh -c echo '#!/bin/sh'   194.5 kB 53f858aaaf03   3 个月前     /bin/sh -c #(nop) ADD file:   188.1 MB 511136ea3c5a   22 个月前                                  0 B

你可以通过使用docker image save将镜像保存为 TAR 文件来扁平化镜像,然后使用docker image import将那个文件系统的内容导入 Docker。但这不是一个好主意,因为你将丢失原始镜像的元数据、变更历史以及当客户下载具有相同底层版本的镜像时可能获得的任何节省。在这种情况下,更明智的做法是创建一个分支。

而不是与层系统作斗争,你可以通过使用层系统创建分支来解决大小和层增长问题。层系统使得回溯镜像的历史并创建新的分支变得非常简单。每次从相同的镜像创建容器时,你都有可能创建一个新的分支。

在重新考虑你的新ubuntu-git镜像策略时,你应该简单地再次从ubuntu:latest开始。使用从ubuntu:latest的新鲜容器,你可以安装你想要的任何版本的 Git。结果是,你创建的原始ubuntu-git镜像和新的镜像将共享相同的父镜像,并且新的镜像不会携带任何无关变更的负担。

分支增加了你需要重复在同级分支中完成步骤的可能性。手动完成这项工作容易出错。使用 Dockerfile 自动化镜像构建是一个更好的主意。

有时需要从头开始构建一个完整的镜像。Docker 为scratch镜像提供了特殊的处理,这告诉构建过程将下一个命令作为结果的第一个层。如果你的目标是保持镜像小,并且你正在使用依赖项很少的技术,如 Go 或 Rust 编程语言,这种做法可能是有益的。在其他时候,你可能想将镜像扁平化以修剪镜像的历史。在两种情况下,你需要一种导入和导出完整文件系统的方法。

7.3. 导出和导入扁平文件系统

在某些情况下,通过在联合文件系统或容器上下文之外处理用于镜像的文件来构建镜像是有利的。为了满足这一需求,Docker 提供了两个命令用于导出和导入文件的归档。

docker container export命令会将扁平化联合文件系统的全部内容以 tar 包的形式输出到 stdout 或输出文件。结果是包含容器视角下所有文件的 tar 包。如果你需要在容器上下文之外使用与镜像一起提供的文件系统,这可能很有用。你可以使用docker cp命令来做到这一点,但如果需要多个文件,导出整个文件系统可能更直接。

创建一个新的容器并使用export子命令来获取其文件系统的扁平化副本:

`docker container create --name export-test \

  • 1 导出文件系统内容

  • 2 显示归档内容

这将在当前目录中生成一个名为 contents.tar 的文件。该文件应包含来自ch7_packed镜像的两个文件:message.txt 和 folder/message.txt。在此阶段,你可以提取、检查或更改这些文件以实现任何目的。归档还将包含一些与设备相关的零字节文件以及 Docker 为每个容器管理的文件,如/etc/resolv.conf。你可以忽略这些文件。如果你省略了--output(或简写为-o),那么文件系统的内容将以 tar 包格式流式传输到 stdout。将内容流式传输到 stdout 使得export命令可以与其他处理 tar 包的 shell 程序链式使用。

docker import命令会将 tar 包的内容流式传输到一个新镜像中。import命令识别几种压缩和非压缩的 tar 包形式。在文件系统导入期间还可以应用可选的 Dockerfile 指令。导入文件系统是获取完整最小文件集到镜像的简单方法。

为了了解这有多有用,考虑一个静态链接的“Hello, World.” Go 版本。创建一个空文件夹并将以下代码复制到一个名为 helloworld.go 的新文件中:

package main import "fmt" func main() {         fmt.Println("hello, world!") }

你可能没有在电脑上安装 Go,但对于 Docker 用户来说这并不是问题。通过运行下一个命令,Docker 将拉取包含 Go 编译器的镜像,编译并静态链接代码(这意味着它可以独立运行),并将该程序放回你的文件夹中:

`docker container run --rm -v "$(pwd)":/usr/src/hello \

如果一切正常,你应该在同一文件夹中有一个可执行的程序(二进制文件),命名为 hello。静态链接的程序在运行时没有外部文件依赖。这意味着这个静态链接版本的“Hello, World”可以在没有其他文件的容器中运行。下一步是将这个程序放入一个 tar 包中:

tar -cf static_hello.tar hello

现在程序已经被打包成 tar 包,你可以使用docker import命令来导入它:

docker import -c "ENTRYPOINT [\"/hello\"]" - \ dockerinaction/ch7_static < static_hello.tar 1

  • 1 通过 UNIX 管道流式传输的 tar 文件

在这个命令中,你使用-c标志来指定 Dockerfile 命令。你使用的命令设置了新镜像的入口点。Dockerfile 命令的确切语法在第八章中有介绍。这个命令中更有趣的参数是第一行末尾的连字符(-)。这个连字符表示 tar 包的内容将通过 stdin 流式传输。如果你是从远程 Web 服务器而不是本地文件系统获取文件,你可以在这个位置指定一个 URL。

你将生成的镜像标记为dockerinaction/ch7_static仓库。花点时间探索一下结果:

docker container run dockerinaction/ch7_static 1 docker history dockerinaction/ch7_static

  • 1 输出:hello, world!

你会注意到这个镜像的历史记录只有一个条目(和层):

IMAGE           CREATED         CREATED BY     SIZE edafbd4a0ac5    11 minutes ago                 1.824 MB

在这种情况下,你生成的镜像很小有两个原因。首先,你生成的程序只有 1.8 MB 多一点,你没有包含操作系统文件或支持程序。这是一个极简主义镜像。其次,只有一个层。在底层没有携带被删除或未使用的文件。使用单层(或扁平)镜像的缺点是,你的系统不会从层重用中受益。如果你的所有镜像都足够小,这可能不是问题。但如果你使用较大的堆栈或不支持静态链接的语言,开销可能会很大。

每个镜像设计决策都有权衡,包括是否使用扁平镜像。无论你使用什么机制来构建镜像,你的用户都需要一个一致且可预测的方式来识别不同的版本。

7.4. 版本控制最佳实践

实用版本控制实践有助于用户充分利用镜像。有效版本控制方案的目标是清晰沟通并提供灵活性给镜像用户。

除非是第一个版本,否则仅构建或维护单个版本的软件通常是不够的。如果你正在发布软件的第一个版本,你应该从一开始就关注用户的采用体验。版本很重要,因为它们标识了采用者所依赖的合同。意外的软件更改会给采用者带来问题,版本是表示软件更改的主要方式之一。

使用 Docker,维护同一软件的多个版本的关键在于适当的仓库标签。理解每个仓库包含多个标签,并且多个标签可以引用相同的镜像,这是实用标签方案的核心。

docker image tag命令与其他两个可以用来创建标签的命令不同。它是唯一应用于现有镜像的命令。为了了解如何使用标签以及它们如何影响用户采用体验,请考虑图 7.7 中显示的仓库的两个标签方案。

图 7.7. 同一仓库中三个镜像的两个标签方案(左侧和右侧)。虚线表示标签和镜像之间的旧关系。

图片

图 7.7 左侧的标签方案有两个问题。首先,它提供了较差的采用灵活性。用户可以选择声明对1.9latest的依赖。当用户采用 1.9 版本而实际实现是 1.9.1 时,他们可能会对该构建版本定义的行为产生依赖。如果没有明确依赖该构建版本的方法,当 1.9 更新到指向 1.9.2 时,他们将会遇到痛苦。

消除这个问题的最佳方法是定义和标记版本,让用户可以依赖一致的合同。这并不是提倡三层版本控制系统。这意味着你使用的版本系统中的最小单位只捕获合同迭代的最低单位。通过在这个级别提供多个标签,你可以让用户决定他们愿意接受多少版本漂移。

考虑图 7.7 的右侧。采用版本 1 的用户将始终使用该主版本下的最高次小版本和构建版本。采用 1.9 将始终使用该次小版本的最高构建版本。需要仔细在不同版本的依赖项之间迁移的采用者可以在控制和选择的时间进行迁移。

第二个问题与latest标签相关。在左侧,latest当前指向一个未做其他标签的镜像,因此采用者无法知道这是哪个版本的软件。在这种情况下,它指的是软件下一个主要版本的候选发布版。一个未察觉的用户可能会采用latest标签,以为它指的是已标记版本的最新构建版本。

latest 标签存在其他问题。它被采用得比应有的频率更高。这是因为它是默认标签。影响是,有责任的仓库维护者应始终确保其仓库的 latest 指向其软件的最新稳定构建,而不是真正的最新版本。

最后要记住的是,在容器上下文中,你不仅对软件进行版本控制,还对软件所有打包依赖项的快照进行版本控制。例如,如果你使用特定的 Linux 发行版(如 Debian)打包软件,那么这些额外的包将成为你镜像接口合同的一部分。你的用户将围绕你的镜像构建工具,在某些情况下可能会依赖你镜像中存在特定的 shell 或脚本。如果你突然将你的软件基于类似 CentOS 这样的平台,但其他方面软件保持不变,你的用户将感到痛苦。

当软件依赖项发生变化,或者软件需要基于多个基础进行分发时,这些依赖项应包含在你的标签方案中。

Docker 官方仓库是理想的参考例子。考虑以下官方 golang 仓库的简略标签列表,其中每一行代表一个不同的镜像:

1.9,             1.9-stretch, 1.9.6 1.9-alpine 1,               1.10,        1.10.2,          latest,    stretch 1.10-alpine,     alpine

用户可以确定 Golang 1、1.x 和 1.10 的最新版本目前都指向 1.10.2 版本。Golang 用户可以选择一个标签来跟踪 Golang 或基础操作系统的更改。如果采用者需要基于 debian:stretch 平台构建的最新镜像,他们可以使用 stretch 标签。这种方案将升级的控制和责任交到采用者手中。

摘要

这是第一章节,涵盖了 Docker 镜像的创建、标签管理以及其他如镜像大小等分发问题。学习这些材料将帮助你构建镜像,并成为更好的镜像消费者。本章的关键点如下:

  • 当使用 docker container commit 命令提交容器更改时,将创建新的镜像。

  • 当容器被提交时,启动时使用的配置将被编码到生成的镜像的配置中。

  • 镜像是一系列层,由其顶层来标识。

  • 镜像在磁盘上的大小是其组件层大小的总和。

  • 可以使用 docker container exportdocker image import 命令将镜像导出为 flat tarball 表示形式,并从中导入。

  • 可以使用 docker image tag 命令为单个仓库分配多个标签。

  • 仓库维护者应保持实用的标签,以简化用户的采用和迁移控制。

  • 使用 latest 标签标记最新的稳定构建。

  • 提供细粒度和重叠的标签,以便采用者可以控制其依赖项版本爬升的范围。

第八章. 使用 Dockerfile 自动构建镜像

本章涵盖

  • 使用 Dockerfile 进行自动化的镜像打包

  • 元数据和文件系统指令

  • 使用参数和多个阶段创建可维护的镜像构建

  • 多进程和耐用容器的包装

  • 减少镜像攻击面和建立信任

Dockerfile 是一个包含构建镜像指令的文本文件。Docker 镜像构建器从上到下执行 Dockerfile,指令可以配置或更改关于镜像的任何内容。从 Dockerfile 构建镜像使得像将文件添加到来自你电脑的容器这样的任务变得简单的一行指令。Dockerfile 是描述如何构建 Docker 镜像最常见的方式。

本章涵盖了使用 Dockerfile 构建的基础知识以及使用它们的最佳理由,对指令的简洁概述以及如何添加未来的构建行为。我们将从一个熟悉的例子开始,展示如何通过代码自动化构建镜像的过程,而不是手动创建它们。一旦在代码中定义了镜像的构建,跟踪版本控制中的更改、与团队成员共享、优化和确保安全性都变得简单。

8.1. 使用 Dockerfile 打包 GIT

让我们从回顾我们在第七章中手动构建的 Git 示例镜像开始。在翻译镜像构建过程从手动操作到代码的过程中,你应该能识别出许多与使用 Dockerfile 工作的细节和优势。

首先,创建一个新的目录,然后从该目录使用你喜欢的文本编辑器创建一个新文件。将新文件命名为 Dockerfile。写下以下五行,然后保存文件:

# 安装 Git 到 Ubuntu 的示例 Dockerfile FROM ubuntu:latest LABEL maintainer="dia@allingeek.com" RUN apt-get update && apt-get install -y git ENTRYPOINT ["git"]

在剖析此示例之前,使用包含 Dockerfile 的同一目录中的docker image build命令从它构建一个新的镜像,并用auto标记该镜像:

docker image build --tag ubuntu-git:auto .

这将输出关于apt-get步骤和输出的多行信息,最终会显示如下信息:

Successfully built cc63aeb7a5a2 Successfully tagged ubuntu-git:auto

运行此命令将启动构建过程。完成后,你应该有一个全新的镜像可以进行测试。使用以下命令查看所有ubuntu-git镜像的列表,并测试最新版本:

docker image ls

新的标记为auto的构建现在应该出现在列表中:

REPOSITORY   TAG        IMAGE ID        CREATED             VIRTUAL SIZE ubuntu-git   auto       cc63aeb7a5a2     2 minutes ago      219MB ubuntu-git   latest     826c66145a59    10 minutes ago      249MB ubuntu-git   removed    826c66145a59    10 minutes ago      249MB ubuntu-git   1.9        3e356394c14e    41 hours ago        249MB ...

现在,您可以使用新的镜像运行 Git 命令:

docker container run --rm ubuntu-git:auto

这些命令演示了您使用 Dockerfile 构建的镜像可以正常工作,并且功能上与您手动构建的镜像等效。检查您做了什么来实现这一点:

首先,您创建了一个包含四个指令的 Dockerfile:

  • FROM ubuntu:latest— 告诉 Docker 从最新的 Ubuntu 镜像开始,就像您手动创建镜像时那样。

  • LABEL maintainer— 设置镜像的维护者姓名和电子邮件。提供此信息有助于人们知道在镜像出现问题时联系谁。这在前面的 commit 调用中已经完成。

  • RUN apt-get update && apt-get install -y git— 告诉构建器运行提供的命令来安装 Git。

  • ENTRYPOINT ["git"]— 将镜像的入口点设置为 git

Dockerfile,像大多数脚本一样,可以包含注释。任何以 # 开头的行都将被构建器忽略。对于任何复杂性的 Dockerfile 来说,良好的文档记录非常重要。除了提高 Dockerfile 的可维护性外,注释还有助于人们审计他们考虑采用的镜像,并传播最佳实践。

关于 Dockerfile 的唯一特殊规则是第一条指令必须是 FROM。如果您从一个空镜像开始,并且您的软件没有依赖项,或者您将提供所有依赖项,那么您可以从一个名为 scratch 的特殊空仓库开始。

在保存了 Dockerfile 之后,您通过调用 docker image build 命令开始了构建过程。该命令设置了一个标志和一个参数。--tag 标志(或简写为 -t)指定了您希望用于结果镜像的完整仓库标识。在这种情况下,您使用了 ubuntu-git:auto。您在末尾包含的参数是一个单独的点。该参数告诉构建器 Dockerfile 的位置。点号告诉它在该当前目录中查找文件。

docker image build 命令还有一个标志,--file(或简写为 -f),允许您设置 Dockerfile 的名称。Dockerfile 是默认值,但使用此标志,您可以告诉构建器查找名为 BuildScript 或 release-image.df 的文件。此标志仅设置文件的名称,而不是位置。这必须在位置参数中始终指定。

构建器通过自动化您手动创建镜像时使用的相同任务来工作。每条指令都会触发创建一个新的容器,并应用指定的修改。修改完成后,构建器提交该层,然后继续执行下一条指令和由新层创建的下一个容器。

构建器验证了 FROM 指令指定的镜像是否作为构建的第一步安装。如果没有安装,Docker 会自动尝试拉取该镜像。查看您运行的 build 命令的输出:

将构建上下文发送到 Docker 守护进程  2.048kB 步骤 1/4 : FROM ubuntu:latest ---> 452a96d81c30

您可以看到,在这种情况下,FROM 指令指定的基本镜像为 ubuntu:latest,这应该已经安装在了您的机器上。基本镜像的缩写 ID 包含在输出中。

下一条指令设置了镜像的维护者信息。这会创建一个新的容器,然后提交生成的层。您可以在步骤 1 的输出中看到此操作的成果:

步骤 2/4 : 标签维护者="dia@allingeek.com" ---> 在 11140b391074 中运行 移除中间容器 11140b391074

输出包括创建的容器 ID 和提交的层 ID。该层将作为下一个指令 RUN 的镜像顶层。RUN 指令在新的镜像层上执行您指定的程序,然后 Docker 将文件系统更改提交到该层,以便它们对下一个 Dockerfile 指令可用。在这种情况下,RUN 指令的输出被 apt-get update && apt-get install -y git 命令的所有输出所覆盖。安装软件包是 RUN 指令最常见的使用场景之一。您应该明确安装容器需要的每个软件包,以确保在需要时可用。

如果您对大量的构建过程输出不感兴趣,可以使用带有 --quiet-q 标志的 docker image build 命令。在静默模式下运行将抑制构建过程和管理中间容器的所有输出。静默模式下的构建过程唯一输出是生成的镜像 ID,如下所示:

sha256:e397ecfd576c83a1e49875477dcac50071e1c71f76f1d0c8d371ac74d97bbc90

虽然安装 Git 的这一步通常需要更长的时间才能完成,但您可以看到指令和输入,以及运行命令的容器 ID 和生成层的 ID。最后,ENTRYPOINT 指令执行所有相同的步骤,输出同样不出所料:

步骤 4/4 : ENTRYPOINT ["git"] ---> 在 6151803c388a 中运行 移除中间容器 6151803c388a ---> e397ecfd576c 成功构建 e397ecfd576c 成功标记 ubuntu-git:auto

在构建的每一步之后,都会向生成的镜像中添加一个新的层。虽然这意味着您可以在这些步骤中的任何一步进行分支,但更重要的是,构建者可以积极缓存每一步的结果。如果在其他几个步骤之后构建脚本出现问题,构建者可以在问题解决后从相同的位置重新启动。您可以通过破坏 Dockerfile 来看到这一过程。

将此行添加到您的 Dockerfile 末尾:

RUN 这将不会工作

然后再次运行构建:

docker image build --tag ubuntu-git:auto .

输出将显示构建者能够跳过哪些步骤以使用缓存结果:

Sending build context to Docker daemon  2.048kB Step 1/5 : FROM ubuntu:latest ---> 452a96d81c30 Step 2/5 : LABEL maintainer="dia@allingeek.com" ---> 使用缓存 1 ---> 83da14c85b5a Step 3/5 : RUN apt-get update && apt-get install -y git ---> 使用缓存 1 ---> 795a6e5d560d Step 4/5 : ENTRYPOINT ["git"] ---> 使用缓存 1 ---> 89da8ffa57c7 Step 5/5 : RUN 这将不会工作 ---> 在 2104ec7bc170 /bin/sh: 1: This: not found 命令 '/bin/sh -c 这将不会工作' 返回了非零代码:127

    1. 注意缓存的使用。

步骤 1 至 4 被跳过,因为它们在您上一次构建时已经构建完成。步骤 5 失败,因为容器中没有名为This的程序。在这种情况下,容器输出是有价值的,因为错误消息会告知您 Dockerfile 的具体问题。如果您修复了问题,相同的步骤将再次被跳过,构建将成功,输出类似于Successfully built d7a8ee0cebd4

在构建过程中使用缓存可以在构建包括下载材料、编译程序或其他耗时操作时节省时间。如果您需要进行完整重建,您可以在docker image build中使用--no-cache标志来禁用缓存的使用。请确保仅在需要时禁用缓存,因为它会给上游源系统和镜像构建系统带来更大的压力。

这个简短的例子使用了 18 个 Dockerfile 指令中的 4 个。这个例子有限制,因为添加到镜像中的所有文件都是从网络上下载的;这个例子以有限的方式修改了环境并提供了一般工具。下一个例子,它具有更具体的目的和本地代码,提供了一个更完整的 Dockerfile 入门。

8.2. DOCKERFILE 入门

Dockerfile 由于其简洁的语法和允许注释而易于表达和理解。您可以使用任何版本控制系统跟踪 Dockerfile 的更改。维护多个镜像版本就像维护多个 Dockerfile 一样简单。Dockerfile 构建过程本身使用广泛的缓存来帮助快速开发和迭代。构建是可追踪和可重复的。它们可以轻松地与现有的构建系统集成,并与许多持续集成工具集成。鉴于有这么多理由更喜欢 Dockerfile 构建而不是手动制作的镜像,学习如何编写它们是非常重要的。

本节中的示例涵盖了大多数镜像中使用的核心 Dockerfile 指令。以下部分展示了如何创建下游行为和更易于维护的 Dockerfile。这里对每个指令都进行了入门级别的介绍。对于每个指令的深入覆盖,最佳参考始终是网上 Docker 文档docs.docker.com/engine/reference/builder/。Docker 构建器参考还提供了良好的 Dockerfile 示例和最佳实践指南。

8.2.1. 元数据指令

第一个示例构建了一个基础镜像和两个使用第二章中使用的不同版本的 mailer 程序的镜像。该程序的目的是在 TCP 端口上监听消息,然后将这些消息发送给预期的接收者。mailer 的第一个版本将监听消息,但只记录这些消息。第二个版本将消息作为HTTP POST发送到定义的 URL。

使用 Dockerfile 构建的最佳理由之一是它们简化了从您的计算机到镜像中复制文件的过程。但并不是所有文件都适合复制到镜像中。开始一个新项目时首先要做的是定义哪些文件永远不应该被复制到任何镜像中。您可以在名为.dockerignore 的文件中完成这项工作。在这个例子中,您将创建三个 Dockerfile,并且都不需要复制到生成的镜像中。

使用您喜欢的文本编辑器创建一个名为.dockerignore 的新文件,并复制以下行:

.dockerignore mailer-base.df mailer-logging.df mailer-live.df

完成后保存并关闭文件。这将防止.dockerignore 文件,或名为 mailer-base.df、mailer-logging.df 或 mailer-live.df 的文件在构建过程中被复制到镜像中。完成这部分会计工作后,你就可以开始对基础镜像进行工作了。

构建基础镜像有助于创建公共层。每个邮件器的版本都将建立在名为mailer-base的镜像之上。当你创建 Dockerfile 时,需要记住每个 Dockerfile 指令都将导致创建一个新的层。尽可能地将指令组合在一起,因为构建器不会执行任何优化。将以下内容放入实践中,创建一个名为 mailer-base.df 的新文件,并添加以下行:

FROM debian:buster-20190910 LABEL maintainer="dia@allingeek.com" RUN groupadd -r -g 2200 example && \ useradd -rM -g example -u 2200 example ENV APPROOT="/app" \ APP="mailer.sh" \ VERSION="0.6" LABEL base.name="Mailer Archetype" \ base.version="${VERSION}" WORKDIR $APPROOT ADD . $APPROOT ENTRYPOINT ["/app/mailer.sh"] 1 EXPOSE 33333 # Do not set the default user in the base otherwise # implementations will not be able to update the image # USER example:example

  • 1 此文件尚不存在。

通过在 mailer-base 文件所在的目录中运行docker image build命令来将所有内容组合在一起。-f标志告诉构建器使用哪个文件名作为输入:

docker image build -t dockerinaction/mailer-base:0.6 -f mailer-base.df .

Dockerfile 的命名

Dockerfile 的默认和最常见名称是 Dockerfile。然而,Dockerfile 可以命名为任何东西,因为它们是简单的文本文件,并且构建命令接受你告诉它的任何文件名。有些人使用扩展名(如.df)来命名他们的 Dockerfile,这样他们就可以在单个项目目录中轻松定义多个镜像的构建(例如,app-build.df、app-runtime.df 和 app-debug-tools.df)。文件扩展名还使得在编辑器中激活 Dockerfile 支持变得容易。

在这个 Dockerfile 中引入了五个新指令。第一个新指令是ENVENV用于为镜像设置环境变量,类似于docker container rundocker container create上的--env标志。在这种情况下,使用单个ENV指令设置了三个不同的环境变量。虽然也可以用三个随后的ENV指令来完成,但这样做会导致创建三个层。你可以通过使用反斜杠来转义换行符(就像在 shell 脚本中一样)来保持指令易于阅读:

Step 4/9 : ENV APPROOT="/app"     APP="mailer.sh"     VERSION="0.6" ---> Running in c525f774240f Removing intermediate container c525f774240f

在 Dockerfile 中声明的环境变量将对生成的镜像可用,但可以在其他 Dockerfile 指令中使用作为替换。在这个 Dockerfile 中,环境变量VERSION被用作下一个新指令LABEL的替换:

Step 5/9 : LABEL base.name="Mailer Archetype"       base.version="${VERSION}" ---> Running in 33d8f4d45042 Removing intermediate container 33d8f4d45042 ---> 20441d0f588e

LABEL 指令用于定义作为镜像或容器的附加元数据记录的键/值对。这反映了 docker rundocker create 中的 --label 标志。像之前的 ENV 指令一样,可以使用单个指令设置多个标签。在这种情况下,VERSION 环境变量的值被替换为 base.version 标签的值。通过这种方式使用环境变量,VERSION 的值将可供容器内运行的过程使用,并记录到适当的标签中。这增加了 Dockerfile 的可维护性,因为当值在单一位置设置时,做出不一致的更改会更困难。

使用标签组织元数据

Docker Inc. 建议使用标签记录元数据以帮助组织镜像、网络、容器和其他对象。每个标签键应该以受作者控制或合作的域的反向 DNS 表示法作为前缀,例如 com.<你的公司>.some-label。标签是灵活的、可扩展的且轻量级,但缺乏结构使得利用信息变得困难。

标签模式项目 (label-schema.org/) 是一个社区努力,旨在标准化标签名称并推广兼容的工具。该模式涵盖了镜像的许多重要属性,如构建日期、名称和描述。例如,当使用标签模式命名空间时,构建日期的键命名为 org.label-schema.build-date,并且应该具有 RFC 3339 格式的值,例如 2018-07-12T16:20:50.52Z

下两条指令是 WORKDIREXPOSE。这些指令在操作上与 docker rundocker create 命令中的相应标志类似。WORKDIR 指令的参数被一个环境变量所替代:

步骤 6/9 : WORKDIR $APPROOT 移除中间容器 c2cb1fc7bf4f ---> cb7953a10e42

WORKDIR 指令的结果将是一个默认工作目录设置为 /app 的镜像。将 WORKDIR 设置为不存在的位置将创建该位置,就像使用命令行选项一样。最后,EXPOSE 命令创建一个打开 TCP 端口 33333 的层:

步骤 9/9 : EXPOSE 33333 ---> 在 cfb2afea5ada 容器中运行 移除中间容器 cfb2afea5ada ---> 38a4767b8df4

你应该能识别出这个 Dockerfile 中的 FROMLABELENTRYPOINT 指令。简要来说,FROM 指令设置层堆栈从 debian:buster-20190910 镜像开始。任何新构建的层都将放置在这个镜像之上。LABEL 指令向镜像的元数据添加键/值对。ENTRYPOINT 指令设置容器启动时运行的可执行文件。在这里,它将指令设置为 exec ./mailer.sh 并使用指令的 shell 形式。

ENTRYPOINT指令有两种形式:shell 形式和 exec 形式。shell 形式看起来像带有空格分隔的参数的 shell 命令。exec 形式是一个字符串数组,其中第一个值是要执行的命令,其余值是参数。使用 shell 形式指定的命令将作为默认 shell 的参数执行。具体来说,在这个 Dockerfile 中使用的命令将在运行时作为/bin/sh –c 'exec ./mailer.sh'执行。最重要的是,如果使用 shell 形式为ENTRYPOINT,则将忽略CMD指令提供的所有其他参数或作为docker container run的额外参数在运行时提供的参数。这使得 shell 形式的ENTRYPOINT灵活性较低。

你可以从构建输出中看到,ENVLABEL指令每个都只产生了一步和一层。但输出并没有显示环境变量值被正确替换。为了验证这一点,你需要检查镜像:

docker inspect dockerinaction/mailer-base:0.6

小贴士

记住,docker inspect命令可以用来查看容器或镜像的元数据。在这种情况下,你用它来检查了一个镜像。

相关的行如下:

"Env": [ "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "APPROOT=/app", "APP=mailer.sh", "VERSION=0.6" ], ... "Labels": { "base.name": "Mailer Archetype", "base.version": "0.6", "maintainer": "dia@allingeek.com" }, ... "WorkingDir": "/app"

元数据清楚地表明环境变量替换是有效的。你可以在ENVADDCOPYLABELWORKDIRVOLUMEEXPOSEUSER指令中使用这种替换形式。

最后一条注释行是一个元数据指令USER。它为所有后续的构建步骤和从镜像创建的容器设置了用户和组。在这种情况下,在基础镜像中设置它将防止任何下游的 Dockerfile 安装软件。这意味着那些 Dockerfile 将需要在权限之间来回切换默认设置。这样做至少会创建两个额外的层。更好的方法是,在基础镜像中设置用户和组账户,然后让实现者在构建完成后设置默认用户。

这个 Dockerfile 最奇特的地方在于ENTRYPOINT被设置为一个不存在的文件。当你尝试从这个基础镜像运行容器时,entrypoint 将会失败。但现在 entrypoint 已经在基础镜像中设置,这意味着对于特定邮件发送器的实现,将少一个需要复制的层。接下来的两个 Dockerfile 构建了不同的mailer.sh实现。

8.2.2. 文件系统指令

包含自定义功能的镜像需要修改文件系统。Dockerfile 定义了三个修改文件系统的指令:COPYVOLUMEADD。第一个实现的 Dockerfile 应该放置在一个名为 mailer-logging.df 的文件中:

FROM dockerinaction/mailer-base:0.6 RUN apt-get update && \     apt-get install -y netcat COPY ["./log-impl", "${APPROOT}"] RUN chmod a+x ${APPROOT}/${APP} && \     chown example:example /var/log USER example:example VOLUME ["/var/log"] CMD ["/var/log/mailer.log"]

在这个 Dockerfile 中,你使用由 mailer-base 生成的镜像作为起点。三条新指令是 COPYVOLUMECMDCOPY 指令将从镜像构建的文件系统中的文件复制到构建容器中。COPY 指令至少需要两个参数。最后一个参数是目标,所有其他参数都是源文件。这个指令只有一个意外的特性:任何复制的文件都将设置文件所有者为 root。这无论在 COPY 指令之前默认用户是如何设置的都适用。最好是将更改文件所有权的 RUN 指令推迟到所有需要更新的文件都已复制到镜像中。

COPY 指令,就像 ENTRYPOINT 和其他指令一样,将尊重 shell 风格和 exec 风格的参数。但如果任何参数包含空格,你需要使用 exec 形式。

小贴士

在可能的情况下尽可能使用 exec(或字符串数组)形式是最佳实践。至少,Dockerfile 应该保持一致,避免混合风格。这将使你的 Dockerfile 更易于阅读,并确保指令的行为符合你的预期,而无需深入了解它们的细微差别。

第二条新指令是 VOLUME。如果你理解了在调用 docker rundocker create--volume 标志的作用,那么它的行为将完全符合你的预期。字符串数组参数中的每个值都将作为新卷定义创建在结果层中。在镜像构建时定义卷比在运行时更有限制。你在镜像构建时无法指定绑定挂载卷或只读卷。VOLUME 指令只会做两件事:在镜像文件系统中创建定义的位置,然后将卷定义添加到镜像元数据中。

在这个 Dockerfile 中的最后一条指令是 CMDCMDENTRYPOINT 指令密切相关,如图 8.1 所示。它们都采用 shell 或 exec 形式,并且都用于在容器内启动进程。但存在一些重要差异。

图 8.1. ENTRYPOINTCMD 之间的关系

CMD命令表示入口点的参数列表。容器的默认入口点是/bin/sh。如果容器没有设置入口点,则传递值,因为命令将被默认入口点包装。但是,如果设置了入口点并且使用 exec 形式声明,您将使用CMD来设置默认参数。此 Dockerfile 将ENTRYPOINT定义为邮件器命令。此 Dockerfile 注入了 mailer.sh 的实现并定义了一个默认参数。使用的参数是用于日志文件的应使用位置。

在构建镜像之前,您需要创建邮件程序的日志版本。在./log-impl目录中创建一个目录。在该目录内,创建一个名为 mailer.sh 的文件,并将以下脚本复制到该文件中:

#!/bin/sh printf "Logging Mailer has started.\n" while true do     MESSAGE=$(nc -l -p 33333)     printf "[Message]: %s\n" "$MESSAGE" > $1     sleep 1 done

此脚本的详细结构并不重要。您需要知道的是,此脚本将在 33333 端口启动邮件器守护程序,并将它接收到的每条消息写入程序的第一个参数指定的文件中。使用以下命令从包含 mailer-logging.df 的目录构建mailer-logging镜像:

docker image build -t dockerinaction/mailer-logging -f mailer-logging.df .

此镜像构建的结果可能不会太引人注目。现在,从新镜像启动一个命名容器:

docker run -d --name logging-mailer dockerinaction/mailer-logging

日志邮件器现在应该构建并运行。链接到此实现的容器将它们的消息记录到/var/log/mailer.log。这在现实世界的场景中可能不太有趣或有用,但它可能对测试很有帮助。一个发送电子邮件的实现将更适合操作监控。

下一个实现示例使用亚马逊网络服务提供的简单电子邮件服务发送电子邮件。从另一个 Dockerfile 开始。将此文件命名为 mailer-live.df:

FROM dockerinaction/mailer-base:0.6 ADD ["./live-impl", "${APPROOT}"] RUN apt-get update && \     apt-get install -y curl netcat python && \     curl "https://bootstrap.pypa.io/get-pip.py" -o "get-pip.py" && \     python get-pip.py && \     pip install awscli && \     rm get-pip.py && \     chmod a+x "${APPROOT}/${APP}" USER example:example CMD ["mailer@dockerinaction.com", "pager@dockerinaction.com"]

此 Dockerfile 包含一个新指令ADDADD指令与COPY指令类似,但有两个重要区别。ADD指令将

  • 如果指定了 URL,则获取远程源文件

  • 提取任何确定为存档文件的源文件

在这两个功能中,自动提取归档文件更有用。使用 ADD 指令的远程获取功能不是好的实践;尽管这个功能很方便,但它没有提供清理未使用文件和导致额外层的机制。相反,你应该使用链式 RUN 指令,就像 mailer-live.df 的第三条指令一样。

在这个 Dockerfile 中需要注意的另一个指令是 CMD,其中传递了两个参数。在这里,你指定了任何发送的电子邮件的“发件人”和“收件人”字段。这与 mailer-logging.df 不同,后者只指定了一个参数。

接下来,在包含 mailer-live.df 的位置下创建一个名为 live-impl 的新子目录。将以下脚本添加到该目录下名为 mailer.sh 的文件中:

#!/bin/sh printf "Live Mailer has started.\n" while true do MESSAGE=$(nc -l -p 33333) aws ses send-email --from $1 --destination "{\"ToAddresses\":[\"$2\"]} --message \"{\\\"Subject\\\":{\\\"Data\\\":\\\"Mailer Alert\\\"},\\\"Body\\\":{\\\"Text\\\":{\\\"Data\\\":\\\"${MESSAGE}\"}}}\" sleep 1 done

从这个脚本中可以得出的关键教训是,就像其他邮件实现一样,它将在端口 33333 上等待连接,对收到的任何消息采取行动,然后在等待下一个消息之前暂停一下。不过,这次脚本将使用简单电子邮件服务的命令行工具发送电子邮件。使用以下两个命令构建并启动容器:

docker image build -t dockerinaction/mailer-live -f mailer-live.df . docker run -d --name live-mailer dockerinaction/mailer-live

如果你将这些与监视器链接起来,你会发现日志邮件器按预期工作。但实时邮件器似乎在连接到简单电子邮件服务以发送消息时遇到了困难。经过一点调查,你最终会意识到容器配置错误。aws 程序需要设置某些环境变量。

为了使这个示例工作,你需要设置 AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEYAWS_DEFAULT_REGION。这些环境变量定义了用于此示例的 AWS 云凭证和位置。随着程序需要时逐个发现执行先决条件可能会让用户感到沮丧。第 8.5.1 节详细介绍了减少这种摩擦并帮助采用者的镜像设计模式。

在学习设计模式之前,你需要了解 Dockerfile 的最后一条指令。记住,并非所有镜像都包含应用程序。有些镜像被构建为下游镜像的平台。这些情况特别受益于注入下游构建时行为的能力。

8.3. 注入下游构建时行为

对于基础镜像的作者来说,一个重要的 Dockerfile 指令是ONBUILDONBUILD指令定义了当生成的镜像用作另一个构建的基础时将执行的其它指令。例如,你可以使用ONBUILD指令来编译由下游层提供的程序。上游 Dockerfile 将构建目录的内容复制到已知位置,然后在该位置编译代码。上游 Dockerfile 将使用类似以下的指令集:

ONBUILD COPY [".", "/var/myapp"] ONBUILD RUN go build /var/myapp

在构建包含ONBUILD指令的 Dockerfile 时,这些指令不会被执行。相反,这些指令会被记录在生成的镜像的元数据中,位于ContainerConfig.OnBuild下。之前的指令将导致以下元数据包含:

... "ContainerConfig": { ...   "OnBuild": [   "COPY [\".\", \"/var/myapp\"]",   "RUN go build /var/myapp"   ],   ...

此元数据将一直保留,直到生成的镜像被用作另一个 Dockerfile 构建的基础。当下游 Dockerfile 在FROM指令中使用带有ONBUILD指令的上游镜像时,这些ONBUILD指令将在FROM指令之后和 Dockerfile 中的下一个指令之前执行。

考虑以下示例以了解ONBUILD步骤何时被注入到构建中。你需要创建两个 Dockerfile 并执行两个构建命令以获得完整的体验。首先,创建一个上游 Dockerfile,该 Dockerfile 定义了ONBUILD指令。将文件命名为 base.df,并添加以下指令:

FROM busybox:latest WORKDIR /app RUN touch /app/base-evidence ONBUILD RUN ls -al /app

你可以看到,从构建 base.df 生成的镜像将在/app 目录中添加一个名为 base-evidence 的空文件。ONBUILD指令将在构建时列出/app 目录的内容,因此如果你想要确切地看到文件系统何时发生变化,请不要以静默模式运行构建。

接下来,创建下游 Dockerfile。当它被构建时,你将能够看到对生成的镜像所做的更改。将文件命名为 downstream.df,并包含以下内容:

FROM dockerinaction/ch8_onbuild RUN touch downstream-evidence RUN ls -al .

此 Dockerfile 将使用名为dockerinaction/ch8_onbuild的镜像作为基础,因此当你构建基础镜像时,你需要使用该仓库名称。然后你可以看到下游构建将创建第二个文件,并再次列出/app 目录的内容。

在这两个文件就绪后,你就可以开始构建了。运行以下命令以创建上游镜像:

docker image build -t dockerinaction/ch8_onbuild -f base.df .

构建输出的结果应该如下所示:

将构建上下文发送到 Docker 守护进程  3.072kB 步骤 1/4 : FROM busybox:latest ---> 6ad733544a63 步骤 2/4 : WORKDIR /app 移除中间容器 dfc7a2022b01 ---> 9bc8aeafdec1 步骤 3/4 : RUN touch /app/base-evidence ---> 在 d20474e07e45 中运行 移除中间容器 d20474e07e45 ---> 5d4ca3516e28 步骤 4/4 : ONBUILD RUN ls -al /app ---> 在 fce3732daa59 中运行 移除中间容器 fce3732daa59 ---> 6ff141f94502 构建成功 6ff141f94502 成功标记 dockerinaction/ch8_onbuild:latest

然后使用以下命令构建下游镜像:

docker image build -t dockerinaction/ch8_onbuild_down -f downstream.df .

结果清楚地显示了当执行来自基础镜像的ONBUILD指令时:

将构建上下文发送到 Docker 守护进程  3.072kB 步骤 1/3 : FROM dockerinaction/ch8_onbuild # 执行 1 个构建触发器 ---> 在 591f13f7a0e7 中运行 总计 8 drwxr-xr-x    1 root     root          4096 Jun 18 03:12 . drwxr-xr-x    1 root     root          4096 Jun 18 03:13 .. -rw-r--r--    1 root     root             0 Jun 18 03:12 base-evidence 移除中间容器 591f13f7a0e7 ---> 5b434b4be9d8 步骤 2/3 : RUN touch downstream-evidence ---> 在 a42c0044d14d 中运行 移除中间容器 a42c0044d14d ---> e48a5ea7b66f 步骤 3/3 : RUN ls -al . ---> 在 7fc9c2d3b3a2 中运行 总计 8 drwxr-xr-x    1 root     root          4096 Jun 18 03:13 . drwxr-xr-x    1 root     root          4096 Jun 18 03:13 .. -rw-r--r--    1 root     root             0 Jun 18 03:12 base-evidence -rw-r--r--    1 root     root             0 Jun 18 03:13 downstream-evidence 移除中间容器 7fc9c2d3b3a2 ---> 46955a546cd3 构建成功 46955a546cd3 成功标记 dockerinaction/ch8_onbuild_down:latest

你可以在基础构建的第 4 步中看到构建器将ONBUILD指令与容器元数据注册。稍后,下游镜像构建的输出显示了它从基础镜像继承的触发器(ONBUILD指令)。构建器在步骤 0(FROM指令)之后立即发现并处理触发器。输出包括触发器指定的RUN指令的结果。输出显示只有基础构建的证据存在。稍后,当构建器继续执行来自下游 Dockerfile 的指令时,它再次列出/app 目录的内容。两个更改的证据都被列出。

那个例子比它有用得多。你应该考虑浏览 Docker Hub,寻找带有onbuild后缀的镜像,以了解它在现实世界中的使用情况。以下是我们最喜欢的几个:

8.4. 创建可维护的 Dockerfile

Dockerfile 具有使维护紧密相关的镜像更简单的功能。这些功能帮助作者在构建时在镜像之间共享元数据和数据。让我们通过几个 Dockerfile 实现来工作,并使用这些功能使它们更简洁、更易于维护。

在您编写邮件应用程序的 Dockerfile 时,您可能已经注意到一些需要每次更新时都更改的重复部分。VERSION变量是重复的最佳例子。版本元数据进入镜像标签、环境变量和标签元数据。还有一个问题。构建系统通常从应用程序的版本控制系统中提取版本元数据。我们宁愿不在我们的 Dockerfile 或脚本中硬编码它。

Dockerfile 的ARG指令提供了解决这些问题的方案。ARG定义了一个变量,用户可以在构建镜像时提供给 Docker。Docker 将参数值插入到 Dockerfile 中,允许创建参数化的 Dockerfile。您可以通过使用一个或多个--build-arg <varname>=<value>选项来向docker image build命令提供构建参数。

让我们在 mailer-base.df 的第 2 行引入ARG VERSION指令:

FROM debian:buster-20190910 ARG VERSION=unknown 1 LABEL maintainer="dia@allingeek.com" RUN groupadd -r -g 2200 example && \     useradd -rM -g example -u 2200 example ENV APPROOT="/app" \     APP="mailer.sh" \     VERSION="${VERSION}" LABEL base.name="Mailer Archetype" \       base.version="${VERSION}" WORKDIR $APPROOT ADD . $APPROOT ENTRYPOINT ["/app/mailer.sh"] EXPOSE 33333

  • 1 定义了具有默认值“unknown”的 VERSION 构建参数

现在可以将版本定义一次作为 shell 变量,并通过命令行作为镜像标签和构建参数传递,以便在镜像内部使用:

version=0.6; docker image build -t dockerinaction/mailer-base:${version} \     -f mailer-base.df \     --build-arg VERSION=${version} \     .

让我们使用docker image inspect来验证VERSION是否被替换到了base.version标签中:

docker image inspect --format '{{ json .Config.Labels }}' \     dockerinaction/mailer-base:0.6

inspect命令应该产生类似以下的 JSON 输出:

{   "base.name": "Mailer Archetype",   "base.version": "0.6",   "maintainer": "dia@allingeek.com" }

如果您没有指定VERSION作为构建参数,则将使用默认值unknown并在构建过程中打印警告。

让我们把注意力转向多阶段构建,它可以通过区分镜像构建的不同阶段来管理重要的问题。多阶段构建可以帮助解决一些常见问题。主要用途包括重用另一个镜像的部分、将应用程序的构建与应用程序运行时镜像的构建分离,以及通过专门的测试或调试工具增强应用程序的运行时镜像。下面的例子展示了如何重用另一个镜像的部分以及分离应用程序的构建和运行时问题。首先,让我们了解 Dockerfile 的多阶段功能。

多阶段 Dockerfile 是一个包含多个FROM指令的 Dockerfile。每个FROM指令标记一个新的构建阶段,其最终层可以在下游阶段中引用。通过在FROM指令后附加AS <name>来命名构建阶段,其中name是你指定的标识符,例如builder。该名称可以在后续的FROMCOPY --from=<name|index>指令中使用,为将文件带入镜像构建的源层提供了一个方便的标识方式。当你使用多个阶段构建 Dockerfile 时,构建过程仍然会产生一个单一的 Docker 镜像。该镜像是由 Dockerfile 中执行的最终阶段产生的。

让我们通过一个使用两个阶段和一些组合的例子来演示多阶段构建的使用;参见图 8.2。跟随这个例子的一种简单方法是克隆位于git@github.com:dockerinaction/ch8_multi_stage_build.git的 Git 仓库。

图 8.2. 多阶段 Docker 构建

图片

此 Dockerfile 定义了两个阶段:builderruntimebuilder阶段收集依赖并构建示例程序。runtime阶段将证书授权(CA)和程序文件复制到运行时镜像中以执行。http-client.df Dockerfile 的来源如下:

################################################# # 定义一个 Builder 阶段并在其中构建应用 FROM golang:1-alpine as builder # 安装 CA 证书 RUN apk update && apk add ca-certificates # 将源代码复制到 Builder ENV HTTP_CLIENT_SRC=$GOPATH/src/dia/http-client/ COPY . $HTTP_CLIENT_SRC WORKDIR $HTTP_CLIENT_SRC # 构建 HTTP 客户端 RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ go build -v -o /go/bin/http-client ################################################# # 定义一个构建运行时镜像的阶段 FROM scratch as runtime ENV PATH="/bin" # 从 builder 阶段复制 CA 证书和应用程序二进制文件 COPY --from=builder \ /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt COPY --from=builder /go/bin/http-client /http-client ENTRYPOINT ["/http-client"]

让我们详细检查镜像构建过程。FROM golang:1-alpine as builder 指令声明第一个阶段将基于 Golang 的 alpine 镜像变体,并别名为 builder 以便于后续阶段的引用。首先,builder 安装用于建立支持 HTTPS 的传输层安全 (TLS) 连接的证书颁发机构文件。这些 CA 文件在本阶段未使用,但将被存储以供运行时镜像组合使用。接下来,builder 阶段将 http-client 源代码复制到容器中,并将 http-client Golang 程序构建为静态二进制文件。http-client 程序存储在 builder 容器的 /go/bin/http-client 目录下。

http-client 程序很简单。它通过 GitHub 发起 HTTP 请求以检索其自身的源代码:

package main import (     "net/http" ) import "io/ioutil" import "fmt" func main() {     url := "https://raw.githubusercontent.com/" +        "dockerinaction/ch8_multi_stage_build/master/http-client.go"     resp, err := http.Get(url)     if err != nil {             panic(err)     }     defer resp.Body.Close()     body, err := ioutil.ReadAll(resp.Body)     fmt.Println("response:\n", string(body)) }

runtime 阶段基于 scratch。当你构建一个 FROM scratch 的镜像时,文件系统开始时是空的,镜像将只包含被 COPY 到那里的内容。请注意,http.Get 语句通过 HTTPS 协议检索文件。这意味着程序需要一个有效的 TLS 证书颁发机构集合。CA 颁发机构从 builder 阶段提供,因为你之前已经安装了它们。runtime 阶段使用以下命令将 ca-certificates.crthttp-client 文件从 builder 阶段复制到 runtime 阶段:

COPY --from=builder \ /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt COPY --from=builder /go/bin/http-client /http-client

runtime 阶段通过将镜像的 ENTRYPOINT 设置为 /http-client 来结束,当容器启动时将调用该命令。最终的镜像将只包含两个文件。你可以使用如下命令构建镜像:

docker build -t dockerinaction/http-client -f http-client.df .

可以按照以下方式执行镜像:

docker container run --rm -it dockerinaction/http-client:latest

http-client 镜像成功运行时,它将输出之前列出的 http-client.go 源代码。总结一下,http-client.df Dockerfile 使用 builder 阶段来检索运行时依赖项并构建 http-client 程序。然后,runtime 阶段将 http-client 和其依赖项从 builder 阶段复制到最小的 scratch 基础镜像上,并为其执行进行配置。生成的镜像只包含运行程序所需的内容,大小仅为 6 MB 多一点。在下一节中,我们将通过使用防御性启动脚本的不同风格来处理应用程序交付。

8.5. 使用启动脚本和多进程容器

无论你选择使用什么工具,你都需要考虑一些图像设计方面。你需要问自己,运行在你容器中的软件是否需要任何启动辅助、监督、监控或与其他容器内进程的协调。如果是这样,你需要在镜像中包含一个启动脚本或初始化程序,并将其安装为入口点。

8.5.1. 环境预条件验证

失败模式难以沟通,如果它们在任意时间发生,可能会让人措手不及。如果容器配置问题总是导致镜像在启动时失败,用户可以确信启动的容器将保持运行。

在软件设计中,快速失败和预条件验证是最佳实践。对于图像设计来说,同样适用这一原则。应该评估的预条件是关于上下文的假设。

Docker 容器无法控制其创建的环境。然而,它们对自己的执行有控制权。镜像作者可以通过在执行主要任务之前引入环境和依赖项验证来巩固其镜像的用户体验。如果从该镜像构建的容器快速失败并显示描述性错误消息,容器用户将更好地了解镜像的要求。

例如,WordPress 需要设置某些环境变量或定义容器链接。没有这个上下文,WordPress 将无法连接到存储博客数据的数据库。在没有访问它应该提供的数据的情况下启动 WordPress 在容器中是没有意义的。WordPress 镜像使用脚本作为容器入口点。该脚本验证容器上下文是否以与包含的 WordPress 版本兼容的方式设置。如果任何必需条件未满足(链接未定义或变量未设置),脚本将在启动 WordPress 之前退出,容器将意外停止。

验证程序启动的预条件通常是特定于用例的。如果你正在将软件打包到镜像中,你通常需要自己编写脚本或仔细配置用于启动程序的工具。启动过程应尽可能验证假设的上下文。这应包括以下内容:

  • 假设链接(以及别名)

  • 环境变量

  • 密码

  • 网络访问

  • 网络端口可用性

  • 根文件系统挂载参数(读写或只读)

  • 当前用户

您可以使用任何您想要的脚本或编程语言来完成此任务。在构建最小化镜像的精神下,使用已包含在镜像中的语言或脚本工具是个好主意。大多数基础镜像都附带了一个 shell,如 /bin/sh 或 /bin/bash。Shell 脚本是最常见的,因为 shell 程序通常可用,并且它们可以轻松适应特定程序和环境的要求。当从 scratch 构建一个用于单个二进制文件的镜像,例如来自 第 8.4 节 的 http-client 示例时,程序负责验证其自身的先决条件,因为没有其他程序将存在于容器中。

考虑以下可能伴随依赖 web 服务器的程序的 shell 脚本。在容器启动时,此脚本强制执行以下条件:另一个容器已链接到 web 别名并暴露了端口 80,或者已定义 WEB_HOST 环境变量:

#!/bin/bash set -e if [ -n "$WEB_PORT_80_TCP" ]; then   if [ -z "$WEB_HOST" ]; then     WEB_HOST='web'   else     echo >&2 '[WARN]: Linked container, "web" overridden by $WEB_HOST.'     echo >&2 "===> Connecting to WEB_HOST ($WEB_HOST)"   fi fi if [ -z "$WEB_HOST" ]; then   echo >&2 '[ERROR]: specify container to link; "web" or WEB_HOST env var'   exit 1 fi exec "$@" # run the default command

如果您不熟悉 shell 脚本,现在是学习它的时候了。这个主题是可接近的,并且有几种优秀的资源可供自学。这个特定的脚本使用了一种模式,其中同时测试了环境变量和容器链接。如果环境变量已设置,则忽略容器链接。最后,执行默认命令。

使用启动脚本验证配置的镜像如果被错误使用,应快速失败,但相同的容器可能因其他原因稍后失败。您可以将启动脚本与容器重启策略结合使用,以创建可靠的容器。但容器重启策略并不是完美的解决方案。已失败并等待重启的容器没有运行。这意味着操作员无法在处于回退窗口中的容器内执行另一个进程。解决这个问题需要确保容器永远不会停止。

8.5.2. 初始化进程

基于 UNIX 的计算机通常首先启动初始化(init)进程。该 init 进程负责启动所有其他系统服务,保持它们运行,并关闭它们。使用类似工具启动、管理、重启和关闭容器进程的 init 风格系统通常是合适的。

初始化进程通常使用一个或一组文件来描述初始化系统的理想状态。这些文件描述了要启动哪些程序,何时启动它们,以及停止时采取哪些行动。使用初始化进程是启动多个程序、清理孤儿进程、监控进程和自动重启任何失败进程的最佳方式。

如果你决定采用这种模式,你应该使用初始化进程作为你应用程序容器入口点。根据你使用的初始化程序,你可能需要使用启动脚本事先准备环境。

例如,runit 程序不会将环境变量传递给它启动的程序。如果你的服务使用启动脚本来验证环境,它将无法访问所需的变量。解决这个问题最好的方法可能是为 runit 程序使用一个启动脚本。这个脚本可能将环境变量写入一个文件,以便你的应用程序的启动脚本可以访问它们。

存在着几个开源的初始化程序。功能齐全的 Linux 发行版通常带有重量级和功能齐全的初始化系统,如 SysV、Upstart 和 systemd。Ubuntu、Debian 和 CentOS 等典型的 Linux Docker 镜像通常已经安装了初始化程序,但默认情况下可能无法正常工作。这些配置可能很复杂,并且可能对需要 root 访问的资源有硬依赖。因此,社区倾向于使用更轻量级的初始化程序。

流行选项包括 runit、tini、BusyBox init、Supervisord 和 DAEMON Tools。这些都试图解决类似的问题,但每个都有其优势和成本。使用初始化进程是应用程序容器的最佳实践,但并不是每个用例都有一个完美的初始化程序。在评估任何用于容器的初始化程序时,考虑以下因素:

  • 程序带入镜像的额外依赖

  • 文件大小

  • 程序如何传递信号给其子进程(或者是否传递)

  • 需要的用户访问权限

  • 监控和重启功能(重启时的回退特性是额外的优势)

  • 僵尸进程清理功能

初始化进程非常重要,以至于 Docker 提供了一个 --init 选项,在容器内运行一个初始化进程来管理正在执行的程序。可以使用 --init 选项向现有镜像添加一个初始化进程。例如,你可以使用 alpine:3.6 镜像运行 Netcat,并用初始化进程来管理它:

docker container run -it --init alpine:3.6 nc -l -p 3000

如果你使用 ps -ef 检查主机的进程,你将看到 Docker 在容器内运行了 /dev/init -- nc -l -p 3000 而不是仅仅 nc。默认情况下,Docker 使用 tini 程序作为初始化进程,尽管你也可以指定另一个初始化进程。

无论你决定使用哪个 init 程序,都要确保你的镜像使用它来提高从你的镜像创建的容器用户的信心。如果容器需要快速失败以传达配置问题,请确保 init 程序不会隐藏该失败。现在你已经为在容器内运行和信号进程打下了坚实的基础,让我们看看如何将容器化进程的健康状态传达给协作者。

8.5.3. 健康检查的目的和使用

健康检查用于确定容器内运行的应用程序是否已准备好并能够执行其功能。工程师为容器定义特定于应用程序的健康检查,以检测应用程序运行时但卡住或具有损坏依赖项的情况。

Docker 在容器内运行单个命令以确定应用程序是否健康。有两种方式可以指定健康检查命令:

  • 在定义镜像时使用HEALTHCHECK指令

  • 在命令行中运行容器时

这个 Dockerfile 定义了 NGINX 网络服务器的健康检查:

FROM nginx:1.13-alpine HEALTHCHECK --interval=5s --retries=2 \ CMD nc -vz -w 2 localhost 80 || exit 1

健康检查命令应该是可靠的、轻量级的,并且不会干扰主应用程序的操作,因为它将被频繁执行。命令的退出状态将用于确定容器的健康状态。Docker 定义了以下退出状态:

  • 0: 成功 - 容器健康且可供使用。

  • 1: 不健康 - 容器工作不正确。

  • 2: 保留 - 不要使用此退出代码。

在 UNIX 世界中的大多数程序在预期顺利进行时退出状态为 0,否则为非零状态。|| exit 1是一种 shell 技巧,意味着或退出 1。这意味着每当nc以任何非零状态退出时,nc的状态将被转换为 1,这样 Docker 就知道容器不健康。将非零退出状态转换为 1 是一种常见模式,因为 Docker 没有定义所有非零健康检查状态的行为,只有 1 和 2。截至本文撰写时,使用未定义行为的退出代码将导致不健康状态。

让我们构建并运行 NGINX 示例:

docker image build -t dockerinaction/healthcheck . docker container run --name healthcheck_ex -d dockerinaction/healthcheck

现在运行了一个带有健康检查的容器,你可以使用docker ps来检查容器的健康状态。当定义了健康检查时,docker ps命令会在状态列中报告容器的当前健康状态。Docker ps的输出可能有点难以处理,所以你会使用一个自定义格式,该格式以表格形式打印容器名称、镜像名称和状态:

docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}' NAMES               IMAGE                        STATUS healthcheck_ex      dockerinaction/healthcheck   Up 3 minutes (healthy)

默认情况下,健康检查命令将每 30 秒运行一次,并且需要三次失败检查才能将容器的 health_status 转换为 unhealthy。健康检查间隔和报告容器为不健康之前所需的连续失败次数可以在 HEALTHCHECK 指令或运行容器时进行调整。

健康检查功能还支持以下选项:

  • 超时— 健康检查命令运行和退出的超时时间。

  • 启动期间— 容器启动初期的宽限期,在此期间不计入健康检查失败对健康状态的影响;一旦健康检查命令返回 healthy,则认为容器已启动,后续的失败将计入健康状态。

镜像作者应在可能的情况下在镜像中定义有用的健康检查。通常这意味着以某种方式锻炼应用程序或检查内部应用程序健康状态指示器,例如在 Web 服务器上的 /health 端点。然而,有时定义 HEALTHCHECK 指令是不切实际的,因为事先对镜像如何运行了解不足。为了解决这个问题,Docker 提供了 --health-cmd 来在运行容器时定义健康检查。

让我们以前面的 HEALTHCHECK 示例为例,在运行容器时指定健康检查:

docker container run --name=healthcheck_ex -d \ --health-cmd='nc -vz -w 2 localhost 80 || exit 1' \ nginx:1.13-alpine

在运行时定义健康检查会覆盖镜像中定义的健康检查(如果存在)。这对于集成第三方镜像很有用,因为你可以考虑特定于你的环境的要求。

这些是你可用的工具,用于构建结果为持久容器的镜像。持久性不是安全性,尽管你的持久镜像的采用者可能相信它们会尽可能长时间地运行,但他们不应该信任你的镜像,直到它们被加固。

8.6. 构建加固的应用程序镜像

作为镜像作者,很难预测你的作品将在哪些场景中使用。因此,尽可能加固你生产的镜像。加固镜像的过程是以一种方式塑造它,从而减少基于它的任何 Docker 容器内的攻击面。

加固应用程序镜像的一般策略是尽量减少其中包含的软件。自然地,包含更少的组件可以减少潜在漏洞的数量。此外,构建最小化镜像可以缩短镜像下载时间,并有助于采用者更快地部署和构建容器。

除了上述一般策略之外,您还可以采取三项措施来加固镜像。首先,您可以强制要求您的镜像从特定的镜像构建。其次,您要确保无论容器如何从您的镜像构建,它们都将有一个合理的默认用户。最后,您应该消除从具有 setuidsetgid 属性的程序中提升 root 用户权限的常见路径。

8.6.1. 内容寻址镜像标识符

本书迄今为止讨论的镜像标识符都是设计用来允许作者以透明的方式更新镜像供使用者使用。镜像作者选择他们的工作将建立在哪个镜像之上,但这一透明层使得很难相信基础镜像自经过安全审查以来没有发生变化。自 Docker 1.6 版本以来,镜像标识符已包含一个可选的摘要组件。

包含摘要组件的镜像 ID 被称为内容寻址镜像标识符(CAIID)。这指的是包含特定内容的特定层,而不是简单地指代一个可能变化的特定层。

现在,只要该镜像位于版本 2 仓库中,镜像作者就可以强制从特定的、不变的起始点进行构建。在标准标签位置处用 @ 符号后跟摘要替换。

使用 docker image pull 并观察输出中标记为 Digest 的行,以发现远程仓库中镜像的摘要。一旦您有了摘要,您就可以将其用作 Dockerfile 中 FROM 指令的标识符。例如,考虑以下使用 debian:stable 的特定快照作为基础的示例:

docker pull debian:stable stable: Pulling from library/debian 31c6765cabf1: Pull complete Digest: sha256:6aedee3ef827... # Dockerfile: FROM debian@sha256:6aedee3ef827... ...

无论何时何地使用 Dockerfile 构建镜像,每次构建都将使用与该 CAIID 相关的内容作为其基础镜像。这对于将已知更新合并到镜像中以及识别运行在您计算机上的软件的确切构建版本非常有用。

虽然这并不直接限制您镜像的攻击面,但使用 CAIIDs 将防止它在您不知情的情况下发生变化。接下来的两个实践确实解决了镜像的攻击面问题。

8.6.2. 用户权限

已知的容器突破策略都依赖于容器内部具有系统管理员权限。第六章 讨论了用于加固容器的工具。该章节深入探讨了用户管理和 USR Linux 命名空间讨论。本节涵盖了为镜像建立合理的默认用户的标准做法。

首先,请理解,当创建容器时,Docker 用户可以始终覆盖镜像的默认设置。因此,镜像无法阻止容器以 root 用户身份运行。镜像作者能做的最好的事情是创建其他非 root 用户,并建立非 root 默认用户和组。

Dockerfile 包含一个USER指令,它以与docker container rundocker container create命令相同的方式设置用户和组。该指令本身已在 Dockerfile 入门部分中介绍。本节是关于考虑事项和最佳实践。

最佳实践和一般指导原则是尽可能早地放弃权限。您可以在创建任何容器之前使用USER指令,或者使用在容器启动时运行的启动脚本。对于镜像作者来说,挑战在于确定最早合适的时机。

如果您过早地放弃权限,活动用户可能没有权限完成 Dockerfile 中的指令。例如,这个 Dockerfile 将无法正确构建:

FROM busybox:latest USER 1000:1000 RUN touch /bin/busybox

构建该 Dockerfile 会导致步骤 2 失败,并显示类似touch: /bin/busybox: Permission denied的消息。文件访问显然会受到用户更改的影响。在这种情况下,UID 1000 没有权限更改文件/bin/busybox 的所有权。该文件目前属于 root。将第二行和第三行颠倒可以修复构建。

第二个时间考虑因素是运行时所需的权限和能力。如果镜像启动一个在运行时需要管理员访问权限的进程,那么在这一点之前将用户访问权限降级到非 root 用户就没有意义了。例如,任何需要访问系统端口范围(1-1024)的进程都需要由具有管理员(至少 CAP_NET_ADMIN)权限的用户启动。考虑一下,当您尝试以非 root 用户身份使用 Netcat 绑定端口 80 时会发生什么。将以下 Dockerfile 放在一个名为 UserPermissionDenied.df 的文件中:

FROM busybox:1.29 USER 1000:1000 ENTRYPOINT ["nc"] CMD ["-l", "-p", "80", "0.0.0.0"]

构建 Dockerfile 并在容器中运行生成的镜像。在这种情况下,用户(UID 1000)将缺少所需的权限,命令将失败:

docker image build \ -t dockerinaction/ch8_perm_denied \ -f UserPermissionDenied.df \ . docker container run dockerinaction/ch8_perm_denied

容器应打印错误消息:

nc: bind: Permission denied

在这些情况下,您可能看不到更改默认用户的好处。相反,您构建的任何启动脚本都应该承担尽快放弃权限的责任。最后一个问题是,应该将哪个用户降级?

在默认的 Docker 配置中,容器使用与主机相同的 Linux USR 命名空间。这意味着容器中的 UID 1000 在主机机器上是 UID 1000。除了 UID 和 GID 之外的所有方面都是隔离的,就像它们在计算机之间一样。例如,你笔记本电脑上的 UID 1000 可能是你的用户名,但与 BusyBox 容器内的 UID 1000 关联的用户名可能是defaultbusyuser或 BusyBox 镜像维护者认为方便的任何名称。当 Docker userns-remap功能(如第六章中所述)启用时,容器中的 UID 映射到主机上的非特权 UID。USR 命名空间重映射提供了完整的 UID 和 GID 隔离,即使是对于 root 用户。但你能否依赖userns-remap功能生效?

图像作者通常不知道他们的图像将在哪里运行的 Docker 守护进程配置。即使 Docker 在默认配置中采用了 USR 命名空间重映射,图像作者也很难知道应该使用哪个 UID/GID。我们可以确定的是,当可以避免时,使用常见的或系统级别的 UID/GID 是不合适的。考虑到这一点,使用原始的 UID/GID 数字仍然很麻烦。这样做使得脚本和 Dockerfile 的可读性降低。因此,图像作者通常包括创建图像使用的用户和组的RUN指令。以下是在 PostgreSQL Dockerfile 中的第二个指令:

# 首先添加我们的用户和组,以确保它们的 ID 能够一致地分配,无论添加了什么依赖 # RUN groupadd -r postgres && useradd -r -g postgres postgres

这个指令只是创建了一个具有自动分配的 UID 和 GID 的postgres用户和组。这个指令放在 Dockerfile 的早期,以确保它在重建之间始终被缓存,并且 ID 保持一致,无论在构建过程中添加了其他用户。然后,这个用户和组可以在USER指令中使用。这将提供一个更安全的默认设置。但 PostgreSQL 容器在启动时需要提升权限。因此,这个特定的镜像使用了一个类似于susudo的程序gosu来以postgres用户启动 PostgreSQL 进程。这样做确保了进程在容器中运行时没有管理员访问权限。

用户权限是构建 Docker 镜像时更为微妙的方面之一。你应该遵循的一般规则是,如果你构建的镜像旨在运行特定的应用程序代码,默认执行应该尽快降低用户权限。

一个正常工作的系统应该有合理的默认设置,从而在合理的安全水平上运行。记住,尽管如此,应用程序或任意代码很少是完美的,可能是故意恶意的。因此,你应该采取额外的步骤来减少图像的攻击面。

8.6.3. SUID 和 SGID 权限

最后要覆盖的加固操作是缓解setuid(SUID)或setgid(SGID)权限。众所周知的文件系统权限(读取、写入、执行)只是 Linux 定义的集合中的一部分。除了这些之外,还有两个特别引人关注:SUID 和 SGID。

这两个在本质上相似。设置了 SUID 位的可执行文件将始终以所有者的身份执行。考虑一个像/usr/bin/passwd 这样的程序,它属于 root 用户,并设置了 SUID 权限。如果一个非 root 用户如bob执行passwd,他将作为 root 用户执行该程序。你可以通过从以下 Dockerfile 构建镜像来看到这一点:

FROM ubuntu:latest # Set the SUID bit on whoami RUN chmod u+s /usr/bin/whoami # Create an example user and set it as the default RUN adduser --system --no-create-home --disabled-password --disabled-login \   --shell /bin/sh example USER example # Set the default to compare the container user and # the effective user for whoami CMD printf "Container running as:          %s\n" $(id -u -n) && \   printf "Effectively running whoami as: %s\n" $(whoami)

一旦创建了 Dockerfile,就需要构建一个镜像并在容器中运行默认命令:

docker image build -t dockerinaction/ch8_whoami . docker run dockerinaction/ch8_whoami

这样做将在终端打印出如下结果:

Container running as:          example Effectively running whoami as: root

默认命令的输出显示,尽管你以示例用户执行了whoami命令,但它是在 root 用户的上下文中运行的。SGID 的工作方式类似。区别在于执行将是从所有者组的上下文中进行的,而不是所有者用户。

在你的基础镜像上快速搜索将给你一个关于有多少文件以及哪些文件具有这些权限的概念:

docker run --rm debian:stretch find / -perm /u=s -type f

它将显示如下列表:

/bin/umount /bin/ping /bin/su /bin/mount /usr/bin/chfn /usr/bin/passwd /usr/bin/newgrp /usr/bin/gpasswd /usr/bin/chsh

此命令将找到所有 SGID 文件:

docker container run --rm debian:stretch find / -perm /g=s -type f

结果列表要短得多:

/sbin/unix_chkpwd /usr/bin/chage /usr/bin/expiry /usr/bin/wall

在这个特定图像中列出的每个文件都具有 SUID 或 SGID 权限,其中任何一个文件中的漏洞都可能被用来在容器内危害 root 账户。好消息是,具有这些权限之一的文件通常在镜像构建期间很有用,但在应用程序用例中很少需要。如果你的镜像将要运行任意或外部来源的软件,那么减轻这种提升风险的最好做法是。

解决这个问题,要么删除所有这些文件,要么取消它们的 SUID 和 SGID 权限。采取任何一种行动都会减少镜像的攻击面。以下 Dockerfile 指令将取消当前镜像中所有文件的 SUID 和 GUID 权限:

RUN for i in $(find / -type f \( -perm /u=s -o -perm /g=s \)); \ do chmod ug-s $i; done

加固镜像将帮助用户构建加固的容器。虽然确实没有任何加固措施可以保护用户免受故意构建弱容器的侵害,但这些措施将帮助那些不太警觉的、最常见的用户。

摘要

大多数 Docker 镜像都是自动从 Dockerfile 构建的。本章涵盖了 Docker 提供的构建自动化以及 Dockerfile 最佳实践。在继续之前,请确保你已经理解了这些关键点:

  • Docker 提供了一个自动化的镜像构建器,该构建器从 Dockerfile 中读取指令。

  • 每个 Dockerfile 指令都会创建一个单独的镜像层。

  • 合并指令以在可能的情况下最小化镜像大小和层计数。

  • Dockerfile 包含设置镜像元数据的指令,包括默认用户、暴露的端口、默认命令和入口点。

  • 其他 Dockerfile 指令会从本地文件系统或远程位置复制文件到生成的镜像中。

  • 下游构建继承了在父级 Dockerfile 中使用 ONBUILD 指令设置的构建触发器。

  • 可以通过多阶段构建和 ARG 指令来改进 Dockerfile 的维护。

  • 在启动主应用程序之前,应使用启动脚本验证容器的执行上下文。

  • 一个有效的执行上下文应该设置适当的环境变量,提供网络依赖,并配置适当的用户。

  • 初始化程序可以用来启动多个进程,监控这些进程,回收孤儿子进程,并将信号传递给子进程。

  • 应该通过构建来自内容可寻址的镜像标识符、创建非 root 默认用户以及禁用或删除具有 SUID 或 SGID 权限的任何可执行文件来加固镜像。

第九章:公共和私有软件分发

本章涵盖

  • 选择项目分发方法

  • 使用托管基础设施

  • 运行和使用自己的注册表

  • 理解手动镜像分发工作流程

  • 分发镜像源

你拥有自己编写的、定制的,或者从互联网上拉取的软件镜像。但如果没有人在安装它,这样的镜像又有什么用呢?Docker 与其他容器管理工具不同,因为它提供了镜像分发功能。

有几种方法可以将你的镜像分发到全世界。本章探讨了这些分发范式,并为创建或选择一个或多个用于你自己的项目提供了一个框架。

托管注册表提供公共和私有存储库以及自动构建工具。相比之下,运行私有注册表允许您隐藏和定制您的图像分发基础设施。更重的定制分发工作流程可能需要您放弃 Docker 图像分发设施并构建自己的。某些系统可能完全放弃图像作为分发单元,转而分发图像的源文件。本章将教会您如何选择和使用一种方法来分发您的图像到全世界或仅在工作场所。

9.1. 选择分发方法

选择分发方法最困难的事情是选择适合您情况的方法。为了帮助解决这个问题,本章中提出的每种方法都将在同一组选择标准下进行审查。

关于使用 Docker 分发软件的第一件事是要认识到没有通用的解决方案。由于许多原因,分发需求各不相同,因此有多种方法可供选择。每种方法都以 Docker 工具为核心,因此总是可以以最小的努力从一种迁移到另一种。

9.1.1. 分发范围

图像分发范围提供了许多具有不同灵活性和复杂性的方法。提供最大灵活性的方法可能最复杂,而那些最简单的方法通常最有限制。图 9.1 展示了完整的范围。

图 9.1. 图像分发范围

图片

范围内的方法从托管注册表,如 Docker Hub,到完全定制的分发架构或源分发方法。我们比其他一些主题更详细地介绍了这些内容。我们还特别关注私有注册表,因为它们在两个关注点之间提供了最平衡的解决方案。

拥有一系列选择可以说明你的选项范围,但你需要一个一致的筛选标准来确定应该使用哪一个。

9.1.2. 选择标准

在这么多选项中,选择最适合您需求的最优分发方法可能会显得有些令人畏惧。在这种情况下,您应该花时间了解这些选项,确定选择标准,并避免急于做出决定或妥协。

以下确定的选择标准基于范围之间的差异和常见的商业关注点。在做出决定时,请考虑这些因素在您的情况中有多重要:

  • 成本

  • 可视性

  • 传输速度或带宽开销

  • 长期控制

  • 可用性控制

  • 访问控制

  • 艺术品完整性

  • 艺术品机密性

  • 必需的专业知识

每种分发方法如何与这些标准相匹配将在本章其余部分的相关部分中介绍。

成本

成本是显而易见的标准,成本分布范围从免费到非常昂贵,且“情况复杂。”通常情况下,低成本更好,但成本通常是灵活性最高的标准。例如,如果情况需要,大多数人会为了工件机密性而牺牲成本。

可见性

可见性是分发方法下一个最明显的标准。机密项目或内部工具应该对未经授权的人难以发现,甚至不可能发现。在另一种情况下,公共作品或开源项目应该尽可能可见,以促进采用。

传输

传输速度和带宽开销是下一个最灵活的标准。文件大小和图像安装速度将在利用图像层、并发下载和预构建图像的方法与使用平面图像文件或依赖于部署时图像构建的方法之间有所不同。对于仅使用即时部署来服务同步请求的系统,高传输速度或低安装延迟至关重要。在开发环境或异步处理系统中,情况则相反。

长期性

长期控制更多地是商业问题而非技术问题。托管分发方法易受其他人或公司的商业问题的影响。一位面临使用托管注册表选择的执行者可能会问:“如果他们停止营业或转向远离存储库托管,会发生什么?”这个问题归结为:“第三方的商业需求在我们之前改变吗?”如果这是您的担忧,那么长期控制就很重要。Docker 使得在方法之间切换变得简单,其他标准如所需的专业技能或成本可能会超过这一担忧。因此,长期控制是更灵活的标准之一。

可用性

可用性控制是指控制您存储库中可用性问题解决的能力。托管解决方案不提供可用性控制。如果您是付费客户,企业通常会提供关于可用性的服务级别协议(SLA),但您无法直接解决问题。在光谱的另一端,私有注册表或定制解决方案将控制和责任都掌握在您的手中。

访问控制

访问控制可以保护您的图像免受未经授权的修改或访问。可用的访问控制程度各不相同。一些系统仅提供对特定存储库修改的访问控制,而其他系统则提供对整个注册表的课程控制。还有其他系统可能包括付费墙或数字版权管理控制。项目通常具有由产品或业务需求决定的具体访问控制需求。这使得访问控制需求成为最不灵活且最重要的考虑因素之一。

完整性

文物完整性和保密性都位于范围中不太灵活且更技术性的末端。文物的完整性是指你的文件和镜像的可靠性和一致性。完整性违规可能包括中间人攻击,其中攻击者拦截你的镜像下载并用自己的内容替换。也可能包括恶意或被黑客攻击的注册表,它们对其返回的有效载荷撒谎。

保密性

文物保密性是开发商业机密或专有软件的公司的一个常见要求。例如,如果你使用 Docker 来分发加密材料,保密性将是一个主要关注点。文物的完整性和保密性功能在整个范围内各不相同。总的来说,开箱即用的分发安全功能不会提供最紧密的保密性或完整性。如果这是你的需求之一,那么信息安全专业人员将需要实施并审查解决方案。

专业知识

选择分发方法时需要考虑的最后一点是所需的专家水平。使用托管方法可能很简单,只需要对工具的机械理解。构建自定义镜像或镜像源分发管道需要一套相关技术的专业知识。如果你没有这种专业知识,或者无法访问拥有这种专业知识的人,使用更复杂的解决方案将是一个挑战。在这种情况下,你可能需要额外付费来弥合差距。

在这个强大的选择标准集下,你可以开始了解和评估各种分发方法。以下各节将使用最差、差、好、较好和最好等评级对这些方法进行评估。最佳起点是光谱的最左侧,即托管注册表。

9.2. 通过托管注册表发布

作为提醒,Docker 注册表是使仓库可供 Docker 拉取命令访问的服务。注册表托管仓库。分发你的镜像最简单的方法是使用托管注册表。

托管注册表是由第三方供应商拥有和运营的 Docker 注册表服务。Docker Hub、Quay.io 和 Google Container Registry 都是托管注册表提供商的例子。默认情况下,Docker 发布到 Docker Hub。Docker Hub 和大多数其他托管注册表都提供公共和私有注册表,如图 9.2 所示。#filepos926454

图 9.2. 分发光谱最简单的一侧和本节的主题

图片

本书使用的示例镜像通过 Docker Hub 和 Quay.io 上的公共仓库进行分发。在本节结束时,你将了解如何使用托管注册表发布自己的镜像,以及托管注册表如何满足选择标准。

9.2.1. 通过公共仓库发布:“Hello World!” via Docker Hub

在托管注册库上开始使用公共仓库的最简单方法是推送您拥有的仓库到 Docker Hub。为此,您需要一个 Docker Hub 账户和一个要发布的镜像。如果您还没有这样做,现在就注册一个 Docker Hub 账户。

一旦您有了账户,您就需要创建一个要发布的镜像。创建一个名为 HelloWorld.df 的新 Dockerfile 并添加以下指令:

FROM busybox:latest 1 CMD echo 'Hello World!'

  • 1 从 HelloWorld.df

第八章 讲解了 Dockerfile 指令。作为提醒,FROM 指令告诉 Docker 镜像构建器从哪个现有镜像开始构建新镜像。CMD 指令设置新镜像的默认命令。从这个镜像创建的容器将显示 Hello World! 并退出。使用以下命令构建您的新镜像:

docker image build \ -t <insert Docker Hub username>/hello-dockerfile \ 1 -f HelloWorld.df \ .

  • 1 输入您的用户名。

请确保在命令中替换您的 Docker Hub 用户名。访问和修改仓库的授权基于 Docker Hub 上仓库名称的用户名部分。如果您使用不同于您自己的用户名创建仓库,您将无法发布它。

使用 docker 命令行工具在 Docker Hub 上发布镜像需要您与该客户端建立认证会话。您可以使用 login 命令来完成此操作:

docker login

此命令将提示您输入用户名、电子邮件地址和密码。您可以使用 --username--email--password 标志将这些信息作为参数传递给命令。当您登录时,docker 客户端会在一个文件中维护您对不同注册库的认证凭据映射。它将特别存储您的用户名和认证令牌,而不是您的密码。

登录后,您将能够将您的仓库推送到托管注册库。使用 docker push 命令来完成此操作:

docker image push <insert Docker Hub username>/hello-dockerfile 1

  • 1 输入您的用户名。

执行该命令应生成如下输出:

将镜像推送到 [dockerinaction/hello-dockerfile] (len: 1) 7f6d4eb1f937: 镜像已存在 8c2e06607696: 镜像成功推送 6ce2e90b0bc7: 镜像成功推送 cf2616975b4a: 镜像成功推送 Digest: sha256:ef18de4b0ddf9ebd1cf5805fae1743181cbf3642f942cae8de7c5d4e375b1f20

命令输出包括上传状态和结果仓库内容摘要。推送操作将在远程注册库上创建仓库,上传每个新层,然后创建适当的标签。

您的公共仓库在推送操作完成后将向全世界开放。通过搜索您的用户名和您的新仓库来验证这一点。例如,使用以下命令查找dockerinaction用户拥有的示例:

docker search dockerinaction

dockerinaction用户名替换为您的用户名,以在 Docker Hub 上找到您的新仓库。您也可以登录到 Docker Hub 网站,查看您的仓库以找到并修改您的新仓库。

使用 Docker Hub 分发您的第一个镜像后,您应该考虑这种方法与选择标准相比如何;参见表 9.1。

表 9.1. 公共主办仓库的性能

|

标准

|

评分

|

备注

|

| 成本              | 最佳   | 主办注册表上的公共仓库几乎总是免费的。这个价格很难被击败。当您刚开始使用 Docker 或发布开源软件时,这些特别有帮助。

|

| 可见性   | 最佳   | 主办注册表是软件分发的知名中心。如果您希望您的项目广为人知并对公众可见,那么主办注册表上的公共仓库是一个明显的分发选择。

|

| 传输速度/大小                      | 更好                      | 主办注册表,如 Docker Hub,是层感知的,并且将与 Docker 客户端一起工作,仅传输客户端尚未拥有的层。此外,需要传输多个仓库的拉取操作将执行这些并行传输。

并行传输。因此,从主办仓库分发镜像速度快,负载最小。

|

长期控制 良好 您无法控制主办注册表的长久性。但所有注册表都将符合 Docker 注册表 API,从一台主机迁移到另一台主机应该是一个低成本的操作。
可用性控制 最差 您无法控制主办注册表的可访问性。

| 访问控制   | 更好   | 公共仓库对公众开放,允许读取访问。写入访问仍然由主机设置的任何机制控制。Docker Hub 上的公共仓库的写入访问通过两种方式控制。首先,由个人拥有的仓库只能由该个人账户写入。

只有该个人账户可以写入。其次,组织拥有的仓库可能只能由该组织中的任何用户写入。

可以由该组织中的任何用户写入。

|

| 艺术品完整性              | 最佳   | Docker 注册表 API 的当前版本,V2,提供了内容可寻址的镜像。V2 API 允许您请求具有特定加密签名的镜像。Docker 客户端将通过重新计算返回镜像的完整性来验证返回镜像的完整性。

签名并将其与请求的签名进行比较。不了解 V2 注册表 API 的旧版 Docker 不

支持此功能并使用 V1。在这些情况下,以及其他签名未知的情况下,对主机提供的授权和静态安全功能的高度信任被投入其中。

将信任置于由主机提供的授权和静态安全功能。

|

| 机密性              | 最差              | 托管注册表和公共仓库从不适合存储和分发明文密钥或敏感代码。记住,密钥包括密码、API 密钥、证书等等。任何人都可以访问这些密钥。

|

| 必需经验              | 最佳              | 在托管注册表上使用公共仓库只需要您对 Docker 有基本的了解,并且能够通过网站设置账户。这个解决方案对任何 Docker 用户来说都触手可及。

|

托管注册表上的公共仓库是开源项目所有者或刚开始使用 Docker 的人的最佳选择。人们仍然应该对从互联网下载并运行的软件持怀疑态度,因此不公开源代码的公共仓库可能对某些用户来说难以信任。托管(受信任)构建在一定程度上解决了这个问题。

9.2.2. 私有托管仓库

从操作和产品角度来看,私有仓库与公共仓库相似。大多数注册表提供商都提供这两种选项,并且通过他们的网站进行配置的差异将是微小的。因为 Docker 注册表 API 对这两种类型的仓库没有区别,所以提供这两种选项的注册表提供商通常要求您通过他们的网站、应用程序或 API 配置私有注册表。

与私有仓库一起工作的工具与用于公共仓库的工具相同,只有一个例外。在您可以使用 docker image pulldocker container run 从私有仓库安装镜像之前,您需要验证存储该仓库的注册表。为此,您使用 docker login 命令,就像您使用 docker image push 上传镜像一样。

以下命令会提示您使用 Docker Hub 和 Quay.io 提供的注册表进行验证。在创建账户并验证后,您将能够访问所有三个注册表上的公共和私有仓库。login 子命令接受一个可选的服务器参数:

docker login # 用户名: dockerinaction # 密码: # 邮箱: book@dockerinaction.com # 警告:登录凭证已保存在 /Users/xxx/.dockercfg 中。 # 登录成功 docker login quay.io # 用户名: dockerinaction # 密码: # 邮箱: book@dockerinaction.com # 警告:登录凭证已保存在 /Users/xxx/.dockercfg 中。 # 登录成功

在您决定私有托管存储库是您的分发解决方案之前,请考虑它们如何满足您的选择标准;参见表 9.2。

表 9.2. 私有托管存储库的性能

|

标准

|

评分

|

备注

|

| 成本                  | 良好              | 私有存储库的成本通常与您需要的存储库数量成比例。计划通常从                         每月几美元的 5 个存储库,到 50 个存储库的约 50 美元不等。存储和每月虚拟

服务器托管是这里的一个驱动因素。需要超过 50 个存储库的用户或组织可能会发现自行运行私有注册表更为合适。

运行他们自己的私有注册表。

|

| 可见性                  | 最佳              | 私有存储库按定义是私有的。这些通常被排除在索引之外,并且在注册表确认存储库的存在之前通常需要身份验证                         。它们是组织不希望承担

与运行自己的注册表或小型私有项目相关的开销。私有存储库不是长期控制的良好候选者

用于宣传商业软件的可用性或分发开源镜像。

|

| 传输速度/大小              | 较好              | 任何托管注册表,如 Docker Hub,都将最小化传输镜像所使用的带宽,并允许客户端并行传输                         镜像层。忽略通过互联网传输文件可能引入的潜在延迟,托管注册表

应始终与其他非注册表解决方案表现良好。

|

长期控制 良好 您无法控制托管注册表的长久性。但所有注册表都将符合 Docker 注册表 API,并且从一个主机迁移到另一个主机应该是一项低成本的操作。

| 可用性控制              | 最差/良好              | 没有托管注册表提供任何可用性控制。然而,与使用公共存储库不同,使用私有存储库                 将使您成为付费客户。付费客户可能享有更强的服务等级协议保证或访问支持人员的权限。

|

访问控制 较好 对私有存储库的读取和写入访问权限仅限于授权用户。
艺术品完整性 最佳 有理由期望所有托管注册表都支持 V2 注册表 API 和内容寻址镜像。

| 隐私                  | 最差              | 尽管这些存储库提供了隐私保护,但它们从不适合存储明文密钥或商业机密                         代码。尽管注册表要求用户对请求的资源进行身份验证和授权,但这些机制存在

几个潜在问题。提供商可能使用弱凭证存储,拥有弱或丢失的证书,或者留下您的艺术品

静止状态下未加密。最后,你的机密材料不应被注册表提供商的员工访问。

|

| 必需的经验                      | 最佳                      | 与公共仓库一样,在托管注册表中使用私有仓库只需要你至少熟悉 Docker,并能够通过网站设置账户。这种解决方案对任何 Docker 用户来说都触手可及。

|

个人和小型团队会发现私有托管仓库最有用。它们低成本和基本授权功能对低成本项目或对安全性要求最低的私有项目来说非常友好。需要更高保密度且预算合适的的大型公司或项目可能会发现运行自己的私有注册表更能满足他们的需求。

9.3. 介绍私有注册表

当你对可用性控制、长期控制或保密性有严格要求时,运行私有注册表可能是你的最佳选择。这样做,你可以在不牺牲与 Docker 拉取和推送机制互操作性或增加你环境的学习曲线的情况下获得控制权。人们可以像与托管注册表交互一样与私有注册表交互。

有许多免费和商业支持的软件包可用于运行 Docker 镜像注册表。如果你的组织有一个用于操作系统或应用程序软件包的商业工件仓库,它可能支持 Docker 镜像注册表 API。运行非生产环境镜像注册表的一个简单选项是使用 Docker 的注册表软件。Docker 注册表,称为 Distribution,是开源软件,在 Apache 2 许可证下分发。该软件的可用性和宽松的许可证使运行自己的注册表的工程成本保持较低。图 9.3 说明了私有注册表位于分发谱的中间。

图 9.3. 图像分发谱中的私有仓库

运行私有仓库是一个很好的分发方法,如果你有如下特殊的基础设施使用案例:

  • 区域图像缓存

  • 针对特定团队的图像分发以实现本地化或可见性

  • 针对特定环境或部署阶段的图像池

  • 审批图像的内部流程

  • 外部图像的长期控制

在决定这是否是你最佳选择之前,考虑选择标准中详细列出的成本,如表 9.3 所示。

表 9.3. 私有注册表的性能

|

标准

|

评分

|

备注

|

| 成本              | 良好              | 至少,私有注册表会增加硬件开销(虚拟或其他),支持费用和失败风险。但社区已经通过构建

开源软件。成本将在不同的维度上扩展。与托管注册表的成本不同,

随着原始存储库数量的增加而增加,私有注册表的成本随着交易率和存储使用量的增加而增加。如果您构建

在高交易率系统中,您需要增加注册表主机的数量,以便能够处理需求。

同样,为一定数量的小型图像提供服务的注册表将比为相同

大型图像的系统具有更低的存储成本。

|

| 可见性                      | 良好                      | 私有注册表的可视性取决于您的决定。但即使是您拥有并向世界开放的注册表,其可见性也会低于 Docker Hub 等流行的注册表。

|

| 传输速度/大小                      | 最佳                      | 任何客户端与任何注册表之间的操作延迟将根据这两个节点之间的网络性能和注册表的负载而变化。由于这些变量,私有注册表可能比托管注册表更快或更慢。大多数人

在大型部署或内部基础设施中运营的人会发现私有注册表很有吸引力。私有注册表消除了

依赖于互联网或数据中心之间的网络,并将提高与外部网络的

约束。因为这个解决方案使用了 Docker 注册表,所以它共享了托管注册表解决方案相同的并行性收益。

|

长期控制 最佳 作为注册表所有者,您完全控制着解决方案的长期性。
可用性控制 最佳 作为注册表所有者,您完全控制着可用性。

| 访问控制                      | 良好                      | 注册表软件默认不包含任何身份验证或授权功能。但实现这些功能可以通过最小的工程练习来完成。

|

| 文件完整性                      | 最佳                      | 注册表 API 的版本 2 支持基于内容的图像,开源软件支持可插拔的存储后端。为了提供额外的完整性保护,您可以强制在网络中使用 TLS,并使用带有

静态加密。

|

| 保密性                      | 良好                      | 私有注册表是光谱上第一个适合存储商业机密或机密材料的解决方案。您控制着身份验证和授权机制。您还控制着网络和传输中的安全机制。

最重要的是,您控制着静态存储。您有权力确保系统以这种方式配置

保证您的机密信息保持机密。

|

| 必要经验                      | 良好                      | 开始运行本地仓库只需要基本的 Docker 经验。但是,运行和维护一个高度可用的生产私有仓库需要具备多种技术的经验。具体取决于你想要

可以利用的。通常,你将需要熟悉 NGINX 来构建代理,LDAP 或 Kerberos 来提供身份验证,

以及 Redis 用于缓存。许多商业产品解决方案可用于运行私有 Docker 仓库,范围从

传统的工件仓库,如 Artifactory 和 Nexus,到软件交付系统,如 GitLab。

|

从托管仓库迁移到私有仓库时最大的权衡是在获得灵活性和控制的同时,需要更大的工程经验深度和广度来构建和维护解决方案。Docker 镜像仓库通常消耗大量存储空间,因此在分析时务必考虑这一点。本节剩余部分涵盖了实现除最复杂仓库部署设计之外所需的内容,并突出了在您环境中定制的机遇。

9.3.1. 使用仓库镜像

无论你这样做的原因是什么,开始使用 Docker 仓库软件都很简单。分发软件可在 Docker Hub 上的名为registry的仓库中找到。在容器中启动本地仓库可以通过单个命令完成:

`docker run -d -p 5000:5000 \

通过 Docker Hub 分发到机器上的镜像配置为从运行客户端 Docker 守护进程的机器进行不安全访问。当你启动了仓库后,你可以像使用其他任何仓库一样使用docker pullruntagpush命令。在这种情况下,仓库位置是localhost:5000。现在,你的系统架构应该与图 9.4 中描述的相匹配。

图 9.4. Docker 客户端、守护进程、本地仓库容器和本地存储之间的交互

图片

想要在他们的外部镜像依赖项上实施严格版本控制的公司将从外部源(如 Docker Hub)拉取镜像并将它们复制到自己的仓库中。你可能这样做是为了确保在作者更新或删除源镜像时,重要的镜像不会意外更改或消失。为了了解在你的仓库中工作的感觉,考虑以下从 Docker Hub 复制镜像到新仓库的工作流程:

docker image pull dockerinaction/ch9_registry_bound 1 docker image ls -f "label=dia_excercise=ch9_registry_bound" 2 `docker image tag dockerinaction/ch9_registry_bound \

  • 1 从 Docker Hub 拉取演示镜像

  • 2 使用标签过滤器验证镜像可被发现

  • 3 将演示镜像推送到注册表

在运行这四个命令时,您将从 Docker Hub 复制一个示例仓库到您的本地仓库。如果您从启动注册表的位置执行这些命令,您会发现新创建的数据子目录包含新的注册表数据。

9.3.2. 从您的注册表中消费镜像

与 Docker 生态系统紧密集成可以使您感觉您正在使用已经安装在您计算机上的软件。当消除互联网延迟,例如您在与本地注册表一起工作时,这种感觉甚至更少像您正在使用分布式组件。因此,将数据推送到本地仓库的练习本身并不那么令人兴奋。

下一个命令集应该让您印象深刻,您正在使用一个真实的注册表。这些命令将从您的 Docker 守护进程的本地缓存中删除示例仓库,证明它们已消失,然后从您的个人注册表重新安装它们:

docker image rm \ dockerinaction/ch9_registry_bound \ localhost:5000/dockerinaction/ch9_registry_bound 1 docker image ls -f "label=dia_excercise=ch9_registry_bound" 2 docker image ls -f "label=dia_excercise=ch9_registry_bound" 3 docker container rm -vf local-registry 4

  • 1 删除标记引用

  • 2 再次从注册表拉取

  • 3 验证镜像已返回

  • 4 清理本地注册表

您可以尽可能多地在本地上使用此注册表,但默认的不安全配置将阻止远程 Docker 客户端使用您的注册表(除非它们明确允许不安全访问)。这是在部署生产环境中的注册表之前需要解决的一些问题之一。

这是涉及 Docker 注册表的最灵活的分发方法。如果您需要更多控制运输、存储和工件管理,您应该考虑在手动分发系统中直接与镜像一起工作。

9.4. 手动镜像发布和分发

镜像是文件,您可以像分发其他任何文件一样分发它们。在网站上下载软件、文件传输协议(FTP)服务器、企业存储网络或通过对等网络分发软件是很常见的。您可以使用这些任何一种分发渠道进行镜像分发。如果您知道您的镜像接收者,您甚至可以使用电子邮件或 USB 驱动器。手动镜像分发方法提供了最大的灵活性,使得各种用例成为可能,例如在活动中同时向许多人分发镜像或向安全的断网网络分发。

当您将镜像作为文件处理时,您只需使用 Docker 来管理本地镜像和创建文件。所有其他问题都留给了您来实现。这种功能空白使得手动镜像发布和分发成为第二灵活但最复杂的一种分发方法。本节涵盖了自定义镜像分发基础设施,如图 9.5 所示。

图 9.5. 在自定义基础设施上进行的 Docker 镜像分发

我们已经涵盖了所有以文件形式处理镜像的方法。第三章涵盖了将镜像加载到 Docker 和将镜像保存到您的硬盘上。第七章涵盖了将完整文件系统作为扁平化镜像导出和导入。这些技术是构建如图 9.6 所示的分发工作流程的基础。

图 9.6. 典型的手动分发工作流程,包括生产者、运输和消费者

此工作流程是对您如何使用 Docker 创建镜像并为其分发做准备的一般化。您应该熟悉使用docker image build来创建镜像,以及使用docker image savedocker container export来创建镜像文件。您可以使用单个命令执行这些操作中的每一个。

在您拥有镜像文件后,可以使用任何文件传输方式。在图 9.6 中未显示的一个自定义组件是将镜像上传到传输机制的机制。这种机制可能是一个由 Dropbox 等文件共享工具监视的文件夹。它也可能是一段定期运行或响应新文件的定制代码,并使用 FTP 或 HTTP 将文件推送到远程服务器。无论机制如何,这个通用组件都需要一些努力来集成。

该图还展示了客户端如何在镜像分发后摄取镜像并使用它来构建容器。客户端需要一个进程或机制来学习镜像的位置,然后从远程源获取镜像。一旦客户端拥有镜像文件,他们可以使用docker image loadimport命令来完成传输。

在不知道具体分发问题的情况下,手动镜像分发方法很难与选择标准进行衡量。使用非 Docker 分发渠道可以给您完全的控制权,使其能够处理不寻常的需求。您将需要确定您的选项如何与选择标准相匹配。表 9.4 探讨了手动镜像分发方法如何与选择标准相匹配。

表 9.4. 自定义镜像分发基础设施的性能

|

标准

|

评分

|

注意事项

|

| 成本 | 好 | 分发成本由带宽、存储和硬件需求驱动。托管分发解决方案,如云存储,将捆绑这些成本,并且通常随着使用量的增加而降低每单位的价格。但托管解决方案捆绑了

成本,包括人员成本以及您可能不需要的几个其他好处,这会使价格与您

own.

|

| 可见性 | 差 | 大多数手动分发方法都是特殊的,与公共或私有注册相比,它们需要更多的努力来宣传和使用。示例可能包括使用流行的网站或其他知名的文件分发中心。

|

| 传输速度/大小 | 好 | 虽然传输速度取决于传输方式,但文件大小取决于您选择使用分层图像或扁平图像。记住,分层图像保留了图像的历史、容器创建元数据和可能

have been deleted or overridden. Flattened images contain only the current set of files on the filesystem.

|

| 长期控制 | 差 | 使用既不开放也不在您控制之下的专有协议、工具或其他技术将影响长期控制。例如,使用像 Dropbox 这样的托管文件共享服务分发图像文件将不会为您提供任何长期

控制权。另一方面,与您的朋友交换 USB 驱动器将持续到你们两人决定使用 USB 驱动器为止。

|

可用性控制 最佳 如果可用性控制对您的案例很重要,您可以使用您拥有的传输机制。

| 访问控制 | 差 | 您可以使用具有所需访问控制功能的传输或使用文件加密。如果您构建了一个使用特定密钥加密图像文件的系统,您可以确信只有拥有正确密钥的人或人们才能访问

image.

|

| 文件完整性 | 差 | 完整性验证是实现广泛分布的更昂贵的功能之一。至少,您需要一个可信的通信通道来宣传加密文件签名并创建通过使用

docker image save and load.

|

| 机密性 | 好 | 您可以使用廉价的加密工具实现内容保密。如果您还需要元保密(即交换本身是保密的)以及内容保密,那么您应该避免使用托管工具,并确保您使用的传输方式提供保密(HTTPS,

SFTP、SSH 或离线)。

|

| 必要经验                      | 良好              | 托管工具通常设计用于易于使用,并且与您的流程集成需要较低的经验水平。                         但在大多数情况下,您也可以轻松使用您拥有的简单工具。

|

所有相同的标准适用于手动分发,但如果没有具体的运输方法背景,很难讨论它们。

9.4.1. 使用 FTP 的一个示例分发基础设施

构建一个完全功能性的示例将帮助您了解手动分发基础设施中包含的内容。本节将帮助您使用文件传输协议构建基础设施。

FTP 不如以前受欢迎。该协议不提供保密性,并且需要通过有线传输凭据进行身份验证。但是,软件是免费提供的,并且为大多数平台编写了客户端。这使得 FTP 成为构建自己的分发基础设施的绝佳工具。图 9.7 展示了您将构建的内容。

图 9.7. 一个 FTP 发布基础设施

本节中的示例使用两个现有镜像。第一个,dockerinaction/ch9_ftpd,是centos:6镜像的专门化;已安装并配置了vsftpd(一个 FTP 守护进程)以允许匿名写入访问。第二个镜像,dockerinaction/ch9_ftp_client,是一个流行的最小化 Alpine Linux 镜像的专门化。已安装了一个名为LFTP的 FTP 客户端,并将其设置为镜像的入口点。

为了准备实验,从 Docker Hub 拉取您想要分发的已知镜像。在示例中,使用的是registry:2镜像:

docker image pull registry:2

一旦您有了要分发的镜像,您就可以开始了。第一步是构建您的镜像分发基础设施。在这种情况下,这意味着运行一个 FTP 服务器,您将在专用网络上执行此操作:

docker network create ch9_ftp docker container run -d --name ftp-server --network=ch9_ftp -p 21:21 \ dockerinaction/ch9_ftpd

此命令启动一个 FTP 服务器,该服务器在 TCP 端口 21(默认端口)上接受 FTP 连接。不要在生产环境中使用此镜像。服务器配置为允许在 pub/incoming 文件夹下匿名连接写入访问。您的分发基础设施将使用该文件夹作为镜像分发点。

接下来,将镜像导出为文件格式。您可以使用以下命令来完成此操作:

docker image save -o ./registry.2.tar registry:2

运行此命令会将registry:2镜像作为结构化镜像文件导出到您的当前目录。该文件保留了与镜像相关的所有元数据和历史记录。在此阶段,您可以注入各种阶段,例如生成校验和或文件加密。此基础设施没有此类要求,您应该继续进行分发。

dockerinaction/ch9_ftp_client 镜像中安装了 FTP 客户端,可以用来将您的新镜像文件上传到 FTP 服务器。记住,您是在名为 ftp-server 的容器中启动 FTP 服务器的。ftp-server 容器连接到一个用户定义的桥接网络(见第五章),名为 ch9_ftp,并且连接到 ch9_ftp 网络的其他容器将能够连接到 ftp-server。让我们使用 FTP 客户端上传注册表镜像存档:

docker container run --rm -it --network ch9_ftp \ -v "$(pwd)":/data \ dockerinaction/ch9_ftp_client \ -e 'cd pub/incoming; put registry.2.tar; exit' ftp-server

此命令创建了一个与您的本地目录绑定并连接到 FTP 服务器容器正在监听的 ch9_ftp 网络的容器。该命令使用 LFTP 上传一个名为 registry.2.tar 的文件到位于 ftp_server 的服务器。您可以通过列出 FTP 服务器文件夹的内容来验证您已上传了镜像:

docker run --rm -it --network ch9_ftp \ -v "$(pwd)":/data \ dockerinaction/ch9_ftp_client \ -e "cd pub/incoming; ls; exit" ftp-server

注册表镜像现在可供任何了解服务器并能通过网络访问它的 FTP 客户端下载。但在当前的 FTP 服务器配置中,该文件可能永远不会被覆盖。如果您打算在生产中使用类似的工具,您需要制定自己的版本控制方案。

在此场景下,广告图像的可用性需要客户端通过使用您运行的最后一个命令来列出文件,定期轮询服务器。或者,您也可以构建一个网站或发送电子邮件通知客户端关于图像的信息,但所有这些都在标准的 FTP 传输工作流程之外发生。

在评估此分发方法是否符合选择标准之前,从您的 FTP 服务器消耗注册表镜像,以了解客户端需要如何集成。

首先,从您的本地镜像缓存和本地目录中删除注册表镜像和文件:

rm registry.2.tar docker image rm registry:2 1 docker image ls registry 2

  • 1 需要先移除任何注册表容器

  • 2 确认注册表镜像已被移除

然后使用 FTP 客户端从您的 FTP 服务器下载镜像文件:

docker container run --rm -it --network ch9_ftp \ -v "$(pwd)":/data \ dockerinaction/ch9_ftp_client \ -e 'cd pub/incoming; get registry.2.tar; exit' ftp-server

到此为止,您应该再次在本地目录中拥有 registry.2.tar 文件。您可以使用 docker load 命令将该镜像重新加载到本地缓存中:

docker image load -i registry.2.tar

您可以通过再次使用 docker image ls registry 列出注册表存储库的镜像来确认镜像已从存档中加载。

这是一个手动图像发布和分发基础设施可能构建的最小示例。通过一点扩展,您可以构建一个基于 FTP 的生产质量分发中心。在其当前配置下,此示例符合所示的选择标准表 9.5。

表 9.5. 基于 FTP 的样本分发基础设施的性能

|

标准化

|

评分

|

备注

|

| 成本              | 良好              | 这是一个低成本传输。所有相关软件都是免费的。带宽和存储成本应与托管图像的数量和客户端数量的线性比例增长。                         |

|

| 可视性   | 最差   | FTP 服务器在一个未宣传的位置运行,具有非标准的集成工作流程。此配置的可视性非常低。                         |

|

| 传输速度/大小              | 差              | 在这个示例中,所有传输都在同一台计算机上的容器之间进行,因此所有命令都很快完成。如果                         客户端通过网络连接到您的 FTP 服务,速度将直接受到您的上传速度的影响。这种分发

方法将下载冗余的艺术品,并且不会并行下载图像的组件。总体而言,这种方法并不

带宽高效。

|

长期控制 最佳 您可以使用为这个示例创建的 FTP 服务器,只要您想。
可用性控制 最佳 您对 FTP 服务器拥有完全的可用性控制。如果它变得不可用,您是唯一能够恢复服务的人。
访问控制 最差 此配置不提供任何访问控制。

| 艺术品完整性                      | 最差                      | 网络传输层确实在端点之间提供了文件完整性。但它容易受到拦截攻击,                         并且在文件创建和上传之间或下载和导入之间不存在完整性保护。

|

机密性 最差 此配置不提供任何保密性。

| 必要经验                      | 良好                      | 实施此解决方案所需的全部经验都已在此提供。如果您有兴趣将示例                         扩展到生产环境,您需要熟悉 vsftpd 配置选项和 SFTP。

|

简而言之,几乎没有任何实际场景适合这种传输配置。但它有助于说明当您将图像作为文件工作时可以创建的不同关注点和基本工作流程。试着想象一下,用scprsync工具替换 FTP,并使用 SSH 协议,将如何提高系统在艺术品完整性和保密性方面的性能。我们将考虑的最后一种图像分发方法既更灵活,也可能更复杂。

9.5. 图像源-分发工作流程

当您分发图像源而不是图像时,您将省去所有的 Docker 分发工作流程,并完全依赖 Docker 镜像构建器。与手动图像发布和分发一样,源分发工作流程应针对特定实现的上下文中的选择标准进行评估。

使用 GitHub 上的 Git 等托管源代码控制系统与使用rsync等文件备份工具非常不同。从某种意义上说,源分发工作流程包含了手动图像发布和分发工作流程的所有关注点。您将不得不构建自己的工作流程,但无需docker saveloadexportimport命令的帮助。生产者需要确定他们将如何打包他们的源,消费者也需要了解这些源是如何打包的,以及如何从它们构建镜像。这种扩展的接口使得源分发工作流程成为最灵活且可能最复杂的一种分发方法。图 9.8 展示了在复杂度光谱的最复杂端上的图像源分发。

图 9.8. 利用现有基础设施分发图像源

图像源分发是最常见的方法之一,尽管它具有最大的复杂性潜力。流行的版本控制软件处理了源分发扩展接口的许多复杂性。

9.5.1. 在 GitHub 上使用 Dockerfile 分发项目

当您使用 Dockerfile 和 GitHub 分发图像源时,图像消费者会直接克隆您的 GitHub 仓库,并使用docker image build在本地构建您的镜像。在源分发中,发布者不需要在 Docker Hub 或另一个 Docker 注册表中拥有账户来发布镜像。

假设生产者有一个现有的项目、Dockerfile 和 GitHub 仓库,他们的分发工作流程将如下所示:

git init git config --global user.email "you@example.com" git config --global user.name "Your Name" git add Dockerfile # git add * whatever other files you need for the image* git commit -m "first commit" git remote add origin https://github.com/<your username>/<your repo>.git git push -u origin master

同时,消费者会使用一个类似以下的通用命令集:

git clone https://github.com/<your username>/<your repo>.git cd <your-repo> docker image build -t <your username>/<your repo>. .

这些都是普通 Git 或 GitHub 用户熟悉的步骤,如表 9.6 所示。

表 9.6. 通过 GitHub 进行图像源分发的性能

|

标准

|

评分

|

备注

|
成本 最佳 如果您使用的是公共 GitHub 仓库,则没有成本。

| 可见性   | 最佳   | GitHub 是开源工具的高可见位置。它提供了优秀的社会和搜索组件,使得项目发现变得简单。

|

| 传输速度/大小              | 良好              | 通过分发镜像源,你可以利用其他注册表进行基础层的分发。这样做将减少传输和存储负担。GitHub 还提供了内容分发网络(CDN)。该 CDN 用于确保全球各地的客户端

世界各地都可以以低网络延迟访问 GitHub 上的项目。

|

| 长期控制 | 差 | 虽然 Git 是一个流行的工具,并且可能会存在一段时间,但通过集成 GitHub 或其他托管版本控制提供商,你放弃了任何长期控制。

|

可用性控制 最差 依赖于 GitHub 或其他托管版本控制提供商消除了任何可用性控制。
访问控制 良好 GitHub 或其他托管版本控制提供商确实为私有仓库提供了访问控制工具。

| 艺术品完整性              | 良好              | 此解决方案不提供构建过程中产生的镜像或克隆到客户端机器后的源代码的完整性。但完整性是版本控制系统的主要目的。任何完整性问题都应该

通过标准的 Git 流程,可以明显且容易地恢复。

|

机密性 最差 公共项目不提供源代码保密性。
必要经验 良好 镜像生产者和消费者需要熟悉 Dockerfile、Docker 构建器和 Git 工具。

镜像源分发与所有 Docker 分发工具分离。通过仅依赖镜像构建器,你可以自由地采用任何可用的分发工具集。如果你被锁定在特定的工具集用于分发或源代码控制,这可能就是唯一符合你标准的选项。

摘要

本章涵盖了各种软件分发机制以及 Docker 在每个机制中的价值。对于最近实施或正在实施分发渠道的读者,可能会从他们的解决方案中获得额外的见解。其他人将了解更多关于可用的选择。在任何情况下,确保在继续之前你已经获得了以下见解是很重要的:

  • 拥有一系列的选择可以展示你的选项范围。

  • 你应该使用一套一致的选择标准来评估你的分发选项,并确定你应该使用哪种方法。

  • 托管公共仓库提供了优秀的项目可见性,免费,并且采用起来经验要求不高。

  • 由于由受信任的第三方构建,消费者将更加信任由自动化构建生成的镜像。

  • 托管私有仓库对于小型团队来说是经济高效的,并且提供了令人满意的访问控制。

  • 运行自己的注册表使您能够构建适合特殊用例的基础设施,而无需放弃 Docker 分发设施。

  • 使用任何文件共享系统都可以分发图像作为文件。

  • 图像源分发是灵活的,但复杂程度仅取决于您如何设置。使用流行的源分发工具和模式将使事情保持简单。

第十章. 图像管道

本章涵盖

  • Docker 图像管道的目标

  • 构建图像和使用元数据帮助消费者使用您的图像的图案

  • 测试图像是否正确配置和安全的常见方法

  • 为图像打标签的图案,以便它们可以被识别并交付给消费者

  • 将图像发布到运行时环境和注册表的图案

在第八章中,您学习了如何通过使用 Dockerfile 和docker build命令自动构建 Docker 镜像。然而,构建图像仅仅是交付功能性和可信图像的更长过程中的一个关键步骤。图像发布者应执行测试以验证图像在预期的操作条件下是否正常工作。随着通过这些测试,对图像工件正确性的信心逐渐增强。接下来,它最终可以被标记并发布到注册表以供消费。消费者可以自信地部署这些图像,因为他们知道许多重要要求已经得到验证。

这些步骤——准备图像材料、构建图像、测试,最后将图像发布到注册表——统称为图像构建管道。管道帮助软件作者快速发布更新,并有效地将新功能和修复传递给消费者。

10.1. 图像构建管道的目标

在这个背景下,管道自动化了构建、测试和发布工件的过程,以便它们可以被部署到运行时环境。图 10.1 图 10.1 展示了在管道中构建软件或其他工件的高级过程。这个过程对于使用持续集成(CI)实践的人来说应该是熟悉的,并且并不特定于 Docker 镜像。

图 10.1. 通用工件构建管道

人们经常使用 Jenkins、Travis CI 或 Drone 等持续集成系统来自动化构建管道。无论具体的管道建模技术如何,构建管道的目标都是从源定义中创建可部署工件时应用一套一致且严格的实践。管道中使用的特定工具之间的差异仅仅是实现细节。Docker 镜像的 CI 过程与其他软件工件类似,看起来像图 10.2。

图 10.2. Docker 图像构建管道

当构建 Docker 镜像时,这个过程包括以下步骤:

  1. 检出定义图像和构建脚本的源代码的干净副本,以便了解构建图像的来源和过程。

  2. 2 检索或生成将包含在镜像中的工件,例如应用程序包和运行时库。

  3. 使用 Dockerfile 构建镜像。

  4. 验证镜像的结构和功能是否符合预期。

  5. (可选) 验证镜像不包含已知漏洞。

  6. 为镜像打标签,以便它可以轻松被消费。

  7. 将图像发布到注册表或其他分发渠道。

应用程序工件是软件作者生成的运行时脚本、二进制文件 (.exe, .tgz, .zip) 和配置文件。此镜像构建过程假定应用程序工件已经构建、测试并发布到工件存储库,以便包含在镜像中。应用程序工件可以在容器内构建,这正是许多现代 CI 系统的工作方式。本章的练习将展示如何使用容器构建应用程序,以及如何将这些应用程序工件打包到运行应用程序的 Docker 镜像中。我们将通过使用 UNIX-like 环境中可用的小型且常见的工具集来实现构建过程。此管道的概念和基本命令应很容易转移到您组织的工具中。

10.2. 构建镜像的模式

存在多种使用容器构建应用程序和镜像的模式。在这里,我们将讨论三种最受欢迎的模式:

  • 全功能——您使用全功能镜像来构建和运行应用程序。

  • 构建加运行时——您使用一个带有单独、更精简的运行时镜像的构建镜像来构建容器化应用程序。

  • 构建加多个运行时——您在多阶段构建中使用具有用于调试和其他补充用例的变体的精简运行时镜像。

图 10.3. 镜像构建模式成熟度

多种构建模式已经演变,以产生适合特定消费用例的镜像。在此上下文中,“成熟度”指的是构建镜像的设计和过程,而不是应用该模式的组织。当镜像将用于内部实验或作为便携式开发环境时,全功能模式可能最为合适。相比之下,当分发将获得商业许可和支持的服务器时,构建加运行时模式可能最为合适。单个软件发布组织通常会使用多种模式来构建他们使用和分发的镜像。应用并修改此处描述的模式来解决您自己的镜像构建和交付问题。

10.2.1. 全功能镜像

一体化镜像包括构建和运行应用程序所需的所有工具。这些工具可能包括软件开发工具包(SDKs)、包管理器、共享库、特定语言的构建工具或其他二进制工具。此类镜像通常还会包含默认的应用程序运行时配置。一体化镜像是开始容器化应用程序的最简单方法。当容器化具有许多依赖项的开发环境或“遗留”应用程序时,它们特别有用。

让我们使用一体化模式,使用流行的 Spring Boot 框架构建一个简单的 Java 网络服务器。以下是一个一体化 Dockerfile,它将应用程序构建到镜像中,并包含构建工具:

FROM maven:3.6-jdk-11 ENV WORKDIR=/project RUN mkdir -p ${WORKDIR} COPY . ${WORKDIR} WORKDIR ${WORKDIR} RUN mvn -f pom.xml clean verify RUN cp ${WORKDIR}/target/ch10-0.1.0.jar /app.jar ENTRYPOINT ["java","-jar","/app.jar"]

克隆github.com/dockerinaction/ch10_patterns-for-building-images.git仓库,并按以下方式构建项目:

docker image build -t dockerinaction/ch10:all-in-one \ --file all-in-one.df .

在这个 Dockerfile 中,源镜像是社区 Maven 3.6 镜像,它还包括 OpenJDK 11。Dockerfile 构建了一个简单的 Java 网络服务器,并将应用程序工件添加到镜像中。镜像定义以一个ENTRYPOINT结束,通过在镜像中构建的应用程序工件调用java来运行服务。这是可能的最简单的事情,也是展示“看,我们可以容器化我们的应用程序!”的一个很好的方法。

一体化镜像有其缺点。因为它们包含运行应用程序所需工具之外的工具,攻击者有更多选择来利用应用程序,并且镜像可能需要更频繁地更新以适应广泛的开发和运营需求。此外,一体化镜像通常很大,通常为 500 MB 或更多。示例中使用的maven:3.6-jdk-11基础镜像起始大小为 614 MB,最终镜像大小为 708 MB。大镜像对镜像分发机制施加了更多压力,尽管这个问题在达到大规模或非常频繁的发布之前相对无害。

这种方法适用于创建便携式应用程序镜像或开发环境,而无需付出太多努力。下一个模式将展示如何通过分离应用程序构建和运行时关注点来改进运行时镜像的许多特性。

10.2.2. 分离构建和运行时镜像

通过创建单独的构建和运行时镜像,可以改进一体化模式。具体来说,在这种方法中,所有应用程序构建和测试工具都将包含在一个镜像中,另一个镜像将只包含应用程序在运行时所需的内容。

您可以使用 Maven 容器构建应用程序:

docker container run -it --rm \ -v "$(pwd)":/project/ \ -w /project/ \ maven:3.6-jdk-11 \ mvn clean verify

Maven 将应用程序工件编译并打包到项目的target目录中:

$ ls -la target/ch10-0.1.0.jar -rw-r--r-- 1 user group 16142344 Jul 2 15:17 target/ch10-0.1.0.jar

在这种方法中,应用程序使用从公共 Maven 镜像创建的容器构建。应用程序工件通过卷挂载输出到主机文件系统,而不是像在全部包含模式中那样存储在构建图像中。运行时图像使用一个简单的 Dockerfile 创建,该 Dockerfile 将应用程序工件COPY到基于 OpenJDK 10 的图像中:

FROM openjdk:11-jdk-slim COPY target/ch10-0.1.0.jar /app.jar ENTRYPOINT ["java","-jar","/app.jar"]

构建运行时图像:

docker image build -t dockerinaction/ch10:simple-runtime \ --file simple-runtime.df .

现在运行 Web 服务器镜像:

docker container run --rm -it -p 8080:8080 dockerinaction/ch10:simple-runtime

应用程序运行得就像在先前的全部包含示例中一样。采用这种方法,构建特定的工具如 Maven 和中间件件不再包含在运行时图像中。运行时图像现在要小得多(401 MB 与 708 MB 相比!)并且攻击面更小。

这种模式现在得到了许多持续集成工具的支持和鼓励。支持通常体现在能够指定一个 Docker 镜像作为步骤的卫生执行环境,或者能够运行容器化构建代理并将步骤分配给它们。

10.2.3. 通过多阶段构建的运行时图像变体

随着你的构建和运营经验的成熟,你可能发现创建应用程序图像的小变体来支持调试、专用测试或性能分析等用例是有用的。这些用例通常需要添加专用工具或更改应用程序的图像。多阶段构建可以用来保持专用图像与应用程序图像同步,避免图像定义的重复。在本节中,我们将重点关注通过使用第八章中引入的FROM指令的多阶段功能来创建专用图像的“构建加多个运行时”模式。

让我们基于我们的应用程序图像构建一个app-image的调试变体。层次结构将看起来像图 10.4。

图 10.4. 多阶段构建示例的图像层次结构

本章示例仓库中的 multi-stage-runtime.df 实现了这个层次结构:

# app-image 构建目标定义了应用程序镜像 FROM openjdk:11-jdk-slim as app-image 1 ARG BUILD_ID=unknown ARG BUILD_DATE=unknown ARG VCS_REF=unknown LABEL org.label-schema.version="${BUILD_ID}" \ org.label-schema.build-date="${BUILD_DATE}" \ org.label-schema.vcs-ref="${VCS_REF}" \ org.label-schema.name="ch10" \ org.label-schema.schema-version="1.0rc1" COPY multi-stage-runtime.df /Dockerfile COPY target/ch10-0.1.0.jar /app.jar ENTRYPOINT ["java","-jar","/app.jar"] FROM app-image as app-image-debug 2 # 将所需的调试工具复制到镜像中 ENTRYPOINT ["sh"] FROM app-image as default 3

  • 1 app-image 构建阶段从 openjdk 开始。

  • 2 app-image-debug 阶段继承并添加到 app-image。

  • 3 默认阶段确保默认生成 app-image。

主应用程序图像的构建阶段声明为从 openjdk:11-jdk-slim 开始,并命名为 app-image:

# app-image 构建目标定义了应用程序镜像 FROM openjdk:11-jdk-slim as app-image …

命名构建阶段有两个重要的目的。首先,阶段名称使 Dockerfile 中的其他构建阶段可以轻松使用另一个阶段。其次,可以通过指定该名称作为构建目标来构建阶段。构建阶段名称仅限于 Dockerfile 的上下文,不会影响镜像标记。

让我们通过在 Dockerfile 中添加一个支持调试的构建阶段来定义应用程序图像的一个变体:

FROM app-image as app-image-debug 1 # 将所需的调试工具复制到镜像中 ENTRYPOINT ["sh"]

  • 1 使用 app-image 作为调试镜像的基础

调试应用程序图像定义指定 app-image 作为其基础镜像,并展示了进行少量更改。在这种情况下,唯一的更改是将镜像的入口点重新配置为 shell 而不是运行应用程序。调试镜像在其他方面与主应用程序镜像相同。

docker image build 命令无论 Dockerfile 中定义了多少阶段,都只生成一个镜像。您可以使用构建命令的 --target 选项来选择构建镜像的阶段。当您在 Dockerfile 中定义多个构建阶段时,最好明确您想要构建的镜像。要构建调试镜像,请调用 docker build 并指定 app-image-debug 阶段:

docker image build -t dockerinaction/ch10:multi-stage-runtime-debug \ -f multi-stage-runtime.df \ --target=app-image-debug .

构建过程将执行 app-image-debug 阶段以及它所依赖的 app-image 阶段以生成调试镜像。

注意,当您从定义了多个阶段的 Dockerfile 构建镜像且未指定构建目标时,Docker 将从 Dockerfile 中定义的最后一个阶段构建镜像。您可以通过在 Dockerfile 的末尾添加一个微不足道的构建阶段来构建 Dockerfile 中定义的主构建阶段的镜像:

# 确保 app-image 是使用此 Dockerfile 构建的默认镜像 FROM app-image as default

这个FROM语句定义了一个名为default的新构建阶段,它基于app-imagedefault阶段不对app-image产生的最后一层添加任何内容,因此是相同的。

现在我们已经讨论了几种生成镜像或一系列密切相关镜像的模式,让我们来讨论我们应该捕获哪些元数据与我们的镜像一起,以方便交付和操作流程。

10.3. 在镜像构建时记录元数据

如第八章所述,可以通过LABEL指令对镜像进行标注,以便消费者和操作员使用。你应该使用标签来捕获你的镜像中至少以下数据:

  • 应用程序名称

  • 应用程序版本

  • 构建日期和时间

  • 版本控制系统提交标识符

除了镜像标签外,还应考虑将用于构建镜像的 Dockerfile 和软件包清单添加到镜像文件系统中。

所有这些信息在编排部署和调试问题时非常有价值。编排器可以通过将元数据记录到审计日志中提供可追溯性。部署工具可以使用构建时间或版本控制系统(VCS)提交标识符来可视化服务部署的组成。在镜像中包含源 Dockerfile 可以作为快速参考,帮助调试问题的人导航容器内部。编排器和安全工具可能会发现其他描述镜像架构角色或安全配置的元数据在决定容器应该在哪里运行或允许做什么时很有用。

Docker 社区标签模式项目在label-schema.org/中定义了常用标签。使用标签模式和 Dockerfile 中的构建参数表示推荐元数据如下所示:

FROM openjdk:11-jdk-slim ARG BUILD_ID=unknown ARG BUILD_DATE=unknown ARG VCS_REF=unknown LABEL org.label-schema.version="${BUILD_ID}" \ org.label-schema.build-date="${BUILD_DATE}" \ org.label-schema.vcs-ref="${VCS_REF}" \ org.label-schema.name="ch10" \ org.label-schema.schema-version="1.0rc1" COPY multi-stage-runtime.df /Dockerfile COPY target/ch10-0.1.0.jar /app.jar ENTRYPOINT ["java","-jar","/app.jar"]

由于我们现在有更多步骤:收集元数据、构建应用程序工件、构建镜像,我们的构建过程现在更加复杂。让我们使用经过时间考验的构建工具make来编排构建过程。

10.3.1. 使用 make 编排构建

make是一个广泛使用的构建程序的工具,它理解构建过程中步骤之间的依赖关系。构建过程作者在make解释和执行以完成构建的 Makefile 中描述每个步骤。make工具提供了一个灵活的 shell-like 执行环境,因此你可以实现几乎任何类型的构建步骤。

make 与标准 shell 脚本相比的主要优势是用户声明步骤之间的依赖关系,而不是直接在步骤之间实现控制流的流动。这些步骤被称为规则,每个规则都通过一个目标名称来标识。以下是 make 规则的一般形式:

target … : prerequisites … 12 recipe command 1 3 recipe command 2   …

  • 1 个目标通过一个逻辑名称或规则生成的文件名来标识规则。

  • 2 先决条件是构建此目标之前可选的目标列表。

  • 3 脚本部分包含用于构建目标的命令列表。

当你运行 make 命令时,它会根据每个规则声明的先决条件构建一个依赖图。该命令使用此图来计算构建指定目标所需的步骤顺序。make 有许多特性和怪癖,我们在此不一一描述,但你可以阅读更多关于它的信息,请参阅www.gnu.org/software/make/manual/。值得注意的是,make 以其对空白字符的敏感性而闻名,尤其是缩进时使用的制表符以及变量声明周围的空格。你可能发现使用本章源代码库中提供的 Makefile(github.com/dockerinaction/ch10_patterns-for-building-images.git)而不是自己输入它们要容易得多。随着我们的 make 入门课程完成,让我们回到构建我们的 Docker 镜像。

在 Windows 上构建

如果你正在使用 Windows,你可能会发现 make 以及在此示例中使用的其他几个命令在你的环境中不可用。最简单的解决方案可能是使用本地或云中的 Linux 虚拟机。如果你计划在 Windows 上使用 Docker 开发软件,你也应该调查使用 Windows Subsystem for Linux(WSL 或 WSL2)与 Docker for Windows。

这里是一个 Makefile,它将收集元数据,然后构建、测试和标记应用程序工件和镜像:

# 如果 BUILD_ID 未设置,计算在构建中使用的元数据 ifeq ($(strip $(BUILD_ID)),)   VCS_REF := $(shell git rev-parse --short HEAD)   BUILD_TIME_EPOCH := $(shell date +"%s")   BUILD_TIME_RFC_3339 :=   $(shell date -u -r $(BUILD_TIME_EPOCH) '+%Y-%m-%dT%I:%M:%SZ')   BUILD_TIME_UTC :=   $(shell date -u -r $(BUILD_TIME_EPOCH) +'%Y%m%d-%H%M%S')   BUILD_ID := $(BUILD_TIME_UTC)-$(VCS_REF) endif ifeq ($(strip $(TAG)),)   TAG := unknown endif .PHONY: clean clean:   @echo "Cleaning"   rm -rf target .PHONY: metadata metadata:   @echo "Gathering Metadata"   @echo BUILD_TIME_EPOCH IS $(BUILD_TIME_EPOCH)   @echo BUILD_TIME_RFC_3339 IS $(BUILD_TIME_RFC_3339)   @echo BUILD_TIME_UTC IS $(BUILD_TIME_UTC)   @echo BUILD_ID IS $(BUILD_ID) target/ch10-0.1.0.jar:   @echo "Building App Artifacts"   docker run -it --rm  -v "$(shell pwd)":/project/ -w /project/   maven:3.6-jdk-11   mvn clean verify .PHONY: app-artifacts app-artifacts: target/ch10-0.1.0.jar .PHONY: lint-dockerfile lint-dockerfile:   @set -e   @echo "Linting Dockerfile"   docker container run --rm -i hadolint/hadolint:v1.15.0 <   multi-stage-runtime.df .PHONY: app-image app-image: app-artifacts metadata lint-dockerfile   1   @echo "Building App Image"   docker image build -t dockerinaction/ch10:$(BUILD_ID)   -f multi-stage-runtime.df   --build-arg BUILD_ID='$(BUILD_ID)'   --build-arg BUILD_DATE='$(BUILD_TIME_RFC_3339)'   --build-arg VCS_REF='$(VCS_REF)'   .   @echo "Built App Image. BUILD_ID: $(BUILD_ID)" .PHONY: app-image-debug app-image-debug: app-image   @echo "Building Debug App Image"   docker image build -t dockerinaction/ch10:$(BUILD_ID)-debug   -f multi-stage-runtime.df   --target=app-image-debug   --build-arg BUILD_ID='$(BUILD_ID)'   --build-arg BUILD_DATE='$(BUILD_TIME_RFC_3339)'   --build-arg VCS_REF='$(VCS_REF)'   .   @echo "Built Debug App Image. BUILD_ID: $(BUILD_ID)" .PHONY: image-tests image-tests:   @echo "Testing image structure"   docker container run --rm -it   -v /var/run/docker.sock:/var/run/docker.sock   -v $(shell pwd)/structure-tests.yaml:/structure-tests.yaml   gcr.io/gcp-runtimes/container-structure-test:v1.6.0 test   --image dockerinaction/ch10:$(BUILD_ID)   --config /structure-tests.yaml .PHONY: inspect-image-labels inspect-image-labels:   docker image inspect --format '{{ json .Config.Labels }}'   dockerinaction/ch10:$(BUILD_ID) | jq .PHONY: tag tag:   @echo "Tagging Image"   docker image tag dockerinaction/ch10:$(BUILD_ID)   dockerinaction/ch10:$(TAG) .PHONY: all all: app-artifacts app-image image-tests   2`

  • 1 app-image 目标需要构建 app-artifacts、metadata 和 linting 目标。

  • 2 您可以使用“make all”构建所有内容。

此 Makefile 为我们讨论的每个构建步骤定义了一个目标:收集元数据、构建应用程序以及构建、测试和标记镜像。例如 app-image 这样的目标依赖于其他目标以确保步骤按正确的顺序执行。因为构建元数据对于所有步骤都是必不可少的,所以它会被自动生成,除非提供了 BUILD_ID。Makefile 实现了一个可以在本地运行或用于持续集成或持续交付(CD)系统的镜像管道。您可以通过制作 app-image 目标来构建应用程序工件和镜像:

make app-image

制作应用程序工件将产生大量输出,因为会检索依赖项然后编译代码。然而,应用程序构建应该通过类似以下的消息表示成功:

[INFO] ------------------------------------------------------------------ [INFO] 构建成功 [INFO] ------------------------------------------------------------------

紧接着,您应该看到一条 Gathering Metadata 消息,然后是此构建的元数据:

BUILD_TIME_EPOCH IS 1562106748 BUILD_TIME_RFC_3339 IS 2019-07-02T10:32:28Z BUILD_TIME_UTC IS 20190702-223228 BUILD_ID IS 20190702-223228-ade3d65

构建过程中的下一步是对于我们镜像的第一个质量保证步骤。您应该看到类似以下的消息:

Linting Dockerfile docker container run --rm -i hadolint/hadolint:v1.15.0 < multi-stage- runtime.df

在构建镜像之前,Dockerfile 会通过名为 hadolint 的 linting 工具进行分析(github.com/hadolint/hadolint)。该检查器会验证 Dockerfile 是否遵循最佳实践并识别常见错误。与其他质量保证实践一样,当检查器报告问题时,您可以选择停止镜像构建流程。Hadolint 是可用于 Dockerfile 的几个检查器之一。因为它将 Dockerfile 解析为抽象语法树,所以它能够执行比基于正则表达式的方法更深入和更复杂的分析。Hadolint 识别错误指定的或已弃用的 Dockerfile 指令、在 FROM 镜像指令中省略标签、使用 aptapkpipnpm 包管理器时的常见错误,以及其他在 RUN 指令中指定的命令。

一旦 Dockerfile 经过 linting,app-image 目标就会执行并构建应用镜像。docker image build 命令应该显示类似以下的成功输出:

成功构建 79b61fb87b96 成功标记 dockerinaction/ch10:20190702-223619-ade3d65 构建应用镜像。BUILD_ID: 20190702-223619-ade3d65

在这个构建过程中,每个应用程序镜像都会被标记上一个由构建时间和当前 Git 提交哈希计算出的BUILD_ID。在这个例子中,新的 Docker 镜像被标记为仓库和BUILD_ID,即20190702-223619-ade3d65。这个20190702-223619-ade3d65标签现在标识了dockerinaction/ch10镜像仓库中的 Docker 镜像 ID 79b61fb87b96。这种BUILD_ID风格在墙钟时间和版本历史中都能以高精度标识镜像。捕获镜像构建的时间是一个重要的实践,因为人们很好地理解了时间,许多镜像构建将执行软件包管理器更新或其他可能不会产生相同结果的操作。包括版本控制 ID,7c5fd3d,提供了一个方便的指针,可以追溯到构建镜像所使用的原始源材料。

下面的步骤将使用BUILD_ID。您可以通过从终端中app-image构建步骤输出的最后一行复制它,并在 shell 中将它作为变量导出,使BUILD_ID易于访问:

export BUILD_ID=20190702-223619-ade3d65

您可以通过检查以下命令来检查添加到镜像中的元数据:

make inspect-image-labels BUILD_ID=20190702-223619-ade3d65

或者,如果您导出了BUILD_ID标签,可以使用以下命令:

make inspect-image-labels BUILD_ID=$BUILD_ID

这个命令使用docker image inspect来显示镜像的标签:

{   "org.label-schema.build-date": "2019-07-02T10:36:19Z",   "org.label-schema.name": "ch10",   "org.label-schema.schema-version": "1.0rc1",   "org.label-schema.vcs-ref": "ade3d65",   "org.label-schema.version": "20190702-223619-ade3d65" }

应用程序镜像现在已准备好进行进一步的测试和标记,以便发布。该镜像有一个独特的BUILD_ID标签,将方便地标识整个交付过程中的镜像。在下一节中,我们将探讨测试镜像是否正确构建并准备好部署的方法。

10.4. 在构建管道中测试镜像

镜像发布者可以在他们的构建管道中使用多种技术来构建对产生的工件有信心。上一节中描述的 Dockerfile linting 步骤是一个质量保证技术,但我们还可以更进一步。

Docker 镜像格式的其中一个主要优势是,镜像元数据和文件系统可以很容易地被工具分析。例如,可以通过测试镜像来验证它是否包含应用程序所需的文件,这些文件具有适当的权限,以及通过执行关键程序来验证它们是否能够正确运行。可以通过检查 Docker 镜像来验证是否已添加可追溯性和部署元数据。对安全性有意识的用户可以扫描镜像以查找漏洞。如果这些步骤中的任何一个失败,发布者可以停止镜像交付过程,这些步骤共同显著提高了发布镜像的质量。

验证 Docker 镜像构建的一个流行工具是来自 Google 的容器结构测试工具(CST)(github.com/GoogleContainerTools/container-structure-test)。使用此工具,作者可以验证镜像(或镜像 tar 包)是否包含具有所需文件权限和所有权的文件,命令执行时产生预期的输出,以及镜像包含特定的元数据,如标签或命令。许多这些检查可以通过传统的系统配置检查工具,如 Chef Inspec 或 Serverspec 来完成。然而,CST 的方法更适合容器,因为该工具在任意镜像上操作,无需在镜像内包含任何工具或库。让我们通过以下配置执行 CST 来验证应用程序工件具有适当的权限,并且已安装正确的 Java 版本:

schemaVersion: "2.0.0" # 验证期望的 Java 版本是否可用并可执行命令测试: - name: "java version" command: "java" args: ["-version"] exitCode: 0 # OpenJDK java -version stderr 将包含类似以下内容的行: # OpenJDK Runtime Environment 18.9 (build 11.0.3+7) expectedError: ["OpenJDK Runtime Environment.*build 11\\..*"] # 验证应用程序归档是否可读且由 root 拥有文件存在性测试: - name: 'application archive' path: '/app.jar' shouldExist: true permissions: '-rw-r--r--' uid: 0 gid: 0

首先,此配置告诉 CST 调用 Java 并输出版本信息。OpenJDK Java 运行时将版本信息打印到 stderr,因此 CST 被配置为将该字符串与OpenJDK Runtime Environment.*build 11\..*正则表达式匹配。如果您需要确保应用程序针对特定的 Java 版本运行,则可以将正则表达式制作得更具体,并将基础镜像更新以匹配。

其次,CST 将验证应用程序归档位于 /app.jar,由 root 拥有,并且对所有用户可读。验证文件所有权和权限可能看似基本,但有助于防止由于程序不可执行、不可读或不在可执行 PATH 中而导致的“隐形”问题。使用以下命令对您之前构建的镜像执行图像测试:

make image-tests BUILD_ID=$BUILD_ID

此命令应产生成功的结果:

`Testing image structure docker container run --rm -it \

许多镜像作者希望在发布镜像之前扫描其漏洞,并在存在重大漏洞时停止交付过程。我们将简要概述这些系统的工作原理以及它们通常如何集成到镜像构建管道中。从商业和社区来源都有可用的几个镜像漏洞扫描解决方案。

通常,镜像漏洞扫描解决方案依赖于在镜像构建管道中运行的轻量级扫描客户端程序。扫描客户端检查镜像内容,并将软件包元数据和文件系统内容与从集中式漏洞数据库或 API 获取的漏洞数据进行比较。大多数这些扫描系统需要向供应商注册才能使用该服务,因此我们不会将任何工具集成到这个镜像构建工作流程中。在选择了一个图像扫描工具之后,应该很容易将其添加到构建过程中。

一般漏洞扫描和修复工作流程的特点

使用扫描仪识别单个镜像中的漏洞是发布无漏洞镜像的第一步,也是最重要的一步。领先的容器安全系统覆盖的扫描和修复用例范围比在镜像构建管道示例中讨论的更广。

这些系统集成了低误报率的漏洞信息源,与组织的 Docker 注册库集成以识别已发布或由外部来源构建的镜像中的问题,并通知具有漏洞的基础镜像或层的维护者以加快修复。在评估容器安全系统时,请特别注意这些功能和每种解决方案将如何与您的交付和运营流程集成。

10.5. 标签镜像的模式

一旦镜像经过测试并被认为在交付的下一阶段部署就绪,应该对其进行标记,以便消费者容易找到和使用它。存在几种标记镜像的方案,其中一些方案对于某些消费模式来说比其他方案更好。理解最重要的图像标记功能如下:

  • 标签是可读的字符串,指向特定的内容可寻址的镜像 ID。

  • 多个标签可能指向单个镜像 ID。

  • 标签是可变的,可以在存储库中的镜像之间移动,或者完全删除。

您可以使用所有这些功能构建适用于组织的方案,但并没有一个通用的方案在所有情况下都适用,或者只有一个方法可以做到。某些标记方案对于某些消费模式可能效果很好,而对于其他模式则不然。

10.5.1. 背景

Docker 镜像标签是可变的。镜像存储库所有者可以从镜像 ID 中删除标签或将它从一个 ID 移动到另一个 ID。图像标签的变异通常用于标识一系列中的最新图像。latest标签在 Docker 社区中被广泛使用,以标识图像存储库中最新的构建。

然而,latest标签引起了很多混淆,因为对其含义没有真正的共识。根据图像存储库或组织,以下任何一项都是对“latest标签标识什么?”的有效回答:

  • CI 系统构建的最新镜像,无论来源控制分支

  • CI 系统构建的最新镜像,来自主发布分支

  • 从稳定发布分支构建的最新镜像,该镜像已通过所有作者的测试

  • 从活跃的开发分支构建的最新镜像,该镜像已通过所有作者的测试

  • 没有东西!因为作者从未推送过标记为latest的镜像,或者最近没有推送过

即使尝试定义latest也会引发许多问题。在采用镜像发布标签方案时,务必明确在您的特定环境中该标签的含义和含义之外的内容。因为标签可能会被修改,您还需要决定消费者是否以及何时应该拉取镜像以接收机器上已存在的镜像标签的更新。

常见的标记和部署方案包括以下内容:

  • 使用唯一标签的持续交付——管道通过交付阶段提升具有唯一标签的单个镜像。

  • 使用环境特定工件进行持续交付— 管道产生环境特定工件,并通过开发、测试和生产阶段进行提升。

  • 语义版本控制— 使用Major.Minor.Patch方案标记和发布镜像,以传达发布中变化的级别。

10.5.2. 使用唯一标记的持续交付

如图 10.5 所示,唯一标记方案是一种常见且简单的方法来支持应用的持续交付。在这个方案中,使用唯一的BUILD_ID标记构建和部署镜像到环境中。当人们或自动化系统决定这个版本的应用已经准备好升级到下一个环境时,他们会使用带有唯一标记的部署到该环境。

图 10.5. 使用唯一标记的持续交付

图片

这种方案易于实施,并支持使用线性发布模型且无分支的应用的持续交付。这种方案的主要缺点是人们必须处理精确的构建标识符,而不是能够使用latestdev标记。因为一个镜像可能被标记多次,许多团队会应用和发布额外的标记,如latest,以提供一个方便的方式来消费最新的镜像。

10.5.3. 部署阶段的配置镜像

一些组织将软件发布版本打包成每个部署阶段的独立工件。然后,这些包被部署到专门的内部环境进行集成测试,名称如devstage。一旦软件在内部环境中经过测试,生产包就会被部署到生产环境。我们可以为每个环境创建一个 Docker 镜像。每个镜像都会包含应用工件和环境特定配置。然而,这并不是一个好的做法,因为主要的部署工件被构建多次,通常在生产之前没有经过测试。

支持部署到多个环境的一个更好的方法是创建两种类型的镜像:

  • 一个通用的、与环境无关的应用镜像

  • 一组环境特定配置镜像,每个镜像包含该环境的特定配置文件

图 10.6. 每个环境的配置镜像

图片

通用应用和环境特定配置镜像应同时构建,并使用相同的BUILD_ID进行标记。部署过程通过使用如持续交付案例中所述的BUILD_ID来识别部署的软件和配置。在部署时,会创建两个容器。首先,从环境特定配置镜像创建一个配置容器。其次,从通用应用镜像创建应用容器,并将配置容器的文件系统作为卷挂载。

从配置容器的文件系统中消费特定环境的文件是一种流行的应用程序编排模式,也是 12 因子应用程序原则的变体(12factor.net/)。在第十二章([index_split_098.html#filepos1254019])中,你将看到 Docker 如何作为编排的第一级功能支持服务的特定环境配置,而不使用辅助图像。

这种方法使软件作者和操作员能够在保持对原始来源的可追溯性的同时,支持特定环境的差异,并保持简单的部署工作流程。

10.5.4. 语义版本控制

语义版本控制(semver.org/)是一种流行的以主.次.修订形式的版本号对工件进行版本控制的方法。语义版本控制规范定义了,随着软件的变化,作者应增加以下内容:

  1. 制作不兼容 API 更改时的主版本

  2. 在向后兼容的方式中添加功能时的次版本

  3. 制作向后兼容的 bug 修复时的修订版本

语义版本控制帮助发布者和消费者管理对更新图像依赖项时获得的变化的期望。向大量消费者发布图像或必须长期维护多个发布流的人经常发现语义版本控制或类似方案很有吸引力。对于许多人依赖作为基础操作系统、语言运行时或数据库的图像,语义版本控制是一个不错的选择。

图 10.7. 使用语义版本控制标记和发布图像发布

图片

假设你在devstage测试了你的镜像后,想要将示例应用的最新构建版本作为版本 1.0.0 发布给你的客户。你可以使用BUILD_ID来标识镜像,并用 1.0.0 进行标记:

make tag BUILD_ID=$BUILD_ID TAG=1.0.0

将图像标记为版本 1.0.0 表示你已准备好在软件操作中维护向后兼容性。现在你已经标记了图像,你可以将其推送到注册表进行分发。你甚至可以选择将图像发布到多个注册表。使用多个注册表来保持内部使用图像的私密性,并将仅官方发布版发布到公共注册表以供客户消费。

无论识别图像以推广的方案是什么,一旦做出推广图像的决定,推广管道应将语义标签(latestdev7)解析为唯一的标签或内容可寻址标识符,并部署该图像。这确保了如果在推广的标签被移动到另一个图像的同时,部署的将是人们决定推广的图像,而不是仅仅在部署时与标签关联的任何图像。

摘要

本章涵盖了在 Docker 镜像中构建和发布应用程序时使用的常见目标、模式和技巧。本章中描述的选项展示了在创建镜像交付流程时可选范围。有了这个基础,你应该能够导航、选择和定制适合将您自己的应用程序作为 Docker 镜像交付的选项。本章需要理解的关键点包括:

  • 构建镜像的管道具有与其他软件和基础设施构建管道相同的结构和目标,以确保 Docker 镜像的质量。

  • 存在用于检测错误、安全问题和其他镜像构建问题的工具,并且可以轻松地将其集成到镜像构建管道中。

  • 通过使用如 make 之类的构建工具来规范镜像构建过程,并在本地开发和 CI/CD 流程中使用该过程。

  • 存在几种组织 Docker 镜像定义的模式。这些模式在管理应用程序构建和部署关注点(如攻击面和镜像大小与复杂性)方面提供了权衡。

  • 镜像的源和构建过程的信息应记录为镜像元数据,以支持部署镜像时的可追溯性、调试和编排活动。

  • Docker 镜像标签为通过使用从私有服务部署中的持续交付到通过语义版本发布长期版本到公共领域的各种风格,向消费者交付软件提供了一个基础。

第三部分:高级抽象和编排

这一部分侧重于通过使用容器来管理组件系统。最有价值的系统通常包含两个或更多组件。管理少量组件的系统不需要太多自动化就可以管理。但随着系统规模的扩大,没有自动化实现一致性和可重复性是困难的。

管理现代服务架构是复杂的,需要自动化工具。这部分深入探讨了高级抽象,如服务、环境和配置。你将学习如何使用 Docker 提供这种自动化工具。

第十一章:使用 Docker 和 Compose 的服务

本章涵盖

  • 理解服务以及它们与容器的关系

  • 使用 Docker Swarm 进行基本服务管理

  • 使用 Docker Compose 和 YAML 构建声明性环境

  • 使用 Compose 和 deploy 命令迭代项目

  • 扩展服务和清理

今天,我们运行的大多数软件都是设计用来与其他程序交互,而不是与人类用户交互。由此产生的相互依赖的过程网服务于集体目的,如处理支付、运行游戏、促进全球通信或提供内容。当你仔细观察这个网络时,你会发现可能运行在容器中的单个运行进程。这些进程被分配内存并在 CPU 上获得时间。它们绑定到网络上,并在特定端口上监听来自其他程序的请求。它们的网络接口和端口在命名系统中注册,以便在该网络上被发现。但随着你扩大视野并检查更多进程,你会注意到它们大多数具有共同的特征和目标。

任何必须在网络上被发现和可用的进程、功能或数据都称为服务。这个名字,服务,是一个抽象。通过将这些目标编码到抽象术语中,我们简化了我们谈论使用此模式的事物的方式。当我们谈论一个特定的服务时,我们不需要明确说明该名称应该通过 DNS 或环境适当的发现机制来发现。我们也不需要说明当客户端需要使用它时,该服务应该正在运行。这些期望已经通过服务抽象的普遍理解而传达。这种抽象让我们专注于使任何特定服务特殊的事物。

我们可以在我们的工具中反映出相同的优势。Docker 已经为容器做了这件事。容器在第六章 中被描述为使用特定 Linux 命名空间、特定文件系统视图和资源分配启动的进程。我们不必每次谈论容器时都描述这些具体细节,也不必亲自创建这些命名空间。Docker 会为我们完成这些。Docker 还为其他抽象提供了工具,包括服务。

本章介绍了 Docker 为在集群模式下处理服务提供的工具。它涵盖了服务生命周期、编排者的角色以及如何与编排者交互以在您的机器上部署和管理服务。您将在本书的剩余部分使用本章中描述的工具。所有容器编排系统,包括 Kubernetes,都提供了相同的概念、问题和基本工具。以下内容将有助于理解您在日常工作中使用的任何编排器。

11.1. 一个“HELLO WORLD!”服务

与容器入门一样简单,开始使用服务。在这种情况下,您可以通过运行以下两个命令在本地启动一个“Hello World!”网络服务器:

docker swarm init 1 docker service create \ 2 --publish 8080:80 \     --name hello-world \    dockerinaction/ch11_service_hw:v1

  • 1 启用服务抽象

  • 2 在 localhost:8080 上启动服务器

与容器不同,Docker 服务仅在 Docker 以集群模式运行时才可用。初始化集群模式将启动一个内部数据库以及在 Docker Engine 中执行服务编排的长时间运行的循环;参见图 11.1。蜂群还提供了本书其余部分将要介绍的其他一些功能。在前面代码中运行init命令启用服务管理子命令。

图 11.1. 初始化蜂群节点

service create子命令定义了一个名为hello-world的服务,该服务应在 8080 端口上可用,并使用如图 11.2 所示的镜像dockerinaction/ch11_service_hw:v1。图 11.2。

图 11.2. 创建您的第一个服务

运行这两个命令后,您应该会看到一个进度条,描述服务的状态。一旦进度完成,它将被标记为Running,并且命令将退出。此时,您应该能够打开 http://localhost:8080 并看到一个友好的小消息,Hello, World! --ServiceV1。它还会显示处理请求的任务 ID(容器 ID)。

任务是蜂群中的一个概念,代表一个工作单元。每个任务都关联一个容器。尽管可能存在其他类型的任务不使用容器,但这些不是本章的主题。蜂群只与任务一起工作。底层组件将任务定义转换为容器。本书不涉及 Docker 内部结构。就我们的目的而言,可以将任务和容器这两个术语大致互换。

图 11.3. 蜂群节点自动创建一个容器来运行服务软件。

这应该感觉就像运行一个类似的容器示例一样。与其关注服务和容器之间的相似之处,不如关注它们之间的不同之处。首先,认识到服务工作负载是用容器实现的。当服务运行时,运行 docker container ps 来发现有一个名为 hello-world.1.pqamgg6bl5eh6p8j4fj503kur 的容器正在运行。这个容器没有什么特别之处。检查容器不会产生任何特别有趣的结果。你可能注意到了一些 Swarm 特定的标签,但仅此而已。然而,如果你移除容器,会发生一些有趣的事情。本节的剩余部分将描述更高层次的属性,服务生命周期,以及 Swarm 如何使用这些属性来执行诸如服务恢复等小型自动化奇迹。

11.1.1. 自动恢复和复制

将服务恢复到生命状态是大多数开发人员和运维人员都比较熟悉的事情。对于这些人来说,手动终止运行服务的唯一进程可能感觉像是在诱惑命运。就像踢一个早期的人工智能机器人——这感觉比我们愿意承担的风险要大。但是,有了正确的工具(以及这些工具的验证),我们可以安心地知道承担这种风险可能不会导致任何类似世界末日般的业务场景。

如果你移除驱动 hello-world 服务的唯一容器(来自上一节),容器将被停止并移除,但几分钟后它将重新启动。或者至少会有另一个配置相似的容器出现在它的位置。你可以亲自尝试一下:找到运行服务器的容器 ID(使用 docker ps);使用 docker container rm -f 来移除它;然后执行几个 docker container ps 命令来验证它已被移除,并观察一个替代品出现。接下来,通过使用 图 11.4 中显示的 service 子命令来深入了解这种恢复。

图 11.4. Swarm 对服务规范和状态变化的响应时间线

首先,运行 docker service ls 命令来列出正在运行的服务。列表将包括 hello-world 服务,并显示有一个副本正在运行,这可以通过命令输出中 REPLICAS 列的 1/1 来表示。这可以通过容器重新启动的事实得到证明。接下来,运行 docker service ps hello-world 命令来列出与特定服务(在本例中为 hello-world)关联的容器。列表包括两个条目。列表将显示第一个条目具有“Running”的期望状态和“Running x minutes ago”的当前状态。第二个条目将分别列出为 ShutdownFailed。这两个列提示了一些关键思想,所以现在让我们来详细解释一下。考虑以下来自 docker service ps 输出的摘录:

名称                目标状态           当前状态 hello-world.1       运行中             运行中,少于 1 秒前 \_ hello-world.1   关闭            失败,16 秒前

自主编排器——例如 Docker 中的 Swarm 组件——跟踪两个东西:期望状态和当前状态。期望状态是用户希望系统执行的操作,或者它应该执行的操作。当前状态描述了系统实际执行的操作。编排器跟踪这两个状态描述,并通过改变系统来协调这两个描述。

在这个例子中,swarm 编排器注意到hello-world服务的容器已失败。在这种情况下,你杀死了进程并不重要。编排器所知道的是进程已失败,服务的期望状态是运行中。Swarm 知道如何使一个进程运行:为该服务启动一个容器。这正是它所做的事情。

使用自主编排器的高级抽象更像是伙伴关系,而不是使用工具。编排器会记住系统应该如何运行,并在不经过用户请求的情况下对其进行操作。因此,为了有效地使用编排器,您需要了解如何描述系统和它们的操作。通过检查hello-world服务,您可以了解很多关于管理服务的内容。

当您运行docker service inspect hello-world时,Docker 将输出服务的当前期望状态定义。生成的 JSON 文档包括以下内容:

  • 服务的名称

  • 服务 ID

  • 版本控制和时间戳

  • 容器工作负载的模板

  • 复制模式

  • 部署参数

  • 类似的回滚参数

  • 服务端点的描述

前几个条目标识了服务和其变更历史。当涉及到复制模式和部署和回滚参数时,事情开始变得有趣。回想一下我们对服务的定义:任何必须在网络上可发现和可用的进程、功能或数据。运行服务的难度,按定义,更多地关于管理网络上某物的可用性。因此,服务定义主要关于如何运行副本、管理软件的变更以及将请求路由到服务端点以访问该软件,这并不令人惊讶。这些都是与服务抽象独特关联的高级属性。让我们更深入地研究这些。

复制模式告诉 Swarm 如何运行工作负载的副本。今天有两种模式:复制和全局。复制模式的服务将创建并维护固定数量的副本。这是默认模式,您现在可以通过使用docker service scale命令来实验。通过运行以下命令告诉您的 swarm 运行三个hello-world服务的副本:

docker service scale hello-world=3

容器启动后,你可以使用 docker container psdocker service ps hello-world 来验证工作,列出单个容器(现在应该是三个)。你应该注意到容器命名约定编码了服务副本号。例如,你应该看到一个名为 hello-world.3.pqamgg6bl5eh6p8j4fj503kur 的容器。如果你将服务规模缩小,你也会注意到编号较高的容器首先被删除。所以如果你运行 docker service scale hello-world=2,名为 hello-world .3.pqamgg6bl5eh6p8j4fj503kur 的容器将被删除。但 hello-world.1hello-world.2 将保持不变。

第二种模式,全局模式,告诉 Docker 在集群中的每个节点上运行一个副本。这种模式目前更难进行实验,因为你正在运行一个单节点集群(除非你正在跳过)。全局模式下的服务对于维护必须在集群中每个节点上本地可用的公共基础设施服务很有用。

在这一点上,你不需要深入理解 Docker 集群中复制的机制。但理解维护高服务可用性需要运行该服务软件的副本是至关重要的。使用副本允许你替换或更改副本集中的部分,或者在不受服务可用性影响的情况下生存失败。当你有软件副本时,某些操作故事会变得更加复杂。例如,升级软件并不像停止旧版本并启动新版本那样简单。一些属性会影响变更管理和部署过程。

11.1.2. 自动部署

部署复制的服务软件的新版本并不特别复杂,但在自动化此过程时,你应该考虑一些重要的参数。你必须描述部署的特性。这包括顺序、批量大小和延迟。这是通过指定约束和参数给集群编排器,使其在部署过程中遵守来完成的。Docker 集群在部署更新时的操作示意图显示在图 11.5 中。

图 11.5. 将更新的服务定义自动部署到 Docker 集群的时序图

考虑以下命令来更新本章早期创建的 hello-world 服务:

docker service update \ --image dockerinaction/ch11_service_hw:v2 \ --update-order stop-first \ --update-parallelism 1 \ --update-delay 30s \ hello-world

  • 1 新的镜像

  • 2 需要更新的服务的名称

此命令告诉 Docker 将hello-world服务更改为使用标记为v2的镜像。它进一步限定了部署特性:一次只更新一个副本;在更新每个副本批次之间等待 30 秒;在启动替换之前停止每个副本。当你执行此命令时,Docker 将报告每个副本的部署进度以及整体变化。如果一切顺利,它将以声明Service converged结束。这是一种特别机械的方式告诉用户命令已成功执行。收敛是一个技术术语,表示服务的当前状态与命令中描述的期望状态相同。

更新服务后,你应该能够在浏览器中重新加载应用程序,并看到它已被签名--ServiceV2。这不是一个特别有趣的例子,因为一切都很顺利。在现实世界中事情并不这么简单。我们使用并行性来平衡更新服务所需的时间,同时保护用户免受失败转换的影响。我们在更新批次之间引入延迟,以便在启动之前让新的服务实例变得稳定(并确保底层平台保持稳定)。实际上,30 秒可能并不足够。这取决于应用程序。

Docker 及其 Swarm 组件对应用程序是中立的。它们永远无法预测可能部署的所有应用程序的行为以及它们在运行时可能如何失败。相反,Docker 命令行和 API 为用户提供指定发现问题和验证成功的方法,以及管理失败部署的行为。

11.1.3. 服务健康和回滚

与编排器的成功合作意味着明确沟通你要求其编排的工作负载的预期需求和行为。尽管 Docker 可以确信停止的容器是不健康的,但并没有一个普遍和准确的关于服务健康状态的定义。这限制了任何编排器对服务健康状态和部署成功或失败所做的安全假设。除非你是经验丰富的服务所有者,否则确定工作负载的健康状态或启动行为预期可能比你想象的要复杂。

在深入细节之前,从一个简单的例子开始。在最明显的服务健康问题案例中,新的容器可能无法启动:

docker service update \ --image dockerinaction/ch11_service_hw:start-failure \ hello-world

当你执行此命令时,Docker 将在hello-world服务上启动部署。与其他不同,这次更改将失败。默认情况下,Docker 将在第一个副本启动失败后暂停部署。命令将退出,但它将继续尝试启动该容器。如果你运行docker service ps hello-world,你会看到有两个副本仍然在旧版本的服务上,而另一个副本则持续在启动和失败状态之间循环。

在这种情况下,部署无法进行。新版本将永远不会启动。因此,服务将处于减容状态,需要人工干预才能修复。通过在update命令中使用--rollback标志来修复立即问题:

docker service update \ --rollback \ hello-world

此命令将要求 Docker 将当前状态与之前期望的状态进行协调。Docker 将确定它只需要更改三个副本中的一个(未能启动的那个)。它知道服务目前处于暂停部署状态,并且只有一个副本已过渡到当前期望状态。其他副本将继续运行。

知道回滚对于此服务是合适的(没有不兼容的应用状态变化的风险),你可以在部署失败时自动化回滚。使用--update-failure-action标志告诉 Swarm 失败的部署应该回滚。但你也应该明确告诉 Swarm 哪些条件应被视为失败。

假设你正在运行 100 个副本的服务,并且这些副本将在一个大型机器集群上运行。有可能某些条件会阻止副本正确启动。在这种情况下,只要关键阈值内的副本处于运行状态,就继续部署可能是合适的。在接下来的部署中,告诉 Swarm 在舰队中三分之一处于运行状态的情况下容忍启动失败,以示说明。你将使用--update-max-failure-ratio标志并指定 0.6:

docker service update \ --update-failure-action rollback \ --update-max-failure-ratio 0.6 \ --image dockerinaction/ch11_service_hw:start-failure \ hello-world

当你运行此示例时,你会看到 Docker 尝试逐个部署更新的副本。第一个副本会在延迟过期之前重试几次,然后下一个副本部署开始。第二个副本失败后,整个部署将被标记为失败,并启动回滚。输出将类似于以下内容:

hello-world 整体进度:回滚更新:3 个任务中的 2 个 1/3:运行 [>                  ] 2/3:启动 [=====>             ] 3/3:运行 [>                  ] 回滚:由于失败或任务 tdpv6fud16e4nbg3tx2jpikah 的提前终止,更新已回滚:服务回滚:回滚完成

命令执行完毕后,服务将处于更新前的相同状态。你可以通过运行docker service ps hello-world来验证这一点。请注意,有一个副本未受影响,而其他两个副本启动得较晚,并且几乎同时。此时,所有副本都将从dockerinaction/ch11_service_hw:v2镜像运行。

正如我们之前提到的,运行并不等同于服务健康。程序可能以多种方式运行,但并不总是正确。像其他编排器一样,Docker 将健康状态与进程状态分开建模,并提供了一些配置点来指定如何确定服务健康状态。

Docker 对应用程序是中立的。它不假设如何确定特定任务是否健康,而是允许你指定一个健康检查命令。该命令将在每个服务副本的容器内执行。Docker 将在任务容器内按照指定的计划定期执行该命令。这就像发出一个docker exec命令一样。健康检查和相关参数可以在创建服务时指定,也可以在服务更新时更改或设置,甚至可以通过使用HEALTHCHECK Dockerfile 指令将其指定为镜像元数据。

每个服务副本容器(任务)将继承服务的健康和健康检查配置定义。当你想手动检查 Docker 中特定任务的健康状态时,你需要检查容器,而不是服务本身。你一直使用的服务的 v1 和 v2 版本都在镜像中指定了健康检查。这些镜像包含一个名为httpping的小型自定义程序。它验证服务在本地主机上是否响应,并且对/的请求导致 HTTP 200 响应代码。Dockerfile 包含以下指令:

HEALTHCHECK --interval=10s CMD ["/bin/httpping"]

运行docker container ps可以看到每个hello-world副本在状态列中被标记为健康。你可以通过检查镜像或容器来进一步检查配置。

在包含服务软件的镜像中包含一些默认的健康检查配置是一个好主意,但并非总是可用。考虑dockerinaction/ch11 _service_hw:no-health这个镜像。这个镜像本质上与 v1 和 v2 镜像相同,但它不包含任何健康检查元数据。现在更新hello-world服务以使用这个版本:

docker service update \ --update-failure-action rollback \ --image dockerinaction/ch11_service_hw:no-health \ hello-world

部署这个版本后,你应该能够再次运行docker container ps并看到容器不再被标记为healthy。没有健康检查元数据,Docker 无法确定服务是否健康。它只知道软件是否正在运行。接下来,更新服务以从命令行添加健康检查元数据:

`docker service update \

健康监控需要持续评估。这里指定的间隔告诉 Docker 多久检查一次每个服务实例的健康状况。当你运行这个命令时,你可以验证服务副本再次被标记为健康。

今天你还可以告诉 Docker 在报告不健康状态之前,健康检查应该重试多少次,启动延迟以及运行健康检查命令的超时时间。这些参数有助于调整行为以适应大多数情况。有时,服务默认或当前的健康检查不适合你的使用方式。在这些情况下,你可以使用--no-healthcheck标志创建或更新一个禁用健康检查的服务。

在部署过程中,一个新的容器可能无法启动。或者它可能启动了,但并不完全正确(不健康)。但你是如何定义服务健康的呢?时间问题可能会模糊这些定义。你应该等待多长时间才能让实例变得健康?一些但不是所有的服务副本可能会失败或处于不健康状态。你的服务可以容忍多少或多少比例的副本部署失败?一旦你能回答这些问题,你就可以告诉 Docker 那些阈值,并调整从你的应用程序到编排器的健康信号。在此期间,你可以自由地删除hello-world服务:

docker service rm hello-world

当你在命令行管理服务时,设置所有这些参数会变得一团糟。当你管理多个服务时,情况可能会变得更糟。在下一节中,你将学习如何使用 Docker 提供的声明性工具,使事情变得更加易于管理。

11.2. 使用 COMPOSE V3 的声明式服务环境

到目前为止,你一直在使用 Docker 命令行单独创建、更改、删除或与容器、镜像、网络和卷交互。我们说这样的系统遵循命令式模式。命令式风格的工具执行用户发出的命令。这些命令可能检索特定信息或描述特定更改。编程语言和命令行工具遵循命令式模式。

命令式工具的好处是它们使用户能够使用原始命令来描述更复杂的工作流程和系统。但命令必须严格按照精确的顺序执行,以确保它们对工作状态有独占控制。如果另一个用户或进程同时更改系统的状态,这两个用户可能会做出无法检测到的冲突更改。

命令式系统有几个问题。用户必须仔细规划和排序所有必要的命令以实现目标,这对用户来说是一项负担。这些计划通常很难审计或测试。在部署之前,时间或共享状态问题难以发现和测试。而且,正如大多数程序员会告诉你的,即使是微小的或无害的错误也可能彻底改变结果。

想象一下,你负责构建和维护一个包含 10、100 或 1000 个逻辑服务的系统,每个服务都有自己的状态、网络连接和资源需求。现在想象一下,你正在使用原始容器来管理这些服务的副本。与 Docker 服务相比,管理原始容器将困难得多。

Docker 服务是声明式抽象,如图 11.6 所示。图 11.6。当我们创建一个服务时,我们声明我们想要该服务的特定数量的副本,Docker 负责维护它们的单个命令。声明式工具使用户能够描述系统的新的状态,而不是描述从当前状态到新状态的步骤。

图 11.6. 声明式处理循环

图片

Swarm 编排系统是一个状态协调循环,它持续比较用户期望的系统声明状态与系统的当前状态。当它检测到差异时,它使用一组简单的规则来改变系统,使其与期望状态相匹配。

声明式工具解决了命令式模式的问题。声明式接口通过限制系统操作的方式简化了系统。这使得声明性策略可以通过一个或几个经过良好测试的引擎实现,这些引擎可以将系统收敛到声明状态。它们更容易编写、审计和理解。声明性语句或文档与版本控制系统完美搭配,因为它们允许我们有效地版本控制所描述的系统状态。

命令式和声明式工具并不相互竞争。你几乎永远不会单独使用其中之一。例如,当我们创建或更新 Docker 服务时,我们正在使用命令式工具来描述对系统的更改。发出docker service create命令是命令式的,但它创建了一个声明式抽象。它汇总了创建和删除容器、评估健康状态、拉取镜像、管理服务发现和网络路由等一系列低级管理命令。

随着你构建更复杂的系统,包括服务、卷、网络和配置,你需要达到目标所需的命令行数量将变成一个新的负担。当这种情况发生时,是时候采用更高层次的声明性抽象了。在这种情况下,这个抽象是一个栈或完整的环境,正如 Docker Compose 所描述的那样。

当你需要模拟整个服务环境时,你应该使用 Docker 栈。栈描述了服务、卷、网络和其他配置抽象的集合。docker命令行提供了部署、删除和检查栈的命令行指令。栈是从整个环境的声明性描述中创建的。这些环境使用 Docker Compose V3 文件格式进行描述。一个描述了之前提到的hello-world服务的环境的 Compose 文件可能看起来像以下这样:

version: "3.7" services: hello-world: image: dockerinaction/ch11_service_hw:v1 ports: - 8080:80 deploy: replicas: 3

Compose 文件使用另一种标记语言(YAML)。并非每个人都熟悉 YAML,这可能会成为采用这一代基础设施和工作负载管理工具中的一些工具的障碍。好消息是,人们很少使用 YAML 的特异功能。大多数人坚持使用基础功能。

本章不是对 Compose 或你将用于管理服务的属性的全面调查。官方 Docker 文档应该起到这个作用。你可以在 Compose 中找到每个命令行功能的镜像。下一节是 YAML 和 Compose 文件的简要入门。

11.2.1. YAML 入门

YAML 用于描述结构化文档,这些文档由结构、列表、映射和标量值组成。这些功能定义为连续的块,其中子结构通过嵌套的块定义以大多数高级语言程序员熟悉的方式定义。

YAML 文档的默认作用域是一个单独的文件或流。YAML 提供了一种机制来指定同一文件中的多个文档,但 Docker 将只使用它在 Compose 文件中遇到的第一个文档。Compose 文件的标准文件名是 docker-compose.yml。

注释支持是今天选择 YAML 而不是 JSON 的最受欢迎的原因之一。YAML 文档可以在任何行的末尾包含注释。注释由一个空格后跟一个井号(#)标记。解析器会忽略直到行尾的任何字符。元素之间的空行对文档结构没有影响。

YAML 使用三种类型的数据和两种描述数据的方式,即块和流。流集合的指定方式与 JavaScript 和其他语言中的集合字面量类似。例如,以下是一个流风格中的字符串列表:

["PersonA","PersonB"]

块样式更为常见,除了特别说明外,本指南将使用块样式。三种数据类型是映射、列表和标量值。

映射由一组唯一的属性定义,这些属性以冒号和空格(: )分隔的键/值对形式存在。而属性名必须是字符串值,属性值可以是 YAML 数据类型中的任何一种,但不能是文档。单个结构不能为同一属性有多个定义。考虑以下块样式示例:

image: "alpine" command: echo hello world

此文档包含一个具有两个属性的单个映射:imagecommandimage 属性有一个标量字符串值,"alpine"command 属性有一个标量字符串值,echo hello world。标量是一个单一值。前面的示例演示了三种流标量样式中的两种。

前面的示例中 image 的值以双引号样式指定,这种样式能够通过使用 \ 转义序列表达任意字符串。大多数程序员都熟悉这种字符串样式。

命令的值以纯样式编写。纯样式(未引用)没有标识符,也不提供任何形式的转义。因此,它是最易读的、最有限的和最上下文相关的样式。对于纯样式标量使用了一组规则。纯样式标量

  • 必须不为空

  • 必须不包含前导或尾随空白字符

  • 不应在会造成歧义的地方以指示字符(例如,-:)开头

  • 必须不得包含使用冒号(:)和井号(#)的字符组合

列表(或块序列)是一系列节点,每个元素由一个前导连字符(-)指示。例如:

- item 1 - item 2 - item 3 - # 一个空项 - item 4

最后,YAML 使用缩进来表示内容作用域。作用域决定了每个元素属于哪个块。有一些规则:

  • 只能使用空格进行缩进。

  • 缩进量不重要,只要

    • 所有同级元素(在同一作用域内)必须有相同的缩进量。

    • 任何子元素都进一步缩进。

这些文档是等效的:

top-level:   second-level:          # 三空格   third-level:         # 再多两个空格   - "list item"       # 此列表项额外缩进一个空格   another-third-level: # 与相同两个空格的第三级同级   fourth-level: "string scalar" # 再多六个空格   another-second-level:   # 有三个空格的二级同级   - a list item   - a peer item # 此范围内的列表项有   # 15 个总前导空格   - a peer item # 列表中有空格的同级列表项 --- # 每个作用域级别增加正好一个空格   top-level: second-level:   third-level:   - "list item"   another-third-level:   fourth-level: "string scalar"   another-second-level:   - a list item   - a peer item

YAML 1.2 的完整规范可在 yaml.org/spec/1.2/2009-07-21/spec.html 找到,并且相当易于阅读。掌握了 YAML 的基本理解,你就可以开始使用 Compose 进行基本的环境建模了。

11.2.2. 使用 Compose V3 的服务集合

Compose 文件描述了每个 Docker 一级资源类型:服务、卷、网络、机密和配置。考虑一个包含三个服务的集合:一个 PostgreSQL 数据库、一个 MariaDB 数据库以及用于管理这些数据库的 Web 管理界面。你可能可以用以下 Compose 文件来表示这些服务:

version: "3.7" services: postgres: image: dockerinaction/postgres:11-alpine environment: POSTGRES_PASSWORD: example mariadb: image: dockerinaction/mariadb:10-bionic environment: MYSQL_ROOT_PASSWORD: example adminer: image: dockerinaction/adminer:4 ports: - 8080:8080

请记住,这份文档是 YAML 格式的,所有具有相同缩进的属性都属于同一个映射。这个 Compose 文件有两个顶级属性:versionservicesversion 属性告诉 Compose 解释器预期哪些字段和结构。services 属性是一个服务名称到服务定义的映射。每个服务定义都是一个属性映射。

在这种情况下,services 映射有三个条目,键为:postgresmariadbadminer。每个条目都通过使用一组小的服务属性(如 imageenvironmentports)定义了一个服务。声明性文档使得具体指定完整的服务定义变得简单。这样做将减少对默认值的隐式依赖,并减少对团队成员的教育负担。省略属性将使用默认值(就像使用命令行界面一样)。这些服务各自定义了容器的 imagepostgresmariadb 服务指定了 environment 变量。adminer 服务使用 ports 将请求路由到主机上的端口 8080,到服务容器中的端口 8080。

创建和更新堆栈

现在请使用这个 Compose 文件创建一个堆栈。记住,Docker 堆栈是一组命名服务、卷、网络、机密和配置的集合。docker stack 子命令管理堆栈。在一个空目录中创建一个名为 databases.yml 的新文件。编辑该文件并添加前面的 Compose 文件内容。通过以下命令创建一个新的堆栈并部署其描述的服务:

docker stack deploy -c databases.yml my-databases

当你运行这个命令时,Docker 将显示如下输出:

Creating network my-databases_default Creating service my-databases_postgres Creating service my-databases_mariadb Creating service my-databases_adminer

到此为止,您可以通过使用浏览器导航到 http://localhost:8080 来测试服务。服务在其 Docker 网络中的名称是可发现的。当您使用 adminer 接口连接到您的 postgresmariadb 服务时,请记住这一点。docker stack deploy 子命令用于创建和更新栈。它始终需要表示栈所需状态的 Compose 文件。每当您使用的 Compose 文件与当前栈中使用的定义不同时,Docker 将确定这两个定义之间的差异,并做出相应的更改。

您可以亲自尝试。告诉 Docker 创建 adminer 服务的三个副本。在 adminer 服务的 deploy 属性下指定 replicas 属性。您的 databases.yml 文件应该看起来像这样:

version: "3.7" services: postgres: image: dockerinaction/postgres:11-alpine environment: POSTGRES_PASSWORD: example mariadb: image: dockerinaction/mariadb:10-bionic environment: MYSQL_ROOT_PASSWORD: example adminer: image: dockerinaction/adminer:4 ports: - 8080:8080 deploy: replicas: 3

在您更新了 Compose 文件之后,请重复之前的 docker stack deploy 命令:

docker stack deploy -c databases.yml my-databases

这次,命令将显示一条消息,指出服务正在更新,而不是创建:

Updating service my-databases_mariadb (id: lpvun5ncnleb6mhqj8bbphsf6) Updating service my-databases_adminer (id: i2gatqudz9pdsaoux7auaiicm) Updating service my-databases_postgres (id: eejvkaqgbbl35glatt977m65a)

消息似乎表明所有服务都在被更改。但实际上并非如此。您可以使用 docker stack ps 列出所有任务及其年龄:

docker stack ps --format '{{.Name}}\t{{.CurrentState}}' my-databases 1

  • 1 指定要列出的栈

此命令应筛选出有趣的列并报告如下:

my-databases_mariadb.1  Running 3 minutes ago my-databases_postgres.1  Running 3 minutes ago my-databases_adminer.1  Running 3 minutes ago my-databases_adminer.2  Running about a minute ago my-databases_adminer.3  Running about a minute ago

此视图暗示在新部署过程中没有触及到任何原始服务容器。唯一的新任务是那些根据当前版本的 databases.yml 文件描述的系统状态所需的 adminer 服务的额外副本。您应该注意,当多个副本运行时,adminer 服务实际上并不工作得很好。我们在这里仅使用它进行说明。

缩小规模和删除服务

当你使用 Docker 和 Compose 时,在重新部署或进行更改时,你永远不需要拆解或以其他方式移除整个堆栈。让 Docker 来处理并为你处理更改。当你处理如 Compose: deletion 这样的声明性表示时,只有一个情况比较难以处理。

当你缩小服务规模时,Docker 会自动删除服务副本。它总是选择编号最高的副本进行删除。例如,如果你有 adminer 服务的三个副本正在运行,它们将被命名为 my-databases_adminer.1my-databases_adminer.2my-databases_adminer.3。如果你将规模缩小到两个副本,Docker 将删除名为 my-databases_adminer.3 的副本。当你尝试删除整个服务时,事情会变得奇怪。

编辑 databases.yml 文件以删除 mariadb 服务定义并将 adminer 服务设置为两个副本。文件应如下所示:

version: "3.7" services: postgres: image: dockerinaction/postgres:11-alpine environment: POSTGRES_PASSWORD: example adminer: image: dockerinaction/adminer:4 ports: - 8080:8080 deploy: replicas: 2

现在当你运行 docker stack deploy -c databases.yml my-databases 命令时,该命令将生成如下输出:

Updating service my-databases_postgres (id: lpvun5ncnleb6mhqj8bbphsf6) Updating service my-databases_adminer  (id: i2gatqudz9pdsaoux7auaiicm)

你提供给 stack deploy 命令的 Compose 文件没有对 mariadb 服务进行任何引用,因此 Docker 没有对该服务进行任何更改。当你再次列出堆栈中的任务时,你会注意到 mariadb 服务仍在运行:

docker stack ps \ --format '{{.Name}}\t{{.CurrentState}}' \ my-databases 1

  • 1 指定要列出哪个堆栈

执行此命令将产生以下输出:

my-databases_mariadb.1  Running 7 minutes ago my-databases_postgres.1 Running 7 minutes ago my-databases_adminer.1  Running 7 minutes ago my-databases_adminer.2  Running 5 minutes ago

你可以看到 adminer 服务的第三个副本已被移除,但 mariadb 服务仍在运行。这正如预期的那样。可以使用多个 Compose 文件创建和管理 Docker 堆栈。但这样做会创建多个错误机会,并且不推荐这样做。删除服务或其他对象有两种方式。

你可以使用 docker service remove 手动删除一个服务。这可以工作,但不会获得与声明性表示一起工作的任何好处。如果此更改是手动进行的,并且没有反映在你的 Compose 文件中,则下一次 docker stack deploy 操作将再次创建该服务。在堆栈中删除服务的最干净的方法是从你的 Compose 文件中删除服务定义,然后使用 --prune 标志执行 docker stack deploy。在不进一步更改你的 databases.yml 文件的情况下,运行以下命令:

docker stack deploy \   -c databases.yml \   --prune \   my-databases

此命令将报告由 databases.yml 描述的服务已更新,但也会报告 my-databases_mariadb 已被删除。当您再次列出任务时,您将看到这种情况:

my-databases_postgres.1 运行 8 分钟前 my-databases_adminer.1  运行 8 分钟前 my-databases_adminer.2  运行 6 分钟前

--prune 标志将清理堆栈中任何在用于部署操作的 Compose 文件中未明确引用的资源。因此,保留一个代表整个环境的 Compose 文件非常重要。否则,您可能会意外删除不存在的服务或卷、网络、机密和配置。

11.3. 状态化服务与数据保留

您一直在使用的 Docker 网络包括一个数据库服务。在 第四章 中,您学习了如何使用卷将容器的生命周期与其使用的数据的生命周期分开。这对于数据库来说尤为重要。如前所述,每次容器被替换(无论出于何种原因)时,堆栈将为 postgres 服务创建一个新的卷,并为每个副本创建一个新的卷。这将在实际系统中引起问题,因为存储的数据是服务身份的重要组成部分。

解决这个问题的最佳方式是通过在 Compose 中建模卷。Compose 文件使用另一个顶级属性名为 volumes。与 services 类似,volumes 是一个卷定义的映射;键是卷的名称,值是定义卷属性的结构的值。您不需要为每个卷属性指定值。Docker 将使用默认值来处理省略的属性值。顶级属性定义了文件内服务可以使用的卷。在服务中使用卷需要您指定需要它的服务的依赖关系。

Compose 服务定义可以包括一个 volumes 属性。该属性是短或长卷规范的列表。它们分别对应于 Docker 命令行支持的卷和挂载语法。我们将使用长格式来增强 databases.yml 并添加一个用于存储 postgres 数据的卷:

version: "3.7" volumes:     pgdata: # 空定义使用卷默认值 services:     postgres:         image: dockerinaction/postgres:11-alpine         volumes:             - type: volume               source: pgdata # 上面的命名卷               target: /var/lib/postgresql/data         environment:             POSTGRES_PASSWORD: example     adminer:         image: dockerinaction/adminer:4         ports:             - 8080:8080         deploy:             replicas: 1 # 缩小到 1 个副本以便测试

在这个例子中,该文件定义了一个名为pgdata的卷,postgres服务将此卷挂载到/var/lib/postgresql/data。该位置是 PostgreSQL 软件将存储任何数据库模式或数据的地方。部署栈并检查结果:

docker stack deploy \ -c databases.yml \ --prune \ my-databases

在应用更改后运行docker volume ls以验证操作是否成功:

DRIVER        VOLUME NAME local         my-databases_pgdata

栈的名称作为任何为其创建的资源的前缀,例如服务或卷。在这种情况下,Docker 使用前缀my-databases创建了名为pgdata的卷。你可以花时间检查服务配置或容器,但进行功能测试更有趣。

打开 http://localhost:8080,并使用adminer界面管理postgres数据库。选择postgresql驱动程序,使用postgres作为主机名,postgres作为用户名,example作为密码。登录后,创建一些表或插入一些数据。完成后,删除postgres服务:

docker service remove my-databases_postgres

然后使用 Compose 文件恢复服务:

docker stack deploy \ -c databases.yml \ --prune \ my-databases

因为数据存储在卷中,Docker 能够将新的数据库副本附加到原始的pg-data卷上。如果数据没有存储在卷中,而只存在于原始副本中,那么在服务被移除时,数据就会丢失。再次通过adminer界面登录数据库(记住用户名是postgres,密码是example,如 Compose 文件中指定)。检查数据库并查找你做出的更改。如果你正确地遵循了这些步骤,那些更改和数据将会可用。

这个例子使用了两个级别的命名间接,使得它有点难以理解。你的浏览器指向 localhost,加载adminer服务,但你告诉adminer通过postgres主机名访问数据库。下一节将解释这种间接性,并描述内置的 Docker 网络增强功能以及如何与服务一起使用它们。

11.4. 使用 COMPOSE 进行负载均衡、服务发现和网络

在通过网页浏览器访问adminer界面时,你正在访问adminer服务上发布的端口。服务的端口发布与在容器上发布端口不同。容器直接将主机接口上的端口映射到特定容器的接口,而服务可能由许多副本容器组成。

容器网络 DNS 面临类似的挑战。当你解析具有名称的容器的网络地址时,你会得到该容器的地址。但服务可能有副本。

Docker 通过创建虚拟 IP(VIP)地址并在所有相关副本之间平衡特定服务的请求来适应服务。当连接到 Docker 网络的应用程序查找连接到该网络的另一个服务的名称时,Docker 的内置 DNS 解析器将响应该服务在网络上的虚拟 IP。图 11.7说明了服务名称和 IP 解析的逻辑流程。

图 11.7. Docker 网络拓扑,服务虚拟 IP 地址和负载均衡

同样,当请求进入主机接口以发布端口或来自内部服务时,它将被路由到目标服务的虚拟 IP 地址。从那里,它被转发到服务副本之一。这意味着还有更多关于 Docker 网络的知识需要理解。当你使用服务时,你至少在使用两个 Docker 网络。

第一个网络命名为ingress,处理从主机接口到服务的所有端口转发。当你以集群模式初始化 Docker 时,它会创建这个网络。在这个堆栈中,只有一个服务具有转发端口,即adminer。检查ingress网络时,你可以清楚地看到相关的接口:

"容器": { "6f64f8aec8c2...": { "名称": "my-databases_adminer.1.leijm5mpoz8o3lf4yxd7khnqn", "端点 ID": "9401eca40941...", "MAC 地址": "02:42:0a:ff:00:22", "IPv4 地址": "10.255.0.34/16", "IPv6 地址": "" }, "ingress-sbox": { "名称": "ingress-endpoint", "端点 ID": "36c9b1b2d807...", "MAC 地址": "02:42:0a:ff:00:02", "IPv4 地址": "10.255.0.2/16", "IPv6 地址": "" } }

每个使用端口转发的服务都将在这个网络中有一个接口;ingress对于 Docker 服务功能至关重要。

第二个网络在堆栈中的所有服务之间共享。你用来创建my-databases堆栈的 Compose 文件没有定义任何网络,但如果你在初始部署期间仔细观察,你会看到 Docker 会为你的堆栈创建一个名为default的网络。默认情况下,堆栈中的所有服务都将连接到这个网络,并且所有服务之间的通信将通过这个网络进行。当你检查这个网络时,你会看到如下三个条目:

`"Containers": {

顶部两个接口由运行 postgresadminer 服务的单个容器使用。如果您将 postgres 服务名称解析为 IP 地址,您可能会期望得到 10.0.5.14 的地址。但您会得到这里未列出的其他地址。这里列出的地址是容器地址,或内部负载均衡器将请求转发到的地址。您会得到的地址在 postgres 服务规范中的端点下列出。当您运行 docker service inspect my-databases_postgres 时,部分结果将类似于以下内容:

`"Endpoint": {

那个虚拟 IP 地址由 Docker 内部负载均衡器处理。连接到该地址的连接将被转发到 postgres 服务的一个副本。

您可以使用 Compose 更改服务网络连接或 Docker 为堆栈创建的网络。Compose 可以创建具有特定名称、类型、驱动程序选项或其他属性的网络。与网络一起工作类似于卷。它分为两部分:一个包含网络定义的顶级 networks 属性,以及服务的 networks 属性,其中您描述连接。考虑以下最终示例:

`version: "3.7" networks:

此示例将 my-databases_default 网络替换为名为 foo 的网络。这两个配置在功能上是等效的。

有几种场合可以使用 Compose 来建模网络。例如,如果你管理多个堆栈并且想在共享网络上进行通信,你将声明该网络,如前所述,但不是指定驱动程序,而是使用external: true属性和网络名称。或者假设你有多个相关的服务组,但这些服务应该独立运行。你将在顶级定义网络,并使用不同的网络附加来隔离这些组。

摘要

本章介绍了更高层次的 Docker 抽象以及使用 Docker 声明性工具,以及使用 Compose 来建模多服务应用程序的期望状态。Compose 和声明性工具减轻了与命令行管理容器相关的许多繁琐工作。本章涵盖了以下内容:

  • 任何必须通过网络可发现和可用的过程、功能或数据都是一项服务。

  • 管理器如 Swarm 跟踪并自动协调用户提供的期望状态和 Docker 对象(包括服务、卷和网络)的当前状态。

  • 管理器自动化服务的复制、恢复、部署、健康检查和回滚。

  • 期望状态是用户希望系统执行的操作,或者它应该执行的操作。人们可以通过使用 Compose 文件以声明性方式描述期望状态。

  • Compose 文件是有结构的文档,以 YAML 表示。

  • 使用 Compose 的声明性环境描述可以启用环境版本控制、共享、迭代和一致性。

  • Compose 可以建模服务、卷和网络。有关其功能的完整描述,请参阅官方的 Compose 文件参考材料。

第十二章. 首要级的配置抽象

本章涵盖了

  • 配置和秘密解决的问题及其形式

  • 建模和解决 Docker 服务的配置问题

  • 将秘密传递给应用程序的挑战

  • 建模和将秘密传递给 Docker 服务

  • 在 Docker 服务中使用配置和秘密的方法

应用程序通常在多个环境中运行,必须适应这些环境中的不同条件。你可以在本地运行应用程序,在集成有协作应用程序和数据源的开发环境中进行测试,最后在生产环境中运行。也许你会为每个客户部署一个应用程序实例,以便隔离或专门化每个客户的体验。每个部署的适应性和专业化通常通过配置来表示。配置是应用程序解释的数据,以适应其行为以支持用例。

配置数据的常见示例包括以下内容:

  • 可启用或禁用的功能

  • 应用程序所依赖的服务位置

  • 内部应用资源及活动的限制,例如数据库连接池大小和连接超时时间

本章将向您展示如何使用 Docker 的配置和秘密资源来根据不同的部署需求调整 Docker 服务部署。Docker 将使用一等资源来建模配置和秘密,以根据部署到的环境部署具有不同行为和功能的服务。您将看到命名配置资源的简单方法是有问题的,并了解解决该问题的模式。最后,您将学习如何使用 Docker 服务安全地管理和使用秘密。有了这些知识,您将部署一个示例 Web 应用程序,该应用程序使用 HTTPS 监听器,并使用管理的 TLS 证书。

12.1. 配置分布与管理

大多数应用程序作者不希望每次需要更改应用程序的行为时都修改程序源代码并重新构建应用程序。相反,他们编写应用程序以在启动时读取配置数据,并在运行时相应地调整其行为。

在开始时,可能可以通过命令行标志或环境变量来表示这种变化。随着程序配置需求的增长,许多实现转向基于文件解决方案。这些配置文件可以帮助你表达更多的配置和更复杂的结构。应用程序可能从 ini、属性、JSON、YAML、TOML 或其他格式的文件中读取其配置。应用程序也可能从网络上的某个配置服务器读取配置。许多应用程序使用多种策略来读取配置。例如,一个应用程序可能首先读取一个文件,然后将某些环境变量的值合并到一个组合配置中。

配置应用程序是一个长期存在的问题,有许多解决方案。Docker 直接支持这些应用程序配置模式中的几个。在我们讨论这一点之前,我们将探讨配置更改生命周期如何适应应用程序的更改生命周期。配置控制着广泛的程序行为,因此可能因许多原因而更改,如图 12.1 所示。

图 12.1. 应用程序更改的时间线

图片

让我们考察一些驱动配置变更的事件。配置可能会随着应用程序的增强而改变。例如,当开发者添加一个功能时,他们可能会使用功能标志来控制对该功能的访问。应用程序部署可能需要更新以响应应用程序范围之外的变化。例如,为服务依赖项配置的主机名可能需要从 cluster-blue 更新到 cluster-green。当然,应用程序可能在不更改配置的情况下更改代码。这些变更可能由应用程序内部或外部的原因引起。无论变更的原因是什么,应用程序的交付流程都必须安全地合并和部署这些变更。

Docker 服务在很大程度上依赖于配置资源,就像它依赖于包含应用程序的 Docker 镜像一样,如图 12.2 所示。如果缺少配置或密钥,应用程序可能无法启动或正常工作。此外,一旦应用程序表达了对配置资源的依赖,该依赖的存在必须是稳定的。如果应用程序依赖的配置在应用程序重启时消失,应用程序可能会崩溃或以意外的方式表现。如果配置内部的值意外更改,应用程序也可能崩溃。例如,更改配置文件内的条目名称或删除条目将破坏不知道如何读取该格式的应用程序。因此,配置的生命周期必须采用一种方案,以保留现有部署的向后兼容性。

图 12.2. 应用程序依赖于配置。

图片

如果你将软件和配置的变更分开到不同的管道中,这些管道之间将存在紧张关系。应用程序交付管道通常以应用程序为中心进行建模,假设所有变更都将通过应用程序的源代码库运行。由于配置可能会因与应用程序无关的原因而改变,并且应用程序通常不是为处理配置模型中破坏向后兼容性的变更而构建的,因此我们需要对配置变更进行建模、集成和排序,以避免破坏应用程序。在下一节中,我们将从应用程序中分离配置以解决部署变化问题。然后,在部署时我们将正确配置与服务关联。

12.2. 分离应用程序和配置

让我们解决一个需要调整部署到多个环境中的 Docker 服务配置的问题。我们的示例应用程序是一个 greetings 服务,它用不同的语言说“Hello World!”。这个服务的开发者希望当用户请求时,服务能够返回一个问候语。当母语者验证问候语的翻译准确无误时,它会被添加到 config.common.yml 文件中的问候语列表中:

greetings:   - 'Hello World!'   - 'Hola Mundo!'   - 'Hallo Welt!'

应用程序镜像构建使用 Dockerfile 中的COPY指令,将此通用配置资源填充到greetings应用程序镜像中。这是合适的,因为该文件在部署时没有变化或敏感数据。

服务还支持加载环境特定的问候语,除了标准问候语。这允许开发团队在每个三个环境中(dev、stage 和 prod)更改和测试显示的问候语。环境特定的问候语将配置在以环境命名的文件中;例如,config.dev.yml:

# config.dev.yml greetings:   - 'Orbis Terrarum salve!'   - 'Bonjour le monde!'

通用和环境特定的配置文件都必须作为文件存在于greetings服务容器文件系统中,如图 12.3 所示。

图 12.3. greetings服务通过文件支持通用和环境特定的配置。

因此,我们需要立即解决的问题是如何仅使用部署描述符将环境特定的配置文件放入容器中。请跟随这个示例,通过克隆并阅读github.com/dockerinaction/ch12_greetings.git源代码库中的文件。

greetings应用程序的部署使用 Docker Compose 应用程序格式定义,并作为堆栈部署到 Docker Swarm。这些概念在第十一章中介绍。在这个应用程序中,有三个 Compose 文件。所有环境通用的部署配置包含在 docker-compose.yml 中。还有针对开发和生产环境的环境特定 Compose 文件(例如,docker-compose.prod.yml)。环境特定的 Compose 文件定义了服务在这些部署中使用的额外配置和秘密资源。

这里是共享的部署描述符,docker-compose.yml:

version: '3.7' configs:   env_specific_config:     file: ./api/config/config.${DEPLOY_ENV:-prod}.yml 1 services:   api:       image: ${IMAGE_REPOSITORY:-dockerinaction/ch12_greetings}:api       ports:         - '8080:8080'         - '8443:8443'       user: '1000'       configs:         - source: env_specific_config           target: /config/config.${DEPLOY_ENV:-prod}.yml 2 uid: '1000'           gid: '1000'           mode: 0400 #默认是 0444 - 对所有用户只读       secrets: []       environment:         DEPLOY_ENV: ${DEPLOY_ENV:-prod}

  • 1 使用环境特定配置文件的内容定义一个配置资源

  • 2 将 env_specific_config 资源映射到容器中的文件

此 Compose 文件将 greetings 应用程序的环境特定配置文件加载到 api 服务的容器中。这补充了内置到应用程序镜像中的通用配置文件。DEPLOY_ENV 环境变量参数化此部署定义。此环境变量以两种方式使用。

首先,当 Docker 插值 DEPLOY_ENV 时,部署描述符将产生不同的部署定义。例如,当 DEPLOY_ENV 设置为 dev 时,Docker 将引用并加载 config.dev.yml。

其次,部署描述符中 DEPLOY_ENV 变量的值将通过环境变量定义传递给 greetings 服务。此环境变量向服务指示其运行的环境,使其能够执行诸如加载以环境命名的配置文件等操作。现在让我们来检查 Docker 配置资源以及如何管理环境特定配置文件。

12.2.1. 使用配置资源

Docker 配置资源是 Swarm 集群对象,部署作者可以使用它来存储应用程序所需的运行时数据。每个配置资源都有一个集群唯一的名称和最多 500 KB 的值。当 Docker 服务使用配置资源时,Swarm 将在服务容器的文件系统中挂载一个包含配置资源内容的文件。

顶层 configs 键定义了特定于此应用程序部署的 Docker 配置资源。此 configs 键定义了一个包含一个配置资源 env_specific_config 的映射:

configs: env_specific_config: file: ./api/config/config.${DEPLOY_ENV:-prod}.yml

当此堆栈部署时,Docker 将将 DEPLOY_ENV 变量的值插入到文件名中,读取该文件,并将其存储在 Swarm 集群内名为 env_specific_config 的配置资源中。

在部署中定义配置不会自动使服务访问它。要使服务访问配置,部署定义必须将其映射到服务自己的 configs 键下。配置映射可以自定义结果文件在服务容器文件系统中的位置、所有权和权限:

# ...省略... services: api: # ...省略... user: '1000' configs: - source: env_specific_config target: /config/config.${DEPLOY_ENV:-prod}.yml 1 uid: '1000' 2 gid: '1000' mode: 0400 3

  • 1 覆盖默认目标文件路径为 /env_specific_config

  • 2 覆盖默认的 uid 和 gid 为 0,以匹配具有 uid 1000 的服务用户

  • 3 覆盖默认文件模式 0444,对所有用户只读

在这个例子中,env_specific_config资源被映射到greetings服务容器中,并进行了几个调整。默认情况下,配置资源被挂载到容器文件系统中的/<config_name>;例如,/env_specific_config。此示例将env_specific_config映射到目标位置/config/config.$ {DEPLOY_ENV:-prod}.yml。因此,对于开发环境的部署,环境特定的配置文件将出现在/config/config.dev.yml。此配置文件的所有权设置为userid=1000groupid=1000。默认情况下,配置资源的文件属于用户 ID 和组 ID 0。文件权限也被限制为模式 0400。这意味着文件只能由文件所有者读取,而默认情况下文件所有者、组和其他用户都可以读取(0444)。

这些更改对于这个应用程序来说并非绝对必要,因为它是受我们控制的。该应用程序可以被实现为使用 Docker 的默认设置来工作。然而,其他应用程序可能没有这么灵活,并且可能有启动脚本,这些脚本以特定的方式工作,你不能更改。特别是,你可能需要控制配置文件名和所有权,以便适应想要以特定用户身份运行并从预定位置读取配置文件的程序。Docker 的服务配置资源映射允许你满足这些需求。如果需要,你甚至可以将单个配置资源映射到多个不同的服务定义中。

在配置资源和服务定义一起设置之后,让我们部署应用程序。

12.2.2. 部署应用程序

通过运行以下命令以开发环境配置部署greetings应用程序:

DEPLOY_ENV=dev docker stack deploy \ --compose-file docker-compose.yml greetings_dev

部署栈之后,你可以通过网页浏览器访问服务,地址为 http://localhost: 8080/,应该会看到一个欢迎信息,如下所示:

欢迎使用 Greetings API 服务器!容器 ID 为 642abc384de5,于 2019-04-16 00:24:37.0958326 +0000 UTC 响应。DEPLOY_ENV: dev 1

  • 1 从环境变量中读取值“dev”。

当您运行docker stack deploy时,Docker 会读取应用程序的特定环境配置文件,并将其作为配置资源存储在 Swarm 集群中。然后当api服务启动时,Swarm 在临时只读文件系统上创建这些文件的副本。即使您将文件模式设置为可写(例如,rw-rw-rw-),它也会被忽略。Docker 将这些文件挂载到配置中指定的目标位置。配置文件的目标位置几乎可以是任何地方,甚至可以是在包含应用程序镜像常规文件的目录内部。例如,greetings服务的通用配置文件(COPY到应用程序镜像中)和特定环境的配置文件(一个 Docker 配置资源)都可在/config 目录中找到。应用程序容器在启动时可以读取这些文件,并且这些文件在整个容器生命周期内都可用。

在启动时,greetings应用程序使用DEPLOY_ENV环境变量来计算特定环境的配置文件名称;例如,/config/config.dev.yml。然后应用程序读取其两个配置文件并合并问候语列表。您可以通过阅读源存储库中的 api/main.go 文件来了解greetings服务是如何做到这一点的。现在,导航到 http://localhost:8080/greeting 端点并发出几个请求。您应该会看到来自通用和特定环境的问候语的混合。例如:

Hello World! Orbis Terrarum salve! Hello World! Hallo Welt! Hola Mundo!

配置资源与配置镜像

您可能还记得在第十章中描述的“每个部署阶段配置镜像”模式。在该模式中,特定环境的配置被构建到一个作为容器运行的镜像中,并且该文件系统在运行时挂载到“真实”服务容器的文件系统上。Docker 配置资源自动化了大多数这种模式。使用配置资源会导致一个文件被挂载到服务任务容器中,并且无需创建和跟踪额外的镜像。Docker 配置资源还允许您轻松地将单个配置文件挂载到文件系统的任意位置。在使用容器镜像模式时,最好挂载整个目录,以避免混淆来自哪个镜像的文件。

在这两种方法中,您都希望使用唯一标识的配置或镜像名称。然而,在 Docker Compose 应用程序部署描述符中,可以使用变量替换来指定镜像名称,这避免了下一节将要讨论的资源命名问题。

到目前为止,我们通过 Docker Compose 部署定义的便利性来管理配置资源。在下一节中,我们将降低抽象级别,并使用docker命令行工具直接检查和管理配置资源。

12.2.3. 直接管理配置资源

docker config 命令提供了另一种管理配置资源的方式。config 命令有多个子命令用于创建、检查、列出和删除配置资源:分别是 createinspectlsrm。您可以使用这些命令直接管理 Docker Swarm 集群的配置资源。现在让我们来做这件事。

检查 greetings 服务的 env_specific_config 资源:

docker config inspect greetings_dev_env_specific_config 1

  • 1 Docker 会自动将配置资源前缀为 greetings_dev 栈名称。

这应该会产生类似于以下输出的结果:

[   {   "ID": "bconc1huvlzoix3z5xj0j16u1",   "Version": {   "Index": 2066   },   "CreatedAt": "2019-04-12T23:39:30.6328889Z",   "UpdatedAt": "2019-04-12T23:39:30.6328889Z",   "Spec": {   "Name": "greetings_dev_env_specific_config",   "Labels": {   "com.docker.stack.namespace": "greetings"   },   "Data":   "Z3JlZXRpbmdzOgogIC0gJ09yYmlzIFRlcnJhcnVtIHNhbHZlIScKICAtICdCb25qb3VyIGx   lIG1vbmRlIScK"   }   } ]

inspect 命令报告与配置资源及其值相关的元数据。配置的值以 Base64 编码的字符串形式返回在 Data 字段中。这些数据未加密,因此在此不提供机密性。Base64 编码仅便于在 Docker Swarm 集群内部传输和存储。当服务引用配置资源时,Swarm 将从集群的中央存储中检索这些数据并将其放置在服务任务文件系统上的一个文件中。

Docker 配置资源是不可变的,一旦创建后不能更新。docker config 命令仅支持 createrm 操作来管理集群的配置资源。如果您尝试使用相同的名称多次创建配置资源,Docker 将返回一个错误,表示资源已存在:

$ docker config create greetings_dev_env_specific_config \   api/config/config.dev.yml Error response from daemon: rpc error: code = AlreadyExists 图片 desc = config greetings_dev_env_specific_config already exists

同样,如果您更改源配置文件并尝试使用相同的配置资源名称重新部署堆栈,Docker 也会响应错误:

$ DEPLOY_ENV=dev docker stack deploy \   --compose-file docker-compose.yml greetings_dev failed to update config greetings_dev_env_specific_config: 图片 Error response from daemon: rpc error: code = InvalidArgument 图片 desc = only updates to Labels are allowed

您可以将 Docker 服务及其依赖关系可视化为一个有向图,就像图 12.4 中所示的一个简单图:

图 12.4. Docker 服务依赖于配置资源。

图片

Docker 正在尝试维护 Docker 服务与其依赖的配置资源之间的一组稳定依赖关系。如果 greetings_dev_env_specific_config 资源发生变化或被删除,新的 greetings_dev 服务任务可能无法启动。让我们看看 Docker 如何跟踪这些关系。

配置资源每个都通过一个唯一的 ConfigID 来标识。在这个例子中,greetings_dev_env_specific_config 通过 bconc1huvlzoix3z5xj0j16u1 来标识,这在 docker config inspect 命令的 greetings_dev_env_specific_config 输出中是可见的。这个相同的配置资源在 greetings 服务定义中通过其 ConfigID 来引用。

让我们通过使用 docker service inspect 命令来验证这一点。这个检查命令只打印 greetings 服务的配置资源引用:

docker service inspect \ --format '{{ json .Spec.TaskTemplate.ContainerSpec.Configs }}' \ greetings_dev_api

对于这个服务实例化,命令生成了以下内容:

[ { "File": { "Name": "/config/config.dev.yml", "UID": "1000", "GID": "1000", "Mode": 256 }, "ConfigID": "bconc1huvlzoix3z5xj0j16u1", "ConfigName": "greetings_dev_env_specific_config" } ]

有几点重要的事情需要指出。首先,ConfigID 引用了 greeetings_dev_env_specific_config 配置资源的唯一配置 ID,bconc1huvlzoix3z5xj0j16u1。其次,服务特定的目标文件配置已被包含在服务的定义中。请注意,如果名称已存在,则无法创建配置;如果在使用中,则无法删除配置。回想一下,docker config 命令没有提供更新子命令。这可能会让您想知道如何更新配置。图 12.5 展示了解决这个问题的方案。

图 12.5. 部署时复制

答案是您不需要更新 Docker 配置资源。相反,当配置文件发生变化时,部署过程应该创建一个具有不同名称的新资源,然后在服务部署中引用该名称。常见的做法是在配置资源名称后附加版本号。greetings 应用程序的部署定义可以定义一个 env_specific_config_v1 资源。当配置发生变化时,该配置可以存储在一个名为 env_specific_config_v2 的新配置资源中。服务可以通过更新配置引用到这个新的配置资源名称来采用这个新配置。

这种不可变配置资源的实现给自动化部署管道带来了挑战。这个问题在 GitHub issue moby/moby 35048 中进行了详细讨论。主要挑战是配置资源的名称不能直接在 YAML Docker Compose 部署定义格式中参数化。自动化部署过程可以通过在运行部署之前使用自定义脚本来替换唯一版本来解决这个问题。

例如,假设部署描述符定义了一个配置env_specific_config_vNNN。自动化构建过程可以搜索_vNNN字符序列,并将其替换为唯一的部署标识符。该标识符可以是部署作业的 ID 或版本控制系统中应用程序的版本。具有 ID 3 的部署作业可以将所有env_specific_config_vNNN实例重写为env_specific_config_v3

尝试这种配置资源版本控制方案。首先,向 config.dev.yml 文件中添加一些问候语。然后将 docker-compose.yml 中的env_specific_config资源重命名为env_specific_config_v2。确保更新顶级配置映射以及api服务配置列表中的键名。现在通过再次部署栈来更新应用程序。Docker 应该会打印一条消息,说明它正在创建env_specific_config_v2并更新服务。现在当你向问候语端点发出请求时,你应该看到你添加的问候语与响应混合在一起。

这种方法可能对某些人来说是可接受的,但有几个缺点。首先,从与版本控制不匹配的文件中部署资源可能对某些人来说不是起点。这个问题可以通过存档用于部署的文件副本来缓解。第二个问题是,这种方法将为每次部署创建一组配置资源,并且旧资源需要通过另一个过程清理。这个过程可以定期检查每个配置资源,以确定它是否在使用中,如果不使用则将其删除。

我们已经完成了greetings应用的开发部署。清理这些资源,并通过移除栈来避免与后续示例冲突:

docker stack rm greetings_dev

这就完成了对 Docker 配置及其集成到交付管道的介绍。接下来,我们将检查 Docker 对特殊类型配置的支持:机密信息。

12.3. 机密信息——一种特殊的配置

机密信息与配置非常相似,但有一个重要区别。机密信息的值很重要,通常非常有价值,因为它可以验证身份或保护数据。机密信息可能以密码、API 密钥或私有加密密钥的形式存在。如果这些机密信息泄露,有人可能能够执行未经授权的操作或访问数据。

存在另一个复杂问题。在 Docker 镜像或配置文件等工件中分发秘密,使得控制对这些秘密的访问成为一个广泛且困难的问题。分布链中的每一个点都需要有强大而有效的访问控制,以防止泄露。

大多数组织在尝试交付秘密而不通过正常的应用程序交付渠道暴露它们时都会放弃。这是因为交付管道通常有很多访问点,而这些点可能没有被设计或配置来确保数据的机密性。组织通过在安全的保险库中存储秘密并在应用程序交付的最后时刻使用专用工具注入它们来避免这些问题。这些工具使应用程序只能在运行时环境中访问其秘密。

图 12.6 展示了应用程序配置和秘密数据通过应用程序工件流动的过程。

图 12.6. 第一秘密问题

图片

如果一个应用程序在没有秘密信息(如密码凭证)的情况下启动,用于认证秘密保险库,那么保险库如何授权访问应用程序的秘密?它不能。这就是第一秘密问题。应用程序需要帮助启动信任链,以便它能够检索其秘密。幸运的是,Docker 的集群、服务和秘密管理设计解决了这个问题,如图 12.7 所示。

图 12.7. Docker Swarm 集群的信任链

图片

第一秘密问题的核心是身份认证。为了一个秘密保险库授权访问特定的秘密,它必须首先验证请求者的身份。幸运的是,Docker Swarm 包含一个安全的秘密保险库,并为你解决了信任初始化问题。Swarm 秘密保险库与集群的身份和安全管理功能紧密集成,这些功能使用安全的通信渠道。Docker 服务 ID 作为应用程序的身份。Docker Swarm 使用服务 ID 来确定服务任务应该访问哪些秘密。当你使用 Swarm 的保险库管理应用程序的秘密时,你可以确信只有拥有 Swarm 集群管理控制权的人或进程才能提供秘密的访问权限。

Docker 部署和运营服务的解决方案建立在强大且加密学上可靠的基础上。每个 Docker 服务都有一个身份,支持该服务的每个任务容器也是如此。所有这些任务都在具有独特身份的 Swarm 节点上运行。Docker 密钥管理功能建立在这样一个身份基础之上。每个任务都有一个与服务关联的身份。服务定义引用了服务和任务所需的密钥。由于服务定义只能由管理节点上的授权 Docker 用户修改,Swarm 知道哪些密钥服务被授权使用。然后 Swarm 可以将这些密钥传递给将运行服务任务的节点。

注意

Docker 集群技术实现了一个高级、安全的设计,以维护一个安全、高可用性和性能最优的控制平面。您可以在www.docker.com/blog/least-privilege-container-orchestration/了解更多关于这种设计以及 Docker 如何实现它的信息。

Docker 服务通过使用 Swarm 内置的身份管理功能来建立信任,而不是依赖于通过另一个通道传递的密钥来验证应用程序对其密钥的访问,从而解决了第一个密钥问题。

12.3.1. 使用 Docker 密钥

使用 Docker 密钥资源类似于使用 Docker 配置资源,只需进行一些调整。

再次强调,Docker 将密钥以文件形式提供给应用程序,这些文件挂载到容器特定的、内存中的只读 tmpfs 文件系统。默认情况下,密钥将被放置在容器的文件系统中的 /run/secrets 目录下。将密钥传递给应用程序的这种方法避免了将密钥作为环境变量提供时固有的多个泄露问题。

作为环境变量的密钥问题

使用环境变量作为密钥传输机制的最重要和常见问题如下:

  • 您无法将访问控制机制分配给环境变量。

  • 这意味着任何由应用程序执行的过程都可能访问这些环境变量。为了说明这一点,考虑一下,如果一个应用程序通过 ImageMagick 进行图像缩放,并且使用包含父应用程序密钥的环境中的不受信任输入执行缩放操作,这可能意味着什么。如果环境包含在知名位置(如云提供商常见的那样)的 API 密钥,这些密钥可能很容易被盗。一些语言和库可以帮助您准备一个安全的过程执行环境,但效果可能会有所不同。

  • 许多应用程序在接收到调试命令或崩溃时,会将其所有环境变量打印到标准输出。这意味着您可能会定期在日志中暴露密钥。

现在让我们看看如何告诉应用程序秘密或配置文件在容器中的放置位置;参见图 12.8。

图 12.8. 提供作为环境变量读取的秘密文件的位置

当应用程序从文件中读取秘密时,我们通常需要在启动时指定该文件的位置。解决此问题的常见模式是将包含秘密(如密码)的文件位置作为环境变量传递给应用程序。容器中秘密的位置不是敏感信息,并且只有运行在容器内的进程才能访问该文件,前提是文件权限允许。应用程序可以通过读取由环境变量指定的文件来加载秘密。这种模式也适用于传达配置文件的位置。

让我们通过一个例子来分析;我们将为greetings服务提供一个 TLS 证书和私钥,以便它可以启动一个安全的 HTTPS 监听器。我们将证书的私钥存储为 Docker 秘密,并将公钥作为配置。然后我们将这些资源提供给greeting服务的生产服务配置。最后,我们将通过环境变量指定文件的位置给greetings服务,以便它知道从哪里加载这些文件。

现在我们将部署一个配置为生产的greetings服务的新实例。部署命令与您之前运行的命令类似。然而,生产部署包括一个额外的--compose-file选项,用于将部署配置包含在docker-compose.prod.yml中。第二个更改是使用名称greetings_prod而不是greetings_dev来部署堆栈。

现在运行这个docker stack deploy命令:

DEPLOY_ENV=prod docker stack deploy --compose-file docker-compose.yml \ --compose-file docker-compose.prod.yml \ greetings_prod

您应该会看到一些输出:

Creating network greetings_prod_default Creating config greetings_prod_env_specific_config service api: secret not found: ch12_greetings-svc-prod-TLS_PRIVATE_KEY_V1

部署失败是因为找不到ch12_greetings-svc-prod-TLS_PRIVATE_KEY_V1秘密资源。让我们检查docker-compose.prod.yml文件并确定这是为什么。以下是该文件的内容:

version: '3.7' configs: ch12_greetings_svc-prod-TLS_CERT_V1:    external: true secrets: ch12_greetings-svc-prod-TLS_PRIVATE_KEY_V1:    external: true services: api: environment: CERT_PRIVATE_KEY_FILE: '/run/secrets/cert_private_key.pem' CERT_FILE: '/config/svc.crt' configs: - source: ch12_greetings_svc-prod-TLS_CERT_V1    target: /config/svc.crt    uid: '1000'    gid: '1000'    mode: 0400 secrets: - source: ch12_greetings-svc-prod-TLS_PRIVATE_KEY_V1    target: cert_private_key.pem    uid: '1000'    gid: '1000'    mode: 0400

存在一个顶层 secrets 键,定义了一个名为 ch12_greetings-svc-prod-TLS_PRIVATE_KEY_V1 的秘密,这与错误报告中报告的内容相同。秘密定义中有一个我们之前未见过的键,external: true。这意味着秘密的值不是由这个部署定义定义的,这容易导致泄露。相反,必须由 Swarm 集群管理员使用 docker CLI 创建 ch12_greetings-svc-prod-TLS_PRIVATE_KEY_V1 秘密。一旦在集群中定义了秘密,这个应用程序部署就可以引用它。

现在让我们通过运行以下命令来定义秘密:

$ cat api/config/insecure.key | \ docker secret create ch12_greetings-svc-prod-TLS_PRIVATE_KEY_V1 - vnyy0gr1a09be0vcfvvqogeoj

docker secret create 命令需要两个参数:秘密的名称和该秘密的值。值可以通过提供文件的路径来指定,或者使用 (连字符)字符来指示值将通过标准输入提供。这个 shell 命令通过将示例 TLS 证书的私钥 insecure.key 的内容打印到 docker secret create 命令中,展示了后者的形式。命令成功完成并打印了秘密的 ID:vnyy0gr1a09be0vcfvvqogeoj

警告

不要将此证书和私钥用于除了处理这些示例之外的其他任何用途。私钥没有被保密,因此不能有效地保护你的数据。

使用 docker secret inspect 命令来查看 Docker 创建的秘密资源的详细信息:

$ docker secret inspect ch12_greetings-svc-prod-TLS_PRIVATE_KEY_V1 [ { "ID": "vnyy0gr1a09be0vcfvvqogeoj", "Version": {    "Index": 2172 },    "CreatedAt": "2019-04-17T22:04:19.3078685Z",    "UpdatedAt": "2019-04-17T22:04:19.3078685Z",    "Spec": {    "Name": "ch12_greetings-svc-prod-TLS_PRIVATE_KEY_V1",    "Labels": {} } } ]

注意,与配置资源不同,这里没有Data字段。密钥的值无法通过 Docker 工具或 Docker Engine API 获取。密钥的值被 Docker Swarm 控制平面严格保护。一旦将密钥加载到 Swarm 中,就无法使用docker CLI 检索它。密钥仅对使用它的服务可用。您可能还会注意到,密钥的规范不包含任何标签,因为它是在堆栈范围之外管理的。

当 Docker 为greetings服务创建容器时,密钥将按照与我们之前描述的配置资源的过程几乎相同的方式映射到容器中。以下是docker-compose.prod.yml文件中的相关部分:

services: api: environment: CERT_PRIVATE_KEY_FILE: '/run/secrets/cert_private_key.pem' CERT_FILE: '/config/svc.crt' secrets: - source: ch12_greetings-svc-prod-TLS_PRIVATE_KEY_V1 target: cert_private_key.pem uid: '1000' gid: '1000' mode: 0400 # ... 省略 ...

ch12_greetings-svc-prod-TLS_PRIVATE_KEY_V1密钥将被映射到容器中,文件名为cert_private_key.pem。密钥文件的默认位置是/run/secrets/。此应用程序在其私钥和证书的位置中查找环境变量,因此这些变量也使用文件的完全限定路径定义。例如,CERT_PRIVATE_KEY_FILE环境变量的值设置为/run/secrets/cert_private_key.pem

生产greetings应用程序还依赖于ch12_greetings_svc-prod-TLS_CERT_V1配置资源。此配置资源包含greetings应用程序将用于提供 HTTPS 服务的公共、非敏感的 x.509 证书。x.509 证书的私钥和公钥一起更改,这就是为什么这些密钥和配置资源作为一对创建的原因。现在通过运行以下命令定义证书的配置资源:

$ docker config create \ ch12_greetings_svc-prod-TLS_CERT_V1 api/config/insecure.crt 5a1lybiyjnaseg0jlwj2s1v5m

docker config create命令与密钥创建命令类似。特别是,可以通过指定文件的路径来创建配置资源,就像我们在这里使用api/config/insecure.crt所做的那样。命令成功完成并打印了新的配置资源的唯一 ID,5a1lybiyjnaseg0jlwj2s1v5m

现在,重新运行部署命令:

$ DEPLOY_ENV=prod docker stack deploy \ --compose-file docker-compose.yml \ --compose-file docker-compose.prod.yml \ greetings_prod Creating service greetings_prod_api

这次尝试应该会成功。运行docker service ps greetings_prod_api并验证服务是否有一个正在运行的单个任务:

ID                NAME              IMAGE                     NODE DESIRED STATE        CURRENT STATE           ERROR               PORTS 93fgzy5lmarp        greetings_prod_api.1   dockerinaction/ch12_greetings:api docker-desktop       运行中             运行中 2 分钟前

现在生产栈已部署,我们可以检查服务的日志以查看是否找到了 TLS 证书和私钥:

docker service logs --since 1m greetings_prod_api

该命令将打印greetings服务应用程序的日志,其外观应如下所示:

初始化 prod 部署环境的 greetings api 服务器 将从'/run/secrets/cert_private_key.pem'读取 TLS 证书私钥 私钥证书中的字符数 3272 将从'/config/svc.crt'读取 TLS 证书 TLS 证书中的字符数 1960 从/config/config.common.yml 加载环境特定配置 从/config/config.prod.yml 加载环境特定配置 问候:[Hello World! Hola Mundo! Hallo Welt!] 初始化完成 在:8443 启动 https 监听器

的确,greetings应用程序在/run/secrets/cert_private_key.pem找到了私钥,并报告该文件包含 3,272 个字符。同样,证书有 1,960 个字符。最后,greetings应用程序报告它正在容器内部启动一个监听 8443 端口 HTTPS 流量的监听器。

使用网络浏览器打开 https://localhost:8443。示例证书不是由受信任的证书颁发机构签发的,因此你会收到一个警告。如果你通过那个警告,你应该会看到来自 greetings 服务的响应:

欢迎使用 Greetings API 服务器!ID 为 583a5837d629 的容器在 2019-04-17 22:35:07.3391735 +0000 UTC 响应 DEPLOY_ENV: prod

哇哦!现在 greetings 服务正在使用 Docker 的密钥管理设施提供的 TLS 证书通过 HTTPS 提供服务。你可以像以前一样在 https://localhost:8443/greeting 请求问候。注意,只提供了常见配置中的三个问候。这是因为应用程序针对 prod 环境的特定配置文件 config.prod.yml 没有添加任何问候。

greetings服务现在正在使用 Docker 支持的每种配置形式:包含在应用程序镜像中的文件、环境变量、配置资源和密钥资源。你也看到了如何结合使用所有这些方法,以安全的方式在多个环境中改变应用程序的行为。

摘要

本章描述了在部署时间而不是构建时间改变应用程序行为的核心挑战。我们探讨了如何使用 Docker 的配置抽象来模拟这种变化。示例应用程序展示了如何使用 Docker 的配置和秘密资源来改变其在不同环境中的行为。这最终导致了一个通过 https 提供流量并具有特定环境数据集的 Docker 服务。本章需要理解的关键点包括:

  • 应用程序通常必须适应它们部署到的环境。

  • Docker 配置和秘密资源可用于模拟和适应应用程序的各种部署需求。

  • 秘密是难以安全处理的一种特殊类型的配置数据。

  • Docker Swarm 建立信任链,并使用 Docker 服务身份来确保秘密被正确且安全地传递给应用程序。

  • Docker 将配置和秘密作为容器特定的tmpfs文件系统上的文件提供给服务,应用程序可以在启动时读取。

  • 部署流程必须使用命名方案为配置和秘密资源命名,以便自动化更新服务。

第十三章. 使用 Swarm 在 Docker 主机集群上编排服务

本章涵盖

  • Docker 应用程序部署的工作原理和选项

  • 将多层应用程序部署到 Docker Swarm

  • Swarm 如何尝试将 Docker 应用程序部署收敛到操作者声明的所需状态

  • Swarm 如何确保在声明的放置和资源约束内,集群中运行着所需数量的副本

  • 从集群节点到网络服务实例的请求流量路由以及协作服务如何使用 Docker 网络相互连接

  • 控制 Docker 服务容器在集群中的放置

13.1. 使用 Docker Swarm 进行集群

应用程序开发者和运维人员经常将服务部署到多个主机上,以实现更高的可用性和可伸缩性。当应用程序部署到多个主机上时,应用程序部署的冗余提供了容量,可以在主机失败或从服务中移除时处理请求。跨多个主机部署还允许应用程序使用比单个主机能提供的更多计算资源。

例如,假设你运行一个电子商务网站,该网站通常在单个主机上表现良好,但在大型促销活动期间(将峰值负载提高两倍)运行缓慢。这个网站可能从重新部署到三个主机中受益。然后你应该有足够的容量来处理峰值流量,即使一个主机失败或因升级而停机。在本章中,我们将向你展示如何使用 Swarm 在主机集群上模拟和部署一个 Web API。

Docker Swarm 提供了一个复杂的平台,用于在多个 Docker 主机上部署和运行容器化应用程序。Docker 的部署工具自动化了将新的 Docker 服务部署到集群或对现有服务进行更改的过程。服务配置更改可能包括在服务定义(docker-compose.yml)中声明的任何内容,例如镜像、容器命令、资源限制、暴露的端口、挂载和消耗的秘密。一旦部署,Swarm 将监督应用程序,以便检测和修复问题。此外,Swarm 将应用程序用户的请求路由到服务的容器中。

在本章中,我们将探讨 Docker Swarm 如何支持这些功能中的每一个。我们将基于第十一章和第十二章中探讨的服务、配置和秘密资源。我们还将利用你对 Docker 容器(第二章)、资源限制(第六章)和网络(第五章)的基本知识。

13.1.1. Docker Swarm 模式的介绍

Docker Swarm 是一种集群技术,它连接了一组运行 Docker 的主机,并允许你在这些机器上运行使用 Docker 服务构建的应用程序。Swarm 协调 Docker 服务在机器集合上的部署和运行。Swarm 根据应用程序的资源需求和机器能力来调度任务。Swarm 集群软件包含在 Docker Engine 和命令行工具中。你可以启用 Swarm 模式并开始使用 Swarm,而无需安装任何额外的组件。图 13.1 显示了 Docker Swarm 部署的组件如何相互关联以及集群的机器如何协作运行应用程序。

图 13.1. Swarm 集群部署

图片

当您将 Docker Engine 加入 Swarm 集群时,您指定该机器应该是管理器还是工作者。管理器监听创建、更改或删除实体(如 Docker 服务、配置和机密)定义的指令。管理器指示工作节点创建实现 Docker 服务实例的容器和卷。管理器持续地将集群收敛到您声明的状态。连接集群 Docker Engine 的控制平面描述了所需集群状态的通信以及实现该状态相关的事件。Docker 服务的客户端可以向集群中任何发布该服务的端口发送请求。Swarm 的网络网格将路由从接收请求的任何节点到可以处理该请求的健康服务容器的请求。Swarm 部署和管理轻量级、专用的负载均衡器和网络路由组件,以接收和传输每个发布端口的网络流量。第 13.3.1 节详细解释了 Swarm 网络网格。让我们部署一个集群来处理本章中的示例。

Swarm 集群可以部署在多种拓扑结构中。每个集群至少有一个管理器来保护集群状态并协调跨工作者的服务。Swarm 管理器需要大多数管理器可用,以便协调并记录对集群的更改。大多数生产级 Swarm 部署应具有三个或五个节点担任管理角色。增加管理器的数量将提高 Swarm 控制平面的可用性,但也会增加管理器确认集群更改所需的时间。有关权衡的详细说明,请参阅 Swarm 管理员指南(docs.docker.com/engine/swarm/admin_guide/)。Swarm 集群可以可靠地扩展到数百个工作节点。社区已经展示了单个 Swarm 集群包含数千个工作节点的测试(请参阅dzone.com/articles/docker-swarm-lessons-from-swarm3k上的 Swarm3K 项目)。

Swarm 是 Docker 提供的原生集群应用程序部署选项,很好地支持 Docker 应用程序模型。许多人会发现 Swarm 比其他容器集群技术更容易部署、使用和管理。您可能会发现为单个团队或项目部署小型 Swarm 集群很有用。您可以使用标签将大型 Swarm 集群划分为多个区域,然后通过使用调度约束将服务实例放置到适当的区域。您可以使用对组织有意义的元数据(如environment=devzone=private)对集群资源进行标记,以便集群的实际管理模型与您的术语相匹配。

13.1.2. 部署 Swarm 集群

您有很多选项可以从节点集群构建 swarm。本章的示例使用具有五个节点的 Swarm 集群,尽管大多数示例在单个节点上工作,例如 Docker for Mac。您可以按自己的喜好配置 Swarm 集群。由于配置选项种类繁多且变化迅速,我们建议您遵循最新的指南,在您喜欢的基础设施提供商上配置 Swarm 集群。许多人使用docker-machine在云提供商(如 DigitalOcean 和 Amazon Web Services)上部署测试集群。

本章中的示例使用 Play with Docker(labs.play-with-docker.com/)创建和测试。在 Play with Docker 网站上,您可以免费实验 Docker 并了解它。集群使用 Play with Docker 模板创建,该模板配置了三个管理节点和两个工作节点。您至少需要两个工作节点来完成本章的所有练习。

部署 Swarm 集群的一般过程如下:

  1. 部署至少三个已安装并运行 Docker Engine 的节点,最好是五个。

  2. 确保以下端口和协议之间允许机器之间的网络流量:

    1. TCP 端口 2377 用于集群管理通信

    2. TCP 和 UDP 端口 7946 用于节点间的通信

    3. UDP 端口 4789 用于覆盖网络流量

  3. 通过在管理节点上运行docker swarm init来初始化 Swarm 集群。

  4. 记录 Swarm 集群加入令牌或使用docker swarm join-token再次显示它们。

  5. 使用docker swarm join将管理节点和工作节点加入集群。

13.2. 将应用程序部署到 Swarm 集群

在本节中,我们将部署一个具有常见三层架构的示例 Web 应用程序。该应用程序具有一个无状态的 API 服务器,连接到 PostgreSQL 关系数据库。API 服务器和数据库都将作为 Docker 服务进行管理。数据库将使用 Docker 卷在重启之间持久化数据。API 服务器将通过一个私有、安全的网络与数据库通信。此应用程序将展示您在前面章节中学到的 Docker 资源如何转换为跨多个节点的部署。

13.2.1. 介绍 Docker Swarm 集群资源类型

Docker Swarm 支持本书中讨论的几乎所有概念,如图 13.2 所示。当使用 Swarm 时,这些资源在集群级别定义和管理。

图 13.2. Docker Swarm 资源类型

图片

Docker Swarm 的关键资源类型如下:

  • 服务—— Docker 服务定义了在 Swarm 集群节点上运行的应用程序进程。Swarm 管理器解释服务定义并在集群的管理节点和工作节点上创建任务。服务在第十一章中介绍。

  • 任务— 任务定义了一个 Swarm 将调度并运行一次直到完成的过程化容器。一个退出的任务可能会根据服务定义的重启策略被新的任务替换。任务还指定了对其他集群资源(如网络和机密)的依赖。

  • 网络— 应用可以使用 Docker overlay 网络在服务之间进行流量传输。Docker 网络开销低,因此你可以创建适合你所需安全模型的网络拓扑。第 13.3.2 节描述了 overlay 网络。

  • 卷— 卷为服务任务提供持久存储。这些卷绑定到单个节点。卷和挂载在第四章中描述。

  • 配置和机密— 配置和机密(第十二章)为集群上部署的服务提供特定环境的配置。

示例应用使用了这些 Docker 资源类型中的每一个。

13.2.2. 使用 Docker 服务定义应用及其依赖

本章我们将要处理的示例应用是一个简单的三层 Web 应用,包括负载均衡器、API 服务器和 PostgreSQL 数据库。我们将使用 Docker 来模拟这个应用,并将其部署到我们的 Swarm 集群中。从逻辑上讲,应用部署将看起来像图 13.3。

图 13.3. 示例应用的逻辑架构

图片

应用有一个 API 服务器,包含两个端点://counter。API 服务将一个端口发布到集群的边缘,该端口由 Swarm 的内置负载均衡器实现。对/端点的请求将返回处理请求的容器信息。/counter端点将在每次请求中增加一个整数。计数器的值存储在 PostgreSQL 数据库中。

让我们分步骤使用 Docker Compose 版本 3 格式定义应用,并综合前几章中介绍的概念。之后,我们将使用docker stack命令部署它。这个应用定义的完整内容可以在github.com/dockerinaction/ch13_multi_tier_app.git找到。`. 克隆这个仓库,随着应用的逐步解释来跟随。

应用使用了两个网络,一个公共网络处理来自外部客户端的请求,另一个是更受信任的私有网络。这些网络在docker-compose.yml应用描述符中如下描述:

version: '3.7' networks: public: driver: overlay driver_opts: encrypted: 'true' private: driver: overlay driver_opts: encrypted: 'true' attachable: true

注意

driver_opts的值true被引号包围,因为 Docker 需要一个字符串或数字。而attachable的值true没有被引号包围,因为 Docker 需要一个布尔值。

通过在应用程序描述符的顶级networks键中添加命名条目,定义了两个网络。构建此应用程序的团队有一个端到端加密的要求。应用程序定义通过加密应用程序使用的所有网络上的流量,满足了该要求的大部分。唯一剩下的工作是通过使用 TLS 来确保服务发布端口上的通信安全。第 13.3 节解释了为什么应用程序应该确保发布端口的安全,第十二章的greetings应用程序展示了实现这一点的其中一种方法。这突出了 Swarm 的一个有趣特性:通过在部署描述符中使用相对简单且可审计的配置,可以轻松满足许多传输加密要求。

接下来,数据库需要持久存储来保存其数据。我们在顶级volumes键下定义一个 Docker 卷:

volumes: db-data:

注意,此卷没有定义任何选项。Swarm 将使用 Docker 内置的local卷驱动程序来创建它。该卷将仅限于该 Swarm 节点,并且不会在其他地方复制、备份或共享。一些 Docker 卷插件可以创建和管理跨节点持久化和共享数据的卷;Docker Cloudstor 和 REX-Ray 插件是很好的例子。

在我们继续到服务定义之前,创建一个引用 API 将用于访问 PostgreSQL 数据库的密码。密码将在部署过程的最初步骤中将配置在 Swarm 集群中。添加一个顶级secrets键,指示从集群的秘密资源中检索密码:

secrets ch13_multi_tier_app-POSTGRES_PASSWORD: external: true 1

  • 1 从集群的秘密资源检索

现在我们已经准备好定义应用程序的服务。让我们从定义顶级services键下的postgres服务开始:

services: postgres: image: postgres:9.6.6 networks: - private volumes: - db-data:/var/lib/postgresql/data secrets: - source: ch13_multi_tier_app-POSTGRES_PASSWORD 1 target: POSTGRES_PASSWORD uid: '999' 2 gid: '999' mode: 0400 environment: POSTGRES_USER: 'exercise' POSTGRES_PASSWORD_FILE: '/run/secrets/POSTGRES_PASSWORD' POSTGRES_DB: 'exercise' deploy: replicas: 1 3 update_config: order: 'stop-first' rollback_config: order: 'stop-first' resources: limits: cpus: '1.00' memory: 50M reservations: cpus: '0.25' memory: 50M`

  • 1 从集群管理的秘密中注入 PostgreSQL 密码

  • 2 由容器管理的 postgres 用户(uid:999)需要读取该文件。

  • 3 通过限制副本数量为 1 并在第一次更新或回滚失败后停止,确保最多只有一个 PostgreSQL 实例

此数据库服务将使用官方的 PostgreSQL 镜像来启动数据库。该 PostgreSQL 容器将连接到(仅)private 网络,挂载 db-data 卷,并使用 POSTGRES_* 环境变量来初始化数据库。POSTGRES_DBPOSTGRES_USER 环境变量分别决定了数据库的名称和我们将用于访问数据库的用户。然而,您应该避免通过环境变量将密码等秘密信息提供给进程,因为这些信息很容易泄露。

一种更好的方式是从一个安全管理的文件中读取那个秘密。Docker 通过其秘密功能直接支持这一点。PostgreSQL 镜像也支持从文件中读取敏感数据,例如 POSTGRES_PASSWORD。对于此堆栈定义,Docker 将从集群的 ch13_multi_tier_app-POSTGRES_PASSWORD 秘密资源定义中检索 PostgreSQL 密码。Swarm 将秘密的值放置在容器中挂载的文件 /run/secrets/POSTGRES_PASSWORD 中。PostgreSQL 进程在启动时切换到用户 ID 为 999 的用户,因此秘密文件的拥有者被配置为可以被该用户读取。

注意

所有在 Docker 容器内部执行的过程都可以访问该容器的所有环境变量。然而,对文件中数据的访问是由文件权限控制的。所以 nobody 无法读取 $SECRET 环境变量,除非文件的所有权和权限允许 nobody 读取 /run/secrets/SECRET 文件。有关详细信息,请参阅第十二章,该章节详细探讨了 Docker 配置和秘密。

postgres 服务定义中看起来是否遗漏了什么?不清楚的一点是客户端将如何连接到数据库。

当使用 Docker overlay 网络时,连接到给定网络的客户端将能够通过任何端口相互通信。在连接到 Docker 网络的应用程序之间不存在防火墙。因为 PostgreSQL 默认监听端口 5432,且没有防火墙,所以连接到那个 private 网络的其他应用程序也将能够连接到该端口的 postgres 服务。

现在让我们在 services 键下添加一个 API 服务定义:

api:   image: ${IMAGE_REPOSITORY:-dockerinaction/ch13_multi_tier_app}:api   networks:   - public   - private   ports:   - '8080:80'   secrets:   - source: ch13_multi_tier_app-POSTGRES_PASSWORD   target: POSTGRES_PASSWORD   mode: 0400   environment:   POSTGRES_HOST: 'postgres'   POSTGRES_PORT: '5432'   POSTGRES_USER: 'exercise'   POSTGRES_DB: 'exercise'   POSTGRES_PASSWORD_FILE: '/run/secrets/POSTGRES_PASSWORD'   depends_on:   - postgres   deploy:   replicas: 2   restart_policy:   condition: on-failure   max_attempts: 10   delay: 5s   update_config:   parallelism: 1   delay: 5s   resources:   limits:   cpus: '0.50'   memory: 15M   reservations:   cpus: '0.25'   memory: 15M

API 服务器连接到publicprivate网络。API 服务器的客户端向集群的 8080 端口发送请求。Swarm 网络路由网格会将客户端请求从网络的边缘转发到一个任务,最终进入端口 80 上的 API 服务器容器。API 服务器连接到仅连接到private网络的 PostgreSQL。API 服务器配置为使用在POSTGRES_*环境变量中定义的信息连接到 PostgreSQL。

注意,PostgreSQL 用户的密码也通过 Docker 密钥提供给 API 服务器。与postgres服务一样,密钥被挂载到每个 API 服务容器中作为一个文件。尽管 API 服务使用从头构建的镜像,并且只包含静态的 Golang 二进制文件,但密钥挂载仍然有效,因为 Docker 为您管理底层的tmpfs文件系统挂载。Docker 会尽最大努力帮助您安全地管理和使用密钥。

API 服务定义的其余部分管理了 Swarm 如何部署服务的具体细节。depends_on键包含了一个 API 服务器所依赖的其他服务的列表——在这个例子中,是postgres。当我们部署堆栈时,Swarm 会在api服务之前启动postgres服务。deploy键声明了 Swarm 应该如何在集群中部署api服务。

在这个配置中,Swarm 将在集群中部署两个副本,并尝试保持这么多任务运行以支持服务。restart_policy决定了 Swarm 如何根据其健康检查处理服务任务退出或进入失败状态。

在这里,当任务启动失败时,Swarm 将重新启动任务。重启是一个误称,因为 Swarm 实际上会启动一个新的容器而不是重启失败的容器。Swarm 默认无限次数地重启服务任务。API 服务的配置在每次重启之间有 5 秒的延迟,最多重启任务 10 次。

服务作者应该仔细思考他们的重启策略,以确定 Swarm 启动服务所需的时间和尝试次数。首先,无限尝试通常是没有用的。其次,无限重试过程可能会耗尽集群资源,这些资源在启动新容器时被消耗,但清理不够快。

API 服务使用一个简单的update_config,该配置将服务的更新推出限制为一次一个任务。在这个配置中,Swarm 将通过关闭具有旧配置的任务,启动一个具有新配置的任务,并在新任务健康之前等待,来更新服务。延迟配置在任务替换操作之间引入了一个间隔,以保持集群和服务流量的稳定性,在推出过程中。

在第十一章中讨论了许多重启、更新和回滚配置的选项。你可以对这些选项进行微调,以补充应用程序的行为并创建一个健壮的部署过程。第十一章。

13.2.3. 部署应用程序

在本节中,我们将把我们定义的应用程序部署到 Swarm 集群中。我们将使用第十一章中介绍的docker stack命令来完成这项工作。图 13.4 显示了该命令如何与集群进行通信。

图 13.4. Docker 控制平面的通信路径

通过向 Swarm 集群的管理节点发出适当的docker命令来管理 Docker 服务、网络和其他 Swarm 资源。当你使用docker CLI 发出命令时,它将连接到 Docker Engine API 并请求更新 Swarm 集群的状态。Swarm 的领导者将编排所需的更改,以将集群上的实际应用程序资源收敛到所需状态。

如果你向工作节点发出 Docker 命令来管理集群或其资源,你将收到一个错误:

[worker1] $ docker node ls 错误响应来自守护进程:此节点不是 Swarm 管理器。工作节点不能用于查看或修改集群状态。请在管理节点上运行此命令或提升当前节点为管理器。

在你的集群中的任何管理节点上打开一个命令外壳。使用docker node ls列出集群的节点:

[manager1] $ docker node ls ID                            HOSTNAME            STATUS             AVAILABILITY        MANAGER STATUS      ENGINE VERSION 7baqi6gedujmycxwufj939r44 *   manager1            Ready Active              Reachable           18.06.1-ce bbqicrevqkfu8w4f9wli1tjcr     manager2            Ready Active              Leader              18.06.1-ce hdpskn4q93f5ou1whw9ht8y01     manager3            Ready Active              Reachable           18.06.1-ce xle0g72ydvj24sf40vnaw08n0     worker1             Ready Active                                  18.06.1-ce l6fkyzqglocnwc0y4va2anfho     worker2             Ready Active                                  18.06.1-ce [manager1] $

在前面的输出中,请注意命令是在名为manager1的节点上执行的。该节点正在运行管理角色,但不是当前集群的领导者。当向此节点发出集群管理命令时,它将被转发到领导者进行处理。

使用 Git 将应用程序克隆到管理节点,并切换到ch13_multi_tier_app目录:

git clone https://github.com/dockerinaction/ch13_multi_tier_app.git cd ch13_multi_tier_app

我们现在可以使用docker stack部署应用程序了。stack子命令可以部署以两种格式定义的应用程序。第一种格式是我们将使用的 Docker Compose 格式。第二种是较旧且不太受欢迎的分布式应用程序包(DAB)格式。因为我们使用的是 Docker Compose 格式,所以我们将使用--compose-file指定组合文件的路径。现在让我们将我们的组合应用程序部署到 Swarm:

docker stack deploy --compose-file docker-compose.yml multi-tier-app

应用部署应失败并显示错误信息,指出未找到ch13_multi_tier_app-POSTGRES_PASSWORD

$ docker stack deploy --compose-file docker-compose.yml multi-tier-app Creating network multi-tier-app_private Creating network multi-tier-app_public service postgres: secret not found: ch13_multi_tier_app-POSTGRES_PASSWORD

docker命令的输出显示 Swarm 能够创建网络,但不能创建服务。Swarm 要求在部署之前,所有服务所依赖的集群级资源都必须存在。因此,当 Docker 确定资源依赖项缺失时,它会停止应用程序的部署。已创建的资源保持原样,可以在后续的部署尝试中使用。这些预部署检查有助于构建健壮的应用程序交付流程。快速失败的部署行为帮助我们迅速发现缺失的依赖项。

此应用程序所依赖的缺失的集群级资源是ch13_multi_tier_app-POSTGRES_PASSWORD密钥。回想一下,应用程序对该密钥的引用表明它是外部定义的:

secrets:   ch13_multi_tier_app-POSTGRES_PASSWORD:     external: true              # 从集群的密钥资源中检索

在此上下文中,external 表示定义在应用程序部署定义之外,并由 Swarm 提供。现在让我们将应用程序的数据库密码作为 Docker secret 存储在 Swarm 集群中:

echo 'mydbpass72' | docker secret create \ ch13_multi_tier_app-POSTGRES_PASSWORD -

注意

这个密码只在这个由 Swarm 管理的 Docker secret 中定义。你可以使用任何有效的 PostgreSQL 密码。请随意更改它。这展示了在 Swarm 中如何轻松安全地处理分布式应用程序的秘密。

docker secret 命令应该成功并打印 Docker 分配的随机标识符来管理秘密。你可以通过列出集群中的秘密来验证秘密是否已创建:

docker secret ls --format "table {{.ID}} {{.Name}} {{.CreatedAt}}" 1

  • 1 以秘密标识符、名称和自创建以来时间(可选)格式化输出为表格

列表应显示秘密最近被创建:

ID NAME CREATED <random id> ch13_multi_tier_app-POSTGRES_PASSWORD 6 seconds ago

现在让我们再次尝试部署堆栈:

[manager1] $ docker stack deploy \ --compose-file docker-compose.yml multi-tier-app Creating service multi-tier-app_postgres Creating service multi-tier-app_api

docker stack 命令应该报告它已为多级应用程序创建了两个 Docker 服务:multi-tier-app_postgresmulti-tier-app_api。列出服务并检查其状态:

docker service ls \ --format "table {{.Name}} {{.Mode}} {{.Replicas}}" 1

  • 1 以服务名称、模式(可选)和副本数(可选)格式化输出为表格

该命令将生成如下输出:

NAME MODE REPLICAS multi-tier-app_api replicated 2/2 multi-tier-app_postgres replicated 1/1

每个服务都有预期的副本数。PostgreSQL 有一个任务,API 在REPLICAS列中有两个任务。

你可以通过检查日志来检查api服务是否正确启动:

docker service logs --follow multi-tier-app_api

每个api任务都应该记录一条消息,说明它正在初始化,从文件中读取 PostgreSQL 密码,并监听请求。例如:

$ docker service logs --no-task-ids multi-tier-app_api multi-tier-app_api.1@worker1 | 2019/02/02 21:25:22 初始化 api 服务器 multi-tier-app_api.1@worker1 | 2019/02/02 21:25:22 将从 '/run/secrets/POSTGRES_PASSWORD' 读取 postgres 密码 multi-tier-app_api.1@worker1 | 2019/02/02 21:25:22 dial tcp: lookup postgres on 127.0.0.11:53: no such host multi-tier-app_api.1@worker1 | 2019/02/02 21:25:23 dial tcp 10.0.0.12:5432: connect: connection refused multi-tier-app_api.1@worker1 | 2019/02/02 21:25:25 初始化完成,启动 http 服务 multi-tier-app_api.2@manager1 | 2019/02/02 21:25:22 初始化 api 服务器 multi-tier-app_api.2@manager1 | 2019/02/02 21:25:22 将从 '/run/secrets/POSTGRES_PASSWORD' 读取 postgres 密码 multi-tier-app_api.2@manager1 | 2019/02/02 21:25:22 dial tcp: lookup postgres on 127.0.0.11:53: no such host multi-tier-app_api.2@manager1 | 2019/02/02 21:25:23 dial tcp: lookup postgres on 127.0.0.11:53: no such host multi-tier-app_api.2@manager1 | 2019/02/02 21:25:25 初始化完成,启动 http 服务

docker service logs <service name> 命令将服务任务部署的节点上的日志消息流式传输到您的终端。您可以通过在管理节点上对 Docker 引擎发出此命令来查看任何服务的日志,但不能在工作节点上。当您查看服务日志时,Docker 引擎连接到集群中运行其任务的引擎,检索日志,并将其返回给您。

从日志消息中,我们可以看到这些 api 任务似乎正在 worker1manager1 节点上运行。您的服务任务可能已在不同节点上启动。我们可以通过 docker service ps 命令来验证这一点,该命令列出了服务的任务。运行以下命令:

docker service ps \ --format "table {{.ID}} {{.Name}} {{.Node}} {{.CurrentState}}" \ 1 multi-tier-app_api

  • 1 以表格格式输出必要任务数据(可选)

此命令将产生如下输出:

ID NAME NODE CURRENT STATE 5jk32y4agzst multi-tier-app_api.1 worker1 运行 16 分钟前 nh5trkrpojlc multi-tier-app_api.2 manager1 运行 16 分钟前

docker service ps 命令报告说,api 服务正在运行两个任务,正如预期的那样。请注意,任务以 <stack name>_<service name>.<replica slot number> 的形式命名;例如,multi-tier-app_api.1。每个任务也都有一个唯一的 ID。docker service ps 命令列出了服务上的任务及其状态,无论它们在集群的哪个位置运行。

相比之下,当在 manager1 节点上运行 docker container ps 时,它只显示该节点上运行的单个容器:

$ docker container ps --format "table {{.ID}} {{.Names}} {{.Status}}" CONTAINER ID NAMES STATUS 4a95fa59a7f8 multi-tier-app_api.2.nh5trkrpojlc3knysxza3sffl Up 27 minutes (healthy)

服务任务容器的名称由任务名称和唯一的任务 ID 组成。两个ps命令都报告说该任务正在运行且状态良好。api服务器的镜像定义了一个HEALTHCHECK,因此我们可以确信这是真的。

太好了——我们的应用程序成功部署,一切看起来都很健康!

打开网页浏览器,将其指向集群中任意节点的 8080 端口。Play with Docker 用户应在网页控制台顶部看到一个 8080 超链接。您也可以使用curl命令从集群的某个节点向 8080 端口发起 HTTP 请求:

curl http://localhost:8080

api服务器应该响应一个类似于以下简单的消息:

欢迎来到 API 服务器!容器 id 256e1c4fb6cb 在 2019-02-03 00:31:23.0915026 +0000 UTC 响应

提示

如果您使用 Play with Docker,每个集群节点的详细页面将有一个指向该节点上发布的端口的链接。您可以打开该链接或使用curl

当您多次发出该请求时,您应该看到不同的容器 ID 在为您提供服务。这个 shell 脚本将发出四个 HTTP 请求并产生以下输出:

$ for i in `seq 1 4`; do curl http://localhost:8080; sleep 1; done; 1 欢迎来到 API 服务器! 2 服务器 9c2eea9f140c 在 2019-02-05 17:51:41.2050856 +0000 UTC 响应欢迎来到 API 服务器!服务器 81fbc94415e3 在 2019-02-05 17:51:42.1957773 +0000 UTC 响应欢迎来到 API 服务器!服务器 9c2eea9f140c 在 2019-02-05 17:51:43.2172085 +0000 UTC 响应欢迎来到 API 服务器!服务器 81fbc94415e3 在 2019-02-05 17:51:44.241654 +0000 UTC 响应欢迎来到 API 服务器!

  • 1 使用 Bash shell 命令向应用程序发出四个请求;您可以将“localhost”替换为任何集群节点的主机名。

  • 2 每个 HTTP 请求的输出

在这里,curl程序向一个集群节点发出 HTTP GET 请求。在上面的例子中,curl程序在集群的某个节点上运行并向该节点上的 8080 端口发送请求。由于没有防火墙阻止curl打开到该网络位置的套接字,Docker Swarm 的服务网格将处理到 8080 端口的连接并将请求路由到活动容器。我们将在下一节更详细地研究请求是如何路由到 Docker 服务的。

13.3. COMMUNICATING WITH SERVICES RUNNING ON A SWARM CLUSTER

Docker 使集群外部的客户端连接到集群中运行的服务变得容易。Swarm 还帮助集群内部运行的服务在共享 Docker 网络时找到并相互联系。在本节中,我们将首先探讨 Docker 如何将服务暴露给集群外的世界。然后我们将查看 Docker 服务如何通过使用 Swarm 的服务发现和 overlay 网络功能相互通信。

13.3.1. 使用 Swarm 路由网格路由客户端请求到服务

Swarm 路由网格提供了一种简单的方法,将运行在容器集群上的服务暴露给外部世界,这是 Swarm 最吸引人的特性之一。路由网格结合了几个复杂的网络构建块来发布服务端口。图 13.5 描述了 Swarm 为示例应用程序创建的逻辑网络拓扑。

图 13.5. 示例应用程序的 Swarm 网络组件

图片

Swarm 为每个发布的端口在每个集群节点上设置一个监听器。您可以配置端口以监听 TCP、UDP 或两种类型的流量。客户端应用程序可以连接到任何集群节点上的此端口并发出请求。

Swarm 通过结合 Linux iptablesipvs 功能来实现此监听器。一个 iptables 规则将流量重定向到为服务分配的专用虚拟 IP (VIP)。通过使用 Linux 内核功能 IP 虚拟服务器,ipvs,服务的专用 VIP 可在 Swarm 集群中提供。IPVS 是一个传输层负载均衡器,它将 TCP 或 UDP 服务的请求转发到其实际端点。IPVS 不是一个针对 HTTP 等协议的应用层负载均衡器。Swarm 使用 ipvs 为每个发布的 Service 端口创建一个 VIP。然后,它将 VIP 绑定到 ingress 网络,该网络在 Swarm 集群中可用。

回到我们的示例应用程序,当流量到达 TCP 端口 8080 的集群节点时,iptables 将该流量重定向到 ingress 网络上附加的 api 服务 VIP。IPVS 将流量从 VIP 转发到最终端点,即 Docker 服务任务。

Swarm 的路由网格将处理来自客户端的连接,连接到健康的服务任务,并将客户端的请求数据转发到服务任务。图 13.6 展示了 Swarm 如何将 curl 的 HTTP 请求路由到 API 服务任务并返回。

图 13.6. 将 HTTP 请求路由到服务任务

图片

当程序连接到发布的端口时,Swarm 将尝试连接到该服务的健康任务。如果服务已扩展到零个副本或不存在健康任务,路由网格将拒绝启动网络连接。一旦建立了 TCP 连接,客户端就可以继续到传输的下一阶段。在 API 服务的例子中,客户端将 HTTP GET 请求写入 TCP 套接字连接。路由网格接收这些数据并将其发送到处理此连接的任务。

重要的是要注意,服务任务不需要在处理客户端连接的节点上运行。发布端口为 Docker 服务建立了一个稳定的入口点,该入口点独立于该服务任务在 Swarm 集群中的临时位置。您可以使用 docker service inspect 检查服务发布的端口:

$ docker service inspect --format="{{json .Endpoint.Spec.Ports}}" \ multi-tier-app_api [ { "Protocol": "tcp", "TargetPort": 80, "PublishedPort": 8080, "PublishMode": "ingress" } ]

此输出指示 multi-tier-app_api 在 TCP 端口 8080 上连接到 ingress 网络的监听器,并且流量将被路由到端口号为 80 的服务任务上。

跳过路由网格

另一种名为 hostPublishMode 跳过了路由网格和 ingress 网络的连接。当使用此模式时,客户端将直接连接到指定主机上的服务任务。如果那里部署了任务,它可以处理连接;否则,连接尝试将失败。

这种 PublishMode 可能最适合在 global 模式下部署的服务,这样在集群节点上就只有一个任务,确保任务可以处理请求并避免端口冲突。全局服务在 第 13.4.3 节 中有更详细的解释。

客户端通过使用 HTTP 与示例应用程序的 API 服务进行交互。HTTP 是一种应用协议(第 7 层),它通过 TCP/IP(第 4 层)网络协议进行传输。Docker 还支持监听 UDP/IP(第 4 层)的服务。Swarm 路由网格依赖于 IPVS,它在第 4 层路由和平衡网络流量。

在第 4 层和第 7 层之间进行路由的区别很重要。因为 Swarm 在 IP 层路由和负载均衡连接,这意味着客户端连接将在后端服务任务之间进行平衡,而不是 HTTP 请求。当一个客户端通过单个连接发出许多请求时,所有这些请求都将发送到单个任务,而不会像预期的那样分布到所有后端服务任务。请注意,Docker 企业版支持 HTTP 协议(第 7 层)的负载均衡,也存在第三方解决方案。

13.3.2. 与 overlay 网络一起工作

Docker Swarm 提供一种名为 overlay 网络的网络资源类型,如图 13.7 所示。#filepos1439530。此网络在逻辑上将其流量与其他网络分离,运行在另一个网络之上。Swarm 集群的 Docker 引擎可以创建连接在不同 Docker 主机上运行的容器的 overlay 网络。在 Docker overlay 网络中,只有连接到该网络的容器可以与该网络上的其他容器通信。overlay 网络将连接到该网络的容器之间的通信与其他网络隔离开来。

图 13.7. overlay 网络的分层视图

考虑覆盖网络的一种方式是,它增强了第五章中描述的用户定义的桥接网络,使其能够跨越 Docker 主机。就像用户定义的桥接网络一样,附加到覆盖网络的所有容器都可以作为对等节点直接相互通信。覆盖网络的一个特殊例子是 ingress 网络。

ingress 网络是一个特殊用途的覆盖网络,它是 Docker 在初始化一个集群时创建的。ingress 网络的唯一职责是将连接到集群内 Docker 服务发布的端口的客户端流量进行路由。这个网络由 Swarm 管理,并且只有 Swarm 可以将容器附加到 ingress 网络。你应该知道,ingress 网络的默认配置是没有加密的。

如果你的应用程序需要端到端加密,所有发布端口的应使用 TLS 终止它们的连接。TLS 证书可以存储为 Docker 机密,并在服务启动时检索,就像我们在本章中演示的密码,以及在 第十二章 中的 TLS 证书一样。

接下来,我们将探讨 Docker 如何帮助服务在共享网络上发现和连接到彼此。

13.3.3. 在覆盖网络中查找服务

Docker 服务使用域名系统 (DNS) 来发现它们在共享的 Docker 网络上其他 Docker 服务的位置。如果程序知道该服务的名称,它就可以连接到 Docker 服务。在我们的示例应用程序中,api 服务器通过 POSTGRES_HOST 环境变量配置了数据库服务的名称:

api:   # ... snip ...       environment:         POSTGRES_HOST: 'postgres'

当一个 api 任务创建到 PostgreSQL 数据库的连接时,它将通过 DNS 将 postgres 名称解析为 IP。附加到 Docker 覆盖网络的容器会自动由 Docker 配置,通过一个特殊的解析器 127.0.0.11 进行 DNS 查询。这也适用于用户定义的桥接网络和 MACVLAN 网络。Docker 引擎处理发送到 127.0.0.1 的 DNS 查询。如果名称解析请求是针对该网络上存在的 Docker 服务,Docker 将响应该服务的虚拟 IP 地址。如果查询是针对另一个名称,Docker 将将请求转发到该容器主机的正常 DNS 解析器。

在我们的示例应用程序中,这意味着当 api 服务查找 postgres 时,该主机上的 Docker 引擎将响应 postgres 服务端点的虚拟 IP,例如,10.0.27.2。api 数据库连接驱动程序可以连接到这个虚拟 IP,Swarm 将路由连接到 postgres 服务任务,该任务可能位于 10.0.27.3。你可能期望这种方便的名称解析和网络路由功能存在,但并非所有容器编排器都以这种方式工作。

如果你还记得之前显示的 图 13.5,你也许也能解释一些看起来不寻常的东西。图 13.8 在这里重现了那个图表。

图 13.8. 示例应用的 Swarm 网络组件

图片

api 服务有三个虚拟 IP,分别在每个它附加的三个覆盖网络上建立其存在:ingressmulti-tier-app_publicmulti-tier-app_private。如果你检查 api 服务的端点,你应该看到输出验证了这三个网络上的 VirtualIPs

docker service inspect --format '{{ json .Endpoint.VirtualIPs }}' \ multi-tier-app_api [ { "NetworkID": "5oruhwaq4996xfpdp194k82td", "Addr": "10.255.0.8/16" }, { "NetworkID": "rah2lj4tw67lgn87of6n5nihc", "Addr": "10.0.2.2/24" }, { "NetworkID": "vc12njqthcq1shhqtk4eph697", "Addr": "10.0.3.2/24" } ]

  • 1 入口网络层

  • 2 多层-app_private 网络层

  • 3 multi-tier-app_public 网络层

跟随一个实验,该实验演示了附加到网络上的服务及其背后的容器的可发现性。启动一个 shell 并将其附加到 multi-tier-app_private 网络上:

docker container run --rm -it --network multi-tier-app_private \ alpine:3.8 sh

我们可以将我们的 shell 容器附加到应用程序的 private 网络上,因为它被定义为 attachable

private: driver: overlay driver_opts: encrypted: "true" attachable: true

默认情况下,只有 Swarm 可以将容器服务任务附加到网络。这个 private 网络被特别设置为可附加,以便进行此服务发现练习。

Ping postgres 服务一次。你应该看到类似以下输出:

/ # ping -c 1 postgres PING postgres (10.0.2.6): 56 数据字节 64 字节来自 10.0.2.6: seq=0 ttl=64 time=0.110 ms --- postgres ping 统计信息 --- 1 个数据包已传输,1 个数据包已接收,0% 数据包丢失,往返时间最小/平均/最大 = 0.110/0.110/0.110 ms

现在 ping api 服务:

/ # ping -c 1 api PING api (10.0.2.2): 56 数据字节 64 字节来自 10.0.2.2: seq=0 ttl=64 time=0.082 ms --- api ping 统计信息 --- 1 个数据包已传输,1 个数据包已接收,0% 数据包丢失,往返时间最小/平均/最大 = 0.082/0.082/0.082 ms

让我们使用 Netcat 从 private 网络上的 shell 手动发出对 api 服务的请求:

$ printf 'GET / HTTP/1.0\nHost: api\n\n' | nc api 80

  • 1 创建一个 HTTP 请求并通过 Netcat 发送到 API

你应该看到与上一节类似的输出:

HTTP/1.0 200 OK 连接: 关闭 内容类型: text/plain; charset=utf-8 日期: Wed, 13 Feb 2019 05:21:43 GMT 内容长度: 98 欢迎来到 API 服务器!服务器 82f4ab268c2a 在 2019-02-13 05:21:43.3537073 +0000 UTC 响应。

我们成功从连接到private网络的 shell 向api服务发出了请求。这是因为api服务除了连接到publicingress网络外,还连接到了private网络。你还可以从你的 shell 连接到 PostgreSQL 数据库:

/ # nc -vz postgres 5432 postgres (10.0.2.6:5432) open

这个 Netcat 命令在端口 5432 上打开到postgres主机名的套接字,然后立即关闭。Netcat 的输出表明它成功连接到了postgres VIP,10.0.2.6。这可能会让你感到惊讶。毕竟,如果你回顾postgres服务定义,你可以确认我们从未发布或暴露过任何端口。这里发生了什么?

连接到给定 Docker 网络的容器之间的通信是完全开放的。在 Docker overlay 网络上的容器之间没有防火墙。因为 PostgreSQL 服务器正在监听端口 5432,并且连接到private网络,所以任何连接到该网络的另一个容器都可以连接到它。

在某些情况下,这种行为可能很方便。然而,你可能需要以不同于你习惯的方式处理连接服务之间的访问控制。接下来,我们将讨论一些隔离服务间通信的想法。

13.3.4. 使用 overlay 网络隔离服务间通信

许多人通过限制可以连接到该服务的网络连接来控制对服务的访问。例如,使用允许从服务 A 到服务 B 流量流动但禁止从 B 到 A 反向流量的防火墙是很常见的。这种方法不适合 Docker overlay 网络,因为连接到给定网络的对等体之间没有防火墙。流量在两个方向上自由流动。overlay 网络唯一可用的访问控制机制是(或不是)连接到网络。

然而,你可以通过使用 Docker overlay 网络来实现应用程序流量流的实质性隔离。Overlay 网络轻量级且易于 Swarm 创建,因此可以用作设计工具来创建安全的应用程序通信拓扑。你可以为你的应用程序部署使用细粒度、特定于应用程序的网络,以避免共享服务来实现隔离。示例应用程序展示了这种方法,除了将private网络设置为可连接之外。

需要记住的关键点是,尽管在范围紧密的网络中流量可能仅限于几个容器,但并没有使用网络标识来验证和授权流量的说法。当应用程序需要控制对其功能的访问时,应用程序必须在应用级别验证客户端的身份和授权。示例应用程序通过使用 PostgreSQL 用户名和密码来控制对 postgres 数据库的访问。这确保了只有 api 服务可以与我们的部署中的数据库交互。api 服务旨在匿名使用,因此它没有实现身份验证,但它当然可以。

你可能会遇到的一个挑战是集成集中式、共享的服务,例如日志服务。假设我们的示例应用程序和集中式日志服务连接到共享网络。Docker 网络将允许 logging 服务在需要时(或攻击者)联系 apipostgres 服务。

解决这个问题的方法是部署集中式日志服务或其他共享服务作为发布端口的 Docker 服务。Swarm 将在 ingress 网络上为 logging 服务设置监听器。运行在集群内部的客户端可以像连接其他已发布服务一样连接到该服务。来自集群内部运行的任务和容器的连接将按照第 13.3.1 节中描述的方式路由到日志服务。因为日志服务的监听器将在 Swarm 集群的每个节点上可用,所以日志服务应该验证其客户端。

让我们用一个简单的 echo 服务来演示这个想法,该服务会回复你发送的任何输入。首先创建该服务:

docker service create --name echo --publish '8000:8' busybox:1.29 \ nc -v -lk -p 8 -e /bin/cat

如果你通过集群节点的 8000 端口使用 Netcat (nc) 向 echo 服务发送数据:

echo "Hello netcat my old friend, I've come to test connections again." \ | nc -v -w 3 192.168.1.26 8000 1

  • 1 将 192.168.1.26 替换为你的集群节点之一的 IP 地址,或者在 Linux 上使用 $(hostname -i) 来替换当前主机的 IP 地址。

Netcat 应该打印出类似于以下内容的响应:

192.168.1.26 (192.168.1.26:8000) open Hello netcat my old friend, I've come to test connections again.

客户端应通过使用该服务发布的端口连接到共享服务。切换到或重新打开上一节中创建的 shell,以便我们可以验证一些事情:

docker container run --rm -it --network multi-tier-app_private \ alpine:3.8 sh

然后,如果你尝试 ping echo 服务,ping 将报告错误:

/ $ ping -c 1 echo ping: bad address 'echo'

当尝试解析主机名 echo 时,nslookup 也会出现相同的情况:

/ $ nslookup echo nslookup: can't resolve '(null)': Name does not resolve

当附加到multi-tier-app_private网络时,echo服务的名称无法解析。api服务需要连接到在集群边缘由echo服务发布的端口,就像在 Swarm 集群外运行的进程一样。到达echo服务的唯一途径是通过ingress网络。

我们可以关于这个设计说一些好事。首先,所有客户端都以统一的方式到达echo服务,通过一个已发布的端口。其次,因为我们没有将echo服务连接到任何网络(除了隐含的ingress网络连接),所以它是隔离的,无法连接到其他服务,除了那些已发布的。第三,Swarm 已经将应用程序认证责任推入应用层,这是它们应该所在的地方。

这种设计的主要影响之一是,使用 Docker Compose 描述的应用程序可能依赖于两组服务及其位置名称。首先,一些服务被限定在应用程序的部署范围内,并在其中定义(例如,api依赖于postgres)。其次,有一些服务,如echo服务,应用程序可能依赖于,但它们使用不同的部署生命周期和范围进行管理。这些后者的服务可能被许多应用程序共享。这种第二种类型的服务需要在一个注册表中注册,如企业级的 DNS,以便应用程序可以找到其位置。接下来,我们将检查在发现位置后,如何在服务 VIP 之后平衡客户端连接。

13.3.5. 负载均衡

让我们探索 Docker 客户端连接如何在 Docker 服务的任务之间进行平衡。客户端通常通过虚拟 IP 连接到 Docker 服务。Docker 服务有一个名为endpoint-mode的属性,默认为vip。到目前为止,我们一直在使用这个默认的vip端点模式。当服务使用vip端点模式时,客户端将通过 VIP 访问服务。到该 VIP 的连接将由 Docker 自动进行负载均衡。

例如,在第 13.3.3 节中,我们将 shell 附加到multi-tier-app_private网络,并使用 Netcat 向api发出 HTTP 请求。当 Netcat 将api主机名解析为 IP 时,Docker 的内部 DNS 回复了api服务的 VIP。在这种情况下,有多个健康的服务任务可用。Docker 的网络路由实现负责在 VIP 后面的健康任务之间平均分配连接。

Docker 基于网络的负载均衡实现被用于所有通过 VIP 端点路由的流量。这种流量可能来自内部覆盖网络,或者通过发布到ingress网络的端口进入。

Docker 不保证哪个服务任务将处理客户端的请求。即使客户端与健康的任务运行在同一节点上,客户端的请求也可能被发送到另一个节点上的健康任务。这在 global 模式(与端点模式不同)的服务中也是真实的,其中每个实例都运行在每个集群节点上。

13.4. 在集群上放置服务任务

在本节中,我们将研究 Swarm 如何在集群周围放置任务,并尝试在声明的约束内运行所需数量的服务副本。首先,我们将介绍 Swarm 管理任务放置的粗粒度控制。然后,我们将向您展示如何通过使用亲和力和反亲和力来控制任务放置,这些亲和力和反亲和力是通过内置和操作员指定的节点标签实现的。

我们将使用由 Play with Docker 模板创建的五个节点 Swarm 集群,如图 13.9 所示。

图 13.9. 测试 Swarm 集群

此集群有三个管理节点和两个工作节点,其名称如下:

  • manager1

  • manager2

  • manager3

  • worker1

  • worker2

13.4.1. 复制服务

对于 Docker 服务而言,默认且最常用的部署模式是复制模式。Swarm 将尝试始终保持服务定义中指定的副本数量运行。Swarm 会持续协调 Docker Compose 定义或 docker service 命令中指定的服务期望状态,以及集群上服务任务的状态。此协调循环,如图 13.10 所示,将不断启动或停止任务以匹配,以确保服务具有所需的健康副本数量。

图 13.10. 事件协调循环

复制服务是有用的,因为你可以将服务扩展到所需的副本数量,以处理负载,并且你的集群有足够的资源来支持。

在此模式下,Swarm 将在具有足够计算资源(内存、CPU)并满足服务标签约束的集群节点上调度一个服务任务以启动。Swarm 尝试将服务的任务分散到集群的各个节点上。这种策略有助于提高服务的可用性并平衡节点间的负载。我们将在下一节中控制任务运行的位置。现在,让我们看看当我们开始扩展示例应用的 api 服务时会发生什么。

默认情况下,api 服务配置为具有两个副本。部署定义还预留和限制了每个容器可以使用的 CPU 和内存资源:

      deploy:         replicas: 2         restart_policy:             condition: on-failure             max_attempts: 10             delay: 5s         update_config:             parallelism: 1             delay: 5s         resources:           limits:             cpus: '0.50'             memory: 15M           reservations:             cpus: '0.25'             memory: 15M

当 Swarm 为每个 api 任务进行调度时,它将寻找至少有 15 MB 内存和 0.25 个 CPU 的节点,这些资源尚未被其他任务保留。一旦识别到具有足够资源的节点,Swarm 将为该任务创建一个容器,该容器限制为(再次)15 MB 内存,并且可能使用高达 0.5 个 CPU。

总体而言,api 服务开始时有两个副本,总共保留了 0.5 个 CPU 和 30 MB 内存。现在让我们将我们的服务扩展到五个副本:

docker service scale multi-tier-app_api=5

服务现在总共保留了 75 MB 的内存和 1.25 个 CPU。Swarm 能够为 api 服务的任务找到资源,并将它们分散在这个集群中:

$ docker service ps multi-tier-app_api \   --filter 'desired-state=running' \   --format 'table {{.ID}} {{.Name}} {{.Node}} {{.CurrentState}}' ID           NAME                 NODE     CURRENT STATE dekzyqgcc7fs multi-tier-app_api.1 worker1  Running 4 minutes ago 3el58dg6yewv multi-tier-app_api.2 manager1 Running 5 minutes ago qqc72ylzi34m multi-tier-app_api.3 manager3 Running about a minute ago miyugogsv2s7 multi-tier-app_api.4 manager2 Starting 4 seconds ago zrp1o0aua29y multi-tier-app_api.7 worker1  Running 17 minutes ago

现在我们来演示一下当服务保留集群所有资源时的样子。只有在你使用的集群允许你耗尽集群资源并阻止其他任务调度的情况下,你才应该跟随操作。我们完成这些操作后,会撤销所有这些设置,但在某个时刻,将无法再调度任何保留 CPU 的新任务。我们建议你不要在 Play with Docker (PWD) 上运行这种资源耗尽练习,因为底层的机器是由使用 PWD 的每个人共享的。

首先,让我们将我们的 api 任务保留的 CPU 从四分之一 CPU 增加到整个 CPU:

docker service update multi-tier-app_api --reserve-cpu 1.0 --limit-cpu 1.0

你会看到 Docker 在节点容量限制下为每个任务重新创建容器时,正在将任务在节点间调度。

现在我们尝试将服务扩展到更多的副本数量,这将耗尽集群的可用资源。例如,如果你运行的是一个五节点集群,并且每个节点有 2 个 CPU,那么总共应该有 10 个 CPU 可用。

以下输出来自一个有 10 个可用 CPU 的集群。postgres 服务已保留 1 个 CPU。api 服务可以成功扩展到 9 个副本:

$ docker service scale multi-tier-app_api=9 multi-tier-app_api scaled to 9 overall progress: 9 out of 9 tasks 1/9: running   [==================================================>] ... snip ... 9/9: running   [==================================================>] verify: Service converged

所有 10 个 CPU 现在都已被 apipostgres 服务保留。当将服务扩展到 10 个副本时,docker 程序似乎挂起了:

docker service scale multi-tier-app_api=10 multi-tier-app_api scaled to 10 overall progress: 9 out of 10 tasks 1/10: running   [==================================================>] ... snip ... 10/10: no suitable node (insufficient resources on 5 nodes) 1

  • 1 创建第 10 个 api 任务时资源不足

输出报告集群的五个节点上资源不足,无法启动第 10 个任务。问题发生在 Swarm 尝试为第 10 个 api 服务任务槽调度任务时。当你耗尽可预订资源时,你需要使用 ^C 键盘操作中断 docker stack deploy 命令以获取终端或等待命令超时。Docker 命令将建议你运行 docker service ps multi-tier-app_api 以获取更多信息并检查服务是否收敛。

现在就去做,并验证 api 任务是否已分布到所有集群节点,Swarm 无法调度最后一个任务。在这种情况下,我们知道除非我们增加集群容量或减少期望的副本数量,否则集群永远不会收敛。让我们撤销我们的更改。

自动缩放服务

Docker Swarm 不支持使用内置功能自动缩放服务。第三方解决方案可以使用资源使用指标,如 CPU 或内存利用率,或应用程序级别的指标,如每任务的 HTTP 请求。Docker Flow 项目是一个很好的起点,monitor.dockerflow.com/auto-scaling/

我们有几种方法可以撤销缩放更改。我们可以从源定义重新部署我们的堆栈,使用 docker service rollback 子命令回滚服务配置更改,或者“向前滚动”并将服务缩放直接设置为可以工作的某个值。尝试回滚:

$ docker service rollback multi-tier-app_api multi-tier-app_api rollback: manually requested rollback overall progress: rolling back update: 9 out of 9 tasks ... snip ... verify: Service converged

service rollback 子命令将服务的期望配置回滚一个版本。multi-tier-app_api 的先前配置有九个副本。你可以通过运行 docker service ls 来确认此配置是否生效。输出应显示 multi-tier-app_api 服务正在运行的副本数已达到预耗尽数;例如,9/9。你可能想知道如果你再次运行 rollback 会发生什么。如果你执行另一个 rollback,Docker 将恢复具有 10 个服务副本的配置,再次耗尽资源。也就是说,Docker 将撤销回滚,使我们回到起点。由于我们想撤销多个更改,我们需要另一种方法。

在我们的情况下,最干净的方法是从其源定义重新部署服务:

docker stack deploy --compose-file docker-compose.yml multi-tier-app

使用docker service ps查看服务任务,以确保服务已返回 Docker Compose 应用程序定义中声明的状态:

docker service ps multi-tier-app_api \    --filter 'desired-state=running' \    --format 'table {{.ID}} {{.Name}} {{.Node}} {{.CurrentState}}' ID           NAME                 NODE     CURRENT STATE h0to0a2lbm87 multi-tier-app_api.1 worker1  正在运行大约一分钟前 v6sq9m14q3tw multi-tier-app_api.2 manager2 正在运行大约一分钟前

手动缩放更改已消失。正如预期的那样,有两个api任务。

注意,一个任务正在worker1节点上运行,而另一个任务正在manager2节点上运行。这并不是我们大多数部署所期望的任务放置方式。通常,我们希望实现如下架构目标:

  • 为运行 Swarm 控制平面保留管理节点,以便它们有专门的计算资源

  • 将发布端口的隔离服务,因为它们比private服务更容易受到攻击

我们将在下一节中使用 Swarm 内置的功能来限制任务运行的位置,以实现这些目标以及更多。

13.4.2. 限制任务运行的位置

我们经常想要控制应用程序在集群中的哪些节点上运行。我们可能想要这样做,以便将工作负载隔离到不同的环境或安全区域,利用特殊的机器能力,如 GPU,或者为关键功能保留一组节点。

Docker 服务提供了一种名为放置约束的功能,允许您控制服务任务可以分配到的节点。使用放置约束,您可以指定服务任务应该或不应该运行的位置。约束可以使用集群节点的内置和用户定义属性。我们将通过每个示例来演示。

在上一节中,我们看到当进行扩展时,api服务被分配到所有节点。api服务不仅运行在管理节点上,还与postgres数据库在同一节点上运行,如图 13.11 所示。

图 13.11. API 服务任务无处不在

许多系统架构师会调整这种部署架构,以便管理节点专门用于运行 Swarm 集群。对于重要的集群来说,这是一个好主意,因为如果服务消耗如 CPU 等资源,Swarm 可能会在监督任务和响应影响集群上所有服务操作的操作命令时落后。此外,由于 Swarm 管理器控制集群,因此应严格控制对这些节点(以及 Docker Engine API)的访问。我们可以使用 Swarm 的节点可用性和服务放置约束来实现这一点。

让我们先确保我们的服务不在管理节点上运行。Swarm 集群中的所有节点默认情况下都可以运行服务任务。然而,我们可以通过使用docker node update命令的--availability选项来重新配置节点的可用性。有三个可用性选项:activepausedrainactive选项表示调度程序可以分配新任务给该节点。pause选项表示现有任务将继续运行,但不会为新任务分配给该节点。drain选项表示现有任务将被关闭并在另一个节点上重新启动,并且不会为新任务分配给该节点。

因此,我们可以将管理节点的可用性设置为drain以防止服务任务在其上运行:

docker node update --availability drain manager1 docker node update --availability drain manager2 docker node update --availability drain manager3

一旦运行了这些命令,docker node ls的输出应反映可用性的变化:

docker node ls --format 'table {{ .ID }} {{ .Hostname }} {{ .Availability }}' ID                        HOSTNAME AVAILABILITY ucetqsmbh23vuk6mwy9itv3xo manager1 Drain b0jajao5mkzdd3ie91q1tewvj manager2 Drain kxfab99xvgv71tm39zbeveglj manager3 Drain rbw0c466qqi0d7k4niw01o3nc worker1  Active u2382qjg6v9vr8z5lfwqrg5hf worker2  Active

我们可以验证 Swarm 是否已将multi-tier-app服务任务迁移到工作节点:

docker service ps multi-tier-app_api multi-tier-app_postgres \ --filter 'desired-state=running' \ --format 'table {{ .Name }} {{ .Node }}' NAME                      NODE multi-tier-app_postgres.1 worker2 multi-tier-app_api.1      worker1 multi-tier-app_api.2      worker2

如果您在管理节点上运行docker container ps,您不应看到任何与服务任务相关的容器。

放置约束通过表达服务基于某些元数据应在或不应在节点上运行来工作。约束的一般形式如下:

<节点属性> 等于或不等于 <值>

当一个服务被限制在某个节点上运行时,我们说它对该节点有亲和力。当它必须不在某个节点上运行时,我们说它对该节点有反亲和力。您将在本讨论和其他关于服务放置的讨论中看到这些术语的使用。Swarm 的约束语言用==表示相等(匹配),用!=表示不等。当服务定义了多个约束时,节点必须满足所有约束,任务才能被调度到那里。也就是说,多个约束是AND在一起的。例如,假设您想在不在公共安全区的 Swarm 工作节点上运行一个服务。一旦您配置了集群的区元数据,您可以通过运行具有以下约束的服务来实现这一点:node.role == workernode .labels.zone != public

Docker 支持几个节点属性,可以用作约束的基础:

  • node.id— 蜂群集群中节点的唯一标识符(例如,ucetqsmbh23vuk6mwy9itv3xo

  • node.hostname— 节点的计算机名,(例如,worker2

  • node.role— 节点在集群中的角色,可以是managerworker

  • node.labels.<label name>— 由操作员应用于节点的标签(例如,具有zone=public标签的节点将具有节点属性node.labels.zone=public

  • engine.labels— 描述节点和 Docker Engine 关键属性的标签集合,例如 Docker 版本和操作系统(例如,engine.labels.operatingsystem==ubuntu 16.04

让我们继续组织我们的系统,通过将集群的 worker 节点分为publicprivate区域来组织系统。一旦我们有了这些区域,我们将更新apipostgres服务,以便它们的任务只在期望的区域中运行。

您可以使用docker node update命令的--label-add选项通过自己的元数据标记 Swarm 集群节点。此选项接受一个键/值对列表,这些键/值对将被添加到节点的元数据中。还有一个--label-rm选项可以从节点中删除元数据。这些元数据将可用于将任务约束到特定的节点。

让我们将worker1识别为private区域的一部分,将worker2识别为公共区域的一部分:

$ docker node update --label-add zone=private worker1 worker1 $ docker node update --label-add zone=public worker2 worker2

现在将api服务约束到public区域。docker service createupdate命令有选项可以添加和删除任务调度约束,分别是--constraint-add--constraint-rm。我们添加到服务中的约束告诉 Swarm 只在具有zone标签等于public的节点上调度api服务任务:

docker service update \ --constraint-add 'node.labels.zone == public' \ multi-tier-app_api

如果一切顺利,Docker 将报告api服务的任务已收敛到新状态:

multi-tier-app_api 总进度:2/2 任务 1/2:运行   [==================================================>] 2/2:运行   [==================================================>] 验证:服务收敛

您可以验证api任务已被重新调度到worker2节点:

docker service ps multi-tier-app_api \ --filter 'desired-state=running' \ --format 'table {{ .Name }} {{ .Node }}' NAME NODE multi-tier-app_api.1 worker2 multi-tier-app_api.2 worker2

不幸的是,我们无法在docker service ps输出中显示节点标签信息,也无法看到我们使用docker node ls添加的标签。目前,查看节点标签的唯一方法是检查节点。以下是一个快速 bash shell 脚本,用于显示集群中所有节点的计算机名、角色和标签信息:

for node_id in `docker node ls -q | head`; do docker node inspect \ --format '{{.Description.Hostname}} {{.Spec.Role}} {{.Spec.Labels}}'\ "${node_id}"; done;

这个脚本应该输出以下内容:

manager1 manager map[] manager2 manager map[] manager3 manager map[] worker1 worker map[zone:private] worker2 worker map[zone:public]

这并不理想,但比试图回忆哪些节点有哪些标签要好。

我们需要对这个系统进行的最后调整是将 postgres 数据库迁移到 private 区域。在这样做之前,使用 curlapi 服务的 /counter 端点发出一些查询:

curl http://127.0.0.1:8080/counter

/counter 端点将一条记录插入一个具有自增 id 列的表中。当 api 服务响应时,它会打印出该列中的所有 ID。如果你向该端点发出三个请求,第三个响应的输出应类似于以下内容:

# curl http://127.0.0.1:8080/counter SERVER: c098f30dd3c4 DB_ADDR: postgres DB_PORT: 5432 ID: 1 ID: 2 ID: 3

这可能看起来有点偏离主题,但插入这些记录将有助于在稍后展示一个关键点。

让我们将 postgres 任务约束在 private 区域:

$ docker service update --constraint-add 'node.labels.zone == private' \ multi-tier-app_postgres multi-tier-app_postgres overall progress: 1 out of 1 tasks 1/1: running   [==================================================>] verify: Service converged

postgres 任务现在正在 worker1 节点上运行:

$ docker service ps multi-tier-app_postgres \ --filter 'desired-state=running' \ --format 'table {{ .Name }} {{ .Node }}' NAME                      NODE multi-tier-app_postgres.1 worker1

现在,如果你向 /counter 端点发出请求,你会看到以下内容:

$ curl http://127.0.0.1:8080/counter SERVER: c098f30dd3c4 DB_ADDR: postgres DB_PORT: 5432 ID: 1

计数器已重置。我们的数据去哪了?它丢失了,因为 postgres 数据库使用了本地于集群节点的 db-data 卷。严格来说,数据并没有丢失。如果 postgres 任务迁移回 worker2 节点,它将挂载原始卷并从 3 继续计数。如果你在自己的集群中跟随操作并且没有注意到数据丢失,那可能是因为 postgres 最初恰好部署到了 worker1。这种缺乏确定性和可能的数据丢失并不是一个好的情况。我们能做些什么?

默认的 Docker 卷存储驱动程序使用节点的存储。这个存储驱动程序不会在 Swarm 集群中共享或备份数据。有一些 Docker 存储驱动程序增加了这样的功能,包括 Docker CloudStor 和 Rex-Ray。这些驱动程序将使你能够在集群中创建和共享卷。在将重要数据提交给它们之前,你应该仔细调查和测试这些驱动程序。

确保任务在给定的节点上持续运行的另一种方法是将其约束到特定的节点。相关的约束选项包括节点主机名、节点 ID 或用户定义的标签。现在,让我们将postgres任务约束到worker1节点,以确保它不会从该节点移动,即使private区域扩展:

docker service update --constraint-add 'node.hostname == worker1' \     multi-tier-app_postgres

现在,postgres服务将不会从该节点移动。检查服务的放置约束显示有两个是活动的:

$ docker service inspect \     --format '{{json .Spec.TaskTemplate.Placement.Constraints }}' \     multi-tier-app_postgres ["node.hostname == worker1","node.labels.zone == private"]

如果我们想要继续使用这些放置约束,我们将在示例应用程序的 docker-compose.yml 中指定这些。以下是表达postgres服务约束的方法:

services:   postgres:     # ... 省略 ...     deploy:       # ... 省略 ...         placement:           constraints:             - node.labels.zone == private             - node.hostname == worker1

现在放置约束在下次使用docker stack deploy部署应用程序时不会丢失。

注意

为了在像 Swarm 这样的容器集群上安全地运行包含重要数据的数据库,你需要进行大量的谨慎工程。具体的策略可能因数据库实现而异,以便你可以利用数据库特定的复制、备份和数据恢复优势。

现在我们已经探讨了如何将服务约束到特定的节点,让我们完全相反的方向,使用global服务部署服务到每个地方。

13.4.3. 使用全局服务在每个节点上执行一个任务

你可以通过声明服务的模式为global来在每个 Swarm 集群的节点上部署一个服务任务。当你想要随着集群的大小扩展服务时,这很有用。常见的用例包括日志记录和监控服务。

让我们部署第二个echo服务实例作为global服务:

docker service create --name echo-global \   --mode global \ 1 --publish '9000:8' \ 2 busybox:1.29 nc -v -lk -p 8 -e /bin/cat

  • 1 使用“global”而不是“replicated”

  • 2 发布端口 9000 以避免与 echo 服务冲突

如果你运行docker service ls,你会看到echo-global服务正在global模式下运行。我们迄今为止部署的其他服务的模式将是replicated,默认模式。

你可以验证 Swarm 是否在每个可用于任务调度的节点上部署了一个任务。此示例使用上一节的 Swarm 集群,其中只有工作节点可用于任务。docker service ps命令确认每个节点上都有一个任务:

docker service ps echo-global \     --filter 'desired-state=running' \     --format 'table {{ .Name }} {{ .Node }}' NAME                                  NODE echo-global.u2382qjg6v9vr8z5lfwqrg5hf worker2 echo-global.rbw0c466qqi0d7k4niw01o3nc worker1

您可以像使用echo服务一样与echo-global服务进行交互。使用以下命令发送几条消息:

[worker1] $  echo 'hello' |  nc 127.0.0.1 -w 3 9000 1

  • 1 通过已发布的端口发送到 echo-global 服务

记住客户端连接将通过服务的虚拟 IP 路由(见第 13.3.5 节)。由于 Docker 网络的路由行为,全局服务的客户端可能会连接到另一个节点上的任务,而不是它自己的。随着集群规模的增加,连接到另一个节点上的全局服务任务的概率也会增加,因为连接是均匀分配的。如果您通过docker service logs --follow --timestamps echo-global检查日志并向服务发送消息,您可以看到基于连接的负载均衡正在发生。

以下输出是通过连接到worker1并每隔 1 秒发送消息产生的:

2019-02-23T23:51:01.042747381Z echo-global.0.rx3o7rgl6gm9@``worker2``    | connect to [::ffff:10.255.0.``95``]:8 from [::ffff:10.255.0.3]:40170 ([::ffff:10.255.0.3]:40170) 2019-02-23T23:51:02.134314055Z echo-global.0.hp01yak2txv2@``worker1``    | connect to [::ffff:10.255.0.``94``]:8 from [::ffff:10.255.0.3]:40172 ([::ffff:10.255.0.3]:40172) 2019-02-23T23:51:03.264498966Z echo-global.0.rx3o7rgl6gm9@``worker2``    | connect to [::ffff:10.255.0.``95``]:8 from [::ffff:10.255.0.3]:40174 ([::ffff:10.255.0.3]:40174) 2019-02-23T23:51:04.398477263Z echo-global.0.hp01yak2txv2@``worker1``    | connect to [::ffff:10.255.0.``94``]:8 from [::ffff:10.255.0.3]:40176 ([::ffff:10.255.0.3]:40176) 2019-02-23T23:51:05.412948512Z echo-global.0.rx3o7rgl6gm9@``worker2``    | connect to [::ffff:10.255.0.``95``]:8 from [::ffff:10.255.0.3]:40178 ([::ffff:10.255.0.3]:40178)

发送消息的nc客户端程序在worker1上运行。此日志输出显示客户端的连接在worker2上的任务(IP 以.95结尾)和worker1上的任务(IP 以.94结尾)之间弹跳。

13.4.4. 在真实集群上部署实际应用程序

前面的练习展示了 Swarm 如何尝试将应用程序的实际已部署资源收敛到应用程序部署描述符中指示的期望状态。

随着应用程序的更新、集群资源(如节点和共享网络)的添加或删除,或操作员提供新的配置和机密信息,集群的所需状态会发生变化。Swarm 处理这些事件,并在集群管理节点之间复制的内部日志中更新状态。当 Swarm 看到改变所需状态的事件时,管理器的领导者向集群的其他部分发出命令,以收敛到所需状态。Swarm 将通过启动或更新服务任务、覆盖网络和其他资源来收敛到所需状态,这些资源由操作员指定的约束条件所限制。

你可能想知道从 Swarm 的哪些功能开始。首先,列出你想要运行的应用程序类型以及它们将需要的 Swarm 资源类型。其次,考虑你想要如何组织在集群上运行的应用程序。第三,决定集群是否将支持有状态服务(如数据库),并确定一个安全地管理数据的方法。

记住,你可以从部署仅使用 Swarm 的网络、配置和机密管理功能的无状态服务开始。这种方法提供了深入了解 Swarm 和服务的多宿主环境操作方式的机会,而不会将数据置于风险之中。

通过对集群资源进行深思熟虑的设计和主动监控,你可以确保应用程序在需要时获得所需的资源。你还可以设定集群应托管哪些活动和数据的预期。

摘要

本章探讨了在 Swarm 管理的宿主集群上运行 Docker 服务的一些方面。练习展示了如何使用 Swarm 最重要的和最常用的功能来部署多种类型的应用程序。我们看到了当集群资源耗尽时会发生什么,并展示了如何从该状态中恢复。我们还重新配置了集群和服务部署,以实现用户定义的架构目标。本章需要理解的关键点包括:

  • 管理员定义了由服务共享的集群范围内的资源,如网络、配置和机密信息。

  • 服务除了使用集群范围内的资源外,还定义了它们自己的服务范围资源。

  • Swarm 管理器存储和管理集群所需状态的更新。

  • 当有足够的资源可用时,Swarm 管理器将实际的应用程序和资源部署收敛到所需状态。

  • 服务任务是非持久的,服务更新会导致任务被新的容器替换。

  • 只要集群在满足服务放置约束条件的节点上有足够的可用资源,服务任务就可以扩展到所需状态。

在 Linux 系统上运行三个容器的 Docker

posted @ 2025-11-14 20:38  绝不原创的飞龙  阅读(20)  评论(0)    收藏  举报