Docker-Windows-教程(全)

Docker Windows 教程(全)

原文:zh.annas-archive.org/md5/51C8B846C280D9811810C638FA10FD64

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

容器是运行软件的新方式。它们高效、安全、可移植,您可以在 Docker 中运行 Windows 应用程序而无需进行代码更改。Docker 帮助您应对 IT 中的最大挑战:现代化传统应用程序、构建新应用程序、迁移到云端、采用 DevOps 并保持创新。

本书将教会您有关 Windows 上 Docker 的一切,从基础知识到在生产环境中运行高可用负载。您将通过一个 Docker 之旅,从关键概念和在 Windows 上的.NET Framework 和.NET Core 应用程序的简单示例开始。然后,您将学习如何使用 Docker 来现代化传统的 ASP.NET 和 SQL Server 应用程序的架构和开发。

这些示例向您展示了如何将传统的单片应用程序拆分为分布式应用程序,并将它们部署到云端的集群环境中,使用与本地运行时完全相同的构件。您将了解如何构建使用 Docker 来编译、打包、测试和部署应用程序的 CI/CD 流水线。为了帮助您自信地进入生产环境,您将学习有关 Docker 安全性、管理和支持选项的知识。

本书最后将指导您如何在自己的项目中开始使用 Docker。您将学习一些 Docker 实施的真实案例,从小规模的本地应用到在 Azure 上运行的大规模应用。

本书适合对象

如果您想要现代化旧的单片应用程序而不必重写它,平稳地部署到生产环境,或者迁移到 DevOps 或云端,那么 Docker 就是您的实现者。本书将为您提供 Docker 的扎实基础,让您能够自信地应对所有这些情况。

要充分利用本书

本书附带了大量代码,存储在 GitHub 的sixeyed/docker-on-windows仓库中。要使用这些示例,您需要:

  • Windows 10 与 1809 更新,或 Windows Server 2019

  • Docker 版本 18.09 或更高

下载示例代码文件

您可以从您在www.packt.com的账户中下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packt.com/support并注册,文件将直接通过电子邮件发送给您。

您可以按照以下步骤下载代码文件:

  1. www.packt.com上登录或注册。

  2. 选择“支持”选项卡。

  3. 点击“代码下载和勘误”。

  4. 在搜索框中输入书名,然后按照屏幕上的说明操作。

下载文件后,请确保使用最新版本的解压或提取文件夹:

  • WinRAR/7-Zip for Windows

  • Zipeg/iZip/UnRarX for Mac

  • 7-Zip/PeaZip for Linux

该书的代码包也托管在 GitHub 上:github.com/PacktPublishing/Docker-on-Windows-Second-Edition。如果代码有更新,将在现有的 GitHub 存储库中更新。

我们还有来自我们丰富书籍和视频目录的其他代码包,可在github.com/PacktPublishing/上找到。去看看吧!

代码包也可以在作者的 GitHub 存储库中找到:github.com/sixeyed/docker-on-windows/tree/second-edition

下载彩色图像

我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图像。你可以在这里下载:www.packtpub.com/sites/default/files/downloads/9781789617375_ColorImages.pdf

使用的约定

本书中使用了许多文本约定。

CodeInText:表示文本中的代码词,数据库表名,文件夹名,文件名,文件扩展名,路径名,虚拟 URL,用户输入和 Twitter 句柄。这是一个例子:“作为 Azure 门户的替代方案,你可以使用az命令行来管理 DevTest 实验室。”

代码块设置如下:

<?xml version="1.0" encoding="utf-8"?> <configuration>
  <appSettings  configSource="config\appSettings.config"  />
  <connectionStrings  configSource="config\connectionStrings.config"  /> </configuration>

任何命令行输入或输出都以以下方式编写:

> docker version

粗体:表示一个新术语,一个重要的词,或者你在屏幕上看到的词。例如,菜单或对话框中的单词会以这种方式出现在文本中。这是一个例子:“在你做任何其他事情之前,你需要选择切换到 Windows 容器…”

警告或重要提示会以这种方式出现。提示和技巧会以这种方式出现。

第一部分:理解 Docker 和 Windows 容器

本节向读者介绍了 Docker 中的所有关键概念——容器、镜像、注册表和集群。读者将学习应用程序如何在容器中运行,以及如何为 Docker 打包他们自己的应用程序。

本节包括以下章节:

  • 第一章,在 Windows 上开始使用 Docker

  • 第二章,在 Docker 容器中打包和运行应用程序

  • 第三章,开发 Docker 化的.NET Framework 和.NET Core 应用程序

  • 第四章,与 Docker 注册表共享镜像

第一章:在 Windows 上使用 Docker 入门

Docker 是一个应用平台。这是一种在隔离的轻量级单元中运行应用程序的新方法,称为容器。容器是运行应用程序的一种非常高效的方式 - 比虚拟机VMs)或裸机服务器要高效得多。容器在几秒钟内启动,并且不会增加应用程序的内存和计算需求。Docker 对其可以运行的应用程序类型完全不可知。您可以在一个容器中运行全新的.NET Core 应用程序,在另一个容器中运行 10 年前的 ASP.NET 2.0 WebForms 应用程序,这两个容器可以在同一台服务器上。

容器是隔离的单元,但它们可以与其他组件集成。您的 WebForms 容器可以访问托管在.NET Core 容器中的 REST API。您的.NET Core 容器可以访问在容器中运行的 SQL Server 数据库,或者在单独的机器上运行的 SQL Server 实例。您甚至可以设置一个混合 Linux 和 Windows 机器的集群,所有这些机器都运行 Docker,并且 Windows 容器可以透明地与 Linux 容器通信。

无论大小,公司都在转向 Docker 以利用这种灵活性和效率。Docker,Inc. - Docker 平台背后的公司 - 的案例研究显示,通过转向 Docker,您可以减少 50%的硬件需求,并将发布时间缩短 90%,同时仍然保持应用程序的高可用性。这种显著的减少同样适用于本地数据中心和云。

效率并不是唯一的收获。当您将应用程序打包到 Docker 中运行时,您会获得可移植性。您可以在笔记本电脑上的 Docker 容器中运行应用程序,并且它将在数据中心的服务器和任何云中的 VM 上表现完全相同。这意味着您的部署过程简单且无风险,因为您正在部署您已经测试过的完全相同的构件,并且您还可以自由选择硬件供应商和云提供商。

另一个重要的动机是安全性。容器在应用程序之间提供了安全隔离,因此您可以放心,如果一个应用程序受到攻击,攻击者无法继续攻击同一主机上的其他应用程序。平台还有更广泛的安全性好处。Docker 可以扫描打包应用程序的内容,并提醒您应用程序堆栈中的安全漏洞。您还可以对容器映像进行数字签名,并配置 Docker 仅从您信任的映像作者运行容器。

Docker 是由开源组件构建的,并作为Docker 社区版Docker CE)和Docker 企业版提供。Docker CE 是免费使用的,并且每月发布。Docker 企业版是付费订阅;它具有扩展功能和支持,并且每季度发布。Docker CE 和 Docker 企业版都可在 Windows 上使用,并且两个版本使用相同的基础平台,因此您可以以相同的方式在 Docker CE 和 Docker 企业版上运行应用程序容器。

本章让您快速上手 Docker 容器。它涵盖了:

  • Docker 和 Windows 容器

  • 理解关键的 Docker 概念

  • 在 Windows 上运行 Docker

  • 通过本书了解 Docker

技术要求

您可以使用 GitHub 存储库github.com/sixeyed/docker-on-windows/tree/second-edition/ch01中的代码示例来跟随本书。您将在本章中学习如何安装 Docker - 唯一的先决条件是 Windows 10 并安装了 1809 微软更新,或者 Windows Server 2019。

Docker 和 Windows 容器

Docker 最初是在 Linux 上开发的,利用了核心 Linux 功能,但使得在应用工作负载中使用容器变得简单高效。微软看到了潜力,并与 Docker 工程团队密切合作,将相同的功能带到了 Windows 上。

Windows Server 2016 是第一个可以运行 Docker 容器的 Windows 版本;Windows Server 2019 通过显著改进的功能和性能继续创新 Windows 容器。您可以在 Windows 10 上运行相同的 Docker 容器进行开发和测试,就像在生产环境中在 Windows Server 上运行一样。目前,您只能在 Windows 上运行 Windows 应用程序容器,但微软正在增加对在 Windows 上运行 Linux 应用程序容器的支持。

首先,您需要知道的是容器与 Windows UI 之间没有集成。容器仅用于服务器端应用工作负载,如网站、API、数据库、消息队列、消息处理程序和控制台应用程序。您不能使用 Docker 来运行客户端应用程序,比如.NET WinForms 或 WPF 应用程序,但您可以使用 Docker 来打包和分发应用程序,这将为您所有的应用程序提供一致的构建和发布流程。

在 Windows Server 2019 和 Windows 10 上运行容器的方式也有所不同。使用 Docker 的用户体验是相同的,但容器托管的方式不同。在 Windows Server 上,服务应用程序的进程实际上在服务器上运行,并且容器和主机之间没有层。在容器中,您可能会看到w3wp.exe运行以提供网站服务,但该进程实际上在服务器上运行 - 如果您运行了 10 个 Web 容器,您将在服务器的任务管理器中看到 10 个w3wp.exe实例。

Windows 10 与 Windows Server 2019 没有相同的操作系统内核,因此为了为容器提供 Windows Server 内核,Windows 10 在一个非常轻量的虚拟机中运行每个容器。这些被称为Hyper-V 容器,如果您在 Windows 10 上的容器中运行 Web 应用程序,您将看不到w3wp.exe在主机上运行 - 它实际上在 Hyper-V 容器中的专用 Windows Server 内核中运行。

这是默认行为,但在最新版本的 Windows 和 Docker 中,您可以在 Windows 10 上运行 Windows Server 容器,因此您可以跳过为每个容器运行 VM 的额外开销。

了解 Windows Server 容器和 Hyper-V 容器之间的区别是很重要的。您可以为两者使用相同的 Docker 工件和相同的 Docker 命令,因此流程是相同的,但使用 Hyper-V 容器会有轻微的性能损失。在本章的后面,我将向您展示在 Windows 上运行 Docker 的选项,您可以选择最适合您的方法。

Windows 版本

Windows Server 容器中的应用程序直接在主机上运行进程,并且服务器上的 Windows 版本需要与容器内的 Windows 版本匹配。本书中的所有示例都基于使用 Windows Server 2019 的容器,这意味着您需要一台 Windows Server 2019 机器来运行它们,或者使用安装了 1809 更新的 Windows 10(winver命令将告诉您您的更新版本)。

您可以将为不同版本的 Windows 构建的容器作为 Hyper-V 容器运行。这样可以实现向后兼容性,因此您可以在运行 Windows Server 2019 的计算机上运行为 Windows Server 2016 构建的容器。

Windows 许可

Windows 容器与运行 Windows 的服务器或虚拟机没有相同的许可要求。Windows 的许可是在主机级别而不是容器级别。如果在一台服务器上运行了 100 个 Windows 容器,您只需要为服务器购买一个许可证。如果您目前使用虚拟机来隔离应用程序工作负载,那么可以节省相当多的费用。去除虚拟机层并直接在服务器上运行应用程序容器可以消除所有虚拟机的许可要求,并减少所有这些机器的管理开销。

Hyper-V 容器有单独的许可。在 Windows 10 上,您可以运行多个容器,但不能用于生产部署。在 Windows Server 上,您还可以以 Hyper-V 模式运行容器以增加隔离性。这在多租户场景中很有用,其中您需要预期和减轻敌对工作负载。Hyper-V 容器是单独许可的,在高容量环境中,您需要 Windows Server 数据中心许可证才能运行 Hyper-V 容器而不需要单独的许可证。

微软和 Docker 公司合作,为 Windows Server 2016 和 Windows Server 2019 提供免费的 Docker Enterprise。Windows Server 许可证的价格包括 Docker Enterprise Engine,这使您可以获得在容器中运行应用程序的支持。如果您在容器或 Docker 服务方面遇到问题,可以向微软提出,并且他们可以将问题升级给 Docker 的工程师。

理解关键的 Docker 概念

Docker 是一个非常强大但非常简单的应用程序平台。你可以在短短几天内开始在 Docker 中运行你现有的应用程序,并在另外几天内准备好投入生产。本书将带你通过许多.NET Framework 和.NET Core 应用程序在 Docker 中运行的示例。你将学习如何在 Docker 中构建、部署和运行应用程序,并进入高级主题,如解决方案设计、安全性、管理、仪表板和持续集成和持续交付(CI/CD)。

首先,你需要了解核心的 Docker 概念:镜像、注册表、容器和编排器 - 以及了解 Docker 的实际运行方式。

Docker 引擎和 Docker 命令行

Docker 作为后台 Windows 服务运行。这个服务管理每个正在运行的容器 - 它被称为 Docker 引擎。引擎为消费者提供了一个 REST API,用于处理容器和其他 Docker 资源。这个 API 的主要消费者是 Docker 命令行工具(CLI),这是我在本书中大部分代码示例中使用的工具。

Docker REST API 是公开的,有一些由 API 驱动的替代管理工具,包括像 Portainer(开源)和 Docker Universal Control Plane(UCP)(商业产品)这样的 Web UI。Docker CLI 非常简单易用 - 你可以使用像docker container run这样的命令来在容器中运行应用程序,使用docker container rm来删除容器。

你还可以配置 Docker API 以实现远程访问,并配置你的 Docker CLI 以连接到远程服务。这意味着你可以使用笔记本电脑上的 Docker 命令管理在云中运行的 Docker 主机。允许远程访问的设置也可以包括加密,因此你的连接是安全的 - 在本章中,我将向你展示一种简单的配置方法。

一旦你运行了 Docker,你将开始从镜像中运行容器。

Docker 镜像

Docker 镜像是一个完整的应用程序包。它包含一个应用程序及其所有的依赖项:语言运行时、应用程序主机和底层操作系统。从逻辑上讲,镜像是一个单一的文件,它是一个可移植的单元 - 你可以通过将你的镜像推送到 Docker 注册表来分享你的应用程序。任何有权限的人都可以拉取镜像并在容器中运行你的应用程序;它对他们来说的行为方式与对你来说完全一样。

这里有一个具体的例子。一个 ASP.NET WebForms 应用程序将在 Windows Server 上的Internet Information ServicesIIS)上运行。为了将应用程序打包到 Docker 中,您构建一个基于 Windows Server Core 的镜像,添加 IIS,然后添加 ASP.NET,复制您的应用程序,并在 IIS 中配置它作为一个网站。您可以在一个称为Dockerfile的简单脚本中描述所有这些步骤,并且您可以使用 PowerShell 或批处理文件来执行每个步骤。

通过运行docker image build来构建镜像。输入是 Dockerfile 和需要打包到镜像中的任何资源(如 Web 应用程序内容)。输出是一个 Docker 镜像。在这种情况下,镜像的逻辑大小约为 5GB,但其中 4GB 将是您用作基础的 Windows Server Core 镜像,并且该镜像可以作为基础共享给许多其他镜像。(我将在第四章中更详细地介绍镜像层和缓存,使用 Docker 注册表共享镜像。)

Docker 镜像就像是您的应用程序一个版本的文件系统快照。镜像是静态的,并且您可以使用镜像注册表来分发它们。

镜像注册表

注册表是 Docker 镜像的存储服务器。注册表可以是公共的或私有的,有免费的公共注册表和商业注册表服务器,可以对镜像进行细粒度的访问控制。镜像以唯一的名称存储在注册表中。任何有权限的人都可以通过运行docker image push来上传镜像,并通过运行docker image pull来下载镜像。

最受欢迎的注册表是Docker Hub,这是由 Docker 托管的公共注册表,但其他公司也托管自己的注册表来分发他们自己的软件:

  • Docker Hub 是默认的注册表,它已经变得非常受欢迎,用于开源项目、商业软件以及团队开发的私有项目。在 Docker Hub 上存储了数十万个镜像,每年提供数十亿次的拉取请求。您可以将 Docker Hub 镜像配置为公共或私有。它适用于内部产品,您可以限制对镜像的访问。您可以设置 Docker Hub 自动从存储在 GitHub 中的 Dockerfile 构建镜像-目前,这仅支持基于 Linux 的镜像,但 Windows 支持应该很快就会到来。

  • Microsoft 容器注册表MCR)是微软托管其自己的 Windows Server Core 和 Nano Server 的 Docker 图像的地方,以及预先配置了.NET Framework 的图像。微软的 Docker 图像可以免费下载和使用。它们只能在 Windows 机器上运行,这是 Windows 许可证适用的地方。

在典型的工作流程中,您可能会在 CI 管道的一部分构建图像,并在所有测试通过时将它们推送到注册表。您可以使用 Docker Hub,也可以运行自己的私有注册表。然后,该图像可供其他用户在容器中运行您的应用程序。

Docker 容器

容器是从图像创建的应用程序实例。图像包含整个应用程序堆栈,并且还指定了启动应用程序的进程,因此 Docker 知道在运行容器时该做什么。您可以从同一图像运行多个容器,并且可以以不同的方式运行容器。(我将在下一章中描述它们。)

您可以使用docker container run启动应用程序,指定图像的名称和配置选项。分发内置到 Docker 平台中,因此如果您在尝试运行容器的主机上没有图像的副本,Docker 将首先拉取图像。然后它启动指定的进程,您的应用程序就在容器中运行了。

容器不需要固定的 CPU 或内存分配,应用程序的进程可以使用主机的计算能力。您可以在一台普通硬件上运行数十个容器,除非所有应用程序都尝试同时使用大量 CPU,它们将愉快地并发运行。您还可以启动具有资源限制的容器,以限制它们可以访问多少 CPU 和内存。

Docker 提供容器运行时,以及图像打包和分发。在小型环境和开发中,您将在单个 Docker 主机上管理单个容器,这可以是您的笔记本电脑或测试服务器。当您转移到生产环境时,您将需要高可用性和扩展选项,这需要像 Docker Swarm 这样的编排器。

Docker Swarm

Docker 有能力在单台机器上运行,也可以作为运行 Docker 的一组机器中的一个节点。这个集群被称为Swarm,你不需要安装任何额外的东西来在 swarm 模式下运行。你在一组机器上安装 Docker - 在第一台机器上,你运行docker swarm init来初始化 swarm,在其他机器上,你运行docker swarm join来加入 swarm。

我将在第七章中深入介绍 swarm 模式,使用 Docker Swarm 编排分布式解决方案,但在你继续深入之前,重要的是要知道 Docker 平台具有高可用性、安全性、规模和弹性。希望你的 Docker 之旅最终会让你受益于所有这些特性。

在 swarm 模式下,Docker 使用完全相同的构件,因此你可以在一个 20 节点的 swarm 中运行 50 个容器的应用,其功能与在笔记本上的单个容器中运行时相同。在 swarm 中,你的应用性能更高,更容忍故障,并且你将能够对新版本执行自动滚动更新。

在 swarm 中,节点使用安全加密进行所有通信,为每个节点使用受信任的证书。你也可以将应用程序秘密作为加密数据存储在 swarm 中,因此数据库连接字符串和 API 密钥可以被安全保存,并且 swarm 只会将它们传递给需要它们的容器。

Docker 是一个成熟的平台。它在 2016 年才新加入 Windows Server,但在 Linux 上发布了四年后才进入 Windows。Docker 是用 Go 语言编写的,这是一种跨平台语言,只有少部分代码是特定于 Windows 的。当你在 Windows 上运行 Docker 时,你正在运行一个经过多年成功生产使用的应用平台。

关于 Kubernetes 的说明

Docker Swarm 是一个非常流行的容器编排器,但并不是唯一的选择。Kubernetes 是另一个选择,它已经取得了巨大的增长,大多数公共云现在都提供托管的 Kubernetes 服务。在撰写本书时,Kubernetes 是一个仅限于 Linux 的编排器,Windows 支持仍处于测试阶段。在你的容器之旅中,你可能会听到很多关于 Kubernetes 的内容,因此了解它与 Docker Swarm 的比较是值得的。

首先,相似之处 - 它们都是容器编排器,这意味着它们都是负责在生产环境中以规模运行容器的机器集群。它们都可以运行 Docker 容器,并且您可以在 Docker Swarm 和 Kubernetes 中使用相同的 Docker 镜像。它们都是基于开源项目构建的,并符合Open Container InitiativeOCI)的标准,因此不必担心供应商锁定问题。您可以从 Docker Swarm 开始,然后转移到 Kubernetes,反之亦然,而无需更改您的应用程序。

现在,不同之处。Docker Swarm 非常简单;您只需几行标记就可以描述要在 swarm 中以容器运行的分布式应用程序。要在 Kubernetes 上运行相同的应用程序,您的应用程序描述将是四倍甚至更多的标记。Kubernetes 比 swarm 具有更多的抽象和配置选项,因此有一些您可以在 Kubernetes 中做但在 swarm 中做不了的事情。这种灵活性的代价是复杂性,而且学习 Kubernetes 的学习曲线比学习 swarm 要陡峭得多。

Kubernetes 很快将支持 Windows,但在一段时间内不太可能在 Linux 服务器和 Windows 服务器之间提供完全的功能兼容性。在那之前,使用 Docker Swarm 是可以的 - Docker 有数百家企业客户在 Docker Swarm 上运行他们的生产集群。如果您发现 Kubernetes 具有一些额外的功能,那么一旦您对 swarm 有了很好的理解,学习 Kubernetes 将会更容易。

在 Windows 上运行 Docker

在 Windows 10 上安装 Docker 很容易,使用Docker Desktop - 这是一个设置所有先决条件、部署最新版本的 Docker Community Engine 并为您提供一些有用选项来管理镜像存储库和远程集群的 Windows 软件包。

在生产环境中,您应该理想地使用 Windows Server 2019 Core,即没有 UI 的安装版本。这样可以减少攻击面和服务器所需的 Windows 更新数量。如果将所有应用程序迁移到 Docker,您将不需要安装任何其他 Windows 功能;您只需将 Docker Engine 作为 Windows 服务运行。

我将介绍这两种安装选项,并向您展示第三种选项,即在 Azure 中使用 VM,如果您想尝试 Docker 但无法访问 Windows 10 或 Windows Server 2019,则这种选项非常有用。

有一个名为 Play with Docker 的在线 Docker 游乐场,网址是dockr.ly/play-with-docker。Windows 支持预计很快就会到来,这是一个很好的尝试 Docker 的方式,而不需要进行任何投资 - 你只需浏览该网站并开始使用。

Docker Desktop

Docker Desktop 可以从 Docker Hub 获取 - 你可以通过导航到dockr.ly/docker-for-windows找到它。你可以在稳定通道Edge 通道之间进行选择。两个通道都提供社区 Docker Engine,但 Edge 通道遵循每月发布周期,并且你将获得实验性功能。稳定通道跟踪 Docker Engine 的发布周期,每季度更新一次。

如果你想使用最新功能进行开发,应该使用 Edge 通道。在测试和生产中,你将使用 Docker Enterprise,因此需要小心,不要使用开发中尚未在 Enterprise 中可用的功能。Docker 最近宣布了Docker Desktop Enterprise,让开发人员可以在本地运行与其组织在生产中运行的完全相同的引擎。

你需要下载并运行安装程序。安装程序将验证你的设置是否可以运行 Docker,并配置支持 Docker 所需的 Windows 功能。当 Docker 运行时,你会在通知栏看到一个鲸鱼图标,你可以右键单击以获取选项:

在做任何其他操作之前,你需要选择切换到 Windows 容器...。Windows 上的 Docker Desktop 可以通过在你的机器上运行 Linux VM 中的 Docker 来运行 Linux 容器。这对于测试 Linux 应用程序以查看它们在容器中的运行方式非常有用,但本书关注的是 Windows 容器 - 所以切换过去,Docker 将在未来记住这个设置。

在 Windows 上运行 Docker 时,你可以打开命令提示符或 PowerShell 会话并开始使用容器。首先,通过运行docker version来验证一切是否按预期工作。你应该看到类似于这段代码片段的输出:

> docker version

Client: Docker Engine - Community
 Version:           18.09.2
 API version:       1.39
 Go version:        go1.10.8
 Git commit:        6247962
 Built:             Sun Feb 10 04:12:31 2019
 OS/Arch:           windows/amd64
 Experimental:      false

Server: Docker Engine - Community
 Engine:
  Version:          18.09.2
  API version:      1.39 (minimum version 1.24)
  Go version:       go1.10.6
  Git commit:       6247962
  Built:            Sun Feb 10 04:28:48 2019
  OS/Arch:          windows/amd64
  Experimental:     true

输出会告诉你命令行客户端和 Docker Engine 的版本。操作系统字段应该都是Windows;如果不是,那么你可能仍然处于 Linux 模式,需要切换到 Windows 容器。

现在使用 Docker CLI 运行一个简单的容器:

docker container run dockeronwindows/ch01-whale:2e

这使用了 Docker Hub 上的公共镜像 - 本书的示例镜像之一,Docker 在您第一次使用时会拉取。如果您没有其他镜像,这将需要几分钟,因为它还会下载我镜像所使用的 Microsoft Nano Server 镜像。当容器运行时,它会显示一些 ASCII 艺术然后退出。再次运行相同的命令,您会发现它执行得更快,因为镜像现在已经在本地缓存中。

Docker Desktop 在启动时会检查更新,并在准备好时提示您下载新版本。只需在发布新版本时安装新版本,即可使您的 Docker 工具保持最新。您可以通过从任务栏菜单中选择 关于 Docker Desktop 来检查您已安装的当前版本:

这就是您需要的所有设置。Docker Desktop 还包含了我将在本书中稍后使用的 Docker Compose 工具,因此您已准备好跟着代码示例进行操作。

Docker 引擎

Docker Desktop 在 Windows 10 上使用容器进行开发非常方便。对于没有 UI 的生产环境中,您可以安装 Docker 引擎以作为后台 Windows 服务运行,使用 PowerShell 模块进行安装。

在新安装的 Windows Server 2019 Core 上,使用 sconfig 工具安装所有最新的 Windows 更新,然后运行这些 PowerShell 命令来安装 Docker 引擎和 Docker CLI:

Install-Module -Name DockerMsftProvider -Repository PSGallery -Force
Install-Package -Name docker -ProviderName DockerMsftProvider

这将配置服务器所需的 Windows 功能,安装 Docker,并设置其作为 Windows 服务运行。根据安装了多少 Windows 更新,您可能需要重新启动服务器:

Restart-Computer -Force

当服务器在线时,请确认 Docker 是否正在运行 docker version,然后从本章的示例镜像中运行一个容器:

docker container run dockeronwindows/ch01-whale:2e

当发布新版本的 Docker Engine 时,您可以通过重复 Install 命令并添加 -Update 标志来更新服务器:

Install-Package -Name docker -ProviderName DockerMsftProvider -Update 

我在一些环境中使用这个配置 - 在轻量级虚拟机中运行 Windows Server 2019 Core,只安装了 Docker。您可以通过远程桌面连接在服务器上使用 Docker,或者您可以配置 Docker 引擎以允许远程连接,这样您就可以使用笔记本电脑上的 docker 命令管理服务器上的 Docker 容器。这是一个更高级的设置,但它确实为您提供了安全的远程访问。

最好设置 Docker 引擎,以便使用 TLS 对客户端进行安全通信,这与 HTTPS 使用的加密技术相同。只有具有正确 TLS 证书的客户端才能连接到服务。您可以通过在 VM 内运行以下 PowerShell 命令来设置这一点,提供 VM 的外部 IP 地址:

$ipAddress = '<vm-ip-address>'

mkdir -p C:\certs\client

docker container run --rm `
 --env SERVER_NAME=$(hostname) `
 --env IP_ADDRESSES=127.0.0.1,$ipAddress `
 --volume 'C:\ProgramData\docker:C:\ProgramData\docker' `
 --volume 'C:\certs\client:C:\Users\ContainerAdministrator\.docker' `
 dockeronwindows/ch01-dockertls:2e

Restart-Service docker

不要太担心这个命令在做什么。在接下来的几章中,您将对所有这些 Docker 选项有一个很好的理解。我正在使用一个基于 Stefan Scherer 的 Docker 镜像,他是微软 MVP 和 Docker Captain。该镜像有一个脚本,用 TLS 证书保护 Docker 引擎。您可以在 Stefan 的博客上阅读更多详细信息stefanscherer.github.io

当这个命令完成时,它将配置 Docker 引擎 API,只允许安全的远程连接,并且还将创建客户端需要使用的证书。从 VM 上的C:\certs\client目录中复制这些证书到您想要使用 Docker 客户端的机器上。

在客户端机器上,您可以设置环境变量,指向 Docker 客户端使用远程 Docker 服务。这些命令将建立与 VM 的远程连接(假设您在客户端上使用了相同的证书文件路径),如下所示:

$ipAddress = '<vm-ip-address>'

$env:DOCKER_HOST='tcp://$($ipAddress):2376'
$env:DOCKER_TLS_VERIFY='1'
$env:DOCKER_CERT_PATH='C:\certs\client'

您可以使用这种方法安全地连接到任何远程 Docker 引擎。如果您没有 Windows 10 或 Windows Server 2019 的访问权限,您可以在云上创建一个 VM,并使用相同的命令连接到它。

Azure VM 中的 Docker

微软让在 Azure 中运行 Docker 变得很容易。他们提供了一个带有 Docker 安装和配置的 VM 映像,并且已经拉取了基本的 Windows 映像,这样您就可以快速开始使用。

用于测试和探索,我总是在 Azure 中使用 DevTest 实验室。这是一个非生产环境的很棒的功能。默认情况下,在 DevTest 实验室中创建的任何虚拟机每天晚上都会被关闭,这样你就不会因为使用了几个小时并忘记关闭的虚拟机而产生大量的 Azure 账单。

您可以通过 Azure 门户创建一个 DevTest 实验室,然后从 Microsoft 的 VM 映像Windows Server 2019 Datacenter with Containers创建一个 VM。作为 Azure 门户的替代方案,您可以使用az命令行来管理 DevTest 实验室。我已经将az打包到一个 Docker 镜像中,您可以在 Windows 容器中运行它:

docker container run -it dockeronwindows/ch01-az:2e

这将运行一个交互式的 Docker 容器,其中包含打包好并准备好使用的az命令。运行az login,然后你需要打开浏览器并对 Azure CLI 进行身份验证。然后,你可以在容器中运行以下命令来创建一个 VM:

az lab vm create `
 --lab-name docker-on-win --resource-group docker-on-win `
 --name dow-vm-01 `
 --image "Windows Server 2019 Datacenter with Containers" `
 --image-type gallery --size Standard_DS2_v2 `
 --admin-username "elton" --admin-password "S3crett20!9"

该 VM 使用带有 UI 的完整 Windows Server 2019 安装,因此你可以使用远程桌面连接到该机器,打开 PowerShell 会话,并立即开始使用 Docker。与其他选项一样,你可以使用docker version检查 Docker 是否正在运行,然后从本章的示例镜像中运行一个容器:

docker container run dockeronwindows/ch01-whale:2e

如果 Azure VM 是你首选的选项,你可以按照上一节的步骤来保护远程访问的 Docker API。这样你就可以在笔记本电脑上运行 Docker 命令行来管理云上的容器。Azure VM 使用 PowerShell 部署 Docker,因此你可以使用上一节中的InstallPackage ... -Update命令来更新 VM 上的 Docker Engine。

所有这些选项 - Windows 10、Windows Server 2019 和 Azure VM - 都可以运行相同的 Docker 镜像,并产生相同的结果。Docker 镜像中的示例应用程序dockeronwindows/ch01-whale:2e在每个环境中的行为都是相同的。

通过本书学习 Docker

本书中的每个代码清单都附有我 GitHub 存储库中的完整代码示例,网址为github.com/sixeyed/docker-on-windows。书中有一个名为second-edition的分支。源代码树按章节组织,每个章节都有一个用于每个代码示例的文件夹。在本章中,我使用了三个示例来创建 Docker 镜像,你可以在ch01\ch01-whalech01\ch01-azch01\ch01-dockertls中找到它们。

本书中的代码清单可能会被压缩,但完整的代码始终可以在 GitHub 存储库中找到。

我在学习新技术时更喜欢跟着代码示例走,但如果你想使用演示应用程序的工作版本,每个示例也可以作为公共 Docker 镜像在 Docker Hub 上找到。无论何时看到docker container run命令,该镜像已经存在于 Docker Hub 上,因此如果愿意,你可以使用我的镜像而不是构建自己的。dockeronwindows组织中的所有镜像,比如本章的dockeronwindows/ch01-whale:2e,都是从 GitHub 存储库中相关的 Dockerfile 构建的。

我的开发环境分为 Windows 10 和 Windows Server 2019,我在 Windows 10 上使用 Docker Desktop,在 Windows Server 2019 上运行 Docker Enterprise Engine。我的测试环境基于 Windows Server 2019 Core,我也在那里运行 Docker Enterprise Engine。我已在所有这些环境中验证了本书中的所有代码示例。

我正在使用 Docker 的 18.09 版本,这是我写作时的最新版本。Docker 一直向后兼容,所以如果你在 Windows 10 或 Windows Server 2019 上使用的版本晚于 18.09,那么示例 Dockerfiles 和镜像应该以相同的方式工作。

我的目标是让这本书成为关于 Windows 上 Docker 的权威之作,所以我涵盖了从容器的基础知识,到使用 Docker 现代化.NET 应用程序以及容器的安全性影响,再到 CI/CD 和生产管理的所有内容。这本书以指导如何在自己的项目中继续使用 Docker 结束。

如果你想讨论这本书或者你自己的 Docker 之旅,欢迎在 Twitter 上@EltonStoneman 找我。

总结

在本章中,我介绍了 Docker,这是一个可以在轻量级计算单元容器中运行新旧应用程序的应用平台。公司正在转向 Docker 以提高效率、安全性和可移植性。我涵盖了以下主题:

  • Docker 在 Windows 上的工作原理以及容器的许可。

  • Docker 的关键概念:镜像、注册表、容器和编排器。

  • 在 Windows 10、Windows Server 2019 或 Azure 上运行 Docker 的选项。

如果你打算在本书的其余部分跟着代码示例一起工作,那么你现在应该有一个可用的 Docker 环境了。在第二章中,将应用程序打包并作为 Docker 容器运行,我将继续讨论如何将更复杂的应用程序打包为 Docker 镜像,并展示如何使用 Docker 卷在容器中管理状态。

第二章:打包和运行应用程序作为 Docker 容器

Docker 将基础设施的逻辑视图简化为三个核心组件:主机、容器和图像。主机是运行容器的服务器,每个容器都是应用程序的隔离实例。容器是从图像创建的,图像是打包的应用程序。Docker 容器图像在概念上非常简单:它是一个包含完整、自包含应用程序的单个单元。图像格式非常高效,图像和容器运行时之间的集成非常智能,因此掌握图像是有效使用 Docker 的第一步。

在第一章中,您已经通过运行一些基本容器来检查 Docker 安装是否正常工作,但我没有仔细检查图像或 Docker 如何使用它。在本章中,您将彻底了解 Docker 图像,了解它们的结构,了解 Docker 如何使用它们,并了解如何将自己的应用程序打包为 Docker 图像。

首先要理解的是图像和容器之间的区别,通过从相同的图像运行不同类型的容器,您可以非常清楚地看到这一点。

在本章中,您将更多地了解 Docker 的基础知识,包括:

  • 从图像运行容器

  • 从 Dockerfile 构建图像

  • 将自己的应用程序打包为 Docker 图像

  • 在图像和容器中处理数据

  • 将传统的 ASP.NET Web 应用程序打包为 Docker 图像

技术要求

要跟随示例,您需要在 Windows 10 上运行 Docker,并更新到 18.09 版,或者在 Windows Server 2019 上运行。本章的代码可在github.com/sixeyed/docker-on-windows/tree/second-edition/ch02上找到。

从图像运行容器

docker container run命令从图像创建一个容器,并在容器内启动应用程序。实际上,这相当于运行两个单独的命令,docker container createdocker container start,这表明容器可以具有不同的状态。您可以创建一个容器而不启动它,并且可以暂停、停止和重新启动运行中的容器。容器可以处于不同的状态,并且可以以不同的方式使用它们。

使用任务容器执行一个任务

dockeronwindows/ch02-powershell-env:2e镜像是一个打包的应用程序的示例,旨在在容器中运行并执行单个任务。该镜像基于 Microsoft Windows Server Core,并设置为在启动时运行一个简单的 PowerShell 脚本,打印有关当前环境的详细信息。让我们看看当我直接从镜像运行容器时会发生什么:

> docker container run dockeronwindows/ch02-powershell-env:2e

Name                           Value
----                           -----
ALLUSERSPROFILE                C:\ProgramData
APPDATA                        C:\Users\ContainerAdministrator\AppData\Roaming
CommonProgramFiles             C:\Program Files\Common Files
CommonProgramFiles(x86)        C:\Program Files (x86)\Common Files
CommonProgramW6432             C:\Program Files\Common Files
COMPUTERNAME                   8A7D5B9A4021
...

没有任何选项,容器将运行内置于镜像中的 PowerShell 脚本,并且脚本将打印有关操作系统环境的一些基本信息。我将其称为任务容器,因为容器执行一个任务然后退出。

如果运行docker container ls,列出所有活动容器,您将看不到此容器。但如果运行docker container ls --all,显示所有状态的容器,您将在Exited状态中看到它:

> docker container ls --all
CONTAINER ID  IMAGE       COMMAND    CREATED          STATUS
8a7d5b9a4021 dockeronwindows/ch02-powershell-env:2e "powershell.exe C:..."  30 seconds ago   Exited

任务容器在自动化重复任务方面非常有用,比如运行脚本来设置环境、备份数据或收集日志文件。您的容器镜像打包了要运行的脚本,以及脚本所需的所有要求的确切版本,因此安装了 Docker 的任何人都可以运行脚本,而无需安装先决条件。

这对于 PowerShell 特别有用,因为脚本可能依赖于几个 PowerShell 模块。这些模块可能是公开可用的,但您的脚本可能依赖于特定版本。您可以构建一个已安装了模块的镜像,而不是共享一个需要用户安装许多不同模块的正确版本的脚本。然后,您只需要 Docker 来运行脚本任务。

镜像是自包含的单位,但您也可以将其用作模板。一个镜像可能配置为执行一项任务,但您可以以不同的方式从镜像运行容器以执行不同的任务。

连接到交互式容器

交互式容器是指与 Docker 命令行保持开放连接的容器,因此您可以像连接到远程机器一样使用容器。您可以通过指定交互式选项和容器启动时要运行的命令来从相同的 Windows Server Core 镜像运行交互式容器:

> docker container run --interactive --tty dockeronwindows/ch02-powershell-env:2e `
 powershell

Windows PowerShell
Copyright (C) Microsoft Corporation. All rights reserved.

PS C:\> Write-Output 'This is an interactive container'
This is an interactive container
PS C:\> exit

--interactive选项运行交互式容器,--tty标志将终端连接附加到容器。在容器映像名称后的powershell语句是容器启动时要运行的命令。通过指定命令,您可以替换映像中设置的启动命令。在这种情况下,我启动了一个 PowerShell 会话,它代替了配置的命令,因此环境打印脚本不会运行。

交互式容器会持续运行,只要其中的命令在运行。当您连接到 PowerShell 时,在主机的另一个窗口中运行docker container ls,会显示容器仍在运行。当您在容器中键入exit时,PowerShell 会话结束,因此没有进程在运行,容器也会退出。

交互式容器在构建自己的容器映像时非常有用,它们可以让您首先以交互方式进行步骤,并验证一切是否按您的预期工作。它们也是很好的探索工具。您可以从 Docker 注册表中拉取别人的映像,并在运行应用程序之前探索其内容。

阅读本书时,您会发现 Docker 可以在虚拟网络中托管复杂的分布式系统,每个组件都在自己的容器中运行。如果您想检查系统的某些部分,可以在网络内部运行交互式容器,并检查各个组件,而无需使部分公开可访问。

在后台容器中保持进程运行

最后一种类型的容器是您在生产中最常使用的,即后台容器,它在后台保持长时间运行的进程。它是一个行为类似于 Windows 服务的容器。在 Docker 术语中,它被称为分离容器,Docker 引擎会在后台保持其运行。在容器内部,进程在前台运行。该进程可能是一个 Web 服务器或一个轮询消息队列以获取工作的控制台应用程序,但只要进程保持运行,Docker 就会保持容器保持活动状态。

我可以再次从相同的映像运行后台容器,指定detach选项和运行一些分钟的命令:

> docker container run --detach dockeronwindows/ch02-powershell-env:2e `
 powershell Test-Connection 'localhost' -Count 100

bb326e5796bf48199a9a6c4569140e9ca989d7d8f77988de7a96ce0a616c88e9

在这种情况下,当容器启动后,控制返回到终端;长随机字符串是新容器的 ID。您可以运行docker container ls并查看正在运行的容器,docker container logs命令会显示容器的控制台输出。对于操作特定容器的命令,您可以通过容器名称或容器 ID 的一部分来引用它们 - ID 是随机的,在我的情况下,这个容器 ID 以bb3开头:

> docker container logs bb3

Source        Destination     IPV4Address      IPV6Address
------        -----------     -----------      -----------
BB326E5796BF  localhost       127.0.0.1        ::1
BB326E5796BF  localhost       127.0.0.1        ::1

--detach标志将容器分离,使其进入后台,而在这种情况下,命令只是重复一百次对localhost的 ping。几分钟后,PowerShell 命令完成,因此没有正在运行的进程,容器退出。

这是一个需要记住的关键事情:如果您想要在后台保持容器运行,那么 Docker 在运行容器时启动的进程必须保持运行。

现在您已经看到容器是从镜像创建的,但它可以以不同的方式运行。因此,您可以完全按照准备好的镜像使用,或者将镜像视为内置默认启动模式的模板。接下来,我将向您展示如何构建该镜像。

构建 Docker 镜像

Docker 镜像是分层的。底层是操作系统,可以是完整的操作系统,如 Windows Server Core,也可以是微软 Nano Server 等最小的操作系统。在此之上是每次构建镜像时对基本操作系统所做更改的层,通过安装软件、复制文件和运行命令。从逻辑上讲,Docker 将镜像视为单个单位,但从物理上讲,每个层都存储为 Docker 缓存中的单独文件,因此具有许多共同特征的镜像可以共享缓存中的层。

镜像是使用 Dockerfile 语言的文本文件构建的 - 指定要从哪个基本操作系统镜像开始以及添加的所有步骤。这种语言非常简单,您只需要掌握几个命令就可以构建生产级别的镜像。我将从查看到目前为止在本章中一直在使用的基本 PowerShell 镜像开始。

理解 Dockerfile

Dockerfile 只是一个将软件打包到 Docker 镜像中的部署脚本。PowerShell 镜像的完整代码只有三行:

FROM mcr.microsoft.com/windows/servercore:ltsc2019  COPY scripts/print-env-details.ps1 C:\\print-env.ps1 CMD ["powershell.exe", "C:\\print-env.ps1"]

即使你以前从未见过 Dockerfile,也很容易猜到发生了什么。按照惯例,指令(FROMCOPYCMD)是大写的,参数是小写的,但这不是强制的。同样按照惯例,你保存文本在一个名为Dockerfile的文件中,但这也不是强制的(在 Windows 中,没有扩展名的文件看起来很奇怪,但请记住 Docker 的传统是在 Linux 中)。

让我们逐行查看 Dockerfile 中的指令:

  • FROM mcr.microsoft.com/windows/servercore:ltsc2019使用名为windows/servercore的镜像作为此镜像的起点,指定了镜像的ltsc2019版本和其托管的注册表。

  • COPY scripts/print-env-details.ps1 C:\\print-env.ps1将 PowerShell 脚本从本地计算机复制到镜像中的特定位置。

  • CMD ["powershell.exe", "C:\\print-env.ps1"]指定了容器运行时的启动命令,在这种情况下是运行 PowerShell 脚本。

这里有一些明显的问题。基础镜像是从哪里来的?Docker 内置了镜像注册表的概念,这是一个容器镜像的存储库。默认注册表是一个名为Docker Hub的免费公共服务。微软在 Docker Hub 上发布了一些镜像,但 Windows 基础镜像托管在Microsoft Container RegistryMCR)上。

Windows Server Core 镜像的 2019 版本被称为windows/servercore:ltsc2019。第一次使用该镜像时,Docker 会从 MCR 下载到本地计算机,然后缓存以供进一步使用。

Docker Hub 是 Microsoft 所有镜像的发现列表,因为 MCR 没有 Web UI。即使镜像托管在 MCR 上,它们也会在 Docker Hub 上列出,所以当你在寻找镜像时,那就是去的地方。

PowerShell 脚本是从哪里复制过来的?构建镜像时,包含 Dockerfile 的目录被用作构建的上下文。从这个 Dockerfile 构建镜像时,Docker 会期望在上下文目录中找到一个名为scripts的文件夹,其中包含一个名为print-env-details.ps1的文件。如果找不到该文件,构建将失败。

Dockerfile 使用反斜杠作为转义字符,以便将指令继续到新的一行。这与 Windows 文件路径冲突,所以你必须将C:\print.ps1写成C:\\print.ps1C:/print.ps1。有一个很好的方法来解决这个问题,在 Dockerfile 开头使用处理器指令,我将在本章后面进行演示。

你如何知道 PowerShell 可以使用?它是 Windows Server Core 基础镜像的一部分,所以你可以依赖它。你可以使用额外的 Dockerfile 指令安装任何不在基础镜像中的软件。你可以添加 Windows 功能,设置注册表值,将文件复制或下载到镜像中,解压 ZIP 文件,部署 MSI 文件,以及其他任何你需要的操作。

这是一个非常简单的 Dockerfile,但即使如此,其中两条指令是可选的。只有FROM指令是必需的,所以如果你想构建一个微软的 Windows Server Core 镜像的精确克隆,你可以在 Dockerfile 中只使用一个FROM语句,并且随意命名克隆的镜像。

从 Dockerfile 构建镜像

现在你有了一个 Dockerfile,你可以使用docker命令行将其构建成一个镜像。像大多数 Docker 命令一样,image build命令很简单,只有很少的必需选项,更倾向于使用约定而不是命令。

要构建一个镜像,打开命令行并导航到 Dockerfile 所在的目录。然后运行docker image build并给你的镜像打上一个标签,这个标签就是将来用来识别镜像的名称。

docker image build --tag dockeronwindows/ch02-powershell-env:2e .

每个镜像都需要一个标签,使用--tag选项指定,这是本地镜像缓存和镜像注册表中镜像的唯一标识符。标签是你在运行容器时将引用镜像的方式。完整的标签指定要使用的注册表:存储库名称,这是应用程序的标识符,以及后缀,这是镜像的版本标识符。

当你为自己构建一个镜像时,你可以随意命名,但约定是将你的存储库命名为你的注册表用户名,后面跟上应用程序名称:{user}/{app}。你还可以使用标签来标识应用程序的版本或变体,比如sixeyed/gitsixeyed/git:2.17.1-windowsservercore-ltsc2019,这是 Docker Hub 上我的两个镜像。

image build命令末尾的句点告诉 Docker 要使用的上下文的位置。.是当前目录。Docker 将目录树的内容复制到一个临时文件夹进行构建,因此上下文需要包含 Dockerfile 中引用的任何文件。复制上下文后,Docker 开始执行 Dockerfile 中的指令。

检查 Docker 构建镜像的过程

理解 Docker 镜像是如何构建的将有助于您构建高效的镜像。image build命令会产生大量输出,告诉您 Docker 在构建的每个步骤中做了什么。Dockerfile 中的每个指令都会作为一个单独的步骤执行,产生一个新的镜像层,最终镜像将是所有层的组合堆栈。以下代码片段是构建我的镜像的输出:

> docker image build --tag dockeronwindows/ch02-powershell-env:2e .

Sending build context to Docker daemon  4.608kB
Step 1/3 : FROM mcr.microsoft.com/windows/servercore:ltsc2019
 ---> 8b79386f6e3b
Step 2/3 : COPY scripts/print-env-details.ps1 C:\\print-env.ps1
 ---> 5e9ed4527b3f
Step 3/3 : CMD ["powershell.exe", "C:\\print-env.ps1"]
 ---> Running in c14c8aef5dc5
Removing intermediate container c14c8aef5dc5
 ---> 5f272fb2c190
Successfully built 5f272fb2c190
Successfully tagged dockeronwindows/ch02-powershell-env:2e

这就是 Docker 构建镜像时发生的事情:

  1. FROM镜像已经存在于我的本地缓存中,因此 Docker 不需要下载它。输出是 Microsoft 的 Windows Server Core 镜像的 ID(以8b79开头)。

  2. Docker 将脚本文件从构建上下文复制到一个新的镜像层(ID 5e9e)。

  3. Docker 配置了当从镜像运行容器时要执行的命令。它从步骤 2镜像创建一个临时容器,配置启动命令,将容器保存为一个新的镜像层(ID 5f27),并删除中间容器(ID c14c)。

最终层被标记为镜像名称,但所有中间层也被添加到本地缓存中。这种分层的方法意味着 Docker 在构建镜像和运行容器时可以非常高效。最新的 Windows Server Core 镜像未经压缩超过 4GB,但当您运行基于 Windows Server Core 的多个容器时,它们将都使用相同的基础镜像层,因此您不会得到多个 4GB 镜像的副本。

您将在本章后面更多地了解镜像层和存储,但首先我将看一些更复杂的 Dockerfile,其中打包了.NET 和.NET Core 应用程序。

打包您自己的应用程序

构建镜像的目标是将您的应用程序打包成一个便携、自包含的单元。镜像应尽可能小,这样在运行应用程序时移动起来更容易,并且应尽可能少地包含操作系统功能,这样启动时间快,攻击面小。

Docker 不对图像大小施加限制。你的长期目标可能是构建在 Linux 或 Nano Server 上运行轻量级.NET Core 应用程序的最小图像。但你可以先将现有的 ASP.NET 应用程序作为 Docker 图像的全部内容打包,以在 Windows Server Core 上运行。Docker 也不对如何打包应用程序施加限制,因此你可以选择不同的方法。

在构建过程中编译应用程序

在 Docker 图像中打包自己的应用程序有两种常见的方法。第一种是使用包含应用程序平台和构建工具的基础图像。因此,在你的 Dockerfile 中,你将源代码复制到图像中,并在图像构建过程中编译应用程序。

这是一个受欢迎的公共图像的方法,因为这意味着任何人都可以构建图像,而无需在本地安装应用程序平台。这也意味着应用程序的工具与图像捆绑在一起,因此可以使在容器中运行的应用程序的调试和故障排除成为可能。

这是一个简单的.NET Core 应用程序的示例。这个 Dockerfile 是为dockeronwindows/ch02-dotnet-helloworld:2e图像而设计的:

FROM microsoft/dotnet:2.2-sdk-nanoserver-1809 WORKDIR /src COPY src/ . USER ContainerAdministrator RUN dotnet restore && dotnet build CMD ["dotnet", "run"]

Dockerfile 使用了来自 Docker Hub 的 Microsoft 的.NET Core 图像作为基础图像。这是图像的一个特定变体,它基于 Nano Server 1809 版本,并安装了.NET Core 2.2 SDK。构建将应用程序源代码从上下文中复制进来,并在容器构建过程中编译应用程序。

这个 Dockerfile 中有三个你以前没有见过的新指令:

  1. WORKDIR指定当前工作目录。如果目录在中间容器中不存在,Docker 会创建该目录,并将其设置为当前目录。它将保持为 Dockerfile 中的后续指令以及从图像运行的容器的工作目录。

  2. USER更改构建中的当前用户。Nano Server 默认使用最低特权用户。这将切换到容器图像中的内置帐户,该帐户具有管理权限。

  3. RUN在中间容器中执行命令,并在命令完成后保存容器的状态,创建一个新的图像层。

当我构建这个图像时,你会看到dotnet命令的输出,这是应用程序从图像构建中的RUN指令中编译出来的:

> docker image build --tag dockeronwindows/ch02-dotnet-helloworld:2e . 
Sending build context to Docker daemon  192.5kB
Step 1/6 : FROM microsoft/dotnet:2.2-sdk-nanoserver-1809
 ---> 90724d8d2438
Step 2/6 : WORKDIR /src
 ---> Running in f911e313b262
Removing intermediate container f911e313b262
 ---> 2e2f7deb64ac
Step 3/6 : COPY src/ .
 ---> 391c7d8f4bcc
Step 4/6 : USER ContainerAdministrator
 ---> Running in f08f860dd299
Removing intermediate container f08f860dd299
 ---> 6840a2a2f23b
Step 5/6 : RUN dotnet restore && dotnet build
 ---> Running in d7d61372a57b

Welcome to .NET Core!
...

你会在 Docker Hub 上经常看到这种方法,用于使用.NET Core、Go 和 Node.js 等语言构建的应用程序,其中工具很容易添加到基础镜像中。这意味着你可以在 Docker Hub 上设置自动构建,这样当你将代码更改推送到 GitHub 时,Docker 的服务器就会根据 Dockerfile 构建你的镜像。服务器可以在没有安装.NET Core、Go 或 Node.js 的情况下执行此操作,因为所有构建依赖项都在基础镜像中。

这种选项意味着最终镜像将比生产应用程序所需的要大得多。语言 SDK 和工具可能会占用比应用程序本身更多的磁盘空间,但你的最终结果应该是应用程序;当容器在生产环境运行时,镜像中占用空间的所有构建工具都不会被使用。另一种选择是首先构建应用程序,然后将编译的二进制文件打包到你的容器镜像中。

在构建之前编译应用程序

首先构建应用程序与现有的构建流水线完美契合。你的构建服务器需要安装所有的应用程序平台和构建工具来编译应用程序,但你的最终容器镜像只包含运行应用程序所需的最小内容。采用这种方法,我的.NET Core 应用程序的 Dockerfile 变得更加简单:

FROM  microsoft/dotnet:2.2-runtime-nanoserver-1809

WORKDIR /dotnetapp
COPY ./src/bin/Debug/netcoreapp2.2/publish .

CMD ["dotnet", "HelloWorld.NetCore.dll"]

这个 Dockerfile 使用了一个不同的FROM镜像,其中只包含.NET Core 2.2 运行时,而不包含工具(因此它可以运行已编译的应用程序,但无法从源代码编译)。你不能在构建应用程序之前构建这个镜像,所以你需要在构建脚本中包装docker image build命令,该脚本还运行dotnet publish命令来编译二进制文件。

一个简单的构建脚本,用于编译应用程序并构建 Docker 镜像,看起来像这样:

dotnet restore src; dotnet publish src

docker image build --file Dockerfile.slim --tag dockeronwindows/ch02-dotnet-helloworld:2e-slim .

如果你把 Dockerfile 指令放在一个名为Dockerfile之外的文件中,你需要使用--file选项指定文件名:docker image build --file Dockerfile.slim

我把平台工具的要求从镜像移到了构建服务器上,这导致最终镜像变得更小:与之前版本相比,这个版本的大小为 410 MB,而之前的版本为 1.75 GB。你可以通过列出镜像并按照镜像仓库名称进行过滤来看到大小的差异:

> docker image ls --filter reference=dockeronwindows/ch02-dotnet-helloworld

REPOSITORY                               TAG     IMAGE ID       CREATED              SIZE
dockeronwindows/ch02-dotnet-helloworld   2e-slim b6e7dca114a4   About a minute ago   410MB
dockeronwindows/ch02-dotnet-helloworld   2e      bf895a7452a2   7 minutes ago        1.75GB

这个新版本也是一个更受限制的镜像。源代码和.NET Core SDK 没有打包在镜像中,所以你不能连接到正在运行的容器并检查应用程序代码,或者对代码进行更改并重新编译应用程序。

对于企业环境或商业应用程序,你可能已经有一个设备齐全的构建服务器,并且打包构建的应用程序可以成为更全面工作流的一部分:

在这个流水线中,开发人员将他们的更改推送到中央源代码仓库(1)。构建服务器编译应用程序并运行单元测试;如果测试通过,那么容器镜像将在暂存环境中构建和部署(2)。集成测试和端到端测试在暂存环境中运行,如果测试通过,那么你的容器镜像版本是一个好的发布候选,供测试人员验证(3)。

通过在生产环境中从镜像运行容器来部署新版本,并且你知道你的整个应用程序堆栈是通过了所有测试的相同的一组二进制文件。

这种方法的缺点是你需要在所有构建代理上安装应用程序 SDK,并且 SDK 及其所有依赖项的版本需要与开发人员使用的相匹配。通常在 Windows 项目中,你会发现安装了 Visual Studio 的 CI 服务器,以确保服务器具有与开发人员相同的工具。这使得构建服务器非常庞大,需要大量的努力来委托和维护。

这也意味着,除非你在你的机器上安装了.NET Core 2.2 SDK,否则你无法从本章的源代码构建这个 Docker 镜像。

通过使用多阶段构建,你可以兼顾两种选择,其中你的 Dockerfile 定义了一个步骤来编译你的应用程序,另一个步骤将其打包到最终镜像中。多阶段 Dockerfile 是可移植的,因此任何人都可以在没有先决条件的情况下构建镜像,但最终镜像只包含了应用程序所需的最小内容。

使用多阶段构建编译

在多阶段构建中,你的 Dockerfile 中有多个FROM指令,每个FROM指令在构建中启动一个新阶段。Docker 在构建镜像时执行所有指令,后续阶段可以访问前期阶段的输出,但只有最终阶段用于完成的镜像。

我可以通过将前两个 Dockerfile 合并成一个,为.NET Core 控制台应用程序编写一个多阶段的 Dockerfile:

# build stage
FROM microsoft/dotnet:2.2-sdk-nanoserver-1809 AS builder

WORKDIR /src
COPY src/ .

USER ContainerAdministrator
RUN dotnet restore && dotnet publish

# final image stage
FROM microsoft/dotnet:2.2-runtime-nanoserver-1809

WORKDIR /dotnetapp
COPY --from=builder /src/bin/Debug/netcoreapp2.2/publish .

CMD ["dotnet", "HelloWorld.NetCore.dll"]

这里有一些新的东西。第一阶段使用了一个大的基础镜像,安装了.NET Core SDK。我使用FROM指令中的AS选项将这个阶段命名为builder。阶段的其余部分继续复制源代码并发布应用程序。当构建器阶段完成时,发布的应用程序将存储在一个中间容器中。

第二阶段使用了运行时.NET Core 镜像,其中没有安装 SDK。在这个阶段,我将从上一个阶段复制已发布的输出,在COPY指令中指定--from=builder。任何人都可以使用 Docker 从源代码编译这个应用程序,而不需要在他们的机器上安装.NET Core。

用于 Windows 应用程序的多阶段 Dockerfile 是完全可移植的。要编译应用程序并构建镜像,唯一的前提是要有一个安装了 Docker 的 Windows 机器和代码的副本。构建器阶段包含了 SDK 和所有编译器工具,但最终镜像只包含运行应用程序所需的最小内容。

这种方法不仅适用于.NET Core。你可以为.NET Framework 应用程序编写一个多阶段的 Dockerfile,其中第一阶段使用安装了 MSBuild 的镜像,用于编译你的应用程序。书中后面有很多这样的例子。

无论你采取哪种方法,都只需要理解几个 Dockerfile 指令,就可以构建更复杂的应用程序镜像,用于与其他系统集成的软件。

使用主要的 Dockerfile 指令

Dockerfile 语法非常简单。你已经看到了FROMCOPYUSERRUNCMD,这已经足够打包一个基本的应用程序以在容器中运行。对于真实世界的镜像,你需要做更多的工作,还有三个关键指令需要理解。

这是一个简单静态网站的 Dockerfile;它使用Internet Information ServicesIIS)并在默认网站上提供一个 HTML 页面,显示一些基本细节:

# escape=` FROM mcr.microsoft.com/windows/servercore/iis:windowsservercore-ltsc2019
SHELL ["powershell"]

ARG ENV_NAME=DEV

EXPOSE 80

COPY template.html C:\template.html
RUN (Get-Content -Raw -Path C:\template.html) `
 -replace '{hostname}', [Environment]::MachineName `
 -replace '{environment}', [Environment]::GetEnvironmentVariable('ENV_NAME') `
 | Set-Content -Path C:\inetpub\wwwroot\index.html

这个 Dockerfile 的开始方式不同,使用了escape指令。这告诉 Docker 使用反引号`用于转义字符的选项,将命令拆分为多行,而不是默认的反斜杠\选项。通过这个转义指令,我可以在文件路径中使用反斜杠和反斜杠来拆分较长的 PowerShell 命令,这对于 Windows 用户来说更加自然。

基本图像是microsoft/iis,这是一个已经设置好 IIS 的 Microsoft Windows Server Core 图像。我将一个 HTML 模板文件从 Docker 构建上下文复制到根文件夹中。然后,我运行一个 PowerShell 命令来更新模板文件的内容,并将其保存在 IIS 的默认网站位置。

在此 Dockerfile 中,我使用了三条新指令:

  • SHELL指定在RUN命令中使用的命令行。默认值是cmd,这会切换到powershell

  • ARG指定要在带有默认值的图像中使用的构建参数。

  • EXPOSE将使图像中的端口可用,以便从主机中向图像的容器发送流量。

此静态网站有一个单一的主页,其中告诉您发送响应的服务器的名称,并在页面标题中显示环境的名称。HTML 模板文件具有主机名和环境名称的占位符。RUN命令执行一个 PowerShell 脚本来读取文件内容,用实际主机名和环境值替换占位符,然后写出内容。

容器在一个隔离的空间中运行,只有在图像明确地使端口可用的情况下,主机才能将网络流量发送到容器中。那就是EXPOSE指令,它类似于一个非常简单的防火墙;您可以使用它来暴露应用程序正在侦听的端口。当您从此图像运行容器时,端口80可供发布,因此 Docker 可以从容器中提供 Web 流量。

我可以按照通常的方式构建此图像,并利用 Dockerfile 中指定的ARG命令,在构建时使用--build-arg选项来覆盖默认值:


docker image build --build-arg ENV_NAME=TEST --tag dockeronwindows/ch02-static-website:2e .

Docker 处理新的指令的方式与您已经看到的指令相同:它从堆栈中的前一个图像创建一个新的中间容器,执行指令,并从容器中提取新的图像层。构建后,我有一个新的图像,可以运行以启动静态 Web 服务器:


> docker container run --detach --publish 8081:80 dockeronwindows/ch02-static-website:2e

6e3df776cb0c644d0a8965eaef86e377f8ebe036e99961a0621dcb7912d96980

这是一个分离的容器,因此它在后台运行,而--publish选项使容器中的端口80对主机可用。发布的端口意味着主机进入的流量可以通过 Docker 定向到容器中。我指定主机上的端口8081应映射到容器中的端口80

您还可以让 Docker 在主机上选择一个随机端口,并使用port命令列出容器公开的端口以及它们在主机上的发布位置:


> docker container port 6e

80/tcp -> 0.0.0.0:8081

现在,我可以在我的计算机上浏览到端口8081,并查看来自容器内运行的 IIS 的响应,显示给我容器的主机名,实际上是容器 ID,以及标题栏中的环境名称:

环境名称只是描述文本,但是传递给docker image build命令的参数值会覆盖 Dockerfile 中ARG指令的默认值。主机名应该显示容器 ID,但是当前实现存在问题。

在网页上,主机名以bf37开头,但实际上我的容器 ID 以6e3d开头。为了理解为什么显示的 ID 不是正在运行的容器的实际 ID,我将再次查看构建映像期间使用的临时容器。

理解临时容器和映像状态

我的网站容器具有以6e3d开头的 ID,这是容器内应用程序应看到的主机名,但网站声称不是。那么,出了什么问题?请记住,Docker 在临时中间容器中执行每个构建指令。

生成 HTML 的RUN指令在临时容器中运行,因此 PowerShell 脚本将该容器的 ID 写入 HTML 文件作为主机名。这就是起始于bf37的容器 ID 的来源。Docker 会删除中间容器,但是它创建的 HTML 文件将保留在映像中。

这是一个重要的概念:当您构建 Docker 映像时,指令在临时容器内执行。容器会被移除,但是它们写入的状态将保留在最终映像中,并将在从该映像运行的任何容器中存在。如果我从我的网站映像运行多个容器,它们将在 HTML 文件中显示相同的主机名,因为它保存在映像中,该映像由所有容器共享。

当然,您也可以将状态存储在单独的容器中,这不是映像的一部分,因此不会在容器之间共享。我将现在介绍如何使用 Docker 的数据,并以真实的 Dockerfile 示例结束本章。

使用 Docker 映像和容器中的数据

在 Docker 容器中运行的应用程序看到的是单个文件系统,可以按照操作系统的惯例读取和写入。容器看到的是单个文件系统驱动器,但实际上是一个虚拟文件系统,底层数据可以位于许多不同的物理位置。

容器可以访问其 C 驱动器上的文件,这些文件实际上可以存储在镜像层中,在容器自己的存储层中或者映射到主机上某个位置的卷中。 Docker 将所有这些位置合并为单个虚拟文件系统。

层和虚拟 C 驱动器中的数据

虚拟文件系统是 Docker 如何将一组物理镜像层视为一个逻辑容器映像的方式。镜像层被挂载为容器文件系统的只读部分,因此它们无法被更改,这就是它们可以被许多容器安全共享的方式。

每个容器在所有只读层的顶部都有自己的可写层,因此每个容器都可以修改自己的数据而不影响其他容器:

该图显示了从相同镜像运行的两个容器。镜像 (1) 由许多层物理组成:每个 Dockerfile 中的指令构建一个层。两个容器 (2 和 3) 在运行时使用相同的镜像层,但它们各自具有其自己的隔离、可写层。

Docker 向容器呈现单个文件系统。层和只读基础层的概念被隐藏起来,你的容器只是读取和写入数据,就好像它拥有一个完整的本机文件系统,带有一个单独的驱动器。如果在构建 Docker 镜像时创建一个文件,然后在容器内编辑该文件,Docker 实际上会在容器的可写层中创建一个已更改文件的副本,并隐藏原始的只读文件。因此,容器中有一个已编辑文件的副本,但镜像中的原始文件保持不变。

你可以通过创建一些具有不同层中的数据的简单镜像来看到这一点。用于dockeronwindows/ch02-fs-1:2e镜像的 Dockerfile 使用 Nano Server 作为基础镜像,创建一个目录,并在其中写入一个文件:


# escape=` FROM mcr.microsoft.com/windows/nanoserver:1809 RUN md c:\data & `echo 'from image 1' > c:\data\file1.txt

用于dockeronwindows/ch02-fs-2:2e镜像的 Dockerfile 创建了一个基于该镜像的镜像,并向数据目录添加了第二个文件:


FROM dockeronwindows/ch02-fs-1:2e RUN echo 'from image 2' > c:\data\file2.txt

基础镜像没有任何特殊之处;任何镜像都可以在新镜像的FROM指令中使用。它可以是 Docker Hub 上的官方镜像,来自私有注册表的商业镜像,从头开始构建的本地镜像,或者是在层次结构中多层深的镜像。

我将构建两个镜像,并从dockeronwindows/ch02-fs-2:2e运行一个交互式容器,以便我可以查看C驱动器上的文件。此命令启动一个容器,并为其指定了一个显式名称c1,这样我就可以在不使用随机容器 ID 的情况下使用它:


docker container run -it --name c1 dockeronwindows/ch02-fs-2:2e

Docker 命令中的许多选项都有短格式和长格式。长格式以两个短横线开头,如--interactive。短格式是一个单独的字母,并以单个短横线开头,如-i。短标记可以组合使用,因此-it等同于-i -t,等同于--interactive --tty。运行docker --help来浏览命令及其选项。

Nano Server 是一个精简的操作系统,专为在容器中运行应用程序而构建。它不是 Windows 的完整版本,你不能在虚拟机或物理机上运行 Nano Server,也不能在 Nano Server 容器中运行所有 Windows 应用程序。基础镜像特意设计得很小,甚至连 PowerShell 也没有包含在内,以减少表面积,意味着你需要更少的更新,攻击向量也更少。

你需要重新熟悉旧的 DOS 命令以使用 Nano Server 容器。dir列出容器内的目录内容:


C:\>dir C:\data
 Volume in drive C has no label.
 Volume Serial Number is BC8F-B36C

 Directory of C:\data

02/06/2019  11:00 AM    <DIR>          .
02/06/2019  11:00 AM    <DIR>          ..
02/06/2019  11:00 AM                17 file1.txt
02/06/2019  11:00 AM                17 file2.txt 

这两个文件都在容器中用于在C:\data目录中使用;第一个文件在来自ch02-fs-1:2e镜像的一个层中,而第二个文件在来自ch02-fs-2:2e镜像的一个层中。dir可执行文件来自基础 Nano Server 镜像的另一个层,并且容器以相同的方式看待它们。

我将向现有文件中追加一些文本,并在c1容器中创建一个新文件:


C:\>echo ' * ADDITIONAL * ' >> c:\data\file2.txt

C:\>echo 'New!' > c:\data\file3.txt

C:\>dir C:\data
 Volume in drive C has no label.
 Volume Serial Number is BC8F-B36C

 Directory of C:\data

02/06/2019  01:10 PM    <DIR>          .
02/06/2019  01:10 PM    <DIR>          ..
02/06/2019  11:00 AM                17 file1.txt
02/06/2019  01:10 PM                38 file2.txt
02/06/2019  01:10 PM                 9 file3.txt 

从文件列表中你可以看到来自镜像层的file2.txt已经被修改,并且有一个新文件file3.txt。现在我会退出这个容器,并使用相同的镜像创建一个新容器:


C:\> exit
PS> docker container run -it --name c2 dockeronwindows/ch02-fs-2:2e 


在这个新容器的C:\data目录中,你期望看到什么?让我们来看看:


C:\>dir C:\data
 Volume in drive C has no label.
 Volume Serial Number is BC8F-B36C

 Directory of C:\data

02/06/2019  11:00 AM    <DIR>          .
02/06/2019  11:00 AM    <DIR>          ..
02/06/2019  11:00 AM                17 file1.txt
02/06/2019  11:00 AM                17 file2.txt 

你知道镜像层是只读的,每个容器都有自己的可写层,所以结果应该是有意义的。新容器c2具有来自镜像的原始文件,没有来自第一个容器c1的更改,这些更改存储在c1的可写层中。每个容器的文件系统是隔离的,所以一个容器看不到另一个容器所做的任何更改。

如果你想在容器之间或在容器和主机之间共享数据,你可以使用 Docker 卷。

使用卷在容器之间共享数据

卷是存储单元。它们有一个与容器不同的生命周期,因此它们可以独立创建,然后在一个或多个容器中挂载。你可以使用 Dockerfile 中的VOLUME指令确保容器始终使用卷存储。

你可以用目标目录指定卷,这是容器内的位置,卷在其中显示。当你运行一个在镜像中定义了卷的容器时,卷被映射到主机上的一个物理位置,该位置特定于该容器。从相同镜像运行的更多容器将使它们的卷映射到不同的主机位置。

在 Windows 中,卷目录需要为空。在你的 Dockerfile 中,你不能在一个目录中创建文件,然后将其公开为卷。卷还需要在镜像中存在的磁盘上定义。在 Windows 基础镜像中,只有一个C盘可用,所以卷需要在C盘上创建。

dockeronwindows/ch02-volumes:2e的 Dockerfile 创建了一个具有两个卷的镜像,并且在从镜像运行容器时明确指定了cmd shell 作为ENTRYPOINT


# escape=`

FROM mcr.microsoft.com/windows/nanoserver:1809 VOLUME C:\app\config VOLUME C:\app\logs USER ContainerAdministrator ENTRYPOINT cmd /S /C

请记住,Nano Server 镜像默认使用最低权限用户。这个用户无法访问卷,所以这个 Dockerfile 切换到管理帐户,当你从镜像运行一个容器时,你可以访问卷目录。

当我从该镜像运行一个容器时,Docker 会从三个源创建一个虚拟文件系统。镜像层是只读的,容器的层是可写的,卷可以设置为只读或可写:

因为卷是与容器分开的,所以即使源容器未运行,它们也可以与其他容器共享。我可以从该映像运行一个任务容器,并使用命令在卷中创建新文件:


docker container run --name source dockeronwindows/ch02-volumes:2e "echo 'start' > c:\app\logs\log-1.txt"

Docker 启动容器,该容器写入文件,然后退出。容器及其卷尚未被删除,因此我可以使用--volumes-from选项连接到另一个容器中的卷,并指定我的第一个容器的名称:


docker container run -it --volumes-from source dockeronwindows/ch02-volumes:2e cmd

这是一个交互式容器,当我列出C:\app目录的内容时,我将看到两个目录,logsconfig,它们是第一个容器中的卷:


> ls C:\app

 Directory: C:\app

Mode     LastWriteTime      Length  Name
----     -------------      ------  ----
d----l   6/22/2017 8:11 AM          config
d----l   6/22/2017 8:11 AM          logs 

共享卷具有读写访问权限,因此我可以看到在第一个容器中创建的文件并向其追加:


C:\>type C:\app\logs\log-1.txt
'start'

C:\>echo 'more' >> C:\app\logs\log-1.txt

C:\>type C:\app\logs\log-1.txt
'start'
'more'

这样在容器之间共享数据非常有用;您可以运行一个任务容器,从长时间运行的后台容器中备份数据或日志文件。默认访问权限是卷可写的,但这是需要注意的一点,因为您可能会编辑数据并破坏运行在源容器中的应用程序。

Docker 允许您以只读模式从另一个容器中挂载卷,方法是在--volumes-from选项中将:ro标志添加到容器名称。如果您只想阅读而不做更改,这是访问数据的更安全方式。我将运行一个新的容器,在只读模式下共享与原始容器相同的卷:


> docker container run -it --volumes-from source:ro dockeronwindows/ch02-volumes:2e cmd

C:\>type C:\app\logs\log-1.txt
'start'
'more'

C:\>echo 'more' >> C:\app\logs\log-1.txt
Access is denied.

C:\>echo 'new' >> C:\app\logs\log-2.txt
Access is denied.

在新容器中,我无法创建新文件或向现有日志文件中写入内容,但我可以看到原始容器中的日志文件中的内容,以及第二个容器追加的行。

使用卷在容器和主机之间共享数据

容器卷存储在主机上,因此您可以直接从运行 Docker 的机器访问它们,但它们将位于 Docker 的程序数据目录中的某个嵌套目录中。docker container inspect命令告诉您容器卷的物理位置,以及更多信息,包括容器的 ID、名称以及 Docker 网络中容器的虚拟 IP 地址。

我可以在container inspect命令中使用 JSON 格式化,传递一个查询以提取Mounts字段中的卷信息。此命令将 Docker 输出传递到一个 PowerShell cmdlet 中,以友好的格式显示 JSON:


> docker container inspect --format '{{ json .Mounts }}' source | ConvertFrom-Json

Type        : volume
Name        : 65ab1b420a27bfd79d31d0d325622d0868e6b3f353c74ce3133888fafce972d9
Source      : C:\ProgramData\docker\volumes\65ab1b42...\_data
Destination : c:\app\config
Driver      : local
RW          : TruePropagation :

Type        : volume
Name        : b1451fde3e222adbe7f0f058a461459e243ac15af8770a2f7a4aefa7516e0761
Source      : C:\ProgramData\docker\volumes\b1451fde...\_data
Destination : c:\app\logs
Driver      : local
RW          : True

我已经缩写了输出,但在Source字段中,您可以看到卷数据存储在主机上的完整路径。我可以直接从主机访问容器的文件,使用该源目录。当我在我的 Windows 机器上运行此命令时,我将看到在容器卷内创建的文件:


> ls C:\ProgramData\docker\volumes\b1451fde...\_data
   Directory: C:\ProgramData\docker\volumes\b1451fde3e222adbe7f0f058a461459e243ac15af8770a2f7a4aefa7516e0761\_data

Mode                LastWriteTime         Length Name
----                -------------         ------ ----
-a----       06/02/2019     13:33             19 log-1.txt

通过这种方式访问主机上的文件是可能的,但使用带有卷 ID 的嵌套目录位置非常麻烦。相反,您可以在创建容器时从主机的特定位置挂载卷。

从主机目录挂载卷

你可以使用 --volume 选项将容器中的一个目录映射到主机上的一个已知位置。容器中的目标位置可以是使用 VOLUME 命令创建的目录,或者容器文件系统中的任何目录。如果 Docker 镜像中的目标位置已经存在,它将被卷挂载隐藏,因此你将看不到任何镜像文件。

我将在我的 Windows 机器的 C 盘上的一个目录中创建一个虚拟配置文件:


PS> mkdir C:\ app-config | Out-Null

PS> echo 'VERSION = 18.09' > C:\ app-config \ version.txt

现在我将运行一个容器,该容器从主机映射一个卷,并读取实际存储在主机上的配置文件:


> docker container run `
 --volume C:\app-config:C:\app\config `
 dockeronwindows/ch02-volumes:2e `
 type C:\app\config\version.txt
VERSION=18.09

--volume 选项指定挂载的格式为 {source}:{target}。源是主机位置,需要存在。目标是容器位置,不需要存在,但如果存在,现有内容将被隐藏。

Windows 和 Linux 容器中的卷挂载不同。在 Linux 容器中,Docker 将源的内容合并到目标中,因此如果镜像中存在文件,则你会看到它们以及卷源的内容。Linux 上的 Docker 也允许你挂载单个文件位置,但在 Windows 上,你只能挂载整个目录。

卷挂载对于在容器中运行有状态的应用程序(如数据库)非常有用。你可以在容器中运行 SQL Server,并将数据库文件存储在主机上的某个位置,这可以是服务器上的 RAID 阵列。当你有模式更新时,你删除旧的容器,并从更新的 Docker 镜像启动新的容器。你使用相同的卷挂载为新容器,以便从旧容器中保留数据。

使用卷来进行配置和状态管理

当你在容器中运行应用程序时,应用程序状态是一个重要考虑因素。容器可以长时间运行,但并不打算是永久的。与传统计算模型相比,容器的最大优势之一是你可以轻松替换它们,而且只需几秒钟的时间。当你有一个新功能要部署,或者要修补的安全漏洞时,你只需构建和测试一个升级后的镜像,停止旧的容器,然后从新镜像启动一个替代容器。

通过将数据与应用程序容器分开,卷让你管理升级过程。我将用一个简单的 Web 应用程序演示这一点,该应用程序将页面的点击计数存储在文本文件中;每当你浏览到页面时,网站都会增加计数。

dockeronwindows/ch02-hitcount-website 镜像的 Dockerfile 使用多阶段构建,使用 microsoft/dotnet 镜像编译应用程序,并使用 microsoft/aspnetcore 作为基础打包最终应用程序:


# escape=`
FROM microsoft/dotnet:2.2-sdk-nanoserver-1809 AS builder

WORKDIR C:\src
COPY src .

USER ContainerAdministrator
RUN dotnet restore && dotnet publish

# app image
FROM microsoft/dotnet:2.2-aspnetcore-runtime-nanoserver-1809

EXPOSE 80
WORKDIR C:\dotnetapp
RUN mkdir app-state

CMD ["dotnet", "HitCountWebApp.dll"]
COPY --from=builder C:\src\bin\Debug\netcoreapp2.2\publish .

在 Dockerfile 中,我在 C:\dotnetapp\app-state 创建了一个空目录,这是应用程序将在其中的文本文件中存储点击计数的位置。我已经使用 2e-v1 标签将应用程序的第一个版本构建成一个镜像:


docker image build --tag dockeronwindows / ch02-hitcount-website:2e-v1。

我会在主机上创建一个目录用于容器的状态,并运行一个容器,将应用程序状态目录从主机上的一个目录挂载到容器中:


mkdir C:\app-state

docker container run -d --publish-all `
 -v C:\app-state:C:\dotnetapp\app-state `
 --name appv1 `
 dockeronwindows/ch02-hitcount-website:2e-v1 

publish-all 选项告诉 Docker 将容器镜像的所有暴露端口发布到主机上的随机端口。这是在本地环境中测试容器的快速选项,因为 Docker 将从主机分配一个空闲端口,你不需要担心其他容器已经使用了哪些端口。你可以使用 container port 命令查看容器发布的端口:


> docker container port appv1
80/tcp -> 0.0.0.0:51377

我可以在 http://localhost:51377 上浏览到该站点。当我刷新页面几次时,会看到点击数增加:

现在,当我有一个要部署的升级版本的应用程序时,我可以将其打包成一个带有 2e-v2 标签的新镜像。当镜像准备好时,我可以停止旧容器并启动一个新容器,使用相同的卷映射:


PS> docker container stop appv1
appv1

PS> docker container run -d --publish-all `
 -v C:\app-state:C:\dotnetapp\app-state `
 --name appv2 `
 dockeronwindows/ch02-hitcount-website:2e-v2

db8a39ba7af43be04b02d4ea5d9e646c87902594c26a62168c9f8bf912188b62

包含应用程序状态的卷被重用,因此新版本将继续使用旧版本的保存状态。我有一个新的容器,带有一个新发布的端口。当我获取端口并首次浏览时,我会看到更新的 UI 和一个吸引人的图标,但点击数从版本 1 中继承下来:

应用程序状态在版本之间可能会有结构性变化,这是你需要自行管理的。开源 Git 服务器 GitLab 的 Docker 镜像就是一个很好的例子。状态存储在卷上的数据库中,当你升级到新版本时,应用程序会检查数据库并在需要时运行升级脚本。

应用程序配置是利用卷挂载的另一种方式。你可以在镜像中嵌入默认的配置集合,但用户可以使用挂载来覆盖基本配置,使用他们自己的文件。

你将在下一章中看到这些技术被很好地运用。

将传统的 ASP.NET Web 应用程序打包为 Docker 镜像

微软已经在 MCR 上提供了 Windows Server Core 基础镜像,这是 Windows Server 2019 的一个版本,具有大部分完整服务器版的功能,但没有用户界面。就基础镜像而言,它非常庞大:在 Docker Hub 上压缩后为 2 GB,而 Nano Server 仅为 100 MB,微小的 Alpine Linux 镜像仅为 2 MB。但这意味着你几乎可以将任何现有的 Windows 应用程序 Docker 化,这是迁移系统到 Docker 的一个很好的方式。

还记得 NerdDinner 吗?它是一个开源的 ASP.NET MVC 展示应用程序,最初由微软的 Scott Hanselman 和 Scott Guthrie 等人编写。你仍然可以在 CodePlex 上获取到代码,但自 2013 年以来就没有进行过任何更改,因此它是证明旧的 .NET Framework 应用程序可以迁移到 Docker Windows 容器的理想候选项,这可以是现代化的第一步。

为 NerdDinner 编写 Dockerfile

我将遵循 NerdDinner 的多阶段构建方法,因此dockeronwindows/ch-02-nerd-dinner:2e镜像的 Dockerfile 从一个构建器阶段开始:


# escape=`
FROM microsoft/dotnet-framework:4.7.2-sdk-windowsservercore-ltsc2019 AS builder

WORKDIR C:\src\NerdDinner
COPY src\NerdDinner\packages.config .
RUN nuget restore packages.config -PackagesDirectory ..\packages

COPY src C:\src
RUN msbuild NerdDinner.csproj /p:OutputPath=c:\out /p:Configuration=Release 

该阶段使用microsoft/dotnet-framework作为编译应用程序的基础镜像。这是微软在 Docker Hub 上维护的一个镜像。它是构建在 Windows Server Core 镜像之上的,并且拥有编译 .NET Framework 应用程序所需的一切,包括 NuGet 和 MSBuild。构建阶段分为两个部分:

  1. 将 NuGet packages.config 文件复制到镜像中,然后运行nuget restore

  2. 复制其余的源代码树并运行msbuild

将这些部分分开意味着 Docker 将使用多个镜像层:第一层将包含所有已还原的 NuGet 包,第二层将包含已编译的 Web 应用程序。这意味着我可以利用 Docker 的层缓存。除非我更改了 NuGet 引用,否则包将从缓存层加载,Docker 将不会运行还原部分,这是一个昂贵的操作。只要任何源文件发生更改,MSBuild 步骤就会运行。

如果我有一个 NerdDinner 的部署指南,在迁移到 Docker 之前,它会是这样的:

  1. 在一个干净的服务器上安装 Windows。

  2. 运行所有 Windows 更新。

  3. 安装 IIS。

  4. 安装 .NET。

  5. 设置 ASP.NET。

  6. 将 Web 应用程序复制到C驱动器中。

  7. 在 IIS 中创建一个应用程序池。

  8. 在 IIS 中创建网站并使用应用程序池。

  9. 删除默认网站。

这将成为 Dockerfile 的第二阶段的基础,但我将能够简化所有步骤。我可以使用微软的 ASP.NET Docker 镜像作为FROM镜像,这将为我提供一个带有 IIS 和 ASP.NET 的干净安装的 Windows。这样一条指令就完成了前五个步骤。这是dockeronwindows/ch-02-nerd-dinner:2e的其余 Dockerfile 部分:


FROM mcr.microsoft.com/dotnet/framework/aspnet:4.7.2-windowsservercore-ltsc2019
SHELL ["powershell", "-Command", "$ErrorActionPreference = 'Stop']

ENV BING_MAPS_KEY bing_maps_key
WORKDIR C:\nerd-dinner

RUN Remove-Website -Name 'Default Web Site'; `
    New-Website -Name 'nerd-dinner' `
                -Port 80 -PhysicalPath 'c:\nerd-dinner' `
                -ApplicationPool '.NET v4.5'

RUN & c:\windows\system32\inetsrv\appcmd.exe `
      unlock config /section:system.webServer/handlers

COPY --from=builder C:\out\_PublishedWebsites\NerdDinner C:\nerd-dinner 

微软同时使用 Docker Hub 和 MCR 存储他们的 Docker 镜像。.NET Framework SDK 在 Docker Hub 上,但 ASP.NET 运行时镜像在 MCR 上。您可以通过在 Docker Hub 上检查来找到镜像的托管位置。

使用escape指令和SHELL指令让我可以在普通的 Windows 文件路径中使用,而无需双反斜杠,并使用 PowerShell 风格的反引号在多行中分隔命令。使用 PowerShell 在 IIS 中删除默认网站并创建新网站非常简单,Dockerfile 清楚地显示了应用程序使用的端口和内容的路径。

我正在使用内置的 .NET 4.5 应用程序池,这是与原始部署过程相比的简化。在虚拟机上的 IIS 中,通常会为每个网站拥有一个专用的应用程序池,以便将进程与其他进程隔离。但在容器化的应用程序中,只会运行一个网站。任何其他网站都将在其他容器中运行,因此我们已经进行了隔离,并且每个容器可以使用默认的应用程序池,而不必担心干扰。

最后的 COPY 指令将发布的 Web 应用程序从构建器阶段复制到应用程序镜像中。这是 Dockerfile 中利用 Docker 缓存的最后一行。当我在应用程序上工作时,源代码将是我最频繁更改的东西。Dockerfile 结构化得当,以便当我更改代码并运行 docker image build 时,只会运行第一阶段的 MSBuild 和第二阶段的复制,因此构建非常快速。

这可能是一个完全功能的 Docker 化的 ASP.NET 网站所需的一切,但在 NerdDinner 的情况下,还有一条额外的指令,证明了当您将应用程序容器化时,您可以处理棘手的,意想不到的细节。NerdDinner 应用程序在其 Web.config 文件的 system.webServer 部分中有一些自定义配置设置,并且默认情况下,该部分由 IIS 锁定。我需要解锁该部分,我在第二个 RUN 指令中使用 appcmd 完成。

现在我可以构建图像并在 Windows 容器中运行遗留的 ASP.NET 应用程序:


docker container run -d -P dockeronwindows/ch02-nerd-dinner:2e

我可以使用docker container port来获取容器的发布端口,并浏览到 NerdDinner 的主页:

这是一个六年前的应用程序,在 Docker 容器中运行,没有代码更改。Docker 是一个很好的平台,可以用来构建新的应用程序和现代化旧的应用程序,但它也是一个很好的方式,可以将现有的应用程序从数据中心移到云端,或者将它们从不再支持的旧版本的 Windows 中移出,比如 Windows Server 2003 和(很快)Windows Server 2008。

在这一点上,这个应用程序还没有完全功能,我只是运行了一个基本版本。Bing Maps 对象没有显示真实的地图,因为我还没有提供 API 密钥。API 密钥是每个环境(每个开发人员、测试环境和生产环境)都会改变的东西。

在 Docker 中,你可以使用环境变量和配置对象来管理环境配置,我将在第三章中使用这些内容来进行 Dockerfile 的下一个迭代,开发 Docker 化的.NET Framework 和.NET Core 应用程序

如果你在这个版本的 NerdDinner 中浏览并尝试注册一个新用户或搜索一个晚餐,你会看到一个黄色的崩溃页面告诉你数据库不可用。在其原始形式中,NerdDinner 使用 SQL Server LocalDB 作为轻量级数据库,并将数据库文件存储在应用程序目录中。我可以将 LocalDB 运行时安装到容器映像中,但这与 Docker 的哲学不符,即一个容器只运行一个应用程序。相反,我将为数据库构建一个单独的映像,这样我就可以在它自己的容器中运行它。

在下一章中,我将对 NerdDinner 示例进行迭代,添加配置管理,将 SQL Server 作为一个独立组件在自己的容器中运行,并演示如何通过使用 Docker 平台来开始现代化传统的 ASP.NET 应用程序。

总结

在本章中,我更仔细地看了 Docker 镜像和容器。镜像是应用程序的打包版本,容器是从镜像运行的应用程序的实例。您可以使用容器来执行简单的一次性任务,与它们进行交互,或者让它们在后台运行。随着您对 Docker 的使用越来越多,您会发现自己会做这三种事情。

Dockerfile 是构建镜像的源脚本。它是一个简单的文本文件,包含少量指令来指定基础镜像,复制文件和运行命令。您可以使用 Docker 命令行来构建镜像,这非常容易添加到您的 CI 构建步骤中。当开发人员推送通过所有测试的代码时,构建的输出将是一个有版本的 Docker 镜像,您可以将其部署到任何主机,知道它将始终以相同的方式运行。

在本章中,我看了一些简单的 Dockerfile,并以一个真实的应用程序结束了。NerdDinner 是一个传统的 ASP.NET MVC 应用程序,它是为在 Windows Server 和 IIS 上运行而构建的。使用多阶段构建,我将这个传统的应用程序打包成一个 Docker 镜像,并在容器中运行它。这表明 Docker 提供的新的计算模型不仅适用于使用.NET Core 和 Nano Server 的新项目,您还可以将现有的应用程序迁移到 Docker,并使自己处于一个良好的现代化起步位置。

在下一章中,我将使用 Docker 来现代化 NerdDinner 的架构,将功能分解为单独的组件,并使用 Docker 将它们全部连接在一起。

第三章:开发 Docker 化的.NET Framework 和.NET Core 应用程序

Docker 是一个用于打包、分发、运行和管理应用程序的平台。当您将应用程序打包为 Docker 镜像时,它们都具有相同的形状。您可以以相同的方式部署、管理、保护和升级它们。所有 Docker 化的应用程序在运行时都具有相同的要求:在兼容的操作系统上运行 Docker 引擎。应用程序在隔离的环境中运行,因此您可以在同一台机器上托管不同的应用程序平台和不同的平台版本而不会发生干扰。

在.NET 世界中,这意味着您可以在单个 Windows 机器上运行多个工作负载。它们可以是 ASP.NET 网站,也可以是作为.NET 控制台应用程序或.NET Windows 服务运行的Windows Communication FoundationWCF)应用程序。在上一章中,我们讨论了如何在不进行任何代码更改的情况下将传统的.NET 应用程序 Docker 化,但是 Docker 对容器内运行的应用程序应该如何行为有一些简单的期望,以便它们可以充分利用该平台的全部优势。

在本章中,我们将探讨如何构建应用程序,以便它们可以充分利用 Docker 平台,包括:

  • Docker 与您的应用程序之间的集成点

  • 使用配置文件和环境变量配置您的应用程序

  • 使用健康检查监视应用程序

  • 在不同容器中运行分布式解决方案的组件

这将帮助您开发符合 Docker 期望的.NET 和.NET Core 应用程序,以便您可以完全使用 Docker 进行管理。

我们将在本章中涵盖以下主题:

  • 为 Docker 构建良好的应用程序

  • 分离依赖项

  • 拆分单片应用程序

为 Docker 构建良好的应用程序

Docker 平台对使用它的应用程序几乎没有要求。您不受限于特定的语言或框架,您不需要使用特殊的库来在应用程序和容器之间进行通信,也不需要以特定的方式构建您的应用程序。

为了支持尽可能广泛的应用程序范围,Docker 使用控制台在应用程序和容器运行时之间进行通信。应用程序日志和错误消息预期出现在控制台输出和错误流中。由 Docker 管理的存储被呈现为操作系统的普通磁盘,Docker 的网络堆栈是透明的。应用程序将看起来像是在自己的机器上运行,通过普通的 TCP/IP 网络连接到其他机器。

Docker 中的一个良好应用是一个对其运行的系统几乎没有假设,并且使用所有操作系统支持的基本机制:文件系统、环境变量、网络和控制台。最重要的是,应用程序应该只做一件事。正如你所看到的,当 Docker 运行一个容器时,它启动 Dockerfile 或命令行中指定的进程,并监视该进程。当进程结束时,容器退出。因此,理想情况下,你应该构建你的应用程序只有一个进程,这样可以确保 Docker 监视重要的进程。

这些只是建议,而不是要求。当容器启动时,你可以在引导脚本中启动多个进程,Docker 会愉快地运行它,但它只会监视最后启动的进程。你的应用程序可以将日志条目写入本地文件,而不是控制台,Docker 仍然会运行它们,但如果你使用 Docker 来检查容器日志,你将看不到任何输出。

在.NET 中,你可以通过运行控制台应用程序轻松满足建议,这提供了应用程序和主机之间的简化集成,这也是为什么所有.NET Core 应用程序(包括网站和 Web API)都作为控制台应用程序运行的一个原因。对于传统的.NET 应用程序,你可能无法使它们成为完美的应用程序,但你可以注意打包它们,以便它们充分利用 Docker 平台。

在 Docker 中托管 Internet 信息服务(IIS)应用程序

完整的.NET Framework 应用程序可以轻松打包成 Docker 镜像,但你需要注意一些限制。微软为 Docker 提供了 Nano Server 和 Windows Server Core 基础镜像。完整的.NET Framework 无法在 Nano Server 上运行,因此要在 Docker 中托管现有的.NET 应用程序,你需要使用 Windows Server Core 基础镜像。

从 Windows Server Core 运行意味着您的应用程序镜像大小约为 4 GB,其中大部分在基础镜像中。您拥有完整的 Windows Server 操作系统,所有软件包都可用于启用 Windows Server 功能,如域名系统(DNS)和动态主机配置协议(DHCP),即使您只想将其用于单个应用程序角色。从 Windows Server Core 运行容器是完全合理的,但您需要了解其影响:

  • 基础镜像具有大量安装的软件,这意味着它可能会有更频繁的安全和功能补丁。

  • 操作系统除了您的应用程序进程外,还运行了许多自己的进程,因为 Windows 的几个核心部分作为后台 Windows 服务运行。

  • Windows 拥有自己的应用程序平台,具有高价值的特性集,用于托管和管理,这些特性与 Docker 方法不会自然集成。

您可以将 ASP.NET Web 应用程序 Docker 化几个小时。它将构建为一个大型 Docker 镜像,比基于轻量级现代应用程序堆栈构建的应用程序需要更长的时间来分发和启动。但您仍将拥有一个部署、配置和准备运行的整个应用程序的单一软件包。这是提高质量和减少部署时间的重要一步,也可以是现代化传统应用程序计划的第一部分。

将 ASP.NET 应用程序与 Docker 更紧密地集成,可以修改 IIS 日志的编写方式,指定 Docker 如何检查容器是否健康,并向容器注入配置,而无需对应用程序代码进行任何更改。如果更改代码是现代化计划的一部分,那么只需进行最小的更改,就可以使用容器的环境变量和文件系统进行应用程序配置。

为 Docker 友好的日志记录配置 IIS

IIS 将日志条目写入文本文件,记录 HTTP 请求和响应。您可以精确配置要写入的字段,但默认安装记录了诸如 HTTP 请求的路由、响应状态代码和 IIS 响应所需的时间等有用信息。将这些日志条目呈现给 Docker 是很好的,但 IIS 管理自己的日志文件,将条目缓冲到磁盘之前,并旋转日志文件以管理磁盘空间。

日志管理是应用程序平台的基本组成部分,这就是为什么 IIS 为 Web 应用程序负责,但 Docker 有自己的日志记录系统。Docker 日志记录比 IIS 使用的文本文件系统更强大和可插拔,但它只从容器的控制台输出流中读取日志条目。您不能让 IIS 将日志写入控制台,因为它在后台作为 Windows 服务运行,没有连接到控制台,所以您需要另一种方法。

有两种选择。第一种是构建一个 HTTP 模块,它插入到 IIS 平台中,具有一个事件处理程序,从 IIS 接收日志。此处理程序可以将所有消息发布到队列或 Windows 管道,因此您不会改变 IIS 日志的方式;您只是添加了另一个日志接收端。然后,您会将您的 Web 应用程序与一个监听已发布的日志条目并将其中继到控制台的控制台应用程序打包在一起。控制台应用程序将是容器启动时的入口点,因此每个 IIS 日志条目都会被路由到控制台供 Docker 读取。

HTTP 模块方法是强大且可扩展的,但在我们刚开始时,它增加了比我们需要的更多复杂性。第二个选项更简单 - 配置 IIS 将所有日志条目写入单个文本文件,并在容器的启动命令中运行一个 PowerShell 脚本来监视该文件,并将新的日志条目回显到控制台。当容器运行时,所有 IIS 日志条目都会回显到控制台,从而将它们呈现给 Docker。

在 Docker 镜像中设置这一点,首先需要配置 IIS,使其将任何站点的所有日志条目写入单个文件,并允许文件增长而不进行旋转。您可以在 Dockerfile 中使用 PowerShell 来完成这一点,使用Set-WebConfigurationProperty cmdlet 来修改应用程序主机级别的中央日志属性。我在dockeronwindows/ch03-iis-log-watcher镜像的 Dockerfile 中使用了这个 cmdlet:

RUN Set-WebConfigurationProperty -p 'MACHINE/WEBROOT/APPHOST' -fi 'system.applicationHost/log' -n 'centralLogFileMode' -v 'CentralW3C'; `
    Set-WebConfigurationProperty -p 'MACHINE/WEBROOT/APPHOST' -fi 'system.applicationHost/log/centralW3CLogFile' -n 'truncateSize' -v 4294967295; `
    Set-WebConfigurationProperty -p 'MACHINE/WEBROOT/APPHOST' -fi 'system.applicationHost/log/centralW3CLogFile' -n 'period' -v 'MaxSize'; `
    Set-WebConfigurationProperty -p 'MACHINE/WEBROOT/APPHOST' -fi 'system.applicationHost/log/centralW3CLogFile' -n 'directory' -v 'C:\iislog'

这是丑陋的代码,但它表明你可以在 Dockerfile 中编写任何你需要设置应用程序的内容。它配置 IIS 将所有条目记录到C:\iislog中的文件,并设置日志轮换的最大文件大小,让日志文件增长到 4GB。这足够的空间来使用 - 记住,容器不应该长时间存在,所以我们不应该在单个容器中有几 GB 的日志条目。IIS 仍然使用子目录格式来记录日志文件,所以实际的日志文件路径将是C:\iislog\W3SVC\u_extend1.log。现在我有了一个已知的日志文件位置,我可以使用 PowerShell 来回显日志条目到控制台。

我在CMD指令中执行这个操作,所以 Docker 运行和监控的最终命令是 PowerShell 的 cmdlet 来回显日志条目。当新条目被写入控制台时,它们会被 Docker 捕捉到。PowerShell 可以很容易地监视文件,但是有一个复杂的地方,因为文件需要在 PowerShell 监视之前存在。在 Dockerfile 中,我在启动时按顺序运行多个命令:

 CMD Start-Service W3SVC; `
     Invoke-WebRequest http://localhost -UseBasicParsing | Out-Null; `
     netsh http flush logbuffer | Out-Null; `
     Get-Content -path 'c:\iislog\W3SVC\u_extend1.log' -Tail 1 -Wait

容器启动时会发生四件事情:

  1. 启动 IIS Windows 服务(W3SVC)。

  2. 发出 HTTP GET请求到本地主机,启动 IIS 工作进程并写入第一个日志条目。

  3. 刷新 HTTP 日志缓冲区,这样日志文件就会被写入磁盘并存在于 PowerShell 监视之中。

  4. 以尾部模式读取日志文件的内容,这样文件中写入的任何新行都会显示在控制台上。

我可以以通常的方式从这个镜像中运行一个容器:

 docker container run -d -P --name log-watcher dockeronwindows/ch03-iis-log-watcher:2e

当我通过浏览到容器的 IP 地址(或在 PowerShell 中使用Invoke-WebRequest)发送一些流量到站点时,我可以看到从Get-Content cmdlet 使用docker container logs中中继到 Docker 的 IIS 日志条目:

> docker container logs log-watcher
2019-02-06 20:21:30 W3SVC1 172.27.97.43 GET / - 80 - 192.168.2.214 Mozilla/5.0+(Windows+NT+10.0;+Win64;+x64;+rv:64.0)+Gecko/20100101+Firefox/64.0 - 200 0 0 7
2019-02-06 20:21:30 W3SVC1 172.27.97.43 GET /iisstart.png - 80 - 192.168.2.214 Mozilla/5.0+(Windows+NT+10.0;+Win64;+x64;+rv:64.0)+Gecko/20100101+Firefox/64.0 http://localhost:51959/ 200 0 0 17
2019-02-06 20:21:30 W3SVC1 172.27.97.43 GET /favicon.ico - 80 - 192.168.2.214 Mozilla/5.0+(Windows+NT+10.0;+Win64;+x64;+rv:64.0)+Gecko/20100101+Firefox/64.0 - 404 0 2 23

IIS 始终在将日志条目写入磁盘之前在内存中缓冲日志条目,以提高性能进行微批量写入。刷新每 60 秒进行一次,或者当缓冲区大小为 64KB 时。如果你想强制容器中的 IIS 日志刷新,可以使用与我在 Dockerfile 中使用的相同的netsh命令:docker container exec log-watcher netsh http flush logbuffer。你会看到一个Ok输出,并且新的条目将在docker container logs中。

我已将配置添加到映像中的 IIS 和一个新命令,这意味着所有 IIS 日志条目都会被回显到控制台。这将适用于托管在 IIS 中的任何应用程序,因此我可以在不更改应用程序或站点内容的情况下回显 ASP.NET 应用程序和静态网站的 HTTP 日志。控制台输出是 Docker 查找日志条目的地方,因此这个简单的扩展将现有应用程序的日志集成到新平台中。

管理应用程序配置

在 Docker 映像中打包应用程序的目标是在每个环境中使用相同的映像。您不会为测试和生产构建单独的映像,因为这将使它们成为单独的应用程序,并且可能存在不一致性。您应该从用户测试的完全相同的 Docker 映像部署生产应用程序,这是生成过程生成的完全相同的映像,并用于所有自动集成测试的映像。

当然,一些东西需要在环境之间进行更改 - 数据库的连接字符串,日志级别和功能开关。这是应用程序配置,在 Docker 世界中,您使用默认配置构建应用程序映像,通常用于开发环境。在运行时,您将当前环境的正确配置注入到容器中,并覆盖默认配置。

有不同的方法来注入此配置。在本章中,我将向您展示如何使用卷挂载和环境变量。在生产中,您将运行运行 Docker 的机器集群,并且可以将配置数据存储在集群的安全数据库中,作为 Docker 配置对象或 Docker 秘密。我将在第七章中介绍这一点,使用 Docker Swarm 编排分布式解决方案

在 Docker 卷中挂载配置文件

传统的应用程序平台使用配置文件在环境之间更改行为。 .NET Framework 应用程序具有丰富的基于 XML 的配置框架,而 Java 应用程序通常在属性文件中使用键值对。您可以在 Dockerfile 中向应用程序映像添加这些配置文件,并且当您从映像运行容器时,它将使用此默认配置。

您的应用程序设置应该使用一个特定的目录来存储配置文件,这样可以通过挂载 Docker 卷在运行时覆盖它们。我已经在dockeronwindows/ch03-aspnet-config:2e中使用了一个简单的 ASP.NET WebForms 应用程序。Dockerfile 只使用了您已经看到的命令:

# escape=` FROM mcr.microsoft.com/dotnet/framework/aspnet COPY Web.config C:\inetpub\wwwroot COPY config\*.config C:\inetpub\wwwroot\config\ COPY default.aspx C:\inetpub\wwwroot

这使用了微软的 ASP.NET 镜像作为基础,并复制了我的应用程序文件 - 一个 ASPX 页面和一些配置文件。在这个例子中,我正在使用默认的 IIS 网站,它从C:\inetpub\wwwroot加载内容,所以我只需要在 Dockerfile 中使用COPY指令,而不需要运行任何 PowerShell 脚本。

ASP.NET 期望在应用程序目录中找到Web.config文件,但您可以将配置的部分拆分成单独的文件。我已经在一个子目录中的文件中做到了这一点,这些文件是从appSettingsconnectionStrings部分加载的:

<?xml version="1.0" encoding="utf-8"?> <configuration>
  <appSettings  configSource="config\appSettings.config"  />
  <connectionStrings  configSource="config\connectionStrings.config"  /> </configuration>

config目录填充了默认配置文件,所以我可以从镜像中运行容器,而不需要指定任何额外的设置:

docker container run -d -P dockeronwindows/ch03-aspnet-config:2e

当我获取容器的端口并浏览到它时,我看到网页显示来自默认配置文件的值:

我可以通过从主机上的目录加载配置文件,将本地目录挂载为一个卷,以C:\inetpub\wwwroot\config为目标,来为不同的环境运行应用程序。当容器运行时,该目录的内容将从主机上的目录加载:

docker container run -d -P `
 -v $pwd\prod-config:C:\inetpub\wwwroot\config `
 dockeronwindows/ch03-aspnet-config:2e

我正在使用 PowerShell 来运行这个命令,它会将$pwd扩展到当前目录的完整值,所以我在说当前路径中的prod-config目录应该被挂载为容器中的C:\inetpub\wwwroot\config。您也可以使用完全限定的路径。

当我浏览到这个容器的端口时,我看到不同的配置值显示出来:

这里重要的是,我在每个环境中使用完全相同的 Docker 镜像,具有相同的设置和相同的二进制文件。只有配置文件会改变,Docker 提供了一种优雅的方法来做到这一点。

推广环境变量

现代应用程序越来越多地使用环境变量作为配置设置,因为它们几乎被每个平台支持,从物理机器到 PaaS,再到无服务器函数。所有平台都以相同的方式使用环境变量 - 作为键值对的存储,因此通过使用环境变量进行配置,可以使您的应用程序具有高度的可移植性。

ASP.NET 应用程序已经在Web.config中具有丰富的配置框架,但通过一些小的代码更改,您可以将关键设置移动到环境变量中。这样,您可以为应用程序构建一个 Docker 镜像,在不同的平台上运行,并在容器中设置环境变量以更改配置。

Docker 允许您在 Dockerfile 中指定环境变量并给出初始默认值。ENV指令设置环境变量,您可以在每个ENV指令中设置一个或多个变量。以下示例来自于dockeronwindows/ch03-iis-environment-variables:2e的 Dockerfile。

 ENV A01_KEY A01 value
 ENV A02_KEY="A02 value" `
     A03_KEY="A03 value"

使用ENV在 Dockerfile 中添加的设置将成为镜像的一部分,因此您从该镜像运行的每个容器都将具有这些值。运行容器时,您可以使用--env-e选项添加新的环境变量或替换现有镜像变量的值。您可以通过一个简单的 Nano Server 容器看到环境变量是如何工作的。

> docker container run `
  --env ENV_01='Hello' --env ENV_02='World' `
  mcr.microsoft.com/windows/nanoserver:1809 `
  cmd /s /c echo %ENV_01% %ENV_02%

Hello World

在 IIS 中托管的应用程序使用 Docker 中的环境变量存在一个复杂性。当 IIS 启动时,它会从系统中读取所有环境变量并对其进行缓存。当 Docker 运行具有设置的环境变量的容器时,它会将它们写入进程级别,但这发生在 IIS 缓存了原始值之后,因此它们不会被更新,IIS 应用程序将无法看到新值。然而,IIS 并不以相同的方式缓存机器级别的环境变量,因此我们可以将 Docker 设置的值提升为机器级别的环境变量,这样 IIS 应用程序就能够读取它们。

推广环境变量可以通过将它们从进程级别复制到机器级别来实现。您可以在容器启动命令中使用 PowerShell 脚本,通过循环遍历所有进程级别变量并将它们复制到机器级别,除非机器级别键已经存在。

 foreach($key in [System.Environment]::GetEnvironmentVariables('Process').Keys) {
     if ([System.Environment]::GetEnvironmentVariable($key, 'Machine') -eq $null) {
         $value = [System.Environment]::GetEnvironmentVariable($key, 'Process')
         [System.Environment]::SetEnvironmentVariable($key, $value, 'Machine')
     }
 }

如果您使用的是基于 Microsoft 的 IIS 镜像的图像,则无需执行此操作,因为它会为您使用一个名为ServiceMonitor.exe的实用程序,该实用程序已打包在 IIS 镜像中。ServiceMonitor 执行三件事——它使进程级环境变量可用,启动后台 Windows 服务,然后监视服务以确保其保持运行。这意味着您可以使用 ServiceMonitor 作为容器的启动进程,如果 IIS Windows 服务失败,ServiceMonitor 将退出,Docker 将看到您的应用程序已停止。

ServiceMonitor.exe可以在 GitHub 上作为二进制文件使用,但它不是开源的,并且并非所有行为都有文档记录(它似乎只适用于默认的 IIS 应用程序池)。它被复制到 Microsoft 的 IIS 镜像中,并设置为容器的ENTRYPOINT。ASP.NET 镜像是基于 IIS 镜像构建的,因此它也配置了 ServiceMonitor。

如果您想要在自己的逻辑中使用 ServiceMonitor 来回显 IIS 日志,您需要在后台启动 ServiceMonitor,并在 Dockerfile 中的启动命令中完成日志读取。我在dockeronwindows/ch03-iis-environment-variables:2e中使用 PowerShell 的Start-Process命令运行 ServiceMonitor:

ENTRYPOINT ["powershell"] CMD Start-Process -NoNewWindow -FilePath C:\ServiceMonitor.exe -ArgumentList w3svc; ` Invoke-WebRequest http://localhost -UseBasicParsing | Out-Null; `
    netsh http flush logbuffer | Out-Null; `
   Get-Content -path 'C:\iislog\W3SVC\u_extend1.log' -Tail 1 -Wait 

ENTRYPOINTCMD指令都告诉 Docker 如何运行您的应用程序。您可以将它们组合在一起,以指定默认的入口点,并允许您的镜像用户在启动容器时覆盖命令。

图像中的应用程序是一个简单的 ASP.NET Web Forms 页面,列出了环境变量。我可以以通常的方式在容器中运行这个应用程序:

docker container run -d -P --name iis-env dockeronwindows/ch03-iis-environment-variables:2e

$port = $(docker container port iis-env).Split(':')[1]
start "http://localhost:$port"

网站显示了来自 Docker 镜像的默认环境变量值,这些值被列为进程级变量:

当容器启动时,我可以获取容器的端口,并在 ASP.NET Web Forms 页面上打开浏览器,使用一些简单的 PowerShell 脚本:

您可以使用不同的环境变量运行相同的镜像,覆盖其中一个镜像变量并添加一个新变量:

docker container run -d -P --name iis-env2 ` 
 -e A01_KEY='NEW VALUE!' ` 
 -e B01_KEY='NEW KEY!' `
 dockeronwindows/ch03-iis-environment-variables:2e

浏览新容器的端口,您将看到 ASP.NET 页面写出的新值:

我现在已经将对 Docker 环境变量管理的支持添加到了 IIS 镜像中,因此 ASP.NET 应用程序可以使用System.Environment类来读取配置设置。我在这个新镜像中保留了 IIS 日志回显,因此这是一个良好的 Docker 公民,现在您可以通过 Docker 配置应用程序并检查日志。

我可以做的最后一个改进是告诉 Docker 如何监视容器内运行的应用程序,以便 Docker 可以确定应用程序是否健康,并在其变得不健康时采取行动。

构建监视应用程序的 Docker 镜像

当我将这些新功能添加到 NerdDinner Dockerfile 并从镜像运行容器时,我将能够使用docker container logs命令查看 Web 请求和响应日志,该命令中继了 Docker 捕获的所有 IIS 日志条目,并且我可以使用环境变量和配置文件来指定 API 密钥和数据库用户凭据。这使得运行和管理传统的 ASP.NET 应用程序与我在 Docker 上运行的任何其他容器化应用程序的方式一致。我还可以配置 Docker 来监视容器,以便我可以管理任何意外故障。

Docker 提供了监视应用程序健康状况的能力,而不仅仅是检查应用程序进程是否仍在运行,使用 Dockerfile 中的HEALTHCHECK指令。使用HEALTHCHECK告诉 Docker 如何测试应用程序是否仍然健康。语法类似于RUNCMD指令。您传递一个要执行的 shell 命令,如果应用程序健康,则应该返回0,如果不健康,则返回1。Docker 在容器运行时定期运行健康检查,并在容器的健康状况发生变化时发出状态事件。

Web 应用程序的健康的简单定义是能够正常响应 HTTP 请求。您进行的请求取决于您希望检查的彻底程度。理想情况下,请求应该执行应用程序的关键部分,以便您确信它全部正常工作。但同样,请求应该快速完成并且对计算影响最小,因此处理大量的健康检查不会影响消费者请求。

对于任何 Web 应用程序的简单健康检查只需使用Invoke-WebRequest PowerShell 命令来获取主页并检查 HTTP 响应代码是否为200,这意味着成功接收到响应:

try { 
    $response = iwr http://localhost/ -UseBasicParsing
    if ($response.StatusCode -eq 200) { 
        return 0
    } else {
        return 1
    } 
catch { return 1 }

对于更复杂的 Web 应用程序,添加一个专门用于健康检查的新端点可能很有用。您可以向 API 和网站添加一个诊断端点,该端点执行应用程序的一些核心逻辑并返回一个布尔结果,指示应用程序是否健康。您可以在 Docker 健康检查中调用此端点,并检查响应内容以及状态码,以便更有信心地确认应用程序是否正常工作。

Dockerfile 中的HEALTHCHECK指令非常简单。您可以配置检查之间的间隔和容器被视为不健康之前可以失败的检查次数,但是要使用默认值,只需在HEALTHCHECK CMD中指定测试脚本。以下是来自dockeronwindows/ch03-iis-healthcheck:2e镜像的 Dockerfile 的示例,它使用 PowerShell 向诊断 URL 发出GET请求并检查响应状态码:

HEALTHCHECK --interval=5s `
 CMD powershell -command `
    try { `
     $response = iwr http://localhost/diagnostics -UseBasicParsing; `
     if ($response.StatusCode -eq 200) { return 0} `
     else {return 1}; `
    } catch { return 1 }

我已经为健康检查指定了一个间隔,因此 Docker 将每 5 秒在容器内执行此命令(如果您不指定间隔,则默认间隔为 30 秒)。健康检查非常便宜,因为它是本地容器的,所以您可以设置这样的短间隔,并快速捕捉任何问题。

此 Docker 镜像中的应用程序是一个 ASP.NET Web API 应用程序,其中有一个诊断端点和一个控制器,您可以使用该控制器来切换应用程序的健康状态。Dockerfile 包含一个健康检查,当您从该镜像运行容器时,您可以看到 Docker 如何使用它:

docker container run -d -P --name healthcheck dockeronwindows/ch03-iis-healthcheck:2e

如果您在启动该容器后运行docker container ls,您会看到状态字段中稍有不同的输出,类似于Up 3 seconds (health: starting)。Docker 每 5 秒运行一次此容器的健康检查,所以在这一点上,检查尚未运行。稍等一会儿,然后状态将变为类似于Up 46 seconds (healthy)

您可以通过查询“诊断”端点来检查 API 的当前健康状况:

$port = $(docker container port healthcheck).Split(':')[1]
iwr "http://localhost:$port/diagnostics"

在返回的内容中,您会看到"Status":"GREEN",这意味着 API 是健康的。直到我调用控制器来切换健康状态之前,这个容器将保持健康。我可以通过一个POST请求来做到这一点,该请求将 API 设置为对所有后续请求返回 HTTP 状态500

iwr "http://localhost:$port/toggle/unhealthy" -Method Post

现在,应用程序将对 Docker 平台发出的所有GET请求响应 500,这将导致健康检查失败。Docker 会继续尝试健康检查,如果连续三次失败,则认为容器不健康。此时,容器列表中的状态字段显示Up 3 minutes (unhealthy)。Docker 不会对不健康的单个容器采取自动操作,因此此容器仍在运行,您仍然可以访问 API。

在集群化的 Docker 环境中运行容器时,健康检查非常重要(我在第七章中介绍了使用 Docker Swarm 编排分布式解决方案),并且在所有 Dockerfile 中包含它们是一个良好的实践。能够打包一个平台可以测试健康状况的应用程序是一个非常有用的功能 - 这意味着无论在哪里运行应用程序,Docker 都可以对其进行检查。

现在,您拥有了所有工具,可以将 ASP.NET 应用程序容器化,并使其成为 Docker 的良好组成部分,与平台集成,以便可以像其他容器一样进行监视和管理。在 Windows Server Core 上运行的完整.NET Framework 应用程序无法满足运行单个进程的期望,因为所有必要的后台 Windows 服务,但您仍应构建容器映像,以便它们仅运行一个逻辑功能并分离任何依赖项。

分离依赖项

在上一章中,我将传统的 NerdDinner 应用程序 Docker 化并使其运行起来,但没有数据库。原始应用程序期望在与应用程序运行的同一主机上使用 SQL Server LocalDB。LocalDB 是基于 MSI 的安装,我可以通过下载 MSI 并在 Dockerfile 中使用RUN命令安装它来将其添加到 Docker 镜像中。但这意味着当我从镜像启动容器时,它具有两个功能:托管 Web 应用程序和运行数据库。

在一个容器中具有两个功能并不是一个好主意。如果您想要升级网站而不更改数据库会发生什么?或者如果您需要对数据库进行一些维护,而这不会影响网站会发生什么?如果您需要扩展网站呢?通过将这两个功能耦合在一起,您增加了部署风险、测试工作量和管理复杂性,并减少了操作灵活性。

相反,我将把数据库打包到一个新的 Docker 镜像中,在一个单独的容器中运行它,并使用 Docker 的网络层从网站容器访问数据库容器。SQL Server 是一个有许可的产品,但免费的变体是 SQL Server Express,它可以从 Docker Hub 上的微软镜像中获得,并带有生产许可证。我可以将其用作我的镜像的基础,构建它以准备一个预配置的数据库实例,其中架构已部署并准备连接到 Web 应用程序。

为 SQL Server 数据库创建 Docker 镜像

设置数据库镜像就像设置任何其他 Docker 镜像一样。我将把设置任务封装在一个 Dockerfile 中。总的来说,对于一个新的数据库,步骤将是:

  1. 安装 SQL Server

  2. 配置 SQL Server

  3. 运行 DDL 脚本来创建数据库架构

  4. 运行 DML 脚本来填充静态数据

这非常适合使用 Visual Studio 的 SQL 数据库项目类型和 Dacpac 部署模型的典型构建过程。从发布项目的输出是一个包含数据库架构和任何自定义 SQL 脚本的.dacpac文件。使用SqlPackage工具,您可以将 Dacpac 文件部署到 SQL Server 实例,它将创建一个新的数据库(如果不存在),或者升级现有的数据库,使架构与 Dacpac 匹配。

这种方法非常适合自定义 SQL Server Docker 镜像。我可以再次使用多阶段构建来为 Dockerfile 构建,这样其他用户就不需要安装 Visual Studio 来从源代码打包数据库。这是dockeronwindows/ch03-nerd-dinner-db:2e镜像的 Dockerfile 的第一阶段:

# escape=` FROM microsoft/dotnet-framework:4.7.2-sdk-windowsservercore-ltsc2019 AS builder SHELL ["powershell", "-Command", "$ErrorActionPreference = 'Stop';"] # add SSDT build tools RUN nuget install Microsoft.Data.Tools.Msbuild -Version 10.0.61804.210 # add SqlPackage tool ENV download_url="https://download.microsoft.com/download/6/E/4/6E406.../EN/x64/DacFramework.msi" RUN Invoke-WebRequest -Uri $env:download_url -OutFile DacFramework.msi ; `Start-Process msiexec.exe -ArgumentList '/i', 'DacFramework.msi', '/quiet', '/norestart' -NoNewWindow -Wait; `Remove-Item -Force DacFramework.msi WORKDIR C:\src\NerdDinner.Database COPY src\NerdDinner.Database . RUN msbuild NerdDinner.Database.sqlproj ` /p:SQLDBExtensionsRefPath="C:\Microsoft.Data.Tools.Msbuild.10.0.61804.210\lib\net46" ` /p:SqlServerRedistPath="C:\Microsoft.Data.Tools.Msbuild.10.0.61804.210\lib\net46" 

这里有很多内容,但都很简单。builder阶段从微软的.NET Framework SDK 镜像开始。这给了我NuGetMSBuild,但没有我构建 SQL Server Dacpac 所需的依赖项。前两个RUN指令安装了 SQL Server 数据工具和SqlPackage工具。如果我有很多数据库项目要容器化,我可以将其打包为一个单独的 SQL Server SDK 镜像。

阶段的其余部分只是复制 SQL 项目源代码并运行MSBuild来生成 Dacpac。

这是 Dockerfile 的第二阶段,它打包了 NerdDinner Dacpac 以在 SQL Server Express 中运行:

FROM dockeronwindows/ch03-sql-server:2e ENV DATA_PATH="C:\data" ` sa_password="N3rdD!Nne720⁶" VOLUME ${DATA_PATH} WORKDIR C:\init COPY Initialize-Database.ps1 . CMD powershell ./Initialize-Database.ps1 -sa_password $env:sa_password -data_path $env:data_path -Verbose COPY --from=builder ["C:\\Program Files...\\DAC", "C:\\Program Files...\\DAC"] COPY --from=builder C:\docker\NerdDinner.Database.dacpac . 

我正在使用我自己的 Docker 镜像,其中安装了 SQL Server Express 2017。微软在 Docker Hub 上发布了用于 Windows 和 Linux 的 SQL Server 镜像,但 Windows 版本并没有定期维护。SQL Server Express 是免费分发的,所以你可以将其打包到自己的 Docker 镜像中(dockeronwindows/ch03-sql-server的 Dockerfile 在 GitHub 的sixeyed/docker-on-windows存储库中)。

除了您迄今为止看到的内容之外,这里没有新的说明。为 SQL Server 数据文件设置了一个卷,并设置了一个环境变量来将默认数据文件路径设置为C:\data。您会看到没有RUN命令,所以当我构建镜像时,我实际上并没有设置数据库架构;我只是将 Dacpac 文件打包到镜像中,这样我就有了创建或升级数据库所需的一切。

CMD指令中,我运行一个设置数据库的 PowerShell 脚本。有时将所有启动细节隐藏在一个单独的脚本中并不是一个好主意,因为这意味着仅凭 Dockerfile 就无法看到容器运行时会发生什么。但在这种情况下,启动过程有很多功能,如果我们把它们都放在那里,Dockerfile 会变得非常庞大。

基本的 SQL Server Express 镜像定义了一个名为sa_password的环境变量来设置管理员密码。我扩展了这个镜像并为该变量设置了默认值。我将以相同的方式使用该变量,以便允许用户在运行容器时指定管理员密码。启动脚本的其余部分处理了在 Docker 卷中存储数据库状态的问题。

管理 SQL Server 容器的数据库文件

数据库容器与任何其他 Docker 容器一样,但侧重于状态。您需要确保数据库文件存储在容器之外,这样您就可以替换数据库容器而不会丢失任何数据。您可以像我们在上一章中看到的那样轻松地使用卷来实现这一点,但有一个问题。

如果您构建了一个带有部署的数据库架构的自定义 SQL Server 镜像,那么您的数据库文件将位于已知位置的镜像中。您可以从该镜像运行一个容器,而无需挂载卷,它将正常工作,但数据将存储在容器的可写层中。如果您在需要执行数据库升级时替换容器,那么您将丢失所有数据。

相反,您可以使用从主机挂载的卷来运行容器,将预期的 SQL Server 数据目录从主机目录映射到一个已知位置的主机上,这样,您的文件就可以存放在容器之外的主机上。这样,您可以确保您的数据文件存储在可靠的地方,比如在服务器上的 RAID 阵列中。但这意味着您不能在 Dockerfile 中部署数据库,因为数据目录将在镜像中存储数据文件,如果您在目录上挂载卷,这些文件将被隐藏。

微软的 SQL Server 镜像通过在运行时附加数据库和日志文件来处理这个问题,因此它的工作原理是您已经在主机上拥有数据库文件。在这种情况下,您可以直接使用该镜像,挂载您的数据文件夹,并使用参数运行 SQL Server 容器,告诉它要附加哪个数据库。这是一个非常有限的方法 - 这意味着您需要首先在不同的 SQL Server 实例上创建数据库,然后在运行容器时附加它。这与自动化发布流程不符。

对于我的自定义镜像,我想做一些不同的事情。镜像包含了 Dacpac,因此它具有部署数据库所需的一切。当容器启动时,我希望它检查数据目录,如果它是空的,那么我通过部署 Dacpac 模型来创建一个新的数据库。如果在容器启动时数据库文件已经存在,则首先附加数据库文件,然后使用 Dacpac 模型升级数据库。

这种方法意味着您可以使用相同的镜像在新环境中运行一个新的数据库容器,或者升级现有的数据库容器而不丢失任何数据。无论您是否从主机挂载数据库目录,这都能很好地工作,因此您可以让用户选择如何管理容器存储,因此该镜像支持许多不同的场景。

执行此操作的逻辑都在Initialize-Database.ps1 PowerShell 脚本中,Dockerfile 将其设置为容器的入口点。在 Dockerfile 中,我将数据目录传递给 PowerShell 脚本中的data_path变量,并且脚本检查该目录中是否存在 NerdDinner 数据(mdf)和日志(ldf)文件:

$mdfPath  =  "$data_path\NerdDinner_Primary.mdf" $ldfPath  =  "$data_path\NerdDinner_Primary.ldf" # attach data files if they exist: if  ((Test-Path  $mdfPath)  -eq  $true) {  $sqlcmd  =  "IF DB_ID('NerdDinner') IS NULL BEGIN CREATE DATABASE NerdDinner ON (FILENAME = N'$mdfPath')"    if  ((Test-Path  $ldfPath)  -eq  $true) {   $sqlcmd  =  "$sqlcmd, (FILENAME = N'$ldfPath')"
 }  $sqlcmd  =  "$sqlcmd FOR ATTACH; END"  Invoke-Sqlcmd  -Query $sqlcmd  -ServerInstance ".\SQLEXPRESS" }

这个脚本看起来很复杂,但实际上,它只是构建了一个CREATE DATABASE...FOR ATTACH语句,如果 MDF 数据文件和 LDF 日志文件存在,则填写路径。然后它调用 SQL 语句,将外部卷中的数据库文件作为 SQL Server 容器中的新数据库附加。

这涵盖了用户使用卷挂载运行容器的情况,主机目录已经包含来自先前容器的数据文件。这些文件被附加,数据库在新容器中可用。接下来,脚本使用SqlPackage工具从 Dacpac 生成部署脚本。我知道SqlPackage工具存在,也知道它的路径,因为它是从构建阶段打包到我的镜像中的:

$SqlPackagePath  =  'C:\Program Files\Microsoft SQL Server\140\DAC\bin\SqlPackage.exe' &  $SqlPackagePath  `
  /sf:NerdDinner.Database.dacpac `
  /a:Script /op:deploy.sql /p:CommentOutSetVarDeclarations=true `
  /tsn:.\SQLEXPRESS /tdn:NerdDinner /tu:sa /tp:$sa_password  

如果容器启动时数据库目录为空,则容器中没有NerdDinner数据库,并且SqlPackage将生成一个包含一组CREATE语句的脚本来部署新数据库。如果数据库目录包含文件,则现有数据库将被附加。在这种情况下,SqlPackage将生成一个包含一组ALTERCREATE语句的脚本,以使数据库与 Dacpac 保持一致。

在这一步生成的deploy.sql脚本将创建新模式,或者对旧模式进行更改以升级它。最终数据库模式在两种情况下都将是相同的。

最后,PowerShell 脚本执行 SQL 脚本,传入数据库名称、文件前缀和数据路径的变量:

$SqlCmdVars  =  "DatabaseName=NerdDinner",  "DefaultFilePrefix=NerdDinner"...  Invoke-Sqlcmd  -InputFile deploy.sql -Variable $SqlCmdVars  -Verbose

SQL 脚本运行后,数据库在容器中存在,并且其模式与 Dacpac 中建模的模式相同,Dacpac 是从 Dockerfile 的构建阶段中的 SQL 项目构建的。数据库文件位于预期位置,并具有预期名称,因此如果用相同镜像的另一个容器替换此容器,新容器将找到现有数据库并附加它。

在容器中运行数据库

现在我有一个数据库镜像,可以用于新部署和升级。开发人员可以使用该镜像,在他们开发功能时运行它而不挂载卷,这样他们每次运行容器时都可以从一个新的数据库开始。同样的镜像也可以在需要保留现有数据库的环境中使用,通过使用包含数据库文件的卷来运行容器。

这就是您在 Docker 中运行 NerdDinner 数据库的方式,使用默认管理员密码,带有数据库文件的主机目录,并命名容器,以便我可以从其他容器中访问它:

mkdir -p C:\databases\nd

docker container run -d -p 1433:1433 ` --name nerd-dinner-db ` -v C:\databases\nd:C:\data ` dockeronwindows/ch03-nerd-dinner-db:2e

第一次运行此容器时,Dacpac 将运行以创建数据库,并将数据和日志文件保存在主机上的挂载目录中。您可以使用ls检查主机上是否存在文件,并且docker container logs的输出显示生成的 SQL 脚本正在运行,并创建资源:

> docker container logs nerd-dinner-db
VERBOSE: Starting SQL Server
VERBOSE: Changing SA login credentials
VERBOSE: No data files - will create new database
Generating publish script for database 'NerdDinner' on server '.\SQLEXPRESS'.
Successfully generated script to file C:\init\deploy.sql.
VERBOSE: Changed database context to 'master'.
VERBOSE: Creating NerdDinner...
VERBOSE: Changed database context to 'NerdDinner'.
VERBOSE: Creating [dbo].[Dinners]...
...
VERBOSE: Deployed NerdDinner database, data files at: C:\data

我使用的docker container run命令还发布了标准的 SQL Server 端口1433,因此您可以通过.NET 连接或SQL Server Management StudioSSMS)远程连接到容器内运行的数据库。如果您的主机上已经运行了 SQL Server 实例,您可以将容器的端口1433映射到主机上的不同端口。

要使用 SSMS、Visual Studio 或 Visual Studio Code 连接到运行在容器中的 SQL Server 实例,请使用localhost作为服务器名称,选择 SQL Server 身份验证,并使用sa凭据。我使用的是SqlElectron,这是一个非常轻量级的 SQL 数据库客户端:

然后,您可以像处理任何其他 SQL Server 数据库一样处理 Docker 化的数据库,查询表并插入数据。从 Docker 主机机器上,您可以使用localhost作为数据库服务器名称。通过发布端口,您可以在主机之外访问容器化的数据库,使用主机机器名称作为服务器名称。Docker 将端口1433上的任何流量路由到运行在容器上的 SQL Server。

从应用程序容器连接到数据库容器

Docker 平台内置了一个 DNS 服务器,容器用它来进行服务发现。我使用了一个显式名称启动了 NerdDinner 数据库容器,同一 Docker 网络中运行的任何其他容器都可以通过名称访问该容器,就像 Web 服务器通过其 DNS 主机名访问远程数据库服务器一样:

这使得应用程序配置比传统的分布式解决方案简单得多。每个环境看起来都是一样的。在开发、集成测试、QA 和生产中,Web 容器将始终使用nerd-dinner-db主机名连接到实际运行在容器内的数据库。容器可以在同一台 Docker 主机上,也可以在 Docker Swarm 集群中的另一台独立机器上,对应用程序来说是透明的。

Docker 中的服务发现不仅适用于容器。容器可以使用其主机名访问网络上的另一台服务器。您可以在容器中运行 Web 应用程序,但仍然让它连接到物理机上运行的 SQL Server,而不是使用数据库容器。

每个环境可能有一个不同的配置,那就是 SQL Server 的登录凭据。在 NerdDinner 数据库镜像中,我使用了与本章前面的dockeronwindows/ch03-aspnet-config相同的配置方法。我已经将Web.config中的appSettingsconnectionStrings部分拆分成单独的文件,并且 Docker 镜像将这些配置文件与默认值捆绑在一起。

开发人员可以直接从镜像中运行容器,并且它将使用默认的数据库凭据,这些凭据与 NerdDinner 数据库 Docker 镜像中内置的默认凭据相匹配。在其他环境中,可以通过在主机服务器上使用配置文件进行卷挂载来运行容器,这些配置文件指定了不同的应用程序设置和数据库连接字符串。

这是一个简化的安全凭据方法,我用它来展示如何使我们的应用更加适合 Docker,而不改变代码。将凭据保存在服务器上的纯文本文件中并不是管理机密信息的好方法,我将在第九章了解 Docker 的安全风险和好处中再次讨论这个问题,当时我会介绍 Docker 中的安全性。

本章对 NerdDinner 的 Dockerfile 进行了一些更新。我添加了健康检查和从 IIS 中输出日志的设置。我仍然没有对 NerdDinner 代码库进行任何功能性更改,只是将Web.config文件拆分,并将默认数据库连接字符串设置为使用运行在 Docker 中的 SQL Server 数据库容器。现在运行 Web 应用程序容器时,它将能够通过名称连接到数据库容器,并使用在 Docker 中运行的 SQL Server Express 数据库:

docker container run -d -P dockeronwindows/ch03-nerd-dinner-web:2e

您可以在创建容器时明确指定 Docker 网络应加入的容器,但在 Windows 上,所有容器默认加入名为nat的系统创建的 Docker 网络。数据库容器和 Web 容器都连接到nat网络,因此它们可以通过容器名称相互访问。

当容器启动时,我现在可以使用容器的端口打开网站,点击注册链接并创建一个账户:

注册页面查询运行在 SQL Server 容器中的 ASP.NET 成员数据库。如果注册页面正常运行,则 Web 应用程序与数据库之间存在有效的连接。我可以在 Sqlectron 中验证这一点,查询UserProfile表并查看新用户行:

我现在已将 SQL Server 数据库与 Web 应用程序分离,每个组件都在一个轻量级的 Docker 容器中运行。在我的开发笔记本上,每个容器在空闲时使用的主机 CPU 不到 1%,数据库使用 250MB 内存,Web 服务器使用 70MB。

docker container top可以显示容器内运行的进程信息,包括内存和 CPU。

容器资源占用较少,因此将功能单元拆分为不同的容器没有任何惩罚,然后可以单独扩展、部署和升级这些组件。

拆分单片应用程序

传统的依赖于 SQL Server 数据库的.NET Web 应用程序可以以最小的工作量迁移到 Docker,而无需重写任何应用程序代码。在我的 NerdDinner 迁移的这个阶段,我有一个应用程序 Docker 镜像和一个数据库 Docker 镜像,我可以可靠地和重复地部署和维护。我还有一些有益的副作用。

在 Visual Studio 项目中封装数据库定义可能是一种新的方法,但它可以为数据库脚本添加质量保证,并将模式引入代码库,因此可以与系统的其余部分一起进行源代码控制和管理。Dacpacs、PowerShell 脚本和 Dockerfiles 为不同的 IT 功能提供了一个新的共同基础。开发、运维和数据库管理团队可以共同使用相同的语言在相同的工件上进行工作。

Docker 是 DevOps 转型的推动者,但无论您的路线图上是否有 DevOps,Docker 都为快速、可靠的发布提供了基础。为了最大限度地利用这一点,您需要考虑将单片应用程序分解为更小的部分,这样您就可以频繁发布高价值组件,而无需对整个大型应用程序进行回归测试。

从现有应用程序中提取核心组件可以在不进行大规模、复杂的重写的情况下将现代、轻量级技术引入您的系统。您可以将微服务架构原则应用于现有解决方案,其中您已经了解了值得提取到自己服务中的领域。

从单体中提取高价值组件

Docker 平台为现代化传统应用程序提供了巨大的机会,使您可以将特性从单体中取出并在单独的容器中运行。如果您可以隔离特性中的逻辑,这也是将其迁移到.NET Core 的机会,这样您可以将其打包成更小的 Docker 镜像。

微软的.NET Core 路线图已经看到它采用了更多的完整.NET Framework 功能,但将传统.NET 应用程序的部分移植到.NET Core 仍然可能是一项艰巨的任务。这是一个值得评估的选项,但它不必成为您现代化方法的一部分。分解单体的价值在于拥有可以独立开发、部署和维护的功能。如果这些组件正在使用完整的.NET Framework,您仍然可以获得这些好处。

当您现代化传统应用程序时的优势在于您已经了解了功能集。您可以识别系统中的高价值功能,并从中提取这些功能到它们自己的组件中。优秀的候选对象将是那些如果频繁更改就能为业务提供价值的功能,因此新的功能请求可以快速构建和部署,而无需修改和测试整个应用程序。

同样优秀的候选特性是那些如果保持不变就能为 IT 提供价值的特性-具有许多依赖关系的复杂组件,业务很少改变。将这样的特性提取到一个单独的组件中意味着您可以部署主应用程序的升级,而无需测试复杂组件,因为它保持不变。像这样分解单体应用程序会给您一组具有自己交付节奏的组件。

在 NerdDinner 中,有一些很适合分离成自己的服务的候选项。在本章的其余部分,我将专注于其中之一:主页。主页是渲染应用程序第一页的 HTML 的功能。在生产环境中快速而安全地部署主页更改的过程将让业务能够尝试新的外观和感觉,评估新版本的影响,并决定是否继续使用它。

当前应用程序分布在两个容器之间。在本章的这一部分,我将把主页分离成自己的组件,这样整个 NerdDinner 应用程序将在三个容器中运行:

我不会改变应用程序的路由。用户仍然会首先进入 NerdDinner 应用程序,然后应用程序容器将调用新的主页服务容器以获取内容显示。这样我就不需要公开新的容器。更改只有一个技术要求:主应用程序需要能够与新的主页服务组件通信。

您可以自由选择容器中应用程序的通信方式。Docker 网络为 TCP/IP 和 UDP 提供了完整的协议支持。您可以使整个过程异步运行,将消息队列放在另一个容器中,并在其他容器中监听消息处理程序。但是在本章中,我将从更简单的方式开始。

在 ASP.NET Core 应用程序中托管 UI 组件

ASP.NET Core 是一个现代的应用程序堆栈,它在快速而轻量的运行时中提供了 ASP.NET MVC 和 Web API 的最佳功能。ASP.NET Core 网站作为控制台应用程序运行,它们将日志写入控制台输出流,并且它们可以使用环境变量和文件进行配置。这种架构使它们成为优秀的 Docker 公民。

将 NerdDinner 主页提取为一个新的服务的最简单方法是将其编写为一个 ASP.NET Core 网站,具有单个页面,并从现有应用程序中中继新应用程序的输出。以下屏幕截图显示了我在 Docker 中使用 ASP.NET Core Razor Pages 运行的时尚、现代化的主页重新设计:

为了将主页应用程序打包为 Docker 镜像,我正在使用与主应用程序和数据库镜像相同的多阶段构建方法。在第十章中,使用 Docker 支持持续部署流水线,您将看到如何使用 Docker 来支持 CI/CD 构建流水线,并将整个自动化部署过程联系在一起。

dockeronwindows/ch03-nerd-dinner-homepage:2e镜像的 Dockerfile 使用了与完整 ASP.NET 应用程序相同的模式。构建器阶段使用 SDK 镜像并分离包恢复和编译步骤:

# escape=` FROM microsoft/dotnet:2.2-sdk-nanoserver-1809 AS builder WORKDIR C:\src\NerdDinner.Homepage COPY src\NerdDinner.Homepage\NerdDinner.Homepage.csproj . RUN dotnet restore COPY src\NerdDinner.Homepage . RUN dotnet publish  

Dockerfile 的最后阶段为NERD_DINNER_URL环境变量提供了默认值。应用程序将其用作主页上链接的目标。 Dockerfile 的其余指令只是复制已发布的应用程序并设置入口点:

FROM microsoft/dotnet:2.2-aspnetcore-runtime-nanoserver-1809 WORKDIR C:\dotnetapp ENV NERD_DINNER_URL="/home/find" EXPOSE 80 CMD ["dotnet", "NerdDinner.Homepage.dll"] COPY --from=builder C:\src\NerdDinner.Homepage\bin\Debug\netcoreapp2.2\publish .

我可以在单独的容器中运行主页组件,但它尚未连接到主 NerdDinner 应用程序。使用本章中采用的方法,我需要对原始应用程序进行代码更改,以便集成新的主页服务。

从其他应用程序容器连接到应用程序容器

从主应用程序容器调用新主页服务基本上与连接到数据库相同:我将使用已知名称运行主页容器,并且可以使用其名称和 Docker 内置服务发现在其他容器中访问服务。

在主 NerdDinner 应用程序的HomeController类中进行简单更改,将从新主页服务中继承响应,而不是从主应用程序呈现页面:

static  HomeController() {
  var  homepageUrl  =  Environment.GetEnvironmentVariable("HOMEPAGE_URL", EnvironmentVariableTarget.Machine); if (!string.IsNullOrEmpty(homepageUrl))
  {
    var  request  =  WebRequest.Create(homepageUrl); using (var  response  =  request.GetResponse())
    using (var  responseStream  =  new  StreamReader(response.GetResponseStream()))
    {
      _NewHomePageHtml  =  responseStream.ReadToEnd();
    }
 } } public  ActionResult  Index() { if (!string.IsNullOrEmpty(_NewHomePageHtml)) { return  Content(_NewHomePageHtml);
  }
  else
  {
    return  Find();
 } }

在新代码中,我从环境变量中获取主页服务的 URL。与数据库连接一样,我可以在 Dockerfile 中为其设置默认值。在分布式应用程序中,这将是不好的做法,因为我们无法保证组件在何处运行,但是在 Docker 化应用程序中,我可以安全地这样做,因为我将控制容器的名称,因此在部署它们时,我可以确保服务名称是正确的。

我已将此更新的镜像标记为dockeronwindows/ch03-nerd-dinner-web:2e-v2。现在,要启动整个解决方案,我需要运行三个容器:

docker container run -d -p 1433:1433 `
 --name nerd-dinner-db ` 
 -v C:\databases\nd:C:\data `
 dockeronwindows/ch03-nerd-dinner-db:2e

docker container run -d -P `
 --name nerd-dinner-homepage `
 dockeronwindows/ch03-nerd-dinner-homepage:2e

docker container run -d -P dockeronwindows/ch03-nerd-dinner-web:2e-v2

当容器正在运行时,我浏览到 NerdDinner 容器的发布端口,我可以看到来自新组件的主页:

“找晚餐”链接将我带回原始的 Web 应用程序,现在我可以在主页上迭代并通过替换该容器发布新的用户界面 - 而无需发布或测试应用程序的其余部分。

新的用户界面发生了什么?在这个简单的例子中,集成的主页没有新的 ASP.NET Core 版本的样式,因为主应用程序只读取页面的 HTML,而不是 CSS 文件或其他资产。更好的方法是在容器中运行反向代理,并将其用作其他容器的入口点,这样每个容器都可以提供所有资产。我会在书中稍后做到这一点。

现在,我的解决方案分布在三个容器中,我大大提高了灵活性。在构建时,我可以专注于提供最高价值的功能,而不必费力测试未更改的组件。在部署时,我可以快速而自信地发布,知道我们推送到生产环境的新镜像将与测试的内容完全相同。然后在运行时,我可以根据其要求独立地扩展组件。

我确实有一个新的非功能性要求,那就是确保所有容器都具有预期的名称,按正确的顺序启动,并且在同一个 Docker 网络中,以便整个解决方案正常工作。Docker 对此提供了支持,重点是使用 Docker Compose 组织分布式系统。我会在第六章中向您展示这一点,使用 Docker Compose 组织分布式解决方案

总结

在本章中,我们涵盖了三个主要主题。首先,我们介绍了将传统的.NET Framework 应用程序容器化,使其成为良好的 Docker 公民,并与平台集成以进行配置、日志记录和监视。

然后,我们介绍了如何使用 SQL Server Express 和 Dacpac 部署模型将数据库工作负载容器化,构建一个版本化的 Docker 镜像,可以将容器作为新数据库运行,或升级现有数据库。

最后,我们展示了如何将单片应用程序的功能提取到单独的容器中,使用 ASP.NET Core 和 Windows Nano Server 打包一个快速、轻量级的服务,主应用程序可以使用。

您已经学会了如何在 Docker Hub 上使用来自 Microsoft 的更多图像,以及如何为完整的.NET 应用程序使用 Windows Server Core,为数据库使用 SQL Server Express,以及.NET Core 图像的 Nano Server 版本。

在后面的章节中,我会回到 NerdDinner,并继续通过将功能提取到专用服务中来使其现代化。在那之前,在下一章中,我会更仔细地研究如何使用 Docker Hub 和其他注册表来存储镜像。

第四章:使用 Docker 注册表共享镜像

发布应用程序是 Docker 平台的一个重要部分。Docker 引擎可以从中央位置下载镜像以从中运行容器,并且还可以上传本地构建的镜像到中央位置。这些共享的镜像存储被称为注册表,在本章中,我们将更仔细地看一下镜像注册表的工作原理以及可用于您的注册表的类型。

主要的镜像注册表是 Docker Hub,这是一个免费的在线服务,也是 Docker 服务默认的工作位置。Docker Hub 是一个很好的地方,社区可以分享构建的用于打包开源软件并且可以自由重新分发的镜像。Docker Hub 取得了巨大的成功。在撰写本书时,上面有数十万个可用的镜像,每年下载量达数十亿次。

公共注册表可能不适合您自己的应用程序。Docker Hub 还提供商业计划,以便您可以托管私有镜像(类似于 GitHub 允许您托管公共和私有源代码仓库的方式),还有其他商业注册表添加了诸如安全扫描之类的功能。您还可以通过使用免费提供的开源注册表实现在您的环境中运行自己的注册表服务器。

在本章中,我将向您展示如何使用这些注册表,并且我将介绍标记镜像的细节 - 这是您可以对 Docker 镜像进行版本控制的方法 - 以及如何使用来自不同注册表的镜像。我们将涵盖:

  • 理解注册表和仓库

  • 运行本地镜像注册表

  • 使用本地注册表推送和拉取镜像

  • 使用商业注册表

理解注册表和仓库

您可以使用docker image pull命令从注册表下载镜像。运行该命令时,Docker 引擎连接到注册表,进行身份验证 - 如果需要的话 - 并下载镜像。拉取过程会下载所有镜像层并将它们存储在本地镜像缓存中。容器只能从本地镜像缓存中可用的镜像运行,因此除非它们是本地构建的,否则需要先拉取。

在 Windows 上开始使用 Docker 时,您运行的最早的命令之一可能是一些简单的命令,就像来自第二章的这个例子,将应用程序打包并作为 Docker 容器运行

> docker container run dockeronwindows/ch02-powershell-env:2e

Name                           Value
----                           -----
ALLUSERSPROFILE                C:\ProgramData
APPDATA                        C:\Users\ContainerAdministrator\AppData\Roaming
...

这将起作用,即使您在本地缓存中没有该镜像,因为 Docker 可以从默认注册表 Docker Hub 中拉取它。如果您尝试从本地没有存储的镜像运行容器,Docker 将在创建容器之前自动拉取它。

在这个例子中,我没有给 Docker 太多信息——只是镜像名称,dockeronwindows/ch02-powershell-env:2e。这个细节足以让 Docker 在注册表中找到正确的镜像,因为 Docker 会用默认值填充一些缺失的细节。仓库的名称是dockeronwindows/ch02-powershell-env,仓库是一个可以包含多个 Docker 镜像版本的存储单元。

检查镜像仓库名称

仓库有一个固定的命名方案:{registry-domain}/{account-id}/{repository-name}:{tag}。所有部分都是必需的,但 Docker 会假设一些值的默认值。所以dockeronwindows/ch02-powershell-env:2e实际上是完整仓库名称的简写形式,即docker.io/dockeronwindows/ch02-powershell-env:2e

  • registry-domain是存储镜像的注册表的域名或 IP 地址。Docker Hub 是默认的注册表,所以在使用来自 Hub 的镜像时,可以省略注册表域。如果不指定注册表,Docker 将使用docker.io作为注册表。

  • account-id是在注册表上拥有镜像的帐户或组织的名称。在 Docker Hub 上,帐户名称是强制的。我的帐户 ID 是sixeyed,伴随本书的图像的组织帐户 ID 是dockeronwindows。在其他注册表上,可能不需要帐户 ID。

  • repository-name是您想要为镜像指定的名称,以在注册表上的您的所有仓库中唯一标识应用程序。

  • tag是用来区分仓库中不同镜像变体的方式。

您可以使用标签对应用程序进行版本控制或识别变体。如果在构建或拉取镜像时未指定标签,Docker 将使用默认标签latest。当您开始使用 Docker 时,您将使用 Docker Hub 和latest标签,这是 Docker 提供的默认值,以隐藏一些复杂性,直到您准备深入挖掘。随着您继续使用 Docker,您将使用标签来清楚地区分应用程序包的不同版本。

一个很好的例子是微软的.NET Core 基础图像,它位于 Docker Hub 的microsoft/dotnet存储库中。 .NET Core 是一个在 Linux 和 Windows 上运行的跨平台应用程序堆栈。您只能在基于 Linux 的 Docker 主机上运行 Linux 容器,并且只能在基于 Windows 的 Docker 主机上运行 Windows 容器,因此 Microsoft 在标签名称中包含了操作系统。

在撰写本文时,Microsoft 在microsoft/dotnet存储库中提供了数十个版本的.NET Core 图像可供使用,并使用不同的标签进行标识。以下只是一些标签:

  • 2.2-runtime-bionic是基于 Ubuntu 18.04 版本的 Linux 图像,其中安装了.NET Core 2.2 运行时

  • 2.2-runtime-nanoserver-1809是一个 Nano Server 1809 版本的图像,其中安装了.NET Core 2.2 运行时

  • 2.2-sdk-bionic是基于 Debian 的 Linux 图像,其中安装了.NET Core 2.2 运行时和 SDK

  • 2.2-sdk-nanoserver-1809是一个 Nano Server 图像,其中安装了.NET Core 2.2 运行时和 SDK

这些标签清楚地表明了每个图像包含的内容,但它们在根本上都是相似的 - 它们都是microsoft/dotnet的变体。

Docker 还支持多架构图像,其中单个图像标签用作许多变体的总称。可以基于 Linux 和 Windows 操作系统,或英特尔和高级 RISC 机器ARM)处理器的图像变体。它们都使用相同的图像名称,当您运行docker image pull时,Docker 会为您的主机操作系统和 CPU 架构拉取匹配的图像。 .NET Core 图像可以做到这一点 - docker image pull microsoft/dotnet:2.2-sdk将在 Linux 机器上下载 Linux 图像,在 Windows 机器上下载 Windows 图像。

如果您将跨平台应用程序发布到 Docker Hub,并且希望尽可能地让消费者使用它,您应该将其发布为多架构图像。在您自己的开发中,最好是明确地在 Dockerfiles 中指定确切的FROM图像,否则您的应用程序将在不同的操作系统上构建不同。

构建、标记和版本化图像

当您首次构建图像时,您会对图像进行标记,但您也可以使用docker image tag命令显式地向图像添加标签。这在对成熟应用程序进行版本控制时非常有用,因此用户可以选择要使用的版本级别。如果您运行以下命令,您将构建一个具有五个标签的图像,其中包含应用程序版本的不同精度级别:

docker image build -t myapp .
docker image tag myapp:latest myapp:5
docker image tag myapp:latest myapp:5.1
docker image tag myapp:latest myapp:5.1.6
docker image tag myapp:latest myapp:bc90e9

最初的 docker image build 命令没有指定标记,因此新图像将默认为 myapp:latest。每个后续的 docker image tag 命令都会向同一图像添加一个新标记。标记不会复制图像,因此没有数据重复 - 您只有一个图像,可以用多个标记引用。通过添加所有这些标记,您为消费者提供了选择使用哪个图像,或者以其为基础构建自己的图像。

此示例应用程序使用语义化版本。最终标记可以是触发构建的源代码提交的 ID;这可能在内部使用,但不公开。5.1.6 是补丁版本,5.1 是次要版本号,5 是主要版本号。

用户可以明确使用 myapp:5.1.6,这是最具体的版本号,知道该标记不会在该级别更改,图像将始终相同。下一个发布将具有标记 5.1.7,但那将是一个具有不同应用程序版本的不同图像。

myapp:5.1 将随着每个补丁版本的发布而更改 - 下一个构建,5.1 将是 5.1.7 的标记别名 - 但用户可以放心,不会有任何破坏性的更改。myapp:5 将随着每个次要版本的发布而更改 - 下个月,它可能是 myapp:5.2 的别名。用户可以选择主要版本,如果他们总是想要版本 5 的最新发布,或者他们可以使用最新版本,可以接受可能的破坏性更改。

作为图像的生产者,您可以决定如何支持图像标记中的版本控制。作为消费者,您应该更加具体 - 尤其是对于您用作自己构建的 FROM 图像。如果您正在打包 .NET Core 应用程序,如果您的 Dockerfile 像这样开始:

FROM microsoft/dotnet:sdk

在撰写本文时,此图像已安装了 .NET Core SDK 的 2.2.103 版本。如果您的应用程序针对 2.2 版本,那就没问题;图像将构建,您的应用程序将在容器中正确运行。但是,当 .NET Core 2.3 或 3.0 发布时,新图像将应用通用的 :sdk 标记,这可能不支持针对 2.2 应用程序的目标。在该发布之后使用完全相同的 Dockerfile 时,它将使用不同的基础图像 - 您的图像构建可能会失败,或者它可能仅在应用程序运行时失败,如果 .NET Core 更新中存在破坏性更改。

相反,您应该考虑使用应用程序框架的次要版本的标签,并明确说明操作系统和 CPU 架构(如果是多架构图片):

FROM microsoft/dotnet:2.2-sdk-nanoserver-1809

这样,您将受益于图像的任何补丁版本,但您将始终使用.NET Core 的 2.2 版本,因此您的应用程序将始终在基础图像中具有匹配的主机平台。

您可以标记您本地缓存中的任何图像,而不仅仅是您自己构建的图像。如果您想要重新标记一个公共图像并将其添加到本地私有注册表中的批准基础图像集中,这将非常有用。

将图像推送到注册表

构建和标记图像是本地操作。docker image builddocker image tag的最终结果是对您运行命令的 Docker Engine 上的图像缓存的更改。需要使用docker image push命令将图像明确共享到注册表中。

Docker Hub 可供使用,无需进行身份验证即可拉取公共图像,但是要上传图像(或拉取私有图像),您需要注册一个账户。您可以在hub.docker.com/免费注册,这是您可以在 Docker Hub 和其他 Docker 服务上使用的 Docker ID 的地方。您的 Docker ID 是您用来验证访问 Docker Hub 的 Docker 服务的方式。这是通过docker login命令完成的:

> docker login
Login with your Docker ID to push and pull images from Docker Hub. If you don't have a Docker ID, head over to https://hub.docker.com to create one.
Username: sixeyed
Password:
Login Succeeded

要将图片推送到 Docker Hub,仓库名称必须包含您的 Docker ID 作为账户 ID。您可以使用任何账户 ID 在本地标记一个图片,比如microsoft/my-app,但是您不能将其推送到注册表上的 Microsoft 组织。您登录的 Docker ID 需要有权限将图片推送到注册表上的账户。

当我发布图片以配合这本书时,我会使用dockeronwindows作为仓库中的账户名来构建它们。这是 Docker Hub 上的一个组织,而我的用户账户sixeyed有权限将图片推送到该组织。当我以sixeyed登录时,我可以将图片推送到属于sixeyeddockeronwindows的仓库:

docker image push sixeyed/git:2.17.1-windowsservercore-ltsc2019 docker image push dockeronwindows/ch03-iis-healthcheck:2e 

Docker CLI 的输出显示了图像如何分成层,并告诉您每个层的上传状态:

The push refers to repository [docker.io/dockeronwindows/ch03-iis-healthcheck]
55e5e4877d55: Layer already exists
b062c01c8395: Layer already exists
7927569daca5: Layer already exists
...
8df29e538807: Layer already exists
b42b16f07f81: Layer already exists
6afa5894851e: Layer already exists
4dbfee563a7a: Skipped foreign layer
c4d02418787d: Skipped foreign layer
2e: digest: sha256:ffbfb90911efb282549d91a81698498265f08b738ae417bc2ebeebfb12cbd7d6 size: 4291

该图像使用 Windows Server Core 作为基本图像。该图像不是公开可再分发的 - 它在 Docker Hub 上列出,并且可以从 Microsoft 容器注册表免费下载,但未经许可不得存储在其他公共图像注册表上。这就是为什么我们可以看到标有跳过外部层的行 - Docker 不会将包含 Windows OS 的层推送到 Docker Hub。

您无法发布到另一个用户的帐户,但可以使用您自己的帐户名称标记另一个用户的图像。这是一组完全有效的命令,如果我想要下载特定版本的 Windows Server Core 图像,给它一个更友好的名称,并在我的帐户下使用该新名称在 Hub 上提供它,我可以运行这些命令:

docker image pull mcr.microsoft.com/windows/servercore:1809_KB4480116_amd64
docker image tag mcr.microsoft.com/windows/servercore:1809_KB4480116_amd64 `
  sixeyed/windowsservercore:2019-1811
docker image push sixeyed/windowsservercore:2019-1811

Microsoft 在不同的时间使用了不同的标记方案来标记他们的图像。Windows Server 2016 图像使用完整的 Windows 版本号,如10.0.14393.2608。Windows Server 2019 图像使用发布名称,后跟图像中包含的最新 Windows 更新的 KB 标识符,如1809_KB4480116

对于用户来说,将图像推送到注册表并不比这更复杂,尽管在幕后,Docker 运行一些智能逻辑。图像分层也适用于注册表,就像适用于 Docker 主机上的本地图像缓存一样。当您将基于 Windows Server Core 的图像推送到 Hub 时,Docker 不会上传 4GB 的基本图像 - 它知道基本层已经存在于 MCR 上,并且只会上传目标注册表上缺少的层。

将公共图像标记并推送到公共 Hub 的最后一个示例是有效的,但不太可能发生 - 您更有可能将图像标记并推送到您自己的私有注册表。

运行本地图像注册表

Docker 平台是可移植的,因为它是用 Go 语言编写的,这是一种跨平台语言。Go 应用程序可以编译成本地二进制文件,因此 Docker 可以在 Linux 或 Windows 上运行,而无需用户安装 Go。在 Docker Hub 上有一个包含用 Go 编写的注册表服务器的官方图像,因此您可以通过从该图像运行 Docker 容器来托管自己的图像注册表。

registry是由 Docker 团队维护的官方存储库,但在撰写本文时,它只有适用于 Linux 的图像。很可能很快就会发布注册表的 Windows 版本,但在本章中,我将向您介绍如何构建自己的注册表图像,因为它展示了一些常见的 Docker 使用模式。

官方存储库就像其他公共镜像一样在 Docker Hub 上可用,但它们经过 Docker, Inc.的策划,并由 Docker 自己或应用程序所有者维护。您可以依赖它们包含正确打包和最新软件。大多数官方镜像都有 Linux 变体,但 Windows 官方镜像的数量正在增加。

构建注册表镜像

Docker 的注册服务器是一个完全功能的镜像注册表,但它只是 API 服务器 - 它没有像 Docker Hub 那样的 Web UI。它是一个开源应用程序,托管在 GitHub 的docker/distribution存储库中。要在本地构建该应用程序,您首先需要安装 Go SDK。如果您已经这样做了,可以运行一个简单的命令来编译该应用程序:

go get github.com/docker/distribution/cmd/registry

但是,如果您不是经常使用 Go 开发人员,您不希望在本地机器上安装和维护 Go 工具的开销,只是为了在需要更新时构建注册服务器。最好将 Go 工具打包到一个 Docker 镜像中,并设置该镜像,以便在运行容器时为您构建注册服务器。您可以使用我在第三章中演示的相同的多阶段构建方法,开发 Docker 化的.NET Framework 和.NET Core 应用程序

多阶段模式有很多优势。首先,这意味着您的应用程序镜像可以尽可能地保持轻量级 - 您不需要将构建工具与运行时一起打包。其次,这意味着您的构建代理被封装在一个 Docker 镜像中,因此您不需要在构建服务器上安装这些工具。第三,这意味着开发人员可以使用与构建服务器相同的构建过程,因此您避免了开发人员机器和构建服务器安装了不同的工具集的情况,这可能导致构建问题。

dockeronwindows/ch04-registry:2e的 Dockerfile 使用官方的 Go 镜像,在 Docker Hub 上有一个 Windows Server Core 变体。构建阶段使用该镜像来编译注册表应用程序:

# escape=` FROM golang:1.11-windowsservercore-1809 AS builder ARG REGISTRY_VERSION="v2.6.2" WORKDIR C:\gopath\src\github.com\docker RUN git clone https://github.com/docker/distribution.git; ` cd distribution; `
    git checkout $env:REGISTRY_VERSION; `
    go build -o C:\out\registry.exe .\cmd\registry  

我使用ARG指令来指定要构建的源代码版本——GitHub 存储库为每个发布的版本都有标签,我默认使用版本 2.6.2。然后我使用git来克隆源代码并切换到标记版本的代码,并使用go build来编译应用程序。Git 客户端和 Go 工具都在基本的golang映像中。输出将是registry.exe,这是一个本地的 Windows 可执行文件,不需要安装 Go 就可以运行。

Dockerfile 的最后阶段使用 Nano Server 作为基础,可以很好地运行 Go 应用程序。以下是整个应用程序阶段:

FROM mcr.microsoft.com/windows/nanoserver:1809 ENV REGISTRY_STORAGE_FILESYSTEM_ROOTDIRECTORY="C:\data"  VOLUME ${REGISTRY_STORAGE_FILESYSTEM_ROOTDIRECTORY} EXPOSE 5000 WORKDIR C:\registry CMD ["registry", "serve", "config.yml"] COPY --from=builder C:\out\registry.exe . COPY --from=builder C:\gopath\src\github.com\docker\...\config-example.yml .\config.yml

这个阶段没有什么复杂的。它从设置图像开始:

  1. REGISTRY_STORAGE_FILESYSTEM_ROOTDIRECTORY是注册表使用的环境变量,作为存储数据的基本路径。

  2. 通过使用环境变量中捕获的路径创建了一个VOLUME,用于注册表数据。

  3. 暴露端口5000,这是 Docker 注册表的常规端口。

Dockerfile 的其余部分设置了容器的入口点,并从构建阶段复制了编译的二进制文件和默认配置文件。

Windows Server 2016 中的 Docker 容器有一个不同的卷实现——容器内的目标目录实际上是一个符号链接,而不是一个普通目录。这导致了 Go、Java 和其他语言的问题。通过使用映射驱动器,可以实现一种解决方法,但现在不再需要。如果您看到任何使用 G:驱动器的 Dockerfile,它们是基于 Windows Server 2016 的,可以通过使用 C:驱动器简化为 Windows Server 2019。

构建注册表映像与构建任何其他映像相同,但当您使用它来运行自己的注册表时,有一些重要因素需要考虑。

运行注册表容器

运行自己的注册表可以让您在团队成员之间共享图像,并使用快速本地网络而不是互联网连接存储所有应用程序构建的输出。您通常会在可以广泛访问的服务器上运行注册表容器,配置如下:

注册表在服务器上的容器(1)上运行。客户端机器(3)连接到服务器,以便它们可以使用本地网络上的注册表来推送和拉取私有图像。

为了使注册表容器可访问,您需要将容器的端口5000发布到主机上的端口5000。注册表用户可以使用主机服务器的 IP 地址或主机名访问容器,这将是您在镜像名称中使用的注册表域。您还需要挂载一个卷从主机存储图像数据在一个已知的位置。当您用新版本替换容器时,它仍然可以使用主机的域名,并且仍然具有之前容器存储的所有图像层。

在我的主机服务器上,我配置了一个作为磁盘E:的 RAID 阵列,我用它来存储我的注册表数据,以便我可以运行我的注册表容器挂载该卷作为数据路径:

mkdir E:\registry-data
docker container run -d -p 5000:5000 -v E:\registry-data:C:\data dockeronwindows/ch04-registry:2e

在我的网络中,我将在具有 IP 地址192.168.2.146的物理机器上运行容器。我可以使用192.168.2.146:5000作为注册表域来标记图像,但这并不是很灵活。最好使用主机的域名,这样我可以在需要时将其指向不同的物理服务器,而无需重新标记所有图像。

对于主机名,您可以使用您网络的域名系统DNS)服务,或者如果您运行公共服务器,可以使用规范名称CNAME)。或者,您可以在客户机上的 hosts 文件中添加一个条目,并使用自定义域名。这是我用来为registry.local添加指向我的 Docker 服务器的主机名条目的 PowerShell 命令:

Add-Content -Path 'C:\Windows\System32\drivers\etc\hosts' -Value "`r`n192.168.2.146 registry.local"

现在我的服务器正在运行一个具有可靠存储的容器中的注册表服务器,并且我的客户端已设置好以使用友好的域名访问注册表主机。我可以开始从自己的注册表推送和拉取私有镜像,这仅对我的网络上的用户可用。

使用本地注册表推送和拉取镜像

只有当镜像标签与注册表域匹配时,才能将镜像推送到注册表。标记和推送的过程与 Docker Hub 相同,但您需要明确包含本地注册表域在新标记中。这些命令从 Docker Hub 拉取我的注册表服务器镜像,并添加一个新标记,使其适合推送到本地注册表:

docker image pull dockeronwindows/ch04-registry:2e

docker image tag dockeronwindows/ch04-registry:2e registry.local:5000/infrastructure/registry:v2.6.2

docker image tag命令首先指定源标记,然后指定目标标记。您可以更改新目标标记的镜像名称的每个部分。我使用了以下内容:

  • registry.local:5000是注册表域。原始镜像名称隐含的域为docker.io

  • infrastructure是帐户名称。原始帐户名称是dockeronwindows

  • registry是存储库名称。原始名称是ch04-registry

  • v2.6.2是图像标记。原始标记是2e

如果您想知道为什么本书的所有图像都有2e标记,那是因为我用它来标识它们与本书的第二版一起使用。我没有在第一版中为图像使用标记,因此它们都具有隐含的latest标记。它们仍然存在于 Docker Hub 上,但通过将新版本标记为2e,我可以将图像发布到相同的存储库,而不会破坏第一版读者的代码示例。

我可以尝试将新标记的映像推送到本地注册表,但 Docker 还不允许我使用注册表:

> docker image push registry.local:5000/infrastructure/registry:v2.6.2

The push refers to a repository [registry.local:5000/infrastructure/registry]
Get https://registry.local:5000/v2/: http: server gave HTTP response to HTTPS client

Docker 平台默认是安全的,相同的原则也适用于映像注册表。Docker 引擎期望使用 HTTPS 与注册表通信,以便流量被加密。我的简单注册表安装使用明文 HTTP,因此我收到了一个错误,说 Docker 尝试使用加密传输进行注册表,但只有未加密传输可用。

设置 Docker 使用本地注册表有两个选项。第一个是扩展注册表服务器以保护通信-如果您提供 SSL 证书,注册表服务器映像可以在 HTTPS 上运行。这是我在生产环境中会做的事情,但是为了开始,我可以使用另一个选项并在 Docker 配置中做一个例外。如果在允许的不安全注册表列表中明确命名,Docker 引擎将允许使用 HTTP 注册表。

您可以使用公司的 SSL 证书或自签名证书在 HTTPS 下运行注册表映像,这意味着您无需配置 Docker 引擎以允许不安全的注册表。GitHub 上的 Docker 实验室存储库docker/labs中有一个 Windows 注册表演练,解释了如何做到这一点。

配置 Docker 以允许不安全的注册表

Docker 引擎可以使用 JSON 配置文件来更改设置,包括引擎允许的不安全注册表列表。该列表中的任何注册表域都可以使用 HTTP 而不是 HTTPS,因此这不是您应该为托管在公共网络上的注册表执行的操作。

Docker 的配置文件位于%programdata%\docker\config\daemon.jsondaemon是 Linux 术语,表示后台服务,因此这是 Docker 引擎配置文件的名称)。您可以手动编辑它,将本地注册表添加为安全选项,然后重新启动 Docker Windows 服务。此配置允许 Docker 使用 HTTP 访问本地注册表:

{  
    "insecure-registries": [    
         "registry.local:5000"  
    ]
}

如果您在 Windows 10 上使用 Docker Desktop,则 UI 具有一个很好的配置窗口,可以为您处理这些问题。只需右键单击状态栏中的 Docker 标志,选择“设置”,导航到“守护程序”页面,并将条目添加到不安全的注册表列表中:

将本地注册表域添加到我的不安全列表后,我可以使用它来推送和拉取镜像:

> docker image push registry.local:5000/infrastructure/registry:v2.6.2

The push refers to repository [registry.2019:5000/infrastructure/registry]
dab5f9f9b952: Pushed
9ab5db0fd189: Pushed
c53fe60c877c: Pushed
ccc905d24a7d: Pushed
470656dd7daa: Pushed
f32c8541ff24: Pushed
3ad7de2744af: Pushed
b9fa4df06e58: Skipped foreign layer
37c182b75172: Skipped foreign layer
v2.6.2: digest: sha256:d7e87b1d094d96569b346199c4d3dd5ec1d5d5f8fb9ea4029e4a4aa9286e7aac size: 2398 

任何具有对我的 Docker 服务器的网络访问权限的用户都可以使用存储在本地注册表中的镜像,使用docker image pulldocker container run命令。您还可以通过在FROM指令中指定名称与注册表域、存储库名称和标签,将本地镜像用作其他 Dockerfile 中的基本镜像:

FROM registry.local:5000/infrastructure/registry:v2.6.2
CMD ["cmd /s /c", "echo", "Hello from Chapter 4."]

没有办法覆盖默认注册表,因此当未指定域时,无法将本地注册表设置为默认值 - 默认值始终为 Docker Hub。如果要为镜像使用不同的注册表,注册表域必须始终在镜像名称中指定。任何不带注册表地址的镜像名称都将被假定为指向docker.io的镜像。

将 Windows 镜像层存储在本地注册表中

不允许公开重新分发 Microsoft 镜像的基本层,但允许将它们存储在私有注册表中。这对于 Windows Server Core 镜像特别有用。该镜像的压缩大小为 2GB,Microsoft 每个月在 Docker Hub 上发布一个新版本的镜像,带有最新的安全补丁。

更新通常只会向镜像添加一个新层,但该层可能是 300MB 的下载。如果有许多用户使用 Windows 镜像,他们都需要下载这些层,这需要大量的带宽和时间。如果运行本地注册表服务器,可以从 Docker Hub 一次拉取这些层,并将它们推送到本地注册表。然后,其他用户从本地注册表中拉取,从快速本地网络而不是从互联网上下载。

您需要在 Docker 配置文件中为特定注册表启用此功能,使用allow-nondistributable-artifacts字段:

{
  "insecure-registries": [
    "registry.local:5000"
  ],
 "allow-nondistributable-artifacts": [
    "registry.local:5000"
  ]
}

这个设置在 Docker for Windows UI 中没有直接暴露,但你可以在设置屏幕的高级模式中设置它:

现在,我可以将 Windows foreign layers推送到我的本地注册表。我可以使用自己的注册表域标记最新的 Nano Server 图像,并将图像推送到那里:

PS> docker image tag mcr.microsoft.com/windows/nanoserver:1809 `
     registry.local:5000/microsoft/nanoserver:1809

PS> docker image push registry.local:5000/microsoft/nanoserver:1809
The push refers to repository [registry.2019:5000/microsoft/nanoserver]
75ddd2c5f09c: Pushed
37c182b75172: Pushing  104.8MB/243.8MB

当您将 Windows 基础镜像层存储在自己的注册表中时,层 ID 将与 MCR 上的原始层 ID 不同。这对 Docker 的图像缓存产生影响。您可以使用完整标签registry.local:5000/microsoft/nanoserver:1809在干净的机器上拉取自己的 Nano Server 图像。然后,如果您拉取官方的 Microsoft 图像,层将再次被下载。它们具有相同的内容但不同的 ID,因此 Docker 不会将它们识别为缓存命中。

如果您要存储 Windows 的基础图像的自己的版本,请确保您是一致的,并且只在您的 Dockerfile 中使用这些图像。这也适用于从 Windows 图像构建的图像-因此,如果您想要使用.NET,您需要使用您的 Windows 图像作为基础构建自己的 SDK 图像。这会增加一些额外的开销,但许多大型组织更喜欢这种方法,因为它可以让他们对基础图像有更好的控制。

使用商业注册表

运行自己的注册表不是拥有安全的私有图像存储库的唯一方法-您可以使用几种第三方提供的选择。在实践中,它们都以相同的方式工作-您需要使用注册表域标记您的图像,并与注册表服务器进行身份验证。有几种可用的选项,最全面的选项来自 Docker,Inc.,他们为不同的服务级别提供了不同的产品。

Docker Hub

Docker Hub 是最广泛使用的公共容器注册表,在撰写本文时,平均每月超过 10 亿次图像拉取。您可以在 Hub 上托管无限数量的公共存储库,并支付订阅费以托管多个私有存储库。

Docker Hub 具有自动构建系统,因此您可以将镜像存储库链接到 GitHub 或 Bitbucket 中的源代码存储库,Docker 的服务器将根据存储库中的 Dockerfile 构建镜像,每当您推送更改时 - 这是一个简单而有效的托管持续集成CI)解决方案,特别是如果您使用可移植的多阶段 Dockerfile。

Hub 订阅适用于较小的项目或多个用户共同开发同一应用程序的团队。它具有授权框架,用户可以创建一个组织,该组织成为存储库中的帐户名,而不是个人用户的帐户名。可以授予多个用户对组织存储库的访问权限,这允许多个用户推送镜像。

Docker Hub 也是用于商业软件分发的注册表。它就像是面向服务器端应用程序的应用商店。如果您的公司生产商业软件,Docker Hub 可能是分发的一个不错选择。您可以以完全相同的方式构建和推送镜像,但您的源代码可以保持私有 - 只有打包的应用程序是公开可用的。

您可以在 Docker 上注册为已验证的发布者,以确定有一个商业实体在维护这些镜像。Docker Hub 允许您筛选已验证的发布者,因此这是一个让您的应用程序获得可见性的好方法:

Docker Hub 还有一个认证流程,适用于托管在 Docker Hub 上的镜像。Docker 认证适用于软件镜像和硬件堆栈。如果您的镜像经过认证,它将保证在任何经过认证的硬件上都可以在 Docker Enterprise 上运行。Docker 在认证过程中测试所有这些组合,这种端到端的保证对大型企业非常有吸引力。

Docker Trusted Registry

Docker Trusted RegistryDTR)是 Docker Enterprise 套件的一部分,这是 Docker 公司提供的企业级容器即服务CaaS)平台。它旨在为在其自己的数据中心或任何云中运行 Docker 主机集群的企业提供服务。Docker Enterprise 配备了一个名为Universal Control PlaneUCP)的全面管理套件,该套件提供了一个界面,用于管理 Docker 集群中的所有资源 - 主机服务器、镜像、容器、网络、卷以及其他所有内容。Docker Enterprise 还提供了 DTR,这是一个安全、可扩展的镜像注册表。

DTR 通过 HTTPS 运行,并且是一个集群化服务,因此您可以在集群中部署多个注册表服务器以实现可伸缩性和故障转移。您可以使用本地存储或云存储来存储 DTR,因此如果在 Azure 中运行,则可以将图像持久保存在具有实际无限容量的 Azure 存储中。与 Docker Hub 一样,您可以为共享存储库创建组织,但是使用 DTR,您可以通过创建自己的用户帐户或插入到轻量级目录访问协议LDAP)服务(如 Active Directory)来管理身份验证。然后,您可以为细粒度权限配置基于角色的访问控制。

DTR 还提供安全扫描功能,该功能扫描图像内部的二进制文件,以检查已知的漏洞。您可以配置扫描以在推送图像时运行,或构建一个计划。计划扫描可以在发现旧图像的依赖项中发现新漏洞时向您发出警报。DTR UI 允许您深入了解漏洞的详细信息,并查看确切的文件和确切的利用方式。

Docker Enterprise 还有一个主要的安全功能,内容信任,这仅在 Docker Enterprise 中可用。Docker 内容信任允许用户对图像进行数字签名,以捕获批准工作流程 - 因此 QA 和安全团队可以通过他们的测试套件运行图像版本并对其进行签名,以确认他们批准了用于生产的发布候选版本。这些签名存储在 DTR 中。UCP 可以配置为仅运行由某些团队签名的图像,因此您可以对集群将运行的软件进行严格控制,并提供证明谁构建和批准软件的审计跟踪。

Docker Enterprise 具有丰富的功能套件,可以通过友好的 Web UI 以及通常的 Docker 命令行访问。安全性,可靠性和可扩展性是功能集中的主要因素,这使其成为企业用户寻找管理图像,容器和 Docker 主机的标准方式的不错选择。我将在第八章中介绍 UCP,管理和监控 Docker 化解决方案,以及在第九章中介绍 DTR,了解 Docker 的安全风险和好处

如果您想在无需设置要求的沙箱环境中尝试 Docker Enterprise,请浏览trial.docker.com以获取一个可用于 12 小时的托管试用版。

其他注册表

Docker 现在非常受欢迎,许多第三方服务已经将图像注册表添加到其现有的服务中。在云端,您可以使用来自亚马逊网络服务(AWS)的 EC2 容器注册表(ECR),微软的 Azure 容器注册表,以及谷歌云平台上的容器注册表。所有这些服务都与标准的 Docker 命令行和各自平台的其他产品集成,因此如果您在某个云服务提供商中有大量投资,它们可能是很好的选择。

还有一些独立的注册表服务,包括 JFrog 的 Artifactory 和 Quay.io,这些都是托管服务。使用托管注册表可以减少运行自己的注册表服务器的管理开销,如果您已经在使用来自提供商的服务,并且该提供商还提供注册表,则评估该选项是有意义的。

所有的注册表提供商都有不同的功能集和服务水平 - 您应该比较它们的服务,并且最重要的是,检查 Windows 支持的水平。大多数现有的平台最初是为了支持 Linux 图像和 Linux 客户端而构建的,对于 Windows 可能没有功能平衡。

总结

在本章中,您已经了解了图像注册表的功能以及如何使用 Docker 与之配合工作。我介绍了仓库名称和图像标记,以识别应用程序版本或平台变化,以及如何运行和使用本地注册表服务器 - 通过在容器中运行一个。

在 Docker 的旅程中,您很可能会很早就开始使用私有注册表。当您开始将现有应用程序 Docker 化并尝试新的软件堆栈时,通过快速的本地网络推送和拉取图像可能会很有用 - 或者如果本地存储空间有问题,可以使用云服务。随着您对 Docker 的使用越来越多,并逐步实施生产,您可能会计划升级到具有丰富安全功能的受支持的注册表 DTR。

现在您已经很好地了解了如何共享图像并使用其他人共享的图像,您可以考虑使用容器优先的解决方案设计,将经过验证和可信赖的软件组件引入我们自己的应用程序中。

第二部分:设计和构建容器化解决方案

Docker 让您以新的方式设计和构建应用程序。在本节中,您将学习如何使用容器来思考应用程序架构,以及如何使用 Docker 来运行分布式应用程序。

本节包括以下章节:

  • 第五章,采用容器优先解决方案设计

  • 第六章,使用 Docker Compose 组织分布式解决方案

  • 第七章,使用 Docker Swarm 编排分布式解决方案

第五章:采用容器优先解决方案设计

将 Docker 作为应用程序平台带来明显的运营优势。容器比虚拟机更轻,但仍提供隔离,因此您可以在更少的硬件上运行更多的工作负载。所有这些工作负载在 Docker 中具有相同的形状,因此运维团队可以以相同的方式管理.NET、Java、Go 和 Node.js 应用程序。Docker 平台在应用程序架构方面也有好处。在本章中,我将探讨容器优先解决方案设计如何帮助您向应用程序添加功能,具有高质量和低风险。

在本章中,我将回到 NerdDinner,从我在第三章中离开的地方继续。NerdDinner 是一个传统的.NET 应用程序,是一个单片设计,组件之间耦合紧密,所有通信都是同步的。没有单元测试、集成测试或端到端测试。NerdDinner 就像其他数百万个.NET 应用程序一样——它可能具有用户需要的功能,但修改起来困难且危险。将这样的应用程序移至 Docker 可以让您采取不同的方法来修改或添加功能。

Docker 平台的两个方面将改变您对解决方案设计的思考方式。首先,网络、服务发现和负载平衡意味着您可以将应用程序分布到多个组件中,每个组件都在容器中运行,可以独立移动、扩展和升级。其次,Docker Hub 和其他注册表上可用的生产级软件范围不断扩大,这意味着您可以为许多通用服务使用现成的软件,并以与自己的组件相同的方式管理它们。这使您有自由设计更好的解决方案,而不受基础设施或技术限制。

在本章中,我将向您展示如何通过采用容器优先设计来现代化传统的.NET 应用程序:

  • NerdDinner 的设计目标

  • 在 Docker 中运行消息队列

  • 开始多容器解决方案

  • 现代化遗留应用程序

  • 在容器中添加新功能

  • 从单体到分布式解决方案

技术要求

要跟着示例进行操作,您需要在 Windows 10 上运行 Docker,并更新到 18.09 版,或者在 Windows Server 2019 上运行。本章的代码可在github.com/sixeyed/docker-on-windows/tree/second-edition/ch05上找到。

NerdDinner 的设计目标

在第三章中,开发 Docker 化的.NET Framework 和.NET Core 应用程序,我将 NerdDinner 首页提取到一个单独的组件中,这样可以快速交付 UI 更改。现在我要做一些更根本的改变,分解传统的应用程序并现代化架构。

我将首先查看 Web 应用程序中的性能问题。NerdDinner 中的数据层使用Entity FrameworkEF),所有数据库访问都是同步的。网站的大量流量将创建大量打开的连接到 SQL Server 并运行大量查询。随着负载的增加,性能将恶化,直到查询超时或连接池被耗尽,网站将向用户显示错误。

改进的一种方式是使所有数据访问方法都是async,但这是一种侵入性的改变——所有控制器操作也需要变成async,而且没有自动化的测试套件来验证这样一系列的改变。另外,我可以添加一个用于数据检索的缓存,这样GET请求将命中缓存而不是数据库。这也是一个复杂的改变,我需要让缓存数据保持足够长的时间,以便缓存命中的可能性较大,同时在数据更改时保持缓存同步。同样,缺乏测试意味着这样的复杂改变很难验证,因此这也是一种风险的方法。

如果我实施这些复杂的改变,很难估计好处。如果所有数据访问都转移到异步方法,这会使网站运行更快,并使其能够处理更多的流量吗?如果我可以集成一个高效的缓存,使读取数据从数据库中移开,这会提高整体性能吗?这些好处很难量化,直到你实际进行了改变,当你可能会发现改进并不能证明投资的价值。

采用以容器为先的方法,可以以不同的方式来看待设计。如果您确定了一个功能,它会进行昂贵的数据库调用,但不需要同步运行,您可以将数据库代码移动到一个单独的组件中。然后,您可以在组件之间使用异步消息传递,从主 Web 应用程序发布事件到消息队列,并在新组件中对事件消息进行操作。使用 Docker,这些组件中的每一个都将在一个或多个容器中运行:

如果我只专注于一个功能,那么我可以快速实现变化。这种设计没有其他方法的缺点,并且有许多好处:

  • 这是一个有针对性的变化,只有一个控制器动作在主应用程序中发生了变化

  • 新的消息处理程序组件小而高度内聚,因此很容易进行测试

  • Web 层和数据层是解耦的,因此它们可以独立扩展

  • 我正在将工作从 Web 应用程序中移出,这样我们就可以确保性能得到改善

还有其他优点。新组件完全独立于原始应用程序;它只需要监听事件消息并对其进行操作。您可以使用.NET、.NET Core 或任何其他技术堆栈来处理消息;您不需要受限于单一堆栈。您还可以通过添加监听这些事件的新处理程序,以后添加其他功能。

Docker 化 NerdDinner 的配置

NerdDinner 使用Web.config进行配置 - 既用于应用程序配置值(在发布之间保持不变)又用于在不同环境之间变化的环境配置值。配置文件被嵌入到发布包中,这使得更改变得尴尬。在第三章中,开发 Docker 化的.NET Framework 和.NET Core 应用程序,我将Web.config中的appSettingsconnectionStrings部分拆分成单独的文件;这样做可以让我运行一个包含不同配置文件的容器,通过附加包含不同配置文件的卷。

不过,有不同类型的配置,而挂载卷对开发人员来说是一个相当沉重的选项。对于您希望在不更改代码的情况下切换的功能设置来说是很好的——像UnobtrusiveJavaScriptEnabled这样的设置应该放在配置文件中。但是对于每个环境和每个开发人员都会更改的设置——比如BingMapsKey——应该有一种更简单的设置方式。

理想情况下,您希望有多层配置,可以从文件中读取,但也可以使用环境变量覆盖值。这就是.NET Core 中配置系统的工作方式,因为.NET Core 中的配置包实际上是.NET Standard 库,它们也可以用于经典的.NET Framework 项目。

为了迎接即将到来的更大变化,我已经更新了本章的代码,使用.NET Core 配置模型来设置所有环境配置,如下所示。之前的文件appSettings.configconnectionStrings.config已经迁移到新的 JSON 配置样式appsettings.json中:

{
  "Homepage": {
    "Url": "http://nerd-dinner-hompage"
  },
  "ConnectionStrings": {
    "UsersContext": "Data Source=nerd-dinner-db...",
    "NerdDinnerContext": "Data Source=nerd-dinner-db..."
  },
  "Apis": {    
    "IpInfoDb": {
      "Key": ""
    },
    "BingMaps": {
      "Key": ""
    }      
  }
}

JSON 格式更易于阅读,因为它包含嵌套对象,您可以将类似的设置分组在一起,我已经在Apis对象中这样做了。我可以通过访问当前配置对象的Apis:BingMaps:Key键在我的代码中获取 Bing Maps API 密钥。我仍然将配置文件存储在一个单独的目录中,所以我可以使用卷来覆盖整个文件,但我也设置了配置来使用环境变量。这意味着如果设置了一个名为Apis:BingMaps:Key的环境变量,那么该变量的值将覆盖 JSON 文件中的值。在我的代码中,我只需引用配置键,而在运行时,.NET Core 会从环境变量或配置文件中获取它。

这种方法让我可以在 JSON 文件中为数据库连接字符串使用默认值,这样当开发人员启动数据库和 Web 容器时,应用程序就可以使用,而无需指定任何环境变量。不过,该应用程序并非 100%功能完善,因为 Bing Maps 和 IP 地理位置服务需要 API 密钥。这些是有速率限制的服务,因此您可能需要为每个开发人员和每个环境设置不同的密钥,这可以在 Web 容器中使用环境变量来设置。

为了使环境值更安全,Docker 允许您从文件中加载它们,而不是在docker container run命令中以纯文本指定它们。将值隔离在文件中意味着文件本身可以被保护,只有管理员和 Docker 服务帐户才能访问它。环境文件是一个简单的文本格式,每个环境变量写成键值对的一行。对于 web 容器,我的环境文件包含了秘密 API 密钥:

Apis:BingMaps:Key=[your-key-here]
Apis:IpInfoDb:Key=[your-key-here]

要运行容器并将文件内容加载为环境变量,您可以使用--env-file选项。

环境值仍然不安全。如果有人获得了对您的应用程序的访问权限,他们可以打印出所有的环境变量并获取您的 API 密钥。我正在使用 JSON 文件以及环境变量的方法意味着我可以在生产中使用相同的应用程序镜像,使用 Docker secrets 进行配置 - 这是安全的。

我已经将这些更改打包到了 NerdDinner Docker 镜像的新版本中,您可以在dockeronwindows/ch05-nerd-dinner-web:2e找到。与第三章中的其他示例一样,《开发 Docker 化的.NET Framework 和.NET Core 应用程序》,Dockerfile 使用引导脚本作为入口点,将环境变量提升到机器级别,以便 ASP.NET 应用程序可以读取它们。

NerdDinner 网站的新版本在 Docker 中运行的命令是:

docker container run -d -P `
 --name nerd-dinner-web `
 --env-file api-keys.env `
 dockeronwindows/ch05-nerd-dinner-web:2e

应用程序需要其他组件才能正确启动。我有一个 PowerShell 脚本,它以正确的顺序和选项启动容器,但到本章结束时,这个脚本将变得笨拙。在下一章中,当我研究 Docker Compose 时,我会解决这个问题。

拆分创建晚餐功能

DinnerController类中,Create操作是一个相对昂贵的数据库操作,不需要是同步的。这个特性很适合拆分成一个单独的组件。我可以从 web 应用程序发布消息,而不是在用户等待时将其保存到数据库中 - 如果网站负载很高,消息可能会在队列中等待几秒甚至几分钟才能被处理,但对用户的响应几乎是即时的。

有两件工作需要做,将该功能拆分为一个新组件。Web 应用程序需要在创建晚餐时向队列发布消息,消息处理程序需要在队列上监听并在接收到消息时保存晚餐。在 NerdDinner 中,还有更多的工作要做,因为现有的代码库既是物理单体,也是逻辑单体——只有一个包含所有内容的 Visual Studio 项目,所有的模型定义以及 UI 代码。

在本章的源代码中,我添加了一个名为NerdDinner.Model的新的.NET 程序集项目到解决方案中,并将 EF 类移动到该项目中,以便它们可以在 Web 应用程序和消息处理程序之间共享。模型项目针对完整的.NET Framework 而不是.NET Core,所以我可以直接使用现有的代码,而不需要为了这个功能更改而引入 EF 的升级。这个选择也限制了消息处理程序也必须是一个完整的.NET Framework 应用程序。

还有一个共享的程序集项目来隔离NerdDinner.Messaging中的消息队列代码。我将使用 NATS 消息系统,这是一个高性能的开源消息队列。NuGet 上有一个针对.NET Standard 的 NATS 客户端包,所以它可以在.NET Framework 和.NET Core 中使用,我的消息项目也有相同的客户端包。这意味着我可以灵活地编写不使用 EF 模型的其他消息处理程序,可以使用.NET Core。

在模型项目中,Dinner类的原始定义被大量的 EF 和 MVC 代码污染,以捕获验证和存储行为,比如Description属性的以下定义:

[Required(ErrorMessage = "Description is required")]
[StringLength(256, ErrorMessage = "Description may not be longer than 256 characters")]
[DataType(DataType.MultilineText)]
public string Description { get; set; }

这个类应该是一个简单的 POCO 定义,但是这些属性意味着模型定义不具有可移植性,因为任何消费者也需要引用 EF 和 MVC。为了避免这种情况,在消息项目中,我定义了一个简单的Dinner实体,没有任何这些属性,这个类是我用来在消息中发送晚餐信息的。我可以使用AutoMapper NuGet 包在Dinner类定义之间进行转换,因为属性基本上是相同的。

这是你会在许多旧项目中找到的挑战类型 - 没有明确的关注点分离,因此分解功能并不简单。您可以采取这种方法,将共享组件隔离到新的库项目中。这样重构代码库,而不会从根本上改变其逻辑,这将有助于现代化应用程序。

DinnersController类的Create方法中的主要代码现在将晚餐模型映射到干净的Dinner实体,并发布事件,而不是写入数据库:

if (ModelState.IsValid)
{
  dinner.HostedBy = User.Identity.Name;
  var eventMessage = new DinnerCreatedEvent
  {
    Dinner = Mapper.Map<entities.Dinner>(dinner),
    CreatedAt = DateTime.UtcNow
  };
  MessageQueue.Publish(eventMessage);
  return RedirectToAction("Index");
}

这是一种“发出即忘记”的消息模式。Web 应用程序是生产者,发布事件消息。生产者不等待响应,也不知道哪些组件 - 如果有的话 - 将消耗消息并对其进行操作。它松散耦合且快速,并且将传递消息的责任放在消息队列上,这正是应该的地方。

监听此事件消息的是一个新的.NET Framework 控制台项目,位于NerdDinner.MessageHandlers.CreateDinner中。控制台应用程序的Main方法使用共享的消息项目打开与消息队列的连接,并订阅这些创建晚餐事件消息。当接收到消息时,处理程序将消息中的Dinner实体映射回晚餐模型,并使用从DinnersController类中原始实现中取出的代码将模型保存到数据库中(并进行了一些整理):

var dinner = Mapper.Map<models.Dinner>(eventMessage.Dinner);
using (var db = new NerdDinnerContext())
{
  dinner.RSVPs = new List<RSVP>
  {
    new RSVP
    {
      AttendeeName = dinner.HostedBy
    }
  };
  db.Dinners.Add(dinner);
  db.SaveChanges();
}

现在,消息处理程序可以打包到自己的 Docker 镜像中,并在网站容器旁边的容器中运行。

在 Docker 中打包.NET 控制台应用程序

控制台应用程序很容易构建为 Docker 的良好组件。应用程序的编译可执行文件将是 Docker 启动和监视的主要进程,因此您只需要利用控制台进行日志记录,并且可以使用文件和环境变量进行配置。

对于我的消息处理程序,我正在使用一个稍有不同模式的 Dockerfile。我有一个单独的镜像用于构建阶段,我用它来编译整个解决方案 - 包括 Web 项目和我添加的新项目。一旦您看到所有新组件,我将在本章后面详细介绍构建者镜像。

构建者编译解决方案,控制台应用程序的 Dockerfile 引用dockeronwindows/ch05-nerd-dinner-builder:2e镜像以复制编译的二进制文件。整个 Dockerfile 非常简单:

# escape=` FROM mcr.microsoft.com/windows/servercore:ltsc2019 CMD ["NerdDinner.MessageHandlers.SaveDinner.exe"]

WORKDIR C:\save-handler
COPY --from=dockeronwindows/ch05-nerd-dinner-builder:2e `
     C:\src\NerdDinner.MessageHandlers.SaveDinner\obj\Release\ . 

COPY指令中的from参数指定文件的来源。它可以是多阶段构建中的另一个阶段,或者—就像在这个例子中—本地机器或注册表中的现有镜像。

新的消息处理程序需要访问消息队列和数据库,每个连接字符串都在项目的appsettings.json文件中。控制台应用程序使用与 NerdDinner web 应用程序相同的Config类,该类从 JSON 文件加载默认值,并可以从环境变量中覆盖它们。

在 Dockerfile 中,CMD指令中的入口点是控制台可执行文件,因此只要控制台应用程序在运行,容器就会保持运行。消息队列的监听器在单独的线程上异步运行到主应用程序。当收到消息时,处理程序代码将触发,因此不需要轮询队列,应用程序运行非常高效。

使用ManualResetEvent对象可以简单地使控制台应用程序无限期地保持运行。在Main方法中,我等待一个永远不会发生的重置事件,因此程序会继续运行:

class Program
{
  private static ManualResetEvent _ResetEvent = new ManualResetEvent(false);

  static void Main(string[] args)
  {
    // set up message listener
    _ResetEvent.WaitOne();
  }
}

这是保持.NET Framework 或.NET Core 控制台应用程序保持活动状态的一种简单有效的方法。当我启动一个消息处理程序容器时,它将在后台保持运行并监听消息,直到容器停止。

在 Docker 中运行消息队列

现在 Web 应用程序发布消息,处理程序监听这些消息,因此我需要的最后一个组件是一个消息队列来连接这两者。队列需要与解决方案的其余部分具有相同的可用性水平,因此它们是在 Docker 容器中运行的良好候选项。在部署在许多服务器上的分布式解决方案中,队列可以跨多个容器进行集群,以提高性能和冗余性。

您选择的消息传递技术取决于您需要的功能,但在.NET 客户端库中有很多选择。Microsoft Message QueueMSMQ)是本机 Windows 队列,RabbitMQ是一个流行的开源队列,支持持久化消息,NATS是一个开源的内存队列,性能非常高。

NATS 消息传递的高吞吐量和低延迟使其成为在容器之间通信的良好选择,并且在 Docker Hub 上有一个官方的 NATS 镜像。NATS 是一个跨平台的 Go 应用程序,Docker 镜像有 Linux、Windows Server Core 和 Nano Server 的变体。

在撰写本文时,NATS 团队仅在 Docker Hub 上发布了 Windows Server 2016 的镜像。很快将有 Windows Server 2019 镜像,但我已经为本章构建了自己的镜像。查看dockeronwindows/ch05-nats:2e的 Dockerfile,您将看到如何轻松地在自己的镜像中使用官方镜像的内容。

您可以像运行其他容器一样运行 NATS 消息队列。Docker 镜像公开了端口4222,这是客户端用来连接队列的端口,但除非您想要在 Docker 容器外部发送消息到 NATS,否则您不需要发布该端口。同一网络中的容器始终可以访问彼此的端口,它们只需要被发布以使它们在 Docker 外部可用。NerdDinner Web 应用程序和消息处理程序正在使用服务器名称message-queue来连接 NATS,因此需要使用该容器名称:

docker container run --detach `
 --name message-queue `
 dockeronwindows/ch05-nats:2e

NATS 服务器应用程序将消息记录到控制台,以便 Docker 收集日志条目。当容器正在运行时,您可以使用docker container logs来验证队列是否正在监听:

> docker container logs message-queue
[7996] 2019/02/09 15:40:05.857320 [INF] Starting nats-server version 1.4.1
[7996] 2019/02/09 15:40:05.858318 [INF] Git commit [3e64f0b]
[7996] 2019/02/09 15:40:05.859317 [INF] Starting http monitor on 0.0.0.0:8222
[7996] 2019/02/09 15:40:05.859317 [INF] Listening for client connections on 0.0.0.0:4222
[7996] 2019/02/09 15:40:05.859317 [INF] Server is ready
[7996] 2019/02/09 15:40:05.948151 [INF] Listening for route connections on 0.0.0.0:6222

消息队列是一个基础架构级组件,不依赖于其他组件。它可以在其他容器之前启动,并且在应用程序容器停止或升级时保持运行。

启动多容器解决方案

随着您对 Docker 的更多使用,您的解决方案将分布在更多的容器中 - 无论是运行自己从单体中拆分出来的自定义代码,还是来自 Docker Hub 或第三方注册表的可靠的第三方软件。

NerdDinner 现在跨越了五个容器运行 - SQL Server,原始 Web 应用程序,新的主页,NATS 消息队列和消息处理程序。容器之间存在依赖关系,它们需要以正确的顺序启动并使用正确的名称创建,以便组件可以使用 Docker 的服务发现找到它们。

在下一章中,我将使用 Docker Compose 来声明性地映射这些依赖关系。目前,我有一个名为ch05-run-nerd-dinner_part-1.ps1的 PowerShell 脚本,它明确地使用正确的配置启动容器:

docker container run -d `
  --name message-queue `
 dockeronwindows/ch05-nats:2e;

docker container run -d -p 1433  `
  --name nerd-dinner-db `
  -v C:\databases\nd:C:\data  `
 dockeronwindows/ch03-nerd-dinner-db:2e; docker container run -d `
  --name nerd-dinner-save-handler  `
 dockeronwindows/ch05-nerd-dinner-save-handler:2e; docker container run -d `
  --name nerd-dinner-homepage `
 dockeronwindows/ch03-nerd-dinner-homepage:2e; docker container run -d -p 80  `
  --name nerd-dinner-web `
  --env-file api-keys.env `
 dockeronwindows/ch05-nerd-dinner-web:2e;

在这个脚本中,我正在使用第三章中的 SQL 数据库和主页图像,开发 Docker 化的.NET Framework 和.NET Core 应用程序——这些组件没有改变,所以它们可以与新组件一起运行。如果您想要自己运行具有完整功能的应用程序,您需要在文件api-keys.env中填写自己的 API 密钥。您需要注册 Bing Maps API 和 IP 信息数据库。您可以在没有这些密钥的情况下运行应用程序,但不是所有功能都会正常工作。

当我使用自己设置的 API 密钥运行脚本并检查 Web 容器以获取端口时,我可以浏览应用程序。现在,NerdDinner 是一个功能齐全的版本。我可以登录并完成创建晚餐表单,包括地图集成:

当我提交表单时,Web 应用程序会向队列发布事件消息。这是一个非常廉价的操作,所以 Web 应用程序几乎立即返回给用户。控制台应用程序在监听消息,它运行在不同的容器中——可能在不同的主机上。它接收消息并处理它。处理程序将活动记录到控制台,以便管理员用户可以使用docker container logs来监视它:

> docker container logs nerd-dinner-save-handler

Connecting to message queue url: nats://message-queue:4222
Listening on subject: events.dinner.created, queue: save-dinner-handler
Received message, subject: events.dinner.created
Saving new dinner, created at: 2/10/2019 8:22:16 PM; event ID: a6340c95-3629-4c0c-9a11-8a0bce8e6d91
Dinner saved. Dinner ID: 1; event ID: a6340c95-3629-4c0c-9a11-8a0bce8e6d91

创建晚餐功能的功能是相同的——用户输入的数据保存到 SQL Server——用户体验也是相同的,但是这个功能的可扩展性得到了极大的改善。为容器设计让我可以将持久性代码提取到一个新的组件中,知道该组件可以部署在与现有解决方案相同的基础设施上,并且如果应用程序部署在集群上,它将继承现有的可扩展性和故障转移级别。

我可以依赖 Docker 平台并依赖一个新的核心组件:消息队列。队列技术本身是企业级软件,能够每秒处理数十万条消息。NATS 是免费的开源软件,可以直接在 Docker Hub 上使用,作为一个容器运行并连接到 Docker 网络中的其他容器。

到目前为止,我已经使用了以容器为先的设计和 Docker 的强大功能来现代化 NerdDinner 的一部分。针对单个功能意味着我可以在仅测试已更改的功能后,自信地发布这个新版本。如果我想要为创建晚餐功能添加审计,我只需更新消息处理程序,而不需要对 Web 应用进行完整的回归测试,因为该组件不会被更新。

以容器为先的设计也为我提供了一个基础,可以用来现代化传统应用程序的架构并添加新功能。

现代化传统应用程序

将后端功能拆分是开始分解传统单体应用的好方法。将消息队列添加到部署中,使其成为一种模式,您可以重复使用任何受益于异步的功能。还有其他分解单体应用的模式。如果我们暴露一个 REST API 并将前端移动到模块化 UI,并使用反向代理在不同组件之间进行路由,我们就可以真正开始现代化 NerdDinner。我们可以用 Docker 做到这一切。

添加 REST API 以公开数据

传统应用程序通常最终成为无法在应用程序外部访问的数据存储。如果这些数据可以访问,它们对其他应用程序或业务合作伙伴将非常有价值。NerdDinner 是一个很好的例子——它是在单页面应用程序时代之前设计和构建的,其中 UI 逻辑与业务逻辑分离,并通过 REST API 公开。NerdDinner 保留其数据;除非通过 NerdDinner UI,否则无法查看晚餐列表。

在 Docker 容器中运行一个简单的 REST API 可以轻松解锁传统数据。它不需要复杂的交付:您可以首先识别传统应用程序中有用于其他业务部门或外部消费者的单个数据集。然后,将该数据集的加载逻辑简单提取到一个单独的功能中,并将其部署为只读 API。当有需求时,您可以逐步向 API 添加更多功能,无需在第一个发布中实现整个服务目录。

NerdDinner 的主要数据集是晚餐列表,我已经构建了一个 ASP.NET Core REST API 来在只读的GET请求中公开所有的晚餐。这一章的代码在NerdDinner.DinnerApi项目中,它是一个非常简单的实现。因为我已经将核心实体定义从主NerdDinner项目中拆分出来,所以我可以从 API 中公开现有的契约,并在项目内使用任何我喜欢的数据访问技术。

我选择使用 Dapper,它是一个为.NET Standard 构建的快速直观的对象关系映射器,因此它可以与.NET Framework 和.NET Core 应用程序一起使用。Dapper 使用基于约定的映射;你提供一个 SQL 语句和一个目标类类型,它执行数据库查询并将结果映射到对象。从现有表中加载晚餐数据并将其映射到共享的Dinner对象的代码非常简单。

protected  override  string  GetAllSqlQuery  =>  "SELECT *, Location.Lat as Latitude... FROM Dinners"; public  override  IEnumerable<Dinner> GetAll()
{ _logger.LogDebug("GetAll - executing SQL query: '{0}'", GetAllSqlQuery); using (IDbConnection  dbConnection  =  Connection)
  { dbConnection.Open(); return  dbConnection.Query<Dinner, Coordinates, Dinner>( GetAllSqlQuery, 
      (dinner,coordinates) => { dinner.Coordinates  =  coordinates; return  dinner;
      }, splitOn: "LocationId");
   }
}

在 API 控制器类中调用了GetAll方法,其余的代码是通常的 ASP.NET Core 设置。

Dapper 通常比这个例子更容易使用,但当你需要时它可以让你进行一些手动映射,这就是我在这里所做的。NerdDinner 使用 SQL Server 位置数据类型来存储晚餐的举办地点。这映射到.NET 的DbGeography类型,但这种类型在.NET Standard 中不存在。如果你浏览第五章中的代码,你会看到我在几个地方映射了DbGeography和我的自定义Coordinates类型,如果你遇到类似的问题,你就需要这样做。

我已经修改了原始的 NerdDinner web 应用程序,使其在DinnersController类中获取晚餐列表时使用这个新的 API。我通过配置设置DinnerApi:Enabled使用了一个功能标志,这样应用程序可以使用 API 作为数据源,或直接从数据库查询。这让我可以分阶段地推出这个功能:

if (bool.Parse(Config.Current["DinnerApi:Enabled"]))
{
  var  client  =  new  RestClient(Config.Current["DinnerApi:Url"]);
  var  request  =  new  RestRequest("dinners");
  var  response  =  client.Execute<List<Dinner>>(request);
  var  dinners  =  response.Data.Where(d  =>  d.EventDate  >=  DateTime.Now).OrderBy(d  =>  d.EventDate);
  return  View(dinners.ToPagedList(pageIndex, PageSize)); } else {
  var  dinners  =  db.Dinners.Where(d  =>  d.EventDate  >=  DateTime.Now).OrderBy(d  =>  d.EventDate);
  return  View(dinners.ToPagedList(pageIndex, PageSize)); }

新的 API 被打包到名为dockeronwindows/ch05-nerd-dinner-api的 Docker 镜像中。这个 Dockerfile 非常简单;它只是从名为microsoft/dotnet:2.1-aspnetcore-runtime-nanoserver-1809的官方 ASP.NET Core 基础镜像开始,并复制编译后的 API 代码进去。

我可以在 Docker 容器中运行 API 作为内部组件,由 NerdDinner web 容器使用,但不对外公开,或者我可以在 API 容器上发布一个端口,并使其在 Docker 网络之外可用。对于公共 REST API 来说,使用自定义端口是不寻常的,消费者期望在端口80上访问 HTTP 和端口443上访问 HTTPS。我可以向我的解决方案添加一个组件,让我可以为所有服务使用标准端口集,并将传入的请求路由到不同的容器中——这就是所谓的反向代理

使用反向代理在容器之间路由 HTTP 请求

反向代理是一个非常有用的技术,无论您是在考虑构建新的微服务架构还是现代化传统的单体架构。反向代理只是一个 HTTP 服务器,它接收来自外部世界的所有传入网络流量,从另一个 HTTP 服务器获取内容,并将其返回给客户端。在 Docker 中,反向代理在一个带有发布端口的容器中运行,并代理来自其他没有发布端口的容器的流量。

这是 UI 和 API 容器的架构,反向代理已经就位:

所有传入流量的路由规则都在代理容器中。它将被配置为从nerd-dinner-homepage容器加载主页位置/的请求;以路径/api开头的请求将从nerd-dinner-api容器加载,而其他任何请求将从nerd-dinner-web容器中的原始应用加载。

重要的是要意识到代理不会将客户端重定向到其他服务。代理是客户端连接的唯一端点。代理代表客户端向实际服务发出 HTTP 请求,使用容器的主机名。

反向代理不仅可以路由请求。所有流量都通过反向代理,因此它可以是应用 SSL 终止和 HTTP 缓存的层。您甚至可以在反向代理中构建安全性,将其用于身份验证和作为 Web 应用程序防火墙,保护您免受常见攻击,如 SQL 注入。这对于传统应用程序尤其有吸引力。您可以在代理层中进行性能和安全改进,将原始应用程序作为容器中的内部组件,除非通过代理,否则无法访问。

反向代理有许多技术选项。Nginx 和 HAProxy 是 Linux 世界中受欢迎的选项,它们也可以在 Windows 容器中使用。您甚至可以将 IIS 实现为反向代理,将其运行在一个单独的容器中,并使用 URL 重写模块设置所有路由规则。这些选项功能强大,但需要相当多的配置才能运行起来。我将使用一个名为 Traefik 的反向代理,它是专为在云原生应用程序中运行的容器而构建的,并且它从 Docker 中获取所需的配置。

使用 Traefik 代理来自 Docker 容器的流量

Traefik 是一个快速、强大且易于使用的反向代理。您可以在一个容器中运行它,并发布 HTTP(或 HTTPS)端口,并配置容器以侦听来自 Docker Engine API 的事件:

docker container run -d -P  `
  --volume \\.\pipe\docker_engine:\\.\pipe\docker_engine `
 sixeyed/traefik:v1.7.8-windowsservercore-ltsc2019 `
  --docker --docker.endpoint=npipe:////./pipe/docker_engine

Traefik 是 Docker Hub 上的官方镜像,但与 NATS 一样,唯一可用的 Windows 镜像是基于 Windows Server 2016 的。我在这里使用自己的镜像,基于 Windows Server 2019。Dockerfile 在我的 GitHub 上的sixeyed/dockerfiles-windows存储库中,但在使用我的镜像之前,您应该检查 Docker Hub,看看官方 Traefik 镜像是否有 2019 变体。

您之前见过volume选项-它用于将主机上的文件系统目录挂载到容器中。在这里,我使用它来挂载一个名为docker_engine的 Windows命名管道。管道是客户端-服务器通信的一种网络方法。Docker CLI 和 Docker API 支持 TCP/IP 和命名管道上的连接。像这样挂载一个管道让容器可以查询 Docker API,而无需知道容器运行的主机的 IP 地址。

Traefik 通过命名管道连接订阅来自 Docker API 的事件流,使用docker.endpoint选项中的连接详细信息。当容器被创建或移除时,Traefik 将从 Docker 那里收到通知,并使用这些事件中的数据来构建自己的路由映射。

当您运行 Traefik 时,您可以使用标签创建应用程序容器,告诉 Traefik 应该将哪些请求路由到哪些容器。标签只是在创建容器时可以应用的键值对。它们会在来自 Docker 的事件流中显示。Traefik 使用带有前缀traefik.frontend的标签来构建其路由规则。这就是我如何通过 Traefik 运行具有路由的 API 容器:

docker container run -d `
  --name nerd-dinner-api `
  -l "traefik.frontend.rule=Host:api.nerddinner.local"  `  dockeronwindows/ch05-nerd-dinner-api:2e;

Docker 创建名为nerd-dinner-api的容器,然后发布一个包含新容器详细信息的事件。Traefik 接收到该事件后,会在其路由映射中添加一条规则。任何进入 Traefik 的带有 HTTP Host 头部api.nerddinner.local的请求都将从 API 容器中进行代理。API 容器不会发布任何端口 - 反向代理是唯一可公开访问的组件。

Traefik 具有非常丰富的路由规则集,可以使用 HTTP 请求的不同部分 - 主机、路径、标头和查询字符串。您可以使用 Traefik 的规则将任何内容从通配符字符串映射到非常具体的 URL。Traefik 还可以执行更多操作,如负载平衡和 SSL 终止。文档可以在traefik.io找到。

使用类似的规则,我可以部署 NerdDinner 的新版本,并让所有前端容器都由 Traefik 进行代理。脚本ch05-run-nerd-dinner_part-2.ps1是一个升级版本,首先删除现有的 web 容器:

docker container rm -f nerd-dinner-homepage docker container rm -f nerd-dinner-web

标签和环境变量在容器创建时被应用,并在容器的生命周期内持续存在。您无法更改现有容器上的这些值;您需要将其删除并创建一个新的容器。我想要为 Traefik 运行 NerdDinner 网站和主页容器,并为其添加标签,因此我需要替换现有的容器。脚本的其余部分启动 Traefik,用新配置替换 web 容器,并启动 API 容器:

docker container run -d -p 80:80  `
  -v \\.\pipe\docker_engine:\\.\pipe\docker_engine `
 sixeyed/traefik:v1.7.8-windowsservercore-ltsc2019 `
  --api --docker --docker.endpoint=npipe:////./pipe/docker_engine  docker container run -d `
  --name nerd-dinner-homepage ` -l "traefik.frontend.rule=Path:/,/css/site.css"  `   -l "traefik.frontend.priority=10"  `
 dockeronwindows/ch03-nerd-dinner-homepage:2e;

docker container run -d `
  --name nerd-dinner-web `
  --env-file api-keys.env `
  -l "traefik.frontend.rule=PathPrefix:/"  `
  -l "traefik.frontend.priority=1"  `   -e "DinnerApi:Enabled=true"  `
 dockeronwindows/ch05-nerd-dinner-web:2e; docker container run -d `
  --name nerd-dinner-api ` -l "traefik.frontend.rule=PathPrefix:/api"  `
  -l "traefik.frontend.priority=5"  `
 dockeronwindows/ch05-nerd-dinner-api:2e;

现在当我加载 NerdDinner 网站时,我将浏览到端口80上的 Traefik 容器。我正在使用Host头路由规则,所以我会在浏览器中输入http://nerddinner.local。这是一个本地开发环境,所以我已经将这些值添加到了我的hosts文件中(在测试和生产环境中,将有一个真正的 DNS 系统解析主机名):

127.0.0.1  nerddinner.local
127.0.0.1  api.nerddinner.local

对于路径/的主页请求是从主页容器代理的,并且我还为 CSS 文件指定了一个路由路径,这样我就可以看到包含样式的新主页:

这个响应是由主页容器生成的,但是由 Traefik 代理。我可以浏览到api.nerddinner.local,并从新的 REST API 容器中以 JSON 格式看到所有晚宴的信息:

原始的 NerdDinner 应用程序仍然以相同的方式工作,但是当我浏览到/Dinners时,显示的晚宴列表是从 API 中获取的,而不是直接从数据库中获取的:

制定代理的路由规则是将单体应用程序分解为多个前端容器的较难部分之一。微服务应用程序在这方面往往更容易,因为它们被设计为在不同的域路径上运行的不同关注点。当您开始将 UI 功能路由到它们自己的容器时,您需要对 Traefik 的规则和正则表达式有很好的理解。

容器优先设计使我能够在不完全重写的情况下现代化 NerdDinner 的架构。我正在使用企业级开源软件和 Docker 来支持以下三种分解单体的模式:

  • 通过在消息队列上发布和订阅事件使功能异步化

  • 使用简单的现代技术栈通过 REST API 公开数据

  • 将前端功能拆分到多个容器中,并通过反向代理在它们之间进行路由

现在,我可以更加灵活地提供功能改进,因为我不总是需要对整个应用程序进行回归测试。我还有一些从关键用户活动中发布的事件,这是迈向事件驱动架构的一步。这让我可以在不更改任何现有代码的情况下添加全新的功能。

在容器中添加新功能

将单体架构分解为小组件并现代化架构具有有益的副作用。我采取的方法已经为一个功能引入了事件发布。我可以在此基础上构建新功能,再次采用以容器为先的方法。

在 NerdDinner 中,有一个单一的数据存储,即存储在 SQL Server 中的事务性数据库。这对于为网站提供服务是可以的,但在涉及用户界面功能(如报告)时有限。没有用户友好的方式来搜索数据,构建仪表板或启用自助式报告。

解决这个问题的理想方案是添加一个次要数据存储,即报告数据库,使用提供自助式分析的技术。如果没有 Docker,这将是一个重大项目,需要重新设计或额外的基础设施或两者兼而有之。有了 Docker,我可以让现有应用程序保持不变,并在现有服务器上运行容器中添加新功能。

Elasticsearch 是另一个企业级开源项目,可以作为 Docker Hub 上的官方镜像使用。Elasticsearch 是一个完整的搜索文档数据存储,作为报告数据库运行良好,还有伴随产品 Kibana,提供用户友好的 Web 前端。

我可以通过在与其他容器相同的网络中在容器中运行 Elasticsearch 和 Kibana,为 NerdDinner 中创建的晚餐添加自助式分析。当前解决方案已经发布了晚餐详情的事件,因此要将晚餐添加到报告数据库中,我需要构建一个新的消息处理程序,订阅现有事件并将详情保存在 Elasticsearch 中。

当新的报告功能准备就绪时,可以将其部署到生产环境,而无需对正在运行的应用程序进行任何更改。零停机部署是容器优先设计的另一个好处。功能被构建为以解耦单元运行,因此可以启动或升级单个容器而不影响其他容器。

对于下一个功能,我将添加一个与解决方案的其余部分独立的新消息处理程序。如果我需要替换保存晚餐处理程序的实现,我也可以使用消息队列在替换处理程序时缓冲事件,实现零停机。

使用 Elasticsearch 与 Docker 和.NET。

Elasticsearch 是一种非常有用的技术,值得稍微详细地了解一下。它是一个 Java 应用程序,但在 Docker 中运行时,你可以将其视为一个黑盒子,并以与所有其他 Docker 工作负载相同的方式进行管理——你不需要安装 Java 或配置 JDK。Elasticsearch 提供了一个 REST API 用于写入、读取和搜索数据,并且所有主要语言都有 API 的客户端包装器可用。

Elasticsearch 中的数据以 JSON 文档的形式存储,每个文档都可以完全索引,这样你就可以在任何字段中搜索任何值。它是一个可以在许多节点上运行的集群技术,用于扩展和弹性。在 Docker 中,你可以在单独的容器中运行每个节点,并将它们分布在服务器群中,以获得规模和弹性,但同时也能获得 Docker 的部署和管理的便利性。

与任何有状态的工作负载一样,Elasticsearch 也需要考虑存储方面的问题——在开发中,你可以将数据保存在容器内,这样当容器被替换时,你就可以从一个新的数据库开始。在测试环境中,你可以使用一个 Docker 卷挂载到主机上的驱动器文件夹,以便在容器外保持持久存储。在生产环境中,你可以使用一个带有驱动程序的卷,用于本地存储阵列或云存储服务。

Docker Hub 上有一个官方的 Elasticsearch 镜像,但目前只有 Linux 变体。我在 Docker Hub 上有自己的镜像,将 Elasticsearch 打包成了一个 Windows Server 2019 的 Docker 镜像。在 Docker 中运行 Elasticsearch 与启动任何容器是一样的。这个命令暴露了端口9200,这是 REST API 的默认端口。

 docker container run -d -p 9200 `
 --name elasticsearch ` --env ES_JAVA_OPTS='-Xms512m -Xmx512m' `
 sixeyed/elasticsearch:5.6.11-windowsservercore-ltsc2019

Elasticsearch 是一个占用内存很多的应用程序,默认情况下在启动时会分配 2GB 的系统内存。在开发环境中,我不需要那么多的内存来运行数据库。我可以通过设置ES_JAVA_OPTS环境变量来配置这个。在这个命令中,我将 Elasticsearch 限制在 512MB 的内存中。

Elasticsearch 是一个跨平台的应用程序,就像 NATS 一样。Windows 没有官方的 Elasticsearch 镜像,但你可以在 GitHub 的sixeyed/dockerfiles-windows仓库中查看我的 Dockerfile。你会看到我使用了基于 Windows Server Core 2019 的官方 OpenJDK Java 镜像来构建我的 Elasticsearch 镜像。

有一个名为NEST的 Elasticsearch NuGet 包,它是用于读写数据的 API 客户端,面向.NET Framework 和.NET Core。我在一个新的.NET Core 控制台项目NerdDinner.MessageHandlers.IndexDinner中使用这个包。新的控制台应用程序监听来自 NATS 的 dinner-created 事件消息,并将 dinner 详情作为文档写入 Elasticsearch。

连接到消息队列并订阅消息的代码与现有消息处理程序相同。我有一个新的Dinner类,它代表 Elasticsearch 文档,因此消息处理程序代码将Dinner实体映射到 dinner 文档并将其保存在 Elasticsearch 中:

var eventMessage = MessageHelper.FromData<DinnerCreatedEvent>(e.Message.Data);
var dinner = Mapper.Map<documents.Dinner>(eventMessage.Dinner);
var  node  =  new  Uri(Config.Current["Elasticsearch:Url"]);
var client = new ElasticClient(node);
client.Index(dinner, idx => idx.Index("dinners"));

Elasticsearch 将在一个容器中运行,新的文档消息处理程序将在一个容器中运行,都在与 NerdDinner 解决方案的其余部分相同的 Docker 网络中。我可以在现有解决方案运行时启动新的容器,因为 Web 应用程序或 SQL Server 消息处理程序没有任何更改。使用 Docker 添加这个新功能是零停机部署。

Elasticsearch 消息处理程序不依赖于 EF 或任何旧代码,就像新的 REST API 一样。我利用了这一点,在.NET Core 中编写这些应用程序,这使我可以在 Linux 或 Windows 主机上的 Docker 容器中运行它们。我的 Visual Studio 解决方案现在有.NET Framework、.NET Standard 和.NET Core 项目。代码库的部分代码在.NET Framework 和.NET Core 应用程序项目之间共享。我可以为每个应用程序的 Dockerfile 使用多阶段构建,但在较大的项目中可能会引发问题。

大型.NET 代码库往往采用多解决方案方法,其中一个主解决方案包含 CI 服务器中使用的所有项目,并且应用程序的每个区域都有不同的.sln文件,每个文件都有一部分项目。这样可以让不同的团队在不必加载数百万行代码到 Visual Studio 的情况下处理他们的代码库的一部分。这节省了很多开发人员的时间,但也引入了一个风险,即对共享组件的更改可能会破坏另一个团队的构建。

如果您将所有组件都迁移到多阶段构建,那么当您迁移到 Docker 时,仍可能遇到这个问题。在这种情况下,您可以使用另一种方法,在其中在单个 Dockerfile 中构建所有代码,就像 Visual Studio 的旧主解决方案一样。

在 Docker 中构建混合.NET Framework 和.NET Core 解决方案

到目前为止,您所看到的多阶段构建都使用了 Docker Hub 上的microsoft/dotnet-framework:4.7.2-sdk图像或microsoft/dotnet:2.2-sdk图像。这些图像提供了相关的.NET 运行时,以及用于还原包、编译源代码和发布应用程序的 SDK 组件。

.NET Framework 4.7.2 图像还包含.NET Core 2.1 SDK,因此如果您使用这些版本(或更早版本),则可以在同一个 Dockerfile 中构建.NET Framework 和.NET Core 应用程序。

在本书的第一版中,没有官方图像同时包含.NET Framework 和.NET Core SDK,因此我向您展示了如何使用非常复杂的 Dockerfile 自己构建图像,并进行了大量的 Chocolatey 安装。我还写道,“我期望 MSBuild 和.NET Core 的后续版本将具有集成工具,因此管理多个工具链的复杂性将消失,”我很高兴地说,现在我们就在这个阶段,微软正在为我们管理这些工具链。

编译混合 NerdDinner 解决方案

在本章中,我采用了一种不同的方法来构建 NerdDinner,这种方法与 CI 流程很好地契合,如果您正在混合使用.NET Core 和.NET Framework 项目(我在第十章中使用 Docker 进行 CI 和 CD,使用 Docker 打造持续部署流水线)。我将在一个图像中编译整个解决方案,并将该图像用作应用程序 Dockerfile 中二进制文件的来源。

以下图表显示了 SDK 和构建器图像如何用于打包本章的应用程序图像:

构建解决方案所需的所有工具都在 Microsoft 的 SDK 中,因此dockeronwindows/ch05-nerd-dinner-builder:2e的 Dockerfile 很简单。它从 SDK 开始,复制解决方案的源树,并还原依赖项:

# escape=` FROM microsoft/dotnet-framework:4.7.2-sdk-windowsservercore-ltsc2019 AS builder WORKDIR C:\src COPY src . RUN nuget restore

这会为 NerdDinner 解决方案文件运行nuget restore。这将为所有项目还原所有.NET Framework、.NET Standard 和.NET Core 引用。最后一条指令构建每个应用程序项目,指定项目文件和它们各自的单独输出路径:

RUN msbuild ...\NerdDinner.csproj /p:OutputPath=c:\nerd-dinner-web; ` msbuild ...\NerdDinner.MessageHandlers.SaveDinner.csproj /p:OutputPath=c:\save-handler; `
    dotnet publish -o C:\index-handler ...\NerdDinner.MessageHandlers.IndexDinner.csproj; `
    dotnet publish -o C:\dinner-api ...\NerdDinner.DinnerApi.csproj

你可以只运行msbuild来处理整个解决方案文件,但这只会生成已编译的二进制文件,而不是完全发布的目录。这种方法意味着每个应用程序都已经准备好进行打包发布,并且输出位于构建图像中的已知位置。这也意味着整个应用程序是从相同的源代码集编译的,因此您将发现应用程序之间的依赖关系中的任何破坏问题。

这种方法的缺点是它没有充分利用 Docker 缓存。整个源树被复制到映像中作为第一步。每当有代码更改时,构建将更新软件包,即使软件包引用没有更改。您可以以不同的方式编写此构建器,首先复制.sln.csprojpackage.config文件进行还原阶段,然后复制其余源进行构建阶段。

这将为您提供软件包缓存和更快的构建速度,但代价是更脆弱的 Dockerfile - 每次添加或删除项目时都需要编辑初始文件列表。

您可以选择最适合您流程的方法。在比这更复杂的解决方案中,开发人员可能会从 Visual Studio 构建和运行应用程序,然后只构建 Docker 映像以在提交代码之前运行测试。在这种情况下,较慢的 Docker 映像构建不是问题(我在第十一章中讨论了在开发过程中在 Docker 中运行应用程序的选项,调试和检测应用程序容器)。

关于构建此映像的方式有一个不同之处。Dockerfile 复制了src文件夹,该文件夹比 Dockerfile 所在的文件夹高一级。为了确保src文件夹包含在 Docker 上下文中,我需要从ch05文件夹运行build image命令,并使用--file选项指定 Dockerfile 的路径:

docker image build `
 --tag dockeronwindows/ch05-nerd-dinner-builder `
 --file ch05-nerd-dinner-builder\Dockerfile .

构建映像会编译和打包所有项目,因此我可以将该映像用作应用程序 Dockerfiles 中发布输出的源。我只需要构建构建器一次,然后就可以用它来构建所有其他映像。

在 Docker 中打包.NET Core 控制台应用程序

在第三章中,《开发 Docker 化的.NET Framework 和.NET Core 应用程序》,我将替换 NerdDinner 首页的 ASP.NET Core Web 应用程序构建为 REST API 和 Elasticsearch 消息处理程序作为.NET Core 应用程序。这些可以打包为 Docker 镜像,使用 Docker Hub 上microsoft/dotnet镜像的变体。

REST API 的 Dockerfile dockeronwindows/ch05-nerd-dinner-api:2e非常简单:它只是设置容器环境,然后从构建图像中复制发布的应用程序:

# escape=` FROM microsoft/dotnet:2.1-aspnetcore-runtime-nanoserver-1809 EXPOSE 80 WORKDIR /dinner-api ENTRYPOINT ["dotnet", "NerdDinner.DinnerApi.dll"] COPY --from=dockeronwindows/ch05-nerd-dinner-builder:2e C:\dinner-api .

消息处理程序的 Dockerfile dockeronwindows/ch05-nerd-dinner-index-handler:2e更简单——这是一个.NET Core 控制台应用程序,因此不需要暴露端口:

# escape=` FROM microsoft/dotnet:2.1-runtime-nanoserver-1809 CMD ["dotnet", "NerdDinner.MessageHandlers.IndexDinner.dll"] WORKDIR /index-handler COPY --from=dockeronwindows/ch05-nerd-dinner-builder:2e C:\index-handler .

内容与用于 SQL Server 消息处理程序的.NET Framework 控制台应用程序非常相似。不同之处在于FROM图像;在这里,我使用.NET Core 运行时图像和CMD指令,这里运行控制台应用程序 DLL 的是dotnet命令。两个消息处理程序都使用构建图像作为复制编译应用程序的来源,然后设置它们需要的环境变量和启动命令。

.NET Core 应用程序都捆绑了appsettings.json中的默认配置值,可以使用环境变量在容器运行时进行覆盖。这些配置包括消息队列和 Elasticsearch API 的 URL,以及 SQL Server 数据库的连接字符串。启动命令运行.NET Core 应用程序。ASP.NET Core 应用程序会一直在前台运行,直到应用程序停止。消息处理程序的.NET Core 控制台应用程序会使用ManualResetEvent对象在前台保持活动状态。两者都会将日志条目写入控制台,因此它们与 Docker 集成良好。

当索引处理程序应用程序运行时,它将监听来自 NATS 的消息,主题为 dinner-created。当从 Web 应用程序发布事件时,NATS 将向每个订阅者发送副本,因此 SQL Server 保存处理程序和 Elasticsearch 索引处理程序都将获得事件的副本。事件消息包含足够的细节,以便两个处理程序运行。如果将来的功能需要更多细节,那么 Web 应用程序可以发布带有附加信息的事件的新版本,但现有的消息处理程序将不需要更改。

运行另一个带有 Kibana 的容器将完成此功能,并为 NerdDinner 添加自助式分析。

使用 Kibana 提供分析

Kibana 是 Elasticsearch 的开源 Web 前端,为您提供了用于分析的可视化和搜索特定数据的能力。它由 Elasticsearch 背后的公司制作,并且被广泛使用,因为它提供了一个用户友好的方式来浏览大量的数据。您可以交互式地探索数据,而高级用户可以构建全面的仪表板与他人分享。

Kibana 的最新版本是一个 Node.js 应用程序,因此像 Elasticsearch 和 NATS 一样,它是一个跨平台应用程序。Docker Hub 上有一个官方的 Linux 和变体镜像,我已经基于 Windows Server 2019 打包了自己的镜像。Kibana 镜像使用了与消息处理器中使用的相同的基于约定的方法构建:它期望连接到默认 API 端口9200上名为elasticsearch的容器。

在本章的源代码目录中,有第二个 PowerShell 脚本,用于部署此功能的容器。名为ch05-run-nerd-dinner_part-3.ps1的脚本启动了额外的 Elasticsearch、Kibana 和索引处理器容器,并假定其他组件已经从 part-1 和 part-2 脚本中运行:

 docker container run -d `
  --name elasticsearch `
  --env ES_JAVA_OPTS='-Xms512m -Xmx512m'  `
 sixeyed/elasticsearch:5.6.11-windowsservercore-ltsc2019; docker container run -d `
  --name kibana `
  -l "traefik.frontend.rule=Host:kibana.nerddinner.local"  `
 sixeyed/kibana:5.6.11-windowsservercore-ltsc2019; docker container run -d `
  --name nerd-dinner-index-handler `
 dockeronwindows/ch05-nerd-dinner-index-handler:2e; 

Kibana 容器带有 Traefik 的前端规则。默认情况下,Kibana 监听端口5601,但在我的设置中,我将能够在端口80上使用kibana.nerddinner.local域名访问它,我已经将其添加到我的hosts文件中,Traefik 将代理 UI。

整个堆栈现在正在运行。当我添加一个新的晚餐时,我将看到来自消息处理器容器的日志,显示数据现在正在保存到 Elasticsearch,以及 SQL Server:

> docker container logs nerd-dinner-save-handler
Connecting to message queue url: nats://message-queue:4222
Listening on subject: events.dinner.created, queue: save-dinner-handler
Received message, subject: events.dinner.created
Saving new dinner, created at: 2/11/2019 10:18:32 PM; event ID: 9919cd1e-2b0b-41c7-8019-b2243e81a412
Dinner saved. Dinner ID: 2; event ID: 9919cd1e-2b0b-41c7-8019-b2243e81a412

> docker container logs nerd-dinner-index-handler
Connecting to message queue url: nats://message-queue:4222
Listening on subject: events.dinner.created, queue: index-dinner-handler
Received message, subject: events.dinner.created
Indexing new dinner, created at: 2/11/2019 10:18:32 PM; event ID: 9919cd1e-2b0b-41c7-8019-b2243e81a412

Kibana 由 Traefik 代理,所以我只需要浏览到kibana.nerddinner.local。启动屏幕唯一需要的配置是文档集合的名称,Elasticsearch 称之为索引。在这种情况下,索引被称为dinners。我已经使用消息处理器添加了一个文档,以便 Kibana 可以访问 Elasticsearch 元数据以确定文档中的字段:

现在创建的每个晚餐都将保存在原始的事务性数据库 SQL Server 中,也会保存在新的报告数据库 Elasticsearch 中。用户可以对聚合数据创建可视化,寻找热门时间或地点的模式,并且可以搜索特定的晚餐详情并检索特定文档:

Elasticsearch 和 Kibana 是非常强大的软件系统。 Docker 使它们对一大批新用户可用。我不会在本书中进一步详细介绍它们,但它们是受欢迎的组件,有很多在线资源,如果您想了解更多,可以搜索。

从单体架构到分布式解决方案

NerdDinner 已经从传统的单体架构发展为一个易于扩展、易于扩展的解决方案,运行在现代应用程序平台上,使用现代设计模式。这是一个快速且低风险的演变,由 Docker 平台和以容器为先的设计推动。

该项目开始将 NerdDinner 迁移到 Docker,运行一个容器用于 Web 应用程序,另一个用于 SQL Server 数据库。现在我有十个组件在容器中运行。其中五个运行我的自定义代码:

  • 原始的 ASP.NET NerdDinner Web 应用程序

  • 新的 ASP.NET Core Web 首页

  • 新的.NET Framework save-dinner 消息处理程序

  • 新的.NET Core index-dinner 消息处理程序

  • 新的 ASP.NET Core 晚餐 API

有四种企业级开源技术:

  • Traefik 反向代理

  • NATS 消息队列

  • Elasticsearch 文档数据库

  • Kibana 分析 UI

最后是 SQL Server Express,在生产中免费使用。每个组件都在轻量级的 Docker 容器中运行,并且每个组件都能够独立部署,以便它们可以遵循自己的发布节奏:

Docker 的一个巨大好处是其庞大的打包软件库,可供您添加到解决方案中。 Docker Hub 上的官方镜像已经经过社区多年的尝试和信任。 Docker Hub 上的认证镜像提供了商业软件,保证在 Docker Enterprise 上能够正确运行。

越来越多的软件包以易于消费的 Docker 镜像形式提供给 Windows,使您有能力在不需要大量开发的情况下为您的应用程序添加功能。

NerdDinner 堆栈中的新自定义组件是消息处理程序和 REST API,所有这些都是包含大约 100 行代码的简单应用程序。save-dinner 处理程序使用了 Web 应用程序的原始代码,并使用了我重构为自己的项目以实现重用的 EF 模型。index dinner 处理程序和 REST API 使用了.NET Core 中的全新代码,这使得它在运行时更高效和可移植,但在构建时,所有项目都在一个单一的 Visual Studio 解决方案中。

以容器为先的方法是将功能分解为离散的组件,并设计这些组件以在容器中运行,无论是作为你自己编写的小型自定义应用程序,还是作为 Docker Hub 上的现成镜像。这种以功能为驱动的方法意味着你专注于对项目利益相关者有价值的领域:

  • 对于业务来说,这是因为它为他们提供了新的功能或更频繁的发布

  • 对于运维来说,因为它使应用程序更具弹性和更易于维护

  • 对开发团队来说,因为它解决了技术债务并允许更大的架构自由

管理构建和部署依赖。

在当前的演进中,NerdDinner 有一个结构良好、逻辑清晰的架构,但实际上它有很多依赖。以容器为先的设计方法给了我技术栈的自由,但这可能会导致许多新技术。如果你在这个阶段加入项目并希望在 Docker 之外本地运行应用程序,你需要以下内容:

  • Visual Studio 2017

  • .NET Core 2.1 运行时和 SDK

  • IIS 和 ASP.NET 4.7.2

  • SQL Server

  • Traefik、NATS、Elasticsearch 和 Kibana

如果你加入了这个项目并且在 Windows 10 上安装了 Docker Desktop,你就不需要这些依赖。当你克隆了源代码后,你可以使用 Docker 构建和运行整个应用程序堆栈。你甚至可以使用 Docker 和轻量级编辑器(如 VS Code)开发和调试解决方案,甚至不需要依赖 Visual Studio。

这也使得持续集成非常容易:你的构建服务器只需要安装 Docker 来构建和打包解决方案。你可以使用一次性构建服务器,在排队构建时启动一个虚拟机,然后在队列为空时销毁虚拟机。你不需要复杂的虚拟机初始化脚本,只需要一个脚本化的 Docker 安装。你也可以使用云中的托管 CI 服务,因为它们现在都支持 Docker。

在解决方案中仍然存在运行时依赖关系,我目前正在使用一个脚本来管理所有容器的启动选项和正确的顺序。这是一种脆弱和有限的方法——脚本没有逻辑来处理任何故障或允许部分启动,其中一些容器已经在运行。在一个真正的项目中你不会这样做;我只是使用这个脚本让我们可以专注于构建和运行容器。在下一章中,我会向你展示正确的方法,使用 Docker Compose 来定义和运行整个解决方案。

总结

在这一章中,我讨论了基于容器的解决方案设计,利用 Docker 平台在设计时轻松而安全地为你的应用程序添加功能。我描述了一种面向特性的方法,用于现代化现有软件项目,最大限度地提高投资回报,并清晰地展示其进展情况。

基于容器的功能优先方法让你可以使用来自 Docker Hub 的生产级软件来为你的解决方案增加功能,使用官方和认证的高质量精心策划的镜像。你可以添加这些现成的组件,并专注于构建小型定制组件来完成功能。你的应用程序将逐渐演变为松散耦合,以便每个单独的元素都可以拥有最合适的发布周期。

在这一章中,开发速度已经超过了运维,所以我们目前拥有一个良好架构的解决方案,但部署起来很脆弱。在下一章中,我会介绍 Docker Compose,它提供了一种清晰和统一的方式来描述和管理多容器解决方案。

第六章:使用 Docker Compose 组织分布式解决方案

软件的交付是 Docker 平台的一个重要组成部分。Docker Hub 上的官方存储库使得使用经过验证的组件设计分布式解决方案变得容易。在上一章中,我向你展示了如何将这些组件集成到你自己的解决方案中,采用了以容器为先的设计方法。最终结果是一个具有多个运动部件的分布式解决方案。在本章中,你将学习如何将所有这些运动部件组织成一个单元,使用 Docker Compose。

Docker Compose 是 Docker,Inc.的另一个开源产品,它扩展了 Docker 生态系统。Docker 命令行界面(CLI)和 Docker API 在单个资源上工作,比如镜像和容器。Docker Compose 在更高的级别上工作,涉及服务和应用程序。一个应用程序是一个由一个或多个服务组成的单个单元,在运行时作为容器部署。你可以使用 Docker Compose 来定义应用程序的所有资源-服务、网络、卷和其他 Docker 对象-以及它们之间的依赖关系。

Docker Compose 有两个部分。设计时元素使用 YAML 规范在标记文件中捕获应用程序定义,而在运行时,Docker Compose 可以从 YAML 文件管理应用程序。我们将在本章中涵盖这两个部分:

  • 使用 Docker Compose 定义应用程序

  • 使用 Docker Compose 管理应用程序

  • 配置应用程序环境

Docker Compose 是作为 Docker Desktop 在 Windows 上的一部分安装的。如果你使用 PowerShell 安装程序在 Windows Server 上安装 Docker,那就不会得到 Docker Compose。你可以从 GitHub 的发布页面docker/compose上下载它。

技术要求

你需要在 Windows 10 上运行 Docker,更新到 18.09 版,或者在 Windows Server 2019 上运行,以便跟随示例。本章的代码可在github.com/sixeyed/docker-on-windows/tree/second-edition/ch06上找到。

使用 Docker Compose 定义应用程序

Docker Compose 文件格式非常简单。YAML 是一种人类可读的标记语言,Compose 文件规范捕获了您的应用程序配置,使用与 Docker CLI 相同的选项名称。在 Compose 文件中,您定义组成应用程序的服务、网络和卷。网络和卷是您在 Docker 引擎中使用的相同概念。服务是容器的抽象。

容器是组件的单个实例,可以是从 Web 应用到消息处理程序的任何内容。服务可以是同一组件的多个实例,在不同的容器中运行,都使用相同的 Docker 镜像和相同的运行时选项。您可以在用于 Web 应用程序的服务中有三个容器,在用于消息处理程序的服务中有两个容器:

服务就像是从图像运行容器的模板,具有已知配置。使用服务,您可以扩展应用程序的组件——从相同图像运行多个容器,并将它们配置和管理为单个单元。服务不在独立的 Docker 引擎中使用,但它们在 Docker Compose 中使用,并且在运行 Docker Swarm 模式的 Docker 引擎集群中也使用(我将在下一章第七章中介绍,使用 Docker Swarm 编排分布式解决方案)。

Docker 为服务提供了与容器相同的可发现性。消费者通过名称访问服务,Docker 可以在服务中的多个容器之间负载均衡请求。服务中的实例数量对消费者是透明的;他们总是引用服务名称,并且流量始终由 Docker 定向到单个容器。

在本章中,我将使用 Docker Compose 来组织我在上一章中构建的分布式解决方案,用可靠且适用于生产的 Docker Compose 文件替换脆弱的docker container run PowerShell 脚本。

捕获服务定义

服务可以在 Compose 文件中以任何顺序定义。为了更容易阅读,我更喜欢从最简单的服务开始,这些服务没有依赖关系——基础设施组件,如消息队列、反向代理和数据库。

Docker Compose 文件通常被称为docker-compose.yml,并且以 API 版本的明确声明开头;最新版本是 3.7。应用程序资源在顶层定义 - 这是一个模板 Compose 文件,包含了服务、网络和卷的部分:

 version: '3.7'

  services:
    ...

  networks:
    ...

  volumes:
    ...

Docker Compose 规范在 Docker 网站docs.docker.com/compose/compose-file/上有文档。这列出了所有支持的版本的完整规范,以及版本之间的更改。

所有资源都需要一个唯一的名称,名称是资源引用其他资源的方式。服务可能依赖于网络、卷和其他服务,所有这些都通过名称来捕获。每个资源的配置都在自己的部分中,可用的属性与 Docker CLI 中相应的create命令大致相同,比如docker network createdocker volume create

在本章中,我将为分布式 NerdDinner 应用程序构建一个 Compose 文件,并向您展示如何使用 Docker Compose 来管理应用程序。我将首先从常见服务开始我的 Compose 文件。

定义基础设施服务

我拥有的最简单的服务是消息队列NATS,它没有任何依赖关系。每个服务都需要一个名称和一个镜像名称来启动容器。可选地,您可以包括您在docker container run中使用的参数。对于 NATS 消息队列,我添加了一个网络名称,这意味着为此服务创建的任何容器都将连接到nd-net网络:

message-queue:
  image: dockeronwindows/ch05-nats:2e
 networks:
 - nd-net 

在这个服务定义中,我拥有启动消息队列容器所需的所有参数:

  • message-queue是服务的名称。这将成为其他服务访问 NATS 的 DNS 条目。

  • image是启动容器的完整镜像名称。在这种情况下,它是我从 Docker Hub 的官方 NATS 镜像中获取的我的 Windows Server 2019 变体,但您也可以通过在镜像名称中包含注册表域来使用来自私有注册表的镜像。

  • networks是连接容器启动时要连接到的网络列表。此服务连接到一个名为nd-net的网络。这将是此应用程序中所有服务使用的 Docker 网络。稍后在 Docker Compose 文件中,我将明确捕获网络的详细信息。

我没有为 NATS 服务发布任何端口。消息队列仅在其他容器内部使用。在 Docker 网络中,容器可以访问其他容器上的端口,而无需将它们发布到主机上。这使得消息队列安全,因为它只能通过相同网络中的其他容器通过 Docker 平台访问。没有外部服务器,也没有在服务器上运行的应用程序可以访问消息队列。

Elasticsearch

下一个基础设施服务是 Elasticsearch,它也不依赖于其他服务。它将被消息处理程序使用,该处理程序还使用 NATS 消息队列,因此我需要将所有这些服务加入到相同的 Docker 网络中。对于 Elasticsearch,我还希望限制其使用的内存量,并使用卷来存储数据,以便它存储在容器之外:

  elasticsearch:
  image: sixeyed/elasticsearch:5.6.11-windowsservercore-ltsc2019
 environment: 
  - ES_JAVA_OPTS=-Xms512m -Xmx512m
  volumes:
     - **es-data:C:\data
   networks:
    - nd-net

在这里,elasticsearch是服务的名称,sixeyed/elasticsearch是镜像的名称,这是我在 Docker Hub 上的公共镜像。我将服务连接到相同的nd-net网络,并且还挂载一个卷到容器中的已知位置。当 Elasticsearch 将数据写入容器上的C:\data时,实际上会存储在一个卷中。

就像网络一样,卷在 Docker Compose 文件中是一流资源。对于 Elasticsearch,我正在将一个名为es-data的卷映射到容器中的数据位置。我将稍后在 Compose 文件中指定es-data卷应该如何创建。

Traefik

接下来是反向代理 Traefik。代理从标签中构建其路由规则,当容器创建时,它需要连接到 Docker API:

reverse-proxy:
  image: sixeyed/traefik:v1.7.8-windowsservercore-ltsc2019
  command: --docker --docker.endpoint=npipe:////./pipe/docker_engine --api
 ports:
   - **"80:80"
 - "8080:8080"
  volumes: - type: npipe source: \\.\pipe\docker_engine target: \\.\pipe\docker_engine 
  networks:
   - nd-net

Traefik 容器发布到主机上的端口80,连接到应用程序网络,并使用卷用于 Docker API 命名管道。这些是我在使用docker container run启动 Traefik 时使用的相同选项;通常,您可以将运行命令复制到 Docker Compose 文件中。

端口发布在 Docker Compose 中与在运行容器时相同。您指定要发布的容器端口和应该发布到哪个主机端口,因此 Docker 会将传入的主机流量路由到容器。ports部分允许多个映射,并且如果有特定要求,您可以选择指定 TCP 或 UDP 协议。

我还发布了端口8080,并在 Traefik 配置中使用了--api标志。这使我可以访问 Traefik 的仪表板,我可以在那里查看 Traefik 配置的所有路由规则。这在非生产环境中很有用,可以检查代理规则是否正确,但在生产环境中,这不是您希望公开的东西。

Docker Compose 还支持扩展定义,我正在使用volume规范。我将卷的类型、源和目标拆分成不同的行,而不是使用单行来定义卷挂载。这是可选的,但它使文件更容易阅读。

Kibana

Kibana是第一个依赖于其他服务的服务——它需要 Elasticsearch 运行,以便它可以连接到数据库。Docker Compose 不会对创建容器的顺序做出任何保证,因此如果服务之间存在启动依赖关系,您需要在服务定义中捕获该依赖关系:

kibana:
  image: sixeyed/kibana:5.6.11-windowsservercore-ltsc2019
  labels:
   - "traefik.frontend.rule=Host:kibana.nerddinner.local"
   depends_on:
   - elasticsearch  networks:
   - nd-net

depends_on属性显示了如何捕获服务之间的依赖关系。在这种情况下,Kibana 依赖于 Elasticsearch,因此 Docker 将确保在启动kibana服务之前,elasticsearch服务已经启动并运行。

像这样捕获依赖关系对于在单台机器上运行分布式应用程序是可以的,但它不具有可扩展性。当您在集群中运行时,您希望编排器来管理分发工作负载。如果您有显式依赖关系,它无法有效地执行此操作,因为它需要确保所有运行依赖服务的容器在启动消费容器之前都是健康的。在我们看 Docker Swarm 时,我们将看到更好的管理依赖关系的方法。

Kibana 将由 Traefik 代理,但 Kibana 之前不需要运行 Traefik。当 Traefik 启动时,它会从 Docker API 获取正在运行的容器列表,以构建其初始路由映射。然后,它订阅来自 Docker 的事件流,以在创建或删除容器时更新路由规则。因此,Traefik 可以在 web 容器之前或之后启动。

kibana服务的容器也连接到应用程序网络。在另一种配置中,我可以有单独的后端和前端网络。所有基础设施服务都将连接到后端网络,而面向公众的服务将连接到后端和前端网络。这两个都是 Docker 网络,但将它们分开可以让我灵活地配置网络。

配置应用程序服务

到目前为止,我指定的基础设施服务并不需要太多的应用程序级配置。我已经使用网络、卷和端口配置了容器与 Docker 平台之间的集成点,但应用程序使用了内置到每个 Docker 镜像中的配置。

Kibana 镜像按照惯例使用主机名elasticsearch连接到 Elasticsearch,这是我在 Docker Compose 文件中使用的服务名称,以支持该惯例。Docker 平台将任何对elasticsearch主机名的请求路由到该服务,如果有多个运行该服务的容器,则在容器之间进行负载均衡,因此 Kibana 将能够在预期的域名找到 Elasticsearch。

我的自定义应用程序需要指定配置设置,我可以在 Compose 文件中使用环境变量来包含这些设置。在 Compose 文件中为服务定义环境变量会为运行该服务的每个容器设置这些环境变量。

index-dinner 消息处理程序服务订阅 NATS 消息队列并在 Elasticsearch 中插入文档,因此它需要连接到相同的 Docker 网络,并且还依赖于这些服务。我可以在 Compose 文件中捕获这些依赖关系,并指定应用程序的配置。

nerd-dinner-index-handler:
  image: dockeronwindows/ch05-nerd-dinner-index-handler:2e
  environment:
   - Elasticsearch:Url=http://elasticsearch:9200
   - **MessageQueue:Url=nats://message-queue:4222
  depends_on:
   - elasticsearch
   - message-queue
  networks:
   - nd-net

在这里,我使用environment部分来指定两个环境变量——每个都有一个键值对——来配置消息队列和 Elasticsearch 的 URL。这实际上是默认值内置到消息处理程序镜像中的,所以我不需要在 Compose 文件中包含它们,但明确设置它们可能会有用。

您可以将 Compose 文件视为分布式解决方案的完整部署指南。如果您明确指定环境值,可以清楚地了解可用的配置选项,但这会使您的 Compose 文件变得不太可管理。

将配置变量存储为明文对于简单的应用程序设置来说是可以的,但对于敏感值,最好使用单独的环境文件,这是我在上一章中使用的方法。这也受到 Compose 文件格式的支持。对于数据库服务,我可以使用一个环境文件来指定管理员密码,使用env-file属性:

nerd-dinner-db:
  image: dockeronwindows/ch03-nerd-dinner-db:2e
 env_file:
   - **db-credentials.env
  volumes:
   - db-data:C:\data
  networks:
   - nd-net

当数据库服务启动时,Docker 将从名为db-credentials.env的文件中设置环境变量。我使用了相对路径,所以该文件需要与 Compose 文件在同一位置。与之前一样,该文件的内容是每个环境变量一行的键值对。在这个文件中,我包括了应用程序的连接字符串,以及数据库的密码,所以凭据都在一个地方:

sa_password=4jsZedB32!iSm__
ConnectionStrings:UsersContext=Data Source=nerd-dinner-db,1433;Initial Catalog=NerdDinner...
ConnectionStrings:NerdDinnerContext=Data Source=nerd-dinner-db,1433;Initial Catalog=NerdDinner...

敏感数据仍然是明文的,但通过将其隔离到一个单独的文件中,我可以做两件事:

  • 首先,我可以保护文件以限制访问。

  • 其次,我可以利用服务配置与应用程序定义的分离,并在不同环境中使用相同的 Docker Compose 文件,替换不同的环境文件。

环境变量即使你保护文件访问也不安全。当你检查一个容器时,你可以查看环境变量的值,所以任何有 Docker API 访问权限的人都可以读取这些数据。对于诸如密码和 API 密钥之类的敏感数据,你应该在 Docker Swarm 中使用 Docker secrets,这将在下一章中介绍。

对于 save-dinner 消息处理程序,我可以利用相同的环境文件来获取数据库凭据。处理程序依赖于消息队列和数据库服务,但在这个定义中没有新的属性:

nerd-dinner-save-handler:
  image: dockeronwindows/ch05-nerd-dinner-save-handler:2e
  depends_on:
   - nerd-dinner-db
   - message-queue
  env_file:
   - db-credentials.env
  networks:
   - nd-net

接下来是我的前端服务,它们由 Traefik 代理——REST API、新的主页和传统的 NerdDinner 网页应用。REST API 使用相同的凭据文件来配置 SQL Server 连接,并包括 Traefik 路由规则:

nerd-dinner-api:
  image: dockeronwindows/ch05-nerd-dinner-api:2e
  labels:
   - "traefik.frontend.rule=Host:api.nerddinner.local"
  env_file:
   - db-credentials.env
  networks:
   - nd-net

主页包括 Traefik 路由规则,还有一个高优先级值,以确保在 NerdDinner 网页应用使用的更一般的规则之前评估此规则:

nerd-dinner-homepage:
  image: dockeronwindows/ch03-nerd-dinner-homepage:2e
  labels:
   - "traefik.frontend.rule=Host:nerddinner.local;Path:/,/css/site.css"
   - "traefik.frontend.priority=10"
  networks:
   - nd-net

最后一个服务是网站本身。在这里,我正在使用环境变量和环境文件的组合。通常在各个环境中保持一致的变量值可以明确地说明配置,我正在为功能标志做到这一点。敏感数据可以从单独的文件中读取,本例中包含数据库凭据和 API 密钥:

nerd-dinner-web:
  image: dockeronwindows/ch05-nerd-dinner-web:2e
  labels:
   - "traefik.frontend.rule=Host:nerddinner.local;PathPrefix:/"
   - "traefik.frontend.priority=1"
 environment: 
   - HomePage:Enabled=false
   - DinnerApi:Enabled=true
  env_file:
   - api-keys.env
   - **db-credentials.env
  depends_on:
   - nerd-dinner-db
   - message-queue
  networks:
    - nd-net

网站容器不需要对外公开,因此不需要发布端口。应用程序需要访问其他服务,因此连接到同一个网络。

所有服务现在都已配置好,所以我只需要指定网络和卷资源,以完成 Compose 文件。

指定应用程序资源

Docker Compose 将网络和卷的定义与服务的定义分开,这允许在环境之间灵活性。我将在本章后面介绍这种灵活性,但为了完成 NerdDinner Compose 文件,我将从最简单的方法开始,使用默认值。

我的 Compose 文件中的所有服务都使用一个名为nd-net的网络,需要在 Compose 文件中指定。Docker 网络是隔离应用程序的好方法。您可以有几个解决方案都使用 Elasticsearch,但具有不同的 SLA 和存储要求。如果每个应用程序都有一个单独的网络,您可以在不同的 Docker 网络中运行单独配置的 Elasticsearch 服务,但所有都命名为elasticsearch。这保持了预期的约定,但通过网络进行隔离,因此服务只能看到其自己网络中的 Elasticsearch 实例。

Docker Compose 可以在运行时创建网络,或者您可以定义资源以使用主机上已经存在的外部网络。这个 NerdDinner 网络的规范使用了 Docker 在安装时创建的默认nat网络,因此这个设置将适用于所有标准的 Docker 主机:

networks:
  nd-net:
   external:
     name: nat

卷也需要指定。我的两个有状态服务,Elasticsearch 和 SQL Server,都使用命名卷进行数据存储:分别是es-datand-data。与其他网络一样,卷可以被指定为外部,因此 Docker Compose 将使用现有卷。Docker 不会创建任何默认卷,因此如果我使用外部卷,我需要在每个主机上运行应用程序之前创建它。相反,我将指定卷而不带任何选项,这样 Docker Compose 将为我创建它们:

volumes:
  es-data:
  db-data:

这些卷将在主机上存储数据,而不是在容器的可写层中。它们不是主机挂载的卷,因此尽管数据存储在本地磁盘上,但我没有指定位置。每个卷将在 Docker 数据目录C:\ProgramData\Docker中写入其数据。我将在本章后面看一下如何管理这些卷。

我的 Compose 文件已经指定了服务、网络和卷,所以它已经准备就绪。完整文件在本章的源代码ch06\ch06-docker-compose中。

使用 Docker Compose 管理应用程序

Docker Compose 提供了与 Docker CLI 类似的界面。docker-compose命令使用一些相同的命令名称和参数来支持其功能,这是完整 Docker CLI 功能的子集。当您通过 Compose CLI 运行命令时,它会向 Docker 引擎发送请求,以对 Compose 文件中的资源进行操作。

Docker Compose 文件是应用程序的期望状态。当您运行docker-compose命令时,它会将 Compose 文件与 Docker 中已经存在的对象进行比较,并进行任何必要的更改以达到期望的状态。这可能包括停止容器、启动容器或创建卷。

Compose 将 Compose 文件中的所有资源视为单个应用程序,并为了消除在同一主机上运行的应用程序的歧义,运行时会向为应用程序创建的所有资源添加项目名称。当您通过 Compose 运行应用程序,然后查看在主机上运行的容器时,您不会看到一个名称完全匹配服务名称的容器。Compose 会向容器名称添加项目名称和索引,以支持服务中的多个容器,但这不会影响 Docker 的 DNS 系统,因此容器仍然通过服务名称相互访问。

运行应用程序

我在ch06-docker-compose目录中有 NerdDinner 的第一个 Compose 文件,该目录还包含环境变量文件。从该目录,我可以使用单个docker-compose命令启动整个应用程序:

> docker-compose up -d
Creating ch06-docker-compose_message-queue_1        ... done
Creating ch06-docker-compose_nerd-dinner-api_1      ... done
Creating ch06-docker-compose_nerd-dinner-db_1            ... done
Creating ch06-docker-compose_nerd-dinner-homepage_1 ... done
Creating ch06-docker-compose_elasticsearch_1        ... done
Creating ch06-docker-compose_reverse-proxy_1        ... done
Creating ch06-docker-compose_kibana_1                    ... done
Creating ch06-docker-compose_nerd-dinner-index-handler_1 ... done
Creating ch06-docker-compose_nerd-dinner-web_1           ... done
Creating ch06-docker-compose_nerd-dinner-save-handler_1  ... done

让我们看一下前面命令的描述:

  • up命令用于启动应用程序,创建网络、卷和运行容器。

  • -d选项在后台运行所有容器,与docker container run中的--detach选项相同。

您可以看到 Docker Compose 遵守服务的depends_on设置。任何作为其他服务依赖项的服务都会首先创建。没有任何依赖项的服务将以随机顺序创建。在这种情况下,message-queue服务首先创建,因为许多其他服务依赖于它,而nerd-dinner-webnerd-dinner-save-handler服务是最后创建的,因为它们有最多的依赖项。

输出中的名称是各个容器的名称,命名格式为{project}_{service}_{index}。每个服务只有一个运行的容器,这是默认的,所以索引都是1。项目名称是我运行compose命令的目录名称的经过清理的版本。

当您运行docker-compose up命令并完成后,您可以使用 Docker Compose 或标准的 Docker CLI 来管理容器。这些容器只是普通的 Docker 容器,compose 使用一些额外的元数据来将它们作为一个整体单元进行管理。列出容器会显示由compose创建的所有服务容器:

> docker container ls
CONTAINER ID   IMAGE                                      COMMAND                     
c992051ba468   dockeronwindows/ch05-nerd-dinner-web:2e   "powershell powershe…"
78f5ec045948   dockeronwindows/ch05-nerd-dinner-save-handler:2e          "NerdDinner.MessageH…"      
df6de70f61df  dockeronwindows/ch05-nerd-dinner-index-handler:2e  "dotnet NerdDinner.M…"      
ca169dd1d2f7  sixeyed/kibana:5.6.11-windowsservercore-ltsc2019   "powershell ./init.p…"      
b490263a6590  dockeronwindows/ch03-nerd-dinner-db:2e             "powershell -Command…"      
82055c7bfb05  sixeyed/elasticsearch:5.6.11-windowsservercore-ltsc2019   "cmd /S /C \".\\bin\\el…"   
22e2d5b8e1fa  dockeronwindows/ch03-nerd-dinner-homepage:2e       "dotnet NerdDinner.H…"     
 058248e7998c dockeronwindows/ch05-nerd-dinner-api:2e            "dotnet NerdDinner.D…"      
47a9e4d91682  sixeyed/traefik:v1.7.8-windowsservercore-ltsc2019  "/traefik --docker -…"      
cfd1ef3f5414  dockeronwindows/ch05-nats:2e              "gnatsd -c gnatsd.co…"
... 

运行 Traefik 的容器将端口80发布到本地计算机,并且我的 hosts 文件中有本地 NerdDinner 域的条目。NerdDinner 应用程序及其新首页、REST API 和 Kibana 分析将按预期运行,因为所有配置都包含在 Compose 文件中,并且所有组件都由 Docker Compose 启动。

这是 Compose 文件格式中最强大的功能之一。该文件包含了运行应用程序的完整规范,任何人都可以使用它来运行您的应用程序。在这种情况下,所有组件都使用 Docker Hub 上的公共 Docker 镜像,因此任何人都可以从这个 Compose 文件启动应用程序。您只需要 Docker 和 Docker Compose 来运行 NerdDinner,它现在是一个包含.NET Framework、.NET Core、Java、Go 和 Node.js 组件的分布式应用程序。

扩展应用程序服务

Docker Compose 让您轻松地扩展服务,向正在运行的服务添加或删除容器。当一个服务使用多个容器运行时,它仍然可以被网络中的其他服务访问。消费者使用服务名称进行发现,Docker 中的 DNS 服务器会在所有服务的容器之间平衡请求。

然而,添加更多的容器并不会自动为您的服务提供规模和弹性;这取决于运行服务的应用程序。只是向 SQL 数据库服务添加另一个容器并不会自动给您提供 SQL Server 故障转移集群,因为 SQL Server 需要显式配置故障转移。如果您添加另一个容器,您将只有两个具有单独数据存储的不同数据库实例。

Web 应用程序通常在设计时支持横向扩展时可以很好地扩展。无状态应用程序可以在任意数量的容器中运行,因为任何容器都可以处理任何请求。但是,如果您的应用程序在本地维护会话状态,则来自同一用户的请求需要由同一服务处理,这将阻止您在许多容器之间进行负载平衡,除非您使用粘性会话。

将端口发布到主机的服务,如果它们在单个 Docker 引擎上运行,则无法扩展。端口只能有一个操作系统进程在其上监听,对于 Docker 也是如此——您不能将相同的主机端口映射到多个容器端口。在具有多个主机的 Docker Swarm 上,您可以扩展具有发布端口的服务,Docker 将在不同的主机上运行每个容器。

在 NerdDinner 中,消息处理程序是真正无状态的组件。它们从包含它们所需的所有信息的队列中接收消息,然后对其进行处理。NATS 支持在同一消息队列上对订阅者进行分组,这意味着我可以运行几个包含 save-dinner 处理程序的容器,并且 NATS 将确保只有一个处理程序获得每条消息的副本,因此我不会有重复的消息处理。消息处理程序中的代码已经利用了这一点。

扩展消息处理程序是我在高峰时期可以做的事情,以增加消息处理的吞吐量。我可以使用up命令和--scale选项来做到这一点,指定服务名称和所需的实例数量:

> docker-compose up -d --scale nerd-dinner-save-handler=3

ch06-docker-compose_elasticsearch_1 is up-to-date
ch06-docker-compose_nerd-dinner-homepage_1 is up-to-date
ch06-docker-compose_message-queue_1 is up-to-date
ch06-docker-compose_nerd-dinner-db_1 is up-to-date
ch06-docker-compose_reverse-proxy_1 is up-to-date
ch06-docker-compose_nerd-dinner-api_1 is up-to-date
Starting ch06-docker-compose_nerd-dinner-save-handler_1 ...
ch06-docker-compose_kibana_1 is up-to-date
ch06-docker-compose_nerd-dinner-web_1 is up-to-date
Creating ch06-docker-compose_nerd-dinner-save-handler_2 ... done
Creating ch06-docker-compose_nerd-dinner-save-handler_3 ... done

Docker Compose 将运行应用程序的状态与 Compose 文件中的配置和命令中指定的覆盖进行比较。在这种情况下,除了 save-dinner 处理程序之外,所有服务都保持不变,因此它们被列为up-to-date。save-handler 具有新的服务级别,因此 Docker Compose 创建了两个更多的容器。

有三个 save-message 处理程序实例运行时,它们以循环方式共享传入消息负载。这是增加规模的好方法。处理程序同时处理消息并写入 SQL 数据库,这增加了保存的吞吐量并减少了处理消息所需的时间。但对于写入 SQL Server 的进程数量仍然有严格的限制,因此数据库不会成为此功能的瓶颈。

我可以通过 web 应用程序创建多个晚餐,当事件消息被发布时,消息处理程序将共享负载。我可以在日志中看到不同的处理程序处理不同的消息,并且没有重复处理事件:

> docker container logs ch06-docker-compose_nerd-dinner-save-handler_1
Connecting to message queue url: nats://message-queue:4222
Listening on subject: events.dinner.created, queue: save-dinner-handler
Received message, subject: events.dinner.created
Saving new dinner, created at: 2/12/2019 11:22:47 AM; event ID: 60f8b653-f456-4bb1-9ccd-1253e9a222b6
Dinner saved. Dinner ID: 1; event ID: 60f8b653-f456-4bb1-9ccd-1253e9a222b6
...

> docker container logs ch06-docker-compose_nerd-dinner-save-handler_2
Connecting to message queue url: nats://message-queue:4222
Listening on subject: events.dinner.created, queue: save-dinner-handler
Received message, subject: events.dinner.created
Saving new dinner, created at: 2/12/2019 11:25:00 AM; event ID: 5f6d017e-a66b-4887-8fd5-ac053a639a6d
Dinner saved. Dinner ID: 5; event ID: 5f6d017e-a66b-4887-8fd5-ac053a639a6d

> docker container logs ch06-docker-compose_nerd-dinner-save-handler_3
Connecting to message queue url: nats://message-queue:4222
Listening on subject: events.dinner.created, queue: save-dinner-handler
Received message, subject: events.dinner.created
Saving new dinner, created at: 2/12/2019 11:24:55 AM; event ID: 8789179b-c947-41ad-a0e4-6bde7a1f2614
Dinner saved. Dinner ID: 4; event ID: 8789179b-c947-41ad-a0e4-6bde7a1f2614

我正在单个 Docker 引擎上运行,所以无法扩展 Traefik 服务,因为只能发布一个容器到端口80。但我可以扩展 Traefik 代理的前端容器,这是测试我的应用程序在扩展到多个实例时是否正常工作的好方法。我将再添加两个原始 NerdDinner web 应用程序的实例:

> docker-compose up -d --scale nerd-dinner-web=3
ch06-docker-compose_message-queue_1 is up-to-date
...
Stopping and removing ch06-docker-compose_nerd-dinner-save-handler_2 ... done
Stopping and removing ch06-docker-compose_nerd-dinner-save-handler_3 ... done
Creating ch06-docker-compose_nerd-dinner-web_2                       ... done
Creating ch06-docker-compose_nerd-dinner-web_3                       ... done
Starting ch06-docker-compose_nerd-dinner-save-handler_1              ... done

仔细看这个输出——发生了一些正确的事情,但并不是我想要的。Compose 已经创建了两个新的 NerdDinner web 容器,以满足我指定的规模为 3,但它也停止并移除了两个 save-handler 容器。

这是因为 Compose 隐式地使用我的docker-compose.yml文件作为应用程序定义,该文件使用每个服务的单个实例。然后它从 web 服务的命令中添加了规模值,并构建了一个期望的状态,即每个服务应该有一个正在运行的容器,除了 web 服务应该有三个。它发现 web 服务只有一个容器,所以创建了另外两个。它发现 save-handler 有三个容器,所以移除了两个。

混合 Compose 文件定义和命令的更改是不推荐的,正是因为这种情况。Compose 文件本身应该是应用程序的期望状态。但在这种情况下,您无法在 Compose 文件中指定规模选项(在旧版本中可以,但从规范的 v3 开始不行),因此您需要显式地为所有服务添加规模级别:

docker-compose up -d --scale nerd-dinner-web=3 --scale nerd-dinner-save-handler=3

现在我有三个 save-handler 容器,它们正在共享消息队列的工作,还有三个 web 容器。Traefik 将在这三个 web 容器之间负载均衡请求。我可以从 Traefik 仪表板上检查该配置,我已经发布在端口8080上:

Traefik 在左侧以蓝色框显示前端路由规则,以绿色框显示它们映射到的后端服务。对于nerddinner.local有一个路径前缀为/的前端路由规则,它将所有流量发送到nerd-dinner-web后端(除了首页,它有不同的规则)。后端显示有三个列出的服务器,它们是我用 Docker Compose 扩展的三个容器。172.20.*.*服务器地址是 Docker 网络上的内部 IP 地址,容器可以用来通信。

我可以浏览 NerdDinner 应用程序,并且它可以正确运行,Traefik 会在后端容器之间负载均衡请求。但是,一旦我尝试登录,我会发现 NerdDinner 并不是设计为扩展到多个实例:

该错误消息告诉我,NerdDinner 希望一个用户的所有请求都由 web 应用程序的同一实例处理。Traefik 支持粘性会话,正是为了解决这种情况,因此要解决这个问题,我只需要在 Compose 文件中的 web 服务定义中添加一个新的标签。这将为 NerdDinner 后端启用粘性会话:

nerd-dinner-web:
  image: dockeronwindows/ch05-nerd-dinner-web:2e
  labels:
   - "traefik.frontend.rule=Host:nerddinner.local;PathPrefix:/"
   - "traefik.frontend.priority=1"
   - "traefik.backend.loadbalancer.stickiness=true"

现在我可以再次部署,确保包括我的规模参数:

> docker-compose up -d --scale nerd-dinner-web=3 --scale nerd-dinner-save-handler=3
ch06-docker-compose_message-queue_1 is up-to-date
...
Recreating ch06-docker-compose_nerd-dinner-web_1 ... done
Recreating ch06-docker-compose_nerd-dinner-web_2 ... done
Recreating ch06-docker-compose_nerd-dinner-web_3 ... done

Compose 重新创建 web 服务容器,删除旧容器,并使用新配置启动新容器。现在,Traefik 正在使用粘性会话,因此我的浏览器会话中的每个请求都将发送到相同的容器。Traefik 使用自定义 cookie 来实现这一点,该 cookie 指定请求应路由到的容器 IP 地址:

在这种情况下,cookie 被称为_d18b8,它会将所有我的请求定向到具有 IP 地址172.20.26.74的容器。

在规模运行时发现问题以前只会发生在测试环境,甚至在生产环境中。在 Docker 中运行所有内容意味着我可以在我的开发笔记本上测试应用程序的功能,以便在发布之前发现这些问题。使用现代技术,如 Traefik,也意味着有很好的方法来解决这些问题,而无需更改我的传统应用程序。

停止和启动应用程序服务

Docker Compose 中有几个管理容器生命周期的命令。重要的是要理解选项之间的区别,以免意外删除资源。

updown命令是启动和停止整个应用程序的粗糙工具。up命令创建 Compose 文件中指定的任何不存在的资源,并为所有服务创建和启动容器。down命令则相反-它停止任何正在运行的容器并删除应用程序资源。如果是由 Docker Compose 创建的容器和网络,则会被删除,但卷不会被删除-因此您拥有的任何应用程序数据都将被保留。

stop命令只是停止所有正在运行的容器,而不会删除它们或其他资源。停止容器会以优雅的方式结束运行的进程。可以使用start再次启动已停止的应用程序容器,它会在现有容器中运行入口点程序。

停止的容器保留其所有配置和数据,但它们不使用任何计算资源。启动和停止容器是在多个项目上工作时切换上下文的非常有效的方式。如果我在 NerdDinner 上开发,当另一个工作作为优先级而来时,我可以停止整个 NerdDinner 应用程序来释放我的开发环境:

> docker-compose stop
Stopping ch06-docker-compose_nerd-dinner-web_2           ... done
Stopping ch06-docker-compose_nerd-dinner-web_1           ... done
Stopping ch06-docker-compose_nerd-dinner-web_3           ... done
Stopping ch06-docker-compose_nerd-dinner-save-handler_3  ... done
Stopping ch06-docker-compose_nerd-dinner-save-handler_2  ... done
Stopping ch06-docker-compose_nerd-dinner-save-handler_1  ... done
Stopping ch06-docker-compose_nerd-dinner-index-handler_1 ... done
Stopping ch06-docker-compose_kibana_1                    ... done
Stopping ch06-docker-compose_reverse-proxy_1             ... done
Stopping ch06-docker-compose_nerd-dinner-homepage_1      ... done
Stopping ch06-docker-compose_nerd-dinner-db_1            ... done
Stopping ch06-docker-compose_nerd-dinner-api_1           ... done
Stopping ch06-docker-compose_elasticsearch_1             ... done
Stopping ch06-docker-compose_message-queue_1             ... done

现在我没有运行的容器,我可以切换到另一个项目。当工作完成时,我可以通过运行docker-compose start再次启动 NerdDinner。

您还可以通过指定名称来停止单个服务,如果您想测试应用程序如何处理故障,这将非常有用。我可以通过停止 Elasticsearch 服务来检查索引晚餐处理程序在无法访问 Elasticsearch 时的行为:

> docker-compose stop elasticsearch
Stopping ch06-docker-compose_elasticsearch_1 ... done

所有这些命令都是通过将 Compose 文件与在 Docker 中运行的服务进行比较来处理的。你需要访问 Docker Compose 文件才能运行任何 Docker Compose 命令。这是在单个主机上使用 Docker Compose 运行应用程序的最大缺点之一。另一种选择是使用相同的 Compose 文件,但将其部署为 Docker Swarm 的堆栈,我将在下一章中介绍。

stopstart命令使用 Compose 文件,但它们作用于当前存在的容器,而不仅仅是 Compose 文件中的定义。因此,如果你扩展了一个服务,然后停止整个应用程序,然后再次启动它——你仍然会拥有你扩展的所有容器。只有up命令使用 Compose 文件将应用程序重置为所需的状态。

升级应用程序服务

如果你从同一个 Compose 文件重复运行docker compose up,在第一次运行之后不会进行任何更改。Docker Compose 会将 Compose 文件中的配置与运行时的活动容器进行比较,并且不会更改资源,除非定义已经改变。这意味着你可以使用 Docker Compose 来管理应用程序的升级。

我的 Compose 文件目前正在使用我在第三章中构建的数据库服务的镜像,开发 Docker 化的.NET Framework 和.NET Core 应用程序,标记为dockeronwindows/ch03-nerd-dinner-db:2e。对于本章,我已经在数据库架构中添加了审计字段,并构建了一个新版本的数据库镜像,标记为dockeronwindows/ch06-nerd-dinner-db:2e

我在同一个ch06-docker-compose目录中有第二个 Compose 文件,名为docker-compose-db-upgrade.yml。升级文件不是完整的应用程序定义;它只包含数据库服务定义的一个部分,使用新的镜像标签:

version: '3.7' services:
  nerd-dinner-db:
  image: dockeronwindows/ch06-nerd-dinner-db:2e

Docker Compose 支持覆盖文件。你可以运行docker-compose命令并将多个 Compose 文件作为参数传递。Compose 将按照命令中指定的顺序从左到右将所有文件合并在一起。覆盖文件可以用于向应用程序定义添加新的部分,或者可以替换现有的值。

当应用程序正在运行时,我可以再次执行docker compose up,同时指定原始 Compose 文件和db-upgrade覆盖文件:

> docker-compose `
   -f docker-compose.yml `
   -f docker-compose-db-upgrade.yml `
  up -d 
ch06-docker-compose_reverse-proxy_1 is up-to-date
ch06-docker-compose_nerd-dinner-homepage_1 is up-to-date
ch06-docker-compose_elasticsearch_1 is up-to-date
ch06-docker-compose_message-queue_1 is up-to-date
ch06-docker-compose_kibana_1 is up-to-date
Recreating ch06-docker-compose_nerd-dinner-db_1 ... done
Recreating ch06-docker-compose_nerd-dinner-web_1          ... done
Recreating ch06-docker-compose_nerd-dinner-save-handler_1 ... done
Recreating ch06-docker-compose_nerd-dinner-api_1          ... done

该命令使用db-upgrade文件作为主docker-compose.yml文件的覆盖。Docker Compose 将它们合并在一起,因此最终的服务定义包含原始文件中的所有值,除了来自覆盖的镜像规范。新的服务定义与 Docker 中正在运行的内容不匹配,因此 Compose 重新创建数据库服务。

Docker Compose 通过移除旧容器并启动新容器来重新创建服务,使用新的镜像规范。不依赖于数据库的服务保持不变,日志条目为up-to-date,任何依赖于数据库的服务在新的数据库容器运行后也会被重新创建。

我的数据库容器使用了我在第三章中描述的模式,使用卷存储数据和一个脚本,可以在容器被替换时升级数据库模式。在 Compose 文件中,我使用了一个名为db-data的卷的默认定义,因此 Docker Compose 为我创建了它。就像 Compose 创建的容器一样,卷是标准的 Docker 资源,可以使用 Docker CLI 进行管理。docker volume ls列出主机上的所有卷:

> docker volume ls

DRIVER  VOLUME NAME
local   ch06-docker-compose_db-data
local   ch06-docker-compose_es-data

我有两个卷用于我的 NerdDinner 部署。它们都使用本地驱动程序,这意味着数据存储在本地磁盘上。我可以检查 SQL Server 卷,看看数据在主机上的物理存储位置(在Mountpoint属性中),然后检查内容以查看数据库文件:

> docker volume inspect -f '{{ .Mountpoint }}' ch06-docker-compose_db-data
C:\ProgramData\docker\volumes\ch06-docker-compose_db-data\_data

> ls C:\ProgramData\docker\volumes\ch06-docker-compose_db-data\_data

    Directory: C:\ProgramData\docker\volumes\ch06-docker-compose_db-data\_data

Mode                LastWriteTime         Length Name
----                -------------         ------ ----
-a----       12/02/2019     13:47        8388608 NerdDinner_Primary.ldf
-a----       12/02/2019     13:47        8388608 NerdDinner_Primary.mdf

卷存储在容器之外,因此当 Docker Compose 移除旧容器数据库时,所有数据都得到保留。新的数据库镜像捆绑了一个 Dacpac,并配置为对现有数据文件进行模式升级,方式与第三章中的 SQL Server 数据库相同,开发 Docker 化的.NET Framework 和.NET Core 应用

新容器启动后,我可以检查日志,看到新容器从卷中附加了数据库文件,然后修改了 Dinners 表以添加新的审计列:

> docker container logs ch06-docker-compose_nerd-dinner-db_1

VERBOSE: Starting SQL Server
VERBOSE: Changing SA login credentials
VERBOSE: Data files exist - will attach and upgrade database
Generating publish script for database 'NerdDinner' on server '.\SQLEXPRESS'.
Successfully generated script to file C:\init\deploy.sql.
VERBOSE: Changed database context to 'NerdDinner'.
VERBOSE: Altering [dbo].[Dinners]...
VERBOSE: Update complete.
VERBOSE: Deployed NerdDinner database, data files at: C:\data

新的审计列在更新行时添加了时间戳,因此现在当我通过 Web UI 创建晚餐时,我可以看到数据库中上次更新行的时间。在我的开发环境中,我还没有为客户端连接发布 SQL Server 端口,但我可以运行docker container inspect来获取容器的本地 IP 地址。然后我可以直接连接我的 SQL 客户端到容器并运行查询以查看新的审计时间戳:

Docker Compose 寻找资源及其定义之间的任何差异,而不仅仅是 Docker 镜像的名称。如果更改环境变量、端口映射、卷设置或任何其他配置,Docker Compose 将删除或创建资源,以将运行的应用程序带到所需的状态。

修改 Compose 文件以运行应用程序时需要小心。如果从文件中删除正在运行的服务的定义,Docker Compose 将不会识别现有的服务容器是应用程序的一部分,因此它们不会包含在差异检查中。您可能会遇到孤立的服务容器。

监视应用程序容器

将分布式应用程序视为单个单元可以更容易地监视和跟踪问题。Docker Compose 提供了自己的toplogs命令,这些命令可以在应用程序服务的所有容器上运行,并显示收集的结果。

要检查所有组件的内存和 CPU 使用情况,请运行docker-compose top

> docker-compose top

ch06-docker-compose_elasticsearch_1
Name          PID     CPU            Private Working Set
---------------------------------------------------------
smss.exe      21380   00:00:00.046   368.6kB
csrss.exe     11232   00:00:00.359   1.118MB
wininit.exe   16328   00:00:00.093   1.196MB
services.exe  15180   00:00:00.359   1.831MB
lsass.exe     12368   00:00:01.156   3.965MB
svchost.exe   18424   00:00:00.156   1.626MB
...

容器按名称按字母顺序列出,每个容器中的进程没有特定的顺序列出。无法更改排序方式,因此无法首先显示最密集的进程所在的最努力工作的容器,但结果是以纯文本形式呈现的,因此可以在 PowerShell 中对其进行操作。

要查看所有容器的日志条目,请运行docker-compose logs

> docker-compose logs
Attaching to ch06-docker-compose_nerd-dinner-web_1, ch06-docker-compose_nerd-dinner-save-handler_1, ch06-docker-compose_nerd-dinner-api_1, ch06-docker-compose_nerd-dinner-db_1, ch06-docker-compose_kibana_1, ch06-docker-compose_nerd-dinner-index-handler_1, ch06-docker-compose_reverse-proxy_1, ch06-docker-compose_elasticsearch_1, ch06-docker-compose_nerd-dinner-homepage_1, ch06-docker-compose_message-queue_1

nerd-dinner-web_1   | 2019-02-12 13:47:11 W3SVC1002144328 127.0.0.1 GET / - 80 - 127.0.0.1 Mozilla/5.0+(Windows+NT;+Windows+NT+10.0;+en-US)+WindowsPowerShell/5.1.17763.134 - 200 0 0 7473
nerd-dinner-web_1   | 2019-02-12 13:47:14 W3SVC1002144328 ::1 GET / - 80 - ::1 Mozilla/5.0+(Windows+NT;+Windows+NT+10.0;+en-US)+WindowsPowerShell/5.1.17763.134 - 200 0 0 9718
...

在屏幕上,容器名称以颜色编码,因此您可以轻松区分来自不同组件的条目。通过 Docker Compose 阅读日志的一个优势是,它显示所有容器的输出,即使组件显示错误并且容器已停止。这些错误消息对于在上下文中查看很有用-您可能会看到一个组件在另一个组件记录其已启动之前抛出连接错误,这可能突出了 Compose 文件中缺少的依赖关系。

Docker Compose 显示所有服务容器的所有日志条目,因此输出可能很多。您可以使用--tail选项限制输出,将输出限制为每个容器的指定数量的最近日志条目。

这些命令在开发或在单个服务器上运行少量容器的低规模项目中非常有用。对于在 Docker Swarm 上运行的跨多个主机的大型项目,它不具备可扩展性。对于这些项目,您需要以容器为中心的管理和监控,我将在第八章中进行演示,管理和监控 Docker 化解决方案

管理应用程序图像

Docker Compose 可以管理 Docker 图像,以及容器。在 Compose 文件中,您可以包括属性,告诉 Docker Compose 如何构建您的图像。您可以指定要发送到 Docker 服务的构建上下文的位置,这是所有应用程序内容的根文件夹,以及 Dockerfile 的位置。

上下文路径是相对于 Compose 文件的位置,而 Dockerfile 路径是相对于上下文的。这对于复杂的源树非常有用,比如本书的演示源,其中每个图像的上下文位于不同的文件夹中。在ch06-docker-compose-build文件夹中,我有一个完整的 Compose 文件,其中包含了应用程序规范,包括指定的构建属性。

这是我为我的图像指定构建细节的方式:

nerd-dinner-db:
  image: dockeronwindows/ch06-nerd-dinner-db:2e
 build:
    context: ../ch06-nerd-dinner-db
    dockerfile: **./Dockerfile** ...
nerd-dinner-save-handler: image: dockeronwindows/ch05-nerd-dinner-save-handler:2e build: context: ../../ch05 dockerfile: ./ch05-nerd-dinner-save-handler/Dockerfile

当您运行docker-compose build时,任何具有指定build属性的服务将被构建并标记为image属性中的名称。构建过程使用正常的 Docker API,因此仍然使用图像层缓存,只重新构建更改的层。向 Compose 文件添加构建细节是构建所有应用程序图像的一种非常有效的方式,也是捕获图像构建方式的中心位置。

Docker Compose 的另一个有用功能是能够管理整个图像组。本章的 Compose 文件使用的图像都是在 Docker Hub 上公开可用的,因此您可以使用docker-compose up运行完整的应用程序,但第一次运行时,所有图像都将被下载,这将需要一些时间。您可以在使用docker-compose pull之前预加载图像,这将拉取所有图像:

> docker-compose pull
Pulling message-queue             ... done
Pulling elasticsearch             ... done
Pulling reverse-proxy             ... done
Pulling kibana                    ... done
Pulling nerd-dinner-db            ... done
Pulling nerd-dinner-save-handler  ... done
Pulling nerd-dinner-index-handler ... done
Pulling nerd-dinner-api           ... done
Pulling nerd-dinner-homepage      ... done
Pulling nerd-dinner-web           ... done

同样,您可以使用docker-compose push将图像上传到远程存储库。对于这两个命令,Docker Compose 使用最近docker login命令的经过身份验证的用户。如果您的 Compose 文件包含您无权推送的图像,这些推送将失败。对于您有写入权限的任何存储库,无论是在 Docker Hub 还是私有注册表中,这些图像都将被推送。

配置应用程序环境

当您在 Docker Compose 中定义应用程序时,您有一个描述应用程序所有组件和它们之间集成点的单个工件。这通常被称为应用程序清单,它是列出应用程序所有部分的文档。就像 Dockerfile 明确定义了安装和配置软件的步骤一样,Docker Compose 文件明确定义了部署整个解决方案的步骤。

Docker Compose 还允许您捕获可以部署到不同环境的应用程序定义,因此您的 Compose 文件可以在整个部署流程中使用。通常,环境之间存在差异,无论是在基础设施设置还是应用程序设置方面。Docker Compose 为您提供了两种选项来管理这些环境差异——使用外部资源或使用覆盖文件。

基础设施通常在生产和非生产环境之间有所不同,这影响了 Docker 应用程序中的卷和网络。在开发笔记本电脑上,您的数据库卷可能映射到本地磁盘上的已知位置,您会定期清理它。在生产环境中,您可以为共享存储硬件设备使用卷插件。同样,对于网络,生产环境可能需要明确指定子网范围,而这在开发中并不是一个问题。

Docker Compose 允许您将资源指定为 Compose 文件之外的资源,因此应用程序将使用已经存在的资源。这些资源需要事先创建,但这意味着每个环境可以被不同配置,但仍然使用相同的 Compose 文件。

Compose 还支持另一种方法,即在不同的 Compose 文件中明确捕获每个环境的资源配置,并在运行应用程序时使用多个 Compose 文件。我将演示这两种选项。与其他设计决策一样,Docker 不会强加特定的实践,您可以使用最适合您流程的任何方法。

指定外部资源

Compose 文件中的卷和网络定义遵循与服务定义相同的模式——每个资源都有名称,并且可以使用与相关的docker ... create命令中可用的相同选项进行配置。Compose 文件中有一个额外的选项,可以指向现有资源。

为了使用现有卷来存储我的 SQL Server 和 Elasticsearch 数据,我需要指定external属性,以及可选的资源名称。在ch06-docker-compose-external目录中,我的 Docker Compose 文件具有这些卷定义:

volumes:
  es-data:
 external: 
      name: nerd-dinner-elasticsearch-data

  db-data:
 external: 
      name: nerd-dinner-database-data

声明外部资源后,我不能只使用docker-compose up来运行应用程序。Compose 不会创建定义为外部的卷;它们需要在应用程序启动之前存在。而且这些卷是服务所必需的,因此 Docker Compose 也不会创建任何容器。相反,您会看到一个错误消息:

> docker-compose up -d

ERROR: Volume nerd-dinner-elasticsearch-data declared as external, but could not be found. Please create the volume manually using `docker volume create --name=nerd-dinner-elasticsearch-data` and try again.

错误消息告诉您需要运行的命令来创建缺失的资源。这将使用默认配置创建基本卷,这将允许 Docker Compose 启动应用程序:

docker volume create --name nerd-dinner-elasticsearch-data
docker volume create --name nerd-dinner-database-data

Docker 允许您使用不同的配置选项创建卷,因此您可以指定显式的挂载点,例如 RAID 阵列或 NFS 共享。Windows 目前不支持本地驱动器的选项,但您可以使用映射驱动器作为解决方法。还有其他类型存储的驱动程序——使用云服务的卷插件,例如 Azure 存储,以及企业存储单元,例如 HPE 3PAR。

可以使用相同的方法来指定网络作为外部资源。在我的 Compose 文件中,我最初使用默认的nat网络,但在这个 Compose 文件中,我为应用程序指定了一个自定义的外部网络:

networks:
  nd-net:
    external:
 name: nerd-dinner-network

Windows 上的 Docker 有几个网络选项。默认和最简单的是网络地址转换,使用nat网络。这个驱动器将容器与物理网络隔离,每个容器在 Docker 管理的子网中都有自己的 IP 地址。在主机上,您可以通过它们的 IP 地址访问容器,但在主机外部,您只能通过发布的端口访问容器。

您可以使用nat驱动程序创建其他网络,或者您还可以使用其他驱动程序进行不同的网络配置:

  • transparent驱动器,为每个容器提供物理路由器提供的 IP 地址

  • l2bridge驱动器,用于在物理网络上指定静态容器 IP 地址

  • overlay驱动器,用于在 Docker Swarm 中运行分布式应用程序

对于我在单个服务器上使用 Traefik 的设置,nat是最佳选项,因此我将为我的应用程序创建一个自定义网络:

docker network create -d nat nerd-dinner-network

当容器启动时,我可以使用我在hosts文件中设置的nerddinner.local域来访问 Traefik。

使用外部资源可以让您拥有一个单一的 Docker Compose 文件,该文件用于每个环境,网络和卷资源的实际实现在不同环境之间有所不同。开发人员可以使用基本的存储和网络选项,在生产环境中,运维团队可以部署更复杂的基础设施。

使用 Docker Compose 覆盖

资源并不是环境之间唯一变化的东西。您还将有不同的配置设置,不同的发布端口,不同的容器健康检查设置等。对于每个环境拥有完全不同的 Docker Compose 文件可能很诱人,但这是您应该努力避免的事情。

拥有多个 Compose 文件意味着额外的开销来保持它们同步 - 更重要的是,如果它们不保持同步,环境漂移的风险。使用 Docker Compose 覆盖可以解决这个问题,并且意味着每个环境的要求都是明确说明的。

Docker Compose 默认寻找名为docker-compose.ymldocker-compose.override.yml的文件,如果两者都找到,它将使用覆盖文件来添加或替换主 Docker Compose 文件中的定义的部分。当您运行 Docker Compose CLI 时,可以传递其他文件以组合整个应用程序规范。这使您可以将核心解决方案定义保存在一个文件中,并在其他文件中具有明确的环境相关覆盖。

ch06-docker-compose-override文件夹中,我采取了这种方法。核心的docker-compose.yml文件包含了描述解决方案结构和运行开发环境的环境配置的服务定义。在同一个文件夹中有三个覆盖文件:

  • docker-compose.test.yml添加了用于测试环境的配置设置。

  • docker-compose.production.yml添加了用于生产环境的配置设置。

  • docker-compose.build.yml添加了用于构建图像的配置设置。

标准的docker-compose.yml文件可以单独使用,它会正常工作。这很重要,以确保部署过程不会给开发人员带来困难。在主文件中指定开发设置意味着开发人员只需运行docker-compose up -d,因为他们不需要了解任何关于覆盖的信息就可以开始工作。

这是docker-compose.yml中的反向代理配置,并且设置为发布随机端口并启动 Traefik 仪表板:

reverse-proxy:
  image: sixeyed/traefik:v1.7.8-windowsservercore-ltsc2019
  command: --docker --docker.endpoint=npipe:////./pipe/docker_engine --api
  ports:
   - "80"
   - "8080"
  volumes:
   - type: npipe
      source: \\.\pipe\docker_engine 
      target: \\.\pipe\docker_engine  networks:
  - nd-net

这对于可能正在为其他应用程序使用端口80的开发人员以及希望深入了解仪表板以查看 Traefik 的路由规则的开发人员非常有用。test覆盖文件将端口定义更改为在主机服务器上使用808080,但仪表板仍然暴露,因此命令部分保持不变:

reverse-proxy:
  ports:
   - "80:80"
   - "8080:8080"

production覆盖更改了启动命令,删除了命令中的--api标志,因此仪表板不会运行,它只发布端口80

reverse-proxy:
  command: --docker --docker.endpoint=npipe:////./pipe/docker_engine
  ports:
   - "80:80"

服务配置的其余部分,要使用的图像,Docker Engine 命名管道的卷挂载和要连接的网络在每个环境中都是相同的,因此覆盖文件不需要指定它们。

另一个例子是新的主页,其中包含了 Traefik 标签中的 URL 的域名。这是特定于环境的,在开发 Docker Compose 文件中,它被设置为使用nerddinner.local

nerd-dinner-homepage:
  image: dockeronwindows/ch03-nerd-dinner-homepage:2e
  labels:
   - "traefik.frontend.rule=Host:nerddinner.local;Path:/,/css/site.css"
   - "traefik.frontend.priority=10"
  networks:
   - nd-net

test覆盖文件中,域是nerd-dinner.test

nerd-dinner-homepage:
  labels:
   - "traefik.frontend.rule=Host:nerd-dinner.test;Path:/,/css/site.css"
   - "traefik.frontend.priority=10"

在生产中,是nerd-dinner.com

nerd-dinner-homepage:
  labels:
 - "traefik.frontend.rule=Host:nerd-dinner.com;Path:/,/css/site.css"
 - "traefik.frontend.priority=10"

在每个环境中,其余配置都是相同的,因此覆盖文件只指定新标签。

Docker Compose 在添加覆盖时不会合并列表的内容;新列表完全替换旧列表。这就是为什么每个文件中都有traefik.frontend.priority标签,因此您不能只在覆盖文件中的标签中有前端规则值,因为优先级值不会从主文件中的标签中合并过来。

在覆盖文件中捕获了测试环境中的其他差异:

  • SQL Server 和 Elasticsearch 端口被发布,以帮助故障排除。

  • 数据库的卷从服务器上的E:驱动器上的路径挂载,这是服务器上的 RAID 阵列。

  • Traefik 规则都使用nerd-dinner.test域。

  • 应用程序网络被指定为外部,以允许管理员创建他们自己的网络配置。

这些在生产覆盖文件中又有所不同:

  • SQL Server 和 Elasticsearch 端口不被发布,以保持它们作为私有组件。

  • 数据库的卷被指定为外部,因此管理员可以配置他们自己的存储。

  • Traefik 规则都使用nerd-dinner.com域。

  • 应用程序网络被指定为外部,允许管理员创建他们自己的网络配置。

部署到任何环境都可以简单地运行docker-compose up,指定要使用的覆盖文件:

docker-compose `
  -f docker-compose.yml `
  -f docker-compose.production.yml `
 up -d

这种方法是保持 Docker Compose 文件简单的好方法,并在单独的文件中捕获所有可变环境设置。甚至可以组合几个 Docker Compose 文件。如果有多个共享许多共同点的测试环境,可以在基本 Compose 文件中定义应用程序设置,在一个覆盖文件中共享测试配置,并在另一个覆盖文件中定义每个特定的测试环境。

总结

在本章中,我介绍了 Docker Compose,这是用于组织分布式 Docker 解决方案的工具。使用 Compose,您可以明确定义解决方案的所有组件、组件的配置以及它们之间的关系,格式简单、清晰。

Compose 文件让您将所有应用程序容器作为单个单元进行管理。您在本章中学习了如何使用docker-compose命令行来启动和关闭应用程序,创建所有资源并启动或停止容器。您还了解到,您可以使用 Docker Compose 来扩展组件或发布升级到您的解决方案。

Docker Compose 是定义复杂解决方案的强大工具。Compose 文件有效地取代了冗长的部署文档,并完全描述了应用程序的每个部分。通过外部资源和 Compose 覆盖,甚至可以捕获环境之间的差异,并构建一组 YAML 文件,用于驱动整个部署流水线。

Docker Compose 的局限性在于它是一个客户端工具。docker-compose命令需要访问 Compose 文件来执行任何命令。资源被逻辑地分组成一个单一的应用程序,但这只发生在 Compose 文件中。Docker 引擎只看到一组资源;它不认为它们是同一个应用程序的一部分。Docker Compose 也仅限于单节点 Docker 部署。

在下一章中,我将继续讲解集群化的 Docker 部署,多个节点在 Docker Swarm 中运行。在生产环境中,这为您提供了高可用性和可扩展性。Docker Swarm 是容器解决方案的强大编排器,非常易于使用。它还支持 Compose 文件格式,因此您可以使用现有的 Compose 文件部署应用程序,但 Docker 将逻辑架构存储在 Swarm 中,无需 Compose 文件即可管理应用程序。

第七章:使用 Docker Swarm 编排分布式解决方案

您可以在单台 PC 上运行 Docker,这是我在本书中迄今为止所做的,也是您在开发和基本测试环境中使用 Docker 的方式。在更高级的测试环境和生产环境中,单个服务器是不合适的。为了实现高可用性并为您提供扩展解决方案的灵活性,您需要多台作为集群运行的服务器。Docker 在平台中内置了集群支持,您可以使用 Docker Swarm 模式将多个 Docker 主机连接在一起。

到目前为止,您学到的所有概念(镜像、容器、注册表、网络、卷和服务)在集群模式下仍然适用。Docker Swarm 是一个编排层。它提供与独立的 Docker 引擎相同的 API,还具有额外的功能来管理分布式计算的各个方面。当您在集群模式下运行服务时,Docker 会确定在哪些主机上运行容器;它管理不同主机上容器之间的安全通信,并监视主机。如果集群中的服务器宕机,Docker 会安排它正在运行的容器在不同的主机上启动,以维持应用程序的服务水平。

自 2015 年发布的 Docker 1.12 版本以来,集群模式一直可用,并提供经过生产硬化的企业级服务编排。集群中的所有通信都使用相互 TLS 进行安全保护,因此节点之间的网络流量始终是加密的。您可以安全地在集群中存储应用程序机密,并且 Docker 只向那些需要访问的容器提供它们。集群是可扩展的,因此您可以轻松添加节点以增加容量,或者移除节点进行维护。Docker 还可以在集群模式下运行自动滚动服务更新,因此您可以在零停机的情况下升级应用程序。

在本章中,我将设置一个 Docker Swarm,并在多个节点上运行 NerdDinner。我将首先创建单个服务,然后转而从 Compose 文件部署整个堆栈。您将学习以下内容:

  • 创建集群和管理节点

  • 在集群模式下创建和管理服务

  • 在 Docker Swarm 中管理应用程序配置

  • 将堆栈部署到 Docker Swarm

  • 无停机部署更新

技术要求

您需要在 Windows 10 更新 18.09 或 Windows Server 2019 上运行 Docker 才能跟随示例。本章的代码可在github.com/sixeyed/docker-on-windows/tree/second-edition/ch07找到。

创建一个群集并管理节点

Docker Swarm 模式使用具有管理者和工作者高可用性的管理者-工作者架构。管理者面向管理员,您可以使用活动管理者来管理集群和运行在集群上的资源。工作者面向用户,并且他们运行您的应用程序服务的容器。

群集管理者也可以运行您的应用程序的容器,这在管理者-工作者架构中是不寻常的。管理小型群集的开销相对较低,因此如果您有 10 个节点,其中 3 个是管理者,管理者也可以运行一部分应用程序工作负载(但在生产环境中,您需要意识到在它们上运行大量应用程序工作负载可能会使管理者计算资源不足的风险)。

您可以在同一个群集中拥有 Windows 和 Linux 节点的混合,这是管理混合工作负载的好方法。建议所有节点运行相同版本的 Docker,但可以是 Docker CE 或 Docker Enterprise——Docker Swarm 功能内置于核心 Docker 引擎中。

许多在生产中运行 Docker 的企业都有一个具有 Linux 节点作为管理者的群集,以及 Windows 和 Linux 节点混合作为工作者。这意味着您可以在单个集群中使用节点操作系统的最低成本选项来运行 Windows 和 Linux 应用程序容器。

初始化群集

群集可以是任何规模。您可以在笔记本电脑上运行单节点群集来测试功能,也可以扩展到数千个节点。您可以通过使用docker swarm init命令来初始化群集:

> docker swarm init --listen-addr 192.168.2.214 --advertise-addr 192.168.2.214
Swarm initialized: current node (jea4p57ajjalioqokvmu82q6y) is now a manager.

To add a worker to this swarm, run the following command:

    docker swarm join --token SWMTKN-1-37p6ufk5jku6tndotqlcy1w54grx5tvxb3rxphj8xkdn9lbeml-3w7e8hxfzzpt2fbf340d8phia 192.168.2.214:2377

To add a manager to this swarm, run 'docker swarm join-token manager' and follow the instructions.

这将创建一个具有单个节点的群集——即您运行命令的 Docker 引擎,并且该节点将成为群集管理器。我的机器有多个 IP 地址,因此我已经指定了listen-addradvertise-addr选项,告诉 Docker 要使用哪个网络接口进行群集通信。始终指定 IP 地址并为管理节点使用静态地址是一个良好的做法。

您可以使用内部私有网络来保护您的集群,以便通信不在公共网络上。您甚至可以完全将管理节点保持在公共网络之外。只有具有面向公共的工作负载的工作节点需要连接到公共网络,除了内部网络之外-如果您正在使用负载均衡器作为基础架构的公共入口点,甚至可以避免这种情况。

将工作节点添加到集群

docker swarm init的输出告诉您如何通过加入其他节点来扩展集群。节点只能属于一个集群,并且要加入,它们需要使用加入令牌。该令牌可以防止恶意节点加入您的集群,如果网络受到损害,因此您需要将其视为安全秘密。节点可以作为工作节点或管理节点加入,并且每个节点都有不同的令牌。您可以使用docker swarm join-token命令查看和旋转令牌。

在运行相同版本的 Docker 的第二台机器上,我可以运行swarm join命令加入集群:

> docker swarm join `
   --token SWMTKN-1-37p6ufk5jku6tndotqlcy1w54grx5tvxb3rxphj8xkdn9lbeml-3w7e8hxfzzpt2fbf340d8phia `
   192.168.2.214:2377 
This node joined a swarm as a worker.

现在我的 Docker 主机正在运行在集群模式下,当我连接到管理节点时,我可以使用更多的命令。docker node命令管理集群中的节点,因此我可以列出集群中的所有节点,并使用docker node ls查看它们的当前状态:

> docker node ls
ID    HOSTNAME    STATUS   AVAILABILITY  MANAGER STATUS  ENGINE VERSION
h2ripnp8hvtydewpf5h62ja7u  win2019-02      Ready Active         18.09.2
jea4p57ajjalioqokvmu82q6y * win2019-dev-02 Ready Active Leader  18.09.2

状态值告诉您节点是否在线在集群中,可用性值告诉您节点是否能够运行容器。管理节点状态字段有三个选项:

  • 领导者:控制集群的活跃管理节点。

  • 可达:备用管理节点;如果当前领导者宕机,它可以成为领导者。

  • 无值:工作节点。

多个管理节点支持高可用性。Docker Swarm 使用 Raft 协议在当前领导者丢失时选举新领导者,因此具有奇数个管理节点,您的集群可以在硬件故障时生存。对于生产环境,您应该有三个管理节点,这就是您所需要的,即使对于具有数百个工作节点的大型集群也是如此。

工作节点不会自动晋升为管理节点,因此如果所有管理节点丢失,那么您将无法管理集群。在这种情况下,工作节点上的容器继续运行,但没有管理节点来监视工作节点或您正在运行的服务。

晋升和删除集群节点

您可以使用docker node promote将工作节点转换为管理节点,并使用docker node demote将管理节点转换为工作节点;这些是您在管理节点上运行的命令。

要离开 Swarm,您需要在节点本身上运行docker swarm leave命令:

> docker swarm leave
Node left the swarm.

如果您有单节点 Swarm,您可以使用相同的命令退出 Swarm 模式,但是您需要使用--force标志,因为这实际上将您从 Swarm 模式切换回单个 Docker Engine 模式。

docker swarmdocker node命令管理着 Swarm。当您在 Swarm 模式下运行时,您将使用特定于 Swarm 的命令来管理容器工作负载。

您将看到关于Docker SwarmSwarm 模式的引用。从技术上讲,它们是不同的东西。Docker Swarm 是一个早期的编排器,后来被构建到 Docker Engine 中作为 Swarm 模式。经典的 Docker Swarm 只在 Linux 上运行,因此当您谈论带有 Windows 节点的 Swarm 时,它总是 Swarm 模式,但通常被称为 Docker Swarm。

在云中运行 Docker Swarm

Docker 具有最小的基础设施要求,因此您可以轻松在任何云中快速启动 Docker 主机或集群 Docker Swarm。要大规模运行 Windows 容器,您只需要能够运行 Windows Server 虚拟机并将它们连接到网络。

云是运行 Docker 的好地方,而 Docker 是迁移到云的好方法。Docker 为您提供了现代应用程序平台的强大功能,而不受平台即服务PaaS)产品的限制。PaaS 选项通常具有专有的部署系统、代码中的专有集成,并且开发人员体验不会使用相同的运行时。

Docker 允许您打包应用程序并以便携方式定义解决方案结构,这样可以在任何机器和任何云上以相同的方式运行。您可以使用所有云提供商支持的基本基础设施即服务IaaS)服务,并在每个环境中实现一致的部署、管理和运行时体验。

主要的云还提供托管的容器服务,但这些服务已经集中在 Kubernetes 上——Azure 上的 AKS,Amazon Web Services 上的 EKS 和 Google Cloud 上的 GKE。在撰写本文时,它们都是 100%的 Linux 产品。对于 Kubernetes 的 Windows 支持正在积极开发中,一旦支持,云服务将开始提供 Windows 支持,但 Kubernetes 比 Swarm 更复杂,我不会在这里涵盖它。

在云中部署 Docker Swarm 的最简单方法之一是使用 Terraform,这是一种强大的基础设施即代码(IaC)技术,通常比云提供商自己的模板语言或脚本工具更容易使用。通过几十行配置,您可以定义管理节点和工作节点的虚拟机,以及网络设置、负载均衡器和任何其他所需的服务。

Docker 认证基础设施

Docker 使用 Terraform 来支持 Docker 认证基础设施(DCI),这是一个用于在主要云提供商和主要本地虚拟化工具上部署 Docker 企业的单一工具。它使用每个提供商的相关服务来设置 Docker 企业平台的企业级部署,包括通用控制平面和 Docker 可信注册表。

DCI 在 Docker 的一系列参考架构指南中有详细介绍,可在 Docker 成功中心(success.docker.com)找到。这个网站值得收藏,你还可以在那里找到关于现代化传统应用程序的指南,以及有关容器中日志记录、监控、存储和网络的最佳实践文档。

在 swarm 模式下创建和管理服务

在上一章中,您看到了如何使用 Docker Compose 来组织分布式解决方案。在 Compose 文件中,您可以使用网络将应用程序的各个部分定义为服务并将它们连接在一起。在 swarm 模式中,使用相同的 Docker Compose 文件格式和相同的服务概念。在 swarm 模式中,构成服务的容器被称为副本。您可以使用 Docker 命令行在 swarm 上创建服务,而 swarm 管理器会在 swarm 节点上作为容器运行副本。

我将通过创建服务来部署 NerdDinner 堆栈。所有服务将在我的集群上的同一个 Docker 网络中运行。在 swarm 模式下,Docker 有一种特殊类型的网络称为覆盖网络。覆盖网络是跨多个物理主机的虚拟网络,因此运行在一个 swarm 节点上的容器可以访问在另一个节点上运行的容器。服务发现的工作方式也是一样的:容器通过服务名称相互访问,Docker 的 DNS 服务器将它们指向一个容器。

要创建覆盖网络,您需要指定要使用的驱动程序并给网络命名。Docker CLI 将返回新网络的 ID,就像其他资源一样:

> docker network create --driver overlay nd-swarm
206teuqo1v14m3o88p99jklrn

您可以列出网络,您会看到新网络使用覆盖驱动程序,并且范围限定为群集,这意味着使用此网络的任何容器都可以相互通信,无论它们在哪个节点上运行:

> docker network ls
NETWORK ID          NAME                DRIVER              SCOPE
osuduab0ge73        ingress             overlay             swarm
5176f181eee8        nat                 nat                 local
206teuqo1v14        nd-swarm            overlay             swarm

这里的输出还显示了默认的nat网络,它具有本地范围,因此容器只能在同一主机上相互访问。在群集模式下创建的另一个网络称为ingress,这是具有发布端口的服务的默认网络。

我将使用新网络来部署 NerdDinner 服务,因为这将使我的应用与群集中将使用自己网络的其他应用隔离开来。我将在本章后面使用 Docker Compose 文件来部署整个解决方案,但我将首先通过手动使用docker service create命令来创建服务,以便您可以看到服务与容器的不同之处。这是如何在 Docker Swarm 中将 NATS 消息队列部署为服务的方法:

docker service create `   --network nd-swarm `
  --name message-queue ` dockeronwindows/ch05-nats:2e 

docker service create的必需选项除了镜像名称外,但对于分布式应用程序,您需要指定以下内容:

  • network:要连接到服务容器的 Docker 网络

  • name:用作其他组件 DNS 条目的服务名称

Docker 支持容器的不同类型的 DNS 解析。默认值是虚拟 IP vip 模式,您应该使用它,因为它是最高性能的。 vip 模式仅支持从 Windows Server 2019 开始,因此对于较早版本,您将看到端点模式设置为dnsrr的示例。这是 DNS 轮询模式,效率较低,并且可能会在客户端缓存 DNS 响应时引起问题,因此除非必须与 Windows Server 2016 上的容器一起工作,否则应避免使用它。

您可以从连接到群集管理器的 Docker CLI 中运行service create命令。管理器查看群集中的所有节点,并确定哪些节点有能力运行副本,然后安排任务在节点上创建为容器。默认副本级别为one,因此此命令只创建一个容器,但它可以在群集中的任何节点上运行。

docker service ps显示正在运行服务的副本,包括托管每个容器的节点的名称:

> docker service ps message-queue
ID    NAME      IMAGE     NODE  DESIRED  STATE  CURRENT    STATE
xr2vyzhtlpn5 message-queue.1  dockeronwindows/ch05-nats:2e  win2019-02  Running        Running

在这种情况下,经理已经安排了一个容器在节点win2019-02上运行,这是我集群中唯一的工作节点。看起来如果我直接在该节点上运行 Docker 容器,我会得到相同的结果,但是将其作为 Docker Swarm 服务运行给了我编排的所有额外好处:

  • 应用程序可靠性:如果此容器停止,经理将安排立即启动替代容器。

  • 基础设施可靠性:如果工作节点宕机,经理将安排在不同节点上运行新的容器。

  • 可发现性:该容器连接到一个覆盖网络,因此可以使用服务名称与在其他节点上运行的容器进行通信(Windows 容器甚至可以与同一集群中运行的 Linux 容器进行通信,反之亦然)。

在 Docker Swarm 中运行服务比在单个 Docker 服务器上运行容器有更多的好处,包括安全性、可扩展性和可靠的应用程序更新。我将在本章中涵盖它们。

在源代码存储库中,ch07-create-services文件夹中有一个脚本,按正确的顺序启动 NerdDinner 的所有服务。每个service create命令的选项相当于第六章中 Compose 文件的服务定义,使用 Docker Compose 组织分布式解决方案。前端服务和 Traefik 反向代理中只有一些差异。

Traefik 在 Docker Swarm 中运行得很好——它连接到 Docker API 来构建其前端路由映射,并且以与在单个运行 Docker Engine 的服务器上完全相同的方式代理来自后端容器的内容。要在 swarm 模式下向 Traefik 注册服务,您还需要告诉 Traefik 容器中的应用程序使用的端口,因为它无法自行确定。REST API 服务定义添加了traefik.port标签:

docker service create `   --network nd-swarm `
  --env-file db-credentials.env `
  --name nerd-dinner-api `
  --label "traefik.frontend.rule=Host:api.nerddinner.swarm"  `
  --label "traefik.port=80"  `
 dockeronwindows/ch05-nerd-dinner-api:2e

Traefik 本身是在 swarm 模式下创建的最复杂的服务,具有一些额外的选项:

docker service create `
  --detach=true `
  --network nd-swarm ` --constraint=node.role==manager `  --publish 80:80  --publish 8080:8080  `
  --mount type=bind,source=C:\certs\client,target=C:\certs `
  --name reverse-proxy `
 sixeyed/traefik:v1.7.8-windowsservercore-ltsc2019 `
  --docker --docker.swarmMode --docker.watch `
  --docker.endpoint=tcp://win2019-dev-02:2376  ` --docker.tls.ca=/certs/ca.pem `
  --docker.tls.cert=/certs/cert.pem `
  --docker.tls.key=/certs/key.pem `
  --api

你只能从运行在管理节点上的 Docker API 获取有关集群服务的信息,这就是为什么你需要将 Docker CLI 连接到管理节点以处理集群资源。服务的constraint选项确保 Docker 只会将容器调度到满足约束条件的节点上运行。在这种情况下,服务副本只会在管理节点上运行。这不是唯一的选择 - 如果你已经配置了对 Docker API 的安全远程访问,你可以在工作节点上运行 Traefik。

为了将 Traefik 连接到 Docker API,我以前使用卷来挂载 Windows 命名的pipe,但是这个功能在 Docker Swarm 中还不支持。所以,我改用 TCP 连接到 API,指定管理者的 DNS 名称win2019-dev-02。我已经用 TLS 保护了我的 Docker 引擎(就像我在第一章中解释的那样,在 Windows 上使用 Docker 入门),所以我还提供了客户端证书来安全地使用连接。证书存储在我的管理节点上的C:\certs\client中,我将其挂载为容器内的一个目录。

服务挂载的命名管道支持意味着你可以使用挂载管道的方法,这样做更容易,因为你不需要指定管理者的主机名,或者提供 TLS 证书。这个功能计划在 Docker 19.03 中推出,并且可能在你阅读本书时已经可用。Docker 的好处在于它是由开源组件构建的,因此诸如此类的功能都是公开讨论的 - github.com/moby/moby/issues/34795会告诉你背景和当前状态。

当我在我的集群上运行脚本时,我会得到一个服务 ID 列表作为输出:

> .\ch07-run-nerd-dinner.ps1
206teuqo1v14m3o88p99jklrn
vqnncr7c9ni75ntiwajcg05ym
2pzc8c5rahn25l7we3bzqkqfo
44xsmox6d8m480sok0l4b6d80
u0uhwiakbdf6j6yemuy6ey66p
v9ujwac67u49yenxk1albw4bt
s30phoip8ucfja45th5olea48
24ivvj205dti51jsigneq3z8q
beakbbv67shh0jhtolr35vg9r
sc2yzqvf42z4l88d3w31ojp1c
vx3zyxx2rubehee9p0bov4jio
rl5irw1w933tz9b5cmxyyrthn

现在我可以用docker service ls看到所有正在运行的服务:

> docker service ls
ID           NAME          MODE       REPLICAS            IMAGE 
8bme2svun122 message-queue             replicated 1/1      nats:nanoserver
deevh117z4jg nerd-dinner-homepage      replicated 1/1      dockeronwindows/ch03-nerd-dinner-homepage...
lxwfb5s9erq6 nerd-dinner-db            replicated 1/1      dockeronwindows/ch06-nerd-dinner-db:latest
ol7u97cpwdcn nerd-dinner-index-handler replicated 1/1      dockeronwindows/ch05-nerd-dinner-index...
rrgn4n3pecgf elasticsearch             replicated 1/1      sixeyed/elasticsearch:nanoserver
w7d7svtq2k5k nerd-dinner-save-handler  replicated 1/1      dockeronwindows/ch05-nerd-dinner-save...
ydzb1z1af88g nerd-dinner-web           replicated 1/1      dockeronwindows/ch05-nerd-dinner-web:latest
ywrz3ecxvkii kibana                    replicated 1/1      sixeyed/kibana:nanoserver

每个服务都列出了一个1/1的副本状态,这意味着一个副本正在运行,而请求的服务级别是一个副本。这是用于运行服务的容器数量。Swarm 模式支持两种类型的分布式服务:复制和全局。默认情况下,分布式服务只有一个副本,这意味着在集群上只有一个容器。我的脚本中的service create命令没有指定副本计数,所以它们都使用默认值one

跨多个容器运行服务

复制的服务是你如何在集群模式下扩展的方式,你可以更新正在运行的服务来添加或删除容器。与 Docker Compose 不同,你不需要一个定义每个服务期望状态的 Compose 文件;这些细节已经存储在集群中,来自docker service create命令。要添加更多的消息处理程序,我使用docker service scale,传递一个或多个服务的名称和期望的服务级别:

> docker service scale nerd-dinner-save-handler=3
nerd-dinner-save-handler scaled to 3
overall progress: 1 out of 3 tasks
1/3: starting  [============================================>      ]
2/3: starting  [============================================>      ]
3/3: running   [==================================================>]

消息处理程序服务是使用默认的单个副本创建的,因此这将添加两个容器来共享 SQL Server 处理程序服务的工作。在多节点集群中,管理器可以安排容器在任何具有容量的节点上运行。我不需要知道或关心哪个服务器实际上在运行容器,但我可以通过docker service ps深入了解服务列表,看看容器在哪里运行:

> docker service ps nerd-dinner-save-handler
ID      NAME    IMAGE  NODE            DESIRED STATE  CURRENT STATE 
sbt4c2jof0h2  nerd-dinner-save-handler.1 dockeronwindows/ch05-nerd-dinner-save-handler:2e    win2019-dev-02      Running             Running 23 minutes ago
bibmh984gdr9  nerd-dinner-save-handler.2 dockeronwindows/ch05-nerd-dinner-save-handler:2e    win2019-dev-02      Running             Running 3 minutes ago
3lkz3if1vf8d  nerd-dinner-save-handler.3 dockeronwindows/ch05-nerd-dinner-save-handler:2e   win2019-02           Running             Running 3 minutes ago

在这种情况下,我正在运行一个双节点集群,副本分布在节点win2019-dev-02win2019-02之间。集群模式将服务进程称为副本,但它们实际上只是容器。你可以登录到集群的节点,并像往常一样使用docker psdocker logsdocker top命令管理服务容器。

通常情况下,你不会这样做。运行副本的节点只是由集群为你管理的黑匣子;你通过管理节点与你的服务一起工作。就像 Docker Compose 为服务提供了日志的整合视图一样,你可以通过连接到集群管理器的 Docker CLI 获得相同的视图:

PS> docker service logs nerd-dinner-save-handler
nerd-dinner-save-handler.1.sbt4c2jof0h2@win2019-dev-02
    | Connecting to message queue url: nats://message-queue:4222
nerd-dinner-save-handler.1.sbt4c2jof0h2@win2019-dev-02
    | Listening on subject: events.dinner.created, queue: save-dinner-handler
nerd-dinner-save-handler.2.bibmh984gdr9@win2019-dev-02
    | Connecting to message queue url: nats://message-queue:4222
nerd-dinner-save-handler.2.bibmh984gdr9@win2019-dev-02
    | Listening on subject: events.dinner.created, queue: save-dinner-handler
...

副本是集群为服务提供容错的方式。当你使用docker service createdocker service updatedocker service scale命令为服务指定副本级别时,该值将记录在集群中。管理节点监视服务的所有任务。如果容器停止并且运行服务的数量低于期望的副本级别,新任务将被安排以替换停止的容器。在本章后面,我将演示当我在多节点集群上运行相同的解决方案时,我可以从集群中取出一个节点,而不会造成任何服务的丢失。

全局服务

替代复制服务的选择是全局服务。在某些情况下,您可能希望同一个服务在集群的每个节点上作为单个容器运行。为此,您可以以全局模式运行服务——Docker 在每个节点上精确安排一个任务,并且任何加入的新节点也将安排一个任务。

全局服务对于具有许多服务使用的组件的高可用性可能很有用,但是再次强调,并不是通过运行许多实例来获得集群化的应用程序。NATS 消息队列可以在多台服务器上作为集群运行,并且可以作为全局服务运行的一个很好的候选。但是,要将 NATS 作为集群运行,每个实例都需要知道其他实例的地址,这与 Docker Engine 分配的动态虚拟 IP 地址不兼容。

相反,我可以将我的 Elasticsearch 消息处理程序作为全局服务运行,因此每个节点都将运行一个消息处理程序的实例。您无法更改正在运行的服务的模式,因此首先需要删除原始服务。

> docker service rm nerd-dinner-index-handler
nerd-dinner-index-handler 

然后,我可以创建一个新的全局服务。

> docker service create `
>>  --mode=global `
>>  --network nd-swarm `
>>  --name nerd-dinner-index-handler `
>>  dockeronwindows/ch05-nerd-dinner-index-handler:2e;
q0c20sx5y25xxf0xqu5khylh7
overall progress: 2 out of 2 tasks
h2ripnp8hvty: running   [==================================================>]
jea4p57ajjal: running   [==================================================>]
verify: Service converged 

现在我在集群中的每个节点上都有一个任务在运行,如果节点被添加到集群中,任务的总数将增加,如果节点被移除,任务的总数将减少。这对于您想要分发以实现容错的服务可能很有用,并且您希望服务的总容量与集群的大小成比例。

全局服务在监控和审计功能中也很有用。如果您有诸如 Splunk 之类的集中式监控系统,或者正在使用 Elasticsearch Beats 进行基础设施数据捕获,您可以将代理作为全局服务在每个节点上运行。

通过全局和复制服务,Docker Swarm 提供了扩展应用程序和维护指定服务水平的基础设施。如果您有固定大小的集群但可变的工作负载,这对于本地部署非常有效。您可以根据需求扩展应用程序组件,只要它们不都需要在同一时间进行峰值处理。在云中,您有更多的灵活性,可以通过向集群添加新节点来增加集群的总容量,从而更广泛地扩展应用程序服务。

在许多实例中扩展运行应用程序通常会增加复杂性 - 您需要一种注册所有活动实例的方式,一种在它们之间共享负载的方式,以及一种监视所有实例的方式,以便如果有任何实例失败,它们不会有任何负载发送到它们。这一切都是 Docker Swarm 中内置的功能,它透明地提供服务发现、负载均衡、容错和自愈应用程序的基础设施。

Swarm 模式中的负载均衡和扩展

Docker 使用 DNS 进行服务发现,因此容器可以通过标准网络找到彼此。应用程序在其客户端连接配置中使用服务器名称,当应用程序进行 DNS 查询以找到目标时,Docker 会响应容器的 IP 地址。在 Docker Swarm 中也是一样的,当您的目标服务器名称实际上可能是在集群中运行着数十个副本的 Docker 服务的名称。

Docker 有两种方式来管理具有多个副本的服务的 DNS 响应。默认情况下是使用VIP:虚拟 IP 地址。Docker 为服务使用单个 IP 地址,并依赖于主机操作系统中的网络堆栈将 VIP 上的请求路由到实际的容器。VIP 负责负载均衡和健康。请求被分享给服务中的所有健康容器。这个功能在 Linux 中已经存在很久了,在 Windows Server 2019 中是新功能。

VIP 的替代方案是DNSRRDNS 轮询,您可以在服务配置中的endpoint_mode设置中指定。DNSRR 返回服务中所有健康容器的 IP 地址列表,并且列表的顺序会轮换以提供负载均衡。在 Windows Server 2019 之前,DNSRR 是 Windows 容器的唯一选项,并且您会在许多示例中看到它,但 VIP 是更好的选择。客户端有缓存 DNS 查询响应的倾向。使用 DNSRR,您可以更新一个服务并发现客户端已经缓存了一个已被替换的旧容器的 IP 地址,因此它们的连接失败。这在 VIP 中不会发生,在 VIP 中,DNS 响应中有一个单一的 IP 地址,客户端可以安全地缓存它,因为它总是会路由到一个健康的容器。

Docker Swarm 负责在服务副本之间负载平衡网络流量,但它也负责负载平衡进入集群的外部流量。在新的 NerdDinner 架构中,只有一个组件是公开访问的——Traefik 反向代理。我们知道一个端口在一台机器上只能被一个进程使用,所以这意味着我们只能将代理服务扩展到集群中每个节点的最大一个容器。但是 Docker Swarm 允许我们过度或不足地提供服务,使用机器上的相同端口来处理零个或多个副本。

附加到覆盖网络的集群服务在发布端口时与标准容器的行为不同。集群中的每个节点都监听发布的端口,当接收到流量时,它会被定向到一个健康的容器。该容器可以在接收请求的节点上运行,也可以在不同的节点上运行。

在这个例子中,客户端在 Docker Swarm 中运行的服务的标准端口80上进行了 HTTP GET 请求:

  1. 客户端请求到达一个没有运行任何服务副本的节点。该节点没有在端口80上监听的容器,因此无法直接处理请求。

  2. 接收节点将请求转发到集群中另一个具有在端口80上监听的容器的节点——这对原始客户端来说是不可见的。

  3. 新节点将请求转发到正在运行的容器,该容器处理请求并发送响应。

这被称为入口网络,它是一个非常强大的功能。这意味着您可以在大型集群上运行低规模的服务,或者在小型集群上运行高规模的服务,它们将以相同的方式工作。如果服务的副本少于集群中的节点数,这不是问题,因为 Docker 会透明地将请求发送到另一个节点。如果服务的副本多于节点数,这也不是问题,因为每个节点都可以处理请求,Docker 会在节点上的容器之间负载平衡流量。

Docker Swarm 中的网络是一个值得详细了解的主题,因为它将帮助您设计和交付可扩展和具有弹性的系统。我编写了一门名为在 Docker Swarm 模式集群中管理负载平衡和扩展的 Pluralsight 课程,涵盖了 Linux 和 Windows 容器的所有关键主题。

负载均衡和服务发现都基于健康的容器,并且这是 Docker Swarm 的一个功能,不需要我进行任何特殊设置。在群集模式下运行的服务默认为 VIP 服务发现和用于发布端口的入口网络。当我在 Docker Swarm 中运行 NerdDinner 时,我不需要对我的部署进行任何更改,就可以在生产环境中获得高可用性和扩展性,并且可以专注于自己应用程序的配置。

在 Docker Swarm 中管理应用程序配置

我在第五章中花了一些时间,采用基于容器的解决方案设计,为 NerdDinner 堆栈构建了一个灵活的配置系统。其中的核心原则是将开发的默认配置捆绑到每个镜像中,但允许在运行容器时覆盖设置。这意味着我们将在每个环境中使用相同的 Docker 镜像,只是交换配置设置以更改行为。

这适用于单个 Docker 引擎,我可以使用环境变量来覆盖单个设置,并使用卷挂载来替换整个配置文件。在 Docker Swarm 中,您可以使用 Docker 配置对象和 Docker 秘密来存储可以传递给容器的群集中的数据。这比使用环境变量和文件更加整洁地处理配置和敏感数据,但这意味着我在每个环境中仍然使用相同的 Docker 镜像。

在 Docker 配置对象中存储配置

在群集模式中有几种新资源 - 除了节点和服务外,还有堆栈、秘密和配置。配置对象只是在群集中创建的文本文件,并在服务容器内部作为文件出现。它们是管理配置设置的绝佳方式,因为它们为您提供了一个存储所有应用程序设置的单一位置。

您可以以两种方式使用配置对象。您可以使用docker config命令创建和管理它们,并在 Docker 服务命令和 Docker Compose 文件中使其对服务可用。这种清晰的分离意味着您的应用程序定义与配置分开 - 定义在任何地方都是相同的,而配置是由 Docker 从环境中加载的。

Docker 将配置对象表面化为容器内的文本文件,位于您指定的路径,因此您可以在 swarm 中拥有一个名为my-app-config的秘密,显示为C:\my-app\config\appSettings.config。Docker 不关心文件内容,因此它可以是 XML、JSON、键值对或其他任何内容。由您的应用程序实际执行文件的操作,这可以是使用完整文件作为配置,或将文件内容与 Docker 镜像中内置的一些默认配置合并。

在我现代化 NerdDinner 时,我已经为我的应用程序设置转移到了.NET Core 配置框架。我在组成 NerdDinner 的所有.NET Framework 和.NET Core 应用程序中都使用相同的Config类。Config类为配置提供程序添加了自定义文件位置:

public  static  IConfigurationBuilder  AddProviders(IConfigurationBuilder  config) {
  return  config.AddJsonFile("config/appsettings.json")
               .AddEnvironmentVariables()
               .AddJsonFile("config/config.json", optional: true)
               .AddJsonFile("config/secrets.json", optional: true); } 

配置提供程序按优先顺序倒序列出。首先,它们从应用程序镜像的config/appsettings.json文件中加载。然后,合并任何环境变量-添加新键,或替换现有键的值。接下来,如果路径config/config.json处存在文件,则其内容将被合并-覆盖任何现有设置。最后,如果路径config/secrets.json处存在文件,则其值将被合并。

这种模式让我可以使用一系列配置源。应用程序的默认值都存在于 Docker 镜像中。在运行时,用户可以使用环境变量或环境变量文件指定覆盖值-这对于在单个 Docker 主机上工作的开发人员来说很容易。在集群环境中,部署可以使用 Docker 配置对象和秘密,这将覆盖默认值和任何环境变量。

举个简单的例子,我可以更改新 REST API 的日志级别。在 Docker 镜像的appsettings.json文件中,日志级别设置为Warning。每次有GET请求时,应用程序都会写入信息级别的日志,因此如果我在配置中更改日志级别,我将能够看到这些日志条目。

我想要在名为nerd-dinner-api-config.json的文件中使用我想要的设置:

{
  "Logging": {
  "LogLevel": {
   "Default": "Information"
  } 
} }

首先,我需要将其存储为 swarm 中的配置对象,因此容器不需要访问原始文件。我使用docker config create来实现这一点,给对象一个名称和配置源的路径。

docker config create nerd-dinner-api-config .\configs\nerd-dinner-api-config.json

您只需要在创建配置对象时访问该文件。现在数据存储在 swarm 中。swarm 中的任何节点都可以获取配置数据并将其提供给容器,任何具有对 Docker Engine 访问权限的人都可以查看配置数据,而无需该源文件。docker config inspect会显示配置对象的内容。

> docker config inspect --pretty nerd-dinner-api-config
ID:                     yongm92k597gxfsn3q0yhnvtb
Name:                   nerd-dinner-api-config
Created at:             2019-02-13 22:09:04.3214402 +0000 utc
Updated at:             2019-02-13 22:09:04.3214402 +0000 utc
Data:
{
 "Logging": {
 "LogLevel": {
 "Default": "Information"
    }
 }
}

您可以通过检查来查看配置对象的纯文本值。这对于故障排除应用程序问题非常有用,但对于安全性来说不好——您应该始终使用 Docker secrets 来存储敏感配置值,而不是配置对象。

在 swarm 服务中使用 Docker 配置对象

在创建服务时,您可以使用--config选项将配置对象提供给容器。然后,您应该能够直接在应用程序中使用它们,但可能会有一个陷阱。当将配置对象作为文件呈现给容器时,它们受到保护,只有管理帐户才能读取它们。如果您的应用程序以最低特权用户身份运行,它可以看到配置文件,但无法读取它。这是一个安全功能,旨在在某人获得对容器中文件系统的访问权限时保护您的配置文件。

在 Linux 容器中情况就不同了,您可以指定在容器内具有文件所有权的用户 ID,因此可以让最低特权帐户访问该文件。Windows 容器不支持该功能,但 Windows 容器正在不断发展,以实现与 Linux 容器功能完备,因此这应该会在未来的版本中实现。在撰写本文时,要使用配置对象,应用程序需要以管理员帐户或具有本地系统访问权限的帐户运行。

以提升的权限运行应用程序在安全角度不是一个好主意,但当您在容器中运行时,这就不那么值得关注了。我在《第九章》中涵盖了这一点,了解 Docker 的安全风险和好处

我已经更新了来自《第五章》采用基于容器的解决方案设计的 REST API 的 Dockerfile,以使用容器中的内置管理员帐户:

# escape=` FROM microsoft/dotnet:2.1-aspnetcore-runtime-nanoserver-1809 EXPOSE 80 WORKDIR /dinner-api ENTRYPOINT ["dotnet", "NerdDinner.DinnerApi.dll"] USER ContainerAdministrator COPY --from=dockeronwindows/ch05-nerd-dinner-builder:2e C:\dinner-api .

改变的只是USER指令,它设置了 Dockerfile 的其余部分和容器启动的用户。代码完全相同:我仍然使用来自第五章的构建器镜像,采用面向容器的解决方案设计。我已将此新镜像构建为dockeronwindows/ch07-nerd-dinner-api:2e,并且可以升级正在运行的 API 服务并应用新配置与docker service update

docker service update `
  --config-add src=nerd-dinner-api-config,target=C:\dinner-api\config\config.json `
  --image dockeronwindows/ch07-nerd-dinner-api:2e  `
 nerd-dinner-api;

更新服务将正在运行的副本替换为新配置,在本例中,使用新镜像并应用配置对象。现在,当我对 REST API 进行GET请求时,它会以信息级别记录日志,并且我可以在服务日志中看到更多细节:

> docker service logs nerd-dinner-api
nerd-dinner-api.1.cjurm8tg1lmj@win2019-02    | Hosting environment: Production
nerd-dinner-api.1.cjurm8tg1lmj@win2019-02    | Content root path: C:\dinner-api
nerd-dinner-api.1.cjurm8tg1lmj@win2019-02    | Now listening on: http://[::]:80
nerd-dinner-api.1.cjurm8tg1lmj@win2019-02    | Application started. Press Ctrl+C to shut down.
nerd-dinner-api.1.cjurm8tg1lmj@win2019-02    | info: Microsoft.AspNetCore.Hosting.Internal.WebHost[1]
nerd-dinner-api.1.cjurm8tg1lmj@win2019-02    |       Request starting HTTP/1.1 GET http://api.nerddinner.swarm/api/dinners
nerd-dinner-api.1.cjurm8tg1lmj@win2019-02    | info: Microsoft.AspNetCore.Mvc.Internal.ControllerActionInvoker[1]
nerd-dinner-api.1.cjurm8tg1lmj@win2019-02    |       Route matched with {action = "Get", controller = "Dinners"}. Executing action NerdDinner.DinnerApi.Controllers.DinnersController.Get (NerdDinner.DinnerApi)

您可以使用此方法来处理在不同环境之间更改的功能标志和行为设置。这是一种非常灵活的应用程序配置方法。使用单个 Docker 引擎的开发人员可以使用镜像中的默认设置运行容器,或者使用环境变量覆盖它们,或者通过挂载本地卷替换整个配置文件。在使用 Docker Swarm 的测试和生产环境中,管理员可以使用配置对象集中管理配置,而在每个环境中仍然使用完全相同的 Docker 镜像。

在 Docker secrets 中存储敏感数据

Swarm 模式本质上是安全的。所有节点之间的通信都是加密的,并且 swarm 提供了分布在管理节点之间的加密数据存储。您可以将此存储用于应用程序秘密。秘密的工作方式与配置对象完全相同-您在 swarm 中创建它们,然后使它们对服务可用。不同之处在于,秘密在 swarm 的数据存储中是加密的,并且在从管理节点到工作节点的传输中也是加密的。它只在运行副本的容器内解密,然后以与配置对象相同的方式作为文件显示。

秘密是通过名称和秘密内容创建的,可以从文件中读取或输入到命令行中。我打算将我的敏感数据移动到 secrets 中,首先是 SQL Server 管理员帐户密码。在ch07-app-config文件夹中,我有一个名为secrets的文件夹,其中包含数据库密码的秘密文件。我将使用它来安全地存储密码在 swarm 中,但在数据库镜像支持秘密之前,我需要对其进行一些工作。

我将最新的 SQL Server 数据库架构打包到 Docker 镜像dockeronwindows/ch06-nerd-dinner-db中。该映像使用环境变量来设置管理员密码,这对开发人员来说很好,但在测试环境中不太好,因为您希望限制访问。我为本章准备了一个更新的版本,其中包括用于数据库的更新的 Dockerfile 和启动脚本,因此我可以从秘密文件中读取密码。

ch07-nerd-dinner-dbInitializeDatabase.ps1脚本中,我添加了一个名为sa_password_path的新参数,并添加了一些简单的逻辑,以从文件中读取密码,如果该路径中存在文件:

if ($sa_password_path  -and (Test-Path  $sa_password_path)) {
  $password  =  Get-Content  -Raw $sa_password_path
  if ($password) {
    $sa_password  =  $password
    Write-Verbose  "Using SA password from secret file: $sa_password_path" }

这是一种完全不同的方法,与 REST API 中采用的方法相反。应用程序对配置有自己的期望,您需要将其与 Docker 的方法整合起来,以在文件中显示配置数据。在大多数情况下,您可以在 Dockerfile 中完成所有操作,因此不需要更改代码直接从文件中读取配置。

Dockerfile 使用具有密码文件路径的默认值的环境变量:

ENV sa_password_path="C:\secrets\sa-password"

这仍然支持以不同方式运行数据库。开发人员可以在不指定任何配置设置的情况下运行它,并且它将使用内置于映像中的默认密码,这与应用程序映像的连接字符串中的相同默认密码相同。在集群环境中,管理员可以单独创建秘密,而无需部署应用程序,并安全地访问数据库容器。

我需要创建秘密,然后更新数据库服务以使用秘密和应用密码的新映像:

docker secret create nerd-dinner-db-sa-password .\secrets\nerd-dinner-db-sa-password.txt; docker service update `
  --secret-add src=nerd-dinner-db-sa-password,target=C:\secrets\sa-password `
  --image dockeronwindows/ch07-nerd-dinner-db:2e  `
 nerd-dinner-db;

现在数据库正在使用由 Docker Swarm 保护的强密码。可以访问 Docker 引擎的用户无法看到秘密的内容,因为它只在明确使用秘密的服务的容器内解密。我可以检查秘密,但我只能看到元数据:

> docker secret inspect --pretty nerd-dinner-db-sa-password
ID:              u2zsrjouhicjnn1fwo5x8jqpk
Name:              nerd-dinner-db-sa-password
Driver:
Created at:        2019-02-14 10:33:04.0575536 +0000 utc
Updated at:        2019-02-14 10:33:04.0575536 +0000 utc

现在我的应用程序出现了问题,因为我已更新了数据库密码,但没有更新使用数据库的应用程序中的连接字符串。这是通过向 Docker Swarm 发出命令来管理分布式应用程序的危险。相反,您应该使用 Docker Compose 文件以声明方式管理应用程序,定义所有服务和其他资源,并将它们部署为 Docker 堆栈。

将堆栈部署到 Docker Swarm

Docker Swarm 中的堆栈解决了在单个主机上使用 Docker Compose 或在 Docker Swarm 上手动创建服务的限制。您可以从 Compose 文件创建堆栈,并且 Docker 将堆栈服务的所有元数据存储在 Swarm 中。这意味着 Docker 知道这组资源代表一个应用程序,您可以在任何 Docker 客户端上管理服务,而无需 Compose 文件。

堆栈是对构成您的应用程序的所有对象的抽象。它包含服务、卷和网络,就像标准的 Docker Compose 文件一样,但它还支持 Docker Swarm 对象——配置和密码——以及用于在规模上运行应用程序的附加部署设置。

堆栈甚至可以抽象出您正在使用的编排器。Docker Enterprise 同时支持 Docker Swarm 和 Kubernetes 在同一集群上,并且您可以使用简单的 Docker Compose 格式和 Docker CLI 将应用程序部署和管理为堆栈到任一编排器。

使用 Docker Compose 文件定义堆栈

Docker Compose 文件模式已经从支持单个 Docker 主机上的客户端部署发展到 Docker Swarm 上的堆栈部署。不同的属性集在不同的场景中是相关的,并且工具会强制执行。Docker Compose 将忽略仅适用于堆栈部署的属性,而 Docker Swarm 将忽略仅适用于单节点部署的属性。

我可以利用多个 Compose 文件来实现这一点,在一个文件中定义应用程序的基本设置,在一个覆盖文件中添加本地设置,并在另一个覆盖文件中添加 Swarm 设置。我已经在ch07-docker-compose文件夹中的 Compose 文件中这样做了。docker-compose.yml中的核心服务定义现在非常简单,它们只包括适用于每种部署模式的属性。甚至 Traefik 的反向代理定义也很简单:

reverse-proxy:
  image: sixeyed/traefik:v1.7.8-windowsservercore-ltsc2019
  networks:
 - nd-net 

docker-compose.local.yml覆盖文件中,我添加了在我的笔记本电脑上开发应用程序和使用 Docker Compose 部署时相关的属性。对于 Traefik,我需要配置要运行的命令以及要发布的端口,并挂载一个用于 Docker Engine 命名管道的卷:

reverse-proxy:
  command: --docker --docker.endpoint=npipe:////./pipe/docker_engine --api
  ports:
  - "80"
  - "8080"
  volumes:
  - type: npipe
     source: \\.\pipe\docker_engine
     target: \\.\pipe\docker_engine 

docker-compose.swarm.yml 覆盖文件中,我有一个属性,当我在集群化的 Docker Swarm 环境中运行时应用——这可能是测试中的两节点 swarm 和生产中的 200 节点 swarm;Compose 文件将是相同的。 我设置了 Traefik 命令以使用 TCP 连接到 swarm 管理器,并且我正在使用 secrets 在 swarm 中存储 TLS 证书:

reverse-proxy:
  command: --docker --docker.swarmMode --docker.watch --docker.endpoint=tcp://win2019-dev-02:2376  
           --docker.tls.ca=/certs/ca.pem --docker.tls.cert=/certs/cert.pem ...
  ports:
   - "80:80"
   - "8080:8080"
  secrets:
   - source: docker-client-ca
      target: C:\certs\ca.pem
   - source: docker-client-cert
      target: C:\certs\cert.pem - source: docker-client-key target: C:\certs\key.pem
  deploy:
   placement:
     constraints:
      - node.role == manager

这个应用程序清单的唯一不可移植部分是我的 swarm 管理器的 DNS 名称 win2019-dev-02。 我在第六章中解释过,使用 Docker Compose 组织分布式解决方案,在 swarm 模式下还不能挂载命名管道,但很快就会推出。 当该功能到来时,我可以在 swarm 模式下像在单个 Docker 引擎上一样使用命名管道来使用 Traefik,并且我的 Compose 文件将在任何 Docker 集群上运行。

其余服务的模式相同:compose.yml 中有基本定义,本地文件中有开发人员的一组覆盖,以及一组替代覆盖在 swarm 文件中。 核心 Compose 文件不能单独使用,因为它没有指定的所有配置,这与第六章中的不同,使用 Docker Compose 组织分布式解决方案,我的 Docker Compose 文件是为开发设置的。 您可以使用最适合您的任何方法,但这种方式的优势在于每个环境的设置都在其自己的覆盖文件中。

有几个值得更详细查看的服务选项。 REST API 在核心 Compose 文件中定义,只需图像和网络设置。 本地覆盖添加了用于向代理注册 API 的标签,并且还捕获了对数据库服务的依赖关系:

nerd-dinner-api:
  depends_on:
   - nerd-dinner-db
  labels:
   - "traefik.frontend.rule=Host:api.nerddinner.local"

Swarm 模式不支持 depends_on 属性。 当您部署堆栈时,无法保证服务将以何种顺序启动。 如果您的应用程序组件具有 retry 逻辑以解决任何依赖关系,那么服务启动顺序就无关紧要。 如果您的组件不具有弹性,并且在无法访问依赖项时崩溃,那么 Docker 将重新启动失败的容器,并且经过几次重试后应用程序应该准备就绪。

传统应用程序通常缺乏弹性,它们假设它们的依赖始终可用并能立即响应。如果您转移到云服务,容器也是如此。Docker 将不断替换失败的容器,但即使对传统应用程序,您也可以通过在 Dockerfile 中构建启动检查和健康检查来增加弹性。

Swarm 定义添加了秘密和配置设置,容器标签的应用方式也有所不同。

nerd-dinner-api:
  configs:
   - source: nerd-dinner-api-config
      target: C:\dinner-api\config\config.json
  secrets:
   - source: nerd-dinner-api-secrets
      target: C:\dinner-api\config\secrets.json
  deploy:
  replicas: 2  labels:
     - "traefik.frontend.rule=Host:api.nerddinner.swarm"
     - "traefik.port=80" 

配置和秘密只适用于 Swarm 模式,但可以在任何 Compose 文件中包含它们——当您在单个 Docker 引擎上运行时,Docker Compose 会忽略它们。deploy部分也只适用于 Swarm 模式,它捕获了副本的基础架构设置。在这里,我有一个副本计数为 2,这意味着 Swarm 将为此服务运行两个容器。我还在deploy部分下有 Traefik 的标签,这确保了标签被应用到容器上,而不是服务本身。

Docker 使用标签来注释任何类型的对象——卷、节点、服务、秘密、容器和任何其他 Docker 资源都可以添加或删除标签,并且它们以键值对的形式暴露在 Docker Engine API 中。Traefik 只查找容器标签,这些标签在 Compose 文件的deploy部分中在 Swarm 模式下应用。如果您直接在服务部分下有标签,那么它们将被添加到服务而不是容器。在这种情况下,这意味着容器上没有标签,因此 Traefik 不会注册任何路由。

在 Docker Compose 文件中定义 Swarm 资源

在本章中,核心的docker-compose.yml文件只包含一个services部分;没有指定其他资源。这是因为我的应用程序的资源在单个 Docker 引擎部署和 Docker Swarm 之间都是不同的。

本地覆盖文件使用现有的nat网络,并对 SQL Server 和 Elasticsearch 使用默认规范的卷。

networks:
  nd-net:
    external:
      name: nat volumes:
  ch07-es-data: ch07-db-data:

Swarm 覆盖将所有服务附加到的相同nd-net网络映射为一个名为nd-swarm的外部网络,这个网络需要在我部署此堆栈之前存在。

networks:
  nd-net:
    external:
      name: nd-swarm

在集群覆盖中没有定义卷。在集群模式下,您可以像在单个 Docker 引擎上使用卷一样使用它们,但您可以选择使用不同的驱动程序,并将存储设备连接到数据中心或云存储服务以连接到您的容器卷。

Docker 中的存储本身就是一个完整的主题。我在我的 Pluralsight 课程在 Docker 中处理数据和有状态应用程序中详细介绍了它。在那门课程中,我演示了如何在桌面上以及在 Docker Swarm 中以高可用性和规模的方式运行有状态的应用程序和数据库。

在集群覆盖文件中有另外两个部分,涵盖了我的应用程序配置:

configs: nerd-dinner-api-config: external: true
  nerd-dinner-config: 
    external: true

secrets:
  nerd-dinner-db-sa-password:
    external: true nerd-dinner-save-handler-secrets: external: true nerd-dinner-api-secrets: external: true nerd-dinner-secrets: external: true

如果您看到这些并认为这是很多需要管理的configssecrets,请记住,这些是您的应用程序无论在哪个平台上都需要的配置数据。Docker 的优势在于所有这些设置都被集中存储和管理,并且如果它们包含敏感数据,您可以选择安全地存储和传输它们。

我的所有配置和秘密对象都被定义为外部资源,因此它们需要在集群中存在才能部署我的应用程序。在ch07-docker-stack目录中有一个名为apply-configuration.ps1的脚本,其中包含所有的docker config createdocker secret create命令:

> .\apply-configuration.ps1
ntkafttcxvf5zjea9axuwa6u9
razlhn81s50wrqojwflbak6qx
npg65f4g8aahezqt14et3m31l
ptofylkbxcouag0hqa942dosz
dwtn1q0idjz6apbox1kh512ns
reecdwj5lvkeugm1v5xy8dtvb
nyjx9jd4yzddgrb2nhwvqjgri
b3kk0hkzykiyjnmknea64e3dk
q1l5yp025tqnr6fd97miwii8f

输出是新对象 ID 的列表。现在所有资源都存在,我可以将我的应用程序部署为一个堆栈。

从 Docker Compose 文件部署集群堆栈

我可以通过在开发笔记本上指定多个 Compose 文件(核心文件和本地覆盖)来使用 Docker Compose 部署应用程序。在集群模式下,您使用标准的docker命令,而不是docker-compose来部署堆栈。Docker CLI 不支持堆栈部署的多个文件,但我可以使用 Docker Compose 将源文件合并成一个单独的堆栈文件。这个命令从两个 Compose 文件中生成一个名为docker-stack.yml的单个 Compose 文件,用于堆栈部署:

docker-compose -f docker-compose.yml -f docker-compose.swarm.yml config > docker-stack.yml

Docker Compose 合并输入文件并检查输出配置是否有效。我将输出保存在一个名为docker-stack.yml的文件中。这是一个额外的步骤,可以轻松地融入到您的部署流程中。现在我可以使用包含核心服务描述、秘密和部署配置的堆栈文件在集群上部署我的堆栈。

您可以使用单个命令docker stack deploy从 Compose 文件中部署堆栈。您需要传递 Compose 文件的位置和堆栈的名称,然后 Docker 将创建 Compose 文件中的所有资源:

> docker stack deploy --compose-file docker-stack.yml nerd-dinner
Creating service nerd-dinner_message-queue
Creating service nerd-dinner_elasticsearch
Creating service nerd-dinner_nerd-dinner-api
Creating service nerd-dinner_kibana
Creating service nerd-dinner_nerd-dinner-index-handler
Creating service nerd-dinner_nerd-dinner-save-handler
Creating service nerd-dinner_reverse-proxy
Creating service nerd-dinner_nerd-dinner-web
Creating service nerd-dinner_nerd-dinner-homepage
Creating service nerd-dinner_nerd-dinner-db

结果是一组资源被逻辑地组合在一起形成堆栈。与 Docker Compose 不同,后者依赖命名约定和标签来识别分组,堆栈在 Docker 中是一等公民。我可以列出所有堆栈,这给我基本的细节——堆栈名称和堆栈中的服务数量:

> docker stack ls
NAME                SERVICES            ORCHESTRATOR
nerd-dinner         10                  Swarm

我的堆栈中有 10 个服务,从一个包含 137 行 YAML 的单个 Docker Compose 文件部署。对于这样一个复杂的系统来说,这是一个很小的配置量:两个数据库,一个反向代理,多个前端,一个 RESTful API,一个消息队列和多个消息处理程序。这样大小的系统通常需要一个运行数百页的 Word 部署文档,并且需要一个周末的手动工作来运行所有步骤。我只用了一个命令来部署这个系统。

我还可以深入了解运行堆栈的容器的状态和它们所在的节点,使用docker stack ps,或者使用docker stack services来获得堆栈中服务的更高级视图。

> docker stack services nerd-dinner
ID              NAME       MODE        REPLICAS        IMAGE
3qc43h4djaau  nerd-dinner_nerd-dinner-homepage       replicated  2/2       dockeronwindows/ch03...
51xrosstjd79  nerd-dinner_message-queue              replicated  1/1       dockeronwindows/ch05...
820a4quahjlk  nerd-dinner_elasticsearch              replicated  1/1       sixeyed/elasticsearch...
eeuxydk6y8vp  nerd-dinner_nerd-dinner-web            replicated  2/2       dockeronwindows/ch07...
jlr7n6minp1v  nerd-dinner_nerd-dinner-index-handler  replicated  2/2       dockeronwindows/ch05...
lr8u7uoqx3f8  nerd-dinner_nerd-dinner-save-handler   replicated  3/3       dockeronwindows/ch05...
pv0f37xbmz7h  nerd-dinner_reverse-proxy              replicated  1/1       sixeyed/traefik...
qjg0262j8hwl  nerd-dinner_nerd-dinner-db             replicated  1/1       dokeronwindows/ch07...
va4bom13tp71  nerd-dinner_kibana                     replicated  1/1       sixeyed/kibana...
vqdaxm6rag96  nerd-dinner_nerd-dinner-api            replicated  2/2       dockeronwindows/ch07...

这里的输出显示我有多个副本运行前端容器和消息处理程序。总共,在我的两节点集群上有 15 个容器在运行,这是两个虚拟机,总共有四个 CPU 核心和 8GB 的 RAM。在空闲时,容器使用的计算资源很少,我有足够的容量来运行额外的堆栈。我甚至可以部署相同堆栈的副本,为代理使用不同的端口,然后我可以在相同的硬件上运行两个完全独立的测试环境。

将服务分组到堆栈中可以更轻松地管理应用程序,特别是当您有多个应用程序在运行,每个应用程序中有多个服务时。堆栈是对一组 Docker 资源的抽象,但您仍然可以直接管理单个资源。如果我运行docker service rm,它将删除一个服务,即使该服务是堆栈的一部分。当我再次运行docker stack deploy时,Docker 会发现堆栈中缺少一个服务,并重新创建它。

当涉及到使用新的镜像版本或更改服务属性来更新应用程序时,您可以采取命令式方法直接修改服务,或者通过修改堆栈文件并再次部署来保持声明性。Docker 不会强加给您任何流程,但最好保持声明性,并将 Compose 文件用作唯一的真相来源。

我可以通过在堆栈文件的部署部分添加replicas: 2并再次部署它,或者通过运行docker service update --replicas=2 nerd-dinner_nerd-dinner-save-handler来扩展解决方案中的消息处理程序。如果我更新了服务但没有同时更改堆栈文件,那么下次部署堆栈时,我的处理程序将减少到一个副本。堆栈文件被视为期望的最终状态,如果当前状态偏离了,那么在再次部署时将进行纠正。

使用声明性方法意味着您始终在 Docker Compose 文件中进行这些更改,并通过再次部署堆栈来更新应用程序。Compose 文件与您的 Dockerfiles 和应用程序源代码一起保存在源代码控制中,因此它们可以进行版本控制、比较和标记。这意味着当您拉取应用程序的任何特定版本的源代码时,您将拥有构建和部署所需的一切。

秘密和配置是例外,您应该将它们保存在比中央源代码库更安全的位置,并且只有管理员用户才能访问明文。Compose 文件只是引用外部秘密,因此您可以在源代码控制中获得应用程序清单的唯一真相来源的好处,而敏感数据则保留在外部。

在开发和测试环境中运行单个节点或双节点集群是可以的。我可以将完整的 NerdDinner 套件作为一个堆栈运行,验证堆栈文件是否正确定义,并且可以扩展和缩小以检查应用程序的行为。这并不会给我带来高可用性,因为集群只有一个管理节点,所以如果管理节点下线,那么我就无法管理堆栈。在数据中心,您可以运行一个拥有数百个节点的集群,并且通过三个管理节点获得完整的高可用性。

您可以在云中运行它,构建具有更高可用性和规模弹性的群集。所有主要的云运营商都支持其 IaaS 服务中的 Docker,因此您可以轻松地启动预安装了 Docker 的 Linux 和 Windows VM,并使用本章中所见的简单命令将它们加入到群集中。

Docker Swarm 不仅仅是在集群中规模化运行应用程序。在多个节点上运行使我具有高可用性,因此在发生故障时我的应用程序可以继续运行,并且我可以利用它来支持应用程序生命周期,实现零停机滚动更新和自动回滚。

无停机部署更新

Docker Swarm 具有两个功能,可以在不影响应用程序的情况下更新整个堆栈-滚动更新和节点排空。滚动更新在您有一个组件的新版本要发布时,用新图像的新实例替换应用程序容器。更新是分阶段进行的,因此只要您有多个副本,就会始终有任务在运行以提供请求,同时其他任务正在升级。

应用程序更新将频繁发生,但您还需要定期更新主机,无论是升级 Docker 还是应用 Windows 补丁。Docker Swarm 支持排空节点,这意味着在节点上运行的所有容器都将停止,并且不会再安排更多容器。如果在排空节点时服务的副本级别下降,任务将在其他节点上启动。当节点排空时,您可以更新主机,然后将其加入到群集中。

我将通过覆盖这两种情况来完成本章。

更新应用程序服务

我在 Docker Swarm 上运行我的堆栈,现在我要部署一个应用程序更新-一个具有重新设计的 UI 的新主页组件,这是一个很好的、容易验证的变化。我已经构建了dockeronwindows/ch07-nerd-dinner-homepage:2e。为了进行更新,我有一个新的 Docker Compose 覆盖文件,其中只包含现有服务的新图像名称:

version: '3.7' services:
  nerd-dinner-homepage:
    image: dockeronwindows/ch07-nerd-dinner-homepage:2e

在正常发布中,您不会使用覆盖文件来更新一个服务。您将更新核心 Docker Compose 文件中的图像标签,并将文件保存在源代码控制中。我使用覆盖文件是为了更容易地跟随本章的示例。

此更新有两个步骤。首先,我需要通过组合 Compose 文件和所有覆盖文件来生成新的应用程序清单:

docker-compose `
  -f docker-compose.yml `
  -f docker-compose.swarm.yml `
 -f new-homepage.yml `
 config > docker-stack-2.yml

现在我可以部署这个堆栈:

> docker stack deploy -c .\docker-stack-2.yml nerd-dinner
Updating service nerd-dinner_nerd-dinner-save-handler (id: 0697sstia35s7mm3wo6q5t8nu)
Updating service nerd-dinner_nerd-dinner-homepage (id: v555zpu00rwu734l2zpi6rwz3)
Updating service nerd-dinner_reverse-proxy (id: kchmkm86wk7d13eoj9t26w1hw)
Updating service nerd-dinner_message-queue (id: jlzt6svohv1bo4og0cbx4y5ac)
Updating service nerd-dinner_nerd-dinner-api (id: xhlzf3kftw49lx9f8uemhv0mo)
Updating service nerd-dinner_elasticsearch (id: 126s2u0j78k1c9tt9htdkup8x)
Updating service nerd-dinner_nerd-dinner-index-handler (id: zd651rohewgr3waud6kfvv7o0)
Updating service nerd-dinner_nerd-dinner-web (id: yq6c51bzrnrfkbwqv02k8shvr)
Updating service nerd-dinner_nerd-dinner-db (id: wilnzl0jp1n7ey7kgjyjak32q)
Updating service nerd-dinner_kibana (id: uugw7yfaza84k958oyg45cznp)

命令输出显示所有服务都在 Updating,但 Docker Swarm 只会实际更改 Compose 文件中期望状态与运行状态不同的服务。在这个部署中,它将使用 Compose 文件中的新镜像名称更新主页服务。

更新对您要升级的镜像没有任何限制。它不需要是同一存储库名称的新标签;它可以是完全不同的镜像。这是非常灵活的,但这意味着您需要小心,不要意外地用新版本的 Web 应用程序更新您的消息处理程序,反之亦然。

Docker 一次更新一个容器,您可以配置更新之间的延迟间隔以及更新失败时要采取的行为。在更新过程中,我可以运行 docker service ps 命令,并看到原始容器处于 Shutdown 状态,替换容器处于 RunningStarting 状态:

> docker service ps nerd-dinner_nerd-dinner-homepage
ID    NAME   IMAGE   NODE  DESIRED STATE CURRENT STATE ERROR  PORTS
is12l1gz2w72 nerd-dinner_nerd-dinner-homepage.1 win2019-02          Running Running about a minute ago
uu0s3ihzp4lk \_ nerd-dinner_nerd-dinner-homepage.1 win2019-02       Shutdown Shutdown 2 minutes ago
0ruzheqp29z1 nerd-dinner_nerd-dinner-homepage.2 win2019-dev-02      Running Running 2 minutes ago
5ivddeffrkjj \_ nerd-dinner_nerd-dinner-homepage.2 win2019-dev-02   Shutdown  Shutdown 2 minutes ago

新的 NerdDinner 主页应用程序的 Dockerfile 具有健康检查,Docker 会等到新容器的健康检查通过后才会继续替换下一个容器。在滚动更新期间,一些用户将看到旧的主页,而一些用户将看到时尚的新主页:

Traefik 与主页容器之间的通信使用 VIP 网络,因此它只会将流量发送到运行容器的主机 - 用户将从已更新并运行 ch07 镜像的容器或者即将更新并运行 ch03 镜像的容器中获得响应。如果这是一个高流量的应用程序,我需要确保服务中有足够的容量,这样当一个任务正在更新时,剩余的任务可以处理负载。

滚动更新可以实现零停机时间,但这并不一定意味着您的应用程序在更新期间将正常运行。这个过程只适用于无状态应用程序 - 如果任务存储任何会话状态,那么用户体验将受到影响。当包含状态的容器被替换时,状态将丢失。如果您有有状态的应用程序,您需要计划一个更谨慎的升级过程 - 或者最好是将这些组件现代化,以便在容器中运行的共享组件中存储状态。

服务更新回滚

在群集模式下更新服务时,群集会存储先前部署的配置。如果您发现发布存在问题,可以使用单个命令回滚到先前的状态:

> docker service update --rollback nerd-dinner_nerd-dinner-homepage
nerd-dinner_nerd-dinner-homepage

回滚是服务更新的一种特殊形式。rollback标志不是传递任务要更新的镜像名称,而是对服务使用的先前镜像进行滚动更新。同样,回滚是一次只更新一个任务,因此这是一个零停机过程。无论您如何应用更新,都可以使用此命令回滚到之前的状态,无论您是使用docker stack deploy还是docker service update

回滚是少数几种情况之一,您可能希望使用命令式命令来管理应用程序,而不是声明式的 Docker Compose 文件。如果您发现服务更新存在问题,只需使用单个命令即可将其回滚到先前状态,这非常棒。

服务更新仅保留一个先前的服务配置用于回滚。如果您从版本 1 更新到版本 2,然后再更新到版本 3,版本 1 的配置将丢失。您可以从版本 3 回滚到版本 2,但如果再次从版本 2 回滚,将回到先前的版本,这将使您在版本 3 之间循环。

配置更新行为

对于大规模部署,可以更改默认的更新行为,以便更快地完成滚动更新,或者运行更保守的滚动更新策略。默认行为是一次只更新一个任务,任务更新之间没有延迟,如果任务更新失败,则暂停滚动更新。可以使用三个参数覆盖配置:

  • update-parallelism:同时更新的任务数量

  • update-delay:任务更新之间等待的时间段;可以指定为小时、分钟和秒

  • update-failure-action:如果任务更新失败,要采取的操作,是继续还是停止滚动更新

您可以在 Dockerfile 中指定默认参数,以便将其嵌入到镜像中,或者在 Compose 文件中指定默认参数,以便在部署时或使用服务命令时设置。对于 NerdDinner 的生产部署,我可能有九个 SQL 消息处理程序实例,Compose 文件中的update_config设置为以三个为一批进行更新,并设置为 10 秒的延迟:

nerd-dinner-save-handler:
  deploy:
  replicas: 9
  update_config:
    parallelism: 3
    delay: 10s
...

服务的更新配置也可以通过docker service update命令进行更改,因此您可以修改更新参数并通过单个命令启动滚动升级。

健康检查在服务更新中尤为重要。如果服务更新中的新任务健康检查失败,这可能意味着镜像存在问题。完成部署可能导致 100%的不健康任务和一个破损的应用程序。默认的更新配置可以防止这种情况发生,因此如果更新的任务没有进入运行状态,部署将被暂停。更新将不会继续进行,但这比拥有一个破损的更新应用程序要好。

更新集群节点

应用程序更新是更新例程的一部分,主机更新是另一部分。您的 Windows Docker 主机应该运行一个最小的操作系统,最好是 Windows Server 2019 Core。这个版本没有用户界面,因此更新的表面积要小得多,但仍然会有一些需要重新启动的 Windows 更新。

重新启动服务器是一个侵入性的过程——它会停止 Docker Engine Windows 服务,杀死所有正在运行的容器。出于同样的原因,升级 Docker 同样具有侵入性:这意味着需要重新启动 Docker Engine。在集群模式中,您可以通过在更新期间将节点从服务中移除来管理此过程,而不会影响服务水平。

我将用我的集群来展示这一点。如果我需要在win2019-02上工作,我可以通过docker node update优雅地重新安排它正在运行的任务,将其置于排水模式:

> docker node update --availability drain win2019-02
win-node02

将节点置于排水模式意味着所有容器都将被停止,由于这些是服务任务容器,它们将在其他节点上被新容器替换。当排水完成时,win-node02上将没有正在运行的任务:它们都已经被关闭。您可以看到任务已被故意关闭,因为“关闭”被列为期望状态:

> docker node ps win2019-02
ID   NAME  NODE         DESIRED STATE         CURRENT                STATE              
kjqr0b0kxoah  nerd-dinner_nerd-dinner-homepage.1      win2019-02     Shutdown Shutdown 48 seconds ago
is12l1gz2w72 \_ nerd-dinner_nerd-dinner-homepage.1    win2019-02     Shutdown Shutdown 8 minutes ago
xdbsme89swha nerd-dinner_nerd-dinner-index-handler.1  win2019-02     Shutdown Shutdown 49 seconds ago
j3ftk04x1e9j  nerd-dinner_nerd-dinner-db.1            win2019-02     Shutdown 
Shutdown 47 seconds ago
luh79mmmtwca   nerd-dinner_nerd-dinner-api.1          win2019-02     Shutdown Shutdown 47 seconds ago
... 

我可以检查服务列表,并看到每个服务仍然处于所需的副本级别:

> docker service ls
ID              NAME                                 MODE          REPLICAS   
126s2u0j78k1  nerd-dinner_elasticsearch            replicated       1/1 
uugw7yfaza84  nerd-dinner_kibana                   replicated       1/1 
jlzt6svohv1b  nerd-dinner_message-queue            replicated       1/1 
xhlzf3kftw49  nerd-dinner_nerd-dinner-api          replicated       2/2  
wilnzl0jp1n7  nerd-dinner_nerd-dinner-db           replicated       1/1   
v555zpu00rwu nerd-dinner_nerd-dinner-homepage      replicated       2/2
zd651rohewgr nerd-dinner_nerd-dinner-index-handler replicated       2/2  
0697sstia35s nerd-dinner_nerd-dinner-save-handler  replicated       3/3
yq6c51bzrnrf nerd-dinner_nerd-dinner-web           replicated       2/2 
kchmkm86wk7d nerd-dinner_reverse-proxy             replicated       1/1 

集群已经创建了新的容器来替换在win2019-02上运行的副本。实际上,现在所有的副本都在单个节点上运行,但通过入口网络和 VIP 负载平衡,应用程序仍然以相同的方式工作。Docker Engine 仍然以排水模式运行,因此如果任何外部流量到达排水节点,它们仍然会将其转发到活动节点上的容器。

处于排水模式的节点被视为不可用,因此如果群需要安排新任务,则不会分配任何任务给排水节点。win-node02现在有效地停用了,所以我可以登录并使用sconfig工具运行 Windows 更新,或者更新 Docker Engine。

更新节点可能意味着重新启动 Docker Engine 或重新启动服务器。完成后,我可以使用另一个docker node update命令将服务器重新上线到群中:

docker node update --availability active win2019-02

这使得节点再次可用。当节点加入群时,Docker 不会自动重新平衡运行的服务,因此所有容器仍然留在win2019-dev02上,即使win-node02再次可用并且容量更大。

在高吞吐量环境中,服务经常启动、停止和扩展,加入群的任何节点很快就会运行其份额的任务。在更静态的环境中,您可以通过运行 Docker 服务update --force来手动重新平衡服务。这不会更改服务的配置,但它会替换所有副本,并在安排新容器运行时使用所有活动节点。

这是一种破坏性的行为,因为它迫使 Docker 停止健康的容器。您需要确信如果强制重新平衡不会影响应用程序的可用性。Docker 无法保证不知道您的应用程序的架构,这就是为什么当节点加入群时服务不会自动重新平衡。

Swarm 模式使您有权更新应用程序的任何组件和运行群的节点,而无需任何停机时间。在更新期间,您可能需要在群中委托额外的节点,以确保您有足够的容量来覆盖被停用的节点,但之后可以将其移除。您无需任何额外的工具即可进行滚动更新、自动回滚和路由到健康容器——这一切都内置在 Docker 中。

混合主机在混合群中

Swarm 模式的另一个功能使其非常强大。群中的节点使用 Docker API 进行通信,而 API 是跨平台的,这意味着您可以在单个群中运行混合的 Windows 和 Linux 服务器。Docker 还可以在不同的 CPU 架构上运行,因此您可以将传统的 64 位 Intel 服务器与高效的新 ARM 板混合使用。

Linux 不是本书的重点,但我会简要介绍混合群集,因为它们开启了新的可能性范围。混合群集可以将 Linux 和 Windows 节点作为管理节点和工作节点。您可以使用完全相同的 Docker CLI 以相同的方式管理节点和它们运行的服务。

混合群集的一个用例是在 Linux 上运行您的管理节点,以减少许可成本或如果您的群集在云中运行则减少运行成本。生产群集将需要至少三个管理节点。即使您的所有工作负载都是基于 Windows 的,也可能更具成本效益地运行 Linux 节点作为管理节点 - 如果有这个选项的话 - 并将 Windows 节点保留给用户工作负载。

另一个用例是用于混合工作负载。我的 NerdDinner 解决方案使用的是作为 Linux Docker 镜像可用的开源软件,但我不得不自己为 Windows Server 2019 容器打包。我可以将任何跨平台组件迁移到混合群集中的 Linux 容器中运行。这可能是来自第五章的.NET Core 组件,以及 Traefik、NATS 消息队列、Elasticsearch、Kibana,甚至 SQL Server。Linux 镜像通常比 Windows 镜像小得多,更轻巧,因此您应该能够以更高的密度运行,将更多的容器打包到每个主机上。

混合群集的巨大好处在于,您可以以相同的方式从相同的用户界面管理所有这些组件。您可以将本地的 Docker CLI 连接到群集管理器,并使用完全相同的命令管理 Linux 上的 Traefik 代理和 Windows 上的 ASP.NET 应用程序。

总结

本章主要介绍了 Docker Swarm 模式,这是内置在 Docker 中的本地集群选项。您学会了如何创建一个群集,如何添加和删除群集节点,以及如何在连接了覆盖网络的群集上部署服务。我展示了您必须为高可用性创建服务,并讨论了如何使用配置和秘密在群集中安全存储敏感的应用程序数据。

您可以使用 Compose 文件将应用程序部署为群集上的堆栈,这样可以非常容易地对应用程序组件进行分组和管理。我演示了在单节点群集和多节点群集上的堆栈部署 - 对于具有数百个节点的群集,流程是相同的。

Docker Swarm 中的高可用性意味着您可以在没有停机时间的情况下执行应用程序更新和回滚。甚至在需要更新 Windows 或 Docker 时,您也可以将节点停用,仍然可以在剩余节点上以相同的服务水平运行您的应用程序。

在下一章中,我将更仔细地研究 docker 化解决方案的管理选项。我将首先看看如何使用现有的管理工具来管理在 Docker 中运行的应用程序。然后,我将继续使用 Docker Enterprise 在生产环境中管理 swarms。

第三部分:准备将 Docker 用于生产环境

使用 Docker 会导致您的流程和工具发生变化,以便进行生产部署。这样做有很多好处,但也有新的东西需要学习。到第三部分结束时,读者将对将他们的应用程序部署到生产环境感到自信。

本节包括以下章节:

  • 第八章,管理和监控 Docker 化解决方案

  • 第九章,了解 Docker 的安全风险和好处

  • 第十章,使用 Docker 构建持续部署流水线

第八章:管理和监控 Docker 化解决方案

基于 Docker 构建的应用程序本质上是可移植的,部署过程对于每个环境都是相同的。当您将应用程序从系统测试和用户测试推广到生产环境时,您每次都会使用相同的构件。您在生产环境中使用的 Docker 镜像与在测试环境中签署的完全相同版本的镜像,任何环境差异都可以在 compose-file 覆盖、Docker 配置对象和 secrets 中捕获。

在后面的章节中,我将介绍 Docker 的持续部署工作原理,因此您的整个部署过程可以自动化。但是当您采用 Docker 时,您将会转移到一个新的应用平台,而通往生产环境的道路不仅仅是部署过程。容器化应用程序的运行方式与部署在虚拟机或裸机服务器上的应用程序有根本的不同。在本章中,我将讨论管理和监控在 Docker 中运行的应用程序。

今天您用来管理 Windows 应用程序的一些工具在应用程序迁移到 Docker 后仍然可以使用,我将从一些示例开始。但是在容器中运行的应用程序有不同的管理需求和机会,本章的主要重点将是特定于 Docker 的管理产品。

在本章中,我将使用简单的 Docker 化应用程序来向您展示如何管理容器,包括:

  • Internet Information Services (IIS)管理器连接到运行在容器中的 IIS 服务

  • 连接 Windows Server Manager 到容器,查看事件日志和功能

  • 使用开源项目查看和管理 Docker 集群

  • 使用Universal Control Plane (UCP)与Docker Enterprise

技术要求

您需要在 Windows 10 更新 18.09 或 Windows Server 2019 上运行 Docker,以便跟随示例。本章的代码可在github.com/sixeyed/docker-on-windows/tree/second-edition/ch08找到。

使用 Windows 工具管理容器

许多 Windows 中的管理工具都能够管理远程机器上运行的服务。IIS 管理器、服务器管理器和SQL Server Management Studio (SSMS)都可以连接到网络上的远程服务器进行检查和管理。

Docker 容器不同于远程机器,但它们可以被设置为允许从这些工具进行远程访问。通常情况下,您需要显式地为工具设置访问权限,通过公开管理端口、启用 Windows 功能和运行 PowerShell cmdlets。这些都可以在您的应用程序的 Dockerfile 中完成,我将为每个工具的设置步骤进行介绍。

能够使用熟悉的工具可能是有帮助的,但你应该对它们的使用有所限制;记住,容器是可以被丢弃的。如果您使用 IIS Manager 连接到 Web 应用程序容器并调整应用程序池设置,当您使用新的容器映像更新应用程序时,这些调整将会丢失。您可以使用图形工具检查运行中的容器并诊断问题,但您应该在 Dockerfile 中进行更改并重新部署。

IIS Manager

IIS Web 管理控制台是一个完美的例子。在 Windows 基础映像中,默认情况下不允许远程访问,但您可以使用一个简单的 PowerShell 脚本进行配置。首先,需要安装 Web 管理功能:

Import-Module servermanager
Add-WindowsFeature web-mgmt-service

然后,您需要使用注册表设置启用远程访问,并启动 Web 管理 Windows 服务:

Set-ItemProperty -Path HKLM:\SOFTWARE\Microsoft\WebManagement\Server -Name EnableRemoteManagement -Value 1
Start-Service wmsvc

您还需要在 Dockerfile 中添加一个EXPOSE指令,以允许流量进入预期端口8172的管理服务。这将允许您连接,但 IIS 管理控制台需要远程机器的用户凭据。为了支持这一点,而不必将容器连接到Active DirectoryAD),您可以在设置脚本中创建用户和密码:

net user iisadmin "!!Sadmin*" /add
net localgroup "Administrators" "iisadmin" /add

这里存在安全问题。您需要在镜像中创建一个管理帐户,公开一个端口,并运行一个额外的服务,所有这些都会增加应用程序的攻击面。与其在 Dockerfile 中运行设置脚本,不如附加到一个容器并交互式地运行脚本,如果您需要远程访问。

我已经在一个镜像中设置了一个简单的 Web 服务器,并在dockeronwindows/ch08-iis-with-management:2e的 Dockerfile 中打包了一个脚本以启用远程管理。我将从这个镜像中运行一个容器,发布 HTTP 和 IIS 管理端口:

docker container run -d -p 80 -p 8172 --name iis dockeronwindows/ch08-iis-with-management:2e

当容器运行时,我将在容器内执行EnableIisRemoteManagement.ps1脚本,该脚本设置了 IIS 管理服务的远程访问:

> docker container exec iis powershell \EnableIisRemoteManagement.ps1
The command completed successfully.
The command completed successfully.

Success Restart Needed Exit Code      Feature Result
------- -------------- ---------      --------------
True    No             Success        {ASP.NET 4.7, Management Service, Mana...

Windows IP Configuration
Ethernet adapter vEthernet (Ethernet):
   Connection-specific DNS Suffix  . : localdomain
   Link-local IPv6 Address . . . . . : fe80::583a:2cc:41f:f2e4%14
   IPv4 Address. . . . . . . . . . . : 172.27.56.248
   Subnet Mask . . . . . . . . . . . : 255.255.240.0
   Default Gateway . . . . . . . . . : 172.27.48.1

安装脚本最后运行ipconfig,所以我可以看到容器的内部 IP 地址(我也可以从docker container inspect中看到这一点)。

现在我可以在 Windows 主机上运行 IIS 管理器,选择“开始页面|连接到服务器”,并输入容器的 IP 地址。当 IIS 要求我进行身份验证时,我使用了在安装脚本中创建的iisadmin用户的凭据:

在这里,我可以像连接到远程服务器一样浏览应用程序池和网站层次结构:

这是检查在 IIS 上运行的 IIS 或 ASP.NET 应用程序配置的良好方法。您可以检查虚拟目录设置、应用程序池和应用程序配置,但这应该仅用于调查目的。

如果我发现应用程序中的某些内容配置不正确,我需要回到 Dockerfile 中进行修复,而不是对正在运行的容器进行更改。当您将现有应用程序迁移到 Docker 时,这种技术可能非常有用。如果您在 Dockerfile 中安装了带有 Web 应用程序的 MSI,您将无法看到 MSI 实际执行的操作,但您可以连接到 IIS 管理器并查看结果。

SQL Server 管理工作室(SSMS)

SSMS 更为直接,因为它使用标准的 SQL 客户端端口1433。您不需要公开任何额外的端口或启动任何额外的服务;来自 Microsoft 和本书的 SQL Server 镜像已经设置好了一切。您可以使用在运行容器时使用的sa凭据使用 SQL Server 身份验证进行连接。

此命令运行 SQL Server 2019 Express Edition 容器,将端口1433发布到主机,并指定sa凭据:

docker container run -d -p 1433:1433 `
 -e sa_password=DockerOnW!nd0ws `
 --name sql `
 dockeronwindows/ch03-sql-server:2e

这将发布标准的 SQL Server 端口1433,因此您有三种选项可以连接到容器内部的 SQL Server。

  • 在主机上,使用localhost作为服务器名称。

  • 在主机上,使用容器的 IP 地址作为服务器名称。

  • 在远程计算机上,使用 Docker 主机的计算机名称或 AP 地址。

我已经获取了容器的 IP 地址,所以在 Docker 主机上的 SSMS 中,我只需指定 SQL 凭据:

您可以像任何 SQL Server 一样管理这个 SQL 实例——创建数据库,分配用户权限,还原 Dacpacs,并运行 SQL 脚本。请记住,您所做的任何更改都不会影响镜像,如果您希望这些更改对新容器可用,您需要构建自己的镜像。

这种方法允许您通过 SSMS 构建数据库,如果这是您的首选,并在容器中运行而无需安装和运行 SQL Server。您可以完善架构,添加服务帐户和种子数据,然后将数据库导出为脚本。

我为一个简单的示例数据库做了这个,将架构和数据导出到一个名为init-db.sql的单个文件中。dockeronwindows/ch08-mssql-with-schema:2e的 Dockerfile 将 SQL 脚本打包到一个新的镜像中,并使用一个引导 PowerShell 脚本在创建容器时部署数据库:

# escape=` FROM dockeronwindows/ch03-sql-server:2e SHELL ["powershell", "-Command", "$ErrorActionPreference = 'Stop';"] ENV sa_password DockerOnW!nd0ws VOLUME C:\mssql  WORKDIR C:\init
COPY . . CMD ./InitializeDatabase.ps1 -sa_password $env:sa_password -Verbose HEALTHCHECK CMD powershell -command ` try { ` $result = invoke-sqlcmd -Query 'SELECT TOP 1 1 FROM Authors' -Database DockerOnWindows; ` if ($result[0] -eq 1) {return 0} ` else {return 1}; ` } catch { return 1 }

这里的 SQL Server 镜像中有一个HEALTHCHECK,这是一个好的做法——它让 Docker 检查数据库是否正常运行。在这种情况下,如果架构尚未创建,测试将失败,因此在架构部署成功完成之前,容器将不会报告为健康状态。

我可以以通常的方式从这个镜像运行一个容器:

docker container run -d -p 1433 --name db dockeronwindows/ch08-mssql-with-schema:2e

通过发布端口1433,数据库容器可以在主机上的随机端口上使用,因此我可以使用 SQL 客户端连接到数据库,并从脚本中查看架构和数据。

这代表了一个应用数据库的新部署,在这种情况下,我使用了 SQL Server 的开发版来制定我的架构,但是实际数据库使用了 SQL Server Express,所有这些都在 Docker 中运行,没有本地 SQL Server 实例。

如果您认为使用 SQL Server 身份验证是一个倒退的步骤,您需要记住 Docker 可以实现不同的运行时模型。您不会有一个运行多个数据库的单个 SQL Server 实例;如果凭据泄露,它们都可能成为目标。每个 SQL 工作负载将在一个专用容器中,具有自己的一组凭据,因此您实际上每个数据库都有一个 SQL 实例,并且您可能每个服务都有一个数据库。

通过在 Docker 中运行,可以增加安全性。除非您需要远程连接到 SQL Server,否则无需从 SQL 容器发布端口。需要数据库访问的任何应用程序都将作为容器在与 SQL 容器相同的 Docker 网络中运行,并且可以访问端口 1433 而无需将其发布到主机。这意味着 SQL 仅对在相同 Docker 网络中运行的其他容器可访问,在生产环境中,您可以使用 Docker 机密来获取连接详细信息。

如果您需要在 AD 帐户中使用 Windows 身份验证,您仍然可以在 Docker 中执行。容器在启动时可以加入域,因此您可以使用服务帐户来代替 SQL Server 身份验证。

事件日志

您可以将本地计算机上的事件查看器连接到远程服务器,但目前 Windows Server Core 或 Nano Server 映像上未启用远程事件日志服务。这意味着您无法使用事件查看器 UI 连接到容器并读取事件日志条目,但您可以使用服务器管理器 UI 进行操作,我将在下一节中介绍。

如果您只想读取事件日志,可以针对正在运行的容器执行 PowerShell cmdlet 以获取日志条目。此命令从我的数据库容器中读取 SQL Server 应用程序的两个最新事件日志条目:

> docker exec db powershell `
 "Get-EventLog -LogName Application -Source MSSQL* -Newest 2 | Format-Table TimeWritten,Message"

TimeWritten          Message
-----------          -------
6/27/2017 5:14:49 PM Setting database option READ_WRITE to ON for database '...
6/27/2017 5:14:49 PM Setting database option query_store to off for database...

如果您遇到无法以其他方式诊断的容器问题,读取事件日志可能会很有用。但是,当您有数十个或数百个容器运行时,这种方法并不适用。最好将感兴趣的事件日志中继到控制台,以便 Docker 平台收集它们,并且您可以使用 docker container logs 或可以访问 Docker API 的管理工具来读取它们。

中继事件日志很容易做到,采用了与 第三章 开发 Docker 化的 .NET Framework 和 .NET Core 应用程序 中中继 IIS 日志类似的方法。对于写入事件日志的任何应用程序,您可以使用启动脚本作为入口点,该脚本运行应用程序,然后进入读取循环,从事件日志中获取条目并将其写入控制台。

这对于作为 Windows 服务运行的应用程序非常有用,这也是 Microsoft 在 SQL Server Windows 映像中使用的方法。Dockerfile 使用 PowerShell 脚本作为 CMD,该脚本以循环结束,调用相同的 Get-EventLog cmdlet 将日志中继到控制台:

$lastCheck = (Get-Date).AddSeconds(-2) 
while ($true) { 
 Get-EventLog -LogName Application -Source "MSSQL*" -After $lastCheck | `
 Select-Object TimeGenerated, EntryType, Message 
 $lastCheck = Get-Date 
 Start-Sleep -Seconds 2 
}

该脚本每 2 秒读取一次事件日志,获取自上次读取以来的任何条目,并将它们写入控制台。该脚本在 Docker 启动的进程中运行,因此日志条目被捕获并可以通过 Docker API 公开。

这并不是一个完美的方法——它使用了定时循环,只选择了日志中的一些数据,并且意味着在容器的事件日志和 Docker 中存储数据。如果您的应用程序已经写入事件日志,并且您希望将其 Docker 化而不需要重新构建应用程序,则这是有效的。在这种情况下,您需要确保您有一种机制来保持应用程序进程运行,比如 Windows 服务,并且在 Dockerfile 中进行健康检查,因为 Docker 只监视事件日志循环。

服务器管理器

服务器管理器是一个很好的工具,可以远程管理和监控服务器,并且它与基于 Windows Server Core 的容器配合良好。您需要采用类似的方法来管理 IIS 控制台,配置容器中具有管理员访问权限的用户,然后从主机连接。

就像 IIS 一样,您可以向镜像添加一个启用访问的脚本,这样您可以在需要时运行它。这比在镜像中始终启用远程访问更安全。该脚本只需要添加一个用户,配置服务器以允许管理员帐户进行远程访问,并确保Windows 远程管理WinRM)服务正在运行:

net user serveradmin "s3rv3radmin*" /add
net localgroup "Administrators" "serveradmin" /add

New-ItemProperty -Path HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System `
 -Name LocalAccountTokenFilterPolicy -Type DWord -Value 1
Start-Service winrm

我有一个示例镜像展示了这种方法,dockeronwindows/ch08-iis-with-server-manager:2e。它基于 IIS,并打包了一个脚本来启用服务器管理器的远程访问。Dockerfile 还公开了 WinRM 使用的端口59855986。我可以启动一个在后台运行 IIS 的容器,然后启用远程访问:

> > docker container run -d -P --name iis2 dockeronwindows/ch08-iis-with-server-manager:2e
9c097d80c08b5fc55cfa27e40121d240090a1179f67dbdde653c1f93d3918370

PS> docker exec iis2 powershell .\EnableRemoteServerManagement.ps1
The command completed successfully.
... 

您可以使用容器的 IP 地址连接到服务器管理器,但容器没有加入域。服务器管理器将尝试通过安全通道进行身份验证并失败,因此您将收到 WinRM 身份验证错误。要添加一个未加入域的服务器,您需要将其添加为受信任的主机。受信任的主机列表需要使用容器的主机名,而不是 IP 地址,所以首先我会获取容器的主机名:

> docker exec iis2 hostname
9c097d80c08b

我将在我的服务器的hosts文件中添加一个条目,位于C:\Windows\system32\drivers\etc\hosts

#ch08 
172.27.59.5  9c097d80c08b

现在,我可以将容器添加到受信任的列表中。此命令需要在主机上运行,而不是在容器中运行。您正在将容器的主机名添加到本地计算机的受信任服务器列表中。我在我的 Windows Server 2019 主机上运行此命令:

Set-Item wsman:\localhost\Client\TrustedHosts 9c097d80c08b -Concatenate -Force

我正在运行 Windows Server 2019,但您也可以在 Windows 10 上使用服务器管理器。安装远程服务器管理工具RSAT),您可以在 Windows 10 上以相同的方式使用服务器管理器。

在服务器管理器中,导航到所有服务器 | 添加服务器,并打开 DNS 选项卡。在这里,您可以输入容器的主机名,服务器管理器将解析 IP 地址:

选择服务器详细信息,然后单击“确定” - 现在服务器管理器将尝试连接到容器。您将在“所有服务器”选项卡中看到更新的状态,其中显示服务器已上线,但访问被拒绝。现在,您可以右键单击服务器列表中的容器,然后单击“以...身份管理”以提供本地管理员帐户的凭据。您需要将主机名指定为用户名的域部分。脚本中创建的本地用户名为serveradmin,但我需要使用9c097d80c08b\serveradmin进行身份验证:

现在连接成功了,您将在服务器管理器中看到来自容器的数据,包括事件日志条目、Windows 服务以及所有安装的角色和功能:

您甚至可以从远程服务器管理器 UI 向容器添加功能-但这不是一个好的做法。像其他 UI 管理工具一样,最好用它们进行探索和调查,而不是在 Dockerfile 中进行任何更改。

使用 Docker 工具管理容器

您已经看到可以使用现有的 Windows 工具来管理容器,但是这些工具可以做的事情并不总是适用于 Docker 世界。一个容器将运行一个单独的 Web 应用程序,因此 IIS Manager 的层次结构导航并不是很有用。在服务器管理器中检查事件日志可能是有用的,但将条目中继到控制台更有用,这样它们可以从 Docker API 中显示出来。

您的应用程序镜像还需要明确设置,以便访问远程管理工具,公开端口,添加用户和运行其他 Windows 服务。所有这些都增加了正在运行的容器的攻击面。您应该将这些现有工具视为在开发和测试环境中调试有用,但它们并不适合生产环境。

Docker 平台为在容器中运行的任何类型的应用程序提供了一致的 API,这为一种新类型的管理员界面提供了机会。在本章的其余部分,我将研究那些了解 Docker 并提供替代管理界面的管理工具。我将从一些开源工具开始,然后转向 Docker 企业中商业容器即服务CaaS)平台。

Docker 可视化工具

可视化工具是一个非常简单的 Web UI,显示 Docker 集群中节点和容器的基本信息。它是 GitHub 上dockersamples/docker-swarm-visualizer存储库中的开源项目。它是一个 Node.js 应用程序,并且它打包在 Linux 和 Windows 的 Docker 镜像中。

我在 Azure 中为本章部署了一个混合 Docker Swarm,其中包括一个 Linux 管理节点,两个 Linux 工作节点和两个 Windows 工作节点。我可以在管理节点上将可视化工具作为 Linux 容器运行,通过部署绑定到 Docker Engine API 的服务:

docker service create `
  --name=viz `
  --publish=8000:8080/tcp `
  --constraint=node.role==manager `
  --mount=type=bind,src=/var/run/docker.sock,dst=/var/run/docker.sock `
  dockersamples/visualizer

该约束条件确保容器仅在管理节点上运行,由于我的管理节点运行在 Linux 上,我可以使用mount选项让容器与 Docker API 进行通信。在 Linux 中,您可以将套接字视为文件系统挂载,因此容器可以使用 API 套接字,而无需将其公开到传输控制协议TCP)上。

您还可以在全 Windows 集群中运行可视化工具。Docker 目前支持 Windows 命名管道作为单个服务器上的卷,但在 Docker Swarm 中不支持;但是,您可以像我在第七章中使用 Traefik 一样,通过 TCP 挂载 API。

可视化工具为您提供了对集群中容器的只读视图。UI 显示主机和容器的状态,并为您提供了一种快速检查集群中工作负载分布的方式。这是我在 Azure 中部署 NerdDinner 堆栈的 Docker 企业集群的外观:

我一眼就能看到我的节点和容器是否健康,我可以看到 Docker 已经尽可能均匀地分布了容器。可视化器使用 Docker 服务中的 API,该 API 使用 RESTful 接口公开所有 Docker 资源。

Docker API 还提供了写访问权限,因此您可以创建和更新资源。一个名为Portainer的开源项目使用这些 API 提供管理功能。

Portainer

Portainer 是 Docker 的轻量级管理 UI。它作为一个容器运行,可以管理单个 Docker 主机和以集群模式运行的集群。它是一个托管在 GitHub 上的开源项目,位于portainer/portainer存储库中。Portainer 是用 Go 语言编写的,因此它是跨平台的,您可以将其作为 Linux 或 Windows 容器运行。

Portainer 有两个部分:您需要在每个节点上运行一个代理,然后运行管理 UI。所有这些都在容器中运行,因此您可以使用 Docker Compose 文件,例如本章源代码中的ch08-portainer中的文件。Compose 文件定义了一个全局服务,即 Portainer 代理,在集群中的每个节点上都在容器中运行。然后是 Portainer UI:

portainer:
  image: portainer/portainer
  command: -H tcp://tasks.agent:9001 --tlsskipverify
  ports:
   - "8000:9000"
  volumes:
   - portainer_data:/data
  networks:
   - agent_network
  deploy: 
    mode: replicated
    replicas: 1
    placement:
      constraints: [node.role == manager]

Docker Hub 上的portainer/portainer镜像是一个多架构镜像,这意味着您可以在 Linux 和 Windows 上使用相同的镜像标签,Docker 将使用与主机操作系统匹配的镜像。您无法在 Windows 上挂载 Docker 套接字,但 Portainer 文档会向您展示如何在 Windows 上访问 Docker API。

当您首次浏览到 Portainer 时,您需要指定管理员密码。然后,服务将连接到 Docker API 并显示有关所有资源的详细信息。在集群模式下,我可以看到集群中节点的数量,堆栈的数量,正在运行的服务和容器的数量,以及集群中的镜像、卷和网络。

集群可视化器链接显示了一个非常类似于 Docker Swarm 可视化器的 UI,显示了每个节点上运行的容器:

服务视图向我展示了所有正在运行的服务,从这里,我可以深入了解服务的详细信息,并且有一个快速链接来更新服务的规模:

Portainer 随着新的 Docker 功能不断发展,您可以从 Portainer 部署堆栈和服务并对其进行管理。您可以深入了解服务日志,连接到容器的控制台会话,并从内置 UI 中部署 Docker Compose 模板的常见应用程序。

您可以在 Portainer 中创建多个用户和团队,并对资源应用访问控制。您可以创建仅限于某些团队访问的服务。认证由 Portainer 通过本地用户数据库或连接到现有的轻量级目录访问协议(LDAP)提供者进行管理。

Portainer 是一个很棒的工具,也是一个活跃的开源项目,但在采用它作为管理工具之前,您应该评估最新版本。Portainer 最初是一个 Linux 工具,仍然有一些 Windows 功能不完全支持的地方。在撰写本文时,代理容器需要在 Windows 节点上进行特殊配置,这意味着您无法将其部署为跨整个群集的全局服务,并且没有它,您无法在 Portainer 中看到 Windows 容器。

在生产环境中,您可能需要运行具有支持的软件。Portainer 是开源的,但也提供了商业支持选项。对于企业部署或具有严格安全流程的环境,Docker Enterprise 提供了完整的功能集。

使用 Docker Enterprise 的 CaaS

Docker Enterprise 是 Docker,Inc.的商业版本。它是一个完整的 CaaS 平台,充分利用 Docker 提供单一的管理界面,用于管理任意数量的运行在任意数量主机上的容器。

Docker Enterprise 是一个在数据中心或云中运行的生产级产品。集群功能支持多个编排器,包括 Kubernetes 和 Docker Swarm。在生产中,您可以拥有一个包含 100 个节点的集群,使用与您的开发笔记本相同的应用程序平台作为单节点集群运行。

Docker Enterprise 有两个部分。其中一个是Docker Trusted RegistryDTR),它类似于运行您自己的私有 Docker Hub 实例,包括图像签名和安全扫描。当我在 Docker 的安全性方面进行讨论时,我将在第九章中涵盖 DTR,理解 Docker 的安全风险和好处。管理组件称为Universal Control PlaneUCP),它是一种新型的管理界面。

理解 Universal Control Plane

UCP 是一个基于 Web 的界面,用于管理节点、图像、服务、容器、秘密和所有其他 Docker 资源。UCP 本身是一个分布式应用程序,运行在 swarm 中连接的服务中的容器中。UCP 为您提供了一个统一的地方来以相同的方式管理所有 Docker 应用程序。它提供了基于角色的访问控制,以便您可以对谁可以做什么进行细粒度的控制。

Docker Enterprise 运行 Kubernetes 和 Docker Swarm。Kubernetes 将在未来的版本中支持 Windows 节点,因此您将能够在单个 Docker Enterprise 集群上将 Windows 容器部署到 Docker Swarm 或 Kubernetes。您可以使用 Docker Compose 文件将堆栈部署到 UCP,将目标设置为 Docker Swarm 或 Kubernetes,UCP 将创建所有资源。

UCP 为您提供了完整的管理功能:您可以创建、扩展和删除服务,检查并连接到运行服务的任务,并管理运行 swarm 的节点。您需要的所有其他资源,如 Docker 网络、配置、秘密和卷,都以相同的方式在 UCP 中进行管理。

您可以在 UCP 和 DTR 的 Linux 节点上运行混合 Docker Enterprise 集群,并在 Windows 节点上运行用户工作负载。作为 Docker 的订阅服务,您可以得到 Docker 团队的支持,他们将为您设置集群并处理任何问题,涵盖所有的 Windows 和 Linux 节点。

导航 UCP UI

您可以从主页登录到 UCP。您可以使用 Docker Enterprise 内置的身份验证,手动管理 UCP 中的用户,或者连接到任何 LDAP 身份验证存储。这意味着您可以设置 Docker Enterprise 来使用您组织的 AD,并让用户使用他们的 Windows 帐户登录。

UCP 主页是一个仪表板,显示了集群的关键性能指标,节点数、服务数,以及在那一刻运行的 Swarm 和 Kubernetes 服务,以及集群的整体计算利用率:

从仪表板,您可以导航到资源视图,按资源类型分组访问:服务、容器、镜像、节点、网络、卷和秘密。对于大多数资源类型,您可以列出现有资源、检查它们、删除它们,并创建新的资源。

UCP 是一个多编排器容器平台,因此您可以在同一集群中在 Kubernetes 中运行一些应用程序,而在 Docker Swarm 中运行其他应用程序。导航栏中的共享资源部分显示了编排器之间共享的资源,包括镜像、容器和堆栈。这是支持异构交付的一个很好的方法,或者在受控环境中评估不同的编排器。

UCP 为所有资源提供了基于角色的访问控制(RBAC)。您可以将权限标签应用于任何资源,并根据该标签来保护访问。团队可以被分配到标签的权限,从无访问权限到完全控制权限不等,这样可以确保团队成员对拥有这些标签的所有资源的访问权限。

管理节点

节点视图显示了集群中的所有节点,列出了操作系统和 CPU 架构、节点状态和节点管理器状态:

我的集群中有六个节点:

  • 用于混合工作负载的两个 Linux 节点:这些节点可以运行 Kubernetes 或 Docker Swarm 服务

  • 仅配置为 Docker Swarm 服务的两个 Linux 节点

  • 两个仅用于 Docker Swarm 的 Windows 节点

这些节点正在运行所有 UCP 和 DTR 容器。Docker Enterprise 可以配置免除管理节点运行用户工作负载,也可以对运行 DTR 进行同样的配置。这是一个很好的方法,可以为 Docker Enterprise 服务划定计算资源的边界,以确保您的应用工作负载不会使管理组件资源匮乏。

在节点管理中,您可以以图形方式查看和管理您可以访问的集群服务器。您可以将节点放入排水模式,从而可以运行 Windows 更新或升级节点上的 Docker。您可以将工作节点提升为管理节点,将管理节点降级为工作节点,并查看加入新节点到集群所需的令牌。

深入了解每个节点,您可以查看服务器的总 CPU、内存和磁盘使用情况,并显示使用情况的图表,您可以将其聚合为 30 分钟到 24 小时的时间段:

在指标选项卡中,列出了节点上的所有容器,显示它们的当前状态以及容器正在运行的镜像。从容器列表中,您可以导航到容器视图,我将很快介绍。

存在于节点级别而不是集群级别,但您可以在 UCP 中管理它们跨所有集群节点。您在集群中管理卷的方式取决于您使用的卷的类型。本地卷适用于诸如将日志和指标写入磁盘然后将其集中转发的全局服务等场景。

作为集群服务运行的持久数据存储也可以使用本地存储。您可以在每个节点上创建一个本地卷,但在具有高容量 RAID 阵列的服务器上添加标签。创建数据服务时,您可以使用约束将其限制为 RAID 节点,因此其他节点永远不会在其上安排任务,并且任务运行的地方将数据写入 RAID 阵列上的卷。

对于本地数据中心和云中,您可以使用卷插件与共享存储。使用共享存储,即使容器移动到不同的集群节点,服务也可以继续访问数据。服务任务将读取和写入数据到持久保存在共享存储设备上的卷中。Docker Store 上有许多卷插件可用,包括用于云服务的 AWS 和 Azure,来自 HPE 和 Nimble 的物理基础设施,以及 vSphere 等虚拟化平台。

Docker Enterprise 使用 Cloudstor 插件提供集群范围的存储,如果您使用 Docker Certified Infrastructure 部署,那么这将为您配置。在撰写本文时,该插件仅受 Linux 节点支持,因此 Windows 节点受限于运行本地卷。在 Docker Swarm 中仍然有许多有状态的应用程序架构可以很好地工作,但您需要仔细配置它们。

存储是容器生态系统中受到很多关注的领域。正在出现的技术可以创建集群范围的存储选项,而无需特定的基础设施。随着这些技术的成熟,您将能够通过汇集集群上的磁盘来运行具有高可用性和可扩展性的有状态服务。

卷有有限数量的选项,因此创建它们是指定驱动程序并应用任何驱动程序选项的情况:

权限可以应用于卷,如其他资源一样,通过指定资源所属的集合。集合是 UCP 如何强制基于角色的访问控制以限制访问的方式。

本地卷在每个节点上创建,因此需要命名卷的容器可以在任何节点上运行并仍然找到卷。在 UCP 创建的混合 Swarm 中,本地卷在每个节点上创建,并显示挂载卷数据的服务器的物理位置:

UCP 为您提供了集群中所有资源的单一视图,包括每个节点上的卷和可用于运行容器的图像。

图像

UCP 不是图像注册表。DTR 是 Docker Enterprise 中的企业私有注册表,但您可以使用 UCP 管理在每个节点上的 Docker 缓存中的图像。在图像视图中,UCP 会显示已在集群节点上拉取的图像,并允许您拉取图像,这些图像会下载到每个节点上:

Docker 图像经过压缩以进行分发,当您拉取图像时,Docker 引擎会解压缩图层。有特定于操作系统的优化,可以在拉取完成后立即启动容器,这就是为什么您无法在 Linux 主机上拉取 Windows 图像,反之亦然。UCP 将尝试在每个主机上拉取图像,但如果由于操作系统不匹配而导致某些主机失败,它将继续进行剩余节点。如果存在不匹配,您将看到错误:

在图像视图中,您可以深入了解图像的详细信息,包括图层的历史记录,健康检查,任何环境变量和暴露的端口。基本详细信息还会显示图像的操作系统平台,虚拟大小和创建日期:

在 UCP 中,您还可以从集群中删除图像。您可能有一个保留集群上当前和先前图像版本的策略,以允许回滚。其他图像可以安全地从 Docker Enterprise 节点中删除,将所有先前的图像版本留在 DTR 中,以便在需要时拉取。

网络

网络管理很简单,UCP 呈现与其他资源类型相同的界面。网络列表显示了集群中的网络,这些网络可以添加到应用了 RBAC 的集合中,因此您只能看到您被允许看到的网络。

有几个网络的低级选项,允许您指定 IPv6 和自定义 MTU 数据包大小。Swarm 模式支持加密网络,在节点之间的流量被透明加密,可以通过 UCP 启用。在 Docker Enterprise 集群中,您将使用覆盖驱动程序允许服务在集群节点之间的虚拟网络中进行通信:

Docker 支持一种特殊类型的 Swarm 网络,称为入口网络。入口网络具有用于外部请求的负载平衡和服务发现。这使得端口发布非常灵活。在一个 10 节点的集群上,您可以在具有三个副本的服务上发布端口80。如果一个节点收到端口80的传入请求,但它没有运行服务任务,Docker 会智能地将其重定向到运行任务的节点。

入口网络是 Docker Swarm 集群中 Linux 和 Windows 节点的强大功能。我在第七章中更详细地介绍了它们,使用 Docker Swarm 编排分布式解决方案

网络也可以通过 UCP 删除,但只有在没有附加的容器时才能删除。如果您定义了使用网络的服务,那么如果您尝试删除它,您将收到警告。

部署堆栈

使用 UCP 部署应用程序有两种方式,类似于使用docker service create部署单个服务和使用docker stack deploy部署完整的 compose 文件。堆栈是最容易部署的,可以让您使用在预生产环境中验证过的 compose 文件。

在本章的源代码中,文件夹ch08-docker-stack包含了在 Docker Enterprise 上运行 NerdDinner 的部署清单,使用了 swarm 模式。core docker-compose.yml文件与第七章中提到的相同,使用 Docker Swarm 编排分布式解决方案,但在覆盖文件中有一些更改以部署到我的生产集群。我正在利用我在 Docker Enterprise 中拥有的混合集群,并且我正在为所有开源基础设施组件使用 Linux 容器。

要使服务使用 Linux 容器而不是 Windows,只有两个更改:镜像名称和部署约束,以确保容器被安排在 Linux 节点上运行。以下是文件docker-compose.hybrid-swarm.yml中 NATS 消息队列的覆盖:

message-queue:
  image: nats:1.4.1-linux
  deploy:
    placement:
      constraints: 
       - node.platform.os == linux

我使用了与第七章相同的方法,使用 Docker Swarm 编排分布式解决方案,使用docker-compose config将覆盖文件连接在一起并将它们导出到docker-swarm.yml中。我可以将我的 Docker CLI 连接到集群并使用docker stack deploy部署应用程序,或者我可以使用 UCP UI。从堆栈视图中,在共享资源下,我可以点击创建堆栈,并选择编排器并上传一个 compose YML 文件:

UCP 验证内容并突出显示任何问题。有效的组合文件将部署为堆栈,并且您将在 UCP 中看到所有资源:网络、卷和服务。几分钟后,我的应用程序的所有图像都被拉到集群节点上,并且 UCP 为每个服务安排了副本。服务列表显示所有组件都以所需的规模运行:

我的现代化 NerdDinner 应用程序现在在一个六节点的 Docker Enterprise 集群中运行了 15 个容器。我在受支持的生产环境中实现了高可用性和扩展性,并且将四个开源组件从我的自定义镜像切换到了官方的 Docker 镜像,而不需要对我的应用程序镜像进行任何更改。

堆栈是首选的部署模型,因为它们继续使用已知的 compose 文件格式,并自动化所有资源。但堆栈并不适用于每种解决方案,特别是当您将传统应用程序迁移到容器时。在堆栈部署中,无法保证服务创建的顺序;Docker Compose 使用的 depends_on 选项不适用。这是一种有意设计的决策,基于服务应该具有弹性的想法,但并非所有服务都是如此。

现代应用程序应该设计成可以容忍故障。如果 web 组件无法连接到数据库,它应该使用基于策略的重试机制来重复连接,而不是无法启动。传统的应用程序通常期望它们的依赖可用,并没有优雅的重试机制。NerdDinner 就是这样,所以如果我从 compose 文件部署一个堆栈,web 应用可能会在数据库服务创建之前启动,然后失败。

在这种情况下,容器应该退出,这样 Docker 就知道应用程序没有在运行。然后它将安排一个新的容器运行,并在启动时,依赖项应该是可用的。如果不是,新容器将结束,Docker 将安排一个替代品,并且这将一直持续下去,直到应用程序正常工作。如果您的传统应用程序没有任何依赖检查,您可以将这种逻辑构建到 Docker 镜像中,使用 Dockerfile 中的启动检查和健康检查。

在某些情况下,这可能是不可能的,或者可能是新容器的重复启动会导致您的传统应用程序出现问题。您仍然可以手动创建服务,而不是部署堆栈。UCP 也支持这种工作流程,这样可以手动确保所有依赖项在启动每个服务之前都在运行。

这是管理应用程序的命令式方法,你真的应该尽量避免使用。更好的方法是将应用程序清单封装在一组简单的 Docker Compose 文件中,这样可以在源代码控制中进行管理,但对于一些传统的应用程序可能会很难做到这一点。

创建服务

docker service create命令有数十个选项。UCP 在引导式 UI 中支持所有这些选项,您可以从服务视图中启动。首先,您需要指定基本细节,比如用于服务的镜像名称;服务名称,其他服务将通过该名称发现此服务;以及命令参数,如果您想要覆盖镜像中的默认启动命令。

我不会覆盖所有细节;它们与docker service create命令中的选项相对应,但是值得关注的是调度选项卡。这是您设置服务模式为复制或全局,添加所需副本数量以及滚动更新配置的地方。

重启策略默认为始终。这与副本计数一起工作,因此如果任何任务失败或停止,它们将被重新启动以维持服务水平。您可以配置自动部署的更新设置,还可以添加调度约束。约束与节点标签一起工作,限制可以用于运行服务任务的节点。您可以使用此功能将任务限制为高容量节点或具有严格访问控制的节点。

在其他部分,您可以配置服务与集群中其他资源的集成方式,包括网络和卷、配置和秘密,还可以指定计算保留和限制。这使您可以将服务限制在有限的 CPU 和内存量上,并且还可以指定每个容器应具有的 CPU 和内存的最小份额。

当您部署服务时,UCP 会负责将镜像拉取到需要的任何节点上,并启动所需数量的容器。对于全局服务,每个节点将有一个容器,对于复制服务,将有指定数量的任务。

监控服务

UCP 允许您以相同的方式部署任何类型的应用程序,可以使用堆栈组合文件或创建服务。该应用程序可以使用多个服务,任何技术组合都可以——NerdDinner 堆栈的部分现在正在我的混合集群中的 Linux 上运行。我已经部署了 Java、Go 和 Node.js 组件作为 Linux 容器,以及.NET Framework 和.NET Core 组件作为 Windows 容器在同一个集群上运行。

所有这些不同的技术平台都可以通过 UCP 以相同的方式进行管理,这就是使其成为对于拥有大型应用程序资产的公司如此宝贵的平台。服务视图显示了所有服务的基本信息,例如总体状态、任务数量以及上次报告错误的时间。对于任何服务,您都可以深入到详细视图,显示有关服务的所有信息。

这是核心 NerdDinner ASP.NET Web 应用程序的概述选项卡:

我已经滚动了这个视图,这样我就可以看到服务可用的秘密,以及环境变量(在这种情况下没有),标签,其中包括 Traefik 路由设置和约束,包括平台约束,以确保其在 Windows 节点上运行。指标视图向我显示了 CPU 和内存使用情况的图表,以及所有正在运行的容器的列表。

您可以使用服务视图来检查服务的总体状态并进行更改-您可以添加环境变量,更改网络或卷,并更改调度约束。对服务定义所做的任何更改都将通过重新启动服务来实施,因此您需要了解应用程序的影响。无状态应用程序和优雅处理瞬态故障的应用程序可以在运行时进行修改,但可能会有应用程序停机时间-这取决于您的解决方案架构。

您可以调整服务的规模,而无需重新启动现有任务。只需在调度选项卡中指定新的规模级别,UCP 将创建或删除容器以满足服务水平:

当您增加规模时,现有的容器将被保留,新的容器将被添加,因此这不会影响您的应用程序的可用性(除非应用程序将状态保留在单独的容器中)。

从服务视图或容器列表中,在共享资源下,您可以选择一个任务来深入了解容器视图,这就是一致的管理体验,使得管理 Docker 化应用程序变得如此简单。显示了运行容器的每个细节,包括配置和容器内的实际进程列表。这是我的 Traefik 代理的容器,它只运行了traefik进程:

您可以阅读容器的日志,其中显示了容器标准输出流的所有输出。这些是 Elasticsearch 的日志,它是一个 Java 应用程序,因此这些日志是以log4j格式的:

您可以以相同的方式查看集群中任何容器的日志,无论是在最小的 Linux 容器中运行的新 Go 应用程序,还是在 Windows 容器中运行的传统 ASP.NET 应用程序。这就是为什么构建 Docker 镜像以便将应用程序的日志条目中继到控制台是如此重要的原因。

甚至可以连接到容器中运行的命令行 shell,如果需要排除问题。这相当于在 Docker CLI 中运行docker container exec -it powershell,但都是从 UCP 界面进行的,因此您不需要连接到集群上的特定节点。您可以运行容器镜像中安装的任何 shell,在 Kibana Linux 镜像中,我可以使用bash

UCP 为您提供了一个界面,让您可以从集群的整体健康状态,通过所有运行服务的状态,到特定节点上运行的个别容器。您可以轻松监视应用程序的健康状况,检查应用程序日志,并连接到容器进行调试 - 这一切都在同一个管理界面中。您还可以下载一个客户端捆绑包,这是一组脚本和证书,您可以使用它们来从远程 Docker 命令行界面CLI)客户端安全地管理集群。

客户端捆绑脚本将您的本地 Docker CLI 指向在集群管理器上运行的 Docker API,并为安全通信设置客户端证书。证书标识了 UCP 中的特定用户,无论他们是在 UCP 中创建的还是外部 LDAP 用户。因此,用户可以登录到 UCP UI 或使用docker命令来管理资源,对于这两种选项,他们将具有 UCP RBAC 策略定义的相同访问权限。

RBAC

UCP 中的授权为您提供对所有 Docker 资源的细粒度访问控制。UCP 中的 RBAC 是通过为主体创建对资源集的访问授权来定义的。授权的主体可以是单个用户、一组用户或包含许多团队的组织。资源集可以是单个资源,例如 Docker Swarm 服务,也可以是一组资源,例如集群中的所有 Windows 节点。授权定义了访问级别,从无访问权限到完全控制。

这是一种非常灵活的安全方法,因为它允许您在公司的任何级别强制执行安全规则。我可以采用应用程序优先的方法,其中我有一个名为nerd-dinner的资源集合,代表 NerdDinner 应用程序,这个集合是其他代表部署环境的集合的父级:生产、UAT 和系统测试。集合层次结构在此图表的右侧:

集合是资源的组合 - 因此,我会将每个环境部署为一个堆栈,其中所有资源都属于相关的集合。组织是用户的最终分组,在这里我在左侧显示了一个nerd-dinner组织,这是所有在 NerdDinner 上工作的人的分组。在组织中,有两个团队:Nerd Dinner Ops是应用程序管理员,Nerd Dinner Testers是测试人员。在图表中只显示了一个用户elton,他是Nerd Dinner Ops团队的成员。

这种结构让我可以创建授权,以便在不同级别为不同资源提供访问权限:

  • nerd-dinner组织对nerd-dinner集合具有仅查看权限,这意味着组织中任何团队的任何用户都可以列出并查看任何环境中任何资源的详细信息。

  • Nerd Dinner Ops团队还对nerd-dinner集合具有受限控制,这意味着他们可以在任何环境中运行和管理资源。

  • Nerd Dinner Ops团队中的用户elton还对nerd-dinner-uat集合拥有完全控制,这为 UAT 环境中的资源提供了完全的管理员控制。

  • Nerd Dinner Testers团队对nerd-dinner-test集合具有调度程序访问权限,这意味着团队成员可以管理测试环境中的节点。

Docker Swarm 集合的默认角色是仅查看受限控制完全控制调度器。您可以创建自己的角色,并为特定类型的资源设置特定权限。

您可以在 UCP 中创建授权以创建将主体与一组资源链接起来的角色,从而赋予它们已知的权限。我已在我的 Docker Enterprise 集群中部署了安全访问图表,并且我可以看到我的授权以及默认的系统授权:

您可以独立于要保护的资源创建授权和集合。然后,在创建资源时,通过添加标签指定集合,标签的键为com.docker.ucp.access.label,值为集合名称。您可以在 Docker 的创建命令中以命令方式执行此操作,在 Docker Compose 文件中以声明方式执行此操作,并通过 UCP UI 执行此操作。在这里,我指定了反向代理服务属于nerd-dinner-prod集合:

如果我以 Nerd Dinner Testers 团队成员的身份登录 UCP,我只会看到一个服务。测试用户无权查看默认集合中的服务,只有代理服务明确放入了nerd-dinner-prod集合中:

作为这个用户,我只有查看权限,所以如果我尝试以任何方式修改服务,比如重新启动它,我会收到错误提示:

团队可以对不同的资源集拥有多个权限,用户可以属于多个团队,因此 UCP 中的授权系统足够灵活,适用于许多不同的安全模型。您可以采用 DevOps 方法,为特定项目构建集合,所有团队成员都可以完全控制项目资源,或者您可以有一个专门的管理员团队,完全控制一切。或者您可以拥有单独的开发团队,团队成员对他们工作的应用程序有受限控制。

RBAC 是 UCP 的一个重要功能,它补充了 Docker 更广泛的安全故事,我将在第九章中介绍,理解 Docker 的安全风险和好处

总结

本章重点介绍了运行 Docker 化解决方案的操作方面。我向您展示了如何将现有的 Windows 管理工具与 Docker 容器结合使用,以及这对于调查和调试是如何有用的。主要重点是使用 Docker Enterprise 中的 UCP 来管理各种工作负载的新方法。

您学会了如何使用现有的 Windows 管理工具,比如 IIS 管理器和服务器管理器,来管理 Docker 容器,您也了解了这种方法的局限性。在开始使用 Docker 时,坚持使用您已知的工具可能是有用的,但专门的容器管理工具是更好的选择。

我介绍了两种开源选项来管理容器:简单的可视化工具和更高级的 Portainer。它们都作为容器运行,并连接到 Docker API,它们是在 Linux 和 Windows Docker 镜像中打包的跨平台应用程序。

最后,我向您介绍了 Docker Enterprise 中用于管理生产工作负载的主要功能。我演示了 UCP 作为一个单一的管理界面,用于管理在同一集群中以多种技术堆栈在 Linux 和 Windows 容器上运行的各种容器化应用程序,并展示了 RBAC 如何让您安全地访问所有 Docker 资源。

下一章将重点介绍安全性。在容器中运行的应用程序可能提供了新的攻击途径。您需要意识到风险,但安全性是 Docker 平台的核心。Docker 让您可以轻松地建立端到端的安全性方案,其中平台在运行时强制执行策略——这是在没有 Docker 的情况下很难做到的。

第九章:了解 Docker 的安全风险和好处

Docker 是一种新型的应用平台,它在建设过程中始终专注于安全性。您可以将现有应用程序打包为 Docker 镜像,在 Docker 容器中运行,并在不更改任何代码的情况下获得显著的安全性好处。

在基于 Windows Server Core 2019 的 Windows 容器上运行的.NET 2.0 WebForms 应用程序将在不进行任何代码更改的情况下愉快地在.NET 4.7 下运行:这是一个立即应用了 16 年安全补丁的升级!仍然有大量运行在不受支持的 Server 2003 上或即将不受支持的 Server 2008 上的 Windows 应用程序。转移到 Docker 是将这些应用程序引入现代技术栈的绝佳方式。

Docker 的安全涵盖了广泛的主题,我将在本章中进行介绍。我将解释容器和镜像的安全方面,Docker Trusted RegistryDTR)中的扩展功能,以及在 swarm 模式下的 Docker 的安全配置。

在本章中,我将深入研究 Docker 的内部,以展示安全性是如何实现的。我将涵盖:

  • 了解容器安全性

  • 使用安全的 Docker 镜像保护应用程序

  • 使用 DTR 保护软件供应链

  • 了解 swarm 模式下的安全性

了解容器安全性

Windows Server 容器中运行的应用程序进程实际上是在主机上运行的。如果在容器中运行多个 ASP.NET 应用程序,您将在主机机器的任务列表中看到多个w3wp.exe进程。在容器之间共享操作系统内核是 Docker 容器如此高效的原因——容器不加载自己的内核,因此启动和关闭时间非常快,对运行时资源的开销也很小。

在容器内运行的软件可能存在安全漏洞,安全人员关心的一个重要问题是:Docker 容器之间的隔离有多安全?如果 Docker 容器中的应用程序受到攻击,这意味着主机进程受到了攻击。攻击者能否利用该进程来攻击其他进程,潜在地劫持主机或在主机上运行的其他容器?

如果操作系统内核存在攻击者可以利用的漏洞,那么可能会打破容器并危害其他容器和主机。Docker 平台建立在深度安全原则之上,因此即使可能存在这种情况,平台也提供了多种方法来减轻风险。

Docker 平台在 Linux 和 Windows 之间几乎具有功能上的平等,但 Windows 方面还存在一些差距,正在积极解决中。但 Docker 在 Linux 上有更长的生产部署历史,许多指导和工具,如 Docker Bench 和 CIS Docker Benchmark,都是针对 Linux 的。了解 Linux 方面是有用的,但许多实际要点不适用于 Windows 容器。

容器进程

所有 Windows 进程都由用户帐户启动和拥有。用户帐户的权限决定了进程是否可以访问文件和其他资源,以及它们是否可用于修改或仅用于查看。在 Windows Server Core 的 Docker 基础映像中,有一个名为容器管理员的默认用户帐户。您在容器中从该映像启动的任何进程都将使用该用户帐户-您可以运行whoami工具,它只会输出当前用户名:

> docker container run mcr.microsoft.com/windows/servercore:ltsc2019 whoami
user manager\containeradministrator

您可以通过启动 PowerShell 来运行交互式容器,并找到容器管理员帐户的用户 ID(SID):

> docker container run -it --rm mcr.microsoft.com/windows/servercore:ltsc2019 powershell

> $user = New-Object System.Security.Principal.NTAccount("containeradministrator"); `
 $sid = $user.Translate([System.Security.Principal.SecurityIdentifier]); `
 $sid.Value
S-1-5-93-2-1

您会发现容器用户的 SID 始终相同,即S-1-5-93-2-1,因为该帐户是 Windows 映像的一部分。由于这个原因,它在每个容器中都具有相同的属性。容器进程实际上是在主机上运行的,但主机上没有容器管理员用户。实际上,如果您查看主机上的容器进程,您会看到用户名的空白条目。我将在后台容器中启动一个长时间运行的ping进程,并检查容器内的进程 ID(PID):

> docker container run -d --name pinger mcr.microsoft.com/windows/servercore:ltsc2019 ping -t localhost
f8060e0f95ba0f56224f1777973e9a66fc2ccb1b1ba5073ba1918b854491ee5b

> docker container exec pinger powershell Get-Process ping -IncludeUserName
Handles      WS(K)   CPU(s)     Id UserName               ProcessName
-------      -----   ------     -- --------               -----------
     86       3632     0.02   7704 User Manager\Contai... PING

这是在 Windows Server 2019 上运行的 Docker 中的 Windows Server 容器,因此ping进程直接在主机上运行,容器内的 PID 将与主机上的 PID 匹配。在服务器上,我可以检查相同 PID 的详细信息,本例中为7704

> Get-Process -Id 7704 -IncludeUserName
Handles      WS(K)   CPU(s)     Id UserName               ProcessName
-------      -----   ------     -- --------               -----------
     86       3624     0.03   7704                        PING

由于容器用户在主机上没有映射任何用户,所以没有用户名。实际上,主机进程是在匿名用户下运行的,并且它在主机上没有权限,它只有在一个容器的沙盒环境中配置的权限。如果发现了允许攻击者打破容器的 Windows Server 漏洞,他们将以无法访问主机资源的主机进程运行。

可能会有更严重的漏洞允许主机上的匿名用户假定更广泛的权限,但这将是核心 Windows 权限堆栈中的一个重大安全漏洞,这通常会得到微软的非常快速的响应。匿名主机用户方法是限制任何未知漏洞影响的良好缓解措施。

容器用户帐户和 ACLs

在 Windows Server Core 容器中,默认用户帐户是容器管理员。该帐户在容器中是管理员组,因此可以完全访问整个文件系统和容器中的所有资源。在 Dockerfile 中指定的CMDENTRYPOINT指令中指定的进程将在容器管理员帐户下运行。

如果应用程序存在漏洞,这可能会有问题。应用程序可能会受到损害,虽然攻击者打破容器的机会很小,但攻击者仍然可以在应用程序容器内造成很大的破坏。管理访问权限意味着攻击者可以从互联网下载恶意软件并在容器中运行,或者将容器中的状态复制到外部位置。

您可以通过以最低特权用户帐户运行容器进程来减轻这种情况。Nano Server 映像使用了这种方法 - 它们设置了一个容器管理员用户,但容器进程的默认帐户是一个没有管理员权限的用户。您可以通过在 Nano Server 容器中回显用户名来查看这一点:

> docker container run mcr.microsoft.com/windows/nanoserver:1809 cmd /C echo %USERDOMAIN%\%USERNAME%
User Manager\ContainerUser

Nano Server 镜像没有whoami命令,甚至没有安装 PowerShell。它只设置了运行新应用程序所需的最低限度。这是容器安全性的另一个方面。如果whoami命令中存在漏洞,那么您的容器应用程序可能会受到威胁,因此 Microsoft 根本不打包该命令。这是有道理的,因为您不会在生产应用程序中使用它。在 Windows Server Core 中仍然存在它,以保持向后兼容性。

ContainerUser帐户在容器内没有管理员访问权限。如果需要管理员权限来设置应用程序,可以在 Dockerfile 中使用USER ContainerAdministrator命令切换到管理员帐户。但是,如果您的应用程序不需要管理员访问权限,应该在 Dockerfile 的末尾切换回USER ContainerUser,以便容器启动命令以最低特权帐户运行。

来自 Microsoft 的Internet Information ServicesIIS)和 ASP.NET 镜像是运行最低特权用户的其他示例。外部进程是运行在IIS_IUSRS组中的本地帐户下的 IIS Windows 服务。该组对 IIS 根路径C:\inetpub\wwwroot具有读取访问权限,但没有写入访问权限。攻击者可能会破坏 Web 应用程序,但他们将无法写入文件,因此下载恶意软件的能力已经消失。

在某些情况下,Web 应用程序需要写入访问权限以保存状态,但可以在 Dockerfile 中以非常细的级别授予。例如,开源内容管理系统CMS)Umbraco 可以打包为 Docker 镜像,但 IIS 用户组需要对内容文件夹进行写入权限。您可以使用RUN指令设置 ACL 权限,而不是更改 Dockerfile 以将服务作为管理帐户运行。

RUN $acl = Get-Acl $env:UMBRACO_ROOT; `
 $newOwner = System.Security.Principal.NTAccount; `
 $acl.SetOwner($newOwner); `
 Set-Acl -Path $env:UMBRACO_ROOT -AclObject $acl; `
 Get-ChildItem -Path $env:UMBRACO_ROOT -Recurse | Set-Acl -AclObject $acl

我不会在这里详细介绍 Umbraco,但它在容器中运行得非常好。您可以在我的 GitHub 存储库github.com/sixeyed/dockerfiles-windows中找到 Umbraco 和许多其他开源软件的示例 Dockerfile。

应该使用最低特权用户帐户来运行进程,并尽可能狭隘地设置 ACL。这限制了任何攻击者在容器内部获得进程访问权限的范围,但仍然存在来自容器外部的攻击向量需要考虑。

使用资源约束运行容器

您可以运行没有约束的 Docker 容器,容器进程将使用主机资源的尽可能多。这是默认设置,但可能是一个简单的攻击向量。恶意用户可能会在容器中对应用程序产生过多的负载,尝试占用 100%的 CPU 和内存,使主机上的其他容器陷入饥饿状态。如果您运行着为多个应用程序工作负载提供服务的数百个容器,这一点尤为重要。

Docker 有机制来防止单个容器使用过多的资源。您可以启动带有显式约束的容器,以限制它们可以使用的资源,确保没有单个容器占用大部分主机的计算能力。您可以将容器限制为显式数量的 CPU 核心和内存。

我有一个简单的.NET 控制台应用程序和一个 Dockerfile,可以将其打包到ch09-resource-check文件夹中。该应用程序被设计为占用计算资源,我可以在容器中运行它,以展示 Docker 如何限制恶意应用程序的影响。我可以使用该应用程序成功分配 600MB 的内存,如下所示:

> docker container run dockeronwindows/ch09-resource-check:2e /r Memory /p 600
I allocated 600MB of memory, and now I'm done.

控制台应用程序在容器中分配了 600MB 的内存,实际上是在 Windows Server 容器中从服务器中分配了 600MB 的内存。我在没有任何约束的情况下运行了容器,因此该应用程序可以使用服务器拥有的所有内存。如果我使用docker container run命令中的--memory限制将容器限制为 500MB 的内存,那么该应用程序将无法分配 600MB:

> docker container run --memory 500M dockeronwindows/ch09-resource-check:2e /r Memory /p 600 
Unhandled Exception: OutOfMemoryException.

示例应用程序也可以占用 CPU。它计算 Pi 的小数点位数,这是一个计算成本高昂的操作。在不受限制的容器中,计算 Pi 到 20000 位小数只需要在我的四核开发笔记本上不到一秒钟:

> docker container run dockeronwindows/ch09-resource-check:2e /r Cpu /p 20000
I calculated Pi to 20000 decimal places in 924ms. The last digit is 8.

我可以通过在run命令中指定--cpu限制来使用 CPU 限制,并且 Docker 将限制可用于此容器的计算资源,为其他任务保留更多的 CPU。相同的计算时间超过了两倍:

> docker container run --cpus 1 dockeronwindows/ch09-resource-check:2e /r Cpu /p 20000
I calculated Pi to 20000 decimal places in 2208ms. The last digit is 8.

生产 Docker Swarm 部署可以使用部署部分的资源限制来应用相同的内存和 CPU 约束。这个例子将新的 NerdDinner REST API 限制为可用 CPU 的 25%和 250MB 的内存:

nerd-dinner-api:
  image: dockeronwindows/ch07-nerd-dinner-api:2e
  deploy: resources:
      limits:
        cpus: '0.25'
        memory: 250M
...

验证资源限制是否生效可能是具有挑战性的。获取 CPU 计数和内存容量的底层 Windows API 使用操作系统内核,在容器中将是主机的内核。内核报告完整的硬件规格,因此限制似乎不会在容器内生效,但它们是强制执行的。您可以使用 WMI 来检查限制,但输出将不如预期:

> docker container run --cpus 1 --memory 1G mcr.microsoft.com/windows/servercore:ltsc2019 powershell `
 "Get-WmiObject Win32_ComputerSystem | select NumberOfLogicalProcessors, TotalPhysicalMemory"

NumberOfLogicalProcessors TotalPhysicalMemory
------------------------- -------------------
                        4         17101447168

在这里,容器报告有四个 CPU 和 16 GB 的 RAM,尽管它被限制为一个 CPU 和 1 GB 的 RAM。实际上已经施加了限制,但它们在 WMI 调用的上层操作。如果容器内运行的进程尝试分配超过 1 GB 的 RAM,那么它将失败。

请记住,只有 Windows Server 容器才能访问主机的所有计算能力,容器进程实际上是在主机上运行的。Hyper-V 容器每个都有一个轻量级的虚拟机,进程在其中运行,该虚拟机有自己的 CPU 和内存分配。您可以使用相同的 Docker 命令应用容器限制,并且这些限制适用于容器的虚拟机。

使用受限制的功能运行容器

Docker 平台有两个有用的功能,可以限制容器内应用程序的操作。目前,它们只适用于 Linux 容器,但如果您需要处理混合工作负载,并且对 Windows 的支持可能会在未来版本中推出,那么了解它们是值得的。

Linux 容器可以使用 read-only 标志运行,这将创建一个具有只读文件系统的容器。此选项可与任何镜像一起使用,并将启动一个具有与通常相同入口进程的容器。不同之处在于容器没有可写文件系统层,因此无法添加或更改文件 - 容器无法修改镜像的内容。

这是一个有用的安全功能。Web 应用程序可能存在漏洞,允许攻击者在服务器上执行代码,但只读容器严重限制了攻击者的操作。他们无法更改应用程序配置文件,更改访问权限,下载新的恶意软件或替换应用程序二进制文件。

只读容器可以与 Docker 卷结合使用,以便应用程序可以写入已知位置以记录日志或缓存数据。如果您有一个写入文件系统的应用程序,那么您可以在只读容器中运行它而不改变功能。您需要意识到,如果您将日志写入卷中的文件,并且攻击者已经访问了文件系统,他们可以读取历史日志,而如果日志写入标准输出并被 Docker 平台消耗,则无法这样做。

当您运行 Linux 容器时,您还可以明确添加或删除容器可用的系统功能。例如,您可以启动一个没有chown功能的容器,因此容器内部的任何进程都无法更改文件访问权限。同样,您可以限制绑定到网络端口或写入内核日志的访问。

只读cap-addcap-drop选项对 Windows 容器没有影响,但是在未来的 Docker on Windows 版本中可能会提供支持。

Docker 的一个很棒的地方是,开源组件内置在受支持的 Docker Enterprise 版本中。您可以在 GitHub 的moby/moby存储库中提出功能请求和跟踪错误,这是 Docker 社区版的源代码。当功能在 Docker CE 中实现后,它们将在随后的 Docker Enterprise 版本中可用。

Windows 容器和 Active Directory

大型组织使用Active DirectoryAD)来管理他们 Windows 网络中的所有用户,组和机器。应用服务器可以加入域,从而可以访问 AD 进行身份验证和授权。这通常是.NET 内部 Web 应用程序部署的方式。该应用程序使用 Windows 身份验证为用户提供单一登录,而 IIS 应用程序池则以访问 SQL Server 的服务帐户运行。

运行 Docker 的服务器可以加入域,但是机器上的容器不能。您可以在容器中运行传统的 ASP.NET 应用程序,但是在默认部署中,您会发现 Windows 身份验证对用户不起作用,应用程序本身也无法连接到数据库。

这是一个部署问题,您可以使用组管理服务帐户gMSA)为 Windows 容器提供对 AD 的访问权限,这是一种无需密码即可使用的 AD 帐户类型。Active Directory 很快就会变成一个复杂的话题,所以我在这里只是给出一个概述,让您知道您可以在容器内部使用 AD 服务:

  • 域管理员在 Active Directory 中创建 gMSA。这需要一个域控制器在运行 Windows Server 2012 或更高版本。

  • 为 gMSA 授予对 Docker 服务器的访问权限。

  • 使用CredentialSpec PowerShell 模块为 gMSA 生成 JSON 格式的凭据规范。

  • 使用security-opt标志运行容器,指定 JSON 凭据规范的路径。

  • 容器中的应用程序实际上是加入域的,并且可以使用已分配给 gMSA 的权限来使用 AD。

从容器内部访问 AD 服务在 Windows Server 2019 中要容易得多。以前,您在 Docker Swarm 中运行时必须使用特定名称的 gMSA,这使得在运行时应用凭据规范变得困难。现在,您可以为 gMSA 使用任何名称,并且一个 gMSA 可以用于多个容器。Docker Swarm 通过使用credential_spec值在 compose 文件中支持凭据规范。

在 Microsoft 的 GitHub 容器文档中有一个完整的创建和使用 gMSA 和凭据规范的演练:github.com/MicrosoftDocs/Virtualization-Documentation/tree/live/windows-server-container-tools/ServiceAccounts

Hyper-V 容器中的隔离

Windows 上的 Docker 具有一个大的安全功能,Linux 上的 Docker 没有:使用 Hyper-V 容器进行扩展隔离。运行在 Windows Server 2019 上的容器使用主机的操作系统内核。当您运行容器时,可以在主机的任务管理器上看到容器内部的进程。

在 Windows 10 上,默认行为是不同的。通过 Windows 1809 更新,您可以通过在 docker 容器运行命令中添加--isolation=process标志在 Windows 10 上以进程隔离的方式运行 Windows Server 容器。您需要在命令中或 Docker 配置文件中指定隔离级别,因为在 Windows 10 上默认值是hyperv

具有自己内核的容器称为Hyper-V容器。它们是使用轻量级虚拟机实现的,提供服务器内核,但这不是完整的虚拟机,也没有典型的虚拟机开销。Hyper-V 容器使用普通的 Docker 镜像,并且它们以与所有容器相同的方式在普通的 Docker 引擎中运行。它们不会显示在 Hyper-V 管理工具中,因为它们不是完整的虚拟机。

Hyper-V 容器也可以在 Windows Server 上使用isolation选项运行。此命令将 IIS 镜像作为 Hyper-V 容器运行,将端口80发布到主机上的随机端口:

docker container run -d -p 80 --isolation=hyperv `
  mcr.microsoft.com/windows/servercore/iis:windowsservercore-ltsc2019

容器的行为方式相同。外部用户可以浏览主机上的80端口,流量由容器处理。在主机上,您可以运行docker container inspect来查看 IP 地址并直接进入容器。Docker 网络、卷和集群模式等功能对 Hyper-V 容器也适用。

Hyper-V 容器的扩展隔离提供了额外的安全性。没有共享内核,因此即使内核漏洞允许容器应用程序访问主机,主机也只是在自己的内核中运行的薄型 VM 层。在该内核上没有其他进程或容器运行,因此攻击者无法危害其他工作负载。

由于有单独的内核,Hyper-V 容器有额外的开销。它们通常启动时间较慢,并且默认情况下会施加内存和 CPU 限制,限制容器在内核级别无法超过的资源。在某些情况下,这种权衡是值得的。在多租户情况下,您对每个工作负载都假定零信任,扩展隔离可以是一种有用的防御。

Hyper-V 容器的许可证不同。普通的 Windows Server 容器在主机级别获得许可,因此您需要为每台服务器获得许可,然后可以运行任意数量的容器。每个 Hyper-V 容器都有自己的内核,并且有限制您可以在每个主机上运行的容器数量的许可级别。

使用安全的 Docker 镜像保护应用程序

我已经涵盖了许多关于运行时保护容器的方面,但 Docker 平台在任何容器运行之前就提供了深度安全性。您可以通过保护打包应用程序的镜像来开始保护您的应用程序。

构建最小化镜像

攻击者不太可能破坏您的应用程序并访问容器,但如果发生这种情况,您应该构建您的映像以减轻损害。构建最小映像至关重要。理想的 Docker 映像应该只包含应用程序和运行所需的依赖项。

这对于 Windows 应用程序比 Linux 应用程序更难实现。 Linux 应用程序的 Docker 映像可以使用最小的发行版作为基础,在其上只打包应用程序二进制文件。该映像的攻击面非常小。即使攻击者访问了容器,他们会发现自己处于一个功能非常有限的操作系统中。

相比之下,使用 Windows Server Core 的 Docker 映像具有完整功能的操作系统作为基础。最小的替代方案是 Nano Server,它具有大大减少的 Windows API,甚至没有安装 PowerShell,这消除了可以被利用的大量功能集。理论上,您可以在 Dockerfile 中删除功能,禁用 Windows 服务,甚至删除 Windows 二进制文件,以限制最终映像的功能。但是,您需要进行大量测试,以确保您的应用程序在定制的 Windows 版本中能够正确运行。

Docker 对专家和社区领袖的认可是 Captain 计划。 Docker Captains 就像 Microsoft MVPs,Stefan Scherer 既是 Captain 又是 MVP。 Stefan 通过创建带有空文件系统并添加最小一组 Windows 二进制文件的镜像来减小 Windows 镜像大小,做了一些有前途的工作。

您无法轻松限制基本 Windows 映像的功能,但可以限制您在其上添加的内容。在可能的情况下,您应该只添加您的应用程序内容和最小的应用程序运行时,以便攻击者无法修改应用程序。一些编程语言对此的支持要比其他语言好,例如:

  • Go 应用程序可以编译为本机二进制文件,因此您只需要在 Docker 映像中打包可执行文件,而不是完整的 Go 运行时。

  • .NET Core 应用程序可以发布为程序集,因此您只需要打包.NET Core 运行时来执行它们,而不是完整的.NET Core SDK。

  • .NET Framework 应用程序需要在容器映像中安装匹配的.NET Framework,但您仍然可以最小化打包的应用程序内容。您应该以发布模式编译应用程序,并确保不打包调试文件。

  • Node.js 使用 V8 作为解释器和编译器,因此,要在 Docker 中运行应用程序,镜像需要安装完整的 Node.js 运行时,并且需要打包应用程序的完整源代码。

您将受到应用程序堆栈支持的限制,但最小镜像是目标。如果您的应用程序将在 Nano Server 上运行,那么与 Windows Server Core 相比,Nano Server 肯定更可取。完整的.NET 应用程序无法在 Nano Server 上运行,但.NET Standard 正在迅速发展,因此将应用程序移植到.NET Core 可能是一个可行的选择,然后可以在 Nano Server 上运行。

当您在 Docker 中运行应用程序时,您使用的单元是容器,并且使用 Docker 进行管理和监控。底层操作系统不会影响您与容器的交互方式,因此拥有最小的操作系统不会限制您对应用程序的操作。

Docker 安全扫描

最小的 Docker 镜像仍然可能包含已知漏洞的软件。Docker 镜像使用标准的开放格式,这意味着可以可靠地构建工具来导航和检查镜像层。一个工具是 Docker 安全扫描,它检查 Docker 镜像中的软件是否存在漏洞。

Docker 安全扫描检查镜像中的所有二进制文件,包括应用程序依赖项、应用程序框架甚至操作系统。每个二进制文件都会根据多个通用漏洞和利用CVE)数据库进行检查,寻找已知的漏洞。如果发现任何问题,Docker 会报告详细信息。

Docker 安全扫描可用于 Docker Hub 的官方存储库以及 Docker Trusted Registry 的私有存储库。这些系统的 Web 界面显示了每次扫描的输出。像 Alpine Linux 这样的最小镜像可能完全没有漏洞:

官方 NATS 镜像有一个 Nano Server 2016 变体,您可以看到该镜像中存在漏洞:

在存在漏洞的地方,您可以深入了解到底有哪些二进制文件被标记,并且链接到 CVE 数据库,描述了漏洞。在nats:nanoserver镜像的情况下,Nano Server 基础镜像中打包的 zlib 和 SQLite 版本存在漏洞。

这些扫描结果来自 Docker Hub 上的官方镜像。Docker Enterprise 还在 DTR 中提供安全扫描,您可以按需运行手动扫描,或配置任何推送到存储库的操作来触发扫描。我已经为 NerdDinner web 应用程序创建了一个存储库,该存储库配置为在每次推送图像时进行扫描:

对该存储库的访问基于第八章中相同的安全设置,即管理和监控 Docker 化解决方案,使用nerd-dinner组织和Nerd Dinner Ops团队。DTR 使用与 UCP 相同的授权,因此您可以在 Docker Enterprise 中构建组织和团队一次,并将它们用于保护图像和运行时资源。用户elton属于Nerd Dinner Ops团队,对nerd-dinner-web存储库具有读写访问权限,这意味着可以推送和拉取图像。

当我向这个存储库推送图像时,Docker Trusted Registry 将开始进行安全扫描,从而识别图像每个层中的所有二进制文件,并检查它们是否存在 CVE 数据库中已知的漏洞。NerdDinner web 应用程序基于 Microsoft 的 ASP.NET 镜像,在撰写本文时,该镜像中的组件存在已知漏洞:

System.Net.Http中的问题只能在 ASP.NET Core 应用程序中被利用,所以我可以自信地说它们在我的.NET Framework 应用程序中不是问题。然而,Microsoft.AspNet.Mvc的跨站脚本(XSS)问题确实适用,我想要更多地了解有关利用的信息,并在我的 CI 流程中添加测试来确认攻击者无法通过我的应用程序利用它。

这些漏洞不是我在 Dockerfile 中添加的库中的漏洞——它们在基础镜像中,并且实际上是 ASP.NET 和 ASP.NET Core 的一部分。这与在容器中运行无关。如果您在任何版本的 Windows 上运行任何版本的 ASP.NET MVC 从 2.0 到 5.1,那么您的生产系统中就存在这个 XSS 漏洞,但您可能不知道。

当您在图像中发现漏洞时,您可以准确地看到它们的位置,并决定如何加以减轻。如果您有一个可以自信地用来验证您的应用程序是否仍然可以正常工作的自动化测试套件,您可以尝试完全删除二进制文件。或者,您可能会决定从您的应用程序中没有漏洞代码的路径,并保持图像不变,并添加测试以确保没有办法利用漏洞。

无论您如何管理它,知道应用程序堆栈中存在漏洞非常有用。Docker 安全扫描可以在每次推送时工作,因此如果新版本引入漏洞,您将立即得到反馈。它还链接到 UCP,因此您可以从管理界面上看到正在运行的容器的图像中是否存在漏洞。

管理 Windows 更新

管理应用程序堆栈更新的过程也适用于 Docker 镜像的 Windows 更新。您不会连接到正在运行的容器来更新其使用的 Node.js 版本,也不会运行 Windows 更新。

微软通常会发布一组综合的安全补丁和其他热修复程序,通常每月一次作为 Windows 更新。同时,他们还会在 Docker Hub 和 Microsoft 容器注册表上发布新版本的 Windows Server Core 和 Nano Server 基础镜像以及任何依赖镜像。镜像标签中的版本号与 Windows 发布的热修复号匹配。

在 Dockerfile 的FROM指令中明确声明要使用的 Windows 版本,并使用安装的任何依赖项的特定版本是一个很好的做法。这使得您的 Dockerfile 是确定性的-在将来任何时候构建它,您将得到相同的镜像,其中包含所有相同的二进制文件。

指定 Windows 版本还清楚地表明了如何管理 Docker 化应用程序的 Windows 更新。.NET Framework 应用程序的 Dockerfile 可能是这样开始的:

FROM mcr.microsoft.com/windows/servercore:1809_KB4471332

这将镜像固定为带有更新KB4471332的 Windows Server 2019。这是一个可搜索的知识库 ID,告诉您这是 Windows 2018 年 12 月的更新。随着新的 Windows 基础镜像的发布,您可以通过更改FROM指令中的标签并重新构建镜像来更新应用程序,例如使用发布KB4480116,这是 2019 年 1 月的更新:

FROM mcr.microsoft.com/windows/servercore:1809_KB4480116

我将在第十章中介绍自动构建和部署,使用 Docker 打造持续部署流水线。通过一个良好的 CI/CD 流水线,您可以使用新的 Windows 版本重新构建您的镜像,并运行所有测试以确认更新不会影响任何功能。然后,您可以通过使用docker stack deploydocker service update在没有停机时间的情况下将更新推出到所有正在运行的应用程序,指定应用程序镜像的新版本。整个过程可以自动化,因此 IT 管理员在补丁星期二时的痛苦会随着 Docker 的出现而消失。

使用 DTR 保护软件供应链

DTR 是 Docker 扩展 EE 提供的第二部分。(我在第八章中介绍了Universal Control PlaneUCP),管理和监控 Docker 化解决方案。)DTR 是一个私有的 Docker 注册表,为 Docker 平台的整体安全性故事增添了一个重要组成部分:一个安全的软件供应链。

您可以使用 DTR 对 Docker 镜像进行数字签名,并且 DTR 允许您配置谁可以推送和拉取镜像,安全地存储用户对镜像应用的所有数字签名。它还与 UCP 一起工作,以强制执行内容信任。通过 Docker 内容信任,您可以设置集群,使其仅运行由特定用户或团队签名的镜像中的容器。

这是一个强大的功能,符合许多受监管行业的审计要求。公司可能需要证明生产中运行的软件实际上是从 SCM 中的代码构建的。没有软件供应链,这是非常难以做到的;您必须依赖手动流程和文件记录。使用 Docker,您可以在平台上强制执行它,并通过自动化流程满足审计要求。

仓库和用户

DTR 使用与 UCP 相同的身份验证模型,因此您可以使用您的Active DirectoryAD)帐户登录,或者您可以使用在 UCP 中创建的帐户。DTR 使用与 UCP 相同的组织、团队和用户的授权模型,但权限是分开的。用户可以对 DTR 中的镜像仓库和从这些镜像中运行的服务具有完全不同的访问权限。

DTR 授权模型的某些部分与 Docker Hub 相似。用户可以拥有公共或私人存储库,这些存储库以他们的用户名为前缀。管理员可以创建组织,组织存储库可以对用户和团队进行细粒度的访问控制。

我在第四章中介绍了镜像注册表和存储库,使用 Docker 注册表共享镜像。存储库的完整名称包含注册表主机、所有者和存储库名称。我在 Azure 中使用 Docker Certified Infrastructure 搭建了一个 Docker Enterprise 集群。我创建了一个名为elton的用户,他拥有一个私人存储库:

要将镜像推送到名为private-app的存储库,需要使用完整的 DTR 域标记它的存储库名称为用户elton。我的 DTR 实例正在运行在dtrapp-dow2e-hvfz.centralus.cloudapp.azure.com,所以我需要使用的完整镜像名称是dtrapp-dow2e-hvfz.centralus.cloudapp.azure.com/elton/private-app

docker image tag sixeyed/file-echo:nanoserver-1809 `
 dtrapp-dow2e-hvfz.centralus.cloudapp.azure.com/elton/private-app

这是一个私人存储库,所以只能被用户elton访问。DTR 呈现与任何其他 Docker 注册表相同的 API,因此我需要使用docker login命令登录,指定 DTR 域作为注册表地址:

> docker login dtrapp-dow2e-hvfz.centralus.cloudapp.azure.com
Username: elton
Password:
Login Succeeded

> docker image push dtrapp-dow2e-hvfz.centralus.cloudapp.azure.com/elton/private-app
The push refers to repository [dtrapp-dow2e-hvfz.centralus.cloudapp.azure.com/elton/private-app]
2f2b0ced10a1: Pushed
d3b13b9870f8: Pushed
81ab83c18cd9: Pushed
cc38bf58dad3: Pushed
af34821b76eb: Pushed
16575d9447bd: Pushing [==================================================>]  52.74kB
0e5e668fa837: Pushing [==================================================>]  52.74kB
3ec5dbbe3201: Pushing [==================================================>]  1.191MB
1e88b250839e: Pushing [==================================================>]  52.74kB
64cb5a75a70c: Pushing [>                                                  ]  2.703MB/143MB
eec13ab694a4: Waiting
37c182b75172: Waiting
...
...

如果我将存储库设为公开,任何有权访问 DTR 的人都可以拉取镜像,但这是一个用户拥有的存储库,所以只有elton账户有推送权限。

这与 Docker Hub 相同,任何人都可以从我的sixeyed用户存储库中拉取镜像,但只有我可以推送它们。对于需要多个用户访问推送镜像的共享项目,您可以使用组织。

组织和团队

组织用于共享存储库的所有权。组织及其拥有的存储库与拥有存储库权限的用户是分开的。特定用户可能具有管理员访问权限,而其他用户可能具有只读访问权限,特定团队可能具有读写访问权限。

DTR 的用户和组织模型与 Docker Hub 的付费订阅层中的模型相同。如果您不需要完整的 Docker Enterprise 生产套件,但需要具有共享访问权限的私人存储库,您可以使用 Docker Hub。

我在 nerd-dinner 组织下为 NerdDinner 堆栈的更多组件创建了存储库:

我可以向个别用户或团队授予对存储库的访问权限。Nerd Dinner Ops团队是我在 UCP 中创建的管理员用户组。这些用户可以直接推送图像,因此他们对所有存储库具有读写权限:

Nerd Dinner 测试团队只需要对存储库具有读取权限,因此他们可以在本地拉取图像进行测试,但无法将图像推送到注册表:

在 DTR 中组织存储库取决于您。您可以将所有应用程序存储库放在一个组织下,并为可能在许多项目中使用的共享组件(如 NATS 和 Elasticsearch)创建一个单独的组织。这意味着共享组件可以由专门的团队管理,他们可以批准更新并确保所有项目都使用相同的版本。项目团队成员具有读取权限,因此他们可以随时拉取最新的共享图像并运行其完整的应用程序堆栈,但他们只能将更新推送到其项目存储库。

DTR 具有无、读取、读写和管理员的权限级别。它们可以应用于团队或个别用户的存储库级别。DTR 和 UCP 的一致身份验证但分离授权模型意味着开发人员可以在 DTR 中具有完全访问权限以拉取和推送图像,但在 UCP 中可能只有读取权限以查看运行中的容器。

在成熟的工作流程中,您不会让个人用户推送图像 - 一切都将自动化。您的初始推送将来自构建图像的 CI 系统,然后您将为图像添加来源层,从推广政策开始。

DTR 中的图像推广政策

许多公司在其注册表中使用多个存储库来存储应用程序生命周期不同阶段的图像。最简单的例子是nerd-dinner-test/web存储库,用于正在经历各种测试阶段的图像,以及nerd-dinner-prod/web存储库,用于已获得生产批准的图像。

DTR 提供了图像推广政策,可以根据您指定的标准自动将图像从一个存储库复制到另一个存储库。这为安全软件供应链增加了重要的链接。CI 流程可以从每次构建中将图像推送到测试存储库,然后 DTR 可以检查图像并将其推广到生产存储库。

您可以根据扫描中发现的漏洞数量、镜像标签的内容以及镜像中使用的开源组件的软件许可证来配置推广规则。我已经为从nerd-dinner-test/webnerd-dinner-prod/web的镜像配置了一些合理的推广策略:

当我将符合所有标准的镜像推送到测试仓库时,它会被 DTR 自动推广到生产仓库:

配置生产仓库,使得没有最终用户可以直接推送到其中,意味着镜像只能通过 DTR 的推广等自动化流程到达那里。

Docker Trusted Registry 为您提供了构建安全交付流水线所需的所有组件,但它并不强制执行任何特定的流程或技术。来自 DTR 的事件可以触发 webhooks,这意味着您可以将您的注册表与几乎任何 CI 系统集成。触发 webhook 的一个事件是镜像推广,您可以使用它来触发新镜像的自动签名。

镜像签名和内容信任

DTR 利用 UCP 管理的客户端证书对镜像进行数字签名,可以追踪到已知用户帐户。用户从 UCP 下载客户端捆绑包,其中包含其客户端证书的公钥和私钥,该证书由 Docker CLI 使用。

您可以使用相同的方法处理其他系统的用户帐户,因此您可以为您的 CI 服务创建一个帐户,并设置仓库,以便只有 CI 帐户可以访问推送。这样,您可以将镜像签名集成到您的安全交付流水线中,从 CI 流程应用签名,并使用它来强制执行内容信任。

您可以通过环境变量打开 Docker 内容信任,并且当您将镜像推送到注册表时,Docker 将使用来自您客户端捆绑包的密钥对其进行签名。内容信任仅适用于特定的镜像标签,而不适用于默认的latest标签,因为签名存储在标签上。

我可以给我的私有镜像添加v2标签,在 PowerShell 会话中启用内容信任,并将标记的镜像推送到 DTR:

> docker image tag `
    dtrapp-dow2e-hvfz.centralus.cloudapp.azure.com/elton/private-app `
    dtrapp-dow2e-hvfz.centralus.cloudapp.azure.com/elton/private-app:v2

> $env:DOCKER_CONTENT_TRUST=1

> >docker image push dtrapp-dow2e-hvfz.centralus.cloudapp.azure.com/elton/private-app:v2The push refers to repository [dtrapp-dow2e-hvfz.centralus.cloudapp.azure.com/elton/private-app]
2f2b0ced10a1: Layer already exists
...
v2: digest: sha256:4c830828723a89e7df25a1f6b66077c1ed09e5f99c992b5b5fbe5d3f1c6445f2 size: 3023
Signing and pushing trust metadata
Enter passphrase for root key with ID aa2544a:
Enter passphrase for new repository key with ID 2ef6158:
Repeat passphrase for new repository key with ID 2ef6158:
Finished initializing "dtrapp-dow2e-hvfz.centralus.cloudapp.azure.com/elton/private-app"
Successfully signed dtrapp-dow2e-hvfz.centralus.cloudapp.azure.com/elton/private-app:v2

推送图像的行为会添加数字签名,在这种情况下使用elton帐户的证书并为存储库创建新的密钥对。DTR 记录每个图像标签的签名,在 UI 中我可以看到v2图像标签已签名:

用户可以推送图像以添加自己的签名。这使得批准流水线成为可能,授权用户拉取图像,运行他们需要的任何测试,然后再次推送以确认他们的批准。

DTR 使用 Notary 来管理访问密钥和签名。与 SwarmKit 和 LinuxKit 一样,Notary 是 Docker 集成到商业产品中的开源项目,添加功能并提供支持。要查看图像签名和内容信任的实际操作,请查看我的 Pluralsight 课程Getting Started with Docker Datacenter

UCP 与 DTR 集成以验证图像签名。在管理设置中,您可以配置 UCP,使其可以运行已由组织中已知团队签名的图像的容器:

我已经配置了 Docker 内容信任,以便 UCP 只运行已由 Nerd Dinners Ops 团队成员签名的容器。这明确捕获了发布批准工作流程,并且平台强制执行它。甚至管理员也不能运行未经所需团队用户签名的图像的容器——UCP 将抛出错误,指出图像未满足签名策略:

构建安全的软件供应链是关于建立一个自动化流水线,您可以保证图像已由已知用户帐户推送,它们满足特定的质量标准,并且已由已知用户帐户签名。DTR 提供了所有集成这一点到 CI 流水线中的功能,使用诸如 Jenkins 或 Azure DevOps 之类的工具。您可以使用任何自动化服务器或服务,只要它可以运行 shell 命令并响应 webhook——这几乎是每个系统。

有一个 Docker 参考架构详细介绍了安全供应链,以 GitLab 作为示例 CI 服务器,并向您展示如何将安全交付流水线与 Docker Hub 或 DTR 集成。您可以在success.docker.com/article/secure-supply-chain找到它。

黄金图像

镜像和注册表的最后一个安全考虑是用于应用程序镜像的基础镜像的来源。在生产中运行 Docker 的公司通常限制开发人员可以使用的基础镜像集,该集已获得基础设施或安全利益相关者的批准。可供使用的这组黄金镜像可能仅在文档中记录,但使用私有注册表更容易强制执行。

在 Windows 环境中,黄金镜像可能仅限于两个选项:Windows Server Core 的一个版本和 Nano Server 的一个版本。运维团队可以从 Microsoft 的基础镜像构建自定义镜像,而不是允许用户使用公共 Microsoft 镜像。自定义镜像可能会添加安全或性能调整,或设置一些适用于所有应用程序的默认值,例如打包公司的证书颁发机构证书。

使用 DTR,您可以为所有基础镜像创建一个组织,运维团队对存储库具有读写访问权限,而所有其他用户只有读取权限。检查镜像是否使用有效的基础镜像只意味着检查 Dockerfile 是否使用了来自 base-images 组织的镜像,这是在 CI/CD 过程中轻松自动化的测试。

黄金镜像为您的组织增加了管理开销,但随着您将越来越多的应用程序迁移到 Docker,这种开销变得更加值得。拥有自己的 ASP.NET 镜像,并使用公司的默认配置部署,使安全团队可以轻松审计基础镜像。您还拥有自己的发布节奏和注册表域,因此您不需要在 Dockerfile 中使用古怪的镜像名称。

了解集群模式中的安全性

Docker 的深度安全性方法涵盖了整个软件生命周期,从构建时的镜像签名和扫描到运行时的容器隔离和管理。我将以概述在集群模式中实施的安全功能结束本章。

分布式软件提供了许多有吸引力的攻击向量。组件之间的通信可能会被拦截和修改。恶意代理可以加入网络并访问数据或运行工作负载。分布式数据存储可能会受到损害。建立在开源 SwarmKit 项目之上的 Docker 集群模式在平台级别解决了这些向量,因此您的应用程序默认在安全基础上运行。

节点和加入令牌

您可以通过运行docker swarm init切换到集群模式。此命令的输出会给您一个令牌,您可以使用它让其他节点加入集群。工作节点和管理节点有单独的令牌。节点没有令牌无法加入集群,因此您需要像保护其他秘密一样保护令牌。

加入令牌由前缀、格式版本、根密钥的哈希和密码学强随机字符串组成。

Docker 使用固定的SWMTKN前缀用于令牌,因此您可以运行自动检查,以查看令牌是否在源代码或其他公共位置上被意外共享。如果令牌受到损害,恶意节点可能会加入集群,如果它们可以访问您的网络。集群模式可以使用特定网络进行节点流量,因此您应该使用一个不公开可访问的网络。

加入令牌可以通过join-token rotate命令进行旋转,可以针对工作节点令牌或管理节点令牌进行操作:

> docker swarm join-token --rotate worker
Successfully rotated worker join token.

To add a worker to this swarm, run the following command:

 docker swarm join --token SWMTKN-1-0ngmvmnpz0twctlya5ifu3ajy3pv8420st...  10.211.55.7:2377

令牌旋转是集群的完全托管操作。所有现有节点都会更新,并且任何错误情况,如节点离线或在旋转过程中加入,都会得到优雅处理。

加密和秘密

集群节点之间的通信使用传输层安全性TLS)进行加密。当您创建集群时,集群管理器会将自身配置为认证机构,并在节点加入时为每个节点生成证书。集群中的节点之间的通信使用相互 TLS 进行加密。

相互 TLS 意味着节点可以安全地通信并相互信任,因为每个节点都有一个受信任的证书来标识自己。节点被分配一个在证书中使用的随机 ID,因此集群不依赖于主机名等属性,这些属性可能会被伪造。

节点之间的可信通信是集群模式中 Docker Secrets 的基础。秘密存储在管理节点的 Raft 日志中并进行加密,只有当工作节点要运行使用该秘密的容器时,才会将秘密发送给工作节点。秘密在传输过程中始终使用相互 TLS 进行加密。在工作节点上,秘密以明文形式在临时 RAM 驱动器上可用,并作为卷挂载到容器中。数据永远不会以明文形式持久保存。

Windows 没有本地的 RAM 驱动器,因此目前的秘密实现将秘密数据存储在工作节点的磁盘上,并建议使用 BitLocker 来保护系统驱动器。秘密文件在主机上受 ACLs 保护。

在容器内部,对秘密文件的访问受到限制,只能由特定用户帐户访问。在 Linux 中可以指定具有访问权限的帐户,但在 Windows 中,目前有一个固定的列表。我在第七章的 ASP.NET Web 应用程序中使用了秘密,使用 Docker Swarm 编排分布式解决方案,您可以在那里看到我配置了 IIS 应用程序池以使用具有访问权限的帐户。

当容器停止、暂停或删除时,容器可用的秘密将从主机中删除。在 Windows 上,秘密目前被持久化到磁盘,如果主机被强制关闭,那么在主机重新启动时秘密将被删除。

节点标签和外部访问

一旦节点被添加到集群中,它就成为容器工作负载的候选对象。许多生产部署使用约束条件来确保应用程序在正确类型的节点上运行,并且 Docker 将尝试将请求的约束与节点上的标签进行匹配。

在受监管的环境中,您可能需要确保应用程序仅在已满足所需审核级别的服务器上运行,例如用于信用卡处理的 PCI 合规性。您可以使用标签识别符合条件的节点,并使用约束条件确保应用程序仅在这些节点上运行。集群模式有助于确保这些约束得到适当执行。

集群模式中有两种类型的标签:引擎标签和节点标签。引擎标签由 Docker 服务配置中的机器设置,因此,如果工作节点受到攻击者的攻击,攻击者可以添加标签,使他们拥有的机器看起来合规。节点标签由集群设置,因此只能由具有对集群管理器访问权限的用户创建。节点标签意味着您不必依赖于各个节点提出的声明,因此,如果它们受到攻击,影响可以得到限制。

节点标签在隔离对应用程序的访问方面也很有用。您可能有仅在内部网络上可访问的 Docker 主机,也可能有访问公共互联网的主机。使用标签,您可以明确记录它作为一个区别,并根据标签运行具有约束的容器。您可以在容器中拥有一个仅在内部可用的内容管理系统,但一个公开可用的 Web 代理。

与容器安全技术的集成

Docker Swarm 是一个安全的容器平台,因为它使用开源组件和开放标准,所以与第三方工具集成得很好。当应用程序在容器中运行时,它们都暴露相同的 API——您可以使用 Docker 来检查容器中运行的进程,查看日志条目,浏览文件系统,甚至运行新命令。容器安全生态系统正在发展强大的工具,利用这一点在运行时增加更多的安全性。

如果您正在寻找 Windows 容器的扩展安全性,有两个主要供应商可供评估:Twistlock 和 Aqua Security。两者都有包括镜像扫描和秘密管理、运行时保护在内的全面产品套件,这是为您的应用程序增加安全性的最创新方式。

当您将运行时安全产品部署到集群时,它会监视容器并构建该应用程序的典型行为文件,包括 CPU 和内存使用情况,以及进出的网络流量。然后,它会寻找该应用程序实例中的异常情况,即容器开始表现出与预期模型不同的方式。这是识别应用程序是否被入侵的强大方式,因为攻击者通常会开始运行新进程或移动异常数量的数据。

以 Aqua Security 为例,它为 Windows 上的 Docker 提供了全套保护,扫描镜像并为容器提供运行时安全控制。这包括阻止从不符合安全标准的镜像中运行的容器——标记为 CVE 严重程度或平均分数、黑名单和白名单软件包、恶意软件、敏感数据和自定义合规性检查。

Aqua 还强制执行容器的不可变性,将运行的容器与其原始图像进行比较,并防止更改,比如安装新的可执行文件。这是防止恶意代码注入或尝试绕过图像管道控制的强大方式。如果您从一个包含许多实际上不需要的组件的大型基础图像构建图像,Aqua 可以对攻击面进行分析,并列出实际需要的功能和能力。

这些功能适用于遗留应用程序中的容器,就像新的云原生应用程序一样。能够为应用程序部署的每一层添加深度安全,并实时监视可疑妥协,使安全方面成为迁移到容器的最有力的原因之一。

总结

本章讨论了 Docker 和 Windows 容器的安全考虑。您了解到 Docker 平台是为深度安全而构建的,并且容器的运行时安全只是故事的一部分。安全扫描、图像签名、内容信任和安全的分布式通信可以结合起来,为您提供一个安全的软件供应链。

你研究了在 Docker 中运行应用程序的实际安全方面,并了解了 Windows 容器中的进程是如何在一个上下文中运行的,这使得攻击者很难逃离容器并侵入其他进程。容器进程将使用它们所需的所有计算资源,但我还演示了如何限制 CPU 和内存使用,这可以防止恶意容器耗尽主机的计算资源。

在 docker 化的应用程序中,您有更多的空间来实施深度安全。我解释了为什么最小化的镜像有助于保持应用程序的安全,以及您如何使用 Docker 安全扫描来在您的应用程序使用的任何依赖关系中发现漏洞时收到警报。您可以通过数字签名图像并配置 Docker,以便它只运行已获得批准用户签名的图像中的容器,来强制执行良好的实践。

最后,我看了一下 Docker Swarm 中的安全实现。Swarm 模式拥有所有编排层中最深入的安全性,并为您提供了一个稳固的基础,让您可以安全地运行应用程序。使用 secrets 来存储敏感的应用程序数据,使用节点标签来识别主机的合规性,使您可以轻松地运行一个安全的解决方案,而开放的 API 使得集成第三方安全增强,如 Aqua 变得很容易。

在下一章中,我们将使用分布式应用程序,并着眼于构建 CI/CD 的流水线。Docker 引擎可以配置为提供对 API 的远程访问,因此很容易将 Docker 部署与任何构建系统集成。CI 服务器甚至可以在 Docker 容器内运行,您可以使用 Docker 作为构建代理,因此对于 CI/CD,您不需要任何复杂的配置。

第十章:使用 Docker 推动持续部署管道

Docker 支持构建和运行可以轻松分发和管理的组件。该平台还适用于开发环境,其中源代码控制、构建服务器、构建代理和测试代理都可以从标准镜像中运行在 Docker 容器中。

在开发中使用 Docker 可以让您在单一硬件集中 consoli 许多项目,同时保持隔离。您可以在 Docker Swarm 中运行具有高可用性的 Git 服务器和镜像注册表的服务,这些服务由许多项目共享。每个项目可以配置有自己的管道和自己的构建设置的专用构建服务器,在轻量级 Docker 容器中运行。

在这种环境中设置新项目只是在源代码控制存储库中创建新存储库和新命名空间,并运行新容器进行构建过程。所有这些步骤都可以自动化,因此项目入职变成了一个只需几分钟并使用现有硬件的简单过程。

在本章中,我将带您完成使用 Docker 设置持续集成和持续交付CI/CD)管道。我将涵盖:

  • 使用 Docker 设计 CI/CD

  • 在 Docker 中运行共享开发服务

  • 在 Docker 中使用 Jenkins 配置 CI/CD

  • 使用 Jenkins 部署到远程 Docker Swarm

技术要求

您需要在 Windows 10 更新 18.09 或 Windows Server 2019 上运行 Docker,以便按照示例进行操作。本章的代码可在github.com/sixeyed/docker-on-windows/tree/second-edition/ch10上找到。

使用 Docker 设计 CI/CD

该管道将支持完整的持续集成。当开发人员将代码推送到共享源代码存储库时,将触发生成发布候选版本的构建。发布候选版本将被标记为存储在本地注册表中的 Docker 镜像。CI 工作流从构建的图像中部署解决方案作为容器,并运行端到端测试包。

我的示例管道具有手动质量门。如果测试通过,图像版本将在 Docker Hub 上公开可用,并且管道可以在远程 Docker Swarm 上运行的公共环境中启动滚动升级。在完整的 CI/CD 环境中,您还可以在管道中自动部署到生产环境。

流水线的各个阶段都将由运行在 Docker 容器中的软件驱动:

  • 源代码控制:Gogs,一个用 Go 编写的简单的开源 Git 服务器

  • 构建服务器:Jenkins,一个基于 Java 的自动化工具,使用插件支持许多工作流

  • 构建代理:将.NET SDK 打包成一个 Docker 镜像,以在容器中编译代码

  • 测试代理:NUnit 打包成一个 Docker 镜像,用于对部署的代码进行端到端测试

Gogs 和 Jenkins 可以在 Docker Swarm 上或在单独的 Docker Engine 上运行长时间运行的容器。构建和测试代理是由 Jenkins 运行的任务容器,用于执行流水线步骤,然后它们将退出。发布候选将部署为一组容器,在测试完成时将被删除。

设置这个的唯一要求是让容器访问 Docker API——在本地和远程环境中都是如此。在本地服务器上,我将使用来自 Windows 的命名管道。对于远程 Docker Swarm,我将使用一个安全的 TCP 连接。我在第一章中介绍了如何保护 Docker API,在 Windows 上使用 Docker 入门,使用dockeronwindows/ch01-dockertls镜像生成 TLS 证书。您需要配置本地访问权限,以便 Jenkins 容器可以在开发中创建容器,并配置远程访问权限,以便 Jenkins 可以在公共环境中启动滚动升级。

这个流水线的工作流是当开发人员将代码推送到运行 Gogs 的 Git 服务器时开始的,Gogs 运行在一个 Docker 容器中。Jenkins 被配置为轮询 Gogs 存储库,如果有任何更改,它将开始构建。解决方案中的所有自定义组件都使用多阶段的 Dockerfile,这些文件存储在项目的 Git 存储库中。Jenkins 对每个 Dockerfile 运行docker image build命令,在同一 Docker 主机上构建镜像,Jenkins 本身也在一个容器中运行。

构建完成后,Jenkins 将解决方案部署到本地,作为同一 Docker 主机上的容器。然后,它运行端到端测试,这些测试打包在一个 Docker 镜像中,并作为一个容器在与被测试的应用程序相同的 Docker 网络中运行。如果所有测试都通过了,那么最终的流水线步骤将把这些图像作为发布候选推送到本地注册表中,而注册表也在一个 Docker 容器中运行。

当您在 Docker 中运行开发工具时,您将获得与在 Docker 中运行生产工作负载时相同的好处。整个工具链变得可移植,您可以在任何地方以最小的计算要求运行它。

在 Docker 中运行共享开发服务

诸如源代码控制和镜像注册表之类的服务是很适合在多个项目之间共享的候选项。它们对于高可用性和可靠存储有类似的要求,因此可以部署在具有足够容量的集群上,以满足许多项目的需求。CI 服务器可以作为共享服务运行,也可以作为每个团队或项目的单独实例运行。

我在第四章中介绍了在 Docker 容器中运行私有注册表,使用 Docker 注册表共享镜像。在这里,我们将看看如何在 Docker 中运行 Git 服务器和 CI 服务器。

将 Git 服务器打包到 Windows Docker 镜像中

Gogs 是一个流行的开源 Git 服务器。它是用 Go 语言编写的,跨平台,可以将其打包为基于最小 Nano Server 安装或 Windows Server Core 的 Docker 镜像。Gogs 是一个简单的 Git 服务器;它通过 HTTP 和 HTTPS 提供远程存储库访问,并且具有 Web UI。Gogs 团队在 Docker Hub 上提供了 Linux 的镜像,但您需要构建自己的镜像以在 Windows 容器中运行。

将 Gogs 打包到 Docker 镜像中非常简单。这是在 Dockerfile 中编写安装说明的情况,我已经为dockeronwindows/ch10-gogs:2e镜像完成了这个过程。该镜像使用多阶段构建,从 Windows Server Core 开始,下载 Gogs 发布并展开 ZIP 文件。

#escape=` FROM mcr.microsoft.com/windows/servercore:ltsc2019 as installer SHELL ["powershell", "-Command", "$ErrorActionPreference = 'Stop';"] ARG GOGS_VERSION="0.11.86" RUN Write-Host "Downloading: $($env:GOGS_VERSION)"; `
 Invoke-WebRequest -Uri "https://cdn.gogs.io/$($env:GOGS_VERSION)...zip" -OutFile 'gogs.zip'; RUN  Expand-Archive gogs.zip -DestinationPath C:\;

这里没有什么新东西,但有几点值得关注。Gogs 团队提供了一个 CDN 来发布他们的版本,并且 URL 使用相同的格式,所以我已经将版本号参数化为可下载。ARG指令使用默认的 Gogs 版本0.11.86,但我可以通过指定构建参数来安装不同的版本,而无需更改 Dockerfile。

为了清楚地表明正在安装的版本,我在下载 ZIP 文件之前写出了版本号。下载在单独的RUN指令中进行,因此下载的文件被存储在 Docker 缓存中的自己的层中。如果我需要编辑 Dockerfile 中的后续步骤,我可以再次构建镜像,并从缓存中获取已下载的文件,因此不需要重复下载。

最终镜像可以基于 Nano Server,因为 Gogs 是一个跨平台技术,但它依赖于难以在 Nano Server 中设置的 Git 工具。使用 Chocolatey 很容易安装依赖项,但在 Nano Server 中无法使用。我正在使用sixeyed/chocolatey作为基础应用程序镜像,这是 Docker Hub 上的一个公共镜像,在 Windows Server Core 上安装了 Chocolatey,然后我为 Gogs 设置了环境:

FROM sixeyed/chocolatey:windowsservercore-ltsc2019 ARG GOGS_VERSION="0.11.86" ARG GOGS_PATH="C:\gogs"

ENV GOGS_VERSION=${GOGS_VERSION} `GOGS_PATH=${GOGS_PATH} EXPOSE 3000 VOLUME C:\data C:\logs C:\repositories CMD ["gogs", "web"]

我正在捕获 Gogs 版本和安装路径作为ARG指令,以便它们可以在构建时指定。构建参数不会存储在最终镜像中,所以我将它们复制到ENV指令中的环境变量中。Gogs 默认使用端口3000,我为所有数据、日志和存储库目录创建卷。

Gogs 是一个 Git 服务器,但它的发布版本中不包括 Git,这就是为什么我使用了安装了 Chocolatey 的镜像。我使用choco命令行来安装git

RUN choco install -y git

最后,我从安装程序阶段复制了扩展的Gogs目录,并从本地的app.ini文件中捆绑了一组默认配置:

WORKDIR ${GOGS_PATH} COPY app.ini ./custom/conf/app.ini COPY --from=installer ${GOGS_PATH} .

构建这个镜像给我一个可以在 Windows 容器中运行的 Git 服务器。

使用比所需更大的基础镜像以及包括 Chocolatey 等安装工具的应用程序镜像并不是最佳实践。如果我的 Gogs 容器受到攻击,攻击者将可以访问choco命令以及 PowerShell 的所有功能。在这种情况下,容器不会在公共网络上,因此风险得到了缓解。

在 Docker 中运行 Gogs Git 服务器

您可以像运行任何其他容器一样运行 Gogs:将其设置为分离状态,发布 HTTP 端口,并使用主机挂载将卷存储在容器之外已知位置:

> mkdir C:\gogs\data; mkdir C:\gogs\repos

> docker container run -d -p 3000:3000 `
    --name gogs `
    -v C:\gogs\data:C:\data `
    -v C:\gogs\repos:C:\gogs\repositories `
    dockeronwindows/ch10-gogs:2e

Gogs 镜像内置了默认配置设置,但当您第一次运行应用程序时,您需要完成安装向导。我可以浏览到http://localhost:3000,保留默认值,并点击安装 Gogs 按钮:

现在,我可以注册用户并登录,这将带我到 Gogs 仪表板:

Gogs 支持问题跟踪和拉取请求,除了通常的 Git 功能,因此它非常类似于 GitHub 的精简本地版本。我继续创建了一个名为docker-on-windows的存储本书源代码的存储库。为了使用它,我需要将 Gogs 服务器添加为我的本地 Git 存储库的远程。

我使用gogs作为容器名称,所以其他容器可以通过该名称访问 Git 服务器。我还在我的主机文件中添加了一个与本地机器指向相同名称的条目,这样我就可以在我的机器和容器内使用相同的gogs名称(这在C:\Windows\System32\drivers\etc\hosts中):

#ch10 
127.0.0.1  gogs

我倾向于经常这样做,将本地机器或容器 IP 地址添加到我的主机文件中。我设置了一个 PowerShell 别名,使这一过程更加简单,它可以获取容器 IP 地址并将该行添加到主机文件中。我在blog.sixeyed.com/your-must-have-powershell-aliases-for-docker上发表了这一点以及我使用的其他别名。

现在,我可以像将源代码推送到 GitHub 或 GitLab 等其他远程 Git 服务器一样,从我的本地机器推送源代码到 Gogs。它在本地容器中运行,但对于我笔记本上的 Git 客户端来说是透明的。

> git remote add gogs http://gogs:3000/docker-on-windows.git

> git push gogs second-edition
Enumerating objects: 2736, done.
Counting objects: 100% (2736/2736), done.
Delta compression using up to 2 threads
Compressing objects: 100% (2058/2058), done.
Writing objects: 100% (2736/2736), 5.22 MiB | 5.42 MiB/s, done.
Total 2736 (delta 808), reused 2089 (delta 487)
remote: Resolving deltas: 100% (808/808), done.
To http://gogs:3000/elton/docker-on-windows.git
 * [new branch]      second-edition -> second-edition

Gogs 在 Docker 容器中是稳定且轻量的。我的实例在空闲时通常使用 50MB 的内存和少于 1%的 CPU。

运行本地 Git 服务器是一个好主意,即使你使用托管服务如 GitHub 或 GitLab。托管服务会出现故障,尽管很少,但可能会对生产力产生重大影响。拥有一个本地次要运行成本很低的服务器可以保护你免受下一次故障发生时的影响。

下一步是在 Docker 中运行一个 CI 服务器,该服务器可以从 Gogs 获取代码并构建应用程序。

将 CI 服务器打包成 Windows Docker 镜像

Jenkins 是一个流行的自动化服务器,用于 CI/CD。它支持具有多种触发类型的自定义作业工作流程,包括计划、SCM 轮询和手动启动。它是一个 Java 应用程序,可以很容易地在 Docker 中打包,尽管完全自动化 Jenkins 设置并不那么简单。

在本章的源代码中,我有一个用于dockersamples/ch10-jenkins-base:2e映像的 Dockerfile。这个 Dockerfile 使用 Windows Server Core 在安装阶段下载 Jenkins web 存档文件,打包了一个干净的 Jenkins 安装。我使用一个参数来捕获 Jenkins 版本,安装程序还会下载下载的 SHA256 哈希并检查下载的文件是否已损坏:

WORKDIR C:\jenkins  RUN Write-Host "Downloading Jenkins version: $env:JENKINS_VERSION"; `
 Invoke-WebRequest  "http://.../jenkins.war.sha256" -OutFile 'jenkins.war.sha256'; `
   Invoke-WebRequest "http://../jenkins.war" -OutFile 'jenkins.war' RUN $env:JENKINS_SHA256=$(Get-Content -Raw jenkins.war.sha256).Split(' ')[0]; `
    if ((Get-FileHash jenkins.war -Algorithm sha256).Hash.ToLower() -ne $env:JENKINS_SHA256) {exit 1}

检查下载文件的哈希值是一个重要的安全任务,以确保您下载的文件与发布者提供的文件相同。这是人们通常在手动安装软件时忽略的一步,但在 Dockerfile 中很容易自动化,并且可以为您提供更安全的部署。

Dockerfile 的最后阶段使用官方的 OpenJDK 映像作为基础,设置环境,并从安装程序阶段复制下载的文件:

FROM openjdk:8-windowsservercore-1809 ARG JENKINS_VERSION="2.150.3" ENV JENKINS_VERSION=${JENKINS_VERSION} ` JENKINS_HOME="C:\data" VOLUME ${JENKINS_HOME} EXPOSE 8080 50000 WORKDIR C:\jenkins ENTRYPOINT java -jar C:\jenkins\jenkins.war COPY --from=installer C:\jenkins .

干净的 Jenkins 安装没有太多有用的功能;几乎所有功能都是在设置 Jenkins 之后安装的插件提供的。其中一些插件还会安装它们所需的依赖项,但其他一些则不会。对于我的 CI/CD 流水线,我需要在 Jenkins 中安装 Git 客户端,以便它可以连接到在 Docker 中运行的 Git 服务器,并且我还希望安装 Docker CLI,以便我可以在构建中使用 Docker 命令。

我可以在 Jenkins 的 Dockerfile 中安装这些依赖项,但这将使其变得庞大且难以管理。相反,我将从其他 Docker 映像中获取这些工具。我使用的是sixeyed/gitsixeyed/docker-cli,这些都是 Docker Hub 上的公共映像。我将这些与 Jenkins 基础映像一起使用,构建我的最终 Jenkins 映像。

dockeronwindows/ch10-jenkins:2e的 Dockerfile 从基础开始,并从 Git 和 Docker CLI 映像中复制二进制文件:

# escape=` FROM dockeronwindows/ch10-jenkins-base:2e  WORKDIR C:\git COPY --from=sixeyed/git:2.17.1-windowsservercore-ltsc2019 C:\git . WORKDIR C:\docker COPY --from=sixeyed/docker-cli:18.09.0-windowsservercore-ltsc2019 ["C:\\Program Files\\Docker", "."]

最后一行只是将所有新的工具位置添加到系统路径中,以便 Jenkins 可以找到它们:

RUN $env:PATH = 'C:\docker;' + 'C:\git\cmd;C:\git\mingw64\bin;C:\git\usr\bin;' + $env:PATH; `   [Environment]::SetEnvironmentVariable('PATH', $env:PATH, [EnvironmentVariableTarget]::Machine)

使用公共 Docker 映像来获取依赖项,可以让我得到一个包含所有所需组件的最终 Jenkins 映像,但使用一组可重用的源映像编写一个可管理的 Dockerfile。现在,我可以在容器中运行 Jenkins,并通过安装插件完成设置。

在 Docker 中运行 Jenkins 自动化服务器

Jenkins 使用端口8080用于 Web UI,因此您可以使用以下命令从本章的映像中运行它,该命令映射端口并挂载本地文件夹到 Jenkins 根目录:

mkdir C:\jenkins

docker run -d -p 8080:8080 `
 -v C:\jenkins:C:\data `
 --name jenkins `
 dockeronwindows/ch10-jenkins:2e

Jenkins 为每个新部署生成一个随机的管理员密码。我可以在浏览网站之前从容器日志中获取该密码:

> docker container logs jenkins
...
*******************************************************
Jenkins initial setup is required. An admin user has been created and a password generated.
Please use the following password to proceed to installation:

6467e40d9c9b4d21916c9bdb2b05bba3

This may also be found at: C:\data\secrets\initialAdminPassword
*******************************************************

现在,我将浏览本地主机上的端口8080,输入生成的密码,并添加我需要的 Jenkins 插件。作为最简单的示例,我选择了自定义插件安装,并选择了文件夹、凭据绑定和 Git 插件,这样我就可以获得大部分所需的功能:

我需要一个插件来在构建作业中运行 PowerShell 脚本。这不是一个推荐的插件,因此它不会显示在初始设置列表中。一旦 Jenkins 启动,我转到“管理 Jenkins | 管理插件”,然后从“可用”列表中选择 PowerShell 并单击“无需重启安装”:

完成后,我拥有了运行 CI/CD 流水线所需的所有基础设施服务。但是,它们运行在已经定制过的容器中。Gogs 和 Jenkins 容器中的应用程序经历了手动设置阶段,并且与它们运行的镜像不处于相同的状态。如果我替换容器,我将丢失我所做的额外设置。我可以通过从容器创建镜像来解决这个问题。

从运行的容器中提交镜像

您应该从 Dockerfile 构建您的镜像。这是一个可重复的过程,可以存储在源代码控制中进行版本控制、比较和授权。但是有一些应用程序在部署后需要额外的设置步骤,并且这些步骤需要手动执行。

Jenkins 是一个很好的例子。您可以使用 Jenkins 自动安装插件,但这需要额外的下载和一些 Jenkins API 的脚本编写。插件依赖关系并不总是在安装时解决,因此手动设置插件并验证部署可能更安全。完成后,您可以通过提交容器来保持最终设置,从容器的当前状态生成新的 Docker 镜像。

在 Windows 上,您需要停止容器才能提交它们,然后运行docker container commit,并提供容器的名称和要创建的新镜像标签:

> docker container stop jenkins
jenkins

> docker container commit jenkins dockeronwindows/ch10-jenkins:2e-final
sha256:96dd3caa601c3040927459bd56b46f8811f7c68e5830a1d76c28660fa726960d

对于我的设置,我已经提交了 Jenkins 和 Gogs,并且有一个 Docker Compose 文件来配置它们,以及注册表容器。这些是基础设施组件,但这仍然是一个分布式解决方案。Jenkins 容器将访问 Gogs 和注册表容器。所有服务都具有相同的 SLA,因此在 Compose 文件中定义它们可以让我捕获并一起启动所有服务。

在 Docker 中使用 Jenkins 配置 CI/CD

我将配置我的 Jenkins 构建作业来轮询 Git 存储库,并使用 Git 推送作为新构建的触发器。

Jenkins 将通过 Gogs 的存储库 URL 连接到 Git,并且构建、测试和部署解决方案的所有操作都将作为 Docker 容器运行。Gogs 服务器和 Docker 引擎具有不同的身份验证模型,但 Jenkins 支持许多凭据类型。我可以配置构建作业以安全地访问源存储库和主机上的 Docker。

设置 Jenkins 凭据

Gogs 与外部身份提供者集成,还具有自己的基本用户名/密码身份验证功能,我在我的设置中使用了它。这在 HTTP 上不安全,因此在真实环境中,我将使用 SSH 或 HTTPS 进行 Git,可以通过在镜像中打包 SSL 证书,或者在 Gogs 前面使用代理服务器来实现。

在 Gogs 管理界面的“用户”部分,我创建了一个jenkins用户,并为其赋予了对docker-on-windowsGit 存储库的读取权限,这将用于我的示例 CI/CD 作业:

Jenkins 将作为jenkins用户进行身份验证,从 Gogs 拉取源代码存储库。我已将用户名和密码添加到 Jenkins 作为全局凭据,以便任何作业都可以使用:

Jenkins 在输入密码后不显示密码,并记录使用凭据的所有作业的审计跟踪,因此这是一种安全的身份验证方式。我的 Jenkins 容器正在运行,它使用一个卷将 Windows 主机的 Docker 命名管道挂载,以便它可以在不进行身份验证的情况下与 Docker 引擎一起工作。

作为替代方案,我可以通过 TCP 连接到远程 Docker API。要使用 Docker 进行身份验证,我将使用在保护 Docker 引擎时生成的传输层安全性TLS)证书。有三个证书——证书颁发机构CA),客户端证书和客户端密钥。它们需要作为文件路径传递给 Docker CLI,并且 Jenkins 支持使用可以保存为秘密文件的凭据来存储证书 PEM 文件。

配置 Jenkins CI 作业

在本章中,示例解决方案位于ch10-nerd-dinner文件夹中。这是现代化的 NerdDinner 应用程序,在前几章中已经发展过了。每个组件都有一个 Dockerfile。这使用了多阶段构建,并且有一组 Docker Compose 文件用于构建和运行应用程序。

这里的文件夹结构值得一看,以了解分布式应用程序通常是如何排列的——src文件夹包含所有应用程序和数据库源代码,docker文件夹包含所有 Dockerfile,compose文件夹包含所有 Compose 文件。

我在 Jenkins 中创建了一个自由风格的作业来运行构建,并配置了 Git 进行源代码管理。配置 Git 很简单,我使用的是在笔记本电脑上 Git 存储库中使用的相同存储库 URL,并且我已经选择了 Gogs 凭据,以便 Jenkins 可以访问它们:

Jenkins 正在 Docker 容器中运行,Gogs 也在同一 Docker 网络的容器中运行。我正在使用主机名gogs,这是容器名称,以便 Jenkins 可以访问 Git 服务器。在我的笔记本电脑上,我已经在 hosts 文件中添加了gogs作为条目,这样我就可以在开发和 CI 服务器上使用相同的存储库 URL。

Jenkins 支持多种类型的构建触发器。在这种情况下,我将定期轮询 Git 服务器。我使用H/5 * * * *作为调度频率,这意味着 Jenkins 将每五分钟检查存储库。如果自上次构建以来有任何新的提交,Jenkins 将运行作业。

这就是我需要的所有作业配置,所有构建步骤现在将使用 Docker 容器运行。

在 Jenkins 中使用 Docker 构建解决方案

构建步骤使用 PowerShell 运行简单的脚本,因此不依赖于更复杂的 Jenkins 插件。有一些特定于 Docker 的插件,可以包装多个任务,比如构建镜像并将其推送到注册表,但我可以使用基本的 PowerShell 步骤和 Docker CLI 来完成我需要的一切。第一步构建所有的镜像:

cd .\ch10\ch10-nerd-dinner

docker image build -t dockeronwindows/ch10-nerd-dinner-db:2e `
                   -f .\docker\nerd-dinner-db\Dockerfile .
docker image build -t dockeronwindows/ch10-nerd-dinner-index-handler:2e `
                   -f .\docker\nerd-dinner-index-handler\Dockerfile .
docker image build -t dockeronwindows/ch10-nerd-dinner-save-handler:2e `
                   -f .\docker\nerd-dinner-save-handler\Dockerfile .
...

使用docker-compose build和覆盖文件会更好,但是 Docker Compose CLI 存在一个未解决的问题,这意味着它在容器内部无法正确使用命名管道。当这个问题在未来的 Compose 版本中得到解决时,构建步骤将更简单。

Docker Compose 是开源的,您可以在 GitHub 上查看此问题的状态:github.com/docker/compose/issues/5934

Docker 使用多阶段 Dockerfile 构建镜像,构建的每个步骤在临时 Docker 容器中执行。Jenkins 本身运行在一个容器中,并且它的镜像中有 Docker CLI。我不需要在构建服务器上安装 Visual Studio,甚至不需要安装.NET Framework 或.NET Core SDK。所有的先决条件都在 Docker 镜像中,所以 Jenkins 构建只需要源代码和 Docker。

运行和验证解决方案

Jenkins 中的下一个构建步骤将在本地部署解决方案,运行在 Docker 容器中,并验证构建是否正常工作。这一步是另一个 PowerShell 脚本,它首先通过docker container run命令部署应用程序:

docker container run -d `
  --label ci ` --name nerd-dinner-db `
 dockeronwindows/ch10-nerd-dinner-db:2e; docker container run -d `
  --label ci `
  -l "traefik.frontend.rule=Host:nerd-dinner-test;PathPrefix:/"  `
  -l "traefik.frontend.priority=1"  `
  -e "HomePage:Enabled=false"  `
  -e "DinnerApi:Enabled=false"  `
 dockeronwindows/ch10-nerd-dinner-web:2e; ... 

在构建中使用 Docker CLI 而不是 Compose 的一个优势是,我可以按特定顺序创建容器,这样可以给慢启动的应用程序(如 NerdDinner 网站)更多的时间准备好,然后再进行测试。我还给所有的容器添加了一个标签ci,以便稍后清理所有的测试容器,而不会删除其他容器。

完成这一步后,所有的容器应该都在运行。在运行可能需要很长时间的端到端测试套件之前,我在构建中有另一个 PowerShell 步骤,运行一个简单的验证测试,以确保应用程序有响应。

Invoke-WebRequest  -UseBasicParsing http://nerd-dinner-test

请记住,这些命令是在 Jenkins 容器内运行的,这意味着它可以通过名称访问其他容器。我不需要发布特定的端口或检查容器以获取它们的 IP 地址。脚本使用名称nerd-dinner-test启动 Traefik 容器,并且所有前端容器在其 Traefik 规则中使用相同的主机名。Jenkins 作业可以访问该 URL,如果构建成功,应用程序将做出响应。

此时,应用程序已经从最新的源代码构建,并且在容器中全部运行。我已经验证了主页是可访问的,这证明了网站正在运行。构建步骤都是控制台命令,因此输出将被写入 Jenkins 作业日志中。对于每个构建,您将看到所有输出,包括以下内容:

  • Docker 执行 Dockerfile 命令

  • NuGet 和 MSBuild 步骤编译应用程序

  • Docker 启动应用程序容器

  • PowerShell 向应用程序发出 Web 请求

Invoke-WebRequest命令是一个简单的构建验证测试。如果构建或部署失败,它会产生错误,但是,如果成功,这仍不意味着应用程序正常工作。为了增强对构建的信心,我在下一个构建步骤中运行端到端集成测试。

在 Docker 中运行端到端测试

在本章中,我还添加了 NerdDinner 解决方案的另一个组件,即使用模拟浏览器与 Web 应用程序进行交互的测试项目。浏览器向端点发送 HTTP 请求,实际上将是一个容器,并断言响应包含正确的内容。

NerdDinner.EndToEndTests项目使用 SpecFlow 来定义功能测试,说明解决方案的预期行为。使用 Selenium 执行 SpecFlow 测试,Selenium 自动化浏览器测试,以及 SimpleBrowser,提供无头浏览器。这些都是可以从控制台运行的 Web 测试,因此不需要 UI 组件,并且可以在 Docker 容器中执行。

如果这听起来像是要添加到您的测试基础设施中的大量技术,实际上这是一种非常巧妙的方式,可以对应用程序进行完整的集成测试,这些测试已经在使用人类语言的简单场景中指定了:

Feature: Nerd Dinner Homepage
    As a Nerd Dinner user
    I want to see a modern responsive homepage
    So that I'm encouraged to engage with the app

Scenario: Visit homepage
    Given I navigate to the app at "http://nerd-dinner-test"
    When I see the homepage 
    Then the heading should contain "Nerd Dinner 2.0!"

我有一个 Dockerfile 来将测试项目构建成dockeronwindows/ch10-nerd-dinner-e2e-tests:2e镜像。它使用多阶段构建来编译测试项目,然后打包测试程序集。构建的最后阶段使用了 Docker Hub 上安装了 NUnit 控制台运行器的镜像,因此它能够通过控制台运行端到端测试。Dockerfile 设置了一个CMD指令,在容器启动时运行所有测试:

FROM sixeyed/nunit:3.9.0-windowsservercore-ltsc2019 WORKDIR /e2e-tests CMD nunit3-console NerdDinner.EndToEndTests.dll COPY --from=builder C:\e2e-tests .

我可以从这个镜像中运行一个容器,它将启动测试套件,连接到http://nerd-dinner-test,并断言响应中包含预期的标题文本。这个简单的测试实际上验证了我的新主页容器和反向代理容器都在运行,它们可以在 Docker 网络上相互访问,并且代理规则已经正确设置。

我的测试中只有一个场景,但因为整个堆栈都在容器中运行,所以很容易编写一套执行应用程序关键功能的高价值测试。我可以构建一个包含已知测试数据的自定义数据库镜像,并编写简单的场景来验证用户登录、列出晚餐和创建晚餐的工作流。我甚至可以在测试断言中查询 SQL Server 容器,以确保新数据已插入。

Jenkins 构建的下一步是运行这些端到端测试。同样,这是一个简单的 PowerShell 脚本,它构建端到端 Docker 镜像,然后运行一个容器。测试容器将在与应用程序相同的 Docker 网络中执行,因此无头浏览器可以使用 URL 中的容器名称访问 Web 应用程序:

cd .\ch10\ch10-nerd-dinner docker image build ` -t dockeronwindows/ch10-nerd-dinner-e2e-tests:2e ` -f .\docker\nerd-dinner-e2e-tests\Dockerfile . $e2eId  = docker container run -d dockeronwindows/ch10-nerd-dinner-e2e-tests:2e

NUnit 生成一个包含测试结果的 XML 文件,将其添加到 Jenkins 工作空间中会很有用,这样在所有容器被移除后可以在 Jenkins UI 中查看。PowerShell 步骤使用docker container cp将该文件从容器复制到 Jenkins 工作空间的当前目录中,使用从运行命令中存储的容器 ID:

docker container cp "$($e2eId):C:\e2e-tests\TestResult.xml" .

在这一步中还有一些额外的 PowerShell 来从该文件中读取 XML 并确定测试是否通过(您可以在本章的源文件夹中的ci\04_test.ps1文件中找到完整的脚本)。当完成时,NUnit 的输出将被回显到 Jenkins 日志中:

[ch10-nerd-dinner] $ powershell.exe ...
30bc931ca3941b3357e3b991ccbb4eaf71af03d6c83d95ca6ca06faeb8e46a33
* E2E test results:
type          : Assembly
id            : 0-1002
name          : NerdDinner.EndToEndTests.dll
fullname      : NerdDinner.EndToEndTests.dll
runstate      : Runnable
testcasecount : 1
result        : Passed
start-time    : 2019-02-19 20:48:09Z
end-time      : 2019-02-19 20:48:10Z
duration      : 1.305796
total         : 1
passed        : 1
failed        : 0
warnings      : 0
inconclusive  : 0
skipped       : 0
asserts       : 2

* Overall: Passed

当测试完成时,数据库容器和所有其他应用程序容器将在测试步骤的最后部分被移除。这使用docker container ls命令列出所有具有ci标签的容器的 ID - 这些是由此作业创建的容器 - 然后强制性地将它们删除:

docker rm -f $(docker container ls --filter "label=ci" -q)

现在,我有一组经过测试并已知良好的应用程序图像。这些图像仅存在于构建服务器上,因此下一步是将它们推送到本地注册表。

在 Jenkins 中标记和推送 Docker 图像

在构建过程中如何将图像推送到您的注册表是您的选择。您可以从为每个图像打上构建编号的标签并将所有图像版本推送到注册表作为 CI 构建的一部分开始。使用高效的 Dockerfile 的项目在构建之间将具有最小的差异,因此您可以从缓存层中受益,并且您在注册表中使用的存储量不应过多。

如果您有大型项目,开发变动很多,发布周期较短,存储需求可能会失控。您可以转向定期推送,每天为图像打上标签并将最新构建推送到注册表。或者,如果您有一个具有手动质量门的流水线,最终发布阶段可以推送到注册表,因此您存储的唯一图像是有效的发布候选者。

对于我的示例 CI 作业,一旦测试通过,我将在每次成功构建后将其推送到本地注册表,使用 Jenkins 构建编号作为图像标签。标记和推送图像的构建步骤是另一个使用 Jenkins 的BUILD_TAG环境变量进行标记的 PowerShell 脚本。

$images = 'ch10-nerd-dinner-db:2e', 'ch10-nerd-dinner-index-handler:2e',  'ch10-nerd-dinner-save-handler:2e', ...  foreach ($image  in  $images) {
   $sourceTag  =  "dockeronwindows/$image"
   $targetTag  =  "registry:5000/dockeronwindows/$image-$($env:BUILD_TAG)"

  docker image tag $sourceTag  $targetTag
  docker image push $targetTag }

这个脚本使用一个简单的循环来为所有构建的图像应用一个新的标签。新标签包括我的本地注册表域,registry:5000,并将 Jenkins 构建标签作为后缀,以便我可以轻松地识别图像来自哪个构建。然后,它将所有图像推送到本地注册表 - 再次强调,这是在与 Jenkins 容器相同的 Docker 网络中运行的容器中,因此可以通过容器名称registry访问。

我的注册表只配置为使用 HTTP,而不是 HTTPS,因此需要在 Docker Engine 配置中显式添加为不安全的注册表。我在第四章中介绍了这一点,与 Docker 注册表共享镜像。Jenkins 容器正在使用主机上的 Docker Engine,因此它使用相同的配置,并且可以将镜像推送到在另一个容器中运行的注册表。

在完成了几次构建之后,我可以从开发笔记本上对注册表 API 进行 REST 调用,查询dockeronwindows/nerd-dinner-index-handler存储库的标签。API 将为我提供我的消息处理程序应用程序镜像的所有标签列表,以便我可以验证它们是否已由 Jenkins 使用正确的标签推送:

> Invoke-RestMethod http://registry:5000/v2/dockeronwindows/ch10-nerd-dinner-index-handler/tags/list |
>> Select tags

tags
----
{2e-jenkins-docker-on-windows-ch10-nerd-dinner-20, 2e-jenkins-docker-on-windows-ch10-nerd-dinner-21,2e-jenkins-docker-on-windows-ch10-nerd-dinner-22}

Jenkins 构建标签为我提供了创建镜像的作业的完整路径。我也可以使用 Jenkins 提供的GIT_COMMIT环境变量来为镜像打标签,标签中包含提交 ID。这样标签会更短,但 Jenkins 构建标签包括递增的构建编号,因此我可以通过对标签进行排序来找到最新版本。Jenkins web UI 显示每个构建的 Git 提交 ID,因此很容易从作业编号追溯到确切的源代码修订版。

构建的 CI 部分现在已经完成。对于每次对 Git 服务器的新推送,Jenkins 将编译、部署和测试应用程序,然后将良好的镜像推送到本地注册表。接下来是将解决方案部署到公共环境。

使用 Jenkins 部署到远程 Docker Swarm

我的示例应用程序的工作流程使用手动质量门和分离本地和外部工件的关注点。在每次源代码推送时,解决方案会在本地部署并运行测试。如果测试通过,镜像将保存到本地注册表。最终部署阶段是将这些镜像推送到外部注册表,并将应用程序部署到公共环境。这模拟了一个项目方法,其中构建在内部进行,然后批准的发布被推送到外部。

在这个示例中,我将使用 Docker Hub 上的公共存储库,并部署到在 Azure 中运行的多节点 Docker Enterprise 集群。我将继续使用 PowerShell 脚本并运行基本的docker命令。原则上,将镜像推送到其他注册表(如 DTR)并部署到本地 Docker Swarm 集群的操作是完全相同的。

我为部署步骤创建了一个新的 Jenkins 作业,该作业被参数化为接受要部署的版本号。版本号是 CI 构建的作业编号,因此我可以随时部署已知版本。在新作业中,我需要一些额外的凭据。我已经添加了用于 Docker Swarm 管理器的 TLS 证书的秘密文件,这将允许我连接到在 Azure 中运行的 Docker Swarm 的管理节点。

作为发布步骤的一部分,我还将推送图像到 Docker Hub,因此我在 Jenkins 中添加了一个用户名和密码凭据,我可以使用它来登录到 Docker Hub。为了在作业步骤中进行身份验证,我在部署作业中添加了凭据的绑定,这将用户名和密码暴露为环境变量:

然后,我设置了命令配置,并在 PowerShell 构建步骤中使用docker login,指定了环境变量中的凭据:

docker login --username $env:DOCKER_HUB_USER --password "$env:DOCKER_HUB_PASSWORD"

注册表登录是使用 Docker CLI 执行的,但登录的上下文实际上存储在 Docker Engine 中。当我在 Jenkins 容器中运行此步骤时,运行该容器的主机使用 Jenkins 凭据登录到 Docker Hub。如果您遵循类似的流程,您需要确保作业在每次运行后注销,或者构建服务器运行的引擎是安全的,否则用户可能会访问该机器并以 Jenkins 帐户身份推送图像。

现在,对于构建的每个图像,我从本地注册表中拉取它们,为 Docker Hub 打标签,然后将它们推送到 Hub。初始拉取是为了以防我想部署以前的构建。自从构建以来,本地服务器缓存可能已被清除,因此这可以确保来自本地注册表的正确图像存在。对于 Docker Hub,我使用更简单的标记格式,只需应用版本号。

此脚本使用 PowerShell 循环来拉取和推送所有图像:

$images  =  'ch10-nerd-dinner-db:2e',  'ch10-nerd-dinner-index-handler:2e',  'ch10-nerd-dinner-save-handler:2e',  ...  foreach ($image  in  $images) { 
 $sourceTag  =  "registry:5000/dockeronwindows/$image...$($env:VERSION_NUMBER)"
  $targetTag  =  "dockeronwindows/$image-$($env:VERSION_NUMBER)"

 docker image pull $sourceTag docker image tag $sourceTag  $targetTag
 docker image push $targetTag }

当此步骤完成时,图像将在 Docker Hub 上公开可用。现在,部署作业中的最后一步是使用这些公共图像在远程 Docker Swarm 上运行最新的应用程序版本。我需要生成一个包含图像标记中最新版本号的 Compose 文件,并且我可以使用docker-compose config与覆盖文件来实现:

cd .\ch10\ch10-nerd-dinner\compose

docker-compose `
  -f .\docker-compose.yml `
  -f .\docker-compose.hybrid-swarm.yml `
  -f .\docker-compose.latest.yml `
  config > docker-stack.yml

docker-compose.latest.yml文件是添加的最后一个文件,并且使用VERSION_NUMBER环境变量,该变量由 Jenkins 填充以创建图像标签:

 services: nerd-dinner-db:
     image: dockeronwindows/ch10-nerd-dinner-db:2e-${VERSION_NUMBER}

   nerd-dinner-save-handler:
     image: dockeronwindows/ch10-nerd-dinner-save-handler:2e-${VERSION_NUMBER} ...

config命令不受影响,无法使用 Docker Compose 在使用命名管道的容器内部署容器的问题。docker-compose config只是连接和解析文件,它不与 Docker Engine 通信。

现在,我有一个 Docker Compose 文件,其中包含我混合使用最新版本的应用程序镜像从 Docker Hub 的 Linux 和 Windows Docker Swarm 的所有设置。最后一步使用docker stack deploy来实际在远程 swarm 上运行堆栈:

$config = '--host', 'tcp://dow2e-swarm.westeurope.cloudapp.azure.com:2376', '--tlsverify', `
 '--tlscacert', $env:DOCKER_CA,'--tlscert', $env:DOCKER_CERT, '--tlskey', $env:DOCKER_KEY

& docker $config `
  stack deploy -c docker-stack.yml nerd-dinner

这个最后的命令使用安全的 TCP 连接到远程 swarm 管理器上的 Docker API。$config对象设置了 Docker CLI 需要的所有参数,以便建立连接:

  • host是管理节点的公共完全限定域名

  • tlsverify指定这是一个安全连接,并且 CLI 应该提供客户端证书

  • tlscacert是 swarm 的证书颁发机构

  • tlscert是用户的客户端证书

  • tlskey是用户客户端证书的密钥

当作业运行时,所有证书都作为 Jenkins 秘密文件呈现。当 Docker CLI 需要时,这些文件在工作空间中可用;因此,这是一个无缝的安全连接。

当工作完成时,更新后的服务将已部署。Docker 会将堆栈定义与正在运行的服务进行比较,就像 Docker Compose 对容器进行比较一样,因此只有在定义发生更改时才会更新服务。部署工作完成后,我可以浏览到公共 DNS 条目(这是我的 Docker Swarm 集群的 CNAME),并查看应用程序:

我的工作流程使用了两个作业,因此我可以手动控制对远程环境的发布,这可能是一个 QA 站点,也可能是生产环境。这可以自动化为完整的 CD 设置,并且您可以轻松地在 Jenkins 作业上构建更多功能-显示测试输出和覆盖率,将构建加入管道,并将作业分解为可重用的部分。

总结

本章介绍了在 Jenkins 中配置的 Docker 中的 CI/CD,以及一个示例部署工作流程。我演示的过程的每个部分都在 Docker 容器中运行:Git 服务器、Jenkins 本身、构建代理、测试代理和本地注册表。

你看到了使用 Docker 运行自己的开发基础设施是很简单的,这为你提供了一个托管服务的替代方案。对于你自己的部署工作流程来说,使用这些服务也是很简单的,无论是完整的 CI/CD 还是带有门控手动步骤的单独工作流程。

你看到了如何在 Docker 中配置和运行 Gogs Git 服务器和 Jenkins 自动化服务器来支持工作流程。我在 NerdDinner 代码的最新版本中为所有镜像使用了多阶段构建,这意味着我可以拥有一个非常简单的 Jenkins 设置,而无需部署任何工具链或 SDK。

我的 CI 流水线是由开发人员推送 Git 更改触发的。构建作业拉取源代码,编译应用程序组件,将它们构建成 Docker 镜像,并在 Docker 中运行应用程序的本地部署。然后在另一个容器中运行端到端测试,如果测试通过,就会给所有镜像打标签并推送到本地注册表。

我演示了一个用户启动的作业,指定要部署的构建版本的手动部署步骤。这个作业将构建的镜像推送到公共 Docker Hub,并通过在 Azure 上运行的 Docker Swarm 上部署堆栈来更新公共环境。

在本章中,我使用的技术没有任何硬性依赖。我用 Gogs、Jenkins 和开源注册表实现的流程可以很容易地使用托管服务(如 GitHub、AppVeyor 和 Docker Hub)来实现。这个流程的所有步骤都使用简单的 PowerShell 脚本,并且可以在支持 Docker 的任何堆栈上运行。

在下一章中,我将回到开发人员的体验,看看在容器中运行、调试和故障排除应用程序的实际操作。

第四部分:开始您的容器之旅

开始使用 Docker 很容易。到第四部分结束时,读者将知道如何将现有应用程序迁移到 Docker,如何在 Visual Studio 中开始使用它们,以及如何添加仪器,使它们准备好投入生产。

本节包括以下最后两章:

  • 第十一章,调试和仪器化应用容器

  • 第十二章,将你所知的内容容器化-实施 Docker 的指导

第十一章:调试和为应用程序容器添加仪器

Docker 可以消除典型开发人员工作流程中的许多摩擦,并显著减少在诸如依赖管理和环境配置等开销任务上花费的时间。当开发人员使用与最终产品相同的应用程序平台运行他们正在处理的更改时,部署错误的机会就会大大减少,升级路径也是直接且易于理解的。

在开发过程中在容器中运行应用程序会为您的开发环境增加另一层。您将使用不同类型的资产,如 Dockerfiles 和 Docker Compose 文件,如果您的集成开发环境支持这些类型,那么这种体验会得到改善。此外,在 IDE 和应用程序之间有一个新的运行时,因此调试体验会有所不同。您可能需要改变您的工作流程以充分利用平台的优势。

在本章中,我将介绍使用 Docker 的开发过程,涵盖 IDE 集成和调试,以及如何为您的 Docker 化应用程序添加仪器。您将了解:

  • 在集成开发环境中使用 Docker

  • Docker 化应用程序中的仪器

  • Docker 中的故障修复工作流程

技术要求

您需要在 Windows 10 更新 18.09 或 Windows Server 2019 上运行 Docker,以便跟随示例。本章的代码可在github.com/sixeyed/docker-on-windows/tree/second-edition/ch11上找到。

在集成开发环境中使用 Docker

在上一章中,我演示了一个容器化的“外部循环”,即编译和打包的 CI 过程,当开发人员推送更改时,它会从中央源代码控制中触发。集成开发环境(IDE)开始支持容器化工作流程的“内部循环”,这是开发人员在将更改推送到中央源代码控制之前编写、运行和调试应用程序的过程。

Visual Studio 2017 原生支持 Docker 工件,包括 Dockerfile 的智能感知和代码完成。ASP.NET 项目在容器中运行时也有运行时支持,包括.NET Framework 和.NET Core。在 Visual Studio 2017 中,您可以按下F5键,您的 Web 应用程序将在 Windows 上的 Docker 桌面中运行的容器中启动。应用程序使用与您在所有其他环境中使用的相同的基本映像和 Docker 运行时。

Visual Studio 2015 有一个插件,提供对 Docker 工件的支持,Visual Studio Code 有一个非常有用的 Docker 扩展。Visual Studio 2015 和 Visual Studio Code 不提供在 Windows 容器中运行.NET 应用程序的集成F5调试体验,但您可以手动配置,我将在本章中演示。

在容器内调试时存在一个折衷之处-这意味着在内部循环和外部循环之间创建了一个断开。您的开发过程使用与 CI 过程不同的一组 Docker 工件,以使调试器可用于容器,并将应用程序程序集映射到源代码。好处是您可以在开发中以相同的开发人员构建和调试体验在容器中运行。缺点是您的开发 Docker 映像与您将推广到测试的映像不完全相同。

缓解这种情况的一个好方法是在快速迭代功能时,使用本地 Docker 工件进行开发。然后,在推送更改之前,您可以使用 CI Docker 工件进行最终构建和端到端测试。

在 Visual Studio 2017 中的 Docker

Visual Studio 2017 是所有.NET IDE 中对 Docker 支持最完整的。您可以在 Visual Studio 2017 中打开一个 ASP.NET Framework Web API 项目,右键单击该项目,然后选择添加|容器编排器支持:

只有一个编排器选项可供选择,即 Docker Compose。然后,Visual Studio 会生成一组 Docker 工件。在Web项目中,它创建一个看起来像这样的 Dockerfile:

FROM microsoft/aspnet:4.7.2-windowsservercore-1803
ARG source
WORKDIR /inetpub/wwwroot
COPY ${source:-obj/Docker/publish} .

Dockerfile 语法有完整的智能感知支持,因此您可以将鼠标悬停在指令上并查看有关它们的信息,并使用Ctrl +空格键打开所有 Dockerfile 指令的提示。

生成的 Dockerfile 使用microsoft/aspnet基础镜像,其中包含已完全安装和配置的 ASP.NET 4.7.2。在撰写本文时,Dockerfile 使用了旧版本的 Windows 基础镜像,因此您需要手动更新为使用最新的 Windows Server 2019 基础镜像,即mcr.microsoft.com/dotnet/framework/aspnet:4.7.2-windowsservercore-ltsc2019

Dockerfile 看起来很奇怪,因为它使用构建参数来指定源文件夹的位置,然后将该文件夹的内容复制到容器镜像内的 web 根目录C:\inetpub\wwwroot

在解决方案根目录中,Visual Studio 创建了一组 Docker Compose 文件。有多个文件,Visual Studio 会使用它们与 Docker Compose 的buildup命令来打包和运行应用程序。当您按下F5键运行应用程序时,这些文件在后台运行,但值得看看 Visual Studio 如何使用它们;它向您展示了如何将此级别的支持添加到不同的 IDE 中。

在 Visual Studio 2017 中使用 Docker Compose 进行调试

生成的 Docker Compose 文件显示在顶级解决方案对象下:

有一个基本的docker-compose.yml文件,其中将 Web 应用程序定义为一个服务,并包含 Dockerfile 的构建细节:

version: '3.4'

services:
  webapi.netfx:
    image: ${DOCKER_REGISTRY-}webapinetfx
    build:
      context: .\WebApi.NetFx
      dockerfile: Dockerfile

还有一个docker-compose.override.yml文件,它添加了端口和网络配置,以便可以在本地运行:

version: '3.4'

services:
  webapi.netfx:
    ports:
      - "80"
networks:
  default:
    external:
      name: nat

这里没有关于构建应用程序的内容,因为编译是在 Visual Studio 中完成而不是在 Docker 中。构建的应用程序二进制文件存储在您的开发计算机上,并复制到容器中。当您按下F5时,容器会启动,Visual Studio 会在容器的 IP 地址上启动浏览器。您可以在 Visual Studio 中的代码中添加断点,当您从浏览器导航到该代码时,将会进入 Visual Studio 中的调试器:

这是一个无缝的体验,但不清楚发生了什么——Visual Studio 调试器在您的计算机上如何连接到容器内的二进制文件?幸运的是,Visual Studio 会将所有发出的 Docker 命令记录到输出窗口,因此您可以追踪它是如何工作的。

在构建输出窗口中,您会看到类似以下的内容:

1>------ Build started: Project: WebApi.NetFx, Configuration: Debug Any CPU ------
1>  WebApi.NetFx -> C:\Users\Administrator\source\repos\WebApi.NetFx\WebApi.NetFx\bin\WebApi.NetFx.dll
2>------ Build started: Project: docker-compose, Configuration: Debug Any CPU ------
2>docker-compose  -f "C:\Users\Administrator\source\repos\WebApi.NetFx\docker-compose.yml" -f "C:\Users\Administrator\source\repos\WebApi.NetFx\docker-compose.override.yml" -f "C:\Users\Administrator\source\repos\WebApi.NetFx\obj\Docker\docker-compose.vs.debug.g.yml" -p dockercompose1902887664513455984 --no-ansi up -d
2>dockercompose1902887664513455984_webapi.netfx_1 is up-to-date
========== Build: 2 succeeded, 0 failed, 0 up-to-date, 0 skipped ==========

您可以看到首先进行构建,然后使用docker-compose up启动容器。我们已经看到的docker-compose.ymldocker-compose.override.yml文件与一个名为docker-compose.vs.debug.g.yml的文件一起使用。Visual Studio 在构建时生成该文件,您需要显示解决方案中的所有文件才能看到它。它包含额外的 Docker Compose 设置:

services:
  webapi.netfx:
    image: webapinetfx:dev
    build:
      args:
        source: obj/Docker/empty/
    volumes:
      - C:\Users\Administrator\source\repos\WebApi.NetFx\WebApi.NetFx:C:\inetpub\wwwroot
      - C:\Program Files (x86)\Microsoft Visual Studio\2017\Professional\Common7\IDE\Remote Debugger:C:\remote_debugger:ro
    entrypoint: cmd /c "start /B C:\\ServiceMonitor.exe w3svc & C:\\remote_debugger\\x64\\msvsmon.exe /noauth /anyuser /silent /nostatus /noclrwarn /nosecuritywarn /nofirewallwarn /nowowwarn /timeout:2147483646"

这里发生了很多事情:

  • Docker 镜像使用dev标签来区分它与发布版本的构建

  • 源位置的构建参数指定一个空目录

  • 一个卷用于从主机上的项目文件夹中挂载容器中的 Web 根目录

  • 第二个卷用于从主机中挂载 Visual Studio 远程调试器到容器中

  • 入口点启动ServiceMonitor来运行 IIS,然后启动msvsmon,这是远程调试器

在调试模式下,源代码环境变量的参数是一个空目录。Visual Studio 使用一个空的wwwroot目录构建 Docker 镜像,然后将主机中的源代码文件夹挂载到容器中的 Web 根目录,以在运行时填充该文件夹。

当容器运行时,Visual Studio 会在容器内运行一些命令来设置权限,从而使远程调试工具能够工作。在 Docker 的输出窗口中,您会看到类似以下的内容:

========== Debugging ==========
docker ps --filter "status=running" --filter "name=dockercompose1902887664513455984_webapi.netfx_" --format {{.ID}} -n 1
3e2b6a7cb890
docker inspect --format="{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}" 3e2b6a7cb890
172.27.58.105 
docker exec 3e2b6a7cb890 cmd /c "C:\Windows\System32\inetsrv\appcmd.exe set config -section:system.applicationHost/applicationPools /[name='DefaultAppPool'].processModel.identityType:LocalSystem /commit:apphost & C:\Windows\System32\inetsrv\appcmd.exe set config -section:system.webServer/security/authentication/anonymousAuthentication /userName: /commit:apphost"
Applied configuration changes to section "system.applicationHost/applicationPools" for "MACHINE/WEBROOT/APPHOST" at configuration commit path "MACHINE/WEBROOT/APPHOST"
Applied configuration changes to section "system.webServer/security/authentication/anonymousAuthentication" for "MACHINE/WEBROOT/APPHOST" at configuration commit path "MACHINE/WEBROOT/APPHOST"
Launching http://172.27.58.105/ ...

这是 Visual Studio 获取使用 Docker Compose 启动的容器的 ID,然后运行appcmd来设置 IIS 应用程序池以使用管理员帐户,并设置 Web 服务器以允许匿名身份验证。

当您停止调试时,Visual Studio 2017 会使容器在后台运行。如果对程序进行更改并重新构建,则仍然使用同一个容器,因此没有启动延迟。通过将项目位置挂载到容器中,重新构建时会反映出内容或二进制文件的任何更改。通过从主机挂载远程调试器,您的镜像不会包含任何开发工具;它们保留在主机上。

这是内部循环过程,您可以获得快速反馈。每当您更改并重新构建应用程序时,您都会在容器中看到这些更改。但是,调试模式下的 Docker 镜像对于外部循环 CI 过程是不可用的;应用程序不会被复制到镜像中;只有在将应用程序从本地源挂载到容器中时才能工作。

为了支持外部循环,还有一个用于发布模式的 Docker Compose 覆盖文件,以及第二个隐藏的覆盖文件,docker-compose.vs.release.g.yml

services:
  webapi.netfx:
    build:
      args:
        source: obj/Docker/publish/
    volumes:
      - C:\Program Files (x86)\Microsoft Visual Studio\2017\Professional\Common7\IDE\Remote Debugger:C:\remote_debugger:ro
    entrypoint: cmd /c "start /B C:\\ServiceMonitor.exe w3svc & C:\\remote_debugger\\x64\\msvsmon.exe /noauth /anyuser /silent /nostatus /noclrwarn /nosecuritywarn /nofirewallwarn /nowowwarn /timeout:2147483646"
    labels:
      com.microsoft.visualstudio.debuggee.program: "C:\\app\\WebApi.NetFx.dll"
      com.microsoft.visualstudio.debuggee.workingdirectory: "C:\\app"

这里的区别在于没有将本地源位置映射到容器中的 Web 根目录。在发布模式下编译时,源参数的值是包含 Web 应用程序的发布位置。Visual Studio 通过将发布的应用程序打包到容器中来构建发布映像。

在发布模式下,您仍然可以在 Docker 容器中运行应用程序,并且仍然可以调试应用程序。但是,您会失去快速反馈循环,因为要更改应用程序,Visual Studio 需要重新构建 Docker 映像并启动新的容器。

这是一个公平的妥协,而 Visual Studio 2017 中的 Docker 工具为您提供了无缝的开发体验,以及 CI 构建的基础。Visual Studio 2017 没有使用多阶段构建,因此项目编译仍然发生在主机而不是容器内。这使得生成的 Docker 工件不够便携,因此您需要不仅仅是 Docker 来在服务器上构建此应用程序。

Visual Studio 2015 中的 Docker

Visual Studio 2015 在市场上有一个名为Visual Studio Tools for Docker的插件。这为 Dockerfile 提供了语法高亮显示,但它并没有将 Visual Studio 与.NET Framework 应用程序的 Docker 集成。在 Visual Studio 2015 中,您可以为.NET Core 项目添加 Docker 支持,但是您需要手动编写自己的 Dockerfile 和 Docker Compose 文件以支持完整的.NET 应用程序。

此外,没有集成的调试功能用于在 Windows 容器中运行的应用程序。您仍然可以调试在容器中运行的代码,但是您需要手动配置设置。我将演示如何使用与 Visual Studio 2017 相同的方法以及一些相同的妥协来做到这一点。

在 Visual Studio 2017 中,您可以将包含远程调试器的文件夹从主机挂载到容器中。当您运行项目时,Visual Studio 会启动一个容器,并从主机执行msvsmon.exe,这是远程调试器代理。您不需要在图像中安装任何内容来提供调试体验。

Visual Studio 2015 中的远程调试器并不是很便携。你可以从主机中将调试器挂载到容器中,但当你尝试启动代理时,你会看到有关缺少文件的错误。相反,你需要将远程调试器安装到你的镜像中。

我在一个名为ch11-webapi-vs2015的文件夹中设置了这个。在这个镜像的 Dockerfile 中,我使用了一个构建时参数来有条件地安装调试器,如果configuration的值设置为debug。这意味着我可以在本地构建时安装调试器,但当我为部署构建时,镜像就不会有调试器了:

ARG configuration

 RUN if ($env:configuration -eq 'debug') `
 { Invoke-WebRequest -OutFile c:\rtools_setup_x64.exe -UseBasicParsing -Uri http://download.microsoft.com/download/1/2/2/1225c23d-3599-48c9-a314-f7d631f43241/rtools_setup_x64.exe; `
 Start-Process c:\rtools_setup_x64.exe -ArgumentList '/install', '/quiet' -NoNewWindow -Wait }

当以调试模式运行时,我使用与 Visual Studio 2017 相同的方法将主机上的源目录挂载到容器中,但我创建了一个自定义网站,而不是使用默认的网站:

ARG source
WORKDIR C:\web-app
RUN Remove-Website -Name 'Default Web Site';`
New-Website -Name 'web-app' -Port 80 -PhysicalPath 'C:\web-app'
COPY ${source:-.\Docker\publish} .

COPY指令中的:-语法指定了一个默认值,如果未提供source参数。默认值是从发布的 web 应用程序复制,除非在build命令中指定了它。我有一个核心的docker-compose.yml文件,其中包含基本的服务定义,还有一个docker-compose.debug.yml文件,它挂载主机源位置,映射调试器端口,并指定configuration变量。

services:
  ch11-webapi-vs2015:
    build:
      context: ..\
      dockerfile: .\Docker\Dockerfile
    args:
      - source=.\Docker\empty
      - configuration=debug
  ports:
    - "3702/udp"
    - "4020"
    - "4021"
  environment:
    - configuration=debug
  labels:
    - "com.microsoft.visualstudio.targetoperatingsystem=windows"
  volumes:
    - ..\WebApi.NetFx:C:\web-app

在 compose 文件中指定的标签将一个键值对附加到容器。该值在容器内部不可见,不像环境变量,但对主机上的外部进程可见。在这种情况下,它被 Visual Studio 用来识别容器的操作系统。

要以调试模式启动应用程序,我使用两个 Compose 文件来启动应用程序:

docker-compose -f docker-compose.yml -f docker-compose.debug.yml up -d

现在,容器正在使用Internet Information Services (IIS)在容器内部运行我的 web 应用程序,并且 Visual Studio 远程调试器代理也在运行。我可以连接到 Visual Studio 2015 中的远程进程,并使用容器的 IP 地址:

Visual Studio 中的调试器连接到容器中运行的代理,并且我可以添加断点和查看变量,就像调试本地进程一样。在这种方法中,容器使用主机挂载来获取 web 应用的内容。我可以停止调试器,进行更改,重新构建应用程序,并在同一个容器中看到更改,而无需启动新的容器。

这种方法与 Visual Studio 2017 中集成的 Docker 支持具有相同的优缺点。我正在容器中运行我的应用程序进行本地调试,因此我可以获得 Visual Studio 调试器的所有功能,并且我的应用程序在其他环境中使用的平台上运行。但我不会使用相同的映像,因为 Dockerfile 具有条件分支,因此它会为调试和发布模式生成不同的输出。

在 Docker 构件中手动构建调试器支持有一个优势。您可以构建具有条件的 Dockerfile,以便默认的docker image build命令生成无需任何额外构件即可用于生产的图像。但是,这个例子仍然没有使用多阶段构建,因此 Dockerfile 不具备可移植性,应用程序在打包之前需要进行编译。

在开发中,您可以以调试模式构建图像一次,运行容器,然后在需要时附加调试器。您的集成测试构建并运行生产图像,因此只有内部循环具有额外的调试器组件。

Visual Studio Code 中的 Docker

Visual Studio Code 是一个新的跨平台 IDE,用于跨平台开发。C#扩展安装了一个可以附加到.NET Core 应用程序的调试器,但不支持调试完整的.NET Framework 应用程序。

Docker 扩展添加了一些非常有用的功能,包括将 Dockerfiles 和 Docker Compose 文件添加到已知平台的现有项目中,例如 Go 和.NET Core。您可以将 Dockerfile 添加到.NET Core 项目中,并选择在 Windows 或 Linux 容器之间进行选择作为基础-点击* F1 *,键入docker,然后选择将 Docker 文件添加到工作区:

以下是.NET Core Web API 项目的生成的 Dockerfile:

FROM microsoft/dotnet:2.2-aspnetcore-runtime-nanoserver-1803 AS base WORKDIR /app EXPOSE 80 FROM microsoft/dotnet:2.2-sdk-nanoserver-1803 AS build WORKDIR /src COPY ["WebApi.NetCore.csproj", "./"] RUN dotnet restore "./WebApi.NetCore.csproj" COPY . . WORKDIR  "/src/." RUN dotnet build "WebApi.NetCore.csproj" -c Release -o /app FROM build AS publish RUN dotnet publish "WebApi.NetCore.csproj" -c Release -o /app  FROM base AS final WORKDIR /app COPY --from=publish /app .
ENTRYPOINT ["dotnet", "WebApi.NetCore.dll"]

这是使用旧版本的.NET Core 基础映像,因此第一步是将FROM行中的nanoserver-1803标签替换为nanoserver-1809。该扩展程序生成了一个多阶段的 Dockerfile,使用 SDK 映像进行构建和发布阶段,以及 ASP.NET Core 运行时用于最终映像。VS Code 在 Dockerfile 中生成了比实际需要更多的阶段,但这是一个设计选择。

VS Code 还会生成一个.dockerignore文件。这是一个有用的功能,可以加快 Docker 镜像的构建速度。在忽略文件中,您列出任何在 Dockerfile 中未使用的文件或目录路径,并且这些文件将被排除在构建上下文之外。排除所有binobjpackages文件夹意味着当您构建图像时,Docker CLI 向 Docker Engine 发送的有效负载要小得多,这可以加快构建速度。

您可以使用 F1 | docker tasks 来构建图像并运行容器,但是没有功能以生成 Docker Compose 文件的方式,就像 Visual Studio 2017 那样。

Visual Studio Code 具有非常灵活的系统,可以运行和调试您的项目,因此您可以添加自己的配置,为在 Windows 容器中运行的应用程序提供调试支持。您可以编辑launch.json文件,以添加新的配置以在 Docker 中进行调试。

ch11-webapi-vscode文件夹中,我有一个示例.NET Core 项目,可以在 Docker 中运行该应用程序并附加调试器。它使用与 Visual Studio 2017 相同的方法。.NET Core 的调试器称为vsdbg,并且与 Visual Studio Code 中的 C#扩展一起安装,因此我使用docker-compose.debug.yml文件将vsdbg文件夹从主机挂载到容器中,以及使用源位置:

volumes:
 - .\bin\Debug\netcoreapp2.2:C:\app
 - ~\.vscode\extensions\ms-vscode.csharp-1.17.1\.debugger:C:\vsdbg:ro

此设置使用特定版本的 C#扩展。在我的情况下是 1.17.1,但您可能有不同的版本。检查您的用户目录中.vscode文件夹中vsdbg.exe的位置。

当您通过使用调试覆盖文件在 Docker Compose 中运行应用程序时,它会启动.NET Core 应用程序,并使来自主机的调试器可用于在容器中运行。这是在 Visual Studio Code 的launch.json文件中配置的调试体验。Debug Docker container配置指定要调试的应用程序类型和要附加的进程的名称:

  "name": "Debug Docker container",
  "type": "coreclr",
  "request": "attach",
  "sourceFileMap": {
    "C:\\app": "${workspaceRoot}"
 }, "processName": "dotnet"

此配置还将容器中的应用程序根映射到主机上的源代码位置,因此调试器可以将正确的源文件与调试文件关联起来。此外,调试器配置指定了如何通过在命名容器上运行docker container exec命令来启动调试器:

"pipeTransport": {
  "pipeCwd": "${workspaceRoot}",
  "pipeProgram": "docker",
  "pipeArgs": [
   "exec", "-i", "webapinetcore_webapi_1"
 ],  "debuggerPath": "C:\\vsdbg\\vsdbg.exe",
  "quoteArgs": false }

要调试我的应用程序,我需要使用 Docker Compose 和覆盖文件在调试配置中构建和运行它:

docker-compose -f .\docker-compose.yml -f .\docker-compose.debug.yml build docker-compose -f .\docker-compose.yml -f .\docker-compose.debug.yml up -d 

然后,我可以使用调试操作并选择调试 Docker 容器来激活调试器:

Visual Studio Code 在容器内启动.NET Core 调试器vsdbg,并将其附加到正在运行的dotnet进程。您将看到.NET Core 应用程序的输出被重定向到 Visual Studio Code 中的 DEBUG CONSOLE 窗口中:

在撰写本文时,Visual Studio Code 尚未完全与在 Windows Docker 容器内运行的调试器集成。您可以在代码中设置断点,调试器将暂停进程,但控制权不会传递到 Visual Studio Code。这是在 Nano Server 容器中运行 Omnisharp 调试器的已知问题-在 GitHub 上进行跟踪:github.com/OmniSharp/omnisharp-vscode/issues/1001

在容器中运行应用程序并能够从您的常规 IDE 进行调试是一个巨大的好处。这意味着您的应用程序在相同的平台上运行,并且具有与在所有其他环境中使用的相同部署配置,但您可以像在本地运行一样进入代码。

IDE 中的 Docker 支持正在迅速改善,因此本章中详细介绍的所有手动步骤将很快内置到产品和扩展中。JetBrains Rider 是一个很好的例子,它是一个与 Docker 很好配合的第三方.NET IDE。它与 Docker API 集成,并可以将自己的调试器附加到正在运行的容器中。

Docker 化应用程序中的仪器

调试应用程序是在逻辑不按预期工作时所做的事情,您正在尝试跟踪出现问题的原因。您不会在生产环境中进行调试,因此您需要您的应用程序记录其行为,以帮助您跟踪发生的任何问题。

仪器经常被忽视,但它应该是您开发的一个关键组成部分。这是了解应用程序在生产环境中的健康状况和活动的最佳方式。在 Docker 中运行应用程序为集中日志记录和仪器提供了新的机会,这样您可以获得对应用程序不同部分的一致视图,即使它们使用不同的语言和平台。

向您的容器添加仪表化可以是一个简单的过程。Windows Server Core 容器已经在 Windows 性能计数器中收集了大量的指标。使用.NET 或 IIS 构建的 Docker 镜像也将具有来自这些堆栈的所有额外性能计数器。您可以通过将性能计数器值暴露给指标服务器来为容器添加仪表化。

使用 Prometheus 进行仪表化

围绕 Docker 的生态系统非常庞大和活跃,充分利用了平台的开放标准和可扩展性。随着生态系统的成熟,一些技术已经成为几乎所有 Docker 化应用程序中强有力的候选项。

Prometheus 是一个开源的监控解决方案。它是一个灵活的组件,您可以以不同的方式使用,但典型的实现方式是在 Docker 容器中运行一个 Prometheus 服务器,并配置其读取您在其他 Docker 容器中提供的仪表化端点。

您可以配置 Prometheus 来轮询所有容器端点,并将结果存储在时间序列数据库中。您可以通过简单地添加一个 REST API 来向您的应用程序添加一个 Prometheus 端点,该 API 会响应来自 Prometheus 服务器的GET请求,并返回您感兴趣的指标列表。

对于.NET Framework 和.NET Core 项目,有一个 NuGet 包可以为您完成这项工作,即向您的应用程序添加一个 Prometheus 端点。它默认公开了一组有用的指标,包括关键的.NET 统计数据和 Windows 性能计数器的值。您可以直接向您的应用程序添加 Prometheus 支持,或者您可以在应用程序旁边运行一个 Prometheus 导出器。

您采取的方法将取决于您想要为其添加仪表化的应用程序类型。如果是要将传统的.NET Framework 应用程序移植到 Docker 中,您可以通过在 Docker 镜像中打包一个 Prometheus 导出器来添加基本的仪表化,这样就可以在不需要更改代码的情况下获得有关应用程序的指标。对于新应用程序,您可以编写代码将特定的应用程序指标暴露给 Prometheus。

将.NET 应用程序指标暴露给 Prometheus

prometheus-net NuGet 包提供了一组默认的指标收集器和一个MetricServer类,该类提供了 Prometheus 连接的仪表端点。该包非常适合为任何应用程序添加 Prometheus 支持。这些指标由自托管的 HTTP 端点提供,您可以为您的应用程序提供自定义指标。

dockeronwindows/ch11-api-with-metrics镜像中,我已经将 Prometheus 支持添加到了一个 Web API 项目中。配置和启动指标端点的代码在PrometheusServer类中。

public  static  void  Start() { _Server  =  new  MetricServer(50505);
  _Server.Start(); }

这将启动一个新的MetricServer实例,监听端口50505,并运行NuGet包提供的默认一组.NET 统计和性能计数器收集器。这些是按需收集器,这意味着它们在 Prometheus 服务器调用端点时提供指标。

MetricServer类还将返回您在应用程序中设置的任何自定义指标。Prometheus 支持不同类型的指标。最简单的是计数器,它只是一个递增的计数器—Prometheus 查询您的应用程序的指标值,应用程序返回每个计数器的单个数字。在ValuesController类中,我设置了一些计数器来记录对 API 的请求和响应:

private  Counter  _requestCounter  =  Metrics.CreateCounter("ValuesController_Requests", "Request count", "method", "url"); private  Counter  _responseCounter  =  Metrics.CreateCounter("ValuesController_Responses", "Response count", "code", "url");

当请求进入控制器时,控制器动作方法通过在计数器对象上调用Inc()方法来增加 URL 的请求计数,并增加响应代码的状态计数:

public IHttpActionResult Get()
{
  _requestCounter.Labels("GET", "/").Inc();
  _responseCounter.Labels("200", "/").Inc();
  return Ok(new string[] { "value1", "value2" });
}

Prometheus 还有各种其他类型的指标,您可以使用它们来记录有关应用程序的关键信息—计数器只增加,但是仪表可以增加和减少,因此它们对于记录快照非常有用。Prometheus 记录每个指标值及其时间戳和您提供的一组任意标签。在这种情况下,我将添加URLHTTP方法到请求计数,以及 URL 和状态代码到响应计数。我可以使用这些在 Prometheus 中聚合或过滤指标。

我在 Web API 控制器中设置的计数器为我提供了一组自定义指标,显示了哪些端点正在使用以及响应的状态。这些由服务器组件在NuGet包中公开,以及用于记录系统性能的默认指标。在此应用的 Dockerfile 中,还有两行额外的代码用于 Prometheus 端点:

EXPOSE 50505
RUN netsh http add urlacl url=http://+:50505/metrics user=BUILTIN\IIS_IUSRS; `
    net localgroup 'Performance Monitor Users' 'IIS APPPOOL\DefaultAppPool' /add

第一行只是暴露了我用于度量端点的自定义端口。第二行设置了该端点所需的权限。在这种情况下,度量端点托管在 ASP.NET 应用程序内部,因此 IIS 用户帐户需要权限来监听自定义端口并访问系统性能计数器。

您可以按照通常的方式构建 Dockerfile 并从镜像运行容器,即通过使用 -P 发布所有端口:

docker container run -d -P --name api dockeronwindows/ch11-api-with-metrics:2e

为了检查度量是否被记录和暴露,我可以运行一些 PowerShell 命令来抓取容器的端口,然后对 API 端点进行一些调用并检查度量:

$apiPort = $(docker container port api 80).Split(':')[1]
for ($i=0; $i -lt 10; $i++) {
 iwr -useb "http://localhost:$apiPort/api/values"
}

$metricsPort = $(docker container port api 50505).Split(':')[1]
(iwr -useb "http://localhost:$metricsPort/metrics").Content

您将看到按名称和标签分组的度量的纯文本列表。每个度量还包含 Prometheus 的元数据,包括度量名称、类型和友好描述:

# HELP process_num_threads Total number of threads
# TYPE process_num_threads gauge
process_num_threads 27
# HELP dotnet_total_memory_bytes Total known allocated memory
# TYPE dotnet_total_memory_bytes gauge
dotnet_total_memory_bytes 8519592
# HELP process_virtual_memory_bytes Virtual memory size in bytes.
# TYPE process_virtual_memory_bytes gauge
process_virtual_memory_bytes 2212962820096
# HELP process_cpu_seconds_total Total user and system CPU time spent in seconds.
# TYPE process_cpu_seconds_total counter
process_cpu_seconds_total 1.734375
...
# HELP ValuesController_Requests Request count
# TYPE ValuesController_Requests counter
ValuesController_Requests{method="GET",url="/"} 10
# HELP ValuesController_Responses Response count
# TYPE ValuesController_Responses counter
ValuesController_Responses{code="200",url="/"} 10

完整的输出要大得多。在这个片段中,我展示了线程总数、分配的内存和 CPU 使用率,这些都来自容器内部的标准 Windows 和 .NET 性能计数器。我还展示了自定义的 HTTP 请求和响应计数器。

此应用程序中的自定义计数器显示了 URL 和响应代码。在这种情况下,我可以看到对值控制器的根 URL 的 10 个请求,以及带有 OK 状态码 200 的十个响应。在本章后面,我将向您展示如何使用 Grafana 可视化这些统计信息。

NuGet 包添加到项目并运行 MetricServer 是源代码的简单扩展。它让我记录任何有用的度量,但这意味着改变应用程序,因此只适用于正在积极开发的应用程序。

在某些情况下,您可能希望添加监视而不更改要检测的应用程序。在这种情况下,您可以在应用程序旁边运行一个导出器。导出器从应用程序进程中提取度量并将其暴露给 Prometheus。在 Windows 容器中,您可以从标准性能计数器中获取大量有用的信息。

在现有应用程序旁边添加 Prometheus 导出器

在 Docker 化解决方案中,Prometheus 将定期调用从容器中暴露的度量端点,并存储结果。对于现有应用程序,您无需添加度量端点 - 您可以在当前应用程序旁边运行一个控制台应用程序,并在该控制台应用程序中托管度量端点。

我在第十章中为 NerdDinner Web 应用程序添加了一个 Prometheus 端点,使用 Docker 支持持续部署流水线,而没有更改任何代码。在dockeronwindows/ch11-nerd-dinner-web-with-metrics镜像中,我添加了一个导出 ASP.NET 性能计数器并提供指标端点的控制台应用程序。ASP.NET 导出程序应用程序来自 Docker Hub 上的公共镜像。NerdDinner 的完整 Dockerfile 复制了导出程序的二进制文件,并为容器设置了启动命令:

#escape=` FROM dockeronwindows/ch10-nerd-dinner-web:2e EXPOSE 50505 ENV COLLECTOR_CONFIG_PATH="w3svc-collectors.json"  WORKDIR C:\aspnet-exporter COPY --from=dockersamples/aspnet-monitoring-exporter:4.7.2-windowsservercore-ltsc2019 C:\aspnet-exporter . ENTRYPOINT ["powershell"] CMD Start-Service W3SVC; ` Invoke-WebRequest http://localhost -UseBasicParsing | Out-Null; `
 Start-Process -NoNewWindow C:\aspnet-exporter\aspnet-exporter.exe; ` netsh http flush logbuffer | Out-Null; `  Get-Content -path 'C:\iislog\W3SVC\u_extend1.log' -Tail 1 -Wait 

aspnet-exporter.exe控制台应用程序实现了一个自定义的指标收集器,它读取系统上运行的命名进程的性能计数器值。它使用与 NuGet 包中默认收集器相同的一组计数器,但它针对不同的进程。导出程序读取 IIS w3wp.exe进程的性能计数器,并配置为导出关键的 IIS 指标。

导出程序的源代码都在 GitHub 的dockersamples/aspnet-monitoring存储库中。

控制台导出程序是一个轻量级组件。它在容器启动时启动,并在容器运行时保持运行。只有在调用指标端点时才使用计算资源,因此在 Prometheus 计划运行时影响最小。我按照通常的方式运行 NerdDinner(这里,我只运行 ASP.NET 组件,而不是完整的解决方案):

docker container run -d -P --name nerd-dinner dockeronwindows/ch11-nerd-dinner-web-with-metrics:2e

我可以按照通常的方式获取容器端口并浏览 NerdDinner。然后,我还可以浏览导出程序端口上的指标端点,该端点发布 IIS 性能计数器:

在这种情况下,没有来自应用程序的自定义计数器,所有指标都来自标准的 Windows 和.NET 性能计数器。导出程序应用程序可以读取运行的w3wp进程的这些性能计数器值,因此应用程序无需更改即可向 Prometheus 提供基本信息。

这些是运行时指标,告诉您 IIS 在容器内的工作情况。您可以看到活动线程的数量,内存使用情况以及 IIS 文件缓存的大小。还有关于 IIS 响应的 HTTP 状态代码百分比的指标,因此您可以看到是否有大量的 404 或 500 错误。

要记录自定义应用程序度量,您需要为您的代码添加仪器,并明确记录您感兴趣的数据点。您需要为此付出努力,但结果是一个已经仪器化的应用程序,在其中您可以看到关键性能度量,除了.NET 运行时度量。

为 Docker 化的应用程序添加仪器意味着为 Prometheus 提供度量端点以进行查询。Prometheus 服务器本身在 Docker 容器中运行,并且您可以配置它以监视您想要监视的服务。

在 Windows Docker 容器中运行 Prometheus 服务器

Prometheus 是一个用 Go 编写的跨平台应用程序,因此它可以在 Windows 容器或 Linux 容器中运行。与其他开源项目一样,团队在 Docker Hub 上发布了一个 Linux 镜像,但你需要构建自己的 Windows 镜像。我正在使用一个现有的镜像,该镜像将 Prometheus 打包到了来自 GitHub 上相同的dockersamples/aspnet-monitoring示例中的 Windows Server 2019 容器中,我用于 ASP.NET 导出器。

Prometheus 的 Dockerfile 并没有做任何在本书中已经看到过很多次的事情——它下载发布文件,提取它,并设置运行时环境。Prometheus 服务器有多个功能:它运行定期作业来轮询度量端点,将数据存储在时间序列数据库中,并提供一个 REST API 来查询数据库和一个简单的 Web UI 来浏览数据。

我需要为调度器添加自己的配置,我可以通过运行一个容器并挂载一个卷来完成,或者在集群模式下使用 Docker 配置对象。我的度量端点的配置相当静态,因此最好将一组默认配置捆绑到我的自己的 Prometheus 镜像中。我已经在dockeronwindows/ch11-prometheus:2e中做到了这一点,它有一个非常简单的 Dockerfile:

FROM dockersamples/aspnet-monitoring-prometheus:2.3.1-windowsservercore-ltsc2019 COPY prometheus.yml /etc/prometheus/prometheus.yml

我已经有从我的仪器化 API 和 NerdDinner web 镜像运行的容器,这些容器公开了供 Prometheus 消费的度量端点。为了在 Prometheus 中监视它们,我需要在prometheus.yml配置文件中指定度量位置。Prometheus 将按可配置的时间表轮询这些端点。它称之为抓取,我已经在scrape_configs部分中添加了我的容器名称和端口:

global:
  scrape_interval: 5s   scrape_configs:
 - job_name: 'Api'
    static_configs:
     - targets: ['api:50505']

 - job_name: 'NerdDinnerWeb'
    static_configs:
     - targets: ['nerd-dinner:50505']

要监视的每个应用程序都被指定为一个作业,每个端点都被列为一个目标。Prometheus 将在同一 Docker 网络上的容器中运行,因此我可以通过容器名称引用目标。

这个设置是为单个 Docker 引擎设计的,但您可以使用相同的方法使用 Prometheus 监视跨多个副本运行的服务,只需使用不同的配置设置。我在我的 Pluralsight 课程使用 Docker 监视容器化应用程序健康状况中详细介绍了 Windows 和 Linux 容器。

现在,我可以在容器中启动 Prometheus 服务器:

docker container run -d -P --name prometheus dockeronwindows/ch11-prometheus:2e

Prometheus 轮询所有配置的指标端点并存储数据。您可以将 Prometheus 用作丰富 UI 组件(如 Grafana)的后端,将所有运行时 KPI 构建到单个仪表板中。对于基本监控,Prometheus 服务器在端口9090上有一个简单的 Web UI。

我可以转到 Prometheus 容器的发布端口,对其从我的应用程序容器中抓取的数据运行一些查询。Prometheus UI 可以呈现原始数据,或者随时间聚合的图表。这是由 REST API 应用程序发送的 HTTP 响应:

您可以看到每个不同标签值的单独行,因此我可以看到不同 URL 的不同响应代码。这些是随着容器的寿命而增加的计数器,因此图表将始终上升。Prometheus 具有丰富的功能集,因此您还可以绘制随时间变化的变化率,聚合指标并选择数据的投影。

来自 Prometheus NuGet软件包的其他计数器是快照,例如性能计数器统计信息。我可以从 NerdDinner 容器中看到 IIS 处理的每秒请求的数量:

在 Prometheus 中,指标名称非常重要。如果我想比较.NET 控制台和 ASP.NET 应用程序的内存使用情况,那么如果它们具有相同的指标名称,比如process_working_set,我可以查询两组值。每个指标的标签标识提供数据的服务,因此您可以对所有服务进行聚合或对特定服务进行筛选。您还应该将每个容器的标识符作为指标标签包括在内。导出器应用程序将服务器主机名添加为标签。实际上,这是容器 ID,因此在大规模运行时,您可以对整个服务进行聚合或查看单个容器。

在第八章中,《管理和监控 Docker 化解决方案》,我演示了 Docker Enterprise 中的Universal Control PlaneUCP),这是Containers-as-a-ServiceCaaS)平台。启动和管理 Docker 容器的标准 API 使该工具能够提供集中的管理和管理体验。Docker 平台的开放性使开源工具可以采用相同的方法进行丰富的、集中的监控。

Prometheus 就是一个很好的例子。它作为一个轻量级服务器运行,非常适合在容器中运行。您可以通过向应用程序添加指标端点或在现有应用程序旁边运行指标导出器来为应用程序添加对 Prometheus 的支持。Docker 引擎本身可以配置为导出 Prometheus 指标,因此您可以收集有关容器和节点健康状况的低级指标。

这些指标是您需要的全部内容,可以为您提供关于应用程序健康状况的丰富仪表板。

在 Grafana 中构建应用程序仪表板

Grafana 是用于可视化数据的 Web UI。它可以从许多数据源中读取,包括时间序列数据库(如 Prometheus)和关系数据库(如 SQL Server)。您可以在 Grafana 中构建仪表板,显示整个应用程序资产的健康状况,包括业务 KPI、应用程序和运行时指标以及基础设施健康状况。

通常,您会将 Grafana 添加到容器化应用程序中,以呈现来自 Prometheus 的数据。您也可以在容器中运行 Grafana,并且可以打包您的 Docker 镜像,以便内置仪表板、用户帐户和数据库连接。我已经为本章的最后部分做了这样的处理,在dockeronwindows/ch11-grafana:2e镜像中。Grafana 团队没有在 Docker Hub 上发布 Windows 镜像,因此我的 Dockerfile 从示例镜像开始,并添加了我设置的所有配置。

# escape=` FROM dockersamples/aspnet-monitoring-grafana:5.2.1-windowsservercore-ltsc2019 SHELL ["powershell", "-Command", "$ErrorActionPreference = 'Stop';"] COPY datasource-prometheus.yaml \grafana\conf\provisioning\datasources\ COPY dashboard-provider.yaml \grafana\conf\provisioning\dashboards\ COPY dashboard.json \var\lib\grafana\dashboards\

COPY init.ps1 . RUN .\init.ps1 

Grafana 有两种自动部署方法。第一种只是使用已知位置的文件,我用它来设置 Prometheus 数据源、仪表板和仪表板提供程序,它只是将 Grafana 指向仪表板目录。第二种使用 REST API 进行身份验证和授权,我的init.ps1脚本使用它来创建一个只读用户,该用户可以访问仪表板。

使用 Grafana 创建自己的仪表板很简单。您可以为特定类型的可视化创建面板——支持数字、图形、热图、交通灯和表格。然后,您将面板连接到数据源并指定查询。通常,您会使用 Prometheus UI 来微调查询,然后将其添加到 Grafana 中。为了节省时间,我的镜像带有一个现成的仪表板。

我将使用ch11文件夹中的 Docker Compose 文件启动监控解决方案,然后浏览 API 和网站以生成一些流量。现在,我可以浏览 Grafana,并使用用户名viewer和密码readonly登录,然后我会看到仪表板:

这只是一个示例仪表板,但它让您了解可以呈现多少信息。我为 REST API 设置了一行,显示了 HTTP 请求和响应的细分,以及 CPU 使用情况的整体视图。我还为 NerdDinner 设置了一行,显示了来自 IIS 的性能指标和缓存使用的头条统计数据。

您可以轻松地向所有应用程序添加工具,并构建详细的仪表板,以便深入了解解决方案中发生的情况。而且,您可以在每个环境中具有完全相同的监视设施,因此在开发和测试中,您可以看到与生产中使用的相同指标。这在追踪性能问题方面非常有用。开发人员可以为性能问题添加新的指标和可视化,解决问题,当更改生效时,它将包括可以在生产中跟踪的新指标。

我将在本章中讨论的最后一件事是如何修复 Docker 中的错误,以及容器化如何使这变得更加容易。

Docker 中的错误修复工作流程

在修复生产缺陷时最大的困难之一是在开发环境中复制它们。这是确认您有错误并深入查找问题的起点。这也可能是问题中最耗时的部分。

大型.NET 项目往往发布不频繁,因为发布过程复杂,并且需要大量手动测试来验证新功能并检查任何回归。一年可能只有三到四次发布,并且开发人员可能发现自己不得不在发布过程的不同部分支持应用程序的多个版本。

在这种情况下,您可能在生产中有 1.0 版本,在用户验收测试(UAT)中有 1.1 版本,在系统测试中有 1.2 版本。开发团队可能需要跟踪和修复任何这些版本中提出的错误,而他们目前正在处理 1.3 版本,甚至是 2.0 的重大升级。

在 Docker 之前修复错误

我经常处于这种境地,不得不从我正在工作的重构后的 2.0 代码库切换回即将发布的 1.1 代码库。上下文切换是昂贵的,但是设置开发环境以重新创建 1.1 UAT 环境的过程更加昂贵。

发布过程可能会创建一个带版本号的 MSI,但通常你不能在开发环境中直接运行它。安装程序可能会打包特定环境的配置。它可能已经以发布模式编译并且没有 PDB 文件,因此没有附加调试器的选项,它可能具有我在开发中没有的先决条件,比如证书、加密密钥或其他软件组件。

相反,我需要重新编译源代码中的 1.1 版本。希望发布过程提供了足够的信息,让我找到用于构建发布的确切源代码,然后在本地克隆它(也许 Git 提交 ID 或 TFS 变更集记录在构建的程序集中)。然后,当我尝试在我的本地开发环境中重新创建另一个环境时,真正的问题开始了。

工作流程看起来有点像这样,在我的设置和 1.1 环境之间存在许多差异:

  • 在本地编译源代码。我将在 Visual Studio 中构建应用程序,但发布版本使用的是 MSBuild 脚本,它做了很多额外的事情。

  • 在本地运行应用程序。我将在 Windows 10 上使用 IIS Express,但发布使用的是部署到 Windows Server 2012 上的 IIS 8 的 MSI。

  • 我的本地 SQL Server 数据库设置为我正在使用的 2.0 架构。发布中有从 1.0 升级到 1.1 的升级脚本,但没有从 2.0 降级到 1.1 的脚本,因此我需要手动修复本地架构。

  • 对于我无法在本地运行的任何依赖项,例如第三方 API,我有存根。发布使用真实的应用程序组件。

即使我可以获得版本 1.1 的确切源代码,我的开发环境与 UAT 环境存在巨大差异。这是我能做的最好的,可能需要数小时的努力。为了减少这段时间,我可以采取捷径,比如利用我对应用程序的了解来运行版本 1.1 与 2.0 数据库架构,但采取捷径意味着我的本地环境与目标环境更不相似。

在这一点上,我可以以调试模式运行应用程序并尝试复制问题。如果错误是由 UAT 中的数据问题或环境问题引起的,那么我将无法复制它,可能需要花费整整一天的时间才能找出这一点。如果我怀疑问题与 UAT 的设置有关,我无法在我的环境中验证这一点;我需要与运维团队合作,查看 UAT 配置。

但希望我可以通过按照错误报告中的步骤重现问题。当我弄清楚手动步骤后,我可以编写一个失败的测试来复制问题,并且在更改代码并且测试运行成功时,我可以确信我已经解决了问题。我的环境与 UAT 之间存在差异,因此可能是我的分析不正确,修复无法修复 UAT,但直到下一个发布之前我才能发现这一点。

如何将该修复发布到 UAT 环境是另一个问题。理想情况下,完整的 CI 和打包过程已经为 1.1 分支设置好,因此我只需推送我的更改,然后就会出现一个准备部署的新 MSI。在最坏的情况下,CI 仅从主分支运行,因此我需要在修复分支上设置一个新的作业,并尝试配置该作业与上次 1.1 发布时相同。

如果在 1.1 和 2.0 之间的任何工具链部分发生了变化,那么这将使整个过程的每一步都变得更加困难,从配置本地环境,运行应用程序,分析问题到推送修复。

使用 Docker 修复错误

使用 Docker 的过程要简单得多。要在本地复制 UAT 环境,我只需要从在 UAT 中运行的相同镜像中运行容器。将有一个描述整个解决方案的 Docker Compose 或堆栈文件进行版本控制,因此通过部署版本 1.1,我可以获得与 UAT 完全相同的环境,而无需从源代码构建。

我应该能够在这一点上复制问题并确认它是编码问题还是与数据或环境有关的问题。如果是配置问题,那么我应该看到与 UAT 相同的问题,并且我可以使用更新的 Compose 文件测试修复。如果是编码问题,那么我需要深入了解代码。

在这一点上,我可以从版本 1.1 标签中克隆源代码并以调试模式构建 Docker 镜像,但除非我相当确定这是应用程序中的问题,否则我不会花时间这样做。如果我在 Dockerfile 中使用多阶段构建,并且所有版本都在其中固定,那么本地构建将产生与在 UAT 中运行的相同镜像,但会有额外的用于调试的工件。

现在,我可以找到问题,编写测试并修复错误。当新的集成测试通过时,它是针对我将在 UAT 中部署的相同 Docker 化解决方案执行的,因此我可以非常确信该错误已经被修复。

如果 1.1 分支没有配置 CI,那么设置它应该很简单,因为构建任务只需要运行docker image builddocker-compose build命令。如果我想要快速反馈,我甚至可以将本地构建的镜像推送到注册表,并部署一个新的 UAT 环境来验证修复,同时配置 CI 设置。新环境将只是测试集群上的不同堆栈,因此我不需要为部署再委托更多的基础设施。

Docker 的工作流程更加清洁和快速,但更重要的是,风险要小得多。当您在本地复制问题时,您使用的是与 UAT 环境上完全相同的应用程序组件在完全相同的平台上运行。当您测试您的修复时,您知道它将在 UAT 中起作用,因为您将部署相同的新构件。

将您投入 Docker 化应用程序的时间将通过节省支持应用程序多个版本的时间而多次偿还。

总结

本章讨论了在容器中运行的应用程序的故障排除,以及调试和仪器化。Docker 是一个新的应用程序平台,但是容器中的应用程序作为主机上的进程运行,因此它们仍然是远程调试和集中监控的合适目标。

Visual Studio 的所有当前版本都支持 Docker。Visual Studio 2017 具有最完整的支持,涵盖 Linux 和 Windows 容器。Visual Studio 2015 和 Visual Studio Code 目前具有提供 Linux 容器调试的扩展。您可以轻松添加对 Windows 容器的支持,但完整的调试体验仍在不断发展。

在本章中,我还介绍了 Prometheus,这是一个轻量级的仪器和监控组件,您可以在 Windows Docker 容器中运行。Prometheus 存储它从其他容器中运行的应用程序提取的指标。容器的标准化性质使得配置诸如这样的监控解决方案非常简单。我使用 Prometheus 数据来驱动 Grafana 中的仪表板,该仪表板在容器中运行,这是呈现应用程序健康状况的综合视图的简单而强大的方式。

下一章是本书的最后一章。我将以分享一些在您自己的领域中开始使用 Docker 的方法结束,包括我在现有项目中在 Windows 上使用 Docker 的案例研究。

第十二章:将您所知道的内容放入容器-实施 Docker 的指导

在本书中,我使用了较旧的.NET 技术来展示示例应用程序,以向您展示 Docker 与现代.NET Core 应用程序一样有效。您可以将十年前的 WebForms 应用程序放入 Docker,并获得与在容器中运行全新的 ASP.NET Core Web 应用程序相同的许多好处。

您已经看到了许多容器化应用程序的示例,并学会了如何使用 Docker 构建、部署和运行生产级应用程序。现在,您已经准备好在自己的项目中开始使用 Docker,并且本章将为您提供如何入门的建议。

我将介绍一些技术和工具,这些将帮助您运行一个概念验证项目,将应用程序迁移到 Docker。我还将通过一些案例研究向您展示我是如何将 Docker 引入现有项目的:

  • 一个小型的.NET 2.0 WebForms 应用程序

  • Windows Communication FoundationWCF)应用程序中的数据库集成服务

  • 在 Azure 中运行的分布式 IoT API 应用程序

你将看到如何解决典型问题以及 Docker 的应用如何帮助解决这些问题。

将您所知道的内容放入 Docker

当您迁移到新的应用程序平台时,您必须使用一组新的工件和新的操作流程。如果您目前使用 Windows 安装程序进行部署,您的工件是 Wix 文件和 MSI 文件。您的部署过程是将 MSI 复制到目标服务器,登录并运行安装程序。

迁移到 Docker 后,您将拥有 Dockerfiles 和镜像作为部署工件。您将镜像推送到注册表并运行容器或更新服务以部署应用程序。在 Docker 中资源和活动更简单,并且它们在项目之间是一致的,但是当您开始时仍然有一个学习曲线。

将您熟悉的应用程序放入容器是提供学习经验的一个很好的方法。当您首次在容器中运行应用程序时,可能会出现错误或不正确的行为,但这将是您自己应用程序的领域。当您追踪问题时,您将处理一个您很了解的领域,因此尽管平台是新的,问题应该很容易识别。

选择一个简单的概念验证应用程序

Docker 理想地适用于分布式应用程序,其中每个组件在轻量级容器中运行,有效地利用最小的硬件集。您可以选择一个分布式应用程序作为您的第一个 Docker 部署,但一个更简单的应用程序将更快地迁移,并且会给您更高的成功机会。

单体应用是一个不错的选择。它不一定要有一个小的代码库,但是与其他组件的集成越少,您就越快地将其在 Docker 中运行。将状态存储在 SQL Server 中的 ASP.NET 应用程序是一个直接的选择。您可以期望用一个简单的应用程序在一两天内运行一个概念验证PoC)。

从已编译的应用程序开始,而不是源代码,是证明该应用程序可以被 Docker 化而无需更改的好方法。在选择您的 PoC 应用程序时,有一些因素需要考虑:

  • 有状态性:如果您的目标应用程序在内存中存储状态,您将无法通过运行多个容器来扩展 PoC。每个容器将有自己的状态,并且除非您还运行具有粘性会话支持的反向代理,否则在不同容器处理请求时会得到不一致的行为。考虑无状态应用程序或可以使用共享状态的应用程序,比如使用 SQL Server 作为 ASP.NET 的会话状态提供程序。

  • 配置:.NET 应用程序通常在Web.configapp.config中使用 XML 配置文件。您可以设置您的 PoC 来使用现有的配置文件作为基础,然后替换任何不适用于容器化环境的值。最好通过 Docker 使用环境变量和秘密来读取配置设置,但对于 PoC 来说,保持配置文件更容易。

  • 弹性:较旧的应用程序通常假设可用性——Web 应用程序期望数据库始终可用,并且不会优雅地处理故障条件。如果您的应用程序对外部连接没有重试逻辑,那么当容器启动时出现瞬时连接故障时,您的 PoC 将面临错误。您可以在 Dockerfile 中通过启动时检查依赖项和持续的健康检查来减轻这种情况。

  • Windows 身份验证:容器未加入域。如果您在 AD 中创建了一个组托管服务帐户,您可以在容器中访问Active DirectoryAD)对象,但这会增加复杂性。对于 PoC,坚持使用更简单的身份验证方案,如基本身份验证。

这些都不是主要限制。您应该能够在容器化现有应用程序的基础上工作,而无需更改代码,但您需要意识到在 PoC 阶段功能可能不完美。

使用 Image2Docker 生成初始 Dockerfile

Image2Docker 是一个开源工具,您可以使用它为现有应用程序生成 Dockerfile。它是一个 PowerShell 模块,您可以在本地计算机上运行,也可以针对远程计算机或虚拟机磁盘文件运行(在 Hyper-V 中,文件以VHDVHDX格式存储)。

这是一个非常简单的开始使用 Docker 的方法-甚至您甚至不需要在本地计算机上安装 Docker 来尝试并查看 Dockerfile 对您的应用程序会是什么样子。Image2Docker 可以处理不同类型的应用程序(称为构件),但对于在 IIS 上运行的 ASP.NET 应用程序,功能是成熟的。

在我的开发计算机上,我有一个部署到Internet Information ServicesIIS)的 ASP.NET 应用程序。我可以通过从 PowerShell 库安装 Image2Docker 并导入模块来在 Docker 中迁移该应用程序。

Install-Module Image2Docker
Import-Module Image2Docker

PowerShell 5.0 是Image2Docker所需的最低版本,但该工具没有其他依赖关系。

我可以运行ConvertTo-Dockerfile cmdlet,指定 IIS 构件来构建一个包含我计算机上所有 IIS 网站的 Dockerfile:

ConvertTo-Dockerfile -Local -Artifact IIS -OutputPath C:\i2d\iis

这将在C:\i2d\iis创建一个目录,在文件夹内部,我将有一个 Dockerfile 和每个网站的子目录。Image2Docker将网站内容从源复制到输出位置。Dockerfile 使用最相关的基础映像来找到应用程序,即microsoft/iismicrosoft/aspnetmicrosoft/aspnet:3.5

如果源中有多个网站或 Web 应用程序,Image2Docker会提取它们所有并构建一个重复原始 IIS 设置的单个 Dockerfile,因此 Docker 镜像中将有多个应用程序。这不是我的目标,因为我希望我的 Docker 镜像中只有一个应用程序,所以我可以使用参数来提取单个网站:

ConvertTo-Dockerfile -Local -Artifact IIS -ArtifactParam SampleApi -OutputPath C:\i2d\api

这个过程是一样的,但这次,Image2Docker只从源中提取一个应用程序 - 在ArtifactParam参数中命名的应用程序。Dockerfile 包含部署应用程序的步骤,您可以运行docker image build来创建图像并运行应用程序。

这可能是 Docker 化应用程序的第一步,然后您将运行一个容器并检查应用程序的功能。可能需要额外的设置,Image2Docker不会为您完成,所以您可能会在生成的 Dockerfile 上进行迭代,但该工具是一个很好的开始。

Image2Docker是一个开源项目。源代码在 GitHub 上 - 使用以下短链接查看:github.com/docker/communitytools-image2docker-win。该工具最近没有更新,因为 Docker 现在有一个商业替代品叫做 Docker Application Convertor(DAC)。DAC 具有更强大的功能集,因为它支持 Linux 和 Windows 应用程序。您可以在 YouTube 上的 DockerCon 会议上看到演示:is.gd/sLMOa1

吸引其他利益相关者

一个成功的 PoC 应该在几天内就能实现。其输出将是在 Docker 中运行的示例应用程序,以及您需要将该 PoC 投入生产的一系列额外步骤。如果您在一个 DevOps 环境中工作,您的团队负责项目的交付,您可以同意投资转移到 Docker 用于生产。

对于更大的项目或更大的团队,您需要与其他利益相关者合作,以进一步进行您的 PoC。您进行的对话类型将取决于您的组织结构,但有一些主题侧重于您在 Docker 中获得的改进:

  • 在部署应用程序时,运维团队经常在从开发部门交接时遇到摩擦。Docker 工件、Dockerfiles 和 Docker Compose 文件是开发和运维可以共同努力的中心点。运维团队不会因为升级而无法部署,因为升级将是一个已经经过尝试和测试的 Docker 镜像。

  • 大公司的安全团队经常需要证明来源。他们需要证明在生产中运行的软件没有被篡改,并且实际上正在运行 SCM 中的代码。目前可能是基于流程的,但通过镜像签名和 Docker 内容信任,可以明确地证明。在某些情况下,安全团队还需要证明系统只能在经过认证的硬件上运行,而在 Docker Swarm 中使用安全标签和约束很容易实现。

  • 产品所有者经常试图在长期发布计划中平衡庞大的积压工作。企业.NET 项目通常难以部署——升级过程缓慢、手动且风险高。有一个部署阶段,然后是用户测试阶段,在此期间应用程序对普通用户不可用。相比之下,使用 Docker 进行部署快速、自动化且安全,这意味着您可以更频繁地部署,当功能准备就绪时添加功能,而不是等待下一个预定发布的几个月。

  • 管理团队将专注于产品和产品运行成本。Docker 通过更有效地利用计算资源和降低许可成本来帮助降低基础设施成本。它通过让团队更高效地工作,消除环境之间的差异以确保部署一致,帮助降低项目成本。它还有助于提高产品质量,因为自动打包和滚动更新意味着您可以更频繁地部署,更快地添加功能和修复缺陷。

您可以通过在 Windows 10 上使用 Docker 桌面获得 Docker 的社区版CE)来开始进行 PoC。您组织中的其他利益相关者将希望了解在容器中运行的应用程序可获得的支持。Docker 企业引擎包含在 Windows Server 2016 或 2019 的许可成本中,因此您可以在没有额外成本的情况下获得来自 Microsoft 和 Docker, Inc.的支持。运营和安全团队可能会在完整的 Docker 企业套件中看到很多好处,其中包括通用控制平面UCP)和Docker 受信任的注册表DTR)。

Docker 最近宣布他们将为 Mac 和 Windows 推出 Docker 桌面企业版。它将拥有与 Docker 桌面相同的出色用户体验,但支持 Windows 10,并能够在本地运行与您的组织在生产中运行的相同版本的 Docker 企业引擎。

您的 PoC 中的 Dockerfiles 和 Docker 镜像将在所有这些版本上以相同的方式工作。 Docker CE,Docker Enterprise Engine 和 Universal Control Plane 都共享相同的基础平台。

实施 Docker 的案例研究

我将通过查看三个真实案例研究来结束本章,这些案例研究中,我已经将 Docker 引入现有解决方案,或者准备了将 Docker 引入项目的路线图。这些都是生产场景,从一个有数十个用户的小公司项目到一个拥有超过一百万用户的大型企业项目。

案例研究 1 – 一个内部 WebForms 应用程序

多年前,我接手了一家汽车租赁公司的 WebForms 应用程序的支持工作。该应用程序由一个约 30 人的团队使用,是一个小规模部署——他们有一个托管数据库的服务器和一个运行 Web 应用程序的服务器。尽管规模小,但这是公司的核心应用程序,他们所有的业务都是通过这个应用程序运行的。

该应用程序的架构非常简单:只有一个 Web 应用程序和一个 SQL Server 数据库。最初,我做了很多工作来改善应用程序的性能和质量。之后,它变成了一个看护人的角色,我每年会管理两到三次发布,添加新功能或修复旧的错误。

这些发布总是比必要的更加困难和耗时。发布通常包括以下内容:

  • 带有更新应用程序的 Web 部署包

  • 一组带有模式和数据更改的 SQL 脚本

  • 一个手动测试指南,用于验证新功能并检查是否存在回归

部署是在办公时间之外进行的,以便我们有时间窗口来解决我们发现的任何问题。我会使用远程桌面协议(RDP)访问他们的服务,复制工件,并手动运行 Web 部署包和 SQL 脚本。通常发布之间相隔几个月,所以我会依赖我写的文档来提醒我这些步骤。然后,我会按照测试指南进行测试并检查主要功能。有时,会出现问题,因为我错过了一个 SQL 脚本或 Web 应用程序的依赖项,我需要尝试追踪之前未见过的问题。

直到最近,该应用程序一直在运行 Windows Server 2003,这个系统早已不再得到支持。当公司想要升级 Windows 时,我建议使用 Windows Server 2016 Core 和 Docker。我的建议是使用 Docker 来运行 Web 应用程序,并将 SQL Server 原生地运行在自己的服务器上,但使用 Docker 作为部署数据库升级的分发机制。

迁移到 Docker 非常简单。我使用 Image2Docker 对生产服务器进行操作,生成了一个初始的 Dockerfile,然后通过添加健康检查和环境变量进行迭代。我在 Visual Studio 中已经有了一个 SQL Server 项目用于架构,所以我添加了另一个 Dockerfile 来打包 Dacpac 和数据库的部署脚本。只用了两天就完成了 Docker 构件,并在测试环境中运行了新版本。这就是 Docker 的架构:

  • 1:Web 应用程序在 Windows Docker 容器中运行。在生产环境中,它连接到一个独立的 SQL Server 实例。在非生产环境中,它连接到在容器中运行的本地 SQL Server 实例。

  • 2:数据库被打包成基于 SQL Server Express 的 Docker 镜像,并且使用 Dacpac 中的数据库架构进行部署。在生产环境中,从该镜像中运行一个任务容器来将架构部署到现有数据库。在非生产环境中,运行一个后台容器来托管数据库。

从那时起,部署变得简单明了,并且总是遵循相同的步骤。我们在 Docker Hub 上有一组私有仓库,其中存储了版本化的应用程序和数据库镜像。我配置了我的本地 Docker CLI 来与他们的 Docker Engine 一起工作,然后我做了以下操作:

  1. 停止 Web 应用程序容器。

  2. 从新的数据库镜像中运行一个容器来升级 SQL Server。

  3. 使用 Docker Compose 来将 Web 应用程序更新为新的镜像。

迁移到 Docker 的最大好处是快速可靠的发布和减少了基础设施需求。公司目前正在考虑用更多的小型服务器来替换他们当前的大型服务器,以便能够运行 Docker Swarm 并实现零停机升级。

另一个好处是发布过程的简单性。因为部署已经经过尝试和测试,使用的是将在生产中使用的相同的 Docker 镜像,所以不需要有人了解该应用程序并跟踪问题。公司的 IT 支持人员现在负责发布,他们可以在没有我的帮助下完成。

我再次与同一家公司合作,管理他们升级到最新的 Windows Server 2019 上的 Docker Enterprise。计划非常简单 - 我已经在最新的 Windows Server 2019 Core 镜像上构建了他们的应用程序和数据库镜像,并验证它们可以通过一套端到端测试工作。现在,他们可以执行服务器升级并使用相同的工具部署新版本,并对成功发布感到自信。

案例研究 2 - 数据库集成服务

我曾为一家金融公司开发一个庞大而复杂的网络应用。这是一个面向内部的应用程序,管理着大量的交易。前端使用的是 ASP.NET MVC,但大部分逻辑都在服务层中,使用 WCF 编写。服务层还是许多第三方应用程序的外观,将集成逻辑隔离在 WCF 层中。

大多数第三方应用程序都有 XML Web 服务或 JSON REST API 可供我们使用,但其中一个较老的应用程序没有集成选项。我们只用它作为参考数据,因此外观是作为数据库级别的集成实现的。WCF 服务公开了封装良好的端点,但实现直接连接到外部应用程序数据库以提供数据。

数据库集成是脆弱的,因为你必须依赖私有数据库架构而不是公共服务契约,但有时别无选择。在这种情况下,架构变化不频繁,我们可以管理这种中断。不幸的是,发布过程是反向的。运营团队首先会在生产环境中发布数据库的新版本,因为该应用程序只在生产环境中得到供应商的支持。当一切正常时,他们会在开发和测试环境中复制发布。

一个发布中有一个数据库架构的变化破坏了我们的集成。任何使用第三方应用程序的参考数据的功能都停止工作了,我们必须尽快进行修复。修复很简单,但 WCF 应用程序是一个庞大的单体,需要大量的回归测试,才能确保这个变化不会影响其他领域。我被要求考虑 Docker 作为更好地管理数据库依赖的方法。

该提案很简单。我并不建议将整个应用程序移至 Docker——这已经在长期路线图上了——而只是将一个服务移至 Docker。该服务的 WCF 端点是数据库应用程序外观将在 Docker 中运行,与应用程序的其余部分隔离。Web 应用程序是该服务的唯一消费者,因此只需更改消费者中服务的 URL。这是拟议的架构:

  • 1:Web 应用程序在 IIS 中运行。代码没有更改,但配置已更新为使用在容器中运行的新集成组件的 URL。

  • 2:原始的 WCF 服务继续在 IIS 中运行,但之前的数据库集成组件已被移除。

  • 3:新的集成组件使用与之前相同的 WCF 合同,但现在它是在容器中托管的,隔离对第三方应用程序数据库的访问。

这种方法有很多好处:

  • 如果数据库架构发生变化,我们只需要更改 Docker 化的服务

  • 服务更改可以通过更新 Docker 镜像而无需进行完整的应用程序发布来发布

  • 这是一个关于 Docker 的沙盒介绍,因此开发和运维团队可以用它进行评估。

在这种情况下,最重要的好处是减少了测试工作量。对于完整的单片应用程序,发布需要数周的测试。通过将服务拆分为 Docker 容器,只有发生变化的服务需要进行发布的测试。这大大减少了所需的时间和精力,从而可以更频繁地发布,从而更快地将新功能推出到业务中。

案例研究 3 - Azure IoT 应用程序

我是一个项目的 API 架构师,负责提供移动应用程序消费的后端服务。有两个主要的 API。配置 API 是只读的,设备调用它来检查设置和软件的更新。事件 API 是只写的,设备发布关于用户行为的匿名事件,产品团队用这些事件来指导下一代设备的设计决策。

这些 API 支持超过 150 万台设备。配置 API 需要高可用性;它们必须快速响应设备调用,并能够扩展到每秒数千个并发请求。事件 API 从设备消费数据并将事件推送到消息队列。在队列上有两组处理程序:一组将所有事件数据存储在 Hadoop 中,用于长期分析,另一组将事件的子集存储以提供实时仪表板。

所有组件都在 Azure 上运行,在项目的高峰期,我们使用了云服务、事件中心、SQL Azure 和 HDInsight。架构如下:

  • 1:事件 API 托管在一个云服务中,有多个实例。设备将事件发布到 API,API 进行一些预处理,然后将它们批量发布到 Azure 事件中心。

  • 2:配置 API 也托管在一个云服务中,有多个实例。设备连接到 API 以检查软件更新和配置设置。

  • 3:实时分析数据,用于一些关键性能指标的子集。这些数据存储在 SQL Azure 中,以便快速访问,因为这些数据量是适度的。

  • 4:批处理分析数据,用于存储所有设备发布的事件。这些数据存储在 HDInsight 中,这是 Azure 上的托管 Hadoop 服务,用于长时间运行的大数据查询。

这个系统的运行成本很高,但它为产品团队提供了大量关于设备使用情况的信息,他们将这些信息输入到下一代设计过程中。每个人都很高兴,但后来产品路线被取消了,不会再有任何设备,所以我们不得不削减运行成本。

我的工作是将 Azure 账单从每月 5 万美元降低到每月不到 1 千美元。我可以放弃一些报告功能,但事件 API 和配置 API 必须保持高可用性。

在 Windows 上可用 Docker 之前发生了这件事,所以我对架构进行了第一次修订,使用在 Azure 中运行的 Docker Swarm 上的 Linux 容器。我用 Elasticsearch 和 Kibana 替换了系统的分析部分,并用 Nginx 提供了静态内容的配置 API。我将自定义的.NET 组件留在云服务中,用于从设备数据提供给 Azure 事件中心的事件 API 和将数据推送到 Elasticsearch 的消息处理程序。

  • 1:配置 API 现在作为 Nginx 中的静态网站运行。配置数据以 JSON 负载的形式提供,保持原始 API 契约。

  • 2:Kibana 用于实时和历史分析。通过减少存储的数据量,我们显著减少了数据存储需求,但代价是失去了详细的指标。

  • 3:Elasticsearch 被用来存储传入的事件数据。仍然使用.NET 云服务从事件中心读取数据,但这个版本将数据保存在 Elasticsearch 中。

这个第一个修订版为我们带来了我们需要的成本节约,主要是通过减少 API 所需的节点数量和从设备中存储的数据量。我将所有数据都集中存储在 Elasticsearch 中,而不是在 Hadoop 中存储所有数据和在 SQL Azure 中存储实时数据的做法。使用 Nginx 来提供配置 API,我们失去了产品团队为发布配置更新而拥有的用户友好功能,但我们可以使用更小的计算资源运行。

当 Windows Server 2016 推出并且 Windows 上的 Docker 得到支持时,我进行了第二次修订。我在 Docker Swarm 中添加了现有 Linux 节点的 Windows 节点,并将事件 API 和消息处理程序迁移到了 Windows Docker 容器中。同时,我还将消息系统迁移到了在 Linux 容器中运行的 NATS:

  • 1:事件 API 现在托管在 Docker 容器中,但代码没有改变;这仍然是一个在 Windows 容器中运行的 ASP.NET Web API 项目。

  • 2:消息组件使用 NATS 而不是事件中心。我们失去了存储和重新处理消息的能力,但消息队列现在具有与事件 API 相同的可用性。

  • 3:消息处理程序从 NATS 读取数据并将数据保存在 Elasticsearch 中。大部分代码保持不变,但现在它作为一个.NET 控制台应用在 Windows 容器中运行。

这第二次修订进一步降低了成本和复杂性:

  • 现在每个组件都在 Docker 中运行,所以我可以在开发中复制整个系统

  • 所有组件都使用 Dockerfiles 构建并打包为 Docker 镜像,因此一切都使用相同的构件

  • 整个解决方案具有相同级别的服务,在单个 Docker Swarm 上高效运行

在这种情况下,该项目注定要逐渐减少,并且很容易通过新解决方案进行适应。设备使用仍然记录并显示在 Kibana 仪表板上。随着时间的推移,使用的设备越来越少,服务需要的计算量也越来越少,我们可以从 Swarm 中删除节点。最终,该项目将在最小的基础设施上运行,可能只是一个双节点 Swarm,在 Azure 的小型 VM 上运行,或者它可以迁回公司的数据中心。

总结

全世界的大大小小公司都在 Windows 和 Linux 上转向 Docker。一些主要的驱动因素是效率、安全性和可移植性。许多新项目都是使用容器从头开始设计的,但还有许多现有项目可以从迁移到 Docker 中受益。

在本章中,我已经研究了将现有应用迁移到 Windows 上的 Docker,并建议您从您熟悉的应用程序开始。对该应用程序进行短期、限时的 Docker 化概念验证将迅速向您展示您的应用在 Docker 中的样子。该概念验证的结果将帮助您了解接下来需要做什么,以及需要谁参与将该概念验证移入生产环境。

我完成了一些非常不同的案例研究,向您展示了如何在现有项目中引入 Docker。在一个案例中,我主要利用 Docker 的打包优势来运行一个单片应用,而不对其进行更改,但为将来的发布提供干净的升级。在另一个案例中,我从单片应用中提取了一个组件并将其提取到容器中,以减少发布的测试负担。在最后一个案例中,我完全将现有解决方案迁移到 Docker,使其更便宜,更易于维护,并为我提供了在任何地方运行它的选项。

希望本章能帮助您思考如何将 Docker 引入您自己的项目中,也希望本书的其余部分能向您展示您可以用 Docker 做什么,以及为什么它是一项如此令人兴奋的技术。感谢阅读,请务必查看我的 Pluralsight 课程,并在 Twitter 上关注我,在 Windows 上使用 Docker 的旅程中祝您好运!

posted @ 2024-05-06 18:32  绝不原创的飞龙  阅读(93)  评论(0编辑  收藏  举报