一个月的-Docker-学习指南-全-
一个月的 Docker 学习指南(全)
原文:Learn Docker in a Month of Lunches
译者:飞龙
第一部分:理解 Docker 容器和镜像
欢迎来到《一个月午餐时间学会 Docker》。本部分将快速让您熟悉 Docker 的核心概念:容器、镜像和仓库。您将学习如何在容器中运行应用程序,如何将您自己的应用程序打包到容器中,以及如何与他人共享这些应用程序。您还将了解如何在 Docker 卷中存储数据以及如何在容器中运行有状态的应用程序。到这些第一章结束时,您将熟悉 Docker 的所有基本概念,并且您将从一开始就学习最佳实践。
1 在开始之前
Docker 是一个在轻量级单元(称为容器)中运行应用程序的平台。容器在软件的各个领域都取得了成功,从云中的无服务器函数到企业的战略规划。Docker 正在成为整个行业操作员和开发者的核心能力——在 2019 年 Stack Overflow 调查中,Docker 被评为人们最想要的“最想拥有的”技术(mng.bz/04lW)。
Docker 是一种易于学习的简单技术。你可以作为一个完全的初学者拿起这本书,你将在第二章中运行容器,在第三章中打包应用程序以便在 Docker 中运行。每一章都专注于实际任务,包含任何运行 Docker 的机器上的示例和实验室——欢迎 Windows、Mac 和 Linux 用户。
在这本书中,你将跟随的旅程是我教授 Docker 多年来的经验结晶。每一章都是实战性的——除了这一章。在你开始学习 Docker 之前,了解容器在现实世界中的使用方式和它们解决的问题类型非常重要——这正是我将在这里介绍的内容。本章还将描述我将如何教授 Docker,这样你可以判断这本书是否适合你。
现在,让我们看看人们正在用容器做什么——我将介绍五个主要场景,在这些场景中,组织使用 Docker 取得了巨大的成功。你将看到你可以用容器解决的问题的广泛范围,其中一些无疑会映射到你自己的工作场景。到本章结束时,你将了解为什么 Docker 是你需要了解的技术,你还将看到这本书将如何帮助你达到这个目标。
1.1 为什么容器将统治世界
我的 Docker 之旅始于 2014 年,当时我正在为一个为 Android 设备提供 API 的项目工作。我们开始使用 Docker 作为开发工具——源代码和构建服务器。然后我们获得了信心,开始在测试环境中将 API 运行在容器中。到项目结束时,每个环境都由 Docker 支持,包括对可用性和扩展性有严格要求的生成环境。
当我离开项目时,将项目交接给新团队的方式是在 GitHub 仓库中的一个单独的 README 文件。构建、部署和管理应用程序(在任何环境中)的唯一要求是 Docker。新开发者只需获取源代码并运行一个命令即可在本地构建和运行一切。管理员使用完全相同的工具在生产集群中部署和管理容器。
通常,在那个规模的项目中,交接需要两周时间。新开发者需要安装半打工具的特定版本,管理员需要安装完全不同的半打工具。Docker 集中了工具链,让每个人都变得如此容易,以至于我认为有一天每个项目都将不得不使用容器。
我于 2016 年加入 Docker,并在过去几年中见证了这一愿景的实现。Docker 正变得无处不在,部分原因是因为它使交付变得如此简单,部分原因是因为它非常灵活——您可以将它带入所有项目,无论是新的还是旧的,Windows 还是 Linux。让我们看看容器在这些项目中的位置。
1.1.1 将应用程序迁移到云端
将应用程序迁移到云端是许多组织的首要任务。这是一个有吸引力的选择——让微软、亚马逊或谷歌来担心服务器、磁盘、网络和电力问题。在全球数据中心托管您的应用程序,几乎具有无限的可扩展潜力。几分钟内部署到新的环境,并且只需为使用的资源付费。但您如何将应用程序迁移到云端呢?
过去,将应用程序迁移到云端有两种选择:基础设施即服务(IaaS)和平台即服务(PaaS)。这两种选择都不太理想。您的选择基本上是一种妥协——选择 PaaS,并运行一个项目将应用程序的所有部分迁移到云中的相关托管服务。这是一个困难的项目,并且会将您锁定在单个云中,但它确实可以降低运行成本。另一种选择是 IaaS,您需要为应用程序的每个组件启动一个虚拟机。您可以在云之间获得可移植性,但运行成本会高得多。图 1.1 显示了使用 IaaS 和 PaaS 进行云迁移的典型分布式应用程序的外观。

图 1.1 迁移到云端的原始选项——使用 IaaS 并运行大量效率低下的虚拟机,每月成本高昂,或使用 PaaS 以降低运行成本,但花费更多时间在迁移上。
Docker 提供了一种没有妥协的第三种选择。您将应用程序的每个部分迁移到容器中,然后您可以使用 Azure Kubernetes 服务或亚马逊的弹性容器服务,或在数据中心您自己的 Docker 集群中运行整个应用程序。您将在第七章中学习如何将此类分布式应用程序打包和运行在容器中,在第十三章和第十四章中您将看到如何在生产中大规模运行。图 1.2 显示了 Docker 选项,它使您能够以低成本在任何云中运行可移植的应用程序——或在数据中心,或在您的笔记本电脑上。

图 1.2 在迁移到云端之前将相同的应用程序迁移到 Docker。此应用程序具有 PaaS 的成本效益,以及 IaaS 的可移植性效益,以及只有 Docker 才能提供的易用性。
迁移到容器确实需要一些投资:您需要将现有的安装步骤构建到名为 Dockerfile 的脚本中,并将部署文档构建到使用 Docker Compose 或 Kubernetes 格式的描述性应用程序清单中。您不需要更改代码,并且最终结果将在每个环境中以相同的方式运行,从您的笔记本电脑到云端。
1.1.2 现代化遗留应用程序
您几乎可以在云中运行任何应用,但如果没有使用较旧的、单体式设计,您将无法获得 Docker 或云平台的全部价值。单体在容器中运行得很好,但它们限制了您的敏捷性。您可以使用容器在 30 秒内自动分阶段推出一个新功能到生产环境中。但如果该功能是构建自两百万行代码的单体的一部分,您可能不得不经历两周的回归测试周期,才能达到发布阶段。
将您的应用迁移到 Docker 是现代化架构、采用新模式而不需要全面重写应用的绝佳第一步。方法很简单——您首先使用本书中将学习的 Dockerfile 和 Docker Compose 语法将您的应用移动到一个单独的容器中。现在您有一个容器中的单体。
容器在自己的虚拟网络中运行,因此它们可以相互通信而无需暴露给外部世界。这意味着您可以从拆分应用程序开始,将功能移动到它们自己的容器中,这样您的单体就可以逐渐演变成一个分布式应用,整个功能集由多个容器提供。图 1.3 显示了这在一个示例应用架构中的样子。

图 1.3 将单体分解为分布式应用,而不需要重写整个项目。所有组件都在 Docker 容器中运行,一个路由组件决定请求是由单体还是新的微服务来满足。
这为您带来了微服务架构的许多好处。您的主要功能被封装在小型、独立的单元中,您可以独立管理它们。这意味着您可以快速测试更改,因为您不是在更改单体,而是在运行您功能的容器。您可以按需扩展和缩减功能,并且可以使用不同的技术来满足需求。
使用 Docker 现代化较老的应用架构很容易——您将在第二十章和第二十一章中通过实际示例自己完成这项工作。您可以交付一个更敏捷、可扩展和有弹性的应用,并且您可以在多个阶段完成它,而不是停下来进行为期 18 个月的全面重写。
1.1.3 构建新的云原生应用
Docker 帮助您将现有应用迁移到云端,无论它们是分布式应用还是单体应用。如果您有单体应用,Docker 可以帮助您将其拆分为现代架构,无论您是在云端运行还是在数据中心。而且,基于云原生原则构建的新项目通过 Docker 得到了极大的加速。
云原生计算基金会(CNCF)将这些新架构描述为使用“开源软件堆栈来部署作为微服务应用,将每个部分打包到自己的容器中,并动态编排这些容器以优化资源利用。”
图 1.4 展示了一个新的微服务应用程序的典型架构——这是一个来自社区的演示应用程序,您可以在 GitHub 上找到,网址为 github.com/microservices-demo 。

图 1.4 展示了云原生应用程序是使用微服务架构构建的,其中每个组件都在容器中运行。
如果您想了解微服务是如何实际实现的,这是一个很好的示例应用程序。每个组件都拥有自己的数据并通过 API 暴露。前端是一个消耗所有 API 服务的 Web 应用程序。演示应用程序使用各种编程语言和不同的数据库技术,但每个组件都有一个 Dockerfile 来打包它,整个应用程序在 Docker Compose 文件中定义。
您将在第四章中学习如何使用 Docker 编译代码,作为打包应用程序的一部分。这意味着您不需要安装任何开发工具来构建和运行此类应用程序。开发者只需安装 Docker,克隆源代码,然后通过单个命令构建和运行整个应用程序。
Docker 还使将第三方软件引入您的应用程序变得容易,无需编写自己的代码即可添加功能。Docker Hub 是一个公共服务,团队在此共享在容器中运行的软件。CNCF 发布了一个开源项目地图,您可以使用它进行从监控到消息队列的任何事情,并且所有这些都可以从 Docker Hub 免费获得。
1.1.4 技术创新:无服务器及其他
现代 IT 的一个关键驱动因素是一致性:团队希望为所有项目使用相同的工具、流程和运行时。您可以使用 Docker 实现,使用容器从在 Windows 上运行的旧 .NET 单体应用到在 Linux 上运行的新 Go 应用程序。您可以构建一个 Docker 集群来运行所有这些应用程序,这样您就可以以相同的方式构建、部署和管理整个应用程序景观。
技术创新不应与常规应用分离。Docker 是一些最大创新的核心,因此您可以在探索新领域的同时继续使用相同的工具和技术。最令人兴奋的创新之一(当然是在容器之后)是无服务器函数。图 1.5 展示了您如何在一个 Docker 集群上运行所有应用程序——遗留的单体应用、新的云原生应用和无服务器函数——这个集群可以运行在云端或数据中心。
无服务器完全是关于容器的。无服务器的目标是让开发者编写函数代码,将其推送到服务,然后该服务构建和打包代码。当消费者使用该函数时,服务启动一个函数实例来处理请求。没有构建服务器、管道或生产服务器需要管理;这一切都由平台负责。
在底层,所有云无服务器选项都使用 Docker 来打包代码和容器以运行函数。但云中的函数不可移植——你不能将你的 AWS Lambda 函数带到 Azure 中运行,因为没有无服务器的开放标准。如果你想要无服务器且不锁定云,或者你在数据中心运行,你可以使用 Nuclio、OpenFaaS 或 Fn Project 等流行的开源无服务器框架在自己的 Docker 平台上托管平台。
其他主要创新,如机器学习、区块链和物联网,都受益于 Docker 一致的打包和部署模型。你会发现主要项目都部署到 Docker Hub 上——TensorFlow 和 Hyperledger 是很好的例子。物联网尤其有趣,因为 Docker 与 Arm 合作,使容器成为边缘和物联网设备的默认运行时。

图 1.5 运行 Docker 的单个服务器集群可以运行任何类型的应用程序,无论它们使用什么架构或技术栈,你都可以以相同的方式构建、部署和管理它们。
1.1.5 使用 DevOps 进行数字化转型
所有这些场景都涉及技术,但许多组织面临的最大问题是运营问题——尤其是对于更大、更老的企业。团队已经被分割成“开发者”和“运营商”,负责项目生命周期的不同部分。发布时的问题变成了一种指责循环,并设置了质量关卡以防止未来的失败。最终,你会有如此多的质量关卡,你一年只能管理两到三个发布,而且它们是风险和劳动密集型的。
DevOps 旨在通过让一个团队拥有整个应用程序的生命周期,将“开发”和“运维”结合成一个可交付成果,从而提高软件部署和维护的敏捷性。DevOps 主要关于文化变革,它可以将组织从巨大的季度发布转变为每日的小规模部署。但是,如果不改变团队使用的科技,很难做到这一点。
运营商可能在 Bash、Nagios、PowerShell 和 System Center 等工具方面有背景。开发者使用 Make、Maven、NuGet 和 MSBuild。当团队不使用共同的技术时,很难将团队聚集在一起,这正是 Docker 真正帮助的地方。你可以通过转向容器来支撑你的 DevOps 转型,突然之间,整个团队都在使用 Dockerfile 和 Docker Compose 文件,说着相同的语言,使用相同的工具。
这还远不止于此。有一个强大的框架用于实现 DevOps,称为 CALMS——文化、自动化、精益、指标和共享。Docker 在所有这些倡议上都发挥作用:自动化是运行容器的核心,分布式应用程序基于精益原则构建,生产应用程序和部署过程的指标可以轻松发布,而 Docker Hub 完全是关于共享,而不是重复工作。
1.2 这本书适合你吗?
我在前一节中概述的五个场景涵盖了目前 IT 行业几乎所有正在进行的活动,我希望很清楚的是,Docker 是这一切的关键。如果你想要将 Docker 应用于这类现实世界的问题,这本书就是为你准备的。它将带你从零基础开始,直到在生产级集群上运行容器中的应用程序。
这本书的目标是教你如何使用 Docker,因此我不会过多地详细介绍 Docker 本身是如何工作的。我不会详细讨论containerd或更底层的细节,如 Linux 的cgroups和namespaces或 Windows 主机计算服务。如果你想要了解内部结构,Jeff Nickoloff 和 Stephen Kuenzli 合著的 Manning 的《Docker in Action》第二版是一个很好的选择。
这本书中的所有示例都是跨平台的,所以你可以使用 Windows、Mac 或 Linux 进行工作——包括 Arm 处理器,因此你也可以使用树莓派。我使用了几种编程语言,但只使用那些跨平台的,所以除了其他语言之外,我还使用.NET Core 而不是.NET Framework(它只能在 Windows 上运行)。如果你想要深入了解 Windows 容器,我的博客是一个很好的资源(blog.sixeyed.com)。
最后,这本书专门讲述 Docker,所以在生产部署方面,我将使用内置在 Docker 中的集群技术 Docker Swarm。在第十二章中,我会谈到 Kubernetes 以及如何在 Swarm 和 Kubernetes 之间进行选择,但不会深入探讨 Kubernetes。Kubernetes 本身就需要一个月的午餐时间,但 Kubernetes 只是运行 Docker 容器的一种不同方式,所以这本书中学到的所有内容都适用。
1.3 创建您的实验室环境
现在让我们开始吧。跟随这本书你需要的是 Docker 和示例的源代码。
1.3.1 安装 Docker
免费的 Docker 社区版适用于开发和甚至生产使用。如果你正在运行 Windows 10 的最新版本或 macOS,最佳选择是 Docker Desktop;较旧版本可以使用 Docker Toolbox。Docker 还为所有主要的 Linux 发行版提供了安装包。首先,使用最适合你的选项安装 Docker——你需要创建一个 Docker Hub 账户来下载,这是免费的,并允许你分享为 Docker 构建的应用程序。
在 Windows 10 上安装 Docker Desktop
要使用 Docker Desktop,你需要 Windows 10 专业版或企业版,并确保你已经安装了所有 Windows 更新——至少应该是发布1809(从命令行运行winver来检查你的版本)。浏览到www.docker.com/products/docker-desktop并选择安装稳定版本。下载安装程序并运行它,接受所有默认设置。当 Docker Desktop 运行时,你会在 Windows 时钟附近的任务栏中看到 Docker 的海豚图标。
在 macOS 上安装 Docker Desktop
要使用 Docker Desktop for Mac,你需要 macOS Sierra 10.12 或更高版本--点击菜单栏左上角的苹果图标,选择“关于本机”以查看你的版本。浏览到www.docker.com/products/docker-desktop并选择安装稳定版本。下载安装程序并运行它,接受所有默认设置。当 Docker Desktop 运行时,你会在 Mac 菜单栏靠近时钟的位置看到 Docker 的海豚图标。
安装 Docker Toolbox
如果你正在使用较旧的 Windows 或 OS X 版本,你可以使用 Docker Toolbox。使用 Docker 的最终体验是相同的,但幕后有一些额外的组件。浏览到docs.docker.com/toolbox并按照说明操作--你首先需要设置虚拟机软件,如 VirtualBox(如果你可以使用 Docker Desktop,它是一个更好的选择,因为你不需要单独的 VM 管理器)。
安装 Docker 社区版和 Docker Compose
如果你正在运行 Linux,你的发行版可能自带了一个可以安装的 Docker 版本,但你不想使用它。这很可能是非常旧的 Docker 版本,因为 Docker 团队现在提供他们自己的安装包。你可以使用 Docker 在每个新版本中更新的脚本,在非生产环境中安装 Docker--浏览到get.docker.com并按照说明运行脚本,然后访问docs.docker.com/compose/install来安装 Docker Compose。
在 Windows Server 或 Linux 服务器发行版上安装 Docker
Docker 的生产部署可以使用社区版,但如果你需要一个受支持的容器运行时,你可以使用 Docker 提供的商业版本,称为 Docker Enterprise。Docker Enterprise 建立在社区版之上,所以你在本书中学到的所有内容都可以与 Docker Enterprise 兼容。它适用于所有主要的 Linux 发行版以及 Windows Server 2016 和 2019。你可以在 Docker Hub 上找到所有 Docker Enterprise 版本以及安装说明,网址为mng.bz/K29E。
1.3.2 验证你的 Docker 设置
Docker 平台由几个组件组成,但在这本书中,你只需要验证 Docker 正在运行,并且 Docker Compose 已安装。
首先使用docker version命令检查 Docker 本身:
PS> docker version Client: Docker Engine - Community Version: 19.03.5 API version: 1.40 Go version: go1.12.12 Git commit: 633a0ea Built: Wed Nov 13 07:22:37 2019 OS/Arch: windows/amd64 Experimental: false Server: Docker Engine - Community Engine: Version: 19.03.5 API version: 1.40 (minimum version 1.24) Go version: go1.12.12 Git commit: 633a0ea Built: Wed Nov 13 07:36:50 2019 OS/Arch: windows/amd64 Experimental: false
您的输出将与我不同,因为版本可能会发生变化,您可能使用的是不同的操作系统,但只要您能看到客户端和服务器版本号,Docker 就运行正常。现在不必担心客户端和服务器是什么,您将在下一章学习 Docker 的架构。
接下来,您需要测试 Docker Compose,这是一个独立的命令行工具,也用于与 Docker 交互。运行 docker-compose version 检查:
PS> docker-compose version docker-compose version 1.25.4, build 8d51620a docker-py version: 4.1.0 CPython version: 3.7.4 OpenSSL version: OpenSSL 1.1.1c 28 May 2019
再次强调,您的确切输出将与我不同,但只要您得到一个没有错误的版本列表,您就可以继续。
1.3.3 下载本书的源代码
本书源代码位于 GitHub 上的公共 Git 仓库中。如果您已安装 Git 客户端,只需运行此命令:
git clone https://github.com/sixeyed/diamol.git
如果您没有 Git 客户端,请浏览到 github.com/sixeyed/diamol 并点击克隆或下载按钮,将源代码的 zip 文件下载到您的本地计算机,并解压缩存档。
1.3.4 记住清理命令
Docker 不会自动为您清理容器或应用程序包。当您退出 Docker Desktop(或停止 Docker 服务)时,所有容器都会停止,并且它们不会使用任何 CPU 或内存,但如果您愿意,您可以在每个章节结束时通过运行此命令来清理:
docker container rm -f $(docker container ls -aq)
如果您在完成练习后想要回收磁盘空间,可以运行此命令:
docker image rm -f $(docker image ls -f reference='diamol/*' -q)
Docker 在下载所需内容方面很智能,因此您可以在任何时候安全地运行这些命令。下次您运行容器时,如果 Docker 在您的机器上找不到所需内容,它会为您下载。
1.4 立即生效
“立即生效”是《午餐月系列》的另一个原则。在接下来的所有章节中,重点是学习技能并将它们付诸实践。
每一章都从对该主题的简要介绍开始,接着是“现在就试”练习,在这里你将使用 Docker 将这些想法付诸实践。然后是一个总结,其中包含更多细节,以填补你从深入研究过程中可能产生的疑问。最后,有一个动手实验室,让你进入下一个阶段。
所有主题都围绕在现实世界中真正有用的任务展开。你将在本章中学习如何立即有效地掌握该主题,并通过理解如何应用新技能来结束学习。让我们开始运行一些容器吧!
2 理解 Docker 和运行 Hello World
是时候亲自动手使用 Docker 了。在这一章中,你将获得大量使用 Docker 核心功能(在容器中运行应用程序)的经验。我还会介绍一些背景知识,这将帮助你确切地了解什么是容器,以及为什么容器是运行应用程序如此轻量级的方式。大部分时间你将跟随“试试看”练习,运行简单的命令,以获得这种新的应用程序工作方式的感觉。
2.1 在容器中运行 Hello World
让我们以运行 Hello World 的方式开始使用 Docker,就像我们开始任何新的计算概念一样:运行 Hello World。你从第一章开始就有 Docker 在运行,所以打开你最喜欢的终端--这可能是 Mac 上的 Terminal 或 Linux 上的 Bash shell,我推荐 Windows 上的 PowerShell。
你将向 Docker 发送一个命令,告诉它运行一个打印一些简单“Hello, World”文本的容器。
现在试试吧 输入以下命令,这将运行 Hello World 容器:
docker container run diamol/ch02-hello-diamol
当我们完成这一章时,你会确切地了解这里发生了什么。现在,只需看看输出。它将类似于图 2.1。

图 2.1 运行 Hello World 容器的输出。你可以看到 Docker 正在下载应用程序包(称为“镜像”),在一个容器中运行应用程序,并显示输出。
输出中有很多内容。我将缩短未来的代码列表以保持它们简短,但这是第一个,我想完整地展示它,这样我们就可以分析它。
首先,实际上发生了什么?docker container run 命令告诉 Docker 在一个容器中运行一个应用程序。这个应用程序已经被打包以在 Docker 中运行,并且已经发布在一个任何人都可以访问的公共网站上。容器包(Docker 称之为“镜像”)的名称为diamol/ ch02-hello-diamol(我在整本书中都会使用这个缩写 diamol--它代表 Docker In A Month Of Lunches)。你刚刚输入的命令告诉 Docker 从这个镜像中运行一个容器。
Docker 在运行使用该镜像的容器之前,需要在本地有一个该镜像的副本。第一次运行这个命令时,你不会有该镜像的副本,你可以在第一行输出中看到:unable to find image locally 。然后 Docker 下载该镜像(Docker 称之为“pull”),你可以看到该镜像已经被下载。
现在 Docker 使用该镜像启动一个容器。该镜像包含了应用程序的所有内容,以及告诉 Docker 如何启动应用程序的指令。这个镜像中的应用程序只是一个简单的脚本,你可以看到输出结果,它启动了Hello from Chapter 2! 它写出了关于它在上面运行的计算机的一些详细信息:
-
机器名称,在这个例子中是
e5943557213b -
操作系统,在这个例子中是
Linux4.9.125-linuxkitx86_64 -
网络地址,在这个例子中是
172.17.0.2
我说你的输出将“类似于这样”——它不会完全相同,因为容器获取的一些信息取决于你的计算机。我在一个运行 Linux 操作系统和 64 位 Intel 处理器的机器上运行了这个命令。如果你使用 Windows 容器运行它,I'm running on行将显示以下内容:
--------------------- 我在以下系统上运行: Microsoft Windows [Version 10.0.17763.557] ---------------------
如果你正在使用树莓派,输出将显示它使用的是不同的处理器(armv7l是 ARM 32 位处理器的代号,而x86_64是 Intel 64 位处理器的代号):
--------------------- 我在以下系统上运行: Linux 4.19.42-v7+ armv7l ---------------------
这是一个非常简单的示例应用程序,但它展示了 Docker 的核心工作流程。有人将他们的应用程序打包到容器中运行(我为这个应用程序做了这件事,但你在下一章中会自己做),然后发布它,使其可供其他用户使用。然后任何有权访问的人都可以在容器中运行该应用程序。Docker 称这为构建、共享、运行。
这是一个非常强大的概念,因为无论应用程序有多复杂,工作流程都是相同的。在这种情况下,它是一个简单的脚本,但它也可以是一个具有多个组件、配置文件和库的 Java 应用程序。工作流程将完全相同。而且 Docker 镜像可以打包在支持 Docker 的任何计算机上运行,这使得应用程序完全可移植——可移植性是 Docker 的关键优势之一。
如果你使用相同的命令运行另一个容器会发生什么?
现在试试看,重复执行完全相同的 Docker 命令:
`docker container run diamol/ch02-hello-diamol`
你将看到与第一次运行类似的输出,但会有所不同。Docker 已经在本地有了一个镜像的副本,因此它不需要首先下载镜像;它直接运行容器。容器输出显示了相同的操作系统详细信息,因为你使用的是同一台计算机,但容器的计算机名和 IP 地址将不同:
*---------------------* Hello from Chapter 2! --------------------- My name is: 858a26ee2741 --------------------- Im running on: Linux 4.9.125-linuxkit x86_64 --------------------- My address is: inet addr:172.17.0.5 Bcast:172.17.255.255 Mask:255.255.0.0 ---------------------
现在我的应用程序正在名为858a26ee2741的机器上运行,IP 地址为172.17.0.5。机器名会每次改变,IP 地址也经常改变,但每个容器都在同一台计算机上运行,那么这些不同的机器名和网络地址是从哪里来的?我们将在下一部分理论中探讨这一点,然后回到练习。
2.2 容器是什么?
Docker 容器与物理容器有相同的概念——想象它就像一个装有应用程序的盒子。在盒子内部,应用程序似乎拥有自己的计算机:它有自己的机器名和 IP 地址,它还有一个自己的磁盘驱动器(Windows 容器还有自己的 Windows 注册表)。图 2.2 显示了应用程序是如何被容器封装的。

图 2.2 容器环境中的应用程序
这些都是虚拟资源——主机名、IP 地址和文件系统都是由 Docker 创建的。它们是由 Docker 管理的逻辑对象,它们都被组合在一起,以创建一个应用程序可以运行的环境。这就是容器的“盒子”。
盒子内的应用程序看不到盒子外面的任何东西,但盒子是在计算机上运行的,而这个计算机也可以运行许多其他盒子。这些盒子中的应用程序拥有它们各自独立的环境(由 Docker 管理),但它们都共享计算机的 CPU 和内存,并且它们都共享计算机的操作系统。您可以在图 2.3 中看到同一台计算机上的容器是如何隔离的。

图 2.3 一台计算机上的多个容器共享相同的操作系统、CPU 和内存。
这为什么如此重要呢?它解决了计算中的两个相互冲突的问题:隔离和密度。密度意味着尽可能在您的计算机上运行尽可能多的应用程序,以利用您拥有的所有处理器和内存。但是应用程序可能与其他应用程序不兼容——它们可能使用不同的 Java 或.NET 版本,它们可能使用不兼容的工具或库版本,或者其中一个可能具有繁重的工作负载,从而耗尽其他应用程序的处理能力。应用程序确实需要相互隔离,这阻止了您在单个计算机上运行大量应用程序,因此您无法获得密度。
解决该问题的最初尝试是使用虚拟机(VM)。虚拟机在概念上与容器相似,因为它们为你提供了一个运行应用程序的盒子,但虚拟机的盒子需要包含自己的操作系统——它不会共享虚拟机运行所在计算机的操作系统。比较图 2.3,它显示了多个容器,与图 2.4,它显示了同一台计算机上的多个虚拟机。

图 2.4 一台计算机上的多个虚拟机各自拥有自己的操作系统。
这在图中可能看起来只是微小的差异,但它有着巨大的影响。每个虚拟机(VM)都需要自己的操作系统,而这个操作系统可能需要使用数 GB 的内存和大量的 CPU 时间——消耗了本应可用于应用程序的计算能力。还有其他一些问题,比如操作系统的许可费用和维护更新时的负担。虚拟机以牺牲密度为代价提供了隔离。
容器为你提供了这两者。每个容器都共享运行容器的计算机的操作系统,这使得它们非常轻量级。容器启动迅速且运行高效,因此你可以在同一硬件上运行比虚拟机更多的容器——通常是五到十倍。你得到了密度,但每个应用程序都在自己的容器中,因此你也得到了隔离。这是 Docker 的另一个关键特性:效率。
现在你已经知道了 Docker 是如何施展其魔法的。在下一个练习中,我们将更紧密地与容器一起工作。
2.3 像远程计算机一样连接到容器
我们运行的第一个容器只做了一件事——应用程序打印了一些文本然后结束。有很多情况下,你只想要做一件事。也许你有一套完整的脚本来自动化某个过程。这些脚本需要特定的工具集来运行,所以你不能只是与同事分享脚本;你还需要分享一个描述设置所有工具的文档,而你的同事需要花费数小时来安装它们。相反,你可以将工具和脚本打包到 Docker 镜像中,分享镜像,然后你的同事可以在不需要额外设置工作的情况下在容器中运行你的脚本。
你还可以以其他方式使用容器。接下来,你将看到如何运行一个容器并连接到容器内部的终端,就像你连接到远程机器一样。你使用相同的docker container run命令,但你需要传递一些额外的标志来运行一个带有连接终端会话的交互式容器。
现在试试 Run the following command in your terminal session:
docker container run --interactive --tty diamol/base
--interactive标志告诉 Docker 你想要设置与容器的连接,而--tty标志意味着你想要连接到容器内部的终端会话。输出将显示 Docker 正在拉取镜像,然后你将留下一个命令提示符。这个命令提示符是容器内部的终端会话,如图 2.5 所示。

图 2.5 运行交互式容器并连接到容器的终端。
与 Windows 上的 Docker 命令完全相同,但你会进入一个 Windows 命令行会话:
Microsoft Windows [版本 10.0.17763.557] (c) 2018 Microsoft Corporation. All rights reserved. C:\>
无论哪种方式,你现在都在容器内部,你可以运行在命令行中通常可以运行的任何操作系统命令。
现在试试 Run the commands hostname and date and you’ll see details of the container’s environment:
/ # hostname f1695de1f2ec / # date Thu Jun 20 12:18:26 UTC 2019
如果你想要进一步探索,你需要对你的命令行有所熟悉,但这里你所拥有的是一个连接到远程机器的本地终端会话——这个机器恰好是一个运行在你电脑上的容器。例如,如果你使用安全外壳(SSH)连接到远程 Linux 机器,或者使用远程桌面协议(RDP)连接到远程 Windows Server Core 机器,你将获得与这里使用 Docker 完全相同的体验。
记住,容器是共享你的电脑操作系统的,这就是为什么如果你运行 Linux,你会看到一个 Linux shell;如果你使用 Windows,你会看到一个 Windows 命令行。有些命令对两者都相同(试试 ping google.com ),但有些则有不同的语法(在 Linux 中,你使用 ls 来列出目录内容,而在 Windows 中,你使用 dir)。
无论你使用的是哪种操作系统或处理器,Docker 本身都有相同的行为。是容器内的应用程序看到它正在运行在基于 Intel 的 Windows 机器上,或者基于 Arm 的 Linux 机器上。无论容器内运行的是什么,你都可以用 Docker 以相同的方式管理容器。
现在试试打开一个新的终端会话,你可以使用这个命令获取所有运行容器的详细信息:
docker container ls
输出显示了每个容器的信息,包括它所使用的镜像、容器 ID 以及 Docker 在容器启动时运行的命令——这是部分缩略输出:
CONTAINER ID IMAGE COMMAND CREATED STATUS f1695de1f2ec diamol/base "/bin/sh" 16 minutes ago Up 16 minutes
如果你有一双敏锐的眼睛,你会注意到容器 ID 与容器内部的 hostname 相同。Docker 为它创建的每个容器分配一个随机 ID,其中一部分用于 hostname。有许多 docker container 命令可以用来与特定的容器交互,你可以使用你想要识别的容器 ID 的前几个字符来识别。
现在试试 docker container top 命令列出了容器中运行的进程。我正在使用 f1 作为容器 ID f1695de1f2ec 的简称:
> docker container top f1 PID USER TIME COMMAND 69622 root 0:00 /bin/sh
如果容器中运行着多个进程,Docker 会显示它们所有。对于 Windows 容器来说,情况也是如此,除了容器应用程序外,它们总是有几个后台进程在运行。
现在试试 docker container logs 命令,它会显示容器收集到的任何日志条目:
> docker container logs f1 / # hostname f1695de1f2ec
Docker 使用容器中应用程序的输出收集日志条目。在这个终端会话的情况下,我看到我运行的命令及其结果,但对于一个真实的应用程序,你会看到你的代码的日志条目。例如,一个 Web 应用程序可能会为每个处理的 HTTP 请求写入一个日志条目,这些条目将显示在容器日志中。
现在试试这个命令:docker container inspect会显示一个容器的所有详细信息:
> docker container inspect f1 `` { "Id": "f1695de1f2ecd493d17849a709ffb78f5647a0bcd9d10f0d97ada0fcb7b05e98", "Created": "2019-06-20T12:13:52.8360567Z"
完整的输出显示了大量的底层信息,包括容器虚拟文件系统的路径、容器内运行的命令以及容器连接到的虚拟 Docker 网络--如果你在追踪应用程序的问题时,这些信息都可能很有用。它以大量 JSON 的形式出现,非常适合用脚本自动化,但不太适合在书中的代码列表中,所以我只展示了前几行。
当你与容器一起工作时,需要解决应用程序问题,想要检查进程是否使用了大量 CPU,或者想要查看 Docker 为容器设置的联网情况时,你将经常使用这些命令。
这些练习的另一个目的是帮助你意识到,从 Docker 的角度来看,所有容器看起来都是一样的。Docker 在每个应用程序之上添加了一个一致的管理层。你可以在 Linux 容器中运行一个 10 年的 Java 应用程序,在一个 Windows 容器中运行一个 15 年的.NET 应用程序,在一个树莓派上运行一个全新的 Go 应用程序。你将使用完全相同的命令来管理它们--run来启动应用程序,logs来读取日志,top来查看进程,以及inspect来获取详细信息。
你现在已经看到了更多你可以用 Docker 做到的事情;我们将以一些练习来结束,以使应用更加有用。你可以关闭你打开的第二个终端窗口(在那里你运行了docker container logs),回到第一个终端,它仍然连接到容器,并运行exit来关闭终端会话。
2.4 在容器中托管网站
到目前为止,我们已经运行了一些容器。前几个容器运行了一个打印一些文本然后退出的任务。下一个使用了交互标志,并连接我们到容器中的终端会话,该会话一直运行,直到我们退出会话。docker container ls将显示你没有容器,因为该命令只显示正在运行的容器。
现在试试这个命令:docker container ls --all,它会显示所有状态的容器:
> docker container ls --all CONTAINER ID IMAGE COMMAND CREATED STATUS f1695de1f2ec diamol/base "/bin/sh" About an hour ago Exited (0) 858a26ee2741 diamol/ch02-hello-diamol "/bin/sh -c ./cmd.sh" 3 hours ago Exited (0) 2cff9e95ce83 diamol/ch02-hello-diamol "/bin/sh -c ./cmd.sh" 4 hours ago Exited (0)
容器的状态为Exited。这里有几个关键点需要理解。
首先,容器仅在容器内的应用程序运行时才会运行。一旦应用程序进程结束,容器就会进入退出状态。当脚本完成后,“Hello World”容器会自动退出。我们连接的交互式容器在我们退出终端应用程序后立即退出。
其次,容器在退出后不会消失。处于退出状态的容器仍然存在,这意味着你可以再次启动它们,检查日志,并将文件复制到容器文件系统。你只能通过docker container ls看到正在运行的容器,但 Docker 不会删除退出状态的容器,除非你明确告诉它这样做。退出状态的容器仍然占用磁盘空间,因为它们的文件系统保留在计算机的磁盘上。
那么,关于启动那些在后台运行并持续运行的容器,又是怎样的情况呢?这实际上是 Docker 的主要用途:运行服务器应用程序,如网站、批处理和数据库。
现在就试试吧,这里有一个简单的例子,在一个容器中运行网站:
docker container run --detach --publish 8088:80 diamol/ch02-hello- diamol-web
这一次,你唯一会看到的输出是一个长的容器 ID,然后你会回到你的命令行。容器仍在后台运行。
现在就试试吧,运行docker container ls,你会看到新容器状态为Up:
> docker container ls CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES e53085ff0cc4 diamol/ch02-hello-diamol-web "bin\\httpd.exe -DFOR..." 52 seconds ago Up 50 seconds 443/tcp, 0.0.0.0:8088->80/tcp reverent_dubinsky
你刚刚使用的镜像为diamol/ch02-hello-diamol-web。这个镜像包含了 Apache 网络服务器和一个简单的 HTML 页面。当你运行这个容器时,你将有一个完整的网络服务器在运行,托管一个自定义网站。位于后台并监听网络流量(在这种情况下是 HTTP 请求)的容器需要在container run命令中添加一些额外的标志:
-
--detach--在后台启动容器并显示容器 ID -
--publish--将容器的一个端口发布到计算机
运行一个分离的容器只是将容器置于后台,使其启动并保持隐藏,就像 Linux 守护进程或 Windows 服务一样。发布端口需要更多的解释。当你安装 Docker 时,它会将自己注入到你的计算机的网络层。进入你的计算机的流量可以被 Docker 拦截,然后 Docker 可以将这些流量发送到容器。
容器默认情况下不会暴露给外部世界。每个容器都有自己的 IP 地址,但这是一个 Docker 为 Docker 管理的网络创建的 IP 地址——容器没有连接到计算机的物理网络。发布容器端口意味着 Docker 会在计算机端口上监听网络流量,然后将它发送到容器。在先前的例子中,发送到端口 8088 的计算机上的流量将被发送到端口 80 的容器中——你可以在图 2.6 中看到流量流向。

图 2.7 在本地机器上由容器提供服务的 Web 应用程序
这是一个非常简单的网站,但即便如此,这个应用程序仍然受益于 Docker 带来的可移植性和效率。网页内容与网页服务器打包在一起,因此 Docker 镜像包含了它所需的一切。网页开发者可以在他们的笔记本电脑上运行一个容器,整个应用程序——从 HTML 到网页服务器堆栈——将和在生产环境中由操作员在服务器集群上运行的 100 个容器上运行的应用程序完全相同。
这个容器中的应用程序会无限期地运行,因此容器也会持续运行。你可以使用我们之前使用的 docker container 命令来管理它。
现在试试看 docker container stats,这也是一个很有用的命令:它显示了容器正在使用的 CPU、内存、网络和磁盘的使用情况。对于 Linux 和 Windows 容器,输出略有不同:
> docker container stats e53 CONTAINER ID NAME CPU % PRIV WORKING SET NET I/O BLOCK I/O e53085ff0cc4 reverent_dubinsky 0.36% 16.88MiB 250kB / 53.2kB 19.4MB / 6.21MB
当你完成与容器的操作后,你可以使用 docker container rm 和容器 ID 来删除它,如果容器仍在运行,可以使用 --force 标志强制删除。
我们将以一个你将习惯定期运行的最后一个命令结束这个练习。
现在试试 Run this command to remove all your containers:
docker container rm --force $(docker container ls --all --quiet)
$() 语法将一个命令的输出传递给另一个命令--它在 Linux 和 Mac 终端以及 Windows PowerShell 上都同样有效。结合这些命令可以获取你电脑上所有容器的 ID 列表,并删除它们。这是一种整理容器的不错方法,但请谨慎使用,因为它不会要求确认。
2.5 理解 Docker 运行容器的方式
在本章中,我们已经进行了很多“试试看”练习,现在你应该对使用容器的基本操作感到满意了。
在本章的第一个“试试看”中,我谈到了 Docker 核心的构建、共享、运行工作流程。这个工作流程使得软件的分发变得非常容易--我已经构建了所有示例容器镜像并分享了它们,知道你可以在 Docker 中运行它们,并且它们对你和我都会产生相同的效果。现在有大量的项目使用 Docker 作为发布软件的首选方式。你可以尝试新的软件--比如,Elasticsearch、SQL Server 的最新版本,或者 Ghost 博客引擎--使用你在这里使用过的相同类型的 docker container run 命令。
我们将以一些额外的背景知识结束本章,这样你就能对使用 Docker 运行应用程序时实际发生的事情有一个扎实的理解。安装 Docker 和运行容器看似简单--实际上涉及几个不同的组件,如图 2.8 所示。
-
Docker 引擎是 Docker 的管理组件。它负责本地镜像缓存,当你需要时下载镜像,如果已经下载则重用它们。它还与操作系统协同工作以创建容器、虚拟网络以及其他 Docker 资源。引擎是一个始终在后台运行的进程(类似于 Linux 守护进程或 Windows 服务)。
-
Docker 引擎通过 Docker API 提供所有功能,这是一个基于标准的 HTTP REST API。你可以配置引擎,使其 API 只在本地计算机上可用(这是默认设置),或者使其对网络上的其他计算机可用。
-
Docker 命令行界面(CLI)是 Docker API 的客户端。当你运行 Docker 命令时,CLI 实际上会将它们发送到 Docker API,然后 Docker 引擎执行工作。
了解 Docker 的架构是很好的。与 Docker 引擎交互的唯一方式是通过 API,并且有不同选项可以用来访问 API 和保护它。CLI 通过向 API 发送请求来工作。
到目前为止,我们使用 CLI 来管理运行 Docker 的同一台计算机上的容器,但你可以将你的 CLI 指向运行 Docker 的远程计算机上的 API,并控制该机器上的容器——这就是你管理不同环境中的容器(如你的构建服务器、测试和生产环境)的方法。Docker API 在每个操作系统上都是相同的,因此你可以使用你的 Windows 笔记本电脑上的 CLI 来管理你的 Raspberry Pi 上的容器,或者云中的 Linux 服务器上的容器。

图 2.8 Docker 的组件
Docker API 有一个已发布的规范,Docker CLI 不仅仅是唯一的客户端。有几个图形用户界面连接到 Docker API,并为你提供了一个与容器交互的视觉方式。API 揭示了关于容器、镜像和其他 Docker 管理的资源的所有细节,因此它可以支持如图 2.9 所示的丰富仪表板。

图 2.9 Docker 通用控制平面,容器图形用户界面
这是通用控制平面(UCP),Docker 背后公司的商业产品(docs.docker.com/ee/ucp/)。Portainer 是另一个选择,它是一个开源项目。UCP 和 Portainer 都作为容器本身运行,因此它们易于部署和管理。
我们不会比这更深入地探讨 Docker 架构。Docker 引擎使用一个名为 containerd 的组件来实际管理容器,而 containerd 又利用操作系统功能来创建容器所代表的虚拟环境。
你不需要理解容器的底层细节,但了解这一点是好的:containerd 是一个由云原生计算基金会监管的开源组件,运行容器的规范是开放和公共的;它被称为开放容器倡议(OCI)。
Docker 是迄今为止最受欢迎且易于使用的容器平台,但它不是唯一的。你可以放心地投资容器,而不用担心你会被锁定在某个供应商的平台。
2.6 实验室:探索容器文件系统
这是本书的第一个实验室,所以这就是它的全部内容。实验室为你设定了一个任务,让你自己完成,这将真正帮助你巩固你在本章中学到的知识。会有一些指导和几个提示,但大部分是关于你超越规定的“现在试试”练习,找到自己的解决问题的方法。
每个实验室在本书的 GitHub 仓库中都有一个示例解决方案。花些时间自己尝试一下是值得的,但如果你想检查我的解决方案,你可以在这里找到:github.com/sixeyed/diamol/tree/master/ch02/lab。
我们开始吧:你的任务是运行本章中的网站容器,但替换 index.html 文件,这样当你浏览到容器时,你会看到一个不同的主页(你可以使用你喜欢的任何内容)。记住,容器有自己的文件系统,在这个应用程序中,网站正在提供位于容器文件系统上的文件。
这里有一些提示来帮助你开始:
-
您可以通过运行
dockercontainer来获取您可以在容器上执行的所有操作的列表。 -
在任何
docker命令中添加--help,你将看到更详细的帮助文本。 -
在
diamol/ch02-hello-diamol-webDocker 镜像中,网站内容是从目录/usr/local/apache2/htdocs(在 Windows 上是C:\usr\local\apache2\htdocs)提供的。
祝你好运 😃
3 构建自己的 Docker 镜像
在上一章中,你已经运行了一些容器并使用 Docker 来管理它们。容器无论应用程序使用什么技术栈,都能提供一致的应用体验。到目前为止,你使用的是我构建和分享的 Docker 镜像;在本章中,你将了解如何构建自己的镜像。这里你将学习 Dockerfile 语法,以及你在容器化自己的应用程序时始终会使用的某些关键模式。
3.1 从 Docker Hub 使用容器镜像
我们将从本章中构建的镜像的最终版本开始,这样你可以看到它是如何设计得与 Docker 一起良好工作的。现在试试看的练习都使用一个名为 web-ping 的简单应用程序,该应用程序检查网站是否运行。该应用程序将在容器中运行,并每三秒向我的博客的 URL 发送 HTTP 请求,直到容器停止。
你可以从第二章了解到,如果容器镜像尚未在您的机器上,docker container run 会本地下载该镜像。这是因为软件分发已经内置到 Docker 平台中。你可以让 Docker 为你管理这些,当需要时它会拉取镜像,或者你可以使用 Docker CLI 明确地拉取镜像。
现在试试看 拉取 web-ping 应用程序的容器镜像:
docker image pull diamol/ch03-web-ping
你会看到类似于图 3.1 中的我的输出。

图 3.1 从 Docker Hub 拉取镜像
镜像名称是 diamol/ch03-web-ping,它存储在 Docker Hub 上,这是 Docker 默认查找镜像的位置。镜像服务器被称为注册表,Docker Hub 是一个你可以免费使用的公共注册表。Docker Hub 还有一个网页界面,你可以在 hub.docker.com/r/diamol/ch03-web-ping 找到关于这个镜像的详细信息。
docker image pull 命令有一些有趣的输出,它显示了镜像是如何存储的。Docker 镜像在逻辑上是一件事——你可以把它想象成一个包含整个应用程序堆栈的大 zip 文件。这个镜像包含了 Node.js 运行时以及我的应用程序代码。
在拉取过程中,你不会看到单个文件被下载;你会看到许多正在进行的下载。这些被称为镜像层。Docker 镜像在物理上存储为许多小文件,Docker 将它们组装在一起以创建容器的文件系统。当所有层都被拉取后,完整的镜像就可以使用了。
现在试试看 让我们从这个镜像中运行一个容器,看看应用程序做了什么:
docker container run -d --name web-ping diamol/ch03-web-ping
-d 标志是 --detach 的简写,因此这个容器将在后台运行。应用程序像批处理作业一样运行,没有用户界面。与第二章中我们运行的分离的网站容器不同,这个容器不接受传入流量,因此你不需要发布任何端口。
在这个命令中有一个新的标志,就是--name。你知道你可以使用 Docker 生成的 ID 来处理容器,但你也可以给它们一个友好的名称。这个容器被称为web-ping,你可以使用这个名字来引用容器而不是使用随机的 ID。
我的博客现在正被你容器中运行的应用程序 ping。该应用程序在一个无限循环中运行,你可以使用与第二章中熟悉相同的docker container命令来查看它在做什么。
现在试试看 查看应用程序的日志,这些日志正在由 Docker 收集:
docker container logs web-ping
你将看到图 3.2 中的输出,显示应用程序正在向blog.sixeyed.com 发送 HTTP 请求。

图 3.2 网络 ping 容器正在运行,向我的博客发送持续流量
一个能够发送网络请求并记录响应时间的应用程序非常有用——你可以将其用作监控网站运行时间的依据。但这个应用程序看起来是硬编码来使用我的博客,所以除了我之外对任何人来说都几乎毫无用处。
除了它不是之外。该应用程序实际上可以被配置为使用不同的 URL,请求之间的不同间隔,甚至是不同类型的 HTTP 调用。这个应用程序从系统的环境变量中读取它应该使用的配置值。
环境变量是操作系统提供的关键/值对。在 Windows 和 Linux 上它们以相同的方式工作,并且是存储小块数据的非常简单的方式。Docker 容器也有环境变量,但它们不是来自计算机的操作系统,而是由 Docker 以创建容器的主机名和 IP 地址相同的方式设置。
web-ping 镜像为环境变量设置了一些默认值。当你运行一个容器时,Docker 会填充这些环境变量,这就是应用程序用来配置网站 URL 的方式。你可以在创建容器时指定不同的环境变量值,这将改变应用程序的行为。
现在试试看 移除现有的容器,并运行一个新的容器,为TARGET环境变量指定一个值:
docker rm -f web-ping docker container run --env TARGET=google.com diamol/ch03-web-ping
你这次的输出将看起来像图 3.3 中的我的输出。

图 3.3 来自同一镜像的容器,向 Google 发送流量
这个容器正在做不同的事情。首先,它正在交互式运行,因为你没有使用--detach标志,所以应用程序的输出显示在你的控制台上。容器将继续运行,直到你通过按 Ctrl-C 结束应用程序。其次,它现在正在 ping google.com 而不是 blog.sixeyed.com。
这将是本章中你将获得的主要收获之一--Docker 镜像可能包含应用程序的默认配置值集,但你应该能够在运行容器时提供不同的配置设置。
环境变量是一种非常简单的方式来实现这一点。web-ping 应用程序代码会查找一个键为 TARGET 的环境变量。该键在镜像中设置为 blog.sixeyed.com 的值,但你可以通过使用 docker container run 命令中的 --env 标志来提供不同的值。图 3.4 显示了容器有自己的设置,彼此不同,也与镜像不同。
主机计算机也有它自己的环境变量集,但它们与容器是分开的。每个容器只包含 Docker 填充的环境变量。图 3.4 中的重要之处在于,每个容器中的 web-ping 应用程序是相同的--它们使用相同的镜像,因此应用程序运行的是完全相同的二进制文件集,但由于配置不同,行为不同。

图 3.4 Docker 镜像和容器中的环境变量
提供这种灵活性的责任在于 Docker 镜像的作者,你现在将看到如何做到这一点,因为你将从一个 Dockerfile 构建你的第一个 Docker 镜像。
3.2 编写你的第一个 Dockerfile
Dockerfile 是你编写的一个简单的脚本,用于打包应用程序--它是一组指令,Docker 镜像是输出结果。Dockerfile 语法易于学习,你可以使用 Dockerfile 打包任何类型的应用程序。就脚本语言而言,它非常灵活。常见任务有自己的命令,而对于任何需要定制的任务,你可以使用标准 shell 命令(Linux 上的 Bash 或 Windows 上的 PowerShell)。列表 3.1 展示了打包 web-ping 应用程序的完整 Dockerfile。
列表 3.1 web-ping Dockerfile
FROM diamol/node ENV TARGET="blog.sixeyed.com" ENV METHOD="HEAD" ENV INTERVAL="3000" WORKDIR /web-ping COPY app.js . CMD ["node", "/web-ping/app.js"]
即使这是你第一次看到 Dockerfile,你也许可以很好地猜测这里发生了什么。Dockerfile 指令是 FROM、ENV、WORKDIR、COPY 和 CMD;它们是大写字母,但这是一种约定,不是必需的。以下是每个指令的分解:
-
FROM--每个镜像都必须从一个镜像开始。在这种情况下,web-ping镜像将使用diamol/node镜像作为其起点。该镜像已安装 Node.js,这是 web-ping 应用程序运行所需的一切。 -
ENV-- 设置环境变量的值。语法是[key]="[value]",这里有三个ENV指令,分别设置三个不同的环境变量。 -
WORKDIR-- 在容器镜像文件系统中创建一个目录,并将其设置为当前工作目录。正斜杠语法适用于 Linux 和 Windows 容器,因此这将在 Linux 上创建/web-ping,在 Windows 上创建C:\web-ping。 -
COPY-- 从本地文件系统复制文件或目录到容器镜像。语法是[source path] [target path]-- 在这种情况下,我正在将 app.js 从我的本地机器复制到镜像中的工作目录。 -
CMD-- 指定在 Docker 从镜像启动容器时要运行的命令。这会运行 Node.js,启动 app.js 中的应用程序代码。
就这些了。这些指令几乎就是您在 Docker 中打包自己的应用程序所需的所有内容,并且在这五行中已经包含了一些良好的实践。
现在试试看。您不需要复制和粘贴这个 Dockerfile;它都在本书的源代码中,您在第一章中克隆或下载了它。导航到您下载它的位置,并检查您是否拥有构建此镜像所需的所有文件:
cd ch03/exercises/web-ping ls
您应该看到您有三个文件:
-
Dockerfile(没有文件扩展名),其内容与列表 3.1 相同
-
app.js,其中包含 web-ping 应用的 Node.js 代码
-
README.md,它只是使用镜像的文档
您可以在图 3.5 中看到这些内容。
您不需要了解 Node.js 或 JavaScript 就可以打包此应用并在 Docker 中运行它。如果您查看 app.js 中的代码,您会看到它相当基础,并且它使用标准的 Node.js 库来执行 HTTP 调用并从环境变量中获取配置值。
在这个目录中,您拥有构建 web-ping 应用程序镜像所需的一切。

图 3.5 构建 Docker 镜像所需的内容
3.3 构建自己的容器镜像
Docker 在从 Dockerfile 构建镜像之前需要知道一些事情。它需要一个镜像的名称,并且它需要知道所有将要打包到镜像中的文件的存储位置。您已经在正确的目录中打开了一个终端,所以您已经准备好了。
现在试试看。通过运行 docker image build 将这个 Dockerfile 转换为 Docker 镜像:
docker image build --tag web-ping .
--tag 参数是镜像的名称,最后一个参数是 Dockerfile 和相关文件所在的目录。Docker 将此目录称为“上下文”,点号表示“使用当前目录”。您将看到 build 命令的输出,执行 Dockerfile 中的所有指令。我的构建过程如图 3.6 所示。
如果你从build命令收到了任何错误,你首先需要检查 Docker Engine 是否已启动。你需要确保 Windows 或 Mac 上的 Docker Desktop 应用程序正在运行(检查任务栏中的鲸鱼图标)。然后检查你是否在正确的目录中。你应该在ch03-web-ping目录中,那里有 Dockerfile 和 app.js 文件。最后,检查你是否正确输入了build命令--命令末尾的点号是必需的,它告诉 Docker 构建上下文是当前目录。

图 3.6 构建 web-ping Docker 镜像的输出
如果在构建过程中收到关于文件权限的警告,那是因为你正在使用 Windows 上的 Docker 命令行来构建 Linux 容器,这是由于 Docker Desktop 的 Linux 容器模式所致。Windows 不会像 Linux 那样记录文件权限,所以这个警告是在告诉你,从你的 Windows 机器复制过来的所有文件在 Linux Docker 镜像中都被设置为具有完全的读写权限。
当你在输出中看到“成功构建”和“成功标记”的消息时,你的镜像就构建完成了。它存储在本地镜像缓存中,你可以使用 Docker 命令来列出镜像。
TRY IT NOW 列出所有以“w”开头的标签名的镜像:
docker image ls 'w*'
你将看到你的 web-ping 镜像被列出:
> docker image ls w* REPOSITORY TAG IMAGE ID CREATED SIZE web-ping latest f2a5c430ab2a 14 minutes ago 75.3MB
你可以使用这个镜像,就像你从 Docker Hub 下载的那个一样。应用程序的内容是相同的,配置设置可以通过环境变量应用。
现在试试 Run a container from your own image to ping Docker’s website every five seconds:
docker container run -e TARGET=docker.com -e INTERVAL=5000 web-ping
你的输出将像我图 3.7 中的那样,第一条日志确认目标 Web URL 是 docker.com,ping 间隔是 5000 毫秒。

图 3.7 从你的镜像运行 web-ping 容器
该容器正在前台运行,所以你需要使用 Ctrl-C 来停止它。这会结束应用程序,容器将进入退出状态。
你已经打包了一个简单的应用程序,使其在 Docker 中运行,对于更复杂的应用程序,这个过程也是完全相同的。你编写 Dockerfile,包含所有打包应用程序的步骤,收集需要放入 Docker 镜像中的资源,并决定你希望你的镜像用户如何配置应用程序的行为。
3.4 理解 Docker 镜像和镜像层
你将在阅读这本书的过程中构建很多镜像。对于本章,我们将坚持使用这个简单的镜像,并使用它来更好地理解镜像的工作原理,以及镜像和容器之间的关系。
Docker 镜像包含你打包的所有文件,这些文件成为容器的文件系统,它还包含关于镜像本身的许多元数据。这包括镜像构建的简要历史。你可以用它来查看镜像的每一层以及构建层的命令。
现在试试看。检查你的 web-ping 镜像的历史:
docker image history web-ping
你将看到每个镜像层的输出行;这些是我镜像的前几行(缩略):
> docker image history web-ping IMAGE CREATED CREATED BY 47eeeb7cd600 30 hours ago /bin/sh -c #(nop) CMD "node" "/web-ping/ap... <missing> 30 hours ago /bin/sh -c #(nop) COPY file:a7cae366c9996502... <missing> 30 hours ago /bin/sh -c #(nop) WORKDIR /web-ping
CREATED 和 BY 命令是 Dockerfile 指令——存在一对一的关系,所以 Dockerfile 中的每一行都创建一个镜像层。我们将深入一点理论,因为理解镜像层是您高效使用 Docker 的关键。
Docker 镜像是一组逻辑上的镜像层。层是物理存储在 Docker Engine 缓存中的文件。这为什么很重要:镜像层可以在不同的镜像和不同的容器之间共享。如果你有很多运行 Node.js 应用的容器,它们将共享包含 Node.js 运行时的同一组镜像层。图 3.8 展示了这是如何工作的。

图 3.9 列出镜像以查看它们的大小
看起来所有的 Node.js 镜像占用的空间相同——Linux 上每个 75 MB。有三个这样的镜像:diamol/node,这是你在diamol/ch03-web-ping从 Docker Hub 拉取的原始示例应用,以及你在web-ping中自己构建的版本。它们应该共享基础镜像层,但docker image ls的输出表明它们每个都是 75 MB 大小,所以总共是 75 * 3 = 225 MB。
但并不完全是这样。你看到的尺寸列是镜像的逻辑大小——这是如果没有其他镜像在你的系统上,该镜像将使用的磁盘空间量。如果你有其他共享层的镜像,Docker 使用的磁盘空间要小得多。你无法从镜像列表中看到这一点,但有一些 Docker 系统命令可以告诉你更多信息。
现在试试。我的镜像列表显示总共有 363.96 MB 的镜像,但这只是逻辑大小。system df命令显示了 Docker 实际使用的磁盘空间:
docker system df
你可以在图 3.10 中看到,我的镜像缓存实际上使用了 202.2 MB,这意味着 163 MB 的镜像层在镜像之间被共享,节省了 45%的磁盘空间。当你有大量应用程序镜像都共享相同的运行时基础层时,通过重用节省的磁盘空间量通常要大得多。这些基础层可能有 Java、.NET Core、PHP——无论你使用什么技术栈,Docker 的行为都是相同的。

图 3.10 检查 Docker 的磁盘空间使用情况
最后一点理论。如果镜像层被共享,它们不能被编辑——否则一个镜像的变化会级联到所有共享更改层的其他镜像。Docker 通过使镜像层只读来强制执行这一点。一旦通过构建镜像创建了一个层,该层就可以被其他镜像共享,但不能被更改。你可以通过优化你的 Dockerfile 来利用这一点,使你的 Docker 镜像更小,构建更快。
3.5 优化 Dockerfile 以使用镜像层缓存
你的web-ping镜像中有一层包含应用程序的 JavaScript 文件。如果你修改了该文件并重新构建你的镜像,你将得到一个新的镜像层。Docker 假设 Docker 镜像中的层按照一个定义的顺序排列,所以如果你在序列中间修改了一个层,Docker 不会假设它可以重用序列中的后续层。
现在试试。修改ch03-web-ping目录中的app.js文件。不需要是代码更改;只需在文件末尾添加一个新空行即可。然后构建你 Docker 镜像的新版本:
docker image build -t web-ping:v2 .
你将看到与我图 3.11 中相同的输出。构建步骤 2 到 5 使用缓存中的层,步骤 6 和 7 生成新的层。

图 3.11 构建可以使用缓存的层的镜像
每个 Dockerfile 指令都会生成一个镜像层,但如果指令在构建之间没有变化,并且进入指令的内容相同,Docker 知道它可以使用缓存中的先前层。这样可以节省再次执行 Dockerfile 指令并生成重复层。输入是相同的,所以输出也将是相同的,因此 Docker 可以使用缓存中已有的内容。
Docker 通过生成一个哈希值来判断输入是否与缓存中的内容匹配,这个哈希值就像输入的数字指纹。哈希值是由 Dockerfile 指令和任何被复制的文件的内容生成的。如果现有镜像层中没有与该哈希值匹配的内容,Docker 将执行指令,这会破坏缓存。一旦缓存被破坏,Docker 将执行后续的所有指令,即使它们没有发生变化。
即使在这个小示例镜像中,这也产生了影响。app.js文件自上次构建以来已更改,因此步骤 6 中的COPY指令需要运行。步骤 7 中的CMD指令与上次构建相同,但由于步骤 6 中缓存被破坏,该指令也会运行。
你编写的任何 Dockerfile 都应该进行优化,以便指令按照它们变化的频率进行排序——在 Dockerfile 的开始处放置不太可能改变的指令,在末尾放置最可能改变的指令。目标是让大多数构建只需要执行最后的指令,其他所有内容都使用缓存。当你开始共享你的镜像时,这可以节省时间、磁盘空间和网络带宽。
web-ping Dockerfile 中只有七个指令,但它仍然可以进行优化。CMD指令不需要放在 Dockerfile 的末尾;它可以在FROM指令之后任何位置,并且仍然会产生相同的结果。由于它不太可能改变,所以你可以将它移动到更靠近顶部的地方。并且可以使用一个ENV指令来设置多个环境变量,所以三个单独的ENV指令可以合并。优化后的 Dockerfile 显示在列表 3.2 中。
列表 3.2 优化后的 web-ping Dockerfile
FROM diamol/node CMD ["node", "/web-ping/app.js"] ENV TARGET="blog.sixeyed.com" \ METHOD="HEAD" \ INTERVAL="3000" WORKDIR /web-ping COPY app.js .
现在尝试一下 优化后的 Dockerfile 也包含在本章的源代码中。切换到web-ping-optimized文件夹,并从新的 Dockerfile 构建镜像:
cd ../web-ping-optimized docker image build -t web-ping:v3 .
与之前的构建相比,你不会注意到太大的差异。现在有五个步骤而不是七个,但最终结果是一样的——你可以从这个镜像运行容器,它的行为就像其他版本一样。但现在,如果你更改了app.js中的应用程序代码并重新构建,除了最后的步骤之外,所有步骤都来自缓存,这正是你想要的,因为这就是你更改的全部内容。
本章关于构建镜像的内容到此结束。你已经看到了 Dockerfile 的语法和需要了解的关键指令,并且学习了如何从 Docker CLI 构建和使用镜像。
从本章中还可以提取两个重要的事项,这些事项将有助于您构建的每一个镜像:优化您的 Dockerfile,并确保您的镜像具有可移植性,以便在部署到不同环境时使用相同的镜像。这实际上意味着您应该注意如何结构化您的 Dockerfile 指令,并确保应用程序可以从容器中读取配置值。这意味着您可以快速构建镜像,并且在部署到生产环境时,您使用的是在测试环境中经过质量审核的完全相同的镜像。
3.6 实验室
好的,现在是实验室时间。这里的目的是回答这个问题:如何在没有 Dockerfile 的情况下创建 Docker 镜像?Dockerfile 用于自动化应用程序的部署,但您并不总是能够自动化一切。有时您需要手动运行应用程序并完成一些步骤,而这些步骤无法被脚本化。
这个实验室是这个更简单版本的。您将从一个 Docker Hub 上的镜像开始:diamol/ch03-lab。这个镜像在路径 /diamol/ch03.txt 处有一个文件。您需要更新这个文本文件并在末尾添加您的名字。然后使用您更改的文件创建自己的镜像。不允许使用 Dockerfile。
如果需要,可以在本书的 GitHub 仓库中找到示例解决方案。您可以在以下链接找到它:github.com/sixeyed/diamol/tree/master/ch03/lab。
这里有一些提示来帮助您开始:
-
记住
-it标志可以让您以交互方式运行到容器中。 -
容器的文件系统在退出时仍然存在。
-
还有许多您尚未使用的命令。
docker container --help将显示两个可能帮助您解决实验室问题的命令。
4 从源代码打包应用程序到 Docker 镜像
构建 Docker 镜像很容易。在第三章中,你学习了只需要在 Dockerfile 中添加一些指令就可以打包一个在容器中运行的应用程序。你需要知道另一件事来打包你自己的应用程序:你还可以在 Dockerfile 中运行命令。
命令在构建过程中执行,并且任何来自命令的文件系统更改都将保存在镜像层中。这使得 Dockerfile 变成了最灵活的打包格式之一;你可以展开 zip 文件,运行 Windows 安装程序,以及做几乎所有其他事情。在本章中,你将利用这种灵活性来从源代码打包应用程序。
4.1 当你有 Dockerfile 时,还需要构建服务器吗?
在你的笔记本电脑上构建软件是你在本地开发时做的事情,但当你在一个团队中工作时,有一个更严格的交付过程。有一个共享的源代码控制系统,如 GitHub,每个人都在那里推送他们的代码更改,通常还有一个单独的服务器(或在线服务),当更改被推送时,它会构建软件。
该过程存在是为了尽早发现问题。如果一个开发者在推送代码时忘记添加文件,构建将在构建服务器上失败,并且团队将会被通知。它保持了项目的健康,但代价是必须维护一个构建服务器。大多数编程语言需要很多工具来构建项目--图 4.1 展示了一些示例。

图 4.1 每个人都需要相同的工具集来构建一个软件项目。
这里有很大的维护开销。团队的新成员将花费他们第一天的时间来安装工具。如果一个开发者更新了他们的本地工具,使得构建服务器运行的是不同版本,构建可能会失败。即使你使用的是托管构建服务,你也会遇到同样的问题,在那里你可能只能安装有限的一组工具。
一次性打包构建工具集并共享它将更加干净利落,这正是你可以使用 Docker 实现的。你可以编写一个 Dockerfile 来脚本化所有工具的部署,并将其构建成一个镜像。然后你可以在你的应用程序 Dockerfile 中使用该镜像来编译源代码,最终的输出是你的打包应用程序。
让我们从一个非常简单的例子开始,因为在这个过程中有几个新事物需要理解。列表 4.1 展示了一个具有基本工作流程的 Dockerfile。
列表 4.1 一个多阶段 Dockerfile
FROM diamol/base AS build-stage RUN echo 'Building...' > /build.txt FROM diamol/base AS test-stage COPY --from=build-stage /build.txt /build.txt RUN echo 'Testing...' >> /build.txt FROM diamol/base COPY --from=test-stage /build.txt /build.txt CMD cat /build.txt
这被称为多阶段 Dockerfile,因为构建有几个阶段。每个阶段都以FROM指令开始,你可以使用AS参数为阶段命名。列表 4.1 有三个阶段:build-stage、test-stage和最终的未命名阶段。尽管有多个阶段,但输出将是一个包含最终阶段内容的单个 Docker 镜像。
每个阶段都是独立运行的,但你可以从之前的阶段复制文件和目录。我使用带有--from参数的COPY指令,这告诉 Docker 从 Dockerfile 中的早期阶段复制文件,而不是从宿主机的文件系统中复制。在这个例子中,我在构建阶段生成一个文件,将其复制到测试阶段,然后将文件从测试阶段复制到最终阶段。
这里有一个新的指令,RUN,我使用它来写入文件。RUN指令在构建过程中在容器内执行一个命令,并且该命令的任何输出都保存在镜像层中。你可以在RUN指令中执行任何操作,但你想运行的命令需要存在于你在FROM指令中使用的 Docker 镜像中。在这个例子中,我使用了diamol/base作为基础镜像,它包含echo命令,所以我确定我的RUN指令会工作。
图 4.2 显示了构建此 Dockerfile 时将要发生的情况--Docker 将按顺序运行阶段。


理解各个阶段是隔离的很重要。你可以使用不同基础镜像和安装了不同工具集的集合,并运行你喜欢的任何命令。最终阶段的输出将只包含你从早期阶段显式复制的文件。如果任何阶段的命令失败,整个构建将失败。
现在尝试一下 打开一个终端会话到存储本书源代码的文件夹,并构建此多阶段 Dockerfile:
cd ch04/exercises/multi-stage docker image build -t multi-stage .
你会看到构建会按照 Dockerfile 中的顺序执行步骤,这通过图 4.3 中可以看到的阶段进行顺序构建。


这是一个简单的例子,但构建任何复杂性的应用程序的单个 Dockerfile 的模式是相同的。图 4.4 显示了 Java 应用程序的工作流程。


在构建阶段,您使用一个已安装应用程序构建工具的基础镜像。您从主机机器复制源代码并运行 build 命令。您可以在测试阶段添加一个运行单元测试的步骤,该步骤使用已安装测试框架的基础镜像,从构建阶段复制编译的二进制文件,并运行测试。最终阶段从一个仅安装应用程序运行时的基础镜像开始,并从构建阶段复制在测试阶段成功测试的二进制文件。
这种方法使您的应用程序真正具有可移植性。您可以在任何地方运行应用程序,也可以在任何地方构建应用程序——Docker 是唯一的前提条件。您的构建服务器只需要安装 Docker;新团队成员可以在几分钟内设置好,构建工具都集中存储在 Docker 镜像中,因此不会出现不同步的情况。
所有主要的应用程序框架已经在 Docker Hub 上发布了带有构建工具的公共镜像,并且有单独的应用程序运行时镜像。您可以直接使用这些镜像或将其封装在自己的镜像中。您将能够使用由项目团队维护的最新更新的镜像。
4.2 应用程序概述:Java 源代码
现在我们将转向一个真实示例,我们将使用 Docker 构建和运行一个简单的 Java Spring Boot 应用程序。您不需要是 Java 开发者或在其机器上安装任何 Java 工具即可使用此应用程序;您所需的一切都将包含在 Docker 镜像中。如果您不使用 Java,您也应该阅读本节内容——它描述了一个适用于其他编译语言(如.NET Core 和 Erlang)的模式。
源代码位于本书的仓库中,路径为 ch04/ exercises/image-of-the-day 。应用程序使用了一套相当标准的 Java 工具:Maven,用于定义构建过程和获取依赖项,以及 OpenJDK,它是一个可自由分发的 Java 运行时和开发工具包。Maven 使用 XML 格式来描述构建,Maven 命令行称为 mvn 。这些信息应该足以理解列表 4.2 中的应用程序 Dockerfile。
列表 4.2 使用 Maven 构建 Java 应用程序的 Dockerfile
FROM diamol/maven AS builder WORKDIR /usr/src/iotd COPY pom.xml . RUN mvn -B dependency:go-offline COPY . . RUN mvn package # app FROM diamol/openjdk WORKDIR /app COPY --from=builder /usr/src/iotd/target/iotd-service-0.1.0.jar . EXPOSE 80 ENTRYPOINT ["java", "-jar", "/app/iotd-service-0.1.0.jar"]
这里的大多数 Dockerfile 指令都是您之前见过的,模式与您构建的示例相似。这是一个多阶段 Dockerfile,您可以通过存在多个 FROM 指令来判断,步骤安排旨在从 Docker 的镜像层缓存中获得最大效益。
第一个阶段被称为 builder 。以下是构建阶段发生的情况:
-
它使用
diamol/maven镜像作为基础。该镜像已安装了 OpenJDK Java 开发套件以及 Maven 构建工具。 -
构建阶段的开始是在镜像中创建一个工作目录,然后复制进
pom.xml文件,这是 Maven 对 Java 构建的定义。 -
第一个
RUN语句执行 Maven 命令,获取所有应用程序依赖项。这是一个昂贵的操作,因此它有自己的步骤来利用 Docker 层缓存。如果有新的依赖项,XML 文件将发生变化,该步骤将运行。如果依赖项没有变化,则使用层缓存。 -
接下来,将剩余的源代码复制进来--
COPY..表示“从 Docker 构建运行的位置,将所有文件和目录复制到镜像中的工作目录。” -
构建阶段的最后一步是运行
mvnpackage,它编译并打包应用程序。输入是一组 Java 源代码文件,输出是一个名为 JAR 文件的 Java 应用程序包。
当这个阶段完成后,编译的应用程序将存在于构建阶段的文件系统中。如果 Maven 构建过程中出现任何问题--如果网络离线且获取依赖项失败,或者源代码中存在编码错误--RUN 指令将失败,整个构建将失败。
如果构建阶段成功完成,Docker 将继续执行最终阶段,该阶段生成应用程序镜像:
-
它从
diamol/openjdk开始,该镜像包含 Java 11 运行时,但没有 Maven 构建工具。 -
这个阶段创建了一个工作目录,并将构建阶段的编译 JAR 文件复制进来。Maven 将应用程序及其所有 Java 依赖项打包在这个单一的 JAR 文件中,因此从构建阶段只需要这个。
-
该应用程序是一个监听 80 端口的网络服务器,因此该端口在
EXPOSE指令中明确列出,告诉 Docker 该端口可以被发布。 -
ENTRYPOINT指令是CMD指令的替代方案--它告诉 Docker 从镜像启动容器时要做什么,在这种情况下是运行 Java 并指定应用程序 JAR 的路径。
现在尝试一下 浏览到 Java 应用程序源代码并构建镜像:
cd ch04/exercises/image-of-the-day docker image build -t image-of-the-day .
由于你会看到 Maven 的所有日志、获取依赖项以及 Java 构建过程中的日志,所以这个构建过程会产生大量的输出。图 4.5 显示了构建过程的简略部分。

图 4.5 在 Docker 中运行 Maven 构建的输出
你刚刚构建了什么?这是一个简单的 REST API,它封装了对 NASA 天文图片每日服务的访问(apod.nasa.gov)。Java 应用程序从 NASA 获取当天图片的详细信息并将其缓存,这样你就可以重复调用此应用程序,而无需反复调用 NASA 的服务。
Java API 只是你将在本章中运行的全应用程序的一部分——它实际上会使用多个容器,并且它们需要相互通信。容器通过虚拟网络访问彼此,使用 Docker 在创建容器时分配的虚拟 IP 地址。你可以通过命令行创建和管理虚拟 Docker 网络。
现在尝试一下:为容器创建一个 Docker 网络以进行通信:
docker network create nat
如果你看到该命令的错误,那是因为你的设置已经有一个名为nat的 Docker 网络,你可以忽略该消息。现在,当你运行容器时,你可以使用--network标志显式地将它们连接到该 Docker 网络,并且该网络上的任何容器都可以通过容器名称相互访问。
现在尝试一下:从图像运行一个容器,将端口 80 发布到主机计算机,并连接到nat网络:
docker container run --name iotd -d -p 800:80 --network nat image-of-the-day
现在,你可以浏览到 http:/ /localhost:800/image,你将看到关于 NASA 每日图像的一些 JSON 详细信息。在我运行容器的那天,图像来自日食——图 4.6 显示了从我 API 获取的细节。

图 4.6 我的应用程序容器中从 NASA 缓存的详细信息
容器中的实际应用程序并不重要(但不要现在就删除它——我们将在本章后面使用它)。重要的是,你可以在安装了 Docker 的任何机器上构建它,只需有一个包含 Dockerfile 的源代码副本。你不需要安装任何构建工具,也不需要特定的 Java 版本——你只需克隆代码仓库,然后通过几个 Docker 命令就可以运行应用程序。
这里还有另一件需要非常清楚的事情:构建工具不是最终应用程序图像的一部分。你可以从新的image-of-the-day Docker 图像中运行一个交互式容器,你会发现里面没有mvn命令。只有 Dockerfile 中的最终阶段的全部内容被制作成应用程序图像;任何你想要从之前阶段的内容都需要在最终阶段显式复制。
4.3 应用程序概述:Node.js 源代码
我们将再次通过一个多阶段 Dockerfile 进行操作,这次是为一个 Node.js 应用程序。随着组织越来越多地使用多样化的技术栈,了解不同构建在 Docker 中的样子是很有帮助的。Node.js 是一个很好的选择,因为它很受欢迎,而且它也是一个不同类型构建的例子——这种模式也适用于其他脚本语言,如 Python、PHP 和 Ruby。这个应用的源代码位于文件夹路径 ch04/exercises/access-log 中。
Java 应用程序是编译的,因此源代码被复制到构建阶段,从而生成一个 JAR 文件。JAR 文件是编译后的应用程序,它被复制到最终应用程序镜像中,但源代码不是。.NET Core 也是如此,编译后的工件是 DLL(动态链接库)。Node.js 不同,它使用 JavaScript,这是一种解释型语言,因此没有编译步骤。Docker 化的 Node.js 应用程序需要在应用程序镜像中包含 Node.js 运行时和源代码。
尽管如此,仍然需要一个多阶段 Dockerfile:它优化了依赖项加载。Node.js 使用一个名为 npm(Node 包管理器)的工具来管理依赖项。列表 4.3 显示了本章 Node.js 应用程序的完整 Dockerfile。
列表 4.3 构建 Node.js 应用程序的 npm Dockerfile
FROM diamol/node AS builder WORKDIR /src COPY src/package.json . RUN npm install # app FROM diamol/node EXPOSE 80 CMD ["node", "server.js"] WORKDIR /app COPY --from=builder /src/node_modules/ /app/node_modules/ COPY src/ .
这里的目标与 Java 应用程序相同——仅通过安装 Docker 来打包和运行应用程序,而无需安装任何其他工具。两个阶段的基镜像都是 diamol/node,其中包含 Node.js 运行时和 npm。Dockerfile 中的构建阶段会复制 package.json 文件,这些文件描述了应用程序的所有依赖项。然后它运行 npm install 来下载依赖项。因为没有编译,所以它只需要做这些。
这个应用程序是另一个 REST API。在最终应用程序阶段,步骤会公开 HTTP 端口并指定 node 命令行作为启动命令。最后一件事情是创建一个工作目录并复制应用程序工件。下载的依赖项从构建阶段复制,源代码从主机计算机复制。src 文件夹包含 JavaScript 文件,包括 server.js,这是由 Node.js 进程启动的入口点。
我们在这里有一个不同的技术栈,有一个不同的应用程序打包模式。Node.js 应用程序的基镜像、工具和命令都与 Java 应用程序不同,但这些差异都记录在 Dockerfile 中。构建和运行应用程序的过程完全相同。
现在尝试一下:浏览到 Node.js 应用程序源代码并构建镜像:
cd ch04/exercises/access-log docker image build -t access-log .
你会看到很多来自 npm 的输出(也可能显示一些错误和警告消息,但你可以忽略那些)。图 4.7 显示了我构建的部分输出。下载的包被保存在 Docker 镜像层缓存中,所以如果你只对应用程序进行代码更改,下一次构建将非常快。

图 4.7 为 Node.js 应用程序构建多阶段 Dockerfile
你刚刚构建的 Node.js 应用程序并不有趣,但你仍然应该运行它以检查它是否正确打包。它是一个 REST API,其他服务可以调用它来记录日志。有一个 HTTP POST端点用于记录新的日志,还有一个GET端点显示已记录的日志数量。
现在试试看 运行来自日志 API 镜像的容器,将端口 80 发布到主机,并将其连接到相同的nat网络:
docker container run --name accesslog -d -p 801:80 --network nat access-log
现在,浏览到 http:/ /localhost:801/stats,你会看到服务已记录了多少日志。图 4.8 显示我目前还没有日志——Firefox 很好地格式化了 API 响应,但你在其他浏览器中可能会看到原始的 JSON。

图 4.8 在容器中运行 Node.js API
日志 API 正在运行在 Node.js 版本 10.16,但就像 Java 示例一样,你不需要安装任何版本的 Node.js 或其他工具来构建和运行此应用程序。在这个 Dockerfile 中的工作流程首先下载依赖项,然后将脚本文件复制到最终镜像中。你可以使用与 Python 相同的精确方法,使用 Pip 进行依赖项,或者使用 Ruby 使用 Gems。
4.4 应用程序概述:Go 源代码
我们有一个多阶段 Dockerfile 的最后一个示例——用于用 Go 编写的 Web 应用程序。Go 是一种现代、跨平台的编程语言,可以编译成原生二进制文件。这意味着你可以将你的应用程序编译成在任何平台上运行(Windows、Linux、Intel 或 Arm),编译后的输出是完整的应用程序。你不需要像 Java、.NET Core、Node.js 或 Python 那样安装单独的运行时,这使得 Docker 镜像非常小。
还有几种其他语言也可以编译成原生二进制文件——Rust 和 Swift 很受欢迎——但 Go 具有最广泛的平台支持,它也是云原生应用程序(Docker 本身是用 Go 编写的)非常流行的语言。在 Docker 中构建 Go 应用程序意味着使用类似于你为 Java 应用程序使用的方法的多阶段 Dockerfile 方法,但有一些重要的区别。列表 4.4 显示了完整的 Dockerfile。
列表 4.4 从源代码构建 Go 应用程序的 Dockerfile
FROM diamol/golang AS builder COPY main.go . RUN go build -o /server # app FROM diamol/base ENV IMAGE_API_URL="http://iotd/image" \ ACCESS_API_URL="http://accesslog/access-log" CMD ["/web/server"] WORKDIR web COPY index.html . COPY --from=builder /server . RUN chmod +x server
Go 编译成原生二进制文件,所以 Dockerfile 中的每个阶段都使用不同的基础镜像。构建阶段使用diamol/golang,其中安装了所有 Go 工具。Go 应用程序通常不会获取依赖项,所以这个阶段直接构建应用程序(这只是一个代码文件,main.go)。最终的应用程序阶段使用最小镜像,它只包含最小的操作系统工具层,称为diamol/base。
Dockerfile 捕获了一些配置设置作为环境变量,并指定启动命令为编译的二进制文件。应用程序阶段通过从主机复制应用程序所服务的 HTML 文件和构建阶段的 Web 服务器二进制文件结束。在 Linux 中,需要显式标记二进制文件为可执行,这就是最终chmod命令的作用(在 Windows 上没有影响)。
现在尝试一下 浏览到 Go 应用程序源代码并构建镜像:
cd ch04/exercises/image-gallery docker image build -t image-gallery .
这次编译输出不会很多,因为 Go 很安静,只有在失败时才写入日志。你可以在图 4.9 中看到我的简略输出。

图 4.9 在多阶段 Dockerfile 中构建 Go 应用程序
这个 Go 应用程序确实做了些有用的事情,但在运行它之前,看看输入和输出的镜像大小是值得的。
现在尝试一下 比较 Go 应用程序镜像大小与 Go 工具集镜像:
docker image ls -f reference=diamol/golang -f reference=image-gallery
许多 Docker 命令允许您过滤输出。此命令列出所有镜像,并过滤输出以仅包括具有diamol/golang或image-gallery引用的镜像——引用实际上只是镜像名称。当你运行这个命令时,你会看到选择正确的 Dockerfile 阶段的基础镜像是多么重要:
REPOSITORY TAG IMAGE ID CREATED SIZE image-gallery latest b41869f5d153 20 minutes ago 25.3MB diamol/golang latest ad57f5c226fc 2 hours ago 774MB
在 Linux 上,安装了所有 Go 工具的镜像大小超过 770MB;实际的 Go 应用程序镜像仅为 25MB。记住,这是虚拟镜像大小,所以很多层可以在不同的镜像之间共享。重要的节省并不是磁盘空间,而是最终镜像中不包含的所有软件。应用程序在运行时不需要任何 Go 工具。通过为应用程序使用最小的基础镜像,我们节省了近 750MB 的软件,这大大减少了潜在攻击的表面积。
现在,你可以运行应用程序了。这总结了本章的工作,因为 Go 应用程序实际上使用了你构建的其他应用程序的 API。你应该确保那些容器正在运行,并且具有之前“尝试一下”练习中正确的名称。如果你运行 docker container ls,你应该看到本章的两个容器--名为 accesslog 的 Node.js 容器和名为 iotd 的 Java 容器。当你运行 Go 容器时,它将使用其他容器的 API。
现在尝试一下 运行 Go 应用程序镜像,发布主机端口并连接到 nat 网络:
docker container run -d -p 802:80 --network nat image-gallery
你可以浏览到 http://localhost:802/ 并看到 NASA 的每日天文图片。图 4.10 显示了我运行容器时的图像。

图 4.10 Go 网络应用程序,显示从 Java API 获取的数据
目前你正在三个容器中运行一个分布式应用程序。Go 网络应用程序调用 Java API 获取要显示的图像详情,然后调用 Node.js API 记录网站已被访问。你不需要为这些语言中的任何一种安装任何工具来构建和运行所有应用程序;你只需要源代码和 Docker。
多阶段 Dockerfile 使你的项目完全可移植。你可能现在使用 Jenkins 来构建应用程序,但你可以尝试 AppVeyor 的托管 CI 服务或 Azure DevOps,而无需编写任何新的管道代码--它们都支持 Docker,所以你的管道只是 docker image build。
4.5 理解多阶段 Dockerfile
在本章中,我们覆盖了很多内容,我将以一些关键点结束,以便你真正清楚地了解多阶段 Dockerfile 的工作原理,以及为什么在容器内构建应用程序非常有用。
第一点是关于标准化。我知道当你运行本章的练习时,你的构建将成功,你的应用程序将工作,因为你正在使用与我完全相同的工具集。无论你有什么操作系统或你的机器上安装了什么,所有构建都在 Docker 容器中运行,容器镜像都包含所有正确版本的工具。在你的真实项目中,你会发现这极大地简化了新开发者的入职流程,消除了构建服务器的维护负担,并消除了用户拥有不同版本工具时可能出现的故障风险。
第二点是性能。多阶段构建中的每个阶段都有自己的缓存。Docker 在每个指令中都会在镜像层缓存中寻找匹配项;如果没有找到,缓存就会损坏,并且所有其余的指令都会执行--但仅限于该阶段。下一个阶段将从缓存重新开始。你将花费时间仔细构建你的 Dockerfile,当你完成优化后,你会发现 90% 的构建步骤都使用了缓存。
最后一点是,多阶段 Dockerfile 让你可以精细调整构建过程,使得最终的应用程序镜像尽可能精简。这不仅仅适用于编译器——你需要的任何工具都可以在早期阶段进行隔离,因此工具本身不会出现在最终镜像中。一个很好的例子是 curl——一个流行的命令行工具,你可以用它从互联网上下载内容。你可能需要它来下载你的应用程序需要的文件,但你可以在 Dockerfile 的早期阶段完成这个操作,这样 curl 本身就不会安装在你的应用程序镜像中。这有助于减小镜像大小,意味着更快的启动时间,但也意味着你的应用程序镜像中可用的软件更少,这意味着攻击者有更少的潜在漏洞可以利用。
4.6 实验室
实验时间!你将把关于多阶段构建和优化 Dockerfile 所学的知识付诸实践。在本书的源代码中,你会在ch04/lab文件夹中找到一个起点。这是一个简单的 Go 网络服务器应用程序,它已经有一个 Dockerfile,因此你可以在 Docker 中构建和运行它。但 Dockerfile 急需优化,这就是你的任务。
本实验有具体的目标:
-
首先,使用现有的 Dockerfile 构建一个镜像,然后优化 Dockerfile 以生成一个新的镜像。
-
当前在 Linux 上的图像大小为 800 MB,在 Windows 上为 5.2 GB。你的优化图像在 Linux 上应约为 15 MB,在 Windows 上约为 260 MB。
-
如果你使用当前的 Dockerfile 更改 HTML 内容,构建将执行七个步骤。
-
当你更改 HTML 时,你的优化 Dockerfile 应只执行一个步骤。
和往常一样,在本书的 GitHub 仓库中有一个示例解决方案。但这是一个你应该真正尝试并找到时间做的实验,因为优化 Dockerfile 是一项非常有价值的技能,你将在每个项目中使用它。不过,如果你需要,我的解决方案在这里:github.com/sixeyed/diamol/blob/master/ch04/lab/Dockerfile.optimized。
这次没有提示,尽管我会说这个示例应用程序看起来非常类似于你在本章中已经构建的一个。
5 使用 Docker Hub 和其他注册表共享镜像
在过去的几章中,你已经对 Docker 工作流程的构建和运行部分有了很好的理解——现在是时候分享你的成果了。分享就是将你在本地机器上构建的镜像提供给其他人使用。我真的认为这是 Docker 公式中最重要的一部分。将你的软件及其所有依赖项打包在一起意味着任何人都可以轻松地在任何机器上使用它——环境之间没有差距,因此不再有浪费在设置软件或追踪实际上是部署问题的错误的日子。
5.1 与注册表、存储库和镜像标签一起工作
软件分发是 Docker 平台内置的功能。你已经看到你可以从一个镜像运行容器,如果你本地没有这个镜像,Docker 会下载它。存储镜像的中央服务器称为 Docker 注册表。Docker Hub 是最受欢迎的镜像注册表,每月托管数十万个镜像,这些镜像被下载数十亿次。它也是 Docker 引擎的默认注册表,这意味着它是 Docker 首先查找本地不可用镜像的地方。
Docker 镜像需要一个名称,并且这个名称包含足够的信息,以便 Docker 能够找到你正在寻找的确切镜像。到目前为止,我们使用的是非常简单的名称,通常由一到两个部分组成,例如 image-gallery 或 diamol/golang。实际上,一个完整的镜像名称由四个部分组成(通常称为镜像引用)。图 5.1 展示了 diamol/golang 的完整引用中的所有这些部分:

图 5.1 Docker 镜像引用的解剖结构
当你开始管理自己的应用程序镜像时,你将使用到镜像引用的所有部分。在你的本地机器上,你可以随意命名镜像,但当你想在注册表上共享它们时,你需要添加更多细节,因为镜像引用是注册表上特定镜像的唯一标识符。
如果你没有为镜像引用的部分提供值,Docker 会使用一些默认值。默认注册表是 Docker Hub,默认标签是 latest。Docker Hub 的域名是 docker.io,因此我的镜像 diamol/golang 是 docker.io/diamol/golang:latest 的简写版本。你可以使用这两种引用中的任何一种。diamol 账户是 Docker Hub 上的一个组织,而 golang 是该组织内的一个存储库。这是一个公开的存储库,因此任何人都可以拉取镜像,但你需要成为 diamol 组织的成员才能推送镜像。
大型公司通常在自己的云环境或本地网络中拥有自己的 Docker 注册表。你通过在引用的第一部分包含域名来针对自己的注册表,这样 Docker 就知道不要使用 Docker Hub。如果我在 r.sixeyed.com 上托管自己的注册表,我的图像可以存储在 r.sixeyed .com/diamol/golang 。这一切都很简单,但图像引用最重要的部分是标签。
到目前为止,你还没有使用图像标签,因为没有它们开始起来更简单,但当你开始构建自己的应用程序图像时,你应该始终为它们添加标签。标签用于识别同一应用程序的不同版本。官方 Docker OpenJDK 图像有数百个标签-- openjdk:13 是最新版本,openjdk:8u212-jdk 是 Java 8 的一个特定版本,还有更多针对不同的 Linux 发行版和 Windows 版本。如果你在创建图像时没有指定标签,Docker 将使用默认标签 latest 。这个名字可能会误导,因为标记为latest的图像可能实际上并不是最新的图像版本。当你推送自己的图像时,你应该始终使用明确的版本进行标记。
5.2 推送自己的图像到 Docker Hub
我们将开始推送第四章中构建的一个图像到 Docker Hub。你需要一个 Docker Hub 账户来做到这一点--如果你还没有,请浏览到 hub.docker.com 并点击链接注册一个账户(它是免费的,并且不会让你的邮箱收到大量的垃圾邮件)。
要将图像推送到注册表,你需要做两件事。首先,你需要使用 Docker 命令行登录到注册表,这样 Docker 就可以检查你的用户账户是否有权推送图像。然后,你需要给你的图像提供一个包含你有权限推送的账户名称的引用。
每个读者都会有自己的 Docker Hub 用户名,为了更容易地跟随练习,让我们首先在你的终端会话中捕获你的 Docker ID。之后,你将能够复制粘贴本章剩余的命令。
现在试试看 打开一个终端会话并将你的 Docker Hub ID 保存在一个变量中。你的 Docker ID 是你的用户名,而不是你的电子邮件地址。这是一条在 Windows 和 Linux 上不同的命令,所以你需要选择正确的选项:
# 在 Windows 上使用 PowerShell $dockerId="<your-docker-id-goes-here>" # 在 Linux 或 Mac 上使用 Bash export dockerId="<your-docker-id-goes-here>"
我目前运行的是 Windows,我的 Docker Hub 用户名是 sixeyed ,所以我运行的命令是 $dockerId="sixeyed" ;在 Linux 上我会运行 dockerId="sixeyed" 。在任何系统上,你都可以运行 echo $dockerId 并应该看到你的用户名显示。从现在开始,你可以复制练习中的命令,它们将使用你的 Docker ID。
首先登录到 Docker Hub。实际上,是 Docker Engine 推送和拉取镜像,但你使用 Docker 命令行进行认证——当你运行login命令时,它会要求你的密码,这是你的 Docker Hub 密码。
现在试试看,登录到 Docker Hub。Hub 是默认的仓库,所以你不需要指定域名:
docker login --username $dockerId
你会在图 5.2 中看到类似我的输出——合乎逻辑地,当你输入密码时,Docker 不会显示它。
现在你已经登录,你可以将镜像推送到你自己的账户或你能够访问的任何组织。我不认识你,但如果我想请你帮忙照看这本书的镜像,我可以将你的账户添加到diamol组织,你将能够推送以diamol/开头的镜像。如果你不是任何组织的成员,你只能将镜像推送到你自己的账户中的仓库。

图 5.2 登录到 Docker Hub
在第四章中,你构建了一个名为image-gallery的 Docker 镜像。这个镜像引用没有账户名,所以你不能将其推送到任何仓库。不过,你不需要重新构建镜像来给它一个新的引用——镜像可以有多个引用。
现在试试看,为你的现有镜像创建一个新的引用,将其标记为版本 1:
docker image tag image-gallery $dockerId/image-gallery:v1
现在你有两个引用;一个有账户和版本号,但两个引用都指向同一个镜像。镜像也有一个唯一的 ID,当你列出它们时,你可以看到单个镜像 ID 是否有多个引用。
现在试试看,列出image-gallery镜像引用:
docker image ls --filter reference=image-gallery --filter reference='*/image-gallery'
你会在图 5.3 中看到与我类似的输出,但你的标记镜像将显示你的 Docker Hub 用户名而不是sixeyed。

图 5.3 一个镜像有两个引用
现在你有一个包含你的 Docker ID 的账户名的镜像引用,你已经登录到 Docker Hub,所以你准备好分享你的镜像了!docker image push命令是pull命令的对应命令;它将你的本地镜像层上传到仓库。
现在试试看,列出image-gallery镜像引用:
docker image push $dockerId/image-gallery:v1
Docker 仓库在镜像层级别上工作,与本地 Docker Engine 的方式相同。你推送一个镜像,但 Docker 实际上上传的是镜像层。在输出中,你会看到一个层 ID 列表及其上传进度。在我的(缩略的)输出中,你可以看到正在推送的层:
The push refers to repository [docker.io/sixeyed/image-gallery] c8c60e5dbe37: Pushed 2caab880bb11: Pushed 3fcd399f2c98: Pushed ... v1: digest: sha256:127d0ed6f7a8d1... size: 2296
注册表与镜像层协同工作的事实是你需要花时间优化你的 Dockerfile 的另一个原因。只有当该层的哈希值没有现有匹配时,层才会物理上传到注册表。这就像你的本地 Docker Engine 缓存,但应用于注册表上的所有镜像。如果你优化到在构建时 90% 的层来自缓存,那么在推送时,90% 的这些层已经存在于注册表中。优化的 Dockerfile 可以减少构建时间、磁盘空间和网络带宽。
你现在可以浏览到 Docker Hub 并检查你的镜像。Docker Hub UI 使用与镜像引用相同的仓库名称格式,因此你可以从你的账户名称中确定你镜像的 URL。
现在试试这个小程序,它会将你的镜像页面的 URL 写入:
echo "https://hub.docker.com/r/$dockerId/image-gallery/tags"
当你浏览到该 URL 时,你会看到类似于图 5.4 的内容,显示你的镜像标签和最后更新时间。

图 5.4 Docker Hub 上的镜像列表
推送镜像就这么简单。如果镜像不存在,Docker Hub 会为它创建一个新的仓库,默认情况下,该仓库具有公共读权限。现在任何人都可以找到、拉取和使用你的 image-gallery 应用程序。他们需要自己找出如何使用它,但你也可以在 Docker Hub 上放置文档。
Docker Hub 是最容易开始的注册表,并且它以零成本提供大量功能——尽管你可以支付每月订阅费以获得额外功能,如私有仓库。还有很多替代的注册表。注册表是一个开放的 API 规范,核心注册表服务器是 Docker 的开源产品。所有云服务提供商都有自己的注册表服务,你可以使用像 Docker Trusted Registry 这样的商业产品在数据中心管理自己的注册表,或者在一个容器中运行一个简单的注册表。
5.3 运行和使用自己的 Docker 注册表
在本地网络中运行自己的注册表很有用。它可以减少带宽使用和传输时间,并让你拥有环境中自己的数据。即使你不关心这一点,了解你可以快速启动本地注册表也是好的,这样你就可以在主注册表离线时将其用作备份选项。
Docker 在 GitHub 上的源代码仓库 docker/distribution 中维护核心注册表服务器。它为你提供了推送和拉取镜像的基本功能,并使用与 Docker Hub 相同的层缓存系统,但它不提供 Hub 上的 Web UI。这是一个超级轻量级的服务器,我已经将其打包到 diamol 镜像中,因此你可以在容器中运行它。
现在运行 Docker 注册表容器,使用我的镜像:
# 运行注册表时带有重启标志,这样容器在重启 Docker 时也会重启: docker container run -d -p 5000:5000 --restart always diamol/registry
你现在在你的本地机器上有一个注册表服务器。服务器的默认端口是 5000,这个命令会发布它。你可以使用域名 localhost:5000 对镜像进行打标签并将它们推送到这个注册表,但这并不是很有用——你只能在你的本地机器上使用注册表。相反,最好给你的机器一个别名,这样你就可以为你的注册表使用一个合适的域名。
下一个命令会创建这个别名。它会给你的电脑一个 registry.local 的名字,以及它可能拥有的任何其他网络名字。它是通过写入电脑的 hosts 文件来做到这一点的,这是一个简单的文本文件,它将网络名字链接到 IP 地址。
现在尝试一下 Windows、Linux 和 Mac 机器都使用相同的 hosts 文件格式,但文件路径不同。Windows 上的命令也不同,所以你需要选择正确的一个:
# 在 Windows 上使用 PowerShell Add-Content -Value "127.0.0.1 registry.local" -Path /windows/system32/drivers/etc/hosts # 在 Linux 或 Mac 上使用 Bash echo $'\n127.0.0.1 registry.local' | sudo tee -a /etc/hosts
如果你从这个命令中得到权限错误,你需要在 Windows 上的提升 PowerShell 会话中用管理员权限登录,或者在 Linux 或 Mac 上使用 sudo。当你成功运行了命令后,你应该能够运行 ping registry.local 并看到来自你电脑的本地 IP 地址 127.0.0.1 的响应,如图 5.5 所示。

图 5.5 为你的电脑添加新的网络别名
现在,你可以在你的镜像引用中使用域名 registry.local:5000 来使用你的注册表。将域名添加到镜像涉及的过程与你在 Docker Hub 上已经执行过的打标签过程相同。这次你只需在新的镜像引用中包含注册表域名即可。
现在尝试一下给你的 image-gallery 镜像打上你的注册表域名标签:
docker image tag image-gallery registry.local:5000/gallery/ui:v1
你本地的注册表没有设置任何认证或授权。这显然不是生产质量,但它可能适用于一个小团队,并且它确实让你可以使用自己的镜像命名方案。第四章中的 NASA 每日图像应用由三个容器组成——你可以使用 gallery 作为项目名称来对所有的镜像进行打标签,以将它们分组在一起:
-
registry.local:5000/gallery/ui:v1-- Go 网页 UI -
registry.local:5000/gallery/api:v1-- Java API -
registry.local:5000/gallery/logs:v1-- Node.js API
在您可以将此镜像推送到本地注册表之前,您还需要做一件事。注册表容器正在使用纯文本 HTTP 而不是加密的 HTTPS 来推送和拉取镜像。默认情况下,Docker 不会与未加密的注册表通信,因为它不安全。在 Docker 允许您使用它之前,您需要明确将您的注册表域名添加到允许的非安全注册表列表中。
这就引出了配置 Docker 的问题。Docker 引擎使用 JSON 配置文件来设置各种配置,包括 Docker 在磁盘上存储镜像层的位置、Docker API 监听连接的位置以及哪些非安全注册表被允许。该文件名为 daemon.json,通常位于 Windows Server 的 C:\ProgramData\docker\config 文件夹中,以及 Linux 的 /etc/docker 文件夹中。您可以直接编辑该文件,但如果您在 Mac 或 Windows 上使用 Docker Desktop,您将需要使用 UI,在那里您可以更改主要配置设置。
现在尝试一下:在任务栏中右键单击 Docker 鲸鱼图标,选择设置(或在 Mac 上选择首选项)。然后打开守护进程选项卡,并在非安全注册表列表中输入 registry.local:5000--您可以在图 5.6 中看到我的设置。

图 5.6 允许 Docker Desktop 使用非安全注册表
Docker 引擎需要重新启动以加载任何新的配置设置,当您应用更改时,Docker Desktop 会为您完成此操作。
如果您没有运行 Docker Desktop,您将需要手动进行此操作。首先,在文本编辑器中打开 daemon.json 文件--如果它不存在,则创建它--并以 JSON 格式添加非安全注册表详情。配置设置看起来像这样--但如果您正在编辑现有文件,请确保保留原始设置:
{ "insecure-registries": [ "registry.local:5000" ] }
然后,在 Windows Server 上使用 Restart-Service docker 或在 Linux 上使用 service docker restart 重新启动 Docker。您可以使用 info 命令检查 Docker 引擎允许的非安全注册表以及其他信息。
现在尝试一下:列出 Docker 引擎的信息并检查您的注册表是否在非安全注册表列表中:
docker info
在输出末尾,您将看到注册表配置,其中应包括您的非安全注册表--您可以在图 5.7 中看到我的配置。

图 5.7 允许 Docker 使用的非安全注册表
您应该小心地将非安全注册表添加到 Docker 配置中。您的连接可能会被破坏,攻击者可以在您推送镜像时读取层。更糟糕的是,他们可以在您拉取镜像时注入自己的数据。所有商业注册表服务器都运行在 HTTPS 上,您也可以配置 Docker 的开源注册表使用 HTTPS。然而,为了在本地服务器上进行演示,这是一个可接受的风险。
你现在可以将你的标记过的镜像推送到你自己的注册表。注册表域名是图像引用的一部分,所以 Docker 知道要使用除 Docker Hub 之外的东西,并且你的在容器中运行的 HTTP 注册表已从不安全注册表列表中清除。
现在试试看 Push 你的标记过的镜像:
docker image push registry.local:5000/gallery/ui:v1
当你第一次运行 push 命令时,你的注册表是空的,所以你会看到所有层正在上传。如果你然后重复 push 命令,你会看到所有层已经存在,没有任何内容被上传。这就是你需要在容器中运行自己的 Docker 注册表所需要做的全部。你可以通过你的机器的 IP 地址或真实域名来在你的网络上共享它。
5.4 有效使用图像标签
你可以将任何字符串放入 Docker 图像标签中,正如你之前看到的,你可以为同一镜像拥有多个标签。你将使用它来对图像中的软件进行版本控制,并让用户了解他们想要使用的内容——以及当你使用他人的图像时,你可以做出自己的明智选择。
许多软件项目使用带有小数点的数字版本控制方案来表示版本之间的变化有多大,你可以在你的图像标签中这样做。基本想法类似于 [major].[minor].[patch],它有一些隐含的保证。仅增加补丁号的发布可能包含错误修复,但它应该与上一个版本具有相同的功能;增加次要版本的发布可能添加功能,但不应该删除任何功能;而主要发布可能具有完全不同的功能。
如果你使用相同的方法处理你的图像标签,你可以让用户选择是否坚持使用主版本或次要版本,或者总是拥有最新的发布版本。
现在试试看 为你打包在镜像中的 Go 应用程序创建一些新的标签,以表示主要、次要和补丁发布版本:
docker image tag image-gallery registry.local:5000/gallery/ui:latest docker image tag image-gallery registry.local:5000/gallery/ui:2 docker image tag image-gallery registry.local:5000/gallery/ui:2.1 docker image tag image-gallery registry.local:5000/gallery/ui:2.1.106
现在想象一个应用程序每月发布,版本号递增。图 5.8 展示了从 7 月到 10 月发布过程中图像标签可能如何演变。
你可以看到,这些图像标签中的一些是移动的目标。gallery/ui:2.1 是 7 月份 2.1.106 版本的别名,但在 8 月份,相同的 2.1 标签是 2.1.114 版本的别名。gallery/ui:2 也是 7 月份 2.1.106 版本的别名,但到了 9 月,2 标签是 2.2.11 版本的别名。最新的标签变动最大——7 月份 gallery/ui 是 2.1.106 版本的别名,但在 10 月份它变成了 3.0.42 版本的别名。
这是您将看到的典型的 Docker 图像版本方案。您应该自己采用这个方案,因为它允许您的图像用户选择他们想要保持的当前程度。他们可以在他们的图像拉取命令中固定到特定的补丁版本,或者在他们的 Dockerfile 中的FROM指令中,并确保他们使用的图像始终相同。在这个例子中的 2.1.106 标签是从 7 月到 10 月的相同图像。如果他们想要获取补丁更新,他们可以使用 2.1 标签,如果他们想要获取小版本发布,他们可以使用 2 标签。

图 5.8 软件发布期间图像标签的演变
任何这些选择都是可以的;这只是平衡风险的问题——使用特定的补丁版本意味着应用程序每次使用时都将保持相同,但您不会获得安全修复。使用主要版本意味着您将获得所有最新的修复,但可能会有意外的功能更改。
在您的 Dockerfile 中使用特定的图像标签对于基础图像尤为重要。使用产品团队的构建工具图像来构建您的应用程序以及它们的运行时图像来打包您的应用程序是很好的,但如果您在标签中不指定版本,您将来可能会遇到麻烦。构建图像的新版本可能会破坏您的 Docker 构建。或者更糟的是,运行时的新版本可能会破坏您的应用程序。
5.5 将官方图像转换为黄金图像
当您查看 Docker Hub 和其他注册表时,还有最后一件事需要理解:您能否信任您在那里找到的图像?任何人都可以将图像推送到 Docker Hub 并使其公开可用。对于黑客来说,这是一种很好的分发恶意软件的方式;您只需给您的图像一个无辜的名称和虚假的描述,然后等待人们开始使用它。Docker Hub 通过验证发布者和官方图像来解决这个问题。
验证发布者是像微软、甲骨文和 IBM 这样的公司,他们在 Docker Hub 上发布图像。他们的图像经过一个包括漏洞安全扫描的审批流程;它们也可能获得认证,这意味着它们得到了 Docker 和发布者的支持。如果您想在容器中运行现成的软件,来自验证发布者的认证图像是最佳选择。
官方图像是不同的事物——它们通常是开源项目,由项目团队和 Docker 共同维护。它们经过安全扫描并定期更新,并符合 Dockerfile 最佳实践。所有官方图像的内容都是开源的,因此您可以在 GitHub 上查看 Dockerfile。大多数人开始使用官方图像作为他们自己图像的基础,但最终发现他们需要更多的控制。然后他们引入他们自己的首选基础图像,称为黄金图像——图 5.9 展示了它是如何工作的。

图 5.9 使用黄金图像封装官方图像
黄金镜像以官方镜像为基础,然后添加它们需要的任何自定义设置,例如安装安全证书或配置默认环境设置。黄金镜像位于公司的 Docker Hub 仓库或他们自己的仓库中,所有应用程序镜像都基于黄金镜像。这种方法提供了官方镜像的好处——由项目团队通过最佳实践设置——但增加了你需要额外配置。
现在试试看。本章源代码中有两个 Dockerfile,可以作为.NET Core 应用程序的黄金镜像构建。浏览到每个文件夹并构建镜像:
cd ch05/exercises/dotnet-sdk docker image build -t golden/dotnetcore-sdk:3.0 . cd ../aspnet-runtime docker image build -t golden/aspnet-core:3.0 .
黄金镜像并没有什么特别之处。它们从一个 Dockerfile 开始,并使用你自己的参考和命名方案构建一个镜像。如果你查看你构建的 Dockerfile,你会发现它们使用LABEL指令向镜像添加了一些元数据,并设置了一些常见配置。现在你可以在一个多阶段 Dockerfile 中使用这些镜像来构建.NET Core 应用程序,这看起来可能像列表 5.1。
列表 5.1 使用.NET Core 黄金镜像的多阶段 Dockerfile
FROM golden/dotnetcore-sdk:3.0 AS builder COPY . . RUN dotnet publish -o /out/app app.csproj FROM golden/aspnet-core:3.0 COPY --from=builder /out /app CMD ["dotnet", "/app/app.dll"]
Dockerfile 的应用格式与任何多阶段构建相同,但现在你拥有基础镜像。官方镜像可能每月都会有一个新版本发布,但你可以选择将你的黄金镜像限制为每季度更新一次。黄金镜像还打开了一种其他可能性——你可以通过持续集成(CI)管道中的工具强制使用它们:Dockerfile 可以被扫描,如果有人尝试构建一个不使用黄金镜像的应用程序,那么构建将失败。这是一种锁定团队可以使用源镜像的好方法。
5.6 实验室
这个实验室需要一些侦探工作,但最终会值得。你需要深入研究 Docker Registry API v2 规范(docs.docker.com/registry/spec/api/),因为 REST API 是唯一你可以与你的本地 Docker 仓库交互的方式——你无法使用 Docker CLI(目前)搜索或删除镜像。
本实验的目标是将你的gallery/ui镜像的所有标签推送到本地仓库,检查它们是否都在那里,然后删除它们并检查它们是否已消失。我们不会包括gallery/api或gallery/logs镜像,因为本实验专注于具有多个标签的镜像,而我们已经有gallery/ui的这些镜像。以下是一些提示:
-
你可以使用单个
imagepush命令推送所有这些标签。 -
你本地注册表的 API URL 是
registry.local:5000/v2。 -
首先列出存储库的镜像标签。
-
然后你需要获取镜像清单。
-
你可以通过 API 删除镜像,但你需要使用清单。
-
读取文档——在你的 HEAD 请求中需要使用特定的请求头。
解决方案在书的 GitHub 仓库中,这是一个可以稍微作弊的罕见案例。前几个步骤应该对你来说很容易解决,但之后会变得有些尴尬,所以如果你最终来到这里:github.com/sixeyed/diamol/tree/master/ch05/lab,请不要感到太难过。
祝你好运。并且记得阅读文档。
6 使用 Docker 卷进行持久化存储
容器是状态无状态应用的理想运行时环境。您可以通过在您的集群上运行多个容器来满足增加的需求,知道每个容器将以相同的方式处理请求。您可以通过自动滚动升级来发布更新,这样您的应用程序在整个过程中都保持在线状态。
但您的应用程序的并非所有部分都是无状态的。将会有一些组件使用磁盘来提高性能或进行永久数据存储。您也可以在 Docker 容器中运行这些组件。
存储确实增加了复杂性,因此您需要了解如何 Docker 化有状态应用程序。本章将带您了解 Docker 卷和挂载,并展示容器文件系统是如何工作的。
6.1 为什么容器中的数据不是永久的
Docker 容器有一个包含单个磁盘驱动器的文件系统,该驱动器的内容由镜像中的文件组成。您已经看到了这一点:当您在 Dockerfile 中使用 COPY 指令时,您复制到镜像中的文件和目录在您从镜像运行容器时是存在的。而且您知道 Docker 镜像是存储为多个层,因此容器的磁盘实际上是一个 Docker 通过合并所有镜像层构建的虚拟文件系统。
每个容器都有自己的文件系统,与其他容器独立。您可以从相同的 Docker 镜像运行多个容器,它们都将使用相同的磁盘内容启动。应用程序可以更改一个容器中的文件,这不会影响其他容器中的文件--或镜像中的文件。通过运行几个写入数据的容器并查看它们的输出,可以直观地看到这一点。
现在尝试一下 打开一个终端会话并运行两个来自同一镜像的容器。镜像中的应用程序将随机数字写入容器中的文件:
docker container run --name rn1 diamol/ch06-random-number docker container run --name rn2 diamol/ch06-random-number
该容器在启动时运行一个脚本,该脚本将一些随机数据写入文本文件然后结束,因此这些容器处于已退出状态。这两个容器是从相同的镜像启动的,但它们将具有不同的文件内容。您在第二章中学到,Docker 在退出时不会删除容器的文件系统--它被保留下来,这样您仍然可以访问文件和文件夹。
Docker CLI 有 docker container cp 命令,用于在容器和本地机器之间复制文件。您指定容器的名称和文件路径,可以使用它将生成的随机数字文件从这些容器复制到您的宿主机上,以便您读取其内容。
现在尝试一下 使用 docker container cp 将每个容器中的随机数字文件复制出来,然后检查其内容:
docker container cp rn1:/random/number.txt number1.txt docker container cp rn2:/random/number.txt number2.txt cat number1.txt cat number2.txt
您的输出将类似于图 6.1 中的我的输出。每个容器都在相同的路径上写了一个文件,即/random/number.txt,但当文件被复制到本地机器上时,您可以看到内容是不同的。这是一种简单的方法来展示每个容器都有一个独立的文件系统。在这种情况下,它是一个不同的单个文件,但这也可能是以相同的 SQL 引擎启动但存储完全不同数据的数据库容器。

图 6.1 运行写入数据的容器并检查数据
容器内的文件系统看起来像是一个单独的磁盘:Linux 容器上的/dev/sda1和 Windows 容器上的C:\。但这个磁盘是一个虚拟文件系统,Docker 从几个来源构建它,并将其作为一个单一单元呈现给容器。该文件系统的基本来源是图像层,这些层可以在容器之间共享,以及容器的可写层,它是每个容器独有的。
图 6.2 展示了随机数字图像和两个容器的外观。您应该从图 6.2 中提取两个重要信息:图像层是共享的,因此它们必须是只读的,每个容器都有一个可写层,其生命周期与容器相同。图像层有自己的生命周期——您拉取的任何图像都将保留在您的本地缓存中,直到您将其删除。但是,容器可写层是在容器启动时由 Docker 创建的,当容器被移除时由 Docker 删除。(停止容器不会自动移除它,因此停止容器的文件系统仍然存在。)

图 6.2 容器文件系统是由图像层和可写层构建的。
当然,可写层不仅仅用于创建新文件。容器可以编辑来自图像层的现有文件。但图像层是只读的,所以 Docker 进行了一些特殊的操作来实现这一点。它使用写时复制的过程来允许编辑来自只读层的文件。当容器尝试编辑图像层中的文件时,Docker 实际上会将该文件复制到可写层,并在那里进行编辑。对于容器和应用程序来说,这一切都是无缝的,但这是 Docker 高效使用存储的基石。
在我们继续运行一些更有用的有状态容器之前,让我们通过一个更简单的例子来分析这个问题。在这个练习中,您将运行一个容器,该容器从图像层打印出文件内容。然后您将更新文件内容并再次运行容器以查看发生了什么变化。
现在尝试一下 运行以下命令以启动一个打印其文件内容的容器,然后更改文件,并再次启动容器以打印新文件内容:
docker container run --name f1 diamol/ch06-file-display echo "http://eltonstoneman.com" > url.txt docker container cp url.txt f1:/input.txt docker container start --attach f1
这次你使用 Docker 将文件从你的主机计算机复制到容器中,目标路径是容器显示的文件。当你再次启动容器时,相同的脚本运行,但现在它打印出不同的内容——你可以在图 6.3 中看到我的输出。

图 6.3 修改容器的状态并再次运行
修改容器中的文件会影响该容器的运行方式,但不会影响镜像或该镜像的任何其他容器。更改的文件仅存在于该容器的可写层中——新的容器将使用镜像中的原始内容,当容器f1被移除时,更新的文件将消失。
现在试试看 启动一个新的容器来检查镜像中的文件是否未更改。然后移除原始容器并确认数据已消失:
docker container run --name f2 diamol/ch06-file-display docker container rm -f f1 docker container cp f1:/input.txt .
你会看到与我图 6.4 中相同的输出。新的容器使用镜像中的原始文件,当你移除原始容器时,其文件系统被移除,更改的文件将永远消失。

图 6.4 在容器中修改文件不会影响镜像,容器中的数据是瞬时的。
容器文件系统与容器的生命周期相同,因此当容器被移除时,可写层也会被移除,容器中任何更改的数据都会丢失。移除容器是你将要做很多事情之一。在生产中,你通过构建新的镜像、移除旧容器并用更新镜像的新容器替换它们来升级应用程序。你在原始应用程序容器中写入的任何数据都会丢失,替换容器将以镜像中的静态数据开始。
在某些情况下,这是可以的,因为你的应用程序只写入瞬态数据——可能是为了保持一个本地缓存,该缓存的数据计算或检索成本高昂——并且对于替换容器以空缓存开始是可行的。在其他情况下,那将是一场灾难。你可以在容器中运行数据库,但你不会期望在推出更新的数据库版本时丢失所有数据。
Docker 也为你覆盖了这些场景。容器的虚拟文件系统始终由镜像层和可写层构建,但也可能有其他来源。这些是 Docker 卷和挂载。它们有独立的生命周期,因此可以用来存储在容器替换之间持久化的数据。
6.2 使用 Docker 卷运行容器
Docker 卷是存储单元--你可以将其视为容器的 USB 棒。卷独立于容器存在,有自己的生命周期,但可以附加到容器。当数据需要持久化时,卷是管理状态化应用存储的方式。你创建一个卷并将其附加到你的应用程序容器;它作为容器文件系统中的一个目录出现。容器将数据写入目录,实际上数据存储在卷中。当你用新版本更新你的应用时,你将相同的卷附加到新容器,所有原始数据都可用。
使用容器与卷有两种方式:你可以手动创建卷并将其附加到容器,或者你可以在 Dockerfile 中使用 VOLUME 指令。这会构建一个在启动容器时创建卷的镜像。语法很简单:VOLUME <target-directory> 。列表 6.1 展示了镜像 diamol/ch06-todo-list 的多阶段 Dockerfile 的部分,这是一个使用卷的状态化应用。
列表 6.1 使用卷的 Dockerfile 的部分
FROM diamol/dotnet-aspnet WORKDIR /app ENTRYPOINT ["dotnet", "ToDoList.dll"] VOLUME /data COPY --from=builder /out/ .
当你从这个镜像运行容器时,Docker 将自动创建一个卷并将其附加到容器。容器将有一个位于 /data(或 Windows 容器上的 C:\data)的目录,它可以像通常一样从中读取和写入。但实际上数据是存储在卷中的,卷将在容器删除后继续存在。如果你从这个镜像运行一个容器并检查卷,你可以看到这一点。
现在试试 Run 一个待办事项应用的容器,并查看 Docker 创建的卷:
docker container run --name todo1 -d -p 8010:80 diamol/ch06-todo-list docker container inspect --format '{{.Mounts}}' todo1 docker volume ls
你会在图 6.5 中看到类似我的输出。Docker 为此容器创建了一个卷,并在容器运行时将其附加。我已经过滤了卷列表,只显示我的容器的卷。

图 6.5 在 Dockerfile 中声明卷的容器运行
Docker 卷对容器中运行的应用程序是完全透明的。浏览到 http: / / localhost:8010,你会看到待办事项应用。该应用将数据存储在 /data 目录下的文件中,因此当你通过网页添加项目时,它们实际上被存储在 Docker 卷中。图 6.6 展示了应用的实际运行情况--这是一个非常适合像我这样工作负载的人的特殊待办事项列表;你可以添加项目,但你永远无法删除它们。

图 6.6 永无止境的待办事项列表,在容器中使用 Docker 卷运行
在 Docker 镜像中声明的卷为每个容器创建为单独的卷,但你也可以在容器之间共享卷。如果你启动一个新的运行待办事项应用程序的容器,它将有自己的卷,待办事项列表将从空开始。但是,你可以使用带有 volumes-from 标志的容器来附加另一个容器的卷。在这个例子中,你可以有两个共享相同数据的待办事项应用程序容器。
现在试试看:运行第二个待办事项列表容器并检查数据目录的内容。然后将其与另一个新容器进行比较,该容器共享第一个容器的卷(Windows 和 Linux 上的 exec 命令略有不同):
# 这个新容器将有自己的卷 docker container run --name todo2 -d diamol/ch06-todo-list # 在 Linux 上: docker container exec todo2 ls /data # 在 Windows 上: docker container exec todo2 cmd /C "dir C:\data" # 这个容器将共享 todo1 的卷 docker container run -d --name t3 --volumes-from todo1 diamol/ch06-todo-list # 在 Linux 上: docker container exec t3 ls /data # 在 Windows 上: docker container exec t3 cmd /C "dir C:\data"
输出将类似于图 6.7(本例中我在 Linux 上运行)。第二个容器以新卷开始,因此 /data 目录为空。第三个容器使用第一个容器的卷,因此可以看到原始应用程序容器的数据。

图 6.7 运行具有专用和共享卷的容器
在容器之间共享卷很简单,但这可能不是你想要做的。通常,写入数据的应用程序期望对文件有独占访问权限,如果另一个容器同时读取和写入同一文件,它们可能无法正确工作(或根本无法工作)。卷更适合在应用程序升级之间保留状态,此时最好显式管理卷。你可以创建一个命名卷并将其附加到应用程序容器的不同版本。
现在试试看:创建一个卷并在待办事项应用程序的版本 1 中使用它。然后在用户界面中添加一些数据并将应用程序升级到版本 2。容器的文件系统路径需要与操作系统匹配,所以我使用变量来简化复制和粘贴:
# 将目标文件路径保存到变量中: target='/data' # 对于 Linux 容器 $target='c:\data' # 对于 Windows 容器 # 创建一个卷来存储数据: docker volume create todo-list # 运行 v1 应用,使用卷进行应用存储: docker container run -d -p 8011:80 -v todo-list:$target --name todo-v1 diamol/ch06-todo-list # 通过 http://localhost:8011 的 Web 应用添加一些数据 # 删除 v1 应用容器: docker container rm -f todo-v1 # 并运行一个使用相同卷进行存储的 v2 容器: docker container run -d -p 8011:80 -v todo-list:$target --name todo-v2 diamol/ch06-todo-list:v2
图 6.8 的输出显示卷有其自己的生命周期。它在创建任何容器之前就存在,当使用它的容器被移除时它仍然存在。应用程序在升级之间保留数据,因为新的容器使用与旧容器相同的卷。

图 6.8 创建命名卷并使用它来在容器更新之间持久化数据
现在当你浏览到 http: / / localhost:8011 时,你会看到待办事项应用的第二个版本,它已经从一家昂贵的创意机构那里得到了界面改造。图 6.9 显示它现在已准备好投入生产。

图 6.9 全新的待办事项应用 UI
在我们继续之前,有一件事需要明确关于 Docker 卷。Dockerfile 中的 VOLUME 指令和运行容器的 volume(或 v)标志是两个不同的功能。使用 VOLUME 指令构建的镜像,如果 run 命令中没有指定卷,将始终为容器创建一个卷。卷将有一个随机 ID,因此容器消失后你可以使用它,但前提是你能够确定哪个卷包含你的数据。
volume 标志无论镜像是否指定了卷,都会将卷挂载到容器中。如果镜像确实有卷,卷标志可以通过使用相同目标路径的现有卷来覆盖它,因此不会创建新的卷。这就是待办事项列表容器发生的情况。
对于在镜像中没有指定卷的容器,你可以使用完全相同的语法并获得相同的结果。作为镜像的作者,你应该使用 VOLUME 指令作为有状态应用程序的备用选项。这样,即使用户没有指定 volume 标志,容器也会始终将数据写入持久卷。但是,作为镜像的使用者,最好不要依赖默认设置,而应该使用命名卷。
6.3 使用文件系统挂载运行容器
卷非常适合分离存储的生命周期,同时让 Docker 为你管理所有资源。卷位于宿主机上,因此它们与容器解耦。Docker 还提供了使用绑定挂载在容器和宿主机之间共享存储的更直接方式。绑定挂载将宿主机上的目录作为容器上的路径提供。绑定挂载对容器是透明的--它只是容器文件系统的一部分目录。但这意味着你可以从容器访问宿主机文件,反之亦然,这解锁了一些有趣的模式。
绑定挂载允许你显式地使用宿主机上的文件系统作为容器数据。这可以是快速固态硬盘、高可用性磁盘阵列,甚至是跨网络可访问的分布式存储系统。如果你可以访问宿主机上的文件系统,你就可以为容器使用它。我可能有一个带有 RAID 阵列的服务器,并使用它作为待办事项应用数据库的可靠存储。
现在试试吧,我确实有一个带有 RAID 阵列的服务器,但你可能没有,所以我们将在你的宿主机计算机上创建一个本地目录并将其绑定挂载到容器中。再次强调,文件系统路径需要与宿主机操作系统匹配,因此我已经为你的机器上的源路径和容器的目标路径声明了变量。注意 Windows 和 Linux 的不同行:
$source="$(pwd)\databases".ToLower(); $target="c:\data" # Windows source="$(pwd)/databases" && target='/data' # Linux mkdir ./databases docker container run --mount type=bind,source=$source,target=$target -d -p 8012:80 diamol/ch06-todo-list curl http://localhost:8012 ls ./databases
这个练习使用curl命令(该命令在 Linux、Mac 和 Windows 系统上都有)向待办事项应用发送 HTTP 请求。这会导致应用启动,从而创建数据库文件。最后的命令列出了宿主机上本地数据库目录的内容,这将显示应用程序的数据库文件实际上就在你的宿主机计算机上,如图 6.10 所示。
绑定挂载是双向的。你可以在容器中创建文件并在宿主机上编辑它们,或者创建宿主机上的文件并在容器中编辑它们。这里有一个安全方面,因为容器通常应该以最小权限账户运行,以最小化攻击者利用系统的风险。但是,容器需要提升权限来读取和写入宿主机上的文件,因此这个镜像在 Dockerfile 中使用了USER指令来赋予容器管理员权限--它使用 Linux 内置的root用户和 Windows 的ContainerAdministrator用户。
如果你不需要写入文件,你可以在容器内部将主机目录绑定挂载为只读。这是从主机将配置设置暴露到应用程序容器的一个选项。待办事项应用程序映像包含一个默认配置文件,该文件将应用程序的日志级别设置为最小值。你可以从相同的映像运行容器,但将本地配置目录挂载到容器中,并覆盖应用程序的配置而不更改映像。
现在尝试一下:待办事项应用程序如果存在,将从 /app/config 路径加载额外的配置文件。运行一个将本地目录绑定挂载到该位置的容器,应用程序将使用主机的配置文件。首先导航到你的 DIAMOL 源代码的本地副本,然后运行以下命令:
cd ./ch06/exercises/todo-list # 将源路径保存为变量: $source="$(pwd)\config".ToLower(); $target="c:\app\config" # Windows source="$(pwd)/config" && target='/app/config' # Linux # 使用挂载运行容器: docker container run --name todo-configured -d -p 8013:80 --mount type=bind,source=$source,target=$target,readonly diamol/ch06-todo-list # 检查应用程序: curl http://localhost:8013 # 以及容器日志: docker container logs todo-configured

图 6.10 使用绑定挂载在主机上与容器共享目录
主机上的目录中的配置文件被设置为使用更详细的日志记录。当容器启动时,它会映射该目录,应用程序看到配置文件并加载日志配置。在图 6.11 所示的最终输出中,有很多调试日志行,这些日志行在标准配置下应用程序不会写入。

图 6.11 使用绑定挂载将只读配置文件加载到容器中
你可以绑定挂载主机计算机可访问的任何源。你可以在 Linux 主机上挂载到 /mnt/nfs 的共享网络驱动器,或者在 Windows 主机上映射到 X: 驱动器。这两个都可以作为绑定挂载的源,并以相同的方式暴露给容器。这是一种非常有用的方式,可以为在容器中运行的有状态应用程序提供可靠甚至分布式的存储,但你需要了解一些限制。
6.4 文件系统挂载的限制
要有效地使用绑定挂载和卷,你需要了解一些关键场景和限制,其中一些是微妙的,并且只会在容器和文件系统的非寻常组合中出现。
第一个场景很简单:当你运行一个带有挂载的容器,而挂载的目标目录已经存在并且包含镜像层的文件时,会发生什么?你可能认为 Docker 会将源合并到目标中。在容器内部,你期望看到目录包含来自镜像的所有现有文件和来自挂载的所有新文件。但这并不是事实。当你挂载一个已经包含数据的目标时,源目录会替换目标目录——因此,镜像中的原始文件不可用。
你可以通过一个简单的练习看到这一点,使用一个在运行时列出目录内容的镜像。对于 Linux 和 Windows 容器,行为是相同的,但命令中的文件系统路径需要与操作系统匹配。
现在试试看。运行一个没有挂载的容器,它将列出镜像中的目录内容。再次运行时使用挂载,它将列出源目录的内容(这里再次有变量以支持 Windows 和 Linux):
cd ./ch06/exercises/bind-mount $source="$(pwd)\new".ToLower(); $target="c:\init" # Windows source="$(pwd)/new" && target='/init' # Linux docker container run diamol/ch06-bind-mount docker container run --mount type=bind,source=$source,target=$target diamol/ch06-bind-mount
你会看到在第一次运行时,容器列出了两个文件:abc.txt 和 def.txt。这些文件是从镜像层加载到容器中的。第二个容器用挂载的源替换了目标目录,因此这些文件没有列出。只显示了123.txt 和 456.txt 这两个文件,它们来自主机上的源目录。图 6.12 显示了我的输出。

图 6.12 显示了绑定挂载目录如果目标目录存在,会覆盖目标目录。
第二个场景是对第一个场景的变体:如果你从主机挂载单个文件到容器文件系统中已存在的目标目录,会发生什么?这次目录内容会被合并,所以你会看到来自镜像的原始文件和来自主机的新文件——除非你在运行 Windows 容器,因为在这个功能上 Windows 容器根本不支持。
容器文件系统是 Windows 容器与 Linux 容器不同之处之一。有些事情以相同的方式工作。你可以在 Dockerfile 中使用标准的 Linux 风格路径,所以/data对 Windows 容器有效,并成为C:\data的别名。但这对于卷挂载和绑定挂载不适用,这就是为什么本章的练习使用变量为 Linux 用户提供/data和 Windows 的C:\data。
单个文件挂载的限制更为明确。如果你有 Windows 和 Linux 机器,或者如果你在 Windows 上运行支持 Linux 和 Windows 容器的 Docker Desktop,你可以亲自尝试这个实验。
现在尝试一下 单文件挂载在 Linux 和 Windows 上的行为不同。如果您有 Linux 和 Windows 容器可用,您可以看到实际效果:
cd ./ch06/exercises/bind-mount # on Linux: docker container run --mount type=bind,source="$(pwd)/new/123.txt",target=/init/123.txt diamol/ch06-bind-mount # on Windows: docker container run --mount type=bind,source="$(pwd)/new/123.txt",target=C:\init\123.txt diamol/ch06-bind-mount docker container run diamol/ch06-bind-mount docker container run --mount type=bind,source="$(pwd)/new/123.txt",target=/init/123.txt diamol/ch06-bind-mount
Docker 镜像和命令都是相同的——除了针对特定操作系统的目标文件系统路径。但当你运行这个命令时,你会看到 Linux 示例按预期工作,但在 Windows 上你会从 Docker 获得错误,如图 6.13 所示。

图 6.13 使用单个文件作为源绑定挂载在 Linux 上有效,但在 Windows 上无效。
第三个场景较为少见。没有设置很多移动部件很难重现,因此不会有练习涵盖这个场景——你只能相信我的话。这个场景是,如果你将分布式文件系统绑定挂载到容器中会发生什么?容器中的应用程序是否仍然可以正确工作?看,连这个问题都很复杂。
分布式文件系统允许您从网络上的任何机器访问数据,并且它们通常使用与操作系统本地文件系统不同的存储机制。这可能是在本地网络上的 SMB 文件共享、Azure Files 或云中的 AWS S3 等技术。您可以将这些分布式存储系统中的位置挂载到容器中。挂载将看起来像文件系统的一个正常部分,但如果它不支持相同的操作,您的应用程序可能会失败。
图 6.14 中有一个具体的例子,尝试在云上的容器中运行 Postgres 数据库系统,使用 Azure Files 作为容器存储。Azure Files 支持常规的文件系统操作,如读取和写入,但它不支持应用程序可能使用的某些更不寻常的操作。在这种情况下,Postgres 容器尝试创建一个文件链接,但 Azure Files 不支持该功能,因此应用程序崩溃。

图 6.14 分布式存储系统可能不会提供所有常规的文件系统功能。
这种情况是一个例外,但你需要意识到它,因为如果发生这种情况,实际上没有其他解决办法。你的绑定挂载的源可能不支持容器中应用程序期望的所有文件系统功能。这是你无法计划的事情--你只有在尝试使用你的存储系统运行你的应用程序时才会知道。如果你想为容器使用分布式存储,你应该意识到这个风险,并且你还需要了解分布式存储的性能特征将与本地存储非常不同。如果你在一个使用分布式存储的容器中运行一个使用大量磁盘的应用程序,那么每次文件写入都会通过网络进行,这可能会导致应用程序运行缓慢。
6.5 理解容器文件系统的构建
我们在本章中涵盖了大量的内容。存储是一个重要的主题,因为容器与物理计算机或虚拟机上的存储选项非常不同。我将通过综合回顾我们所涵盖的所有内容,并提供一些使用容器文件系统的最佳实践指南来完成本章节。
每个容器只有一个磁盘,这是一个由 Docker 从多个来源拼接而成的虚拟磁盘。Docker 称这为联合文件系统。我不会探讨 Docker 如何实现联合文件系统,因为不同的操作系统有不同的技术。当你安装 Docker 时,它会为你的操作系统做出正确的选择,因此你不需要担心细节。
联合文件系统允许容器看到一个单一的磁盘驱动器,并以相同的方式处理文件和目录,无论它们在磁盘上的位置如何。但是,磁盘上的位置可以物理存储在不同的存储单元中,如图 6.15 所示。

图 6.15 容器文件系统是由多个来源的联合创建的。
容器内的应用程序看到一个单一的磁盘,但作为镜像作者或容器用户,你选择该磁盘的来源。容器中可以有多个镜像层、多个卷挂载和多个绑定挂载,但它们将始终只有一个可写层。以下是一些关于如何使用存储选项的一般性指南:
-
可写层 -- 适用于短期存储,例如将数据缓存到磁盘以节省网络调用或计算。这些是每个容器独有的,但容器被移除后它们将永远消失。
-
本地绑定挂载 -- 用于在主机和容器之间共享数据。开发者可以使用绑定挂载将他们的计算机上的源代码加载到容器中,这样当他们修改 HTML 或 JavaScript 文件时,更改会立即反映在容器中,而无需构建新的镜像。
-
分布式绑定挂载--用于在网络存储和容器之间共享数据。这些很有用,但你需要意识到网络存储的性能可能不会与本地磁盘相同,并且可能不会提供完整的文件系统功能。它们可以用作只读源来存储配置数据或共享缓存,或者用作读写来存储可以被同一网络上的任何机器上的任何容器使用的数据。
-
卷挂载--用于在容器和由 Docker 管理的存储对象之间共享数据。这些对于持久化存储很有用,其中应用程序将数据写入卷。当你用新容器升级应用程序时,它将保留之前版本写入卷的数据。
-
镜像层--这些提供了容器的初始文件系统。层是堆叠的,最新层覆盖了早期层,因此 Dockerfile 开始时写入层的文件可以被随后写入相同路径的层覆盖。层是只读的,并且可以在容器之间共享。
6.6 实验室
我们将在本实验室中把这些部分组合起来。它回到了那个好用的待办事项列表应用程序,但这次有所不同。应用程序将在容器中运行,并从一组已创建的任务开始。你的任务是使用相同的镜像但不同的存储选项运行应用程序,这样待办事项列表就可以从空开始,当你保存项目时,它们会被存储到 Docker 卷中。本章的练习应该能帮助你做到这一点,但这里有一些提示:
-
记住,删除所有现有容器的方法是
docker rm -f $(docker ps -aq)。 -
首先从
diamol/ch06-lab运行应用程序来检查任务。 -
然后你需要从相同的镜像运行一个带有一些挂载点的容器。
-
应用程序使用配置文件--里面不仅有日志的设置。
如果你需要,我的示例解决方案在书的 GitHub 仓库上,但你应该尝试完成这个任务,因为如果你没有太多经验,容器存储可能会让你感到困惑。有几种方法可以解决这个问题。
我的解决方案在这里:github.com/sixeyed/diamol/blob/master/ch06/lab/README.md。
第二部分. 在容器中运行分布式应用程序
很少有应用程序在单个组件中完成所有操作--它们通常分布在多个部分。在本部分书中,你将学习如何使用 Docker 和 Docker Compose 来定义、运行和管理跨多个容器运行的应用程序。你将学习如何使用 Docker 为持续集成构建管道提供动力,配置你的应用程序以便你可以在单台机器上运行多个环境,以及使用 Docker 网络隔离工作负载。本部分还将帮助你通过健康检查和可观察性来为容器做好生产准备。
7 使用 Docker Compose 运行多容器应用程序
大多数应用程序不是在一个单一组件中运行的。即使是大型老式应用程序,通常也是作为前端和后端组件构建的,这些组件是物理上分布在不同组件中的独立逻辑层。Docker 极其适合运行分布式应用程序--从多层单体到现代微服务。每个组件都在自己的轻量级容器中运行,Docker 使用标准网络协议将它们连接起来。你可以使用 Docker Compose 以这种方式定义和管理多容器应用程序。
Compose 是一种描述分布式 Docker 应用程序的文件格式,也是管理它们的工具。在本章中,我们将回顾书中早些时候的一些应用程序,并看看 Docker Compose 如何使它们的使用更加容易。
7.1 Docker Compose 文件的解剖结构
你已经与许多 Dockerfile 一起工作过,你知道 Dockerfile 是一个打包应用程序的脚本。但对于分布式应用程序,Dockerfile 实际上只是用于打包应用程序的一部分。对于一个具有前端网站、后端 API 和数据库的应用程序,你可能需要三个 Dockerfile--每个组件一个。你将如何在这个容器中运行该应用程序?
你可以使用 Docker CLI 逐个启动每个容器,指定应用程序运行所需的全部选项。这是一个手动过程,可能会成为故障点,因为如果你有任何选项错误,应用程序可能无法正确运行,或者容器可能无法相互通信。相反,你可以使用 Docker Compose 文件描述应用程序的结构。
Docker Compose 文件描述了应用程序的期望状态--当一切运行时它应该看起来是什么样子。这是一个简单的文件格式,其中你将所有会放在 docker container run 命令中的选项放入 Compose 文件中。然后你使用 Docker Compose 工具运行应用程序。它会计算出 Docker 需要的资源,这些资源可能是容器、网络或卷--然后向 Docker API 发送请求以创建它们。
列表 7.1 展示了一个完整的 Docker Compose 文件--你可以在书籍源代码的“exercises”文件夹中找到这个文件。
列表 7.1 运行第六章待办事项应用程序的 Docker Compose 文件
version: '3.7' services: todo-web: image: diamol/ch06-todo-list ports: - "8020:80" networks: - app-net networks: app-net: external: name: nat
此文件描述了一个简单的应用程序,其中一个 Docker 容器连接到一个 Docker 网络。Docker Compose 使用 YAML,这是一种人类可读的文本格式,因其易于转换为 JSON(这是 API 的标准语言)而广泛使用。在 YAML 中,空格很重要--缩进用于标识对象及其对象的子属性。
在这个例子中,有三个顶级语句:
-
version是此文件中使用的 Docker Compose 格式的版本。功能集在许多版本中已经演变,因此这里的版本标识了哪些版本与此定义兼容。 -
services列出了构成应用程序的所有组件。Docker Compose 使用服务而非实际容器的概念,因为一个服务可以以多个容器从同一镜像的规模运行。 -
networks列出了服务容器可以连接的所有 Docker 网络。
您可以使用 Compose 运行此应用程序,并且它将启动单个容器以到达所需状态。图 7.1 显示了应用程序资源的架构图。
在我们实际运行此应用程序之前,有几件事情需要更仔细地查看。名为todo-web的服务将从一个名为diamol/ch06-todo-list的镜像运行单个容器。它将在主机上发布端口8020到容器的端口80,并且它将连接容器到在 Compose 文件中称为app-net的 Docker 网络。最终结果将与运行docker container run -p 8020:80 --name todo-web --network nat diamol/ch06-todo-list相同。

图 7.1 简单 Compose 文件的一个服务和网络架构
在服务名称下方是属性,这些属性与docker container run命令中的选项相当接近:image是要运行的镜像,ports是要发布的端口,而networks是要连接的网络。服务名称成为容器名称和容器的 DNS 名称,其他容器可以使用它来在 Docker 网络上进行连接。服务中的网络名称是app-net,但在网络部分,该网络被指定为映射到名为nat的外部网络。external选项意味着 Compose 期望nat网络已经存在,并且它不会尝试创建它。
您可以使用docker-compose命令行来管理应用程序,该命令行与 Docker CLI 分开。docker-compose命令使用不同的术语,因此您使用up命令启动应用程序,该命令告诉 Docker Compose 检查 Compose 文件并创建任何需要的内容以将应用程序带到所需状态。
现在尝试一下 打开终端并创建 Docker 网络。然后浏览到包含 7.1 列表中 Compose 文件的文件夹,然后使用docker-compose命令行运行应用程序:
docker network create nat cd ./ch07/exercises/todo-list docker-compose up
你并不总是需要为 Compose 应用程序创建 Docker 网络,你可能已经从第四章的练习中运行了nat网络,在这种情况下,你会得到一个可以忽略的错误。如果你使用 Linux 容器,Compose 可以为你管理网络,但如果你使用 Windows 容器,你需要使用 Docker 在 Windows 上安装时创建的默认网络nat。我正在使用nat网络,所以无论你运行 Linux 容器还是 Windows 容器,相同的 Compose 文件都会为你工作。
Compose 命令行期望在当前目录中找到一个名为docker-compose.yml的文件,因此在这种情况下,它加载了待办事项列表应用程序定义。对于todo-web服务,你不会有任何匹配所需状态的容器,所以 Compose 将启动一个容器。当 Compose 运行容器时,它会收集所有应用程序日志并将它们按容器分组显示,这对于开发和测试非常有用。
运行上一个命令的输出结果在图 7.2 中——当你自己运行时,你也会看到从 Docker Hub 拉取的镜像,但我在运行命令之前已经拉取了它们。

图 7.2 使用 Docker Compose 启动应用程序,它创建了 Docker 资源
现在,你可以浏览到 http: / / localhost:8020 来查看待办事项列表应用程序。它的工作方式与第六章完全相同,但 Docker Compose 为你提供了启动应用程序的更稳健的方式。Docker Compose 文件将位于源代码控制中,与应用程序代码和 Dockerfile 一起,并成为描述应用程序所有运行时属性的单一位置。你不需要在 README 文件中记录镜像名称或发布的端口,因为所有这些都在 Compose 文件中。
Docker Compose 格式记录了你配置应用程序所需的所有属性,它还可以记录其他顶级 Docker 资源,如卷和机密。这个应用程序只有一个服务,即使在这种情况下,也最好有一个可以用来运行应用程序和记录其设置的 Compose 文件。但当你运行多容器应用程序时,Compose 才真正有意义。
7.2 使用 Compose 运行多容器应用程序
在第四章中,我们构建了一个分布式应用程序,该程序展示了来自 NASA 每日天文图片 API 的图片。该应用程序包含一个 Java 前端网站,一个用 Go 编写的 REST API,以及一个用 Node.js 编写的日志收集器。我们通过依次启动每个容器来运行应用程序,并且必须将容器连接到相同的网络并使用正确的容器名称,以便组件能够相互找到。这正是 Docker Compose 为我们解决的问题之一。
在列表 7.2 中,你可以看到描述图像库应用程序的 Compose 文件的services部分。我已经移除了网络配置,以便专注于服务属性,但服务就像待办事项应用程序示例中一样连接到nat网络。
列表 7.2 多容器图像画廊应用程序的 Compose 服务
accesslog: image: diamol/ch04-access-log iotd: image: diamol/ch04-image-of-the-day ports: - "80" image-gallery: image: diamol/ch04-image-gallery ports: - "8010:80" depends_on: - accesslog - iotd
这是一个配置不同类型服务的良好示例。accesslog 服务不发布任何端口或使用任何其他从 docker container run 命令捕获的属性,因此唯一记录的值是镜像名称。iotd 服务是 REST API--Compose 文件记录了镜像名称,并在容器上发布了端口 80 到主机的随机端口。image-gallery 服务具有镜像名称和特定的发布端口:主机上的 8010 端口映射到容器中的端口 80。它还有一个 depends_on 部分,说明此服务依赖于其他两个服务,因此 Compose 应该确保在启动此服务之前那些服务正在运行。

图 7.3 一个更复杂的 Compose 文件,指定了连接到同一网络的三种服务
图 7.3 展示了此应用程序的架构。我使用一个读取 Compose 文件并生成架构 PNG 图像的工具生成了本章中的图表。这是一个保持文档更新的好方法--每次有更改时,您都可以从 Compose 文件生成图表。该图表工具当然运行在 Docker 容器中--您可以在 GitHub 上找到它,网址为 github.com/pmsipilot/docker-compose-viz 。
我们将使用 Docker Compose 来运行应用程序,但这次我们将以分离模式运行。Compose 仍然会为我们收集日志,但容器将在后台运行,这样我们就可以恢复终端会话,并使用 Compose 的更多功能。
现在尝试一下 打开终端会话到您的 DIAMOL 源代码根目录,然后导航到图像画廊文件夹并运行应用程序:
cd ./ch07/exercises/image-of-the-day docker-compose up --detach
您的输出将像我图 7.4 中的那样。您可以看到,由于 Compose 文件中记录的依赖关系,accesslog 和 iotd 服务在 image-gallery 服务之前启动。

图 7.4 使用 Docker Compose 指定依赖关系启动多容器应用程序
当应用程序运行时,你可以浏览到 http: / / localhost:8010. 它与第四章中的操作一样,但现在你在 Docker Compose 文件中有明确的定义,说明容器需要如何配置才能一起工作。你还可以使用 Compose 文件来管理整个应用程序。API 服务实际上是无状态的,因此你可以将其扩展到在多个容器上运行。当 Web 容器从 API 请求数据时,Docker 会将这些请求共享到正在运行的 API 容器中。
现在尝试一下 在相同的终端会话中,使用 Docker Compose 增加iotd服务的扩展规模,然后刷新网页几次并检查iotd容器的日志:
docker-compose up -d --scale iotd=3 # 浏览到 http://localhost:8010 并刷新 docker-compose logs --tail=1 iotd
你会在输出中看到 Compose 创建了两个新的容器来运行图像 API 服务,因此现在它的扩展规模为三。当你刷新显示照片的网页时,Web 应用程序会从 API 请求数据,这个请求可以由任何 API 容器处理。API 在处理请求时写入日志条目,你可以在容器日志中看到。Docker Compose 可以显示所有容器的所有日志条目,或者你可以使用它来过滤输出----tail=1参数仅从每个iotd服务容器中获取最后一个日志条目。
我的输出在图 7.5 中--你可以看到容器 1 和 3 已被 Web 应用程序使用,但容器 2 迄今为止还没有处理任何请求。

图 7.5 使用 Docker Compose 扩展应用程序组件并检查其日志
Docker Compose 现在正在为我管理五个容器。我可以使用 Compose 控制整个应用程序;我可以停止所有容器以节省计算资源,当我需要应用程序运行时再次启动它们。但这些都是我可以使用 Docker CLI 进行操作的普通 Docker 容器。Compose 是一个用于管理容器的独立命令行工具,但它以与 Docker CLI 相同的方式使用 Docker API。你可以使用 Compose 来管理你的应用程序,但仍然可以使用标准的 Docker CLI 来与 Compose 创建的容器进行交互。
现在尝试一下 在相同的终端会话中,使用 Docker Compose 命令停止和启动应用程序,然后使用 Docker CLI 列出所有正在运行的容器:
docker-compose stop docker-compose start docker container ls
你的输出将像我图 7.6 中的那样。你会看到当停止应用程序时,Compose 会列出单个容器,但当它再次启动应用程序时,它只列出服务,并且服务以正确的依赖顺序启动。在容器列表中,你会看到 Compose 重新启动了现有的容器,而不是创建新的容器。所有我的容器都显示它们是在 30 分钟前创建的,但它们只运行了几秒钟。

图 7.6 使用 Docker Compose 停止和启动多容器应用程序
Compose 有许多其他功能——运行 docker-compose 而不带任何选项以查看完整的命令列表——但在你进一步操作之前,有一个非常重要的考虑因素。Docker Compose 是一个客户端工具。它是一个命令行,根据 Compose 文件的内容向 Docker API 发送指令。Docker 本身只是运行容器;它没有意识到许多容器代表一个单一的应用程序。只有 Compose 知道这一点,而 Compose 只是通过查看 Docker Compose YAML 文件来了解你的应用程序结构,因此你需要有这个文件来管理你的应用程序。
有可能使你的应用程序与 Compose 文件不同步,例如当 Compose 文件更改或你更新正在运行的应用程序时。当你返回使用 Compose 管理应用程序时,这可能会导致意外的行为。我们已经自己做过这件事了——我们将 iotd 服务扩展到三个容器,但这个配置没有在 Compose 文件中捕获。当你关闭应用程序然后重新创建它时,Compose 会将其恢复到原始的缩放级别。
现在尝试一下,在同一个终端会话中——因为 Compose 需要使用相同的 YAML 文件——使用 Docker Compose 将应用程序关闭并重新启动。然后通过列出运行中的容器来检查缩放情况:
docker-compose down docker-compose up -d docker container ls
down 命令删除应用程序,因此 Compose 会停止并删除容器——如果它们在 Compose 文件中记录并且未标记为 external,它也会删除网络和卷。然后 up 命令启动应用程序,因为没有正在运行的容器,Compose 会创建所有服务——但它使用 Compose 文件中的应用程序定义,该定义没有记录缩放,因此 API 服务使用一个容器启动,而不是我们之前运行的三个容器。
你可以在图 7.7 的输出中看到。这里的目的是重新启动应用程序,但我们意外地将 API 服务缩放到了更低的级别。
Docker Compose 使用简单且功能强大,但你需要注意它是一个客户端工具,因此它依赖于对应用程序定义 YAML 文件的良好管理。当你使用 Compose 部署应用程序时,它会创建 Docker 资源,但 Docker 引擎并不知道这些资源是相关的——只有当你有 Compose 文件来管理它们时,它们才是一个应用程序。
7.3 Docker 如何连接容器
分布式应用程序中的所有组件都运行在 Compose 的 Docker 容器中,但它们如何相互通信呢?您知道容器是一个具有自己网络空间的虚拟化环境。每个容器都由 Docker 分配一个虚拟 IP 地址,连接到同一 Docker 网络的容器可以使用它们的 IP 地址相互通信。但是容器在应用程序生命周期中会被替换,新的容器将拥有新的 IP 地址,因此 Docker 也支持使用 DNS 进行服务发现。
DNS 是域名系统,它将名称链接到 IP 地址。它在公共互联网和私有网络上工作。当您将浏览器指向 blog.sixeyed.com 时,您正在使用域名,该域名解析为托管我的博客的 Docker 服务器之一的 IP 地址。您的机器实际上使用 IP 地址获取内容,但作为用户,您与域名一起工作,这要友好得多。
Docker 内置了自己的 DNS 服务。运行在容器中的应用程序在尝试访问其他组件时进行域名查找。Docker 中的 DNS 服务执行该查找——如果域名实际上是一个容器名称,Docker 返回容器的 IP 地址,消费者可以直接在 Docker 网络中工作。如果域名不是容器,Docker 将请求转发到 Docker 运行的服务器,因此它将进行标准的 DNS 查找以在您的组织网络或公共互联网上找到 IP 地址。
您可以通过 image-gallery 应用程序看到这一过程。Docker 的 DNS 服务响应将包含单个 IP 地址,用于在单个容器中运行的服务,或者如果服务在多个容器中跨规模运行,则包含多个 IP 地址。

图 7.7 移除和重新创建应用程序将其重置为 Docker Compose 文件中的状态。
现在尝试一下。在相同的终端会话中,使用 Docker Compose 以 API 运行三倍的规模启动应用程序。然后连接到 Web 容器中的会话——选择要运行的 Linux 或 Windows 命令——并执行 DNS 查找:
docker-compose up -d --scale iotd=3 # 对于 Linux 容器: docker container exec -it image-of-the-day_image-gallery_1 sh # 对于 Windows 容器: docker container exec -it image-of-the-day_image-gallery_1 cmd nslookup accesslog exit
nslookup 是网络应用程序基本镜像的一部分的小工具——它为您提供的名称执行 DNS 查找,并打印出 IP 地址。我的输出在图 7.8 中——您可以看到 nslookup 的错误消息,您可以忽略(这与 DNS 服务器本身有关),然后是容器的 IP 地址。我的 accesslog 容器的 IP 地址是 172.24.0.2 。

图 7.8 使用 Docker Compose 缩放服务并执行 DNS 查找
连接到同一 Docker 网络的容器将获得相同网络范围内的 IP 地址,并且它们通过该网络连接。使用 DNS 意味着当您的容器被替换且 IP 地址发生变化时,您的应用程序仍然可以工作,因为 Docker 中的 DNS 服务将始终从域名查找中返回当前容器的 IP 地址。
您可以通过使用 Docker CLI 手动删除accesslog容器来验证这一点,然后使用 Docker Compose 再次启动应用程序。Compose 会看到没有正在运行的accesslog容器,因此它会启动一个新的容器。该容器可能从 Docker 网络获得新的 IP 地址——这取决于正在创建的其他容器——因此当您进行域名查找时,您可能会看到不同的响应。
现在尝试一下 仍然在同一个终端会话中,使用 Docker CLI 删除accesslog容器,然后使用 Docker Compose 将应用程序恢复到所需状态。然后再次连接到 Web 容器,使用 Linux 中的sh或 Windows 中的cmd,并运行更多的 DNS 查找:
docker container rm -f image-of-the-day_accesslog_1 docker-compose up -d --scale iotd=3 # 对于 Linux 容器: docker container exec -it image-of-the-day_image-gallery_1 sh # 对于 Windows 容器: docker container exec -it image-of-the-day_image-gallery_1 cmd nslookup accesslog nslookup iotd exit
您可以在图 7.9 中看到我的输出。在我的情况下,没有其他进程创建或删除容器,因此相同的 IP 地址172.24.0.2被用于新的accesslog容器。在iotd API 的 DNS 查找中,您可以看到返回了三个 IP 地址,每个服务中的三个容器对应一个。
DNS 服务器可以为域名返回多个 IP 地址。Docker Compose 使用此机制进行简单的负载均衡,为服务返回所有容器的 IP 地址。处理多个响应的方式取决于执行 DNS 查找的应用程序;一些应用程序采用简单的方法,使用列表中的第一个地址。为了尝试在所有容器之间提供负载均衡,Docker DNS 每次都会以不同的顺序返回列表。您会看到,如果您重复对iotd服务进行nslookup调用——这是一个尝试在所有容器之间分配流量的基本方法。
Docker Compose 记录了您容器的所有启动选项,并在运行时处理容器之间的通信。您还可以使用它来设置环境的配置。

图 7.9 服务与多个容器一起扩展——查找中返回了每个容器的 IP 地址。
7.4 Docker Compose 中的应用程序配置
第六章中的待办事项应用可以以不同的方式使用。您可以将其作为一个单独的容器运行,在这种情况下,它将在 SQLite 数据库中存储数据——这只是一个容器内的文件。您在第六章中看到了如何使用卷来管理该数据库文件。SQLite 适用于小型项目,但大型应用程序将使用单独的数据库,并且待办事项应用可以配置为使用远程 Postgres SQL 数据库而不是本地 SQLite。
Postgres 是一个强大且流行的开源关系型数据库。它在 Docker 中运行良好,因此您可以在一个容器中运行应用程序,在另一个容器中运行数据库的分布式应用程序。此待办事项应用的 Docker 镜像是根据本书中的指南构建的,因此它包含开发环境的默认配置集,但配置设置可以应用,以便与其他环境兼容。我们可以使用 Docker Compose 应用这些配置设置。
查看列表 7.3 中的 Compose 文件的服务——这些指定了 Postgres 数据库服务和待办事项应用程序服务。
列表 7.3 带有 Postgres 数据库的待办事项应用的 Compose 服务
services: todo-db: image: diamol/postgres:11.5 ports: - "5433:5432" networks: - app-net todo-web: image: diamol/ch06-todo-list ports: - "8020:80" environment: - Database:Provider=Postgres depends_on: - todo-db networks: - app-net secrets: - source: postgres-connection target: /app/config/secrets.json
数据库的规范很简单——它使用 diamol/postgres:11.5 镜像,将容器中的标准 Postgres 端口 5342 发布到主机上的端口 5433,并使用服务名称 todo-db,这将是服务的 DNS 名称。Web 应用程序有一些新的配置部分:
-
environment设置在容器内创建的环境变量。当此应用运行时,容器内将设置一个名为Database:Provider的环境变量,其值为Postgres。 -
secrets可以从运行时环境读取,并在容器内作为文件填充。此应用将在/app/config/secrets.json文件中包含名为postgres-connection的秘密内容。
在集群环境中,秘密通常由容器平台提供——这可能是 Docker Swarm 或 Kubernetes。它们存储在集群数据库中,可以进行加密,因此对于敏感配置数据(如数据库连接字符串、证书或 API 密钥)非常有用。在运行 Docker 的单台机器上,没有集群数据库用于秘密,因此使用 Docker Compose 可以从文件中加载秘密。此 Compose 文件的末尾有一个 secrets 部分,如列表 7.4 所示。
列表 7.4 在 Docker Compose 中从本地文件加载秘密
secrets: postgres-connection: file: ./config/secrets.json
这告诉 Docker Compose 从名为secrets.json的主机文件中加载名为postgres-connection的秘密。这种场景类似于我们在第六章中提到的绑定挂载——实际上,主机上的文件被暴露到容器中。但将其定义为秘密,您可以选择在集群环境中迁移到真正的加密秘密。
将应用程序配置插入到 Compose 文件中,让您以不同的方式使用相同的 Docker 镜像,并对每个环境的设置进行明确说明。您可以为开发和测试环境分别创建不同的 Compose 文件,发布不同的端口并触发应用程序的不同功能。此 Compose 文件设置环境变量和秘密,以便在 Postgres 模式下运行待办事项应用程序,并提供连接到 Postgres 数据库的详细信息。
当您运行应用程序时,您会看到它的行为方式相同,但现在数据存储在可以单独管理(与应用程序分开)的 Postgres 数据库容器中。
现在尝试一下 打开代码根目录的终端会话,并切换到本练习的目录。在那个目录中,您将看到 Docker Compose 文件以及包含要加载到应用程序容器中的秘密的 JSON 文件。使用docker-compose up命令以常规方式启动应用程序:
cd ./ch07/exercises/todo-list-postgres # for Linux containers: docker-compose up -d # OR for Windows containers (which use different file paths): docker-compose -f docker-compose-windows.yml up -d docker-compose ps
图 7.10 显示了我的输出。那里没有新的内容,除了docker-compose ps命令,它列出了所有作为此 Compose 应用程序一部分运行的容器。

图 7.10 使用 Docker Compose 运行新应用程序并列出其容器
您可以通过 http: / / localhost:8030 访问此版本的待办事项应用程序。功能相同,但现在数据正在 Postgres 数据库容器中保存。您可以使用数据库客户端进行检查——我使用 Sqlectron,这是一个快速的开源、跨平台 UI,用于连接到 Postgres、MySQL 和 SQL Server 数据库。服务器的地址是localhost:5433,这是容器公开的端口;数据库名为todo,用户名为postgres,没有密码。您可以在图 7.11 中看到,我已经向 Web 应用程序添加了一些数据,并且我可以在 Postgres 中查询它。
将应用程序包与运行时配置分离是 Docker 的一个关键优势。你的应用程序镜像将由你的构建管道生成,并且相同的镜像将经过测试环境直到它准备好投入生产。每个环境都会应用自己的配置设置,使用环境变量或绑定挂载或机密信息--这些都可以轻松地捕获在 Docker Compose 文件中。在每一个环境中,你都在使用相同的 Docker 镜像,因此你可以确信你正在将所有其他环境中测试通过的精确二进制文件和依赖项发布到生产环境中。

图 7.11 在容器中运行待办事项应用程序,使用 Postgres 数据库并查询数据
7.5 理解 Docker Compose 解决的问题
Docker Compose 是一种非常整洁的方式来描述复杂分布式应用程序的设置,使用的是小型、清晰的文件格式。Compose YAML 文件实际上是你应用程序的部署指南,但它比用 Word 文档编写的指南要先进得多。在以前,那些 Word 文档描述了应用程序发布的每一步,它们有数十页,充满了不准确描述和过时信息。Compose 文件简单且可操作--你用它来运行你的应用程序,因此不存在过时的风险。
当你开始更多地使用 Docker 容器时,Compose 是你工具箱中的一个有用部分。但了解 Docker Compose 的确切用途及其局限性非常重要。Compose 允许你定义你的应用程序并将定义应用到运行 Docker 的单台机器上。它会将该机器上实时 Docker 资源与 Compose 文件中描述的资源进行比较,并将向 Docker API 发送请求以替换已更新的资源,并在需要的地方创建新资源。
当你运行 docker-compose up 命令时,你将获得应用程序期望的状态,但 Docker Compose 的任务就到这里结束了。它不是一个像 Docker Swarm 或 Kubernetes 那样的完整容器平台--它不会持续运行以确保你的应用程序保持期望的状态。如果容器失败或你手动删除它们,Docker Compose 将不会重新启动或替换它们,直到你再次明确运行 docker-compose up 命令。图 7.12 展示了 Compose 在应用程序生命周期中的位置。

图 7.12 在应用程序生命周期中从开发到生产使用 Docker Compose 的位置
这并不是说 Docker Compose 不适合生产环境。如果您刚开始使用 Docker,并且正在将工作负载从单个虚拟机迁移到容器,它可能是一个不错的起点。在那个 Docker 机器上,您不会获得高可用性、负载均衡或故障转移,但您在单个应用程序虚拟机上也没有这些。您将为所有应用程序获得一致的工具集--所有内容都有 Dockerfile 和 Docker Compose 文件--并且您将获得一致的工具来部署和管理您的应用程序。这可能足以在您考虑运行容器集群之前开始。
7.6 实验室
Docker Compose 中有一些有用的功能,可以为运行您的应用程序提供可靠性。在这个实验中,我希望您创建一个 Compose 定义,以便在测试环境中更可靠地运行待办事项 Web 应用程序:
-
如果机器重新启动或 Docker 引擎重新启动,应用程序容器将重新启动。
-
数据库容器将使用绑定挂载来存储文件,因此您可以停用并重新启动应用程序,同时保留您的数据。
-
Web 应用程序应监听标准端口 80 进行测试。
对于这一点,只有一个提示:
- 您可以在 Docker 的参考文档中找到 Docker Compose 文件规范:
docs.docker.com/compose/compose-file。它定义了您可以在 Compose 中捕获的所有设置。
我的示例解决方案始终在书的 GitHub 仓库中。希望这个示例不会太复杂,这样您就不需要它了:github.com/sixeyed/diamol/blob/master/ch07/lab/README.md。
8 使用健康检查和依赖检查支持可靠性
我们正在朝着在容器中使软件生产就绪的目标前进。您已经看到将应用打包到 Docker 镜像中、在容器中运行它们以及使用 Docker Compose 定义多容器应用是多么简单。在生产中,您将在 Docker Swarm 或 Kubernetes 等容器平台上运行您的应用,这些平台具有帮助您部署自愈应用的功能。您可以将容器打包成平台使用的信息,以检查容器中的应用是否健康。如果应用停止正常工作,平台可以移除一个故障容器,并用一个新的容器替换它。
在本章中,您将学习如何将这些检查打包到您的容器镜像中,以帮助平台保持您的应用在线。
8.1 将健康检查构建到 Docker 镜像中
每次运行容器时,Docker 都会在基本层面上监控您的应用健康。容器启动时会运行一个特定的进程,这可能是 Java 或.NET Core 运行时、shell 脚本或应用程序二进制文件。Docker 会检查该进程是否仍在运行,如果停止,容器就会进入已退出的状态。
这为您提供了一个适用于所有环境的健康检查基础。如果进程失败并且容器退出,开发者可以看到他们的应用不健康。在集群环境中,容器平台可以重新启动已退出的容器或创建一个替换容器。但这只是一个非常基础的检查——它确保进程正在运行,但并不能保证应用实际上健康。一个容器中的 Web 应用可能会达到最大容量,并开始向每个请求返回 HTTP 503“服务不可用”的响应,但只要容器中的进程仍在运行,Docker 就会认为容器是健康的,尽管应用已经停滞。
Docker 提供了一种巧妙的方法,只需在 Dockerfile 中添加逻辑,就可以将真实的应用健康检查直接构建到 Docker 镜像中。我们将使用一个简单的 API 容器来实现这一点,但首先我们将不运行任何健康检查以确保我们理解了问题。
现在试试 Run 一个托管简单 REST API 并返回随机数的容器。该应用有缺陷,所以当 API 被调用三次后,它变得不健康,后续的每次调用都会失败。打开一个终端,运行容器,并使用 API——这是一个新镜像,所以您会在运行容器时看到 Docker 将其拉取:
# 启动 API 容器 docker container run -d -p 8080:80 diamol/ch08-numbers-api # 重复三次 - 它返回一个随机数 curl http://localhost:8080/rng curl http://localhost:8080/rng curl http://localhost:8080/rng # 从第四次调用开始,API 总是失败 curl http://localhost:8080/rng # 检查容器状态 docker container ls
你可以在图 8.1 中看到我的输出。API 在前三次调用时表现正确,然后返回 HTTP 500“内部服务器错误”响应。代码中的错误意味着它将始终返回 500。实际上,这并不是一个错误;应用程序是故意这样编写的。如果你想查看它是如何工作的,源代码在本书第八章的源代码中(ch08/exercises/numbers/numbers-api/Dockerfile.v2)。在容器列表中,API 容器状态为Up。容器内的进程仍在运行,所以从 Docker 的角度来看,看起来一切正常。容器运行时无法知道该进程内部发生了什么,以及应用程序是否仍然表现正确。

图 8.1 Docker 检查应用程序进程,即使应用程序处于失败状态,容器也能正常运行。
输入HEALTHCHECK指令,你可以将其添加到 Dockerfile 中,以告诉运行时如何确切地检查容器中的应用程序是否仍然健康。HEALTHCHECK指令指定了 Docker 在容器内运行的命令,该命令将返回一个状态码——命令可以是任何你需要检查应用程序是否健康的命令。Docker 将在容器中以定时间隔运行该命令。如果状态码表明一切正常,则容器是健康的。如果状态码连续几次失败,则容器被标记为不健康。
列表 8.1 显示了为随机数 API 创建的新 Dockerfile 中的HEALTHCHECK命令,我将构建为版本 2(完整文件在本书源代码的ch08/exercises/numbers/numbers-api/Dockerfile.v2中)。这个健康检查使用了一个类似于我在主机上使用的curl命令,但这次它在容器内运行。/health URL 是应用程序中的另一个端点,用于检查是否触发了错误;如果应用程序运行正常,它将返回 200“OK”状态码;如果应用程序损坏,它将返回 500“内部服务器错误”。
列表 8.1 Dockerfile 中的HEALTHCHECK指令
FROM diamol/dotnet-aspnet ENTRYPOINT ["dotnet", "/app/Numbers.Api.dll"] HEALTHCHECK CMD curl --fail http://localhost/health WORKDIR /app COPY --from=builder /out/ .
Dockerfile 的其余部分相当简单。这是一个.NET Core 应用程序,所以ENTRYPOINT运行dotnet命令,Docker 监控的就是这个dotnet进程,以检查应用程序是否仍在运行。健康检查通过 HTTP 调用/health端点,这是 API 提供的用于测试应用程序是否健康的端点。使用--fail参数意味着curl命令会将状态码传递给 Docker——如果请求成功,它返回数字 0,Docker 将其读取为成功的检查。如果失败,它返回一个非 0 的数字,这意味着健康检查失败。
我们将构建该镜像的新版本,以便你可以看到build命令与不同文件结构一起是如何工作的。通常你会在应用程序源文件夹中有一个 Dockerfile,Docker 会找到它并运行构建。在这种情况下,Dockerfile 有一个不同的名称,并且位于源代码的单独文件夹中,所以你需要在build命令中显式指定路径。
现在试试看 运行一个终端,浏览到包含书籍源代码的文件夹。然后使用 v2 标签构建新的镜像,使用 v2 Dockerfile:
# 浏览到根路径,该路径包含源代码和 Dockerfile 文件夹: cd ./ch08/exercises/numbers # 使用-f 标志指定 Dockerfile 的路径来构建镜像: docker image build -t diamol/ch08-numbers-api:v2 -f ./numbers-api/Dockerfile.v2 .
一旦构建了镜像,你就可以运行应用并进行健康检查。你可以配置健康检查的运行频率以及连续失败多少次意味着应用不健康。默认情况下,每 30 秒运行一次,连续三次失败将触发不健康状态。API 镜像的 v2 版本已经内置了健康检查,所以当你重复测试时,你会发现在容器中报告了容器的健康状态。
现在试试看 运行相同的测试,但使用 v2 镜像标签,并在命令之间留出一些时间,让 Docker 在容器内触发健康检查。
# 启动 API 容器,v2 docker container run -d -p 8081:80 diamol/ch08-numbers-api:v2 # 等待大约 30 秒,然后列出容器 docker container ls # 重复四次 - 它返回三个随机数字然后失败 curl http://localhost:8081/rng curl http://localhost:8081/rng curl http://localhost:8081/rng curl http://localhost:8081/rng # 现在应用处于失败状态 - 等待 90 秒并检查 docker container ls
我的输出在图 8.2 中。你可以看到 API 容器的新版本最初显示为健康状态——如果镜像内置了健康检查,Docker 会显示运行容器的健康检查状态。在我触发错误一段时间后,容器显示为不健康。

图 8.2 一个损坏的应用显示为不健康的容器,但容器仍然在运行。
不健康状态作为 Docker API 的事件发布,因此运行容器的平台会收到通知并可以采取行动修复应用程序。Docker 还会记录最近的健康检查结果,你可以在检查容器时看到这些结果。你已经看到了docker container inspect的输出,它显示了 Docker 知道的关于容器的一切。如果正在进行健康检查,它也会显示出来。
现在试试看。我们有两个正在运行的 API 容器,我们在创建它们时没有命名,但我们可以使用带有 --last 标志的 container ls 命令找到最新创建的容器的 ID。您可以将它输入到 container inspect 中,以查看最新容器的状态:
docker container inspect $(docker container ls --last 1 --format '{{.ID}}')
这里返回的是 JSON 数据的页面,如果您滚动到 State 字段,您会看到有一个 Health 部分。它包含健康检查的当前状态、连续失败的次数(即“failing streak”)以及最近健康检查调用的日志。在图 8.3 中,您可以看到我的容器状态的摘录。健康检查正处于连续失败的第六次,这触发了容器进入不健康状态,您可以看到健康检查命令的日志,当它们得到 HTTP 状态码为 500 的结果时,这些命令会失败。

图 8.3 显示了具有健康检查的容器的健康状态和健康检查日志。
健康检查正在做它应该做的事情:测试容器内的应用程序,并向 Docker 标记应用程序不再健康。但您也可以在图 8.3 中看到,我的不健康容器有一个“运行”状态,所以尽管 Docker 知道它运行不正确,它仍然在运行。为什么 Docker 没有重新启动或替换那个容器?
简单的答案是 Docker 无法安全地这样做,因为 Docker 引擎是在单个服务器上运行的。Docker 可以停止并重新启动该容器,但这意味着在容器被回收期间,您的应用程序将出现停机时间。或者 Docker 可以删除该容器并从相同的设置中启动一个新的容器,但如果您的应用程序在容器内写入数据,那么这将意味着停机时间和数据丢失。Docker 无法确定采取行动修复不健康的容器不会使情况变得更糟,因此它广播容器不健康的信息,但仍然让它运行。健康检查也会继续,所以如果失败是暂时的,并且下一次检查通过,容器状态会再次变为健康。
在由 Docker Swarm 或 Kubernetes 管理的多个服务器上运行的 Docker 集群中,健康检查变得非常有用。如果容器不健康,容器平台会收到通知并采取行动。因为集群中有多余的容量,可以在不健康的容器仍在运行时启动一个替换容器,所以不应该有任何应用程序的停机时间。
8.2 使用依赖检查启动容器
健康检查是一个持续进行的测试,有助于容器平台保持应用程序运行。具有多个服务器的集群可以通过启动新的容器来处理暂时性故障,因此即使一些容器停止响应,也不会丢失服务。但是,跨集群运行为分布式应用程序带来了新的挑战,因为你无法控制可能相互依赖的容器的启动顺序。
我们的随机数生成器 API 伴随有一个网站。该 Web 应用程序在其自己的容器中运行,并使用 API 容器生成随机数。在单个 Docker 服务器上,你可以确保在 Web 容器启动之前创建 API 容器,这样当 Web 应用程序启动时,它就有所有可用的依赖项。你甚至可以使用 Docker Compose 明确捕获这一点。然而,在集群容器平台上,你无法指定容器的启动顺序,因此 Web 应用程序可能会在 API 可用之前启动。
那么接下来会发生什么取决于你的应用程序。随机数应用程序处理得不是很好。
现在尝试一下:移除所有正在运行的容器,因此现在你没有 API 容器。然后运行 Web 应用程序容器并浏览到它。容器已启动,应用程序可用,但你发现它实际上并不工作。
docker container rm -f $(docker container ls -aq) docker container run -d -p 8082:80 diamol/ch08-numbers-web docker container ls
现在,浏览到 http: / / localhost:8082。你会看到一个看起来正常的简单 Web 应用程序,但如果你点击随机数按钮,你会看到图 8.4 中显示的错误。

图 8.4 未验证其依赖项是否可用的应用程序可能看起来正常,但实际上处于失败状态。
这正是你不想发生的事情。容器看起来没问题,但应用程序不可用,因为它的关键依赖项不可用。一些应用程序可能内置了逻辑来验证它们在启动时所需的依赖项是否存在,但大多数应用程序没有,随机数 Web 应用程序就是其中之一。它假设 API 在需要时将可用,因此它不会进行任何依赖性检查。
你可以在 Docker 镜像内部添加依赖性检查。依赖性检查与健康检查不同--它在应用程序启动之前运行,并确保应用程序所需的一切都可用。如果一切都在那里,依赖性检查将成功完成,应用程序启动。如果依赖项不存在,检查将失败,容器退出。Docker 没有内置的类似 HEALTHCHECK 指令的依赖性检查功能,但你可以在启动命令中放入该逻辑。
列表 8.2 显示了 Web 应用程序的新 Dockerfile 的最终应用程序阶段(完整文件位于 ch08/exercises/numbers/numbers-web/Dockerfile.v2)--CMD 指令在启动应用程序之前验证 API 是否可用。
列表 8.2 一个在启动命令中进行依赖性检查的 Dockerfile
FROM diamol/dotnet-aspnet ENV RngApi:Url=http://numbers-api/rng CMD curl --fail http://numbers-api/rng && \ dotnet Numbers.Web.dll WORKDIR /app COPY --from=builder /out/ .
此检查再次使用 curl 工具,它是基础镜像的一部分。CMD指令在容器启动时运行,并调用 API 进行 HTTP 调用,这是一个简单的检查,以确保其可用性。双与号&&在 Linux 和 Windows 命令行中工作方式相同——如果左侧的命令成功,它将运行右侧的命令。
如果我的 API 可用,curl 命令将成功,应用程序将被启动。这是一个.NET Core Web 应用程序,因此 Docker 将监控dotnet进程以验证应用程序是否仍然存活(在这个 Dockerfile 中没有健康检查)。如果 API 不可用,curl 命令将失败,dotnet命令将不会运行,容器中不会发生任何事情,因此它将退出。
现在试试看:从随机数字 Web 镜像的 v2 标签运行一个容器。目前还没有 API 容器,所以当这个容器启动时,它会失败并退出:
docker container run -d -p 8084:80 diamol/ch08-numbers-web:v2 docker container ls --all
你可以在图 8.5 中看到我的输出。v2 容器在启动后仅几秒钟就退出了,因为 curl 命令未能找到 API。原始的 Web 应用程序容器仍在运行,但它仍然不可用。

图 8.5 启动时进行依赖性检查的容器在检查失败时退出。
这看起来有些反直觉,但在这种情况下,有一个退出的容器比一个正在运行的容器更好。这是一种快速失败的行为,当你大规模运行时,这正是你想要的。当容器退出时,平台可以调度一个新的容器来启动并替换它。也许 API 容器启动需要很长时间,所以在 Web 容器运行时不可用;在这种情况下,Web 容器会退出,调度一个替换,当它启动时 API 已经运行。
通过健康和依赖性检查,我们可以将应用程序打包成容器平台中的良好公民。到目前为止,我们使用的检查方法非常基础,是使用 curl 进行的 HTTP 测试。这证明了我们想要做的事情,但这是一种简单的方法,而且最好不要依赖外部工具进行你的检查。
8.3 为应用程序检查逻辑编写自定义实用工具
Curl 是测试 Web 应用程序和 API 的非常有用的工具。它是跨平台的,因此可以在 Linux 和 Windows 上运行,它是我的黄金镜像的基础,也就是我使用的.NET Core 运行时镜像的一部分,所以我知道它将用于运行我的检查。然而,实际上我并不需要在镜像中包含 curl 来运行我的应用程序,安全审查可能会要求将其删除。
我们在第四章中讨论了这一点——你的 Docker 镜像应该包含运行应用程序所需的最小内容。任何额外的工具都会增加镜像的大小,它们还会增加更新的频率和安全攻击面。所以虽然 curl 是开始容器检查的绝佳工具,但最好是用与你的应用程序相同的语言编写自定义实用工具进行检查——Java 应用程序使用 Java,Node.js 应用程序使用 Node.js,等等。
这有很多优点:
-
你可以减少镜像中的软件需求——你不需要安装任何额外的工具,因为检查实用工具运行所需的一切都已经为应用程序准备好了。
-
你可以在检查中使用更复杂的条件逻辑,包括重试或分支,这在 shell 脚本中很难表达,特别是如果你正在发布适用于 Linux 和 Windows 的跨平台 Docker 镜像。
-
你的实用工具可以使用与你的应用程序相同的配置,这样你就不需要在多个地方指定设置,如 URL,从而降低它们不同步的风险。
-
你可以执行所需的任何测试,检查数据库连接或文件路径是否存在你期望平台加载到容器中的证书——所有这些都可以使用你的应用程序使用的相同库。
实用工具也可以被设计成通用的,以便在多种情况下使用。我编写了一个简单的.NET Core HTTP 检查实用工具,我可以用它来在 API 镜像中进行健康检查,以及在 Web 镜像中进行依赖项检查。每个应用程序都有多阶段 Dockerfile,其中一个阶段编译应用程序,另一个阶段编译检查实用工具,最后一个阶段将应用程序和实用工具复制进来。图 8.6 展示了这个过程的外观。

图 8.6 使用多阶段构建编译和打包与应用程序一起的实用工具
API 的Dockerfile.v3的最终阶段在列表 8.3 中展示。健康检查现在使用检查实用工具,这是一个.NET Core 应用程序,因此检查不再需要在镜像中安装 curl。
列表 8.3 使用自定义实用工具进行健康检查以消除对 curl 的需求
FROM diamol/dotnet-aspnet ENTRYPOINT ["dotnet", "Numbers.Api.dll"] HEALTHCHECK CMD ["dotnet", "Utilities.HttpCheck.dll", "-u", "http://localhost/health"] WORKDIR /app COPY --from=http-check-builder /out/ . COPY --from=builder /out/ .
新的健康检查行为基本上相同;与 curl 版本相比,唯一的区别是当你检查容器时,输出中不会看到那么多冗长的日志。每个检查只有一行,说明它是成功还是失败。应用程序最初应该仍然报告为健康;在你对 API 进行几次调用后,它将被标记为不健康。
现在尝试一下 删除您现有的所有容器,并运行随机数 API 的版本 3。这次我们将指定健康检查的间隔,以便它更快地触发。检查容器是否被列为健康,然后使用 API 并检查容器是否变为不健康:
# 清除现有容器 docker container rm -f $(docker container ls -aq) # 启动 API 容器,v3 docker container run -d -p 8080:80 --health-interval 5s diamol/ch08-numbers-api:v3 # 等待大约五秒钟,然后列出容器 docker container ls # 重复四次 - 它返回三个随机数然后失败 curl http://localhost:8080/rng curl http://localhost:8080/rng curl http://localhost:8080/rng curl http://localhost:8080/rng # 现在应用程序处于失败状态 - 等待 15 秒钟再次检查 docker container ls
图 8.7 显示了我的输出。行为与版本 2 相同,健康检查在 API 中的错误被触发后失败,因此 HTTP 检查实用工具正在正常工作。

图 8.7 使用打包在 Docker 镜像中的实用工具进行的容器健康检查
HTTP 检查实用工具有很多选项,使其适用于不同的场景。在 Web 应用程序的Dockerfile.v3中,我使用相同的实用工具在启动时进行依赖项检查,以查看 API 是否可用。
表 8.4 显示了 Dockerfile 的最终阶段。在这种情况下,我使用-t标志来设置实用工具等待响应的时间,-c标志告诉实用工具加载与应用程序相同的配置文件,并从应用程序配置中获取 API 的 URL。
表 8.4 在容器启动时使用实用工具进行依赖项检查
FROM diamol/dotnet-aspnet ENV RngApi:Url=http://numbers-api/rng CMD dotnet Utilities.HttpCheck.dll -c RngApi:Url -t 900 && \ dotnet Numbers.Web.dll WORKDIR /app COPY --from=http-check-builder /out/ . COPY --from=builder /out/ .
再次,这消除了在应用程序镜像中需要 curl 的要求,但与启动命令中的 HTTP 实用工具的行为几乎相同。
现在尝试一下 运行 Web 应用程序的版本 3,您会看到容器几乎立即退出,因为 HTTP 检查实用工具在执行 API 检查时失败:
docker container run -d -p 8081:80 diamol/ch08-numbers-web:v3 docker container ls --all
您的输出将像我图 8.8 中的那样。您会看到 API 容器仍在运行,但它仍然不健康。Web 容器没有找到它,因为它正在寻找 DNS 名称numbers-api,而我们运行 API 容器时没有指定该名称。如果我们为 API 容器使用该名称,Web 应用程序就会连接并能够使用它,尽管它仍然会显示错误,因为 API 中的错误已被触发,它没有响应。
在实用程序中编写自己的检查的另一个好处是它使得你的镜像可移植。不同的容器平台有不同的声明和使用健康检查和依赖检查的方式,但如果你在你的镜像中的实用程序中拥有所有需要的逻辑,你就可以让它在 Docker Compose、Docker Swarm 和 Kubernetes 上以相同的方式工作。
8.4 在 Docker Compose 中定义健康检查和依赖检查
如果你还没有确信容器在依赖不可用时失败和退出是一个好主意,你很快就会看到为什么这样做是有效的。Docker Compose 可以在一定程度上修复不可靠的应用程序,但它不会替换不健康的容器,原因与 Docker Engine 不会替换的原因相同:你正在单个服务器上运行,修复可能会造成停机。但它可以设置容器在退出时重启,并且如果镜像中还没有的话,它可以添加一个健康检查。

图 8.8 使用打包到 Docker 镜像中的实用程序作为依赖检查工具
列表 8.5 显示了随机数 API 在 Docker Compose 文件中声明为服务(完整的文件位于ch08/exercises/numbers/docker-compose.yml)。它指定了 v3 容器镜像,该镜像使用 HTTP 实用程序进行健康检查,并添加了配置健康检查应该如何工作的设置。
列表 8.5 在 Docker Compose 文件中指定健康检查参数
numbers-api: image: diamol/ch08-numbers-api:v3 ports: - "8087:80" healthcheck: interval: 5s timeout: 1s retries: 2 start_period: 5s networks: - app-net
你可以对健康检查有细粒度的控制。我正在使用 Docker 镜像中定义的实际健康检查命令,但使用自定义设置来运行它:
-
interval是检查之间的时间间隔——在这种情况下是五秒。 -
timeout是在检查被认为失败之前允许其运行的时间。 -
retries是在容器被标记为不健康之前允许连续失败的次数。 -
start_period是在触发健康检查之前需要等待的时间,这让你可以在健康检查运行之前给你的应用程序一些启动时间。
这些设置可能因每个应用程序和每个环境而异——在快速发现应用程序失败和允许暂时性故障之间需要平衡,这样你就不必触发关于不健康容器的误报。我设置的 API 相当激进;运行健康检查会消耗 CPU 和内存,所以在生产环境中你可能会使用更长的间隔。
你也可以在你的 Compose 文件中为那些在镜像中没有声明的容器添加健康检查。列表 8.6 显示了同一 Docker Compose 文件中的 Web 应用服务,而我正在为该服务添加一个健康检查。我指定了与 API 服务相同的选项集,但还有一个 test 字段,它提供了 Docker 运行的健康检查命令。
列表 8.6 在 Docker Compose 中添加健康检查
numbers-web: image: diamol/ch08-numbers-web:v3 restart: on-failure ports: - "8088:80" healthcheck: test: ["CMD", "dotnet", "Utilities.HttpCheck.dll", "-t", "150"] interval: 5s timeout: 1s retries: 2 start_period: 10s networks: - app-net
为所有容器添加健康检查是个好主意,但这个例子结合了镜像中的依赖检查和 restart: on-failure 设置,这意味着如果容器意外退出,Docker 将重新启动它(如果你还没有完成第七章的实验,这是其中一个答案)。没有 depends_on 设置,所以 Docker Compose 可以以任何顺序启动容器。如果 Web 容器在 API 容器准备好之前启动,依赖检查将失败,Web 容器将退出。同时,API 容器已经启动,所以当 Web 应用容器重新启动时,依赖检查将成功,应用将完全运行。
现在试试看 清除正在运行的容器,并使用 Docker Compose 启动随机数应用。列出容器以查看 Web 应用是否首先启动,然后重新启动:
# 浏览到 Compose 文件 cd ./ch08/exercises/numbers # 清除现有容器 docker container rm -f $(docker container ls -aq) # 启动应用 docker-compose up -d # 等待大约五秒钟,然后列出容器 docker container ls # 检查 Web 应用日志 docker container logs numbers_numbers-web_1
我的输出在图 8.9 中,你的输出应该非常相似。Compose 同时创建了两个容器,因为没有指定依赖关系。当 API 容器启动时——在应用准备好处理请求之前——Web 容器的依赖检查会运行。你可以在我的日志中看到 HTTP 检查返回成功代码,但耗时 3176 毫秒,而检查被设置为要求在 150 毫秒内得到响应,所以检查失败,容器退出。Web 服务被配置为在失败时重启,所以相同的容器再次启动。这次 API 检查在 115 毫秒内得到成功状态代码,所以检查通过,应用处于工作状态。

图 8.9 Docker Compose 增强了容器的弹性——Web 容器在第一次检查失败后重新启动。
浏览到 http://localhost:8088,您最终可以通过 Web 应用程序获取一个随机数。至少,您可以点击按钮三次并得到一个数字——在第四次点击时,您将触发 API 错误,之后您将只会收到错误。图 8.10 显示了其中一次罕见的成功。

图 8.10 应用程序终于正常运行,容器中的健康和依赖性检查已完成。
您可能会问,为什么要在容器启动时构建依赖性检查,当 Docker Compose 可以用depends_on标志为您完成这项工作时?答案是,Compose 只能管理单台机器上的依赖项,而您的应用程序在生产集群上的启动行为远没有那么可预测。
8.5 理解检查如何为自愈应用程序提供动力
将您的应用程序构建为一个由许多小型组件组成的分布式系统可以增加您的灵活性和敏捷性,但这确实会使管理变得更加复杂。组件之间将有许多依赖关系,您可能会想声明组件启动的顺序,以便您可以模拟依赖关系。但这样做实际上并不是一个好主意。
在单台机器上,我可以告诉 Docker Compose 我的 Web 容器依赖于我的 API 容器,并且它会按照正确的顺序启动它们。在生产环境中,我可能在几十台服务器上运行 Kubernetes,我可能需要 20 个 API 容器和 50 个 Web 容器。如果我模拟启动顺序,容器平台会先启动所有 20 个 API 容器,然后再启动任何 Web 容器吗?如果前 19 个容器都能正常运行,但第 20 个容器有问题,需要 5 分钟才能启动怎么办?我没有 Web 容器,所以我的应用程序无法运行,但所有 50 个 Web 容器都在运行,即使有一个 API 容器不可用,它也能正常工作。
这就是依赖性检查和健康检查发挥作用的地方。您不需要平台来保证启动顺序——您让它尽可能快地启动尽可能多的容器,分布在尽可能多的服务器上。如果其中一些容器无法访问其依赖项,它们会迅速失败并重新启动,或者被其他容器替换。在大型应用程序达到 100%服务之前,可能需要几分钟的调整时间,但在这几分钟内,应用程序将一直在线并服务于用户。图 8.11 展示了生产集群中容器生命周期的一个示例。

图 8.11 生产集群中的自愈应用程序--容器可以被重新启动或替换。
自愈应用程序的想法是,平台可以处理任何瞬态故障。如果您的应用程序有一个导致其耗尽内存的严重错误,平台将关闭容器,并用一个具有新鲜内存分配的新容器替换它。它不会修复错误,但会保持应用程序的正常运行。
虽然你需要对你的检查非常小心。健康检查会定期运行,因此它们不应该做太多工作。你需要找到平衡点,以便检查能够测试你的应用程序的关键部分是否正常工作,同时运行时间不要太长或使用太多的计算资源。依赖性检查仅在启动时运行,因此你不需要过于担心它们使用的资源,但你需要小心检查的内容。一些依赖性超出了你的控制范围,如果平台无法解决问题,那么如果你的容器失败,这也不会有帮助。
确定检查中要使用的逻辑是难点。Docker 使捕获这些检查并为你执行它们变得容易,如果你做对了,你的容器平台会为你保持应用程序的运行。
8.6 实验室
一些应用程序会持续使用资源,因此初始的依赖性检查和持续的健康检查正在测试相同的内容。这个实验室就是这样。这是一个模拟内存消耗者的应用程序--只要它在运行,它就会不断分配并保留更多的内存。这是一个 Node.js 应用程序,它需要一些检查:
-
在启动时,它应该检查是否有足够的内存来运行;如果没有,它应该退出。
-
在运行时,它应该每 5 秒检查一次,看它是否分配了比允许的更多的内存;如果是,它需要标记它不健康。
-
测试逻辑已经编写在
memory-check.js脚本中。它只需要被连接到 Dockerfile 中。 -
脚本和初始的 Dockerfile 位于源文件夹
ch08/lab中。
注意:应用程序实际上并没有分配任何内存。容器中的内存管理因不同环境而复杂化--Windows 上的 Docker Desktop 与 Linux 上的 Docker Community Edition 表现不同。对于这个实验室,应用程序只是假装使用内存。
这个实验室相当直接。我只想指出,Node.js 应用程序不需要编译,因此你不需要多个阶段。我的示例在同一目录中,名为Dockerfile.solution,你可以在书籍的 GitHub 存储库中找到说明:github.com/sixeyed/diamol/blob/master/ch08/lab/README.md。
9 使用容器化监控添加可观察性
自主应用程序会根据传入流量自动扩展和缩减,当出现间歇性故障时会自我修复。这听起来太好了,以至于不太可能是真的——而且可能确实如此。如果你在构建 Docker 镜像时包含健康检查,容器平台可以为你做很多操作工作,但你仍然需要持续的监控和警报,以便在事情出错时人类可以介入。如果你对你的容器化应用程序没有任何洞察,这将是你无法进入生产环境的头号障碍。
当你在容器中运行应用程序时,可观察性是软件景观中的一个关键部分——它告诉你应用程序在做什么以及它们的性能如何,并且可以帮助你确定问题的根源。在本章中,你将学习如何使用 Docker 的一个成熟监控方法:从你的应用程序容器中公开指标,并使用 Prometheus 收集它们,使用 Grafana 在用户友好的仪表板中可视化它们。这些工具是开源的,跨平台的,并且与你的应用程序一起在容器中运行。这意味着你可以在每个环境中获得相同的应用程序性能洞察,从开发到生产。
9.1 容器化应用程序的监控堆栈
监控在容器中运行的应用程序时有所不同。在传统环境中,你可能有一个监控仪表板显示服务器列表及其当前利用率——磁盘空间、内存、CPU——以及警报来告诉你是否有任何服务器过载并且可能停止响应。容器化的应用程序更加动态——它们可能运行在数十或数百个短暂存在的容器中,这些容器由容器平台创建或删除。
你需要一个容器感知的监控方法,使用能够连接到容器平台进行发现并找到所有运行中的应用程序的工具,而无需静态的容器 IP 地址列表。Prometheus 是一个开源项目,正是这样做的。它是一个成熟的产品,由云原生计算基金会(Kubernetes 和 containerd 容器运行时的背后基金会)监督。Prometheus 在 Docker 容器中运行,因此你可以轻松地为应用程序添加监控堆栈。图 9.1 展示了该堆栈的外观。

图 9.1 在容器中运行 Prometheus 以监控其他容器和 Docker 本身
Prometheus 为监控带来了一个非常重要的方面:一致性。你可以为所有应用程序导出相同类型的指标,这样你就有了一个标准的方式来监控它们,无论它们是 Windows 容器中的 .NET 应用程序还是 Linux 容器中的 Node.js 应用程序。你只需要学习一种查询语言,就可以将其应用于整个应用程序堆栈。
使用 Prometheus 的另一个好理由是 Docker 引擎也可以以该格式导出指标,这让你也能了解容器平台上的情况。你需要在 Docker 引擎配置中显式启用 Prometheus 指标--你可以在第五章中看到如何更新配置。在 Windows 上,你可以直接在C:\ProgramData\docker\config中编辑daemon.json文件,或者在 Linux 上的/etc/docker。或者,在 Docker Desktop 上,你可以右键单击鲸鱼图标,选择设置,并在守护进程部分编辑配置。
现在试试吧 打开您的配置设置并添加两个新值:
"metrics-addr" : "0.0.0.0:9323", "experimental": true
这些设置启用了监控并在端口 9323 上发布指标。
你可以在图 9.2 中看到我的完整配置文件。

图 9.2 配置 Docker 引擎以导出 Prometheus 格式的指标
Docker 引擎指标目前是一个实验性功能,这意味着它提供的详细信息可能会改变。但这个实验性功能已经有一段时间了,并且已经稳定。它值得包含在您的仪表板中,因为它为系统的整体健康状况增加了另一层细节。现在您已经启用了指标,您可以浏览到 http://localhost:9323/metrics 来查看 Docker 提供的信息。图 9.3 显示了我的指标,包括 Docker 运行的机器以及 Docker 管理的容器信息。

图 9.3 Docker 捕获的样本指标并通过 HTTP API 公开
此输出为 Prometheus 格式。它是一种简单的基于文本的表示,其中每个指标都显示其名称和值,指标之前有一些帮助文本说明指标是什么以及数据类型。这些基本的文本行是您容器监控解决方案的核心。每个组件都会公开一个类似这样的端点,提供当前指标;当 Prometheus 收集它们时,它会给数据添加时间戳,并将它们与所有之前的收集存储在一起,这样您就可以使用聚合查询数据或跟踪随时间的变化。
现在试试吧 你可以在容器中运行 Prometheus 来读取 Docker 机器的指标,但首先你需要获取机器的 IP 地址。容器不知道它们运行的服务器的 IP 地址,所以你需要先找到它,并将其作为环境变量传递给容器:
# 将你的机器的 IP 地址加载到一个变量中 - 在 Windows 上: $hostIP = $(Get-NetIPConfiguration | Where-Object {$_.IPv4DefaultGateway -ne $null }).IPv4Address.IPAddress # 在 Linux 上: hostIP=$(ip route get 1 | awk '{print $NF;exit}') # 以及在 Mac 上: hostIP=$(ifconfig en0 | grep -e 'inet\s' | awk '{print $2}') # 将你的 IP 地址作为环境变量传递给容器: docker container run -e DOCKER_HOST=$hostIP -d -p 9090:9090 diamol/prometheus:2.13.1
在diamol/prometheus Prometheus 镜像中的配置使用DOCKER_HOST IP 地址与主机机器通信并收集你在 Docker Engine 中配置的指标。通常情况下,你不需要从容器内部访问主机上的服务,如果你需要这样做,你通常会使用你的服务器名称,Docker 会找到 IP 地址。在开发环境中,这可能不起作用,但 IP 地址方法应该是可行的。
Prometheus 现在正在运行。它做了几件事情:它运行一个计划任务从你的 Docker 主机拉取指标,它将这些指标值与时间戳一起存储在其自己的数据库中,并且它有一个基本的 Web UI,你可以用它来导航指标。Prometheus UI 显示了 Docker /metrics端点的所有信息,你可以过滤指标并以表格或图形的形式显示它们。
现在尝试一下 浏览到 http://localhost:9090,你会看到 Prometheus 的 Web 界面。你可以通过浏览到状态 > 目标菜单选项来检查 Prometheus 是否可以访问指标。你的DOCKER_HOST状态应该是绿色的,这意味着 Prometheus 已经找到了它。
然后切换到图形菜单,你会看到一个下拉列表,显示 Prometheus 从 Docker 收集的所有可用指标。其中之一是engine_daemon_container_actions_seconds_sum,这是不同容器操作耗时记录。选择该指标并点击执行,你的输出将类似于图 9.4,显示创建、删除和启动容器所需的时间。
Prometheus UI 是一个简单的方式来查看正在收集的内容并运行一些查询。查看指标,你会看到 Docker 记录了大量的信息点。有些是高级读数,如每个状态的容器数量和失败的检查数量;其他提供低级细节,如 Docker Engine 分配的内存量;还有一些是静态信息,如 Docker 可用的 CPU 数量。这些都是基础设施级别的指标,所有这些都可以包括在你的状态仪表板中。

图 9.4 Prometheus 提供了一个简单的 Web 界面,你可以使用它来查找指标和运行查询。
您的应用程序将公开它们自己的度量,这些度量也将记录不同级别的详细信息。目标是让每个容器都有一个度量端点,并且 Prometheus 定期从它们中收集度量。Prometheus 将存储足够的信息,以便您构建一个仪表板,显示整个系统的整体健康状况。
9.2 从您的应用程序公开度量
我们已经查看 Docker Engine 公开的度量,因为这是一个开始使用 Prometheus 的简单方法。从每个应用程序容器公开一组有用的度量需要更多的努力,因为您需要代码来捕获度量并为 Prometheus 提供一个 HTTP 端点进行调用。这并不像听起来那么困难,因为所有主要编程语言都有 Prometheus 客户端库来为您完成这项工作。
在本章的代码中,我重新审视了 NASA 图片库应用程序,并为我的每个组件添加了 Prometheus 度量。我使用 Java 和 Go 的官方 Prometheus 客户端,以及 Node.js 的社区客户端库。图 9.5 显示了每个应用程序容器现在都打包了一个 Prometheus 客户端,该客户端收集并公开度量。

图 9.5 Prometheus 客户端库在您的应用程序中使度量端点在容器中可用。
从 Prometheus 客户端库收集的信息点是运行时级别的度量。它们提供了关于您的容器正在做什么以及它工作有多努力的关键信息,这些信息与应用程序运行时相关。Go 应用程序的度量包括活跃的 Goroutines 数量;Java 应用程序的度量包括 JVM 使用的内存。每个运行时都有自己的重要度量,客户端库在收集和导出这些度量方面做得很好。
现在试试看 这个章节的练习中有一个 Docker Compose 文件,它会启动一个带有每个容器中度量的新版本的图片库应用程序。使用该应用程序,然后浏览到一个度量端点:
cd ./ch09/exercises
我的输出在图 9.6 中。这些是从 Go 前端 Web 应用程序收集的度量——不需要自定义代码来生成这些数据。您只需将 Go 客户端库添加到您的应用程序中并设置它,就可以免费获得所有这些数据。

图 9.6 来自图片库 Web 容器的 Go 运行时 Prometheus 度量
如果您浏览到 http:/ /localhost:8011/ actuator/prometheus,您将看到 Java REST API 的类似指标。指标端点是文本的海洋,但所有关键数据点都在那里,可以构建一个仪表板,该仪表板将显示容器是否在“热”运行--如果它们正在使用大量的计算资源,如 CPU 时间、内存或处理器线程。
这些运行时指标是您在 Docker 的基础设施指标之后的下一个细节级别,但这两个级别并没有告诉您整个故事。最终的数据点是您明确捕获的应用程序指标,以记录有关应用程序的关键信息。这些指标可以是操作导向的,显示组件处理的事件数量或处理响应的平均时间。或者它们可以是业务导向的,显示当前活跃用户数量或注册新服务的人数。
Prometheus 客户端库也允许您记录这类指标,但您需要显式编写代码来捕获应用程序中的信息。这并不困难。列表 9.1 展示了一个使用 Node.js 库的示例,该示例位于图像库应用程序 access-log 组件的代码中。我不想向您展示一大堆代码,但随着您在容器方面进一步发展,您肯定会在 Prometheus 上花费更多时间,而这个来自 server.js 文件的片段展示了几个关键点。
列表 9.1 在 Node.js 中声明和使用自定义 Prometheus 指标值
//声明自定义指标: const accessCounter = new prom.Counter({ name: "access_log_total", help: "Access Log - total log requests" }); const clientIpGauge = new prom.Gauge({ name: "access_client_ip_current", help: "Access Log - current unique IP addresses" }); //稍后,更新指标值: accessCounter.inc(); clientIpGauge.set(countOfIpAddresses);
在本章的源代码中,您将看到我是如何在用 Go 编写的 image-gallery 网络应用程序和用 Java 编写的 image-of-the-day REST API 中添加指标的。每个 Prometheus 客户端库的工作方式都不同。在 main.go 源文件中,我以类似于 Node.js 应用程序的方式初始化计数器和仪表,但随后使用来自客户端库的仪表化处理程序,而不是显式设置指标。Java 应用程序又有所不同--在 ImageController.java 中,我使用了 @Timed 属性并在源代码中增加了一个 registry.counter 对象。每个客户端库都以对语言最合理的方式工作。
Prometheus 中有不同的指标类型——我在这些应用程序中使用了最简单的:计数器和仪表。它们都是数值。计数器保持一个增加或保持不变的值,而仪表保持可以增加或减少的值。选择指标类型并在正确的时间设置其值取决于你或你的应用程序开发者;其余的由 Prometheus 和客户端库处理。
现在试试看。你从上一个练习中运行了图像库应用程序,因此这些指标已经被收集。向应用程序运行一些负载,然后浏览到 Node.js 应用程序的指标端点:
# 循环进行 5 个 HTTP GET 请求 - 在 Windows 上: for ($i=1; $i -le 5; $i++) { iwr -useb http://localhost:8010 | Out-Null } # 或在 Linux 上: for i in {1..5}; do curl http://localhost:8010 > /dev/null; done # 现在,浏览到 http://localhost:8012/metrics
你可以在图 9.7 中看到我的输出——我运行了几个循环来发送流量。前两条记录显示了我的自定义指标,记录了接收到的访问请求数量和使用的总 IP 地址数。这些是简单的数据点(而 IP 计数实际上是假的),但它们起到了收集和显示指标的作用。Prometheus 允许你记录更复杂的指标类型,但即使使用简单的计数器和仪表,你也能捕获应用程序中的详细仪表。

图 9.7 包含自定义数据和 Node.js 运行时数据的指标端点
你捕获的内容取决于你的应用程序,但以下列表提供了一些有用的指南——你可以在月底准备为你的应用程序添加详细监控时返回这些指南。
-
当你与外部系统通信时,记录调用花费了多长时间以及响应是否成功——你将很快就能看到是否有其他系统正在减慢你的速度或破坏它。
-
任何值得记录的内容都可能在指标中记录——在内存、磁盘和 CPU 上增加计数器可能比写入日志条目更便宜,而且更容易可视化事情发生的频率。
-
任何关于应用程序或用户行为,业务团队希望报告的详细信息都应该记录为指标——这样你就可以构建实时仪表板,而不是发送历史报告。
9.3 运行 Prometheus 容器以收集指标
Prometheus 使用拉模型来收集指标。它不是让其他系统发送数据给它,而是从这些系统中获取数据。它称之为抓取,当你部署 Prometheus 时,你需要配置它要抓取的端点。在生产容器平台上,你可以配置 Prometheus,使其自动发现集群中的所有容器。在单服务器上的 Docker Compose 中,你使用一个简单的服务名称列表,Prometheus 通过 Docker 的 DNS 来查找容器。
列表 9.2 展示了我为 Prometheus 配置的抓取图像库应用程序中两个组件的配置。有一个 global 设置,它使用默认的 10 秒间隔进行抓取,然后为每个组件有一个 job。作业有一个名称,配置指定了指标端点的 URL 路径以及 Prometheus 将查询的目标列表。这里我使用了两种类型。首先,static_configs 指定了一个目标主机名,这对于单个容器来说是可以的。我还使用了 dns_sd_configs,这意味着 Prometheus 将使用 DNS 服务发现——这将找到多个容器的服务,并且它支持大规模运行。
列表 9.2 Prometheus 配置用于抓取应用程序指标
global: scrape_interval: 10s scrape_configs: - job_name: "image-gallery" metrics_path: /metrics static_configs: - targets: ["image-gallery"] - job_name: "iotd-api" metrics_path: /actuator/prometheus static_configs: - targets: ["iotd"] - job_name: "access-log" metrics_path: /metrics dns_sd_configs: - names: - accesslog type: A port: 80
此配置将 Prometheus 设置为每 10 秒轮询所有容器。它将使用 DNS 获取容器 IP 地址,但对于 image-gallery,它只期望找到一个容器,所以如果你扩展该组件,你会得到意外的行为。如果 DNS 响应包含多个 IP 地址,Prometheus 总是使用列表中的第一个 IP 地址,所以当 Docker 负载均衡请求到指标端点时,你会从不同的容器中获取指标。accesslog 组件配置为支持多个 IP 地址,所以 Prometheus 将构建一个包含所有容器 IP 地址的列表,并按照相同的计划轮询它们。图 9.8 展示了抓取过程是如何运行的。

图 9.8 在容器中运行的 Prometheus,配置为抓取应用程序容器的指标
我为图像库应用程序构建了一个定制的 Prometheus Docker 镜像。它基于 Prometheus 团队在 Docker Hub 上发布的官方镜像,并复制了我的配置文件(你可以在本章源代码中找到 Dockerfile)。这种方法为我提供了一个预配置的 Prometheus 镜像,我可以无需任何额外配置即可运行,但如果需要,我总是可以在其他环境中覆盖配置文件。
当运行大量容器时,指标更有趣。我们可以扩展图像库应用程序的 Node.js 组件,使其在多个容器上运行,Prometheus 将抓取并收集所有容器的指标。
现在试试吧。章节的练习文件夹中还有一个 Docker Compose 文件,它为 access-log 服务发布了一个随机端口,这样该服务就可以进行扩展运行。用三个实例运行它并向网站发送更多负载:
docker-compose -f docker-compose-scale.yml up -d --scale accesslog=3
每当网站处理一个请求时,都会调用 access-log 服务--运行该服务的有三个容器,因此调用应该在这所有容器之间进行负载均衡。我们如何检查负载均衡是否有效?该组件的指标包括一个标签,用于捕获发送指标的机器的主机名--在这种情况下是 Docker 容器 ID。打开 Prometheus UI 并检查 access-log 指标。你应该看到三组数据。
现在试试吧。浏览到 http://localhost:9090/graph。在度量下拉菜单中选择 access_log_total 并点击执行。
你会看到与我图 9.9 中类似的结果--每个容器都有一个度量值,标签包含主机名。每个容器的实际值将显示负载均衡的均匀程度。在理想情况下,这些数值应该是相等的,但由于存在许多网络因素(如 DNS 缓存和 HTTP 保持连接),这意味着如果你在单机上运行,你可能看不到这种情况。

图 9.9 使用处理指标可以用来验证请求是否正在负载均衡。
在 Prometheus 中使用标签记录额外信息是其最强大的功能之一。它允许你在不同粒度级别上使用单个度量。目前你看到的是指标的原始数据,表格中每行显示一个容器最近一次的度量值。你可以使用 sum() 查询跨所有容器进行聚合,忽略单个标签并显示总合,你还可以在图表中显示,以查看随时间增加的使用情况。
现在试试吧。在 Prometheus UI 中,点击添加图表按钮以添加一个新的查询。在表达式文本框中粘贴以下查询:
sum(access_log_total) without(hostname, instance)
点击执行,你将看到一个时间序列的折线图,这是 Prometheus 表示数据的方式--一组带有时间戳记录的度量。
在我添加新的图表之前,我向本地应用程序发送了一些更多的 HTTP 请求--你可以在图 9.10 中看到我的输出。

图 9.10 聚合度量,从所有容器中汇总值并显示结果图表
sum() 查询是用 Prometheus 自有的查询语言 PromQL 编写的。它是一种功能强大的语言,包含统计函数,允许你查询随时间的变化和变化率,并且你可以添加子查询来关联不同的指标。但是,你不需要深入到任何这种复杂性中就能构建有用的仪表板。Prometheus 的格式结构非常良好,你可以通过简单的查询来可视化关键指标。你可以使用标签来过滤值,并汇总结果以进行聚合,仅这些功能就能为你提供一个有用的仪表板。

图 9.11 一个简单的 Prometheus 查询。你不需要学习比这更多的 PromQL。
图 9.11 展示了一个典型的查询,它将被用于仪表板。这个查询聚合了所有 image_gallery_request 指标的值,过滤出响应代码为 200 的情况,并且没有使用 instance 标签进行汇总,因此我们将从所有容器中获取指标。结果将是所有运行图像库网络应用程序的容器发送的 200 个“OK”响应的总数。
Prometheus UI 适用于检查你的配置,验证所有抓取目标是否可访问,以及制定查询。但它并不是一个仪表板——这正是 Grafana 的作用所在。
9.4 运行 Grafana 容器以可视化指标
我们在本章中涵盖了大量的内容,因为监控是容器的一个核心主题,但我们进展很快,因为更详细的内容都是非常依赖于应用程序的。你需要捕获哪些指标将取决于你的业务和运营需求,而你如何捕获它们将取决于你使用的应用程序运行时以及该运行时的 Prometheus 客户端库的机制。
一旦你的数据存储在 Prometheus 中,事情就会变得简单起来——它成为所有应用程序的一个相当标准的做法。你将使用 Prometheus UI 来导航你正在记录的指标,并针对你想要查看的数据进行查询。然后你将运行 Grafana 并将这些查询连接到仪表板。每个数据点都以用户友好的可视化形式出现,整个仪表板展示了你的应用程序正在发生的事情。
我们在本章中一直在为图像库应用程序构建 Grafana 仪表板,图 9.12 展示了最终结果。这是一种非常整洁的方式来展示所有应用程序组件和 Docker 运行时的核心信息。这些查询也构建来支持扩展,因此相同的仪表板可以在生产集群中使用。

图 9.12 应用程序的 Grafana 仪表板。看起来很复杂,但实际上构建起来相当简单。
Grafana 仪表板在应用的不同层级传达关键信息。它看起来很复杂,但每个可视化都由一个单一的 PromQL 查询驱动,而且没有任何查询比过滤和聚合更复杂。图 9.12 中的缩小视图并没有给出完整的画面,但我已经将仪表板打包成一个自定义的 Grafana 镜像,这样你就可以在容器中运行它并探索。
现在试试吧 你需要再次捕获你的计算机的 IP 地址,这次作为一个环境变量,Compose 文件会查找并注入到 Prometheus 容器中。然后使用 Docker Compose 运行应用程序并生成一些负载:
# 将你的机器的 IP 地址加载到环境变量中 - 在 Windows 上: $env:HOST_IP = $(Get-NetIPConfiguration | Where-Object {$_.IPv4DefaultGateway -ne $null }).IPv4Address.IPAddress # 在 Linux 上: export HOST_IP=$(ip route get 1 | awk '{print $NF;exit}') # 使用包含 Grafana 的 Compose 文件运行应用程序: docker-compose -f ./docker-compose-with-grafana.yml up -d --scale accesslog=3 # 现在发送一些负载以初始化指标 - 在 Windows 上: for ($i=1; $i -le 20; $i++) { iwr -useb http://localhost:8010 | Out-Null } # 或在 Linux 上: for i in {1..20}; do curl http://localhost:8010 > /dev/null; done # 然后浏览到 http://localhost:3000
Grafana 使用端口 3000 用于网页 UI。当你首次浏览时,你需要登录——凭据是用户名admin,密码admin。你将在首次登录时被要求更改管理员密码,但如果点击跳过,我不会评判你。当 UI 加载时,你将进入你的“主页”仪表板——点击左上角的“主页”链接,你将看到图 9.13 中的仪表板列表。点击 Image Gallery 以加载应用程序仪表板。

图 9.13 在 Grafana 中导航仪表板——最近使用的文件夹显示在此处
我的仪表板是一个合理的生产系统设置。你需要一些关键数据点,以确保你正在监控正确的事情——谷歌在《站点可靠性工程》一书中讨论了这一点(mng.bz/EdZj)。他们的重点是延迟、流量、错误和饱和度,他们称之为“黄金信号”。
我将详细说明我的第一组可视化,以便你可以看到一个智能仪表板可以由基本的查询和正确的可视化选择构建而成。图 9.14 显示了 Image Gallery 网页 UI 的指标行——我已经将其分割以便更容易查看,但这些在仪表板上显示在同一行。
这里显示了四个指标,展示了系统被使用的程度以及系统为了支持这种使用水平所付出的努力:
-
HTTP 200 响应 -- 这是对网站随时间发送的 HTTP “OK” 响应数量的简单统计。PromQL 查询是对应用程序计数器指标的求和:
sum(image_gallery_requests_total{code="200"})without(instance)。我可以添加一个类似的图表,通过查询过滤code="500"来显示错误数量。 -
在途请求 -- 这显示了在任何给定时间点的活动请求数量。它是一个 Prometheus 仪表,因此它可以上升或下降。对此没有过滤条件,图表将显示所有容器中的总数,因此查询是另一个求和:
sum(image_gallery_in_flight_requests)without(instance)。 -
内存使用情况 -- 这显示了图像库容器使用的系统内存量。它是一个条形图,对于此类数据来说更容易观察;当我扩展网络组件时,它将为每个容器显示一个条形。PromQL 查询根据作业名称进行过滤:
go_memstats_stack_inuse_bytes{job="image-gallery"}。我需要过滤条件,因为这是一个标准的 Go 指标,并且 Docker 引擎作业返回了一个具有相同名称的指标。 -
活跃 Goroutines -- 这是一个粗略的指标,表明组件工作有多努力--Goroutine 是 Go 中的一个工作单元,并且可以并发运行多个。此图表将显示网络组件是否突然出现处理活动的峰值。这是另一个标准的 Go 指标,因此 PromQL 查询从网络作业中过滤统计信息并将它们求和:
sum(go_goroutines{job=\"image-gallery\"})without(instance)。
仪表板其他行的可视化都使用类似的查询。不需要复杂的 PromQL--选择正确的指标来显示以及正确的可视化来展示它们才是您真正需要的。
在这些可视化中,实际值不如趋势有用。我的网络应用平均使用 200 MB 或 800 MB 的内存并不重要--重要的是当出现突然的峰值偏离正常情况时。组件的指标集应帮助您快速看到异常并找到相关性。如果错误响应的图表呈上升趋势,并且活跃 Goroutines 的数量每几秒翻倍,那么很明显有问题--组件可能已饱和,因此您可能需要通过添加更多容器来扩展以处理负载。

图 9.14 仔细查看应用程序仪表板以及可视化如何与黄金信号相关
Grafana 是一个极其强大的工具,但使用起来非常简单。它是现代应用程序最受欢迎的仪表板系统,因此值得学习--它可以查询许多不同的数据源,并且还可以将警报发送到不同的系统。构建仪表板与编辑现有仪表板相同--你可以添加或编辑可视化(称为面板),调整大小并移动它们,然后将你的仪表板保存到文件中。
现在尝试一下 Google SRE 方法认为 HTTP 错误计数是一个核心指标,而这个指标在仪表板中缺失,所以我们现在将把它添加到图片库行中。如果你还没有运行整个图片库应用程序,请重新运行它,浏览到 Grafana 的 http://localhost:3000,并使用用户名admin和密码admin登录。
打开图片库仪表板,点击屏幕右上角的添加面板图标--它就是图 9.15 中显示的带有加号的柱状图。

图 9.15 用于添加面板、选择时间段和保存仪表板的 Grafana 工具栏
现在在新面板窗口中点击添加查询,你将看到一个屏幕,你可以捕获可视化的所有细节。选择 Prometheus 作为查询的数据源,并在度量字段粘贴以下 PromQL 表达式:
sum(image_gallery_requests_total{code="500"}) without(instance)
你的面板应该看起来像我图 9.16 中的那样。图片库应用程序大约有 10%的时间会返回错误响应,所以如果你请求足够多,你会在你的图表中看到一些错误。
按下 Esc 键返回主仪表板。
你可以通过拖动底右角来调整面板大小,通过拖动标题来移动它们。当你把仪表板调整到你想要的样子时,你可以从工具面板中点击分享仪表板图标(再次查看图 9.15),在那里你可以选择将仪表板导出为 JSON 文件。

图 9.16 向 Grafana 仪表板添加新面板以显示 HTTP 错误
使用 Grafana 的最终步骤是打包自己的 Docker 镜像,该镜像已经配置了 Prometheus 作为数据源以及应用程序仪表板。我已经为diamol/ch09-grafana镜像完成了这项工作。列表 9.3 显示了完整的 Dockerfile。
列表 9.3 打包自定义 Grafana 镜像的 Dockerfile
FROM diamol/grafana:6.4.3
该镜像从一个特定的 Grafana 版本开始,然后只是复制一组 YAML 和 JSON 文件。Grafana 遵循我在本书中已经推广的配置模式--内置了一些默认配置,但你可以应用自己的配置。当容器启动时,Grafana 会在特定的文件夹中查找文件,并应用它找到的任何配置文件。YAML 文件设置 Prometheus 连接并加载/var/lib/Grafana/dashboards文件夹中的任何仪表板。最后一行将我的仪表板 JSON 复制到该文件夹,因此容器启动时会加载它。
你可以用 Grafana 配置实现更多功能,你也可以使用 API 来创建用户并设置他们的偏好。构建一个包含多个仪表板和具有访问所有这些仪表板权限的只读用户 Grafana 镜像并不需要做太多工作,这些仪表板可以组合成一个 Grafana 播放列表。然后你可以在办公室的大屏幕上浏览 Grafana,并自动循环显示所有仪表板。
9.5 理解可观察性的级别
当你从简单的概念验证容器转移到准备生产时,可观察性是一个关键要求。但我在本章引入 Prometheus 和 Grafana 的另一个非常好的原因是:学习 Docker 不仅仅是关于 Dockerfile 和 Docker Compose 文件的机制。Docker 的魔力部分是围绕容器成长起来的巨大生态系统,以及围绕该生态系统出现的模式。
当容器最初变得流行时,监控确实是个头疼的问题。我当时的生产发布与今天一样容易构建和部署,但我在应用程序运行时没有洞察力。我必须依赖外部服务如 Pingdom 来检查我的 API 是否仍然可用,并依赖用户报告来确保应用程序运行正确。今天对容器进行监控的方法是一条经过验证和值得信赖的途径。我们在本章中遵循了这条途径,图 9.17 总结了这种方法。

图 9.17 容器化应用程序的监控架构--Prometheus 位于中心。
我已经为图像库应用程序走过了单个仪表板,这是一个应用程序的整体视图。在生产环境中,你会有额外的仪表板,它们会深入到更详细的级别。会有一个基础设施仪表板显示所有服务器的可用磁盘空间、可用 CPU、内存和网络饱和度。每个组件可能都有自己的仪表板,显示额外的信息,例如,为 Web 应用程序的每一页或每个 API 端点提供服务的响应时间分解。
摘要仪表板是关键。你应该能够将应用程序指标中的所有最重要的数据点汇总到一个屏幕上,这样你就可以一眼看出是否有问题,并在问题恶化之前采取规避措施。
9.6 实验室
本章向图像库应用程序添加了监控功能,这个实验室要求你对待办事项列表应用程序做同样的操作。你不需要深入研究源代码--我已经构建了一个包含 Prometheus 指标的新版本的应用程序镜像。从diamol/ch09-todo-list运行一个容器,浏览到应用程序,并添加一些项目,你将看到在/metrics URL 上可用的指标。对于实验室,你希望将那个应用程序带到与图像库相同的位置:
-
编写一个 Docker Compose 文件,你可以使用它来运行应用程序,它还会启动一个 Prometheus 容器和一个 Grafana 容器。
-
Prometheus 容器应该已经配置好了从待办事项应用中抓取指标。
-
Grafana 容器应该配置一个仪表板来显示应用中的三个关键指标:创建的任务数量、处理的总 HTTP 请求数量,以及当前正在处理的 HTTP 请求数量。
这听起来像是一大堆工作,但实际上并不是——本章的练习涵盖了所有细节。这是一个很好的实验室练习,因为它将让你获得与新的应用程序指标一起工作的经验。
和往常一样,你可以在 GitHub 上找到我的解决方案,以及我最终仪表板的图形:github.com/sixeyed/diamol/blob/master/ch09/lab/README.md。
10 使用 Docker Compose 运行多个环境
我们在第七章中探讨了 Docker Compose,并且你对如何使用 YAML 描述多容器应用程序以及如何使用 Compose 命令行来管理它有了很好的理解。从那时起,我们已经增强了我们的 Docker 应用程序,通过健康检查和监控使它们为生产做好准备。现在,是时候回到 Compose 了,因为我们不需要在每个环境中都使用所有那些生产功能。可移植性是 Docker 的主要优势之一。当你将应用程序打包到容器中运行时,它在任何部署的地方都以相同的方式工作,这是很重要的,因为它消除了环境之间的 Drift。
Drift 是在手动过程用于部署软件时总会发生的事情。一些更新被遗漏了,或者一些新的依赖项被忘记了,因此生产环境与用户测试环境不同,而系统测试环境又与用户测试环境不同。当部署失败时,通常是因为 Drift,追踪缺失的部分并纠正它们需要花费大量的时间和精力。转向 Docker 解决了这个问题,因为每个应用程序都已经打包了其依赖项,但你仍然需要灵活性来支持不同环境的不同行为。Docker Compose 通过本章将要介绍的高级功能提供了这种灵活性。
10.1 使用 Docker Compose 部署多个应用程序
Docker Compose 是一个在单个 Docker 引擎上运行多容器应用程序的工具。它非常适合开发者,并且在非生产环境中也被广泛使用。组织通常在不同的环境中运行应用程序的多个版本——比如版本 1.5 在生产环境中运行,版本 1.5.1 在热修复环境中进行测试,版本 1.6 正在完成用户测试,而版本 1.7 正在进行系统测试。这些非生产环境不需要生产环境的规模和性能,因此 Docker Compose 运行这些环境并从硬件中获得最大利用率是一个很好的用例。
为了使其工作,环境之间需要有一些差异。不能有多个容器试图监听端口 80 的流量,或者向服务器上的同一文件写入数据。你可以设计你的 Docker Compose 文件来支持这一点,但首先你需要了解 Compose 如何识别哪些 Docker 资源属于同一应用程序的一部分。它是通过命名约定和标签来做到这一点的,如果你想要运行相同应用程序的多个副本,你需要绕过默认设置。
现在尝试一下 打开终端并浏览到本章的练习。运行我们已经使用过的两个应用程序,然后尝试运行待办事项应用程序的另一个实例:
cd ./ch10/exercises # 运行第八章中的随机数应用: docker-compose -f ./numbers/docker-compose.yml up -d # 运行第六章中的待办事项列表应用: docker-compose -f ./todo-list/docker-compose.yml up -d # 并尝试另一个待办事项列表的副本: docker-compose -f ./todo-list/docker-compose.yml up -d
你的输出将与我的图 10.1 中的输出相同。你可以从不同文件夹中的 Compose 文件启动多个应用,但你不能通过从同一文件夹运行up来启动应用的第二个实例。Docker Compose 认为你要求它运行一个已经运行的应用,因此它不会启动任何新的容器。

图 10.1 重复执行 Docker Compose 命令以启动应用不会运行应用的第二个副本。
Docker Compose 使用项目概念来识别各种资源是同一应用的一部分,并且它使用包含 Compose 文件的目录名称作为默认项目名称。当创建资源时,Compose 会在项目名称前加前缀,对于容器,它还会添加一个数字计数器作为后缀。所以如果你的 Compose 文件在一个名为app1的文件夹中,并且定义了一个名为web的服务和一个名为disk的卷,Compose 将通过创建一个名为app1_disk的卷和一个名为app1_web_1的容器来部署它。容器名称末尾的计数器支持扩展,所以如果你将 web 服务的实例扩展到两个,新的容器将被命名为app1_web_2。图 10.2 显示了待办事项应用容器名称的构建方式。

图 10.2 Docker Compose 通过包含项目名称为它管理的资源构建名称。
你可以覆盖 Docker Compose 使用的默认项目名称,这就是你如何在单个 Docker Engine 上以不同的容器集运行同一应用多个副本的方法。
现在尝试一下 你已经有一个待办事项应用的实例正在运行;你可以通过指定不同的项目名称来启动另一个。网站使用随机端口,所以如果你想实际尝试这些应用,你需要找到分配的端口:
docker-compose -f ./todo-list/docker-compose.yml -p todo-test up -d docker container ls docker container port todo-test_todo-web_1 80
我的输出在图 10.3 中。指定项目名称意味着在 Compose 看来这是一个不同的应用,没有资源与这个项目名称匹配,所以 Compose 创建了一个新的容器。命名模式是可预测的,所以我知道新的容器将被命名为todo-test_todo-web_1。Docker CLI 有container port命令来查找容器的已发布端口,我可以使用生成的容器名称来查找应用端口。

图 10.3 指定项目名称允许你使用一个 Compose 文件运行同一应用的多个副本。
这种方法允许你运行许多不同应用程序的多个副本。我也可以使用相同的 Compose 文件部署我的随机数应用程序的另一个实例,只需指定不同的项目名称即可。这很有用,但对于大多数情况,你可能需要更多的控制——对于运维或测试团队来说,找出每个版本要使用的随机端口并不是一个很好的工作流程。为了支持不同环境中的不同设置,你可以创建重复的 Compose 文件并编辑需要更改的属性,但 Compose 提供了一种更好的方法来管理覆盖。
10.2 使用 Docker Compose 覆盖文件
团队面临在 Docker Compose 中尝试运行不同的应用程序配置的问题,通常最终会有许多 Compose 文件——每个环境一个。这可以工作,但不可维护,因为这些 Compose 文件通常是 90% 的重复内容,这意味着它们会失去同步,你又会回到漂移状态。覆盖文件是一种更整洁的方法。Docker Compose 允许你合并多个文件,其中较后文件中的属性会覆盖合并中较早的属性。
图 10.4 展示了如何使用覆盖来构建一组易于维护的 Compose 文件。你从一个包含应用基本结构的核心 docker-compose.yml 文件开始,其中定义并配置了适用于所有环境的通用服务属性。然后每个环境都有自己的覆盖文件,它添加了特定的设置,但不会复制核心文件中的任何配置。

图 10.4 使用添加环境特定设置的覆盖文件消除重复
这种方法是可维护的。如果你需要做出适用于所有环境的更改——比如更改镜像标签以使用最新版本——你只需在核心文件中做一次更改,它就会过滤到每个环境。如果你只需要更改一个环境,你只需更改那个单一文件。每个环境拥有的覆盖文件也充当了环境之间差异的清晰文档。
列表 10.1 展示了一个非常简单的示例,其中核心 Compose 文件指定了大多数应用程序属性,而覆盖则更改了镜像标签,因此这次部署将使用待办事项应用程序的 v2 版本。
列表 10.1 一个更新单个属性的 Docker Compose 覆盖文件
# 来自 docker-compose.yml - 核心应用程序规范: services: todo-web: image: diamol/ch06-todo-list ports: - 80 environment: - Database:Provider=Sqlite networks: - app-net # 来自 docker-compose-v2.yml - 版本覆盖文件: services: todo-web: image: diamol/ch06-todo-list:v2
在覆盖文件中,你只需指定你关心的属性,但需要保留主 Compose 文件的结构,以便 Docker Compose 可以将定义链接在一起。本例中的覆盖文件仅更改image属性的值,但需要在services块下的todo-web块中指定,以便 Compose 可以将其与核心文件中的完整服务定义相匹配。
当你在docker-compose命令中指定多个文件路径时,Docker Compose 会合并文件。config命令在这里非常有用--它验证输入文件的内容,如果输入有效,则输出最终结果。你可以使用它来查看应用覆盖文件时会发生什么。
现在试试看 在本章的练习文件夹中,使用 Docker Compose 合并列表 10.1 中的文件并打印输出:
docker-compose -f ./todo-list/docker-compose.yml -f ./todo-list/docker-compose-v2.yml config
config 命令实际上并不部署应用程序;它只是验证配置。你会在输出中看到两个文件已经被合并。所有属性都来自核心 Docker Compose 文件,除了image标签,其值已被第二个文件覆盖--你可以在图 10.5 中看到这一点。

图 10.5 合并 Compose 文件与覆盖文件并显示输出
Docker Compose 按照命令中文件列表的顺序应用覆盖,右侧的文件覆盖左侧的文件。这很重要,因为如果你顺序错误,你会得到意外的结果--config命令在这里很有用,因为它显示了完整的 Compose 文件的预览。输出按字母顺序排序,所以你会看到网络、然后是服务、然后是 Compose 版本号,一开始可能会感到不安,但很有用。你可以将此命令作为部署过程的一部分进行自动化,并将合并后的文件提交到源代码控制中--这样按字母顺序比较版本就变得容易了。
使用覆盖来更改图像标签只是一个快速示例。在numbers文件夹中有一个更现实的随机数应用程序的 Compose 文件集:
-
docker-compose.yml-- 核心应用程序定义。它指定了 Web 和 API 服务,但没有指定任何端口或网络定义。 -
docker-compose-dev.yml-- 用于在开发中运行应用程序。它指定了一个 Docker 网络,并为服务添加了发布端口,并禁用了健康和依赖性检查。这样开发者可以快速启动和运行。 -
docker-compose-test.yml-- 用于在测试环境中运行。它指定了一个网络,添加了健康检查参数,并为 Web 应用程序发布了一个端口,但通过不发布任何端口,保持了 API 服务的内部性。 -
docker-compose-uat.yml-- 用于用户验收测试环境。它指定了一个网络,发布标准端口 80 用于网站,设置服务始终重启,并指定更严格的健康检查参数。
列表 10.2 显示了开发覆盖文件的内容——很明显,它不是一个完整的应用程序规范,因为没有指定镜像。这里的值将被合并到核心 Compose 文件中,如果核心文件中有匹配的键,则会添加新的属性或覆盖现有的属性。
列表 10.2 一个覆盖文件仅指定了与主 Compose 文件的不同之处
services: numbers-api: ports: - "8087:80" healthcheck: disable: true numbers-web: entrypoint: - dotnet - Numbers.Web.dll ports: - "8088:80" networks: app-net: name: numbers-dev
其他覆盖文件遵循相同的模式。每个环境使用不同的端口来运行 Web 应用和 API,因此可以在单个机器上运行所有这些应用。
现在尝试一下:首先删除所有现有的容器,然后在多个环境中运行随机数应用。每个环境需要一个项目名称和正确的 Compose 文件集:
# 删除任何现有容器 docker container rm -f $(docker container ls -aq) # 在开发配置下运行应用: docker-compose -f ./numbers/docker-compose.yml -f ./numbers/docker-compose-dev.yml -p numbers-dev up -d # 以及测试设置: docker-compose -f ./numbers/docker-compose.yml -f ./numbers/docker-compose-test.yml -p numbers-test up -d # 以及 UAT: docker-compose -f ./numbers/docker-compose.yml -f ./numbers/docker-compose-uat.yml -p numbers-uat up -d
现在,你有三个应用程序副本正在运行,它们彼此之间都是隔离的,因为每个部署都在使用自己的 Docker 网络。在一个组织中,这些应用将运行在一台服务器上,团队可以通过浏览到正确的端口来使用他们想要的任何环境。例如,你可以使用端口 80 进行 UAT,端口 8080 进行系统测试,端口 8088 用于开发团队的集成环境。图 10.6 显示了我的输出,其中包含了创建的网络和容器。

图 10.6 在单个机器上运行多个隔离的应用程序环境
现在你有三个部署,它们作为独立的环境运行:http: / / localhost 是 UAT,http: / / localhost:8080 是系统测试,而http://localhost:8088是开发环境。浏览到任何一个,你都会看到相同的应用,但每个 Web 容器只能看到其网络中的 API 容器。这保持了应用的分离,所以如果你在开发环境中持续获取随机数字,API 将会崩溃,但系统测试和 UAT 环境仍然在运行。每个环境中的容器使用 DNS 名称进行通信,但 Docker 限制了容器网络内的流量。图 10.7 展示了网络隔离如何保持所有环境分离。

图 10.7 在一个 Docker Engine 上运行多个环境,使用网络进行隔离
现在是时候提醒你了,Docker Compose 是一个客户端工具,你需要访问所有你的 Compose 文件来管理你的应用。你还需要记住你使用的项目名称。如果你想清除测试环境,移除容器和网络,你通常会只运行docker-compose down,但这对这些环境不起作用,因为 Compose 需要你在up命令中使用的所有相同文件和项目信息来匹配资源。
现在尝试一下 让我们移除那个测试环境。你可以尝试不同的down命令变体,但唯一能工作的是具有与原始up命令相同文件列表和项目名称的那个:
# 如果我们使用了默认的 docker-compose.yml 文件,这将有效: docker-compose down # 如果我们使用了没有项目名称的覆盖文件,这将有效: docker-compose -f ./numbers/docker-compose.yml -f ./numbers/docker-compose-test.yml down # 但我们指定了项目名称,所以我们也需要包含它: docker-compose -f ./numbers/docker-compose.yml -f ./numbers/docker-compose-test.yml -p numbers-test down
你可以在图 10.8 中看到我的输出。你可能已经猜到,除非你提供匹配的文件和项目名称,否则 Compose 无法识别应用的运行资源,所以在第一个命令中它不会删除任何内容。在第二个命令中,Compose 确实尝试删除连接到该网络的容器网络。

图 10.8 你需要使用相同的文件和项目名称来使用 Compose 管理应用。
那些错误发生是因为在 Compose 覆盖文件中明确命名了网络。我在第二个 down 命令中没有指定项目名称,所以它使用了默认值,即文件夹名称 numbers。Compose 寻找名为 numbers_numbers-web_1 和 numbers_numbers-api_1 的容器,但找不到它们,因为它们实际上是使用项目前缀 numbers-test 创建的。Compose 认为那些容器已经不存在了,它只需要清理网络,而它确实找到了,因为 Compose 文件中的显式网络名称没有使用项目前缀。Compose 尝试删除那个网络,但幸运的是,Docker 不允许你删除仍有容器附加的网络。
这是一种长篇大论的方式来告诉你,你需要小心使用 Docker Compose。它是一个非生产环境中的优秀工具,通过在单台机器上部署成百上千的应用程序,使你从计算资源中获得最大价值。覆盖文件让你可以重用应用程序定义并识别不同环境之间的差异,但你需要注意管理开销。你应该考虑为你的部署和拆卸编写脚本和自动化。
10.3 使用环境变量和密钥注入配置
你可以使用 Docker 网络隔离应用程序,并使用 Compose 覆盖来捕捉环境之间的差异,但你还需要在不同环境之间更改应用程序配置。大多数应用程序可以从环境变量或文件中读取配置设置,而 Compose 对这两种方法都有良好的支持。
我将在本节中涵盖所有选项,所以我们将比之前更深入地研究 Compose。这将帮助你了解你可以选择的配置设置应用选项,你将能够选择适合你的选项。
这些练习又回到了待办事项应用。该应用的 Docker 镜像被构建为读取环境变量和配置设置所需的文件。有三个项目需要在不同环境之间进行变化:
-
记录--日志详细程度应该有多详细。这将在开发环境中非常详细,在测试和生产环境中则不那么详细。
-
数据库提供者--是否在应用程序容器内部使用简单的数据文件,或者使用单独的数据库(该数据库可能或可能不在容器中运行)。
-
数据库连接字符串--如果应用程序不使用本地数据文件,连接到数据库的详细信息。
我使用覆盖文件为不同的环境注入配置,并且为每个项目使用不同的方法,这样我可以向你展示 Docker Compose 提供的选项。列表 10.3 展示了核心的 Compose 文件;这只是一个设置了作为密钥的配置文件的 Web 应用程序的基本信息。
列表 10.3 Compose 文件指定了带有密钥的 Web 服务
services: todo-web: image: diamol/ch06-todo-list secrets: - source: todo-db-connection target: /app/config/secrets.json
密钥是一种有用的注入配置的方式——它们在 Docker Compose、Docker Swarm 和 Kubernetes 中都有支持。在 Compose 文件中,你指定密钥的源和目标。源是从容器运行时加载密钥的地方,而目标是密钥在容器内部暴露的文件路径。
此密钥指定为来自源 todo-db-connection,这意味着在 Compose 文件中需要定义具有该名称的密钥。密钥的内容将被加载到目标路径 /app/config/secrets.json,这是应用程序搜索配置设置的位置之一。
上述 Compose 文件本身是无效的,因为没有密钥部分,而 todo-db-connection 密钥在服务定义中是必需的。列表 10.4 展示了开发覆盖文件,它为服务设置了一些更多的配置并指定了密钥。
列表 10.4 开发覆盖文件添加了配置设置和密钥设置
services: todo-web: ports: - 8089:80 environment: - Database:Provider=Sqlite env_file: - ./config/logging.debug.env secrets: todo-db-connection: file: ./config/empty.json
在此覆盖文件中有三个属性,用于注入应用程序配置并改变容器中应用程序的行为。你可以使用它们的任何组合,但每种方法都有其优点:
-
environment在容器内添加一个环境变量。此设置配置应用程序使用 SQLite 数据库,这是一个简单的数据文件。这是设置配置值的最简单方式,从 Compose 文件中可以清楚地看到正在配置的内容。 -
env_file包含一个文本文件的路径,文本文件的内容将被加载到容器中作为环境变量。文本文件中的每一行都被读取为一个环境变量,名称和值由等号分隔。此文件的内容设置了日志配置。使用环境变量文件是共享多个组件设置的一种简单方式,因为每个组件都引用该文件而不是复制环境变量列表。 -
secrets是 Compose YAML 文件中的顶级资源,类似于services和networks。它包含todo-db-connection的实际源,这是一个本地文件系统上的文件。在这种情况下,应用程序没有单独的数据库要连接,因此它使用一个空的 JSON 文件作为密钥。应用程序将读取该文件,但没有配置设置要应用。
现在试试看。你可以使用 Compose 文件和 todo-list-configured 目录中的覆盖配置来以开发配置运行应用。使用 curl 向 Web 应用发送请求并检查容器是否正在记录大量详细信息:
# 移除现有容器: docker container rm -f $(docker container ls -aq) # 使用配置覆盖启动应用 - 对于 Linux 容器: docker-compose -f ./todo-list-configured/docker-compose.yml -f ./todo-list-configured/docker-compose-dev.yml -p todo-dev up -d # 或者对于 Windows 容器,使用不同的文件路径存储密钥: docker-compose -f ./todo-list-configured/docker-compose.yml -f ./todo-list-configured/docker-compose-dev.yml -f ./todo-list-configured/docker-compose-dev-windows.yml -p todo-dev up -d # 向应用发送一些流量: curl http://localhost:8089/list # 检查日志: docker container logs --tail 4 todo-dev_todo-web_1
你可以在图 10.9 中看到我的输出。Docker Compose 总是为每个应用程序使用一个网络,因此它会创建一个默认网络并将容器连接到它,即使 Compose 文件中没有指定网络。在我的情况下,最新的日志行显示了应用程序使用的 SQL 数据库命令。你的输出可能不同,但如果你检查整个日志,你应该会看到 SQL 语句。这表明增强了日志配置。
开发者部署使用环境变量和密钥进行应用配置--在 Compose 文件和配置文件中指定的值被加载到容器中。
此外,还有一个使用另一种由 Compose 支持的方法的测试部署:使用主机机器上的环境变量为容器提供值。这使得部署更加便携,因为你可以更改环境而无需更改 Compose 文件本身。如果你想在配置不同的服务器上启动第二个测试环境,这将非常有用。列表 10.5 展示了 todo-web 服务的规范。
列表 10.5 在 Compose 文件中使用环境变量作为值
todo-web: ports: - "${TODO_WEB_PORT}:80" environment: - Database:Provider=Postgres env_file: - ./config/logging.information.env networks: - app-net
端口的美元和花括号设置会被替换为具有该名称的环境变量。所以如果我在运行 Docker Compose 的机器上设置了一个名为 TODO_WEB_PORT 的变量,其值为 8877,那么 Compose 会注入这个值,端口规范实际上变为 "8877:80"。这个服务规范在文件 docker-compose-test.yml 中,该文件还包括一个数据库服务和一个用于连接数据库容器的密钥。

图 10.9 通过在 Docker Compose 中应用配置设置来更改应用程序行为
你可以通过与开发环境相同的方式指定 Compose 文件和项目名称来运行测试环境,但 Compose 有一个使事情更简单的最终配置功能。如果 Compose 在当前文件夹中找到一个名为.env的文件,它将将其视为环境文件,并在运行命令之前读取其内容作为一组环境变量,填充它们。
现在尝试一下 导航到配置好的待办事项应用程序的目录,并运行它,无需向 Docker Compose 指定任何参数:
cd ./todo-list-configured # 或者对于 Windows 容器 - 使用不同的文件路径: cd ./todo-list-configured-windows docker-compose up -d
图 10.10 显示,Compose 已创建了 Web 和数据库容器,尽管核心 Compose 文件没有指定数据库服务。它还使用了项目名称todo_ch10,尽管我没有指定名称。.env文件设置 Compose 配置以默认运行测试环境,无需你指定测试覆盖文件。

图 10.10 使用环境文件指定 Docker Compose 文件和项目名称的默认值
在这里,你可以使用一个简单的命令而不指定文件名,因为.env文件包含了一组环境变量,这些变量可以用来配置 Docker Compose。第一次使用是用于容器配置设置,例如 Web 应用的端口;第二次是用于 Compose 命令本身,列出要使用的文件和项目名称。列表 10.6 显示了完整的.env文件。
列表 10.6 使用环境文件配置容器和 Compose
# 容器配置 - 发布的端口: TODO_WEB_PORT=8877 TODO_DB_PORT=5432 # Compose 配置 - 文件和项目名称: COMPOSE_PATH_SEPARATOR=; COMPOSE_FILE=docker-compose.yml;docker-compose-test.yml COMPOSE_PROJECT_NAME=todo_ch10
环境文件捕获了测试配置中应用程序的默认 Compose 设置--你可以轻松地修改它,以便开发配置成为默认设置。将环境文件与你的 Compose 文件一起保存有助于记录哪些文件集代表哪个环境,但请注意,Docker Compose 只寻找名为.env的文件。你不能指定文件名,因此你不能轻松地在多个环境文件之间切换。
探索 Docker Compose 的配置选项花了一些时间。在你的 Docker 使用过程中,你将处理很多 Compose 文件,所以你需要熟悉所有选项。我将在这里总结它们,但其中一些比其他更有用:
-
使用
environment属性来指定环境变量是最简单的选项,它使得从 Compose 文件中读取应用程序配置变得容易。然而,这些设置是纯文本,因此你不应该将它们用于敏感数据,如连接字符串或 API 密钥。 -
使用具有
secret属性的配置文件进行加载是最灵活的选项,因为它被所有容器运行时支持,并且可以用于敏感数据。当使用 Compose 时,秘密的来源可以是本地文件,也可以是存储在 Docker Swarm 或 Kubernetes 集群中的加密秘密。无论来源如何,秘密的内容都会被加载到应用程序容器的文件中。 -
当服务之间有很多共享设置时,使用
environment_file属性将设置存储在文件中并将它们加载到容器中非常有用。Compose 会本地读取该文件,并将单个值设置为环境属性,因此当你连接到远程 Docker Engine 时,可以使用本地环境文件。 -
Compose 环境文件
.env对于捕获你想要作为默认部署目标的任何环境的设置非常有用。
10.4 使用扩展字段减少重复
到目前为止,你可能认为 Docker Compose 已经有足够的配置选项来满足任何情况。但实际上,它是一个非常简单的规范,并且在使用过程中你会遇到一些限制。其中最常见的问题之一是如何在服务共享大量相同设置时减少 Compose 文件的冗余。在本节中,我将介绍 Docker Compose 的一个最终特性,它可以解决这个问题——使用扩展字段在单个位置定义 YAML 块,你可以在整个 Compose 文件中重用这些块。扩展字段是 Compose 中一个强大但使用较少的功能。它们消除了许多重复和潜在的错误,一旦你习惯了 YAML 合并语法,它们的使用就非常直接。
在本章练习的 image-gallery 文件夹中,有一个 docker-compose-prod.yml 文件,它使用了扩展字段。列表 10.7 展示了如何定义扩展字段,将它们声明在任何顶级块(服务、网络等)之外,并使用带符号的命名法给它们命名。
列表 10.7 在 Docker Compose 文件顶部定义扩展字段
x-labels: &logging logging: options: max-size: '100m' max-file: '10' x-labels: &labels app-name: image-gallery
扩展字段是自定义定义;在这个文件中,有两个被称为 logging 和 labels。按照惯例,你需要在块名称前加上一个“x”,因此 x-labels 块定义了一个名为 labels 的扩展。日志扩展指定了容器日志的设置,并且可以在服务定义中使用。标签扩展指定了一个键/值对,该键/值对可以在服务定义中现有的 labels 字段中使用。
你应该注意这些定义之间的区别——日志字段包括日志属性,这意味着它可以直接在服务中使用。标签字段不包括标签属性,因此需要在现有的标签集中使用。列表 10.8 清楚地说明了这一点,其中包含了一个使用这两个扩展的服务定义。
列表 10.8 在服务定义中使用 YAML 合并扩展
services: iotd: ports: - 8080:80 <<: *logging labels: <<: *labels public: api
扩展字段与 YAML 合并语法 <<: 一起使用,后跟字段名称,该名称以星号开头。因此 <<: *logging 将合并 YAML 文件该点的 logging 扩展字段的值。当 Compose 处理此文件时,它将添加来自日志扩展的日志部分到服务中,并且它将在现有的标签部分中添加一个额外的标签,合并来自 labels 扩展字段的值。
现在尝试一下 我们不需要运行此应用程序来查看 Compose 如何处理文件。只需运行 config 命令即可。这将验证所有输入并打印出最终的 Compose 文件,其中扩展字段已合并到服务定义中:
# 浏览到 ch10/exercises 下的 image-gallery 文件夹: cd ../image-gallery # 检查生产覆盖的配置: docker-compose -f ./docker-compose.yml -f ./docker-compose-prod.yml config
我的输出在图 10.11 中——我没有显示完整的输出,只是足够的服务定义来显示扩展字段是如何合并的。

图 10.11 使用 config 命令处理具有扩展字段的文件并检查结果
扩展字段是确保 Compose 文件中最佳实践的有用方式——使用相同的日志设置和容器标签是设置所有服务标准的好例子。这不是你会在每个应用程序中都使用的东西,但当你准备复制和粘贴大块 YAML 时,拥有它是很好的。现在你有一个更好的方法。尽管如此,有一个大限制:扩展字段不适用于多个 Compose 文件,因此你无法在核心 Compose 文件中定义一个扩展并在覆盖中使用它。这是 YAML 而不是 Compose 的限制,但这是你应该注意的事情。
10.5 理解 Docker 的配置工作流程
将整个部署配置捕获在源控制中存在的一系列工件集中是非常有价值的。它允许您通过获取该版本的源代码并运行部署脚本来部署应用程序的任何版本。它还允许开发人员通过在本地运行生产堆栈并再现他们自己环境中的错误来快速修复问题。
环境之间总是存在差异,Docker Compose 允许您在仍然提供存在于源控制中的部署工件集的同时,捕获环境之间的差异。在本章中,我们探讨了使用 Docker Compose 定义不同的环境,并重点关注了三个关键领域:
-
应用程序组合 -- 并非每个环境都会运行整个堆栈。例如,监控仪表板可能不会被开发人员使用,或者应用程序可能在测试环境中使用容器化数据库,但在生产环境中连接到云数据库。覆盖文件允许您整洁地完成这项工作,共享通用服务并在每个环境中添加特定的服务。
-
容器配置 -- 属性需要更改以匹配环境和功能的要求。发布的端口需要是唯一的,以免与其他容器冲突,并且卷路径可能在测试环境中使用本地驱动器,但在生产环境中使用共享存储。覆盖功能可以实现这一点,以及为每个应用程序提供隔离的 Docker 网络,允许您在单个服务器上运行多个环境。
-
应用程序配置 -- 容器内应用程序的行为将在不同环境之间发生变化。这可能会改变应用程序所做的日志记录量,或它用于存储本地数据的缓存大小,或者整个功能可能会被打开或关闭。您可以使用 Compose 通过任何组合的覆盖文件、环境文件和密钥来实现这一点。
图 10.12 显示了我们在第 10.3 节中运行的待办事项列表应用程序。开发和测试环境完全不同:在 dev 中,应用程序配置为使用本地数据库文件,而在测试中,Compose 还运行了一个数据库容器,应用程序配置为使用该容器。但每个环境都使用隔离的网络和唯一的端口,因此它们可以在同一台机器上运行,这对于开发人员需要启动本地测试环境并查看其与开发版本的比较来说非常完美。

图 10.12 使用 Docker Compose 为同一应用程序定义非常不同的环境
从这个实验中最重要的收获是配置工作流程在每一个环境中都使用相同的 Docker 镜像。构建过程将生成通过所有自动化测试的容器镜像的标记版本。这是一个发布候选版本,您将使用 Compose 文件中的配置将其部署到烟雾测试环境。当它通过烟雾测试后,它将进入下一个环境,该环境使用相同的镜像集并应用来自 Compose 的新配置。最终,如果所有测试都通过,您将发布该版本,并将相同的容器镜像部署到生产环境中,使用您的 Docker Swarm 或 Kubernetes 部署清单。发布的软件正是通过所有测试的相同软件,但现在它具有来自容器平台的生产行为。
10.6 实验室
在这个实验室中,我希望您为待办事项应用程序构建自己的环境定义集。您将组合一个开发环境和测试环境,并确保它们都可以在同一台机器上运行。
开发环境应该是默认的,您可以使用 docker-compose up 来运行它。设置应该是
-
使用本地数据库文件
-
发布到端口
8089 -
运行待办事项应用程序的 v2 版本
测试环境需要使用特定的 Docker Compose 文件和项目名称来运行。其设置应该是
-
使用独立的数据库容器
-
使用卷来存储数据库
-
发布到端口
8080 -
使用最新的待办事项应用程序镜像
在这里与本章todo-list-configured练习中的 Compose 文件有相似之处。主要区别在于容量——数据库容器使用名为 PGDATA 的环境变量来设置数据文件应该写入的位置。您可以在您的 Compose 文件中使用该变量以及卷规格。
正如您在本章中看到的,您有多种方法可以解决这个问题。我的解决方案在 GitHub 上:github.com/sixeyed/diamol/blob/master/ch10/lab/README.md。
11 使用 Docker 和 Docker Compose 构建和测试应用程序
自动化是 Docker 的核心。你通过 Dockerfile 描述将组件打包的步骤,并使用 Docker 命令行来执行这些步骤;你通过 Docker Compose 文件描述你的应用程序架构,并使用 Compose 命令行来启动和停止应用程序。命令行工具非常适合与自动化流程配合,比如每天定时运行或开发者推送代码更改时运行的作业。无论你使用哪种工具来运行这些作业,它们都允许你运行脚本命令,这样你就可以轻松地将 Docker 工作流程与自动化服务器集成。
在本章中,你将学习如何使用 Docker 进行持续集成(CI)。CI 是一个定期运行的自动化过程,用于构建应用程序并执行一系列测试。当 CI 作业健康时,这意味着应用程序的最新代码是好的,已经被打包,并作为发布候选准备部署。设置和管理 CI 服务器和作业曾经是耗时且密集的工作——“构建经理”在大型项目中是一个全职角色。Docker 简化了 CI 过程的每个部分,并让人们有更多时间从事更有趣的工作。
11.1 Docker 中 CI 过程的工作原理
CI 过程是一个从代码开始,执行一系列步骤,并以经过测试的可部署工件结束的管道。CI 的一个挑战是,每个项目的管道都是独特的——不同的技术堆栈在步骤中执行不同的操作并产生不同类型的工件。CI 服务器需要适用于所有这些独特的管道,因此服务器上可以安装所有编程语言和构建框架的组合,这可能会变得难以管理。
Docker 为 CI 过程带来了一致性,因为每个项目都遵循相同的步骤并产生相同类型的工件。图 11.1 展示了使用 Docker 的典型管道——它由代码更改或定时计划触发,并生成一系列 Docker 镜像。这些镜像包含最新版本的代码——编译、测试、打包,并推送到注册表以进行分发。

图 11.1 使用 Docker 构建、测试和发布应用程序的基本 CI 管道步骤。
CI 管道中的每一步都使用 Docker 或 Docker Compose 运行,所有工作都在容器内完成。你使用容器来编译应用程序,因此 CI 服务器不需要安装任何编程语言或构建 SDK。自动单元测试作为镜像构建的一部分运行,所以如果代码有误,构建将失败,CI 作业将停止。你还可以通过启动整个应用程序(使用 Docker Compose)以及一个运行测试的独立容器来运行更复杂的端到端测试,该容器模拟用户工作流程。
在 Docker 化的 CI 流程中,所有艰苦的工作都在容器中完成,但你仍然需要一些基础设施组件来将一切整合在一起:一个集中的源代码系统、一个用于存储镜像的 Docker 注册库,以及一个自动化服务器来运行 CI 作业。你可以从众多支持 Docker 的托管服务中选择,这些服务都可以混合搭配——你可以将 GitHub 与 Azure DevOps 和 Docker Hub 结合使用,或者你可以使用 GitLab,它提供了一站式解决方案。或者你也可以在 Docker 容器中运行自己的 CI 基础设施。
11.2 使用 Docker 启动构建基础设施
当你可以免费获得可靠的托管服务时,没有人愿意运行自己的基础设施组件,但了解在 Docker 中运行构建系统是一个非常实用的替代方案。如果你希望将源代码和打包的镜像完全保留在自己的网络内——为了数据主权或传输速度——这是理想的。即使你使用服务处理所有事情,当 GitHub 或 Docker Hub 出现故障,或者你的互联网连接断开时,拥有一个简单的备份选项也是极好的。
你需要的三个组件可以很容易地在容器中使用企业级开源软件运行。你可以使用一条命令运行自己的设置,使用 Gogs 进行源代码控制,开源 Docker 注册库进行分发,以及 Jenkins 作为自动化服务器。
现在尝试一下 在本章的exercises文件夹中,有一个定义构建基础设施的 Docker Compose 文件。设置的一部分对于 Linux 和 Windows 容器是不同的,所以你需要选择正确的文件。如果你在 5.3 节中没有这样做,你还需要在你的 hosts 文件中添加一个条目,用于 DNS 名称registry.local。
cd ch11/exercises/infrastructure # 使用 Linux 容器启动应用: docker-compose -f docker-compose.yml -f docker-compose-linux.yml up -d # 或者使用 Windows 容器启动: docker-compose -f docker-compose.yml -f docker-compose-windows.yml up -d # 在 Mac 或 Linux 上向本地 hosts 文件添加注册库域名: echo $'\n127.0.0.1 registry.local' | sudo tee -a /etc/hosts # 或者 Windows 上: Add-Content -Value "127.0.0.1 registry.local" -Path /windows/system32/drivers/etc/hosts # 检查容器: docker container ls
你可以在图 11.2 中看到我的输出。Linux 和 Windows 上的命令不同,但结果相同——你将拥有发布到端口 3000 的 Gogs Git 服务器,发布到端口 8080 的 Jenkins,以及发布到端口 5000 的注册库。

图 11.2 使用一条命令在容器中运行整个构建基础设施
这三个工具很有趣,因为它们支持不同级别的自动化。注册服务器在容器中运行,无需任何额外设置,因此现在您可以使用 registry.local:5000 作为您的镜像标签中的域名来推送和拉取镜像。Jenkins 使用插件系统来添加功能,您可以手动设置它,或者您可以在 Dockerfile 中捆绑一组脚本来自动化设置。Gogs 并没有很好的自动化故事,所以尽管它在运行,但仍需要一些手动配置。
现在试试吧 浏览到 http: / / localhost:3000,您将看到 Gogs 的 Web UI。第一页是初始安装,如图 11.3 所示。这仅在首次使用新容器时显示。所有值都已正确配置;您只需向下滚动并点击安装 Gogs。

图 11.3 在容器中运行 Gogs。它是一个需要一些手动设置的开源 Git 服务器。
安装非常快速,您将进入登录页面。没有默认账户,因此您需要点击注册来创建一个。创建一个用户名为 diamol 的用户,如图 11.4 所示--您可以使用任何电子邮件地址或密码,但 Jenkins CI 作业期望 Gogs 用户名为 diamol。

图 11.4 创建一个新用户在 Gogs 中,您可以使用该用户将源代码推送到服务器
点击创建新账户,然后使用 diamol 用户名和您的密码登录。最后一步是创建一个仓库--这是我们将会推送触发 CI 作业的代码的地方。浏览到 http:/ /localhost:3000/repo/create 并创建一个名为 diamol 的仓库--其他细节可以留空,如图 11.5 所示。

图 11.5 在 Gogs 中创建 Git 仓库,您可以在此处上传您应用的源代码
当您在 Docker 中运行软件时需要手动配置软件,这相当令人沮丧,而且更令人沮丧的是需要将截图复制粘贴到书中,但并非每个应用程序都允许您完全自动化安装。我本可以构建一个包含这些设置步骤的定制镜像,但重要的是让您看到您并不总能将事物优雅地打包到 docker container run 工作流程中。
Jenkins 提供了更好的体验。Jenkins 是一个 Java 应用程序,您可以使用一组在容器启动时运行的脚本将其打包为 Docker 镜像。这些脚本几乎可以完成任何事情--安装插件、注册用户和创建管道作业。这个 Jenkins 容器做了所有这些,因此您可以直接登录并开始使用它。

图 11.6 在容器中运行 Jenkins -- 它已经完全配置好,包括用户和 CI 作业已经设置好。
现在试试看。浏览到 http://localhost:8080。你会看到图 11.6 中的屏幕——那里已经有一个名为 diamol 的配置好的作业,它处于失败状态。点击右上角的“登录”链接,使用用户名 diamol 和密码 diamol 登录。
Jenkins 作业失败是因为它被配置为从 Gogs Git 服务器获取代码,但目前还没有代码。这本书的源代码已经是一个 Git 仓库,你最初是从 GitHub 克隆的。你可以将你的本地 Gogs 容器作为另一个 Git 服务器添加到仓库中,并将书籍的代码推送到你自己的基础设施。
现在试试看。你可以使用 git remote add 添加一个额外的 Git 服务器,然后推送远程。这会将你本地机器上的代码上传到 Gogs 服务器,而 Gogs 服务器恰好也是你机器上的一个容器:
git remote add local http://localhost:3000/diamol/diamol.git git push local # Gogs 将要求你登录 - # 使用你在 Gogs 中注册的 diamol 用户名和密码
现在你已经将整本书的源代码放在了你的本地 Git 服务器上。Jenkins 作业被配置为每分钟检查代码的更改,如果有更改,它将触发 CI 管道。第一次作业运行失败是因为代码仓库不存在,所以 Jenkins 已经暂停了计划。你需要手动运行作业以再次启动计划。

图 11.7 Jenkins 的工作页面显示了作业的当前状态,并允许你手动启动构建。
现在试试看。浏览到 http://localhost:8080/job/diamol。你会看到图 11.7 中的屏幕,你可以在左侧菜单中点击“现在构建”来运行作业。如果你看不到“现在构建”选项,请确保你已经使用 diamol 凭据登录到 Jenkins。
大约一分钟后,构建将成功完成,网页将刷新,你将在图 11.8 中看到输出。

图 11.8 Jenkins 的工作页面显示了最近构建的状态和管道步骤。
这个管道的每一部分都是使用 Docker 容器运行的,利用了一个巧妙的小技巧:在 Docker 中运行的容器可以连接到 Docker API 并在它们运行的同一个 Docker 引擎上启动新的容器。Jenkins 镜像安装了 Docker CLI,而 Compose 文件中的配置设置了 Jenkins,当它运行 Docker 命令时,这些命令会被发送到你机器上的 Docker 引擎。这听起来很奇怪,但实际上只是利用了 Docker CLI 调用 Docker API 的这一事实,因此来自不同地方的 CLI 可以连接到同一个 Docker 引擎。图 11.9 展示了它是如何工作的。

图 11.9 使用卷绑定 Docker API 的私有通道运行容器
Docker CLI 默认通过本地 Docker API 连接,使用一个仅限于您机器的私有通信通道——Linux 上的套接字或 Windows 上的命名管道。这个通信通道可以用作容器的绑定挂载,因此当容器中的 CLI 运行时,它实际上是在连接到您机器上的套接字或命名管道。这解锁了一些有用的场景,其中容器内的应用程序可以查询 Docker 以找到其他容器,或启动和停止新的容器。这里也存在一个安全问题,因为容器中的应用程序可以完全访问主机上的所有 Docker 功能,所以您需要谨慎使用您信任的 Docker 镜像——当然,您可以信任我的 diamol 镜像。
列表 11.1 展示了您启动基础设施容器时运行的 Docker Compose 文件的部分内容,重点关注 Jenkins 规范。您可以看到卷绑定到了 Linux 版本的 Docker 套接字和 Windows 版本的命名管道——这是 Docker API 的地址。
列表 11.1 将 Jenkins 中的 Docker CLI 绑定到 Docker 引擎
# docker-compose.yml services: jenkins: image: diamol/jenkins ports: - "8080:8080" networks: - infrastructure # docker-compose-linux.yml jenkins: volumes: - type: bind source: /var/run/docker.sock target: /var/run/docker.sock # docker-compose-windows.yml jenkins: volumes: - type: npipe source: \\.\pipe\docker_engine target: \\.\pipe\docker_engine
这就是您需要的所有基础设施。Jenkins 通过连接到 Docker 引擎来运行 Docker 和 Docker Compose 命令,并且因为它与 Git 服务器和 Docker 仓库都在同一个 Docker 网络中,所以它可以通过 DNS 连接到它们。CI 流程运行单个命令来构建应用程序,所有构建的复杂性都记录在 Dockerfile 和 Docker Compose 文件中。
11.3 使用 Docker Compose 捕获构建设置
Jenkins 运行的作业构建了第八章中随机数应用程序的新版本。您在第十章中看到过如何将应用程序定义拆分到多个 Compose 文件中,这个应用程序就是使用这种方法来捕获构建设置的细节。列表 11.2 来自 ch11/exercises 文件夹中的基本 docker-compose.yml 文件——它包含带有环境变量的镜像名称的 Web 和 API 服务定义。
列表 11.2 使用镜像标签中的变量作为核心 Docker Compose 文件
services: numbers-api: image: ${REGISTRY:-docker.io}/diamol/ch11-numbers-api:v3-build-${BUILD_NUMBER:-local} networks: - app-net numbers-web: image: ${REGISTRY:-docker.io}/diamol/ch11-numbers-web:v3-build-${BUILD_NUMBER:-local} environment: - RngApi__Url=http://numbers-api/rng networks: - app-net
这里环境变量语法包括使用 :- 设置的默认值,所以 ${REGISTRY:-docker.io} 告诉 Compose 在运行时将此占位符替换为名为 REGISTRY 的环境变量的值。如果该环境变量不存在或为空,它将使用默认值 docker.io,这是 Docker Hub 的域名。我使用相同的方法处理镜像标签,所以如果设置了环境变量 BUILD_NUMBER,该值将用于标签;否则使用 local。
这是一个非常有用的模式,用于支持使用相同的一组工件进行 CI 流程和本地开发者构建。当开发者构建 API 镜像时,他们不会设置任何环境变量,所以镜像将被命名为 docker.io/diamol/ ch11-numbers-api:v3-build-local 。但 docker.io 是 Docker Hub,这是默认域名,所以镜像将仅显示为 diamol/ch11-numbers-api:v3-build-local 。当相同的构建在 Jenkins 上运行时,变量将被设置为使用本地 Docker 注册表和作业的实际构建号,Jenkins 将其设置为递增的数字,所以镜像名称将是 registry.local:5000/ diamol/ ch11-numbers-api:v3-build-2 。
设置灵活的镜像名称是 CI 设置的一个重要部分,但关键信息在覆盖文件 docker-compose-build.yml 中指定,它告诉 Compose 在哪里找到 Dockerfile。
现在试试看 你可以使用与 CI 构建管道相同的步骤在本地构建应用程序。从一个终端会话开始,浏览到章节目录,并使用 Docker Compose 构建应用程序:
cd ch11/exercises # build both images: docker-compose -f docker-compose.yml -f docker-compose-build.yml build # check the labels for the web image: docker image inspect -f '{{.Config.Labels}}' diamol/ch11-numbers-api:v3-build-local
你可以在图 11.10 中看到我的输出。

图 11.10 使用 Docker Compose 构建镜像并检查镜像标签
通过 Docker Compose 构建应用程序实际上为每个指定了构建设置的服务运行一个 docker image build 命令。这可能是一打镜像或一个镜像——即使是单个镜像,使用 Compose 构建也是一个好习惯,因为这样你的 Compose 文件就指定了构建镜像时想要的标签。在这个构建过程中还有一些其他事情是成功 CI 管道的一部分——你可以在列出镜像标签的最终 inspect 命令中看到这些。
Docker 允许你将标签应用于大多数资源--容器、镜像、网络和卷。它们是简单的键/值对,你可以存储有关资源的额外数据。标签在镜像中非常有用,因为它们被嵌入到镜像中并随其移动--当你推送或拉取镜像时,标签也会随之移动。当你使用 CI 管道构建应用程序时,有一个审计跟踪对于追踪从运行容器到创建它的构建作业非常重要,镜像标签可以帮助你做到这一点。
列表 11.3 展示了随机数 API 的 Dockerfile 的一部分(完整的文件可以在本章的练习中找到,位于numbers/numbers-api/Dockerfile.v4)。这里有两个新的 Dockerfile 指令-- ARG 和 LABEL。
列表 11.3 在 Dockerfile 中指定镜像标签和构建参数
# 应用程序镜像 FROM diamol/dotnet-aspnet ARG BUILD_NUMBER=0 ARG BUILD_TAG=local LABEL version="3.0" LABEL build_number=${BUILD_NUMBER} LABEL build_tag=${BUILD_TAG} ENTRYPOINT ["dotnet", "Numbers.Api.dll"]
LABEL 指令只是在构建时将 Dockerfile 中的键/值对应用到镜像上。你可以在 Dockerfile 中看到指定的 version=3.0,这与图 11.10 中的标签输出相匹配。其他两个 LABEL 指令使用环境变量来设置标签值,这些环境变量由 ARG 指令提供。
ARG 与 ENV 指令非常相似,不同之处在于它是在构建图像时而不是在容器运行时起作用。它们都设置环境变量的值,但对于 ARG 指令,这种设置只存在于构建期间,因此从该镜像运行的任何容器都不会看到该变量。这是一个将不适用于运行容器的数据传递到构建过程的好方法。我在这里使用它来提供要放入镜像标签中的值--在 CI 过程中,这些记录了构建的编号和完整的构建名称。ARG 指令还设置默认值,因此当你本地构建镜像而不传递任何变量时,你会在镜像标签中看到 build_number:0 和 build_tag:local。
你可以看到 CI 管道中的环境设置是如何传递到 Compose 覆盖文件中的 Docker build 命令的。列表 11.4 展示了包含所有构建设置的docker-compose-build.yml文件内容。
列表 11.4 在 Docker Compose 中指定构建设置和可重用参数
x-args: &args args: BUILD_NUMBER: ${BUILD_NUMBER:-0} BUILD_TAG: ${BUILD_TAG:-local} services: numbers-api: build: context: numbers dockerfile: numbers-api/Dockerfile.v4 <<: *args numbers-web: build: context: numbers dockerfile: numbers-web/Dockerfile.v4 <<: *args
这个 Compose 文件不应该太复杂,除非你跳过了第十章,在这种情况下,你应该回去阅读它。这不会花你超过一顿午餐的时间。
Compose 规范中的build块有三个部分:
-
context-- 这是 Docker 将用作构建工作目录的路径。这通常是当前目录,你通过在docker image build命令中使用点来传递它,但在这里是numbers目录——路径相对于 Compose 文件的位置。 -
dockerfile-- Dockerfile 的路径,相对于上下文。 -
args-- 需要传递的任何构建参数,这些参数需要与 Dockerfile 中ARG指令指定的键匹配。这个应用的两个 Dockerfile 都使用相同的BUILD_NUMBER和BUILD_TAG参数,所以我使用 Compose 扩展字段一次性定义这些值,并使用 YAML 合并将其应用到两个服务中。
你会在很多不同的地方看到指定的默认值,这是为了确保对 CI 过程的支撑不会破坏其他工作流程。你应该始终追求一个单一的 Dockerfile,无论以何种方式构建,都能以相同的方式进行构建。Compose 文件中的默认参数意味着在 CI 环境外运行时构建成功,而 Dockerfile 中的默认参数意味着即使不使用 Compose,镜像也能正确构建。
现在尝试一下 你可以使用正常的image build命令构建随机数 API 镜像,绕过 Compose 文件中的设置。你可以将镜像命名为你喜欢的任何名称——构建成功并且标签被应用,因为 Dockerfile 中的默认值:
# 将目录更改为数字目录 # (这是通过 Compose 中的上下文设置完成的): cd ch11/exercises/numbers # 构建镜像,指定 Dockerfile 路径和构建参数: docker image build -f numbers-api/Dockerfile.v4 --build-arg BUILD_TAG=ch11 -t numbers-api . # 检查标签: docker image inspect -f '{{.Config.Labels}}' numbers-api
我的输出在图 11.11 中——你可以在标签中看到,build_tag:ch11的值是由我的build命令设置的,但build_number:0的值是由 Dockerfile 中ARG的默认值设置的。

图 11.11 包含构建参数的默认值支持开发人员构建工作流程。
这里有很多细节级别,只是为了将标签放入镜像中,但这是正确设置的重要事情。你应该能够运行docker image inspect并找到那个镜像的确切来源,追踪回生成它的 CI 作业,这反过来又追踪回触发构建的确切代码版本。这是从任何环境中的运行容器回源代码的审计跟踪。
11.4 使用除 Docker 之外无依赖项的 CI 作业编写
在本章中,您已经愉快地使用 Docker 和 Docker Compose 为随机数应用程序构建镜像,而无需在您的机器上安装任何其他工具。该应用程序有两个组件,两者都使用 .NET Core 3.0 编写,但您不需要在您的机器上安装 .NET Core SDK 来构建它们。它们使用第四章中提到的多阶段 Dockerfile 方法来编译和打包应用程序,因此您只需要 Docker 和 Compose。
这是容器化 CI 的一个主要优势,并且所有托管构建服务(如 Docker Hub、GitHub Actions 和 Azure DevOps)都支持它。这意味着您不再需要一个安装了大量工具的构建服务器,并且工具需要与所有开发者保持更新。这也意味着您的构建脚本变得非常简单——开发者可以在本地使用完全相同的构建脚本,并获得与 CI 管道相同的输出,因此在不同构建服务之间移动变得容易。
我们使用 Jenkins 进行 CI 流程,并且 Jenkins 作业可以通过一个简单的文本文件进行配置,该文件位于源代码控制中,与应用程序代码、Dockerfile 和 Compose 文件一起。列表 11.5 显示了管道的一部分(来自文件 ch11/exercises/Jenkinsfile),以及管道步骤执行的批处理脚本。
列表 11.5:Jenkinsfile 中的构建步骤,描述 CI 作业
# Jenkinsfile 中的构建阶段 - 它切换目录,然后运行两个 # shell 命令 - 第一个设置一个脚本文件以便执行 # 第二个调用脚本: stage('Build') { steps { dir('ch11/exercises') { sh 'chmod +x ./ci/01-build.bat' sh './ci/01-build.bat' } } } # 这是 01-build.bat 脚本中的内容: docker-compose -f docker-compose.yml -f docker-compose-build.yml build --pull
好吧,看看这个——这正是您在本地运行的相同的 docker-compose build 命令。只不过它添加了 pull 标志,这意味着 Docker 将在构建过程中拉取它需要的任何镜像的最新版本。当您进行构建时,养成这个习惯是很好的,因为它意味着您将始终从包含所有最新安全修复的基础镜像构建您的镜像。在 CI 流程中,这尤其重要,因为 Dockerfile 使用的镜像可能会发生变化,这可能会破坏您的应用程序,并且您希望尽快发现这一点。
构建步骤运行一个简单的脚本文件——文件名以 .bat 结尾,以便在 Windows 容器中的 Jenkins 下运行良好,但它也可以在 Linux 容器中正常运行。这一步骤执行构建,因为它是一个简单的命令行调用,所以 Docker Compose 的所有输出(也是 Docker 的输出)都被捕获并存储在构建日志中。
现在尝试一下 你可以在 Jenkins UI 中查看日志。浏览到 http:/ /localhost :8080/job/diamol 以查看作业,然后在管道视图中单击作业 #2 的构建步骤。然后单击日志。你可以展开构建步骤,你会看到常规的 Docker 构建输出;我的输出在图 11.12 中。

图 11.12 显示了在 Jenkins 中查看管道构建输出的结果,显示了常规的 Docker 日志。
构建管道中的每个步骤都遵循相同的模式;它只是调用一个批处理脚本,通过运行 Docker Compose 命令来完成实际工作。这种方法使得在不同构建服务之间切换变得容易;而不是在专有管道语法中编写逻辑,你编写脚本,并使用管道来调用脚本。我可以添加管道文件以在 GitLab 或 GitHub Actions 中运行构建,并且它们会调用相同的批处理脚本。
Jenkins 构建的所有阶段都由容器驱动:
-
Verify 调用脚本
00-verify.bat,该脚本仅打印 Docker 和 Docker Compose 的版本信息。这是一种启动管道的有用方式,因为它验证了 Docker 依赖项是否可用,并记录了构建镜像的工具版本。 -
Build 调用
01-build.bat,你之前已经看到过;它使用 Docker Compose 构建镜像。REGISTRY环境变量在 Jenkinsfile 中指定,以便为本地注册表标记镜像。 -
Test 调用
02-test.bat,该脚本使用 Docker Compose 启动整个应用程序,然后列出容器并将应用程序关闭。这只是简单的说明,但它确实证明了容器可以正常运行而不会失败。在实际项目中,你会在另一个容器中启动应用程序并运行端到端测试。 -
Push 调用
03-push.bat,该脚本使用 Docker Compose 推送所有构建的镜像。镜像标签包含本地注册表域名,因此如果构建和测试阶段成功,镜像将被推送到注册表。
CI 管道中的阶段是顺序的,所以如果在任何地方出现失败,作业就会结束。这意味着注册表只存储潜在发布候选人的镜像--任何推送到注册表的镜像都必须成功通过构建和测试阶段。
现在尝试一下 你从 Jenkins 中有一个成功的构建--构建编号 1 失败是因为没有源代码,然后构建编号 2 成功。你可以使用 REST API 查询你的本地注册表容器,你应该看到每个随机数镜像只有一个版本 2 标签:
# 目录端点显示所有镜像仓库: curl http://registry.local:5000/v2/_catalog # 标签端点显示一个仓库的各个标签: curl http://registry.local:5000/v2/diamol/ch11-numbers-api/tags/list curl http://registry.local:5000/v2/diamol/ch11-numbers-web/tags/list
你可以在图 11.13 中看到我的输出--有 Web 和 API 镜像的存储库,但每个都只有一个build-2标签,因为第一次构建失败,没有推送任何镜像。

图 11.13 向注册表 API 发送 Web 请求以查询容器中存储的镜像
这是一个相当简单的 CI 管道,但它展示了构建的所有关键阶段和一些重要的最佳实践。关键是要让 Docker 做艰苦的工作,并在脚本中构建你的管道阶段。然后你可以使用任何 CI 工具,只需将你的脚本插入工具的管道定义中。
11.5 理解 CI 过程中的容器
在容器中编译和运行应用程序只是你在 CI 管道中使用 Docker 的开始。Docker 在你的所有应用程序构建之上添加了一层一致性,你可以利用这层一致性为你的管道添加许多有用的功能。图 11.14 展示了更广泛的 CI 流程,其中包括对已知漏洞进行安全扫描的容器镜像和为镜像进行数字签名以声明其来源。


Docker 将这种方法称为安全软件供应链,对于所有规模的组织都至关重要,因为它让你有信心,你即将部署的软件是安全的。你可以在管道中运行工具来检查已知的安全漏洞,并在有问题时失败构建。你可以配置你的生产环境,只运行已数字签名的镜像--这个过程发生在构建成功结束时。当你的容器部署到生产环境时,你可以确信它们正在运行来自你的构建过程的镜像,并且包含通过了所有测试且没有安全问题的软件。
你在管道中添加的检查和平衡在容器和镜像上工作,因此它们以相同的方式应用于所有应用程序平台。如果你在项目中使用多种技术,你将使用不同的基础镜像和不同的 Dockerfile 构建步骤,但 CI 管道都将相同。
11.6 实验室
实验时间!你将构建自己的 CI 管道--但不要害怕。我们将使用本章中的想法和练习,但管道阶段将会更简单。
在本章的实验室文件夹中,你可以找到第六章待办事项应用程序的源代码副本。该应用程序的构建几乎可以立即使用--Jenkinsfile 已经存在,CI 脚本也存在,核心 Docker Compose 文件也存在。你只需要做几件事情:
-
编写一个名为
docker-compose-build.yml的覆盖文件,包含构建设置。 -
创建一个 Jenkins 作业来运行管道。
-
将你的更改推送到
diamol存储库的 Gogs。
只需完成三个任务,但如果你的前几次构建失败,需要检查日志并调整一些设置,请不要气馁。历史上没有人第一次运行就能成功编写一个 Jenkins 作业,所以这里有一些提示:
-
你的 Compose 覆盖设置将与练习中的类似——指定上下文和构建编号标签的构建参数。
-
在 Jenkins UI 中,你点击“新建项目”来创建一个作业,并且你可以从现有的
diamol作业中复制内容。 -
新的作业设置将与 Jenkinsfile 的路径相同,除了你需要指定
lab文件夹而不是exercises文件夹。
如果你在这方面进展不大,你可以在实验室文件夹中的“read-me”文件中找到更多信息,其中包含 Jenkins 步骤的截图以及 Docker Compose 文件的示例构建配置:github.com/sixeyed/diamol/blob/master/ch11/lab/README.md。
第三部分. 使用容器编排器进行大规模运行
编排(Orchestration)是指在不同服务器——即服务器集群上运行容器化应用。你使用相同的 Docker 镜像,并且可以使用相同的 Docker Compose 文件格式,但不是自己管理容器,而是告诉集群你希望最终结果是什么,然后它为你管理容器。在本书的这一部分,你将学习如何使用 Docker Swarm,这是一个内置在 Docker 中的简单而强大的编排器。你将了解应用程序的更新和回滚过程,以及如何将你的构建管道连接到集群以添加持续部署到你的 CI 管道中。
12 理解编排:Docker Swarm 和 Kubernetes
我们一起的容器之旅已经过半,到现在您应该已经非常熟悉使用 Docker 和 Docker Compose 打包和运行应用程序了。下一步是了解这些应用程序如何在生产环境中运行,在那里有许多机器运行 Docker,以提供高可用性和处理大量 incoming traffic 的能力。
在那个环境中,您的应用程序仍然使用与您本地运行相同的 Docker 镜像在容器中运行,但有一个管理层负责协调所有机器并为您运行容器。这被称为编排,两个主要的容器编排器是 Docker Swarm 和 Kubernetes。它们共享许多相同的功能和能力,但 Kubernetes 是一个复杂的系统,有自己的学习路径——《一个月午餐学 Kubernetes》将是您的指南。在本章中,您将学习如何使用 Docker Swarm 进行编排,这是一个内置在 Docker 中的强大生产级容器编排器。即使您的最终目标是学习 Kubernetes,从 Swarm 开始也是好的——Kubernetes 的学习曲线很陡峭,但如果你已经知道 Swarm,那么学习起来会容易得多。
12.1 容器编排器是什么?
Docker Compose 适用于在单台机器上运行容器,但在生产环境中并不适用——如果该机器离线,您将丢失所有应用程序。生产系统需要高可用性,这就是编排发挥作用的地方。编排器基本上是将许多机器分组在一起形成一个集群;编排器管理容器,在所有机器之间分配工作,平衡网络流量,并替换任何变得不健康的容器。
您可以通过在每个机器上安装 Docker 来创建一个集群,然后使用编排平台——Swarm 或 Kubernetes 将它们连接起来。从那时起,您可以使用命令行工具或 Web UI 远程管理集群。图 12.1 展示了从基础设施视图看它是如何呈现的。

图 12.1 一个编排器将许多服务器转换成一个单一的集群,并为您管理容器。
Orchestrator 提供了一套额外的功能,将你的容器提升到新的水平。集群中有一个分布式数据库,存储了你部署的所有应用程序的信息。然后有一个调度器来确定容器的运行位置,还有一个系统在集群中的所有服务器之间发送心跳。这些都是可靠性的基本构建块。你通过将 YAML 文件发送到集群来部署应用程序;它存储这些信息,然后调度容器运行应用程序——将工作分配给具有可用容量的服务器。当应用程序运行时,集群确保它持续运行。如果服务器离线并且你丢失了一大批容器,集群将在其他服务器上启动替换容器。
Orchestrators 负责管理容器的所有繁重工作;你只需在 YAML 文件中定义所需的状态,无需了解或关心集群中有多少服务器或容器运行的位置。orchestrator 还提供了网络、配置应用程序和存储数据的功能。图 12.2 展示了网络流量如何路由到集群内部以及容器如何读取配置对象和秘密,并将数据写入共享存储。

图 12.2 orchestrators 为容器提供了额外的功能——网络、配置和存储。
图 12.2 中的图示缺少一个重要的元素——服务器。orchestrator 隐藏了单个机器、网络和存储设备的细节。你将集群作为一个单一单元来工作,通过 API 发送命令和运行查询,命令行连接到该 API。集群可以是 1000 台机器或一台机器——你以相同的方式与之交互,发送相同的命令和 YAML 文件来管理你的应用程序。你的应用程序的用户可以连接到集群中的任何服务器,编排层负责将流量路由到容器。
12.2 设置 Docker Swarm 集群
现在让我们开始吧。使用 Docker Swarm 部署容器 orchestrator 非常简单,因为所有功能都内置在 Docker Engine 中。你只需要通过初始化集群来切换到 Swarm 模式。
现在试试看。Docker CLI 有一组用于管理集群操作的命令。swarm init命令切换到 Swarm 模式。通常你可以在没有任何参数的情况下运行它,但如果你的机器连接到多个网络,你会得到一个错误,Docker 会询问你哪个 IP 地址用于 Swarm 通信:
docker swarm init
你可以在图 12.3 中看到我的输出,它告诉我 Swarm 已初始化,我的机器是管理器。集群中的机器可以有不同的角色:它们可以是管理器或工作节点。运行swarm init的输出显示了需要在其他机器上运行的命令,以便它们作为工作节点加入 Swarm。

图 12.3 切换到 Swarm 模式创建了一个包含单个节点的集群,这个节点是管理员。
管理员和工人的区别在于管理员负责整个流程——集群数据库存储在管理员上,你将命令和 YAML 文件发送到托管在管理员上的 API,所有的调度和监控都由管理员完成。工人通常只在管理员调度时运行容器,并报告其状态,尽管你也可以让管理员运行工作负载(在这里插入你自己的笑话,将其与人类管理员进行比较)。
初始化 Swarm 是一次性完成的,然后你可以加入任意数量的机器——Docker 将 Swarm 中的机器称为节点。要将节点加入 Swarm,它需要处于同一网络中,并且你需要管理员的加入令牌,这就像是一个密码,用于保护 Swarm 免受恶意节点的侵害。如果你可以访问管理员,你可以打印出节点加入作为工作节点或额外管理员的令牌,并且你可以列出 Swarm 中的节点。
现在就试试看吧!一旦进入 Swarm 模式,Docker CLI 将提供更多命令。运行这些命令以找到工作节点或管理节点的加入令牌,并列出 Swarm 中的所有节点:
# 打印加入新工作节点命令 docker swarm join-token worker # 打印加入新管理节点命令 docker swarm join-token manager # 列出 Swarm 中的所有节点 docker node ls
你可以在图 12.4 中看到我的输出。我的 Swarm 中只有一个节点,但我可以使用join命令中的管理员的 IP 地址将网络上的任何其他机器添加到 Swarm 中。

图 12.4 在 Swarm 模式下,你可以使用额外的命令来管理集群中的节点。
单节点 Swarm 的工作方式与多节点 Swarm 完全相同,只是你没有从备用机器中获得高可用性,或者将容器扩展到多个机器以使用其容量的选项。图 12.5 比较了单节点 Swarm 的架构,你可以用于开发和测试环境,以及多节点集群,你会在生产中使用。

图 12.5 测试和生产 Swarm 具有不同数量的节点,但具有相同的功能集。
Docker Swarm 相对于 Kubernetes 的一个重大优势是集群设置和管理的简单性。你只需在每个服务器上安装 Docker,运行一次docker swarm init,然后对其他所有节点运行docker swarm join,就可以构建一个包含数十个节点的 Swarm。没有隐藏的复杂性——生产环境和测试环境的过程是相同的。
现在你有了单节点 Swarm,你可以探索当有编排器为你管理容器时应用程序是如何工作的。
12.3 以 Docker Swarm 服务运行应用程序
在 Docker Swarm 中,你不会直接运行容器——你部署服务,Swarm 会为你运行容器。服务只是对单个容器概念的抽象。Swarm 在这里使用与 Docker Compose 相同的术语,原因相同:服务可以部署为多个容器。
服务定义中包含了很多你用来运行容器时使用的信息。你指定要使用的镜像、要设置的环境变量、要发布的端口以及服务的名称,该名称将成为网络上的 DNS 名称。区别在于,一个服务可以有多个副本——这些副本是使用服务相同规范的单个容器,可以在 Swarm 中的任何节点上运行。
现在尝试一下:创建一个运行一个容器的服务,使用来自 Docker Hub 的简单应用程序镜像,然后列出服务以检查其是否正确运行:
docker service create --name timecheck --replicas 1 diamol/ch12-timecheck:1.0 docker service ls
在 Docker Swarm 中,服务是一等对象,但你需要运行在 Swarm 模式下——或者连接到 Swarm 管理器——才能与之交互。我的输出如图 12.6 所示,你可以看到服务被创建,并且从服务列表命令中显示了基本详情,显示有一个副本正在运行。

图 12.6 创建服务是请求 Swarm 为你运行容器的做法。
构成服务的容器被称为副本,但它们只是普通的 Docker 容器。你可以连接到运行副本的节点,并使用常规的 Docker 容器命令与之交互。在单节点 Swarm 中,每个副本都会在该机器上运行,因此你可以与刚刚创建的服务容器一起工作。尽管如此,这通常不是你想要做的事情,因为容器是由 Swarm 管理的。如果你尝试自己管理它们,结果可能不会如你所预期。
现在尝试一下:服务副本正在你的机器上运行,但它由 Swarm 管理。你可以删除容器,但 Swarm 会看到服务的副本数低于期望值,并将创建一个替代品。
# 列出服务的副本: docker service ps timecheck # 检查机器上的容器: docker container ls # 删除最新的容器(即服务副本): docker container rm -f $( docker container ls --last 1 -q) # 再次检查副本: docker service ps timecheck
你可以在图 12.7 中看到我的输出。我有一个容器正在运行我的服务的副本,我手动将其删除。但是服务仍然存在于 Swarm 中,并且它应该有一个副本级别为 1。当我删除容器时,Swarm 看到运行的副本不足,并启动了一个替换。你在最终的副本列表中看到原始容器被显示为失败,因为 Swarm 不知道容器停止的原因。正在运行的副本是一个只运行了 10 秒的新容器。

图 12.7 服务副本是普通容器,但它们是由 Swarm 管理的——而不是由你管理。
当你在 Swarm 模式下运行时,你将你的应用程序作为服务来管理,并且让 Swarm 管理单个容器。这必须是这样,因为自己管理容器是不可管理的——你不得不连接到 Swarm 中的每个节点,找出它是否运行了你的服务的副本,如果你想要检查状态或打印日志,你还需要直接与容器打交道。Docker 通过提供操作 Swarm 资源的命令来支持你。你可以使用docker service命令来打印出所有副本的日志条目,并检查服务以读取其规范。
现在试试看 The docker service命令是你在 Swarm 模式下应该使用的方式来处理应用程序。你可以从副本中获取信息,比如所有的日志条目,以及关于整个服务的相关信息:
# 打印过去 10 秒的服务日志: docker service logs --since 10s timecheck # 获取服务详情,仅显示镜像: docker service inspect timecheck -f '{{.Spec.TaskTemplate.ContainerSpec.Image}}'
我的输出在图 12.8 中。它显示了服务副本的最新日志条目以及部分服务规范。

图 12.8 你将服务作为一个单一单元来打印副本日志或检查规范。
整个规范保存在集群中,你可以通过运行相同的service inspect命令(但不带格式参数)来查看它。那里有很多信息,安全地存储在集群数据库中,该数据库在所有管理节点之间进行了复制。这是 Docker Swarm 和 Docker Compose 之间的一大区别,因为 Docker Compose 没有用于应用程序定义的数据存储。只有当你有可用的 Compose 文件时,你才能使用 Docker Compose 来管理应用程序,因为那是应用程序定义的来源。在 Swarm 模式下,应用程序定义存储在集群中,因此你可以不使用本地 YAML 文件来管理应用程序。
您可以通过更新正在运行的服务来尝试此操作。您可以指定新的镜像版本,但不需要重复服务规范中的任何其他信息。这就是在集群中部署应用程序更新的方法。当您更新服务定义时,Swarm 会推出更改,通过移除旧容器并启动新的容器来替换副本。
现在尝试一下:将 timecheck 服务更新为使用新的镜像版本。这是一个简单的应用程序,每隔几秒写入一个时间戳,但更新会在日志中打印新的应用程序版本:
# 更新服务以使用新的应用程序镜像: docker service update --image diamol/ch12-timecheck:2.0 timecheck # 列出服务副本: docker service ps timecheck # 并检查日志: docker service logs --since 20s timecheck
当您使用service ps列出副本时,您会看到有两个实例--一个是从镜像标签 1.0 运行的旧副本,另一个是从镜像标签 2.0 运行的替换副本。服务日志包括一个 ID,这样您就可以看到哪个副本产生了日志条目。这些只是被写入容器的应用程序日志,由 Swarm 收集,并带有副本 ID 显示出来。您可以在图 12.9 中看到我的示例。

图 12.9 显示了更新服务开始逐步推出新的应用程序版本。
所有容器编排器都使用分阶段推出的方法来更新应用程序,这可以在升级期间保持您的应用程序在线。Swarm 通过一次替换一个副本来实现这一点,所以如果您有多个副本托管您的应用程序,总有容器在运行以处理传入的请求。实际滚动升级的行为可以为您自己的服务进行配置。您可能有 10 个副本提供您的 Web 应用程序,当您推出升级时,Docker 可以一次替换两个副本,在替换下一个两个副本之前检查新容器是否健康,直到所有 10 个副本都被替换。
图 12.10 显示了在部署过程中滚动升级的外观--一些副本正在运行应用程序镜像的旧版本,而一些副本正在运行新版本。在推出过程中,您的应用程序的两个版本都是活跃的,用户可能会击中任何一个--您需要自己管理更新的用户体验方面。

图 12.10 显示了在 Docker Swarm 和 Kubernetes 中,服务更新是增量进行的。
自动滚动更新是手动应用发布的一个巨大改进,并且它是支持自愈应用的另一个特性。在滚动部署新容器的同时,更新过程会检查这些新容器是否健康;如果新版本存在问题,并且容器正在失败,更新可以自动暂停以防止整个应用崩溃。Swarm 还会将其数据库中存储的服务的上一个版本规格,因此如果您需要手动回滚到上一个版本,您只需一个命令即可完成。
现在试试看 您通常使用 YAML 文件来管理应用部署,但如果部署出现错误,仅回滚到之前的状态就非常有用。Docker Swarm 可以做到这一点,因为它将其数据库中存储了服务的当前和上一个状态:
# 回滚上一个更新: docker service update --rollback timecheck # 列出所有服务副本: docker service ps timecheck # 打印所有副本过去 25 秒的日志: docker service logs --since 25s timecheck
回滚过程与更新过程类似,采用分阶段部署,但它使用的是最近一次更新之前的服务的规格,因此您不需要提供镜像标签。这在更新以某种方式破坏了应用,而 Docker 没有注意到的情况下非常有用,这可能发生在您没有健康检查或检查不够详细的情况下。在这种情况下,当您发现应用已损坏时,只需运行回滚命令,您就不需要疯狂地尝试找到上一个服务规格的详细信息。我的输出如图 12.11 所示,您可以看到所有部署的副本,以及最近副本的服务日志——从 2.0 更新回滚到 1.0。

图 12.11 您可以使用一个命令回滚服务更新以返回到之前的规格。
在 Swarm 模式下,您管理的是服务资源,而不是容器。您还可以管理一些新的资源类型,但一些关键的 Docker 资源以相同的方式工作。当容器需要在 Swarm 模式下进行通信时,它们通过 Docker 网络进行通信,并且您发布端口以允许外部流量进入您的应用。
12.4 在集群中管理网络流量
在 Swarm 模式下,对于容器内的应用而言,网络是标准的 TCP/IP。组件通过 DNS 名称相互查找,Docker 中的 DNS 服务器返回一个 IP 地址,容器将网络流量发送到该 IP 地址。最终,流量被一个容器接收并响应。在 Swarm 模式下,发送请求的容器和发送响应的容器可能运行在不同的节点上,但对于容器以及容器内的应用来说,这一切都是透明的。
在幕后发生着各种巧妙的网络逻辑,以使跨集群通信无缝,但你不需要深入研究任何这些,因为“一切正常工作”。Swarm 模式提供了一种新的 Docker 网络类型,称为覆盖网络。这是一个跨越集群中所有节点的虚拟网络,当服务附加到覆盖网络时,它们可以使用服务名称作为 DNS 名称相互通信。
图 12.12 展示了两个支持不同应用程序的覆盖网络如何工作,其中每个应用程序在许多节点上的多个服务上运行。覆盖网络允许服务在形成同一应用程序的一部分时进行通信,但网络是隔离的,因此不同网络上的服务无法相互访问。

图 12.12 Swarm 中的网络覆盖整个集群,同时仍为应用程序提供隔离。
与普通 Docker 网络上的容器相比,覆盖网络上的服务还有一个不同之处。你在第七章中看到,你可以使用 Docker Compose 来扩展并运行单个 Compose 服务的一个容器实例的多个实例。对该 Compose 服务的 Docker 进行 DNS 查询将返回所有容器的 IP 地址,并且它将依赖于消费者选择一个来发送流量。当你有一个 Swarm 服务中有数百个副本时,这并不容易扩展,因此覆盖网络采用不同的方法,并为服务返回一个单独的虚拟 IP 地址。
现在尝试一下。让我们从之前的练习中删除简单的应用程序,并为我们在前几章中使用过的 NASA 每日图像应用程序创建网络和 API 服务。
# 删除原始应用程序: docker service rm timecheck # 为新应用程序创建覆盖网络: docker network create --driver overlay iotd-net # 创建 API 服务,将其附加到网络: docker service create --detach --replicas 3 --network iotd-net --name iotd diamol/ch09-image-of-the-day # 以及日志 API,附加到同一网络: docker service create --detach --replicas 2 --network iotd-net --name accesslog diamol/ch09-access-log # 检查服务: docker service ls
现在你正在运行 NASA 每日图像 API 服务,并且这些服务附加到了覆盖网络上。正如你在图 12.13 的输出中可以看到的那样,有三个副本正在运行图像 API 服务,两个副本正在运行访问日志服务。这仍然是在我的单节点 Swarm 上使用 Docker Desktop 运行的,但我可以在有 500 个节点的 Swarm 上运行相同的命令集,输出将相同——只是副本将在不同的节点上运行。

图 12.13 在 Swarm 模式下运行服务并将它们连接到覆盖网络
要查看虚拟 IP 地址(这称为 VIP 网络),最简单的方法是连接到任何容器副本的终端会话。您可以通过运行一些网络命令来对服务名称执行 DNS 查询,并检查返回的 IP 地址。
现在尝试一下:在最新的容器中执行一个交互式终端会话,并运行 API 服务的 DNS 查找。对于 Linux 和 Windows 容器,前几个命令是不同的,但一旦您连接到容器中的终端,它们就是相同的:
# 运行终端会话 - Windows 容器: docker container exec -it $(docker container ls --last 1 -q) cmd # 或者 Linux 容器: docker container exec -it $(docker container ls --last 1 -q) sh # 运行 DNS 查找: nslookup iotd nslookup accesslog
您可以从图 12.14 中的我的输出中看到,每个服务都有一个单独的 IP 地址,即使有多个容器运行这些服务。服务 IP 地址是一个虚拟 IP 地址,它在所有副本之间共享。

图 12.14:服务使用 VIP 网络,因此无论有多少副本,都有一个单独的 IP 地址。
这是 VIP 网络,它在 Linux 和 Windows 上得到支持,是一种更有效的负载均衡网络流量的方式。DNS 查找只有一个 IP 地址,即使服务扩展或缩减,这个地址也保持不变。客户端将流量发送到该 IP 地址,操作系统的网络层发现实际上有多个地址目标,并决定使用哪一个。
Docker Swarm 使用 VIP 网络在服务之间提供可靠和负载均衡的访问。您只需要知道这一点,因为如果您正在尝试调试通信问题,这会很有用——否则,您可能会对具有许多副本的服务运行 DNS 查找,并惊讶地看到一个 IP 地址返回。作为 Swarm 服务运行的应用程序将像往常一样使用 DNS 名称,因此覆盖网络的复杂性完全被隐藏。
Swarm 模式采用简化复杂网络模式的方法来处理进入集群的流量。如果您考虑集群的规模和应用程序的规模,这是一个更复杂的问题。您可能有 10 个副本运行的 Web 应用程序。如果您的集群中有 20 个节点,一些节点没有运行您的任何 Web 容器,Swarm 需要将请求定向到运行容器的节点。如果您的集群中只有五个节点,每个节点将运行多个副本,Swarm 需要在节点上的容器之间进行负载均衡。Swarm 使用入口网络来处理这个问题——图 12.15 中的图显示了入口的工作方式,每个节点在外部监听相同的端口,Docker 在集群内部内部转发流量。

图 12.15 Docker Swarm 使用入口网络将流量路由到节点上的容器。
当您为服务发布端口时,入口网络是 Swarm 模式下的默认设置,所以它与 overlay 网络相同——这是一种复杂的技术,但使用起来却非常简单。您可以在创建服务时发布端口,这就是您需要做的全部事情来利用入口网络。
现在试试看图像库应用的最后一个组件就是网站本身。当您将其作为 Swarm 服务运行并发布端口时,它使用入口网络:
# 为应用创建 Web 前端: docker service create --detach --name image-gallery --network iotd-net --publish 8010:80 --replicas 2 diamol/ch09-image-gallery # 列出所有服务: docker service ls
现在您有一个具有多个副本的服务,监听单个端口。您无法使用 Docker Compose 做到这一点,因为您不能让多个容器都监听相同的端口,但在 Docker Swarm 中可以,因为它是使用入口网络监听端口的那个服务。当一个请求进入集群时,入口网络会将它发送到服务的一个副本,这个副本可能运行在接收请求的节点上,也可能是集群中的另一个节点。图 12.16 显示了运行着两个副本并已发布端口的该服务。

图 12.16 在入口网络中注册一个服务就像发布一个端口一样简单。
您可以浏览到该端口,并看到第四章中的 NASA 图像应用——除非您正在运行 Windows 容器。到目前为止,我设法避免了 Windows 和 Linux 读者之间任何大的差异,除了命令的细微差别,但这个问题是无法回避的。如果您正在运行 Linux 容器——在 Linux 机器上、Mac 上,或者在 Windows 10 上的 Linux 容器模式下——您可以直接浏览到 http://localhost:8010 来查看该应用。如果您正在运行 Windows 容器——无论是在 Windows Server 上还是在 Windows 10 的容器模式下——您无法这样做,因为 Swarm 服务无法通过 localhost 访问。
这是在 Windows 容器和 Linux 容器工作方式不完全相同的情况之一,这归因于 Windows 网络堆栈的限制。在实践中,这通常不是一个问题,因为您的 Swarm 集群将是测试或生产环境中的远程服务器,并且当您访问远程机器时,入口网络确实可以工作。但在您本地的单节点 Windows Swarm 上,您只能通过从不同的机器浏览来访问服务。我知道这并不好,但至少在我们遇到“Windows 上这很糟糕”的时刻之前,我们已经有 12 章了,而且我认为不会再有更多了。
我在本章中切换到了 Linux 容器,如图 12.17 所示,你可以看到 day 应用的镜像。我的网络请求被路由到 web 服务的两个副本之一,然后它会从 API 服务的三个副本之一获取数据。

图 12.17 服务中使用的发布端口使用 ingress 网络,Swarm 将请求路由到副本。
我在本章中已经说过,但再次强调——就部署和管理应用程序而言,集群的大小并不重要。我可以在运行在云中 50 个节点的集群上运行完全相同的命令,结果将会相同——两个我可以从任何节点访问的 web 服务副本,以及 web 容器可以在任何节点上访问的三个 API 服务副本。
12.5 理解 Docker Swarm 和 Kubernetes 之间的选择
Docker Swarm 被设计成一个简单的容器编排器。它从已经非常受欢迎的 Docker Compose 中吸取了网络和服务的概念,并将其构建成一个成为 Docker Engine 一部分的编排器。其他编排器作为商业或开源项目被发布,但其中大部分努力都已被搁置,现在选择就只剩下 Docker Swarm 和 Kubernetes。
Kubernetes 是更受欢迎的选择,因为它被所有主要公共云提供商作为托管服务提供。您只需从它们的 CLI 或在其网络门户上点击几个按钮,就可以在 Microsoft Azure、Amazon Web Services 或 Google Cloud 中启动一个多节点 Kubernetes 集群。它们负责初始化集群——这并不像 Docker Swarm 那样简单——以及管理作为节点的虚拟机。Kubernetes 易于扩展,因此云提供商可以将其与其其他产品(如负载均衡器和存储)集成,这使得部署功能齐全的应用程序变得容易。
Docker Swarm 不是云提供商提供的托管服务,部分原因是因为它包含的组件较少,因此更难与其他服务集成。如果您想在云中运行 Docker Swarm 集群,您将需要自行配置虚拟机并初始化 Swarm。所有这些都可以自动化,但并不像使用托管服务那样简单。图 12.18 显示了如果您想在 Azure 中运行 Docker Swarm 集群,您需要自行配置和管理的主要云资源。

图 12.18 展示了您需要管理的部分云资源,以运行一个生产级别的 Swarm。
然而,您部署集群的频率将低于部署应用程序的频率,对于持续运营,Docker Swarm 要简单得多。它没有 Kubernetes 的所有功能,但它拥有大多数组织所需的一切,而 Kubernetes 的复杂性却小得多。您发送到 Swarm 集群的 YAML 是 Docker Compose 语法的扩展,它简洁且逻辑性强。Kubernetes 的 YAML 规范要复杂得多,部分原因是它支持了额外的资源。这两个编排器最终都有运行 Docker 容器的任务,并且使用相同的 Docker 镜像,但 Kubernetes 应用程序定义的版本可以包含 5 到 10 倍的 YAML。
对于刚开始使用编排的新团队,我的建议是先从 Docker Swarm 开始,如果需要 Swarm 没有的功能,再转向 Kubernetes。您必须对您的应用程序进行一些投资才能将它们迁移到 Docker,但如果转向 Kubernetes,这些投资就不会浪费--您将运行来自相同镜像的容器。但这并不是一个简单的决定,您还需要考虑以下几个因素:
-
基础设施 -- 如果您要将应用程序部署到云端,Kubernetes 是一个更简单的选择,但如果您在数据中心,Swarm 则更容易管理。此外,如果您的团队背景是 100% Windows,您可以使用 Swarm 而无需承担 Linux 的负担。
-
学习曲线 -- 转向 Swarm 很简单,因为它是对您已经拥有的 Docker 和 Compose 体验的扩展。Kubernetes 是一套全新的学习内容,团队中并非每个人都会做出这种投资。
-
功能集 -- Kubernetes 的复杂性部分是由于它具有巨大的可配置性。您可以使用 Kubernetes 做 Swarm 中难以做到的事情,比如蓝/绿部署、自动服务扩展和基于角色的访问控制。
-
未来投资 -- Kubernetes 拥有最大的开源社区之一,并且非常活跃。不断有变化和新功能出现,而 Swarm 已经是一个稳定的产品,一段时间以来没有推出大型新功能。
最终,您的路线图可能会通过《在一个月的午餐时间学习 Kubernetes》带您走向 Kubernetes,但到达那里并不急迫。Swarm 是一个优秀的产品,它将向您介绍生产中的容器编排,并使运行工作负载变得容易,无论它们可能有多大。Visa 在 Docker 的会议上谈到过使用他们的 Swarm 集群来支持他们系统中所有的支付,包括黑色星期五的巨大峰值。
12.6 实验室
这次实验室相当简单,只是为了增加您使用作为 Docker Swarm 服务的应用程序的工作经验。我希望您在 Swarm 集群中运行第八章中的随机数应用程序。您需要两个服务和连接它们的网络,并且这些服务需要使用以下 Docker 镜像(这些镜像在 Docker Hub 上,因此您不需要自己构建它们):
-
diamol/ch08-numbers-api:v3 -
diamol/ch08-numbers-web:v3
我的解决方案在 GitHub 的常规位置,但只有几个命令,所以你实际上并不需要查找:github.com/sixeyed/diamol/blob/master/ch12/lab/README.md。
13 在 Docker Swarm 中作为堆栈部署分布式应用程序
我有一个坦白——在上一章中,我让您花了很多时间学习如何使用命令行创建 Docker Swarm 服务,但在实际项目中您永远不会这样做。这是一种开始编排并理解自己运行容器和由编排器为您管理它们之间的区别的有用方法。但在实际系统中,您不会连接到管理器并发送命令来运行服务。相反,您将在 YAML 文件中描述您的应用程序,并将该文件发送给管理器;然后它将决定采取哪些行动来使您的应用程序运行。这与您在 Docker Compose 中看到的相同期望状态方法——YAML 文件指定了您想要的最终状态,编排器查看当前正在运行的内容,并确定它需要做什么才能达到该状态。
Docker Swarm 和 Kubernetes 都使用相同的期望状态方法,但 YAML 语法不同。Swarm 使用 Docker Compose 语法来定义您应用程序的所有组件,当您将您的 YAML 发送到管理器时,它会创建网络、服务以及您声明的任何其他内容。Compose 格式非常适合描述用于集群部署的分布式应用程序,但有些概念仅在 Swarm 模式下有意义,有些则仅在单个服务器上有意义。该规范足够灵活,可以支持两者,在本章中,我们将基于您对 Docker Compose 和 Docker Swarm 的了解,在集群中运行分布式应用程序。
13.1 使用 Docker Compose 进行生产部署
Docker Swarm 的真正力量来自 Compose——您的生产部署使用与您在开发和测试环境中使用的相同文件格式,因此您的每个环境和每个项目的工件和工具都保持一致性。Swarm 的最简单部署与简单的 Compose 文件相同——列表 13.1 显示了第六章中待办事项应用程序的基本部署,它仅指定了镜像名称和要发布的端口。
列表 13.1 可以部署到 Swarm 的 Compose 文件
version: "3.7" services: todo-web: image: diamol/ch06-todo-list ports: - 8080:80
您可以使用 Docker Compose 在单个服务器上部署它,您将获得一个运行中的容器,并有一个已发布的端口来访问应用程序。您可以在 Swarm 上部署完全相同的文件,您将获得一个运行单个副本的服务,使用入口网络来发布端口。您通过创建一个堆栈来在 Swarm 模式下部署应用程序,这只是一个将许多其他资源(如服务、网络和卷)组合在一起的资源。
现在尝试一下 将这个简单的 Compose 文件作为堆栈部署。您需要初始化您的 Swarm 并切换到本章练习的文件夹。部署堆栈后,检查正在运行的内容:
cd ch13/exercises # 从 Compose 文件部署堆栈: docker stack deploy -c ./todo-list/v1.yml todo # 列出所有堆栈并查看新创建的堆栈: docker stack ls # 列出所有服务并查看由部署创建的服务: docker service ls
您可以从图 13.1 中的我的输出中看到,行为非常类似于 Docker Compose,尽管您使用标准的 Docker CLI 将服务部署到 Swarm 中。我将 Compose 文件发送到我的集群,然后管理器创建了一个默认网络以将服务插入其中,并为我的应用程序创建了一个服务。在 Swarm 模式下,堆栈是一个一等资源;您可以使用 CLI 创建、列出和删除它们。在这个练习中部署堆栈创建了一个单一的服务。

图 13.1 使用标准 Docker Compose 文件在 Swarm 模式下部署堆栈
如果您正在运行 Linux 容器,您可以通过访问 http: / / localhost:8080 来浏览应用程序,但如果您正在使用 Windows 容器,您仍然会遇到无法本地浏览到入口网络的问题,因此您需要从另一台机器上浏览。这个待办事项应用程序与之前一样工作,所以我们将跳过截图。从这个练习中您应该吸取的教训是,您已经使用了一个标准的 Docker Compose 文件,没有额外的配置来部署到 Swarm。如果您在 Swarm 中有多个节点,您将拥有高可用性--运行服务副本的节点可以离线,Swarm 将在另一个节点上启动一个替换来保持您的应用程序可用。
当然,Swarm 模式有一套额外的功能,您可以通过在您的 Compose 文件中的服务中添加一个deploy部分来在您的应用程序中使用它们。这些属性仅在您在集群中运行时才有意义,因此它们在部署堆栈时应用,但您可以使用相同的文件在单个服务器上使用 Docker Compose,并且deploy设置将被忽略。列表 13.2 显示了包含部署属性以运行多个副本并限制每个副本可以使用的计算资源的待办事项应用程序的更新服务定义。
列表 13.2 在您的 Docker Compose 文件中添加 Swarm 部署配置
services: todo-web: image: diamol/ch06-todo-list ports: - 8080:80 deploy: replicas: 2 resources: limits: cpus: "0.50" memory: 100M
这些是你希望在生产部署中包含的基本属性。运行多个副本意味着你的应用程序可以管理更多的负载,这也意味着如果一个副本因为服务器故障或服务更新而离线,另一个副本将可用于服务流量。你还应该在服务上线时为所有服务指定计算限制,以保护你的集群免受恶意副本消耗所有处理能力和内存的影响。确定限制需要一些努力,因为你需要知道应用程序在最繁忙时所需的 CPU 和内存量——第九章中看到的那些指标有助于这一点。在此应用程序规范中,资源限制将每个副本限制在一个 CPU 核心的最大 50% 和 100 MB 的内存。
将更新部署到 Swarm 堆栈与部署新应用程序相同——你将更新的 YAML 文件发送到管理器,它会为你进行更改。当你部署 v2 Compose 文件时,Swarm 将创建一个新的副本并替换现有的副本。
现在试试吧 使用新的 Compose 文件但原始堆栈名称运行 stack deploy 命令——这就像更新现有堆栈一样。列出服务任务,你会看到更新是如何发生的:
# 部署更新后的堆栈 Compose 文件 docker stack deploy -c ./todo-list/v2.yml todo # 检查所有 Web 服务的副本: docker service ps todo_todo-web
我的输出在图 13.2 中。你可以看到堆栈更新了服务,并且服务有两个新的副本。原始副本被替换,因为将资源限制添加到 Compose 文件中会更改容器定义,而这需要通过一个新的容器来执行。

图 13.2 使用新的 Compose 文件更新堆栈将更新服务,如果定义已更改。
如果你没有指定限制,Docker 容器可以访问主机机器的所有 CPU 和内存。这是默认设置,对于你希望在服务器上尽可能多地运行应用程序的非生产环境来说,这是可以接受的。然而,在生产环境中,你希望限制来保护系统免受糟糕的代码或恶意用户试图耗尽系统资源的影响,但这些限制是在容器启动时建立的,所以如果你更新它们,你会得到一个新的容器,这在 Swarm 模式下是一个副本更新。
Swarm 堆栈是一种将应用程序分组的好方法,因为你通常需要在一个集群中运行许多应用程序。你可以使用 Docker CLI 中的 stack 命令来整体管理应用程序,列出单个服务及其副本,或者完全删除应用程序。
现在试试吧 堆栈是应用程序的管理单元。它们为你提供了一个简单的方式来处理可能运行多个服务(每个服务可能有多个副本)的应用程序。检查待办事项应用程序堆栈中正在运行的内容,然后将其删除:
# 列出堆栈中的所有服务: docker stack services todo # 列出堆栈中所有服务的所有副本: docker stack ps todo # 删除堆栈: docker stack rm todo
这个应用程序是一个带有 Docker 网络、一个服务和两个副本的非常简单的示例。更大的分布式应用程序可以在 Swarm 中运行数十个服务,跨越数百个副本,并且您仍然可以使用 Compose 文件以相同的方式部署它们,并使用docker stack命令来管理它们。图 13.3 显示了我的输出,最终删除了整个堆栈。

图 13.3 使用 Docker CLI 与堆栈一起工作——您可以列出资源并删除它们。
您可以管理堆栈中的所有资源,而无需需要 Compose 文件,因为所有规范都存储在集群数据库中。该共享数据库在 Swarm 管理器之间进行复制,因此它是一个安全的地方来存储其他资源。这就是您在 Swarm 中存储应用程序配置文件的方式,您可以在 Compose 文件中将这些文件提供给服务。
13.2 使用配置对象管理应用程序配置
在容器中运行的应用程序需要能够从运行容器的平台加载其配置设置。我使用带有环境变量的 Docker Compose 在本地开发和测试环境中解决了这个问题。现在我们可以通过使用存储在集群中的 Docker 配置对象来完善生产环境。图 13.4 展示了它是如何工作的,这里重要的是每个环境中都是相同的 Docker 镜像。只是应用程序的行为发生了变化。

图 13.4 从平台应用配置;Swarm 模式使用配置对象和秘密。
配置是部署如此关键的一部分,所有编排器都有一个一等资源来保存应用程序配置。在 Swarm 中,这些是 Docker 配置对象。它们很强大,因为它们允许容器从集群加载其配置,但它们也解耦了应用程序部署与配置管理的作用。
组织通常有一个配置管理团队,可以访问所有秘密——API 密钥、数据库服务器密码、SSL 证书——并且这些秘密都存储在一个安全系统中。该系统通常与运行应用程序的环境完全分开,因此团队需要一种方法将中央系统中的配置应用到应用程序平台上。Docker Swarm 通过一种特定的资源类型——配置对象——支持这种工作流程,您可以从现有的配置文件中将这些对象加载到集群中。
现在尝试一下 待办事项应用程序使用 JSON 进行配置。镜像中的默认配置使用本地数据库文件进行存储,但如果你运行许多副本则不起作用--每个容器将有自己的数据库,并且用户将根据其请求的副本服务看到不同的列表。修复此问题的第一步是在集群中部署新的配置文件:
# 从本地 JSON 文件创建配置对象: docker config create todo-list-config ./todo-list/configs/config.json # 检查集群中的配置: docker config ls
配置对象通过名称和配置文件内容的路径来创建。此应用程序使用 JSON,但配置对象可以存储任何类型的数据--XML、键/值对,甚至是二进制文件。Swarm 将配置对象作为容器文件系统中的文件交付,因此应用程序看到的是你上传的确切相同数据。图 13.5 显示了我的输出--配置对象除了名称外还创建了一个长随机 ID。

图 13.5 将本地文件加载到 Swarm 集群作为配置对象
你可以像处理其他 Docker 资源一样处理配置对象--有命令可以删除、检查以及创建它们。检查是有用的,因为它显示了配置文件的内容。这是关于配置对象的一个重要观点:它们不是为敏感数据设计的。文件内容在 Swarm 数据库中未加密,在从管理器移动到运行副本的节点时也不会在传输过程中加密。
现在尝试一下 你可以检查配置对象以读取其完整内容。这显示了当副本使用配置对象时在容器文件系统中将看到的内容:
# 使用 pretty 标志检查配置以显示内容: docker config inspect --pretty todo-list-config
图 13.6 显示了我的输出,其中包含有关配置对象和文件内容的所有元数据,包括空白字符。

图 13.6 配置对象不安全--任何有权访问集群的人都可以看到内容。
管理配置对象是管理使用这些配置对象的应用程序的工作流程之外的独立流程。在 DevOps 工作流程中,所有这些都可以由同一个团队或一个自动化的管道来完成,但在大型企业中,如果这与你的现有流程相匹配,你可以保持功能分离。
服务通过在 Compose 文件中指定配置对象来消费配置对象。列表 13.3 显示了待办事项应用程序更新定义的一部分(完整文件名为 v3.yml),该应用程序从配置对象中加载配置。
列表 13.3 服务中的配置对象在容器文件系统中被暴露
services: todo-web: image: diamol/ch06-todo-list ports: - 8080:80 configs: - source: todo-list-config target: /app/config/config.json #... configs: todo-list-config: external: true
当一个容器作为此服务的副本运行时,它将从 Swarm 加载配置对象的全部内容到 /app/config/config.json 文件中,这是应用程序用作配置源之一的路径。你可以使用更短的语法,只需指定配置对象的名字,Docker 就会使用默认的目标路径,但实际路径因不同的操作系统而异,所以最好明确指出你希望文件出现在哪个位置。(正斜杠目录路径在 Windows 和 Linux 容器中都有效。)
列表 13.3 中的 Compose 文件的第二部分显示了配置对象本身,包括其名称和 external 标志。external 是你指定此资源应该在集群中已经存在的方式。部署工作流程是首先部署配置对象,然后部署使用它们的应用程序。你可以通过部署 v3 Compose 文件来实现,它还包括一个用于 SQL 数据库的服务,这样多个 Web 容器就可以共享同一个数据库。
现在就试试吧!通过部署 YAML 文件来更新应用程序——stack 命令是一样的。Swarm 将为数据库服务创建一个新的副本,并为 Web 应用程序创建新的副本:
# 部署更新的应用程序定义: docker stack deploy -c ./todo-list/v3.yml todo # 列出堆栈中的服务: docker stack services todo
在之前的练习中,你已经移除了旧的堆栈,所以这是一个新的部署。你会看到创建了一个网络和两个服务。我已经将 Web 组件的副本数减少到单个,这样我们就可以更容易地跟踪更新;现在每个服务都在运行单个副本。我的输出在图 13.7 中。

现在应用程序已配置为使用 Postgres 作为数据库,这是配置对象加载到副本中的设置。如果你浏览到 http: / / localhost:8080(或者如果你在 Windows 上,从另一台机器访问你的机器),你会看到应用程序没有工作。你可以检查 Web 服务的日志来查看原因,并且它将显示许多关于连接到数据库的错误。这次部署配置了 Web 应用程序使用 Postgres,但配置对象没有提供数据库的连接细节,所以连接失败——我们将在下一个练习中修复这个问题。
敏感数据不应存储在配置对象中,因为它们未加密,并且任何有权访问集群的人都可以读取。这包括可能包含用户名和密码的数据库连接字符串,以及生产服务的 URL 和 API 密钥。您应该在生产环境中追求深度防御,即使有人访问您的集群的机会很小,您也应该在集群内部加密敏感数据。Docker Swarm 提供密钥来存储此类配置。
13.3 使用密钥管理机密设置
密钥是 Swarm 中由集群管理的一种资源,它们几乎与配置对象完全相同。您可以从本地文件创建密钥,并将其存储在集群数据库中。然后,在服务规范中引用密钥,密钥的内容在运行时被加载到容器文件系统中。与密钥的关键区别在于,您只能在工作流程中的一个点上以纯文本形式读取它们:在容器内部,当它们从 Swarm 加载时。
密钥在其在集群中的整个生命周期内都是加密的。数据以加密形式存储在由管理器共享的数据库中,并且只有计划运行需要密钥的副本的节点才会接收密钥。密钥在从管理节点到工作节点的传输过程中加密,并且只有在容器内部才会解密,在那里它们会以原始文件内容的形式出现。我们将使用密钥来存储待办事项应用的数据库连接字符串。
现在尝试一下:从本地文件创建密钥,然后检查它以查看 Docker 提供的关于密钥的信息:
# 从本地 JSON 文件创建密钥: docker secret create todo-list-secret ./todo-list/secrets/secrets.json # 使用 pretty 标志检查密钥以查看数据: docker secret inspect --pretty todo-list-secret
使用密钥的用户体验与配置对象相同。唯一的区别是,一旦密钥被存储,您就无法读取其内容。您可以在图 13.8 中看到我的输出--检查密钥仅显示有关资源的元数据,而不是实际数据,如果您查看的是配置对象,您会看到这些数据。

图 13.8 一旦密钥存储在 Swarm 中,您就无法读取原始未加密的内容。
现在,密钥已存储在 Swarm 中,我们可以使用包含密钥的服务规范部署应用的新版本。密钥的 Compose 语法与配置对象非常相似;您在服务定义中指定密钥的源和目标路径,然后密钥本身获得其自己的定义。列表 13.4 显示了新部署的关键部分,这些部分位于 v4.yml 文件中。
列表 13.4 指定应用配置的密钥和配置
services: todo-web: image: diamol/ch06-todo-list ports: - 8080:80 configs: - source: todo-list-config target: /app/config/config.json secrets: - source: todo-list-secret target: /app/config/secrets.json #... secrets: todo-list-secret:
那个秘密的内容是更多的 JSON,加载到另一个路径,应用程序会在这里查找配置源。这设置了应用程序使用 Postgres 容器作为其数据存储的连接细节,因此当你部署应用程序时,无论哪个 Web 副本提供服务,用户都会得到相同的物品列表。
现在尝试一下 部署应用程序的最新版本,它提供了缺少的数据库连接字符串并修复了 Web 应用程序。这将更新服务。
# 部署应用程序的新版本: docker stack deploy -c ./todo-list/v4.yml todo # 检查堆栈的副本: docker stack ps todo
只有在 Compose 文件中的 Web 服务定义已更改,但当你运行它时,你会看到 Docker 状态更新了两个服务。实际上,它并没有对数据库服务进行任何更新,所以这是 CLI 的一个稍微误导性的输出--它将列出 Compose 文件中的所有服务作为“更新中”,即使它们不会全部更改。你可以在图 13.9 的输出中看到这一点。

图 13.9 部署最新应用程序版本将纠正配置并修复应用程序。
现在,应用程序正在正常工作,如果你从远程机器(如果你使用 Windows 容器)或 localhost(如果你使用 Linux 容器)浏览到端口 8080,你会看到这一点。图 13.10 显示了基础设施设置,容器在 Docker 网络上连接,并且从 Swarm 加载了秘密。
图 13.10 中缺少的重要东西是硬件视图,这是因为这个应用程序在任何大小的 Swarm 上都有相同的部署架构。秘密和配置对象存储在管理员的分布式数据库中,并且对每个节点都是可用的。堆栈创建了一个覆盖网络,以便容器可以在它们运行的任何节点上相互连接,并且服务使用入口网络,以便消费者可以向任何节点发送流量,并由其中一个 Web 副本执行操作。

图 13.10 使用堆栈运行的任务应用程序使用了 Docker Swarm 的关键特性。
关于配置对象和秘密,你需要理解的一件事是:它们不能被更新。当你集群中创建它们时,内容总是相同的,如果你需要更新应用程序的配置,你需要替换它。这将涉及三个步骤:
-
使用更新的内容和一个不同于上一个对象的名称创建一个新的配置对象或秘密。
-
更新 Compose 文件中应用程序使用的配置对象或秘密的名称,并指定新名称。
-
从更新的 Compose 文件部署堆栈。
这个过程意味着每次你更改配置时都需要更新你的服务,这意味着正在运行的容器将被新的容器替换。这是编排器采取不同方法的一个领域——Kubernetes 允许你在集群中更新现有的配置和秘密对象。但这也会带来自己的问题,因为一些应用平台会监视它们的配置文件以查找更改,而其他平台则不会,因此更改可能会被忽略,你仍然需要替换容器。Swarm 是一致的——当你推出配置更改时,你总是需要更新你的服务。
尽管如此,更新服务不应该让你感到害怕。每次你有新功能要部署到你的应用中,或者当你在使用的依赖项或基于你镜像的操作系统中有安全更新时,你都会推出容器更新。至少,你应该预计每个月发布一次更新,这是大多数基于操作系统的镜像在 Docker Hub 上更新的频率。
这就引出了在 Swarm 模式下的有状态应用。你将定期替换容器,因此你需要使用 Docker 卷来持久化存储,而在 Swarm 中卷的工作方式略有不同。
13.4 在 Swarm 中使用卷存储数据
我们在第六章中就介绍了 Docker 卷——它们是具有独立生命周期的存储单元。任何你想容器化的有状态应用都可以使用卷进行存储。卷作为容器文件系统的一部分出现,但实际上它们存储在容器之外。应用升级会替换容器并将卷附加到新容器上,因此新容器启动时将具有前一个容器所有的数据。
在编排器中,卷的概念上是相同的;你在 Compose 文件中为服务添加卷挂载规范,副本将把该卷视为本地目录。然而,数据存储的方式有很大不同,这是你需要理解以确保你的应用按预期工作的事情。在一个集群中,你将有多节点可以运行容器,每个节点都有自己的磁盘,用于存储本地卷。在更新之间保持状态的最简单方法是使用本地卷。
然而,这种方法存在一个问题——替换副本可能被调度在原始节点之外的其他节点上运行,因此它将无法访问原始节点上的数据。你可以将服务固定到特定的节点上,这意味着更新将始终在具有数据的节点上运行。这对于你希望在容器外部存储应用数据以使其在更新中存活,但不需要运行多个副本且不需要允许服务器故障的场景有效。你给你的节点应用一个标签,并在你的 Compose 文件中限制副本只在那个节点上运行。
现在试试看 你有一个单节点 Swarm,所以每个副本无论如何都会在这个节点上运行,但标签过程对于多节点 Swarm 也是相同的。标签可以是任何键/值对;我们将使用这个来分配一个虚构的存储类:
# 找到你的节点 ID 并更新它,添加一个标签: docker node update --label-add storage=raid $(docker node ls -q)
该命令的输出只是节点 ID,所以我们省略了截图。更有趣的是,你现在有了一种识别集群中节点的方法,这可以用来约束服务副本的调度位置。列表 13.5 显示了待办数据库服务定义中的 constraint 字段,该数据库现在也指定了卷--这位于 v5.yml 部署文件中。
列表 13.5 配置 Swarm 中服务的约束和卷
services: todo-db: image: diamol/postgres:11.5 volumes: - todo-db-data:/var/lib/postgresql/data deploy: placement: constraints: - node.labels.storage == raid #... volumes: todo-db-data:
我没有在列表末尾的 Compose 文件中缩减卷的指定部分--卷名就是所有内容。这个卷将使用 Swarm 的默认卷驱动程序创建,该驱动程序使用本地磁盘。当你将此部署到你的集群时,它将确保数据库副本在匹配存储标签的节点上运行,并且该节点将创建一个名为 todo-db-data 的本地卷,数据文件将存储在这里。
现在试试看 Compose 文件中的约束与你在 Swarm 节点上添加的标签相匹配,所以数据库容器将在这里运行并使用该节点上的本地卷。这些命令将在部署前后探索你节点上的卷:
# 列出你节点上的所有卷,只显示 ID: docker volume ls -q # 更新堆栈到 v5 - 对于 Linux 容器: docker stack deploy -c ./todo-list/v5.yml todo # 或者使用 Windows 容器,使用 Windows 风格的路径来指定卷: docker stack deploy -c ./todo-list/v5-windows.yml todo # 再次检查卷: docker volume ls -q
你会看到有很多卷(你可能比我多得多;我在这些练习之前用 docker volume prune 命令清除了我的卷)。镜像可以在 Dockerfile 中指定卷,如果服务使用带有卷的镜像,堆栈会为服务创建一个默认卷。这个卷的寿命与堆栈相同,所以如果你删除了堆栈,卷也会被删除,如果你更新了服务,它们将获得一个新的默认卷。如果你想使数据在更新之间持久化,你需要在 Compose 文件中使用一个命名的卷。你可以在图 13.11 中看到我的输出;部署堆栈创建了一个新的命名卷而不是默认卷。

图 13.11 部署堆栈也会创建卷,这些卷可以是匿名的或命名的。
如果标记的节点本身可用,这种部署提供了数据可用性的保证。如果容器失败其健康检查并被替换,新的副本将在与先前副本相同的节点上运行,并附加到相同的命名卷。当你更新数据库服务规范时,你将获得相同的保证。这意味着数据库文件在容器之间持久化,你的数据是安全的。你可以通过 Web UI 添加项目到待办事项列表,升级数据库服务,并发现旧数据仍然在新数据库容器中的 UI 中。
现在试试看。自从我写第六章以来,Postgres 服务器已经发布了一个新版本,保持最新是个好主意,所以我们将更新数据库服务。v6.yml中的 Compose 规范与v5.yml相同,除了它使用了更新的 Postgres 版本:
# 部署更新的数据库 - 对于 Linux 容器: docker stack deploy -c ./todo-list/v6.yml todo # 或者对于 Windows 容器: docker stack deploy -c ./todo-list/v6-windows.yml todo # 检查堆栈中的任务: docker stack ps todo # 并检查卷: docker volume ls -q
你可以在图 13.12 中看到我的输出。新的数据库副本是从更新的 Docker 镜像运行的,但它附加到来自先前副本的卷,所以我的所有数据都得到了保留。

图 13.12 更新使用命名卷的服务可以保留新容器的数据。
这是一个简单的例子,当你为你的应用程序有不同的存储需求时,事情会变得更加复杂,因为本地卷中的数据不会在所有节点之间复制。使用磁盘作为数据缓存的程序对本地卷可以很好地工作,因为每个副本的数据可以不同,但这对于需要在整个集群中访问共享状态的程序来说是不行的。Docker 有一个用于卷驱动程序的插件系统,因此 Swarm 可以被配置为使用云存储系统或数据中心中的存储设备来提供分布式存储。配置这些卷取决于你使用的基础设施,但你可以以相同的方式消费它们,将卷附加到服务。
13.5 理解集群如何管理堆栈
Docker Swarm 中的堆栈只是集群为你管理的资源组。一个生产堆栈将包含许多资源,它们在编排器管理它们的方式上略有不同。图 13.13 显示了 Swarm 如何管理典型类型的资源。

图 13.13 Docker Swarm 资源如何通过堆栈部署进行管理
从这个例子中我们可以得到一些启示。你已经在练习中处理了一些这些场景,但我们将完成这一章,使它们变得清晰:
-
Swarm 可以创建和删除卷。如果服务镜像指定了默认卷,堆栈将创建一个默认卷,并且当堆栈被删除时,该卷将被删除。如果您为堆栈指定了命名卷,则在部署时将创建它,但在删除堆栈时不会删除它。
-
当外部文件上传到集群时,会创建机密和配置。它们存储在集群数据库中,并交付到需要它们的容器中。它们是实际上写一次读多次的对象,并且不能更新。在 Swarm 中存储应用程序配置的管理员过程与应用程序部署过程是分开的。
-
网络可以独立于应用程序进行管理,管理员可以明确为应用程序创建网络,或者由 Swarm 管理,Swarm 将在必要时创建和删除它们。每个堆栈都将部署一个网络以附加服务,即使配置文件中没有指定也是如此。
-
当堆栈部署时,会创建或删除服务,并且在它们运行时,Swarm 会持续监控它们以确保达到期望的服务级别。失败健康检查的副本将被替换,当节点离线时丢失的副本也是如此。
堆栈是由组成应用程序的组件的逻辑组,但它不会映射出服务之间的依赖关系图。当您将堆栈部署到集群时,管理器将在集群中尽可能快地启动尽可能多的服务副本。您不能限制集群在启动另一个服务之前完全启动一个服务,如果可以,这可能会破坏部署性能。相反,您需要假设您的组件将以随机顺序启动,并在您的镜像中捕获健康和依赖性检查,以便如果应用程序无法运行,容器可以快速失败。这样,集群可以通过重启或替换容器来修复损坏,从而实现自我修复的应用程序。
13.6 实验室
实验室!这个实验将让您获得更多编写 Compose 文件以定义应用程序并将其作为堆栈在 Swarm 上部署的经验。我希望您为第九章中的图像库应用程序编写一个生产部署,该部署应在一个符合这些要求的单个 Compose 文件中:
-
访问日志 API 使用镜像
diamol/ch09-access-log。它是一个仅由 Web 应用程序访问的内部组件,并且应该在三个副本上运行。 -
NASA API 使用镜像
diamol/ch09-image-of-the-day。它应该在端口 8088 上公开访问,并运行在五个副本上以支持预期的入站负载。 -
Web 应用程序使用镜像
diamol/ch09-image-gallery。它应该在标准的 HTTP 端口 80 上可用,并运行在两个副本上。 -
所有组件都应该有合理的 CPU 和内存限制(这可能需要几轮部署来确定安全最大值)。
-
当您部署堆栈时,应用程序应该能够运行。
使用此应用无需担心卷积、配置或秘密,因此它应该是一个相当简单的 Compose 文件。一如既往,您可以在 GitHub 上找到我的解决方案作为参考:github.com/sixeyed/diamol/blob/master/ch13/lab/README.md .
14 自动化升级和回滚的发布
更新容器化应用程序应该是一个零停机时间的过程,由容器编排器管理。你通常在集群中有额外的计算能力,管理者可以使用这些能力在更新期间安排新的容器,并且你的容器镜像有健康检查,这样集群就知道新组件是否失败。这些都是零停机更新所必需的,你已经在第十三章中通过 Docker Swarm 堆栈部署的过程进行了这个过程。更新过程高度可配置,我们将在本章中花时间探索配置选项。
调整更新配置可能听起来像是一个你可以安全跳过的主题,但根据我自己的经验,如果你不了解如何进行滚动发布以及如何修改默认行为,这将会给你带来痛苦。本章专注于 Docker Swarm,但所有编排器都有一个分阶段滚动发布过程,其工作方式类似。了解更新和回滚的工作原理让你能够尝试找到适合你应用程序的设置,这样你就可以频繁地将应用程序部署到生产环境中,有信心更新要么成功执行,要么自动回滚到上一个版本。
14.1 使用 Docker 的应用程序升级过程
Docker 镜像是一种表面上简单的打包格式。你构建你的镜像,在容器中运行你的应用程序,感觉上你可以让它一直运行,直到你有新版本的应用程序要部署,但你需要考虑至少四种部署周期。首先是你的应用程序及其依赖项,然后是编译你的应用程序的 SDK,然后是运行应用程序的平台,最后是操作系统本身。图 14.1 显示了一个为 Linux 构建的.NET Core 应用程序的示例,实际上有六个更新周期。

图 14.1 当你包含你使用的其他镜像时,你的 Docker 镜像有很多依赖项。
你可以看到,你确实应该计划每月部署更新,以覆盖操作系统更新,并且你应该能够随时启动一个临时的部署,以覆盖你应用程序使用的库中的安全修复。这就是为什么你的构建管道是项目的核心。每次源代码发生变化时,你的管道都应该运行——这将处理新应用程序功能和应用程序依赖项的手动更新。它还应该每晚构建,确保你总是有一个基于最新 SDK、应用程序平台和操作系统更新的可发布镜像。
无论应用程序是否发生变化,每月发布一次听起来可能很可怕,尤其是在发布仪式在时间和资源方面成本极高的组织中,你一年只做三次。但这种方法能让整个组织拥有更健康的心态:发布更新是一件无聊的事情,它经常发生,通常不需要任何人的参与。当你有定期的自动化发布时,每次更新都会增强对流程的信心,而且在你意识到之前,你就可以在完成新功能后立即发布,而不是等待下一个部署窗口。
只有在发布成功时,你才能获得这种信心,这就是为什么应用程序健康检查变得至关重要的原因。没有它们,你没有一个自我修复的应用程序,这意味着你不能进行安全更新和回滚。我们将通过本章中第八章的随机数应用来探讨这一点,利用你在第十章中学到的 Docker Compose 覆盖功能。这将使我们能够保持一个干净的 Compose 文件,其中包含核心应用程序定义,一个单独的 Compose 文件用于生产规范,以及用于更新的附加文件。但是,Docker 不支持从多个 Compose 文件中进行堆栈部署,所以首先你需要使用 Docker Compose 将覆盖文件合并在一起。
现在试试吧!让我们先部署随机数应用的第一个版本。我们将运行一个单独的 Web 容器和六个 API 容器的副本,这将帮助我们了解更新是如何分阶段实施的。你需要以 Swarm 模式运行;然后合并一些 Compose 文件并部署堆栈:
cd ch14/exercises # 将核心 Compose 文件与生产覆盖文件合并: docker-compose -f ./numbers/docker-compose.yml -f ./numbers/prod.yml config > stack.yml # 部署合并后的 Compose 文件: docker stack deploy -c stack.yml numbers # 显示堆栈中的服务: docker stack services numbers
你可以在图 14.2 中看到我的输出--Docker Compose 命令将核心 Compose 文件与生产覆盖文件合并。使用 Docker Compose 合并覆盖文件很有用,因为它还验证了内容,这可能是持续部署管道的一部分。堆栈部署创建了一个覆盖网络和两个服务。

图 14.2 通过首先合并它们来从多个 Compose 文件部署堆栈
图 14.2 中您看到的堆栈有一个新特点--API 服务以正常复制模式运行,但 Web 服务以全局模式运行。全局服务在每个 Swarm 节点上运行一个副本,您可以使用此配置来绕过入口网络。在某些场景中,如反向代理,这是一个好的部署选项,但我在这里使用它,以便您可以看到它与复制服务在推出时的不同。Web 服务的设置在列表 14.1 中(这是 prod.yml 覆盖文件的摘录)。
列表 14.1 使用主机网络而不是入口网络的全球服务
numbers-web: ports: - target: 80 published: 80 mode: host deploy: mode: global
在此新配置中,有两个字段用于配置全局服务:
-
mode:global-- 在deploy部分的此设置配置部署在 Swarm 的每个节点上运行一个容器。副本的数量将等于节点的数量,如果有任何节点加入,它们也将为服务运行一个容器。 -
mode:host-- 在ports部分的此设置配置服务直接绑定到主机的 80 端口,而不使用入口网络。如果您的 Web 应用程序足够轻量级,只需要每个节点一个副本,并且网络性能至关重要,您不想在入口网络中引入路由开销,这可以是一个有用的模式。
此部署使用原始应用程序镜像,这些镜像没有任何健康检查,并且这是 API 存在错误的那个应用程序,几次调用后就会停止工作。您可以通过 http: / / localhost(或从具有 Windows 容器的外部机器)浏览,并且可以请求大量的随机数字,因为调用在六个 API 服务副本之间进行负载均衡。最终它们都会崩溃,然后应用程序将停止工作并且永远不会自行修复--集群不会替换容器,因为它不知道它们是不健康的。这并不是一个安全的位置,因为如果您在没有健康检查的情况下推出更新版本,集群也无法知道更新是否成功。
因此,我们将继续部署应用程序镜像的版本 2,该版本内置了健康检查。v2 Compose 覆盖文件使用 v2 镜像标签,还有一个覆盖配置,用于设置健康检查的触发频率和触发纠正操作的失败次数。这位于正常的 healthcheck 块中,它在 Docker Compose 中的工作方式与之前相同,但 Compose 不会为您执行纠正操作。当此版本的应用程序部署到 Docker Swarm 时,集群将修复 API。当您破坏 API 容器时,它们将失败健康检查并被替换,然后应用程序将再次开始工作。
现在试试看。您需要将新的 v2 Compose 覆盖文件与健康检查和生产覆盖文件一起加入,以获取您的堆栈部署 YAML 文件。然后您只需再次部署堆栈即可:
# 将健康检查和 v2 覆盖文件添加到之前的文件中: docker-compose -f ./numbers/docker-compose.yml -f ./numbers/prod.yml -f ./numbers/prod-healthcheck.yml -f ./numbers/v2.yml --log-level ERROR config > stack.yml # 更新堆栈: docker stack deploy -c stack.yml numbers # 检查堆栈的副本: docker stack ps numbers
此部署更新了 Web 和 API 服务到其镜像的版本 2。服务更新始终作为分阶段滚动发布完成,默认情况下是在启动新容器之前停止现有容器。这对于使用主机模式端口的全局服务来说是有意义的,因为新容器无法启动,直到旧容器退出并释放端口。如果您的应用程序期望最大扩展级别,这也可能对复制服务有意义,但您需要意识到,在更新期间,服务将在旧容器关闭和替换启动时处于容量不足状态。您可以在图 14.3 中看到这种行为。

图 14.3 使用默认配置部署服务更新--每次只更新一个副本。
Docker Swarm 在服务更新的滚动发布中使用了谨慎的默认设置。它一次更新一个副本,并在移动到下一个副本之前确保容器正确启动。服务通过在启动替换容器之前停止现有容器来滚动发布,如果更新失败因为新容器无法正确启动,则滚动发布会暂停。当以权威的语气在书中呈现时,这似乎都是合理的,但实际上它相当奇怪。为什么在不知道新容器是否可以正常工作的情况下,默认先删除旧容器再启动新容器?为什么暂停失败的滚动发布,这可能会让您留下一个半损坏的系统,而不是自动回滚?幸运的是,滚动发布可以通过更合理的选项进行配置。
14.2 使用 Compose 配置生产滚动发布
随机数应用程序的版本 2 由于健康检查而具有自我修复功能。如果您通过 Web UI 请求大量随机数,API 副本都会崩溃,但等待大约 20 秒后,Swarm 会替换它们,应用程序将重新开始工作。这是一个极端的例子,但在实际应用程序偶尔出现故障的情况下,您可以看到集群如何根据健康检查监控容器并保持应用程序在线。
版本 2 的滚动发布使用了默认的更新配置,但我想 API 的滚动发布更快更安全。这种行为在 Compose 文件中服务的 deploy 部分被设置。列表 14.2 显示了我想要应用于 API 服务的 update_config 部分(这是 prod-update-config.yml 文件的一个摘录)。
列表 14.2 指定应用程序部署的自定义配置
numbers-api: deploy: update_config: parallelism: 3 monitor: 60s failure_action: rollback order: start-first
更新配置部分的四个属性会改变部署流程的工作方式:
-
parallelism表示并行替换的副本数量。默认值为 1,因此更新是逐个容器进行部署。这里显示的设置将同时更新三个容器。这会加快部署速度,并增加发现失败的机会,因为运行的新副本数量更多。 -
monitor表示 Swarm 在继续部署之前等待监控新副本的时间段。默认值为 0,如果你的镜像有健康检查,你绝对需要更改这个设置,因为 Swarm 将会监控健康检查这个时间段。这增加了部署的信心。 -
failure_action表示在monitor期间容器无法启动或健康检查失败时采取的操作。默认操作是暂停部署;我在这里将其设置为自动回滚到上一个版本。 -
order表示替换副本的顺序。默认为stop-first,这确保运行中的副本数量永远不会超过所需数量,但如果你的应用程序可以处理额外的副本,则start-first更好,因为新副本会在旧副本被移除之前创建和检查。
这种设置通常是大多数应用程序的良好实践,但你可能需要根据自己使用情况进行调整。并行度可以设置为全副本数量的约 30%,以便更新发生得更快,但你应该有一个足够长的监控时间段来运行多个健康检查,这样下一组任务只有在前一次更新成功后才会更新。
有一个重要的事情需要理解:当你向堆栈部署更改时,首先应用更新配置。然后,如果你的部署还包括服务更新,部署将使用新的更新配置进行。
现在尝试一下。下一次部署将设置更新配置并将服务更新到标签 v3。副本部署将使用新的更新配置:
docker-compose -f ./numbers/docker-compose.yml -f ./numbers/prod.yml -f ./numbers/prod-healthcheck.yml -f ./numbers/prod-update-config.yml -f ./numbers/v3.yml --log-level ERROR config > stack.yml docker stack deploy -c stack.yml numbers docker stack ps numbers
当您进行几次更新后,您会发现stack ps的副本列表变得难以管理。它显示了每个部署的所有副本,因此原始容器和已更新的 v2 容器以及新的 v3 副本都会显示出来。我在图 14.4 中裁剪了我的输出,但如果您在您的输出中向下滚动,您会看到 API 服务的三个副本已被更新,并在下一个集合更新之前正在被监控。

图 14.4 更新堆栈的新更新配置--滚动设置立即生效。
有一种更整洁的方式来报告 Swarm 服务,该服务可以识别服务规范、更新配置和最新的更新状态。这是使用带有pretty标志的inspect命令。由堆栈创建的服务使用命名约定{stack-name}_{service-name},因此您可以直接处理堆栈服务。
现在尝试一下 检查随机数 API 服务以查看更新状态:
docker service inspect --pretty numbers_numbers-api
您可以在图 14.5 中看到我的输出。我已经再次进行了裁剪,只显示主要的信息部分,但如果您在输出中滚动,您也会看到健康检查配置、资源限制和更新配置。

图 14.5 检查服务显示了当前配置和最新的更新状态。
当您更改默认更新配置设置时,您需要注意的一个重要事项是,您需要将这些设置包含在每次后续部署中。我的 v3 部署添加了自定义设置,但如果我不在下一个部署中包含相同的更新覆盖文件,Docker 会将服务回滚到默认更新设置。Swarm 首先更改更新配置,因此它会将更新配置重置为默认值,然后逐个副本推出下一个版本。
Swarm 滚动更新的更新配置设置有一个相同的集合适用于回滚,因此您也可以配置每次同时更新多少个副本以及在每个集合之间等待多长时间以进行自动回滚。这些可能看起来像是微调,但对于生产部署来说,指定更新和回滚过程并使用您的应用程序进行规模测试是非常重要的。您需要确信您可以在任何时间推出更新,并且它将快速应用,但在过程中有足够的检查以便在出现问题时自动回滚。您可以通过使用这些配置设置处理故障场景来获得这种信心。
14.3 配置服务回滚
没有名为 docker stack rollback 的命令;只有单个服务可以被回滚到之前的状态。除非出了严重错误,否则你通常不需要手动启动服务回滚。当集群执行滚动更新并识别到新副本在监控期间失败时,回滚应该自动发生。如果发生这种情况,并且你的配置正确,你直到疑惑为什么新功能没有显示出来时才会意识到回滚已经发生。
应用部署是导致停机时间的主要原因,因为即使一切自动化,仍然有人员编写自动化脚本和应用程序 YAML 文件,有时事情会被遗忘。我们可以通过随机数应用来体验这一点——一个新版本已经准备好部署,但它有一个必须设置的配置选项。如果没有设置,API 将会立即失败。
现在试试看。运行随机数应用的 v5 版本(v4 是我们在第十一章中用来演示持续集成的版本,但它使用了与 v3 相同的代码)。这次部署将会失败,因为 v5 需要的配置设置没有在 Compose 文件中提供:
# 将多个 Compose 文件合并 docker-compose -f ./numbers/docker-compose.yml -f ./numbers/prod.yml -f ./numbers/prod-healthcheck.yml -f ./numbers/prod-update-config.yml -f ./numbers/v5-bad.yml config > stack.yml # 部署更新: docker stack deploy -c stack.yml numbers # 等待一分钟并检查服务状态: docker service inspect --pretty numbers_numbers-api
这是一个典型的失败部署。新的 API 副本被成功创建并启动,但它们在健康检查中失败了——健康检查配置设置为每两秒运行一次,在标记容器为不健康之前进行两次重试。如果在滚动更新的监控期间,任何新的副本报告为不健康,这将触发回滚操作,我已经为这个服务设置了自动回滚。如果你在检查服务之前等待大约 30 秒,你将看到类似于图 14.6 中的输出,表明更新已经回滚,服务正在运行六个 v3 镜像的副本。

图 14.6 当你正确配置了配置时,失败的更新会被识别并回滚。
部署出错时没有乐趣,但像这样的失败更新会自动回滚,至少能保持你的应用运行。使用start-first部署策略有助于这一点。如果我使用默认的stop-first,那么在三个 v3 副本停止、三个 v5 副本启动并失败期间,会有一个容量减少的时期。在新副本标记自己为不健康和回滚完成所需的时间内,API 将只有三个活动副本。用户不会看到任何错误,因为 Docker Swarm 不会将流量发送到不健康的副本,但 API 将运行在 50%的容量。
此部署使用默认的回滚配置,这与更新默认配置相同:一次一个任务,使用stop-first策略,零监控时间,如果替换副本失败,回滚会暂停。我发现这太谨慎了,因为在你的应用运行正常而部署破坏了它的情况下,你通常希望尽可能快地回滚到之前的状态。列表 14.3 显示了此服务的首选回滚配置(来自prod-rollback-config.yml):
列表 14.3 快速回滚失败更新的配置
numbers-api: deploy: rollback_config: parallelism: 6 monitor: 0s failure_action: continue order: start-first
这里的目标是尽可能快地恢复——并行度为 6,所以所有失败的副本将一次性替换,使用start-first策略,这样旧版本的副本将在回滚担心关闭新版本副本之前启动。没有监控期,如果回滚失败(因为副本无法启动),它仍然会继续。这是一个激进的回滚策略,它假设上一个版本是好的,当副本启动时将再次变得良好。
现在试试看 我们将再次尝试 v5 更新,并指定自定义回滚配置。这次部署仍然会失败,但回滚将会更快,使应用回到 v3 API 的全容量状态:
# 将更多的 Compose 文件合并在一起: docker-compose -f ./numbers/docker-compose.yml -f ./numbers/prod.yml -f ./numbers/prod-healthcheck.yml -f ./numbers/prod-update-config.yml -f ./numbers/prod-rollback-config.yml -f ./numbers/v5-bad.yml config > stack.yml # 再次使用新的回滚配置部署更新: docker stack deploy -c stack.yml numbers # 等待,你会看到它再次回滚: docker service inspect --pretty numbers_numbers-api
这次你会看到回滚发生得更快,但只是略微快一些,因为 API 服务中只有少数副本,所有副本都在我的单个节点上运行。你可以看到在一个可能跨越 20 个节点运行 100 个副本的更大部署中,这有多么重要——逐个回滚每个副本将延长你的应用在低于容量或不稳定状态下运行的时间。你可以在图 14.7 中看到我的输出——这次我足够快,能够捕捉到回滚刚刚触发时的状态,所以状态显示回滚已经开始。

图 14.7 指定自定义回滚设置意味着失败的部署可以更快地得到修复。
当你自己运行这个操作时,请在回滚完成后查看完整的服务配置——你会看到回滚配置已重置为默认值。这肯定会引起混淆,因为你可能会认为回滚配置没有被应用。但实际上,这是因为整个服务配置都被回滚了,这包括回滚设置——副本按照新策略回滚,然后回滚策略也被回滚。下次你部署时,你需要确保继续添加更新和回滚配置,否则它们将被更新回默认设置。
这就是拥有多个覆盖文件变得危险的地方,因为它们都是必要的,并且它们都需要按照正确的顺序指定。通常你不会将一个环境的设置拆分到多个文件中;我只是这样做,以便使我们的更新和回滚过程更容易跟踪。通常,你会有一个核心 Compose 文件,一个环境覆盖文件,以及可能的一个版本覆盖文件。我们将采取这种方法进行最终部署,修复 v5 问题,并使应用恢复正常工作。
现在试试看 v5 更新失败并回滚了,所以我们召集了团队,意识到我们遗漏了一个关键的配置设置。v5.yml 覆盖文件添加了那个设置,而 prod-full.yml 覆盖文件将所有生产设置放在一个地方。现在我们可以成功部署 v5:
# 这才是正确的做法 - 所有自定义配置都在 prod-full 文件中: docker-compose -f ./numbers/docker-compose.yml -f ./numbers/prod-full.yml -f ./numbers/v5.yml --log-level ERROR config > stack.yml # 部署 v5 的工作版本: docker stack deploy -c stack.yml numbers # 稍等片刻,检查部署是否成功: docker service inspect --pretty numbers_numbers-api
我的输出在图 14.8 中。我在部署和服务列表之间等待了几分钟,以确保更新已经成功,并且没有回滚。

图 14.8 修复应用配置后的成功部署
现在,你已经在所有荣耀中运行了 v5——它实际上和之前的简单演示应用程序相同,但我们可以用它来说明关于回滚的最后一个要点。应用程序现在运行良好,健康检查已经到位,所以如果你继续使用 API 并破坏副本,它们将被替换,应用程序将重新开始工作。失败的健康检查不会触发最后一次更新的回滚;它们只会触发替换副本,除非失败发生在更新的监控期间。如果你部署 v5,并在 60 秒的监控期间破坏 API 容器,这将触发回滚。图 14.9 显示了从 v3 到 v5 更新的更新和回滚过程。

图 14.9 这看起来可疑地像流程图,但它只是建模更新过程的有用方式。
更新和回滚配置到此结束。这实际上只是在你的 Compose 文件部署部分设置几个值,并测试不同的变体以确保你的更新快速且安全,如果出现问题,它们可以快速回滚。这有助于你最大化应用程序的运行时间。剩下要做的就是了解当集群中的节点出现停机时,这种运行时间会受到怎样的影响。
14.4 管理集群的停机时间
容器编排器将一堆机器转换成一个强大的集群,但最终运行容器的是这些机器,它们容易出现停机。磁盘、网络和电源都可能在某个时刻出现故障——你的集群越大,出现故障的频率就越高。集群能够通过大多数故障来保持你的应用程序运行,但一些未计划的故障需要主动干预,如果你有计划的停机,你可以让 Swarm 更容易地绕过它们。
如果你想要跟随本节内容,你需要一个多节点 Swarm。如果你乐意构建虚拟机并在其上安装 Docker,你可以自己设置,或者你可以使用在线沙盒。Play with Docker 是一个不错的选择——你可以创建一个多节点 Swarm,并练习部署和节点管理,而不需要任何额外的机器。浏览到labs.play-with-docker.com,使用你的 Docker Hub ID 登录,然后点击添加新实例以将虚拟 Docker 服务器添加到你的在线会话中。我已经在我的会话中添加了五个实例,我将使用它们作为我的 Swarm。
现在尝试一下 启动你的 Play with Docker 会话并创建五个实例——你将在左侧导航中看到它们,你可以点击选择它们。在主窗口中,你会看到一个连接到你选择的节点的终端会话。
# 选择节点 1 并使用节点的 IP 地址初始化 Swarm: ip=$(hostname -i) docker swarm init --advertise-addr $ip # 显示将管理器和工作节点加入 Swarm 的命令: docker swarm join-token manager docker swarm join-token worker # 选择节点 2 并粘贴管理器加入命令,然后在节点 3 上执行相同的操作 # 选择节点 4 并粘贴工作节点加入命令,然后在节点 5 上执行相同的操作 # 回到节点 1,确保所有节点都准备就绪: docker node ls
这为您提供了一个完全可丢弃的 Swarm。您可以造成尽可能多的损害,然后只需关闭会话,所有这些节点都将消失(它们实际上是运行 Docker-in-Docker 的容器,具有许多智能来管理会话和网络)。您可以在图 14.10 中看到我的输出,Swarm 已经准备就绪。

图 14.10 使用 Play with Docker 中的可丢弃实例初始化多节点 Swarm
首先,让我们从最简单的情况开始——您需要关闭一个节点以在服务器上进行操作系统更新或其他基础设施任务。该节点可能正在运行容器,您希望它们能够优雅地关闭,在其他节点上替换,并且让您的机器进入维护模式,这样 Docker 就不会在您需要重启的任何周期中尝试调度任何新的容器。Swarm 中的节点维护模式称为排空模式,您可以将管理器或工作节点放入排空模式。
现在尝试一下 切换到您的节点 1 管理器的终端会话,并将其他两个节点设置为排空模式:
# 将一个工作节点和一个管理节点设置为排空模式: docker node update --availability drain node5 docker node update --availability drain node3 # 检查节点: docker node ls
对于工作节点和管理节点,排空模式意味着不同的事情。在两种情况下,节点上运行的所有副本都将关闭,并且不会为该节点调度更多副本。尽管如此,管理节点仍然是管理组的一部分,因此它们仍然同步集群数据库,提供对管理 API 的访问,并且可以是领导者。图 14.11 显示了我的集群中有两个排空节点。

图 14.11 进入排空模式会移除所有容器,并允许您对节点进行维护。
这关于领导者管理器是什么意思?您需要多个管理器来实现高可用性,但这是一种活动-被动模型。只有一个管理器实际上在控制集群,那就是领导者。其他管理器保留集群数据库的副本,它们可以处理 API 请求,并且在领导者失败时可以接管。这发生在剩余管理器之间的选举过程中,需要多数投票,为此您始终需要奇数个管理器——对于较小的集群通常是三个,对于大型集群通常是五个。如果您永久丢失一个管理节点,发现自己有偶数个管理器,可以将工作节点提升为管理器。
现在试试看 在 Docker 中模拟节点故障并不容易,但你可以连接到领导者并手动将其从 Swarm 中移除。然后剩下的一个管理者成为领导者,你可以提升一个工作节点以保持管理者的奇数数量:
# 在节点 1 上强制离开 Swarm: docker swarm leave --force # 在节点 2 上使工作节点再次可用: docker node update --availability active node5 # 提升工作节点为管理者: docker node promote node5 # 检查节点: docker node ls
节点可以以两种方式离开 Swarm——管理者可以使用node rm命令启动它,或者节点本身可以使用swarm leave命令执行。如果节点自行离开,那与节点离线的情况类似——Swarm 管理者认为它应该还在那里,但它无法访问。你可以在图 14.12 的输出中看到这一点。原始节点 1 仍然被列为管理者,但状态是Down,管理者状态是Unreachable。

图 14.12 节点管理确保即使节点离线,Swarm 也能完全可用。
现在,Swarm 又有三个管理者了,这给它提供了高可用性。如果节点 1 意外离线,当它重新上线时,我可以通过运行node demote命令将其他管理者中的一个返回到工作池。这些几乎是你管理 Docker Swarm 集群所需的所有命令。
我们将结束几个不太常见的场景,这样你就知道如果你遇到它们时 Swarm 会如何表现:
-
所有管理者都离线 —— 如果所有管理者都离线但工作节点仍在运行,那么你的应用程序仍在运行。如果没有管理者,入口网络和工作节点上的所有服务副本将以相同的方式工作,但现在没有任何东西可以监控你的服务,所以如果容器失败,它将不会被替换。你需要修复这个问题,并将管理者上线,以使集群恢复健康。
-
领导者和除了一个管理者之外的所有管理者都离线 —— 如果除了一个管理者节点之外的所有管理者节点都离线,而剩下的管理者不是领导者,那么你可能会失去对集群的控制。管理者必须投票选举新的领导者,如果没有其他管理者,则无法选举领导者。你可以通过在剩下的管理者上运行带有
force-new-cluster参数的swarm init命令来修复这个问题。这使得该节点成为领导者,但保留了所有集群数据和所有运行的任务。然后你可以添加更多管理者以恢复高可用性。 -
平衡副本以实现均匀分布 —— 当你添加新节点时,服务副本不会自动重新分配。如果你使用新节点增加了集群的容量,但没有更新任何服务,新节点将不会运行任何副本。你可以通过运行
service update --force来重新平衡副本,使它们在集群周围均匀分布,而无需更改任何其他属性。
14.5 理解 Swarm 集群中的高可用性
在你的应用程序部署中,有多个层次需要考虑高可用性。我们已经在本章中讨论了很多:健康检查告诉集群你的应用程序是否正在运行,并且它会替换失败的容器以保持应用程序在线;多个工作节点为容器提供额外的容量,以便在节点离线时重新调度;多个管理者为调度容器和监控工作节点提供冗余。还有一个需要考虑的最终区域——集群运行的数据中心。
我只是简要地介绍这部分内容,以便完成本章,因为人们常常试图通过构建跨越几个数据中心的一个单一集群来实现区域之间的高可用性。从理论上讲,你可以这样做——你可以在数据中心 A 创建管理者,并在数据中心 A、B 和 C 中创建工作节点。这确实简化了你的集群管理,但问题是网络延迟。Swarm 中的节点非常健谈,如果 A 和 B 之间突然出现网络延迟,管理者可能会认为所有 B 节点都离线了,并将所有容器重新调度到 C 节点上。而且这些情况只会变得更糟,可能会出现脑裂现象:不同区域中的多个管理者都认为自己是领导者。
如果你真的需要在区域故障时保持应用程序运行,唯一安全的方法是使用多个集群。这会增加你的管理开销,并且存在集群和它们运行的应用程序之间的漂移风险,但这些是可管理的问题,与网络延迟不同。图 14.13 显示了这种配置的外观。

图 14.13 要实现数据中心冗余,需要在不同区域拥有多个集群。
14.6 实验室
这次实验室回到图像库应用程序,轮到你来构建一个具有合理的推出和回滚配置的堆栈部署,用于 API 服务。但是有一个转折——API 组件的 Docker 镜像中没有内置健康检查,所以你需要考虑如何在服务规范中添加健康检查。以下是要求:
-
编写一个堆栈文件,使用以下容器镜像部署图像库应用程序:
diamol/ch04-access-log,diamol/ch04-image-of-the-day,和diamol/ch04-image-gallery。 -
API 组件是
diamol/ch04-image-of-the-day,它应该运行四个副本,应该指定一个健康检查,并且应该使用一个快速但安全的更新配置和一个仅快速回滚的配置。 -
当你部署了应用程序后,准备另一个更新服务的堆栈文件,以更新以下镜像:
diamol/ch09-access-log,diamol/ch09-image-of-the-day,和diamol/ch09-image-gallery。 -
部署你的堆栈更新,并确保 API 组件按照你预期的策略推出,并且不会因为不正确的健康检查而回滚。
这件事应该很有趣,如果你觉得这类事情有趣的话。无论如何,我的解决方案已经上传到 GitHub 上,你可以在通常的位置查看:github.com/sixeyed/diamol/blob/master/ch14/lab/README.md。祝更新愉快!
15 配置 Docker 以实现安全的远程访问和 CI/CD
Docker 命令行提供了一个无缝的工作方式来处理容器,很容易忘记命令行本身并不真正做任何事情——它只是将指令发送到运行在 Docker 引擎上的 API。将命令行与引擎分离有两个主要好处——其他工具可以消费 Docker API,因此命令行并不是管理容器的唯一方式,你还可以配置你的本地命令行以与运行 Docker 的远程机器协同工作。你可以在不离开办公桌的情况下,从在笔记本电脑上运行容器切换到管理拥有数十个节点的集群,使用你习惯的所有 Docker 命令,这真是令人惊叹。
远程访问是管理测试环境或调试生产中问题的方法,也是你启用 CI/CD 管道中持续部署部分的方法。在管道的持续集成阶段成功完成后,你将有一个可能发布的版本的应用存储在 Docker 注册库中。持续部署是管道的下一阶段——连接到远程 Docker 引擎并部署应用的新版本。这一阶段可能是一个测试环境,它将运行一系列集成测试,然后最终阶段可能连接到生产集群并将应用部署到实时环境中。在本章中,你将学习如何公开 Docker API 并保护它,以及如何从你的机器和 CI/CD 管道连接到远程 Docker 引擎。
15.1 Docker API 的端点选项
当你安装 Docker 时,你不需要配置命令行来与 API 通信——默认设置是引擎监听本地通道,命令行使用相同的通道。本地通道使用 Linux 套接字或 Windows 命名管道,这两种都是限制流量到本地机器的网络技术。如果你想启用对 Docker 引擎的远程访问,你需要在配置中明确设置。有几种不同的选项可以设置远程访问的通道,但最简单的是允许未加密的 HTTP 访问。
启用未加密的 HTTP 访问是一个糟糕的主意。它将 Docker API 设置为监听正常的 HTTP 端点,任何有权访问你网络的用户都可以连接到你的 Docker 引擎并管理容器——无需任何身份验证。你可能认为在你的开发笔记本电脑上这并不是太糟糕,但它打开了一个很好的、简单的攻击向量。一个恶意网站可以构造一个请求到 http://localhost:2375,你的 Docker API 就在那里监听,并在你的机器上启动一个比特币挖矿容器——直到你好奇为什么你的 CPU 都去哪儿了,你才会知道。
我将带你完成启用纯 HTTP 访问的步骤,但前提是你承诺在这次练习之后不再这样做。在本节结束时,你将很好地理解远程访问是如何工作的,因此你可以禁用 HTTP 选项并转向更安全的选择。
现在尝试一下 远程访问是 Engine 的配置选项。你可以在 Windows 10 或 Mac 上的 Docker Desktop 中轻松设置它,通过鲸鱼菜单打开设置并选择在 tcp:/ /localhost:2375 上暴露守护进程,不使用 TLS。图 15.1 显示了该选项——一旦保存设置,Docker 将重新启动。

图 15.1 显示了启用对 Docker API 的纯 HTTP 访问——你应该努力忘记你看到了这个。
如果你正在使用 Linux 或 Windows Server 上的 Docker Engine,你需要编辑配置文件。在 Linux 上,你可以在 /etc/docker/daemon.json 找到它,或者在 Windows 上在 C:\ProgramData\docker\config\daemon.json 。你需要添加的字段是 hosts ,它包含要监听端点的列表。列表 15.1 展示了用于未加密 HTTP 访问的设置,使用 Docker 的传统端口 2375。
列表 15.1 通过 daemon.json 配置 Docker Engine 的纯 HTTP 访问
{ "hosts": [ # 在端口 2375 上启用远程访问: "tcp://0.0.0.0:2375", # 并且继续监听本地通道 - Windows 管道: "npipe://" # 或者 Linux 套接字: "fd://" ], "insecure-registries": [ "registry.local:5000" ] }
你可以通过向 API 发送 HTTP 请求并使用 Docker CLI 中的 TCP 主机地址来检查 Engine 是否已配置为远程访问。
现在尝试一下 Docker 命令行可以使用 host 参数连接到远程机器。远程机器可以是 localhost,但通过 TCP 而不是本地通道:
# 通过 TCP 连接到本地 Engine: docker --host tcp://localhost:2375 container ls # 使用 HTTP 通过 REST API: curl http://localhost:2375/containers/json
Docker 和 Docker Compose 命令行都支持 host 参数,该参数指定了你想要发送命令的 Docker Engine 的地址。如果 Engine 配置为监听无安全性的本地地址,则 host 参数就是你所需要的;用户无需认证,网络流量也不会加密。你可以在图 15.2 中看到我的输出——我可以用 Docker CLI 或 API 列出容器。

图 15.2 当 Docker Engine 通过 HTTP 可用时,任何拥有机器地址的人都可以使用它。
现在想象一下,如果运营团队得知你想要管理一个 Docker 服务器,因此需要他们启用远程访问——顺便说一句,这将允许任何人在该机器上对 Docker 进行任何操作,没有任何安全性和审计记录。不要低估这有多么危险。Linux 容器使用与主机服务器相同的用户账户,所以如果你以 Linux 管理员账户root运行容器,你几乎就有对服务器的管理员访问权限。Windows 容器的工作方式略有不同,所以你不会从容器中获得无限的服务器访问权限,但你仍然可以做一些不愉快的事情。
当你与远程 Docker Engine 一起工作时,你发送的任何命令都在该机器的上下文中执行。所以如果你运行一个容器并从本地磁盘挂载一个卷,容器看到的是远程机器的磁盘。如果你想在测试服务器上运行一个挂载本地机器源代码的容器,这可能会让你感到困惑。要么命令会失败,因为你要挂载的目录在服务器上不存在(这会令你困惑,因为你知道它在你的机器上确实存在),要么更糟糕的是,该路径在服务器上确实存在,但你不会理解为什么容器内的文件与你的磁盘不同。这也为那些没有服务器访问权限但有权访问 Docker Engine 的人提供了一个有用的快捷方式来浏览远程服务器的文件系统。
现在试试看,让我们看看为什么对 Docker Engine 的不安全访问如此糟糕。运行一个挂载 Docker 机器磁盘的容器,你就可以浏览主机的文件系统:
# 使用 Linux 容器: docker --host tcp://localhost:2375 container run -it -v /:/host-drive diamol/base # 或者 Windows 容器: docker --host tcp://localhost:2375 container run -it -v C:\:C:\host-drive diamol/base # 在容器内浏览文件系统: ls ls host-drive
你可以在图 15.3 中看到我的输出——运行容器的用户对主机上的文件有完全的读写访问权限。

图 15.3 拥有对 Docker Engine 的访问权限意味着你可以访问主机的文件系统。
在这个练习中,你只是连接到自己的机器,所以你实际上并没有绕过安全措施。但如果你发现运行你容器化工资系统的服务器的名称或 IP 地址,并且该服务器对 Docker Engine 有未受保护的外部访问——那么,你可能会在预期之前更快地做出一些更改,并开始在新特斯拉车上工作。这就是为什么你不应该启用对 Docker Engine 的不安全访问,除非作为学习练习。
在我们继续之前,让我们摆脱我们创建的危险情况,回到 Docker Engine 的私有本地通道。您可以在 Docker Desktop 的设置中取消勾选 localhost 复选框,或者撤销您为 Docker 守护进程所做的配置更改,然后我们将继续探讨远程访问的更安全选项。
15.2 配置 Docker 以实现安全远程访问
Docker 支持 API 监听的其他两个通道,并且两者都是安全的。第一个使用传输层安全性(TLS)——与 HTTPS 网站使用的基于数字证书的加密技术相同。Docker API 使用相互 TLS,因此服务器有一个证书来识别自己并加密流量,客户端也有一个证书来识别自己。第二个选项使用安全外壳(SSH)协议,这是连接到 Linux 服务器的标准方式,但它也支持 Windows。SSH 用户可以使用用户名和密码或使用私钥进行认证。
安全选项为您提供了不同的方式来控制谁可以访问您的集群。相互 TLS 是最广泛使用的,但它需要在生成和轮换证书时承担管理开销。SSH 要求您在连接的机器上有一个 SSH 客户端,但大多数现代操作系统都有,它为您提供了管理谁可以访问您的机器的更简单方式。图 15.4 显示了 Docker API 支持的不同通道。

图 15.4 存在安全地暴露 Docker API、提供加密和认证的方法。
这里有一个重要的事情——如果您想配置对 Docker Engine 的安全远程访问,您需要能够访问运行 Docker 的机器。您无法通过 Docker Desktop 获得这一点,因为桌面实际上在您的机器上的虚拟机中运行 Docker,您无法配置该虚拟机的监听方式(除了我们刚刚使用的未加密的 HTTP 复选框)。不要尝试使用 Docker Desktop 执行下一个练习——您可能会收到一个错误,告诉您某些设置无法调整,或者更糟糕的是,它可能会让您调整它们,然后一切都会崩溃,您需要重新安装。在本节的其余部分,练习使用 Play with Docker (PWD)在线游乐场,但如果您有一台运行 Docker 的远程机器(这里就是您的树莓派发挥作用的地方),您可以在本章源代码的 readme 文件中找到如何在不使用 PWD 的情况下执行相同操作的详细信息。
我们将首先通过使用相互 TLS 来安全地访问远程 Docker Engine。为此,您需要生成证书和密钥文件对(密钥文件充当证书的密码)——一个用于 Docker API,另一个用于客户端。大型组织将有一个内部证书颁发机构(CA)和一个拥有证书并可以为您生成它们的团队。我已经完成了这项工作,生成了与 PWD 兼容的证书,因此您可以使用这些证书。
现在尝试一下 在labs.play-with-docker.com上登录 Play with Docker 并创建一个新的节点。在那个会话中,运行一个容器来部署证书,并配置 PWD 中的 Docker Engine 使用证书。然后重新启动 Docker:
# 创建证书目录: mkdir -p /diamol-certs # 运行设置证书和配置的容器: docker container run -v /diamol-certs:/certs -v /etc/docker:/docker diamol/pwd-tls:server # 杀死 docker 并使用新配置重启: pkill dockerd dockerd &>/docker.log &
您运行的容器从 PWD 节点挂载了两个卷,并将证书和新的daemon.json文件从容器镜像复制到节点上。如果您更改 Docker Engine 配置,则需要重新启动它,这就是dockerd命令所做的事情。您可以在图 15.5 中看到我的输出--此时引擎正在端口 2376 上监听(这是安全 TCP 访问的惯例)使用 TLS。

图 15.5 配置 Play with Docker 会话,使引擎使用相互 TLS 监听
在我们实际上从本地机器向 PWD 节点发送流量之前,还有最后一步。点击“打开端口”按钮并打开端口 2376。将打开一个新标签页,显示一个错误消息。忽略该消息,并将该新标签页的 URL 复制到剪贴板。这是您会话的独特 PWD 域名。它可能类似于ip172-18-0-62-bo9pj8nad2eg008a76e0-2376.direct.labs.play-with-docker.com,您将使用它从您的本地机器连接到 PWD 中的 Docker Engine。图 15.6 显示了如何打开端口。

图 15.6 在 PWD 中打开端口,让您可以将外部流量发送到容器和 Docker Engine。
您的 PWD 实例现在可以远程管理。您使用的证书是我使用 OpenSSH 工具(在容器中运行--如果您想了解它是如何工作的,Dockerfile 在images/cert-generator文件夹中)生成的。我不会详细介绍 TLS 证书和 OpenSSH,因为这会是一个我们都不愿意的长篇大论。但是,了解 CA、服务器证书和客户端证书之间的关系很重要。图 15.7 显示了这一点。

图 15.7 互信 TLS 快速指南--服务器证书和客户端证书标识持证人并共享 CA。
如果您打算使用 TLS 来保护您的 Docker Engine,您将为要保护的每个引擎生成一个 CA、一个服务器证书,并为每个您希望允许访问的用户生成一个客户端证书。证书有有效期,因此您可以创建短期客户端证书,以便临时访问远程引擎。所有这些都可以自动化,但管理证书仍然有开销。
当你配置 Docker 引擎使用 TLS 时,你需要指定 CA 证书、服务器证书和密钥对的路径。列表 15.2 展示了已部署在 PWD 节点上的 TLS 设置。
列表 15.2 启用 TLS 访问的 Docker 守护进程配置
{ "hosts": ["unix:///var/run/docker.sock", "tcp://0.0.0.0:2376"], "tls": true, "tlscacert": "/diamol-certs/ca.pem", "tlskey": "/diamol-certs/server-key.pem", "tlscert": "/diamol-certs/server-cert.pem" }
现在远程 Docker 引擎已加密,除非你提供 CA 证书、客户端证书和客户端密钥,否则你不能使用 curl 的 REST API 或通过 Docker CLI 发送命令。API 也不会接受任何旧的客户端证书——它需要使用与服务器相同的 CA 生成。尝试在没有客户端 TLS 的情况下使用 API 会被引擎拒绝。你可以使用你在 PWD 上运行的镜像的变体来下载本地机器上的客户端证书,并使用这些证书进行连接。
现在尝试一下 确保你有访问 PWD 端口 2376 的 URL ——这就是你从本地机器连接到 PWD 会话的方式。使用你之前打开端口 2376 时复制的会话域名。尝试连接到 PWD 引擎:
# 从地址栏获取你的 PWD 域名 - 类似于 # ip172-18-0-62-bo9pj8nad2eg008a76e0-6379.direct.labs.play-with- # docker.com # 将你的 PWD 域名存储在一个变量中 - 在 Windows 上: $pwdDomain="<your-pwd-domain-from-the-address-bar>" # OR Linux: pwdDomain="<your-pwd-domain-goes-here>" # 尝试直接访问 Docker API: curl "http://$pwdDomain/containers/json" # 现在尝试使用命令行: docker --host "tcp://$pwdDomain" container ls # 将 PWD 客户端证书提取到你的机器上: mkdir -p /tmp/pwd-certs cd ./ch15/exercises tar -xvf pwd-client-certs -C /tmp/pwd-certs # 使用客户端证书连接: docker --host "tcp://$pwdDomain" --tlsverify --tlscacert /tmp/pwd-certs/ca.pem --tlscert /tmp/pwd-certs/client-cert.pem --tlskey /tmp/pwd-certs/client-key.pem container ls # 你可以使用任何 Docker CLI 命令: docker --host "tcp://$pwdDomain" --tlsverify --tlscacert /tmp/pwd-certs/ca.pem --tlscert /tmp/pwd-certs/client-cert.pem --tlskey /tmp/pwd-certs/client-key.pem container run -d -P diamol/apache
将 TLS 参数传递给每个 Docker 命令有点繁琐,但你也可以将它们捕获在环境变量中。如果你没有提供正确的客户端证书,你会得到一个错误,当你提供证书时,你可以完全控制从本地机器运行的 PWD 中的 Docker 引擎。你可以在图 15.8 中看到这一点。

图 15.8 你只能在与 TLS 加密的 Docker 引擎一起工作时使用客户端证书。
另一种安全远程访问的选项是 SSH;这里的优势是 Docker CLI 使用标准的 SSH 客户端,并且不需要对 Docker 引擎进行任何配置更改。不需要创建或管理证书,因为认证由 SSH 服务器处理。在你的 Docker 机器上,你需要为每个你想允许远程访问的人创建一个系统用户;他们运行任何针对远程机器的 Docker 命令时使用这些凭据。
现在试试吧 在你的 PWD 会话中,记下 node1 的 IP 地址,然后点击创建另一个节点。运行以下命令,通过 SSH 从 node2 的命令行管理 node1 上的 Docker 引擎:
# 将 node1 的 IP 地址保存到变量中: node1ip="<node1-ip-address-goes-here>"
Play with Docker 使这变得非常简单,因为它为节点提供了连接彼此所需的一切。在实际环境中,你需要创建用户,如果你想避免输入密码,你还需要生成密钥并将公钥分发到服务器,私钥分发给用户。你可以从图 15.9 中的我的输出中看到,所有这些都在 Play with Docker 会话中完成,并且无需特殊设置即可正常工作。

图 15.9 Play with Docker 配置了节点之间的 SSH 客户端,以便你可以与 Docker 一起使用。
系统管理员对于使用 SSH 通过 Docker 可能会有复杂的感受。一方面,它比管理证书要容易得多,如果你的组织有很多 Linux 管理员经验,这并不是什么新鲜事。另一方面,这意味着将服务器访问权限授予任何需要 Docker 访问的人,这可能是他们不需要的权限。如果你的组织主要是 Windows,你可以在 Windows 上安装 OpenSSH 服务器并使用相同的方法,但这与管理员通常管理 Windows 服务器访问的方式非常不同。尽管有证书开销,TLS 可能是一个更好的选择,因为所有操作都在 Docker 内部完成,并且不需要 SSH 服务器或客户端。
使用 TLS 或 SSH 保护对您的 Docker 引擎的访问提供了加密(CLI 和 API 之间的流量在网络中无法被读取)和身份验证(用户必须证明他们的身份才能连接)。这种安全性不提供授权或审计,因此您无法限制用户可以做什么,并且您没有他们做了什么的记录。在考虑谁需要访问哪些环境时,您需要意识到这一点。用户还需要小心使用哪些环境——Docker CLI 使切换到远程引擎变得非常容易,而且很容易因为误以为连接到笔记本电脑而删除包含重要测试数据的卷。
15.3 使用 Docker 上下文与远程引擎一起工作
您可以使用 host 参数将本地 Docker CLI 指向远程机器,如果您使用的是安全通道,还可以包括所有 TLS 证书路径,但为每个命令这样做会很麻烦。Docker 通过上下文使在 Docker 引擎之间切换变得更容易。您使用 CLI 创建 Docker 上下文,指定引擎的所有连接细节。您可以创建多个上下文,每个上下文的连接细节都存储在您的本地机器上。
现在试试看 创建一个上下文来使用在当前工作目录 (PWD) 中运行的远程 TLS 启用的 Docker 引擎:
# 使用您的 PWD 域和证书创建上下文: docker context create pwd-tls --docker "host=tcp://$pwdDomain,ca=/tmp/pwd-certs/ca.pem,cert=/tmp/pwd-certs/client-cert.pem,key=/tmp/pwd-certs/client-key.pem" # 对于 SSH,将是: # docker context create local-tls --docker "host=ssh://user@server" # 列出上下文: docker context ls
您将在输出中看到有一个默认上下文,它使用私有通道指向您的本地引擎。我的图 15.10 的输出来自一台 Windows 机器,因此默认通道使用命名管道。您还会看到有一个 Kubernetes 端点选项——您还可以使用 Docker 上下文来存储 Kubernetes 集群的连接细节。

图 15.10 通过指定远程主机名和 TLS 证书路径添加新的上下文
上下文包含您在本地和远程 Docker 引擎之间切换所需的所有信息。本练习使用了 TLS 加密的引擎,但您可以通过替换主机参数和证书路径为您的 SSH 连接字符串,使用 SSH 加密的引擎运行相同的命令。
上下文可以将您的本地 CLI 连接到本地网络或公共互联网上的其他机器。有两种方式来切换上下文——您可以临时切换,在单个终端会话期间进行,或者永久切换,以便在您再次切换之前在所有终端会话中生效。
现在尝试一下 当您切换上下文时,您的 Docker 命令会发送到选定的引擎——您不需要指定主机参数。您可以使用环境变量临时切换,或使用context use命令永久切换:
# 使用环境变量切换到命名上下文 - 这是切换上下文的 # 建议方式,因为它只持续本次 # 会话
输出可能不是您预期的,由于这些不同的设置方式,您需要小心处理上下文。图 15.11 显示了我的输出,尽管我已经切换回默认设置,但上下文仍然设置为 PWD 连接。

图 15.11 有两种切换上下文的方式,如果您混合使用它们,您会感到困惑。
使用docker context use设置的上下文成为系统默认。您打开的任何新终端窗口,或任何运行 Docker 命令的批处理进程,都将使用该上下文。您可以使用DOCKER_CONTEXT环境变量来覆盖它,它优先于选定的上下文,并且仅适用于当前终端会话。如果您经常在上下文之间切换,我发现始终使用环境变量选项并将默认上下文保留为您的本地 Docker Engine 是一个好习惯。否则,很容易在一天开始时清除所有正在运行的容器,忘记昨天您已经将上下文设置为使用生产服务器。
当然,您不应该需要定期访问生产 Docker 服务器。随着您在容器之旅中不断前进,您将更多地利用 Docker 带来的便捷自动化,并达到一个只有超级管理员和 CI/CD 管道的系统账户可以访问 Docker 的地方。
15.4 将持续部署添加到您的 CI 管道中
现在我们已经配置了具有安全访问的远程 Docker 机器,我们可以编写一个完整的 CI/CD 管道,基于第十一章中我们与 Jenkins 一起完成的工作。该管道涵盖了持续集成(CI)阶段——在容器中构建和测试应用程序,并将构建的镜像推送到 Docker 仓库。持续部署(CD)阶段在此基础上增加了部署到测试环境以进行最终确认,然后部署到生产环境。
CI 阶段和 CD 阶段之间的区别在于,CI 构建都是在构建机器上使用 Docker Engine 本地进行的,但部署需要通过远程 Docker Engine 进行。管道可以使用我们在练习中采取的相同方法,使用带有指向远程机器的主机参数的 Docker 和 Docker Compose 命令,并提供安全凭证。这些凭证需要存储在某个地方,绝对不能放在源代码控制中——需要与源代码工作的人与需要与生产服务器工作的人不是同一群人,因此生产凭证不应广泛可用。大多数自动化服务器都允许你在构建服务器内部存储机密,并在管道作业中使用它们,这样就将凭证管理从源代码控制中分离出来。
现在尝试一下 我们将启动一个类似于第十一章的本地构建基础设施,包括本地 Git 服务器、Docker 注册中心和 Jenkins 服务器,所有这些都在容器中运行。有一些脚本会在 Jenkins 容器启动时运行,从您本地的 PWD 证书文件中创建凭证,因此 CD 阶段将部署到 PWD:
# 切换到包含 Compose 文件的文件夹: cd ch15/exercises/infrastructure # 启动容器 - 使用 Windows 容器: docker-compose -f ./docker-compose.yml -f ./docker-compose-windows.yml up -d # 或者使用 Linux 容器: docker-compose -f ./docker-compose.yml -f ./docker-compose-linux.yml up -d
当容器运行时,浏览到 Jenkins 的 http://localhost:8080/credentials,使用用户名diamol和密码diamol登录。您会看到 Docker CA 证书和客户端连接的证书已经存储在 Jenkins 中——它们是从您的机器上的 PWD 证书加载的,并且可以在作业中使用。图 15.12 显示了作为 Jenkins 凭证加载的证书。

图 15.12 使用 Jenkins 凭证为管道提供 TLS 证书以连接到 PWD 的 Docker
这是一个全新的构建基础设施,在全新的容器中运行。由于使用了自动化脚本,Jenkins 已经配置完毕并准备就绪,但 Git 服务器需要一些手动设置。您需要浏览到 http://localhost:3000 并完成安装,创建一个名为diamol的用户,然后创建一个名为diamol的仓库。如果您需要复习,可以翻回到第十一章——第 11.3、11.4 和 11.5 节会向您展示如何操作。
本节中我们将运行的管道将构建第十二章中 timecheck 应用程序的新版本,该应用程序每 10 秒打印一次本地时间。该章节的源代码中已经准备好了所有脚本,但你需要对管道进行更改以添加自己的 PWD 域名。然后当构建运行时,它将运行 CI 阶段并将从你的本地容器部署到你的 PWD 会话。我们将假装 PWD 既是用户验收测试环境也是生产环境。
现在尝试一下 打开文件夹 ch15/exercises 中的管道定义文件 -- 如果你在运行 Linux 容器,请使用 Jenkinsfile;如果你使用的是 Windows 容器,请使用 Jenkinsfile.windows。在环境部分,有 Docker 注册域名和用户验收测试(UAT)以及生产 Docker 引擎的变量。将 pwd-domain 替换为你的实际 PWD 域名,并确保在域名后包含端口号 :80 -- PWD 在外部监听端口 80,并将其映射到会话中的端口 2376:
environment { REGISTRY = "registry.local:5000" UAT_ENGINE = "ip172-18-0-59-bngh3ebjagq000ddjbv0-2376.direct.labs.play-with-docker.com:80" PROD_ENGINE = "ip172-18-0-59-bngh3ebjagq000ddjbv0-2376.direct.labs.play-with-docker.com:80" }
现在,你可以将你的更改推送到你的本地 Git 服务器:
git remote add ch15 http://localhost:3000/diamol/diamol.git git commit -a -m 'Added PWD domains' git push ch15 # Gogs will ask you to login - # use the diamol username and password you registered in Gogs
现在,浏览到 Jenkins,网址为 http://localhost:8080/job/diamol/,然后点击“立即构建”。
此管道的启动方式与第十一章的管道相同:从 Git 获取代码,使用多阶段 Dockerfile 构建应用程序,运行应用程序以测试其启动,然后将镜像推送到本地注册库。然后是新部署阶段:首先是将应用程序部署到远程 UAT 引擎,然后管道停止,等待人工批准以继续。这是一种很好的开始 CD 的方式,因为每一步都是自动化的,但仍然有一个手动质量关卡,这对于不习惯自动部署到生产环境的组织来说可能是个安慰。你可以在图 15.13 中看到构建已通过到 UAT 阶段,现在它已停止在“等待批准”。

图 15.13 Jenkins 中的 CI/CD 管道已部署到 UAT 并正在等待批准以继续。
你的手动批准阶段可能涉及一个专门团队进行整整一天的性能测试,或者它可能只是对新的部署在类似生产环境中的外观进行快速检查。当你对部署满意时,返回 Jenkins 并发出你的批准信号。然后它将继续到最后一个阶段--部署到生产环境。
现在尝试一下 回到你的 PWD 会话中,检查 timecheck 容器是否正在运行,并且是否正在输出正确的日志:
docker container ls docker container logs timecheck-uat_timecheck_1
我相信一切都会顺利,所以回到 Jenkins 并点击“等待批准”阶段的蓝色框。一个窗口会弹出,要求确认部署——点击“执行”!管道将继续。
现在越来越激动人心了——我们的生产部署几乎完成了。您可以在图 15.14 中看到我的输出,UAT 测试在背景中,批准阶段在前景中。

图 15.14 UAT 部署已正确完成,应用正在 PWD 中运行。接下来是生产环境!
管道中的 CD 阶段并不比 CI 阶段复杂。每个阶段都有一个脚本文件,使用单个 Docker Compose 命令执行工作,将相关的覆盖文件连接起来(如果远程环境是一个 Swarm 集群,这可以很容易地是一个 docker stack deploy 命令)。部署脚本期望 TLS 证书路径和 Docker 主机域名通过环境变量提供,这些变量在管道作业中已设置。
在使用 Docker 和 Docker Compose CLIs 实际执行的工作与管道中完成的工作的组织之间保持分离是很重要的。这减少了您对特定自动化服务器的依赖,并使得在它们之间切换变得容易。列表 15.3 显示了 Jenkinsfile 的一部分和部署到 UAT 的批处理脚本。
列表 15.3 使用 Jenkins 凭据将 Docker TLS 证书传递到脚本文件
# Jenkinsfile 的部署阶段: stage('UAT') { steps { withCredentials( [file(credentialsId: 'docker-ca.pem', variable: 'ca'), file(credentialsId: 'docker-cert.pem', variable: 'cert'), file(credentialsId: 'docker-key.pem', variable: 'key')]) { dir('ch15/exercises') { sh 'chmod +x ./ci/04-uat.bat' sh './ci/04-uat.bat' echo "Deployed to UAT" } } } } # 实际的脚本仅使用 Docker Compose: docker-compose \ --host tcp://$UAT_ENGINE --tlsverify \ --tlscacert $ca --tlscert $cert --tlskey $key \ -p timecheck-uat -f docker-compose.yml -f docker-compose-uat.yml \ up -d
Jenkins 从其自己的凭据提供 TLS 证书给 shell 脚本。您可以将此构建移至 GitHub Actions,并只需使用存储在 GitHub 仓库中的机密来模拟工作流程——构建脚本本身不需要更改。生产部署阶段几乎与 UAT 相同;它只是使用不同的 Compose 文件来指定环境设置。我们正在使用相同的 PWD 环境进行 UAT 和生产,因此当作业完成时,您将能够看到两个部署都在运行。
现在试试吧 返回 PWD 会话,最后一次,您可以检查您的本地 Jenkins 构建是否已正确部署到 UAT 和生产环境:
docker container ls docker container logs timecheck-prod_timecheck_1
我的输出在图 15.15 中。我们有一个从 Jenkins 在本地容器中运行的成功的 CI/CD 流程,并部署到两个远程 Docker 环境(在这个例子中恰好是同一个)。

图 15.15 PWD 的部署。要使用实际的集群,我只需更改域名和证书。
这非常强大。运行不同环境的容器以及运行 CI/CD 基础设施机器不需要比 Docker 服务器更多的东西。您可以在一天内通过您自己的应用程序的流程来证明这一点(假设您已经将组件 Docker 化了),而通往生产的路径只需要启动集群并更改部署目标。
在规划您的生产流程之前,然而,当您将 Docker 引擎远程提供时,还有一件事需要您注意——即使它是受保护的。那就是 Docker 资源访问模型。
15.5 理解 Docker 的访问模型
这实际上不需要一个完整的章节,因为 Docker 资源访问模型非常简单,但它有自己的章节来帮助它突出。保护您的引擎主要涉及两个方面:加密 CLI 和 API 之间的流量,并验证以确保用户有权访问 API。没有授权——访问模型是全有或全无。如果您无法连接到 API,您将无法做任何事情,如果您可以连接到 API,您可以做任何事情。
您是否感到恐惧取决于您的背景、您的基础设施以及您安全模型的成熟度。您可能正在运行没有公开访问的内部集群,为您的管理者使用单独的网络,并限制对该网络的 IP 访问,您每天都会轮换 Docker CA。这为您提供了深度防御,但仍然需要考虑来自您自己的员工的攻击向量(是的,我知道 Stanley 和 Minerva 是优秀的团队成员,但您真的确定他们不是骗子吗?特别是 Stanley)。
有其他选择,但它们很快就会变得复杂。Kubernetes 和 Docker Enterprise 都有基于角色的访问控制模型,因此您可以限制哪些用户可以访问资源,以及他们可以使用这些资源做什么。或者有一种 GitOps 方法,它将 CI/CD 流程颠倒过来,使用基于拉的模型,以便集群在新的构建被批准时知道,并自行部署更新。图 15.16 展示了这一点——这里没有共享凭证,因为不需要任何东西连接到集群。

图 15.16 GitOps 的勇敢新世界——一切都在 Git 中存储,集群开始部署。
GitOps 是一个非常有趣的方法,因为它使一切可重复且可版本化——不仅包括你的应用程序源代码和部署 YAML 文件,还包括基础设施设置脚本。它为你提供了整个堆栈的单个真相来源,Git,你可以轻松审计和回滚。如果你对这个想法感兴趣,但你是从零开始的——好吧,这将花费你一段时间才能达到那里,但你可以从本章中我们讨论的非常简单的 CI/CD 管道开始,随着你获得信心,逐渐发展和完善你的流程和工具。
15.6 实验室
如果你跟随了第 15.4 节中的 CD 练习,你可能想知道部署是如何工作的,因为 CI 阶段将镜像推送到你的本地注册表,而 PWD 无法访问该注册表。它是如何将镜像拉取来运行容器的?好吧,它并没有。我作弊了。部署覆盖文件使用了一个不同的镜像标签,一个来自 Docker Hub 的标签,是我自己构建并推送的(如果你感到失望,我表示抱歉,但本书中的所有镜像都是使用 Jenkins 管道构建的,所以实际上是一样的)。在这个实验中,你将纠正这一点。
构建中缺失的部分在第三阶段,它只是将镜像推送到本地注册表。在一个典型的管道中,会有一个在本地服务器上的测试阶段,可以在推送到生产注册表之前访问该镜像,但我们将跳过这一阶段,直接添加另一个推送到 Docker Hub 的操作。这是目标:
-
为你的 CI 镜像打上标签,使其使用 Docker Hub 上的你的账户和简单的“3.0”标签。
-
将镜像推送到 Docker Hub,同时确保你的 Hub 凭证安全。
-
使用你自己的 Docker Hub 镜像部署到 UAT 和生产环境。
这里有几个需要考虑的环节,但仔细查看现有的流程,你就会知道你需要做什么。两个提示:首先,你可以在 Jenkins 中创建一个用户名/密码凭证,并在 Jenkinsfile 中使用withCredentials块使其可用。其次,到 PWD 会话的开放端口有时会停止监听,因此你可能需要在 Jenkinsfile 中启动新的会话,这些会话将需要新的 PWD 域。
我在 GitHub 上的解决方案最初是复制了练习文件夹,所以如果你想看看我做了哪些更改,你可以比较文件,以及检查方法:github.com/sixeyed/diamol/blob/master/ch15/lab/README.md。
16 构建在任何地方运行的 Docker 镜像:Linux、Windows、Intel 和 Arm
本书中有数十个“现在就试”的练习,如果你使用不同的机器来跟随,你会发现这些练习在 Mac、Windows、Linux 和 Raspberry Pi 上工作方式相同。这不是偶然——我已经将本书中的每个 Docker 镜像构建为多架构镜像。多架构镜像以多种变体构建并推送到注册表,每种变体针对不同的操作系统或 CPU 架构,但都使用相同的镜像名称。当你使用这些镜像之一来运行容器或构建另一个镜像时,Docker 会根据你机器上的 CPU 和 OS 拉取匹配的变体。如果你在不同的架构上使用相同的镜像名称,你会得到不同的镜像变体,但它将是相同的应用程序,并且将以相同的方式工作。这对用户来说是一个超级简单的流程,但对镜像发布者来说则需要一些努力。
在本章中,你将学习到产生多架构构建的不同方法,但如果你因为不使用 Windows 或 Arm 而考虑跳过这一部分,至少应该阅读第一部分,了解为什么这是一个改变游戏规则的选择。
16.1 为什么多架构镜像很重要
亚马逊网络服务为使用 Intel、AMD 或 Arm 处理器的虚拟机提供不同的计算类别。Arm 选项(称为 A1 实例)的价格几乎比 Intel/AMD 选项便宜一半。AWS 是第一个支持 Arm 的主要云服务提供商,但你可以确信,如果其他云服务提供商因为 Arm CPU 的节省而开始失去工作负载,他们也会添加支持。如果你可以将你的应用程序运行在几乎一半的价格上,你为什么不这样做呢?嗯,因为很难让为 Intel 构建的应用程序在 Arm 上运行。
在另一端,物联网设备通常运行 Arm 处理器,因为它们在功耗上非常高效(因此云中的价格降低),如果能以容器镜像的形式将软件发送到设备上那就太好了。但是,Arm CPU 指令与 Intel 和 AMD 使用的标准 x64 指令不兼容。因此,为了在云或边缘(或在充满 Raspberry Pi 的数据中心)支持 Arm CPU,你需要使用一个可以在 Arm 上运行的应用程序平台,并且你需要使用 Arm 机器来构建你的应用程序。这就是 Docker 解决的问题,无论是对于生产构建农场还是对于开发者工作流程。Docker Desktop 支持仿真来构建带有 Arm 架构的 Docker 镜像和运行容器,即使在 Intel 机器上也是如此。
现在就试一试 这个可能不适合 Docker Engine 或 PWD 用户,因为仅 Docker Engine 本身没有 Arm 仿真——这只有在 Docker Desktop 中才有。你可以在 Mac 或 Windows 上这样做(在 Linux 容器模式下)。
首先,你需要从鲸鱼图标设置中启用实验模式——参见图 16.1。

图 16.1 启用实验模式可以解锁仍在开发中的功能。
现在打开一个终端,使用 Arm 仿真构建一个图像:
# 切换到练习文件夹: cd ch16/exercises # 为 64 位 Arm 构建: docker build -t diamol/ch16-whoami:linux-arm64 --platform linux/arm64 ./whoami # 检查图像的架构: docker image inspect diamol/ch16-whoami:linux-arm64 -f '{{.Os}}/{{.Architecture}}' # 以及您引擎的原生架构: docker info -f '{{.OSType}}/{{.Architecture}}'
您会看到您构建的图像针对的是 64 位 Arm 平台,尽管您的机器正在运行 64 位 Intel 或 AMD 机器。此图像使用多阶段 Dockerfile 来编译和打包.NET Core 应用程序。.NET Core 平台在 Arm 上运行,Dockerfile 中的基础镜像(对于 SDK 和运行时)有可用的 Arm 变体。这就是您需要支持跨平台构建的所有内容。
您可以将此图像推送到注册表,并在真实的 Arm 机器(如 Raspberry Pi 或 AWS 中的 A1 实例)上运行容器,它将正常工作。您可以在图 16.2 中看到我的输出,我在其中从一个 Intel 机器构建了一个 Arm 图像。

图 16.2 跨平台支持,使用仿真在 Intel 机器上构建 Arm 图像
Docker 了解您机器的许多信息,包括操作系统和 CPU 架构,当您尝试拉取图像时,它将使用这些信息作为匹配项。拉取图像不仅仅是下载层——还包括优化以扩展压缩层并使图像准备好运行。这种优化仅在您想要使用的图像与您正在运行的架构匹配时才有效,因此如果没有匹配项,您将得到一个错误——您甚至无法拉取图像来尝试运行容器。
现在试试看您可以使用运行 Linux 容器的任何 Docker Engine 来验证这一点——尝试下载一个 Microsoft Windows 图像:
# 拉取 Windows Nano Server 图像: docker image pull mcr.microsoft.com/windows/nanoserver:1809
您可以在图 16.3 中看到我的输出——Docker 获取当前引擎的操作系统和 CPU,并检查注册表中是否有匹配的变体。没有匹配项,因此图像没有被拉取,我得到了一个错误。

图 16.3 如果没有与您的操作系统和 CPU 相匹配的变体,您无法从注册表中拉取图像。
清单列表是镜像的变体集合。Windows Nano Server 镜像并不是真正的多架构,它只能在 Windows 容器上运行——清单列表中没有 Linux 变体。基本原理是镜像的架构必须与引擎的架构相匹配,但也有一些细微差别——Linux 镜像可以用于不匹配的 CPU 架构,但容器将因一个神秘的“用户进程导致‘exec 格式错误’”消息而失败。一些 Windows 引擎有一个名为 Windows 上的 Linux 容器(LCOW)的实验性功能,因此它们可以运行 Linux 容器(但复杂的应用程序将因更神秘的日志而失败)。最好坚持使用与引擎匹配的架构,而多架构镜像让您可以根据需要为每个操作系统和 CPU 定制镜像。
16.2 从一个或多个 Dockerfile 构建多架构镜像
构建多架构镜像有两种方法。在第一种方法中,您遵循本章练习中whoami应用程序的例子:编写一个多阶段 Dockerfile,从源代码编译应用程序并将其打包以在容器中运行。如果您使用的 SDK 和运行时镜像支持您想要支持的所有架构,那么您就可以开始了。
这种方法的巨大优势在于您只有一个 Dockerfile,您可以在不同的机器上构建它以获得您想要支持的架构。我使用这种方法来构建自己的.NET Core 堆栈的黄金镜像;图 16.4 显示了 SDK 的构建方法。

图 16.4 使用基于多架构镜像的多阶段 Dockerfile 构建自己的多架构镜像
如果您的源镜像不是多架构镜像,或者它不支持您想要支持的所有镜像,您就不能遵循这种方法。Docker Hub 上大多数官方镜像都是多架构的,但它们并不支持您可能想要的每一个变体。在这种情况下,您将需要不同的 Dockerfile,可能是一个用于 Linux 和一个用于 Windows,或者可能还需要额外的用于 Arm 32 位和 64 位的 Dockerfile。这种方法需要更多的管理,因为您有多个 Dockerfile 需要维护,但它为您提供了更多的自由来为每个目标架构调整行为。我使用这种方法为我的 Maven(一个用于构建 Java 应用程序的工具)黄金镜像——图 16.5 显示了堆栈。
在本章的练习中,有一个名为folder-list的应用程序非常简单——它只是打印一些关于运行时的一些基本信息,然后列出文件夹的内容。这里有四个 Dockerfile,每个对应本书中支持的架构:Intel 上的 Windows、Intel 上的 Linux、32 位 Arm 上的 Linux 和 64 位 Arm 上的 Linux。您可以使用 Docker Desktop 的 Linux 容器 CPU 模拟来构建和测试其中的三个。

图 16.5 您还可以使用针对每个架构定制的 Dockerfile 构建多架构镜像。
现在试试看 使用每个平台的 Dockerfile 为不同的平台构建镜像。每个 Dockerfile 都略有不同,所以我们可以比较运行容器时的结果:
cd ./folder-list # 为本地架构编译 - Intel/AMD: docker image build -t diamol/ch16-folder-list:linux-amd64 -f ./Dockerfile.linux-amd64 . # 为 Arm 64 位编译: docker image build -t diamol/ch16-folder-list:linux-arm64 -f ./Dockerfile.linux-arm64 --platform linux/arm64 . # 和 Arm 32 位 docker image build -t diamol/ch16-folder-list:linux-arm -f ./Dockerfile.linux-arm --platform linux/arm . # 运行所有容器并验证输出: docker container run diamol/ch16-folder-list:linux-amd64 docker container run diamol/ch16-folder-list:linux-arm64 docker container run diamol/ch16-folder-list:linux-arm
容器在运行时打印一些简单的文本--一个硬编码的字符串,声明它们应该使用的操作系统和架构,然后是操作系统报告的实际操作系统和 CPU,接着是一个包含单个文件的文件夹列表。您可以在图 16.6 中看到我的输出。Docker 在必要时使用仿真,所以在这种情况下,它使用 Arm 模拟器来运行 Arm-32 和 Arm-64 Linux 变体。

图 16.6 镜像是为特定架构构建的,但 Docker Desktop 也支持仿真。
Linux 变体的 Dockerfile 都非常相似,除了预期的架构的硬编码字符串。Windows 变体具有相同的行为,但 Windows 有不同的命令来打印输出。这就是为什么每个架构的多个 Dockerfile 都很有用;我可以有完全不同的 Dockerfile 指令,但仍然得到相同期望的输出。列表 16.1 比较了 64 位 Arm Linux 版本的 Dockerfile 和 64 位 Intel Windows 版本的 Dockerfile。
列表 16.1 Linux 和 Windows 镜像变体的 Dockerfile
# linux FROM diamol/base:linux-arm64 WORKDIR /app COPY file.txt . CMD echo "Built as: linux/arm64" && \ uname -a && \ ls /app # windows # escape=` FROM diamol/base:windows-amd64 WORKDIR /app COPY file.txt . CMD echo Built as: windows/amd64 && ` echo %PROCESSOR_ARCHITECTURE% %PROCESSOR_IDENTIFIER% && ` dir /B C:\app
每个版本都从一个不同的 FROM 镜像开始,这个镜像针对的是目标架构,而不是多架构镜像。Windows Dockerfile 使用 escape 关键字来更改换行符,将其更改为反引号而不是默认的反斜杠,这样我就可以在目录路径中使用反斜杠。Windows 没有与 Linux uname 命令等效的命令,所以为了打印 CPU 架构,我输出了 Windows 设置的一些环境变量。功能大致相同,但我可以采取不同的路径来实现,因为这是一个特定于 Windows 的 Dockerfile。
如果你想要构建第三方应用的多架构版本,通常需要多个 Dockerfile。本书中 Prometheus 和 Grafana 的黄金镜像就是很好的例子。项目团队发布了所有我想使用的 Linux 变体的多架构镜像,但没有为 Windows 发布。因此,我有一个基于项目镜像的 Linux Dockerfile 和一个从网络下载安装应用的 Windows Dockerfile。对于你自己的应用,应该很容易有一个单一的 Dockerfile 并避免额外的维护,但你需要小心,只使用你知道在所有目标架构上都能工作的操作系统命令子集。很容易不小心包含一个在某个架构上不工作的命令(比如uname),最终导致一个损坏的版本。
现在尝试一下:对于文件夹列表应用程序,还有一个 Dockerfile,它尝试创建一个多架构 Dockerfile。它使用多架构镜像作为基础,但它混合了 Linux 和 Windows 命令,因此构建的镜像将在每个架构上都会失败。
# 构建多架构应用: docker image build -t diamol/ch16-folder-list . # 尝试运行它: docker container run diamol/ch16-folder-list
你会发现构建过程成功完成,看起来你有一个好的镜像,但每次运行容器时都会失败。你可以看到我在图 16.7 中的输出——我运行了镜像的 Linux 和 Windows 版本,但两个容器都失败了,因为CMD指令包含无效的命令。

图 16.7 构建一个在部分平台上运行时失败的多架构镜像很容易。
这一点很重要,尤其是如果你使用复杂的启动脚本。如果你使用未知的操作系统命令,RUN指令将在构建时失败,但CMD指令不会被验证,所以你不知道镜像已损坏,直到你尝试运行一个容器。
在我们继续推进多架构镜像之前,有一件最后的事情需要了解,那就是要明白 Docker 支持哪些架构,以及当你开始使用它们时可能会遇到的各个奇怪的代号。表 16.1 显示了主要的操作系统和 CPU 架构组合及其别名。
表 16.1 Docker 支持的架构及其代号
| OS | CPU | 字长 | CPU 名称 | CPU 别名 |
|---|---|---|---|---|
| Windows | Intel/AMD | 64 位 | amd64 | x86_64 |
| Linux | Intel/AMD | 64 位 | amd64 | x86_64 |
| Linux | Arm | 64 位 | arm64 | aarch64, armv8 |
| Linux | Arm | 32 位 | arm | arm32v7, armv7, armhf |
Docker 支持许多其他架构,但这些都是您会发现的主要架构。amd64 CPU 类型是 Intel 和 AMD 机器中相同的指令集,为几乎所有桌面、服务器和笔记本电脑提供动力(Docker 还支持 32 位 Intel x86 处理器)。32 位和 64 位 Arm CPU 存在于手机、物联网设备和单板计算机中;最著名的是树莓派,直到 Pi4 发布之前都是 32 位的,Pi4 是 64 位的。大型机用户也不会被排除在外——Docker 支持 Linux 的 IBM CPU 架构,所以如果您在地下室有一台 IBM Z、POWER 或 PowerPC 机器,您也可以将大型机应用程序迁移到 Docker 容器中。
16.3 将多架构图像推送到带有清单的注册表
您可以使用 Docker Desktop 构建不同 CPU 架构的 Linux 图像,但它们只有在您将它们与清单一起推送到注册表时才成为多架构图像。清单是一块元数据,它将多个图像变体链接到相同的图像标签。清单使用 Docker 命令行生成并推送到注册表。清单包含所有图像变体的列表,它们需要首先存在于注册表中,因此工作流程是创建和推送所有图像,然后创建和推送清单。
现在试试吧!推送您构建的文件夹列表应用的图像变体。首先,您需要用您的 Docker Hub ID 标记它们,以便将它们推送到您的账户——您没有权限将图像推送到 diamol 组织:
# 将您的 Docker ID 存储在一个变量中 - 在 Windows 上: $dockerId = '<your-docker-hub-id>' # 或者 Linux 上: dockerId='<your-docker-hub-id>' # 使用您自己的账户名称标记图像: docker image tag diamol/ch16-folder-list:linux-amd64 "$dockerId/ch16-folder-list:linux-amd64" docker image tag diamol/ch16-folder-list:linux-arm64 "$dockerId/ch16-folder-list:linux-arm64" docker image tag diamol/ch16-folder-list:linux-arm "$dockerId/ch16-folder-list:linux-arm" # 并推送到 Docker Hub(这将推送图像的所有标签): docker image push "$dockerId/ch16-folder-list"
您将看到所有图像都推送到 Docker Hub。Docker 注册表与架构无关——图像规范对所有架构都是相同的,并且注册表以相同的方式存储它们。注册表知道图像是为哪个架构构建的,并在它们被拉取之前将其提供给 Docker Engine 作为检查。我的输出在图 16.8 中——每个图像的架构存储在图像元数据中。我也将其包含在标签中,但这不是必需的。

图 16.8 推送所有图像变体是使多架构图像可用的第一阶段。
管理 Docker 清单是命令行的一个功能,但它是一个新功能,因此您需要启用实验性功能。CLI 和 Docker 引擎都支持实验性功能,但您必须明确选择才能使用它们。您的引擎可能已经在使用它们,但您还需要为客户端启用它们。您可以在 Docker Desktop 的设置中这样做,或者对于 Docker 引擎在命令行上这样做。
现在尝试一下 如果您使用 Docker Desktop,从鲸鱼菜单打开设置,并导航到命令行部分。切换启用实验性功能标志,如图 16.9 所示。

图 16.9 启用 CLI 的实验性功能解锁了 Docker 清单命令。
如果您使用 Docker Community Engine(或企业引擎),从您的家目录编辑或创建 CLI 配置文件:~/.docker/config.json。您只需要一个设置:
{ "experimental":"enabled" }
现在您的 CLI 处于实验模式,您已经解锁了 docker manifest 命令,您可以使用这些命令在本地创建清单,将它们推送到注册表,并且还可以检查注册表上现有的清单。检查清单是查看图像支持哪些架构的好方法,而无需导航 Docker Hub UI。您不需要在本地拉取任何图像--该命令会从注册表中读取所有元数据。
现在尝试一下 通过检查本书基础图像的已发布清单来验证您的 CLI 是否适用于清单命令:
docker manifest inspect diamol/base
manifest inspect 命令没有过滤器参数--您无法限制输出。它将显示所有图像清单,因此它适用于单个图像以及多架构图像。在输出中,您可以看到每个图像的唯一摘要,以及 CPU 架构和操作系统。我的输出在图 16.10 中。我使用了 jq 命令来过滤输出,但这只是为了更容易阅读;您不需要自己这样做。

图 16.10 多架构图像有几个清单;每个清单都包含图像的架构。
现在您可以创建清单,就像图像一样,它首先存在于您的本地机器上,然后您将其推送到注册表。从技术上讲,您正在创建的是一个清单列表,它将一组图像组合在单个图像标签下。每个图像都已经有一个清单,您可以从注册表中检查它,但如果返回多个清单,您将有一个多架构图像。图 16.11 显示了图像、清单和清单列表之间的关系。

图 16.11 清单和清单列表存在于 Docker 注册表中,并包含有关图像的元数据。
你可以将清单列表视为镜像标签列表,清单列表的名称即为多架构镜像的名称。你迄今为止构建的所有镜像都有标签来标识操作系统和 CPU;你可以使用不带标签的相同镜像名称创建清单,这将作为使用默认 latest 标签的多架构镜像可用。你也可以使用包含版本号(除操作系统和 CPU 之外)的标签推送你的镜像,然后多架构标签将只是版本号。
现在尝试一下 创建一个清单以链接所有 Linux 变体,并将其推送到 Docker Hub。清单的名称成为多架构镜像的镜像标签。
# 创建一个包含名称和所有标签的清单: docker manifest create "$dockerId/ch16-folder-list" "$dockerId/ch16-folder-list:linux-amd64" "$dockerId/ch16-folder-list:linux-arm64" "$dockerId/ch16-folder-list:linux-arm" # 将清单推送到 Docker Hub: docker manifest push "$dockerId/ch16-folder-list" # 现在浏览到 Docker Hub 上的你的页面并检查镜像
当你浏览到 Docker Hub 上的镜像时,你会发现有一个 latest 标签,包含多个变体——UI 显示操作系统和 CPU 架构,以及唯一标识每个镜像的摘要。任何拥有 Linux Docker 引擎的人都可以从这个镜像运行容器,它将在英特尔或 AMD 机器上运行 amd64 变体,在 AWS A1 机器或最新的树莓派上运行 arm64 变体,以及在较旧的 Pi 上运行 arm 变体。你可以在图 16.12 中看到我在 Docker Hub 上的仓库。

图 16.12 多架构镜像具有单个名称但许多变体。Docker Hub 显示所有变体。
这些 Arm 镜像是使用 Docker Desktop 中的模拟构建的,这仅适用于偶尔的构建。模拟很慢,并且不是每个指令在模拟中的工作方式都与在真实 CPU 中相同。如果你想支持多架构镜像,并且希望构建快速且针对目标 CPU 100% 准确,你需要一个构建农场。这就是我在本书中构建镜像的原因——几块具有不同 CPU 架构的单板计算机,配置了我要支持的所有操作系统。我的 Jenkins 作业连接到每台机器上的 Docker 引擎,为每个架构构建一个镜像变体并将其推送到 Docker Hub,然后作业创建并推送清单。
16.4 使用 Docker Buildx 构建多架构镜像
运行 Docker 构建农场还有另一种更高效且更易于使用的方法,那就是使用 Docker 的新功能 Buildx。Buildx 是 Docker 构建命令的扩展版本,它使用一个新的构建引擎,该引擎经过大量优化以提高构建性能。它仍然使用 Dockerfile 作为输入并生成镜像作为输出,因此您可以使用它作为 docker image build 的直接替代品。Buildx 在跨平台构建方面表现尤为出色,因为它与 Docker 上下文集成,并且可以通过单个命令在多个服务器之间分配构建。
Buildx 目前不支持 Windows 容器,并且它只支持从单个 Dockerfile 构建,因此它不会涵盖每个场景(我无法用它来构建这本书的镜像)。但如果您只需要支持 Linux 的 CPU 变体,它工作得非常好。您可以使用 Buildx 创建和管理构建农场,以及构建镜像。
我们将使用 Play with Docker 进行一个完整的端到端示例,这样您就可以尝试一个真实的分布式构建农场。第一步是为构建农场中的每个节点创建一个 Docker 上下文。
现在尝试一下,首先设置您的 PWD 会话。浏览到 play-with-docker.com 并将两个实例添加到您的会话中。我们将使用 node1 来执行所有命令。首先存储 node2 的 IP 地址并验证 SSH 连接;然后为 node1 和 node2 创建上下文:
# 存储 node2 的 IP 地址: node2ip=<your-node-2-ip> # 验证 ssh 连接: ssh $node2ip # 然后 exit 以返回到 node1 exit # 使用本地套接字为 node1 创建上下文: docker context create node1 --docker "host=unix:///var/run/docker.sock" # 使用 SSH 为 node2 创建上下文: docker context create node2 --docker "host=ssh://root@$node2ip" # 检查上下文是否存在: docker context ls
这些上下文是为了使 Buildx 设置更简单。您可以在图 16.13 中看到我的输出--node1 是我将运行 Buildx 的客户端,因此它使用本地通道,并且配置为通过 SSH 连接到 node2。

图 16.13 Buildx 可以使用 Docker 上下文来设置构建农场,因此创建上下文是第一步。
设置您的上下文是创建构建农场的第一步。在实际环境中,您的自动化服务器将是 Buildx 客户端,因此您会在 Jenkins(或您使用的任何系统)中创建 Docker 上下文。您将拥有一个或多个机器来支持您想要支持的每个架构,并且您将为它们中的每一个创建一个 Docker 上下文。这些机器不需要与 Swarm 或 Kubernetes 集群;它们可以是仅用于构建镜像的独立机器。
接下来,您需要安装和配置 Buildx。Buildx 是一个 Docker CLI 插件——客户端已经安装在 Docker Desktop 和最新的 Docker CE 版本中(您可以通过运行 docker buildx 来检查)。PWD 没有 Buildx,因此我们需要手动安装它,然后设置一个使用我们两个节点的构建器。
现在试试 Buildx,这是一个 Docker CLI 插件——要使用它,您需要下载二进制文件并将其添加到您的 CLI 插件文件夹中:
# 下载最新的 Buildx 二进制文件: wget -O ~/.docker/cli-plugins/docker-buildx https://github.com/docker/buildx/releases/download/v0.3.1/buildx-v0.3.1.linux-amd64 # 将文件设置为可执行: chmod a+x ~/.docker/cli-plugins/docker-buildx # 现在插件已经安装,使用它通过 node1 创建构建器: docker buildx create --use --name ch16 --platform linux/amd64 node1 # 并将 node2 添加到构建器中: docker buildx create --append --name ch16 --platform linux/386 node2 # 检查构建器设置: docker buildx ls
Buildx 非常灵活。它使用 Docker 上下文发现潜在的构建节点,并连接到它们以查看它们支持哪些平台。您创建一个构建器并将其节点添加到其中,您可以让 Buildx 确定每个节点可以构建的平台,或者您可以具体指定并将节点限制在特定平台上。这就是我在这里所做的那样,所以 node1 将只构建 x64 镜像,而 node2 将只构建 386 镜像。您可以在图 16.14 中看到这一点。

使用 Buildx 设置构建农场非常简单;它使用 Docker 上下文连接到引擎。
现在,构建农场已经准备好了。它可以构建可以以 32 位或 64 位 Intel Linux 容器运行的多架构镜像,只要构建的 Dockerfile 使用的镜像支持这两种架构。Buildx 在构建节点上并发启动构建,将 Dockerfile 和包含 Docker 构建上下文的文件夹(通常包含您的源代码)发送给它们。您可以在 PWD 会话中克隆这本书的 Git 仓库,然后使用单个 Buildx 命令构建和推送此练习的多架构镜像。
现在试试 Buildx。克隆源代码并切换到一个包含文件夹列表应用的多架构 Dockerfile 的文件夹。使用 Buildx 构建和推送多个变体:
git clone https://github.com/sixeyed/diamol.git cd diamol/ch16/exercises/folder-list-2/ # 存储您的 Docker Hub ID 并登录,以便 Buildx 可以推送镜像: dockerId=<your-docker-id> docker login -u $dockerId # 使用 Buildx 通过 node1 和 node2 构建和推送两个变体: docker buildx build -t "$dockerId/ch16-folder-list-2" --platform linux/amd64,linux/386 --push .
Buildx 构建的输出令人印象深刻--当其他人可以看到你的屏幕时,让它运行是件好事。客户端显示了每个构建节点上的日志行,你得到了大量的快速输出,看起来你正在做一项非常技术性的工作。实际上,Buildx 做了所有的工作,你将从输出中看到它甚至为你推送镜像、创建清单并推送清单。图 16.15 显示了构建的结束和 Docker Hub 上的镜像标签。

图 16.15 Buildx 分发 Dockerfile 和构建上下文,收集日志,并推送镜像。
Buildx 使这些多架构构建变得非常简单。你提供你想要支持的每个架构的节点,Buildx 可以使用它们全部,所以无论你是在为两个架构还是十个架构构建,你的构建命令都不会改变。与 Docker Hub 上的 Buildx 镜像有一个有趣的不同之处--没有为变体设置单独的镜像标签,只有一个单一的多架构标签。与之前我们手动推送变体并添加清单的部分相比--所有变体在 Docker Hub 都有自己的标签,随着你构建和部署更多镜像版本,这可能会让用户难以导航。如果你不需要支持 Windows 容器,Buildx 是目前构建多架构镜像的最佳方式。
16.5 理解多架构镜像在你的路线图中的位置
可能你现在不需要多架构镜像。没关系--无论如何,感谢你阅读了这一章。了解多架构镜像的工作原理以及如何构建自己的镜像绝对值得,即使你现在还没有计划这么做,因为它们很可能出现在你的路线图中。你可能承担了一个需要支持物联网设备的项目,或者你可能需要削减云运行成本,或者也许你的客户迫切需要 Windows 支持。图 16.16 显示了项目如何随着支持多架构镜像的需求而发展,在需要时在多年内添加更多变体。

图 16.16 项目以 Linux Intel 支持启动,并随着其流行添加变体。
如果你坚持为所有 Dockerfile 遵循两个简单的规则,你可以确保自己的未来兼容性,并使多架构镜像的转换变得简单:在FROM指令中始终使用多架构镜像,不要在RUN或CMD指令中包含任何特定于操作系统的命令。如果你需要一些复杂的部署或启动逻辑,你可以使用与你的应用程序相同的语言构建一个简单的实用程序应用程序,并在构建的另一个阶段进行编译。
Docker Hub 上的所有官方镜像都是多架构的,所以使用这些作为你的基础镜像是一个好主意(或者你可以使用官方镜像创建自己的黄金基础镜像)。本书的所有黄金镜像也都是多架构的,如果你需要灵感,可以检查源代码中的images文件夹,那里有一系列大量的示例。作为一个粗略的指南,所有现代的应用平台都支持多架构(Go、Node.js、.NET Core、Java),如果你在寻找数据库,Postgres 是我找到的最佳多架构选项。
目前还没有支持所有架构的托管构建服务——一些支持 Linux 和 Windows,但如果你还需要 Arm,你需要自己设置。你可以在 AWS 上运行一个相当便宜的构建农场,使用安装了 Docker 的 Linux、Windows 和 Arm 虚拟机。如果你需要 Linux 和 Windows 但不需要 Arm,你可以使用像 Azure DevOps 或 GitHub Actions 这样的托管服务。重要的是不要假设你永远不会需要支持其他架构:遵循 Dockerfile 中的最佳实践,使多架构支持变得容易,并且知道如果你确实需要添加多架构支持,你需要采取哪些步骤来演进你的构建管道。
16.6 实验室
本章的实验要求你修复一个 Dockerfile,使其能够用于生成多架构镜像。如果你有一个没有遵循我的最佳实践建议的 Dockerfile,你可能会遇到这种情况——这个 Dockerfile 基于特定架构的镜像,并使用不兼容的操作系统命令。我希望你修复本章实验文件夹中的 Dockerfile,使其能够构建针对 Linux on Intel 或 Arm,以及 Windows on Intel 的镜像。解决这个问题有很多方法;这里只是给你提供一些建议:
-
一些 Dockerfile 指令是跨平台的,而
RUN指令中的等效操作系统命令可能不是。 -
一些 Windows 命令与 Linux 相同,在本书的黄金基础镜像中,有一些别名使得其他 Linux 命令可以在 Windows 上运行。
你可以在 GitHub 上找到该章节的Dockerfile.solution文件,链接为:github.com/sixeyed/diamol/blob/master/ch16/lab/README.md。
第四部分. 为你的容器准备生产就绪
你几乎已经知道所有内容,但还有一些东西是你应该掌握的。本书的最后一部分专注于一些在你将容器化应用部署到生产之前会使用的重要实践。你将学习如何优化 Docker 镜像以及将你的应用与 Docker 平台集成--读取配置并写入日志条目。你还将学习一些非常实用的架构方法:使用反向代理和消息队列。它们可能听起来有些吓人,但与 Docker 结合使用时它们既强大又简单易懂。
17 优化 Docker 镜像以实现大小、速度和安全性
一旦你的应用程序被容器化并在集群中运行良好,你可能认为你可以直接进入生产环境,但还有一些最佳实践你需要投入时间。优化你的 Docker 镜像是最重要的之一,因为你需要你的构建和部署要快,你的应用程序内容要安全,你的夜晚要自由——你不想在凌晨 2 点被叫醒,因为你的服务器磁盘空间已满。Dockerfile 语法小巧直观,但它隐藏了一些你需要理解以充分利用镜像构建的复杂性。
本章将带你深入了解镜像格式,让你知道如何以及为什么需要优化它。我们将基于第三章的内容,你学习了 Docker 镜像是从多个镜像层合并而成的。
17.1 如何优化 Docker 镜像
Docker 镜像格式高度优化。尽可能地在镜像之间共享层,这减少了构建时间、网络流量和磁盘使用。但 Docker 对数据持保守态度,它不会自动删除你拉取的镜像——这是你需要明确执行的事情。所以当你替换容器以更新你的应用程序时,Docker 会下载新的镜像层,但不会删除任何旧的镜像层。你的磁盘很容易被大量的旧镜像层吞噬,尤其是在经常更新的开发或测试机器上。
现在试试看 你可以使用 system df 命令查看你的镜像实际占用的磁盘空间,该命令还会显示容器、卷和构建缓存磁盘使用情况:
docker system df
如果你从未清理过 Docker 引擎中的旧镜像,你可能会对结果感到惊讶。我的输出在图 17.1 中——你可以看到有 185 个镜像,总共占用 7.5 GB 的存储空间,尽管我没有运行任何容器。

图 17.1 很容易看到你的磁盘被你甚至没有使用的 Docker 镜像吞噬了。
这个例子是一个轻微的例子——我见过一些被忽视的服务器已经运行 Docker 几年了,浪费了数百 GB 的空间在未使用的镜像上。定期运行 docker system prune 是一个好习惯——它清除镜像层和构建缓存,而不删除完整的镜像。你可以通过计划任务来运行它以删除未使用的层,但如果你的镜像已经优化,那么这不会成为一个大问题。优化你的技术栈的部分通常是一个周期性的过程,有很多小的改进,但使用 Docker,通过遵循一些简单的最佳实践,你可以很容易地做出大的改进。
第一条规则是除非你需要它们,否则不要将文件包含在你的镜像中。这听起来很明显,但你经常会编写一个 Dockerfile,复制整个文件夹结构,而没有意识到该文件夹包含文档或图像或其他在运行时不需要的二进制文件。明确列出你复制的文件可以是你节省的第一个大步骤。比较列表 17.1 中的 Dockerfile——第一个示例复制了整个文件夹,而第二个示例意识到复制添加了一些额外的文件,并包含了一个新步骤来删除它们。
列表 17.1 尝试通过删除文件优化 Dockerfile
# Dockerfile v1 - 复制整个目录结构: FROM diamol/base CMD echo app- && ls app && echo docs- && ls docs COPY . . # Dockerfile v2 - 添加一个新步骤来删除未使用的文件 FROM diamol/base CMD echo app- && ls app && echo docs- && ls docs COPY . . RUN rm -rf docs
在 v2 Dockerfile 中,你会认为镜像大小会更小,因为它删除了额外的 docs 文件夹,但图像层的工作方式并非如此。图像是所有层的合并,所以文件仍然存在于 COPY 层;它们只是被隐藏在删除层中,所以总镜像大小并没有缩小。
现在试试看!构建这两个示例并比较大小
cd ch17/exercises/build-context docker image build -t diamol/ch17-build-context:v1 . docker image build -t diamol/ch17-build-context:v2 -f ./Dockerfile.v2 . docker image ls -f reference= diamol/ch17*
你会发现 v2 图像的大小与 v1 图像完全相同,就像没有运行删除文件夹的 rm 命令一样。你可以看到我在图 17.2 中的输出——我正在使用 Linux 容器,所以大小非常小,但几乎一半的大小来自 docs 文件夹中的不必要文件。

图 17.2 惊奇!如果删除操作在其自己的图层中,删除文件并不会减少图像大小。
Dockerfile 中的每条指令都会生成一个图像层,层合并在一起形成整个图像。如果你在层中写入文件,这些文件将永久存在;如果你在后续层中删除它们,Docker 只是在文件系统中隐藏它们。这是当你进行图像优化时需要理解的一个基本概念——试图在后续层中删除冗余是没有用的——你需要优化每个层。你可以通过从删除之前运行的层中运行图像来轻松地看到删除层只是通过隐藏文件来实现的。
现在试试看!如果你在缓存中有这些层,你可以从任何图像层运行容器。比较最终镜像与之前的图像层:
# 从完成的镜像运行容器: docker container run diamol/ch17-build-context:v2 # 检查图像历史记录以找到上一个层 ID: docker history diamol/ch17-build-context:v2 # 从那个上一个层运行容器: docker container run <previous-layer-id>
镜像的最后一层没有特殊之处。你可以从图像堆栈中的某个层运行容器,并且你会看到文件系统合并到该层。我的输出在图 17.3 中--你可以看到当我从上一个镜像层运行容器时,所有已删除的文件都是可用的。

图 17.3 合并的文件系统隐藏了已删除的文件,但你仍然可以从之前的层访问它们。
这是关于优化的第一个要点--不要将不需要运行应用程序的任何内容复制到镜像中。即使你在后续指令中尝试删除它,它仍然会在镜像堆栈的某个地方存在,占用磁盘空间。在COPY指令中更精确地指定,只将你想要的文件带入镜像会更好。这会使镜像尺寸更小,并且也会使你的 Dockerfile 中的安装文档更加清晰。列表 17.2 显示了此简单应用程序的优化 v3 Dockerfile--与 v1 相比,唯一的更改是它复制了app子文件夹而不是整个目录。
列表 17.2 仅复制必要文件的优化 Dockerfile
FROM diamol/base CMD echo app- && ls app && echo docs- && ls docs COPY ./app ./app
当你构建这个项目时,你会发现图像的大小更小,但在这里你还可以进行另一种优化。Docker 在构建过程中会压缩构建上下文(即运行构建的文件夹)并将其与 Dockerfile 一起发送到引擎。这就是你如何从本地机器上的文件在远程引擎上构建镜像的方法。构建上下文通常包含你不需要的文件,因此你可以通过在名为.dockerignore的文件中列出文件路径或通配符来排除它们。
现在尝试一下:构建优化的 Docker 镜像,然后使用.dockerignore文件再次构建,以减小上下文的大小:
# 构建优化后的镜像;这会将未使用的文件添加到上下文中: docker image build -t diamol/ch17-build-context:v3 -f ./Dockerfile.v3 . # 现在重命名已准备好的忽略文件并检查其内容: mv rename.dockerignore .dockerignore cat .dockerignore # 再次运行相同的构建命令: docker image build -t diamol/ch17-build-context:v3 -f ./Dockerfile.v3 .
你会看到在第一个构建命令中,Docker 将 2 MB 的构建上下文发送到引擎。这没有压缩,所以它是该文件夹中文件的全尺寸--其中大部分是一个 2 MB 的鲸鱼图片。在第二个构建中,当前目录中有一个.dockerignore文件,它告诉 Docker 排除 docs 文件夹和 Dockerfile,因此构建上下文现在是 4 KB。你可以在图 17.4 中看到我的输出。

图 17.4 使用.dockerignore文件减小构建上下文的大小和发送时间。
.dockerignore文件在计算构建上下文中未使用数据的成本时可以节省你大量时间,并且它可以节省空间,即使你在 Dockerfile 中使用显式路径。可能你是在本地构建代码,同时也使用多阶段构建在 Docker 中编译--你可以在.dockerignore文件中指定构建的二进制文件,并确保它们不会被复制到镜像中。文件格式与 Git 的.gitignore文件相同,你可以从 GitHub 上的应用平台模板作为良好的起点(如果你的 Dockerfile 位于仓库的根目录,你应该包括 Git 历史文件夹.git)。
现在你已经看到了管理进入你的 Docker 镜像的文件的重要性,我们将退一步看看你用作基础的镜像。
17.2 选择合适的基镜像
基镜像大小的选择在安全性、磁盘空间和网络传输时间方面同样重要。如果你的基础操作系统镜像很大,它可能包含各种可能在真实机器上有用的工具,但在容器中可能成为安全漏洞。如果你的操作系统基础镜像安装了 curl,攻击者可能利用它下载恶意软件或将你的数据上传到他们的服务器,如果他们设法从你的应用程序容器中突破出来。
这也适用于应用平台基镜像。如果你正在运行 Java 应用程序,OpenJDK 官方镜像是一个好的基础,但有许多标签具有不同的 Java 运行时(JRE)和开发 SDK(JDK)配置。表 17.1 显示了 SDK 与运行时以及最精简版本的多架构镜像的大小差异:
表 17.1 Docker Hub 上兼容 Java 11 镜像的大小差异
| :11-jdk | :11-jre | :11-jre-slim | :11-jre-nanoserver-1809 | |
|---|---|---|---|---|
| Linux | 296 MB | 103 MB | 69 MB | |
| Windows | 2.4 GB | 2.2 GB | 277 MB |
Linux 用户可以使用 69 MB 的基础镜像而不是 296 MB,Windows 用户可以使用 277 MB 而不是 2.4 GB,只需在 Docker Hub 上检查变体,选择具有最小操作系统镜像和最小 Java 安装的镜像。OpenJDK 团队对多架构镜像持谨慎态度。他们选择具有最广泛兼容性的镜像,但尝试使用较小的变体很简单。作为一个好的规则,使用 Alpine 或 Debian Slim 镜像作为 Linux 容器的基操作系统,对于 Windows 容器使用 Nano Server(另一种选择是 Windows Server Core,它几乎与完整的 Windows Server OS 相同--这就是磁盘空间达到数 GB 的原因)。并非每个应用程序都能与较小的变体一起工作,但很容易在FROM行中切换镜像并测试它。
大小不仅仅是关于磁盘空间,它还关乎占用空间的内容。最大的 OpenJDK 镜像包括了整个 Java SDK,因此如果有人设法破坏您的容器,这里就有一个很好的攻击向量。他们可以将一些 Java 源代码文件写入容器的磁盘,使用 SDK 编译它们,并运行一个在您的应用容器安全上下文中做任何他们想做的应用。
现在尝试一下 本章的练习之一是一个使用默认 JDK 镜像的 Java 应用程序。它运行一个非常简单的 REST API,总是返回值 true:
cd ch17/exercises/truth-app # 构建镜像 - 基础镜像使用 :11-jdk 标签: docker image build -t diamol/ch17-truth-app . # 运行应用并尝试使用它: docker container run -d -p 8010:80 --name truth diamol/ch17-truth-app curl http://localhost:8010/truth
您正在运行的容器包含 Java REST API,该 API 在镜像中编译,但它也包含所有编译其他 Java 应用程序的工具。如果攻击者设法从应用中突破并运行任意命令,他们可以运行自己的代码,在您的应用容器安全上下文中做任何他们想做的事情。在这个镜像中,我“意外”包含了一个测试代码文件,恶意用户可以找到并运行它来改变应用的行为。
现在尝试一下 通过连接到您的 API 容器中的 shell 来模拟容器突破。然后使用 JDK 编译并运行测试代码,之后再次检查应用:
# 连接到 API 容器 - 对于 Linux 容器: docker container exec -it truth sh # 或者对于 Windows 容器: docker container exec -it truth cmd # 在容器内编译并运行测试 Java 文件: javac FileUpdateTest.java java FileUpdateTest exit # 回到您的机器上,再次尝试 API: curl http://localhost:8010/truth
您会看到应用的行为已经改变--测试用例将响应设置为 false 而不是 true。图 17.5 中的输出显示的是“黑客”之前的原始响应和改变后的响应。

图 17.5 在您的应用镜像中包含 SDK 会让您面临任意代码执行攻击。
这是一个稍微有些牵强的例子,因为测试文件就在镜像中,使得事情变得简单,但容器突破是可能的,这展示了有趣的攻击选项。平台可以将容器锁定以防止网络访问,但这种攻击仍然有效。教训是,您的基镜像应该包含运行您的应用所需的一切,但不要包含构建应用(如 Node.js 和 Python 这样的解释型语言需要构建工具才能运行)的额外工具。
金色镜像是一种绕过这个问题的方法。你有一个团队选择合适的基镜像并为你组织构建自己的版本。我在这本书中采用了这种方法--我的 Java 应用程序是从 diamol/openjdk 构建的,这是一个多架构镜像,为每个操作系统使用最小的变体。我可以控制我的金色镜像更新的频率,并且可以在金色镜像构建后触发应用程序镜像的构建。自己构建金色镜像的另一个优点是,你可以在构建过程中使用第三方工具如 Anchore 在基层集成额外的安全检查。
现在试试看 Anchore 是一个用于分析 Docker 镜像的开源项目。分析组件在 Docker 容器中运行,但不幸的是,它们没有多架构支持。如果你在 Intel(使用 Docker Desktop 或 Community Engine)上运行 Linux 容器,你将得到支持;否则,你可以启动一个 PWD 会话并克隆这本书的 GitHub 仓库来完成这个练习。
cd ch17/exercises/anchore # 启动所有 Anchore 组件: docker-compose up -d # 等待 Anchore 下载其数据库 - 这可能需要 15 分钟, # 因此你可能想为这个命令打开一个新的终端窗口: docker exec anchore_engine-api_1 anchore-cli system wait # 现在将我的 Java 金色镜像的 Dockerfile 复制到容器中: docker container cp "$(pwd)/../../../images/openjdk/Dockerfile" anchore_engine-api_1:/Dockerfile # 为 Anchore 添加镜像和 Dockerfile 以进行分析: docker container exec anchore_engine-api_1 anchore-cli image add diamol/openjdk --dockerfile /Dockerfile # 等待分析完成: docker container exec anchore_engine-api_1 anchore-cli image wait diamol/openjdk
Anchore 完全启动需要一段时间,因为第一次运行时会下载已知安全问题的数据库。通常,你会将 Anchore 集成到你的 CI/CD 流程中,所以这种影响只会发生在你第一次部署它时。wait 命令将保持你的会话阻塞,直到 Anchore 准备就绪--你可以在图 17.6 中看到我已经添加了我的 OpenJDK 镜像以进行扫描,但它还没有被分析。

图 17.6 使用 Anchore 分析已知问题的 Docker 镜像
当 Anchore 完成分析后,它对你的镜像了解得很多,包括镜像中所有组件使用的开源许可证,一直到操作系统和应用程序平台细节,以及镜像中任何二进制的安全问题。这些发现都可能成为接受更新基镜像的质量关卡的一部分。如果新版本使用了你组织禁止的 OSS 许可证,或者它包括关键的安全漏洞,你可能会跳过这次更新。Anchore 为 Jenkins 等 CI/CD 工具提供了插件,因此你可以在你的管道中自动应用这些策略,你也可以直接使用 Anchore API 容器查询结果。
现在尝试一下 当上一个练习中的等待命令完成后,镜像已经被分析。检查 Anchore 关于应用程序平台和镜像安全问题的发现:
# 检查 Anchore 在镜像中发现的 Java 组件: docker container exec anchore_engine-api_1 anchore-cli image content diamol/openjdk java # 并检查已知的安全问题: docker container exec anchore_engine-api_1 anchore-cli image vuln diamol/openjdk all
这些只是 Anchore 可以提供的一些输出样本--在这种情况下,它提供了镜像中 Java 运行时的详细信息以及一个庞大的安全漏洞列表。在撰写本文时,这些漏洞的严重性都是可忽略的--这意味着它们不构成重大威胁,你可能在你的镜像中接受它们。输出包括一个链接到漏洞详细信息的链接,你可以阅读更多内容并自行决定。图 17.7 显示了扫描结果的局部输出。

图 17.7 Anchore 将其数据库中的所有安全漏洞与镜像中的所有二进制文件进行比对。
这些结果是可接受的,因为我已经为我的黄金镜像选择了最小的 OpenJDK 基镜像。如果你将官方的openjdk:11-jdk镜像添加到 Anchore 并检查结果,你会看到它有更多的漏洞,很多是“未知”严重性,还有一个“低”严重性的核心 SSL 安全库。这可能不是可接受的,所以你可能希望阻止用户基于该镜像构建他们的应用,即使它是由 OpenJDK 团队维护的官方镜像。
Anchore 只是这个领域中的一个技术--你可以从你自己运行的开源项目中获得类似的功能(如 Clair),或者可以与你的 Docker 注册表集成的商业项目(如 Aqua)。这样的工具真的可以帮助你了解镜像的安全性,并对你构建的黄金镜像集有信心。你还可以在应用镜像上运行这些工具,你应该检查的一个策略是每个应用都应从你自己的黄金镜像中构建。这强制使用你精选和批准的镜像。
17.3 最小化镜像层数量和层大小
一个最小化和安全的基镜像是你优化应用镜像的前提。下一步真正要做的是设置包含你应用所需一切内容且不包含其他内容的镜像--这比听起来要深奥得多。许多安装软件的过程会在后面留下残留物,因为它们缓存了软件包列表或部署了额外的推荐软件包。你可以控制这些内容--对于不同的操作系统,细节可能不同,但总体方法是一样的。
现在试试看,Debian Linux 使用 APT(高级包工具)来安装软件。本练习通过一个简单的例子来展示如何移除不必要的软件包和清理软件包列表可以节省大量空间(本练习在 Windows 容器中无法工作--可以选择使用 Play with Docker):
cd ch17/exercises/socat # v1 镜像使用标准的 apt-get 命令安装软件包: docker image build -t diamol/ch17-socat:v1 . # v2 镜像安装相同的软件包,但使用了优化调整: docker image build -t diamol/ch17-socat:v2 -f Dockerfile.v2 . # 检查镜像大小: docker image ls -f reference=diamol/ch17-socat
两个版本的 Dockerfile 都在相同的 Debian Slim 镜像上安装了相同的两个工具--curl 和 socat,并且它们的功能完全相同。但你将看到 v2 镜像几乎小了 20 MB,如图 17.8 所示。
我只是对安装命令进行了一些调整,就实现了节省空间。第一个调整是利用 APT 的一个特性,只安装列出的软件包而不安装任何推荐软件包。第二个调整是将安装步骤合并为一个 RUN 指令,该指令以删除软件包列表缓存并释放磁盘空间的命令结束。列表 17.3 展示了 Dockerfile 之间的差异。
列表 17.3 安装软件包--错误的方式和优化的方式
# Dockerfile - 使用 APT 的简单安装: FROM debian:stretch-slim RUN apt-get update RUN apt-get install -y curl=7.52.1-5+deb9u9 RUN apt-get install -y socat=1.7.3.1-2+deb9u1 # Dockerfile.v2 - 优化安装步骤: FROM debian:stretch-slim RUN apt-get update \ && apt-get install -y --no-install-recommends \ curl=7.52.1-5+deb9u9 \ socat=1.7.3.1-2+deb9u1 \ && rm -rf /var/lib/apt/lists/*
将多个步骤合并到一个 RUN 指令中的另一个优点是它会产生一个单独的镜像层。减少镜像层的数量并不是真正的优化。镜像层的最大数量是有限的,但通常足够大--通常是 127 层,这取决于操作系统。但拥有更少的层确实使得跟踪文件系统变得容易得多。将删除软件包列表的最终 rm 命令放入自己的 RUN 指令中是很简单的,并且可以说这使 Dockerfile 更易于阅读。但你从本章中知道,从之前的层中删除文件只是将它们从文件系统中隐藏起来,所以如果你这样做,就不会节省磁盘空间。
让我们再看看一个适用于所有平台的这种模式的例子。通常你需要从互联网下载一个压缩的软件包,然后展开它。当你正在处理 Dockerfile 时,将下载步骤放在单独的指令中是很诱人的,这样你可以使用缓存的下载层并加快你的开发时间。这很好,但一旦你的 Dockerfile 开始工作,你需要进行整理,将下载-展开-删除步骤合并成一个指令。

图 17.8 在这个练习中,优化软件安装将图像大小减少了 20%以上。
现在试试看 机器学习数据集是一个很好的例子,因为它们是大型下载,会扩展到更大的文件夹结构。在本章的练习中,有一个例子是从加州大学欧文分校(UCI)的存档中下载一个数据集,并从中提取一个文件。
cd ch17/exercises/ml-dataset # v1 下载并展开存档,然后删除不必要的文件: docker image build -t diamol/ch17-ml-dataset:v1 . # v2 下载存档,但只展开必要的文件: docker image build -t diamol/ch17-ml-dataset:v2 -f Dockerfile.v2 . # 比较大小: docker image ls -f reference=diamol/ch17-ml-dataset
你会看到巨大的大小差异,这完全是由于相同的优化技术——确保层不需要比它们需要的更多文件。我的结果在图 17.9 中——两个图像都有从数据下载的单个文件,但一个接近 2.5 GB,另一个只有 24 MB。

仔细关注你如何处理文件可以节省大量的磁盘空间。
这不是一个虚构的例子。当你迭代 Dockerfile 时,将指令分开是很常见的,因为这可以更容易地进行调试——你可以在构建过程中从某个层运行容器并调查文件系统,你可以在后续指令上工作,同时保留缓存的下载。当你将多个命令压缩到一个RUN指令中时,你无法这样做,但当你对构建满意时,进行这种优化是很重要的。列表 17.4 显示了优化的 Dockerfile,它为数据文件生成单个层(下载 URL 在此处被缩写,但你将在章节的源代码中看到它)。
列表 17.4 下载和提取文件的优化方法
FROM diamol/base ARG DATASET_URL=https://archive.ics.uci.edu/.../url_svmlight.tar.gz WORKDIR /dataset RUN wget -O dataset.tar.gz ${DATASET_URL} && \ tar -xf dataset.tar.gz url_svmlight/Day1.svm && \ rm -f dataset.tar.gz
在这里最大的节省实际上并不是来自删除存档;而是仅仅提取单个文件。v1 方法会展开整个存档(这就是 2GB 磁盘空间被占用的原因),然后删除所有文件,除了所需的那个。了解你的工具如何工作以及哪些特性可以最小化磁盘使用量,有助于你将层的大小控制在合理范围内,就像在这个例子中用 tar 和在之前的例子中使用 APT 所看到的那样。
对于这种情况,还有一种替代方法可以提供最佳的开发者工作流程和优化的最终镜像,那就是使用具有单独阶段的所有磁盘密集型步骤的多阶段 Dockerfile。
17.4 将你的多阶段构建提升到下一个水平
你第一次在第四章中看到多阶段构建,我们使用一个阶段从源代码编译应用程序,在后面的阶段中打包编译的二进制文件以供运行时使用。对于所有除了最简单的镜像之外的所有镜像,多阶段 Dockerfile 应该是一个最佳实践,因为它们使得优化最终的镜像变得容易得多。
我们可以重新审视数据集下载器,并为每个步骤使用单独的阶段。列表 17.5 显示了这样做的 Dockerfile 会更加易于阅读(下载 URL 再次被缩写)。
列表 17.5 多阶段 Dockerfile 有助于提高可读性并简化优化
FROM diamol/base AS download ARG DATASET_URL=https://archive.ics.uci.edu/.../url_svmlight.tar.gz RUN wget -O dataset.tar.gz ${DATASET_URL} FROM diamol/base AS expand COPY --from=download dataset.tar.gz . RUN tar xvzf dataset.tar.gz FROM diamol/base WORKDIR /dataset/url_svmlight COPY --from=expand url_svmlight/Day1.svm .
在每个阶段中都很清楚你在做什么,你不需要深入研究不寻常的命令优化来节省磁盘空间,因为最终的镜像将只包含从早期阶段显式复制进来的文件。当你构建 v3 版本时,你会发现它与优化后的 v2 版本大小相同,但它有一个优点,那就是易于调试。多阶段 Dockerfile 可以构建到特定的阶段,所以如果你需要在构建过程中检查文件系统,你可以轻松地做到这一点,而无需在镜像历史中搜索层 ID。
现在尝试一下 target 参数让你可以在特定阶段停止多阶段构建。尝试使用不同的目标构建那个 v3 镜像:
cd ch17/exercises/ml-dataset # 构建完整的 v3 镜像: docker image build -t diamol/ch17-ml-dataset:v3 -f Dockerfile.v3 . # 构建到 'download' 目标 - 相同的 Dockerfile,不同的标签: docker image build -t diamol/ch17-ml-dataset:v3-download -f Dockerfile.v3 --target download . # 并构建到 'expand' 目标: docker image build -t diamol/ch17-ml-dataset:v3-expand -f Dockerfile.v3 --target expand . # 检查镜像大小: docker image ls -f reference=diamol/ch17-ml-dataset:v3*
现在,你将拥有 v3 图像的三个变体。完整的构建与优化构建相同,都是 24 MB,所以我们没有在迁移到多阶段 Dockerfile 时丢失任何优化。其他变体在特定阶段停止构建,如果你需要调试文件系统,你可以从这些镜像之一运行容器。阶段构建还显示了磁盘空间的使用情况——你可以在图 17.10 中看到下载大约为 200 MB,并扩展到超过 2 GB。

图 17.10 通过构建到特定阶段的多阶段 Dockerfile,你可以调试内容并检查大小。
这确实是最好的方法——你得到了一个优化的图像,但你仍然可以保持你的 Dockerfile 指令简单,因为你不需要在中间阶段清理磁盘。
多阶段构建的最后一个优点真正地让人印象深刻:每个阶段都有自己的构建缓存。如果你需要调整 expand 阶段,当你运行构建时,download 阶段仍然来自缓存。最大化构建缓存是优化的最后一部分,这完全关乎构建图像的速度。
充分利用构建缓存的基本方法是将你的 Dockerfile 中的指令排序,使变化最少的部分放在前面,变化最多的部分放在后面。这需要几次迭代才能正确设置,因为你需要了解步骤变化的频率,但通常你可以在文件开头放置静态设置,如暴露的端口、环境变量和应用程序入口点。变化最多的部分是你的应用程序二进制文件和配置文件,它们可以放在文件末尾。设置正确后,你可以显著减少构建时间。
现在试试看 这个练习构建了一个最小的 Jenkins 安装。它是不完整的,所以不要尝试运行它——我们只是用它来进行构建。Dockerfile 下载 Jenkins Java 文件并设置初始配置。v2 Dockerfile 充分利用了缓存,当你进行内容更改时你会看到:
cd ch17/exercises/jenkins # 构建 v1 图像和优化的 v2 图像: docker image build -t diamol/ch17-jenkins:v1 . docker image build -t diamol/ch17-jenkins:v2 -f Dockerfile.v2 . # 现在更改两个 Dockerfile 都使用的配置文件: echo 2.0 > jenkins.install.UpgradeWizard.state # 重复构建并查看它们运行的时间: docker image build -t diamol/ch17-jenkins:v1 . docker image build -t diamol/ch17-jenkins:v2 -f Dockerfile.v2 .
第二轮构建是缓存发挥作用的地方。v1 Dockerfile 在下载 Jenkins 文件(这是一个 75 MB 的下载)之前将配置文件复制到镜像中,所以当配置文件发生变化时,它会破坏缓存,下载就会重新开始。v2 Dockerfile 使用多阶段构建并将指令顺序设置为将配置文件复制放在最后。我使用 PowerShell 中的Measure-Command函数来检查每个构建的持续时间(Linux 中有一个等效的time命令)。你可以在图 17.11 中看到,正确排序指令和使用多阶段 Dockerfile 可以将构建时间从 10 多秒缩短到不到一秒。

图 17.11 正确排序 Dockerfile 指令可以意味着在构建时间上的巨大节省。
充分利用缓存可以让你在每次从源代码控制中更改时构建和推送 Docker 镜像,而不会在 CI/CD 管道中消耗时间。不过,你确实需要确保不要过度缓存东西,因为如果你使用RUN指令安装或下载软件,它们将被缓存,直到 Dockerfile 中的指令发生变化(假设在那些指令之前缓存没有被破坏)。当你向你的镜像添加包时,你应该始终使用显式版本,这样你就能确切知道你在运行什么,并且你可以选择何时更新。列表 17.3 中的 socat 示例在 APT 命令中使用了显式版本号,而 Jenkins 示例使用了ARG指令来下载版本——这两种方法都让你能够在更改安装的版本之前使用缓存。
17.5 理解优化为何重要
你在本章中看到,你可以遵循一些简单的最佳实践,让你的 Dockerfile 变得易于使用。这些实践归结为
-
选择正确的基镜像——理想情况下,精心挑选你自己的黄金镜像集合。
-
对于所有除了最简单的应用之外,都使用多阶段 Dockerfile。
-
不要添加任何不必要的包或文件——关注层的大小。
-
按变更频率对 Dockerfile 指令进行排序——最大化缓存。
随着你将更多应用迁移到容器,构建、推送和拉取镜像成为你组织工作流程的核心部分。优化这些镜像可以消除许多痛点,加快工作流程,并防止更严重的问题。图 17.12 显示了镜像的典型生命周期以及优化起作用的地方。

图 17.12 优化你的 Docker 镜像对你的项目生命周期有积极的影响。
17.6 实验室
现在是时候测试你的优化技能了。你的目标是优化一个安装 Docker 命令行的镜像。本章的实验室文件夹中有 Linux 和 Windows 的示例;Dockerfile 目前工作正常,但它们产生了不必要的大的镜像。你的目标是
-
优化文件系统,使得 Linux 容器镜像小于 80 MB,或 Windows 容器镜像小于 330 MB。
-
利用图像层缓存,这样你的图像重复构建所需时间将少于 1 秒。
-
生成一个能够正确写入 Docker CLI 版本的镜像,从
dockercontainerrun<image>dockerversion(该命令会因为服务器未连接到 Docker Engine 而报错,但 CLI 版本应该能够正确打印)。
你可能不需要任何提示,但在查看原始 Docker 文件时需要具有创造性思维。你可能不会通过优化现有指令达到目标;可能从图像的目标反向工作会更好。
我的优化文件在同一个实验文件夹中--你还可以在 GitHub 上查看它们:github.com/sixeyed/diamol/blob/master/ch17/lab/README.md。
你已经有了知识,现在去优化吧!
18 容器中的应用配置管理
应用程序需要从它们运行的环境中加载其配置,这通常是环境变量和从磁盘读取的文件的组合。Docker 为在容器中运行的应用程序创建该环境,它可以设置环境变量并从许多不同的来源构建文件系统。所有这些部件都是为了帮助您为应用程序构建灵活的配置方法,因此当您部署到生产环境时,您使用的是通过了所有测试阶段的相同镜像。您只需做一些工作来将这些部件组合在一起,设置您的应用程序以从多个位置合并配置值。
本章将通过.NET Core、Java、Go 和 Node.js 中的示例,向您介绍推荐的方法(以及一些替代方案)。这里的一些工作位于开发空间中,引入库以提供配置管理,其余部分位于开发和运维之间的灰色区域,该区域依赖于沟通,以便双方都了解配置模型的工作方式。
18.1 应用配置的多层方法
您的配置模型应该反映您存储的数据的结构,通常是以下三种类型之一:
-
发布级别的设置,对于给定发布的每个环境都是相同的
-
环境级别的设置,对于每个环境都是不同的
-
特性级别的设置,可用于在发布之间更改行为
其中一些相对静态,一些是动态的,具有一组已知的变量,而其他则是动态的,具有一组未知的变量。图 18.1 显示了某些示例配置设置以及它们可以从环境中读取的位置。

图 18.1 从镜像、文件系统和环境变量中读取设置的配置层次结构
我们将要使用的第一个例子是 Node.js,以及一个流行的配置管理库,名为 node-config。这个库允许您从层次结构中的多个文件位置读取配置,并通过环境变量覆盖它们。本章练习中的 access-log 示例应用程序使用了 node-config 库,并设置了两个目录来读取配置文件
-
config-- 这将打包到 Docker 镜像中的默认设置中。 -
config-override-- 这个配置在镜像中不存在,但可以从卷、配置对象或秘密中在容器文件系统中配置。
现在尝试一下 运行具有默认配置的示例应用程序,然后是具有开发环境覆盖文件的相同镜像:
cd ch18/exercises/access-log # 在镜像中运行默认配置的容器: docker container run -d -p 8080:80 diamol/ch18-access-log # 运行加载本地配置文件覆盖的容器: docker container run -d -p 8081:80 -v "$(pwd)/config/dev:/app/config-override" diamol/ch18-access-log # 检查每个容器中的配置 API: curl http://localhost:8080/config curl http://localhost:8081/config
第一个容器仅使用镜像中打包的默认配置文件--指定了发布周期名称(19.12)并设置了要启用的 Prometheus 指标。环境名称有一个UNKNOWN设置--如果你看到这个设置,你就知道环境级别的配置设置还没有正确应用。第二个容器将本地配置目录作为卷加载到应用程序预期的覆盖位置--它设置了环境名称并将指标功能关闭。当你调用配置 API 时,你会看到来自同一镜像的容器应用了不同的设置--我的设置在图 18.2 中。

图 18.2 使用卷、配置对象或机密项合并配置文件非常直接。
从应用程序代码中的已知路径加载配置覆盖项可以让你在容器中从任何来源提供它们。我正在使用本地绑定挂载,但源可以是配置对象或存储在容器集群中的机密(如我们在第十章和第十三章中看到的),行为将是相同的。这个模式有一个细微差别--你的配置目标可以是特定的文件路径或目录。目录目标更灵活(Windows 容器不支持从单个文件加载卷),但源文件名需要与应用程序期望的配置文件名匹配。在这个例子中,绑定源是目录config/dev,它只有一个文件--容器看到/app/config-override/local.json,这是它查找覆盖的地方。
node-config 包还可以从环境变量加载设置,并且它们会覆盖从文件层次结构加载的任何设置。这是在《十二要素应用》中推荐的方法(12factor.net)--一种现代的应用架构风格,其中环境变量始终优先于其他配置源。这是一个有用的方法,有助于你养成容器是瞬时的思维模式,因为更改环境变量以设置应用程序配置意味着替换容器。Node-config 有一个稍微不同寻常的实现:不是将单个设置指定为环境变量,你需要以 JSON 格式字符串的形式在环境变量中提供设置。
现在试试看 运行第三个版本的访问日志容器,以开发模式运行但启用指标。使用卷挂载加载 dev 配置,并使用环境变量覆盖指标设置:
cd ch18/exercises/access-log # 运行一个带有覆盖文件和环境变量的容器: docker container run -d -p 8082:80 -v "$(pwd)/config/dev:/app/config-override" -e NODE_CONFIG='{\"metrics\": {\"enabled\":\"true\"}}' diamol/ch18-access-log # 检查配置: curl http://localhost:8082/config
第三个容器合并了镜像中的默认文件、卷中的本地配置覆盖文件和特定的环境变量设置。这是一个构建配置以使开发者工作流程顺利运行的优秀示例。开发者可以运行默认设置而不启用度量(这将节省 CPU 周期和内存),但当他们需要为某些调试打开度量时,他们可以使用相同的镜像和环境变量开关来完成。图 18.3 显示了我的输出。

图 18.3 从环境变量合并配置使更改特定功能变得容易。
这是您应该在所有应用程序中应用的配置核心模式。从这个例子中,您可以清楚地看到模式,但细节很重要,这是在交付和部署之间知识可能崩溃的灰色区域。访问日志应用程序允许您使用新的配置文件覆盖默认配置文件,但该目标文件必须位于特定位置。您还可以使用环境变量覆盖所有文件设置,但环境变量需要是 JSON 格式。最终,这将在您用于部署的 YAML 文件中记录,但您需要意识到该模式可能存在错误的风险。一种替代方法可以消除这种风险,但代价是使配置管理变得不那么灵活。
18.2 为每个环境打包配置
许多应用程序框架支持配置管理系统,其中您将部署中每个环境的所有配置文件捆绑在一起,并在运行时设置一个值以指定正在运行的环境名称。应用程序平台加载与环境名称匹配的配置文件,您的应用程序就完全配置好了。.NET Core 通过其默认配置提供程序设置来实现这一点,其中配置设置从以下来源合并:
-
appsettings.json-- 所有环境的默认值 -
appsettings.{Environment}.json-- 对指定环境的覆盖设置 -
环境变量 -- 用于指定环境名称和设置覆盖
本章的新任务列表应用程序使用在 Docker 镜像中打包所有配置文件的方法。您使用特定的环境变量来提供当前的环境名称,该名称在配置文件之前加载。
现在尝试一下 运行默认配置的任务列表应用程序,默认配置设置为开发环境名称,然后使用测试环境设置:
# 使用默认配置运行待办事项列表应用: docker container run -d -p 8083:80 diamol/ch18-todo-list # 使用测试环境的配置运行应用: docker container run -d -p 8084:80 -e DOTNET_ENVIRONMENT=Test diamol/ch18-todo-list
这两个容器是从相同的镜像运行,但加载不同的配置文件。在镜像内部,有针对开发、测试和生产环境的配置文件。第一个容器将核心的appsettings.json与appsettings.Development.json合并--因为它在开发模式下运行,因为在 Dockerfile 中将 Development 设置为默认环境。第二个容器将appsettings.json与appsettings.Test.json合并。这两个环境配置文件已经存在于 Docker 镜像中,因此不需要挂载外部源以获取新的配置。浏览到 http:/ /localhost:8083/diagnostics 以查看开发配置,浏览到 http:/ /localhost:8084/diagnostics 以查看测试版本。我的输出在图 18.4 中。

图 18.4 将每个环境的配置文件打包到镜像中,使得环境切换变得容易。
如果你有一个独立系统来管理你的配置文件和源代码,这种方法可以很好地工作。CI/CD 管道可以将配置文件作为构建的一部分带入 Docker 镜像,这样你就可以将配置管理从开发中分离出来。缺点是,你仍然不能打包每个设置,因为你需要将机密信息从 Docker 镜像中排除。你需要有一个多层次的安全方法,并假设你的注册表可能会被攻破--在这种情况下,你不想让某人找到你镜像中的所有密码和 API 密钥,这些信息以漂亮的纯文本文件的形式存在。
如果你喜欢这种方法,你仍然需要允许覆盖文件,以及使用环境变量的最终覆盖。待办事项列表应用就是这样做的,如果存在名为config-overrides的文件夹,它会从该文件夹中加载文件,并使用.NET Core 的加载环境变量的标准方法,最后加载环境变量。这让你能够做些有用的事情,比如在尝试复现问题时在本地运行生产环境,但覆盖环境设置以使用数据库文件而不是远程数据库服务器。
现在试试看 待办事项列表应用仍然支持配置覆盖,尽管所有环境配置都打包在应用中。如果你以生产模式运行,应用会失败,因为它期望找到数据库服务器,但你可以通过覆盖文件以使用数据库文件而不是远程数据库服务器来以生产模式运行:
cd ch18/exercises/todo-list docker container run -d -p 8085:80 -e DOTNET_ENVIRONMENT=Production -v "$(pwd)/config/prod-local:/app/config-override" diamol/ch18-todo-list
你可以浏览到 http:/ /localhost:8085/diagnostics 并看到应用正在以生产模式运行,但配置文件覆盖改变了数据库设置,因此应用仍然可以运行,无需运行 Postgres 容器。我的输出在图 18.5 中。

图 18.5 选择运行环境时,应仍然支持来自附加文件中的配置覆盖。
此容器将默认的 appsettings.json 文件与 prod-local 文件夹中的环境文件 appsettings.Production.json 和覆盖文件 local.json 合并。设置与 Node.js 示例类似,因此文件夹和文件名周围有一些一致性,但 .NET Core 在使用环境变量设置覆盖方面采取了不同的方法。在 node-config 中,你通过将 JSON 字符串作为环境变量传递来覆盖设置,但在 .NET Core 中,你指定单个设置作为环境变量。
现在试试看 运行与生产相同的本地版本,但通过覆盖该设置使用自定义发布名称:
# 使用绑定挂载和自定义环境变量运行容器: docker container run -d -p 8086:80 -e DOTNET_ENVIRONMENT=Production -e release=CUSTOM -v "$(pwd)/config/prod-local:/app/config-override" diamol/ch18-todo-list
浏览到 http:/ /localhost:8086/diagnostics,你将看到来自环境变量的自定义发布名称。我的输出在图 18.6 中。

图 18.6 配置层次结构覆盖了任何配置文件中的环境变量值。
我必须说,我不喜欢这种打包多个配置文件的方式,尽管这在许多应用程序平台上是一种常见的做法。存在一种风险,你可能会在镜像中包含一些你认为不敏感的配置设置,但你的安全团队可能不同意。服务器名称、URL、文件路径、日志级别,甚至缓存大小都可能是有意攻击你系统的人有用的信息。在你将所有机密设置移动到从运行时应用的覆盖文件中之前,这些打包的环境文件中可能已经剩下很少的内容了。我也不喜欢这种分割,其中一些设置在源代码控制中管理,而其他设置在配置管理系统中。
容器的美妙之处在于你可以遵循你喜欢的任何模式,所以不要让我为你做决定。根据你的组织和技术堆栈,某些方法可能更有效。如果你要处理多个堆栈,事情也会变得更加复杂——你将在下一个使用 Go 应用程序的示例中看到这一点。
18.3 从运行时加载配置
Go 语言有一个流行的配置模块叫做 Viper,它提供了与 .NET Core 库或 node-config 相当多的功能。你将这个模块添加到你的包列表中,然后在你的应用程序代码中指定配置目录的路径以及你是否希望环境变量用来覆盖配置文件。我已经将它添加到本章的图像库应用程序中,使用与其他示例相似的层次结构:
-
文件首先从
config目录加载,该目录在 Docker 镜像中填充。 -
从
config-override目录加载特定环境的文件,该目录在镜像中为空,可以是容器文件系统挂载的目标。 -
环境变量覆盖文件设置。
Viper 支持比其他示例更广泛的配置文件语言集。你可以使用 JSON 或 YAML,但在 Go 世界中流行的格式是 TOML(以创建者 Tom Preston-Werner 的名字命名)。TOML 非常适合配置文件,因为它可以轻松地映射到代码中的字典,并且比 JSON 或 YAML 更容易阅读。列表 18.1 显示了图片库应用的 TOML 配置。
列表 18.1 TOML 格式使得配置文件易于管理
release = "19.12" environment = "UNKNOWN" [metrics] enabled = true [apis] [apis.image] url = "http://iotd/image" [apis.access] url = "http://accesslog/access-log"
你会在许多云原生项目中看到 TOML 的使用,因为它比其他替代方案容易得多。如果你有多种格式可供选择,TOML 值得考虑,因为“易于阅读”也意味着易于调试,并且在合并工具中更容易看到版本之间的差异。除了文件格式之外,此示例与 Node.js 应用的工作方式相同,默认的config.toml文件打包到 Docker 镜像中。
现在尝试:运行应用而不进行任何额外的配置设置,以检查默认设置:
# 运行容器: docker container run -d -p 8086:80 diamol/ch18-image-gallery # 检查配置 API: curl http://localhost:8086/config
当你运行这个练习时,你会看到当前的应用配置,所有这些配置都来自默认的 TOML 文件。我的输出在图 18.7 中,显示了发布周期和该应用所消耗的 API 的默认 URL。

图 18.7 你可以将应用与默认设置打包,这些设置可以工作,但不是完整的环境。
输出来自返回当前配置设置 JSON 的配置 API。当你的应用有多个配置源层时,配置 API 是一个非常有用的功能;它使得调试配置问题变得容易得多,但你需要保护这些数据。如果它们可以被尝试浏览/config的任何人公开读取,那么使用机密设置就没有意义,所以如果你要添加配置 API,你需要做三件事:
-
不要只发布整个配置;要有选择性,永远不要包含机密信息。
-
保护端点,以确保只有授权用户可以访问它。
-
将配置 API 作为一个可以通过配置启用的功能。
图片库应用采用了与分层配置模型略有不同的方法--默认设置保存在图片中,但不是针对任何特定环境。预期是每个环境都将指定自己的附加配置文件,该文件扩展或覆盖默认文件中的设置,以设置完整的环境。
现在尝试一下 再次运行相同的应用程序,使用覆盖文件来构建一个完整的环境:
cd ch18/exercises/image-gallery # 使用绑定挂载到本地配置目录运行容器: docker container run -d -p 8087:80 -v "$(pwd)/config/dev:/app/config-override" diamol/ch18-image-gallery # 再次检查配置: curl http://localhost:8087/config
我在图 18.8 中的输出显示,应用程序现在已完全配置为开发环境,将镜像中的发布级别配置文件与环境覆盖文件合并。

图 18.8 Go Viper 模块以与 node-config 包相同的方式合并配置文件。
展示所有这些配置主题的细微变化并不仅仅是为了填补章节内容。当组织采用 Docker 时,它们往往会发现使用速度加快,很快就会有很多应用程序在容器中运行,每个应用程序都有自己的配置观点。由于应用程序平台在提供的功能和期望的约定上存在差异,因此必然会发生许多这样的小变化。您可以在高层次上应用标准——镜像必须包含默认配置,并且必须支持文件和环境变量覆盖——但配置文件和环境变量格式的细节将难以标准化。
我们将在最后一个 Go 应用程序的例子中看到这一点。Viper 模块支持环境变量来覆盖配置文件中的设置,但与 node-config 和.NET Core 的约定不同。
现在尝试一下 使用环境变量覆盖运行容器。此应用程序的配置模型仅使用以IG为前缀的环境变量:
cd ch18/exercises/image-gallery # 使用配置覆盖和环境变量运行容器: docker container run -d -p 8088:80 -v "$(pwd)/config/dev:/app/config-override" -e IG_METRICS.ENABLED=TRUE diamol/ch18-image-gallery # 检查配置: curl http://localhost:8088/config
Viper 有一个约定,即你应该给环境变量名称添加前缀,以避免与其他环境变量冲突。在这个应用程序中,前缀是IG,后面跟着一个下划线,然后是配置设置名称的点表示法(因此IG_METRICS .ENABLED与 TOML 文件中metrics组中的enabled值匹配)。您可以从我在图 18.9 中的输出中看到,这种设置在默认设置之上添加了开发环境,但随后覆盖了指标设置以启用 Prometheus 指标。

图 18.9 所有示例应用程序都支持配置环境变量,但存在细微差异。
我们已经用三个不同的应用程序演示了配置建模,并且我们有三种略有不同的方法。这些差异是可管理的,并且可以在应用程序清单文件中轻松记录,并且实际上不会影响您构建镜像或运行容器的方式。在本章中,我们将查看一个最后的例子,它采用相同的配置模型并将其应用于没有优雅的新配置库的应用程序,因此需要做一些额外的工作来使其表现得像现代应用程序。
18.4 以与新版应用程序相同的方式配置旧版应用程序
旧版应用程序对配置有自己的看法,通常不涉及环境变量或文件合并。例如,Windows 上的.NET Framework 应用程序——它们期望在特定位置有 XML 配置文件。它们不喜欢在应用程序根文件夹外寻找文件,并且根本不查看环境变量。您仍然可以使用相同的配置方法来处理这些应用程序,但您需要在 Dockerfile 中做一些额外的工作。
这里的方法是将一个实用程序应用程序或脚本集打包到容器环境中,将这些配置设置转换为应用程序期望的配置模型。具体的实现将取决于您的应用程序框架以及它如何使用配置文件,但逻辑可能如下所示:
-
从容器中指定的源文件读取配置覆盖设置。
-
从环境变量中读取覆盖设置。
-
合并两组覆盖设置,使环境变量具有优先权。
-
将合并后的覆盖设置写入容器中指定的目标文件。
在本章的练习中,有一个使用此方法的每日图像 Java API 的更新版本。它实际上不是一个旧版应用程序,但我已经按照旧版模式构建了镜像,就像应用程序不能使用正常的容器配置选项一样。有一个实用程序应用程序在启动时运行并设置配置,因此尽管内部配置机制不同,用户仍然可以像其他示例一样配置容器。
现在尝试一下 运行“旧版”应用程序,使用默认配置设置和文件覆盖:
cd ch18/exercises/image-of-the-day # 运行具有默认配置的容器: docker container run -d -p 8089:80 diamol/ch18-image-of-the-day # 使用绑定挂载中的配置覆盖文件运行: docker container run -d -p 8090:80 -v "$(pwd)/config/dev:/config-override" -e CONFIG_SOURCE_PATH="/config-override/application.properties" diamol/ch18-image-of-the-day # 检查配置设置: curl http://localhost:8089/config curl http://localhost:8090/config
用户体验与其他应用程序非常相似--使用环境覆盖文件(源可以是配置对象或密钥)挂载卷,但您还必须在环境变量中指定覆盖文件的位置,以便启动实用程序知道在哪里查找。您将在输出中看到,镜像中的默认配置指定了发布周期但没有指定环境--这将在第二个容器中的覆盖文件中合并。我的输出在图 18.10 中。

图 18.10 此应用程序有一个用于引导配置模型的实用程序,但用户体验相同。
在这里发生魔法般的事情的是一个简单的 Java 实用程序应用程序,它与应用程序的其他部分一起编译和打包在多阶段构建中。列表 18.2 显示了构建实用程序并将其设置为启动时运行的 Dockerfile 的关键部分。
列表 18.2 在 Dockerfile 中构建和使用配置加载实用程序
FROM diamol/maven AS builder # ... RUN mvn package # config util FROM diamol/maven as utility-builder WORKDIR /usr/src/utilities COPY ./src/utilities/ConfigLoader.java . RUN javac ConfigLoader.java # app FROM diamol/openjdk ENV CONFIG_SOURCE_PATH="" \ CONFIG_TARGET_PATH="/app/config/application.properties" CMD java ConfigLoader && \ java -jar /app/iotd-service-0.1.0.jar WORKDIR /app COPY --from=utility-builder /usr/src/utilities/ConfigLoader.class . COPY --from=builder /usr/src/iotd/target/iotd-service-0.1.0.jar .
这里的重要收获是您可以扩展您的 Docker 镜像,使旧应用程序的行为与新应用程序相同。您控制启动逻辑,因此可以在启动实际应用程序之前运行所需的任何步骤。当您这样做时,您正在增加容器启动和应用程序准备就绪之间的时间,同时也增加了容器可能失败的风险(如果启动逻辑有错误)。您应该在镜像或应用程序清单中始终有健康检查来减轻这一点。
我配置加载实用程序支持 12 因子方法,该方法中环境变量会覆盖其他设置。它将环境变量与覆盖配置文件合并,并将输出作为应用程序期望的位置的配置文件写入。该实用程序采用与 Viper 相同的方法,寻找具有特定前缀的环境变量,这有助于将应用程序设置与其他容器中的设置分开。
现在试试吧!传统应用程序不使用环境变量,但配置实用程序会设置它们,以便用户体验与现代应用程序相同。
# 使用覆盖文件和环境变量运行容器: docker run -d -p 8091:80 -v "$(pwd)/config/dev:/config-override" -e CONFIG_SOURCE_PATH="/config-override/application.properties" -e IOTD_ENVIRONMENT="custom" diamol/ch18-image-of-the-day # 检查配置设置: curl http://localhost:8091/config
这个实用程序让我可以用与我其他应用程序相同的方式处理我的旧应用程序。对用户来说主要是透明的——他们只需设置环境变量并将覆盖文件加载到卷中。对应用程序来说也是透明的,它只读取它期望看到的配置文件——这里没有对原始应用程序代码的更改。图 18.11 显示了这个“遗留”应用程序使用了现代的多层配置方法。

图 18.11 环境变量使这个旧应用程序的配置模型表现得像新应用程序。
现在,图像库应用程序中的每个组件都使用相同的配置模式。所有组件之间有一个标准化的层次,但也存在一些小的实现差异。每个组件都可以通过文件覆盖来配置以在开发模式下运行,并且每个组件都可以通过环境变量来配置以启用 Prometheus 指标。实际如何操作因应用程序而异,这就是我在一开始提到的灰色区域——如果环境变量 ENABLE_METRICS=true,要强制执行每个组件都将运行 Prometheus 端点的标准是困难的,因为应用程序平台的工作方式不同。
文档是消除这种困惑的方法,在 Docker 世界中,部署文档最好在应用程序清单文件中完成。本章的练习中有一个 Docker Compose 文件,它正好做了我在上一段中描述的事情——将每个组件设置为开发模式,但启用 Prometheus 指标。列表 18.3 展示了 Compose 文件的配置部分。
列表 18.3 在 Docker Compose 中记录配置设置
version: "3.7" services: accesslog: image: diamol/ch18-access-log environment: NODE_CONFIG: '{"metrics": {"enabled":"true"}}' secrets: - source: access-log-config target: /app/config-override/local.json iotd: image: diamol/ch18-image-of-the-day environment: CONFIG_SOURCE_PATH: "/config-override/application.properties" IOTD_MANAGEMENT_ENDPOINTS_WEB_EXPOSURE_INCLUDE: "health,prometheus" secrets: - source: iotd-config target: /config-override/application.properties image-gallery: image: diamol/ch18-image-gallery environment: IG_METRICS.ENABLED: "TRUE" secrets: - source: image-gallery-config target: /app/config-override/config.toml secrets: access-log-config: file: access-log/config/dev/local.json iotd-config: file: image-of-the-day/config/dev/application.properties image-gallery-config: file: image-gallery/config/dev/config.toml
这段代码列表有点长,但我希望将所有内容放在一个地方,这样你可以看到模式是相同的,尽管细节不同。Node.js 应用程序使用环境变量中的一个 JSON 字符串来启用指标,并加载一个 JSON 文件作为配置覆盖。
Java 应用程序使用一个列出要包含的管理端点的环境变量;在其中添加 Prometheus 可以启用指标收集。然后它从属性文件加载配置覆盖,这是一个键值对的序列。
Go 应用程序使用环境变量中的一个简单字符串"TRUE"来启用指标,并将配置覆盖作为 TOML 文件加载。我在 Docker Compose 中使用了秘密支持来处理文件源,但对于卷挂载或集群中的配置对象,模式是相同的。
在这里,用户体验既有好的一面也有不好的一面。好的一面是,你可以通过更改配置覆盖的源路径来轻松加载不同的环境,并且可以使用环境变量更改单个设置。不好的一面是,你需要了解应用程序的怪癖。项目团队可能会逐步演变各种 Docker Compose 覆盖,以覆盖不同的配置,因此编辑配置设置不会是一个常见的活动。运行应用程序将会更加常见,而且这和用 Compose 启动任何应用程序一样简单。
现在试试看。让我们使用为所有组件固定的一组配置来整体运行应用程序。首先,删除所有正在运行的容器,然后使用 Docker Compose 运行应用程序:
# 清除所有容器: docker container rm -f $(docker container ls -aq) cd ch18/exercises # 使用配置设置运行应用程序: docker-compose up -d # 检查所有配置 API: curl http://localhost:8030/config curl http://localhost:8020/config curl http://localhost:8010/config
你可以通过访问 http: / / localhost:8010 来浏览应用程序,并像通常一样使用它,并浏览到 Prometheus 端点以查看组件指标(在 http:/ /localhost:8010/ metrics, http:/ /localhost:8030/metrics, 和 http:/ /localhost:8020/actuator/prometheus)。但实际上,所有确认应用程序配置正确的信息都来自那些配置 API。
你可以在图 18.12 中看到我的输出。每个组件都会从镜像中的默认配置文件加载发布周期名称,从配置覆盖文件加载环境名称,并从环境变量加载指标设置。

图 18.12 Docker Compose 可以记录应用程序配置设置,并使用该配置启动应用程序。
关于构建应用程序以从容器环境获取配置的模式,我们实际上需要涵盖的内容就这些了。我们将以对多级配置模型可能带给你哪些好处的思考来结束本章。
18.5 理解为什么灵活的配置模型会带来好处
我在第十一章和第十五章中用 Docker 介绍了 CI/CD 管道,该管道的核心设计是构建一个镜像,你的部署过程就是将这个镜像通过你的环境提升到生产环境。你的应用在每个环境中都需要稍微有所不同,而为了保持单镜像方法同时支持这一点,你可以使用多级配置模型。
在实践中,你将使用内置在容器镜像中的发布级别设置,几乎在所有情况下,环境级别覆盖文件由容器平台提供,但使用环境变量设置功能级别配置的能力是一个有用的补充。这意味着你可以快速响应生产问题——如果这是一个性能问题,则降低日志级别,或者关闭存在安全漏洞的功能。这也意味着你可以在开发机器上创建类似生产环境的环境来复现错误,使用移除机密的发布配置覆盖,并使用环境变量。
能够在任何环境中运行完全相同的镜像,这是在配置模型上投入时间的回报。图 18.13 显示了从 CI/CD 管道开始的镜像生命周期。

图 18.13 CI/CD 管道生成一个镜像,你使用配置模型来改变行为。
你在创建这种灵活配置模型时所做的努力将大大有助于确保你的应用在未来具有适应性。所有容器运行时都支持从配置对象或机密中加载文件到容器中,并设置环境变量。本章图片库应用的 Docker 镜像将以相同的方式与 Docker Compose、Docker Swarm 或 Kubernetes 一起工作。而且不仅限于容器运行时——标准配置文件和环境变量也是平台即服务(PAAS)产品和无服务器函数中使用的模型。
18.6 实验室
深入研究新应用的配置模型可能会有些棘手,需要弄清楚如何设置覆盖文件和配置功能覆盖,因此你将在本实验室中获得一些实践机会。你将使用相同的图片库应用——在本章的实验室文件夹中有一个包含应用组件的 Docker Compose 文件,但没有配置。你的任务是设置每个组件以
-
使用卷来加载配置覆盖文件。
-
加载测试环境的配置覆盖。
-
将发布周期覆盖为“20.01”而不是“19.12”。
这应该相当直接,但花些时间调整应用配置而不更改应用本身将是有用的。当你使用docker-compose up 运行应用时,你应该能够浏览到 http: / / localhost:8010,并且应用应该能够正常工作。你应该能够浏览到所有三个配置 API,并看到发布名称是 20.01,环境是 TEST。
我的解决方案在同一个文件夹中的 docker-compose-solution.yml 文件里,或者您也可以在这里的 GitHub 上查看:github.com/sixeyed/diamol/blob/master/ch18/lab/README.md .
19 使用 Docker 编写和管理应用程序日志
记录日志通常是学习新技术中最无聊的部分,但 Docker 不是这样。基本原理很简单:你需要确保你的应用程序日志被写入标准输出流,因为那是 Docker 寻找它们的地方。有几个方法可以实现这一点,我们将在本章中介绍,然后乐趣就开始了。Docker 有一个可插拔的日志框架——你需要确保你的应用程序日志从容器中输出,然后 Docker 可以将它们发送到不同的地方。这让你可以构建一个强大的日志模型,其中所有容器的应用程序日志都被发送到一个中央日志存储,并且在其顶部有一个可搜索的用户界面——所有这些都使用开源组件,所有都在容器中运行。
19.1 欢迎来到 stderr 和 stdout!
Docker 镜像是你应用程序的二进制文件和依赖项的文件系统快照,同时也包含一些元数据,告诉 Docker 当你从镜像运行容器时应该启动哪个进程。该进程在前台运行,所以就像启动一个 shell 会话然后运行一个命令一样。只要命令是活跃的,它就控制着终端的输入和输出。命令将日志条目写入标准输出和标准错误流(称为 stdout 和 stderr),所以在终端会话中你会在窗口中看到输出。在容器中,Docker 监视 stdout 和 stderr,并从流中收集输出——这就是容器日志的来源。
现在试试看!如果你在一个容器中运行第十五章中的 timecheck 应用,你可以很容易地看到这一点。应用程序本身在前台运行,并将日志条目写入 stdout:
# 在前台运行容器: docker container run diamol/ch15-timecheck:3.0 # 完成后使用 Ctrl-C 退出容器
你会在终端中看到一些日志行,你会发现你无法输入更多命令——容器正在前台运行,所以就像在你的终端中运行应用程序本身一样。每隔几秒钟应用程序就会将另一个时间戳写入 stdout,所以你会在会话窗口中看到另一行。我的输出在图 19.1 中。

图 19.1 前台的容器接管终端会话,直到它退出。
这就是容器的标准操作模型——Docker 在容器内启动一个进程,并收集该进程的输出流作为日志。我们在这本书中使用的所有应用程序都遵循相同的模式:应用程序进程在前台运行——这可能是一个 Go 二进制文件或 Java 运行时——并且应用程序本身被配置为将日志写入 stdout(或 stderr;Docker 以相同的方式处理这两个流)。这些应用程序日志由运行时写入输出流,并由 Docker 收集。图 19.2 显示了应用程序、输出流和 Docker 之间的交互。

图 19.2 Docker 监视容器中的应用程序进程并收集其输出流。
容器日志以 JSON 文件的形式存储,因此日志条目对于没有终端会话的分离容器以及已退出的容器仍然可用。Docker 为你管理这些 JSON 文件,它们具有与容器相同的生命周期--当容器被删除时,日志文件也会被删除。
现在试试看 Run a container from the same image in the background as a detached container, and check the logs and then the path to the log file:
# 运行一个分离的容器 docker container run -d --name timecheck diamol/ch15-timecheck:3.0 # 检查最新的日志条目: docker container logs --tail 1 timecheck # 停止容器并再次检查日志: docker container stop timecheck docker container logs --tail 1 timecheck # 检查 Docker 存储容器日志文件的位置: docker container inspect --format='{{.LogPath}}' timecheck
如果你使用的是带有 Linux 容器的 Docker Desktop,请记住 Docker Engine 正在 Docker 为你管理的 VM 内部运行--你可以看到容器日志文件的路径,但你无法访问 VM,因此无法直接读取文件。如果你在 Linux 上运行 Docker CE 或使用 Windows 容器,日志文件的路径将在你的本地机器上,你可以打开文件以查看原始内容。你可以在图 19.3 中看到我的输出(使用 Windows 容器)。

图 19.3 Docker 将容器日志存储在 JSON 文件中并管理该文件的生命周期。
日志文件实际上只是一个实现细节,你通常不需要担心。其格式非常简单;它包含一个 JSON 对象,每个日志条目都有一个包含日志的字符串、日志来源的流名称(stdout 或 stderr)和一个时间戳。列表 19.1 显示了 timecheck 容器日志的示例。
列表 19.1 容器日志的原始格式是一个简单的 JSON 对象
{"log":"环境:DEV;版本:3.0;时间检查:09:42.56\r\n","stream":"stdout","time":"2019-12-19T09:42:56.814277Z"} {"log":"环境:DEV;版本:3.0;时间检查:09:43.01\r\n","stream":"stdout","time":"2019-12-19T09:43:01.8162961Z"}
只有在你有一个产生大量日志的容器,并且你希望保留所有日志条目一段时间,但希望它们在一个可管理的文件结构中时,你才需要考虑 JSON。默认情况下,Docker 为每个容器创建一个单独的 JSON 日志文件,并且允许它增长到任何大小(直到填满你的磁盘)。你可以配置 Docker 使用滚动文件,并设置最大大小限制,这样当日志文件填满时,Docker 就会开始写入新文件。你还可以配置要使用多少个日志文件,当它们都满了之后,Docker 就会开始覆盖第一个文件。你可以在 Docker 引擎级别设置这些选项,以便更改适用于每个容器,或者你可以为单个容器设置它们。为特定容器配置日志选项是获取一个应用程序的小型轮换日志文件但保留其他容器所有日志的好方法。
现在试试 Run the same app again, but this time specifying log options to use three rolling log files with a maximum of 5 KB each:
# 使用日志选项和应用程序设置运行,以写入大量日志: docker container run -d --name timecheck2 --log-opt max-size=5k --log-opt max-file=3 -e Timer__IntervalSeconds=1 diamol/ch15-timecheck:3.0 # 等待几分钟 # 检查日志: docker container inspect --format='{{.LogPath}}' timecheck2
你会发现容器的日志路径仍然只是一个单一的 JSON 文件,但 Docker 实际上正在使用该名称作为基础,但带有日志文件编号后缀来轮换日志文件。如果你正在运行 Windows 容器或在 Linux 上运行 Docker CE,你可以列出存储日志的目录的内容,你将看到那些文件后缀。我的后缀如图 19.4 所示。

图 19.4 滚动日志文件允许你为每个容器保留已知数量的日志数据。
对于来自 stdout 的应用程序日志有一个收集和处理阶段,这是你可以配置 Docker 如何处理日志的地方。在上一个练习中,我们配置了日志处理以控制 JSON 文件结构,并且你可以对容器日志做更多的事情。为了充分利用这一点,你需要确保每个应用程序都在将日志推送到容器外部,在某些情况下,这需要做更多的工作。
19.2 从其他汇点转发日志到 stdout
并非每个应用程序都能很好地与标准日志模型兼容;当你将某些应用程序容器化时,Docker 在输出流中看不到任何日志。一些应用程序作为 Windows 服务或 Linux 守护进程在后台运行,因此容器启动过程实际上并不是应用程序过程。其他应用程序可能使用现有的日志框架,将日志写入日志文件或其他位置(在日志世界中称为“汇”),例如 Linux 中的 syslogs 或 Windows 事件日志。无论如何,容器启动过程中没有应用程序日志,因此 Docker 看不到任何日志。
现在尝试一下 本章节有一个新的 timecheck 应用程序版本,它将日志写入文件而不是 stdout。当您运行这个版本时,没有容器日志,尽管应用程序日志被存储在容器文件系统中:
# 从新镜像运行容器: docker container run -d --name timecheck3 diamol/ch19-timecheck:4.0 # 检查 - 没有日志从 stdout 输出: docker container logs timecheck3 # 现在连接到正在运行的容器,对于 Linux: docker container exec -it timecheck3 sh # 或者 Windows 容器: docker container exec -it timecheck3 cmd # 并读取应用程序日志文件: cat /logs/timecheck.log
您会看到没有容器日志,尽管应用程序本身正在写入大量的日志条目。我的输出在图 19.5 中——我需要连接到容器并从容器文件系统中读取日志文件以查看日志条目。

图 19.5 如果应用程序没有向输出流写入任何内容,您将看不到任何容器日志。
这是因为应用程序正在使用它自己的日志接收器——在这个练习中是一个文件,Docker 对此接收器一无所知。Docker 只会从 stdout 读取日志;没有方法配置它从容器内的不同日志接收器读取。
处理此类应用程序的模式是在容器启动命令中运行第二个进程,该进程从应用程序使用的接收器读取日志条目并将它们写入 stdout。该进程可以是 shell 脚本或简单的实用程序应用程序,它是启动序列中的最后一个进程,因此 Docker 读取其输出流,应用程序日志作为容器日志被转发。图 19.6 展示了它是如何工作的。

图 19.6 您需要在容器镜像中打包一个实用工具,以便从文件中转发日志。
这不是一个完美的解决方案。您的实用程序进程在前台运行,因此它需要健壮,因为如果它失败,容器会退出,即使实际的应用程序仍在后台工作。反之亦然:如果应用程序失败但日志转发仍在运行,容器会保持运行,尽管应用程序已经不再工作。您需要在镜像中添加健康检查以防止这种情况发生。最后,这并不是对磁盘的高效使用,尤其是如果您的应用程序写入大量日志——它们会在容器文件系统中填充一个文件,并在 Docker 主机机器上的 JSON 文件中填充。
即使如此,了解这个模式也是有用的。如果您的应用程序在前台运行,并且您可以调整配置以将日志写入 stdout,那么这是一个更好的方法。但如果您的应用程序在后台运行,就没有其他选择了,并且最好接受低效并让应用程序像所有其他容器一样运行。
本章对 timecheck 应用进行了更新,添加了此模式,构建了一个小型实用程序来监视日志文件并将行传递到 stdout。列表 19.2 显示了多阶段 Dockerfile 的最终阶段--Linux 和 Windows 有不同的启动命令。
列表 19.2 使用您的应用程序构建和打包日志中继实用程序
# 应用程序镜像 FROM diamol/dotnet-runtime AS base ... WORKDIR /app COPY --from=builder /out/ . COPY --from=utility /out/ . # windows FROM base AS windows CMD start /B dotnet TimeCheck.dll && dotnet Tail.dll /logs timecheck.log # linux FROM base AS linux CMD dotnet TimeCheck.dll & dotnet Tail.dll /logs timecheck.log
两个CMD指令实现了相同的功能,但使用了两种不同的方法来处理两种操作系统。首先,在 Windows 中使用start命令在后台启动.NET 应用程序进程,在 Linux 中在命令后缀一个单 ampersand &。然后启动.NET tail 实用程序,配置为读取应用程序写入的日志文件。tail 实用程序只是监视该文件,并将每次写入的新行传递出去,因此日志被暴露到 stdout 并成为容器日志。
现在尝试一下 运行新镜像的容器,并验证日志是否来自容器,并且它们仍然被写入文件系统:
# 使用 tail 实用程序进程运行容器: docker container run -d --name timecheck4 diamol/ch19-timecheck:5.0 # 检查日志: docker container logs timecheck4 # 并连接到容器 - 在 Linux 上: docker container exec -it timecheck4 sh # 或者使用 Windows 容器: docker container exec -it timecheck4 cmd # 检查日志文件: cat /logs/timecheck.log
现在日志来自容器。这是一个复杂的方法来达到这个目的,需要额外运行一个进程来将日志文件内容传递到 stdout,但一旦容器运行,这一切都是透明的。这种方法的不利之处在于日志中继使用的额外处理能力和存储日志所需的额外磁盘空间。您可以在图 19.7 中看到我的输出,它显示了日志文件仍然在容器文件系统中。

图 19.7 日志中继实用程序将应用程序日志输出到 Docker,但使用了两倍的磁盘空间。
在这个例子中,我使用了一个自定义实用程序来中继日志条目,因为我希望应用程序能够在多个平台上工作。我可以用标准的 Linux tail命令代替,但没有 Windows 的等效命令。自定义实用程序方法也更加灵活,因为它可以从任何接收器读取并将数据中继到 stdout。这应该涵盖了任何您的应用程序日志被锁定在容器中某个地方,而 Docker 无法看到的情况。
当你将所有容器镜像配置为以容器日志的形式写入应用程序日志时,你就可以开始利用 Docker 的可插拔日志系统,并整合来自所有容器的所有日志。
19.3 收集和转发容器日志
在第二章中,我谈到了 Docker 如何为所有应用程序添加一个一致的管理层——无论容器内部发生什么;你都可以以相同的方式启动、停止和检查一切。当你在架构中引入一个集中的日志系统时,这尤其有用。我们将通过一个最流行的开源示例来了解这一点:Fluentd。
Fluentd 是一个统一的日志层。它可以从许多不同的来源摄取日志,过滤或丰富日志条目,然后将它们转发到许多不同的目标。它是由云原生计算基金会(它还管理 Kubernetes、Prometheus 以及 Docker 的其他项目,如容器运行时)管理的项目,并且是一个成熟且高度灵活的系统。你可以在容器中运行 Fluentd,它将监听日志条目。然后你可以运行其他容器,这些容器使用 Docker 的 Fluentd 日志驱动程序而不是标准 JSON 文件,这些容器日志将被发送到 Fluentd。
现在尝试一下。Fluentd 使用配置文件来处理日志。运行一个具有简单配置的容器,该配置将使 Fluentd 收集日志并将它们输出到容器的标准输出。然后运行 timecheck 应用程序,该应用程序使用该容器将日志发送到 Fluentd:
cd ch19/exercises/fluentd # 运行 Fluentd,发布标准端口并使用配置文件: docker container run -d -p 24224:24224 --name fluentd -v "$(pwd)/conf:/fluentd/etc" -e FLUENTD_CONF=stdout.conf diamol/fluentd # 现在运行一个设置为使用 Docker Fluentd 日志驱动的 timecheck 容器: docker container run -d --log-driver=fluentd --name timecheck5 diamol/ch19-timecheck:5.0 # 检查 timecheck 容器的日志: docker container logs timecheck5 # 并检查 Fluentd 容器的日志: docker container logs --tail 1 fluentd
当你尝试从 timecheck 容器检查日志时,你会看到一个错误——并非所有日志驱动程序都允许你直接从容器中查看日志条目。在这个练习中,它们被 Fluentd 收集,并且这个配置将输出写入标准输出,因此你可以通过查看 Fluentd 的日志来查看 timecheck 容器的日志。我的输出在图 19.8 中。

图 19.8 显示 Fluentd 从其他容器收集日志,并且它可以存储它们或将它们写入标准输出。
当 Fluentd 存储日志时,它会为每条记录添加自己的元数据,包括容器 ID 和名称。这是必要的,因为 Fluentd 成为所有容器的中央日志收集器,你需要能够识别哪些日志条目来自哪个应用程序。将 stdout 作为 Fluentd 的目标只是一个简单的方式来查看一切是如何工作的。通常,你会将日志转发到中央数据存储。Elasticsearch 是一个非常流行的选项——它是一个适用于日志的无 SQL 文档数据库。你可以在容器中运行 Elasticsearch 进行日志存储,并在另一个容器中运行配套的应用程序 Kibana,它是一个搜索用户界面。图 19.9 显示了日志模型的外观。

图 19.9 一个集中式日志模型将所有容器日志发送到 Fluentd 进行处理和存储。
它看起来像是一个复杂的架构,但像往常一样,使用 Docker,你可以在 Docker Compose 文件中非常容易地指定所有日志设置的部分,并通过一条命令启动整个堆栈。当你将日志基础设施运行在容器中时,你只需使用 Fluentd 日志驱动程序来为任何想要加入集中式日志的容器配置。
现在尝试一下 移除任何正在运行的容器,并启动 Fluentd-Elasticsearch-Kibana 日志容器。然后使用 Fluentd 日志驱动程序运行一个 timecheck 容器:
docker container rm -f $(docker container ls -aq) cd ch19/exercises # 启动日志堆栈: docker-compose -f fluentd/docker-compose.yml up -d docker container run -d --log-driver=fluentd diamol/ch19-timecheck:5.0
给 Elasticsearch 一点时间准备,然后浏览到 Kibana 在 http:/ /localhost:5601. 点击 Discover 标签页,Kibana 会要求输入要搜索的文档集合的名称。输入fluentd*,如图 19.10 所示。

图 19.10 Elasticsearch 将文档存储在名为索引的集合中——Fluentd 使用自己的索引。
在下一个屏幕中,你需要设置包含时间过滤器的字段——选择如图 19.11 所示的@timestamp。

图 19.11 Fluentd 已经将数据保存到 Elasticsearch 中,因此 Kibana 可以看到字段名称。
你可以自动化 Kibana 的设置,但我还没有这么做,因为如果你是 Elasticsearch 堆栈的新手,那么逐步了解各个组件是如何组合在一起的会很有价值。Fluentd 收集的每个日志条目都保存为 Elasticsearch 中的一个文档,在一个名为fluentd-{date}的文档集合中。Kibana 为你提供了所有这些文档的视图——在默认的 Discover 标签页中,你会看到一个条形图显示随时间创建的文档数量,你可以深入查看单个文档的详细信息。在这个练习中,每个文档都是 timecheck 应用的日志条目。你可以在图 19.12 中看到 Kibana 中的数据。

图 19.12 EFK 堆栈的全貌——收集并存储的容器日志,便于简单搜索
Kibana 允许您搜索所有文档中的特定文本,或按日期或其他数据属性过滤文档。它还具有类似于 Grafana 的仪表板功能,您在第九章中已经看到,因此您可以构建显示每个应用程序日志计数或错误日志计数的图表。Elasticsearch 具有巨大的可扩展性,因此适用于生产中的大量数据,当您开始通过 Fluentd 发送所有容器日志时,您很快会发现这比在控制台中滚动日志行要容易管理得多。
现在试试 Run the image gallery app with each component configured to use the Fluentd logging driver:
# from the cd ch19/exercises folder docker-compose -f image-gallery/docker-compose.yml up -d
浏览到 http:/ /localhost:8010 生成一些流量,容器将开始写入日志。图像库应用程序的 Fluentd 设置为每个日志添加一个标签,以识别生成它的组件,因此日志行可以轻松识别--比使用容器名称或容器 ID 更容易识别。您可以在图 19.13 中看到我的输出。我正在运行完整的图像库应用程序,但我正在 Kibana 中过滤日志,只显示 access-log 组件--记录应用程序访问时间的 API。

图 19.13 图像库和 timecheck 容器的日志正在 Elasticsearch 中收集。
为 Fluentd 添加一个标签非常简单,它会显示为 log_name 字段以进行过滤;这是日志驱动程序的一个选项。您可以使用一个固定名称或注入一些有用的标识符--在这个练习中,我使用 gallery 作为应用程序前缀,然后添加生成日志的组件名称和镜像名称。这是一种识别应用程序、组件以及每行日志的确切版本的好方法。列表 19.3 展示了图像库应用程序 Docker Compose 文件中的日志选项。
列表 19.3 使用标签识别 Fluentd 的日志条目来源
services: accesslog: image: diamol/ch18-access-log logging: driver: "fluentd" options: tag: " gallery.access-log.{{.ImageName}}" iotd: image: diamol/ch18-image-of-the-day logging: driver: "fluentd" options: tag: "gallery.iotd.{{.ImageName}}" image-gallery: image: diamol/ch18-image-gallery logging: driver: "fluentd" options: tag: "gallery.image-gallery.{{.ImageName}}" ...
当您为生产准备容器时,应考虑使用可搜索的数据存储和用户友好的 UI 的集中式日志记录模型。您不仅限于使用 Fluentd--Docker 有许多其他日志驱动程序,因此您可以使用其他流行的工具,如 Graylog,或商业工具,如 Splunk。记住,您可以在 Docker 配置的引擎级别设置默认日志驱动程序和选项,但我认为在应用程序清单中这样做更有价值--它清楚地说明了每个环境中使用的日志系统。
如果您还没有建立日志系统,Fluentd 是一个不错的选择。它易于使用,可以从单个开发机器扩展到完整的生产集群,并且您可以在每个环境中以相同的方式使用它。您还可以配置 Fluentd 来丰富日志数据,使其更容易处理,并过滤日志将它们发送到不同的目标。
19.4 管理您的日志输出和收集
记录日志是在捕获足够的信息以在诊断问题时有用和不过度存储大量数据之间保持微妙的平衡。Docker 的日志模型为您提供了额外的灵活性来帮助平衡,因为您可以在存储之前以比预期更详细的级别生成容器日志,但过滤掉它们。然后,如果您需要查看更详细的日志,您可以通过更改过滤配置而不是应用程序配置来更改 Fluentd 容器而不是应用程序容器。
您可以在 Fluentd 配置文件中配置此级别的过滤。上一个练习中的配置将所有日志发送到 Elasticsearch,但列表 19.4 中更新的配置过滤掉了来自更详细的 access-log 组件的日志。这些日志将发送到 stdout,其余的应用程序日志将发送到 Elasticsearch。
列表 19.4 根据记录的标签将日志条目发送到不同的目标
<match gallery.access-log.**> @type copy <store> @type stdout </store> </match> <match gallery.**> @type copy <store> @type elasticsearch ...
match块告诉 Fluentd 如何处理日志记录,而 filter 参数使用在日志驱动程序选项中设置的标签。当您运行此更新后的配置时,access-log 条目将匹配第一个 match 块,因为标签前缀是gallery.access-log。这些记录将不再在 Elasticsearch 中显示,并且只能通过读取 Fluentd 容器的日志来获取。更新后的配置文件还丰富了所有日志条目,将标签拆分为单独的字段,用于应用程序名称、服务名称和镜像名称,这使得在 Kibana 中进行过滤变得更容易。
现在尝试一下 更新 Fluentd 配置,通过部署一个指定新配置文件的 Docker Compose 覆盖文件,并更新图像库应用程序以生成更详细的日志:
# 更新 Fluentd 配置: docker-compose -f fluentd/docker-compose.yml -f fluentd/override-gallery-filtered.yml up -d # 更新应用程序日志配置: docker-compose -f image-gallery/docker-compose.yml -f image-gallery/override-logging.yml up -d
您可以检查这些覆盖文件的内容,您会看到它们只是指定了应用程序的配置设置;所有图像都是相同的。现在当您使用 http:/ /localhost:8010 的应用程序时,访问日志条目仍然会生成,但它们会被 Fluentd 过滤掉,因此您在 Kibana 中不会看到任何新的日志。您将看到其他组件的日志,并且这些日志被新的元数据字段丰富。您可以在图 19.14 的输出中看到这一点。

图 19.14 Fluentd 使用日志中的标签来过滤记录并生成新字段。
访问日志条目仍然可用,因为它们在 Fluentd 容器内部写入 stdout。您可以将它们视为容器日志--但它们来自 Fluentd 容器,而不是访问日志容器。
现在试试吧 检查 Fluentd 容器日志以确保记录仍然可用:
docker container logs --tail 1 fluentd_fluentd_1
您可以在图 19.15 中查看我的输出。访问日志条目已发送到不同的目标,但它仍然经过了相同的处理,以应用、服务和图像名称丰富记录:

图 19.15 这些日志被过滤,因此它们不会存储在 Elasticsearch 中,而是回显到 stdout。
这是一种将核心应用程序日志与希望拥有的日志分开的好方法。在生产中您不会使用 stdout,但您可能对不同类别的日志有不同的输出--性能关键组件可以将日志条目发送到 Kafka,面向用户的日志可以发送到 Elasticsearch,其余的可以存储在 Amazon S3 云存储中。这些都是 Fluentd 支持的日志存储。
本章有一个最后的练习来重置日志并将访问日志条目放回 Elasticsearch。这近似于生产环境中您发现系统问题并希望增加日志以查看发生了什么的情况。在我们的日志设置中,日志已经被应用程序写入。我们只需更改 Fluentd 配置文件就可以暴露它们。
现在部署一个新的 Fluentd 配置,将访问日志记录发送到 Elasticsearch:
docker-compose -f fluentd/docker-compose.yml -f fluentd/override-gallery.yml up -d
此部署使用一个配置文件,移除了访问日志记录的match块,因此所有画廊组件的日志都存储在 Elasticsearch 中。当您在浏览器中刷新图像画廊页面时,日志将被收集并存储。您可以在图 19.16 中查看我的输出,其中显示了 API 和访问日志组件的最新日志。

图 19.16 对 Fluentd 配置的更改在不更改应用程序的情况下将日志重新添加到 Elasticsearch 中。
你需要意识到,使用这种方法可能会丢失日志条目。在部署期间,容器可能会在没有任何 Fluentd 容器运行来收集它们的情况下发送日志。Docker 在这种情况下会优雅地继续运行,你的应用程序容器也会继续运行,但日志条目不会被缓冲,因此它们将会丢失。在集群化生产环境中,这不太可能成为问题,但即使发生了这种情况,也比重新启动一个具有增加日志配置的应用程序容器更可取——至少因为新的容器可能不会像旧容器那样有问题,所以你的新日志不会告诉你任何有趣的事情。
19.5 理解容器日志模型
Docker 中的日志方法非常灵活,但前提是你必须使你的应用程序日志作为容器日志可见。你可以通过让应用程序直接将日志写入 stdout 来实现,或者通过在容器中使用一个中继工具间接地复制日志条目到 stdout。你需要花一些时间确保所有应用程序组件都写入容器日志,因为一旦你做到了这一点,你就可以按自己的喜好处理日志。
在本章中,我们使用了 EFK 堆栈——Elasticsearch、Fluentd 和 Kibana——你已经看到了如何轻松地将所有容器日志拉入一个具有用户友好搜索界面的集中式数据库。所有这些技术都是可互换的,但 Fluentd 是最常用的,因为它既简单又强大。这个堆栈在单机环境中运行良好,也可以扩展到生产环境。图 19.17 显示了集群化环境中每个节点上运行 Fluentd 容器的情况,其中 Fluentd 容器收集该节点上其他容器的日志并将它们发送到 Elasticsearch 集群——Elasticsearch 也运行在容器中。

图 19.17 EFK 堆栈在生产环境中使用集群存储和多个 Fluentd 实例工作。
在我们进入实验室之前,我要提醒大家注意一点。有些团队不喜欢容器日志模型中的所有处理层;他们更愿意直接将应用程序日志写入最终存储,因此,应用程序不是写入 stdout 并通过 Fluentd 将数据发送到 Elasticsearch,而是直接写入 Elasticsearch。我真的很不喜欢这种方法。你虽然节省了一些处理时间和网络流量,但代价是完全缺乏灵活性。你已经将日志堆栈硬编码到所有应用程序中,如果你想切换到 Graylog 或 Splunk,你需要去重新修改你的应用程序。我总是更喜欢保持简单和灵活——将你的应用程序日志写入 stdout,并利用平台来收集、丰富、过滤和存储数据。
19.6 实验室
在本章中,我没有过多关注 Fluentd 的配置,但获得一些设置经验是值得的,所以我将要求你在实验室中完成这个任务。在本章的实验室文件夹中,有一个用于随机数字应用的 Docker Compose 文件和一个用于 EFK 堆栈的 Docker Compose 文件。应用容器未配置为使用 Fluentd,Fluentd 设置也不进行任何丰富化,因此你有三个任务:
-
扩展 numbers 应用的 Compose 文件,以便所有组件都使用 Fluentd 日志驱动程序,并设置一个包含应用名称、服务名称和镜像的标签。
-
将 Fluentd 的配置文件
elasticsearch.conf扩展,以便将标签拆分为应用名称、服务名称和镜像名称字段,用于所有来自 numbers 应用的记录。 -
在 Fluentd 配置中添加一个安全失败的
match块,以便将所有不是来自 numbers 应用的记录转发到 stdout。
对于这一部分没有提示,因为这是一个通过配置图像库应用进行配置设置并查看需要为 numbers 应用添加哪些组件的案例。一如既往,我的解决方案已上传到 GitHub 供您检查:github.com/sixeyed/diamol/blob/master/ch19/lab/README.md。
20 使用反向代理控制容器的 HTTP 流量
Docker 负责将外部流量路由到您的容器中,但您只能有一个容器监听网络端口。在非生产环境中使用任何旧的端口都是可以的——在这本书的一些章节中,我们使用了十个不同的端口来保持应用程序的分离——但您在上线时不能这样做。您可能希望许多应用程序在单个集群上运行,但您需要它们都能通过标准的 HTTP 和 HTTPS 端口,即 80 和 443 进行访问。
这就是反向代理发挥作用的地方。它是容器化环境架构中的一个关键部分,在本章中,您将了解它提供的所有功能和它启用的模式。我们将使用这个领域中最受欢迎的两种技术——Nginx(发音为“engine x”)和 Traefik——当然是在容器中运行。
20.1 什么是反向代理?
代理是一种网络组件,代表其他组件处理网络流量。您可能在公司网络中有一个代理,它会拦截您的浏览器请求并决定您是否允许访问某些网站,记录您的所有活动,并缓存响应,以便同一网站的其它用户获得更快的体验。反向代理做的是类似的事情,但方向相反。您运行反向代理作为多个 Web 应用的网关;所有流量都流向反向代理,它决定从哪个应用获取内容。它可以在发送回客户端之前缓存响应并对其进行修改。图 20.1 显示了容器中的反向代理看起来是什么样子。

图 20.1 反向代理是您应用的网关;应用程序容器不公开。
反向代理是唯一一个公开端口的容器——它接收所有传入的请求并从其他容器中获取响应。这意味着您所有的应用程序容器都变成了内部组件,这可以使它们更容易进行扩展、更新和保障安全。反向代理不是一项新技术,但随着容器革命的兴起,它们已经向左移动。它们过去位于生产环境中,由运维团队管理,甚至开发者都不知道存在代理;现在它们运行在轻量级容器中,您可以在每个环境中使用相同的代理配置。
现在尝试一下 Nginx 多年来一直是一种流行的反向代理选择——它为互联网上的超过 30%提供服务。它是一个非常轻量级、快速且强大的 HTTP 服务器,既可以提供自己的内容,也可以代理其他服务器:
# 为本章的应用创建一个网络 - 对于 Linux 容器: docker network create ch20 # 或者 Windows 容器: docker network create --driver=nat ch20 cd ch20/exercises # 在 Linux 上使用绑定挂载到本地配置文件夹运行 Nginx - # Linux: docker-compose -f nginx/docker-compose.yml -f nginx/override-linux.yml up -d # 或者 Windows 容器: docker-compose -f nginx/docker-compose.yml -f nginx/override-windows.yml up -d # 浏览到 http://localhost
Nginx 为它服务的每个网站使用一个配置文件。这个容器有一个绑定挂载到名为sites-enabled的本地文件夹,但里面还没有配置文件。Nginx 有一个默认网站,是一个简单的 HTML 页面--你可以在图 20.2 中看到我的输出。

图 20.2 Nginx 是一个 HTTP 服务器--它可以提供静态内容并作为反向代理运行
我们目前还没有使用 Nginx 作为反向代理,但我们可以通过为另一个网站添加配置文件来设置它。当你在一个相同的端口上托管多个应用时,你需要一种方法来区分它们,通常是通过网站的域名。当你浏览到像blog.sixeyed.com 这样的网站时,浏览器会在客户端请求中包含一个 HTTP 头:Host=blog.sixeyed.com。Nginx 使用这个主机头找到要服务的网站的配置文件。在你的本地机器上,你可以向你的 hosts 文件添加域名,这是一个简单的 DNS 查找,以便从你的 Nginx 容器中提供不同的应用。
现在试试看 我们将在容器中运行简单的 who-am-I 网络应用,不发布任何端口,并通过主机域名 whoami.local 通过 Nginx 提供服务:
# 在 Mac 或 Linux 上向本地 hosts 文件添加 who-am-I 域名: echo $'\n127.0.0.1 whoami.local' | sudo tee -a /etc/hosts # 或者 Windows 上: Add-Content -Value "127.0.0.1 whoami.local" -Path /windows/system32/drivers/etc/hosts # 启动 who-am-I 容器: docker-compose -f whoami/docker-compose.yml up -d # 将应用配置复制到 Nginx 配置文件夹: cp ./nginx/sites-available/whoami.local ./nginx/sites-enabled/ # 并重新启动 Nginx 以加载配置: docker-compose -f nginx/docker-compose.yml restart nginx # 浏览到 http://whoami.local
当你浏览到 http:/ /whoami.local 时,你的 hosts 文件中的条目会把你引导到你的本地机器,Nginx 容器接收这个请求。它使用 HTTP 头Host=whoami.local来找到正确的网站配置,然后从 who-am-I 容器加载内容并发送回来。你会在图 20.3 中看到,响应与直接从 who-am-I 应用容器返回的响应相同。

图 20.3 动态中的反向代理,从后台的应用容器加载内容
Nginx 是一个非常强大的服务器,具有庞大的功能集,但代理 Web 应用程序的基本配置文件非常简单。您需要指定服务器的域名和内容的位置,这可以是一个内部 DNS 名称。Nginx 容器将通过 Docker 网络使用容器名称作为 DNS 从应用容器获取内容。列表 20.1 显示了 who-am-I 网站的完整配置文件。
列表 20.1 who-am-I 网站的 Nginx 代理配置
server { server_name whoami.local; # 域名主机名 location / { proxy_pass http://whoami; # 内容的源地址 proxy_set_header Host $host; # 设置源的域名 add_header X-Host $hostname; # 在响应中添加代理名称 } }
反向代理不仅适用于网站——它们适用于任何 HTTP 内容,因此 REST API 是很好的目标,也可能支持其他类型的流量(如纯 TCP/IP 或 gRPC)。这个简单的配置使 Nginx 的工作方式类似于透明代理,因此对于它收到的每个请求,它都会调用源容器(称为“上游”)并将响应发送回客户端(称为“下游”)。如果上游应用失败,Nginx 会将失败响应发送回下游客户端。
现在试试看:向您的 hosts 文件中添加另一个域名,并运行随机数应用的 API,使用 Nginx 进行代理。这是在几次调用后失败的 API,刷新后您会看到 Nginx 返回的 500 响应:
# 在 Mac 或 Linux 上将 API 域名添加到本地 hosts 文件中: echo $'\n127.0.0.1 api.numbers.local' | sudo tee -a /etc/hosts # 或者 Windows 上: Add-Content -Value "127.0.0.1 api.numbers.local" -Path /windows/system32/drivers/etc/hosts # 运行 API: docker-compose -f numbers/docker-compose.yml up -d # 复制站点配置文件并重启 Nginx: cp ./nginx/sites-available/api.numbers.local ./nginx/sites-enabled/ docker-compose -f nginx/docker-compose.yml restart nginx # 浏览到 http://api.numbers.local/rng 并刷新,直到它崩溃
您会从这次练习中看到,无论用户是直接访问应用还是通过 Nginx 访问,应用的用户体验都是相同的。您有两个由 Nginx 托管的 app,因此它正在管理到上游容器的路由,但它不处理流量,所以响应体与由应用容器发送的完全一样。图 20.4 展示了通过反向代理返回的 API 失败响应。

图 20.4 在简单的代理配置中,Nginx 会发送来自应用的响应——即使它是失败的。
反向代理可以做得更多。所有应用流量都进入代理,因此它可以是一个配置的中心位置,并且您可以保持许多基础设施级别的关注点远离应用容器。
20.2 在反向代理中处理路由和 SSL
我们一直遵循的过程是将新应用程序添加到 Nginx,即启动应用程序容器,复制配置文件,然后重启 Nginx。这个顺序很重要,因为当 Nginx 启动时,它会读取所有的服务器配置并检查它是否可以访问所有上游服务。如果有任何服务不可用,它会退出;如果所有服务都可用,它会构建一个内部路由列表,将主机名链接到 IP 地址。这是代理可以处理的第一项基础设施问题——如果有多个上游容器,它将进行负载均衡。
现在尝试一下 我们现在将运行图像库应用程序,通过 Nginx 代理主 Web 应用程序。我们可以扩展 Web 组件,Nginx 将在容器之间进行负载均衡:
# 在 Mac 或 Linux 上向本地 hosts 文件添加域名: echo $'\n127.0.0.1 image-gallery.local' | sudo tee -a /etc/hosts # 或者,在 Windows 上: Add-Content -Value "127.0.0.1 image-gallery.local" -Path /windows/system32/drivers/etc/hosts # 使用 3 个 Web 容器运行应用程序: docker-compose -f ./image-gallery/docker-compose.yml up -d --scale image-gallery=3 # 添加配置文件并重启 Nginx: cp ./nginx/sites-available/image-gallery.local ./nginx/sites-enabled/ docker-compose -f ./nginx/docker-compose.yml restart nginx # 几次调用网站: curl -i --head http://image-gallery.local
图像库网站的 Nginx 配置与列表 20.1 中的代理设置相同,使用不同的主机名和上游 DNS 名称。它还添加了一个额外的响应头X-Upstream,显示 Nginx 获取响应的容器的 IP 地址。你在图 20.5 中可以看到,对于我来说,上游 IP 地址在 172.20 范围内,这是 Docker 网络上应用程序容器的 IP 地址。如果你多次重复 curl 调用,你会看到不同的 IP 地址,因为 Nginx 在 Web 容器之间进行负载均衡。
现在,你可以在单个 Docker 机器上运行你的应用程序,并启用负载均衡——你不需要切换到 Swarm 模式或启动一个 Kubernetes 集群来测试你的应用程序在类似生产环境的配置。应用程序本身也不需要任何代码或配置更改;所有这些操作都由代理处理。

图 20.5 Nginx 负责负载均衡,因此你可以按比例运行应用程序容器。
到目前为止,我们使用 Nginx 通过不同的主机名在容器之间进行路由,这是在单个环境中运行多个应用程序的方法。你还可以为 Nginx 路由配置细粒度路径,所以如果你想选择性地公开应用程序的某些部分,你可以在同一个域名内做到这一点。
现在尝试一下 图像库应用程序使用 REST API,你可以配置 Nginx 使用 HTTP 请求路径代理 API。API 看起来是 Web UI 的一部分,尽管它实际上来自一个独立的容器:
# 删除原始图像库配置: rm ./nginx/sites-enabled/image-gallery.local # 复制新配置,其中包含 API,并重新启动 Nginx: cp ./nginx/sites-available/image-gallery-2.local ./nginx/sites-enabled/image-gallery.local docker-compose -f ./nginx/docker-compose.yml restart nginx curl -i http://image-gallery.local/api/image
这是一个非常棒的用于选择性地公开应用堆栈部分的模式,在同一域名下组装一个由许多组件组成的应用。图 20.6 显示了我的输出——响应来自 API 容器,但客户端正在使用与 Web UI 相同的image-gallery.local域名发起请求。

![图 20.6 Nginx 可以根据域名或请求路径路由请求到不同的容器]
负载均衡和路由功能让你可以在单个开发或测试机器上接近生产环境,并且反向代理还负责处理 SSL 终止。如果你的应用以 HTTPS 网站的形式发布(它们应该这样),配置和证书需要存储在某个地方,而且将它们放在你的中央代理中比放在每个应用组件中要好得多。Nginx 可以配置使用从域名提供商或类似 Let’s Encrypt 的服务获得的真实证书,但在非生产环境中,你可以创建自己的自签名证书并使用它们。
现在试试看:为图像库应用生成一个 SSL 证书,并通过 Nginx 代理它,使用证书将其作为 HTTPS 网站提供服务:
# 在 Linux 上为应用生成自签名证书: docker container run -v "$(pwd)/nginx/certs:/certs" -e HOST_NAME=image-gallery.local diamol/cert-generator # 或者 Windows 容器: docker container run -v "$(pwd)/nginx/certs:C:\certs" -e HOST_NAME=image-gallery.local diamol/cert-generator # 删除现有的图像库配置: rm ./nginx/sites-enabled/image-gallery.local # 复制包含 SSL 的新站点配置: cp ./nginx/sites-available/image-gallery-3.local ./nginx/sites-enabled/image-gallery.local # 并重新启动 Nginx: docker-compose -f nginx/docker-compose.yml restart nginx # 浏览 http://image-gallery.local
在这个练习中有很多事情要做。你运行的第一个容器使用 OpenSSL 工具生成自签名证书,并将它们复制到你的本地certs目录,该目录也被绑定挂载到 Nginx 容器中。然后你用使用这些证书的新配置文件替换图像库配置文件,并重新启动 Nginx。当你使用 HTTP 浏览到该网站时,你会被重定向到 HTTPS,并且你会收到浏览器的警告,因为自签名证书不受信任。在图 20.7 中,你可以看到 Firefox 的警告——我可以点击“高级”按钮忽略警告并继续查看网站。

图 20.7 Nginx 将 HTTP 请求重定向到 HTTPS,并使用 SSL 证书提供服务。
Nginx 允许您为您的 SSL 设置配置各种细节,包括您支持的协议和加密算法(您可以从 www.ssllabs.com 检查您的网站并获取应用最佳实践的列表)。我不会深入所有这些细节,但 HTTPS 设置的核心部分在列表 20.2 中——您可以看到 HTTP 网站监听端口 80 并返回 HTTP 301 响应,将客户端重定向到监听端口 443 的 HTTPS 网站。
列表 20.2 使用 HTTP 重定向在 HTTPS 上提供服务
server { server_name image-gallery.local; listen 80; return 301 https://$server_name$request_uri; } server { server_name image-gallery.local; listen 443 ssl; ssl_certificate /etc/nginx/certs/server-cert.pem; ssl_certificate_key /etc/nginx/certs/server-key.pem; ssl_protocols TLSv1 TLSv1.1 TLSv1.2; ...
配置从容器的文件系统中加载证书和密钥文件。每个证书和密钥对仅适用于一个域名,因此您将为每个应用程序使用一组文件(尽管您可以生成一个覆盖多个子域的证书)。这些是机密文件,因此在生产环境中,您会使用集群中的机密来存储它们。将 HTTPS 从您的应用程序容器中排除意味着更少的配置和证书管理——这是一个基础设施问题,现在它存在于代理中——并且开发者可以为测试启动简单的 HTTP 版本。
我们在这里将介绍 Nginx 的最后一个特性,它可以大幅提升性能:缓存来自上游组件(即您的自己的网络应用程序)的响应。
20.3 使用代理提高性能和可靠性
Nginx 是一个非常高性能的 HTTP 服务器。您可以使用它来为简单的网站或单页应用程序提供静态 HTML,一个容器可以轻松处理每秒数千个请求。您也可以利用这种性能来提升您自己的应用程序——Nginx 可以作为一个缓存代理工作,因此当它从您的应用程序(称为“上游”)获取内容时,它会将其副本存储在本地磁盘或内存存储中。对于相同内容的后续请求将直接从代理服务器提供服务,而上游则不会被使用。图 20.8 展示了缓存的工作原理。

图 20.8 使用 Nginx 作为缓存代理可以减少应用程序容器的负载。
这样做有两个好处。首先,你减少了处理请求所需的时间,因为应用程序平台生成响应所需的时间肯定比 Nginx 从内存中读取缓存响应的时间要长。其次,你减少了发送到应用程序的总流量,因此你应该能够从相同的基础设施中处理更多的用户。你可以缓存非用户特定的内容,但这可能只是简单地绕过缓存,如果存在认证 cookie 的话。像图片库应用这样的通用站点可以完全由缓存提供服务。
现在试试看 使用 Nginx 作为图片库应用的缓存代理。此配置将 Web 应用程序和 API 都设置为使用 Nginx 缓存:
# 删除当前站点配置: rm ./nginx/sites-enabled/image-gallery.local # 复制缓存配置并重启 Nginx: cp ./nginx/sites-available/image-gallery-4.local ./nginx/sites-enabled/image-gallery.local docker-compose -f ./nginx/docker-compose.yml restart nginx # 向站点发送一些请求: curl -i --head --insecure https://image-gallery.local curl -i --head --insecure https://image-gallery.local
新的代理配置设置了一个自定义的响应头,X-Cache,Nginx 会填充缓存查找的结果。如果没有匹配的缓存(这将是您第一次调用站点的情况),则响应头为X-Cache: MISS,表示缓存中没有匹配的响应,并且有一个带有容器 IP 地址的X-Upstream头,其中 Nginx 从该容器中获取了内容。当您重复调用时,响应确实来自缓存,因此您会看到X-Cache: HIT并且没有X-Upstream头,因为 Nginx 没有使用上游。我的输出在图 20.9 中。

图 20.9 如果代理在其缓存中有响应,它将发送它而不使用上游。
Nginx 允许您微调您如何使用缓存。在最新的配置中,我已将 API 设置为使用短期缓存,因此响应在分钟后就会过时,然后 Nginx 从 API 容器中获取最新的内容。这对于需要新鲜内容但负载非常高的内容来说是一个很好的设置——如果您的 API 每秒有 5,000 个请求,即使是一分钟的缓存也能节省 300,000 个请求到达 API。Web 应用程序设置为使用较长的缓存,因此响应保持新鲜六小时。列表 20.3 显示了缓存配置。
列表 20.3 Nginx 作为 API 和 Web 内容的缓存反向代理
... location = /api/image { proxy_pass http://iotd/image; proxy_set_header Host $host; proxy_cache SHORT; proxy_cache_valid 200 1m; ... } location / { proxy_pass http://image-gallery; proxy_set_header Host $host; proxy_cache LONG; proxy_cache_valid 200 6h; proxy_cache_use_stale error timeout invalid_header updating http_500 http_502 http_503 http_504; ... }
缓存,命名为LONG和SHORT,在diamol/nginx镜像中的核心 Nginx 配置中定义。缓存规范设置了用于响应的内存和磁盘使用量,以及陈旧项的驱逐时间。
我不想深入挖掘 Nginx 配置,但有一个非常有用的功能可以帮助提高应用可靠性,这个功能在proxy_cache_use_stale设置中定义,用于 Web 应用。这告诉 Nginx,即使上游不可用,它也可以使用陈旧的缓存响应。从缓存中的陈旧项提供服务意味着即使应用容器已关闭,您的应用仍然可以在线(尽管可能不完全功能),这是一个非常实用的备份,用于解决应用中的暂时性故障,或者需要回滚的应用部署。您需要仔细考虑可以从缓存中成功提供的内容路径,但在一个简单的演示应用中,您可以提供整个应用。
现在尝试一下:对图像库应用和 API 进行几次调用,以便 Nginx 将其响应保存到其缓存中。然后终止容器并再次请求内容:
# 调用站点和 API: curl -s --insecure https://image-gallery.local curl -s --insecure https://image-gallery.local/api/image # 删除所有 Web 容器: docker container rm -f $(docker container ls -f name=image-gallery_image-gallery_* -q) # 再次尝试 Web 应用: curl -i --head --insecure https://image-gallery.local # 删除 API 容器: docker container rm -f image-gallery_iotd_1 # 再次尝试 API: curl -i --head --insecure https://image-gallery.local/api/image
您将在这里看到不同的缓存配置在实际中的应用。Web 缓存设置为六小时后过期,因此即使没有可用的 Web 容器,内容也会从 Nginx 的缓存中继续提供服务。API 响应缓存在一分钟后过期,并且未设置为使用陈旧缓存,因此您将从 Nginx 获得 HTTP 502 错误,这意味着它无法到达上游组件。我的输出在图 20.10 中。

图 20.10 Nginx 缓存可以微调以保持内容新鲜或为您的应用增加可靠性。
我们将在这里结束对 Nginx 的练习。它是一个非常强大的反向代理,还有很多其他的事情可以做——比如为 HTTP 响应启用 GZip 压缩和添加客户端缓存头——这可以提高最终用户的性能并减少对应用程序容器的负载。它是在容器出现之前就存在的技术,所以它实际上并不与容器平台集成;它只是在网络级别上查找 DNS 名称的 IP 地址,这是 Docker 提供容器 IP 地址的地方。它运行得很好,但你需要为每个应用程序维护一个配置文件,并在配置更改时重新加载 Nginx。
我们将通过查看一个与现代替代方案结束本章,该方案具有容器感知性并且与 Docker 很好地集成。
20.4 使用云原生反向代理
在第十一章中,我们使用 Jenkins 构建了一个 CI 管道,该管道在一个容器中运行。这个容器连接到了它所运行的 Docker Engine,因此它可以构建和推送镜像。将容器连接到 Docker Engine 还允许应用程序查询 Docker API 以了解其他容器,这正是云原生反向代理 Traefik(大约发音为“traffic”)所依赖的。对于你想要在代理中提供的每个应用程序,都没有静态配置文件;相反,你给你的容器添加标签,Traefik 使用这些标签来构建自己的配置和路由图。
动态配置是像 Traefik 这样的容器感知代理的主要好处之一。在运行 Traefik 之前,您不需要启动上游应用程序,因为它在运行时会监视新的容器。您不需要重新启动 Traefik 或重新加载配置来更改应用程序设置——这一切都是您应用程序部署的一部分。Traefik 有自己的 API 和 Web UI,可以显示规则,因此您可以在没有其他容器的情况下运行 Traefik,然后部署一个应用程序并查看配置是如何构建的。
现在尝试一下:首先删除所有现有的容器;然后运行 Traefik 并检查 UI,以了解 Traefik 如何管理组件:
docker container rm -f $(docker container ls -aq) # 启动 Traefik - 连接到 Linux Docker Engine: docker-compose -f traefik/docker-compose.yml -f traefik/override-linux.yml up -d # 或者使用 Windows 容器: docker-compose -f traefik/docker-compose.yml -f traefik/override-windows.yml up -d # 浏览到 http://localhost:8080
由于 Linux 和 Windows 使用不同的私有通道将容器连接到 Docker Engine,因此有不同的覆盖文件。除此之外,Traefik 在所有平台上的行为都是完全相同的。仪表板是您查看 Traefik 代理的应用程序及其配置的地方。您可以在图 20.11 中看到 Traefik 配置代理所使用的资源。

图 20.11 Traefik 仪表板显示了所有被代理的应用程序的配置。
Traefik 非常广泛地被使用,并且它具有与 Nginx 相似的操作模型 -- 有一个免费的开源产品,作为官方镜像发布在 Docker Hub 上,如果你需要支持运行,还有一个商业版本。如果你是反向代理的新手,Nginx 和 Traefik 是我推荐的两个选项;它将成为你基础设施的重要组成部分,因此你应该花些时间比较这两个。让我们深入了解一下 Traefik 的工作原理:
-
入口点 -- 这些是 Traefik 监听外部流量的端口,因此这些映射到容器的发布端口。我使用 80 和 443 用于 HTTP 和 HTTPS,以及 8080 用于 Traefik 仪表板。
-
路由器 -- 这些是匹配传入请求到目标容器的规则。HTTP 路由器有诸如主机名和路径等规则来识别客户端请求。
-
服务 -- 这些是上游组件 -- 实际向 Traefik 提供内容以便它可以向客户端返回响应的应用程序容器。
-
中间件 -- 这些是可以在请求发送到服务之前修改路由器请求的组件。你可以使用中间件组件来更改请求路径或头部,甚至强制执行身份验证。
最简单的配置只需要设置一个带有规则的路由器,以便将客户端请求匹配到路由器所连接的服务。
现在试试看 部署 who-am-I 应用程序,使用包含启用通过 Traefik 路由的标签的更新 Compose 定义:
# 使用覆盖文件中的 Traefik 标签部署应用程序: docker-compose -f whoami/docker-compose.yml -f whoami/override-traefik.yml up -d # 浏览到路由器的 Traefik 配置: # http://localhost:8080/dashboard/#/http/routers/whoami@docker # 并检查路由: curl -i http://whoami.local
这是一个非常简单的配置 -- 路由只是将入口点端口链接到上游服务,即 who-am-I 容器。你可以在图 20.12 中看到 Traefik 已经为路由器构建了配置,将主机域名 whoami.local 链接到 whoami 服务。

图 20.12 Traefik 使用 Docker API 来查找容器和标签,并使用它们来构建配置。
所有这些操作都是通过在容器上应用两个标签来完成的:一个用于为应用程序启用 Traefik,另一个用于指定要匹配的主机名。列表 20.4 显示了覆盖 Compose 文件中的这些标签。
列表 20.4 通过向应用程序容器添加标签来配置 Traefik
services: whoami: labels: - "traefik.enable=true" - "traefik.http.routers.whoami.rule=Host(`whoami.local`)"
Traefik 支持一些非常复杂的路由选项。您可以通过主机名和路径,或者路径前缀进行匹配,然后使用中间件组件来去除前缀。这听起来很复杂,但这正是我们为图像库 API 所需要的,因此我们可以将其作为主图像库域中的路径暴露出来。我们可以配置 Traefik 监听带有“api”路径前缀的传入请求,并在调用服务之前从请求 URL 中去除前缀,因为服务本身不使用该前缀。
现在试试吧!图像库应用只需要一个带有指定标签的重写文件来启用 Traefik 支持。部署应用后,Traefik 将配置添加到其路由规则中:
# 使用新的 Traefik 标签启动应用: docker-compose -f image-gallery/docker-compose.yml -f image-gallery/override-traefik.yml up -d # 检查 Web 应用: curl --head http://image-gallery.local # 以及 API: curl -i http://image-gallery.local/api/image
您将在输出中看到从 API 调用得到正确的响应——Traefik 在 http:/ /image-gallery.local/api/image 上接收外部请求,并使用路由器和中间件配置对 http:/ /iotd/image 上的容器进行内部调用。该配置稍微有些晦涩。您定义路由器,然后定义中间件组件,然后将中间件附加到路由器上——如果您想查看它,它位于image-gallery/override-traefik.yml文件中。
这种复杂性对消费者来说是完全透明的。您可以在图 20.13 中看到,响应看起来就像直接从 API 返回的一样。

图 20.13 路由规则让您可以在单个域名下展示一个多容器应用。
反向代理并不都支持相同的功能集。截至版本 2.1,Traefik 没有缓存,所以如果您需要一个缓存代理,Nginx 是您的选择。但是,当涉及到 SSL 时,Traefik 有更好的支持——它默认集成了证书提供者,因此您可以自动连接到 Let’s Encrypt 并为您更新证书。或者,您可以使用默认的自签名证书提供者,在非生产环境中为您的网站添加 SSL,而无需任何证书管理。
现在为图像库应用和 API 添加 SSL 支持需要更复杂的 Traefik 设置。它需要监听 HTTPS 入口点以及 HTTP,并将 HTTP 调用重定向到 HTTPS。所有这些仍然是通过标签完成的,所以部署只是应用更新:
# 使用 Traefik 标签启动 HTTPS 应用: docker-compose -f image-gallery/docker-compose.yml -f image-gallery/override-traefik-ssl.yml up -d # 使用 HTTPS 检查网站: curl --head --insecure https://image-gallery.local # 以及 API: curl --insecure https://image-gallery.local/api/image
如果你浏览到网站或 API,你将在浏览器中看到与使用 Nginx 进行 SSL 时相同的警告消息--证书不受已知证书机构的信任。但这次我们不需要创建自己的证书并仔细管理证书和密钥文件--Traefik 做了所有这些。你可以在图 20.14 中看到我的输出。使用带有 insecure 标志的 curl 告诉它即使证书不受信任也要继续。

图 20.14 使用 Traefik 进行 HTTPS--它可以生成证书或从第三方提供商获取证书
路由、负载均衡和 SSL 终止是反向代理的主要功能,Traefik 通过容器标签提供动态配置来支持所有这些功能。如果你正在将其与 Nginx 进行比较,你需要记住 Traefik 不提供缓存--这是一个广受欢迎的功能,可能在未来的版本中添加到 Traefik 中。
我们将尝试的最后一个是 Traefik 中简单而在 Nginx 中困难的最后一个功能:粘性会话。现代应用程序被构建成尽可能多的无状态组件--当你大规模运行时,客户端请求可以被路由到任何容器,这样你可以从负载均衡中受益,并在扩展时立即看到结果。旧应用程序往往不是由无状态组件构建的,当你将这些应用程序迁移到容器中运行时,你可能希望用户每次都被路由到同一个容器。这被称为粘性会话,你可以在 Traefik 的服务设置中启用它。
现在试试 The whoami 应用程序是粘性会话的一个简单示例。你可以扩展当前的部署并重复调用--它们将由 Traefik 在容器之间进行负载均衡。部署带有粘性会话的新版本,所有请求都将由同一个容器处理:
# 使用多个容器运行 who-am-I 应用程序: docker-compose -f whoami/docker-compose.yml -f whoami/override-traefik.yml up -d --scale whoami=3 # 检查请求是否在容器之间进行负载均衡: curl -c c.txt -b c.txt http://whoami.local curl -c c.txt -b c.txt http://whoami.local # 现在部署具有粘性会话支持的相同应用程序: docker-compose -f whoami/docker-compose.yml -f whoami/override-traefik-sticky.yml up -d --scale whoami=3 # 并检查请求是否由同一容器提供服务: curl -c c.txt -b c.txt http://whoami.local curl -c c.txt -b c.txt http://whoami.local
启用粘性会话后,每次请求都由相同的容器提供服务,因为 Traefik 设置了一个 cookie 来标识它应该为该客户端使用哪个容器(你也会在浏览器中看到同样的行为)。如果你感兴趣,可以检查浏览器会话中的 cookie 或在c.txt文件中的 cookie,你会发现 Traefik 将容器的 IP 地址放入该 cookie 中。下次你发起调用时,它会使用 IP 地址来访问相同的容器。我的输出如图 20.15 所示。

图 20.15 在 Traefik 中启用粘性会话——它使用 cookie 将客户端发送到相同的容器。
粘性会话是团队将旧应用程序迁移到容器时的一个主要需求,Traefik 使其变得相当简单。这并不完全等同于物理服务器或 VM 的粘性会话,因为容器更换得更频繁,所以客户端可能会被绑定到一个不再存在的容器上。如果 cookie 指示 Traefik 指向一个不可用的容器,它会选择另一个容器,所以用户会看到响应,但他们的会话已经结束。
20.5 理解反向代理所支持的模式
当你开始在生产中运行许多容器化应用程序时,反向代理几乎是必需的。我们在本章中介绍了一些更高级的功能——SSL、缓存和粘性会话——但即使没有这些,你也会发现你迟早需要反向代理。反向代理支持三种主要模式,我们将通过它们来结束本章。
第一种模式是在标准 HTTP 和 HTTPS 端口上托管多个 Web 应用程序,使用客户端请求中的主机名来获取正确的内容,如图 20.16 所示。

图 20.16 使用反向代理在一个集群中托管具有不同域名的大量应用程序
第二种模式是针对微服务架构,其中单个应用程序运行在多个容器上。你可以使用反向代理有选择地暴露单个微服务,通过 HTTP 请求路径进行路由。从外部来看,你的应用程序有一个单一的域名,但不同的路径由不同的容器提供服务。图 20.17 展示了这种模式。

图 20.17 由反向代理暴露的微服务属于同一应用程序域。
如果你希望将旧的单体应用程序迁移到容器中,这种最终模式非常强大。你可以使用反向代理开始分解旧应用程序的单体前端,将功能拆分到新的容器中。这些新功能由反向代理路由,因为它们在独立的容器中,所以可以使用不同的、更现代的技术堆栈。图 20.18 展示了这一点。

图 20.18 反向代理隐藏了单体架构,使其可以分解成更小的服务。
这些模式不是互斥的——在一个单独的集群中,你可以有一个反向代理驱动所有三个模式,托管多个域名,这些域名运行着混合了微服务和单体应用,这些应用都在容器中运行。
20.6 实验室
我们为这个实验室开发了一个全新的应用——一个将清楚地展示缓存反向代理强大功能的应用。这是一个简单的网站,可以计算到指定小数位数的π。在本章的实验室文件夹中,你可以使用 Docker Compose 运行该应用,并通过访问 http://localhost:8031/?dp=50000 来查看π看起来像什么,精确到 50,000 位小数。刷新浏览器,你会发现计算相同响应所需的时间是一样的。你的任务是运行一个反向代理后的应用:
-
应用应该可在
pi.local域的常规 HTTP 端口上访问。 -
代理应缓存响应,所以当用户重复相同的请求时,响应将从缓存中提供,这比从应用中提供要快得多。
-
代理应增加容错性,所以如果你杀死了应用容器,任何缓存的响应仍然可以通过代理获得。
我的解决方案已上传至 GitHub,你会在那里发现使用这种计算密集型工作(如缓存代理)可以节省大量时间:github.com/sixeyed/diamol/blob/master/ch20/lab/README.md。
21 使用消息队列进行异步通信
这是本书的最后一章完整章节,它介绍了一种新的系统组件通信方式:通过队列发送和接收消息。消息队列已经存在很长时间了——它们是一种解耦组件的方法,而不是直接连接以进行通信,而是向队列发送消息。队列可以将消息传递给一个或多个接收者,这为你的架构增加了许多灵活性。
在本章中,我们将关注当你向应用程序添加消息队列时启用的两种场景:提高系统性能和可伸缩性,以及在不中断服务的情况下添加新功能。我们将使用两个在现代 Docker 中运行得很好的现代消息队列:Redis 和 NATS。
21.1 什么是异步消息?
软件组件通常以同步方式通信——客户端连接到服务器,发送请求,等待服务器发送响应,然后关闭连接。这对于 REST API、SOAP Web 服务和 gRPC 都适用,它们都使用 HTTP 连接。
同步通信就像打电话一样:需要双方同时可用,因此需要仔细管理。服务器可能离线或满载运行,因此无法接受连接。服务可能需要很长时间才能处理,客户端连接可能在等待响应时超时。连接可能在网络级别失败,客户端需要知道是否可以安全地重复请求。你需要在应用程序代码或库中添加大量逻辑来处理所有故障模式。
异步通信在客户端和服务器之间增加了一层。如果客户端需要服务器执行某些操作,它会向队列发送一条消息。服务器监听队列,接收到消息后进行处理。服务器可以向队列发送响应消息,如果客户端需要响应,它将监听队列并接收消息。异步消息就像通过电子邮件进行通信——各方可以在有空时加入。如果服务器离线或超出容量,消息将留在队列中,直到有服务器可以处理它。如果消息处理时间较长,这不会影响客户端或队列。如果客户端发送消息时出现故障,消息不在队列中,客户端可以安全地再次发送。图 21.1 展示了使用异步消息的通信。

图 21.1 消息队列解耦组件,使它们不直接通信。
消息传递一直是集成架构师的首选选项,但过去它引发了一些难题——队列技术需要超级可靠,但企业队列在测试环境中运行成本太高,所以我们是否可以在不同的环境中使用不同的队列,或者在开发中完全跳过队列?Docker 解决了这个问题,使得轻松将企业级开源队列添加到您的应用程序中。在轻量级容器中运行队列意味着您可以针对每个应用程序运行一个专门的队列,而开源软件的免费使用意味着您可以在每个环境中使用相同的技术。Redis 是一个流行的消息队列选项(您也可以将其用作数据存储),您可以轻松尝试它来感受异步消息传递。
现在试试 Run the Redis server in a container, connected to a network where you can run other containers to send and receive messages:
# 在 Linux 容器中创建网络: docker network create ch21 # 或者 Windows 容器: docker network create -d nat ch21 # 运行 Redis 服务器: docker container run -d --name redis --network ch21 diamol/redis # 检查服务器是否正在监听: docker container logs redis --tail 1
消息队列是服务器组件,只需运行直到您停止它们。Redis 在端口 6379 上监听连接,并且相同的地址被客户端用于发送消息,以及服务器用于监听消息。您将从容器日志中看到,Redis 在您启动容器后仅几秒钟就开始运行了——我的输出在图 21.2 中。

图 21.2 消息队列就像任何其他后台容器一样,等待连接。
客户端需要打开到队列的连接以发送他们的消息——如果您想知道这比直接调用 REST API 更好,那全在于速度。队列通常有自己的自定义通信协议,它高度优化,因此当客户端发送消息时,它只需传输请求的字节并等待确认它已被接收。队列不对消息进行任何复杂的处理,因此它们应该能够轻松处理每秒数千条消息。
现在试试 We won’t send thousands of requests, but we’ll use the Redis CLI to send a few messages. The command syntax is a bit involved, but this is going to publish the message “ping” on the channel called channel21 , and it will repeat that message 50 times with a 5 second interval in between:
# 在后台运行 Redis 客户端以发布消息: docker run -d --name publisher --network ch21 diamol/redis-cli -r 50 -i 5 PUBLISH channel21 ping # 检查日志以查看消息是否正在发送: docker logs publisher
这个 Redis 客户端容器将在后台运行,每五秒发送一条消息。日志输出仅显示每次消息发送的响应代码,所以如果一切正常,你会看到很多零,这是“OK”响应。你可以在图 21.3 中看到我的示例。

图 21.3 Redis CLI 是向运行在 Redis 容器中的队列发送消息的简单方法。
这里有一些新的术语,因为“客户端”和“服务器”在消息传递的术语中并不真正适用。每个组件都是消息队列的客户端;他们只是以不同的方式使用它。发送消息的组件是发布者,接收消息的组件是订阅者。可能有多个不同的系统使用队列,因此 Redis 使用通道来保持消息的分离。在这种情况下,发布者正在channel21通道上发送消息,因此为了使组件能够读取这些消息,它需要订阅相同的通道。
现在尝试一下 运行另一个带有 Redis CLI 的容器,这次订阅其他容器发布消息的通道:
# 运行一个交互式订阅者,你将每五秒看到一次消息 # 收到: docker run -it --network ch21 --name subscriber diamol/redis-cli SUBSCRIBE channel21
我们正在使用 Redis CLI,这是一个使用 Redis 消息协议进行通信的简单客户端--对于所有主要的应用平台都有 Redis SDK,因此你也可以将其集成到自己的应用中。CLI 会在多行打印输出,所以你首先会看到订阅队列的输出。发布容器仍在后台运行,每次它发布消息时,Redis 都会将副本发送给订阅容器--然后你会在日志中看到消息详情。我的在图 21.4 中。

图 21.4 队列的订阅者接收了在通道上发布的每条消息的副本。
你可以用 Ctrl-C 退出容器,或者用docker container rm -f subscriber命令杀死容器。在此之前,它将继续监听消息。你可以看到这是一种异步通信:发布者在有订阅者监听之前就发送了消息,而订阅者将在没有发布者时继续监听消息。每个组件都与消息队列一起工作,并且不知道其他正在发送或接收消息的组件。
这个简单的原理,通过队列解耦发送者和接收者,有助于使你的应用性能更优和可扩展,你将在下一个版本的任务列表应用中看到这一点。
21.2 使用云原生消息队列
待办事项应用程序有一个 Web 前端和 SQL 数据库用于存储。在原始实现中,组件之间的所有通信都是同步的——当 Web 应用程序发送查询或插入数据时,它会打开到数据库的连接,并保持打开状态直到请求完成。这种架构扩展性不好。我们可以运行数百个 Web 容器来支持高用户负载,但最终我们会达到一个极限,即使用所有可用的数据库连接,应用程序将开始失败。
这就是消息队列如何帮助提高性能和扩展性的地方。待办事项应用程序的新版本使用异步消息进行保存工作流程——当用户添加新的待办事项时,Web 应用程序会在队列上发布消息。队列可以处理比数据库更多的连接,并且连接的生存周期要短得多,所以即使在非常高的用户负载下,队列也不会达到最大容量。我们将在这个练习中使用不同的队列技术:NATS,这是一个成熟且广泛使用的云原生计算基金会(CNCF)项目。它将消息存储在内存中,因此非常快,非常适合容器之间的通信。
现在尝试 Run NATS 在一个容器中。它有一个简单的管理 API,您可以使用它来查看连接到队列的客户数量:
# 切换到练习文件夹: cd ch21/exercises/todo-list # 启动消息队列: docker-compose up -d message-queue # 检查日志: docker container logs todo-list_message-queue_1 # 并检查活动连接: curl http://localhost:8222/connz
连接 API 调用返回有关活动连接数量的 JSON 详细信息。可能会有成千上万,因此响应是分页的,但在这个案例中,只有一个数据页,因为没有任何连接。您可以在图 21.5 中看到我的输出。

图 21.5 NATS 是一个替代的消息队列;它非常轻量级,并有一个管理 API。
当您转向异步消息时,会涉及一些开发工作,对于待办事项应用程序来说,这意味着对 Web 应用程序的一些更改。现在当用户添加待办事项时,Web 应用程序会向 NATS 发布消息,而不是在数据库中插入数据。这些更改实际上相当小。即使您不熟悉.NET Core,您也可以在列表 21.1 中看到发布消息涉及的工作不多。
列表 21.1 替代将数据写入数据库发布消息
public void AddToDo(ToDo todo) { MessageQueue.Publish(new NewItemEvent(todo)); _NewTasksCounter.Inc(); }
NATS 不使用与 Redis 相同的通道概念。相反,每条消息都有一个主题,这是一个用于标识消息类型的字符串。你可以为消息主题选择自己的命名方案。这个主题是events.todo .newitem,表示这是待办应用中的新项目事件。如果订阅者对新项目事件感兴趣,他们将能够监听带有该主题的消息,但即使没有订阅者,应用仍然会发布消息。
现在试试看 运行待办 Web 应用的新版本和数据库。你会发现应用可以加载,你可以使用它而不会出现任何错误,但它并不完全正确地工作:
# 启动 Web 和数据库容器: docker-compose up -d todo-web todo-db # 浏览到 http://localhost:8080 并添加一些项目
你会发现这个应用很乐意让你添加新项目,但当你浏览到列表时,却一个也没有。那是因为列表页面是从数据库中获取数据的,但新项目页面不再将数据插入数据库。新项目事件消息正在发布到 NATS 消息队列中,但没有人在监听它们。你可以在图 21.6 中看到我的空待办事项列表(这根本不代表现实生活)。

图 21.6 待办事项应用的消息发布;没有任何订阅者,缺少了功能。
有许多消息队列技术以不同的方式处理这种情况——即消息被发布但没有订阅者。有些队列将它们移动到死信队列供管理员管理,其他则存储消息以便在客户端连接并订阅时交付。Redis 和 NATS 有效地吞没了这些消息——它们向客户端确认收到,但没有地方可以发送,因此它们被丢弃。新订阅 Redis 或 NATS 队列的订阅者只会接收到它们开始监听后发布的消息。
现在试试看 GitHub 上的项目示例中有一个简单的 NATS 订阅者工具。你可以用它来监听特定主题的消息,这样我们就可以检查待办事件是否实际上被发布了:
# 运行一个监听"events.todo.newitem"消息的订阅者 docker container run -d --name todo-sub --network todo-list_app-net diamol/nats-sub events.todo.newitem # 检查订阅者日志: docker container logs todo-sub # 浏览到 http://localhost:8080 并添加一些新项目 # 检查新项目事件是否已发布: docker container logs todo-sub
用户体验完全相同——Web 应用仍然不起作用。它发布消息,但不知道它们会发生什么,但现在有一个订阅者接收了每条消息的副本。如果你在网站上输入一些待办事项,你将在订阅者的日志中看到它们被列出——我的显示在图 21.7 中。

图 21.7 一个简单的记录消息的订阅者是一个很好的方法来检查它们是否被发布。
到现在为止,你应该已经意识到待办事项应用缺少一个对发布的消息进行操作的组件。你需要做三件工作来迁移到异步消息:运行消息队列,在有趣的事件发生时发布消息,并订阅这些消息,以便在事件发生时做一些工作。待办事项应用缺少最后一部分,我们将在下一部分添加。
21.3 消费和处理消息
订阅队列的组件被称为消息处理器,通常你会有一个处理器用于每种类型的消息(Redis 中的每个频道或 NATS 中的每个主题)。待办事项应用需要一个消息处理器来监听新项目事件并在数据库中插入数据。图 21.8 显示了完成的架构。

图 21.8 异步处理使用消息处理器以及事件发布者。
这种设计是可以扩展的,因为队列就像一个缓冲区,可以平滑掉来自用户负载的任何峰值。你可以有数百个 Web 容器,但只有 10 个消息处理器容器——处理器在一个组中,所以队列共享消息,每个消息由单个容器处理。容器一次处理一个消息,所以插入数据时使用的最大 SQL 连接数限制为 10,无论有多少用户疯狂点击按钮。如果进入的负载超过这 10 个处理器可以处理的,消息就会被保存在队列中,直到处理器准备好处理更多。应用会继续工作,数据最终会被保存。
现在试试看!待办事项应用的消息处理器已经构建并发布到 Docker Hub,所以它已经准备好了。现在运行它,看看应用如何使用异步消息工作:
# 启动消息处理器: docker-compose up -d save-handler # 检查容器日志中的连接: docker logs todo-list_save-handler_1 # 浏览到 http://localhost:8080 并添加一些新项目 # 检查事件是否已被处理: docker logs todo-list_save-handler_1
应用再次工作!几乎。你会发现你可以添加新项目,它们会出现在列表页面上,但不是立即出现。当你保存一个项目时,Web 应用会重定向到列表页面,该页面在消息仍在队列中处理和处理器处理时加载。在数据库查询运行时,新项目还没有被保存,所以新数据不会显示。你可以在图 21.9 中看到我的输出——在这个时候,我的网页上没有显示任何项目,尽管已经保存了一个新项目。

图 21.9 消息处理器订阅队列,接收每条消息的副本,并对其采取行动。
这是一种异步消息的副作用,称为最终一致性——当所有消息都已被处理时,您的应用程序数据的状态将是正确的,但在那时之前,您可能会得到不一致的结果。有方法可以解决这个问题,使整个 UI 异步,因此待办事项 Web 应用会监听一个表示列表已更改的事件,然后刷新自己。这种推送模型可能比轮询查询更有效率,但本书中涉及的内容太多。我们现在只需刷新即可。
将架构迁移到异步消息是一个相当大的变化,但它开辟了许多机会,所以了解它是绝对值得的。消息处理器是小型、专注的组件,可以独立于主应用程序或彼此进行更新或扩展。在这个练习中,我们使用队列来解决扩展问题,现在我们可以运行多个保存消息处理器的实例来处理传入的负载,同时有效地限制我们使用的 SQL 连接数量。
现在尝试一下 消息处理器是内部组件;它们不监听任何端口,因此您可以在单台机器上以多个容器的规模运行它们。如果运行了相同处理器的多个实例,NATS 支持负载均衡来共享消息:
# 扩展处理器: docker-compose up -d --scale save-handler=3 # 检查是否有新的处理器已连接: docker logs todo-list_save-handler_2 # 浏览到 http://localhost:8080 并添加一些新项目 # 查看哪些处理器已处理消息: docker-compose logs --tail=1 save-handler
您会看到消息被发送到不同的容器中。NATS 使用轮询负载均衡在连接的订阅者之间共享负载,您会发现您投入的负载越多,分布就越均匀。图 21.10 中的我的输出显示容器 1 和 2 已经处理了消息,但容器 3 没有。

图 21.10 多个消息处理器分担工作负载,以便您可以根据需求进行扩展。
重要的是要意识到,我没有对我的新功能进行任何更改以获得三倍的处理能力——网站和消息处理器代码完全相同。我只是运行了更多相同消息处理器容器的实例。如果您有另一个由相同事件触发的功能,您可以运行一个订阅相同消息主题的不同消息处理器。这为在不更改现有代码的情况下将新功能部署到您的应用中提供了有趣的选择。
21.4 使用消息处理器添加新功能
我们将待办事项应用程序推向了事件驱动架构,这是一种设计,其中应用程序发布事件来说明事情已经发生,而不是立即处理所有事情。这是一种构建松散耦合应用程序的好方法,因为您可以在不更改发布事件的逻辑的情况下更改对事件的响应。我们只是在这个应用程序中使用它来处理一种类型的事件,但这仍然提供了在不更改现有应用程序的情况下添加新功能的灵活性。
要做到这一点,最简单的方法是在一个新组中添加一个新的消息处理器,该处理器接收每个事件的副本,但对其执行不同的操作。现有的消息处理器将数据保存到 SQL 数据库中;新的消息处理器可以将数据保存到 Elasticsearch 中,以便用户在 Kibana 中轻松查询,或者它可以将项目添加到 Google 日历中的提醒。我们为下一个练习有一个更简单的例子--一个像审计跟踪一样工作的处理器,为每个新的待办事项写入日志条目。
现在试试看 新的消息处理器位于一个 Compose 覆盖文件中。当你部署它时,你会发现这是一个增量部署。Compose 创建了一个新的容器,但其他所有容器都没有改变:
# 运行审计消息处理器,保持保存处理器的相同规模 # 处理器: docker-compose -f docker-compose.yml -f docker-compose-audit.yml up -d --scale save-handler=3 # 检查审计处理器是否正在监听: docker logs todo-list_audit-handler_1 # 浏览到 http://localhost:8080 并添加一些新项目 # 检查审计记录: docker logs todo-list_audit-handler_1
这是一个零停机时间部署;原始的应用容器保持不变,新功能在一个新的容器中实现。审计处理器订阅与保存处理器相同的消息主题,因此它接收每条消息的副本,而另一份消息副本则发送到保存处理器容器之一。您可以在图 21.11 中看到我的输出,其中审计处理器写出了待办事项的日期和文本。

图 21.11 发布事件解耦了您的应用程序组件,并允许您添加新功能。
现在有两个进程在用户创建待办事项时被触发,并且它们都在不同的容器中运行的独立组件中执行。这些进程可能需要任何长度的时间,但这不会影响用户体验,因为 Web UI 不等待它们(甚至不知道它们)--它只是将事件发布到队列中,并且无论有多少订阅者监听,这种行为都具有相同的延迟。
您应该对这种架构的强大之处有一个大致的了解,即使是从这个简单的例子中也能看出。一旦您的应用程序将关键事件作为消息发布到队列中,您就可以在不接触现有组件的情况下构建全新的功能。新功能可以独立构建和测试,并且可以在不影响运行中的应用程序的情况下部署。如果功能存在问题,您只需停止消息处理程序即可取消部署。
我们将查看本章的最后一个练习,以帮助您相信异步消息是您应该考虑的应用程序模式。对于某种类型的事件,我们可以有多个订阅者,但也可以有多个发布者。新项事件在代码中是一个固定结构,因此任何组件都可以发布该事件,这为我们创建待办事项提供了新的选项。我们将利用这一点来部署应用程序的 REST API,而无需更改任何现有部分。
现在试试吧,待办事项列表 API 已经编写完毕并准备好部署。它监听 8081 端口,当用户发起 HTTP POST请求时,会发布一个新项事件:
# 启动 API 容器,定义在覆盖文件中: docker-compose -f docker-compose.yml -f docker-compose-audit.yml -f docker-compose-api.yml up -d todo-api # 通过 API 添加新项目: curl http://localhost:8081/todo -d '{"item":"Record promo video"}' -H 'Content-Type: application/json' # 检查审计日志: docker logs todo-list_audit-handler_1
新的 API 是一个简单的 HTTP 服务器,其中唯一的真正逻辑是使用列表 21.1 中的相同消息队列方法将事件发布到队列中。您将看到通过 API 输入的新项目会被审计处理程序和保存处理程序处理,因此会有审计条目,当您刷新 Web 应用程序时,您会看到新项目已存在于数据库中。我的输出如图 21.12 所示。

图 21.12 事件可以有多个订阅者和多个发布者,这实现了松耦合。
这是非常强大的功能,这一切都来自于应用程序中发布的一个单一事件。异步消息使您能够构建更灵活的应用程序,这些应用程序更容易扩展和更新,您还可以将所有这些优点添加到现有的应用程序中,只需从几个关键事件开始构建即可。
在您去白板之前,我们将通过更详细地研究消息模式来结束本章,让您了解您可能面临的情况。
21.5 理解异步消息模式
异步消息是一个高级话题,但 Docker 大大降低了入门门槛,因为运行队列在容器中非常容易,您可以快速原型化应用程序如何与事件发布一起工作。在队列上发送和接收消息有不同的方式,了解几种替代方案是值得的。
本章中使用的模式被称为发布-订阅(或“pub-sub”),它允许零个或多个订阅者接收发布的消息,如图 21.13 所示。

图 21.13 Pub-sub 消息允许许多进程对同一被发布的消息进行操作。
这种模式并不适合每个场景,因为消息发布者不知道谁消费了消息,他们如何处理它,或者他们何时完成。一个替代方案是请求-响应消息,客户端向队列发送消息并等待响应。处理器处理请求消息,然后发送一个响应消息,队列将其路由回客户端。这可以用来替换标准同步服务调用,其优点是处理器不会过载,客户端可以在等待响应时做其他工作。图 21.14 展示了这种模式。

图 21.14 请求-响应消息是客户端-服务通信,没有直接连接。
几乎所有队列技术都支持这些模式,以及像“fire-and-forget”(客户端在消息中发送命令请求而不是发布事件,但不在乎响应)和“scatter-gather”(客户端发布一个消息,多个订阅者对其操作,然后汇总所有响应)这样的变体。我们在本章中探讨了 Redis 和 NATS,但还有一项技术你也应该考虑:RabbitMQ。RabbitMQ 是一个更高级的队列,支持复杂的路由和持久消息,因此消息被保存到磁盘上,队列内容在容器重启后仍然存在。所有这些队列技术都在 Docker Hub 上提供官方镜像。
消息队列技术可以解放你的应用程序设计。你可以从一开始就构建一个事件驱动的架构,或者逐渐向其演变,或者只是使用消息来处理关键事件。当你开始部署新功能而无需停机,或者缩小处理器的规模以保护数据库免受饥饿而不会崩溃应用程序时,你会意识到这些模式的力量,你也会为完成了这一章而感到高兴。
21.6 实验室
这是本书的最后一个实验室,这个实验室有点狡猾。目标是为待办事项应用程序添加另一个消息处理器——一个在项目保存后更改文本的处理器。这个处理器已经存在,所以这主要关于将新服务连接到 Docker Compose 文件,但还有一些配置设置你需要挖掘。
你的解决方案需要使用来自 Docker Hub 的镜像diamol/ch21-mutating-handler运行一个新的处理器,当你使其工作后,有几个问题需要调查:
-
新组件监听名为
events.todo.itemsaved的事件,但目前还没有什么发布这些事件。你需要搜索一个可以应用于现有组件的配置设置,使其发布这些事件。 -
新组件有一组糟糕的默认配置,因此它没有使用正确的地址来访问消息队列。你需要搜索设置并修复它。
这并不像看起来那么糟糕;你需要的所有答案都在 Dockerfile 中,你只需在你的 Compose 文件中设置值——无需更改源代码或重建镜像。这是一个有用的练习,因为当你真正使用 Docker 时,你肯定会花一些时间试图弄清楚配置设置,并且最终的消息处理器为待办事项应用添加了一个有用的功能。
我的解决方案始终在 GitHub 上,附有截图以证明其工作:github.com/sixeyed/diamol/blob/master/ch21/lab/README.md。
22 永远不止于此
学习 Docker 是一项非常令人兴奋的技术,因为它有如此多的用途——从运行自己的 Git 服务器到将遗留应用程序迁移到云端,再到构建和运行全新的云原生应用程序。我希望我们在本书中走过的旅程已经帮助你增强了在容器方面的信心,现在你知道你可以在当前或下一个项目中如何使用它们。最后一章为你提供了一些提示,告诉你如何成功实现这一点,并以 Docker 社区的介绍结束。
22.1 运行你自己的概念验证
你使用 Docker 越多,你对容器就越熟悉,你从这项技术中获得的也会越多。几乎任何应用程序都可以容器化,所以运行一个概念验证(PoC),将你自己的应用程序之一迁移到 Docker,是一个很好的开始。这将给你一个机会将本书中的实用技能应用到你的工作中,最终结果将是你可以向团队其他成员展示的东西。
成功的 PoC 不仅涉及 docker image build 和 docker container run。如果你真的想向人们展示容器的能力,你的 PoC 应该有更广泛的范围:
-
努力将多个组件 Docker 化,这样你就可以展示 Docker Compose 运行应用程序不同配置的能力(参见第十章)。
-
从一开始就遵循最佳实践,展示迁移到 Docker 如何改善整个交付生命周期——使用多阶段 Dockerfile 并对其进行优化,包括你的黄金镜像(第十七章)。
-
包括集中式日志(第十九章)和度量(第九章)的可观察性。一个有意义的 Grafana 仪表板和用 Kibana 搜索日志的能力将使你的 PoC 超越基础。
-
建立一个 CI/CD 流水线,即使是一个非常简单的使用容器中的 Jenkins(第十一章)的流水线,以展示如何使用 Docker 自动化一切。
PoC 不需要涉及巨大的努力。即使范围扩大到这种程度,我认为如果你从一个相当简单的应用程序开始,你可以舒适地将这项练习限制在五天内。你不需要整个团队都参与;在这个阶段,它只是一个副项目即可。
但如果你在工作中无法获得使用 Docker 的批准,这并不意味着你需要停止——许多 Docker 高级用户都是从家里开始的。你可以在树莓派上运行一些相当令人印象深刻的软件容器,这将使你定期使用 Docker。
22.2 在你的组织中为 Docker 做出案例
Docker 对大多数组织来说是一个巨大的变革,因为它几乎影响到 IT 的各个方面,并不是每个团队都准备好接受一种新的工作方式。这本书中应该有足够的内容来帮助你向其他技术团队展示迁移到 Docker 的优势,但以下是我发现对不同利益相关者有吸引力的关键主题:
-
开发者可以在他们的机器上运行整个应用程序堆栈,使用与生产环境中完全相同的技术。不再有浪费时间去追踪缺失的依赖项或处理多个软件版本的问题。开发团队使用与运维团队相同的工具,因此组件有共同的所有权。
-
运营商和管理员可以针对每个应用程序使用标准工具和流程,每个容器化组件都有一个标准的 API 用于日志记录、指标和配置。部署和回滚将完全自动化,故障应该变得很少见,发布可以更频繁地进行。
-
数据库管理员可能不想在生产环境中运行数据库容器,但容器是提供给开发人员和测试团队自助服务的好方法,这样他们就不需要 DBA 为他们创建数据库。数据库模式可以移动到源控制,并打包在 Docker 镜像中,也将 CI/CD 带入数据库开发。
-
安全团队将关注运行时容器的妥协,但 Docker 允许你在整个生命周期中采用深度安全。黄金镜像、安全扫描和镜像签名都提供安全的软件供应链,这让你对部署的软件更有信心。运行时工具如 Aqua 和 Twistlock 可以自动监控容器行为并关闭攻击。
-
业务利益相关者和产品所有者理解发布语言——他们知道历史上的发布问题导致了越来越多的质量关卡,这导致了越来越少地发布。自愈应用程序、健康仪表板和持续部署都应该鼓励用户,迁移到容器意味着更好的软件质量,并且等待新功能的时间更短。
-
高级管理层对业务的兴趣(希望如此)将与业务保持一致,但他们也会密切关注 IT 预算。将应用程序从虚拟机迁移到容器可以节省大量资金,因为当你运行更多应用程序在更少的容器服务器上时,你可以合并硬件。这也减少了操作系统许可证。
-
IT 管理层应该意识到容器趋势不会消失。Docker 自 2014 年以来一直是一个成功的产品,所有主要云服务提供商都提供托管容器平台。将 Docker 纳入你的路线图将使你的技术堆栈保持最新,并让团队保持满意。
22.3 规划生产路径
如果你想让你的组织与你同行,理解你在 Docker 中的目标至关重要。本书开头,我向您介绍了五种由 Docker 支持的项目类型——从现代化遗留应用程序到运行无服务器函数。无论你的 PoC 是否符合这些定义,或者你正在做更独特的事情,你都需要理解最终目标,以便你可以规划你的路线图并跟踪进度。
你需要做出的主要决定是在 Docker Swarm 和 Kubernetes 之间。你在本书中使用了 Swarm,这是一个很好的入门方式,但如果你正在考虑云服务,Kubernetes 是更好的选择。你可以在 Kubernetes 中使用所有的 Docker 镜像,但应用程序定义的格式与 Docker Compose 不同,而且你需要考虑一个相当陡峭的学习曲线;通过《一个月午餐学 Kubernetes》这本书可以帮助你度过这个阶段。如果你计划在数据中心运行容器平台,我的建议是先从 Docker Swarm 开始,它在操作上易于管理。Kubernetes 是一个复杂的系统,需要专门的行政团队,并且商业产品可能是一个更好的选择。
22.4 认识 Docker 社区
我将以确保你知道你不是一个人结束。Docker 社区非常庞大,有一个非常活跃的在线空间,全球范围内都有面对面的聚会。你肯定能找到愿意分享他们知识和经验的人,Docker 社区几乎是所有社区中最友好的。以下是加入的方式:
-
Docker 社区 Slack 群组--
dockr.ly/slack. -
找一个面对面的或虚拟的聚会--
events.docker.com. -
关注 Docker Captains;这些是 Docker 认可的专家和分享者社区成员-- www.docker.com/community/captains .
-
DockerCon,容器会议--
dockercon.com.
我也是这个社区的一员--你可以在社区 Slack @elton-stoneman 和 Twitter @EltonStoneman 找到我;随时欢迎联系我。你还可以在 GitHub (@sixeyed) 和我的博客,blog.sixeyed.com 找到我。感谢阅读。我希望你觉得这本书有用,并希望 Docker 能带你到想去的地方。


浙公网安备 33010602011771号