容器开发者手册-全-

容器开发者手册(全)

原文:annas-archive.org/md5/769c70a657ac1b78b984fdb2e4123e05

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

本书是一本关于使用容器创建应用程序的实用入门书籍。读者将学习容器是什么以及它们为何成为一种新的应用程序部署标准。我们将从容器的关键概念、功能和使用开始,讲解它们如何帮助我们加速并保护应用程序的生命周期。你还将学习如何为你的应用程序创建安全的容器镜像,并如何在不同的开发阶段和生产环境中共享和运行这些镜像。

本书分为不同部分,帮助你使用软件容器技术。首先,你将学习如何使用主机环境构建和运行应用程序,使用多个组件,然后你将学习如何在复杂的容器编排器中分布式运行它们。

本书聚焦于 Kubernetes 容器编排器,因为它具有独特的功能和广泛的流行度。你将学习如何使用不同的 Kubernetes 资源来安全地完成各种应用架构模型。书中还将介绍不同的软件选项,帮助你在桌面上使用完全功能的 Kubernetes 打包平台来创建和测试应用程序。我们将教你如何使用这些 Kubernetes 桌面环境来准备应用程序,并采用最佳安全实践,在任何 Kubernetes 平台上交付它们。我们将涵盖重要的主题,如数据管理(配置、敏感数据和应用数据)、发布应用程序的不同机制,以及应用可观察性(监控、日志和可追溯性)。

最后,我们将向你展示如何自动化构建、测试和交付应用程序,创建在 Kubernetes 集群中运行的持续集成和持续交付工作流。

本书适合的读者

本书适用于软件开发生命周期中的不同角色:

  • 学习如何使用软件容器准备应用程序的开发人员,应用程序在容器编排器和现代微服务架构中分布式运行。

  • 需要实现安全软件供应链的 DevOps 人员,使用软件容器技术。

本书内容

第一章使用 Docker 构建现代基础设施和应用程序,解释了软件架构的发展及微服务如何与基于容器的应用程序契合,原因在于它们的特殊功能和特点。

第二章构建 Docker 镜像,教你什么是容器镜像,解释了镜像的层次模型及如何使用 Dockerfile 构建这些镜像,采用最佳安全实践。

第三章发布 Docker 镜像,展示了如何存储和共享你项目的容器镜像。

第四章运行 Docker 容器,介绍了如何使用不同的容器客户端运行容器,解释了如何管理容器的隔离、安全性和资源使用。

第五章创建多容器应用程序,教你如何基于多个组件使用容器运行应用程序,并使用 Docker Compose 在不同环境中构建、运行和部署应用程序。

第六章编排基础知识,介绍了容器编排的概念,用于定义和管理分布式容器运行时中的应用程序组件逻辑,作为集群的一部分运行。

第七章使用 Swarm 进行编排,探讨了 Docker Swarm 编排器,并通过示例展示了其功能和使用方法。

第八章使用 Kubernetes 编排器部署应用程序,介绍了 Kubernetes 编排器,展示了其组件和功能,并解释了如何准备应用程序在自己的 Kubernetes 平台上运行。

第九章实施架构模式,展示了如何利用 Kubernetes 的独特功能交付和保护不同的应用程序架构模型。

第十章在 Kubernetes 中利用应用程序数据管理,深入探讨了用于管理敏感、临时和持久分布式数据的不同 Kubernetes 资源。

第十一章发布应用程序,描述了不同的架构策略,以安全地在 Kubernetes 上发布应用程序前端。

第十二章获取应用程序洞察,介绍了在 Kubernetes 中管理应用程序可观察性,使用开源工具监控指标并提供日志记录和追踪功能。

第十三章管理应用程序生命周期,介绍了应用程序软件生命周期的概念和阶段,涵盖了如何通过使用容器来管理它们。本章还深入探讨了如何通过持续集成和持续部署模型来自动化和改进生命周期。

为了充分利用本书内容

本书中,我们将使用不同的开源工具来创建、运行和共享软件容器。本书提供了不同的实验环境,你可以选择自己最舒适的环境进行操作。为了跟随本书中的实验,你需要以下内容:

  • 推荐使用一台普通的笔记本电脑或台式机,配备现代 CPU(如 Intel Core i5 或 i7,或相当的 AMD CPU)和 16GB 内存。你可能能在资源较低的情况下运行实验,但体验可能会受到影响。

  • 一种 Microsoft Windows 或 Linux 操作系统。尽管本书将提到 Linux,你也可以选择使用任意一种操作系统。

  • 你需要具备一定的 Linux/Windows 用户级别知识。

  • 具有 Go、JavaScript、Java 或 .NET Core 等常见编程语言的编程经验会很有帮助,尽管示例并不复杂,易于跟随。

书中涉及的 软件/硬件 操作系统 要求
Docker 和其他软件容器工具 Windows、macOS 或 Linux
Docker Swarm 调度器 Windows、macOS 或 Linux
Kubernetes 调度器桌面环境,如 Docker Desktop、Rancher Desktop 和 Minikube Windows、macOS 或 Linux
监控、日志记录和追踪工具,如 Prometheus、Grafana Loki 和 OpenTelemetry Windows、macOS 或 Linux

实验中使用的 Kubernetes 特性将在任何上述操作系统上运行。如果底层平台需要特殊的特性,会在实验中描述。

如果你使用的是本书的数字版本,建议你自己输入代码,或者从本书的 GitHub 仓库中访问代码(链接将在下一节提供)。这样可以避免由于复制和粘贴代码而导致的潜在错误。

下载示例代码文件

您可以从 GitHub 下载本书的示例代码文件,地址为 github.com/PacktPublishing/Containers-for-Developers-Handbook。如果代码有更新,GitHub 仓库中的代码会随之更新。

我们还提供来自我们丰富图书和视频目录中的其他代码包,您可以在 github.com/PacktPublishing/ 访问。快来看看吧!

实践中的代码

本书的 实践中的代码 视频可以在 packt.link/JdOIY 查看。

使用的约定

本书中使用了若干文本约定。

文本中的代码:表示文本中的代码字、数据库表名、文件夹名称、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名。例如:“我们可以通过执行 docker image inspect 来验证系统的镜像并查看其信息。”

一段代码块的格式如下:

apiVersion: apps/v1
kind: ReplicaSet
metadata:
  name: replicated-webserver
spec:
  replicas: 3
  selector:
    matchLabels:
      application: webserver
  template:
     metadata:
         application: webserver
     spec:
       containers:
       - name: webserver-container
         image: docker.io/nginx:alpine

所有命令行输入或输出如下所示:

$ docker rm -f webserver webserver2

粗体:表示新术语、重要词汇或屏幕上显示的词语。例如,菜单或对话框中的词语会以 粗体 显示。示例如下:“你可以通过快速访问 Docker Desktop 设置,导航至 Settings | Resources | WSL Integration 来验证这一点。”

提示或重要说明

显示如下。

联系我们

我们非常欢迎读者的反馈。

一般反馈:如果您对本书的任何部分有疑问,请通过电子邮件联系我们:customercare@packtpub.com,并在邮件主题中提及书名。

勘误表:尽管我们已尽力确保内容的准确性,但错误仍然可能发生。如果您在本书中发现错误,我们将非常感激您向我们报告。请访问 www.packtpub.com/support/errata 并填写表格。

盗版:如果您在互联网上发现任何我们作品的非法副本,我们将非常感谢您提供其所在地址或网站名称。请通过 copyright@packt.com 与我们联系,并附上链接。

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

分享您的想法

一旦您阅读完《开发者容器手册》,我们非常希望听到您的想法!请点击这里直接进入亚马逊评价页面并分享您的反馈。

您的评价对我们和技术社区非常重要,能帮助我们确保提供卓越的优质内容。

下载本书的免费 PDF 版本

感谢您购买本书!

您喜欢随时随地阅读,但又无法携带实体书籍?您的电子书购买是否与您选择的设备不兼容?

不用担心,现在每本 Packt 图书您都可以免费获得该书的 DRM 无保护 PDF 版本。

随时随地,在任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制并粘贴代码到您的应用程序中。

福利不仅如此,您还可以独享折扣、时事通讯,以及每日通过邮箱收到优质的免费内容。

请按照以下简单步骤获得这些好处:

  1. 扫描二维码或访问以下链接

packt.link/free-ebook/978-1-80512-798-7

  1. 提交您的购买凭证

  2. 就是这样!我们将直接通过电子邮件发送您的免费 PDF 和其他福利。

第一部分:容器的关键概念

本部分将解释软件容器的关键概念。我们将学习它们的主要特性,如何利用操作系统的常见功能实现默认安全概念,以及如何在 Linux 和 Windows 环境中基于容器创建和部署应用程序。

本部分包含以下章节:

  • 第一章使用 Docker 构建现代基础设施与应用程序

  • 第二章构建 Docker 镜像

  • 第三章发布 Docker 镜像

  • 第四章运行 Docker 容器

  • 第五章, 创建多容器应用程序

第一章:1

使用 Docker 的现代基础设施与应用程序

软件工程与开发一直在不断发展,引入新技术到架构和工作流中。软件容器在十多年前就已出现,并在过去五年中特别流行,得益于 Docker 的普及,使得这一概念成为主流。目前,每个企业都在云端和本地分布式基础设施上管理其基于容器的应用程序基础设施。本书将教你如何通过使用软件容器来提高开发生产力,从而创建、测试、共享和运行你的应用程序。你将使用基于容器的工作流,最终的应用程序构件将是基于 Docker 镜像的部署,准备在生产环境中运行。

本章将介绍软件容器,尤其是在当前软件开发文化中的应用。现代开发文化需要更快速的软件供应链,由可移动、分布式的组件组成。我们将回顾容器的工作原理以及它们如何适应基于分布式组件(具有非常特定功能的微服务)的现代应用程序架构。这使得开发人员可以为每个应用组件选择最合适的语言,并分散整个应用程序的负载。我们将学习使软件容器成为可能的内核特性,并学习如何创建、共享和运行作为软件容器的应用程序组件。在本章的最后,我们将了解不同的工具,帮助我们使用软件容器,并为你的笔记本电脑、台式机和服务器提供具体的应用场景。

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

  • 应用程序架构的演变,从单体架构到分布式微服务架构

  • 开发基于微服务的应用程序

  • 容器如何适应微服务模型

  • 理解软件容器的主要概念、特性和组成部分

  • 比较虚拟化和容器

  • 构建、共享和运行容器

  • 解释 Windows 容器

  • 使用软件容器提升安全性

技术要求

本书将教你如何使用软件容器来提高应用程序开发效率。我们将使用开源工具来构建、共享和运行容器,并结合一些不需要专业授权的商业工具。此外,本书还包含一些实验,帮助你实践理解我们所讨论的内容。这些实验可以在github.com/PacktPublishing/Containers-for-Developers-Handbook/tree/main/Chapter1找到。本章的Code In Action视频可以在packt.link/JdOIY找到。

从单体架构到分布式微服务架构

随着技术的进步,应用架构不断演变。在计算历史上,每当硬件和软件工程中的技术差距得到解决时,软件架构师就会重新思考如何改进应用,以利用这些新的技术进展。例如,网络速度的提升使得将应用组件分布到不同服务器成为可能,如今,甚至将这些组件分布到多个国家的数据中心也不成问题。

要快速了解计算机是如何被企业采纳的,我们必须回到早期的主机时代(1990 年代之前)。这可以被视为我们今天所称的单体架构的基础——一台拥有所有处理功能的大型计算机,用户通过终端进行访问。在此之后,随着用户端技术的进步,客户端-服务器模型变得非常流行。服务器技术不断改进,而客户端则获得了越来越多的功能,减轻了服务器的负载,从而支持应用发布。我们认为这两种模型都是单体的,因为所有应用组件都运行在同一台服务器上;即使数据库与其他组件解耦,将所有重要组件运行在专用服务器上,仍然被视为单体架构。这两种模型在性能下降时都很难升级。在这种情况下,通常需要更高规格的硬件。这些模型也存在可用性问题,即任何对服务器或应用层的维护任务都可能导致服务中断,进而影响系统的正常运行时间。

探索单体应用

单体应用是指所有功能由一个组件或一组紧密集成的组件提供,这些组件彼此之间无法解耦,从而使得它们的维护变得困难。它们的设计并未考虑重用性或模块化,意味着每当开发人员需要修复一个问题、添加新功能或改变应用的行为时,整个应用都会受到影响,例如,可能需要重新编译整个应用程序的代码。

为单体应用程序提供高可用性需要重复的硬件、仲裁资源和应用程序节点之间的持续可见性。虽然今天这一点可能没有太大变化,但我们现在拥有许多其他资源来提供高可用性。随着应用程序复杂性的增加,它们开始承担更多任务和功能,我们开始将其解耦成几个较小的组件(例如,Web 服务器、数据库等具有特定功能的组件),尽管核心组件保持不变。将所有应用程序组件运行在同一台服务器上比将它们分散到更小的部分更为合适,因为当时的网络通信速度并不够快。通常,使用本地文件系统在应用程序进程之间共享信息。这些应用程序的扩展性差(需要更多的硬件资源,通常导致需要购买更先进的服务器),并且升级困难(生产前需要相同或至少兼容的硬件来进行测试、预发布和认证环境的构建)。事实上,一些应用程序只能在特定的硬件和操作系统版本上运行,开发人员需要具备相同硬件或操作系统的工作站或服务器才能开发修复程序或新功能。

既然我们已经了解了早期应用程序的设计方式,现在让我们介绍一下数据中心中的虚拟化技术。

虚拟机

虚拟化的概念——为特定目的提供一组物理硬件资源——早在 1990 年代前就已经出现在大型机时代,但在那个时期,它更接近于计算级别的时间共享定义。我们通常所说的虚拟化概念源于 1990 年代末期引入的虚拟机监控器(hypervisor)及新技术,它使得能够创建运行自己虚拟操作系统的完整虚拟服务器。这个虚拟机监控器软件组件能够在虚拟化的客户操作系统中虚拟化并共享主机资源。在 1990 年代,微软 Windows 的普及和 Linux 作为企业级服务器操作系统的崛起,使得 x86 服务器成为行业标准,虚拟化技术推动了这两者在数据中心的增长,提高了硬件的利用率并促进了服务器的升级。当应用程序需要更多内存或 CPU 时,虚拟化层简化了虚拟硬件的升级过程,也提高了高可用性服务的提供过程。随着新的服务器能够运行数十个虚拟服务器,数据中心变得更加紧凑,而随着物理服务器硬件能力的提升,每个节点上虚拟化的服务器数量也在增加。

在 1990 年代末期,服务器变成了服务。这意味着公司开始考虑他们提供的服务,而不是如何提供服务。云服务提供商应运而生,向那些不想拥有和维护自己数据中心的小型企业提供服务。因此,创建了一种新的架构模型,并变得相当流行:云计算基础设施模型。亚马逊推出了亚马逊网络服务AWS),提供存储、计算、数据库以及其他基础设施资源。很快,弹性计算云(Elastic Compute Cloud)进入了虚拟化领域,允许用户通过几次点击就能运行自己的服务器。云服务提供商还允许用户使用他们的文档完备的应用程序编程接口API)进行自动化,并引入了基础设施即代码IaC)的概念。我们可以通过编程和可重用的代码创建虚拟化实例。这个模型还改变了服务/硬件的关系,最初作为一个好主意——将云平台用于每个企业服务——最终变成了大企业的问题,这些企业很快就看到了基于网络带宽使用的成本增加,且由于没有充分控制其云资源的使用,成本进一步上升。控制云服务成本很快成为许多企业的优先事项,许多开源项目也基于提供云类基础设施的前提开始了。基础设施弹性简易配置是这些项目的关键。OpenStack 是第一个,它被分发成多个小项目,每个项目专注于不同的功能(存储、网络、计算、配置等)。拥有本地云基础设施的想法促使软件和基础设施供应商建立新的合作伙伴关系,最终为数据中心提供了具有所需灵活性和资源分配的新技术。他们还提供了用于快速部署和管理配置基础设施的 API,现在,我们可以使用相同的代码,仅需少量更改,就能配置云基础设施资源或我们数据中心的资源。

现在,我们已经对今天的服务器基础设施有了清晰的了解,接下来让我们回到应用程序。

三层架构

即使有了这些解耦的基础设施,如果我们没有将应用程序准备好分离成不同的组件,它们仍然可能是单体应用。弹性基础设施允许我们分配资源,最好能够拥有分布式组件。网络通信至关重要,技术的进步提高了速度,使我们能够像使用本地服务一样使用网络提供的服务,并促进了分布式组件的使用。

三层架构是一种软件应用架构,其中应用被解耦成三到五个逻辑和物理计算层。我们有表示层,即用户界面;应用层,即后台,数据在此被处理;以及数据层,应用所需的数据在此存储和管理,比如在数据库中。即使在虚拟化技术出现之前,这种模型也已经被使用,但你可以想象,能够将应用组件分布到不同的虚拟服务器上,而不是增加数据中心中服务器的数量,这样的改进。

在继续我们的旅程之前,先回顾一下:基础设施和网络通信的发展使我们能够运行组件分布式的应用,但在三层模型中,每个应用只有少量组件。请注意,在这个模型中,由于通常采用不同的软件技术,不同的角色参与到应用的维护中。例如,我们需要数据库管理员、中间件管理员以及系统和网络通信的基础设施管理员。在这个模型中,尽管我们仍然不得不使用服务器(虚拟或物理),但应用组件的维护、可扩展性和可用性得到了显著提高。我们可以独立管理每个组件,执行不同的维护任务和修复,并在不依赖于应用核心的情况下增加新功能。在这个模型中,开发人员可以专注于前端或后端组件。一些编程语言专门为每一层设计——例如,JavaScript 是前端开发人员的首选语言(尽管它也发展成为后端服务的语言)。

随着 Linux 系统在 1990 年代末期的普及,应用被分布成不同的组件,最终,不同的应用在不同操作系统上协同工作,成为一种新的需求。最初通过网络文件系统提供的共享文件(使用网络附加存储NAS)或更复杂的存储区域网络SAN)存储后端)被使用,但简单对象访问协议SOAP)和其他队列消息技术帮助应用在组件之间分发数据并管理其信息,而无需与文件系统交互。这有助于将应用解耦成更多分布式组件,运行在不同的操作系统之上。

微服务架构

微服务架构模型更进一步,将应用程序解耦为足够小的组件,每个组件都有足够的功能,可以视为独立的模块。该模型使我们能够管理完全独立的组件生命周期,允许我们选择最适合该功能的编程语言。应用程序组件在功能和内容方面保持轻量,这应该使它们占用更少的主机资源,并能够更快地响应启动和停止命令。更快的重启对于系统的弹性至关重要,并帮助我们在应用程序运行时减少故障停机。应用程序的健康状况不应依赖于组件外部的基础设施;我们应改进组件的逻辑和弹性,使其能够尽可能快速地启动和停止。这意味着我们可以确保应用程序的变更能够迅速应用,并且在发生故障时,所需的进程能在几秒钟内启动。这还帮助我们管理应用程序组件的生命周期,因为我们可以非常快速地升级组件,并准备断路器来管理停止的依赖。

微服务采用无状态范式;因此,应用组件应该是无状态的。这意味着微服务的状态必须与其逻辑或执行过程相分离。这对于能够运行多个副本的应用组件至关重要,使我们能够在不同节点上分布运行它们。

该模型还引入了随处运行的概念,即应用程序应该能够在云端或本地基础设施上运行其组件,甚至是两者的混合(例如,组件的展示层可以运行在云基础设施上,而数据则存储在我们的数据中心)。

微服务架构提供以下有用特性:

  • 应用程序被解耦为多个较小的部分,提供不同的特性或功能;因此,我们可以随时更改其中任何一部分,而不会影响整个应用程序。

  • 将应用程序解耦为更小的部分,使开发人员可以专注于特定的功能,并允许他们为每个组件使用最合适的编程语言。

  • 应用组件之间的交互通常通过表现性状态转移REST)API 调用使用 HTTP 来提供。RESTful 系统旨在实现快速的性能和可靠性,并能够无障碍地扩展。

  • 开发人员描述他们的微服务提供哪些方法、操作和数据,这些方法、操作和数据会被其他开发人员或用户使用。软件架构师必须标准化应用组件之间的交互方式以及微服务的使用方式。

  • 将应用组件分布在不同的节点上,能够将微服务分组到节点中,以获得最佳的性能,更接近数据源并具备更好的安全性。我们可以创建具有不同特性的节点,以便为我们的应用组件提供最合适的环境。

现在我们已经了解了什么是微服务架构,让我们来看看它对开发过程的影响。

开发分布式应用程序

单体应用程序,正如我们在上一节中看到的,是所有功能共同运行的应用程序。这些应用程序大多数是为特定的硬件、操作系统、库、二进制版本等创建的。要在生产环境中运行这些应用程序,你至少需要一台配备正确硬件、操作系统、库等的专用服务器,而开发人员即便只是为了修复可能的应用问题,也需要类似的节点架构和资源。更不用说,像认证和测试等任务的预生产环境将显著增加服务器的数量。即使你的企业有足够的预算来购买这些服务器,任何由于操作系统相关组件升级导致的维护任务,都必须在所有其他环境中进行复制。这时,自动化有助于在环境之间复制更改,但这并不容易。你必须复制并维护这些环境。另一方面,过去(在虚拟化出现之前)新节点的配置可能需要数月时间(准备新节点的规格、制定预算、提交到公司审批流程、寻找硬件供应商等)。虚拟化帮助系统管理员为开发人员更快地配置新节点,自动化工具(如 Chef、Puppet,以及我最喜欢的 Ansible)帮助对所有环境之间的更改进行对齐。因此,开发人员能够迅速获取他们的开发环境,并确保他们使用的是对齐版本的系统资源,从而提高了应用程序维护的效率。

虚拟化与三层应用架构的兼容性也非常好。开发人员在需要连接数据库服务器来编写新更改时,能够轻松运行应用组件。虚拟化的问题出在复制一个完整操作系统及其服务器应用组件的概念上,而我们只需要软件部分。仅操作系统就消耗了大量硬件资源,且由于这些节点运行的是一个完整的操作系统,该系统又位于一个虚拟机监控器上,而虚拟机监控器又运行在一台具有自己操作系统的物理服务器上,因此重启这些节点需要一些时间。

不过,开发者受限于过时的操作系统版本和软件包,这使得他们难以推动应用程序的发展。系统管理员开始管理数百个虚拟主机,即使使用自动化,也无法保持操作系统和应用生命周期的同步。通过使用云提供商的基础设施即服务IaaS)平台,或者使用平台即服务PaaS)环境,并通过其 API(IaC)脚本化基础设施,虽然有所帮助,但由于应用程序数量的快速增长以及所需的更改,问题并未完全解决。应用生命周期从每年一两次更新变成了每天几十次更新。

开发者开始使用云提供的服务,并且使用脚本和应用程序变得比运行它们的基础设施更为重要,这在今天看起来是完全正常和合乎逻辑的。更快的网络通信和分布式可靠性使得我们能够更容易地在任何地方部署应用程序,数据中心也变得越来越小。我们可以说,正是开发者推动了这个运动,并且它变得如此流行,以至于我们最终将应用组件从底层操作系统中解耦。

软件容器是计算机历史发展中学到的进程隔离特性的演进。多年前,大型机计算机使我们能够共享 CPU 时间和内存资源。Chroot 和 jail 环境是共享操作系统资源的常见方式,用户可以使用系统管理员为他们在 BSD 操作系统中准备的所有二进制文件和库。在 Solaris 系统中,我们有区域作为资源容器,充当单一操作系统实例内完全隔离的虚拟服务器。

那么,为什么我们不直接隔离进程,而不是完整的操作系统呢?这就是容器背后的主要思想。容器利用内核特性,在操作系统层面提供进程隔离,所有进程运行在同一主机上,但彼此隔离。因此,每个进程都有自己的一套资源,分享同一个主机内核。

自 2000 年代末以来,Linux 内核就以控制组cgroups)的形式具备了这一进程分组的设计。这个特性允许 Linux 内核管理、限制和审计进程组。

另一个与容器一起使用的非常重要的 Linux 内核特性是内核命名空间,它允许 Linux 运行与其进程层次结构绑定的进程,同时具备自己的网络接口、用户、文件系统挂载和进程间通信。通过使用内核命名空间和控制组,我们可以完全隔离操作系统中的进程。它将像在自己的操作系统中运行一样,使用自己有限的 CPU 和内存(我们甚至可以限制它的磁盘 I/O)。

Linux 容器LXC)项目进一步发展了这一理念,创造了第一个可行的实现。这个项目仍然存在,并且还在持续进展,它是我们现在所知的Docker 容器的关键。LXC 引入了诸如模板等术语,用来描述使用内核命名空间创建封装进程。

Docker 容器将所有这些概念结合起来,创建了 Docker Inc.,一个开源项目,使得在我们的系统上运行软件容器变得简单。容器带来了一场伟大的革命,就像虚拟化在 20 多年前所做的那样。

回到微服务架构,理想的应用解耦意味着将定义和特定的应用功能作为完全独立且隔离的进程运行。这促生了将微服务应用组件运行在容器内的理念,且操作系统开销最小。

什么是容器?

我们可以将容器定义为一个进程,所有的需求都通过 cgroups 和命名空间内核功能被隔离。进程是我们在操作系统内执行任务的方式。如果我们将程序定义为使用编程语言开发的指令集,并以可执行格式存储在磁盘上,那么我们可以说,进程就是程序在运行。

进程的执行涉及使用一些系统资源,如 CPU 和内存,尽管它在自己的环境中运行,但它可以使用与其他共享同一主机系统的进程相同的信息。

操作系统提供了在执行过程中操控进程行为的工具,允许系统管理员优先处理关键进程。每个在系统上运行的进程都有一个唯一的进程标识符PID)。当一个进程在执行过程中执行一个新进程(或创建一个新线程)时,会产生进程之间的父子关系。新创建的进程(或子进程)将以之前的进程为父进程,依此类推。操作系统使用 PID 和父 PID 存储进程关系的信息。进程可能会继承从运行它们的用户那里来的父级层级,因此用户拥有并管理自己的进程。只有管理员和特权用户能够与其他用户的进程交互。这种行为同样适用于我们执行时创建的子进程。

每个进程都在自己的环境中运行,我们可以使用操作系统的功能来操控它的行为。进程可以根据需要访问文件,并在执行过程中使用指针来描述符来管理这些文件系统资源。

操作系统内核管理所有进程,将它们调度到物理或虚拟化的 CPU 上,分配适当的 CPU 时间,并为它们提供内存或网络资源(等等)。

这些定义适用于所有现代操作系统,并且是理解软件容器的关键,我们将在下一节详细讨论。

理解容器的主要概念

我们已经了解到,与虚拟化不同,容器是运行在隔离环境中的进程,并共享主机操作系统的内核。在本节中,我们将回顾使容器成为可能的各个组件。

内核进程隔离

我们已经介绍了内核进程命名空间隔离作为运行软件容器的关键特性。操作系统内核提供基于命名空间的隔离。自 2006 年以来,这一特性就存在于 Linux 内核中,并提供与进程在主机上运行时的属性或特征相关的不同隔离层级。当我们将这些命名空间应用于进程时,它们会运行自己的属性集,并且看不到与它们并行运行的其他进程。因此,内核资源被分割,使每组进程看到不同的资源集。资源可以存在于多个空间中,进程可能会共享这些资源。

容器作为主机进程运行,具有自己的内核命名空间集,如下所示:

  • 进程:容器的主进程是容器内其他进程的父进程。所有这些进程共享同一进程命名空间。

  • 网络:每个容器都分配一个独特的网络栈,包含唯一的接口和 IP 地址。共享同一网络命名空间的进程(或容器)将获得相同的 IP 地址。容器之间的通信通过主机桥接接口进行。

  • 用户:容器内的用户是独立的;因此,每个容器都有自己的用户集,但这些用户会映射到主机的实际用户标识符。

  • 进程间通信IPC):每个容器都会获得一组独立的共享内存、信号量和消息队列,从而避免与主机上其他进程发生冲突。

  • 挂载:每个容器挂载一个根文件系统;我们还可以附加远程和主机本地挂载。

  • Unix 时间共享UTS):每个容器都会被分配一个主机名,并且时间会与底层主机同步。

在容器内运行的进程共享同一内核命名空间时,将获得类似于单独在自己内核内运行的 PID。容器的主进程被分配 PID 1,其他子进程或线程将获得后续 ID,继承主进程的层次结构。如果主进程死亡(或被停止),容器也会死掉。

下图展示了我们的系统如何管理容器 PID 在容器的 PID 命名空间(由灰色框表示)内外的分配:

图 1.1 – 展示执行一个带有四个工作进程的 NGINX web 服务器时 PID 层次结构的示意图

图 1.1 – 展示执行一个带有四个工作进程的 NGINX web 服务器时 PID 层次结构的示意图

在前面的图中,容器内运行的主进程被分配为 PID 1,而其他进程是其子进程。主机运行自己的 PID 1 进程,所有其他进程与这个初始进程关联运行。

控制组

cgroup 是 Linux 内核提供的一项功能,允许我们限制和隔离与进程相关的主机资源(例如 CPU、内存和磁盘 I/O)。它提供了以下功能:

  • 资源限制:通过使用 cgroup 限制主机资源,因此进程可以使用的资源数量(包括 CPU 或内存)是有限的

  • 优先级:如果观察到资源争用,可以控制与另一个 cgroup 中的进程相比,进程可以使用的主机资源(CPU、磁盘或网络)数量

  • 计账:Cgroups 在 cgroup 层级监控并报告资源限制的使用情况

  • 控制:我们可以管理 cgroup 中所有进程的状态

cgroup 的隔离机制不会允许容器通过耗尽主机资源来使主机崩溃。有趣的是,你可以在没有软件容器的情况下使用 cgroup,只需挂载一个 cgroup(cgroup 类型系统),调整该组的 CPU 限制,最后将一组 PID 添加到该组中。这个过程适用于 cgroups-V1 或更新的 cgroups-V2。

容器运行时

容器运行时,或称为 容器引擎,是一种在主机上运行容器的软件。它负责从注册表下载容器镜像以创建容器,监控主机上可用的资源以运行这些镜像,并管理操作系统提供的隔离层。容器运行时还会检查容器的当前状态并管理其生命周期,在主进程死亡时重新启动(如果我们声明容器在这种情况下可以随时恢复)。

我们通常将容器运行时分为 低级容器运行时高级容器运行时

低级容器运行时是那些仅专注于软件容器执行的简单运行时。我们可以考虑对二进制文件和库执行 ldd 命令,并迭代所有依赖项的过程。这样我们就能得到一个完整的文件列表,列出所有进程严格需要的文件,这将成为应用程序的最小镜像。

高级容器运行时通常实现 OCI 的 容器运行时接口 (CRI) 规范。这是为了使容器编排更具运行时无关性。在这一组中,我们有 Docker、CRI-O 和 Windows/Hyper-V 容器。

CRI 接口定义了规则,使我们能够将容器运行时集成到容器编排器中,例如 Kubernetes。容器运行时应具备以下特点:

  • 能够启动/停止 pod

  • 处理所有容器(启动、暂停、停止和删除它们)

  • 管理容器镜像

  • 提供度量收集和容器日志访问

Docker 容器运行时在 2016 年成为主流,使得用户可以轻松执行容器。CRI-O 是由 Red Hat 专门为 Kubernetes 调度器创建的,旨在使用任何符合 OCI 标准的低级运行时执行容器。高级运行时提供与它们交互的工具,这也是大多数人选择它们的原因。

Containerd 作为一种行业标准的容器运行时,提供了低级和高级容器运行时之间的中间地带。它可以在 Linux 和 Windows 上运行,并且可以管理整个容器生命周期。

运行时背后的技术发展非常迅速;我们甚至可以通过沙箱技术(Google 的gVisor)和虚拟化运行时(Kata Containers)来改善容器和宿主机之间的交互。前者通过不与宿主机共享内核来增加容器的隔离性。容器提供一个特定的内核(具有限制能力的小unikernel)作为代理,来替代真实的内核。而虚拟化运行时则使用虚拟化技术将容器隔离在一个非常小的虚拟机中。虽然这两种情况都会给底层操作系统增加一些负担,但通过容器不直接与宿主机内核交互,安全性得到了提升。

容器运行时仅审查主进程的执行。如果容器内的其他进程死亡且不影响主进程,容器将继续运行。

内核能力

从 Linux 内核 2.2 版本开始,操作系统将进程权限划分为不同的单元,称为能力。这些能力可以由操作系统和系统管理员启用或禁用。

我们之前了解到,容器通过使用宿主机的内核来运行进程。但重要的是要知道,除非显式声明,否则只有一小部分内核能力允许在容器内使用。因此,容器提高了进程在宿主机级别的安全性,因为这些进程不能做任何它们想做的事情。当前在基于 Docker 容器运行时运行的容器内可用的能力包括SETPCAPMKNODAUDIT_WRITECHOWNNET_RAWDAC_OVERRIDEFOWNERFSETIDKILLSETGIDSETUIDNET_BIND_SERVICESYS_CHROOTSETFCAP

这一套功能允许,例如,容器内的进程附加并监听低于1024端口(NET_BIND_SERVICE能力)或使用 ICMP(NET_RAW能力)。

如果我们在容器内的进程需要,例如,创建一个新的网络接口(可能是为了运行一个容器化的 OpenVPN 服务器),应该包含NET_ADMIN能力。

重要提示

容器运行时允许容器以完全权限运行,使用特殊的参数。这些容器中的进程将使用所有内核功能,这可能非常危险。您应该避免使用特权容器——最好花些时间验证应用程序正确运行所需的功能。

容器编排工具

现在我们知道我们需要一个运行时来执行容器,我们还必须理解,这将在一个独立的环境中工作,且没有硬件高可用性。这意味着服务器维护、操作系统升级以及在软件、操作系统或硬件层面上的任何问题都可能影响您的应用程序。

高可用性需要资源的冗余,因此需要更多的服务器和/或硬件。这些资源将允许容器在多个主机上运行,每个主机都有一个容器运行时。然而,在这种情况下维持应用程序的可用性并不容易。我们需要确保容器能够在这些节点中的任何一个上运行;在覆盖文件系统一节中,我们将了解到,同步节点内与容器相关的资源不仅仅是复制几个文件。容器编排工具管理节点资源并将其提供给容器。它们根据需要调度容器,处理容器状态,为持久性提供资源,并管理内部和外部通信(在第六章《编排基础》中,我们将学习一些编排工具如何将其中的一些功能委派给不同的模块,以优化它们的工作)。

当今最著名且广泛使用的容器编排工具是Kubernetes。它有很多很棒的功能,帮助管理集群容器,尽管学习曲线可能较为陡峭。此外,Docker Swarm非常简单,并且允许您快速执行具有高可用性(或弹性)的应用程序。我们将在第七章《使用 Swarm 进行编排》和第八章《使用 Kubernetes 编排器部署应用程序》中详细讲解这两者。在这场竞争中还有其他对手,但它们都被抛在了后头,而 Kubernetes 则占据了主导地位。

HashiCorp 的 Nomad 和 Apache 的 Mesos 仍然用于一些非常特殊的项目,但对于大多数企业和用户来说超出了范围。Kubernetes 和 Docker Swarm 是社区项目,一些厂商甚至将它们包含在企业级解决方案中。Red Hat 的 OpenShift、SUSE 的 Rancher、Mirantis 的 Kubernetes Engine(旧版 Docker 企业平台)和 VMware 的 Tanzu 等,提供了本地部署的以及部分云准备的定制 Kubernetes 平台。但使 Kubernetes 成为最常用平台的是那些著名的云提供商——Google、Amazon、Azure 和 Alibaba 等,他们提供自己的容器编排工具,如 Amazon 的 弹性容器服务Fargate,Google 的 Cloud Run,以及 Microsoft 的 Azure 容器实例,他们还为我们打包并管理自己的 Kubernetes 基础设施(Google 的 GKE,Amazon 的 EKS,Microsoft 的 AKS 等)。他们提供 Kubernetes 即服务 平台,你只需要一个账户就可以开始部署应用程序。他们还为你提供存储、先进的网络工具、发布应用程序的资源,甚至是 跟随太阳 或全球分布式架构。

Kubernetes 有很多实现。最流行的可能是 OpenShift 或其开源项目 OKD。还有一些基于二进制文件的项目,通过自动化程序启动并创建所有 Kubernetes 组件,如 Rancher RKE(或其政府版本 RKE2),以及仅包含严格必要的 Kubernetes 组件的项目,如 K3S 或 K0S,以提供最轻量的平台,适用于物联网和更谦逊的硬件。最后,我们还有一些 Kubernetes 发行版,专为桌面计算机提供,具备 Kubernetes 所有功能,准备好用于开发和测试应用程序。在这个组中,我们有 Docker Desktop、Rancher Desktop、Minikube 和 Kubernetes in DockerKinD)。我们将在本书中学习如何使用它们来开发、打包和准备生产环境的应用程序。

我们不应忽视基于多个容器在独立服务器或桌面计算机上运行编排应用程序的解决方案,例如Docker Compose。Docker 为我们准备了一个简单的基于 Python 的编排工具,用于快速应用程序开发,管理容器依赖关系。这对于在笔记本电脑上以最小开销测试我们所有组件非常方便,而不是运行完整的 Kubernetes 或 Swarm 集群。我们将在第五章中介绍这个工具,创建 多容器应用程序,因为它已经发展了很多,并且现在是常见的 Docker 客户端命令行的一部分。

容器镜像

本章前面提到,容器能够运行是因为有了容器镜像,这些镜像作为模板用于在隔离环境中执行进程并附加到文件系统上;因此,容器镜像包含了其进程所需的所有文件(如二进制文件、库、配置文件等)。这些文件可以是某些操作系统的子集,或者只是由你自己构建的少量二进制文件和配置。

虚拟机模板是不可变的,容器模板也是如此。这个不可变性意味着它们在执行之间不会发生变化。这个特性非常关键,因为它确保每次使用镜像创建容器时,我们都会得到相同的结果。容器的行为可以通过容器运行时的配置或命令行参数进行更改。这确保了开发人员创建的镜像在生产环境中能够按预期工作,并且将应用程序迁移到生产环境(甚至在不同版本之间创建升级)将变得平滑且快速,从而缩短上市时间。

容器镜像是分层分发的文件集合。我们不应添加除应用程序所需文件以外的任何内容。由于镜像是不可变的,这些层会作为只读文件集呈现给容器化进程。但我们不会在层之间重复文件。只有在某一层上修改的文件会存储在上面一层中——这样,每一层都会保留来自原始基础层(称为基础镜像)的更改。

以下图示展示了如何使用多个层创建容器镜像:

图 1.2 – 表示容器镜像的堆叠层次结构示意图

图 1.2 – 表示容器镜像的堆叠层次结构示意图

基础层始终会包含,即使它是空的。位于基础层之上的层可能包含新的二进制文件,或者仅包含新的元信息(这不会创建一个层,而只是修改元信息)。

为了方便在计算机之间或不同环境之间共享这些模板,这些文件层会被打包成 .tar 文件,这些文件最终就被称为镜像。这些包包含了所有的层文件,以及描述内容的元信息,指定要执行的进程,标识将暴露出来以与其他容器化进程进行通信的端口,指定将拥有该镜像的用户,指示将在容器生命周期中保持不变的目录等信息。

我们使用不同的方法来创建这些镜像,但我们的目标是使这个过程可重复,因此我们使用 Dockerfile 作为配方。在第二章《构建容器镜像》中,我们将学习镜像创建的工作流程,同时使用最佳实践并深入探讨命令行选项。

这些容器镜像存储在注册表中。该应用软件旨在将文件层和元信息存储在一个集中位置,使得在不同镜像之间共享公共层变得容易。这意味着,两个使用相同 Debian 基础镜像(来自完整操作系统的文件子集)的镜像将共享这些基础文件,从而优化磁盘空间的使用。这也可以在容器的底层主机本地文件系统上使用,节省大量空间。

使用这些镜像层的另一个结果是,使用相同模板镜像执行其进程的容器将使用相同的文件集,只有被修改的文件会被存储。

所有这些与优化不同镜像和容器之间共享文件的行为都得到了操作系统的支持,这要归功于 overlay 文件系统。

Overlay 文件系统

Overlay 文件系统是一种联合挂载文件系统(将多个目录合并成一个,看起来包含其所有合并内容的方式),它结合了多个底层挂载点。这导致了一个结构,其中包含一个单一的目录,包含来自所有源的所有底层文件和子目录。

Overlay 文件系统将来自不同目录的内容合并,结合由不同进程生成的文件对象(如果有的话),其中 上层 文件系统具有优先权。这是容器镜像层可重用性和节省磁盘空间背后的魔法。

现在我们了解了镜像是如何打包以及它们如何共享内容的,接下来我们回到学习更多关于容器的内容。正如你在本节中可能学到的,容器是依赖于容器运行时在主机操作系统上孤立运行的进程。尽管多个容器共享内核主机,但像内核命名空间和 cgroups 这样的特性提供了特殊的隔离层,使我们能够将其隔离开来。容器进程需要一些文件来工作,这些文件作为不可变模板包含在容器空间中。正如你所想,这些进程可能需要修改或创建一些新的文件,这些文件位于容器镜像层中,新的读写层将用于存储这些更改。容器运行时将这个新层呈现给容器,以便进行更改——我们通常称之为 容器层

以下架构概述了来自容器镜像模板的读写层与新添加的容器层,其中容器运行进程存储其文件修改:

图 1.3 – 容器镜像层将始终为只读;容器会添加一个具有读写权限的新层

图 1.3 – 容器镜像层将始终为只读;容器会添加一个具有读写权限的新层

容器进程所做的更改始终是短暂的,因为每当我们删除容器时,容器层将被丢失,而镜像层是不可变的,将保持不变。了解这种行为后,我们可以轻松理解为什么可以使用相同的容器镜像运行多个容器。

下图表示了这一情况,三个不同的运行容器是从相同的镜像创建的:

图 1.4 – 使用相同容器镜像运行的三个不同容器

图 1.4 – 使用相同容器镜像运行的三个不同容器

如你所注意到的,这种行为在操作系统中留下的磁盘空间占用非常小。容器层非常小(或者至少它应该是小的,作为开发者,你会学到哪些文件不应该留在容器生命周期内)。

容器运行时管理这些叠加文件夹如何包含在容器内及其背后的机制。这个机制基于特定的操作系统驱动程序,这些驱动程序实现了写时复制(copy-on-write)文件系统。各层依次排列,只有在其中修改的文件才会合并到上层。这个过程由操作系统驱动程序高速管理,但总会有一些小的开销,因此请记住,所有由应用程序持续修改的文件(例如日志文件)不应包含在容器内。

重要提示

写时复制(Copy-on-write)使用小型分层文件系统或文件夹。任何层中的文件都可以进行读取访问,但写入则需要在底层查找文件,并将该文件复制到上层以存储更改。因此,从文件读取产生的 I/O 开销非常小,我们可以保持多个层以便更好地分配文件到容器之间。相比之下,写入需要更多资源,最好将大文件和那些需要频繁或持续修改的文件排除在容器层之外。

同样重要的是要注意,容器并非完全短暂。如前所述,容器层中的更改会一直保留,直到容器从操作系统中被移除;因此,如果你在容器层创建了一个 10 GB 的文件,它将会保存在主机的磁盘中。容器编排器管理这种行为,但要小心你存储持久文件的位置。管理员应当进行容器清理和磁盘维护,以避免磁盘压力问题。

开发者应该牢记这一点,并使用容器准备应用程序,使其在逻辑上是短暂的,并将持久数据存储在容器层之外。我们将在第十章中学习关于持久化的选项,在 Kubernetes 中利用应用数据管理

这种思维方式引导我们进入下一部分,在这一部分我们将讨论容器环境的内在动态性。

理解基于容器的应用程序中的动态性

我们已经看到容器如何使用不可变存储(容器镜像)运行,以及容器运行时如何为管理已更改的文件添加新层。尽管我们在上一节中提到容器在磁盘使用方面并非短暂存在,但我们仍然必须在应用程序设计中考虑这一特性。每当你升级应用程序的组件时,容器将会启动和停止。每当你更改基础镜像时,将创建一个全新的容器(记得前面提到的层次结构生态系统)。如果你想将这些应用程序组件分发到一个集群中,这种情况会变得更加复杂——即使使用相同的镜像,也会在不同的主机上创建不同的容器。因此,这种动态性在这些平台中是继承而来的。

在容器内的网络通信上下文中,我们知道容器内运行的进程共享其网络命名空间,因此它们都会获得相同的网络栈和 IP 地址。但每当创建一个新容器时,容器运行时会提供一个新的 IP 地址。得益于容器编排和所包含的域名系统DNS),我们可以与我们的容器进行通信。由于 IP 地址由容器运行时的内部IP 地址管理IPAM)使用定义的池进行动态管理,每当一个容器死亡时(无论是主进程被停止、手动杀死,还是因错误结束),它将释放其 IP 地址,IPAM 会将其分配给一个新的容器,这个容器可能属于一个完全不同的应用程序。因此,我们可以信任 IP 地址分配,尽管我们不应该在应用程序配置中使用容器 IP 地址(更糟的是,在代码中写入它们,这是任何场景下的坏实践)。IP 地址将由 IPAM 容器运行时组件默认动态管理。我们将在第四章中学习更多可以用来引用应用程序容器的更好机制,比如服务名称,运行 Docker 容器

应用程序使用完全限定的域名(或者在使用内部域名通信时使用简短的名称,正如我们在使用 Docker Compose 运行多容器应用程序时会学到的那样,此外,当应用程序运行在更复杂的容器编排中时也是如此)。

由于 IP 地址是动态的,因此应该使用专门的资源来为服务名称分配一组 IP 地址(或者如果我们只有一个进程副本,则分配唯一的 IP 地址)。同样,发布应用程序组件也需要一些资源映射,使用网络地址转换NAT)来实现用户与外部服务以及运行在容器中的服务之间的通信,这些容器可能分布在不同的服务器上的集群中,甚至是在不同的基础设施中(例如,云提供的容器编排器)。

由于我们在本章回顾与容器相关的主要概念,因此不能忽视用于创建、执行和共享容器的工具。

管理容器的工具

正如我们之前所学,容器运行时将管理我们可以通过容器实现的大多数操作。大多数这些运行时以守护进程的形式运行,并提供与之交互的接口。在这些工具中,Docker 脱颖而出,因为它提供了一盒子中的所有工具。Docker 作为一个客户端-服务器应用程序,且在较新的版本中,客户端和服务器组件是分别打包的,但无论如何,用户都需要这两者。最初,当 Docker Engine 是最流行和可靠的容器引擎时,Kubernetes 选择了它作为其运行时。但是这种结合并没有持续太久,Docker Engine 在 Kubernetes 1.22 版本中被弃用。这是因为 Docker 管理它自己的 Containerd 集成,而这并非标准化,也不能直接由 Kubernetes 的 CRI 使用。尽管如此,Docker 仍然是开发基于容器的应用程序最广泛使用的选项,也是构建镜像的事实标准。

我们在本节中之前提到了 Docker Desktop 和 Rancher Desktop。两者都充当容器运行时客户端,使用dockernerdctl命令行。我们可以使用这些客户端,因为在这两种情况下,dockerdcontainerd充当容器运行时。

开发者和广泛的社区推动 Docker 为那些希望运行容器而不需要运行特权系统守护进程的用户提供解决方案,而这正是dockerd的默认行为。这花了一些时间,但最终,在几年前,Docker 发布了其无根(rootless)运行时,并赋予用户权限。在此开发阶段,另一个名为 Podman 的容器执行器应运而生,由 Red Hat 创建,旨在解决相同的问题。此解决方案可以在没有 root 权限的情况下运行,并且避免使用守护进程化的容器运行时。默认情况下,主机用户可以无需任何系统权限运行容器;如果容器要在安全加固环境中运行,管理员只需要做一些小的调整。这使得 Podman 成为在生产环境中运行容器(没有编排)的一个非常安全的选项。Docker 也在 2019 年底加入了无根容器,使得这两种选择默认都是安全的。

正如你在本节开始时学到的,容器是运行在操作系统上的进程,通过操作系统的内核特性进行隔离。容器在微服务环境中如此流行是显而易见的(一个容器运行一个进程,最终它是一个微服务),尽管我们仍然可以在没有容器的情况下构建基于微服务的应用程序。也可以使用容器将整个应用组件一起运行,尽管这并不是理想的情况。

重要提示

在本章中,我们将主要关注在 Linux 操作系统环境中的软件容器。这是因为容器在 Windows 系统中直到后期才被引入。然而,我们也会简要讨论它们在 Windows 环境中的应用。

我们不应该将容器与虚拟节点进行比较。正如本节前面所讨论的,容器主要基于 cgroups 和内核命名空间,而虚拟节点则基于虚拟化管理程序软件。这些软件提供了沙箱功能以及特定的虚拟化硬件资源给来宾主机。我们仍然需要为这些虚拟来宾主机准备操作系统。每个来宾节点将获得一块虚拟化的硬件,我们必须像管理物理服务器一样管理这些虚拟主机之间的交互。

在下一节中,我们将并排比较这些模型。

虚拟化与容器比较

下图表示几个虚拟来宾节点在物理主机之上的运行情况:

图 1.5 – 运行在物理服务器之上的虚拟来宾节点上运行的应用程序

图 1.5 – 运行在物理服务器之上的虚拟来宾节点上运行的应用程序

一台物理服务器运行其自身的操作系统,并执行一个虚拟化管理程序(hypervisor)软件层,以提供虚拟化功能。一定数量的硬件资源被虚拟化并分配给这些新的虚拟来宾节点。我们需要为这些新主机安装操作系统,之后我们就可以运行应用程序。物理主机资源被划分给来宾主机,且两个节点完全隔离。每个虚拟机执行自己的内核,操作系统运行在主机之上。由于底层主机的虚拟化管理程序软件将它们隔离开来,因此来宾操作系统之间是完全隔离的。

在这种模型中,我们需要大量资源,即使我们只需为每个虚拟主机运行几个进程。启动和停止虚拟主机将需要时间。很多不必要的软件和进程可能会在我们的来宾主机上运行,我们需要做一些调整以去除它们。

正如我们所学到的,微服务模型基于应用程序在不同进程中解耦运行,并具备完整功能的理念。因此,在仅仅几个进程中运行完整的操作系统似乎并不是一个好主意。

尽管自动化会帮助我们,但我们仍然需要维护和配置这些来宾操作系统,以便运行所需的进程并管理用户、访问权限、网络通信等内容。系统管理员像管理物理主机一样管理这些主机。开发人员需要自己的副本来开发、测试和认证应用组件。扩展这些虚拟服务器可能会成为问题,因为在大多数情况下,增加资源需要重新启动才能应用更改。

现代虚拟化软件提供基于 API 的管理,增强了它们的使用和虚拟节点的维护,但这对于微服务环境来说还不够。在弹性环境中,组件应该能够根据需求进行扩展或收缩,而虚拟机并不适合这种需求。

现在,让我们回顾以下架构图,表示一组运行在物理和虚拟主机上的容器:

图 1.6 – 一组运行在物理和虚拟主机上的容器

图 1.6 – 一组运行在物理和虚拟主机上的容器

在此架构中,所有容器共享相同的主机内核,因为它们只是运行在操作系统之上的进程。在这种情况下,我们不关心它们是运行在虚拟主机还是物理主机上;我们期望它们表现相同。我们不再需要虚拟机管理程序软件,而是可能需要/etc/hosts/etc/nsswitch.conf文件(以及一些网络库及其依赖项)。攻击面将与拥有满是二进制文件、库和运行服务的完整操作系统完全不同,无论应用程序是否使用它们。

容器被设计为只运行一个主要进程(及其线程或子进程),这使得它们非常轻量。它们可以像其主进程一样快速启动和停止。

容器消耗的所有资源都与给定的进程相关,这在硬件资源分配方面非常有利。我们可以通过观察所有微服务的负载来计算应用程序的资源消耗。

我们将镜像定义为运行容器的模板。这些镜像包含容器工作所需的所有文件,并附带一些元信息,提供其特性、能力以及将用于启动进程的命令或二进制文件。通过使用镜像,我们可以确保使用相同模板创建的所有容器运行方式相同。这消除了基础设施摩擦,并帮助开发人员准备应用程序在生产环境中运行。配置(当然,还有诸如凭证之类的安全信息)是开发、测试、认证和生产环境之间唯一的差异。

软件容器还提高了应用程序的安全性,因为它们默认以有限的权限运行,并且仅允许一组系统调用。它们可以在任何地方运行;我们所需要的只是一个容器运行时,以便能够创建、共享和运行容器。

现在我们知道了容器是什么以及涉及的最重要概念,让我们尝试理解它们如何融入开发流程中。

构建、共享和运行容器

构建、发布和运行:你可能几年前听过或读到过这句话。Docker 公司使用它来推广容器的使用简便性。在创建基于容器的应用程序时,我们可以使用 Docker 来构建容器镜像,在环境中共享这些镜像,将内容从开发工作站移动到测试和预发布环境,作为容器执行它们,最终在生产环境中使用这些包。整个过程只需要进行少量更改,主要是在应用程序的配置层面。这个工作流程确保了开发、测试和预发布阶段之间应用的使用性和不可变性。根据每个阶段所选择的容器运行时和容器编排器,Docker 可能会贯穿始终(Docker Engine 和 Docker Swarm)。无论如何,大多数人仍然使用 Docker 命令行来创建容器镜像,因为它具有出色且不断发展的功能,使我们能够例如在桌面计算机上为不同处理器架构构建镜像。

添加持续集成CI)和持续部署CD)(或持续交付,具体取决于来源)简化了开发人员的工作,使他们能够专注于应用程序的架构和代码。

他们可以在工作站上编写代码并将其推送到源代码库,这一事件将触发 CI/CD 自动化,构建应用程序工件,编译代码并将工件以二进制或库的形式提供。这种自动化还可以将这些工件包含在容器镜像中。这些工件成为新的应用程序工件并存储在镜像仓库中(存储容器镜像的后端)。不同的执行可以通过链接来测试这个新编译的组件与其他组件在集成阶段的表现,通过一些测试验证它在测试阶段的效果,依此类推,经过不同阶段直到进入生产环境。所有这些链式工作流都基于容器、配置和用于执行的镜像。在这个工作流中,开发人员从未显式创建发布镜像;他们只构建和测试开发镜像,但相同的 Dockerfile 配方既用于他们的工作站,也用于在服务器上执行的 CI/CD 阶段。可复现性是关键。

开发人员可以在开发工作站上运行多个容器,就像使用真实环境一样。他们可以在环境中测试自己的代码和其他组件,这使得他们能够更快地评估和发现问题,甚至在将组件移到 CI/CD 管道之前就修复这些问题。当代码准备好后,开发人员可以将其推送到代码库并触发自动化流程。开发人员可以构建他们的开发镜像,在本地进行测试(无论是独立组件、多个组件,还是一个完整的应用程序),准备发布代码,然后将其推送,CI/CD 协调器会为他们构建发布镜像。

在这些环境中,图像通过使用镜像注册表在不同环境之间共享。从服务器到服务器传输镜像非常简单,因为主机的容器运行时将从指定的注册表下载镜像——但只有那些在服务器上尚不存在的层会被下载,因此容器镜像中的层分布至关重要。

以下架构概述了这个简化的工作流:

图 1.7 – 使用软件容器将应用程序交付到生产环境中的 CI/CD 工作流示例的简化架构

图 1.7 – 使用软件容器将应用程序交付到生产环境中的 CI/CD 工作流示例的简化架构

运行这些不同阶段的服务器可以是独立服务器、编排集群中的节点池,或者更复杂的专用基础设施,包括某些情况下由云提供的主机或整个集群。使用容器镜像可以确保制品的内容和特定基础设施的配置能够在每个案例中以定制应用环境的方式运行。

牢记这一点,我们可以设想如何使用容器构建完整的开发链条。我们已经讨论过 Linux 内核命名空间,那么接下来我们将继续了解这些隔离机制如何在微软 Windows 上工作。

解释 Windows 容器

在本章中,我们重点讨论了 Linux 操作系统中的软件容器。软件容器起源于 Linux 系统,但由于其重要性以及在主机资源使用方面的技术进步,微软在 Microsoft Windows Server 2016 操作系统中引入了容器。在此之前,Windows 用户和管理员只能通过虚拟化使用 Linux 软件容器。因此,出现了 Docker Toolbox 解决方案,其中 Docker Desktop 是其一部分,安装此软件可以让我们在基于 Windows 的计算机上拥有一个终端,其中包括 Docker 命令行、精美的 GUI 和一个 Hyper-V Linux 虚拟机,容器将在其中运行。这使得初学者可以轻松地在 Windows 桌面上使用软件容器,但微软最终带来了一个改变游戏规则的创新,创造了一种新的封装模型。

重要提示

容器运行时是客户端-服务器应用程序,因此我们可以将运行时服务提供给本地(默认)和远程客户端。当我们使用远程运行时时,我们可以使用不同的客户端(如 dockernerdctl,具体取决于服务器端)在此运行时上执行命令。我们在本章前面提到,诸如 Docker Desktop 或 Rancher Desktop 这样的桌面解决方案使用这种模型,运行一个容器运行时服务器,常见客户端可以从常规的 Linux 终端或 Microsoft PowerShell 中执行,用来管理运行在服务器端的容器。

微软提供了两种不同的软件容器模型:

  • Hyper-V Linux 容器:旧模型,使用 Linux 虚拟机。

  • Windows Server 容器,也称为Windows 进程容器:这是新模型,允许运行基于 Windows 操作系统的应用程序。

从用户的角度来看,运行在 Windows 上的容器的管理和执行是相同的,无论使用的是哪种模型,但每台服务器只能使用一种模型,因此适用于该服务器上的所有容器。这里的差异来源于每种模型所使用的隔离方式。

进程隔离在 Windows 上的工作方式与 Linux 上相同。多个进程在主机上运行,访问主机的内核,主机通过命名空间和资源控制(以及根据底层操作系统的其他特定方法)提供隔离。如我们所知,进程有自己的文件系统、网络、进程标识符等,但在这种情况下,它们还会拥有自己的 Windows 注册表和对象命名空间。

由于微软 Windows 操作系统的本质,一些系统服务和动态链接库DLLs)在容器中是必需的,并且无法从宿主机共享。因此,进程容器需要包含这些资源的副本,这使得 Windows 镜像比基于 Linux 的容器镜像要大得多。根据用于生成镜像的基础操作系统(文件树),您还可能会遇到一些兼容性问题。

以下示意图并排展示了这两种模型,方便我们观察主要的堆栈差异:

图 1.8 – 微软 Windows 软件容器模型比较

图 1.8 – 微软 Windows 软件容器模型比较

当我们的应用程序需要与微软操作系统紧密集成时,例如集成组管理服务帐户gMSA)或封装无法在 Linux 主机上运行的应用程序时,我们将使用 Windows Server 容器。

根据我的经验,Windows Server 容器在最初推出时非常受欢迎,但随着微软改进了其应用程序对 Linux 操作系统的支持,以及开发者能够为微软 Windows 或 Linux 创建 .NET Core 应用程序的事实,再加上许多云服务提供商未提供此技术,使得 Windows Server 容器几乎从市场上消失。

还需要提到的是,编排技术的演变帮助开发者转向仅 Linux 的容器。Windows Server 容器在 2019 年之前仅在 Docker Swarm 上受支持,直到 Kubernetes 宣布支持它。由于 Kubernetes 在开发者社区甚至企业环境中的广泛应用,Windows Server 容器的使用减少到非常特定和小众的用例。

如今,Kubernetes 支持运行 Microsoft Windows Server 主机作为工作角色,允许进程容器的执行。我们将在第八章《使用 Kubernetes 协调器部署应用程序》中了解 Kubernetes 和主机角色。尽管如此,你可能不会发现很多 Kubernetes 集群在运行 Windows Server 容器工作负载。

我们提到过容器可以提高应用程序的安全性。下一节将展示在主机和容器层面上使容器默认安全的改进措施。

使用软件容器提高安全性

在本节中,我们将介绍一些容器平台上的功能,这些功能有助于提高应用程序的安全性。

如果我们记住容器是如何运行的,我们知道首先需要一个带有容器运行时的主机。因此,拥有一个只包含必要软件的主机是第一道安全防线。我们应该在生产环境中使用专用主机来运行容器工作负载。开发过程中我们不需要过多担心这一点,但系统管理员应为生产节点准备最小化攻击面。我们绝不应该共享这些主机用于提供其他技术或服务。这个特性非常重要,以至于我们可以找到专门的操作系统,比如 Red Hat 的 CoreOS、SuSE 的 RancherOS、VMware 的 PhotonOS、TalOS 或 Flatcar Linux,仅举几种流行的操作系统。这些是最小化的操作系统,仅包含一个容器运行时。你甚至可以通过使用 Moby 的 LinuxKit 项目来创建自己的操作系统。一些厂商定制的 Kubernetes 平台,比如 Red Hat 的 OpenShift,使用 CoreOS 创建集群,从而提高整个环境的安全性。

我们永远不会连接到任何集群主机来执行容器。容器运行时采用客户端-服务器模式。相反,我们暴露这个引擎服务,简单地在我们的笔记本电脑或台式机上使用客户端就足以在主机上执行容器。

在本地,客户端通过使用 /var/run/docker.sock 连接到容器运行时,例如 dockerd。为特定用户添加对这个套接字的读写访问权限将允许他们使用守护进程来构建、拉取和推送镜像或执行容器。在这种方式下配置容器运行时,如果主机在编排环境中具有主控角色,可能会带来更大的安全风险。理解这一特性并了解哪些用户能够在每个主机上运行容器至关重要。系统管理员应确保容器运行时的套接字免受不信任用户的访问,仅允许授权访问。这些套接字是本地的,具体取决于我们使用的运行时,TCP 或甚至 SSH(例如在 dockerd 中)可以用于确保远程访问的安全。始终确保使用传输层安全性TLS)来保护套接字访问。

需要注意的是,容器运行时不提供任何基于角色的访问控制RBAC)。我们需要使用其他工具后续添加这一层。Docker Swarm 不提供 RBAC,但 Kubernetes 提供。RBAC 是管理用户权限和多个应用隔离的关键。

我们应该在此提到,目前,桌面环境(Docker Desktop 和 Rancher Desktop)也采用这一模型,在这种模型中,你并不直接连接到运行容器运行时的主机。一个虚拟化环境会部署在你的系统上(如果是 Linux,使用 Qemu;如果是 Windows 主机,使用 Hyper-V 或更新的 Windows 子系统 Linux),我们的客户端将通过终端连接到该虚拟容器运行时(或者在 Kubernetes 上部署工作负载时,连接到 Kubernetes API,正如我们将在第八章,《使用 Kubernetes 调度器部署应用程序》中学习到的)。

在这里,我们必须重申,容器运行时默认仅向容器进程添加了一部分内核能力。但在某些情况下,这可能不够。为了提升容器的安全行为,容器运行时还包括一个默认的安全计算模式Seccomp)配置文件。Seccomp 是一个 Linux 安全功能,用于过滤容器内允许的系统调用。特定的配置文件可以由运行时包含并使用,以添加一些所需的系统调用。作为开发者,你需要注意当应用程序需要额外的能力或非常规系统调用时。本节所描述的特性,例如,适用于主机监控工具,或者当我们需要通过系统管理容器添加新的内核模块时。

容器运行时通常作为守护进程运行;因此,它们很可能以 root 用户身份运行。这意味着任何容器都可以包含主机的文件(我们将在第四章,《运行 Docker 容器》中学习如何在容器内挂载卷和主机路径),或者包含主机的命名空间(容器进程可能访问主机的 PID、网络、IPC 等)。为了避免容器运行时权限带来的不良影响,系统管理员应该使用Linux 安全模块LSM),如 SELinux 或 AppArmor 等,采取特别的安全措施。

SELinux 应该与容器运行时和容器编排进行集成。这些集成可以确保例如,仅允许某些路径进入容器。如果你的应用程序需要访问主机的文件,应包含非默认的 SELinux 标签,以修改默认的运行时行为。容器运行时的软件安装包包括这些设置等,以确保常见应用能够正常运行。然而,那些有特殊需求的应用,例如需要读取主机日志的应用,将需要进一步的安全配置。

到目前为止,在本章中,我们已经提供了与容器相关的关键概念的快速概述。在接下来的部分,我们将把这些内容付诸实践。

实验

在本章中,我们学习了大量内容,了解了什么是容器以及它们如何融入现代微服务架构。

在本实验中,我们将为基于容器的应用程序安装一个完整的开发环境。我们将使用 Docker Desktop,因为它包含了一个容器运行时、客户端,并且提供了一个最小但功能完整的 Kubernetes 编排解决方案。

我们本可以直接在 Linux 上使用 Docker Engine(仅容器运行时,按照docs.docker.com/中的说明),用于大多数实验,但对于 Kubernetes 实验,我们需要安装一个新工具,要求进行最小的 Kubernetes 集群安装。因此,即使是仅使用命令行,我们也将使用 Docker Desktop 环境。

重要说明

我们将使用 Kubernetes 桌面环境,以最小化 CPU 和内存要求。还有更轻量的 Kubernetes 集群替代方案,如 KinD 或 K3S,但这些可能需要一些自定义配置。当然,如果您觉得更舒服,您也可以使用任何云服务提供商的 Kubernetes 环境。

安装 Docker Desktop

本实验将引导您在笔记本电脑或工作站上安装Docker Desktop,并执行测试以验证其是否正确运行。

Docker Desktop 可以安装在 Microsoft Windows 10、大多数常见的 Linux 版本和 macOS 上(arm64 和 amd64 架构都受支持)。本实验将向您展示如何在 Windows 10 上安装此软件,但在其他实验中,我将交替使用 Windows 和 Linux,因为它们的操作方式大致相同——我们将在需要时回顾不同平台之间的差异。

我们将按照docs.docker.com/get-docker/中记录的简单步骤进行操作。Docker Desktop 可以在 Windows 上使用Hyper-V或较新的Windows Subsystem for Linux 2(WSL 2)进行部署。第二种选项占用的计算和内存资源较少,并且与微软 Windows 紧密集成,是首选的安装方法。但请注意,在安装 Docker Desktop 之前,您的主机必须安装 WSL2。请在安装 Docker Desktop 之前,按照微软提供的learn.microsoft.com/en-us/windows/wsl/install中的说明进行操作。您可以安装任何 Linux 发行版,因为该集成会自动包含。

我们将使用Ubuntu WSL 发行版。它可以从微软商店获取,安装起来非常简单:

图 1.9 – 微软商店中的 Ubuntu

图 1.9 – 微软商店中的 Ubuntu

在安装过程中,您将被提示输入用户名密码,以完成此 Windows 子系统的安装:

图 1.10 – 安装 Ubuntu 后,你将拥有一个完全功能的 Linux 终端

图 1.10 – 安装 Ubuntu 后,你将拥有一个完全功能的 Linux 终端

你可以关闭此 Ubuntu 终端,因为 Docker Desktop 集成将要求你在配置完成后打开一个新的终端。

重要提示

如果你的操作系统尚未更新,可能需要在 docs.microsoft.com/windows/wsl/wsl2-kernel 执行一些额外步骤以更新 WSL2。

现在,让我们继续进行 Docker Desktop 安装:

  1. docs.docker.com/get-docker/ 下载安装程序:

图 1.11 – Docker Desktop 下载部分

图 1.11 – Docker Desktop 下载部分

  1. 下载后,执行 Docker Desktop Installer.exe 可执行文件。系统会要求你选择 Hyper-V 或 WSL2 后端虚拟化,我们将选择 WSL2:

图 1.12 – 选择 WSL2 集成以获得更好的性能

图 1.12 – 选择 WSL2 集成以获得更好的性能

  1. 点击确定后,安装过程将开始解压所需的文件(库、二进制文件、默认配置等)。这可能需要一些时间(1 到 3 分钟),具体取决于主机的磁盘速度和计算资源:

图 1.13 – 安装过程将花费一段时间,因为应用文件正在解压并安装到系统中

图 1.13 – 安装过程将花费一段时间,因为应用文件正在解压并安装到系统中

  1. 为完成安装,我们将被要求注销并重新登录,因为我们的用户已被添加到新的系统组(Docker)中,以便通过操作系统管道(类似 Unix 套接字)访问远程 Docker 守护进程:

图 1.14 – Docker Desktop 已成功安装,且我们必须注销

图 1.14 – Docker Desktop 已成功安装,且我们必须注销

  1. 登录后,我们可以通过新添加的应用图标执行 Docker Desktop。我们可以启用 Docker Desktop 开机启动,这可能非常有用,但如果计算机资源不足,可能会导致计算机变慢。我建议仅在需要使用 Docker Desktop 时启动它。

    一旦我们接受了 Docker 订阅许可协议,Docker Desktop 将启动。这可能需要一些时间:

图 1.15 – Docker Desktop 正在启动

图 1.15 – Docker Desktop 正在启动

你可以跳过 Docker Desktop 运行时出现的快速指南,因为我们将在后续章节中深入学习如何构建容器镜像和容器执行。

  1. 我们将看到以下屏幕,显示 Docker Desktop 已准备就绪:

图 1.16 – Docker Desktop 主屏幕

图 1.16 – Docker Desktop 主屏幕

  1. 我们需要启用 WSL2 与我们最喜爱的 Linux 发行版的集成:

图 1.17 – 启用我们先前安装的 Ubuntu 使用 WSL2

图 1.17 – 启用我们先前安装的 Ubuntu 使用 WSL2

  1. 完成此步骤后,我们终于准备好使用 Docker Desktop。让我们使用 Ubuntu 发行版打开终端,执行 docker,然后执行 docker info

图 1.18 – 执行一些 Docker 命令以验证容器运行时集成

图 1.18 – 执行一些 Docker 命令以验证容器运行时集成

如你所见,我们拥有一个完全功能的 Docker 客户端命令行,它与 Docker Desktop WSL2 服务器关联。

  1. 我们将通过执行docker run-ti alpine来下载 Alpine 镜像并使用它执行容器,结束本实验:

图 1.19 – 创建一个容器并在退出前执行一些命令

图 1.19 – 创建一个容器并在退出前执行一些命令

  1. 此容器执行已在 Docker Desktop 中留下更改;我们可以查看当前容器运行时中存在的镜像:

图 1.20 – Docker Desktop – 镜像视图

图 1.20 – Docker Desktop – 镜像视图

  1. 我们还可以查看容器,它已经停止,因为我们通过在其 shell 内执行 exit 命令退出:

图 1.21 – Docker Desktop – 容器视图

图 1.21 – Docker Desktop – 容器视图

现在,Docker Desktop 已经启动,我们准备好使用 WSL2 Ubuntu Linux 发行版继续进行接下来的实验。

摘要

在本章中,我们学习了有关容器的基础知识以及它们如何融入现代微服务应用程序。本章内容帮助你理解如何在分布式架构中实现容器,利用现有的主机操作系统隔离特性和容器运行时,后者是构建、共享和执行容器所需的软件组件。

软件容器通过其天生的特性帮助应用程序开发,提供了弹性、高可用性、可扩展性和可移植性,并将帮助你创建和管理应用程序生命周期。

在下一章中,我们将深入探讨创建容器镜像的过程。

第二章:2

构建 Docker 镜像

作为软件容器运行的应用程序是一个相对较新的发展,也是避免底层基础设施问题的一种很好的方式。正如我们在上一章中所学到的,容器是利用宿主的内核执行的进程,通过内核中提供的特性(有时已存在多年)实现隔离,并被封装在自己的文件系统中。

在本章中,我们将使用容器镜像,它们是类似模板的对象,用于创建容器。构建这些镜像是创建你自己基于容器的应用程序的第一步。我们将学习不同的构建容器镜像的过程。这些镜像将成为我们新应用程序的产物,因此我们需要安全地构建它们,并准备在我们的笔记本电脑或计算机、预发布和生产服务器,甚至云提供的基础设施上运行它们。

在本章中,我们将讨论以下内容:

  • 理解写时复制(copy-on-write)文件系统的工作原理

  • 构建容器镜像

  • 理解常见的 Dockerfile 关键字

  • 创建镜像的命令行

  • 高级镜像创建技巧

  • 容器镜像创建的最佳实践

技术要求

在本章中,我们将教你如何构建容器镜像,并在你的代码编译工作流中使用它们。我们将使用开源工具,以及一些可以在非专业用途下无许可证运行的商业工具来构建镜像并验证其安全性。本章中还包含了一些实验,帮助你理解所介绍的内容。这些实验已发布在以下 GitHub 仓库:github.com/PacktPublishing/Containers-for-Developers-Handbook/tree/main/Chapter2。在这里,你将找到一些为了让章节更易于跟进而省略的扩展说明。本章的实践代码视频可以在packt.link/JdOIY找到。

理解写时复制(copy-on-write)文件系统的工作原理

构建容器镜像是使用容器开发应用程序时的第一步。在本章中,我们将学习不同的镜像构建方法。但首先,深入探讨如何从文件系统角度创建镜像会很有趣。

容器是利用内核特性实现隔离运行的进程。它们运行在宿主系统上,拥有自己的文件系统,仿佛在各自的子系统内完全独立运行。这个文件系统中的文件被分成不同的层次,一层叠一层。需要修改的较低层文件会被复制到要进行修改的层,修改后会提交。新文件只会创建在上层。这就是写时复制(CoW)文件系统的基础。

正如我们所预期的,这种模型下,容器运行时将管理所有这些变化。每次文件修改都需要主机资源来在层之间复制文件,因此,使得这个机制在连续创建文件时成为一个问题。在上层创建新文件之前,必须读取所有层,以确保文件尚未存在,并将其内容复制到上层。

所有这些层在每次我们使用特定容器镜像作为模板创建容器时,都以只读模式呈现给容器,并且会在其他层上方添加一个新的读写模式层。这个新层将包含容器启动后所有的文件更改。然而,这种行为会出现在系统中所有运行的容器中。所有基于相同容器镜像的容器共享这些只读层,这在磁盘使用方面非常重要。只有容器层在每次执行新的容器时会有所不同。

重要提示

所有在不同容器执行之间应该持久化的数据,必须在容器生命周期之外声明和使用——例如,通过使用,正如我们将在第四章《运行 Docker 容器》中学习的那样。我们可以在容器镜像构建过程中声明卷,这表明内容存在于镜像层之外。

如我们所见,使用这些模板加速了容器创建并减少了我们系统中所有容器的大小。如果我们将其与虚拟机进行比较,它就像虚拟机模板或快照一样。只有更改被存储在主机级别,尽管这里需要特别提到的是,容器占用的空间非常少。

然而,在使用 CoW 文件系统时,性能总是会受到影响,这是你需要注意的。切勿将日志存储在容器层中,因为如果你删除容器,日志可能会丢失,且非常重要的一点是,由于任何文件的查找-复制-写入过程,应用程序性能可能也会受到影响。因此,我们绝不会在容器层中存储日志,尤其是那些不断写入文件或监控数据的进程。你应该将这些文件写入远程后端或使用容器卷功能。这种性能下降适用于大量小文件(成千上万),相反地(少量巨大文件),或有深层树结构的大量目录。作为开发者,你必须避免这些情况出现在你的应用中,并且应当提前准备好容器来避免它们。

现在我们了解了这些 CoW 文件系统的行为,适用于容器镜像的创建和执行,让我们学习如何构建镜像。

创建容器镜像

在本节中,我们将回顾构建容器镜像的不同方法,以及它们的优缺点和使用场景,以便你根据需求选择合适的方法。

创建容器镜像有三种方法:

  • 在 Dockerfile 中使用基础镜像,Dockerfile 是一个配方文件,包含创建镜像时执行的不同自动化步骤。

  • 交互式手动执行命令并存储生成的文件系统

  • 从空文件系统开始,使用 Dockerfile 配方文件,只复制我们应用所需的二进制文件和库。

很容易看出,最后一种方法在安全性方面是最好的,但如果你的代码有很多依赖关系并且与操作系统文件紧密集成,那么这可能很难实现。让我们从最常见的方法开始,逐一探讨这些方法。

使用 Dockerfile 创建容器镜像

在我们描述这种方法之前,让我们先了解一下什么是Dockerfile

Dockerfile 是一个符合开放容器倡议OSI)规范的文件,作为创建容器镜像的配方,包含一步步的程序。它包含一组键值对,描述不同的执行步骤以及镜像行为的元信息。我们可以使用变量扩展在构建镜像时传递的参数,且它非常适合自动化。如果一个 Dockerfile 写得很好,我们可以确保它的可复现性。

以下是一个 Dockerfile 的示例:

FROM debian:stable-slim
RUN apt-get update -qq && apt-get install -qq package1 package2
COPY . /myapp
RUN make /myapp
CMD python /myapp/app.py
EXPOSE 5000

如前所述,这个文件描述了组装镜像所需的所有步骤。让我们快速概览一下所展示的 Dockerfile 中的步骤。

第一行,FROM debian:stable-slim,表示此容器镜像将作为基础镜像,因此它的所有层都会被使用。容器运行时会下载(pull)所有这些层,如果它们在我们的主机上不存在的话。如果其中任何一层已经存在于主机上,它们将被使用。这个层可能已经来自主机中的任何其他镜像。容器镜像的层是可以重用的。

第二行,RUN apt-get update -qq && apt-get install -qq package1 package2,执行所有作为值的内容。首先会执行apt-get update -qq,如果成功,则会执行apt-get install -qq package1 package2。这个完整步骤只会创建一个层,叠加在之前的层之上。此层会自动对使用相同执行的任何其他镜像启用,使用相同的debian:stable-slim基础镜像。

第三行,COPY . /myapp,会将当前目录下所有可用的文件复制到名为/myapp的目录,并在新的一层中完成。正如第二行提到的,这也为包含相同入口的任何新镜像创建了可重用的层。

第四行,RUN make /myapp,执行make /myapp命令并创建一个新的层。记住,这是一个示例。我们添加了make命令来构建源代码。在这一步骤中,例如,我们运行了一个先前在镜像中安装的编译器,并构建了我们的二进制文件。所有执行层(包含RUN键的层)应该正确退出。如果发生异常,镜像构建过程将中断并停止。如果发生这种情况,所有之前的镜像层将保留在你的系统中。容器运行时会创建一个层缓存,所有后续执行会默认重用它们。这种行为可以通过在构建过程中重新创建所有之前的镜像来避免。

最后的两步不会增加镜像层。CMD键声明了将执行的命令行(记住,容器运行的是一个主进程),EXPOSE则添加了有关应暴露(监听)哪个端口的元信息。通过这种方式,我们明确声明了应用程序将在哪个端口监听任何类型的通信。

重要提示

你应该在 Dockerfile 中声明所有相关的元信息,比如暴露的端口持久化数据的卷、为你的主进程定义的用户名(或用户 ID)以及启动时应执行的命令行。这些信息可能是容器编排管理员所需要的,因为它们对于避免安全问题至关重要。他们可能会在生产平台上强制实施一些安全策略,以禁止你的应用程序执行。询问他们是否应用了一些安全策略,确保你已经添加了所需的信息。无论如何,如果你遵循本书中描述的安全实践,你在生产中可能不会遇到任何问题。

如你所见,这是一个相当可复现的过程。如果我们不做任何更改,这个配方每次都会创建相同的镜像。这帮助开发者专注于他们的代码。然而,创建可复现的镜像并不容易。如果你仔细查看所使用的FROM值,我们使用的是debian:stable-slim,这意味着默认镜像是docker.io。目前,你只需要知道注册表是所有容器镜像层的存储库。FROM键的值表示将使用一个特定标签为stable-slimdebian镜像,因此,如果 Docker 更改了这个镜像,你的所有镜像构建也会随之变化。标签是我们识别镜像的方式,但它们并不是唯一标识的。每个镜像及镜像中的层都是通过摘要哈希唯一标识的,这些才是你应该密切关注的真正相关值。为了获取这些值,我们要么需要拉取镜像,要么检查定义注册表中的信息。较为简单的方法是拉取镜像,这会在执行构建过程时自动发生,但在这个示例中,我们使用了一个模拟的 Dockerfile,因此它不会直接起作用。

所以,让我们从官方 Docker 镜像注册表 hub.docker.com 拉取镜像,或者使用 docker.io 命令行工具:

$ docker image pull debian:stable-slim
stable-slim: Pulling from library/debian
de661c304c1d: Pull complete
Digest: sha256:f711bda490b4e5803ee7f634483c4e6fa7dae54102654f2c231ca58eb233a2f1
Status: Downloaded newer image for debian:stable-slim
docker.io/library/debian:stable-slim

在这里,我们执行 docker image pull debian:stable-slimdocker.io 下载该镜像。所有相关的层将被下载。Docker Hub 网站提供了许多有用的信息,比如与镜像相关的所有标签以及在包含的文件中发现的漏洞。

上述代码片段中显示的摘要将唯一标识此镜像。我们可以通过执行 docker image inspect 并使用其 镜像 ID 来验证系统中的镜像并查看其信息:

$ docker image ls --no-trunc
REPOSITORY   TAG           IMAGE ID                                                                  CREATED      SIZE
debian       stable-slim   sha256:4ea5047878b3bb91d62ac9a99cdcf9e53f4958b01000d85f541004ba587c1cb1   9 days ago   80.5MB
$ docker image inspect 4ea5047878b3bb91d62ac9a99cdcf9e53f4958b01000d85f541004ba587c1cb1 |grep -A1 -i repodigest
        "RepoDigests": [
            "debian@sha256:f711bda490b4e5803ee7f634483c4e6fa7dae54102654f2c231ca58eb233a2f1"

所有与容器相关的对象都有唯一的对象 ID,因此我们可以用它们来引用每个对象。在这个例子中,我们使用镜像 ID 来检查对象。

重要提示

我们可以在列出本地镜像时使用 --digests 来检索所有镜像的摘要——例如,使用本节中的镜像:

$ docker image ls --digests

REPOSITORY TAG DIGEST IMAGE ID CREATED ``SIZE

debian stable-slim sha256:f711bda490b4e5803ee7f634483c4e6fa7dae54102654f2c231ca58eb233a2f1 4ea5047878b3 9 天前 ``80.5MB

需要注意的是,镜像 ID 和其摘要是不同的。ID 代表当前在系统中生成的编译或标识符,而摘要则代表所有层的汇总,并且本质上可以在任何地方唯一标识该镜像——无论是在你的笔记本电脑、服务器上,还是在远程存储的注册表中。镜像摘要与镜像内容清单相关联(docs.docker.com/registry/spec/manifest-v2-2/),并用于 V2 注册表(目前大多数现代注册表实现使用的版本)。由于本地构建的镜像不符合注册表格式,因此摘要将显示为 none。将镜像推送到 V2 注册表后,情况将发生变化。

让我们通过查看一个简短且简单的示例来回顾这个过程以及镜像 ID 和摘要。我们将使用以下两行 Dockerfile 构建几个镜像:

FROM debian:stable-slim
RUN apt-get update -qq && apt-get install -qq curl

使用当前的 debian:stable-slim 镜像,我们将更新其内容并安装 curl 包。

我们将构建两个镜像,onetwo,如下面的截图所示:

图 2.1 – 执行两个连续的容器镜像构建。预计没有变化,因此镜像是相同的

图 2.1 – 执行两个连续的容器镜像构建。预计没有变化,因此镜像是相同的

第一次构建过程将创建一个层缓存,因此第二次构建将复用这些缓存,构建过程会变得更快,因为层是相同的。安装过程不会被触发。我们使用当前目录作为构建上下文。像 Docker 这样的容器运行时是以客户端-服务器模型运行的,因此,我们通过 Docker 命令行与 Docker 守护进程进行交互。构建过程会将所有文件发送到当前上下文路径)中的守护进程,以便它可以使用这些文件来创建镜像的文件系统。这一点至关重要,因为如果我们选择了错误的上下文,很多文件会被发送到守护进程,这将影响构建过程。我们应该正确指定包含代码的目录,这将在构建过程中使用。在这个上下文文件夹中,我们应避免放入二进制文件、库文件、文档等。需要注意的是,我们可以使用 Git 仓库(以 URL 格式)作为构建上下文,这使得 CI/CD 集成变得非常有趣。

为了避免在构建过程中将无关文件发送给守护进程,我们可以使用 .dockerignore 文件。在该文件中,我们将列出应该排除的文件和文件夹,即使它们存在于我们的构建上下文中。

让我们查看系统中这些图像的相关信息。如果我们执行 docker image ls --digest,将获得它们的图像 ID 和摘要:

图 2.2 – 创建的容器镜像列表,显示它们完全相同的 ID

图 2.2 – 创建的容器镜像列表,显示它们完全相同的 ID

我们首先可以看到的是,onetwo 这两个镜像具有相同的镜像 ID。这是因为我们复用了它们的层。在第二次构建过程中,使用相同的 Dockerfile,容器运行时会复用所有先前相同的镜像层(那些来自相同执行的层),因此镜像创建非常快速。它们是相同的镜像,但有两个不同的标签。

我们还可以看到,只有基础镜像显示了它的摘要。正如前面所提到的,它是唯一来自 V2 注册表的镜像。如果我们将某个镜像上传到Docker Hub(或任何其他支持 V2 的注册表),它的摘要将会被创建。

重要提示

要能够将图像上传到 Docker Hub,您需要一个有效的账户。通过访问hub.docker.com/signup来创建您的账户。这个过程非常简单,您将在一分钟内拥有一个 Docker Hub 注册账户。

让我们来看一下上传镜像的工作原理,以及它如何生成不可变且唯一的引用摘要。

在启动过程之前,我们只需使用账户名登录 Docker Hub。系统会提示输入密码,之后我们应该会收到 Login Succeeded 消息:

$ docker login --username <YOUR_USERNAME>
Password:
Login Succeeded
Logging in with your password grants your terminal complete access to your account.
For better security, log in with a limited-privilege personal access token. Learn more at https://docs.docker.com/go/access-tokens/

现在我们已经登录,需要重新标记我们的镜像。镜像标签是我们用来引用镜像的易读格式。在本示例中使用的构建过程中,我们通过命令行输入 docker build –t <TAG>,将 onetwo 作为标签。然而,我们看到这两个镜像是相同的;因此,我们可以说标签是镜像 ID 的名称,这可能会让您感到困惑。我们能信任镜像标签吗? 简短的回答是,不,我们不能。它们并不代表一个唯一的镜像状态。我们可以为镜像 ID 使用不同的标签并修改这些镜像,但如果您仍然使用这些标签,您将使用完全不同的镜像。在我们的示例中,任何人都可以更改我们的 debian:stable-slim 镜像。如果我们基于这个标签重新构建某些镜像,我们将创建一个具有完全不同内容的新镜像。如果新镜像包含一些代码漏洞,因为恶意攻击者将其包含在该基础镜像中呢?在像 Docker Hub 这样的高度受控的镜像注册表中,这不应该发生,但这个问题确实存在。

让我们重新标记并上传我们的镜像,使用 docker tag 然后 docker push

图 2.3 – 标记和推送镜像以获取其摘要

图 2.3 – 标记和推送镜像以获取其摘要

请注意,我们需要推送镜像。仅重新标记并不起作用。现在,我们拥有一个唯一的镜像,任何人都可以使用我们的标签来引用它。如果我们更新了我们的one镜像,添加了一些新内容或更改了要执行的命令行,这个摘要将发生变化。即使我们仍然使用相同的frjaraur/one标签,使用我们的镜像进行的新构建过程也将创建新的内容。

重要提示

作为开发者,您应该注意您作为参考来创建镜像时所使用的镜像中引入的任何更改。您可能会想知道管理这些更改的正确方法是什么。简短的回答是:始终使用镜像摘要(遵循示例标签和摘要,我们将使用FROM debian:stable-slim@sha256:f711bda490b4e5803ee7f634483c4e6fa7dae54102654f2c231ca58eb233a2f1)。这种方法可能非常复杂,但它是最安全的。另一种方法是使用您自己的私有注册表,隔离于互联网,在其中存储您的镜像。使用您自己管理的私有注册表,您可以放心使用镜像标签。您将是唯一能够更新您的基础镜像的人,因此,您管理整个镜像生命周期。

正如我们在本示例开始时提到的,我们使用相同的 Dockerfile 构建了两个镜像,并且我们意识到这两个镜像具有相同的镜像 ID;因此,它们是完全相同的。让我们稍微改变一下,使用 docker build –no-cache 选项,这样可以避免重新使用先前创建的层:

图 2.4 – 执行无缓存的镜像构建过程

图 2.4 – 执行无缓存的镜像构建过程

我们可以看到,即使使用相同的 Dockerfile,仍然构建出了一个全新的镜像。这是由于执行之间的时间差异。因为我们在两个不同的时间点进行了修改,所以下次构建执行时,层会发生变化。当然,我们也可以包括由于软件包更新带来的新变更,但在这种情况下,情况更加简单。

我们可以从中学到的是,重用层有助于保持镜像的大小和构建时间(在这个例子中我们没有注意到这一点,因为我们使用了一个简单的两行 Dockerfile,但当你在为代码编译或下载大量模块时,这可能需要很长时间),但是当我们需要刷新镜像内容时,禁用缓存是必须的。当我们为项目创建基础镜像文件时,这一点非常有用——例如,我们自己的 .NET Core 和 Python 项目。我们将使用这些基础镜像并将它们上传到我们的注册中心,确保它们的内容是可靠的。当新的发布版本到来时,我们可以重建这些镜像以及它们的所有依赖镜像(我们的应用镜像)。这个过程应该是我们自动化 CI/CD 管道的一部分。

现在我们已经理解了如何使用 Dockerfile 构建镜像,接下来我们将介绍一种在某些特定情况下非常有用的新方法。

交互式创建容器镜像

我们之前没有提到,但这里需要特别指出,Dockerfile 中的 RUN 行会创建中间容器来执行命令,这些命令作为 RUN 键后的值书写。因此,docker build 命令启动了一系列链式容器,这些容器创建了最终构成镜像的不同层。在执行新容器之前,这个过程会将修改过的文件(容器层)存储到系统中,使用容器运行时的 commit 功能。

这些容器一个接一个地运行,使用前一个容器创建的层。我们即将描述的交互式过程以简化的方式遵循这一工作流。我们将运行一个容器,使用镜像作为基础,并手动运行和复制应用程序所需的所有命令和内容。变更将在运行时创建,当我们完成后,我们将提交创建的容器层。当我们需要安装要求交互式不同配置的软件,并且无法自动化此过程时,这种方法可能会很有趣。

这种方法缺乏可重现性,如果我们能找到一种方法来自动化镜像创建过程,就不应该使用它。没有人会知道你是如何在镜像中安装内容的(如果你没有删除它,shell 历史会包含这些步骤,但交互式命令不会出现)。让我们介绍一个可以帮助我们了解镜像如何构建的命令——docker image history。这个命令显示了创建镜像的所有步骤,包括在过程中添加的元信息,按倒序排列。

让我们看一下使用上一节中提到的一个镜像的输出,使用 Dockerfile 创建 容器镜像

图 2.5 – 回顾用于创建容器镜像的所有步骤

图 2.5 – 回顾用于创建容器镜像的所有步骤

镜像历史记录必须从最新的行开始按逆序读取。我们将从 ADD 键开始,这代表了我们 Dockerfile 中的初始 FROM 键。这是因为 FROM 键被解释为将所有基础镜像的内容复制到基础层之上。

我们使用了–-no-trunc选项,以便从输出中读取完整的命令行。我们可以很容易地看到,这个镜像是通过 /bin/sh -c apt-get update –q && apt-get install –qq curl 命令创建的。docker image history 命令将显示我们从 Dockerfile 构建任何镜像时执行的步骤,但它对交互式创建的镜像无效。

让我们看一个使用 Debian 镜像安装 Postfix 邮件服务器的简单示例:

图 2.6 – 手动执行 Postfix 邮件包

图 2.6 – 手动执行 Postfix 邮件包

一旦安装过程完成,我们将被提示配置服务器的各个方面。这些配置是完全交互式的:

图 2.7 – Postfix 安装是交互式的,因为它要求用户进行特定配置

图 2.7 – Postfix 安装是交互式的,因为它要求用户进行特定配置

安装过程中将要求你进行一些交互式配置,之后 Postfix 服务器将准备好工作。我们可以通过执行 exit 来退出容器进程,然后将容器层作为新镜像提交。我们使用 docker container ls –l 仅列出最后执行的容器,然后执行 docker commit(或 docker container commit — 两个命令都可以使用,因为它们都指容器)将当前容器层保存为新镜像:

图 2.8 – 提交容器层以创建图像

图 2.8 – 提交容器层以创建图像

然而,正如我们之前提到的关于此方法的内容,我们无法知道创建图像时所采取的步骤。

让我们试试使用 docker imagehistory 命令:

图 2.9 – 当执行交互式过程时,历史记录不会显示任何命令

图 2.9 – 当执行交互式过程时,历史记录不会显示任何命令

我们在输出中看到的只是我们使用bash做了某些事情。我们将在它的.bash_history文件中看到命令,但这并不是应有的做法。如果你必须在特定情况下使用这种方法,例如当应用程序的安装需要一些交互步骤时,记得将你所做的所有更改记录在文件中,以便其他开发人员理解你的过程。

这种方法不推荐使用,因为它不可重复,并且我们无法向容器镜像中添加任何元信息。在下一节中,我们将描述可能是解决此问题的最佳方法,但它需要对应用程序的二进制文件、库以及隐藏的依赖项有大量的了解。

从零开始创建镜像

在这种方法中,正如其名称所示,我们将创建一个空层,并通过使用一组打包好的文件来引入所有文件。你可能已经注意到,我们迄今为止看到的所有图像历史都涉及使用ADD关键字作为第一步。这就是容器运行时启动构建过程的方式——通过复制基础镜像的内容。

使用这种方法,你可以确保只有明确需要的文件会被包含在容器镜像中。它与 Go 等编程语言配合得非常好,因为你可以将所有依赖项都包含在二进制文件中;因此,添加已编译的工件可能就足以使应用程序正常工作。此方法也使用 Dockerfile 文件,但在这种情况下,我们将从简单的FROM scratch行开始。这将为我们的文件创建一个空层。让我们看一个简单的 Dockerfile 示例:

FROM scratch
ADD hello /
CMD ["/hello"]

这是一个简单的 Dockerfile,我们只是添加文件和元信息。它将包含我们的二进制文件,基于一个空结构,并包含构建完整容器镜像所需的元信息。正如你所想,这种方法创建了最安全的镜像,因为攻击面完全被缩减为我们自己的应用程序。开发人员可以从零开始创建镜像,打包他们应用程序所需的所有文件。这样做可能非常棘手,需要付出很多努力来包含所有依赖项。正如本节前面提到的,这种方法在运行静态二进制文件的应用程序中效果非常好,因为这些二进制文件已经包括了所有依赖项。

这种方法还可以用于创建基于外来或高度自定义操作系统的镜像,这些操作系统没有基础镜像。在这些情况下,你应该删除所有不需要的文件和对底层硬件的所有引用。这可能非常困难,这也是为什么通常推荐使用官方容器镜像的原因。我们将在第三章中了解更多有关不同类型镜像的内容,并且如何确保它们的来源、不可变性和所有权,发布 Docker 镜像

现在我们知道了如何使用不同的方法制作容器镜像,我们应该回顾一下在 Dockerfile 中将使用到的最重要的关键字。

理解常见的 Dockerfile 键

在本节中,我们将查看最重要的键及其最佳实践。为了全面了解,最好参考 Docker Inc. 提供的文档 (docs.docker.com/engine/reference/builder/)。

容器运行时可以通过读取写在 Dockerfile 中的一系列指令来创建容器镜像。遵循这种类似配方的文件,容器运行时将组装出容器镜像。

FROM

所有 Dockerfile 都以 FROM 键开始。此键用于设置基础镜像并初始化构建过程。我们可以使用任何有效的容器镜像作为 FROM 键的有效值,并且保留了 scratch 关键字,用于基于空层构建镜像。

一个 Dockerfile 可以包含多个镜像构建过程,尽管通常我们会为每个过程使用不同的文件。

我们可以通过名称和标签来引用镜像,还可以包括其摘要以确保镜像的唯一性。如果没有使用标签,则会自动使用 latest 标签。尽量避免这种不良做法,并始终使用合适的标签,或者更好地,在使用公共镜像注册中心时,添加其摘要。还可以使用 AS 键为每个构建过程定义一个引用。通过这种方式,我们可以在使用唯一 Dockerfile 构建的容器镜像之间共享内容。多阶段构建是一种将内容从一个镜像复制到另一个镜像的做法。我们将在本章稍后的高级镜像构建过程部分探讨一个用例。

如前所述,Dockerfile 可以包含多个构建定义,我们将使用 AS 键为其命名,这样我们可以仅执行特定的目标,并使用 –-``target 命令。

要修改构建过程的行为,我们将使用 ARGENV 键。我们可以使用 –-build-arg 选项在构建过程中包含额外的参数,当找到 ARG 键时,容器运行时将评估这些值。以下行显示了如何将参数传递给 build 命令的示例:

$ docker image build –build-arg myvariable=myvalue –tag mynewimage:mytag context-directory –file myDockerfile

这里需要注意,我们通过添加 –file 参数使用了特定的 context 和非默认的 Dockerfile。我们还将 myvalue 添加到了 myvariable 变量中,并且应该在 myDockerfile 文件中包含 ARG 键,以扩展此值。

ARG

ARG 是唯一一个可以在 FROM 之前使用的键,用于使用构建参数——例如,选择特定的基础镜像。作为开发者,您可能希望为生产和开发使用两个不同的镜像,可能有一些小的修改,比如启用调试标志。我们将只使用一个 Dockerfile,但会根据传递的参数触发两个构建过程。以下简单示例可以帮助您理解这一用例:

ARG CODE_VERSION=dev
FROM base:${CODE_VERSION}
CMD /code/run-app

每当我们需要构建生产环境镜像时,我们将使用–build-arg CODE_VERSION=prod,并使用特定的基础镜像base:prod,该镜像可能包含较少的文件和二进制文件。

通常还会将ENV关键字与ARG一起使用。ENV关键字用于在构建过程中为容器添加或修改环境变量——例如,向LD_LIBRARY添加某些路径或更改PATH变量。然后,ARG可以用于在运行时修改环境变量。

要在最终的容器镜像中包含元信息,我们可以使用LABEL关键字。标签将帮助我们识别所使用的框架、发布版本、内容的创建者和维护者等,甚至是其使用的简短描述。

重要提示

OCI 定义了一些可以使用的约定标签,使用它们可能比自己创建标签更有趣,因为许多应用程序已集成此标准。你可以在github.com/opencontainers/image-spec/blob/main/annotations.md查看这些标签。你会找到像org.opencontainers.image.authorsorg.opencontainers.image.vendororg.opencontainers.artifact.description等标签,所有这些都是标准标签,已集成到许多容器相关工具中。

WORKDIR

在 Dockerfile 中定义的所有命令执行都将在相对工作目录中运行。我们可以通过使用WORKDIR关键字来改变这一点。一旦在 Dockerfile 中定义,所有后续定义的步骤将使用此环境——例如,复制文件到镜像层内。

COPY 和 ADD

向镜像层中添加文件是必须的。我们将包括我们的代码或二进制文件、库、一些静态文件等。然而,我们不应该添加证书、令牌、密码等。一般来说,任何需要一定安全性或可能频繁变化的内容,必须在运行时包含,而不是在镜像层中。

我们可以使用COPYADD关键字将文件添加到镜像层中。COPY指令将文件和目录复制到指定的镜像路径。如果源使用相对路径,文件必须包含在构建上下文目录中。如果目标使用相对路径,则会使用WORKDIR关键字作为参考路径。我们还可以通过使用–-from=<IMAGE_TARGET_NAME>从在同一 Dockerfile 中声明的其他镜像复制文件。需要注意的是,可以使用–-chown=<USERNAME or USERID>:<GROUPNAME or GROUPID>命令更改文件所有权;如果省略,则将使用当前容器执行步骤中的用户。

ADD的功能与COPY相似,但在这种情况下,你可以使用远程 URL 作为源,也可以使用 TAR 和 gzip 打包文件。如果使用压缩和打包的文件,它将在指定的目标位置自动解包和解压。

所有传递的文件都会根据镜像文件的校验和进行验证,但修改时间不会被记录,因此在执行构建过程之前,你必须注意对文件所做的更改。最好为那些经常编辑的文件(例如你的应用程序代码)添加单独的COPY命令,或者如果不确定文件更改是否已正确复制,可以简单地禁用缓存。

为了避免将某些文件复制到我们的项目文件夹中,我们可以使用.dockerignore文件。该文件包含不应包含在 Docker 构建上下文中的文件列表,因此这些文件将不会被复制到镜像层中。

运行

RUN关键字用于在构建过程中创建的容器内部执行命令行。这个操作是创建容器镜像的基础。所有传递给该关键字的命令将会被执行,结果生成的容器层将作为一个新的镜像层提交;因此,所有的RUN命令都会创建一个层。只有COPYADDRUN关键字会创建镜像层;其他任何关键字都不会增加镜像的大小,因为它们只是修改了镜像的行为或添加了元数据。你可能会看到RUN命令使用多行,从&&开始,使用\结束。这种简单的技巧可以避免为每个执行的命令创建新的层。通过这种方式,你可以将多个执行操作连接到一行,并将其拆分成多行以便阅读。行会被视为单行,因此只会创建一个层。在此过程中需要小心,因为这样做可能会丧失层的可复用性,这种方法也可能掩盖构建过程中的错误。如果你遇到包含大量命令的长命令行的问题,可以将它们分解为多个执行操作,以隔离错误,解决后再将命令行重新连接成一行。

一个简单的例子如下所示:

RUN apt-get update –qq \
&& apt-get install --no-install-recommends --no-install-suggests –qq \
curl \
ca-certificates \
&& apt-get clean

这五行命令会被解释为在同一个容器中执行的三次不同操作,因此它们只会为最终镜像创建一个层。

重要的是要理解,构建过程不会存储进程状态。这意味着如果我们运行一个进程,并且期望在下一个RUN命令执行时该进程仍然运行,它不会继续运行,因为容器运行时只会存储来自容器层的文件。这同样适用于服务或守护进程。如果你期望某些进程已经在运行,并且你应用了一些数据或文件到这些进程中,构建过程将无法正常工作。每次执行在RUN命令被处理时就结束了。

用户

默认情况下,容器运行时会以userid执行容器中的所有命令,该userid在基础镜像中定义。如果我们从头开始创建镜像,默认会以root用户运行。你会发现大多数官方的 Docker 容器镜像都会以root身份运行。Docker 公司及其他供应商准备他们的镜像以允许你安装和管理额外的软件和二进制文件。你应该确保你的镜像遵循最小权限原则,因此,必须声明哪个用户将运行容器的主进程。Dockerfile 中的USER关键字将帮助我们定义这个用户,甚至可以在同一个 Dockerfile 中多次切换用户。切换用户将确保每一行 Dockerfile 都以适当的用户运行,而使用该镜像创建的容器也会以正确的用户身份运行。

强制避免使用具有特权的用户容器。这基本上将保护你的应用程序和底层基础设施。如果你需要使用root或其他特权用户,应该明确声明这种情况。例如,你可以使用标签来表明你的镜像需要特权账户才能运行。

重要说明

如果你正在开发需要 root 用户执行的应用程序,可以使用用户命名空间映射。此功能允许我们将容器的 root 用户与主机中的普通用户映射。如果需要设置此功能,你可以参考docs.docker.com/engine/security/userns-remap/中提供的说明。

ENTRYPOINT

现在,让我们介绍如何声明将在哪些进程中运行的内容。以下关键字会添加必要的元信息,用于定义镜像中将运行的二进制文件或脚本。

我们将使用ENTRYPOINT关键字来定义容器将运行的主进程。如果未定义此关键字,则 Linux 容器将使用/bin/sh shell,Microsoft Windows 容器将使用cmd.exe。这个关键字可能已经在基础镜像中被修改为自定义值,但我们也可以在 Dockerfile 中重写它,以修改容器的行为。

你还可以使用CMD关键字,它允许你指定应该传递给 shell、Windows 命令或任何其他已定义ENTRYPOINT的参数。因此,我们可以将主进程的执行理解为ENTRYPOINTCMD关键字的拼接或叠加。例如,如果我们使用默认的/bin/sh shell 的ENTRYPOINT,并将CMD关键字定义为ping 8.8.8.8,那么在容器内执行的最终命令将是/bin/sh -c ping 8.8.8.8;换句话说,shell 会被扩展来执行我们的ping命令。我们可以在容器创建时修改它们中的任何一个,但请记住,使用USER关键字定义的用户将是进程的拥有者。

如前所述,我们可以通过更改这些非常重要的键来更改镜像行为。ENTRYPOINTCMD 由容器运行时作为数组管理,尽管我们可以在 Dockerfile 中将它们定义为字符串,这在手动执行容器时也常用。容器运行时会将这两个数组连接在一起,构建最终的命令行。由于这种行为,将 ENTRYPOINT 设置为字符串会强制忽略 CMD,但我们可以在 ENTRYPOINT 为数组时将 CMD 设置为字符串,CMD 会被视为大小为 0 的数组。

这两个值可以在容器执行时被覆盖,但通常我们会通过使用 CMD 自定义容器参数;因此,该键可以作为 Dockerfile 中的默认值使用。作为开发者,您应尽可能提供关于应用程序行为的详细信息,以使其更具可用性,LABELUSERCMD 必须出现在您的 Dockerfile 中。

EXPOSE

我们还应该将 EXPOSE 键添加到此列表中,该键定义了您的应用程序将使用的端口。您可以根据需要定义多个端口,并指定将使用的传输协议,无论是 TCP 还是 UDP。通过这些信息,您可以确保任何使用您的应用程序的人都知道您的进程将监听哪些端口。

以下示意图展示了一个简单的 Dockerfile 堆栈的实际操作,包括位于顶部的容器层:

图 2.10 – 使用 Dockerfile 创建的容器镜像层的示意图。容器层位于顶部,用于跟踪由进程创建的更改

图 2.10 – 使用 Dockerfile 创建的容器镜像层的示意图。容器层位于顶部,用于跟踪由进程创建的更改

此图表示使用 docker image history 命令获得的顺序。对于这个例子,我们执行了以下步骤:

  1. 我们使用了一个简单的 alpine:3.5 基础镜像。我们更新了包源并安装了 nginxcurl

  2. 接下来,我们准备了 NGINX 日志,将它们的输出流式传输到 /dev/stdout/dev/stderr。这将确保我们能够通过容器运行时读取应用程序日志,因为这些描述符将被容器的主进程使用。

  3. 我们复制了自定义的 NGINX 配置文件,覆盖了默认配置文件。

  4. 我们暴露了端口 80,这表示我们的主要进程将在该端口上监听。

  5. 最后,我们定义了默认的命令行。在这种情况下,/bin/sh -c "nginx –g daemon off;" 将在每次使用此镜像运行容器时执行。

HEALTHCHECK

为确保主进程在容器内正确运行,我们应该添加健康检查探针,以指示该进程是否健康。假设我们运行的是一个 Web 服务器应用程序,假如它卡住了,进程会继续运行,但功能完全丧失。为了解决这个问题,我们可以使用HEALTHCHECK键来定义一条命令行,以检查我们主应用程序的健康状态。我们可以使用带有参数的脚本或二进制文件,比如 Web 服务器的curl,或者运行数据库服务器时使用的数据库客户端。对于健康检查来说,重要的一点是,如果应用程序正常运行,命令应正确退出(exit 0)。如果我们的检查进程以任何其他信号退出,容器会因为应用程序被标记为不健康而停止运行。HEALTHCHECK键允许我们管理如何执行检查,以确保应用程序持续运行。我们可以修改标记主进程为不健康的检查次数以及检查的间隔。当达到定义的尝试次数并且返回负面响应(任何非 0 的退出代码)时,容器运行时会收到通知,即使主进程看似正常运行,服务已经不可用,容器应该停止。通常,这意味着会创建一个新的健康容器,但为了使这个过程正常工作,我们应该配置容器使用restart: always选项。我们将在第三章中深入探讨容器执行,运行 Docker 容器

VOLUME

在本节结束时,我们将回顾一下VOLUME键。由于容器镜像的构建过程依赖于多个容器的执行及其层的存储,因此该键用于避免某些目录在容器生命周期内的变化。建议使用此键来指示您为持久化存储准备的镜像中的文件夹。您可以在所有RUN键之后使用此键,以避免在构建过程中丢失应用程序的文件夹。

我们已经在本章的实验室部分提供了这些键的清晰和简洁的示例,以帮助您理解它们的使用。

在接下来的章节中,我们将为您介绍一些常用的命令行选项,这些选项通常用于构建容器镜像。

创建镜像的命令行

本节中,我们将详细介绍 Docker 以及其他常用的工具,您将使用它们来为您的项目创建容器镜像。

我们将从回顾最流行的工具之一——docker命令行开始,因其简单性和友好的环境,成为了开发者和用户的首选。

Docker 对所有参数和选项使用统一的模式。我们将使用 docker <OBJECT> <ACTION> <OPTIONS>。由于 Docker 容器运行时通过 ID 来识别其对象,因此通常会省略 <OBJECT> 原语,但你应该确保使用正确的对象。犯错的可能性很小,但记得在命令中包含对象是一个好习惯。

让我们从基础开始——也就是学习哪个命令可以创建镜像。

创建镜像的操作

我们使用 build 操作通过 Dockerfile 创建镜像。默认情况下,它会在当前目录中搜索文件,但我们可以使用任何名称和路径来存储我们的构建清单。我们必须始终声明构建上下文,通常我们会使用 –tag 选项来为镜像定义名称和标签。以下是其常见用法的示例:

图 2.11 – 执行简单的镜像构建过程

图 2.11 – 执行简单的镜像构建过程

在这个例子中,context2 是包含所有应传送到容器运行时的文件夹名称,其中一些文件应复制到最终镜像中。

这里是你可能会添加到 docker image build 中的一些最常见选项:

  • --build-arg 是我们为构建过程提供参数的方式。它通常与 ARG Dockerfile 关键字一起使用来修改镜像创建——例如,我们可以使用 build 参数将一些证书 授权CA)证书添加到命令中。

重要提示

当你处于代理服务器后面时,通常会使用 –build-arg 传递著名的 Linux HTTPS_PROXYHTTP_PROXYNO_PROXY 变量作为参数:

docker build --build-arg HTTP_PROXY=$http_proxy \

--build-arg HTTPS_PROXY=$http_proxy --build-arg NO_PROXY="$no_proxy" \

--build-arg http_proxy=$http_proxy --build-arg https_proxy=$http_proxy \

--build-arg no_proxy="$no_proxy" -t myimage:tag mycontext

  • --force-rm 会清除所有中间容器。默认情况下,在构建过程中创建的所有容器都会保留在主机中,除非你的进程成功结束,否则会占用磁盘空间。如果你知道构建过程会创建较大的层(例如,当你的应用程序在容器中编译并创建了许多依赖项,之后过程失败时),清理中间容器是一个好习惯。

  • --label 允许你为容器镜像添加额外的标签。添加所有必要的信息,例如特定的库版本、作者、简短的描述以及任何能够帮助其他开发人员理解你的内容的信息,都会受到极大的赞赏。

  • --no-cache 将让我们决定是否使用之前创建并本地存储的层。使用此参数时,构建过程会创建新的层,即使这些层已经存在于主机中。请注意,如果没有缓存,所有过程都会被执行并将中间容器数据存储在本地;因此,构建将花费更多的时间。通过尽可能多地重用已经包含在主机中的层,你将获得更快的构建过程。这对于在镜像构建中编译应用程序时尤其重要,因为如果没有缓存,任何微小的变化都会导致进程的完全重启。

  • --target 用于标识 Dockerfile 中的构建定义。它可以表示一个特定的编译过程或多阶段构建中的某个阶段。例如,我们可以使用目标来维护一个包含不同构建定义的唯一 Dockerfile,如 smallcompletedebug,每个定义需要不同的步骤和基础镜像。我们可以触发一个特定定义的构建过程,以构建适用于生产环境的最小发布版本。这个过程也可以通过参数进行管理,根据变量选择不同的基础镜像。

  • --cpuquota--cpu-shares--memory 将帮助我们管理每个构建过程可用的资源。如果你在桌面计算机上资源不足,这一点尤其重要。

现在我们已经了解了用于构建镜像的命令行,让我们来看一下如何管理镜像。

管理容器镜像

容器镜像将存储在主机的不同目录中,将数据文件与元数据分离。文件的存储位置将取决于你使用的容器运行时,或者在 Podman 的情况下,它们可能会存储在你的主目录中。这个运行时以无根模式运行,并且没有守护进程,因此非常适合用户容器。无论如何,你将永远无法直接访问容器镜像文件。

在 Docker(以及其他任何容器运行时客户端)中最常用的操作之一是 list(或 ls),用于列出主机(或远程运行时)中可用的对象。默认情况下,镜像可以通过它们的名称(或仓库——我们将在第三章《Docker 镜像的传输》)中学习如何存储和管理镜像)、ID、标签、创建时间和大小来表示。在这个上下文中,大小是镜像在主机上占用的空间。镜像越小越好,这也是为什么作为开发人员,你应该关注镜像的内容。只包含绝对必要的文件,并考虑你的层策略,尤其是在你与项目共享依赖项时。使用 .dockerignore 文件来避免不必要的文件,因为这可以帮助你节省大量空间:

$ docker image list
REPOSITORY     TAG           IMAGE ID       CREATED        SIZE
example1       0.0           f7bba7eac35e   22 hours ago   9.51MB
postfix        test          a8768bd1ec8f   2 days ago     169MB
four           latest        3de85feddb15   2 days ago     105MB
three          latest        55f07527310e   2 days ago     105MB
frjaraur/two   180223        8e64333d6033   2 days ago     105MB
frjaraur/one   180223        331ed31f8816   2 days ago     105MB
one            latest        331ed31f8816   2 days ago     105MB
<none>         <none>        7ed6e7202eca   About a minute ago 72.8MB
alpine         latest        b2aa39c304c2   10 days ago    7.05MB
debian         stable-slim   4ea5047878b3   12 days ago    80.5MB

上面的代码片段显示我们有多个名称(仓库)包含相同的内容;我们知道这一点是因为它们有相同的 ID。具有相同 ID 的镜像是相同的,它们只是在标签上有所不同。因此,我们可以为镜像添加多个标签。我们将使用docker tag <ORIGINAL> <NEWTAG>来标记镜像。为了能够将镜像上传到注册表,这是必要的,因为它们存储在自己的仓库中。标签将帮助您在我们的注册表中识别镜像,但尽管标签在每个仓库中是唯一的,我们仍然可以有多个标签指向相同的镜像,您需要确保使用的是正确的镜像。

开发人员可能会选择按照应用程序的生命周期来标记他们的镜像,您可能会遇到许多使用release.minor.fixes模型标记的镜像。这是一种良好的做法,添加一些关键标签来识别作者、项目等,会改善您的工作。

您可能还注意到有一个没有标签或名称的镜像。这是一个悬空容器镜像,它没有被其他镜像使用,并且因为另一个使用相同仓库和标签的镜像被创建而未被标记。它没有被任何镜像引用,现在只占据空间。这些悬空的镜像应该被删除,我们可以使用docker image prune来删除它们。

要删除单个镜像,我们可以使用docker image rm <IMAGE>。需要理解的是,如果镜像在容器或其他镜像中有引用,是无法删除的。我们可以通过使用–force来强制删除,但只有在容器被停止(或已死)的情况下才有效。还值得注意的是,可以通过使用镜像的 ID 来删除多个镜像标签,而不是使用它们的镜像仓库名称。

要查看容器镜像对象中包含的所有信息,我们可以使用docker image inspect <IMAGE>。该命令将展示非常有用的信息,包括镜像摘要(如果镜像有来自注册表的引用)、镜像构建时的架构、其标签、层次结构,以及用于启动容器的配置,比如环境变量和需要执行的主要命令。

值得介绍一些格式化和过滤选项,我们可以在某些命令中使用:

  • --filter将允许我们使用定义的标签从列表中过滤对象。这适用于容器运行时提供的任何列表——例如,如果我们使用environment键标记了我们的镜像,我们可以用它来获取特定的镜像:

    $ docker image list --filter label=environment
    REPOSITORY     TAG           IMAGE ID       CREATED        SIZE
    frjaraur/two   180223        8e64333d6033   2 days ago     105MB
    frjaraur/one   180223        331ed31f8816   2 days ago     105MB
    $ docker image list --filter label=environment=production
    REPOSITORY     TAG           IMAGE ID       CREATED        SIZE
    --format works with *Go templates* to manipulate output for listing (and logs from containers). The container runtime and clients work with *JSON* streams; hence, using these templates will help us interpret objects’ data. For example, we can use table to obtain a table-like output, with the keys we need to review:
    
    

    $ docker image list \

    --format "table {{.Repository}}:{{.Tag}}\t{{.Size}}"

    REPOSITORY:TAG SIZE

    example1:0.0 9.51MB

    postfix:test 169MB

    frjaraur/two:180223 105MB

    two:latest 105MB

    one:latest 105MB

    frjaraur/one:180223 105MB

    alpine:latest 7.05MB

    docker image ls --format "{{json .}}"。要获取特定镜像的所有标签,我们可以使用docker image inspect <IMAGE> --format "{{ index .Config.Labels }}"

    
    

在下一节中,我们将学习命令行中可用的选项,用于在主机或用户之间共享镜像。

共享镜像的操作

你可能在想,所有这些示例都是基于主机构建的,因此我们需要能够将我们的镜像与其他开发者共享,甚至将它们迁移到准备好管理应用程序生命周期的服务器上(例如测试、预生产、认证或生产环境)。我们可以将容器镜像导出并导入到新位置,但使用镜像注册中心是更好的选择,因为这些存储库将与容器的协调器共享,而容器运行时将自动为我们执行拉取过程。

docker image pulldocker image push 分别用于拉取和推送镜像。为了使其正常工作,通常需要登录到你的注册中心。要访问你的注册中心,你需要用户名和密码。Docker Hub(docker.io)可能是最知名的容器注册中心。它作为一个云服务,提供镜像存储、扫描和自动化镜像构建的功能。还有其他选项;所有云提供商都提供注册中心服务,许多代码仓库也提供镜像存储(因为它们被视为代码工件)。我们可以在本地部署一些这些解决方案,但我们也可以找到像 VMware 提供的 Harbor 这样的解决方案,它专门为数据中心准备。你可能会注意到,容器运行时也存储镜像,事实上,它可以被认为是一个注册中心——一个本地注册中心。podman 命令行工具支持本章中描述的所有操作,并且可以作为 Docker 客户端的替代品,它将你的镜像构建为 localhost/IMAGE_NAME:TAG,其中 IMAGE_NAME 是仓库的名称。我们将在第三章《运输 Docker 镜像》中学习镜像注册中心是如何工作的;现在,我们只回顾一些最常用的共享镜像的选项。

当有人请求我们提供镜像时,我们可以使用 docker image save 将容器镜像导出到一个文件中。这将完全打包所有层和元信息。默认情况下,标准输出将用于流式传输所有数据,但我们可以使用 –output 选项指定一个文件。你可以将这个文件复制到另一个工作站或服务器,并执行 docker image load 来导入所有镜像层和元数据。默认情况下,该命令将使用标准输入,但我们可以添加 –input 选项来指定一个文件:

图 2.12 – 将镜像保存到文件中以便共享很简单

图 2.12 – 将镜像保存到文件中以便共享很简单

我们可以验证镜像大小是否保留,并且如果列出包文件中包含的文件,我们将获得层和元数据文件。

Docker 客户端可以使用docker image load将镜像集成到本地注册表中,但我们也可以使用docker image import仅上传镜像层。这一点很有趣,因为它可以用作从零开始构建的基础镜像,但需要注意的是,如果没有元数据清单 JSON 文件,你将无法执行容器。你需要添加其暴露的端口、用户、主进程、参数等信息。

正如你所想象的,docker image savedocker image load在小环境中工作良好,但当你需要在十几个服务器上分发文件时,它们就不再适用了。如果没有保持良好的标签管理,镜像就很难同步;因此,尽量使用具有代表性的标签,并为你的镜像打上标签,帮助他人理解其内容。

在回顾一些最佳实践和建议之前,我们将学习一些有助于优化工作流的主题,以便能够构建新的镜像。

高级镜像创建技术

在本节中,我们将回顾一些可用的选项和技术,以加速构建过程并优化镜像大小。

第一章《使用 Docker 的现代基础设施和应用程序》中,我们了解到镜像是一个由多个层组成的包。这些层一个接一个地分布,包含所有的文件,所有这些层的合并给我们提供了一个优化的文件分发,旨在减少磁盘空间使用,采用写时复制(CoW)文件系统。当一个较低层的文件需要被修改时,如果该文件尚未存在于顶层,它将被复制到顶层。所有未修改的文件都以只读模式使用。由此可见,正确管理 CoW 过程有助于加速镜像创建时间。

每当我们在 Dockerfile 的末尾添加新的RUN命令时,所有先前的层都会被使用(除非我们指定--no-cache);因此,容器运行时只需要根据这些新变化创建新的层。然而,每当我们在 Dockerfile 中间添加一行来复制新文件,或者当文件被修改时,所有在此更改之后的层都会失效。COPYADDRUN都会发生这种情况,因为这些 Dockerfile 指令会添加新的层,但WORKDIRENV也可能修改构建过程的行为,从而影响后续的层。一旦某个层发生变化,容器运行时必须重新构建所有下游层,即使我们在上述变化后没有修改 Dockerfile 中的任何行。

以下是一些可能有助于构建过程的建议:

  • 多阶段构建是最小化和保护容器镜像的关键。我们将在 Dockerfile 中定义不同的目标,将它们作为阶段来编译代码和依赖项,并且只将所需的文件添加到最终的镜像中。通过这种技术,我们可以确保最终镜像中不会包含任何编译器。这是一个简单的示例:

    FROM alpine:3.17.2 as git # First stage, install git on small Alpine
    RUN apk add git
    FROM git as fetcher # Second stage, fetching repository
    WORKDIR /src
    RUN git clone https://gitlab.com/myrepo/mycode.git .
    FROM nginx: 1.22.1-alpine as webserver
    COPY --from=fetcher /src/html/ /usr/share/nginx/html
    

    这是一个非常简单的 Dockerfile;最终镜像只包含从我们的 Git 代码仓库中获取的docs目录。我们将在本章的实验部分看到一个更好的示例。

  • 层的顺序对于加速构建和维护应用程序更改至关重要。尽量找到最合理的顺序来声明 Dockerfile 的配方。如果我们有一些耗时的任务,例如安装大量软件包,最好将这些任务放在构建过程的开头。相反,我们更频繁更改的文件,可能是我们应用程序的代码,应该接近 Dockerfile 的末尾。

  • 这同样适用于COPY关键字;如果您的应用程序有很多依赖,一次性复制所有代码和需求可能会带来问题。更好的做法是将文件拆分成不同的COPY语句,首先复制模块需求声明文件,然后更新这些依赖,之后再复制构建代码。这样可以确保所有的代码更改不会导致容器构建过程中再次下载依赖。

  • 我们再次提醒您,容器镜像中应仅保留必要的文件,避免包含任何不必要的文件。这将增加构建时间和最终镜像的大小,有时决定这些文件存储位置也很重要。此外,使用.dockerignore可以帮助您避免将不必要的文件发送到容器运行时,即使这些文件不会保留在最终镜像中。如果不确定文件内容、是否会重新构建之前的工件、是否会重新构建它们,或者日志等内容,请避免使用COPY . /src来复制整个目录。

  • 安装包时要避免安装不必要的依赖。根据您使用的基础操作系统发行版,您会有不同的参数或选项来仅安装特定的包,从而避免安装那些推荐但不必要的关联包。您可能需要在安装之前更新包列表;如果您没有添加或修改任何包仓库,请在开始时执行此操作。如果不打算安装其他包,也建议清理包缓存。我们可以使用RUN --mount type=cache,target=DIRECTORY_PATH <INSTALL_EXPRESSION>来安装包。这个选项会在不同的构建过程中保持定义目录的内容,从而加速新软件的安装。

  • 敏感信息不应包含在容器镜像中。可以通过 COPYADD 键,或者甚至作为 docker build 命令的参数,将一些包含密码、证书、令牌等的文件包含在 Dockerfile 中,并在构建完成前将其删除。尽管这些看起来不是坏解决方案,但它们并不完美,因为你可能会不小心留下敏感数据。如果机密用于下载二进制文件或库,多阶段构建可以帮助我们,并且可以轻松将它们复制到最终阶段,而无需将任何敏感数据添加到其层中。然而,还有一个更好的解决方案——使用 buildx。这个 Docker 工具包括一个选项,可以在特定的 RUN 步骤中挂载机密,而无需将其存储在任何层中,就像它们是来自卷的文件一样。下面是一个简单的示例:

    FROM python: 3.9.16-alpine3.17
    COPY mycript.sh .
    RUN --mount=type=secret,id=mysecret ./myscript.sh
    

    要将一个值传递给 mysecret 键,我们可以使用环境变量——例如,我们可以通过以下命令行执行构建过程:

    $ SECRETVALUE="mysecretpass" docker image buildx build --secret id= SECRETVALUE <CONTEXT>
    

重要提示

buildx 甚至允许我们挂载包含数据的文件,例如用户凭证、令牌、证书等,以便在构建过程中运行的容器内部作为机密使用,方法是使用 docker image buildx build –secret id=mysecret,src=<FULLPATH_TO_SECRETFILE>。默认情况下,这些文件将包含在容器中的 /run/secrets/<SECRETID>,但我们可以在 Dockerfile 的 mount 定义中添加 target,并指定我们希望创建的目标文件的完整路径。

  • 保持层尽可能小是一个好习惯。我们将尽量使用 RUNCOPYADD,执行尽可能多的更改,尽管这可能会影响层的可重用性。我们将把多个 RUN 执行合并为一行。较少的 Dockerfile 行意味着更小的缓存,这很好,但你不能为新镜像过于频繁地重用层。你 Dockerfile 之间的任何小变动都会使缓存失效。

重要提示

我们可以使用 heredocs 格式将多行合并。这提高了 Dockerfile 的可读性。例如,我们可以编写以下内容:

RUN <<EOF

set -e

apt-get update -qq

apt-get install mypackage1 mypackage2

EOF

  • Docker 客户端安装还提供了 buildx 的独特功能,帮助我们减少构建时间和大小。我们可以配置垃圾回收,以根据时间删除未使用的层,并启用远程缓存位置。这个功能改善了 CI/CD 流水线,特别是那些使用分布式缓存的项目,这些项目必须编译大量的依赖或低级语言,如 CRust

  • 可以使用docker buildx build –platform命令和一个独特的 Dockerfile 来构建多个处理器架构,如riscv64arm64。过去,我们通常为每个架构使用不同的 Dockerfile,并且需要使用不同处理器的机器来执行构建过程。这个新特性允许你在自己的笔记本电脑上使用 Docker Desktop 为不同平台准备镜像。在本章的实验部分,我们将为arm64平台准备一个容器镜像。

  • 如果镜像包含许多层,我们可以通过使用–squash显著减少最终镜像的大小。合并容器镜像是 Docker 容器运行时提供的实验性功能。这意味着我们需要启用docker.json文件,一旦配置好,我们就能使用docker image build –squash命令。将层数减少为一个层可以减小镜像大小,但你将失去共享层的优势。需要在此提到的是,你不应期待奇迹。镜像合并的效果取决于使用的层数,因此,最终大小可能与使用较少层时的大小差不多。

在开始实验之前,我们将通过概述构建容器镜像的最佳实践来回顾本章所学的内容。

容器镜像创建的最佳实践

在这一部分,我们将推荐一份最佳实践清单,供你在创建应用程序时参考,从而提高应用程序的安全性、可重用性和构建过程:

  • 仅包括应用程序所需的文件。不要安装应用程序不需要的包、二进制文件、库和任何其他文件,并保持镜像内容尽可能小,暴露最小的攻击面。

  • 使用.dockerignore文件避免将不必要的文件从构建上下文传递到容器运行时。

  • 准备调试版本的镜像,包括一些二进制文件或工具,以帮助你解决问题,但永远不要在生产环境中使用这些镜像。

  • 准备好 Dockerfile 的逻辑来适应你的变更,因此,应将代码放在文件的接近尾部,并考虑到可能需要更改多少模块或依赖项,以确保更新能在正确的部分执行。

  • 每当可能时,使用层缓存以加速构建过程,并记住,使用多个层会提高可重用性,但当文件需要运行时更改时,会影响性能。

  • 除非绝对必要,否则不要在应用程序中使用root。如果使用了root,你应该了解其风险并加以管理。在构建过程中,你可以多次使用USER关键字来更改执行用户,但始终确保在 Dockerfile 的最后使用非 root 用户。

  • 永远不要在最终的容器镜像中包含敏感信息,如证书、密码和令牌。这些信息应该在运行时提供。使用 Docker 的 buildx 仅在构建过程中包含秘密信息。

  • 在你的 Dockerfile 中声明所有应用程序的要求,如进程用户、暴露的端口以及需要执行的命令行。这将有助于其他开发者使用你的应用程序。

  • 使用标签添加关于应用程序生命周期、维护者、所需特殊库等信息。这些信息对于其他开发者来说非常有帮助,可以帮助他们理解如何将他们的代码集成到你的镜像中,或是如何改进你的 Dockerfile。

  • 镜像的大小很重要,特别是在你将容器化应用程序运行在分布式环境中时。如果必须在主机上创建容器,容器运行时需要下载镜像。根据你对镜像所做的更改数量,这可能会成为一个挑战,并且如果你的平台定义了 always-pull 策略,应用程序问题的弹性可能会受到影响。我们已经讨论了一些减少镜像大小的技术;请使用它们,但要记住,层的可重用性可能会受到影响。

有了这个清单,你可以准备自己的容器镜像创建工作流程。部分建议可能比较棘手,需要一些练习,但我可以向你保证,这是值得的,你将为你的应用程序交付高质量的镜像。

现在我们已经了解了不同的构建镜像方法、常用的命令行和一些创建良好且安全的镜像的高级技术与建议,是时候在下一节中通过一些实验来将这些知识付诸实践。

实验

以下实验将提供示例,帮助你将本章中学到的概念和流程付诸实践。我们将使用 Docker Desktop 或任何其他容器运行时。我们还将使用不同的工具,如 Podmannerdctl,展示你手头的一些可能性,尽管某些实验所需的特定功能可能仅在某个特定工具中可用(或某个工具具有更友好的界面)。在这些情况下,我们将要求你使用特定的命令行界面。

所有实验的第一步是下载本书 GitHub 仓库的最新版本,地址为 github.com/PacktPublishing/Docker-for-Developers-Handbook.git。你可以通过执行 git clone https://github.com/PacktPublishing/Docker-for-Developers-Handbook.git 来下载所有内容。如果你之前已经下载过,确保通过在该目录下执行 git pull 来获取最新版本。

本节将从一个简单的实验开始,介绍如何使用缓存加速构建过程。所有在这些实验中呈现的命令将会在 Docker-for-Developers-Handbook/Chapter2 目录下执行。

重要提示

为了向您展示与容器交互的不同工具,我们将在这些实验中使用nerdctl,但您也可以使用podmandocker(独立使用或在 Docker Desktop 中)。每个工具都有各自的特点,但大多数容器中的工作将类似地执行。如果某些命令需要特定工具,我们会明确通知您。请按照本书 GitHub 代码库中的具体说明来安装每个工具。我们将使用containerd作为容器运行时,并将nerdctl命令行集成到 WSL 2 中,但所有实验也可以使用 Docker 命令行来执行,docker替代nerdctl

缓存层

在这个第一个实验中,我们将回顾缓存对加速构建过程的重要性。我们将使用nerdctl,但dockerpodman也可以使用,此外还有buildahbuildah.io),这是一个专门为增强构建过程而准备的开源工具。

我们将构建一个简单的Node.js应用程序,它是我几年前为快速演示而准备的。它的唯一目的是展示关于运行它的容器的一些信息、请求头以及它的版本。稍后在本书中,了解容器编排器中的负载均衡过程将会很有趣,但现在我们将专注于构建过程:

  1. 首先,我们将进入Chapter2/colors/nodejs文件夹,并执行一个简单的构建,使用ch2lab1:first作为镜像名称和标签。我们将在此过程中使用以下 Dockerfile:

    FROM docker.io/node:18.14.2-alpine3.16
    ENV APPDIR /APP
    WORKDIR ${APPDIR}
    COPY package.json package.json
    RUN apk add --no-cache --update curl \
    && rm -rf /var/cache/apk \
    && npm install
    COPY app.js app.js
    COPY index.xhtml index.xhtml
    CMD ["node","app.js","3000"]
    EXPOSE 3000
    

    请注意,我们在这里将内容复制分为三行,尽管我们本可以使用一行来包含所有内容——例如,使用COPY . .

重要提示

如您所见,这个 Dockerfile 没有包含任何USER指令,但它的应用程序在没有任何特权的情况下运行,因为它非常简单,不使用任何 Linux 功能或特权端口。无论如何,最好包含USER指令,您可以将其添加到您的本地仓库。以下步骤中描述的所有内容都能正常工作。

  1. 我们将向build命令添加time来测量构建过程所需的时间:

     $ time nerdctl build -t ch2lab1:one \
      --label nodejs=18.14.2 \
      --label=base=alpine3.16 \
      nodejs  --progress plain
    #1 [internal] load .dockerignore
    #1 transferring context: 2B done
    #1 DONE 0.0s
    #2 [internal] load build definition from Dockerfile
    #2 transferring dockerfile: 311B done
    #2 DONE 0.0s
    #3 [internal] load metadata for docker.io/library/node:18.14.2-alpine3.16
    #3 DONE 1.1s
    #4 [internal] load build context
    #4 transferring context: 90B done
    #4 DONE 0.0s
    

    在这些行之后,我们的 Dockerfile 开始被容器运行时处理:

    #5 [1/6] FROM docker.io/library/node:18.14.2-alpine3.16@sha256:84b677af19caffafe781722d4bf42142ad765ac4233960e18bc526ce036306fe
    #5 resolve docker.io/library/node:18.14.2-alpine3.16@sha256:84b677af19caffafe781722d4bf42142ad765ac4233960e18bc526ce036306fe 0.0s done
    #5 DONE 0.1s
    #5 [1/6] FROM docker.io/library/node:18.14.2-alpine3.16@sha256:84b677af19caffafe781722d4bf42142ad765ac4233960e18bc526ce036306fe
    #5 sha256:aef46d6998490e32dcd27364100923d0c33b16165d2ee39c307b6d5b74e7a184 0B / 2.35MB 0.2s
    

    一旦所需的层被加载,我们的任务将开始执行命令。在我们的示例中,需要安装很多包:

    #8 [4/6] RUN apk add --no-cache --update curl && rm -rf /var/cache/apk && npm install
    #0 0.115 fetch https://dl-cdn.alpinelinux.org/alpine/v3.16/main/x86_64/APKINDEX.tar.gz
    #8 0.273 fetch https://dl-cdn.alpinelinux.org/alpine/v3.16/community/x86_64/APKINDEX.tar.gz
    #8 0.503 (1/5) Installing ca-certificates (20220614-r0)
    ...
    #8 0.601 (5/5) Installing curl (7.83.1-r6)
    #8 0.618 Executing busybox-1.35.0-r17.trigger
    #8 0.620 Executing ca-certificates-20220614-r0.trigger
    #8 0.637 OK: 10 MiB in 21 packages
    #8 3.247
    #8 3.247 added 3 packages, and audited 4 packages in 2s
    #8 3.247
    #8 3.247 found 0 vulnerabilities
    #8 3.248 npm notice
    #8 3.248 npm notice New patch version of npm available! 9.5.0 -> 9.5.1
    #8 3.248 npm notice Changelog: <https://github.com/npm/cli/releases/tag/v9.5.1>
    #8 3.248 npm notice Run `npm install -g npm@9.5.1` to update!
    #8 3.248 npm notice
    #8 DONE 3.3s
    Once all execution lines are concluded, a tar file is created with the layer where changes were made:
    #11 sending tarball
    #11 sending tarball 0.6s done
    #11 DONE 0.8s
    time command before nerdctl build:
    
    real    0m12.588s
    user    0m0.009s
    nodejs/app.js file. Change var APP_VERSION="1.0"; to any other value, such as the following:
    
    

    var APP_VERSION="1.1";.

    
    Execute the first step again with a new tag, and note the `CACHED` lines in the output:
    
    

    $ time nerdctl build -t ch2lab1:two \

    --label nodejs=18.14.2 \

    --label=base=alpine3.16 nodejs \

    --progress plain

    1 [内部] 加载 .dockerignore

    1 正在传输上下文:已完成 2B

    CACHED 表示这些层已经被创建;我们使用这些层,而不是执行实际的命令来创建层:

    #7 [3/6] COPY package.json package.json
    app.js file; hence, a new layer must be created:
    
    

    9 [5/6] 复制 app.js app.js

    9 完成 0.0 秒

    10 [6/6] 复制 index.xhtml index.xhtml

    10 完成 0.0 秒

    
    All successive lines will also create new layers because we *broke the cache*. A new line of changes was created:
    
    

    11 正在发送 tarball

    11 正在发送 tarball 0.6 秒 已完成

    11 完成 0.7 秒

    解包 docker.io/library/ch2lab1:two (sha256:bfffba0cd2d7cc82f686195b0b996731d0d5a49e4f689a3d39c7b0e6c57dcf0e)…

    
    Finally, we obtained our new image:
    
    

    加载的镜像:docker.io/library/ch2lab1:two

    实际耗时    0m1.272s

    用户    0m0.007s

    index.xhtml 或我们的简单代码 app.js,所有的包将再次被下载。

    
    
    
    
  2. 让我们通过更改 Dockerfile 中的复制过程来重复这个过程:

    FROM docker.io/node:18.14.2-alpine3.16
    ENV APPDIR /APP
    WORKDIR ${APPDIR}
    COPY . .
    RUN apk add --no-cache --update curl \
    && rm -rf /var/cache/apk \
    && npm install
    CMD ["node","app.js","3000"]
    EXPOSE 3000
    

    我们再次执行构建过程。我们预期它会在 12 秒内完成,因为基础镜像已经在我们的主机中:

    $ time nerdctl build -t ch2lab1:three \
    --label nodejs=18.14.2 \
    --label=base=alpine3.16  nodejs  \
    --progress plain
    …
    …
    #6 [2/4] WORKDIR /APP
    #6 CACHED
    …
    COPY step, and no cache can be used:
    
    

    7 [3/4] 复制 . .

    7 完成 0.0s

    8 [4/4] 运行 apk add --no-cache --update curl && rm -rf /var/cache/apk && npm install

    ...

    ...

    8 完成 2.8s

    ...

    ...

    9 发送 tarball 0.6s 完成

    9 完成 0.8s

    解包 docker.io/library/ch2lab1:three (sha256:b38074f0ee5a9e6c4ee7f68e90d8a25575dc7df9560b0b66906b29f3feb8741c)...

    加载的镜像:docker.io/library/ch2lab1:three

    实际耗时    0m4.634s

    用户    0m0.004s

    APP_VERSION 改为新的变量值,看看重新构建时会发生什么。将其从 var APP_VERSION="1.1"; 更改为 var APP_VERSION="1.2";,然后再次执行:

    $ time nerdctl build -t ch2lab1:four \
    --label nodejs=18.14.2 \
    --label=base=alpine3.16  nodejs  \
    --progress plain
    #1 [internal] load build definition from Dockerfile
    …
    …
    #6 [2/4] WORKDIR /APP
    #6 CACHED
    

    之前的层已被缓存,但由于一个最小的更改打破了所有的进程,因此必须重新创建这些层:

    #7 [3/4] COPY . .
    #7 DONE 0.0s
    #8 [4/4] RUN apk add --no-cache --update curl && rm -rf /var/cache/apk && npm install
    #0 0.084 fetch https://dl-cdn.alpinelinux.org/alpine/v3.16/main/x86_64/APKINDEX.tar.gz
    #8 0.172 fetch https://dl-cdn.alpinelinux.org/alpine/v3.16/community/x86_64/APKINDEX.tar.gz
    #8 0.307 (1/5) Installing ca-certificates (20220614-r0)
    …
    #8 0.376 OK: 10 MiB in 21 packages
    …
    #8 3.433 added 3 packages, and audited 4 packages in 3s
    …
    #8 DONE 3.5s
    …
    …
    #9 DONE 0.8s
    unpacking docker.io/library/ch2lab1:four (sha256:75ba902c55459593f792c816b8da55a673ffce3633f1504800c90ec9fd214d26)...
    Loaded image: docker.io/library/ch2lab1:four
    real    0m5.210s
    user    0m0.007s
    sys     0m0.000s
    

    如你所见,花费的时间与之前的执行相同,因为容器运行时无法识别和隔离这些小的更改,并且无法重用之前创建的层。

    
    

在本实验中,我们回顾了缓存层的工作原理,并讨论了如何通过为应用程序选择正确的 Dockerfile 逻辑来避免构建问题。

在下一个实验中,我们将执行一个多阶段构建过程,使用空层作为最终镜像。

执行多阶段构建过程

这是一个非常有趣的用例,因为我们的代码是用 Go 语言编写的,并且我们将包括静态依赖:

  1. 移动到 Chapter2/colors 文件夹,并这次使用 go 子文件夹。多阶段的 Dockerfile 如下所示:

    FROM golang:1.20-alpine3.17 AS builder
    WORKDIR /src
    COPY ./src/* .
    RUN mkdir bin && go build -o bin/webserver /src/webserver.go
    FROM scratch
    WORKDIR /app
    COPY --from=builder /src/bin/webserver .
    CMD ["/app/webserver"]
    USER 1000
    EXPOSE 3000
    
  2. 我们将使用 golang:1.20-alpine3.17 镜像来编译我们的代码。编译后的二进制文件将从 builder 镜像复制到我们的最终镜像:

    $ nerdctl build -t ch2lab1:go.1 \
    --label golang=1.20 --label=base=alpine3.17  go  \
    --progress plain
    #1 [internal] load .dockerignore
    #1 transferring context: 2B done
    ...
    FROM key is reached and the image build process starts:
    
    

    6 [builder 1/4] 从 docker.io/library/golang:1.20-alpine3.17@sha256:48f336ef8366b9d6246293e3047259d0f614ee167db1869bdbc343d6e09aed8a

    6 完成 3.2s

    6 [builder 1/4] 从 docker.io/library/golang:1.20-alpine3.17@sha256:48f336ef8366b9d6246293e3047259d0f614ee167db1869bdbc343d6e09aed8a

    6 提取 sha256:752c438cb1864d6b2151010a811031b48f0c3511c7aa49f540322590991c949d

    ...

    6 完成 4.8s

    7 [builder 2/4] 工作目录 /src

    7 完成 0.2s

    8 [builder 3/4] 复制 ./src/* .

    8 完成 0.0s

    9 [builder 4/4] 运行 mkdir bin && go build -o bin/webserver /src/webserver.go

    达到 FROM 键并开始新的镜像构建过程——在这个例子中,仅复制了之前内容:

    #10 [stage-1 2/2] COPY --from=builder /src/bin/webserver .
    #10 DONE 0.0s
    #11 exporting to oci image format
    ...
    ...
    #11 sending tarball 0.1s done
    #11 DONE 0.3s
    unpacking docker.io/library/ch2lab1:go.1 (sha256:527a2d2f49c7ea0083f0ddba1560e0fc725eb26ade22c3990bb05260f1558b0b)...
    Loaded image: docker.io/library/ch2lab1:go.1
    
    
    
  3. 最终的镜像非常小,因为它仅包含我们的应用代码:

    $ nerdctl image ls
    REPOSITORY    TAG       IMAGE ID        CREATED              PLATFORM       SIZE         BLOB SIZE
    ch2lab1       one     7f63598f2144    2 hours ago          linux/amd64    186.6 MiB    51.7 MiB
    ch2lab1       go.1      527a2d2f49c7    4 minutes ago        linux/amd64    6.3 MiB      3.6 MiB
    

    在这个输出中,你可以对比我们获得的不同大小(因为本书 GitHub 仓库中的代码可能会有更新,导致大小有所变化)。

使用二进制文件从零开始创建镜像可能非常棘手,但这是交付我们应用程序的最佳方式。

本实验展示了如何通过使用静态构建二进制文件从零开始创建容器镜像,这些镜像是您可以创建的最佳应用镜像。

在下一个实验中,我们将使用 Docker 的buildx功能,因此我们将使用docker命令行。

为不同架构构建镜像

如果您使用nerdctl命令行跟随实验,请退出Rancher Desktop并启动Docker Desktop(或您自己的 Docker 引擎实现)。

重要说明

Podman 和 nerdctl 在新版本中也提供了多平台支持,并且多架构构建通常是可用的;因此,任何这些工具都适用于本实验。

请注意,当您从一个容器运行时切换到另一个容器运行时时,镜像列表会完全不同。每个容器运行时都会管理自己的环境,如预期那样。

我们将在Chapter2/colors文件夹内继续本实验。我们将为多个架构构建镜像——即amd64arm64

  1. 我们将使用buildx配合–-platform参数和arm64。但首先,我们将确保通过执行docker buildx ls命令,能够为其他架构构建镜像:

    $ docker buildx ls
    NAME/NODE     DRIVER/ENDPOINT STATUS  BUILDKIT PLATFORMS
    default *     docker
    arm64 architecture build:
    
    

    $ docker buildx build -t ch2lab1:six \

    --label nodejs=18.14.2 \

    --label=base=alpine3.16 \

    nodejs --progress plain \

    --platform arm64 \

    --load –no-cache

    1 [internal] 从 Dockerfile 加载构建定义

    1 正在传输 dockerfile: 32B 完成

    1 完成 0.0s

    ...

    在此过程中下载了 aarch64 架构的镜像:

    #8 0.348 fetch https://dl-cdn.alpinelinux.org/alpine/v3.17/main/aarch64/APKINDEX.tar.gz
    #8 0.753 fetch https://dl-cdn.alpinelinux.org/alpine/v3.17/community/aarch64/APKINDEX.tar.gz
    #8 1.204 (1/5) Installing ca-certificates (20220614-r4)
    …
    …
    #8 1.341 Executing busybox-1.35.0-r29.trigger
    #8 1.366 Executing ca-certificates-20220614-r4.trigger
    ...
    …
    #11 writing image sha256:2588e9451f156ca179694c5c5623bf1c80b9a36455e5f162dae6b111d8ee00fd done
    #11 naming to docker.io/library/ch2lab1:six done
    arm64 Alpine image was used, even though we used the same Dockerfile from previous labs.
    
    
    
  2. 我们可以通过使用docker inspect来验证此镜像架构:

    $ docker image inspect ch2lab1:six \
    --format='{{.Architecture}}'
    arm64 architectures and can be used in some QNAP NAS platforms.
    

在这个构建过程中,我们还使用了--load–-no-cache。第一个参数用于将构建好的镜像加载到我们的容器运行时。如果我们不与 Docker 的buildx一起使用它,镜像默认只会作为新构建的缓存使用。为了避免在此构建过程中使用任何缓存层,我们使用了–-no-cache,这样可以确保 Dockerfile 中定义的每个步骤都被完整执行。

本实验展示了通过使用统一的 Dockerfile,并执行带有–-``platform参数的构建过程,您可以为任何可用架构准备镜像。

总结

在本章中,我们学习了如何为应用程序创建容器镜像。我们首先概述了 CoW 文件系统,这是通过使用层来创建容器镜像的基础。我们研究了不同的构建镜像方法,以及它们的优缺点和示例。使用 Dockerfile 是最好的方法,因为它通过按顺序编写的不同步骤提供了一种可重现的创建镜像方式。我们简要回顾了在 Dockerfile 和命令行中可以使用的最重要的指令及其参数。由于容器镜像构建过程可能比较复杂,我们介绍了一些可以用来改进工作流的高级功能和实践,以提高速度、可重用性和质量。

在下一章中,我们将简要概述镜像仓库,学习如何在其中存储和标记我们的镜像,并学习如何通过签名和扫描容器镜像来提高完整性和安全性。

第三章:3

分享 Docker 镜像

分享容器镜像是能够在任何地方运行应用程序的关键。你已经在工作站或笔记本电脑上构建了应用程序组件,现在你准备将它们移动到不同的平台阶段。本章将介绍镜像如何存储并与其他用户或编排平台共享。我们还将回顾各种容器镜像签名的方法,以提高开发工作流中的安全性。我们还将学习如何使用内容扫描工具来发现容器镜像中可能存在的安全问题。到本章结束时,你将能够交付安全可信的镜像到生产环境。

本章将涵盖以下主要内容:

  • 容器镜像注册表和仓库

  • 通过签名容器镜像来提高安全性

  • 使用镜像扫描工具分析容器镜像内容

本章将教授你不同的工具和技术,这些工具和技术可用于交付安全的镜像,这将极大地提高你在项目中使用软件容器的效果。

技术要求

你可以在 github.com/PacktPublishing/Containers-for-Developers-Handbook/tree/main/Chapter3 找到本章的实验内容,里面有一些章节中省略的详细解释,以便更容易跟进。本章的实战视频可以在 packt.link/JdOIY 找到。

容器镜像注册表和仓库

第一章《使用 Docker 的现代基础设施和应用》中,我们讨论了为什么软件容器变得如此流行。在第二章《构建 Docker 镜像》中,我们学习了如何通过使用容器模板来创建容器镜像。在深入容器执行之前,本章将教你如何通过使用注册表来存储和管理容器镜像。

什么是注册表?

注册表是一个容器镜像存储服务。这种存储可以由云服务提供商以服务形式提供,或者通过部署自己的注册表在本地提供。云注册表不需要你进行任何维护;你只需要管理通常的未使用镜像的清理工作。Docker Hub (docker.io) 可能是最常见的这类服务,但我们也有 Google 的 Container Registry (gcr.io) 和 Red Hat、GitHub、GitLab 等的类似服务。容器镜像已经成为运行应用程序的新制品,现如今,许多云代码仓库也包含了它们自己的镜像注册表服务。

现在,大多数镜像仓库都可以接受并管理 Docker 的镜像清单 V2 以及开放容器倡议(OCI)规范,因此,我们可以使用这些格式来管理我们的镜像。这些镜像架构指定了如何将镜像层和元数据关联,以供容器运行时使用。

在本章的最后,我们将运行自己的本地镜像仓库,并验证镜像是如何存储的。

容器运行时完全管理我们与软件容器或其镜像的所有操作;因此,你的客户端只是告诉容器运行时在执行容器之前下载镜像,或者在构建完成后上传镜像。

理解镜像仓库如何工作

在继续了解镜像仓库如何工作之前,我们应该在这里介绍完整的容器镜像命名约定。镜像总是通过以下格式进行引用:name:tag

full_FQDN_registry_name[:registry_port]/[username or team]/image_repository:image_tag

这意味着所有镜像引用必须包含存储它们的镜像仓库。当容器运行时与本地镜像交互时,你可以省略镜像仓库,但每次使用任何远程镜像时,你应始终使用仓库的 443 端口,并且采用 TLS/SSL。容器运行时将默认使用该端口连接到镜像仓库,但我们可以通过指定端口来使用其他端口。镜像仓库是我们通常用来指代存储镜像的软件仓库的术语,但需要理解的是,镜像遵循与代码仓库相同的命名规则,因此,不同的标签用来在仓库内标识镜像。

容器镜像可能与在仓库中拥有这些镜像的用户或团队相关联。这在这类服务中是常见的,因为用户是推送镜像的人,而镜像可以是公开的或私有的。如果你是某个组的一部分,你的镜像可能对该组的成员可访问。这种基于角色的访问可能在不同的镜像仓库之间有所不同;你应该向管理员询问或阅读相应镜像仓库的文档。发布在 Docker Hub 上的 Docker 镜像显示为 docker.io/library,并且所有公开的镜像仓库都发布在这个根组下,类似于本小节开头呈现的架构。

相反,虽然标签有助于识别镜像,我们在第二章《构建 Docker 镜像》中了解到,一张镜像可以有多个标签;因此,标签并不能唯一地标识一个特定的镜像。然而,镜像摘要能够唯一标识每个镜像的一组层,这些在不同镜像间确实是不同的。为了确保我们使用正确的镜像,我们应当使用它的摘要

给镜像打标签并不总是容易的;作为开发者,你必须确保其他人能够通过使用适当的标签来跟踪你的工作,这些标签引用了用户正在运行的版本。记住,你可以根据需要添加尽可能多的标签,以包含对用户相关的额外信息。我总是尝试遵循代码发布方案,使用X.Y.Z表示主版本、次版本和修复版本。包含关于生成镜像工件的代码提交标签是个好主意。这将帮助你跟踪问题,同时也能改善应用的生命周期。自动化将真正帮助你实现工作流,并遵循你自己的标签和标记逻辑方案。

每当我们从注册表拉取镜像时,我们都会在其元数据中获取镜像摘要,如下例所示,通过执行docker image pull alpine:3.17.2

图 3.1 – 拉取镜像后,镜像摘要可以轻松恢复

图 3.1 – 拉取镜像后,镜像摘要可以轻松恢复

根据你使用的容器运行时,可以设置默认的容器注册表。Docker 容器运行时默认使用docker.io,这就是为什么我们可以执行docker image pull alpine:3.17.2来下载alpine:3.17.2镜像,如图 3.1所示。两种情况下的摘要是相同的,因此它们是相同的镜像。然而,我们可以在系统中创建一个具有相同名称的新镜像。如果我们先删除之前下载的镜像(名称在容器运行时中是唯一的)并创建一个新的,它将具有完全不同的摘要。即使我们使用相同的 Dockerfile,这也会发生,因为摘要还会整合每次构建过程中的执行日期。

重要说明

我们可以通过使用--all-tags参数来下载仓库中的所有镜像,如下例所示:docker image pull --all-tags alpine。这将下载alpine仓库中的所有镜像。

我们可以通过使用docker info查看 Docker Desktop 中默认包含的注册表。以下截图展示了使用Windows PowerShell的示例输出:

图 3.2 – 我们的容器运行时使用的本地容器镜像注册表

图 3.2 – 我们的容器运行时使用的本地容器镜像注册表

在这个例子中,展示了localhost注册表和hubproxy.docker.internal:5000。本地注册表用于将镜像存储在本地。桌面客户端,如Docker DesktopRancher Desktop,将以图形方式展示本地镜像:

图 3.3 – Docker Desktop 本地镜像概览

图 3.3 – Docker Desktop 本地镜像概览

事实上,Docker 提供了与 Docker Hub 账户的集成。你还可以查看你的远程镜像:

图 3.4 – Docker Desktop Docker Hub 远程镜像概览

图 3.4 – Docker Desktop Docker Hub 远程镜像概览

该界面还允许你下载并查看镜像内容中发现的漏洞,如果你订阅了 Docker 服务。我们将在本章的 扫描镜像内容中的漏洞 部分深入了解安全内容扫描。

重要提示

你的容器运行时可能需要一些特定的配置,以允许新的容器镜像仓库。所有容器镜像仓库都应该使用 insecure-registries 列表。

镜像仓库通常需要登录,我们将使用其 FQDN 来访问它们。如果需要访问私有仓库,则需要一个账户。

我们将使用 docker search 来基于字符串查找仓库:

图 3.5 – 使用 docker search 命令搜索镜像

图 3.5 – 使用 docker search 命令搜索镜像

请注意,某些镜像标记为 OFFICIAL,而其他则标记为 AUTOMATED。Docker Hub 提供此功能,因为根据你的镜像仓库,一些 CI/CD 集成可能会自动为你构建镜像。这些标记有助于标识自动构建的镜像。如果符合你的需求,你可以使用它们的自动化功能。

Docker 官方镜像是由 Docker 根据最佳实践构建和维护的容器镜像。它们还通过持续更新来确保安全性。所有官方镜像的代码都可以在 GitHub 上公开查看,你可以直接使用它或根据自己的需求进行自定义。如果在使用过程中遇到任何问题,你可以提供反馈并联系 Docker。一个很好的示例是 Alpine 镜像仓库(github.com/alpinelinux/docker-alpine/),你可以在这里找到构建所有 Alpine 镜像的代码。以下截图展示了如何找到你需要的任何技术的官方镜像:

图 3.6 – Docker Hub 中可用的官方镜像概览

图 3.6 – Docker Hub 中可用的官方镜像概览

软件供应商和开源社区项目也提供由他们准备和维护的容器镜像。将第三方项目或组件集成到你的应用程序中的最佳方法永远是使用 Docker、经过验证的发布者或赞助的开源软件提供商已为你准备好的镜像。这些镜像有很好的文档支持,你可以通过使用参数和环境变量来定制容器行为。使用这些镜像可以避免许多问题,而不必从头开始创建你自己的镜像。在 实验室 部分,我们将通过一个非常常用的 PostgreSQL 数据库示例来更好地理解这一点。

在仓库中搜索

Docker 客户端提供了一些仓库搜索功能,但在尝试查找特定镜像时,它们并不充分。你可能会使用 Docker Hub 的 Web 界面来进行更细致的搜索。镜像注册表会发布它们的 API,我们可以使用curl或其他 Web 接口,带上参数来查找这些镜像。或者,我们可以使用skopeo。这个工具可以让我们在搜索仓库时,过滤出特定的标签。

如本节前面提到的,注册表提供了一个 HTTP API,我们可以用来查询特定的仓库、标签等,但它并不容易使用。例如,我们可以使用curl myregistry.com:5000/v2/_catalog 来列出目录中的所有镜像。推荐使用skopeo,因为它提供了一个清晰简便的命令行,特别是当处理需要身份验证的注册表时(需要证书和登录)。它在不同 Linux 发行版的包管理库中都有提供。你可以按照你所在发行版或 WSL 的安装说明,查看这里的指引:github.com/containers/skopeo/blob/main/install.md

不幸的是,skopeo在 Ubuntu 20.04 LTS 的包管理库中不可用。我们可以通过在 Windows PowerShell 终端执行wsl --install -d Ubuntu-22.04来安装更新的 WSL 发行版。一旦新的 Ubuntu 22.04 WSL 发行版准备好后,我们可以通过使用sudo apt-get update -qq && sudo apt-get install skopeo -qq来安装该软件包。然后,我们可以通过进入设置 | 资源 | WSL 集成,或者文件 | 首选项 | WSL来将容器运行时集成到 Docker Desktop 或 Rancher Desktop 中。以下截图展示了 Docker Desktop 中的界面:

图 3.7 – 在 Ubuntu 22.04 LTS WSL 中启用 Docker Desktop 集成

图 3.7 – 在 Ubuntu 22.04 LTS WSL 中启用 Docker Desktop 集成

使用skopeo,我们可以轻松列出一个仓库中包含的所有标签,如下例所示:

$ skopeo  list-tags docker://docker.io/frjaraur/colors
{
    "Repository": "docker.io/frjaraur/colors",
    "Tags": [
        "1.0",
        "1.1",
        "1.2",
        "1.5",
        "latest"
    ]
}

我们甚至可以通过使用skopeo inspect来检查远程镜像的信息:

$ skopeo inspect docker://docker.io/frjaraur/colors:1.0 \
--format="{{ .Digest }}"
sha256:cb7c1e49bcac66663aafea571ce5a6e6626e387c43b4836cc4d9e4c0e5d9faff

我们可以通过使用docker container run命令,直接使用 Red Hat 的官方镜像,而不需要在本地安装skopeo

$ docker container run --rm quay.io/skopeo/stable \
inspect docker://docker.io/frjaraur/colors:1.0 \
--format="{{ .Digest }}"
Unable to find image 'quay.io/skopeo/stable:latest' locally
latest: Pulling from skopeo/stable
1a72627e77ed: Already exists
...
Digest: sha256:23f4b378c4aff49621e90289b33daf133462824b5eba603b0834e25cb83a97ca
Status: Downloaded newer image for quay.io/skopeo/stable:latest
sha256:cb7c1e49bcac66663aafea571ce5a6e6626e387c43b4836cc4d9e4c0e5d9faff

希望这能为你使用软件容器提供新的视角。我们可以将工具打包成容器镜像,使用它们,而不必在计算机上安装软件。

现在我们已经知道如何存储和引用注册表中的镜像,接下来是学习如何通过对镜像进行签名来提高镜像的所有权和安全性。

通过对容器镜像进行签名来提高安全性

正如我们在前一部分提到的,摘要是我们验证自己使用的镜像的唯一方式。在这一部分,我们将回顾如何通过签署镜像来改进这一过程。这将确保我们使用的是正确的镜像,因为我们可以检查签名并验证每个镜像的所有权。

我们将分析并了解 Docker 用于签署镜像的方法,但也有其他方法可用。我们将在 实验室 部分使用 Cosign,它看起来更简单,并且与 Kubernetes 容器编排器集成得非常好。

Docker 几年前创建了Docker 内容信任,将数字签名集成到容器镜像管理工作流中,并将签名与镜像标签关联起来。我们将能够拥有签名和未签名的镜像仓库,例如,在进入新阶段之前进行本地测试。作为开发者,您可以创建镜像并决定哪些镜像应签名。

签名过程基于一组不同的密钥,这些密钥将用于将元信息添加到您注册的镜像中。其中一些密钥由用户交互式管理,而其他一些则在执行过程中计算。这些密钥包括以下内容:

  • root 密钥始终用于启动签名过程。系统会要求您提供此密钥,但您也可以将其作为变量包含在您的环境中。非常重要的一点是,如果您丢失了此密钥,您将需要重新签署所有镜像,因为没有已知的恢复过程。始终备份此密钥;丢失它可能会在生产环境中造成真正的问题,因为您的旧签名将不再有效。

  • 每个仓库也将拥有其自己的密钥。此密钥将用于签署特定仓库中的镜像,因此丢失它只会影响该仓库中的镜像。尽管如此,您应该妥善保管并备份它。

  • timestamp 密钥会自动添加到最终的签名中。这确保了安全性,因为每个签名将始终完全不同。

您将被要求为根密钥创建一个密码短语,每个仓库的密钥将随机生成。Docker 在您用户的 ~/.docker 目录下提供了它自己的密码管理器。

重要提示

为了备份您的密钥,请准备一个包含 ~/.docker/trust/private 目录内容的 tar.gz 文件。我们可以执行以下命令:

$ umask 077; tar -zcvf private_keys_backup.tar.gz ~/.docker/trust/private; umask 022

我们可以通过设置 DOCKER_CONTENT_TRUST=1 环境变量,在客户端启用 Docker 内容信任。这将为执行的任何新命令启用内容信任,这意味着您的环境中将只启用签名镜像。如果我们只需要为特定命令启用内容信任,可以使用 --disable-content-trust=false 参数。

让我们尝试通过设置 DOCKER_CONTENT_TRUST 变量来启用镜像的内容信任。在这个示例中,我们执行以下步骤:

  1. 我们首先拉取 docker.io/busybox:latest 镜像:

    $ docker image pull busybox
    Using default tag: latest
    latest: Pulling from library/busybox
    Digest: sha256:7b3ccabffc97de872a30dfd234fd972a66d247c8cfc69b0550f276481852627c
    Status: Image is up to date for busybox:latest
    DOCKER_CONTENT_TRUST=1 and download the same image again:
    
    

    $ export DOCKER_CONTENT_TRUST=1

    $ docker image pull busybox

    使用默认标签:latest

    拉取(1/1):busybox:latest@sha256:7b3ccabffc97de872a30dfd234fd972a66d247c8cfc69b0550f276481852627c

    docker.io/library/busybox@sha256:7b3ccabffc97de872a30dfd234fd972a66d247c8cfc69b0550f276481852627c: 从 library/busybox 拉取

    摘要:sha256:7b3ccabffc97de872a30dfd234fd972a66d247c8cfc69b0550f276481852627c

    状态:镜像已更新,适用于 busybox@sha256:7b3ccabffc97de872a30dfd234fd972a66d247c8cfc69b0550f276481852627c

    将 busybox@sha256:7b3ccabffc97de872a30dfd234fd972a66d247c8cfc69b0550f276481852627c 标记为 busybox:latest

    busybox 镜像,docker.io/frjaraur/busybox-untrusted:0.1。让我们看看当 Docker 内容信任启用时,如果尝试拉取一个不受信任的镜像会发生什么:

    $ docker pull docker.io/frjaraur/busybox-untrusted:0.1
    Error: remote trust data does not exist for docker.io/frjaraur/busybox-untrusted: notary.docker.io does not have trust data for docker.io/frjaraur/busybox-untrusted
    

    这意味着未找到有效的签名,但如果我们禁用内容信任,我们就可以毫无问题地拉取镜像:

    $ export DOCKER_CONTENT_TRUST=0
    $ docker pull docker.io/frjaraur/busybox-untrusted:0.1
    0.1: Pulling from frjaraur/busybox-untrusted
    Digest: sha256:907ca53d7e2947e849b839b1cd258c98fd3916c60f2e6e70c30edbf741ab6754
    Status: Downloaded newer image for frjaraur/busybox-untrusted:0.1
    docker.io/frjaraur/busybox-untrusted:0.1
    

    事实上,我们不能使用这个不受信任的镜像来运行任何容器,这点我们可以轻松验证:

    $ docker run -ti --disable-content-trust=false\
    docker.io/frjaraur/busybox-untrusted:0.1
    docker: Error: remote trust data does not exist for docker.io/frjaraur/busybox-untrusted: notary.docker.io does not have trust data for docker.io/frjaraur/busybox-untrusted.
    See 'docker run --help'.
    

    相反,如果我们运行一个受信任的镜像,一切按预期工作:

    $ docker run -ti --disable-content-trust=false \
    docker.io/busybox:latest ls -ld /tmp
    docker trust command line:
    
    

    $ docker trust inspect \

    --pretty docker.io/busybox:latest

    docker.io/busybox:latest 的签名

    签名标签   摘要

    签名者

    latest       7b3ccabffc97de872a30dfd234fd972a66d247c8cfc69b0550f276481852627c   (仓库管理员)

    docker.io/busybox:latest 的管理密钥

    仓库密钥:       02d15c99120886f6e02b4b0186522bc72d21a339ec35fad8af0a1b4a47c871d2

    根密钥:     074cad59e43e13b440b11d1b5521e20aa8633fc8f3928720590268895711d0c6

    $ docker trust inspect --pretty docker.io/frjaraur/busybox-untrusted:0.1

    frjaraur/busybox-untrusted:0.1;因此,这个镜像不能在启用 Docker 内容信任的容器运行时中使用。

    
    
    
    
  2. 让我们通过使用 docker trust key generate 创建一个用户密钥,开始签名过程:

    $ docker trust key generate frjaraur
    Generating key for frjaraur...
    Enter passphrase for new frjaraur key with ID ceb39cd:
    Repeat passphrase for new frjaraur key with ID ceb39cd:
    ~/.docker/trust/private/:
    
    

    $ ls -lart ~/.docker/trust/private/

    总计 12

    drwx------ 4 frjaraur frjaraur 4096 Mar  5 17:43 ..

    -rw------- 1 frjaraur frjaraur  420 Mar  5 17:50 ceb39cd48cf78d478ffef211cc9da3e97ff9912ae60585254d6dc661076d0d2a.key

    带有 docker.io/frjaraur/busybox-trusted:0.1 的 busybox 镜像;因此,两个镜像是相同的,但签名与标签相关:

    $ docker image tag busybox docker.io/frjaraur/busybox-trusted:0.1
    $ docker image push docker.io/frjaraur/busybox-trusted:0.1
    The push refers to repository [docker.io/frjaraur/busybox-trusted]
    b64792c17e4a: Mounted from frjaraur/busybox
    0.1: digest: sha256:907ca53d7e2947e849b839b1cd258c98fd3916c60f2e6e70c30edbf741ab6754 size: 528
    Signing and pushing trust metadata
    You are about to create a new root signing key passphrase. This passphrase
    will be used to protect the most sensitive key in your signing system. Please
    choose a long, complex passphrase and be careful to keep the password and the
    key file itself secure and backed up. It is highly recommended that you use a
    password manager to generate the passphrase and keep it safe. There will be no
    way to recover this key. You can find the key in your config directory.
    Enter passphrase for new root key with ID dfbeee2:
    Repeat passphrase for new root key with ID dfbeee2:
    Enter passphrase for new repository key with ID 9cfa33d:
    Repeat passphrase for new repository key with ID 9cfa33d:
    Finished initializing "docker.io/frjaraur/busybox-trusted"
    docker trust sign docker.io/frjaraur/busybox-trusted:0.1 and then push it with docker image push docker.io/frjaraur/busybox-trusted:0.1.
    
    
    
  3. 现在,我们可以轻松查看我们的镜像签名:

    $ docker trust inspect \
    --pretty docker.io/frjaraur/busybox-trusted:0.1
    Signatures for docker.io/frjaraur/busybox-trusted:0.1
    SIGNED TAG   DIGEST                                                             SIGNERS
    0.1          907ca53d7e2947e849b839b1cd258c98fd3916c60f2e6e70c30edbf741ab6754   (Repo Admin)
    Administrative keys for docker.io/frjaraur/busybox-trusted:0.1
      Repository Key:       9cfa33df6e6b93596416b06bb82198a46befb94479bbf5b0d92e73a213a30126
      Root Key:     f802546452481df2edc8b9670d30638e079164e7dc7187b698cd275d894531f4
    

在这里需要强调的是,你需要一个带有Notary的注册表服务器。这个项目负责管理你的签名。它是一个客户端-服务器应用程序,和你的注册表一起运行,集成签名部分。Docker Hub 已经将 Notary 服务器集成到他们的注册表中,这就是为什么我们能够将签名集成到我们的镜像元数据中的原因。

如果你计划使用带有内容信任的本地注册表,你还需要运行并集成一个 Notary 服务器。你可以在 github.com/notaryproject/notary 上了解更多关于 Notary 的信息。

我们可以通过使用 docker trust revoke 来撤销特定容器镜像标签的签名。通过共享我们的公钥,可以将签名委托给其他用户。镜像签名的集成在你的平台中实际上取决于你使用的注册表。我们已经了解了 Docker Hub 的流程,但其他解决方案可能会实现不同的命令和选项。我们将展示如何使用 Cosign,它管理一种不同类型的签名,但它同样有助于实施良好的安全供应链,确保镜像的来源和所有权。

现在我们已经知道如何通过使用摘要和签名来确保容器镜像的唯一性,接下来我们将通过实施镜像内容漏洞扫描来进一步保障镜像的安全性。

扫描镜像内容中的漏洞

容器镜像可以安全地存储在注册表中,我们可以通过查看它们的摘要和签名来追踪它们的来源和所有权。如果我们能信任镜像层中包含的所有文件,那将是非常棒的。现在有许多解决方案可以检查镜像层中的文件是否存在任何已报告的问题或漏洞,这些漏洞可能会影响到应用程序的完整性。然而,这需要新的工具和努力。

镜像扫描可以在本地开发环境或镜像最终存储和共享的远程注册表中实施。大多数内容扫描器使用著名的公共和社区支持的已知漏洞和利用数据库。这些数据库会为我们提供一份常见漏洞和暴露CVE)列表,用于与我们的内容进行对比。该列表中的每个二进制文件或库都会通过其摘要进行标识,我们可以轻松找出镜像层中的文件是否被认为在该列表中是脆弱的。

Docker 容器运行时扫描功能集成在客户端命令行中,可以通过镜像或 Dockerfile 来执行,这一点非常有趣,因为我们可以在镜像构建之前就概览可能出现的问题。

所有 Docker Hub 的官方镜像都会提供非常详细的安全扫描报告。已验证的发布者(软件供应商)和赞助的开源软件(开源社区支持的项目)镜像也提供此类报告。我们可以通过这些镜像的标签部分访问这些报告。以下截图显示了 Postgres 每个标签的摘要报告:

图 3.8 – Docker Hub 中官方 Postgres 镜像漏洞的回顾

图 3.8 – Docker Hub 中官方 Postgres 镜像漏洞的回顾

我们可以通过点击任何标签进行深入探讨。这将显示与检测到的漏洞相关的 CVE 编号。以下截图显示了在postgres:latest镜像层中发现的一些 stdlib 1.18.2 的 CVE:

图 3.9 – 深入分析当前在 postgres:latest 镜像中的漏洞

图 3.9 – 深入分析当前在 postgres:latest 镜像中的漏洞

该报告让我们对 Docker Hub 镜像的健康状况有了很好的了解。建议始终查看我们在开发中使用的官方镜像的扫描报告。

Docker 命令行扫描实现使用 docker scan。下面是对 postgres:latest 执行的一个示例:

图 3.10 – 在我们的本地环境中使用 docker scan 功能

图 3.10 – 在我们的本地环境中使用 docker scan 功能

在前面的截图中,我们使用了 --accept-license 参数进行非交互式执行;否则,我们会被要求接受与 Snyk 服务的协议。我们还加入了 --severity 参数进行过滤,只显示严重漏洞。在这个示例中,Snyk 没有显示任何严重漏洞,这与 Docker Hub 网站上的情况不同。这可能与基础镜像扫描有关。Snyk 扫描器每月仅提供 10 次扫描,这可能不够用。你可以注册以增加这个限制,或者订阅他们的服务以增加可用功能,避免任何限制。在本章的 实验 部分,我们将学习如何使用另一种扫描工具 Trivy,该工具将作为容器执行,从而避免在我们的工作环境中安装。

我们已经学会了如何使用镜像注册表并通过扫描和签名镜像确保安全。在接下来的部分,我们将回顾一些简单的实验,帮助实践我们讨论过的概念。

实验

接下来的实验将提供示例,帮助实践本章中学到的概念和程序。我们将使用 Docker Desktop 作为容器运行时,使用 WSL 2(或你的 Linux/macOS 终端)来执行所描述的命令。

所有实验的第一步都是始终下载本书 GitHub 仓库的最新版本,地址为 github.com/PacktPublishing/Containers-for-Developers-Handbook.git。为此,只需执行 git clone github.com/PacktPublishing/Containers-for-Developers-Handbook.git 即可下载所有内容。如果你之前已经下载过,确保通过在该目录下执行 git pull 来获取最新版本。

本章中所有实验中的命令将会在 Containers-for-Developers-Handbook/Chapter3 目录下执行。

部署和使用你自己的本地注册表

在这个第一个实验中,我们将部署一个简单的无认证和不受信任(HTTP,而非 HTTPS)的注册中心。我们将使用当前可用的 Docker 官方注册镜像,它是registry:2.8.1,写作时该版本为最新。我们可以通过访问hub.docker.com/layers/library/registry/2.8.1/images/sha256-a001a2f72038b13c1cbee7cdd2033ac565636b325dfee98d8b9cc4ba749ef337?context=explore来查看它的漏洞:

图 3.11 – 官方 Docker registry:2.8.1 漏洞概览

图 3.11 – 官方 Docker registry:2.8.1 漏洞概览

然后我们执行以下步骤:

  1. 拉取docker.io/registry:2.8.1镜像:

    $ docker image pull docker.io/registry:2.8.1
    ...
    Digest: sha256:3f71055ad7c41728e381190fee5c4cf9b8f7725839dcf5c0fe3e5e20dc5db1faStatus: Downloaded newer image for registry:2.8.1
    docker.io/library/registry:2.8.1
    
  2. 现在,回顾其CMDENTRYPOINTVOLUMEEXPOSE键。这些将显示我们将执行的命令、将使用的端口以及将用于持久化数据的目录:

    $ docker image inspect docker.io/registry:2.8.1 \
    --format="{{ .Config.Cmd }} {{.Config.Entrypoint }} {{.Config.Volumes }} {{.Config.ExposedPorts }}"
    5000 will be published, and a custom script will be launched with a configuration file as the argument. The /var/lib/registry directory will be used for our images; hence, we will map it to a local folder in this lab.If you’ve already downloaded this book’s GitHub repository, change to the `Chapter3` folder and follow the next steps from there. If you haven’t, please download the repository to your computer by executing `git clone` [`github.com/PacktPublishing/Containers-for-Developers-Handbook.git`](https://github.com/PacktPublishing/Containers-for-Developers-Handbook.git). We will remove the long path in the following prompts.
    
  3. 创建一个用于注册数据的目录,并使用之前拉取的注册镜像执行一个容器:

    Chapter3$ mkdir registry-data
    Chapter3$ docker container run -P -d \
    --name myregstry \
    -v $(pwd)/registry-data:/var/lib/registry \
    5000. It also used the directory we created to store all our images, by using $(pwd) to get the current directory. Adding volumes to a container requires the use of the directory’s full path.As we identified our new container as `myregistry`, we can easily review its status:
    
    

    $ docker container ls

    容器 ID 镜像 命令 创建时间 状态 端口 名称

    5000。我们使用了–P 选项,以便容器运行时可以选择任何可用端口来发布应用程序的端口;因此,这个端口在您的环境中可能不同:

    $ curl -I 0.0.0.0:32768
    HTTP/1.1 200 OK
    Cache-Control: no-cache
    32768 (in my example environment).
    
    
    
  4. 让我们下载一个alpine容器镜像并上传到我们的注册中心。首先,我们需要拉取这个镜像:

    Chapter3$ docker pull alpine
    Using default tag: latest
    ...
    Status: Downloaded newer image for alpine:latest
    localhost:32768:
    
    

    Chapter3$ docker image tag alpine localhost:32768/alpine:0.1

    
    
  5. 我们可以列出本地镜像,然后再推送到本地注册中心:

    Chapter3$ docker image ls |grep "alpine"
    alpine                        latest      b2aa39c304c2   3 weeks ago    7.05MB
    localhost:32768 registry:
    
    

    Chapter3$ docker image push localhost:32768/alpine:0.1

    推送涉及的仓库是[localhost:32768/alpine]

    7cd52847ad77: 推送完成

    0.1: 摘要:sha256:e2e16842c9b54d985bf1ef9242a313f36b856181f188de21313820e177002501 大小:528

    
    As you can see, everything works as if we were pushing to the Docker Hub registry. The only difference here is that we didn’t have to log in and our registry uses HTTP. We can manage this by adding an NGINX web server as a frontend, behind the registry server.
    
  6. 现在让我们回顾一下图像在文件系统中的分布情况:

    Chapter3$ ls -lart registry-data/docker/registry/v2/
    total 16
    drwxr-xr-x 3 root root 4096 Mar  6 19:55 repositories
    drwxr-xr-x 3 root root 4096 Mar  6 19:55 ..
    drwxr-xr-x 3 root root 4096 Mar  6 19:55 blobs
    repositories directory manages the metadata for each image repository, while the blobs directory stores all the layers from all container images.The `blobs` directory is distributed in many other directories to be able to manage an enormous number of layers:
    
    

    Chapter3$ ls -lart registry-data/docker/registry/v2/blobs/sha256/

    总计 20

    drwxr-xr-x 3 root root 4096 3 月 6 19:55 63

    drwxr-xr-x 3 root root 4096 3 月 6 19:55 ..

    drwxr-xr-x 3 root root 4096 3 月 6 19:55 e2

    drwxr-xr-x 3 root root 4096 3 月 6 19:55 b2

    alpine:latest 镜像作为 localhost:32768/alpine:0.2:

    Chapter3$ docker image tag alpine localhost:32768/alpine:0.2
    

    这意味着我们为原始 Alpine 镜像创建了一个新的标签;因此,我们预计只有元数据会被修改。

    
    
  7. 让我们推送镜像并回顾文件系统的变化:

    $ docker image push localhost:32768/alpine:0.2
    The push refers to repository [localhost:32768/alpine]
    7cd52847ad77: Layer already exists
    localhost:32768 registry says that the image layers already exist.
    
  8. 我们可以再次列出注册表的内容:

    Chapter3$ ls -lart registry-data/docker/registry/v2/blobs/sha256/
    total 20
    drwxr-xr-x 3 root root 4096 Mar  6 19:55 63
    drwxr-xr-x 3 root root 4096 Mar  6 19:55 ..
    drwxr-xr-x 3 root root 4096 Mar  6 19:55 e2
    drwxr-xr-x 3 root root 4096 Mar  6 19:55 b2
    blobs directory wasn’t changed, but let’s review the repositories directory, where the image metadata is managed:
    
    

    Chapter3$ ls -lart registry-data/docker/registry/v2/repositories/alpine/_manifests/tags/

    总计 16

    drwxr-xr-x 4 root root 4096 3 月 6 19:55 0.1

    drwxr-xr-x 4 root root 4096 3 月 6 19:55 ..

    drwxr-xr-x 4 root root 4096 3 月 6 19:59 0.2

    blobs 目录适用于标签 0.1 和 0.2。现在,让我们推送一个有些变化的新镜像。

    
    
  9. 我们现在将通过将 alpine.latest 镜像作为基础镜像,在新的构建过程中创建它的修改版。我们将通过管道传递 Dockerfile 来进行实时构建:

    Chapter3$ cat <<EOF | docker build -t \
    localhost:32768/alpine:0.3 -
    FROM docker.io/alpine:latest
    RUN apk add --update nginx
    EXPOSE 80
    CMD ["whatever command"]
    EOF
    

    这是另一种使用 Dockerfile 构建镜像的方式。在这种情况下,我们不能使用镜像内容,因此复制文件将不起作用,但对于这个例子来说是可以的。我们使用 Unix 管道创建一个新的镜像,以避免创建文件。这样,我们就可以实时创建镜像:

    Chapter3$ cat <<EOF | docker build -t \
    localhost:32768/alpine:0.3 -
    FROM> FROM docker.io/alpine:latest
    > RUN apk add --update nginx
    > EXPOSE 80
    > CMD ["whatever command"]
    > EOF
    [+] Building 1.3s (6/6) FINISHED
    ...
    => [1/2] FROM docker.io/library/alpine:latest
    0.0s
    => [2/2] RUN apk add --update nginx
    1.2s
    ...
    => => writing image sha256:e900ec26c76b9d779bc3d6a7f828403db07daea66c85b5271ccd94e12b460ccd                                0.0s
    => => naming to localhost:32768/alpine:0.3
    
  10. 现在我们推送这个新镜像并查看目录:

    Chapter3$ docker push localhost:32768/alpine:0.3
    The push refers to repository [localhost:32768/alpine]
    33593eed7b41: Pushed
    7cd52847ad77: Layer already exists
    0.3: digest: sha256:1bf4c7082773b616fd2247ef9758dfec9e3084ff0d23845452a1384a6e715c40 size: 739
    

    如你所见,一个新的层已被推送。

  11. 现在,我们来查看本地文件夹,了解镜像注册表在我们的主机中存储数据的位置:

    Chapter3$ ls -lart registry-data/docker/\
    registry/v2/repositories/alpine/_manifests/tags/
    total 20
    drwxr-xr-x 4 root root 4096 Mar  6 19:55 0.1
    drwxr-xr-x 4 root root 4096 Mar  6 19:55 ..
    drwxr-xr-x 4 root root 4096 Mar  6 19:59 0.2
    drwxr-xr-x 4 root root 4096 Mar  6 20:08 0.3
    drwxr-xr-x 5 root root 4096 Mar  6 20:08 .
     Chapter3$ ls -lart registry-data/docker/registry/v2/blobs/sha256/
    total 32
    drwxr-xr-x 3 root root 4096 Mar  6 19:55 63
    drwxr-xr-x 3 root root 4096 Mar  6 19:55 ..
    drwxr-xr-x 3 root root 4096 Mar  6 19:55 e2
    drwxr-xr-x 3 root root 4096 Mar  6 19:55 b2
    drwxr-xr-x 3 root root 4096 Mar  6 20:08 c1
    drwxr-xr-x 3 root root 4096 Mar  6 20:08 e9
    drwxr-xr-x 3 root root 4096 Mar  6 20:08 1b
    repositories and blobs locations.
    

我们已经了解了镜像是如何在我们的注册表中存储和管理的。现在让我们进入一个新实验,回顾一下如何使用一个不同的工具Cosign来签名镜像。

使用 Cosign 签署镜像

在这个新实验中,我们将使用一个新的工具 Cosign,它可以以不同的格式轻松下载:

  1. 我们将通过下载其二进制文件来安装 Cosign:

    Chapter3$ mkdir bin
    Chapter3$ export PATH=$PATH:$(pwd)/bin
    Chapter3$ curl -sL -o bin/cosign https://github.com/sigstore/cosign/releases/download/v2.0.0/cosign-linux-amd64
    Chapter3$ chmod 755 bin/*
    Chapter3$ cosign --help
    A tool for Container Signing, Verification and Storage in an OCI registry.
    Usage:
      cosign [command]
    --output-key-prefix to ensure our keys have an appropriate name:
    
    

    Chapter3$ cosign generate-key-pair \

    --output-key-prefix frjaraur

    输入私钥密码:

    再次输入私钥密码:

    私钥写入到 frjaraur.key

    公钥写入到 frjaraur.pub

    
    Use your own name for your key. You will be asked for a password. Use your own, and remember that this will be required to sign any image. This will create your public and private keys:
    
    

    Chapter3$ ls -l

    total 12

    -rw------- 1 frjaraur frjaraur 649 Mar 7 19:51 frjaraur.key

    -rw-r--r-- 1 frjaraur frjaraur 178 Mar 7 19:51 frjaraur.pub

    
    
  2. 我们将为镜像添加一个新的名称和标签,然后推送它:

    Chapter3$ docker tag localhost:32768/alpine:0.3 \
    localhost:32768/alpine:0.4-signed
    Chapter3$ docker push localhost:32768/alpine:0.4-signed
    The push refers to repository [localhost:32768/alpine]
    dfdda8f0d335: Pushed
    7cd52847ad77: Layer already exists
    0.4-signed: digest: sha256:f7ffc0ab458dfa9e474f656afebb4289953bd1196022911f0b4c739705e49956 size: 740
    
  3. 现在,我们可以继续签署镜像:

    Chapter3$ cosign sign --key frjaraur.key \
    localhost:32768/alpine:0.4-signed
    Enter password for private key:
    WARNING: Image reference localhost:32768/alpine:0.4-signed uses a tag, not a digest, to identify the image to sign.
        This can lead you to sign a different image than the intended one. Please use a
        digest (example.com/ubuntu@sha256:abc123...) rather than tag
        (example.com/ubuntu:latest) for the input to cosign. The ability to refer to
        images by tag will be removed in a future release.
            Note that there may be personally identifiable information associated with this signed artifact.
            This may include the email address associated with the account with which you authenticate.
            This information will be used for signing this artifact and will be stored in public transparency logs and cannot be removed later.
    By typing 'y', you attest that you grant (or have permission to grant) and agree to have this information stored permanently in transparency logs.
    Are you sure you would like to continue? [y/N] y
    tlog entry created with index: 14885625
    Pushing signature to: localhost:32768/alpine
    

    请注意警告信息。正如我们在第二章《构建 Docker 镜像》中学到的,只有镜像摘要真正确保镜像的唯一性,在这个例子中,我们使用标签来引用我们正在签名的镜像。我们应该使用摘要来改进签名过程,确保我们为生产签署正确的镜像,但在这个例子中,我们可以继续使用标签。

  4. 现在我们可以验证与该镜像相关联的签名:

    Chapter3$ cosign verify --key frjaraur.pub \
    localhost:32768/alpine:0.4-signed
    Verification for localhost:32768/alpine:0.4-signed --
    The following checks were performed on each of these signatures:
      - The cosign claims were validated
      - Existence of the claims in the transparency log was verified offline
      - The signatures were verified against the specified public key
    cosign triangulate to verify whether an image is signed:
    
    

    Chapter3$ cosign triangulate localhost:32768/\

    alpine:0.4-signed

    localhost:32768/alpine:sha256-f7ffc0ab458dfa9e474f656afebb4289953bd1196022911f0b4c739705e49956.sig

    
    This hash is the digest referenced:
    
    

    Chapter3$ docker image ls --digests |grep "0.4-signed"

    localhost:32768/alpine:0.3 镜像:

    Chapter3$ docker tag localhost:32768/alpine:0.3 \
    localhost:32768/alpine:0.4-signed
    
    
    
  5. 现在,我们再次推送它:

    Chapter3$ docker push localhost:32768/\
    alpine:0.4-signed
    The push refers to repository [localhost:32768/alpine]
    33593eed7b41: Layer already exists
    7cd52847ad77: Layer already exists
    0.4-signed: digest: sha256:1bf4c7082773b616fd2247ef9758dfec9e3084ff0d23845452a1384a6e715c40 size: 739
    
  6. 现在我们可以再次验证新推送的镜像:

    Chapter3$ cosign verify --key frjaraur.pub \
    localhost:32768/alpine:0.4-signed
    Error: no matching signatures:
    ValidatingWebHook. This will ensure that only signed images (we can also include specific signatures) will be available to create containers.
    

现在我们知道如何通过签名和验证签名来提高安全性,我们可以更进一步,使用安全扫描器来审查它们内容中的任何潜在漏洞。

通过使用镜像内容漏洞扫描器来提高安全性

在这个实验中,我们将使用来自 Aquasec 的Trivy。它是一个非常强大的安全扫描器,可以扫描文件内容、配置错误,甚至是 Kubernetes 资源。它将大大帮助你在日常的 DevOps 工作中,也会对开发人员有帮助。我们将创建一个用于离线使用的自定义 Trivy 镜像,并包含在线数据库。在这个例子中,我们还将学习如何管理镜像中的缓存内容。

Chapter3文件夹中,您将找到trivy目录,其中包含一个准备好的 Dockerfile,供您为本实验构建上述自定义镜像:

  1. 首先,我们将使用skopeo验证最新稳定版本的docker.io/trivy镜像的摘要:

    Chapter3$ skopeo inspect \
    docker://aquasec/trivy:0.38.2-amd64|grep -i digest
    trivy image. We will move inside the trivy folder to build our new image. Review the Dockerfile’s content, and write down the appropriate hash for your base image:
    
    

    FROM aquasec/trivy:0.38.2-amd64@sha256:8038205ca56f2d88b93d804d0407831056ee0e40616cb0b8d74b0770c93aaa9f

    LABEL MAINTAINER "frjaraur at github.com"

    LABEL TRIVY "0.38.2-amd64"

    ENV TRIVY_CACHE_DIR="/cache"  \

    TRIVY_NO_PROGRESS=true

    RUN TRIVY_TEMP_DIR=$(mktemp -d) \

    && trivy --cache-dir $TRIVY_CACHE_DIR image --download-db-only \

    && tar -cf ./db.tar.gz -C $TRIVY_CACHE_DIR/db metadata.json trivy.db

    ENV TRIVY_SKIP_DB_UPDATE=true

    RUN chmod 777 -R /cache

    USER nobody

    
    
  2. 现在我们将构建我们的镜像:

    Chapter3/trivy$ docker build -t \
    localhost:32768/trivy:custom-0.38.2 . --no-cache
    [+] Building 23.5s (7/7) FINISHED
    => [internal] load build definition from Dockerfile
    0.1s
    ...
    => => writing image sha256:de8c7b30b715d05ab3167f6c8d66ef47f25603d05b8392ab614e8bb8eb70d4b3             0.1s
    => => naming to localhost:32768/trivy:custom-0.38.2
    python:alpine image available in Docker Hub. We will only scan for content vulnerability:
    
    

    Chapter3/trivy$ docker run -ti \

    localhost:32768/trivy:custom-0.38.2 \

    image python:alpine --scanners vuln \

    --severity 高危,严重

    2023-03-08T20:49:21.927Z        INFO    已启用漏洞扫描

    2023-03-08T20:49:26.865Z        INFO    检测到操作系统: alpine

    2023-03-08T20:49:26.865Z        INFO    正在检测 Alpine 漏洞...

    2023-03-08T20:49:26.869Z        INFO    语言特定文件的数量:1

    2023-03-08T20:49:26.869Z        INFO    正在检测 python-pkg 漏洞...

    python:alpine (alpine 3.17.2)

    总计:1(高危:1,严重:0)

    │  库       │ 漏洞         │ 严重程度 │ 已安装版本 │ 修复版本 │                           标题                            │

    │ libcom_err │ CVE-2022-1304 │ 高危     │ 1.46.5-r4         │ 1.46.6-r0     │ e2fsprogs: 通过精心构造的文件系统进行越界读写 │

    仅报告高危和严重漏洞,以避免任何非关键输出。我们使用了默认的表格格式输出,但也可以使用 JSON 格式,例如,将漏洞扫描器集成到自动化任务中。

    
    

镜像扫描将真正帮助我们决定使用哪些版本,甚至通过了解基础镜像中包含的漏洞,修复镜像中的问题。扫描过程通常会被包含在构建管道中,以确保您的工作流不会产生易于管理的漏洞镜像。

总结

在本章中,我们学习了如何将容器镜像存储在镜像仓库中,使用适当的仓库和标签来管理我们的应用组件。作为开发者,您必须为您的镜像提供逻辑名称、标签和所需信息,以便用户能够正确运行您的应用程序。标签还允许您包括任何相关信息,这些信息有助于您跟踪代码更改以及这些更改如何影响您的应用程序流程。

另外,确保我们图像制品的安全供应链至关重要。我们了解到摘要提供了唯一性,但这还不够。我们可以包含签名来告知用户我们创建的图像的来源和所有权,但签名并不能保证我们图像层内包含的文件的健康。我们将在构建过程中包含内容漏洞扫描。这将使我们能够审查和验证我们用来创建项目的图像是否包含任何安全问题。了解我们图像中的漏洞将帮助我们改进应用程序生命周期。尽管修复所有漏洞可能很困难甚至不可能,但理解我们在项目中必须处理的可能问题是关键的。

现在我们有了一个良好的基础来创建和共享我们的应用程序图像,使用最佳技术和改进的安全性,是时候进入下一章了,我们将学习如何运行软件容器以及不同功能和容器运行时的命令行。

第四章:4

运行 Docker 容器

软件容器是现代平台的标准应用程序制品。在前几章中,我们学习了如何创建软件容器镜像并与其他开发者或服务共享它们。本章将学习如何有效地使用容器。我们将理解主要的 Docker 容器对象以及如何使用适当的命令行操作和选项来管理它们。理解容器网络模型以及如何管理持久化数据是使用容器的关键。我们还将介绍管理这两者的概念。最后,本章将回顾一些你应该了解的重要维护任务,以便你能够有效地管理你的环境。

本章将涵盖以下主题:

  • 了解 Docker 软件容器对象

  • 学习使用命令行与容器互动

  • 限制容器访问主机资源

  • 管理容器行为

  • 容器运行时维护任务

技术要求

本书教你如何使用软件容器来提升应用程序的开发效率。本章的实验可以在github.com/PacktPublishing/Docker-for-Developers-Handbook/tree/main/Chapter4找到。在这里,你将找到本章内容中为便于理解而省略的一些扩展解释。本章的实战视频可以在packt.link/JdOIY观看。

本章将从介绍最重要的 Docker 容器对象开始。

了解 Docker 软件容器对象

容器运行时通常采用客户端-服务器模型工作。我们通过使用客户端命令行(如dockernerdctlcrictl)与运行时进行交互,具体取决于后端。运行时本身负责管理不同的对象或资源,用户可以通过与其交互轻松地操作这些对象。在我们学习如何与软件容器交互并管理容器之前,本节将介绍容器运行时管理的不同对象。所有命令或操作将与这些对象相关,目的是创建、删除或修改它们的属性。我们在第一章《使用 Docker 的现代基础设施与应用程序》和第二章《构建 Docker 镜像》中学习了容器镜像,并且还学会了如何构建它们。让我们从复习这些在所有容器运行时中通用的著名对象开始:

  • 容器镜像:这些对象也被称为容器工件。它们是创建容器的基础,因为它们包含所有文件,并集成到不同的层中,这些文件将包含在容器的文件系统内。镜像还包含运行容器所需的元信息,例如将要在内部运行的进程、将暴露到外部的端口、将用于覆盖容器文件系统的卷等。作为开发者,你将为你的应用创建并使用大量镜像。请参考在第二章《构建 Docker 镜像》中回顾的安全最佳实践。

  • 容器:当我们使用容器镜像并运行容器时,我们是在告诉容器运行时执行镜像元信息中定义的进程,并使用镜像层提供这些进程所需的基础文件系统。内核特性如 cgroups 和命名空间也被提供,以隔离容器。这使得在同一主机上以安全的方式运行不同的容器成为可能。除非特别声明,否则它们互相无法看到。容器的文件系统层将以读写模式添加到镜像层之上,所有低于容器层的层将以只读模式使用,已修改或创建的文件将使用 CoW 文件系统的特性进行管理。

  • 在主机级别,docker0 桥接接口,尽管也可以使用其他网络选项和驱动程序。容器运行时通过内部 IPAM 管理 IP 地址,并使用 NAT 允许容器访问主机附加的真实网络。网络对象允许我们创建不同的桥接接口并将容器连接到它们,隔离在不同网络中运行的容器。

  • :CoW 文件系统可能会影响应用程序的行为。如果你的进程修改了大量文件或任何文件必须在容器的生命周期中持久化,则必须使用卷来覆盖 CoW 文件系统管理。我们使用卷来存储容器层之外的文件。这些可以是我们主机系统中的文件夹、远程文件系统,甚至是外部块设备。可以使用不同的驱动程序,并且默认情况下,卷将作为文件夹在每个底层主机中本地可用。

所有这些对象将通过唯一的 ID 进行标识,我们将使用它们的名称或 ID 来引用它们。创建和删除它们的常见操作将在我们的客户端命令行中提供。我们还可以列出它们并检查它们的属性,并且我们可以使用 Go 模板格式化,就像我们在第二章《构建 Docker 镜像》中学到的那样。

容器编排器将拥有一组自己的对象或资源(如在 Kubernetes 中)。我们将在 第七章《与 Swarm 编排》以及 第八章《使用 Kubernetes 编排器部署应用程序》中分别进行回顾。

在我们回顾这些新对象之前,让我们记住,容器是通过容器运行时在主机之上运行的进程。这些进程通过使用主机的特殊内核功能相互隔离。容器镜像将为这些进程提供基础文件系统,我们将使用客户端命令行与它们进行交互。

容器被视为无状态和短暂的,尽管它们存在于底层主机上。在容器中运行的应用程序应当准备好可以在任何地方运行,并且它们的状态和数据应该管理在容器生命周期之外。如果我们需要存储应用程序的数据或其状态该怎么办?我们可以使用卷对象来持久化数据和进程状态,当容器被删除或使用相同数据创建新容器时。若我们在分布式或编排环境中工作,共享这些卷是至关重要的,我们需要使用外部卷将数据附加到任何需要它的容器上。

根据我们使用的容器运行时,相关文件的位置可能会有所不同,但在 Docker 容器运行时的情况下,我们预计所有镜像和容器层及其文件都位于 /var/lib/dockerc:\ProgramData\docker 下。对于新的桌面环境,如 Docker Desktop 和 Rancher Desktop,这些路径可能看起来完全不同。在这些环境中,我们将使用 WSL 或 Windows 命令行来执行客户端并与容器运行时交互。容器运行时运行在不同的 WSL 环境中;因此,你将无法直接访问其数据路径。若要查看客户端当前的数据路径,如果你使用的是 Docker 作为容器运行时,可以使用 docker info 命令:

$ docker info --format="{{ .DockerRootDir }}"
/var/lib/docker

如果你直接使用 containerd,数据根路径将位于 /var/lib/containerd 目录下,但在这两种情况下,你都无法在桌面环境中访问这些文件夹,因为客户端访问是通过管道连接远程访问容器运行时的。所有对象的元数据信息将存储在 DockerRootDir 路径下,我们可以通过容器运行时客户端使用适当的命令来检索这些对象的属性。

如果你使用的是 WSL2 与 Docker Desktop,那么两个 WSL 实例会被创建:docker-desktopdocker-desktop-data。第二个实例用于挂载所有数据到你自己的 WSL 实例(在我的例子中是 ubuntu-22.04,但在你那里可能不同)。这一切都得益于 Docker Desktop 的集成。我们可以在 \\wsl.localhost\docker-desktop-data\data\docker 目录中找到所有的 Docker 容器内容。以下是 PowerShell 截图,展示了我的环境数据:

图 4.1 – Docker 容器运行时对象数据在 docker-desktop-data WSL 实例中的展示

图 4.1 – Docker 容器运行时对象数据在 docker-desktop-data WSL 实例中的展示

现在我们已经了解了容器运行时管理的主要对象,我们可以回顾一下用于管理和与之交互的命令行选项。

学习如何使用命令行与容器交互

在本节中,我们将学习如何管理容器。我们将使用由 Docker 客户端提供的 Docker 命令行(docker-client 包或集成到 Docker Desktop 环境中的 WSL)。我们将在本章讨论的所有命令行操作对于其他客户端(如 nerdctlpodman)也类似,尽管在后者的情况下,它不使用 containerd 守护进程。

每次我们获取关于 Docker 对象的信息时,Docker 客户端都会通过 API 将操作发送到 Docker 守护进程。客户端可以使用 SSHHTTP/HTTPS 或直接 sockets(在 Microsoft 操作系统中为 pipes)进行通信。

首先,我们将从所有容器运行时对象的常见操作开始:

  • create:所有容器运行时对象都可以创建和销毁。这不适用于容器镜像,因为我们将使用 docker image build 来启动构建过程以创建它们。所有对象都会自动接收一个 ID,并且在某些情况下,如容器,还会自动添加一个随机名称。如果我们想要指定一个固定名称,可以使用 --name 参数。

  • list:此操作将显示定义类别中的所有对象;例如,docker image list 将列出我们容器运行时中本地所有可用的镜像。如我们在 第二章《构建 Docker 镜像》中所学,我们可以使用 --all 参数来筛选和格式化输出。如果我们只需要对象标识符,可以使用 --quiet 选项。这对于将输出传递给另一个命令非常有用。

重要提示

你可能注意到,我们也可以使用 docker container ps 或其简化版 docker ps 来列出容器。容器是运行在主机中的进程,利用内核特性提供隔离;因此,似乎将此命令作为列出进程的方式是合适的。默认情况下,只有正在运行的进程(或容器)会被列出,如果我们还希望显示已停止的容器,则需要使用 --all 参数。

  • inspect:检查对象将允许我们检索与定义对象相关的所有信息。默认情况下,所有对象的数据将以 JSON 格式呈现,但我们也可以使用 --format 参数来格式化输出。

  • remove:所有对象也可以被移除。我们将使用它们的 ID 或名称来删除它们。在某些情况下,可能会出现内部依赖关系。例如,如果任何现有的容器正在使用某个容器镜像,我们就无法移除该镜像。为了避免这些依赖关系,我们可以使用 --force 参数。

这些操作是所有容器运行时管理的对象共有的,但当我们深入容器时,会发现更多的操作。接下来,我们将回顾当前容器的操作,但首先,我们必须理解,当我们创建一个对象时,我们会准备所有必要的配置。这意味着,创建一个容器会准备容器以便运行,但容器会被停止。让我们通过一个快速的示例来查看这个功能的实际操作:

$ docker container create --name test alpine
f7536c408182698af04f53f032ea693f1623985ae12ab0525f7fb4119c8850d9
$ docker container inspect test --format="{{ .Config.Cmd }}"
[/bin/sh]
$ docker container ls
CONTAINER ID   IMAGE     COMMAND   CREATED   STATUS    PORTS     NAMES

我们的 Docker 容器运行时刚刚创建了一个容器,但它并未运行,尽管它仍然存在于我们的主机系统中。

我们可以移除这个容器并再次检查它。我们将无法从这个对象中获取任何值,因为它现在已经不存在了:

$ docker container rm test
test
$ docker container inspect test --format="{{ .Config.Cmd }}"
Error: No such container: test

现在,我们可以继续回顾容器的操作,首先从容器创建后实际启动容器的操作开始:

  • start:这个操作需要一个先前创建的容器对象。容器运行时将执行定义的容器对象的进程,使用宿主机的隔离和定义的附加资源。

  • run:这个操作会使用 --detach 参数;在这种情况下,容器将在后台运行,我们的终端会与容器分离。我们可以使用 --interactive--tty 来以交互模式执行当前容器,并使用伪终端;这样,我们就可以与容器的主进程进行主动交互。容器将使用其配置中定义的所有参数运行,例如用户名、定义的内核命名空间、卷、网络等。通过向命令行添加不同的参数,我们可以修改这些定义。

  • stop:容器可以被停止。这个操作会要求容器运行时向容器的主进程发送停止信号(SIGTERM),如果进程仍然存活,它会等待 10 秒钟(默认设置)后再发送终止信号(SIGKILL)。

  • kill:杀死容器将直接请求容器运行时发送SIGKILL信号。作为开发者,你应该准备好让应用程序在收到SIGTERMSIGKILL时正确退出。确保你的文件正确关闭,且在主进程终止后没有未管理的进程继续运行。

  • restart:此操作将用于停止并启动容器。我们可以要求容器运行时在主进程死亡时始终重启容器。

  • pause/unpause:容器可以被暂停。这将使容器运行时通知内核停止容器进程的 CPU 时间。这一点非常重要,因为暂停的容器可以用于共享容器资源,如卷和命名空间。

重要提示

如果你在前台运行某些进程时没有使用--interactive参数,可能会卡住并无法退出容器的主进程标准输出和错误输出。为避免这种情况,可以使用CTRL + P + Q键盘快捷键。

现在我们已经了解了管理容器的最重要操作,让我们回顾一下可以用来修改容器行为的一些参数:

  • --name:每个容器都会由唯一的 ID 标识,但始终会分配一个名称。这个名称将是随机的,由两个字符串组成。将使用内部数据库来生成它们,最终的拼接字符串将是唯一的。我们可以通过使用--name并传递我们选择的字符串来避免这种行为,但请记住,容器名称必须是唯一的,且不能重复使用该名称。

  • --restart:如前所述,我们可以要求容器运行时为我们管理容器的生命周期。默认情况下,如果主进程死亡,容器不会重启,但我们可以使用诸如on-failurealwaysunless-stopped的字符串来定义容器在失败时(任何退出码非0)、始终或仅在未有效停止容器的情况下是否应重新启动。我们还可以通过不使用任何特定的字符串或命令,确保 Docker 运行时不管理容器的生命周期。

  • --entrypoint:此选项允许我们覆盖容器镜像定义的入口点(主进程)。理解这一点非常重要,因为任何人都可以更改你的镜像入口点,执行镜像层中可用的任何二进制文件或脚本;因此,严格包含应用程序所需的文件是至关重要的。

  • --env:我们可以通过使用此参数或--env-file来添加新的环境变量。在这种情况下,将包含一个键值对格式的文件来添加一组变量。

  • --expose:默认情况下,仅容器镜像中定义的端口会被暴露,但如果容器行为的某些修改需要新的端口或协议,可以添加新的端口。

  • --user:此参数允许我们修改实际执行容器主进程的用户。如果你更改了容器的用户,必须确保你的应用程序仍然能够运行;这在你将应用程序运行在 Kubernetes 中时可能尤为关键。在 第八章使用 Kubernetes 编排器部署应用程序 中,我们将学习是否可以通过使用 安全上下文 来提高应用程序的安全性。

  • --publish--publish-all:这些参数允许我们发布一个端口(我们可以多次使用这些参数以添加多个端口),或者发布镜像定义的所有暴露端口。这将使应用程序可以通过 NAT 从容器的网络外部访问。除非在容器执行期间定义了特定端口,否则将使用随机主机端口来发布应用程序。

  • --memory--cpus:这些选项,和其他一些选项一起,将允许我们管理分配给容器的内存和 CPU 资源。我们还可以通过使用 --gpus 来包含主机的 GPU。

现在我们已经概览了与容器一起使用的最重要的参数,接下来我们将查看一些关键选项,以确保我们的工作负载安全:

  • --cap-add:此选项允许我们为容器内进程的执行特定地添加一些内核功能。内核功能是一组与超级用户相关的特权,系统以细粒度的方式提供这些特权。默认情况下,容器运行时不允许特权容器以所有可用的功能运行。容器运行时默认只允许所有可用功能的一个子集(当前可用的功能可以在man7.org/linux/man-pages/man7/capabilities.7.xhtml中查看)。例如,Docker 运行时允许 14 个功能(docs.docker.com/engine/reference/run/#runtime-privilege-and-linux-capabilities),这些功能可能足以让你的应用程序运行,但如果你的应用程序需要一些特定的功能,比如使用 NET_ADMIN 权限管理网络接口,你应该使用 --cap-add NET_ADMIN 参数来添加该功能。添加功能可能对修改当前内核行为有帮助,特别是如果你的应用程序需要一些特殊的特性。作为开发人员,你应该告知相关人员你的应用程序所需的特殊权限,因为在安全的容器编排环境中,功能可能会被剥离。请告知你的 DevOps 或集群管理员团队你的特殊需求。

  • --cap-drop:此选项用于移除某些能力。例如,如果我们需要移除在容器生命周期内更改文件所有权的可能性,可以使用 --cap-drop CHOWN,或者移除发送原始网络数据包的能力,例如 ICMP,可以使用 --cap-drop NET_RAW。你可能会在一些安全环境中看到所有能力都被丢弃的情况。

重要提示

--cap-drop--cap-add 都可以与 ALL 参数一起使用,这意味着所有的能力都会被丢弃或添加,具体取决于命令。作为开发者,你非常需要测试如果丢弃所有可用能力时可能出现的问题。这将帮助你为安全环境准备好应用程序。

  • --privileged:此选项将提供所有能力,并避免任何资源限制。你应该避免在应用程序容器中使用此选项。花时间查看你的应用程序所需的能力和资源,并应用它们。在生产环境中覆盖所有进程限制是个坏主意,应仅应用于特定的应用程序容器,例如用于监控你的基础设施。在这些特定情况下,你可能需要额外的资源或能够访问主机的所有能力、进程等,以便从容器内管理应用程序。

  • --disable-content-trust:此选项将禁用任何 Docker 内容信任验证;因此,任何签名或镜像来源检查都将被省略。

  • --read-only:在 /tmp 目录中执行容器时,你需要设置一个附加到此路径的卷以允许此交互。

  • --security-opt:某些扩展的安全措施可能需要额外的选项,例如设置不同的 seccomp 配置文件或指定 SELinux 选项。一般来说,这个选项允许我们修改 Linux 安全模块的行为。

现在我们知道如何运行容器以及最重要的选项,让我们回顾一下如何限制和包括底层主机的资源。

限制容器访问主机资源

在本节中,我们将学习如何限制容器内主机的资源,但首先,我们将了解容器的网络模型以及如何使用卷覆盖容器存储。

网络隔离

默认情况下是 docker0。这个接口在 Docker 守护进程安装时创建,所有容器的 IP 接口将与 docker0 相关联。可以使用不同的驱动程序扩展这一默认行为,例如,直接将网络 VLAN 附加到容器。

重要提示

默认情况下,以下网络插件可用:bridgehostipvlanmacvlannulloverlay

默认情况下,新的 Docker 容器运行时安装会创建三个不同的接口。我们可以通过列出安装后的默认网络对象来查看它们:

$ docker network list
NETWORK ID     NAME      DRIVER    SCOPE
490f99141fa4   bridge    bridge    local
b984f74311fa   host      host      local
25c30b67b7cd   none      null      local

所有容器默认将使用bridge接口运行。每次我们创建一个容器时,都会在主机中创建一个虚拟接口,并将其附加到docker0。所有的出站和入站流量都会经过这个接口。在这种情况下,理解所有附加到这个公共桥接的容器彼此之间是可见的,这一点非常重要。让我们通过这个例子来看看这是如何发生的:

$  docker container create --name one alpine sleep INF
116220a54ee1da127a4b2b56974884b349de573a4ed27e2647b1e780543374f9
$ docker container inspect one --format='{{ .NetworkSettings.IPAddress }}'

这个容器在运行之前不会获取 IP 地址。我们现在可以执行它,并再次查看 IP 地址:

$ docker container start one
one
$ docker container inspect one --format='{{ .NetworkSettings.IPAddress }}'
172.17.0.2

容器运行时管理 IP 分配,我们可以验证使用的网络段:

$ docker network inspect bridge --format='{{ .IPAM }}'
{default map[] [{172.17.0.0/16  172.17.0.1 map[]}]}

让我们验证当另一个容器运行并附加到网络桥接接口时发生了什么:

$ docker container ls
CONTAINER ID   IMAGE     COMMAND       CREATED          STATUS         PORTS     NAMES
116220a54ee1   alpine    "sleep INF"   12 minutes ago   Up 8 minutes             one
$ docker container run -ti alpine ping -c 3 172.17.0.2
PING 172.17.0.2 (172.17.0.2): 56 data bytes
64 bytes from 172.17.0.2: seq=0 ttl=64 time=1.148 ms
64 bytes from 172.17.0.2: seq=1 ttl=64 time=0.163 ms
64 bytes from 172.17.0.2: seq=2 ttl=64 time=0.165 ms
--- 172.17.0.2 ping statistics ---
3 packets transmitted, 3 packets received, 0% packet loss
round-trip min/avg/max = 0.163/0.492/1.148 ms

第二个容器对第一个容器的 IP 地址执行了三次 ping 操作,并且可以访问。两个容器都运行在同一网络段,关联到同一个桥接接口。此默认行为可以通过在创建网络对象时设置某些键来管理,例如com.docker.network.bridge.enable_icc,它管理同一网络中容器之间的隔离(更多信息请参见docs.docker.com/engine/reference/commandline/network_create))。

我们将使用--network来定义容器应该附加到的网络。

none网络可以用于初始化和运行没有任何网络能力的容器。当我们运行一些不需要网络流量的任务时,这会非常有用——例如,管理存储在卷中的数据。

我们可以通过使用host网络共享主机的网络命名空间。当我们将容器附加到此网络时,它将使用主机的 IP 接口。我们可以通过执行docker container run --rm --network=host alpine ip address show来验证这一行为。以下截图显示了该命令的输出,展示了使用host网络的容器内部接口:

图 4.2 – 我们主机的网络接口,包含在一个运行中的容器内

图 4.2 – 我们主机的网络接口,包含在一个运行中的容器内

在这里,我们可以看到docker0和前一个容器的接口都出现在新容器中。host网络通常用于监控和安全应用——例如,当我们需要访问所有主机接口以管理或获取它们的流量统计数据时。

重要提示

我们使用了--rm参数,在容器执行后立即删除容器。这个选项在测试和快速执行容器内部命令时非常有用。

理解自定义网络

我们还可以创建自定义网络,就像其他任何容器运行时对象一样。我们可以使用 docker network create <NETWORK_NAME> 来执行此任务;默认情况下,将创建一个新的桥接网络接口。这些新网络接口将类似于 docker0 接口,但会添加一些重要的功能:

  • 每个自定义网络与其他网络隔离,使用完全不同的网络段。每个自定义网络都会创建一个新的桥接接口,并将该网络段与之关联。所有连接到此网络的容器将彼此可见,但它们无法访问连接到任何其他网络的容器,包括默认桥接网络。反过来也是如此;因此,连接到某个网络的容器只能看到在同一网络上运行的容器。

  • 自定义网络可以动态连接。这意味着可以使用 docker network connect <CONTAINER>docker network disconnect <CONTAINER> 来连接和断开容器。这种行为无法在默认桥接网络中重现。

  • 每个自定义网络都提供内部 DNS。这意味着所有连接的容器都可以通过名称进行访问。因此,提供了网络发现,每次新的容器连接到此网络时,都会将一个新的条目添加到内部 DNS。但是,请记住,DNS 名称仅在定义的网络内可访问。如果我们使用 --link 参数,默认桥接网络也可以通过名称访问容器。这样,我们可以将容器连接在一起,使它们像使用 DNS 一样工作,但这只适用于包含在该参数中的容器;其他容器将无法通过名称访问。

让我们通过使用 docker network create 创建一个新网络来查看一个快速示例。我们还将定义名称、范围和关联的子网:

$ docker network create --subnet 192.168.30.0/24 mynetwork
43ee9a8bde09de1882c91638ae7605e67bab0857c0b1ee9fe785c2d5e5c9c3a7
$ docker network inspect mynetwork --format='{{ .IPAM }}'
{default map[] [{192.168.30.0/24   map[]}]}
$ docker run --detach  --name forty \
--network=mynetwork alpine sleep INF
3aac157b4fd859605ef22641ea5cc7e8b37f2216f0075d92a36fc7f62056e2da
$ docker container ls
CONTAINER ID   IMAGE     COMMAND       CREATED          STATUS         PORTS     NAMES
3aac157b4fd8   alpine    "sleep INF"   10 seconds ago   Up 8 seconds             forty
116220a54ee1   alpine    "sleep INF"   2 hours ago      Up 2 hours               one

现在,让我们尝试访问连接到创建的自定义网络的容器:

$ docker run  --rm --network=mynetwork alpine ping \
-c 1 forty
PING forty (192.168.30.2): 56 data bytes
64 bytes from 192.168.30.2: seq=0 ttl=64 time=0.222 ms
 --- forty ping statistics ---
1 packets transmitted, 1 packets received, 0% packet loss
round-trip min/avg/max = 0.222/0.222/0.222 ms

它可以通过其名称访问,但让我们再次尝试将容器连接到默认网络:

$ docker run  --rm --network=mynetwork alpine ping \
-c 1 one
ping: bad address 'one'

通过 DNS 名称无法访问。让我们验证网络是否可用:

$ docker container inspect one \
--format='{{ .NetworkSettings.IPAddress }}'
172.17.0.2
$ docker run  --rm --network=mynetwork alpine ping -c 1 172.17.0.2
PING 172.17.0.2 (172.17.0.2): 56 data bytes
--- 172.17.0.2 ping statistics ---
1 packets transmitted, 0 packets received, 100% packet loss

所有数据包都丢失。自定义网络与我们创建的网络之间无法访问桥接网络,尽管它们都使用主机的接口。每个网络都连接到自己的桥接网络,但我们可以使用 docker connect <NETWORK> <CONTAINER> 将容器连接到两个网络:

$ docker network connect mynetwork one

现在,我们有一个容器同时连接到自定义网络和默认桥接网络:

$ docker exec -ti one ip address show|grep inet
    inet 127.0.0.1/8 scope host lo
    inet 172.17.0.2/16 brd 172.17.255.255 scope global eth0
    inet 192.168.30.3/24 brd 192.168.30.255 scope global eth1

因此,我们现在可以访问自定义网络中的容器:

$ docker exec -ti one ping -c 1 forty
PING forty (192.168.30.2): 56 data bytes
64 bytes from 192.168.30.2: seq=0 ttl=64 time=0.199 ms
 --- forty ping statistics ---
1 packets transmitted, 1 packets received, 0% packet loss
round-trip min/avg/max = 0.199/0.199/0.199 ms

一些选项可以修改容器内部的默认网络行为。让我们看看一些用于运行容器或创建网络的选项:

  • --add-host:这个选项允许我们以 host:ip 格式包含一些外部主机,使它们像在 DNS 中一样可用。

  • --dns--dns-search--dns-option:这些选项允许我们修改容器的 DNS 解析。默认情况下,容器运行时会包含当前的 DNS,但我们可以改变这一行为。

  • --domainname:我们可以将容器的域名设置为不同于默认域名的名称。

  • --ip:尽管使用默认的动态 IP 地址映射非常重要,但我们可能更愿意为容器分配特定的 IP 地址。请小心使用此选项,因为不能重复使用 IP 地址。

  • --hostname:默认情况下,每个容器将使用容器的 ID 作为名称,但我们可以通过使用此选项来更改此行为。

  • --link:此选项允许我们通过多次使用该选项来连接两个或更多的容器。它与 --add-host 选项非常相似,但在这种情况下,我们将使用它以 CONTAINER_NAME:DNS_ALIAS 格式将容器连接到 DNS 名称,以便通过其 DNS 名称访问该容器。

  • --network-alias:有时,我们需要让容器在网络中使用多个名称。此选项允许我们为容器添加 DNS 别名。

  • --subnet--ip-range:此选项适用于网络,允许我们修改内部 IP 地址的分配。我们还可以使用 --gateway 参数修改每个网络的默认网关(默认情况下将使用最低的 IP 地址)。

在下一节中,我们将学习卷的工作原理。

管理容器中的持久数据

在容器中运行的应用程序必须能够在任何主机上运行。我们甚至可以进一步说,我们应该能够在云环境或本地环境中运行它们。然而,容器的生命周期不应包含进程状态和数据。将帮助我们管理容器生命周期之外的数据(如果我们使用远程存储解决方案,如 NAS)。在本节中,我们将学习容器运行时如何管理本地卷。卷将允许容器访问主机的文件系统或远程文件系统。

本地卷默认情况下会位于容器运行时主机上的 DockerRootDir 路径下。这对于任何在镜像元数据中声明了 VOLUME 键的情况以及由用户在容器运行期间创建或声明的命名卷都适用。

我们还可以使用任何本地文件系统(tmpfs卷,这在我们需要尽可能快的存储后端时非常有趣)。在绑定挂载的情况下,任何来自主机文件系统的目录或文件都可以包含在容器内。但是这里会出现一个问题:每当我们将应用程序迁移到另一个主机时,任何与主机相关的预期位置可能会发生变化。为避免这种情况,建议使用外部存储,这些存储可以在其他主机上同时呈现或在容器需要运行时呈现。这对于集群特别相关,集群中的多个节点可以运行你的容器。在这一点上,我们仅讨论如何让数据脱离容器的生命周期;在第十章中,利用 Kubernetes 中的应用数据管理,我们将讨论如何在这些更复杂的场景中管理数据。

让我们深入探讨一下,并描述本地卷的类型:

  • VOLUME定义。这些卷用于覆盖容器的文件系统,但作为用户,我们不负责其内容。换句话说,每当一个新容器运行时,一个新的卷将动态创建并使用新的数据。如果我们需要数据在执行之间持久化,我们必须自己定义一个卷。容器运行时完全管理未命名的卷。我们可以使用docker volume rm命令将其删除,但我们需要先停止并删除相关的容器。让我们用postgres:alpine镜像运行一个快速示例,该镜像使用动态未命名卷覆盖容器的/var/lib/postgresql/data目录:

    $ docker pull postgres:alpine -q
    docker.io/library/postgres:alpine
    $ docker image inspect postgres:alpine \
    --format="{{ .Config.Volumes }}"
    map[/var/lib/postgresql/data:{}]
    $ docker run -d -P postgres:alpine
    27f008dea3f834f85c8b8674e8e30d4b4fc6c643df5080c62a14b63b5651401f
    $ docker container inspect 27f008dea3 \
    --format="{{ .Mounts }}"
    [{volume 343e58f19c66d664e92a512ca2e8bb201d8787bc62bb9835d5b2d5ba46584fe2 /var/lib/docker/volumes/343e58f19c66d664e92a512ca2e8bb201d8787bc62bb9835d5b2d5ba46584fe2/_data /var/lib/postgresql/data local  true }]
    frjaraur@sirius:~$ docker volume ls
    DRIVER    VOLUME NAME
    docker volume create to create them or just include a volume name when we run a container. If this volume already exists, the container runtime will attach it to the container, and if isn’t already present, it will be created. Container runtimes allow us to extend their volumes’ functionality by using different plugins; this will allow us to use NFS, for example. Let’s run the previous example using a defined volume; we will use DATA as the name for this new volume:
    
    

    $ docker run -d -P \

    -v DATA:/var/lib/postgresql/data postgres:alpine

    ad7dde43bfa926fb7afaa2525c7b54a089875332baced7f86cd3709f04629709

    $ docker container inspect ad7dde43bf \

    --format="{{ .Mounts }}"

    [{volume DATA /var/lib/docker/volumes/DATA/_data /var/lib/postgresql/data local z true }]

    $ docker volume ls

    DRIVER VOLUME NAME

    local 343e58f19c66d664e92a512ca2e8bb201d8787bc62bb9835d5b2d5ba46584fe2

    DATA 是卷的名称,每次我们删除 postgresql 容器并创建一个新容器时,都可以重新使用此卷。在这两个示例中,数据都会保存在卷中,但命名卷使我们能够更方便地管理数据。

    
    

重要提示

理解命名卷和未命名卷(动态卷)使用主机存储非常重要。你必须小心文件系统中遗忘的卷;我们将在本章稍后的容器运行时维护任务部分中回顾一些处理方法。

  • 绑定挂载:在这种情况下,我们将把主机的目录或文件包含在容器内部。在实验室部分,我们将练习使用这种类型的卷。

  • 内存或 tmpfs 卷:这些卷可用于覆盖容器的存储,提供快速存储,例如主机的内存。这对于存储一些经常变化的小数据(例如统计数据)非常有用。如果不限制使用的内存量,它也可能非常危险。

重要提示

卷可以以只读模式使用,以保持现有数据不变。这对于展示你希望保持不变的数据非常有用,例如操作系统文件。我们还可以操作容器文件系统中看到的任何已挂载卷的所有权和权限。通常也会使用--volumes-from在容器之间共享卷。

正如你可能已经注意到的,我们在运行时使用了-v--volumes参数来为容器添加卷。我们可以多次使用这个参数,并使用--volume SOURCE_VOLUME:FULL_DESTINE_PATH[:ro][:Z]格式,其中SOURCE_VOLUME可以是之前描述的任何类型(使用绑定挂载时,必须使用共享目录的完整路径)。卷可以以只读模式挂载,当你向容器提供配置时,这非常有趣,如果需要,我们还可以使用Z选项强制启用 SELinux。然而,我们也可以使用--mount参数来使用卷,它提供了一个扩展版的卷挂载选项,采用键值对格式:

  • type:我们指定挂载的类型(bindvolumetmpfs)。

  • source(或src):此键用于定义挂载的源。值可以是一个名称(用于命名卷)、一个完整路径(用于绑定挂载),或为空(用于未命名卷)。

  • target(或dst):此键定义了卷将呈现的目标路径。

我们还可以包含volume-opt

现在,我们已经学习了如何将主机的网络和文件系统包含在容器内,接下来我们将继续访问 CPU 和内存等其他资源。

限制访问主机硬件资源

在容器中共享主机资源是容器模型的关键,但这需要能够限制它们访问这些资源的方式。在第一章使用 Docker 的现代基础设施和应用程序中,我们了解到资源隔离是通过 cgroups 提供的。

如果我们的主机内存或 CPU 耗尽,所有在其上运行的容器都会受到影响。这就是为什么限制对主机资源的访问如此重要的原因。

默认情况下,容器没有任何限制,因此它们可以消耗主机的所有资源。作为开发人员,你应该知道你的所有应用组件所需的资源,并限制为它们提供的资源。我们现在将回顾可以传递给容器运行时的参数,以有效地限制容器对资源的访问:

  • --cpus:此参数允许我们定义分配给容器主进程的 CPU 数量。此值取决于主机上可用的 CPU 数量。我们可以使用小数来表示总 CPU 数量的一个子集。此值保证可以用于运行容器进程的 CPU 数量。

  • --memory:我们可以设置分配给容器进程的最大内存。当达到此限制时,主机的内核将通过执行名为--oom-kill-disable的运行时杀死容器的主进程;然而,强烈不建议这样做,因为如果消耗了过多的内存,可能会使您的主机失去保护。

重要说明

容器运行时提供了更多选项,用于管理容器可用的 CPU 和内存资源。甚至可以限制对块设备的输入/输出 (I/O) 操作或修改默认的内核调度行为。我们刚刚回顾了最重要的选项,帮助您理解如何限制对主机资源的访问。您可以在docs.docker.com/config/containers/resource_constraints/查阅完整选项。

扩展对主机资源的访问

容器依赖于容器运行时在主机上运行。默认情况下,主机的 CPU 和内存是通过 cgroups 提供的。卷通过不同类型(动态无名卷、命名卷、主机绑定或 tmpfs 卷)在容器内提供,网络通过内核命名空间提供。我们可以使用其他 Docker 客户端参数来集成或修改内核命名空间:

  • --ipc:此参数允许我们修改容器内的 IPC 行为(共享内存段、信号量和消息队列)。出于监控目的,使用--ipc host来包含主机的 IPC 是很常见的做法。

  • --pid:此选项用于设置 PID 内核命名空间。默认情况下,容器会以自己的进程树运行,但我们可以通过使用--pid container:CONTAINER_NAME来包含其他容器的 PID。我们还可以通过使用--pid host来包含底层主机的 PID 树。如果您的应用程序需要监控主机的进程,这会非常有用。

  • --userns:我们可以创建一个不同的内核用户命名空间并将其包含在容器内。这允许我们将不同的用户 ID 映射到容器内运行的进程。

其他有趣的选项允许我们包括不同的主机设备:

  • --device:此选项允许我们将主机的设备包含在容器内。运行在容器内的进程将把这些设备视为直接连接到容器。我们可以使用此选项挂载块设备(--device=/dev/sda:/dev/xvdc)、声音设备(--device=/dev/snd:/dev/snd)等。

  • --gpus:我们可以使用--gpus all

在本节中,我们学习了如何限制对不同主机资源的访问。在下一节中,我们将学习如何管理在主机上运行的容器及其行为。

管理容器行为

容器运行时将通过提供一组选项来帮助我们了解容器的行为,包括查看进程日志、在容器之间复制文件、执行容器内部进程等。

以下操作允许我们与容器进程和文件系统进行交互:

  • exec:我们可以通过使用docker container exec将新进程附加到容器的命名空间。此选项允许我们运行容器文件系统中或挂载卷中的任何脚本或二进制文件。

  • attach:当容器在后台运行,且与容器运行时客户端命令行分离时,我们可以使用此操作附加到其输出。我们将把 Docker 客户端附加到容器的主进程;因此,所有输出和错误都会显示在我们的终端上。使用此选项时要小心,因为你应该从容器的主进程输出中分离出来,以释放你的终端。不要使用Ctrl + C键组合,因为这会将SIGNINT信号发送给容器的主进程,可能会导致容器停止。你可以通过使用Ctrl + P + Q来从容器的进程中分离出来。

  • cp:有时,我们需要从容器中获取一些文件进行调试,例如,某些错误。我们可以使用cp操作来复制文件到/从容器中。记住,你可以使用卷为容器提供文件或目录;cp操作应仅用于小文件,因为它使用容器运行时和客户端来从容器中检索文件或将文件从本地客户端发送到容器中。

  • logs:从容器中获取日志是理解应用程序工作原理的关键。每个容器都有一个主进程,通过使用logs操作,我们可以获取该进程的STDOUTSTDERR流作为输出。我们可以使用--follow来持续附加到容器的进程输出,并使用--tail来仅获取一定数量的行。可以使用--since--until按日期过滤日志。

重要提示

如果你需要在容器内执行交互式会话,重要的是要加上--interactive--tty。这些选项要求容器运行时准备一个伪终端并附加到exec操作中定义的二进制文件上;例如,我们可以使用docker container exec --ti CONTAINER /bin/bash在指定的容器内执行一个bash shell。

我们还可以使用运行中的容器创建一个容器镜像。我们在第二章中学习了有关此功能的信息,即构建 Docker 镜像。从容器创建镜像不提供可重复使用的方法,并且不是创建镜像的好方法,但在某些情况下,您可能需要容器的内容来查看包含的文件。

我们可以使用docker container commit从容器层创建镜像,并通过docker container export导出所有层。此操作仅存储容器中包含的文件,不包括任何元信息,因为我们仅处理内容。

另一个非常有趣的快速调试容器进程所做文件更改的操作是diff。此操作允许我们通过比较容器层的所有文件与镜像层的文件来检索创建的更改。让我们通过一个快速示例回顾这个操作:

$ docker container run --name test alpine touch /tmp/TESTFILE
$ docker container diff test
C /tmp
A /tmp/TESTFILE

正如我们从命令输出中所见,/tmp目录已更改(由C指示),并且添加了一个文件/tmp/TESTFILE(由A指示)。

现在,我们已经全面了解了如何与容器交互并获取调试应用程序信息,让我们学习一些有助于保持容器环境健康的日常任务。

容器运行时维护任务

在本节中,我们将快速浏览一些用于维护我们容器运行时的日常操作。

维护主机中可用存储空间的正确量是非常重要的任务。根据您的容器运行时,您可能需要修剪特定对象而不是使用通用工具。例如,使用名为nerdctlcontainerd客户端时会发生这种情况。如果您使用的是 Rancher Desktop,则需要针对每个类别专门删除不必要的对象。让我们通过 Docker 客户端使用docker system prune来回顾如何执行此操作。但是,在修剪系统并清理旧对象之前,您应首先了解磁盘空间的使用情况。

要查看已分配给不同对象的实际磁盘量,请使用docker system df命令:

$ docker system df
TYPE            TOTAL     ACTIVE    SIZE      RECLAIMABLE
Images          5         4         1.564GB   11.1MB (0%)
Containers      17        0         729.3MB   729.3MB (100%)
Local Volumes   2         2         0B        0B
Build Cache     13        0         1.094GB   1.094GB

这个命令显示了系统上图像、容器和本地卷使用的空间。通过添加--verbose参数,我们可以获取相当详细的信息,该参数将显示主机中每个对象使用的确切空间量。每个对象类别的特定部分将显示在删除这些对象后我们将释放的空间(仅作为示例显示对象标题和一个对象行;您可以访问完整输出网址github.com/PacktPublishing/Docker-for-Developers-Handbook/blob/main/Chapter4/Readme.md):

$ docker system df --verbose
Images space usage:
 REPOSITORY               TAG             IMAGE ID       CREATED       SIZE      SHARED SIZE   UNIQUE SIZE   CONTAINERS
localhost:32768/trivy    custom-0.38.2   bdde1846d546   2 weeks ago   1.3GB     7.05MB        1.293GB       4
Containers space usage:
CONTAINER ID   IMAGE                                 COMMAND                  LOCAL VOLUMES   SIZE      CREATED        STATUS                       NAMES
df967027f21a   alpine                                "touch /tmp/TESTFILE"    0               0B        47 hours ago   Exited (0) 47 hours ago      test
Local Volumes space usage:
VOLUME NAME                                                        LINKS     SIZE
DATA                                                               1         0B
343e58f19c66d664e92a512ca2e8bb201d8787bc62bb9835d5b2d5ba46584fe2   1         0B
 Build cache usage: 1.094GB
 CACHE ID       CACHE TYPE     SIZE      CREATED       LAST USED     USAGE     SHARED
lipx4a3h7x8j   regular        4.05MB    2 weeks ago   2 weeks ago   2         true

这个输出给了我们一个很好的了解,关于我们的环境是如何运行的。作为开发者,如果你在本地构建镜像,你可能会有很多缓存层。这些层会帮助加速构建过程,但所有很长时间未被使用的层可以被删除。

让我们看一下在示例中镜像是如何分布的:

REPOSITORY               TAG             IMAGE ID       CREATED       SIZE      SHARED SIZE   UNIQUE SIZE   CONTAINERS
localhost:32768/trivy    custom-0.38.2   bdde1846d546   2 weeks ago   1.3GB     7.05MB        1.293GB       4
localhost:32768/alpine   0.3             a043ba94e082   2 weeks ago   11.1MB    7.05MB        4.049MB       0
registry                 2.8.1           0d153fadf70b   6 weeks ago   24.15MB   0B            24.15MB       1
postgres                 alpine          6a35e2c987a6   6 weeks ago   243.1MB   7.05MB        236MB         2
alpine                   latest          b2aa39c304c2   6 weeks ago   7.05MB    7.05MB        0B            10

所有基于alpine的镜像共享7.05MB;因此,使用共同的基础镜像将帮助你节省大量存储空间,这是一个好习惯。

CONTAINERS部分将帮助我们发现可能的问题,因为我们不希望容器占用太多空间。记住,容器是为了短暂存在的,持久数据应该保存在容器存储之外。应用日志应该重定向到卷或STDOUT/STDERR(这是推荐的选项)。因此,容器占用的空间应该最小,仅包括运行时的修改,不应该持久化。在我们的示例中,我们可以看到几个容器使用了几兆字节的空间:

737aa47334e2   localhost:32768/trivy:custom-0.38.2   "trivy image python:…"   0               365MB     2 weeks ago    Exited (0) 2 weeks ago       infallible_mirzakhani
f077c99cb082   localhost:32768/trivy:custom-0.38.2   "trivy image python:…"   0               365MB     2 weeks ago    Exited (0) 2 weeks ago       sharp_kowalevski

在这两种情况下,trivy数据库可能被包含在容器的层中(我们在构建这些镜像时使用了trivy并更新了它的数据库)。

我们也有一些卷(动态和命名卷)存在,但没有存储数据,因为我们没有向数据库示例添加任何数据。

最后,我们可以在docker system df –verbose命令的输出中看到缓存部分,在这里我们将找到在buildx过程中使用的共享层。

docker system df显示的对象磁盘使用情况是对/var/lib/docker(默认的rootDir)中物理空间分布的表示。

修剪容器对象

一旦我们了解了主机存储的分布情况,就可以开始清理未使用的对象。我们将使用docker system prune一次性清理所有未使用的对象。它会通过删除不同类别的对象来尝试释放磁盘空间。我们可以通过使用--volumes参数来包括卷。system prune命令将删除以下内容:

  • 所有悬空的镜像(默认情况下):未被任何容器镜像引用的镜像层。

  • 所有未使用的镜像(使用 --all 参数):未被任何容器引用的镜像(无论是正在运行的还是停止的容器)。

  • 所有停止的容器(默认情况下):默认情况下,所有停止的容器(状态为exited的容器)都会被删除。这将移除容器的层。

  • 所有未使用的卷(使用 --volumes):未被任何容器使用的卷。

  • 所有未使用的网络(默认情况下):没有容器附加的网络。

  • 所有悬空的缓存层(默认情况下):所有未在任何构建过程中引用的层。

你将始终被要求确认此操作,因为它无法撤销:

$ docker system prune
WARNING! This will remove:
  - all stopped containers
  - all networks not used by at least one container
  - all dangling images
  - all dangling build cache
 Are you sure you want to continue? [y/N] y
Deleted Containers:
df967027f21a15e473d236a9c30fa95d5104a8a180a91c3ca9e0e117bdeb6400
...
Deleted Networks:
test1
...
Deleted build cache objects:
1cmmyj0xgul6e37qdrwjijrhf
...
 Total reclaimed space: 1.823GB

清理过程完成后,会显示回收的空间的总结。

对于每一类对象,我们可以执行以下修剪操作:

  • docker container prune

  • docker image prune

  • docker buildx prune

  • docker network prune

这些操作只会清理特定的对象,如果你不想更改其他对象,这可能非常有用。

所有修剪选项都可以使用适当的--filter参数进行过滤。以下是一些最常用的过滤器:

  • until:我们使用时间戳参数来只删除在某个日期之前创建的容器。

  • label:这将帮助我们过滤掉某一类别中只希望删除的对象。可以使用多个标签,用逗号分隔。标签可以根据其存在或不存在进行过滤,我们还可以使用键和值进行精细选择。

重要提示

如果你计划安排修剪进程,你可能需要使用--force来以非交互方式执行它们。

在继续下一个部分之前,了解容器的日志也会存在于主机系统中是非常重要的。

配置容器运行时日志

你的系统容器日志选项取决于你的容器运行时。在本节中,我们将快速回顾一些 Docker 容器运行时提供的选项,因为它是提供最先进选项的运行时。你可能会发现可以使用 JSON 格式进行日志记录的选项,但 Docker 还提供了其他日志驱动程序。

默认情况下,Docker 守护进程将使用json-file日志驱动程序,但我们可以在 Docker 的daemon.json文件中更改这一行为。该驱动程序比其他驱动程序使用更多的磁盘空间,因此建议在本地开发时使用本地日志驱动程序。我们可以通过配置syslogjournald驱动程序,在 Linux 环境中使用主机的系统日志,但如果我们需要将容器日志发送到外部应用程序,我们可能会使用gelf(常用标准)或splunk驱动程序,尽管也有一些特定于云环境的驱动程序。

我们可以通过向daemon.json文件添加特定的键来配置一些维护选项。以下是一个示例,它将日志的大小保持在 20MB 以内:

   {
      "log-driver": "json-file",
      "log-opts": {
            "max-size": "20m",
            "max-file": "10",
        }
   }

我们将应用此配置并重启我们的 Docker 容器运行时。我们可以在 Docker Desktop 中进行这些更改:

图 4.3 – Docker Desktop 中可用的 Docker 守护进程设置(我们环境中配置的嵌入式 daemon.json 文件)

图 4.3 – Docker Desktop 中可用的 Docker 守护进程设置(我们环境中配置的嵌入式 daemon.json 文件)

可以为每个容器定义一个特定的日志驱动程序,尽管通常建议为整个环境定义一个通用的日志驱动程序:

$ docker run \
      --log-driver local --log-opt max-size=10m \
      alpine echo hello world

在完成本节内容之前,我们需要讨论一下可以在我们的环境中使用的不同日志记录策略:

  • 本地日志:在开发应用程序时,你可能会使用本地日志。当你删除容器时,这些日志也会被删除,并且始终由容器运行时进行管理。这些日志只会保存在你的电脑桌面、笔记本电脑或服务器本地。

  • :使用容器的外部存储可以确保日志在执行之间得以保留;尽管这些日志可能会附加到主机的存储上,从而只会在本地保存。如果你希望在其他服务器上保留这些日志,以防你迁移容器(或执行带有相同附加卷的新容器),你将需要使用外部存储解决方案,如 NAS 或 SAN。

  • 外部日志接收:这是生产环境的最佳选择。你的应用程序可以直接将日志从代码发送到外部日志接收解决方案,或者你可以配置容器运行时直接将日志发送出去。这将帮助你保持一个统一的环境,尤其是当你的应用程序在容器中运行时。

在下一节中,我们将通过执行一些实验来回顾本章所学的内容。

实验

以下实验将提供示例,帮助将本章中学到的概念和步骤付诸实践。我们将使用 Docker Desktop 作为容器运行时,并使用 WSL2(或你的 Linux/macOS 终端)来执行文中描述的命令。

确保你已从 github.com/PacktPublishing/Docker-for-Developers-Handbook.git 下载了本书的 GitHub 仓库内容。对于本章的实验,我们将使用 Chapter4 目录中的内容。

容器网络概念回顾

在这一节中,我们将回顾本章中学习到的一些最重要的网络主题:

  1. 首先,我们将在后台运行一个容器,该容器将作为其他步骤中的参考。我们将运行一个简单的 sleep 命令:

    $ docker container run -d --name one alpine sleep INF
    one is running, we will run a second one directly with the ping command. We will use one as the name to test the default bridge network DNS’s existence:
    
    

    $ docker container run -ti --rm \

    --name two alpine ping -c1 one

    one 无法解析,但让我们验证一下是否存在通信。我们使用了 --rm 参数,在容器执行完毕后立即删除它。

    
    
  2. 让我们通过使用 inspect 命令来验证容器的 IP 地址:

    $ docker container inspect one \
    --format="{{ .NetworkSettings.IPAddress }}"
    two can reach container one:
    
    

    $ docker container run -ti --rm --name two \

    --add-host one:172.17.0.2 alpine ping -c1 one

    PING one (172.17.0.2): 56 data bytes

    从 172.17.0.2 收到 64 字节:seq=0 ttl=64 time=0.116 ms

    --- one ping 统计信息 ---

    1 个数据包传输,1 个数据包接收,0% 数据包丢失

    往返时间最小/平均/最大 = 0.116/0.116/0.116 毫秒

    
    As expected, both containers see each other because they are running in the default bridge network. Let’s remove the reference container so that we can test this again using a custom network:
    
    

    $ docker container rm --force one

    测试网网络并回顾其 IPAM 配置:

    $ docker network create testnet
    582fe354cf843270a84f8d034ca9e152ac4bffe47949ce5399820e81fb0ba555
    $ docker network inspect testnet --format="{{ .IPAM.Config }}"
    [{172.18.0.0/16  172.18.0.1 map[]}]
    

    现在我们开始启动连接到这个网络的参考容器:

    $ docker container run -d --net testnet --name one alpine sleep INF
    027469ad503329300c5df6019cfe72982af1203e0ccf7174fc7d0e242b7999aa
    
    
    

重要提示

如果容器已经在运行,可以通过使用 docker network connect NETWORK CONTAINER 来完成此操作(例如,如果我们重新使用之前步骤中的容器并将其连接到桥接网络,我们也可以将其连接到新的自定义网络)。

现在,让我们回顾一下分配给这个自定义网络中的容器的 IP 地址:

$ docker network inspect testnet \
--format="{{ .Containers }}"
ping command with the first container’s name as the target:

$ docker container run -ti --rm --name two \

--net testnet alpine ping -c1 one

PING one (172.18.0.2): 56 data bytes

从 172.18.0.2 收到 64 字节:seq=0 ttl=64 time=0.117 ms

--- one ping 统计 ---

1 个数据包发送,1 个数据包接收,0%数据包丢失

默认情况下是 docker0 桥接接口)。

访问容器服务

在本实验中,我们将使用创建的自定义网络并运行一个简单的 NGINX web 服务器:

  1. 我们使用nginx:alpine镜像运行一个新容器,并将其连接到自定义网络。请注意,我们没有使用--it(交互式和伪终端附加)参数,因为我们不会与 NGINX 进程进行交互:

    $ docker container run -d --net testnet \
    --name webserver nginx:alpine
    docker container ls or docker ps:
    
    

    $ docker ps

    CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES

    1eb773889e80 nginx:alpine "/docker-entrypoint.…" 4 minutes ago Up 4 minutes 80/tcp webserver

    使用 curl 包并测试与运行在自定义网络中的 Web 服务器的连接:

    $ docker container exec -ti one /bin/sh
    / # ps -ef
    PID   USER     TIME  COMMAND
        1 root      0:00 sleep INF
        7 root      0:00 /bin/sh
       26 root      0:00 ps -ef
    / # apk add --update --no-cache curl
    fetch https://dl-cdn.alpinelinux.org/alpine/v3.17/main/x86_64/APKINDEX.tar.gz
    ...
    OK: 9 MiB in 20 packages
    / # curl webserver -I
    HTTP/1.1 200 OK
    ...
    

    现在,我们在参考容器内执行一个 shell,退出之前,我们可以验证参考容器的主机名默认是容器的 ID:

    / # hostname
    027469ad5033
    webserver container because we are going to modify its main page by using a bind mount volume:
    
    

    当前路径下的 data 目录,我们将使用一个简单的 echo 命令创建 index.xhtml 文件:

    $ mkdir $(pwd)/data
    webserver container again, but this time, we will add the created directory as a volume so that we can include our index.xhtml file:
    
    

    $ docker container run -d --net testnet -v $(pwd)/data:/usr/share/nginx/html \

    --name webserver nginx:alpine

    再次启动 webserver 服务:

    $ docker container exec -ti one curl webserver
    webserver container using the same volume, we will obtain the same result because this directory provides persistency for static content:
    
    

    $ docker container run -d --net testnet -v $(pwd)/data:/usr/share/nginx/html \

    --name webserver2 nginx:alpine

    $ docker container exec -ti one curl webserver2

    index.xhtml 文件并验证结果:

    $ echo "My webserver 2" >data/index.xhtml
    $ docker container exec -ti one curl webserver2
    My webserver 2
    

    请注意,我们可以在容器运行状态下更改静态内容。如果您的应用程序管理静态内容,您可以在开发过程中在线验证更改,但如果您的进程在启动时读取信息,则可能无法正常工作。在这种情况下,您需要重新启动/重新创建容器。

    
    
    
    
    
    
    
    
    1. 最后,让我们移除第二个 web 服务器:
    $ docker container rm -fv webserver2
    -fv argument to force-remove the container (stop it if it was running) and the associated volumes (in this case, we used a bind mount, which will never be removed by the container runtime, so don’t worry about this type of mount). Let’s also launch our web server by using the extended mount definition just to understand its usage:
    
    

    $ docker container run -d --net testnet \

    –name webserver \

    --mount type=bind,source=$(pwd)/data,target=/usr/share/nginx/html \

    nginx:alpine

    b2446c4e77be587f911d141238a5a4a8c1c518b6aa2a0418e574e89dc135d23b

    $ docker container exec -ti one curl webserver

    我的 webserver 2

    
    
    1. 现在,让我们测试一个命名卷的行为:
    $ docker container run -d --net testnet -v WWWROOT:/usr/share/nginx/html --name webserver nginx:alpine
    fb59d6cf6e81dfd43b063204f5fd4cdbbbc6661cd4166bcbcc58c633fee26e86
    $ docker container exec -ti one curl webserver
    <!DOCTYPE html>
    <html>
    <head>
    <title>Welcome to nginx!</title>
    …
    <h1>Welcome to nginx!</h1>
    $ docker cp data/index.xhtml webserver:/usr/share/nginx/html
    

    让我们再测试一次,验证更改:

    $ docker container exec -ti one curl webserver
    My webserver 2
    

正如我们所看到的,我们可以使用卷在容器内管理持久数据,并且可以使用docker cp将一些内容复制到其中(您也可以使用相同的命令来检索容器的内容)。我们还测试了所有内部通信;我们没有将任何服务暴露到容器运行时环境之外。让我们移除仍然存在的webserver容器:

$ docker rm -f webserver webserver2

现在,让我们继续进行下一个实验。

暴露应用程序

在本实验中,我们将暴露应用程序的容器,允许它们在容器运行时的内部网络之外访问:

  1. 我们将使用--publish-all-P参数来发布镜像定义的所有暴露端口:

    $ docker container run -d \
    --net testnet -P -v WWWROOT:/usr/share/nginx/html \
    --name webserver nginx:alpine
    dc658849d9c34ec05394a3d1f41377334261283092400e0a0de4ae98582238a7
    $ docker ps
    CONTAINER ID   IMAGE          COMMAND                  CREATED          STATUS          PORTS                   NAMES
    dc658849d9c3   nginx:alpine   "/docker-entrypoint.…"   14 seconds ago   Up 12 seconds   0.0.0.0:32768->80/tcp   webserver
    32768 and 61000.
    
    1. 现在,让我们从主机检查服务。我们可以使用localhost127.0.0.10.0.0.0作为 IP 地址,因为我们没有指定任何主机的 IP 地址:
    $ curl 127.0.0.1:32768
    host network.As we already have `curl` installed on one container, we can use `commit` for these changes to prepare a new image for running new containers with `curl`:
    
    

    $ docker container commit one myalpine

    sha256:6732b418977ae171a31a86460315a83d13961387daacf5393e965921499b446e

    
    

重要提示

如果您直接在 Linux 中使用 host 网络,您将能够直接连接到容器的端口,即使它们没有暴露。在 WSL 环境中,直接这样做是行不通的,但在集群环境中,您可以使用这种行为。

  1. 现在,我们可以通过将此新容器连接到主机网络,来验证网络的变化:

    $ docker container run -d --net host \
    --name two myalpine sleep INF
    inspect, we will notice that no IP addresses will be associated with the container via the container runtime. Instead, all the host’s network interfaces will be attached to the containers:
    
    

    $ docker container exec \

    -ti two ip add show|grep "inet "

    inet 127.0.0.1/8 范围主机 lo

    inet 172.17.0.1/16 brd 172.17.255.255 范围全局 docker0

    inet 192.168.65.4 peer 192.168.65.5/32 范围全局 eth0

    inet 172.18.0.1/16 brd 172.18.255.255 范围全局 br-582fe354cf84

    
    
    1. 但是,您应该注意到,在主机网络中 DNS 容器解析不起作用:
    $ docker container exec -ti two curl webserver -I
    curl: (6) Could not resolve host: webserver
    
    1. 让我们获取 web 服务器的 IP 地址,以通过主机网络容器访问它:
    $ docker container inspect webserver --format="{{ .NetworkSettings.Networks.testnet.IPAddress }}"
    172.18.0.3
    $ docker container exec -ti two curl 172.18.0.3
    My webserver 2
    

接下来,我们将回顾如何限制对主机硬件资源的访问,以及如何超出内存限制会触发 OOM-Killer 内核进程的执行。

限制容器的资源使用

在本实验中,我们将回顾如何限制容器内的主机内存:

  1. 首先,我们将创建一个自定义镜像,包括stress-ng应用:

    $ cat <<EOF|docker build -q -t stress -
    FROM alpine:latest
    RUN apk add --update --no-cache stress-ng
    EOF
    stress-ng works by using just one worker process and a maximum memory capacity of 1,024 MB:
    
    

    $ docker run -d --name stress stress stress-ng \

    --vm-bytes 1024M --fork 1 -m 1

    使用 docker stats 检索当前容器的资源使用情况:

    $ docker stats --no-stream
    CONTAINER ID   NAME      CPU %     MEM USAGE / LIMIT     MEM %     NET I/O     BLOCK I/O   PIDS
    docker stats to retrieve the statistics continuously:
    
    

    $ docker stats --no-stream

    CONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O PIDS

    stress 容器并重新运行,限制其访问主机内存:

    $ docker container kill stress
    stress
    $ docker run -d --name stress-limited  \
    --memory 128M stress stress-ng --vm-bytes 1024M  \
    --fork 1 -m 1
    stats action again continuously (to show the output in this book, we executed it using --no-stream a few times) and we can verify that although stress-ng runs a process with 1,024 MB, the container never uses that amount of memory:
    
    

    $ docker stats --no-stream

    CONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O PIDS

    ff3f4797af43 stress-limited 166.65% 125.1MiB / 128MiB 97.74% 1.12kB / 0B 0B / 0B 4

    
    Wait a few seconds and execute it again:
    
    

    $ docker stats --no-stream

    CONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O PIDS

    ff3f4797af43 stress-limited 142.81% 127MiB / 128MiB 99.19% 1.12kB / 0B 0B / 0B 5

    
    As we expected, the memory usage is limited. You can verify what happened by reviewing the current host’s system log. The container runtime uses cgroups to limit the container’s use of resources and the kernel launched the OOM-Killer feature to kill the processes that were consuming more memory than expected:
    
    

    $ dmesg|grep -i oom

    [22893.337110] oom_reaper: 收割进程 19232 (stress-ng),现在 anon-rss:0kB,file-rss:0kB,shmem-rss:32kB

    [22893.915193] stress-ng 调用了 oom-killer:gfp_mask=0xcc0(GFP_KERNEL),order=0,oom_score_adj=1000

    [22893.915221] oom_kill_process.cold+0xb/0x10

    [22893.915307] [ pid ] uid tgid total_vm rss pgtables_bytes swapents oom_score_adj name

    stress-ng 工作进程,但它会启动更多进程(这是 stress-ng 的正常行为,但如果 OOM-Killer 被要求销毁您的进程,您的应用程序可能会崩溃)。

    
    
    
    
    
    
    1. 我们将通过简单地删除使用过的容器来完成此实验:
    $ docker container rm --force stress-limited
    stress-limited
    

现在我们可以进入下一个实验,在其中我们将学习如何限制如果不需要特权用户的情况下在进程中使用它们。

避免在容器内使用 root 用户

这个快速实验将向你展示如何在没有 root 权限的情况下运行 NGINX web 服务器。但首先,我们将回顾更改默认 NGINX 环境时会发生什么:

  1. 首先,让我们查看默认的 nginx:alpine 镜像使用的用户,方法是简单地启动一个新的 web 服务器:

    $ docker container run -d --publish 8080:80 \
    --name webserver nginx:alpine
    cbcd52a7ca480606c081edc63a59df5b6a237bb2891a4f4bb2ae68f9882fd0b3
    $ docker container ls
    CONTAINER ID   IMAGE          COMMAND                  CREATED         STATUS         PORTS                  NAMES
    8080:
    
    

    $ curl 0.0.0.0:8080 -I

    HTTP/1.1 200 OK

    ...

    
    
    1. 现在,让我们检索它的日志:
    $ docker logs webserver
    /docker-entrypoint.sh: /docker-entrypoint.d/ is not empty, will attempt to perform configuration
    ...
    2023/03/31 19:26:57 [notice] 1#1: start worker process 33
    --tail 2 (this will show only the last two lines of the container’s logs):
    
    

    $ docker logs webserver --details \

    --timestamps --tail 2

    2023-03-31T19:29:35.362006700Z 172.17.0.1 - - [31/Mar/2023:19:29:35 +0000] "GET / HTTP/1.1" 200 615 "-" "curl/7.81.0" "-"

    --timestamp 用于显示容器运行时包含的时间戳。当运行的应用程序未提供任何时间戳时,这非常有用。默认情况下,NGINX 会写入 /var/log/nginx/access.log/var/log/nginx/error.log。了解该容器镜像的开发者如何设置进程,将日志写入 /dev/stdout/dev/stderr,是非常有趣的。你可以在 github.com/nginxinc/docker-nginx/blob/73a5acae6945b75b433cafd0c9318e4378e72cbb/mainline/alpine-slim/Dockerfile 上了解更多。以下是目前重要的行摘录:

    # forward request and error logs to docker log collector
        && ln -sf /dev/stdout /var/log/nginx/access.log \
        && ln -sf /dev/stderr /var/log/nginx/error.log \
    
    
    
    1. 现在,让我们查看运行此实例的用户:
    $ docker exec -ti webserver id
    root should always be preferred, so let’s remove this container and create a new safer one (without root):
    
    

    $ docker container rm webserver --force -v

    0 用户(root)到普通 1000 ID:

    $ docker container run -d --publish 8080:80 \
    --name webserver  --user 1000 nginx:alpine
    root user:
    
    

    $ docker logs webserver

    ...

    nginx: [warn] "user" 指令仅在主进程以超级用户权限运行时才有意义,在 /etc/nginx/nginx.conf:2 中被忽略

    2023/04/01 11:36:03 [emerg] 1#1: mkdir() "/var/cache/nginx/client_temp" 失败 (13: 权限被拒绝)

    80 是因为它是系统限制的;如果必须在你的环境中使用此端口,则应添加像 NET_BIND_SERVICE 这样的特殊权限。我们不会更改当前镜像的行为,而是将使用 NGINX, Inc. 提供的新镜像:

    $ docker search nginxinc
    NAME                                         DESCRIPTION                                     STARS OFFICIAL   AUTOMATED
    nginxinc/nginx-unprivileged                  Unprivileged NGINX Dockerfiles                  90
    ...
    

    你可以在 hub.docker.com/r/nginxinc/nginx-unprivileged#! 找到此镜像及其信息。

    
    
    
    
    1. 让我们从 Docker Hub 拉取镜像并查看端口和使用的用户:
    $ docker image pull nginxinc/nginx-unprivileged:alpine-slim -q
    docker inspect to do so:
    
    

    $ docker image inspect \

    nginxinc/nginx-unprivileged:alpine-slim \

    --format="{{ .Config.ExposedPorts }} {{ .Config.User }}"

    8080 在我们主机的端口 8080 上。注意,我们使用了 --publish 选项,它允许我们以 IP:host_port:container_port 格式使用主机的特定 IP 地址:

    $ docker container run -d --publish 8080:8080 --name webserver nginxinc/nginx-unprivileged:alpine-slim
    369307cbd5e8b74330b220947ec41d4f263ebfe7727efddae3efbcc3a1610e5e
    $ docker container ps
    CONTAINER ID   IMAGE                                     COMMAND                  CREATED          STATUS          PORTS                    NAMES
    369307cbd5e8   nginxinc/nginx-unprivileged:alpine-slim   "/docker-entrypoint.…"   15 seconds ago   Up 13 seconds   0.0.0.0:8080->8080/tcp   webserver
    
    
    
    1. 让我们再次测试我们的 web 服务器并查看日志:
    $ curl 0.0.0.0:8080  -I
    HTTP/1.1 200 OK
    ...
     $ docker logs --tail 2 webserver
    2023/04/01 11:40:29 [notice] 1#1: start worker process 32
    172.17.0.1 - - [01/Apr/2023:11:41:36 +0000] "HEAD / HTTP/1.1" 200 0 "-" "curl/7.81.0" "-"
    
    1. 现在,让我们查看 web 服务器的用户:
    $ docker exec webserver id
    uid=101(nginx) gid=101(nginx) groups=101(nginx)
    

正如我们预期的那样,这个 webserver 应用程序是使用非特权用户运行的,比以 root 身份运行要更安全。作为开发者,你必须优先考虑在应用程序中使用非特权用户,以提高组件的安全性。

清理容器运行时

为了完成本章的实验,我们将通过组合多个命令,快速清理实验过程中创建的所有对象:

  1. 杀掉所有正在运行的容器(我们也可以通过一行命令将它们删除,但我们将在执行prune操作前先终止它们):

    $ docker ps -q|xargs docker kill
    6f883a19a8f1
    3e37afe57357
    -q argument is used to only show the containers’ IDs. Then, we piped the result using the xargs command to docker kill. This combination kills all the running containers.
    
    1. 现在,我们可以使用docker system prune命令删除所有已创建的对象。我们将使用--all删除所有未使用的镜像,并通过添加--volumes来删除卷(系统会要求确认):
    $ docker system prune --all --volumes
    WARNING! This will remove:
      - all stopped containers
      - all networks not used by at least one container
      - all volumes not used by at least one container
      - all images without at least one container associated to them
      - all build cache
     Are you sure you want to continue? [y/N] y
    ...
    docker system df:
    
    

    $ docker system df

    类型 总计 活跃 大小 可回收

    镜像 0 0 0B 0B

    容器 0 0 0B 0B

    本地卷 0 0 0B 0B

    构建缓存 0 0 0B 0B

    
    

在这些实验中,我们涵盖了本章中几乎所有的内容。你可以在为本章准备的 GitHub 仓库中找到更多信息:github.com/PacktPublishing/Docker-for-Developers-Handbook/tree/main/Chapter4

总结

在本章中,我们学习了如何运行容器并管理它们的行为。我们还回顾了如何通过应用不同的内核功能来限制对主机资源的访问。不同的技术允许我们在容器运行时与之交互,并能用它们获取关于容器内应用程序的重要信息。在本章结束时,我们还学习了一些简单的命令,帮助我们保持环境中没有旧的未使用容器对象。

现在我们知道如何创建容器镜像、存储镜像并使用它们运行容器后,我们可以进入下一章,学习如何使用多个相互交互的容器来运行应用程序。


第五章:5

创建多容器应用程序

本书将一步步引导你使用容器开发应用程序的路径。在之前的章节中,我们学习了如何创建容器镜像,如何共享它们,最后,如何在容器内运行应用程序进程。在本章中,我们将进一步讨论如何使用多个容器运行应用程序。这是你开发应用程序时可能会使用的方法,通过运行不同的相互连接的组件、共享信息,并只将前端进程暴露给用户。通过本章的学习,你将能够构建、交付并运行由多个容器组成的应用程序,并使用新学会的命令行一次性管理这些容器。

本章将涵盖以下主题:

  • 安装和使用 Docker Compose

  • 介绍 Docker Compose 文件语法

  • 构建和共享多容器应用程序

  • 运行和调试多容器应用程序

  • 使用 Docker Compose 管理多个环境

技术要求

我们将使用开源工具来构建、共享和运行由多个容器组成的应用程序。本章的实验将帮助你理解所展示的内容,实验文件可以在 github.com/PacktPublishing/Containers-for-Developers-Handbook/tree/main/Chapter5 上找到。本章的 Code In Action 视频可以在 packt.link/JdOIY 观看。

安装和使用 Docker Compose

Docker Compose 是 Docker Inc. 开发的一款工具,帮助开发人员在多个容器中运行的应用程序中创建、交付和运行多个组件。这个工具可能随 Docker 容器运行时分发版一起提供,或者需要单独安装。如果你使用诸如 Podman 的工具,也会提供等效的命令行工具。

Docker Compose 于 2014 年作为一个开源项目开发,旨在基于 YAML 定义管理多个容器。该命令行将直接与 Docker 容器运行时 API 进行交互。这意味着所有由 docker-compose 文件管理的容器将一起运行在同一个容器运行时之上,因此在同一主机上。理解这一点非常重要,因为如果需要为你的应用提供高可用性,你将需要使用第三方工具和配置。我们可以将 Docker Compose 看作是一个单节点容器编排器。

如果你正在使用 Docker Desktop,你会注意到 Docker Compose 已经为你提供了。它应该已经集成到你的 WSL 环境中,如果你在 Docker Desktop 中勾选了启用集成选项(该选项应该在你环境中的所有前几章实验和示例中已经勾选)。你可以通过快速访问 Docker Desktop 设置来验证这一点,路径为设置 | 资源 | WSL 集成

图 5.1 – Docker Desktop WSL 集成设置

图 5.1 – Docker Desktop WSL 集成设置

然后,你可以在 WSL 环境中打开终端,并简单地执行 which docker-compose

$ which docker-compose
/usr/bin/docker-compose

Docker Desktop 安装了一个现代的 Docker CLI 环境,这包括一个内置的 docker-compose 链接。你可以通过简单地检索相关信息来验证这一点,如下所示:

$ docker compose --help
Usage:  docker compose [OPTIONS] COMMAND
Docker Compose
Options:
...
Commands:
  build       Build or rebuild services
…
  version     Show the Docker Compose version information
Run 'docker compose COMMAND --help' for more information on a command.

因此,我们可以使用 docker-composedocker compose 来运行 compose 命令。

如果你直接在计算机上使用 Docker 容器运行时,并且客户端环境中没有这个内置链接,你需要正确安装 docker-compose 二进制文件。你可以使用以下任何一种方法:

  • 使用 Python 包管理器安装 docker-compose 模块(pip install docker-compose)。这将安装最新的基于 Python 的 docker-compose 版本(1.29.2):

    $ pip install docker-compose
    Defaulting to user installation because normal site-packages is not writeable
    Collecting docker-compose
      Downloading docker_compose-1.29.2-py2.py3-none-any.whl (114 kB) ——— 114.8/114.8 KB 3.9 MB/s eta 0:00:00
    ...
    Successfully installed attrs-22.2.0 bcrypt-4.0.1 certifi-2022.12.7 cffi-1.15.1 charset-normalizer-3.1.0 docker-6.0.1 docker-compose-1.29.2 dockerpty-0.4.1 docopt-0.6.2 idna-3.4 jsonschema-3.2.0 packaging-23.0 paramiko-3.1.0 pycparser-2.21 pynacl-1.5.0 pyrsistent-0.19.3 python-dotenv-0.21.1 requests-2.28.2 texttable-1.6.7 urllib3-1.26.15 websocket-client-0.59.0
    

    然而,这种方法将在新版本的 docker-compose 二进制文件使用 Go 语言构建后被淘汰。我们可以使用 --version 参数检查当前安装的版本:

    $ docker-compose --version
    docker-compose version 1.29.2, build unknown
    
  • docker-compose 包。我们将展示在 Ubuntu 22.04 上的安装步骤,该版本提供了所需的软件包及其依赖项:

    $ sudo apt-get install -qq docker-compose
    Preconfiguring packages ...
    Selecting previously unselected package pigz.
    …
    Setting up docker-compose (1.29.2-1) ...
    Processing triggers for dbus (1.12.20-2ubuntu4.1) ...
    Processing triggers for man-db (2.10.2-1) .
    

    如你所见,这种方法还会安装最新的基于 Python 的 docker-compose 版本:

    $ file /usr/bin/docker-compose
    /usr/bin/docker-compose: Python script, ASCII text executable
    

    Docker Compose v1 将在 2023 年 6 月被淘汰。我们应该至少使用 Docker Compose v2 和基于 Go 的适当命令行版本。该版本可以通过 Docker Desktop 自动安装,正如本节开头所提到的,或者通过将 Docker Compose 作为 Docker 客户端插件安装。

  • docker-compose-plugin。我们将以以下 Ubuntu 过程为例:

    $ sudo apt-get install docker-compose-plugin -qq
    Selecting previously unselected package docker-compose-plugin
    ...
    Unpacking docker-compose-plugin (2.17.2-1~ubuntu.22.04~jammy) ...
    Setting up docker-compose-plugin (2.17.2-1~ubuntu.22.04~jammy) ...
    $ docker compose version
    docker-compose version compatible with both Docker Compose v2 and v3.
    

重要提示

也可以直接通过从项目的 GitHub 仓库下载其二进制文件来安装 docker-compose。你可以使用以下链接查看进一步的安装说明:docs.docker.com/compose/install/linux/#install-the-plugin-manually

一旦我们按照任何一种方法安装了 docker-compose,就可以快速回顾可用的主要功能:

  • 我们可以构建多个镜像、代码块和 Dockerfile,它们可以分布在不同的文件夹中。这对于自动化一次性构建所有应用组件非常有用。

  • 使用 docker-compose 共享应用的容器镜像组件更加简便,因为所有镜像将一次性推送。

  • 我们可以使用 docker-compose 启动和停止基于多个容器的应用。所有组件默认情况下将同时运行,尽管我们可以定义组件间的依赖关系。

  • 所有应用的标准错误和输出将通过单一命令获取,这意味着我们可以一次性访问所有应用日志。这在调试多个组件之间的交互时非常有用。

  • 使用 docker-compose 来提供和淘汰环境非常简单,因为所有必需的应用组件都可以通过简单的操作如 docker compose createdocker compose rm 创建和删除。

  • docker-compose 非常适合轻松共享数据并隔离进程间通信。我们将只发布特定的应用进程,其他进程将保持内部运行,用户无法看到。

  • 我们将使用 <PROJECT>-<SERVICE_NAME> 语法。我们可以通过使用 docker-compose ls 命令来获取运行中的项目列表。此命令将展示所有正在运行的 docker-compose 项目及其 Compose YAML 文件定义。

  • 通过使用 docker-compose --profile prod up --detach 启动生产环境中的应用,而使用 --profile debug 将运行一些额外的组件/服务进行调试。我们将在 Compose YAML 文件中使用 profile 键来分组服务,服务可以被添加到多个配置文件中。我们将使用字符串来定义这些配置文件,并在后续的 docker-compose 命令行中使用它们。如果没有指定配置文件,docker-compose 将在不使用任何配置文件的情况下执行操作(没有配置文件的对象将会被使用)。

以下列表展示了 docker-compose 的主要操作:

  • config:此操作将检查并展示 Compose YAML 文件的回顾。它可以与 --services--volumes 参数结合使用,以仅获取这些对象。如前所述,--profile 可用于特别获取某一组或一类对象的信息。

  • images:此命令展示了我们 Compose YAML 文件中定义的镜像。如果你在想是否需要构建镜像或镜像是否已经存在于你的环境中,这将非常有用。

  • build:即使你计划将应用程序部署到像 Kubernetes 这样的容器编排集群中,此操作也使得docker-compose成为一个非常强大的工具,因为我们可以通过一个命令构建所有应用组件的容器镜像。使用docker-compose创建的镜像将包含项目名称,因此它们将被标识为<PROJECT_NAME>-<SERVICE_NAME>。每个组件目录中应该包含一个 Dockerfile,尽管我们可以通过直接指定镜像仓库来覆盖某些镜像的构建。记得我们在第三章中学到的镜像标签知识,发布 Docker 镜像。我们可以通过contextdockerfile键来修改构建上下文和 Dockerfile 文件名。如果 Dockerfile 包含多个目标,我们可以通过target键定义用于构建服务镜像的目标。还可以通过args键传递构建过程中的参数,以使用一系列键值对来修改环境。

  • pull/push:定义的镜像可以一次性下载,镜像创建后,构建定义也可以推送到远程注册表。

  • up:此操作相当于对我们在 Compose YAML 文件中定义的每个组件/服务执行docker run。默认情况下,docker compose up会同时启动所有容器,并且我们的终端将附加到所有容器的输出,这对于测试可能很有趣,但对于生产环境来说则不适用(我们的终端将被附加到进程中,我们必须使用Ctrl + P + Q来脱离它们)。为了避免这种情况,我们应使用-d--detach参数来将容器在后台启动。docker-compose还支持run操作,但通常用于一次运行特定服务。

  • down:此操作,顾名思义,执行与up相反的操作;它将停止并删除所有正在运行的容器。需要理解的是,如果之前使用此操作删除了容器,那么新的容器将被重新创建。任何持久化数据都必须存储在容器生命周期之外。要完全删除应用程序,请记得始终删除关联的卷。我们可以添加--volumes参数来强制删除任何关联的卷。

  • create/run/start/stop/rm:这些操作等同于我们在第四章中学到的操作,运行 Docker 容器,但在这种情况下,它们将应用于一次多个容器。

  • ps:由于我们为一个项目运行多个容器,因此此操作将列出所有关联的容器。容器的性能可以通过使用docker-compose top来查看,这是我们在第四章中学习的docker stats命令的扩展,运行 Docker 容器

  • exec:这个选项允许我们执行附加到某个容器(在这种情况下是项目的服务)上的命令。

  • logs:我们可以使用 docker-compose logs 来检索所有项目容器的日志。这对于通过单一视角和一个命令获取所有应用程序日志非常有用。容器输出将通过颜色进行区分,并且所有在第四章《运行 Docker 容器》中学到的过滤选项都会被应用,包括 --follow,它会持续跟踪所有日志。我们也可以通过将服务名称作为参数来仅检索某个服务的日志。

重要提示

虽然您通常会针对所有容器执行 docker-compose 操作,但也可以通过添加特定的服务名称来一次指定一个服务,docker-compose <ACTION> <SERVICE>。这个选项几乎适用于所有命令,并且在调试容器出现问题时非常有用。

既然我们已经知道如何安装 docker-compose 以及可以期待的功能,我们就可以学习如何使用它来创建应用程序了。

介绍 Docker Compose 文件语法

我们将使用带有 YAML 文件的 docker-compose,在该文件中我们将定义所有将一起运行并作为应用程序组件进行管理的服务、卷和网络。所使用的 YAML 文件应遵循 Compose 应用模型(更多信息请参见 github.com/compose-spec/compose-spec/blob/master/spec.md)。该模型将应用程序组件分布在 服务 中,并通过 网络 进行互相通信。这些网络为我们的应用程序容器提供了隔离和抽象层。服务将使用 来存储和共享它们的数据。

服务可能需要额外的配置,我们将使用 configsecret 资源来添加特定的信息以管理应用程序的行为。这些对象将被挂载到我们的容器内,容器中的进程将使用提供的配置。Secrets 将用于注入敏感数据,容器运行时将以不同的方式处理它们。

正如本章前面讨论的,Compose v1 很快将被弃用,您应该迁移到至少 Compose v2。您的文件可能需要进行一些更改。您可以通过查看 docs.docker.com/compose/compose-file/compose-versioning 来验证这一点。Compose 应用模型规范融合了 v2 和 v3 的对象定义。

现在,让我们深入了解 Docker Compose YAML 文件的定义关键字。

YAML 文件定义关键字

默认情况下,docker-compose 命令将在当前目录中查找 docker-compose.yamlcompose.yaml 文件(你可以使用 .yaml.yml 扩展名)。可以同时使用多个 Compose 文件,它们出现的顺序将决定最终使用的文件规范。值将被最新的文件覆盖。我们还可以使用在运行时可以扩展的变量,通过设置环境变量来实现。这将帮助我们使用一个包含变量的通用文件来支持多个环境。

Compose YAML 文件的基本结构如下所示:

services:
      service_name1:
            <SERVICE_SPECS>
...
      service_nameN:
            <SERVICE_SPECS>
volumes:
      volume_name1:
            <VOLUME_SPECS>
…
      volume_nameN:
            <VOLUME_SPECS>
networks:
      network_name1:
            <NETWORK_SPECS>
…
      network_nameN:
            <NETWORK_SPECS>

每个服务至少需要一个容器镜像定义或一个包含 Dockerfile 的目录。

让我们通过一个示例文件来回顾 Compose 语法:

version: "3.7"
services:
  # load balancer
  lb:
    build: simplestlb
    image: myregistry/simplest-lab:simplestlb
    environment:
      - APPLICATION_ALIAS=simplestapp
      - APPLICATION_PORT=3000
    networks:
      simplestlab:
          aliases:
          - simplestlb
    ports:
      - "8080:80"
  db:
    build: simplestdb
    image: myregistry/simplest-lab:simplestdb
    environment:
        - "POSTGRES_PASSWORD=changeme"
    networks:
       simplestlab:
        aliases:
          - simplestdb
    volumes:
      - pgdata:/var/lib/postgresql/data
  app:
    build: simplestapp
    image: myregistry/simplest-lab:simplestapp
    environment:
      - dbhost=simplestdb
      - dbname=demo
      - dbuser=demo
      - dbpasswd=d3m0
    networks:
       simplestlab:
        aliases:
          - simplestapp
    depends_on:
      - lb
      - db
volumes:
  pgdata:
networks:
  simplestlab:
    ipam:
      driver: default
      config:
        - subnet: 172.16.0.0/16

第一行用于标识使用的 Compose 语法版本。目前,version 键仅用于提供信息,添加此项是为了向后兼容。如果某些键在当前 Compose 版本中不被允许,我们将会收到警告,并且这些键将会被忽略。在撰写本书时,Compose YAML 文件不要求包含 version 键。

此 Compose YAML 文件包含三个服务定义:lbdbapp。它们都包含一个 image 键,定义了用于创建每个服务的镜像仓库。我们还有一个 build 键,定义了用于构建镜像的目录。拥有这两个键将允许我们在执行服务之前,先创建具有指定名称的镜像。正如你可能注意到的,我们为 app 服务定义了依赖关系。该服务依赖于 lbdb 服务,因此它们的容器必须在任何 app 容器启动之前处于运行并健康的状态。每个容器镜像中定义的健康检查将用于验证容器进程的健康状态。因此,作为开发者,你应该为应用组件定义适当的健康检查。

重要提示

尽管在这个例子中我们使用了 depends_on 键,但在我们的应用代码中,管理不同组件之间的依赖关系是非常重要的。这一点很重要,因为 depends_on 键仅在 Compose YAML 文件中有效。当你在 Docker Swarm 或 Kubernetes 中部署应用时,依赖关系不能以同样的方式进行管理。Compose 会为你管理依赖关系,但在编排环境中并没有这个功能,你的应用程序应该为此做好准备。例如,你可能需要在执行某些任务之前验证与数据库组件的连接,或者你可能需要在代码中处理因失去连接而导致的异常。你的应用组件可能需要多个组件,你应该决定在其中一个组件发生故障时你的应用该如何处理。如果关键的应用组件发生故障,应该停止代码执行,以避免整个应用的功能中断。

在这个例子中,我们还定义了一个卷 pgdata 和一个网络 simplestlabvolumesnetworks 部分允许我们定义容器使用的对象。每个定义的服务应包含应附加到该服务容器的卷和网络。与服务关联的容器将以服务名称命名,并加上项目作为前缀。每个容器被视为服务的一个实例,并将进行编号;因此,最终容器的名称将是 <PROJECT_NAME>-<SERVICE_NAME>-<INSTANCE_NUMBER>

我们可以为每个服务配置多个实例。这意味着可以为一个定义的服务运行多个容器。我们将使用 --scale SERVICE_NAME=<NUMBER_OF_REPLICAS> 来定义为特定服务运行的副本数量。

重要提示

如前所述,服务容器将使用动态名称,但我们可以使用 container_name 键来定义一个特定的名称。这对于从其他容器访问容器名称可能很有用,但此服务将无法扩展,因为如你所知,容器名称对于每个容器运行时都是唯一的,因此在这种情况下我们无法管理副本。

Compose YAML 文件允许我们覆盖容器镜像中定义的所有键。我们将在每个 services 定义块内包含它们。在示例中,我们为所有服务添加了一些环境变量:

…
services:
  lb:
    environment:
      - APPLICATION_ALIAS=simplestapp
      - APPLICATION_PORT=3000
…
  db:
    environment:
        - "POSTGRES_PASSWORD=changeme"
…
  app:
    environment:
      - dbhost=simplestdb
      - dbname=demo
      - dbuser=demo
      - dbpasswd=d3m0
…

正如你可能注意到的,这些环境变量定义了一些将改变应用组件行为的配置。这些配置中有些包含敏感数据,我们可以使用额外的 Compose 对象,如 secrets。非敏感数据可以使用 config 对象写入。

对于这些对象,将在根级别使用一个额外的键:

...services:
  app:
...
    configs:
     - source: appconfig
        target: /app/config
        uid: '103'
        gid: '103'
        mode: 0440
volumes:
…
networks:
...
configs:
  appconfig:
    file: ./appconfig.txt

在这个例子中,我们将所有 app 组件的环境变量更改为一个 config 对象,该对象将会被挂载到容器内。

重要提示

默认情况下,如果未使用 target 键,config 对象文件将会挂载到 /<source> 中。虽然有一种简短的方式可以将 config 对象文件挂载到服务容器内,但建议使用长格式,因为它允许我们指定 sourcetarget 的完整路径,以及文件的权限和所有权。

秘密对象仅在 swarm 模式下可用。这意味着即使你只使用一个节点,你也必须执行 docker swarm init 来初始化一个单节点的 Swarm 集群。这将允许我们创建秘密,秘密会作为集群对象由 Docker 容器引擎存储。Compose 可以管理这些对象并将它们展示在我们服务的容器内。默认情况下,秘密会被挂载到容器中的 /run/secrets/<SECRET_NAME> 路径,但我们可以在接下来的例子中看到,这个路径是可以更改的。

首先,我们通过 docker secret create 创建一个包含数据库密码的秘密,该密码用于 db 服务:

$ printf "mysecretdbpassword" | docker secret create postgres_pass -
dzr8bbh5jqgwhfidpnrq7m5qs

然后,我们可以修改我们的 Compose YAML 文件,加入这个新的 secret

…
  db:
    build: simplestdb
    image: myregistry/simplest-lab:simplestdb
    environment:
        - POSTGRES_PASSWORD_FILE: /run/secrets/postgres_pass
    secrets:
    - postgres_pass
…
secrets:
  postgres_pass:
     external: true

在这个示例中,我们通过标准输出创建了一个秘密,并使用 external: true 来声明该秘密已被设置,容器运行时必须使用其密钥存储来查找它。我们本来也可以使用文件作为来源。通常,也会通过以下格式将某些文件集成为容器内的 secrets

secrets:
  my_secret_name:
    file: <FULL_PATH_TO_SECRET_FILE>

这里的主要区别在于,你可能会使用一个普通文本文件作为秘密,该文件将由 Docker 容器运行时加密并挂载到容器内部。任何可以访问该普通文本文件的人都能读取你的秘密。使用标准输出提高了安全性,因为只有容器运行时可以访问 secret 对象。事实上,Docker Swarm 存储也可以被加密,增加了一层新的安全保障。

现在我们已经理解了基本的 Compose YAML 语法,我们可以继续学习如何使用这些文件来构建和共享我们的应用容器镜像。

构建和共享多容器应用

Docker Compose 允许你在单个节点上运行多容器应用程序。这些应用程序实际上并不会具备高可用性,因为你会有单点故障,而且你可能更倾向于使用 Kubernetes 或 Docker Swarm 来使用集群编排。然而,即便在这些情况下,docker-compose 仍然可以帮助你构建和管理项目的容器镜像。在本节中,我们将学习如何使用这些功能。

你的 Compose YAML 文件将包含一些服务定义,每个服务的容器都需要一个镜像定义或 build 目录。image 键将用于下载该镜像(如果它在你的容器运行时中尚不存在)或设置服务容器镜像的名称,如果存在 build 文件夹的话。如我们在前一节中提到的,项目名称将默认作为所有镜像的前缀,但有了这个 image 键则可以覆盖这一行为。项目前缀有助于你识别为项目准备的所有镜像,但当一个项目需要执行两次(两个不同的项目实例)时,可能会导致混淆。在这种情况下,将为两个项目分别准备并推送镜像,而不是使用默认的文件夹名称来构建镜像,会更为方便。

现在我们将专注于与 build 相关的键:

services:
  lb:
    build: simplestlb
    image: myregistry/simplest-lab:simplestlb
...
  db:
    build: simplestdb
    image: myregistry/simplest-lab:simplestdb
...
  app:
    build: simplestapp
    image: myregistry/simplest-lab:simplestapp

如我们所述,image 键定义了要下载的镜像,但在这种情况下,build 键也存在,并且带有一个文件夹字符串,这意味着这个文件夹将用于构建镜像:

$ docker-compose --project-name test build \
--progress quit --quiet
$ docker image ls
REPOSITORY                TAG           IMAGE ID       CREATED      SIZE
myregistry/simplest-lab   simplestapp   26a95450819f   3 days ago   73.7MB
myregistry/simplest-lab   simplestdb    7d43a735f2aa   3 days ago   243MB
myregistry/simplest-lab   simplestlb    3431155dcfd0   3 days ago   8.51MB

正如你所注意到的,项目名称被包含在内,以避免使用默认的目录名作为前缀,但创建的镜像使用了定义的仓库和标签字符串。

让我们删除 image 键行并重新启动 build 过程:

$ docker-compose --project-name test build \
--progress quit --quiet
$ docker image ls
REPOSITORY                TAG           IMAGE ID       CREATED      SIZE
test-app                  latest        b1179d0492be   3 days ago   73.7MB
myregistry/simplest-lab   simplestapp   b1179d0492be   3 days ago   73.7MB
test-db                   latest        8afd263a1e89   3 days ago   243MB
myregistry/simplest-lab   simplestdb    8afd263a1e89   3 days ago   243MB
test-lb                   latest        4ac39ad7cefd   3 days ago   8.51MB
latest was used by default. This is what we expect in such situations, but we could have used any of the following keys to modify the build process:

*   `context`: This key must be included inside the `build` key to identify the context used for each image. All the files included in this `context` directory will be passed to the container runtime for analysis. Take care to remove any unnecessary files in this path.
*   `dockerfile`: By default, the container runtime will use any existing Dockerfile in your `build` folder, but we can use this key to change this filename and use our own.
*   `dockerfile_inline`: This key may be very interesting, as it allows us to use `inline` definitions, as we already learned in *Chapter 2*, *Building Docker Images*. These quick definitions don’t permit any `COPY` or `ADD` keys.
*   `args`: This key is equivalent to `--build-arg` and it allows us to add any required arguments to our `build` process. Remember that you should include appropriate `ARG` keys in your Dockerfile.
*   `labels`: We can include labels in our Dockerfile, and we can also add new ones or overwrite those already defined by using the `labels` key. We will include a list with these labels in key-value format.
*   `targets`: This key will identify which targets should be compiled in the `build` process. This may be of interest when you want to separate the base image and some additional debug ones from the production-ready final container images.
*   `tags`: We can add more than one tag at a time. This may be pretty interesting for defining a `build` process that creates a container image for different registries. By using this key, you will be able to push to all registries at the same time (you will need to be already logged in or you will be asked for your username and password).
*   `platforms`: Remember that we learned that `buildx` allowed us to prepare images for different container runtimes architectures (we learned how to create images for ARM64, AMD64, and so on in *Chapter 2*, *Building Docker Images*). In our Compose file, we can write which architectures must always be included in the `build` process. This is very interesting for automating your software supply chain.
*   `secrets`: Sometimes, we need to include a token, an authentication file with a username and password, or a certificate for accessing some SSL-protected site during the `build` process. In such situations, we can use a secret to introduce this information only at such a stage. You should always avoid adding sensitive information to your container images. Secrets will only be accessible to the containers created for building the image; thus they will not be present in the final image. We will need to define a `secrets` object in our Compose file, but this time, it will be used in the `build` process instead of the container runtime. Here is an example of adding a certificate required to access a server:

    ```

    服务:

    前端:

    构建:

    秘密:

    - server-certificate

    秘密:

    服务器证书:

    文件: ./server.cert

    ```

    We can use the long syntax, which is always recommended because it allows us to set the destination path for the included file (by default, the secrets will be present in `/run/secrets`) and its permissions and ownership:

    ```

    服务:

    前端:

    构建:

    秘密:

    - 来源: server-certificate

    目标:server.cert

    uid: "103"

    gid: "103"

    模式: 0440

    秘密:

    服务器证书:

    文件: ./server.cert

    ```

There are some keys that may be interesting to you if you plan on modifying the default caching behavior during the `build` processes. You can find additional information at [`docs.docker.com/compose/compose-file/build/`](https://docs.docker.com/compose/compose-file/build/).
You can push images to the registries to share them using `docker-compose` if you included a registry in the `image` key definition; otherwise, images will be local.
By default, all images will be pushed when you execute `docker-compose push`, but as with any other Compose action, you may need to pass a service as an argument. In this case, it is useful to use the `--include-deps` argument to push all the images of the services defined in the `depends_on` key. This will ensure that your service will not miss any required images when it is executed:

$ docker-compose --project-name test push --include-deps app

[+] 运行中 0/26bc077c4d137 层已存在   3.6 秒

⠇ 正在推送 lb: 0bc077c4d137 层已存在    3.8 秒

⠧ 正在推送 db: a65fdf68ac5a 层已存在    3.7 秒

⠧ 正在推送 app: 7dfc1aa4c504 层已存在   3.7 秒


 Notice, in this example, that even though we have just pushed the `app` service image, `lb` and `db` are also pushed to our registry because they were declared as dependencies.
In this section, we learned how to use `docker-compose` for building and sharing our application’s components. In the next section, we will run containers defined in our Compose YAML files and review their logs and status.
Running and debugging multi-container applications
Applications executed using Docker Compose will be orchestrated but without high availability. This doesn’t mean you can’t use it in production, but you may need additional applications or infrastructure to keep your components always on.
Docker Compose provides an easy way of running applications using a *single point of management*. You may have more than one YAML file for defining your application’s components, but the `docker-compose` command will merge them into a single definition. We will simply use `docker-compose up` to launch our complete application, although we can manage each component separately by simply adding its service’s name. `docker-compose` will refresh the components’ status and will just recreate those that have stopped or are non-existent. By default, it will attach our terminal to all application containers, which may be very useful for debugging but it isn’t useful for publishing our services. We will use `-d` or `--detach` to launch all container processes in the background.
The `docker-compose up` execution will first verify whether all the required images are present on your system. If they aren’t found, the container runtime will receive the order of creating them if a `build` key is found. If this key isn’t present, images will be downloaded from the registry defined in your `image` key. This process will be followed every time you execute `docker-compose up`. If you are changing some code within your application’s component folders, you will need to recreate them by changing your compose YAML `image` tags or the image’s digest.
Important note
You can use `docker-compose up --d --build` to specifically ask your container runtime to rebuild all the images (or part of them if you specified a service). As you may expect, the runtime will check each image layer (`RUN` and `COPY`/`ADD` keys) and rebuild only those that have changed. This will avoid the use of an intermediate `docker-compose build` process. Remember to maintain your container runtime disk space by pruning old unnecessary images.
As we already mentioned in this section, containers will always be created if they don’t exist when we execute `docker-compose up`. But in some cases, you will need to execute a fresh start of all containers (maybe some non-resolved dependencies in your code may need to force some order or reconnection). In such situations, we can use the `--force-recreate` argument to enforce the recreation of your services’ containers.
We will use the `entrypoint` and `command` keys to *overwrite* the ones defined in the images used for creating each service container and we will specifically define which services will be available for the users by publishing them. All other services will use the internally defined network for their communications.
As you may expect, everything we learned about `<PROJECT_NAME>_<SERVICE_NAME>_<INSTANCE>`); we will instead use the service’s name to locate a defined service. For example, we will use `db` in our `app` component connection string. Take care because your instance name will also be available but shouldn’t be used. This will really break the portability and dynamism of your applications if you move them to clustered environments where instances’ names may not be usable or if you use more than one replica for some services.
Important note
We can manage the number of container replicas for a service by using the `replicas` key. These replicas will run in isolation, and you will not need a load balancer service to redirect the service requests to each instance. Consider the following example:
`services:`
`app:`
`…`
`deploy:`
`replicas: 2`
`…`
In such a situation, two containers of our `app` service will be launched. Docker Swarm and Kubernetes will provide TCP load balancer capabilities. If you need to apply your own balancer rules (such as specific weights), you need to add your own load balancer service. Your container runtime will just manage OSI layer 3 communications ([`en.wikipedia.org/wiki/OSI_model`](https://en.wikipedia.org/wiki/OSI_model)).
Multi-container applications defined in a Compose file will run after we execute `docker-compose up --detach`, and to review their *state*, we will use `docker-compose ps`. Remember to add your project in all your commands if you need to overwrite the default project’s name (current folder). We can use common `--filter` and `--format` arguments to filter and modify the output of this command. If some of the service’s containers are missing, maybe they didn’t start correctly; by default, `docker-compose ps` will only show the running containers. To review all the containers associated with our project, we will use the `--all` argument, which will show the running and stopped containers.
If any issues are found in our project’s containers, we will see them as exited in the `docker-compose ps` command’s output. We will use `docker-compose logs` to review all container logs at once, or we can choose to review only the specific service in error by adding the name of the service to this command.
We can use the `--file` (or `-f`) argument to define the complete path to our Compose YAML file. For this to work, it is very useful to first list all the Compose applications running in our system by using `docker-compose ls`. The full path to each application’s Compose YAML file will be shown along with its project’s name, as in this example:

$ docker-compose ls

名称                状态              配置文件

测试                运行中(3)          /home/frjaraur/labs/simplest-lab/docker-compose.yaml


 In this case, we can add the path to the Compose file to any `docker-compose` action:

$ docker-compose --project-name test \

--file /home/frjaraur/labs/simplest-lab/docker-compose.yaml ps

名称                镜像                                         命令                  服务             创建时间             状态              端口

test-app-1          docker.io/frjaraur/simplest-lab:simplestapp   "node simplestapp.js…"   app                 25 小时前        运行中 28 分钟       3000/tcp

test-db-1           docker.io/frjaraur/simplest-lab:simplestdb    "docker-entrypoint.s…"   db                  25 小时前        运行中 24 分钟       5432/tcp

test-lb-1           docker.io/frjaraur/simplest-lab:simplestlb    "/entrypoint.sh /bin…"   lb                  25 小时前        运行中 24 分钟       0.0.0.0:8080->80/tcp


 This will work even with `build` actions, as the Compose YAML file location will be used as a reference for all commands. The `context` key may be included to modify its behavior.
We can review the port exposed for the application in the `docker-compose ps` output. To review our application’s logs, we can use `docker-compose logs`, and each service will be represented in a different random color. This is very useful for following the different entries in each service. We can specify a single service by passing its name as an argument.
The following screenshot shows the output of `docker-compose logs` using `--tail 5` to only retrieve the latest five lines:
![Figure 5.2 – Service container logs retrieved by using docker-compose logs](https://github.com/OpenDocCN/freelearn-devops-pt8-zh/raw/master/docs/cntn-dev-hb/img/B19845_05_02.jpg)

Figure 5.2 – Service container logs retrieved by using docker-compose logs
Notice that in this simple test, we only have two services, and colors are applied to each one. We retrieved only the latest five lines of each container by adding `--tail 5`. This argument applies to all containers (we didn’t get the latest five lines of all logs merged). It is also important to mention that service names must be used as arguments when we need to use an action in a specific service. We will never use the container names; hence, we need to include the appropriate project name.
We can use the same approach to access a container’s namespace by using the `exec` action. Remember that we learned in *Chapter 4*, *Running Docker Containers*, that we can execute a new process inside our container (it will share all the container’s process kernel namespaces). By using `docker-compose exec <SERVICE_NAME>`, we can execute a new process inside any of our service’s containers:

$ docker-compose --project-name test exec db ps -ef

PID   用户     时间  命令

1 postgres  0:00 postgres

53 postgres  0:00 postgres: 检查点

54 postgres  0:00 postgres: 背景写入器

55 postgres  0:00 postgres: walwriter

56 postgres  0:00 postgres: autovacuum 启动器

57 postgres  0:00 postgres: stats collector

58 postgres  0:00 postgres: logical replication 启动器

90 root      0:00 ps -ef


 In summary, we will be able to run the same actions for containers by using `docker-compose`.
For you as a developer, Docker Compose can really help you develop applications faster. You will be able to run all application containers at once. In the development stage, you can include your code in specific containers by mounting a volume, and you can verify how your changes affect other components. For example, you can mount the code of one application component and change it while other components are running. Of course, you can do this without the `docker-compose` command line, but you will need to automate your deployments with scripts and verify the containers’ state. Docker Compose orchestrates this for you, and you can focus on changing your code. If you work in a team and all other developers provide container images and you share some application information, you can run these images locally while you are still working on your component.
Now that we know how to run and interact with multi-container applications, we will end this chapter by learning how to use environment variables to deploy your applications under different circumstances.
Managing multiple environments with Docker Compose
In this section, we will learn how to prepare our Compose YAML files as templates for running our applications in different environments and under different circumstances, such as developing or debugging.
If you are familiar with the use of environment variables in different operating systems, this section will seem pretty easy. We already learned how to use variables to modify the default behavior of Dockerfiles (*Chapter 2*, *Building Docker Images*) and containers at runtime (*Chapter 4*, *Running Docker Containers*). We used variables to overwrite the default values defined and modify the `build` process or the execution of container image processes. We will use the same approach with Compose YAML files. We will now review some of the different options we have to use variables with the `docker-compose` command line.
We can define a `.env` file with all the variables we are going to use in a Compose YAML file defined as a template. Docker Compose will search for this file in our project’s root folder by default, but we can use `--env-file <FULL_FILE_PATH>` or the `env_file` key in our Compose YAML file. In this case, the key must be set for each service using the environment file:

env_file:

  • ./debug.env

 The environment file will overwrite the values defined in our images. Multiple environment files can be included; thus, the order is critical. The lower ones in your list will overwrite the previous values, but this also happens when we use more than one Compose YAML file. The order of the arguments passed will modify the final behavior of the execution.
You, as a developer, must prepare your Compose YAML files with variables to modify the execution passed to your container runtime.  The following example shows how we can implement some variables to deploy applications in different environments:

服务:

lb:

构建:

上下文: ./simplestlb

参数:

alpine 版本: "1.14"

dockerfile: Dockerfile.${environment}

标签:

org.codegazers.description: "测试镜像"

镜像: ${dockerhubid}/simplest-lab:simplestlb

环境:

  • 应用别名=simplestapp

  • 应用端口=$

网络:

simplestlab:

别名:

  • simplestlb

端口:

  • "${loadbalancer_port}:80"

 In this example, we can complete our variables with the following `.``env` file:

环境=dev

dockerhubid=frjaraur

loadbalancer 端口=8080

后端端口=3000


 This environment file will help us define a base build and deployment. Different Dockerfiles will be included – `Dockerfile.dev` and `Dockerfile.prod`, for example.
We can then verify the actual configuration applied using `docker-compose`:

$ docker-compose --project-name test \

--file myapp-docker-compose.yaml 配置

名称: test

服务:

lb:

...

上下文: /home/frjaraur/tests/dcadeg/chapter5/simplest-lab/simplestlb

dockerfile: Dockerfile.dev

参数:

alpine 版本: "1.14"

标签:

org.codegazers.description: 测试镜像

环境:

应用别名:simplestapp

应用端口: "3000"

镜像: frjaraur/simplest-lab:simplestlb

...

端口:

  • 模式: ingress

目标: 80

已发布: "8080"

协议:tcp

网络:

...


 All the values have already been assigned using the `.env` file, but these can be overridden manually:

$ dockerhubid=myid \

docker-compose --project-name test \

--file myapp-docker-compose.yaml 配置

镜像:myid/simplest-lab:simplestlb


 Remember that profiles and targets can also be used to prepare specific images and then run the services completely customized.
We can now review some labs that will help us better understand some of the content of this chapter.
Labs
The following labs will help you deploy a simple demo application by using some of the commands learned in this chapter. The code for the labs is available in this book’s GitHub repository at [`github.com/PacktPublishing/Containers-for-Developers-Handbook.git`](https://github.com/PacktPublishing/Containers-for-Developers-Handbook.git). Ensur[e that you have the latest revision available by simply executing `git`](https://github.com/PacktPublishing/Docker-for-Developers-Handbook.git) `clone https://github.com/PacktPublishing/Docker-for-Developers-Handbook.git` to download all its content or `git pull` if you have already downloaded the repository before. All commands and content used in these labs will be located inside the `Docker-for-Developers-Handbook/Chapter5` directory.
In this chapter, we learned how to deploy a complete application using `docker-compose`. Let’s put this into practice by deploying a sample application.
Deploying a simple demo application
In this lab, we will learn how to deploy an application with three components: a load balancer, a frontend, and a database.
There are hundreds of good Docker Compose examples and, in fact, there are many vendors who provide their applications packaged in the Compose YAML format, even for production. We chose this pretty simple application because we are focusing on the Docker command line and not on the application itself.
If you list the content of the `Chapter5` folder, you will see a folder named `simplestapp`. There is a subfolder for each component and a Compose file that will allow us to deploy the full application.
The Compose YAML file that defines our application contains the following code:

版本:"3.7"

服务:

lb:

构建:simplestlb

镜像:myregistry/simplest-lab:simplestlb

环境:

  • APPLICATION_ALIAS=simplestapp

  • APPLICATION_PORT=3000

网络:

simplestlab:

别名:

  • simplestlb

端口:

  • "8080:80"

db:

构建:simplestdb

镜像:myregistry/simplest-lab:simplestdb

环境:

  • "POSTGRES_PASSWORD=changeme"

网络:

simplestlab:

别名:

  • simplestdb

卷:

  • pgdata:/var/lib/postgresql/data

应用:

构建:simplestapp

镜像:myregistry/simplest-lab:simplestapp

环境:

  • dbhost=simplestdb

  • dbname=demo

  • dbuser=demo

  • dbpasswd=d3m0

网络:

simplestlab:

别名:

  • simplestapp

依赖于:

  • lb

  • db

卷:

pgdata:

网络:

simplestlab:

ipam:

驱动:默认

配置:

  • 子网:172.16.0.0/16

 This application is a very simplified demo for showing how various components could be deployed. Never use environment variables for your sensitive data. We already learned how to use `configs` and `secrets` objects in this chapter. It is good to also notice that we didn’t use a non-root user for the database and load balancer components. You, as a developer, should always try to keep security at the maximum on your application components. It is also important to notice the lack of health checks at the Dockerfile and Compose levels. We will learn more about application health checks in Kubernetes later in this book because it may not always be a good idea to include some **Transmission Control Protocol** (**TCP**) check tools in your images. In *Chapter 8*, *Deploying Applications with the Kubernetes Orchestrator*, we will learn how this orchestration platform provides internal mechanisms for such tasks and how we can enforce better security options.
In this lab, only one volume will be used for the database component, and the only service published is the load balancer. We included this service just to let you understand how we can integrate a multilayer application and only share one visible component. All images will be created locally (you may want to upload to your own registry or Docker Hub account). Follow the next steps to deploy the `simplestapp` application described in the `compose` file:

1.  To build all the images for this project, we will use `docker-compose build`:

    ```

    $ docker-compose --file \

    simplestlab/docker-compose.yaml \

    --project-name chapter5 build

    [+] 正在构建 0.0s (0/0)

    …

    => => 写入镜像 sha256:2d88460e20ca557fcd25907b5f026926b0e61d93fde58a8e0b854cfa0864c3bd 0.0s

    docker-compose run 来构建或拉取镜像并运行所有容器,但通过这种方式,我们可以逐步回顾过程。

    ```

Important note
If you reset your Docker Desktop before starting the labs, you may find some errors regarding an old Docker container runtime integration on your WSL environment:
`$ docker-compose --file simplestlab/docker-compose.yaml --project-name` `chapter5 build`
`docker endpoint for "default"` `not found`
The solution is very easy: simply remove your old Docker integration by removing your `.docker` directory, located in your home directory: `$ rm -``rf ~/.docker`.

1.  We can take a look at the images created locally:

    ```

    $ docker image ls

    仓库                       标签          镜像 ID          创建时间          大小

    myregistry/simplest-lab simplestapp 2d88460e20ca 8 分钟前 73.5MB

    myregistry/simplest-lab simplestdb e872ee4e9593 8 分钟前 243MB

    构建键存在时,执行构建过程,而不是直接拉取镜像。

    ```

     2.  Let’s now create the container for the database service:

    ```

    $ docker-compose --file \

    simplestlab/docker-compose.yaml \

    --project-name chapter5 创建 db

    [+] 正在运行 3/3

    :: 网络 chapter5_simplestlab 创建中 0.8s

    :: 卷 "chapter5_pgdata" 创建中 0.0s

    :: 容器 chapter5-db-1 创建中 0.2s

    ```

    All the objects required for the database service are created. It is not running yet, but it is ready for that.

     3.  We run this service alone and review its status:

    ```

    $ docker-compose --file \

    simplestlab/docker-compose.yaml \

    --project-name chapter5 up -d db

    [+] 正在运行 1/1

    :: 容器 chapter5-db-1 启动中

    ```

    If you omit the Compose filename and the project’s name, we will get neither the services nor the containers:

    ```

    $ docker-compose ps

    没有提供配置文件:未找到

    $ docker-compose --file simplestlab/docker-compose.yaml ps

    名称                   镜像                        命令                     服务              创建时间           状态              端口

    ```

    Always ensure you use the appropriate name and Compose file for all the commands related to a project:

    ```

    $ docker-compose --file \

    simplestlab/docker-compose.yaml \

    --project-name chapter5 ps

    名称                   镜像                         命令                     服务              创建时间           状态              端口

    通过使用 docker-compose up -d 启动 lb 和 app 服务:

    ```
    $ docker-compose --file \
    simplestlab/docker-compose.yaml \
    --project-name chapter5 up -d
    [+] Running 3/3
     :: Container chapter5-lb-1   Started                                        2.0s
     :: Container chapter5-db-1   Running                                  0.0s
    Created to Started.
    ```

    ```

     4.  We can now review the status of all our application components and the ports exposed:

    ```

    $ docker-compose --file \

    simplestlab/docker-compose.yaml \

    --project-name chapter5 ps

    名称                   镜像                        命令                     服务              创建时间           状态              端口

    chapter5-app-1 myregistry/simplest-lab:simplestapp "node simplestapp.js…" app 9 分钟前 正在运行 9 分钟 3000/tcp

    chapter5-db-1 myregistry/simplest-lab:simplestdb "docker-entrypoint.s…" db 16 分钟前 启动 15 分钟前 5432/tcp

    simplestlab 应用可以通过浏览器访问 http://127.0.0.1:8080:

    ```

![Figure 5.3 – The simplestlab application](https://github.com/OpenDocCN/freelearn-devops-pt8-zh/raw/master/docs/cntn-dev-hb/img/B19845_05_03.jpg)

Figure 5.3 – The simplestlab application
This allows us to graphically review how requests are distributed when multiple backends are available.

1.  We can scale our `app` component in this example. This option may be complicated or impossible in other deployments, as it really depends on your own application code and logic. For example, you should scale a database component without appropriate database internal scale logic (you should review the database server vendor’s documentation):

    ```

    $ docker-compose --file \

    simplestlab/docker-compose.yaml \

    --project-name chapter5 up --scale app=2 -d

    [+] 正在运行 4/4

    :: 容器 chapter5-db-1 运行中 0.0s

    :: 容器 chapter5-lb-1 运行中 0.0s

    :: 容器 chapter5-app-2 已创建 0.2s

    应用服务日志。我们将获取两个容器的日志:

    ```
    $ docker-compose --file \
    simplestlab/docker-compose.yaml \
    --project-name chapter5 logs app
    chapter5-app-1  | dbuser: demo dbpasswd: d3m0
    …
    chapter5-app-1  | dbuser: demo dbpasswd: d3m0
    …
    chapter5-app-2  | Can use environment variables to avoid '/APP/dbconfig.js' file configurations.
    ```

    ```

Finally, your application is up and running, and we can move on to the next lab, in which we will use the same Compose file to deploy a second project with the same application.
Deploying another project using the same Compose YAML file
In this simple example, we will review and discuss the problems we may encounter by running two projects using the same Compose YAML file. To do this, we will follow these instructions:

1.  Let’s create a new project by using a new project name:

    ```

    $ docker-compose --file \

    simplestlab/docker-compose.yaml \

    --project-name newdemo create

    [+] 正在运行 0/0

    :: 网络 newdemo_simplestlab 错误 0.0s

    docker-compose 定义:

    ```
    networks:
      simplestlab:
        ipam:
          driver: default
    ```

    现在我们再次尝试部署应用:

    ```
    $ docker-compose --file \
    simplestlab/docker-compose.yaml \
    --project-name newdemo create
    [+] Running 5/5
     :: Network newdemo_simplestlab  Created                           0.9s
     :: Volume "newdemo_pgdata"      Created                         0.0s
     :: Container newdemo-db-1       Created                         0.2s
     :: Container newdemo-lb-1       Created                    0.2s
    volume and network objects were created with the project prefix. We will not be able to reuse the project name because object names must be unique.
    ```

    ```

     2.  Let’s run all the application components now:

    ```

    $ docker-compose --file simplestlab/docker-compose.yaml --project-name newdemo start

    [+] 正在运行 1/2

    :: 容器 newdemo-db-1 已启动 1.4s

    :: 容器 newdemo-lb-1 启动中 1.4s

    lb 服务。这个在生产环境中看起来是可以的,但是在开发环境中定义特定端口会破坏容器化组件的动态性,因为在开发中可能需要运行多个应用副本。为了让其正常工作,我们可以简单地更改端口号,让系统为我们选择一个随机端口,或者定义一个变量来为每个项目指定端口。

    ```

     3.  We change our Compose YAML and add the `LB_PORT` variable as the port for exposing our application:

    ```

    services:

    lb:

    …

    ports:

    - "${LB_PORT}:80"

    ```

    Then, we test it again:

    ```

    $ LB_PORT=8081 docker-compose --file \

    simplestlab/docker-compose.yaml \

    --project-name newdemo up lb

    [+] 正在运行 1/1

    :: 容器 newdemo-lb-1 已重新创建

    ```

    Let’s review the component status:

    ```

    $ docker-compose --file simplestlab/docker-compose.yaml --project-name newdemo ps

    WARN[0000] "LB_PORT" 变量未设置,默认值为空字符串。

    NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS

    newdemo-db-1 myregistry/simplest-lab:simplestdb "docker-entrypoint.s…" db 11 分钟前 启动 8 分钟前 5432/tcp

    newdemo-lb-1 myregistry/simplest-lab:simplestlb "/entrypoint.sh /bin…" lb 46 秒前 启动 34 秒前 0.0.0.0:8081->80/tcp

    $ docker-compose --file simplestlab/docker-compose.yaml --project-name newdemo up app -d

    [+] 正在运行 3/3

    :: 容器 newdemo-db-1 运行中 0.0s

    :: 容器 newdemo-lb-1 运行中 0.0s

    docker-compose.yaml 文件中,我们能够使用一个独特的 Compose YAML 文件部署第二个项目。我们可以列出主机上部署的项目及其组件数量:

    ```
    $ docker-compose ls
    NAME                STATUS              CONFIG FILES
    chapter5            running(4)          /home/frjaraur/labs/Chapter5/simplestlab/docker-compose.yaml
    newdemo             running(3)          /home/frjaraur/labs/Chapter5/simplestlab/docker-compose.yaml
    ```

    ```

With this simple example of the usual problems you may find while preparing your applications, we will end this chapter’s labs by removing all the objects created.
Removing all projects
To remove all the created projects, we will perform the following steps:

1.  We will remove all the deployed projects by using `docker-compose down`:

    ```

    $ docker-compose -f /home/frjaraur/labs/Chapter5/simplestlab/docker-compose.yaml --project-name chapter5 down

    WARN[0000] "LB_PORT" 变量未设置,默认值为空字符串。

    [+] 正在运行 5/5

    :: 容器 chapter5-app-2 已删除 0.1s

    :: 容器 chapter5-app-1      已移除                          0.1s

    :: 容器 chapter5-db-1       已移除                          0.1s

    :: 容器 chapter5-lb-1       已移除                          0.1s

    :: 网络 chapter5_simplestlab  已移除                          0.6s

    ```

    You may notice that volumes are not listed as removed. We can review the current volumes on your system:

    ```

    $ docker volume ls

    DRIVER    VOLUME NAME

    local     chapter5_pgdata

    chapter5 项目,但我们将使用 --volumes 参数来移除与项目相关的所有卷:

    ```
    $ docker-compose -f  /home/frjaraur/labs/Chapter5/simplestlab/docker-compose.yaml --project-name newdemo down --volumes
    WARN[0000] The "LB_PORT" variable is not set. Defaulting to a blank string.
    [+] Running 5/5
    :: Container newdemo-app-1      Removed                                  0.0s
    :: Container newdemo-lb-1       Removed                              0.1s
    :: Container newdemo-db-1       Removed                                0.1s
    :: Volume newdemo_pgdata        Removed                                       0.1s
    newdemo project was removed, as we can verify now, but the volume from the Chapter5 project is still present:

    ```

    $ docker volume ls

    DRIVER    VOLUME NAME

    local     chapter5_pgdata

    ```

    ```

    ```

     2.  We remove the remaining volume manually:

    ```

    $ docker volume rm  chapter5_pgdata

    chapter5_pgdata

    ```

Additional labs are included in this book’s GitHub repository.
Summary
In this chapter, we covered the basic usage of Docker Compose and discussed how it will help you develop and run your multi-component applications locally. You will be able to run and review the status of all your application’s components using a single command line. We learned about the syntax of Compose YAML files and how to prepare template-like files for developing your applications using different customizations. You will probably run your applications in production using a clustered orchestrated environment such as Docker Swarm or Kubernetes, but `docker-compose` does also provide a basic orchestration for running your multi-container applications in production using a single server. Understanding the basic orchestration and networking features of Docker Compose will help us introduce more sophisticated orchestration methods, which we will learn about in the next chapters.
In the next chapter, we will briefly review orchestration platforms that will help us run our applications cluster-wide.

第二部分:容器编排

在本书的这一部分中,我们将讲解如何在集群范围的环境中进行容器的编排。我们将学习如何在集群中的不同主机上部署分布式组件应用程序,允许用户与组件进行交互并发布服务。

本部分包含以下章节:

  • 第六章编排基础

  • 第七章与群体协作

  • 第八章使用 Kubernetes 编排器部署应用程序

第六章:6

容器编排的基本原理

到目前为止,我们已经了解了什么是软件容器,它们如何工作,以及如何创建它们。我们专注于作为开发人员使用它们,创建我们的应用程序并将功能分布到在容器中运行的不同组件中。本章将为你介绍一个全新的视角。我们将学习如何在生产环境中使用容器运行我们的应用程序。我们还将介绍容器编排器的概念,探讨它们能提供什么,并讨论我们需要在应用程序中加入哪些关键改进,以便在分布式集群中以集群范围的方式运行它们。

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

  • 介绍编排的关键概念

  • 理解无状态和有状态应用程序

  • 探索容器编排器

接下来,我们将研究如何在第七章《使用 Swarm 进行编排》和第八章《使用 Kubernetes 编排器部署应用程序》中利用 Docker Swarm 和 Kubernetes 编排器的功能。本章不包括任何实验,因为它旨在教授 Docker Swarm 和 Kubernetes 背后的理论。

介绍编排的关键概念

在主机上运行应用程序可能会很复杂,但在由多个主机组成的分布式环境中执行同一应用程序则会非常繁琐。在本节中,我们将回顾一些关于应用组件编排的关键概念,无论它们是通过容器运行,还是作为不同的虚拟机运行。

编排器是帮助我们管理应用程序组件之间不同交互和依赖关系的特殊软件组件。正如你能想象的那样,如果你将应用程序拆分为多个不同的功能模块,每个模块都有自己的实体,那么将它们编排在一起是至关重要的。我们必须在此提到,某些特殊功能,例如依赖管理,可能在你的编排器中不可用,因此你需要自己进行管理。这就引出了一个重要问题:为了准备我们的应用程序,我们需要了解编排器的哪些知识?编排器保持我们的进程运行,管理应用程序组件之间的通信,并连接这些进程所需的存储。

特别针对基于容器的应用程序,很容易理解容器运行时将成为编排基础设施的一部分,因为它们是运行容器所必需的。与容器工作时最大的问题是与其网络功能相关的内在动态性。这在虚拟机环境中可能不是问题,但容器通常在每次执行时使用不同的 IP 地址(尽管我们可以手动为容器分配 IP 地址,但这并不是一个好习惯)。请注意,在之前章节的实验中(第四章运行 Docker 容器,和 第五章创建多容器应用程序),我们使用了服务名称而不是容器 IP 地址。

让我们回顾一下在设计应用程序以在分布式集群中运行其组件时需要牢记的关键概念:

  • 依赖关系解析:你的应用程序可能存在一些依赖关系。这意味着某些服务需要在其他服务之前启动,以使应用程序功能正常。一些编排解决方案,如 Docker Compose(独立编排),包括此功能,但大多数其他解决方案通常没有。作为开发者,你需要在代码中解决这些依赖关系所带来的问题。一个简单的例子是数据库连接。如果由于数据库故障,某些组件的连接丢失,那么由你来决定如何处理。某些组件可能仍能正常工作,而其他组件可能需要重新连接。在这种情况下,你应该在任何事务之前进行连接性验证,并准备管理可能在组件发现数据库故障之前积压的排队事务。

  • 状态:随时了解每个组件的当前状态至关重要。一些调度器有自己的功能来检查和验证每个组件的状态,但作为应用程序的开发者,你最清楚哪些路径、进程、端口等是必需的,以及如何测试它们是否正常工作。在第二章构建 Docker 镜像中,我们讨论了容器镜像内容越简洁,安全性就越好。如果你的应用程序需要额外的软件来测试其健康状况,你应该包括它;然而,可能更好的做法是包括一些测试端点或可以在需要时调用的测试函数,以验证应用程序组件的健康状况。

  • 熔断器:如果我们已经成功地管理了应用程序的依赖关系,熔断器将帮助我们识别任何问题,并在某些组件出现故障时做出适当的决策。编排工具本身不提供任何熔断器功能;你需要额外的软件或基础设施组件来实现此类解决方案。根据应用程序的复杂性,集成一些熔断器可能是有益的。例如,我们可以在数据库不可用时停止所有依赖健康数据库的组件,而其他组件可以继续运行并提供其功能。

  • 可扩展性:也许你的一些应用组件会以多个副本的形式运行。可扩展性应在设计时就考虑进去。编排工具允许你执行多个副本的任何组件,但管理它们的共存仍然由你来负责。如果一个数据库组件以多个副本运行,而没有为这种情况做准备,就会损坏你的数据。在这种情况下,你需要使用主从式或分布式数据库架构。关于数据事务的其他问题可能会出现,比如在前端,如果你没有管理用户会话。在这种情况下,确保会话一致性可能需要额外的组件,以确保所有事务遵循协调的工作流。编排工具并不了解你应用组件的功能,因此,由你来决定哪些组件可以扩展或缩减。一些编排工具会提供规则,帮助你决定何时进行扩展,但它们仅仅触发管理组件副本的动作。所有副本将以相同方式处理,如果你需要负载均衡功能来确保某些副本接收比其他副本更多的数据,你需要添加额外的组件。

  • 高可用性:高可用性的概念可能因询问对象的不同而有所不同,无论是基础设施团队还是应用团队的人,他们都会同意它应该对最终用户是透明的。从基础设施的角度来看,我们可以从多个层面考虑高可用性:

    • 基础设施冗余:当物理或虚拟机用于提供我们应用的高可用性时,我们需要对所有基础设施进行冗余,包括底层的通信层和存储层。大多数设备都知道如何管理当复制设备作为主设备时必须更改的特殊配置。服务器应保持法定人数或主从关系,以决定哪一台服务器处理用户请求。这在我们计划采用主动-被动(只有一个实例处理所有请求,其他实例只有在出现错误时才接管)或主动-主动响应(所有实例同时提供服务)的情况下也是必要的。

    • 健康组件的路由:复制的基础设施需要额外的组件层来路由用户的请求。负载均衡器帮助以完全透明的方式为用户呈现不同应用程序的端点。

    • 存储后端:正如我们所期望的那样,存储不仅必须安全地保留数据,还必须将其附加到任何正在运行和提供服务的实例上。在主备环境中,存储将从损坏的实例切换到健康的实例。作为开发人员,您可能需要在切换后确保数据完整性。

    高可用性意味着服务永远不会中断,即使维护任务需要停止一个实例。容器编排器本身不提供高可用性;它必须作为应用程序设计的一部分考虑进去。

  • 服务状态定义:通常我们定义应用程序的部署方式,包括必须可用的副本数量,以便服务被视为健康。如果任何副本失败,编排器将为您创建或启动一个新的副本。我们不需要触发任何事件,只需定义监视器,以便编排器审查每个应用程序组件的状态。

  • 弹性:应用程序可能会失败,编排器会尝试重新启动它们。这就是弹性的概念,编排器默认提供。当您的应用程序在容器中运行时,容器运行时会在出现问题时重新启动应用程序,保持应用程序的运行。编排器与容器运行时交互,管理容器在整个集群范围内的启动和停止过程,试图减轻应用程序失败的影响。因此,您必须设计您的应用程序以快速停止和启动。通常,在容器中运行的应用程序不会花费超过几秒钟的时间来运行,因此您的用户可能甚至不会注意到中断。因此,为了避免给用户带来重大问题,我们必须为应用程序的组件提供高可用性。

  • 分布式数据:当您的应用程序以分布式集群方式运行时,不同的主机将运行您的应用程序组件,因此需要在需要时提供所需的数据。编排器将与容器运行时交互以挂载容器卷。您可以在所有可能的主机上挂载数据,以预期容器将使用它,这乍看起来可能是个好主意。然而,管理应用程序数据权限可能会给您带来意想不到的后果。例如,您可能会错误配置根目录的权限,不同的应用程序正在其中存储它们的数据,允许一些应用程序读取其他应用程序的数据文件。通常最好将卷管理委托给编排器本身,利用其为此提供的功能。

  • 互操作性:应用程序组件之间的通信可能非常复杂,但编排提供了简化的网络层。在第七章《使用 Swarm 进行编排》和第八章《使用 Kubernetes 编排器部署应用程序》中,我们将看到这两种编排器如何为您的应用程序提供不同的通信层。学习和理解它们如何部署应用程序的通信,并从一开始就设计应用程序以避免任何网络锁定非常重要。这样,您的应用程序就可以在任何可用的编排器上运行。

  • 动态寻址:容器环境基于由容器运行时管理的动态对象。这些对象中的一些可能具有静态属性,例如容器内的 IP 地址。在高度动态的基础设施中管理静态属性可能非常困难,并且不推荐这样做。如果您遵循编排器的规则,编排器会为您管理动态性。

  • 服务可发现性:在编排器中发布的服务将会在内部宣布,并且可以从其他任何应用程序组件访问。默认情况下,这些服务仅在内部工作,这提高了您完整应用程序的安全性,因为您仅发布那些必须由用户访问的前端服务。

  • 多站点:对于跟随太阳的工作负载或灾难恢复架构,拥有多个数据中心在大型企业中是很常见的。您必须始终确保您的应用程序可以在任何地方运行。我们可以更进一步,因为一些公司可能同时拥有云提供的基础设施和本地服务——在这种情况下,您的应用程序可能在云基础设施上运行某些组件,而其他组件则在本地运行。除了这种情况可能带来的基础设施同步挑战,如果您在设计应用程序时考虑到这些高级环境,并且了解需要避免的断点(例如,失去通信时组件之间的法定人数问题),您仍然能够在极端情况下运行您的应用程序。

部署应用程序到集群中的关键方面之一是组件状态的管理。在下一节中,我们将回顾在设计应用程序时,将组件的状态设置在容器生命周期之外的重要性。

理解无状态和有状态应用程序

之前,我们简要介绍了关于如何协调应用程序并将其分布式运行在集群上的关键点和概念,你可能已经注意到,在没有中断的情况下向用户提供服务是非常复杂的。我们回顾了编排工具如何帮助我们提供具有弹性的进程,并且看到高可用性如何必须在应用程序中设计。此类设计的一个关键方面涉及到你的应用程序如何随着时间推移管理进程的状态。

应用程序根据其如何管理进程的状态分为两类:有状态无状态

在学习每一个之前,理解应用程序或进程的状态意味着什么非常重要。系统的状态是指在特定时间点其所处的状态。这个系统可以是运行中的,也可以是停止的,或者处于两者之间的状态,如正在启动或停止。能够识别和管理应用程序组件的状态非常重要。为了管理系统的状态,例如触发启动或停止系统的操作,我们必须知道如何获取状态。在许多情况下,这并不简单,尤其是复杂的情况可能需要管理某些依赖关系或与其他外部系统的交互。

现在我们已经定义了系统的状态,接下来让我们通过一些示例来描述有状态应用和无状态应用。

有状态应用

想象一下一个情境,一个进程需要加载一些数据才能启动。如果这个进程在启动时读取所有配置,那么每次配置发生变化时,我们都需要重启它。但如果这个进程在需要某些功能或操作时才读取部分配置(或全部配置),那么就可能不需要重启。这就是为什么我们需要知道进程是否已经启动,以便加载所需数据。在某些情况下,我们可以设计一个完整的进程,每次启动时都加载数据,而不需要检查之前是否已启动。然而,在其他情况下,不能这样做,因为我们不能替换数据或加载数据超过一次。一个简单的文件可以用作标志,指示加载过程是否已经执行,或者是否需要重新加载数据。

如果我们的应用程序作为一个简单进程在主机上运行,那么可以很容易地在本地管理状态,但如果使用容器则不太容易。当容器在主机上运行时,它使用自己的存储层,除非我们指定一个卷来存储一些数据。运行相同进程的一个新容器,如果你的应用程序通过设计将该标志文件存储在容器生命周期之外,则可以重用以前的卷。在一个独立的主机上运行所有进程时,这看起来相当简单。

卷可以是绑定挂载(来自主机文件系统的目录)或命名卷(具有已知且可重用名称的卷)。当你在集群范围内运行应用程序时,这种方法可能无法正确工作。因为绑定挂载是附加到主机的,该目录在其他主机上不存在。可以使用远程文件系统来持久化标志,并使其在其他主机上可用。在这种情况下,我们使用卷,编排工具将管理所需文件系统的挂载。

然而,当涉及多个进程时,管理应用程序的状态会变得更加困难。在这种情况下,建议从一开始就将这一需求纳入应用程序设计过程中。例如,如果我们设计一个 Web 应用程序,我们需要存储一些用户数据以识别谁发起了特定请求。在这种情况下,我们不仅要管理进程状态——还需要管理用户数据,因此需要多个文件,并且我们必须使用数据库来存储这些数据。我们通常说这样的应用程序是有状态的,并且需要持久数据

无状态应用程序

无状态应用程序则不需要任何数据持久性。我们可以在需要时重新启动这些组件,而无需持久化任何数据。应用程序本身包含所有所需的信息。想象一个接收数据的服务,如果该服务没有响应,我们会再次发送数据,直到它给出响应。这个服务可以对接收到的数据执行一些操作并发送响应,而无需保存任何数据。在这种情况下,这个服务是无状态的。它可能需要一些外部数据来执行操作,但如果出现问题需要重新启动服务,我们无需关心任何挂起操作的状态。我们只需再次发送数据,直到获得有效响应。当一些操作仍在等待时,我们就有了一个有状态的过程,它需要我们加载一些挂起的请求,以及一个无状态的过程,因为它不会自行存储请求。发送请求的服务可能需要存储请求,而处理操作的服务则不需要。

正如你可能想象的那样,无状态应用程序在分布式环境中更容易管理。我们不需要在集群的不同位置管理进程状态及其相关数据。

在下一节中,我们将回顾一些最受欢迎的容器编排工具。

探索容器编排工具

现在我们知道了容器编排工具的基本要求,让我们回顾一些最重要和最具技术相关性的工具。我们还将快速了解每个选项的优缺点。

我们将从目前最受欢迎和广泛应用的容器编排工具 Kubernetes 开始。

Kubernetes

Kubernetes 是一个开源容器编排平台,正迅速成为在云提供商和本地数据中心运行微服务的事实标准。它起初是 Google 为管理公司内部应用程序而开发的一个项目,始于 2003 年。这个项目最初叫做 Borg,旨在部署分布在不同节点和集群中的工作负载。该项目逐渐发展成一个更复杂的编排平台,称为 Omega,专注于运行数千个工作负载的大型集群,用于不同的应用程序。2014 年,Google 将 Borg 的代码公开发布到开源社区,并在同年将其改名为 Kubernetes。2015 年,Kubernetes 1.0 的第一个版本发布,此时 Red Hat、IBM、Microsoft 和 Docker 等公司也加入了这一社区项目。Kubernetes 社区现在非常庞大,正是这一点使得该编排器在当今如此受欢迎。

Kubernetes 最重要的特点是,它的核心专注于执行少数任务,并将更复杂的任务委托给外部插件或控制器。它的可扩展性极强,许多贡献者每天都会添加新功能。如今,Kubernetes 已成为最受欢迎且广泛使用的容器编排工具,当你要求软件供应商提供高可用性时,提供自己 Kubernetes 定义的应用程序已变得相当普遍。Kubernetes 默认不提供容器运行时、集群网络功能或集群存储。我们需要自己决定使用哪种容器运行时来运行容器,这些容器将由编排器进行部署和维护。

在网络方面,Kubernetes 定义了一系列规则,任何我们希望在平台中包含的 容器网络接口 (CNI) 都必须遵循这些规则,以便实现跨容器通信的集群功能,正如我们将在第八章《使用 Kubernetes 编排器部署应用程序》中学习到的那样。Kubernetes 的网络模型与其他编排解决方案不同,它呈现的是一种简单或扁平的网络(容器之间不需要路由),所有容器默认都是可以访问的。部署 Kubernetes 网络的许多开源和专有选项也可用,包括 Flannel、Weave、Cilium 和 Calico。这些网络提供商定义了我们的 Kubernetes 集群的覆盖网络和 IPAM 配置,甚至加密节点之间的通信。

Kubernetes 提供了许多云服务提供商的集成,因为它的设计初衷就是云就绪。提供云控制器用于管理与发布应用程序或使用某些特殊云提供的存储后端的集成。如本节前面所述,Kubernetes 并未提供部署任何集群级存储后端的解决方案,但你可以将 NFS 和一些 AWS、Google 和 Azure 存储后端集成到你的应用程序中。为了扩展存储可能性,你可以使用容器存储接口CSI),这是不同供应商或社区驱动的存储后端,可以轻松地集成到 Kubernetes 中,为我们管理的容器提供不同的存储解决方案。

许多云服务提供商和软件供应商打包并分享或销售他们自己的 Kubernetes 版本。例如,Red Hat/IBM 在其 OpenShift 产品中提供了自己的 Kubernetes 平台。微软、亚马逊、谷歌,实际上几乎所有的云服务提供商都有自己的 Kubernetes 实现,供用户使用。在这些 Kubernetes 平台中,你甚至不需要管理任何控制平面功能——这些平台作为 Kubernetes 管理的解决方案提供给你,作为开发者,你可以使用它们来交付应用程序。这些解决方案被称为Kubernetes 即服务平台,用户为工作负载和应用程序使用的带宽付费。

Kubernetes 项目大约每 4 个月发布一次版本,并同时维护三个次要版本(因此每个版本都能提供几乎一年的补丁和支持)。在版本之间总是会有一些变动和弃用,因此非常重要的是要审查每个版本的变更说明。

Kubernetes 集群有不同角色的节点:master节点创建控制平面以交付容器,而worker节点执行分配给它们的工作负载。这种模型使我们能够通过复制一些 master 节点的服务来部署高可用性的 Kubernetes 集群。一个开源的键值数据库,名为 etcd,用于管理所有对象(在 Kubernetes 中称为资源)的引用和状态。

Kubernetes 发展得如此之快,以至于现在我们甚至可以通过使用像 KubeVirt 这样的操作符将虚拟机管理和集成到 Kubernetes 集群中。Kubernetes 的另一个伟大特点是,当 Kubernetes 核心资源无法满足应用程序的某些特殊需求时,你可以为应用程序创建自己的资源(Kubernetes 自定义资源定义)。

让我们快速总结一下使用 Kubernetes 的优点:

  • 许多云服务提供商和本地软件解决方案的容器提供商为客户提供了 Kubernetes 的支持

  • 有非常详尽的文档和大量学习基础知识的示例与指南

  • 通过标准化接口(如 CNI 和 CSI)高度可扩展

  • 很多对象或资源能够满足你大多数应用程序在集群范围内运行的需求。

  • 包含许多安全功能,如基于角色的访问控制、服务帐户、安全上下文和网络策略。

  • 提供多种发布应用程序的方法。

  • 作为许多软件供应商的标准部署方法,你可以轻松地找到以 Kubernetes 清单格式打包的应用程序。

然而,它也有一些缺点。包括以下几点:

  • 由于其不断演化和许多资源类型,它并不容易掌握。与其他编排解决方案相比,学习曲线可能显得更陡峭。

  • 每年发布的版本可能需要大量的工作来维护平台。

  • 拥有许多变种可能会成为问题,因为每个供应商都会在其平台上引入自己的特性。

  • 你永远不会仅为一个应用程序或几个小应用程序使用 Kubernetes,因为它需要大量的维护和部署工作。事实上,像微软的 Azure Kubernetes 服务这样的 Kubernetes 即服务提供商将帮助你减少维护工作。

我们将在第八章中详细学习 Kubernetes 的所有功能,以及如何在该编排工具中准备和部署我们的应用程序,使用 Kubernetes 编排器部署应用程序

Docker Swarm

Docker Swarm是由 Docker 公司创建的容器编排解决方案。它旨在提供一个简单的编排平台,默认情况下包括运行我们容器化应用程序所需的所有功能,覆盖整个集群。这包括覆盖网络(可以加密)和隔离,通过为每个项目创建不同的网络(如果需要)。

可以使用不同的对象来部署我们的应用程序,如全局服务或复制服务,每个服务都有其自己的属性,用于管理容器如何在集群中分布。正如我们在 Kubernetes 中看到的,也使用主节点-工作节点(主从)模式。主节点创建完整的控制平面,工作节点执行容器。

需要提到的是集群中变更管理的一个重要区别。Kubernetes 使用 etcd 作为其键值数据库,而 Docker Swarm 使用 Raft 共识算法管理自己的对象数据库解决方案,并提供完整的命令行界面。只需安装 Docker 引擎就可以开始使用 Docker Swarm,因为容器运行时二进制文件也包含了 SwarmKit 特性。Moby 是 Docker Inc. 背后的开源项目,创建了用于交付和改进容器通信的工具包(VPNKit)以及改进默认的 docker build 功能的工具包(BuildKit),其中一些内容我们在 第二章构建 Docker 镜像 中,使用 buildx 扩展构建命令行进行了介绍。SwarmKit 是 Docker Swarm 背后的 Moby 项目,提供集群功能、安全性以及模型的简易性。Docker Swarm 相当简单,但这并不意味着它不适合生产环境。它提供了部署高可用性应用所需的最小功能。

需要提到的是,Compose YAML 文件允许我们使用一组清单来部署应用程序,创建并管理 Docker Swarm 中的所有应用对象。我们在 第五章创建多容器应用程序 中学到的一些键值,在这里无法使用,例如 depends_on,因此应用程序的依赖管理必须在代码中自行处理。

下面是 Docker Swarm 的一些优点:

  • 比其他容器编排工具更易于学习

  • 集成在 Docker 引擎内,并通过 Docker 命令行进行管理

  • 单一二进制文件部署

  • 与 Compose YAML 文件兼容

一些缺点如下:

  • 部署应用程序所需的对象或资源较少,这可能会影响应用程序中应用的逻辑。重要的是要理解,作为开发人员,你可以在代码中实现应用程序的逻辑,并避免与编排相关的任何潜在问题。

  • 它仅与 Docker 容器运行时兼容,因此存在供应商锁定,无法使用其他容器运行时提供的安全性改进。

  • 尽管 Docker Swarm 提供了一些与容器运行时相关的插件,但它不像 Kubernetes 那样开放和可扩展。

  • 发布应用程序更容易,但这意味着没有外部工具,我们无法应用任何高级功能。

我们将在 第七章使用 Swarm 进行编排 中深入了解 Docker Swarm。

Nomad

HashiCorp Nomad 是一个平台,允许我们运行容器、虚拟机(使用 QEMU,这是一个著名的开源虚拟化引擎)和 Java 应用程序。它专注于调度应用程序工作负载,并检查其他 HashiCorp 工具(如 ConsulVault)提供的服务,如发现、健康检查监控、DNS 和秘密管理。Nomad 的安全性基于 访问控制列表ACLs),包括令牌、策略、角色和权限。在网络方面,它使用 CNI 插件进行桥接工作模式。其多站点功能允许我们从单一编排角度,通过 联邦,在不同区域运行应用程序。

Nomad 采用了一些 Kubernetes 和 Docker Swarm 提到的架构特性,其中一些节点充当控制平面(服务器),而其他节点(客户端)执行所有工作负载。服务器接收用户的作业,管理客户端,并确定工作负载的分配。

HashiCorp 提供了社区版和 软件即服务SaaS)平台,部署在其云中。它可以通过 API 与一些 CI/CD 环境集成,并且可以包含基础设施自动化的脚本。其一些优点如下:

  • 使用和维护简单

  • 单二进制部署

  • 灵活部署和管理虚拟机,以及容器化和非容器化应用程序

以下是它的一些局限性:

  • 尽管 HashiCorp 提供了良好的文档,但它的用户并不多,因此项目背后缺乏强大的社区支持。

  • Nomad 与 Docker Swarm(一个遗留平台)和 Kubernetes 一起出现,但 Nomad 最初侧重于虚拟机和应用程序。容器编排与 Docker Swarm 和 Kubernetes 密切相关,后者在该领域因而获得了更多的关注。

  • 由于相关项目较少,Nomad 被单一公司掌控,这家公司可能更为成熟。这使得该产品的演进或新特性添加相比社区驱动的项目进展较慢。

Apache Mesos

Mesos 是一个由 Apache 组织在 2009 年创建的项目,用于运行集群范围的工作负载。这发生在容器广泛应用之前,因此容器只有在大部分架构逻辑已经设计完成后才被集成到该项目中。这意味着 Mesos 可以像 Nomad 一样,在集群范围内运行容器和正常的应用程序工作负载。

Hadoop 和其他大数据工作负载管理器是由 Apache Mesos 管理的主要框架,而它在容器方面的使用相对有限,或者至少没有其他解决方案那么受欢迎。Mesos 的优势在于,它可以轻松集成 Apache 项目的工作负载,如 Spark、Hadoop 和 Kafka,因为它是为这些项目设计的。

然而,它的缺点包括:

  • 专用的工作负载或框架可能需要手动配置,因此它们不像 Kubernetes 或 Docker Compose 的 YAML 文件那样标准化。

  • 这个协调器并不是很受欢迎,因此它的社区较小,相比于 Kubernetes 或 Docker Swarm,只有少数几个教程示例可供参考。

云服务商特定的编排平台

现在我们已经对最重要的本地编排工具有了快速了解(其中一些也作为云解决方案提供),接下来让我们看看一些云服务商为其平台专门创建的编排解决方案。使用它们特定功能时,你可能会遇到一定程度的供应商锁定问题。让我们看看其中最重要的几个:

  • Amazon Elastic Container Service (ECS) 和 Fargate:Amazon ECS 是一个由 Amazon 管理的容器编排服务。ECS 依赖于你的 EC2 合同资源(存储、虚拟网络和负载均衡器),因此,你可以通过添加更多资源或节点来增加或减少平台可用的硬件。Amazon Elastic Kubernetes Service (EKS) 与这个简化的选项完全不同,因为它为你部署了一个完整的 Kubernetes 集群。另一方面,AWS Fargate 是一种更简化的技术,让你可以在不需要管理服务器或集群的情况下运行容器。你只需将应用程序打包成容器,指定基础操作系统和 CPU、内存需求。你只需要配置一些网络设置和 身份与访问管理 (IAM) 来保障访问安全。这些就是你最终能够运行应用程序的所有要求。

  • Google AnthosGoogle Cloud Run:尽管 Google Cloud Platform 提供了自己的 Kubernetes 即服务平台 Google Kubernetes Engine (GKE),但它也提供了 Anthos 和 Cloud Run。Anthos 是一个混合型且与云平台无关的容器管理平台,允许将运行在 Google 云和你的数据中心中的容器化应用程序集成。它专注于准备和运行作为虚拟机使用的容器化应用程序。另一方面,Google Cloud Run 更加灵活,提供了按需扩展工作负载的能力,并可以集成 CI/CD 工具以及不同的容器运行时。

  • Azure Service FabricAzure Container Instances (ACI):微软提供了不同的解决方案来运行简单的容器,同时也有 Azure Kubernetes Service (AKS) 可供使用。Azure Service Fabric 为你的应用程序提供了一个完整的微服务平台,而 ACI 则是一个简化版本,你可以像使用小型虚拟机一样运行容器。你只需为应用程序编写代码并构建容器镜像,而无需管理运行它们的基础设施。

所有这些云平台允许你测试甚至在生产环境中运行应用程序,而无需管理任何底层的编排。只需使用简单的 Web 界面,你就可以确定当应用程序组件失败时应该采取的措施,并可以根据需要添加资源。每个供应商提供的云存储服务可供你的应用程序使用,你还可以通过云平台提供的报告监控应用程序的完整成本。以下是这些云供应商平台的一些优点:

  • 比任何其他容器编排都更容易使用

  • 对于测试甚至执行应用程序来说,可能不需要完整的 Kubernetes 集群,这使得云供应商解决方案成为一种合理的选择

它们的缺点包括以下几点:

  • 使用这些平台时,供应商锁定问题始终存在,因为你将使用许多云供应商嵌入的服务

  • 它们可以有效地用于测试,甚至发布一些简单的应用程序,但在使用微服务时,根据应用程序组件的复杂性,可能会出现一些问题。

总结

在本章中,我们回顾了在应用程序中使用的常见编排概念以及可用的不同平台。我们了解了可帮助我们决定哪些选项更适合我们应用程序的最重要功能。学习编排器的工作原理将极大地帮助你作为开发者,在设计应用程序时实现集群级别的高可用性,得益于编排提供的独特功能。在接下来的章节中,我们将深入探讨 Kubernetes(最受欢迎且广泛扩展的容器编排平台)和 Docker Swarm,这两者都可以在云端和本地环境中使用。

第七章:7

使用 Swarm 进行编排

作为开发人员,你可以基于微服务创建应用程序。使用容器将你的应用程序分发到不同的组件,可以为它们提供不同的功能和能力,如可扩展性弹性。使用 Docker Compose 等工具,单独的环境操作比较简单,但当容器能够在不同主机的集群范围内运行时,事情就变得复杂了。在本章中,我们将学习如何通过 Docker Swarm 来协调应用容器,提供一整套管理可扩展性、网络和弹性的功能。我们将回顾如何在 Docker 容器引擎中实现协调需求,并且如何实现每个应用程序的具体需求。

本章将覆盖以下主题:

  • 部署 Docker Swarm 集群

  • 使用 Docker Swarm 提供高可用性

  • 为你的应用创建任务和服务

  • 堆栈和其他 Docker Swarm 资源的回顾

  • 使用 Docker Swarm 进行网络配置和暴露应用

  • 更新应用程序的服务

技术要求

我们将使用开源工具构建、分享并运行一个简单但功能完善的 Docker Swarm 环境。本章包含的实验将帮助你理解所呈现的内容,并且它们已发布在 https://github.com/PacktPublishing/Containers-for-Developers-Handbook/tree/main/Chapter7。 本章的 Code In Action 视频可以在 packt.link/JdOIY 找到。

部署 Docker Swarm 集群

Docker Swarm 是 Docker 公司开发的编排平台。它可能是最简单的编排解决方案,用于开始部署你的容器化应用程序。它已包含在 Docker 容器运行时中,不需要额外的软件来部署、管理和提供完整且安全的 Docker Swarm 集群解决方案。不过,在我们学习如何操作之前,先来探索一下 Docker Swarm 的架构。

理解 Docker Swarm 的架构

Docker Swarm 的架构基于控制平面管理平面数据平面(或工作负载平面)的概念。控制平面监督集群的状态,管理平面提供所有平台管理功能,最后,数据平面执行用户定义的任务。可以使用多个网络接口将这些平面相互隔离(但对你作为开发者来说,这应该是完全透明的)。这个模型在其他协调器中也存在,例如 Kubernetes(简化为角色节点)。不同的角色将用于定义集群内节点的工作。Docker Swarm 与其他协调器的主要区别在于,这些角色可以轻松地在节点之间互换;因此,控制/管理平面节点只需一个命令即可转换为工作负载就绪的节点。Docker Swarm 使用传输层安全性TLS)加密网络来安全地管理所有控制平面通信。内部证书授权CA)及其证书将由 Docker Swarm 完全管理。

重要提示

大多数容器编排平台将定义主节点为用于管理平台的节点,而工作节点最终将执行所有工作负载。这些角色也可以共享,主节点可以执行一些特定任务。Docker Swarm 允许我们通过命令行完全更改节点的角色,而无需重新安装或重新创建节点。

我们将使用服务的概念来定义集群中的工作负载。服务具有不同的属性,用于修改和管理流量如何传递到应用程序的工作负载。我们将定义副本的数量,以确保服务被认为是活跃的。协调器将负责确保此数量的副本始终在运行。考虑到这一点,要扩展或缩减服务,我们只需修改认为健康所需的副本数。当集群节点发生故障时,Docker Swarm 会将任务调度到其他可用主机上。

使用许多不同容器化组件的应用程序将需要多个服务才能运行,这使得定义它们之间的通信变得至关重要。Docker Swarm 将同时管理工作负载状态和网络层(覆盖网络)。还可以为服务通信启用加密。为了确保所有服务副本都可访问,Docker Swarm 会管理一个内部 DNS 并在所有健康副本之间进行负载均衡服务请求。

集群还将管理应用程序服务的滚动升级,每当组件发生变化时,Docker Swarm 提供了针对特定需求的不同部署方式。此功能使我们能够通过简单地替换或降级服务来执行维护任务(如更新),避免任何可能的停机。

我们可以通过以下几点总结 Docker Swarm 的所有功能:

  • Docker Swarm 是内置的 Docker 容器运行时,无需额外软件即可部署容器编排集群。

  • 一个控制平面、一个管理平面和一个数据平面被部署来分别监督集群状态、管理所有任务,并执行我们应用程序的进程。

  • 集群节点可以是控制和管理平面的一部分(具有管理角色),仅执行分配的工作负载(具有工作者或计算角色),或同时具备这两种角色。我们可以通过 Docker 客户端命令行轻松地将节点的角色从管理者更改为工作者。

  • 一个应用程序的工作负载被定义为服务,通过若干健康的副本来表示。当任何副本未能满足服务的要求时,编排器将自动监督执行一个调节过程。

  • 所有集群的控制平面和服务通信(覆盖网络)由 Docker Swarm 管理,默认通过 TLS 提供控制平面的安全性,并为服务提供加密功能。

  • Docker Swarm 提供了内部服务发现和在所有服务副本之间的同质负载均衡。我们定义了在更改任何内容或工作负载特性时,服务副本将如何进行更新,而 Docker Swarm 会管理这些更新。

现在我们可以继续本节内容,学习如何部署 Docker Swarm 集群及其架构。作为开发者,你可以运用自己的知识,决定哪些集群特性和资源将帮助你在该编排器的监督下运行应用程序。

Docker Swarm 管理节点

如本节前面所述,控制平面由一组主机提供。这些主机在 Swarm 中被称为管理节点。这些集群中的节点对于提供所有控制平面的功能至关重要。它们都管理 Docker Swarm 集群环境。一个内部的键值数据库用于维护所有在集群中创建和管理的对象的元数据。

为了为集群提供高可用性,我们部署了多个管理节点,并将共享该键值存储,以防某个管理节点发生故障。一个管理节点充当领导者,负责将所有对象的更改写入其数据存储中。其他管理节点将把这些数据复制到它们自己的数据库中。好处是,所有这些都是由 Docker Swarm 内部管理的。它实现了Raft 共识算法来管理和存储所有集群状态。这确保了信息在多个管理节点之间的均匀分配。

在 Docker Swarm 集群中,第一个创建的节点自动成为集群的领导节点,并且每当领导节点失败时,都会触发选举过程。所有健康的管理节点会在内部投票选举新的领导节点;因此,在选举新的领导节点之前,必须达成共识。这意味着我们至少需要N/2+1个健康的管理节点才能选举出新的领导节点。我们需要部署奇数个管理节点,并且这些节点会维护集群的健康状态,提供 Docker Swarm HTTP API,并在健康的、可用的计算节点上调度工作负载。

默认情况下,所有管理节点与工作节点之间的通信都会通过 TLS(相互 TLS)加密。我们无需管理任何加密过程;内部的 CA 会被部署,且服务器证书会自动轮换。

现在我们了解了集群是如何管理的,让我们来回顾一下应用程序如何在计算节点上执行。

Docker Swarm 工作节点

管理节点的领导者会查看平台的状态,并决定在哪里运行新的任务。所有节点都会报告其状态和负载,以帮助领导者决定执行服务副本的最佳位置。工作节点与管理节点通信,向其报告正在运行的容器的状态,这些信息会传递到领导节点。

工作节点只会执行容器;它们永远不会参与任何调度决策,并且它们是数据平面的一部分,所有服务的内部通信都在此管理。这些通信(覆盖网络)基于 UDP VXLAN 隧道,并且可以加密,尽管默认情况下没有启用加密,因为加密会带来一些额外的开销。

重要说明

需要知道的是,Docker Swarm 的管理节点也具有工作节点的角色。这意味着默认情况下,任何工作负载都可以在管理节点或工作节点上运行。我们将使用额外的机制,如工作负载位置,来避免在管理节点上执行应用程序的容器。

现在我们可以继续并学习如何创建一个简单的集群。

创建 Docker Swarm 集群

Docker Swarm 的功能完全嵌入在 Docker 容器运行时中,因此我们无需额外的二进制文件来创建集群。

要创建一个 Docker Swarm 集群,我们将从初始化开始。我们可以选择任何主机接口来创建集群,如果没有选择,默认会使用第一个可用的接口。我们将在集群节点上执行docker swarm init命令,执行该命令的节点将成为领导节点。重要的是要理解,即使只有一个节点(领导节点),我们也能拥有一个完全功能的集群,尽管在这种情况下,我们无法为应用程序提供高可用性。默认情况下,任何管理节点,包括领导节点,都可以运行任何应用程序的工作负载。

一旦创建了 Docker Swarm 集群,Docker 容器运行时将开始以swarm 模式工作。此时,一些新的 Docker 对象将变得可用,这对你作为开发人员来说,可能会让你更感兴趣地部署自己的集群:

  • Swarm:该对象代表集群本身,具有其独特的属性。

  • 节点:集群中的每个节点都由一个节点对象表示,无论它是领导者、管理节点还是工作节点。为每个节点添加一些标签非常有用,可以帮助内部调度器将工作负载分配到特定节点(请记住,所有节点都可以运行任何工作负载)。

  • 服务:服务代表最小的工作负载调度单元。我们将为每个应用组件创建一个服务,即使它只运行一个单独的容器。我们绝不会在 Docker Swarm 集群中运行独立的容器,因为这些容器将不会由调度器进行管理。

  • 机密:这些对象允许我们安全地存储各种敏感数据(最大可达 500 KB)。机密将在服务容器中挂载并使用,集群将管理并存储其内容。

  • 配置:配置对象的作用类似于机密,但它们以明文形式存储。需要理解的是,配置和机密在集群中是分布式的,这一点非常重要,因为容器将运行在不同的主机上。

  • 堆栈:这是一种新类型的对象,用于在 Docker Swarm 中部署应用程序。我们将使用 Compose YAML 文件语法来描述所有应用组件及其存储和网络配置。

重要提示

Docker Swarm 集群平台不需要像 Kubernetes 那样消耗大量资源;因此,可以在你的笔记本电脑上部署一个三节点集群进行测试。你将能够验证应用程序的运行方式,并在某些应用组件失败或集群节点完全离线时保持服务级别。我们使用独立的 Docker Swarm 集群,以便使用特殊对象,如机密配置

正如我们在本节之前提到的,我们可以通过执行 docker swarm init 来创建一个 Docker Swarm 集群,但许多参数可以修改默认行为。我们将回顾一些最重要的参数,以让你了解集群的隔离性和安全性:

  • --advertise-addr:我们可以使用此选项定义用于启动集群的接口。所有其他节点将使用此 IP 地址加入刚创建的集群。默认情况下,将使用第一个接口。此选项将允许我们设置用于宣布控制平面的接口。

  • --data-path-addr--data-path-port:我们可以通过使用这些参数设置主机特定接口的 IP 地址和端口,来为应用程序隔离数据平面。流量可以加密,这对您的应用程序是完全透明的。Docker Swarm 将管理此通信;由于加密/解密过程,可能会有一些开销。

  • --listen-addr:默认情况下,Docker Swarm API 将监听所有主机接口,但我们可以通过定义的接口来安全地回答 API。

  • --autolock:Docker Swarm 将存储所有数据在 /var/lib/docker/swarm 下(默认情况下,取决于您的运行时根数据路径)。此目录包含 CA,用于创建所有节点证书,以及 Docker Swarm 自动创建的快照,以在发生故障时保留数据。此信息必须是安全的,--autolock 选项允许我们锁定内容,直到提供密码为止。

重要说明

锁定 Docker Swarm 内容可能会影响集群的高可用性。这是因为每次重启 Docker 运行时时,您必须使用解锁操作来检索目录的内容,并且您将被要求输入自动锁定的密码。因此,由于需要手动干预,组件的自动重启被打破了。

当初始化 Docker 集群时,会创建一对集群令牌。这些令牌应该用于将额外的节点加入到集群中。一个令牌用于加入新的管理节点,另一个令牌仅用于集成工作节点。请记住,如果管理节点失败,可以随时更改节点的角色。以下代码展示了如何呈现这些令牌:

$ docker swarm init
Swarm initialized: current node (s3jekhby2s0vn1qmbhm3ulxzh) is now a manager.

要将工作节点添加到此集群中,可以运行以下命令:

     docker swarm join --token SWMTKN-1-17o42n70mys1l3qklmew87n82lrgymtrr65exmaga9jp57831g-4g2kzh4eoec2973l7hc561bte 192.168.65.4:2377

要将管理器添加到此集群中,请运行 docker swarm join-token manager 并按照提示操作。

我们使用 docker swarm join,后跟 --token 和适用于新管理器或工作节点的适当令牌。此令牌将显示在集群初始化时,但可以随时通过简单使用 docker swarm join-token 来检索。默认情况下,每 90 天会触发令牌的自动轮换。

Docker Swarm 节点可以通过执行 docker swarm leave 随时离开集群。重要的是要理解,失去一个管理节点可能会使您的集群处于危险之中。在更改其角色为工作节点或将其从集群中删除时,请特别小心管理节点。

重要说明

通过使用 docker swarm update 可以修改一些 Swarm 对象属性,例如自动锁定或证书过期。

在下一节中,我们将了解为 Docker Swarm 集群提供高可用性所需的内容及我们应用程序的要求。

使用 Docker Swarm 提供高可用性

如果我们使用奇数个管理节点,Docker Swarm 调度器将提供开箱即用的高可用性。用于管理内部数据库的Raft 协议要求节点数量为奇数,以保持其健康。如前所述,本章讨论的完全功能集群的最小健康管理器数量为N/2+1。然而,无论有多少个管理器在工作,您的应用程序功能可能不会受到影响。即使没有满足所需的最小管理节点数量,工作节点也会继续工作。应用程序的服务将继续运行,除非容器发生故障。在这种情况下,如果管理器不可用,您的容器将无法被集群管理,因此应用程序将受到影响。理解这一点非常重要,因为它是为这些情况做好集群准备的关键。

尽管您的集群实现了完全的高可用性,但您仍然需要为您的应用程序做好准备。默认情况下,系统会提供弹性支持。这意味着,如果一个正在运行的容器发生故障,将会创建一个新的容器来替代它,但即使您运行的是完全无状态的服务,这仍然可能会影响到您的应用程序。

服务集成了任务实例,它们最终代表一个容器。因此,我们必须设置服务所需的副本数(或任务数),以确保服务被认为是健康的。运行工作负载的 Docker 容器运行时将通过执行容器镜像中包含的健康检查或执行时定义的健康检查(使用 Compose YAML 文件格式编写的服务定义)来检查容器是否健康。

当然,副本的数量会在出现故障时影响服务的中断。因此,您应该通过执行多个副本来为这种情况做好应用程序的准备,例如,确保服务有超过一个副本。当然,这要求您从一开始就考虑应用程序组件的逻辑。例如,即使您的应用程序完全无状态,并使用有状态服务(如数据库),您可能也需要考虑如何为该组件提供高可用性或至少容错性。数据库可以在容器内运行,但其逻辑可能需要做一些调整。有时,您可以将 SQL 数据库替换为分布式的 NoSQL 数据库解决方案。

在之前的应用示例中,包含了数据库组件,但我们没有考虑使用卷管理有状态数据的问题(即使是使用分布式解决方案),但每个有状态的应用应该能够从一个集群节点迁移到另一个节点。这也影响到必须附加到容器上的相关卷,无论它们运行在哪个节点,只要节点接收到任务。我们可以使用远程存储文件系统解决方案,如 NFS,或在节点之间同步文件系统或文件夹。作为开发者,你不必管理基础设施,但你必须准备好你的应用程序,例如,验证某些文件的存在。你还应该问自己,如果多个副本尝试访问你的数据,会发生什么情况?这种情况肯定会破坏数据库,例如。其他调度器,如 Kubernetes,为这些情况提供了更有趣的解决方案,我们将在 第八章 中学习,使用 Kubernetes 调度器部署应用程序

Docker Swarm 节点可以从工作节点提升为管理节点,反之亦然,管理节点可以被降级为工作节点。我们还可以对节点进行排空暂停,这可以让我们将所有容器完全从一个节点迁移到另一个可用的工作节点,并分别禁用在定义的节点中调度。所有这些操作都是基础设施管理的一部分。你至少应该验证当触发排空操作时,应用程序会如何表现,当你的容器从一个节点停止并在另一个节点启动时,应用的组件会如何管理这种情况?这会如何影响那些使用了一些受影响服务的组件容器?作为开发者,这是你必须在应用逻辑和代码中解决的问题。

接下来,让我们学习如何在 Docker Swarm 中调度我们的应用。

为你的应用创建任务和服务

首先你应该知道的是,我们永远不会在 Docker Swarm 集群上调度容器。我们始终会运行服务,它是 Docker Swarm 集群中最小的部署单元。

每个服务由若干个副本定义,在 Docker Swarm 中称为任务。最后,每个任务将运行一个容器。

重要提示

Docker Swarm 基于 Moby 的SwarmKit项目,该项目旨在运行任何类型的任务集群(例如虚拟机)。Docker 通过在调度器中实现 SwarmKit 创建了 Docker Swarm,但它特别用于运行容器。

我们将使用声明性模型在我们的 Docker Swarm 集群中安排服务,通过设置我们服务的期望状态。Docker Swarm 将持续关注它们的状态,并在任何故障时采取纠正措施。例如,如果运行的副本数量不正确,因为一个容器已失败,Docker Swarm 将创建一个新的来纠正服务的状态。

让我们继续创建一个简单的webserver服务,使用nginx:alpine容器镜像:

$ docker service create --name webserver nginx:alpine
k40w64wkmr5v582m4whfplca5
overall progress: 1 out of 1 tasks
1/1: running   [==================================================>]
verify: Service converged

我们刚刚定义了服务的名称以及要用于关联容器的镜像。默认情况下,服务将创建一个副本。

我们可以通过简单执行docker service ls来验证服务的状态,以列出所有 Docker Swarm 服务:

$ docker service ls
ID             NAME        MODE         REPLICAS   IMAGE          PORTS
k40w64wkmr5v   webserver   replicated   1/1        nginx:alpine

正如您可能注意到的,创建了一个服务 ID(对象 ID),我们可以使用在第二章第四章中学到的任何操作,用于列出、检查和移除 Docker 对象。

我们可以使用docker node ps来验证服务的容器运行在哪个节点。这将列出与服务相关联的运行在集群中的所有容器:

$ docker node ps
ID             NAME          IMAGE          NODE             DESIRED STATE   CURRENT STATE           ERROR     PORTS
og3zh7h7ht9q   webserver.1   nginx:alpine   docker-desktop   Running         Running 7 minutes ago

在本示例中,一个容器正在docker-desktop主机(Docker Desktop 环境)上运行。我们没有为发布我们的 Web 服务器指定端口;因此,它将完全在内部工作,并且对用户不可达。当我们没有设置其他任何内容时,只部署了一个副本,因为使用了默认值。因此,要创建一个真正的服务,通常需要指定以下信息:

  • 需要下载图像的存储库

  • 我们的服务需要的健康副本/容器数量以被视为处于活动状态

  • 如果服务必须在外部可达,则发布的端口

也很重要的是要提到,Docker Swarm 服务可以是复制的(默认情况下,如我们在前面的示例中看到的)或全局的(在所有集群节点上运行)。

复制的服务将创建多个副本,称为任务,每个任务将创建一个容器。作为开发者,您可以准备您应用程序的逻辑以运行多个副本,从而提供简单但有用的高可用性(这将帮助您在发生故障时失去一半的服务)。这将减少故障的影响,并且在需要引入更改时真正有助于升级过程。

另一方面,全局服务将在每个集群节点上运行你的服务的一个副本。这非常强大,但如果你能够将应用程序的负载分布到不同的进程中,它可能会降低集群的整体性能。这种类型的服务用于部署监控和日志记录应用程序,它们作为代理,自动分布到所有节点上。需要注意的是,Docker Swarm 会在加入集群的任何节点上为每个服务调度一个任务。当你需要在集群上运行类似代理的应用程序时,可以使用全局服务。

作为开发者,你应该考虑哪种服务类型最适合你的应用程序,并使用 --mode 参数创建适当的 Docker Swarm 服务。

重要提示

你可能认为将分布式数据库(如 MongoDB 或任何简单的键值存储)或队列管理解决方案(如 RabbitMQ 或 Apache Kafka)作为全局服务运行,以确保其可用性是个好主意,但你必须注意正在运行的容器的最终数量。全局服务不保证容器/进程的奇数数量,如果你将新节点加入集群,它可能会破坏你的应用程序。每次加入新节点时,都会创建一个新的容器作为全局服务的一部分。

我们可以使用标签来为某些服务定义位置。它们会同时影响所有副本。例如,我们可以创建一个只能在标记为 web 的节点上运行的全局服务:

$ docker service create --detach \
--name global-webserver --mode global \
--constraint node.labels.web=="true" nginx:alpine
n9e24dh4s5731q37oboo8i7ig

Docker Desktop 环境类似于一个单节点 Docker Swarm 集群;因此,如果存在适当的标签 web,全局服务应该正在运行:

$ docker service ls
ID             NAME               MODE         REPLICAS   IMAGE          PORTS
n9e24dh4s573   global-webserver   global       0/0        nginx:alpine
k40w64wkmr5v   webserver          replicated   1/1        nginx:alpine

让我们为唯一的集群节点添加标签:

$ docker node update --label-add web="true" docker-desktop
docker-desktop
$ docker node inspect docker-desktop \
--format="{{.Spec.Labels}}"
map[web:true]

Docker Swarm 自动检测到节点标签的变化,并为我们调度了全局服务容器:

$ docker service ls
ID             NAME               MODE         REPLICAS   IMAGE          PORTS
n9e24dh4s573   global-webserver   global       1/1        nginx:alpine
k40w64wkmr5v   webserver          replicated   1/1        nginx:alpine

如你所见,Docker Swarm 允许我们更改任何服务的默认位置。让我们回顾一些可用的选项,以便将应用程序的任务放置在特定的节点或节点池中:

  • --constraint:此选项指定我们的服务容器应该运行的位置。它使用标签,就像我们在前面的例子中看到的那样。我们可以通过使用 docker service inspect 来验证服务的放置要求:

    $ docker service inspect global-webserver \
    --format="{{.Spec.TaskTemplate.Placement}}"
    --placement-pref: Sometimes, we are looking for a preferred location, but we need to ensure that the application will execute even if this doesn’t exist. We will use a placement preference in such situations.
    
  • --replicas-max-per-node:在某些情况下,设置位置的另一种方式是避免每个集群节点上的副本数量超过特定值。这将确保例如副本不会在同一主机上争夺资源。

通过使用放置约束或首选位置,你可以确保你的应用程序运行在具有 GPU 或更快磁盘的特定节点上。

重要提示

作为开发者,你应该设计你的应用程序以便几乎可以在任何地方运行。你需要向 Docker Swarm 管理员询问任何位置标签或偏好设置,并在部署中使用它们。这些基础设施特性可能会影响你的应用程序运行方式,因此你必须了解它们。

我们也可以执行 Completed,并且不会执行其他容器。Docker Swarm 允许执行全局任务或副本任务,分别为 global-jobreplicated-job 服务类型。

Docker Swarm 服务可以随时更新,例如更改容器镜像或其他属性,比如它们在集群节点中的调度位置。

要更新任何可用服务的属性,我们将使用 docker service update。在以下示例中,我们只会更新服务的副本数量:

$ docker service update webserver --replicas=2
webserver
overall progress: 2 out of 2 tasks
1/2: running   [==================================================>]
2/2: running   [==================================================>]
verify: Service converged
$ docker service ls
ID             NAME               MODE         REPLICAS   IMAGE          PORTS
n9e24dh4s573   global-webserver   global       1/1        nginx:alpine
k40w64wkmr5v   webserver          replicated   2/2        nginx:alpine

现在我们已经为 webservice 服务运行了两个副本或实例,我们可以验证 Docker Swarm 如何检查和管理任何故障:

$ docker node ps
ID             NAME                                         IMAGE          NODE             DESIRED STATE   CURRENT STATE           ERROR     PORTS
g72t1n2myffy   global-webserver.s3jekhby2s0vn1qmbhm3ulxzh   nginx:alpine   docker-desktop   Running         Running 3 hours ago
og3zh7h7ht9q   webserver.1                                  nginx:alpine   docker-desktop   Running         Running 16 hours ago
x2u85bbcrxip   webserver.2                                  nginx:alpine   docker-desktop   Running         Running 2 minutes ago

使用 docker 运行时客户端,我们可以列出所有正在运行的容器(因为我们只使用一个节点的集群,docker-desktop 主机,所以可以这样操作):

$ docker ps
CONTAINER ID   IMAGE          COMMAND                  CREATED         STATUS         PORTS     NAMES
cfd1b37cca93   nginx:alpine   "/docker-entrypoint.…"   9 minutes ago   Up 9 minutes   80/tcp    webserver.2.x2u85bbcrxiplsvtmj08x69z5
9bc7a5df593e   nginx:alpine   "/docker-entrypoint.…"   3 hours ago     Up 3 hours     80/tcp    global-webserver.s3jekhby2s0vn1qmbhm3ulxzh.g72t1n2myffy0abl1bj57m6es
3d784315a0af   nginx:alpine   "/docker-entrypoint.…"   16 hours ago    Up 16 hours    80/tcp    webserver.1.og3zh7h7ht9qpv1xkip84a9gb

我们可以终止一个 webserver 服务的容器,并验证 Docker Swarm 会创建一个新的容器来恢复服务的状态:

$ docker kill webserver.2.x2u85bbcrxiplsvtmj08x69z5
webserver.2.x2u85bbcrxiplsvtmj08x69z5

在检测到故障后的1 秒钟,将会运行一个新的容器:

$ docker service ls
ID             NAME               MODE         REPLICAS   IMAGE          PORTS
n9e24dh4s573   global-webserver   global       1/1        nginx:alpine
k40w64wkmr5v   webserver          replicated   2/2        nginx:alpine

我们可以验证 Docker Swarm 如何管理容器问题:

$ docker service ps webserver
ID             NAME              IMAGE          NODE             DESIRED STATE   CURRENT STATE           ERROR                         PORTS
og3zh7h7ht9q   webserver.1       nginx:alpine   docker-desktop   Running         Running 16 hours ago
x02zcj86krq7   webserver.2       nginx:alpine   docker-desktop   Running         Running 4 minutes ago
x2u85bbcrxip    \_ webserver.2   nginx:alpine   docker-desktop   Shutdown        Failed 4 minutes ago    "task: non-zero exit (137)"

上面的代码片段展示了一个简短的历史记录,包含了失败的容器 ID 和为保持服务健康而创建的新容器。

正如你可能已经注意到的,Docker Swarm 中创建的容器都有与服务相关的前缀,后面跟着实例编号。这些有助于我们在需要执行节点维护任务时,识别哪些服务可能会受到影响。我们可以列出当前的容器,查看服务的任务是如何执行的:

$ docker container ls
CONTAINER ID   IMAGE          COMMAND                  CREATED          STATUS          PORTS     NAMES
52779dbe389e   nginx:alpine   "/docker-entrypoint.…"   13 minutes ago   Up 13 minutes   80/tcp    webserver.2.x02zcj86krq7im2irwivwzvww
9bc7a5df593e   nginx:alpine   "/docker-entrypoint.…"   4 hours ago      Up 4 hours      80/tcp    global-webserver.s3jekhby2s0vn1qmbhm3ulxzh.g72t1n2myffy0abl1bj57m6es
3d784315a0af   nginx:alpine   "/docker-entrypoint.…"   16 hours ago     Up 16 hours     80/tcp    webserver.1.og3zh7h7ht9qpv1xkip84a9gb

你必须记住我们正在运行容器;因此,服务可以继承我们与容器一起使用的所有参数(参见 第四章运行 Docker 容器)。一个有趣的点是,我们可以使用 Go 模板格式作为变量,将一些 Docker Swarm 内部键值包括进来,应用到我们的部署中:

  • .Service.ID.Service.Name.Service.Labels 键。使用这些服务标签可能会很有趣,特别是当你想在应用程序中标识或包含一些信息时。

  • .Node.ID.Node.Hostname

  • .Task.ID.Task.Name.Task.Slot,如果你想在某些应用程序的组件中管理容器的行为,这些可能会很有用。

让我们看一个快速示例,看看如何使用这些变量:

$ docker exec webserver.2.x02zcj86krq7im2irwivwzvww hostname
52779dbe389e
$ docker exec webserver.1.og3zh7h7ht9qpv1xkip84a9gb hostname
3d784315a0af

现在我们用一个新的主机名更新我们的服务:

$ docker service update --hostname="{{.Node.Hostname}}" webserver
webserver
overall progress: 2 out of 2 tasks
1/2: running   [==================================================>]
2/2: running   [==================================================>]
verify: Service converged

我们现在可以验证容器的主机名已经发生了变化。

让我们继续定义一个完整的应用程序,就像我们在独立环境中使用 Compose 时那样,只不过这次是跨集群运行。

审查堆栈和其他 Docker Swarm 资源

Docker Swarm 允许我们通过运行堆栈来部署具有多个服务的应用程序。这个新对象在 Compose YAML 文件中定义了应用程序的结构、组件、通信和与外部资源的交互。因此,我们将使用基础设施即代码IaC)在 Docker Swarm 编排器之上部署我们的应用程序。

重要说明

虽然我们使用的是 Compose YAML 文件,但并非所有的docker-compose键都可用。例如,depends_on键在堆栈中不可用,因为它们不包含任何依赖声明。这就是为什么在代码中准备应用程序逻辑如此重要的原因。健康检查将让你决定在某些组件失败时如何断开某些电路,但你应该在依赖组件上包括状态验证,例如当它们需要一些时间来启动时。Docker Compose 在独立服务器上运行应用程序的容器,而 Docker Swarm 堆栈则将应用程序的服务(容器)部署到整个集群中。

在堆栈 YAML 文件中,我们将声明应用程序的网络、卷和配置。我们可以通过一些修改使用任何 Compose 文件。实际上,如果你使用了一个不允许的键,Docker Swarm 模式下的 Docker 容器运行时会通知你并失败。其他键,例如depends_on,在使用 Docker Swarm 时的docker-compose文件中会被省略。这里是一个使用第五章的 Compose YAML 文件的示例,创建多容器应用程序。我们将使用docker stack deploy

$ docker stack deploy --compose-file  docker-compose.yaml test
Ignoring unsupported options: build
Creating network test_simplestlab
Creating service test_lb
Creating service test_db
Creating service test_app

如你所见,容器运行时通知我们一个不支持的键,Ignoring unsupported options: build。你可以使用 Compose YAML 文件来构建、推送,然后在应用程序中使用容器镜像,但你必须为你的镜像使用一个注册表。通过使用注册表,我们可以确保所有容器运行时都能获取到镜像。你可以下载镜像,将它们保存为文件,并将其复制到所有节点,但这不是一个可复现的过程,并且可能需要一些时间和精力来同步所有更改。使用注册表来维护所有可用镜像似乎是一个非常合理的做法。

现在我们可以查看已部署的堆栈及其服务:

$ docker stack ls
NAME      SERVICES   ORCHESTRATOR
test      3          Swarm
$ docker service ls
ID             NAME               MODE         REPLICAS   IMAGE                                 PORTS
4qmfnmibqqxf   test_app           replicated   0/1        myregistry/simplest-lab:simplestapp   *:3000->3000/tcp
asr3kwfd6b8u   test_db            replicated   0/1        myregistry/simplest-lab:simplestdb    *:5432->5432/tcp
jk603a8gmny6   test_lb            replicated   0/1        myregistry/simplest-lab:simplestlb    *:8080->80/tcp

注意到REPLICAS列显示所有服务为0/1;这是因为我们使用了一个虚拟的注册表和仓库。在这个示例中,容器运行时不会拉取镜像,因为我们使用的是一个不存在的内部注册表,但这仍然展示了如何部署一个完整的应用程序。与注册表交互可能需要使用--with-registry-auth来应用某些身份验证到我们的服务上。如果你使用的是私有注册表,应该使用凭证来拉取与每个服务关联的镜像。

你可能也已经意识到,所有服务的名称前缀都带有栈的名称,正如我们在第五章创建多容器应用程序》中学习的那样。

现在,让我们快速回顾一下集群范围内的配置管理。如我们所料,跨集群运行的应用程序可能需要大量的同步工作。每当我们创建一个带有某些配置或持久数据的服务时,都需要确保它在任何主机上都可用。Docker Swarm 通过管理集群内所有配置的同步帮助我们完成这一任务。Docker Swarm 提供了两种类型的对象来管理配置:机密和配置。由于我们已经学习了如何使用 Compose 来处理机密和配置,这里将简要回顾一下,因为我们将在本章的实验中使用它们。

机密

tmpfs 在 Linux 主机上)。这确保了容器终止时信息会被删除。它可以被视为易失性的,因此仅在运行中的容器中可用。默认情况下,机密作为文件挂载,路径为/run/secrets/<SECRET_NAME>,并包含机密对象的内容。该路径可以更改,文件权限和所有权也可以修改。

我们可以在环境变量中使用机密,这没问题,因为它们仅在容器内部可见。然而,你也可以使用机密来存储完整的配置文件,即使其中并非所有内容都需要加密。机密只能包含 500 KB 的数据,因此,如果你认为不够,可能需要将配置拆分为不同的机密。

机密可以像其他 Docker 容器对象一样被创建、列出、删除、检查等,但不能被更新。

重要说明

由于机密是加密的,docker secret inspect 将显示它们的标签和其他相关信息,但数据本身是不可见的。需要理解的是,机密不能被更新,因此如果需要,应该重新创建它们(删除并重新创建)。

配置

配置类似于机密对象,但它们没有加密且可以更新。这使得它们成为轻松重新配置应用程序的完美选择,但请记住,始终删除任何敏感信息,例如包含密码可见的连接字符串、令牌等,这些信息可能被攻击者用来利用你的应用程序。配置对象也以明文形式存储在 Docker Swarm Raft 日志数据库中;因此,攻击者如果能访问 Docker Swarm 信息,就可以查看它们(记住,这些信息可以通过密码保护)。这些文件的最大大小为 500 KB,但你可以包括二进制文件。

配置对象将像绑定挂载的文件一样,挂载到容器内,归主进程用户所有,并具有所有读取权限。与密钥一样,配置挂载文件的权限和所有权可以根据自己的需求进行更改。

在这两种情况下,Docker Swarm 会在集群范围内同步这些对象,我们无需额外操作。

重要提示

在这两种情况下,你可以决定密钥或配置文件的挂载路径及其所有者和权限。请注意你赋予文件的权限,并确保仅授予读取文件所需的最小权限。

现在我们将了解如何将我们的应用程序在内部和外部发布,以及应用程序的服务如何在集群范围内宣布。

使用 Docker Swarm 进行网络连接和应用暴露

我们已经了解了容器运行时如何通过设置网络命名空间和附加到主机桥接网络接口的虚拟接口,为容器提供网络功能。所有这些功能和过程在 Docker Swarm 中也能正常工作,但主机之间的通信也是必需的,这正是 overlay 网络的作用。

理解 Docker Swarm 的 overlay 网络

为了管理整个集群的通信,将提供一个新的网络驱动程序overlay。overlay 网络通过在所有集群主机之间设置 UDP VXLAN 隧道来工作。这些通信可以加密,但会有一些额外的开销,Docker Swarm 会为所有容器设置路由层。Docker Swarm 只负责 overlay 网络,而容器运行时将管理所有其他本地范围的网络。

一旦初始化了 Docker Swarm 集群,两个新网络将会出现,docker_gwbridge(桥接)和 ingress(overlay),它们有不同的功能。第一个用于互联所有容器运行时,第二个用于管理所有服务流量。如果在创建服务时没有提供额外的网络,则默认情况下,所有服务将会连接到 ingress 网络。

重要提示

如果你在使用 Docker Swarm 时遇到问题,检查防火墙是否阻止了 overlay 网络通信;2377/TCP(集群管理流量)、7946/TCP-UDP(节点间通信)和4789/UDP(overlay 网络)流量应被允许。

所有连接到同一 overlay 网络的服务将可以被其他连接到同一网络的服务访问。我们还可以运行连接到这些网络的容器,但请记住,独立容器不会由 Docker Swarm 管理。默认情况下,所有 overlay 网络将是未加密且不可连接的(独立容器无法连接);因此,我们需要在创建 overlay 网络时,传递--opt encrypted --attachable参数与--driver overlay(创建 overlay 网络所必需)一起,以加密它们并使其可连接。

我们可以创建不同的叠加网络来隔离我们的应用程序,因为连接到一个网络的容器将无法看到连接到另一个网络的容器。建议在生产环境中隔离应用程序,并在需要时通过将服务连接到多个网络来定义任何允许的通信。可以使用子网或子网内的 IP 地址范围等配置来创建自定义网络,但请记得指定--driver参数以确保创建叠加网络。

现在让我们看看如何访问和发布我们的服务。

使用服务发现和内部负载均衡

Docker Swarm 提供了自己的内部 IPAM 和 DNS。每个服务都会从连接的网络范围内获得一个 IP 地址,并为其创建一个 DNS 条目。还提供了内部负载均衡功能,将请求分发到服务的副本。因此,当我们访问服务的名称时,可用的副本将接收我们的流量。然而,作为开发者,你无需管理任何内容——Docker Swarm 会为你处理一切——但你必须确保应用程序的组件连接到适当的网络,并且使用正确的服务名称。内部负载均衡器接收流量并将请求路由到服务的任务容器。切勿在应用程序中使用容器的 IP 地址,因为它可能会发生变化(容器会终止并创建新的容器),但服务的 IP 地址在你重新创建服务之前会保持不变(即删除并重新创建一个新的服务)。服务的 IP 地址由内部 IPAM 从特定的地址集分配。

发布你的应用程序

你可能会问,默认创建的叠加ingress网络怎么样?嗯,这个网络将用于发布我们的应用程序。正如我们在独立环境中学到的那样,容器可以连接到一个网络并在内部暴露其进程的端口,但我们也可以通过使用–publish选项将其暴露到外部。在 Docker Swarm 中,我们也有相同的行为。如果没有暴露端口,镜像中声明的端口将被内部发布(你可以覆盖端口定义,但可能无法访问你的应用程序)。然而,我们也可以将服务的容器公开到外部,暴露其进程到30000-32767范围内的随机端口或一个特定定义的端口(如往常一样,每个容器可以发布多个端口)。

所有节点都会参与叠加的ingress网络,发布的容器端口将在所有可用主机上使用端口 NAT 进行连接。Docker Swarm 使用网状结构提供内部 OSI 三层路由,引导请求到所有可用服务的任务。因此,我们可以在任何集群主机上通过定义的发布端口访问我们的服务,即使它们没有正在运行的服务容器。

可以使用外部负载均衡器来分配 IP 地址,并将客户端的请求转发到特定的集群主机(足以为我们的服务提供高可用性)。

让我们通过创建一个新服务并将容器端口 80 发布到主机端口 1080 来快速查看一个示例:

$ docker service create --name webserver-published \
--publish published=1080,target=80,protocol=tcp \
--quiet nginx:alpine
gws9iqphhswujnbqpvncimj5f

现在,我们可以验证它的状态:

$ docker service ls
ID             NAME                  MODE         REPLICAS   IMAGE                                 PORTS
gws9iqphhswu   webserver-published   replicated   1/1        nginx:alpine                          *:1080->80/tcp

我们可以在任何集群主机上测试端口 1080(我们在 Docker Desktop 上只有一台主机):

$ curl 0.0.0.0:1080 -I
HTTP/1.1 200 OK
Server: nginx/1.23.4
Date: Fri, 12 May 2023 19:05:43 GMT
Content-Type: text/html
Content-Length: 615
Last-Modified: Tue, 28 Mar 2023 17:09:24 GMT
Connection: keep-alive
ETag: "64231f44-267"
Accept-Ranges: bytes

正如我们所看到的,容器可在主机的端口上访问。实际上,在一个拥有多个主机的集群中,这个端口在所有主机上都可以访问,这是 Docker Swarm 中发布应用的默认机制。不过,在这个编排器中还有其他方法可以发布应用:

  • host 模式允许我们仅在实际运行服务容器的节点上设置端口。使用此模式,我们可以通过设置标签来指定一组集群主机,在这些主机上运行服务实例,然后使用外部负载均衡器将客户端的流量转发到这些主机。

  • dnsrr 模式允许我们避免使用服务的虚拟 IP 地址;因此,不会设置来自 IPAM 的 IP 地址,服务的名称将直接与容器的 IP 地址相关联。我们可以使用 --endpoint-mode 参数来管理发布模式,在创建服务时使用它。在 dnsrr 模式下,内部 DNS 将使用 轮询解析。集群内部的客户端进程每次请求服务名称时,都会解析到不同的容器 IP 地址。

现在我们已经学习了如何将运行在 Docker Swarm 集群中的应用发布,以便集群内部和外部的应用可以使用,让我们继续回顾如何自动更新服务容器和其他属性。

更新您的应用服务

在本节中,我们将回顾 Docker Swarm 如何帮助我们的应用在推送更改时保持稳定性和可用性。重要的是要理解,不管我们使用什么平台来运行容器,都需要能够修改应用内容,以修复问题或添加新功能。在生产环境中,这可能会更加受限,但自动化应该也能做到这一点,确保供应链的安全。

Docker Swarm 提供了一个滚动更新功能,可以在不打断当前副本的情况下部署新更改,并且在更新出错时会自动切换回旧的配置(回滚)。

作为开发者,你必须考虑哪种更新方法最适合你的应用程序。记住,如果你想避免任何停机,应该部署多个副本。通过设置更新并行度(--update-parallelism)、容器更新之间的延迟时间(--update-delay)以及部署变更的顺序(--update-order)——这允许我们在启动新容器之前停止之前的容器(默认),或者做相反的操作——我们可以确保在应用变更时服务的健康状况。理解这一点非常重要:你的应用程序必须允许你同时运行多个容器副本,因为这可能需要同时访问一个卷。记住,如果你的进程不允许这样做,这可能会破坏你的应用数据(例如,数据库可能会损坏)。

当我们的服务部署了多个副本时,例如一个无状态的前端服务,决定在升级过程中遇到问题时应该怎么做是非常重要的。

默认情况下,Docker Swarm 将在开始监控每个任务更新的状态之前等待五秒钟。如果你的应用程序需要更多时间才能被认为是健康的,你可能需要通过使用--update-monitor参数设置一个合适的值。

更新过程默认按以下方式工作:

  1. Docker Swarm 停止第一个服务的容器(第一个副本/任务;容器的后缀显示任务编号)。

  2. 然后,更新将为这个停止的任务触发。

  3. 一个新的容器开始更新任务。

  4. 然后,可能会出现两种情况:

    • 如果过程顺利,任务的更新会返回RUNNING。然后,Docker Swarm 会等待定义的更新间隔时间,并再次触发下一个服务任务的更新过程。

    • 如果过程失败,例如容器没有正确启动,更新任务会返回FAILED,当前的服务更新过程会被暂停。

  5. 当服务更新过程暂停时,我们必须决定是手动回滚到先前的版本(配置、容器镜像等——实际上是自上次正确更新以来部署的任何更改),还是再次执行新的更新。

  6. 我们将使用--update-failure-action参数来自动化处理在更新过程中出现问题时的过程。这个选项允许我们在某些容器失败的情况下,仍然继续更新,暂停更新过程(默认),或者在发生任何错误时自动触发回滚

强烈建议你测试部署和更新,以便清楚地了解在失败的情况下你的应用程序可能会受到哪些影响。

所有定义更新过程的选项在回滚过程中也适用;因此,即使在触发服务更改时,我们也有许多选项来管理应用程序的稳定性。

在接下来的部分中,我们将为 Docker Swarm 准备一个应用程序,并回顾本章学习的一些功能。

实验室

以下实验将帮助你在 Docker Swarm 集群上部署一个简单的演示应用程序,以回顾这个容器编排器提供的最重要功能。实验代码可在本书的 GitHub 仓库中找到,链接是 github.com/PacktPublishing/Containers-for-Developers-Handbook.git。确保你获取到最新版本,只需执行 git clone https://github.com/PacktPublishing/Containers-for-Developers-Handbook.git 下载所有内容,或者如果你之前已经下载过该仓库,只需执行 git pull。更多实验也包括在 GitHub 中。所有在这些实验中使用的命令和内容都位于 Containers-for-Developers-Handbook/Chapter7 目录下。

我们将从部署我们自己的 Docker Swarm 集群开始。

部署单节点 Docker Swarm 集群

在这个实验中,我们将使用 Docker Desktop 环境创建一个单节点的 Docker Swarm 集群。

重要说明

部署一个单节点集群足以回顾本章学习的最重要功能,但当然,我们无法将服务任务移到其他节点。如果你对这种情况感兴趣,并想回顾高级容器调度场景,可以根据本章文件夹中 multiple-nodes-cluster.md Markdown 文件中描述的任何方法部署多节点集群。

要创建一个单节点的 Docker Swarm 集群,我们将按照以下步骤进行:

  1. 使用 docker CLI 和 swarm 对象。在本示例中,我们将使用默认的 IP 地址值来初始化一个 Docker Swarm 集群:

    $ docker swarm init
    docker swarm join-token manager and follow the instructions.
    
  2. 我们现在可以验证当前的 Docker Swarm 节点:

    $ docker node ls
    ID                            HOSTNAME         STATUS    AVAILABILITY   MANAGER STATUS   ENGINE VERSION
    pyczfubvyyih2kmeth8xz9yd7 *   docker-desktop   Ready     Active         Leader           20.10.22
    
  3. 创建了 Overlay 和特定的桥接网络,我们可以通过列出可用的网络轻松验证:

    $ docker network ls
    NETWORK ID     NAME              DRIVER    SCOPE
    75aa7ed7603b   bridge            bridge    local
    9f1d6d85cb3c   docker_gwbridge   bridge    local
    07ed8a3c602e   host              host      local
    7977xslkr9ps   ingress           overlay   swarm
    cc46fa305d96   none              null      local
    
  4. 这个集群只有一个节点,因此该节点是管理节点(领导者),同时也充当工作节点(默认情况下):

    $ docker node inspect docker-desktop \
    --format="{{ .Status }}"
    {ready  192.168.65.4}
    $ docker node inspect docker-desktop \
    --format="{{ .ManagerStatus }}"
    {true reachable 192.168.65.4:2377}
    

这个集群现在已经准备好运行 Docker Swarm 服务了。

回顾 Docker Swarm 服务的主要功能

在这个实验中,我们将通过运行一个复制的全局服务来回顾一些最重要服务的特性:

  1. 我们将通过使用 Docker Hub 的 nginx:alpine 容器镜像来创建一个简单的 webserver 服务:

    $ docker service create --name webserver nginx:alpine
    m93gsvuin5vly5bn4ikmi69sq
    overall progress: 1 out of 1 tasks
    1/1: running   [==================================================>]
    docker service ls:
    
    

    $ docker service ls

    ID             名称        模式         副本数   镜像         端口

    m93gsvuin5vl   webserver   复制模式   1/1        nginx:alpine

    $ docker service ps webserver

    ID             名称         镜像         节点             期望状态   当前状态                错误     端口

    webserver.1 并且它运行在 docker-desktop 节点上;我们可以通过列出该节点上的容器来验证相关容器:

    $ docker container ls
    CONTAINER ID   IMAGE          COMMAND                  CREATED         STATUS         PORTS     NAMES
    63f1dfa649d8   nginx:alpine   "/docker-entrypoint.…"   3 minutes ago   Up 3 minutes   80/tcp    webserver.1.l38u6vpyq5zo9qfyc70g2411x
    

    跟踪与服务关联的容器很容易。我们仍然可以直接使用容器运行时运行容器,但这些容器不会由 Docker Swarm 管理。

    
    
  2. 现在我们通过添加一个新任务来复制这个服务:

    $ docker service update --replicas 3 webserver
    webserver
    overall progress: 3 out of 3 tasks
    1/3: running   [==================================================>]
    2/3: running   [==================================================>]
    3/3: running   [==================================================>]
    docker service ps webserver again:
    
    

    $ docker service ps webserver

    ID 名称 镜像 节点 期望状态 当前状态 错误 端口

    l38u6vpyq5zo webserver.1 nginx:alpine docker-desktop 运行中 大约一个小时前运行

    j0at9tnwc3tx webserver.2 nginx:alpine docker-desktop 运行中 4 分钟前运行

    vj6k8cuf0rix webserver.3 nginx:alpine docker-desktop 运行中 4 分钟前运行

    
    
  3. 每个容器都有自己的 IP 地址,我们在发布服务时会访问每一个。我们通过查看服务的日志来验证所有容器是否正确启动:

    $ docker service logs webserver --tail 2
    webserver.1.l38u6vpyq5zo@docker-desktop    | 2023/05/14 09:06:44 [notice] 1#1: start worker process 31
    webserver.1.l38u6vpyq5zo@docker-desktop    | 2023/05/14 09:06:44 [notice] 1#1: start worker process 32
    webserver.2.j0at9tnwc3tx@docker-desktop    | 2023/05/14 09:28:02 [notice] 1#1: start worker process 33
    webserver.2.j0at9tnwc3tx@docker-desktop    | 2023/05/14 09:28:02 [notice] 1#1: start worker process 34
    webserver.3.vj6k8cuf0rix@docker-desktop    | 2023/05/14 09:28:02 [notice] 1#1: start worker process 32
    webserver service:
    
    

    $ docker service update \

    --publish-add published=8080,target=80 webserver

    webserver

    总体进度:3/3 任务完成

    1/3: 正在运行 [==================================================>]

    2/3: 正在运行 [==================================================>]

    3/3: 正在运行 [==================================================>]

    
    We can review the service’s status again and see that the instances were recreated:
    
    

    $ docker service ps webserver

    ID 名称 镜像 节点 期望状态 当前状态 错误 端口

    u7i2t7u60wzt webserver.1 nginx:alpine docker-desktop 运行中 26 秒前运行

    l38u6vpyq5zo _ webserver.1 nginx:alpine docker-desktop 已关机 29 秒前关机

    i9ia5qjtgz96 webserver.2 nginx:alpine docker-desktop 运行中 31 秒前运行

    j0at9tnwc3tx _ webserver.2 nginx:alpine docker-desktop 已关机 33 秒前关机

    9duwbwjt6oow webserver.3 nginx:alpine docker-desktop 运行中 35 秒前运行

    80 已分配给一个随机主机端口):

    $ docker service ls
    ID             NAME        MODE         REPLICAS   IMAGE          PORTS
    curl:
    
    

    $ curl localhost:8080 -I

    HTTP/1.1 200 OK

    服务器:nginx/1.23.4

    ...

    使用 curl 命令访问多个服务的副本。

    
    
    
    
  4. 现在我们可以再次检查日志:

    $ docker service logs webserver --tail 2
    ...
    webserver.2.afp6z72y7y1p@docker-desktop    | 10.0.0.2 - - [14/May/2023:10:36:11 +0000] "HEAD / HTTP/1.1" 200 0 "-" "curl/7.81.0" "-"
    ...
    webserver.3.ub28rsqbo8zq@docker-desktop    | 10.0.0.2 - - [14/May/2023:10:38:11 +0000] "HEAD / HTTP/1.1" 200 0 "-" "curl/7.81.0" "-"
    ...
    

    正如您可能已经注意到的那样,多个副本已被访问;因此,内部负载均衡按预期工作。

  5. 我们通过删除创建的服务来结束这个实验:

    $ docker service rm webserver
    webserver
    

本实验展示了如何部署和修改一个简单的复制服务。您可能会对部署您自己的全局服务并查看它们之间的差异感兴趣。

现在我们将使用 Compose YAML 文件运行一个简单的应用程序。

使用 Docker 部署完整应用程序

在这个实验中,我们将使用堆栈对象运行一个完整的应用程序。仔细查看我们将用来部署应用程序的 YAML 文件:

  1. 我们首先创建一些我们将在堆栈中使用的秘密对象:

    $ echo demo|docker secret create dbpasswd.env -
    2tmqj06igbkjt4cot95enyj53
    $ docker secret create dbconfig.json dbconfig.json
    echo to create the secret and included only the demo string inside the dbpasswd.env secret, while we included a complete JSON file in the dbconfig.json secret. These secret names can be changed because we will use the full format to reference them in the Compose file.
    
  2. 为了使用我们自己的数据结构创建一个初始数据库,我们添加了一个新的配置 init-demo.sh,以覆盖镜像中包含的文件:

    $ docker config create init-demo.sh init-demo.sh
    services section will contain all the services to create. First, we have the lb service:
    
    

    版本: "3.9"

    服务:

    lb:

    镜像: frjaraur/simplestlab:simplestlb

    环境:# 这些环境定义是明文的,因为它们不管理任何敏感数据

    • APPLICATION_ALIAS=app # 我们使用服务的名称

    • APPLICATION_PORT=3000

    网络:

    simplestlab:

    端口:

    • target: 80

    发布端口:8080

    协议:tcp

    
    After the `lb` service, we have the definition for the database. Each service’s section includes the container image, the environment variables, and the networking features of the service:
    
    

    db:

    image: frjaraur/simplestlab:simplestdb

    environment: # Postgres 镜像允许使用密码文件。

    • POSTGRES_PASSWORD_FILE=/run/secrets/dbpasswd.env

    networks:

    simplestlab:

    secrets:

    • dbpasswd.env

    configs: # 我们加载一个 initdb 脚本来初始化我们的演示数据库。

    • source: init-demo.sh

    target: /docker-entrypoint-initdb.d/init-demo.sh

    mode: 0770

    volumes:

    • pgdata:/var/lib/postgresql/data
    
    Notice that this component includes `secrets`, `configs`, and `volumes` sections. They allow us to include data inside the application’s containers. Let’s continue with the `app` service:
    
    

    app:

    image: frjaraur/simplestlab:simplestapp

    secrets: # 密钥用于将数据库连接集成到我们的应用程序中。

    • source: dbconfig.json

    target: /APP/dbconfig.json

    mode: 0555

    networks:

    simplestlab:volumes:

    pgdata: # 这个卷应当从可供其他主机使用的网络资源挂载,或者在节点之间同步内容

    
    At the end of the file, we have the definitions for `networks`, `configs`, and `secrets` included in each service definition:
    
    

    networks:

    simplestlab:

    configs:

    init-demo.sh:

    external: true

    secrets:

    dbpasswd.env:

    external: true

    dbconfig.json:

    external: true

    
    You may notice that all secrets and configs are defined as external resources. This allows us to create them outside of the stack. It is not a good idea to include the sensitive content of secrets in cleartext in your Compose YAML files.
    

重要提示

我们没有使用网络卷,因为我们使用的是单节点集群,因此不需要它。但如果您计划在集群中部署更多节点,则必须准备网络存储或集群范围的同步解决方案,以确保数据在数据库组件运行的地方都可用。否则,您的数据库服务器将无法正确启动。

  1. 现在我们可以将 Compose YAML 文件部署为 Docker stack:

    $ docker stack deploy -c docker-compose.yaml chapter7
    Creating network chapter7_simplestlab
    Creating service chapter7_db
    Creating service chapter7_app
    Creating service chapter7_lb
    
  2. 我们验证已部署 stack 的状态:

    $ docker stack ps chapter7
    ID             NAME             IMAGE                              NODE             DESIRED STATE   CURRENT STATE           ERROR     PORTS
    zaxo9aprs42w   chapter7_app.1   frjaraur/simplestlab:simplestapp   docker-desktop   Running         Running 2 minutes ago
    gvjyiqrudi5h   chapter7_db.1    frjaraur/simplestlab:simplestdb    docker-desktop   Running         Running 2 minutes ago
    tyixkplpfy6x   chapter7_lb.1    frjaraur/simplestlab:simplestlb    docker-desktop   Running         Running 2 minutes ago
    
  3. 现在我们查看哪些端口可用于访问我们的应用程序:

    $ docker stack services chapter7
    ID             NAME           MODE         REPLICAS   IMAGE                              PORTS
    dmub9x0tis1w   chapter7_app   replicated   1/1        frjaraur/simplestlab:simplestapp
    g0gha8n57i7n   chapter7_db    replicated   1/1        frjaraur/simplestlab:simplestdb
    chapter7 application stack using our browser (http://localhost:8080):
    

图 7.1 – 应用程序可通过 http://localhost:8080 访问

图 7.1 – 应用程序可通过 http://localhost:8080 访问

在使用 docker stack rm chapter7 删除应用程序之前,您可能会想尝试扩展和缩小应用程序组件,并更改一些内容(您已部署代码、配置和密钥)。这将帮助您实验 Docker Swarm 如何管理滚动更新和回滚。

这个实验帮助您理解了如何将 Docker Compose 文件参数化,以将一个完整的应用程序部署到 Docker Swarm 集群中。

总结

在这一章中,我们介绍了 Docker Swarm 的基本使用方法。我们学习了如何部署一个简单的集群,并利用 Docker Swarm 的功能来运行我们的应用程序。我们学习了如何使用 Compose YAML 文件来部署 stacks,并通过服务和任务完全定义一个应用程序,最终执行其容器。Docker Swarm 管理集群范围内复杂的网络通信,帮助我们发布应用程序供用户或其他应用程序访问。它还提供了机制,确保即使触发组件更新(例如更改容器镜像),我们的应用程序也能保持可用。

在下一章,我们将学习 Kubernetes 的基础知识,这是目前最流行、最先进的容器编排工具。

第八章:8

使用 Kubernetes 编排器部署应用

在工作站或笔记本电脑上为你的应用开发容器,确实能通过运行其他应用的组件来改善开发过程,让你专注于自己的代码。这种简单的独立架构在开发阶段非常适用,但它并不为你的应用提供 高可用性HA)。在集群范围内部署容器编排器将帮助你保持应用的健康运行。在上一章中,我们简要回顾了 Docker Swarm,它更简单,是进入更复杂的编排器平台的好入门平台。在本章中,我们将学习如何在 Kubernetes 上准备并运行我们的应用,Kubernetes 现在被认为是运行集群容器的标准。

在本章中,我们将涵盖以下内容:

  • 介绍 Kubernetes 的主要特点

  • 理解 Kubernetes 的高可用性(HA)

  • 使用 kubectl 与 Kubernetes 交互

  • 部署一个功能齐全的 Kubernetes 集群

  • 创建 Pods 和 Services

  • 部署编排资源

  • 使用 Kubernetes 提高应用的安全性

技术要求

本章的实验可以在 github.com/PacktPublishing/Containers-for-Developers-Handbook/tree/main/Chapter8 找到,在这里你将看到一些扩展的解释,这些内容在本章的正文中被省略,以便更易于跟进。关于本章的 Code In Action 视频可以在 packt.link/JdOIY 找到。

现在,让我们从了解 Kubernetes 的主要特点开始,看看为什么这个编排器如此受欢迎。

介绍 Kubernetes 的主要特点

我们可以毫无疑问地说,Kubernetes 已经成为基于容器部署应用的新标准。然而,它的成功并非一蹴而就;Kubernetes 于 2015 年作为一个社区项目启动,基于 Google 自有的工作负载编排器 Borg。Kubernetes GitHub 仓库中的第一次提交发生在 2014 年,一年后,发布了第一个版本。两年后,Kubernetes 凭借其强大的社区成为主流。我必须说,你可能不会单独使用 Kubernetes;你将部署多个组件,以实现一个完全功能的平台,但这并不是什么坏事,因为你可以根据具体需求定制 Kubernetes 平台。此外,Kubernetes 默认与云平台有很多集成,因为它从一开始就考虑到了这些平台。例如,云存储解决方案可以无需额外组件即可使用。

让我们稍作停顿,简单比较一下 Kubernetes 的特点与 Docker Swarm 的区别。

比较 Kubernetes 和 Docker Swarm

我必须声明,个人而言,我对 Kubernetes 的初步印象并不好。对我来说,它提供了许多功能来解决一些简单的任务,而这些任务当时我可以通过 Docker Swarm 解决。然而,随着应用程序变得越来越复杂,你需要更多的功能,Docker Swarm 最终变得过于简单,因为它并没有太多发展。Docker Swarm 对于简单项目来说表现良好,但微服务架构通常需要复杂的交互和大量的可移植性功能。Kubernetes 的学习曲线非常陡峭,并且它在不断发展,这意味着你几乎每天都需要跟进该项目。Kubernetes 的核心功能通常会在每个新版本中得到改进,许多可插拔功能和附加项目也会不断出现,这使得该平台的生态系统日益增长。

我们将看到 Docker Swarm 编排模型与 Kubernetes 模型之间的一些差异。我们可以从集群中工作负载的定义开始。我们在第七章《使用 Swarm 进行编排》中提到过,Docker Swarm 并不调度容器;实际上,它调度的是服务。在 Kubernetes 中,我们调度的是Pods,这是这个编排系统中的最小调度单元。一个 Pod 可以包含多个容器,尽管大多数情况下它只运行一个。我们将在本章后面深入探讨并学习更多关于 Pods 的内容。

探索控制平面

容器编排器应提供一个控制平面来处理所有管理任务,提供调度能力,以便在数据平面上执行我们的应用工作负载,并提供集群范围的网络功能。Kubernetes 控制平面组件旨在管理每个集群组件,调度工作负载,并审查平台中出现的事件。它还管理节点组件,实际通过容器运行时执行容器。Kubernetes 遵循与 Docker Swarm 相似的管理者-工作者模型,定义了两种不同的节点角色。管理节点将管理控制平面组件,而工作节点将执行控制平面节点分配的任务。

现在,让我们回顾一些关键进程。

了解关键进程

以下是 Kubernetes 控制平面中运行的关键进程列表:

  • kube-apiserver:API 服务器是与所有其他组件和用户进行交互的组件。组件之间没有直接的通信,因此 kube-apiserver 在每个 Kubernetes 集群中都是至关重要的。所有集群管理通过暴露该组件的 API 提供,我们可以使用不同的客户端与集群进行交互。不同的端点使我们能够检索和设置 Kubernetes 资源。

  • etcd:这是一个为所有集群组件提供数据存储的组件。它是一个键值存储,可以通过其 HTTP REST API 进行访问。这个可靠的键值存储包含敏感数据,但如前所述,只有 kube-apiserver 组件可以访问它。

  • kube-scheduler:负责将工作负载分配给节点上部署的容器运行时。为了决定哪些节点运行不同的容器,kube-scheduler 会向 kube-apiserver 请求集群中所有节点的硬件资源和可用性信息。

  • kube-controller-manager:不同的控制器进程在 Kubernetes 集群内运行,负责维护平台和运行在其中的应用程序的状态。kube-controller-manager 负责管理这些控制器,并将不同的任务委派给每个控制器:

    • 节点控制器管理节点的状态。

    • job 控制器负责管理工作负载的任务,并创建 Pod 来运行它们。

    • 端点控制器创建端点资源以暴露 Pod。

    • 服务账户控制器令牌控制器管理账户和 API 访问令牌授权。

  • cloud-controller-manager:这是一个独立的组件,管理与底层云提供商 API 交互的不同控制器:

    • 节点控制器管理部署在云提供商上的节点的状态和健康状况。

    • 路由控制器通过使用特定 API 在云提供商中创建路由,以访问你部署的工作负载。

    • 服务控制器管理云提供商的负载均衡器资源。你不会在本地数据中心部署这个组件,因为它是为云集成设计的。

接下来,让我们回顾一下节点组件,它们负责执行并展示工作负载进程。

理解节点组件。

节点组件运行在工作节点上(如同在 Docker Swarm 中,管理节点也可以具有工作角色)。让我们深入了解一下它们:

  • 容器运行时:运行容器的运行时是关键,因为它将为我们执行所有工作负载。Kubernetes 调度器将在每个主机上调度 Pod,它们为我们运行容器。

  • kubelet:我们可以将 kubelet 视为 Kubernetes 的集成代理。所有具有工作角色的节点都必须运行 kubelet,以便与控制平面通信。事实上,控制平面将管理与工作节点的通信,以接收每个工作节点的健康状况和运行负载的状态。kubelet 只会管理部署在 Kubernetes 集群中的容器;换句话说,你仍然可以在工作节点的容器运行时中执行容器,但这些容器不会由 Kubernetes 管理。

  • kube-proxy:这个组件负责 Kubernetes 的通信。需要在此提及的是,Kubernetes 本身并没有提供完整的网络功能,这个组件仅管理 Kubernetes 集群内的服务资源集成。要实现一个完全功能的集群,还需要其他通信组件。可以公平地说,kube-proxy 在工作节点级别工作,发布集群内的应用程序,但为了访问部署在其他集群节点上的其他服务,还需要更多的组件。

重要提示

服务资源(或简称 Service)旨在使应用程序的 Pods 可访问。有多种选项可以将我们的应用程序发布到内部或外部供用户使用。服务资源将获得自己的 IP 地址来访问相关 Pods 的端点。我们可以将服务资源视为逻辑组件。我们将使用服务来访问我们的应用程序,因为 Pods 可能会消失并被重新创建,获得新的 IP 地址,但服务将始终保持可见,并具有指定的 IP 地址。

当需要时,工作节点可以被替换;我们可以在需要时执行维护任务,将工作负载从一个节点移动到另一个节点。然而,控制平面组件不能被替换。为了实现 Kubernetes 的高可用性(HA),我们需要执行多个控制平面组件的副本。在 etcd 的情况下,我们必须拥有奇数个副本,这意味着至少需要三个副本才能实现 HA。这个要求使我们需要至少三个管理节点(或 Kubernetes 术语中的主节点)来部署具有 HA 的 Kubernetes 集群,尽管其他组件可以通过两个副本提供 HA。相反,工作节点的数量可以变化,这真的取决于你应用程序的 HA,尽管始终推荐至少两个工作节点。

Kubernetes 网络

需要记住的是,Kubernetes 的网络架构与 Docker Swarm 模型有所不同。Kubernetes 本身并不提供集群范围的通信,但提供了一个标准化接口,即 容器网络接口CNI)。Kubernetes 定义了一组规则,任何集成通信接口的项目都必须遵循这些规则:

  • 使用 localhost 来解决它们的内部通信问题。当多个容器需要协同工作时,这确实简化了通信。

  • 主机到容器通信:每个主机可以使用其容器运行时与本地运行的 Pods 进行通信。

  • Pod 到 Pod 通信:这些通信在本地工作,但无法跨集群工作,Kubernetes 强制要求通信必须在没有任何 网络地址转换NAT)的情况下进行。这是 CNI 必须解决的问题。

  • Pod 到 Service 的交互:Pod 永远不会相互通信,因为它们的 IP 地址可能会随着时间变化。我们将使用 Services 来暴露 Pods,而 Kubernetes 会管理它们的 IP 地址,但 CNI 必须在集群范围内管理它们。

  • 发布服务:发布应用程序有多种方法,但它们通过 Service 类型和 Ingress 资源来解决,集群范围的通信必须包含在 CNI 中。

由于不允许使用 NAT,这种模型声明了一个平面网络,在 Kubernetes 部署中包含 CNI 后,Pods 可以彼此看到。这与 Docker Swarm 完全不同,在 Docker Swarm 中,应用程序或项目可以运行在隔离的网络中。在 Kubernetes 中,我们需要实现额外的机制来隔离我们的应用程序。

有许多 CNI 插件可用于实现这些集群范围的通信。你可以使用任何一个,但有些比其他的更受欢迎;以下列表显示了推荐的一些插件及其关键特性:

  • Flannel 是一个简单的覆盖网络提供者,开箱即用效果非常好。它在节点之间创建 VXLAN 来传播 Pods 的 IP 地址到集群范围,但它不提供网络策略。这些是 Kubernetes 资源,可以限制或允许 Pods 的连接性。

  • Calico 是一个网络插件,支持不同的网络配置,包括非覆盖网络和覆盖网络,可以使用或不使用 边界网关协议BGP)。该插件提供网络策略,并且足以满足几乎所有小型环境的需求。

  • Canal 默认在 SUSE 的 Rancher 环境中使用。它结合了 Flannel 的简单性和 Calico 的策略功能。

  • Cilium 是一个非常有趣的网络插件,因为它将 扩展伯克利数据包过滤器eBPF)Linux 内核特性集成到 Kubernetes 中。这个网络提供商适用于多集群环境,或者当你想将网络可观察性集成到你的平台时。

  • Multus 可用于在你的集群中部署多个 CNI 插件。

  • 云服务提供商提供了他们自己的云特定的 CNI,使我们能够实现不同的网络场景,并在我们自己的私有云基础设施中管理 Pods 的 IP 地址。

CNI 插件应该在 Kubernetes 控制平面启动后始终部署,因为一些组件(如内部 DNS 或 kube-apiserver)需要在集群范围内可访问。

命名空间范围隔离

Kubernetes 通过使用 命名空间 提供项目或应用程序的隔离,命名空间允许我们对资源进行分组。Kubernetes 提供了集群范围和命名空间范围的资源:

  • 集群范围 的资源是集群范围内可用的资源,我们可以将它们大多数视为集群管理资源,由集群管理员拥有。

  • 命名空间范围 的资源是那些仅限于命名空间级别的资源。例如,Services 和 Pods 是在命名空间级别定义的,而节点资源则是集群范围可用的。

命名空间资源是隔离应用程序并限制用户访问资源的关键。Kubernetes 提供不同的认证和授权方法,虽然我们可以集成和结合额外的组件,如外部轻量级目录访问协议LDAP)或微软的 Active Directory。

内部解析

内部 DNS 基于SERVICE_NAME.NAMESPACE.svc.cluster.local

将数据附加到容器

Kubernetes 包含不同类型的资源来将存储附加到我们的工作负载:

  • emptyDir)、主机存储和网络文件系统NFS),以及其他远程存储解决方案。其他非常重要的类卷资源包括 Secrets 和 ConfigMaps,分别可用于管理集群范围内的敏感数据和配置。

  • 持久卷是当你在本地数据中心进行生产工作时的首选解决方案。存储供应商提供自己的驱动程序,将网络附加存储NAS)和存储区域网络SAN)解决方案集成到我们的应用程序中。

  • 投影卷用于映射一个独特 Pod 容器目录中的多个卷。

为我们的应用程序提供持久存储是容器编排器中的关键,Kubernetes 与不同的动态配置解决方案集成得非常好。

发布应用程序

最后,我们将介绍Ingress资源的概念。这些资源通过将服务资源与特定应用程序的 URL 关联起来,简化并保障了在 Kubernetes 中运行应用程序的发布。需要一个 Ingress 控制器来管理这些资源,我们可以在这个组件中集成多种不同的选项,如 NGINX、Traefik,甚至更复杂的解决方案,如 Istio。值得注意的是,许多网络设备供应商也准备了自己的 Kubernetes 平台集成,提升了性能和安全性。

现在,我们已经快速了解了 Kubernetes,接下来可以深入探讨该平台的组件和功能。

理解 Kubernetes 的高可用性(HA)

使用 HA 部署我们的应用程序需要一个具有 HA 的 Kubernetes 环境。至少需要三个 etcd 副本和其他控制平面组件的两个副本。一些生产架构将 etcd 部署在专用主机上,而其他组件则部署在额外的主节点上。这完全隔离了键值存储与其余控制平面组件,提升了安全性,但也增加了环境的复杂性。通常,你会找到三个主节点和足够的工作节点来部署你的生产应用程序。

Kubernetes 安装配置并管理其自己的内部 证书颁发机构 (CA),然后为不同的控制平面和 kubelet 组件部署证书。这确保了 kube-apiserver 与其他组件之间的 TLS 通信。下图展示了单主节点场景下的 Kubernetes 不同组件架构:

图 8.1 – 带有高可用性的 Kubernetes 集群架构

图 8.1 – 带有高可用性的 Kubernetes 集群架构

工作节点是用于运行工作负载的节点。根据 Kubernetes 的安装方式,如果主节点同时运行 kubelet 和 kube-proxy 组件,你也可以在主节点上运行特定的工作负载。我们可以使用不同的亲和性和反亲和性规则来确定哪些节点最终应该执行集群中的容器。

然而,仅仅复制控制平面并不能为你的应用程序提供高可用性或弹性。你需要一个 CNI 来管理容器之间在集群范围内的通信。内部负载均衡将请求路由到 Kubernetes 集群中已部署的 Pods。

在不同的主机上运行应用程序需要合适的存储解决方案。每当容器使用容器运行时启动时,所需的卷应该被附加。如果你在本地部署,你可能会在你的基础设施中使用 容器存储接口 (CSI)。然而,作为开发者,你应该考虑你的存储需求,你的基础设施管理员会为你提供最佳的解决方案。不同的提供商会提供文件系统、块存储或对象存储,你可以选择最适合你的应用程序的存储方案。所有这些都将在集群范围内工作,并帮助你提供高可用性。

最后,你必须考虑你的应用程序组件如何与多个副本一起工作。你的基础设施为容器提供了弹性,但你的应用程序逻辑必须支持复制。

运行生产集群可能会很困难,但部署自己的集群来学习 Kubernetes 是如何工作的,我真的推荐给任何想在这些容器架构上部署应用程序的人。第一次创建 Kubernetes 集群时,推荐使用 kubeadm

Kubeadm Kubernetes 部署

Kubeadm 是一个可以轻松部署完全功能的 Kubernetes 集群的工具。事实上,我们甚至可以使用它来部署生产就绪的集群。

我们将从第一个部署节点初始化集群,执行 kubeadm init。这将创建并触发引导过程以部署集群。我们执行此操作的节点将成为集群的领导节点,我们只需执行 kubeadm join,即可加入新的主节点和工作节点。这极大简化了部署过程;创建集群所需的每个步骤都是自动化的。首先,我们将创建控制平面组件;因此,kubeadm join 将在其余指定的主节点上执行。一旦主节点安装完成,我们将加入工作节点。

Kubeadm 作为一个二进制文件安装在你的操作系统中。这里需要特别注意的是,Kubernetes 的主节点角色仅在 Linux 操作系统上可用。因此,我们不能仅通过 Microsoft Windows 或 macOS 节点来安装 Kubernetes 集群。

这个工具不仅仅是安装一个新的集群。它还可以用来修改当前 kubeadm 部署的集群配置或将其升级到更新的版本。这是一个非常强大的工具,了解如何使用它是很有帮助的,但不幸的是,这超出了本书的范围。可以简要地说,有许多命令行参数可以帮助我们完全定制 Kubernetes 的部署,例如用于管理控制平面内部通信的 IP 地址、Pods 的 IP 地址范围以及要使用的认证和授权模型。如果你有时间和硬件资源,建议使用 kubeadm 创建一个至少两节点的集群,以便了解部署过程和 Kubernetes 集群上默认部署的组件。

这是使用 kubeadm 工具进行 Kubernetes 部署过程的链接:kubernetes.io/docs/setup/production-environment/tools/kubeadm。在本书中,我们不会使用 kubeadm 来部署 Kubernetes。我们将使用 Docker Desktop、Rancher Desktop 或 Minikube 工具,它们提供完全自动化的部署,能够即刻在我们的笔记本电脑或台式电脑上工作。

Docker 或任何其他容器运行时只关心容器。我们在第七章使用 Swarm 进行编排 中学到了 Docker 如何提供命令行来管理 Docker Swarm 集群,但这在 Kubernetes 中不起作用,因为它是一个完全不同的平台。kube-apiserver 组件是管理员和最终用户唯一可以访问的组件。Kubernetes 社区项目提供了自己的工具来管理 Kubernetes 集群及其上部署的资源。因此,在下一小节中,我们将学习 kubectl 的基础知识,这是我们在本书中将用来管理集群中的配置、内容和工作负载的工具。

使用 kubectl 与 Kubernetes 交互

在本节中,我们将学习kubectl命令行的基础知识。它是官方的 Kubernetes 客户端,可以通过添加插件扩展其功能。

该工具的安装过程相当简单,因为它是一个用 Go 语言编写的单一二进制文件;因此,我们可以从官方 Kubernetes 二进制文件库下载它。要从此库下载,必须在 URL 中包含要使用的发布版本。例如,dl.k8s.io/release/v1.27.4/bin/linux/amd64/kubectl 将链接到 Kubernetes 1.27.4 的kubectl Linux 二进制文件。你可以使用来自不同发布版本的二进制文件来管理 Kubernetes 集群,尽管建议保持客户端和 Kubernetes 服务器版本的一致性。如何为每个平台安装该工具,请参见 https://kubernetes.io/docs/tasks/tools/#kubectl。由于我们将在实验室部分使用 Microsoft Windows,我们将使用以下链接来安装该工具的二进制文件:kubernetes.io/docs/tasks/tools/install-kubectl-windows

让我们通过学习使用此工具执行命令的语法来开始kubectl

kubectl [command] [TYPE] [NAME] [flags]

TYPE表示kubectl可以与许多不同的 Kubernetes 资源一起使用。我们可以使用单数、复数或缩写形式,且它们将以不区分大小写的方式使用。

在学习kubectl的一些用法之前,首先要了解的是如何配置访问任何集群。默认情况下,kubectl命令使用每个用户主目录下.kube目录中的config配置文件。我们可以通过添加--kubeconfig <FILE_PATH_AND_NAME>参数或设置KUBECONFIG变量来改变使用的配置文件位置。通过更改kubeconfig文件的内容,我们可以轻松地拥有不同的集群配置。然而,这种路径更改实际上并不必要,因为配置文件结构允许不同的上下文。每个上下文用于唯一地配置一组用户和服务器值,允许我们配置一个包含身份验证信息和 Kubernetes 集群端点的上下文。

重要提示

通常,你会通过 FQDN(或其解析后的 IP 地址)访问 Kubernetes 集群。这个名称或其 IP 地址将被负载均衡到所有 Kubernetes 集群的可用 kube-apiserver 实例;因此,集群服务器前会设置负载均衡器。在我们的本地环境中,我们将使用与集群关联的简单 IP 地址。

让我们来看一下配置文件的样子:

apiVersion: v1
kind: Config
clusters:
- cluster:
    certificate-authority-data: BASE64_CLUSTER_CA or the CA file path
    server: https://cluster1_URL:6443
  name: cluster1
contexts:
- context:
    cluster: cluster1
    user: user1
  name: user1@cluster1
current-context: user1@cluster1
users:
- name: user1
  user:
    client-certificate-data: BASE64_USER_CERTIFICATE or the cert file path
    client-key-data: BASE64_USER_KEY or the key file path

我们可以添加多个服务器和用户,并将它们链接到多个上下文中。我们可以通过使用kubectl config use-context CONTEXT_NAME在定义的上下文之间切换。

我们可以使用 kubectl api-resources 来获取定义集群中可用的资源类型。这很重要,因为 kubectl 命令行从 Kubernetes 集群中检索数据,因此它的行为会根据终端点的不同而发生变化。以下截图展示了样本 Kubernetes 集群中的 API 资源:

图 8.2 – 样本集群中的 Kubernetes API 资源

图 8.2 – 样本集群中的 Kubernetes API 资源

如你所见,有一列表示 Kubernetes 资源是否是命名空间限定的。这显示了资源必须定义的范围。资源可以具有集群范围,定义并在集群级别使用,也可以是命名空间限定的,在这种情况下,它们存在于 Kubernetes 命名空间内并按命名空间分组。Kubernetes 命名空间是允许我们在集群内隔离和分组资源的资源。定义在命名空间内的资源在命名空间内是唯一的,因为我们将使用命名空间来标识它们。

有许多 kubectl 命令可用,但在本节中我们将重点介绍其中的一些命令:

  • create:此操作允许我们从文件或终端 stdin 创建 Kubernetes 资源。

  • apply:此操作用于创建和更新 Kubernetes 中的资源。

  • delete:我们可以使用 kubectl delete 删除已创建的资源。

  • run:此操作可用于快速部署简单的工作负载并定义容器镜像。

  • get:我们可以使用 kubectl get 获取任何 Kubernetes 资源的定义。创建或检索任何 Kubernetes 对象需要有效的授权。我们还可以使用 kubectl describe,它会提供被检索集群资源的详细描述。

  • edit:我们可以修改一些资源的属性,以便在集群中进行更改。这也会改变我们应用程序的行为。

我们可以通过命令式声明式方法配置 Kubernetes 资源:

  • 在命令式配置中,我们通过命令行描述 Kubernetes 资源的配置,使用我们的终端进行操作。

  • 通过使用声明式配置,我们将创建一个描述资源配置的文件,然后将该文件的内容创建或应用到 Kubernetes 集群中。这种方法是可重复的。

现在我们对 Kubernetes 组件、安装过程、如何与集群交互以及运行功能性 Kubernetes 平台的要求有了基本的了解,接下来让我们看看如何轻松部署我们自己的环境。

部署一个功能性的 Kubernetes 集群

在本节中,我们将回顾不同的 Kubernetes 部署方法,以满足不同的需求。作为开发者,你不需要部署生产环境,但理解这个过程并能够创建一个最简环境来测试你的应用程序是非常重要的。如果你真的对整个过程感兴趣,建议你查看 Kelsey Hightower 的 GitHub 仓库,Kubernetes the Hard Wayhttps://github.com/kelseyhightower/kubernetes-the-hard-way)。在这个仓库中,你将找到逐步手动部署 Kubernetes 集群的完整过程。理解集群是如何创建的,真的有助于解决问题,尽管这超出了本书的范围。在这里,我们将回顾一些自动化的 Kubernetes 解决方案,你可以专注于代码而非平台本身。我们将从最流行的容器桌面解决方案开始这一节。

Docker Desktop

在本书中,我们使用 Docker Desktop 创建并运行容器,使用的是 Windows Subsystem for LinuxWSL)终端。Docker Desktop 还包含一个单节点的 Kubernetes 环境。让我们通过以下步骤开始使用它:

  1. 点击 设置 | 启用 Kubernetes。下图显示了如何在 Docker Desktop 环境中设置 Kubernetes:

图 8.3 – Docker Desktop 设置区域,可以启用 Kubernetes 集群

图 8.3 – Docker Desktop 设置区域,可以启用 Kubernetes 集群

在启用 Kubernetes 集群后,Docker Desktop 启动环境。下图显示了 Kubernetes 启动的时刻:

图 8.4 – Kubernetes 在 Docker Desktop 环境中的启动

图 8.4 – Kubernetes 在 Docker Desktop 环境中的启动

  1. 一旦启动,我们可以通过 WSL 终端使用 kubectl 命令行来访问集群。正如你可能注意到的,我们没有安装任何额外的软件。Docker Desktop 通过将所需的文件附加到我们的 WSL 环境中,为我们集成了命令。

    Kubernetes 集群的状态显示在 Docker Desktop GUI 的左下角,如下图所示:

图 8.5 – Kubernetes 状态在 Docker Desktop 中显示

图 8.5 – Kubernetes 状态在 Docker Desktop 中显示

  1. 我们可以通过执行 kubectl get nodes 来验证部署的 Kubernetes 集群中包含的节点数量:

    $ kubectl get nodes
    NAME             STATUS   ROLES           AGE   VERSION
    docker-desktop   Ready    control-plane   45m   v1.25.4
    

    请注意,为此环境还添加了一个 Kubernetes 配置文件:

    $ ls -lart ~/.kube/config
    -rw-r--r-- 1 frjaraur frjaraur 5632 May 27 11:21 /home/frjaraur/.kube/config
    

这非常简单,现在我们有一个功能完备的 Kubernetes 集群。这个集群不可配置,但它是你准备应用部署和进行测试所需的一切。这个解决方案不允许我们决定要部署哪个版本的 Kubernetes,但我们可以随时通过 Docker Desktop 的 Kubernetes 设置 页面重置环境,这在我们需要从头开始时非常有用。它可以部署在 Microsoft Windows(使用 虚拟机VM)和 Hyper-V 或 WSL,推荐使用 WSL,因为它消耗的资源更少)、macOS(支持 Intel 和 Apple Silicon 架构)或 Linux(使用带 内核虚拟机KVM)的虚拟机)上。

Docker 为我们准备了一些包含所有 Kubernetes 组件的镜像,并在我们启用 Docker Desktop 中的 Kubernetes 时为我们部署它们。默认情况下,所有为此目的创建的容器都在 Docker Desktop GUI 中隐藏,但我们可以通过 Docker 命令行查看它们。如果你使用 Docker 命令行创建应用程序,这种 Kubernetes 解决方案非常合适,因为从构建到协调执行所需的一切都已提供。我们可以使用不同类型的 Kubernetes 卷,因为在第十章在 Kubernetes 中利用应用数据管理中,有 storageClass 资源。不过,在此 Kubernetes 部署中省略了一些内容,这可能会影响你的工作,因此了解它的局限性是很有帮助的:

  • 环境内部的 IP 地址不能更改。

  • 你无法 ping 容器;这是由于 Docker Desktop 的网络设置,且它也会影响容器的运行时。

  • 不提供 CNI;因此,无法应用网络策略。

  • 默认情况下未提供 Ingress 资源。其实这并不是问题,因为其他桌面 Kubernetes 环境也不会提供它,但你可能需要部署自己的 Ingress,并修改你的 /etc/hosts 文件(或 Microsoft Windows 中的等效文件 C:\Windows\system32\drivers\etc\hosts)以访问你的应用程序。在第十一章发布应用程序 中,我们将了解 Ingress 资源和控制器。

这些是在使用 Docker Desktop 部署 Kubernetes 时你会遇到的较重要问题。启用 Kubernetes 后,性能会受到影响,且你需要至少 4 GB 的空闲内存和四个 虚拟 核心vCores)。

如官方文档所述,Docker Desktop 不是一个开源项目,它的授权基于 Docker 订阅服务协议。这意味着对于小型企业(即员工少于 250 人且年收入低于 1000 万美元)、个人使用、教育和非商业性开源项目,它是免费的;否则,对于专业用途,则需要付费订阅。

我们现在将回顾另一种桌面解决方案,用于部署简化的 Kubernetes 环境。

Rancher Desktop

这个解决方案来自 SUSE,它确实为你提供了在笔记本电脑或台式计算机上体验 Kubernetes Rancher 部署的体验。Rancher Desktop 可以在 Windows 系统上安装,使用 WSL 或虚拟机,或在 macOS 和 Linux 上,仅使用虚拟机。它是一个开源项目,包含了 Moby 组件、containerd 以及其他借助 Rancher 经验的组件,允许开发新的不同项目,比如RancherOS(一个容器导向的操作系统)或K3s(一个轻量级的认证 Kubernetes 发行版)。Rancher Desktop 具有一些有趣的功能:

  • 我们可以选择为环境使用哪种容器运行时。这是一个关键区别,使得使用 containerd 直接测试你的应用程序变得非常重要。

  • 我们可以设置要部署的 Kubernetes 版本。

  • 可以定义用于虚拟机的资源(在 Mac 和 Linux 上)。

  • 它提供了 Rancher 控制面板,当你的服务器环境也运行 Kubernetes 和 Rancher 时,它与基础设施完美结合。

以下截图展示了我们如何在 Rancher Desktop GUI 中的偏好设置区域设置 Kubernetes 发布。通过这种方式,我们可以使用不同的 API 发布测试我们的应用程序,这在将应用程序移动到预发布或生产阶段之前非常有趣。每个 Kubernetes 发布都提供了自己的 API 资源集;你应该阅读每个发布说明,以了解 API 版本和资源的变化,这些变化可能会影响你的项目——例如,某些beta资源现在可能已包含在发布中,或某些资源已被弃用。以下截图展示了可以在 Rancher Desktop 中部署的 Kubernetes 发布:

图 8.6 – 可以选择不同的 Kubernetes 发布

图 8.6 – 可以选择不同的 Kubernetes 发布

正如我们在 Docker Desktop 中看到的,Rancher Desktop 也提供了一个简单的按钮来完全重置 Kubernetes 集群。以下截图展示了故障排除区域,在这里我们可以重置集群:

图 8.7 – Rancher Desktop GUI 中的故障排除区域

图 8.7 – Rancher Desktop GUI 中的故障排除区域

Rancher Desktop 还部署了基于 Traefik 的 Ingress 控制器。这个控制器将帮助我们发布应用程序,正如我们在 第十一章 中学习的,发布应用程序。我们可以通过在Kubernetes 偏好设置部分取消选中Traefik选项来移除这个组件并部署我们自己的 Ingress 控制器,但默认提供一个还是很有趣的。

通过点击 Rancher Desktop 通知图标并选择打开集群仪表盘,可以访问 Rancher Dashboard。Rancher Dashboard 以图形化方式提供对许多 Kubernetes 资源的访问,这对于初学者来说非常有用。以下截图显示了 Rancher Dashboard 主页面,在这里你可以查看和修改不同的已部署 Kubernetes 资源:

图 8.8 – Rancher Dashboard 主页面

图 8.8 – Rancher Dashboard 主页面

我们可以通过在 WSL 终端中检查其版本来验证 Kubernetes 环境:

$ kubectl version --short
Flag --short has been deprecated, and will be removed in the future. The --short output will become the default.
Client Version: v1.27.2
Kustomize Version: v5.0.1
Server Version: v1.27.2+k3s1

重要说明

我们可以使用 nerdctl 并加上 -–namespace k8s.io 参数,在不使用外部注册表的情况下构建 Kubernetes 环境的镜像。这样,镜像将直接可用于我们的部署。

有趣的是,这种 Kubernetes 实现与 Kubernetes 预期的网络功能保持一致;因此,我们可以通过 ping Pods 和 Services,甚至从 WSL 环境访问 Service 端口。通过在主机定义中添加 .localhost 后缀,它还使我们的应用程序可访问(我们将在第十一章《发布应用程序》一章中深入探讨这个选项)。然而,这个集群仍然是一个独立节点,我们无法测试在某些故障或节点之间迁移时应用程序的行为。如果你真的需要测试这些功能,我们需要进一步操作,部署其他节点并使用其他解决方案。

Docker Desktop 和 Rancher Desktop 都提供基于 GUI 的 Kubernetes 部署,但通常,如果不需要任何 GUI,我们甚至可以部署更轻量的解决方案。

我们现在将回顾 Minikube,它可能是最完整且可插拔的解决方案。

Minikube

Minikube Kubernetes 环境非常可配置,并且比其他解决方案消耗更少的硬件资源,允许我们每个集群部署多个节点,甚至在单个计算机主机上部署多个集群。我们可以使用 Docker、QEMU、Hyperkit、Hyper-V、KVM、Parallels、Podman、VirtualBox 或 VMware Fusion/Workstation 创建一个 Kubernetes 集群。我们可以使用许多不同的虚拟化解决方案,甚至容器运行时,Minikube 可以在 Microsoft Windows、macOS 或 Linux 操作系统上部署。

以下是 Minikube 的一些功能:

  • 它支持不同的 Kubernetes 版本

  • 可以使用不同的容器运行时

  • 一个直接的 API 端点改进了镜像管理

  • 高级 Kubernetes 自定义配置,如添加 feature gates

  • 它是一个可插拔的解决方案,因此我们可以包括像 Ingress 这样的插件,以扩展 Kubernetes 功能

  • 它支持与常见 CI 环境的集成

Kubernetes 部署非常简单,对于 Linux 系统只需要一个二进制文件。可以使用不同的参数来设置环境。让我们回顾一些最重要的:

  • start:此操作创建并启动一个 Kubernetes 集群。我们可以使用--nodes参数来定义要部署的节点数量,使用--driver来指定创建集群时使用的方法。虚拟硬件资源也可以通过使用--cpu--memory来定义;默认情况下,将使用 2 个 CPU 和 2GB 内存。我们甚至可以选择特定的 CNI 进行部署,使用--cni参数(autobridgecalicociliumflannelkindnet可用,但我们也可以添加自己的 CNI 清单路径)。

  • status:此操作显示 Minikube 集群的状态。

  • stop:此命令停止正在运行的 Minikube 集群。

  • delete:此操作删除之前创建的集群。

  • dashboard:可以作为插件部署一个开源的 Kubernetes Dashboard,使用minikube dashboard即可访问。

  • service:此选项非常有用,可以公开部署的应用服务。它返回一个服务 URL,可以用来访问该服务。

  • mount:我们可以使用此选项将主机目录挂载到 Minikube 节点中。

  • ssh:我们可以通过使用minikube ssh <NODE>来访问部署的 Kubernetes 主机。

  • node:此操作允许我们管理集群节点。

  • kubectl:此命令运行与集群版本匹配的kubectl二进制文件。

  • addons:Minikube 的最佳功能之一是我们可以通过插件扩展其功能,以管理集群的附加存储选项(例如,定义一个特定的csi-hostpath-driver,或者指定要使用的默认存储类default-storageclass,或者动态storage-provisioner等选项),Ingress 控制器(ingressingress-dnsistio,和kong),以及安全(pod-security-policy)。我们甚至可以自动部署 Kubernetes Dashboard 或指标服务器,从所有运行中的工作负载中恢复指标。

要创建一个包含两个节点(一个主节点和一个工作节点)的集群,我们只需执行minikube start --nodes 2。让我们来看一下这个操作:

PS > minikube start --nodes 2 `
--kubernetes-version=stable `
--driver=hyperv
* minikube v1.30.1 on Microsoft Windows 10 Pro 10.0.19045.2965 Build 19045.2965
* Using the hyperv driver based on user configuration
* Starting control plane node minikube in cluster minikube
...
* Done! kubectl is now configured to use "minikube" cluster and "default" namespace by default

部署完成后,我们可以使用kubectl查看集群状态:

PS > kubectl get nodes
NAME           STATUS   ROLES           AGE   VERSION
minikube       Ready    control-plane   25m   v1.26.3
minikube-m02   Ready    <none>          22m   v1.26.3

Minikube 是一个非常可配置的解决方案,提供常见的 Kubernetes 功能。在我看来,它在性能和功能方面是最优秀的。

重要提示

在 Microsoft Windows 上部署 Minikube Kubernetes 需要管理员权限,特别是当你使用 Hyper-V 时。因此,你需要以管理员身份打开 PowerShell 或命令提示符,但这可能还不够。还必须包括 PowerShell 的 Hyper-V,并且我们需要在 PowerShell 控制台中执行Enable-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V-Tools-All –All来启用它。

替代的 Kubernetes 桌面部署

目前,还有一些其他有趣的选项,它们使用的硬件资源甚至更少,但它们提供的功能不如 Minikube 丰富。让我们讨论一些不错的尝试候选项:

  • kind:这个解决方案利用 Docker 运行时安装,通过容器及其自定义镜像来部署 Kubernetes。它基于 kubeadm 部署,并且在 Linux 桌面系统中运行得非常顺畅,这些系统通常不会安装 Docker Desktop 来运行容器。

  • K3s:这个 Kubernetes 部署是 Rancher Desktop Kubernetes 功能的基础。它使用定制的二进制文件部署一个轻量级的环境,消耗更少的内存。如果你使用前沿功能,这可能会影响应用程序的部署,因为这些功能可能无法使用。这个解决方案来自 Rancher-SUSE,后者还提供 K3D,通过容器部署 Kubernetes。

  • containerd。它仍处于初期阶段,但如果你在应用开发中不使用任何 Docker 工具,目前它似乎是一个不错的解决方案。

我们现在可以继续深入探讨 Pods 和 Services 的概念。

创建 Pods 和 Services

在这一部分中,我们将学习在 Kubernetes 编排平台中用于部署应用程序的资源。我们将从学习容器是如何在 Pods 内部实现的开始。

Pods

localhost;因此,我们只能使用每个端口一次。与 Pod 相关联的卷也在容器之间共享。我们可以将 Pod 看作是一个小型的虚拟机,其中不同的进程(容器)一起运行。当 Pod 中的所有容器都正常运行时,Pod 被视为处于健康状态。

由于 Pods 可以包含多个容器,我们可以考虑使用 Pod 来部署具有多个组件的应用程序。与 Pod 相关联的所有容器都运行在同一个集群主机上。通过这种方式,我们可以确保所有应用程序组件一起运行,它们之间的通信也会更快。由于 Pods 是 Kubernetes 中最小的单位,我们只能整体扩展或缩减 Pods;因此,所有包含的容器也会多次执行,这可能并不是我们需要的。并非所有应用组件都应该遵循相同的扩展规则;因此,最好为一个应用程序部署多个 Pods。

以下图示展示了一个 Pod。它包含两个容器,因此,它们共享同一个 Pod 的 IP 地址和卷:

图 8.9 – 包含两个容器的 Pod 结构图

图 8.9 – 包含两个容器的 Pod 结构图

我们可以通过使用主机的命名空间(主机的网络、进程间通信IPC、进程等)来运行共享主机资源的 Pods。我们应该限制这种类型的 Pod,因为它们可以直接访问主机的进程、接口等。

以下示例展示了一个声明性文件,用于执行一个示例 Pod:

apiVersion: v1
kind: Pod
metadata:
    name: examplepod
  labels:
    example: singlecontainer
  spec:
      containers:
      - name: examplecontainer
        image: nginx:alpine

我们可以使用 JSON 或 YAML 文件来定义 Kubernetes 资源,但 YAML 文件更受欢迎;因此,在准备部署文件时,你需要特别注意缩进。为了将此 Pod 部署到我们的 Kubernetes 集群中,我们只需执行kubectl create -f <PATH_TO_THE_FILE>

重要提示

当你访问一个 Kubernetes 集群时,一个命名空间会与您的个人资料或上下文关联。默认情况下,关联的命名空间是default;因此,如果我们没有指定任何 Kubernetes 命名空间作为创建资源的参数,default命名空间将被使用。我们可以通过执行kubectl config use-context --current --namespace <NEW_NAMESPACE>来更改当前上下文的命名空间,使其适用于后续的所有命令。每个资源的命名空间可以在该资源的 YAML 清单中的metadata键下指定:

...

metadata:

name: podname

namespace: my-namespace

...

我们可以通过修改容器镜像的行为来修改容器的任何方面,正如我们在第四章中学到的,运行 Docker 容器

如果你不确定可用的键,或者正在学习如何使用新的 Kubernetes 资源,你可以使用kubectl explain <RESOURCE>来获取可用键及其预期值的准确描述:

PS > kubectl explain pod
KIND:     Pod
VERSION:  v1
DESCRIPTION:
     Pod is a collection of containers that can run on a host. This resource is
...
 FIELDS:
   apiVersion   <string>
     APIVersion defines the versioned schema of this representation of an
 ...
   spec <Object>
     ...
   status       <Object>
     …

我们可以继续添加键以获得更具体的定义——例如,我们可以检索pod.spec.containers.resources下的键:

PS > kubectl explain pod.spec.containers.resources
KIND:     Pod
...
RESOURCE: resources <Object>
 ...
DESCRIPTION:
     Compute Resources required by this container. Cannot be updated. More info:
     https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/
 ...
FIELDS:
   claims       <[]Object>
     Claims lists the names of resources, defined in spec.resourceClaims, that
     are used by this container.
 ...
    limits       <map[string]string>
     Limits describes the maximum amount of compute resources allowed. More
   ...
   requests     <map[string]string>
     Requests describes the minimum amount of compute resources required. If
   ...

每个描述都显示了扩展信息,并附有指向 Kubernetes 文档的链接。

重要提示

我们可以通过使用kubectl explain pod --recursive一次性检索特定资源的所有可用键。这个选项真的帮助我们完全自定义资源。

我们可以使用 NGINX Web 服务器来测试真实的 Pod 部署。为此,按照以下步骤操作:

  1. 我们将使用命令式模式与kubectl run

    PS > kubectl run webserver --image=docker.io/nginx:alpine `
    --port 80
    pod/webserver created
    
  2. 现在我们可以列出 Pods 来验证 webserver 是否正在运行:

    PS > kubectl get pods
    NAME        READY   STATUS              RESTARTS   AGE
    webserver   0/1     ContainerCreating   0          5s
    
  3. 如我们所见,它正在启动。几秒钟后,我们可以验证我们的 web 服务器是否正在运行:

    PS > kubectl get pods -o wide
    NAME        READY   STATUS    RESTARTS   AGE     IP           NODE       NOMINATED NODE   READINESS GATES
    -o wide to modify the command’s output. As you can see, we now have the associated IP address.
    
  4. 我们可以进入minikube节点并验证 Pod 的连通性。以下截图显示了与集群节点的交互:

图 8.10 – 测试来自 minikube 节点的连通性

图 8.10 – 测试来自 minikube 节点的连通性

  1. 如果我们现在删除 Pod 并创建一个新的 Pod,我们可以轻松地看到新的 Pod 会获得一个新的 IP 地址,因此,我们的应用可能需要不断更改 IP 地址:

    PS > kubectl delete pod webserver
    pod "webserver" deleted
    PS > kubectl run webserver `
    --image=docker.io/nginx:alpine --port 80
    pod/webserver created
    PS > kubectl get pods -o wide
    NAME        READY   STATUS    RESTARTS   AGE   IP           NODE       NOMINATED NODE   READINESS GATES
    webserver   1/1     Running   0          4s    10.244.0.4   minikube   <none>           <none>
    

这是一个实际问题,这也是我们永远不使用 Pod IP 地址的原因。现在我们来讨论 Services,以及它们如何帮助我们解决这个问题。

Services

服务 是 Kubernetes 中的抽象对象;它们用于暴露一组 Pods,从而为应用组件提供服务。它们会从 Kubernetes 的内部 IPAM 系统中获取一个 IP 地址,我们将使用这个地址来访问关联的 Pods。我们还可以将服务与外部资源关联,使其对用户可访问。Kubernetes 提供了不同类型的服务,可以将它们发布到集群内部或集群外部。让我们快速回顾一下不同的服务类型:

  • ClusterIP:这是默认的服务类型。Kubernetes 会从定义的服务 IP 地址范围中分配一个 IP 地址,容器可以通过其 IP 地址或名称访问此服务。运行在同一命名空间中的容器将能够简单地使用服务的名称,而其他容器则需要使用其 Kubernetes FQDN(SERVICE_NAME.SERVICE_NAMESPACE.svc.cluster.local,其中 cluster.local 是 Kubernetes 集群本身的 FQDN)。这是由于 Kubernetes 的内部 服务发现 (SD),它为所有服务在集群范围内创建 DNS 条目。

  • Headless:这些服务是 ClusterIP 类型的一种变体。它们不接收 IP 地址,服务的名称将解析为所有关联 Pods 的 IP 地址。我们通常使用无头服务来与非 Kubernetes SD 解决方案进行交互。

  • NodePort:当我们使用 NodePort 服务时,我们将一组主机端口与服务的 clusterIP 定义的 IP 地址关联。这使得服务能够从集群外部进行访问。我们可以从客户端计算机连接到定义端口中的任何集群主机,Kubernetes 将通过内部 DNS 将请求路由到服务,关联到 ClusterIP 地址,无论哪个节点接收到请求。因此,关联该服务的 Pods 将接收来自客户端的网络流量。

  • LoadBalancerLoadBalancer 服务类型用于在外部负载均衡器中发布定义好的服务。它使用外部负载均衡器的 API 来定义到达集群所需的规则,实际上,该模型使用 NodePort 服务类型来实现集群范围内访问服务。此服务类型主要用于在云提供商的 Kubernetes 集群中发布服务,尽管一些厂商也在本地提供此功能。

我们已经看到了如何将服务发布到 Kubernetes 集群外部,以便其他外部应用程序甚至我们的用户使用,但我们也可以做相反的操作。我们可以通过使用 External 服务类型,将我们实际网络中的外部服务包含到 Kubernetes 集群内。

以下架构表示一个 NodePort 服务,我们在该服务中发布端口 7000,并将其附加到端口 5000,在本示例中的容器中暴露:

图 8.11 – NodePort 服务架构

图 8.11 – NodePort 服务架构

在这个示例中,来自用户的外部请求会负载均衡到端口7000,该端口监听所有集群主机。所有来自用户的流量会被内部负载均衡到端口5000,使其在所有服务分配的 Pod 上都能使用。

以下示例展示了一个 Kubernetes 服务的清单,这是通过使用命令式方法仅检索输出得到的:

PS >   kubectl  expose  pod  webserver  -o yaml  `
--port 80  --dry-run=client
apiVersion: v1
kind: Service
metadata:
  creationTimestamp: null
  labels:
    run: webserver
  name: webserver
spec:
  ports:
  - port: 80
    protocol: TCP
    targetPort: 80
  selector:
    run: webserver
status:
  loadBalancer: {}

在这个示例中,服务资源没有被创建,因为我们添加了-o yaml参数以显示 YAML 格式的输出,并使用了–dry-run=client。这个选项显示了执行创建命令时返回的输出结果,该命令是针对 kube-apiserver 执行的。

接下来,让我们学习如何在集群中部署工作负载,因为 Pod 不提供弹性;它们作为未管理的独立工作负载运行,缺乏控制器。

部署编排资源

现在我们已经知道如何使用命令式和声明式模式来部署 Pod,我们将定义可以管理 Pod 生命周期的新资源。直接使用kubectl命令行执行的 Pod 如果容器终止,将不会被重新创建。为了控制 Kubernetes 集群中的工作负载,我们需要部署额外的资源,由 Kubernetes 控制器进行管理。这些控制器是控制循环,它们监控不同 Kubernetes 资源的状态,并在需要时进行更改或请求更改。每个控制器都会跟踪一些资源,并尽力保持它们的定义状态。Kubernetes 的 kube-controller-manager 管理这些控制器,以保持不同集群资源的整体期望状态。每个控制器都可以通过 API 访问,我们将使用kubectl与它们进行交互。

在本节中,我们将学习 Kubernetes 控制器的基础知识,并深入了解如何在第九章中使用它们,实现 架构模式

副本集

最简单的可以保持应用程序定义状态的资源是副本集。它会保持一组副本 Pod 的运行。为了创建副本集,我们将使用 Pod 清单作为模板来创建多个副本。让我们来看一个简单的示例:

apiVersion: apps/v1
kind: ReplicaSet
metadata:
  name: replicated-webserver
spec:
  replicas: 3
  selector:
    matchLabels:
      application: webserver
  template:
     metadata:
         application: webserver
     spec:
       containers:
       - name: webserver-container
         image: docker.io/nginx:alpine

这个副本集将运行三个具有相同docker.io/nginx:alpine镜像的 Pod;template部分定义了这三个 Pod 资源的规格,每个 Pod 一个容器。副本集通过使用在 Pod 的template部分定义的application标签及其webserver值,来识别需要管理的 Pod。

当我们部署这个副本集时,集群会创建这三个 Pod,每当其中任何一个 Pod 终止时,控制器会管理这个变化并触发新的 Pod 创建。

我们将继续审查更多资源,但请记住,template部分嵌入在更一般的定义中的基本概念适用于所有资源。

部署

我们可以将 Deployment 视为 ReplicaSet 的进化版本。它允许我们更新这些副本,因为一个 Deployment 管理着一组副本,但只运行其中一个。每次我们创建一个新的 Deployment 时,都会创建一个关联的 ReplicaSet。当我们更新这个 Deployment 时,会创建一个新的 ReplicaSet,带有新的定义,反映前一个资源的更改。

DaemonSets

使用 DaemonSet,我们可以确保所有集群节点都有一个工作负载副本,但我们无法在 DaemonSet 中定义副本的数量。

StatefulSets

StatefulSet 允许我们在工作负载中使用更高级的特性。它使我们能够管理 Pods 的顺序和唯一性,确保每个副本获得自己唯一的资源集,例如卷。尽管 Pods 是从相同的模板部分创建的,但 StatefulSet 为每个 Pod 维护不同的身份。

作业

一个 Job 会一次创建一个或多个 Pods,但它会持续创建,直到创建的 Pod 数量达到定义的成功终止数量。当一个 Pod 退出时,控制器会验证所需完成的 Pod 数量是否已达到,如果没有,它会创建一个新的 Pod。

CronJobs

我们可以通过使用 CronJobs 来调度 Pods,因为它们负责调度任务。当执行时间到达时,会创建一个 Job,并触发创建定义的 Pods。正如我们在第九章《实现架构模式》中所学到的那样,CronJobs 清单包括两个template部分——一个用于创建作业,另一个用于定义如何创建 Pods。

ReplicationControllers

我们可以将 ReplicationControllers 视为当前 ReplicaSet 资源类型的前身。它们的工作方式与我们保持一定数量的 Pod 副本活跃类似,但它们在分组监控的 Pods 时有所不同,因为 ReplicationControllers 不支持基于集的选择器。这个选择器方法使 ReplicaSets 能够获取由其自身清单之外创建的 Pods 的状态管理;因此,如果一个已运行的 Pod 符合 ReplicaSet 标签的选择,它将自动被包括在复制 Pod 池中。

现在我们对允许我们创建跨集群编排资源的不同资源有了概览,接下来可以学习一些 Kubernetes 的功能,这些功能可以提高我们应用程序的安全性。

使用 Kubernetes 提高应用程序的安全性

在容器中运行的应用程序提供了许多全新的功能。我们可以在主机上同时运行多个应用程序版本;它们可以在几秒钟内启动和停止。我们可以轻松地扩展组件,不同的应用程序可以共存,甚至不需要彼此之间的交互。应用程序的弹性也继承自容器运行时的特性(例如,退出的容器会自动重新启动)。

然而,我们也可以通过在 Kubernetes 中运行应用程序来提升它们的性能。每个 Kubernetes 集群由多个容器运行时共同运行并协调工作。容器运行时通过内核命名空间和控制组cgroups)隔离主机的资源,但 Kubernetes 增加了一些有趣的功能:

  • 命名空间:命名空间是 Kubernetes 资源,它们将其他资源分组并设计用于在多个用户之间分配 Kubernetes 资源,这些用户按团队或项目进行分组。

  • 认证策略:来自 Kubernetes 客户端的请求可以使用不同的认证机制,如客户端证书、持有者令牌或身份验证代理来进行认证。

  • 授权请求:用户在通过认证、授权和不同的准入控制器后,向 Kubernetes API 发出请求。授权阶段涉及授予访问 Kubernetes 资源和功能的权限。请求的属性会根据策略进行评估,并决定是允许还是拒绝。请求中提供的用户、组、API、请求路径、命名空间、动词等都用于这些验证。

  • root,或者简单地将最终结果用户更改为非特权用户,以保持集群安全。

  • 使用kubectl检查是否有某些动词可供我们使用,或者是否可以通过模拟其他用户来检查:

    $ kubectl auth can-i create pods –namespace dev
    yes
    $ kubectl auth can-i create svc -n prod –as dev-user
    no
    
  • Secrets 和 ConfigMaps:我们已经在第七章《使用 Swarm 进行编排》中学习了如何在编排环境中部署某些配置。在 Kubernetes 中,我们也有 Secrets 和 ConfigMaps 资源,但如果允许,用户可以检索它们(RBAC)。理解 Secrets 是以 Base64 格式打包的非常重要;因此,如果我们没有准备合适的角色,敏感数据可能会被访问。kubelet Kubernetes 组件将自动为你挂载 Secrets 和 ConfigMaps,我们可以在应用程序部署中将它们作为文件或环境变量使用。Kubernetes 可以加密静态的 Secrets,以确保操作系统管理员无法从 etcd 数据库文件中检索它们,但此功能默认是禁用的。

  • securityContext配置文件,这是至关重要的,因为我们可以确保一个 Pod 不会以 root 身份运行,或者不会在任何定义的命名空间中使用特权容器或非只读容器。

  • 网络策略:Kubernetes 默认部署一个扁平化网络,因此所有容器都能相互访问。为了避免这种情况,Kubernetes 还提供了 NetworkPolicies 和 GlobalNetworkPolicies(在集群级别应用)。并非所有的 CNI 都能够实现这一功能。Kubernetes 仅提供 自定义资源CR)类型,这些类型将用于实现 网络策略。请确保你的网络提供程序能够实现这些策略,以便能够使用此功能(许多流行的 CNI 插件如 Calico、Canal 和 Cilium 完全支持)。强烈建议实现一些默认的全局策略,拒绝所有外部访问,并在命名空间级别允许每个应用所需的通信。网络策略定义了入站和出站规则。这些规则在连接级别工作,因此我们没有原始的数据包日志(尽管一些 CNI 插件提供了日志功能)。我们将在 第十一章 发布应用 中学习如何按照最佳实践实现这些规则。

现在我们已经概览了 Kubernetes 中可用的一些最重要功能,这些功能帮助我们保护应用程序和整个集群,我们可以继续进行一些简单的实验,涵盖 Kubernetes 环境的基本使用。

实验

现在,我们将进行一个简短的实验环节,帮助我们学习和理解如何使用 Minikube 部署本地 Kubernetes 环境,并测试其中的一些资源类型来验证集群。

本书实验的代码可在其 GitHub 仓库 https://github.com/PacktPublishing/Containers-for-Developers-Handbook.git 中找到。通过执行 git clone https://github.com/PacktPublishing/Containers-for-Developers-Handbook.git 来下载所有内容,或如果你已经下载过该仓库,则执行 git pull 获取最新版本。所有实验中使用的命令和内容都位于 Containers-for-Developers-Handbook/Chapter8 目录下。

我们将通过部署一个包含两个节点(一个主节点和一个工作节点)的 Minikube 集群开始。我们将为每个节点分配 3 GB 的 RAM,这足以在某些集群节点宕机时测试应用程序的行为,但对于日常使用来说,你可能不需要两个节点。

部署一个包含两个节点的 Minikube 集群

在这个实验中,我们将在本地部署一个完全功能的 Kubernetes 集群,用于测试目的。我们将继续在一台拥有 16 GB RAM 的 Windows 10 笔记本电脑上进行操作,这对于本书中的实验来说已经足够了。请按照以下步骤操作:

  1. 安装 Minikube。首先,从 https://minikube.sigs.k8s.io/docs/start/ 下载 Minikube,选择合适的安装方法,并按照操作系统的简单安装步骤进行安装。我们将使用 Hyper-V,因此必须在桌面电脑或笔记本电脑上启用并运行它。

  2. 一旦 Minikube 安装完成,我们将打开一个管理员 PowerShell 终端。使用 Hyper-V 部署 Minikube 需要以管理员权限执行。这是由于 Hyper-V 层的原因;因此,如果您使用 VirtualBox 作为虚拟化程序或 Linux 作为操作系统,则不需要管理员权限(其他虚拟化程序也可以使用,例如 KVM,它与 Minikube 配合得非常好)。如果需要删除 Minikube 集群,管理员权限也是必需的。一旦 PowerShell 终端准备好,我们执行以下命令:

    minikube start –nodes 2 –memory 3G –cpus 2 `
    --kubernetes-version=stable –driver=hyperv `
    --cni=calico `
    minikube start:
    
    PS C:\Windows\system32> cd c:\
    PS C:\>  minikube start –nodes 2 –memory 3G `
    --cpus 2 –kubernetes-version=stable `
    --driver=hyperv –cni=calico `
    --addons=ingress,metrics-server,csi-hostpath-driver
    * minikube v1.30.1 on Microsoft Windows 10 Pro 10.0.19045.2965 Build 19045.2965
    * Using the hyperv driver based on user configuration
    * Starting control plane node minikube in cluster minikube
    * Creating hyperv VM (CPUs=2, Memory=3072MB, Disk=20000MB) …
    * Preparing Kubernetes v1.26.3 on Docker 20.10.23 ...
      - Generating certificates and keys ...
    ...
      - Using image registry.k8s.io/metrics-server/metrics-server:v0.6.3
    * Verifying ingress addon...
    ...
    * Starting worker node minikube-m02 in cluster minikube
    ...
    * Done! kubectl is now configured to u"e "minik"be" cluster a"d "defa"lt" namespace by default
    kubectl get nodes:
    
    

    PS C:> kubectl get nodes

    NAME           STATUS   ROLES           AGE   VERSION

    minikube       Ready    control-plane   23m   v1.26.3

    minikube-m02 节点没有显示任何角色。这是因为 Kubernetes 中的一切都是通过标签进行管理的。记住,我们之前看到如何使用选择器来识别哪些 Pods 属于特定的 ReplicaSet。

    
    
  3. 我们可以查看节点标签并为工作节点创建一个新标签。这将向我们展示如何通过使用标签修改资源的行为:

    PS C:\> kubectl get nod– --show-labels
    NAME           STATUS   ROLES           AGE   VERSION   LABELS
    minikube       Ready    control-plane   27m   v1.26.3   beta.kubernetes.io/arch=amd64,beta.kubernetes.io/os=linux,kubernetes.io/arch=amd64,kubernetes.io/hostname=minikube,kubernetes.io/os=linux,minikube.k8s.io/commit=08896fd1dc362c097c925146c4a0d0dac715ace0,minikube.k8s.io/name=minikube,minikube.k8s.io/primary=true,minikube.k8s.io/updated_at=2023_05_31T10_59_55_0700,minikube.k8s.io/version=v1.30.1,node-role.kubernetes.io/control-plane=,node.kubernetes.io/exclude-from-external-load-balancers=,topology.hostpath.csi/node=minikube
    node-role.kubernetes.io/worker. This label is not required, and that’s why it is not included by default, but it is good to include it to identify nodes in the node cluster review (kubectl get nodes).
    
  4. 我们现在通过使用 kubectl label、<RESOURCE> <LABEL_TO_ADD> 为工作节点添加一个新标签:

    PS C:\> kubectl label node minikube-m02  `
    node-role.kubernetes.io/worker=
    node/minikube-m02 labeled
    PS C:\> kubectl get nodes
    NAME           STATUS   ROLES           AGE   VERSION
    minikube       Ready    control-plane   33m   v1.26.3
    kubectl label to add any label to any resource. In the specific case of node-role.kubernetes.io, it is used by Kubernetes to show the ROLES column, but we can use any other label to identify a set of nodes. This will help you in a production cluster to run your applications in nodes that best fit your purposes – for example, by selecting nodes with fast solid-state disks, or those with special hardware devices. You may need to ask your Kubernetes administrators about these nodes’ special characteristics.
    

我们现在将展示如何使用已部署的 Kubernetes 集群。

与 Minikube 部署集群进行交互

在这个实验中,我们将与当前集群进行交互,回顾并创建一些新资源:

  1. 我们将从列出集群中所有部署的 Pods 开始,使用 kubectl get pods --A。这将列出集群中所有的 Pods。由于我们作为管理员连接,在 Minikube 安装后我们能够列出它们。以下截图展示了 kubectl get pods -A 的输出,接着是使用 kubectl get namespaces 命令列出的当前命名空间:

图 8.12 – kubectl get pods –A 和 kubectl get namespace 命令的输出

图 8.12 – kubectl get pods –A 和 kubectl get namespace 命令的输出

  1. 让我们通过使用 kubectl create ns chapter8 来创建一个新的命名空间 chapter8

    PS C:\> kubectl create ns chapter8
    chapter8 namespace by adding --namespace or just --n to kubectl get pods:
    
    

    PS C:> kubectl get pods -n chapter8

    ingress-nginx 命名空间。我们将使用 kubectl get all 列出该命名空间中部署的所有资源,正如以下截图所示:

    
    

图 8.13 – kubectl get all –n ingress-nginx 的输出

图 8.13 – kubectl get all –n ingress-nginx 的输出

现在,我们知道如何筛选与特定命名空间关联的资源。

  1. 现在,让我们通过使用命令式格式在 chapter8 命名空间中创建一个简单的 Pod。我们将执行 kubectl run webserver --image=nginx:alpine 来运行一个包含一个容器的 Pod,使用 docker.io/nginx:alpine 镜像:

    PS C:\> kubectl run webserver --image=nginx:alpine `
    -n chapter8
    pod/webserver created
    PS C:\> kubectl get pods -n chapter8
    NAME        READY   STATUS    RESTARTS   AGE
    kubectl command talks with the Kubernetes API using the credentials from our local kubeconfig file (this file is located in your home directory in the .kube directory; you can use $env:USERPROFILE\.kube in Microsoft Windows), and kube-apiserver gets this information from etcd before it is presented in our terminal. The following screenshot shows part of the output we get by using kubectl get pod <PODNAME> -o yaml. The –o yaml modifier shows the output from a current resource in the YAML format. This really helps us to understand how objects are created and managed by Kubernetes:
    

图 8.14 – kubectl get pods --namespace chapter8 -o yaml webserver 的输出

图 8.14 – kubectl get pods --namespace chapter8 -o yaml webserver 的输出

  1. 让我们通过使用kubectl get pods -o wide来查看是哪个节点执行了我们的 Pod,该命令显示扩展信息,或者通过从 YAML 输出中筛选hostIP键:

    PS C:\> kubectl get pods -n chapter8 -o wide
    NAME        READY   STATUS    RESTARTS   AGE   IP          NODE           NOMINATED NODE   READINESS GATES
    webserver   1/1     Running   0          27m   10.244.205.194   minikube-m02   <none>           <none>
    

    这也可以通过使用 JSON 路径模板(https://kubernetes.io/docs/reference/kubectl/jsonpath/)来完成:

    PS C:\> kubectl get pods -n chapter8 `
    -o jsonpath='{ .status.hostIP }' webserver
    172.19.146.184
    

重要说明

你可以看到节点名称也可以在spec部分(spec.nodeName)中找到,但这一部分是 Pod 规格呈现的地方。我们将在下一章学习如何通过直接修改 Kubernetes 中的在线清单来改变工作负载的行为。status部分是只读的,因为它显示了资源的实际状态,而metadataspec部分的一些内容可以被修改——例如,可以通过添加新的标签或注释来修改。

在本章的实验结束前,我们将通过添加一个NodePort服务来暴露已部署的 Pod,这将引导我们的请求到正在运行的 Web 服务器 Pod。

使用 NodePort 服务暴露 Pod

在这个快速实验中,我们将使用命令式模型部署一个NodePort服务,以暴露已经部署的 Web 服务器 Pod:

  1. 由于我们没有在webserver Pod 中定义容器端口,Kubernetes 将无法知道哪个端口必须与服务关联;因此,我们需要传递--target-port 80参数,以指定服务应连接到监听的 NGINX 容器端口。我们将使用端口8080作为服务端口,并让 Kubernetes 为我们选择一个NodePort端口:

    PS C:\> kubectl expose pod webserver -n chapter8 `
    --target-port 80 --port 8080 --type=NodePort
    chapter8 namespace:
    
    

    PS C:> kubectl get all -n chapter8

    名称 就绪 状态 重启次数 存活时间

    pod/webserver 1/1 运行中 0 50m

    名称 类型 集群-IP 外部-IP 端口(S) 存活时间

    32317 与服务的端口 8080 相关联,该端口与 webserver Pod 的端口 80(NGINX 容器在该端口上监听)相关联。

    
    
  2. 现在我们可以在任何主机上访问已发布的NodePort端口,即使该主机没有运行任何与服务相关的 Pod。我们可以使用任何 Minikube 集群节点的 IP 地址,或者使用minikube service -n chapter8 webserver命令自动在关联的 URL 中打开默认的 Web 浏览器。

    以下屏幕截图显示了两种情况的输出。首先,我们使用kubectl get nodes –o wide获取了主机的 IP 地址。我们使用 PowerShell 的Invoke-WebRequest命令访问了节点的 IP 地址与NodePort发布端口的组合。然后,我们使用 Minikube 的内置 DNS 解析了服务的 URL,通过使用minikube服务:

图 8.15 – kubectl get nodes 的输出,使用集群节点进行不同测试,以及 Minikube 服务 URL 解析

图 8.15 – kubectl get nodes 的输出,使用集群节点进行不同测试,以及 Minikube 服务 URL 解析

正如您所见,我们使用了主节点和工作节点的 IP 地址进行了测试,它们可以正常工作,尽管 Pod 只在工作节点上运行。此输出还显示了通过使用 Minikube 的集成服务解析来测试服务有多么容易。它自动打开了我们的默认网络浏览器,我们可以直接访问我们的服务,如下面的屏幕截图所示:

图 8.16 – 默认网络浏览器访问 Web 服务器服务的 NodePort 端口

图 8.16 – 默认网络浏览器访问 Web 服务器服务的 NodePort 端口

  1. 现在,我们可以移除本章中创建的所有资源。首先移除 Pod,然后移除 Service,最后移除命名空间是很重要的。先移除命名空间将触发级联删除所有关联资源,如果 Kubernetes 无法删除某些资源可能会出现一些问题。在这个简单的实验中永远不会发生,但在删除命名空间本身之前移除命名空间内的资源是一个良好的做法:

    PS C:\> kubectl delete service/webserver pod/webserver -n chapter8
    service "webserver" deleted
    pod "webserver" deleted
    PS C:\> kubectl delete ns chapter8
    namespace "chapter8" deleted
    

您现在已经准备好在下一章中学习更高级的 Kubernetes 主题了。

总结

在这一章中,我们探索了 Kubernetes,这是最流行和扩展的容器编排器。我们进行了整体架构审查,描述了每个组件以及我们如何实现具有高可用性的环境,并学习了一些最重要的 Kubernetes 资源的基础知识。为了能够准备我们的应用程序在 Kubernetes 集群中运行,我们学习了一些应用程序,这些应用程序将帮助我们在我们的台式计算机或笔记本电脑上实现完全功能的 Kubernetes 环境。

在下一章中,我们将深入探讨我们将用于部署应用程序的资源类型,审查有趣的用例和示例,并学习应用于我们应用程序组件的不同架构模式。

第三部分:应用程序部署

这部分将描述应用程序在生产环境中的运行情况,我们将使用不同的模型和 Kubernetes 特性来帮助我们安全地交付可靠的应用程序。

这部分包括以下章节:

  • 第九章, 实施架构模式

  • 第十章, 在 Kubernetes 中利用应用程序数据管理

  • 第十一章, 发布应用程序

  • 第十二章, 获取应用程序洞察

第九章:9

实现架构模式

Kubernetes 是生产环境中最流行的容器调度器。该平台提供了不同的资源,允许我们以高韧性和分布式的方式部署应用程序,同时平台本身具有高可用性。在本章中,我们将学习这些资源如何提供不同的应用架构模式,并结合用例和最佳实践来实施它们。我们还将回顾不同的应用数据管理选项,并学习如何管理应用程序的健康,以最有效的方式应对可能出现的健康和性能问题。在本章的最后,我们将回顾 Kubernetes 提供的安全模式,以提高应用程序的安全性。本章将为您提供一个关于哪些 Kubernetes 资源最适合您应用需求的良好概览。本章将涵盖以下主题:

  • 将 Kubernetes 资源应用于常见应用模式

  • 理解高级 Pod 应用模式

  • 验证应用健康状况

  • 资源管理与可扩展性

  • 使用 Pods 提升应用安全性

技术要求

本章的实验可以在 github.com/PacktPublishing/Containers-for-Developers-Handbook/tree/main/Chapter9 找到,您将在那里找到一些扩展的解释,这些内容在本章中被省略,以便更容易理解。本章的 Code In Action 视频可以在 packt.link/JdOIY 找到。

本章将从回顾 第八章,《使用 Kubernetes 调度器部署应用》,开始,并介绍一些常见的用例。

将 Kubernetes 资源应用于常见应用模式

Kubernetes 容器调度器基于由不同控制器管理的资源。默认情况下,我们的应用程序可以使用以下其中之一来运行容器中的进程:

  • Pods

  • ReplicaSets

  • ReplicaControllers

  • Deployments

  • StatefulSets

  • DaemonSets

  • Jobs

  • CronJobs

此列表显示了每个 Kubernetes 安装中允许的标准或默认资源,但我们可以创建自定义资源来实现任何非标准或更具体的应用行为。在本节中,我们将学习这些标准资源,以便我们能够决定哪个最适合我们的应用需求。

Pods

Pods 是在 Kubernetes 集群中部署工作负载的最小单元。一个 Pod 可以包含多个容器,我们有不同的机制来实现这一点。

默认情况下,以下内容适用于 Pod 内运行的所有容器:

  • 它们共享网络命名空间,因此它们都指向相同的本地主机,并使用相同的 IP 地址运行。

  • 它们都在同一主机上调度。

  • 它们共享可以在容器级别定义的命名空间。我们将为每个容器定义资源限制(使用 cgroups),尽管我们也可以在 Pod 级别定义资源限制。Pod 资源限制将应用于所有容器。

  • 附加到 Pod 的卷对所有在 Pod 内运行的容器都是可用的。

所以,我们可以看到 Pods 是一组一起运行的容器,它们共享内核命名空间和计算资源。

你会发现许多 Pods 只运行一个容器,这完全是可以接受的。容器在不同 Pods 之间的分布取决于你的应用程序组件的分布。你需要问自己,某些进程是否必须一起运行。例如,你可以将需要快速通信的两个容器放在一起,或者你可能需要在不共享远程数据卷的情况下跟踪其中一个容器创建的文件。但非常重要的是要理解,所有在 Pod 中运行的容器是一起扩展和复制的。这意味着,Pod 的多个副本将执行相同数量的容器副本,因此应用程序的行为可能会受到影响,因为相同类型的进程会多次运行,并且它们也会同时访问你的文件。例如,这会破坏你的数据库中的数据,或者可能导致数据不一致。因此,你需要明智地决定是否将应用程序的容器分布到不同的 Pods 中,或者将它们一起运行。

Kubernetes 会通过执行 Pod 定义的探针来跟踪每个容器在 Pod 中的状态。每个容器应该有自己的探针(存在不同类型的探针,我们将在验证应用程序健康部分学习)。但是此时,我们必须理解,Pod 内所有容器的健康状况会控制整个 Pod 的行为。如果其中一个容器崩溃,整个 Pod 会被标记为不健康,从而触发定义的 Kubernetes 事件和资源行为。因此,我们可以并行执行多个服务容器,或者通过执行一些预处理来准备我们的应用程序,例如填充一些最小的文件系统资源、二进制文件、权限等等。这些在实际应用程序进程之前运行的容器称为 initContainers(它是定义它们的关键字),并且会在其他容器之前按顺序运行;因此,如果这些初始容器中的任何一个失败,Pod 将无法按预期运行。

重要说明

Kubernetes 1.25 版本引入了 kubectl debug 操作,后跟 Pod 的名称,你的终端应该连接到该 Pod(共享内核命名空间)。我们将在 实验室 部分提供一个快速示例。

让我们回顾一下如何编写创建 Pod 所需的清单:

图 9.1 – Pod 清单

图 9.1 – Pod 清单

所有 Kubernetes 的清单文件至少包含 apiVersionkindmetadata 这三个键,它们分别用于定义将使用哪个 API 版本来访问关联的 API 服务器路径,定义我们正在定义的资源类型,以及描述资源在 Kubernetes 集群内唯一标识的信息。我们可以通过 Kubernetes API 使用 JSON 或 YAML 键层次结构访问所有资源清单信息;例如,要获取 Pod 的名称,我们可以使用 .metadata.name 来访问其键。资源的属性通常应写入 specdata 部分。Kubernetes 角色、角色绑定(在集群和命名空间范围内)、服务帐户以及其他资源没有包含 dataspec 键来声明其功能。我们甚至可以创建自定义资源,使用自定义定义来声明其属性。在默认的工作负载资源中,我们总是会使用 spec 部分来定义资源的行为。

请注意,在前面的代码片段中,containers 键是一个数组。这允许我们定义多个容器,正如我们之前提到的,初始容器也是如此;在两种情况下,我们都将定义一个容器列表,并且我们至少需要容器运行时必须使用的镜像和容器的名称。

重要提示

我们可以使用 kubectl explain pod.spec.containers --recursive 来检索定义资源下 spec 部分的所有现有键。explain 操作允许你直接从 Kubernetes 集群中检索每个资源的所有键;这很重要,因为它不依赖于你 kubectl 二进制文件的版本。该操作的输出还显示哪些键可以在运行时更改,一旦资源在集群中创建。

在这里需要特别提到的是,Pod 本身没有集群范围的自动修复功能。这意味着,当你运行一个 Pod,并且它由于某种原因(任何容器被认为不健康)被认为不健康时,它不会在集群中的其他主机上执行。Pod 包含 restartPolicy 属性来管理 Pod 死亡后的行为。我们可以将该属性设置为 Always(始终重启容器的 Pod)、OnFailure(仅在容器失败时重启)、或 Never。新的 Pod 永远不会在其他集群主机上重新创建。我们需要更高级的资源来管理集群范围内容器的生命周期,这些将在后续章节中讨论。

Pod 用于运行应用程序或其组件的测试,但我们从不使用它们来运行实际的服务,因为 Kubernetes 只是保持它们运行;它不管理它们的更新或重新配置。让我们回顾一下副本集是如何解决在需要保持应用程序容器持续运行时的这些问题。

副本集(ReplicaSets)

ReplicaSet 是一组应该同时运行的 Pods,用于应用的组件(或者如果应用只有一个组件,则用于应用本身)。为了定义一个 ReplicaSet 资源,我们需要编写以下内容:

  • 一个 selector 部分,用于定义哪些 Pods 是资源的一部分

  • 维持资源健康所需的副本数量

  • 一个 Pod 模板,用于定义当集合中的某个 Pod 死亡时,如何创建新的 Pods

让我们回顾一下这些资源的语法:

图 9.2 – ReplicaSet 清单

图 9.2 – ReplicaSet 清单

如你所见,template 部分描述了 ReplicaSet 中 spec 部分的 Pod 定义。这个 spec 部分还包括 selector 部分,用于定义哪些 Pods 会被包含。我们可以使用 matchLabels 来包含来自 Pods 的精确标签键值对,使用 matchExpressions 来包括一些高级规则,如定义的标签是否存在,或者其值是否包含在字符串列表中。

重要说明

ReplicaSet 资源的选择器也适用于正在运行的 Pods。这意味着你需要注意那些唯一标识你应用组件的标签。ReplicaSet 资源是有命名空间的,因此我们可以在实际创建 ReplicaSet 之前使用 kubectl get pods --show-labels 来确保正确的 Pods 会被包含在集合中。

在 Pod 模板中,我们将定义要附加到 ReplicaSet 创建的不同容器上的卷,但需要理解的是,这些卷对于所有副本都是共享的。因此,所有容器副本都会附加相同的卷(实际上,运行它们的主机挂载这些卷,kubelet 会将它们提供给 Pods 的容器),如果你的应用不允许这种情况,可能会产生问题。例如,如果你正在部署数据库,运行多个副本并附加相同的卷可能会破坏你的数据文件。我们应该确保我们的应用可以同时运行多个复制进程,如果不能,请确保在 accessMode 键中应用适当的 ReadWriteOnce 模式标志。我们将在 第十章在 Kubernetes 中利用应用数据管理 中深入探讨这个键、它的重要性以及它对我们工作负载的意义。

ReplicaSets 中最重要的键是 replicas 键,它定义了应该运行的活动健康 Pods 数量。这允许我们对应用的进程数量进行扩展或缩减。与 ReplicaSet 关联的 Pods 的名称将遵循 <REPLICASET_NAME>-<POD_RANDOM_UNIQUE_GENERATED_ID> 格式。这有助于我们理解哪些 ReplicaSet 创建了它们。我们还可以通过使用 kubectl get pod –o yaml 来查看 ReplicaSet 的创建者。metadata.OwnerReferences 键显示了最终创建每个 Pod 资源的 ReplicaSet。

我们可以使用以下任一方法修改正在运行的 ReplicaSet 资源的副本数:

  • 使用kubectl直接编辑 Kubernetes 中正在运行的 ReplicaSet 资源:edit <REPLICASET_NAME>

  • 使用kubectl patch修补当前的 ReplicaSet 资源

  • 使用kubectlscale命令,设置副本数:kubectl scale rs --replicas <``NUMBER_OF_REPLICAS> <REPLICASET_NAME>

虽然更改副本数会自动生效,但其他更改则效果不佳。在 Pod 模板中,如果我们更改用于创建容器的镜像,资源会显示此更改,但当前关联的 Pods 不会改变。这是因为 ReplicaSets 并不管理这些更改;我们需要使用更高级的 Deployment 资源来进行操作。要在 ReplicaSet 中使任何更改生效,我们需要手动重新创建 Pods,可以通过删除当前 Pods(使用kubectl delete pod <REPLICASET_POD_NAMES>)或将副本数缩减为零,待所有 Pods 删除后再扩容。任何一种方法都会创建新的副本,使用新的 ReplicaSet 定义。

重要提示

你可以使用kubectl delete pod --selector <LABEL_SELECTOR>,结合创建当前 ReplicaSet 时使用的标签选择器,删除所有关联的 Pod 资源。

默认情况下,ReplicaSets 不会发布任何 Service;我们需要创建一个 Service 资源来访问部署的容器。当我们创建与 ReplicaSet 关联的 Service(使用 Service 的标签选择器与相应 ReplicaSet 的标签),所有 ReplicaSet 实例都可以通过 Service 的ClusterIP地址访问(默认的 Service 模式)。所有副本会接收相同数量的请求,因为内部负载均衡提供了轮询访问。

我们在生产环境中可能不会单独使用 ReplicaSets,因为我们已经看到,任何对其定义的更改都需要我们进行额外的操作,而在像 Kubernetes 这样的动态环境中,这并不理想。

在我们讨论 Deployments(用于部署 ReplicaSets 的高级资源)之前,我们将快速回顾一下 ReplicationControllers,它们与 ReplicaSets 非常相似。

ReplicationControllers

ReplicationController 是 Kubernetes 中用于 Pod 复制的原始方法,但现在它已几乎完全被 ReplicaSet 资源所取代。我们可以将 ReplicationController 看作是一个可配置性较低的 ReplicaSet。如今,我们不再直接创建 ReplicationController,因为我们通常会创建 Deployments 来部署运行在 Kubernetes 上的应用组件。我们了解到,ReplicaSets 有两种选择关联标签的方式。labelSelector 键可以是简单的标签查询(matchLabels),也可以是使用 matchExpressions 的更高级规则。ReplicationController 清单只能查找 Pods 中的特定标签,这使得它们更易于使用。Pod 模板部分在 ReplicaSets 和 ReplicationControllers 中看起来相似。然而,ReplicationControllers 和 ReplicaSets 之间也有一个根本性的区别。我们可以通过使用滚动更新操作来执行应用程序的升级。这些操作对于 ReplicaSets 不可用,但通过使用 Deployments,升级功能在这些资源中得以提供。

Deployments

我们可以说,Deployment 是一个高级版的 ReplicaSet。它通过允许我们通过创建新的 ReplicaSet 资源来升级 Pod 的规格,从而增加了我们之前错过的生命周期管理部分。这是生产中使用最广泛的工作负载管理资源。Deployment 资源创建并管理不同的 ReplicaSet 资源。当创建一个 Deployment 资源时,一个关联的 ReplicaSet 也会动态创建,遵循 <DEPLOYMENT_NAME>-<RS_RANDOM_UNIQUE_GENERATED_ID> 命名法。这个动态创建的 ReplicaSet 将创建与之关联的 Pods,遵循描述的命名规则,因此我们会在定义的命名空间中看到类似 <DEPLOYMENT_NAME>-<RS_RANDOM_UNIQUE_GENERATED_ID>-<POD_RANDOM_UNIQUE_GENERATED_ID> 的 Pod 名称。这将帮助我们跟踪哪个 Deployment 生成了哪个 Pod 资源。Deployment 资源管理着完整的 ReplicaSet 生命周期。为此,每当我们更改任何 Deployment 模板的规格键时,会创建一个新的 ReplicaSet 资源,并触发新的关联 Pods 的创建。Deployment 资源会跟踪所有关联的 ReplicaSets,这使得我们能够轻松地回滚到先前的版本,而不必包含最新的资源修改。这对于发布新的应用更新非常有用。每当更新的资源出现问题时,我们可以在几秒钟内回滚到任何先前的版本,这得益于 Deployment 资源——事实上,我们可以回滚到任何先前存在的 ReplicaSet 资源。我们将在第十三章管理应用生命周期中深入探讨滚动更新。

以下代码片段展示了这些资源的语法:

图 9.3 – 部署清单

图 9.3 – 部署清单

strategy键允许我们决定新容器是否在旧容器死亡之前尝试启动(RollingUpdate值,默认使用此值),或者完全重新创建相关的 ReplicaSet(Recreate值),当只有一个容器能够在特定时刻以写模式访问附加卷时需要使用这种方法。

我们将使用 Deployments 来部署无状态或有状态的应用程序工作负载,其中不需要任何特殊的存储附加,并且所有副本可以按相同的方式处理(所有副本都是一样的)。Deployments 非常适合用于部署具有静态内容的 Web 服务和动态 Web 服务,当会话持久性由不同的应用组件管理时。我们不能使用 Deployment 资源来部署我们的应用容器,特别是在每个副本必须附加其特定数据卷,或需要按顺序执行进程的情况下。

现在我们将学习 StatefulSet 资源如何帮助我们解决这些特定情况。

StatefulSets

StatefulSet 资源旨在管理有状态的应用程序组件——这些组件的持久化数据必须在副本之间保持唯一。这些资源还允许我们在执行进程时为不同的副本提供顺序。每个副本将获得一个唯一的有序标识符(从 0 开始的序号),并且它将用于增加或减少副本的数量。

以下代码片段展示了一个 StatefulSet 资源的示例:

图 9.4 – StatefulSet 清单

图 9.4 – StatefulSet 清单

上面的代码片段展示了template部分,包括了 Pod 资源和卷资源。

每个 Pod 的名称将遵循<STATEFULSET_NAME>-<REPLICA_NUMBER>的格式。例如,如果我们创建一个名为database的 StatefulSet 资源,且有三个副本,那么相关的 Pods 将为database-0database-1database-2。这种命名结构同样适用于 StatefulSet 的volumeClaimTemplates模板部分中定义的卷。

请注意,我们还在之前的代码片段中包含了serviceName键。应创建一个无头服务(没有ClusterIP)以在 Kubernetes 内部 DNS 中引用 ReplicaSet 的 Pod,但这个键告诉 Kubernetes 创建所需的 DNS 条目。在所示的示例中,第一个副本将作为database-0.database.NAMESPACE.svc.<CLUSTER_NAME>发布到集群 DNS,所有其他副本将遵循相同的命名规则。这些名称可以集成到我们的应用程序中,以创建应用程序集群,甚至配置默认负载均衡机制以外的高级负载均衡机制(用于 ReplicaSets 和 Deployments)。

当我们使用 StatefulSet 资源时,Pods 会按顺序创建,这可能会在需要删除某些副本时引入额外的复杂性。我们需要确保正确执行可能解决副本之间依赖关系的进程;因此,如果我们需要删除一个 StatefulSet 副本,缩减副本数量比直接删除副本更安全。记住,我们必须准备好让应用程序完全管理唯一副本,这可能需要一些应用程序进程来删除某个应用程序的集群组件。例如,在运行多个实例的分布式数据库时,这种情况是典型的,去除一个实例需要数据库更改,但这同样适用于任何 ReplicaSet 清单的更新。你必须确保更改按正确的顺序应用,通常,最好是先缩减到零副本,然后再扩展到所需的副本数。

在前面代码片段中呈现的 StatefulSet 示例中,我们指定了volumeClaimTemplate部分,它定义了动态配置卷所需的属性。我们将在第十章在 Kubernetes 中利用应用程序数据管理中学习动态存储配置的工作原理,但理解这一点很重要:此template部分会通知 Kubernetes API,每个副本都需要其自己的有序卷。动态配置的这一要求通常会与StorageClass资源的使用相关联。

一旦这些卷(与每个副本相关联)被配置并使用,删除一个副本(无论是通过使用kubectl delete pod命令直接删除,还是通过缩减副本数量)都不会删除相关联的卷。你可以确信,通过 ReplicaSet 部署的数据库永远不会丢失数据。

重要提示

ReplicaSet 相关的卷不会被自动删除,这使得这些资源对于任何工作负载都很有吸引力,特别是在你需要确保删除资源时不会删除数据的情况下。

我们可以使用 StatefulSet 来确保一个复制的服务被唯一管理。像 Hashicorp 的 Consul 这样的软件在多个预定义节点上以集群方式运行;我们可以通过容器将其部署在 Kubernetes 之上,但 Pod 需要按顺序部署,并且每个 Pod 都需要特定的存储,就好像它们是完全不同的主机一样。在数据库服务中也必须采用类似的方法,因为它们进程的复制可能会导致数据损坏。在这些情况下,我们可以使用 StatefulSet 复制的资源,但应用程序应该管理不同部署副本之间的集成,以及扩缩容过程。Kubernetes 仅提供了底层架构,确保数据的唯一性和副本的执行顺序。

DaemonSets

DaemonSet 资源将在每个 Kubernetes 集群节点上执行一个关联的 Pod。这确保了任何新加入的节点将自动获得自己的副本。

以下代码片段展示了一个 DaemonSet 清单示例:

图 9.5 – DaemonSet 清单

图 9.5 – DaemonSet 清单

正如您可能已经注意到的,我们使用标签选择器来匹配和关联 Pods。在前面的示例中,我们还介绍了 tolerations 键。让我们快速介绍一下 NoSchedule(只有具有适当容忍度的 Pods 才能在该节点上运行)、PreferNoSchedule(Pods 除非没有其他节点可用,否则不会在该节点上运行)和 NoExecute(如果 Pods 没有适当的容忍度,它们将从节点上驱逐)。污点和容忍度必须匹配,这使我们能够为特定任务专门分配节点,避免在其上执行其他工作负载。kubelet 将使用动态污点来驱逐 Pods,当集群节点出现问题时—例如,当内存使用过多或磁盘空间不足时。在我们的示例中,我们添加了一个容忍度,以便在具有 node-role.kubernetes.io/control-plane=NoSchedule 污点的节点上执行 DaemonSet Pods。

DaemonSets 通常用于部署应该在所有节点上运行的应用程序,例如那些作为软件代理运行的应用程序,用于监控或日志记录目的。

重要提示

尽管不常见,但确实可以使用静态 Pods 来运行节点特定的进程。这是 Kubernetes 基于 kubeadm 的部署使用的机制。静态 Pods 是与节点关联的 Pods,由 kubelet 直接执行,因此它们不受 Kubernetes 管理。您可以通过它们的名称来识别这些 Pods,因为它们包含主机的名称。执行静态 Pods 的清单位于 kubeadm 集群的 /etc/kubernetes/manifests 目录中。

在这一点上,我们必须提到,迄今为止呈现的所有工作负载管理资源都没有提供一个机制来运行不应该在执行期间维持的任务。接下来我们将回顾 Job 资源,这些资源专门为此目的而创建。

Jobs

Job 资源负责执行一个 Pod,直到我们获得成功的终止。Job 资源还通过使用模板选择器跟踪一组 Pods 的执行。我们配置所需的成功执行次数,当所有所需的 Pod 执行成功完成时,Job 资源将被视为完成

在 Job 资源中,我们可以配置并行性,以便同时执行多个 Pod,并能够更快地达到所需的成功执行次数。与 Job 相关的 Pods 将一直保留在我们的 Kubernetes 集群中,直到我们删除相关的 Job 或手动移除它们。

一个 Job 可以被挂起,这将删除当前活动的 Pods(正在执行的),直到我们重新恢复它。

我们可以使用 Jobs 执行一次性任务,但它们通常与周期性执行相关联,感谢 CronJob 资源。另一个常见的使用场景是在 Kubernetes 集群中直接执行应用程序的一些一次性任务。在这种情况下,您的应用程序需要能够内部访问 Kubernetes API(default 命名空间中的 kubernetes 服务),并且具有创建 Jobs 的适当权限。通常通过将允许此类操作的命名空间 Role 与执行应用程序 Pod 的 ServiceAccount 资源关联来实现。这种关联是通过使用命名空间 RoleBinding 来建立的。

以下代码片段展示了一个 Job 清单示例:

图 9.6 – Job 清单

图 9.6 – Job 清单

在这里,我们通过设置 completionsbackoffLimit 键,定义了成功完成的次数和将 Job 标记为失败的失败次数。至少需要三个 Pods 成功退出,才能达到四次失败的限制。可以通过设置 parallelism 键(默认值为 1)来并行执行多个 Pods,以加快完成速度。

TTL-after-finished 控制器提供了一个 ttlSecondsAfterFinished 键。由于此键基于日期时间参考,因此维护集群的时间并确保与我们所在时区一致非常重要。

Jobs 通常在 CronJobs 中使用,用于定义应该在某些时间段内执行的任务——例如,执行备份。让我们学习如何实现 CronJobs,以便我们能够定期安排 Jobs 执行。

CronJobs

CronJob 资源用于在特定时间安排 Jobs。以下代码片段展示了一个 CronJob 清单示例:

图 9.7 – CronJob 清单

图 9.7 – CronJob 清单

为了能够查看已执行的 Pods(与创建的 Jobs 相关联)的日志,我们可以设置 failedJobsHistoryLimitsuccessfulJobsHistoryLimit,以指定要保留的 Jobs 数量,从而能够查看 Pods 的日志。请注意,我们将示例 Job 设置为每日执行,时间为 00:00,使用常见的Unix Crontab格式,如下所示:

图 9.8 – Unix Crontab 格式

图 9.8 – Unix Crontab 格式

schedule 键定义了何时创建 Job,以及关联的 Pods 何时运行。请记得始终对您的值加上引号,以避免出现问题。

重要说明

CronJob 资源使用 Unix Crontab 格式,因此可以使用诸如 @hourly@daily@monthly@yearly 等值。

CronJobs 可以被暂停,如果我们将 suspend 键的值更改为 true,将会影响任何新的 Job 创建。要重新启用 CronJob,我们需要将此键更改为 false,这将继续按照正常的时间表创建新的 Jobs。

CronJobs 的一个常见用例是执行部署在 Kubernetes 上的应用程序的备份任务。通过这个解决方案,我们可以避免将内部应用程序暴露到外部,如果用户访问不是必需的。

现在我们了解了可以用来部署工作负载的不同资源,让我们快速回顾一下它们将如何帮助我们为应用程序提供弹性和高可用性。

使用 Kubernetes 资源确保弹性和高可用性

Pod 资源开箱即用提供弹性,因为我们可以配置它们在进程失败时总是重新启动。我们可以使用 spec.restartPolicy 键来定义它们应何时重新启动。需要理解的是,这个选项仅限于主机范围,因此 Pod 只会尝试在其先前运行的主机上重新启动。Pod 资源并不提供集群范围的高可用性或弹性。

Deployments,因此 ReplicaSets 和 StatefulSets 都是为实现全局弹性而设计的,因为弹性不依赖于主机。Pod 仍然会尝试在它之前运行的节点上重新启动,但如果无法运行,它将被调度到一个新的可用节点。这将允许 Kubernetes 管理员在节点上执行维护任务,将工作负载从一个主机迁移到另一个主机,但如果应用程序没有为这种迁移做好准备,可能会对其产生影响。换句话说,如果你的进程只有一个副本,它们会在几秒钟(或几分钟,具体取决于镜像的大小和进程启动所需的时间)内停止,这将影响你的应用程序。解决方案很简单:部署多个副本的应用程序 Pod。然而,重要的是要理解,你的应用程序需要为多个副本进程并行工作做好准备。

StatefulSets 的副本永远不会使用相同的存储卷,但 Deployments 并非如此。所有副本将共享存储卷,你必须意识到这一点。共享静态内容将非常顺利,但如果多个进程试图同时写入同一个文件,如果你的代码没有处理并发问题,可能会遇到问题。

DaemonSets 的工作方式不同,我们不需要管理任何复制;每个节点上只会运行一个 Pod,但它们也会共享存储卷。由于这种资源的特性,在这种情况下不常见包含共享存储卷。

但即使我们的应用程序以复制方式运行,我们也不能确保所有副本同时停止,除非配置了 Pod 中断策略。我们可以配置一个最小可用 Pod 数量,确保不仅提供弹性,还能实现高可用性。我们的应用程序会受到一定影响,但它将继续提供服务(高可用性)。

要配置中断策略,我们必须使用PodDisruptionBudget资源为我们的应用程序提供所需的逻辑。通过配置minAvailablemaxUnavailable键,我们可以设置在任何情况下我们的应用程序工作负载所需的 Pod 数量。我们可以使用整数(Pod 的数量)或配置的副本百分比。PodDisruptionBudget资源使用选择器在命名空间中的 Pod 之间进行选择(这些选择器我们已经用于创建 Deployments、ReplicaSets 等)。以下代码片段展示了一个示例:

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: webserver-pdb
spec:
  minAvailable: 2
  selector:
    matchLabels:
      app: webserver

在这个示例中,正在监控至少两个带有app=webserver标签的 Pod。我们将在 Deployment 中定义副本数,但PodDisruptionBudget资源不会允许我们将副本缩减到少于两个副本。因此,即使我们决定执行kubectl drain node1(假设在此示例中,webserver Deployment 与app=webserver Pod 的标签匹配,且node1node2各有一个副本),仍将运行两个副本。PodDisruptionBudget资源是命名空间级的,因此我们可以通过执行kubectl get poddisruptionbudgets来查看命名空间中的所有这些资源。

在接下来的部分中,我们将回顾一些有趣的想法,使用 Pod 特性来解决常见的应用架构模式。

理解高级 Pod 应用模式

在本节中,我们将讨论一些使用简单 Pod 的有趣模式。我们将要回顾的所有模式都是基于 Kubernetes 为 Pod 提供的特殊机制,这些机制允许容器在 Pod 内运行时共享内核命名空间,从而使容器能够挂载相同的卷,并通过 localhost 进行互联。

初始化容器

一个容器内可以运行多个 Pod。Pod 允许我们隔离不同的应用程序进程,将它们分别维护在不同的容器中。这有助于我们,例如,维护可以由不同的代码库和构建工作流表示的不同镜像。

初始化容器在主应用容器(如果我们并行运行多个容器)之前运行。这些初始化容器可以用于设置作为卷呈现的共享文件系统上的权限、创建数据库模式或任何有助于初始化应用程序的过程。我们甚至可以使用它们在进程开始之前检查依赖关系,或通过从外部源检索文件来预置所需的文件。

我们可以定义多个初始化容器,它们将按顺序逐个执行,所有容器必须成功完成,才能启动实际的应用容器。如果任何一个初始化容器失败,Pod 也会失败,尽管这些容器没有与之关联的探针来验证它们的状态。它们执行的过程必须包含故障检查,以防出现问题。

理解 Pod 所消耗的总 CPU 和内存资源是从 Pod 初始化开始计算的,因此需要检查初始化容器。你需要确保 Pod 的资源使用保持在定义的限制范围内(包括所有并行运行容器的资源使用)。

Sidecar 容器

使用kubectl patch命令来修改正在运行的 Deployment 资源清单。

一些现代监控应用程序设计为与 Kubernetes 集成,也使用 sidecar 容器来部署应用程序特定的监控组件,从应用程序获取指标并将其暴露为一个新的服务。

接下来我们要审查的几个模式基于这个 sidecar 容器的概念。

大使容器

大使应用程序模式旨在卸载常见的客户端连接任务,帮助遗留应用程序在不改变旧代码的情况下实现更先进的功能。通过这种设计,我们可以通过增加负载均衡、API 网关和 SSL 加密来改善应用程序的路由、通信安全性和弹性。

我们可以通过在 Pod 中添加特殊容器来部署这个模式,这些容器设计用于提供轻量的反向代理功能。通过这种方式,大使容器可用于部署服务网格解决方案,拦截应用程序进程通信,确保与其他应用组件的互联互通,强制加密通信,并管理应用程序路由等功能。

适配器容器

适配器容器模式的使用场景之一是当我们希望在不改变遗留应用程序代码的情况下进行监控或获取日志。为了避免这种情况,我们可以在应用程序的 Pod 中添加第二个容器,从应用程序获取指标或日志,而无需修改任何原始代码。这还允许我们统一日志内容或将其发送到远程服务器。经过精心设计的容器会将进程的标准输出和错误输出重定向到前台,这样我们可以查看它们的日志,但有时应用程序无法重定向日志或会生成多个日志。我们可以将它们合并为一个日志,或通过添加第二个进程(适配器容器)来重定向其内容,该容器会格式化日志(添加一些自定义列、日期格式等),并将结果重定向到标准输出或远程日志组件。此方法不需要对主机资源的特殊访问,并且对于应用程序来说可能是透明的。

Prometheus 是一个非常流行的开源监控解决方案,并在 Kubernetes 环境中扩展。它的主要组件会轮询代理组件并从中获取指标,常常使用这种适配器容器模式来展示应用程序的指标,而无需修改其标准行为。这些指标会在应用程序 Pod 的不同端口暴露,Prometheus 服务器会连接到该端口以获取指标。

让我们了解 Kubernetes 如何验证容器的健康状况,以决定 Pod 的状态。

验证应用程序健康

在这一部分,我们将回顾应用程序 Pods 如何被认为是健康的。Pods 总是从 Pending 状态开始,一旦主容器被认为健康,它们会继续进入 Running 状态。如果 Pod 执行一个服务进程,它将保持在 Running 状态。如果 Pod 与一个 Job 资源关联,它可能会成功结束(Succeeded 状态)或失败(Failed 状态)。

如果我们移除一个 Pod 资源,它将进入 Terminating 状态,直到从 Kubernetes 中完全移除。

重要说明

如果 Kubernetes 无法获取 Pod 的状态,它的状态将为 Unknown。这通常是由于主机的 kubelet 和 API 服务器之间的通信问题。

Kubernetes 会审查容器的状态来设置 Pod 的状态,容器可以是 WaitingRunningTerminated。我们可以使用 kubectl describe pod <POD_NAME> 来查看这些阶段的详细信息。让我们快速回顾一下这些状态:

  • Waiting 表示 Running 之前的状态,在这个阶段会出现所有的容器执行前的流程。此阶段,容器镜像会从镜像仓库拉取,并准备不同的卷挂载。如果 Pod 无法运行,它将进入 Pending 状态,这表明在部署工作负载时出现了问题。

  • Running 表示容器运行正常,没有任何问题。

  • Terminated 状态是在容器停止时被考虑的状态。

如果 Pod 配置了 restartPolicy 属性,类型为 AlwaysOnFailure,那么所有容器将在停止的节点上重新启动。这就是为什么如果节点宕机,Pod 资源既不提供高可用性也不提供弹性。

让我们通过探针的执行来回顾一下 Pod 状态是如何在这些阶段中被评估的。

理解探针的执行

kubelet 会定期执行探针,通过在容器内执行某些代码或直接执行网络请求。根据我们需要检查的应用组件类型,提供了不同类型的探针:

  • exec:该方法在容器内部执行一个命令,kubelet 会验证该命令是否正确退出。

  • httpGet:这种方法可能是最常见的,因为现代应用程序通过 REST API 暴露服务。此检查的响应必须返回 2XX 或 3XX(重定向)代码。

  • tcpSocket:此探针用于检查应用程序的端口是否可用。

  • grpc:如果我们的应用程序通过现代谷歌远程过程调用gRPCs)被使用,我们可以通过这种方式验证容器的状态。

探针必须返回有效值才能认为容器是健康的。不同的探针可以在它们的生命周期中依次执行。我们来看看可用的不同选项,以验证容器的进程是否正在启动或服务应用程序本身。

启动探针

AlwaysOnFailure 在其restartPolicy键中。

如果我们的进程在准备好之前需要很长时间,我们将设置这些探针——例如,当我们启动一个数据库服务器并且它必须处理之前的数据事务才能准备好,或者当我们的进程已经集成了一些顺序检查,最终执行主进程之前。

存活探针

restartPolicy值。当在进程内部管理主进程的失败很困难时,它们将被使用。通过livenessProbe键集成一个外部检查,来验证主进程是否健康,可能会更为简单。

就绪探针

selector部分在此探针成功结束之前不会将此 Pod 标记为准备好接收请求。当探针失败时,同样会将其从可用端点列表中移除。

就绪探针对于管理 Pod 的流量至关重要,因为我们可以确保应用程序组件能正确处理请求。此探针应始终设置,以改善我们的微服务交互。

spec.containers级别,有一些常用的键可以帮助我们自定义不同探针类型的行为。例如,我们可以配置需要多少次失败检查才能将探针视为失败(failureThreshold)或探针类型执行之间的时间间隔(periodSeconds)。我们还可以通过设置initialDelaySeconds键来配置这些探针启动前的延迟,尽管推荐先了解应用程序的工作方式,并调整探针以适应我们的初始顺序。在本章的实验室部分,我们将回顾一些刚才讨论过的探针。

现在我们已经知道 Kubernetes(kubelet 组件)如何验证集群中启动或运行的 Pods 的健康状态,我们还必须理解它们在被认为CompletedFailed时的停止顺序。

Pods 的终止

我们可以使用terminationGracePeriodSeconds键来设置如果 Pod 的进程需要很长时间才能结束,kubelet 会等待多久。当 Pod 被删除时,kubelet 会向它发送SIGTERM信号,但如果花费的时间太长,kubelet 将在terminationGracePeriodSeconds配置的时间达到时,向所有仍在运行的容器进程发送SIGKILL信号。这个时间阈值也可以在探针级别进行配置。

要立即删除一个 Pod,我们可以通过使用kubelet delete pod <POD_NAME> --force并结合--grace-period=0来强制并更改此 Pod 级别定义的宽限期。如果你不了解其工作原理,强制删除 Pod 可能会对你的应用程序造成意想不到的后果。kubectl 客户端发送SIGKILL信号,并且不等待确认,通知 API 服务器 Pod 已经终止。当 Pod 属于 StatefulSet 时,这可能是危险的,因为 Kubernetes 集群将尝试执行一个新的 Pod,而不确认它是否已经被终止。为了避免这些情况,最好是先缩减副本数,然后再扩展副本以进行完全重启。

当我们更新应用的某些组件,甚至当它们因错误失败时,我们的应用可能需要执行一些特定的进程来管理不同组件之间的交互。我们可以在容器启动或停止时包含一些触发器—例如,在集群化应用中重新配置新的主进程。

容器生命周期钩子

Pod 中的容器可以在其规格中包含生命周期钩子。有两种类型可用:

  • PostStart钩子可以用来在容器创建之后执行一个进程。

  • PreStop钩子在容器被终止之前执行。宽限期从 kubelet 接收到停止操作时开始,因此,如果定义的进程花费时间过长,这个钩子可能会受到影响。

当我们的应用需要时,Pods 可以手动进行扩展或缩减,只要得到支持,但我们还可以进一步管理副本的自动化。接下来的部分将展示如何实现这一点。

资源管理和可扩展性

默认情况下,Pods 在没有计算资源限制的情况下运行。这对于了解应用程序的行为是可以的,而且它有助于你定义其需求和限制。

Kubernetes 集群管理员还可以定义配额,这些配额可以在不同级别进行配置。通常会在命名空间级别定义这些配额,您的应用将受到 CPU 和内存限制。但这些配额也可以识别一些特殊资源,如 GPU、存储,甚至可以在命名空间中部署的资源数量。在这一部分,我们将学习如何在 Pod 和容器中限制资源,但在部署之前,您应始终询问 Kubernetes 管理员是否在命名空间级别应用了任何配额,以准备好遵守这些配额。有关资源配额配置的更多信息,可以在 Kubernetes 官方文档中找到:kubernetes.io/docs/concepts/policy/resource-quotas

我们将使用spec.resources部分来定义与 Pod 相关的限制和请求。让我们来看看它们如何工作:

  • spec.resources.requests.memoryspec.resources.requests.cpu分别允许我们定义在任何集群主机上运行 Pod 所需的最小资源。

  • spec.resources.limits.memoryspec.resources.limits.cpu分别配置最大内存和可分配的 CPU 数量。

资源可以在 Pod 或容器级别定义,并且它们必须相互兼容。所有容器资源限制的总和不得超过 Pod 的资源值。如果我们省略 Pod 资源,则将使用定义的容器资源的总和。如果任何容器没有资源定义,则将使用 Pod 的限制和请求。容器的等效键为spec.containers[].resources

内存限制和请求将以字节为单位进行配置,我们可以使用后缀如kiMiGiTi表示以 1,000 为倍数的内存,或使用kMT表示以 1,024 为倍数的内存。例如,要指定 100 MB 的内存限制,我们将使用100M。当达到允许的内存限制时,OOMKiller将在执行主机中触发,Pod 或容器将被终止。

对于 CPU,我们将定义允许或请求的 CPU 数量(无论是物理 CPU 还是虚拟 CPU),如果我们正在定义请求限制。当 CPU 限制达到时,容器或 Pod 将无法获得更多的 CPU 资源,这可能会使您的 Pod 被认为是不健康的,因为检查会失败。CPU 资源必须以整数或小数形式进行配置,并且我们可以添加m作为后缀来表示毫核;因此,0.5 个 CPU 也可以写作500m,而 0.001 个 CPU 将表示为1m

重要提示

当我们使用 Linux 节点时,可以请求并限制巨大页资源,这使我们能够定义内核为内存块分配的页大小。必须使用特定的键名;例如,spec.resources.limits.hugepages-2Mi使我们能够定义为 2 MiB 巨大页分配的内存块限制。

你的管理员可以为某些 LimitRange 资源做准备,这些资源将定义与 Pod 资源相关的限制和请求的约束。

现在我们知道如何限制和请求资源,我们可以通过增加限制来垂直扩展工作负载。而水平扩展则需要复制 Pods。接下来我们将继续学习如何动态地水平扩展与运行中工作负载相关的 Pods。

重要提示

垂直 Pod 自动扩展也是 Kubernetes 内部的一个项目。它不太流行,因为垂直扩展会影响当前的 Deployments 或 StatefulSets,因为它需要扩展运行中副本的资源数量。这使得垂直扩展应用起来更为复杂,通常更好的做法是精细化管理应用中的资源,使用水平 Pod 自动扩展,它不会修改当前的副本规格。

水平 Pod 自动扩展

HorizontalPodAutoscaler 作为控制器工作。当工作负载的负载增加或减少时,它会增加或减少 Pod 的副本数量。自动扩展仅适用于 Deployments(通过扩展和修改其 ReplicaSets)和 StatefulSets。为了衡量与特定工作负载相关的资源消耗,我们需要在集群中加入一个工具,例如 Kubernetes Metrics Server。这个服务器将用于管理标准度量指标。可以通过其清单文件轻松部署,地址为 github.com/kubernetes-sigs/metrics-server。如果你在笔记本电脑或台式机上使用 Minikube,它也可以作为可插拔的附加组件运行。

我们将定义一个 HorizontalPodAutoscalerhpa)资源;控制器将检索并分析 hpa 定义中指定的工作负载资源的度量。

可以为 hpa 资源使用不同类型的度量指标,虽然最常用的是 Pod 的 CPU 消耗。

可以定义与 Pod 相关的度量指标,控制器会检查这些度量指标并通过结合这些度量与集群可用资源和 Pod 状态的算法进行分析(kubernetes.io/docs/tasks/run-application/horizontal-pod-autoscale/#algorithm-details),然后决定是否需要扩展关联的资源(Deployment 或 StatefulSet)。

要定义一个 hpa 资源,我们需要设置一个度量指标来进行分析,并指定一个副本的范围(最大和最小副本数)。当该值达到时,控制器会检查当前的副本数,如果还有空间创建新的副本,则会创建新的副本。hpa 资源可以以命令式或声明式格式定义。例如,要管理最少两个 Pods 和最多十个 Pods,当当前 Pods 的 CPU 使用率超过 50% 时,可以使用以下语法:

kubectl autoscale <RESOURCE_TYPE> <RESOURCE_NAME> --cpu-percent=50 --min=2 --max=10

当资源的 CPU 消耗超过 50% 时,会创建一个副本;当该指标低于此值时,会减少一个副本;但是,我们永远不会执行超过 10 个副本或少于两个副本。

重要说明

我们可以通过添加 –o yaml 来查看用于创建任何资源的清单。清单将显示出来,我们可以验证其值。例如,我们可以使用 kubectl autoscale deploy webserver --cpu-percent=50 --min=2 --max=10 -o yaml

如果我们想在创建资源之前查看值,可以添加 --dry-run=client 参数,只显示清单,而不实际创建资源。

由于 hpa 资源是命名空间的,我们可以通过执行 kubectl get hpa -A 获取所有已部署的 hpa 资源。

通过这一点,我们已经看到 Kubernetes 如何通过使用特定的资源提供开箱即用的弹性、高可用性和自动扩展功能。在下一节中,我们将学习它如何提供一些有趣的安全功能,帮助我们提升应用程序的安全性。

提升应用程序安全性与 Pods

在 Kubernetes 集群中,我们可以将分布在集群中的应用程序工作负载分类为特权或非特权。除非严格必要,否则应始终避免为普通应用程序使用特权工作负载。在本节中,我们将帮助你通过在工作负载清单中声明需求,定义应用程序的安全性。

安全上下文

在安全上下文中,我们定义了 Pod 或其中包含的容器所需的特权和安全配置。安全上下文允许我们配置以下安全功能:

  • runAsUser/runAsGroup:这些选项管理运行容器中主进程的 userIDgroupID 属性。我们可以通过使用 supplementalGroups 键添加更多的组。

  • runAsNonRoot:该键可以控制是否允许进程以 root 身份运行。

  • fsGroup/fsGroupChangePolicy:这些选项管理 Pod 中包含的卷的权限。fsGroup 键将设置作为卷挂载的文件系统的所有者,以及任何新文件的所有者。我们可以使用 fsGroupChangePolicy 仅在权限与配置的 fsGroup 不匹配时应用所有权更改。

  • seLinuxOptions/seccompProfile:这些选项允许我们通过配置特定的 SELinux 标签和 seccomp 配置文件来覆盖默认的 SELinux 和 seccomp 设置。

  • capabilities:内核能力可以添加或移除(drop),以仅允许特定的内核交互(容器共享主机的内核)。你应避免在应用程序中使用不必要的能力。

  • privileged/AllowPrivilegeEscalation: 我们可以通过将privileged键设置为true来允许容器内部的进程以privileged身份(拥有所有能力)执行,或者通过将AllowPrivilegeEscalation设置为true来允许进程获得权限,即使privileged键被设置为false。在这种情况下,容器进程并不具备所有能力,但它们会允许内部进程像拥有CAP_SYS_ADMIN能力一样运行。

  • readOnlyRootFilesystem: 总是非常好的做法是将容器的根文件系统设置为只读模式运行。这将不允许进程对容器内的任何内容进行更改。如果你理解应用程序的需求,你将能够识别出可能需要更改的任何目录,并添加适当的卷以正确运行你的进程。例如,通常会将/tmp添加为单独的临时文件系统(emptyDir)。

其中一些键可用于容器或 Pod 级别,或两者都有。使用kubectl explain pod.spec.securityContextkubectl explain pod.spec.containers.securityContext来获取每个作用域中可用选项的详细列表。你必须了解所使用的作用域,因为 Pod 规范适用于所有容器,除非在容器作用域下存在相同的键——在这种情况下,将使用容器作用域中的值。

让我们回顾一下可以准备的最佳设置,以提升我们的应用程序安全性。

安全最佳实践

以下列表展示了一些常用的安全设置。如果你是开发者,可以通过确保为你的 Pods 启用以下安全措施来提升应用程序的安全性:

  • runAsNonRoot必须始终设置为true,以避免在容器中使用root。确保还配置runAsUserrunAsGroup,并将它们设置为大于1000的 ID。你的 Kubernetes 管理员可以为你的应用程序推荐一些 ID。这将有助于在集群范围内控制应用程序 ID。

  • 始终禁用所有能力,只启用应用程序所需的能力。

  • 除非绝对必要,否则不要为应用程序使用特权容器。通常,只有与监控或内核相关的应用程序需要特殊权限。

  • 识别应用程序所需的文件系统,并始终将readOnlyRootFilesystem设置为true。这个简单的设置提高了安全性,禁止了任何意外的更改。所需的文件系统可以作为卷挂载(有很多选项可用,我们将在第十章,“在 Kubernetes 中利用应用数据管理”中学习)。

  • 向你的 Kubernetes 管理员询问是否有一些 SELinux 设置需要考虑并应用于你的 Pods。这同样适用于seccomp配置文件。管理员可能已经配置了默认配置文件。请向管理员了解这种情况,以避免任何系统调用问题。

  • 管理员可能已经在使用诸如 Kyverno 或 OPA Gatekeeper 之类的工具来提高集群安全性。在这些情况下,他们可以通过在 Kubernetes 集群中使用准入控制器来强制执行安全上下文设置。这些功能的使用超出了本书的范围,但您可以向管理员询问在您的 Kubernetes 平台中执行应用程序所需的合规规则。

在接下来的部分中,我们将回顾如何通过准备多组件应用程序来实现本章中学习的一些 Kubernetes 特性,该应用程序在之前的章节中已经使用过(Chapter 5创建多容器应用程序,以及Chapter 7使用 Swarm 进行编排),在 Kubernetes 上运行。

实验

本节将向您展示如何在 Kubernetes 中部署simplestlab三层应用程序。我们已经为您准备了所有组件的清单,这些清单遵循了本章中解释的技术和 Kubernetes 资源。您将能够验证不同选项的使用,并能够根据本章描述的内容和最佳实践进行操作。

这些实验的代码可以在本书的 GitHub 存储库中找到:github.com/PacktPublishing/Containers-for-Developers-Handbook.git。通过简单执行 git clone github.com/PacktPublishing/Containers-for-Developers-Handbook.git 来下载其所有内容,或者如果您之前已经下载了存储库,则执行 git pull 更新至最新版本。所有清单和运行simplestlab所需的步骤都位于Containers-for-Developers-Handbook/Chapter9目录中。

在 GitHub 上的实验中,我们将通过定义适当的资源清单在 Kubernetes 上部署simplestlab应用程序,该应用程序在之前的章节中已经使用过。

  • 数据库组件将使用 StatefulSet 资源部署。

  • 应用程序后端组件将使用 Deployment 资源部署。

  • 负载均衡器(或展示器)组件将使用 DaemonSet 资源部署。

在其清单中,我们已经包含了本章中学习的一些机制,用于检查组件的健康状况、复制其进程,并通过不允许其作为 root 用户执行等功能来提高其安全性。让我们从回顾和部署数据库组件开始:

  1. 我们将使用一个 StatefulSet 来确保复制其进程(扩展)永远不会成为我们数据的问题。重要的是要理解,新的副本将空白地开始,没有数据,并加入到可用端点池中,这可能会成为一个问题。这意味着在这些条件下,Postgres 数据库不可扩展,因此此组件部署为 StatefulSet,以保持其数据,即使在手动复制的情况下也是如此。此示例仅提供了弹性,因此不要扩展此组件。如果您需要部署具有高可用性的数据库,则需要像 MongoDB 这样的分布式数据库。完整的数据库清单可以在 Chapter9/db.satatefulset.yaml 中找到。这里是来自此文件的一个小节选:

    apiVersion: apps/v1
    kind: StatefulSet
    metadata:
      name: db
      labels:
        component: db
        app: simplestlab
    spec:
      replicas: 1
      selector:
        matchLabels:
          component: db
          app: simplestlab
      template:
        metadata:
          labels:
            component: db
            app: simplestlab
        spec:
          securityContext:
            runAsNonRoot: true
            runAsUser: 10000
            runAsGroup: 10000
            fsGroup: 10000
    ...
      volumeClaimTemplates:
      - metadata:
          name: postgresdata
        spec:
          accessModes: [ "ReadWriteOnce" ]
          #storageClassName: "csi-hostpath-sc"
          resources:
            requests:
              storage: 1Gi
    

    在这里,我们定义了用于创建 Pod 的模板以及用于 VolumeClaims 的单独模板(我们将在 第十章 中讨论它们)。这确保每个 Pod 都将获得自己的卷。创建的卷将被挂载到数据库容器中作为 /data 文件系统,其大小将为 1,000 MB(1 GiB)。不会创建其他容器。设置并传递了 POSTGRES_PASSWORDPGDATA 环境变量到容器中。它们将用于为 Postgres 用户创建密码和数据库数据的补丁。用于容器的镜像是 docker.io/frjaraur/simplestdb:1.0,并将使用端口 5432 来公开其服务。Pod 仅在 Kubernetes 网络内部公开其服务,因此您永远无法从远程客户端访问这些服务。我们指定了一个副本,并且控制器将通过搜索带有 component=dbapp=simplestlab 标签的 Pod 将这些 Pod 与此 StatefulSet 关联起来。我们通过仅检查到端口 5432 的 TCP 连接来简化数据库的探测。我们在 Pod 级别定义了安全上下文,这将默认应用于所有容器:

         securityContext:
            runAsNonRoot: true
            runAsUser: 10000
            runAsGroup: 10000
            fsGroup: 10000
            fsGroupChangePolicy: OnRootMismatch
    
  2. 数据库进程将作为 10000:10000user:group 运行,因此它们是安全的(不需要 root)。如果将容器设置为只读,我们本可以更进一步,但在这种情况下我们没有这样做,因为 Docker 的官方 Postgres 镜像;然而,使用完全只读文件系统会更好。

    Pod 将获得一个 IP 地址,但如果 Pod 由于任何原因重新创建,此 IP 地址可能会更改,这使得 Pod 的 IP 地址在这种动态环境下无法使用。我们将使用一个 Service 来将一个 固定的 IP 地址与 Service 关联,然后与与 Service 相关的 Pod 的端点关联起来。

  3. 以下是从 Service 清单中提取的内容(您可以在 Chapter9/db.service.yaml 中找到):

    apiVersion: v1
    kind: Service
    metadata:
      name: db
    spec:
      clusterIP: None
      selector:
        component: db
        app: simplestlab
     ...
    

    该 Service 通过使用选择器(components=dbapp=simplestlab标签)与 Pods 关联,Kubernetes 将流量路由到适当的 Pods。当 TCP 数据包到达 Service 的端口5432时,它会负载均衡到所有可用 Pod 的端点(在这种情况下,我们只有一个副本),端口为5432。在这两种情况下,我们使用了端口5432,但你必须理解,targetPort指的是容器端口,而端口键指的是 Service 的端口,它们可以是完全不同的。我们使用的是无头 Service,因为它与 StatefulSets 及其轮询模式解析非常兼容。

  4. 使用 StatefulSet 定义和 Service,我们可以部署数据库组件:

    PS Chapter9> kubectl create -f .\db.statefulset.yaml
    statefulset.apps/db created
    PS Chapter9> kubectl create -f .\db.service.yaml
    service/db created
    PS Chapter9> kubectl get pods
    NAME   READY   STATUS    RESTARTS   AGE
    app component.
    
  5. 该应用程序(后端组件)作为Deployment工作负载进行部署。让我们来看一下其清单的一个片段:

    apiVersion: apps/v1
    kind: Deployment
    metadata:
      name: app
      labels:
        component: app
        app: simplestlab
    spec:
      replicas: 3
      selector:
        matchLabels:
          component: app
          app: simplestlab
      template:
        metadata:
          labels:
            component: app
            app: simplestlab
        spec:
          securityContext:
            runAsNonRoot: true
            runAsUser: 10001
            runAsGroup: 10001
    

    你可以在Chapter9/app.deployment.yaml文件中找到完整的清单。

  6. 对于该组件,我们定义了三个副本,因此会在集群中部署三个 Pods。此组件使用docker.io/frjaraur/simplestapp:1.0镜像。我们已配置了两个安全上下文,其中一个是在 Pod 级别:

         securityContext:
            runAsNonRoot: true
            runAsUser: 10001
            runAsGroup: 10001
    

    第二个用于强制容器使用只读文件系统:

           securityContext:
              readOnlyRootFilesystem: true
    
  7. 在这里,我们为readinessProbe使用了httpGet,但仍然保留tcpSocket用于livenessProbe。我们将/healthz作为应用程序的健康检查端点,用于检查其健康状态。

  8. 在此组件中,我们为应用容器添加了资源部分:

           resources:
              requests:
                cpu: 10m
                memory: 20M
              limits:
                cpu: 20m
                memory: 30Mi
    

    在这种情况下,我们要求 Kubernetes 至少分配 10 毫核心 CPU 和 20M 内存。limits部分描述了最大 CPU(20 毫核心)和内存(30Mi)。如果内存限制被达到,Kubelet 将触发 OOM-Killer 过程并杀死容器。当 CPU 限制达到时,内核不会再为容器提供更多的 CPU 周期,这可能导致探针失败,进而导致容器崩溃。这个组件是无状态的,并且完全以只读模式运行。

重要提示

在完整的 YAML 文件清单中,你将看到我们使用了环境变量来传递敏感数据。始终避免通过环境变量传递敏感数据,因为任何能够访问你的清单文件的人都能读取这些数据。我们将学习如何在第十章中包含敏感数据,在 Kubernetes 中利用应用数据管理

  1. 我们还将为访问app Deployment工作负载添加一个 Service:

    apiVersion: v1
    kind: Service
    metadata:
      name: app
    spec:
      selector:
        app: simplestlab
        component: app
      ports:
        - protocol: TCP
          port: 3000
          targetPort: 3000
    
  2. 我们创建了这两个 Kubernetes 资源:

    PS Chapter9> kubectl create -f .\app.deployment.yaml `
    -f .\app.service.yaml
    deployment.apps/app created
    service/app created
    PS Chapter9> kubectl get pods
    NAME                  READY   STATUS    RESTARTS   AGE
    db-0                  1/1     Running   0          96s
    app-585f8bb87-r8dqh   1/1     Running   0          41s
    app-585f8bb87-wsfm7   1/1     Running   0          41s
    configMap with these special configurations for NGINX; you will find it as Chapter9/ lb.configmap.yaml. This configuration will allow us to run as user 101 (nginx). We created this configMap before the actual DaemonSet; although it is possible to do the opposite, it is important to understand the requirements and prepare them before the workload deployment. This configuration allows us to run NGINX as non-root on a port greater than 1024 (system ports). We will use port 8080 to publish the loadbalancer component.Notice that we added a `proxy_pass` sentence to reroute the requests for `/` to `http://app:3000`, where `app` is the Service’s name, resolved via internal DNS. We will use `/healthz` to check the container’s healthiness.
    
  3. 让我们来看一下 DaemonSet 清单的一个片段:

    apiVersion: apps/v1
    kind: DaemonSet
    metadata:
      name: lb
      labels:
        component: lb
        app: simplestlab
            image: docker.io/nginx:alpine
            ports:
            - containerPort: 8080
            securityContext:
              readOnlyRootFilesystem: true
            volumeMounts:
            - name: cache
              mountPath: /var/cache/nginx
            - name: tmp
              mountPath: /tmp/nginx
            - name: conf
              mountPath: /etc/nginx/nginx.conf
              subPath: nginx.conf
    ...
          volumes:
          - name: cache
            emptyDir: {}
          - name: tmp
            emptyDir: {}
          - name: conf
            configMap:
              name: lb-config
    

    请注意,我们添加了/var/cache/nginx/tmp作为emptyDir卷,如前所述。该组件也将是无状态的,并且以只读模式运行,但必须创建一些临时目录作为emptyDir卷,这样可以在不允许整个容器文件系统的情况下写入数据。

  4. 创建了以下安全上下文:

    • 在 Pod 级别:

           securityContext:
              runAsNonRoot: true
              runAsUser: 101
              runAsGroup: 101
      
    • 在容器级别:

             securityContext:
                readOnlyRootFilesystem: true
      
  5. 最后,我们有了 Service 定义,在这里我们将使用 NodePort 类型来快速暴露我们的应用程序:

    apiVersion: v1
    kind: Service
    metadata:
      name: lb
    spec:
      type: NodePort
      selector:
        app: simplestlab
        component: lb
      ports:
        - protocol: TCP
          port: 80
          targetPort: 8080
          nodePort: 32000
    
  6. 现在,让我们部署所有的 lb 组件(前端)清单:

    PS Chapter9> kubectl create -f .\lb.configmap.yaml
    configmap/lb-config created
    PS Chapter9> kubectl create -f .\lb.daemonset.yaml
    daemonset.apps/lb created
    PS Chapter9> kubectl create -f .\lb.service.yaml
    32000. Your browser should access the application and show something like this (if you’re using Docker Desktop, you will need to use http://localhost:32000):
    

图 9.9 – simplestlab 应用程序 Web 图形用户界面

图 9.9 – simplestlab 应用程序 Web 图形用户界面

你可以在 Chapter9 代码库中找到关于扩展和缩减应用程序后端组件的额外步骤。本章中包含的实验将帮助你理解如何使用不同的 Kubernetes 资源来部署应用程序。

总结

在本章中,我们了解了可以帮助我们在 Kubernetes 中部署应用工作负载的资源。我们查看了运行复制进程的不同选项,并验证其健康状态,以提供弹性、高可用性和自动扩展。我们还学习了一些 Pod 特性,这些特性可以帮助我们实现高级模式并提高整体应用程序的安全性。现在,我们已经准备好使用最佳模式部署我们的应用程序,并应用和自定义 Kubernetes 提供的资源,同时知道如何实施适当的健康检查,并在平台中限制资源消耗。

在下一章,我们将深入探讨管理 Kubernetes 中的数据以及如何将其呈现给我们的应用程序的选项。

第十章:在 Kubernetes 中利用应用程序数据管理

在 Kubernetes 中部署应用程序有助于通过使用副本实例来管理弹性、高可用性 (HA) 和可扩展性。但如果不了解应用程序的实际工作方式以及如何管理其数据,这些功能都无法使用。本章中,我们将回顾如何创建和管理 SecretsConfigMaps 和不同的 volume 选项。虽然 Secret 和 ConfigMap 资源将用于在容器内集成不同的身份验证选项,但卷用于管理应用程序的数据,正如我们在 第八章中简要介绍的内容,使用 Kubernetes 编排器部署应用程序。应用程序可以是有状态的、无状态的,或像通常情况那样是两者的组合。本章将介绍管理数据的不同选项,并将其与应用程序生命周期分开。

本章回顾了以下主要概念:

  • 理解应用程序中的数据

  • 使用 ConfigMaps 应用配置

  • 使用 Secret 资源管理敏感数据

  • 管理无状态和有状态数据

  • 使用 PersistentVolume (PV) 资源增强 Kubernetes 中的存储管理

技术要求

您可以在 github.com/PacktPublishing/Containers-for-Developers-Handbook/tree/main/Chapter10 找到本章的实验,其中包含了一些在章节内容中省略的详细说明,目的是为了更容易跟随。您可以在 packt.link/JdOIY 找到本章的 Code In Action 视频。

由于您应用程序所使用的数据非常重要,我们将首先回顾在 Kubernetes 中可以使用的不同选项和资源。

理解应用程序中的数据

微服务架构通过将功能分布到不同的模块中来提高应用程序的性能和弹性,这使得我们能够对其进行横向扩展或缩减,并在某些组件故障时继续提供部分功能。但功能的分布意味着与每个组件相关的数据也会分布,并在多个组件需要时共享它。还需要理解的是,应用程序必须允许在不破坏数据的情况下进行扩展,以防多个副本同时访问相同的数据。将应用程序组件作为容器运行将帮助我们分布进程,保持每个副本中相同的数据内容,并快速启动和停止进程。容器运行时将定义的卷附加到容器,但它不管理应用程序的逻辑。这就是为什么在准备应用程序在容器编排环境中运行时,理解数据如何使用是至关重要的。

容器编排器将为你提供将配置注入容器的机制,并保持这些配置在所有容器副本中同步。这些配置可以作为容器中的文件或环境变量使用。你必须明白,如果你的应用程序使用明文配置,在攻击者进入容器后,你将无法保护它们。这种情况始终存在,即使你在将配置注入容器之前加密它们。如果你的代码以明文方式读取配置内容,它始终是可以访问的,因为权限足够允许你的进程读取配置,并且容器将使用主进程用户来附加任何新进程(通过docker execkubectl exec)。如果你使用环境变量,任何在容器内运行的进程都能轻松读取它们。但这些并不意味着你的信息在容器内部不安全。不同的机制,如 RBAC,允许我们限制从编排平台对容器的访问,但访问集群节点将覆盖平台的安全性。你永远不应该从集群节点执行命令。你的容器编排器管理员可能会为你提供一个完整的持续部署CD)解决方案,或者你也可以使用平台客户端(命令行或图形界面)来访问。

向应用程序的容器注入数据可以通过以下任何机制实现:

  • arguments 是任何 Pod 资源的关键。你永远不应通过这种方法传递敏感数据。

  • 环境变量:使用环境变量包含信息是很常见的做法。一些编程语言甚至有标准化的命名法来直接操作变量;无论如何,您的应用程序必须准备好使用它们。在包括敏感数据时,也应该避免这种方法,除非它与 Secret 资源结合使用,正如我们将在下一节中学习到的。

  • ConfigMaps:这些资源是将配置添加到我们的工作负载中的最佳选择。我们可以使用它们将文件添加到容器内部,确保它们在任何节点上运行实例时都能可用。容器编排工具将管理并同步其内容的任何更改。它们也可以用于设置一些带有值的变量,并将它们用作环境变量。

  • Secrets:Secret 资源是管理 Kubernetes 或 Docker Swarm 平台中敏感数据的合适方法。然而,它们在每个平台中封装的方式存在很大差异。Docker Swarm 对其内容进行加密,且我们甚至无法检索其内容,而 Kubernetes 使用 Base64 格式在集群中存储内容。可以启用加密来保护在 etcd 中存储的 Secret 资源,但默认情况下是未启用的。这只会影响 etcd 数据库,而正常用户不应能访问该数据库,但如果您对存储在 Secret 资源中的数据感到担忧,可以向您的 Kubernetes 管理员询问相关配置。使用 Secret 资源来添加敏感文件(如密码、身份验证令牌、证书,甚至是容器镜像注册表连接字符串)或将变量呈现给容器是非常常见的做法。

  • Volumes:Volumes 不是用来注入数据的,而是用来在容器执行期间存储数据的。它们可以是短暂的或持久的,用于在执行之间持久化数据。在 Kubernetes 中,我们将 Volumes 视为集成到 Kubernetes 平台代码中的存储资源。许多云存储解决方案从 Kubernetes 开发之初就已集成,因为它是设计的一部分,尽管也可以使用主机绑定挂载、临时目录、NFS 和其他本地解决方案。ConfigMap 和 Secret 资源也属于 Volumes,但由于它们的内容不同,我们会对它们进行不同的处理。

  • 向下 API:尽管向下 API 被认为是一个 Volume 资源,但由于其使用方式,我们可以将其视为完全不同的概念。您可以通过使用向下 API 挂载来自当前命名空间资源的元数据,以便在应用程序中使用,向下 API 会自动管理向 Kubernetes API 发出的必要请求来获取这些信息。

  • PVs: PV 是由 Kubernetes 管理员提供的存储,用于满足应用组件的 PersistentVolumeClaim (PVC) 资源的存储需求。每当创建 PVC 资源时,如果存在符合所需大小和属性的空闲 PV 资源,则会将其绑定。

重要提示

还存在 Projected Volumes 的概念,这是容器内同一目录中集成的不同卷的特定映射。此功能允许我们在同一目录中定位 Secret 和 ConfigMap 资源。

我们将学习如何通过使用 ConfigMaps 来包含非敏感信息,将数据注入并在容器内使用。

使用 ConfigMaps 应用配置

在本节中,我们将学习如何使用 ConfigMap 资源,在容器内挂载文件或作为环境变量,并为我们应用的进程呈现信息。

ConfigMap 资源的内容存储在 Kubernetes 的 etcd 键值存储中。因此,内容大小不能超过 1 MB。这些资源的清单没有 spec 部分。相反,我们可以使用 databinaryData(用于 Base64 内容)键来定义内容。以下截图显示了一个 ConfigMap 清单示例:

图 10.1 – ConfigMap 资源清单

图 10.1 – ConfigMap 资源清单

在所示的截图中的代码中,我们声明了两种类型的配置。APP_VAR1APP_VAR2 以键值对的形式定义,而 appsettings 部分定义了一个完整的配置文件,可以被挂载。请注意,管道符号 (|) 用于定义 appsettings 键。这允许我们将所有后续内容包含为键的值。在编写 YAML 文件时务必注意缩进,以避免文件内容出现问题。

让我们看看如何在 Pod 中使用这些配置:

图 10.2 – 使用 ConfigMap 资源的 Pod 资源清单

图 10.2 – 使用 ConfigMap 资源的 Pod 资源清单

在前面截图中显示的 Pod 清单中,我们展示了两种使用 ConfigMap 资源中声明信息的机制。我们使用了 settings ConfigMap 中的键值对作为环境变量。但是 settings ConfigMap 中 appsettings 键的内容被作为 demo 容器中的一个卷展示。在这种情况下,将创建一个 /app/config/appsettings 文件,其内容来自 appsettings 键。请注意,我们使用 ReadOnly 键来定义挂载的文件不可写。

在这个例子中,我们没有使用最简单的挂载配置文件的机制。让我们看看如何简单地添加一个完整的配置文件,使用kubectl create configmap <CONFIGMAP_NAME> --from-file=<CONFIGURATION_FILE>创建。我们将以appsettings.json文件为例,文件内容可以是任何内容,且是使用kubectl create cm appsettings –from-file=appsettings.json创建的:

apiVersion: v1
kind: Pod
metadata:
  name: mypod
spec:
  containers:
  - name: mypod
    image: myregistry/myimage
    volumeMounts:
    - name: config
      mountPath: "/app/config/appsettings.json"
      subPath: appsettings.json
      readOnly: true
  volumes:
  - name: config
    configMap:
      name: appsettings

在这种情况下,我们使用了subPath键来设置配置文件的文件名和完整路径。

配置文件可以随时更新,除非我们使用了immutable键(默认为false),在这种情况下,我们需要重新创建资源。要修改内容或任何允许的键(使用kubectl explain configmap查看),我们可以使用以下任意方法:

  • 使用kubectl edit在线编辑并修改其值。

  • 通过使用kubectl patch修补文件(kubernetes.io/docs/tasks/manage-kubernetes-objects/update-api-object-kubectl-patch)。

  • 通过使用kubectl replace –f <MANIFEST_FILE>替换资源文件。这是首选方法,因为所有更改都可以通过存储清单文件来追踪(使用GitOps方法论,正如我们将在第十三章中学习的,管理应用生命周期)。

配置映射资源将会在正在运行的工作负载中更新,除非它们的值作为环境变量被使用,或者通过subPath键挂载到容器中,在这种情况下将不会更新。非常重要的是理解 Kubernetes 如何在应用工作负载中管理更新。即使配置被更新,也取决于你的应用如何使用它、何时加载以及这些更改如何影响容器进程。如果你的进程只在启动时读取配置,那么你需要重新创建应用的 Pods。因此,确保新配置生效的唯一方法是重新创建容器。根据你为配置使用的工作负载,你只需要删除或缩放资源,以便使配置更改生效。

重要提示

我们可以使用kubectl create cm <CONFIGMAP_NAME> --from-literal=KEY=VALUE直接创建包含键值对的配置映射资源。

我们可以将注解添加到 ConfigMap 资源中,并更新它们以触发工作负载的更新。这将确保您的 Pods 会被重新创建,从而 ConfigMap 会立即更新。默认情况下,当 ConfigMap 被更新时,Kubernetes 会定期更新使用此配置的 Pods 中的内容。如果您使用 subPath 键挂载内容,则此自动更新机制不起作用。然而,请注意,更新文件内容并不意味着更新您的应用程序;它取决于应用程序的工作方式以及配置刷新频率。

Kubernetes 还允许我们在运行时将信息包含在 Pods 中。我们将使用向下 API 将 Kubernetes 数据注入 Pods,相关内容将在下一节中学习。

使用向下 API 注入配置数据

我们将使用向下 API 挂载端点来注入关于当前 Pod 资源的信息。通过这种方式,我们可以导入诸如 Pod 名称(metadata.name)、注解、标签和服务账户等信息。例如,可以通过环境变量传递信息或将其挂载为卷文件。

假设一个 Pod 拥有以下注解:

...
metadata:
  annotations:
    environment: development
    cluster: cluster1
    location: Berlin
...

我们可以通过卷定义和其中的 mountPath 参数将此信息挂载到 Pod 的容器中:

...
  containers:
  ...
    volumeMounts:
        - name: podinfo
          mountPath: /etc/pod-annotations
  volumes:
    - name: podinfo
      downwardAPI:
        items:
          - path: "annotations"
            fieldRef:
              fieldPath: metadata.annotations
...

请注意,我们正在将注解注入到 /etc/pod-annotations 文件中。我们可以使用静态数据(手动添加)或动态信息(从 Kubernetes 中获取)。

接下来,我们来看一下如何通过使用 Secrets 来包含敏感数据。

使用 Secret 资源管理敏感数据

我们应始终避免将敏感信息添加到应用镜像中。密码、连接字符串、令牌、证书或许可证信息都不应写入容器镜像中;所有这些内容必须在运行时包含。因此,代替使用以明文存储的 ConfigMap 资源,我们将使用 Secrets。Kubernetes Secret 资源内容采用 base64 格式描述。它们并未加密,任何有权限访问的人都可以读取其数据。这包括任何可以在同一命名空间中创建 Pod 的用户,因为 Secret 可以被包含并因此被读取。只有适当的 RBAC 资源访问权限才能确保 Secret 的安全。因此,重要的是要理解,应该通过适当的 Kubernetes 角色角色绑定(Kubernetes RBAC)来避免访问您的 Secret 资源。此外,默认情况下,Kubernetes 不会加密 etcd 中的 Secrets,因此不应允许在文件系统级别访问键值数据文件。Secrets 是命名空间级资源,因此我们能够在命名空间级别管理 Kubernetes 访问权限。Kubernetes 管理员应确保在集群级别的适当访问。

我们将使用 Secret 资源作为 (呈现文件,如证书或令牌)、环境变量(当查看在线 Pod 资源清单时,内容被隐藏)或作为 认证 来访问远程注册表(本地或云服务)。

我们可以使用 kubectl create secret generic <SECRET_NAME> --from-file=<SENSITIVE_DATA_FILE>kubectl create secret generic <SECRET_NAME> --from-literal=SECRET_VARIABLE_NAME=SECRET_VALUE--from-file--from-literal 参数可以多次使用,以添加多个数据键。可以创建以下类型的 Secret 资源:

  • generic:这是最常见的类型,可以用于包含任何敏感文件或值。

  • tls:这表示包含其内容的 SSL_CERT_FILE 变量,或者在配置中使用关联的 .cert.key 文件。

  • docker-registry:这些资源可以包含 dockercfgdockerconfigjson 内容。它们将用于配置从注册表拉取镜像的配置文件。

默认情况下,Kubernetes 为每个服务帐户自动创建一个 Secret。这些 Secret 资源包含一个关联的令牌,可用于从应用程序组件与 Kubernetes API 交互。此令牌用于对您的进程进行认证和授权。

如果您的 Kubernetes 管理员配置了与您应用程序的命名空间相关联的 ResourceQuota 资源,请向他们询问,因为像许多其他资源一样,Secret 也可能会受到数量限制。因此,在创建大量 Secret 时,您可能会受到限制,并且需要考虑包含的信息。Secret 内容的大小也限制为 1 MB,这通常足够用于传递敏感配置。如果需要更多空间,您可能需要使用适当的 Volumes 或 PVs。

让我们来看一个快速示例。我们使用 kubectl create secret settings --from-literal=user=test --from-literal=pass=testpass --from-file=mysecretfile --dry-run=client -o yaml 来生成以下 Secret 清单:

图 10.3 – Secret 清单

图 10.3 – Secret 清单

我们现在将使用 Pod 中的 Secret 值作为环境变量,并作为卷进行挂载:

图 10.4 – Secret 资源使用示例

图 10.4 – Secret 资源使用示例

在前面的截图中,我们使用了 userpass 两次。首先,我们将它们的值作为环境变量添加,但我们也将它们作为卷挂载到 /etc/settings 中。在此示例中,创建了三个文件:/etc/settings/user/etc/settings/pass/etc/settings/mysecretfile。每个文件的内容在 Secret 中进行了定义。请注意,我们使用 defaultMode 键定义了文件权限,并且我们将卷挂载为只读模式。如果我们仅需将 Secret 挂载为文件,并且需要此文件位于特定路径下,我们使用 subPath 键定义文件的名称。例如,如果我们使用 kubectl create secret generic example --from-file=mysecretfile,我们可以按如下方式挂载它:

   volumeMounts:
   - name: mysecret
     mountPath: "/etc/settings/mysecretfile"
     subPath: mysecretfile

我们永远不会将 Secret 文件清单以明文形式存储在代码库中。在上传之前,你可以使用任何第三方工具加密其内容。另一方面,更好的解决方案可能是像 SealedSecret 这样的工具,它会为你生成 Secret。在 SealedSecret 清单中,数据是加密的,你可以在代码库中管理它而不会遇到任何问题。SealedSecret 实体在 Kubernetes 集群内工作,因此只有你的 SealedSecret 实例能够解密你的数据(通过证书交换加密)。你的数据将安全加密,并且在需要时会自动解密。

你可以使用更复杂的解决方案,如 Hashicorp 的 Vault 来管理 Kubernetes Secret 资源。这个解决方案提供了许多功能,可以帮助你管理多个平台的敏感数据,不仅仅是 Kubernetes,但如果你计划在高可用环境中使用它,可能需要大量的硬件资源和管理。

云服务提供商有自己的软件工具,用于在 Kubernetes 集群上部署敏感数据。它们提供与云 身份与访问管理 (IAM) 的不同访问控制集成,如果你计划使用云平台进行生产环境部署,这可能是更好的解决方案。在部署 Secrets 到你的环境中时,向 Kubernetes 管理员询问最佳解决方案总是很有帮助的。

投影卷允许我们将多个资源(仅 Secrets、ConfigMaps、向下 API 或 ServiceAccount 令牌允许)挂载到容器的目录中。以下示例展示了如何挂载一个 Secret、一个容器规格和一个 ConfigMap 资源:

图 10.5 – 投影卷示例

图 10.5 – 投影卷示例

现在我们已经了解了如何使用 Kubernetes 资源集群范围内注入配置和敏感数据,我们将继续审查如何存储来自应用程序的数据。我们将从卷开始,卷是 Kubernetes 核心中包含的存储解决方案。

管理无状态和有状态数据

当我们考虑存储应用程序的数据时,我们必须考虑数据是否应该持久化,还是临时的。如果数据在 Pod 重新创建时必须持久化,我们必须考虑到数据应该在整个集群范围内可用,因为容器可能在任何工作节点上运行。Kubernetes 不存储容器的状态。如果您的应用程序通过使用文件管理其状态,您可以使用卷,但如果不可能这样做——例如,因为多个实例同时工作——我们应该实现一种机制,例如数据库来存储其状态,并使其对所有实例可用。

存储可以是基于文件系统、基于块或基于对象。这也适用于 Kubernetes 数据卷,因此在深入了解 Kubernetes 为卷提供不同解决方案之前,让我们快速回顾一下这些存储类型概念:

  • 基于文件系统的存储:当我们使用基于文件系统的存储(或文件存储)时,我们通过将所有数据保存在本地或远程操作系统提供的文件和子文件夹的分层结构中来管理它。我们使用访问控制列表来决定用户是否能够读取或修改文件的内容。

    文件系统非常常见且易于使用。我们每天都在工作站上本地使用它们,或通过 NAS、通用 Internet 文件系统CIFS)甚至云特定的解决方案远程使用它们。对于有限数量和有限大小的文件,文件系统存储效果很好,但对于大文件可能存在问题。在这些解决方案中,文件被索引化,这限制了当我们有大量文件时的使用。它也不易扩展,并且在多个进程远程访问同一文件时难以管理文件锁,尽管它对本地存储非常有效。

  • 块存储:块存储(或块设备)通过将数据分割成指定大小的小块,这些小块可以分布在不同的物理本地设备上来使用。块设备可以在本地或远程(SAN 或甚至云提供的解决方案)使用,并且可以直接使用或使用不同的文件系统选项格式化,这取决于底层操作系统。此存储解决方案比文件系统更快,并且可以通过使用分布式设备实现高可用性和韧性。但冗余性不便宜,因为它基于块的复制。应用程序必须准备好使用块设备,并且块存储不常用,因为它在很大程度上依赖于基础架构。它非常适合虚拟化和为其准备的数据库解决方案。

  • 对象存储:对象存储是一种解决方案,它将数据划分为单独的单元,这些单元存储在底层存储后端(块设备或文件系统)中。我们可以使用分布式后端,从而提高容错性和高可用性。文件通过 ID 唯一标识,使用 ACL 管理数据访问变得更加容易。它还提供冗余和版本控制。最初它是为云提供商的发布存储开发的,但现在在数据中心中也非常普遍。它通常通过 REST API(HTTP/HTTPS)进行访问,这使得它可以轻松地集成到我们的应用程序中。如今,许多备份解决方案已经为对象存储后端做好了准备,因为它们适合存储大文件和大量数据项。

Kubernetes 包括一些驱动程序,允许我们将经过审核的存储解决方案挂载为卷,但我们将使用 PVC 资源以获得更先进的效果。

使用卷来存储数据

在 Kubernetes 中,卷通过将其定义添加到 .spec.volumes 键来使用。Kubernetes 支持不同类型的卷,Pod 资源可以同时使用任意数量的卷类型。我们有临时(或短暂的)卷,这些卷只会在 Pod 执行期间存在,而数据可以通过非短暂卷来持久化。与 Pod 关联的所有卷可以挂载到所有容器内运行的任何挂载点(来自容器文件系统的目录)。使用这些卷,我们可以持久化应用程序的数据。

虽然 .spec.volumes 键允许我们定义要包含在 Pod 挂载命名空间中的卷,但我们将使用 .spec.containers[*].volumeMounts 来定义每个容器如何以及在哪里使用这些卷。

让我们回顾一下几种最流行的卷类型:

  • emptyDiremptyDir 定义要求 kubelet 组件在主机上创建一个空的临时目录,该目录将跟随关联容器的生命周期。当 Pod 被删除时,容器释放该存储,kubelet 将其删除。默认情况下,emptyDir 类型的卷会在主机的文件系统上创建,但可以使用 medium 子键定义存储应创建的位置。通过使用 sizeLimit 键,也可以限制其大小。需要注意的是,这些卷是在主机上创建的,因此必须小心其内容和大小。例如,永远不要使用它们来存储无限量的日志。并且要记住,一旦 Pod 被删除或重新创建,它们将被移除。

  • hostPath:这些卷允许我们将定义的主机存储包含在 Pod 中。这是一个重要的安全隐患,除非你的应用需要监控或修改主机的文件,否则必须避免使用。通过设置type键,可以使用不同类型的hostPath卷。默认情况下,如果 Pod 启动时目录不存在,它将被创建,但我们也可以创建或使用特定的文件、套接字、块设备,甚至特殊类型的文件。由于hostPath卷应受到限制,因此你必须将其使用情况告知 Kubernetes 管理员。

  • iscsi:如果你已经在使用hostPath。目前,使用iscsi卷并不常见,因为它要求所有工作节点完全相同(所有集群主机中的磁盘设备名称必须完全相同)。你可以使用节点标签指定工作负载的运行位置,但这使得它们对底层基础设施过于固定。

  • nfs:NFS 卷类型允许我们在 Pod 中包含远程 NFS 文件系统。除非你从 Pod 的进程中移除它,否则挂载的文件系统内容会被保持。我们指定服务器和暴露的路径,并可以通过将readOnly键设置为true来以只读模式挂载它。下图展示了一个快速示例,显示了所需的键:

图 10.6 – 展示在 Pod 中挂载 NFS 卷的清单

图 10.6 – 展示在 Pod 中挂载 NFS 卷的清单

  • PersistentVolumeClaim:这是最先进的卷定义,需要一个完整的章节来描述其使用方式。我们将在下一小节学习如何使用它们。

  • ephemeral:这些卷可以被认为类似于emptyDir,因为它们旨在提供在 Pod 运行期间的临时存储,但它们的不同之处在于我们可以将 PVC 资源作为临时卷或本地存储进行集成。当卷挂载到 Pod 时,它们可以是空的,也可以已经包含一些内容。

很多云提供商的卷已经从基于 Kubernetes 核心的存储转向使用外部 PV 动态供应器的现代 PVC 管理。将存储供应从 Kubernetes 代码中拆分出来,使硬件存储制造商、云提供商和中间件软件开发者能够准备自己的解决方案,并将其发展到 Kubernetes 开发周期之外。我们现在将学习 PV 和 PVC 资源如何帮助我们改善 Kubernetes 中的存储管理。

在 Kubernetes 中通过 PV 资源增强存储管理

PV 资源表示可以在 Kubernetes 中使用的存储单元。PV 可以通过存储提供后端手动或动态创建。

到目前为止看到的卷定义是命名空间级别的,而 PV 资源是由 Kubernetes 管理员在集群级别定义的。我们使用它们来定义存储容量(大小),使用capacity.storage键,并定义它的访问模式,使用accessMode键。让我们快速回顾这些模式,因为我们的应用可能需要特定的数据访问,特别是当我们运行多个副本进程时:

  • ReadWriteOnce:此模式仅为第一个附加该存储的 Pod 提供写模式。其他 Pod(无论是副本还是定义在其他不同工作负载中的 Pod)只能对数据进行读取访问。

  • ReadOnlyMany:在此模式下,所有 Pod 将以只读模式挂载卷。

  • ReadWriteMany:当在不同 Pod 中运行的进程需要同时写入 PV 时,使用此选项。您必须确保应用程序管理写数据时的锁,以避免数据损坏。

您需要注意的是,accessMode并不会在卷挂载后强制执行写保护。如果您需要确保某些 Pod 挂载时是只读模式,必须使用readOnly键。

在 PV 资源中可以定义多个访问模式,但在挂载时仅使用其中一种。这有助于集群绑定 PV,但它将根据每个卷请求的规格来适配。

重要提示

此模式还会影响应用程序的更新方式,特别是在发起滚动更新时。默认情况下,Deployment 资源会在旧 Pod 停止之前先启动新 Pod 实例,以保持应用程序的运行。但是,ReadWriteOnce访问模式只允许旧 Pod 访问存储,而新 Pod 则会永远等待挂载。在这种情况下,可能需要改变默认的滚动更新行为,以确保旧进程完全停止并释放卷,之后新进程再开始使用附加的存储。

为了使用可用的 PV,我们将使用 PVC 资源,可以将其视为对存储的请求。这些资源是命名空间级别定义的,因为它们将被我们的工作负载使用。作为开发人员,您将为应用程序请求存储,定义一个带有所需capacity.storageaccessMode键的 PVC。当这两个选项与已创建并空闲的 PV 资源匹配时,它们会绑定,并且存储将附加到 Pod 上。实际上,卷会附加到运行 Pod 的主机上,kubelet 会使其在 Pod 内可用。

可以使用标签通过selector.matchLabels和适当的标签来固定 PVC 和 PV 的子集。以下截图展示了一个作为本地hostPath变量创建的PersistentVolume示例:

图 10.7 – PV 和 PVC 清单

图 10.7 – PV 和 PVC 清单

如果创建了 PVC,但没有可用的 PV 满足 PVC 的定义要求,它将永远保持未绑定状态,等待创建或释放一个 PV 在 Kubernetes 集群中。

当一个 Pod 使用 PVC 并且绑定到 PV 时,在 Pod 释放它并删除 Pod 之前,存储不能被移除。以下截图展示了一个 Pod 如何使用 PVC:

图 10.8 – 使用与 PV 关联的 PVC 的 Pod 清单

图 10.8 – 使用与 PV 关联的 PVC 的 Pod 清单

如果底层文件系统是 xfsext3ext4,则可以调整 PV 的大小。根据存储后端,我们可以克隆 PV 内容,甚至创建快照,这对于调试应用程序出现问题时的数据集,或进行备份可能非常有用。

节点亲和性可以用于将特定的集群节点与特定目录或甚至磁盘设备一起使用,并将 PV 挂载到该目录所在的节点。作为开发人员,除非您的 Kubernetes 管理员要求您使用此功能,否则应避免使用节点亲和性,因为它会使您的工作负载依赖于基础设施。

PV 可以由您的 Kubernetes 管理员手动配置,也可以通过动态方式配置,使用与存储后端交互的容器存储接口集成解决方案来为您创建卷。多个存储解决方案可以在您的集群中共存,这些解决方案可能提供不同的存储能力。无论您的平台使用动态配置还是手动创建卷,我们都可能访问更快的后端、更具韧性的后端,或者具有特定管理功能的后端。

重要提示

重要的是要理解,将 PVC 与合适的 PV 匹配可能会导致磁盘空间的浪费。当 PVC 请求 5 Gi 而可用的 PV 大小为 10 Gi 时,它们会被绑定,尽管它们的大小不匹配。这意味着我们只会使用可用空间的 50%。这就是动态配置如此重要的原因,因为它创建与所需大小完全匹配的 PV,然后它们会被绑定,从而数据完美地适配到配置的存储中。

我们将使用 StorageClass 资源按能力对 PV 进行分类。Kubernetes 管理员将把创建的 PV 资源与任何已配置的 StorageClass 资源关联。可以定义默认的 StorageClass 来关联所有没有 spec.storageClassName 键的 PV。每当我们创建 PVC 资源时,我们可以定义一个特定的 StorageClass,或者等待集群从默认的 StorageClass 为我们的声明分配一个 PV。

现在我们了解了存储管理的基本概念,我们可以快速查看存储的配置和退役过程。

存储的配置和退役

Kubernetes 不管理如何在存储后端创建或销毁 PV。它只是通过 REST API 与它们的接口交互,以检索和跟踪存储的变化和状态。

动态配置 需要一些 Kubernetes 管理工作。Kubernetes 管理员将部署 容器存储接口CSI)解决方案,并将它们附加到特定的 StorageClass 资源,使用户能够使用它们。每个 StorageClass 资源都有自己调用提供者的参数,因此将根据使用的机制创建不同的 PV 资源。

以下截图显示了两个 StorageClass 资源,使用 kubernetes.io/gce-pd API 端点来创建标准和 SSD(快速硬盘)PV:

图 10.9 – 标准存储和 SSD 存储的 StorageClass 清单

图 10.9 – 标准存储和 SSD 存储的 StorageClass 清单

注意 reclaimPolicy 键,它管理当 Pod 释放一个 PV 并且该 PV 被重新使用时,提供者的行为:

  • Retain 策略不会修改 PV 中的内容,因此当数据不再需要时,我们需要手动从提供者后台移除该 PV。

  • Recycle 策略基本上会删除卷中的所有内容(例如,通过从挂载 PV 的 Pod 发出 rm -rf /volumedata/* 命令)。这仅在 NFS 和 hostPath 提供者中受支持。

  • Delete 策略将完全删除 PV 及其内容,通过请求提供者 API 删除关联的卷。

重要提示

使用 PV 和 PVC 资源可以让我们准备好应用程序的工作负载,使其能够在任何 Kubernetes 基础设施上运行。我们只需修改 StorageClassName 来适应部署平台上现有的任何 StorageClass。

我们现在将回顾一些实验,以帮助我们更好地理解本章的某些内容。

实验

在本节中,我们将使用一些已呈现的资源来改进我们的 simplestlab 应用程序。我们将在 Secret 资源中包含敏感数据,在 ConfigMap 资源中包含 NGINX 配置,并通过 StorageClass 资源实现一个 PV 资源和一个 PVC 资源,为我们的 StatefulSet 资源提供存储。

本书的所有实验代码可以在其 GitHub 仓库中找到,链接为 github.com/PacktPublishing/Containers-for-Developers-Handbook.git。通过执行 git clone https://github.com/PacktPublishing/Containers-for-Developers-Handbook.git 下载所有内容,或者如果你已经下载了仓库,则执行 git pull 来确保你有最新的版本。所有清单文件和为 simplestlab 应用程序添加存储的步骤都位于 Containers-for-Developers-Handbook/Chapter10 目录下。

本节将向你展示如何在为 Kubernetes 准备的 simplestlab 第三层应用程序中实现不同的卷解决方案,详见 第九章实现 架构模式

以下是您将在 第十章 GitHub 仓库中找到的任务:

  • 我们将通过为应用程序需要的任何敏感数据添加 Secret 资源来提高 simplestlab 应用程序的安全性。我们将审查每个应用程序组件,创建所需的 Secret 资源,并修改组件的清单以包含新创建的资源。这包括一些数据库组件所需的数据库初始化脚本。

  • 我们还将为 lb 应用程序组件准备不同的 ConfigMap 资源,并验证这些更改如何影响应用程序行为。

您可以使用以下任一 Kubernetes 桌面环境:

  • Docker Desktop

  • Rancher Desktop

  • Minikube

这些实验可以在其中任何一个上运行,当然,也可以在任何其他 Kubernetes 环境中运行。您可能会遇到它们的默认存储类问题,但文件中有一些评论可以进行更改。我们将从改进 simplestlab 应用程序在 Kubernetes 上的部署开始:

  1. simplestlab 应用程序是一个非常简单的三级应用程序(负载均衡器可以呈现额外的静态内容,但为了实验目的没有添加)。为了提高安全性,我们将为所有所需的用户身份验证添加 Secret 资源。

    该应用由三个组件组成:

    • 一个 db 组件:Postgres 数据库

    • 一个 app 组件:Node.js 后端应用

    • 一个 lb 组件:NGINX 前端静态内容

    我们在它们的清单中包括了本章中学习的一些存储解决方案。

  2. 让我们首先看看数据库组件。由于我们已经使用了 StatefulSet 资源,已配置 PVC 资源,但数据库容器中以明文形式呈现了 postgres 用户密码。我们添加了一个 Secret 资源来包含此密码,并且我们还包括了一个完整的数据库初始化脚本,它将帮助我们准备数据库。在之前的实验中,此组件是通过容器镜像中包含的脚本初始化的。在这种情况下,我们可以通过修改此脚本并替换 Secret 来管理应用程序的数据库创建。您必须知道,我们无法使用此机制来修改先前已初始化的数据库。这就是我们预期从头开始部署此组件的原因。

    这是创建的两个 Secret 清单:

    • dbcredentials.secret.yaml,其内容如下所示:

      apiVersion: v1
      data:
        POSTGRES_PASSWORD: Y2hhbmdlbWU=
      kind: Secret
      metadata:
        name: dbcredentials
      
    • initdb.secret.yaml:此 Secret 是通过包含 init-demo.sh 脚本的内容创建的:

      $ cat init-demo.sh
      #!/bin/bash
      set -e
      psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" <<-EOSQL
          CREATE USER demo with PASSWORD 'd3m0' ;
          CREATE DATABASE demo owner demo;
          GRANT ALL PRIVILEGES ON DATABASE demo TO demo;
          \connect demo;
          CREATE TABLE IF NOT EXISTS hits
          (
            hitid serial,
            serverip varchar(15) NOT NULL,
            clientip varchar(15) NOT NULL,
            date timestamp without time zone,
            PRIMARY KEY (hitid)
          );
          ALTER TABLE hits OWNER TO demo;
      initdb.secret.yaml Secret manifest:
      
      

      $ kubectl create secret generic initdb \

      --from-file=init-demo.sh --dry-run=client \

      -o yaml |tee initdb.secret.yaml

      apiVersion: v1

      data:

      init-demo.sh: IyEvYmluL2Jhc2gKc2V0IC1lCgpwc3FsIC12IE9OX0VSUk9SX1NUT1A9MSAt....pZCkKICAgICk7CiAgICBBTFRFUiBUQUJMRSBoaXRzIE9XTkVSIFRPIGRlbW87CkVPU1FMCg==

      kind: Secret

      metadata:

      creationTimestamp: null

      name: initdb

      
      
    • 在这两种情况下,值都以 Base64 格式编码。它们没有加密,我们可以验证:

      $ kubectl apply -f dbcredentials.secret.yaml
      $ kubectl get secret dbcredentials \
      -ojsonpath="{.data.POSTGRES_PASSWORD}"|base64 -d
      StatefulSet manifest was modified:
      
      

      API 版本:apps/v1

      类型:StatefulSet

      元数据:

      名称:db

      标签:

      组件:db

      应用程序:simplestlab

      规格:

      副本:1

      ...

      卷:

      • 名称:initdb-secret

      密钥:

      secretName:initdb

      可选:true

      容器:

      • 名称:database

      镜像:docker.io/frjaraur/simplestdb:1.0

      ...

      环境:

      • 名称:POSTGRES_PASSWORD

      valueFrom:

      secretKeyRef:

      名称:dbcredentials

      键:POSTGRES_PASSWORD

      • 名称:PGDATA

      值:/data/postgres

      volumeMounts:

      • 名称:postgresdata

      mountPath: /data

      • 名称:initdb-secret

      mountPath: "/docker-entrypoint-initdb.d/"

      只读:true

      ...

      volumeClaimTemplates:

      • 元数据:

      名称:postgresdata

      规格:

      访问模式:[ "ReadWriteOnce" ]

      storageClassName: "csi-hostpath-sc"

      资源:

      请求:

      存储:1Gi

      
      The full manifest file can be found in the `Chapter10/db.statefulset.yaml` file.
      
    • 我们创建了数据库的所有组件:

      $ kubectl create -f dbcredentials.secret.yaml \
      -f initdb.secret.yaml
      secret/dbcredentials created
      secret/initdb created
      $ kubectl create -f db.statefulset.yaml
      statefulset.apps/db created
      $ kubectl create -f db.service.yaml
      service/db created
      

      服务完全未修改。

    • 我们通过连接到数据库服务器并显示其用户来检查是否已创建用户:

      $ kubectl exec -ti db-0 -- psql -U postgres
      psql (15.3)
      Type "help" for help.
      postgres=# \du
                                         List of roles
       Role name |                         Attributes
      | Member of
      -----------+------------------------------------------------------------+-----------
       demo      |
      | {}
       postgres  | Superuser, Create role, Create DB, Replication, Bypass RLS | {}
      postgres=# \q
      POSTGRES_PASSWORD variable, and now it’s taken from the Secret we created.We also included `initdb-secre`t as a volume, and it’s mounted in the `/docker-entrypoint-initdb.d` directory. Notice that we didn’t use `subPath` because this directory is empty. You can change the content of the Secret and it will be synced inside the containers, but this will not change the authentication values in the database because it is an initialization script. You can modify it to enforce the change of the password via SQL.
      
    • 现在我们可以查看通过使用模板创建的 PVC(因为它是一个 StatefulSet)以及相关的 PV:

      $ kubectl get pvc
      NAME                STATUS   VOLUME
      CAPACITY   ACCESS MODES   STORAGECLASS   AGE
      postgresdata-db-0   Bound    pvc-4999f00b-deb3-4cec-97a0-3a289c4457d9   1Gi        RWO            hostpath
      168m
      $ kubectl get pv pvc-4999f00b-deb3-4cec-97a0-3a289c4457d9
      NAME                                       CAPACITY   ACCESS MODES   RECLAIM POLICY   STATUS   CLAIM      STORAGECLASS   REASON   AGE
      pvc-4999f00b-deb3-4cec-97a03a289c4457d9   1Gi        RWO  Delete     Bound    default/postgresdata-db-0   hostpath                168m
      
    • 在 Docker Desktop 中定义了一个 StorageClass 资源,并且我们默认使用它:

      $ kubectl get sc
      NAME                 PROVISIONER          RECLAIMPOLICY   VOLUMEBINDINGMODE   ALLOWVOLUMEEXPANSION   AGE
      appcredentials Secret. This method does not generate a YAML manifest, which may be a problem because you will need to store your passwords somewhere. If you need to store all your manifest in your code repository, which is always recommended, you must always encrypt your Secret manifests:
      
      

      $ kubectl create secret generic appcredentials \

      --from-literal=dbhost=db \

      --from-literal=dbname=demo \

      --from-literal=dbuser=demo \

      --from-literal=dbpasswd=d3m0

      secret/appcredentials 已创建

      
      The values for these variables must be the ones used in the initialization script.
      
    • 让我们审查包含在应用程序中加载数据库身份验证的更改:

      apiVersion: apps/v1
      kind: Deployment
      metadata:
        name: app
      ...
            containers:
            - name: app
              image: docker.io/frjaraur/simplestapp:1.0
              ports:
              - containerPort: 3000
              env:
              - name: dbhost
                valueFrom:
                  secretKeyRef:
                    name: appcredentials
                    key: dbhost
              - name: dbname
                valueFrom:
                  secretKeyRef:
                    name: appcredentials
                    key: dbname
      ...
      

      完整的清单文件可以在 Chapter10/app.deployment.yaml 文件中找到。

    • 我们刚刚从之前创建的 Secret 中包含了所有必需的环境变量。我们部署了应用程序清单:

      $ kubectl create -f app.deployment.yaml
      -f app.service.yaml
      deployment.apps/app created
      service/app created
      

      现在我们验证容器中包含的内容:

      $ kubectl exec -ti app-5f9797d755-2bgtt – env
      ...
      dbhost=db
      dbname=demo
      dbuser=demo
      dbpasswd=d3m0
      ...
      $ kubectl get pods
      NAME                   READY   STATUS    RESTARTS   AGE
      app-5f9797d755-2bgtt   1/1     Running   0          100s
      app-5f9797d755-gdpw7   1/1     Running   0          100s
      app-5f9797d755-rzkqz   1/1     Running   0          100s
      db-0                   1/1     Running   0          179m
      
    • 现在我们可以继续处理前端组件。在此应用程序部署的早期版本中,我们已经使用 ConfigMap 资源配置了 NGINX 负载均衡器。

      这是带有特殊配置的 ConfigMap 资源的内容,用于我们的 NGINX 负载均衡器:

      apiVersion: v1
      kind: ConfigMap
      metadata:
        name: lb-config
        labels:
          component: lb
          app: simplestlab
      data:
        nginx.conf: |
          user  nginx;
          worker_processes  auto;
          error_log  /tmp/nginx/error.log warn;
          pid        /tmp/nginx/nginx.pid;
          events {
            worker_connections  1024;
          }
          http {
            server {
              listen 8080; # specify a port higher than 1024 if running as non-root user
              location /healthz {
                  add_header Content-Type text/plain;
                  return 200 'OK';
              }
              location / {
                proxy_pass http://app:3000;
              }
            }
          }
      
    • 我们部署所有的 lb 清单:

      $ kubectl create -f lb.daemonset.yaml \
      -f lb.configmap.yaml -f  lb.service.yaml
      daemonset.apps/lb created
      configmap/lb-config created
      service/lb create
      
    • 我们审查所有应用程序组件的状态和应用于 NGINX 组件的配置:

      $ kubectl get pods
      NAME                   READY   STATUS    RESTARTS   AGE
      app-5f9797d755-2bgtt   1/1     Running   0          5m52s
      app-5f9797d755-gdpw7   1/1     Running   0          5m52s
      app-5f9797d755-rzkqz   1/1     Running   0          5m52s
      db-0                   1/1     Running   0          3h4m
      lb-zcm6q               1/1     Running   0          2m2s
      $ kubectl exec lb-zcm6q -- cat /etc/nginx/nginx.conf
      user  nginx;
      worker_processes  auto;
      ...    location / {
            proxy_pass http://app:3000;
          }
        }
      }
      $
      
    • 现在,我们可以在任何 Kubernetes 集群主机的端口 32000 上访问我们的应用程序。您的浏览器应该访问该应用程序并显示类似以下内容(如果您使用 Docker Desktop,则需要使用 localhost:32000):

图 10.10 – simplestlab 应用程序 Web GUI

图 10.10 – simplestlab 应用程序 Web GUI

通过这一最后步骤,您完全部署了 simplestlab 应用程序的三个组件。在 GitHub 存储库的 Chapter10 文件夹中,您将找到额外的步骤以添加新的应用程序组件实例并修改负载均衡器组件以连接到此新后端。

这些实验将帮助您了解 Secret 资源如何提高应用程序的安全性,以及如何为不同的应用程序需求实现不同的卷类型。

摘要

在这一章中,我们回顾了如何在 Kubernetes 集群中管理数据,Kubernetes 目前是最流行的容器编排工具,你可能会在大多数项目中使用它。我们学习了如何在应用程序的容器中包含配置和敏感数据,以及如何使用不同的 Kubernetes 资源来管理无状态和有状态存储。在本章的最后,我们学习了如何为我们的应用程序使用动态数据卷配置,这非常适合微服务模型,在微服务中,自动化对底层基础设施的资源抽象至关重要。本章非常重要,因为它教会了你如何在微服务和基于容器的环境中管理数据。

我们将在下一章中继续讨论,在这一章中,我们将学习如何使用最佳安全实践发布我们的应用程序,隔离所有后台组件与用户和其他应用程序之间的关系。

第十一章:发布应用程序

在 Kubernetes 上运行应用程序通过将其进程作为容器运行,为应用程序的所有组件增加了弹性。这有助于我们提供稳定性,并在不影响用户的情况下更新这些组件。尽管 Kubernetes 提供了大量资源来简化应用程序的集群管理,但我们确实需要了解使用这些资源会如何影响我们的应用程序被用户访问的方式。

在本章中,我们将学习如何发布我们的应用程序,使其可供用户访问。这将涉及将某些 Pods 或容器发布以提供服务,但有时我们也可能需要调试应用程序以解决出现的问题。

到本章结束时,我们将了解 NetworkPolicy 资源 如何帮助我们隔离部署在集群中的工作负载,并回顾使用 服务网格 解决方案来提高应用程序组件之间的整体安全性。

本章将涵盖以下主题:

  • 理解 Kubernetes 发布应用程序的集群范围特性

  • 为调试代理和转发应用程序

  • 使用主机网络命名空间发布应用程序

  • 使用 Kubernetes 的 NodePort 特性发布应用程序

  • 通过 LoadBalancer 服务提供对服务的访问

  • 理解 Ingress 控制器

  • 提高我们应用程序的安全性

本章将通过回顾 Kubernetes 开箱即用的不同选项,开始讲解如何将应用程序交付给用户。

技术要求

你可以在 github.com/PacktPublishing/Containers-for-Developers-Handbook/tree/main/Chapter11 找到本章的实验,其中包含一些扩展的解释,这些内容在章节内容中被省略,以便更容易跟随。本章的 Code In Action 视频可以在 packt.link/JdOIY 找到。

理解 Kubernetes 发布应用程序的集群范围特性

Kubernetes 是一个容器编排器,允许用户在整个集群中运行其应用程序的工作负载。 我们在第九章实施架构模式中回顾了使用不同 Kubernetes 资源部署应用程序时可以使用的不同模式。 Pod 是我们应用程序的最小部署单元,并且具有动态 IP 地址,因此我们无法将它们用于发布我们的应用程序。 动态性影响了所有组件的内部和外部暴露 - 虽然 Kubernetes 成功地使容器的创建和删除变得简单,但使用的 IP 地址将持续变化。 因此,我们需要一个中间组件,即服务资源,来管理任何类型客户端与应用程序组件相关的后端运行的 Pod 的交互。 我们也可以让服务资源指向外部资源(例如,ExternalName 服务类型)。

重要说明

理解一个应用程序的组件并不是所有的组件都需要在集群外或者甚至命名空间范围内可访问是至关重要的。 在本章中,我们将学习发布应用程序以便在 Kubernetes 集群内外访问的不同选项和机制。 作为开发者,您必须知道并理解应用程序的哪些组件将作为应用程序的前端,因此必须可访问,哪些应该作为后端并且可以访问,并在每种情况下使用适当的机制。

我们将使用 Kubernetes 服务资源根据需要在应用的 Pod 内部或外部发布。我们永远不会直接连接到 Pod 的发布端口。 Pod 的端口将使用标签与服务资源关联。 创建中间资源以关联服务与 Pod、EndpointSlices 和 Endpoint 资源。 创建这些资源是在创建服务并定位相关的 Pod 时自动进行的。 EndpointSlices 指向 Endpoint 资源,后者在后端 Pod(或外部服务)更改时进行更新。

让我们通过一个示例看看这是如何工作的。 我们将在实际创建其 Pod 之前创建一个服务资源。 以下代码片段显示了一个服务资源清单的示例:

apiVersion: v1
kind: Service
metadata:
  name: myservice
spec:
  selector:
   myapp: test
  ports:
    - protocol: TCP
      port: 80
      targetPort: 8080

如果我们使用上述 YAML 清单创建服务资源并检索创建的端点,我们将看到哪些 Pod(及其 IP 地址)与后端相关联。 让我们查看当前关联的端点:

$ kubectl get endpoints myservice
NAME        ENDPOINTS   AGE
myservice   <none>      29s

端点列表为空,因为我们没有任何关联的后端 Pod(带有 myapp=test 标签)。 让我们使用 kubectl run 创建一个带有此标签的简单 Pod:

$ kubectl run mypod1 --labels myapp=test \
--image=nginx:alpine
pod/mypod1 created

现在让我们再次查看关联的 Pod:

$ kubectl get endpoints myservice
NAME        ENDPOINTS         AGE
myservice   10.1.0.113:8080   5m58s

请注意,我们没有为 Pod 指定任何端口,因此关联可能是错误的(实际上,docker.io/nginx:alpine镜像为进程定义了端口80)。Kubernetes 不会验证这些信息;它只是创建资源之间所需的链接。

EndpointSlice 资源由 Kubernetes 管理,并且会在创建新的 Pod 或旧的 Pod 失败时动态更新(实际上,后端 Endpoint 资源会发生变化,并且更新会传播)。如果你遇到 Service 没有响应,但 Pods 却在运行,这可能是你需要检查的内容。

这只是创建一个简单的ClusterIP Service 的示例,这是默认选项。我们已经在第九章《实现架构模式》中学习了不同的 Service 资源类型,但快速回顾一下那些允许我们发布应用程序的类型可能仍然很重要。

  • ClusterIP:这是默认类型,用于在内部发布 Service。Kubernetes 的内部 DNS(CoreDNS 组件)会为该 Service 资源的 IP 地址创建一个 FQDN(由 Service 池的内部 IPAM 分配)。

  • Headless:这些 Services 没有关联的 IP 地址,尽管它们也有 FQDN。在这种情况下,所有与 Endpoint 资源关联的 Pods 的 IP 地址都会被解析。

  • 30000-32767端口范围。理解 NodePort Services 具有 ClusterIP 地址,并通过内部 Service 的 FQDN 进行关联,这一点很重要。

  • LoadBalancer:这种类型的 Service 资源与外部云或本地软件或硬件负载均衡器(或它在你的云基础设施中创建负载均衡器)集成,用于将用户流量路由到应用程序的 Pods。在这种情况下,会创建一个 NodePort(以及其关联的 ClusterIP),以将流量从外部负载均衡器路由到后端的 Endpoint 资源。当创建 LoadBalancer Service 资源时,Kubernetes 将使用其与云基础设施的集成来创建所需的负载均衡器,或应用指向关联 NodePort 的特定配置。

重要说明

我们使用 ClusterIP 和 Headless Services 进行内部使用,而在需要公开应用程序时使用 NodePort 和 LoadBalancer Services。但这并不完全准确,因为我们也可以使用Ingress Controllers来发布应用程序,而不需要使用 NodePort 或 LoadBalancer 资源。这有助于将应用程序与底层基础设施进行抽象。

让我们继续探索 Kubernetes 平台提供的不同选项,通过引入 Ingress Controller 概念来发布应用程序。Ingress Controller 是一个 Kubernetes 控制器,我们可以将其添加到集群中以实现反向代理功能。这将允许我们使用 ClusterIP 服务资源来暴露我们的应用程序,因为来自用户的流量将完全通过这个代理组件内部路由到服务,再到达相关的 Pods。这个代理通过使用 Ingress 资源动态配置。这些资源允许我们定义应用程序的主机头并将其与服务资源链接起来。作为开发人员,你的工作是为前端应用程序的组件创建适当的 Ingress 资源。

最后,让我们介绍 Kubernetes /api/v1/namespaces/default/pods 路径。

出于调试目的,我们还可以使用 kubectl port-forward,它将特定的服务代理到我们的桌面计算机客户端。请注意,在生产环境中不应允许使用这两种方法,proxyport-forward,因为它们直接暴露重要资源,绕过了我们的 Kubernetes 和负载均衡器基础设施安全。

在下一节中,我们将使用 kubectl proxy 功能来访问服务资源并访问我们的应用程序。

调试时的代理和转发应用程序

在本节中,我们将学习如何直接在桌面计算机上发布 Kubernetes API,并访问集群中创建的任何服务(如果我们有适当的权限),以及如何使用 port-forward 功能将服务直接转发到我们的客户端计算机。

Kubernetes 客户端代理功能

我们使用 kubectl proxy 来启用 Kubernetes 代理功能。一些重要选项帮助我们管理 Kubernetes API 的访问方式和位置。我们使用以下选项来定义 Kubernetes API 的发布位置:

  • --address:此选项允许我们定义用于发布 Kubernetes API 的客户端主机 IP 地址。默认情况下使用 127.0.0.1

  • --port-p:此选项用于设置 Kubernetes API 可用的具体端口。默认值是 8001,虽然我们可以通过使用 -p=0 让 Kubernetes 使用随机端口,但建议始终定义一个特定端口。

  • --unix-socket-u:此选项用于定义 Unix 套接字而非 TCP 端口,这在限制文件系统级别的套接字访问时更为安全。

以下选项用于保护 Kubernetes API 访问:

  • --accept-hosts--accept-paths:这些选项允许我们确保只有特定的主机头和 API 路径会被允许。例如,我们可以使用以下正则表达式模式 '^localhost$,¹²⁷\.0\.0\.1$,^\[::1\]$' 来确保仅允许本地访问,并配合 --accept-hosts 参数使用。

  • --reject-methods:我们可以通过拒绝特定的 API 方法来阻止它们。例如,我们可以通过使用kubectl proxy --reject-methods='PATCH'来禁用对任何 Kubernetes 资源的修补。

  • --reject-paths:我们可以使用此选项指定某些路径被拒绝。例如,我们可以通过使用–-reject-paths='^/api/.*/pods/.*/exec,'来禁用向 Pod 资源附加新进程(相当于kubectl exec)。

需要理解的是,尽管我们已经看到了一些确保安全的选项,但 Kubernetes 代理功能不应在生产环境中使用,因为如果有人通过代理端口访问 API,可能绕过 RBAC 系统。用于创建代理的用户身份验证将允许任何人通过暴露的 API 访问。

此方法仅应在自己的 Docker Desktop、Rancher Desktop 或 Minikube 中用于调试,暴露 Kubernetes 远程开发环境。如果你不是在使用自己的 Kubernetes 环境,你的 Kubernetes 管理员必须为你启用此方法。如果你仍然无法访问代理的 Kubernetes API,必须通过查看防火墙设置来确保操作系统允许访问指定端口。

现在我们已经回顾了如何使用此方法发布 Kubernetes API,接下来让我们通过一个快速示例来访问已创建的服务资源:

图 11.1 – 使用 NGINX 创建简单的 webserver 服务并暴露 Kubernetes API

图 11.1 – 使用 NGINX 创建简单的 webserver 服务并暴露 Kubernetes API

重要提示

请注意,我们使用&在后台执行了kubectl proxy。这样做是为了能够继续在当前终端中操作。kubectl proxy操作会在前台运行,并且会一直运行,直到我们按下Ctrl + C终止该进程。要结束后台执行,我们可以使用以下步骤:

$ jobs

[1]+ 运行 kubectl proxy &

$ kill %1

现在我们可以访问 Kubernetes API,我们可以直接通过代理端口访问 ClusterIP 服务资源,但首先让我们回顾一下服务资源:

图 11.2 – 使用 Kubernetes 代理访问 webserver 服务资源

图 11.2 – 使用 Kubernetes 代理访问 webserver 服务资源

我们为webserver服务资源配置了端口8080。Kubernetes 代理将使用以下 URI 格式发布服务资源(Kubernetes API):

/``api/v1/namespaces/<NAMESPACE>/services/<SERVICE_NAME>:<SERVICE_PORT>/proxy/

因此,webserver服务可以通过/api/v1/namespaces/default/services/webserver:8080/proxy/访问,并且我们可以访问 NGINX 的默认index.xhtml页面,如下图所示:

图 11.3 – 使用 kubectl 代理功能访问 webserver 服务

图 11.3 – 使用 kubectl proxy 功能访问 webserver 服务

该服务是可以访问的,我们已经成功到达了webserver服务的默认页面。现在,让我们来看看如何将服务的端口转发到我们的桌面计算机,而不需要实现复杂的路由基础设施。

Kubernetes 客户端端口转发功能

我们可以使用 kubectl port-forward 来将服务、部署、ReplicaSet、StatefulSet,甚至是 Pod 资源的端口直接转发,而无需访问整个 Kubernetes API。在这种情况下,使用透明的 NAT 将后端端口转发到我们桌面计算机上定义的端口,通过执行 kubectl 命令行客户端来实现。

让我们看一下这如何工作,使用上一节中定义的 webserver 服务作为示例:

图 11.4 – 使用端口转发发布 webserver 服务资源示例

图 11.4 – 使用端口转发发布 webserver 服务资源示例

正如这个例子所示,转发任何监听指定端口的 Kubernetes 资源是相当简单的。我们可以通过使用 [LOCAL_PORT:]RESOURCE_PORT 来指定应用程序附加的本地端口。请注意,当在具有多个 IP 地址的多网卡主机上工作时,使用 --address 参数选择本地 IP 地址非常重要。这将通过附加接口并定义适当的防火墙规则,仅允许我们的主机来提高整体安全性。默认情况下,使用 localhost,这意味着只要我们是唯一能够访问桌面计算机的用户,它将保持安全。

在下一节中,我们将讨论如何直接使用主机的内核网络命名空间来发布 Pod 资源。

使用主机网络命名空间发布应用程序

到目前为止,我们已经看到了通过代理或将端口转发到桌面计算机的不同方法,来访问 ClusterIP 服务资源或 Pods(使用不同工作负载类型创建)。然而,有时应用程序需要直接连接到主机的接口,而不是通过容器运行时创建的桥接接口。在这种情况下,Pod 中的容器将使用主机的网络命名空间,这使得容器内部的进程能够控制主机,因为它们可以访问主机的所有接口和网络流量。这可能是危险的,必须仅用于管理和监控主机的接口。

使用 hostNetwork 键

要使用主机的网络命名空间,我们将hostNetwork键设置为true。Pod 现在将获得与主机相关的所有 IP 地址,包括与该主机中运行的容器相关的所有虚拟接口的 IP 地址。但在发布应用程序时,特别重要的一点是,它们将可以通过主机的任何 IP 地址进行访问,并在 Pod 的spec部分定义的ports键所定义的端口上等待请求。让我们通过执行一个带有前述hostNetwork键的 NGINX Pod 来看一下这如何运作。我们将使用cat(重定向到kubectl)来快速创建一个 Pod 资源,使用nginx/nginx-unprivileged:stable-alpine3.18镜像(该镜像使用无特权端口8080):

图 11.5 – 使用 hostNetwork 暴露 Pod

图 11.5 – 使用 hostNetwork 暴露 Pod

通过这种方式,您的 NGINX web 服务器将在其运行的主机的 IP 地址上可访问(在这个例子中是 IP 地址192.168.65.4,这是我们的 Docker Desktop 工作节点和主节点的地址)。以下代码片段展示了如何使用主机的接口创建webserver应用程序,以及如何获取 NGINX 进程的内容:

图 11.6 – 使用主机的 IP 地址访问 webserver 应用程序

图 11.6 – 使用主机的 IP 地址访问 webserver 应用程序

请注意,我们在webserver Pod 内部执行了curl二进制文件。在这个示例中,我们使用的是 Docker Desktop 与frjaraur/nettools镜像(由我开发和维护),来验证应用程序是否可访问。

在这个例子中,我们只使用了 Pod 中的一个端口;事实上,我们甚至没有在 Pod 的容器中声明端口,因此容器镜像中定义的所有端口都会被使用。使用hostNetwork时,镜像中定义的所有端口都会被暴露,如果你不想将某些特定的 Pod 暴露到外部(例如,如果你的应用程序有一个内部 API 或管理界面,而你不希望能够外部访问),这可能会成为问题。如果你自己管理平台,可以通过修改主机的防火墙来管理访问,但这可能会比较棘手。在这种情况下,我们可以在容器级别使用hostPort键,而不是在 Pod 资源级别使用hostNetwork。我们将在下一节中探讨这个问题。

使用hostPort

hostPort键用于 Pod 的containers部分,在这里我们定义要暴露的端口,可以是内部端口或外部端口。通过hostPort,我们只能暴露需要的端口,其余端口可以保持内部访问。让我们看一个示例,定义webserver Pod 中的两个容器:

图 11.7 – 一个示例,包含两个容器,但仅有一个在主机级别暴露

图 11.7 – 一个示例,包含两个容器,但仅有一个在主机级别暴露

在前面的截图中,我们有两个容器。让我们再次使用 frjaraur/nettools 镜像,尝试通过 curl 访问这两个端口,来验证它们是否可达:

图 11.8 – 访问 web 服务器服务的端口 8080 和 80

图 11.8 – 访问 web 服务器服务的端口 8080 和 80

在前面的截图中,我们可以看到,只有端口 8080 可以通过主机的 IP 地址访问。端口 80 仅在 Kubernetes 集群内本地可达。

hostNetworkhostPort 都不应在没有 Kubernetes 管理员监督的情况下使用。两者都代表了安全漏洞,除非应用程序严格需要,否则应避免使用。它们通常用于监控或管理工作负载,当我们需要管理或监控主机的 IP 地址时使用。

既然我们已经了解了在主机级别的不同选项,让我们继续回顾与 Service 资源相关的 NodePort 机制。

使用 Kubernetes 的 NodePort 功能发布应用程序

正如我们在本章开头提到的,在理解 Kubernetes 特性以发布集群范围的应用程序部分中,每个 NodePort 服务资源都有一个相关联的 ClusterIP 地址。这个 IP 地址用于内部负载均衡所有客户端请求(来自 Kubernetes 集群的内部和外部客户端)。Kubernetes 将这个内部负载分配给所有可用的 Pod 副本。所有副本具有相同的权重,因此它们将接收相同数量的请求。ClusterIP 地址使得运行在 Pod 内的应用程序能够在内部访问。为了让它们在外部可用,NodePort 服务类型通过 NAT 在所有集群节点上附加定义的端口。以下架构表示请求到达 Kubernetes 集群内运行的应用程序的路径:

图 11.9 – NodePort 简化的通信架构

图 11.9 – NodePort 简化的通信架构

Endpoint 资源用于将 Pod 的后端与服务的 ClusterIP 进行映射。该资源使用 Service YAML 清单中的标签选择器动态配置。以下是一个简单示例:

图 11.10 – 简单的 Pod 和 NodePort YAML 清单

图 11.10 – 简单的 Pod 和 NodePort YAML 清单

上面的截图展示了最常见的服务资源使用方式。通过这个清单,会创建一个 EndpointSlice 资源,利用选择器部分定义的标签,将应用程序的 Pod 与服务关联。注意,使用这些标签选择器会创建指向在同一命名空间中运行的后端 Pod 资源的 EndpointSlice 资源。但是,我们也可以创建没有动态 Pod 附加的服务资源。例如,这种场景可能有用,例如将运行在 Kubernetes 外部的外部服务与内部服务资源连接起来(这就是ExternalName服务资源类型的工作方式),或者从另一个命名空间访问服务,就好像它在当前命名空间上部署一样。由于 kube-proxy 组件,内部 Pod 得以被访问,它会将流量注入到 Pod 的容器中。尽管服务可以在集群范围内访问,但这只会发生在实际 Pod 运行的节点上。

使用标签选择器的 EndpointSlice 资源将创建 Endpoint 资源,因此,它们的状态更新会传播。失败的 Pod 资源将从实际服务中弃用,且请求不会被路由到这些后端,因此 Kubernetes 只会将流量路由到健康的 Pod。这是最流行和推荐的使用服务资源的方式,因为通过这种方式,您的资源与基础设施无关。

让我们通过创建一个webserver Pod 并使用kubectl expose以 NodePort 模式发布 Web 服务,快速查看 Endpoint 资源创建的工作原理:

图 11.11 – 使用命令式格式暴露 Pod

图 11.11 – 使用命令式格式暴露 Pod

在上面的例子中,我们创建了一个 Pod,然后通过使用3000032767范围将其暴露给服务资源的端口。我们还检索了一个端点列表和创建的动态配置。我们使用kubectl expose <WORKLOAD_TYPE> <WORKLOAD_NAME>格式语法创建了服务。这使用标签选择器来创建服务资源,从实际工作负载中获取标签,因此创建了一个 EndpointSlice 资源,将可用的 Pod 附加到服务。

在这个例子中,webserver应用程序将通过docker-desktop节点的 IP 地址进行访问,如果你使用 WSL2 进行执行,可能需要额外的配置。这是因为在这个基础架构中,我们需要声明一个 NAT IP 地址来转发到你的桌面计算机。如果使用 Hyper-V 或 Minikube 作为 PC 上的 Kubernetes 环境,则不需要这种配置。在远程 Kubernetes 集群中,您必须确保主机的 IP 地址和端口可以从您的计算机访问。

重要提示

由于 NodePort 服务使用主机的端口,这些端口必须在每个节点的防火墙中允许。你的 Kubernetes 管理员可能已在 Kubernetes 平台节点上配置了多个接口,并应告知你使用哪些 IP 地址使应用程序可访问。

如果你的工作负载运行在云基础设施上,可能需要额外的步骤来允许访问你的服务资源,因此这通常不是发布应用程序的最佳选择。

在下一节中,我们将回顾 LoadBalancer 服务类型,该类型是专门为云环境创建的,但现在也可以在本地基础设施中使用,这得益于像 MetalLB 这样的软件负载均衡器。

使用 LoadBalancer 服务提供对你的服务的访问

LoadBalancer 类型的服务需要外部设备来集成你的应用程序。这种类型的服务资源包括一个 NodePort 和其 ClusterIP IP 地址。外部设备提供一个 LoadBalancer IP 地址,该地址将被负载均衡到集群节点的 IP 地址和关联的 NodePort。这个配置完全由 Kubernetes 为你管理,但你必须为你的基础设施定义一个合适的 spec 部分。这种类型的资源依赖于实际的基础设施,因为它将使用来自软件定义网络基础设施的 API 来路由和发布应用程序的服务。LoadBalancer 服务资源最初是为 Kubernetes 云平台准备的,但现在在现代本地数据中心中更常见,尤其是那些具有软件定义网络和 API 管理设备的环境,尽管它们需要对底层平台有较好的了解才能使用。如前所述,每个 LoadBalancer 服务资源都会动态分配一个 IP 地址,这可能需要在你的云基础设施上进行额外的管理,甚至可能会产生额外的费用。

云提供商决定如何对服务进行负载均衡。根据使用的云平台,NodePort 部分有时可以省略,因为如果平台供应商定义了,可能会提供直接路由。

本地虚拟云基础设施,如 OpenStack,可以集成到我们的 Kubernetes 平台中,以管理这种类型的服务资源,因为它们也是 Kubernetes 核心的一部分。但如果你不使用 OpenStack 或其他本地虚拟云基础设施,可以通过像 MetalLB (metallb.org/) 这样的解决方案,在任何裸金属基础设施上运行一个 Kubernetes 兼容且动态可配置的负载均衡器。

如果你希望获得最大的兼容性并避免使用特定厂商的资源,则不推荐使用这种类型的服务资源。它确实依赖于底层基础设施,并可能需要在平台上进行额外的配置。

如果你作为开发人员需要实现一个类型为 LoadBalancer 的服务(或者你只是对它们的定义感到好奇),可以使用 Minikube,因为它能够在桌面计算机上实现此功能,而无需任何外部要求来进行协商。Docker Desktop 会将 LoadBalancer 的 IP 地址报告为 localhost,因此你可以直接使用 127.0.0.1 IP 地址连接到给定的服务。

让我们通过一个简单的例子来看这个是如何工作的。我们将首先启动一个新的 Minikube 集群环境(确保在启动 Minikube 之前停止 Docker Desktop 或 Rancher Desktop 实例),然后我们将执行 minikube startminikube tunnel 命令:

图 11.12 – 从管理员控制台执行 Minikube 集群

图 11.12 – 从管理员控制台执行 Minikube 集群

我们将打开另一个控制台,但这次我们将连接到 Kubernetes 集群,因此不需要以管理员身份执行命令。我们创建一个 Pod,然后使用命令式模式暴露它,类型为 LoadBalancer。

图 11.13 – 在 Minikube 中创建 LoadBalancer 服务

图 11.13 – 在 Minikube 中创建 LoadBalancer 服务

请注意,我们有一个新的列显示外部 IP 地址。在这种情况下,它是一个真实外部负载均衡器设备的仿真,为新的服务清单提供了特定的 IP 地址。实际上,Minikube 会从 Kubernetes 节点创建一个通道到你的桌面计算机,使得 Pod 可以通过分配的负载均衡 IP 地址 (EXTERNAL-IP) 和服务端口进行访问。在这种情况下,我们可以通过 http://10.98.19.87:8080 访问 NGINX Web 服务器。然后,我们使用 curl 测试应用程序的可访问性(curl 是 Windows PowerShell 的 Invoke-WebRequest 命令的别名)。

LoadBalancer 服务类型对平台基础设施的依赖使得这种类型过于特定,难以在日常使用中广泛应用,且可能在并非所有 Kubernetes 集群中都可用。因此,兼容性最好的解决方案是使用 Ingress 控制器,正如我们在接下来的章节中将学习的那样。

理解 Ingress 控制器

Ingress 控制器是一种软件,提供负载均衡、SSL 终止和基于主机的虚拟路由。它是一个反向代理,运行在 Kubernetes 集群中,管理一个反向代理网络组件,可以在 Kubernetes 集群内部或外部运行,类似于任何其他网络基础设施设备。Ingress 控制器的作用就像部署在 Kubernetes 集群中的其他控制器,尽管它并不由集群本身管理。我们必须手动部署这个控制器,因为它不是 Kubernetes 核心的一部分。如果需要,我们可以在集群中部署多个 Ingress 控制器,并定义默认使用哪个控制器(如果没有指定)。

Ingress 控制器在 HTTP/HTTPS 应用程序(OSI 第 7 层,应用层)中表现非常好,但我们也可以发布 TCP 和 UDP 应用程序(OSI 第 4 层,传输层),尽管这需要更多的配置,并且可能不是最佳选项。在这种情况下,最好使用外部负载均衡器并将流量路由到 NodePort 服务资源,因为 TCP 和 UDP Ingress 资源将需要额外的端口来分配传入流量。

Kubernetes 管理员使用 IngressClass 资源 来声明平台上可用的不同 Ingress 控制器。每个 Ingress 控制器都与一个 IngressClass 资源相关联。作为开发人员,你必须创建 Ingress 资源,这是反向代理你的应用工作负载所需的定义。

部署 Ingress 控制器有多种选择:云服务提供商和许多软件供应商已经开发了他们自己的解决方案,你可以将它们中的任何一种包含到你的 Kubernetes 设置中,但你必须理解它们的特定功能和特点。你可以在 Kubernetes 文档中查看可用的解决方案,链接为:kubernetes.io/docs/concepts/services-networking/ingress-controllers/#additional-controllers,并在准备应用程序之前向你的 Kubernetes 管理员询问你平台上可用的 Ingress 控制器。在你的 Ingress 资源中可能需要做一些小调整。接下来的部分,我们将讨论最常见的选项,默认包含在一些 Kubernetes 解决方案中的 Kubernetes NGINX Ingress Controller

部署 Ingress 控制器

部署 Ingress 控制器时,我们只需按照所选解决方案的具体指示操作。安装给定软件到你的集群中可能有不同的方法,但我们将遵循最简单的方式:部署一个包含所有必需资源的 YAML 文件(raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.8.1/deploy/static/provider/cloud/deploy.yaml)。在使用该 URL 之前,请确保检查最新的可用版本,并参考特定的安装说明。撰写本书时,NGINX 控制器版本 1.8.1 是最新的版本。在这个例子中,我们使用了云端 YAML 文件,尽管如果你已经安装了一个完全功能的 Kubernetes 环境,你也可以选择裸金属选项(该版本使用 NodePort 而不是 LoadBalancer 类型)。让我们通过一个简单的例子来操作:

  1. 我们从在 Docker Desktop 环境中运行 kubectl apply 开始:

图 11.14 – 流行的 Kubernetes NGINX Ingress Controller 部署

图 11.14 – 部署流行的 Kubernetes NGINX Ingress Controller

如前面的截图所示,Ingress Controller 工作所需的许多资源已经创建。一个新的命名空间 ingress-nginx 已创建,且一些 Pod 已经在其中运行:

图 11.15 – 部署、Pod 和 IngressClass 资源已创建

图 11.15 – 部署、Pod 和 IngressClass 资源已创建

在前面的截图中,我们可以看到创建了一个 IngressClass 资源。我们可能需要将其配置为默认。

  1. 让我们检查已部署的 Ingress Controller。我们首先检查为访问 Deployment 资源而创建的 Service 资源,使用 kubectl get svc

图 11.16 – 验证已部署的 Ingress Controller

图 11.16 – 验证已部署的 Ingress Controller

请注意前面截图中,Service 资源被创建为 LoadBalancer 类型。它获得了 localhost IP 地址(我们在此示例中使用的是 Docker Desktop),这意味着我们应该能够直接使用 curl 通过 localhost 访问 NGINX Ingress Controller 后端。该 Service 正在监听端口 80443,我们能够访问这两个端口(我们通过给 curl 添加 -k 参数,以避免验证相关的自动签名和不受信任的 SSL 证书)。

使用 Ingress Controllers 可以提高安全性,当我们添加 SSL 证书以实现应用程序暴露组件与用户之间,或甚至不同组件之间使用与 Service 资源关联的 Ingress URL 的 SSL 隧道时。

现在让我们继续学习如何使用 Ingress 资源管理应用程序的行为。

Ingress 资源

与任何其他资源一样,我们需要定义 apiVersionkindmetadataspec 键及其对应的部分。最重要的部分是 .spec.rules,它定义了一组主机规则,用来动态配置由 Ingress Controller 部署的反向代理。让我们看一个基本的示例:

图 11.17 – Ingress 资源示例

图 11.17 – Ingress 资源示例

在前面的截图中,我们可以看到 ingressClassName 键,它指示将使用的 Ingress Controller。rules 部分定义了一组主机头和与不同后端关联的路径。在我们的示例中,要求包含 www.webserver.com 主机头;如果请求未包含该头,它们将被重定向到默认后端(如果已定义),或显示 404 错误(页面未找到)。backend 部分描述了将接收应用程序请求的 Service 资源。

让我们使用上一节创建的 webserver 服务资源快速运行一个示例。它将在端口 8080 上监听,因此我们创建一个带有虚假主机名的 Ingress 资源,并使用 curl -H "host: <FAKE_HOST>" http://localhost 来验证其可访问性(我们使用 localhost,因为它的 IP 地址是与 LoadBalancer 服务关联的)。

图 11.18 – 用于 Web 服务器服务示例的 Ingress Web 服务器资源

图 11.18 – 用于 Web 服务器服务示例的 Ingress Web 服务器资源

安全功能在 .spec.tls 部分实现,在这里我们将主机与其密钥和证书链接,并集成到一个 Secret 资源中。这个 Secret 必须包含在你定义 Ingress 资源的命名空间中,并且是 tls 类型。这些 Secrets 中的 data 部分必须包含生成证书的密钥和生成的证书本身。我们将在 实验 部分通过一个示例学习如何创建它。

我们可以拥有一个包含多个主机规则的 Ingress 资源,每个主机有多个路径,尽管将每个主机分开到不同的 Ingress 资源中以便于管理,并为不同的后端服务资源设置多个路径是更常见的做法。这个组合代表了一个典型的微服务架构,其中每个应用功能由不同的后端服务提供。

annotations 部分可以用来指示 Ingress 控制器进行特殊配置。以下是我们可以通过注解为 Kubernetes NGINX Ingress 控制器管理的一些最重要的配置:

  • nginx.ingress.kubernetes.io/rewrite-target:通常我们会在 Ingress 资源中集成一些重写规则,用于重写应用程序的 URI 路径。这里也有一些选项用于重定向 URL。

  • nginx.ingress.kubernetes.io/auth-typenginx.ingress.kubernetes.io/auth-secret:这将允许我们在 Ingress 层面为我们的应用程序使用基本认证。

  • nginx.ingress.kubernetes.io/proxy-ssl-verify:如果我们的服务资源后端使用 TLS,那么有许多注解可以管理 NGINX 如何与它们连接。

  • nginx.ingress.kubernetes.io/enable-cors:我们可能需要在应用程序中启用跨域资源共享CORS),以允许一些外部路由和 URL。这里还有其他一些有趣的选项,用于管理和保护 CORS 行为。

  • nginx.ingress.kubernetes.io/client-body-buffer-size:限制客户端请求的大小以避免整体性能问题是很常见的,但你的应用程序可能需要更大的响应。

除了这些选项外,还有许多其他选项可供选择,您可能需要向您的 Kubernetes 和基础设施管理员寻求建议。可供选择的范围包括集成外部身份验证后端、限制请求速率以防止分布式拒绝服务DDoS)攻击、重定向和重写 URL、启用 SSL 通透,甚至管理金丝雀应用程序部署,将部分请求路由到工作负载后端的更新版本。有些选项可以在 Ingress 控制器级别进行定义,这将影响所有的 Ingress 资源。有关可用注解的完整列表,请参阅以下页面:kubernetes.github.io/ingress-nginx/user-guide/nginx-configuration/annotations/

理解这里提到的选项非常重要,因为它们可能与其他 Ingress 控制器提供的选项有所不同(至少,它们肯定会使用其他注解键)。一些 Ingress 控制器,例如 Kong,还为后端服务实现了 API 管理,如果这些服务涉及许多交互,这将非常有用。请向你的 Kubernetes 管理员咨询有关平台上部署的 Ingress 控制器。

我们在这里涵盖了基础知识,但像往常一样,请注意,您的 Ingress 资源可能需要一些小调整才能完全实现您的平台要求。例如,在 OpenShift 中,可以启用 Ingress 控制器,但默认情况下,Kubernetes 将使用 OpenShift Route,这是 Red Hat 实现的用于在 Kubernetes 中发布应用的 L7 反向代理。Ingress 控制器和 OpenShift Route 非常相似(即使它们的资源看起来也很相似),但如果您的应用需要在 OpenShift 集群上运行,您应该进一步查看相关的特定信息。如果在您的应用上可以同时使用这两种实现,以下链接可能有助于您决定使用哪个:cloud.redhat.com/blog/kubernetes-ingress-vs-openshift-route

默认情况下,Kubernetes 实现了一个扁平网络,应用之间没有任何访问边界。这对横向流量(东-西流量)没有任何限制,这种配置可能导致严重的安全问题。在接下来的章节中,我们将回顾一些安全改进,以帮助我们安全地发布应用。

改进我们应用程序的安全性

在 Kubernetes 中,应用流量默认是自由流动的。部署了一个扁平网络来覆盖 Pod 到 Pod 以及 Service 到 Pod 的通信——请记住,Pod 内的容器共享一个公共 IP 地址。运行在 Kubernetes 集群中的 Pod 会彼此看到,即使它们运行在不同的节点和命名空间中,保护一个应用免受另一个应用的影响也需要额外的工作。

听起来可能有些奇怪,但运行在不同命名空间中的应用程序可以彼此看到。实际上,如果它们有相关联的 Service 资源,就可以轻松通过内部 DNS 解析其关联的 IP 地址并访问其进程。

在下一部分,我们将学习如何使用 NetworkPolicy 资源定义我们的应用程序通信,并让 Kubernetes 为我们阻止任何不必要的连接。

网络策略

NetworkPolicy 资源(也称为 netpol)允许我们管理 OSI 第 3 层和第 4 层的通信(分别为 IP 和端口访问)。Kubernetes 将 NetworkPolicy 资源作为核心的一部分提供,但其实现依赖于集群中部署的 CNI。因此,使用实现此功能的 CNI(如 Calico、Canal 或 Cilium 等)至关重要。

NetworkPolicy 资源定义了 Pod 通信的所有方面:出口(输出流量)和入口(输入流量)。正如我们稍后将看到的,NetworkPolicy 是通过使用 .spec.podSelector 部分应用于特定 Pod 集合的。NetworkPolicy 资源是命名空间范围的,因此 podSelector 允许我们决定哪些 Pods 会受到规则定义的影响。可以对 Pod 应用多个规则,您的 Kubernetes 管理员可能已包括一些影响整个集群的 GlobalNetworkPolicy 资源,因此您应该询问是否有任何集群默认规则需要允许某些出口或入口流量。在您的集群中,通常默认仅允许 DNS 流量,禁止所有其他出口流量。如果您的集群是这种情况,您将需要在应用程序的清单中声明所有的出口(以及入口)通信。我们来看一个简单的例子,展示如何在 NetworkPolicy 中声明出口和入口通信:

图 11.19 – 包含出口和入口规则的 NetworkPolicy 资源清单

图 11.19 – 包含出口和入口规则的 NetworkPolicy 资源清单

让我们回顾一下前面代码片段中一些最重要的键和值。.spec.podSelector 部分声明了当前命名空间中(如果 metadata 部分没有声明)哪些 Pods 会受到此策略的影响。在 policyTypes 键下,我们可以看到定义的策略类型列表。在这里需要澄清的是,出口通信是由 Pod 发起的,而入口通信是指进入 Pod 的通信。如果同时声明了出口和入口类型,而仅为其中之一声明了部分(如前面的例子中的 egressingress 部分),则未声明的部分将被视为空,意味着该类型的通信将完全不被允许。本例中的 egress 部分是要应用的一系列规则。我们再仔细看一下:

  • 第一条规则允许从选定的 Pods(带有 app=myapp 标签)到 10.0.0.0/24 子网中任何主机的端口 5978 的外发通信。

  • 第二条规则允许向任何主机的 UDP 端口 53 进行外发通信(Kubernetes 内部和外部 DNS)。

ingress 部分,也声明了两条规则:

  • 第一条规则允许从 172.17.0.0/16 子网(但不包括 172.17.1.0/24 子网)中的主机、带有 project=myproject 标签的命名空间中的 Pods 以及当前命名空间中带有 role=frontend 标签的 Pods 访问选定 Pods(带有 app=myapp 标签)的端口 6379。我们可以说,Kubernetes 完全是 关于标签

  • 第二条入站规则允许来自 192.168.200.0/24 子网中的主机访问选定 Pods 的端口 80

这些规则看起来可能很复杂,但实际上实施起来非常简单。

如果你计划将应用程序的所有组件部署在特定命名空间中,可能值得允许所有 Pods 之间的所有外发和入站通信。但在生产环境中这样做并不是一个好主意,因为这样只有攻击者横向移动到其他命名空间时才会被阻止,但如果你某个 Pod 中出现安全问题,可能会影响同一命名空间中的其他 Pods。在准备 NetworkPolicy 资源或调试应用程序时,允许所有命名空间内部的东西-West 流量也可能是必要的。以下的 YAML 清单允许所有内部通信,并且仅将前端组件暴露给外部:

图 11.20 – 示例清单,允许所有命名空间通信并允许对特定 Pod 的端口 80 进行访问

图 11.20 – 示例清单,允许所有命名空间通信并允许对特定 Pod 的端口 80 进行访问。

上面的截图显示了两个清单:

  • 左侧的清单声明了一个 NetworkPolicy 资源,允许当前命名空间中所有 Pods 之间的所有通信。由于 podSelector 为空,因此规则适用于命名空间中的所有 Pods。

  • 右侧的清单允许从 192.168.200.0/24 子网中的主机访问带有 appcomponent=frontend 标签的 Pod(podSelector 适用于该 Pod)上的端口 80

重要说明

NetworkPolicy 资源在连接级别应用,并且默认情况下不会留下任何连接痕迹,这在尝试修复组件之间的某些连接问题时可能会很不方便。一些 CNI 插件,如 Calico,允许记录 Pods 之间的连接。这需要在 Kubernetes 环境中额外的权限。如果需要调试应用程序,可以向 Kubernetes 管理员询问是否可以提供一些连接痕迹。在某些情况下,最好从一个允许并记录命名空间中所有连接的 NetworkPolicy 开始。

作为开发者,你需要负责创建和维护应用程序的资源清单,因此,也包括应用程序所需的 NetworkPolicy 资源。如何组织这些资源由你决定,但建议使用具有描述性的名称,并将多个规则按每个应用程序组件分组在一个清单中。通过这种方式,你可以精细调整每个组件的配置。在实验部分,我们为你准备了一个具体的练习,帮助你通过只允许受信任的访问来保护应用程序。

NetworkPolicies 允许你彻底隔离所有应用程序的组件,尽管它们可能很难实现,但这个解决方案确实提供了极高的粒度,并且不依赖于底层基础设施。你只需要一个支持此功能的 Kubernetes CNI。

在接下来的章节中,我们将回顾服务网格解决方案如何通过在所有应用程序的 Pod 上注入小型、轻量级的代理来提供更复杂的安全功能。

服务网格

通过实施 NetworkPolicies,我们强制执行一些类似防火墙的连接规则,限制应用程序工作负载之间的互联,但这可能还不够。服务网格被视为一个基础设施层,它连接服务并管理它们如何相互交互。服务网格用于管理东西向和南北向流量到后台服务,在某些情况下,甚至可以替代 Ingress 控制器,前提是服务网格解决方案已在 Kubernetes 中部署。

最流行的服务网格解决方案是Istio(一个开源解决方案,属于云原生计算基金会CNCF)),尽管还有一些其他值得提及的选项,如 Linkerd、Consul Connect 和 Traefik Mesh。如果你正在使用云 Kubernetes 平台,可能会有自己的云提供商解决方案可用。

服务网格解决方案能够为你的应用程序添加 TLS 通信、流量管理和可观察性,而无需修改其代码。如果你在寻找一个透明的安全和管理层,使用服务网格解决方案可能是完美的选择,但它也增加了高度的复杂性和一定的平台开销。

服务网格解决方案在你所有的应用程序工作负载上部署一个小型代理。这些代理会拦截你应用程序的所有网络流量,并应用规则来允许或禁止你的应用程序进程之间的通信。

本书的范围不包括服务网格的实现和使用,但值得调查一下你的 Kubernetes 管理员是否在平台上部署了服务网格解决方案,这可能需要实现特定于服务网格的资源。

正如本节前面提到的,NetworkPolicy 资源通过禁用未经授权的通信来隔离应用的工作负载,这可能为生产环境提供足够的安全性。这些资源高度可配置,你需要负责定义应用各个组件之间所需的通信,并准备所需的 YAML 清单文件,以完整实现所有应用通信。在接下来的 实验 部分,我们将看到本章中学到的一些内容在实际操作中的应用,尝试发布前几章使用的 simplestlab 应用。

实验

在本节中,我们将展示如何实现 simplestlab Tier-3 应用的 Ingress 资源,该应用为 Kubernetes 环境准备,相关内容在 第九章实现架构模式》中进行了介绍,并在 第十章在 Kubernetes 中利用应用数据管理》中得到了改进。所有资源的清单文件已经为你准备好,存放在本书的 GitHub 仓库中,地址为 github.com/PacktPublishing/Containers-for-Developers-Handbook.git,可以在 Chapter11 文件夹中找到。通过执行 git clone 命令下载所有内容,确保你获取到最新版本,或者如果你之前已经下载过该仓库,可以使用 git pull 更新。所有运行 simplestlab 应用所需的清单文件和步骤都位于 Containers-for-Developers-Handbook/Chapter11/simplestlab 目录下,而 Ingress 和 NetworkPolicy 资源的清单文件则直接位于 Chapter11 文件夹中。

这些实验将帮助你学习和理解 Kubernetes 中 Ingress 和 NetworkPolicy 资源的工作原理。你将部署一个 Ingress 控制器,使用 HTTP 和 HTTPS 协议发布 simplestlab 示例应用,并创建一些 NetworkPolicy 资源来仅允许适当的连接。Ingress 控制器实验可以在 Docker Desktop、Minikube 和 Rancher 上运行,但对于 NetworkPolicy 资源部分,你需要使用支持此类资源的 Kubernetes CNI,例如 Calico。每个 Kubernetes 桌面或平台的实现方式不同,它们以不同的方式管理并展示各自的网络基础设施。

这是你在本章 GitHub 仓库中可以找到的任务:

  1. 首先,我们将部署 Kubernetes NGINX Ingress 控制器(如果你在实验平台中没有自己的 Ingress 控制器)。

  2. 我们将部署为 simplestlab 应用准备的所有清单文件,这些文件位于 simplestlab 文件夹内。我们将使用 kubectl create -f simplestlab 命令。

  3. 一旦所有组件准备就绪,我们将使用为此任务准备的清单文件创建一个 Ingress 资源。

  4. 在 GitHub 仓库中,你将找到有关部署更高级的 Ingress 清单的说明,包含自签名证书以及加密客户端通信的内容。

  5. 在 GitHub 仓库中还有一个 NetworkPolicy 实验,帮助你了解如何通过兼容的 CNI(如 Calico)使用此功能来保护应用程序。

在第一个任务中,我们将部署我们自己的 Ingress 控制器。

通过部署你自己的 Ingress 控制器来改善应用程序访问

对于此任务,我们将使用 Docker Desktop,它提供了良好的负载均衡器服务实现。这些 Service 资源将附加本地 IP 地址,便于连接已发布的服务。我们将使用基于 LoadBalancer 服务类型的 Kubernetes NGINX Ingress 控制器的云部署(kubernetes.github.io),其清单描述如下:raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.8.1/deploy/static/provider/cloud/deploy.yaml。如果你使用的是完全裸金属基础设施,可以使用裸金属 YAML(raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.8.1/deploy/static/provider/baremetal/deploy.yaml)并按照 kubernetes.github.io/ingress-nginx/deploy/baremetal/ 中的附加说明进行 NodePort 路由配置。

重要提示

仓库中提供了两个 YAML 文件的本地副本,分别为 kubernetes-nginx-ingress-controller-full-install-cloud.yamlkubernetes-nginx-ingress-controller-full-install-baremetal.yaml

完成此步骤后,按照以下步骤进行操作:

  1. 我们只会部署云版本,在 YAML 文件中以一系列连接的清单提供。我们将使用 kubectl apply 来部署控制器:

    Chapter11$ kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-
    nginx/controller-v1.8.1/deploy/static/provider/cloud/deploy.yaml
    namespace/ingress-nginx created
    serviceaccount/ingress-nginx created
    serviceaccount/ingress-nginx-admission created
    role.rbac.authorization.k8s.io/ingress-nginx created
    role.rbac.authorization.k8s.io/ingress-nginx-admission created
    clusterrole.rbac.authorization.k8s.io/ingress-nginx created
    clusterrole.rbac.authorization.k8s.io/ingress-nginx-admission created
    rolebinding.rbac.authorization.k8s.io/ingress-nginx created
    rolebinding.rbac.authorization.k8s.io/ingress-nginx-admission created
    clusterrolebinding.rbac.authorization.k8s.io/ingress-nginx created
    clusterrolebinding.rbac.authorization.k8s.io/ingress-nginx-admission created
    configmap/ingress-nginx-controller created
    service/ingress-nginx-controller created
    service/ingress-nginx-controller-admission created
    deployment.apps/ingress-nginx-controller created
    job.batch/ingress-nginx-admission-create created
    job.batch/ingress-nginx-admission-patch created
    ingressclass.networking.k8s.io/nginx created
    validatingwebhookconfiguration.admissionregistration.k8s.io/ingress-nginx-admission created
    
  2. 我们可以查看已创建的工作负载资源:

    Chapter11$ kubectl get all -n ingress-nginx
    NAME                                            READY   STATUS       RESTARTS   AGE
    pod/ingress-nginx-admission-create-9cpnb        0/1     Completed   0          13m
    pod/ingress-nginx-admission-patch-6gq2c         0/1     Completed   1          13m
    pod/ingress-nginx-controller-74469fd44c-h6nlc   1/1     Running     0          13m
    NAME                                         TYPE           CLUSTER-IP       EXTERNAL-IP   PORT(S)                      AGE
    service/ingress-nginx-controller             LoadBalancer   10.100.162.170   localhost      80:31901/TCP,443:30080/TCP   13m
    service/ingress-nginx-controller-admission   ClusterIP      10.100.197.210   <none>        443/TCP                      13m
    NAME                                       READY   UP-TO-DATE   AVAILABLE   AGE
    deployment.apps/ingress-nginx-controller   1/1     1            1           13m
    NAME                                                  DESIRED    CURRENT   READY   AGE
    replicaset.apps/ingress-nginx-controller-74469fd44c   1         1         1       13m
    NAME                                       COMPLETIONS   DURATION   AGE
    job.batch/ingress-nginx-admission-create   1/1           7s          13m
    ingress-nginx-controller Service is attached to the localhost IP address, so we can check its availability at http://localhost:80 and https://localhost:443 (exposed ports):
    
    

    Chapter11$ curl http://localhost

    <head><title>404 未找到</title></head> <body>

    404 未找到


    nginx
    </body>

    –k 参数用于避免证书验证:

    Chapter11$ curl https://localhost
    curl: (60) SSL certificate problem: self-signed certificate
    More details here: https://curl.se/docs/sslcerts.xhtml
    curl failed to verify the legitimacy of the server and therefore could not
    establish a secure connection to it. To learn more about this situation and
    how to fix it, please visit the web page mentioned above.
    Chapter11$ curl -k https://localhost
    <html>
    <head><title>404 Not Found</title></head>
    <body>
    <center><h1>404 Not Found</h1></center>
    <hr><center>nginx</center>
    </body>
    </html>
    
    
    

Ingress 控制器现在已部署并在监听,404 错误表明 localhost 主机没有关联的 Ingress 资源(实际上甚至没有配置默认资源,但 Ingress 控制器响应正确)。

使用 Ingress 控制器在 Kubernetes 上发布 simplestlab 应用程序

在这个实验中,我们将部署 simplestlab,一个非常简化的三级应用,位于 simplestlab 目录中,并且我们将发布其前端组件 lb,无需 TLS 加密。你可以按照以下步骤进行:

  1. 应用程序的清单已经为您编写;我们只需要使用 kubectl 创建一个适当的命名空间,然后部署所有相关资源:

    Chapter11$ kubectl create ns simplestlab
    namespace/simplestlab created
    Chapter11$ kubectl create -n simplestlab \
    -f simplestlab/
    deployment.apps/app created
    service/app created
    secret/appcredentials created
    service/db created
    statefulset.apps/db created
    secret/dbcredentials created
    secret/initdb created
    configmap/lb-config created
    daemonset.apps/lb created
    simplestlab namespace:
    
    

    Chapter11$ kubectl get all -n simplestlab

    NAME                       READY   STATUS    RESTARTS   AGE

    pod/app-5f9797d755-5t4nz   1/1     正在运行   0          81 秒

    pod/app-5f9797d755-9rzlh   1/1     正在运行   0          81 秒

    pod/app-5f9797d755-nv58j   1/1     正在运行   0          81 秒

    pod/db-0                   1/1     正在运行   0          80 秒

    pod/lb-5wl7c               1/1     正在运行   0          80 秒

    NAME          TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)    AGE

    service/app   ClusterIP   10.99.29.167            3000/TCP   81 秒

    service/db    ClusterIP   None                    5432/TCP   81 秒

    service/lb    ClusterIP   10.105.219.69           80/TCP     80 秒

    NAME                DESIRED   CURRENT   READY   UP-TO-DATE   AVAILABLE   NODE SELECTOR   AGE

    daemonset.apps/lb   1         1         1       1            1                      80 秒

    NAME                  READY   UP-TO-DATE   AVAILABLE   AGE

    deployment.apps/app   3/3     3            3           81 秒

    NAME                             DESIRED   CURRENT   READY   AGE

    replicaset.apps/app-5f9797d755   3         3         3       81 秒

    NAME                  READY   AGE

    lb 组件没有暴露。它正在监听端口 80,但使用了 ClusterIP,因此该服务仅在集群内部可用。

    
    
  2. 我们现在将创建一个 Ingress 资源。ingress 目录中有两个清单文件。我们将使用 simplestlab.ingress.yaml,该文件将在没有自定义 TLS 加密的情况下进行部署:

    Chapter11$ cat ingress/simplestlab.ingress.yaml
    apiVersion: networking.k8s.io/v1
    kind: Ingress
    metadata:
      name: simplestlab
      annotations:
        # nginx.ingress.kubernetes.io/rewrite-target: /
    spec:
      ingressClassName: nginx
      rules:
      - host: simplestlab.local.lab
        http:
          paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: lb
                port:
                  number: 80
    
  3. 我们将仅部署之前创建的清单:

    Chapter11$ kubectl create \
    -f ingress/simplestlab.ingress.yaml -n simplestlab
    ingress.networking.k8s.io/simplestlab created
    Chapter11$ kubectl get ingress -n simplestlab
    NAME CLASS HOSTS ADDRESS PORTS AGE
    curl:
    
    

    Chapter11$ curl -H "host: simplestlab.local.lab" http://localhost/

    <head>

    </head> <body>

    </body> </body>

    simplestlab 应用程序现在已可用并且可以访问。

    
    
  4. 我们可以更改 /etc/hosts 文件(或等效的 MS Windows c:\system32\drivers\etc\hosts 文件)。添加以下行,并打开网页浏览器访问 simplestlab 应用程序:

    127.0.0.1 simplestlab.local.lab
    

    这需要 root 或管理员权限,因此使用 curl 并带有 -H--header 参数来检查应用程序可能会更为方便。

重要说明

您可以使用浏览器扩展程序来修改请求头,或使用包括 nip.io 的 FQDN,这将在 第十三章中讨论,管理应用程序生命周期。例如,如果您使用 MS Edge,可以直接添加 simple-modify-headers 扩展程序(对于其他浏览器和操作系统,您也能找到类似的扩展)。配置此扩展的更多信息可以在本章的 GitHub Readme.md 文件中找到。

应用程序将在localhost可用(请注意,我们在simple-modify-headers扩展配置中定义了 URL 模式为http://locahost/*):

 图 11.21 – 简单修改标题 Edge 扩展配置

图 11.21 – 简单修改标题 Edge 扩展配置

  1. 扩展配置完成后,我们可以使用localhost访问simplestlab应用程序:

图 11.22 – 由于 Ingress 控制器,simplestlab 应用程序可访问

图 11.22 – 由于 Ingress 控制器,simplestlab 应用程序可访问

在 GitHub 存储库中,您将找到有关如何向 Ingress 资源添加 TLS 以提高应用程序安全性以及如何使用 Calico 作为 CNI 与 Minikube 实现 NetworkPolicy 资源的说明。

这些实验室帮助您了解如何通过隔离应用程序的组件并仅暴露和发布用户和其他应用程序组件所需的内容来提高应用程序的安全性。

总结

在本章中,我们学习了如何为我们的用户和其他部署在同一集群内部或外部的组件发布我们的应用程序。我们考察了不同的机制,但最终,决定哪些应用程序组件应该被暴露和访问是由您决定的。

在本章中,我们回顾了一些快速解决方案,用于在我们的桌面计算机上直接调试和发布服务资源,使用kubectl客户端。我们还研究了不同的服务类型,这些类型对于在远程 Kubernetes 开发集群上本地访问远程应用程序可能很有用。我们讨论了 LoadBalancer 服务是 Kubernetes 核心的一部分,并且为云平台做好了准备,因此在本地部署可能会很困难,这就是为什么推荐的交付应用程序的选项是创建自己的 Ingress 资源清单。Ingress 控制器将帮助您在任何 Kubernetes 平台上发布应用程序。您将使用 Ingress 资源定义应用程序的发布方式,并根据部署在您的 Kubernetes 平台上的 Ingress 控制器调整其语法。

在本章末尾,我们介绍了 NetworkPolicy 资源和服务网格概念,通过删除任何不受信任和未定义的通信方式,提供了改善应用程序安全性的手段。接着是一些实验室来测试我们所学到的内容。

在下一章中,我们将回顾一些有用的机制和工具,用于监控和收集应用程序的性能数据。

第十二章:获取应用程序洞察

到目前为止,本书中我们已经看到如何使用软件容器实现应用程序,以及 Kubernetes 如何帮助我们在生产环境中运行它们,并确保安全性和高可用性。我们可以运行并管理自己的 Kubernetes 环境,为任何 Kubernetes 环境准备我们的应用程序;为了定制部署到特定平台,我们只需要做少量的修改。在本章中,我们将学习如何获取访问我们应用程序的 Kubernetes 资源权限,以及可以用于识别应用程序问题的不同工具。我们将回顾Prometheus,这是 Kubernetes 世界中用于监控应用程序组件健康状况、交互和资源的流行工具。我们还将探索Loki,这是一个开源日志平台,具有高度的可扩展性、可配置性,并且容易与 Kubernetes 集成。在本章结束时,我们将了解一些可用于应用程序的仪器化选项。

这是本章内容的总结:

  • 理解你的应用程序的行为

  • 获取访问你的 Kubernetes 资源的权限

  • 监控你的应用程序的指标

  • 记录应用程序的重要信息

  • 对你的应用程序进行负载测试

  • 向应用程序代码中添加仪器化

技术要求

你可以在github.com/PacktPublishing/Containers-for-Developers-Handbook/tree/main/Chapter12找到本章的实验室,在那里你会找到一些本章内容中省略的扩展解释,以便让内容更容易跟随。本章的Code In Action视频可以在packt.link/JdOIY找到。

理解你的应用程序的行为

如果你不知道应用程序是如何工作的,理解其行为可能会非常困难。这听起来似乎很显而易见,但你对应用程序了解得越深入,就能更好地实施不同的机制,以随时验证它的状态。

作为开发人员,你需要问自己,在哪里添加监控端点或标志是最合适的。但你的应用程序也应当通过外部第三方工具进行监控,这引出了以下几种监控机制:

  • 内部应用程序指标:在项目结束时实现监控点可能会很困难,但如果你从一开始就引入它们,并且测量事务之间的时间,你将能够获得应用程序的整体性能视图。

  • 内部健康检查:健康检查对于识别应用程序何时失败至关重要,但我们可以进一步提升。我们可以有一些简单的错误/OK 快速测试,这些测试可以频繁执行,帮助 Kubernetes 保持应用程序的正常运行。

  • 外部应用程序指标:某些组件,如数据库,可能允许你查询可以导出并供外部组件使用的某些指标。

  • 外部健康检查:这些健康检查用于提供应用程序行为的全面概述。它们可能很复杂,涉及多个组件,并且可能会触发一些警报或创建事件,帮助我们管理应用程序的状态。

我们还没有讨论这些机制如何实现。虽然在应用程序中包含一些指标可能需要我们对代码进行一些修改,添加一些带有度量值的条目,或者创建需要提取的度量源,其他方面则可以通过使用外部工具,在应用程序代码之外来实现。

在容器中运行应用程序可以帮助你实现这里描述的任何模型。但绝对不要在应用程序容器中包含并行检查进程。这不是容器的正确使用方式。记住,我们在第一章《使用 Docker 的现代基础设施与应用程序》中引入了容器的概念:容器是主进程,在主机中独立运行,共享内核给所有其他容器。运行多个进程并不是一个好的实践,因为你需要确保每个进程在容器停止时收到 SIGTERM 或 SIGKILL 信号,如果你创建了多个进程并让它们在容器的命名空间中独立运行(类似于 PID 命名空间,但没有依赖关系),这可能会变得很棘手。

在本章中,我们将学习如何实现不同的模型来监控、记录,甚至跟踪我们应用程序的进程。但让我们先从回顾如何从我们自己应用程序的工作负载中检索和管理 Kubernetes 资源开始,这是我们后面将使用的某些工具的关键。

获取对 Kubernetes 资源的访问权限。

有时,你的应用程序需要管理某些 Kubernetes 资源。让我们考虑以下几种情况:

  • 默认的 Kubernetes 自动扩展不符合我们的需求。

  • 我们需要创建一些由事件触发的资源——例如,当我们的应用程序启动时。

在这些场景中,我们的应用程序进程需要从 Kubernetes API 获取信息并创建一些资源。如果我们考虑这个工作流,至少我们的 Pods 需要能够访问 Kubernetes API 服务器的 IP 地址,并且具有执行所需操作和资源的适当权限。在本节中,我们将学习如何从应用程序进程中创建和管理 Kubernetes 对象。

首先,我们需要记住一些来自第八章的概念,使用 Kubernetes 调度器部署应用程序。在那一章中,我们讨论了 Kubernetes 如何通过使用不同的身份验证和授权策略来提高应用程序的安全性。你可能需要向你的 Kubernetes 管理员询问一些 Kubernetes 平台的见解,但你很可能会在你的环境中使用基于角色的访问控制RBAC),因为目前所有 Kubernetes 平台都使用这种机制。这意味着所有 Kubernetes API 请求都必须通过角色授权系统进行授权。Kubernetes 将使用客户端证书、持有者令牌或身份验证代理通过身份验证插件来验证 API 请求,当客户端请求经过身份验证时,授权系统将允许或拒绝这些请求。

默认情况下,在命名空间中运行的所有 Pods 都将继承一个服务账户及其令牌,用于在 Kubernetes 集群内验证应用程序进程的身份。由于这种行为不安全,因此 Kubernetes 管理员通常会避免这种情况。但是无论如何,我们将使用 Pod 定义中包含的服务账户及其关联令牌来标识进程并验证对指定资源和操作的访问权限。

在 Kubernetes 集群中,RBAC API 声明了角色及其绑定,用于将 Kubernetes 资源与允许的操作配对。该角色系统将包括命名空间授权(Role 和 RoleBinding 资源)和集群级别授权(ClusterRole 和 ClusterRoleBinding 资源),它们帮助我们提供精细化访问控制。

Role 和 ClusterRole 资源定义了表示附加权限的规则;因此,如果某个权限没有规则定义,则会被拒绝。我们将在它们的清单定义中匹配资源和允许的动词。我们将使用 ClusterRole 资源来定义集群范围的权限,或从更高层次定义命名空间范围的权限,这使得我们可以在多个命名空间中重用它们。

让我们看一个角色的示例,以及如何通过 RoleBinding 将其与 ServiceAccount 资源关联:

图 12.1 – 角色和 RoleBinding 资源,允许我们在命名空间中列出和读取 Pods

图 12.1 – 角色和 RoleBinding 资源,允许我们在命名空间中列出和读取 Pods

请注意,在这两个资源中,我们都定义了 namespace,因为我们使用了命名空间范围的资源(如果我们将其部署到当前命名空间,则可以省略)。在角色资源中,我们定义了允许的 verbs 或动作列表,以及 resources,即这些动作将应用的资源(你可以使用 kubectl api-resources -o wide 来检索每个 Kubernetes 资源可用的动作)。当我们有不同的资源,它们的名称相同,但属于不同的 API 时,apiGroups 键就会被使用。让我们通过一个快速示例来演示这个配置。我们将在 default 命名空间中创建角色和角色绑定资源:

图 12.2 – 创建资源以便可以在命名空间中列出和读取 Pods

图 12.2 – 创建资源以便可以在命名空间中列出和读取 Pods

重要说明

我们可以使用 kubectl create role pod-reader --verb=get,list,watch --resource=Pods 来创建前面代码片段中的 pod-reader 角色资源。

我们已经将角色绑定资源应用于特定的服务账户,但它尚不存在。让我们在继续之前创建它:

图 12.3 – 创建  ServiceAccount 资源

图 12.3 – 创建 myserviceaccount ServiceAccount 资源

我们有一个角色,它允许我们列出、查看和获取任何命名空间中的 Pods(但仅应用于当前的 default 命名空间),并且有一个角色绑定将该角色资源与 default 命名空间中定义的服务账户 myserviceaccount 关联。我们现在将运行一个使用此服务账户的 Pod,并获取当前 Pods 的列表。我们将运行一个包含 kubectl 命令行的容器镜像。我们包含了 myserviceaccount 服务账户资源,并将 get pod 作为镜像的参数:

图 12.4 – 创建一个列出当前命名空间中所有 Pods 的 Pod

图 12.4 – 创建一个列出当前命名空间中所有 Pods 的 Pod

在前面的代码片段中,我们创建了一个 Pod,其容器将使用 myserviceaccount 服务账户与 Kubernetes API 进行交互。请注意,我们刚刚从 Pod 中检索了日志,并且从执行的 kubectl get pods 命令行中得到了输出。执行时所有正在运行的 Pods 都被列出。让我们尝试使用一个不使用此服务账户的新 Pod:

frjaraur@sirius:~$ kubectl run kubectl2 \
--image=bitnami/kubectl:latest -- get pods
pod/kubectl2 created

现在,我们可以从这个新 Pod 中检索日志:

$ kubectl logs kubectl2
Error from server (Forbidden): pods is forbidden: User "system:serviceaccount:default:default" cannot list resource "pods" in API group "" in the namespace "default"

如你所见,这次新 Pod 无法访问 Kubernetes API(默认使用了 default 服务账户,而该账户没有权限列出命名空间中的 Pods)。

当你创建自定义资源并需要细粒度访问时,访问 Kubernetes API 可能变得复杂,但请确保管理 RBAC 访问的原则是相同的。

重要说明

你可以实现自己的 Kubeconfig 文件来与应用程序一起使用。Kubernetes 提供了与服务账户令牌的开箱即用集成,但你可以使用 Secret 资源来包含认证文件并在应用程序中使用它。当你从统一的控制平面集群管理不同的 Kubernetes 集群时,这尤其有用。

在这些示例中,我们没有使用 NetworkPolicy 资源,但你需要确保你的 Pod 可以访问 Kubernetes API 服务器 Pod。这些 Pod 将使用控制平面主机的 IP 地址和端口 6443,尽管这可能在不同的 Kubernetes 平台之间有所不同(请向你的 Kubernetes 管理员确认这些要求)。如果你的平台管理员配置了 GlobalNetworkPolicy 资源,你可能需要为你的部署添加一些 egress 规则。

在这个例子中,我们使用了 kubectl Kubernetes 客户端,但你会发现有适用于不同编程语言的客户端库和模块,这使得集成更加安全。如果你的代码仅管理必需的资源,攻击者将无法利用错误的 RBAC 配置。另一方面,添加新功能可能需要重新编译代码,但这是值得的。这里有一个指向当前文档的链接,你可以在其中找到有关客户端库的更多信息:kubernetes.io/docs/reference/using-api/client-libraries/。在本书撰写时,C、.NET、Go、Perl、Python、Java、JavaScript 和 Ruby 等语言得到官方支持。如果你需要使用 Rust 等其他语言,可以找到一些社区项目正在开发用于访问 Kubernetes 的库。

现在,让我们介绍 Kubernetes 操作符的概念,它将帮助我们在 Kubernetes 中部署和操作应用程序。在本章中我们将使用其中的一些操作符,在此之前了解其基础知识是非常有趣的。

理解 Kubernetes 操作符

Kubernetes 操作符是一个与 Kubernetes 集成运行的软件,用于管理应用程序及其组件。它们使用我们在本章中所学的概念,按需监控并创建资源,以支持你的应用程序。

Kubernetes 操作符旨在自动化人类操作员管理应用程序时必须执行的大多数重复任务。市面上有许多经过充分文档化的 Kubernetes 操作符示例,它们将帮助你执行诸如部署高可用性数据库、使用单个 YAML 清单管理具有多个组件的复杂应用程序等任务。这些都是你可能会期望 Kubernetes 操作符为某个特定应用程序所执行的一些任务:

  • 自动部署应用程序及其所有组件

  • 管理和创建应用程序所需的 Kubernetes CustomResourceDefinitionsCRDs

  • 备份和恢复功能,帮助你通过简单的步骤恢复应用程序

  • 完全托管的应用程序升级,涵盖所有内部应用程序组件,如数据库架构迁移

  • 当您的应用程序需要分布式控制平面,或者必须管理应用程序组件之间的主从关系(或主从结构)时,选择一个领导者

正如您所看到的,运维人员是管理复杂应用程序部署的关键,而将它们集成到 Kubernetes 中使得管理变得更加简便。数据库管理有很多好的例子,主要由最重要的软件数据库供应商创建,其他软件类别也有类似的例子。您可以在operatorhub.io找到所需内容。在接下来的部分,我们将使用Prometheus Operator来部署和管理 Prometheus 监控工具。

如果您没有找到符合您应用程序要求的 Kubernetes 运维操作符,您可以使用任何可用于不同语言的软件开发工具包SDKs)来编写您自己的运维操作符(kubernetes.io/docs/concepts/extend-kubernetes/operator/#writing-operator)。

到目前为止,我们已经学习了如何查询 Kubernetes 中某些资源的状态以及如何在应用程序中管理它们。在接下来的部分,我们将学习如何使用第三方应用程序来监控我们的应用程序。

监控您的应用程序指标

分析应用程序的指标是了解如何为用户或其他应用程序提供服务的关键。在这一部分,我们将学习如何使用Prometheus监控应用程序,这是一个非常适合 Kubernetes 生态系统的监控解决方案。

Prometheus 是一个开源的监控解决方案,自 2016 年以来由云原生计算基金会CNCF)托管。它用于收集、存储和呈现指标,并通过阈值向用户发出警报。它可以集成到任何基础设施中,尽管它与 Kubernetes 配合使用时表现非常好。它已经包含在一些 Kubernetes 平台的部署中,因此它被认为是 Kubernetes 社区的标准。

Prometheus 包括一个易于查询的数据模型,使用其专有的Prometheus 查询语言PromQL),该语言通过键值对标识的不同来源存储随着时间记录的指标。默认情况下,Prometheus 将通过 HTTP 请求拉取不同的端点,虽然推送数据也是可用的,但较少使用。这些端点通常被称为targets,可以自动发现或手动配置,这使得 Prometheus 在需要动态性支持的 Kubernetes 集群中非常适用。

尽管 Prometheus 提供了一个图形工具,但通常将其作为数据源集成到更先进的仪表盘工具中,例如Grafana,或通过 API(使用 PromQL 查询)直接消费其数据。

本书不会深入讲解这个工具,因为它超出了本书的范围,但我们将回顾一些其组件、快速安装以及如何为你的应用程序实现一些监控端点。

探索 Prometheus 架构

Prometheus 基于至少五个不同的组件:

  • Prometheus 服务器:这是核心组件。它从 Prometheus 导出器获取指标数据,并将来自 Pushgateway 的数据添加进去。所有这些数据都存储在其自己的 时间序列数据库TSDB)中,并可以通过 HTTP API 访问,这个 API 也是由 Prometheus 服务器组件管理的。它还会检查不同配置的阈值。

  • Pushgateway:有些设备或组件无法被抓取。Pushgateway 组件允许你直接将数据推送到 Prometheus,而不是等待 Prometheus 拉取。不同的库适用于常见的编程语言,如 Java、Go、Python 和 Ruby 等,也有其他社区支持的库。

  • Alertmanager:Alertmanager 处理来自 Prometheus 服务器生成的所有警报。可以使用不同的通知后端,如电子邮件或 Webhook。

  • Prometheus Web UI:提供的 Web UI 使我们能够查询存储的指标、快速绘制数据图表,并查看不同配置目标的状态。

  • Prometheus 导出器:这些组件是平台可扩展性的关键。许多客户端库在不同的编程语言中得到了官方支持(还有一些是非官方支持的),允许我们为应用程序创建指标。当 Prometheus 抓取你的端点时,客户端库会呈现数据,并将其存储在服务器中以供稍后访问或阈值验证。你可以在 GitHub 的 Prometheus 组织内找到官方支持的 Prometheus 导出器(github.com/orgs/prometheus/repositories?q=exporter&type=all)。

Prometheus 可以通过在 Kubernetes 集群内部运行(在自己的命名空间内,或者甚至在应用程序的命名空间内,不推荐这样做)或在外部不同的基础设施中运行(例如虚拟机),来监控运行在 Kubernetes 中的应用程序。建议在 Kubernetes 集群内部运行 Prometheus,因为这样你可以使用内部通信,而不必将导出器暴露出去,以便从集群外部拉取数据。这将提高安全性,即使你通过 HTTP 协议而非 HTTPS 协议在内部暴露导出器。将 Prometheus 部署在 Kubernetes 中将允许我们作为 Kubernetes 操作员来部署 Prometheus,这将帮助我们实现目标的自动发现,并且轻松管理完整的监控平台。所有组件都会为我们安装,我们只需配置它们的部署方式。

安装 Prometheus

为了安装 Prometheus 监控平台,我们将使用 Helm,它是一个可以让我们轻松自定义和部署一组清单的工具。我们将在 第十三章《管理应用生命周期》中深入学习如何使用 Helm 打包应用。在这种情况下,这些清单包括 kube-prometheus 平台组件(github.com/prometheus-operator/kube-prometheus)。kube-prometheus 社区项目安装了一个适合集群使用的监控平台,包含以下组件:

  • Prometheus Operator,它将创建自己的 CRDs 并管理服务发现集成。

  • PrometheusAlertmanager,具有高可用性,均作为 StatefulSets 部署。包括一组默认的警报,帮助您开始监控 Kubernetes 环境。

  • Prometheus node-exporter,作为 DaemonSet 部署到所有 Kubernetes 集群节点。此导出器将包含与主机相关的指标,如 CPU、内存和磁盘空间。

  • Prometheus Adapter for Kubernetes Metrics APIs,它会自动将所有 Kubernetes Metrics Server 的指标集成到 Prometheus 中。

  • kube-state-metrics,它连接到 Kubernetes API 服务器并获取不同资源(如 Pods、Deployments 等)的状态,并将这些状态以指标的形式提供给 Prometheus。默认情况下,会为您创建并配置重要的指标。

  • Grafana,作为平台的一部分部署,用于增强 Grafana 仪表盘中的 Prometheus 图表。包括一组默认仪表盘,以向您展示平台的工作原理。

重要提示

kube-prometheus 项目中包含的默认警报和仪表盘来自 kubernetes-mixin 项目(github.com/kubernetes-monitoring/kubernetes-mixin),该项目提供了一套良好文档化的规则和简单的 Kubernetes 监控仪表盘。

作为开发人员,您可能不会使用该平台提供的许多仪表盘和指标,但它将帮助您了解如何实现自己的指标和规则,并使用您的数据创建仪表盘。

安装 kube-prometheus-stack 非常简单。我们有一个现成的 Helm Chart(Helm 特定的包)。我们只需添加 Prometheus 社区 Helm Charts 仓库,更新仓库缓存,并在集群中安装 Helm Chart 发布版本。

重要提示

如果你使用的是 Rancher Desktop,Helm 会预装在你的命令行工具中;但在其他平台上,可能需要先安装才能使用它。Helm 支持 Windows、macOS 和 Linux,不同平台有不同的安装方法。我们建议直接使用二进制文件,这样你可以随时更新它并在需要时使用不同的版本。如果你使用的是 Windows,可以使用 Get-Content 下载它,并将其添加到 PATH 中:

图 12.5 – 使用 gc 下载所需包来安装 Helm 二进制文件

图 12.5 – 使用 gc 下载所需包来安装 Helm 二进制文件

安装完 Helm 后,我们只需使用 helm install 并加上 --create-namespace 参数,告诉 Helm 为我们创建一个新的命名空间。在这个示例中,我们使用 Minikube 作为 Kubernetes 环境:

图 12.6 – 使用 Helm 安装 Prometheus 堆栈

图 12.6 – 使用 Helm 安装 Prometheus 堆栈

几秒钟后,Prometheus 堆栈将启动并运行。此时,我们可以检查平台的 Pod 和 Service 资源:

图 12.7 – Prometheus 堆栈的 Pod 和 Service 资源

图 12.7 – Prometheus 堆栈的 Pod 和 Service 资源

这个小型安装仅用于本地使用——这样你可以在桌面计算机上为自己的应用程序开发监控。请注意,我们甚至没有包括 PersistentVolume,因此每次重启环境时数据都会丢失。在安装级别可以进行很多定制以满足你的特定需求,但在配置自己的 Helm 值 YAML 文件之前,你应该先阅读文档 (github.com/prometheus-community/helm-charts/blob/main/charts/kube-prometheus-stack/values.yaml)。

审查 Prometheus 环境

在本节中,我们将了解 Prometheus 的 GUI 和功能。我们可以通过使用 prometheus-stack-grafana Service 来进入 Prometheus:

  1. 为了快速查看,我们将使用 port-forward 访问 Grafana web UI:

    PS C:\Users\frjaraur> kubectl -n monitoring `
    port-forward service/prometheus-operated 9090:9090
    Forwarding from 127.0.0.1:9090 -> 9090
    Forwarding from [::1]:9090 -> 9090
    http://localhost:8080 in your browser to access Prometheus. If you click on Status, you will see the available pages related to the monitored endpoints:
    

图 12.8 – Prometheus GUI 显示状态部分

图 12.8 – Prometheus GUI 显示状态部分

  1. 现在,我们可以进入 Targets 部分,查看当前 Prometheus 平台监控的目标:

图 12.9 – Prometheus GUI 显示 Targets 部分

图 12.9 – Prometheus GUI 显示 Targets 部分

  1. 我们可以使用右侧的过滤器,取消选中 Healthy 目标,以查看当前哪些目标处于不可用状态:

图 12.10 – Prometheus GUI 显示 Targets 部分中的不健康目标

图 12.10 – Prometheus GUI 显示 Targets 部分中的不健康目标

别担心——这是正常的。Minikube 并未公开所有 Kubernetes 指标,因此某些监控端点将不可用。

  1. 让我们查看当前通过点击container_cpu_usage_seconds_total指标获取的一些指标:

图 12.11 – Prometheus GUI 显示带有查询的图形部分指标

图 12.11 – Prometheus GUI 显示带有查询的图形部分指标

请注意,我们只获取来自kube-systemmonitoring(为栈本身创建的)命名空间的指标。

  1. 让我们在默认命名空间上快速创建一个 Web 服务器部署,并验证它是否出现在监控平台中:

    PS C:\Users\frjaraur> kubectl create deployment `
    webserver --image=nginx:alpine --port=80
    deployment.apps/webserver created
    

    几秒钟后,新的 Web 服务器 Pod 将出现在 Prometheus Graph 部分:

图 12.12 – 使用 CPU 过滤的 Kubernetes 容器列表

图 12.12 – 使用 CPU 过滤的 Kubernetes 容器列表

请注意,在这种情况下,我们使用了一个新的 PromQL 查询,container_cpu_usage_seconds_total{namespace!~"monitoring",namespace!~"kube-system"},在其中我们移除了来自monitoringkube-system命名空间的任何指标。这些指标通过 Prometheus Adapter for Kubernetes Metrics APIs 组件自动包含。

重要提示

关于 Kubernetes 指标或 Pod 指标的知识超出了本章的范围。我们使用 Prometheus 来展示如何传递您的应用程序指标。本节中提到的每个导出器或集成都有其文档,您可以在其中找到可用指标的信息。PromQL 和 Prometheus 本身的使用也超出了本书的范围。您可以在prometheus.io/docs上找到非常有用的文档。为了监控我们的应用程序并获取其活动硬件资源消耗,我们不需要部署 Alertmanager 组件,这将减少您桌面环境的要求。

Prometheus 使用标签来过滤资源。选择良好的指标和标签约定将帮助您设计应用程序监控。仔细查看prometheus.io/docs/practices/naming,文档中解释了良好的命名和标签策略。

现在我们已经了解了 Prometheus 界面的基础知识,接下来可以回顾 Prometheus 服务器如何获取基础设施和应用程序数据。

了解 Prometheus 如何管理指标数据

让我们回顾一下 Prometheus Operator 如何配置目标:

  1. 我们将获取 Prometheus 栈部署创建的新 CRD:

图 12.13 – 可用 Kubernetes API 资源的过滤列表

图 12.13 – 可用 Kubernetes API 资源的过滤列表

Prometheus Operator 将使用 PodMonitorServiceMonitor 资源按时间间隔查询相关的端点。因此,要监控我们的应用程序,我们需要创建一个自定义的度量导出器,用于提供应用程序的度量数据,并创建一个 PodMonitor 或 ServiceMonitor 来将其暴露给 Prometheus。

  1. 让我们快速了解一下已经暴露的一些度量标准。我们将在这里回顾一下 Node Exporter 组件。与该监控组件关联的 Service 资源可以通过以下命令轻松获取:

    PS C:\Users\frjaraur> kubectl get svc `
    -n monitoring prometheus-prometheus-node-exporter `
    -o jsonpath='{.spec}'
    app.kubernetes.io/name=prometheus-node-exporter label.
    
  2. 让我们暴露这个 Pod 并查看展示的数据:

图 12.14 – 通过端口转发功能暴露 Prometheus Node Exporter

图 12.14 – 通过端口转发功能暴露 Prometheus Node Exporter

  1. 现在,我们可以打开一个新的 PowerShell 终端,并使用 Invoke-WebRequest 来检索数据,或者使用任何网页浏览器(你将获得一个中间网页,表明度量数据可以在 /metrics 路径下找到):

图 12.15 – Node Exporter 提供的度量数据,通过本地端口 9100 的端口转发暴露

图 12.15 – Node Exporter 提供的度量数据,通过本地端口 9100 的端口转发暴露

Node Exporter 组件的度量数据是通过一个关联的 Service 资源在内部发布的。这就是我们应该如何创建监控端点。我们可以在这里使用两种不同的架构:

  • 将我们的监控组件集成到应用程序的 Pod 内部,使用新的容器和 sidecar 模式

  • 运行一个独立的 Pod 来部署监控组件,并通过 Kubernetes 覆盖网络在内部检索数据

你必须记住,运行在 Pod 内的容器共享一个公共的 IP 地址,并且总是会一起调度。这可能是主要的区别,也可能是你选择前面列表中第一个选项的原因。运行一个不同的 Pod 还需要在两个 Pod 之间进行一些依赖追踪和节点亲和模式(如果我们希望它们一起运行在同一个主机上)。在这两种情况下,我们都需要一个 PodMonitor 或 ServiceMonitor 资源。

接下来,我们将快速了解 Prometheus 如何在我们为应用程序创建新的导出器时,自动抓取这些导出的度量数据。

使用 Prometheus 抓取度量数据

Prometheus 的安装会创建一个新的资源 Prometheus,它代表一个 Prometheus 实例。我们可以列出集群中的 Prometheus 实例(我们使用了 -A 来包括所有命名空间进行搜索):

PS C:\Users\frjaraur> kubectl get Prometheus -A
NAMESPACE    NAME           VERSION   DESIRED   READY   RECONCILED   AVAILABLE   AGE
monitoring   prometheus-kube-prom-prometheus   v2.45.0   1         1       True         True        17h

如果我们查看这个资源,我们会发现有两个有趣的键决定了要监控哪些资源。让我们获取 Prometheus 资源的 YAML 清单并回顾一些有趣的键:

PS C:\Users\frjaraur> kubectl get Prometheus -n monitoring prometheus-kube-prom-prometheus  -o yaml
apiVersion: monitoring.coreos.com/v1
kind: Prometheus
...
spec:
  podMonitorNamespaceSelector: {}
  podMonitorSelector:
    matchLabels:
      release: prometheus-stack
…
  serviceMonitorNamespaceSelector: {}
  serviceMonitorSelector:
    matchLabels:
      release: prometheus-stack

这个Prometheus资源包括了修改 Prometheus 本身行为的重要键值(例如 scrapeInterval、数据 retentionevaluationInterval)。从前面的代码片段中,我们可以看到,如果所有命名空间中的 ServiceMonitor 资源包含 release=prometheus-stack 标签,它们都会被监控。PodMonitors 也需要这样做,因此我们只需为我们的新应用监控创建一个 ServiceMonitor 资源。以下是一个示例:

图 12.16 – ServiceMonitor 示例清单

图 12.16 – ServiceMonitor 示例清单

实验室部分,我们将为我们的应用程序添加一些开源监控端点(来自grafana.com/oss/prometheus/exporters/Postgres exporter)。

现在,让我们回顾一些配置良好日志记录策略的快速概念,适用于我们的应用程序。

记录应用程序的重要信息

在本节中,我们将概述不同的日志记录策略,并讨论如何实现一个开源的、支持 Kubernetes 的解决方案,例如Grafana Loki

第四章中,运行 Docker 容器,我们讨论了可用于应用程序的策略。作为经验法则,运行在容器中的进程应该始终记录标准输出和错误输出。这使得所有进程更容易(或至少为所有进程同时准备了一个好的解决方案)。然而,您的应用程序可能需要针对不同用例采用不同的日志记录策略。例如,您不应将任何敏感信息记录到标准输出中。虽然本地日志记录在开发应用程序时可能很有帮助,但在开发或生产环境中,这可能会非常棘手(甚至仅限于 Kubernetes 管理员可用)。因此,我们应使用卷或外部日志采集平台。使用卷可能需要额外的访问权限,以便您可以从存储后端恢复日志,因此外部平台将是一个更好的方法来满足您的日志记录需求。事实上,如果您的应用程序运行在 Kubernetes 中,使用外部平台可以让您的生活更轻松。有很多 Kubernetes 就绪的日志记录平台,允许您将所有容器日志推送到后端,您可以在其中管理并为不同用户添加适当的视图。这将解决日志记录敏感数据的问题,因为它可能在调试过程中需要使用,但只能对某些信任的用户可见。您可能需要询问 Kubernetes 管理员,因为您的 Kubernetes 平台可能已经有一个正在运行的日志记录解决方案。

在本章中,我们将讨论如何在 Kubernetes 环境中使用 Grafana Loki 来读取并转发应用程序的容器日志,并将其发送到统一的后端,在那里我们可以使用 Grafana 等额外工具来查看应用程序的数据。

Grafana Loki 可以使用不同的模式进行部署,具体取决于你的平台规模和预期的日志数量。为了开发和准备你的应用程序日志,我们将使用最小化安装,就像我们之前使用 Prometheus 一样。我们将使用单体模式(grafana.com/docs/loki/latest/fundamentals/architecture/deployment-modes/),在这种模式下,Loki 的所有微服务将一起运行在单个容器镜像中。Loki 能够管理大量日志,并且使用对象存储后端。对于我们作为开发者的需求来说,这不需要,我们将仅使用由我们自己的 Kubernetes 平台提供的本地存储(文件系统模式)。

虽然 Grafana Loki 提供了服务器端的功能,Grafana Promtail 将作为代理工作,读取、准备并将日志发送到 Loki 后端。

我们不关心 Grafana Loki 或 Prometheus 如何工作或如何定制化。本章的目的是学习如何使用它们来监控和记录我们的应用程序进程,所以我们将安装 Loki 并配置 Promtail 从 Kubernetes 部署的应用程序中获取日志。

安装和配置 Loki 与 Promtail

让我们继续在 Kubernetes 集群中安装 Grafana Loki,这样我们就能管理所有平台日志。之后,我们将准备好安装 Promtail 来检索并推送日志到 Loki 服务器:

  1. 我们将再次使用helm在不同的命名空间中安装 Grafana Loki。最简单的安装方法是使用单一二进制文件图表方法和文件系统存储(grafana.com/docs/loki/latest/installation/helm/install-monolithic):

图 12.17 – 使用 Helm 安装 Grafana Loki

图 12.17 – 使用 Helm 安装 Grafana Loki

  1. 我们使用了以下设置来应用单体模式并移除 API 身份验证:

    --set loki.commonConfig.replication_factor=1 --set loki.commonConfig.storage.type=filesystem --set singleBinary.replicas=1 --set loki.auth_enabled=false --set monitoring.lokiCanary.enabled=false --set test.enabled=false --set monitoring.selfMonitoring.enabled=false
    

    所有这些标志将帮助你减少测试环境所需的硬件资源。

  2. 它现在已启动并运行。让我们在安装 Promtail 组件之前快速查看服务资源:

图 12.18 – Grafana Loki 服务资源

图 12.18 – Grafana Loki 服务资源

我们已经查看了 Loki 服务,因为我们将配置 Promtail 将所有检索到的日志信息发送到loki-gateway 服务,该服务在 logging 命名空间中的端口 80 上可用。

  1. Helm 可以通过执行 helm show values <CHART> 显示用于安装 Helm Chart 的默认值。因此,我们可以通过执行 helm show values grafana/promtail 来获取 grafana/promtail Chart 的默认值。输出内容非常庞大,显示了所有的默认值,但我们只需要查看客户端配置。该配置适用于 Promtail,并定义了将从不同来源读取的日志发送到哪里:

    PS C:\Users\frjaraur> helm show values `
    grafana/promtail
    …
    config:
    …
      clients:
        - url: http://loki-gateway/loki/api/v1/push
    defaultVolumeMounts key in the chart values YAML file). All the files included will be read and managed by the Promtail agent and the data extracted will be sent to the URL defined in config.clients[].url. This is the basic configuration we need to review because, by default, Kubernetes logs will be included in the config.snippets section. Prometheus, Grafana Loki, and Promtail are quite configurable applications, and their customization can be very tricky. In this chapter, we are reviewing the basics for monitoring and logging your applications with them. It may be very useful for you to review the documentation of each mentioned tool to extend these configurations.
    
  2. 默认情况下,Promtail 会将所有数据发送到 http://loki-gateway,如果我们在日志命名空间中运行该工具,并且与 Grafana Loki 一起使用,就会看到它存在于 Kubernetes 集群中。接下来,我们将使用日志命名空间并使用默认值安装 Promtail:

图 12.19 – 使用 Helm Chart 安装 Promtail

图 12.19 – 使用 Helm Chart 安装 Promtail

  1. 安装完成后,我们可以在 Grafana 中查看所有 Kubernetes 集群的日志。但首先,我们需要在 Grafana 中配置 Prometheus(监控)和 Loki(日志)数据源。我们将使用端口转发来公开 Grafana 服务:

    PS C:\Users\frjaraur> kubectl port-forward `
    -n monitoring service/prometheus-grafana 8080:80
    Forwarding from 127.0.0.1:8080 -> 3000
    admin username with the prom-operator password), accessible at http://localhost:8080, we can configure the data sources by navigating to Home | Adminsitration | Datasources:
    

图 12.20 – Grafana – 数据源配置

图 12.20 – Grafana – 数据源配置

  1. 请注意,使用 kube-prometheus-stack Chart 部署 Grafana 时,Prometheus 和 Alertmanager 数据源已经为我们配置好了。我们将为 Loki 配置一个新的数据源:

图 12.21 – Grafana Loki 数据源配置

图 12.21 – Grafana Loki 数据源配置

  1. 点击 保存并测试 – 就这样!你可能会收到一个错误信息,提示“数据源已连接,但未收到标签。请确认 Loki 和 Promtail 已正确配置”。这表示我们还没有可用于数据索引的标签;这通常发生在你刚刚安装完 Promtail 组件时。如果等几分钟,标签就会变得可用,一切都会正常工作。无论如何,我们可以通过点击 探索 按钮,在 数据源 | 设置 页面开头来验证 Loki 数据。探索 部分允许我们直接从任何 Loki 类型的数据源中检索数据。在我们的示例中,我们将 Loki 作为数据源的名称,并可以选择 Promtail 生成的标签,使用来自我们容器的信息:

图 12.22 – 探索 Loki 数据源

图 12.22 – 探索 Loki 数据源

  1. 我们只需选择命名空间标签和列表中的相应值,即可检索来自 kube-system 命名空间的所有日志:

图 12.23 – 探索来自 kube-system 命名空间的所有 Pod 资源的日志

图 12.23 – 探索来自 kube-system 命名空间的所有 Pod 资源的日志

你将能够根据当前标签进行筛选,并增加非常有用的操作,如分组、计数某些字符串出现的次数、搜索特定的正则表达式等。提供了许多选项,它们对你作为开发者会非常有用。你可以将不同应用组件的所有日志集中在一个统一的仪表板中,甚至准备你自己的应用仪表板,混合不同的数据源来展示指标和日志。

Prometheus、Loki 和 Grafana 是非常强大的工具,我们在这里只能涵盖基础内容。你可以通过查看 Grafana 文档 (grafana.com/docs/grafana/latest/dashboards/use-dashboards/) 来创建仪表板。Grafana 社区提供了许多仪表板示例 (grafana.com/grafana/dashboards/)。我们将在实验室部分为 simplestlab 应用创建一个功能齐全的仪表板。

下一部分将介绍一些负载测试机制,并回顾如何使用开源工具Grafana k6

对你的应用进行负载测试

负载测试是我们通过测量应用在不同压力情况下的表现来审视应用如何工作的任务。作为开发者,你总是需要考虑你的应用在这些压力情境下如何应对,并尝试回答以下一些问题:

  • 我的应用能否在高用户负载下正常运行?

  • 在这种情况下,我的应用组件将如何受到影响?

  • 扩展某些组件是否能维持整体性能,还是会导致其他组件出现问题?

在应用上线之前进行测试,帮助我们预测应用的表现。自动化是模拟大量请求的关键。

在负载测试或性能测试中,我们试图给应用施加压力并增加它的工作负载。我们可以出于不同的原因对应用进行测试:

  • 理解我们的应用在预期负载下的行为

  • 确定应用能够承受的最大负载

  • 尝试发现可能随着时间推移出现的内存或性能问题(内存泄漏、存储资源的耗尽、缓存等)

  • 测试我们的应用在某些情况下如何自动扩展,以及不同组件将如何受到影响

  • 在开发环境中确认一些配置更改,确保它们在生产环境中进行之前不会出现问题

  • 测试应用在不同位置的表现,特别是那些网络速度不同的地方

所有这些测试点都可以通过一些脚本技术和自动化来交付。根据我们正在测试的应用程序,我们可以使用一些著名的、简单但有效的工具,如 Apache JMeterjmeter.apache.org)或更简单的 Apache Benchhttpd.apache.org/docs/2.4/programs/ab.xhtml)。这些工具可以模拟应用程序请求,但它们永远不会像真实的网页浏览器一样行为,而 Seleniumwww.selenium.dev)则会。该工具包括一个 WebDriver 组件,模拟 万维网联盟W3C)的浏览体验,但它可能在集成到自动化过程中时比较复杂(不同版本和与不同语言的集成可能非常耗时)。Grafana 提供了 k6,它是一个开源工具,几年前由 Load Impact 创建,现在是 Grafana 工具生态系统的一部分。它是一个非常小巧的工具,使用 Go 编写,并可以通过 JavaScript 进行配置,这使得它非常定制化。

重要提示

Grafana k6 的功能可以通过使用扩展来扩展,尽管你需要重新编译 k6 二进制文件以包含它们(k6.io/docs/extensions)。

Grafana k6 测试可以集成到 Grafana SaaS 平台Grafana Cloud)或你自己的 Grafana 环境中,尽管在编写本书时,某些功能仅在云平台中提供。

安装这个工具非常简单;它可以在 Linux、macOS 和 Windows 上运行,并适用于在容器中运行,这使得它非常适合在 Kubernetes 中作为 Job 资源运行。

要在 Windows 上安装此工具,请以管理员权限打开 PowerShell 控制台并执行 winget install k6

图 12.24 – 在 Windows 上安装 Grafana k6

图 12.24 – 在 Windows 上安装 Grafana k6

让我们通过编写一个简单的 JavaScript check 脚本来快速看看它的使用方法:

import { check } from "k6";
import http from "k6/http";
export default function() {
  let res = http.get("https://www.example.com/");
  check(res, {
    "is status 200": (r) => r.status === 200
  });
};

现在我们可以测试这个示例脚本,模拟五个虚拟用户运行 10 秒钟,使用 k6 命令行:

图 12.25 – 执行 k6 测试脚本

图 12.25 – 执行 k6 测试脚本

在前面的示例中,我们正在验证从页面返回的 200 代码。现在我们可以使用 5,000 个虚拟用户在相同的时间内进行测试,这可能会在后台引发性能问题。结果可以与不同的分析工具集成,如 Prometheus、Datadog 等。如果你已经熟悉 Prometheus 和 Grafana,你可能会喜欢 k6。

k6.io/docs/examples 中有一些很好的使用示例,Grafana 文档中列出了不同的用例:

  • 单个和复杂的 API 请求

  • HTTP 和 OAuth 身份验证与授权

  • 令牌的关联、动态数据和 Cookie 管理

  • POST 数据参数化和 HTML 表单以及结果解析

  • 数据上传或抓取网站

  • HTTP2、WebSockets 和 SOAP 的负载测试

我们可以通过添加一些内容解析,并在测试执行过程中增加或减少虚拟用户的数量,来创建一个更复杂的示例:

import http from 'k6/http';
import { sleep, check } from 'k6';
import {parseHTML} from "k6/html";
export default function() {
    const res = http.get("https://k6.io/docs/");
    const doc = parseHTML(res.body);
    sleep(1);
    doc.find("link").toArray().forEach(function (item) {
        console.log(item.attr("href"));
     });
}

在前面的代码片段中,我们正在查找所有的 link 字符串,并检索它们的 href 值:

图 12.26 – 执行一个 k6 测试脚本,解析“链接”字符串

图 12.26 – 执行一个 k6 测试脚本,解析“链接”字符串

Grafana k6 非常可配置。我们可以定义阶段,在这些阶段中可以改变探针在检查过程中的行为,并且有许多复杂的功能完全超出了本书的范围。我们建议您阅读该工具的文档,以更好地理解其功能并根据需求进行定制。

现在我们将进入仪表化部分。在下一节中,我们将学习如何将一些追踪技术集成到我们的应用可观察性策略中。

向应用程序的代码中添加仪表化

当您编写应用程序时,应该容易准备适当的监控端点和足够的监控工具,以便检索应用程序的指标。可观察性帮助我们理解应用程序,而不需要真正了解其内部代码。在这一节中,我们将探索一些提供追踪、指标和日志的工具,这些工具帮助我们了解应用程序,而无需积极地知道应用程序的功能。监控日志记录是观察任务的一部分,但在不同的背景下。我们积极知道从哪里检索监控和日志信息,但有时,我们需要进一步探讨——例如,当我们运行第三方应用程序或没有足够的时间将监控端点添加到应用程序时。即使在开始规划应用程序时,也很重要为监控做好准备。同样,日志记录部分也是如此——您必须为应用程序提供良好的日志记录信息,而不仅仅是通过进程的输出。

在应用程序的各个组件中分配相同类型的追踪、日志记录和监控将帮助您理解应用程序中发生的事情,并跟踪每个请求在应用程序流程中所采取的不同步骤。获取的追踪、指标和日志数据被视为您应用程序的遥测数据。

OpenTelemetry 已成为标准的可观察性框架。它是开源的,并提供不同的工具和 SDK,帮助实现遥测解决方案,轻松地从应用中检索追踪、日志和指标。这些数据可以集成到 Prometheus 和其他可观察性工具中,如 Jaeger。

OpenTelemetry 平台的主要目标是为你的应用程序添加可观察性,而无需任何代码修改。目前支持 Java、Python、Go 和 Ruby 编程语言。与 Kubernetes 中使用 OpenTelemetry 的最简单方式是使用 OpenTelemetry Operator。这个 Kubernetes Operator 将为我们部署所需的 OpenTelemetry 组件,并创建关联的 CRD,允许我们配置环境。这个实现将会部署收集器的组件,这些组件将接收、处理、过滤并将遥测数据导出到指定的后端,并创建 OpenTelemetryCollector 和仪表资源定义。

部署 OpenTelemetry Collector 有四种不同的方式或模式:

  • Deployment 模式 允许我们像控制一个简单应用程序在 Kubernetes 中运行一样控制收集器,它适用于监控简单的应用程序。

  • DaemonSet 模式 将会作为代理在每个集群节点上运行一个收集器副本。

  • StatefulSet 模式,如预期的那样,当你不想丢失任何追踪数据时,这种模式将非常适合。可以执行多个副本,每个副本将拥有一份数据集。

  • Sidecar 模式 将会把一个收集器容器附加到应用程序的工作负载上,如果你经常创建和删除应用程序(非常适合开发环境),这种模式更为合适。如果你使用不同的语言并且希望为每个应用组件选择特定的收集器,它还提供了更精细的配置。我们可以通过特殊的注解来管理为特定 Pod 使用哪个收集器。

让我们通过 OpenTelemetry 社区快速运行一个演示环境(github.com/open-telemetry/opentelemetry-demo/)。这个演示部署了一个网上商店示例应用程序、Grafana、Jaeger 以及获取应用程序度量和追踪所需的 OpenTelemetry 组件。

重要说明

Jaeger 是一个分布式追踪平台,它将应用程序的流程和请求映射并分组,帮助我们理解工作流和性能问题,分析我们的服务及其依赖关系,并在应用程序出现故障时追踪根本原因。你可以在项目的 URL 找到它的文档:www.jaegertracing.io/

完整的演示通过 Helm Chart (open-telemetry/opentelemetry-demo) 安装。我们可以在任何命名空间上部署它,但所有组件将一起运行。所展示的演示提供了一个非常好的概览,说明我们可以在 Docker Desktop、Rancher Desktop 或 Minikube 中将哪些内容包含到桌面环境中(也许在其他 Kubernetes 环境中也能工作),尽管它没有遵循一些现代的 OpenTelemetry 最佳实践来添加追踪和管理收集器。这个演示没有部署 Kubernetes OpenTelemetry Operator,而是将 OpenTelemetry Collector 作为一个简单的部署进行部署。

在下一节中,我们将安装演示并审查已部署的应用和工具。

审查 OpenTelemetry 演示

在本节中,我们将安装并审查 OpenTelemetry 项目准备的即用型演示的一些重要功能。该演示将部署一个简单的 web 商店应用及管理和检索来自不同组件的追踪数据所需的工具(更多有用的信息可以在 opentelemetry.io/docs/demo 找到):

  1. 首先,我们将按照 opentelemetry.io/docs/demo/kubernetes-deployment/ 上描述的简单步骤安装演示。我们将添加 OpenTelemetry 项目的 Helm Chart 仓库,然后使用演示包中包含的默认值执行 helm install

图 12.27 – OpenTelemetry 演示应用部署

图 12.27 – OpenTelemetry 演示应用部署

如你所见,部署了不同的 web UI。我们可以快速查看创建的不同 Pods:

图 12.28 – OpenTelemetry 演示应用运行的 Pods

图 12.28 – OpenTelemetry 演示应用运行的 Pods

that OpenTelemetry Collector has been deployed using a deployment that will monitor all the applications instead of selected ones. Please read the OpenTelemetry documentation (https://opentelemetry.io/docs) and specific guides available for the associated Helm Charts (https://github.com/open-telemetry/opentelemetry-operator and https://github.com/open-telemetry/opentelemetry-helm-charts/tree/main/charts/opentelemetry-operator).
  1. 我们将使用端口转发,以便快速轻松地访问所有可用的演示 web UI。通过演示 Helm Chart 部署的反向代理,所有的 web UI 都可以通过 localhost 在不同路径下同时访问。web 商店应用将可以在 http://localhost:8080 访问:

图 12.29 – 使用端口转发 kubectl 功能访问的 web 商店演示应用

图 12.29 – 使用端口转发 kubectl 功能访问的 web 商店演示应用

演示 web 商店展示了望远镜的目录,并将模拟购物体验。

  1. 我们现在可以在 http://localhost:8080/jaeguer/ui 访问 Jaeger:

图 12.30 – Jaeger UI 使用端口转发 kubectl 功能

图 12.30 – Jaeger UI 使用端口转发 kubectl 功能

  1. 在 Jaeger UI 中,我们可以选择要查看的服务,不同的追踪数据会出现在右侧面板:

图 12.31 – Jaeger UI 显示不同应用服务的追踪信息

图 12.31 – Jaeger UI 显示不同应用服务的追踪信息

  1. 系统架构 标签下也提供了架构概述,以便你可以验证不同应用组件之间的关系:

图 12.32 – Jaeger UI 显示应用程序组件

图 12.32 – Jaeger UI 显示应用程序组件

请注意,这里有一个负载生成工作负载,它正在为我们创建合成请求,以便能够从演示环境中检索一些统计信息和追踪数据。

  1. 演示部署还安装了 Grafana 和 Prometheus。Grafana 用户界面可以通过 http://localhost:8080/grafana 访问,Prometheus 和 Jaeger 数据源已为我们配置好:

图 12.33 – Grafana 用户界面,显示已配置的数据源

图 12.33 – Grafana 用户界面,显示已配置的数据源

因此,我们可以使用 Grafana 绘制来自这两个数据源的数据,并创建一个应用程序仪表板。OpenTelemetry 的团队为我们准备了一些仪表板,显示应用程序的度量指标和跟踪信息:

图 12.34 – Grafana 的仪表板页面和 Spammetrics 演示仪表板,显示每个服务的当前请求量

图 12.34 – Grafana 的仪表板页面和 Spammetrics 演示仪表板,显示每个服务的当前请求量

我们本可以在此场景中加入 Grafana Loki,添加一些日志条目,并最终创建一个自定义仪表板,包含所有相关的度量指标、跟踪和日志条目。Grafana 平台甚至可以将某些数据库作为数据源,从而改善我们应用程序健康状况的整体概览。

实验

本节将向你展示如何使用 Kubernetes 中的 simplestlab 三层应用程序实现本章中介绍的一些技术。我们将部署一个完整的可观测性平台,包括 Grafana、Prometheus 和 Loki,并为我们的应用程序组件准备一些导出器。

这些实验室的代码可以在本书的 GitHub 仓库中找到,地址是 github.com/PacktPublishing/Containers-for-Developers-Handbook.git。通过执行 git clone https://github.com/PacktPublishing/Containers-for-Developers-Handbook.git 下载所有内容,或如果你之前已下载过该仓库,则执行 git pull 确保你获得最新版本。所有运行实验室所需的清单和步骤都包含在 Containers-for-Developers-Handbook/Chapter12 目录中。实验所需的所有清单都包含在代码仓库中。下载相关 GitHub 后,Chapter12 目录中提供了详细的运行实验室的说明。让我们简要了解一下要执行的步骤:

  1. 首先,我们将在桌面计算机上创建一个 Minikube Kubernetes 环境。我们将部署一个包含一个节点的简单集群,并启用 ingress 和 metrics-server 插件:

    Chapter12$ minikube start --driver=hyperv /
    --memory=6gb --cpus=2 --cni=calico /
    simplestlab application, which we used in previous chapters, on Kubernetes. The following steps will be executed inside the Chapter12 folder:
    
    

    Chapter12$ kubectl create ns simplestlab

    Chapter12$ kubectl create -f .\simplestlab\ /

    集群中的 simplestlab 应用程序。

    
    
  2. 接下来,我们将在我们的 Kubernetes 桌面平台上部署一个功能完整的监控和日志记录环境。为此,我们将首先部署 Kubernetes Prometheus 栈。我们准备了一个自定义的kube-prometheus-stack.values.yaml值文件,包含适合部署小型环境(包括 Grafana 和 Prometheus)的内容。我们已在Chapter12/charts目录中提供了所需的 Helm Charts。在这种情况下,我们将使用kube-prometheus-stack子目录。要部署解决方案,我们将使用以下命令:

    Chapter12$ helm install kube-prometheus-stack /
    --namespace monitoring --create-namespace /
    hosts file (/etc/hosts or c:\windows\system32\drivers\etc\hosts) to include the Minikube IP address for grafana.local.lab and simplestlab.local.lab. We will now be able to access Grafana, published in our ingress controller, at https://grafana.local.lab.Then, we will deploy Grafana Loki to retrieve the logs from all the applications and the Kubernetes platform. We will use a custom values file (`loki.values.yaml`) to deploy Loki that’s included inside `Chapter12` directory. The file has already been prepared for you with the minimum requirements for deploying a functional Loki environment. We will use the following command:
    
    

    helm install loki --namespace logging /

    Chapter12/charts/promtail 目录。我们将保持默认值不变,因为与 Loki 的通信仅限于内部:

    container_cpu_usage_seconds_total and container_memory_max_usage_bytes metrics, which were retrieved using  Prometheus, and the simplestlab application logs in Loki thanks to the Promtail component.
    
    
    
  3. 到本章实验结束时,我们将修改我们的simplestlab应用,添加一些 Prometheus 导出器来监控不同的应用组件,这将帮助我们定制这些组件,以达到最佳性能。我们将集成一个 Postgres 数据库导出器和 NGINX 导出器,后者用于simplestlabloadbalancer组件。两者的集成清单已为你准备好,位于Chapter12/exporters目录中。

  4. 必须配置 Prometheus 来轮询这些新的目标——Postgres 数据库和 NGINX 导出器。我们将为每个目标准备一个 ServiceMonitor 资源,告知 Prometheus 从这些新源中获取指标。这些 ServiceMonitor 资源清单包含在Chapter12/exporters目录中。以下是为 NGINX 组件准备的清单:

    apiVersion: monitoring.coreos.com/v1
    kind: ServiceMonitor
    metadata:
      labels:
        component: lb
        app: simplestlab
        release: kube-prometheus-stack
      name: lb
      namespace: simplestlab
    spec:
      endpoints:
      - path: /metrics
        port: exporter
        interval: 30s
      jobLabel: jobLabel
      selector:
        matchLabels:
          component: lb
          app: simplestlab
    
  5. 关于如何在 Grafana 中使用这些新指标绘制数据的简单查询的详细信息,可以在本章的Readme.md文件中找到,该文件位于本书的 GitHub 仓库中。

本章为你准备的实验室将概述如何为你的应用程序实现一个简单的监控和日志记录解决方案,并准备一些指标来回顾你的一些应用组件的性能。

总结

在本章中,我们学习了如何为在 Kubernetes 中的应用工作负载实现一些监控、日志记录和追踪的工具和技术。我们还快速浏览了负载测试任务,概述了你应从探针中预期的内容。我们讨论了 Grafana、Prometheus 和 Loki 等工具,但我们在本章讨论的原则可以应用于任何你使用的监控、日志记录或追踪工具。

监控应用程序消耗的硬件资源量,并在统一环境中查看应用程序组件的日志,可以帮助你了解应用程序的限制和需求。如果你测试应用程序在高负载下的表现,它可以帮助改进应用程序的逻辑,并预测在意外情况下的性能。手动添加跟踪到代码中,或使用本章中看到的一些自动化机制,将有助于通过理解洞察和集成,推动应用程序的进一步开发。

在下一章中,我们将把到目前为止所见的所有概念整合到应用生命周期管理中。

第四部分:改进应用开发工作流

在本部分中,我们将回顾一些知名的应用生命周期管理阶段和最佳实践。我们将讨论如何通过容器化来改进这些阶段,并回顾一些持续集成和持续部署的逻辑模式。

本部分包含以下章节:

  • 第十三章管理应用生命周期

第十三章:管理应用生命周期

本书回顾了一些现代架构和微服务概念,理解容器如何适应这种新的应用开发逻辑,并介绍了如何使用不同的容器来创建应用程序,以提供其不同的功能。这个概念真的改变了游戏规则:我们可以使用不同的部署策略来实现应用程序的组件,并根据需要扩展或缩减过程。我们使用容器注册中心来存储和管理新的工件和容器镜像,这些镜像又用于创建容器。容器运行时允许我们运行这些组件。然后,我们介绍了编排,它让我们轻松管理应用的可用性和更新。容器编排需要新的资源来解决这些新架构中出现的各种问题。在本章中,我们将探讨如何将所有这些部分结合起来,管理应用生命周期。接着,我们将学习如何通过自动化这些操作来提供一个完整的应用供应链,在 Kubernetes 上运行持续集成/持续交付CI/CD)。

以下是本章涵盖的主要主题:

  • 回顾应用生命周期

  • 将我们应用的安全性左移

  • 理解 CI 模式

  • 自动化持续应用部署

  • 使用 Kubernetes 编排 CI/CD

技术需求

本章的实验室内容可以在github.com/PacktPublishing/Containers-for-Developers-Handbook/tree/main/Chapter13找到,那里包含了一些扩展的解释,这些内容在本章中被省略,以便更容易跟随。此章的Code In Action视频可以在packt.link/JdOIY找到。

回顾应用生命周期

当我们谈论应用程序如何创建和演变时,必须考虑所有相关的创作和维护过程。应用生命周期包括以下阶段:

  1. 软件解决方案的规划

  2. 应用组件的开发

  3. 不同的测试阶段,包括组件集成和性能测试

  4. 解决方案的部署

  5. 维护

如我们所见,整个应用生命周期涉及了大量的人、流程和工具。然而,本书将只涵盖那些可以通过软件容器技术解决的问题。我们可以使用以下架构图来将上述流程放置在更广泛的背景中:

图 13.1 – 基本应用生命周期架构图

图 13.1 – 基本应用生命周期架构图

现在让我们思考一下,哪些阶段可以使用软件容器来实现。

规划软件解决方案

这一阶段涵盖了软件解决方案的早期阶段,即一个想法转变为项目的过程。它包括收集和分析用户、客户及其他项目相关方的需求。这些需求始终需要验证,以确保最终开发解决方案的特性。根据项目的规模,可能会需要对目前市场上可用的替代方案以及解决方案的可行性进行探索,可能会因此暂停该过程。项目的成功通常与规划阶段的有效性直接相关,在这个阶段,不同的团队提出架构、基础设施、软件框架和其他可能对最终解决方案至关重要的资源。

重要提示

本书中展示的所有内容都适用于在云环境或本地数据中心基础设施上工作。你可以使用桌面计算机来开发你的应用程序代码,并且可以通过不同的项目阶段使用各种工作流与不同的基础设施平台进行交互,正如我们将在本章中学习的那样。

为项目制定一个良好的时间表总是至关重要的,而使用容器有助于改善交付时间,因为容器不需要专门或过于具体的基础设施。你甚至可以在一个平台上启动项目,然后迁移到一个完全不同的平台。容器减少了任何摩擦,消除了基础设施供应商的锁定。

在这一阶段,你还需要为你的应用程序决定架构。将应用程序划分为小型、代码独立但能够协作的服务,可以让不同的开发团队并行工作,这将始终加快项目的交付速度。使用微服务架构可以让你作为开发者专注于特定的功能,并按照定义的规范交付你的组件,以确保正确的集成。如果需要,还必须为应用程序的任何进程进行扩展或缩减的逻辑准备,并确保组件的高可用性HA)和韧性。这将为你的解决方案增加灵活性,并提高用户的整体可用性。

开发应用程序的组件

这一阶段涉及为你的应用程序编写代码。当你开发微服务应用程序时,可以选择最适合的编程语言,但你必须注意所使用的依赖关系中的问题,并理解使用某些组件而不是其他组件所带来的风险。使用开源库或框架总是需要对维护者的活动和其代码的成熟度有良好的了解。

在微服务模型中,你的应用程序提供其 API,资源和其他组件使用它们。如果计划启用多个实例,必须确保应用程序的逻辑允许这种情况。为避免基础设施摩擦并提供最大可用性,确保应用程序在不同情况下运行,管理其依赖关系,并启用一些熔断器。你需要弄清楚当某些组件出现故障时,处理过程的行为,如何在丢失连接并恢复时重新连接,如果决定在云平台或不同集群中执行应用程序的组件时会发生什么,等等。

测试你的应用程序

一旦开发完成,测试阶段的选择将被触发。由于这是一个迭代过程,你可以交付应用程序的某些组件(甚至完整解决方案),但直到所有测试返回正面结果之前,应用程序都不会真正完成。我们在准备和执行测试时必须始终考虑以下原则:

  • 测试必须满足预期要求

  • 测试应由第三方团队执行,这些团队未参与应用程序的设计或开发,以保持测试的独立性

  • 自动化有助于在不同的迭代中在相同的情况下重现测试

  • 测试必须在小组件或一组一起运行的组件上执行

让我们看看一些测试类型以及容器如何将它们集成:

  • 单元测试:这种测试类型测试应用程序的单个组件。通常在开发阶段生成和执行,因为开发人员需要知道他们的代码是否按预期工作。根据组件代码的复杂性和请求返回的对象,它们可能会被包括在容器探针中。如果返回的状态无效,则该组件将不被认为是健康的,尽管可以在验证返回数据时包括进一步的模式匹配。如果你正在开发一个通过 API 工作的组件,应该考虑有一个始终返回有效值的测试请求,或者使用模拟数据。单元测试有助于在修复问题时验证代码的有效性,它们还使你的代码更加模块化(微服务)。每个组件都应包含自己的单元测试,我们还可以根据定义的标准进行一些代码质量验证。

  • 集成测试:这些测试验证你的软件解决方案中不同组件是如何协同工作的。它们帮助我们识别组件之间的问题,并修复所有组件的交付和交互。因此,这类测试需要在不同组件的开发者之间进行安排,并且需要一致地规划。如果我们的应用程序组件在容器中运行,那么准备 Docker Compose 或某些 Kubernetes 清单以在开发环境中同时运行所有必需的组件将变得非常容易——尽管这些测试也可以在远程 CI/CD 平台上自动化执行,正如我们稍后在本章的 Kubernetes 中的 CI/CD 编排 部分中所看到的那样。如果某些组件对于应用程序的健康至关重要,它们的端点或探针可以集成到监控平台中,以确保一切按预期工作。

  • 回归测试:这些测试验证新的更改不会引入新的问题或破坏整体项目。在这些测试中使用容器可以显著改善整体过程。我们可以使用新的容器镜像构建进行前进,或使用先前的镜像进行回滚。如果你的代码在发布之间发生了重大变化,比如因为升级到了新的 Python 或 Java 版本而导致完全不同的开发平台,可能会比较棘手,但使用容器可以使这一过程变得顺畅简单。回归测试帮助我们解决与代码进展或更改(解决方案的演变)相关的任何问题,这些更改可能会破坏当前应用程序的行为。

  • depends_on 键,但建议在你的代码中解决任何依赖顺序问题,因为常用的容器编排器不包含这种键,通常需要其他机制来管理依赖关系。你可以包含额外的 init 容器或侧车容器,它们将在其他容器启动之前检查所需的组件。

  • 压力测试:这些测试验证你的应用程序组件在压力或高负载下的表现。我们在 第十二章中学到了如何使用第三方工具进行测试。这些工具可以部署在容器中并自动化生成成千上万的请求,测试应用程序的组件。如果我们已经处理了应用程序组件的监控,我们可以很好地概览我们进程的硬件需求,并利用这些信息在容器编排集群中最小化资源使用。

  • 性能测试:一旦你整合了所有组件并测试了每个组件的需求,你可以进一步验证应用程序在不同环境下的表现。例如,你可以测试应用程序在多个前端组件的情况下如何运行,或者研究如何在多个数据库之间分配负载。你可以在容器内准备应用程序和测试,按需扩展或缩减某些组件,并分析性能结果。这可以让你自动分配负载,并为你的软件解决方案增添动态性——但你需要确保代码能够支持某些组件的多个实例同时运行。例如,你可以有多个分布式 NoSQL 数据库实例或多个静态前端实例,但无法同时运行多个数据库实例并写入同一数据文件。这也适用于你的应用程序代码。如果你不阻止文件,无法同时执行多个写入同一文件的进程,因为只有一个进程可以完全访问它。另一个例子是允许来自不同实例的用户请求,而不通过中央数据库管理响应。你必须将请求进行原子化,或者集成机制将请求分发到不同实例。

  • 验收测试:在交付解决方案之前,你应始终定义用户验收测试UATs),因为这些测试可以确保你的代码符合项目初期提出的需求。根据解决方案的复杂性,这一阶段可以包括多个测试(如 alpha 测试、beta 测试)。在这些测试中可能会出现新的问题,因此可能需要进行多次迭代。交付的自动化和使用软件容器带来的简便性,都有助于你在短时间内为用户提供不同的测试环境。

测试阶段对于项目非常重要,因为它有助于提高软件交付的质量和可靠性,提前识别并修复问题,增加项目的可见性,提高利益相关者的信任度和用户满意度。我们还可以减少解决方案的维护成本,因为它在设计和测试时考虑了所有要求,并进行了多次验证,因此在影响生产之前,出现的错误应该已经被解决。另一方面,测试总是费时的,但通过使用容器进行不同的测试,将减少成本(因为测试所需的环境更少)和每次测试所花费的时间(因为我们可以同时部署多个版本并行测试)。

部署解决方案

在这个阶段,我们实际上将我们的软件解决方案部署到生产环境中。这个解决方案通常会经历多个环境,直到此步骤完成。例如,我们可以有一个预生产环境来验证某些版本,以及质量保证QA)环境,在这些环境中可以进行其他更具体的测试。使用容器使得这些测试阶段的部署变得简单——我们只需更改配置;所有容器镜像将保持一致。将容器作为新的部署工件可以简化流程。让我们快速介绍一些容器的打包解决方案:

  • Helm charts:这个打包解决方案仅适用于 Kubernetes。Helm chart 只是一个打包好的清单集,其中包含修改应用程序及其组件部署的变量。现在,兼容版本 3 的 Helm charts 是主流。Helm 的旧版本在一段时间前被弃用,使用了 Tiller 特权组件来部署清单,这可能会影响集群的完整性和安全性。较新的版本简化了应用程序的部署方式,无需在 Kubernetes 集群中创建任何 Helm 特定的资源。Helm charts 非常流行,软件供应商为其应用程序提供了自己支持的 chart 仓库,用户可以直接从互联网上将其应用程序安装到自己的 Kubernetes 集群中。

  • kubectl 命令行包括 Kustomize 功能,使得它开箱即用,无需在环境中包含新的二进制文件。

  • Cloud-Native Application BundleCNAB):CNAB 比 Helm 和 Kustomize 更进一步。它的设计目标是包含我们的应用程序运行所需的基础设施和服务。多个工具协同工作,提供基础设施(由 Porter 组件集成 Helm、HashiCorp Terraform 和云提供商的 API)和应用程序(由 Duffle 和 Docker 管理)。这个解决方案目前并未广泛使用,许多组件已被弃用,但值得一提的是,它可以为你提供将软件解决方案(即基础设施和应用程序一起)完全打包的一些思路。

  • Kubernetes 操作员:Kubernetes 操作员是部署和管理特定应用程序部署的控制器,近年来已变得非常流行。操作员会在 Kubernetes 集群内部署自己的特定控制器,以管理应用实例。Kubernetes 操作员旨在自我管理应用程序管理和升级中的所有复杂部分。作为用户,你只需要为实例定义某些必需的值,操作员会处理所需组件和依赖项的安装,并在其生命周期内管理任何升级。如果你计划使用 Kubernetes 操作员开发应用程序,确保包含所有应用程序的清单、依赖项以及使应用程序能够启动所需的自动化。第三方 Kubernetes 操作员作为黑盒在 Kubernetes 集群中运行,可能不会包含你期望的所有功能,因此在部署第三方 Kubernetes 操作员之前,阅读文档可能是值得的。

使用微服务架构部署应用程序可以让你整合不同组件的发布。根据你的软件解决方案,你可能会使用一个完整的部署,或者为应用程序的每个组件使用多个小型部署。无论哪种方式,解决方案必须提供项目规划阶段用户和利益相关者所要求的所有功能。

维护应用程序

我们可能会认为解决方案的部署是最后一个阶段,但事实并非如此。一旦应用程序进入生产环境,可能需要新增功能、对现有功能进行改进,并且不可避免地会出现新的错误。如果你的应用程序被监控,你可以在实际错误出现之前获得有关不同组件状态的反馈。日志记录有助于识别问题,而追踪则可以帮助你改进代码。

但无论如何,应用程序的生命周期继续进行,新项目可能会开始增加新功能,同时修复当前版本中的问题。单体架构需要多个环境来进行这些过程。与此同时,处理两个版本将加倍环境维护的工作量。微服务架构使我们能够根据不同的组件分配工作,从而减少需要为每个组件建立独立环境的需求。更重要的是,我们可以一次更改一个组件,专注于解决特定问题,或者让不同的团队管理每个应用组件,并安排不同的发布时间。这些团队使用最适合其需求功能的编程语言开发代码,同时考虑发布时机和与应用程序的适当集成。然而,值得注意的是,每个团队还需要跟踪其实现中的漏洞和安全问题。

在本书中,我们学习了一些安全实践,这些实践将在我们在容器内工作时(第二章第三章)以及与容器编排工具一起使用时(第六章第七章,和 第八章)使我们的应用程序更安全。左移安全超越了这些建议,包括从项目一开始就注重安全。我们可以将左移安全视为一种实践,意味着我们不会等到应用程序已经开发、构建并打包完毕后,才去解决软件安全漏洞。在下一部分,我们将学习如何从应用程序生命周期的最初阶段开始处理安全问题,从而显著提高解决方案的整体安全性。

将我们的应用程序安全性向左移动

左移安全是指在应用程序开发的早期阶段尽早开始进行安全检查的实践。这并不意味着我们在其他阶段不应用任何安全措施,而是指安全性将从应用程序生命周期的最初阶段开始得到改善。左移安全使我们能够在应用程序进入生产环境之前识别出任何漏洞和其他问题。将安全性左移的好处包括以下几点:

  • 它通过在开发初期阶段就发现并修复错误,改善了软件解决方案的交付

  • 它将应用程序安全性分布到不同的阶段,在每个阶段采取不同的措施,从代码阶段开始,直到应用程序最终部署到基础设施上

  • 不同的团队可以实施不同的安全策略和机制,进一步促进组织内安全文化的建设

  • 它通过减少因安全性差导致推迟应用程序交付的时间和成本,从而缩短了整体开发周期

现在,让我们了解一些不同的软件开发生命周期方法论,以及它们如何影响安全性。

软件生命周期方法论

让我们在这里介绍一些软件生命周期方法论,帮助我们理解在开发阶段加速推进时,安全性的重要性:

  • 瀑布模型:在此模型中,各个阶段必须线性进行,因此一个新阶段必须在前一个阶段结束后开始。该模型在我们不预期有许多需求变动,且项目任务明确时非常有效。然而,该模型缺乏灵活性,使得实施变更变得困难,问题通常会被隐藏,直到项目结束时才暴露。

  • 敏捷模型:在这个模型中,我们通过迭代各个阶段来改进最终的软件解决方案。灵活性和快速响应是这个模型的关键。迭代允许引入新的变化并解决在前一次审查中发现的问题。这个模型的主要问题是它需要各个阶段之间有大量的协作,因此可能不适用于大型项目,但微服务架构非常适合这种开发模型。

  • 螺旋模型:该模型可以被视为瀑布模型和敏捷模型的混合体。最终的软件解决方案将是不同迭代的结果,可以看作是一个完整的软件开发周期。在每一次迭代中,我们从头开始,收集用户需求、设计解决方案、开发代码、进行测试、实施并维护解决方案,然后再进入下一个迭代。敏捷和螺旋开发模型让我们可以在下一次迭代前审查和解决问题,这不仅加快了开发过程,也使解决方案更加安全。

在这些方法中,尤其是敏捷方法已经彻底改变了软件的开发和交付方式。它们的采用使团队能够更快速地工作,并在用户要求新特性时迅速调整软件解决方案。然而,在这种情况下,安全团队可能成为瓶颈。这些团队在软件解决方案进入生产之前进行审查,旨在识别并解决任何漏洞和安全问题,以防恶意用户在生产环境中发现这些问题。如果我们将应用程序拆解成小块(即微服务),那么安全审查任务所需的工作量将乘以拆解后的各个小块数量,即使它们很小。当我们意识到大多数用于审查单体应用程序安全性的传统工具无法在像 Kubernetes 这样的高度分布式和动态环境中使用时,情况变得更糟。

事实上,软件容器和开源解决方案已经在数据中心和云平台中得到广泛应用,以至于我们在部署第三方软件解决方案时,几乎无法了解其内部内容。即使是软件供应商,也会将开源产品嵌入到自己的复杂软件解决方案中。因此,我们不能继续在基础设施和应用层面上使用陈旧的安全方法。

应用层的安全性

如前所述,将应用程序的安全性左移意味着尽可能早地将安全机制和最佳实践融入我们的软件开发模型中。但这并不意味着我们将安全问题完全交给开发人员。我们将在测试阶段准备自动化的安全验证,并在开发环境和生产集群中实施安全政策。这将确保每个人都知道已应用的安全措施以及如何实施它们。DevSecOps 团队准备基础设施和应用规则,并与所有开发团队共享。基础设施规则包括在执行环境中强制执行的所有政策,通常是 Kubernetes 云或本地平台。这些政策可能包括,例如,拒绝任何特权容器,拒绝没有资源限制的 Pods,拒绝访问主机文件系统。但请注意,这些规则不属于代码的一部分,尽管它们确实影响应用程序的执行。

如果我们从应用程序的角度考虑安全性,我们可以应用多种技术:

  • 软件组成分析SCA):当我们将开源库或其他组件添加到代码中时,我们无意中为应用程序增加了风险。SCA 工具帮助我们识别这些风险,在某些情况下,通过补丁和更新来缓解这些风险。虽然 静态应用程序安全测试SAST)工具(我们接下来会讨论)用于在开发周期中查找漏洞,检查代码中的问题,但 SCA 工具提供了持续的漏洞监控。

  • SAST:这些测试用于在代码实际编译之前发现漏洞,因此它们在开发阶段的早期执行。运行这些测试的工具会在我们的代码中搜索已知的不安全模式,并将其报告给我们。任何硬编码的机密数据和配置错误将作为问题在分析中被报告。

  • 动态应用程序安全测试DAST):这些测试在应用程序运行时执行,即在测试阶段。它们涉及对应用程序组件进行模拟攻击的执行。这些测试可能包括代码注入或格式错误的请求,这些请求可能在某些时刻使你的应用程序崩溃。

这三种测试在将应用程序迁移到生产环境之前,非常有价值地帮助识别漏洞,但在谈论“安全左移”时,SAST 和 SCA 是重点。当自动化到位时,我们可以持续执行这些测试,并使用 集成开发环境IDE)插件,在问题实际存入代码之前帮助发现问题。首先,我们可以为特定的编程语言使用任何合适的代码检查工具。接下来我们将讨论这些工具。

引入代码检查工具

代码检查工具是用来分析我们的代码,寻找问题的工具。根据给定检查工具的质量,它可以从简单的代码改进到更高级的问题进行识别。通常会为不同的编程语言使用特定的代码检查工具。你可以查看你喜欢的 IDE 中可用的扩展。

代码检查工具帮助我们减少开发阶段的代码错误,改善代码风格、构建一致性和性能。

一个简单的代码检查工具将执行以下操作:

  • 检查语法错误

  • 验证代码标准

  • 检查 代码异味(代码中表明某些地方可能出错的常见标志)

  • 验证安全性检查

  • 让你的代码看起来像是由一个人编写的

你应该在代码环境中包含检查工具,但具体选择将取决于你使用的编程语言。优秀的代码检查工具可以根据它们关注的方面进行分类,如下所述:

  • 标准化编码:例如 SonarLint、Prettier、StandardJS、Brakeman 和 StyleCop。一些编程语言如 .NET 甚至包括它们自己的代码检查工具(Format)。

  • 安全性:GoSec、ESLint 或 Bandit(Python 模块)。

更重要的是,一些代码检查工具在使用适当的配置时,可以同时用于这两个方面。你可以访问 owasp.org/www-community/Source_Code_Analysis_Tools 来查看额外的代码分析工具。

让我们通过一个使用 Dockerfile 检查工具 Hadolintgithub.com/hadolint/hadolint)的快速示例来看看。我们将简单地检查一个有效的 Dockerfile,它没有包含我们在第一章中学到的最佳实践,《现代基础设施与 Docker 应用》。让我们看看下面的屏幕截图:

图 13.2 – 本地 Hadolint 安装检查简单的 Dockerfile

图 13.2 – 本地 Hadolint 安装检查简单的 Dockerfile

但这里的好处是,我们可以将这个检查工具或任何其他检查工具,包含在容器镜像中,并为我们可能遇到的任何语言准备一组现成的检查工具。让我们看看如何在容器中使用 docker run –i hadolint/hadolint hadolint - 来实现:

图 13.3 – 基于 Docker 的 Hadolint 执行检查简单的 Dockerfile

图 13.3 – 基于 Docker 的 Hadolint 执行检查简单的 Dockerfile

重要提示

有一些工具,如Conftestwww.conftest.dev/),可以与不同的基础设施即代码IaC)解决方案集成,并在将基础设施脚本部署到平台之前用于验证这些脚本。

代码检查工具可以在我们的开发过程中自动执行,以提高安全性。我们将在讲解 CI/CD 工作流时看到它的实际应用。

在接下来的部分中,我们将介绍简单的方法和实践,了解 CI 如何帮助我们管理应用程序的生命周期。

理解 CI 模式

CI 指的是自动化将多个贡献者(甚至多个项目)的代码更改集成到单个项目中的实践。这些自动化过程可能每天发生一次,也可能每小时发生多次。我们可以将 CI 视为软件供应链的一部分,在这里我们构建我们的应用程序(或其组件)并在进入生产之前执行不同的测试。这个过程的第二部分是将应用程序或其组件部署到生产环境中,尽管在特殊情况下也可以使用一些中间环境来测试解决方案的质量或认证(例如,将我们的解决方案与来自供应商的第三方解决方案集成发布)。

在这一部分,我们将按照最直观的逻辑顺序审查一些用于 CI 的最常见模式。开发人员应始终获取他们的代码的最新版本来开始开发新功能,或者重新创建新组件,或者修复新版本。因此,我们将通过从版本控制系统VCS)中拉取代码来开始我们的开发过程。

代码的版本控制

VCS 是一个工具,可以随时间存储文件和目录的更改,使我们能够以后恢复特定版本。从开发者的角度来看,这个工具至关重要,因为它允许多个开发人员共同工作,并跟踪应用程序代码随时间所做的更改。对代码和创建的构件进行版本控制使我们能够运行特定的集成测试,并部署我们代码的特定发布版本。

这些工具通常以客户端-服务器模式运行。用户使用相关命令进行推送和拉取更改。VCS 存储和管理这些更改。一旦所有更改都同步(提交),您就可以继续构建应用程序的构件。如果使用解释性脚本语言,则可能不需要此步骤,尽管可能会创建一些字节码构件以加速应用程序的执行。我们可以自动化此过程,并在特定情况下触发代码的编译 - 例如,当我们提交(同步代码)时。因此,每次仅仅提交我们的代码时,我们就会获得一个带有所有依赖项的二进制构件。但是,我们可以进一步在我们的代码仓库上创建不同的分支,以允许不同的用户同时与代码交互或解决不同的代码功能。一旦完成所有所需的更改,我们可以将这些分支 consol 统合到一个共同的分支,并再次构建构件。根据最终产品、找到的问题和所需的功能,这个过程可能很复杂,但是可以使用自动化来创建一个更容易遵循和重现的常规工作流。

当一个项目由多个开发者或团队共同开发时,必须进行一定类型的管理,以避免更改之间的冲突。版本控制系统(VCS)提供了机制来解决当多个开发者同时更改相同文件时不同拉取请求之间的不兼容问题。MAJOR.MINOR.PATCH 版本语法,其中 MAJOR 表示可能会破坏与之前版本的兼容性的更改,MINOR 表示添加了某些功能且没有破坏兼容性,而 PATCH 用于解决某些问题,但实际上并没有修改任何先前的功能。另一方面,分支名称可以用来引用在代码中发现的任何问题及其解决方案。

在这个早期阶段,我们可以使用代码检查工具添加一些 验证测试,以确保代码语法的正确性、代码质量以及安全功能(如有效的外部依赖项)的存在,并排除任何可能已经进入代码的敏感信息。

如果我们使用容器,我们的代码应至少包含一个 Dockerfile,以便创建我们的容器镜像文件。该文件也需要进行版本管理,因此它将存储在我们的代码仓库中(即版本控制系统)。验证测试可以自动化并执行,以验证某些模式,例如用户执行容器的主要进程或暴露的端口。

因此,CI 管道是一组旨在自动化软件应用程序代码验证、构建和集成的工作流过程。相应地,我们在这里简要介绍 DevOps 的概念。

引入 DevOps 方法论。

DevOps 是一种通过整合和自动化软件开发的某些阶段、与应用程序运行的系统的操作和维护任务以及应用程序本身的相关操作,来改善软件工程的方法论。我们应该把 DevOps 看作是一种文化,它超越了你组织中的小组或团队;它适用于整个组织,目的是最小化开发、部署和维护阶段之间的时间和摩擦。

以下列表显示了 DevOps 方法的一些关键特性:

  • 自动化软件生命周期中的尽可能多的任务。

  • 不同团队之间的协作,作为这种文化的一部分,使事情变得更加高效。

  • 持续的任务修订和反馈、自动化以及代码质量,所有这些都是改进软件开发流程的关键。

  • 监控和日志记录是应用程序生命周期的一部分,它们对于提高性能和发现代码问题非常重要。

由于 DevOps 涉及大量任务和学科,市面上有许多工具可以帮助你完成不同任务和阶段。例如,对于版本控制系统和代码库,你可以使用非常流行的云服务,如 GitHub(2018 年被微软收购)、Atlassian 的 Bitbucket 或 GitLab 等。如果你在寻找本地解决方案,你可以使用开源工具,如 Gitea、GitLab 或 Azure DevOps Server。为你的组织选择合适的工具可能会很复杂,因为许多工具提供了多种功能。以下示意图展示了与应用开发阶段相关的部分流行 DevOps 工具,并显示它们最佳的适用位置:

图 13.4 – 最受欢迎的 DevOps 工具

图 13.4 – 最受欢迎的 DevOps 工具

重要提示

最近推出了一种新的方法论,重点关注开发、部署和维护过程的安全性,称为DevSecOps。该方法论强调将安全性作为不同团队文化的一部分,延伸至整个过程。这也是我们回顾“左移安全”实践的原因,左移安全是 DevSecOps 的一部分,更接近开发团队。DevSecOps 文化打破了传统的思维方式,以往只有一个单独的团队负责安全,并且仅在软件进入生产环境前的最后阶段参与开发过程,验证代码。

一旦代码同步并验证通过,我们就能构建我们的软件解决方案。接下来我们将讨论这一过程。

构建工件

根据编程语言及其依赖项的不同,准备不同版本的环境可能会很棘手。例如,从一个较旧的 Node.js 版本迁移到较新的版本,可能需要独立的构建环境,即使该语言是解释型的,而非编译型的。

想象一种情况,不同的代码开发人员需要在同一环境中同时编译他们的软件。这将是一场完全的混乱,且来自不同版本的错误会接踵而至。自动化可以让我们打包环境并使用合适的环境来构建软件。但我们可以进一步通过使用软件容器来实现,因为这些环境只需要在运行时存在,特别是在需要时。我们可以利用软件容器来构建我们的软件,使用所需的构建环境。成功构建并验证新工件后,完整构建过程的容器镜像会被存储在容器镜像注册中心。

更重要的是,我们可以准备一个完整的工作流程,其中所有代码都通过我们的规则进行验证(代码语法、代码质量、非特权执行等),然后工作流程触发代码的构建,最后,使用生成的容器镜像触发不同的测试(单元测试、集成测试、压力测试、性能测试等)。如果某个应用程序的执行不来自这个标准化的构建工作流程,我们可以禁止其在生产环境中的执行。作为开发人员,你可以在笔记本电脑上编写代码并测试你的应用程序,但在实际部署到生产环境之前,你必须通过所有公司验证检查,在共享环境或平台上进行测试(有时甚至在质量或认证阶段之前)。

测试你的应用程序组件

如前所述,自动化不同的测试可以让我们在任何测试失败时中断工作流程,避免进入下一步。为了通过容器实现这一点,我们可以使用 Docker Compose(例如)准备一些集成的流程,验证它们如何协同工作。这可以在你自己的桌面环境中完成,或者使用共享服务,借助定义的任务触发组件的执行。这些任务也可以在 Docker Compose 格式中定义,并与代码一起存储。有些工具如 Jenkins 可以帮助我们定义这些自动化作业,并在不同的系统上执行它们。这个工具是一个非常流行的 CI/CD 协调工具,旨在管理不同系统上的构建任务,并可以扩展到整合容器的使用,从而简化整体工作流程。我们可以通过使用唯一的容器运行时,利用软件容器,而不是为不同的语言或编译器拥有不同的节点和单独的发布版本。

监控构建过程和测试

为了理解变化如何改善或对我们的应用程序产生负面影响,我们需要持续测量不同测试的性能和输出。我们必须始终确保监控工作流程过程,因为这将通过不同测试的迭代帮助我们改进整体开发过程。流行的 CI 协调工具总是会测量构建时间,我们可以检索执行链式作业时所花费的时间,因此我们将能够追踪代码中的某个变化(例如,添加新的依赖项)如何影响构建并相应地修改测试。

分享开发过程中的信息

DevOps 文化强调沟通变更、交换反馈以及共享有关出现问题的信息,以确保参与过程的所有团队达成一致。自动化将避免许多误解;所有操作都应该是可重现的,因此如果我们没有更改任何内容,期望结果应该是相同的。所有更改必须可追溯,以便我们能迅速识别与特定更改相关的问题并应用适当的修补程序。正如我们在图 13**.4中所看到的,有许多工具可以帮助我们保持团队的知情。一项好的实践是实现自动通知,每当开发任务执行时(如代码更改、验证测试等),由不同工具自动发送通知。

从代码中排除配置

尽管这可能显而易见,但我们应该将应用程序的任何配置或敏感信息排除在代码之外。最好包括一组默认值以及一些文档,说明如何更改这些值,但请记住,您的应用程序将经历多个阶段,可能还有不同的环境。在本书中,我们探讨了多种机制,用于在容器中包含敏感信息和配置(第二章第四章,以及第五章)。绝不应包含证书,即使它们只是用于从自签名或公司服务器下载某些工件的简单步骤。重要的是要理解,有时需要为配置使用版本控制。如果您更改了代码中变量的使用方式,可能会导致回滚到先前版本时失败。在这种情况下,您可能还需要将配置存储在版本控制系统中。但请记住,这个仓库的受众可能与存储代码的仓库不同。自动化帮助我们跟踪不同版本的代码和相应配置,并确保每个任务顺利进行。

在我们回顾了开发过程的第一部分(即应用程序的编码、编译和验证)之后,我们可以进入交付阶段。

自动化持续应用部署

在本节中,我们将讨论软件开发过程的第二部分——产品交付。许多组织将所有精力投入到持续集成(CI)中,而将是否在生产环境中执行软件的决定交给人工来做。

CD 管道从工件和代码仓库获取更改,包括所需的配置,并以流畅和持续的方式将它们部署到生产环境中。为了实现这一目标,我们需要以某种方式将所有这些工件和配置打包成可复现且可部署的状态,旨在保持我们系统的最大稳定性和可靠性。以下列表展示了使用 CD 的一些最显著的好处:

  • 我们降低了部署新版本的风险,因为自动化确保了在出现问题时能够快速回滚

  • 自动化可以使用蓝绿部署和金丝雀部署,在较旧的进程仍在提供服务的同时启用新的应用程序发布。

  • 由于应用程序生命周期所带来的信任度,市场上线时间TTM)缩短和成本降低是可以可靠预期的

虽然 CI 自动化了构建和测试阶段,但 CD 则继续这个过程并更进一步,自动化整个生命周期中打包、部署和测试的工作。

尽管持续集成(CI)的好处主要面向开发人员,但我们可能认为持续交付(CD)更侧重于运维团队。然而,在 DevOps 文化中,很多阶段是两个团队共享的。使用 CD 的主要好处甚至扩展到最终用户,因为应用程序始终保持更新,并且在变更之间不会发生停机。此外,新功能的添加也更少遇到阻力。用户可以通过定义的渠道提供反馈(请参见图 13.4),而监控、日志记录和追踪软件使我们能够丰富这些反馈,然后循环重新开始,不断改进应用程序的代码。

如果我们思考如何实现 CD 自动化的不同阶段,容器非常适合这一需求,因为我们可以为不同环境打包容器镜像和应用程序的配置,并部署软件解决方案。如果出现错误,容器运行时提供弹性,而容器编排工具则允许我们在几秒钟内回滚到之前的版本,并告知我们部署过程中遇到的问题。如前所述,蓝绿部署和金丝雀部署使我们能够逐步部署新版本,或仅通过少数用户进行测试,以避免发生大规模停机。

重要提示

现代应用程序生命周期模型,如GitOps,通过将存储库定义为真相源SOT)来管理软件发布的部署。我们应用程序内部的任何更改,甚至是 Kubernetes 集群本身,都被视为不同步的情况,需要手动干预或自动触发器来应用适当的更改并将情况与所需配置(SOT)同步。在这种情况下,我们将根据应用程序的所需状态自定义如何在每个环境中执行部署包。资源升级或回滚将被执行,以将当前状态与所需状态同步。

监控新部署的实际性能对于在大多数用户仍在使用旧版本的情况下限制对新版本的访问至关重要。如果我们要继续使用新版本,我们必须有可靠的性能基线,以充分了解变更如何影响我们应用程序的服务。尽管我们可能已经成功通过了所有性能测试,但部署新版本可能会在真实用户访问时显示不同的行为。在测试阶段测试越好,真实用户体验与自动化测试之间的差距就越小,这降低了发布新版本的风险。

日志记录也很重要。我们使用日志搜索设计良好的错误模式。您公司的日志标准化可用于轻松为所有应用程序组件实施常见模式,并为所有进程提供单一的日志控制平面,这将使跨多个日志轻松查找错误,并验证某些请求如何在特定时间段影响不同组件。

在生产环境中进行跟踪推荐,除非您有专门用于此目的的项目实例,或者您正在审查关键错误。

检索用户反馈是完整应用程序生命周期的最后一步。用户反馈,连同应用程序组件的监控和日志记录(最终跟踪)一起,进入应用程序生命周期流程的下一次迭代,以改善其整体性能和行为,然后流程重新开始。

我们在第十二章中审查了一些开源监控、日志记录和跟踪工具,获得应用洞察。要获得用户反馈,任何故障跟踪软件都可以,但它与完整的 DevOps 范式集成得越顺畅,越好。在图 13**.4中,我们展示了一些最常见和流行的 DevOps 工具。所有严肃的代码存储库都包括一个问题跟踪系统,您可以将用户的评论和问题与实际的代码提交对齐,解决这些问题或添加请求的功能。

正如您所想,一些工具可以部署到 Kubernetes 中并进行集成。下一节将展示一个示例 DevOps 环境,在这个环境中,我们将使用图 13.4中展示的一些工具来提供完整的应用程序生命周期管理平台。

使用 Kubernetes 协调 CI/CD

本节将帮助我们理解在 Kubernetes 集群中准备和管理的应用程序的完整生命周期。让我们从回顾 CI 部分开始。

了解工作流中的 CI 组件

我们工作流中的 CI 部分是我们编码、构建和测试解决方案的地方。

在项目开始时,会收集用户需求。随后,在开发阶段,您可以使用自己喜欢的代码编辑器。根据您使用的编程语言,可能需要进行编译,这就要求您安装编译器。您也可以使用软件容器来执行实际的编译步骤。这样,您将能够同时使用不同版本的代码编译器,享受不同环境和工具集,而不需要实际安装任何一个。事实上,在一台计算机上管理多个版本的代码环境可能会很棘手。使用容器构建应用程序代码将帮助您决定在每个阶段(构建应用程序的构件以及用于测试或生产的运行)哪些容器镜像最适合您的需求。

接下来,在 CI 工作流中,您将构建二进制文件并准备应用程序组件的 Dockerfile。可以为单个组件创建多个 Dockerfile,指定是否包括某些调试工具或标志,这些工具或标志在测试阶段可能非常有用。

接下来,您需要构建容器镜像。生产镜像必须是干净的,仅包含运行应用程序进程所需的二进制文件和库。您可以为应用程序在编码环境中构建代码构件和容器镜像进行测试,尽管您可能已经有了一个共享的环境来执行这些任务。

使用容器后,您可以使用 Docker Compose 本地测试每个应用程序组件(单元测试),甚至是完整的应用程序堆栈(集成测试)。在这种情况下,您需要访问其他应用程序组件的容器镜像和一些模拟配置,这些配置将帮助您更轻松地运行示例环境。通常会包含一些模拟的默认值,也许还有一些测试连接字符串、认证和令牌(这些将在执行过程中被真实值覆盖)。拥有示例值在团队合作中至关重要,其他开发者可能需要执行您的构件并调整其参数以满足他们的需求。

根据你所在的开发阶段,你可能会在代码的特定分支上工作。代码分支通常用于修复问题或开发新功能,允许多个开发人员在不同的资源上并行编码。一旦某个问题得到解决并成功测试,代码就可以提交、推送,并最终合并到主代码中。

你可能有自己的代码流程,但很可能它与你这里描述的流程非常相似(步骤的顺序可能不同,但最终主要代码应包含你的更改),而且你可能会应用类似的步骤来添加一些新功能或修复问题。

将新代码推送到代码库时,将触发自动化机制,这些机制使用标签和标签来创建适当的工件(如二进制文件、库和容器镜像),帮助你追踪相关的更改以及包括的问题或功能。这使得你可以使用自己构建的工件,或使用自动化系统根据你的构建规则和代码中包含的 Dockerfiles 创建的工件。建议使用自动化构建环境创建的工件,因为你的 DevOps 团队很可能已经创建了一个完整的供应链,这一步只是一个更长过程的开始,在这个过程中他们将使用这些自动创建的工件。

代码库很可能会在你本地基础设施上的 Kubernetes 之上运行,尽管你也可以选择使用 SaaS 服务。根据不同步骤所需的集成,完全将云解决方案与本地工具集成可能会很困难,因为这样可能会带来一定的风险,比如某些数据中心凭证存储在你的云平台上(例如,从云代码库到你本地 Kubernetes 集群的集成)。你应始终确保所有平台集成的最小所需权限,无论它们是运行在云端还是你自己的数据中心。

一旦你的代码推送到代码仓库,可以配置不同的触发器,首先验证代码的质量、项目中所包含的依赖项的成熟度和安全性,以及代码本身和构建的二进制文件的安全性。为了完成这些任务,可以使用图 13**.4中展示的不同工具。例如,我们可以配置一些带有编程语言代码检查器和规则的容器镜像,并执行注入我们代码进行验证的容器。该过程可以在任何测试未通过时停止,或者在检查结束时通知我们有关代码改进的建议,无论是小改动还是大改进。这些任务可以作为作业配置在我们喜欢的 CI/CD 编排环境中,可能还会运行在 Kubernetes 上,以利用集群容器运行时的可用性。图 13**.4中展示了一些最流行的 CI/CD 编排工具,但许多高级代码仓库已经包括了自己的任务管理功能,这简化了执行完整 CI/CD 工作流所需的工具数量。例如,我们可以使用 GitLab 来存储和版本化代码,存储和管理构建产物(构建的产物和容器镜像),并执行不同的 CI/CD 任务。我们将在实验部分通过完整示例看到这些平台的实际应用。

如前所述,可以触发连续的验证任务(测试),作为最终步骤,我们可以构建一个准备好生产环境的容器镜像。此时,可以执行新的测试,验证新组件发布与其他应用组件的集成,并验证解决方案的性能。根据所需的集成,整个流水线(即定义不同的串联任务以执行)可能会很复杂。通常建议将任务进行分组,并准备不同过程的输出,以提供易于阅读的报告。图 13**.4中验证组提到的大多数工具提供了总结报告,这些报告可以解析,查找任何应停止工作流的错误。与流水线相关的任务可以在容器中执行(Kubernetes 上的隔离 Pod),并且这些容器的日志应该在 CI/CD 编排工具中可用,因为这些容器将是易失性的。

根据应用的复杂性,可能值得在执行测试之前先打包所需的组件。你可能不会在 Kubernetes 环境中执行简单的清单,而是会使用 Helm Charts 或 Kustomize 为你的整个应用或每个组件创建包。

重要提示

一些工具,如Argo CD,可以将 Helm 图表用作应用程序部署的模板。尽管我们并不直接使用 Helm 图表来部署应用程序,但该过程将使用它来管理和操作与您的应用程序相关的 Kubernetes 资源。这就是为什么将应用程序准备为包总是值得的原因:它允许其他人轻松部署您的完整应用程序或其中的一些组件,而无需真正了解内容的细节。

在我们继续之前,让我们看看 Helm 的一些重要功能,以及如何创建一个简单的清单包。

使用 Helm 打包我们的应用程序资源清单

Helm 是一个工具,它使用模板化的 YAML 文件和自动化脚本打包 Kubernetes 资源清单,允许我们通过简单的命令行和配置文件完全配置和部署应用程序。

使用 Helm 图表,我们可以一次性替换所有应用程序资源清单,或者仅替换那些已经更改的部分,并通过简单的路径随时将其回滚到之前的版本。Helm 会跟踪所有对 Helm 实例所做的更改,并能够通过应用先前存储的发布版本来恢复这些更改。

当我们执行 helm create <NAME_OF_THE_CHART> 时,Helm 会创建一个包含一些示例清单和其他文件的目录结构,这些文件用于创建一个新的 Helm 图表包:

图 13.5 – Helm 图表文件结构

图 13.5 – Helm 图表文件结构

在这段代码中,我们使用了 helm create 来创建 Helm 图表树结构。您可能注意到 charts 目录的存在。一个 Helm 图表可以包含其他 Helm 图表作为依赖项。这样,我们可以创建一个 Chart.yaml 文件,描述您的包及其版本的依赖关系。在您的 Chart.yaml 文件中,您将找到两个版本控制属性:

  • version 键,表示包的发布版本号

  • appVersion 键,用于标识您的应用程序发布版本

重要提示

Helm 图表可以上传到仓库进行存储并与其他用户共享。通过这种方式,任何被授权拉取并执行包含清单的 Helm 图表的用户都可以部署您的应用程序。许多供应商和开源项目提供他们的 Helm 图表,作为部署其应用程序的一种方式,一些社区驱动的仓库托管着成千上万的图表,供您在项目中使用。两个最受欢迎的仓库是 ArtifactHub (artifacthub.io) 和 Bitnami 应用程序 堆栈 (bitnami.com/stacks/helm)。

一些关键变量的管理和组合背后的魔法,比如实例名称,包含在_helpers.tpl文件中,组合后的变量将用于templates目录中的所有 YAML 清单文件。我们将包含所有使应用程序或其组件能够正常工作的清单。所有的 PersistentVolumeClaims、Deployments、StatefulSets、DaemonSets、Secrets 和 ConfigMaps 都应该被包括在内。事实上,如果我们的应用程序需要特定的权限,我们还必须包括 ServiceAccounts 以及相应的 Role 和 RoleBinding 清单。默认包含的values.yaml文件用于验证通过helm命令创建的清单,这些清单会使用一组默认值。这是另一个可以在我们管道中包含的验证测试,测试可以在创建 Helm 图表包之前进行。如果这个values.yaml文件实现了所有必需的值(一个模拟版本),管道过程可以继续并创建 Helm 图表包。Helm 图表的文件也应该使用版本控制系统进行管理,因此我们将它们存储在我们的代码仓库中。是否使用不同的仓库取决于您作为开发人员的决定,但如果我们使用不同的仓库来管理应用程序组件和部署它们的 Helm 图表的不同版本,那会更好。

实验室部分,您将通过使用simplestlab应用程序的完整示例进行操作。我们为每个应用程序的组件准备了一个 Helm 图表,以及一个部署完整应用程序的伞形图表。

在继续描述应用程序生命周期的管道链的其余部分之前,让我们总结一下到目前为止描述的步骤:

  1. 编写代码并将其推送到代码仓库。我们的代码应至少包含一个用于构建应用程序组件容器镜像的 Dockerfile。尽管不是强制要求,但建议为存储 Helm 图表文件维护一个单独的代码仓库。这样,您可以对应用程序代码和 Helm 图表代码遵循相同的代码工作流,但将每个仓库隔离开来可以让我们管理代码和 Helm 图表包的不同版本。

  2. 将使用相关的 linter 来验证代码,确保其质量、符合组织的编码规范、其依赖关系以及内在安全性(除非是模拟的,否则不要包含敏感信息)。

  3. 不同的制品将会在你的代码仓库中创建并存储。当代码构建完成时,生成的制品(如二进制文件和库文件)将被存储(在我们的示例中,是在 GitLab 中)。如果制品在组件之间共享,例如二进制文件和客户端库等,那么存储这些制品是非常重要的。容器镜像也会存储在 GitLab 中,因为 GitLab 还提供了镜像注册表功能。你可以为每种制品使用不同的仓库,但 GitLab 是一个很好的通用解决方案,因为它提供了代码、制品和容器镜像的存储。

  4. 当所有的制品(构建和容器镜像)都创建完成后,我们可以自动执行单元测试,或者将生成的发布镜像(包括修复或新功能)拉取到开发计算机上进行测试,甚至可以两者兼顾。

  5. 集成测试可能需要打包应用程序的组件。如果是这种情况,则会触发 Helm 图表代码的验证,随后创建包。有时,我们只是更改应用程序的容器镜像(即,我们更改一些代码,触发新的制品构建并创建新的镜像),而不实际更改应用程序的 Helm 图表。因此,始终保持跟踪 Helm 图表包模板的更改,并将其存放在与应用程序代码不同的仓库中是非常有用的。你可能需要在不更改模板化部署清单的情况下升级应用程序代码。在这种情况下,我们只需要用于部署新容器镜像的自定义值和Chart.yaml文件中的appVersion键。这是一个好的做法,因为你将能够同时跟踪你的包和应用程序的发布。

  6. 一旦容器镜像正确创建并存储在镜像注册表中,且 Helm 图表包创建完成,应用程序就可以进行部署了。可以使用容器镜像触发额外的漏洞测试。一些工具,如 AquaSec 的 Trivy,会使用材料清单BOM),即列出所有包含在容器镜像层中的文件,并利用它们自己的漏洞数据库和基于互联网的漏洞数据库来搜索已知问题。

现在我们继续处理管道的第二部分。如你所见,我们通常会提到完整的 CI/CD 工作流,因为 CI 和 CD 经常是自动连接在一起的。

将 CD 添加到工作流中

通过直接使用 Docker Compose 或 Kubernetes 清单,或者通过 Helm 图表包执行不同的集成和性能测试,Helm 图表包提供了一个更具可定制性的解决方案。

CI/CD 工作流继续进行测试,如下所示:

  1. 不同测试的部署是通过存储在代码仓库中的自定义值文件触发的。需要理解的是,我们绝不应该在代码仓库中以明文存储敏感数据。相反,应该使用像 HashiCorp 的 Vault 或 Bitnami 的 SealedSecrets 这样的解决方案,在加密下存储敏感数据。这两种解决方案都支持在部署阶段进行数据解密。

  2. 应用程序的性能和工作流任务指标可以集成到你喜欢的仪表板环境中。大多数阶段中的测试会提供有用的总结,列出已执行的验证任务,我们可以通过这些总结很好地概览新添加更改的影响。日志将突出显示来自任务或应用程序进程的任何错误。我们应该将这些错误分开显示在不同的仪表板中,因为它们可能有不同的终端用户。

  3. 一旦所有测试通过,我们就可以准备在生产环境中部署新的版本。是否自动触发此过程取决于你们组织如何管理生产环境中的更改。如果你的应用程序采用 GitOps 模型进行管理,可以将你的配置仓库用作单一数据源(SOT),CI/CD 编排器将把更改推送到 Kubernetes 平台。应用程序组件的当前状态可能需要升级到新版本或回滚到旧版本,以便同步应用程序的期望状态。此模型允许通过更改其部署配置来管理所有应用程序。

重要说明

GitOps 模型通过使用自定义值仓库作为单一数据源SSOT)来触发交付流程,从而扩展了仓库的使用,以改进基础设施和应用程序更改的跟踪。我们可以包括自动化,要求在部署之前解决特定的安全配置、应用程序或基础设施的依赖关系,或者任何其他应用程序正常工作的需求。所有对代码及用于部署应用程序的值所做的更改都被跟踪,使得更新和回滚变得比以往任何时候都更加容易。

  1. 自动化部署我们的应用程序需要访问和授权我们的 Kubernetes 环境。我们将在 CI/CD 平台中为部署用户提供所需的凭据。我们可以使用 Argo CD 实现一个简单的 GitOps 工作模型。通过这种方式,在自定义包参数中进行简单的更改将触发使用更新的清单部署新版本。结果,新的应用程序版本将交付,包含给定的修复或新增的功能。

  2. 部署的新版本将在维护阶段保持,直到发布新版本来替代它。监控应用程序并从用户那里获取并分析反馈将结束这一迭代。该过程将重新开始,团队会计划实施新请求的功能和解决尚未解决的最新版本中的问题。以下架构图表示前面提到的工作流:

图 13.6 – 用于交付新应用程序版本的工作流架构

图 13.6 – 用于交付新应用程序版本的工作流架构

我们将在接下来的实验室部分回顾一些前面提到的阶段,使用部署在 Minikube 桌面环境上的 GitLab 平台。

实验室

在这个实验室中,我们将通过安装和配置 GitLab 和 Argo CD,在一个测试环境中重现一个非常简化的供应链,采用自动化和 GitOps 部署模型来构建、测试和部署 simplestlab 应用程序。你可以使用一个完全可用的 Kubernetes 平台(无论是云上还是本地)或简化的 Kubernetes 桌面环境。该过程的详细步骤已在本书的 GitHub 仓库中说明,位于 Chapter13 文件夹,但以下是该过程的总结以及你在其中找到的一些显著配置:

  1. 首先,我们将准备实验室所需的工具(Helm、kubectl 和 Argo CD CLI),并且我们还将使用一些环境变量,以便更轻松地配置每个应用程序的 Ingress 资源和 CA 证书。

  2. 你将找到完整的 simplestlab 应用程序的 Helm 图表,以及一些用于部署该应用程序的值配置。文件中使用的具体值将取决于你的环境,我们已经提供了解释以帮助你理解。你可以使用本地配置来测试和部署 Helm 图表。

  3. 我们将部署并使用 GitLab 来存储所有应用程序代码、Helm 图表、容器镜像和应用程序配置。包括创建组、子组、仓库以及所需用户的步骤。

  4. Chapter13 仓库中包含的代码和 Helm 图表文件夹带有一个 .gitlab-ci.yml 文件,该文件描述并准备 CI 自动化,用于验证我们的 Dockerfile 是否通过 Hadolint(一个 Docker 语法检查工具),最后使用 Kaniko(一个从 Dockerfile 内部的容器或 Kubernetes 集群中构建容器镜像的工具)来构建镜像。这个工具不依赖于 Docker 容器运行时,并且在用户空间内完全执行 Dockerfile 中的每个命令,这对安全性非常有利。这样,我们就可以在任何标准 Kubernetes 集群中构建镜像。

  5. 我们将使用 git 命令、不同的分支和标签来触发示例管道中包含的不同自动化操作,涵盖代码和 Helm 图表。

  6. 自动化过程使用不同的容器镜像标签创建devrelease镜像。开发镜像将被添加到代码库中,而发布镜像将被视为生产环境准备就绪,并存储在单独的容器镜像库中。

  7. Helm charts 采用伞式结构创建;因此,simplestlab chart 一次性部署所有组件。此 chart 包括不同应用组件的依赖关系,这些依赖关系应该在部署前解决。我们将通过本地示例来看这个过程,并接着自动化 Helm chart 的创建。

  8. Argo CD 提供了 CD 部分。虽然 GitLab 可以直接部署到 Kubernetes 集群上,但 Argo CD 遵循 GitOps 模型工作。我们将配置 Argo CD 以检查values仓库中的任何更改,并使用存储在 GitLab 中的资源(容器镜像、Helm charts 和部署应用所需的文件)来部署应用。我们将简要讨论本实验中的步骤,并建议你参考Chapter13/Readme.md文件中的完整描述。

    我们为你准备了三个主要目录:

    • ArgoCD:包含 Argo CD 组件的安装和我们将用来部署simplestlab应用的 Application 资源。

    • GitLab:包含 GitLab 组件的安装。

    • simplestlab:此目录包含用于部署simplestlab应用实例的所有代码、Helm charts 和配置值。

    我们将需要以下工具在环境中:

    • base64字符串(如果你没有Base64工具)

    • kubectl:用于连接我们的 Kubernetes 集群。

    • Base64:用于解码某些字符串

安装这些工具的详细步骤包含在代码库中。我们将通过设置 Minikube 环境来开始实验。我们将使用 Linux 和 Docker 来运行此环境,以便能够设置一个固定的 IP 地址。如果你决定花时间进行实验,并且希望在启动和停止 Minikube 环境时保持设置不变,这会帮助你。请按以下步骤操作:

  1. 使用以下命令行启动minikube

    Chapter13$ minikube start --driver=docker \
    --memory=8gb --cpus=4 \
    --addons=ingress,metrics-server --cni=calico \
    --insecure-registry="172.16.0.0/16" \
    simplestlab application using a directory to avoid conflicts between different Git environments because you downloaded this repository from GitHub:
    
    

    每次启动实验时,Simplestlab_WORKSPACE 文件夹及其子文件夹(如果有的话)中的.git 文件夹都会被使用。我们将使用这些文件夹推送一些代码更改到 Code/simplestapp 中,推送 Helm charts 到 HelmCharts 目录中,并推送包含在 Values 文件夹中的部署值。

    
    
  2. 按照代码库中包含的说明安装 GitLab。我们已准备好一个设置脚本,帮助你自定义用于通过 Helm 部署 GitLab 的配置文件。该 chart 包含在chapter13/GitLab目录下。

  3. 安装完成后,我们将查看使用凭据创建的密钥,并登录到发布在gitlab.172.31.255.254.nip.io上的 GitLab 网页 UI。

重要提示

我们使用了 nip.io 域名来简化您环境中所有的合格域名。您可以在 nip.io/ 阅读更多关于这个简化域名的内容。

我们将在 Minikube 设置中包含 GitLab 环境,以允许 Kubernetes 下载镜像。完整的步骤描述见 GitLab 仓库。

  1. 然后,我们将使用安装脚本和 Helm 安装 Argo CD。该脚本会根据您的环境定制一个值文件,我们将用它通过 Chapter13/ArgoCD 目录中的 Helm chart 部署 Argo CD。

  2. 详细步骤可在代码仓库中找到。安装完成后,您将能够访问 Argo CD,网址为 argocd.172.31.255.254.nip.io。您将使用管理员用户,并使用从部署秘密中获取的密码,按照代码仓库中描述的流程进行操作。

  3. 然后,我们将把 SimplestLab/Code 目录中的代码上传到 GitLab。但首先,我们将在 GitLab 中创建一个具有开发者权限的用户(coder 用户)。该用户将仅用于拉取和推送代码,而没有特权访问权限。有关创建此用户和管理代码、Helm charts、镜像以及部署应用所需值的不同项目的步骤,已在代码仓库中描述。

重要说明

在 GitLab 中,将为不同的项目声明不同的权限。我们简化了环境,将一些项目设置为 Public。请按照 Chapter13 仓库中的详细说明进行操作。

  1. 使用此 coder 用户,我们将把 Chapter13/Simplestlab/Code/simplestapp 目录中的 simplestapp 组件代码推送到我们的 GitLab 实例。

  2. Docker 镜像构建的自动化是通过我们代码仓库中存在的 .gitlab-ci.yml 文件触发的。该文件描述了验证和构建自定义镜像的自动化过程和步骤。我们在文件中包含了三个阶段:

    • test(基本上是验证我们的 Dockerfile 语法)

    • security(在构建镜像之前审核要包含的文件内容)

    • build(使用 Kaniko 而不是 Docker 来提高安全性,避免使用 Kubernetes 主机的 Docker 或 containerd 引擎)

    该过程的详细描述可以在代码仓库中包含的 Readme.md 文件中找到。

重要说明

为了避免将 GitLab 环境的 SSL 证书添加到我们的客户端环境中,我们将配置 Git 跳过 SSL 验证(步骤已包括在代码仓库中)。

  1. 该自动化将使用以下变量来执行 .gitlab-ci.yaml 文件中定义的任务:

    • PROJECTGROUP_USERNAME: coder

    • PROJECTGROUP_PASSWORD: C0der000

    • LABS_LOCAL_GITLAB_CERTIFICATE: 完整的 GitLab TLS 证书链的 Base64 解码值,可以通过以下命令获取:

      Code project repository
      
    • Images 项目仓库

    我们将创建 devmain 两个代码分支,修改一些代码,将其推送到 GitLab,并在分支之间切换,以查看更改是否会触发构建过程。一旦准备好构建发布镜像,我们将用发布名称标记提交,推送到 GitLab,并验证自动化管道如何在 GitLab 中的镜像项目内创建相应的发布镜像。这些任务的步骤已包含在 Chapter13/Readme.md 文件中,请仔细遵循,并在不同的 GitLab 项目(CodeImages)中查看管道结果和生成的文件。在继续下一步之前,请熟悉这些流程,接下来我们将推送并构建 Helm charts,以便部署不同应用组件。

  2. 现在我们将管理 Helm charts 的代码文件及其相关项目的仓库。我们为你设置了三个 Helm charts,每个组件一个(simplestlab-dbsimplestlab-appsimplestlab-lb),以及一个包含其他 charts 作为依赖项的 umbrella chart。因此,必须创建四个项目仓库:

    • simplestlab:此 chart 定义了用于一次性部署所有组件的 umbrella Helm chart 以及其 Ingress 资源。我们没有在任何其他组件上添加 Ingress 资源。

    • simplestlab-app:描述了应用程序后端组件的 Deployment 资源部署。

    • simplestlab-db:描述了数据库组件 StatefulSet 部署。

    • simplestlab-lb:描述了负载均衡器 DaemonSet 部署。

    本项目应设置为 Public,因为我们不会在 Argo CD 中声明任何凭证。你将在生产和开发平台上使用凭证和 Private 仓库,但这在演示环境中肯定需要更多的配置。

重要提示

simplestlab umbrella chart 依赖于 simplestlab-appsimplestlab-dbsimplestlab-lb charts。对这些项目的任何更改都需要在 simplestlab umbrella chart 上更新 Helm chart 依赖项。在使用已准备好的 CI/CD 环境时,你需要再次运行 simplestlab umbrella chart 项目管道,以重新构建这些依赖项。如果你想手动更新它们,可以在 HelmCharts/simplestlab 目录下执行 Helm 依赖项更新。我们在 Chart.yaml 文件中准备了各种场景,以防你想在本地测试(请查看 Chapter13/Simplestlab/Values/simplestlab/values.yaml 文件中的注释)。

一旦 Helm charts 项目仓库创建完成,我们就可以将 Helm charts 的代码推送到它们对应的 GitLab 仓库。Charts 的代码位于Chapter13/Simplestlab/HelmCharts。将每个组件的代码推送到相应的仓库。

  1. 我们在 charts 代码中包含了 .gitlab-ci.yaml 文件,用于 GitLab 自动化。该文件描述了三个阶段:

    • test(使用自己的 linter 验证 Helm chart)

    • dependencies(用于验证是否声明了 chart 依赖项)

    • build(将代码打包成 Helm chart .tgz 文件)

重要提示

我们需要包括两个新变量,DOCKERHUB_USERNAME(你的 Docker Hub 用户名)和DOCKERHUB_PASSWORD(你的 Docker Hub 密码)。这些变量应仅在HelmChart/SimplestLab主 chart 中定义。此仓库是公开的,任何人都可以读取你的密码,但你正在使用自己的演示环境。你可以将此密码设置为私有,但你需要准备一些用户名身份验证(新用户或甚至是此处的开发人员用户),并将其包含在 Argo CD OCI 仓库中。

  1. GitLab 自动化文件将触发两种类型的包构建过程:

    • HelmChart项目仓库。

    • simplestlab chart。

    我们将创建devmain代码分支,并在将代码推送到 GitLab 时验证构建过程。修改并推送代码的步骤可以在Chapter13/Readme.md文件中找到。simplestlab主 chart 将被推送到 Docker Hub,我们准备好使用它,但首先我们需要将values.yaml文件添加到Values项目仓库中。

  2. 我们将创建一个Simplestlab/values/simplestlab仓库,用于管理一个简单的值文件,该文件将用于通过简易实验室的主 Helm chart 部署simplestlab应用。该文件包含不同的部分:

    • simplestlab-lb:定义了在部署simplestlab-lb Helm chart 时要覆盖的值,该 chart 作为依赖项添加到主 chart 中。

    • simplestlab-app:定义了在部署simplestlab-app Helm chart 时要覆盖的值,该 chart 作为依赖项添加到主 chart 中。

    • simplestlab-db:定义了在部署simplestlab-db Helm chart 时要覆盖的值,该 chart 作为依赖项添加到主 chart 中。

    • App组件(__dbhost: db__)。正确的数据已被注释:__dbhost: simplestlab-simplestlab-db__。因此,当首次创建 Argo CD 应用时,应用组件和负载均衡器组件将失败。直到你在values YAML 文件中更改正确的值,此问题才会在负载均衡器组件中修复。

    • 第二个测试将部署一个新配置,通过部署全新的nginx.conf ConfigMap 来修复负载均衡器组件。要实现此目标,请取消注释nginxConfig键,在simplestlab-lb中进行修改。缩进非常关键;取消注释所有行(可以保留###################################这一行)。

    当在 Argo CD 中创建应用资源时,开始与不同报告的同步,每次更改 Helm chart 包或值文件时,配置错误将会反映在 Argo CD 环境中。

  3. Values 项目中创建一个 simplestlab 值仓库(Project),并将 Chapter13/Simplestlab/values/simplestlab 文件推送到这个新仓库中。

  4. 我们现在将把应用集成到 Argo CD 中。我们将使用 Argo CD CLI 来管理我们 Kubernetes 集群与 Argo CD 的集成。为了将 Kubernetes 与 Argo CD 连接,创建一个具有集群权限的 ServiceAccount 资源,以便在整个集群范围内管理应用。关于集成 Minikube Kubernetes 集群的详细说明已包含在 Chapter13 仓库中。按照这些说明进行操作,然后登录到 Argo CD,创建以下仓库:

    • coder 作为用户名,c0der000 作为密码。

    • simplestlab-chart 包。

    • simplestlab-chart 包已上传至 Docker Hub,作为 Argo CD 在自签名证书问题上的一种解决方法 (github.com/argoproj/argo-cd/issues/12371)。

    指令中提供了截图,帮助你完成设置过程。

  5. 一旦在 Argo CD 中创建了仓库,我们可以创建一个 Argo CD 应用资源。由于 Argo CD GUI 不允许我们使用多个仓库,因此我们无法同时使用一个代码仓库来存储值文件,另一个仓库来存储 Helm chart 包的制品。在这种情况下,我们需要通过 YAML 文件来准备应用资源。我们在 Chapter13/ArgoCD/Applications 中为你提供了一个 YAML 文件。minikube-simplestlab.yaml 文件包括了值文件仓库(gitlab.172.31.255.254.nip.io/simplestlab/values/simplestlab.git)和 Helm chart 仓库(docker.io/frjaraur)。如果你已经按照所有步骤操作,你也可以使用自己的 Helm chart 仓库。我的仓库是公开的,你可以随时使用它。Applications 清单包括了应用部署的源和目标环境——在我们的例子中是 Minikube 实验环境。

    我们将使用 kubectl 创建这个新资源:

    Chapter13$ kubectl create \
    Chapter13 repository.
    

重要说明

我们在 Argo CD 应用资源中包含了 simplestlab 命名空间。该命名空间应在应用实际部署之前创建。

  1. 接下来,我们更改数据库主机实验室。你首先会注意到应用的 App 组件无法正常工作。这是因为连接字符串错误(请查看 Chapter13/Simplestlab/Values/simplestlab/values.yaml 文件中的注释)。将 dbhost 键更改为 simplestlab-simplestlab-db 并在 Argo CD 中验证更改。

  2. 验证由 Helm chart 模板自动创建的新名称(这些名称本可以被修复,但这是一个常见的错误,我们可以通过这个例子来看到如何解决它):

    Chapter13$ kubectl get svc -n simplestlab
    NAME                          TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)    AGE
    simplestlab-simplestlab-app   ClusterIP   10.96.6.93      <none>        3000/TCP   2d14h
    simplestlab-simplestlab-db    ClusterIP   10.98.159.97    <none>        5432/TCP   2d14h
    dbhost value in the values.yaml file:
    
    

    envVariables:

    dbhost: simplestlab-simplestlab-db

    
    
  3. 提交新的更改并通过 Git 将文件推送到 GitLab 仓库。

    这些更改会在几秒钟内在 Argo CD 上显示。我们没有配置自动同步,因此我们将看到值的配置错误(不同步)。集群中的当前值与配置文件中期望的值不同。我们只需继续同步应用程序(屏幕截图已包含在仓库中)。这将创建一个新的 Secret 资源。我们将删除 App 组件的 Pods,新的更改将应用于此组件。

  4. 一旦第一个问题解决,你会发现一个新的错误,因为 Loadbalancer 组件无法访问 App 组件。因此,接下来我们需要修复 Loadbalancer 组件。在这种情况下,我们将修改 Nginx ___Lb___ 所需的 __nginx.conf__ 文件。它作为 ConfigMap 资源包含,并由 values 文件中的 ___nginxConfig___ 键管理。我们需要更改应用程序后端服务的名称(___App___ 组件)。默认情况下,它使用 ___app___,如你在 ___simplest-lb___ Helm 图表中的默认值文件(SimplestLab/HelmCharts/simplestlab/values.yaml)中所见。

    我们首先验证 App 组件服务的名称:

    Chapter13$ kubectl get svc -n simplestlab
    NAME                          TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)    AGE
    simplestlab-simplestlab-app   ClusterIP   10.96.6.93      <none>        3000/TCP   2d14h
    simplestlab-simplestlab-db    ClusterIP   10.98.159.97    <none>        5432/TCP   2d14h
    Chapter13/Simplestlab/Values/simplestlab/values.yaml file. This time, you will need to uncomment the nginxConfig key. Please be very careful with the indentation as it may break the integration with Argo CD. If the application isn’t synced, verify the values fail because it may contain some unexpected characters.We uncomment the `___nginxConfig___`  key value prepared for you. After uncommenting the value, you should have something like this:
    
    

    第二次测试更新 -- 取消注释此部分

    nginxConfig: |

    user  nginx;

    worker_processes  auto;

    error_log  /tmp/nginx/error.log warn;

    pid        /tmp/nginx/nginx.pid;

    events {

    worker_connections  1024;

    }

    http {

    server {

    listen 8080;

    location /healthz {

    add_header Content-Type text/plain;

    return 200 'OK';

    }

    location / {

    proxy_pass http://simplestlab-simplestlab-app:3000;

    }

    }

    }

    
    
  5. 我们提交并推送新的更改。Argo CD 会在几秒钟内显示这些更改,我们将同步资源并删除与 DaemonSet 关联的 Lb Pod,以修复 NGINX 配置问题。同步和删除 Pod 后,新的 Pod 会正常工作,Argo CD 会显示应用程序为健康且已同步。

我们现在已经到达了这个漫长而复杂实验的结尾,但我们将其分成了不同的阶段,以便更容易跟随。你可以更改你的配置、代码或 Helm 图表,并触发管道或 GitOps 集成来管理你的应用程序状态和行为。我们无法在一个实验中解释所有我们所做的配置,这些配置使得整个工作流程能够顺利运作;我们给了你一些有用的提示,你可以深入研究,探索已经准备好的配置和脚本步骤。

跟随实验时,最好包含在第十一章中创建的 NetworkPolicy 资源,以及在第十二章中准备的 NGINX 和 Postgres Prometheus 导出器。完成本实验后,你将理解不同的自动化是如何工作的,并且能够使用任何其他流行的 DevOps 工具创建自己的自动化,因为基本概念是相同的,无论你是使用云解决方案还是在自己的数据中心部署 DevOps 工具。

总结

在本章中,我们描述了使用软件容器的应用程序生命周期。我们使用了本书至今学习的大部分内容来准备一个 CI/CD 工作流,同时快速回顾了基于容器创建应用程序的不同阶段。我们还介绍了一些由 DevOps 团队用来实现和自动化应用程序完整供应链的最受欢迎的应用程序,并在实验室部分学习了如何使用它们。这个最终实验展示了应用程序生命周期中涉及的不同阶段。我们编写了应用程序代码,准备了容器镜像作为应用程序的工件,并准备了 Helm 图表,用于在 Kubernetes 中部署应用程序。最后,我们通过 Argo CD 触发了应用程序在 Kubernetes 集群中的执行,以在配置完成后交付应用程序。所有的变更都会被追踪,自动化和编排功能帮助我们快速且可靠地交付变更。现在,你已经准备好运用本书的内容创建你自己的供应链,或使用其他常见的 DevOps 工具创建的供应链。祝你好运,在使用软件容器准备和交付应用程序的过程中取得成功!

posted @ 2025-07-08 12:24  绝不原创的飞龙  阅读(188)  评论(0)    收藏  举报