Docker-19-x-基础知识学习指南第二版-全-

Docker 19,x 基础知识学习指南第二版(全)

原文:annas-archive.org/md5/2da39904b7bcc328bcf993425ba057d4

译者:飞龙

协议: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 Swarm 上,实现零停机和自动回滚功能。它还介绍了机密作为保护敏感信息的手段。

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

第十六章,使用 Kubernetes 部署、更新和保护应用程序,解释了如何将应用程序部署、更新和扩展到 Kubernetes 集群中。它还解释了如何通过活性探针和就绪探针为应用程序服务提供支持,以帮助 Kubernetes 进行健康检查和可用性检查。此外,本章还介绍了如何实现零停机部署,以便对关键任务应用程序进行无中断的更新和回滚。最后,本章介绍了 Kubernetes 机密,作为配置服务和保护敏感数据的手段。

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

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

为了最大程度地利用本书

期望读者对分布式应用架构有一定了解,并有兴趣加速和简化构建、交付和运行高度分布式应用的过程。无需具备 Docker 容器的先前经验。

强烈推荐使用安装了 Windows 10 Professional 或 macOS 的电脑。该电脑应至少有 16GB 内存。

书中涉及的软件/硬件 操作系统要求
Docker for Desktop, Docker Toolbox, Visual Studio Code, Powershell 或 Bash 终端。 Windows 10 Pro/macOS/Linux,最低 8GB 内存

如果你使用的是本书的数字版,我们建议你自己输入代码,或通过 GitHub 仓库访问代码(链接将在下一部分提供)。这样可以帮助你避免与代码复制/粘贴相关的潜在错误。

下载示例代码文件

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

你可以通过以下步骤下载代码文件:

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

  2. 选择“支持”标签。

  3. 点击“代码下载”。

  4. 在搜索框中输入书名,并按照屏幕上的指示操作。

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

  • WinRAR/7-Zip for Windows

  • Zipeg/iZip/UnRarX for Mac

  • 7-Zip/PeaZip for Linux

本书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/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)"

粗体:表示新术语、重要单词或屏幕上显示的词语。例如,菜单或对话框中的词语在文本中会这样显示。举个例子:“从管理面板中选择 System info。”

警告或重要说明如下所示。

小贴士和技巧如下所示。

联系我们

我们欢迎读者提供反馈。

一般反馈:如果您对本书的任何方面有疑问,请在邮件主题中注明书名,并通过customercare@packtpub.com与我们联系。

勘误表:尽管我们已经尽力确保内容的准确性,但错误是难以避免的。如果您发现本书中有错误,欢迎向我们报告。请访问 www.packtpub.com/support/errata,选择您的书籍,点击勘误提交表单链接,并填写相关信息。

盗版:如果您在互联网上遇到任何非法复制的我们的作品,感谢您提供相关的网址或网站名称。请通过copyright@packt.com与我们联系,并附上该材料的链接。

如果您有兴趣成为作者:如果您在某个领域有专业知识,并且有意写书或为书籍贡献内容,请访问 authors.packtpub.com

评论

请留下您的评论。在阅读并使用本书后,为什么不在您购买书籍的站点上留下评论呢?潜在的读者可以通过您的客观意见做出购买决策,我们在 Packt 可以了解您对我们产品的看法,作者们也能看到您对他们书籍的反馈。感谢您的支持!

如需了解更多关于 Packt 的信息,请访问 packt.com

第一部分:动机与入门

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

本节包含以下章节:

  • 第一章,什么是容器以及为什么要使用它们?

  • 第二章,设置工作环境

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

本章将介绍容器及其编排的世界。本书从最基础开始,假设你没有任何容器的相关知识,并将为你提供一个非常实用的入门介绍。

本章将重点讨论软件供应链及其中的摩擦。接着,我们将介绍容器,它们用于减少这种摩擦并在其上增加企业级的安全性。我们还将探讨容器及其周围生态系统是如何组装的。我们将特别指出,容器的上游开源软件OSS)组件,统一以 Moby 为代号,这些组件构成了 Docker 及其他供应商的下游产品的基石。

本章内容包括以下主题:

  • 什么是容器?

  • 为什么容器如此重要?

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

  • Moby 项目

  • Docker 产品

  • 容器架构

完成本模块后,你将能够做到以下几点:

  • 用简单的几句话,使用类似物理容器的类比,向感兴趣的外行人解释容器是什么。

  • 使用类比解释为什么容器如此重要,比如将物理容器与传统运输方式或公寓房屋与独立住宅进行比较,以此向感兴趣的外行人说明。

  • 列举至少四个被 Docker 产品使用的上游开源组件,比如 Docker for Desktop。

  • 识别至少三个 Docker 产品。

什么是容器?

软件容器是一个相当抽象的概念,因此如果我们从一个大多数人都很熟悉的类比入手,可能会更容易理解。这个类比是运输行业中的货运集装箱。纵观历史,人们一直在通过各种方式运输货物。轮子发明之前,货物很可能是通过袋子、篮子或箱子,由人类自己肩挑,或者他们可能会使用驴、骆驼或大象等动物来运输。

随着轮子的发明,运输变得更加高效,人类修建了可以推动马车的道路,能够一次运输更多的货物。随着第一台蒸汽驱动机器的问世,后续出现的汽油发动机,运输变得更加强大。现在,我们可以通过火车、船舶和卡车运输大量的货物。同时,货物的种类越来越多,有时也变得更加复杂,难以处理。

在这几千年的发展中,有一件事始终没有改变,那就是需要在目标地点卸下货物,并可能将它们装上另一种运输工具。比如,农民将一车苹果运到一个中央火车站,那里会将苹果与其他许多农民的苹果一起装上火车。又比如,酿酒师将装满酒桶的卡车运到港口,货物被卸下后再转移到船上,运往海外。

将货物从一种运输工具卸下再装上另一种运输工具的过程,曾经是一个非常复杂且繁琐的流程。每种类型的产品都有自己独特的包装方式,因此需要以特定的方式处理。此外,散装货物还面临着被不道德工人偷窃或在处理过程中损坏的风险。

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

现在,借助标准化的集装箱,想要将货物从 A 地运输到 B 地的公司,会将这些货物装入这些集装箱。然后,他们会联系一个运输公司,运输公司会提供标准化的运输工具。这可以是一个能装载集装箱的卡车,或者是一个每个车厢可以运输一个或多个集装箱的火车。最后,我们有专门用来运输大量集装箱的船只。运输公司无需拆卸或重新包装货物。对于运输公司来说,集装箱只是一个黑色的盒子,他们对里面的东西不感兴趣,大多数情况下也不需要关心。它只是一个具有标准尺寸的大铁盒子。现在,包装货物进入集装箱的任务完全交由那些希望将货物运输出去的各方,他们应该知道如何处理和包装这些货物。

由于所有集装箱都具有相同的形状和尺寸,运输公司可以使用标准化的工具来处理集装箱;例如,起重机可以将集装箱从火车或卡车上卸下,再装到船上,反之亦然。只需一种类型的起重机,就可以处理所有过来的集装箱。此外,运输工具也可以标准化,比如集装箱船、卡车和火车。

正是由于这一切标准化,所有与货物运输相关的流程也得以标准化,从而比集装箱时代之前要高效得多。

现在,你应该已经很好地理解了为什么集装箱运输如此重要,以及它为什么能彻底改变整个运输行业。我故意选择这个类比,因为我们将要介绍的软件容器在所谓的软件供应链中,正好履行了与运输行业中集装箱相同的角色。

在过去,开发者会开发一个新的应用程序。一旦他们认为应用程序完成了,就会将其交给运维工程师,后者负责将其安装到生产服务器上并启动。如果运维工程师足够幸运,他们甚至能从开发者那里获得一份相对准确的安装文档。到此为止,一切顺利,生活也变得简单。

但当一个企业中有多个开发团队,开发出不同类型的应用,而这些应用却都需要安装在同一台生产服务器上并保持运行时,事情就变得有些失控了。通常,每个应用都有一些外部依赖,例如它基于的框架、所使用的库等等。有时,两个应用使用相同的框架,但版本不同,这可能会导致兼容性问题。我们的运维工程师的工作变得越来越困难。他们必须非常有创意地想办法在不出问题的情况下将不同的应用加载到(他们的服务器)上。

安装某个应用的新版本如今已经变成了一个复杂的项目,通常需要几个月的规划和测试。换句话说,软件供应链中存在很多摩擦。然而,现在的公司越来越依赖软件,发布周期需要变得越来越短。我们已经无法再只每年发布一两次了。应用需要在几周或几天内更新,有时甚至是每天多次更新。不遵守这一点的公司,因缺乏敏捷性而面临破产的风险。那么,解决方案是什么呢?

最初的一个解决方法是使用虚拟机VMs)。公司不再在同一台服务器上运行多个应用,而是将每个应用打包并在各自的虚拟机上运行。通过这种方式,所有的兼容性问题都解决了,生活似乎恢复了平静。不幸的是,这种幸福没有持续多久。虚拟机本身非常“沉重”,因为每个虚拟机都包含一个完整的操作系统,如 Linux 或 Windows Server,而这一切只是为了运行单个应用。这就像你在运输行业中,使用整艘船只来运输一车香蕉。真是浪费!这种方式永远无法盈利。

解决这个问题的终极方案是提供比虚拟机(VMs)更轻量级的东西,但又能够完美封装需要运输的物品。在这里,物品指的是由我们的开发者编写的实际应用程序,并且——这点很重要——还包括所有应用程序的外部依赖项,如框架、库、配置等。这种软件包装机制的“圣杯”就是Docker 容器

开发者使用 Docker 容器将他们的应用程序、框架和库打包到其中,然后将这些容器交给测试人员或运维工程师。对于测试人员和运维工程师来说,容器只是一个黑盒子。尽管如此,它是一个标准化的黑盒子。所有容器,不管内部运行什么应用,都可以平等对待。工程师知道,如果任何容器能够在他们的服务器上运行,那么其他容器也应该能运行。除了一些总会存在的边缘情况外,这一点是成立的。

因此,Docker 容器是一种以标准化方式打包应用程序及其依赖项的手段。然后,Docker 创造了“构建、运输和随处运行”这一说法。

为什么容器如此重要?

现如今,应用程序的更新发布周期越来越短,但软件本身却没有变得更简单。相反,软件项目的复杂性不断增加。因此,我们需要一种方法来驯服这一“猛兽”,简化软件供应链。

此外,我们每天都听到网络攻击正在上升。许多知名公司已经或曾经受到过安全漏洞的影响。像社会安全号码、信用卡信息等高度敏感的客户数据在此类事件中被盗取。但不仅仅是客户数据受到威胁——公司的敏感机密也同样被盗取。

容器可以在许多方面提供帮助。首先,Gartner 发现,运行在容器中的应用比那些没有运行在容器中的应用更安全。容器利用 Linux 安全原语,如 Linux 内核命名空间,将运行在同一台计算机上的不同应用程序进行沙箱化,同时使用控制组cgroups)来避免“噪声邻居”问题,即某个不良应用占用了服务器的所有可用资源,导致其他应用无法运行。

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

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

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

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

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

由于与虚拟机相比,容器非常精简,因此在开发者的笔记本电脑上同时运行多个容器而不使电脑超负荷是很常见的情况。

容器重要的第三个原因是,运维人员终于可以专注于他们擅长的事情:提供基础设施并在生产环境中运行和监控应用程序。当他们需要在生产系统中运行的所有应用程序都是容器化的时,运维人员就可以开始标准化他们的基础设施。每台服务器就变成了另一个 Docker 主机。这些服务器上无需安装特殊的库或框架,只需要一个操作系统和容器运行时,比如 Docker。

此外,运维人员不再需要深入了解应用程序的内部结构,因为这些应用程序在容器中独立运行,这些容器对他们来说应该就像黑匣子一样,类似于运输行业中的货运集装箱对工作人员的意义。

那么,这对我或我的公司有什么好处呢?

有人曾经说过,今天每个一定规模的公司都必须认识到,他们需要成为一家软件公司。从这个意义上讲,现代银行是一家软件公司,恰好专注于金融业务。软件运行所有的业务,没错。随着每个公司都成为软件公司,建立软件供应链成为一种需求。为了保持竞争力,公司的软件供应链必须既安全又高效。效率可以通过全面的自动化和标准化来实现。但是,在安全、自动化和标准化这三个领域中,容器技术已经展现出卓越的优势。大型和知名企业报告称,当它们将现有的传统应用程序(许多人称之为旧版应用程序)容器化,并建立基于容器的完全自动化软件供应链时,能够将这些关键应用程序的维护成本降低 50%到 60%,并且能够将这些传统应用程序的新版本发布之间的时间缩短最多 90%。

话虽如此,采用容器技术为这些公司节省了大量资金,同时也加快了开发进程并缩短了上市时间。

Moby 项目

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

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

在所有这些原因,以及更多的原因中,出现了这样一个想法:Docker 必须采取措施,明确区分 Docker 的开源部分与 Docker 的商业部分。此外,公司还希望防止竞争对手利用并滥用 Docker 这一名称谋取私利。这就是 Moby 项目诞生的主要原因。Moby 项目作为一个伞式项目,涵盖了 Docker 开发并持续开发的大部分开源组件。这些开源项目现在不再使用 Docker 这个名称。

Moby 项目提供了用于镜像管理、秘密管理、配置管理、网络和配置等方面的组件,举几个例子。同时,Moby 项目的一部分是特殊的 Moby 工具,例如,用于将组件组装成可运行的工件。

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

Docker 产品

Docker 目前将其产品线分为两个部分。一个是社区版CE),它是闭源的但完全免费;另一个是企业版EE),它也是闭源的,需要按年许可。这些企业产品提供 24/7 支持并包含错误修复。

Docker CE

Docker 社区版的一部分包括像 Docker Toolbox 和 Docker for Desktop 这样适用于 Mac 和 Windows 的版本。这些产品主要面向开发人员。

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

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

例如,使用原生 Azure 应用程序的 Docker for Azure,您可以通过几次点击即可在 Azure 上设置 Docker,它已优化并与底层 Azure 基础设施即服务IaaS)服务集成。它帮助运维工程师在 Azure 中构建和运行 Docker 应用时加速生产力。

Docker for AWS 的工作方式非常相似,但它是为 Amazon 云设计的。

Docker EE

Docker 企业版由统一控制平面UCP)和Docker 受信注册表DTR)组成,它们都运行在 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 时间或 RAM 数量。

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. 正确

B. 错误

  1. 将企业的传统应用程序容器化后,通常能节省多少成本?

A. 20%

B. 33%

C. 50%

D. 75%

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

进一步阅读

以下是一些链接,带有更多关于本章讨论内容的详细信息:

第二章:设置工作环境

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

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

本章包含以下内容:

  • Linux 命令行

  • Windows 的 PowerShell

  • 安装和使用包管理器

  • 安装 Git 并克隆代码仓库

  • 选择和安装代码编辑器

  • 在 macOS 或 Windows 上安装 Docker for Desktop

  • 安装 Docker Toolbox

  • 安装 Minikube

技术要求

对于本章,你将需要一台安装了 macOS 或 Windows 的笔记本电脑或工作站,最好是安装了 Windows 10 专业版。你还需要具备可用的互联网连接,以便下载应用程序并安装这些应用程序。

如果你的操作系统是 Linux 发行版,例如 Ubuntu 18.04 或更新版本,你也可以跟随本书的内容进行学习。我会尽力指出在命令和示例上与 macOS 或 Windows 存在显著差异的地方。

Linux 命令行

Docker 容器最初是在 Linux 上为 Linux 开发的。因此,使用的主要命令行工具(也叫 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 系统上工作时。如果你是完全的初学者,我们强烈建议你参考以下备忘单:

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 是一个不错的选择。如果你使用的是基于 Debian 的 Linux 发行版,例如 Ubuntu,那么大多数人的包管理器选择是默认安装的 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 CodeVS Code),仅举几例。VS Code 是一个完全免费的轻量级编辑器,但它非常强大,支持 macOS、Windows 和 Linux。根据 Stack Overflow 的数据,它目前是最受欢迎的代码编辑器。如果你还没有决定使用其他编辑器,强烈建议你尝试一下 VS Code。

但是,如果你已经有了喜欢的代码编辑器,请继续使用它。只要你能编辑文本文件就可以了。如果你的编辑器支持 Dockerfile、JSON 和 YAML 文件的语法高亮,那就更好了。唯一的例外是第六章,调试容器中运行的代码。该章中的示例将特别针对 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 Code 将启动并将当前文件夹(~/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 for Desktop。

安装 Docker for Desktop

如果你正在使用 macOS 或者在你的笔记本上安装了 Windows 10 专业版,那么我们强烈建议你安装 Docker for Desktop。这个平台能为你提供最佳的容器工作体验。

目前,Docker for Desktop 不支持 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 Desktop

  1. 点击蓝色的“下载 Docker Desktop”按钮。然后您应该看到如下界面:

下载适用于 macOS 的 Docker Desktop 安装界面

请注意,如果您使用的是 Windows 电脑,蓝色按钮将显示为“下载 Docker Desktop for Windows”。

在 macOS 上安装 Docker for Desktop

请按照以下步骤进行安装:

  1. 成功安装适用于 macOS 的 Docker Desktop 后,请打开终端窗口并执行以下命令:
$ docker version

您应该看到类似如下的内容:

Docker for Desktop 上的 Docker 版本

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

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

在 Docker for Desktop for macOS 上运行 Hello-World

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

在 Windows 上安装 Docker for Desktop

请按照以下步骤进行安装:

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

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

在 Linux 上安装 Docker CE

如前所述,Docker for Desktop 仅适用于 macOS 和 Windows 10 Pro。如果您使用的是 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 for Desktop 这样的新工具早推出。Docker Toolbox 让用户可以非常优雅地在任何 macOS 或 Windows 计算机上使用容器。容器必须在 Linux 主机上运行。Windows 和 macOS 无法原生运行容器。因此,我们需要在笔记本电脑上运行一个 Linux 虚拟机(VM),然后可以在其中运行容器。Docker Toolbox 会在我们的笔记本电脑上安装 VirtualBox,用来运行我们所需要的 Linux 虚拟机。

作为 Windows 用户,你可能已经知道有所谓的 Windows 容器可以原生运行在 Windows 上,你说得没错。微软已将 Docker 引擎移植到 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 安装 Toolbox:
$ 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 就绪虚拟机。如果你刚刚安装了 Docker Toolbox,你应该看到以下输出:

所有 Docker 就绪虚拟机的列表

  1. 好的,我们可以看到安装了一个名为 default 的虚拟机,但它当前的 STATEstopped。让我们使用 docker-machine 启动这个虚拟机,以便可以与之互动:
$ docker-machine start default

这将产生以下输出:

启动 Docker Toolbox 中的默认虚拟机

如果我们现在再次列出虚拟机,我们应该看到以下内容:

列出 Docker Toolbox 中正在运行的虚拟机

使用的 IP 地址可能与你的情况不同,但它一定会在192.168.0.0/24范围内。我们还可以看到虚拟机已经安装了 Docker 版本18.06.1-ce

  1. 如果因为某些原因,你没有默认虚拟机或不小心删除了它,你可以使用以下命令创建一个:
$ docker-machine create --driver virtualbox default 

这将生成以下输出:

在 Docker Toolbox 中创建新的默认虚拟机

如果你仔细分析上面的输出,你会发现 docker-machine 自动从 Docker 下载了最新的虚拟机 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 虚拟机上运行的 Docker:
$ eval $(docker-machine env default) 
  1. 现在,我们可以执行第一个 Docker 命令:
$ docker version

这应该会显示以下输出:

docker 版本输出

这里有两部分:客户端和服务器部分。客户端是直接在你的 macOS 或 Windows 笔记本上运行的 CLI,而服务器部分则运行在 VirtualBox 中的 default 虚拟机上。

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

这将产生以下输出:

上面的输出确认 Docker Toolbox 按预期工作并且能够运行容器。

即使你通常使用 Docker for Desktop 进行 Docker 开发,Docker Toolbox 也是一个很好的补充。Docker Toolbox 允许你在 VirtualBox 中创建多个 Docker 主机(或虚拟机),并将它们连接到一个集群,在这个集群上你可以运行 Docker Swarm 或 Kubernetes。

安装 Minikube

如果你无法使用 Docker for Desktop,或者由于某些原因你只能使用不支持 Kubernetes 的旧版本工具,那么安装 Minikube 是一个不错的选择。Minikube 在你的工作站上配置一个单节点 Kubernetes 集群,并且可以通过 kubectl 访问,它是用于与 Kubernetes 交互的命令行工具。

在 macOS 和 Windows 上安装 Minikube

要在 macOS 或 Windows 上安装 Minikube,请访问以下链接:kubernetes.io/docs/tasks/tools/install-minikube/

请仔细按照说明操作。如果你已经安装了 Docker Toolbox,那么你的系统中已经有一个虚拟化程序,因为 Docker Toolbox 安装程序同时也安装了 VirtualBox。否则,我建议你先安装 VirtualBox。

如果你已经安装了 macOS 或 Windows 版 Docker,那么你已经安装了 kubectl,因此可以跳过这一步。如果没有,请按照网站上的说明操作。

测试 Minikube 和 kubectl

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

启动 Minikube

注意,您的输出可能会略有不同。在我的案例中,我在 Windows 10 Pro 计算机上运行 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 和运维工程师。在这一过程中,我们确保使用良好的编辑器,安装了 Docker for macOS 或 Docker for Windows,并能够使用docker-machine在 VirtualBox 或 Hyper-V 中创建虚拟机,然后使用这些虚拟机来运行和测试容器。

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

问题

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

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

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

A. 正确

B. 错误

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

  2. 列举三到四个 Docker 认证可运行的 Linux 发行版。

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

进一步阅读

请参考以下链接进一步阅读:

第二章:容器化,从入门到高手

在本节中,您将掌握构建、运送和运行单一容器的所有关键方面。

本节包括以下章节:

  • 第三章,掌握容器

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

  • 第五章,数据卷和配置

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

  • 第七章,使用 Docker 提升自动化

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

第四章:精通容器

在上一章中,你学会了如何优化地准备你的工作环境,以便高效、顺畅地使用 Docker。在本章中,我们将开始动手实践,学习与容器相关的所有重要内容。以下是我们将在本章中覆盖的主题:

  • 运行第一个容器

  • 启动、停止和删除容器

  • 检查容器

  • 进入正在运行的容器

  • 附加到正在运行的容器

  • 检索容器日志

  • 容器的结构

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

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

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

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

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

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

  • 将终端附加到一个已经在运行的容器。

  • 用你自己的话向一个感兴趣的外行解释容器的基本原理。

技术要求

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

运行第一个容器

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

$ docker version

如果你使用的是 Docker Toolbox,请使用与 Toolbox 一起安装的 Docker Quickstart Terminal,而不是 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 上安装了 beta3 版本 19.03.0

如果这对你不起作用,那么说明你的安装可能有问题。请确保你按照上一章中关于如何在系统上安装 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^(th) 次运行前面的命令时,你应该只在终端中看到这个输出:

 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 镜像,带有完整的软件包索引,大小仅为 5 MB。

最后,我们需要定义当容器运行时,应该在容器内部执行什么样的进程或任务。在我们的例子中,这就是命令的最后一部分,echo "Hello World"

也许以下截图能帮助你更好地理解整个过程:

Docker 容器运行表达式的组成

现在我们已经理解了运行容器命令的各个部分,接下来让我们尝试运行另一个容器,并在其中执行不同的进程。请输入以下命令到你的终端:

$ 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 镜像,CentOS 是一个由 Red Hat 提供支持的社区发行版,源代码免费公开,源自 Red Hat Enterprise Linux (RHEL)。

让我们详细分析输出内容。

第一行如下:

Unable to find image 'centos:latest' locally

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

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 输出七列,含义如下:

| Column

| Description

|

Container ID 这是容器的唯一 ID,采用 SHA-256 格式。
Image 这是该容器所基于的容器镜像的名称。
Command 这是用于在容器中运行主进程的命令。
Created 这是容器创建的日期和时间。
Status 这是容器的状态(创建中、重启中、运行中、删除中、暂停中、已退出或已死亡)。
Ports 这是已映射到主机的容器端口列表。
Names 这是分配给此容器的名称(可以有多个名称)。

如果我们不仅想列出当前正在运行的容器,还想列出所有已定义的容器,则可以使用命令行参数 -a--all,如以下所示:

$ docker container ls -a

这将列出所有状态的容器,如 created(创建),running(运行中),或 exited(退出)。

有时,我们只需要列出所有容器的 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 模板语法。在这个例子中,我们只希望看到整个输出中的state部分,且格式为 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

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

  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守护进程,我们可以使用此驱动程序,它将日志转发到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 主机上。为此,我们将使用带有 Ubuntu 镜像的 Vagrant:
$ 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 内核。这与虚拟机有根本不同,因为每个虚拟机都有自己完整的操作系统。

一个典型容器的启动时间可以用毫秒来衡量,而虚拟机通常需要几秒到几分钟才能启动。虚拟机的设计目的是长时间运行。每个运维工程师的首要目标是最大化虚拟机的正常运行时间。与此相反,容器的设计目的是临时的。它们来得快,去得也快。

让我们首先从高层次了解一下使我们能够运行容器的架构。

架构

这里,我们有一个架构图,展示了所有这些如何结合在一起:

Docker 高层架构

在前述图的下半部分,我们有 Linux 操作系统及其 cgroups命名空间层级能力,以及我们此处不需要明确提及的 其他操作系统功能。然后,是由 containerdrunc 组成的中介层。所有这些上面现在是 Docker 引擎Docker 引擎 提供了一个 RESTful 接口,可以通过任何工具访问,例如 Docker CLI、Docker for macOS 和 Docker for Windows 或 Kubernetes,举几个例子。

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

命名空间

在 Docker 使用容器之前,Linux 命名空间已经存在多年。命名空间是全局资源的抽象,例如文件系统、网络访问、进程树(也称为 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 是一个根据 开放容器倡议OCI)规范启动和运行容器的工具。它是一个正式规范的配置格式,受 开放容器项目OCP)的管理,OCP 是由 Linux 基金会主办的。

Containerd

runC 是一个低级容器运行时实现;containerd 基于它并添加了更高级的功能,如镜像传输与存储、容器执行和监控,以及网络与存储附件。通过这些,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 中,一切都是文件。整个操作系统基本上是一个包含文件和文件夹的文件系统,存储在本地磁盘上。记住这一点对于理解容器镜像的概念非常重要。正如我们将看到的,镜像基本上是一个包含文件系统的大 tar 包。更具体地说,它包含了一个分层的文件系统。

分层文件系统

容器镜像是用于创建容器的模板。这些镜像并非由一个单一的整体块组成,而是由许多层构成。镜像中的第一层也称为基础层。我们可以在以下图示中看到这一点:

镜像作为一层一层的堆栈

每个单独的层包含文件和文件夹。每一层只包含相对于底层的文件系统的变化。Docker 使用联合文件系统——如第三章《掌握容器》中所讨论的——通过将这些层结合起来,创建了一个虚拟文件系统。存储驱动程序处理这些层如何相互作用的详细信息。在不同的情况下,存在具有不同优缺点的存储驱动程序。

容器镜像的各个层都是不可变的。不可变意味着一旦生成,该层就永远不能改变。影响该层的唯一操作是物理删除它。层的不可变性非常重要,因为它为我们提供了巨大的机会,正如我们将看到的那样。

在下图中,我们可以看到一个用于 Web 应用程序的自定义镜像,使用 Nginx 作为 Web 服务器,可能是这样的:

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

我们的基础层由Alpine Linux发行版组成。然后,在它之上,我们有一个Add 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

这使用apkAlpine 包管理器安装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的镜像具有预期的 ID 44bca4141130,并且自动分配了latest标签。因为我们没有显式定义标签,所以 Docker 会默认使用latest标签。

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

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

my-alpine Docker 镜像的历史

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

使用 Dockerfiles

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

这就是所谓的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

这在构建超简 minimal 镜像时非常有用,这些镜像只包含——例如——一个二进制文件:实际的静态链接可执行文件,如 Hello-Worldscratch 镜像字面上就是一个空的基础镜像。

FROM scratchDockerfile 中是一个“空操作”,因此不会在最终的容器镜像中生成层。

RUN 关键字

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

RUN yum install -y wget

前面的命令使用 yum CentOS 包管理器将 wget 包安装到运行中的容器中。这假设我们的基础镜像是 CentOS 或 Red Hat Enterprise LinuxRHEL)。如果我们的基础镜像是 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/*

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

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

COPYADD 关键字

COPYADD关键字非常重要,因为最终我们希望向现有的基础镜像中添加一些内容,使其成为一个自定义镜像。大多数时候,这些内容可能是一个网页应用程序的源文件,或者是一个已编译应用程序的几个二进制文件。

这两个关键字用于将文件和文件夹从主机复制到我们正在构建的镜像中。这两个关键字非常相似,唯一的区别是,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/

从安全的角度来看,默认情况下,镜像内部的所有文件和文件夹将具有用户 IDUID)和组 IDGID)为0。幸运的是,对于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

这样就会导致容器进行 5 秒钟的回环 ping 操作。

如果我们想要覆盖在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文件夹(Fundamentals of 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

4. 保存文件并退出编辑器。

5. 返回终端窗口,我们现在可以使用前面的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 daemon

  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只有两个语句,我们处于步骤 1中的2。我们还可以看到该部分的内容。在这里,它是基础镜像的声明,我们想在其上构建自定义镜像。构建器接下来会从 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

这告诉我们,生成的自定义镜像被分配了 ID bc070cc81b87,并且被标记为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

结果镜像的大小为 176MB,实在是太大了。最终,它只是一个 Hello World 应用程序。之所以这么大,是因为镜像不仅包含了 Hello World 二进制文件,还包含了所有用于从源代码编译和链接应用程序的工具。但在生产环境中运行应用程序时,这显然是不理想的。理想情况下,我们只希望镜像中包含最终的二进制文件,而不是完整的 SDK。

正因为如此,我们应该将 Dockerfile 定义为多阶段的。我们有一些阶段用于构建最终的构件,然后是一个最终阶段,在这个阶段中,我们使用最小的基础镜像并将构件复制进去。这样会得到非常小的 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

我们已经成功将镜像大小从 176 MB 减少到 4 MB,缩小了 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文件的工作原理与.gitignore文件完全相同,对于熟悉 Git 的人来说也是如此。在.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 镜像,从而在容器中运行它们,并从中获益。

提升并迁移:将遗留应用容器化

我们并不总是能从零开始,开发一个全新的应用程序。更常见的是,我们会发现自己拥有一大堆传统的应用程序,这些应用程序已经在生产环境中运行,并为公司或公司客户提供了至关重要的价值。通常,这些应用程序是自然发展起来的,结构复杂。文档稀缺,而且没有人愿意去接触这些应用程序。通常,“永远不要触碰正在运行的系统”这句话是适用的。然而,市场需求发生变化,随之而来的是更新或重写这些应用程序的需求。由于资源和时间的限制,或者由于过高的成本,完全重写往往是不可能的。那么,我们该如何处理这些应用程序呢?我们是否可以将它们容器化,并从容器带来的好处中获益?

事实证明我们

让我们暂时想象一下这样的传统应用程序。假设我们有一个 10 年前写的旧 Java 应用程序,并且在接下来的 5 年里持续更新。这个应用程序基于 Java SE 6,后者是在 2006 年 12 月发布的。它使用环境变量和属性文件进行配置。像数据库连接字符串中使用的用户名和密码等敏感信息会从一个密钥库中提取出来,比如 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 镜像时可用。

  • 环境:这是与应用程序运行环境相关的配置信息——例如,开发环境(DEVELOPMENT)与预发布(STAGING)或生产环境(PRODUCTION)。这种配置会在容器启动时应用到应用程序,例如在生产环境中。

  • 运行时:这是应用程序在运行时获取的信息,例如访问外部 API 所需的密钥。

密钥

每个关键业务应用程序都需要以某种形式处理机密。最常见的机密是用于访问数据库的连接信息,这些数据库用于持久化应用程序生产或使用的数据。其他机密包括访问外部 API 所需的凭据,比如信用评分查询 API。需要注意的是,这里所说的机密是指应用程序必须提供给其使用或依赖的服务提供商的机密,而不是应用程序用户提供的机密。这里的主体是我们的应用程序,它需要通过外部授权机构和服务提供商进行身份验证和授权。

传统应用程序获取机密的方式有很多种。最差也是最不安全的方式是将机密硬编码在代码中,或者从配置文件或环境变量中读取,这些机密通常以明文形式存在。更好的方式是通过运行时从一个特殊的机密存储中读取机密,该存储以加密方式持久化机密,并通过安全连接(如传输层安全性TLS))将其提供给应用程序。

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

编写 Dockerfile

一旦我们完成了前几个部分讨论的所有项目的清单,我们就可以开始编写我们的Dockerfile了。但我要提醒你:不要期望这是一项一次完成的任务。你可能需要几次迭代,直到你制作出最终的DockerfileDockerfile可能会相当长,而且看起来很不优雅,但这不是问题,只要我们得到一个有效的 Docker 镜像。我们可以在得到一个有效版本后随时对Dockerfile进行微调。

基础镜像

让我们从确定要使用的基础镜像开始,并以此为基础构建我们的镜像。是否有一个符合我们要求的官方 Java 镜像?记住,我们的假设应用程序基于 Java SE 6。如果有这样的基础镜像,那就使用它。否则,我们希望从一个 Linux 发行版开始,如 Red Hat、Oracle 或 Ubuntu。在后一种情况下,我们将使用相应的发行版包管理器(yumapt或其他)来安装所需版本的 Java 和 Maven。为此,我们在Dockerfile中使用RUN关键字。记住,RUN允许我们在构建过程中执行镜像中的任何有效 Linux 命令。

汇总源代码

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

WORKDIR /app
COPY . .

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

如前所述,你也可以使用ADD关键字将源文件和其他工件下载到 Docker 镜像中,这些文件不在构建上下文中,而是位于通过 URI 可访问的位置,如下所示:

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 <main application 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 等会议上公开披露,他们在将传统应用容器化时,获得了两个主要的好处:

  • 维护成本节省超过 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

在这里,我们有一个名为 web-app 的镜像,标记为版本 1.0,并且属于 engineering 组织,托管在 https://registry.acme.com 的私有注册表中。

现在,有一些特殊约定:

  • 如果省略了注册表 URL,则默认使用 Docker Hub。

  • 如果我们省略标签,则默认使用 latest

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

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

镜像 描述
alpine Docker Hub 上官方的 alpine 镜像,标签为 latest
ubuntu:19.04 Docker Hub 上官方的 ubuntu 镜像,标签为 19.04 或版本号为 19.04
microsoft/nanoserver Docker Hub 上微软的 nanoserver 镜像,标签为 latest
acme/web-api:12.0 web-api 镜像版本 12.0,关联到 acme 组织。该镜像托管在 Docker Hub 上。
gcr.io/gnschenker/sample-app:1.1 属于个人 gnschenkersample-app 镜像,标签为 1.1,托管在 Google 的容器注册表中。

现在我们知道了 Docker 镜像的完整名称是如何定义的,以及它的各个部分,那么让我们来讨论一下在 Docker Hub 上可以找到的一些特殊镜像。

官方镜像

在前面的表格中,我们提到了 官方镜像。这需要进一步解释。镜像存储在 Docker Hub 注册表中的仓库中。官方仓库是由个人或组织策划的仓库集合,这些个人或组织对镜像中的软件也负有责任。我们来看一个例子。Ubuntu Linux 发行版背后有一个官方组织。这个团队还提供包含其 Ubuntu 发行版的 Docker 镜像的官方版本。

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

Docker 支持一个团队,负责审查并发布所有在 Docker Hub 公共仓库中的精选镜像。此外,Docker 会对所有官方镜像进行漏洞扫描。

推送镜像到注册表

创建自定义镜像是好事,但在某个时候,我们希望将镜像共享或推送到目标环境,例如测试、质量保证QA)或生产系统。为此,我们通常使用容器注册表。其中最受欢迎的公共注册表之一是 Docker Hub。它在你的 Docker 环境中被配置为默认注册表,也是我们迄今为止从中拉取所有镜像的注册表。

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

要将镜像推送到我的个人账户上,我需要相应地为其打标签:

  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 并运行 ping 呢?ping 的默认地址将是 127.0.0.1

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

  3. 创建一个 Dockerfile,该文件使用多个步骤创建一个最小大小的 Hello World 应用镜像,该应用用 C 或 Go 编写。

  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 卷。让我们来了解一下它们。

创建卷

由于在此时,使用 Docker for Desktop 的 macOS 或 Windows 计算机上,容器并没有在 macOS 或 Windows 上本地运行,而是在 Docker for Desktop 创建的(隐藏的)虚拟机中运行,为了演示目的,最好使用docker-machine创建并使用一个显式运行 Docker 的虚拟机。此时,我们假设你已经在系统上安装了 Docker Toolbox。如果没有,请返回第二章中的设置工作环境,那里有关于如何安装 Toolbox 的详细说明:

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

如果你在 Windows 上启用了 Hyper-V,可以参考第二章中关于如何使用docker-machine创建基于 Hyper-V 的虚拟机的说明,设置工作环境

  1. 另一方面,如果你有一个名为node-1的虚拟机,但它没有运行,请启动它,如下所示:
$ docker-machine start node-1
  1. 现在一切准备就绪,可以使用docker-machine通过 SSH 连接到这个虚拟机,如下所示:
$ docker-machine ssh node-1
  1. 你应该会看到这个欢迎图片:

docker-machine 虚拟机欢迎消息

  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 下找到。在我们的情况下,当使用 docker-machine 并在 VirtualBox 中运行基于 LinuxKit 的虚拟机时,该文件夹是 /mnt/sda1/var/lib/docker/volumes/sample/_data

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

在我们基于 LinuxKit 的 Docker Toolbox 虚拟机中,访问也被拒绝,且我们没有 sudo 权限。难道这就意味着我们的探索到此为止了吗?

幸运的是,没有;我已经准备了一个 fundamentalsofdocker/nsenter 工具容器,它允许我们访问之前创建的 sample 卷的后备文件夹。

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

我们正在以 --privileged 标志运行容器。这意味着在容器中运行的任何应用都可以访问主机的设备。--pid=host 标志表示容器可以访问主机的进程树(即 Docker 守护进程运行的隐藏虚拟机)。现在,前述容器运行 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 卷到容器内的文件夹被排除在联合文件系统之外。也就是说,容器内该文件夹及其任何子文件夹中的所有更改都不会成为容器层的一部分,而是会保存在卷驱动程序提供的后端存储中。这个事实非常重要,因为容器层在对应容器停止并从系统中删除时会被删除。

  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 用来运行容器的隐藏虚拟机而言的。此时,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 上运行 Docker for Desktop 的隐藏虚拟机中。

要从 macOS 访问那个隐藏虚拟机,我们有两种选择。我们可以使用一个特殊容器并以特权模式运行它,或者使用screen工具进入 Docker 驱动程序。第一种方法同样适用于 Docker for Windows。

  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 虚拟机的会话,请按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虚拟机。你应该回到你的 Docker for Desktop。使用docker-machine停止虚拟机,如下所示:
$ docker-machine stop node-1 

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

使用主机卷

在某些场景下,比如开发新的容器化应用程序,或者当一个容器化应用程序需要消费某个由旧版应用程序生成的文件夹中的数据时,使用挂载特定主机文件夹的卷非常有用。让我们看以下示例:

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

上述表达式交互式地启动一个alpine容器并打开一个 shell,并将当前目录的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 中的 ARGdocker 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 和/或 Windows 上使用 Docker 的人员,您可以使用 PowerShell 命令 Invoke-WebRequest 或简称为 iwr,而不是 curl。然后,前面命令的等效命令将是 iwr -Url localhost:3000/status

  1. 暂时保持容器中运行的应用程序,并进行另一个更改。在导航到 /status 时,我们希望返回消息 OK, all good 而不仅仅是返回 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吗?还是我们需要两个不同的Dockerfile,一个用于开发,一个用于生产环境?

我们原来的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是另一个流行的平台。让我们看看在开发 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项目中缺少的依赖项。在这种情况下,点击Yes按钮。

在 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. 在 VS Code 中按command + Shift + D(Windows 系统为Ctrl + Shift + D)打开调试视图。

  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

我们将看到如下内容:

示例 .NET 应用程序在 SDK 容器内交互式启动

  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 + DCtrl + Shift + 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 示例应用程序定义一个 logger

在第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的消息:

生成警告

这一次,我们在第24行的colors函数中生成了一个日志级别为WARN的消息。到目前为止,一切顺利—这并不难!

  1. 现在让我们运行应用程序,看看我们得到什么输出:
$ python main.py
  1. 然后,在浏览器中,先访问 localhost:5000/,然后访问 localhost:5000/colors。你应该会看到类似以下的输出:

运行已仪器化的示例 Python 应用程序

如你所见,只有警告被输出到控制台;INFO 消息没有被输出。这是由于我们在定义日志记录器时设置的过滤器。还要注意,日志消息的格式是以日期和时间开始,然后是日志记录器的名称、日志级别,最后是我们在应用程序第 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. 然后,在类体中,添加一个类型为 ILogger 的实例变量 _logger,并添加一个接受 ILogger<T> 类型参数的构造函数。将该参数赋值给实例变量 _logger

为 Web API 控制器定义日志记录器

  1. 现在我们可以在控制器方法中使用日志记录器了。让我们在 Get 方法中添加一个 INFO 消息:

从 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. 现在让我们尝试访问 /api/values/{id} 端点,并为 {id} 提供一个无效的值。我们应该会看到类似这样的内容:

我们 .NET 示例应用程序生成的调试和错误日志项

我们可以清楚地看到,首先是 DEBUG 级别的日志项,然后是 ERROR 级别的日志项。输出中的后者被标记为红色的 fail

  1. 完成后,请使用 Ctrl + C 结束应用程序。

现在我们已经了解了如何进行仪表化,接下来我们将在下一节中介绍 Jaeger。

使用 Jaeger 进行监控和故障排除

当我们想要监控和故障排除一个复杂分布式系统中的事务时,我们需要一些比我们刚刚学到的东西更强大的工具。当然,我们可以并且应该继续用有意义的日志消息来仪表化我们的代码,但我们还需要一些更高层次的东西。这个 更高层次的东西 就是能够追踪一个请求或事务从头到尾的能力,追踪它如何在由多个应用服务组成的系统中流动。理想情况下,我们还希望捕获其他有趣的指标,比如每个组件所花费的时间与请求总时间的对比。

幸运的是,我们不必重新发明轮子。外面有经过实战验证的开源软件,可以帮助我们实现前面提到的目标。Jaeger(www.jaegertracing.io/)就是这样的一个基础设施组件或软件示例。使用 Jaeger 时,我们运行一个中央的 Jaeger 服务器组件,而每个应用程序组件使用一个 Jaeger 客户端,透明地将调试和追踪信息转发到 Jaeger 服务器组件。Jaeger 客户端适用于所有主流编程语言和框架,如 Node.js、Python、Java 和 .NET。

本书中我们不会深入探讨如何使用 Jaeger 的所有细节,而是会对其概念性工作原理进行高层次概述:

  1. 首先,我们定义一个 Jaeger 的 tracer 对象。这个对象基本上协调了整个通过分布式应用程序追踪请求的过程。我们可以使用这个 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 对象构建的跨度将是调用方法的子跨度。这里的子跨度被称为 "format-string"。另请注意,我们在前面的方法中使用 logger 对象显式生成了一个 INFO 级别的日志项。

在本章包含的代码中,您可以找到一个完整的 C# 示例应用程序,其中包含一个 Jaeger 服务器容器和两个应用程序容器,分别名为 client 和 library,它们使用 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 示例应用程序的客户端组件

在前面的输出中,您可以看到三个用红色箭头标记的跨度,从最内部的跨度到最外部的跨度。我们还可以使用 Jaeger 的图形界面查看更多详细信息:

  1. 在您的浏览器中,导航到 http://localhost:16686 以访问 Jaeger UI。

  2. 在搜索面板中,确保选择了 hello-world 服务。将操作设置为 all,然后点击查找跟踪按钮。您应该会看到以下内容:

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 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 镜像。我们将使用该镜像的精简版本:
$ 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 的情况下,我们就能够实现目标。

如果这还不能说服你,假设你正在运行一个名为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 这样的工具自动化与应用程序交互的用户操作,Selenium 提供了一个开发人员自动化操作的手段,例如在表单中填写字段或点击按钮。

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. 现在我们可以启动数据库了。当然,我们将使用 Postgres 的官方 Docker 镜像在容器中运行数据库。但首先,我们将创建一个 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文件,使得script块看起来像这样:

为我们的集成测试添加测试脚本

  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容器。第二行移除我们为第三个容器使用的网络,最后,第三行移除 Postgres 使用的卷。每次测试运行后,执行此文件,使用./cleanup.sh

现在你可以开始向 API 组件添加更多的代码和集成测试了。每次你想要测试新的或修改过的代码时,只需运行test.sh脚本。

挑战:你如何进一步优化这个过程,以减少手动操作步骤?

使用我们在第六章中学到的内容,调试在容器中运行的代码

Testcontainers 项目

如果你是 Java 开发者,那么有一个很棒的项目叫做 Testcontainers(testcontainers.org)。用他们的话说,这个项目可以总结如下:

"Testcontainers 是一个 Java 库,支持 JUnit 测试,提供轻量级、可丢弃的常见数据库、Selenium 浏览器实例,或者任何可以在 Docker 容器中运行的东西。"

要实验 Testcontainers,按照以下步骤进行:

  1. 首先创建一个testcontainer-node文件夹并进入:
$ mkdir ~/fod/ch07/testcontainer-node && cd ~/fod/ch07/testcontainer-node
  1. 接下来,在该文件夹中打开 VS Code,使用命令code .。在同一文件夹内创建三个子文件夹,databaseapitests。在api文件夹中,添加一个包含以下内容的package.json文件:

API 的 package.json 内容

  1. 将一个server.js文件添加到api文件夹,并使用以下内容:

使用 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库做 exactly 这件事,并为我们运行一个 PostgreSQL 实例。

  1. 接下来,我们需要找出暴露的端口5432映射到哪个主机端口。我们可以通过以下逻辑来做到这一点:
const dbPort = dbContainer.getMappedPort(5432);

我们需要这些信息,因为 API 组件必须通过这个端口访问 Postgres。

  1. 我们还需要知道从容器内能够访问主机的 IP 地址——请注意,从容器内无法使用 localhost,因为这会映射到容器自身网络栈的回环适配器。我们可以通过以下方式获取主机的 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库发出 HTTP GET 请求到/hobbies端点:
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,它将包含包含多个阶段的管道定义。

一个简单的Jenkinsfile,包含BuildTestDeploy 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 服务器后,首先创建一个新项目;你可能需要在主菜单中点击New Item

在 Jenkins 中添加新项目

  1. 给项目命名为sample-pipeline,选择Pipeline类型,并点击 OK。

  2. 在配置视图中,选择 Pipeline 选项卡,并将前面的管道定义添加到 Script 文本框中:

在我们名为 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 文件,定义一个具有三个端点的 RESTful API,GET /GET /hobbiesGET /hobbies/:id。该代码使用 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. 为了避免所有的节点模块被保存到 GitHub 中,向项目的 root 文件夹添加一个 .gitignore 文件,内容如下:
node_modules
  1. 现在,我们需要在 GitHub 上定义一个仓库。登录到你的 GitHub 账户:github.com

  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

确保你在第一行中将 gnschenker 替换为你自己的 GitHub 账号名。完成这一步后,你的代码将可以在 GitHub 上使用。稍后 Jenkins 就是其中一个将从这个仓库拉取代码的用户。

  1. 接下来的步骤是回到 Jenkins (localhost:8080),并修改项目的配置。如果需要,先登录 Jenkins,然后选择你的项目 sample-pipeline

  2. 然后,选择主菜单中的“配置”,选择“流水线”标签,并修改设置,使其看起来像这样:

配置 Jenkins 从 GitHub 拉取源代码

这样,我们就配置了 Jenkins 从 GitHub 拉取代码,并使用 Jenkinsfile 来定义流水线。Jenkinsfile 应该位于项目的 root 目录中。注意,在仓库 URL 路径中,我们需要提供相对于 /home 目录的路径,该目录是我们项目所在的路径。记住,在运行 Jenkins 容器时,我们通过 -v "$HOME":/home 将主机上的个人文件夹映射到 Jenkins 容器内的 /home 文件夹。

  1. 点击绿色的保存按钮以接受更改。

  2. 我们已定义 Jenkinsfile 需要位于项目的 root 文件夹中。这是 Pipeline-as-Code 的基础,因为 pipeline 定义文件将与其他代码一起提交到 GitHub 仓库。因此,添加一个名为 Jenkinsfile 的文件到 jenkins-pipeline 文件夹中,并将此代码添加到文件中:

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"
            }
        }
    }
}

好的,我们一个部分一个部分地深入了解这个文件。在顶部,我们定义了两个环境变量,这些变量将在 pipeline 的每个阶段中都可用。我们将在 Build & Push Docker image 阶段使用这些变量:

environment {
    registry = "gnschenker/jenkins-docker-test"
    DOCKER_PWD = credentials('docker-login-pwd')
}

第一个变量,registry,仅包含我们最终将生成并推送到 Docker Hub 的容器镜像的完整名称。将 gnschenker 替换为你自己的 GitHub 用户名。第二个变量,DOCKER_PWD,稍微有点不同。它将包含登录到我 Docker Hub 帐户的密码。当然,我不希望在代码中硬编码这个值,因此,我使用 Jenkins 的凭据功能,这让我可以访问存储在 Jenkins 中名为 docker-login-pwd 的机密。

接下来,我们定义了一个代理,用来运行 Jenkins pipeline。在我们的例子中,这个代理基于 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 定义的单元测试。

第三个阶段,Build & Push Docker image,更有意思。现在我们已经成功构建并测试了我们的应用程序,我们可以为它创建一个 Docker 镜像,并将其推送到注册中心。我们使用 Docker Hub 作为注册中心,但任何私有或公共注册中心都可以使用。在这个阶段,我们定义了四个步骤:

  1. 我们使用 Docker 来构建镜像。我们使用在 Jenkinsfile 第一个部分定义的环境变量 $registry$BUILD_NUMBER 变量由 Jenkins 自身定义。

  2. 在我们能将某些内容推送到注册中心之前,我们需要先登录。在这里,我使用了之前定义的 $DOCKER_PWD 变量。

  3. 一旦我们成功登录到注册中心,就可以推送镜像。

  4. 由于镜像现在已经在注册中心,我们可以从本地缓存中删除它,以避免浪费空间。

记住,所有阶段都在我们的gnschenker/node-docker构建器容器内运行。因此,我们是在容器内运行 Docker。但由于我们已将 Docker 套接字映射到构建器容器中,Docker 命令实际上是作用于主机的。

让我们再向管道中添加两个阶段。第一个阶段看起来是这样的:

stage('Deploy and smoke test') {
    steps{
        sh './jenkins/scripts/deploy.sh'
    }
}

将它添加到Build & Push Docker image阶段之后。这个阶段会执行位于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

请注意,我已将 -f 参数添加到 prune 命令中。这是为了防止命令行界面询问我们是否确认真的要删除那些多余的层。

停止的容器也可能浪费宝贵的资源。如果你确定这些容器不再需要,应该将它们删除,可以使用以下命令逐个删除:

$ docker container rm <container-id>

或者,你也可以使用以下命令批量删除它们:

$ docker container prune --force

值得再次提到的是,我们可以使用 <container-name> 来代替 <container-id> 来识别一个容器。

未使用的 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 命令中传递这些环境变量的值。

我们希望在 builder 容器中运行该脚本。由于脚本使用了 Docker CLI,因此我们的 builder 容器必须安装 Docker CLI,并且为了访问 Docker 引擎,builder 容器必须绑定挂载 Docker 套接字。让我们开始为这样的 builder 容器创建一个 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 文件夹中定义的示例应用程序。确保将 <user><password> 替换为你自己的 Docker Hub 凭据:

$ 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 容器的用户。在 Docker for Windows 上,你可以通过绑定挂载 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 中的弱点吗?在 第四章《创建和管理容器镜像》中,我们学到了一张镜像由一系列层组成。每一行(逻辑上)都会创建一层,除了包含 CMD 和/或 ENTRYPOINT 关键字的行。我们还学到,Docker 构建器尽力通过缓存层并在后续构建之间重用它们来提高效率。但缓存只会使用发生在第一个更改层之前的缓存层。所有后续层都需要重新构建。也就是说,上述 Dockerfile 结构会破坏镜像层的缓存!

为什么?嗯,根据经验,你一定知道,在一个典型的 Node.js 应用程序中,npm install可能是一个相当耗费资源的操作,尤其是当有许多外部依赖时。这个命令的执行可能需要几秒钟到几分钟不等。也就是说,每当其中一个源文件发生变化,而我们知道在开发过程中这会经常发生时,第 3 行的Dockerfile会导致相应的镜像层发生变化。因此,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级别的权限。

再次让我们通过一个例子来说明我们所说的。假设我们有一个包含顶级机密内容的文件。我们希望使用chmod工具在基于 Unix 的系统上保护这个文件,确保只有拥有 root 权限的用户可以访问它。假设我以gabriel身份登录到dev主机,因此我的提示符是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

我得到权限被拒绝,这正是我们想要的。除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身份运行容器,你认为会发生什么?试试看吧!

这些是一些专业人士在日常容器使用中非常有用的提示和技巧。还有很多更多的技巧,去 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. 使用此处提供的说明,在虚拟机上安装 Docker:docs.docker.com/install/linux/docker-ce/ubuntu/

  2. 特别注意,不要忘记使用以下命令将你的用户(在我的例子中是gnschenker)添加到虚拟机的docker组中:

$ sudo usermod -aG docker <user-name>

这样,你就避免了在所有 Docker 命令中都必须使用sudo。你需要退出并重新登录虚拟机以使此更改生效。

  1. 现在,我们准备在虚拟机上的容器中运行Shell in a Box (github.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文件夹。这对于远程编辑我们在~/dev文件夹中从 GitHub 克隆的代码非常有用。

另外,请注意,我们将 Shell in a Box 的4200端口映射到主机的4200端口。我们将通过这个端口使用浏览器和 HTTPS 访问 Shell。因此,你需要在虚拟机上为流量打开4200端口。请选择 TCP 协议。

  1. 一旦容器启动,并且你已为流量打开了4200端口,打开一个新的浏览器窗口,访问https://<public-IP>:4200,其中<public-IP>是你的虚拟机的公共 IP 地址。由于我们使用的是自签名证书,你将看到一个警告,下面是使用 Firefox 时的显示:

由于使用了自签名证书,浏览器会显示警告

  1. 在我们的情况下,这不是问题;我们知道原因——它是自签名证书。因此,点击高级...按钮,然后选择接受风险并继续。现在,你将被重定向到登录页面。使用你的用户名和密码登录:

使用 HTTPS 通过浏览器登录远程虚拟机

我们已通过 HTTPS 协议登录到在远程虚拟机上运行的Shell in a Box应用程序。

  1. 现在,我们完全可以访问,例如,从主机虚拟机映射到/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 容器,并且需要安装一些额外的软件,别犹豫,创建一个继承自sspreitzer/shellinabox的自定义 Docker 镜像吧。

接下来,我们将看到如何在容器内运行你的开发环境。

在容器内运行你的开发环境

假设你只有一台安装了 Docker for Desktop 的工作站,但无法对这台工作站进行任何其他修改或安装软件。现在,你想做一些概念验证,并使用 Python 编写一个示例应用程序。不幸的是,你的电脑上没有安装 Python。你该怎么办?如果你可以在一个容器内运行完整的开发环境,包括代码编辑器和调试器呢?而且,同时你还能将代码文件保存在主机上呢?

容器是非常强大的,聪明的工程师们为这种问题设计了完美的解决方案。

让我们试试这个 Python 应用:

  1. 我们将使用 Visual Studio Code——我们最喜欢的代码编辑器——来展示如何在容器内运行完整的 Python 开发环境。不过,首先我们需要安装必要的 Visual Studio Code 扩展。打开 Visual Studio Code 并安装名为 Remote Development 的扩展:

Visual Studio Code 的远程开发扩展

  1. 然后,点击 Visual Studio Code 窗口左下角绿色的快速操作状态栏项。在弹出的菜单中,选择 Remote-Containers: Open Folder in Container...

在远程容器中打开项目

  1. 在容器中选择你想要工作的项目文件夹。在我们的例子中,我们选择了 ~/fod/ch08/remote-app 文件夹。Visual Studio Code 将开始准备环境,而第一次准备时可能需要几分钟时间。你会看到类似如下的消息:

Visual Studio Code 准备开发容器

默认情况下,这个开发容器以非 root 用户身份运行——在我们的例子中是 python 用户。我们在前一部分学到,这是一种强烈推荐的最佳实践。不过,你也可以修改配置,以 root 用户身份运行,方法是注释掉 .devcontainer/devcontainer.json 文件中的这一行:"runArgs": [ "-u", "python" ],

  1. 在 Visual Studio Code 中,按 Shift + Ctrl + ** 打开终端,并使用 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 的客户端部分——即用户界面——继续在主机上运行。

  1. 按 *Shift+Ctrl+* 打开另一个终端窗口,然后使用 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 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

输出应该类似于以下内容:

在远程虚拟机上启动 Visual Studio Code 远程服务器

Code Server 使用自签名证书来确保通信安全,因此我们可以通过 HTTPS 访问它。请确保记下屏幕上的 Password 输出,因为你在通过浏览器访问 Code Server 时需要它。还要注意,我们使用端口 4200 来在主机上公开 Code Server,原因是我们已经为虚拟机打开了该端口。你当然可以选择任何端口,只需确保为该端口开放入口流量。

  1. 打开一个新的浏览器页面,访问 https://<public IP>:4200,其中 <public IP> 是你虚拟机的公共 IP 地址。由于我们再次使用了自签名证书,浏览器会显示类似于本章前面使用 Shell in a Box 时的警告。接受该警告后,你将被重定向到 Code Server 的登录页面:

Code Server 的登录页面

  1. 输入你之前记下的密码并点击 ENTER IDE。现在你可以通过浏览器远程使用 Visual Studio Code,连接是安全的 HTTPS 连接:

在浏览器中通过 HTTPS 运行的 Visual Studio Code

  1. 现在你可以从任何地方进行开发,例如使用 Chromebook 或受限的工作站,毫无任何限制。但等一下,你现在可能会问!这和容器有什么关系?你说得对——到目前为止,确实没有涉及容器。不过,我可以说,如果你的远程虚拟机已安装 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. 现在你可以通过浏览器访问 Visual Studio Code,网址为http://<public IP>:4200。请注意网址中的HTTP,而不是HTTPS!类似于在远程虚拟机上本地运行 Code Server,你现在可以直接在浏览器中使用 Visual Studio Code:

在浏览器中进行开发

有了这个,希望你已经感受到容器的使用为你提供的几乎无限的可能性。

总结

在本章中,我们展示了一些高级 Docker 用户的技巧和窍门,可以让你的工作更加高效。我们还展示了如何利用容器提供整套开发环境,这些环境运行在远程服务器上,并且可以通过安全的 HTTPS 连接从浏览器中访问。

在下一章中,我们将介绍分布式应用架构的概念,并讨论成功运行分布式应用所需的各种模式和最佳实践。此外,我们还将列出在生产环境或类似生产环境中运行此类应用所需满足的一些关切。

问题

  1. 列出你希望将完整的开发环境运行在容器中的原因。

  2. 为什么你应该避免以 root 用户身份在容器内运行应用程序?

  3. 为什么你会将 Docker 套接字绑定挂载到容器中?

  4. 当你清理 Docker 资源以释放空间时,为什么需要特别小心地处理卷?

深入阅读

第三部分:编排基础与 Docker Swarm

在本节中,你将了解 docker 化分布式应用的概念,以及容器编排工具,并使用 Docker Swarm 来部署和运行你的应用。

本节包括以下章节:

  • 第九章,分布式应用架构

  • 第十章,单主机网络

  • 第十一章,Docker Compose

  • 第十二章,编排工具

  • 第十三章,Docker Swarm 简介

  • 第十四章,零停机部署与密钥管理

第十章:分布式应用架构

在上一章中,我们讨论了在将复杂的分布式应用容器化时,或在使用 Docker 自动化复杂任务时,如何利用一些高级技巧、窍门和概念。

在本章中,我们将介绍分布式应用架构的概念,并讨论成功运行分布式应用所需的各种模式和最佳实践。最后,我们将讨论在生产环境中运行这种应用程序所需满足的额外要求。

在本章中,我们将讨论以下主题:

  • 理解分布式应用架构

  • 模式和最佳实践

  • 在生产环境中运行

完成本章后,你将能够完成以下任务:

  • 至少列出分布式应用架构的四个特征

  • 列出实现生产就绪的分布式应用所需的三到四个模式

理解分布式应用架构

在本节中,我们将解释当我们谈到分布式应用架构时,究竟指的是什么。首先,我们需要确保我们使用的所有词汇或缩写都有明确的定义,并且我们在讨论时使用的是相同的语言。

定义术语

在本章以及后续章节中,我们将讨论许多可能对每个人来说都不太熟悉的概念。为了确保大家都在使用相同的术语,接下来我们将简要介绍并解释这些概念或词汇中最重要的部分:

术语 解释
虚拟机(VM) 虚拟机的缩写。它是一个虚拟计算机。
节点 用于运行应用程序的独立服务器。这可以是物理服务器,通常称为裸金属服务器,或是虚拟机(VM)。它可以是大型机、超级计算机、标准商业服务器,甚至是树莓派。节点可以是公司自有数据中心中的计算机,也可以是云中的计算机。通常,节点是集群的一部分。
集群 通过网络连接的一组节点,用于运行分布式应用程序。
网络 集群中各节点及其运行的程序之间的物理和软件定义的通信路径。
端口 应用程序(如 Web 服务器)监听传入请求的通道。
服务 这个词不幸地有很多含义,它的真正意义取决于使用的上下文。如果我们在应用程序的上下文中使用“服务”一词,比如应用服务,它通常指的是一段实现有限功能的软件,其他部分的应用程序会使用这些功能。随着本书的深入,我们还将讨论其他类型的服务,它们有着稍微不同的定义。

简单来说,分布式应用架构可以被看作是与单体应用架构的对立面,但从某种角度看,先理解这种单体架构并不无道理。传统上,大多数商业应用都是以一种方式编写的,可以看作是一个单一、紧密耦合的程序,运行在数据中心某个命名的服务器上。所有的代码被编译成一个单一的二进制文件或几个紧密耦合的二进制文件,运行该应用时需要将这些文件共同放置。此时,应用运行所在的服务器,或更一般的主机,有一个明确的名称或静态 IP 地址,也是非常重要的。让我们看看以下图示,更清晰地说明这种应用架构:

单体应用架构

在前面的图示中,我们可以看到一台名为blue-box-12a服务器,其IP地址为172.52.13.44,正在运行一个名为pet-shop的应用程序,这个应用是一个由主模块和几个紧密耦合的库组成的单体应用。

现在,让我们看看下面的图示:

分布式应用架构

在这里,突然之间,我们不再只有一台命名的服务器;相反,我们有很多台服务器,它们没有人类友好的名称,而是一些类似于通用唯一标识符UUID)这样的独特 ID。宠物商店应用程序也突然不再是一个单一的单体块,而是由许多交互的、但松散耦合的服务组成,如pet-apipet-webpet-inventory。此外,每个服务在这群服务器或主机的集群中运行多个实例。

你可能会想知道,为什么我们要在一本关于 Docker 容器的书中讨论这个问题,你问得很对。虽然我们要探讨的所有主题同样适用于没有(还没有)容器的世界,但必须意识到,容器和容器编排引擎在更高效、直接的方式上解决了所有这些问题。在分布式应用架构中曾经非常难以解决的大部分问题,在容器化的世界中变得相当简单。

模式和最佳实践

分布式应用架构有许多引人注目的优势,但与单体应用架构相比,它也有一个非常显著的缺点——前者的复杂度要高得多。为了控制这种复杂性,业界提出了一些重要的最佳实践和模式。在接下来的章节中,我们将更详细地探讨其中一些最重要的内容。

松散耦合的组件

解决复杂问题的最佳方式一直是将其分解为更小、更易管理的子问题。例如,如果一次性建造一座房子,那将是疯狂复杂的。通过将房子从简单的部分开始构建,然后将这些部分组合成最终结果,会容易得多。

同样的道理也适用于软件开发。如果我们将一个非常复杂的应用分解为几个互相协作并构成整体应用的小组件,开发这些组件将会容易得多。如果这些组件之间的耦合较松散,那么开发它们将更加容易。也就是说,组件 A 不需要假设组件 B 和 C 的内部工作原理,只关心如何通过定义良好的接口与这两个组件进行通信。

如果每个组件都有一个定义良好且简单的公共接口,通过这个接口可以与系统中的其他组件及外部世界进行通信,那么这将使我们能够独立地开发每个组件,而不依赖于其他组件。在开发过程中,系统中的其他组件可以轻松地被存根或模拟对象替代,从而使我们能够测试我们的组件。

有状态与无状态

每个有意义的业务应用都会创建、修改或使用数据。在信息技术领域,数据的同义词是状态。创建或修改持久数据的应用服务被称为有状态组件。典型的有状态组件包括数据库服务或创建文件的服务。另一方面,不创建或修改持久数据的应用组件被称为无状态组件。

在分布式应用架构中,无状态组件比有状态组件更容易处理。无状态组件可以轻松地扩展或收缩。此外,它们可以快速且无痛地被销毁并在集群的完全不同节点上重启——这一切都因为它们没有与之相关的持久数据。

鉴于这一点,设计系统时,尽可能让大部分应用服务保持无状态是非常有帮助的。最好将所有有状态组件推到应用的边界,并限制其数量。管理有状态组件是非常困难的。

服务发现

随着我们构建由许多独立组件或服务组成的应用,这些组件或服务之间需要相互通信,我们需要一种机制,使得这些独立组件能够在集群中相互发现。相互发现通常意味着你需要知道目标组件在哪个节点上运行,以及它在哪个端口监听通信。通常,节点是通过 IP 地址和端口来标识的,而端口只是一个在定义良好的范围内的数字。

从技术上讲,我们可以告诉服务 A,它需要与目标服务 B通信,目标的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,如acme.com/pets)路由到我们系统中的适当目标组件。

负载均衡

负载均衡用于当服务 A需要与服务 B进行通信时,例如在请求-响应模式中,但服务 B有多个实例运行,如下图所示:

服务 A 的请求被负载均衡到服务 B

如果我们在系统中运行多个服务实例,比如服务 B,我们希望确保每个实例都能分配到相等的工作负载。这是一个通用任务,这意味着我们不希望调用者来执行负载均衡,而是一个外部服务来拦截请求并负责决定将请求转发到哪个目标服务实例。这个外部服务被称为负载均衡器。负载均衡器可以使用不同的算法来决定如何将传入的请求分发到目标服务实例。最常用的算法是轮询算法。这个算法以重复的方式分配请求,从实例 1 开始,然后是实例 2,直到实例 n。服务完最后一个实例后,负载均衡器会从实例 1 重新开始。

在前面的例子中,负载 均衡器 还促进了高可用性,因为来自服务 A的请求将被转发到健康的服务 B实例。负载均衡器还承担定期检查每个 B 实例健康状况的角色。

防御性编程

在为分布式应用程序开发服务时,重要的是要记住,服务不会是独立的,它依赖于其他应用服务,甚至是第三方提供的外部服务,例如信用卡验证服务或股票信息服务,仅举两个例子。所有这些其他服务都是外部服务,我们无法控制它们的正确性或在任何给定时间的可用性。因此,在编码时,我们总是需要假设最坏的情况并希望最好的情况发生。假设最坏意味着我们必须明确处理潜在的故障。

重试

当存在外部服务可能暂时不可用或响应不及时的情况时,可以采用以下步骤。当调用其他服务失败或超时时,调用代码应按如下方式构建:在短暂等待后重复相同的调用。如果调用再次失败,等待时间应稍长一点再进行下一次尝试。调用应重复进行,直到达到最大重试次数,每次增加等待时间。之后,服务应放弃并提供降级服务,这可能意味着返回一些陈旧的缓存数据或根本不返回数据,具体取决于情况。

日志记录

在服务上执行的重要操作应该始终进行日志记录。日志信息需要进行分类,才能具有实际价值。常见的分类包括调试、信息、警告、错误和致命。日志信息应该由中央日志聚合服务收集,而不是存储在集群的单个节点上。聚合日志易于解析和筛选出相关信息。这些信息对于快速定位分布式系统中故障或意外行为的根本原因至关重要,特别是在生产环境中,系统包含许多动态组件。

错误处理

如前所述,分布式应用中的每个应用服务都依赖于其他服务。作为开发人员,我们应该始终预期最坏的情况并采取适当的错误处理措施。最重要的最佳实践之一是快速失败。编写服务代码时,要确保不可恢复的错误尽早被发现,如果检测到此类错误,应立即让服务失败。但不要忘记将有意义的信息记录到STDERRSTDOUT,供开发人员或系统操作员稍后跟踪系统故障。同时,返回一个有帮助的错误信息给调用者,尽可能精确地指明调用失败的原因。

失败快速处理的一个示例是始终检查调用方提供的输入值。值是否在预期的范围内且完整?如果不是,则不要继续处理,而是立即中止操作。

冗余

一个关键任务系统必须随时可用,全天候、全年无休。停机是不可接受的,因为它可能导致公司失去大量的机会或声誉。在一个高度分布式的应用中,至少一个组件发生故障的可能性是不可忽视的。我们可以说,问题不是某个组件是否会失败,而是失败何时发生。

为了避免在系统中众多组件之一发生故障时造成停机,每个系统的独立部分都需要是冗余的。这包括应用组件以及所有基础设施部分。这意味着,如果我们以支付服务为例作为应用的一部分,那么我们需要对这个服务进行冗余部署。最简单的方式就是在我们集群的不同节点上运行该服务的多个实例。同样,边缘路由器或负载均衡器也适用这种方式。我们不能允许这些组件发生故障。因此,路由器或负载均衡器必须是冗余的。

健康检查

我们已经多次提到,在一个分布式应用架构中,由于其组成部分众多,单个组件的故障是高度可能的,且只不过是时间问题。因此,我们对系统中的每个组件都进行了冗余部署。代理服务随后将流量均衡地分配到服务的各个实例上。

但现在,出现了另一个问题。代理或路由器如何知道某个服务实例是否可用?它可能已经崩溃,或者可能没有响应。为了解决这个问题,我们可以使用所谓的健康检查。代理,或代理代表的其他系统服务,定期轮询所有服务实例并检查它们的健康状况。基本的问题是,你还在吗?你健康吗?每个服务的答案要么是“是”,要么是“否”,如果实例不再响应,则健康检查超时。

如果组件的回答是“否”或发生超时,则系统会终止相应的实例并启动一个新的实例来替代它。如果这一切都是完全自动化完成的,那么我们可以说我们已经建立了一个自愈系统。

代理定期轮询组件状态的方式,也可以反过来进行。可以要求组件定期向代理发送存活信号。如果组件未能在预定义的较长时间内发送存活信号,则认为该组件不健康或已死。

在某些情况下,以上两种方式中的任意一种更为适用。

断路器模式

熔断器是一种机制,用于防止分布式应用程序因许多关键组件的级联故障而崩溃。熔断器有助于避免一个故障的组件通过连锁反应破坏其他依赖服务。就像电力系统中的熔断器,通过中断电力线路来保护房屋免受损坏,防止故障的电器引发火灾,分布式应用程序中的熔断器也会在服务 A服务 B的连接中断时工作,如果服务 B没有响应或发生故障。

这可以通过将受保护的服务调用包装在熔断器对象中来实现。该对象监控失败。一旦失败次数达到一定阈值,熔断器就会触发。所有后续对熔断器的调用都会返回错误,而不会执行受保护的调用:

熔断器模式

在前面的图示中,我们看到一个熔断器,在调用服务 B时收到第二个超时后触发。

运行在生产环境中

为了成功地在生产环境中运行分布式应用程序,我们需要考虑一些超出前述最佳实践和模式的其他方面。一个特别值得注意的领域是自省和监控。让我们详细讨论最重要的方面。

日志记录

一旦分布式应用程序进入生产环境,就无法进行实时调试。那么我们该如何找出应用程序故障的根本原因呢?解决这个问题的方法是让应用程序在运行时生成大量有意义的日志信息。开发者需要对他们的应用服务进行必要的操作,使其输出有用的信息,比如在出现错误或遇到潜在的意外或不希望发生的情况时。通常,这些信息会输出到STDOUTSTDERR,然后被系统守护进程收集,并写入本地文件或转发到中央日志聚合服务。

如果日志中有足够的信息,开发者可以利用这些日志来追踪系统中错误的根本原因。

在分布式应用架构中,由于其众多组件,日志记录比单体应用中更为重要。单个请求通过应用的所有组件的执行路径可能非常复杂。此外,请记住,组件是分布在多个节点上的。因此,记录所有重要的内容是有意义的,并且在每个日志条目中添加诸如发生的确切时间、发生的组件和运行该组件的节点等信息,仅举几例。此外,日志信息应汇总在一个中央位置,以便开发人员和系统运维人员可以方便地进行分析。

跟踪

跟踪用于找出单个请求如何通过分布式应用程序流转,以及该请求在每个组件和整体上花费了多少时间。如果收集到这些信息,它可以作为显示系统行为和健康状况的仪表盘数据来源之一。

监控

运维工程师喜欢查看显示系统关键指标的仪表盘,这些仪表盘可以让他们一眼看到应用程序的整体健康状况。这些指标可以是非功能性的,如内存和 CPU 使用情况、系统或应用组件的崩溃次数、节点的健康状况,也可以是功能性的,因而是特定于应用的指标,例如订购系统中的结账次数或库存服务中缺货的商品数量。

最常见的情况是,用于聚合显示仪表盘数据的基础数据来自日志信息。这可以是系统日志,主要用于非功能性度量,或是应用级日志,用于功能性度量。

应用更新

公司的一项竞争优势是能够及时应对市场变化。部分原因是能够快速调整应用程序以满足新的或变化的需求,或者增加新功能。我们更新应用程序的速度越快越好。现在,许多公司每天会推出多次新的或更改的功能。

由于应用更新非常频繁,这些更新必须是非中断性的。我们不能允许系统在升级时停机进行维护。所有更新必须无缝且透明地进行。

滚动更新

更新应用程序或应用服务的一种方式是使用滚动更新。这里的假设是,必须更新的特定软件在多个实例中运行。只有在这种情况下,我们才能使用这种更新方式。

发生的情况是,系统停止当前服务的一个实例,并用新服务的实例替代它。一旦新实例准备好,它就会开始处理流量。通常,新实例会被监控一段时间,以确认它是否按预期工作。如果一切正常,接下来当前服务的另一个实例会被下线,并用新实例替代。这个模式会重复,直到所有服务实例都被替换为止。

由于在任何时候总会有一些实例在运行,无论是当前版本还是新版本,应用始终处于可操作状态。无需停机时间。

蓝绿部署

在蓝绿部署中,当前版本的应用服务,称为蓝色,处理所有的应用流量。然后,我们将在生产系统上安装新版本的应用服务,称为绿色。新服务尚未与其他应用连接。

一旦绿色服务安装完成,我们就可以对新服务执行冒烟测试,如果测试成功,路由器可以配置为将所有原本流向蓝色服务的流量引导到新服务绿色。接着,密切观察绿色的表现,如果所有成功标准都满足,就可以停用蓝色服务。但如果由于某种原因,绿色出现了意外或不希望出现的行为,路由器可以重新配置,将所有流量返回到蓝色服务。然后,绿色服务可以被移除并修复,之后可以执行一个新的蓝绿部署,使用修正过的版本。

蓝绿部署

接下来,我们来看一下金丝雀发布。

金丝雀发布

金丝雀发布是指我们在系统中并行安装当前版本和新版本的应用服务。因此,它们类似于蓝绿部署。一开始,所有流量仍然通过当前版本进行路由。然后,我们配置一个路由器,将整体流量的一小部分,例如 1%,引导到新版本的应用服务。随后,密切监控新服务的行为,看看它是否按预期工作。如果所有成功标准都达成,路由器就会配置为将更多流量(比如这次是 5%)引导到新服务。同样,新服务的行为会被密切监控,如果它成功,更多流量会被引导到它,直到 100%流量都转到新服务。当所有流量都已切换到新服务且运行稳定一段时间后,旧版本的服务就可以停用。

为什么我们称之为金丝雀发布?它得名于煤矿工人,他们曾用金丝雀鸟作为矿井中的早期预警系统。金丝雀鸟对有毒气体特别敏感,如果金丝雀鸟死了,矿工们就知道必须立即撤离矿井。

不可逆的数据变更

如果我们的更新过程需要在状态中执行不可逆的更改,例如在后端关系型数据库中执行不可逆的模式更改,那么我们需要特别小心地处理此问题。如果我们采用正确的方法,实际上可以在不造成停机的情况下执行这些更改。需要认识到,在这种情况下,我们无法同时部署需要新数据结构的数据存储代码更改和数据更改。相反,整个更新必须分为三个独立的步骤。在第一步中,我们发布向后兼容的模式和数据更改。如果这一步成功,接下来我们在第二步中发布新代码。同样,如果第二步成功,我们在第三步中清理模式并移除向后兼容性:

发布不可逆的数据或模式更改

上述图示展示了数据及其结构如何更新,然后应用代码如何更新,最后在第三步中,数据和数据结构如何被清理。

回滚

如果我们有频繁的更新应用服务并且这些服务在生产环境中运行,迟早其中一次更新会出现问题。也许某个开发人员在修复一个 bug 时引入了新的 bug,而这个 bug 没有被所有自动化测试,甚至手动测试捕捉到,导致应用程序出现异常行为,这时我们就必须将服务回滚到之前的正常版本。在这种情况下,回滚就是一种灾难恢复。

再次强调,在分布式应用架构中,问题不在于是否会需要回滚,而是回滚何时发生。因此,我们必须确保可以随时回滚到任何构成我们应用程序的服务的先前版本。回滚不能是事后考虑的事情;它们必须是经过测试并且已验证的部署流程的一部分。

如果我们使用蓝绿部署来更新服务,那么回滚应该相对简单。我们只需要将路由器从新的绿色版本切换回先前的蓝色版本。

总结

在本章中,我们了解了什么是分布式应用架构,以及哪些模式和最佳实践对成功运行分布式应用有帮助或是必需的。最后,我们讨论了运行此类应用程序在生产环境中还需要哪些其他内容。

在下一章中,我们将深入讨论限制在单一主机上的网络。我们将讨论如何让同一主机上的容器之间互相通信,以及如果需要,外部客户端如何访问容器化的应用程序。

问题

请回答以下问题,以评估你对本章内容的理解:

  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 Network Plugin Cisco 全球 开源容器网络

所有不是由 Docker 直接提供的网络类型都可以作为插件添加到 Docker 主机中。

网络防火墙

Docker 一直秉持“安全第一”的理念。这一哲学直接影响了单主机和多主机 Docker 环境中网络的设计和实现。软件定义的网络既易于创建,又成本低廉,但它们完美地为附加到该网络上的容器与其他非附加容器以及外部世界之间设置了防火墙。所有属于同一网络的容器可以自由通信,而其他容器则无法做到这一点。

在下图中,我们有两个网络,分别叫做 frontbackc1c2 容器附加到前端网络,c3c4 容器附加到后端网络。c1c2 可以自由地互相通信,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 简介》中,我们还将讨论具有全局范围的其他网络类型,这些网络可以跨整个主机集群。

现在,让我们深入了解一下这个bridge网络的详细情况。为此,我们将使用 Docker 的inspect命令:

$ docker network inspect bridge

执行时,这将输出关于相关网络的大量详细信息。该信息应该如下所示:

执行inspect命令后生成的输出

我们在列出所有网络时看到了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 主机是虚拟机,则是一个虚拟 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。我们还可以看到,容器如预期地获得了172.17.0.4的 IP 地址,并且网关位于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 地址将是子网中下一个空闲的 IP,即172.17.0.3,我们可以通过以下测试来验证:

$ docker container inspect --format "{{.NetworkSettings.IPAddress}}" c2
172.17.0.3

现在,我们有两个容器连接到bridge网络。我们可以再次尝试检查此网络,以便在输出中找到连接到该网络的所有容器列表:

$ docker network inspect bridge

此信息可以在Containers节点下找到:

Docker 网络检查 bridge 命令的容器部分输出

再次为了可读性,我们已经缩短了输出,仅保留相关部分。

现在,我们创建两个附加到test-net的额外容器,c3c4。为此,我们将使用--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 网络检查命令 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 重新确认我是否真的要删除所有未使用的网络。

主机和空网络

在这一部分,我们将介绍两种预定义的、具有一定独特性的网络类型:host网络和null网络。我们先从前者开始。

主机网络

有时我们需要在主机的网络命名空间中运行一个容器。这在我们需要在容器中运行一些用于分析或调试主机网络流量的软件时可能是必要的。但请记住,这些是非常特定的场景。在容器中运行业务软件时,永远没有理由将相关容器连接到主机网络。出于安全原因,强烈建议你不要在生产环境或类似生产的环境中运行任何连接到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 还为我们提供了一种额外的方式来定义容器运行的网络命名空间。在创建新容器时,我们可以指定它应该附加到(或者说包括在)一个现有容器的网络命名空间中。通过这种技术,我们可以在一个单独的网络命名空间中运行多个容器:

在单一网络命名空间中运行多个容器

在上面的图中,我们可以看到,在最左侧的网络命名空间中,我们有两个容器。这两个容器因为共享相同的命名空间,所以可以通过 localhost 互相通信。然后,网络命名空间(而不是单独的容器)被连接到网络 1

当我们想要调试现有容器的网络时,这非常有用,而不需要在该容器内运行额外的进程。我们只需将一个特殊的工具容器附加到容器的网络命名空间中进行检查。Kubernetes 在创建 Pod 时也使用了这个功能。我们将在本书的第十五章,Kubernetes 简介中了解更多关于 Kubernetes 和 Pod 的内容。

现在,让我们演示一下这个是如何工作的:

  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!我们可以通过使用 wget 工具来证明这一点,wget 是 Alpine 容器的一部分,它可以连接到 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 参数的值形式为 <host port>:<container port>。因此,在上述情况下,我们将容器端口 80 映射到主机端口 8080。一旦 web2 容器运行,我们可以通过浏览器访问 localhost:8080 来进行测试,我们应该会看到与之前自动端口映射示例中看到的相同的 nginx 欢迎页面。

当使用 UDP 协议在某个端口进行通信时,publish 参数将类似于 -p 3000:4321/udp。请注意,如果我们希望允许使用 TCP 和 UDP 协议在同一端口进行通信,那么我们必须分别为每种协议单独映射端口。

使用反向代理进行 HTTP 层级路由

想象一下,你的任务是将一个单体应用程序容器化。这个应用程序多年来已经演变成一个无法维护的庞然大物。即使是修改源代码中的一个小功能,也可能由于代码库中的紧密耦合而导致其他功能的崩溃。由于其复杂性,发布过程很少,并且需要整个团队参与。在发布窗口期间,应用程序必须停机,这会给公司带来大量损失,除了错失的机会,还包括他们的声誉损失。

管理层决定结束这种恶性循环,并通过将单体应用程序容器化来改善这种情况。仅这一措施就会显著减少发布之间的时间,这一点已经在业内得到了验证。在后续步骤中,公司还计划将单体应用程序中的每个功能模块拆分出来,转变为微服务。这个过程将持续进行,直到单体应用程序完全被消除。

但正是这一点让团队成员感到困惑。我们如何在不影响众多现有客户端的情况下,将这个单体应用拆解成松耦合的微服务呢?尽管单体应用的公共 API 非常复杂,但其设计结构良好。公共 URI 经精心设计,绝不应更改。例如,应用程序中有一个产品目录功能,可以通过 https://acme.com/catalog?category=bicycles 访问,从而查看公司提供的自行车列表。

另一方面,我们有一个 URL https://acme.com/checkout,可以用来启动客户购物车的结账流程,等等。我希望您能明白我们这么做的目的。

容器化单体应用

让我们从单体应用开始。我已经准备了一份简单的代码库,使用 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

该应用程序将在localhost上通过5000端口启动并监听:

运行 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 微服务来实现。

你可以在项目文件夹中的 catalog 子文件夹中找到他们编写的代码和 Dockerfile,也就是 e-shop 文件夹。这是一个简单的 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 相比,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 地址

    • 与网络关联的子网

  5. host 网络的目的是什么?

  6. 请列举一个或两个适合使用 host 网络的场景。

  7. none 网络的目的是什么?

  8. 在哪些场景下应该使用 none 网络?

  9. 为什么我们要将反向代理如 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:在这一部分,我们在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服务的容器中。

  • volumes:任何服务使用的卷必须在这一部分声明。在我们的示例中,这是文件的最后一部分。应用程序第一次运行时,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。但这只是第一部分;输出的第二部分应该类似于以下内容:

为 db 服务构建 Docker 镜像

这里,docker-compose 再次从 Docker Hub 拉取基础镜像 postgres:12.0-alpine,然后使用在 db 文件夹中找到的 Dockerfile 来构建我们称为 fundamentalsofdocker/ch11-db:2.0 的镜像。

使用 Docker Compose 运行应用程序

一旦我们构建了镜像,就可以使用 Docker Compose 启动应用程序:

$ docker-compose up

输出将显示应用程序的启动情况。我们应该看到以下内容:

运行示例应用程序,第一部分

在输出的第一部分中,我们可以看到 Docker Compose 执行了以下操作:

  • 创建一个名为 ch11_default 的桥接网络

  • 创建一个名为 ch11_pets-data 的卷

  • 创建两个服务 ch11_web_1ch11_db_1,并将它们附加到网络上

Docker Compose 还显示了由数据库(蓝色)和 Web 服务(黄色)生成的日志输出,两者都在启动中。输出的倒数第三行显示 Web 服务已准备就绪,并监听在端口 3000。请记住,这是容器端口,而不是主机端口。我们已将容器端口 3000 映射到主机端口 80,这是我们稍后要访问的端口。

现在让我们看看输出的第二部分:

运行示例应用程序,第二部分

我们稍微简化了输出的第二部分。它向我们展示了数据库如何完成初始化。我们可以具体看到我们的初始化脚本 init-db.sql 的应用,该脚本定义了一个数据库并用一些数据进行了填充。

现在,我们可以打开一个浏览器标签并导航到 localhost/animal。我们应该会看到一只野生动物的图片,这张图片是我在肯尼亚马赛马拉国家公园拍摄的:

浏览器中的示例应用程序

刷新浏览器几次以查看其他猫图片。应用程序从数据库中存储的 12 张图片的集合中随机选择当前图片。

由于应用程序正在交互模式下运行,因此我们运行 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 会在所有名称前加上docker-compose.yml文件所在父文件夹的名称并加上下划线。在这种情况下,父文件夹名为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以构建相应的镜像。如果我们想使用一个名为Dockerfile-devDockerfile,比如用于web服务,那么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 主机上以声明式方式处理多服务应用的工具。

本章介绍了编排器的概念。它讲解了为什么需要编排器以及它们的概念性工作原理。本章还将概述最流行的编排器,并列出它们的一些优缺点。

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

  • 什么是编排器,我们为什么需要它们?

  • 编排器的任务

  • 常见编排器概览

完成本章后,您将能够完成以下任务:

  • 请列出三到四个由编排器负责的任务

  • 列举两到三种最流行的编排器

  • 用你自己的话,并通过适当的类比,向一个感兴趣的外行解释为什么我们需要容器编排器

什么是编排器,我们为什么需要它们?

在第九章,分布式应用架构中,我们学习了构建、部署和运行高分布式应用程序时常用的模式和最佳实践。现在,如果我们的分布式应用程序是容器化的,那么我们就面临着与非容器化分布式应用程序相同的问题或挑战。这些挑战正是我们在第九章,分布式应用架构中讨论过的——服务发现、负载均衡、扩展等。

类似于 Docker 对容器的做法——通过引入容器来标准化软件的打包和运输——我们希望有一个工具或基础设施软件来处理我们刚才提到的所有或大多数挑战。这些软件就是我们所说的容器编排器,或者也可以称它们为编排引擎。

如果我刚才说的这些还没有让你完全理解,那我们换个角度来看这个问题。想象一个演奏乐器的艺术家,他们能够独自为观众演奏美妙的音乐——仅仅是艺术家和他们的乐器。但是,如果你把一群音乐家组成一个管弦乐队,安排他们在同一个房间里,给他们一份交响曲的乐谱,让他们去演奏,然后离开房间。如果没有指挥,这些非常有天赋的音乐家可能无法和谐地演奏这首曲子;最终的效果可能听起来就像一阵杂乱无章的噪音。只有当乐团有了一位指挥,来指挥这些音乐家,乐团演奏出的音乐才会让我们的耳朵感到愉悦:

容器编排器就像管弦乐队的指挥

来源: it.wikipedia.org/wiki/Giuseppe_Lanzetta#/media/File:UMB_5945.JPG

许可证: creativecommons.org/licenses/by-sa/3.0/deed.en

现在我们不再有音乐家,而是有容器;不再有不同的乐器,而是有对容器主机有不同要求的容器。与音乐以不同的节奏演奏不同,我们现在有容器以特定的方式相互通信,并且需要进行扩展或缩减。在这方面,容器编排器的角色与交响乐团的指挥非常相似。它确保集群中的容器和其他资源能够和谐地共同工作。

我希望你现在能更清楚地看到容器编排器是什么,以及为什么我们需要它。假设你确认了这个问题,我们现在可以问自己,编排器如何实现预期的结果,也就是确保集群中的所有容器能够和谐地协同工作。那么,答案是,编排器必须执行非常具体的任务,类似于交响乐团指挥执行一系列任务,以驯服并同时提升乐团的表现。

编排器的任务

那么,**我们期望一个值得信赖的编排器为我们执行哪些任务呢?让我们详细看一下它们。以下列表展示了在写这篇文章时,企业用户通常期望他们的编排器完成的最重要的任务。

协调期望状态

使用编排器时,你以声明式的方式告诉它如何运行特定的应用或应用服务。我们在第十一章,Docker Compose中学习了声明式与命令式的区别。这种描述我们想要运行的应用服务的声明式方式包括元素,例如使用哪个容器镜像、运行多少个服务实例、开放哪些端口等等。我们所说的应用服务的属性声明就是我们所称之为的期望状态

所以,当我们第一次告诉编排器根据声明创建一个新的应用服务时,编排器会确保按要求调度集群中尽可能多的容器。如果容器镜像尚未在容器应运行的目标节点上可用,调度器会确保先从镜像注册表下载它们。接下来,容器会启动,并配置所有设置,例如要连接的网络或需要暴露的端口。编排器会尽力确保集群的实际状态与声明完全匹配。

一旦我们的服务按要求启动并运行,即处于期望的状态,编排器会继续监控它。每次编排器发现服务的实际状态与期望状态之间存在差异时,它会再次尽最大努力去协调期望状态。

应用服务的实际状态和期望状态之间可能出现什么样的不一致呢?假设服务的某个副本,也就是其中一个容器,由于一个 bug 崩溃了,那么调度器会发现实际状态与期望状态在副本数量上不同:少了一个副本。调度器会立即将一个新的实例调度到集群的另一个节点,替代崩溃的实例。另一个不一致可能是应用服务运行的实例过多,如果该服务已经被缩减。在这种情况下,调度器会随机杀掉多余的实例,以便使实际实例数与期望实例数保持一致。还有一种不一致可能是调度器发现有某个实例正在运行错误(可能是旧版)的底层容器镜像。到现在,你应该明白了吧?

因此,代替我们主动监控在集群中运行的应用服务,并纠正任何偏离期望状态的情况,我们将这项繁琐的任务委托给了调度器。前提是我们使用声明式而非命令式的方式来描述应用服务的期望状态,这样的方式效果非常好。

复制型和全局型服务

我们可能想要在由调度器管理的集群中运行两种截然不同类型的服务。它们是 复制型全局型 服务。复制型服务是指需要以特定数量的实例运行的服务,例如 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)。调度器允许我们为每个应用服务定义节点亲和性。然后,调度器会确保它的调度器只会在满足要求条件的集群节点上调度容器。

应避免将亲和性定义到特定节点上;这样做会引入单点故障,从而危及高可用性。始终将一组多个集群节点定义为应用服务的目标。

一些调度引擎还支持所谓的位置感知地理位置感知。这意味着您可以要求调度器将服务的实例平均分布到不同的地点。例如,您可以定义一个datacenter标签,可能的值包括westcentereast,并将标签应用于所有集群节点,值对应于各自节点所在的地理区域。然后,您指示调度器使用这个标签来实现某个应用服务的地理位置感知。在这种情况下,如果您请求九个副本的服务,那么调度器会确保在三个数据中心——西部、中心和东部——的每个节点上部署三个实例。

地理位置感知甚至可以按层次定义;例如,您可以将数据中心作为顶级区分器,然后是可用性区域。

地理位置感知或位置感知用于减少由于电力供应故障或数据中心故障引起的停机概率。如果应用实例分布在多个节点、可用性区域甚至数据中心中,那么所有的实例同时宕机的可能性非常低。总会有一个区域是可用的。

安全

如今,IT 安全是一个非常热门的话题。网络战处于历史最高水平。大多数高知名度的公司都曾是黑客攻击的受害者,且付出了高昂的代价。每位首席信息官CIO)或首席技术官CTO)最糟糕的噩梦之一就是早上醒来,听到新闻说他们的公司成为了黑客攻击的受害者,并且敏感信息被窃取或泄露。

为了应对大多数安全威胁,我们需要建立一个安全的软件供应链,并加强深度安全防御。让我们来看看在企业级调度器中可以期待的一些任务。

安全通信和加密节点身份

首先,我们要确保由协调器管理的集群是安全的。只有受信任的节点才能加入集群。每个加入集群的节点都会获得一个加密的节点身份,节点之间的所有通信必须加密。为此,节点可以使用相互传输层安全性MTLS)。为了验证集群节点之间的身份,使用证书。这些证书会定期自动轮换,或者按需轮换,以防止证书泄露时保护系统。

集群中的通信可以分为三种类型。你可以谈论通信平面——管理平面、控制平面和数据平面:

  • 管理平面是集群管理员或主节点使用的平面,例如,调度服务实例、执行健康检查,或创建和修改集群中的其他资源,如数据卷、机密或网络。

  • 控制平面用于在集群的所有节点之间交换重要的状态信息。例如,这些信息用于更新集群中的本地 IP 表,这些表用于路由。

  • 数据平面是实际的应用服务相互通信和交换数据的地方。

通常,协调器主要关心保护管理平面和控制平面。数据平面的安全由用户负责,尽管协调器可以协助完成这一任务。

安全网络和网络策略

在运行应用服务时,并不是每个服务都需要与集群中的其他每个服务进行通信。因此,我们希望能够将服务相互隔离,只在那些必须互相通信的服务之间,才将它们放置在同一个网络沙箱中。所有其他服务和所有来自集群外部的网络流量都不应有机会访问这些沙箱服务。

这种基于网络的沙箱隔离可以通过至少两种方式实现。我们可以使用软件定义网络SDN)将应用服务进行分组,或者可以使用一个扁平网络,结合网络策略控制谁有权限访问特定的服务或服务组。

基于角色的访问控制(RBAC)

在使集群适合企业使用之前,协调器必须完成的一项最重要任务(仅次于安全性)是提供基于角色的访问控制。RBAC 定义了系统的主体、用户或用户组(如团队等)如何访问和操作系统资源。它确保未经授权的人员无法对系统造成任何损害,也无法看到他们不应该知道或看到的系统资源。

一个典型的企业可能会有一些用户组,如开发组(Development)、质量保障组(QA)和生产组(Prod),每个组可以有一个或多个与之关联的用户。开发者约翰·多伊(John Doe)是开发组的成员,因此他可以访问专门为开发团队提供的资源,但他无法访问生产团队的资源,例如安·哈伯(Ann Harbor)是生产组的成员。反过来,她也不能干扰开发团队的资源。

实现基于角色的访问控制(RBAC)的一种方法是通过授权的定义。授权是主题、角色和资源集合之间的关联。在这里,角色由一组对资源的访问权限组成。这些权限可以是创建、停止、删除、列出或查看容器;部署新应用服务;列出集群节点或查看集群节点的详细信息等。

资源集合是集群中一组逻辑相关的资源,如应用服务、机密信息、数据卷或容器。

机密

在我们的日常生活中,有很多机密信息。机密信息是指那些不应该公开的内容,例如你用来访问网上银行账户的用户名和密码,或是你手机的密码,或者是健身房储物柜的密码。

在编写软件时,我们常常需要使用机密信息。例如,我们需要一个证书来验证我们的应用服务与我们想要访问的外部服务,或者我们需要一个令牌来验证和授权我们的服务在访问其他 API 时的权限。在过去,开发者为了方便,通常会将这些值硬编码,或者将它们以明文形式放在某些外部配置文件中。这样,这些非常敏感的信息就被广泛的群体所访问,而实际上,这些人不应该有机会看到这些机密。

幸运的是,现在的调度器提供了所谓的机密信息管理功能,以一种高度安全的方式处理这些敏感信息。机密可以由授权或可信的人员创建。之后,这些机密的值会被加密并存储在高度可用的集群状态数据库中。由于机密信息已经被加密,因此在静止状态下是安全的。一旦授权的应用服务请求机密信息,该机密仅会被转发到实际运行该服务实例的集群节点,而该机密的值从不存储在节点上,而是以tmpfs基于内存的卷挂载到容器中。只有在各自的容器内部,机密的值才以明文形式可用。

我们之前提到过,秘密在静态时是安全的。一旦服务请求了这些秘密,集群管理器或主节点会解密秘密,并通过网络将其发送到目标节点。那么,秘密在传输过程中安全吗? 好吧,我们之前了解到,集群节点在通信时使用 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 设计,后来捐赠给了Cloud Native Computing FoundationCNCF)。Kubernetes 的模型来源于 Google 的专有 Borg 系统,Borg 多年来一直在超大规模运行容器。Kubernetes 是 Google 重新审视并完全重新设计系统的尝试,目的是结合 Borg 的所有经验教训。

与专有技术 Borg 不同,Kubernetes 早期就开源了。这是 Google 非常明智的选择,因为它吸引了大量来自公司外部的贡献者,仅仅几年时间,围绕 Kubernetes 已经发展出一个更加庞大的生态系统。你可以毫不夸张地说,Kubernetes 是容器编排领域社区的宠儿。没有其他编排工具能够像它一样,激起如此大的关注,并吸引这么多愿意以有意义的方式贡献成功的项目成员或早期采用者。

在这方面,我觉得 Kubernetes 在容器编排领域,和 Linux 在服务器操作系统领域的地位非常相似。Linux 已经成为了服务器操作系统的事实标准。所有相关公司,如微软、IBM、亚马逊、红帽,甚至 Docker,都已经拥抱了 Kubernetes。

有一件事是不可否认的:Kubernetes 从一开始就被设计为具有大规模可扩展性。毕竟,它是以 Google 的 Borg 为基础设计的。

你可能对 Kubernetes 提出的一个负面意见是,至少在写这篇文章时,它的设置和管理仍然复杂。对于新手来说,存在一个显著的障碍。第一步陡峭,但一旦你与这个编排工具工作了一段时间,一切都会变得清晰。整体设计经过深思熟虑,并且执行得非常好。

在 Kubernetes 的 1.10 版本中,正式发布版GA)于 2018 年 3 月发布,相较于 Docker Swarm 等其他编排工具,最初的一些不足之处已经被消除。例如,安全性和保密性现在不仅是事后的考虑,而是系统的一个不可或缺的部分。

新功能的实现速度极快。新的版本大约每三个月发布一次,准确来说,大约每 100 天发布一次。大部分新功能都是根据需求驱动的,也就是说,使用 Kubernetes 编排其关键任务应用程序的公司可以提出他们的需求。这使得 Kubernetes 变得适合企业级应用。认为这个编排工具只适用于初创企业,而不适用于风险规避的企业是错误的。恰恰相反,我为什么这么说? 其实,我的说法是有根据的,因为像微软、Docker 和 Red Hat 这样的公司,它们的客户大多是大型企业,已经完全拥抱了 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,实际上就像在集群的第一个节点上执行docker swarm init,该节点将成为所谓的领导节点,然后在所有其他节点上执行docker swarm join <join-token>join-token是在初始化过程中由领导节点生成的。整个过程在最多 10 个节点的 Swarm 上不到 5 分钟就能完成。如果是自动化的,时间会更少。

正如我之前提到的,安全性是 Docker 设计和开发 SwarmKit 时的首要考虑事项。容器通过依赖 Linux 内核命名空间和 cgroups,以及 Linux 系统调用白名单(seccomp),并支持 Linux 能力和Linux 安全模块LSM)来提供安全性。除此之外,SwarmKit 增加了 MTLS 和在静态存储和传输过程中都加密的秘密管理功能。此外,Swarm 还定义了所谓的容器网络模型CNM),该模型支持软件定义网络(SDN),为在 Swarm 上运行的应用服务提供沙箱隔离功能。

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 可能是一个合适的选择。需要指出的是 ECS 的一个非常重要的限制:如果你选择了这个容器编排工具,那么你就将自己锁定在 AWS 环境中。你将无法轻松地将运行在 ECS 上的应用程序迁移到其他平台或云环境。

亚马逊将其 ECS 服务宣传为一个高度可扩展、快速的容器管理服务,使得在集群上运行、停止和管理 Docker 容器变得非常简单。除了运行容器,ECS 还可以直接访问许多 AWS 的其他服务,这些服务在容器内部的应用服务中运行。这种与众多流行 AWS 服务的紧密和无缝集成,使得 ECS 对于那些希望以简便的方式在一个强大且高度可扩展的环境中运行容器化应用的用户非常有吸引力。亚马逊还提供了自己的私有镜像注册中心。

使用 AWS ECS,你可以利用 Fargate 完全管理底层基础设施,从而让你可以专注于部署容器化应用程序,而不需要担心如何创建和管理节点集群。ECS 支持 Linux 和 Windows 容器。

总结来说,ECS 易于使用、具有高度可扩展性,并且与其他流行的 AWS 服务集成良好;但它不如 Kubernetes 或 Docker SwarmKit 那样强大,并且仅可在亚马逊 AWS 上使用。

微软 ACS

类似于我们对 ECS 的评价,我们也可以对微软的 ACS 做出相同的评价。它是一个简单的容器编排服务,如果你已经深度融入 Azure 生态系统,那么它是有意义的。我应该说出和我对 Amazon ECS 所指出的相同的内容:如果你选择了 ACS,你就将自己锁定在微软的服务中。从 ACS 将你的容器化应用程序迁移到任何其他平台或云环境将会非常困难。

ACS 是微软的容器服务,支持多个编排工具,如 Kubernetes、Docker Swarm 和 Mesos DC/OS。随着 Kubernetes 越来越受欢迎,微软的重点显然已经转向这个编排工具。微软甚至重新命名了它的服务,并称之为 Azure Kubernetes ServiceAKS),以便将焦点放在 Kubernetes 上。

AKS 在 Azure 上为你管理托管的 Kubernetes、Docker Swarm 或 DC/OS 环境,这样你可以专注于你想要部署的应用程序,而无需关注基础设施的配置。微软用自己的话说,声明如下:

“AKS 使得快速且轻松地部署和管理容器化应用程序变得更加简单,即使没有容器编排的专业知识。它还通过按需提供、升级和扩展资源,消除了持续运维和维护的负担,而无需将你的应用程序下线。”

总结

本章展示了为什么首先需要编排器,以及它们的概念性工作原理。指出了在撰写时最为突出的编排器,并讨论了不同编排器之间的主要共同点和差异。

下一章将介绍 Docker 的原生编排器 SwarmKit。它将详细阐述 SwarmKit 使用的所有概念和对象,这些概念和对象用于在集群中(无论是本地还是云端)部署和运行一个分布式、弹性、稳健且高可用的应用程序。

问题

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

  1. 我们为什么需要一个编排器?提供两到三个理由。

  2. 列举三到四个编排器的典型职责。

  3. 至少列举两个容器编排器,以及它们背后的主要赞助商。

深入阅读

以下链接提供了一些关于编排相关主题的更深入见解:

第十四章:Docker Swarm 介绍

在上一章中,我们介绍了编排工具。就像乐队指挥一样,编排工具确保我们的容器化应用服务能够和谐地协同工作,并共同为一个共同的目标作出贡献。这些编排工具承担着许多责任,我们已进行了详细讨论。最后,我们简要概述了市场上最重要的容器编排工具。

本章介绍了 Docker 的原生编排工具 SwarmKit。它详细阐述了 SwarmKit 用于在本地或云中集群中部署和运行分布式、弹性、强健且高度可用的应用程序的所有概念和对象。本章还介绍了 SwarmKit 如何通过使用 软件定义网络 (SDN) 来隔离容器,从而确保应用程序的安全性。此外,本章演示了如何在云中安装一个高可用的 Docker Swarm。它介绍了路由网格,该网格提供第 4 层路由和负载均衡。最后,它演示了如何将由多个服务组成的第一个应用程序部署到 Swarm 上。

本章我们将讨论以下主题:

  • Docker Swarm 架构

  • Swarm 节点

  • 堆栈、服务和任务

  • 多主机网络

  • 创建一个 Docker Swarm

  • 部署第一个应用程序

  • Swarm 路由网格

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

  • 在白板上勾画出高可用 Docker Swarm 的关键部分

  • 用两三句话向感兴趣的外行解释什么是(Swarm)服务

  • 在 AWS、Azure 或 GCP 中创建一个高可用的 Docker Swarm,包括三个管理节点和两个工作节点

  • 成功地在 Docker Swarm 上部署一个复制服务,例如 Nginx

  • 扩展和缩减运行中的 Docker Swarm 服务

  • 获取复制的 Docker Swarm 服务的聚合日志

  • 为至少包含两个交互服务的示例应用程序编写一个简单的堆栈文件

  • 将一个堆栈部署到 Docker Swarm 中

Docker Swarm 架构

从 30,000 英尺的高度看,Docker Swarm 的架构由两个主要部分组成——一个由奇数个管理节点组成的 Raft 共识组,以及一个通过 Gossip 网络相互通信的工作节点组,也叫做控制平面。下图展示了这一架构:

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 的状态都存储在每个管理节点上的高性能键值存储(kv-store)中。没错,每个管理节点都存储整个 Swarm 状态的完整副本。这种冗余使得 Swarm 具有高度可用性。如果一个管理节点宕机,剩余的管理节点都能随时访问完整的状态。

如果一个新的管理节点加入共识组,那么它会将 Swarm 状态与现有的组成员同步,直到它拥有完整的副本。在典型的 Swarm 中,这种复制通常非常快速,但如果 Swarm 很大,并且上面运行着许多应用程序,这可能会需要一些时间。

Swarm 工作节点

正如我们之前提到的,Swarm 工作节点是用来托管和运行包含实际应用服务的容器的,这些服务是我们希望在集群中运行的。它们是 Swarm 的主力军。从理论上讲,一个管理节点也可以是工作节点。但是,正如我们已经说过的那样,这在生产系统中并不推荐。在生产系统中,我们应该让管理节点保持管理节点的角色。

工作节点通过所谓的控制平面进行通信。它们使用 gossip 协议进行通信,这种通信是异步的,这意味着在任何给定的时刻,不一定所有工作节点都能完美同步。

现在,您可能会问——工作节点交换哪些信息? 这些信息大多数是用于服务发现和路由的信息,也就是说,关于哪些容器在节点上运行等信息:

工作节点之间的通信

在上面的图中,您可以看到工作节点是如何相互通信的。为了确保在大规模 Swarm 中,gossip 协议能够良好扩展,每个工作节点只与三个随机邻居同步其自身状态。对于熟悉大 O 表示法的人来说,这意味着使用 gossip 协议同步工作节点的过程具有 O(0) 级别的扩展性。

工作节点可以说是被动的。它们除了运行由管理节点分配的工作负载外,几乎不会主动执行其他操作。尽管如此,工作节点会确保以最佳能力运行这些工作负载。在本章后续部分,我们将进一步了解管理节点分配给工作节点的具体工作负载。

堆栈、服务和任务

使用 Docker Swarm 相较于单一 Docker 主机时,会有范式上的变化。我们不再讨论运行进程的单个容器,而是抽象成服务,代表每个进程的副本集,通过这种方式,服务可以实现高可用性。我们也不再讨论带有固定名称和 IP 地址的单个 Docker 主机了;现在我们谈论的是将服务部署到的主机集群。我们不再关心单一主机或节点,不再给它赋予有意义的名称;每个节点对我们来说只是一个数字。我们也不再关心单个容器以及它们部署的位置,我们只关心通过服务定义的期望状态。我们可以通过以下图示来描述这一点:

容器部署到知名服务器

与前面图示中将web容器部署到 IP 地址为52.120.12.1alpha服务器,将payments容器部署到 IP 为52.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 文件内容连接起来:

显示 Stack、服务和任务之间关系的图表

在前面的图表中,右侧展示了一个示例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 地址,并通过物理网络(网络电缆)连接在一起,这个网络被称为underlay network

在左侧节点上,我们有一个容器,IP 地址为10.3.0.2,在右侧节点上,则有另一个容器,IP 地址为10.3.0.5。现在,前一个容器想要与后一个容器通信。这怎么实现呢? 在第十章,单主机网络中,我们已经看到了当两个容器位于同一节点时,如何通过 Linux 桥接实现通信。但 Linux 桥接仅限于本地操作,不能跨节点工作。所以,我们需要另一个机制,Linux VXLAN 来解决这个问题。VXLAN 在 Linux 中早在容器出现之前就已经可用了。

当左侧容器发送数据包时,bridge会意识到数据包的目标不在此主机上。现在,每个参与覆盖网络的节点都获得一个所谓的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 初始化命令的输出

现在我们的计算机是一个 Swarm 节点。它的角色是管理者,并且是领导者(在管理者中是领导者,这很合理,因为目前只有一个管理者)。虽然执行docker swarm init只用了非常短的时间,但这个命令在这段时间内做了很多事情。以下是其中的一些:

  • 它创建了一个根证书授权机构CA)。

  • 它创建了一个键值存储,用于存储整个 Swarm 的状态。

现在,在前面的输出中,我们可以看到一个可以用来将其他节点加入我们刚创建的 Swarm 的命令。该命令如下:

$ docker swarm join --token <join-token> <IP address>:2377

在这里,我们有以下内容:

  • <join-token> 是 Swarm 领导者在初始化 Swarm 时生成的令牌。

  • <IP 地址> 是领导者的 IP 地址。

尽管我们的集群保持简单,因为它只有一个成员,但我们仍然可以要求 Docker CLI 列出 Swarm 中的所有节点。这看起来类似于以下截图:

列出 Docker Swarm 中的节点

在这个输出中,我们首先看到分配给节点的ID。紧随其后的星号(*)表示这是执行docker node ls命令的节点——基本上说明这是活动节点。接下来,我们可以看到节点的(人类可读的)名称、状态、可用性和管理状态。如前所述,Swarm 的第一个节点自动成为领导者,在前面的截图中有标示。最后,我们看到正在使用的 Docker 引擎版本。

若要获取更多关于节点的信息,我们可以使用docker node inspect命令,如下图所示:

docker node inspect 命令的截断输出

该命令生成了大量信息,因此我们只展示了截断后的输出。例如,当你需要排查故障节点时,这些输出信息可能会很有用。

在 VirtualBox 或 Hyper-V 中创建本地 Swarm

有时候,一个单节点的 Swarm 是不够的,但我们没有或不想使用账户在云中创建 Swarm。在这种情况下,我们可以在 VirtualBox 或 Hyper-V 中创建本地 Swarm。在 VirtualBox 中创建 Swarm 稍微简单一些,但如果你使用的是 Windows 10 并且运行 Docker for Windows,那么你不能同时使用 VirtualBox。这两个虚拟机管理程序是互斥的。

假设我们在笔记本电脑上安装了 VirtualBox 和docker-machine。然后,我们可以使用docker-machine列出所有当前已定义且可能在 VirtualBox 中运行的 Docker 主机:

$ docker-machine ls
NAME ACTIVE DRIVER STATE URL SWARM DOCKER ERRORS
default - virtualbox Stopped Unknown

在我的情况下,我已经定义了一个名为default的虚拟机,它当前处于停止状态。我可以通过执行docker-machine start default命令轻松启动虚拟机。这个命令需要一些时间,并将产生以下(简化)输出:

$ 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的文档。默认情况下,新虚拟机将分配 1 GB 的内存,这足够将此虚拟机用作开发或测试 Swarm 的节点。

如果你使用的是 Windows 10 并且安装了 Docker Desktop,可以改用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 了。从技术上讲,我们可以 SSH 进入第一个虚拟机node-1并初始化 Swarm,然后再 SSH 进入所有其他虚拟机,将它们加入 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 登录。成功登录后,你将看到如下截图所示的界面:

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

通过点击+ ADD NEW INSTANCE链接四次,在 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 Swarm 都非常适合用于开发、实验或演示。如果我们想要创建一个可以作为生产环境使用的 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 实例。前面命令的输出如下图所示:

在 AWS 上使用 DM 创建一个 Swarm 节点

它看起来与我们在使用 VirtualBox 时已经熟悉的输出非常相似。现在,我们可以配置我们的终端以便远程访问这个 EC2 实例:

$ eval $(docker-machine env aws-node-1)

这将根据 Docker CLI 配置相应的环境变量:

Docker 用于启用对 AWS EC2 节点远程访问的环境变量

出于安全原因,传输层安全性TLS)用于我们的 CLI 和远程节点之间的通信。为此所需的证书已由 DM 复制到我们为环境变量 DOCKER_CERT_PATH 指定的路径中。

我们现在在终端中执行的所有 Docker 命令,将会在 AWS 上的 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 端口给公众访问。通常,只有 80443 端口用于 HTTP 和 HTTPS 访问,其他的端口都是潜在的黑客入侵点。

你可以通过像 www.whatismyip.com/ 这样的服务获取你自己的 IP 地址。现在,如果我们再次执行 curl 命令,将会返回 Nginx 的欢迎页面。

在离开 SG 之前,我们应该向其添加另一条规则。Swarm 节点需要能够通过 TCP 和 UDP 在端口 79464789 上自由通信,并通过 TCP 在端口 2377 上通信。我们现在可以添加五条符合这些要求的规则,其中源是 SG 本身,或者我们只定义一条粗略的规则,允许 SG 内部的所有入站流量(在我的情况下是 sg-c14f4db3):

启用 Swarm 内部通信的 SG 规则

现在,让我们继续创建其余的四个节点。我们可以再次使用脚本来简化这个过程:

$ 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

实现相同目标的另一种方式是,无需 SSH 进入单个节点,而是每次访问不同的节点时重新配置我们的客户端 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 Swarm。创建之后,Swarm 在任何平台上表现相同。我们在 Swarm 上部署和更新应用的方式不依赖于平台。避免在使用 Swarm 时被厂商锁定一直是 Docker 的主要目标之一。准备好 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 格式不同,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上所有运行的容器会得到什么:

node-1 上的容器列表

正如预期的那样,我们找到了一个来自training/whoami:latest镜像的容器,容器的名称是其父任务名称和 ID 的组合。我们可以尝试可视化在部署示例堆栈时生成的所有对象的层级结构:

Docker Swarm 堆栈的对象层级结构

堆栈可以由一个或多个服务组成。每个服务都有一组任务。每个任务与一个容器一一对应。堆栈和服务在 Swarm 管理节点上创建并存储。然后,任务会调度到 Swarm 工作节点,工作节点在其上创建相应的容器。我们还可以通过检查服务获取更多信息。执行以下命令:

$ docker service inspect sample-stack_whoami

这提供了关于服务所有相关设置的丰富信息。这包括我们在stack.yaml文件中明确定义的设置,也包括那些我们未指定的,因此被分配了默认值的设置。我们不会在这里列出完整的输出,因为它太长,但我鼓励读者在自己的机器上查看。我们将在The swarm routing mesh部分更详细地讨论其中的一部分信息。

服务日志

在前面的章节中,我们处理了容器生成的日志。在这里,我们专注于服务。请记住,最终,一个具有多个副本的服务有多个容器在运行。因此,如果我们要求服务返回日志,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 在输出中,显示为Delivered to you by container 8b906b509a7e。如果多次运行curl命令,ID 应在三个不同的值之间循环。这些是我们请求web服务的三个容器(或副本)的 ID。

完成后,我们可以使用docker stack rm pets删除堆栈。

Swarm 路由网格

如果你一直在关注,那么你可能会注意到上一部分有一个有趣的现象。我们部署了pets应用程序,结果在node-1node-2node-3三个节点上安装了web服务的实例。然而,我们能够通过localhost访问node-1上的web服务,并且从那里访问每个容器。这怎么可能? 其实这是由于所谓的 Swarm 路由网格。路由网格确保当我们发布一个服务的端口时,这个端口会在 Swarm 的所有节点上发布。因此,任何节点上的网络流量,只要请求使用特定的端口,都会通过路由网格转发到服务的某个容器上。我们来看一下下面的图,看看它是如何工作的:

Docker Swarm 路由网格

在这种情况下,我们有三个节点,分别称为Host AHost BHost C,它们的 IP 地址分别是172.10.0.15172.10.0.17172.10.0.33。在图的左下角,我们看到创建web服务的命令,服务包含两个副本。相应的任务已被调度到Host BHost C上。任务 1 被分配到Host B,而任务 2 被分配到Host C

当在 Docker Swarm 上创建一个服务时,它会自动分配一个虚拟 IPVIP)地址。这个 IP 地址在服务的整个生命周期内都是稳定且保留的。假设在我们的案例中,VIP 为10.2.0.1

如果现在来自外部负载均衡器LB)的请求针对我们的 Swarm 节点的端口8080,那么该请求将由该节点上的 LinuxIP 虚拟服务器IPVS)服务处理。该服务会在 IP 表中查找端口8080,并找到它对应的是web服务的 VIP。现在,由于 VIP 并不是实际的目标,IPVS 服务将进行负载均衡,将请求转发到与该服务关联的任务的 IP 地址。在我们的案例中,它选择了任务 2,对应的 IP 地址是10.2.0.3。最后,入口网络(Overlay)被用来将请求转发到Host C上的目标容器。

需要注意的是,外部请求被外部负载均衡器External 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 <join token>

  2. 如果您想从 Docker Swarm 中移除一个工作节点,需要哪些步骤?

  3. 如何创建一个名为 front-tier 的覆盖网络?使该网络可连接。

  4. 如何使用 nginx:alpine 镜像创建一个名为 web 的服务,包含五个副本,在入口网络上暴露端口 3000,并连接到 front-tier 网络?

  5. 如何将网页服务缩放至三个实例?

深入阅读

请查阅以下链接,获取有关选定主题的更深入信息:

第十五章:零停机部署与密钥

在上一章中,我们详细探讨了 Docker Swarm 及其资源。我们学习了如何在本地和云中构建一个高可用的 Swarm 集群。接着,我们深入讨论了 Swarm 服务和堆栈。最后,我们在 Swarm 中创建了服务和堆栈。

本章中,我们将展示如何在不中断服务可用性的情况下,更新运行在 Docker Swarm 中的服务和堆栈。这被称为零停机部署。我们还将介绍 Swarm 密钥,作为一种安全地将敏感信息提供给使用这些密钥的服务容器的方法。

本章将涵盖以下主题:

  • 零停机部署

  • 在 Swarm 中存储配置数据

  • 使用 Docker 密钥保护敏感数据

完成本章后,你将能够完成以下任务:

  • 列出两到三种常用的部署策略,用于在不中断服务的情况下更新服务。

  • 以批次更新服务而不导致服务中断。

  • 定义用于服务回滚的策略,以防更新失败。

  • 使用 Docker 配置存储非敏感的配置数据。

  • 使用 Docker 密钥与服务配合。

  • 更新密钥的值而不导致停机。

技术要求

本章的代码文件可以在 GitHub 上找到,网址为github.com/PacktPublishing/Learn-Docker---Fundamentals-of-Docker-19.x-Second-Edition。如果你已经按照第二章中提到的步骤克隆了仓库,设置工作环境,那么你将会在~/fod-solution/ch14找到代码。

零停机部署

一个需要频繁更新的关键任务应用程序最重要的一个方面是能够以零停机的方式进行更新。我们称之为零停机部署。在整个过程中,更新的应用程序必须始终保持完全可用。

常见的部署策略

有多种方法可以实现这一点,以下是其中一些方法:

  • 滚动更新

  • 蓝绿部署

  • 金丝雀发布

Docker Swarm 开箱即支持滚动更新。其他两种类型的部署需要我们付出额外的努力来实现。

滚动更新

在一个关键任务的应用中,每个应用服务都必须以多个副本运行。根据负载,副本数量可能从两三个实例少到几十个、上百个或成千上万个实例。任何时候,我们都希望确保所有服务实例中有明显的多数在运行。因此,如果我们有三个副本,我们希望至少有两个副本始终在运行。如果有 100 个副本,我们也能接受至少有 90 个副本在可用状态。通过这样做,我们可以定义一个副本的批量大小,用来进行升级时的停机。在第一个例子中,批量大小是 1,而在第二个例子中,批量大小是 10。

当我们停机副本时,Docker Swarm 会自动将这些实例从负载均衡池中移除,所有流量将会重新在剩下的活跃实例中进行负载均衡。因此,这些剩余的实例将会经历流量的轻微增加。在下图中,在滚动更新开始之前,如果任务 A3想要访问服务 B,它可能会被 SwarmKit 负载均衡到服务 B的任意三个任务中的一个。一旦滚动更新开始,SwarmKit 会停止任务 B1进行更新。此时,这个任务会自动从目标池中移除。所以,如果任务 A3现在请求连接服务 B,负载均衡只会从剩余的任务中选择,也就是B2B3。因此,这两个任务可能会暂时经历更高的负载:

任务 B1 被停机更新

被停止的实例将被等量的新实例替换,这些新实例是应用服务的新版本。一旦新实例启动并运行,我们可以让 Swarm 监控它们一段时间,并确保它们健康。如果一切正常,我们就可以继续进行,停掉下一批实例,并用新版本的实例替换它们。这个过程会重复,直到所有应用服务的实例都被替换。

在下图中,我们可以看到服务 B任务 B1已更新至版本 2。任务 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. 首先,我们需要确保终端窗口已配置好,可以访问我们集群中的一个主节点。我们以 leader 节点 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 提供了一个接口,我们可以在其中为其提供一些帮助。我们,作为在 Swarm 中容器内部运行的应用服务的作者,最清楚我们的服务是否处于健康状态。SwarmKit 让我们有机会定义一个命令,该命令会针对我们的应用服务执行健康检查。这个命令具体做什么对 Swarm 并不重要;它只需要返回OKNOT OKtime out。后两种情况,即NOT OKtimeout,会告诉 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关键字定义了实际的探测命令。在我们的例子中,我们定义了一个向localhost/health端点发送请求的探测命令,端口为3000。这个调用预期会有三种可能的结果:

    • 命令成功执行。

    • 命令执行失败。

    • 命令超时。

后两者被 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,已经识别出该服务定义了健康检查功能,并正在使用它来确定服务中每个任务的健康状态。

回滚

有时,事情并不如预期那样发展。应用程序发布中的临时修复可能无意中引入了一个新漏洞,或者新版本显著降低了组件的吞吐量,等等。在这种情况下,我们需要有备选计划,通常意味着能够回滚更新到先前的良好版本。

与更新类似,回滚必须以不会导致应用程序中断的方式进行;它需要实现零停机时间。从这个意义上来说,回滚可以看作是一次反向更新。我们安装了一个新版本,但这个新版本实际上是上一个版本。

与更新操作类似,我们可以在堆栈文件或 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

在这个堆栈文件中,我们定义了滚动更新、健康检查和回滚时的行为,文件在我们的实验室中以 stack-rollback.yaml 呈现。健康检查被定义为,在初始等待时间 2 秒后,协调器开始每 2 秒轮询一次服务(在 http://localhost 上),并在考虑任务为不健康之前,重试 3 次。

如果我们做一下计算,假设任务由于 bug 导致不健康,那么至少需要 8 秒钟才能停止任务。因此,现在在部署下,我们新增了一个名为 monitor 的条目。该条目定义了新部署的任务应该监控多久的健康状态,以及是否继续进行滚动更新中的下一批。在这个示例中,我们设置了 10 秒。这个时间比我们计算出来的发现服务故障的 8 秒稍长,因此是合适的。

我们还新增了一个条目,failure_action,用于定义在滚动更新过程中如果遇到失败(例如服务不健康)时,协调器将采取什么措施。默认情况下,行动是停止整个更新过程,并将系统保持在中间状态。由于这是滚动更新,系统并没有完全宕机,至少一些健康的服务实例仍然在运行,但操作工程师会更擅长检查并修复问题。

在我们的案例中,我们已经定义了回滚操作。因此,在失败的情况下,SwarmKit 会自动将所有已更新的任务回滚到其先前的版本。

蓝绿部署

在第九章《分布式应用架构》中,我们以抽象的方式讨论了蓝绿部署是什么。事实证明,在 Docker Swarm 中,我们无法对任意服务实现蓝绿部署。服务发现和负载均衡在 Docker Swarm 中由 Swarm 路由网格处理,不能(轻易地)进行自定义。

如果服务 A想调用服务 B,那么 Docker 会隐式处理这个过程。Docker 在给定目标服务的名称后,会使用 Docker DNS 服务将该名称解析为虚拟 IPVIP)地址。当请求指向VIP时,Linux IPVS 服务会在 Linux 内核的 IP 表中根据VIP进行查找,并将请求负载均衡到服务代表的任务之一的物理 IP 地址,如下图所示:

Docker Swarm 中的服务发现和负载均衡工作原理

不幸的是,目前没有简单的方法来拦截这一机制并用自定义行为替代。但这对于实现我们示例中的目标服务Service B的真正蓝绿部署是必要的。正如我们将在第十六章《使用 Kubernetes 部署、更新和保护应用程序》中看到的那样,Kubernetes 在这一领域更具灵活性。

也就是说,我们始终可以以蓝绿方式部署面向公众的服务。我们可以使用 Interlock 2 及其第七层路由机制来实现真正的蓝绿部署。

金丝雀发布

从技术上讲,滚动更新是一种金丝雀发布。但由于缺少可以插入自定义逻辑的“缝隙”,滚动更新只是金丝雀发布的一个非常有限的版本。

真正的金丝雀发布要求我们对更新过程进行更细粒度的控制。此外,真正的金丝雀发布在所有流量都已完全切换到新版本之前,不会停止旧版本的服务。从这个角度来看,它们与蓝绿部署类似。

在金丝雀发布场景中,我们不仅仅希望使用健康检查等指标来决定是否将越来越多的流量引导到新版本的服务中;我们还希望在决策过程中考虑外部输入,例如由日志聚合器收集并汇总的度量数据或追踪信息。一个可以作为决策依据的示例是遵循服务水平协议SLA),即如果新版本的服务响应时间超出了容忍范围。这可能发生在我们为现有服务添加新功能时,而这个新功能导致了响应时间的下降。

在 Swarm 中存储配置数据

如果我们想要在 Docker Swarm 中存储非敏感数据,如配置文件,我们可以使用 Docker 配置。Docker 配置与我们将在下一节中讨论的 Docker 密钥非常相似。主要的区别在于,配置值在静态存储时没有加密,而密钥则是加密的。Docker 配置只能在 Docker Swarm 中使用,也就是说,它们不能在非 Swarm 开发环境中使用。Docker 配置会直接挂载到容器的文件系统中。配置值可以是字符串或最大为 500 KB 的二进制值。

通过使用 Docker 配置,您可以将配置与 Docker 镜像和容器分离。这样,您的服务可以轻松地根据环境特定的值进行配置。生产 Swarm 环境的配置值与暂存 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 推荐的将配置数据传递给应用程序的方式是使用所谓的 properties 文件。properties 文件只是一个包含键值对列表的文本文件。让我们看一下:

  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 配置也可以从 swarm 中删除,但前提是它们没有被使用。如果我们在没有先停止并删除服务的情况下尝试删除我们刚才使用的配置,将会得到以下输出:

$ 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 秘密保护敏感数据

秘密用于以安全的方式处理机密数据。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 的文件中读取的。一旦创建了一个秘密,就无法访问它的值。例如,我们可以列出所有秘密以获取以下输出:

所有机密的列表

在这个列表中,我们只能看到秘密的 IDNAME,以及其他一些元数据,但秘密的实际值是不可见的。我们还可以对一个秘密使用 inspect 命令,例如,获取有关 other-secret 的更多信息:

检查 Swarm 秘密

即便如此,我们也无法获取到密钥的值。这当然是有意为之:密钥就是密钥,需要保持机密。如果需要,我们可以为密钥分配标签,并且如果 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

然后,我列出该节点上的所有容器,找到属于我的服务的那个实例,并复制它的container 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

在这个命令中,我们使用扩展语法来定义一个包含目标文件夹的密钥。

在开发环境中模拟密钥

在开发时,我们通常在本地机器上没有一个集群。但密钥只在集群中有效。那么,我们该怎么办?幸运的是,答案非常简单。由于密钥被当作文件处理,我们可以轻松地将包含密钥的卷挂载到容器中预期的位置,默认位置是/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. 为了测试这一点,我们可以在前面的容器中执行一个 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文件夹中的 YAML 配置文件中的一个条目,该文件名为app.config,其相关部分如下所示:

...

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镜像定义的容器中运行的遗留应用程序,该应用程序期望一个名为db_password的机密定义在应用程序文件夹中的whoami.conf文件中。让我们来看一下这些步骤:

  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 secret 的两到三种特性。

  5. 你需要推出一个新版本的inventory服务。你的命令应该是什么样的?以下是一些额外信息:

    • 新的镜像名为acme/inventory:2.1

    • 我们希望使用滚动更新策略,每批任务的大小为两个任务。

    • 我们希望系统在每批任务执行完后等待一分钟。

  6. 你需要通过 Docker secret 更新一个名为inventory的现有服务,新的密码通过一个名为MYSQL_PASSWORD_V2的 Docker secret 提供。服务中的代码期望这个 secret 名为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 集群由一组服务器组成。这些服务器可以是虚拟机(VM)或物理服务器,后者也称为裸金属。集群的每个成员都有两个角色之一。它要么是 Kubernetes 主节点,要么是(工作)节点。前者用于管理集群,而后者将运行应用程序工作负载。我把工作节点放在括号中,因为在 Kubernetes 术语中,只有在讨论运行应用程序工作负载的服务器时,才会提到节点。但在 Docker 术语和 Swarm 中,相应的概念是工作节点。我认为“工作节点”的概念比简单的节点更能描述服务器的角色。

在集群中,你会有一个小而奇数的 master 节点,以及根据需要数量的 worker 节点。小型集群可能只有几个 worker 节点,而更现实的集群可能有几十个甚至上百个 worker 节点。从技术上讲,集群中 worker 节点的数量没有限制;但实际上,当处理成千上万个节点时,某些管理操作可能会显著变慢。集群中的所有成员需要通过一个物理网络连接,这就是所谓的底层网络

Kubernetes 为整个集群定义了一个扁平网络。Kubernetes 本身并不提供任何内建的网络实现,而是依赖于第三方的插件。Kubernetes 只是定义了容器网络接口CNI),并将实现留给其他人。CNI 非常简单,基本上规定了集群中运行的每个 pod 必须能够访问集群中运行的任何其他 pod,且中间不能发生网络地址转换NAT)。集群节点与 pod 之间也必须满足相同的要求,即直接在集群节点上运行的应用或守护进程必须能够访问集群中的每个 pod,反之亦然。

以下图示展示了 Kubernetes 集群的高级架构:

Kubernetes 的高级架构图

上面的图解说明如下:

  • 在顶部中间,我们有一个etcd节点集群。etcd是一个分布式的键值存储,在 Kubernetes 集群中,用于存储集群的所有状态。etcd节点的数量必须是奇数,这是 Raft 共识协议的要求,该协议规定哪些节点用于相互协调。当我们谈论集群状态时,并不包括在集群中运行的应用所生产或消耗的数据;而是指集群拓扑结构、运行的服务、网络设置、使用的密钥等所有信息。也就是说,这个etcd集群对于整个集群至关重要,因此,在生产环境或任何需要高可用性的环境中,我们绝不应该只运行一个etcd服务器。

  • 然后,我们有一个 Kubernetes 节点的集群,它们之间也会形成一个 共识 ,类似于 etcd 节点。主节点的数量也必须是奇数。我们可以用单个主节点运行集群,但在生产环境或关键任务系统中永远不应这样做。此时,我们应该始终至少有三个主节点。由于主节点用于管理整个集群,所以我们也在讨论管理平面。主节点使用 etcd 集群作为其后备存储。将 负载均衡器LB)放置在主节点前面,并使用一个众所周知的 完全合格域名FQDN),例如 https://admin.example.com,是一种良好的实践。所有用于管理 Kubernetes 集群的工具应通过这个 LB 访问,而不是直接使用某个主节点的公共 IP 地址。这在前述图的左上方有所展示。

  • 在图的底部,我们有一个 工作 节点的集群。节点的数量可以低至一个,没有上限。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 也必须配置为高可用模式,这通常意味着我们至少有三个 etcd 实例在不同的节点上运行。

我们再一次强调,整个集群的状态存储在 etcd 中。这包括所有集群节点的信息、所有副本集、部署、机密、网络策略、路由信息等。因此,确保我们有一个稳健的备份策略来保护这个键值存储至关重要。

现在,让我们来看看那些将运行集群实际工作负载的节点。

集群节点

集群节点是 Kubernetes 调度应用工作负载的节点。它们是集群的主力军。一个 Kubernetes 集群可以拥有少量、几十个、几百个甚至几千个集群节点。Kubernetes 从一开始就为了高扩展性而构建。别忘了,Kubernetes 是以 Google Borg 为模型的,后者已经运行了数万个容器多年:

Kubernetes 工作节点

一个工作节点可以运行在虚拟机、裸金属、内部服务器或云环境中。最初,工作节点只能在 Linux 上配置。但是从 Kubernetes 1.10 版本开始,工作节点也可以运行在 Windows Server 上。拥有一个包含 Linux 和 Windows 工作节点的混合集群是完全可以的。

在每个节点上,我们需要运行三个服务,具体如下:

  • Kubelet:这是第一个也是最重要的服务。Kubelet 是主要的节点代理。kubelet 服务使用 Pod 规范确保对应 Pod 的所有容器都在运行且健康。Pod 规范是用 YAML 或 JSON 格式编写的文件,它们声明性地描述一个 Pod。我们将在下一节中了解什么是 Pod。PodSpec 主要通过 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 命令行工具来部署这个 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 相比,尽管 Minikube 也是一个单节点集群,但 Docker 工具提供的版本使用了所有 Kubernetes 组件的容器化版本:

macOS 和 Windows 上的 Docker Kubernetes 支持

上面的图表大致展示了 Kubernetes 支持是如何被添加到 Docker for macOS 和 Windows 中的。Docker for macOS 使用 hyperkit 来运行基于 LinuxKit 的虚拟机。Docker for Windows 使用 Hyper-V 来实现相同的效果。在虚拟机内,安装了 Docker 引擎。引擎的一部分是 SwarmKit,它启用了 Swarm-Mode。Docker for macOS 或 Windows 使用 kubeadm 工具来设置和配置虚拟机中的 Kubernetes。以下三个事实值得一提:Kubernetes 将其集群状态存储在 etcd 中,因此我们在这个虚拟机上运行了 etcd。接着,我们有了组成 Kubernetes 的所有服务,最后还有一些支持将 Docker 堆栈从 Docker CLI 部署到 Kubernetes 的服务。这个服务不是 Kubernetes 官方发行版的一部分,但它是 Docker 特有的。

所有 Kubernetes 组件都在 LinuxKit VM 中的容器中运行。这些容器可以通过 Docker for macOS 或 Windows 中的设置来隐藏。在本节稍后,我们将提供一份完整的 Kubernetes 系统容器列表,如果你启用了 Kubernetes 支持,它们将会在你的笔记本电脑上运行。为了避免重复,从现在开始,我将只提到 Docker for Desktop,而不是 Docker for macOS 或 Docker for Windows。我要说的内容同样适用于这两个版本。

启用 Kubernetes 的 Docker for Desktop 相较于 Minikube 的一个大优点是,前者允许开发者使用单一工具来构建、测试和运行针对 Kubernetes 的容器化应用。甚至可以通过 Docker Compose 文件将多服务应用部署到 Kubernetes 中。

现在,让我们动手操作一下:

  1. 首先,我们需要启用 Kubernetes。在 macOS 上,点击菜单栏中的 Docker 图标;在 Windows 上,进入命令托盘并选择“首选项”。在弹出的对话框中,选择 Kubernetes,如下图所示:

在 Docker for Desktop 中启用 Kubernetes

  1. 然后,勾选“启用 Kubernetes”复选框。同时,勾选“默认将 Docker 堆栈部署到 Kubernetes”以及“显示系统容器(高级)”复选框。接着,点击“应用并重启”按钮。Kubernetes 的安装和配置需要几分钟时间。现在,正是休息一下,享受一杯好茶的时刻。

  2. 安装完成后(Docker 会通过在设置对话框中显示绿色状态图标来通知我们),我们可以进行测试。由于现在我们有两个 Kubernetes 集群在笔记本上运行,即 Minikube 和 Docker for Desktop,因此我们需要配置 kubectl 来访问后者。

首先,让我们列出所有的上下文:

kubectl 的上下文列表

在这里,我们可以看到,在我的笔记本上,我有之前提到的两个上下文。当前,Minikube 上下文仍然处于活动状态,通过 CURRENT 列中的星号标记。我们可以使用以下命令切换到 docker-for-desktop 上下文:

更改 Kubernetes CLI 的上下文

现在,我们可以使用 kubectl 访问 Docker for Desktop 刚刚创建的集群。我们应该看到如下内容:

Docker for Desktop 创建的单节点 Kubernetes 集群

好的,这看起来很熟悉。它与我们在使用 Minikube 时看到的几乎相同。我的 Docker for Desktop 正在使用的 Kubernetes 版本是 1.15.5。我们还可以看到该节点是主节点。

如果我们列出当前在 Docker for 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,并且我们会看到它按照预期运行:

宠物应用在 Kubernetes 上运行,使用 Docker for Desktop

现在,让我们来看一下当我们执行 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 Pod

在上面的图示中,我们有两个 Pod,Pod 1Pod 2。第一个 Pod 包含两个容器,而第二个 Pod 仅包含一个容器。每个 Pod 都会获得 Kubernetes 分配的唯一 IP 地址,这些 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 网络。在下图中,左边是 Docker 容器网络,右边是 Kubernetes Pod 网络:

在 Pod 中的容器共享相同的网络命名空间

当创建 Docker 容器且未指定特定网络时,Docker 引擎会创建一个虚拟以太网veth)端点。第一个容器获得veth0,下一个容器获得veth1,依此类推。这些虚拟以太网端点连接到 Docker 在安装时自动创建的 Linux 桥接docker0。流量从docker0桥接路由到每个连接的veth端点。每个容器都有自己的网络命名空间。没有两个容器使用相同的命名空间。这是故意设置的,用来隔离容器内运行的应用程序。

对于 Kubernetes Pod,情况有所不同。在创建新 Pod 时,Kubernetes 首先创建一个所谓的pause容器,唯一目的是创建和管理 Pod 与所有容器共享的命名空间。除此之外,它没有任何实际作用,只是处于睡眠状态。pause容器通过veth0连接到docker0桥接网络。任何后续的容器都会使用 Docker 引擎的一个特殊功能,允许它重用现有的网络命名空间。实现这个功能的语法如下所示:

$ docker container create --net container:pause ... 

重要部分是--net参数,其值为container:<container name>。如果我们以这种方式创建一个新容器,那么 Docker 不会创建一个新的 veth 端点;容器将使用与pause容器相同的端点。

另一个重要的后果是多个容器共享相同的网络命名空间,它们相互通信的方式。让我们考虑以下情况:一个 Pod 包含两个容器,一个监听80端口,另一个监听3000端口:

Pod 中的容器通过 localhost 进行通信

当两个容器使用相同的 Linux 内核网络命名空间时,它们可以通过 localhost 进行通信,就像在同一主机上运行的两个进程可以通过 localhost 相互通信一样。这个过程在上面的图示中有所展示。从main容器来看,它内部的容器化应用程序可以通过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互相访问。为了展示这一点,我们需要进入main容器执行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 默认的 bridge 网络

接下来,我们将查看 pod 的生命周期。

Pod 生命周期

在本书前面,我们学习了容器有一个生命周期。一个容器会被初始化、运行,最终退出。当容器退出时,它可以以退出码为零的方式优雅地退出,或者它可能会因为错误而终止,这相当于一个非零的退出码。

类似地,pod 也有生命周期。由于一个 pod 可以包含多个容器,因此它的生命周期比单一容器要复杂一些。pod 的生命周期可以通过以下图示来展示:

Kubernetes Pod 的生命周期

当在集群节点上创建一个Pod时,它首先进入待处理状态。一旦 Pod 的所有容器都启动并正常运行,Pod 就会进入运行中状态。只有当所有容器都成功运行时,Pod 才会进入这个状态。如果 Pod 被要求终止,它将请求所有容器终止。如果所有容器都以退出代码零终止,那么 Pod 将进入成功状态。这是理想的情况。

现在,让我们看看一些导致 Pod 处于失败状态的场景。有三种可能的情况:

  • 如果在 Pod 启动期间,至少有一个容器无法运行并失败(即以非零退出代码退出),Pod 将从待处理状态进入失败状态。

  • 如果 Pod 处于运行中状态,且其中一个容器突然崩溃或以非零退出代码退出,那么 Pod 将从运行中状态转变为失败状态。

  • 如果 Pod 被要求终止,并且在关闭期间,至少有一个容器以非零退出代码退出,那么 Pod 也会进入失败状态。

现在,让我们看看 Pod 的规格。

Pod 规格

在 Kubernetes 集群中创建 Pod 时,我们可以使用命令式或声明式的方法。我们在本书早些时候讨论了这两种方法的区别,但为了重述最重要的方面,使用声明式方法意味着我们编写一个描述我们希望实现的最终状态的清单。我们将省略编排器的细节。我们希望实现的最终状态也称为期望状态。通常,在所有已建立的编排器中,声明式方法是强烈推荐的,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 中的每个规范都以版本信息开头。Pod 已经存在了一段时间,因此 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列出集群中的所有 Pods:

$ 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 秘密。另一方面,卷将在下文讨论。

Pods 和卷

在第五章中,数据卷和配置,我们了解了卷及其目的:访问和存储持久数据。由于容器可以挂载卷,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-apiReplicaSet,它管辖着多个 pods。这些 pods 被称为 pod-apiReplicaSet 负责确保在任何时候,都有预定数量的 pod 正在运行。如果其中一个 pod 因为某种原因崩溃,ReplicaSet 会在一个有空闲资源的节点上调度一个新的 pod。如果 pod 的数量超过预期,ReplicaSet 会终止多余的 pods。通过这一点,我们可以说 ReplicaSet 保证了一个自愈和可扩展的 pod 集合。ReplicaSet 可以容纳的 pod 数量没有上限。

ReplicaSet 规格

类似于我们关于 pods 学到的内容,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 行,我们有一个选择器,它决定了哪些 pods 将成为 ReplicaSet 的一部分。在这种情况下,选择的是所有标签为 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,其期望的状态是三个(pod)。当前状态也显示有三个 pod,并告诉我们这三个 pod 都已经准备好。我们还可以列出系统中所有的 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

在这里,我们可以看到我们预期的三个 pod。每个 pod 的名称使用了 ReplicaSet 的名称,并为每个 pod 附加了一个唯一的 ID。在 READY 列中,我们可以看到 pod 中定义了多少个容器,以及其中有多少个容器已经准备好。在我们的例子中,每个 pod 只有一个容器,并且每个容器都已经准备好。因此,pod 的整体状态是 Running。我们还可以看到每个 pod 被重启的次数。在我们的例子中,我们没有任何重启。

自愈

现在,让我们通过随机终止其一个 pod 来测试 自愈 ReplicaSet 的神奇能力,并观察发生了什么。让我们删除上面列表中的第一个 pod:

$ kubectl delete po/rs-web-6qzld
pod "rs-web-6qzld" deleted

现在,让我们再次列出所有 pod。我们期望只看到两个 pod,对吗?错了:

删除 pod 后 ReplicaSet 中的 pod 列表

好的;显然,列表中的第二个 pod 已被重新创建,从 AGE 列中可以看出这一点。这就是自动修复的表现。让我们看看如果描述 ReplicaSet 会发现什么:

描述 ReplicaSet

的确,我们在 Events 中发现一条记录,告诉我们 ReplicaSet 创建了一个新的 pod,名为 rs-web-q6cr7

Kubernetes 部署

Kubernetes 非常重视单一职责原则。所有 Kubernetes 对象都被设计为只做一件事,而且做这件事做得非常好。在这方面,我们需要理解 Kubernetes ReplicaSetsDeployments。正如我们所了解的,ReplicaSet 负责实现并协调应用服务的期望状态。这意味着 ReplicaSet 管理一组 pod。

DeploymentReplicaSet 上增强了滚动更新和回滚功能。在 Docker Swarm 中,Swarm 服务结合了 ReplicaSetDeployment 的功能。在这方面,SwarmKit 比 Kubernetes 更加单一化。下图展示了 DeploymentReplicaSet 的关系:

Kubernetes 部署

在上面的图示中,ReplicaSet 定义并管理一组相同的 pod。ReplicaSet 的主要特点是它是 自愈的可扩展的,并且始终尽力协调 期望 状态。Kubernetes 部署(Deployment)则在此基础上添加了滚动更新和回滚功能。在这方面,部署实际上是对 ReplicaSet 的一个封装对象。

我们将在第十六章中了解更多关于滚动更新和回滚的内容,使用 Kubernetes 部署、更新和保护应用程序

在接下来的部分,我们将了解更多关于 Kubernetes 服务的内容,以及它们如何实现服务发现和路由。

Kubernetes 服务

一旦我们开始处理由多个应用服务组成的应用程序,我们就需要服务发现。下图说明了这个问题:

服务发现

在前面的图示中,我们有一个需要访问其他三个服务的Web API服务:支付运输订单Web API永远不需要关心如何以及在哪里找到这三个服务。在 API 代码中,我们只想使用我们希望访问的服务名称和其端口号。一个示例是以下 URL http://payments:3000,用于访问支付服务的一个实例。

在 Kubernetes 中,支付应用服务由一个副本集(ReplicaSet)中的多个 Pod 表示。由于高度分布式系统的特点,我们不能假设 Pod 拥有稳定的端点。Pod 可能随时创建或销毁。但是,如果我们需要从内部或外部客户端访问相应的应用服务,那就成了一个问题。如果我们不能依赖 Pod 的端点稳定性,那我们还能做些什么呢?

这时,Kubernetes 服务派上用场。它们旨在为副本集(ReplicaSets)或部署(Deployments)提供稳定的端点,具体如下:

Kubernetes 服务为客户端提供稳定的端点

在前面的图示中,我们可以看到中间部分的 Kubernetes 服务。它提供了一个可靠的集群范围内的 IP 地址,也称为虚拟 IPVIP),以及一个可靠的、在整个集群中唯一的端口。Kubernetes 服务代理的 Pod 是由服务规范中定义的选择器决定的。选择器始终基于标签。每个 Kubernetes 对象都可以被分配零到多个标签。在我们的例子中,选择器app=web;即,所有具有名为 app 且值为 web 的标签的 Pod 都会被代理。

在下一节中,我们将深入了解基于上下文的路由以及 Kubernetes 如何减轻这一任务。

基于上下文的路由

我们经常需要为我们的 Kubernetes 集群配置基于上下文的路由。Kubernetes 为我们提供了多种方式来实现这一点。目前,首选且最具可扩展性的方法是使用 IngressController。下图试图说明这个 IngressController 是如何工作的:

使用 Kubernetes Ingress 控制器的基于上下文的路由

在前面的图示中,我们可以看到使用 IngressController(如 Nginx)时,基于上下文(或第七层)路由是如何工作的。这里我们部署了一个名为 web 的应用服务。该应用服务的所有 Pod 都有如下标签:app=web。然后,我们有一个名为 web 的 Kubernetes 服务,它为这些 Pod 提供稳定的端点。该服务有一个(虚拟)IP 地址 52.14.0.13,并暴露端口 30044。也就是说,如果一个请求访问 Kubernetes 集群中任何节点的名称为 web 且端口为 30044,它会被转发到该服务。然后,服务将请求负载均衡到其中一个 Pod 上。

到目前为止,一切顺利,但是如何将客户端发来的入口请求路由到我们的 web 服务的 http[s]://example.com/web URL?首先,我们必须定义从基于上下文的请求到对应 <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 Cluster 由相应编排工具管理的一组服务器/节点。
Node Cluster member 单一主机(物理或虚拟),是 Swarm/集群的成员。
Manager node Master 管理 Swarm/集群的节点。这是控制平面。
Worker node Node 运行应用工作负载的 Swarm/集群成员节点。
Container Container** 运行在节点上的容器镜像实例。**注意:在 Kubernetes 集群中,我们不能直接运行容器。
Task Pod 在节点上运行的服务(Swarm)或 ReplicaSet(Kubernetes)的实例。任务管理一个单一的容器,而 Pod 包含一个或多个容器,它们共享同一个网络命名空间。
Service ReplicaSet 定义并协调由多个实例组成的应用服务的期望状态。
Service Deployment 部署是一个增强了滚动更新和回滚功能的 ReplicaSet。
Routing Mesh Service Swarm 路由网格通过 IPVS 提供 L4 路由和负载均衡。Kubernetes 服务是一个抽象,定义了一个逻辑上的 Pod 集合,并有一个策略可以用于访问它们。它是一组 Pods 的稳定端点。
Stack Stack ** 由多个(Swarm)服务组成的应用的定义。**注意:虽然堆栈在 Kubernetes 中不是原生支持的,但 Docker 的工具 Docker for Desktop 会将其转换为部署到 Kubernetes 集群。
网络 网络策略 Swarm 软件定义网络SDN)用于为容器设置防火墙。Kubernetes 仅定义了一个平面的网络。每个 pod 都可以访问其他所有 pod 和/或节点,除非显式定义了网络策略来限制 pod 之间的通信。

总结

在本章中,我们学习了 Kubernetes 的基础知识。我们概述了它的架构,并介绍了用于定义和运行 Kubernetes 集群中应用程序的主要资源。我们还介绍了 Minikube 和 Docker for Desktop 中的 Kubernetes 支持。

在下一章中,我们将把应用程序部署到 Kubernetes 集群中。然后,我们将使用零停机策略更新该应用程序的一个服务。最后,我们将使用秘密在 Kubernetes 中运行的应用程序服务中处理敏感数据。敬请关注!

问题

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

  1. 用简短的几句话解释 Kubernetes master 的角色。

  2. 列出每个 Kubernetes(工作)节点上需要存在的元素。

  3. 我们不能在 Kubernetes 集群中运行单独的容器。

A. 是

B. 否

  1. 解释为什么 pod 中的容器可以使用localhost进行相互通信。

  2. pod 中所谓的暂停容器的目的是什么?

  3. Bob 告诉你:“我们的应用程序由三个 Docker 镜像组成:webinventorydb。由于我们可以在 Kubernetes pod 中运行多个容器,因此我们打算将应用程序的所有服务部署到单个 pod 中。”列出三个到四个原因,说明为什么这是一个糟糕的主意。

  4. 用你自己的话解释为什么我们需要 Kubernetes ReplicaSets。

  5. 在哪些情况下我们需要 Kubernetes 部署?

  6. 列出至少三种 Kubernetes 服务,并解释它们的目的及其区别。

进一步阅读

以下是包含有关本章讨论的各种主题的更详细信息的文章列表:

第十七章:使用 Kubernetes 部署、更新和保护应用程序

在上一章中,我们学习了容器编排工具 Kubernetes 的基础知识。我们对 Kubernetes 的架构有了高层次的了解,并学到了许多关于 Kubernetes 用来定义和管理容器化应用程序的重要对象。

在本章中,我们将学习如何将应用程序部署、更新和扩展到 Kubernetes 集群中。我们还将解释如何实现零停机部署,以便无干扰地更新和回滚关键任务应用程序。最后,我们将介绍 Kubernetes secrets,作为配置服务和保护敏感数据的手段。

本章涵盖以下内容:

  • 部署第一个应用程序

  • 定义活跃性和就绪性

  • 零停机部署

  • Kubernetes secrets

完成本章内容后,你将能够完成以下任务:

  • 将多服务应用程序部署到 Kubernetes 集群中

  • 为你的 Kubernetes 应用程序服务定义活跃性和就绪性探针

  • 在 Kubernetes 中更新应用程序服务而不造成停机

  • 在 Kubernetes 集群中定义 secrets

  • 配置应用程序服务以使用 Kubernetes secrets

技术要求

在本章中,我们将在本地计算机上使用 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:

Kubernetes 部署定义(web 组件)

上述部署定义可以在 ~/fod/ch16 文件夹中的 web-deployment.yaml 文件中找到。代码行如下:

  • 在第 4 行:我们为 Deployment 对象定义了名称 web

  • 在第 6 行:我们声明要运行一个 web 组件实例。

  • 从第 8 行到第 10 行:我们定义了哪些 pods 会成为我们部署的一部分,即那些拥有 appservice 标签,标签值分别为 petsweb 的 pods。

  • 在第 11 行:在从第 11 行开始的 pod 模板中,我们定义了每个 pod 都会应用 appservice 标签。

  • 从第 17 行:我们定义了将在 pod 中运行的唯一容器。该容器的镜像为我们熟知的 fundamentalsofdocker/ch11-web:2.0 镜像,容器的名称为 web

  • ports:最后,我们声明容器暴露 3000 端口供 TCP 类型的流量使用。

请确保已经将 kubectl 的上下文设置为 Minikube。有关如何设置的详细信息,请参见 第二章,设置工作环境

我们可以使用 kubectl 部署这个 Deployment 对象:

$ kubectl create -f web-deployment.yaml

我们可以使用 Kubernetes CLI 再次确认部署是否已创建。我们应该看到以下输出:

列出在 Minikube 上运行的所有资源

在前面的输出中,我们可以看到 Kubernetes 创建了三个对象——部署(deployment)、相应的 ReplicaSet 和一个单独的 pod(记住我们指定了只需要一个副本)。当前状态与这三个对象的期望状态一致,所以到目前为止一切正常。

现在,网络服务需要向公众公开。为此,我们需要定义一个 NodePort 类型的 Kubernetes 服务对象。以下是定义,可以在 ~/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标签,分别为petsweb值的 Pod。

现在我们已经有了这个 Service 对象的规范,可以使用kubectl创建它:

$ kubectl create -f web-service.yaml

我们可以列出所有服务,查看上一个命令的结果:

为 Web 组件创建的 Service 对象

在上面的输出中,我们可以看到一个名为web的服务已经被创建。此服务分配了一个唯一的clusterIP,值为10.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文件中找到。具体内容如下:

数据库组件的 StatefulSet

好吧,这看起来有点吓人,但实际上并不是。由于我们还需要定义一个卷来存储 PostgreSQL 数据库的数据,这比定义 web 组件的部署稍长一些。卷声明定义在第25行到33行。我们想创建一个名为pets-data的卷,最大大小为100 MB。在第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的服务下访问数据库。

为了使服务发现能够在集群内部工作,我们还必须为数据库组件定义一个 Kubernetes 服务对象。由于数据库应该只能从集群内部访问,我们需要的服务对象类型是ClusterIP。以下是该规范,可以在~/fod/ch16/db-service.yaml文件中找到:

定义数据库的 Kubernetes 服务对象

数据库组件将由这个Service对象表示,它可以通过db这个名字访问,这个名字在第4行定义。数据库组件不需要公开访问,因此我们决定使用ClusterIP类型的Service对象。第1012行的选择器定义了该服务表示所有具有相应标签的 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 组件的DeploymentService对象

  • 数据库组件的StatefulSetService对象

要从集群中移除应用程序,我们可以使用以下小脚本:

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

在这里,我们已经将 pets 应用的四个对象定义收集在 ~/fod/ch16/pets.yaml 文件中,我们可以一次性部署该应用。

使用一个脚本来部署宠物应用

同样,我们创建了一个名为~/fod/ch16/remove-pets.sh的脚本,用于从 Kubernetes 集群中删除所有与宠物应用相关的文件。

从 Kubernetes 集群中删除宠物

通过这一点,我们已经将第十一章中介绍的Docker Compose宠物应用程序,定义了部署该应用到 Kubernetes 集群所需的所有 Kubernetes 对象。在每个步骤中,我们都确保达到了预期的结果,一旦所有工件存在于集群中,我们展示了正在运行的应用。

定义存活性和就绪性

像 Kubernetes 和 Docker Swarm 这样的容器编排系统使得部署、运行和更新高度分布式的关键任务应用程序变得更加容易。编排引擎自动化了许多繁琐的任务,例如扩展、确保始终保持期望的状态等。

但是,编排引擎并不能自动做所有事情。有时,我们开发者需要为引擎提供一些只有我们才能了解的信息。那么,我是什么意思呢?

我们来看一个单一的应用服务。假设它是一个微服务,称之为服务 A。如果我们在 Kubernetes 集群上运行容器化的服务 A,那么 Kubernetes 可以确保我们在服务定义中要求的五个实例始终在运行。如果其中一个实例崩溃,Kubernetes 可以迅速启动一个新实例,从而保持期望状态。但是,如果某个服务的实例没有崩溃,而是处于不健康状态,或者只是尚未准备好处理请求呢?显然,Kubernetes 应该知道这两种情况。但它做不到,因为从应用服务的角度来看,健康或不健康是编排引擎无法知晓的。只有我们这些应用开发人员才能知道何时我们的服务是健康的,何时它是不健康的。

比如,应用服务可能正在运行,但由于某些 bug,它的内部状态可能已经被破坏,可能处于死循环或死锁状态。类似地,只有我们这些应用开发人员知道我们的服务是否已准备好工作,或者它是否仍在初始化阶段。尽管强烈建议将微服务的初始化阶段尽可能缩短,但如果某个服务需要较长时间才能准备好工作,这种情况通常是无法避免的。然而,处于初始化状态并不等同于不健康状态。初始化阶段是微服务或任何其他应用服务生命周期中的预期部分。

因此,Kubernetes 不应尝试在我们的微服务处于初始化阶段时杀死它。如果我们的微服务不健康,Kubernetes 应该尽快将其终止,并替换为一个新的实例。

Kubernetes 有一个探针的概念,用来提供编排引擎和应用开发人员之间的连接。Kubernetes 使用这些探针来获取更多关于应用服务内部状态的信息。探针是在每个容器内部本地执行的。这里有一个用于服务健康(也叫存活状态)的探针,一个启动探针,以及一个用于服务就绪性的探针。我们依次来看它们。

Kubernetes 存活探针

Kubernetes 使用存活探针来决定何时需要杀死一个容器,并启动另一个实例来替代它。由于 Kubernetes 在 pod 级别进行操作,如果 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 将在容器内执行的命令作为探针。在我们的例子中,我们有一个 PostgreSQL 容器,并使用netcat Linux 工具通过 TCP 探测端口5432。当 Postgres 监听该端口时,nc localhost 5432命令会成功。

另外两个设置,initialDelaySecondsperiodSeconds,定义了 Kubernetes 在启动容器后需要等待多长时间才会执行第一次探针,以及之后执行探针的频率。在我们的案例中,Kubernetes 在执行第一次探针之前等待 10 秒,然后每 5 秒执行一次探针。

也可以探测 HTTP 端点,而不是使用命令。假设我们正在运行一个微服务,镜像为acme.com/my-api:1.0,该 API 有一个名为/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 协议并执行一个GET请求,访问本地主机端口5000上的/api/health端点。记住,探针是在容器内部执行的,这意味着我可以使用 localhost。

我们还可以直接使用 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 中的容器执行存活探针和就绪探针。

既然我们已经有了存活探针和就绪探针,什么时候需要使用启动探针呢?可能会有一些情况,我们需要考虑异常长的启动和初始化时间,例如将遗留应用程序容器化。我们技术上可以配置就绪探针或存活探针来应对这种情况,但那样就违背了这些探针的目的。后者的探针旨在为 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

一旦我们部署了 pods 和服务,就可以通过以下命令测试我们的 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

现在,我们的开发人员已经创建了 Web 组件的 2.1 版本。新版本的 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 Deployment 对象中 pods 使用的镜像。我们可以通过使用 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,最初只有一个副本。因此,在那个特定时刻,系统中存在六个 pods:五个初始 pods 和一个新的版本的 pod。但是,由于 Deployment 对象的期望状态要求我们只有五个副本,Kubernetes 现在将旧的 ReplicaSet 缩减为四个副本,我们可以在第三个事件中看到这一点。

然后,新创建的 ReplicaSet 再次扩展到两个实例,随后,旧的 ReplicaSet 缩减到三个实例,以此类推,直到我们拥有五个新实例,所有旧实例都被停用。虽然我们无法看到具体的时间(除了 3 分钟)发生了什么,但事件的顺序告诉我们,整个更新是以滚动方式进行的。

在短时间内,一些对 web 服务的调用会得到来自旧版本组件的响应,而另一些调用则会得到新版本组件的响应,但在任何时候,服务都不会宕机。

我们还可以列出集群中的 ReplicaSet 对象,从而确认我在前面部分所说的内容:

列出集群中的所有 ReplicaSet 对象

在这里,我们可以看到新的 ReplicaSet 有五个实例正在运行,而旧的 ReplicaSet 已缩减到零实例。旧的 ReplicaSet 对象仍然存在的原因是 Kubernetes 提供了回滚更新的可能性,在这种情况下,它会重用该 ReplicaSet。

为了回滚图像更新,以防新代码中出现了某些未被发现的 bug,我们可以使用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。我们将通过为 pod 打上color: blue标签来实现这一点。

  2. 为这些具有color: blue标签的 pod 部署 Kubernetes 服务,在选择器部分进行配置。

  3. 现在,我们可以部署 web 组件的版本 2,不过这次 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 服务

关于我们在本章早些时候使用的服务定义,唯一的区别是第 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

正如预期的那样,我们得到了响应 Pets Demo Application。现在,我们可以部署绿色版本的 web 组件。其 Deployment 对象的定义可以在 ~/fod/ch16/web-deploy-green.yaml 文件中找到,如下所示:

为 web 组件指定绿色部署

有趣的行如下:

  • 4:命名为 web-green,以区别于 web-blue,并允许并行安装

  • 1117:具有 green 颜色

  • 20:现在使用版本 2.1 的镜像

现在,我们准备部署此绿色版本的服务。它应与蓝色服务分开运行:

$ kubectl create -f web-deploy-green.yaml

我们可以通过以下方式确保两个部署共存:

显示集群中运行的 Deployment 对象列表

正如预期的那样,我们同时运行着蓝色和绿色版本。我们可以验证蓝色仍然是活动服务:

$ curl -4 ${IP}:${PORT}/
Pets Demo Application

现在进入有趣的部分。我们可以通过编辑现有的 web 组件服务来切换流量,从蓝色版本切换到绿色版本。为此,执行以下命令:

$ kubectl edit svc/web

将标签 color 的值从 blue 改为 green,然后保存并退出编辑器。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 从green切换到blue,依此类推。

我们已经成功地展示了如何在 Kubernetes 集群中的 pets 应用程序的 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

然后,秘密可以像手动创建的秘密一样使用。

你可能会问,为什么这种方法比另一种方法更安全?首先,没有定义秘密的 YAML 文件存储在某些源代码版本控制系统中(如 GitHub),这些系统许多人都有访问权限,因此可以查看并解码秘密。只有被授权知道秘密的管理员才能看到这些值,并用它们直接在(生产)集群中创建秘密。集群本身受角色访问控制保护,因此无授权人员无法访问它,也无法解码集群中定义的秘密。

现在,让我们看看如何实际使用我们定义的秘密。

在 Pod 中使用秘密

假设我们要创建一个Deployment对象,其中web组件使用我们在前一节中介绍的秘密pets-secret。我们可以使用以下命令在集群中创建秘密:

$ kubectl create -f pets-secret.yaml

~/fod/ch16/web-deploy-secret.yaml文件中,我们可以找到Deployment对象的定义。我们不得不将从23行开始的部分添加到原始的Deployment对象定义中:

带有秘密的web组件的Deployment对象

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,使其如下所示:

部署映射密钥值到环境变量

2333行中,我们定义了两个环境变量,PETS_USERNAMEPETS_PASSWORD,并将pets-secret的相应键值对映射到这些环境变量中。

请注意,我们不再需要卷;相反,我们直接将pets-secret的各个密钥映射到容器内部有效的相应环境变量。以下命令序列显示了密钥值确实可以在容器内部通过相应的环境变量访问:

密钥值映射到环境变量

在本节中,我们向您展示了如何在 Kubernetes 集群中定义密钥,并如何在作为部署一部分的容器中使用这些密钥。我们展示了两种密钥映射到容器中的方式,第一种是使用文件,第二种是使用环境变量。

小结

本章中,我们学习了如何将应用程序部署到 Kubernetes 集群中,以及如何为该应用程序设置应用级路由。此外,我们还学习了如何在不导致停机的情况下更新 Kubernetes 集群中运行的应用服务。最后,我们使用密钥为运行在集群中的应用服务提供敏感信息。

在下一章中,我们将学习用于监控在 Kubernetes 集群上运行的单个服务或整个分布式应用程序的不同技术。我们还将学习如何在不更改集群或服务运行的集群节点的情况下,排除在生产环境中运行的应用服务的问题。敬请期待。

问题

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

  1. 你有一个由两个服务组成的应用程序,第一个是 Web API,第二个是数据库,例如 Mongo DB。你想将此应用程序部署到 Kubernetes 集群中。简短地解释一下你会如何进行。

  2. 用你自己的话描述建立应用程序的第七层(或应用层)路由所需的组件。

  3. 列出实现简单应用服务的蓝绿部署所需的主要步骤。避免过多细节。

  4. 列举通过 Kubernetes secrets 提供给应用服务的三种或四种信息类型。

  5. 命名 Kubernetes 创建 secret 时接受的源。

进一步阅读

以下是一些提供关于本章讨论主题的额外信息的链接:

第十八章:生产环境中应用的监控与故障排除

在上一章中,我们学习了如何将一个多服务应用部署到 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。它是一个开源项目,已经捐赠给云原生计算基金会(CNCF)。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. 我们将使用 Prometheus 的 .NET 适配器,它作为一个 NuGet 包 prometheus-net.AspNetCore 提供。使用以下命令将此包添加到 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 UI,或者像 Grafana 这样的更复杂的解决方案,在仪表板上展示重要的指标。

与大多数用于从应用服务和基础设施组件收集指标的工具不同,Prometheus 服务器承担了工作负载,并定期抓取所有定义的目标。这样,应用程序和服务就无需担心转发数据。你也可以把这描述为拉取指标而非推送指标。这使得 Prometheus 服务器非常适合我们的案例。

我们现在将讨论如何将 Prometheus 部署到 Kubernetes,然后是我们的两个示例应用服务。最后,我们将把 Grafana 部署到集群中,并使用它在仪表板上展示我们的客户指标。

架构

让我们快速了解一下计划系统的架构。如前所述,我们有微服务、Prometheus 服务器和 Grafana。此外,所有内容都将部署到 Kubernetes 上。下图展示了它们之间的关系:

使用 Prometheus 和 Grafana 进行监控的应用程序高级概述

在图表的顶部中央,我们有 Prometheus,它定期从 Kubernetes 中抓取指标,显示在左侧。它还定期从服务中抓取指标,在我们的例子中是从我们在前一部分创建并加了监控的 Node.js 和.NET 示例服务中抓取。最后,在图表的右侧,我们有 Grafana,它定期从 Prometheus 中拉取数据,然后在图形化的仪表板上展示出来。

将 Prometheus 部署到 Kubernetes 中

如前所述,我们首先将 Prometheus 部署到 Kubernetes 中。让我们首先定义一个 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的抓取任务每 5 秒从 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 的网页 UI。打开一个新的浏览器标签页,导航到 http://localhost:<port>/targets,其中 <port> 在我的例子中是 31962。你应该看到类似这样的页面:

Prometheus 网页 UI 显示已配置的目标

在上一张截图中,我们可以看到我们为 Prometheus 定义了三个目标。列表中的第三个目标是正在运行并且可以被 Prometheus 访问的。它是我们在配置文件中为从 Prometheus 本身抓取指标的任务定义的端点。其他两个服务此时未运行,因此它们的状态是停机的。

  1. 现在通过点击 UI 顶部菜单中的相应链接来导航到 Graph。

  2. 打开指标下拉列表,并检查 Prometheus 找到的所有列出的指标。在这种情况下,它仅显示由 Prometheus 服务器本身定义的指标列表:

Prometheus 网页 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. 将包含以下内容的 Dockerfile 添加到 ch17/dotnet/sample-api 项目文件夹:
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 .

请注意,你可能需要将前面和后面的命令中的 fundamentalsofdocker 替换为你自己的 Docker Hub 用户名。

  1. 将镜像推送到 Docker Hub:
$ docker image push fundamentalsofdocker/ch17-dotnet-api:2.0

现在我们对 Node 示例 API 做同样的操作:

  1. 将包含以下内容的 Dockerfile 添加到 ch17/node 项目文件夹:
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 .

再次注意,你可能需要将前面和后面的命令中的 fundamentalsofdocker 替换为你自己的 Docker Hub 用户名。

  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. 在此文件中,添加一个 Grafana 的 Kubernetes Deployment 定义:

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

在我的机器上,如果我在 Docker for Windows 上运行 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 集群节点上进行故障排除。你该如何进行?

进一步阅读

这里有一些链接,提供了有关本章讨论主题的额外信息:

第十九章:在云中运行容器化应用

在上一章中,我们学习了如何在生产环境中部署、监控和排除应用故障。

在本章中,我们将概述一些在云中运行容器化应用的流行方式。我们将探讨自托管和托管解决方案,并讨论它们的优缺点。微软 Azure 和谷歌云引擎等供应商提供的完全托管服务也将简要讨论。

本章我们将讨论以下主题:

  • 亚马逊网络服务AWS)上部署并使用 Docker 企业版EE

  • 探索微软的Azure Kubernetes 服务AKS

  • 了解谷歌 Kubernetes 引擎GKE

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

  • 在 AWS 中使用 Docker EE 创建一个 Kubernetes 集群

  • 在 AWS 的 Docker EE 集群中部署并运行一个简单的分布式应用

  • 在微软的 AKS 上部署并运行一个简单的分布式应用

  • 在 GKE 上部署并运行一个简单的分布式应用

技术要求

本章我们将使用 AWS、微软 Azure 和谷歌云。因此,需要为每个平台准备一个账户。如果您没有现有账户,可以申请这些云服务提供商的试用账户。

我们还将使用来自 GitHub 的labs仓库中~/fod-solution/ch18文件夹中的文件,链接为:github.com/PacktPublishing/Learn-Docker---Fundamentals-of-Docker-19.x-Second-Edition/tree/master/ch18

在 AWS 上部署和使用 Docker EE

在本节中,我们将安装 Docker 统一控制平面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 上的 ASG 为 Docker EE 准备就绪

一旦 ASG 创建完成,在继续之前,我们需要稍微开放安全组SG),以便能够通过 SSH 从我们的笔记本电脑访问它,并且虚拟机VMs)之间能够相互通信。

  1. 转到您的安全组(SG)并添加两个新的入站规则,具体如下所示:

AWS 安全组(SG)设置

在之前的截图中,第一个规则允许任何来自我个人笔记本(IP 地址为70.113.114.234)的流量访问安全组中的任何资源。第二个规则允许安全组内部的所有流量。这些设置不适合用于生产环境,因为它们过于宽松。但是在这个演示环境中,它们非常有效。

接下来,我们将展示如何在刚才准备好的虚拟机上安装 Docker。

安装 Docker

在配置好集群节点后,我们需要在每个节点上安装 Docker。通过以下步骤可以轻松完成:

  1. SSH 连接到所有三个实例并安装 Docker。使用下载的密钥,SSH 连接到第一台机器:
$ ssh -i pets.pem ubuntu@<IP address>

这里,<IP 地址>是我们想要 SSH 连接的虚拟机的公网 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 是否已正确安装。为另外两台虚拟机重复之前的步骤。

sudo 只在下次打开此虚拟机的 SSH 会话时才需要,因为我们已经将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登录。当系统要求提供许可证时,上传你的许可证密钥,或者点击链接获取试用许可证。

登录后,在左侧的共享资源(Shared Resources)部分,选择节点(Nodes),然后点击添加节点(Add Node)按钮:

向 UCP 添加新节点

在随后的添加节点(Add Node)对话框中,确保节点类型选择为 Linux,并且选择了工作节点(Worker)角色。然后,复制对话框底部的docker swarm join命令。通过 SSH 连接到你创建的另外两个虚拟机,并运行此命令,让相应的节点作为工作节点加入 Docker swarm:

将节点作为工作节点加入 UCP 集群

返回到 UCP 的 Web 界面,你应该可以看到我们现在有三个节点已准备好,如下所示:

UCP 集群中的节点列表

默认情况下,工作节点被配置为只能运行 Docker Swarm 工作负载。不过,这可以在节点详情中更改。在此,有三种设置方式:仅 Swarm、仅 Kubernetes 或混合工作负载。让我们从 Docker Swarm 作为编排引擎开始,并部署我们的宠物应用程序。

使用远程管理员管理 UCP 集群

为了能够从笔记本电脑远程管理 UCP 集群,我们需要从 UCP 创建并下载一个所谓的客户端捆绑包。请按照以下步骤操作:

  1. 在 UCP Web UI 中,在左侧的 admin 下,选择我的个人资料(My Profile)选项。

  2. 在随后的对话框中,选择“新客户端捆绑包”(New Client Bundle)选项,然后点击“生成客户端捆绑包”(Generate Client Bundle):

生成并下载 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. 要测试应用程序,我们可以在主菜单中的服务(Services)下,点击 Swarm。集群中运行的服务列表将如下所示:

宠物堆栈的“web”服务详细信息

在前面的截图中,我们看到我们的两个服务,webdb,它们属于 pets 堆栈。如果我们点击 web 服务,其详细信息会显示在右侧。在那里,我们可以找到一个条目,“已发布的端点”(Published Endpoints)。

  1. 点击链接,我们的pets应用程序应该会在浏览器中显示。

完成后,用以下命令从控制台中删除堆栈:

$ docker stack rm pets

或者,你可以尝试从 UCP Web UI 中删除该堆栈。

部署到 Kubernetes

从你之前用来远程访问 UCP 集群以部署宠物应用程序的终端,现在我们可以尝试使用 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 中创建了一个由三台虚拟机组成的集群,并在它们上安装了 Docker 和 UCP 3.0。然后,我们将著名的宠物应用程序部署到 UCP 集群中,一次使用 Docker Swarm 作为编排引擎,另一次使用 Kubernetes。

Docker UCP 是一个平台无关的容器平台,提供安全的企业级软件供应链,支持任何云环境、本地环境、裸金属或虚拟化环境。它甚至在编排引擎方面提供了选择自由,用户可以在 Docker Swarm 和 Kubernetes 之间进行选择。还可以在同一集群中同时运行两个编排器中的应用程序。

探索 Microsoft 的 Azure Kubernetes 服务(AKS)

要在 Azure 上试验 Microsoft 的容器相关服务,我们需要一个 Azure 账户。你可以创建一个试用账户或使用现有账户。你可以在这里获取免费的试用账户:azure.microsoft.com/en-us/free/

Microsoft 在 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 可以在 Docker Hub 上找到。让我们拉取它:

$ docker image pull mcr.microsoft.com/azure-cli:latest

我们将从这个 CLI 运行一个容器,并在该容器内的 Shell 中执行所有后续命令。现在,我们需要克服一个小问题。这个容器中不会安装 Docker 客户端。但是我们也需要运行一些 Docker 命令,因此我们必须创建一个从前一个镜像派生的自定义镜像,其中包含 Docker 客户端。为了做到这一点,我们需要的Dockerfile文件可以在~/fod/ch18文件夹中找到,内容如下:

FROM mcr.microsoft.com/azure-cli:latest
RUN apk update && apk add docker

在第二行,我们只是使用 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 服务主体不会被删除。有关如何删除服务主体的详细信息,请参阅在线帮助。

下一项是 Google 的 Kubernetes 引擎。

理解 GKE

Google 是 Kubernetes 的发明者,并且至今仍是其背后的推动力。因此,你可以预期 Google 在托管 Kubernetes 方面有强有力的产品。现在让我们一探究竟。要继续,你需要拥有一个 Google Cloud 帐户,或者在此创建一个测试帐户:console.cloud.google.com/freetrial。继续以下步骤:

  1. 在主菜单中选择 Kubernetes 引擎。第一次选择时,可能需要一些时间来初始化 Kubernetes 引擎。

  2. 接下来,创建一个新项目并命名为 massai-mara;这可能需要一点时间。

  3. 准备好后,我们可以通过点击弹窗中的创建集群来创建一个集群。

  4. 在表单左侧选择你的第一个集群模板。

  5. 将集群命名为animals-cluster,选择离你最近的区域或可用区,保持创建 Kubernetes 集群表单中的所有其他设置为默认值,然后点击表单底部的创建按钮。

它将再次花费一些时间为我们配置集群。一旦集群创建完成,我们可以通过点击视图右上角的 Shell 图标来打开 Cloud Shell。这应该看起来像下面的截图:

在 GKE 中准备好第一个 Kubernetes 集群并打开 Cloud Shell

现在我们可以使用以下命令将我们的labs GitHub 仓库克隆到此环境中:

$ 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 拉取它们。这非常简单,类似于我们在 AKS 部分学到的内容,在 Google Cloud 中创建这样一个容器注册中心也很容易。

在继续之前,我们需要设置 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,首先克隆了我们的 labs GitHub 仓库,然后使用 kubectl 工具将动物应用程序部署到 Kubernetes 集群中。

当考虑托管的 Kubernetes 解决方案时,GKE 是一个非常有吸引力的选择。它让启动变得非常简单,并且由于 Google 是 Kubernetes 背后的主要推动力,我们可以放心地使用 Kubernetes 的全部功能。

总结

在本书的最后一章,你首先了解了如何安装和使用 Docker 的 UCP,这是 Docker 在 AWS 上的企业级产品的一部分。然后,你学会了如何在 AKS 中创建托管的 Kubernetes 集群并运行动物应用程序,接着是 Google 自己的托管 Kubernetes 解决方案 GKE。

我很荣幸您选择了这本书,我也要感谢您与我一同走过这段旅程,在这段旅程中我们一起探讨了 Docker 容器及容器编排引擎。我希望这本书能成为您学习旅程中的宝贵资源。祝愿您在当前和未来的项目中使用容器时取得成功!

问题

为了评估您的知识,请回答以下问题:

  1. 给出在 AWS 上配置和运行 Docker UPC 所需任务的高级描述。

  2. 列举选择托管 Kubernetes 服务(如微软的 AKS 或谷歌的 GKE)来运行应用程序的一些原因。

  3. 在使用托管 Kubernetes 解决方案(如 AKS 或 GKE)时,列出两个考虑将容器镜像托管在相应云服务提供商的容器注册表中的理由。

进一步阅读

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

第二十章:评估

第一章

以下是本章提出问题的一些示例答案:

  1. 正确答案是DE

  2. Docker 容器对 IT 的意义就像运输行业中的集装箱对运输业的意义一样。它定义了如何打包货物的标准。在这里,货物就是开发人员编写的应用程序。供应商(在这种情况下是开发人员)负责将货物打包进容器并确保一切按预期装配好。一旦货物被打包进容器,它就可以被运输。由于这是标准集装箱,运输商可以标准化他们的运输方式,如卡车、火车或船只。运输商并不关心容器里装的是什么。此外,从一种运输方式到另一种运输方式(例如从火车到船)的装卸过程可以高度标准化。这极大提高了运输效率。与此类似,IT 中的运维工程师可以将开发人员构建的软件容器运送到生产系统并在那儿以高度标准化的方式运行,而无需担心容器中装的是什么。它将按预期工作。

  3. 容器之所以能改变游戏规则的原因如下:

    • 容器是自包含的,因此如果它们在一个系统上运行,它们就能在任何能运行容器的地方运行。

    • 容器可以在本地和云端运行,也可以在混合环境中运行。这对今天的典型企业非常重要,因为它允许从本地环境到云端的平滑过渡。

    • 容器镜像由最了解的人——开发人员来构建或打包。

    • 容器镜像是不可变的,这对于良好的发布管理非常重要。

    • 容器是基于封装(使用 Linux 命名空间和 cgroups)、机密、内容信任和镜像漏洞扫描的安全软件供应链的推动者。

  4. 任何给定的容器可以在任何容器可以运行的地方运行,原因如下:

    • 容器是自包含的黑盒子。它们不仅封装了一个应用程序,还封装了它的所有依赖项,如库、框架、配置数据、证书等。

    • 容器基于广泛接受的标准,如 OCI。

  1. 答案是B。容器对现代应用程序以及将传统应用程序容器化都非常有用。企业在进行后者时能获得巨大的收益。据报道,维护传统应用程序的成本可以节省 50%以上。此类传统应用程序的新版本发布之间的时间可减少多达 90%。这些数据已由实际企业客户公开报告。

  2. 50%以上。

  3. 容器基于 Linux 命名空间(网络、进程、用户等)和 cgroups(控制组)。

第二章

以下是本章提出问题的一些示例答案:

  1. docker-machine可以用于以下场景:

    1. 在多个提供商(如 VirtualBox、Hyper-V、AWS、MS Azure 或 Google Compute Engine)上创建一个虚拟机,该虚拟机将作为 Docker 主机。

    2. 启动、停止或终止先前生成的虚拟机。

    3. 用这个工具创建的本地或远程 Docker 主机虚拟机中使用 SSH。

    4. 重新生成用于安全使用 Docker 主机虚拟机的证书。

  2. A. 正确。是的,使用 Docker for Windows,您可以开发并运行 Linux 容器。使用本书未讨论的此版本的 Docker for Desktop,您也可以开发并运行原生 Windows 容器。对于 macOS 版本,您只能开发和运行 Linux 容器。

  3. 脚本用于自动化过程,从而避免人为错误。构建、测试、共享和运行 Docker 容器是应该始终自动化的任务,以提高其可靠性和可重复性。

  4. 以下 Linux 发行版已通过认证,可以运行 Docker:RedHat Linux (RHEL)、CentOS、Oracle Linux、Ubuntu 等。

  5. 以下 Windows 操作系统已通过认证,可以运行 Docker:Windows 10 专业版、Windows Server 2016 和 Windows Server 2019。

第三章

以下是本章中提出问题的一些示例答案:

  1. Docker 容器的可能状态如下:

    • created:一个已创建但未启动的容器

    • restarting:一个正在重启过程中的容器

    • running:一个当前正在运行的容器

    • paused:一个其进程已被暂停的容器

    • exited:一个已运行并完成的容器

    • dead:一个 Docker 引擎尝试但未能停止的容器

  2. 我们可以使用 docker container ls(或者旧的、简短的版本 docker ps)列出当前在 Docker 主机上运行的所有容器。请注意,这不会列出已停止的容器,若要查看这些容器需要使用额外的参数 --all(或 -a)。

  3. 要列出所有容器的 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 包的一部分。使用 docker image build -t my-pinger 构建名为 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 虚拟机中。

因此,我们建议执行以下操作:

$ docker-machine create --driver virtualbox volume-test
$ docker-machine ssh volume-test

既然你已经进入名为 volume-test 的 Linux 虚拟机,你可以进行以下练习:

  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. 收集所有环境变量及其对应的值到一个配置文件中,然后通过 --env-file 命令行参数将其提供给容器,在 docker run 命令中使用,示例如下:

$ 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 容器知识。

    • 缺点:与本地使用工具相比,使用该工具的方式稍显间接。

  2. 在容器中运行测试具有以下优点:

    • 它们在开发者机器上运行和在测试或 CI 系统中运行一样顺畅。

    • 每次测试运行时,保持相同的初始条件会更容易。

    • 所有开发人员使用相同的环境设置,例如库和框架的版本。

  3. 在这里,我们期望看到一个图示,展示一个开发人员写代码并提交代码,例如到 GitHub。然后我们希望看到一个自动化服务器,如 Jenkins 或 TeamCity,图中显示它定期轮询 GitHub 是否有更新,或者是 GitHub 触发自动化服务器(通过 HTTP 回调)创建新的构建。图中还应显示自动化服务器运行所有测试,以验证构建的产物,如果所有测试通过,自动将应用程序或服务部署到集成系统中,在那里再次进行测试,例如进行一些冒烟测试。再次,如果这些测试通过,自动化服务器应该要求人工批准是否将其部署到生产环境中(这等同于持续交付),或者自动化服务器应自动将其部署到生产环境中(持续部署)。

第八章

这里是本章提出问题的一些示例答案:

  1. 你可能在一个资源或功能有限的工作站上工作,或者你的工作站可能被公司锁定,禁止安装任何未获官方批准的软件。有时,你可能需要使用公司尚未批准的语言或框架进行概念验证或实验(但如果概念验证成功,未来可能会被批准)。

  2. 当容器化应用程序需要自动化某些与容器相关的任务时,将 Docker 套接字绑定到容器中是推荐的方式。这可以是像 Jenkins 这样的自动化服务器应用程序,通常用于构建、测试和部署 Docker 镜像。

  3. 大多数商业应用程序在执行任务时不需要根级别的授权。从安全角度来看,因此强烈建议以最小权限运行此类应用程序,确保它们仅拥有完成任务所需的最低权限。任何不必要的提升权限可能会被黑客在恶意攻击中利用。通过以非 root 用户身份运行应用程序,你可以使潜在的黑客更难以入侵系统。

  4. 卷包含数据,而数据的生命周期通常需要远远超过容器或应用程序的生命周期。数据通常是任务关键的,需要安全存储数天、数月,甚至数年。当你删除一个卷时,你会不可逆地删除与其相关的数据。因此,删除卷时一定要确认你知道自己在做什么。

第九章

这里是本章提出问题的一些示例答案:

  1. 在分布式应用架构中,软件和基础设施的每一部分都需要在生产环境中具有冗余,以确保应用程序的持续运行时间是至关重要的。高度分布式的应用由多个部分组成,随着部分数量的增加,某个部分发生故障或异常行为的可能性也在增加。可以保证,给定足够的时间,每个部分最终都会失败。为了避免应用程序的停机,我们需要对每个部分进行冗余,无论是服务器、网络交换机,还是在容器中集群节点上运行的服务。

  2. 在高度分布式、可扩展和容错的系统中,应用程序的单个服务可能会由于扩展需求或组件故障而发生变化。因此,我们不能将不同的服务硬编码在一起。服务 A 需要访问服务 B 时,不应该知道服务 B 的 IP 地址等细节。它应该依赖于外部提供者来获取这些信息。DNS 就是这种位置服务提供者。服务 A 只需要告诉它要与服务 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. host网络允许我们在主机的网络命名空间中运行容器。

  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. 容器编排工具将我们从以下繁琐的日常任务中解放出来:

    • 扩展服务的规模

    • 负载均衡请求

    • 将请求路由到目标

    • 监控服务实例的健康状况

    • 保护分布式应用程序

  3. 在这个领域的赢家是 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 密钥在静态存储时是加密的。它们只会传输给使用这些密钥的服务和容器。由于群集节点之间的通信使用相互 TLS,因此密钥是加密传输的。密钥永远不会物理存储在工作节点上。

  5. 实现此操作的命令如下:

$ docker service update --image acme/inventory:2.1 \
 --update-parallelism 2 \
 --update-delay 60s \
 inventory
  1. 首先,我们需要将旧的密钥从服务中删除,然后需要将新版本的密钥添加进去(无法直接更新密钥):
$ 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 集群上运行独立容器。Pod 是该集群中部署的原子单元。

  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 服务是定义逻辑集的抽象,并规定如何访问它们的策略。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的 Kubernetes 服务,类型为ClusterIP,用于db-deployment

    • 定义一个用于 Web API 的部署,我们称之为web-deployment。我们将这个服务扩展为三个实例。

    • 定义一个名为api的 Kubernetes 服务,类型为NodePort,用于web-deployment

    • 如果我们使用机密(secrets),则需要通过 kubectl 直接在集群中定义这些机密。

    • 使用 kubectl 部署应用程序。

  2. 要为应用实现第 7 层路由,理想情况下我们使用 IngressController。IngressController 是一个反向代理,例如 Nginx,它有一个 sidecar 监听 Kubernetes 服务器 API 的相关更改,并在检测到此类更改时更新反向代理的配置并重启它。然后,我们需要在集群中定义 Ingress 资源,定义路由规则,例如基于上下文的路由,如https://example.com/pets to <service name>/<port>,或者像api/32001这样的配对。一旦 Kubernetes 创建或更改了这个 Ingress 对象,IngressController 的 sidecar 就会捕捉到并更新代理的路由配置。

  3. 假设这是一个集群内部的库存服务,那么我们接下来做以下操作:

    • 在部署版本 1.0 时,我们定义一个名为inventory-deployment-blue的部署,并为 pod 添加标签color: blue

    • 我们为前面的部署部署一个类型为ClusterIP的 Kubernetes 服务,命名为 inventory,选择器包含color: blue

    • 当我们准备好部署支付服务的新版本时,我们为服务的 2.0 版本定义一个部署,并将其命名为inventory-deployment-green。我们为 pod 添加一个标签color: green

    • 我们现在可以对 "green" 服务进行冒烟测试,当一切正常时,我们可以更新库存服务,使得选择器包含 color: green

  4. 一些机密信息,应该通过 Kubernetes secrets 提供给服务,包括密码、证书、API 密钥 ID、API 密钥密文和令牌。

  5. 秘密值的来源可以是文件或 base64 编码的值。

第十七章

以下是本章问题的一些示例答案:

  1. 出于性能和安全原因,我们不能在生产系统上进行实时调试。这包括交互式调试或远程调试。然而,应用服务可能由于代码缺陷或其他与基础设施相关的问题(如网络故障或不可用的外部服务)而表现出意外行为。为了快速确定服务行为异常或失败的原因,我们需要尽可能多的日志信息。这些信息应该为我们提供线索,并引导我们找到错误的根本原因。当我们对服务进行监控时,正是为了做到这一点——我们以合理的方式生成尽可能多的信息,形式是日志条目和发布的度量。

  2. Prometheus 是一个用于收集由其他基础设施服务,最重要的是应用服务提供的功能性或非功能性度量的服务。由于 Prometheus 本身周期性地从所有配置的服务中拉取这些度量,服务本身无需担心发送数据。Prometheus 还定义了生产者应呈现度量数据的格式。

  3. 要为基于 Node.js 的应用服务进行监控,我们需要执行以下四个步骤:

    1. 向项目中添加 Prometheus 适配器。Prometheus 的维护者推荐使用名为 siimon/prom-client 的库。

    2. 在应用启动时配置 Prometheus 客户端。这包括定义度量注册表。

    3. 暴露一个 HTTP GET 端点/度量,在该端点我们返回度量注册表中定义的度量集合。

    4. 最后,我们定义 countergaugehistogram 类型的自定义度量,并在我们的代码中使用它们;例如,我们每次调用某个端点时,就增加一个 counter 类型的度量。

  4. 在生产环境中,Kubernetes 集群节点通常只包含一个最小化的操作系统,以尽可能减少攻击面并避免浪费宝贵的资源。因此,我们不能假设通常用于排查应用或进程问题的工具在各个主机上都可用。一个强大且推荐的排查方式是运行一个特殊的工具或排查容器,作为一个临时 pod 的一部分。这个容器可以作为堡垒,从中我们可以调查与有问题服务相关的网络或其他问题。一个已经成功被许多 Docker 工程师在客户现场使用的容器是 netshoot

第十八章

以下是本章问题的一些示例答案:

  1. 要在 AWS 上安装 UCP,我们按以下步骤操作:

    • 创建一个包含子网和安全组(SG)的虚拟私有云(VPC)。

    • 然后,提供一组 Linux 虚拟机,可能作为自动伸缩组(ASG)的一部分。许多 Linux 发行版是支持的,如 CentOS、RHEL 和 Ubuntu。

    • 接下来,在每个虚拟机上安装 Docker。

    • 最后,选择一台虚拟机并使用 docker/ucp 镜像来安装 UCP。

    • 一旦 UCP 安装完成,将其他虚拟机加入集群,作为工作节点或管理节点。

  2. 以下是考虑使用托管 Kubernetes 服务的一些原因:

    • 你可能不想安装和管理一个 Kubernetes 集群,或者没有足够的资源来完成这一任务。

    • 你希望专注于为你的业务带来价值的部分,在大多数情况下,这就是应该运行在 Kubernetes 上的应用,而不是 Kubernetes 本身。

    • 你偏向于一种按需付费的成本模型,只为你需要的部分付费。

    • 你的 Kubernetes 集群节点会自动进行补丁更新。

    • 升级 Kubernetes 版本且零停机时间的操作简单直接。

  3. 将容器镜像托管在云服务提供商的容器注册表(例如微软 Azure 上的 ACR)上的两个主要原因是:

    • 镜像地理位置靠近你的 Kubernetes 集群,因此延迟和传输网络成本极低。

    • 生产环境或类似生产环境的集群最好与互联网隔离,因此 Kubernetes 集群节点无法直接访问 Docker Hub。

posted @ 2025-06-29 10:38  绝不原创的飞龙  阅读(4)  评论(0)    收藏  举报