Docker-学习手册第二版(全)

Docker 学习手册第二版(全)

原文:zh.annas-archive.org/md5/4FF7CBA6C5E093012874A6BAC2B803F8

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

开发人员面临着越来越大的压力,需要以高速部署高度分布式应用程序。运维工程师正在寻找一个统一的部署策略,涵盖他们不断增长的应用程序组合的大部分或全部内容,利益相关者希望保持总体拥有成本低。Docker 容器与 Kubernetes 等容器编排器结合使用,帮助他们实现这些目标。

Docker 容器加速并简化了高度分布式应用程序的构建、交付和运行。容器加速了 CI/CD 流水线,容器化的应用程序允许公司在一个共同的部署平台上实现标准化,如 Kubernetes。容器化的应用程序更安全,并且可以在任何能够运行容器的平台上运行,无论是在本地还是在云端。

这本书适合谁

本书面向系统管理员、运维工程师、DevOps 工程师以及有兴趣从零开始学习 Docker 的开发人员或利益相关者。

本书涵盖了什么内容

第一章《什么是容器,为什么我应该使用它们?》介绍了容器的概念以及它们在软件行业中为何如此有用。

第二章《建立工作环境》详细讨论了如何为开发人员、DevOps 和运维人员建立一个理想的工作环境,用于处理 Docker 容器。

第三章《掌握容器》解释了如何启动、停止和移除容器。我们还将看到如何检查容器以从中检索额外的元数据。此外,我们将看到如何运行额外的进程,如何附加到已经运行的容器中的主进程,以及如何从容器中检索由其中运行的进程产生的日志信息。最后,本章介绍了容器的内部工作原理,包括 Linux 命名空间和组等内容。

第四章,《创建和管理容器镜像》,介绍了创建作为容器模板的容器镜像的不同方法。它介绍了镜像的内部结构以及它是如何构建的。本章还解释了如何将现有的遗留应用程序迁移,以便它可以在容器中运行。

第五章,《数据卷和配置》,介绍了可以被运行在容器中的有状态组件使用的数据卷。本章还展示了我们如何为容器内运行的应用程序定义单独的环境变量,以及如何使用包含整套配置设置的文件。

第六章,《在容器中运行代码调试》,讨论了常用的技术,允许开发人员在容器中运行代码时进行演变、修改、调试和测试。有了这些技术,开发人员将享受到类似于在本地开发运行应用程序时的无摩擦的开发过程。

第七章,《使用 Docker 来加速自动化》,展示了我们如何使用工具执行管理任务,而无需在主机计算机上安装这些工具。我们还将看到如何使用承载和运行测试脚本或用于测试和验证在容器中运行的应用程序服务的代码的容器。最后,本章指导我们完成构建基于 Docker 的简单 CI/CD 流水线的任务。

第八章,《高级 Docker 使用场景》,介绍了在将复杂的分布式应用程序容器化或使用 Docker 自动化复杂任务时有用的高级技巧、技巧和概念。

第九章,《分布式应用程序架构》,介绍了分布式应用程序架构的概念,并讨论了成功运行分布式应用程序所需的各种模式和最佳实践。最后,它讨论了在生产环境中运行此类应用程序需要满足的额外要求。

第十章,“单主机网络”,介绍了 Docker 容器网络模型及其在桥接网络形式下的单主机实现。本章介绍了软件定义网络的概念,并解释了它们如何用于保护容器化应用程序。还讨论了如何将容器端口对外开放,从而使容器化组件可以从外部访问。最后,介绍了 Traefik,一个反向代理,可以实现容器之间复杂的 HTTP 应用级路由。

第十一章,“Docker Compose”,讨论了由多个服务组成的应用程序的概念,每个服务在一个容器中运行,以及 Docker Compose 如何允许我们使用声明性方法轻松构建、运行和扩展这样的应用程序。

第十二章,“编排器”,介绍了编排器的概念。它解释了为什么需要编排器以及它们的工作原理。本章还概述了最流行的编排器,并列举了它们的一些优缺点。

第十三章,“Docker Swarm 简介”,介绍了 Docker 的本地编排器 SwarmKit。我们将了解 SwarmKit 用于在本地或云中部署和运行分布式、具有弹性、健壮和高可用性应用程序所使用的所有概念和对象。本章还介绍了 SwarmKit 如何通过软件定义网络来确保安全应用程序,以隔离容器,并使用秘密来保护敏感信息。此外,本章还展示了如何在云中安装高可用的 Docker Swarm。它介绍了路由网格,提供第 4 层路由和负载平衡。最后,展示了如何将由多个服务组成的应用程序部署到 Swarm 上。

第十四章,“零停机部署和秘密”,解释了如何在 Docker 集群上部署服务或应用程序,实现零停机和自动回滚功能。还介绍了秘密作为保护敏感信息的手段。

第十五章,“Kubernetes 简介”,介绍了当前最流行的容器编排器。它介绍了用于在集群中定义和运行分布式、有弹性、健壮和高可用应用程序的核心 Kubernetes 对象。最后,它介绍了 MiniKube 作为在本地部署 Kubernetes 应用程序的一种方式,以及 Kubernetes 与 Docker for Mac 和 Docker for Windows 的集成。

第十六章,“使用 Kubernetes 部署、更新和保护应用程序”,解释了如何将应用程序部署、更新和扩展到 Kubernetes 集群中。它还解释了如何使用活跃性和就绪性探针来为 Kubernetes 支持健康和可用性检查。此外,该章还解释了如何实现零停机部署,以实现对关键任务应用程序的无干扰更新和回滚。最后,该章介绍了 Kubernetes secrets 作为配置服务和保护敏感数据的手段。

第十七章,“监控和故障排除正在生产中运行的应用程序”,教授了监视在 Kubernetes 集群上运行的单个服务或整个分布式应用程序的不同技术。它还展示了如何在不更改集群或运行服务的集群节点的情况下,对正在生产中运行的应用程序服务进行故障排除。

第十八章,“在云中运行容器化应用程序”,概述了在云中运行容器化应用程序的一些最流行的方式。我们包括自托管和托管解决方案,并讨论它们的优缺点。微软 Azure 和谷歌云引擎等供应商的完全托管服务也进行了简要讨论。

为了充分利用本书

期望对分布式应用程序架构有扎实的理解,并对加速和简化构建、交付和运行高度分布式应用程序感兴趣。不需要有 Docker 容器的先前经验。

强烈建议使用安装了 Windows 10 专业版或 macOS 的计算机。计算机应至少具有 16GB 内存。

书中涵盖的软件/硬件 操作系统要求
Docker for Desktop, Docker Toolbox, Visual Studio Code, Powershell 或 Bash 终端。 Windows 10 Pro/macOS/Linux 至少 8GB RAM

如果您使用本书的数字版本,我们建议您自己输入代码或通过 GitHub 仓库访问代码(链接在下一节中提供)。这样做将有助于避免与复制/粘贴代码相关的任何潜在错误。

下载示例代码文件

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

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

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

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

  3. 点击“代码下载”。

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

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

  • WinRAR/7-Zip 适用于 Windows

  • Zipeg/iZip/UnRarX 适用于 Mac

  • 7-Zip/PeaZip 适用于 Linux

该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Learn-Docker---Fundamentals-of-Docker-19.x-Second-Edition。如果代码有更新,将在现有的 GitHub 仓库上进行更新。

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

下载彩色图片

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

使用的约定

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

CodeInText:指示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。这是一个例子:"Docker 主机上的容器运行时由 containerdrunc 组成。"

代码块设置如下:

{
  "name": "api",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC"
}

当我们希望引起您对代码块的特定部分的注意时,相关行或项目将以粗体显示:

ARG BASE_IMAGE_VERSION=12.7-stretch
FROM node:${BASE_IMAGE_VERSION}
WORKDIR /app
COPY packages.json .
RUN npm install
COPY . .
CMD npm start

任何命令行输入或输出都是这样写的:

$ /usr/bin/ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"

粗体:表示一个新术语,一个重要的词,或者你在屏幕上看到的词。例如,菜单或对话框中的单词会在文本中以这种方式出现。这是一个例子:“从管理面板中选择系统信息。”

警告或重要提示会这样出现。提示和技巧会这样出现。

第一部分:动机和入门

第一部分的目标是向您介绍容器的概念,并解释为什么它们在软件行业中如此有用。您还将为使用 Docker 准备您的工作环境。

本节包括以下章节:

  • 第一章,什么是容器,为什么我应该使用它们?

  • 第二章,设置工作环境

第一章:什么是容器,为什么我应该使用它们?

本章将向您介绍容器及其编排的世界。本书从最基础的知识开始,假设您对容器没有先前的了解,并将为您提供一个非常实用的主题介绍。

在本章中,我们将关注软件供应链及其中的摩擦。然后,我们将介绍容器,这些容器用于减少这种摩擦,并在其上添加企业级安全性。我们还将探讨容器及其生态系统是如何组装的。我们将特别指出上游开源软件(OSS)组件的区别,这些组件统一在代号 Moby 下,构成了 Docker 和其他供应商的下游产品的基本组成部分。

本章涵盖以下主题:

  • 什么是容器?

  • 为什么容器很重要?

  • 对我或我的公司有什么好处?

  • Moby 项目

  • Docker 产品

  • 容器架构

完成本模块后,您将能够做到以下事项:

  • 用类似于物理容器的类比,向感兴趣的普通人解释容器是什么,用简单的几句话

  • 用类似于物理容器与传统船运或公寓与独栋住宅等的类比来证明容器为何如此重要,以便向感兴趣的普通人解释

  • 至少列出四个上游开源组件,这些组件被 Docker 产品使用,比如 Docker for Desktop

  • 至少识别三个 Docker 产品

什么是容器?

软件容器是一个相当抽象的东西,因此,如果我们从一个对大多数人来说应该很熟悉的类比开始,可能会有所帮助。这个类比是运输行业中的集装箱。在历史上,人们一直通过各种方式从一个地方运输货物到另一个地方。在轮子发明之前,货物很可能是由人们自己的肩膀上的袋子、篮子或箱子运输的,或者他们可能使用驴、骆驼或大象等动物来运输它们。

随着轮子的发明,交通变得更加高效,因为人类修建了可以沿着道路移动他们的车辆。一次可以运输更多的货物。当第一台蒸汽驱动的机器,以及后来的汽油驱动引擎被引入时,交通变得更加强大。现在我们通过火车、船舶和卡车运输大量货物。与此同时,货物的种类变得越来越多样化,有时处理起来也更加复杂。

在这几千年的时间里,有一件事情没有改变,那就是在目的地卸货,也许将它们装载到另一种交通工具上的必要性。例如,一个农民将满满一车的苹果运到中央火车站,然后这些苹果与其他农民的苹果一起装上火车。或者想象一下,一位酿酒师用卡车将他的酒桶运到港口,然后卸货,然后转移到一艘将它们运往海外的船上。

这种从一种交通工具卸货,然后装载到另一种交通工具的过程是非常复杂和繁琐的。每种产品都以自己的方式包装,因此必须以自己的特定方式处理。此外,散装货物面临着被不道德的工人偷窃或在处理过程中受损的风险。

然后,集装箱出现了,它们彻底改变了运输行业。集装箱只是一个标准尺寸的金属箱子。每个集装箱的长度、宽度和高度都是相同的。这是一个非常重要的点。如果世界没有就一个标准尺寸达成一致,整个集装箱的事情就不会像现在这样成功。

现在,有了标准化的集装箱,想要将货物从 A 运送到 B 的公司将这些货物打包进这些集装箱中。然后,他们会联系一家船运公司,该公司配备了标准化的运输工具。这可以是一辆可以装载集装箱的卡车,或者每个运输一个或多个集装箱的火车车厢。最后,我们有专门运输大量集装箱的船只。船运公司永远不需要拆包和重新包装货物。对于船运公司来说,集装箱只是一个黑匣子,他们对其中的内容不感兴趣,在大多数情况下也不应该关心。它只是一个具有标准尺寸的大铁箱。现在,将货物打包进集装箱完全交给了想要运输货物的各方,他们应该知道如何处理和打包这些货物。

由于所有集装箱都具有相同的约定形状和尺寸,船公司可以使用标准化工具来处理集装箱;也就是说,用于卸载集装箱的起重机,比如从火车或卡车上卸载集装箱,并将其装上船舶,反之亦然。一种类型的起重机足以处理随时间而来的所有集装箱。此外,运输工具也可以标准化,比如集装箱船、卡车和火车。

由于所有这些标准化,围绕货物运输的所有流程也可以标准化,因此比集装箱时代之前的货物运输更加高效。

现在,你应该对为什么集装箱如此重要以及为什么它们彻底改变了整个运输行业有了很好的理解。我特意选择了这个类比,因为我们要在这里介绍的软件容器在所谓的软件供应链中扮演着与集装箱在实物货物供应链中扮演的完全相同的角色。

在过去,开发人员会开发一个新的应用程序。一旦他们认为该应用程序已经完成,他们会将该应用程序交给运维工程师,然后运维工程师应该在生产服务器上安装它并使其运行。如果运维工程师幸运的话,他们甚至可以从开发人员那里得到一份相对准确的安装说明文档。到目前为止,一切都很顺利,生活也很容易。

但当一个企业中有许多开发团队创建了完全不同类型的应用程序,但所有这些应用程序都需要安装在同一生产服务器上并在那里运行时,情况就有点失控了。通常,每个应用程序都有一些外部依赖项,比如它是基于哪个框架构建的,它使用了哪些库等等。有时,两个应用程序使用相同的框架,但是版本不同,这些版本可能与彼此兼容,也可能不兼容。我们的运维工程师的工作变得越来越困难。他们必须非常有创意地想办法在不破坏任何东西的情况下,将不同的应用程序加载到他们的船上(服务器)上。

现在安装某个应用程序的新版本已经成为一个复杂的项目,通常需要数月的规划和测试。换句话说,在软件供应链中存在很多摩擦。但如今,公司越来越依赖软件,发布周期需要变得越来越短。我们不能再负担得起每年只发布两次或更少的情况了。应用程序需要在几周或几天内进行更新,有时甚至一天内进行多次更新。不遵守这一点的公司会因缺乏灵活性而面临倒闭的风险。那么,解决方案是什么呢?

最初的方法之一是使用虚拟机VMs)。公司不再在同一台服务器上运行多个应用程序,而是将单个应用程序打包并在每个虚拟机上运行。这样一来,所有的兼容性问题都消失了,生活似乎又变得美好起来。不幸的是,这种幸福感并没有持续多久。虚拟机本身就非常庞大,因为它们都包含了一个完整的操作系统,比如 Linux 或 Windows Server,而这一切只是为了运行一个应用程序。这就好像在运输行业中,你使用整艘船只是为了运输一车香蕉。多么浪费!这是永远不可能盈利的。

这个问题的最终解决方案是提供比虚拟机更轻量级的东西,但也能完美地封装需要传输的货物。在这里,货物是由我们的开发人员编写的实际应用程序,以及 - 这一点很重要 - 应用程序的所有外部依赖项,例如其框架、库、配置等。这种软件打包机制的圣杯就是 Docker 容器

开发人员使用 Docker 容器将他们的应用程序,框架和库打包到其中,然后将这些容器发送给测试人员或运维工程师。对于测试人员和运维工程师来说,容器只是一个黑匣子。尽管如此,它是一个标准化的黑匣子。所有容器,无论其中运行什么应用程序,都可以被平等对待。工程师们知道,如果他们的服务器上运行任何容器,那么任何其他容器也应该运行。这实际上是真的,除了一些边缘情况,这种情况总是存在的。

因此,Docker 容器是一种以标准化方式打包应用程序及其依赖项的手段。Docker 随后创造了短语构建,交付和在任何地方运行

为什么容器很重要?

如今,应用程序发布之间的时间变得越来越短,但软件本身并没有变得更简单。相反,软件项目的复杂性增加了。因此,我们需要一种方法来驯服野兽并简化软件供应链。

此外,我们每天都听说网络攻击正在上升。许多知名公司受到了安全漏洞的影响。在这些事件中,高度敏感的客户数据被盗,如社会安全号码,信用卡信息等。但不仅仅是客户数据受到了损害 - 敏感的公司机密也被窃取。

容器可以在许多方面提供帮助。首先,Gartner 发现在容器中运行的应用程序比不在容器中运行的应用程序更安全。容器使用 Linux 安全原语,如 Linux 内核命名空间来隔离在同一台计算机上运行的不同应用程序,以及控制组cgroups)以避免嘈杂邻居问题,即一个糟糕的应用程序使用服务器的所有可用资源并使所有其他应用程序陷入困境。

由于容器图像是不可变的,很容易对其进行扫描以查找常见漏洞和暴露CVEs),从而提高我们应用程序的整体安全性。

另一种使我们的软件供应链更加安全的方法是让我们的容器使用内容信任。内容信任基本上确保容器图像的作者是他们所声称的,并且容器图像的消费者有保证图像在传输过程中没有被篡改。后者被称为中间人攻击MITM)。

当然,我刚才说的一切在没有使用容器的情况下也是技术上可能的,但是由于容器引入了一个全球公认的标准,它们使得实施这些最佳实践并强制执行它们变得更加容易。

好吧,但安全性并不是容器重要的唯一原因。还有其他原因。

一个原因是容器使得在开发人员的笔记本电脑上轻松模拟类似生产环境。如果我们可以将任何应用程序容器化,那么我们也可以将诸如 Oracle 或 MS SQL Server 之类的数据库容器化。现在,每个曾经在计算机上安装 Oracle 数据库的人都知道这并不是一件容易的事情,而且会占用大量宝贵的空间。你不会想要在你的开发笔记本上做这件事,只是为了测试你开发的应用程序是否真的能够端到端地工作。有了容器,我们可以像说 123 一样轻松地在容器中运行一个完整的关系型数据库。当测试完成后,我们可以停止并删除容器,数据库就会消失,不会在我们的计算机上留下任何痕迹。

由于容器与虚拟机相比非常精简,因此在开发人员的笔记本电脑上同时运行多个容器而不会使笔记本电脑不堪重负并不罕见。

容器之所以重要的第三个原因是,运营商最终可以集中精力做他们真正擅长的事情:提供基础设施、运行和监控生产中的应用程序。当他们需要在生产系统上运行的应用程序都被容器化时,运营商可以开始标准化他们的基础设施。每台服务器都只是另一个 Docker 主机。这些服务器上不需要安装特殊的库或框架,只需要一个操作系统和一个像 Docker 这样的容器运行时。

此外,运营商不再需要对应用程序的内部有深入的了解,因为这些应用程序在容器中自包含,对他们来说应该看起来像黑匣子一样,类似于运输行业的人员看待集装箱的方式。

对我或我的公司有什么好处?

有人曾经说过,今天,每家规模一定的公司都必须承认他们需要成为一家软件公司。从这个意义上讲,现代银行是一家专门从事金融业务的软件公司。软件驱动着所有的业务。随着每家公司都成为了一家软件公司,就需要建立一个软件供应链。为了保持竞争力,他们的软件供应链必须安全高效。通过彻底的自动化和标准化,可以实现效率。但在安全、自动化和标准化这三个领域,容器已经被证明是非常出色的。一些大型知名企业已经报告说,当他们将现有的传统应用程序(许多人称之为传统应用程序)容器化,并建立基于容器的完全自动化软件供应链时,他们可以将这些关键应用程序的维护成本降低 50%至 60%,并且可以将这些传统应用程序的新版本发布时间缩短 90%。

也就是说,采用容器技术可以为这些公司节省大量资金,同时加快开发过程并缩短上市时间。

Moby 项目

最初,当 Docker(公司)推出 Docker 容器时,一切都是开源的。当时 Docker 没有任何商业产品。公司开发的 Docker 引擎是一个庞大的软件单体。它包含许多逻辑部分,如容器运行时、网络库、RESTful(REST)API、命令行界面等等。

其他供应商或项目,如红帽或 Kubernetes,都在他们自己的产品中使用 Docker 引擎,但大多数情况下,他们只使用了其部分功能。例如,Kubernetes 没有使用 Docker 引擎的网络库,而是提供了自己的网络方式。红帽则不经常更新 Docker 引擎,而更倾向于对旧版本的 Docker 引擎应用非官方的补丁,但他们仍然称之为 Docker 引擎。

出于这些原因以及许多其他原因,出现了这样一个想法,即 Docker 必须做一些事情,以清楚地将 Docker 开源部分与 Docker 商业部分分开。此外,公司希望阻止竞争对手利用和滥用 Docker 这个名字来谋取自己的利益。这就是 Moby 项目诞生的主要原因。它作为 Docker 开发和继续开发的大多数开源组件的总称。这些开源项目不再带有 Docker 的名称。

Moby 项目提供了用于图像管理、秘密管理、配置管理和网络和配置等的组件,仅举几例。此外,Moby 项目的一部分是特殊的 Moby 工具,例如用于将组件组装成可运行的工件。

从技术上属于 Moby 项目的一些组件已经被 Docker 捐赠给了云原生计算基金会(CNCF),因此不再出现在组件列表中。最突出的是notarycontainerdrunc,其中第一个用于内容信任,后两者形成容器运行时。

Docker 产品

Docker 目前将其产品线分为两个部分。有社区版CE),它是闭源的,但完全免费,然后还有企业版EE),它也是闭源的,需要按年度许可。这些企业产品得到 24/7 支持,并得到错误修复的支持。

Docker CE

Docker 社区版的一部分是产品,如 Docker 工具箱和适用于 Mac 和 Windows 的 Docker 桌面版。所有这些产品主要面向开发人员。

Docker 桌面版是一个易于安装的桌面应用程序,可用于在 macOS 或 Windows 机器上构建、调试和测试 Docker 化的应用程序或服务。Docker for macOS 和 Docker for Windows 是与各自的虚拟化框架、网络和文件系统深度集成的完整开发环境。这些工具是在 Mac 或 Windows 上运行 Docker 的最快、最可靠的方式。

在 CE 的总称下,还有两个更偏向于运维工程师的产品。这些产品是 Docker for Azure 和 Docker for AWS。

例如,对于 Docker for Azure,这是一个本地 Azure 应用程序,您可以通过几次点击设置 Docker,优化并与底层 Azure 基础设施即服务IaaS)服务集成。它帮助运维工程师在 Azure 中构建和运行 Docker 应用程序时加快生产力。

Docker for AWS 的工作方式非常类似,但适用于亚马逊的云。

Docker EE

Docker 企业版由Universal Control PlaneUCP)和Docker Trusted RegistryDTR)组成,两者都运行在 Docker Swarm 之上。两者都是 Swarm 应用程序。Docker EE 基于 Moby 项目的上游组件,并添加了企业级功能,如基于角色的访问控制RBAC)、多租户、混合 Docker Swarm 和 Kubernetes 集群、基于 Web 的 UI 和内容信任,以及顶部的镜像扫描。

容器架构

现在,让我们讨论一下高层次上如何设计一个能够运行 Docker 容器的系统。以下图表说明了安装了 Docker 的计算机的外观。请注意,安装了 Docker 的计算机通常被称为 Docker 主机,因为它可以运行或托管 Docker 容器:

Docker 引擎的高级架构图

在上图中,我们可以看到三个基本部分:

  • 在底部,我们有Linux 操作系统

  • 在中间,深灰色部分,我们有容器运行时

  • 在顶部,我们有Docker 引擎

容器之所以可能,是因为 Linux 操作系统提供了一些原语,比如命名空间、控制组、层功能等,所有这些都是由容器运行时和 Docker 引擎以非常特定的方式利用的。Linux 内核的命名空间,比如进程 IDpid)命名空间或网络net)命名空间,允许 Docker 封装或隔离在容器内运行的进程。控制组确保容器不会遭受嘈杂邻居综合症,即运行在容器中的单个应用程序可能会消耗整个 Docker 主机的大部分或全部可用资源。控制组允许 Docker 限制每个容器分配的资源,比如 CPU 时间或内存量。

Docker 主机上的容器运行时由 containerdrunc 组成。runc 是容器运行时的低级功能,而基于 runccontainerd 提供了更高级的功能。两者都是开源的,并且已由 Docker 捐赠给 CNCF。

容器运行时负责容器的整个生命周期。如果需要,它会从注册表中拉取容器镜像(这是容器的模板),从该镜像创建容器,初始化和运行容器,最终在系统中停止并删除容器。

Docker 引擎提供了容器运行时的附加功能,例如网络库或插件支持。它还提供了一个 REST 接口,通过该接口可以自动化所有容器操作。我们将在本书中经常使用的 Docker 命令行界面是这个 REST 接口的消费者之一。

总结

在本章中,我们看到容器如何大大减少了软件供应链中的摩擦,并且使供应链更加安全。

在下一章中,我们将学习如何准备我们的个人或工作环境,以便我们可以高效有效地使用 Docker。所以,请继续关注。

问题

请回答以下问题,以评估您的学习进度:

  1. 哪些陈述是正确的(可以有多个答案)?

A. 一个容器就像一个轻量级的虚拟机

B. 一个容器只能在 Linux 主机上运行

C. 一个容器只能运行一个进程

D. 容器中的主进程始终具有 PID 1

E. 一个容器是由 Linux 命名空间封装的一个或多个进程,并受 cgroups 限制

  1. 用自己的话,可能通过类比,解释什么是容器。

  2. 为什么容器被认为是 IT 领域的一个改变者?列出三到四个原因。

  3. 当我们声称:如果一个容器在给定平台上运行,那么它就可以在任何地方运行... 时,这意味着什么?列出两到三个原因,说明为什么这是真的。

  4. Docker 容器只对基于微服务的现代绿地应用程序真正有用。请证明你的答案。

A. True

B. False

  1. 当企业将其传统应用程序容器化时,通常可以节省多少成本?

A. 20%

B. 33%

C. 50%

D. 75%

  1. Linux 容器基于哪两个核心概念?

进一步阅读

以下是一些链接列表,这些链接可以带您了解本章讨论的主题的更详细信息:

第二章:设置工作环境

在上一章中,我们了解了 Docker 容器是什么,以及它们为什么重要。我们了解了容器在现代软件供应链中解决了哪些问题。

在这一章中,我们将准备我们的个人或工作环境,以便与 Docker 高效有效地工作。我们将详细讨论如何为开发人员、DevOps 和运维人员设置一个理想的环境,用于使用 Docker 容器时的工作。

本章涵盖以下主题:

  • Linux 命令 shell

  • Windows 的 PowerShell

  • 安装和使用软件包管理器

  • 安装 Git 并克隆代码存储库

  • 选择并安装代码编辑器

  • 在 macOS 或 Windows 上安装 Docker 桌面版

  • 安装 Docker 工具箱

  • 安装 Minikube

技术要求

对于本章,您将需要一台装有 macOS 或 Windows 的笔记本电脑或工作站,最好是安装了 Windows 10 专业版。您还应该有免费的互联网访问权限来下载应用程序,并且有权限在您的笔记本电脑上安装这些应用程序。

如果您的操作系统是 Linux 发行版,如 Ubuntu 18.04 或更新版本,也可以按照本书进行。我会尽量指出命令和示例与 macOS 或 Windows 上的命令有明显不同的地方。

Linux 命令 shell

Docker 容器最初是在 Linux 上为 Linux 开发的。因此,用于与 Docker 一起工作的主要命令行工具,也称为 shell,是 Unix shell;请记住,Linux 源自 Unix。大多数开发人员使用 Bash shell。在一些轻量级的 Linux 发行版中,如 Alpine,Bash 未安装,因此必须使用更简单的 Bourne shell,简称为sh。每当我们在 Linux 环境中工作,比如在容器内或 Linux 虚拟机上,我们将使用/bin/bash/bin/sh,具体取决于它们的可用性。

虽然苹果的 macOS X 不是 Linux 操作系统,但 Linux 和 macOS X 都是 Unix 的变种,因此支持相同的工具集。其中包括 shell。因此,在 macOS 上工作时,您可能会使用 Bash shell。

在本书中,我们期望您熟悉 Bash 和 PowerShell 中最基本的脚本命令。如果您是一个绝对的初学者,我们强烈建议您熟悉以下备忘单:

Windows 的 PowerShell

在 Windows 计算机、笔记本电脑或服务器上,我们有多个命令行工具可用。最熟悉的是命令行。几十年来,它一直可用于任何 Windows 计算机。它是一个非常简单的 shell。对于更高级的脚本编写,微软开发了 PowerShell。PowerShell 非常强大,在 Windows 上的工程师中非常受欢迎。最后,在 Windows 10 上,我们有所谓的Windows 子系统用于 Linux,它允许我们使用任何 Linux 工具,比如 Bash 或 Bourne shell。除此之外,还有其他工具可以在 Windows 上安装 Bash shell,例如 Git Bash shell。在本书中,所有命令都将使用 Bash 语法。大多数命令也可以在 PowerShell 中运行。

因此,我们建议您使用 PowerShell 或任何其他 Bash 工具来在 Windows 上使用 Docker。

使用软件包管理器

在 macOS 或 Windows 笔记本上安装软件的最简单方法是使用一个好的软件包管理器。在 macOS 上,大多数人使用 Homebrew,在 Windows 上,Chocolatey 是一个不错的选择。如果你使用的是像 Ubuntu 这样的基于 Debian 的 Linux 发行版,那么大多数人选择的软件包管理器是默认安装的apt

在 macOS 上安装 Homebrew

Homebrew 是 macOS 上最流行的软件包管理器,易于使用且非常多功能。在 macOS 上安装 Homebrew 很简单;只需按照brew.sh/上的说明操作即可:

  1. 简而言之,打开一个新的终端窗口并执行以下命令来安装 Homebrew:
$ /usr/bin/ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"
  1. 安装完成后,通过在终端中输入brew --version来测试 Homebrew 是否正常工作。你应该会看到类似这样的内容:
$ brew --version
Homebrew 2.1.4
Homebrew/homebrew-core (git revision 77d1b; last commit 2019-06-07)
  1. 现在,我们准备使用 Homebrew 来安装工具和实用程序。例如,如果我们想要安装 Vi 文本编辑器,可以这样做:
$ brew install vim

这将为您下载并安装编辑器。

在 Windows 上安装 Chocolatey

Chocolatey 是 Windows 上基于 PowerShell 的流行软件包管理器。要安装 Chocolatey 软件包管理器,请按照chocolatey.org/上的说明操作,或者以管理员模式打开一个新的 PowerShell 窗口并执行以下命令:

PS> Set-ExecutionPolicy Bypass -Scope Process -Force; iex ((New-Object System.Net.WebClient).DownloadString('https://chocolatey.org/install.ps1'))

重要的是要以管理员身份运行上述命令,否则安装将不会成功。

  1. 一旦安装了 Chocolatey,请使用choco --version命令进行测试。你应该看到类似以下的输出:
PS> choco --version
0.10.15
  1. 要安装一个应用程序,比如 Vi 编辑器,使用以下命令:
PS> choco install -y vim

-y参数确保安装过程不需要再次确认。

请注意,一旦 Chocolatey 安装了一个应用程序,你需要打开一个新的 PowerShell 窗口来使用该应用程序。

安装 Git

我们正在使用 Git 从其 GitHub 存储库中克隆伴随本书的示例代码。如果你的计算机上已经安装了 Git,你可以跳过这一部分:

  1. 要在 macOS 上安装 Git,请在终端窗口中使用以下命令:
$ choco install git
  1. 要在 Windows 上安装 Git,请打开 PowerShell 窗口并使用 Chocolatey 进行安装:
PS> choco install git -y
  1. 最后,在你的 Debian 或 Ubuntu 机器上,打开一个 Bash 控制台并执行以下命令:
$ sudo apt update && sudo apt install -y git
  1. 安装完 Git 后,验证它是否正常工作。在所有平台上,使用以下命令:
$ git --version

这应该输出类似以下内容的东西:

git version 2.16.3
  1. 现在 Git 正常工作了,我们可以从 GitHub 上克隆伴随本书的源代码。执行以下命令:
$ cd ~
$ git clone https://github.com/PacktPublishing/Learn-Docker---Fundamentals-of-Docker-19.x-Second-Edition fod-solution

这将把主分支的内容克隆到你的本地文件夹~/fod-solution中。现在这个文件夹将包含我们在本书中一起做的所有实验的示例解决方案。如果遇到困难,请参考这些示例解决方案。

现在我们已经安装了基础知识,让我们继续使用代码编辑器。

选择一个代码编辑器

使用一个好的代码编辑器对于高效地使用 Docker 是至关重要的。当然,哪个编辑器是最好的是非常有争议的,取决于个人偏好。很多人使用 Vim,或者其他一些编辑器,比如 Emacs,Atom,Sublime,或者 Visual Studio Code(VS Code),只是举几个例子。VS Code 是一个完全免费且轻量级的编辑器,但它非常强大,并且适用于 macOS,Windows 和 Linux。根据 Stack Overflow 的数据,它目前是迄今为止最受欢迎的代码编辑器。如果你还没有决定使用其他编辑器,我强烈建议你试试 VS Code。

但是,如果您已经有一个喜欢的代码编辑器,请继续使用它。只要您可以编辑文本文件,就可以继续前进。如果您的编辑器支持 Dockerfiles 和 JSON 和 YAML 文件的语法高亮显示,那就更好了。唯一的例外将是[第六章](b6647803-2c5c-4b9d-9a4a-a836ac356329.xhtml),在容器中运行的代码调试。该章节中呈现的示例将大量定制为 VS Code。

在 macOS 上安装 VS Code

按照以下步骤进行安装:

  1. 打开一个新的终端窗口并执行以下命令:
$ brew cask install visual-studio-code
  1. 一旦 VS Code 安装成功,转到您的主目录(~)并创建一个名为fundamentals-of-docker的文件夹;然后进入这个新文件夹:
$ mkdir ~/fundamentals-of-docker && cd ~/fundamentals-of-docker
  1. 现在从这个文件夹中打开 VS Code:
$ code .

不要忘记前面命令中的句号(.)。VS 将启动并打开当前文件夹(~/fundamentals-of-docker)作为工作文件夹。

在 Windows 上安装 VS Code

按照以下步骤进行安装:

  1. 以管理员模式打开一个新的 PowerShell 窗口并执行以下命令:
PS> choco install vscode -y
  1. 关闭您的 PowerShell 窗口并打开一个新窗口,以确保 VS Code 在您的路径中。

  2. 现在转到您的主目录并创建一个名为fundamentals-of-docker的文件夹;然后进入这个新文件夹:

PS> mkdir ~\fundamentals-of-docker; cd ~\fundamentals-of-docker
  1. 最后,从这个文件夹中打开 Visual Studio Code:
PS> code .

不要忘记前面命令中的句号(.)。VS 将启动并打开当前文件夹(~\fundamentals-of-docker)作为工作文件夹。

在 Linux 上安装 VS Code

按照以下步骤进行安装:

  1. 在您的 Debian 或基于 Ubuntu 的 Linux 机器上,打开 Bash 终端并执行以下语句以安装 VS Code:
$ sudo snap install --classic code
  1. 如果您使用的是不基于 Debian 或 Ubuntu 的 Linux 发行版,请按照以下链接获取更多详细信息:code.visualstudio.com/docs/setup/linux

  2. 一旦 VS Code 安装成功,转到您的主目录(~)并创建一个名为fundamentals-of-docker的文件夹;然后进入这个新文件夹:

$ mkdir ~/fundamentals-of-docker && cd ~/fundamentals-of-docker
  1. 现在从这个文件夹中打开 Visual Studio Code:
$ code .

不要忘记前面命令中的句号(.)。VS 将启动并打开当前文件夹(~/fundamentals-of-docker)作为工作文件夹。

安装 VS Code 扩展

扩展是使 VS Code 成为如此多才多艺的编辑器的原因。在 macOS、Windows 和 Linux 三个平台上,您可以以相同的方式安装 VS Code 扩展:

  1. 打开 Bash 控制台(或 Windows 中的 PowerShell),并执行以下一组命令,以安装我们将在本书中的示例中使用的最基本的扩展:
code --install-extension vscjava.vscode-java-pack
code --install-extension ms-vscode.csharp
code --install-extension ms-python.python
code --install-extension ms-azuretools.vscode-docker
code --install-extension eamodio.gitlens

我们正在安装一些扩展,使我们能够更加高效地使用 Java、C#、.NET 和 Python。我们还安装了一个扩展,用于增强我们与 Docker 的体验。

  1. 在成功安装了上述扩展之后,重新启动 VS Code 以激活这些扩展。现在您可以点击 VS Code 左侧活动面板上的扩展图标,查看所有已安装的扩展。

接下来,让我们安装 Docker 桌面版。

安装 Docker 桌面版

如果您使用的是 macOS,或者在笔记本电脑上安装了 Windows 10 专业版,则我们强烈建议您安装 Docker 桌面版。这个平台在使用容器时会给您最好的体验。

目前,Docker 桌面版不支持 Linux。有关更多详细信息,请参阅在 Linux 上安装 Docker CE部分。请注意,旧版本的 Windows 或 Windows 10 家庭版无法运行 Docker for Windows。Docker for Windows 使用 Hyper-V 在虚拟机中透明地运行容器,但是 Hyper-V 在旧版本的 Windows 上不可用;在 Windows 10 家庭版中也不可用。在这种情况下,我们建议您使用 Docker Toolbox,我们将在下一节中描述。按照以下步骤进行操作:

  1. 无论您使用什么操作系统,都可以导航到 Docker 的起始页面www.docker.com/get-started

  2. 在加载的页面右侧,您会找到一个大大的蓝色按钮,上面写着 Download Desktop and Take a Tutorial。点击这个按钮并按照说明进行操作。您将被重定向到 Docker Hub。如果您还没有 Docker Hub 账户,请创建一个。这是完全免费的,但您需要一个账户来下载软件。否则,只需登录即可。

  3. 一旦您登录,注意页面上的以下内容:

在 Docker Hub 上下载 Docker 桌面版

  1. 点击蓝色的 Download Docker Desktop 按钮。然后您应该会看到以下屏幕:

在 macOS 上下载 Docker 桌面版的屏幕提示请注意,如果您使用的是 Windows PC,蓝色按钮将会显示为 Download Docker Desktop for Windows。

在 macOS 上安装 Docker 桌面版

按照以下步骤进行安装:

  1. 安装成功 Docker 桌面版后,请打开终端窗口并执行以下命令:
$ docker version

您应该看到类似于这样的东西:

Docker 桌面版的 Docker 版本

  1. 要查看是否可以运行容器,请在终端窗口中输入以下命令并按 Enter 键:
$ docker run hello-world

如果一切顺利,您的输出应该看起来类似于以下内容:

在 macOS 上的 Docker 桌面版上运行 Hello-World

接下来,我们将在 Windows 上安装 Docker。

在 Windows 上安装 Docker 桌面版

按照以下步骤进行安装:

  1. 安装成功 Docker 桌面版后,请打开 PowerShell 窗口并执行以下命令:
PS> docker --version
Docker version 19.03.5, build 633a0ea
  1. 要查看是否可以运行容器,请在 PowerShell 窗口中输入以下命令并按 Enter 键:
PS> docker run hello-world

如果一切顺利,您的输出应该与前面的图像类似。

在 Linux 上安装 Docker CE

如前所述,Docker 桌面版仅适用于 macOS 和 Windows 10 专业版。如果您使用的是 Linux 机器,则可以使用 Docker 社区版(CE),其中包括 Docker 引擎以及一些附加工具,如 Docker 命令行界面(CLI)和 docker-compose。

请按照以下链接中的说明安装特定 Linux 发行版(在本例中为 Ubuntu)的 Docker CE:docs.docker.com/install/linux/docker-ce/ubuntu/

安装 Docker Toolbox

Docker Toolbox 已经为开发人员提供了几年。它是 Docker 桌面版等新工具的前身。Toolbox 允许用户在任何 macOS 或 Windows 计算机上非常优雅地使用容器。容器必须在 Linux 主机上运行。Windows 和 macOS 都无法本地运行容器。因此,我们需要在笔记本电脑上运行 Linux 虚拟机,然后在其中运行容器。Docker Toolbox 在笔记本电脑上安装 VirtualBox,用于运行我们需要的 Linux 虚拟机。

作为 Windows 用户,您可能已经意识到有所谓的 Windows 容器可以在 Windows 上本地运行,这一点您是正确的。微软已经将 Docker Engine 移植到了 Windows,并且可以在 Windows Server 2016 或更新版本上直接运行 Windows 容器,无需虚拟机。所以,现在我们有两种容器,Linux 容器和 Windows 容器。前者只能在 Linux 主机上运行,后者只能在 Windows 服务器上运行。在本书中,我们专门讨论 Linux 容器,但我们学到的大部分东西也适用于 Windows 容器。

如果您对 Windows 容器感兴趣,我们强烈推荐阅读《Docker on Windows, Second Edition》这本书:www.packtpub.com/virtualization-and-cloud/docker-windows-second-edition

让我们从在 macOS 上安装 Docker Toolbox 开始。

在 macOS 上安装 Docker Toolbox

按照以下步骤进行安装:

  1. 打开一个新的终端窗口,并使用 Homebrew 安装工具箱:
$ brew cask install docker-toolbox 

您应该会看到类似于这样的东西:

在 macOS 上安装 Docker Toolbox

  1. 要验证 Docker Toolbox 是否已成功安装,请尝试访问docker-machinedocker-compose,这两个工具是安装的一部分:
$ docker-machine --version
docker-machine version 0.15.0, build b48dc28d
$ docker-compose --version
docker-compose version 1.22.0, build f46880f

接下来,我们将在 Windows 上安装 Docker Toolbox。

在 Windows 上安装 Docker Toolbox

在管理员模式下打开一个新的 Powershell 窗口,并使用 Chocolatey 安装 Docker Toolbox:

PS> choco install docker-toolbox -y

输出应该类似于这样:

在 Windows 10 上安装 Docker Toolbox

我们现在将设置 Docker Toolbox。

设置 Docker Toolbox

按照以下步骤进行设置:

  1. 让我们使用docker-machine来设置我们的环境。首先,我们列出当前在系统上定义的所有 Docker-ready VM。如果您刚刚安装了 Docker Toolbox,您应该会看到以下输出:

所有 Docker-ready VM 的列表

  1. 好的,我们可以看到已经安装了一个名为default的单个 VM,但它目前处于stopped的状态。让我们使用docker-machine来启动这个 VM,这样我们就可以使用它了:
$ docker-machine start default

这将产生以下输出:

启动 Docker Toolbox 中的默认 VM

如果我们现在再次列出 VM,我们应该会看到这个:

列出 Docker Toolbox 中正在运行的 VM

在您的情况下使用的 IP 地址可能不同,但肯定会在192.168.0.0/24范围内。我们还可以看到 VM 安装了 Docker 版本18.06.1-ce

  1. 如果由于某种原因您没有默认的 VM,或者意外删除了它,可以使用以下命令创建它:
$ docker-machine create --driver virtualbox default 

这将生成以下输出:

在 Docker Toolbox 中创建一个新的默认 VM

如果仔细分析前面的输出,您会发现docker-machine自动从 Docker 下载了最新的 VM ISO 文件。它意识到我的当前版本已过时,并用版本v18.09.6替换了它。

  1. 要查看如何将 Docker 客户端连接到在此虚拟机上运行的 Docker 引擎,请运行以下命令:
$ docker-machine env default 

这将输出以下内容:

export DOCKER_TLS_VERIFY="1"
export DOCKER_HOST="tcp://192.168.99.100:2376"
export DOCKER_CERT_PATH="/Users/gabriel/.docker/machine/machines/default"
export DOCKER_MACHINE_NAME="default"
# Run this command to configure your shell:
# eval $(docker-machine env default)
  1. 我们可以执行前面代码片段中最后一行中列出的命令,来配置我们的 Docker CLI 以使用在default VM 上运行的 Docker:
$ eval $(docker-machine env default) 
  1. 现在我们可以执行第一个 Docker 命令:
$ docker version

这应该产生以下输出:

docker version 的输出

这里有两个部分,客户端和服务器部分。客户端是直接在您的 macOS 或 Windows 笔记本电脑上运行的 CLI,而服务器部分在 VirtualBox 中的default VM 上运行。

  1. 现在,让我们尝试运行一个容器:
$ docker run hello-world

这将产生以下输出:

前面的输出证实了 Docker Toolbox 正在按预期工作并且可以运行容器。

Docker Toolbox 是一个很好的补充,即使您通常使用 Docker Desktop 进行 Docker 开发。 Docker Toolbox 允许您在 VirtualBox 中创建多个 Docker 主机(或 VM),并将它们连接到集群,然后在其上运行 Docker Swarm 或 Kubernetes。

安装 Minikube

如果您无法使用 Docker Desktop,或者由于某种原因,您只能访问尚不支持 Kubernetes 的旧版本工具,则安装 Minikube 是一个好主意。 Minikube 在您的工作站上为单节点 Kubernetes 集群提供了支持,并且可以通过kubectl访问,这是用于处理 Kubernetes 的命令行工具。

在 macOS 和 Windows 上安装 Minikube

要安装 macOS 或 Windows 的 Minikube,请转到以下链接:kubernetes.io/docs/tasks/tools/install-minikube/

请仔细遵循说明。如果您已安装 Docker Toolbox,则系统上已经有一个 hypervisor,因为 Docker Toolbox 安装程序还安装了 VirtualBox。否则,我建议您先安装 VirtualBox。

如果您已安装了 macOS 或 Windows 的 Docker,则kubectl也已经安装了,因此您也可以跳过这一步。否则,请按照网站上的说明操作。

测试 Minikube 和 kubectl

一旦 Minikube 成功安装在您的工作站上,打开终端并测试安装。首先,我们需要启动 Minikube。在命令行输入minikube start。这个命令可能需要几分钟来完成。输出应该类似于以下内容:

启动 Minikube 注意,您的输出可能略有不同。在我的情况下,我正在 Windows 10 专业版计算机上运行 Minikube。在 Mac 上,通知会有所不同,但这里并不重要。

现在,输入kubectl version并按Enter,看到类似以下截图的内容:

确定 Kubernetes 客户端和服务器的版本

如果前面的命令失败,例如超时,那么可能是因为您的kubectl没有配置正确的上下文。kubectl可以用来处理许多不同的 Kubernetes 集群。每个集群称为一个上下文。要找出kubectl当前配置的上下文,使用以下命令:

$ kubectl config current-context
minikube

答案应该是minikube,如前面的输出所示。如果不是这种情况,请使用kubectl config get-contexts列出系统上定义的所有上下文,然后将当前上下文设置为minikube,如下所示:

$ kubectl config use-context minikube

kubectl的配置,它存储上下文,通常可以在~/.kube/config中找到,但这可以通过定义一个名为KUBECONFIG的环境变量来覆盖。如果您的计算机上设置了这个变量,您可能需要取消设置。

有关如何配置和使用 Kubernetes 上下文的更深入信息,请参考以下链接:kubernetes.io/docs/concepts/configuration/organize-cluster-access-kubeconfig/

假设 Minikube 和kubectl按预期工作,我们现在可以使用kubectl获取有关 Kubernetes 集群的信息。输入以下命令:

$ kubectl get nodes
NAME STATUS ROLES AGE VERSION
minikube Ready master 47d v1.17.3

显然,我们有一个节点的集群,在我的情况下,上面安装了Kubernetes v1.17.3

摘要

在本章中,我们设置和配置了我们的个人或工作环境,以便我们可以有效地使用 Docker 容器进行工作。这同样适用于开发人员、DevOps 和运维工程师。在这种情况下,我们确保使用一个好的编辑器,安装了 macOS 的 Docker 或 Windows 的 Docker,并且可以使用docker-machine在 VirtualBox 或 Hyper-V 中创建虚拟机,然后我们可以使用它来运行和测试容器。

在下一章中,我们将学习有关容器的所有重要知识。例如,我们将探讨如何运行、停止、列出和删除容器,但更重要的是,我们还将深入探讨容器的结构。

问题

根据您对本章的阅读,请回答以下问题:

  1. docker-machine用于什么?列举三到四个场景。

  2. 使用 Docker for Windows,您可以开发和运行 Linux 容器。

A. True

B. False

  1. 为什么良好的脚本技能(如 Bash 或 PowerShell)对于有效使用容器至关重要?

  2. 列出三到四个 Docker 认证可在其上运行的 Linux 发行版。

  3. 列出所有可以运行 Windows 容器的 Windows 版本。

进一步阅读

考虑以下链接以获取更多阅读材料:

第二部分:从初学者到黑带的容器化

在这一部分,您将掌握构建、运输和运行单个容器的所有基本方面。

本节包括以下章节:

  • 第三章,掌握容器

  • 第四章,创建和管理容器镜像

  • 第五章,数据卷和配置

  • 第六章,调试在容器中运行的代码

  • 第七章,使用 Docker 来加速自动化

  • 第八章,高级 Docker 使用场景

第三章:掌握容器

在上一章中,您学会了如何为高效和无摩擦地使用 Docker 准备您的工作环境。在本章中,我们将亲自动手,学习在使用容器时需要了解的一切重要内容。以下是本章我们将要涵盖的主题:

  • 运行第一个容器

  • 启动、停止和删除容器

  • 检查容器

  • 在运行的容器中执行

  • 附加到运行的容器

  • 检索容器日志

  • 容器的结构

完成本章后,您将能够做到以下几点:

  • 基于现有镜像(如 Nginx、BusyBox 或 Alpine)运行、停止和删除容器。

  • 列出系统上的所有容器。

  • 检查正在运行或已停止容器的元数据。

  • 检索在容器内运行的应用程序产生的日志。

  • 在已运行的容器中运行/bin/sh等进程。

  • 将终端连接到已运行的容器。

  • 用您自己的话向一个感兴趣的外行人解释容器的基础知识。

技术要求

本章中,您应该已经在您的 macOS 或 Windows PC 上安装了 Docker for Desktop。如果您使用的是较旧版本的 Windows 或者使用的是 Windows 10 家庭版,那么您应该已经安装并准备好使用 Docker Toolbox。在 macOS 上,请使用终端应用程序,在 Windows 上,请使用 PowerShell 或 Bash 控制台来尝试您将要学习的命令。

运行第一个容器

在我们开始之前,我们希望确保 Docker 已正确安装在您的系统上,并准备好接受您的命令。打开一个新的终端窗口,并输入以下命令:

$ docker version

如果您使用的是 Docker Toolbox,则请使用已与 Toolbox 一起安装的 Docker Quickstart 终端,而不是 macOS 上的终端或 Windows 上的 PowerShell。

如果一切正常,您应该在终端中看到安装在您的笔记本电脑上的 Docker 客户端和服务器的版本。在撰写本文时,它看起来是这样的(为了可读性而缩短):

Client: Docker Engine - Community
 Version: 19.03.0-beta3
 API version: 1.40
 Go version: go1.12.4
 Git commit: c55e026
 Built: Thu Apr 25 19:05:38 2019
 OS/Arch: darwin/amd64
 Experimental: false

Server: Docker Engine - Community
 Engine:
 Version: 19.03.0-beta3
 API version: 1.40 (minimum version 1.12)
 Go version: go1.12.4
 Git commit: c55e026
 Built: Thu Apr 25 19:13:00 2019
 OS/Arch: linux/amd64
 ...

您可以看到我在我的 macOS 上安装了19.03.0版本的beta3

如果这对您不起作用,那么您的安装可能有问题。请确保您已按照上一章中关于如何在您的系统上安装 Docker for Desktop 或 Docker Toolbox 的说明进行操作。

所以,您已经准备好看到一些操作了。请在您的终端窗口中输入以下命令并按Return键:

$ docker container run alpine echo "Hello World" 

当您第一次运行上述命令时,您应该在终端窗口中看到类似于这样的输出:

Unable to find image 'alpine:latest' locally
latest: Pulling from library/alpine
e7c96db7181b: Pull complete
Digest: sha256:769fddc7cc2f0a1c35abb2f91432e8beecf83916c421420e6a6da9f8975464b6
Status: Downloaded newer image for alpine:latest
Hello World

现在这很容易!让我们再次尝试运行完全相同的命令:

$ docker container run alpine echo "Hello World" 

第二次、第三次或第 n 次运行上述命令时,您应该在终端中只看到以下输出:

 Hello World  

尝试推理第一次运行命令时为什么会看到不同的输出,而所有后续次数都不同。但是如果您无法弄清楚,不要担心;我们将在本章的后续部分详细解释原因。

启动、停止和删除容器

在上一节中,您已成功运行了一个容器。现在,我们想详细调查到底发生了什么以及为什么。让我们再次看看我们使用的命令:

$ docker container run alpine echo "Hello World" 

这个命令包含多个部分。首先,我们有单词docker。这是 Docker命令行界面CLI)工具的名称,我们使用它与负责运行容器的 Docker 引擎进行交互。接下来是单词container,它表示我们正在处理的上下文。因为我们要运行一个容器,所以我们的上下文是container。接下来是我们要在给定上下文中执行的实际命令,即run

让我回顾一下——到目前为止,我们有docker container run,这意味着,“嘿,Docker,我们想要运行一个容器。”

现在我们还需要告诉 Docker 要运行哪个容器。在这种情况下,这就是所谓的alpine容器。

alpine 是一个基于 Alpine Linux 的最小 Docker 镜像,具有完整的软件包索引,大小仅为 5MB。

最后,我们需要定义在容器运行时应执行什么类型的进程或任务。在我们的情况下,这是命令的最后一部分,echo "Hello World"

也许以下截图可以帮助您更好地了解整个过程:

docker container run 表达式的解剖

现在我们已经了解了运行容器命令的各个部分,让我们尝试在容器中运行另一个不同的进程。在终端中键入以下命令:

$ docker container run centos ping -c 5 127.0.0.1

您应该在终端窗口中看到类似以下的输出:

Unable to find image 'centos:latest' locally
latest: Pulling from library/centos
8ba884070f61: Pull complete
Digest: sha256:b5e66c4651870a1ad435cd75922fe2cb943c9e973a9673822d1414824a1d0475
Status: Downloaded newer image for centos:latest
PING 127.0.0.1 (127.0.0.1) 56(84) bytes of data.
64 bytes from 127.0.0.1: icmp_seq=1 ttl=64 time=0.104 ms
64 bytes from 127.0.0.1: icmp_seq=2 ttl=64 time=0.059 ms
64 bytes from 127.0.0.1: icmp_seq=3 ttl=64 time=0.081 ms
64 bytes from 127.0.0.1: icmp_seq=4 ttl=64 time=0.050 ms
64 bytes from 127.0.0.1: icmp_seq=5 ttl=64 time=0.055 ms
--- 127.0.0.1 ping statistics ---
5 packets transmitted, 5 received, 0% packet loss, time 4127ms
rtt min/avg/max/mdev = 0.050/0.069/0.104/0.022 ms

改变的是,这次我们使用的容器镜像是centos,我们在centos容器内执行的进程是ping -c 5 127.0.0.1,它会对回环地址进行五次 ping 直到停止。

centos是 CentOS Linux 的官方 Docker 镜像,这是一个社区支持的发行版,源自Red HatRed Hat Enterprise LinuxRHEL)免费提供给公众的源代码。

让我们详细分析输出。

第一行如下:

Unable to find image 'centos:latest' locally

这告诉我们 Docker 在系统的本地缓存中没有找到名为centos:latest的镜像。因此,Docker 知道它必须从存储容器镜像的某个注册表中拉取镜像。默认情况下,您的 Docker 环境配置为从docker.io的 Docker Hub 拉取镜像。这由第二行表示,如下所示:

latest: Pulling from library/centos 

接下来的三行输出如下:

8ba884070f61: Pull complete
Digest: sha256:b5e66c4651870a1ad435cd75922fe2cb943c9e973a9673822d1414824a1d0475
Status: Downloaded newer image for centos:latest

这告诉我们 Docker 已成功从 Docker Hub 拉取了centos:latest镜像。

输出的所有后续行都是由我们在容器内运行的进程生成的,这种情况下是 Ping 工具。如果你到目前为止一直很注意,那么你可能已经注意到latest关键字出现了几次。每个镜像都有一个版本(也称为tag),如果我们不明确指定版本,那么 Docker 会自动假定它是latest

如果我们在我们的系统上再次运行前面的容器,输出的前五行将会丢失,因为这一次 Docker 会在本地找到容器镜像的缓存,因此不需要先下载它。试一试,验证我刚才告诉你的。

运行一个随机琐事问题容器

在本章的后续部分,我们需要一个在后台持续运行并产生一些有趣输出的容器。这就是为什么我们选择了一个产生随机琐事问题的算法。产生免费随机琐事的 API 可以在jservice.io/找到。

现在的目标是在容器内运行一个进程,每五秒产生一个新的随机琐事问题,并将问题输出到STDOUT。以下脚本将完全做到这一点:

while : 
do 
 wget -qO- http://jservice.io/api/random | jq .[0].question 
 sleep 5 
done

在终端窗口中尝试一下。通过按Ctrl+C来停止脚本。输出应该类似于这样:

"In 2004 Pitt alumna Wangari Maathai became the first woman from this continent to win the Nobel Peace Prize"
"There are 86,400 of these in every day"
"For $5 million in 2013 an L.A. movie house became TCL Chinese Theatre, but we bet many will still call it this, after its founder"
^C

每个响应都是一个不同的琐事问题。

您可能需要先在您的 macOS 或 Windows 计算机上安装jqjq是一个方便的工具,通常用于过滤和格式化 JSON 输出,这样可以增加屏幕上的可读性。

现在,让我们在一个alpine容器中运行这个逻辑。由于这不仅仅是一个简单的命令,我们想把前面的脚本包装在一个脚本文件中并执行它。为了简化事情,我创建了一个名为fundamentalsofdocker/trivia的 Docker 镜像,其中包含了所有必要的逻辑,这样我们就可以直接在这里使用它。稍后,一旦我们介绍了 Docker 镜像,我们将进一步分析这个容器镜像。暂时,让我们就这样使用它。执行以下命令将容器作为后台服务运行。在 Linux 中,后台服务也被称为守护进程:

$ docker container run -d --name trivia fundamentalsofdocker/trivia:ed2

在前面的表达式中,我们使用了两个新的命令行参数-d--name。现在,-d告诉 Docker 将在容器中运行的进程作为 Linux 守护进程运行。而--name参数则可以用来给容器指定一个显式的名称。在前面的示例中,我们选择的名称是trivia

如果我们在运行容器时没有指定显式的容器名称,那么 Docker 将自动为容器分配一个随机但唯一的名称。这个名称将由一个著名科学家的名字和一个形容词组成。这样的名称可能是boring_borgangry_goldberg。我们的 Docker 工程师们相当幽默,不是吗?

我们还在容器中使用标签ed2。这个标签只是告诉我们这个镜像是为本书的第二版创建的。

一个重要的要点是,容器名称在系统上必须是唯一的。让我们确保trivia容器正在运行:

$ docker container ls -l

这应该给我们类似于这样的东西(为了可读性而缩短):

CONTAINER ID  IMAGE                            ... CREATED         STATUS ...
0ff3d7cf7634  fundamentalsofdocker/trivia:ed2  ... 11 seconds ago  Up 9 seconds ...

前面输出的重要部分是STATUS列,本例中是Up 9 seconds。也就是说,容器已经运行了 9 秒钟。

如果最后一个 Docker 命令对您来说还不太熟悉,不要担心,我们将在下一节回到它。

完成本节,让我们停下来,使用以下命令停止并移除trivia容器:

$ docker rm -f trivia

现在是时候学习如何列出在我们的系统上运行或悬空的容器了。

列出容器

随着时间的推移,我们继续运行容器,我们的系统中会有很多容器。要找出当前在我们的主机上运行的是什么,我们可以使用container ls命令,如下所示:

$ docker container ls

这将列出所有当前正在运行的容器。这样的列表可能看起来类似于这样:

列出系统上所有正在运行的容器

默认情况下,Docker 输出七列,含义如下:

描述
容器 ID 这是容器的唯一 ID。它是一个 SHA-256。
镜像 这是实例化该容器的容器镜像的名称。
命令 这是用于在容器中运行主进程的命令。
创建时间 这是容器创建的日期和时间。
状态 这是容器的状态(已创建、重新启动、运行中、正在移除、暂停、已退出或已停止)。
端口 这是已映射到主机的容器端口列表。
名称 这是分配给该容器的名称(可以有多个名称)。

如果我们不仅想列出当前正在运行的容器,而是所有在系统上定义的容器,那么我们可以使用命令行参数-a--all,如下所示:

$ docker container ls -a

这将列出任何状态的容器,例如已创建运行中已退出

有时,我们只想列出所有容器的 ID。为此,我们有-q参数:

$ docker container ls -q

您可能会想知道这有什么用。我将在这里向您展示一个非常有用的命令:

$ docker container rm -f $(docker container ls -a -q)

往后倾斜,深呼吸。然后,尝试找出前面的命令是做什么的。在找到答案或放弃之前,请不要再往下读。

前面的命令会删除系统上当前定义的所有容器,包括已停止的容器。rm命令代表删除,很快就会解释。

在前面的部分,我们在列表命令中使用了-l参数。尝试使用 Docker 帮助找出-l参数代表什么。您可以按照以下方式调用列表命令的帮助:

$ docker container ls -h 

接下来,让我们学习如何停止和重新启动容器。

停止和启动容器

有时,我们想(暂时)停止一个运行中的容器。让我们尝试一下之前使用的 trivia 容器:

  1. 用这个命令再次运行容器:
$ docker container run -d --name trivia fundamentalsofdocker/trivia:ed2
  1. 现在,如果我们想要停止这个容器,我们可以通过发出这个命令来做到:
$ docker container stop trivia

当您尝试停止 trivia 容器时,您可能会注意到这个命令执行起来需要一段时间。确切地说,大约需要 10 秒。为什么会这样?

Docker 向容器内部运行的主进程发送 Linux SIGTERM信号。如果进程对此信号不做出反应并终止自身,Docker 将等待 10 秒,然后发送SIGKILL,这将强制终止进程并终止容器。

在前面的命令中,我们使用容器的名称来指定我们要停止的容器。但我们也可以使用容器 ID。

我们如何获取容器的 ID?有几种方法可以做到这一点。手动方法是列出所有运行中的容器,并在列表中找到我们要找的容器。然后,我们复制它的 ID。更自动化的方法是使用一些 shell 脚本和环境变量。例如,如果我们想要获取 trivia 容器的 ID,我们可以使用这个表达式:

$ export CONTAINER_ID=$(docker container ls -a | grep trivia | awk '{print $1}')

我们在 Docker container ls命令中使用-a参数来列出所有容器,即使是已停止的。在这种情况下是必要的,因为我们刚刚停止了 trivia 容器。

现在,我们可以在表达式中使用$CONTAINER_ID变量,而不是使用容器名称:

$ docker container stop $CONTAINER_ID 

一旦我们停止了容器,它的状态就会变为Exited

如果容器已停止,可以使用docker container start命令重新启动。让我们用 trivia 容器来做这个操作。让它再次运行是很好的,因为我们将在本章的后续部分中需要它:

$ docker container start trivia 

现在是时候讨论我们不再需要的已停止容器该怎么办了。

删除容器

当我们运行docker container ls -a命令时,我们可以看到相当多的容器处于Exited状态。如果我们不再需要这些容器,那么将它们从内存中删除是一件好事;否则,它们会不必要地占用宝贵的资源。删除容器的命令如下:

$ docker container rm <container ID>

另一个删除容器的命令如下:

$ docker container rm <container name>

尝试使用其 ID 删除一个已退出的容器。

有时,删除容器可能不起作用,因为它仍在运行。如果我们想要强制删除,无论容器当前的状态如何,我们可以使用命令行参数-f--force

检查容器

容器是镜像的运行时实例,并且具有许多特征其行为的关联数据。要获取有关特定容器的更多信息,我们可以使用inspect命令。通常情况下,我们必须提供容器 ID 或名称来标识我们想要获取数据的容器。因此,让我们检查我们的示例容器:

$ docker container inspect trivia 

响应是一个充满细节的大型 JSON 对象。它看起来类似于这样:

[
    {
        "Id": "48630a3bf188...",
        ...
        "State": {
            "Status": "running",
            "Running": true,
            ...
        },
        "Image": "sha256:bbc92c8f014d605...",
        ...
        "Mounts": [],
        "Config": {
            "Hostname": "48630a3bf188",
            "Domainname": "",
            ...
        },
        "NetworkSettings": {
            "Bridge": "",
            "SandboxID": "82aed83429263ceb6e6e...",
            ...
        }
    }
]

输出已经被缩短以便阅读。

请花一点时间分析你得到的信息。您应该看到诸如以下信息:

  • 容器的 ID

  • 容器的创建日期和时间

  • 构建容器的镜像

输出的许多部分,如MountsNetworkSettings,现在并没有太多意义,但我们肯定会在本书的后续章节中讨论这些内容。您在这里看到的数据也被称为容器的元数据。在本书的其余部分中,我们将经常使用inspect命令作为信息来源。

有时,我们只需要整体信息的一小部分,为了实现这一点,我们可以使用grep工具或过滤器。前一种方法并不总是得到预期的答案,所以让我们看看后一种方法:

$ docker container inspect -f "{{json .State}}" trivia | jq .

-f--filter参数用于定义过滤器。过滤器表达式本身使用 Go 模板语法。在这个例子中,我们只想以 JSON 格式看到整个输出中的状态部分。

为了使输出格式良好,我们将结果传输到jq工具中:

{
  "Status": "running",
  "Running": true,
  "Paused": false,
  "Restarting": false,
  "OOMKilled": false,
  "Dead": false,
  "Pid": 18252,
  "ExitCode": 0,
  "Error": "",
  "StartedAt": "2019-06-16T13:30:15.776272Z",
  "FinishedAt": "2019-06-16T13:29:38.6412298Z"
}

在我们学会如何检索有关容器的大量重要和有用的元信息之后,我们现在想调查如何在运行的容器中执行它。

在运行的容器中执行

有时,我们希望在已经运行的容器内运行另一个进程。一个典型的原因可能是尝试调试行为异常的容器。我们如何做到这一点?首先,我们需要知道容器的 ID 或名称,然后我们可以定义我们想要运行的进程以及我们希望它如何运行。再次,我们使用当前正在运行的 trivia 容器,并使用以下命令在其中交互式运行一个 shell:

$ docker container exec -i -t trivia /bin/sh

-i标志表示我们要交互式地运行附加进程,-t告诉 Docker 我们希望它为命令提供 TTY(终端仿真器)。最后,我们运行的进程是/bin/sh

如果我们在终端中执行上述命令,那么我们将看到一个新的提示符/app#。我们现在在 trivia 容器内的 shell 中。我们可以很容易地通过执行ps命令来证明这一点,该命令将列出上下文中所有正在运行的进程:

/app # ps

结果应该看起来与这个有些相似:

列出在 trivia 容器内运行的进程

我们可以清楚地看到,具有PID 1的进程是我们在 trivia 容器内定义的要运行的命令。具有PID 1的进程也被称为主进程。

通过按下Ctrl + D来离开容器。我们不仅可以在容器中交互地执行额外的进程。请考虑以下命令:

$ docker container exec trivia ps

输出显然与前面的输出非常相似。

列出在 trivia 容器内运行的进程

我们甚至可以使用-d标志以守护进程的形式运行进程,并使用-e标志变量定义环境变量,如下所示:

$ docker container exec -it \
 -e MY_VAR="Hello World" \
 trivia /bin/sh
/app # echo $MY_VAR
Hello World
/app # <CTRL-d>

很好,我们已经学会了如何进入一个正在运行的容器并运行额外的进程。但是还有另一种重要的方式可以与正在运行的容器交互。

附加到一个正在运行的容器

我们可以使用attach命令将我们终端的标准输入、输出和错误(或三者的任意组合)附加到正在运行的容器,使用容器的 ID 或名称。让我们为我们的 trivia 容器这样做:

$ docker container attach trivia

在这种情况下,我们将每隔五秒左右在输出中看到一个新的引用出现。

要退出容器而不停止或杀死它,我们可以按下组合键Ctrl + P + Ctrl + Q。这样我们就可以从容器中分离出来,同时让它在后台运行。另一方面,如果我们想要分离并同时停止容器,我们只需按下Ctrl + C

让我们运行另一个容器,这次是一个 Nginx Web 服务器:

$ docker run -d --name nginx -p 8080:80 nginx:alpine

在这里,我们在一个名为nginx的容器中以守护进程的形式运行 Alpine 版本的 Nginx。-p 8080:80命令行参数在主机上打开端口8080,以便访问容器内运行的 Nginx Web 服务器。不用担心这里的语法,因为我们将在第十章“单主机网络”中更详细地解释这个特性:

  1. 让我们看看是否可以使用curl工具访问 Nginx 并运行这个命令:
$ curl -4 localhost:8080

如果一切正常,你应该会看到 Nginx 的欢迎页面(为了方便阅读而缩短):

<html> 
<head> 
<title>Welcome to nginx!</title> 
<style> 
    body { 
        width: 35em; 
        margin: 0 auto; 
        font-family: Tahoma, Verdana, Arial, sans-serif; 
    } 
</style> 
</head> 
<body> 
<h1>Welcome to nginx!</h1> 
...
</html> 
  1. 现在,让我们附加我们的终端到nginx容器,观察发生了什么:
$ docker container attach nginx
  1. 一旦你附加到容器上,你首先看不到任何东西。但现在打开另一个终端,在这个新的终端窗口中,重复curl命令几次,例如,使用以下脚本:
$ for n in {1..10}; do curl -4 localhost:8080; done 

你应该会看到 Nginx 的日志输出,看起来类似于这样:

172.17.0.1 - - [16/Jun/2019:14:14:02 +0000] "GET / HTTP/1.1" 200 612 "-" "curl/7.54.0" "-"
172.17.0.1 - - [16/Jun/2019:14:14:02 +0000] "GET / HTTP/1.1" 200 612 "-" "curl/7.54.0" "-"
172.17.0.1 - - [16/Jun/2019:14:14:02 +0000] "GET / HTTP/1.1" 200 612 "-" "curl/7.54.0" "-"
...
  1. 按下Ctrl+C来退出容器。这将分离你的终端,并同时停止nginx容器。

  2. 清理时,使用以下命令删除nginx容器:

$ docker container rm nginx 

在下一节中,我们将学习如何处理容器日志。

检索容器日志

对于任何良好的应用程序来说,生成一些日志信息是最佳实践,开发人员和运维人员都可以使用这些信息来找出应用程序在特定时间正在做什么,以及是否存在任何问题,以帮助找出问题的根本原因。

在容器内运行时,应用程序最好将日志项输出到STDOUTSTDERR,而不是输出到文件中。如果日志输出被定向到STDOUTSTDERR,那么 Docker 可以收集这些信息,并准备好供用户或任何其他外部系统使用:

  1. 要访问特定容器的日志,我们可以使用docker container logs命令。例如,如果我们想要检索我们的trivia容器的日志,我们可以使用以下表达式:
$ docker container logs trivia

这将检索应用程序从其存在的最开始产生的整个日志。

停下,等一下——我刚才说的不太对。默认情况下,Docker 使用所谓的json-file日志驱动程序。这个驱动程序将日志信息存储在一个文件中。如果定义了文件滚动策略,那么docker container logs只会检索当前活动日志文件中的内容,而不是之前滚动文件中可能仍然可用的内容。

  1. 如果我们只想获取一些最新的条目,我们可以使用-t--tail参数,如下所示:
$ docker container logs --tail 5 trivia

这将只检索容器内运行的进程产生的最后五个条目。

有时,我们希望跟踪容器产生的日志。当使用-f--follow参数时,这是可能的。以下表达式将输出最后五个日志项,然后跟踪容器化进程产生的日志:

$ docker container logs --tail 5 --follow trivia 

通常使用容器日志的默认机制是不够的。我们需要一种不同的日志记录方式。这将在下一节中讨论。

日志记录驱动程序

Docker 包括多种日志记录机制,帮助我们从运行的容器中获取信息。这些机制被称为日志记录驱动程序。使用哪个日志记录驱动程序可以在 Docker 守护程序级别进行配置。默认的日志记录驱动程序是json-file。目前原生支持的一些驱动程序如下:

驱动程序 描述
none 不会产生特定容器的日志输出。
json-file 这是默认驱动程序。日志信息存储在以 JSON 格式的文件中。
journald 如果主机上运行着日志守护程序,我们可以使用此驱动程序。它将日志转发到journald守护程序。
syslog 如果主机上运行着syslog守护程序,我们可以配置此驱动程序,它将日志消息转发到syslog守护程序。
gelf 使用此驱动程序时,日志消息将写入Graylog 扩展日志格式GELF)端点。此类端点的常见示例包括 Graylog 和 Logstash。
fluentd 假设在主机系统上安装了fluentd守护程序,此驱动程序将日志消息写入其中。

如果更改了日志记录驱动程序,请注意docker container logs命令仅适用于json-filejournald驱动程序。

使用特定于容器的日志记录驱动程序

我们已经看到日志记录驱动程序可以在 Docker 守护程序配置文件中全局设置。但我们也可以在容器与容器之间定义日志记录驱动程序。在以下示例中,我们运行了一个busybox容器,并使用--log-driver参数配置了none日志记录驱动程序:

$ docker container run --name test -it \
 --log-driver none \
 busybox sh -c 'for N in 1 2 3; do echo "Hello $N"; done'

我们应该看到以下内容:

Hello 1
Hello 2
Hello 3 

现在,让我们尝试获取前一个容器的日志:

$ docker container logs test

输出如下:

Error response from daemon: configured logging driver does not support reading

这是可以预期的,因为none驱动程序不会产生任何日志输出。让我们清理并删除test容器:

$ docker container rm test

高级主题-更改默认日志记录驱动程序

让我们更改 Linux 主机的默认日志记录驱动程序:

  1. 在真实的 Linux 主机上进行这项操作是最简单的。为此,我们将使用 Vagrant 和 Ubuntu 镜像:
$ vagrant init bento/ubuntu-17.04
$ vagrant up
$ vagrant ssh

Vagrant是由 Hashicorp 开发的开源工具,通常用于构建和维护可移植的虚拟软件开发环境。

  1. 进入 Ubuntu 虚拟机后,我们要编辑 Docker 守护程序配置文件。转到/etc/docker文件夹并运行vi如下:
$ vi daemon.json 
  1. 输入以下内容:
{
  "Log-driver": "json-log",
  "log-opts": {
    "max-size": "10m",
    "max-file": 3
  }
}
  1. 通过首先按Esc,然后输入:w:q,最后按Enter键保存并退出vi

前面的定义告诉 Docker 守护程序使用json-log驱动程序,最大日志文件大小为 10MB,然后滚动,并且在系统上可以存在的最大日志文件数为3,在最老的文件被清除之前。

现在我们必须向 Docker 守护程序发送SIGHUP信号,以便它接受配置文件中的更改:

$ sudo kill -SIGHUP $(pidof dockerd)

请注意,前面的命令只重新加载配置文件,而不重新启动守护程序。

容器的解剖学

许多人错误地将容器与虚拟机进行比较。然而,这是一个值得商榷的比较。容器不仅仅是轻量级的虚拟机。那么,容器的正确描述是什么

容器是在主机系统上运行的特殊封装和安全进程。容器利用了 Linux 操作系统中许多可用的特性和原语。最重要的是命名空间cgroups。在容器中运行的所有进程只共享底层主机操作系统的相同 Linux 内核。这与虚拟机有根本的不同,因为每个虚拟机都包含自己的完整操作系统。

Typical container 的启动时间可以用毫秒来衡量,而虚拟机通常需要几秒到几分钟才能启动。虚拟机的寿命较长。每个运维工程师的主要目标是最大化虚拟机的正常运行时间。相反,容器的寿命较短。它们相对快速地出现和消失。

让我们首先对使我们能够运行容器的架构进行高级概述。

架构

在这里,我们有一个关于所有这些如何组合在一起的架构图:

Docker 的高级架构

在上图的下部,我们有 Linux 操作系统及其cgroups名称空间 功能,以及我们不需要在这里明确提到的其他操作系统功能。然后,有一个由containerdrunc组成的中间层。现在所有这些之上是Docker 引擎Docker 引擎为外部世界提供了一个 RESTful 接口,可以被任何工具访问,比如 Docker CLI、Docker for macOS 和 Docker for Windows 或 Kubernetes 等。

现在让我们更详细地描述一下主要的构建模块。

名称空间

Linux 名称空间在被 Docker 用于其容器之前已经存在多年。名称空间是全局资源的抽象,如文件系统、网络访问和进程树(也称为 PID 名称空间)或系统组 ID 和用户 ID。Linux 系统初始化时具有每种名称空间类型的单个实例。初始化后,可以创建或加入其他名称空间。

Linux 名称空间起源于 2002 年的 2.4.19 内核。在内核版本 3.8 中,引入了用户名称空间,随之而来的是名称空间已经准备好被容器使用。

如果我们将一个正在运行的进程,比如说,放在一个文件系统名称空间中,那么这个进程会产生一种错觉,认为它拥有自己完整的文件系统。当然,这是不真实的;这只是一个虚拟文件系统。从主机的角度来看,包含的进程获得了整体文件系统的受保护子部分。就像一个文件系统中的文件系统:

Linux 上的文件系统名称空间

对于所有其他全局资源,名称空间也适用。用户 ID 名称空间是另一个例子。有了用户名称空间,我们现在可以在系统上定义一个jdoe用户多次,只要它存在于自己的名称空间中。

PID 名称空间是防止一个容器中的进程看到或与另一个容器中的进程交互的机制。一个进程在容器内可能具有表面上的 PID 1,但如果我们从主机系统检查它,它将具有普通的 PID,比如334

Docker 主机上的进程树

在给定的名称空间中,我们可以运行一个到多个进程。当我们谈论容器时,这一点很重要,当我们在已经运行的容器中执行另一个进程时,我们已经有了这种经验。

控制组(cgroups)

Linux cgroups 被用来限制、管理和隔离在系统上运行的进程集合的资源使用。资源包括 CPU 时间、系统内存、网络带宽,或者这些资源的组合等等。

Google 的工程师最初在 2006 年实现了这个功能。cgroups 功能被合并到 Linux 内核主线中,内核版本为 2.6.24,发布于 2008 年 1 月。

使用 cgroups,管理员可以限制容器可以消耗的资源。通过这种方式,我们可以避免例如经典的“吵闹的邻居”问题,其中在容器中运行的恶意进程消耗了所有的 CPU 时间或者保留了大量的内存,从而使得所有在主机上运行的其他进程,无论它们是否被容器化,都饿死了。

联合文件系统(Unionfs)

Unionfs 构成了所谓的容器镜像的基础。我们将在下一章详细讨论容器镜像。此时,我们只想更好地理解 Unionfs 是什么,以及它是如何工作的。Unionfs 主要用于 Linux,允许不同文件系统的文件和目录叠加在一起,形成一个统一的文件系统。在这种情况下,各个文件系统被称为分支。在合并分支时,指定了分支之间的优先级。这样,当两个分支包含相同的文件时,具有更高优先级的文件将出现在最终的文件系统中。

容器管道

Docker 引擎构建的基础是容器管道,由两个组件runccontainerd组成。

最初,Docker 是以单片方式构建的,并包含了运行容器所需的所有功能。随着时间的推移,这变得过于僵化,Docker 开始将功能的部分拆分成它们自己的组件。两个重要的组件是 runc 和 containerd。

runC

runC 是一个轻量级、便携的容器运行时。它完全支持 Linux 命名空间,以及 Linux 上所有可用的安全功能,如 SELinux、AppArmor、seccomp 和 cgroups。

runC 是一个根据Open Container Initiative(OCI)规范生成和运行容器的工具。它是一个经过正式规范化的配置格式,由 Linux Foundation 的Open Container Project(OCP)监管。

Containerd

runC 是一个容器运行时的低级实现;containerd 在其基础上构建,并添加了更高级的功能,如镜像传输和存储、容器执行和监督,以及网络和存储附件。通过这些功能,它管理容器的完整生命周期。Containerd 是 OCI 规范的参考实现,是目前最受欢迎和广泛使用的容器运行时。

Containerd 于 2017 年捐赠并被 CNCF 接受。OCI 规范存在替代实现。其中一些是 CoreOS 的 rkt,RedHat 的 CRI-O 和 Linux Containers 的 LXD。然而,containerd 目前是最受欢迎的容器运行时,并且是 Kubernetes 1.8 或更高版本和 Docker 平台的默认运行时。

总结

在本章中,您学习了如何使用基于现有镜像的容器。我们展示了如何运行、停止、启动和删除容器。然后,我们检查了容器的元数据,提取了它的日志,并学习了如何在已运行的容器中运行任意进程。最后,我们深入挖掘了容器的工作原理以及它们利用的底层 Linux 操作系统的特性。

在下一章中,您将学习容器镜像是什么,以及我们如何构建和共享我们自己的自定义镜像。我们还将讨论构建自定义镜像时常用的最佳实践,例如最小化其大小和利用镜像缓存。敬请关注!

问题

为了评估您的学习进度,请回答以下问题:

  1. 容器的状态是什么?

  2. 哪个命令帮助我们找出当前在我们的 Docker 主机上运行的内容?

  3. 用于列出所有容器的 ID 的命令是什么?

进一步阅读

以下文章为您提供了一些与本章讨论的主题相关的更多信息:

第四章:创建和管理容器镜像

在上一章中,我们学习了容器是什么,以及如何运行、停止、删除、列出和检查它们。我们提取了一些容器的日志信息,在已经运行的容器内运行其他进程,最后,我们深入研究了容器的解剖学。每当我们运行一个容器时,我们都是使用容器镜像创建它。在本章中,我们将熟悉这些容器镜像。我们将详细了解它们是什么,如何创建它们以及如何分发它们。

本章将涵盖以下主题:

  • 什么是镜像?

  • 创建镜像

  • 举起和转移:容器化传统应用程序

  • 共享或运输镜像

完成本章后,您将能够执行以下操作:

  • 列举容器镜像的三个最重要特征。

  • 通过交互式更改容器层并提交来创建自定义镜像。

  • 编写一个简单的Dockerfile来生成自定义镜像。

  • 使用docker image save导出现有的镜像,并使用docker image load将其导入到另一个 Docker 主机。

  • 编写一个两步的 Dockerfile,通过仅在最终镜像中包含生成的工件来最小化结果镜像的大小。

什么是镜像?

在 Linux 中,一切都是文件。整个操作系统基本上是一个存储在本地磁盘上的文件系统。当查看容器镜像时,这是一个重要的事实要记住。正如我们将看到的,镜像基本上是一个包含文件系统的大型 tarball。更具体地说,它包含一个分层文件系统。

分层文件系统

容器镜像是创建容器的模板。这些镜像不仅由一个单一的块组成,而是由许多层组成。镜像中的第一层也被称为基础层。我们可以在下面的图形中看到这一点:

镜像就像一堆层叠的图层

每个单独的图层都包含文件和文件夹。每个图层只包含相对于底层的文件系统的更改。Docker 使用 Union 文件系统——如第三章中所讨论的掌握容器——从一组图层中创建虚拟文件系统。存储驱动程序处理有关这些图层如何相互交互的详细信息。不同的存储驱动程序可在不同情况下具有优势和劣势。

容器镜像的层都是不可变的。不可变意味着一旦生成,该层就永远不能被改变。唯一可能影响层的操作是其物理删除。层的这种不可变性很重要,因为它为我们打开了大量的机会,我们将会看到。

在下面的屏幕截图中,我们可以看到一个基于 Nginx 作为 Web 服务器的 Web 应用程序的自定义镜像是什么样子的:

基于 Alpine 和 Nginx 的示例自定义镜像

我们的基础层是Alpine Linux发行版。然后,在此基础上,我们有一个添加 Nginx层,其中 Nginx 添加在 Alpine 之上。最后,第三层包含构成 Web 应用程序的所有文件,如 HTML、CSS 和 JavaScript 文件。

正如之前所说,每个镜像都以基础镜像开始。通常,这个基础镜像是在 Docker Hub 上找到的官方镜像之一,比如 Linux 发行版、Alpine、Ubuntu 或 CentOS。然而,也可以从头开始创建一个镜像。

Docker Hub 是一个用于容器镜像的公共注册表。它是一个中央枢纽,非常适合共享公共容器镜像。

每个层只包含相对于前一组层的更改。每个层的内容都映射到主机系统上的一个特殊文件夹,通常是/var/lib/docker/的子文件夹。

由于层是不可变的,它们可以被缓存而永远不会变得过时。这是一个很大的优势,我们将会看到。

可写的容器层

正如我们所讨论的,一个容器镜像由一堆不可变或只读的层组成。当 Docker 引擎从这样的镜像创建一个容器时,它会在这堆不可变层的顶部添加一个可写的容器层。我们的堆现在看起来是这样的:

可写的容器层

容器层标记为可读/可写。镜像层的不可变性的另一个优点是它们可以在许多从该镜像创建的容器之间共享。所需的只是每个容器的一个薄的可写容器层,如下面的屏幕截图所示:

共享相同镜像层的多个容器

当然,这种技术会大大减少资源的消耗。此外,这有助于减少容器的加载时间,因为一旦镜像层加载到内存中,只需创建一个薄容器层,这仅发生在第一个容器中。

写时复制

Docker 在处理镜像时使用写时复制技术。写时复制是一种用于最大效率共享和复制文件的策略。如果一个层使用了一个低层次层中可用的文件或文件夹,那么它就直接使用它。另一方面,如果一个层想要修改一个低层次层中的文件,那么它首先将该文件复制到目标层,然后进行修改。在下面的截图中,我们可以看到这意味着什么:

使用写时复制的 Docker 镜像

第二层想要修改文件 2,它存在于基础层中。因此,它将其复制并进行修改。现在,假设我们正处于前面截图的顶层。这一层将使用基础层中的文件 1,以及第二层中的文件 2文件 3

图形驱动程序

图形驱动程序是启用联合文件系统的东西。图形驱动程序也称为存储驱动程序,在处理分层容器镜像时使用。图形驱动程序将多个镜像层合并为容器的挂载命名空间的根文件系统。换句话说,驱动程序控制着镜像和容器在 Docker 主机上的存储和管理方式。

Docker 支持使用可插拔架构的多种不同的图形驱动程序。首选驱动程序是overlay2,其次是overlay

创建镜像

在您的系统上有三种方法可以创建一个新的容器镜像。第一种方法是通过交互式地构建一个包含所有所需的添加和更改的容器,然后将这些更改提交到一个新的镜像中。第二种,也是最重要的方法是使用Dockerfile描述新镜像中的内容,然后使用该Dockerfile构建镜像作为清单。最后,创建镜像的第三种方法是通过从 tarball 导入到系统中。

现在,让我们详细看看这三种方式。

交互式镜像创建

我们可以创建自定义镜像的第一种方式是通过交互式构建容器。也就是说,我们从要用作模板的基本镜像开始,并以交互方式运行一个容器。假设这是 Alpine 镜像。

要交互式地创建一个镜像,请按照以下步骤进行:

  1. 运行容器的命令应该如下所示:
$ docker container run -it \
    --name sample \
    alpine:3.10 /bin/sh

上述命令基于alpine:3.10镜像运行一个容器。

我们使用-it参数交互式运行附加了电传打字机TTY)的容器,使用--name参数将其命名为sample,最后在容器内部使用/bin/sh运行一个 shell。

在运行上述命令的终端窗口中,您应该看到类似于这样的内容:

Unable to find image 'alpine:3.10' locally
3.10: Pulling from library/alpine
921b31ab772b: Pull complete
Digest: sha256:ca1c944a4f8486a153024d9965aafbe24f5723c1d5c02f4964c045a16d19dc54
Status: Downloaded newer image for alpine:3.10
/ #

默认情况下,alpine容器没有安装ping工具。假设我们想要创建一个新的自定义镜像,其中安装了ping

  1. 在容器内部,我们可以运行以下命令:
/ # apk update && apk add iputils

这使用apk Alpine 软件包管理器来安装iputils库,其中包括ping。上述命令的输出应该大致如下:

在 Alpine 上安装ping

  1. 现在,我们确实可以使用ping,如下面的代码片段所示:

在容器内部使用 ping

  1. 完成自定义后,我们可以通过在提示符处键入exit退出容器。

如果我们现在使用ls -a Docker 容器列出所有容器,我们可以看到我们的示例容器的状态为Exited,但仍然存在于系统中,如下面的代码块所示:

$ docker container ls -a | grep sample
040fdfe889a6 alpine:3.10 "/bin/sh" 8 minutes ago Exited (0) 4 seconds ago
  1. 如果我们想要查看容器相对于基本镜像的变化,我们可以使用docker container diff命令,如下所示:
$ docker container diff sample

输出应该呈现出容器文件系统上的所有修改列表,如下所示:

C /usr
C /usr/sbin
A /usr/sbin/getcap
A /usr/sbin/ipg
A /usr/sbin/tftpd
A /usr/sbin/ninfod
A /usr/sbin/rdisc
A /usr/sbin/rarpd
A /usr/sbin/tracepath
...
A /var/cache/apk/APKINDEX.d8b2a6f4.tar.gz
A /var/cache/apk/APKINDEX.00740ba1.tar.gz
C /bin
C /bin/ping
C /bin/ping6
A /bin/traceroute6
C /lib
C /lib/apk
C /lib/apk/db
C /lib/apk/db/scripts.tar
C /lib/apk/db/triggers
C /lib/apk/db/installed

我们已经缩短了上述输出以便更好地阅读。在列表中,A代表添加C代表更改。如果有任何已删除的文件,那么它们将以D为前缀。

  1. 现在,我们可以使用docker container commit命令来保存我们的修改并从中创建一个新的镜像,如下所示:
$ docker container commit sample my-alpine
sha256:44bca4141130ee8702e8e8efd1beb3cf4fe5aadb62a0c69a6995afd49c2e7419

通过上述命令,我们指定了新镜像将被称为my-alpine。上述命令生成的输出对应于新生成的镜像的 ID。

  1. 我们可以通过列出系统上的所有镜像来验证这一点,如下所示:
$ docker image ls

我们可以看到这个图像 ID(缩短)如下:

REPOSITORY   TAG      IMAGE ID       CREATED              SIZE
my-alpine    latest   44bca4141130   About a minute ago   7.34MB
...

我们可以看到名为my-alpine的图像具有预期的 ID44bca4141130,并自动分配了latest标签。这是因为我们没有明确定义标签。在这种情况下,Docker 总是默认为latest标签。

  1. 如果我们想要查看我们的自定义图像是如何构建的,我们可以使用history命令如下:
$ docker image history my-alpine

这将打印出我们的图像包含的层的列表,如下所示:

my-alpine Docker 图像的历史

在前面的输出中,第一层是我们刚刚通过添加iputils包创建的层。

使用 Dockerfile

手动创建自定义图像,如本章的前一节所示,当进行探索、创建原型或进行可行性研究时非常有帮助。但它有一个严重的缺点:这是一个手动过程,因此不可重复或可扩展。它也像人类手动执行的任何其他任务一样容易出错。必须有更好的方法。

这就是所谓的Dockerfile发挥作用的地方。Dockerfile是一个文本文件,通常被称为Dockerfile。它包含了构建自定义容器映像的指令。这是一种声明性构建图像的方式。

声明式与命令式:

在计算机科学中,通常情况下,特别是在 Docker 中,人们经常使用声明性的方式来定义任务。人们描述期望的结果,让系统找出如何实现这个目标,而不是给系统提供逐步实现所需结果的指令。后者是一种命令式的方法。

让我们看一个示例Dockerfile,如下所示:

FROM python:2.7
RUN mkdir -p /app
WORKDIR /app
COPY ./requirements.txt /app/
RUN pip install -r requirements.txt
CMD ["python", "main.py"]

这是一个Dockerfile,用于容器化 Python 2.7 应用程序。正如我们所看到的,文件有六行,每行以关键字开头,如FROMRUNCOPY。习惯上将关键字写成大写,但这不是必须的。

Dockerfile的每一行都会导致结果图像中的一个层。在下面的截图中,与本章前面的插图相比,图像被颠倒过来,显示为一堆层。在这里,基础层显示在顶部。不要被这个搞混了。实际上,基础层始终是堆栈中最低的层:

图像中 Dockerfile 和层的关系

现在,让我们更详细地看看各个关键字。

FROM 关键字

每个Dockerfile都以FROM关键字开始。通过它,我们定义了要从哪个基础镜像开始构建我们的自定义镜像。例如,如果我们想从 CentOS 7 开始构建,我们会在Dockerfile中有以下行:

FROM centos:7

在 Docker Hub 上,有所有主要 Linux 发行版的精选或官方镜像,以及所有重要的开发框架或语言,比如 Python、Node.js、Ruby、Go 等等。根据我们的需求,我们应该选择最合适的基础镜像。

例如,如果我想容器化一个 Python 3.7 应用程序,我可能会选择相关的官方python:3.7镜像。

如果我们真的想从头开始,我们也可以使用以下语句:

FROM scratch

这在构建超小型镜像的情况下非常有用,比如只包含一个二进制文件的情况:实际的静态链接可执行文件,比如Hello-Worldscratch镜像实际上是一个空的基础镜像。

FROM scratchDockerfile中是一个no-op,因此不会在生成的容器镜像中生成一个层。

RUN 关键字

下一个重要的关键字是RUNRUN的参数是任何有效的 Linux 命令,比如以下内容:

RUN yum install -y wget

前面的命令使用yum CentOS 包管理器来在运行的容器中安装wget包。这假设我们的基础镜像是 CentOS 或 Red Hat Enterprise Linux(RHEL)。如果我们的基础镜像是 Ubuntu,那么命令会类似于以下内容:

RUN apt-get update && apt-get install -y wget

这是因为 Ubuntu 使用apt-get作为包管理器。同样,我们可以定义一行RUN命令,如下所示:

RUN mkdir -p /app && cd /app

我们也可以这样做:

RUN tar -xJC /usr/src/python --strip-components=1 -f python.tar.xz

在这里,前者在容器中创建了一个/app文件夹并导航到它,后者将一个文件解压到指定位置。完全可以,甚至建议你使用多于一行的物理行来格式化 Linux 命令,比如这样:

RUN apt-get update \
 && apt-get install -y --no-install-recommends \
 ca-certificates \
 libexpat1 \
 libffi6 \
 libgdbm3 \
 libreadline7 \
 libsqlite3-0 \
 libssl1.1 \
 && rm -rf /var/lib/apt/lists/*

如果我们使用多行,需要在行尾加上反斜杠(\)来指示 shell 命令在下一行继续。

尝试找出前面的命令是做什么的。

COPY 和 ADD 关键字

COPYADD关键字非常重要,因为最终我们希望向现有的基础镜像添加一些内容,使其成为自定义镜像。大多数情况下,这些是一些源文件,比如 Web 应用程序,或者一些已编译应用程序的二进制文件。

这两个关键字用于将文件和文件夹从主机复制到正在构建的镜像中。 这两个关键字非常相似,唯一的区别是ADD关键字还允许我们复制和解压缩 TAR 文件,并提供 URL 作为要复制的文件和文件夹的来源。

让我们看一些如何使用这两个关键字的示例,如下所示:

COPY . /app
COPY ./web /app/web
COPY sample.txt /data/my-sample.txt
ADD sample.tar /app/bin/
ADD http://example.com/sample.txt /data/

在上述代码的前几行中,适用以下内容:

  • 第一行将当前目录中的所有文件和文件夹递归地复制到容器镜像内的app文件夹中。

  • 第二行将web子文件夹中的所有内容复制到目标文件夹/app/web

  • 第三行将单个文件sample.txt复制到目标文件夹/data中,并同时将其重命名为my-sample.txt

  • 第四个语句将sample.tar文件解压缩到目标文件夹/app/bin中。

  • 最后,最后一个语句将远程文件sample.txt复制到目标文件/data中。

源路径中允许使用通配符。 例如,以下语句将所有以sample开头的文件复制到镜像内的mydir文件夹中:

COPY ./sample* /mydir/

从安全角度来看,重要的是要知道,默认情况下,镜像内的所有文件和文件夹都将具有0用户 IDUID)和组 IDGID)。 好处是,对于ADDCOPY,我们可以使用可选的--chown标志更改镜像内文件的所有权,如下所示:

ADD --chown=11:22 ./data/web* /app/data/

前面的语句将复制所有以web开头的文件并将它们放入镜像中的/app/data文件夹,并同时为这些文件分配用户11和组22

除了数字之外,用户和组也可以使用名称,但是这些实体必须已在镜像的根文件系统中的/etc/passwd/etc/group中定义; 否则,镜像的构建将失败。

WORKDIR 关键字

WORKDIR关键字定义了在从我们的自定义镜像运行容器时使用的工作目录或上下文。 因此,如果我想将上下文设置为镜像内的/app/bin文件夹,则我的Dockerfile中的表达式必须如下所示:

WORKDIR /app/bin

在前一行之后发生的所有活动都将使用此目录作为工作目录。 非常重要的一点是要注意,Dockerfile中以下两个片段不同:

RUN cd /app/bin
RUN touch sample.txt

将前面的代码与以下代码进行比较:

WORKDIR /app/bin
RUN touch sample.txt

前者将在图像文件系统的根目录中创建文件,而后者将在/app/bin文件夹中的预期位置创建文件。只有WORKDIR关键字设置了图像层之间的上下文。cd命令本身不会跨层持久存在。

CMD 和 ENTRYPOINT 关键字

CMDENTRYPOINT关键字是特殊的。虽然Dockerfile为图像定义的所有其他关键字都是由 Docker 构建器在构建图像时执行的,但这两个关键字实际上是定义了当从我们定义的图像启动容器时会发生什么。当容器运行时启动一个容器,它需要知道在该容器内部将运行的进程或应用程序是什么。这正是CMDENTRYPOINT用于告诉 Docker 启动进程是什么以及如何启动该进程。

现在,CMDENTRYPOINT之间的区别微妙,老实说,大多数用户并不完全理解它们,也不按照预期的方式使用它们。幸运的是,在大多数情况下,这不是问题,容器仍然会运行;只是处理它的方式不像可能那么直接。

为了更好地理解如何使用这两个关键字,让我们分析一下典型的 Linux 命令或表达式是什么样的。让我们以ping实用程序为例,如下所示:

$ ping -c 3 8.8.8.8

在上述表达式中,ping是命令,-c 3 8.8.8.8是这个命令的参数。让我们再看一个表达式:

$ wget -O - http://example.com/downloads/script.sh

同样,在上述表达式中,wget是命令,-O - http://example.com/downloads/script.sh是参数。

现在我们已经处理了这个问题,我们可以回到CMDENTRYPOINTENTRYPOINT用于定义表达式的命令,而CMD用于定义命令的参数。因此,使用 Alpine 作为基础镜像并在容器中定义ping作为要运行的进程的Dockerfile可能如下所示:

FROM alpine:3.10
ENTRYPOINT ["ping"]
CMD ["-c","3","8.8.8.8"]

对于ENTRYPOINTCMD,值被格式化为一个字符串的 JSON 数组,其中各个项对应于表达式的标记,这些标记由空格分隔。这是定义CMDENTRYPOINT的首选方式。它也被称为exec形式。

另外,也可以使用所谓的 shell 形式,如下所示:

CMD command param1 param2

现在我们可以从上述Dockerfile构建一个名为pinger的镜像,如下所示:

$ docker image build -t pinger .

然后,我们可以从我们刚刚创建的pinger镜像中运行一个容器,就像这样:

$ docker container run --rm -it pinger
PING 8.8.8.8 (8.8.8.8): 56 data bytes
64 bytes from 8.8.8.8: seq=0 ttl=37 time=19.298 ms
64 bytes from 8.8.8.8: seq=1 ttl=37 time=27.890 ms
64 bytes from 8.8.8.8: seq=2 ttl=37 time=30.702 ms

这样做的美妙之处在于,我现在可以通过在docker container run表达式的末尾添加新值来覆盖我在Dockerfile中定义的CMD部分(记住,它是["-c", "3","8.8.8.8"]),就像这样:

$ docker container run --rm -it pinger -w 5 127.0.0.1

这将导致容器现在对环回进行 ping 操作,持续 5 秒。

如果我们想要覆盖Dockerfile中定义的ENTRYPOINT,我们需要在docker container run表达式中使用--entrypoint参数。假设我们想要在容器中执行 shell 而不是ping命令。我们可以通过使用以下命令来实现:

$ docker container run --rm -it --entrypoint /bin/sh pinger

我们随后将发现自己在容器内部。键入exit离开容器。

正如我已经提到的,我们不一定要遵循最佳实践,并通过ENTRYPOINT定义命令和通过CMD定义参数;相反,我们可以将整个表达式作为CMD的值输入,它将起作用,如下面的代码块所示:

FROM alpine:3.10
CMD wget -O - http://www.google.com

在这里,我甚至使用了 shell 形式来定义CMD。但是在ENTRYPOINT未定义的情况下会发生什么?如果您未定义ENTRYPOINT,那么它将具有默认值/bin/sh -c,并且CMD的任何值都将作为字符串传递给 shell 命令。因此,前面的定义将导致输入以下代码来运行容器内的进程:

/bin/sh -c "wget -O - http://www.google.com"

因此,/bin/sh是在容器内运行的主要进程,并且它将启动一个新的子进程来运行wget实用程序。

一个复杂的 Dockerfile

我们已经讨论了 Dockerfile 中常用的最重要的关键字。让我们看一个现实的,有些复杂的Dockerfile的例子。感兴趣的读者可能会注意到,它看起来与我们在本章中呈现的第一个Dockerfile非常相似。以下是内容:

FROM node:12.5-stretch
RUN mkdir -p /app
WORKDIR /app
COPY package.json /app/
RUN npm install
COPY . /app
ENTRYPOINT ["npm"]
CMD ["start"]

好了,这里发生了什么?显然,这是一个用于构建 Node.js 应用程序的Dockerfile;我们可以从使用node:12.5-stretch基础镜像这一事实推断出来。然后,第二行是一个指令,在镜像的文件系统中创建一个/app 文件夹。第三行定义了镜像中的工作目录或上下文为这个新的/app 文件夹。然后,在第四行,我们将一个package.json文件复制到镜像内的/app 文件夹中。之后,在第五行,我们在容器内执行npm install命令;请记住,我们的上下文是/app 文件夹,因此 npm 会在那里找到我们在第四行复制的 package.json 文件。

在安装了所有 Node.js 依赖项之后,我们将应用程序的其余文件从主机的当前文件夹复制到镜像的/app 文件夹中。

最后,在最后两行,我们定义了当从这个镜像运行容器时启动命令将是什么。在我们的情况下,它是npm start,这将启动 Node.js 应用程序。

构建镜像

让我们看一个具体的例子并构建一个简单的 Docker 镜像,如下所示:

  1. 在你的主目录中,创建一个名为fod(代表 Docker 基础知识)的文件夹,其中包含一个名为ch04的子文件夹,并导航到这个文件夹,就像这样:
$ mkdir -p ~/fod/ch04 && cd ~/fod/ch04
  1. 在上述文件夹中,创建一个sample1子文件夹并导航到它,就像这样:
$ mkdir sample1 && cd sample1
  1. 使用你喜欢的编辑器在这个示例文件夹中创建一个名为Dockerfile的文件,并包含以下内容:
FROM centos:7
RUN yum install -y wget
  1. 保存文件并退出编辑器。

  2. 回到终端窗口,我们现在可以使用上述Dockerfile作为清单或构建计划构建一个新的容器镜像,就像这样:

$ docker image build -t my-centos .

请注意,上述命令末尾有一个句点。这个命令意味着 Docker 构建器正在使用当前目录中存在的Dockerfile创建一个名为my-centos的新镜像。这里,命令末尾的句点代表当前目录。我们也可以将上述命令写成如下形式,结果是一样的:

$ docker image build -t my-centos -f Dockerfile .

但是我们可以省略-f参数,因为构建器假设Dockerfile的确切名称为Dockerfile。只有当我们的Dockerfile具有不同的名称或不位于当前目录时,我们才需要-f参数。

上述命令给出了这个(缩短的)输出:

Sending build context to Docker daemon 2.048kB
Step 1/2 : FROM centos:7
7: Pulling from library/centos
af4b0a2388c6: Pull complete
Digest: sha256:2671f7a3eea36ce43609e9fe7435ade83094291055f1c96d9d1d1d7c0b986a5d
Status: Downloaded newer image for centos:7
---> ff426288ea90
Step 2/2 : RUN yum install -y wget
---> Running in bb726903820c
Loaded plugins: fastestmirror, ovl
Determining fastest mirrors
* base: mirror.dal10.us.leaseweb.net
* extras: repos-tx.psychz.net
* updates: pubmirrors.dal.corespace.com
Resolving Dependencies
--> Running transaction check
---> Package wget.x86_64 0:1.14-15.el7_4.1 will be installed
...
Installed:
  wget.x86_64 0:1.14-15.el7_4.1
Complete!
Removing intermediate container bb726903820c
---> bc070cc81b87
Successfully built bc070cc81b87
Successfully tagged my-centos:latest

让我们分析这个输出,如下所示:

  1. 首先,我们有以下一行:
Sending build context to Docker daemon 2.048kB

构建器的第一件事是打包当前构建上下文中的文件,排除了.dockerignore文件中提到的文件和文件夹(如果存在),然后将生成的.tar文件发送给Docker 守护程序

  1. 接下来,我们有以下几行:
Step 1/2 : FROM centos:7
7: Pulling from library/centos
af4b0a2388c6: Pull complete
Digest: sha256:2671f7a...
Status: Downloaded newer image for centos:7
---> ff426288ea90

构建器的第一行告诉我们当前正在执行Dockerfile的哪个步骤。在这里,我们的Dockerfile中只有两个语句,我们正在执行第2步中的步骤 1。我们还可以看到该部分的内容是什么。在这里,它是基础镜像的声明,我们要在其上构建自定义镜像。然后构建器会从 Docker Hub 拉取这个镜像,如果本地缓存中没有的话。前面代码片段的最后一行指示了构建器分配给刚构建的镜像层的 ID。

  1. 现在,继续下一步。我将它比前面的部分更加简短,以便集中在关键部分上:
Step 2/2 : RUN yum install -y wget
---> Running in bb726903820c
...
...
Removing intermediate container bb726903820c
---> bc070cc81b87

在这里,第一行再次告诉我们,我们正在步骤 2中的步骤 2。它还向我们显示了Dockerfile中的相应条目。在第二行,我们可以看到Running in bb726903820c,这告诉我们构建器已创建了一个 ID 为bb726903820c的容器,在其中执行了RUN命令。

我们在片段中省略了yum install -y wget命令的输出,因为在这一部分并不重要。当命令完成时,构建器停止容器,将其提交到一个新层,然后删除容器。在这种特殊情况下,新层的 ID 是bc070cc81b87

  1. 在输出的最后,我们遇到以下两行:
Successfully built bc070cc81b87
Successfully tagged my-centos:latest

这告诉我们,生成的自定义镜像已被赋予 IDbc070cc81b87,并且已被标记为名称my-centos:latest

那么,构建器的工作原理是什么?它从基本图像开始。一旦将基本图像下载到本地缓存中,构建器就会创建一个容器,并在该容器中运行Dockerfile中的第一个语句。然后,它停止容器,并将容器中所做的更改持久化到一个新的图像层中。然后,构建器从基本图像和新层创建一个新的容器,并在该新容器中运行第二个语句。再次,结果被提交到一个新的层中。这个过程重复进行,直到Dockerfile中遇到最后一个语句。在提交了新图像的最后一层之后,构建器为该图像创建一个 ID,并使用我们在“build”命令中提供的名称对图像进行标记,如下面的屏幕截图所示。

可视化的图像构建过程

现在我们已经分析了 Docker 图像的构建过程以及涉及的步骤,让我们谈谈如何通过引入多步构建来进一步改进这一过程。

多步构建

为了演示为什么具有多个构建步骤的Dockerfile是有用的,让我们制作一个示例Dockerfile。让我们以 C 语言编写的“Hello World”应用程序为例。以下是hello.c文件中的代码:

#include <stdio.h>
int main (void)
{
    printf ("Hello, world!\n");
    return 0;
}

跟着来体验多步构建的优势:

  1. 要将此应用程序容器化,我们首先编写一个带有以下内容的Dockerfile
FROM alpine:3.7
RUN apk update &&
apk add --update alpine-sdk
RUN mkdir /app
WORKDIR /app
COPY . /app
RUN mkdir bin
RUN gcc -Wall hello.c -o bin/hello
CMD /app/bin/hello
  1. 接下来,让我们构建这个图像:
$ docker image build -t hello-world .

这给我们带来了相当长的输出,因为构建器必须安装 Alpine 软件开发工具包(SDK),其中包含我们需要构建应用程序的 C++编译器等工具。

  1. 构建完成后,我们可以列出图像并查看其大小,如下所示:
$ docker image ls | grep hello-world
hello-world   latest   e9b...   2 minutes ago   176MB

生成的图像大小为 176 MB,太大了。最后,它只是一个“Hello World”应用程序。它如此之大的原因是图像不仅包含“Hello World”二进制文件,还包含从源代码编译和链接应用程序所需的所有工具。但是当在生产环境中运行应用程序时,这确实是不可取的。理想情况下,我们只希望图像中有生成的二进制文件,而不是整个 SDK。

正是因为这个原因,我们应该将 Dockerfiles 定义为多阶段。我们有一些阶段用于构建最终的构件,然后有一个最终阶段,在这个阶段我们使用最小必要的基础镜像,并将构件复制到其中。这样可以得到非常小的 Docker 镜像。看一下这个修改后的Dockerfile

FROM alpine:3.7 AS build
RUN apk update && \
    apk add --update alpine-sdk
RUN mkdir /app
WORKDIR /app
COPY . /app
RUN mkdir bin
RUN gcc hello.c -o bin/hello

FROM alpine:3.7
COPY --from=build /app/bin/hello /app/hello
CMD /app/hello

在这里,我们有第一个阶段使用build别名来编译应用程序,然后第二个阶段使用相同的alpine:3.7基础镜像,但不安装 SDK,只是使用--from参数将二进制文件从build阶段复制到最终镜像中:

  1. 让我们再次构建镜像,如下所示:
$ docker image build -t hello-world-small .
  1. 当我们比较镜像的大小时,得到以下输出:
$ docker image ls | grep hello-world
hello-world-small  latest   f98...   20 seconds ago   4.16MB
hello-world        latest   469...   10 minutes ago   176MB

我们已经成功将大小从 176MB 减小到 4MB。这是大小减小了 40 倍。较小的镜像有许多优点,比如对黑客来说攻击面积更小,内存和磁盘消耗更少,相应容器的启动时间更快,以及从 Docker Hub 等注册表下载镜像所需的带宽减少。

Dockerfile 最佳实践

在编写Dockerfile时,有一些推荐的最佳实践需要考虑,如下所示:

  • 首先,我们需要考虑容器是短暂的。所谓短暂,意味着容器可以停止和销毁,然后新建一个并以绝对最少的设置和配置放置在原位。这意味着我们应该努力将容器内运行的应用程序初始化所需的时间保持在最低限度,以及终止或清理应用程序所需的时间也要尽量减少。

  • 下一个最佳实践告诉我们应该按照尽可能利用缓存的方式来排序Dockerfile中的各个命令。构建镜像的一层可能需要相当长的时间,有时甚至需要几秒钟,甚至几分钟。在开发应用程序时,我们将不得不多次为我们的应用程序构建容器镜像。我们希望将构建时间保持在最低限度。

当我们重新构建之前构建过的镜像时,只有发生了变化的层才会被重新构建,但如果需要重新构建一个层,所有后续的层也需要重新构建。这一点非常重要。考虑以下例子:

FROM node:9.4
RUN mkdir -p /app
WORKIR /app
COPY . /app
RUN npm install
CMD ["npm", "start"]

在这个例子中,Dockerfile的第五行上的npm install命令通常需要最长的时间。经典的 Node.js 应用程序有许多外部依赖项,这些依赖项都会在这一步骤中下载和安装。这可能需要几分钟才能完成。因此,我们希望避免在重建图像时每次运行npm install,但是开发人员在应用程序开发过程中经常更改其源代码。这意味着第四行的COPY命令的结果每次都会更改,因此必须重新构建该图层。但正如我们之前讨论的,这也意味着所有后续的图层都必须重新构建,而在这种情况下,包括npm install命令。为了避免这种情况,我们可以稍微修改Dockerfile,并采用以下方式:

FROM node:9.4
RUN mkdir -p /app
WORKIR /app
COPY package.json /app/
RUN npm install
COPY . /app
CMD ["npm", "start"]

我们在这里所做的是,在第四行,我们只复制了npm install命令需要的单个文件,即package.json文件。在典型的开发过程中,这个文件很少更改。因此,npm install命令也只有在package.json文件更改时才需要执行。所有其余经常更改的内容都是在npm install命令之后添加到图像中的。

  • 进一步的最佳实践是保持构成图像的图层数量相对较少。图像的图层越多,图形驱动程序就需要更多的工作来将这些图层合并为相应容器的单一根文件系统。当然,这需要时间,因此图像的图层数量越少,容器的启动时间就越快。

但是我们如何保持图层数量较少呢?请记住,在Dockerfile中,每一行以FROMCOPYRUN等关键字开头的命令都会创建一个新的图层。减少图层数量的最简单方法是将多个单独的RUN命令合并为一个。例如,假设我们在Dockerfile中有以下内容:

RUN apt-get update
RUN apt-get install -y ca-certificates
RUN rm -rf /var/lib/apt/lists/*

我们可以将这些内容合并为一个单一的连接表达式,如下所示:

RUN apt-get update \
    && apt-get install -y ca-certificates \
    && rm -rf /var/lib/apt/lists/*

前者将在生成的图像中生成三个图层,而后者只创建一个单一的图层。

接下来的三种最佳实践都会导致更小的图像。为什么这很重要?更小的图像减少了从注册表下载图像所需的时间和带宽。它们还减少了在 Docker 主机上本地存储副本所需的磁盘空间和加载图像所需的内存。最后,更小的图像也意味着对黑客的攻击面更小。以下是提到的最佳实践:

  • 第一个最佳实践有助于减小图像大小的方法是使用.dockerignore文件。我们希望避免将不必要的文件和文件夹复制到图像中,以使其尽可能精简。.dockerignore文件的工作方式与熟悉 Git 的人所熟悉的.gitignore文件完全相同。在.dockerignore文件中,我们可以配置模式来排除某些文件或文件夹在构建图像时包含在上下文中。

  • 下一个最佳实践是避免将不必要的软件包安装到图像的文件系统中。再次强调,这是为了使图像尽可能精简。

  • 最后但同样重要的是,建议您使用多阶段构建,以使生成的图像尽可能小,并且仅包含运行应用程序或应用程序服务所需的绝对最小内容。

保存和加载图像

创建新容器图像的第三种方法是通过从文件导入或加载它。容器图像只不过是一个 tarball。为了证明这一点,我们可以使用docker image save命令将现有图像导出为 tarball,就像这样:

$ docker image save -o ./backup/my-alpine.tar my-alpine

上述命令将我们之前构建的my-alpine图像导出到名为./backup/my-alpine.tar的文件中。

另一方面,如果我们有一个现有的 tarball 并希望将其导入为图像到我们的系统中,我们可以使用docker image load命令,如下所示:

$ docker image load -i ./backup/my-alpine.tar 

在下一节中,我们将讨论如何为现有的传统应用程序创建 Docker 图像,并在容器中运行它们,并从中获利。

举起和转移:容器化传统应用程序

我们并不总是能够从零开始开发全新的应用程序。往往情况是,我们手头有一大批传统应用程序,它们已经在生产环境中运行,并为公司或客户提供了至关重要的价值。这些应用程序往往是有机发展而来,非常复杂。文档稀缺,没有人真的愿意去碰这样的应用程序。通常情况下,有句话叫做“不要碰正在运行的系统”。然而,市场需求在变化,因此需要更新或重写这些应用程序。由于资源和时间的缺乏,或者成本过高,通常情况下完全重写是不可能的。那么我们该怎么办呢?我们是否可以将它们 Docker 化,并从容器引入的好处中获益呢?

事实证明我们可以。2017 年,Docker 为企业客户推出了一个名为“现代化传统应用程序”(MTA)的计划,该计划本质上承诺帮助这些客户将他们现有的或传统的 Java 和.NET 应用程序进行容器化,而无需改变一行代码。MTA 的重点是 Java 和.NET 应用程序,因为它们在典型企业中占据了绝大部分传统应用程序的份额。但对于任何使用其他语言和平台编写的应用程序,比如 C、C++、Python、Node.js、Ruby、PHP 或 Go 等,也是可能的。

让我们想象一下这样一个遗留应用程序。假设我们有一个 10 年前编写的旧 Java 应用程序,并在接下来的 5 年中不断更新。该应用程序基于 2006 年 12 月发布的 Java SE 6。它使用环境变量和属性文件进行配置。数据库连接字符串中使用的用户名和密码等机密信息是从诸如 HashiCorp 的 Vault 之类的机密存储库中提取的。

对外部依赖关系的分析。

现代化过程中的第一步之一是发现和列出遗留应用程序的所有外部依赖关系。

我们需要问自己一些类似以下的问题:

  1. 它是否使用数据库?如果是,是哪种数据库?连接字符串是什么样的?

  2. 它是否使用外部 API,比如信用卡批准或地理映射 API?API 密钥和密钥机密是什么?

  3. 它是否从企业服务总线(ESB)中消费或发布?

这些只是我想到的一些可能的依赖关系。还有更多存在。这些是应用程序与外部世界的接缝,我们需要意识到它们并创建清单。

源代码和构建说明

下一步是定位所有源代码和其他资产,例如应用程序的图像、CSS 和 HTML 文件。理想情况下,它们应该位于一个单独的文件夹中。这个文件夹将是我们项目的根目录,可以有许多子文件夹。这个项目根文件夹将在构建我们想要为我们的遗留应用程序创建的容器映像时成为上下文。请记住,Docker 构建器只包括构建中的上下文中的文件;在我们的情况下,这是根项目文件夹。

不过,有一个选项可以在构建过程中从不同位置下载或复制文件,使用COPYADD命令。有关如何使用这两个命令的确切细节,请参考在线文档。如果您的遗留应用程序的源不能轻松地包含在一个单独的本地文件夹中,这个选项是有用的。

一旦我们意识到所有部分都对最终应用程序有贡献,我们需要调查应用程序是如何构建和打包的。在我们的情况下,这很可能是通过使用 Maven 来完成的。Maven 是 Java 最流行的构建自动化工具,并且在大多数开发 Java 应用程序的企业中一直被使用。对于遗留的.NET 应用程序,很可能是通过使用 MSBuild 工具来完成;对于 C/C++应用程序,可能会使用 Make。

再次,让我们扩展我们的库存并记录使用的确切构建命令。以后在编写Dockerfile时,我们将需要这些信息。

配置

应用程序需要进行配置。在配置过程中提供的信息可以是,例如,要使用的应用程序日志记录类型、连接到数据库的连接字符串、到诸如 ESB 的服务的主机名或到外部 API 的 URI 等。

我们可以区分几种类型的配置,如下所示:

  • 构建时间:这是在构建应用程序和/或其 Docker 映像时需要的信息。在我们创建 Docker 映像时,它需要可用。

  • 环境:这是随着应用程序运行环境的不同而变化的配置信息,例如开发环境与暂存或生产环境。这种配置在应用程序启动时被应用,例如在生产环境中。

  • 运行时:这是应用程序在运行时检索的信息,例如访问外部 API 的秘钥。

秘钥

每个关键的企业应用程序都需要以某种形式处理秘钥。最熟悉的秘钥是访问数据库所需的连接信息,这些数据库用于保存应用程序产生或使用的数据。其他秘钥包括访问外部 API 所需的凭据,例如信用评分查询 API。重要的是要注意,这里我们谈论的是应用程序必须提供给应用程序使用或依赖的服务提供商的秘钥,而不是应用程序用户提供的秘钥。这里的主体是我们的应用程序,它需要被外部机构和服务提供商进行认证和授权。

传统应用程序获取秘钥的方式有很多种。最糟糕和最不安全的提供秘钥的方式是将它们硬编码或从配置文件或环境变量中读取,这样它们就以明文形式可用。一个更好的方式是在运行时从特殊的秘钥存储中读取秘钥,该存储将秘钥加密并通过安全连接(如传输层安全性(TLS))提供给应用程序。

再一次,我们需要创建一个清单,列出我们的应用程序使用的所有秘钥以及它们获取秘钥的方式。是通过环境变量或配置文件,还是通过访问外部密钥存储,例如 HashiCorp 的 Vault?

编写 Dockerfile

一旦我们完成了前面几节讨论的所有项目清单,我们就可以开始编写我们的Dockerfile。但我想警告你:不要期望这是一个一次性的任务。你可能需要多次迭代,直到你制定出最终的DockerfileDockerfile可能会相当长,看起来很丑陋,但这并不是问题,只要我们得到一个可用的 Docker 镜像。一旦我们有了可用的版本,我们总是可以微调Dockerfile

基础镜像

让我们首先确定我们想要使用和构建图像的基本图像。是否有官方的 Java 图像可用,符合我们的要求?请记住,我们的虚构应用程序是基于 Java SE 6。如果有这样的基本图像可用,那么让我们使用那个。否则,我们想要从 Red Hat、Oracle 或 Ubuntu 等 Linux 发行版开始。在后一种情况下,我们将使用发行版的适当软件包管理器(yumapt或其他)来安装所需版本的 Java 和 Maven。为此,我们在Dockerfile中使用RUN关键字。请记住,RUN关键字使我们有可能在构建过程中执行图像中的任何有效的 Linux 命令。

组装源代码

在这一步中,我们确保所有构建应用程序所需的源文件和其他工件都是图像的一部分。在这里,我们主要使用Dockerfile的两个关键字:COPYADD。最初,图像中的源结构应该与主机上的完全相同,以避免任何构建问题。理想情况下,您将有一个单独的COPY命令,将主机上的根项目文件夹全部复制到图像中。然后,相应的Dockerfile片段可能看起来就像这样简单:

WORKDIR /app
COPY . .

不要忘记还要提供一个位于项目根文件夹中的.dockerignore文件,其中列出了项目根文件夹中不应成为构建上下文一部分的所有文件和(子)文件夹。

如前所述,您还可以使用ADD关键字将不位于构建上下文中但可以通过 URI 访问的源代码和其他工件下载到 Docker 图像中,如下所示:

ADD http://example.com/foobar ./ 

这将在图像的工作文件夹中创建一个foobar文件夹,并从 URI 中复制所有内容。

构建应用程序

在这一步中,我们确保创建组成我们可执行的遗留应用程序的最终工件。通常,这是一个 JAR 或 WAR 文件,有或没有一些附属的 JAR 文件。Dockerfile的这部分应该完全模仿您在将应用程序容器化之前传统用于构建应用程序的方式。因此,如果使用 Maven 作为构建自动化工具,Dockerfile的相应片段可能看起来就像这样简单:

RUN mvn --clean install

在这一步中,我们可能还想列出应用程序使用的环境变量,并提供合理的默认值。但是永远不要为提供给应用程序的秘密环境变量提供默认值,比如数据库连接字符串!使用ENV关键字来定义你的变量,就像这样:

ENV foo=bar
ENV baz=123

还要声明应用程序正在侦听的所有端口,并且需要通过EXPOSE关键字从容器外部访问,就像这样:

EXPOSE 5000
EXPOSE 15672/tcp

定义启动命令

通常,Java 应用程序是通过诸如java -jar <主应用程序 jar>这样的命令启动的,如果它是一个独立的应用程序。如果是 WAR 文件,那么启动命令可能看起来有点不同。因此,我们可以定义ENTRYPOINTCMD来使用这个命令。因此,我们的Dockerfile中的最终语句可能是这样的:

ENTRYPOINT java -jar pet-shop.war

然而,通常情况下这太过简单,我们需要执行一些预运行任务。在这种情况下,我们可以编写一个包含需要执行以准备环境并运行应用程序的一系列命令的脚本文件。这样的文件通常被称为docker-entrypoint.sh,但你可以自由地命名它。确保文件是可执行的—例如,使用以下命令:

chmod +x ./docker-entrypoint.sh

Dockerfile的最后一行将如下所示:

ENTRYPOINT ./docker-entrypoint.sh

现在你已经得到了如何将传统应用程序容器化的提示,是时候进行总结并问自己:真的值得花这么大的努力吗?

为什么费这个劲呢?

此时,我可以看到你正在挠头,问自己:为什么要费这个劲呢? 为什么你要花这么大的力气来容器化一个传统应用程序?有什么好处呢?

事实证明投资回报率ROI)是巨大的。Docker 的企业客户在 DockerCon 2018 和 2019 等会议上公开披露,他们看到了 Docker 化传统应用程序的这两个主要好处:

  • 维护成本节约超过 50%。

  • 新版本发布之间的时间减少了 90%。

通过减少维护开销节省的成本可以直接再投资,并用于开发新功能和产品。在传统应用程序的新版本发布期间节省的时间使企业更具敏捷性,能够更快地对客户或市场需求的变化做出反应。

现在我们已经详细讨论了如何构建 Docker 图像,是时候学习如何通过软件交付流程的各个阶段来部署这些图像了。

分享或部署图像

为了能够将我们的自定义图像部署到其他环境中,我们需要首先为其指定一个全局唯一的名称。这个操作通常被称为给图像打标签。然后我们需要将图像发布到一个中央位置,其他感兴趣或有权限的方可以从中拉取。这些中央位置被称为图像注册表

给图像打标签

每个图像都有一个所谓的标签。标签通常用于对图像进行版本控制,但它的作用远不止于版本号。如果在使用图像时没有明确指定标签,那么 Docker 会自动假定我们指的是latest标签。这在从 Docker Hub 拉取图像时很重要,就像下面的例子一样:

$ docker image pull alpine

上述命令将从 Docker Hub 拉取alpine:latest图像。如果我们想要明确指定一个标签,可以这样做:

$ docker image pull alpine:3.5

现在将拉取已标记为3.5alpine图像。

图像命名空间

到目前为止,我们一直在拉取各种图像,并没有太在意这些图像的来源。您的 Docker 环境配置为,默认情况下所有图像都是从 Docker Hub 拉取的。我们还只从 Docker Hub 拉取了所谓的官方图像,比如alpinebusybox

现在,是时候稍微扩大一下视野,了解图像的命名空间是如何工作的了。定义图像最通用的方式是通过其完全限定名称,如下所示:

<registry URL>/<User or Org>/<name>:<tag>

让我们更详细地看一下:

  • <registry URL>:这是我们想要从中拉取图像的注册表的 URL。默认情况下,这是docker.io。更一般地说,这可能是https://registry.acme.com

除了 Docker Hub,还有很多公共注册表可以从中拉取图像。以下是其中一些的列表,没有特定顺序:

让我们看一个例子,如下:

https://registry.acme.com/engineering/web-app:1.0

在这里,我们有一个带有版本1.0标签的web-app镜像,属于https://registry.acme.com上的engineering组织的私有注册表。

现在,有一些特殊的约定:

  • 如果我们省略了注册表 URL,那么 Docker Hub 会自动被使用。

  • 如果我们省略了标签,那么将使用latest

  • 如果它是 Docker Hub 上的官方镜像,那么不需要用户或组织命名空间。

以下是一些以表格形式呈现的示例:

镜像 描述
alpine Docker Hub 上的官方alpine镜像,带有latest标签。
ubuntu:19.04 Docker Hub 上的官方ubuntu镜像,带有19.04标签或版本。
microsoft/nanoserver Microsoft 在 Docker Hub 上的nanoserver镜像,带有latest标签。
acme/web-api:12.0 acme组织相关联的web-api镜像版本12.0。该镜像在 Docker Hub 上。
gcr.io/gnschenker/sample-app:1.1 sample-app镜像,带有1.1标签,属于 Google 容器注册表上的gnschenkerID。

现在我们知道了 Docker 镜像的完全限定名称是如何定义的,以及它的组成部分是什么,让我们来谈谈在 Docker Hub 上可以找到的一些特殊镜像。

官方镜像

在上表中,我们多次提到了官方镜像。这需要解释。镜像存储在 Docker Hub 注册表上的存储库中。官方存储库是由个人或组织策划的一组存储库,他们还负责镜像内打包的软件。让我们看一个例子来解释这意味着什么。Ubuntu Linux 发行版背后有一个官方组织。该团队还提供包含他们 Ubuntu 发行版的官方版本的 Docker 镜像。

官方镜像旨在提供基本的操作系统存储库、流行编程语言运行时的镜像、经常使用的数据存储以及其他重要服务。

Docker 赞助一个团队,他们的任务是审查并发布 Docker Hub 上公共存储库中的所有精选图像。此外,Docker 还扫描所有官方图像以查找漏洞。

将图像推送到注册表

创建自定义图像当然很好,但在某个时候,我们希望实际上将我们的图像共享或发布到目标环境,比如测试、质量保证(QA)或生产系统。为此,我们通常使用容器注册表。其中最受欢迎和公共的注册表之一是 Docker Hub。它在您的 Docker 环境中配置为默认注册表,并且是我们迄今为止拉取所有图像的注册表。

在注册表上,通常可以创建个人或组织帐户。例如,我的 Docker Hub 个人帐户是gnschenker。个人帐户适用于个人使用。如果我们想专业使用注册表,那么我们可能会想在 Docker Hub 上创建一个组织帐户,比如acme。后者的优势在于组织可以拥有多个团队。团队可以具有不同的权限。

要能够将图像推送到 Docker Hub 上的个人帐户,我需要相应地对其进行标记:

  1. 假设我想将 Alpine 的最新版本推送到我的帐户并给它打上1.0的标签。我可以通过以下方式做到这一点:
$ docker image tag alpine:latest gnschenker/alpine:1.0
  1. 现在,为了能够推送图像,我必须登录到我的帐户,如下所示:
$ docker login -u gnschenker -p <my secret password>
  1. 成功登录后,我可以像这样推送图像:
$ docker image push gnschenker/alpine:1.0

我将在终端中看到类似于这样的内容:

The push refers to repository [docker.io/gnschenker/alpine]
04a094fe844e: Mounted from library/alpine
1.0: digest: sha256:5cb04fce... size: 528

对于我们推送到 Docker Hub 的每个图像,我们会自动创建一个存储库。存储库可以是私有的或公共的。每个人都可以从公共存储库中拉取图像。从私有存储库中,只有在登录到注册表并配置了必要的权限后,才能拉取图像。

总结

在本章中,我们详细讨论了容器图像是什么以及我们如何构建和发布它们。正如我们所见,图像可以通过三种不同的方式创建——手动、自动或通过将 tarball 导入系统。我们还学习了构建自定义图像时通常使用的一些最佳实践。

在下一章中,我们将介绍 Docker 卷,用于持久化容器的状态。我们还将展示如何为容器内运行的应用程序定义单独的环境变量,以及如何使用包含整套配置设置的文件。

问题

请尝试回答以下问题以评估您的学习进度:

  1. 如何创建一个继承自 Ubuntu 版本19.04的 Dockerfile,安装ping并在容器启动时运行pingping的默认地址将是127.0.0.1

  2. 如何创建一个使用alpine:latest并安装curl的新容器镜像?将新镜像命名为my-alpine:1.0

  3. 创建一个Dockerfile,使用多个步骤创建一个用 C 或 Go 编写的Hello World应用程序的最小尺寸镜像。

  4. 列出三个 Docker 容器镜像的基本特征。

  5. 您想将名为foo:1.0的镜像推送到 Docker Hub 上的jdoe个人账户。以下哪个是正确的解决方案?

A. $ docker container push foo:1.0 B. $ docker image tag foo:1.0 jdoe/foo:1.0

$ docker image push jdoe/foo:1.0 C. $ docker login -u jdoe -p <your password>

$ docker image tag foo:1.0 jdoe/foo:1.0

$ docker image push jdoe/foo:1.0 D. $ docker login -u jdoe -p <your password>

$ docker container tag foo:1.0 jdoe/foo:1.0

$ docker container push jdoe/foo:1.0 E. $ docker login -u jdoe -p <your password>

$ docker image push foo:1.0 jdoe/foo:1.0

进一步阅读

以下参考资料列表提供了一些更深入探讨容器镜像创作和构建主题的材料:

第五章:数据卷和配置

在上一章中,我们学习了如何构建和共享我们自己的容器镜像。特别关注了如何通过只包含容器化应用程序真正需要的构件来尽可能地减小镜像的大小。

在本章中,我们将学习如何处理有状态的容器,即消耗和产生数据的容器。我们还将学习如何使用环境变量和配置文件在运行时和构建时配置容器。

以下是我们将讨论的主题列表:

  • 创建和挂载数据卷

  • 在容器之间共享数据

  • 使用主机卷

  • 在镜像中定义卷

  • 配置容器

完成本章后,您将能够做到以下事项:

  • 创建、删除和列出数据卷。

  • 将现有的数据卷挂载到容器中。

  • 在容器内部使用数据卷创建持久化数据。

  • 使用数据卷在多个容器之间共享数据。

  • 使用数据卷将任何主机文件夹挂载到容器中。

  • 定义容器访问数据卷中数据的访问模式(读/写或只读)。

  • 为在容器中运行的应用程序配置环境变量。

  • 通过使用构建参数对Dockerfile进行参数化。

技术要求

在本章中,您需要在您的机器上安装 Docker Toolbox 或者访问在您的笔记本电脑或云中运行 Docker 的 Linux 虚拟机(VM)。此外,最好在您的机器上安装 Docker for Desktop。本章没有附带任何代码。

创建和挂载数据卷

所有有意义的应用程序都会消耗或产生数据。然而,容器最好是无状态的。我们该如何处理这个问题呢?一种方法是使用 Docker 卷。卷允许容器消耗、产生和修改状态。卷的生命周期超出了容器的生命周期。当使用卷的容器死亡时,卷仍然存在。这对状态的持久性非常有利。

修改容器层

在我们深入讨论卷之前,让我们首先讨论一下如果容器中的应用程序更改了容器文件系统中的内容会发生什么。在这种情况下,更改都发生在我们在《精通容器》第三章中介绍的可写容器层中。我们可以通过运行一个容器并在其中执行一个创建新文件的脚本来快速演示这一点,就像这样:

$ docker container run --name demo \
 alpine /bin/sh -c 'echo "This is a test" > sample.txt'

上述命令创建了一个名为demo的容器,并在该容器内创建了一个名为sample.txt的文件,内容为This is a test。运行echo命令后容器退出,但仍保留在内存中,供我们进行调查。让我们使用diff命令来查找容器文件系统中与原始镜像文件系统相关的更改,如下所示:

$ docker container diff demo

输出应该如下所示:

A /sample.txt

显然,如A所示,容器的文件系统中已经添加了一个新文件,这是预期的。由于所有源自基础镜像(在本例中为alpine)的层都是不可变的,更改只能发生在可写容器层中。

与原始镜像相比发生了变化的文件将用C标记,而已删除的文件将用D标记。

如果我们现在从内存中删除容器,它的容器层也将被删除,所有更改将被不可逆转地删除。如果我们需要我们的更改持久存在,甚至超出容器的生命周期,这不是一个解决方案。幸运的是,我们有更好的选择,即 Docker 卷。让我们来了解一下它们。

创建卷

由于在这个时候,在 macOS 或 Windows 计算机上使用 Docker for Desktop 时,容器并不是在 macOS 或 Windows 上本地运行,而是在 Docker for Desktop 创建的(隐藏的)VM 中运行,为了说明问题,最好使用docker-machine来创建和使用运行 Docker 的显式 VM。在这一点上,我们假设您已经在系统上安装了 Docker Toolbox。如果没有,请返回到第二章《设置工作环境》中,我们提供了如何安装 Toolbox 的详细说明:

  1. 使用docker-machine列出当前在 VirtualBox 中运行的所有虚拟机,如下所示:
$ docker-machine ls 
  1. 如果您的列表中没有名为node-1的 VM,请使用以下命令创建一个:
$ docker-machine create --driver virtualbox node-1 

如果您在启用了 Hyper-V 的 Windows 上运行,可以参考第二章 设置工作环境中的内容,了解如何使用docker-machine创建基于 Hyper-V 的 VM。

  1. 另一方面,如果您有一个名为node-1的 VM,但它没有运行,请按以下方式启动它:
$ docker-machine start node-1
  1. 现在一切准备就绪,使用docker-machine以这种方式 SSH 到这个 VM:
$ docker-machine ssh node-1
  1. 您应该会看到这个欢迎图片:

docker-machine VM 欢迎消息

  1. 要创建一个新的数据卷,我们可以使用docker volume create命令。这将创建一个命名卷,然后可以将其挂载到容器中,用于持久数据访问或存储。以下命令创建一个名为sample的卷,使用默认卷驱动程序:
$ docker volume create sample 

默认的卷驱动程序是所谓的本地驱动程序,它将数据存储在主机文件系统中。

  1. 找出主机上存储数据的最简单方法是使用docker volume inspect命令查看我们刚刚创建的卷。实际位置可能因系统而异,因此这是找到目标文件夹的最安全方法。您可以在以下代码块中看到这个命令:
$ docker volume inspect sample [ 
    { 
        "CreatedAt": "2019-08-02T06:59:13Z",
        "Driver": "local",
        "Labels": {},
        "Mountpoint": "/mnt/sda1/var/lib/docker/volumes/sample/_data",
        "Name": "my-data",
        "Options": {},
        "Scope": "local"
    } 
] 

主机文件夹可以在输出中的Mountpoint下找到。在我们的情况下,当使用基于 LinuxKit 的 VM 在 VirtualBox 中运行docker-machine时,文件夹是/mnt/sda1/var/lib/docker/volumes/sample/_data

目标文件夹通常是受保护的文件夹,因此我们可能需要使用sudo来导航到这个文件夹并在其中执行任何操作。

在我们基于 LinuxKit 的 VM 中,Docker Toolbox 中,访问也被拒绝,但我们也没有sudo。我们的探索到此为止了吗?

幸运的是,我已经准备了一个fundamentalsofdocker/nsenter实用程序容器,允许我们访问我们之前创建的sample卷的后备文件夹。

  1. 我们需要以privileged模式运行此容器,以访问文件系统的受保护部分,就像这样:
$ docker run -it --rm --privileged --pid=host \
 fundamentalsofdocker/nsenter / #

我们正在使用--privileged标志运行容器。这意味着在容器中运行的任何应用程序都可以访问主机的设备。--pid=host标志表示容器被允许访问主机的进程树(Docker 守护程序运行的隐藏 VM)。现在,前面的容器运行 Linux nsenter工具以进入主机的 Linux 命名空间,然后在其中运行一个 shell。通过这个 shell,我们因此被授予对主机管理的所有资源的访问权限。

在运行容器时,我们基本上在容器内执行以下命令:

nsenter -t 1 -m -u -n -i sh

如果这对你来说听起来很复杂,不用担心;随着我们在本书中的学习,你会更多地理解。如果有一件事可以让你受益,那就是意识到正确使用容器可以有多强大。

  1. 在容器内部,我们现在可以导航到代表卷挂载点的文件夹,然后列出其内容,如下所示:
/ # cd /mnt/sda1/var/lib/docker/volumes/sample/_data
/ # ls -l total 0

由于我们尚未在卷中存储任何数据,该文件夹目前为空。

  1. 通过按下Ctrl + D退出工具容器。

还有其他来自第三方的卷驱动程序,以插件的形式提供。我们可以在create命令中使用--driver参数来选择不同的卷驱动程序。其他卷驱动程序使用不同类型的存储系统来支持卷,例如云存储、网络文件系统(NFS)驱动、软件定义存储等。然而,正确使用其他卷驱动程序的讨论超出了本书的范围。

挂载卷

一旦我们创建了一个命名卷,我们可以按照以下步骤将其挂载到容器中:

  1. 为此,我们可以在docker container run命令中使用-v参数,如下所示:
$ docker container run --name test -it \
 -v sample:/data \
    alpine /bin/sh Unable to find image 'alpine:latest' locally
latest: Pulling from library/alpine
050382585609: Pull complete
Digest: sha256:6a92cd1fcdc8d8cdec60f33dda4db2cb1fcdcacf3410a8e05b3741f44a9b5998
Status: Downloaded newer image for alpine:latest
/ #

上述命令将sample卷挂载到容器内的/data文件夹。

  1. 在容器内,我们现在可以在/data文件夹中创建文件,然后退出,如下所示:
/ # cd /data / # echo "Some data" > data.txt 
/ # echo "Some more data" > data2.txt 
/ # exit
  1. 如果我们导航到包含卷数据的主机文件夹并列出其内容,我们应该看到我们刚刚在容器内创建的两个文件(记住:我们需要使用fundamentalsofdocker/nsenter工具容器来这样做),如下所示:
$ docker run -it --rm --privileged --pid=host \
 fundamentalsofdocker/nsenter
/ # cd /mnt/sda1/var/lib/docker/volumes/sample/_data
/ # ls -l 
total 8 
-rw-r--r-- 1 root root 10 Jan 28 22:23 data.txt
-rw-r--r-- 1 root root 15 Jan 28 22:23 data2.txt
  1. 我们甚至可以尝试输出,比如说,第二个文件的内容,如下所示:
/ # cat data2.txt
  1. 让我们尝试从主机在这个文件夹中创建一个文件,然后像这样使用另一个容器的卷:
/ # echo "This file we create on the host" > host-data.txt 
  1. 通过按下Ctrl + D退出工具容器。

  2. 现在,让我们删除test容器,并基于 CentOS 运行另一个容器。这次,我们甚至将我们的卷挂载到不同的容器文件夹/app/data中,就像这样:

$ docker container rm test
$ docker container run --name test2 -it \
 -v my-data:/app/data \
 centos:7 /bin/bash Unable to find image 'centos:7' locally
7: Pulling from library/centos
8ba884070f61: Pull complete
Digest: sha256:a799dd8a2ded4a83484bbae769d97655392b3f86533ceb7dd96bbac929809f3c
Status: Downloaded newer image for centos:7
[root@275c1fe31ec0 /]#
  1. 一旦进入centos容器,我们可以导航到我们已经挂载卷的/app/data文件夹,并列出其内容,如下所示:
[root@275c1fe31ec0 /]# cd /app/data 
[root@275c1fe31ec0 /]# ls -l 

正如预期的那样,我们应该看到这三个文件:

-rw-r--r-- 1 root root 10 Aug 2 22:23 data.txt
-rw-r--r-- 1 root root 15 Aug 2 22:23 data2.txt
-rw-r--r-- 1 root root 32 Aug 2 22:31 host-data.txt

这是数据在 Docker 卷中持久存在超出容器生命周期的明确证据,也就是说,卷可以被其他甚至不同的容器重复使用,而不仅仅是最初使用它的容器。

重要的是要注意,在容器内部挂载 Docker 卷的文件夹被排除在 Union 文件系统之外。也就是说,该文件夹及其任何子文件夹内的每个更改都不会成为容器层的一部分,而是将持久保存在卷驱动程序提供的后备存储中。这一事实非常重要,因为当相应的容器停止并从系统中删除时,容器层将被删除。

  1. 使用Ctrl + D退出centos容器。现在,再次按Ctrl + D退出node-1虚拟机。

删除卷

可以使用docker volume rm命令删除卷。重要的是要记住,删除卷会不可逆地销毁包含的数据,因此应该被视为危险命令。在这方面,Docker 在一定程度上帮助了我们,因为它不允许我们删除仍然被容器使用的卷。在删除卷之前,一定要确保要么有数据的备份,要么确实不再需要这些数据。让我们看看如何按照以下步骤删除卷:

  1. 以下命令删除了我们之前创建的sample卷:
$ docker volume rm sample 
  1. 执行上述命令后,仔细检查主机上的文件夹是否已被删除。

  2. 为了清理系统,删除所有正在运行的容器,运行以下命令:

$ docker container rm -f $(docker container ls -aq)  

请注意,在用于删除容器的命令中使用-v--volume标志,您可以要求系统同时删除与该特定容器关联的任何卷。当然,这只有在特定卷只被该容器使用时才有效。

在下一节中,我们将展示在使用 Docker for Desktop 时如何访问卷的后备文件夹。

访问使用 Docker for Desktop 创建的卷

按照以下步骤:

  1. 让我们创建一个sample卷并使用我们的 macOS 或 Windows 机器上的 Docker for Desktop 进行检查,就像这样:
$ docker volume create sample
$ docker volume inspect sample
[
 {
 "CreatedAt": "2019-08-02T07:44:08Z",
 "Driver": "local",
 "Labels": {},
 "Mountpoint": "/var/lib/docker/volumes/sample/_data",
 "Name": "sample",
 "Options": {},
 "Scope": "local"
 }
]

Mountpoint显示为/var/lib/docker/volumes/sample/_data,但您会发现在您的 macOS 或 Windows 机器上没有这样的文件夹。原因是显示的路径是与 Docker for Windows 用于运行容器的隐藏 VM 相关的。此时,Linux 容器无法在 macOS 或 Windows 上本地运行。

  1. 接下来,让我们从alpine容器内部生成两个带有卷数据的文件。要运行容器并将示例volume挂载到容器的/data文件夹,请使用以下代码:
$ docker container run --rm -it -v sample:/data alpine /bin/sh
  1. 在容器内的/data文件夹中生成两个文件,就像这样:
/ # echo "Hello world" > /data/sample.txt
/ # echo "Other message" > /data/other.txt
  1. 通过按Ctrl + D退出alpine容器。

如前所述,我们无法直接从我们的 macOS 或 Windows 访问sample卷的支持文件夹。这是因为该卷位于 macOS 或 Windows 上运行的隐藏 VM 中,该 VM 用于在 Docker for Desktop 中运行 Linux 容器。

要从我们的 macOS 访问隐藏的 VM,我们有两个选项。我们可以使用特殊容器并以特权模式运行它,或者我们可以使用screen实用程序来筛选 Docker 驱动程序。第一种方法也适用于 Windows 的 Docker。

  1. 让我们从运行容器的fundamentalsofdocker/nsenter镜像开始尝试提到的第一种方法。我们在上一节中已经在使用这个容器。运行以下代码:
$ docker run -it --rm --privileged --pid=host fundamentalsofdocker/nsenter / #
  1. 现在我们可以导航到支持我们sample卷的文件夹,就像这样:
/ # cd /var/lib/docker/volumes/sample/_data

通过运行此代码来查看此文件夹中有什么:

/ # ls -l 
total 8
-rw-r--r-- 1 root root 14 Aug 2 08:07 other.txt
-rw-r--r-- 1 root root 12 Aug 2 08:07 sample.txt
  1. 让我们尝试从这个特殊容器内创建一个文件,然后列出文件夹的内容,如下所示:
/ # echo "I love Docker" > docker.txt
/ # ls -l total 12
-rw-r--r-- 1 root root 14 Aug 2 08:08 docker.txt
-rw-r--r-- 1 root root 14 Aug 2 08:07 other.txt
-rw-r--r-- 1 root root 12 Aug 2 08:07 sample.txt

现在,我们在sample卷的支持文件夹中有了文件。

  1. 要退出我们的特权容器,只需按Ctrl + D

  2. 现在我们已经探索了第一种选项,如果您使用的是 macOS,让我们尝试screen工具,如下所示:

$ screen ~/Library/Containers/com.docker.docker/Data/com.docker.driver.amd64-linux/tty
  1. 这样做,我们将会看到一个空屏幕。按Enter,将显示一个docker-desktop:~#命令行提示符。现在我们可以导航到卷文件夹,就像这样:
docker-desktop:~# cd /var/lib/docker/volumes/sample/_data
  1. 让我们创建另一个带有一些数据的文件,然后列出文件夹的内容,如下所示:
docker-desktop:~# echo "Some other test" > test.txt 
docker-desktop:~# ls -l
total 16 -rw-r--r-- 1 root root 14 Aug 2 08:08 docker.txt -rw-r--r-- 1 root root 14 Aug 2 08:07 other.txt
-rw-r--r-- 1 root root 12 Aug 2 08:07 sample.txt
-rw-r--r-- 1 root root 16 Aug 2 08:10 test.txt
  1. 要退出 Docker VM 的会话,请按Ctrl + A + K

我们现在已经使用三种不同的方法创建了数据,如下所示:

    • 从已挂载sample卷的容器内部。
  • 使用特权文件夹来访问 Docker for Desktop 使用的隐藏虚拟机,并直接写入sample卷的后备文件夹。

  • 仅在 macOS 上,使用screen实用程序进入隐藏的虚拟机,并直接写入sample卷的后备文件夹。

在容器之间共享数据

容器就像应用程序在其中运行的沙盒。这在很大程度上是有益的和需要的,以保护运行在不同容器中的应用程序。这也意味着对于在容器内运行的应用程序可见的整个文件系统对于这个应用程序是私有的,其他在不同容器中运行的应用程序不能干扰它。

有时,我们想要在容器之间共享数据。假设在容器 A 中运行的应用程序生成了一些数据,将被在容器 B 中运行的另一个应用程序使用。我们该如何实现这一点?好吧,我相信你已经猜到了——我们可以使用 Docker 卷来实现这一目的。我们可以创建一个卷,并将其挂载到容器 A,以及容器 B。这样,应用程序 A 和 B 都可以访问相同的数据。

现在,当多个应用程序或进程同时访问数据时,我们必须非常小心以避免不一致。为了避免并发问题,如竞争条件,理想情况下只有一个应用程序或进程创建或修改数据,而所有其他进程同时访问这些数据只读取它。我们可以通过将卷作为只读挂载来强制在容器中运行的进程只能读取卷中的数据。看一下以下命令:

$ docker container run -it --name writer \
 -v shared-data:/data \
 alpine /bin/sh

在这里,我们创建了一个名为writer的容器,它有一个卷shared-data,以默认的读/写模式挂载:

  1. 尝试在这个容器内创建一个文件,就像这样:
# / echo "I can create a file" > /data/sample.txt 

它应该成功。

  1. 退出这个容器,然后执行以下命令:
$ docker container run -it --name reader \
 -v shared-data:/app/data:ro \
 ubuntu:19.04 /bin/bash

我们有一个名为reader的容器,它有相同的卷挂载为只读(ro)。

  1. 首先,确保你能看到在第一个容器中创建的文件,就像这样:
$ ls -l /app/data 
total 4
-rw-r--r-- 1 root root 20 Jan 28 22:55 sample.txt
  1. 然后,尝试创建一个文件,就像这样:
# / echo "Try to break read/only" > /app/data/data.txt

它将失败,并显示以下消息:

bash: /app/data/data.txt: Read-only file system
  1. 通过在命令提示符处输入exit来退出容器。回到主机上,让我们清理所有容器和卷,如下所示:
$ docker container rm -f $(docker container ls -aq) 
$ docker volume rm $(docker volume ls -q) 
  1. 完成后,通过在命令提示符处输入 exit 退出 docker-machine VM。您应该回到您的 Docker for Desktop。使用 docker-machine 停止 VM,就像这样:
$ docker-machine stop node-1 

接下来,我们将展示如何将 Docker 主机中的任意文件夹挂载到容器中。

使用主机卷

在某些情况下,比如开发新的容器化应用程序或者容器化应用程序需要从某个文件夹中消耗数据——比如说——由传统应用程序产生,使用挂载特定主机文件夹的卷非常有用。让我们看下面的例子:

$ docker container run --rm -it \
 -v $(pwd)/src:/app/src \
 alpine:latest /bin/sh

前面的表达式交互式地启动一个带有 shell 的 alpine 容器,并将当前目录的 src 子文件夹挂载到容器的 /app/src。我们需要使用 $(pwd)(或者 pwd,无论哪种方式),即当前目录,因为在使用卷时,我们总是需要使用绝对路径。

开发人员在他们在容器中运行的应用程序上工作时,经常使用这些技术,并希望确保容器始终包含他们对代码所做的最新更改,而无需在每次更改后重新构建镜像和重新运行容器。

让我们做一个示例来演示它是如何工作的。假设我们想要使用 nginx 创建一个简单的静态网站作为我们的 web 服务器,如下所示:

  1. 首先,在主机上创建一个新的文件夹,我们将把我们的网页资产—如 HTML、CSS 和 JavaScript 文件—放在其中,并导航到它,就像这样:
$ mkdir ~/my-web 
$ cd ~/my-web 
  1. 然后,我们创建一个简单的网页,就像这样:
$ echo "<h1>Personal Website</h1>" > index.html 
  1. 现在,我们添加一个 Dockerfile,其中包含构建包含我们示例网站的镜像的说明。

  2. 在文件夹中添加一个名为 Dockerfile 的文件,内容如下:

FROM nginx:alpine
COPY . /usr/share/nginx/html

Dockerfile 以最新的 Alpine 版本的 nginx 开始,然后将当前主机目录中的所有文件复制到 /usr/share/nginx/html 容器文件夹中。这是 nginx 期望网页资产位于的位置。

  1. 现在,让我们用以下命令构建镜像:
$ docker image build -t my-website:1.0 . 
  1. 最后,我们从这个镜像中运行一个容器。我们将以分离模式运行容器,就像这样:
$ docker container run -d \
 --name my-site \
 -p 8080:80 \
 my-website:1.0

注意 -p 8080:80 参数。我们还没有讨论这个,但我们将在第十章《单主机网络》中详细讨论。目前,只需知道这将把 nginx 监听传入请求的容器端口 80 映射到您的笔记本电脑的端口 8080,然后您可以访问应用程序。

  1. 现在,打开一个浏览器标签,导航到http://localhost:8080/index.html,你应该看到你的网站,目前只包括一个标题,个人网站

  2. 现在,在你喜欢的编辑器中编辑index.html文件,使其看起来像这样:

<h1>Personal Website</h1> 
<p>This is some text</p> 
  1. 现在保存它,然后刷新浏览器。哦!那没用。浏览器仍然显示index.html文件的先前版本,只包括标题。所以,让我们停止并删除当前容器,然后重建镜像,并重新运行容器,如下所示:
$ docker container rm -f my-site
$ docker image build -t my-website:1.0 .
$ docker container run -d \
 --name my-site \
   -p 8080:80 \
 my-website:1.0

这次,当你刷新浏览器时,新内容应该显示出来。好吧,它起作用了,但涉及的摩擦太多了。想象一下,每次对网站进行简单更改时都要这样做。这是不可持续的。

  1. 现在是使用主机挂载卷的时候了。再次删除当前容器,并使用卷挂载重新运行它,就像这样:
$ docker container rm -f my-site
$ docker container run -d \
 --name my-site \
   -v $(pwd):/usr/share/nginx/html \
 -p 8080:80 \
 my-website:1.0
  1. 现在,向index.html文件追加一些内容,并保存。然后,刷新你的浏览器。你应该看到变化。这正是我们想要实现的;我们也称之为编辑和继续体验。你可以对网页文件进行任意更改,并立即在浏览器中看到结果,而无需重建镜像和重新启动包含你的网站的容器。

重要的是要注意,更新现在是双向传播的。如果你在主机上进行更改,它们将传播到容器,反之亦然。同样重要的是,当你将当前文件夹挂载到容器目标文件夹/usr/share/nginx/html时,已经存在的内容将被主机文件夹的内容替换。

在镜像中定义卷

如果我们回顾一下我们在第三章中学到的关于容器的知识,掌握容器,那么我们有这样的情况:每个容器的文件系统在启动时由底层镜像的不可变层和特定于该容器的可写容器层组成。容器内运行的进程对文件系统所做的所有更改都将持久保存在该容器层中。一旦容器停止并从系统中删除,相应的容器层将从系统中删除并且不可逆地丢失。

一些应用程序,比如在容器中运行的数据库,需要将它们的数据持久保存超出容器的生命周期。在这种情况下,它们可以使用卷。为了更加明确,让我们看一个具体的例子。MongoDB 是一个流行的开源文档数据库。许多开发人员使用 MongoDB 作为他们应用程序的存储服务。MongoDB 的维护者已经创建了一个镜像,并将其发布到 Docker Hub,可以用来在容器中运行数据库的实例。这个数据库将产生需要长期持久保存的数据,但 MongoDB 的维护者不知道谁使用这个镜像以及它是如何被使用的。因此,他们对于用户启动这个容器的docker container run命令没有影响。他们现在如何定义卷呢?

幸运的是,在Dockerfile中有一种定义卷的方法。这样做的关键字是VOLUME,我们可以添加单个文件夹的绝对路径或逗号分隔的路径列表。这些路径代表容器文件系统的文件夹。让我们看一些这样的卷定义示例,如下:

VOLUME /app/data 
VOLUME /app/data, /app/profiles, /app/config 
VOLUME ["/app/data", "/app/profiles", "/app/config"] 

前面片段中的第一行定义了一个要挂载到/app/data的单个卷。第二行定义了三个卷作为逗号分隔的列表。最后一个与第二行定义相同,但这次值被格式化为 JSON 数组。

当容器启动时,Docker 会自动创建一个卷,并将其挂载到Dockerfile中定义的每个路径对应的容器目标文件夹。由于每个卷都是由 Docker 自动创建的,它将有一个 SHA-256 作为其 ID。

在容器运行时,在Dockerfile中定义为卷的文件夹被排除在联合文件系统之外,因此这些文件夹中的任何更改都不会改变容器层,而是持久保存到相应的卷中。现在,运维工程师有责任确保卷的后备存储得到适当备份。

我们可以使用docker image inspect命令来获取关于Dockerfile中定义的卷的信息。让我们按照以下步骤来看看 MongoDB 给我们的信息:

  1. 首先,我们使用以下命令拉取镜像:
$ docker image pull mongo:3.7
  1. 然后,我们检查这个镜像,并使用--format参数来从大量数据中提取必要的部分,如下:
 $ docker image inspect \
    --format='{{json .ContainerConfig.Volumes}}' \
    mongo:3.7 | jq . 

请注意命令末尾的| jq .。我们正在将docker image inspect的输出导入jq工具,它会很好地格式化输出。如果您尚未在系统上安装jq,您可以在 macOS 上使用brew install jq,或在 Windows 上使用choco install jq来安装。

上述命令将返回以下结果:

{
 "/data/configdb": {},
 "/data/db": {}
}

显然,MongoDB 的Dockerfile/data/configdb/data/db定义了两个卷。

  1. 现在,让我们作为后台守护进程运行一个 MongoDB 实例,如下所示:
$ docker run --name my-mongo -d mongo:3.7
  1. 我们现在可以使用docker container inspect命令获取有关已创建的卷等信息。

使用此命令只获取卷信息:

$ docker inspect --format '{{json .Mounts}}' my-mongo | jq .

前面的命令应该输出类似这样的内容(缩短):

[
  {
    "Type": "volume",
    "Name": "b9ea0158b5...",
    "Source": "/var/lib/docker/volumes/b9ea0158b.../_data",
    "Destination": "/data/configdb",
    "Driver": "local",
    ...
  },
  {
    "Type": "volume",
    "Name": "5becf84b1e...",
    "Source": "/var/lib/docker/volumes/5becf84b1.../_data",
    "Destination": "/data/db",
    ...
  }
]

请注意,为了便于阅读,NameSource字段的值已被修剪。Source字段为我们提供了主机目录的路径,MongoDB 在容器内生成的数据将存储在其中。

目前关于卷的内容就是这些。在下一节中,我们将探讨如何配置在容器中运行的应用程序,以及容器镜像构建过程本身。

配置容器

往往我们需要为容器内运行的应用程序提供一些配置。配置通常用于允许同一个容器在非常不同的环境中运行,例如开发、测试、暂存或生产环境。

在 Linux 中,通常通过环境变量提供配置值。

我们已经了解到,在容器内运行的应用程序与其主机环境完全隔离。因此,在主机上看到的环境变量与在容器内看到的环境变量是不同的。

让我们首先看一下在我们的主机上定义了什么:

  1. 使用此命令:
$ export

在我的 macOS 上,我看到类似这样的东西(缩短):

...
COLORFGBG '7;0'
COLORTERM truecolor
HOME /Users/gabriel
ITERM_PROFILE Default
ITERM_SESSION_ID w0t1p0:47EFAEFE-BA29-4CC0-B2E7-8C5C2EA619A8
LC_CTYPE UTF-8
LOGNAME gabriel
...
  1. 接下来,让我们在alpine容器内运行一个 shell,并列出我们在那里看到的环境变量,如下所示:
$ docker container run --rm -it alpine /bin/sh
/ # export 
export HOME='/root'
export HOSTNAME='91250b722bc3'
export PATH='/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
export PWD='/'
export SHLVL='1'
export TERM='xterm'

我们从export命令看到的前面的输出显然与我们直接在主机上看到的完全不同。

  1. 按下Ctrl + D离开alpine容器。

接下来,让我们为容器定义环境变量。

为容器定义环境变量

现在,好处是我们实际上可以在启动时将一些配置值传递到容器中。我们可以使用--env(或简写形式-e)参数以--env <key>=<value>的形式这样做,其中<key>是环境变量的名称,<value>表示与该变量关联的值。假设我们希望在容器中运行的应用程序能够访问名为LOG_DIR的环境变量,其值为/var/log/my-log。我们可以使用以下命令来实现:

$ docker container run --rm -it \
 --env LOG_DIR=/var/log/my-log \
 alpine /bin/sh
/ #

上述代码在alpine容器中启动了一个 shell,并在运行的容器内定义了所请求的环境。为了证明这是真的,我们可以在alpine容器内执行这个命令:

/ # export | grep LOG_DIR 
export LOG_DIR='/var/log/my-log'

输出看起来如预期的那样。我们现在确实在容器内有了所请求的环境变量和正确的值。

当然,当我们运行容器时,我们可以定义多个环境变量。我们只需要重复--env(或-e)参数。看一下这个示例:

$ docker container run --rm -it \
 --env LOG_DIR=/var/log/my-log \    --env MAX_LOG_FILES=5 \
 --env MAX_LOG_SIZE=1G \
 alpine /bin/sh
/ #

如果我们现在列出环境变量,我们会看到以下内容:

/ # export | grep LOG 
export LOG_DIR='/var/log/my-log'
export MAX_LOG_FILES='5'
export MAX_LOG_SIZE='1G'

让我们现在看一下我们有许多环境变量需要配置的情况。

使用配置文件

复杂的应用程序可能有许多环境变量需要配置,因此我们运行相应容器的命令可能会变得难以控制。为此,Docker 允许我们将环境变量定义作为文件传递,并且我们在docker container run命令中有--env-file参数。

让我们试一下,如下所示:

  1. 创建一个fod/05文件夹并导航到它,就像这样:
$ mkdir -p ~/fod/05 && cd ~/fod/05
  1. 使用您喜欢的编辑器在此文件夹中创建一个名为development.config的文件。将以下内容添加到文件中,并保存如下:
LOG_DIR=/var/log/my-log
MAX_LOG_FILES=5
MAX_LOG_SIZE=1G

注意我们每行定义一个环境变量的格式是<key>=<value>,其中,再次,<key>是环境变量的名称,<value>表示与该变量关联的值。

  1. 现在,从fod/05文件夹中,让我们运行一个alpine容器,将文件作为环境文件传递,并在容器内运行export命令,以验证文件中列出的变量确实已经在容器内部创建为环境变量,就像这样:
$ docker container run --rm -it \
 --env-file ./development.config \
 alpine sh -c "export"

确实,变量已经被定义,正如我们在生成的输出中所看到的:

export HOME='/root'
export HOSTNAME='30ad92415f87'
export LOG_DIR='/var/log/my-log'
export MAX_LOG_FILES='5'
export MAX_LOG_SIZE='1G'
export PATH='/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
export PWD='/'
export SHLVL='1'
export TERM='xterm'

接下来,让我们看看如何为给定 Docker 镜像的所有容器实例定义环境变量的默认值。

在容器镜像中定义环境变量

有时,我们希望为必须存在于给定容器镜像的所有容器实例中的环境变量定义一些默认值。我们可以在用于创建该镜像的Dockerfile中这样做,按照以下步骤:

  1. 使用您喜欢的编辑器在~/fod/05文件夹中创建一个名为Dockerfile的文件。将以下内容添加到文件中,并保存:
FROM alpine:latest
ENV LOG_DIR=/var/log/my-log
ENV  MAX_LOG_FILES=5
ENV MAX_LOG_SIZE=1G
  1. 使用前述Dockerfile创建一个名为my-alpine的容器镜像,如下所示:
$ docker image build -t my-alpine .

从该镜像运行一个容器实例,输出容器内定义的环境变量,就像这样:

$ docker container run --rm -it \
    my-alpine sh -c "export | grep LOG" 
export LOG_DIR='/var/log/my-log'
export MAX_LOG_FILES='5'
export MAX_LOG_SIZE='1G'

这正是我们所期望的。

不过,好消息是,我们并不完全受困于这些变量值。我们可以使用docker container run命令中的--env参数覆盖其中一个或多个变量。看一下以下命令及其输出:

$ docker container run --rm -it \
    --env MAX_LOG_SIZE=2G \
    --env MAX_LOG_FILES=10 \
    my-alpine sh -c "export | grep LOG" 
export LOG_DIR='/var/log/my-log'
export MAX_LOG_FILES='10'
export MAX_LOG_SIZE='2G'

我们还可以使用环境文件和docker container run命令中的--env-file参数来覆盖默认值。请自行尝试。

构建时的环境变量

有时,我们希望在构建容器镜像时定义一些环境变量,这些变量在构建时是有效的。想象一下,你想定义一个BASE_IMAGE_VERSION环境变量,然后在你的Dockerfile中将其用作参数。想象一下以下的Dockerfile

ARG BASE_IMAGE_VERSION=12.7-stretch
FROM node:${BASE_IMAGE_VERSION}
WORKDIR /app
COPY packages.json .
RUN npm install
COPY . .
CMD npm start

我们使用ARG关键字来定义一个默认值,每次从前述Dockerfile构建镜像时都会使用这个默认值。在这种情况下,这意味着我们的镜像使用node:12.7-stretch基础镜像。

现在,如果我们想为—比如—测试目的创建一个特殊的镜像,我们可以使用--build-arg参数在构建镜像时覆盖这个变量,如下所示:

$ docker image build \
 --build-arg BASE_IMAGE_VERSION=12.7-alpine \
 -t my-node-app-test .

在这种情况下,生成的my-node-test:latest镜像将从node:12.7-alpine基础镜像构建,而不是从node:12.7-stretch默认镜像构建。

总之,通过--env--env-file定义的环境变量在容器运行时有效。在Dockerfile中使用ARG或在docker container build命令中使用--build-arg定义的变量在容器镜像构建时有效。前者用于配置容器内运行的应用程序,而后者用于参数化容器镜像构建过程。

总结

在本章中,我们介绍了 Docker 卷,可以用来持久保存容器产生的状态并使其持久。我们还可以使用卷来为容器提供来自各种来源的数据。我们已经学会了如何创建、挂载和使用卷。我们已经学会了各种定义卷的技术,例如按名称、通过挂载主机目录或在容器镜像中定义卷。

在这一章中,我们还讨论了如何配置环境变量,这些变量可以被容器内运行的应用程序使用。我们已经展示了如何在docker container run命令中定义这些变量,可以明确地一个一个地定义,也可以作为配置文件中的集合。我们还展示了如何通过使用构建参数来参数化容器镜像的构建过程。

在下一章中,我们将介绍常用的技术,允许开发人员在容器中运行代码时进行演变、修改、调试和测试。

问题

请尝试回答以下问题,以评估您的学习进度:

  1. 如何创建一个名为my-products的命名数据卷,使用默认驱动程序?

  2. 如何使用alpine镜像运行一个容器,并将my-products卷以只读模式挂载到/data容器文件夹中?

  3. 如何找到与my-products卷关联的文件夹并导航到它?另外,您将如何创建一个带有一些内容的文件sample.txt

  4. 如何在另一个alpine容器中运行,并将my-products卷挂载到/app-data文件夹中,以读/写模式?在此容器内,导航到/app-data文件夹并创建一个带有一些内容的hello.txt文件。

  5. 如何将主机卷(例如~/my-project)挂载到容器中?

  6. 如何从系统中删除所有未使用的卷?

  7. 在容器中运行的应用程序看到的环境变量列表与应用程序直接在主机上运行时看到的相同。

A. 真

B. 假

  1. 您的应用程序需要在容器中运行,并为其配置提供大量环境变量。运行一个包含您的应用程序并向其提供所有这些信息的容器的最简单方法是什么?

进一步阅读

以下文章提供更深入的信息:

第六章:在容器中运行的代码调试

在上一章中,我们学习了如何处理有状态的容器,即消耗和产生数据的容器。我们还学习了如何使用环境变量和配置文件在运行时和镜像构建时配置我们的容器。

在本章中,我们将介绍常用的技术,允许开发人员在容器中运行时演变、修改、调试和测试他们的代码。有了这些技术,您将享受到在容器中运行应用程序时无摩擦的开发过程,类似于开发本地运行的应用程序时的体验。

以下是我们将讨论的主题列表:

  • 在容器中运行的代码进行演变和测试

  • 在更改后自动重新启动代码

  • 在容器内逐行调试代码

  • 为您的代码添加仪表,以产生有意义的日志信息

  • 使用 Jaeger 进行监控和故障排除

完成本章后,您将能够做到以下事情:

  • 将源代码挂载到运行中的容器中

  • 配置在容器中运行的应用程序在代码更改后自动重新启动

  • 配置 Visual Studio Code 以逐行调试在容器内运行的 Java、Node.js、Python 或.NET 编写的应用程序

  • 从应用程序代码中记录重要事件

技术要求

在本章中,如果您想跟着代码进行操作,您需要在 macOS 或 Windows 上安装 Docker for Desktop 和一个代码编辑器——最好是 Visual Studio Code。该示例也适用于安装了 Docker 和 VS Code 的 Linux 机器。

在容器中运行的代码进行演变和测试

在开发最终将在容器中运行的代码时,通常最好的方法是从一开始就在容器中运行代码,以确保不会出现意外的情况。但是,我们必须以正确的方式来做这件事,以免在开发过程中引入不必要的摩擦。让我们首先看一下我们可以在容器中运行和测试代码的天真方式:

  1. 创建一个新的项目文件夹并导航到它:
$ mkdir -p ~/fod/ch06 && cd ~/fod/ch06
  1. 让我们使用npm来创建一个新的 Node.js 项目:
$ npm init
  1. 接受所有默认设置。请注意,将创建一个package.json文件,内容如下:
{
  "name": "ch06",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC"
}
  1. 我们想在我们的 Node 应用程序中使用 Express.js 库;因此,使用npm来安装它:
$ npm install express --save

这将在我们的机器上安装最新版本的 Express.js,并且由于--save参数,会向我们的package.json文件添加一个类似于这样的引用:

"dependencies": {
  "express": "⁴.17.1"
}
  1. 从该文件夹中启动 VS Code:
$ code .
  1. 在 VS Code 中,创建一个新的index.js文件,并将以下代码片段添加到其中。不要忘记保存:
const express = require('express');
const app = express();

app.listen(3000, '0.0.0.0', ()=>{
    console.log('Application listening at 0.0.0.0:3000');
})

app.get('/', (req,res)=>{
    res.send('Sample Application: Hello World!');
})
  1. 从终端窗口中再次启动应用程序:
$ node index.js

您应该看到以下输出:

Application listening at 0.0.0.0:3000

这意味着应用程序正在运行并准备在0.0.0.0:3000上监听。您可能会问自己主机地址0.0.0.0的含义是什么,为什么我们选择了它。稍后我们会回到这个问题,当我们在容器内运行应用程序时。暂时只需知道0.0.0.0是一个具有特殊含义的保留 IP 地址,类似于环回地址127.0.0.10.0.0.0地址简单地意味着本地机器上的所有 IPv4 地址。如果主机有两个 IP 地址,比如52.11.32.1310.11.0.1,并且在主机上运行的服务器监听0.0.0.0,它将在这两个 IP 上可达。

  1. 现在在您喜欢的浏览器中打开一个新标签,并导航到localhost:3000。您应该看到这个:

在浏览器中运行的示例 Node.js 应用程序

太好了——我们的 Node.js 应用程序正在我们的开发者机器上运行。在终端中按Ctrl + C停止应用程序。

  1. 现在我们想通过在容器内运行来测试我们迄今为止开发的应用程序。为此,我们首先必须创建一个Dockerfile,以便我们可以构建一个容器镜像,然后从中运行一个容器。让我们再次使用 VS Code 将一个名为Dockerfile的文件添加到我们的项目文件夹中,并给它以下内容:
FROM node:latest
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
CMD node index.js
  1. 然后我们可以使用这个Dockerfile来构建一个名为sample-app的镜像,如下所示:
$ docker image build -t sample-app .
  1. 构建后,使用以下命令在容器中运行应用程序:
$ docker container run --rm -it \
    --name my-sample-app \
    -p 3000:3000 \
    sample-app

上述命令从容器镜像sample-app运行一个名为my-sample-app的容器,并将容器端口3000映射到等效的主机端口。端口映射是必要的;否则,我们无法从容器外部访问在容器内运行的应用程序。我们将在第十章单主机网络中学到更多关于端口映射的知识。

与我们在主机上直接运行应用程序时类似,输出如下:

Application listening at 0.0.0.0:3000
  1. 刷新之前的浏览器标签(或者打开一个新的浏览器标签并导航到localhost:3000,如果你关闭了它)。你应该看到应用程序仍然运行,并产生与本地运行时相同的输出。这很好。我们刚刚证明了我们的应用不仅在我们的主机上运行,而且在容器内部也可以运行。

  2. 通过在终端中按Ctrl + C停止和删除容器。

  3. 现在让我们修改我们的代码并添加一些额外的功能。我们将在/hobbies处定义另一个HTTP GET端点。请将以下代码片段添加到您的index.js文件中:

const hobbies = [
  'Swimming', 'Diving', 'Jogging', 'Cooking', 'Singing'
];

app.get('/hobbies', (req,res)=>{
  res.send(hobbies);
})

我们可以首先在主机上运行应用程序,通过node index.js运行应用程序,并在浏览器中导航到localhost:3000/hobbies。我们应该在浏览器窗口中看到预期的输出。测试完成后,不要忘记使用Ctrl + C停止应用程序。

  1. 接下来,我们需要测试代码在容器内运行时的情况。因此,首先,我们创建一个新版本的容器映像:
$ docker image build -t sample-app .
  1. 接下来,我们从这个新映像运行一个容器:
$ docker container run --rm -it \
    --name my-sample-app \
    -p 3000:3000 \
    sample-app 

现在,我们可以在浏览器中导航到localhost:3000/hobbies,并确认应用程序在容器内部也按预期工作。再次强调,测试完成后,请不要忘记通过按Ctrl + C停止容器。

我们可以一遍又一遍地重复这一系列任务,为我们添加的每个功能或改进的现有功能。事实证明,与我们开发的所有应用程序都直接在主机上运行的时候相比,这增加了很多摩擦。

然而,我们可以做得更好。在下一节中,我们将看一种技术,它允许我们消除大部分摩擦。

将不断发展的代码装载到正在运行的容器中

如果在代码更改后,我们不必重新构建容器映像并重新运行容器呢?如果我们在编辑器(如 VS Code)中保存更改后,更改立即在容器内部可用,这不是很好吗?好吧,使用卷映射确实可以做到这一点。在上一章中,我们学习了如何将任意主机文件夹映射到容器内的任意位置。我们想要在本节中利用这一点。

第五章中,数据卷和配置,我们看到了如何将主机文件夹映射为容器中的卷。例如,如果我想要将主机文件夹/projects/sample-app挂载到容器中的/app,则其语法如下:

$ docker container run --rm -it \
 --volume /projects/sample-app:/app \
 alpine /bin/sh

注意行--volume <host-folder>:<container-folder>。主机文件夹的路径需要是绝对路径,就像示例中的/projects/sample-app一样。

如果我们现在想要从我们的sample-app容器映像运行一个容器,并且如果我们从项目文件夹中这样做,那么我们可以将当前文件夹映射到容器的/app文件夹中,如下所示:

$ docker container run --rm -it \
 --volume $(pwd):/app \
    -p 3000:3000 \

请注意$(pwd)代替主机文件夹路径。$(pwd)会计算为当前文件夹的绝对路径,这非常方便。

现在,如果我们按照上述描述将当前文件夹挂载到容器中,那么sample-app容器映像的/app文件夹中的内容将被映射主机文件夹的内容覆盖,也就是在我们的情况下是当前文件夹。这正是我们想要的 - 我们希望将主机中的当前源映射到容器中。

让我们测试一下是否有效:

  1. 如果您已经启动了容器,请按Ctrl + C停止它。

  2. 然后将以下代码片段添加到index.js文件的末尾:

app.get('/status', (req,res)=>{
  res.send('OK');
})

不要忘记保存。

  1. 然后再次运行容器 - 这次不需要先重新构建镜像 - 看看会发生什么:
$ docker container run --rm -it \
    --name my-sample-app \
 --volume $(pwd):/app \
 -p 3000:3000 \
 sample-app
  1. 在浏览器中,导航到localhost:3000/status,并期望在浏览器窗口中看到OK输出。或者,您可以在另一个终端窗口中使用curl
$ curl localhost:3000/status
OK

对于所有在 Windows 和/或 Docker for Windows 上工作的人,您可以使用 PowerShell 命令Invoke-WebRequestiwr代替curl。然后,前面命令的等效命令将是iwr -Url localhost:3000/status

  1. 暂时让容器中的应用程序继续运行,并进行另一个更改。我们不仅希望在导航到/status时返回OK,还希望返回消息“OK,一切正常”。进行修改并保存更改。

  2. 然后再次执行curl命令,或者如果您使用了浏览器,请刷新页面。你看到了什么?没错 - 什么也没发生。我们所做的更改没有反映在运行的应用程序中。

  3. 好吧,让我们再次仔细检查更改是否已在运行的容器中传播。为此,让我们执行以下命令:

$ docker container exec my-sample-app cat index.js

我们应该看到类似这样的东西 - 我已经缩短了输出以便阅读:

...
app.get('/hobbies', (req,res)=>{
 res.send(hobbies);
})

app.get('/status', (req,res)=>{
 res.send('OK, all good');
})
...

显然,我们的更改已经按预期传播到容器中。那么,为什么更改没有反映在运行的应用程序中呢?嗯,答案很简单:要应用更改到应用程序,必须重新启动应用程序。

  1. 让我们试试看。通过按Ctrl + C停止运行应用程序的容器。然后重新执行前面的docker container run命令,并使用curl来探测端点localhost:3000/status。现在,应该显示以下新消息:
$ curl localhost:3000/status
 OK, all good

因此,通过在运行的容器中映射源代码,我们在开发过程中实现了摩擦的显著减少。现在,我们可以添加新的或修改现有的代码并进行测试,而无需首先构建容器镜像。然而,仍然存在一些摩擦。每次想要测试一些新的或修改过的代码时,我们必须手动重新启动容器。我们能自动化这个过程吗?答案是肯定的!我们将在下一节中具体演示这一点。

在更改后自动重启代码

很好,在上一节中,我们展示了如何通过在容器中进行源代码文件的卷映射来大大减少摩擦,从而避免不断重建容器镜像和重新运行容器。

但我们仍然感到一些摩擦。容器内运行的应用程序在代码更改发生时不会自动重启。因此,我们必须手动停止和重新启动容器才能应用新的更改。

Node.js 的自动重启

如果你编程一段时间了,肯定听说过一些有用的工具,可以在发现代码库中的更改时运行应用程序并自动重启它们。对于 Node.js 应用程序,最流行的工具就是nodemon。我们可以使用以下命令在系统上全局安装nodemon

$ npm install -g nodemon

现在,有了nodemon,我们可以不再用node index.js在主机上启动应用程序,而是直接执行nodemon,我们应该会看到以下内容:

使用 nodemon 运行 Node.js 应用程序

显然,nodemon已经从解析我们的package.json文件中认识到,它应该使用node index.js作为启动命令。

现在尝试更改一些代码,例如,在index.js的末尾添加以下代码片段,然后保存文件:

app.get('/colors', (req,res)=>{
 res.send(['red','green','blue']);
})

看一下终端窗口。你看到有什么发生了吗?你应该看到这个额外的输出:

[nodemon] restarting due to changes...
[nodemon] starting `node index.js`
Application listening at 0.0.0.0:3000

这清楚地表明nodemon已经认识到了一些更改,并自动重新启动了应用程序。通过浏览器尝试一下,导航到localhost:3000/colors。你应该在浏览器中看到以下预期的输出:

获取颜色

这很酷——你得到了这个结果,而不必手动重新启动应用程序。这使我们又多了一点生产力。现在,我们能在容器内做同样的事情吗?是的,我们可以。我们不会使用在我们的Dockerfile的最后一行中定义的启动命令node index.js

CMD node index.js

我们将使用nodemon代替。

我们需要修改我们的Dockerfile吗?还是我们需要两个不同的Dockerfiles,一个用于开发,一个用于生产?

我们的原始Dockerfile创建了一个不幸不包含nodemon的镜像。因此,我们需要创建一个新的Dockerfile。让我们称之为Dockerfile-dev。它应该是这样的:

FROM node:latest          
RUN npm install -g nodemon
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
CMD nodemon

与我们的原始 Dockerfile 相比,我们添加了第 2 行,安装了nodemon。我们还改变了最后一行,现在使用nodemon作为我们的启动命令。

让我们按照以下方式构建我们的开发镜像:

$ docker image build -t sample-app-dev .

我们将像这样运行一个容器:

$ docker container run --rm -it \
   -v $(pwd):/app \
   -p 3000:3000 \
   sample-app-dev

现在,当应用程序在容器中运行时,改变一些代码,保存,并注意到容器内的应用程序会自动重新启动。因此,我们在容器中运行时实现了与直接在主机上运行时相同的减少摩擦。

你可能会问,这只适用于 Node.js 吗?不,幸运的是,许多流行的语言支持类似的概念。

Python 的自动重启

让我们看看同样的东西在 Python 中是如何工作的:

  1. 首先,为我们的示例 Python 应用程序创建一个新的项目文件夹,并导航到它:
$ mkdir -p ~/fod/ch06/python && cd ~/fod/ch06/python
  1. 使用命令code .从这个文件夹中打开 VS Code。

  2. 我们将创建一个使用流行的 Flask 库的示例 Python 应用程序。因此,向这个文件夹添加一个requirements.txt文件,其中包含flask的内容。

  3. 接下来,添加一个main.py文件,并给它这个内容:

from flask import Flask
app = Flask(__name__)

@app.route("/")
def hello():
  return "Hello World!"

if __name__ == "__main__":
  app.run()

这是一个简单的Hello World类型的应用程序,在localhost:5000/上实现了一个 RESTful 端点。

  1. 在我们可以运行和测试这个应用程序之前,我们需要安装依赖项——在我们的情况下是 Flask。在终端中运行以下命令:
$ pip install -r requirements.txt

这应该在你的主机上安装 Flask。我们现在准备好了。

  1. 在使用 Python 时,我们也可以使用nodemon来在代码发生任何更改时自动重新启动我们的应用程序。例如,假设你的启动 Python 应用程序的命令是python main.py。那么你只需要像下面这样使用nodemon
$ nodemon main.py

你应该看到这个:

  1. 使用nodemon启动和监视 Python 应用程序,我们可以使用curl测试该应用程序,并应该看到这个:
$ curl localhost:5000/
Hello World!
  1. 现在让我们通过将此片段添加到main.py中的/端点的定义之后,并保存来修改代码:
from flask import jsonify

@app.route("/colors")
def colors():
   return jsonify(["red", "green", "blue"])

nodemon将发现更改并重新启动 Python 应用程序,正如我们可以在终端产生的输出中看到的那样:

nodemon 发现 Python 代码的更改

  1. 再次,相信是好的,测试更好。因此,让我们再次使用我们的朋友curl来探测新的端点,看看我们得到了什么:
$ curl localhost:5000/colors
["red", "green", "blue"]

很好-它有效!有了这个,我们已经涵盖了 Python。.NET 是另一个流行的平台。让我们看看在.NET 上开发 C#应用程序时是否可以做类似的事情。

.NET 的自动重启

我们的下一个候选者是用 C#编写的.NET 应用程序。让我们看看.NET 中的自动重启是如何工作的。

  1. 首先,为我们的示例 C#应用程序创建一个新的项目文件夹并导航到它:
$ mkdir -p ~/fod/ch06/csharp && cd ~/fod/ch06/csharp

如果您以前没有这样做,请在您的笔记本电脑或工作站上安装.NET Core。您可以在dotnet.microsoft.com/download/dotnet-core上获取它。在撰写本文时,版本 2.2 是当前稳定版本。安装完成后,使用dotnet --version检查版本。对我来说是2.2.401

  1. 导航到本章的源文件夹:
$ cd ~/fod/ch06
  1. 从这个文件夹内,使用dotnet工具创建一个新的 Web API,并将其放在dotnet子文件夹中:
$ dotnet new webapi -o dotnet
  1. 导航到这个新项目文件夹:
$ cd dotnet
  1. 再次使用code .命令从dotnet文件夹内打开 VS Code。

如果这是您第一次使用 VS Code 打开.NET Core 2.2 项目,那么编辑器将开始下载一些 C#依赖项。等到所有依赖项都下载完成。编辑器可能还会显示一个弹出窗口,询问您是否要为我们的dotnet项目添加缺少的依赖项。在这种情况下点击“是”按钮。

在 VS Code 的项目资源管理器中,您应该看到这个:

在 VS Code 项目资源管理器中的 DotNet Web API 项目

  1. 请注意Controllers文件夹中的ValuesController.cs文件。打开此文件并分析其内容。它包含了ValuesController类的定义,该类实现了一个简单的 RESTful 控制器,其中包含GETPUTPOSTDELETE端点在api/values

  2. 从您的终端运行应用程序使用 dotnet run。您应该会看到类似以下内容:

在主机上运行.NET 示例 Web API

  1. 我们可以使用 curl 测试应用程序,例如:
$ curl --insecure https://localhost:5001/api/values ["value1","value2"]

应用程序运行并返回了预期的结果。

请注意,默认情况下,该应用程序配置为将 http://localhost:5000 重定向到 https://localhost:5001。但是,这是一个不安全的端点,为了抑制警告,我们使用 --insecure 开关。

  1. 现在我们可以尝试修改 ValuesController.cs 中的代码,并从第一个 GET 端点返回三个项目而不是两个。
[HttpGet]
public ActionResult<IEnumerable<string>> Get()
{
    return new string[] { "value1", "value2", "value3" };
}
  1. 保存您的更改并重新运行 curl 命令。注意结果不包含新添加的值。这与我们观察到的 Node.js 和 Python 的问题相同。要查看新更新的返回值,我们需要(手动)重新启动应用程序。

  2. 因此,在您的终端中,使用 Ctrl + C 停止应用程序,并使用 dotnet run 重新启动。再次尝试 curl 命令。结果现在应该反映您的更改。

  3. 幸运的是,dotnet 工具有 watch 命令。通过按 Ctrl + C 停止应用程序并执行 dotnet watch run。您应该会看到类似以下内容的输出:

使用 watch 任务运行.NET 示例应用程序

注意前面输出的第二行,指出正在运行的应用程序现在正在监视更改。

  1. ValuesController.cs 中进行另一个更改;例如,在第一个 GET 端点的返回值中添加第四个项目并保存。观察终端中的输出。它应该看起来像这样:

自动重新启动正在运行的.NET Core 示例应用程序

  1. 通过对代码进行更改,应用程序会自动重新启动,结果立即对我们可用,并且我们可以通过运行 curl 命令轻松测试它:
$ curl --insecure https://localhost:5001/api/values ["value1","value2","value3","value4"]
  1. 现在我们在主机上有自动重启工作,我们可以编写一个 Dockerfile,在容器内运行的应用程序也可以实现相同的功能。在 VS Code 中,向项目添加一个名为 Dockerfile-dev 的新文件,并向其中添加以下内容:
FROM mcr.microsoft.com/dotnet/core/sdk:2.2
WORKDIR /app
COPY dotnet.csproj ./
RUN dotnet restore
COPY . .
CMD dotnet watch run
  1. 在我们继续构建容器镜像之前,我们需要对.NET 应用程序的启动配置进行轻微修改,使得 Web 服务器(在这种情况下是 Kestrel)监听,例如,0.0.0.0:3000,因此能够在容器内运行并且能够从容器外部访问。打开Program.cs文件,并对CreateWebHostBuilder方法进行以下修改:
public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
    WebHost.CreateDefaultBuilder(args)
    .UseUrls("http://0.0.0.0:3000")
    .UseStartup<Startup>();

通过UseUrls方法,我们告诉 Web 服务器监听所需的端点。

现在我们准备构建容器镜像:

  1. 使用以下命令构建镜像:
$ docker image build -f Dockerfile-dev -t sample-app-dotnet .
  1. 一旦镜像构建完成,我们就可以从中运行一个容器:
$ docker container run --rm -it \
   -p 3000:3000 \
   -v $(pwd):/app \
   sample-app-dotnet

我们应该看到类似于本地运行时看到的输出:

在容器中运行的.NET 示例应用程序

  1. 让我们用我们的朋友curl来测试应用程序:
$ curl localhost:3000/api/values
["value1","value2","value3","value4"]
$
$ curl localhost:3000/api/values/1
value

这里没有什么意外——它按预期工作。

  1. 现在让我们在控制器中进行代码更改,然后保存。观察终端窗口中发生的情况。我们应该看到类似于这样的输出:

在容器内运行的.NET 示例应用程序的自动重启

好吧,这正是我们所期望的。通过这样做,我们已经消除了在开发.NET 应用程序时使用容器引入的大部分摩擦。

在容器内逐行代码调试

在我们深入讨论容器内运行代码的逐行调试之前,让我先做一个声明。你将在这里学到的东西通常应该是你最后的选择,如果其他方法都不起作用的话。理想情况下,在开发应用程序时遵循测试驱动的方法,由于你已经为它编写了单元测试和集成测试,并对代码进行了测试,所以代码大部分是可以保证工作的,这些测试也是在容器中运行的。或者,如果单元测试或集成测试不能为你提供足够的洞察力,你确实需要逐行调试你的代码,你可以在主机上直接运行你的代码,从而利用开发环境的支持,比如 Visual Studio、Eclipse 或 IntelliJ 等 IDE。

通过这一切准备,你应该很少需要手动调试你的代码,因为它是在容器内运行的。也就是说,让我们看看你如何做到这一点!

在本节中,我们将专注于如何在使用 Visual Studio Code 时进行调试。其他编辑器和 IDE 可能或可能不提供类似的功能。

调试 Node.js 应用程序

我们将从最简单的开始——一个 Node.js 应用程序。我们将使用我们在本章早些时候使用过的~/fod/ch06/node文件夹中的示例应用程序:

  1. 确保您导航到此项目文件夹并从其中打开 VS Code:
$ cd ~/fod/ch06/node
$ code .
  1. 在终端窗口中,从项目文件夹内部,运行一个带有我们示例 Node.js 应用程序的容器:
$ docker container run --rm -it \
   --name my-sample-app \
   -p 3000:3000 \
   -p 9229:9229 \
   -v $(pwd):/app \
   sample-app node --inspect=0.0.0.0 index.js

注意我是如何将端口9229映射到主机的。这个端口是调试器使用的,VS Studio 将通过这个端口与我们的 Node 应用程序通信。因此,重要的是您打开这个端口——但只在调试会话期间!还要注意,我们用node --inspect=0.0.0.0 index.js覆盖了 Dockerfile 中定义的标准启动命令(node index.js)。--inspect=0.0.0.0告诉 Node 以调试模式运行,并在容器中监听所有 IP4 地址。

现在我们准备为手头的场景定义一个 VS Code 启动任务,也就是我们的代码在容器内运行:

  1. 要打开launch.json文件,按Ctrl+Shift+P(或在 Windows 上按Ctrl+Shift+P)打开命令面板,然后搜索Debug:Open launch.json并选择它。launch.json文件应该在编辑器中打开。

  2. 点击蓝色的“Add Configuration...”按钮,添加我们需要在容器内调试的新配置。

  3. 从选项中选择Docker: Attach to Node。新条目将被添加到launch.json文件的配置列表中。它应该看起来类似于这样:

{
  "type": "node",
  "request": "attach",
  "name": "Docker: Attach to Node",
  "remoteRoot": "/usr/src/app"
},

由于我们的代码在/app文件夹中,容器内部,我们需要相应地更改remoteRoot的值。将/usr/src/app的值更改为/app。不要忘记保存您的更改。就是这样,我们已经准备好了。

  1. 通过按下command + Shift + D(在 Windows 上为Ctrl + Shift + D)来打开 VS Code 中的调试视图。

  2. 确保您在视图顶部的绿色启动按钮旁边的下拉菜单中选择正确的启动任务。选择Docker: Attach to Node如下所示:

在 VS Code 中选择正确的启动任务进行调试

  1. 接下来,点击绿色的启动按钮,将 VS Code 连接到运行在容器中的 Node 应用程序。

  2. 在编辑器中打开index.js,并在调用端点'/'时在返回消息"Sample Application: Hello World!"的行上设置断点。

  3. 在另一个终端窗口中,使用curl导航到localhost:3000/,并观察代码执行是否在断点处停止:

代码执行在断点处停止

在前面的屏幕截图中,我们可以看到黄色条表示代码执行已在断点处停止。在右上角,我们有一个工具栏,允许我们浏览代码,例如,逐步执行。在左侧,我们看到VARIABLESWATCHCALL STACK窗口,我们可以使用它们来观察我们运行的应用程序的细节。我们真正调试运行在容器内的代码的事实可以通过在启动容器的终端窗口中看到输出Debugger attached.来验证,这是我们在 VS Code 中开始调试时生成的。

让我们看看如何进一步改进调试体验:

  1. 要停止容器,请在终端中输入以下命令:
$ docker container rm -f my-sample-app
  1. 如果我们想要使用nodemon来获得更大的灵活性,那么我们必须稍微改变container run命令:
$ docker container run --rm -it \
   --name my-sample-app \
   -p 3000:3000 \
   -p 9229:9229 \
   -v $(pwd):/app \
   sample-app-dev nodemon --inspect=0.0.0.0 index.js

注意我们如何使用启动命令nodemon --inspect=0.0.0.0 index.js。这将带来一个好处,即在任何代码更改时,容器内运行的应用程序将自动重新启动,就像我们在本章前面学到的那样。您应该看到以下内容:

使用 nodemon 启动 Node.js 应用程序并打开调试功能

  1. 不幸的是,应用程序重新启动的后果是调试器与 VS Code 失去了连接。但别担心,我们可以通过在launch.json文件中的启动任务中添加"restart": true来减轻这一点。修改任务,使其看起来像这样:
{
  "type": "node",
  "request": "attach",
  "name": "Docker: Attach to Node",
  "remoteRoot": "/app",
  "restart": true
},
  1. 保存更改后,通过单击调试窗口中的绿色启动按钮在 VS Code 中启动调试器。在终端中,您应该再次看到输出Debugger attached.。除此之外,VS Code 在底部显示一个橙色状态栏,指示编辑器处于调试模式。

  2. 在另一个终端窗口中,使用curl并尝试导航到localhost:3000/,以测试逐行调试是否仍然有效。确保代码执行在代码中设置的任何断点处停止。

  3. 一旦您验证了调试仍然有效,请尝试修改一些代码;例如,将消息"Sample Application: Hello World!"更改为"Sample Application: Message from within container",然后保存您的更改。观察nodemon如何重新启动应用程序,并且调试器自动重新附加到容器内运行的应用程序:

nodemon重新启动应用程序,并且调试器自动重新附加到应用程序

有了这些,我们现在可以像在主机上本地运行相同的代码一样,在容器内运行代码。我们已经基本上消除了引入容器带来的开发过程中的所有摩擦。现在我们可以享受在容器中部署代码的好处。

清理时,按Ctrl + C停止容器。

调试.NET 应用程序

现在我们想快速介绍一下如何逐行调试.NET 应用程序。我们将使用本章前面创建的示例.NET 应用程序。

  1. 转到项目文件夹并从其中打开 VS Code:
$ cd ~/fod/ch06/dotnet
$ code .
  1. 要使用调试器,我们首先需要在容器中安装调试器。因此,让我们在项目目录中创建一个新的Dockerfile。将其命名为Dockerfile-debug并添加以下内容:
FROM mcr.microsoft.com/dotnet/core/sdk:2.2
RUN apt-get update && apt-get install -y unzip && \
    curl -sSL https://aka.ms/getvsdbgsh | \
        /bin/sh /dev/stdin -v latest -l ~/vsdbg
WORKDIR /app
COPY dotnet.csproj ./
RUN dotnet restore
COPY . .
CMD dotnet watch run

请注意Dockerfile的第二行,它使用apt-get安装unzip工具,然后使用curl下载并安装调试器。

  1. 我们可以按照以下方式从这个Dockerfile构建一个名为sample-app-dotnet-debug的镜像:
$ docker image build -t sample-app-dotnet-debug .

这个命令可能需要一些时间来执行,因为调试器需要下载和安装。

  1. 完成后,我们可以从这个镜像中交互式运行一个容器:
$ docker run --rm -it \
   -v $(pwd):/app \
   -w /app \
   -p 3000:3000 \
   --name my-sample-app \
   --hostname sample-app \
   sample-app-dotnet-debug

我们会看到类似这样的东西:

在 SDK 容器内交互式启动示例.NET 应用程序

  1. 在 VS Code 中,打开launch.json文件并添加以下启动任务:
{
   "name": ".NET Core Docker Attach",
   "type": "coreclr",
   "request": "attach",
   "processId": "${command:pickRemoteProcess}",
   "pipeTransport": {
      "pipeProgram": "docker",
      "pipeArgs": [ "exec", "-i", "my-sample-app" ],
      "debuggerPath": "/root/vsdbg/vsdbg",
      "pipeCwd": "${workspaceRoot}",
      "quoteArgs": false
   },
   "sourceFileMap": {
      "/app": "${workspaceRoot}"
   },
   "logging": {
      "engineLogging": true
   }
},
  1. 保存您的更改,并切换到 VS Code 的调试窗口(使用command + Shift + DCtrlShift + *D *打开它)。确保您已选择了正确的调试启动任务——它的名称是.NET Core Docker Attach

在 VS Code 中选择正确的调试启动任务

  1. 现在单击绿色的启动按钮启动调试器。因此,弹出窗口显示了要附加到的潜在进程列表。选择看起来像下面截图中标记的进程。

选择要附加调试器的进程

  1. 让我们在ValuesController.cs文件的第一个GET请求中设置一个断点,然后执行一个curl命令:
$ curl localhost:3000/api/values

代码执行应该在断点处停止,如下所示:

在容器内运行的.NET Core 应用程序进行逐行调试

  1. 现在我们可以逐步执行代码,定义观察点,或者分析应用程序的调用堆栈,类似于我们在示例 Node.js 应用程序中所做的。单击调试工具栏上的“继续”按钮或按F5继续执行代码。

  2. 现在更改一些代码并保存更改。观察终端窗口中应用程序如何自动重新启动。

  3. 再次使用curl测试您的更改是否对应用程序可见。确实,更改是可用的,但您是否注意到了什么?是的,代码执行没有从断点开始。不幸的是,重新启动应用程序会导致调试器断开连接。您必须通过单击 VS Code 调试视图中的启动按钮并选择正确的进程来重新附加调试器。

  4. 要停止应用程序,请在启动容器的终端窗口中按Ctrl + C

现在我们知道如何逐行调试容器中运行的代码,是时候为我们的代码添加有意义的日志信息了。

为您的代码添加有意义的日志信息

一旦应用程序在生产环境中运行,就不可能或者强烈不建议交互式调试应用程序。因此,当系统行为异常或引起错误时,我们需要想出其他方法来找到根本原因。最好的方法是让应用程序生成详细的日志信息,然后开发人员可以使用这些信息来跟踪任何错误。由于日志记录是如此常见的任务,所有相关的编程语言或框架都提供了使应用程序内部生成日志信息的库。

将应用程序输出的信息分类为日志,并称为严重级别是很常见的。以下是这些严重级别的列表,以及每个的简短描述:

安全级别 解释
TRACE 非常精细的信息。在这个级别,您正在捕获关于应用程序行为的每一个可能的细节。
DEBUG 相对细粒度和大多数诊断信息,有助于确定潜在问题。
INFO 正常的应用程序行为或里程碑。
WARN 应用程序可能遇到问题,或者您检测到异常情况。
ERROR 应用程序遇到严重问题。这很可能代表了重要应用程序任务的失败。
FATAL 应用程序的灾难性失败。建议立即关闭应用程序。

生成日志信息时使用的严重级别列表

日志库通常允许开发人员定义不同的日志接收器,即日志信息的目的地。常见的接收器是文件接收器或控制台流。在使用容器化应用程序时,强烈建议始终将日志输出定向到控制台或STDOUT。然后 Docker 将通过docker container logs命令向您提供此信息。还可以使用其他日志收集器,如 Prometheus,来抓取此信息。

为 Python 应用程序进行仪器化

现在让我们尝试为我们现有的 Python 示例应用程序进行仪器化:

  1. 首先,在您的终端中,导航到项目文件夹并打开 VS Code:
$ cd ~/fob/ch06/python
$ code .
  1. 打开main.py文件,并在顶部添加以下代码片段:

为我们的 Python 示例应用程序定义一个记录器

在第1行,我们导入标准的logging库。然后我们在第3行为我们的示例应用程序定义一个logger。在第4行,我们定义要使用的日志过滤器。在这种情况下,我们将其设置为WARN。这意味着应用程序产生的所有日志消息,其严重程度等于或高于WARN,将被输出到在本节开头称为logging处理程序或接收器的定义。在我们的情况下,只有具有WARNERRORFATAL日志级别的日志消息将被输出。

在第6行,我们创建了一个日志接收器或处理程序。在我们的情况下,它是StreamHandler,输出到STDOUT。然后,在第8行,我们定义了我们希望logger如何格式化输出的消息。在这里,我们选择的格式将输出时间和日期、应用程序(或logger)名称、日志严重级别,最后是我们开发人员在代码中定义的实际消息。在第9行,我们将格式化程序添加到日志处理程序中,在第10行,我们将处理程序添加到logger中。请注意,我们可以为每个 logger 定义多个处理程序。现在我们准备使用logger了。

  1. 让我们来对hello函数进行仪器化,当我们导航到端点/时会调用该函数:

使用日志记录对方法进行仪器化

如您在上面的截图中所见,我们在第17行添加了一行,我们在那里使用logger对象生成了一个日志级别为INFO的日志消息。消息是:"访问端点'/'"。

  1. 让我们对另一个函数进行仪器化,并输出一个日志级别为WARN的消息:

生成一个警告

这一次,我们在colors函数的第24行以WARN日志级别生成了一条消息。到目前为止,一切都很顺利——这并不难!

  1. 现在让我们运行应用程序,看看我们得到什么输出:
$ python main.py
  1. 然后,在浏览器中,首先导航到localhost:5000/,然后导航到localhost:5000/colors。您应该看到类似于这样的输出:

运行经过仪器化的示例 Python 应用程序

如您所见,只有警告被输出到控制台;INFO消息没有。这是由于我们在定义 logger 时设置的过滤器。还请注意,我们的日志消息是如何以日期和时间开头,然后是 logger 的名称,日志级别,最后是我们在应用程序的第24行定义的实际消息。完成后,请按Ctrl + C停止应用程序。

对.NET C#应用程序进行仪器化

现在让我们对我们的示例 C#应用程序进行仪器化:

  1. 首先,导航到项目文件夹,从那里您将打开 VS Code:
$ cd ~/fod/ch06/dotnet
$ code .
  1. 接下来,我们需要向项目添加一个包含日志库的 NuGet 包:
$ dotnet add package Microsoft.Extensions.Logging

这应该会将以下行添加到您的dotnet.csproj项目文件中:

<PackageReference  Include="Microsoft.Extensions.Logging"  Version="2.2.0"  />
  1. 打开Program.cs类,并注意我们在第21行调用了CreateDefaultBuilder(args)方法。

在 ASP.NET Core 2.2 中配置日志记录

默认情况下,此方法向应用程序添加了一些日志提供程序,其中包括控制台日志提供程序。这非常方便,使我们无需进行任何复杂的配置。当然,您可以随时使用自己的设置覆盖默认设置。

  1. 接下来,在Controllers文件夹中打开ValuesController.cs文件,并在文件顶部添加以下using语句:
using Microsoft.Extensions.Logging;
  1. 然后,在类主体中,添加一个名为_logger的实例变量,类型为ILogger,并添加一个具有类型为ILogger<T>的参数的构造函数。将此参数分配给实例变量_logger

为 Web API 控制器定义一个记录器

  1. 现在我们准备在控制器方法中使用记录器。让我们使用INFO消息对Get方法进行调试:

从 API 控制器记录 INFO 消息

  1. 现在让我们对Get(int id)方法进行一些调试:

使用日志级别 WARN 和 ERROR 记录消息

在第 31 行,我们让记录器生成一个 DEBUG 消息,然后在第 32 行对id的意外值进行一些逻辑处理,并生成 ERROR 消息并返回 HTTP 响应状态 404(未找到)。

  1. 让我们使用以下内容运行应用程序:
$ dotnet run
  1. 当导航到localhost:3000/api/values时,我们应该看到这个:

访问端点/api/values时我们示例.NET 应用程序的日志

我们可以看到我们的 INFO 类型的日志消息输出。所有其他日志项都是由 ASP.NET Core 库生成的。您可以看到如果需要调试应用程序,则有大量有用的信息可用。

  1. 现在让我们尝试使用无效的{id}值访问端点/api/values/{id}。我们应该看到类似于以下内容:

我们的.NET 示例应用程序生成的调试和错误日志项

我们首先可以清楚地看到级别为DEBUG的日志项,然后是级别为ERROR的日志项。输出中后者标记为fail并以红色显示。

  1. 完成后,请使用Ctrl + C 结束应用程序。

现在我们已经了解了如何进行调试,接下来我们将在下一节中学习 Jaeger。

使用 Jaeger 进行监视和故障排除

当我们想要监视和排查复杂分布式系统中的事务时,我们需要比我们刚刚学到的更强大的东西。当然,我们可以并且应该继续用有意义的日志消息来仪器化我们的代码,但我们需要更多的东西。这更多是追踪单个请求或事务的能力,从而使其在由许多应用服务组成的系统中流动时,我们可以端到端地追踪它。理想情况下,我们还希望捕获其他有趣的指标,比如在每个组件上花费的时间与请求所花费的总时间。

幸运的是,我们不必重新发明轮子。有经过实战考验的开源软件可以帮助我们实现上述目标。这样一个基础设施组件或软件的例子就是 Jaeger(www.jaegertracing.io/)。使用 Jaeger 时,您运行一个中央 Jaeger 服务器组件,每个应用组件都使用一个 Jaeger 客户端,该客户端会将调试和跟踪信息透明地转发到 Jaeger 服务器组件。对于所有主要的编程语言和框架,如 Node.js、Python、Java 和.NET,都有 Jaeger 客户端。

我们不会在本书中详细介绍如何使用 Jaeger 的所有细节,但会对其工作原理进行高层次的概述:

  1. 首先,我们定义一个 Jaegertracer对象。这个对象基本上协调了我们的分布式应用程序中追踪请求的整个过程。我们可以使用这个tracer对象,还可以从中创建一个logger对象,我们的应用代码可以使用它来生成日志项,类似于我们在之前的 Python 和.NET 示例中所做的。

  2. 接下来,我们需要用 Jaeger 称为span的代码来包装每个方法。span有一个名称,并为我们提供一个scope对象。让我们看一些 C#伪代码,以说明这一点:

public void SayHello(string helloTo) {
  using(var scope = _tracer.BuildSpan("say-hello").StartActive(true)) {
    // here is the actual logic of the method
    ...
    var helloString = FormatString(helloTo);
    ...
  }
}

正如你所看到的,我们正在为SayHello方法进行仪器化。通过使用using语句创建一个 span,我们将整个该方法的应用代码进行包装。我们将 span 命名为"say-hello",这将是我们在 Jaeger 生成的跟踪日志中用来识别该方法的 ID。

请注意,该方法调用另一个嵌套方法FormatString。就需要为其进行仪器化所需的代码而言,这个方法看起来会非常相似:

public void string Format(string helloTo) {
   using(var scope = _tracer.BuildSpan("format-string").StartActive(true)) {
       // here is the actual logic of the method
       ...
       _logger.LogInformation(helloTo);
       return 
       ...
   }
}

我们的tracer对象在此方法中构建的 span 将是调用方法的子 span。这里的子 span 称为"format-string"。还要注意,我们在前面的方法中使用logger对象显式生成了一个级别为INFO的日志项。

在本章附带的代码中,您可以找到一个完整的 C#示例应用程序,其中包括一个 Jaeger 服务器容器和两个应用程序容器,称为客户端和库,它们使用 Jaeger 客户端库来对代码进行仪器化。

  1. 转到项目文件夹:
$ cd ~/fod/ch06/jaeger-sample
  1. 接下来,启动 Jaeger 服务器容器:
$ docker run -d --name jaeger \
   -e COLLECTOR_ZIPKIN_HTTP_PORT=9411 \
   -p 5775:5775/udp \
   -p 6831:6831/udp \
   -p 6832:6832/udp \
   -p 5778:5778 \
   -p 16686:16686 \
   -p 14268:14268 \
   -p 9411:9411 \
   jaegertracing/all-in-one:1.13
  1. 接下来,我们需要运行 API,它是作为 ASP.NET Core 2.2 Web API 组件实现的。转到api文件夹并启动组件:

启动 Jaeger 示例的 API 组件

  1. 现在打开一个新的终端窗口,然后进入client子文件夹,然后运行应用程序:
$ cd ~/fod/ch06/jaeger-sample/client
 $ dotnet run Gabriel Bonjour

请注意我传递的两个参数—GabrielBonjour—它们对应于<name><greeting>。您应该看到类似于这样的内容:

运行 Jaeger 示例应用程序的客户端组件

在前面的输出中,您可以看到用红色箭头标记的三个 span,从最内部到最外部的 span。我们还可以使用 Jaeger 的图形界面来查看更多细节:

  1. 在浏览器中,转到http://localhost:16686以访问 Jaeger UI。

  2. 在搜索面板中,确保选择了hello-world服务。将操作保留为all,然后点击Find Traces按钮。您应该看到以下内容:

Jaeger UI 的搜索视图

  1. 现在点击(唯一的)条目hello-world: say-hello以查看该请求的详细信息:

Jaeger 报告的请求细节

在前面的截图中,我们可以看到请求是如何从hello-world组件的say-hello方法开始,然后导航到同一组件中的format-string方法,然后调用Webservice中的一个端点,其逻辑是在FormatController控制器中实现的。对于每一步,我们都可以看到确切的时间以及其他有趣的信息。您可以在此视图中深入了解更多细节。

在继续之前,您可能想花些时间浏览一下我们刚刚用于此演示的 API 和client组件的代码。

  1. 清理时,请停止 Jaeger 服务器容器:
$ docker container rm -f jaeger

同时停止 API,使用Ctrl + C

摘要

在本章中,我们学习了如何调试在容器内运行的 Node.js、Python、Java 和.NET 代码。我们首先通过将主机的源代码挂载到容器中,以避免每次代码更改时重新构建容器映像。然后,我们进一步简化了开发过程,通过在代码更改时在容器内启用自动应用程序重启。接下来,我们学习了如何配置 Visual Studio Code 以启用在容器内运行的代码的完全交互式调试。最后,我们学习了如何对我们的应用程序进行配置,使其生成日志信息,这些信息可以帮助我们对在生产环境中运行的失败或行为不端的应用程序或应用程序服务进行根本原因分析。

在下一章中,我们将展示如何使用 Docker 容器可以加速您的自动化,从在容器中运行简单的自动化任务,到使用容器构建 CI/CD 流水线。

问题

请尝试回答以下问题,以评估您的学习进度:

  1. 列举两种有助于减少容器使用引入的开发过程中的摩擦的方法。

  2. 如何实现容器内代码的实时更新?

  3. 在何时以及为什么会使用在容器内运行的代码的逐行调试?

  4. 为什么在代码中加入良好的调试信息至关重要?

进一步阅读

第七章:使用 Docker 来加速自动化

在上一章中,我们介绍了通常用于允许开发人员在容器中演变、修改、调试和测试其代码的技术。我们还学习了如何对应用程序进行工具化,以便它们生成日志信息,这些信息可以帮助我们对在生产环境中运行的应用程序或应用服务的故障或异常行为进行根本原因分析。

在本章中,我们将展示如何使用工具执行管理任务,而无需在主机计算机上安装这些工具。我们还将说明托管和运行测试脚本或代码的容器,用于测试和验证在容器中运行的应用服务。最后,我们将指导读者构建一个基于 Docker 的简单 CI/CD 流水线。

这是本章中我们将涉及的所有主题的快速概述:

  • 在容器中执行简单的管理员任务

  • 使用测试容器

  • 使用 Docker 来驱动 CI/CD 流水线

完成本章后,您将能够执行以下操作:

  • 在容器中运行主机上不可用的工具

  • 使用容器来运行测试脚本或代码来对应用服务进行测试

  • 使用 Docker 构建一个简单的 CI/CD 流水线

技术要求

在本节中,如果您想跟着代码进行操作,您需要在 macOS 或 Windows 机器上安装 Docker for Desktop 和一个代码编辑器,最好是 Visual Studio Code。该示例也适用于安装了 Docker 和 VS Code 的 Linux 机器。

在容器中执行简单的管理员任务

假设您需要从文件中删除所有前导空格,并且您找到了以下方便的 Perl 脚本来做到这一点:

$ cat sample.txt | perl -lpe 's/^\s*//'

事实证明,您的工作机器上没有安装 Perl。你能做什么?在机器上安装 Perl 吗?嗯,这当然是一个选择,这也是大多数开发人员或系统管理员所做的。但等一下,您已经在机器上安装了 Docker。我们不能使用 Docker 来规避安装 Perl 的需要吗?是的,我们可以。这就是我们要做的:

  1. 创建一个名为ch07/simple-task的文件夹,并导航到它:
$ mkdir -p ~/fod/ch07/simple-task && cd ~/fod/ch07/simple-task
  1. 从这个文件夹中打开 VS Code:
$ code .
  1. 在这个文件夹中,创建一个名为sample.txt的文件,内容如下:
1234567890
  This is some text
   another line of text
 more text
     final line

请注意每行开头的空格。保存文件。

  1. 现在,我们可以运行一个安装了 Perl 的容器。幸运的是,Docker Hub 上有一个官方的 Perl 镜像。我们将使用镜像的 slim 版本:
$ docker container run --rm -it \
 -v $(pwd):/usr/src/app \
 -w /usr/src/app \
 perl:slim sh -c "cat sample.txt | perl -lpe 's/^\s*//'"

上面的命令以交互方式运行了一个 Perl 容器(perl:slim),将当前文件夹的内容映射到容器的/usr/src/app文件夹,并将容器内的工作文件夹设置为/usr/src/app。在容器内运行的命令是sh -c "cat sample.txt | perl -lpe 's/^\s*//'",基本上是生成一个 Bourne shell 并执行我们想要的 Perl 命令。

上面的命令生成的输出应该如下所示:

1234567890
This is some text
another line of text
more text
final line
  1. 无需在我们的机器上安装 Perl,我们就能实现我们的目标。

如果这还不能说服你,因为如果你在 macOS 上,你已经安装了 Perl,那么请考虑一下,你想要运行一个名为your-old-perl-script.pl的 Perl 脚本,它是旧的,不兼容你系统上已安装的最新版本的 Perl。你会尝试在你的机器上安装多个版本的 Perl 并可能破坏一些东西吗?不,你只需运行一个与你的脚本兼容的(旧)Perl 版本的容器,就像这个例子:

$ docker container run -it --rm \
-v $(pwd):/usr/src/app \
 -w /usr/src/app \
 perl:<old-version> perl your-old-perl-script.pl

这里,<old-version>对应于你需要运行你的脚本的 Perl 版本的标签。好处是,脚本运行后,容器将从你的系统中删除,不会留下任何痕迹,因为我们在docker container run命令中使用了--rm标志。

许多人使用快速而简单的 Python 脚本或迷你应用程序来自动化一些无法用 Bash 等编码的任务。现在,如果 Python 脚本是用 Python 3.7 编写的,而你只安装了 Python 2.7,或者根本没有在你的机器上安装任何版本,那么最简单的解决方案就是在容器内执行脚本。让我们假设一个简单的例子,Python 脚本统计给定文件中的行数、单词数和字母数,并将结果输出到控制台:

  1. ch07/simple-task文件夹中添加一个stats.py文件,并添加以下内容:
import sys

fname = sys.argv[1]
lines = 0
words = 0
letters = 0

for line in open(fname):
    lines += 1
    letters += len(line)

    pos = 'out'
    for letter in line:
        if letter != ' ' and pos == 'out':
            words += 1
            pos = 'in'
        elif letter == ' ':
            pos = 'out'

print("Lines:", lines)
print("Words:", words)
print("Letters:", letters)
  1. 保存文件后,您可以使用以下命令运行它:
$ docker container run --rm -it \
 -v $(pwd):/usr/src/app \
 -w /usr/src/app \
 python:3.7.4-alpine python stats.py sample.txt

请注意,在这个例子中,我们重用了之前的sample.txt文件。在我的情况下,输出如下:

Lines: 5
Words: 13
Letters: 81

这种方法的美妙之处在于,这个 Python 脚本现在可以在任何安装了任何操作系统的计算机上运行,只要这台机器是一个 Docker 主机,因此可以运行容器。

使用测试容器

对于每个严肃的软件项目,强烈建议进行大量的测试。有各种测试类别,如单元测试、集成测试、压力和负载测试以及端到端测试。我尝试在以下截图中可视化不同的类别:

应用程序测试的类别

单元测试断言整体应用程序或应用程序服务中的单个、孤立部分的正确性和质量。集成测试确保紧密相关的部分按预期工作在一起。压力和负载测试通常将应用程序或服务作为整体,并断言在各种边缘情况下的正确行为,例如通过服务处理的多个并发请求的高负载,或通过向服务发送大量数据来淹没服务。最后,端到端测试模拟真实用户与应用程序或应用程序服务的工作。用户通常会执行的任务被自动化。

受测试的代码或组件通常被称为系统测试对象SUT)。

单元测试在其性质上与实际代码或 SUT 紧密耦合。因此,这些测试必须在与受测试代码相同的上下文中运行。因此,测试代码与 SUT 位于同一容器中。SUT 的所有外部依赖项都是模拟的或存根的。

另一方面,集成测试、压力和负载测试以及端到端测试作用于系统测试对象的公共接口,因此最常见的是在单独的容器中运行测试代码:

使用容器的集成测试

在上图中,我们可以看到测试代码在其自己的测试容器中运行。测试代码访问也在专用容器中运行的API组件的公共接口。API组件具有外部依赖,如其他 服务数据库,它们分别在其专用容器中运行。在这种情况下,API其他 服务数据库的整个集合是我们的系统测试对象,或 SUT。

压力和负载测试会是什么样子?想象一种情况,我们有一个 Kafka Streams 应用程序需要进行测试。以下图表给出了我们可以从高层次上测试的具体内容:

压力和负载测试 Kafka Streams 应用程序

简而言之,Kafka Streams 应用程序从存储在 Apache Kafka(R)中的一个或多个主题中消费数据。该应用程序过滤、转换或聚合数据。结果数据被写回 Kafka 中的一个或多个主题。通常,在使用 Kafka 时,我们处理实时数据流入 Kafka。现在测试可以模拟以下情况:

  • 大量记录的大型主题

  • 数据以非常高的频率流入 Kafka

  • 应用程序在测试下分组的数据,其中有很多不同的键,每个键的基数很低

  • 按时间窗口聚合的数据,窗口的大小很小,例如,每个窗口只有几秒钟

端到端测试通过使用诸如 Selenium Web Driver 之类的工具自动化与应用程序交互的用户,该工具提供了开发者手段来自动执行给定网页上的操作,例如填写表单字段或点击按钮。

Node.js 应用程序的集成测试

现在让我们来看一个在 Node.js 中实现的样本集成测试。这是我们将要研究的设置:

Express JS 应用程序的集成测试

以下是创建这样一个集成测试的步骤:

  1. 让我们首先准备我们的项目文件夹结构。我们创建项目根目录并导航到它:
$ mkdir ~/fod/ch07/integration-test-node && \
    cd ~/fod/ch07/integration-test-node
  1. 在这个文件夹中,我们创建三个子文件夹,testsapidatabase
$ mkdir tests api database
  1. 现在,我们从项目根目录打开 VS Code:
$ code .
  1. database文件夹中,添加一个init-script.sql文件,内容如下:
CREATE TABLE hobbies(
 hobby_id serial PRIMARY KEY,
 hobby VARCHAR (255) UNIQUE NOT NULL
);

insert into hobbies(hobby) values('swimming');
insert into hobbies(hobby) values('diving');
insert into hobbies(hobby) values('jogging');
insert into hobbies(hobby) values('dancing');
insert into hobbies(hobby) values('cooking');

上述脚本将在我们的 Postgres 数据库中创建一个hobbies表,并填充一些种子数据。保存文件。

  1. 现在我们可以启动数据库。当然,我们将使用官方的 Docker 镜像来运行 Postgres 数据库。但首先,我们将创建一个 Docker 卷,数据库将在其中存储其文件。我们将称该卷为pg-data
$ docker volume create pg-data
  1. 现在,是时候运行数据库容器了。从项目根目录(integration-test-node)中运行以下命令:
$ docker container run -d \
 --name postgres \
 -p 5432:5432 \
 -v $(pwd)/database:/docker-entrypoint-initdb.d \
 -v pg-data:/var/lib/postgresql/data \
 -e POSTGRES_USER=dbuser \
 -e POSTGRES_DB=sample-db \
 postgres:11.5-alpine

请注意,运行上述命令的文件夹很重要,因为我们在数据库初始化脚本init-script.sql中使用了卷挂载。还要注意,我们正在使用环境变量来定义 Postgres 中数据库的名称和用户,并且我们正在将 Postgres 的端口5432映射到主机上的等效端口。

  1. 在启动数据库容器后,通过检索其日志来双重检查它是否按预期运行:
$ docker container logs postgres

你应该看到类似于这样的东西:

...
server started
CREATE DATABASE

/usr/local/bin/docker-entrypoint.sh: running /docker-entrypoint-initdb.d/init-db.sql
CREATE TABLE
INSERT 0 1
INSERT 0 1
INSERT 0 1
INSERT 0 1
INSERT 0 1

...

PostgreSQL init process complete; ready for start up.

2019-09-07 17:22:30.056 UTC [1] LOG: listening on IPv4 address "0.0.0.0", port 5432
...

注意,我们已经缩短了输出以便更好地阅读。前面输出的重要部分是前几行,我们可以看到数据库已经接受了我们的初始化脚本,创建了hobbies表并用五条记录进行了填充。最后一行也很重要,告诉我们数据库已经准备好工作。当解决问题时,容器日志总是你的第一站!

有了这个,我们的 SUT 的第一部分就准备好了。让我们继续下一个部分,也就是我们在 Express JS 中实现的 API:

  1. 在终端窗口中,导航到api文件夹:
$ cd ~/fod/ch07/integration-test-node/api
  1. 然后,运行npm init来初始化 API 项目。只接受所有默认值:
$ npm init

生成的package.json文件应该是这样的:

{
  "name": "api",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC"
}
  1. 修改上述文件的scripts节点,使其包含一个启动命令:

 在 package.json 文件中添加一个启动脚本

  1. 然后我们需要安装 Express JS,可以使用以下命令来完成:
$ npm install express --save

这将安装库及其所有依赖项,并在我们的package.json文件中添加一个类似于这样的依赖项节点:

将 Express JS 添加为 API 的依赖项

  1. api文件夹中,创建一个server.js文件,并添加以下代码片段:

简单的 Express JS API

这是一个简单的 Express JS API,只实现了/端点。它作为我们探索集成测试的起点。请注意,API 将在端口3000上监听,在容器内的所有端点(0.0.0.0)。

  1. 现在我们可以使用npm start启动 API,然后使用curl测试主页端点,例如:
$ curl localhost:3000
Sample API

经过所有这些步骤,我们已经准备好搭建测试环境了。

  1. 我们将使用jasmine来编写我们的测试。导航到tests文件夹并运行npm init来初始化测试项目:
$ cd ~/fod/ch07/integration-test-node/tests && \
    npm init

接受所有默认值。

  1. 接下来,将jasmine添加到项目中:
$ npm install --save-dev jasmine
  1. 然后为这个项目初始化jasmine
$ node node_modules/jasmine/bin/jasmine init
  1. 我们还需要更改我们的package.json文件,使得脚本块看起来像这样:

为我们的集成测试添加一个测试脚本

  1. 我们不能随时通过在tests文件夹中执行npm test来运行测试。第一次运行时,我们会收到错误提示,因为我们还没有添加任何测试:

第一次运行失败,因为没有找到测试

  1. 现在在项目的spec/support子文件夹中,让我们创建一个jasmine.json文件。这将包含jasmine测试框架的配置设置。将以下代码片段添加到此文件并保存:
{
  "spec_dir": "spec",
  "spec_files": [
    "**/*[sS]pec.js"
  ],
  "stopSpecOnExpectationFailure": false,
  "random": false
}
  1. 由于我们将要编写集成测试,我们希望通过其公共接口访问 SUT,而在我们的情况下,这是一个 RESTful API。因此,我们需要一个客户端库来允许我们这样做。我的选择是 Requests 库。让我们将其添加到我们的项目中:
$ npm install request --save-dev
  1. 在项目的spec子文件夹中添加一个api-spec.js文件。它将包含我们的测试函数。让我们从第一个开始:

API 的示例测试套件

我们正在使用request库来对我们的 API 进行 RESTful 调用(第 1 行)。然后,在第 3 行,我们定义了 API 正在监听的基本 URL。请注意,我们使用的代码允许我们使用环境变量BASE_URL来覆盖默认的http://localhost:3000。第 5 行定义了我们的测试套件,第 6 行有一个GET /的测试。然后我们断言两个结果,即GET调用/的状态码为200(OK),并且响应主体中返回的文本等于Sample API

  1. 如果我们现在运行测试,将得到以下结果:

成功运行基于 Jasmine 的集成测试

我们有两个规范——测试的另一个词——正在运行;所有这些都是成功的,因为我们没有报告任何失败。

  1. 在继续之前,请停止 API 并使用docker container rm -f postgres删除 Postgres 容器。

到目前为止一切顺利,但现在让我们把容器引入讨论。这是我们最感兴趣的部分,不是吗?我们很兴奋地运行所有东西,包括容器中的测试代码。如果你还记得,我们将处理三个容器,数据库、API 和包含测试代码的容器。对于数据库,我们只是使用标准的 Postgres Docker 镜像,但是对于 API 和测试,我们将创建自己的镜像:

  1. 让我们从 API 开始。在api文件夹中,添加一个Dockerfile文件,内容如下:
FROM node:alpine
WORKDIR /usr/src/app
COPY package.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD npm start

这只是创建一个基于 Node.js 的应用程序的容器镜像的非常标准的方法。这里没有什么特别的。

  1. tests文件夹中添加一个具有以下内容的 Dockerfile:
FROM node:alpine
WORKDIR /usr/src/app
COPY package.json ./
RUN npm install
COPY . .
CMD npm test
  1. 现在,我们准备按正确的顺序运行所有三个容器。为了简化这个任务,让我们创建一个 shell 脚本来做到这一点。将test.sh文件添加到integration-test-node文件夹中,我们的项目根文件夹。将以下内容添加到这个文件中并保存:
docker image build -t api-node api
docker image build -t tests-node tests

docker network create test-net

docker container run --rm -d \
 --name postgres \
 --net test-net \
 -v $(pwd)/database:/docker-entrypoint-initdb.d \
 -v pg-data:/var/lib/postgresql/data \
 -e POSTGRES_USER=dbuser \
 -e POSTGRES_DB=sample-db \
 postgres:11.5-alpine

docker container run --rm -d \
 --name api \
 --net test-net \
api-node

echo "Sleeping for 5 sec..."
sleep 5

docker container run --rm -it \
 --name tests \
 --net test-net \
 -e BASE_URL="http://api:3000" \
 tests-node

在脚本的前两行,我们确保 API 和测试的两个容器镜像都使用最新的代码构建。然后,我们创建一个名为test-net的 Docker 网络,我们将在这个网络上运行所有三个容器。暂时不用担心这个的细节,因为我们将在第十章中详细解释网络,单主机网络。暂且可以说,如果所有容器都在同一个网络上运行,那么在这些容器内运行的应用程序可以像在主机上本地运行一样看到彼此,并且它们可以通过名称相互调用。

下一个命令启动数据库容器,然后是启动 API 的命令。然后,我们暂停几秒钟,让数据库和 API 完全启动和初始化,然后我们启动第三个和最后一个容器,即测试容器。

  1. 使用以下命令将此文件设置为可执行文件:
$ chmod +x ./test.sh 
  1. 现在你可以运行它:
$ ./test.sh

如果一切按预期运行,你应该看到类似以下内容的东西(为了便于阅读而缩短):

...
Successfully built 44e0900aaae2
Successfully tagged tests-node:latest
b4f233c3578898ae851dc6facaa310b014ec86f4507afd0a5afb10027f10c79d
728eb5a573d2c3c1f3a44154e172ed9565606af8e7653afb560ee7e99275ecf6
0474ea5e0afbcc4d9cd966de17e991a6e9a3cec85c53a934545c9352abf87bc6
Sleeping for 10 sec...

> tests@1.0.0 test /usr/src/app
> jasmine

Started
..

2 specs, 0 failures
Finished in 0.072 seconds
  1. 我们还可以创建一个在测试后进行清理的脚本。为此,添加一个名为cleanup.sh的文件,并以与test.sh脚本相同的方式将其设置为可执行文件。将以下代码片段添加到这个文件中:
docker container rm -f postgres api
docker network rm test-net
docker volume rm pg-data

第一行删除postgresapi容器。第 2 行删除我们用于第三个容器的网络,最后,第 3 行删除 Postgres 使用的卷。在每次测试运行后,使用./cleanup.sh执行此文件。

现在你可以开始向你的 API 组件添加更多的代码和更多的集成测试。每次你想要测试新的或修改过的代码,只需运行test.sh脚本。

挑战:你如何进一步优化这个过程,以便需要更少的手动步骤?

使用我们在第六章中学到的内容,在容器中运行代码调试

Testcontainers 项目

如果您是 Java 开发人员,那么有一个名为 Testcontainers 的不错的项目(testcontainers.org)。用他们自己的话来说,该项目可以总结如下:

"Testcontainers 是一个支持 JUnit 测试的 Java 库,提供常见数据库、Selenium Web 浏览器或任何可以在 Docker 容器中运行的轻量级一次性实例。"要尝试 Testcontainer,请跟随以下步骤:

  1. 首先创建一个testcontainer-node文件夹并导航到它:
$ mkdir ~/fod/ch07/testcontainer-node && cd ~/fod/ch07/testcontainer-node
  1. 接下来,使用code .从该文件夹中打开 VS Code。在同一文件夹中创建三个子文件夹,databaseapitests。向api文件夹中添加一个package.json文件,并添加以下内容:

API 的 package.json 内容

  1. api文件夹添加一个server.js文件,并添加以下内容:

使用 pg 库访问 Postgres 的示例 API

在这里,我们创建一个在端口3000监听的 Express JS 应用程序。该应用程序使用pg库,这是一个用于 Postgres 的客户端库,用于访问我们的数据库。在第815行,我们正在定义一个连接池对象,它将允许我们连接到 Postgres 并检索或写入数据。在第2124行,我们正在定义一个GET方法,它位于/hobbies端点上,该端点通过 SQL 查询SELECT hobby FROM hobbies从数据库中检索到的爱好列表。

  1. 现在在同一文件夹中添加一个 Dockerfile,并添加以下内容:

API 的 Dockerfile

这与我们在上一个示例中使用的定义完全相同。有了这个,API 已经准备好使用了。现在让我们继续进行使用testcontainer库来简化基于容器的测试的测试。

  1. 在您的终端中,导航到我们之前创建的tests文件夹,并使用npm init将其初始化为一个 Node.js 项目。接受所有默认值。接下来,使用npm安装request库和testcontainers库:
$ npm install request --save-dev
$ npm install testcontainers --save-dev

其结果是一个package.json文件,应该看起来类似于这样:

测试项目的 package.json 文件

  1. 现在,在tests文件夹中,创建一个tests.js文件,并添加以下代码片段:
const request = require("request");
const path = require('path');
const dns = require('dns');
const os = require('os');
const { GenericContainer } = require("testcontainers");

(async () => {
 // TODO
})();

注意我们正在请求一个新对象,比如request对象,它将帮助我们访问示例 API 组件的 RESTful 接口。我们还从testcontainers库请求GenericContainer对象,它将允许我们构建和运行任何容器。

然后,我们定义一个异步自调用函数,它将作为我们设置和测试代码的包装器。它必须是一个异步函数,因为在其中,我们将等待其他异步函数,比如从testcontainers库使用的各种方法。

  1. 作为非常重要的一步,我们想使用testcontainers库来创建一个带有必要种子数据的 Postgres 容器。让我们在//TODO之后添加这段代码片段:
const localPath = path.resolve(__dirname, "../database");
const dbContainer = await new GenericContainer("postgres")
 .withName("postgres")
 .withExposedPorts(5432)
 .withEnv("POSTGRES_USER", "dbuser")
 .withEnv("POSTGRES_DB", "sample-db")
 .withBindMount(localPath, "/docker-entrypoint-initdb.d")
 .withTmpFs({ "/temp_pgdata": "rw,noexec,nosuid,size=65536k" })
 .start();

前面的代码片段与 Docker 的run命令有一些相似之处。这并非偶然,因为我们正在指示testcontainers库做的正是这样,为我们运行一个 PostgreSQL 实例。

  1. 接下来,我们需要找出暴露端口5432映射到哪个主机端口。我们可以用以下逻辑来做到这一点:
const dbPort = dbContainer.getMappedPort(5432);

我们将需要这些信息,因为 API 组件将需要通过这个端口访问 Postgres。

  1. 我们还需要知道主机在容器内可达的 IP 地址是哪个——注意,本地主机在容器内不起作用,因为这将映射到容器自己网络堆栈的环回适配器。我们可以这样获取主机 IP 地址:
const myIP4 = await lookupPromise();

lookupPromise函数是一个包装函数,使正常的异步dns.lookup函数返回一个 promise,这样我们就可以await它。这是它的定义:

async function lookupPromise(){
 return new Promise((resolve, reject) => {
 dns.lookup(os.hostname(), (err, address, family) => {
 if(err) throw reject(err);
 resolve(address);
 });
 });
};
  1. 现在,有了这些信息,我们准备指示testcontainer库首先为 API 构建容器镜像,然后从该镜像运行容器。让我们从构建开始:
const buildContext = path.resolve(__dirname, "../api");
const apiContainer = await GenericContainer
 .fromDockerfile(buildContext)
 .build();

注意这个命令如何使用我们在api子文件夹中定义的 Dockerfile。

  1. 一旦我们有了引用新镜像的apiContainer变量,我们就可以使用它来从中运行一个容器:
const startedApiContainer = await apiContainer
 .withName("api")
 .withExposedPorts(3000)
 .withEnv("DB_HOST", myIP4)
 .withEnv("DB_PORT", dbPort)
 .start();
  1. 再一次,我们需要找出 API 组件的暴露端口3000映射到哪个主机端口。testcontainer库使这变得轻而易举:
const apiPort = startedApiContainer.getMappedPort(3000);
  1. 通过这最后一行,我们已经完成了测试设置代码,现在终于可以开始实现一些测试了。我们首先定义要访问的 API 组件的基本 URL。然后,我们使用request库向/hobbies端点发出 HTTP GET 请求:
const base_url = `http://localhost:${apiPort}`
request.get(base_url + "/hobbies", (error, response, body) => {
 //Test code here...
})
  1. 现在让我们在//Test code here...注释之后实现一些断言:
console.log("> expecting status code 200");
if(response.statusCode != 200){
 logError(`Unexpected status code ${response.statusCode}`);
}

首先,当运行测试时,我们将我们的期望记录到控制台作为反馈。然后,我们断言返回的状态码是200,如果不是,我们会记录一个错误。logError辅助函数只是将给定的消息以红色写入控制台,并在前面加上***ERR。这是这个函数的定义:

function logError(message){
 console.log('\x1b[31m%s\x1b[0m', `***ERR: ${message}`);
}
  1. 让我们再添加两个断言:
const hobbies = JSON.parse(body);
console.log("> expecting length of hobbies == 5");
if(hobbies.length != 5){
 logError(`${hobbies.length} != 5`);
}
console.log("> expecting first hobby == swimming");
if(hobbies[0].hobby != "swimming"){
 logError(`${hobbies[0].hobby} != swimming`);
}

我把确切的断言做什么留给你,亲爱的读者,去找出来。

  1. 在断言结束时,我们必须进行清理,以便为下一次运行做好准备:
await startedApiContainer.stop()
await dbContainer.stop();

我们要做的就是停止 API 和数据库容器。这将自动将它们从内存中删除。

  1. 现在我们可以使用以下命令在tests子文件夹中运行这个测试套件:
$ node tests.js 

在我的情况下,输出看起来是这样的(注意,我在代码中添加了一些console.log语句,以更容易地跟踪到底在某个时间点发生了什么):

运行基于 testcontainer 的集成测试

完整的代码在您从 GitHub 克隆的示例代码存储库中提供。如果您在运行测试时遇到问题,请将您的实现与给定的示例解决方案进行比较。

现在我们已经很好地了解了如何使用容器来运行我们的集成测试,我们将继续进行另一个非常流行的基于容器的自动化用例,即构建持续集成和持续部署或交付(CI/CD)流水线。

使用 Docker 来支持 CI/CD 流水线

本节的目标是构建一个类似于以下的 CI/CD 流水线:

使用 Jenkins 的简单 CI/CD 流水线

我们将使用 Jenkins(jenkins.io)作为我们的自动化服务器。其他自动化服务器,如 TeamCity(www.jetbrains.com/teamcity)同样有效。在使用 Jenkins 时,中心文档是Jenkinsfile,其中包含了具有多个阶段的流水线的定义。

一个简单的JenkinsfileBuildTestDeploy to StagingDeploy to Production阶段可能是这样的:

pipeline {
    agent any
    options {
        skipStagesAfterUnstable()
    }
    stages {
        stage('Build') {
            steps {
                echo 'Building'
            }
        }
        stage('Test') {
            steps {
                echo 'Testing'
            }
        }
        stage('Deploy to Staging') {
            steps {
                echo 'Deploying to Staging'
            }
        }
        stage('Deploy to Production') {
            steps {
                echo 'Deploying to Production'
            }
        }
    }
}

当然,前面的流水线只是在每个阶段输出一条消息,什么也不做。尽管如此,它作为一个起点是有用的,可以从中构建我们的流水线。

  1. 创建一个名为jenkins-pipeline的项目文件夹并导航到它:
$ mkdir ~/fod/ch07/jenkins-pipeline && cd ~/fod/ch07/jenkins-pipeline
  1. 现在,让我们在 Docker 容器中运行 Jenkins。使用以下命令来执行:
$ docker run --rm -d \
 --name jenkins \
 -u root \
-p 8080:8080 \
-v jenkins-data:/var/jenkins_home \
 -v /var/run/docker.sock:/var/run/docker.sock \
 -v "$HOME":/home \
 jenkinsci/blueocean

请注意,我们正在作为容器内的root用户运行,并且我们正在将 Docker 套接字挂载到容器中(-v /var/run/docker.sock:/var/run/docker.sock),以便 Jenkins 可以从容器内访问 Docker。Jenkins 生成和使用的数据将存储在 Docker 卷jenkins-data中。

  1. 我们可以使用以下命令自动由 Jenkins 生成的初始管理员密码:
$ docker container exec jenkins cat /var/jenkins_home/secrets/initialAdminPassword

在我的情况下,这将输出7f449293de5443a2bbcb0918c8558689。保存这个密码,因为您将在下一步中使用它。

  1. 在浏览器中,导航至http://localhost:8080以访问 Jenkins 的图形界面。

  2. 使用前面的命令检索的管理员密码解锁 Jenkins。

  3. 接下来,选择安装建议的插件,让 Jenkins 自动安装最有用的插件。插件包括 GitHub 集成,电子邮件扩展,Maven 和 Gradle 集成等等。

  4. 一旦插件安装完成,创建您的第一个管理员帐户。在要求重新启动 Jenkins 时,这样做。

  5. 一旦您配置了 Jenkins 服务器,首先创建一个新项目;您可能需要在主菜单中点击新项目

在 Jenkins 中添加一个新项目

  1. 给项目命名为sample-pipeline,选择Pipeline类型,然后点击确定。

  2. 在配置视图中,选择 Pipeline 标签,并将前面的管道定义添加到脚本文本框中:

在我们的名为 sample-pipeline 的 Jenkins 项目中定义管道

  1. 点击保存,然后在 Jenkins 的主菜单中选择立即构建。过一会儿,您应该会看到这个:

在 Jenkins 中运行我们的示例管道

  1. 现在我们已经准备好了 Jenkins,我们可以开始集成我们的示例应用程序。让我们从构建步骤开始。首先,我们将jenkins-pipeline项目文件夹初始化为 Git 项目:
$ cd ~/fod/ch07/jenkins-pipeline && git init
  1. 向此文件夹添加一个package.json文件,内容如下:
{
  "name": "jenkins-pipeline",
  "version": "1.0.0",
  "main": "server.js",
  "scripts": {
    "start": "node server.js",
    "test": "jasmine"
  },
  "dependencies": {
    "express": "⁴.17.1"
  },
  "devDependencies": {
    "jasmine": "³.4.0"
  }
}

在这个文件中没有什么特别的,除了通常的外部依赖列表,这种情况下是expressjasmine。还要注意我们为npm定义的两个脚本starttest

  1. 向项目添加一个hobbies.js文件,该文件实现了作为 JavaScript 模块hobbies调用的爱好检索逻辑:
const hobbies = ["jogging","cooking","diving","swimming","reading"];

exports.getHobbies = () => {
    return hobbies;
}

exports.getHobby = id => {
    if(id<1 || id > hobbies.length)
        return null;
    return hobbies[id-1];
}

这段代码显然是通过提供存储在hobbies数组中的预先准备好的数据来模拟数据库。我们之所以这样做是为了简单起见。

  1. 接下来,在文件夹中添加一个server.js文件,该文件定义了一个具有三个端点GET /GET /hobbiesGET /hobbies/:id的 RESTful API。该代码使用hobbies模块中定义的逻辑来检索数据:
const hobbies = require('./hobbies');
const express = require('express');
const app = express();

app.listen(3000, '0.0.0.0', () => {
    console.log('Application listening at 0.0.0.0:3000');
})

app.get('/', (req, res) => {
    res.send('Sample API');
})

app.get('/hobbies', async (req, res) => {
    res.send(hobbies.getHobbies());
})

app.get('/hobbies/:id', async (req, res) => {
    const id = req.params.id;
    const hobby = hobbies.getHobby(id);
    if(!hobby){
        res.status(404).send("Hobby not found");
        return;
    }
    res.send();
})
  1. 现在我们需要定义一些单元测试。在项目中创建一个spec子文件夹,并向其中添加hobbies-spec.js文件,其中包含以下代码,用于测试hobbies模块:
const hobbies = require('../hobbies');
describe("API unit test suite", () => {
    describe("getHobbies", () => {
        const list = hobbies.getHobbies();
        it("returns 5 hobbies", () => {
            expect(list.length).toEqual(5);
        });
        it("returns 'jogging' as first hobby", () => {
            expect(list[0]).toBe("jogging");
        });
    })
})
  1. 最后一步是添加一个support/jasmine.json文件来配置我们的测试框架 Jasmine。添加以下代码片段:
{
    "spec_dir": "spec",
    "spec_files": [
      "**/*[sS]pec.js"
    ],
    "stopSpecOnExpectationFailure": false,
    "random": false
}

这是我们目前所需要的所有代码。

我们现在可以开始构建 CI/CD 管道:

  1. 使用以下命令提交本地创建的代码:
$ git add -A && git commit -m "First commit"
  1. 为了避免所有的 node 模块都保存到 GitHub 上,向项目的root文件夹中添加一个.gitignore文件,并包含以下内容:
node_modules
  1. 现在,我们需要在 GitHub 上定义一个存储库。在github.com上登录您的 GitHub 帐户。

  2. 在那里创建一个新的存储库,并将其命名为jenkins-pipeline

为 Jenkins 管道示例应用程序创建一个新的 GitHub 存储库请注意,我的 GitHub 帐户是gnschenker。在您的情况下,将是您自己的帐户。

  1. 在您点击了绿色按钮“创建存储库”之后,回到您的项目,并在项目的root文件夹中执行以下两个命令:
$ git remote add origin https://github.com/gnschenker/jenkins-pipeline.git
$ git push -u origin master

确保您在第一行中用您自己的 GitHub 帐户名替换gnschenker。完成此步骤后,您的代码将可在 GitHub 上供进一步使用。其中一个用户将是 Jenkins,它将从该存储库中拉取代码,我们将很快展示。

  1. 下一步是返回 Jenkins(localhost:8080)并修改项目的配置。如果需要,请登录 Jenkins 并选择您的项目sample-pipeline

  2. 然后,在主菜单中选择配置。选择 Pipeline 选项卡,并修改设置,使其看起来类似于这样:

配置 Jenkins 以从 GitHub 拉取源代码

使用这个,我们配置 Jenkins 从 GitHub 拉取代码,并使用Jenkinsfile来定义流水线。预计Jenkinsfile应该在项目的根目录中找到。请注意,对于存储库 URL 路径,我们需要给出相对路径到我们项目所在的/home目录。请记住,当运行 Jenkins 容器时,我们将我们自己的主机上的主目录映射到 Jenkins 容器内的/home目录,方法是:-v "$HOME":/home

  1. 点击绿色的保存按钮以接受更改。

  2. 我们已经定义了Jenkinsfile需要在项目的根目录中。这是Pipeline-as-Code的基础,因为流水线定义文件将与其余代码一起提交到 GitHub 存储库中。因此,请在jenkins-pipeline文件夹中添加一个名为Jenkinsfile的文件,并将以下代码添加到其中:

pipeline {
    environment {
        registry = "gnschenker/jenkins-docker-test"
        DOCKER_PWD = credentials('docker-login-pwd')
    }
    agent {
        docker {
            image 'gnschenker/node-docker'
            args '-p 3000:3000'
            args '-w /app'
            args '-v /var/run/docker.sock:/var/run/docker.sock'
        }
    }
    options {
        skipStagesAfterUnstable()
    }
    stages {
        stage("Build"){
            steps {
                sh 'npm install'
            }
        }
        stage("Test"){
            steps {
                sh 'npm test'
            }
        }
        stage("Build & Push Docker image") {
            steps {
                sh 'docker image build -t $registry:$BUILD_NUMBER .'
                sh 'docker login -u gnschenker -p $DOCKER_PWD'
                sh 'docker image push $registry:$BUILD_NUMBER'
                sh "docker image rm $registry:$BUILD_NUMBER"
            }
        }
    }
}

好的,让我们一次解决这个文件的一部分。在顶部,我们定义了两个环境变量,它们将在流水线的每个阶段中都可用。我们将在Build & Push Docker image阶段中使用这些变量:

environment {
    registry = "gnschenker/jenkins-docker-test"
    DOCKER_PWD = credentials('docker-login-pwd')
}

第一个变量registry只包含我们最终将生成并推送到 Docker Hub 的容器镜像的完整名称。用您自己的 GitHub 用户名替换gnschenker。第二个变量DOCKER_PWD更有趣一些。它将包含登录到我的 Docker Hub 帐户的密码。当然,我不想在这里将值硬编码在代码中,因此,我使用 Jenkins 的凭据功能,它让我访问存储在 Jenkins 中名称为docker-login-pwd的秘密。

接下来,我们定义要在其上运行 Jenkins 流水线的代理。在我们的情况下,它是基于 Docker 镜像的。我们使用gnschenker/node-docker镜像来实现这一目的。这是一个基于node:12.10-alpine的镜像,其中安装了 Docker 和curl,因为我们将在某些阶段需要这两个工具:

agent {
    docker {
        image 'gnschenker/node-docker'
        args '-v /var/run/docker.sock:/var/run/docker.sock'
    }
}

通过args参数,我们还将 Docker 套接字映射到容器中,以便我们可以在代理内部使用 Docker。

暂时忽略选项部分。然后我们定义了三个阶段:

stages {
    stage("Build"){
        steps {
            sh 'npm install'
        }
    }
    stage("Test"){
        steps {
            sh 'npm test'
        }
    }
    stage("Build & Push Docker image") {
        steps {
            sh 'docker image build -t $registry:$BUILD_NUMBER .'
            sh 'docker login -u gnschenker -p $DOCKER_PWD'
            sh 'docker image push $registry:$BUILD_NUMBER'
            sh "docker image rm $registry:$BUILD_NUMBER"
        }
    }
}

第一个阶段Build只是运行npm install,以确保我们应用程序的所有外部依赖项都可以安装。例如,如果这是一个 Java 应用程序,我们可能还会在这一步中编译和打包应用程序。

在第二阶段Test中,我们运行npm test,这将运行我们为示例 API 定义的单元测试。

第三阶段,构建和推送 Docker 镜像,有点更有趣。现在我们已经成功构建并测试了我们的应用程序,我们可以为它创建一个 Docker 镜像并将其推送到注册表中。我们使用 Docker Hub 作为我们的注册表,但任何私有或公共注册表都可以使用。在这个阶段,我们定义了四个步骤:

  1. 我们使用 Docker 来构建镜像。我们使用了在 Jenkinsfile 的第一部分中定义的$registry环境变量。$BUILD_NUMBER变量是由 Jenkins 自己定义的。

  2. 在我们可以将某些东西推送到注册表之前,我们需要登录。在这里,我使用了之前定义的$DOCKER_PWD变量。

  3. 一旦我们成功登录到注册表,我们就可以推送镜像。

  4. 由于镜像现在在注册表中,我们可以从本地缓存中删除它,以避免浪费空间。

请记住,所有阶段都在我们的gnschenker/node-docker构建器容器内运行。因此,我们在 Docker 内部运行 Docker。但是,由于我们已经将 Docker 套接字映射到了构建器中,Docker 命令会在主机上执行。

让我们在流水线中再添加两个阶段。第一个看起来像这样:

stage('Deploy and smoke test') {
    steps{
        sh './jenkins/scripts/deploy.sh'
    }
}

将其添加到构建和推送 Docker 镜像阶段之后。这个阶段只是执行位于jenkins/scripts子文件夹中的deploy.sh脚本。我们的项目中还没有这样的文件。

因此,请将这个文件添加到你的项目中,并包含以下内容:

#!/usr/bin/env sh

echo "Removing api container if it exists..."
docker container rm -f api || true
echo "Removing network test-net if it exists..."
docker network rm test-net || true

echo "Deploying app ($registry:$BUILD_NUMBER)..."
docker network create test-net

docker container run -d \
    --name api \
    --net test-net \
    $registry:$BUILD_NUMBER

# Logic to wait for the api component to be ready on port 3000

read -d '' wait_for << EOF
echo "Waiting for API to listen on port 3000..."
while ! nc -z api 3000; do 
  sleep 0.1 # wait for 1/10 of the second before check again
  printf "."
done
echo "API ready on port 3000!"
EOF

docker container run --rm \
    --net test-net \
    node:12.10-alpine sh -c "$wait_for"

echo "Smoke tests..."
docker container run --name tester \
    --rm \
    --net test-net \
    gnschenker/node-docker sh -c "curl api:3000"

好的,所以这段代码做了以下几件事。首先,它试图移除可能残留在之前失败的流水线运行中的任何残留物。然后,它创建了一个名为test-net的 Docker 网络。接下来,它从我们在上一步中构建的镜像中运行一个容器。这个容器是我们的 Express JS API,相应地被称为api

这个容器和其中的应用可能需要一些时间才能准备好。因此,我们定义了一些逻辑,使用netcatnc工具来探测端口3000。一旦应用程序在端口3000上监听,我们就可以继续进行烟雾测试。在我们的情况下,烟雾测试只是确保它可以访问我们 API 的/端点。我们使用curl来完成这个任务。在一个更现实的设置中,你可能会在这里运行一些更复杂的测试。

作为最后阶段,我们添加了一个Cleanup步骤:

  1. 在你的Jenkinsfile中添加以下片段作为最后一个阶段:
stage('Cleanup') {
    steps{
        sh './jenkins/scripts/cleanup.sh'
    }
}

再次,这个Cleanup阶段使用了位于jenkins/script子文件夹中的脚本。

  1. 请向你的项目添加一个包含以下内容的文件:
#!/usr/bin/env sh

docker rm -f api
docker network rm test-net

该脚本删除了我们用来运行容器的api容器和 Docker 网络test-net

  1. 现在,我们准备好了。使用git提交您的更改并将其推送到您的存储库:
$ git -a . && git commit -m "Defined code based Pipeline"
$ git push origin master

代码推送到 GitHub 后,返回 Jenkins。

  1. 选择您的sample-pipeline项目并在主菜单中点击立即构建。Jenkins 将开始构建流水线。如果一切顺利,您应该看到类似于这样的东西:

在 Jenkins 中运行我们的完整基于代码的流水线

我们的流水线已成功执行,现在有六个步骤。从 GitHub 检出已自动添加为第一个启用步骤。要访问流水线执行期间生成的日志,可以点击构建历史下运行左侧的小球图标。在前面的屏幕截图中,它是#26左侧的蓝色图标。如果流水线步骤失败,这将特别有帮助,可以快速找到失败的根本原因。

总之,我们已经构建了一个简单的 CI/CD 流水线,其中包括自动化服务器 Jenkins 在内的所有内容都在容器中运行。我们只是触及了可能性的表面。

概要

在本章中,我们学习了如何使用 Docker 容器来优化各种自动化任务,从运行简单的一次性任务到构建容器化的 CI/CD 流水线。

在下一章中,我们将介绍在容器化复杂的分布式应用程序或使用 Docker 自动化复杂任务时有用的高级技巧、技巧和概念。

问题

  1. 列出在容器中运行一次性任务的几个优缺点。

  2. 列出在容器中运行测试的两三个优点。

  3. 勾画一个以容器化的 CI/CD 流水线为起点的高层次图,从用户生成代码到代码部署到生产环境。

进一步阅读

第八章:高级 Docker 使用场景

在上一章中,我们向您展示了如何使用工具执行管理任务,而无需在主机计算机上安装这些工具。我们还说明了容器的使用,这些容器托管和运行用于测试和验证在容器中运行的应用程序服务的测试脚本或代码。最后,我们指导您构建了一个使用 Jenkins 作为自动化服务器的基于 Docker 的简单 CI/CD 流水线的任务。

在本章中,我们将介绍在将复杂的分布式应用程序容器化或使用 Docker 自动化复杂任务时有用的高级技巧、窍门和概念。

这是本章中我们将涉及的所有主题的快速概述:

  • 所有 Docker 专业人士的技巧和窍门

  • 在远程容器中运行终端并通过 HTTPS 访问它

  • 在容器内运行开发环境

  • 在远程容器中运行代码编辑器并通过 HTTPS 访问它

完成本章后,您将能够执行以下操作:

  • 在完全混乱后成功恢复您的 Docker 环境

  • 在容器中运行远程终端,并通过 HTTPS 在浏览器中访问它

  • 通过 HTTPS 在浏览器中使用 Visual Studio Code 远程编辑代码

技术要求

在本章中,如果您想跟着代码进行操作,您需要在 Mac 或 Windows 机器上安装 Docker for Desktop 和 Visual Studio Code 编辑器。该示例也适用于安装了 Docker 和 Visual Studio Code 的 Linux 机器。Docker Toolbox 在本章中不受支持。

所有 Docker 专业人士的技巧和窍门

在本节中,我将介绍一些非常有用的技巧和窍门,这些技巧和窍门可以让高级 Docker 用户的生活变得更加轻松。我们将从如何保持 Docker 环境清洁开始。

保持您的 Docker 环境清洁

首先,我们想学习如何删除悬空镜像。根据 Docker 的说法,悬空镜像是与任何已标记镜像没有关联的层。这样的镜像层对我们来说肯定是无用的,并且可能会很快地填满我们的磁盘——最好定期将它们删除。以下是命令:

$ docker image prune -f

请注意,我已经向prune命令添加了-f参数。这是为了防止 CLI 询问我们是否真的要删除那些多余的层。

停止的容器也会浪费宝贵的资源。如果您确定不再需要这些容器,那么您应该使用以下命令逐个删除它们:

$ docker container rm <container-id>

或者,您可以使用以下命令批量删除它们:

$ docker container prune --force

值得再次提到的是,除了<container-id>,我们还可以使用<container-name>来标识容器。

未使用的 Docker 卷也可能很快填满磁盘空间。在开发或 CI 环境中,特别是在创建大量临时卷的情况下,妥善处理您的卷是一个好习惯。但是我必须警告您,Docker 卷是用来存储数据的。通常,这些数据的生存周期必须比容器的生命周期长。这在生产或类似生产环境中尤其如此,那里的数据通常是至关重要的。因此,在使用以下命令清理 Docker 主机上的卷时,请务必百分之百确定自己在做什么:

$ docker volume prune
WARNING! This will remove all local volumes not used by at least one container.
Are you sure you want to continue? [y/N]

我建议不要使用-f(或--force)标志的这个命令。这是一个危险的终端操作,最好给自己第二次机会来重新考虑您的行动。没有这个标志,CLI 会输出您在上面看到的警告。您必须通过输入y并按下Enter键来明确确认。

在生产或类似生产系统中,您应该避免使用上述命令,而是使用以下命令逐个删除不需要的卷:

$ docker volume rm <volume-name>

我还应该提到有一个命令可以清理 Docker 网络。但由于我们尚未正式介绍网络,我将把这个推迟到[第十章],单主机网络

在下一节中,我们将展示如何可以从容器内部自动化 Docker。

在 Docker 中运行 Docker

有时,我们可能想要运行一个托管应用程序的容器,该应用程序自动化执行某些 Docker 任务。我们该怎么做呢?Docker 引擎和 Docker CLI 已安装在主机上,但应用程序在容器内运行。早期,Docker 就提供了一种将 Linux 套接字从主机绑定到容器的方法。在 Linux 上,套接字被用作在同一主机上运行的进程之间非常高效的数据通信端点。Docker CLI 使用套接字与 Docker 引擎通信;它通常被称为 Docker 套接字。如果我们可以将 Docker 套接字授予在容器内运行的应用程序访问权限,那么我们只需在此容器内安装 Docker CLI,然后我们将能够在相同的容器中运行使用本地安装的 Docker CLI 自动化特定于容器的任务的应用程序。

重要的是要注意,这里我们不是在谈论在容器内运行 Docker 引擎,而是只运行 Docker CLI 并将 Docker 套接字从主机绑定到容器中,以便 CLI 可以与主机计算机上运行的 Docker 引擎进行通信。这是一个重要的区别。在容器内运行 Docker 引擎虽然可能,但不建议。

假设我们有以下脚本,名为pipeline.sh,自动化构建、测试和推送 Docker 镜像:

#! /bin/bash
# *** Sample script to build, test and push containerized Node.js applications *
# build the Docker image
docker image build -t $HUB_USER/$REPOSITORY:$TAG .
# Run all unit tests
docker container run $HUB_USER/$REPOSITORY:$TAG npm test
# Login to Docker Hub
docker login -u $HUB_USER -p $HUB_PWD
# Push the image to Docker Hub
docker image push $HUB_USER/$REPOSITORY:$TAG

请注意,我们正在使用四个环境变量:$HUB_USER$HUB_PWD是 Docker Hub 的凭据,$REPOSITORY$TAG是我们要构建的 Docker 镜像的名称和标签。最终,我们将不得不在docker run命令中传递这些环境变量的值。

我们想要在一个构建器容器内运行该脚本。由于该脚本使用 Docker CLI,我们的构建器容器必须安装 Docker CLI,并且要访问 Docker 引擎,构建器容器必须将 Docker 套接字绑定。让我们开始为这样一个构建器容器创建一个 Docker 镜像:

  1. 首先,创建一个builder文件夹并导航到它:
$ mkdir builder && cd builder
  1. 在这个文件夹里,创建一个看起来像这样的Dockerfile
FROM alpine:latest
RUN apk update && apk add docker
WORKDIR /usr/src/app
COPY . .
CMD ./pipeline.sh
  1. 现在在builder文件夹中创建一个pipeline.sh文件,并将我们在前面文件中呈现的流水线脚本添加为内容。

  2. 保存并使文件可执行:

$ chmod +x ./pipeline.sh
  1. 构建镜像很简单:
$ docker image build -t builder .

我们现在准备使用一个真实的 Node.js 应用程序来尝试builder,例如我们在ch08/sample-app文件夹中定义的示例应用程序。确保您用 Docker Hub 的自己的凭据替换<user><password>

$ cd ~/fod/ch08/sample-app
$ docker container run --rm \
 --name builder \
 -v /var/run/docker.sock:/var/run/docker.sock \
    -v "$PWD":/usr/src/app \
 -e HUB_USER=<user> \
 -e HUB_PWD=<password>@j \
 -e REPOSITORY=ch08-sample-app \
 -e TAG=1.0 \
 builder

请注意,在上述命令中,我们使用-v /var/run/docker.sock:/var/run/docker.sock将 Docker 套接字挂载到容器中。如果一切顺利,您应该已经为示例应用程序构建了一个容器镜像,测试应该已经运行,并且镜像应该已经推送到 Docker Hub。这只是许多用例中的一个,其中能够绑定挂载 Docker 套接字非常有用。

特别注意,所有想尝试 Windows 容器的人。在 Windows 上的 Docker 中,您可以通过绑定挂载 Docker 的命名管道来创建类似的环境,而不是一个套接字。在 Windows 上,命名管道与基于 Unix 的系统上的套接字大致相同。假设您正在使用 PowerShell 终端,运行 Windows 容器托管 Jenkins 时,绑定挂载命名管道的命令如下:

**PS>** **docker container run ** --name jenkins ** **-p 8080:8080 **-v \.\pipe\docker_engine:\.\pipe\docker_engine `

friism/jenkins**`

注意特殊的语法\\.\pipe\docker_engine,用于访问 Docker 的命名管道。

格式化常见 Docker 命令的输出

有时您是否希望您的终端窗口是无限宽的,因为像docker container ps这样的 Docker 命令的输出会在每个项目上跨越多行?不用担心,因为您可以根据自己的喜好自定义输出。几乎所有产生输出的命令都有一个--format参数,它接受一个所谓的 Go 模板作为参数。如果您想知道为什么是 Go 模板,那是因为 Docker 的大部分代码都是用这种流行的低级语言编写的。让我们看一个例子。假设我们只想显示docker container ps命令输出的容器名称、镜像名称和容器状态,用制表符分隔。格式将如下所示:

$ docker container ps -a \
--format "table {{.Names}}\t{{.Image}}\t{{.Status}}"

请注意,format字符串是区分大小写的。还要注意添加-a参数以包括已停止的容器在输出中。示例输出可能如下所示:

NAMES              IMAGE            STATUS
elated_haslett     alpine           Up 2 seconds
brave_chebyshev    hello-world      Exited (0) 3 minutes ago

这绝对比未格式化的输出更好,即使在窄窄的终端窗口上也是如此,未格式化的输出会在多行上随意散开。

过滤常见 Docker 命令的输出

与我们在上一节中所做的内容类似,通过美化 Docker 命令的输出,我们也可以筛选输出内容。支持许多过滤器。请在 Docker 在线文档中找到每个命令的完整列表。过滤器的格式很简单,是--filter <key>=<value>的类型。如果我们需要结合多个过滤器,我们可以结合多个这些语句。让我们以docker image ls命令为例,因为我在我的工作站上有很多镜像:

$ docker image ls --filter dangling=false --filter "reference=*/*/*:latest"

前面的过滤器只输出不悬空的镜像,也就是真实的镜像,其完全限定名称的形式为<registry>/<user|org><repository>:<tag>,并且标签等于latest。我的机器上的输出如下:

REPOSITORY                                  TAG     IMAGE ID      CREATED   SIZE
docker.bintray.io/jfrog/artifactory-cpp-ce  latest  092f11699785  9 months  ago 900MB
docker.bintray.io/jfrog/artifactory-oss     latest  a8a8901c0230  9 months  ago 897MB

在展示了如何美化和筛选 Docker CLI 生成的输出之后,现在是时候再次谈论构建 Docker 镜像以及如何优化这个过程了。

优化构建过程

许多 Docker 初学者在编写他们的第一个Dockerfile时会犯以下错误:

FROM node:12.10-alpine
WORKDIR /usr/src/app
COPY . .
RUN npm install
CMD npm start

你能发现这个典型的 Node.js 应用程序的Dockerfile中的薄弱点吗?在第四章中,创建和管理容器镜像,我们已经学到镜像由一系列层组成。Dockerfile中的每一行(逻辑上)都创建一个层,除了带有CMD和/或ENTRYPOINT关键字的行。我们还学到 Docker 构建器会尽力缓存层,并在后续构建之间重用它们。但是缓存只使用在第一个更改的层之前出现的缓存层。所有后续层都需要重新构建。也就是说,Dockerfile的前面结构破坏了镜像层缓存!

为什么?嗯,从经验上来说,你肯定知道在一个典型的具有许多外部依赖的 Node.js 应用程序中,npm install 可能是一个非常昂贵的操作。执行此命令可能需要几秒钟到几分钟。也就是说,每当源文件之一发生变化,我们知道在开发过程中这经常发生,Dockerfile 中的第 3 行会导致相应的镜像层发生变化。因此,Docker 构建器无法重用缓存中的此层,也无法重用由 RUN npm install 创建的随后的层。代码的任何微小变化都会导致完全重新运行 npm install。这是可以避免的。包含外部依赖列表的 package.json 文件很少改变。有了所有这些信息,让我们修复 Dockerfile

FROM node:12.10-alpine
WORKDIR /usr/src/app
COPY package.json ./
RUN npm install
COPY . .
CMD npm start

这一次,在第 3 行,我们只将 package.json 文件复制到容器中,这个文件很少改变。因此,随后的 npm install 命令也需要同样很少地执行。第 5 行的 COPY 命令是一个非常快速的操作,因此在一些代码改变后重新构建镜像只需要重新构建这最后一层。构建时间减少到几乎只有一小部分秒数。

同样的原则适用于大多数语言或框架,比如 Python、.NET 或 Java。避免破坏你的镜像层缓存!

限制容器消耗的资源

容器的一个很棒的特性,除了封装应用程序进程外,还可以限制单个容器可以消耗的资源。这包括 CPU 和内存消耗。让我们来看看如何限制内存(RAM)的使用:

$ docker container run --rm -it \
    --name stress-test \
 --memory 512M \
 ubuntu:19.04 /bin/bash

一旦进入容器,安装 stress 工具,我们将用它来模拟内存压力:

/# apt-get update && apt-get install -y stress

打开另一个终端窗口并执行 docker stats 命令。你应该会看到类似这样的东西:

docker stats 显示了一个资源受限的容器

观察 MEM USAGELIMIT。目前,容器只使用了 1.87MiB 的内存,限制为 512MB。后者对应我们为这个容器配置的内容。现在,让我们使用 stress 来模拟四个尝试以 256MB 为块的工作进程。在容器内运行此命令以执行:

/# stress -m 4

在运行 Docker stats 的终端中,观察 MEM USAGE 的值如何接近但从未超过 LIMIT。这正是我们从 Docker 期望的行为。Docker 使用 Linux cgroups 来强制执行这些限制。

我们可以通过--cpu开关类似地限制容器可以消耗的 CPU 数量。

通过这种操作,工程师可以避免在繁忙的 Docker 主机上出现嘈杂的邻居问题,即一个容器通过消耗过多的资源使其他所有容器陷入困境。

只读文件系统

为了保护您的应用免受恶意黑客攻击,通常建议将容器的文件系统或部分文件系统定义为只读。这对于无状态服务来说是最有意义的。假设您在作为分布式、关键任务应用的一部分运行的容器中有一个计费服务。您可以按以下方式运行计费服务:

$ docker container run -d --rm \
 --name billing \
 --read-only \
 acme/billing:2.0

--read-only标志将容器的文件系统挂载为只读。如果黑客成功进入您的计费容器并试图恶意更改应用程序,比如用一个受损的二进制文件替换其中一个,那么这个操作将失败。我们可以通过以下命令轻松演示:

$ docker container run --tty -d \
    --name billing \
    --read-only \
    alpine /bin/sh 
$ docker container exec -it billing \
 sh -c 'echo "You are doomed!" > ./sample.txt' sh: can't create ./sample.txt: Read-only file system

第一个命令以只读文件系统运行容器,第二个命令尝试在该容器中执行另一个进程,该进程应该向文件系统写入一些东西——在这种情况下是一个简单的文本文件。这会失败,正如我们在前面的输出中看到的,出现了错误消息只读文件系统

加强容器中运行应用程序安全性的另一种方法是避免以root身份运行它们。

避免以 root 身份运行容器化应用

大多数运行在容器内的应用或应用服务不需要 root 访问权限。为了加强安全性,在这些情况下以最小必要权限运行这些进程是有帮助的。这些应用不应该以root身份运行,也不应该假设它们具有root级别的权限。

再次,让我们通过一个例子来说明我们的意思。假设我们有一个包含绝密内容的文件。我们希望在我们的基于 Unix 的系统上使用chmod工具来保护这个文件,以便只有具有 root 权限的用户才能访问它。假设我以dev主机上的gabriel身份登录,因此我的提示符是gabriel@dev $。我可以使用sudo su来冒充超级用户。不过我必须输入超级用户密码:

gabriel@dev $ sudo su
Password: <root password>
root@dev $

现在,作为root用户,我可以创建一个名为top-secret.txt的文件并保护它:

root@dev $ echo "You should not see this." > top-secret.txt
root@dev $ chmod 600 ./top-secret.txt
root@dev $ exit
gabriel@dev $

如果我尝试以gabriel的身份访问文件,会发生以下情况:

gabriel@dev $ cat ./top-secret.txt
cat: ./top-secret.txt: Permission denied

我得到了Permission denied,这正是我们想要的。除了root之外,没有其他用户可以访问这个文件。现在,让我们构建一个包含这个受保护文件的 Docker 镜像,当从中创建一个容器时,尝试输出它的内容。Dockerfile可能是这样的:

FROM ubuntu:latest
COPY ./top-secret.txt /secrets/
# simulate use of restricted file
CMD cat /secrets/top-secret.txt

我们可以使用以下命令从该 Dockerfile 构建一个镜像(以root身份!):

gabriel@dev $ sudo su
Password: <root password>
root@dev $ docker image build -t demo-image .
root@dev $ exit
gabriel@dev $

然后,从该镜像运行一个容器,我们得到:

gabriel@dev $ docker container run demo-image You should not see this.

好的,尽管我在主机上冒充gabriel用户并在该用户账户下运行容器,但容器内运行的应用程序自动以root身份运行,因此可以完全访问受保护的资源。这很糟糕,所以让我们来修复它!我们不再使用默认设置,而是在容器内定义一个显式用户。修改后的Dockerfile如下:

FROM ubuntu:latest
RUN groupadd -g 3000 demo-group |
 && useradd -r -u 4000 -g demo-group demo-user
USER demo-user
COPY ./top-secret.txt /secrets/
# simulate use of restricted file
CMD cat /secrets/top-secret.txt

我们使用groupadd工具来定义一个新的组,demo-group,ID 为3000。然后,我们使用useradd工具向这个组添加一个新用户,demo-user。用户在容器内的 ID 为4000。最后,通过USER demo-user语句,我们声明所有后续操作应该以demo-user身份执行。

重新构建镜像——再次以root身份——然后尝试从中运行一个容器:

gabriel@dev $ sudo su
Password: <root password>
root@dev $ docker image build -t demo-image .
root@dev $ exit
gabriel@dev $ docker container run demo-image cat: /secrets/top-secret.txt: Permission denied

正如您在最后一行所看到的,容器内运行的应用程序以受限权限运行,无法访问需要 root 级别访问权限的资源。顺便问一下,如果我以root身份运行容器会发生什么?试一试吧!

这些是一些对专业人士有用的日常容器使用技巧。还有很多。去 Google 搜索一下。值得的。

在远程容器中运行您的终端并通过 HTTPS 访问它

有时您需要访问远程服务器,只能使用浏览器进行访问。您的笔记本电脑可能被雇主锁定,因此不允许您例如ssh到公司域之外的服务器。

要测试这种情况,请按照以下步骤进行:

  1. 在 Microsoft Azure、GCP 或 AWS 上创建一个免费账户。然后,创建一个虚拟机,最好使用 Ubuntu 18.04 或更高版本作为操作系统,以便更容易跟随操作。

  2. 一旦您的虚拟机准备就绪,就可以通过 SSH 登录。执行此操作的命令应该类似于这样:

$ ssh gnschenker@40.115.4.249

要获得访问权限,您可能需要首先为虚拟机打开22端口以进行入口。

我在虚拟机配置期间定义的用户是gnschenker,我的虚拟机的公共 IP 地址是40.115.4.249

  1. 使用此处找到的说明在 VM 上安装 Docker:docs.docker.com/install/linux/docker-ce/ubuntu/

  2. 特别注意,不要忘记使用以下命令将您的用户(在我的情况下是gnschenker)添加到 VM 上的docker组中:

$ sudo usermod -aG docker <user-name>

通过这样做,您可以避免不断使用sudo执行所有 Docker 命令。您需要注销并登录到 VM 以使此更改生效。

  1. 现在,我们准备在 VM 上的容器中运行Shell in a Boxgithub.com/shellinabox/shellinabox)。有很多人将 Shell in a Box 容器化。我们使用的是 Docker 镜像,sspreitzer/shellinabox。在撰写本文时,这是 Docker Hub 上迄今为止最受欢迎的版本。使用以下命令,我们将以用户gnschenker、密码top-secret、启用用户的sudo和自签名证书运行应用程序:
$ docker container run --rm \
    --name shellinabox \
 -p 4200:4200 \
    -e SIAB_USER=gnschenker \
 -e SIAB_PASSWORD=top-secret \
 -e SIAB_SUDO=true \
 -v `pwd`/dev:/usr/src/dev \
 sspreitzer/shellinabox:latest

请注意,最初我们建议以交互模式运行容器,以便您可以跟踪发生的情况。一旦您更熟悉该服务,考虑使用-d标志在后台运行它。还要注意,我们将主机的~/dev文件夹挂载到容器内的/usr/src/dev文件夹。如果我们想要远程编辑我们从 GitHub 克隆的代码,这将非常有用,例如,克隆到~/dev文件夹中。

还要注意,我们将 Shell in a Box 的端口4200映射到主机端口4200。这是我们将能够使用浏览器和 HTTPS 访问 shell 的端口。因此,您需要在 VM 上为入口打开端口4200。作为协议,选择 TCP。

  1. 一旦容器正在运行,并且您已经为入口打开了端口4200,请打开一个新的浏览器窗口,导航到https://<public-IP>:4200,其中<public-IP>是您的 VM 的公共 IP 地址。由于我们使用的是自签名证书,您将收到一个警告,如在使用 Firefox 时所示:

由于使用自签名证书而导致的浏览器警告

  1. 在我们的情况下,这不是问题;我们知道原因——就是自签名证书。因此,点击高级...按钮,然后接受风险并继续。现在,您将被重定向到登录屏幕。使用您的用户名和密码登录:

使用 HTTPS 从浏览器登录到远程 VM

我们已经登录到运行在远程 VM 上的Shell in a Box应用程序,使用 HTTPS 协议。

  1. 现在,我们可以完全访问,例如,从主机 VM 映射到/usr/src/dev的文件和文件夹。例如,我们可以使用vi文本编辑器来创建和编辑文件,尽管我们必须首先安装 vi,方法如下:
$ sudo apt-get update && sudo apt-get install -y vim
  1. 可能性几乎是无穷无尽的。请尝试使用这个设置。例如,使用挂载了 Docker 套接字的 Shell in a Box 容器,安装容器内的 Docker,然后尝试从容器内使用 Docker CLI。这真的很酷,因为你可以在浏览器内完成所有这些操作!

  2. 如果你打算经常使用这个 Shell in a Box 容器,并且需要安装一些额外的软件,请毫不犹豫地创建你自己的自定义 Docker 镜像,继承自sspreitzer/shellinabox

接下来,我们将看到如何在容器内运行你的开发环境。

在容器内运行开发环境

想象一下,你只能访问安装了 Docker for Desktop 的工作站,但无法在这台工作站上添加或更改任何其他内容。现在你想做一些概念验证,并使用 Python 编写一些示例应用程序。不幸的是,你的计算机上没有安装 Python。你能做什么?如果你能在容器内运行整个开发环境,包括代码编辑器和调试器,同时仍然可以在主机上拥有你的代码文件呢?

容器很棒,聪明的工程师已经提出了解决这种问题的解决方案。

让我们尝试一下 Python 应用程序:

  1. 我们将使用我们最喜欢的代码编辑器 Visual Studio Code,来展示如何在容器内运行完整的 Python 开发环境。但首先,我们需要安装必要的 Visual Studio Code 扩展。打开 Visual Studio Code 并安装名为 Remote Development 的扩展:

Visual Studio Code 的 Remote Development 扩展

  1. 然后,点击 Visual Studio Code 窗口左下角的绿色快速操作状态栏项。在弹出窗口中,选择Remote-Containers: Open Folder in Container...

在远程容器中打开一个项目

  1. 选择要在容器中使用的项目文件夹。在我们的案例中,我们选择了~/fod/ch08/remote-app文件夹。Visual Studio Code 将开始准备环境,这在第一次可能需要几分钟左右。在此期间,您将看到如下消息:

Visual Studio Code 正在准备开发容器

默认情况下,此开发容器以非根用户身份运行,我们的情况下称为python。我们在之前的部分中了解到,这是一个强烈推荐的最佳实践。您可以通过注释掉.devcontainer/devcontainer.json文件中的"runArgs": [ "-u", "python" ]行来更改,并以root身份运行。

  1. 使用Shift + Ctrl + **在 Visual Studio Code 内打开一个终端,并使用env FLASK_APP=main.py flask run`命令运行 Flask 应用程序。您应该会看到如下输出:

从 Visual Studio Code 在容器内运行的 Python Flask 应用程序开始

python@df86dceaed3d:/workspaces/remote-app$提示表明我们不是直接在我们的 Docker 主机上运行,而是在 Visual Studio Code 为我们启动的开发容器内运行。Visual Studio Code 本身的远程部分也运行在该容器内。只有 Visual Studio Code 的客户端部分——UI——继续在我们的主机上运行。

  1. 通过按*Shift+Ctrl+*在 Visual Studio Code 内打开另一个终端窗口。然后,使用curl`测试应用程序:

测试远程 Flask 应用程序

  1. 按下Ctrl + C停止 Flask 应用程序。

  2. 我们也可以像在主机上直接工作时那样调试应用程序。打开.vscode/launch.json文件,了解 Flask 应用程序是如何启动的以及调试器是如何附加的。

  3. 打开main.py文件,并在home()函数的return语句上设置一个断点。

  4. 然后,切换到 Visual Studio Code 的调试视图,并确保在下拉菜单中选择启动任务Python: Flask

  5. 接下来,按下绿色的启动箭头开始调试。终端中的输出应该如下所示:

在容器中运行的远程应用程序开始调试

  1. 使用Shift + Ctrl + **打开另一个终端,并通过运行curl localhost:9000/`命令来测试应用程序。调试器应该会触发断点,您可以开始分析:

在容器内运行的 Visual Studio Code 中逐行调试

我无法强调这有多酷。Visual Studio Code 的后端(非 UI 部分)正在容器内运行,Python、Python 调试器和 Python Flask 应用程序也是如此。同时,源代码从主机挂载到容器中,Visual Studio Code 的 UI 部分也在主机上运行。这为开发人员在受限制最严格的工作站上打开了无限的可能性。您可以对所有流行的语言和框架执行相同的操作,比如.NET、C#、Java、Go、Node.js 和 Ruby。如果某种语言不受支持,您可以创建自己的开发容器,然后它将与我们展示的 Python 相同的方式工作。

如果您在没有安装 Docker for Desktop 并且受到更严格限制的工作站上工作,该怎么办?您有哪些选择?

在远程容器中运行您的代码编辑器,并通过 HTTPS 访问它

在本节中,我们将展示如何使用 Visual Studio Code 在容器内启用远程开发。当您在工作站上受限时,这是很有趣的。让我们按照以下步骤进行:

  1. 下载并提取最新版本的code-server。您可以通过导航到github.com/cdr/code-server/releases/latest来找到 URL。在撰写本文时,它是1.1156-vsc1.33.1
$ VERSION=<version>
$ wget https://github.com/cdr/code-server/releases/download/${VERSION}/code-server${VERSION}-linux-x64.tar.gz
$ tar -xvzf code-server${VERSION}-linux-x64.tar.gz

确保用您的特定版本替换<version>

  1. 导航到提取的二进制文件所在的文件夹,使其可执行,并启动它:
$ cd code-server${VERSION}-linux-x64
$ chmod +x ./code-server
$ sudo ./code-server -p 4200

输出应该类似于这样:

在远程 VM 上启动 Visual Studio Code 远程服务器

Code Server 使用自签名证书来保护通信,因此我们可以通过 HTTPS 访问它。请确保您记下屏幕上的Password输出,因为在浏览器中访问 Code Server 时需要它。还要注意,我们使用端口4200在主机上暴露 Code Server,原因是我们已经为 VM 上的入口打开了该端口。当然,您可以选择任何端口 - 只需确保您为入口打开它。

  1. 打开一个新的浏览器页面,导航到https://<public IP>:4200,其中<public IP>是您的 VM 的公共 IP 地址。由于我们再次使用自签名证书,浏览器会出现警告,类似于我们在本章前面使用 Shell in a Box 时发生的情况。接受警告,您将被重定向到 Code Server 的登录页面:

Code Server 的登录页面

  1. 输入您之前记录的密码,然后点击“进入 IDE”。现在您将能够通过安全的 HTTPS 连接远程使用 Visual Studio Code:

在浏览器上运行的 Visual Studio Code 通过 HTTPS

  1. 现在,您可以从 Chrome Book 或受限制的工作站等设备进行开发,而不受限制。但等一下,您可能会说!这与容器有什么关系?您是对的——到目前为止,没有涉及到容器。不过,我可以说,如果您的远程 VM 安装了 Docker,您可以使用 Code Server 来进行任何与容器相关的开发,我就可以解决问题了。但那将是一个廉价的答案。

  2. 让我们在一个容器中运行 Code Server 本身。这应该很容易,不是吗?尝试使用这个命令,将内部端口8080映射到主机端口4200,并将包含 Code Server 设置和可能包含您的项目的主机文件夹挂载到容器中:

$ docker container run -it \
 -p 4200:8080 \
 -v "${HOME}/.local/share/code-server:/home/coder/.local/share/code-server" \
 -v "$PWD:/home/coder/project" \
 codercom/code-server:v2

请注意,前面的命令以不安全模式运行 Code Server,如输出所示:

info Server listening on http://0.0.0.0:8080
info - No authentication
info - Not serving HTTPS
  1. 您现在可以在浏览器中访问http://<public IP>:4200中的 Visual Studio Code。请注意 URL 中的HTTP而不是HTTPS!与在远程 VM 上本地运行 Code Server 时类似,您现在可以在浏览器中使用 Visual Studio Code:

 在浏览器中进行开发

通过这个,我希望您对容器的使用提供了近乎无限的可能性有所了解。

摘要

在本章中,我们展示了一些高级 Docker 用户的技巧和窍门,可以让您的生活更加高效。我们还展示了如何利用容器来提供在远程服务器上运行并可以通过安全的 HTTPS 连接从浏览器中访问的整个开发环境。

在下一章中,我们将介绍分布式应用架构的概念,并讨论运行分布式应用所需的各种模式和最佳实践。除此之外,我们还将列出在生产环境或类似生产环境中运行此类应用所需满足的一些问题。

问题

  1. 列出你想在容器内运行完整开发环境的原因。

  2. 为什么应该避免以root身份在容器内运行应用程序?

  3. 为什么要将 Docker 套接字绑定到容器中?

  4. 在清理 Docker 资源以释放空间时,为什么需要特别小心处理卷?

进一步阅读

第三部分:编排基础知识和 Docker Swarm

在这一部分,您将熟悉 docker 化的分布式应用程序的概念,以及容器编排器,并使用 Docker Swarm 部署和运行您的应用程序。

本节包括以下章节:

  • 第九章,分布式应用程序架构

  • 第十章,单主机网络

  • 第十一章,Docker Compose

  • 第十二章,编排器

  • 第十三章,介绍 Docker Swarm

  • 第十四章,零停机部署和秘密

第九章:分布式应用架构

在上一章中,我们讨论了在容器化复杂的分布式应用程序或使用 Docker 自动化复杂任务时有用的高级技巧和概念。

在本章中,我们将介绍分布式应用架构的概念,并讨论运行分布式应用所需的各种模式和最佳实践。最后,我们将讨论在生产环境中运行此类应用所需满足的额外要求。

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

  • 理解分布式应用架构

  • 模式和最佳实践

  • 在生产环境中运行

完成本章后,您将能够做到以下事情:

  • 至少列出分布式应用架构的四个特征

  • 列出需要在生产环境中实施的三到四种模式

理解分布式应用架构

在本节中,我们将解释当我们谈论分布式应用架构时的含义。首先,我们需要确保我们使用的所有单词或首字母缩写都有意义,并且我们都在说同样的语言。

定义术语

在本章和后续章节中,我们将谈论许多可能不为所有人熟悉的概念。为了确保我们都在说同样的语言,让我们简要介绍和描述这些概念或词语中最重要的:

术语 解释
虚拟机 虚拟机的缩写。这是一台虚拟计算机。
节点 用于运行应用程序的单个服务器。这可以是物理服务器,通常称为裸金属,也可以是虚拟机。可以是大型机、超级计算机、标准业务服务器,甚至是树莓派。节点可以是公司自己数据中心或云中的计算机。通常,节点是集群的一部分。
集群 由网络连接的节点组成,用于运行分布式应用。
网络 集群中各个节点之间的物理和软件定义的通信路径,以及在这些节点上运行的程序。
端口 应用程序(如 Web 服务器)监听传入请求的通道。
服务 不幸的是,这是一个非常负载的术语,它的真正含义取决于它所使用的上下文。如果我们在应用程序的上下文中使用术语服务,那么通常意味着这是一个实现了一组有限功能的软件,然后被应用程序的其他部分使用。随着我们在本书中的进展,将讨论具有稍微不同定义的其他类型的服务。

天真地说,分布式应用架构是单片应用架构的反义词,但首先看看这种单片架构也并非不合理。传统上,大多数业务应用都是以这种方式编写的,结果可以看作是一个单一的、紧密耦合的程序,运行在数据中心的某个命名服务器上。它的所有代码都被编译成一个单一的二进制文件,或者几个非常紧密耦合的二进制文件,在运行应用程序时需要共同定位。服务器,或者更一般的主机,应用程序运行的这一事实具有明确定义的名称或静态 IP 地址,在这种情况下也是重要的。让我们看下面的图表,更清楚地说明这种类型的应用架构:

单片应用架构

在前面的图表中,我们可以看到一个名为blue-box-12a服务器,具有172.52.13.44IP地址,运行一个名为pet-shop的应用程序,它是一个由主模块和几个紧密耦合的库组成的单片。

现在,让我们看一下以下的图表:

分布式应用架构

在这里,突然之间,我们不再只有一个命名的服务器;相反,我们有很多服务器,它们没有人类友好的名称,而是一些可以是类似于通用唯一标识符UUID)的唯一 ID。突然之间,宠物商店应用程序也不再只是由一个单一的单片块组成,而是由许多相互作用但松散耦合的服务组成,例如pet-apipet-webpet-inventory。此外,每个服务在这个服务器或主机集群中运行多个实例。

你可能会想为什么我们在一本关于 Docker 容器的书中讨论这个问题,你问得对。虽然我们要调查的所有主题同样适用于容器尚未存在的世界,但重要的是要意识到,容器和容器编排引擎可以以更高效和直接的方式解决所有这些问题。在容器化的世界中,以前在分布式应用架构中很难解决的大多数问题变得相当简单。

模式和最佳实践

分布式应用架构具有许多引人注目的好处,但与单片应用架构相比,它也有一个非常重要的缺点——前者要复杂得多。为了控制这种复杂性,该行业提出了一些重要的最佳实践和模式。在接下来的章节中,我们将更详细地研究其中一些最重要的内容。

松散耦合的组件

解决复杂问题的最佳方法一直是将其分解为更易管理的较小子问题。举个例子,一步到位地建造一座房子将会非常复杂。将房子从简单的部件组合成最终结果会更容易。

同样适用于软件开发。如果我们将这个应用程序分解成相互协作并构成整体应用程序的较小组件,那么开发一个非常复杂的应用程序就会变得更容易。现在,如果这些组件之间的耦合度较低,那么单独开发这些组件就会变得更容易。这意味着组件 A 不会对组件 B 和 C 的内部工作做任何假设,而只关心它如何通过明确定义的接口与这两个组件进行通信。

如果每个组件都有一个明确定义且简单的公共接口,通过该接口与系统中的其他组件和外部世界进行通信,那么这将使我们能够单独开发每个组件,而不会对其他组件产生隐式依赖。在开发过程中,系统中的其他组件可以很容易地被存根或模拟替换,以便我们测试我们的组件。

有状态与无状态

每个有意义的业务应用程序都会创建、修改或使用数据。在 IT 中,数据的同义词是“状态”。创建或修改持久数据的应用服务称为有状态组件。典型的有状态组件是数据库服务或创建文件的服务。另一方面,不创建或修改持久数据的应用组件称为无状态组件。

在分布式应用架构中,无状态组件比有状态组件更容易处理。无状态组件可以轻松地进行扩展和缩减。此外,它们可以快速而轻松地在集群的完全不同节点上关闭和重新启动,因为它们与持久数据没有关联。

鉴于这一事实,有助于以大多数应用服务为无状态的方式设计系统。最好将所有有状态组件推到应用程序的边界并限制它们的数量。管理有状态组件很困难。

服务发现

构建应用程序时,通常由许多个体组件或相互通信的服务组成,我们需要一种机制,允许个体组件在集群中找到彼此。找到彼此通常意味着您需要知道目标组件在哪个节点上运行,以及它在哪个端口上监听通信。大多数情况下,节点由 IP 地址和端口标识,端口只是一个在明确定义范围内的数字。

从技术上讲,我们可以告诉想要与目标“服务 B”通信的“服务 A”,目标的 IP 地址和端口是什么。例如,这可以通过配置文件中的条目来实现。

组件是硬连线的

在单体应用程序的上下文中,这可能非常有效,该应用程序在一个或仅有几个知名和精心策划的服务器上运行,但在分布式应用程序架构中完全失效。首先,在这种情况下,我们有许多组件,手动跟踪它们变成了一场噩梦。这绝对不可扩展。此外,服务 A通常不应该或永远不会知道其他组件在集群的哪个节点上运行。它们的位置甚至可能不稳定,因为组件 B 可能由于应用程序外部的各种原因从节点 X 移动到另一个节点 Y。因此,我们需要另一种方式,服务 A可以找到服务 B,或者其他任何服务。最常用的是一个外部机构,它在任何给定时间都了解系统的拓扑结构。

这个外部机构或服务知道当前属于集群的所有节点和它们的 IP 地址;它知道所有正在运行的服务以及它们在哪里运行。通常,这种服务被称为DNS 服务,其中DNS代表域名系统。正如我们将看到的,Docker 实现了一个作为底层引擎的 DNS 服务。Kubernetes - 首要的容器编排系统,我们将在第十二章中讨论,编排器 - 也使用DNS 服务来促进集群中运行的组件之间的通信。

组件咨询外部定位器服务

在前面的图表中,我们可以看到服务 A想要与服务 B通信,但它无法直接做到这一点。首先,它必须查询外部机构,一个注册表服务(这里称为DNS 服务),询问服务 B的下落。注册表服务将回答所请求的信息,并提供服务 A可以用来到达服务 B的 IP 地址和端口号。服务 A然后使用这些信息并与服务 B建立通信。当然,这只是一个关于低级别实际发生情况的天真图像,但它是一个帮助我们理解服务发现架构模式的好图像。

路由

路由是将数据包从源组件发送到目标组件的机制。路由被分类为不同类型。所谓的 OSI 模型(有关更多信息,请参阅本章的进一步阅读部分中的参考资料)用于区分不同类型的路由。在容器和容器编排的上下文中,第 2、3、4 和 7 层的路由是相关的。我们将在后续章节中更详细地讨论路由。在这里,让我们只说第 2 层路由是最低级别的路由类型,它将 MAC 地址连接到另一个 MAC 地址,而第 7 层路由,也称为应用级路由,是最高级别的路由。后者例如用于将具有目标标识符(即 URL)的请求路由到我们系统中的适当目标组件。

负载均衡

负载均衡在服务 A 需要与服务 B 通信时使用,比如在请求-响应模式中,但后者运行在多个实例中,如下图所示:

服务 A 的请求被负载均衡到服务 B

如果我们的系统中运行着多个服务 B 的实例,我们希望确保每个实例都被分配了相等的工作负载。这是一个通用的任务,这意味着我们不希望调用者进行负载均衡,而是希望一个外部服务拦截调用并决定将调用转发给目标服务实例的部分。这个外部服务被称为负载均衡器。负载均衡器可以使用不同的算法来决定如何将传入的调用分发给目标服务实例。最常用的算法称为轮询。这个算法只是以重复的方式分配请求,从实例 1 开始,然后是 2,直到实例 n。在最后一个实例被服务后,负载均衡器重新从实例 1 开始。

在前面的例子中,负载均衡器还有助于高可用性,因为来自服务 A 的请求将被转发到健康的服务 B 实例。负载均衡器还承担定期检查 B 的每个实例健康状况的角色。

防御性编程

在开发分布式应用程序的服务时,重要的是要记住这个服务不会是独立的,它依赖于其他应用程序服务,甚至依赖于第三方提供的外部服务,比如信用卡验证服务或股票信息服务,仅举两个例子。所有这些其他服务都是我们正在开发的服务的外部服务。我们无法控制它们的正确性或它们在任何给定时间的可用性。因此,在编码时,我们总是需要假设最坏的情况,并希望最好的结果。假设最坏的情况意味着我们必须明确处理潜在的故障。

重试

当外部服务可能暂时不可用或响应不够及时时,可以使用以下程序。当对其他服务的调用失败或超时时,调用代码应以一种结构化的方式进行,以便在短暂的等待时间后重复相同的调用。如果再次失败,下一次尝试前等待时间应稍长。应重复调用,直到达到最大次数,每次增加等待时间。之后,服务应放弃并提供降级服务,这可能意味着返回一些陈旧的缓存数据或根据情况根本不返回数据。

日志记录

对服务执行的重要操作应始终记录。日志信息需要分类,才能具有真正的价值。常见的分类列表包括调试、信息、警告、错误和致命。日志信息应由中央日志聚合服务收集,而不应存储在集群的单个节点上。聚合日志易于解析和过滤相关信息。这些信息对于快速定位由许多运行在生产环境中的移动部件组成的分布式系统中的故障或意外行为的根本原因至关重要。

错误处理

正如我们之前提到的,分布式应用程序中的每个应用服务都依赖于其他服务。作为开发人员,我们应该始终预料到最坏的情况,并采取适当的错误处理措施。最重要的最佳实践之一是快速失败。以这样的方式编写服务,使得不可恢复的错误尽早被发现,如果检测到这样的错误,立即使服务失败。但不要忘记记录有意义的信息到STDERRSTDOUT,以便开发人员或系统操作员以后可以用来跟踪系统的故障。同时,向调用者返回有用的错误信息,尽可能准确地指出调用失败的原因。

快速失败的一个示例是始终检查调用者提供的输入值。这些值是否在预期范围内并且完整?如果不是,那么不要尝试继续处理;而是立即中止操作。

冗余

一个使命关键的系统必须全天候、全年无休地可用。停机是不可接受的,因为它可能导致公司机会或声誉的巨大损失。在高度分布式的应用程序中,至少有一个涉及的组件失败的可能性是不可忽视的。我们可以说问题不在于一个组件是否会失败,而在于失败将在何时发生。

为了避免系统中的许多组件之一出现故障时停机,系统的每个单独部分都需要是冗余的。这包括应用程序组件以及所有基础设施部分。这意味着,如果我们的应用程序中有一个支付服务,那么我们需要冗余地运行这个服务。最简单的方法是在集群的不同节点上运行这个服务的多个实例。同样,对于边缘路由器或负载均衡器也是如此。我们不能承受它出现故障的风险。因此,路由器或负载均衡器必须是冗余的。

健康检查

我们已经多次提到,在分布式应用程序架构中,由于其许多部分,单个组件的故障是非常可能的,而且只是时间问题。因此,我们将系统的每个单个组件都运行冗余。代理服务然后在服务的各个实例之间平衡流量。

但现在,又出现了另一个问题。代理或路由器如何知道某个服务实例是否可用?它可能已经崩溃或者无响应。为了解决这个问题,我们可以使用所谓的健康检查。代理或代理的其他系统服务定期轮询所有服务实例并检查它们的健康状况。基本上问题是,你还在吗?你健康吗?对每个服务的答案要么是是,要么是否,或者如果实例不再响应,则健康检查超时。

如果组件回答“否”或发生超时,那么系统将终止相应的实例并在其位置上启动一个新的实例。如果所有这些都是以完全自动化的方式发生的,那么我们可以说我们有一个自愈系统。

代理定期轮询组件的状态的责任可以被转移。组件也可以被要求定期向代理发送活动信号。如果一个组件在预定义的延长时间内未能发送活动信号,就被认为是不健康或已死亡。

有时候,上述的任一方式更为合适。

断路器模式

断路器是一种机制,用于避免分布式应用因许多重要组件的级联故障而崩溃。断路器有助于避免一个故障组件以多米诺效应拖垮其他依赖服务。就像电气系统中的断路器一样,它通过切断电源线来保护房屋免受由于插入式设备故障而导致的火灾,分布式应用中的断路器在服务 A服务 B的连接中断,如果后者没有响应或者发生故障。

这可以通过将受保护的服务调用包装在断路器对象中来实现。该对象监视故障。一旦故障次数达到一定阈值,断路器就会跳闸。所有随后对断路器的调用都将返回错误,而根本不会进行受保护的调用:

断路器模式

在前面的图表中,我们有一个断路器,在调用服务 B时收到第二个超时后会跳闸。

在生产中运行

要成功地在生产环境中运行分布式应用程序,我们需要考虑在前面部分介绍的最佳实践和模式之外的一些方面。一个特定的领域是内省和监控。让我们详细介绍最重要的方面。

日志记录

一旦分布式应用程序投入生产,就不可能进行实时调试。但是我们如何找出应用程序故障的根本原因呢?解决这个问题的方法是应用程序在运行时产生丰富而有意义的日志信息。开发人员需要以这样的方式对其应用程序服务进行工具化,以便输出有用的信息,例如发生错误时或遇到潜在的意外或不需要的情况时。通常,这些信息输出到STDOUTSTDERR,然后由系统守护进程收集并将信息写入本地文件或转发到中央日志聚合服务。

如果日志中有足够的信息,开发人员可以使用这些日志来追踪系统中错误的根本原因。

在分布式应用程序架构中,由于其许多组件,日志记录甚至比在单体应用程序中更为重要。单个请求通过应用程序的所有组件的执行路径可能非常复杂。还要记住,这些组件分布在一个节点集群中。因此,记录所有重要信息并向每个日志条目添加诸如发生时间、发生组件和运行组件的节点等信息是有意义的。此外,日志信息应聚合在一个中央位置,以便开发人员和系统操作员可以进行分析。

跟踪

跟踪用于查找单个请求如何通过分布式应用程序进行传递,以及请求总共花费多少时间以及每个单独组件的时间。如果收集了这些信息,可以将其用作显示系统行为和健康状况的仪表板的信息源之一。

监控

运维工程师喜欢有仪表板,显示系统的关键指标,让他们一目了然地了解应用程序的整体健康状况。这些指标可以是非功能指标,如内存和 CPU 使用情况,系统或应用程序组件的崩溃次数,节点的健康状况,以及功能和因此特定于应用程序的指标,如订单系统中的结账次数或库存服务中缺货商品的数量。

大多数情况下,用于聚合仪表板使用的基础数据是从日志信息中提取的。这可以是系统日志,主要用于非功能指标,或者应用程序级别的日志,用于功能指标。

应用程序更新

公司的竞争优势之一是能够及时对不断变化的市场情况做出反应。其中一部分是能够快速调整应用程序以满足新的和变化的需求,或者添加新的功能。我们更新应用程序的速度越快,越好。如今,许多公司每天都会推出新的或更改的功能多次。

由于应用程序更新频繁,这些更新必须是非中断的。在升级时,我们不能允许系统进行维护而停机。所有这些都必须无缝、透明地进行。

滚动更新

更新应用程序或应用程序服务的一种方法是使用滚动更新。这里的假设是需要更新的特定软件运行在多个实例中。只有在这种情况下,我们才能使用这种类型的更新。

系统停止当前服务的一个实例,并用新服务的实例替换它。一旦新实例准备就绪,它将提供流量服务。通常,新实例会被监视一段时间,以查看它是否按预期工作,如果是,那么当前服务的下一个实例将被关闭并替换为新实例。这种模式重复进行,直到所有服务实例都被替换。

由于总是有一些实例在任何给定时间运行,当前或新的,应用程序始终处于运行状态。不需要停机时间。

蓝绿部署

在蓝绿部署中,应用服务的当前版本称为蓝色,处理所有应用流量。然后我们在生产系统上安装应用服务的新版本,称为绿色。新服务尚未与其余应用程序连接。

一旦安装了绿色,我们可以对这项新服务执行烟雾测试,如果测试成功,路由器可以配置为将以前发送到蓝色的所有流量引导到新服务绿色。然后密切观察绿色的行为,如果所有成功标准都得到满足,蓝色可以被废弃。但是,如果由于某种原因绿色显示出一些意外或不需要的行为,路由器可以重新配置以将所有流量返回到蓝色。然后可以移除绿色并修复,然后可以使用修正版本执行新的蓝绿部署:

蓝绿部署

接下来,让我们看看金丝雀发布。

金丝雀发布

金丝雀发布是指在系统中并行安装当前版本的应用服务和新版本的发布。因此,它们类似于蓝绿部署。起初,所有流量仍然通过当前版本路由。然后我们配置路由器,使其将整体流量的一小部分,比如 1%,引导到应用服务的新版本。随后,密切监视新服务的行为,以找出它是否按预期工作。如果满足了所有成功标准,那么就配置路由器,使其通过新服务引导更多的流量,比如这次是 5%。再次密切监视新服务的行为,如果成功,就会将更多的流量引导到它,直到达到 100%。一旦所有流量都被引导到新服务,并且它已经稳定了一段时间,旧版本的服务就可以被废弃。

为什么我们称之为金丝雀发布?这是以煤矿工人为名,他们会在矿井中使用金丝雀作为早期警报系统。金丝雀对有毒气体特别敏感,如果这样的金丝雀死亡,矿工们就知道他们必须立即离开矿井。

不可逆的数据更改

如果我们的更新过程中包括在我们的状态中执行不可逆转的更改,比如在支持关系数据库中执行不可逆转的模式更改,那么我们需要特别小心处理这个问题。如果我们采用正确的方法,就可以在没有停机时间的情况下执行这些更改。重要的是要认识到,在这种情况下,我们不能同时部署需要新数据结构的代码更改和数据更改。相反,整个更新必须分为三个不同的步骤。在第一步中,我们推出一个向后兼容的模式和数据更改。如果这成功了,那么我们在第二步中推出新代码。同样,如果这成功了,我们在第三步中清理模式并删除向后兼容性:

推出不可逆转的数据或模式更改

前面的图表显示了数据及其结构的更新,然后是应用程序代码的更新,最后,在第三步中,数据和数据结构是如何清理的。

回滚

如果我们的应用服务在生产中运行并经常更新,迟早会出现其中一个更新的问题。也许开发人员在修复错误时引入了一个新错误,这个错误没有被所有自动化测试和可能的手动测试捕捉到,因此应用程序表现异常,迫切需要将服务回滚到之前的良好版本。在这方面,回滚是从灾难中恢复。

同样,在分布式应用程序架构中,问题不是是否会需要回滚,而是何时需要回滚。因此,我们必须确保我们始终可以回滚到我们应用程序中组成的任何服务的先前版本。回滚不能是事后想到的,它们必须是我们部署过程中经过测试和证明的一部分。

如果我们正在使用蓝绿部署来更新我们的服务,那么回滚应该是相当简单的。我们所需要做的就是将路由器从新的绿色版本的服务切换回之前的蓝色版本。

总结

在本章中,我们了解了分布式应用程序架构是什么,以及哪些模式和最佳实践对于成功运行分布式应用程序是有帮助或需要的。最后,我们讨论了在生产中运行这样的应用程序还需要什么。

在下一章中,我们将深入讨论仅限于单个主机的网络。我们将讨论同一主机上的容器如何相互通信,以及外部客户端如何在必要时访问容器化应用程序。

问题

请回答以下问题,以评估您对本章内容的理解:

  1. 分布式应用架构中的每个部分何时何地需要冗余?用几句话解释。

  2. 为什么我们需要 DNS 服务?用三到五句话解释。

  3. 什么是断路器,为什么需要它?

  4. 单体应用程序和分布式或多服务应用程序之间的一些重要区别是什么?

  5. 什么是蓝绿部署?

进一步阅读

以下文章提供了关于本章内容的更深入信息:

第十章:单主机网络

在上一章中,我们了解了处理分布式应用架构时使用的最重要的架构模式和最佳实践。

在本章中,我们将介绍 Docker 容器网络模型及其在桥接网络形式下的单主机实现。本章还介绍了软件定义网络的概念以及它们如何用于保护容器化应用程序。此外,我们将演示如何将容器端口对外开放,从而使容器化组件对外界可访问。最后,我们将介绍 Traefik,一个反向代理,它可以用于在容器之间启用复杂的 HTTP 应用级别路由。

本章涵盖以下主题:

  • 解剖容器网络模型

  • 网络防火墙

  • 使用桥接网络

  • 主机和空网络

  • 在现有网络命名空间中运行

  • 管理容器端口

  • 使用反向代理进行 HTTP 级别路由

完成本章后,您将能够执行以下操作:

  • 创建、检查和删除自定义桥接网络

  • 运行连接到自定义桥接网络的容器

  • 通过在不同的桥接网络上运行它们来使容器彼此隔离

  • 将容器端口发布到您选择的主机端口

  • 添加 Traefik 作为反向代理以启用应用级别路由

技术要求

对于本章,您唯一需要的是能够运行 Linux 容器的 Docker 主机。您可以使用带有 Docker for macOS 或 Windows 的笔记本电脑,或者安装了 Docker Toolbox。

解剖容器网络模型

到目前为止,我们大部分时间都在处理单个容器。但实际上,一个容器化的业务应用程序由多个容器组成,它们需要合作以实现目标。因此,我们需要一种让单个容器相互通信的方式。这是通过建立我们可以用来在容器之间发送数据包的路径来实现的。这些路径被称为网络。 Docker 定义了一个非常简单的网络模型,即所谓的容器网络模型CNM),以指定任何实现容器网络的软件必须满足的要求。以下是 CNM 的图形表示:

Docker CNM

CNM 有三个元素-沙盒、端点和网络:

  • 沙盒: 沙盒完全隔离了容器与外部世界的联系。沙盒容器不允许任何入站网络连接。但是,如果绝对不可能与容器进行任何通信,那么容器在系统中就没有任何价值。为了解决这个问题,我们有第二个元素,即端点。

  • 端点: 端点是从外部世界进入网络沙盒的受控网关,用于保护容器。端点将网络沙盒(但不是容器)连接到模型的第三个元素,即网络。

  • 网络: 网络是传输通信实例的数据包的路径,从端点到端点,或者最终从容器到容器。

需要注意的是,网络沙盒可以有零个或多个端点,或者说,生活在网络沙盒中的每个容器可以不连接到任何网络,也可以同时连接到多个不同的网络。在前面的图表中,三个网络沙盒中的中间一个通过一个端点连接到网络 1网络 2

这种网络模型非常通用,不指定进行网络通信的各个容器在哪里运行。例如,所有容器可以在同一台主机上运行(本地),也可以分布在一个主机集群中(全球)。

当然,CNM 只是描述容器之间网络工作方式的模型。为了能够在容器中使用网络,我们需要 CNM 的真正实现。对于本地和全局范围,我们有多种 CNM 的实现。在下表中,我们简要概述了现有实现及其主要特点。列表没有特定顺序:

网络 公司 范围 描述
桥接 Docker 本地 基于 Linux 桥接的简单网络,允许在单个主机上进行网络连接
Macvlan Docker 本地 在单个物理主机接口上配置多个第二层(即 MAC)地址
Overlay Docker 全球 基于虚拟可扩展局域网VXLan)的多节点容器网络
Weave Net Weaveworks 全球 简单、弹性、多主机 Docker 网络
Contiv 网络插件 Cisco 全球 开源容器网络

所有不是由 Docker 直接提供的网络类型都可以作为插件添加到 Docker 主机上。

网络防火墙

Docker 一直以安全第一为信条。这种理念直接影响了单个和多主机 Docker 环境中网络设计和实现的方式。软件定义网络易于创建且成本低廉,但它们可以完全防火墙连接到该网络的容器,使其与其他未连接的容器和外部世界隔离。属于同一网络的所有容器可以自由通信,而其他容器则无法这样做。

在下图中,我们有两个名为frontback的网络。连接到前端网络的有容器c1c2,连接到后端网络的有容器c3c4c1c2可以自由地相互通信,c3c4也可以。但是c1c2无法与c3c4通信,反之亦然:

Docker 网络

现在,如果我们有一个由三个服务组成的应用程序:webAPIproductCatalogdatabase?我们希望webAPI能够与productCatalog通信,但不能与database通信,而且我们希望productCatalog能够与database服务通信。我们可以通过将webAPI和数据库放在不同的网络上,并将productCatalog连接到这两个网络来解决这个问题,如下图所示:

连接到多个网络的容器

由于创建 SDN 成本低廉,并且每个网络通过将资源与未经授权的访问隔离提供了额外的安全性,因此强烈建议您设计和运行应用程序,使其使用多个网络,并且只在绝对需要相互通信的服务上运行在同一网络上。在前面的例子中,webAPI组件绝对不需要直接与database服务通信,因此我们将它们放在了不同的网络上。如果最坏的情况发生,黑客入侵了webAPI,他们也无法从那里访问database而不同时入侵productCatalog服务。

使用桥接网络

Docker 桥接网络是我们将要详细查看的容器网络模型的第一个实现。这个网络实现是基于 Linux 桥的。当 Docker 守护程序第一次运行时,它会创建一个 Linux 桥并将其命名为docker0。这是默认行为,可以通过更改配置来改变。然后 Docker 使用这个 Linux 桥创建一个名为bridge的网络。我们在 Docker 主机上创建的所有容器,如果没有明确绑定到另一个网络,都会自动连接到这个桥接网络。

要验证我们的主机上确实有一个名为bridgebridge类型网络,我们可以使用以下命令列出主机上的所有网络:

$ docker network ls

这应该提供类似以下的输出:

列出默认情况下所有可用的 Docker 网络

在你的情况下,ID 会有所不同,但输出的其余部分应该是一样的。我们确实有一个名为bridge的第一个网络,使用bridge驱动程序。范围为local只是意味着这种类型的网络受限于单个主机,不能跨多个主机。在第十三章中,Docker Swarm 简介,我们还将讨论其他具有全局范围的网络类型,这意味着它们可以跨整个主机集群。

现在,让我们更深入地了解一下这个桥接网络。为此,我们将使用 Docker 的inspect命令:

$ docker network inspect bridge

执行时,会输出有关所讨论网络的大量详细信息。这些信息应该如下所示:

检查 Docker 桥接网络时生成的输出

当我们列出所有网络时,我们看到了IDNameDriverScope的值,所以这并不是什么新鲜事。但让我们来看看IP 地址管理IPAM)块。IPAM 是用于跟踪计算机上使用的 IP 地址的软件。IPAM块的重要部分是Config节点及其对SubnetGateway的值。桥接网络的子网默认定义为172.17.0.0/16。这意味着连接到此网络的所有容器将获得由 Docker 分配的 IP 地址,该地址取自给定范围,即172.17.0.2172.17.255.255172.17.0.1地址保留给此网络的路由器,在这种类型的网络中,其角色由 Linux 桥接器承担。我们可以预期,由 Docker 连接到此网络的第一个容器将获得172.17.0.2地址。所有后续容器将获得更高的编号;下图说明了这一事实:

桥接网络

在前面的图表中,我们可以看到主机的网络命名空间,其中包括主机的eth0端点,如果 Docker 主机在裸机上运行,则通常是一个 NIC,如果 Docker 主机是一个 VM,则是一个虚拟 NIC。所有对主机的流量都通过eth0Linux 桥接器负责在主机网络和桥接网络子网之间路由网络流量。

默认情况下,只允许出站流量,所有入站流量都被阻止。这意味着,虽然容器化应用可以访问互联网,但不能被任何外部流量访问。连接到网络的每个容器都会与桥接器建立自己的虚拟以太网veth)连接。下图中有示例:

桥接网络的详细信息

前面的图表向我们展示了从主机的角度来看世界。我们将在本节的后面探讨从容器内部看这种情况是什么样子的。

我们不仅限于bridge网络,因为 Docker 允许我们定义自己的自定义桥接网络。这不仅是一个很好的功能,而且建议最佳实践是不要在同一个网络上运行所有容器。相反,我们应该使用额外的桥接网络来进一步隔离那些不需要相互通信的容器。要创建一个名为sample-net的自定义桥接网络,请使用以下命令:

$ docker network create --driver bridge sample-net

如果我们这样做,我们就可以检查 Docker 为这个新的自定义网络创建了什么子网,如下所示:

$ docker network inspect sample-net | grep Subnet

这将返回以下值:

"Subnet": "172.18.0.0/16",

显然,Docker 刚刚为我们的新自定义桥接网络分配了下一个空闲的 IP 地址块。如果出于某种原因,我们想要在创建网络时指定自己的子网范围,我们可以使用--subnet参数来实现:

$ docker network create --driver bridge --subnet "10.1.0.0/16" test-net

为了避免由于重复的 IP 地址而引起的冲突,请确保避免创建具有重叠子网的网络。

现在我们已经讨论了桥接网络是什么,以及我们如何创建自定义桥接网络,我们想要了解如何将容器连接到这些网络。首先,让我们交互式地运行一个 Alpine 容器,而不指定要连接的网络:

$ docker container run --name c1 -it --rm alpine:latest /bin/sh

在另一个终端窗口中,让我们检查c1容器:

$ docker container inspect c1

在庞大的输出中,让我们集中一下提供与网络相关信息的部分。这可以在NetworkSettings节点下找到。我在以下输出中列出了它:

容器元数据的 NetworkSettings 部分

在前面的输出中,我们可以看到容器确实连接到了bridge网络,因为NetworkID等于026e65...,我们可以从前面的代码中看到这是bridge网络的 ID。我们还可以看到容器获得了预期的 IP 地址172.17.0.4,网关位于172.17.0.1。请注意,容器还有一个与之关联的MacAddress。这很重要,因为 Linux 桥使用MacAddress进行路由。

到目前为止,我们已经从容器的网络命名空间外部进行了讨论。现在,让我们看看当我们不仅在容器内部,而且在容器的网络命名空间内部时情况是什么样的。在c1容器内部,让我们使用ip工具来检查发生了什么。运行ip addr命令并观察生成的输出,如下所示:

容器命名空间,如 IP 工具所示

前面输出的有趣部分是数字19,即eth0端点。Linux 桥在容器命名空间外创建的veth0端点映射到容器内的eth0。Docker 始终将容器网络命名空间的第一个端点映射到eth0,从命名空间内部看。如果网络命名空间连接到其他网络,则该端点将映射到eth1,依此类推。

由于此时我们实际上对eth0以外的任何端点都不感兴趣,我们可以使用命令的更具体的变体,它将给我们以下内容:

/ # ip addr show eth0
195: eth0@if196: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 1500 qdisc noqueue state UP
 link/ether 02:42:ac:11:00:02 brd ff:ff:ff:ff:ff:ff
 inet 172.17.0.2/16 brd 172.17.255.255 scope global eth0
 valid_lft forever preferred_lft forever

在输出中,我们还可以看到 Docker 将哪个 MAC 地址(02:42:ac:11:00:02)和哪个 IP(172.17.0.2)与该容器网络命名空间关联起来。

我们还可以使用ip route命令获取有关请求路由的一些信息:

/ # ip route
default via 172.17.0.1 dev eth0
172.17.0.0/16 dev eth0 scope link src 172.17.0.2

此输出告诉我们,所有流向网关172.17.0.1的流量都通过eth0设备路由。

现在,让我们在同一网络上运行另一个名为c2的容器:

$ docker container run --name c2 -d alpine:latest ping 127.0.0.1

由于我们没有指定任何其他网络,c2容器也将连接到bridge网络。它的 IP 地址将是子网中的下一个空闲地址,即172.17.0.3,我们可以轻松测试:

$ docker container inspect --format "{{.NetworkSettings.IPAddress}}" c2
172.17.0.3

现在,我们有两个容器连接到bridge网络。我们可以再次尝试检查此网络,以在输出中找到所有连接到它的容器的列表:

$ docker network inspect bridge

这些信息可以在Containers节点下找到:

Docker 网络检查桥的容器部分

再次,为了可读性,我们已将输出缩短为相关部分。

现在,让我们创建两个额外的容器c3c4,并将它们附加到test-net。为此,我们将使用--network参数:

$ docker container run --name c3 -d --network test-net \
 alpine:latest ping 127.0.0.1
$ docker container run --name c4 -d --network test-net \
 alpine:latest ping 127.0.0.1

让我们检查network test-net并确认c3c4容器确实连接到它:

$ docker network inspect test-net

这将为Containers部分提供以下输出:

docker network inspect test-net 命令的容器部分

接下来我们要问自己的问题是,c3c4容器是否可以自由通信。为了证明这确实是这种情况,我们可以exec进入c3容器:

$ docker container exec -it c3 /bin/sh

进入容器后,我们可以尝试通过名称和 IP 地址 ping 容器c4

/ # ping c4
PING c4 (10.1.0.3): 56 data bytes
64 bytes from 10.1.0.3: seq=0 ttl=64 time=0.192 ms
64 bytes from 10.1.0.3: seq=1 ttl=64 time=0.148 ms
...

以下是使用c4的 IP 地址进行 ping 的结果:

/ # ping 10.1.0.3
PING 10.1.0.3 (10.1.0.3): 56 data bytes
64 bytes from 10.1.0.3: seq=0 ttl=64 time=0.200 ms
64 bytes from 10.1.0.3: seq=1 ttl=64 time=0.172 ms
...

在这两种情况下的答案都向我们确认,连接到同一网络的容器之间的通信正常工作。我们甚至可以使用要连接的容器的名称,这表明 Docker DNS 服务提供的名称解析在这个网络内部工作。

现在,我们要确保bridgetest-net网络之间有防火墙。为了证明这一点,我们可以尝试从c3容器中 pingc2容器,无论是通过名称还是 IP 地址:

/ # ping c2
ping: bad address 'c2'

以下是使用c2容器的 IP 地址进行 ping 的结果:

/ # ping 172.17.0.3
PING 172.17.0.3 (172.17.0.3): 56 data bytes 
^C
--- 172.17.0.3 ping statistics ---
43 packets transmitted, 0 packets received, 100% packet loss

前面的命令一直挂起,我不得不用Ctrl+C终止命令。从 pingc2的输出中,我们还可以看到名称解析在网络之间不起作用。这是预期的行为。网络为容器提供了额外的隔离层,因此增强了安全性。

早些时候,我们了解到一个容器可以连接到多个网络。让我们同时将c5容器连接到sample-nettest-net网络:

$ docker container run --name c5 -d \
 --network sample-net \
 --network test-net \
 alpine:latest ping 127.0.0.1

现在,我们可以测试c5是否可以从c2容器中访问,类似于我们测试c4c2容器时的情况。结果将显示连接确实有效。

如果我们想要删除一个现有的网络,我们可以使用docker network rm命令,但请注意我们不能意外地删除已连接到容器的网络:

$ docker network rm test-net
Error response from daemon: network test-net id 863192... has active endpoints

在我们继续之前,让我们清理并删除所有的容器:

$ docker container rm -f $(docker container ls -aq)

现在,我们可以删除我们创建的两个自定义网络:

$ docker network rm sample-net
$ docker network rm test-net 

或者,我们可以使用prune命令删除所有未连接到容器的网络:

$ docker network prune --force

我在这里使用了--force(或-f)参数,以防止 Docker 重新确认我是否真的要删除所有未使用的网络。

主机和空网络

在本节中,我们将看一下两种预定义且有些独特的网络类型,即hostnull网络。让我们从前者开始。

主机网络

有时候,我们希望在主机的网络命名空间中运行容器。当我们需要在容器中运行用于分析或调试主机网络流量的软件时,这可能是必要的。但请记住,这些是非常特定的场景。在容器中运行业务软件时,没有任何理由将相应的容器附加到主机的网络上。出于安全原因,强烈建议您不要在生产环境或类似生产环境中运行任何附加到host网络的容器。

也就是说,我们如何在主机的网络命名空间中运行容器呢?只需将容器连接到host网络即可:

$ docker container run --rm -it --network host alpine:latest /bin/sh

如果我们使用ip工具从容器内部分析网络命名空间,我们会发现得到的结果与直接在主机上运行ip工具时完全相同。例如,如果我检查我的主机上的eth0设备,我会得到这样的结果:

/ # ip addr show eth0
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP qlen 1000
    link/ether 02:50:00:00:00:01 brd ff:ff:ff:ff:ff:ff
    inet 192.168.65.3/24 brd 192.168.65.255 scope global eth0
       valid_lft forever preferred_lft forever
    inet6 fe80::c90b:4219:ddbd:92bf/64 scope link
       valid_lft forever preferred_lft forever

在这里,我可以看到192.168.65.3是主机分配的 IP 地址,这里显示的 MAC 地址也对应于主机的 MAC 地址。

我们还可以检查路由,得到以下结果(缩短):

/ # ip route
default via 192.168.65.1 dev eth0 src 192.168.65.3 metric 202
10.1.0.0/16 dev cni0 scope link src 10.1.0.1
127.0.0.0/8 dev lo scope host
172.17.0.0/16 dev docker0 scope link src 172.17.0.1
...
192.168.65.0/24 dev eth0 scope link src 192.168.65.3 metric 202

在让您继续阅读本章的下一部分之前,我再次要指出,使用host网络是危险的,如果可能的话应该避免使用。

空网络

有时候,我们需要运行一些不需要任何网络连接来执行任务的应用服务或作业。强烈建议您将这些应用程序运行在附加到none网络的容器中。这个容器将完全隔离,因此不会受到任何外部访问的影响。让我们运行这样一个容器:

$ docker container run --rm -it --network none alpine:latest /bin/sh

一旦进入容器,我们可以验证没有eth0网络端点可用:

/ # ip addr show eth0
ip: can't find device 'eth0'

也没有可用的路由信息,我们可以使用以下命令来证明:

/ # ip route

这将返回空值。

在现有的网络命名空间中运行

通常,Docker 为我们运行的每个容器创建一个新的网络命名空间。容器的网络命名空间对应于我们之前描述的容器网络模型的沙盒。当我们将容器连接到网络时,我们定义一个端点,将容器的网络命名空间与实际网络连接起来。这样,我们每个网络命名空间有一个容器。

Docker 为我们提供了另一种定义容器运行的网络命名空间的方法。在创建新容器时,我们可以指定它应该附加到(或者我们应该说包含在)现有容器的网络命名空间中。通过这种技术,我们可以在单个网络命名空间中运行多个容器:

在单个网络命名空间中运行多个容器

在前面的图中,我们可以看到在最左边的网络 命名空间中,我们有两个容器。由于它们共享相同的命名空间,这两个容器可以在本地主机上相互通信。然后将网络命名空间(而不是单个容器)附加到网络 1

当我们想要调试现有容器的网络而不在该容器内运行其他进程时,这是非常有用的。我们只需将特殊的实用容器附加到要检查的容器的网络命名空间即可。这个特性也被 Kubernetes 在创建 pod 时使用。我们将在本书的第十五章中学习更多关于 Kubernetes 和 pod 的知识,Kubernetes 简介

现在,让我们演示一下这是如何工作的:

  1. 首先,我们创建一个新的桥接网络:
$ docker network create --driver bridge test-net
  1. 接下来,我们运行一个附加到这个网络的容器:
$ docker container run --name web -d \
 --network test-net nginx:alpine
  1. 最后,我们运行另一个容器并将其附加到我们的web容器的网络中:
$ docker container run -it --rm --network container:web \
alpine:latest /bin/sh

特别要注意我们如何定义网络:--network container:web。这告诉 Docker 我们的新容器应该使用与名为web的容器相同的网络命名空间。

  1. 由于新容器与运行 nginx 的 web 容器在相同的网络命名空间中,我们现在可以在本地访问 nginx!我们可以通过使用 Alpine 容器的一部分的wget工具来证明这一点,以连接到 nginx。我们应该看到以下内容:
/ # wget -qO - localhost
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
...
</html>

请注意,为了便于阅读,我们已经缩短了输出。还请注意,在运行附加到相同网络的两个容器和在相同网络命名空间中运行两个容器之间存在重要区别。在这两种情况下,容器可以自由地相互通信,但在后一种情况下,通信发生在本地主机上。

  1. 要清理容器和网络,我们可以使用以下命令:
$ docker container rm --force web
$ docker network rm test-net

在下一节中,我们将学习如何在容器主机上公开容器端口。

管理容器端口

现在我们知道了我们可以通过将它们放在不同的网络上来隔离防火墙容器,并且我们可以让一个容器连接到多个网络,但是还有一个问题没有解决。我们如何将应用服务暴露给外部世界?想象一下一个容器运行着一个 Web 服务器,托管着我们之前的 WebAPI。我们希望来自互联网的客户能够访问这个 API。我们已经设计它为一个公开访问的 API。为了实现这一点,我们必须象征性地打开我们防火墙中的一个门,通过这个门我们可以将外部流量传递到我们的 API。出于安全原因,我们不只是想要敞开大门;我们希望有一个单一受控的门,流量可以通过。

我们可以通过将容器端口映射到主机上的一个可用端口来创建这样的门。我们也称之为打开一个通往容器端口的门以发布一个端口。请记住,容器有自己的虚拟网络堆栈,主机也有。因此,默认情况下,容器端口和主机端口完全独立存在,根本没有任何共同之处。但是现在我们可以将一个容器端口与一个空闲的主机端口连接起来,并通过这个链接传递外部流量,如下图所示:

将容器端口映射到主机端口

但现在,是时候演示如何实际将容器端口映射到主机端口了。这是在创建容器时完成的。我们有不同的方法来做到这一点:

  1. 首先,我们可以让 Docker 决定将我们的容器端口映射到哪个主机端口。Docker 将在 32xxx 范围内选择一个空闲的主机端口进行自动映射,这是通过使用-P参数完成的:
$ docker container run --name web -P -d nginx:alpine

上述命令在一个容器中运行了一个 nginx 服务器。nginx 在容器内部监听端口80。使用-P参数,我们告诉 Docker 将所有暴露的容器端口映射到 32xxx 范围内的一个空闲端口。我们可以通过使用docker container port命令找出 Docker 正在使用的主机端口:

$ docker container port web
80/tcp -> 0.0.0.0:32768

nginx 容器只暴露端口80,我们可以看到它已经映射到主机端口32768。如果我们打开一个新的浏览器窗口并导航到localhost:32768,我们应该会看到以下屏幕:

nginx 的欢迎页面

  1. 找出 Docker 用于我们的容器的主机端口的另一种方法是检查它。主机端口是NetworkSettings节点的一部分:
$ docker container inspect web | grep HostPort
32768
  1. 最后,获取这些信息的第三种方法是列出容器:
$ docker container ls
CONTAINER ID    IMAGE         ...   PORTS                  NAMES
56e46a14b6f7    nginx:alpine  ...   0.0.0.0:32768->80/tcp  web

请注意,在上述输出中,/tcp部分告诉我们该端口已经为 TCP 协议通信打开,但未为 UDP 协议打开。TCP 是默认的,如果我们想指定为 UDP 打开端口,那么我们必须明确指定。映射中的0.0.0.0告诉我们,任何主机 IP 地址的流量现在都可以到达web容器的端口80

有时,我们想将容器端口映射到一个非常特定的主机端口。我们可以使用-p参数(或--publish)来实现这一点。让我们看看如何使用以下命令来实现这一点:

$ docker container run --name web2 -p 8080:80 -d nginx:alpine

-p参数的值的格式为<主机端口>:<容器端口>。因此,在上述情况中,我们将容器端口80映射到主机端口8080。一旦web2容器运行,我们可以通过浏览器导航到localhost:8080来测试它,我们应该会看到与处理自动端口映射的上一个示例中看到的相同的 nginx 欢迎页面。

使用 UDP 协议进行特定端口通信时,publish参数看起来像-p 3000:4321/udp。请注意,如果我们想要允许在同一端口上使用 TCP 和 UDP 协议进行通信,那么我们必须分别映射每个协议。

使用反向代理进行 HTTP 级别的路由

想象一下,你被要求将一个庞大的应用程序容器化。这个应用程序多年来已经自然地演变成了一个难以维护的怪物。由于代码库中存在紧密耦合,即使是对源代码进行微小的更改也可能会破坏其他功能。由于其复杂性,发布版本很少,并且需要整个团队全力以赴。在发布窗口期间必须关闭应用程序,这会给公司带来很大的损失,不仅是由于失去的机会,还有他们的声誉损失。

管理层已决定结束这种恶性循环,并通过容器化单体应用来改善情况。这一举措将大大缩短发布之间的时间,正如行业所见。在随后的步骤中,公司希望从单体应用中分离出每一个功能,并将它们实现为微服务。这个过程将持续进行,直到单体应用完全被分解。

但正是这第二点让参与其中的团队感到困惑。我们如何将单体应用分解为松耦合的微服务,而不影响单体应用的众多客户?单体应用的公共 API 虽然非常复杂,但设计得很结构化。公共 URI 已经经过精心设计,绝对不能改变。例如,应用程序中实现了一个产品目录功能,可以通过https://acme.com/catalog?category=bicycles来访问,以便我们可以访问公司提供的自行车列表。

另一方面,有一个名为https://acme.com/checkout的 URL,我们可以用它来启动客户购物车的结账,等等。我希望大家清楚我们要做什么。

容器化单体应用

让我们从单体应用开始。我已经准备了一个简单的代码库,它是用 Python 2.7 实现的,并使用 Flask 来实现公共 REST API。示例应用程序并不是一个完整的应用程序,但足够复杂,可以进行一些重新设计。示例代码可以在ch10/e-shop文件夹中找到。在这个文件夹中有一个名为monolith的子文件夹,其中包含 Python 应用程序。按照以下步骤进行:

  1. 在新的终端窗口中,导航到该文件夹,安装所需的依赖项,并运行应用程序:
$ cd ~/fod/ch10/e-shop/monolith
$ pip install -r requirements.txt
$ export FLASK_APP=main.py 
$ flask run

应用程序将在localhost5000端口上启动并监听:

运行 Python 单体应用

  1. 我们可以使用curl来测试应用程序。使用以下命令来检索公司提供的所有自行车的列表:
$ curl localhost:5000/catalog?category=bicycles [{"id": 1, "name": "Mountanbike Driftwood 24\"", "unitPrice": 199}, {"id": 2, "name": "Tribal 100 Flat Bar Cycle Touring Road Bike", "unitPrice": 300}, {"id": 3, "name": "Siech Cycles Bike (58 cm)", "unitPrice": 459}]

您应该看到一个 JSON 格式的自行车类型列表。好吧,目前为止一切顺利。

  1. 现在,让我们更改hosts文件,为acme.com添加一个条目,并将其映射到127.0.0.1,即环回地址。这样,我们可以模拟一个真实的客户端使用 URL http://acme.cnoteom/catalog?category=bicycles 访问应用程序,而不是使用localhost。在 macOS 或 Linux 上,您需要使用 sudo 来编辑 hosts 文件。您应该在hosts文件中添加一行,看起来像这样:
127.0.0.1  acme.com  
  1. 保存您的更改,并通过 ping acme.com来确认它是否正常工作:

通过hosts文件将acme.com映射到环回地址在 Windows 上,您可以通过以管理员身份运行记事本,打开c:\Windows\System32\Drivers\etc\hosts文件并修改它来编辑文件。

经过所有这些步骤,现在是时候将应用程序容器化了。我们需要做的唯一更改是确保应用程序 Web 服务器侦听0.0.0.0而不是localhost

  1. 我们可以通过修改应用程序并在main.py的末尾添加以下启动逻辑来轻松实现这一点:
if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5000)

然后,我们可以使用python main.py启动应用程序。

  1. 现在,在monolith文件夹中添加一个Dockerfile,内容如下:
FROM python:3.7-alpine
WORKDIR /app
COPY requirements.txt ./
RUN pip install -r requirements.txt
COPY . .
EXPOSE 5000
CMD python main.py
  1. 在您的终端窗口中,从单体文件夹中执行以下命令,为应用程序构建 Docker 镜像:
$ docker image build -t acme/eshop:1.0 .
  1. 构建完镜像后,尝试运行应用程序:
$ docker container run --rm -it \
 --name eshop \
 -p 5000:5000 \
 acme/eshop:1.0

请注意,现在在容器内运行的应用程序的输出与在主机上直接运行应用程序时获得的输出是无法区分的。现在,我们可以使用两个curl命令来访问目录和结账逻辑,测试应用程序是否仍然像以前一样工作:

在容器中运行时测试单体应用程序

显然,即使使用正确的 URL,即http://acme.com,单体仍然以与以前完全相同的方式工作。太好了!现在,让我们将单体的一部分功能拆分为一个 Node.js 微服务,这将被单独部署。

提取第一个微服务

团队经过一番头脑风暴后决定,产品catalog是第一个具有内聚力且足够独立的功能片段,可以从单体中提取出来作为微服务实现。他们决定将产品目录实现为一个基于 Node.js 的微服务。

您可以在项目文件夹的e-shopcatalog子文件夹中找到他们提出的代码和Dockerfile。这是一个简单的 Express.js 应用程序,复制了以前在单体中可用的功能。让我们开始吧:

  1. 在您的终端窗口中,从catalog文件夹中构建这个新的微服务的 Docker 镜像:
$ docker image build -t acme/catalog:1.0 .
  1. 然后,从您刚刚构建的新镜像中运行一个容器:
$ docker run --rm -it --name catalog -p 3000:3000 acme/catalog:1.0
  1. 从另一个终端窗口中,尝试访问微服务并验证它返回与单体相同的数据:
$ curl http://acme.com:3000/catalog?type=bicycle

请注意与访问单体应用程序中相同功能时的 URL 的差异。在这里,我们正在访问端口3000上的微服务(而不是5000)。但是我们说过,我们不想改变访问我们电子商店应用程序的客户端。我们能做什么?幸运的是,有解决这类问题的解决方案。我们需要重新路由传入的请求。我们将在下一节中向您展示如何做到这一点。

使用 Traefik 重新路由流量

在上一节中,我们意识到我们将不得不将以http://acme.com:5000/catalog开头的目标 URL 的传入流量重新路由到另一个 URL,例如product-catalog:3000/catalog。我们将使用 Traefik 来偏向这样做。

Traefik 是一个云原生边缘路由器,它是开源的,这对我们来说非常好。它甚至有一个漂亮的 Web UI,您可以用来管理和监视您的路由。Traefik 可以与 Docker 非常直接地结合使用,我们马上就会看到。

为了与 Docker 很好地集成,Traefik 依赖于在每个容器或服务中找到的元数据。这些元数据可以以包含路由信息的标签的形式应用。

首先,让我们看一下如何运行目录服务:

  1. 这是 Docker run命令:
$ docker container run --rm -d \
 --name catalog \
 --label traefik.enable=true \
 --label traefik.port=3000 \
 --label traefik.priority=10 \
 --label traefik.http.routers.catalog.rule="Host(\"acme.com\") && PathPrefix(\"/catalog\")" \
 acme/catalog:1.0
  1. 让我们快速看一下我们定义的四个标签:
    • traefik.enable=true:这告诉 Traefik 这个特定的容器应该包括在路由中(默认值为false)。
  • traefik.port=3000:路由器应将调用转发到端口3000(这是 Express.js 应用程序正在监听的端口)。

  • traefik.priority=10:给这条路线高优先级。我们马上就会看到为什么。

  • traefik.http.routers.catalog.rule="Host(\"acme.com\") && PathPrefix(\"/catalog\")":路由必须包括主机名acme.com,路径必须以/catalog开头才能被重定向到该服务。例如,acme.com/catalog?type=bicycles符合此规则。

请注意第四个标签的特殊形式。它的一般形式是traefik.http.routers.<service name>.rule

  1. 现在,让我们看看如何运行eshop容器:
$ docker container run --rm -d \
    --name eshop \
    --label traefik.enable=true \
    --label traefik.port=5000 \
    --label traefik.priority=1 \
    --label traefik.http.routers.eshop.rule="Host(\"acme.com\")" \
    acme/eshop:1.0

在这里,我们将任何匹配的调用转发到端口5000,这对应于eshop应用程序正在监听的端口。请注意优先级设置为1(低)。这与catalog服务的高优先级结合起来,使我们能够过滤出所有以/catalog开头的 URL,并将其重定向到catalog服务,而所有其他 URL 将转到eshop服务。

  1. 现在,我们终于可以将 Traefik 作为边缘路由器运行,它将作为我们应用程序前面的反向代理。这是我们启动它的方式:
$ docker run -d \
 --name traefik \
 -p 8080:8080 \
 -p 80:80 \
 -v /var/run/docker.sock:/var/run/docker.sock \
 traefik:v2.0 --api.insecure=true --providers.docker

注意我们如何将 Docker 套接字挂载到容器中,以便 Traefik 可以与 Docker 引擎交互。我们将能够将 Web 流量发送到 Traefik 的端口80,然后根据参与容器的元数据中的路由定义,根据我们的规则进行重定向。此外,我们可以通过端口8080访问 Traefik 的 Web UI。

现在一切都在运行,即单体应用程序,第一个名为catalog的微服务和 Traefik,我们可以测试一切是否按预期工作。再次使用curl来测试:

$ curl http://acme.com/catalog?type=bicycles
$ curl http://acme.com/checkout

正如我们之前提到的,现在我们将所有流量发送到端口80,这是 Traefik 正在监听的端口。然后,这个代理将把流量重定向到正确的目的地。

在继续之前,请停止所有容器:

$ docker container rm -f traefik eshop catalog

这就是本章的全部内容。

摘要

在本章中,我们了解了单个主机上运行的容器如何相互通信。首先,我们看了一下 CNM,它定义了容器网络的要求,然后我们调查了 CNM 的几种实现,比如桥接网络。然后我们详细了解了桥接网络的功能,以及 Docker 提供给我们有关网络和连接到这些网络的容器的信息。我们还学习了如何从容器的内外采用两种不同的视角。最后,我们介绍了 Traefik 作为一种提供应用级路由到我们的应用程序的手段。

在下一章中,我们将介绍 Docker Compose。我们将学习如何创建一个由多个服务组成的应用程序,每个服务在一个容器中运行,并且 Docker Compose 如何允许我们使用声明性方法轻松构建、运行和扩展这样的应用程序。

问题

为了评估您从本章中获得的技能,请尝试回答以下问题:

  1. 命名容器网络模型CNM)的三个核心元素。

  2. 如何创建一个名为frontend的自定义桥接网络?

  3. 如何运行两个连接到frontend网络的nginx:alpine容器?

  4. 对于frontend网络,获取以下内容:

  • 所有连接的容器的 IP 地址

  • 与网络相关联的子网

  1. host网络的目的是什么?

  2. 使用host网络适用的一个或两个场景的名称。

  3. none网络的目的是什么?

  4. 在什么情况下应该使用none网络?

  5. 为什么我们会与容器化应用一起使用反向代理,比如 Traefik?

进一步阅读

以下是一些更详细描述本章主题的文章:

第十一章:Docker Compose

在上一章中,我们学到了关于容器网络在单个 Docker 主机上是如何工作的。我们介绍了容器网络模型CNM),它构成了 Docker 容器之间所有网络的基础,然后我们深入研究了 CNM 的不同实现,特别是桥接网络。最后,我们介绍了 Traefik,一个反向代理,以实现容器之间复杂的 HTTP 应用级路由。

本章介绍了一个应用程序由多个服务组成的概念,每个服务在一个容器中运行,以及 Docker Compose 如何允许我们使用声明式方法轻松构建、运行和扩展这样的应用程序。

本章涵盖以下主题:

  • 揭秘声明式与命令式

  • 运行多服务应用程序

  • 扩展服务

  • 构建和推送应用程序

  • 使用 Docker Compose 覆盖

完成本章后,读者将能够做到以下事情:

  • 用几句简短的话解释命令式和声明式方法在定义和运行应用程序方面的主要区别

  • 用自己的话描述容器和 Docker Compose 服务之间的区别

  • 为简单的多服务应用程序编写 Docker Compose YAML 文件

  • 使用 Docker Compose 构建、推送、部署和拆除简单的多服务应用程序

  • 使用 Docker Compose 扩展和缩减应用服务

  • 使用覆盖定义特定于环境的 Docker Compose 文件

技术要求

本章附带的代码可以在以下位置找到:github.com/PacktPublishing/Learn-Docker---Fundamentals-of-Docker-19.x-Second-Edition/tree/master/ch11

您需要在系统上安装docker-compose。如果您在 Windows 或 macOS 计算机上安装了 Docker for Desktop 或 Docker Toolbox,则这是自动的。否则,您可以在这里找到详细的安装说明:docs.docker.com/compose/install/

揭秘声明式与命令式

Docker Compose 是 Docker 提供的一个工具,主要用于在单个 Docker 主机上运行和编排容器。这包括但不限于开发、持续集成CI)、自动化测试、手动 QA 或演示。

Docker Compose 使用 YAML 格式的文件作为输入。默认情况下,Docker Compose 期望这些文件被称为docker-compose.yml,但也可以使用其他名称。docker-compose.yml的内容被称为描述和运行一个可能由多个容器组成的容器化应用程序的声明性方式。

那么,声明性的意思是什么呢?

首先,声明性命令式的反义词。好吧,这并没有太大帮助。既然我介绍了另一个定义,我需要解释这两个定义:

  • 命令式:这是一种通过指定系统必须遵循的确切过程来解决问题的方式。

如果我命令式地告诉 Docker 守护程序如何运行一个应用程序,那意味着我必须逐步描述系统必须做什么,以及如果发生意外情况时系统必须如何反应。我必须非常明确和精确地说明我的指示。我需要涵盖所有边缘情况以及它们需要如何处理。

  • 声明式:这是一种解决问题的方式,不需要程序员指定要遵循的确切过程。

声明性方法意味着我告诉 Docker 引擎我的应用程序的期望状态是什么,它必须自行解决如何实现这个期望状态,以及如果系统偏离了这个状态,如何调和它。

Docker 在处理容器化应用程序时明确推荐使用声明性方法。因此,Docker Compose 工具使用了这种方法。

运行多服务应用程序

在大多数情况下,应用程序不仅由一个单块组成,而是由几个应用程序服务共同工作。使用 Docker 容器时,每个应用程序服务都在自己的容器中运行。当我们想要运行这样一个多服务应用程序时,当然可以使用众所周知的docker container run命令启动所有参与的容器,我们在之前的章节中已经这样做了。但这充其量是低效的。使用 Docker Compose 工具,我们可以以声明性的方式在使用 YAML 格式的文件中定义应用程序。

让我们来看一个简单的docker-compose.yml文件的内容:

version: "2.4"
services:
 web:
    image: fundamentalsofdocker/ch11-web:2.0
    build: web
    ports:
    - 80:3000
 db:
    image: fundamentalsofdocker/ch11-db:2.0
    build: db
    volumes:
    - pets-data:/var/lib/postgresql/data

volumes:
 pets-data:

文件中的行解释如下:

  • version:在这一行中,我们指定要使用的 Docker Compose 格式的版本。在撰写本文时,这是 2.4 版本。

  • 服务:在这一部分,我们在services块中指定了构成我们应用程序的服务。在我们的示例中,我们有两个应用程序服务,我们称它们为webdb

  • webweb服务使用一个名为fundamentalsofdocker/ch11-web:2.0的镜像,如果镜像不在镜像缓存中,它将从web文件夹中的Dockerfile构建。该服务还将容器端口3000发布到主机端口80

  • db:另一方面,db服务使用的是镜像名称fundamentalsofdocker/ch11-db:2.0,这是一个定制的 PostgreSQL 数据库。同样,如果镜像不在缓存中,它将从db文件夹中的Dockerfile构建。我们将一个名为pets-data的卷挂载到db服务的容器中。

  • :任何服务使用的卷都必须在此部分声明。在我们的示例中,这是文件的最后一部分。第一次运行应用程序时,Docker 将创建一个名为pets-data的卷,然后在后续运行中,如果卷仍然存在,它将被重用。当应用程序由于某种原因崩溃并需要重新启动时,这可能很重要。然后,先前的数据仍然存在并准备好供重新启动的数据库服务使用。

请注意,我们使用的是 Docker Compose 文件语法的 2.x 版本。这是针对单个 Docker 主机部署的版本。Docker Compose 文件语法还存在 3.x 版本。当您想要定义一个针对 Docker Swarm 或 Kubernetes 的应用程序时,可以使用此版本。我们将从第十二章开始更详细地讨论这个问题,编排器

使用 Docker Compose 构建镜像

导航到fods文件夹的ch11子文件夹,然后构建镜像:

$ cd ~/fod/ch11
$ docker-compose build

如果我们输入上述命令,那么工具将假定当前目录中必须有一个名为docker-compose.yml的文件,并将使用该文件来运行。在我们的情况下,确实如此,工具将构建镜像。

在您的终端窗口中,您应该看到类似于这样的输出:

为 web 服务构建 Docker 镜像

在上述屏幕截图中,您可以看到docker-compose首先从 Docker Hub 下载了基本图像node:12.12-alpine,用于我们正在构建的 Web 图像。 随后,它使用web文件夹中找到的Dockerfile构建图像,并将其命名为fundamentalsofdocker/ch11-web:2.0。 但这只是第一部分; 输出的第二部分应该类似于这样:

浏览器中的示例应用程序

创建了两个服务,ch11_web_1ch11_db_1,并将它们附加到网络

在这里,docker-compose再次从 Docker Hub 拉取基本图像postgres:12.0-alpine,然后使用db文件夹中找到的Dockerfile构建我们称为fundamentalsofdocker/ch11-db:2.0的图像。

使用 Docker Compose 运行应用程序

构建了 db 服务的 Docker 镜像

$ docker-compose up

输出将向我们展示应用程序的启动。 我们应该看到以下内容:

运行示例应用程序,第一部分

在输出的第一部分中,我们看到 Docker Compose 执行以下操作:

  • 现在让我们看一下输出的第二部分:

  • 创建名为ch11_pets-data的卷

  • 现在我们可以打开一个浏览器标签,并导航到localhost/animal。 我们应该会看到我在肯尼亚马赛马拉国家公园拍摄的一张野生动物的照片:

Docker Compose 还显示了数据库(蓝色)和 Web 服务(黄色)生成的日志输出。 倒数第三行的输出向我们展示了 Web 服务已准备就绪,并在端口3000上监听。 请记住,这是容器端口,而不是主机端口。 我们已将容器端口3000映射到主机端口80,这是我们稍后将访问的端口。

运行示例应用程序,第二部分

我们已经缩短了输出的第二部分。 它向我们展示了数据库如何完成初始化。 我们可以具体看到我们的初始化脚本init-db.sql的应用,该脚本定义了一个数据库并用一些数据填充它。

创建名为ch11_default的桥接网络

一旦我们构建了图像,就可以使用 Docker Compose 启动应用程序:

刷新浏览器几次以查看其他猫的图片。 应用程序从数据库中存储的 12 个图像的 URL 中随机选择当前图像。

由于应用程序正在交互模式下运行,因此我们运行 Docker Compose 的终端被阻塞,我们可以通过按Ctrl+C来取消应用程序。如果我们这样做,我们会看到以下内容:

^CGracefully stopping... (press Ctrl+C again to force)
Stopping ch11_web_1 ... done
Stopping ch11_db_1 ... done

我们会注意到数据库和 web 服务会立即停止。不过有时,一些服务可能需要大约 10 秒钟才能停止。原因是数据库和 web 服务监听并对 Docker 发送的SIGTERM信号做出反应,而其他服务可能不会,因此 Docker 在预定义的 10 秒超时间隔后将它们杀死。

如果我们再次使用docker-compose up运行应用程序,输出将会更短:

docker-compose up 的输出

这一次,我们不需要下载镜像,数据库也不需要从头开始初始化,而是只是重用了上一次运行中已经存在的pets-data卷中的数据。

我们也可以在后台运行应用程序。所有容器将作为守护进程运行。为此,我们只需要使用-d参数,如下面的代码所示:

$ docker-compose up -d

Docker Compose 为我们提供了许多比up更多的命令。我们可以使用这个工具来列出应用程序中的所有服务:

docker-compose ps 的输出

这个命令类似于docker container ls,唯一的区别是docker-compose只列出应用程序中的容器或服务。

要停止和清理应用程序,我们使用docker-compose down命令:

$ docker-compose down
Stopping ch11_web_1 ... done
Stopping ch11_db_1 ... done
Removing ch11_web_1 ... done
Removing ch11_db_1 ... done
Removing network ch11_default

如果我们还想删除数据库的卷,那么我们可以使用以下命令:

$ docker volume rm ch11_pets-data

或者,我们可以将docker-compose downdocker volume rm <volume name>两个命令合并成一个单一的命令:

$ docker-compose down -v

在这里,参数-v(或--volumes)会移除在compose文件的volumes部分声明的命名卷和附加到容器的匿名卷。

为什么卷的名称中有一个ch11前缀?在docker-compose.yml文件中,我们已经调用了要使用的卷为pets-data。但是,正如我们已经提到的,Docker Compose 会用父文件夹的名称加上下划线作为所有名称的前缀。在这种情况下,父文件夹的名称叫做ch11。如果你不喜欢这种方法,你可以显式地定义一个项目名称,例如:

$ docker-compose -p my-app up

它使用了一个名为 my-app 的项目名称来运行应用程序。

扩展服务

现在,让我们假设我们的示例应用程序已经在网络上运行并且变得非常成功。很多人想要看我们可爱的动物图片。所以现在我们面临一个问题,因为我们的应用程序开始变慢了。为了解决这个问题,我们想要运行多个 web 服务的实例。使用 Docker Compose,这很容易实现。

运行更多实例也被称为扩展。我们可以使用这个工具将我们的web服务扩展到,比如说,三个实例:

$ docker-compose up --scale web=3

如果我们这样做,我们会有一个惊喜。输出将类似于以下的截图:

docker-compose --scale 的输出

web 服务的第二个和第三个实例无法启动。错误消息告诉我们原因:我们不能多次使用相同的主机端口80。当第 2 和第 3 个实例尝试启动时,Docker 意识到端口80已经被第一个实例占用。我们能做什么?嗯,我们可以让 Docker 决定为每个实例使用哪个主机端口。

如果在compose文件的ports部分中,我们只指定容器端口,而不指定主机端口,那么 Docker 会自动选择一个临时端口。让我们就这样做:

  1. 首先,让我们拆除应用程序:
$ docker-compose down
  1. 然后,我们修改docker-compose.yml文件如下所示:
version: "2.4"
services:
  web:
    image: fundamentalsofdocker/ch11-web:2.0
    build: web
    ports:
      - 3000
  db:
    image: fundamentalsofdocker/ch11-db:2.0
    build: db
    volumes:
      - pets-data:/var/lib/postgresql/data

volumes:
  pets-data:
  1. 现在,我们可以再次启动应用程序,并立即扩展它:
$ docker-compose up -d
$ docker-compose up -d --scale web=3
Starting ch11_web_1 ... done
Creating ch11_web_2 ... done
Creating ch11_web_3 ... done
  1. 如果我们现在执行docker-compose ps,我们应该会看到以下的截图:

docker-compose ps 的输出

  1. 正如我们所看到的,每个服务都关联到了不同的主机端口。我们可以尝试看看它们是否工作,比如使用curl。让我们测试第三个实例,ch11_web_3
$ curl -4 localhost:32772
Pets Demo Application

答案Pets Demo Application告诉我们,我们的应用程序确实仍然按预期工作。为了确保,尝试对其他两个实例进行测试。

构建和推送应用程序

我们之前已经看到,我们也可以使用docker-compose build命令来构建docker-compose文件中定义的应用程序的镜像。但是为了使其工作,我们必须将构建信息添加到docker-compose文件中。在文件夹中,我们有一个名为docker-compose.dev.yml的文件,其中已经添加了这些指令。它基本上是我们迄今为止使用的docker-compose.yml文件的副本。

version: "2.4"
services:
  web:
    build: web
    image: fundamentalsofdocker/ch11-web:2.0
    ports:
      - 80:3000
  db:
    build: db
    image: fundamentalsofdocker/ch1-db:2.0
    volumes:
      - pets-data:/var/lib/postgresql/data

volumes:
  pets-data:

请注意每个服务的build键。该键的值表示 Docker 期望找到Dockerfile以构建相应映像的上下文或文件夹。如果我们想要为web服务使用命名不同的Dockerfile,比如Dockerfile-dev,那么docker-compose文件中的build块将如下所示:

build:
    context: web
    dockerfile: Dockerfile-dev

现在让我们使用另一个docker-compose-dev.yml文件:

$ docker-compose -f docker-compose.dev.yml build

-f参数将告诉 Docker Compose 应用程序使用哪个compose文件。

要将所有映像推送到 Docker Hub,我们可以使用docker-compose push。我们需要登录到 Docker Hub,以便成功,否则在推送时会出现身份验证错误。因此,在我的情况下,我执行以下操作:

$ docker login -u fundamentalsofdocker -p <password>

假设登录成功,然后我可以推送以下代码:

$ docker-compose -f docker-compose.dev.yml push

这可能需要一段时间,具体取决于您的互联网连接带宽。在推送时,您的屏幕可能看起来类似于这样:

使用 docker-compose 将映像推送到 Docker Hub

上述命令将两个映像推送到 Docker Hub 上的fundamentalsofdocker帐户。您可以在以下网址找到这两个映像:hub.docker.com/u/fundamentalsofdocker/

使用 Docker Compose 覆盖

有时,我们希望在需要特定配置设置的不同环境中运行我们的应用程序。Docker Compose 提供了一个方便的功能来解决这个问题。

让我们举一个具体的例子。我们可以定义一个基本的 Docker Compose 文件,然后定义特定于环境的覆盖。假设我们有一个名为docker-compose.base.yml的文件,内容如下:

version: "2.4"
services:
  web:
    image: fundamentalsofdocker/ch11-web:2.0
  db:
    image: fundamentalsofdocker/ch11-db:2.0
    volumes:
      - pets-data:/var/lib/postgresql/data

volumes:
  pets-data:

这只定义了在所有环境中应该相同的部分。所有特定的设置都已被移除。

假设我们想要在 CI 系统上运行我们的示例应用程序,但是我们想要为数据库使用不同的设置。我们用来创建数据库映像的Dockerfile如下所示:

FROM postgres:12.0-alpine
COPY init-db.sql /docker-entrypoint-initdb.d/
ENV POSTGRES_USER dockeruser
ENV POSTGRES_PASSWORD dockerpass
ENV POSTGRES_DB pets

请注意我们在第 3 到 5 行定义的三个环境变量。web服务的Dockerfile具有类似的定义。假设在 CI 系统上,我们想要执行以下操作:

  • 从代码构建映像

  • POSTGRES_PASSWORD定义为ci-pass

  • 将 web 服务的容器端口3000映射到主机端口5000

然后,相应的覆盖文件将如下所示:

version: "2.4"
services:
  web:
    build: web
    ports:
      - 5000:3000
    environment:
      POSTGRES_PASSWORD: ci-pass
  db:
    build: db
    environment:
      POSTGRES_PASSWORD: ci-pass

我们可以使用以下命令运行此应用程序:

$ docker-compose -f docker-compose.yml -f docker-compose-ci.yml up -d --build

请注意,第一个-f参数提供基本的 Docker Compose 文件,第二个参数提供覆盖文件。--build参数用于强制docker-compose重新构建镜像。

在使用环境变量时,请注意以下优先级:

  • 在 Docker 文件中声明它们会定义默认值

  • 在 Docker Compose 文件中声明相同的变量会覆盖 Dockerfile 中的值

如果我们遵循标准命名约定,将基本文件命名为docker-compose.yml,覆盖文件命名为docker-compose.override.yml,那么我们可以使用docker-compose up -d来启动应用程序,而无需显式命名 compose 文件。

总结

在本章中,我们介绍了docker-compose工具。该工具主要用于在单个 Docker 主机上运行和扩展多服务应用程序。通常,开发人员和 CI 服务器使用单个主机,这两者是 Docker Compose 的主要用户。该工具使用 YAML 文件作为输入,其中包含以声明方式描述应用程序的描述。

该工具还可用于构建和推送镜像,以及许多其他有用的任务。本章附带的代码可以在fod/ch11中找到。

在下一章中,我们将介绍编排器。编排器是一种基础设施软件,用于在集群中运行和管理容器化应用程序,同时确保这些应用程序始终处于所需的状态。

问题

为了评估您的学习进度,请回答以下问题:

  1. 你将如何使用docker-compose以守护进程模式运行应用程序?

  2. 你将如何使用docker-compose来显示运行服务的详细信息?

  3. 你将如何将特定的 web 服务扩展到比如说三个实例?

进一步阅读

以下链接提供了本章讨论的主题的额外信息:

第十二章:编排器

在上一章中,我们介绍了 Docker Compose,这是一个允许我们在单个 Docker 主机上以声明方式定义多服务应用程序的工具。

本章介绍了编排器的概念。它教会我们为什么需要编排器,以及它们在概念上是如何工作的。本章还将概述最流行的编排器,并列出它们的一些优缺点。

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

  • 编排器是什么,为什么我们需要它们?

  • 编排器的任务

  • 流行编排器概述

完成本章后,您将能够做到以下几点:

  • 列举编排器负责的三到四个任务

  • 列举两到三个最流行的编排器

  • 用你自己的话和适当的类比向一个感兴趣的外行解释为什么我们需要容器编排器

编排器是什么,为什么我们需要它们?

在[第九章](bbbf480e-3d5a-4ad7-94e9-fae735b025ae.xhtml),分布式应用架构中,我们了解了成功构建、部署和运行高度分布式应用程序常用的模式和最佳实践。现在,如果我们的分布式应用程序是容器化的,那么我们将面临与非容器化分布式应用程序面临的完全相同的问题或挑战。其中一些挑战是在[第九章](bbbf480e-3d5a-4ad7-94e9-fae735b025ae.xhtml),分布式应用架构中讨论过的——服务发现、负载均衡、扩展等等。

类似于 Docker 对容器所做的事情——通过引入这些容器来标准化软件的打包和交付——我们希望有一些工具或基础设施软件来处理提到的所有或大部分挑战。这个软件就是我们所说的容器编排器,或者我们也称之为编排引擎。

如果我刚才说的对你来说还不太有意义,那么让我们从另一个角度来看。拿一个演奏乐器的艺术家来说。他们可以独自为观众演奏美妙的音乐 - 只有艺术家和他们的乐器。但现在想象一个由音乐家组成的管弦乐团。把他们都放在一个房间里,给他们一首交响乐的音符,让他们演奏,并离开房间。没有指挥,这群非常有才华的音乐家将无法和谐地演奏这首曲子;它听起来或多或少会像一片杂音。只有管弦乐团有一个指挥,来指挥这群音乐家,管弦乐团的音乐才会让我们的耳朵愉悦:

那么,我们期望一个值得投资的编排者为我们执行哪些任务呢? 让我们详细看一下。以下列表显示了在撰写本文时,企业用户通常期望从他们的编排者那里得到的最重要的任务。

容器编排者就像管弦乐团的指挥

我希望你现在能更清楚地看到容器编排者是什么,以及为什么我们需要它。假设你确认了这个问题,我们现在可以问自己编排者将如何实现预期的结果,即确保集群中的所有容器和谐地相互配合。嗯,答案是,编排者必须执行非常具体的任务,类似于管弦乐团的指挥也有一系列任务要执行,以驯服和同时提升管弦乐团。

来源:https://it.wikipedia.org/wiki/Giuseppe_Lanzetta#/media/File:UMB_5945.JPGLicense: https://creativecommons.org/licenses/by-sa/3.0/deed.en

编排者的任务

现在我们有的不是音乐家,而是容器,不同的乐器,而是对容器主机运行的不同要求。音乐以不同的速度演奏,我们有以特定方式相互通信的容器,并且需要扩展和缩减。在这方面,容器编排者与管弦乐团的指挥有着非常相似的角色。它确保集群中的容器和其他资源和谐地相互配合。

协调所需的状态

在使用编排器时,您以声明方式告诉它如何运行特定的应用程序或应用程序服务。我们在《Docker Compose》的[第十一章]中学到了声明式与命令式的含义。描述我们想要运行的应用程序服务的声明方式包括诸如要使用哪个容器镜像、要运行多少个此服务的实例、要打开哪些端口等元素。我们称这些应用服务属性的声明为“期望状态”。

因此,当我们现在首次告诉编排器根据声明创建这样一个新的应用服务时,编排器会确保在集群中安排尽可能多的容器。如果容器镜像尚未在集群的目标节点上可用,调度程序会确保首先从镜像注册表中下载它们。接下来,容器将以所有设置启动,例如要附加到的网络或要公开的端口。编排器会尽其所能确保将集群与声明的状态完全匹配。

一旦我们的服务按要求启动并运行,也就是说,它以期望的状态运行,那么编排器会继续监视它。每当编排器发现服务的实际状态与期望状态之间存在差异时,它会再次尽力调解期望状态。

应用程序服务的实际状态与期望状态之间可能存在什么差异呢?比如说,服务的一个副本,也就是一个容器,由于某种原因崩溃了,编排器会发现实际状态与期望状态之间的差异在于副本的数量:缺少一个副本。编排器会立即将一个新实例调度到另一个集群节点,以替换崩溃的实例。另一个差异可能是应用程序服务的实例数量过多,如果服务已经缩减。在这种情况下,编排器将随机关闭所需数量的实例,以实现实际实例和期望实例数量之间的平衡。另一个差异可能是编排器发现应用程序服务的一个实例运行了错误(可能是旧)版本的底层容器映像。到现在为止,你应该明白了吧?

因此,我们不需要主动监视集群中运行的应用程序服务,并纠正与期望状态的任何偏差,而是将这一繁琐的任务委托给编排器。只要我们使用声明性而不是命令式的方式描述应用程序服务的期望状态,这种方法就非常有效。

复制和全局服务

在由编排器管理的集群中,我们可能想要运行两种完全不同类型的服务。它们是复制全局服务。复制服务是指需要在特定数量的实例中运行的服务,比如说 10 个。而全局服务则是要求集群中每个工作节点上都运行一个实例的服务。我在这里使用了“工作节点”这个术语。在由编排器管理的集群中,通常有两种类型的节点,即管理节点工作节点。管理节点通常由编排器专门用于管理集群,不运行任何其他工作负载。而工作节点则运行实际的应用程序。

因此,编排器确保对于全局服务,无论有多少个工作节点,它都在每个工作节点上运行一个实例。我们不需要关心实例的数量,只需要确保在每个节点上都保证运行服务的单个实例。

再次,我们可以完全依赖编排器来处理这个问题。在复制的服务中,我们总是能够找到确切所需数量的实例,而对于全局服务,我们可以确保在每个工作节点上始终运行服务的一个实例。编排器将尽其所能保证这种期望状态。

在 Kubernetes 中,全局服务也被称为DaemonSet

服务发现

当我们以声明方式描述应用服务时,我们永远不应该告诉编排器服务的不同实例必须在哪些集群节点上运行。我们让编排器决定哪些节点最适合这项任务。

当然,从技术上讲,指示编排器使用非常确定性的放置规则是可能的,但这将是一种反模式,不建议在非常特殊的边缘情况之外使用。

因此,如果我们现在假设编排引擎完全自由地决定放置应用服务的各个实例的位置,而且实例可能会崩溃并由编排器重新安排到不同的节点,那么我们会意识到,我们无法追踪每个实例在任何给定时间运行在哪里是一项徒劳的任务。更好的是,我们甚至不应该尝试知道这一点,因为这并不重要。

好吧,你可能会说,但如果我有两个服务,A 和 B,服务 A 依赖于服务 B;服务 A 的任何给定实例都应该知道在哪里可以找到服务 B 的实例吗?

在这里,我必须大声明确地说——不,不应该。在高度分布式和可扩展的应用程序中,这种知识是不可取的。相反,我们应该依赖编排器为我们提供所需的信息,以便访问我们依赖的其他服务实例。这有点像在电话的旧时代,当我们不能直接打电话给朋友,而必须打电话给电话公司的中央办公室,那里的一些操作员会将我们路由到正确的目的地。在我们的情况下,编排器扮演操作员的角色,将来自服务 A 实例的请求路由到可用的服务 B 实例。整个过程被称为服务发现

路由

到目前为止,我们已经了解到在分布式应用中,有许多相互作用的服务。当服务 A 与服务 B 交互时,它是通过数据包的交换来实现的。这些数据包需要以某种方式从服务 A 传输到服务 B。这个从源到目的地传输数据包的过程也被称为路由。作为应用的作者或操作者,我们期望编排器来接管这个路由任务。正如我们将在后面的章节中看到的,路由可以发生在不同的层面。就像在现实生活中一样。假设你在一家大公司的办公楼里工作。现在,你有一份需要转发给公司另一名员工的文件。内部邮件服务将从你的发件箱中取出文件,并将其送到同一建筑物内的邮局。如果目标人员在同一建筑物内工作,文件可以直接转发给该人员。另一方面,如果该人员在同一街区的另一栋建筑物内工作,文件将被转发到目标建筑物的邮局,然后通过内部邮件服务分发给接收者。第三,如果文件的目标是公司位于不同城市甚至不同国家的另一分支机构的员工,那么文件将被转发给 UPS 等外部邮政服务,后者将把它运送到目标地点,然后再次由内部邮件服务接管并将其送达收件人。

当在容器中运行的应用服务之间路由数据包时,类似的事情会发生。源容器和目标容器可以位于同一集群节点上,这对应于两名员工在同一建筑物内工作的情况。目标容器可以在不同的集群节点上运行,这对应于两名员工在同一街区的不同建筑物内工作的情况。最后,第三种情况是当数据包来自集群外部并且必须路由到集群内部运行的目标容器时。

编排器必须处理所有这些情况,以及更多。

负载均衡

在高可用的分布式应用中,所有组件都必须是冗余的。这意味着每个应用服务都必须以多个实例运行,以便如果一个实例失败,整个服务仍然可用。

为了确保一个服务的所有实例实际上都在工作,而不是闲置,您必须确保对服务的请求均匀分布到所有实例。这种在服务实例之间分配工作负载的过程称为负载均衡。存在各种算法来分配工作负载。通常,负载均衡器使用所谓的轮询算法,确保工作负载使用循环算法均匀分布到实例上。

再次,我们期望编排器处理从一个服务到另一个服务的负载均衡请求,或者从外部来源到内部服务的请求。

扩展

当在由编排器管理的集群中运行我们的容器化分布式应用程序时,我们还希望有一种简单的方式来处理预期或意外的工作负载增加。为了处理增加的工作负载,我们通常会安排正在经历增加负载的服务的额外实例。然后负载均衡器将自动配置为在更多可用的目标实例之间分发工作负载。

但在现实场景中,工作负载会随时间变化而变化。如果我们看一个像亚马逊这样的购物网站,它在晚上高峰时段可能会有很高的负载,当每个人都在家里网上购物;在特殊的日子,比如黑色星期五,它可能会经历极端的负载;而在早晨可能会经历很少的流量。因此,服务不仅需要能够扩展,还需要在工作负载减少时能够缩减。

我们还期望编排器在扩展时以有意义的方式分发服务的实例。将所有服务实例安排在同一集群节点上是不明智的,因为如果该节点宕机,整个服务就会宕机。编排器的调度程序负责容器的放置,还需要考虑不将所有实例放置在同一台计算机机架上,因为如果机架的电源供应失败,整个服务将受到影响。此外,关键服务的服务实例甚至应该分布在数据中心,以避免中断。所有这些决定,以及许多其他决定,都是编排器的责任。

在云中,通常使用“可用区”这个术语,而不是计算机机架。

自愈

如今,编排器非常复杂,可以为我们做很多事情来维护一个健康的系统。编排器监视集群中运行的所有容器,并自动用新实例替换崩溃或无响应的容器。编排器监视集群节点的健康状况,并在节点变得不健康或宕机时将其从调度循环中移除。原本位于这些节点上的工作负载会自动重新调度到其他可用节点上。

所有这些活动,编排器监视当前状态并自动修复损坏或协调期望状态,导致了所谓的自愈系统。在大多数情况下,我们不需要积极参与和修复损害。编排器会自动为我们完成这些工作。

然而,有一些情况编排器无法在没有我们帮助的情况下处理。想象一种情况,我们有一个运行在容器中的服务实例。容器正在运行,并且从外部看起来非常健康。但是,容器内部运行的应用程序处于不健康状态。应用程序没有崩溃,只是不能再像最初设计的那样工作了。编排器怎么可能在没有我们提示的情况下知道这一点呢?它不可能!处于不健康或无效状态对每个应用服务来说意味着完全不同。换句话说,健康状态是与服务相关的。只有服务的作者或其操作者知道在服务的上下文中健康意味着什么。

现在,编排器定义了应用服务可以与其通信的接口或探针。存在两种基本类型的探针:

  • 服务可以告诉编排器它的健康状态

  • 服务可以告诉编排器它已经准备好或者暂时不可用

服务如何确定前面提到的任一答案完全取决于服务本身。编排器只定义了它将如何询问,例如通过HTTP GET请求,或者它期望的答案类型,例如OKNOT OK

如果我们的服务实现了逻辑来回答前面提到的健康或可用性问题,那么我们就拥有了一个真正的自愈系统,因为编排器可以终止不健康的服务实例并用新的健康实例替换它们,还可以将暂时不可用的服务实例从负载均衡器的轮询中移除。

零停机部署

如今,很难再为需要更新的关键任务应用程序辩解完全停机。这不仅意味着错失机会,还可能导致公司声誉受损。使用该应用程序的客户不再愿意接受这样的不便,并会迅速离开。此外,我们的发布周期变得越来越短。在过去,我们每年可能会有一两次新版本发布,但如今,许多公司每周甚至每天多次更新他们的应用程序。

解决这个问题的方法是提出一个零停机应用程序更新策略。编排器需要能够逐批更新单个应用程序服务。这也被称为滚动更新。在任何给定时间,只有给定服务的总实例数中的一个或几个会被关闭,并被该服务的新版本替换。只有新实例是可操作的,并且不会产生任何意外错误或显示任何不当行为,才会更新下一批实例。这一过程重复进行,直到所有实例都被替换为它们的新版本。如果由于某种原因更新失败,那么我们期望编排器自动将更新的实例回滚到它们的先前版本。

其他可能的零停机部署包括蓝绿部署和金丝雀发布。在这两种情况下,服务的新版本与当前活动版本并行安装。但最初,新版本只能在内部访问。运营人员可以对新版本运行烟雾测试,当新版本似乎运行良好时,就可以在蓝绿部署的情况下,将路由器从当前蓝色版本切换到新的绿色版本。一段时间内,新的绿色版本的服务将受到密切监控,如果一切正常,旧的蓝色版本就可以被废弃。另一方面,如果新的绿色版本不如预期那样工作,那么只需将路由器设置回旧的蓝色版本,就可以实现完全回滚。

在金丝雀发布的情况下,路由器被配置为将整体流量的一小部分,比如 1%,引导到服务的新版本,而仍然有 99%的流量通过旧版本路由。新版本的行为受到密切监视,并与旧版本的行为进行比较。如果一切正常,那么通过新服务引导的流量百分比会略微增加。这个过程会重复,直到 100%的流量通过新服务路由。如果新服务运行一段时间并且一切正常,那么旧服务可以被停用。

大多数编排器至少支持开箱即用的滚动更新类型的零停机部署。蓝绿部署和金丝雀发布通常很容易实现。

亲和性和位置感知

有时,某些应用服务需要节点上专用硬件的可用性。例如,I/O 密集型服务需要具有附加高性能固态硬盘SSD)的集群节点,或者用于机器学习等用途的某些服务需要加速处理单元APU)。编排器允许我们为每个应用服务定义节点亲和性。然后,编排器将确保其调度程序仅在满足所需条件的集群节点上调度容器。

避免将亲和力定义为特定节点;这将引入单点故障,从而损害高可用性。始终将多个集群节点定义为应用服务的目标。

一些编排引擎还支持所谓的位置感知地理感知。这意味着您可以要求编排器将服务的实例均匀分布在不同位置的一组位置上。例如,您可以定义一个数据中心标签,其可能的值为西,并将该标签应用于具有对应于各自节点所在地理区域的值的所有集群节点。然后,您指示编排器使用此标签来进行某个应用服务的地理感知。在这种情况下,如果您请求该服务的九个副本,那么编排器将确保将三个实例部署到每个数据中心的节点中——西、中和东。

地理意识甚至可以按层次定义;例如,您可以将数据中心作为最高级别的判别器,然后是可用区。

地理意识或位置意识用于减少由电源供应故障或数据中心故障导致的中断的概率。如果应用实例分布在节点、可用区甚至数据中心之间,那么一切同时崩溃的可能性极小。总会有一个地区是可用的。

安全

如今,IT 安全是一个非常热门的话题。网络战争达到了历史最高点。大多数知名公司都曾是黑客攻击的受害者,造成了非常昂贵的后果。每个首席信息官(CIO)或首席技术官(CTO)最糟糕的噩梦之一就是早上醒来听到自己的公司成为黑客攻击的受害者,并且敏感信息被窃取或泄露的消息。

为了对抗大多数安全威胁,我们需要建立一个安全的软件供应链,并在深度上强制执行安全防御。让我们来看看您可以从企业级编排器中期望的一些任务。

安全通信和加密节点身份

首先,我们希望确保由编排器管理的集群是安全的。只有受信任的节点才能加入集群。加入集群的每个节点都会获得一个加密的节点身份,并且节点之间的所有通信必须加密。为此,节点可以使用相互传输层安全(MTLS)。为了相互认证集群的节点,使用证书。这些证书会定期自动轮换,或者根据请求进行轮换,以保护系统以防证书泄露。

集群中发生的通信可以分为三种类型。您可以谈论通信平面-管理、控制和数据平面:

  • 管理平面由集群管理器或主节点使用,例如,调度服务实例,执行健康检查,或创建和修改集群中的任何其他资源,如数据卷、密钥或网络。

  • 控制平面用于在集群的所有节点之间交换重要的状态信息。例如,这种信息用于更新用于路由目的的集群上的本地 IP 表。

  • 数据平面是实际应用服务相互通信和交换数据的地方。

通常,编排器主要关心保护管理和控制平面。保护数据平面留给用户,尽管编排器可能会促进这项任务。

安全网络和网络策略

在运行应用服务时,并非每个服务都需要与集群中的其他服务通信。因此,我们希望能够将服务相互隔离,并且只在绝对需要相互通信的情况下在相同的网络沙盒中运行这些服务。所有其他服务和来自集群外部的所有网络流量都不应该有可能访问被隔离的服务。

至少有两种网络沙盒化的方式。我们可以使用软件定义网络(SDN)来分组应用服务,或者我们可以使用一个扁平网络,并使用网络策略来控制谁有权访问特定服务或服务组。

基于角色的访问控制(RBAC)

编排器必须履行的最重要任务之一(除了安全性)是为集群及其资源提供基于角色的访问。RBAC 定义了系统的主体、用户或用户组,组织成团队等如何访问和操作系统。它确保未经授权的人员无法对系统造成任何伤害,也无法看到他们不应该知道或看到的系统中的任何可用资源。

典型的企业可能有开发、QA 和生产等用户组,每个组都可以有一个或多个用户与之关联。开发人员约翰·多伊是开发组的成员,因此可以访问专门为开发团队提供的资源,但他不能访问例如生产团队的资源,其中安·哈伯是成员。反过来,她也不能干扰开发团队的资源。

实施 RBAC 的一种方式是通过定义授权。授权是主体、角色和资源集合之间的关联。在这里,角色由对资源的一组访问权限组成。这些权限可以是创建、停止、删除、列出或查看容器;部署新的应用服务;列出集群节点或查看集群节点的详细信息;以及许多其他权限。

资源集合是集群中逻辑相关的资源的组合,例如应用服务、秘密、数据卷或容器。

秘密

在我们的日常生活中,我们有很多秘密。秘密是不应该公开知道的信息,比如你用来访问在线银行账户的用户名和密码组合,或者你手机或健身房储物柜的密码。

在编写软件时,我们经常也需要使用秘密。例如,我们需要一个证书来验证我们的应用服务与我们想要访问的外部服务进行身份验证,或者我们需要一个令牌来在访问其他 API 时验证和授权我们的服务。过去,为了方便起见,开发人员通常会将这些值硬编码,或者将它们以明文形式放在一些外部配置文件中。在那里,这些非常敏感的信息对广大观众都是可访问的,而实际上,他们本不应该有机会看到这些秘密。

幸运的是,这些天,编排器提供了所谓的秘密,以高度安全的方式处理这些敏感信息。秘密可以由授权或信任的人员创建。这些秘密的值然后被加密并存储在高可用的集群状态数据库中。由于这些秘密是加密的,所以它们现在在静态时是安全的。一旦一个被授权的应用服务请求一个秘密,该秘密只会被转发到实际运行该特定服务实例的集群节点,并且秘密值永远不会存储在节点上,而是挂载到容器中的tmpfs基于 RAM 的卷中。只有在相应的容器内,秘密值才以明文形式可用。

我们已经提到,秘密在静态时是安全的。一旦它们被服务、集群管理器或主节点请求,主节点会解密秘密并将其通过网络发送到目标节点。那么,秘密在传输过程中安全吗?嗯,我们之前了解到集群节点使用 MTLS 进行通信,因此即使秘密以明文传输,也仍然是安全的,因为数据包将被 MTLS 加密。因此,秘密在静态和传输过程中都是安全的。只有被授权使用秘密的服务才能访问这些秘密值。

内容信任

为了增加安全性,我们希望确保只有受信任的图像在我们的生产集群中运行。一些编排器允许我们配置集群,以便它只能运行经过签名的图像。内容信任和签署图像的目的在于确保图像的作者是我们所期望的人,即我们信任的开发人员,甚至更好的是我们信任的 CI 服务器。此外,通过内容信任,我们希望保证我们获取的图像是新鲜的,而不是旧的,可能存在漏洞的图像。最后,我们希望确保图像在传输过程中不会被恶意黑客篡改。后者通常被称为中间人MITM)攻击。

通过在源头签署图像,并在目标处验证签名,我们可以保证我们想要运行的图像没有被篡改。

逆向正常运行时间

我想在安全性的背景下讨论的最后一点是逆向正常运行时间。这是什么意思呢?想象一下,你已经配置和保护了一个生产集群。在这个集群上,你正在运行公司的一些关键应用程序。现在,一个黑客设法在你的软件堆栈中找到了一个安全漏洞,并且已经获得了对你的集群节点的 root 访问权限。这本身已经够糟糕了,但更糟糕的是,这个黑客现在可以掩盖他们在这个节点上的存在,毕竟他们已经有了 root 访问权限,然后将其用作攻击你的集群中其他节点的基地。

在 Linux 或任何 Unix 类型的操作系统中,root 访问权限意味着你可以在这个系统上做任何事情。这是某人可以拥有的最高级别的访问权限。在 Windows 中,相当于这个角色的是管理员。

但是,如果我们利用容器是短暂的,集群节点通常可以快速配置,通常在几分钟内完全自动化的情况下呢?我们只需在一定的正常运行时间后关闭每个集群节点,比如说 1 天。编排器被指示排空节点,然后将其从集群中排除。一旦节点离开集群,它就会被拆除并被一个新配置的节点所取代。

这样,黑客就失去了他们的基地,问题也被消除了。尽管这个概念目前还没有广泛应用,但对我来说,这似乎是向增加安全性迈出的一大步,而且据我与在这个领域工作的工程师讨论,实施起来并不困难。

内省

到目前为止,我们已经讨论了许多由编排器负责的任务,它可以完全自主地执行。但是,人类操作员也需要能够查看和分析集群上当前运行的内容,以及个别应用程序的状态或健康状况。为了做到这一点,我们需要进行内省。编排器需要以易于消化和理解的方式呈现关键信息。

编排器应该从所有集群节点收集系统指标,并使其对操作员可访问。指标包括 CPU、内存和磁盘使用情况、网络带宽消耗等。这些信息应该以逐个节点的方式轻松获取,以及以汇总形式获取。

我们还希望编排器能够让我们访问由服务实例或容器产生的日志。此外,如果我们有正确的授权,编排器还应该为我们提供对每个容器的exec访问权限。有了对容器的exec访问权限,您就可以调试行为不端的容器。

在高度分布式的应用程序中,每个对应用程序的请求都要经过多个服务,直到完全处理,跟踪请求是一项非常重要的任务。理想情况下,编排器支持我们实施跟踪策略,或者给我们一些好的遵循指南。

最后,人类操作员在使用所有收集到的指标、日志和跟踪信息的图形表示时,可以最好地监视系统。在这里,我们谈论的是仪表板。每个体面的编排器都应该提供至少一些基本的仪表板,以图形方式表示最关键的系统参数。

然而,人类操作员并不是唯一关心内省的人。我们还需要能够将外部系统连接到编排器,以便消费这些信息。需要提供一个 API,通过该 API,外部系统可以访问集群状态、指标和日志等数据,并利用这些信息做出自动决策,例如创建警报或电话警报、发送电子邮件,或者在系统超过某些阈值时触发警报。

流行编排器的概述

在撰写本文时,有许多编排引擎在使用中,但有一些明显的赢家。第一名显然是由 Kubernetes 占据,它统治着。遥遥领先的第二名是 Docker 自己的 SwarmKit,其次是其他一些,如 Apache Mesos,AWS 弹性容器服务(ECS),或 Microsoft Azure 容器服务(ACS)。

Kubernetes

Kubernetes 最初由 Google 设计,后来捐赠给了云原生计算基金会(CNCF)。Kubernetes 是模仿 Google 专有的 Borg 系统而设计的,该系统多年来一直在超大规模上运行容器。Kubernetes 是 Google 重新设计的尝试,完全重新开始并设计一个系统,其中包含了与 Borg 学到的所有教训。

与专有技术 Borg 相反,Kubernetes 在早期就开源了。这是 Google 的一个非常明智的选择,因为它吸引了大量来自公司外部的贡献者,仅仅在短短几年内,Kubernetes 周围的生态系统更加庞大。你可以说 Kubernetes 是容器编排领域社区的宠儿。没有其他编排器能够产生如此多的炒作,并吸引如此多愿意以有意义的方式为项目的成功做出贡献的人才,无论是作为贡献者还是早期采用者。

在这方面,Kubernetes 在容器编排领域对我来说非常像 Linux 在服务器操作系统领域所变成的。Linux 已经成为服务器操作系统的事实标准。所有相关公司,如微软、IBM、亚马逊、红帽,甚至 Docker,都已经接受了 Kubernetes。

有一件事是无法否认的:Kubernetes 从一开始就被设计用于大规模扩展。毕竟,它是以 Google Borg 为目标而设计的。

可以提出反对 Kubernetes 的一个负面方面是,至少在撰写本文时,它仍然很复杂,设置和管理起来。对于新手来说,这是一个重大障碍。第一步是艰难的,但一旦你使用这个编排器一段时间,一切就会变得清晰。整体设计经过深思熟虑,执行得非常好。

在 Kubernetes 的 1.10 版本中,与其他编排器(如 Docker Swarm)相比,最初的缺点大多已经消除。例如,安全性和保密性现在不仅仅是一个事后的考虑,而是系统的一个组成部分。

新功能以惊人的速度实施。新版本大约每 3 个月发布一次,更确切地说,大约每 100 天发布一次。大多数新功能都是需求驱动的,也就是说,使用 Kubernetes 来编排其关键任务应用程序的公司可以提出他们的需求。这使得 Kubernetes 适合企业使用。认为这个编排器只适用于初创企业而不适用于风险规避型企业是错误的。相反的情况是。我基于什么来做出这个断言?嗯,我的断言是有根据的,因为像微软、Docker 和红帽这样的公司,他们的客户大多是大型企业,已经完全接受了 Kubernetes,并为其提供企业级支持,如果它被用于并集成到他们的企业产品中。

Kubernetes 支持 Linux 和 Windows 容器。

Docker Swarm

众所周知,Docker 推广和商品化了软件容器。Docker 并没有发明容器,但是标准化了它们,并使其广泛可用,其中包括提供免费镜像注册表—Docker Hub。最初,Docker 主要关注开发人员和开发生命周期。然而,开始使用和喜爱容器的公司很快也希望不仅在开发或测试新应用程序时使用它们,而且在生产中运行这些应用程序时也使用它们。

最初,Docker 在这个领域没有什么可提供的,所以其他公司跳进这个真空并为用户提供帮助。但是没过多久,Docker 意识到有一个对于一个简单而强大的编排器的巨大需求。Docker 的第一次尝试是一个名为经典 Swarm 的产品。它是一个独立的产品,使用户能够创建一个 Docker 主机集群,可以用于以高可用和自愈的方式运行和扩展其容器化应用程序。

然而,经典 Docker Swarm 的设置很困难。涉及许多复杂的手动步骤。客户喜欢这个产品,但在处理其复杂性时遇到了困难。因此,Docker 决定可以做得更好。它回到了起点,并提出了 SwarmKit。SwarmKit 在 2016 年的 DockerCon 大会上在西雅图推出,并成为最新版本的 Docker 引擎的一个重要组成部分。是的,你没听错;SwarmKit 是,直到今天仍然是 Docker 引擎的一个重要组成部分。因此,如果你安装了 Docker 主机,你自动就有了 SwarmKit。

SwarmKit 的设计理念是简单和安全。其口号是,几乎可以轻松地设置一个 Swarm,并且 Swarm 在开箱即用时必须具有高度安全性。Docker Swarm 的运行基于最低权限的假设。

在集群中的第一个节点上使用docker swarm init开始安装完整、高可用的 Docker Swarm,这个节点成为所谓的领导者,然后在所有其他节点上使用docker swarm join <join-token>join-token是在初始化期间由领导者生成的。整个过程在具有多达 10 个节点的集群上不到 5 分钟。如果自动化,时间会更短。

正如我之前提到的,安全性是 Docker 设计和开发 SwarmKit 时的首要考虑因素。容器通过依赖 Linux 内核命名空间和 cgroups、Linux 系统调用白名单(seccomp)以及对 Linux 功能和 Linux 安全模块(LSM)的支持来提供安全性。现在,在此基础上,SwarmKit 还增加了 MTLS 和在静态和传输中加密的秘密。此外,Swarm 定义了所谓的容器网络模型(CNM),允许为在集群上运行的应用服务提供沙盒环境的 SDN。

Docker SwarmKit 支持 Linux 和 Windows 容器。

Apache Mesos 和 Marathon

Apache Mesos 是一个开源项目,最初旨在使服务器或节点集群从外部看起来像一个单一的大服务器。Mesos 是一种使计算机集群管理变得简单的软件。Mesos 的用户不必关心单个服务器,只需假设他们拥有一个庞大的资源池,这对应于集群中所有节点的所有资源的总和。

从 IT 术语上讲,Mesos 已经相当古老,至少与其他编排器相比是这样。它首次公开亮相是在 2009 年,但当时当然并不是为了运行容器,因为当时甚至还没有 Docker。与 Docker 对容器的处理方式类似,Mesos 使用 Linux cgroups 来隔离 CPU、内存或磁盘 I/O 等资源,以便为单个应用程序或服务提供资源隔离。

Mesos 实际上是其他建立在其之上的有趣服务的基础基础设施。从容器的角度来看,Marathon 非常重要。Marathon 是一个运行在 Mesos 之上的容器编排器,能够扩展到数千个节点。

Marathon 支持多个容器运行时,如 Docker 或其自己的 Mesos 容器。它不仅支持无状态的应用服务,还支持有状态的应用服务,例如像 PostgreSQL 或 MongoDB 这样的数据库。与 Kubernetes 和 Docker SwarmKit 类似,它支持本章前面描述的许多功能,例如高可用性、健康检查、服务发现、负载均衡和位置感知等等。

尽管 Mesos 和在一定程度上 Marathon 是相当成熟的项目,但它们的影响范围相对有限。它似乎在大数据领域最受欢迎,即运行诸如 Spark 或 Hadoop 之类的数据处理服务。

亚马逊 ECS

如果您正在寻找一个简单的编排器,并且已经深度融入了 AWS 生态系统,那么亚马逊的 ECS 可能是您的正确选择。但是,有一点非常重要的限制需要指出:如果您选择了这个容器编排器,那么您就将自己锁定在 AWS 中。您将无法轻松地将在 ECS 上运行的应用程序迁移到另一个平台或云上。

亚马逊将其 ECS 服务宣传为一个高度可扩展、快速的容器管理服务,可以轻松在集群上运行、停止和管理 Docker 容器。除了运行容器,ECS 还可以直接访问容器内运行的应用服务的许多其他 AWS 服务。这种与许多热门 AWS 服务的紧密无缝集成,使 ECS 对于寻求在强大且高度可扩展的环境中轻松运行其容器化应用的用户非常具有吸引力。亚马逊还提供自己的私有镜像注册表。

使用 AWS ECS,您可以使用 Fargate 来完全管理底层基础设施,让您专注于部署容器化应用程序,而不必关心如何创建和管理节点集群。ECS 支持 Linux 和 Windows 容器。

总之,ECS 使用简单,高度可扩展,并与其他热门的 AWS 服务很好地集成在一起;但它不像 Kubernetes 或 Docker SwarmKit 那样强大,并且仅在 Amazon AWS 上可用。

微软 ACS

与我们对 ECS 所说的类似,我们也可以对微软的 ACS 提出同样的要求。如果您已经在 Azure 生态系统中投入了大量资金,那么 ACS 是一个有意义的容器编排服务。我应该说与我为 Amazon ECS 指出的相同:如果您选择 ACS,那么您就会将自己锁定在微软的产品中。将容器化应用程序从 ACS 移动到其他平台或云将不容易。

ACS 是微软的容器服务,支持多个编排器,如 Kubernetes、Docker Swarm 和 Mesos DC/OS。随着 Kubernetes 变得越来越受欢迎,微软的重点显然已经转移到了该编排器上。微软甚至重新将其服务命名为 Azure Kubernetes Service(AKS),以便将重点放在 Kubernetes 上。

AKS 为您管理在 Azure 中托管的 Kubernetes 或 Docker Swarm 或 DC/OS 环境,这样您就可以专注于要部署的应用程序,而不必关心配置基础设施。微软自己声称如下:

“AKS 使得快速轻松地部署和管理容器化应用程序成为可能,而无需容器编排专业知识。它还通过根据需求提供、升级和扩展资源来消除持续运营和维护的负担,而不会使您的应用程序下线。”

总结

本章阐明了为什么首先需要编排器,以及它们在概念上是如何工作的。它指出了在撰写时最突出的编排器,并讨论了各种编排器之间的主要共同点和区别。

下一章将介绍 Docker 的本地编排器 SwarmKit。它将详细阐述 SwarmKit 用于在集群(本地或云中)部署和运行分布式、有弹性、健壮和高可用应用所使用的所有概念和对象。

问题

回答以下问题以评估您的学习进度:

  1. 我们为什么需要编排器?提供两到三个理由。

  2. 列出编排器的三到四个典型职责。

  3. 请至少列出两个容器编排器,以及它们背后的主要赞助商。

进一步阅读

以下链接提供了有关编排相关主题的更深入的见解:

第十三章:介绍 Docker Swarm

在上一章中,我们介绍了编排器。就像管弦乐队中的指挥一样,编排器确保我们所有的容器化应用服务和谐地共同演奏,为共同的目标做出贡献。这样的编排器有很多责任,我们详细讨论了这些责任。最后,我们简要概述了市场上最重要的容器编排器。

本章介绍了 Docker 的本地编排器 SwarmKit。它详细阐述了 SwarmKit 用于在本地或云上部署和运行分布式、有弹性、健壮和高可用应用的所有概念和对象。本章还介绍了 SwarmKit 如何通过使用软件定义网络(SDN)来隔离容器来确保安全应用。此外,本章演示了如何在云中安装一个高可用的 Docker Swarm。它介绍了路由网格,提供了第四层路由和负载平衡。最后,它演示了如何在群集上部署由多个服务组成的第一个应用程序。

本章我们将讨论以下主题:

  • Docker Swarm 架构

  • Swarm 节点

  • 堆栈、服务和任务

  • 多主机网络

  • 创建一个 Docker Swarm

  • 部署第一个应用程序

  • Swarm 路由网格

完成本章后,您将能够做到以下事项:

  • 在白板上勾画一个高可用的 Docker Swarm 的基本部分

  • 用两三个简单的句子向感兴趣的门外汉解释(群)服务是什么

  • 在 AWS、Azure 或 GCP 中创建一个高可用的 Docker Swarm,包括三个管理节点和两个工作节点

  • 成功在 Docker Swarm 上部署一个复制的服务,如 Nginx

  • 扩展正在运行的 Docker Swarm 服务

  • 检索复制的 Docker Swarm 服务的聚合日志

  • 为一个由至少两个相互作用的服务组成的示例应用程序编写一个简单的堆栈文件

  • 将一个堆栈部署到 Docker Swarm 中

Docker Swarm 架构

从 30,000 英尺的视角来看,Docker Swarm 的架构由两个主要部分组成——一个由奇数个管理节点组成的 raft 一致性组,以及一个与控制平面上的八卦网络相互通信的工作节点组。以下图表说明了这种架构:

Docker Swarm 的高级架构

管理节点管理 Swarm,而工作节点执行部署到 Swarm 中的应用程序。每个管理节点在其本地 Raft 存储中都有完整的 Swarm 状态副本。管理节点之间同步通信,它们的 Raft 存储始终保持同步。

另一方面,为了可伸缩性的原因,工作节点是异步通信的。在一个 Swarm 中可能有数百甚至数千个工作节点。现在我们已经对 Docker Swarm 有了一个高层次的概述,让我们更详细地描述 Docker Swarm 的所有单个元素。

Swarm 节点

Swarm 是节点的集合。我们可以将节点分类为物理计算机或虚拟机(VM)。如今,物理计算机通常被称为“裸金属”。人们说“我们在裸金属上运行”以区别于在虚拟机上运行。

当我们在这样的节点上安装 Docker 时,我们称这个节点为 Docker 主机。以下图表更好地说明了节点和 Docker 主机是什么:

裸金属和虚拟机类型的 Docker Swarm 节点

要成为 Docker Swarm 的成员,节点必须是 Docker 主机。Docker Swarm 中的节点可以担任两种角色之一。它可以是管理节点,也可以是工作节点。管理节点做其名字所示的事情;它们管理 Swarm。而工作节点则执行应用程序工作负载。

从技术上讲,管理节点也可以是工作节点,因此运行应用程序工作负载,尽管这并不被推荐,特别是如果 Swarm 是运行关键任务应用程序的生产系统。

Swarm 管理节点

每个 Docker Swarm 至少需要包括一个管理节点。出于高可用性的原因,我们应该在 Swarm 中有多个管理节点。这对于生产环境或类似生产环境尤为重要。如果我们有多个管理节点,那么这些节点将使用 Raft 一致性协议一起工作。Raft 一致性协议是一个标准协议,当多个实体需要共同工作并且始终需要就下一步执行的活动达成一致意见时,通常会使用该协议。

为了良好运行,Raft 共识协议要求在所谓的共识组中有奇数个成员。因此,我们应该始终有 1、3、5、7 等管理者节点。在这样的共识组中,总是有一个领导者。在 Docker Swarm 的情况下,最初启动 Swarm 的第一个节点成为领导者。如果领导者离开,剩下的管理者节点将选举新的领导者。共识组中的其他节点称为跟随者。

现在,让我们假设出于维护原因关闭当前的领导节点。剩下的管理者节点将选举新的领导者。当之前的领导节点恢复在线时,它将成为跟随者。新的领导者仍然是领导者。

共识组的所有成员之间进行同步通信。每当共识组需要做出决策时,领导者会要求所有跟随者同意。如果大多数管理者节点给出积极答复,那么领导者执行任务。这意味着如果我们有三个管理者节点,那么至少有一个跟随者必须同意领导者。如果我们有五个管理者节点,那么至少有两个跟随者必须同意。

由于所有管理者跟随者节点都必须与领导节点同步通信,以在集群中做出决策,所以随着形成共识组的管理者节点数量增加,决策过程变得越来越慢。Docker 的建议是在开发、演示或测试环境中使用一个管理者。在小到中等规模的 Swarm 中使用三个管理者节点,在大型到超大型的 Swarm 中使用五个管理者。在 Swarm 中使用超过五个管理者几乎没有理由。

管理者节点不仅负责管理 Swarm,还负责维护 Swarm 的状态。我们指的是什么?当我们谈论 Swarm 的状态时,我们指的是关于它的所有信息,例如Swarm 中有多少节点每个节点的属性是什么,比如名称或 IP 地址。我们还指的是 Swarm 中哪个节点上运行了哪些容器等更多信息。另一方面,Swarm 状态中不包括由 Swarm 上容器中运行的应用服务产生的数据。这被称为应用数据,绝对不是由管理者节点管理的状态的一部分。

一个 Swarm 管理器共识组

所有 Swarm 状态都存储在每个manager节点上的高性能键值存储(kv-store)中。没错,每个manager节点都存储了整个 Swarm 状态的完整副本。这种冗余使 Swarm 具有高可用性。如果一个manager节点宕机,剩下的manager都有完整的状态可用。

如果一个新的manager加入共识组,那么它会与现有组成员同步 Swarm 状态,直到拥有完整的副本。在典型的 Swarm 中,这种复制通常非常快,但如果 Swarm 很大并且有许多应用程序在其中运行,可能需要一段时间。

Swarm 工人

正如我们之前提到的,Swarm 工作节点旨在托管和运行包含我们感兴趣在集群上运行的实际应用服务的容器。它们是 Swarm 的工作马。理论上,管理节点也可以是工作节点。但是,正如我们已经说过的,这在生产系统上是不推荐的。在生产系统上,我们应该让管理节点成为管理节点。

工作节点通过所谓的控制平面彼此交流。它们使用流言协议进行通信。这种通信是异步的,这意味着在任何给定时间,可能并非所有工作节点都完全同步。

现在,您可能会问——工作节点交换什么信息?主要是用于服务发现和路由的信息,即关于哪些容器正在哪些节点上运行等信息:

工作节点之间的通信

在上图中,您可以看到工人如何彼此交流。为了确保流言蜚语在大型 Swarm 中能够良好扩展,每个worker节点只与三个随机邻居同步自己的状态。对于熟悉大 O 符号的人来说,这意味着使用流言协议同步worker节点的规模为 O(0)。

Worker节点有点被动。除了运行由管理节点分配的工作负载之外,它们从不主动做任何事情。然而,worker确保以最佳能力运行这些工作负载。在本章后面,我们将更多地了解由管理节点分配给工作节点的工作负载。

堆栈、服务和任务

当使用 Docker Swarm 而不是单个 Docker 主机时,会有一种范式变化。我们不再谈论运行进程的单个容器,而是将其抽象为代表每个进程的一组副本的服务,并以这种方式变得高度可用。我们也不再谈论具有众所周知的名称和 IP 地址的单个 Docker 主机,我们现在将会提到部署服务的主机集群。我们不再关心单个主机或节点。我们不给它一个有意义的名称;对我们来说,每个节点都变成了一个数字。我们也不再关心个别容器以及它们被部署到哪里——我们只关心通过服务定义所需状态。我们可以尝试将其描述如下图所示:

容器部署到众所周知的服务器

与前面的图中将个别容器部署到众所周知的服务器不同,其中我们将web容器部署到具有 IP 地址52.120.12.1alpha服务器,将payments容器部署到具有 IP52.121.24.33beta服务器,我们转向了这种新的服务和 Swarm(或更一般地说,集群)的范式:

服务部署到 Swarm

在前面的图中,我们看到一个web服务和一个inventory服务都部署到了由许多节点组成的Swarm中。每个服务都有一定数量的副本:web有六个,inventory有五个。我们并不关心副本将在哪个节点上运行;我们只关心所请求的副本数量始终在Swarm调度器决定放置它们的任何节点上运行。

服务

Swarm 服务是一个抽象的东西。它是对我们想要在 Swarm 中运行的应用程序或应用程序服务的期望状态的描述。Swarm 服务就像一个描述,描述了以下内容:

  • 服务的名称

  • 用于创建容器的镜像

  • 要运行的副本数量

  • 服务的容器附加到的网络

  • 应该映射的端口

有了这个服务清单,Swarm 管理器确保所描述的期望状态始终得到调和,如果实际状态偏离了期望状态。因此,例如,如果服务的一个实例崩溃,那么 Swarm 管理器上的调度程序会在具有空闲资源的节点上调度这个特定服务的新实例,以便重新建立期望状态。

任务

我们已经了解到,服务对应于应用程序服务应始终处于的期望状态的描述。该描述的一部分是服务应该运行的副本数量。每个副本由一个任务表示。在这方面,Swarm 服务包含一组任务。在 Docker Swarm 上,任务是部署的原子单位。服务的每个任务由 Swarm 调度程序部署到工作节点。任务包含工作节点运行基于服务描述的镜像的所有必要信息。在任务和容器之间存在一对一的关系。容器是在工作节点上运行的实例,而任务是这个容器作为 Swarm 服务的一部分的描述。

堆栈

现在我们对 Swarm 服务和任务有了一个很好的了解,我们可以介绍堆栈。堆栈用于描述一组相关的 Swarm 服务,很可能是因为它们是同一应用程序的一部分。在这种意义上,我们也可以说堆栈描述了一个由我们想要在 Swarm 上运行的一到多个服务组成的应用程序。

通常,我们在一个文本文件中以 YAML 格式进行格式化描述堆栈,并使用与已知的 Docker Compose 文件相同的语法。这导致有时人们会说堆栈是由docker-compose文件描述的。更好的措辞应该是:堆栈是在使用类似于docker-compose文件的堆栈文件中描述的。

让我们尝试用下图来说明堆栈、服务和任务之间的关系,并将其与堆栈文件的典型内容联系起来:

显示堆栈、服务和任务之间关系的图表

在前面的图表中,我们可以看到右侧是一个样本Stack的声明性描述。Stack包括了三种服务,分别是webpaymentsinventory。我们还可以看到web服务使用example/web:1.0镜像,并且有四个副本。

在图表的左侧,我们可以看到Stack包含了提到的三种服务。每种服务又包含了一系列的Tasks,数量与副本一样多。在web服务的情况下,我们有一个包含四个Tasks的集合。每个Task包含了它将实例化容器的Image的名称,一旦Task被安排在 Swarm 节点上。

多主机网络

在第十章中,单主机网络,我们讨论了容器在单个 Docker 主机上的通信。现在,我们有一个由节点或 Docker 主机组成的 Swarm。位于不同节点上的容器需要能够相互通信。有许多技术可以帮助我们实现这个目标。Docker 选择为 Docker Swarm 实现了一个覆盖网络驱动程序。这个覆盖网络允许连接到同一覆盖网络的容器相互发现并自由通信。以下是覆盖网络的工作原理的示意图:

覆盖网络

我们有两个节点或 Docker 主机,IP 地址分别为172.10.0.15172.10.0.16。我们选择的 IP 地址的值并不重要;重要的是两个主机都有不同的 IP 地址,并且通过一个物理网络(网络电缆)连接,这个网络称为底层网络

在左侧的节点上有一个运行着 IP 地址为10.3.0.2的容器,右侧的节点上有另一个 IP 地址为10.3.0.5的容器。现在,前者的容器想要与后者通信。这怎么可能?在第十章中,单主机网络,我们看到了当两个容器位于同一节点上时,这是如何工作的——通过使用 Linux 桥接。但 Linux 桥接只能在本地操作,无法跨越节点。所以,我们需要另一种机制。Linux VXLAN 来解救。VXLAN 在容器出现之前就已经在 Linux 上可用。

当左侧容器发送数据包时,桥接意识到数据包的目标不在此主机上。现在,参与覆盖网络的每个节点都会得到一个所谓的VXLAN 隧道端点VTEP)对象,它拦截数据包(此时的数据包是 OSI 第 2 层数据包),用包含运行目标容器的主机的目标 IP 地址的头部包装它(这样它现在是 OSI 第 3 层数据包),并将其发送到VXLAN 隧道。隧道另一侧的VTEP解包数据包并将其转发到本地桥接,本地桥接再将其转发到目标容器。

覆盖驱动程序包含在 SwarmKit 中,在大多数情况下是 Docker Swarm 的推荐网络驱动程序。还有其他来自第三方的多节点网络驱动程序可作为插件安装到每个参与的 Docker 主机上。Docker 商店提供认证的网络插件。

创建一个 Docker Swarm

创建一个 Docker Swarm 几乎是微不足道的。如果你知道编排器是什么,那么它是如此容易,以至于似乎不真实。但事实是,Docker 在使 Swarm 简单而优雅的使用方面做得非常出色。与此同时,Docker Swarm 已被大型企业证明在使用中非常稳健和可扩展。

创建一个本地单节点 Swarm

所以,想象足够了,让我们演示一下我们如何创建一个 Swarm。在其最简单的形式中,一个完全功能的 Docker Swarm 只包括一个单节点。如果你正在使用 Docker for Mac 或 Windows,甚至是使用 Docker Toolbox,那么你的个人计算机或笔记本电脑就是这样一个节点。因此,我们可以从这里开始,演示 Swarm 的一些最重要的特性。

让我们初始化一个 Swarm。在命令行上,只需输入以下命令:

$ docker swarm init

在非常短的时间后,你应该看到类似以下截图的东西:

Docker Swarm init 命令的输出

我们的计算机现在是一个 Swarm 节点。它的角色是管理者,它是领导者(管理者中的领导者,这是有道理的,因为此时只有一个管理者)。虽然docker swarm init只花了很短的时间就完成了,但在那段时间里命令做了很多事情。其中一些如下:

  • 它创建了一个根证书颁发机构CA)。

  • 它创建了一个用于存储整个 Swarm 状态的键值存储。

现在,在前面的输出中,我们可以看到一个命令,可以用来加入我们刚刚创建的 Swarm 的其他节点。命令如下:

$ docker swarm join --token <join-token> <IP address>:2377

在这里,我们有以下内容:

  • 是 Swarm 领导者在初始化 Swarm 时生成的令牌。

  • <IP 地址>是领导者的 IP 地址。

尽管我们的集群仍然很简单,因为它只包含一个成员,但我们仍然可以要求 Docker CLI 列出 Swarm 的所有节点。这将类似于以下屏幕截图:

列出 Docker Swarm 的节点

在此输出中,我们首先看到赋予节点的 ID。跟随 ID 的星号(*)表示这是执行 docker node ls 的节点,基本上表示这是活动节点。然后,我们有节点的(人类可读的)名称,其状态,可用性和管理器状态。正如前面提到的,Swarm 的第一个节点自动成为领导者,这在前面的屏幕截图中有所指示。最后,我们看到我们正在使用的 Docker 引擎的版本。

要获取有关节点的更多信息,我们可以使用 docker node inspect 命令,如下面的屏幕截图所示:

使用 docker node inspect 命令的截断输出

此命令生成了大量信息,因此我们只呈现输出的截断版本。例如,当您需要排除集群节点的故障时,此输出可能很有用。

在 VirtualBox 或 Hyper-V 中创建本地 Swarm

有时,单个节点的 Swarm 是不够的,但我们没有或不想使用帐户在云中创建 Swarm。在这种情况下,我们可以在 VirtualBox 或 Hyper-V 中创建本地 Swarm。在 VirtualBox 中创建 Swarm 比在 Hyper-V 中创建 Swarm 稍微容易一些,但是如果您使用 Windows 10 并且正在运行 Docker for Windows,则无法同时使用 VirtualBox。这两个 hypervisor 是互斥的。

假设我们的笔记本电脑上已安装了 VirtualBox 和 docker-machine。然后,我们可以使用 docker-machine 列出当前定义并可能在 VirtualBox 中运行的所有 Docker 主机:

$ docker-machine ls
NAME ACTIVE DRIVER STATE URL SWARM DOCKER ERRORS
default - virtualbox Stopped Unknown

在我的情况下,我定义了一个名为 default 的 VM,当前已停止。我可以通过发出 docker-machine start default 命令轻松启动 VM。此命令需要一段时间,并将导致以下(缩短的)输出:

$ docker-machine start default
Starting "default"...
(default) Check network to re-create if needed...
(default) Waiting for an IP...
Machine "default" was started.
Waiting for SSH to be available...
Detecting the provisioner...
Started machines may have new IP addresses. You may need to re-run the `docker-machine env` command.

现在,如果我再次列出我的虚拟机,我应该看到以下截图:

在 Hyper-V 中运行的所有虚拟机列表

如果我们还没有名为default的虚拟机,我们可以使用create命令轻松创建一个:

docker-machine create --driver virtualbox default

这将产生以下输出:

docker-machine create 的输出

我们可以在前面的输出中看到docker-machine如何从 ISO 映像创建虚拟机,定义 SSH 密钥和证书,并将它们复制到虚拟机和本地~/.docker/machine目录,以便我们以后在通过 Docker CLI 远程访问此虚拟机时使用。它还为新的虚拟机提供了一个 IP 地址。

我们使用docker-machine create命令和--driver virtualbox参数。docker-machine 也可以使用其他驱动程序,如 Hyper-V、AWS、Azure、DigitalOcean 等。有关更多信息,请参阅docker-machine的文档。默认情况下,新的虚拟机关联了 1GB 的内存,这足以将此虚拟机用作开发或测试 Swarm 的节点。

如果您使用的是带有 Docker for Desktop 的 Windows 10,请改用hyperv驱动程序。但是,要成功,您需要以管理员身份运行。此外,您需要在 Hyper-V 上首先定义一个外部虚拟交换机。您可以使用 Hyper-V 管理器来完成。该命令的输出将与virtualbox驱动程序的输出非常相似。

现在,让我们为一个五节点的 Swarm 创建五个虚拟机。我们可以使用一些脚本来减少手动工作:

$ for NODE in `seq 1 5`; do
  docker-machine create --driver virtualbox "node-${NODE}"
done

docker-machine现在将创建五个名为node-1node-5的虚拟机。这可能需要一些时间,所以现在是喝杯热茶的好时机。虚拟机创建完成后,我们可以列出它们:

我们需要 Swarm 的所有虚拟机列表

现在,我们准备构建一个 Swarm。从技术上讲,我们可以 SSH 到第一个 VM node-1并初始化一个 Swarm,然后 SSH 到所有其他 VM 并加入它们到 Swarm 领导者。但这并不高效。让我们再次使用一个可以完成所有繁重工作的脚本:

# get IP of Swarm leader
$ export IP=$(docker-machine ip node-1)
# init the Swarm
$ docker-machine ssh node-1 docker swarm init --advertise-addr $IP
# Get the Swarm join-token
$ export JOIN_TOKEN=$(docker-machine ssh node-1 \
    docker swarm join-token worker -q)

现在我们有了加入令牌和 Swarm 领导者的 IP 地址,我们可以要求其他节点加入 Swarm,如下所示:

$ for NODE in `seq 2 5`; do
  NODE_NAME="node-${NODE}"
  docker-machine ssh $NODE_NAME docker swarm join \
        --token $JOIN_TOKEN $IP:2377
done

为了使 Swarm 具有高可用性,我们现在可以将例如node-2node-3提升为管理者:

$ docker-machine ssh node-1 docker node promote node-2 node-3
Node node-2 promoted to a manager in the swarm.
Node node-3 promoted to a manager in the swarm.

最后,我们可以列出 Swarm 的所有节点:

$ docker-machine ssh node-1 docker node ls

我们应该看到以下内容:

VirtualBox 上 Docker Swarm 的所有节点列表

这证明我们刚刚在本地笔记本电脑或工作站上创建了一个高可用的 Docker Swarm。让我们把所有的代码片段放在一起,使整个过程更加健壮。脚本如下所示:

alias dm="docker-machine"
for NODE in `seq 1 5`; do
  NODE_NAME=node-${NODE}
  dm rm --force $NODE_NAME
  dm create --driver virtualbox $NODE_NAME
done
alias dms="docker-machine ssh"
export IP=$(docker-machine ip node-1)
dms node-1 docker swarm init --advertise-addr $IP;
export JOIN_TOKEN=$(dms node-1 docker swarm join-token worker -q);
for NODE in `seq 2 5`; do
  NODE_NAME="node-${NODE}"
  dms $NODE_NAME docker swarm join --token $JOIN_TOKEN $IP:2377
done;
dms node-1 docker node promote node-2 node-3

上述脚本首先删除(如果存在),然后重新创建名为node-1node-5的五个虚拟机,然后在node-1上初始化一个 Swarm。之后,剩下的四个虚拟机被添加到 Swarm 中,最后,node-2node-3被提升为管理者状态,使 Swarm 高可用。整个脚本执行时间不到 5 分钟,可以重复执行多次。完整的脚本可以在存储库的docker-swarm子文件夹中找到;它被称为create-swarm.sh

在我们的笔记本电脑或工作站上,始终编写脚本并自动化操作是一种强烈推荐的最佳实践。

使用 Play with Docker 生成一个 Swarm

为了在我们的计算机上无需安装或配置任何东西的情况下尝试 Docker Swarm,我们可以使用Play with DockerPWD)。PWD 是一个可以通过浏览器访问的网站,它为我们提供了创建一个由最多五个节点组成的 Docker Swarm 的能力。正如名称所示,它绝对是一个游乐场,我们可以使用的时间限制为每个会话四个小时。我们可以打开尽可能多的会话,但每个会话在四小时后会自动结束。除此之外,它是一个完全功能的 Docker 环境,非常适合尝试 Docker 或演示一些功能。

现在让我们访问该网站。在浏览器中,导航到网站labs.play-with-docker.com。您将看到一个欢迎和登录屏幕。使用您的 Docker ID 登录。成功登录后,您将看到一个看起来像以下截图的屏幕:

Play with Docker 窗口

正如我们立即看到的,有一个大计时器从四小时开始倒计时。这是我们在本次会话中剩下的时间。此外,我们看到一个+ ADD NEW INSTANCE 链接。单击它以创建一个新的 Docker 主机。这样做后,您的屏幕应该看起来像以下的截图:

PWD 带有一个新节点

在左侧,我们看到了新创建的节点及其 IP 地址(192.168.0.48)和名称(node1)。在右侧,屏幕的上半部分显示了有关这个新节点的一些额外信息,下半部分显示了一个终端。是的,这个终端用于在我们刚刚创建的节点上执行命令。这个节点已经安装了 Docker CLI,因此我们可以在上面执行所有熟悉的 Docker 命令,比如docker version。试一下吧。

但现在我们想要创建一个 Docker Swarm。在浏览器的终端中执行以下命令:

$ docker swarm init --advertise-addr=eth0

前面命令生成的输出与我们之前在工作站上使用单节点集群和在 VirtualBox 或 Hyper-V 上使用本地集群时已经知道的内容相对应。重要的信息再次是我们想要用来加入额外节点到我们刚刚创建的集群的join命令。

你可能已经注意到,这次我们在 Swarm 的init命令中指定了--advertise-addr参数。为什么在这里有必要?原因是 PWD 生成的节点有多个与之关联的 IP 地址。我们可以通过在节点上执行ip a命令轻松验证这一点。这个命令将向我们显示确实存在两个端点,eth0eth1。因此,我们必须明确地指定给新的 Swarm 管理器我们想要使用哪一个。在我们的情况下,是eth0

通过点击四次“+添加新实例”链接在 PWD 中创建四个额外的节点。新节点将被命名为node2node3node4node5,并且都将列在左侧。如果你点击左侧的一个节点,右侧将显示相应节点的详细信息和该节点的终端窗口。

选择每个节点(2 到 5)并在相应的终端中执行从领导节点(node1)复制的docker swarm join命令:

加入节点到 PWD 中的 Swarm

一旦你将所有四个节点加入到 Swarm 中,切换回node1并列出所有节点,结果如下:

PWD 中 Swarm 的所有节点列表

仍然在node1上,我们现在可以提升,比如说,node2node3,使 Swarm 高度可用:

$ docker node promote node2 node3
Node node2 promoted to a manager in the swarm.
Node node3 promoted to a manager in the swarm.

有了这个,我们在 PWD 上的 Swarm 已经准备好接受工作负载。我们已经创建了一个高可用的 Docker Swarm,其中包括三个管理节点,形成一个 Raft 共识组,以及两个工作节点。

在云端创建一个 Docker Swarm

到目前为止,我们创建的所有 Docker Swarms 都非常适合在开发中使用,或者用于实验或演示目的。但是,如果我们想创建一个可以用作生产环境的 Swarm,在那里运行我们的关键应用程序,那么我们需要在云端或本地创建一个——我很想说——真正的 Swarm。在本书中,我们将演示如何在 AWS 中创建 Docker Swarm。

创建 Swarm 的一种方法是使用docker-machineDM)。DM 在 AWS 上有一个驱动程序。如果我们在 AWS 上有一个账户,我们需要 AWS 访问密钥 ID 和 AWS 秘密访问密钥。我们可以将这两个值添加到一个名为~/.aws/configuration的文件中。它应该看起来像下面这样:

[default]
aws_access_key_id = AKID1234567890
aws_secret_access_key = MY-SECRET-KEY

每次我们运行docker-machine create,DM 都会在该文件中查找这些值。有关如何获取 AWS 账户和获取两个秘钥的更深入信息,请参考此链接:dockr.ly/2FFelyT

一旦我们有了 AWS 账户并将访问密钥存储在配置文件中,我们就可以开始构建我们的 Swarm。所需的代码看起来与我们在 VirtualBox 上的本地机器上创建 Swarm 时使用的代码完全相同。让我们从第一个节点开始:

$ docker-machine create --driver amazonec2 \
 --amazonec2-region us-east-1 aws-node-1

这将在请求的区域(在我的情况下是us-east-1)中创建一个名为aws-node-1的 EC2 实例。前面命令的输出如下截图所示:

使用 DM 在 AWS 上创建一个 Swarm 节点

它看起来与我们已经知道的与 VirtualBox 一起工作的输出非常相似。我们现在可以配置我们的终端以远程访问该 EC2 实例:

$ eval $(docker-machine env aws-node-1)

这将相应地配置 Docker CLI 使用的环境变量:

Docker 用于启用对 AWS EC2 节点的远程访问的环境变量

出于安全原因,传输层安全TLS)用于我们的 CLI 和远程节点之间的通信。DM 将必要的证书复制到我们分配给环境变量DOCKER_CERT_PATH的路径。

我们现在在终端中执行的所有 Docker 命令都将在我们的 EC2 实例上远程执行。让我们尝试在此节点上运行 Nginx:

$ docker container run -d -p 8000:80 nginx:alpine

我们可以使用docker container ls来验证容器是否正在运行。如果是的话,让我们使用curl进行测试:

$ curl -4 <IP address>:8000

这里,<IP 地址>是 AWS 节点的公共 IP 地址;在我的情况下,它将是35.172.240.127。遗憾的是,这不起作用;前面的命令超时:

访问 AWS 节点上的 Nginx 超时

原因是我们的节点是 AWS 安全组(SG)的一部分。默认情况下,拒绝对此 SG 内部的对象的访问。因此,我们必须找出我们的实例属于哪个 SG,并显式配置访问权限。为此,我们通常使用 AWS 控制台。转到 EC2 仪表板,并在左侧选择实例。找到名为aws-node-1的 EC2 实例并选择它。在详细视图中,在“安全组”下,单击 docker-machine 链接,如下图所示:

找到我们的 Swarm 节点所属的 SG

这将引导我们到 SG 页面,其中docker-machine SG 被预先选择。在“入站”选项卡下的详细信息部分,为您的 IP 地址(工作站的 IP 地址)添加一个新规则:

为我们的计算机打开 SG 访问权限

在前面的屏幕截图中,IP 地址70.113.114.234恰好是分配给我的个人工作站的 IP 地址。我已经允许来自此 IP 地址的所有入站流量进入docker-machine SG。请注意,在生产系统中,您应该非常小心地选择要向公众开放的 SG 端口。通常,这是用于 HTTP 和 HTTPS 访问的端口80443。其他所有内容都是对黑客的潜在邀请。

您可以通过诸如www.whatismyip.com/之类的服务获取自己的 IP 地址。现在,如果我们再次执行curl命令,将返回 Nginx 的欢迎页面。

在我们离开 SG 之前,我们应该向其添加另一个规则。Swarm 节点需要能够通过 TCP 和 UDP 自由通信的端口79464789,以及通过 TCP 的端口2377。我们现在可以添加五个符合这些要求的规则,其中源是 SG 本身,或者我们只需定义一个允许 SG 内部所有入站流量的粗糙规则(在我的情况下是sg-c14f4db3):

 SG 规则以启用 Swarm 内部通信

现在,让我们继续创建剩下的四个节点。我们可以再次使用脚本来简化这个过程:

$ for NODE in `seq 2 5`; do
 docker-machine create --driver amazonec2 \
 --amazonec2-region us-east-1 aws-node-${NODE}
done

节点的配置完成后,我们可以使用 DM 列出所有节点。在我的情况下,我看到了这个:

DM 创建的所有节点列表

在前面的截图中,我们可以看到我们最初在 VirtualBox 中创建的五个节点和我们在 AWS 中创建的五个新节点。显然,AWS 上的节点正在使用一个新版本的 Docker;这里的版本是18.02.0-ce。我们在URL列中看到的 IP 地址是我的 EC2 实例的公共 IP 地址。

因为我们的 CLI 仍然配置为远程访问aws-node-1节点,所以我们可以直接运行以下swarm init命令:

$ docker swarm init

要获取加入令牌,请执行以下操作:

$ export JOIN_TOKEN=$(docker swarm join-token -q worker)

要获取领导者的 IP 地址,请使用以下命令:

$ export LEADER_ADDR=$(docker node inspect \
 --format "{{.ManagerStatus.Addr}}" self)

有了这些信息,我们现在可以将其他四个节点加入到 Swarm 的领导者中:

$ for NODE in `seq 2 5`; do
 docker-machine ssh aws-node-${NODE} \
 sudo docker swarm join --token ${JOIN_TOKEN} ${LEADER_ADDR}
done

实现相同目标的另一种方法是,无需登录到各个节点,每次想要访问不同的节点时都重新配置我们的客户端 CLI:

$ for NODE in `seq 2 5`; do
 eval $(docker-machine env aws-node-${NODE})
 docker swarm join --token ${JOIN_TOKEN} ${LEADER_ADDR}
done

作为最后一步,我们希望将节点23提升为管理节点:

$ eval $(docker-machine env node-1)
$ docker node promote aws-node-2 aws-node-3

然后,我们可以列出所有 Swarm 节点,如下截图所示:

云中我们 Swarm 的所有节点列表

因此,我们在云中拥有一个高可用的 Docker Swarm。为了清理云中的 Swarm 并避免产生不必要的成本,我们可以使用以下命令:

$ for NODE in `seq 1 5`; do
 docker-machine rm -f aws-node-${NODE}
done

部署第一个应用程序

我们在各种平台上创建了一些 Docker Swarms。一旦创建,Swarm 在任何平台上的行为都是相同的。我们在 Swarm 上部署和更新应用程序的方式并不依赖于平台。Docker 的主要目标之一就是避免在使用 Swarm 时出现供应商锁定。支持 Swarm 的应用程序可以轻松地从例如在本地运行的 Swarm 迁移到基于云的 Swarm。甚至在技术上可以在本地运行 Swarm 的一部分,另一部分在云中运行。这是可行的,但我们当然必须考虑由于地理上相距较远的节点之间的更高延迟可能导致的可能的副作用。

现在我们有一个高可用的 Docker Swarm 正在运行,是时候在其上运行一些工作负载了。我正在使用通过 docker-machine 创建的本地 Swarm。我们将首先创建一个单一服务。为此,我们需要 SSH 登录到其中一个管理节点。我选择node-1

$ docker-machine ssh node-1

创建一个服务

服务可以作为堆栈的一部分创建,也可以直接使用 Docker CLI 创建。让我们首先看一个定义单一服务的示例堆栈文件:

version: "3.7"
services:
  whoami:
    image: training/whoami:latest
    networks:
      - test-net
    ports:
      - 81:8000
    deploy:
      replicas: 6
      update_config:
        parallelism: 2
        delay: 10s
      labels:
        app: sample-app
        environment: prod-south

networks:
  test-net:
    driver: overlay

在前面的示例中,我们看到了一个名为whoami的服务的期望状态:

  • 它基于training/whoami:latest镜像。

  • 服务的容器连接到test-net网络。

  • 容器端口8000发布到端口81

  • 它以六个副本(或任务)运行

  • 在滚动更新期间,单个任务以每批两个的方式更新,每个成功批之间延迟 10 秒。

  • 该服务(及其任务和容器)被分配了两个标签appenvironment,其值分别为sample-appprod-south

我们可以为服务定义许多其他设置,但前面的设置是一些更重要的设置。大多数设置都有有意义的默认值。例如,如果我们没有指定副本的数量,那么 Docker 会将其默认为1。服务的名称和镜像当然是必需的。请注意,服务的名称在 Swarm 中必须是唯一的。

要创建前面的服务,我们使用docker stack deploy命令。假设存储前面内容的文件名为stack.yaml,我们有以下内容:

$ docker stack deploy -c stack.yaml sample-stack

在这里,我们创建了一个名为sample-stack的堆栈,其中包含一个名为whoami的服务。我们可以列出我们的 Swarm 上的所有堆栈,然后我们应该得到这个:

$ docker stack ls
NAME             SERVICES
sample-stack     1

如果我们列出我们的 Swarm 中定义的服务,我们会得到以下输出:

列出在 Swarm 中运行的所有服务

在输出中,我们可以看到目前只有一个正在运行的服务,这是可以预料的。该服务有一个ID。与迄今为止用于容器、网络或卷的格式相反,ID的格式是字母数字(在后一种情况下,它总是sha256)。我们还可以看到服务的NAME是我们在堆栈文件中定义的服务名称和堆栈的名称的组合,堆栈的名称被用作前缀。这是有道理的,因为我们希望能够使用相同的堆栈文件将多个堆栈(具有不同名称)部署到我们的 Swarm 中。为了确保服务名称是唯一的,Docker 决定将服务名称和堆栈名称组合起来。

在第三列中,我们看到模式是replicatedREPLICAS的数量显示为6/6。这告诉我们,六个请求的REPLICAS中有六个正在运行。这对应于期望的状态。在输出中,我们还可以看到服务使用的镜像和服务的端口映射。

检查服务及其任务

在前面的输出中,我们看不到已创建的6个副本的详细信息。为了更深入地了解这一点,我们可以使用docker service ps命令。如果我们为我们的服务执行此命令,我们将得到以下输出:

whoami 服务的详细信息

在前面的输出中,我们可以看到与我们请求的whoami服务的六个副本相对应的六个任务的列表。在NODE列中,我们还可以看到每个任务部署到的节点。每个任务的名称是服务名称加上递增索引的组合。还要注意,与服务本身类似,每个任务都被分配了一个字母数字 ID。

在我的情况下,显然任务 2,名称为sample-stack_whoami.2,已部署到了node-1,这是我们 Swarm 的领导者。因此,我应该在这个节点上找到一个正在运行的容器。让我们看看如果我们列出在node-1上运行的所有容器会得到什么:

节点 1 上的容器列表

预期地,我们发现一个容器正在运行training/whoami:latest镜像,其名称是其父任务名称和 ID 的组合。我们可以尝试可视化我们部署示例堆栈时生成的所有对象的整个层次结构:

Docker Swarm 堆栈的对象层次结构

堆栈可以由一个到多个服务组成。每个服务都有一组任务。每个任务与一个容器有一对一的关联。堆栈和服务是在 Swarm 管理节点上创建和存储的。然后将任务调度到 Swarm 工作节点,工作节点在那里创建相应的容器。我们还可以通过检查来获取有关我们的服务的更多信息。执行以下命令:

$ docker service inspect sample-stack_whoami

这提供了有关服务的所有相关设置的丰富信息。这包括我们在stack.yaml文件中明确定义的设置,但也包括我们没有指定的设置,因此被分配了它们的默认值。我们不会在这里列出整个输出,因为它太长了,但我鼓励读者在自己的机器上检查它。我们将在Swarm 路由网格部分更详细地讨论部分信息。

服务的日志

在早些章节中,我们处理了容器产生的日志。在这里,我们专注于一个服务。请记住,最终,具有许多副本的服务有许多容器在运行。因此,我们期望,如果我们要求服务的日志,Docker 会返回属于该服务的所有容器的日志的聚合。确实,这就是我们使用docker service logs命令得到的内容:

whoami 服务的日志

在这一点上,日志中没有太多信息,但足以讨论我们得到了什么。日志中每行的第一部分始终包含容器的名称,以及日志条目来源的节点名称。然后,通过竖线(|)分隔,我们得到实际的日志条目。因此,如果我们直接要求获取列表中第一个容器的日志,我们将只获得一个条目,而在这种情况下我们将看到的值是Listening on :8000

使用docker service logs命令获取的聚合日志没有按任何特定方式排序。因此,如果事件的相关性发生在不同的容器中,您应该在日志输出中添加信息,使这种相关性成为可能。通常,这是每个日志条目的时间戳。但这必须在源头完成;例如,产生日志条目的应用程序还需要确保添加时间戳。

我们也可以通过提供任务 ID 而不是服务 ID 或名称来查询服务的单个任务的日志。因此,查询任务 2 的日志会给我们以下输出:

whoami 服务的单个任务的日志

调和期望的状态

我们已经了解到,Swarm 服务是我们希望应用程序或应用程序服务在其中运行的期望状态的描述或清单。现在,让我们看看 Docker Swarm 如何调和这个期望的状态,如果我们做了一些导致服务的实际状态与期望状态不同的事情。这样做的最简单方法是强制杀死服务的一个任务或容器。

让我们用安排在node-1上的容器来做这个:

$ docker container rm -f sample-stack_whoami.2.n21e7ktyvo4b2sufalk0aibzy

如果我们这样做,然后立即运行docker service ps,我们将看到以下输出:

Docker Swarm 在一个任务失败后调和期望的状态

我们看到任务 2 以退出码137失败,并且 Swarm 立即通过在具有空闲资源的节点上重新调度失败的任务来调和期望的状态。在这种情况下,调度程序选择了与失败任务相同的节点,但这并不总是这样。因此,在我们不干预的情况下,Swarm 完全解决了问题,并且由于服务正在多个副本中运行,服务从未停机。

让我们尝试另一种失败场景。这一次,我们将关闭整个节点,并看看 Swarm 的反应。让我们选择node-2,因为它上面有两个任务(任务 3 和任务 4)正在运行。为此,我们需要打开一个新的终端窗口,并使用docker-machine来停止node-2

$ docker-machine stop node-2

回到node-1,我们现在可以再次运行docker service ps来看看发生了什么:

Swarm 重新安排了一个失败节点的所有任务

在前面的屏幕截图中,我们可以看到立即任务 3 被重新安排在node-1上,而任务 4 被重新安排在node-3上。即使这种更激进的失败也能被 Docker Swarm 优雅地处理。

但需要注意的是,如果node-2在 Swarm 中重新上线,之前在其上运行的任务将不会自动转移到它上面。但是该节点现在已经准备好接受新的工作负载。

删除服务或堆栈

如果我们想要从 Swarm 中移除特定的服务,我们可以使用docker service rm命令。另一方面,如果我们想要从 Swarm 中移除一个堆栈,我们类似地使用docker stack rm命令。这个命令会移除堆栈定义中的所有服务。在whoami服务的情况下,它是通过使用堆栈文件创建的,因此我们将使用后者命令:

移除一个堆栈

上述命令将确保堆栈的每个服务的所有任务被终止,并且相应的容器首先发送SIGTERM,然后,如果不成功,在 10 秒的超时后发送SIGKILL

重要的是要注意,停止的容器不会从 Docker 主机中删除。因此,建议定期清理工作节点上的容器,以回收未使用的资源。为此,使用docker container purge -f

问题:为什么让停止或崩溃的容器留在工作节点上,而不自动删除它们是有意义的?

部署多服务堆栈

在第十一章中,Docker Compose,我们使用了一个由两个服务组成的应用程序,在 Docker compose 文件中进行了声明性描述。我们可以使用这个 compose 文件作为模板,创建一个堆栈文件,允许我们将相同的应用程序部署到 Swarm 中。我们的堆栈文件的内容,名为pet-stack.yaml,如下所示:

version: "3.7"
services:
 web:
   image: fundamentalsofdocker/ch11-web:2.0
   networks:
   - pets-net
   ports:
   - 3000:3000
   deploy:
     replicas: 3
 db:
   image: fundamentalsofdocker/ch11-db:2.0
   networks:
   - pets-net
   volumes:
   - pets-data:/var/lib/postgresql/data

volumes:
 pets-data:

networks:
 pets-net:
 driver: overlay

我们要求web服务有三个副本,并且两个服务都连接到叠加网络pets-net。我们可以使用docker stack deploy命令部署这个应用程序:

部署宠物堆栈

Docker 创建了pets_pets-net叠加网络,然后创建了两个服务pets_webpets_db。然后我们可以列出pets堆栈中的所有任务:

宠物堆栈中所有任务的列表

最后,让我们使用curl测试应用程序。确实,应用程序按预期工作:

使用 curl 测试宠物应用程序

容器 ID 在输出中,其中写着由容器 8b906b509a7e 提供给您。如果多次运行curl命令,ID 应该在三个不同的值之间循环。这些是我们为web服务请求的三个容器(或副本)的 ID。

完成后,我们可以使用docker stack rm pets来删除堆栈。

Swarm 路由网格

如果你一直在关注,那么你可能已经注意到了上一节中的一些有趣的事情。我们部署了pets应用程序,结果是web服务的一个实例被安装在三个节点node-1node-2node-3上。然而,我们能够通过localhost访问node-1上的web服务,并从那里访问每个容器。这是怎么可能的?嗯,这是由于所谓的 Swarm 路由网格。路由网格确保当我们发布一个服务的端口时,该端口会在 Swarm 的所有节点上发布。因此,命中 Swarm 的任何节点并请求使用特定端口的网络流量将通过路由网格转发到服务容器之一。让我们看看下面的图表,看看它是如何工作的:

Docker Swarm 路由网格

在这种情况下,我们有三个节点,称为主机 A主机 C,它们的 IP 地址分别是172.10.0.15172.10.0.17172.10.0.33。在图表的左下角,我们看到了创建一个具有两个副本的web服务的命令。相应的任务已经被安排在主机 B主机 C上。任务 1 落在主机 B上,而任务 2 落在主机 C上。

当在 Docker Swarm 上创建服务时,它会自动分配一个虚拟 IP(VIP)地址。这个 IP 地址在整个服务的生命周期内是稳定和保留的。假设在我们的情况下,VIP 是10.2.0.1

如果现在来自外部负载均衡器LB)的端口8080的请求被定向到我们 Swarm 的一个节点上,那么这个请求将由该节点上的 Linux IP 虚拟服务器IPVS)服务处理。该服务在 IP 表中使用给定的端口8080进行查找,并将找到这对应于web服务的 VIP。现在,由于 VIP 不是一个真正的目标,IPVS 服务将负载均衡与该服务关联的任务的 IP 地址。在我们的情况下,它选择了任务 2,其 IP 地址为10.2.0.3。最后,入口网络(Overlay)用于将请求转发到Host C上的目标容器。

重要的是要注意,外部请求被外部 LB转发到哪个 Swarm 节点并不重要。路由网格将始终正确处理请求并将其转发到目标服务的任务之一。

总结

在本章中,我们介绍了 Docker Swarm,它是继 Kubernetes 之后第二受欢迎的容器编排器。我们研究了 Swarm 的架构,讨论了在 Swarm 中运行的所有类型的资源,如服务、任务等,并在 Swarm 中创建了服务,并部署了由多个相关服务组成的应用程序。

在下一章中,我们将探讨如何在 Docker Swarm 上部署服务或应用程序,实现零停机时间和自动回滚功能。我们还将介绍秘密作为保护敏感信息的手段。

问题

为了评估您的学习进度,请回答以下问题:

  1. 如何初始化一个新的 Docker Swarm?

A. docker init swarm

B. docker swarm init --advertise-addr <IP 地址>

C. docker swarm join --token <加入令牌>

  1. 您想要从 Docker Swarm 中删除一个工作节点。需要哪些步骤?

  2. 如何创建一个名为front-tier的覆盖网络?使网络可附加。

  3. 您将如何从nginx:alpine镜像创建一个名为web的服务,该服务有五个副本,将端口3000暴露在入口网络上,并附加到front-tier网络?

  4. 您将如何将 web 服务缩减到三个实例?

进一步阅读

请参考以下链接,了解有关所选主题的更深入信息:

第十四章:零停机部署和 Secrets

在上一章中,我们详细探讨了 Docker Swarm 及其资源。我们学习了如何在本地和云中构建高可用的 swarm。然后,我们深入讨论了 Swarm 服务和堆栈。最后,我们在 swarm 中创建了服务和堆栈。

在本章中,我们将向您展示如何在 Docker Swarm 中更新服务和堆栈而不中断其可用性。这被称为零停机部署。我们还将介绍 swarm secrets 作为一种安全地向服务的容器提供敏感信息的手段。

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

  • 零停机部署

  • 在 swarm 中存储配置数据

  • 使用 Docker Secrets 保护敏感数据

完成本章后,您将能够做到以下事情:

  • 列举两到三种常用的部署策略,用于在不中断的情况下更新服务。

  • 批量更新服务而不会造成服务中断。

  • 为服务定义回滚策略,如果更新失败则使用。

  • 使用 Docker 配置存储非敏感配置数据。

  • 使用 Docker secret 与服务。

  • 更新 secret 的值而不会造成停机时间。

技术要求

本章的代码文件可以在 GitHub 上找到github.com/PacktPublishing/Learn-Docker---Fundamentals-of-Docker-19.x-Second-Edition。如果您已经按照第二章中指示的设置工作环境检出了存储库,那么您可以在~/fod-solution/ch14找到代码。

零停机部署

需要频繁更新的关键应用程序最重要的一个方面是能够以完全无中断的方式进行更新。我们称之为零停机部署。更新后的应用程序必须始终完全可操作。

流行的部署策略

有各种方法可以实现这一点。其中一些如下:

  • 滚动更新

  • 蓝绿部署

  • 金丝雀发布

Docker Swarm 支持开箱即用的滚动更新。其他两种部署类型需要我们额外的努力才能实现。

滚动更新

在关键任务应用中,每个应用服务必须以多个副本运行。根据负载的大小,副本可以少至两到三个实例,多至数十、数百或数千个实例。在任何给定时间,我们希望所有服务实例的运行都有明确的多数。因此,如果我们有三个副本,我们希望至少有两个副本一直在运行。如果我们有 100 个副本,我们可以满足于至少有 90 个副本可用。通过这样做,我们可以定义一个批量大小的副本,我们可以关闭以进行升级。在第一种情况下,批量大小将为 1,在第二种情况下,将为 10。

当我们关闭副本时,Docker Swarm 将自动将这些实例从负载均衡池中移除,所有流量将在剩余的活动实例之间进行负载均衡。因此,这些剩余实例将暂时经历流量的轻微增加。在下图中,在滚动更新开始之前,如果Task A3想要访问Service B,它可能已经被 SwarmKit 负载均衡到Service B的任何三个任务中的一个。一旦滚动更新开始,SwarmKit 将关闭Task B1进行更新。自动地,这个任务就被从目标池中移除。因此,如果Task A3现在请求连接到Service B,负载均衡将只从剩余的任务中选择,即B2B3。因此,这两个任务可能暂时经历更高的负载:

Task B1被关闭以进行更新

然后停止实例,用新版本的应用服务的等效数量的新实例替换它们。一旦新实例正常运行,我们可以让 Swarm 在一定时间内观察它们,确保它们健康。如果一切正常,那么我们可以继续关闭下一批实例,并用新版本的实例替换它们。这个过程重复进行,直到所有应用服务的实例都被替换。

在下图中,我们可以看到Service BTask B1已更新为版本 2。Task B1的容器被分配了一个新的IP地址,并部署到另一个具有空闲资源的工作节点上:

正在进行滚动更新的第一批

重要的是要理解,当服务的任务被更新时,在大多数情况下,它会被部署到与其原来所在的不同的工作节点上。但只要相应的服务是无状态的,这应该没问题。如果我们有一个有状态的服务,它是位置或节点感知的,并且我们想要对其进行更新,那么我们必须调整我们的方法,但这超出了本书的范围。

现在,让我们看看如何实际指示 Swarm 执行应用服务的滚动更新。当我们在堆栈文件中声明一个服务时,我们可以定义在这种情况下相关的多个选项。让我们看一个典型堆栈文件的片段:

version: "3.5"
services:
 web:
   image: nginx:alpine
   deploy:
     replicas: 10
     update_config:
       parallelism: 2
       delay: 10s
...

在这个片段中,我们可以看到一个名为update_config的部分,其中包含parallelismdelay属性。parallelism定义了在滚动更新期间一次要更新多少个副本的批处理大小。delay定义了 Docker Swarm 在更新单个批次之间要等待多长时间。在前面的例子中,我们有10个副本,每次更新两个实例,并且在每次成功更新之间,Docker Swarm 等待10秒。

让我们测试这样一个滚动更新。导航到我们labs文件夹的ch14子文件夹,并使用stack.yaml文件创建一个已配置为滚动更新的 web 服务。该服务使用基于 Alpine 的 Nginx 镜像,版本为1.12-alpine。我们将把服务更新到一个更新的版本,即1.13-alpine

首先,我们将把这个服务部署到我们在 VirtualBox 中本地创建的 Swarm。让我们来看一下:

  1. 首先,我们需要确保我们的终端窗口已配置,以便我们可以访问我们集群的主节点之一。让我们选择领导者,即node-1
$ eval $(docker-machine env node-1)
  1. 现在,我们可以使用堆栈文件部署服务:
$ docker stack deploy -c stack.yaml web

上述命令的输出如下:

部署 web 堆栈

  1. 服务部署后,我们可以使用以下命令对其进行监视:
$ watch docker stack ps web

我们将看到以下输出:

运行在 Swarm 中的 web 堆栈的 web 服务,有 10 个副本。如果您在 macOS 机器上工作,您需要确保您安装了 watch 工具。使用brew install watch命令来安装。

上述命令将持续更新输出,并为我们提供滚动更新期间发生的情况的良好概述。

现在,我们需要打开第二个终端,并为我们的 Swarm 的管理节点配置远程访问。一旦我们完成了这一步,我们可以执行docker命令,它将更新堆栈的web服务的镜像,也称为web

$ docker service update --image nginx:1.13-alpine web_web

上述命令导致以下输出,指示滚动更新的进度:

显示滚动更新进度的屏幕

上述输出表明,前两批每批两个任务已成功,并且第三批正在准备中。

在观看堆栈的第一个终端窗口中,我们现在应该看到 Docker Swarm 如何以10 秒的间隔逐批更新服务。第一批之后,它应该看起来像以下截图:

Docker Swarm 中服务的滚动更新

在上述截图中,我们可以看到前两个任务89已经更新。Docker Swarm 正在等待10 秒后继续下一批。

有趣的是,在这种特殊情况下,SwarmKit 将任务的新版本部署到与先前版本相同的节点。这是偶然的,因为我们有五个节点,每个节点上有两个任务。SwarmKit 始终尝试在节点之间均匀平衡工作负载。因此,当 SwarmKit 关闭一个任务时,相应的节点的工作负载小于所有其他节点,因此新实例被调度到该节点。通常情况下,您不能期望在同一节点上找到任务的新实例。只需尝试通过删除具有docker stack rm web并将副本数更改为例如七个,然后重新部署和更新来自己尝试。

一旦所有任务都已更新,我们的docker stack ps web命令的输出将类似于以下截图:

所有任务已成功更新

请注意,SwarmKit 不会立即从相应节点中删除任务的先前版本的容器。这是有道理的,因为我们可能希望,例如,检索这些容器的日志以进行调试,或者我们可能希望使用docker container inspect检索它们的元数据。SwarmKit 在清除旧实例之前会保留最近的四个终止任务实例,以防止它用未使用的资源堵塞系统。

我们可以使用--update-order参数指示 Docker 在停止旧容器之前启动新的容器副本。这可以提高应用程序的可用性。有效值为"start-first""stop-first"。后者是默认值。

完成后,我们可以使用以下命令拆除堆栈:

$ docker stack rm web

虽然使用堆栈文件来定义和部署应用程序是推荐的最佳实践,但我们也可以在服务create语句中定义更新行为。如果我们只想部署单个服务,这可能是做事情的首选方式。让我们看看这样一个create命令:

$ docker service create --name web \
 --replicas 10 \
 --update-parallelism 2 \
 --update-delay 10s \
 nginx:alpine

这个命令定义了与前面的堆栈文件相同的期望状态。我们希望服务以10个副本运行,并且我们希望滚动更新以每次两个任务的批次进行,并且在连续批次之间间隔 10 秒。

健康检查

为了做出明智的决定,例如在滚动更新 Swarm 服务期间,关于刚安装的新服务实例批次是否正常运行,或者是否需要回滚,SwarmKit 需要一种了解系统整体健康状况的方式。SwarmKit(和 Docker)本身可以收集相当多的信息。但是有限制。想象一个包含应用程序的容器。从外部看,容器可能看起来绝对健康,可以正常运行。但这并不一定意味着容器内部运行的应用程序也很好。例如,应用程序可能陷入无限循环或处于损坏状态,但仍在运行。但只要应用程序运行,容器就运行,并且从外部看,一切都看起来完美。

因此,SwarmKit 提供了一个接口,我们可以在其中提供一些帮助。我们,即运行在集群容器内部的应用程序服务的作者,最了解我们的服务是否处于健康状态。SwarmKit 给了我们定义一个命令的机会,该命令针对我们的应用程序服务进行健康测试。这个命令具体做什么对 Swarm 来说并不重要;命令只需要返回OKNOT OK超时。后两种情况,即NOT OK超时,将告诉 SwarmKit 正在调查的任务可能不健康。

在这里,我故意写了一些东西,稍后我们会看到原因:

FROM alpine:3.6
...
HEALTHCHECK --interval=30s \
    --timeout=10s
    --retries=3
    --start-period=60s
    CMD curl -f http://localhost:3000/health || exit 1
...

在来自 Dockerfile 的前面的片段中,我们可以看到关键字 HEALTHCHECK。它有一些选项或参数和一个实际的命令,即 CMD。让我们讨论一下选项:

  • --interval:定义健康检查之间的等待时间。因此,在我们的情况下,编排器每 30 秒执行一次检查。

  • --timeout:此参数定义 Docker 在健康检查不响应时应等待多长时间,直到超时出现错误。在我们的示例中,这是 10 秒。现在,如果一个健康检查失败,SwarmKit 会重试几次,直到放弃并声明相应的任务不健康,并打开 Docker 杀死该任务并用新实例替换的机会。

  • 重试次数由--retries 参数定义。在前面的代码中,我们希望有三次重试。

  • 接下来,我们有启动周期。有些容器需要一些时间来启动(虽然这不是一种推荐的模式,但有时是不可避免的)。在这个启动时间内,服务实例可能无法响应健康检查。有了启动周期,我们可以定义 SwarmKit 在执行第一次健康检查之前等待多长时间,从而给应用程序初始化的时间。为了定义启动时间,我们使用--start-period 参数。在我们的情况下,我们在 60 秒后进行第一次检查。启动时间需要多长取决于应用程序及其启动行为。建议是从相对较低的值开始,如果有很多错误的阳性和任务被多次重启,可能需要增加时间间隔。

  • 最后,我们在最后一行用 CMD 关键字定义了实际的探测命令。在我们的情况下,我们正在定义对端口 3000 的 localhost 的/health 端点的请求作为探测命令。这个调用有三种可能的结果:

  • 命令成功。

  • 命令失败。

  • 命令超时。

SwarmKit 将后两者视为相同。这是编排器告诉我们相应的任务可能不健康。我故意说可能,因为 SwarmKit 并不立即假设最坏的情况,而是假设这可能只是任务的暂时故障,并且它将从中恢复。这就是为什么我们有一个--retries参数的原因。在那里,我们可以定义 SwarmKit 在可以假定任务确实不健康之前应重试多少次,因此杀死它并在另一个空闲节点上重新安排此任务的另一个实例以调和服务的期望状态。

为什么我们可以在我们的探测命令中使用 localhost?这是一个非常好的问题,原因是因为当 SwarmKit 在 Swarm 中运行的容器进行探测时,它在容器内执行这个探测命令(也就是说,它做了类似docker container exec <containerID> <probing command>的事情)。因此,该命令在与容器内运行的应用程序相同的网络命名空间中执行。在下图中,我们可以看到服务任务的生命周期:

具有瞬态健康失败的服务任务

首先,SwarmKit 等待到启动期结束才进行探测。然后,我们进行第一次健康检查。不久之后,任务在探测时失败。它连续失败两次,但然后恢复。因此,健康检查 4成功了,SwarmKit 让任务继续运行。

在这里,我们可以看到一个永久失败的任务:

任务的永久失败

我们刚刚学习了如何在服务的镜像的Dockerfile中定义健康检查。但这并不是我们可以做到这一点的唯一方式。我们还可以在用于将我们的应用程序部署到 Docker Swarm 中的堆栈文件中定义健康检查。以下是这样一个堆栈文件的简短片段:

version: "3.5"
services:
  web:
    image: example/web:1.0
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 60s
...

在上述片段中,我们可以看到健康检查相关信息是如何在堆栈文件中定义的。首先,首先要意识到的是,我们必须为每个服务单独定义健康检查。没有应用程序或全局级别的健康检查。

与我们之前在 Dockerfile 中定义的类似,SwarmKit 用于执行健康检查的命令是 curl -f http://localhost:3000/health。我们还定义了 intervaltimeoutretriesstart_period。这四个键值对的含义与我们在 Dockerfile 中使用的相应参数相同。如果镜像中定义了与健康检查相关的设置,那么堆栈文件中定义的设置将覆盖 Dockerfile 中的设置。

现在,让我们尝试使用一个定义了健康检查的服务。在我们的 lab 文件夹中,有一个名为 stack-health.yaml 的文件,内容如下:

version: "3.5"
services:
  web:
    image: nginx:alpine
    healthcheck:
      test: ["CMD", "wget", "-qO", "-", "http://localhost"]
      interval: 5s
      timeout: 2s
      retries: 3
      start_period: 15s

让我们部署这个:

$ docker stack deploy -c stack-health.yaml myapp

我们可以使用 docker stack ps myapp 命令找出单个任务部署到了哪里。在特定的节点上,我们可以列出所有容器,找到我们的其中一个堆栈。在我的例子中,任务已经部署到了 node-3

显示运行任务实例的健康状态

这张截图中有趣的地方是 STATUS 列。Docker,或者更准确地说,SwarmKit,已经识别出服务有一个健康检查函数定义,并且正在使用它来确定服务的每个任务的健康状况。

回滚

有时,事情并不如预期。应用发布的最后一分钟修复可能无意中引入了一个新的 bug,或者新版本显著降低了组件的吞吐量,等等。在这种情况下,我们需要有一个备用计划,这在大多数情况下意味着能够将更新回滚到之前的良好版本。

与更新一样,回滚必须以不会导致应用中断的方式进行。它需要零停机时间。从这个意义上讲,回滚可以被看作是一个反向更新。我们正在安装一个新版本,但这个新版本实际上是之前的版本。

与更新行为一样,我们可以在堆栈文件或 Docker 服务 create 命令中声明系统在需要执行回滚时应该如何行为。在这里,我们有之前使用的堆栈文件,但这次有一些与回滚相关的属性:

version: "3.5"
services:
  web:
    image: nginx:1.12-alpine
    ports:
      - 80:80
    deploy:
      replicas: 10
      update_config:
        parallelism: 2
        delay: 10s

        failure_action: rollback
        monitor: 10s

    healthcheck:
      test: ["CMD", "wget", "-qO", "-", "http://localhost"]
      interval: 2s
      timeout: 2s
      retries: 3
      start_period: 2s

在这个堆栈文件中,我们定义了关于滚动更新、健康检查和回滚期间行为的详细信息。健康检查被定义为,在初始等待时间为2秒后,编排器开始每2秒在http://localhost上轮询服务,并在考虑任务不健康之前重试3次。

如果我们做数学计算,那么如果由于错误而导致任务不健康,那么至少需要 8 秒才能停止任务。因此,现在在部署下,我们有一个名为monitor的新条目。该条目定义了新部署的任务应该被监视多长时间以确保其健康,并且是否继续进行滚动更新的下一批任务。在这个示例中,我们给了它10秒。这比我们计算出的 8 秒稍微长一些,可以发现已部署的有缺陷的服务,所以这很好。

我们还有一个新条目,failure_action,它定义了在滚动更新过程中遇到失败时编排器将采取的行动,例如服务不健康。默认情况下,动作只是停止整个更新过程,并使系统处于中间状态。系统并没有宕机,因为它是一个滚动更新,至少一些健康的服务实例仍然在运行,但运维工程师最好能够查看并解决问题。

在我们的情况下,我们已经定义了动作为rollback。因此,在失败的情况下,SwarmKit 将自动将所有已更新的任务回滚到它们的先前版本。

蓝绿部署

在第九章中,分布式应用架构,我们以抽象的方式讨论了蓝绿部署是什么。事实证明,在 Docker Swarm 上,我们不能真正为任意服务实现蓝绿部署。在 Docker Swarm 中运行的两个服务之间的服务发现和负载均衡是 Swarm 路由网格的一部分,不能(轻松地)定制。

如果Service A想要调用Service B,那么 Docker 会隐式地执行这个操作。给定目标服务的名称,Docker 将使用 Docker DNS服务将此名称解析为虚拟 IPVIP)地址。然后,当请求针对VIP时,Linux IPVS服务将在 Linux 内核 IP 表中使用VIP进行另一个查找,并将请求负载均衡到VIP所代表的服务的任务的物理 IP 地址之一,如下图所示:

Docker Swarm 中的服务发现和负载均衡是如何工作的

不幸的是,目前还没有简单的方法来拦截这种机制并用自定义行为替换它。但这是需要的,以便允许对我们示例中的目标服务Service B进行真正的蓝绿部署。正如我们将在第十六章中看到的那样,使用 Kubernetes 部署、更新和保护应用程序,Kubernetes 在这个领域更加灵活。

也就是说,我们总是可以以蓝绿方式部署面向公众的服务。我们可以使用 interlock 2 及其第 7 层路由机制来实现真正的蓝绿部署。

金丝雀发布

从技术上讲,滚动更新是金丝雀发布的一种形式。但由于它们缺乏接口,无法将自定义逻辑插入系统中,滚动更新只是金丝雀发布的一个非常有限的版本。

真正的金丝雀发布要求我们对更新过程有更精细的控制。此外,真正的金丝雀发布在将 100%的流量引导到新版本之前不会关闭旧版本的服务。在这方面,它们被视为蓝绿部署。

在金丝雀发布的情况下,我们不仅希望使用诸如健康检查之类的因素来决定是否将更多的流量引导到新版本的服务中;我们还希望考虑决策过程中的外部输入,例如由日志聚合器收集和聚合的指标或跟踪信息。可以作为决策者的一个示例是符合服务级别协议SLA),即如果服务的新版本显示出超出容忍范围的响应时间。如果我们向现有服务添加新功能,但这些新功能降低了响应时间,就会发生这种情况。

在 swarm 中存储配置数据

如果我们想在 Docker Swarm 中存储诸如配置文件之类的非敏感数据,那么我们可以使用 Docker 配置。Docker 配置与 Docker 秘密非常相似,我们将在下一节中讨论。主要区别在于配置值在静止状态下没有加密,而秘密有。Docker 配置只能在 Docker Swarm 中使用,也就是说,它们不能在非 Swarm 开发环境中使用。Docker 配置直接挂载到容器的文件系统中。配置值可以是字符串,也可以是二进制值,最大大小为 500 KB。

通过使用 Docker 配置,您可以将配置与 Docker 镜像和容器分离。这样,您的服务可以轻松地使用特定于环境的值进行配置。生产 swarm 环境的配置值与分期 swarm 的配置值不同,而后者又与开发或集成环境的配置值不同。

我们可以向服务添加配置,也可以从运行中的服务中删除配置。配置甚至可以在 swarm 中运行的不同服务之间共享。

现在,让我们创建一些 Docker 配置:

  1. 首先,我们从一个简单的字符串值开始:
$ echo "Hello world" | docker config create hello-config - rrin36epd63pu6w3gqcmlpbz0

上面的命令创建了Hello world配置值,并将其用作名为hello-config的配置的输入。此命令的输出是存储在 swarm 中的这个新配置的唯一ID

  1. 让我们看看我们得到了什么,并使用列表命令来这样做:
$ docker config ls ID                         NAME           CREATED              UPDATED
rrin36epd63pu6w3gqcmlpbz0  hello-config   About a minute ago   About a minute ago

列表命令的输出显示了我们刚刚创建的配置的IDNAME,以及其CREATED和(最后)更新时间。但由于配置是非机密的,我们可以做更多的事情,甚至输出配置的内容,就像这样:

$ docker config docker config inspect hello-config
[
    {
        "ID": "rrin36epd63pu6w3gqcmlpbz0",
        "Version": {
            "Index": 11
        },
        "CreatedAt": "2019-11-30T07:59:20.6340015Z",
        "UpdatedAt": "2019-11-30T07:59:20.6340015Z",
        "Spec": {
            "Name": "hello-config",
            "Labels": {},
            "Data": "SGVsbG8gd29ybGQK"
        }
    }
]

嗯,有趣。在前面的 JSON 格式输出的Spec子节点中,我们有一个Data键,其值为SGVsbG8gd29ybGQK。我们不是刚说过配置数据在静止状态下没有加密吗?原来这个值只是我们的字符串编码为base64,我们可以很容易地验证:

$ echo 'SGVsbG8gd29ybGQK' | base64 -d
Hello world

到目前为止,一切都很好。

现在,让我们定义一个稍微复杂一些的 Docker 配置。假设我们正在开发一个 Java 应用程序。Java 传递配置数据给应用程序的首选方式是使用所谓的“属性”文件。属性文件只是一个包含键值对列表的文本文件。让我们来看一下:

  1. 让我们创建一个名为my-app.properties的文件,并将以下内容添加到其中:
username=pguser
database=products
port=5432
dbhost=postgres.acme.com
  1. 保存文件并从中创建一个名为app.properties的 Docker 配置:
$ docker config create app.properties ./my-app.properties
2yzl73cg4cwny95hyft7fj80u

现在,我们可以使用这个(有些牵强的)命令来获取我们刚刚创建的配置的明文值:

$ docker config inspect app.properties | jq .[].Spec.Data | xargs echo | base64 -d username=pguser
database=products
port=5432
dbhost=postgres.acme.com

这正是我们预期的。

  1. 现在,让我们创建一个使用前述配置的 Docker 服务。为简单起见,我们将使用 nginx 镜像来实现:
$ docker service create \
 --name nginx \
 --config source=app.properties,target=/etc/my-app/conf/app.properties,mode=0440 \
 nginx:1.13-alpine

p3f686vinibdhlnrllnspqpr0
overall progress: 1 out of 1 tasks
1/1: running [==================================================>]
verify: Service converged

在前面的服务create命令中有趣的部分是包含--config的那一行。通过这一行,我们告诉 Docker 使用名为app.properties的配置,并将其挂载为一个文件到容器内的/etc/my-app/conf/app.properties。此外,我们希望该文件具有0440的模式。

让我们看看我们得到了什么:

$ docker service ps nginx
ID            NAME     IMAGE              NODE DESIRED    STATE    CURRENT STATE ...
b8lzzwl3eg6y  nginx.1  nginx:1.13-alpine  node-1  Running  Running 2 minutes ago

在前面的输出中,我们可以看到服务的唯一实例正在节点node-1上运行。在这个节点上,我现在可以列出容器以获取 nginx 实例的ID

$ docker container ls
CONTAINER ID   IMAGE               COMMAND                  CREATED         STATUS         PORTS ...
bde33d92cca7   nginx:1.13-alpine   "nginx -g 'daemon of…"   5 minutes ago   Up 5 minutes   80/tcp ...

最后,我们可以exec进入该容器并输出/etc/my-app/conf/app.properties文件的值:

$ docker exec bde33 cat /etc/my-app/conf/app.properties
username=pguser
database=products
port=5432
dbhost=postgres.acme.com

毫无意外;这正是我们预期的。

当然,Docker 配置也可以从集群中移除,但前提是它们没有被使用。如果我们尝试移除之前使用过的配置,而没有先停止和移除服务,我们会得到以下输出:

$ docker config rm app.properties
Error response from daemon: rpc error: code = InvalidArgument desc = config 'app.properties' is in use by the following service: nginx

我们收到了一个错误消息,其中 Docker 友好地告诉我们该配置正在被我们称为nginx的服务使用。这种行为与我们在使用 Docker 卷时所习惯的有些相似。

因此,首先我们需要移除服务,然后我们可以移除配置:

$ docker service rm nginx
nginx
$ docker config rm app.properties
app.properties

需要再次注意的是,Docker 配置绝不应该用于存储诸如密码、秘钥或访问密钥等机密数据。在下一节中,我们将讨论如何处理机密数据。

使用 Docker secrets 保护敏感数据

秘密用于以安全的方式处理机密数据。Swarm 秘密在静态和传输中是安全的。也就是说,当在管理节点上创建新的秘密时,它只能在管理节点上创建,其值会被加密并存储在 raft 一致性存储中。这就是为什么它在静态时是安全的。如果一个服务被分配了一个秘密,那么管理节点会从存储中读取秘密,解密它,并将其转发给请求秘密的 swarm 服务的所有容器实例。由于 Docker Swarm 中的节点之间通信使用了传输层安全TLS),即使解密了,秘密值在传输中仍然是安全的。管理节点只将秘密转发给服务实例正在运行的工作节点。然后,秘密被挂载为文件到目标容器中。每个秘密对应一个文件。秘密的名称将成为容器内文件的名称,秘密的值将成为相应文件的内容。秘密永远不会存储在工作节点的文件系统上,而是使用tmpFS挂载到容器中。默认情况下,秘密被挂载到容器的/run/secrets目录中,但您可以将其更改为任何自定义文件夹。

需要注意的是,在 Windows 节点上,秘密不会被加密,因为没有类似于tmpfs的概念。为了达到在 Linux 节点上获得的相同安全级别,管理员应该加密相应 Windows 节点的磁盘。

创建秘密

首先,让我们看看我们实际上如何创建一个秘密:

$ echo "sample secret value" | docker secret create sample-secret - 

这个命令创建了一个名为sample-secret的秘密,其值为sample secret value。请注意docker secret create命令末尾的连字符。这意味着 Docker 期望从标准输入获取秘密的值。这正是我们通过将sample secret value值传输到create命令中所做的。

或者,我们可以使用文件作为秘密值的来源:

$ docker secret create other-secret ~/my-secrets/secret-value.txt

在这里,具有名称other-secret的秘密的值是从名为~/my-secrets/secret-value.txt的文件中读取的。一旦创建了一个秘密,就没有办法访问它的值。例如,我们可以列出所有的秘密来获取以下输出:

所有秘密的列表

在这个列表中,我们只能看到秘密的ID名称,以及一些其他元数据,但秘密的实际值是不可见的。我们也可以对秘密使用inspect,例如,获取有关other-secret的更多信息:

检查集群秘密

即使在这里,我们也无法获取秘密的值。当然,这是有意的:秘密是秘密,因此需要保密。如果我们愿意,我们可以为秘密分配标签,甚至可以使用不同的驱动程序来加密和解密秘密,如果我们对 Docker 默认提供的不满意的话。

使用秘密

秘密被用于在集群中运行的服务。通常,秘密在创建服务时分配。因此,如果我们想要运行一个名为web的服务并分配一个名为api-secret-key的秘密,语法如下:

$ docker service create --name web \
 --secret api-secret-key \
 --publish 8000:8000 \
 fundamentalsofdocker/whoami:latest

该命令基于fundamentalsofdocker/whoami:latest镜像创建了一个名为web的服务,将容器端口8000发布到所有集群节点的端口8000,并分配了名为api-secret-key的秘密。

只有在集群中定义了名为api-secret-key的秘密时,这才有效;否则,将生成一个带有文本secret not found: api-secret-key的错误。因此,让我们现在创建这个秘密:

$ echo "my secret key" | docker secret create api-secret-key -

现在,如果我们重新运行服务create命令,它将成功:

使用秘密创建服务

现在,我们可以使用docker service ps web来找出唯一服务实例部署在哪个节点上,然后exec进入这个容器。在我的情况下,该实例已部署到node-3,所以我需要SSH进入该节点:

$ docker-machine ssh node-3

然后,我列出该节点上的所有容器,找到属于我的服务的一个实例并复制其容器 ID。然后,我们可以运行以下命令,确保秘密确实在容器内以明文形式的预期文件名中可用:

$ docker exec -it <container ID> cat /run/secrets/api-secret-key

再次强调,在我的情况下,这看起来是这样的:

容器看到的秘密

如果由于某种原因,Docker 在容器内部挂载秘密的默认位置不可接受,您可以定义一个自定义位置。在下面的命令中,我们将秘密挂载到/app/my-secrets

$ docker service create --name web \
 --name web \
 -p 8000:8000 \
 --secret source=api-secret-key,target=/run/my-secrets/api-secret-key \
 fundamentalsofdocker/whoami:latest

在这个命令中,我们使用了扩展语法来定义一个包括目标文件夹的秘密。

在开发环境中模拟秘密

在开发中,我们通常在本地没有一个 swarm。但是秘密只在 swarm 中起作用。我们能做什么呢?幸运的是,这个答案非常简单。由于秘密被视为文件,我们可以轻松地将包含秘密的卷挂载到容器中的预期位置,这个位置默认为/run/secrets

假设我们在本地工作站上有一个名为./dev-secrets的文件夹。对于每个秘密,我们都有一个与秘密名称相同且具有未加密值的文件作为文件内容。例如,我们可以通过在工作站上执行以下命令来模拟一个名为demo-secret的秘密,其秘密值为demo secret value

$ echo "demo secret value" > ./dev-secrets/sample-secret

然后,我们可以创建一个容器,挂载这个文件夹,就像这样:

$ docker container run -d --name whoami \
 -p 8000:8000 \
 -v $(pwd)/dev-secrets:/run/secrets \
 fundamentalsofdocker/whoami:latest

容器内运行的进程将无法区分这些挂载的文件和来自秘密的文件。因此,例如,demo-secret在容器内作为名为/run/secrets/demo-secret的文件可用,并具有预期值demo secret value。让我们在以下步骤中更详细地看一下这个情况:

  1. 为了测试这一点,我们可以在前面的容器中exec一个 shell:
$ docker container exec -it whoami /bin/bash
  1. 现在,我们可以导航到/run/secrets文件夹,并显示demo-secret文件的内容:
/# cd /run/secrets
/# cat demo-secret
demo secret value

接下来,我们将研究秘密和遗留应用程序。

秘密和遗留应用程序

有时,我们希望将无法轻松或不想更改的遗留应用程序容器化。这个遗留应用程序可能希望将秘密值作为环境变量可用。那么我们现在该怎么办呢? Docker 将秘密呈现为文件,但应用程序期望它们以环境变量的形式存在。

在这种情况下,定义一个在容器启动时运行的脚本是有帮助的(称为入口点或启动脚本)。这个脚本将从相应的文件中读取秘密值,并定义一个与文件名相同的环境变量,将新变量赋予从文件中读取的值。对于一个名为demo-secret的秘密,其值应该在名为DEMO_SECRET的环境变量中可用,这个启动脚本中必要的代码片段可能如下所示:

export DEMO_SECRET=$(cat /run/secrets/demo-secret)

类似地,假设我们有一个旧应用程序,它期望将秘密值作为一个条目存在于位于/app/bin文件夹中的一个名为app.config的 YAML 配置文件中,其相关部分如下所示:

...

secrets:
  demo-secret: "<<demo-secret-value>>"
  other-secret: "<<other-secret-value>>"
  yet-another-secret: "<<yet-another-secret-value>>"
...

我们的初始化脚本现在需要从secret文件中读取秘密值,并用secret值替换配置文件中的相应占位符。对于demo-secret,这可能看起来像这样:

file=/app/bin/app.conf
demo_secret=$(cat /run/secret/demo-secret)
sed -i "s/<<demo-secret-value>>/$demo_secret/g" "$file"

在上面的片段中,我们使用sed工具来替换占位符为实际值。我们可以使用相同的技术来处理配置文件中的其他两个秘密。

我们将所有的初始化逻辑放入一个名为entrypoint.sh的文件中,使该文件可执行,并将其添加到容器文件系统的根目录。然后,在Dockerfile中将此文件定义为ENTRYPOINT,或者我们可以在docker container run命令中覆盖镜像的现有ENTRYPOINT

让我们做一个示例。假设我们有一个旧应用程序运行在由fundamentalsofdocker/whoami:latest镜像定义的容器中,该应用程序期望在应用程序文件夹中的一个名为whoami.conf的文件中定义一个名为db_password的秘密。让我们看看这些步骤:

  1. 我们可以在本地机器上定义一个名为whoami.conf的文件,其中包含以下内容:
database:
  name: demo
  db_password: "<<db_password_value>>"
others:
  val1=123
  val2="hello world"

这个片段的第 3 行是重要的部分。它定义了启动脚本必须放置秘密值的位置。

  1. 让我们在本地文件夹中添加一个名为entrypoint.sh的文件,其中包含以下内容:
file=/app/whoami.conf
db_pwd=$(cat /run/secret/db-password)
sed -i "s/<<db_password_value>>/$db_pwd/g" "$file"

/app/http

上述脚本中的最后一行源自于原始Dockerfile中使用的启动命令。

  1. 现在,将此文件的模式更改为可执行:
$ sudo chmod +x ./entrypoint.sh

现在,我们定义一个继承自fundamentalsofdocker/whoami:latest镜像的Dockerfile

  1. 在当前文件夹中添加一个名为Dockerfile的文件,其中包含以下内容:
FROM fundamentalsofdocker/whoami:latest
COPY ./whoami.conf /app/
COPY ./entrypoint.sh /
CMD ["/entrypoint.sh"]
  1. 让我们从这个Dockerfile构建镜像:
$ docker image build -t secrets-demo:1.0 .
  1. 构建完镜像后,我们可以从中运行一个服务。但在这之前,我们需要在 Swarm 中定义秘密:
$ echo "passw0rD123" | docker secret create demo-secret -
  1. 现在,我们可以创建一个使用以下秘密的服务:
$ docker service create --name demo \
 --secret demo-secret \
 secrets-demo:1.0

更新秘密

有时,我们需要更新运行中的服务中的秘密,因为秘密可能会泄露给公众,或者被恶意人士,如黑客,窃取。在这种情况下,我们需要更改我们的机密数据,因为一旦它泄露给不受信任的实体,它就必须被视为不安全。

更新秘密,就像任何其他更新一样,必须以零停机的方式进行。Docker SwarmKit 在这方面支持我们。

首先,在 Swarm 中创建一个新的秘密。建议在这样做时使用版本控制策略。在我们的例子中,我们使用版本作为秘密名称的后缀。我们最初使用名为db-password的秘密,现在这个秘密的新版本被称为db-password-v2

$ echo "newPassw0rD" | docker secret create db-password-v2 -

让我们假设使用该秘密的原始服务是这样创建的:

$ docker service create --name web \
 --publish 80:80
 --secret db-password
 nginx:alpine

容器内运行的应用程序能够访问/run/secrets/db-password处的秘密。现在,SwarmKit 不允许我们在运行中的服务中更新现有的秘密,因此我们必须删除现在过时的秘密版本,然后添加新的秘密。让我们从以下命令开始删除:

$ docker service update --secret-rm db-password web

现在,我们可以使用以下命令添加新的秘密:

$ docker service update \
 --secret-add source=db-password-v2,target=db-password \
 web

请注意--secret-add的扩展语法,其中包括sourcetarget参数。

摘要

在本章中,我们学习了 SwarmKit 如何允许我们更新服务而不需要停机。我们还讨论了 SwarmKit 在零停机部署方面的当前限制。在本章的第二部分,我们介绍了秘密作为一种以高度安全的方式向服务提供机密数据的手段。

在下一章中,我们将介绍目前最流行的容器编排器 Kubernetes。我们将讨论用于在 Kubernetes 集群中定义和运行分布式、弹性、健壮和高可用应用程序的对象。此外,本章还将使我们熟悉 MiniKube,这是一个用于在本地部署 Kubernetes 应用程序的工具,并演示 Kubernetes 与 Docker for macOS 和 Docker for Windows 的集成。

问题

为了评估您对本章讨论的主题的理解,请回答以下问题:

  1. 用简洁的语句向一个感兴趣的外行解释什么是零停机部署。

  2. SwarmKit 如何实现零停机部署?

  3. 与传统的(非容器化)系统相反,为什么 Docker Swarm 中的回滚可以正常工作?用简短的句子解释一下。

  4. 描述 Docker 秘密的两到三个特征。

  5. 您需要推出inventory服务的新版本。您的命令是什么样的?以下是更多信息:

  • 新镜像名为acme/inventory:2.1

  • 我们希望使用批量大小为两个任务的滚动更新策略。

  • 我们希望系统在每个批次之后等待一分钟。

  1. 您需要更新名为inventory的现有服务的密码,该密码通过 Docker secret 提供。新的秘密称为MYSQL_PASSWORD_V2。服务中的代码期望秘密被称为MYSQL_PASSWORD。更新命令是什么样子?(请注意,我们不希望更改服务的代码!)

更多阅读

以下是一些外部来源的链接:

第四部分:Docker、Kubernetes 和云

在本节中,您将成功地在 Kubernetes 中部署、运行、监控和解决高度分布式的应用程序,无论是在本地还是在云中。

本节包括以下章节:

  • 第十五章,Kubernetes 简介

  • 第十六章,使用 Kubernetes 部署、更新和保护应用程序

  • 第十七章,监控和解决在生产环境中运行的应用程序

  • 第十八章,在云中运行容器化应用程序

第十五章:Kubernetes 简介

在上一章中,我们学习了 SwarmKit 如何使用滚动更新来实现零停机部署。我们还介绍了 Docker 配置文件,用于在集群中存储非敏感数据并用于配置应用程序服务,以及 Docker 秘密,用于与在 Docker Swarm 中运行的应用程序服务共享机密数据。

在本章中,我们将介绍 Kubernetes。Kubernetes 目前是容器编排领域的明显领导者。我们将从高层次概述 Kubernetes 集群的架构开始,然后讨论 Kubernetes 中用于定义和运行容器化应用程序的主要对象。

本章涵盖以下主题:

  • Kubernetes 架构

  • Kubernetes 主节点

  • 集群节点

  • MiniKube 简介

  • Docker for Desktop 中的 Kubernetes 支持

  • Pod 简介

  • Kubernetes ReplicaSet

  • Kubernetes 部署

  • Kubernetes 服务

  • 基于上下文的路由

  • 比较 SwarmKit 和 Kubernetes

完成本章后,您将能够做到以下事项:

  • 在餐巾纸上起草 Kubernetes 集群的高层架构

  • 解释 Kubernetes pod 的三到四个主要特征

  • 用两三句话描述 Kubernetes ReplicaSets 的作用

  • 解释 Kubernetes 服务的两三个主要职责

  • 在 Minikube 中创建一个 pod

  • 配置 Docker for Desktop 以使用 Kubernetes 作为编排器

  • 在 Docker for Desktop 中创建一个部署

  • 创建一个 Kubernetes 服务,将应用程序服务在集群内(或外部)暴露出来

技术要求

本章的代码文件可以在 GitHub 上找到:github.com/PacktPublishing/Learn-Docker---Fundamentals-of-Docker-19.x-Second-Edition。或者,如果您在计算机上克隆了伴随本书的 GitHub 存储库,如第二章中所述,设置工作环境,那么您可以在~/fod-solution/ch15找到代码。

Kubernetes 架构

Kubernetes 集群由一组服务器组成。这些服务器可以是虚拟机或物理服务器。后者也被称为裸金属。集群的每个成员可以扮演两种角色中的一种。它要么是 Kubernetes 主节点,要么是(工作)节点。前者用于管理集群,而后者将运行应用程序工作负载。我在工作节点中加了括号,因为在 Kubernetes 术语中,只有在谈论运行应用程序工作负载的服务器时才会谈论节点。但在 Docker 术语和 Swarm 中,相当于的是工作节点。我认为工作节点这个概念更好地描述了服务器的角色,而不仅仅是一个节点

在一个集群中,你会有少量奇数个的主节点和所需数量的工作节点。小集群可能只有几个工作节点,而更现实的集群可能有数十甚至数百个工作节点。从技术上讲,集群可以拥有无限数量的工作节点;但实际上,当处理数千个节点时,你可能会在一些管理操作中遇到显著的减速。集群的所有成员都需要通过一个物理网络连接,即所谓的底层网络

Kubernetes 为整个集群定义了一个扁平网络。Kubernetes 不会提供任何开箱即用的网络实现;相反,它依赖于第三方的插件。Kubernetes 只是定义了容器网络接口(CNI),并将实现留给其他人。CNI 非常简单。它基本上规定了集群中运行的每个 pod 必须能够在不经过任何网络地址转换(NAT)的情况下到达集群中运行的任何其他 pod。集群节点和 pod 之间也必须是如此,也就是说,直接在集群节点上运行的应用程序或守护程序必须能够到达集群中的每个 pod,反之亦然。

下图说明了 Kubernetes 集群的高级架构:

Kubernetes 的高级架构图

前面的图解释如下:

  • 在顶部中间,我们有一组etcd节点。etcd是一个分布式键值存储,在 Kubernetes 集群中用于存储集群的所有状态。etcd节点的数量必须是奇数,根据 Raft 共识协议的规定,该协议规定了用于彼此协调的节点。当我们谈论集群状态时,我们不包括集群中运行的应用程序产生或消耗的数据;相反,我们谈论的是集群拓扑的所有信息,正在运行的服务,网络设置,使用的密钥等。也就是说,这个etcd集群对整个集群非常关键,因此,在生产环境或需要高可用性的任何环境中,我们永远不应该只运行单个etcd服务器。

  • 然后,我们有一组 Kubernetes master节点,它们也形成一个共识组,类似于etcd节点。主节点的数量也必须是奇数。我们可以使用单个主节点运行集群,但在生产或关键系统中绝不能这样做。在那里,我们应该始终至少有三个主节点。由于主节点用于管理整个集群,我们也在谈论管理平面。主节点使用etcd集群作为其后备存储。在主节点前面放置一个负载均衡器LB)是一个良好的做法,具有一个众所周知的完全合格的域名FQDN),例如https://admin.example.com。用于管理 Kubernetes 集群的所有工具都应该通过这个 LB 访问,而不是使用其中一个主节点的公共 IP 地址。这在上图的左上方显示。

  • 图表底部,我们有一组worker节点。节点数量可以低至一个,没有上限。Kubernetes 的主节点和工作节点之间进行通信。这是一种双向通信,与我们从 Docker Swarm 中所知的通信方式不同。在 Docker Swarm 中,只有管理节点与工作节点通信,而不是相反。访问集群中运行的应用程序的所有入口流量都应该通过另一个负载均衡器。这是应用程序负载均衡器或反向代理。我们永远不希望外部流量直接访问任何工作节点。

现在我们对 Kubernetes 集群的高级架构有了一个概念,让我们深入一点,看看 Kubernetes 的主节点和工作节点。

Kubernetes 主节点

Kubernetes 主节点用于管理 Kubernetes 集群。以下是这样一个主节点的高级图表:

Kubernetes 主节点

在上图的底部,我们有基础设施,它可以是本地或云端的虚拟机,也可以是本地或云端的服务器(通常称为裸金属)。目前,Kubernetes 主节点只能在Linux上运行。支持最流行的 Linux 发行版,如 RHEL、CentOS 和 Ubuntu。在这台 Linux 机器上,我们至少运行以下四个 Kubernetes 服务:

  • API 服务器:这是 Kubernetes 的网关。所有对集群中任何资源进行列出、创建、修改或删除的请求都必须通过这个服务。它暴露了一个 REST 接口,像kubectl这样的工具用来管理集群和集群中的应用程序。

  • 控制器:控制器,或者更准确地说是控制器管理器,是一个控制循环,通过 API 服务器观察集群的状态并进行更改,试图将当前状态或有效状态移向期望的状态,如果它们不同。

  • 调度器:调度器是一个服务,它尽力在考虑各种边界条件时将 pod 调度到工作节点上,例如资源需求、策略、服务质量需求等。

  • 集群存储:这是一个 etcd 的实例,用于存储集群状态的所有信息。

更准确地说,作为集群存储使用的 etcd 不一定要安装在与其他 Kubernetes 服务相同的节点上。有时,Kubernetes 集群配置为使用独立的 etcd 服务器集群,就像在前一节的架构图中所示。但使用哪种变体是一个高级管理决策,超出了本书的范围。

我们至少需要一个主节点,但为了实现高可用性,我们需要三个或更多的主节点。这与我们所学习的 Docker Swarm 的管理节点非常相似。在这方面,Kubernetes 的主节点相当于 Swarm 的管理节点。

Kubernetes 主节点从不运行应用负载。它们的唯一目的是管理集群。Kubernetes 主节点构建 Raft 一致性组。Raft 协议是一种标准协议,用于需要做出决策的成员组的情况。它被用于许多知名软件产品,如 MongoDB、Docker SwarmKit 和 Kubernetes。有关 Raft 协议的更详细讨论,请参见进一步阅读部分中的链接。

正如我们在前一节中提到的,Kubernetes 集群的状态存储在 etcd 中。如果 Kubernetes 集群应该是高可用的,那么 etcd 也必须配置为 HA 模式,这通常意味着我们至少有三个运行在不同节点上的 etcd 实例。

让我们再次声明,整个集群状态存储在 etcd 中。这包括所有集群节点的所有信息,所有副本集、部署、秘密、网络策略、路由信息等等。因此,对于这个键值存储,我们必须有一个强大的备份策略。

现在,让我们来看看将运行集群实际工作负载的节点。

集群节点

集群节点是 Kubernetes 调度应用负载的节点。它们是集群的工作马。Kubernetes 集群可以有少数、几十个、上百个,甚至上千个集群节点。Kubernetes 是从头开始构建的,具有高可扩展性。不要忘记,Kubernetes 是模仿 Google Borg 而建立的,Google Borg 多年来一直在运行数万个容器:

Kubernetes 工作节点

工作节点可以在虚拟机、裸机、本地或云上运行。最初,工作节点只能在 Linux 上配置。但自 Kubernetes 1.10 版本以来,工作节点也可以在 Windows Server 上运行。在混合集群中拥有 Linux 和 Windows 工作节点是完全可以的。

在每个节点上,我们需要运行三个服务,如下:

  • Kubelet:这是第一个,也是最重要的服务。Kubelet 是主要的节点代理。kubelet 服务使用 pod 规范来确保相应 pod 的所有容器都在运行并且健康。Pod 规范是以 YAML 或 JSON 格式编写的文件,它们以声明方式描述一个 pod。我们将在下一节了解什么是 pod。Pod 规范主要通过 API 服务器提供给 kubelet。

  • 容器运行时:每个工作节点上需要存在的第二个服务是容器运行时。Kubernetes 默认从 1.9 版本开始使用containerd作为其容器运行时。在那之前,它使用 Docker 守护程序。其他容器运行时,如 rkt 或 CRI-O,也可以使用。容器运行时负责管理和运行 pod 中的各个容器。

  • kube-proxy:最后,还有 kube-proxy。它作为一个守护进程运行,是一个简单的网络代理和负载均衡器,用于运行在该特定节点上的所有应用服务。

现在我们已经了解了 Kubernetes 的架构、主节点和工作节点,是时候介绍一下我们可以用来开发针对 Kubernetes 的应用程序的工具了。

Minikube 简介

Minikube 是一个工具,它在 VirtualBox 或 Hyper-V 中创建一个单节点 Kubernetes 集群(其他虚拟化程序也支持),可以在开发容器化应用程序期间使用。在第二章《设置工作环境》中,我们了解了如何在我们的 macOS 或 Windows 笔记本电脑上安装 Minikube 和kubectl。正如在那里所述,Minikube 是一个单节点 Kubernetes 集群,因此该节点同时也是 Kubernetes 主节点和工作节点。

让我们确保 Minikube 正在运行,使用以下命令:

$ minikube start

一旦 Minikube 准备就绪,我们可以使用kubectl访问它的单节点集群。我们应该会看到类似以下的内容:

列出 Minikube 中的所有节点

正如我们之前提到的,我们有一个名为minikube的单节点集群。Minikube 使用的 Kubernetes 版本是v1.16.2(在我的情况下)。

现在,让我们尝试将一个 pod 部署到这个集群中。现在不要担心 pod 是什么;我们将在本章后面深入了解所有细节。暂时就按原样进行。

我们可以使用labs文件夹中ch15子文件夹中的sample-pod.yaml文件来创建这样一个 pod。它的内容如下:

apiVersion: v1
kind: Pod
metadata:
  name: nginx
spec:
  containers:
  - name: nginx
    image: nginx:alpine
    ports:
    - containerPort: 80
    - containerPort: 443

使用以下步骤运行 pod:

  1. 首先,导航到正确的文件夹:
$ cd ~/fod/ch15
  1. 现在,让我们使用名为kubectl的 Kubernetes CLI 来部署这个 pod:
$ kubectl create -f sample-pod.yaml
pod/nginx created

如果我们现在列出所有的 pod,我们应该会看到以下内容:

$ kubectl get pods
NAME    READY   STATUS    RESTARTS   AGE
nginx   1/1     Running   0          51s
  1. 为了能够访问这个 pod,我们需要创建一个服务。让我们使用名为sample-service.yaml的文件,它的内容如下:
apiVersion: v1
kind: Service
metadata:
  name: nginx-service
spec:
  type: LoadBalancer
  ports:
  - port: 8080
    targetPort: 80
    protocol: TCP
  selector:
    app: nginx
  1. 再次强调,现在不用担心服务是什么。我们稍后会解释这个。让我们创建这个服务:
$ kubectl create -f sample-service.yaml
  1. 现在,我们可以使用curl来访问服务:
$ curl -4 http://localhost

我们应该收到 Nginx 欢迎页面作为答案。

  1. 在继续之前,请删除刚刚创建的两个对象:
$ kubectl delete po/nginx
$ kubectl delete svc/nginx-service

Docker for Desktop 中的 Kubernetes 支持

从版本 18.01-ce 开始,Docker for macOS 和 Docker for Windows 已经开始默认支持 Kubernetes。想要将其容器化应用程序部署到 Kubernetes 的开发人员可以使用这个编排器,而不是 SwarmKit。Kubernetes 支持默认关闭,必须在设置中启用。第一次启用 Kubernetes 时,Docker for macOS 或 Windows 需要一些时间来下载创建单节点 Kubernetes 集群所需的所有组件。与 Minikube 相反,后者也是单节点集群,Docker 工具提供的版本使用所有 Kubernetes 组件的容器化版本:

Docker for macOS 和 Windows 中的 Kubernetes 支持

上图大致概述了 Kubernetes 支持是如何添加到 Docker for macOS 和 Windows 中的。Docker for macOS 使用 hyperkit 来运行基于 LinuxKit 的 VM。Docker for Windows 使用 Hyper-V 来实现结果。在 VM 内部,安装了 Docker 引擎。引擎的一部分是 SwarmKit,它启用了Swarm-Mode。Docker for macOS 或 Windows 使用kubeadm工具在 VM 中设置和配置 Kubernetes。以下三个事实值得一提:Kubernetes 将其集群状态存储在etcd中,因此我们在此 VM 上运行etcd。然后,我们有组成 Kubernetes 的所有服务,最后,一些支持从Docker CLI部署 Docker 堆栈到 Kubernetes 的服务。这项服务不是官方 Kubernetes 发行版的一部分,但它是特定于 Docker 的。

所有 Kubernetes 组件都在LinuxKit VM中以容器形式运行。这些容器可以通过 Docker for macOS 或 Windows 中的设置进行隐藏。在本节的后面,我们将提供在您的笔记本电脑上运行的所有 Kubernetes 系统容器的完整列表,如果您启用了 Kubernetes 支持。为避免重复,从现在开始,我将只谈论 Docker for Desktop 而不是 Docker for macOS 和 Docker for Windows。我将要说的一切同样适用于两个版本。

启用 Docker Desktop 的 Kubernetes 的一个很大优势是,它允许开发人员使用单个工具构建、测试和运行针对 Kubernetes 的容器化应用程序。甚至可以使用 Docker Compose 文件将多服务应用程序部署到 Kubernetes。

现在,让我们动手:

  1. 首先,我们必须启用 Kubernetes。在 macOS 上,点击菜单栏中的 Docker 图标;或者在 Windows 上,转到命令托盘并选择“首选项”。在打开的对话框中,选择 Kubernetes,如下面的屏幕截图所示:

在 Docker Desktop 中启用 Kubernetes

  1. 然后,选中“启用 Kubernetes”复选框。还要选中“默认情况下将 Docker 堆叠部署到 Kubernetes”和“显示系统容器(高级)”复选框。然后,点击“应用并重启”按钮。安装和配置 Kubernetes 需要几分钟。现在,是时候休息一下,享受一杯好茶了。

  2. 安装完成后(Docker 通过在设置对话框中显示绿色状态图标来通知我们),我们可以进行测试。由于我们现在在笔记本电脑上运行了两个 Kubernetes 集群,即 Minikube 和 Docker Desktop,我们需要配置kubectl以访问后者。

首先,让我们列出所有我们拥有的上下文:

kubectl 的上下文列表

在这里,我们可以看到,在我的笔记本电脑上,我有之前提到的两个上下文。当前,Minikube 上下文仍然处于活动状态,在CURRENT列中标有星号。我们可以使用以下命令切换到docker-for-desktop上下文:

更改 Kubernetes CLI 的上下文

现在,我们可以使用kubectl来访问 Docker Desktop 刚刚创建的集群。我们应该看到以下内容:

Docker Desktop 创建的单节点 Kubernetes 集群

好的,这看起来非常熟悉。这几乎与我们在使用 Minikube 时看到的一样。我的 Docker Desktop 使用的 Kubernetes 版本是1.15.5。我们还可以看到节点是主节点。

如果我们列出当前在 Docker Desktop 上运行的所有容器,我们将得到下面截图中显示的列表(请注意,我使用--format参数来输出容器的Container IDNames):

Kubernetes 系统容器

在前面的列表中,我们可以识别出组成 Kubernetes 的所有熟悉组件,如下所示:

  • API 服务器

  • etcd

  • Kube 代理

  • DNS 服务

  • Kube 控制器

  • Kube 调度程序

还有一些容器中带有compose一词。这些是特定于 Docker 的服务,允许我们将 Docker Compose 应用程序部署到 Kubernetes 上。Docker 将 Docker Compose 语法进行转换,并隐式创建必要的 Kubernetes 对象,如部署、Pod 和服务。

通常,我们不希望在容器列表中混杂这些系统容器。因此,我们可以在 Kubernetes 的设置中取消选中“显示系统容器(高级)”复选框。

现在,让我们尝试将 Docker Compose 应用程序部署到 Kubernetes。转到~/fod文件夹的ch15子文件夹。我们使用docker-compose.yml文件将应用程序部署为堆栈:

$ docker stack deploy -c docker-compose.yml app

我们应该看到以下内容:

将堆栈部署到 Kubernetes

我们可以使用curl来测试应用程序,并且会发现它按预期运行:

在 Docker 桌面上的 Kubernetes 中运行的宠物应用程序

现在,让我们看看在执行docker stack deploy命令时 Docker 到底做了什么。我们可以使用kubectl来找出:

列出由 docker stack deploy 创建的所有 Kubernetes 对象

Docker 为web服务创建了一个部署,为db服务创建了一个有状态集。它还自动为webdb创建了 Kubernetes 服务,以便它们可以在集群内部访问。它还创建了 Kubernetes svc/web-published服务,用于外部访问。

这相当酷,至少可以说,极大地减少了团队在开发过程中针对 Kubernetes 作为编排平台时的摩擦

在继续之前,请从集群中删除堆栈:

$ docker stack rm app

还要确保将kubectl的上下文重置回 Minikube,因为我们将在本章中使用 Minikube 进行所有示例:

$ kubectl config use-context minikube

现在,我们已经介绍了用于开发最终将在 Kubernetes 集群中运行的应用程序的工具,是时候了解用于定义和管理这样的应用程序的所有重要 Kubernetes 对象了。我们将从 Pod 开始。

Pod 简介

与 Docker Swarm 中可能的情况相反,在 Kubernetes 集群中不能直接运行容器。在 Kubernetes 集群中,您只能运行 Pod。Pod 是 Kubernetes 中部署的原子单位。Pod 是一个或多个共同定位的容器的抽象,它们共享相同的内核命名空间,如网络命名空间。在 Docker SwarmKit 中不存在等价物。多个容器可以共同定位并共享相同的网络命名空间的事实是一个非常强大的概念。下图说明了两个 Pod:

Kubernetes pods

在上图中,我们有两个 Pod,Pod 1Pod 2。第一个 Pod 包含两个容器,而第二个 Pod 只包含一个容器。每个 Pod 都由 Kubernetes 分配一个 IP 地址,在整个 Kubernetes 集群中是唯一的。在我们的情况下,它们的 IP 地址分别是:10.0.12.310.0.12.5。它们都是由 Kubernetes 网络驱动程序管理的私有子网的一部分。

一个 Pod 可以包含一个到多个容器。所有这些容器共享相同的 Linux 内核命名空间,特别是它们共享网络命名空间。这是由包围容器的虚线矩形表示的。由于在同一个 Pod 中运行的所有容器共享网络命名空间,因此每个容器都需要确保使用自己的端口,因为在单个网络命名空间中不允许重复端口。在这种情况下,在Pod 1中,主容器使用端口80,而支持容器使用端口3000

来自其他 Pod 或节点的请求可以使用 Pod 的 IP 地址和相应的端口号来访问各个容器。例如,您可以通过10.0.12.3:80访问Pod 1中主容器中运行的应用程序。

比较 Docker 容器和 Kubernetes Pod 网络

现在,让我们比较一下 Docker 的容器网络和 Kubernetes 的 Pod 网络。在下图中,我们将前者放在左侧,后者放在右侧:

Pod 中的容器共享相同的网络命名空间

当创建一个 Docker 容器并且没有指定特定的网络时,Docker 引擎会创建一个虚拟以太网(veth)端点。第一个容器得到veth0,下一个得到veth1,以此类推。这些虚拟以太网端点连接到 Linux 桥docker0,Docker 在安装时自动创建。流量从docker0桥路由到每个连接的veth端点。每个容器都有自己的网络命名空间。没有两个容器使用相同的命名空间。这是有意为之,目的是隔离容器内运行的应用程序。

对于 Kubernetes pod,情况是不同的。在创建一个新的 pod 时,Kubernetes 首先创建一个所谓的pause容器,其唯一目的是创建和管理 pod 将与所有容器共享的命名空间。除此之外,它没有任何有用的功能;它只是在睡觉。pause容器通过veth0连接到docker0桥。任何随后成为 pod 一部分的容器都使用 Docker 引擎的一个特殊功能,允许它重用现有的网络命名空间。这样做的语法看起来像这样:

$ docker container create --net container:pause ... 

重要的部分是--net参数,它使用container:<container name>作为值。如果我们以这种方式创建一个新容器,那么 Docker 不会创建一个新的 veth 端点;容器使用与pause容器相同的端点。

多个容器共享相同的网络命名空间的另一个重要后果是它们相互通信的方式。让我们考虑以下情况:一个包含两个容器的 pod,一个在端口80上监听,另一个在端口3000上监听。

Pod 中的容器通过 localhost 通信

当两个容器使用相同的 Linux 内核网络命名空间时,它们可以通过 localhost 相互通信,类似于当两个进程在同一主机上运行时,它们也可以通过 localhost 相互通信。这在前面的图表中有所说明。从主容器中,其中的容器化应用程序可以通过http://localhost:3000访问支持容器内运行的服务。

共享网络命名空间

在所有这些理论之后,你可能会想知道 Kubernetes 是如何实际创建一个 Pod 的。Kubernetes 只使用 Docker 提供的内容。那么,这个网络命名空间共享是如何工作的呢?首先,Kubernetes 创建所谓的pause容器,如前所述。这个容器除了保留内核命名空间给该 Pod 并保持它们的活动状态外,没有其他功能,即使 Pod 内没有其他容器在运行。然后,我们模拟创建一个 Pod。我们首先创建pause容器,并使用 Nginx 来实现这个目的:

$ docker container run -d --name pause nginx:alpine

现在,我们添加一个名为main的第二个容器,将其附加到与pause容器相同的网络命名空间:

$ docker container run --name main -dit \
 --net container:pause \
 alpine:latest /bin/sh

由于pause和示例容器都是同一个网络命名空间的一部分,它们可以通过localhost相互访问。为了证明这一点,我们必须exec进入主容器:

$ docker exec -it main /bin/sh

现在,我们可以测试连接到运行在pause容器中并监听端口80的 Nginx。如果我们使用wget工具来做到这一点,我们会得到以下结果:

两个共享相同网络命名空间的容器

输出显示我们确实可以在localhost上访问 Nginx。这证明了这两个容器共享相同的命名空间。如果这还不够,我们可以使用ip工具来显示两个容器内部的eth0,我们将得到完全相同的结果,具体来说,相同的 IP 地址,这是 Pod 的特征之一,所有容器共享相同的 IP 地址:

使用ip工具显示eth0的属性

如果我们检查bridge网络,我们会看到只有pause容器被列出。另一个容器没有在Containers列表中得到条目,因为它正在重用pause容器的端点:

检查 Docker 默认桥接网络

接下来,我们将研究 Pod 的生命周期。

Pod 的生命周期

在本书的前面,我们学到了容器有一个生命周期。容器被初始化,运行,最终退出。当一个容器退出时,它可以以退出码零的方式优雅地退出,也可以以错误终止,这相当于非零的退出码。

同样,一个 Pod 也有一个生命周期。由于一个 Pod 可以包含多个容器,因此其生命周期比单个容器的生命周期稍微复杂一些。Pod 的生命周期可以在下图中看到:

Kubernetes Pod 的生命周期

当在集群节点上创建一个Pod时,它首先进入pending状态。一旦所有的 Pod 容器都启动并运行,Pod 就会进入running状态。只有当所有容器成功运行时,Pod 才会进入这个状态。如果要求 Pod 终止,它将请求所有容器终止。如果所有容器以退出码零终止,那么 Pod 就会进入succeeded状态。这是一条顺利的路径。

现在,让我们看一些导致 Pod 处于 failed 状态的情景。有三种可能的情景:

  • 如果在 Pod 启动过程中,至少有一个容器无法运行并失败(即以非零退出码退出),Pod 将从pending状态转换为failed状态。

  • 如果 Pod 处于 running 状态,而其中一个容器突然崩溃或以非零退出码退出,那么 Pod 将从 running 状态转换为 failed 状态。

  • 如果要求 Pod 终止,并且在关闭过程中至少有一个容器以非零退出码退出,那么 Pod 也会进入 failed 状态。

现在,让我们来看一下 Pod 的规范。

Pod 规范

在 Kubernetes 集群中创建一个 Pod 时,我们可以使用命令式或声明式方法。我们之前在本书中讨论过这两种方法的区别,但是,重申最重要的一点,使用声明式方法意味着我们编写一个描述我们想要实现的最终状态的清单。我们将略去编排器的细节。我们想要实现的最终状态也被称为desired state。一般来说,在所有已建立的编排器中,声明式方法都是强烈推荐的,Kubernetes 也不例外。

因此,在本章中,我们将专注于声明式方法。Pod 的清单或规范可以使用 YAML 或 JSON 格式编写。在本章中,我们将专注于 YAML,因为它对我们人类来说更容易阅读。让我们看一个样本规范。这是pod.yaml文件的内容,可以在我们的labs文件夹的ch12子文件夹中找到:

apiVersion: v1
kind: Pod
metadata:
  name: web-pod
spec:
  containers:
  - name: web
    image: nginx:alpine
    ports:
    - containerPort: 80

Kubernetes 中的每个规范都以版本信息开头。Pods 已经存在了相当长的时间,因此 API 版本是v1。第二行指定了我们要定义的 Kubernetes 对象或资源的类型。显然,在这种情况下,我们要指定一个Pod。接下来是包含元数据的块。至少,我们需要给 pod 一个名称。在这里,我们称其为web-pod。接下来跟随的是spec块,其中包含 pod 的规范。最重要的部分(也是这个简单示例中唯一的部分)是这个 pod 中所有容器的列表。我们这里只有一个容器,但是多个容器是可能的。我们为容器选择的名称是web,容器镜像是nginx:alpine。最后,我们定义了容器正在暴露的端口列表。

一旦我们编写了这样的规范,我们就可以使用 Kubernetes CLI kubectl将其应用到集群中。在终端中,导航到ch15子文件夹,并执行以下命令:

$ kubectl create -f pod.yaml

这将回应pod "web-pod" created。然后我们可以使用kubectl get pods列出集群中的所有 pod:

$ kubectl get pods
NAME      READY   STATUS    RESTARTS   AGE
web-pod   1/1     Running   0          2m

正如预期的那样,我们在运行状态中有一个 pod。该 pod 被称为web-pod,如所定义。我们可以使用describe命令获取有关运行中 pod 的更详细信息:

描述运行在集群中的 pod

请注意在前面的describe命令中的pod/web-pod表示法。其他变体也是可能的;例如,pods/web-podpo/web-podpodpopods的别名。kubectl工具定义了许多别名,使我们的生活变得更加轻松。

describe命令为我们提供了关于 pod 的大量有价值的信息,其中包括发生的事件列表,以及影响了这个 pod 的事件。列表显示在输出的末尾。

Containers部分中的信息与docker container inspect输出中的信息非常相似。

我们还可以看到Volumes部分中有一个Secret类型的条目。我们将在下一章讨论 Kubernetes secrets。另一方面,卷将在下一章讨论。

Pods 和 volumes

在第五章中,数据卷和配置,我们学习了卷及其目的:访问和存储持久数据。由于容器可以挂载卷,Pod 也可以这样做。实际上,实际上是 Pod 内的容器挂载卷,但这只是一个语义细节。首先,让我们看看如何在 Kubernetes 中定义卷。Kubernetes 支持大量的卷类型,所以我们不会深入讨论这个问题。让我们通过隐式定义一个名为my-data-claimPersistentVolumeClaim来创建一个本地卷:

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: my-data-claim
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 2Gi

我们已经定义了一个请求 2GB 数据的声明。让我们创建这个声明:

$ kubectl create -f volume-claim.yaml

我们可以使用kubectl列出声明(pvcPersistentVolumeClaim的快捷方式):

在集群中列出持久存储声明对象

在输出中,我们可以看到声明已经隐式创建了一个名为pvc-<ID>的卷。我们现在准备在 Pod 中使用声明创建的卷。让我们使用之前使用的 Pod 规范的修改版本。我们可以在ch12文件夹中的pod-with-vol.yaml文件中找到这个更新的规范。让我们详细看一下这个规范:

apiVersion: v1
kind: Pod
metadata:
  name: web-pod
spec:
  containers:
  - name: web
    image: nginx:alpine
    ports:
    - containerPort: 80
    volumeMounts:
    - name: my-data
      mountPath: /data
  volumes:
  - name: my-data
    persistentVolumeClaim:
      claimName: my-data-claim

在最后四行中,在volumes块中,我们定义了我们想要为这个 Pod 使用的卷的列表。我们在这里列出的卷可以被 Pod 的任何一个容器使用。在我们的特定情况下,我们只有一个卷。我们指定我们有一个名为my-data的卷,这是一个持久卷声明,其声明名称就是我们刚刚创建的。然后,在容器规范中,我们有volumeMounts块,这是我们定义我们想要使用的卷以及容器内部的(绝对)路径的地方,卷将被挂载到容器文件系统的/data文件夹。让我们创建这个 Pod:

$ kubectl create -f pod-with-vol.yaml

然后,我们可以通过exec进入容器,通过导航到/data文件夹,创建一个文件,并退出容器来再次检查卷是否已挂载:

$ kubectl exec -it web-pod -- /bin/sh
/ # cd /data
/data # echo "Hello world!" > sample.txt
/data # exit

如果我们是正确的,那么这个容器中的数据必须在 Pod 的生命周期之外持续存在。因此,让我们删除 Pod,然后重新创建它并进入其中,以确保数据仍然存在。这是结果:

存储在卷中的数据在 Pod 重新创建时仍然存在

现在我们对 pod 有了很好的理解,让我们来看看如何借助 ReplicaSets 来管理这些 pod。

Kubernetes ReplicaSet

在具有高可用性要求的环境中,单个 pod 是不够的。如果 pod 崩溃了怎么办?如果我们需要更新 pod 内运行的应用程序,但又不能承受任何服务中断怎么办?这些问题等等表明单独的 pod 是不够的,我们需要一个可以管理多个相同 pod 实例的更高级概念。在 Kubernetes 中,ReplicaSet 用于定义和管理在不同集群节点上运行的相同 pod 的集合。除其他事项外,ReplicaSet 定义了在 pod 内运行的容器使用哪些容器镜像,以及集群中将运行多少个 pod 实例。这些属性和许多其他属性被称为所需状态。

ReplicaSet 负责始终协调所需的状态,如果实际状态偏离所需状态。这是一个 Kubernetes ReplicaSet:

Kubernetes ReplicaSet

在前面的图表中,我们可以看到一个名为 rs-api 的 ReplicaSet,它管理着一些 pod。这些 pod 被称为 pod-api。ReplicaSet 负责确保在任何给定时间,始终有所需数量的 pod 在运行。如果其中一个 pod 因任何原因崩溃,ReplicaSet 会在具有空闲资源的节点上安排一个新的 pod。如果 pod 的数量超过所需数量,那么 ReplicaSet 会终止多余的 pod。通过这种方式,我们可以说 ReplicaSet 保证了一组 pod 的自愈和可伸缩性。ReplicaSet 可以容纳多少个 pod 没有限制。

ReplicaSet 规范

与我们对 pod 的学习类似,Kubernetes 也允许我们以命令式或声明式方式定义和创建 ReplicaSet。由于在大多数情况下,声明式方法是最推荐的方法,我们将集中讨论这种方法。以下是一个 Kubernetes ReplicaSet 的样本规范:

apiVersion: apps/v1
kind: ReplicaSet
metadata:
  name: rs-web
spec:
  selector:
    matchLabels:
      app: web
  replicas: 3
  template: 
    metadata:
      labels:
        app: web
    spec:
      containers:
      - name: nginx
        image: nginx:alpine
        ports:
        - containerPort: 80

这看起来非常像我们之前介绍的 Pod 规范。让我们集中精力关注不同之处。首先,在第 2 行,我们有kind,它曾经是Pod,现在是ReplicaSet。然后,在第 6-8 行,我们有一个选择器,它确定将成为ReplicaSet一部分的 Pods。在这种情况下,它是所有具有app标签值为web的 Pods。然后,在第 9 行,我们定义了我们想要运行的 Pod 的副本数量;在这种情况下是三个。最后,我们有template部分,首先定义了metadata,然后定义了spec,它定义了在 Pod 内运行的容器。在我们的情况下,我们有一个使用nginx:alpine镜像并导出端口80的单个容器。

真正重要的元素是副本的数量和选择器,它指定了由ReplicaSet管理的 Pod 集合。

在我们的ch15文件夹中,有一个名为replicaset.yaml的文件,其中包含了前面的规范。让我们使用这个文件来创建ReplicaSet

$ kubectl create -f replicaset.yaml
replicaset "rs-web" created

如果我们列出集群中的所有 ReplicaSets,我们会得到以下结果(rsreplicaset的缩写):

$ kubectl get rs
NAME     DESIRED   CURRENT   READY   AGE
rs-web   3         3         3       51s

在上面的输出中,我们可以看到我们有一个名为rs-web的单个 ReplicaSet,其期望状态为三(个 Pods)。当前状态也显示了三个 Pods,并告诉我们所有三个 Pods 都已准备就绪。我们还可以列出系统中的所有 Pods。这将导致以下输出:

$ kubectl get pods
NAME           READY   STATUS    RESTARTS   AGE
rs-web-6qzld   1/1     Running   0          4m
rs-web-frj2m   1/1     Running   0          4m
rs-web-zd2kt   1/1     Running   0          4m

在这里,我们可以看到我们期望的三个 Pods。Pods 的名称使用 ReplicaSet 的名称,并为每个 Pod 附加了唯一的 ID。在READY列中,我们可以看到在 Pod 中定义了多少个容器以及其中有多少个是就绪的。在我们的情况下,每个 Pod 只有一个容器,并且每种情况下都已准备就绪。因此,Pod 的整体状态是Running。我们还可以看到每个 Pod 需要重新启动的次数。在我们的情况下,我们没有任何重新启动。

自愈

现在,让我们测试自愈ReplicaSet的魔力,随机杀死其中一个 Pod 并观察发生了什么。让我们从前面的列表中删除第一个 Pod:

$ kubectl delete po/rs-web-6qzld
pod "rs-web-6qzld" deleted

现在,让我们再次列出所有的 Pods。我们期望只看到两个 Pods,对吗?错了:

杀死 ReplicaSet 中一个 Pod 后的 Pod 列表

好的;显然,列表中的第二个 Pod 已经被重新创建,我们可以从AGE列中看到。这就是自动修复的工作。让我们看看如果我们描述 ReplicaSet 会发现什么:

描述 ReplicaSet

确实,在“事件”下我们找到了一个条目,告诉我们 ReplicaSet 创建了名为 rs-web-q6cr7 的新 pod。

Kubernetes 部署

Kubernetes 非常严肃地遵循单一责任原则。所有 Kubernetes 对象都被设计成只做一件事,并且它们被设计得非常出色。在这方面,我们必须了解 Kubernetes 的 ReplicaSets 和 Deployments。正如我们所学到的,ReplicaSet 负责实现和协调应用服务的期望状态。这意味着 ReplicaSet 管理一组 pod。

部署通过在 ReplicaSet 的基础上提供滚动更新和回滚功能来增强 ReplicaSet。在 Docker Swarm 中,Swarm 服务结合了 ReplicaSet 和部署的功能。在这方面,SwarmKit 比 Kubernetes 更加单片化。下图显示了部署与 ReplicaSet 的关系:

Kubernetes 部署

在上图中,ReplicaSet 定义和管理一组相同的 pod。ReplicaSet 的主要特点是它是自愈的、可扩展的,并且始终尽最大努力协调期望状态。而 Kubernetes 部署则为此添加了滚动更新和回滚功能。在这方面,部署实际上是对 ReplicaSet 的包装对象。

我们将在第十六章《使用 Kubernetes 部署、更新和保护应用程序》中学习滚动更新和回滚。

在下一节中,我们将更多地了解 Kubernetes 服务以及它们如何实现服务发现和路由。

Kubernetes 服务

一旦我们开始处理由多个应用服务组成的应用程序,我们就需要服务发现。下图说明了这个问题:

服务发现

在上图中,我们有一个需要访问其他三个服务的Web API服务:支付运输订购Web API不应该关心如何以及在哪里找到这三个服务。在 API 代码中,我们只想使用我们想要到达的服务的名称和端口号。一个示例是以下 URL http://payments:3000,用于访问支付服务的一个实例。

在 Kubernetes 中,支付应用程序服务由一组 Pod 的 ReplicaSet 表示。由于高度分布式系统的性质,我们不能假设 Pod 具有稳定的端点。一个 Pod 可能随心所欲地出现和消失。但是,如果我们需要从内部或外部客户端访问相应的应用程序服务,这就是一个问题。如果我们不能依赖于 Pod 端点的稳定性,我们还能做什么呢?

这就是 Kubernetes 服务发挥作用的地方。它们旨在为 ReplicaSets 或 Deployments 提供稳定的端点,如下所示:

Kubernetes 服务为客户端提供稳定的端点

在上图中,中心位置有一个这样的 Kubernetes Service。它提供了一个可靠的集群范围IP地址,也称为虚拟 IPVIP),以及整个集群中唯一的可靠端口。Kubernetes 服务代理的 Pod 由服务规范中定义的选择器确定。选择器总是基于标签。每个 Kubernetes 对象都可以分配零个或多个标签。在我们的情况下,选择器app=web;也就是说,所有具有名为 app 且值为 web 的标签的 Pod 都被代理。

在接下来的部分,我们将学习更多关于基于上下文的路由以及 Kubernetes 如何减轻这项任务。

基于上下文的路由

通常,我们希望为我们的 Kubernetes 集群配置基于上下文的路由。Kubernetes 为我们提供了各种方法来做到这一点。目前,首选和最可扩展的方法是使用IngressController。以下图尝试说明这个 IngressController 是如何工作的:

使用 Kubernetes Ingress Controller 进行基于上下文的路由

在上图中,我们可以看到当使用IngressController(如 Nginx)时,基于上下文(或第 7 层)的路由是如何工作的。在这里,我们部署了一个名为web的应用服务。该应用服务的所有 pod 都具有以下标签:app=web。然后,我们有一个名为web的 Kubernetes 服务,为这些 pod 提供了一个稳定的端点。该服务具有一个(虚拟)IP52.14.0.13,并暴露端口30044。也就是说,如果任何 Kubernetes 集群的节点收到对web名称和端口30044的请求,那么它将被转发到该服务。然后该服务将请求负载均衡到其中一个 pod。

到目前为止,一切都很好,但是客户端对http[s]://example.com/web的 Ingress 请求是如何路由到我们的 web 服务的呢?*首先,我们必须定义从基于上下文的请求到相应的<service name>/<port>请求的路由。这是通过一个Ingress对象完成的:

  1. Ingress对象中,我们将HostPath定义为源和(服务)名称,端口定义为目标。当 Kubernetes API 服务器创建此 Ingress 对象时,运行在IngressController中的一个进程会捕捉到这个变化。

  2. 该进程修改了 Nginx 反向代理的配置文件。

  3. 通过添加新路由,然后要求 Nginx 重新加载其配置,从而能够正确地将任何传入请求路由到http[s]://example.com/web

在接下来的部分,我们将通过对比每个编排引擎的一些主要资源来比较 Docker SwarmKit 和 Kubernetes。

比较 SwarmKit 和 Kubernetes

现在我们已经学习了关于 Kubernetes 中最重要的资源的许多细节,通过匹配重要资源来比较两个编排器 SwarmKit 和 Kubernetes 是有帮助的。让我们来看一下:

SwarmKit Kubernetes 描述
Swarm 集群 由各自编排器管理的一组服务器/节点。
节点 集群成员 Swarm/集群的单个主机(物理或虚拟)。
管理节点 主节点 管理 Swarm/集群的节点。这是控制平面。
工作节点 节点 运行应用程序工作负载的 Swarm/集群成员。
容器 容器** 在节点上运行的容器镜像的实例。**注意:在 Kubernetes 集群中,我们不能直接运行容器。
任务 Pod 在节点上运行的服务(Swarm)或 ReplicaSet(Kubernetes)的实例。一个任务管理一个容器,而一个 Pod 包含一个到多个共享相同网络命名空间的容器。
服务 副本集 定义并协调由多个实例组成的应用服务的期望状态。
服务 部署 部署是一个带有滚动更新和回滚功能的 ReplicaSet。
路由网格 服务 Swarm 路由网格使用 IPVS 提供 L4 路由和负载平衡。Kubernetes 服务是一个抽象,定义了一组逻辑 pod 和可用于访问它们的策略。它是一组 pod 的稳定端点。
堆栈 堆栈 ** 由多个(Swarm)服务组成的应用程序的定义。**注意:虽然堆栈不是 Kubernetes 的本机功能,但 Docker 的工具 Docker for Desktop 将它们转换为部署到 Kubernetes 集群上的功能。
网络 网络策略 Swarm 的软件定义网络(SDN)用于防火墙容器。Kubernetes 只定义了一个单一的平面网络。除非明确定义了网络策略来限制 pod 之间的通信,否则每个 pod 都可以访问每个其他 pod 和/或节点。

总结

在本章中,我们了解了 Kubernetes 的基础知识。我们概述了其架构,并介绍了在 Kubernetes 集群中定义和运行应用程序的主要资源。我们还介绍了 Minikube 和 Docker for Desktop 中的 Kubernetes 支持。

在下一章中,我们将在 Kubernetes 集群中部署一个应用程序。然后,我们将使用零停机策略更新此应用程序的其中一个服务。最后,我们将使用机密信息对在 Kubernetes 中运行的应用程序服务进行仪器化。敬请关注!

问题

请回答以下问题以评估您的学习进度:

  1. 用几句简短的话解释一下 Kubernetes 主节点的作用。

  2. 列出每个 Kubernetes(工作)节点上需要存在的元素。

  3. 我们不能在 Kubernetes 集群中运行单独的容器。

A. 是

B. 否

  1. 解释为什么 pod 中的容器可以使用localhost相互通信。

  2. 所谓的暂停容器在 pod 中的目的是什么?

  3. 鲍勃告诉你:“我们的应用由三个 Docker 镜像组成:webinventorydb。由于我们可以在 Kubernetes pod 中运行多个容器,我们将在一个单独的 pod 中部署我们应用的所有服务。”列出三到四个这样做是个坏主意的原因。

  4. 用自己的话解释为什么我们需要 Kubernetes ReplicaSets。

  5. 在什么情况下我们需要 Kubernetes 部署?

  6. 列出至少三种 Kubernetes 服务类型,并解释它们的目的和区别。

进一步阅读

以下是一些包含更多关于我们在本章讨论的各种主题的详细信息的文章列表:

第十六章:使用 Kubernetes 部署、更新和保护应用程序

在上一章中,我们了解了容器编排器 Kubernetes 的基础知识。我们对 Kubernetes 的架构有了高层次的概述,并且学到了很多关于 Kubernetes 用于定义和管理容器化应用程序的重要对象。

在本章中,我们将学习如何将应用程序部署、更新和扩展到 Kubernetes 集群中。我们还将解释如何实现零停机部署,以实现对关键任务应用程序的无干扰更新和回滚。最后,我们将介绍 Kubernetes 秘密作为配置服务和保护敏感数据的手段。

本章涵盖以下主题:

  • 部署第一个应用程序

  • 定义活动性和就绪性

  • 零停机部署

  • Kubernetes 秘密

通过本章的学习,您将能够做到以下事情:

  • 将多服务应用程序部署到 Kubernetes 集群中

  • 为您的 Kubernetes 应用程序服务定义活动性和就绪性探测

  • 在不造成停机的情况下更新在 Kubernetes 中运行的应用程序服务

  • 在 Kubernetes 集群中定义秘密

  • 配置应用程序服务以使用 Kubernetes 秘密

技术要求

在本章中,我们将在本地计算机上使用 Minikube。有关如何安装和使用 Minikube 的更多信息,请参阅第二章,设置工作环境

本章的代码可以在此处找到:github.com/PacktPublishing/Learn-Docker---Fundamentals-of-Docker-19.x-Second-Edition/tree/master/ch16/probes

请确保您已经克隆了本书的 GitHub 存储库,如第二章中所述,设置工作环境

在终端中,导航到~/fod/ch16文件夹。

部署第一个应用程序

我们将把我们在第十一章中首次介绍的宠物应用程序,Docker Compose,部署到 Kubernetes 集群中。我们的集群将是 Minikube,正如您所知,它是一个单节点集群。但是,从部署的角度来看,集群的大小以及集群在云中的位置、公司的数据中心或个人工作站并不重要。

部署 web 组件

作为提醒,我们的应用程序由两个应用程序服务组成:基于 Node 的 web 组件和支持的 PostgreSQL 数据库。在上一章中,我们了解到我们需要为要部署的每个应用程序服务定义一个 Kubernetes Deployment 对象。首先让我们为 web 组件做这个。就像本书中的所有内容一样,我们将选择声明性的方式来定义我们的对象。以下是为 web 组件定义 Deployment 对象的 YAML:

用于 web 组件的 Kubernetes 部署定义

前面的部署定义可以在~/fod/ch16文件夹中的web-deployment.yaml文件中找到。代码行如下:

  • 在第 4 行:我们为我们的Deployment对象定义了名称为web

  • 在第 6 行:我们声明我们想要运行一个web组件的实例。

  • 从第 8 行到第 10 行:我们定义了哪些 pod 将成为我们部署的一部分,即那些具有appservice标签,其值分别为petsweb的 pod。

  • 在第 11 行:在从第 11 行开始的 pod 模板中,我们定义每个 pod 将被应用appservice标签。

  • 从第 17 行开始:我们定义将在 pod 中运行的单个容器。容器的镜像是我们熟悉的fundamentalsofdocker/ch11-web:2.0镜像,容器的名称将是web

  • ports:最后,我们声明容器为 TCP 类型流量公开端口3000

请确保您已将 kubectl 的上下文设置为 Minikube。有关如何执行此操作的详细信息,请参见第二章,“设置工作环境”。

我们可以使用 kubectl 部署这个 Deployment 对象:

$ kubectl create -f web-deployment.yaml

我们可以使用我们的 Kubernetes CLI 再次检查部署是否已创建。我们应该看到以下输出:

列出在 Minikube 中运行的所有资源

在前面的输出中,我们可以看到 Kubernetes 创建了三个对象-部署、相关的 ReplicaSet 和一个单独的 pod(请记住,我们指定了我们只想要一个副本)。当前状态与所有三个对象的期望状态相对应,所以到目前为止一切都很好。

现在,web 服务需要暴露给公众。为此,我们需要定义一个NodePort类型的 Kubernetes Service对象。以下是定义,可以在~/fod/ch16文件夹中的web-service.yaml文件中找到:

为我们的 web 组件定义的 Service 对象

上述代码的前几行如下:

  • 在第4行:我们将这个Service对象的name设置为web

  • 在第6行:我们定义了我们正在使用的Service对象的type。由于 web 组件必须从集群外部访问,这不能是ClusterIP类型的Service对象,必须是NodePortLoadBalancer类型的。我们在上一章讨论了各种类型的 Kubernetes 服务,所以不会再详细讨论这个问题。在我们的示例中,我们使用了NodePort类型的服务。

  • 在第8行和9行:我们指定我们要通过TCP协议公开端口3000。Kubernetes 将自动将容器端口3000映射到 30,000 到 32,768 范围内的空闲主机端口。Kubernetes 实际上选择的端口可以在创建后使用kubectl get servicekubectl describe命令来确定服务。

  • 从第10行到12行:我们为这个服务定义筛选标准,以确定这个服务将作为哪些 pod 的稳定端点。在这种情况下,它是所有具有appservice标签的 pod,分别具有petsweb值。

现在我们有了一个Service对象的规范,我们可以使用kubectl来创建它:

$ kubectl create -f web-service.yaml

我们可以列出所有的服务来查看前面命令的结果:

为 web 组件创建的 Service 对象

在前面的输出中,我们可以看到一个名为web的服务已经被创建。为这个服务分配了一个唯一的clusterIP10.99.99.133,并且容器端口3000已经发布到所有集群节点的端口31331上。

如果我们想测试这个部署,我们需要找出 Minikube 的 IP 地址,然后使用这个 IP 地址来访问我们的 web 服务。以下是我们可以用来做这件事的命令:

$ IP=$(minikube ip)
$ curl -4 $IP:31331/
Pets Demo Application

好的,响应是Pets Demo Application,这是我们预期的。web 服务在 Kubernetes 集群中已经启动。接下来,我们要部署数据库。

部署数据库

数据库是一个有状态的组件,必须与无状态的组件(如我们的 web 组件)有所不同对待。我们在第九章和第十二章中详细讨论了分布式应用架构中有状态和无状态组件的区别,以及编排器。

Kubernetes 为有状态的组件定义了一种特殊类型的 ReplicaSet 对象。这个对象被称为 StatefulSet。让我们使用这种对象来部署我们的数据库。定义可以在~fod/ch16/db-stateful-set.yaml 文件中找到。详细信息如下:

DB 组件的 StatefulSet

好的,这看起来有点可怕,但其实并不是。由于我们还需要定义一个卷,让 PostgreSQL 数据库可以存储数据,所以它比 web 组件的部署定义要长一些。卷索赔定义在第 25 到 33 行。我们想要创建一个名为 pets-data 的卷,最大大小为 100MB。在第 22 到 24 行,我们使用这个卷并将其挂载到容器中的/var/lib/postgresql/data 目录,PostgreSQL 期望它在那里。在第 21 行,我们还声明 PostgreSQL 正在 5432 端口监听。

一如既往,我们使用 kubectl 来部署 StatefulSet:

$ kubectl create -f db-stateful-set.yaml

现在,如果我们列出集群中的所有资源,我们将能够看到已创建的附加对象。

StatefulSet 及其 pod

在这里,我们可以看到已经创建了一个 StatefulSet 和一个 pod。对于这两者,当前状态与期望状态相符,因此系统是健康的。但这并不意味着 web 组件此时可以访问数据库。服务发现到目前为止还不起作用。请记住,web 组件希望以 db 的名称访问 db 服务。

为了使服务发现在集群内部工作,我们还必须为数据库组件定义一个 Kubernetes Service 对象。由于数据库只能从集群内部访问,我们需要的 Service 对象类型是 ClusterIP。以下是规范,可以在~/fod/ch16/db-service.yaml 文件中找到:

数据库的 Kubernetes Service 对象定义

数据库组件将由此 Service 对象表示,并且可以通过名称db访问,这是服务的名称,如第 4 行所定义。数据库组件不必是公开访问的,因此我们决定使用 ClusterIP 类型的 Service 对象。第 10 到 12 行的选择器定义了该服务代表具有相应标签的所有 Pod 的稳定端点,即app: petsservice: db

让我们使用以下命令部署此服务:

$ kubectl create -f db-service.yaml

现在,我们应该准备好测试应用程序了。这次我们可以使用浏览器来欣赏美丽的动物图片:

在 Kubernetes 中运行宠物应用程序的测试

172.29.64.78是我的 Minikube 的 IP 地址。使用minikube ip命令验证您的地址。端口号32722是 Kubernetes 自动为我的web服务对象选择的端口号。将此数字替换为 Kubernetes 分配给您的服务的端口。您可以使用kubectl get services命令获取该数字。

现在,我们已成功将宠物应用程序部署到了 Minikube,这是一个单节点的 Kubernetes 集群。为此,我们必须定义四个工件,它们如下:

  • Web 组件的 Deployment 和 Service 对象

  • 数据库组件的 StatefulSet 和 Service 对象

从集群中删除应用程序,我们可以使用以下小脚本:

kubectl delete svc/web
kubectl delete deploy/web
kubectl delete svc/db
kubectl delete statefulset/db

接下来,我们将简化部署。

简化部署

到目前为止,我们已经创建了四个需要部署到集群的工件。这只是一个非常简单的应用程序,由两个组件组成。想象一下拥有一个更复杂的应用程序。它很快就会变成一个维护的噩梦。幸运的是,我们有几种选项可以简化部署。我们将在这里讨论的方法是在 Kubernetes 中定义构成应用程序的所有组件的可能性在单个文件中。

超出本书范围的其他解决方案可能包括使用 Helm 等软件包管理器。

如果我们的应用程序由许多 Kubernetes 对象(如DeploymentService对象)组成,那么我们可以将它们全部放在一个单独的文件中,并通过三个破折号分隔各个对象的定义。例如,如果我们想要在单个文件中为web组件定义DeploymentService,则如下所示:

apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: web
spec:
  replicas: 1
  selector:
    matchLabels:
      app: pets
      service: web
  template:
    metadata:
      labels:
        app: pets
        service: web
    spec:
      containers:
      - image: fundamentalsofdocker/ch11-web:2.0
        name: web
        ports:
        - containerPort: 3000
          protocol: TCP
---
apiVersion: v1
kind: Service
metadata:
  name: web
spec:
  type: NodePort
  ports:
  - port: 3000
    protocol: TCP
  selector:
    app: pets
    service: web

在这里,我们已经在~/fod/ch16/pets.yaml文件中收集了pets应用程序的所有四个对象定义,并且我们可以一次性部署该应用程序:

使用单个脚本部署宠物应用程序

同样,我们创建了一个名为~/fod/ch16/remove-pets.sh的脚本,用于从 Kubernetes 集群中删除宠物应用程序的所有构件:

从 Kubernetes 集群中删除宠物

通过这种方式,我们已经将我们在第十一章中介绍的宠物应用程序,Docker Compose,并定义了部署此应用程序到 Kubernetes 集群所必需的所有 Kubernetes 对象。在每个步骤中,我们确保获得了预期的结果,一旦所有构件存在于集群中,我们展示了运行中的应用程序。

定义存活和就绪

诸如 Kubernetes 和 Docker Swarm 之类的容器编排系统大大简化了部署、运行和更新高度分布式、使命关键的应用程序。编排引擎自动化了许多繁琐的任务,如扩展或缩减规模,确保始终保持所需状态等。

但是,编排引擎并不能自动完成所有事情。有时,我们开发人员需要提供一些只有我们才知道的信息来支持引擎。那么,我是什么意思呢?

让我们看一个单个的应用服务。假设它是一个微服务,我们称之为服务 A。如果我们在 Kubernetes 集群上容器化运行服务 A,那么 Kubernetes 可以确保我们在服务定义中需要的五个实例始终运行。如果一个实例崩溃,Kubernetes 可以快速启动一个新实例,从而保持所需的状态。但是,如果服务的一个实例并没有崩溃,而是不健康或者还没有准备好提供服务呢?显然,Kubernetes 应该知道这两种情况。但它不能,因为从应用服务的角度来看,健康与否是编排引擎无法知道的。只有我们应用开发人员知道我们的服务何时是健康的,何时不是。

例如,应用服务可能正在运行,但由于某些错误,其内部状态可能已经损坏,它可能陷入无限循环或死锁状态。同样,只有我们应用开发人员知道我们的服务是否准备好工作,或者它是否仍在初始化。虽然建议微服务的初始化阶段尽可能短,但如果某个特定服务需要较长的时间才能准备好运行,通常是无法避免的。处于初始化状态并不等同于不健康。初始化阶段是微服务或任何其他应用服务生命周期的预期部分。

因此,如果我们的微服务处于初始化阶段,Kubernetes 不应该试图终止它。但是,如果我们的微服务不健康,Kubernetes 应该尽快终止它,并用新实例替换它。

Kubernetes 有一个探针的概念,提供编排引擎和应用程序开发人员之间的接口。Kubernetes 使用这些探针来了解正在处理的应用服务的内部状态。探针在每个容器内部本地执行。有一个用于服务健康(也称为活跃性)的探针,一个用于启动的探针,以及一个用于服务就绪的探针。让我们依次来看看它们。

Kubernetes 活跃性探针

Kubernetes 使用活跃探针来决定何时需要终止一个容器,以及何时应该启动另一个实例。由于 Kubernetes 在 pod 级别操作,如果其至少一个容器报告为不健康,相应的 pod 将被终止。或者,我们可以说反过来:只有当一个 pod 的所有容器报告为健康时,该 pod 才被认为是健康的。

我们可以在 pod 的规范中定义活跃探针如下:

apiVersion: v1
kind: Pod
metadata:
 ...
spec:
 containers:
 - name: liveness-demo
 image: postgres:12.10
 ...
 livenessProbe:
 exec:
 command: nc localhost 5432 || exit -1
 initialDelaySeconds: 10
 periodSeconds: 5

相关部分在livenessProbe部分。首先,我们定义一个命令,Kubernetes 将在容器内部执行作为探针。在我们的例子中,我们有一个 PostresSQL 容器,并使用netcat Linux 工具来探测 TCP 端口5432。一旦 Postgres 监听到它,nc localhost 5432命令就会成功。

另外两个设置,initialDelaySecondsperiodSeconds,定义了 Kubernetes 在启动容器后应该等待多长时间才首次执行探针,以及之后探针应该以多频率执行。在我们的例子中,Kubernetes 在启动容器后等待 10 秒才执行第一次探针,然后每 5 秒执行一次探针。

也可以探测 HTTP 端点,而不是使用命令。假设我们正在从一个镜像acme.com/my-api:1.0运行一个微服务,它有一个名为/api/health的端点,如果微服务健康则返回状态200(OK),如果不健康则返回50x(Error)。在这里,我们可以定义活跃探针如下:

apiVersion: v1
kind: Pod
metadata:
  ...
spec:
  containers:
  - name: liveness
    image: acme.com/my-api:1.0
    ...
    livenessProbe:
 httpGet:
 path: /api/health
 port: 3000
 initialDelaySeconds: 5
 periodSeconds: 3

在上面的片段中,我已经定义了活跃探针,以便它使用 HTTP 协议,并在本地主机的端口5000上执行GET请求到/api/health端点。记住,探针是在容器内执行的,这意味着我可以使用本地主机。

我们也可以直接使用 TCP 协议来探测容器上的端口。但等一下,我们刚刚在我们的第一个示例中做过这个,我们使用了基于任意命令的通用活跃探针?是的,你说得对,我们做了。但我们必须依赖容器中netcat工具的存在才能这样做。我们不能假设这个工具总是存在。因此,依赖 Kubernetes 来为我们执行基于 TCP 的探测是有利的。修改后的 pod 规范如下:

apiVersion: v1kind: Pod
metadata:
 ...
spec:
 containers:
 - name: liveness-demo
   image: postgres:12.10
   ...
 livenessProbe:
 tcpSocket:
 port: 5432
 initialDelaySeconds: 10
 periodSeconds: 5

这看起来非常相似。唯一的变化是探针的类型已从exec更改为tcpSocket,而不是提供一个命令,我们提供了要探测的port

让我们试一试:

  1. 转到~/fod/ch16/probes文件夹,并使用以下命令构建 Docker 镜像:
$ docker image build -t fundamentalsofdocker/probes-demo:2.0 .
  1. 使用kubectl部署在probes-demo.yaml中定义的示例 pod:
$ kubectl apply -f probes-demo.yaml
  1. 描述 pod,特别分析输出的日志部分:
$ kubectl describe pods/probes-demo

在接下来的半分钟左右,你应该会得到以下输出:

健康 pod 的日志输出

  1. 等待至少 30 秒,然后再次描述 pod。这次,你应该看到以下输出:

将 pod 的状态更改为Unhealthy后的日志输出

最后两行表明了探针的失败以及 pod 将要重新启动的事实。

如果你获取 pod 列表,你会看到该 pod 已经重新启动了多次:

$ kubectl get pods
NAME         READY   STATUS    RESTARTS   AGE
probes-demo  1/1     Running   5          7m22s

当你完成示例后,使用以下命令删除 pod:

$ kubectl delete pods/probes-demo

接下来,我们将看一下 Kubernetes 的就绪探针。

Kubernetes 就绪探针

Kubernetes 使用就绪探针来决定服务实例(即容器)何时准备好接受流量。现在,我们都知道 Kubernetes 部署和运行的是 pod 而不是容器,因此谈论 pod 的就绪性是有意义的。只有当 pod 中的所有容器报告准备就绪时,pod 才被认为是准备就绪的。如果一个 pod 报告未准备就绪,那么 Kubernetes 会将其从服务负载均衡器中移除。

就绪探针的定义方式与活跃性探针完全相同:只需将 pod 规范中的livenessProbe键切换为readinessProbe。以下是使用我们之前的 pod 规范的示例:

 ...
spec:
 containers:
 - name: liveness-demo
   image: postgres:12.10
   ...
   livenessProbe:
     tcpSocket:
       port: 5432
     failureThreshold: 2
     periodSeconds: 5

   readinessProbe:
 tcpSocket:
 port: 5432
 initialDelaySeconds: 10
 periodSeconds: 5

请注意,在这个例子中,我们不再需要活跃性探针的初始延迟,因为现在有了就绪探针。因此,我用一个名为failureThreshold的条目替换了活跃性探针的初始延迟条目,该条目指示 Kubernetes 在失败的情况下应重复探测多少次,直到假定容器不健康。

Kubernetes 启动探针

对于 Kubernetes 来说,了解服务实例何时启动通常是有帮助的。如果我们为容器定义了启动探针,那么只要容器的启动探针不成功,Kubernetes 就不会执行活跃性或就绪性探针。再次强调,Kubernetes 会查看 pod,并且只有当所有 pod 容器的启动探针成功时,才会开始执行活跃性和就绪性探针。

在什么情况下会使用启动探测,考虑到我们已经有了存活性和就绪性探测?可能会出现需要考虑异常长的启动和初始化时间的情况,比如将传统应用程序容器化时。我们可以在技术上配置就绪性或存活性探测来考虑这一事实,但这将违背这些探测的目的。后者的探测旨在为 Kubernetes 提供有关容器健康和可用性的快速反馈。如果我们配置长时间的初始延迟或周期,那么这将抵消预期的结果。

毫不奇怪,启动探测的定义方式与就绪性和存活性探测完全相同。以下是一个例子:

spec:
  containers:
    ..
    startupProbe:
 tcpSocket:
 port: 3000
 failureThreshold: 30
 periodSeconds: 5
  ...

确保定义failureThreshold * periodSeconds产品,以便足够大以考虑最坏的启动时间。

在我们的示例中,最大启动时间不应超过 150 秒。

零停机部署

在关键任务环境中,应用程序始终保持运行是非常重要的。如今,我们不能再容忍任何停机时间。Kubernetes 给了我们各种手段来实现这一点。在集群中对应用程序执行不会导致停机的更新称为零停机部署。在本节中,我们将介绍两种实现这一目标的方法。这些方法如下:

  • 滚动更新

  • 蓝绿部署

让我们从讨论滚动更新开始。

滚动更新

在上一章中,我们了解到 Kubernetes 的 Deployment 对象与 ReplicaSet 对象的区别在于它在后者的功能基础上增加了滚动更新和回滚功能。让我们使用我们的 web 组件来演示这一点。显然,我们将不得不修改 web 组件的部署清单或描述。

我们将使用与上一节相同的部署定义,但有一个重要的区别 - 我们将有五个 web 组件的副本在运行。以下定义也可以在~/fod/ch16/web-deploy-rolling-v1.yaml文件中找到:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: web
spec:
  replicas: 5
  selector:
    matchLabels:
      app: pets
      service: web
  template:
    metadata:
      labels:
        app: pets
        service: web
    spec:
      containers:
      - image: fundamentalsofdocker/ch11-web:2.0
        name: web
        ports:
        - containerPort: 3000
          protocol: TCP

现在,我们可以像往常一样创建这个部署,同时也创建使我们的组件可访问的服务:

$ kubectl create -f web-deploy-rolling-v1.yaml
$ kubectl create -f web-service.yaml

一旦我们部署了 pod 和服务,我们可以使用以下命令测试我们的 web 组件:

$ PORT=$(kubectl get svc/web -o yaml | grep nodePort | cut -d' ' -f5)
$ IP=$(minikube ip)
$ curl -4 ${IP}:${PORT}/
Pets Demo Application

我们可以看到,应用程序正在运行,并返回预期的消息Pets Demo Application

现在,我们的开发人员已经创建了一个新版本 2.1 的web组件。新版本的web组件的代码可以在~/fod/ch16/web文件夹中找到,唯一的更改位于server.js文件的第 12 行:

Web 组件 2.0 版本的代码更改

开发人员已经按以下方式构建了新的镜像:

$ docker image build -t fundamentalsofdocker/ch16-web:2.1 web

随后,他们将镜像推送到 Docker Hub,如下所示:

$ docker image push fundamentalsofdocker/ch16-web:2.1

现在,我们想要更新web部署对象中的 pod 所使用的镜像。我们可以使用kubectlset image命令来实现这一点:

$ kubectl set image deployment/web \
 web=fundamentalsofdocker/ch16-web:2.1

如果我们再次测试应用程序,我们将得到一个确认,更新确实已经发生:

$ curl -4 ${IP}:${PORT}/
Pets Demo Application v2

现在,我们如何知道在此更新过程中没有发生任何停机时间?更新确实是以滚动方式进行的吗?滚动更新到底意味着什么?让我们来调查一下。首先,我们可以通过使用rollout status命令从 Kubernetes 那里得到确认,部署确实已经发生并且成功了:

$ kubectl rollout status deploy/web
deployment "web" successfully rolled out

如果我们用kubectl describe deploy/web描述部署 web,我们会在输出的最后得到以下事件列表:

Web 组件部署描述输出中找到的事件列表

第一个事件告诉我们,在创建部署时,一个名为web-769b88f67的 ReplicaSet 被创建,有五个副本。然后,我们执行了更新命令。列表中的第二个事件告诉我们,这意味着创建一个名为web-55cdf67cd的新 ReplicaSet,最初只有一个副本。因此,在那个特定的时刻,系统上存在六个 pod:五个初始 pod 和一个具有新版本的 pod。但是,由于部署对象的期望状态指定我们只想要五个副本,Kubernetes 现在将旧的 ReplicaSet 缩减到四个实例,我们可以在第三个事件中看到。

然后,新的 ReplicaSet 再次扩展到两个实例,随后,旧的 ReplicaSet 缩减到三个实例,依此类推,直到我们有了五个新实例,所有旧实例都被废弃。虽然我们无法看到确切的时间(除了 3 分钟),这发生的顺序告诉我们整个更新是以滚动方式进行的。

在短时间内,对 web 服务的一些调用可能会得到来自组件的旧版本的答复,而一些调用可能会得到来自组件的新版本的答复,但是服务从未中断。

我们还可以列出集群中的 ReplicaSet 对象,并确认我在前面部分所说的内容:

列出集群中的所有 ReplicaSet 对象

在这里,我们可以看到新的 ReplicaSet 有五个实例在运行,而旧的 ReplicaSet 已被缩减为零个实例。旧的 ReplicaSet 对象仍然存在的原因是 Kubernetes 为我们提供了回滚更新的可能性,在这种情况下,将重用该 ReplicaSet。

为了回滚图像的更新,以防一些未被检测到的错误潜入新代码,我们可以使用rollout undo命令:

$ kubectl rollout undo deploy/web
deployment "web"
$ curl -4 ${IP}:${PORT}/
Pets Demo Application

我还在前面的片段中列出了使用curl进行测试的命令,以验证回滚确实发生了。如果我们列出 ReplicaSets,我们将看到以下输出:

回滚后列出 ReplicaSet 对象

这证实了旧的 ReplicaSet(web-769b88f67)对象已被重用,新的 ReplicaSet 已被缩减为零个实例。

然而,有时我们不能或不想容忍旧版本与新版本共存的混合状态。我们希望采取“全有或全无”的策略。这就是蓝绿部署发挥作用的地方,接下来我们将讨论这个问题。

蓝绿部署

如果我们想要为宠物应用程序的 web 组件执行蓝绿部署,那么我们可以通过创造性地使用标签来实现。首先,让我们回顾一下蓝绿部署的工作原理。以下是一个大致的逐步说明:

  1. 部署web组件的第一个版本为blue。我们将使用color: blue标签为 pod 打上标签。

  2. 使用color: blue标签在选择器部分为这些 pod 部署 Kubernetes 服务。

  3. 现在,我们可以部署版本 2 的 web 组件,但是这一次,pod 的标签是color: green

  4. 我们可以测试服务的绿色版本,以检查它是否按预期工作。

  5. 现在,我们通过更新 web 组件的 Kubernetes 服务,将流量从蓝色切换到绿色。我们修改选择器,使其使用color: green标签。

让我们为版本 1(蓝色)定义一个 Deployment 对象:

Web 组件的蓝色部署规范

前面的定义可以在~/fod/ch16/web-deploy-blue.yaml文件中找到。请注意第 4 行,我们在那里定义了部署的名称为web-blue,以区分它与即将到来的部署web-green。还要注意,我们在第 11 行和第 17 行添加了标签color: blue。其他一切与以前一样。

现在,我们可以为 Web 组件定义 Service 对象。它将与之前使用的相同,但有一个小改变,如下面的屏幕截图所示:

Kubernetes 服务支持蓝绿部署的 Web 组件

关于我们在本章前面使用的服务定义的唯一区别是第 13 行,它在选择器中添加了color: blue标签。我们可以在~/fod/ch16/web-svc-blue-green.yaml文件中找到前面的定义。

然后,我们可以使用以下命令部署 Web 组件的蓝色版本:

$ kubectl create -f web-deploy-blue.yaml
$ kubectl create -f web-svc-blue-green.yaml

一旦服务启动运行,我们可以确定其 IP 地址和端口号并进行测试:

$ PORT=$(kubectl get svc/web -o yaml | grep nodePort | cut -d' ' -f5)
$ IP=$(minikube ip)
$ curl -4 ${IP}:${PORT}/
Pets Demo Application

正如预期的那样,我们得到了“宠物演示应用程序”的响应。现在,我们可以部署 Web 组件的绿色版本。其部署对象的定义可以在~/fod/ch16/web-deploy-green.yaml文件中找到,如下所示:

部署绿色 Web 组件的规范

有趣的行如下:

  • 第 4 行:命名为web-green以区分它与web-blue并允许并行安装

  • 第 11 行和第 17 行:颜色为绿色

  • 第 20 行:现在使用图像的 2.1 版本

现在,我们准备部署这个绿色版本的服务。它应该与蓝色服务分开运行。

$ kubectl create -f web-deploy-green.yaml

我们可以确保两个部署共存如下:

显示在集群中运行的部署对象列表

正如预期的那样,蓝色和绿色都在运行。我们可以验证蓝色仍然是活动服务:

$ curl -4 ${IP}:${PORT}/
Pets Demo Application

现在是有趣的部分。我们可以通过编辑 Web 组件的现有服务将流量从蓝色切换到绿色。为此,请执行以下命令:

$ kubectl edit svc/web

将标签颜色的值从蓝色更改为绿色。然后保存并退出编辑器。Kubernetes CLI 将自动更新服务。现在再次查询 web 服务时,我们会得到这个:

$ curl -4 ${IP}:${PORT}/
Pets Demo Application v2

这证实了流量确实已经切换到 web 组件的绿色版本(注意响应curl命令末尾的v2)。

如果我们意识到我们的绿色部署出了问题,新版本有缺陷,我们可以通过再次编辑服务 web 并将标签颜色的值替换为蓝色,轻松地切换回蓝色版本。这种回滚是瞬时的,应该总是有效的。然后,我们可以移除有问题的绿色部署并修复组件。当我们纠正了问题后,我们可以再次部署绿色版本。

一旦组件的绿色版本按预期运行并表现良好,我们可以停用蓝色版本:

$ kubectl delete deploy/web-blue

当我们准备部署新版本 3.0 时,这个版本成为蓝色版本。我们相应地更新~/fod/ch16/web-deploy-blue.yaml文件并部署它。然后,我们将服务 web 从绿色切换到蓝色,依此类推。

我们已经成功地演示了在 Kubernetes 集群中如何实现蓝绿部署,使用了宠物应用程序的 web 组件。

Kubernetes 秘密

有时,我们希望在 Kubernetes 集群中运行的服务必须使用诸如密码、秘密 API 密钥或证书等机密数据。我们希望确保这些敏感信息只能被授权或专用服务看到。集群中运行的所有其他服务都不应该访问这些数据。

因此,Kubernetes 引入了秘密。秘密是一个键值对,其中键是秘密的唯一名称,值是实际的敏感数据。秘密存储在 etcd 中。Kubernetes 可以配置为在休息时加密秘密,即在 etcd 中,以及在传输时,即当秘密从主节点传输到运行使用该秘密的服务的工作节点时。

手动定义秘密

我们可以像在 Kubernetes 中创建任何其他对象一样,声明性地创建一个秘密。以下是这样一个秘密的 YAML:

apiVersion: v1
kind: Secret
metadata:
  name: pets-secret
type: Opaque
data:
  username: am9obi5kb2UK
  password: c0VjcmV0LXBhc1N3MHJECg==

前面的定义可以在~/fod/ch16/pets-secret.yaml文件中找到。现在,你可能想知道这些值是什么。这些是真实的(未加密)值吗?不,不是。它们也不是真正加密的值,而只是 base64 编码的值。因此,它们并不是真正安全的,因为 base64 编码的值可以很容易地恢复为明文值。我是如何得到这些值的?很简单:按照以下步骤:

  1. 使用base64工具如下编码值:

创建秘密的 base64 编码值

  1. 使用前面的值,我们可以创建秘密并描述它:

创建和描述 Kubernetes 秘密

  1. 在秘密的描述中,值是隐藏的,只给出了它们的长度。所以,也许现在秘密是安全的?不,不是真的。我们可以很容易地使用kubectl get命令解码这个秘密:

Kubernetes 秘密解码

正如我们在前面的截图中看到的,我们恢复了我们的原始秘密值。

  1. 解码之前获得的值:
$ echo "c0VjcmV0LXBhc1N3MHJECg==" | base64 --decode
sEcret-pasSw0rD

因此,这种创建 Kubernetes 的方法的后果是不应该在除了开发环境之外的任何环境中使用,我们在那里处理非敏感数据。在所有其他环境中,我们需要更好的方法来处理秘密。

使用 kubectl 创建秘密

定义秘密的一个更安全的方法是使用kubectl。首先,我们创建包含 base64 编码的秘密值的文件,类似于我们在前面的部分所做的,但是这次,我们将值存储在临时文件中:

$ echo "sue-hunter" | base64 > username.txt
$ echo "123abc456def" | base64 > password.txt

现在,我们可以使用kubectl从这些文件中创建一个秘密,如下所示:

$ kubectl create secret generic pets-secret-prod \
 --from-file=./username.txt \
 --from-file=./password.txt
secret "pets-secret-prod" created

秘密可以像手动创建的秘密一样使用。

你可能会问,为什么这种方法比另一种方法更安全?首先,没有定义秘密并存储在一些源代码版本控制系统(如 GitHub)中的 YAML,许多人都可以访问并查看和解码秘密。只有被授权知道秘密的管理员才能看到它们的值并直接在(生产)集群中创建秘密。集群本身受基于角色的访问控制的保护,因此未经授权的人员无法访问它,也无法解码集群中定义的秘密。

现在,让我们看看我们如何实际使用我们定义的秘密。

在 pod 中使用秘密

假设我们想要创建一个Deployment对象,其中web组件使用我们在前一节中介绍的秘密pets-secret。我们可以使用以下命令在集群中创建秘密:

$ kubectl create -f pets-secret.yaml

~/fod/ch16/web-deploy-secret.yaml文件中,我们可以找到Deployment对象的定义。我们不得不添加从第23行开始的部分到Deployment对象的原始定义中:

带有秘密的 web 组件的部署对象

在第2730行,我们定义了一个名为secrets的卷,来自我们的秘密pets-secret。然后,我们在容器中使用这个卷,如第2326行所述。我们在容器文件系统中挂载秘密到/etc/secrets,并且以只读模式挂载卷。因此,秘密值将作为文件出现在容器中的文件夹中。文件的名称将对应于键名,文件的内容将是相应键的值。这些值将以未加密的形式提供给容器内运行的应用程序。

在我们的情况下,由于我们在秘密中有usernamepassword键,我们将在容器文件系统的/etc/secrets文件夹中找到两个文件,名为usernamepasswordusername文件应包含值john.doepassword文件应包含值sEcret-pasSw0rD。这是确认:

确认秘密在容器内可用

在前面输出的第1行,我们exec进入 web 组件运行的容器。然后,在第25行,我们列出了/etc/secrets文件夹中的文件,最后,在第68行,我们显示了两个文件的内容,毫不奇怪地显示了明文的秘密值。

由于任何语言编写的应用程序都可以读取简单的文件,因此使用秘密的这种机制非常向后兼容。甚至一个老的 Cobol 应用程序也可以从文件系统中读取明文文件。

然而,有时应用程序希望秘密以环境变量的形式可用。让我们看看 Kubernetes 在这种情况下为我们提供了什么。

环境变量中的秘密值

假设我们的 web 组件期望在环境变量PETS_USERNAME中找到用户名,在PETS_PASSWORD中找到密码。如果是这种情况,我们可以修改我们的部署 YAML 文件,使其如下所示:

部署映射秘密值到环境变量

在第 23 到 33 行,我们定义了两个环境变量PETS_USERNAMEPETS_PASSWORD,并将pets-secret的相应键值对映射到它们。

请注意,我们不再需要卷;相反,我们直接将pets-secret的各个键映射到容器内部有效的相应环境变量中。以下命令序列显示了秘密值确实在容器内部作为相应的环境变量可用:

秘密值映射到环境变量

在本节中,我们向您展示了如何在 Kubernetes 集群中定义秘密,并如何在作为部署的一部分运行的容器中使用这些秘密。我们展示了两种在容器内部映射秘密的变体,第一种使用文件,第二种使用环境变量。

总结

在本章中,我们学习了如何将应用程序部署到 Kubernetes 集群中,以及如何为该应用程序设置应用程序级别的路由。此外,我们还学习了如何在 Kubernetes 集群中运行的应用程序服务中进行更新而不会造成任何停机时间。最后,我们使用秘密来向运行在集群中的应用程序服务提供敏感信息。

在下一章中,我们将学习有关用于监视在 Kubernetes 集群上运行的单个服务或整个分布式应用程序的不同技术。我们还将学习如何在生产环境中运行的应用程序服务进行故障排除,而不会改变集群或运行服务的集群节点。敬请关注。

问题

为了评估你的学习进度,请回答以下问题:

  1. 你有一个由两个服务组成的应用程序,第一个是 web API,第二个是一个数据库,比如 Mongo DB。你想将这个应用程序部署到 Kubernetes 集群中。简要解释一下你会如何进行。

  2. 描述一下你需要哪些组件才能为你的应用程序建立第 7 层(或应用程序级)路由。

  3. 列出实施简单应用服务的蓝绿部署所需的主要步骤。避免过多细节。

  4. 您将通过 Kubernetes 秘密向应用服务提供三到四种类型的信息。

  5. Kubernetes 在创建秘密时接受哪些来源的名称。

进一步阅读

以下是一些链接,提供了本章讨论的主题的更多信息:

第十七章:监控和故障排除在生产环境中运行的应用程序

在上一章中,我们学习了如何将多服务应用程序部署到 Kubernetes 集群中。我们为应用程序配置了应用程序级别的路由,并使用了零停机策略更新了其服务。最后,我们使用 Kubernetes Secrets 为运行的服务提供了机密数据。

在本章中,您将学习用于监视在 Kubernetes 集群上运行的单个服务或整个分布式应用程序的不同技术。您还将学习如何在生产环境中运行的应用服务进行故障排除,而不会更改集群或运行服务的集群节点。

本章涵盖以下主题:

  • 监视单个服务

  • 使用 Prometheus 监视您的分布式应用程序

  • 故障排除在生产环境中运行的服务

通过完成本章,您将能够执行以下操作:

  • 为服务配置应用程序级别的监控。

  • 使用 Prometheus 收集和集中聚合相关的应用程序指标。

  • 使用特殊工具容器来故障排除在生产环境中运行的服务。

技术要求

在本章中,我们将在本地计算机上使用 Minikube。有关如何安装和使用 Minikube 的更多信息,请参阅 第二章 设置工作环境

本章的代码可以在以下网址找到:github.com/PacktPublishing/Learn-Docker---Fundamentals-of-Docker-19.x-Second-Edition/tree/master/ch17.

请确保您已经克隆了 GitHub 存储库,如 第二章 设置工作环境 中所述。

在终端中,导航到 ~/fod/ch17 文件夹。

监视单个服务

当在生产环境或任何类似生产环境中使用分布式的关键任务应用程序时,尽可能多地了解这些应用程序的内部运作是至关重要的。你有没有机会看过飞机驾驶舱或核电站的指挥中心?飞机和发电厂都是提供关键任务服务的高度复杂系统的样本。如果飞机坠毁或发电厂意外关闭,至少可以说很多人会受到负面影响。因此,驾驶舱和指挥中心都装满了显示系统某个部分当前或过去状态的仪器。你看到的是系统的一些战略部分放置的传感器的视觉表示,它们不断收集数据,比如温度或流速。

与飞机或发电厂类似,我们的应用程序需要安装“传感器”,这些传感器可以感知应用服务或其运行基础设施的“温度”。我用双引号括起来的温度只是一个占位符,用于表示应用程序中重要的事物,比如给定 RESTful 端点每秒的请求次数,或者对同一端点的请求的平均延迟。

我们收集的结果数值或读数,比如请求的平均延迟,通常被称为指标。我们的目标应该是尽可能多地公开我们构建的应用服务的有意义的指标。指标可以是功能性的,也可以是非功能性的。功能性指标是关于应用服务的与业务相关的数值,比如如果服务是电子商务应用程序的一部分,每分钟执行多少次结账,或者如果我们谈论的是流媒体应用程序,过去 24 小时内最受欢迎的五首歌曲是哪些。

非功能性指标是重要的数值,它们与应用程序所用于的业务类型无关,比如特定网页请求的平均延迟是多少,或者另一个端点每分钟返回多少个4xx状态代码,或者给定服务使用了多少 RAM 或多少 CPU 周期。

在一个分布式系统中,每个部分都暴露指标的情况下,一些全面的服务应该定期从每个组件中收集和聚合值。或者,每个组件应该将其指标转发到一个中央指标服务器。只有当我们高度分布式系统的所有组件的指标都可以在一个中央位置进行检查时,它们才有任何价值。否则,监控系统将变得不可能。这就是为什么飞机的飞行员在飞行期间从不必亲自检查飞机的各个关键部件;所有必要的读数都被收集并显示在驾驶舱中。

如今,最受欢迎的用于暴露、收集和存储指标的服务之一是 Prometheus。它是一个开源项目,并已捐赠给Cloud Native Computing FoundationCNCF)。Prometheus 与 Docker 容器、Kubernetes 和许多其他系统和编程平台具有一流的集成。在本章中,我们将使用 Prometheus 来演示如何对暴露重要指标的简单服务进行仪表化。

基于 Node.js 的服务仪表化

在本节中,我们想要学习如何通过以下步骤对 Node Express.js 编写的微服务进行仪表化:

  1. 创建一个名为node的新文件夹并导航到它:
$ mkdir node && cd node
  1. 在这个文件夹中运行npm init,并接受除了入口点之外的所有默认值,将其从默认的index.js更改为server.js

  2. 我们需要使用以下命令将express添加到我们的项目中:

$ npm install --save express
  1. 现在我们需要使用以下命令为 Node Express 安装 Prometheus 适配器:
$ npm install --save prom-client 
  1. 在文件夹中添加一个名为server.js的文件,并包含以下内容:
const app = require("express")();

app.get('/hello', (req, res) => {
  const { name = 'World' } = req.query;
  res.json({ message: `Hello, ${name}!` });
});

app.listen(port=3000, () => {
  console.log(`Example api is listening on http://localhost:3000`);
}); 

这是一个非常简单的 Node Express 应用程序,只有一个端点:/hello

  1. 在上述代码中,添加以下片段以初始化 Prometheus 客户端:
const client = require("prom-client");
const register = client.register;
const collectDefaultMetrics = client.collectDefaultMetrics;
collectDefaultMetrics({ register });
  1. 接下来,添加一个端点来暴露指标:
app.get('/metrics', (req, res) => {
  res.set('Content-Type', register.contentType);
  res.end(register.metrics());
});
  1. 现在让我们运行这个示例微服务:
$ npm start

> node@1.0.0 start C:\Users\Gabriel\fod\ch17\node
> node server.js

Example api is listening on http://localhost:3000

我们可以在前面的输出中看到,服务正在端口3000上监听。

  1. 现在让我们尝试访问在代码中定义的/metrics端点上的指标:
$ curl localhost:3000/metrics
...
process_cpu_user_seconds_total 0.016 1577633206532

# HELP process_cpu_system_seconds_total Total system CPU time spent in seconds.
# TYPE process_cpu_system_seconds_total counter
process_cpu_system_seconds_total 0.015 1577633206532

# HELP process_cpu_seconds_total Total user and system CPU time spent in seconds.
# TYPE process_cpu_seconds_total counter
process_cpu_seconds_total 0.031 1577633206532
...
nodejs_version_info{version="v10.15.3",major="10",minor="15",patch="3"} 1

我们得到的输出是一个相当长的指标列表,可以被 Prometheus 服务器消费。

这很容易,不是吗?通过添加一个节点包并在应用程序启动中添加几行微不足道的代码,我们已经获得了大量的系统指标访问权限。

现在让我们定义我们自己的自定义指标。让它是一个Counter对象:

  1. 将以下代码片段添加到server.js中,以定义名为my_hello_counter的自定义计数器:
const helloCounter = new client.Counter({ 
  name: 'my_hello_counter', 
  help: 'Counts the number of hello requests',
});
  1. 在现有的/hello端点中,添加代码以增加计数器:
app.get('/hello', (req, res) => {
  helloCounter.inc();
  const { name = 'World' } = req.query;
  res.json({ message: `Hello, ${name}!` });
});
  1. 使用npm start重新运行应用程序。

  2. 为了测试新的计数器,让我们两次访问我们的/hello端点:

$ curl localhost:3000/hello?name=Sue
  1. 当访问/metrics端点时,我们将获得以下输出:
$ curl localhost:3000/metrics

...
# HELP my_hello_counter Counts the number of hello requests 
# TYPE my_hello_counter counter
my_hello_counter 2

我们在代码中定义的计数器显然有效,并且输出了我们添加的HELP文本。

现在我们知道如何为 Node Express 应用程序添加仪表,让我们为基于.NET Core 的微服务做同样的事情。

为.NET Core 服务添加仪表

让我们首先创建一个基于 Web API 模板的简单.NET Core 微服务。

  1. 创建一个新的dotnet文件夹,并导航到其中:
$ mkdir dotnet && cd dotnet
  1. 使用dotnet工具来创建一个名为sample-api的新微服务:
$ dotnet new webapi --output sample-api
  1. 我们将使用.NET 的 Prometheus 适配器,该适配器作为名为prometheus-net.AspNetCore的 NuGet 软件包提供给我们。使用以下命令将此软件包添加到sample-api项目中:
$ dotnet add sample-api package prometheus-net.AspNetCore
  1. 在您喜欢的代码编辑器中打开项目;例如,当使用 VS Code 时,执行以下操作:
$ code .
  1. 找到Startup.cs文件,并打开它。在文件开头添加一个using语句:
using Prometheus; 
  1. 然后在Configure方法中,将endpoints.MapMetrics()语句添加到端点的映射中。您的代码应如下所示:
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    ...
    app.UseEndpoints(endpoints =>
    {
        endpoints.MapControllers();
        endpoints.MapMetrics();
    });
}

请注意,以上内容适用于.NET Core 3.x 版本。如果您使用的是早期版本,则配置略有不同。请查阅以下存储库以获取更多详细信息,网址为github.com/prometheus-net/prometheus-net.

  1. 有了这个,Prometheus 组件将开始发布 ASP.NET Core 的请求指标。让我们试试。首先,使用以下命令启动应用程序:
$ dotnet run --project sample-api

info: Microsoft.Hosting.Lifetime[0]
 Now listening on: https://localhost:5001 
info: Microsoft.Hosting.Lifetime[0]
 Now listening on: http://localhost:5000 
...

上述输出告诉我们微服务正在https://localhost:5001上监听。

  1. 现在我们可以使用curl调用服务的指标端点:
$ curl --insecure https://localhost:5001/metrics 

# HELP process_private_memory_bytes Process private memory size
# TYPE process_private_memory_bytes gauge
process_private_memory_bytes 55619584
# HELP process_virtual_memory_bytes Virtual memory size in bytes. 
# TYPE process_virtual_memory_bytes gauge
process_virtual_memory_bytes 2221930053632
# HELP process_working_set_bytes Process working set
# TYPE process_working_set_bytes gauge
process_working_set_bytes 105537536
...
dotnet_collection_count_total{generation="1"} 0
dotnet_collection_count_total{generation="0"} 0
dotnet_collection_count_total{generation="2"} 0

我们得到的是我们微服务的系统指标列表。这很容易:我们只需要添加一个 NuGet 软件包和一行代码就可以让我们的服务被仪表化!

如果我们想要添加我们自己的(功能性)指标怎么办?这同样很简单。假设我们想要测量对我们的/weatherforecast端点的并发访问次数。为此,我们定义一个gauge并使用它来包装适当端点中的逻辑。我们可以通过以下步骤来实现这一点:

  1. 定位Controllers/WeatherForecastController.cs类。

  2. 在文件顶部添加using Prometheus;

  3. WeatherForecastController类中定义一个Gauge类型的私有实例变量:

private static readonly Gauge weatherForecastsInProgress = Metrics
    .CreateGauge("myapp_weather_forecasts_in_progress", 
                 "Number of weather forecast operations ongoing.");
  1. 使用using语句包装Get方法的逻辑:
[HttpGet]
public IEnumerable<WeatherForecast> Get()
{
    using(weatherForecastsInProgress.TrackInProgress())
 {
...
 }
}
  1. 重新启动微服务。

  2. 使用curl调用/weatherforecast端点几次:

$ curl --insecure https://localhost:5001/weatherforecast
  1. 使用curl获取指标,就像本节前面所述的那样:
$ curl --insecure https://localhost:5001/metrics 

# HELP myapp_weather_forecasts_in_progress Number of weather forecast operations ongoing.
# TYPE myapp_weather_forecasts_in_progress gauge
myapp_weather_forecasts_in_progress 0
...

您会注意到现在列表中有一个名为myapp_weather_forecasts_in_progress的新指标。它的值将为零,因为目前您没有针对被跟踪端点运行任何请求,而gauge类型指标只测量正在进行的请求的数量。

恭喜,您刚刚定义了您的第一个功能性指标。这只是一个开始;还有许多更复杂的可能性可以供您使用。

基于 Node.js 或.NET Core 的应用服务并不特殊。用其他语言编写的服务同样简单易懂,比如 Java、Python 或 Go。

学会了如何为应用服务添加重要指标,现在让我们看看如何使用 Prometheus 来收集和聚合这些值,以便我们可以监控分布式应用。

使用 Prometheus 监控分布式应用

现在我们已经学会了如何为应用服务添加 Prometheus 指标,现在是时候展示如何收集这些指标并将其转发到 Prometheus 服务器,所有指标将被聚合和存储。然后我们可以使用 Prometheus 的(简单)Web 界面或类似 Grafana 这样更复杂的解决方案来在仪表板上显示重要的指标。

与大多数用于收集应用服务和基础设施组件指标的工具不同,Prometheus 服务器承担了工作负载,并定期抓取所有定义的目标。这样应用程序和服务就不需要担心转发数据。您也可以将此描述为拉取指标与推送指标。这使得 Prometheus 服务器非常适合我们的情况。

现在我们将讨论如何将 Prometheus 部署到 Kubernetes,然后是我们的两个示例应用服务。最后,我们将在集群中部署 Grafana,并使用它在仪表板上显示我们的客户指标。

架构

让我们快速概述一下计划系统的架构。如前所述,我们有我们的微服务、Prometheus 服务器和 Grafana。此外,一切都将部署到 Kubernetes。以下图显示了它们之间的关系:

使用 Prometheus 和 Grafana 监控应用程序的高级概述

在图的中上部,我们有 Prometheus,它定期从左侧显示的 Kubernetes 中抓取指标。它还定期从我们在上一节中创建和记录的 Node.js 和.NET 示例服务中抓取指标。最后,在图的右侧,我们有 Grafana,它定期从 Prometheus 中获取数据,然后在图形仪表板上显示出来。

部署 Prometheus 到 Kubernetes

如上所示,我们首先通过在 Kubernetes 上部署 Prometheus 来开始。首先,我们需要定义一个 Kubernetes YAML 文件,以便我们可以使用它来执行此操作。首先,我们需要定义一个 Kubernetes Deployment,它将创建一个 Prometheus 服务器实例的ReplicaSet,然后我们将定义一个 Kubernetes 服务来向我们公开 Prometheus,以便我们可以从浏览器标签内访问它,或者 Grafana 可以访问它。让我们来做吧:

  1. 创建一个ch17/kube文件夹,并导航到其中:
$ mkdir -p ~/fod/ch17/kube && cd ~/fod/ch17/kube
  1. 在此文件夹中添加一个名为prometheus.yaml的文件。

  2. 将以下代码片段添加到此文件中;它为 Prometheus 定义了Deployment

apiVersion: apps/v1
kind: Deployment
metadata:
  name: prometheus-deployment
  labels:
    app: prometheus
    purpose: monitoring-demo
spec:
  replicas: 2
  selector:
    matchLabels:
      app: prometheus
      purpose: monitoring-demo
  template:
    metadata:
      labels:
        app: prometheus
        purpose: monitoring-demo
    spec:
      containers:
      - name: prometheus
        image: prom/prometheus
        volumeMounts:
          - name: config-volume
            mountPath: /etc/prometheus/prometheus.yml
            subPath: prometheus.yml
        ports:
        - containerPort: 9090
      volumes:
        - name: config-volume
          configMap:
           name: prometheus-cm

我们正在定义一个包含两个 Prometheus 实例的副本集。每个实例被分配两个标签:app: prometheuspurpose: monitoring-demo,用于识别目的。有趣的部分在于容器规范的volumeMounts。在那里,我们将一个名为prometheus-cm的 Kubernetes ConfigMap对象,其中包含 Prometheus 配置,挂载到容器中,以便 Prometheus 可以在其中找到其配置文件。ConfigMap类型的卷在上述代码片段的最后四行中定义。

请注意,我们将在稍后定义config映射。

  1. 现在让我们为 Prometheus 定义 Kubernetes 服务。将此代码片段附加到文件中:
---
kind: Service
apiVersion: v1
metadata:
  name: prometheus-svc
spec:
  type: NodePort
  selector:
    app: prometheus
    purpose: monitoring-demo
  ports:
  - name: promui
    protocol: TCP
    port: 9090
    targetPort: 9090

请注意,代码片段开头的三个破折号(---)是必需的,用于在我们的 YAML 文件中分隔单个对象定义。

我们将我们的服务称为prometheus-svc,并将其设置为NodePort(而不仅仅是ClusterIP类型的服务),以便能够从主机访问 Prometheus Web UI。

  1. 现在我们可以为 Prometheus 定义一个简单的配置文件。这个文件基本上指示 Prometheus 服务器从哪些服务中抓取指标以及多久抓取一次。首先,创建一个ch17/kube/config文件夹:
$ mkdir -p ~/fod/ch17/kube/config
  1. 请在最后一个文件夹中添加一个名为prometheus.yml的文件,并将以下内容添加到其中:
scrape_configs:
    - job_name: 'prometheus'
      scrape_interval: 5s
      static_configs:
        - targets: ['localhost:9090']

    - job_name: dotnet
      scrape_interval: 5s
      static_configs:
        - targets: ['dotnet-api-svc:5000']

    - job_name: node
      scrape_interval: 5s
      static_configs:
        - targets: ['node-api-svc:3000']
          labels:
            group: 'production'

在前面的文件中,我们为 Prometheus 定义了三个作业:

    • 第一个称为prometheus,每五秒从 Prometheus 服务器本身抓取指标。它在localhost:9090目标找到这些指标。请注意,默认情况下,指标应该在/metrics端点公开。
  • 第二个作业称为dotnet,从dotnet-api-svc:5000服务中抓取指标,这将是我们之前定义和配置的.NET Core 服务。

  • 最后,第三个作业也为我们的 Node 服务做同样的事情。请注意,我们还为这个作业添加了一个group: 'production'标签。这允许进一步对作业或任务进行分组。

  1. 现在我们可以在我们的 Kubernetes 集群中定义ConfigMap对象,使用下一个命令。在ch17/kube文件夹中执行以下命令:
$ kubectl create configmap prometheus-cm \
 --from-file config/prometheus.yml
  1. 现在我们可以使用以下命令将 Prometheus 部署到我们的 Kubernetes 服务器:
$ kubectl apply -f prometheus.yaml deployment.apps/prometheus-deployment created
service/prometheus-svc created
  1. 让我们再次确认部署成功:
$ kubectl get all

NAME                                        READY  STATUS   RESTARTS  AGE
pod/prometheus-deployment-779677977f-727hb  1/1    Running  0         24s
pod/prometheus-deployment-779677977f-f5l7k  1/1    Running  0         24s

NAME                    TYPE       CLUSTER-IP      EXTERNAL-IP  PORT(S)         AGE
service/kubernetes      ClusterIP  10.96.0.1       <none>       443/TCP         28d
service/prometheus-svc  NodePort   10.110.239.245  <none>       9090:31962/TCP  24s

NAME                                   READY  UP-TO-DATE  AVAILABLE  AGE
deployment.apps/prometheus-deployment  2/2    2           2          24s

NAME                                              DESIRED  CURRENT  READY  AGE
replicaset.apps/prometheus-deployment-779677977f  2        2        2      24s

密切关注 pod 的列表,并确保它们都正常运行。还请注意prometheus-svc对象的端口映射。在我的情况下,9090端口映射到31962主机端口。在你的情况下,后者可能不同,但也会在3xxxx范围内。

  1. 现在我们可以访问 Prometheus 的 Web UI。打开一个新的浏览器标签,并导航到http://localhost:<port>/targets,在我的情况下,<port>31962。你应该看到类似这样的东西:

Prometheus Web UI 显示配置的目标

在最后的截图中,我们可以看到我们为 Prometheus 定义了三个目标。列表中只有第三个目标是可用的,并且可以被 Prometheus 访问。这是我们在作业的配置文件中定义的端点,用于从 Prometheus 本身抓取指标。其他两个服务目前没有运行,因此它们的状态是 down。

  1. 现在通过单击 UI 顶部菜单中的相应链接,导航到 Graph。

  2. 打开指标下拉列表,并检查 Prometheus 找到的所有列出的指标。在这种情况下,只有由 Prometheus 服务器本身定义的指标列表:

Prometheus web UI 显示可用的指标

有了这个,我们准备将之前创建的.NET 和 Node 示例服务部署到 Kubernetes 上。

将我们的应用服务部署到 Kubernetes

在我们可以使用之前创建的示例服务并将它们部署到 Kubernetes 之前,我们必须为它们创建 Docker 镜像并将它们推送到容器注册表。在我们的情况下,我们将它们推送到 Docker Hub。

让我们从.NET Core 示例开始:

  1. 找到.NET 项目中的Program.cs文件并打开它。

  2. 修改CreateHostBuilder方法,使其看起来像这样:

Host.CreateDefaultBuilder(args)
    .ConfigureWebHostDefaults(webBuilder =>
    {
        webBuilder.UseStartup<Startup>();
        webBuilder.UseUrls("http://*:5000");
    });
  1. ch17/dotnet/sample-api项目文件夹中添加以下内容的Dockerfile
FROM mcr.microsoft.com/dotnet/core/aspnet:3.1 AS base
WORKDIR /app
EXPOSE 5000

FROM mcr.microsoft.com/dotnet/core/sdk:3.1 AS builder
WORKDIR /src
COPY sample-api.csproj ./
RUN dotnet restore
COPY . .
RUN dotnet build -c Release -o /src/build

FROM builder AS publisher
RUN dotnet publish -c Release -o /src/publish

FROM base AS final
COPY --from=publisher /src/publish .
ENTRYPOINT ["dotnet", "sample-api.dll"]
  1. dotnet/sample-api项目文件夹中使用以下命令创建一个 Docker 镜像:
$ docker image build -t fundamentalsofdocker/ch17-dotnet-api:2.0 .

注意,您可能需要在前后命令中用您自己的 Docker Hub 用户名替换fundamentalsofdocker

  1. 将镜像推送到 Docker Hub:
$ docker image push fundamentalsofdocker/ch17-dotnet-api:2.0

现在我们对 Node 示例 API 做同样的操作:

  1. ch17/node项目文件夹中添加以下内容的Dockerfile
FROM node:13.5-alpine
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD ["npm", "start"]
  1. ch17/node项目文件夹中使用以下命令创建一个 Docker 镜像:
$ docker image build -t fundamentalsofdocker/ch17-node-api:2.0 .

再次注意,您可能需要在前后命令中用您自己的 Docker Hub 用户名替换fundamentalsofdocker

  1. 将镜像推送到 Docker Hub:
$ docker image push fundamentalsofdocker/ch17-node-api:2.0

有了这个,我们准备为部署这两个服务定义必要的 Kubernetes 对象。定义有些冗长,可以在存储库的~/fod/ch17/kube/app-services.yaml文件中找到。请打开该文件并分析其内容。

让我们使用这个文件来部署服务:

  1. 使用以下命令:
$ kubectl apply -f app-services.yaml

deployment.apps/dotnet-api-deployment created
service/dotnet-api-svc created
deployment.apps/node-api-deployment created
service/node-api-svc created
  1. 使用kubectl get all命令双重检查服务是否正常运行。确保 Node 和.NET 示例 API 服务的所有 pod 都正常运行。

  2. 列出所有 Kubernetes 服务,找出每个应用服务的主机端口:

$ kubectl get services

NAME             TYPE        CLUSTER-IP       EXTERNAL-IP   PORT(S)          AGE
dotnet-api-svc   NodePort    10.98.137.249    <none>        5000:30822/TCP   5m29s
grafana-svc      NodePort    10.107.232.211   <none>        8080:31461/TCP   33m
kubernetes       ClusterIP   10.96.0.1        <none>        443/TCP          28d
node-api-svc     NodePort    10.110.15.131    <none>        5000:31713/TCP   5m29s
prometheus-svc   NodePort    10.110.239.245   <none>        9090:31962/TCP   77m

在我的情况下,.NET API 映射到端口30822,Node API 映射到端口31713。您的端口可能不同。

  1. 使用curl访问两个服务的/metrics端点:
$ curl localhost:30822/metrics # HELP process_working_set_bytes Process working set
# TYPE process_working_set_bytes gauge
process_working_set_bytes 95236096
# HELP process_private_memory_bytes Process private memory size
# TYPE process_private_memory_bytes gauge
process_private_memory_bytes 186617856
...

$ curl localhost:31713/metrics
# HELP process_cpu_user_seconds_total Total user CPU time spent in seconds.
# TYPE process_cpu_user_seconds_total counter
process_cpu_user_seconds_total 1.0394399999999997 1578294999302
# HELP process_cpu_system_seconds_total Total system CPU time spent in seconds.
# TYPE process_cpu_system_seconds_total counter
process_cpu_system_seconds_total 0.3370890000000001 1578294999302
...
  1. 在 Prometheus 中双重检查/targets端点,确保这两个微服务现在是可达的:

Prometheus 显示所有目标都正常运行

  1. 为了确保我们为 Node.js 和.NET 服务定义和公开的自定义指标被定义和公开,我们需要至少访问每个服务一次。因此,使用curl多次访问各自的端点:
# access the /weatherforecast endpoint in the .NET service
$ curl localhost:31713/weatherforecast

# and access the /hello endpoint in the Node service 
$ curl localhost:30822/hello

最后一步是将 Grafana 部署到 Kubernetes,这样我们就能够创建复杂和外观吸引人的仪表板,显示我们应用服务和/或基础设施组件的关键指标。

将 Grafana 部署到 Kubernetes

现在让我们也将 Grafana 部署到我们的 Kubernetes 集群中,这样我们就可以像分布式应用程序的所有其他组件一样管理这个工具。作为一个允许我们为监控应用程序创建仪表板的工具,Grafana 可以被认为是使命关键的,因此需要这种对待。

将 Grafana 部署到集群中非常简单。让我们按照以下步骤进行:

  1. ch17/kube文件夹中添加一个名为grafana.yaml的新文件。

  2. 在这个文件中,为 Kubernetes 的 GrafanaDeployment添加定义:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: grafana-deployment
  labels:
    app: grafana
    purpose: monitoring-demo
spec:
  replicas: 1
  selector:
    matchLabels:
      app: grafana
      purpose: monitoring-demo
  template:
    metadata:
      labels:
        app: grafana
        purpose: monitoring-demo
    spec:
      containers:
      - name: grafana
        image: grafana/grafana

在这个定义中没有什么意外。在这个例子中,我们运行了一个单独的 Grafana 实例,并且它使用apppurpose标签进行识别,类似于我们用于 Prometheus 的方式。这次不需要特殊的卷映射,因为我们只使用默认设置。

  1. 我们还需要暴露 Grafana,因此需要将以下片段添加到前面的文件中,以定义 Grafana 的服务:
---
kind: Service
apiVersion: v1
metadata:
  name: grafana-svc
spec:
  type: NodePort
  selector:
    app: grafana
    purpose: monitoring-demo
  ports:
  - name: grafanaui
    protocol: TCP
    port: 3000
    targetPort: 3000

再次,我们使用NodePort类型的服务,以便能够从我们的主机访问 Grafana UI。

  1. 现在我们可以使用这个命令部署 Grafana:
$ kubectl apply -f grafana.yaml deployment.apps/grafana-deployment created
service/grafana-svc created
  1. 让我们找出我们可以访问 Grafana 的端口号是多少:
$ kubectl get services

NAME             TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)          AGE
dotnet-api-svc   NodePort    10.100.250.40   <none>        5000:30781/TCP   16m
grafana-svc      NodePort    10.102.239.176  <none>        3000:32379/TCP   11m
kubernetes       ClusterIP   10.96.0.1       <none>        443/TCP          28d
node-api-svc     NodePort    10.100.76.13    <none>        3000:30731/TCP   16m
prometheus-svc   NodePort    10.104.205.217  <none>        9090:31246/TCP   16m
  1. 打开一个新的浏览器标签,并导航到http://localhost:<port>,其中<port>是您在上一步中确定的端口,在我的情况下是32379。您应该会看到类似于这样的东西:

Grafana 的登录界面

  1. 使用默认的admin用户名登录,密码也是admin。当要求更改密码时,现在点击跳过链接。您将被重定向到主页仪表板。

  2. 在主页仪表板上,点击创建您的第一个数据源,然后从数据源列表中选择 Prometheus。

  3. 为 Prometheus 的 URL 添加http://prometheus-svc:9090,然后点击绿色的保存和测试按钮。

  4. 在 Grafana 中,返回到主页仪表板,然后选择新仪表板。

  5. 单击“添加查询”,然后从指标下拉菜单中选择我们在.NET 示例服务中定义的自定义指标:

在 Grafana 中选择.NET 自定义指标

  1. 将相对时间的值从1h更改为5m(五分钟)。

  2. 更改视图右上角找到的仪表板刷新率为5s(五秒)。

  3. 对 Node 示例服务中定义的自定义指标执行相同操作,这样您的新仪表板上将有两个面板。

  4. 通过查阅grafana.com/docs/grafana/latest/guides/getting_started/中的文档,修改仪表板及其面板以满足您的喜好。

  5. 使用curl访问示例服务的两个端点,并观察仪表板。它可能看起来像这样:

具有两个自定义指标的 Grafana 仪表板

总之,我们可以说 Prometheus 非常适合监视我们的微服务,因为我们只需要公开一个指标端口,因此不需要增加太多复杂性或运行额外的服务。然后,Prometheus 负责定期抓取配置的目标,这样我们的服务就不需要担心发出它们。

故障排除正在生产中运行的服务

推荐的最佳实践是为生产创建最小的镜像,不包含任何绝对不需要的内容。这包括通常用于调试和故障排除应用程序的常用工具,例如 netcat、iostat、ip 或其他工具。理想情况下,生产系统只安装了容器编排软件(如 Kubernetes)和最小的操作系统(如 Core OS)的集群节点。应用程序容器理想情况下只包含绝对必要的二进制文件。这最小化了攻击面和处理漏洞的风险。此外,小型镜像具有快速下载、在磁盘和内存上使用更少空间以及显示更快启动时间的优势。

但是,如果我们 Kubernetes 集群上运行的应用服务之一显示出意外行为,甚至可能崩溃,这可能会成为一个问题。有时,我们无法仅从生成和收集的日志中找到问题的根本原因,因此我们可能需要在集群节点上对组件进行故障排除。

我们可能会想要 SSH 进入给定的集群节点并运行一些诊断工具。但这是不可能的,因为集群节点只运行一个没有安装此类工具的最小 Linux 发行版。作为开发人员,我们现在可以要求集群管理员安装我们打算使用的所有 Linux 诊断工具。但这不是一个好主意。首先,这将为潜在的脆弱软件打开大门,现在这些软件驻留在集群节点上,危及运行在该节点上的所有其他 pod,并且为黑客打开了可以利用的集群本身的大门。此外,无论您有多么信任您的开发人员,直接让开发人员访问生产集群的节点都是一个坏主意。只有有限数量的集群管理员才能够这样做。

更好的解决方案是让集群管理员代表开发人员运行所谓的堡垒容器。这个堡垒或故障排除容器安装了我们需要的所有工具,可以帮助我们找出应用服务中 bug 的根本原因。还可以在主机的网络命名空间中运行堡垒容器;因此,它将完全访问容器主机的所有网络流量。

netshoot 容器

前 Docker 员工 Nicola Kabar 创建了一个方便的 Docker 镜像,名为nicolaka/netshoot,Docker 的现场工程师经常使用它来排查在 Kubernetes 或 Docker Swarm 上运行的生产应用程序。我们为本书创建了该镜像的副本,可在fundamentalsofdocker/netshoot上找到。创建者的这个容器的目的如下:

“目的:Docker 和 Kubernetes 网络故障排除可能变得复杂。通过对 Docker 和 Kubernetes 网络工作原理的适当理解以及正确的工具集,您可以解决这些网络问题。netshoot容器具有一组强大的网络故障排除工具,可用于解决 Docker 网络问题。” - Nicola Kabar

要将此容器用于调试目的,我们可以按照以下步骤进行:

  1. 使用以下命令在 Kubernetes 上启动一个一次性的堡垒容器进行调试:
$ kubectl run tmp-shell --generator=run-pod/v1 --rm -i --tty \
 --image fundamentalsofdocker/netshoot \
 --command -- bash

 bash-5.0#
  1. 您现在可以在此容器中使用ip等工具:
bash-5.0# ip a

在我的机器上,如果我在 Windows 上的 Docker 上运行 pod,结果会类似于以下内容:

1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
 link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
 inet 127.0.0.1/8 scope host lo
 valid_lft forever preferred_lft forever
 2: sit0@NONE: <NOARP> mtu 1480 qdisc noop state DOWN group default qlen 1000
 link/sit 0.0.0.0 brd 0.0.0.0
 4: eth0@if263: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default
 link/ether 52:52:9d:1d:fd:cc brd ff:ff:ff:ff:ff:ff link-netnsid 0
 inet 10.1.0.71/16 scope global eth0
 valid_lft forever preferred_lft forever
  1. 要离开这个故障排除容器,只需按下Ctrl + D或输入exit然后按Enter

  2. 如果我们需要深入一点,并在与 Kubernetes 主机相同的网络命名空间中运行容器,那么我们可以使用这个命令:

$ kubectl run tmp-shell --generator=run-pod/v1 --rm -i --tty \
 --overrides='{"spec": {"hostNetwork": true}}' \
 --image fundamentalsofdocker/netshoot \
 --command -- bash
  1. 如果我们在这个容器中再次运行ip,我们将看到容器主机也能看到的所有veth端点。

netshoot容器安装了工程师在解决与网络相关的问题时所需的所有常用工具。其中一些更常见的工具有ctopcurldhcpingdrillethtooliftopiperfiproute2

摘要

在本章中,您学习了一些用于监视在 Kubernetes 集群上运行的单个服务或整个分布式应用程序的技术。此外,您还调查了在生产环境中运行的应用服务的故障排除,而无需更改集群或运行服务的集群节点。

在本书的下一章中,您将了解在云中运行容器化应用程序的一些最流行的方式。本章包括如何自行托管和使用托管解决方案的示例,并讨论它们的优缺点。微软 Azure 和谷歌云引擎等供应商的完全托管服务也会被简要讨论。

问题

为了评估你的学习进度,请回答以下问题:

  1. 为什么为您的应用服务进行仪器化是重要的?

  2. 你能向一个感兴趣的门外汉描述一下 Prometheus 是什么吗?

  3. 导出 Prometheus 指标很容易。你能用简单的话描述一下如何为 Node.js 应用程序做到这一点吗?

  4. 您需要在生产环境中调试在 Kubernetes 上运行的服务。不幸的是,这个服务产生的日志本身并不能提供足够的信息来准确定位根本原因。您决定直接在相应的 Kubernetes 集群节点上对该服务进行故障排除。您该如何进行?

进一步阅读

以下是一些链接,提供了本章讨论的主题的额外信息:

第十八章:在云中运行容器化应用程序

在上一章中,我们学习了如何在生产环境中部署、监控和排除故障。

在本章中,我们将概述在云中运行容器化应用程序的一些最流行的方法。我们将探讨自托管和托管解决方案,并讨论它们的优缺点。我们将简要讨论来自供应商如 Microsoft Azure 和 Google Cloud Engine 的完全托管的解决方案。

以下是本章将讨论的主题:

  • Amazon Web Services (AWS)上部署和使用 Docker Enterprise Edition (EE)

  • 探索 Microsoft 的Azure Kubernetes Service (AKS)

  • 了解Google Kubernetes Engine (GKE)

阅读完本章后,您将能够做到以下几点:

  • 使用 Docker EE 在 AWS 中创建一个 Kubernetes 集群

  • 在 AWS 上部署和运行一个简单的分布式应用程序的 Docker EE 集群

  • 在 Microsoft 的 AKS 上部署和运行一个简单的分布式应用程序

  • 在 GKE 上部署和运行一个简单的分布式应用程序

技术要求

在本章中,我们将使用 AWS、Microsoft Azure 和 Google Cloud。因此,需要为每个平台拥有一个账户。如果您没有现有账户,可以要求这些云服务提供商提供试用账户。

我们还将使用 GitHub 上我们的labs仓库中~/fod-solution/ch18文件夹中的文件,网址为github.com/PacktPublishing/Learn-Docker---Fundamentals-of-Docker-19.x-Second-Edition/tree/master/ch18

在 AWS 上部署和使用 Docker EE

在这一部分,我们将安装 Docker Universal Control Plane (UCP) 版本 3.0。UCP 是 Docker 企业套件的一部分,支持两种编排引擎,Docker Swarm 和 Kubernetes。UCP 可以在云端或本地安装。甚至可以在 UCP 中使用混合云。

要尝试这个,您需要一个 Docker EE 的有效许可证,或者您可以在 Docker Store 上申请免费测试许可证。

基础设施的规划

在这一部分,我们将设置安装 Docker UCP 所需的基础设施。如果您对 AWS 有一定了解,这相对比较简单。让我们按照以下步骤来做:

  1. 在 AWS 中使用 Ubuntu 16.04 服务器 AMI 创建一个自动扩展组ASG)。配置 ASG 包含三个大小为t2.xlarge的实例。这是此操作的结果:

AWS 上准备好 Docker EE 的 ASG

一旦 ASG 创建完成,并且在继续之前,我们需要稍微打开安全组SG)(我们的 ASG 是其中的一部分),以便我们可以通过 SSH 从我们的笔记本访问它,也以便虚拟机VMs)可以相互通信。

  1. 转到您的 SG 并添加两个新的入站规则,如下所示:

AWS SG 设置

在上面的屏幕截图中,第一个规则允许来自我的个人笔记本(IP 地址为70.113.114.234)的任何流量访问 SG 中的任何资源。第二个规则允许 SG 内部的任何流量。这些设置不适用于生产环境,因为它们太过宽松。但是,对于此演示环境,它们效果很好。

接下来,我们将向您展示如何在我们刚准备好的虚拟机上安装 Docker。

安装 Docker

在配置完集群节点之后,我们需要在每个节点上安装 Docker。按照以下步骤可以轻松实现:

  1. SSH 进入所有三个实例并安装 Docker。使用下载的密钥,SSH 进入第一台机器:
$ ssh -i pets.pem ubuntu@<IP address>

在这里,<IP 地址>是我们要 SSH 进入的 VM 的公共 IP 地址。

  1. 现在我们可以安装 Docker 了。有关详细说明,请参阅dockr.ly/2HiWfBc。我们在~/fod/ch18/aws文件夹中有一个名为install-docker.sh的脚本可以使用。

  2. 首先,我们需要将labs GitHub 存储库克隆到虚拟机中:

$ git clone https://github.com/PacktPublishing/Learn-Docker---Fundamentals-of-Docker-19.x-Second-Edition.git ~/fod
$ cd ~/fod/ch18/aws
  1. 然后,我们运行脚本来安装 Docker:
$ ./install-docker.sh
  1. 脚本完成后,我们可以使用sudo docker version验证 Docker 是否已安装。对其他两个 VM 重复前面的代码。

sudo只在下一个 SSH 会话打开到此 VM 之前是必要的,因为我们已将ubuntu用户添加到docker组中。因此,我们需要退出当前的 SSH 会话并重新连接。这次,sudo不应与docker一起使用。

接下来,我们将展示如何在我们刚准备好的基础设施上安装 Docker UCP。

安装 Docker UCP

我们需要设置一些环境变量,如下所示:

$ export UCP_IP=<IP address>
$ export UCP_FQDN=<FQDN>
$ export UCP_VERSION=3.0.0-beta2

在这里,<IP 地址><FQDN>是我们在 UCP 中安装的 AWS EC2 实例的公共 IP 地址和公共 DNS 名称。

之后,我们可以使用以下命令下载 UCP 需要的所有镜像:

$ docker run --rm docker/ucp:${UCP_VERSION} images --list \
 | xargs -L 1 docker pull

最后,我们可以安装 UCP:

在 AWS 的 VM 中安装 UCP 3.0.0-beta2

现在,我们可以打开浏览器窗口并导航到https://<IP 地址>。使用您的用户名admin和密码adminadmin登录。当要求许可证时,上传您的许可证密钥或按照链接获取试用许可证。

登录后,在左侧的“共享资源”部分下,选择“节点”,然后单击“添加节点”按钮:

向 UCP 添加新节点

在随后的“添加节点”对话框中,请确保节点类型为 Linux,并选择“工作节点”角色。然后,复制对话框底部的docker swarm join命令。SSH 进入您创建的另外两个 VM 并运行此命令,使相应的节点加入 Docker Swarm 作为工作节点:

将节点作为工作节点加入到 UCP 集群

回到 UCP 的 Web UI 中,您应该看到我们现在有三个准备好的节点,如下所示:

UCP 集群中的节点列表

默认情况下,工作节点被配置为只能运行 Docker Swarm 工作负载。但是,这可以在节点详细信息中更改。在此,有三种设置可能:仅 Swarm、仅 Kubernetes 或混合工作负载。让我们从 Docker Swarm 作为编排引擎开始,并部署我们的宠物应用程序。

使用远程管理员管理 UCP 集群

为了能够从我们的笔记本电脑远程管理我们的 UCP 集群,我们需要从 UCP 中创建并下载一个所谓的客户端包。按照以下步骤进行:

  1. 在 UCP Web UI 中,在左侧的“管理员”下,选择“我的个人资料”选项。

  2. 在随后的对话中,选择“新客户端包”选项,然后生成客户端包:

生成并下载 UCP 客户端包

  1. 在您的磁盘上找到并解压下载的包。

  2. 在新的终端窗口中,导航到该文件夹并源化env.sh文件:

$ source env.sh

您应该会得到类似于这样的输出:

Cluster "ucp_34.232.53.86:6443_admin" set.
User "ucp_34.232.53.86:6443_admin" set.
Context "ucp_34.232.53.86:6443_admin" created.

现在,我们可以验证我们确实可以远程访问 UCP 集群,例如,列出集群的所有节点:

列出远程 UCP 集群的所有节点

在下一节中,我们将看看如何使用 Docker Swarm 作为编排引擎将宠物应用程序部署为堆栈。

部署到 Docker Swarm

现在是时候将我们的分布式应用程序部署到由 Docker Swarm 编排的集群了。按照以下步骤进行操作:

  1. 在终端中,导航到〜/fod/ch18/ucp文件夹,并使用stack.yml文件创建pets堆栈:

将宠物堆栈部署到 UCP 集群

  1. 在 UCP Web UI 中,我们可以验证已创建堆栈:

UCP Web UI 中的宠物堆栈列表

  1. 为了测试应用程序,我们可以在主菜单的 Swarm 下导航到 Services。集群中运行的服务列表将显示如下:

宠物堆栈的“web”服务的详细信息

在上述截图中,我们看到了pets堆栈的两个服务webdb。如果我们点击web服务,它的详细信息将显示在右侧。在那里,我们找到了一个条目,发布的端点。

  1. 单击链接,我们的pets应用程序应该显示在浏览器中。

完成后,使用以下命令从控制台中删除堆栈:

$ docker stack rm pets

或者,您可以尝试从 UCP Web UI 中删除该堆栈。

部署到 Kubernetes

从用于远程访问 UCP 集群以使用 Docker Swarm 作为编排引擎部署宠物应用程序的堆栈的同一终端,我们现在可以尝试使用 Kubernetes 作为编排引擎将宠物应用程序部署到 UCP 集群。

确保您仍然在〜/fod/ch18/ucp文件夹中。使用kubectl部署宠物应用程序。首先,我们需要测试是否可以使用 Kubernetes CLI 获取集群的所有节点:

使用 Kubernetes CLI 获取 UCP 集群的所有节点

显然,我的环境已正确配置,并且kubectl确实可以列出 UCP 集群中的所有节点。这意味着我现在可以使用pets.yaml文件中的定义部署宠物应用程序:

使用 Kubernetes CLI 在 UCP 集群中创建宠物应用程序

使用kubectl get all可以列出通过创建的对象。然后在浏览器中,我们可以导航到http://<IP 地址>:<端口>来访问宠物应用程序,其中<IP 地址>是 UCP 集群节点之一的公共 IP 地址,<端口>web Kubernetes 服务发布的端口。

我们在 AWS ASG 中创建了一个由三个 VM 组成的集群,并在其中安装了 Docker 和 UCP 3.0。然后我们将我们著名的宠物应用程序部署到 UCP 集群中,一次使用 Docker Swarm 作为编排引擎,一次使用 Kubernetes。

Docker UCP 是一个平台无关的容器平台,可以在任何云和本地、裸机或虚拟化环境中提供安全的企业级软件供应链。甚至在编排引擎方面也提供了选择的自由。用户可以在 Docker Swarm 和 Kubernetes 之间进行选择。还可以在同一集群中在两个编排器中运行应用程序。

探索微软的 Azure Kubernetes 服务(AKS)

要在 Azure 中尝试微软的与容器相关的服务,我们需要在 Azure 上拥有一个帐户。您可以创建一个试用帐户或使用现有帐户。您可以在此处获取免费试用帐户:https://azure.microsoft.com/en-us/free/。

微软在 Azure 上提供了不同的与容器相关的服务。最容易使用的可能是 Azure 容器实例,它承诺在 Azure 中以最快最简单的方式运行容器,而无需预配任何虚拟机,也无需采用更高级别的服务。如果您想在托管环境中运行单个容器,这项服务确实非常有用。设置非常简单。在 Azure 门户(portal.azure.com)中,您首先创建一个新的资源组,然后创建一个 Azure 容器实例。您只需要填写一个简短的表单,包括容器的名称、要使用的镜像和要打开的端口等属性。容器可以在公共或私有 IP 地址上提供,并且如果崩溃,将自动重新启动。还有一个不错的管理控制台可用,例如用于监视 CPU 和内存等资源消耗。

第二种选择是 Azure 容器服务(ACS),它提供了一种简化创建、配置和管理预配置为运行容器化应用程序的虚拟机集群的方式。ACS 使用 Docker 镜像,并提供了三种编排器选择:Kubernetes、Docker Swarm 和 DC/OS(由 Apache Mesos 提供支持)。微软声称他们的服务可以扩展到数万个容器。ACS 是免费的,您只需要为计算资源付费。

在本节中,我们将集中讨论基于 Kubernetes 的最受欢迎的服务。它被称为 AKS,可以在这里找到:azure.microsoft.com/en-us/services/kubernetes-service/。AKS 使您能够轻松将应用程序部署到云中,并在 Kubernetes 上运行它们。所有繁琐和困难的管理任务都由微软处理,您可以完全专注于您的应用程序。这意味着您永远不必处理诸如安装和管理 Kubernetes、升级 Kubernetes 或升级底层 Kubernetes 节点操作系统等任务。所有这些都由微软 Azure 的专家处理。此外,您永远不必处理etc或 Kubernetes 主节点。这些都对您隐藏,您唯一需要与之交互的是运行您的应用程序的 Kubernetes 工作节点。

准备 Azure CLI

也就是说,让我们开始吧。我们假设您已经创建了一个免费试用账户,或者您正在使用 Azure 上的现有账户。与 Azure 账户交互的方式有很多种。我们将使用在本地计算机上运行的 Azure CLI。我们可以在本地计算机上本地下载和安装 Azure CLI,也可以在本地 Docker for Desktop 上运行容器中的 Azure CLI。由于本书都是关于容器的,让我们选择后一种方法。

Azure CLI 的最新版本可以在 Docker Hub 上找到。让我们拉取它:

$ docker image pull mcr.microsoft.com/azure-cli:latest

我们将从此 CLI 运行一个容器,并在容器内部运行所有后续命令。现在,我们需要克服一个小问题。这个容器将不会安装 Docker 客户端。但我们也将运行一些 Docker 命令,所以我们必须创建一个从前面的镜像派生出来的自定义镜像,其中包含一个 Docker 客户端。需要的Dockerfile可以在~/fod/ch18文件夹中找到,内容如下:

FROM mcr.microsoft.com/azure-cli:latest
RUN apk update && apk add docker

在第 2 行,我们只是使用 Alpine 软件包管理器apk来安装 Docker。然后我们可以使用 Docker Compose 来构建和运行这个自定义镜像。相应的docker-compose.yml文件如下:

version: "2.4"
services:
    az:
        image: fundamentalsofdocker/azure-cli
        build: .
        command: tail -F anything
        working_dir: /app
        volumes:
            - /var/run/docker.sock:/var/run/docker.sock
            - .:/app

请注意用于保持容器运行的命令,以及在volumes部分中挂载 Docker 套接字和当前文件夹的命令。如果您在 Windows 上运行 Docker for Desktop,则需要定义COMPOSE_CONVERT_WINDOWS_PATHS环境变量以能够挂载 Docker 套接字。使用

从 Bash shell 执行export COMPOSE_CONVERT_WINDOWS_PATHS=1,或者在运行 PowerShell 时执行$Env:COMPOSE_CONVERT_WINDOWS_PATHS=1。请参考以下链接获取更多详情:github.com/docker/compose/issues/4240

现在,让我们构建并运行这个容器:

$ docker-compose up --build -d

然后,让我们进入az容器,并在其中运行一个 Bash shell,命令如下:

$ docker-compose exec az /bin/bash

bash-5.0#

我们将发现自己在容器内部的 Bash shell 中运行。让我们首先检查 CLI 的版本:

bash-5.0# az --version

这应该会产生类似于以下内容的输出(缩短版):

azure-cli 2.0.78
...
Your CLI is up-to-date.

好的,我们正在运行版本2.0.78。接下来,我们需要登录到我们的账户。执行以下命令:

bash-5.0# az login

您将收到以下消息:

To sign in, use a web browser to open the page https://microsoft.com/devicelogin and enter the code <code> to authenticate.

按照说明通过浏览器登录。一旦您成功验证了您的 Azure 账户,您可以回到您的终端,您应该已经登录了,这将由您得到的输出所指示:

[
  {
    "cloudName": "AzureCloud",
    "id": "<id>",
    "isDefault": true,
    "name": "<account name>",
    "state": "Enabled",
    "tenantId": "<tenant-it>",
    "user": {
      "name": "xxx@hotmail.com",
      "type": "user"
    }
  }
]

现在,我们准备首先将我们的容器映像移动到 Azure。

在 Azure 上创建一个容器注册表

首先,我们创建一个名为animal-rg的新资源组。在 Azure 中,资源组用于逻辑地组合一组相关的资源。为了获得最佳的云体验并保持延迟低,重要的是您选择一个靠近您的地区的数据中心。您可以使用以下命令列出所有地区:

bash-5.0# az account list-locations 
[
  {
    "displayName": "East Asia",
    "id": "/subscriptions/186760ad-9152-4499-b317-c9bff441fb9d/locations/eastasia",
    "latitude": "22.267",
    "longitude": "114.188",
    "name": "eastasia",
    "subscriptionId": null
  },
  ...
]

这将为您提供一个相当长的列表,列出了您可以选择的所有可能区域。使用name,例如eastasia,来标识您选择的区域。在我的情况下,我将选择westeurope。请注意,并非所有列出的位置都适用于资源组。

创建资源组的命令很简单;我们只需要为组和位置命名:

bash-5.0# az group create --name animals-rg --location westeurope

{
  "id": "/subscriptions/186760ad-9152-4499-b317-c9bff441fb9d/resourceGroups/animals-rg",
  "location": "westeurope",
  "managedBy": null,
  "name": "animals-rg",
  "properties": {    
    "provisioningState": "Succeeded"
  },
  "tags": null,
  "type": "Microsoft.Resources/resourceGroups"
}

确保您的输出显示"provisioningState": "Succeeded"

在生产环境中运行容器化应用程序时,我们希望确保可以自由地从容器注册表中下载相应的容器图像。到目前为止,我们一直从 Docker Hub 下载我们的图像。但这通常是不可能的。出于安全原因,生产系统的服务器通常无法直接访问互联网,因此无法访问 Docker Hub。让我们遵循这个最佳实践,并假设我们即将创建的 Kubernetes 集群也是如此。

那么,我们能做什么呢?嗯,解决方案是使用一个靠近我们集群的容器镜像注册表,并且处于相同的安全上下文中。在 Azure 中,我们可以创建一个Azure 容器注册表ACR)并在那里托管我们的图像。让我们首先创建这样一个注册表:

bash-5.0# az acr create --resource-group animals-rg --name <acr-name> --sku Basic

请注意,<acr-name>需要是唯一的。在我的情况下,我选择了名称fodanimalsacr。输出(缩短版)如下所示:

{
 "adminUserEnabled": false,
 "creationDate": "2019-12-22T10:31:14.848776+00:00",
 "id": "/subscriptions/186760ad...",
 "location": "westeurope",
 "loginServer": "fodanimalsacr.azurecr.io",
 "name": "fodanimalsacr",
 ...
 "provisioningState": "Succeeded",

成功创建容器注册表后,我们需要使用以下命令登录到该注册表:

bash-5.0# az acr login --name <acr-name> 
Login Succeeded
WARNING! Your password will be stored unencrypted in /root/.docker/config.json.
Configure a credential helper to remove this warning. See
https://docs.docker.com/engine/reference/commandline/login/#credentials-store

一旦我们成功登录到 Azure 上的容器注册表,我们需要正确标记我们的容器,以便我们可以将它们推送到 ACR。接下来将描述标记和推送图像到 ACR。

将我们的图像推送到 ACR

一旦我们成功登录到 ACR,我们就可以标记我们的图像,以便它们可以推送到注册表。为此,我们需要获取我们 ACR 实例的 URL。我们可以使用以下命令来实现:

$ az acr list --resource-group animals-rg \
 --query "[].{acrLoginServer:loginServer}" \
 --output table

AcrLoginServer
------------------------
fodanimalsacr.azurecr.io

现在我们使用前面的 URL 来标记我们的图像:

bash-5.0# docker image tag fundamentalsofdocker/ch11-db:2.0 fodanimalsacr.azurecr.io/ch11-db:2.0
bash-5.0# docker image tag fundamentalsofdocker/ch11-web:2.0 fodanimalsacr.azurecr.io/ch11-web:2.0

然后,我们可以将它们推送到我们的 ACR 中:

bash-5.0# docker image push fodanimalsacr.azurecr.io/ch11-db:2.0
bash-5.0# docker image push fodanimalsacr.azurecr.io/ch11-web:2.0

为了再次检查我们的图像确实在我们的 ACR 中,我们可以使用这个命令:

bash-5.0# az acr repository  list --name  <acr-name> --output **table 
Result
--------
ch11-db
ch11-web 

实际上,我们刚刚推送的两个图像已列出。有了这个,我们就可以创建我们的 Kubernetes 集群了。

创建 Kubernetes 集群

我们将再次使用我们的自定义 Azure CLI 来创建 Kubernetes 集群。我们必须确保集群可以访问我们刚刚创建的 ACR 实例,那里存放着我们的容器映像。因此,创建一个名为animals-cluster的集群,带有两个工作节点的命令如下:

bash-5.0# az aks create \
 --resource-group animals-rg \
 --name animals-cluster \
 --node-count 2 \
 --generate-ssh-keys \
 --attach-acr <acr-name>

这个命令需要一段时间,但几分钟后,我们应该会收到一些 JSON 格式的输出,其中包含了关于新创建的集群的所有细节。

要访问集群,我们需要kubectl。我们可以使用这个命令在我们的 Azure CLI 容器中轻松安装它:

bash-5.0# az aks install-cli

安装了kubectl之后,我们需要必要的凭据来使用这个工具在 Azure 中操作我们的新 Kubernetes 集群。我们可以用这个命令获取必要的凭据:

bash-5.0# az aks get-credentials --resource-group animals-rg --name animals-cluster 
Merged "animals-cluster" as current context in /root/.kube/config

在上一个命令成功执行后,我们可以列出集群中的所有节点:

bash-5.0# kubectl get nodes NAME                                STATUS   ROLES   AGE     VERSION
aks-nodepool1-12528297-vmss000000   Ready    agent   4m38s   v1.14.8
aks-nodepool1-12528297-vmss000001   Ready    agent   4m32s   v1.14.8

正如预期的那样,我们有两个工作节点正在运行。这些节点上运行的 Kubernetes 版本是1.14.8

现在我们已经准备好将我们的应用程序部署到这个集群中。在下一节中,我们将学习如何做到这一点。

将我们的应用程序部署到 Kubernetes 集群

要部署应用程序,我们可以使用kubectl apply命令:

bash-5.0# kubectl apply -f animals.yaml 

上一个命令的输出应该类似于这样:

deployment.apps/web created
service/web created
deployment.apps/db created
service/db created

现在,我们想要测试这个应用程序。记住,我们为 web 组件创建了一个LoadBalancer类型的服务。这个服务将应用程序暴露给互联网。这个过程可能需要一些时间,因为 AKS 除了其他任务外,还需要为这个服务分配一个公共 IP 地址。我们可以用以下命令观察到这一点:

bash-5.0# kubectl get service web --watch

请注意上一个命令中的--watch参数。它允许我们随着时间监视命令的进展。最初,我们应该看到类似于这样的输出:

NAME TYPE        CLUSTER-IP  EXTERNAL-IP  PORT(S)         AGE
web LoadBalancer 10.0.124.0  <pending>    3000:32618/TCP  5s

公共 IP 地址标记为待定。几分钟后,应该会变成这样:

NAME TYPE        CLUSTER-IP  EXTERNAL-IP    PORT(S)         AGE
web LoadBalancer 10.0.124.0  51.105.229.192 3000:32618/TCP  63s

我们的应用程序现在准备就绪,位于 IP 地址51.105.229.192和端口号3000。请注意,负载均衡器将内部端口32618映射到外部端口3000;这在第一次对我来说并不明显。

让我们来检查一下。在新的浏览器标签中,导航至http://51.105.229.192:3000/pet,你应该能看到我们熟悉的应用程序:

我们在 AKS 上运行的示例应用程序

有了这个,我们已成功将我们的分布式应用部署到了 Azure 中托管的 Kubernetes。我们不必担心安装或管理 Kubernetes;我们可以专注于应用本身。

现在我们已经完成了对应用程序的实验,我们不应忘记在 Azure 上删除所有资源,以避免产生不必要的成本。我们可以通过删除资源组来删除所有创建的资源,方法如下:

bash-5.0# az group delete --name animal-rg --yes --no-wait 

Azure 在容器工作负载方面有一些引人注目的提供,由于 Azure 主要提供开源编排引擎(如 Kubernetes、Docker Swarm、DC/OS 和 Rancher),因此与 AWS 相比,锁定不太明显。从技术上讲,如果我们最初在 Azure 中运行我们的容器化应用程序,然后决定迁移到另一个云提供商,我们仍然可以保持灵活性。成本应该是有限的。

值得注意的是,当您删除资源组时,AKS 集群使用的 Azure Active Directory 服务主体不会被删除。有关如何删除服务主体的详细信息,请参考在线帮助。

接下来是谷歌和他们的 Kubernetes Engine。

了解 GKE

谷歌是 Kubernetes 的发明者,迄今为止,也是其背后的推动力。因此,您会期望谷歌在托管 Kubernetes 方面有一个引人注目的提供。现在让我们来看一下。要继续,您需要在谷歌云上拥有现有帐户或在此创建一个测试帐户:console.cloud.google.com/freetrial。按照以下步骤进行:

  1. 在主菜单中,选择 Kubernetes Engine。第一次这样做时,Kubernetes 引擎初始化需要一些时间。

  2. 接下来,创建一个新项目并将其命名为massai-mara;这可能需要一些时间。

  3. 一旦准备就绪,我们可以通过点击弹出窗口中的 Create Cluster 来创建一个集群。

  4. 在表单的左侧选择Your first cluster模板。

  5. 将集群命名为animals-cluster,选择离您最近的区域或区域,将创建 Kubernetes 集群表单中的所有其他设置保持为默认值,并在表单底部点击 Create。

这将再次花费一些时间为我们提供集群。一旦集群创建完成,我们可以通过点击视图右上角的 shell 图标来打开 Cloud Shell。这应该看起来类似于以下截图:

第一个 Kubernetes 集群已准备就绪,并且 Cloud Shell 在 GKE 中打开

现在,我们可以使用以下命令将我们的labsGitHub 存储库克隆到这个环境中:

$ git clone https://github.com/PacktPublishing/Learn-Docker---  Fundamentals-of-Docker-19.x-Second-Edition.git ~/fod
$ cd ~/fod/ch18/gce

现在,我们应该在当前文件夹中找到一个animals.yaml文件,我们可以使用它来将动物应用程序部署到我们的 Kubernetes 集群中。看一下这个文件:

$ less animals.yaml

它的内容基本与我们在上一章中使用的文件相同。两个不同之处在于:

  • 我们使用LoadBalancer类型的服务(而不是NodePort)来公开web组件。

  • 我们不使用卷来配置 PostgreSQL 数据库,因为在 GKE 上正确配置 StatefulSets 比在 Minikube 中更复杂一些。这样做的后果是,如果db pod 崩溃,我们的动物应用程序将不会保持状态。如何在 GKE 上使用持久卷超出了本书的范围。

另外,请注意,我们不是使用 Google 容器注册表来托管容器映像,而是直接从 Docker Hub 拉取它们。在 Google Cloud 中创建这样的容器注册表非常简单,类似于我们在 AKS 部分学到的内容。

在继续之前,我们需要设置gcloudkubectl凭据:

$ gcloud container clusters get-credentials animals-cluster --zone europe-west1-b 
Fetching cluster endpoint and auth data.
kubeconfig entry generated for animals-cluster.

完成这些操作后,现在是部署应用程序的时候了:

$ kubectl create -f animals.yaml 
deployment.apps/web created
service/web created
deployment.apps/db created
service/db created

创建对象后,我们可以观察LoadBalancer服务web,直到它被分配一个公共 IP 地址:

$ kubectl get svc/web --watch NAME   TYPE           CLUSTER-IP   EXTERNAL-IP     PORT(S)          AGE
web    LoadBalancer   10.0.5.222   <pending>       3000:32139/TCP   32s
web    LoadBalancer   10.0.5.222   146.148.23.70   3000:32139/TCP   39s

输出中的第二行显示了负载均衡器创建仍在等待的情况,第三行显示了最终状态。按Ctrl + C退出watch命令。显然,我们得到了分配的公共 IP 地址146.148.23.70,端口为3000

然后,我们可以使用此 IP 地址并导航至http://<IP 地址>:3000/pet,我们应该会看到熟悉的动物图像。

完成应用程序的操作后,请删除 Google Cloud 控制台中的集群和项目,以避免不必要的成本。

我们在 GKE 中创建了一个托管的 Kubernetes 集群。然后,我们使用 GKE 门户提供的 Cloud Shell 首先克隆了我们的labsGitHub 存储库,然后使用kubectl工具将动物应用程序部署到 Kubernetes 集群中。

在研究托管的 Kubernetes 解决方案时,GKE 是一个引人注目的选择。它非常容易上手,而且由于 Google 是 Kubernetes 背后的主要推动力,我们可以放心地利用 Kubernetes 的全部功能。

总结

在本书的最后一章中,你首先快速了解了如何安装和使用 Docker 的 UCP,这是 Docker 在 AWS 上的企业产品的一部分。然后,你学会了如何在 AKS 上创建一个托管的 Kubernetes 集群,并在其上运行动物应用程序,接着是在 Google 自己的托管 Kubernetes 解决方案 GKE 上做同样的操作。

我很荣幸你选择了这本书,我想感谢你陪伴我一起探索 Docker 容器和容器编排引擎的旅程。我希望这本书对你的学习之旅有所帮助。祝你在当前和未来的项目中使用容器时一切顺利并取得成功。

问题

为了评估你的知识,请回答以下问题:

  1. 请提供所需的任务高级描述,以在 AWS 上配置和运行 Docker UPC。

  2. 列举一些选择托管的 Kubernetes 解决方案(如 Microsoft 的 AKS 或 Google 的 GKE)来在 Kubernetes 上运行应用程序的原因。

  3. 列举两个使用托管的 Kubernetes 解决方案(如 AKS 或 GKE)时,考虑将容器映像托管在相应云提供商的容器注册表的原因。

进一步阅读

以下文章为你提供了一些与本章讨论的主题相关的更多信息:

第十九章:评估

第一章

以下是本章提出的问题的一些示例答案:

  1. 正确答案是DE

  2. Docker 容器对 IT 来说就像运输业的集装箱一样。它定义了如何打包货物的标准。在这种情况下,货物是开发人员编写的应用程序。供应商(在这种情况下是开发人员)负责将货物打包到集装箱中,并确保一切都符合预期。一旦货物被打包到集装箱中,它就可以被运输。由于它是一个标准的集装箱,承运人可以标准化他们的运输方式,如卡车、火车或船只。承运人并不真正关心集装箱里装的是什么。此外,从一种运输方式到另一种运输方式(例如,从火车到船)的装卸过程可以高度标准化。这极大地提高了运输的效率。类似于这一点的是 IT 中的运维工程师,他可以接收开发人员构建的软件容器,并以高度标准化的方式将其运输到生产系统并在那里运行,而不必担心容器里装的是什么。它会正常工作。

  3. 容器改变游戏规则的一些原因如下:

  • 容器是自包含的,因此如果它们在一个系统上运行,它们就可以在任何容器可以运行的地方运行。

  • 容器可以在本地和云端以及混合环境中运行。这对于今天的典型企业非常重要,因为它允许顺利地从本地过渡到云端。

  • 容器镜像是由最了解的人构建或打包的-开发人员。

  • 容器镜像是不可变的,这对于良好的发布管理非常重要。

  • 容器是基于封装(使用 Linux 命名空间和 cgroups)、秘密、内容信任和镜像漏洞扫描的安全软件供应链的推动者。

  1. 任何给定的容器之所以可以在任何容器可以运行的地方运行,是因为:
    • 容器是自包含的黑匣子。它们不仅封装了应用程序,还包括所有的依赖项,如库和框架、配置数据、证书等。
  • 容器是基于广泛接受的标准,如 OCI。

  1. 答案是B。容器对于现代应用程序以及将传统应用程序容器化都非常有用。对企业来说,后者的好处是巨大的。据报道,维护传统应用程序的成本节约了 50%或更多。这些传统应用程序发布新版本的时间可以减少高达 90%。这些数字是由真实的企业客户公开报道的。

  2. 50%或更多。

  3. 容器基于 Linux 命名空间(网络、进程、用户等)和 cgroups(控制组)。

第二章

以下是本章中提出的问题的一些示例答案:

  1. docker-machine可用于以下情景:

  2. 在各种提供商上创建一个 VM,例如 VirtualBox、Hyper-V、AWS、MS Azure 或 Google Compute Engine,该 VM 将用作 Docker 主机。

  3. 启动、停止或终止先前生成的 VM。

  4. 通过此工具创建的本地或远程 Docker 主机 VM 进行 SSH。

  5. 重新生成用于安全使用 Docker 主机 VM 的证书。

  6. A. 是的,使用 Docker for Windows,您可以开发和运行 Linux 容器。还可以使用此版本的 Docker for Desktop 开发和运行本机 Windows 容器,但本书中未讨论。使用 macOS 版本,您只能开发和运行 Linux 容器。

  7. 脚本用于自动化流程,从而避免人为错误。构建、测试、共享和运行 Docker 容器是应该始终自动化以增加其可靠性和可重复性的任务。

  8. 以下 Linux 发行版已获得 Docker 认证:RedHat Linux(RHEL)、CentOS、Oracle Linux、Ubuntu 等。

  9. 以下 Windows 操作系统已获得 Docker 认证:Windows 10 专业版,Windows Server 2016 和 Windows Server 2019

第三章

以下是本章中提出的问题的一些示例答案:

  1. Docker 容器的可能状态如下:
  • 已创建:已创建但尚未启动的容器

  • 重新启动:正在重新启动的容器

  • 运行:当前正在运行的容器

  • 暂停:进程已暂停的容器

  • 退出:运行并完成的容器

  • 死亡:Docker 引擎尝试但未能停止的容器

  1. 我们可以使用docker container ls(或旧的更短版本docker ps)来列出当前在 Docker 主机上运行的所有容器。请注意,这不会列出已停止的容器,对于这些容器,您需要额外的参数--all(或-a)。

  2. 要列出所有容器的 ID,无论是运行还是停止,我们可以使用docker container ls -a -q,其中-q表示仅输出 ID。

第四章

以下是本章中提出的问题的一些示例答案:

  1. Dockerfile可能是这样的:
FROM ubuntu:19.04
RUN apt-get update && \
    apt-get install -y iputils-ping
CMD ping 127.0.0.1

请注意,在 Ubuntu 中,ping工具是iputils-ping包的一部分。构建名为pinger的镜像,例如,使用docker image build -t my-pinger

  1. Dockerfile可能是这样的:
FROM alpine:latest
RUN apk update && \
    apk add curl

使用docker image build -t my-alpine:1.0构建镜像。

  1. 用于 Go 应用程序的Dockerfile可能是这样的:
FROM golang:alpine
WORKDIR /app
ADD . /app
RUN cd /app && go build -o goapp
ENTRYPOINT ./goapp

您可以在~/fod/ch04/answer03文件夹中找到完整的解决方案。

  1. Docker 镜像具有以下特征:

  2. 它是不可变的。

  3. 它由一到多个层组成。

  4. 它包含打包应用程序运行所需的文件和文件夹。

  5. C. 首先,您需要登录 Docker Hub;然后,使用用户名正确标记您的镜像;最后,推送镜像。

第五章

以下是本章中提出的问题的一些示例答案:

玩弄卷的最简单方法是使用 Docker Toolbox,因为当直接使用 Docker for Desktop 时,卷存储在 Docker for Desktop 透明使用的(有点隐藏的)Linux VM 中。

因此,我们建议以下操作:

$ docker-machine create --driver virtualbox volume-test
$ docker-machine ssh volume-test

现在您在名为volume-test的 Linux VM 中,可以进行以下练习:

  1. 创建一个命名卷,运行以下命令:
$ docker volume create my-products
  1. 执行以下命令:
$ docker container run -it --rm \
 -v my-products:/data:ro \
 alpine /bin/sh
  1. 要获取卷在主机上的路径,请使用此命令:
$ docker volume inspect my-products | grep Mountpoint

(如果您使用docker-machine和 VirtualBox)应该导致这样:

"Mountpoint": "/mnt/sda1/var/lib/docker/volumes/myproducts/_data"

现在执行以下命令:

$ sudo su
$ cd /mnt/sda1/var/lib/docker/volumes/my-products/_data
$ echo "Hello world" > sample.txt
$ exit
  1. 执行以下命令:
$ docker run -it --rm -v my-products:/data:ro alpine /bin/sh
/ # cd /data
/data # cat sample.txt

在另一个终端中,执行此命令:

$ docker run -it --rm -v my-products:/app-data alpine /bin/sh
/ # cd /app-data
/app-data # echo "Hello other container" > hello.txt
/app-data # exit
  1. 执行这样的命令:
$ docker container run -it --rm \
 -v $HOME/my-project:/app/data \
 alpine /bin/sh
  1. 退出两个容器,然后回到主机上,执行此命令:
$ docker volume prune
  1. 答案是 B。每个容器都是一个沙盒,因此具有自己的环境。

  2. 收集所有环境变量及其相应的值到一个配置文件中,然后在docker run命令中使用--env-file命令行参数将其提供给容器,就像这样:

$ docker container run --rm -it \
 --env-file ./development.config \
 alpine sh -c "export"

第六章

以下是本章中提出的问题的一些示例答案:

  1. 可能的答案:a) 在容器中挂载源代码;b) 使用工具,在检测到代码更改时自动重新启动容器内运行的应用程序;c) 为远程调试配置容器。

  2. 您可以在容器中将包含源代码的文件夹挂载到主机上。

  3. 如果你无法轻松地通过单元测试或集成测试覆盖某些场景,如果观察到的应用程序行为无法在主机上重现。另一种情况是由于缺乏必要的语言或框架,无法直接在主机上运行应用程序的情况。

  4. 一旦应用程序在生产环境中运行,作为开发人员,我们无法轻易访问它。如果应用程序出现意外行为甚至崩溃,日志通常是我们唯一的信息来源,帮助我们重现情况并找出错误的根本原因。

第七章

以下是本章提出的问题的一些示例答案:

  1. 优缺点:
  • 优点:我们不需要在主机上安装任务所需的特定 shell、工具或语言。

  • 优点:我们可以在任何 Docker 主机上运行,从树莓派到大型计算机;唯一的要求是主机能够运行容器。

  • 优点:成功运行后,当容器被移除时,工具会从主机上完全清除痕迹。

  • 缺点:我们需要在主机上安装 Docker。

  • 缺点:用户需要对 Docker 容器有基本的了解。

  • 缺点:使用该工具比直接在本机使用要间接一些。

  1. 在容器中运行测试具有以下优点:
  • 它们在开发者机器上和测试或 CI 系统上同样运行良好。

  • 更容易以相同的初始条件开始每次测试运行。

  • 所有使用代码的开发人员使用相同的设置,例如库和框架的版本。

  1. 在这里,我们期望看到一个图表,显示开发人员编写代码并将其检入,例如 GitHub。然后我们希望在图中看到一个自动化服务器,比如 Jenkins 或 TeamCity,它要么定期轮询 GitHub 进行更改,要么 GitHub 触发自动化服务器(通过 HTTP 回调)创建新的构建。图表还应显示自动化服务器然后运行所有测试以针对构建的工件,如果所有测试都成功,则部署应用程序或服务到集成系统,在那里再次进行测试,例如进行一些 smoke 测试。再次,如果这些测试成功,自动化服务器应该要么要求人类批准部署到生产环境(这相当于持续交付),要么自动部署到生产环境(持续部署)。

第八章

以下是本章提出的问题的一些示例答案:

  1. 您可能正在使用资源或功能有限的工作站工作,或者您的工作站可能被公司锁定,以便您不被允许安装任何未经官方批准的软件。有时,您可能需要使用公司尚未批准的语言或框架进行概念验证或实验(但如果概念验证成功,可能将来会被批准)。

  2. 将 Docker 套接字绑定到容器是当容器化应用程序需要自动执行一些与容器相关的任务时的推荐方法。这可以是一个应用程序,比如您正在使用它来构建、测试和部署 Docker 镜像的自动化服务器,比如 Jenkins。

  3. 大多数商业应用程序不需要根级授权来完成其工作。从安全的角度来看,强烈建议以尽可能少的访问权限来运行这些应用程序。任何不必要的提升权限都可能被黑客利用进行恶意攻击。通过以非根用户身份运行应用程序,可以使潜在黑客更难以 compromise 您的系统。

  4. 卷包含数据,数据的寿命往往需要远远超出容器或应用程序的生命周期。数据通常是至关重要的,并且需要安全地存储数天、数月,甚至数年。当您删除一个卷时,您将不可逆地删除与其关联的数据。因此,在删除卷时,请确保知道自己在做什么。

第九章

以下是本章提出的问题的一些示例答案:

  1. 在分布式应用架构中,软件和基础设施的每个部分在生产环境中都需要冗余,因为应用的持续运行时间至关重要。高度分布式的应用程序由许多部分组成,其中一个部分失败或行为不当的可能性随着部分数量的增加而增加。可以保证,足够长的时间后,每个部分最终都会失败。为了避免应用中断,我们需要在每个部分都有冗余,无论是服务器、网络交换机还是在容器中运行的集群节点上的服务。

  2. 在高度分布式、可扩展和容错的系统中,应用程序的各个服务可能会因为扩展需求或组件故障而移动。因此,我们不能将不同的服务硬编码在一起。需要访问服务 B 的服务 A 不应该知道诸如服务 B 的 IP 地址之类的细节,而应该依赖于提供此信息的外部提供者。DNS 就是这样一个位置信息的提供者。服务 A 只需告诉 DNS 它想要与服务 B 通信,DNS 服务将找出详细信息。

  3. 断路器是避免级联故障的一种手段,如果分布式应用程序中的一个组件失败或行为不当。类似于电气布线中的断路器,软件驱动的断路器会切断客户端与失败服务之间的通信。如果调用了失败的服务,断路器将直接向客户端组件报告错误。这为系统提供了从故障中恢复或修复的机会。

  4. 单体应用程序比多服务应用程序更容易管理,因为它由单个部署包组成。另一方面,单体应用程序很难扩展以满足增加的需求。在分布式应用程序中,每个服务都可以单独扩展,并且每个服务都可以在优化的基础设施上运行,而单体应用程序需要在适用于其实现的所有或大多数功能的基础设施上运行。维护和更新单体应用程序比多服务应用程序要困难得多,因为每个服务都可以独立更新和部署。单体通常是一堆复杂且紧密耦合的代码。小的修改可能会产生意想不到的副作用。另一方面,(微)服务是自包含的、简单的组件,其行为类似于黑匣子。依赖服务对服务的内部工作一无所知,因此不依赖于它。

  5. 蓝绿部署是一种软件部署形式,允许无零停机部署应用程序或应用程序服务的新版本。例如,如果服务 A 需要使用新版本进行更新,那么我们称当前运行的版本为蓝色。服务的新版本部署到生产环境,但尚未与应用程序的其余部分连接。这个新版本被称为绿色。一旦部署成功并且冒烟测试表明它已经准备就绪,将负责将流量引导到蓝色的路由器重新配置为切换到绿色。观察绿色的行为一段时间,如果一切正常,蓝色将被废弃。另一方面,如果绿色造成困难,路由器可以简单地切换回蓝色,然后修复绿色并稍后重新部署。

第十章

以下是本章中提出的一些问题的样本答案:

  1. 三个核心元素是沙盒端点网络

  2. 执行此命令:

$ docker network create --driver bridge frontend
  1. 运行此命令:

$ docker container run -d --name n1 \
 --network frontend -p 8080:80 nginx:alpine
$ docker container run -d --name n2 \
 --network frontend -p 8081:80 nginx:alpine

测试两个 NGINX 实例是否正常运行:

$ curl -4 localhost:8080
$ curl -4 localhost:8081

在这两种情况下,您应该看到 NGINX 的欢迎页面。

  1. 要获取所有已附加容器的 IP,请运行此命令:
$ docker network inspect frontend | grep IPv4Address

您应该看到类似以下内容:

"IPv4Address": "172.18.0.2/16",
"IPv4Address": "172.18.0.3/16",

要获取网络使用的子网,请使用以下命令(例如):

$ docker network inspect frontend | grep subnet

您应该收到类似以下内容的信息(从上一个示例中获得):

"Subnet": "172.18.0.0/16",
  1. “主机”网络允许我们在主机的网络命名空间中运行容器。

  2. 仅在调试目的或构建系统级工具时使用此网络。永远不要在运行生产环境的应用程序容器中使用host网络!

  3. none网络基本上表示容器未连接到任何网络。它应该用于不需要与其他容器通信并且不需要从外部访问的容器。

  4. 例如,none网络可以用于在容器中运行的批处理过程,该过程只需要访问本地资源,例如可以通过主机挂载卷访问的文件。

  5. Traefik 可用于提供第 7 层或应用程序级别的路由。如果要从单体中分离功能并具有明确定义的 API,则这将非常有用。在这种情况下,您需要重新路由某些 HTTP 调用到新的容器/服务。这只是可能的使用场景之一,但也是最重要的一个。另一个可能是将 Traefik 用作负载均衡器。

第十一章

以下是本章提出的问题的一些示例答案:

  1. 以下代码可用于以分离或守护程序模式运行应用程序:
$ docker-compose up -d
  1. 执行以下命令以显示运行服务的详细信息:
$ docker-compose ps

这应该导致以下输出:

Name               Command               State  Ports
-------------------------------------------------------------------
mycontent_nginx_1  nginx -g daemon off;  Up     0.0.0.0:3000->80/tcp
  1. 以下命令可用于扩展 Web 服务:
$ docker-compose up --scale web=3

第十二章

以下是本章提出的问题的一些示例答案:

  1. 作为高度分布式的关键任务,高可用性应用程序实现为相互连接的应用程序服务系统,这些系统过于复杂,无法手动监视、操作和管理。容器编排器在这方面有所帮助。它们自动化了大部分典型任务,例如协调所需状态,或收集和聚合系统的关键指标。人类无法快速反应,以使这样的应用程序具有弹性或自我修复能力。软件支持是必要的,这就是所提到的容器编排器的形式。

  2. 容器编排器使我们摆脱了以下繁琐和繁重的任务:

  • 扩展服务的规模

  • 负载均衡请求

  • 将请求路由到所需的目标

  • 监视服务实例的健康状况

  • 保护分布式应用程序

  1. 在这个领域的赢家是 Kubernetes,它是由 CNCF 开源并拥有。它最初是由 Google 开发的。我们还有 Docker Swarm,它是专有的,并由 Docker 开发。AWS 提供了一个名为 ECS 的容器服务,它也是专有的,并且与 AWS 生态系统紧密集成。最后,微软提供了 AKS,它与 AWS ECS 具有相同的优缺点。

第十三章

以下是本章中提出的一些问题的样本答案:

  1. 正确答案如下:
$ docker swarm init [--advertise-addr <IP address>]

--advertise-addr是可选的,只有在主机有多个 IP 地址时才需要。

  1. 在要移除的工作节点上执行以下命令:
 $ docker swarm leave

在其中一个主节点上执行命令$ docker node rm -f<node ID>,其中<node ID>是要移除的工作节点的 ID。

  1. 正确答案如下:
$ docker network create \
 --driver overlay \
 --attachable \
 front-tier
  1. 正确答案如下:
$ docker service create --name web \
 --network front-tier \
 --replicas 5 \
 -p 3000:80 \
 nginx:alpine
  1. 正确答案如下:
$ docker service update --replicas 3 web

第十四章

以下是本章中提出的一些问题的样本答案:

  1. 零停机部署意味着在分布式应用程序中,服务的新版本可以更新到新版本,而无需停止应用程序的运行。通常情况下,使用 Docker SwarmKit 或 Kubernetes(如我们将看到的那样),这是以滚动方式完成的。一个服务由多个实例组成,这些实例会分批更新,以确保大多数实例始终处于运行状态。

  2. 默认情况下,Docker SwarmKit 使用滚动更新策略来实现零停机部署。

  3. 容器是部署的自包含单元。如果部署的服务的新版本不如预期地工作,我们(或系统)只需要回滚到以前的版本。服务的以前版本也是以自包含容器的形式部署的。在概念上,向前(更新)或向后(回滚)滚动没有区别。一个容器的版本被另一个版本替换。主机本身不会受到这些变化的任何影响。

  4. Docker secrets 在静止状态下是加密的。它们只会传输给使用这些秘密的服务和容器。秘密之间的通信是加密的,因为 swarm 节点之间的通信使用相互 TLS。秘密从未在工作节点上物理存储。

  5. 实现此目的的命令如下:

$ docker service update --image acme/inventory:2.1 \
 --update-parallelism 2 \
 --update-delay 60s \
 inventory

6.首先,我们需要从服务中删除旧的秘密,然后将新版本添加到其中(直接更新秘密是不可能的):

$ docker service update \
 --secret-rm MYSQL_PASSWORD \
 inventory
$ docker service update \
 --secret-add source=MYSQL_PASSWORD_V2, target=MYSQL_PASSWORD \
 inventory

第十五章

以下是本章中提出的问题的一些示例答案:

  1. Kubernetes 主节点负责管理集群。所有创建对象、重新调度 pod、管理 ReplicaSets 等请求都在主节点上进行。主节点不会在生产或类似生产的集群中运行应用程序工作负载。

  2. 在每个工作节点上,我们都有 kubelet、代理和容器运行时。

  3. 答案是 A。。你不能在 Kubernetes 集群上运行独立的容器。Pods 是这样一个集群中部署的原子单位。

  4. 在一个 pod 内运行的所有容器共享相同的 Linux 内核网络命名空间。因此,所有在这些容器内运行的进程可以通过localhost相互通信,类似于直接在主机上运行的进程或应用程序可以通过localhost相互通信的方式。

  5. pause容器的唯一作用是为在其中运行的容器保留 pod 的命名空间。

  6. 这是一个坏主意,因为一个 pod 的所有容器都是共同定位的,这意味着它们运行在同一个集群节点上。此外,如果多个容器在同一个 pod 中运行,它们只能一起扩展或缩减。然而,应用程序的不同组件(即webinventorydb)通常在可伸缩性或资源消耗方面有非常不同的要求。web组件可能需要根据流量进行扩展或缩减,而db组件则对存储有特殊要求,而其他组件则没有。如果我们将每个组件都运行在自己的 pod 中,我们在这方面会更加灵活。

  7. 我们需要一种机制来在集群中运行多个 pod 实例,并确保实际运行的 pod 数量始终与期望的数量相对应,即使个别 pod 由于网络分区或集群节点故障而崩溃或消失。 ReplicaSet 是为任何应用程序服务提供可伸缩性和自愈能力的机制。

  8. 每当我们想要在 Kubernetes 集群中更新应用程序服务而不会导致服务中断时,我们都需要部署对象。部署对象为 ReplicaSets 添加了滚动更新和回滚功能。

  9. Kubernetes 服务对象用于使应用程序服务参与服务发现。它们为一组 pod 提供了一个稳定的端点(通常由 ReplicaSet 或部署管理)。Kube 服务是定义了一组逻辑 pod 和访问它们的策略的抽象。有四种类型的 Kube 服务:

  • ClusterIP:在集群内部的 IP 地址上公开服务;这是一个虚拟 IP(VIP)。

  • NodePort:在每个集群节点上发布 30,000-32,767 范围内的端口。

  • LoadBalancer:这种类型使用云提供商的负载均衡器(如 AWS 上的 ELB)来外部公开应用程序服务。

  • ExternalName:当您需要为集群的外部服务(如数据库)定义代理时使用。

第十六章

以下是本章中提出的问题的一些示例答案:

  1. 假设我们在两个应用程序服务的注册表中有一个 Docker 镜像,web API 和 Mongo DB,然后我们需要做以下操作:
  • 使用 StatefulSet 为 Mongo DB 定义一个部署;让我们称之为db-deployment。StatefulSet 应该有一个副本(复制 Mongo DB 需要更多的工作,超出了本书的范围)。

  • db-deployment定义一个名为db的 Kubernetes 服务,类型为ClusterIP

  • 为 web API 定义一个部署;让我们称之为web-deployment。将这个服务扩展到三个实例。

  • web-deployment定义一个名为api的 Kubernetes 服务,类型为NodePort

  • 如果我们使用 secrets,那么使用 kubectl 直接在集群中定义这些 secrets。

  • 使用 kubectl 部署应用程序。

  1. 为了实现应用程序的第 7 层路由,我们理想情况下使用 IngressController。IngressController 是一个反向代理,比如 Nginx,它有一个 sidecar 监听 Kubernetes Server API 的相关变化,并更新反向代理的配置,如果检测到变化,则重新启动。然后,我们需要在集群中定义 Ingress 资源,定义路由,例如基于上下文的路由,比如https://example.com/pets<服务名称>/<端口>,或者一对,比如api/32001。一旦 Kubernetes 创建或更改了这个 Ingress 对象,IngressController 的 sidecar 就会接收并更新代理的路由配置。

  2. 假设这是一个集群内部的库存服务,那么我们需要做以下操作:

  • 在部署版本 1.0 时,我们定义了一个名为inventory-deployment-blue的部署,并使用color: blue的标签标记 pod。

  • 我们部署了 Kubernetes 服务的ClusterIP类型,称为 inventory,前面的部署中包含选择器color: blue

  • 当我们准备部署支付服务的新版本时,我们为服务的 2.0 版本定义一个部署,并称其为inventory-deployment-green。我们向 pod 添加了color: green的标签。

  • 现在我们可以对“green”服务进行冒烟测试,当一切正常时,我们可以更新 inventory 服务,以便选择器包含color: green

  1. 一些机密的信息形式应通过 Kubernetes secrets 提供给服务,包括密码、证书、API 密钥 ID、API 密钥密钥和令牌。

  2. 秘密值的来源可以是文件或 base64 编码的值。

第十七章

以下是本章中提出的问题的一些示例答案:

  1. 出于性能和安全原因,我们不能在生产系统上进行任何实时调试。这包括交互式或远程调试。然而,应用服务可能会因代码缺陷或其他基础设施相关问题(如网络故障或不可用的外部服务)而表现出意外行为。为了快速找出服务的异常行为或失败的原因,我们需要尽可能多的日志信息。这些信息应该给我们一个线索,并引导我们找到错误的根本原因。当我们对服务进行仪器化时,我们确实做到了这一点——以日志条目和发布的指标的形式产生尽可能多的信息。

  2. Prometheus 是一个用于收集其他基础设施服务和最重要的应用服务提供的功能或非功能指标的服务。由于 Prometheus 本身定期从所有配置的服务中拉取这些指标,因此服务本身不必担心发送数据。Prometheus 还定义了生产者呈现指标的格式。

  3. 要对基于 Node.js 的应用服务进行仪器化,我们需要执行以下四个步骤:

  4. 向项目添加 Prometheus 适配器。Prometheus 的维护者推荐使用名为siimon/prom-client的库。

  5. 在应用程序启动期间配置 Prometheus 客户端。这包括定义一个指标注册表。

  6. 公开一个 HTTP GET 端点/度量标准,返回度量标准注册表中定义的度量集合。

  7. 最后,我们定义countergaugehistogram类型的自定义度量标准,并在我们的代码中使用它们;例如,每次调用特定端点时,我们增加counter类型的度量标准。

  8. 通常在生产环境中,Kubernetes 集群节点只包含最小的操作系统,以尽可能限制其攻击面,并且不浪费宝贵的资源。因此,我们不能假设通常用于排除应用程序或进程的工具在相应的主机上可用。排除故障的一种强大且推荐的方法是作为临时 Pod 的一部分运行特殊工具或排除故障容器。然后,该容器可以用作我们可以调查受困扰的服务的网络和其他问题的堡垒。许多 Docker 现场工程师在客户现场成功使用的容器是netshoot

第十八章

以下是本章中提出的问题的一些示例答案:

  1. 要在 AWS 中安装 UCP,我们执行以下操作:
  • 创建具有子网和 SG 的 VPC。

  • 然后,提供一组 Linux VM 的集群,可能作为 ASG 的一部分。支持许多 Linux 发行版,如 CentOS、RHEL 和 Ubuntu。

  • 然后,在每个 VM 上安装 Docker。

  • 最后,选择一个 VM 来安装 UCP,使用docker/ucp镜像。

  • 安装 UCP 后,将其他 VM 加入集群,作为工作节点或管理节点。

  1. 以下是考虑托管 Kubernetes 的一些原因:
  • 您不希望或没有资源来安装和管理 Kubernetes 集群。

  • 您希望专注于为您的业务带来价值的内容,这在大多数情况下是应该在 Kubernetes 上运行的应用程序,而不是 Kubernetes 本身。

  • 您更喜欢按需付费的成本模型。

  • 您的 Kubernetes 集群节点会自动打补丁和更新。

  • 升级 Kubernetes 版本而无需停机时间非常简单和直接。

  1. 将容器镜像托管在云提供商的容器注册表(例如 Microsoft Azure 上的 ACR)的两个主要原因是:
  • 镜像地理位置靠近您的 Kubernetes 集群,因此延迟和传输网络成本最小。

  • 生产或类似生产的集群理想情况下应该与互联网隔离,因此 Kubernetes 集群节点无法直接访问 Docker Hub。

posted @ 2024-05-06 18:33  绝不原创的飞龙  阅读(18)  评论(0编辑  收藏  举报