现代-DevOps-实践指南第二版-全-
现代 DevOps 实践指南第二版(全)
原文:
annas-archive.org/md5/3c34d287e2879a0f121f1884a118ac03译者:飞龙
前言
本书的新修订版超越了 DevOps 工具及其部署的基础知识,涵盖了实际示例,帮助你快速掌握容器、基础设施自动化、无服务器容器服务、持续集成和交付、自动化部署、部署流水线安全性以及在生产中运行服务的技能,所有这些都围绕容器和 GitOps 展开。
本书适用对象
如果你是软件工程师、系统管理员或运维工程师,想要进入公共云平台中的 DevOps 世界,那么本书适合你。当前的 DevOps 工程师也会发现本书非常有用,因为它涵盖了实施 DevOps 时的最佳实践、技巧和窍门,并以云原生的思维方式为重点。虽然不要求有容器化经验,但对软件开发生命周期和交付的基本理解将帮助你更好地从本书中获益。
本书涵盖内容
第一章,现代 DevOps 方法,深入探讨了现代 DevOps 的领域,强调其与传统 DevOps 的区别。我们将探讨推动现代 DevOps 的核心技术,特别强调容器在其中的核心作用。鉴于容器是相对较新的技术发展,我们将深入了解开发、部署和安全管理基于容器的应用程序的最佳实践和技术。
第二章,使用 Git 和 GitOps 进行源代码管理,介绍了 Git 这一领先的源代码管理工具及其在通过 GitOps 管理软件开发和交付中的应用。
第三章,使用 Docker 进行容器化,开启了我们对 Docker 的探索,包括安装、Docker 存储配置、启动初始容器,并通过 Journald 和 Splunk 进行 Docker 监控。
第四章,创建和管理容器镜像,剖析了 Docker 镜像,这是 Docker 使用中的一个关键组件。我们将了解 Docker 镜像、分层模型、Dockerfile 指令、镜像扁平化、镜像构建以及构建镜像的最佳实践。此外,我们还将探讨无发行版镜像及其在 DevSecOps 中的相关性。
第五章,使用 Kubernetes 进行容器编排,介绍了 Kubernetes。我们将使用 minikube 和 kinD 安装 Kubernetes,深入研究 Kubernetes 的架构基础,并探索 Kubernetes 的基本构建模块,如 Pods、容器、ConfigMaps、密钥、探针以及多容器 Pods。
第六章,管理高级 Kubernetes 资源,深入探讨了复杂的 Kubernetes 概念,包括网络、DNS、服务、部署、水平 Pod 自动缩放器以及有状态集。
第七章,容器即服务(CaaS)和容器的无服务器计算,探讨了 Kubernetes 的混合特性,架起了 IaaS 和 PaaS 的桥梁。此外,我们还将研究 AWS ECS 等无服务器容器服务,以及 Google Cloud Run 和 Azure 容器实例等替代方案。最后,我们将讨论 Knative,一种开源、云原生、无服务器技术。
第八章,使用 Terraform 的基础设施即代码(IaC),介绍了使用 Terraform 的 IaC,阐明了其核心原则。我们将通过动手实践的例子,使用 Terraform 从头开始在 Azure 上创建资源组和虚拟机,同时掌握 Terraform 的基本概念。
第九章,使用 Ansible 的配置管理,使我们熟悉通过 Ansible 进行配置管理及其基本原理。我们将通过在 Azure 虚拟机上配置 MySQL 和 Apache 应用,来探索 Ansible 的关键概念。
第十章,使用 Packer 的不可变基础设施,深入探讨了使用 Packer 实现不可变基础设施的内容。我们将结合第八章,使用 Terraform 的基础设施即代码(IaC),以及第九章,使用 Ansible 的配置管理,在 Azure 上启动基于 IaaS 的 Linux、Apache、MySQL 和 PHP(LAMP)栈。
第十一章,使用 GitHub Actions 和 Jenkins 的持续集成,从容器中心的角度解释了持续集成,评估了各种工具和方法,以持续构建基于容器的应用程序。我们将研究 GitHub Actions 和 Jenkins 等工具,分析何时以及如何使用每个工具,并在部署示例微服务架构的分布式应用——博客应用时加以应用。
第十二章,使用 Argo CD 的持续部署/交付,深入探讨了持续部署/交付,使用了 Argo CD 这一基于 GitOps 的现代持续交付工具。Argo CD 简化了容器应用的部署和管理。我们将利用它的功能来部署我们的示例博客应用。
第十三章,保护和测试部署管道,探讨了多种保护容器部署管道的策略,包括容器镜像分析、漏洞扫描、机密管理、存储、集成测试和二进制授权。我们将整合这些技术,以增强我们现有的 CI/CD 管道的安全性。
第十四章,理解生产服务的关键性能指标(KPIs),介绍了站点可靠性工程,并调查了一系列关键性能指标,这些指标对于有效管理生产中的分布式应用至关重要。
第十五章,使用 Istio 在生产中操作容器,将带你了解广泛采用的服务网格技术 Istio。我们将探索在生产环境中进行日常操作的各种技术,包括流量管理、安全措施以及增强可观察性的技术,应用于我们示例中的博客应用。
如何最大限度地利用本书
本书所需的资源包括:
-
一个 Azure 订阅来进行一些练习:目前,Azure 提供一个为期 30 天、价值 $200 的免费试用;请在
azure.microsoft.com/en-in/free注册。 -
一个 AWS 订阅:目前,AWS 提供一些产品的免费套餐。你可以在
aws.amazon.com/free注册。本书使用了一些付费服务,但我们将尽量在练习中最小化使用这些付费服务的数量。 -
一个 Google Cloud Platform 订阅:目前,Google Cloud Platform 提供一个免费的 $300 试用,试用期为 90 天,你可以在
console.cloud.google.com/注册。 -
对于某些章节,你需要克隆以下 GitHub 仓库以继续进行练习:
github.com/PacktPublishing/Modern-DevOps-Practices-2e本书中涵盖的软件/硬件 操作系统要求 Google Cloud Platform Windows、macOS 或 Linux AWS Windows、macOS 或 Linux Azure Windows、macOS 或 Linux Linux 虚拟机 Ubuntu 18.04 LTS 或更高版本
如果你使用的是本书的数字版,我们建议你自己输入代码,或者从本书的 GitHub 仓库获取代码(链接将在下一节中提供)。这样做有助于避免与复制粘贴代码相关的潜在错误。
下载示例代码文件
你可以从 GitHub 上下载本书的示例代码文件,链接为 github.com/PacktPublishing/Modern-DevOps-Practices-2e。如果代码有更新,将会在 GitHub 仓库中更新。
我们还提供了其他来自丰富图书和视频目录的代码包,链接为 github.com/PacktPublishing/。快来看看吧!
使用的约定
本书中使用了若干文本约定。
文本中的代码:表示文本中的代码词汇、数据库表名、文件夹名称、文件名、文件扩展名、路径名、虚拟网址、用户输入和 Twitter 账号。示例如下:“让我们尝试发起一个拉取请求,将我们的代码从 feature/feature1 分支合并到 master 分支。”
一段代码块如下所示:
import os
import datetime
from flask import Flask
app = Flask(__name__)
@app.route('/')
def current_time():
ct = datetime.datetime.now()
return 'The current time is : {}!\n'.format(ct)
if __name__ == "__main__":
app.run(debug=True,host='0.0.0.0')
任何命令行输入或输出如下所示:
$ cp ~/modern-devops/ch13/install-external-secrets/app.tf \
terraform/app.tf
$ cp ~/modern-devops/ch13/install-external-secrets/\
external-secrets.yaml manifests/argocd/
粗体:表示新术语、重要词汇或屏幕上显示的文字。例如,菜单或对话框中的文字通常会以 粗体 显示。示例如下:“点击 创建拉取请求 按钮来创建拉取请求。”
提示或重要说明
以这种方式出现。
联系我们
我们始终欢迎读者的反馈。
一般反馈:如果你对本书的任何方面有疑问,请通过 customercare@packtpub.com 发送电子邮件,并在邮件主题中注明书名。
勘误表:虽然我们已尽一切努力确保内容的准确性,但错误仍然可能发生。如果你在本书中发现错误,我们将非常感激你能向我们报告。请访问 www.packtpub.com/support/errata 并填写表单。
盗版:如果你在互联网上遇到任何形式的非法复制品,我们将非常感激你能提供位置地址或网站名称。请通过 copyright@packt.com 与我们联系,并附上材料链接。
如果你有兴趣成为作者:如果你在某个领域有专长,并且有兴趣写作或为一本书做贡献,请访问 authors.packtpub.com。
分享你的想法
一旦你阅读完 现代 DevOps 实践,我们很想听听你的想法!请 点击这里直接进入亚马逊评论页面 并分享你的反馈。
你的评论对我们和技术社区都非常重要,它将帮助我们确保提供优质的内容。
下载本书的免费 PDF 版本
感谢你购买本书!
你喜欢随时阅读,但无法随身携带印刷版书籍吗?
你的电子书购买无法兼容你选择的设备吗?
不用担心,现在每本 Packt 书籍都会附带免费的无 DRM PDF 版本。
随时随地,在任何设备上阅读。直接从你最喜爱的技术书籍中搜索、复制并粘贴代码到你的应用程序中。
优惠不仅仅如此,你还可以获得独家折扣、新闻通讯以及每日送到你收件箱的精彩免费内容。
按照以下简单步骤获得福利:
- 扫描二维码或访问以下链接

packt.link/free-ebook/9781805121824
-
提交你的购买凭证
-
就这些!我们将直接通过电子邮件发送您的免费 PDF 和其他福利
第一部分:现代 DevOps 基础
本部分将向您介绍现代 DevOps 和容器的世界,并为容器技术打下坚实的知识基础。在本节中,您将了解容器如何帮助组织在云中构建分布式、可扩展且可靠的系统。
本部分包含以下章节:
-
第一章,现代 DevOps 方法
-
第二章,使用 Git 和 GitOps 进行源代码管理
-
第三章,使用 Docker 进行容器化
-
第四章,创建和管理容器镜像
第一章:现代 DevOps 方式
本章将提供一些 DevOps 实践、流程和工具的背景知识。我们将了解现代 DevOps 以及它与传统 DevOps 的区别。我们还将介绍容器,并详细理解容器如何在云环境中改变整个 IT 格局,以便在本书的基础上继续深入学习。尽管本书并不完全聚焦于容器及其编排,但现代 DevOps 实践强调这一点。
本章将涵盖以下主要主题:
-
什么是 DevOps?
-
云计算简介
-
了解现代云原生应用
-
现代 DevOps 与传统 DevOps
-
容器的需求
-
容器架构
-
容器与现代 DevOps 实践
-
从虚拟机迁移到容器
到本章结束时,你应该理解以下几个关键方面:
-
什么是 DevOps,它在现代 IT 环境中扮演的角色
-
什么是云计算,它如何改变 IT 服务
-
现代云原生应用的样貌以及它如何改变 DevOps
-
为什么我们需要容器,以及容器解决了什么问题
-
容器架构及其工作原理
-
容器如何促成现代 DevOps 实践
-
从基于虚拟机的架构迁移到容器的高级步骤
什么是 DevOps?
如你所知,软件开发和运维传统上由不同的团队承担,各自有不同的角色和责任。开发者专注于编写代码和创建新功能,而运维团队则专注于在生产环境中部署和管理软件。这种分离常常导致沟通鸿沟、发布周期缓慢以及低效的工作流程。
DevOps通过推动协作文化、共享责任以及在整个软件开发生命周期中使用自动化来弥合开发和运维之间的差距,确保持续反馈。
这是一套原则和实践,也是一种哲学,鼓励开发和运维团队参与整个软件开发生命周期,包括软件维护和运维。为了实现这一点,组织管理多个流程和工具,帮助自动化软件交付过程,从而提高速度和灵活性,通过持续集成和持续交付(CI/CD)管道减少代码发布的周期时间,并监控运行在生产环境中的应用程序。
DevOps 团队应该确保,不是将开发、运维和质量保证分成不同的职能小组,而是组建一个能够涵盖整个软件开发生命周期(SDLC)的单一团队——即,该团队负责构建、部署和监控软件。合并后的团队拥有整个应用程序的所有权,而不是某些职能的所有权。这并不意味着团队成员没有专业技能,而是要确保开发人员了解运维工作,运维工程师也要了解开发工作。QA 团队与开发人员和运维工程师密切合作,了解业务需求以及在实际操作中遇到的各种问题。基于这些经验,他们需要确保自己开发的产品能够满足业务需求,并解决实际操作中遇到的问题。
在传统的开发团队中,积压工作的来源是业务及其架构师。然而,对于 DevOps 团队来说,他们的日常积压工作有两个来源——业务及其架构师,以及客户和他们在生产环境中运行应用时遇到的问题。因此,DevOps 实践通常遵循一个无限循环,而不是线性的交付路径,如下图所示:

图 1.1 – DevOps 无限循环
为了确保具有不同技能的人员之间能够顺利协作,DevOps 强调自动化和工具的使用。DevOps 的目标是尽可能地自动化重复性的任务,集中精力处理更重要的事务。这确保了产品的质量和快速交付。DevOps 注重 人员、流程 和 工具,其中最重要的是人员,最不重要的是工具。我们通常使用工具来自动化流程,帮助人员实现正确的目标。
DevOps 工程师通常会遇到一些基本的概念和术语,下面列出了一些。我们将在本书中重点讨论这些内容:
- 持续 集成(CI)
CI 是一种软件开发实践,涉及频繁地将多个开发人员的代码更改合并到共享的代码库中,通常是每天几次。这确保开发人员定期将代码合并到一个中央代码库中,并在此库中运行自动化构建和测试,实时向团队反馈。这大大减少了周期时间,并提高了代码质量。该过程旨在尽早发现代码中的错误,而不是等到测试阶段。它能尽早发现集成问题,并确保软件始终保持可发布状态。
- 持续 交付(CD)
CD 主要是将您经过测试的软件在准备好时部署到生产环境中。因此,CD 管道将把您的更改打包并进行集成和系统测试。一旦您彻底测试了代码,就可以自动(或经批准后)将更改部署到测试和生产环境中。所以,CD 的目标是准备好最新的经过测试的工件,以便进行部署。
- 基础设施即 代码 (IaC)
IaC 是软件开发中的一种实践,涉及使用代码和配置文件而非手动过程来管理和配置基础设施资源,如服务器、网络和存储。IaC 将基础设施视为软件,使团队能够以可编程和版本控制的方式定义和管理基础设施资源。随着虚拟机、容器和云技术的出现,技术基础设施在很大程度上已变得虚拟化。这意味着我们可以通过 API 调用和模板来构建基础设施。借助现代工具,我们还可以以声明式方式在云中构建基础设施。这意味着您现在可以构建 IaC,将构建基础设施所需的代码存储在源代码库中,如 Git,并使用 CI/CD 管道来创建和管理基础设施。
- 配置即 代码 (CaC)
CaC 是软件开发和系统管理中的一种实践,涉及使用代码和版本控制系统管理和配置设置。它将配置设置视为代码制品,使团队能够以程序化和可重现的方式定义、存储和管理配置。历史上,服务器通常是从头开始手动构建的,并且很少发生变化。然而,随着弹性基础设施的出现和对自动化的重视,配置也可以通过代码进行管理。CaC 与 IaC 密切配合,共同构建可扩展的、容错的基础设施,从而使您的应用程序能够无缝运行。
- 监控 和日志记录
监控和日志记录是软件开发和运维中的基本实践,涉及捕获和分析关于软件应用和系统行为及性能的数据。它们为软件的健康、可用性和性能提供洞察,使团队能够识别问题、排查故障并做出有依据的改进决策。监控和日志记录属于可观察性范畴,这是任何 DevOps 团队都必须关注的领域——即通过监控知道您的应用程序出现问题和异常,并通过日志记录进行问题追踪。这些实践和工具就像您的眼睛,是 DevOps 堆栈中的关键领域。此外,它们对构建 DevOps 团队的待办事项列表也有重要贡献。
- 沟通 与协作
沟通与协作是 DevOps 实践中的关键要素。它们促进了开发、运营和其他参与软件交付生命周期的利益相关者之间的高效团队合作、知识共享和简化的工作流程。沟通与协作使得 DevOps 团队能够高效运作。过去,通过电子邮件的沟通方式已经不再适用。现代 DevOps 团队使用票务和敏捷工具来管理待办事项,利用 Wiki 跟踪知识文章和其他文档,并通过聊天和即时消息(IM)工具进行即时沟通。
虽然这些只是 DevOps 实践和工具的一些核心方面,但随着容器和云的出现,已经发生了一些变化——即现代云原生应用栈。现在我们已经介绍了几个流行词汇,接下来让我们理解什么是云计算和云计算服务。
云计算简介
传统上,软件应用程序通常运行在内部计算机(服务器)上,这些服务器被称为数据中心。这意味着组织必须购买和管理物理计算机和网络基础设施,这通常需要大量的资本支出,同时还需要支付相当高的运营费用。此外,服务器会出现故障并需要维护。这意味着那些希望尝试新事物的小型公司通常不会开始,因为所需的资本支出(CapEx)非常庞大。这表明,项目必须经过良好的规划、预算和架构设计,然后再根据需求购买和配置基础设施。这也意味着,随着时间推移,快速扩展基础设施变得不可行。例如,假设你开始时规模较小,并未预料到网站的流量很大,因此你订购和配置了较少的资源,但网站突然变得很受欢迎。在这种情况下,你的服务器将无法处理如此大量的流量,可能会崩溃。快速扩展将涉及购买新的硬件并将其添加到数据中心,这需要时间,而你的业务可能会失去这一机会。
为了解决这个问题,像亚马逊、微软和谷歌等互联网巨头开始建设公共基础设施来运行他们的互联网系统,最终将其推出供公众使用。这促成了一个新的现象,即云计算。
云计算是指通过互联网按需提供计算资源,如服务器、存储、数据库、网络、软件和分析服务。与将这些资源托管在本地物理基础设施上不同,云计算使组织能够访问和利用云服务提供商(CSPs)提供的计算服务。一些领先的公共 CSPs 包括亚马逊云服务(AWS)、微软 Azure和谷歌云平台。
在云计算中,云服务提供商(CSP)拥有、维护并管理底层的基础设施和资源,而用户或组织则利用这些资源来支持他们的应用程序和服务。
简单来说,云计算不过是使用他人的数据中心来运行您的应用程序,且应按需提供。它应该通过网络门户、API 等方式提供控制面板,以便让您进行操作。作为交换,您需要按按需付费的方式支付租赁费用,来使用您所配置(或使用)的资源。
因此,云计算提供了多种好处,并为企业打开了前所未有的新机遇。以下是其中的一些好处:
-
可扩展性:云上的资源是可扩展的。这意味着您可以根据需要向现有服务器添加新的服务器或资源。您还可以通过流量自动扩展应用程序。这意味着,如果您需要一台服务器来运行应用程序,而由于受欢迎程度或高峰时段的原因,您突然需要五台服务器,您的应用程序可以通过云计算 API 和内置的管理资源自动扩展到五台服务器。这为企业提供了强大的能力,因为他们可以从小规模开始,而不必过多担心未来的流量和扩展问题。
-
成本节省:云计算遵循按需付费模式,用户只需为他们实际使用的资源和服务付费。这消除了对硬件和基础设施的前期资本支出(CapEx)的需求。对于企业来说,租用资源通常比投资计算硬件要便宜。因此,您只需为特定时间段内所需的资源付费,无需预先配置资源来应对未来的负载,这为大多数中小型组织节省了大量成本。
-
灵活性:云资源不再仅仅是服务器。您还可以获取许多其他服务,如简单的对象存储解决方案、网络和块存储、托管数据库、容器服务等。这些为您在应用程序的使用上提供了极大的灵活性。
-
可靠性:云计算资源受服务水平协议(SLA)的约束,有时可达到 99.999%的可用性。这意味着大多数云资源永远不会停机;如果停机,您也不会注意到,因为云平台有内置的冗余机制。
-
安全性:由于云计算公司为多个客户运行应用程序,它们通常拥有比您在本地构建的更严格的安全防护网。它们拥有一支安全专家团队,全天候监控云平台,并且默认提供加密、访问控制和威胁检测等服务。因此,在正确架构的情况下,运行在云上的应用程序更加安全。
云计算提供了多种服务,包含以下几种:
-
基础设施即服务(IaaS)类似于在服务器上运行应用程序。它是一种云计算服务模型,通过互联网提供虚拟化的计算资源。使用 IaaS,组织可以访问和管理基本的 IT 基础设施组件,如虚拟机、存储和网络,而无需投资和维护物理硬件。在 IaaS 模式中,CSP 拥有并管理底层的物理基础设施,包括服务器、存储设备、网络设备和数据中心。而用户或组织则可以控制虚拟化基础设施上运行的操作系统(OSs)、应用程序和配置。
-
平台即服务(PaaS)为你提供了一个抽象层,在这个层级上,你可以专注于代码编写,将应用程序管理交给云服务。PaaS 是一种云计算服务模型,为开发人员提供一个平台和环境,以便构建、部署和管理应用程序,而无需担心底层的基础设施组件。PaaS 抽象化了基础设施管理的复杂性,使开发人员可以专注于应用程序开发和部署。在 PaaS 模式中,CSP 提供的平台包括操作系统(OS)、开发框架、运行时环境,以及支持应用程序开发生命周期所需的各种工具和服务。用户或组织可以利用这些平台资源来开发、测试、部署和扩展应用程序。
-
软件即服务(SaaS)为你提供了一个预构建的应用程序,例如一个可以轻松与应用程序集成的现成监控服务。在 SaaS 模式中,云服务提供商(CSP)托管和管理软件应用程序,包括基础设施、服务器、数据库和维护。用户或组织可以通过网页浏览器或瘦客户端应用程序访问该应用。通常他们根据使用量支付订阅费用,软件作为按需服务提供。
云的出现带来了一个新的行业流行词——云原生应用。我们将在下一节中讨论它们。
理解现代云原生应用
当我们说云原生时,指的是那些为在云中原生运行而构建的应用程序。云原生应用旨在充分利用云的能力和优势,尽可能多地使用云服务在云中运行。
这些应用程序天生具有可扩展性、灵活性和弹性(容错能力)。它们在很大程度上依赖于云服务和自动化。
现代云原生应用的一些特征如下:
微服务架构:现代云原生应用通常遵循微服务架构。微服务是将应用程序拆分为多个较小,松散耦合的部分,具有独立的业务功能。独立的微服务可以根据需要或特定功能使用不同的编程语言编写。这些较小的部分可以独立扩展,灵活运行,并且从设计上具有弹性。
容器化:微服务应用通常使用容器来运行。容器为应用程序提供了一种一致,便携和轻量级的环境,确保它们捆绑了所有必要的依赖关系和配置。容器可以在所有环境和云平台上运行相同的内容。
DevOps 和自动化:云原生应用程序大量使用现代 DevOps 的实践和工具,因此在很大程度上依赖自动化。这简化了应用程序的开发,测试和运营。自动化还带来了可扩展性,韧性和一致性。
动态编排:云原生应用程序构建为可扩展且本质上是容错的。这些应用程序通常是短暂的(临时的);因此,服务的副本可以根据需要随时出现和消失。诸如Kubernetes和Docker Swarm之类的动态编排平台用于管理这些服务。这些工具帮助在变化的需求和流量模式下运行您的应用程序。
云原生数据服务的使用:云原生应用通常使用托管的云数据服务,如存储,数据库,缓存和消息系统,以便多个服务之间进行通信。
云原生系统强调 DevOps,并且现代 DevOps 已经出现来管理它们。因此,现在让我们来看看传统 DevOps 和现代 DevOps 之间的区别。
现代 DevOps 与传统 DevOps 的对比
DevOps 的传统方法涉及建立一个包含开发,质量保证和运维成员的 DevOps 团队,并努力创建更快,更好的软件。然而,虽然会专注于自动化软件交付,但自动化工具如Jenkins,Git等仍需手动安装和维护。这导致了另一个问题,因为现在我们不得不管理另一套 IT 基础设施。最终问题归结为基础设施和配置,而焦点是自动化自动化过程。
随着容器的出现和公共云景观的近期繁荣,DevOps 的现代方法进入了视野,其中涉及到一切的自动化。从基础设施的供应到工具和流程的配置,一切都有相应的代码。因此,现在我们有IaC,CaC,不可变基础设施和容器。我称这种方法为现代 DevOps,并且这将是本书的重点。
以下表格描述了现代 DevOps 和传统 DevOps 之间的一些关键相似性和差异:
| 方面 | 现代 DevOps | 传统 DevOps |
|---|---|---|
| 软件交付 | 重视 CI/CD 流水线、自动化测试和部署自动化。 | 重视 CI/CD 流水线、自动化测试和部署自动化。 |
| 基础设施管理 | 常用 IaC 来进行基础设施资源的规划和管理。经常使用云平台和容器化技术。 | 手动进行基础设施的规划和配置,通常依赖传统数据中心和有限的自动化。 |
| 应用程序部署 | 广泛采用容器化和容器编排技术,如 Docker 和 Kubernetes,以确保应用程序的可移植性和可扩展性。 | 使用传统的部署方法,如直接在虚拟机或物理服务器上部署应用程序,没有采用容器化。 |
| 可扩展性和弹性 | 利用云平台和容器编排的自动扩展能力来处理不同的工作负载。专注于高可用性和容错性。 | 通过纵向扩展(向现有服务器添加资源)或手动容量规划来实现可扩展性。通过手动添加冗余服务器来实现高可用性。弹性不存在,容错性不是重点。 |
| 监控与日志记录 | 广泛使用监控工具、日志聚合和实时分析来获取应用程序和基础设施性能的洞察。 | 监控和日志记录做法有限,可用工具和分析较少。 |
| 协作与文化 | 强调开发和运维团队之间的协作、沟通和共享责任(DevOps 文化)。 | 强调开发和运维团队之间的协作、沟通和共享责任(DevOps 文化)。 |
| 安全性 | 安全性通过使用DevSecOps实践集成到开发过程中。安全测试和漏洞扫描是自动化的。 | 安全措施通常是手动应用并由独立的安全团队管理。在软件开发生命周期中自动化安全测试有限。 |
| 部署速度 | 通过自动化流水线快速频繁地部署软件更新,从而缩短上市时间。 | 快速部署应用程序,但缺乏自动化的基础设施部署。 |
表 1.1 – 现代 DevOps 和传统 DevOps 之间的主要相似性和差异
需要注意的是,现代 DevOps 与传统 DevOps 之间的区别并不是严格的二元对立,因为组织可以在一个范围内采用各种实践和技术。现代 DevOps 方法通常专注于利用云技术、自动化、容器化和 DevSecOps 原则,以提高协作性、敏捷性以及软件开发和部署的效率。
正如我们之前讨论的,容器有助于实现现代 DevOps,并构成了这一实践的核心。我们将在下一节中深入了解容器。
容器的需求
最近,容器非常流行,原因也很充分。它们解决了计算机架构中最关键的问题——在任何计算环境中运行可靠的、分布式的软件,并具备接近无限的可扩展性。
它们促成了软件工程领域的一门全新学科——微服务。它们还在技术中引入了一次打包,随时部署的概念。结合云计算和分布式应用程序,容器与容器编排技术共同促成了业界的一个新热词——云原生——彻底改变了 IT 生态系统。
在我们深入探讨更多技术细节之前,让我们用简单明了的方式来理解容器。
容器得名于集装箱。我将通过集装箱类比来解释容器,以便更好地理解。历史上,由于交通运输的改善,大量物品跨越多个地理区域进行运输。由于各种货物通过不同方式运输,货物在每个运输点的装卸问题巨大。而且,随着劳动力成本的上升,航运公司要在规模化运营的同时保持低价格变得不切实际。
此外,这也导致了物品经常遭受损坏,货物容易被错放或与其他货物混淆,因为没有隔离措施。运输货物需要一种标准的方式,以提供必要的货物隔离,并便于装卸。航运业提出了集装箱这一优雅的解决方案来解决这个问题。
现在,集装箱简化了航运业中的许多事情。通过标准化集装箱,我们可以只移动集装箱就将货物从一个地方运输到另一个地方。相同的集装箱可以用于公路、装载到火车上并通过船只运输。这些运输工具的操作员大多数时候不需要担心集装箱内的货物。下图以图形方式描绘了整个工作流程,便于理解:

图 1.2 – 集装箱工作流程
类似地,软件行业在软件可移植性和计算资源管理方面也存在问题。在标准软件开发生命周期中,软件会在多个环境中移动,有时候,多个应用程序共享同一个操作系统。环境之间的配置可能存在差异,因此在开发环境中可以正常工作的软件在测试环境中可能无法正常工作。测试环境中正常工作的东西在生产环境中也可能不正常。
此外,当您在单台计算机内运行多个应用程序时,它们之间没有隔离。一个应用程序可能会耗尽另一个应用程序的计算资源,这可能导致运行时问题。
在部署的每个步骤中都需要重新打包和重新配置应用程序,因此需要大量的时间和精力,有时会出错。
在软件行业,容器通过提供应用程序之间的隔离和计算资源管理来解决这些问题,为这些问题提供了最佳解决方案。
软件行业面临的最大挑战是提供应用程序隔离和优雅地管理外部依赖项,以便它们可以在任何平台上运行,无论操作系统或基础设施如何。软件用多种编程语言编写,并使用各种依赖项和框架。这导致了一个称为地狱矩阵的场景。
地狱矩阵
假设您正在准备一台服务器,该服务器将为多个团队的多个应用程序运行。现在,假设您没有虚拟化基础设施,并且需要在一个物理机器上运行所有内容,如下图所示:

图 1.3 – 物理服务器上的应用程序
一个应用程序使用某个依赖项的特定版本,而另一个应用程序使用不同版本,您最终在一个系统中管理两个版本的同一软件。当您扩展系统以适应多个应用程序时,您将管理数百个依赖项和不同应用程序版本,这会逐渐变得在一个物理系统内难以管理。这种情况在流行的计算术语中被称为地狱矩阵。
地狱矩阵产生了多种解决方案,但有两种显著的技术贡献 – 虚拟机 和 容器。
虚拟机
虚拟机通过一种叫做虚拟机管理程序(hypervisor)的技术来模拟操作系统。虚拟机管理程序可以作为软件运行在物理主机操作系统上,或者作为固件运行在裸机上。虚拟机作为虚拟的客操作系统在虚拟机管理程序上运行。借助这项技术,您可以将一台庞大的物理机器划分为多个较小的虚拟机,每个虚拟机都服务于特定的应用程序。这项技术已经彻底改变了计算基础设施,近二十年来一直在使用,至今仍然活跃在市场上。市场上一些最流行的虚拟机管理程序包括VMware和Oracle VirtualBox。
下图展示了虚拟机上的同一堆栈。您可以看到,每个应用程序现在都包含一个专用的客操作系统,每个操作系统都有自己的库和依赖关系:

图 1.4 – 虚拟机上的应用程序
尽管这种方法是可接受的,但它就像使用一整艘船来运输货物,而不是使用简单的货运集装箱来做类比。虚拟机资源消耗较大,因为它需要一个较重的客操作系统层来隔离应用程序,而不是更轻量的解决方案。我们需要为虚拟机分配专用的 CPU 和内存;资源共享不够理想,因为人们往往为了应对高峰负载而过度配置虚拟机。虚拟机的启动速度也较慢,虚拟机的扩展传统上较为繁琐,因为涉及到多个活动组件和技术。因此,使用虚拟机自动化水平扩展(通过向资源池中添加更多机器来处理更多来自用户的流量)并不是特别直接。此外,系统管理员现在不得不处理多个服务器,而不是一个服务器中的大量库和依赖关系。虽然比以前有所改善,但从计算资源的角度来看,它仍然不是最优解。
容器
这就是容器技术的引入背景。容器技术解决了地狱矩阵的问题,并且没有涉及重型的客操作系统层。相反,容器通过封装应用程序运行时和依赖关系,将它们隔离开来,创建了一种叫做容器的抽象。现在,您可以在单一操作系统上运行多个容器。运行在容器中的众多应用程序可以共享相同的基础设施。因此,它们不会浪费您的计算资源。您也无需担心应用程序的库和依赖关系,因为它们被隔离在其他应用程序之外——对每个人来说,都是一种双赢的局面!
容器运行在容器运行时环境上。虽然Docker是最流行且几乎是事实上的容器运行时,但市场上还有其他选择,比如Rkt和Containerd。它们都使用相同的 Linux 内核cgroups特性,这一特性的基础来源于谷歌、IBM、OpenVZ 和 SGI 的共同努力,将OpenVZ嵌入到 Linux 主内核中。OpenVZ 是最早尝试在不使用客操作系统层的情况下,在 Linux 内核中实现虚拟环境特性的项目,我们现在称之为容器。
它在我的机器上能运行
你可能在职业生涯中听过这句话很多次。这是一个典型的情形:开发人员给测试团队带来困扰,他们会说“但在我的机器上能运行”,而测试团队则回应道“我们不会把你的机器交给客户。”容器使用一次构建,到处运行和一次打包,随处部署的理念,解决了它在我的机器上能运行的问题。由于容器需要容器运行时,它们可以在任何机器上以相同的方式运行。应用程序的标准化设置也意味着系统管理员的工作仅限于照顾容器运行时和服务器,并将应用程序的责任委托给开发团队。这减少了软件交付的管理负担,软件开发团队现在可以在没有太多外部依赖的情况下引领开发——这确实是强大的能力!现在,让我们看看容器是如何设计来实现这一点的。
容器架构
在大多数情况下,你可以将容器视为迷你虚拟机——至少,它们看起来像虚拟机。但实际上,它们只是运行在操作系统中的计算机程序。那么,让我们来看看容器中应用堆栈的高层次示意图:

图 1.5 – 容器中的应用程序
正如我们所看到的,计算基础设施位于最底部,形成了基础层,其上是主机操作系统和运行其上的容器运行时(在本例中为 Docker)。接着,我们有多个使用容器运行时的容器化应用程序,它们作为独立进程在主操作系统上运行,利用命名空间和cgroups。
正如你可能已经注意到的,我们在其中没有像虚拟机那样的客操作系统层。每个容器都是在内核用户空间上运行的软件程序,它与主机操作系统共享相同的操作系统及相关的运行时和其他依赖项,容器中仅包含所需的库和依赖项。容器不会继承操作系统的环境变量。你必须为每个容器单独设置这些变量。
容器复制了文件系统,尽管它们存在于磁盘上,但彼此隔离。这使得容器能够在安全的环境中运行应用程序。单独的容器文件系统意味着容器不需要与操作系统文件系统进行频繁的交互,从而比虚拟机更快地执行。
容器设计使用 Linux 命名空间 来提供隔离,并使用 cgroups 对 CPU、内存和磁盘 I/O 消耗进行限制。
这意味着,如果你列出操作系统进程,你将看到容器进程与其他进程一起运行,如下所示:

图 1.6 – 操作系统进程
然而,当你列出容器的进程时,你只会看到容器进程,如下所示:
$ docker exec -it mynginx1 bash
root@4ee264d964f8:/# pstree
nginx---nginx
这就是命名空间如何在容器之间提供一定程度的隔离。
Cgroups 在限制一组进程可以使用的计算资源方面起着重要作用。例如,如果你将进程添加到一个 cgroup,你可以限制这些进程使用的 CPU、内存和磁盘 I/O。此外,你还可以衡量和监控资源使用情况,当应用程序出现问题时,可以停止一组进程。所有这些特性构成了容器化技术的核心,我们将在本书后面看到这些内容。
一旦我们有了独立运行的容器,我们还需要了解它们如何交互。因此,我们将在下一节探讨容器网络。
容器网络
容器是操作系统中的独立网络实体。Docker 运行时使用网络驱动程序来定义容器之间的网络连接,它们是软件定义的网络。容器网络通过使用软件来操作 主机 iptables、连接外部网络接口、创建隧道网络以及执行其他活动,以便容器之间可以进行连接。
虽然你可以在容器中实现多种网络配置,但了解一些常用的配置还是很有帮助的。如果细节看起来有些复杂,不要太担心——你将在本书后续的实操练习中理解它们,跟随文本并不需要完全掌握这些内容。现在,让我们来看一下你可以定义的几种容器网络类型:
-
无:这是一个完全隔离的网络,您的容器无法与外界通信。它们被分配了一个回环接口,并且无法连接到外部网络接口。您可以使用此网络来测试容器、为未来使用准备容器,或者运行不需要任何外部连接的容器,比如批处理任务。
-
docker0接口用于默认容器。桥接网络通过操作 IP 表来提供 网络地址转换(NAT),实现容器与主机网络之间的外部网络连接。它还可以避免端口冲突,使得运行在同一主机上的容器之间能够进行网络隔离。因此,你可以在单个主机内运行多个使用相同容器端口的应用程序。桥接网络允许同一主机内的容器通过容器 IP 地址进行通信。然而,它不允许与运行在不同主机上的容器通信。因此,你不应该在集群配置中使用桥接网络(即使用多台服务器联合运行容器)。 -
主机网络:主机网络使用主机机器的网络命名空间来处理所有容器的网络。这类似于在主机上运行多个应用程序。虽然主机网络实现简单,易于可视化和故障排除,但它容易出现端口冲突问题。尽管容器使用主机网络进行所有通信,但除非在特权模式下运行,否则它无法操作主机网络接口。主机网络不使用 NAT,因此速度较快,并且以裸机速度进行通信。因此,你可以使用主机网络来优化性能。然而,由于容器之间没有网络隔离,从安全和管理的角度来看,在大多数情况下,你应该避免使用主机网络。
-
底层网络:底层网络直接将主机网络接口暴露给容器。这意味着你可以直接在网络接口上运行容器,而无需使用桥接网络。有几种底层网络,最著名的有 MACvlan 和 IPvlan。MACvlan 允许你为每个容器分配一个 MAC 地址,使得容器看起来像是一个物理设备。这对于将现有堆栈迁移到容器特别有用,尤其是当你的应用程序需要在物理机器上运行时。MACvlan 还提供了对主机网络的完全隔离,因此,如果你有严格的安全要求,可以使用这种模式。MACvlan 有一定的限制,因为它不能与设置了安全策略禁止 MAC 欺骗的网络交换机一起使用。它还受到某些网络接口卡的 MAC 地址数量限制,例如 Broadcom,只允许每个接口最多 512 个 MAC 地址。
-
Overlay:不要将 Overlay 与 Underlay 混淆 - 尽管它们看起来像是反义词,但实际上并非如此。Overlay 网络通过网络隧道允许不同主机上的容器进行通信。因此,从容器的角度来看,它们似乎在单个主机上与容器进行交互,即使它们位于其他位置。它克服了桥接网络的限制,特别适用于集群配置,尤其是在使用容器编排器如 Kubernetes 或 Docker Swarm 时。一些流行的 Overlay 技术包括 flannel、calico 和 VXLAN。
在深入讨论不同类型网络技术之前,让我们了解容器网络的微妙之处。在这次讨论中,我们将特别谈谈 Docker。
每个在主机上运行的 Docker 容器都分配了一个唯一的 IP 地址。如果你 exec(打开一个 shell 会话)进入容器并运行 hostname -I,你应该看到类似以下的内容:
$ docker exec -it mynginx1 bash
root@4ee264d964f8:/# hostname -I
172.17.0.2
这允许不同的容器通过简单的 TCP/IP 链路进行通信。Docker 守护进程充当每个容器的 DHCP 服务器。在这里,你可以为一组容器定义虚拟网络,并将它们结合在一起以提供网络隔离(如果需要)。你还可以将容器连接到多个网络,以便为它们提供两种不同的角色共享。
Docker 为每个容器分配一个唯一的主机名,默认为容器 ID。然而,这可以很容易地被覆盖,只要在特定网络中使用唯一的主机名即可。因此,如果你 exec 进入一个容器并运行 hostname,你应该看到容器 ID 作为主机名,如下所示:
$ docker exec -it mynginx1 bash
root@4ee264d964f8:/# hostname
4ee264d964f8
这使得容器能够作为独立的网络实体而不仅仅是简单的软件程序,你可以轻松地将容器视为小型虚拟机。
容器还继承了主机操作系统的 DNS 设置,因此如果你希望所有容器共享相同的 DNS 设置,你不必太担心。如果你想为你的容器定义单独的 DNS 配置,你可以通过传递一些标志来轻松实现。Docker 容器不继承 /etc/hosts 文件中的条目,因此你必须在使用 docker run 命令创建容器时声明它们。
如果你的容器需要代理服务器,你必须在 Docker 容器的环境变量中设置或者通过向 ~/.docker/config.json 文件添加默认代理来设置。
到目前为止,我们已经讨论了容器及其定义。现在,让我们讨论容器如何正在改变 DevOps 的世界,以及在一开始就明确拼写这一点是必要的。
容器和现代 DevOps 实践
容器和现代 DevOps 实践高度互补,并且已经改变了我们对软件开发和部署的方式。
容器与现代 DevOps 实践具有很好的协同作用,因为它们提供了必要的基础设施封装、可移植性、可扩展性和灵活性,从而实现快速高效的软件交付。通过现代 DevOps 实践,如 CI/CD、IaC 和微服务,容器为组织提供了一个强大的基础,使它们能够实现更快的市场响应时间、提升的软件质量和增强的操作效率。
容器从一开始就遵循 DevOps 实践。如果你查看典型的容器构建和部署工作流,你将会看到以下内容:
-
首先,用你想要的任何语言编写应用程序。
-
然后,创建一个Dockerfile,其中包含一系列步骤来安装应用程序的依赖项和运行应用所需的环境配置。
-
接下来,通过以下操作,使用 Dockerfile 创建容器镜像:
a) 构建容器镜像。
b) 运行容器镜像。
c) 对运行在容器上的应用程序进行单元测试。
-
然后,将镜像推送到像DockerHub这样的容器注册中心。
-
最后,从容器镜像创建容器,并在集群中运行它们。
你可以将这些步骤优雅地嵌入到下面显示的 CI/CD 管道示例中:

图 1.7 – 容器 CI/CD 管道示例
这意味着你的应用程序及其运行时依赖项都在代码中定义。你从一开始就遵循配置管理,允许开发人员像处理短期工作负载一样处理容器(短期工作负载是可以临时替换的工作负载,如果某个工作负载消失了,可以随时启动另一个,而不会对功能产生影响)。如果它们表现不佳,你可以替换它们——这在虚拟机中是不太优雅的做法。
容器非常适合现代的 CI/CD 实践,因为现在你有了一种标准的方式来构建和部署应用程序,无论你使用何种编程语言。你不必管理昂贵的构建和部署软件,因为容器提供了一切。
容器很少独立运行,在业界,将它们插入到容器编排器中,如Kubernetes,或者使用容器即服务(CaaS)平台,如AWS ECS和EKS,Google Cloud Run和Kubernetes Engine,Azure ACS和AKS,Oracle OCI和OKE等,已经成为行业的标准做法。流行的功能即服务(FaaS)平台,如AWS Lambda、Google Functions、Azure Functions和Oracle Functions,也会在后台运行容器。因此,尽管它们可能已经将底层机制抽象化,但你可能已经在不知不觉中使用了容器。
由于容器轻量化,你可以将应用程序的更小部分构建成容器,以便独立管理它们。结合像 Kubernetes 这样的容器编排工具,你就可以轻松运行一个分布式微服务架构。这些小部分可以独立扩展、自我修复,并独立于其他部分发布,这意味着你可以比以前更快、更可靠地将它们发布到生产环境中。
你还可以在上面接入服务网格(允许你发现、列出、管理并使你的微服务应用程序中多个组件(服务)之间进行通信的基础设施组件),比如Istio,并轻松获得流量管理、安全性和可观察性等高级运维功能。然后,你可以做一些很酷的事情,如蓝绿部署和A/B 测试,在生产环境中进行操作测试,使用流量镜像、基于地理位置的路由等,功能丰富。
因此,大型和小型企业比以往任何时候都更快地拥抱容器化,且这一领域正在呈指数级增长。根据businesswire.com的数据,应用容器市场年均复合增长率为 31%,预计到 2025 年将达到 69 亿美元。云计算领域每年增长 30.3%,预计到 2025 年将超过 24 亿美元,这也促进了容器化的快速发展。
因此,现代 DevOps 工程师必须理解容器及相关技术,以有效地交付容器化应用程序。这并不意味着虚拟机变得不再必要,我们不能完全忽视基于 IaaS 的解决方案在市场中的作用,因此在后续章节中我们还会涉及一些Ansible配置管理内容。由于云计算的到来,基础设施即代码(IaC)最近获得了很大的发展势头,因此我们还将介绍作为 IaC 工具的Terraform。
从虚拟机迁移到容器
随着技术市场朝着容器化发展,DevOps 工程师面临着一个重要任务——将运行在虚拟机上的应用程序迁移到容器中,以便它们能够在容器上运行。嗯,这是大多数 DevOps 工程师职位描述中的一项任务,也是我们做的最重要的事情之一。
理论上,容器化一个应用程序就像写几个简单的步骤,但从实际操作来说,它可能是一个复杂的难题,尤其是在没有使用配置管理来设置虚拟机的情况下。当前企业中运行的虚拟机大多是由系统管理员通过大量手动劳动创建的,他们一块一块地改进服务器,并使得很难追溯他们可能做过的热修复记录。
由于容器从一开始就遵循配置管理原则,因此它不像直接获取虚拟机镜像并使用转换工具将其转换为 Docker 容器那样简单。
将运行在虚拟机上的遗留应用程序迁移需要多个步骤。让我们更详细地了解这些步骤。
发现
首先,我们从发现阶段开始:
-
了解应用程序的不同部分
-
评估可以容器化的遗留应用程序部分,并判断是否技术上可行
-
定义迁移范围,并与相关方就迁移的明确目标、收益以及时间表达成一致
应用程序需求评估
一旦发现阶段完成,我们需要进行应用程序需求评估:
-
评估是否更好地将应用程序拆分为更小的部分。如果是,那么这些应用程序部分是什么,它们如何相互作用?
-
评估与应用程序相关的架构、性能和安全性方面,思考在容器化环境中的对应内容。
-
了解相关风险并决定减轻风险的应对措施。
-
理解迁移原则,并决定迁移策略,例如应该首先容器化应用程序的哪一部分。总是从外部依赖最少的应用程序开始。
容器基础设施设计
容器基础设施设计涉及创建一个稳健且可扩展的环境,以支持容器化应用程序的部署和管理。
设计容器基础设施涉及考虑可扩展性、网络、安全、自动化和监控等因素。关键是将基础设施设计与容器化应用程序的特定需求和目标对齐,并遵循高效、可靠的容器部署和管理的最佳实践。
一旦我们评估了所有需求、架构及其他方面,我们就可以进入容器基础设施设计阶段:
-
在做出决策时,了解当前和未来的运营规模。根据应用程序的复杂性,你可以从多种选项中进行选择。关键问题包括:我们需要在平台上运行多少个容器?这些容器之间有哪些依赖关系?我们将多频繁地部署组件更改?应用程序可能会接收到的潜在流量是多少?应用程序的流量模式是什么?
-
根据前面问题的答案,你需要了解将应用程序运行在哪种基础设施上。是本地部署还是云端,使用托管的 Kubernetes 集群,还是自行托管并管理?你还可以考虑轻量级应用程序的 CaaS 选项。
-
你将如何监控和操作容器?是否需要安装专业工具?是否需要与现有监控工具堆栈进行集成?了解可行性并做出适当的设计决策。
-
你将如何保护你的容器?是否有任何关于安全性的法规和合规要求?所选择的解决方案是否满足这些要求?
容器化应用程序
容器化应用程序包括将应用程序及其依赖项打包成容器镜像,这样可以在不同的环境中一致地部署和运行。
容器化应用程序带来了更好的可移植性、可扩展性和可重现性等好处。它简化了部署过程,并确保在不同环境中应用程序行为的一致性。
一旦我们考虑了设计的所有方面,就可以开始容器化应用程序:
-
这时,我们需要查看应用程序并创建一个 Dockerfile,包含创建容器的步骤,就像当前一样。这需要大量的头脑风暴和评估,特别是当配置管理工具没有通过在虚拟机上运行来构建你的应用程序时,比如 Ansible。如果应用程序是如何安装的需要花费很多时间去弄清楚,那么你就需要编写准确的步骤来实现这一过程。
-
如果你计划将应用程序拆分为更小的部分,可能需要从头开始构建应用程序。
-
你必须决定一个适用于基于虚拟机并行应用程序的测试套件,并随着时间推移不断改进。
测试
测试容器化应用程序是确保其功能、性能和兼容性的重要步骤。
通过实施全面的测试策略,你可以确保容器化应用程序的可靠性、性能和安全性。在不同层次进行测试,集成自动化,并密切监控应用程序的行为,将帮助你在开发生命周期的早期识别和解决问题,从而使容器化应用程序更加稳健和可靠。
一旦我们容器化了应用程序,接下来的步骤是进行测试:
-
为了证明你的容器化应用程序是否与虚拟机中的应用程序完全一样,你需要进行广泛的测试,证明你没有遗漏任何细节或之前应该考虑的部分。运行现有的测试套件或你为容器创建的测试套件。
-
运行现有的测试套件可能是正确的方法,但你也需要考虑软件的非功能性方面。对原始应用程序进行基准测试是一个好的开始,你需要理解容器化解决方案所带来的开销。你还需要对应用程序进行微调,以使其符合性能指标。
-
你还需要考虑安全性的重要性以及如何将其引入容器化世界。渗透测试将揭示许多你可能没有意识到的安全漏洞。
部署和发布
部署和发布容器化应用程序涉及将容器镜像部署到目标环境,并使应用程序可供使用。
一旦我们测试了容器并且足够有信心,我们就可以将我们的应用部署到生产环境中:
-
最后,我们将应用部署到生产环境,并从中学习是否需要进一步的修改。然后,我们返回到发现过程,直到我们完善了应用。
-
你必须定义并开发一个自动化的运行手册和 CI/CD 管道,以减少周期时间并快速排查问题。
-
进行 A/B 测试,容器化应用并行运行,可以帮助你在将所有流量切换到新方案之前发现潜在问题。
以下图表总结了这些步骤,如你所见,这一过程是循环的。这意味着你可能需要根据在生产中运行容器时学到的经验,时不时地重新审视这些步骤:

图 1.8 – 从虚拟机迁移到容器
现在,让我们了解一下为了确保从虚拟机迁移到容器时尽量减少摩擦并获得最佳效果,我们需要做些什么。
哪些应用应该容器化?
在从虚拟机迁移到容器的过程中,首先需要评估哪些应用可以容器化,哪些不能。广义上讲,你的应用工作负载可以分为两种类型——无状态和有状态。无状态工作负载不存储状态,是计算能力强大的应用,如 API 和函数;而有状态应用,如数据库,则需要持久化存储才能正常工作。
虽然任何可以在 Linux 虚拟机上运行的应用都可以容器化,但无状态应用通常是你首先要考虑的低悬果。容器化这些工作负载相对容易,因为它们没有存储依赖。你拥有的存储依赖越多,应用在容器中的复杂性就越高。
其次,你还需要评估你想要托管应用的基础设施形式。例如,如果你打算在 Kubernetes 上运行整个技术栈,尽量避免异构环境。在这种情况下,你可能也希望容器化有状态应用。对于 Web 服务和中间件层,大多数应用依赖某种形式的状态才能正确运行。所以,无论如何,你都会管理存储。
虽然这可能会打开潘多拉的盒子,但业内并没有达成共识关于是否将数据库容器化。虽然一些专家反对在生产环境中使用容器化数据库,但也有相当一部分人认为没有问题。主要原因是缺乏足够的数据来支持或反驳在生产环境中使用容器化数据库。
我建议你在处理数据库时要小心。虽然我并不反对将数据库容器化,但你必须考虑各种因素,如分配适当的内存、CPU、磁盘,以及你在虚拟机上的每个依赖关系。此外,你还需要关注团队的行为方面。如果你有一支管理生产环境中数据库的 DBAs 团队,他们可能不太愿意处理额外的复杂性——即容器。
我们可以通过以下流程图总结这些高层次的评估步骤:

图 1.9 – 虚拟机到容器迁移评估
这个流程图考虑了评估过程中最常见的因素。你还需要考虑到属于你组织的独特情况。因此,在做出任何决定之前,最好也考虑这些因素。
让我们看一些适合容器化的用例,以便更好地理解。以下类型的应用程序通常使用容器进行部署:
-
微服务架构:采用微服务架构的应用程序,其中功能被分割成小的、独立的服务,非常适合容器化。每个微服务可以作为一个独立的容器进行打包,从而简化了个别服务的开发、部署、扩展和管理。
-
Web 应用:Web 应用程序,包括前端应用、后端 API 和 Web 服务,可以容器化。容器提供了一致的运行环境,使得在不同环境(如开发、测试和生产)中打包和部署 Web 应用变得更加容易。
-
有状态应用:容器也可以用于运行需要持久数据存储的有状态应用。通过利用容器编排平台的功能,如持久卷或有状态集,有状态应用程序(如数据库、内容管理系统或文件服务器)可以被容器化并有效管理。
-
批处理或定时任务:执行批处理任务或定时任务的应用程序,如数据处理、定期备份或报告生成,可以从容器化中受益。容器为这些任务提供了一个受控和隔离的环境,确保一致的执行和可重现性。
-
CI/CD 工具:将 Jenkins、GitLab CI/CD 或 CircleCI 等 CI/CD 工具容器化,可以实现一致和可重现的构建、测试和部署流水线。容器简化了依赖关系的管理、构建环境的隔离,并能够快速部署 CI/CD 基础设施。
-
开发和测试环境:容器对于创建隔离的、可重复的开发和测试环境非常有价值。开发人员可以使用容器将他们的应用及所需的依赖、库和开发工具一起打包,这样可以确保在不同机器和团队成员之间拥有一致的开发和测试体验。
-
物联网(IoT)应用:容器可以用于在物联网场景中部署和管理应用程序。它们为物联网应用提供轻量级、便捷的运行环境,使得应用可以在边缘设备、网关或云基础设施上轻松部署。
-
机器学习和数据分析应用:容器化在部署机器学习模型和数据科学应用方面的应用日益增多。容器封装了所需的依赖、库和运行时环境,从而实现数据密集型应用的无缝部署和扩展。
需要注意,并非所有应用都适合容器化。具有复杂图形界面的应用、与底层基础设施紧密耦合的遗留单体架构,或者需要直接访问硬件的应用可能不适合容器化。在这种情况下,虚拟机或其他部署方法可能更为合适。
将应用拆分成更小的部分
如果你将应用的各个部分独立运行,你将最大限度地发挥容器的优势。
这种方法具有许多好处,具体如下:
-
由于你现在可以在不影响其他部分的情况下更改应用的某一部分,你可以更频繁地发布应用;你的部署也将花费更少的时间来运行。
-
你的应用部分可以独立扩展。例如,如果你有一个购物应用,而你的订单模块非常繁忙,它可以比评论模块扩展得更多,因为后者的繁忙程度可能远低于前者。如果是单体应用,整个应用会随着流量一起扩展,这从资源消耗的角度来看并不是最优方案。
-
影响应用某一部分的变化不会破坏整个系统。例如,如果评论模块出现故障,顾客仍然可以将商品添加到购物车并结算订单。
然而,你也不应将你的应用拆分成过小的组件。这将导致相当大的管理开销,因为你将无法区分各个组件的作用。以购物网站为例,拥有一个订单容器,一个评论容器,一个购物车容器和一个目录容器是可以的。然而,拥有创建订单、删除订单和更新订单容器则不合适,这会显得过于繁琐。将应用拆分成符合你业务逻辑的组件才是正确的方法。
但你应该在第一步就将应用程序拆分成更小的部分吗?嗯,这取决于情况。大多数人都希望从容器化工作中获得投资回报(ROI)。假设你从虚拟机迁移到容器,尽管你面临的变量很少,而且可以快速进入容器。那样的话,你不会从中获得任何好处——尤其是当你的应用程序是一个庞大的单体时。相反,由于容器层的存在,你会增加一些应用程序的开销。所以,将应用程序重新架构以适应容器化环境是继续前进的关键。
我们到了吗?
所以,你可能会想,我们到了吗?其实还没有!虚拟机将长期存在。它们有存在的充分理由,虽然容器解决了大部分问题,但并不是所有事情都能容器化。许多遗留系统运行在无法迁移到容器上的虚拟机中。
随着云计算的到来,虚拟化基础设施成为其基础,虚拟机处于核心地位。大多数容器在云中的虚拟机上运行,尽管你可能在一组节点中运行容器,但这些节点仍然是虚拟机。
然而,容器时代最棒的一点是,它将虚拟机视为标准设置的一部分。你只需在虚拟机上安装容器运行时,便无需区分它们。你可以在任何你希望的虚拟机上运行容器内的应用程序。使用像Kubernetes这样的容器编排工具,你还可以受益于编排工具在考虑各种因素的情况下决定容器的运行位置——资源可用性是其中最关键的因素之一。
本书将探讨现代 DevOps 实践的各个方面,包括管理基于云的基础设施、虚拟机和容器。虽然我们主要讨论容器,但我们也会同样重视使用 Ansible 进行配置管理,并学习如何使用 Terraform 快速部署基础设施。
我们还将探讨现代 CI/CD 实践,并学习如何高效、无误地将应用程序交付到生产环境中。为此,我们将介绍Jenkins和Argo CD等工具。本书将为你提供在云和容器时代担任现代 DevOps 工程师角色所需的一切知识。
总结
在本章中,我们了解了现代 DevOps、云计算和现代云原生应用程序。接着,我们探讨了软件行业如何迅速转向容器,并讨论了随着云计算的发展,现代 DevOps 工程师必须掌握的技能,以应对容器和云的挑战。然后,我们简要了解了容器架构,并讨论了从基于虚拟机的架构迁移到容器化架构的一些高层步骤。
在下一章中,我们将学习使用Git进行源代码管理,这将构成本书其余部分的基础。
问题
回答以下问题以测试你对本章的知识:
-
云计算比本地部署更贵。 (正确/错误)
-
云计算比资本支出(CapEx)需要更多的运营支出(OpEx)。 (正确/错误)
-
以下哪项关于云原生应用程序的说法是正确的?(选择三项)
A. 它们通常遵循微服务架构
B. 它们通常是单体的
C. 它们使用容器
D. 它们使用动态编排
E. 它们使用本地数据库
-
容器需要虚拟机管理程序才能运行。 (正确/错误)
-
以下哪项关于容器的说法是不正确的?(选择一项)
A. 容器是虚拟机中的虚拟机
B. 容器是简单的操作系统进程
C. 容器使用 cgroups 提供隔离
D. 容器使用容器运行时
E. 容器是短暂的工作负载
-
所有应用程序都可以容器化。 (正确/错误)
-
以下哪项是容器运行时?(选择两项)
A. Docker
B. Kubernetes
C. Containerd
D. Docker Swarm
- 你应该选择容器化哪种类型的应用程序?
A. APIs
B. 数据库
C. 大型机
-
容器原生支持 CI/CD 原则。 (正确/错误)
-
以下哪项是将应用程序拆分成多个部分的优点?(选择四项)
A. 故障隔离
B. 更短的发布周期
C. 独立的、细粒度的扩展
D. 应用架构简单性
E. 更简单的基础设施
- 在将应用程序拆分为微服务时,应该考虑哪个方面?
A. 将应用程序拆分成尽可能多的小组件
B. 将应用程序拆分成逻辑组件
- 你应该首先容器化哪种类型的应用程序?
A. 无状态
B. 有状态
- 以下哪项是 CaaS 的示例?(选择三项)
A. Azure Functions
B. Google Cloud Run
C. Amazon ECS
D. Azure ACS
E. Oracle Functions
答案
-
错误
-
错误
-
A, C, D
-
错误
-
A
-
错误
-
A, C
-
A
-
正确
-
A, B, C, E
-
B
-
A
-
B, C, D
第二章:使用 Git 和 GitOps 进行源代码管理
在上一章中,我们了解了现代 DevOps 的核心概念,简要介绍了云计算,并对容器有了基本了解。在本章中,我们将了解源代码管理以及启用 DevOps 的现代方法之一——GitOps。
在本章中,我们将涵盖以下主要内容:
-
什么是源代码管理?
-
Git 快速入门
-
什么是 GitOps?
-
GitOps 的原则
-
为什么选择 GitOps?
-
分支策略和 GitOps 工作流
-
Git 与 GitOps
技术要求
要跟随本章内容,您需要访问基于 Linux 的命令行。如果您使用 macOS,可以使用内建的终端完成所有任务。如果您是 Windows 用户,则必须从git-scm.com/download/win安装GitBash。我们将在接下来的章节中介绍安装说明。
现在,让我们从了解源代码管理开始。
什么是源代码管理?
软件开发涉及编写代码。代码是软件的唯一有形部分,它使软件能够运行。因此,您需要将代码存储在某个地方,以便编写和修改现有软件。代码有两种类型——源代码,它是用高级语言编写的,以及二进制文件,它是从源代码编译而来的。通常,二进制文件只不过是执行软件时运行的功能性应用,而源代码是为了生成二进制文件而编写的人类可读的代码,这也是为什么源代码被称为源代码的原因。
一个软件开发团队有多个成员在编写软件功能,因此他们必须在代码上进行协作。他们不能单独编写代码而不理解应用程序如何工作。有时,多个开发人员会共同开发同一个功能,因此他们需要一个地方来与同伴共享代码。源代码本身是一种资产;因此,我们希望将其安全地存储在中央位置,同时仍然能够便捷地为开发人员提供访问权限,而不妨碍他们的工作。您还需要跟踪更改并对其进行版本控制,因为您可能想知道是什么导致了问题,并能够立即回滚。您还需要保存代码的历史记录,以便了解哪些更改是由谁进行的,并且您还希望有一个源代码同行评审的机制。
如您所见,您可能希望管理源代码的多个方面,因此您将使用源代码管理工具来实现这一目标。
源代码管理工具帮助您管理源代码的各个方面。它提供一个集中位置来存储代码、版本更改,并允许多个开发者在相同的源代码上进行协作。它还通过版本历史记录保存所有的更改,以及我们之前提到的其他内容。有效的源代码管理实践能够提高协作效率;促进开发工作流的高效性;提供版本控制、仓库管理、分支和合并、变更跟踪、审计等功能;并提高软件项目的整体质量和可维护性。一些流行的 SCM 工具有 Git、Subversion、Mercurial 和 CVS。然而,最流行且事实上的 SCM 标准是 Git。所以,接下来我们来学习它。
Git 快速入门
Git 是目前最流行的源代码管理系统,现如今所有开发者都必须学习 Git,至少要掌握基础内容。在本次快速入门中,我们将学习所有基本的 Git 操作,并在后续章节中进行拓展。
Git 是一个分布式版本控制系统。这意味着每个 Git 仓库都是原始仓库的副本,您可以在需要时将其复制到远程位置。在本章中,我们将创建并初始化一个本地 Git 仓库,然后将整个仓库推送到远程位置。
存储在远程中央位置的 Git 仓库也被称为 远程仓库。在这个中央仓库中,所有开发者将本地仓库的更改同步到远程仓库,类似于下图所示:

图 2.1 – Git 分布式仓库模型
首先,让我们在本地安装 Git 并初始化一个本地仓库。稍后我们将查看远程仓库。
安装 Git
根据您的平台和工作站的不同,安装 Git 的方式也不同。在 Ubuntu 上安装 Git,请运行以下命令:
$ sudo apt install -y git-all
对于其他操作系统和平台,您可以按照以下链接中的步骤操作:https://git-scm.com/book/en/v2/Getting-Started-Installing-Git。
要检查 Git 是否安装成功,请运行以下命令:
$ git --version
git version 2.30.2
现在,让我们初始化第一个 Git 仓库。
初始化您的第一个 Git 仓库
要创建一个 Git 仓库,您需要创建一个目录并运行 git init 命令,如下所示:
$ mkdir first-git-repo && cd first-git-repo/
$ git init
Initialized empty Git repository in ~/first-git-repo/.git/
您现在可以使用您的 Git 仓库了。您还可以看到,当您初始化 Git 仓库时,Git 创建了一个隐藏目录 .git,用于跟踪所有的更改和提交。您在仓库中所做的任何更改,Git 都会将它们作为差异(delta)记录,使用 + 和 - 符号表示。我们将在后续部分详细探讨这些内容。现在,让我们在 Git 仓库中创建一个新文件并暂存更改。
暂存代码更改
Git 允许开发者在提交之前暂存他们的更改。这有助于你准备好提交到仓库的内容。暂存区是你更改的临时存放区,你可以通过使用git add和git restore命令将文件添加或移除出暂存区。
让我们在本地 Git 仓库中创建第一个文件并暂存更改:
$ touch file1
或者,你也可以在first-git-repo目录中创建一个空白文件。
现在,我们将检查 Git 是否能检测到我们创建的新增文件。为此,我们需要运行以下命令:
$ git status
On branch master
No commits yet
Untracked files: (use "git add <file>..." to include in what will be committed)
file1
nothing added to commit but untracked files present (use "git add" to track)
所以,如我们所见,Git 已经检测到file1并告诉我们它目前没有跟踪该文件。为了让 Git 跟踪该文件,让我们运行以下命令:
$ git add file1
现在,让我们再次运行git status来查看有哪些更改:
$ git status
On branch master
No commits yet
Changes to be committed: (use "git rm --cached <file>..." to unstage)
new file: file1
如我们所见,Git 现在显示file1作为暂存区中的新文件。你可以继续进行更改,当你完成后,可以使用以下命令提交这些更改:
$ git commit -m "My first commit"
[master (root-commit) cecfb61] My first commit
1 file changed, 0 insertions(+), 0 deletions(-)
create mode 100644 file1
Git 现在已记录了一个包含你更改的提交。现在,让我们再次使用以下命令查看它的状态:
$ git status
On branch master
nothing to commit, working tree clean
Git 现在报告工作树是干净的,没有任何需要提交的内容。它还显示没有未跟踪的文件。现在,让我们修改file1并在其中添加一些文本:
$ echo "This is first line" >> file1
$ cat file1
This is first line
file1现在包含了第一行。让我们继续提交这个更改:
$ git add file1
$ git commit -m "My second commit"
[master 4c55cf5] My second commit
1 file changed, 1 insertion(+)
正如我们所看到的,Git 现在报告有一个文件已更改,并且有一处插入。记得我们讨论过 Git 只追踪提交之间的增量更改吗?这就是发生的情况。
在介绍中,我们提到 Git 提供了所有提交的历史记录。让我们看看如何显示这个历史记录。
显示提交历史
Git 保留了所有提交的历史记录。要查看你所做的所有提交列表,可以使用以下命令:
$ git log
commit 275d24c62a0e946b8858f562607265c269ec5484 (HEAD -> master)
Author: Gaurav Agarwal <example@gmail.com>
Date: Wed Apr 19 12:27:13 2023 +0530
My second commit
commit cecfb61b251f9966f50a4d8bb49742b7af014da4
Author: Gaurav Agarwal < example@gmail.com>
Date: Wed Apr 19 12:20:02 2023 +0530
My first commit
如我们所见,Git 已显示了我们两个提交的历史记录。注意,Git 为每个提交标记了一个提交 ID。我们还可以通过使用git diff <first_commit_id> <second_commit_id>命令来深入查看在提交中进行了哪些更改,具体如下:
$ git diff cecfb61b251f9966f50a4d8bb49742b7af014da4 \
275d24c62a0e946b8858f562607265c269ec5484
diff --git a/file1 b/file1
index e69de29..0cbcf32 100644
--- a/file1
+++ b/file1
@@ -0,0 +1 @@
+This is first line
输出清楚地显示,第二次提交已将This is first line添加到file1中。
你突然意识到需要在file1中再添加一行,并希望将其与同一提交一起完成。我们可以通过修改提交来做到这一点。我们将在下一节中讨论这个问题。
修改上一次提交
最佳实践是为某个特定功能的更改创建单独的提交。这有助于你更好地追踪更改,并使审阅者更容易进行审查。反过来,这也使得可视化和管理更清晰。然而,频繁提交也是最佳实践,以确保你的更改不会丢失。幸运的是,Git 允许你向上一次提交中添加更改。
为了演示这一点,让我们修改file1并添加另一行:
$ echo "This is second line" >> file1
$ cat file1
This is first line
This is second line
现在,让我们使用以下命令将更改添加到之前的提交中:
$ git add file1
$ git commit --amend
一旦你运行这个命令,Git 会显示一个提示,允许你修改提交信息(如果需要的话)。它将类似于以下内容:
My second commit
# Please enter the commit message for your changes. Lines
# starting with # will be ignored and an empty message aborts the commit
# Date Wed Apr 19 12:27:13 2023 +0530
# on branch master
# Changes to be commited
# modified: file1
#
保存此文件(使用 ESC:wq 保存退出 Vim)。这将修改最后一次提交并包含更改。你应该会看到以下输出:
Date: Wed Apr 19 12:27:13 2023 +0530
1 file changed, 2 insertions(+)
当 Git 修改提交时,你将无法再使用相同的提交 ID 引用之前的提交。相反,Git 会为修改后的提交生成一个单独的SHA-1 id。所以,让我们查看日志,亲自看看这个变化:
$ git log
commit d11c13974b679b1c45c8d718f01c9ef4e96767ab (HEAD -> master)
Author: Gaurav Agarwal <gaurav.agarwal@example.com>
Date: Wed Apr 19 12:27:13 2023 +0530
My second commit
commit cecfb61b251f9966f50a4d8bb49742b7af014da4
Author: Gaurav Agarwal < example@gmail.com>
Date: Wed Apr 19 12:20:02 2023 +0530
My first commit
现在,让我们再次运行 diff 命令,看看它报告了什么:
$ git diff cecfb61b251f9966f50a4d8bb49742b7af014da4 \
d11c13974b679b1c45c8d718f01c9ef4e96767ab
diff --git a/file1 b/file1
index e69de29..655a706 100644
--- a/file1
+++ b/file1
@@ -0,0 +1,2 @@
+This is first line
+This is second line
输出清楚地显示,第二次提交在 file1 中添加了 This is first line 和 This is second line。这样,你就成功修改了提交。
本地仓库的作用就像是在系统上保存文件。然而,既然你需要与他人共享代码,并防止因笔记本操作系统崩溃、盗窃、物理损坏等原因造成的安全隐患,你需要将代码推送到远程仓库。我们将在下一节讨论远程仓库。
理解远程仓库
远程仓库是 Git 仓库在中央位置的副本,供多人访问。这样,开发者可以同时在同一个代码库上工作,并且为你的代码提供了备份。你可以使用各种工具来托管远程仓库。著名的包括GitHub、Bitbucket 和 Gerrit。你可以将它们安装在本地服务器或云服务器上,或者使用软件即服务(SaaS)平台在线存储它们。本书将重点讲解 GitHub。
GitHub 是一个基于 web 的平台,帮助开发者进行代码协作。它基于 Git,并允许你托管远程 Git 仓库。GitHub 成立于 2008 年,并在 2018 年被微软收购。它是最受欢迎的开源 SaaS Git 仓库服务之一,包含了几乎所有全球可用的开源代码。
在创建第一个远程仓库之前,我们必须访问 github.com/signup 创建一个账户。
创建完账户后,我们可以继续创建第一个远程 Git 仓库。
创建远程 Git 仓库
在 GitHub 上创建远程 Git 仓库非常简单。访问 github.com/new,设置 first-git-repo,其他字段保持默认,然后点击 创建 仓库 按钮。
完成后,GitHub 会为你提供一些步骤,指导你如何连接到远程仓库。在我们深入这些步骤之前,我们需要为本地 Git 命令行配置一些身份验证,以便与远程仓库交互。让我们来看看。
设置远程 Git 仓库的身份验证
你可以通过以下几种方式与远程 Git 仓库进行身份验证:
-
HTTPS:在这种模式下,Git 使用 HTTPS 协议连接远程 Git 仓库。我们需要在 GitHub 账户中创建一个 HTTPS 令牌,并使用该令牌作为密码来进行身份验证。这个过程要求你每次进行 Git 身份验证时都输入令牌,因此并不是一个方便的选项。
-
SSH:在这种模式下,Git 使用 SSH 协议连接远程 Git 仓库。在使用 SSH 时,我们无需使用密码进行身份验证;相反,我们必须将从 Linux(或如果你使用 Git Bash 的话,可以在 Windows 上)命令行生成的 SSH 密钥对 的 公钥 添加到 GitHub 账户中。这个过程既更安全又更方便。
所以,让我们通过 SSH 认证设置与远程 Git 仓库的连接。
首先,我们必须在本地系统中生成 SSH 密钥对。打开终端,运行以下命令生成 SSH 密钥对:
$ ssh-keygen -t rsa
Generating public/private rsa key pair.
系统会提示你输入其他详细信息。继续按 Enter 键,直到再次出现提示。
一旦生成了密钥对,复制 ~/.ssh/id_rsa.pub 文件中的公钥。
然后,访问 github.com/settings/ssh/new,将公钥粘贴到 Key 字段,并点击 Add SSH Key 按钮。现在,我们已经准备好连接远程 Git 仓库。接下来,让我们看看在本地仓库中需要做哪些配置才能连接到远程仓库。
将本地仓库与远程仓库连接
你需要使用以下命令添加远程条目,以便从本地仓库连接到远程仓库:
$ git remote add origin git@github.com:<your-github-username>/first-git-repo.git
你还可以在 GitHub 仓库的 快速设置 页面找到这些详细信息。
现在我们已经设置好连接,接下来让我们看看如何将更改推送到远程仓库。
将更改从本地仓库推送到远程仓库
要将本地仓库的更改推送到远程仓库,使用以下命令:
$ git push -u origin master
Enumerating objects: 6, done.
Counting objects: 100% (6/6), done.
Delta compression using up to 12 threads
Compressing objects: 100% (2/2), done.
Writing objects: 100% (6/6), 474 bytes | 474.00 KiB/s, done.
Total 6 (delta 0), reused 0 (delta 0), pack-reused 0
To github.com: <your-github-username>/first-git-repo.git
* [new branch] master -> master
Branch 'master' set up to track remote branch 'master' from 'origin'.
现在,刷新远程仓库页面。你应该能看到代码已经同步,如下图所示:

图 2.2 – 远程仓库中的代码同步
你还可以使用内联编辑器通过 GitHub Web 门户进一步修改文件。虽然不推荐这么做,但我们会这样做来模拟另一位开发者更改了你正在工作的相同文件的情况。
点击 file1,然后点击 铅笔 图标以编辑文件,如下图所示:

图 2.3 – 编辑远程仓库中的文件
完成此操作后,会打开一个编辑窗口,你可以在文件中进行更改。让我们在文件中添加 This is third line,如图所示:

图 2.4 – 添加新行
向下滚动–你应该能看到一个提交更改部分,在这里你可以添加提交信息字段并点击提交按钮,如下图所示:

图 2.5 – 提交更改部分
一旦你点击了那个按钮,你应该能看到第三行,如下图所示:

图 2.6 – 远程提交的更改
此时,远程仓库已经做出了更改,但你也在本地仓库上进行了一些更改。为了模拟这种情况,让我们也在本地仓库中做一个更改,使用以下命令:
$ echo "This is fourth line" >> file1
$ cat file1
This is first line
This is second line
This is fourth line
$ git add file1
$ git commit -m "Added fourth line"
[master e411e91] Added fourth line
1 file changed, 1 insertion(+)
现在我们已经在本地仓库提交了更改,让我们尝试使用以下命令将代码推送到远程仓库:
$ git push
To github.com:<your-github-username>/first-git-repo.git
! [rejected] master -> master (fetch first)
error: failed to push some refs to 'github.com:<your-github-username>/first-git-repo.git'
hint: Updates were rejected because the remote contains work that you do not have locally.
This is usually caused by another repository pushing to the same ref. You may want to
first integrate the remote changes.
hint: (e.g., 'git pull ...') before pushing again.
hint: See the 'Note about fast-forwards' in 'git push --help' for details.
等等,发生了什么?我们尝试推送更改时,远程仓库拒绝了更改,因为在我们推送时,其他人已经在远程仓库做了提交,而我们的更改已经过时。我们需要先将远程仓库的更改拉取到本地仓库,然后再将我们的更改应用到这些现有的更改之上。我们将在下一节中讨论这个问题。
拉取和变基你的代码
拉取代码是指将远程仓库的最新代码下载到本地仓库。变基意味着将你的更改应用到最新的远程提交之上。在远程仓库中,最好的实践是先拉取代码,再将你的更改变基到已经存在的代码上。
让我们使用以下命令执行此操作:
$ git pull --rebase
remote: Enumerating objects: 5, done.
remote: Counting objects: 100% (5/5), done.
remote: Compressing objects: 100% (2/2), done.
remote: Total 3 (delta 0), reused 0 (delta 0),pack-reused 0
Unpacking objects: 100% (3/3), 652 bytes | 130.00 KiB/s, done.
From github.com:<your-github-username>/first-git-repo
d11c139..f5b7620 master -> origin/master
Auto-merging file1
CONFLICT (content): Merge conflict in file1
error: could not apply e411e91... Added fourth line
Resolve all conflicts manually, mark them as resolved with
"git add/rm <conflicted_files>", then run "git rebase --continue".
You can instead skip this commit: run "git rebase --skip".
To abort and get back to the state before "git rebase", run "git rebase --abort".
Could not apply e411e91... Added fourth line
现在,我们遇到了另一个问题:我们无法变基我们的提交,因为我们试图修改一个已经被其他人修改过的文件。Git 要求我们检查文件并做出适当的更改,以确保更改被正确应用。这种情况被称为合并冲突。Git 还会提供包含冲突的文件。让我们用文本编辑器打开该文件并进行适当的更改。
当前文件看起来是这样的:
This is first line
This is second line
<<<<<<< HEAD
This is third line
=======
This is fourth line
>>>>>>> e411e91 (Added fourth line)
HEAD所示的部分是远程仓库中的一行,显示了最近在远程做的更改。e411e91 提交显示了我们在本地所做的更改。让我们将文件更改为以下内容并保存:
This is first line
This is second line
This is third line
This is fourth line
现在,让我们将文件添加到暂存区,并使用以下命令继续执行变基:
$ git add file1
$ git rebase --continue
[detached HEAD 17a0242] Added fourth line
1 file changed, 1 insertion(+)
Successfully rebased and updated refs/heads/master.
现在我们已经变基了更改,让我们通过运行以下命令查看 Git 仓库的状态:
$ git status
On branch master
Your branch is ahead of 'origin/master' by 1 commit.
(use "git push" to publish your local commits)
nothing to commit, working tree clean
如我们所见,我们已经添加了一个需要推送到远程仓库的提交。现在让我们使用以下命令将其推送:
$ git push
Enumerating objects: 5, done.
Counting objects: 100% (5/5), done.
Delta compression using up to 12 threads
Compressing objects: 100% (2/2), done.
Writing objects: 100% (3/3), 297 bytes | 148.00 KiB/s, done.
Total 3 (delta 0), reused 0 (delta 0), pack-reused 0
To github.com:<your-github-username>/first-git-repo.git
f5b7620..17a0242 master -> master
这次,推送成功了。
在大多数情况下,你通常需要复制主代码并进行更改,以测试新特性。你可能还希望在将更改合并到主代码库之前,先让别人审核这些更改。Git 通过使用 Git 分支来管理这一过程。我们将在下一节讨论 Git 分支。
Git 分支
Git 分支是代码库的一个副本(从中创建分支的位置),你可以独立修改和工作,而不会影响主代码库。你在处理新特性时希望创建分支,以确保不影响包含已审核代码的主分支。大多数科技公司通常会有多个环境,其中代码会在不同阶段部署。例如,你可能有一个开发环境来测试特性,一个预生产环境来集成所有特性并测试完整的应用程序,还有一个生产环境,是终端用户访问的应用程序所在的环境。所以,也有可能你会有额外的特定环境分支,用于存放在这些分支上部署的代码。在本章的接下来的章节中,我们将讨论 GitOps,它基于这一基本原则。现在,让我们看看如何创建和管理 Git 分支。
创建和管理 Git 分支
要创建一个 Git 分支,必须先切换到你希望从中分支的分支。例如,在我们的示例仓库中,我们是在 master 分支上工作的。让我们保持在该分支并从中创建一个特性分支。
要创建分支,运行以下命令:
$ git branch feature/feature1
如我们所见,特性分支已经创建。要查看我们现在所在的分支,可以使用git branch命令。让我们现在就这样做:
$ git branch
正如我们通过*符号看到的,我们仍然在master分支上。好的一点是,它还在列表中显示了feature/feature1分支。现在让我们使用以下命令切换到特性分支:
$ git checkout feature/feature1
Switched to branch 'feature/feature1'
现在,我们在feature/feature1分支上。让我们对feature/feature1分支进行一些更改并提交到本地仓库:
$ echo "This is feature 1" >> file1
$ git add file1
$ git commit -m "Feature 1"
[feature/feature1 3fa47e8] Feature 1
1 file changed, 1 insertion(+)
如我们所见,代码现在已经提交到feature/feature1分支。要查看版本历史记录,让我们运行以下命令:
$ git log
commit 3fa47e8595328eca0bc7d2ae45b3de8d9fd7487c (HEAD -> feature/feature1)
Author: Gaurav Agarwal <gaurav.agarwal@example.com>
Date: Fri Apr 21 11:13:20 2023 +0530
Feature 1
commit 17a02424d2b2f945b479ab8ba028f3b535f03575 (origin/master, master)
Author: Gaurav Agarwal <gaurav.agarwal@example.com>
Date: Wed Apr 19 15:35:56 2023 +0530
Added fourth line
如我们所见,Feature 1的提交记录出现在 Git 日志中。现在,让我们切换到master分支并再次运行相同的命令:
$ git checkout master
Switched to branch 'master'
Your branch is up to date with 'origin/master'.
$ git log
commit 17a02424d2b2f945b479ab8ba028f3b535f03575 (HEAD -> master, origin/master)
Author: Gaurav Agarwal <gaurav.agarwal@example.com>
Date: Wed Apr 19 15:35:56 2023 +0530
Added fourth line
commit f5b7620e522c31821a8659b8857e6fe04c2f2355
Author: Gaurav Agarwal <<your-github-username>@gmail.com>
Date: Wed Apr 19 15:29:18 2023 +0530
My third commit
如我们所见,Feature 1的提交更改缺失。这表明两个分支现在是隔离的(并且已经分叉)。现在,改变只存在于本地,还没有推送到远程仓库。为了将更改推送到远程仓库,我们将再次切换到feature/feature1分支。让我们通过以下命令来实现:
$ git checkout feature/feature1
Switched to branch 'feature/feature1'
现在我们已经切换到特性分支,让我们使用以下命令将分支推送到远程仓库:
$ git push -u origin feature/feature1
Enumerating objects: 5, done.
Counting objects: 100% (5/5), done.
Delta compression using up to 12 threads
Compressing objects: 100% (2/2), done.
Writing objects: 100% (3/3), 286 bytes | 286.00 KiB/s, done.
Total 3 (delta 1), reused 0 (delta 0), pack-reused 0
remote: Resolving deltas: 100% (1/1), completed with 1 local object.
remote:
remote: Create a pull request for 'feature/feature1' on GitHub by visiting:
remote: https://github.com/<your-github-username>/first-git-repo/pull/new/feature/feature1
remote:
To github.com:<your-github-username>/first-git-repo.git
* [new branch] feature/feature1 -> feature/feature1
Branch 'feature/feature1' set up to track remote branch 'feature/feature1' from 'origin'.
这样,我们就成功地将新分支推送到了远程仓库。假设功能已经准备好,我们希望将更改合并到 master 分支。为此,我们需要发起一个拉取请求。我们将在下一部分查看拉取请求。
使用拉取请求
master。拉取请求通常对开发者有用,帮助他们在将代码合并到最终版本之前进行同行评审。评审者通常会检查代码质量、是否遵循最佳实践、以及编码标准是否合适。如果评审者不满意,他们可能会标记出某些更改部分并要求修改。通常会有多轮评审、修改和重新评审。一旦评审者对更改满意,他们可以批准拉取请求,然后请求者就可以合并代码。让我们看一下这个过程:
- 让我们尝试发起一个拉取请求,将我们的代码从
feature/feature1分支合并到master分支。为此,进入你的 GitHub 仓库,选择拉取请求,然后点击新建拉取请求按钮,如下图所示:

图 2.7 – 新拉取请求
- 保持
master,并在feature/feature1中:

图 2.8 – 比较更改
- 如你所见,它显示了我们在
feature/feature1分支上所做的所有更改。点击创建拉取请求按钮来创建拉取请求。在下一页,保持默认设置并点击创建拉取请求按钮:

图 2.9 – 拉取请求已创建
-
如你所见,拉取请求已成功创建。在这里,你可以分配评审者并进行代码评审。一旦评审者批准代码,你就可以将代码合并到 master 分支。现在,让我们点击合并拉取请求按钮,接着点击确认合并按钮,这样就可以合并拉取请求。
-
现在,检查
master分支是否在 GitHub 上显示了更改。如果显示了,切换到master分支,并将更改拉取到本地仓库。你应该也能在本地仓库中看到这些更改。
这个留给你作为练习。
这是一个关于 Git 的速成课程,帮助你入门。现在,让我们继续了解 GitOps,它使用 Git 作为单一的真理来源,来启动你应用程序和基础设施中的几乎任何内容。
什么是 GitOps?
GitOps 是一种方法,旨在实现 DevOps,使得 Git 成为唯一的真相来源。与其维护一长串脚本和工具来支持这一点,GitOps 专注于为一切编写声明式代码,包括基础设施、配置和应用程序代码。这意味着你可以通过简单地使用 Git 仓库来轻松地创建任何东西。其理念是,你在 Git 仓库中声明所需的内容,背后有工具确保所需的状态始终保持在运行的应用程序和周围的基础设施中。用于启动这些工具的代码也存储在 Git 中,你不需要任何 Git 以外的东西。这意味着包括工具在内的一切都是自动化的。
虽然 GitOps 也使得组织内部的 DevOps 得以实现,但它主要侧重于使用 Git 来管理基础设施的配置和应用程序软件的部署。DevOps 是一个广泛的术语,包含一组原则、过程和工具,旨在使开发人员和运维团队无缝协作,缩短开发生命周期,最终目标是通过 CI/CD 周期更快地交付更好的软件。尽管 GitOps 严重依赖 Git 及其特性,并始终依赖 Git 进行版本控制、查找配置漂移并仅应用增量,DevOps 本身并不依赖任何工具,更加关注概念和过程。因此,你可以在不使用 Git 的情况下实现 DevOps,但无法在没有 Git 的情况下实现 GitOps。简单来说,GitOps 实现了 DevOps,但反过来并不总是成立。
为什么选择 GitOps?
GitOps 为我们提供了以下好处:
-
它更快地部署更好的软件:GitOps 简化了软件交付。你无需担心为部署类型需要什么工具。相反,你只需在 Git 中提交你的更改,背后的工具会自动进行部署。
-
git revert并恢复你的环境。其理念是,你不需要学习除 Git 之外的其他任何内容来执行发布或回滚。 -
它提供更好的凭证管理:使用 GitOps,你无需将凭证存储在不同的位置来使部署正常工作。你只需为工具提供对 Git 仓库和二进制仓库的访问权限,GitOps 会处理其余的部分。你可以通过限制开发人员对环境的访问并为他们提供对 Git 的访问,来保持环境的安全。
-
部署是自我文档化的:因为一切都保存在 Git 中,而 Git 记录了所有提交,所以部署是自动自我文档化的。你可以通过查看提交历史,准确知道是谁在什么时间部署了什么内容。
-
它促进共享所有权和知识:由于 Git 是组织内所有代码和配置的唯一真理来源,团队可以在一个地方理解事情是如何实现的,而不必依赖其他团队成员或存在歧义。这有助于推动团队内代码和知识的共享所有权。
现在我们已经了解了 GitOps 的好处,让我们来看看它的关键原则。
GitOps 的原则
GitOps 有以下关键原则:
-
它以声明性方式描述整个系统:拥有声明性代码是 GitOps 的第一原则。这意味着,与其提供构建基础设施的指令,应用相关配置并部署应用程序,不如声明所需的最终状态。这意味着您的 Git 仓库始终保持唯一的真理来源。由于声明性变更是幂等的,您不必担心系统的状态,因为它最终会与 Git 中的代码保持一致。
-
它使用 Git 来版本化所需的系统状态:由于 Git 是一个优秀的版本控制系统,您不必过于担心如何部署和回滚。一次简单的 Git 提交意味着一个新的部署,而 Git 回滚意味着回滚。这意味着,除了确保 Git 仓库反映您需要的内容外,您不需要担心其他任何问题。
-
它使用工具自动应用已批准的更改:由于您已经将一切存储在 Git 中,您可以使用工具来查找仓库中的更改,并自动将其应用到环境中。您还可以有多个分支将更改应用到不同的环境中,并使用基于拉取请求的审批和控制过程,确保只有已批准的更改最终进入您的环境。
-
它使用自愈代理来警报并纠正任何偏离:我们有工具可以自动将 Git 中的任何更改应用到环境中。然而,我们还需要自愈代理来警报任何与仓库的偏离。例如,假设有人手动从环境中删除了一个容器,但没有从 Git 仓库中删除它。在这种情况下,代理应该警报团队,并重新创建该容器以纠正偏离。这意味着没有办法绕过 GitOps,Git 始终是唯一的真理来源。
使用现代 DevOps 工具和技术来实现并遵循这些原则非常简单,我们将在 第十一章 和 第十二章 中实际实施它们。然而,在本章中,我们将通过分支策略和 GitOps 工作流来审视这些设计原则。
分支策略和 GitOps 工作流
GitOps 至少需要两种 Git 仓库才能正常工作:应用仓库,它是触发构建的来源,以及环境仓库,它包含所有基础设施和配置即代码(CaC)。所有的部署都来自环境仓库,而对代码仓库的变化驱动部署。GitOps 有两种主要的部署模型:推送模型和拉取模型。我们来逐一讨论这两种模型。
推送模型
推送模型将 Git 仓库中的任何变化推送到环境中。下图详细解释了这一过程:

图 2.10 – 推送模型
推送模型本身并不关注现有配置,只会对 Git 仓库中的变化作出反应。因此,你需要设置某种形式的监控,来了解是否存在任何偏差。此外,推送模型需要在工具中存储所有环境凭证,因为它与环境交互并且需要管理部署。通常,我们使用Jenkins、CircleCI 或 Travis CI 来实现推送模型。虽然不推荐使用推送模型,但在使用Terraform进行云资源配置或使用Ansible进行配置管理时,它是不可避免的,因为它们本身就是基于推送的模型。现在,让我们更仔细地看看拉取模型。
拉取模型
拉取模型是一种基于代理的部署模型(也叫做基于操作员的部署模型)。在你的环境中,代理(或操作员)监控 Git 仓库的变化,并根据需要应用这些变化。操作员不断地将现有的配置与环境仓库中的配置进行比较,如果需要,则应用变化。下图详细展示了这一过程:

图 2.11 – 拉取模型
拉取模型的优点是,它在监控和响应环境变化的同时,也能响应仓库中的变化。这确保了任何与 Git 仓库不匹配的变化都会从环境中恢复。它还会通过邮件通知、工单工具或 Slack 通知,提醒运维团队任何无法修复的问题。由于操作员与代码部署所在的环境处于同一环境中,因此我们不需要在工具中存储凭证。相反,这些凭证会安全地存储在环境中。你甚至可以完全不存储任何凭证,使用像 Kubernetes 这样的工具时,你可以利用基于角色的访问控制(RBAC)和服务账户来管理操作员对环境的管理。
提示
在选择 GitOps 模型时,最佳实践是检查是否可以实现基于拉取的模型,而不是基于推送的模型。仅当无法使用基于拉取的模型时,才实施基于推送的模型。在基于推送的模型中实施轮询是一个好主意,通过定期运行cron作业等调度,定期运行推送以确保没有配置漂移。
我们不能单靠其中一种模型生存,因此大多数组织采用混合模型来运行 GitOps。这种混合模型结合了推送和拉取模型,并侧重于使用拉取模型。当无法使用拉取模型时,它使用推送模型。现在,让我们了解如何构建我们的 Git 仓库,以便它可以实施 GitOps。
Git 仓库的结构化
要实施 GitOps,我们至少需要两个仓库:应用程序仓库和环境仓库。这并不意味着您不能将两者合并,但是为了简单起见,让我们分别看看它们。
应用程序仓库
应用程序仓库存储应用程序代码。这是一个您的开发人员可以积极开发您业务运行的产品的代码仓库。通常,您的构建源自这个应用程序代码,并且它们最终成为容器(如果我们使用基于容器的方法)。您的应用程序仓库可能有环境特定的分支,也可能没有。大多数组织将应用程序仓库独立于环境,并专注于使用分支策略构建语义化的代码版本。现在,有多种分支策略可用于管理您的代码,例如Gitflow、GitHub flow和任何适合您需求的其他分支策略。
Gitflow 是组织中使用的最流行的分支策略之一。也可以说,它是最复杂的之一,因为它需要多种类型的分支(例如主分支、热修复分支、发布分支、开发分支和功能分支)并且有一个严格的结构。Gitflow 的结构如下图所示:

图 2.12 – Gitflow 结构
使用 GitHub flow 的简化方法是做事情更少的分支,并且更易于维护。通常,它包含一个单一的主分支和许多功能分支,这些分支最终与主分支合并。主分支始终具有准备部署到环境中的软件。您在主分支中标记和版本化代码,选择并部署它,测试它,然后将其推广到更高的环境中。以下图详细展示了 GitHub flow:

图 2.13 – GitHub flow
请注意,您可以根据自己的需求和适用情况自由创建自己的分支策略。
提示
如果你有一个庞大的团队、大型单体仓库,并且有多个并行发布版本,可以选择 Gitflow。如果你在一个快速发展的组织中工作,每周发布几次更新,并且没有并行发布的概念,可以选择 GitHub flow。GitHub flow 通常适用于微服务,在这种情况下,变更较小且快速。
通常,应用程序仓库不需要过多担心环境问题;它们可以更多地专注于创建可部署的软件版本。
环境仓库
环境仓库存储运行应用程序代码所需的环境特定配置。因此,它们通常会包含基础设施即代码(IaC),如 Terraform 脚本、Ansible 剧本形式的 CaC,或 Kubernetes 清单,这些通常有助于部署我们从应用程序仓库构建的代码。
环境仓库应遵循特定环境的分支策略,其中每个分支代表一个特定的环境。对于这些场景,您可以使用基于拉取请求的门控。通常,您会从开发分支构建开发环境,然后提出拉取请求将更改合并到暂存分支。从暂存分支到生产分支,您的代码随着环境的变化而推进。如果有 10 个环境,您可能会在环境仓库中有 10 个不同的分支。以下图示展示了您可能希望遵循的环境仓库分支策略:

图 2.14 – 环境仓库
环境仓库旨在作为您环境的唯一可信来源。您添加到仓库中的配置将直接应用于您的环境。
提示
尽管可以将环境和应用程序仓库合并为一个,但最佳实践是将它们分开。GitOps 通过分别使用应用程序仓库和环境仓库,为 CI 和 CD 过程提供了明确的分离。
现在我们已经详细介绍了 Git 和 GitOps,接下来我们来看看为什么 Git 和 GitOps 是相关但不同的概念。
Git 与 GitOps
下表总结了 Git 和 GitOps 之间的区别:
| Git | GitOps | |
|---|---|---|
| 定义 | Git 是一种分布式版本控制系统,用于跟踪源代码和其他文件的更改。它允许多个开发者高效地协作和管理代码修订。 | GitOps 是一组实践和原则,通过将 Git 作为管理和自动化基础设施和应用程序部署与操作的唯一可信来源。 |
| 重点 | 主要集中于源代码的版本控制和协作。 | 专注于通过基于 Git 的 DevOps 工作流自动化和管理基础设施和应用程序的部署与操作。 |
| 用法 | 在软件开发项目中广泛用于版本控制和协作。开发者使用 Git 来跟踪变更、管理分支和合并代码。 | 用于声明性定义和管理基础设施和应用程序配置。Git 存储库作为定义所需状态和驱动自动化的中心枢纽。 |
| 核心组件 | 存储库、分支、提交和拉取请求。 | Git 存储库、声明性配置文件(如 YAML)、Kubernetes 清单、CI/CD 流水线和部署工具如 Argo CD 或 Flux。 |
| 工作流程 | 开发者克隆、修改、提交和推送更改到远程存储库。他们通过拉取请求和分支合并进行协作。 | 基础设施和应用配置存储在 Git 存储库中。对这些配置的更改会触发自动化流程,如 CI/CD 流水线或协调循环,以将这些更改应用到目标环境中。 |
| 好处 | 为软件开发团队提供高效的版本控制、协作和代码管理。 | 促进基础设施和应用程序的代码、配置版本控制和声明式管理。简化基础设施部署,提供一致性,并启用自动化工作流程。 |
| 关注领域 | 源代码管理。 | 基础设施和应用程序部署与管理。 |
| 示例 | GitHub、Bitbucket、GitLab。 | Argo CD、Flux、Jenkins X、Weave Flux。 |
请记住,虽然 Git 是一个版本控制系统,但 GitOps 通过利用 Git 作为基础设施和应用程序配置的中心真实来源,允许自动化部署和管理 DevOps 工作流程。
总结
本章涵盖了 Git、GitOps、我们为什么需要它、其原则以及各种 GitOps 部署。我们还研究了可以创建来实现 GitOps 的不同类型的存储库,以及每种存储库的分支策略选择。
你现在应该能够做到以下几点:
-
理解什么是源代码管理及其在现代 DevOps 中的必要性
-
创建一个 Git 存储库并尝试使用
clone、add、commit、push、pull、branch和checkout命令进行操作 -
理解 GitOps 是什么以及它如何适应现代 DevOps 的背景
-
理解为什么我们需要 GitOps 以及它如何实现现代 DevOps
-
理解 GitOps 的显著原则
-
理解如何使用有效的分支策略来实现基于组织结构和产品类型的 GitOps
在下一章中,我们将深入理解容器并探讨 Docker。
问题
回答以下问题以测试你对本章的理解:
-
以下关于 Git 的说法哪些是正确的?(选择三个)
-
这是一个分布式 SCM 平台
-
这是一个集中式 SCM 平台
-
它允许多个开发者协作
-
它具有提交和分支
-
-
在 Git 术语中,Git checkout 是什么意思?
-
从远程同步代码到本地
-
从一个分支切换到另一个分支
-
审查并批准拉取请求
-
-
在 GitOps 中,什么构成了唯一的真理来源
-
Git 仓库
-
存储在数据存储中的配置
-
秘密管理系统
-
人工制品库
-
-
以下哪些选项是 GitOps 的部署模型?(选择两个)
-
推送模型
-
拉取模型
-
错开模型
-
-
是否应该在环境仓库中使用 Gitflow?
-
对于具有多个并行开发和多个版本发布的单体应用,最适合的 Git 分支策略是什么?
-
Gitflow
-
GitHub 流
-
混合 GitHub 流
-
-
哪种是 GitOps 推荐的部署模型?
-
推送模型
-
拉取模型
-
错开模型
-
答案
-
A,C,D
-
B
-
A
-
A,B
-
否
-
A
-
B
第三章:使用 Docker 容器化
在上一章中,我们讨论了 Git 的源代码管理,在那里我们进行了 Git 的速成学习,然后探讨了 GitOps 以及它如何塑造现代 DevOps 实践。
在本章中,我们将亲自操作并探索 Docker —— 当今最常用的容器运行时。到本章结束时,你应该能够安装并配置 Docker,运行你的第一个容器,并进行监控。本章内容也将为后续章节奠定基础,因为我们将在后面的演示中使用相同的设置。
本章我们将涵盖以下主要内容:
-
安装工具
-
安装 Docker
-
介绍 Docker 存储驱动程序和卷
-
运行你的第一个容器
-
Docker 日志记录与日志驱动
-
使用 Prometheus 进行 Docker 监控
-
使用 Docker Compose 进行声明式容器管理
技术要求
本章内容需要一台运行 Ubuntu 18.04 Bionic LTS 或更高版本的 Linux 机器,并且具有 sudo 权限。我们将在本书中使用 Ubuntu 22.04 Jammy Jellyfish,但你也可以自由选择任何操作系统。我将提供其他安装说明的链接。
你还需要克隆以下 GitHub 仓库以进行一些练习:github.com/PacktPublishing/Modern-DevOps-Practices-2e。
我们在上一章中详细讨论了 Git;因此,你可以轻松地利用这些知识克隆仓库。现在,让我们继续安装 Docker。
安装 Docker
我们将在 Ubuntu 系统上安装 Docker。对于其他操作系统,请参考 docs.docker.com/engine/install/。
要安装 Docker,我们需要安装支持工具,允许 apt 包管理器通过 HTTPS 下载 Docker。我们使用以下命令来完成此操作:
$ sudo apt-get update
$ sudo apt-get install -y ca-certificates curl gnupg
下载 Docker gpg 密钥并将其添加到 apt 包管理器:
$ sudo install -m 0755 -d /etc/apt/keyrings
$ curl -fsSL https://download.docker.com/linux/ubuntu/gpg | \
sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
$ sudo chmod a+r /etc/apt/keyrings/docker.gpg
然后,你需要将 Docker 仓库添加到你的 apt 配置中,以便从那里下载包:
$ echo \
"deb [arch="$(dpkg --print-architecture)" \
signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/ubuntu \
"$(. /etc/os-release && echo "$VERSION_CODENAME")" \
stable" | sudo tee /etc/apt/sources.list.d/docker.list \
> /dev/null
最后,通过以下命令安装 Docker 引擎:
$ sudo apt-get update
$ sudo apt-get -y install docker-ce docker-ce-cli \
containerd.io docker-buildx-plugin docker-compose-plugin
要验证 Docker 是否安装成功,请运行以下命令:
$ sudo docker --version
你应该看到类似以下的输出:
Docker version 24.0.2, build cb74dfc
接下来,你需要做的事是允许普通用户使用 Docker。你希望你的用户在构建和运行容器时不是以 root 用户身份进行操作。为此,请运行以下命令:
$ sudo usermod -a -G docker <username>
要使更改应用到你的配置文件中,请从虚拟机退出并重新登录。
现在 Docker 已经在你的机器上完全设置好,让我们运行一个 hello-world 容器来验证这一点:
$ docker run hello-world
你应该看到以下输出:
Unable to find image 'hello-world:latest' locally
latest: Pulling from library/hello-world
719385e32844: Pull complete
Digest: sha256:fc6cf906cbfa013e80938cdf0bb199fbdbb86d6e3e013783e5a766f50f5dbce0
Status: Downloaded newer image for hello-world:latest
Hello from Docker!
你还会看到以下信息,告诉你在后台发生了什么,为什么会在屏幕上显示 Hello from Docker! 消息:
This message shows that your installation appears to be working correctly.
To generate this message, Docker took the following steps:
1\. The Docker client contacted the Docker daemon.
2\. The Docker daemon pulled the hello-world image from Docker Hub.(amd64).
3\. The Docker daemon created a new container from that image that runs the executable
that produces the output you are currently reading.
4\. The Docker daemon streamed that output to the Docker client, which sent it to your
Terminal:
To try something more ambitious, you can run an Ubuntu container with:
$ docker run -it ubuntu bash
Share images, automate workflows, and more with a free Docker ID:
https://hub.docker.com/
For more examples and ideas, visit:
https://docs.docker.com/get-started/
所有这些有用的信息是不言自明的。简单解释一下 Docker Hub,它是一个公共的 Docker 容器注册表,托管着许多供像你我这样的用户使用的 Docker 镜像。
由于 Docker 使用分层架构,大多数 Docker 镜像都是从一个或多个托管在 Docker Hub 上的基础镜像派生出来的。因此,请为自己创建一个 Docker Hub 账户,以便托管容器并与世界分享。
大多数组织可能希望保持镜像的私密性,因此你可以选择在 Docker Hub 中创建私有仓库。你还可以使用 Google 容器注册表 (GCR) 等 SaaS 服务托管自己的内部 Docker 注册表,或者安装如 Sonatype Nexus 或 JFrog Artifactory 这样的工件仓库。不管你选择哪种工具,机制和工作原理始终是相同的。
介绍 Docker 存储驱动和卷
Docker 容器是短暂的工作负载。你在容器文件系统上存储的任何数据,在容器消失后都会被清除。数据在容器生命周期内存储在磁盘上,但不会超出其生命周期。实际上,大多数应用程序都是有状态的。它们需要在容器生命周期之外存储数据,并希望数据能够持久化。
那么,我们该如何进行呢?Docker 提供了几种存储数据的方式。默认情况下,所有数据都存储在可写容器层上,而该层是临时的。可写容器层通过存储驱动与主机文件系统交互。由于存在抽象层,写入容器层的文件比直接写入主机文件系统要慢。
为了解决这个问题并提供持久存储,Docker 提供了卷、绑定挂载和 tmpfs。通过这些,你可以直接与主机文件系统(以及在 tmpfs 的情况下与内存)交互,并节省大量的 每秒 I/O 操作 (IOPS),从而提高性能。尽管本节侧重于容器文件系统的存储驱动,但讨论 Docker 中的多种数据存储选项也很有价值,以便提供背景信息。
Docker 数据存储选项
每个选项都有其使用场景和权衡。让我们来看一下每个选项以及应该在哪些地方使用它们。
卷
Docker 卷将数据直接存储在主机的文件系统中。它们不使用中间的存储驱动层,因此写入卷的速度更快。它们是持久化数据的最佳方式。Docker 将卷存储在 /var/lib/docker/volumes 中,并假设除了 Docker 守护进程外,没有人可以修改它们上的数据。
因此,卷提供以下功能:
-
提供与主机文件系统的某些隔离。如果你不希望其他进程与数据交互,那么卷应该是你的选择。
-
你可以与多个容器共享一个卷。
-
卷可以是命名的或匿名的。Docker 将匿名卷存储在一个具有唯一随机名称的目录中。
-
数据卷使你能够将数据远程存储或通过云提供商使用数据卷驱动进行存储。如果多个容器共享同一个数据卷以提供多实例的主动-主动配置,这非常有帮助。
-
即使容器被删除,数据卷中的数据也会持久存在。
现在,让我们来看一下另一种存储选项——绑定挂载。
绑定挂载
绑定挂载与数据卷非常相似,但有一个显著的区别:它允许你将现有的主机目录作为容器的文件系统进行挂载。这使你能够与 Docker 容器共享重要文件,例如/etc/resolv.conf。
绑定挂载也允许多个进程与 Docker 一起修改数据。因此,如果你将容器数据与另一个不在 Docker 中运行的应用程序共享,绑定挂载是最佳选择。
tmpfs 挂载
tmpfs 挂载将数据存储在内存中。它们不会在磁盘上存储任何数据——无论是容器还是主机文件系统。你可以使用它们来存储敏感信息和容器生命周期中的非持久状态。
挂载数据卷
如果你将一个已经包含文件的主机目录挂载到容器的空数据卷上,容器就可以看到主机中存储的文件。这是为容器预填充文件的绝佳方式。然而,如果该目录在主机文件系统中不存在,Docker 会自动创建该目录。如果数据卷非空且主机文件系统中已经包含文件,Docker 将遮蔽该挂载。这意味着,在 Docker 卷挂载到该目录时,你不会看到原始文件,但文件并没有被删除,你可以通过卸载 Docker 卷来恢复它们。
我们将在下一部分讨论 Docker 存储驱动。
Docker 存储驱动
存储驱动类型有很多种。以下是一些最受欢迎的存储驱动:
-
overlay2:这是一个适用于生产环境的驱动,是 Docker 推荐的存储选择。它适用于大多数环境。 -
devicemapper:这是 RHEL 和 CentOS 7 及以下版本的设备驱动,这些版本不支持overlay2。如果你的容器中有写密集型操作,你可以使用此驱动。 -
btrfs和zfs:这些驱动程序是写密集型的,提供许多功能,例如允许快照,并且仅在主机使用btrfs或zfs文件系统时才可以使用。 -
vfs:此存储驱动仅应在没有写时复制文件系统的情况下使用。它非常慢,不建议在生产环境中使用。
让我们集中讨论两种最受欢迎的驱动——overlay2 和 devicemapper。
overlay2
overlay2是大多数操作系统中默认和推荐的存储驱动,除了 RHEL 7、CentOS 7 及更早版本。它们使用基于文件的存储,并在读取操作多于写入操作时表现最佳。
devicemapper
devicemapper 是基于块的存储,当写操作比读操作多时,它的表现最佳。虽然它与 CentOS 7、RHEL 7 及以下版本兼容,并且是默认存储驱动(因为它们不支持 overlay2),但在这些支持 overlay2 的新版本操作系统中,当前不推荐使用它。
提示
尽可能使用 overlay2,但如果你有特定的用例不适合使用它(比如容器写操作过于频繁),那么 devicemapper 是一个更好的选择。
配置存储驱动
在本讨论中,我们将配置 overlay2 作为存储驱动。虽然它是默认配置,如果你跟随本书的内容,你可以跳过这一步,但如果你想将其更改为其他驱动,还是值得了解一下。
首先,让我们列出当前的存储驱动:
$ docker info | grep 'Storage Driver'
Storage Driver: overlay2
我们可以看到当前的存储驱动已经是 overlay2。如果我们需要更改为 devicemapper,下面我们来看如何操作。
使用你选择的编辑器编辑 /etc/docker/daemon.json 文件。如果你使用 vim,可以运行以下命令:
$ sudo vim /etc/docker/daemon.json
将 storage-driver 条目添加到 daemon.json 配置文件中:
{
"storage-driver": "devicemapper"
}
然后,重启 Docker 服务:
$ sudo systemctl restart docker
检查 Docker 服务的状态:
$ sudo systemctl status docker
现在,重新运行 docker info 查看我们得到的结果:
$ docker info | grep 'Storage Driver'
Storage Driver: devicemapper
WARNING: The devicemapper storage-driver is deprecated, and will be removed in a future
release.
Refer to the documentation for more information: https://docs.docker.com/go/
storage-driver/
WARNING: devicemapper: usage of loopback devices is strongly discouraged for production
use.
Use `--storage-opt dm.thinpooldev` to specify a custom block storage device.
这里,我们可以看到 devicemapper 存储驱动。我们还看到几个警告,表示 devicemapper 存储驱动已被弃用,并将在未来的版本中移除。
因此,除非有特定需求,否则我们应该坚持使用默认配置。
所以,让我们撤销之前的更改,并再次将存储驱动设置为 overlay2:
$ sudo vim /etc/docker/daemon.json
在 daemon.json 配置文件中,将 storage-driver 条目修改为 overlay2:
{
"storage-driver": "overlay2"
}
然后,重启 Docker 服务并检查其状态:
$ sudo systemctl restart docker
$ sudo systemctl status docker
如果你重新运行 docker info,你将看到存储驱动变为 overlay2,所有警告也将消失:
$ docker info | grep 'Storage Driver'
Storage Driver: overlay2
提示
更改存储驱动会清除磁盘上现有的容器,因此在进行此操作时请小心,并确保在生产环境中安排适当的停机时间。如果你这样做,你还需要重新拉取镜像,因为本地镜像将不再存在。
现在我们已经在机器上安装了 Docker 并配置了正确的存储驱动,是时候运行我们的第一个容器了。
运行你的第一个容器
你可以通过 Docker 容器镜像来创建 Docker 容器。虽然我们将在后续章节中讨论容器镜像及其架构,但一个很好的形象化方式是将其视为包含所有文件、应用程序库和依赖项的副本,这些内容构成了你的应用环境,类似于虚拟机镜像。
要运行 Docker 容器,我们可以使用 docker run 命令,它的结构如下:
$ docker run [OPTIONS] IMAGE[:TAG|@DIGEST] [COMMAND] [ARG...]
让我们通过一些实际示例来看一下 docker run 命令及其变种。
在最简单的形式下,你只需输入以下命令来使用 docker run:
$ docker run hello-world
Unable to find image 'hello-world:latest' locally
latest: Pulling from library/hello-world
0e03bdcc26d7: Pull complete
Digest: sha256:e7c70bb24b462baa86c102610182e3efcb12a04854e8c582
838d92970a09f323
Status: Downloaded newer image for hello-world:latest
Hello from Docker!
...
如你所记得,我们在安装 Docker 时使用了这个命令。在这里,我有意省略了tag、options、command和arguments。我们将通过多个示例来展示它的实际使用场景。
由于我们没有提供tag,Docker 自动假设tag为latest,因此,如果你查看命令输出,你将看到 Docker 正在从 Docker Hub 拉取hello-world:latest镜像。
现在,让我们来看一个带有特定版本标签的示例。
从有版本标记的镜像运行容器
我们可以使用以下命令运行nginx:1.18.0:
$ docker run nginx:1.18.0
Unable to find image 'nginx:1.18.0' locally
1.18.0: Pulling from library/nginx
852e50cd189d: Pull complete
48b8657f2521: Pull complete
b4f4d57f1a55: Pull complete
d8fbe49a7d55: Pull complete
04e4a40fabc9: Pull complete
Digest: sha256:2104430ec73de095df553d0c7c2593813e01716a48d66f
85a3dc439e050919b3
Status: Downloaded newer image for nginx:1.18.0
/docker-entrypoint.sh: /docker-entrypoint.d/ is not empty, will attempt to perform
configuration
/docker-entrypoint.sh: Looking for shell scripts in /docker-entrypoint.d/
/docker-entrypoint.sh: Launching /docker-entrypoint.d/10-
listen-on-ipv6-by-default.sh
10-listen-on-ipv6-by-default.sh: Getting the checksum of /etc/nginx/conf.d/default.conf
10-listen-on-ipv6-by-default.sh: Enabled listen on IPv6 in /etc/nginx/conf.d/default.conf
/docker-entrypoint.sh: Launching /docker-entrypoint.d/20-
envsubst-on-templates.sh
/docker-entrypoint.sh: Configuration complete; ready for
start up
注意,提示符在此后将会卡住。这是有原因的:nginx是一个长期运行的进程,也就是守护进程。由于 NGINX 是一个需要持续监听 HTTP 请求的 Web 服务器,所以它应该永远不会停止。而hello-world应用程序的唯一任务是打印消息并退出。NGINX 的用途完全不同。
现在,没人会为一个 Web 服务器保持打开的 Bash 会话,所以必须有某种方法将其运行在后台。你可以通过分离模式来运行容器。我们将在下一节中详细讨论这一点。
在后台运行 Docker 容器
要将 Docker 容器作为守护进程在后台运行,你可以使用带有-d标志的docker run命令:
$ docker run -d nginx:1.18.0
beb5dfd529c9f001539c555a18e7b76ad5d73b95dc48e8a35aecd7471ea938fc
如你所见,它仅打印一个随机 ID 并将控制权返回到 shell。
容器故障排除
要查看容器内发生的情况,你可以使用docker logs命令。但在使用之前,我们需要知道容器的 ID 或名称,以便查看容器的日志。
要获取主机中正在运行的容器列表,请运行以下命令
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
beb5dfd529c9 nginx:1.18.0 "/docker- 2 minutes ago Up 2 minutes 80/tcp fervent_
entrypoint.…" shockley
上面的命令列出了我们刚刚启动的 NGINX 容器。除非你为容器指定了特定的名称,否则 Docker 会分配一个随机名称。在这种情况下,它将容器命名为fervent_shockley。它还会为每个容器分配一个唯一的容器 ID,例如beb5dfd529c9。
你可以使用容器 ID 或容器名称与容器进行交互,以列出日志。我们这次使用容器 ID:
$ docker logs beb5dfd529c9
/docker-entrypoint.sh: /docker-entrypoint.d/ is not empty, will attempt to perform
configuration
...
/docker-entrypoint.sh: Configuration complete; ready for start up
如你所见,它打印出了与我们在前台运行时相似的日志输出。
实际上,除非你需要用 BusyBox 调试某些问题,否则你 90%的时间都会使用docker logs。BusyBox 是一个轻量级的 Shell 容器,可以帮助你排查和调试容器的问题——主要是网络问题。
让我们让 BusyBox 为我们回显Hello World!:
$ docker run busybox echo 'Hello World!'
Unable to find image 'busybox:latest' locally
latest: Pulling from library/busybox
325d69979d33: Pull complete
Digest: sha256:560af6915bfc8d7630e50e212e08242d37b63bd5c1ccf9bd4acccf116e262d5b
Status: Downloaded newer image for busybox:latest
Hello World!
如我们所见,Docker 从 Docker Hub 拉取了最新的busybox镜像并运行了echo 'Hello World'命令。
你还可以通过使用-it标志在交互模式下使用 BusyBox,这将帮助你在 BusyBox shell 上运行一系列命令。最好还加上--rm标志,告诉 Docker 在我们退出 shell 后清理容器,像这样:
$ docker run -it --rm busybox /bin/sh
/ # echo 'Hello world!'
Hello world!
/ # wget http://example.com
Connecting to example.com (93.184.216.34:80)
saving to 'index.html'
index.html 100% |***********************************
****| 1256 0:00:00 ETA
'index.html' saved
/ # exit
在列出所有容器时,我们没有看到 busybox 容器:
$ docker ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
beb5dfd529c9 nginx: "/docker- 17 minutes Up 17 80/tcp fervent_
1.18.0 entrypoint.…" ago minutes shockley
你可以使用各种其他标志来配置容器,每个标志都有特定的用途。让我们看几个常见的。
综合起来
高可用 NGINX 容器的最佳设置应类似于以下内容:
$ docker run -d --name nginx --restart unless-stopped \
-p 80:80 --memory 1000M --memory-reservation 250M nginx:1.18.0
让我们更详细地看看这个:
-
-d:以守护进程模式在分离模式下运行。 -
--name nginx:为容器命名为nginx。 -
--restart unless-stopped:除非手动停止,否则始终在失败时自动重启,并且在 Docker 守护进程启动时自动启动。其他选项包括no、on_failure和always。 -
-p 80:80:将主机端口80转发到容器端口80。这允许你将容器暴露到主机网络。 -
--memory 1000M:将容器的内存消耗限制为1000M。如果内存超出此限制,容器会停止,并根据--restart标志进行处理。 -
--memory-reservation 250M:为容器分配一个软限制250M内存,如果服务器内存不足时使用。
在接下来的部分中,我们将深入探讨其他标志,并进行更多实操。
提示
考虑使用 unless-stopped 代替 always,因为它允许在需要进行维护时手动停止容器。
现在,让我们列出容器,看看我们得到什么:
$ docker ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
06fc749371b7 nginx "/docker- 17 seconds Up 16 0.0.0.0: nginx
entrypoint.…" ago seconds 80->80/tcp
beb5dfd529c9 nginx: "/docker- 22 minutes Up 22 80/tcp fervent_shockley
1.18.0 entrypoint.…" ago minutes
如果仔细观察,你会看到一个名为 nginx 的容器,并且有一个从 0.0.0.0:80 -> 80 的端口转发。
现在,让我们在主机的 localhost:80 上使用 curl 来查看结果:
$ curl localhost:80
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
...
</html>
我们得到了 NGINX 欢迎信息。这意味着 NGINX 正常运行,我们可以从机器上访问它。如果你已将机器的端口 80 暴露到外部世界,你也可以通过浏览器访问它,如下所示:

图 3.1 – NGINX 欢迎页面
你可能还希望偶尔重启或删除容器。我们将在下一节中介绍如何做到这一点。
重启和删除容器
要重启容器,你必须先停止它们,然后再启动它们。
要停止容器,请运行以下命令:
$ docker stop nginx
要启动容器,请运行以下命令:
$ docker start nginx
如果你想完全删除容器,首先需要停止容器,然后使用以下命令删除它:
$ docker stop nginx && docker rm nginx
或者,你可以使用以下命令一次性完成:
$ docker rm -f nginx
现在,让我们看看如何使用 journald 和 Splunk 等工具监控容器。
Docker 日志和日志驱动程序
Docker 不仅改变了应用程序的部署方式,还改变了日志管理的工作流。容器不再将日志写入文件,而是将日志写入控制台(stdout/stderr)。然后,Docker 使用日志驱动程序将容器日志导出到指定的目标位置。
容器日志管理
日志管理是 Docker 中的一个重要功能,就像任何应用程序一样。然而,由于 Docker 工作负载的临时性质,它变得更加重要,因为当容器被删除或遇到任何问题时,我们可能会丢失文件系统和日志。因此,我们应该使用日志驱动程序将日志导出到特定的位置并存储和持久化它。如果你有日志分析解决方案,那么将日志存储在其中是最好的地方。Docker 通过日志驱动程序支持多个日志目标。让我们来看看。
日志驱动程序
在撰写时,以下日志驱动程序可用:
-
none: 容器没有日志,因此日志不会存储在任何地方。 -
local: 日志以自定义格式本地存储,从而最小化开销。 -
json-file: 日志文件以 JSON 格式存储。这是默认的 Docker 日志驱动程序。 -
syslog: 该驱动程序也使用syslog来存储 Docker 日志。当你将syslog作为默认日志机制时,这个选项是有意义的。 -
journald: 使用journald存储 Docker 日志。你可以使用journald命令行来浏览容器和 Docker 守护进程日志。 -
gelf: 将日志发送到 Graylog 扩展日志格式 (GELF) 端点,如 Graylog 或 Logstash。 -
fluentd: 将日志发送到 Fluentd。 -
awslogs: 将日志发送到 AWS CloudWatch。 -
splunk: 使用 HTTP 事件收集器将日志发送到 Splunk。 -
etwlogs: 将日志发送到 Windows 事件追踪 (ETW) 事件。只能在 Windows 平台上使用。 -
gcplogs: 将日志发送到 Google Cloud Logging。 -
logentries: 将日志发送到 Rapid7 Logentries。
尽管这些选项都可行,我们将重点查看 journald 和 Splunk。journald 是一种本地操作系统服务监控选项,而 Splunk 是最著名的日志分析和监控工具之一。现在,让我们了解如何配置日志驱动程序。
配置日志驱动程序
让我们从查找当前的日志驱动程序开始:
$ docker info | grep "Logging Driver"
Logging Driver: json-file
当前,默认日志驱动程序设置为 json-file。如果我们希望使用 journald 或 Splunk 作为默认日志驱动程序,必须在 daemon.json 文件中配置默认日志驱动程序。
使用你喜欢的编辑器编辑 /etc/docker/daemon.json 文件。如果你使用 vim,运行以下命令:
$ sudo vim /etc/docker/daemon.json
在 daemon.json 配置文件中添加 log-driver 项:
{
"log-driver": "journald"
}
然后,重启 Docker 服务:
$ sudo systemctl restart docker
检查 Docker 服务的状态:
$ sudo systemctl status docker
现在,重新运行 docker info 查看我们得到的结果:
$ docker info | grep "Logging Driver"
Logging Driver: journald
现在,journald 是默认的日志驱动程序,让我们启动一个新的 NGINX 容器并查看日志:
$ docker run --name nginx-journald -d nginx
66d50cc11178b0dcdb66b114ccf4aa2186b510eb1fdb1e19d563566d2e96140c
现在,让我们查看 journald 日志,看看我们能得到什么:
$ sudo journalctl CONTAINER_NAME=nginx-journald
...
Jun 01 06:11:13 99374c32101c fb8294aece02[10826]: 10-listen-on-ipv6-by-default.sh: info:
Enabled listen on IPv6 in /etc/nginx/conf.d/default.conf
...
Jun 01 06:11:13 99374c32101c fb8294aece02[10826]: 2023/06/01 06:11:13 [notice] 1#1: start
worker process 30
...
我们可以在日志中查看到这些内容。
同样,我们可以配置 Splunk 日志驱动程序将数据发送到 Splunk 进行分析和可视化。让我们来看看。
使用你喜欢的编辑器编辑 /etc/docker/daemon.json 文件。如果你使用 vim,运行以下命令:
$ vim /etc/docker/daemon.json
将 log-driver 条目添加到daemon.json配置文件:
{
"log-driver": "splunk",
"log-opts": {
"splunk-token": "<Splunk HTTP Event Collector token>",
"splunk-url": "<Splunk HTTP(S) url>"
}
}
然后,重启 Docker 服务:
$ sudo systemctl restart docker
检查 Docker 服务的状态:
$ sudo systemctl status docker
现在,重新运行docker info,看看我们得到什么:
$ docker info | grep "Logging Driver"
Logging Driver: splunk
由于 Splunk 现在是默认的日志驱动程序,让我们启动一个新的 NGINX 容器并可视化日志:
$ docker run --name nginx-splunk -d nginx
dedde062feba33f64efd89ef9102c7c93afa854473cda3033745d35d9065c9e5
登录到你的 Splunk 实例;你将看到 Docker 日志正在流式传输。然后,你可以分析日志并从中创建可视化。
你还可以为不同的容器配置不同的日志驱动程序,方法是通过命令行传递log-driver和log-opts标志来覆盖默认设置。由于我们当前的配置是 Splunk,并且我们希望将数据导出到 JSON 文件,因此可以在运行容器时将log-driver指定为json-file。让我们来看一下:
$ docker run --name nginx-json-file --log-driver json-file -d nginx
379eb8d0162d98614d53ae1c81ea1ad154745f9edbd2f64cffc2279772198bb2
要查看 JSON 日志,我们需要查看 JSON 日志目录——即/var/lib/docker/containers/<container_id>/<container_id>-json.log。
对于nginx-json-file容器,我们可以执行以下操作:
$ cat /var/lib/docker/containers\
/379eb8d0162d98614d53ae1c81ea1ad154745f9edbd2f64cffc2279772198bb2\
/379eb8d0162d98614d53ae1c81ea1ad154745f9edbd2f64cffc2279772198bb2-json.log
{"log":"/docker-entrypoint.sh: /docker-entrypoint.d/ is not
empty, will attempt to perform configuration\n","stream":"
stdout","time":"2022-06-01T06:27:05.922950436Z"}
...
{"log":"/docker-entrypoint.sh: Configuration complete; ready
for start up\n","stream":"stdout","time":"2023-06-01T06:27:
05.937629749Z"}
我们可以看到,日志现在正在流式传输到 JSON 文件,而不是 Splunk。这就是我们如何覆盖默认日志驱动程序的方式。
提示
在大多数情况下,最好坚持使用一个默认的日志驱动程序,这样你就可以在一个地方分析和可视化你的日志。
现在,让我们了解一下与 Docker 日志相关的一些挑战和最佳实践。
Docker 日志的典型挑战以及应对这些挑战的最佳实践:
Docker 允许你在单台机器或一组机器上运行多个应用程序。大多数组织同时运行虚拟机和容器,并且已经配置了日志记录和监控堆栈以支持虚拟机。
大多数团队在让 Docker 日志像虚拟机日志一样工作时遇到困难。因此,大多数团队会将日志发送到主机文件系统,然后日志分析解决方案从中获取数据。这并不是理想的做法,你应该避免犯这个错误。如果你的容器是静态的,这种做法可能有效,但如果你有一组运行 Docker 的服务器集群,并且可以将容器调度到任意虚拟机上,那么就会成为一个问题。
从日志的角度来看,将容器视为运行在虚拟机上的应用程序是一个错误。相反,你应该将容器视为一个实体——就像虚拟机一样。你绝不应该将容器与虚拟机关联在一起。
一种解决方案是使用日志驱动程序将日志直接转发到日志分析解决方案中。但是,日志记录就会严重依赖于日志分析解决方案的可用性。所以,这可能不是最好的做法。当 Docker 上的服务因为日志分析解决方案不可用或网络问题而宕机时,很多人都遇到过问题。
好吧,解决这个问题的最佳方法是使用 JSON 文件暂时存储虚拟机中的日志,并使用另一个容器将日志以传统方式推送到你选择的日志分析解决方案。这样,你就将应用程序的运行与外部服务的依赖解耦了。
你可以使用日志驱动程序直接将日志导出到日志转发容器中的日志分析解决方案。有许多日志驱动程序支持多个日志目标。始终以这样的方式标记日志,使容器作为独立实体出现。这将使容器与虚拟机解耦,之后你就可以充分利用分布式容器架构。
到目前为止,我们已经看过容器的日志方面,但 DevOps 工程师角色的一个重要元素是监控。我们将在下一节中详细探讨这一点。
使用 Prometheus 进行 Docker 监控
监控 Docker 节点和容器是管理 Docker 的关键部分。有许多工具可以用于监控 Docker。虽然你可以使用传统工具,如 Nagios,但 Prometheus 在云原生监控中逐渐崭露头角,因为它简单且具有可插拔的架构。
Prometheus 是一个免费的开源监控工具,它提供了维度数据模型、使用 Prometheus 查询语言(PromQL)的高效且简单的查询、有效的时间序列数据库和现代的告警功能。
它提供了多个导出器,可从各种来源导出数据,支持虚拟机和容器。在深入细节之前,让我们看看容器监控的一些挑战。
容器监控的挑战
从概念上讲,容器监控与传统方法没有区别。你仍然需要指标、日志、健康检查和服务发现。这些并不是未知的或没有被探索过的东西。容器面临的问题是它们带来的抽象;让我们来看一下其中的一些问题:
-
容器表现得像迷你虚拟机;然而,实际上,它们是运行在服务器上的进程。但它们仍然有许多需要监控的内容,就像我们在虚拟机中一样。容器进程会有许多指标,类似于虚拟机,需要作为独立实体来处理。处理容器时,大多数人会犯一个错误,那就是把容器映射到特定的虚拟机。
-
容器是临时的,大多数人没有意识到这一点。当你有一个容器并且它被重建时,它会有一个新的 IP 地址。这可能会混淆传统的监控系统。
-
运行在集群中的容器可以从一个节点(服务器)移动到另一个节点。这增加了一个复杂性层,因为您的监控工具需要知道容器的位置,以便从中抓取指标。使用现代的、容器优化的工具,这不应该成为问题。
Prometheus 帮助我们解决这些挑战,因为它是从分布式应用程序的角度构建的。为了理解这一点,我们将通过一个实践示例来进行说明。但是,在此之前,让我们在另一台 Ubuntu 22.04 Linux 机器上安装 Prometheus。
安装 Prometheus
安装 Prometheus 包括几个步骤,为了简化,我创建了一个 Bash 脚本,用于在 Ubuntu 机器上安装和设置 Prometheus。
在您想要设置 Prometheus 的另一台机器上使用以下命令:
$ git clone https://github.com/PacktPublishing/Modern-DevOps-Practices-2e.git \
modern-devops
$ cd modern-devops/ch3/prometheus/
$ sudo bash prometheus_setup.sh
要检查 Prometheus 是否已安装并正在运行,可以使用以下命令检查 Prometheus 服务的状态:
$ sudo systemctl status prometheus
prometheus.service – Prometheus
Loaded: loaded (/etc/systemd/system/prometheus.service; enabled; vendor preset:
enabled)
Active: active (running) since Tue 2023-06-01 09:26:57 UTC; 1min 22s ago
由于服务处于活动状态,我们可以得出结论,Prometheus 已成功安装并正在运行。下一步是配置 Docker 服务器,以便 Prometheus 能够从中收集日志。
配置 cAdvisor 和节点出口程序以暴露指标
现在,我们将在运行 Docker 的机器上启动一个 cAdvisor 容器,以暴露 Docker 容器的指标。cAdvisor 是一个指标收集器,用于从容器中抓取指标。要启动容器,请使用以下命令:
$ docker run -d --restart always --name cadvisor -p 8080:8080 \
-v "/:/rootfs:ro" -v "/var/run:/var/run:rw" -v "/sys:/sys:ro" \
-v "/var/lib/docker/:/var/lib/docker:ro" google/cadvisor:latest
现在 cAdvisor 正在运行,我们需要配置节点导出器,以便在 Docker 机器上导出节点指标。为此,运行以下命令:
$ cd ~/modern-devops/ch3/prometheus/
$ sudo bash node_exporter_setup.sh
现在节点导出器正在运行,让我们配置 Prometheus 以连接到 cAdvisor 和节点导出器,并从那里抓取指标。
配置 Prometheus 以抓取指标
我们现在将在 Prometheus 机器上配置 Prometheus,以便它可以从 cAdvisor 抓取指标。为此,修改 /etc/prometheus/prometheus.yml 文件,使其在运行 Prometheus 的服务器中包含以下内容:
$ sudo vim /etc/prometheus/prometheus.yml
...
- job_name: 'node_exporter'
scrape_interval: 5s
static_configs:
- targets: ['localhost:9100', '<Docker_IP>:9100']
- job_name: 'Docker Containers'
static_configs:
- targets: ['<Docker_IP>:8080']
更改此配置后,我们需要重新启动 Prometheus 服务。使用以下命令执行此操作:
$ sudo systemctl restart prometheus
现在,让我们启动一个示例 Web 应用程序,我们将使用 Prometheus 来监控它。
启动一个示例容器应用程序
现在,让我们运行一个名为 web 的 NGINX 容器,该容器在 Docker 机器上的 8081 端口上运行。为此,请使用以下命令:
$ docker run -d --name web -p 8081:80 nginx
f9b613d6bdf3d6aee0cb3a08cb55c99a7c4821341b058d8757579b52cabbb0f5
现在我们已经设置好 Docker 容器,让我们通过访问 https://<PROMETHEUS_SERVER_EXTERNAL_IP>:9090 打开 Prometheus UI,然后在文本框中输入以下查询来运行它:
container_memory_usage_bytes{name=~"web"}
它应该显示类似以下内容:

图 3.2 – Prometheus – container_memory_usage_bytes
我们还可以通过点击图表标签页来查看此指标的时间序列。然而,在此之前,让我们使用 Apache Bench 工具加载我们的 NGINX 服务。Apache Bench 是一款负载测试工具,帮助我们通过命令行向 NGINX 端点发送 HTTP 请求。
在你的 Docker 服务器上,运行以下命令来启动负载测试:
$ ab -n 100000 http://localhost:8081/
它将向端点发送 100,000 个请求,这意味着它提供了相当大的负载来进行内存突增。现在,如果你打开图表标签页,你应该会看到类似以下内容:

图 3.3 – Prometheus – container_memory_usage_bytes – 图表
要可视化节点指标,我们可以使用以下 PromQL 语句来获取 Docker 主机的 node_cpu 值:
node_cpu{instance="<Docker_IP>:9100",job="node_exporter"}
如下图所示,它将为我们提供多种模式的 node_cpu 指标:

图 3.4 – Prometheus – node_cpu
Prometheus 还提供了多种其他可视化的指标。让我们来了解一些你可以监控的指标。
需要监控的指标
监控指标是一个复杂的话题,主要取决于你的使用场景。不过,以下是一些关于你需要监控的指标的指南。
主机指标
你需要监控你的主机指标,因为你的容器是在它们上面运行的。你可以监控的一些指标如下:
-
主机 CPU:了解你的主机是否有足够的 CPU 来运行容器非常重要。如果没有,它可能会终止一些容器来弥补这一点。因此,为了确保可靠性,你需要时刻监控这一点。
-
主机内存:像监控主机 CPU 一样,你需要监控主机内存,以便发现内存泄漏或内存异常等问题。
-
主机磁盘空间:由于 Docker 容器使用主机文件系统来存储临时和持久化文件,因此你需要监控它。
Docker 容器指标
Docker 容器指标是下一个需要考虑的事项:
-
容器 CPU:此指标将提供 Docker 容器使用的 CPU 数量。你应该监控它,以了解容器的使用模式,并决定如何有效地放置容器。
-
限速 CPU 时间:此指标帮助我们了解容器 CPU 被限速的总时间。这可以让我们知道是否某个特定的容器比其他容器需要更多的 CPU 时间,从而可以相应地调整 CPU 份额限制。
-
容器内存失败计数器:此指标提供容器请求的内存超过分配内存的次数。这将帮助你了解哪些容器需要更多的内存,从而可以根据需要调整容器运行。
-
容器内存使用量:此指标将提供 Docker 容器使用的内存量。你可以根据使用情况设置内存限制。
-
容器交换:这个指标会告诉你哪些容器使用了交换空间而不是内存。它帮助我们识别占用内存较多的容器。
-
容器磁盘 I/O:这是一个重要的指标,帮助我们了解容器的磁盘使用情况。峰值可能表明磁盘存在瓶颈,或者提示你可能需要重新审视存储驱动程序的配置。
-
容器网络指标:这个指标将告诉我们容器使用了多少网络带宽,并帮助我们理解流量模式。你可以使用这些指标来检测意外的网络高峰或拒绝服务攻击。
重要提示
在非生产环境的性能测试阶段对你的应用程序进行性能分析,将给你一个系统在生产环境中表现的粗略概念。应用程序的实际调优工作开始于你将其部署到生产环境时。因此,监控至关重要,调优是一个持续的过程。
到目前为止,我们一直在运行命令来完成大部分工作。这是命令式的做法。但如果我告诉你,你不需要输入命令,而是可以声明你想要的内容,接着某个工具就能代表你执行所有需要的命令呢?这就是所谓的声明式应用程序管理方法。Docker Compose 是实现这一目标的流行工具之一。我们将在下一节中对此进行详细了解。
使用 Docker Compose 进行声明式容器管理
Docker Compose 帮助你以声明的方式管理多个容器。你可以创建一个 YAML 文件,指定你想构建的内容、要运行的容器以及容器之间如何交互。你可以在 YAML 文件中定义挂载、网络、端口映射以及许多不同的配置。
之后,你只需运行docker compose up来启动整个容器化应用程序。
声明式管理因其强大和简便正在迅速获得认可。现在,系统管理员不再需要记住他们运行过的命令,也不需要编写冗长的脚本或剧本来管理容器。相反,他们只需在 YAML 文件中声明自己想要的内容,然后docker compose或其他工具可以帮助他们实现这一状态。我们在安装 Docker 时也安装了 Docker Compose,接下来我们将通过示例应用程序来实际操作。
使用 Docker Compose 部署示例应用程序
我们有一个 Python Flask 应用程序,监听端口5000,最终我们将其映射到主机端口80。该应用程序将作为后端服务连接到 Redis 数据库,使用默认端口6379,并获取页面的最后访问时间。我们不会将该端口暴露给主机系统。这意味着数据库对于任何能够访问应用程序的外部方来说完全是不可触及的。
以下图表展示了应用程序架构:

图 3.5 – 示例应用程序
必要的文件可以在本书的 GitHub 仓库中找到。运行以下命令来定位文件:
$ git clone https://github.com/PacktPublishing/Modern-DevOps-Practices-2e.git \
modern-devops
$ cd modern-devops/ch3/docker-compose
$ ls -l
total 16
-rw-r--r-- 1 root root 681 Nov 25 06:11 app.py
-rw-r--r-- 1 root root 389 Nov 25 06:45 docker-compose.yaml
-rw-r--r-- 1 root root 238 Nov 25 05:27 Dockerfile
-rw-r--r-- 1 root root 12 Nov 25 05:26 requirements.txt
app.py 文件如下所示:
import time
import redis
from flask import Flask
from datetime import datetime
app = Flask(__name__)
cache = redis.Redis(host='redis', port=6379)
def get_last_visited():
try:
last_visited = cache.getset('last_visited',str(datetime.now().strftime("%Y-%m-%d,
%H:%M:%S")))
if last_visited is None:
return cache.getset('last_visited',str(datetime.now().strftime("%Y-%m-%d,
%H:%M:%S")))
return last_visited
except redis.exceptions.ConnectionError as e:
raise e
@app.route('/')
def index():
last_visited = str(get_last_visited().decode('utf-8'))
return 'Hi there! This page was last visited on {}.\n'.format(last_visited)
requirements.txt 文件如下所示:
flask
redis
我已经为你构建了应用程序,镜像已上传到 Docker Hub。下一章将详细介绍如何构建 Docker 镜像。现在,让我们先看一下 docker-compose 文件。
创建 docker-compose 文件
过程的下一步是创建一个 docker-compose 文件。docker-compose 文件是一个 YAML 文件,其中包含服务、网络、卷和其他相关配置的列表。让我们看一下以下示例 docker-compose.yaml 文件,以便更好地理解:
version: "2.4"
services:
flask:
image: "bharamicrosystems/python-flask-redis:latest"
ports:
- "80:5000"
networks:
- flask-app-net
redis:
image: "redis:alpine"
networks:
- flask-app-net
command: ["redis-server", "--appendonly", "yes"]
volumes:
- redis-data:/data
networks:
flask-app-net:
driver: bridge
volumes:
redis-data:
YAML 文件描述了两个服务——flask 和 redis。
flask 服务使用 python-flask-redis:latest 镜像——这是我们使用前述代码构建的镜像。它还将主机端口 80 映射到容器端口 5000,将此应用程序暴露给主机机器的端口 80,你可以通过 http://localhost 访问它。
redis 服务使用官方的 redis:alpine 镜像,并且不暴露任何端口,因为我们不希望此服务位于容器网络的外部。然而,它声明了一个持久化卷 redis-data,该卷包含 /data 目录。我们可以将此卷挂载到主机文件系统中,以便在容器生命周期之外保持数据持久性。
还有一个使用桥接驱动的 flask-app-net 网络,两个服务共享同一个网络。这意味着这些服务可以通过服务名称相互调用。如果你查看 app.py 代码,你会看到我们使用 redis 主机名建立了与 Redis 服务的连接。
要应用配置,只需运行 docker-compose up -d:
$ docker compose up -d
[+] Running 17/17
flask 9 layers [] 0B/0B Pulled 10.3s
redis 6 layers [] 0B/0B Pulled 9.1s
[+] Building 0.0s (0/0)
[+] Running 4/4
Network docker-compose_flask-app-net Created 0.1s
Volume "docker-compose_redis-data" Created 0.0s
Container docker-compose-flask-1 Started 3.8s
Container docker-compose-redis-1 Stated
现在,让我们列出 Docker 容器,看看情况如何:
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
9151e72f5d66 redis: "docker- 3 minutes Up 3 6379/tcp docker-compose
alphone entrypoint.s…" ago minutes -redis-1
9332c2aaf2c4 bharamicrosystems "flask run" 3 minutes Up 3 0.0.0.0:80-> docker-compose
/python-flask- ago minutes 5000/tcp, -flask-1
redis:latest :::80->5000/tcp
我们可以看到两个容器正在为这两个服务运行。我们还可以看到主机端口 80 将连接转发到 flask 服务的容器端口 5000。
redis 服务是内部的,因此没有端口映射。
让我们执行 curl localhost 看看我们得到什么:
$ curl localhost
Hi there! This page was last visited on 2023-06-01, 06:54:27.
在这里,我们根据示例 Flask 应用程序代码从 Redis 缓存中获取最后访问的页面。
让我们运行几次,看看时间是否会变化:
$ curl localhost
Hi there! This page was last visited on 2023-06-01, 06:54:28.
$ curl localhost
Hi there! This page was last visited on 2023-06-01, 06:54:51.
$ curl localhost
Hi there! This page was last visited on 2023-06-01, 06:54:52.
我们可以看到每次执行 curl 时,最后访问时间都会变化。由于卷是持久的,即使容器重新启动,我们应该能得到类似的最后访问时间。
首先,让我们执行 curl,获取最后访问时间以及当前日期:
$ curl localhost && date
Hi there! This page was last visited on 2023-06-01, 06:54:53.
Thu Jun 1 06:55:50 UTC 2023
现在,下次执行 curl 时,我们应该得到类似 2023-06-01, 06:55:50 的日期时间。但在此之前,让我们重新启动容器,看看数据是否持久:
$ docker compose restart redis
[+] Restarting 1/1
Container docker-compose-redis-1 Started
现在 Redis 已重新启动,让我们再次运行 curl:
$ curl localhost
Hi there! This page was last visited on 2023-06-01, 06:55:50.
如我们所见,即使重新启动 redis 服务,我们也能获取到正确的最后访问时间。这意味着数据持久性工作正常,卷已经正确挂载。
你可以在 docker compose 上进行许多其他配置,这些内容可以从官方文档中轻松获取。然而,你现在应该对如何使用 docker compose 及其优点有一个大致的了解。接下来,让我们看看一些与 Docker Compose 相关的最佳实践。
Docker Compose 最佳实践
Docker Compose 提供了一种声明式的方式来管理 Docker 容器配置。这使得 GitOps 成为可能,适用于你的 Docker 工作负载。虽然 Docker Compose 主要用于开发环境,但你可以在生产环境中非常有效地使用它,特别是当 Docker 在生产环境中运行,并且没有使用像 Kubernetes 这样的容器编排工具时。
始终与代码一起使用 docker-compose.yml 文件
YAML 文件定义了如何运行你的容器。因此,它成为一个非常有价值的工具,可以从一个地方声明性地构建和部署你的容器。你可以将所有依赖项添加到你的应用程序中,并在同一个网络中运行相关的应用程序。
使用覆盖文件来分隔多个环境的 YAML 文件
Docker Compose YAML 文件允许我们同时构建和部署 Docker 镜像。Docker 实现了 一次构建,随处运行 的概念。这意味着我们在开发环境中构建一次,然后在随后的环境中使用创建的镜像。因此,问题就来了:我们如何实现这一点呢?Docker Compose 允许我们按顺序应用多个 YAML 文件,其中下一个配置会覆盖上一个配置。这样,我们就可以为不同的环境创建单独的覆盖文件,并使用这些文件来管理多个环境。
例如,假设我们有以下基础的 docker-compose.yaml 文件:
version: "2.4"
services:
flask:
image: "bharamicrosystems/python-flask-redis:latest"
ports:
- "80:5000"
networks:
- flask-app-net
redis:
image: "redis:alpine"
networks:
- flask-app-net
command: ["redis-server", "--appendonly", "yes"]
volumes:
- redis-data:/data
networks:
flask-app-net:
driver: bridge
volumes:
redis-data:
我们只需要在开发环境中构建 Flask 应用程序容器镜像,以便为开发环境创建一个覆盖文件——即 docker-compose.override.yaml:
web:
build: .
environment:
DEBUG: 'true'
redis:
ports:
- 6379:6379
在这里,我们在 web 服务中添加了一个 build 参数。这意味着 Python Flask 应用程序将被重新构建并部署。我们还在 web 服务中设置了 DEBUG 环境变量,并将 redis 端口暴露到宿主文件系统。这在开发环境中是有意义的,因为我们可能希望直接从开发机器调试 Redis。不过,我们不希望在生产环境中出现这种情况。因此,默认的 docker-compose.yaml 文件将在生产环境中工作,正如我们在上一节中所看到的那样。
使用 .env 文件存储敏感变量
你可能不想将密码和机密等敏感内容存储在版本控制中。相反,你可以使用一个 .env 文件,里面包含变量名和值的列表,并将其保存在像 HashiCorp Vault 这样的机密管理系统中。
在生产环境中要注意依赖关系
当你更改某个容器并希望重新部署时,docker-compose 也会重新部署所有依赖项。不过,这可能并不是你想要的行为,因此你可以通过使用以下命令来覆盖这一行为:
$ docker-compose up --no-deps -d <container_service_name>
将 docker-compose 文件视为代码
永远对你的 docker-compose 文件进行版本控制,并将其与代码一起保存。这将允许你跟踪文件版本,并使用 Git 特性,如拉取请求(pull request)。
总结
本章旨在同时满足初学者和有经验的用户需求。我们从介绍 Docker 的基础概念开始,逐步深入到更高级的主题和实际应用场景。本章首先介绍了 Docker 的安装,运行第一个 Docker 容器,理解不同的容器运行模式,了解 Docker 卷和存储驱动程序。我们还学习了如何选择合适的存储驱动程序、卷选项以及一些最佳实践。所有这些技能将帮助你轻松搭建一个生产就绪的 Docker 服务器。我们还讨论了日志代理,如何将 Docker 日志快速发送到多个目的地,如 journald、Splunk 和 JSON 文件,帮助你监控容器。我们还探讨了如何使用 Docker Compose 声明性地管理 Docker 容器,并部署了一个完整的复合容器应用。请尝试本章中提到的所有命令,进行更多的实践体验——实践是实现有价值的成果和学习新知识的关键。
下一步,在接下来的章节中,我们将研究 Docker 镜像,创建和管理它们,以及一些最佳实践。
问题
回答以下问题,测试你对本章内容的掌握:
-
对于 CentOS 和 RHEL 7 及以下版本,应该使用
overlay2。(正确/错误) -
以下哪个陈述是正确的?(选择四个)
A. 卷增加 IOPS。
B. 卷减少 IOPS。
C.
tmpfs挂载使用系统内存。D. 你可以使用绑定挂载将主机文件挂载到容器中。
E. 你可以使用卷挂载进行多实例的活动-活动配置。
-
更改存储驱动会删除主机上的现有容器。(正确/错误)
-
devicemapper是比overlay2更适合写密集型容器的选项。(正确/错误) -
Docker 支持以下哪些日志驱动?(选择四个)
A.
journaldB. Splunk
C. JSON 文件
D. Syslog
E. Logstash
-
Docker Compose 是一种管理容器的命令式方法。(正确/错误)
-
以下哪些
docker run配置是正确的?(选择三个)A.
dockerrun nginxB.
docker run --namenginx nginx:1.17.3C.
docker run -d --namenginx nginxD.
docker run -d --name nginx nginx --``restart never
答案
以下是本章问题的答案:
-
错误 – 对于 CentOS 和 RHEL 7 及以下版本,应该使用
devicemapper,因为它们不支持overlay2。 -
B、C、D、E。
-
正确。
-
正确。
-
A、B、C、D。
-
错误 – Docker Compose 是一种声明式的容器管理方法。
-
A, B, C.
第四章:创建和管理容器镜像
在上一章中,我们讲解了如何使用 Docker 进行容器化,安装了 Docker 并运行了我们的第一个容器。我们涵盖了一些核心基础知识,包括 Docker 卷、挂载、存储驱动程序和日志驱动程序。我们还介绍了 Docker Compose 作为一种声明性管理容器的方法。
现在,我们将讨论容器的核心构建模块:容器镜像。容器镜像还实现了现代 DevOps 实践的一个核心原则:配置即代码(config as code)。因此,理解容器镜像、它们是如何工作的以及如何有效地构建镜像,对于现代 DevOps 工程师来说非常重要。
在本章中,我们将涵盖以下主要主题:
-
Docker 架构
-
理解 Docker 镜像
-
理解 Dockerfiles、组件和指令
-
构建和管理 Docker 镜像
-
扁平化 Docker 镜像
-
使用无发行版镜像优化容器
-
理解 Docker 仓库
技术要求
对于本章,我们假设你已经在一台运行 Ubuntu 18.04 Bionic LTS 或更高版本的 Linux 机器上安装了 Docker,并且具有 sudo 权限。你可以阅读 第三章,使用 Docker 进行容器化,了解更多关于如何实现这一点的细节。
你还需要克隆一个 GitHub 仓库,用于本章的一些练习,仓库地址是 github.com/PacktPublishing/Modern-DevOps-Practices-2e。此外,大多数活动需要你有一个 Docker Hub 账户。要创建一个,请访问 hub.docker.com/。
Docker 架构
想象一下,你是一位充满热情的厨师,致力于创造令人垂涎的菜肴,以满足饥饿的顾客。在你的厨房里,这个神奇的地方叫做 Docker,你拥有特殊的能力来规划、制作并展示你的烹饪创作。让我们来拆解其中的关键部分:
食材(应用代码和依赖项):想象你的厨房里有一排架子,上面摆满了食材,比如面粉、鸡蛋和香料。这些食材以特定的方式结合在一起,做成一道菜。同样,你的应用代码和依赖项也需要协同工作,才能构建出你的应用程序。
食谱(镜像):每个食谱就像是某道菜的计划。想象一下你有一个巧克力蛋糕或卡邦尼意面的食谱。这些食谱就像是你创作的构建模块。同样,Docker 镜像就是制作 Docker 容器的计划。
食谱卡(Dockerfile):你的烹饪旅程中涉及使用特别的食谱卡,这些卡片被称为 Dockerfiles。这些卡片展示了你需要遵循的重要步骤和食材(命令)。例如,一个巧克力蛋糕的 Dockerfile 可能包括“混合面粉和糖”或“加入鸡蛋和可可粉”这样的步骤。这些 Dockerfiles 引导你的助手(Docker)制作这道菜(容器)。
做好的菜肴(容器):当有人想要一份菜肴时,你用食谱(镜像)来做它。然后,你就有了一道新鲜热乎的菜肴,准备好上桌。这些菜肴是独立的,但它们可以一次又一次地被做出来(多亏了食谱),就像容器一样。
厨房助手(Docker Engine):在你忙碌的厨房里,你的助手(Docker Engine)发挥着重要作用。他们做了繁重的工作,从获取食材到按照食谱做菜并上菜。你给他们指令(Docker 命令),他们就会把事情做成。他们甚至会在做完每道菜后帮你清理。
特制套餐菜单(Docker Compose):有时,你想提供一道包含多种菜肴的特别套餐,它们相互搭配非常好。想象一下,一顿包含前菜、主菜和甜点的餐食。使用 Docker Compose 就像为这个场合制作一个特别的菜单。它列出了每道菜的食谱(镜像)以及它们应该如何搭配。你甚至可以自定义它,只用一个命令就能创造出一整顿饭的体验。
存储区域(Volumes):在厨房里,你需要一个地方来存放食材和餐具。把 Docker 卷想象成特殊的存储区域,你可以在这里保存重要的东西,如数据和文件,多个菜肴(容器)都可以使用这些存储。
通信通道(Networks):你的厨房是个热闹的地方,充满了交谈和互动。在 Docker 中,网络就像是特殊的通信路径,帮助你厨房中的不同部分(容器)相互交流。
所以,Docker 就像你的神奇厨房,你可以使用计划(Dockerfiles)和食材(镜像),在厨房助手(Docker Engine)的帮助下制作菜肴(容器)。你甚至可以提供整套套餐(Docker Compose),并使用特殊的存储区域(Volumes)和通信通道(Networks)来使你的菜肴更加美味。就像大厨通过练习不断进步一样,探索 Docker 将帮助你迅速成为 DevOps 的高手!现在,让我们深入了解 Docker 架构,理解其中的细节!
正如我们已经知道的,Docker 使用 一次构建,到处运行 的概念。Docker 将应用打包成镜像,Docker 镜像形成容器的蓝图,因此容器就是镜像的一个实例。
容器镜像打包了应用程序及其依赖项,因此它们是一个可以在任何运行 Docker 的机器上运行的单一不可变单元。你也可以将它们视为容器的快照。
我们可以在 Docker 注册中心中构建和存储 Docker 镜像,例如 Docker Hub,然后将这些镜像下载到我们希望部署它们的系统中。镜像由多个层组成,这有助于将镜像拆分成多个部分。层通常是可重用的阶段,其他镜像可以在此基础上构建。这也意味着我们在更改镜像时不必传输整个镜像,而只需传输差异部分,这大大节省了网络 I/O。我们将在本章稍后详细讨论分层文件系统。
以下图示展示了 Docker 用于协调以下活动的组件:

图 4.1 – Docker 架构
组件包括:
-
Docker 守护进程:该进程运行在我们希望运行容器的服务器上。它们在 Docker 服务器上部署和运行容器。
-
Docker 仓库:这些用于存储和分发 Docker 镜像。
-
向 Docker 守护进程发送
docker命令。
现在我们了解了 Docker 架构的关键组件,以及 Docker 镜像在其中的重要作用,让我们更详细地理解 Docker 镜像及其组件、指令和仓库。
理解 Docker 镜像
Docker 镜像构成了 Docker 容器的蓝图。就像你需要为运输集装箱设计一个蓝图来确定其大小以及它将包含哪些货物一样,Docker 镜像指定了需要使用的包、源代码、依赖项和库。它还决定了源代码如何运行才能有效。
从技术上讲,它由一系列步骤构成,这些步骤是在基础操作系统镜像上执行的,以便让你的应用程序正常运行。这可能包括安装软件包和依赖项、将源代码复制到正确的文件夹、构建代码生成二进制文件等等。
你可以将 Docker 镜像存储在容器仓库中,这是一个集中存储位置,Docker 主机可以从这里拉取镜像来创建容器。
Docker 镜像使用分层文件系统。我们不再使用一个庞大的、单一的文件系统块作为运行容器的模板,而是有许多层,叠加在一起。那么这意味着什么?解决了什么问题?让我们在下一部分中看看。
分层文件系统
Docker 中的层是中间的 Docker 镜像。其理念是我们对每个 Dockerfile 语句执行时,都会在上一层之上进行更改并构建一个新层。随后的语句会修改当前层,生成下一个层。最终的层会执行 Docker 的 CMD 或 ENTRYPOINT 命令,结果镜像由多个层组成,一个层叠在另一个层之上。让我们通过一个简单的例子来理解这一点。
如果我们拉取上章构建的 Flask 应用程序,我们将看到以下内容:
$ docker pull bharamicrosystems/python-flask-redis
Using default tag: latest
latest: Pulling from bharamicrosystems/python-flask-redis
188c0c94c7c5: Pull complete
a2f4f20ac898: Pull complete
f8a5b284ee96: Pull complete
28e9c106bfa8: Pull complete
8fe1e74827bf: Pull complete
95618753462e: Pull complete
03392bfaa2ba: Pull complete
4de3b61e85ea: Pull complete
266ad40b3bdb: Pull complete
Digest: sha256:bb40a44422b8a7fea483a775fe985d4e05f7e5c59b0806a2
4f6cca50edadb824
Status: Downloaded newer image for bharamicrosystems/python-flask-redis:latest
docker.io/bharamicrosystems/python-flask-redis:latest
如你所见,许多 Pull complete 语句旁边都有随机 ID。这些被称为 层。当前层仅包含与上一层和当前层文件系统之间的差异。一个容器镜像由多个层组成。
容器在镜像层之上包含一个额外的可写文件系统。这是容器修改文件系统以提供预期功能的层。
使用层次结构而不是简单地复制整个容器文件系统有几个优点。由于镜像层是只读的,从一个镜像创建的多个容器共享相同的层次文件系统,从而减少了总体磁盘和网络占用。层次结构还允许你在镜像之间共享文件系统。例如,如果两个镜像来自同一个基础镜像,它们就共享相同的基础层。
下图展示了一个在 Ubuntu 操作系统上运行的 Python 应用程序。从高层次来看,你会看到一个基础层(Ubuntu 操作系统)和安装在其上的 Python。在 Python 上面,我们安装了 Python 应用程序。所有这些组件共同构成了镜像。当我们从镜像创建容器并运行它时,我们得到的是位于最上层的可写文件系统:

图 4.2 – 容器层
所以,你可以从相同的基础镜像创建多个 Python 应用镜像,并根据需要进行定制。
每个从容器镜像创建的容器都有独特的可写文件系统,即使你从相同的镜像创建容器也是如此。
镜像历史
要理解镜像及其层次结构,你可以随时查看镜像历史。
让我们通过运行以下命令检查最后一个 Docker 镜像的历史:
$ docker history bharamicrosystems/python-flask-redis
IMAGE CREATED CREATED BY SIZE COMMENT
6d33489ce4d9 2 years ago /bin/sh -c #(nop) CMD ["flask" "run"] 0B
<missing> 2 years ago /bin/sh -c #(nop) COPY dir:61bb30c35fb351598… 1.2kB
<missing> 2 years ago /bin/sh -c #(nop) EXPOSE 5000 0B
<missing> 2 years ago /bin/sh -c pip install -r requirements.txt 11.2MB
<missing> 2 years ago /bin/sh -c #(nop) COPY file:4346cf08412270cb… 12B
<missing> 2 years ago /bin/sh -c apk add --no-cache gcc musl-dev l… 143MB
<missing> 2 years ago /bin/sh -c #(nop) ENV FLASK_RUN_HOST=0.0.0.0 0B
<missing> 2 years ago /bin/sh -c #(nop) ENV FLASK_APP=app.py 0B
<missing> 2 years ago /bin/sh -c #(nop) CMD ["python3"] 0B
<missing> 2 years ago /bin/sh -c set -ex; wget -O get-pip.py "$P… 7.24MB
<missing> 2 years ago /bin/sh -c #(nop) ENV PYTHON_GET_PIP_SHA256… 0B
<missing> 2 years ago /bin/sh -c #(nop) ENV PYTHON_GET_PIP_URL=ht… 0B
<missing> 2 years ago /bin/sh -c #(nop) ENV PYTHON_PIP_VERSION=20… 0B
<missing> 2 years ago /bin/sh -c cd /usr/local/bin && ln -s idle3… 32B
<missing> 2 years ago /bin/sh -c set -ex && apk add --no-cache --… 28.3MB
<missing> 2 years ago /bin/sh -c #(nop) ENV PYTHON_VERSION=3.7.9 0B
<missing> 2 years ago /bin/sh -c #(nop) ENV GPG_KEY=0D96DF4D4110E… 0B
<missing> 2 years ago /bin/sh -c set -eux; apk add --no-cache c… 512kB
<missing> 2 years ago /bin/sh -c #(nop) ENV LANG=C.UTF-8 0B
<missing> 2 years ago /bin/sh -c #(nop) ENV PATH=/usr/local/bin:/… 0B
<missing> 2 years ago /bin/sh -c #(nop) CMD ["/bin/sh"] 0B
<missing> 2 years ago /bin/sh -c #(nop) ADD file:f17f65714f703db90… 5.57MB
如你所见,存在多个层次,每个层次都有相应的命令。你还可以看到这些层次何时创建以及每层占用的磁盘空间大小。有些层并不占用磁盘空间,因为它们没有对文件系统做任何更改,比如 CMD 和 EXPOSE 指令。它们执行某些功能,但不会向文件系统写入任何内容。而像 apk add 这样的命令会写入文件系统,你可以看到它们占用了磁盘空间。
每一层都会以某种方式修改旧的层,因此每一层只是文件系统配置的增量。
在下一节中,我们将深入研究 Dockerfile,了解如何构建 Docker 镜像并查看其层次结构是怎样的。
理解 Dockerfile、组件和指令
Dockerfile 是一个简单的文件,它包含了一系列构建 Docker 镜像的步骤。每个步骤称为 指令。有不同类型的指令。让我们通过一个简单的例子来理解它是如何工作的。
我们将从零开始构建一个简单的 NGINX 容器,而不是使用 Docker Hub 上现成的镜像。NGINX 是一种非常流行的 Web 服务器软件,适用于各种应用场景;例如,它可以作为负载均衡器或反向代理服务器。
从创建一个 Dockerfile 开始:
$ vim Dockerfile
FROM ubuntu:bionic
RUN apt update && apt install -y curl
RUN apt update && apt install -y nginx
CMD ["nginx", "-g", "daemon off;"]
让我们逐行分析每个指令,理解这个 Dockerfile 是如何工作的:
-
FROM指令指定了此容器的基础镜像。这意味着我们使用另一个镜像作为基础,并将在其上构建层。我们使用ubuntu:bionic包作为此次构建的基础镜像,因为我们想在 Ubuntu 上运行 NGINX。 -
RUN指令指定了在特定层上需要执行的命令。你可以通过&&分隔多个命令。如果我们想将依赖命令放入同一层中,可以在一行中运行多个命令。每个层应达到特定的目标。在前面的例子中,第一个RUN指令用于安装curl,而下一个RUN指令用于安装nginx。 -
你可能会想知道为什么每次安装之前都要运行
apt update。这是必需的,因为 Docker 构建镜像是通过层来实现的。因此,一个层不应该隐性依赖于前一个层。在这个例子中,如果在安装nginx时省略了apt update,并且我们想在不更改包含apt update指令(即安装curl的那一行)的情况下更新nginx版本,那么当我们运行构建时,apt update不会再运行,导致你的nginx安装可能会失败。 -
CMD指令指定了当构建的镜像作为容器运行时需要执行的一组命令。这是默认执行的命令,它的输出将记录在容器日志中。你的容器可以包含一个或多个CMD指令。对于像 NGINX 这样的长时间运行的进程,最后一个CMD应该包含一些不会将控制权交还给 shell 并且能够持续运行至容器生命周期结束的命令。在这种情况下,我们运行nginx -g daemon off;,这是一种标准的方式来在前台运行 NGINX。
一些指令很容易混淆,比如ENTRYPOINT和CMD,或者CMD和RUN。这些也能考察你对 Docker 基础的掌握程度,所以我们来看看这两者的区别。
我们可以用ENTRYPOINT替代CMD吗?
你可以用ENTRYPOINT替代CMD。虽然它们服务的目的是类似的,但它们是两个完全不同的指令。每个 Docker 容器都有一个默认的ENTRYPOINT——/bin/sh -c。你在CMD中添加的内容会被追加到ENTRYPOINT之后并执行;例如,CMD ["nginx", "-g", "daemon off;"]将被生成为/bin/sh -c nginx -g daemon off;。如果你使用自定义的ENTRYPOINT,那么在启动容器时使用的命令将被追加到它之后。因此,如果你定义了ENTRYPOINT ["nginx", "-g"],并使用docker run nginx daemon off;,你将得到类似的结果。
为了在启动容器时不添加任何CMD参数,您也可以使用ENTRYPOINT ["nginx", "-g", "daemon off;"]来获得类似的效果。
提示
除非有特定的CMD需求,否则应该使用ENTRYPOINT。使用ENTRYPOINT可以确保用户无法更改容器的默认行为,因此它是一种更安全的替代方案。
现在,让我们来看一下 RUN 与 CMD 的区别。
RUN 和 CMD 是一样的吗?
不,RUN 和 CMD 是不同的,它们有不同的用途。RUN 用于构建容器,仅在构建过程中修改文件系统,而 CMD 指令仅在容器运行后在可写容器层中执行。
虽然一个 Dockerfile 中可以有多个 RUN 语句,每个语句修改现有层并生成下一层,但如果 Dockerfile 中包含多个 CMD 指令,除了最后一个,其他都会被忽略。
RUN 指令用于在容器文件系统内执行语句来构建和定制容器镜像,从而修改镜像层。使用 CMD 指令的目的是为容器镜像提供默认的命令,这些命令将在运行时执行。这只会改变可写的容器文件系统。你还可以通过在 docker run 语句中传递自定义命令来覆盖这些命令。
现在,让我们开始构建我们的第一个容器镜像。
构建我们的第一个容器
构建容器镜像非常简单。实际上,它是一个一行命令:docker build -t <image-name>:version <build_context>。虽然我们将在 构建和管理容器镜像 部分详细讨论构建容器镜像,但首先让我们构建 Dockerfile:
$ docker build -t <your_dockerhub_user>/nginx-hello-world .
[+] Building 50.0s (7/7) FINISHED
=> [internal] load .dockerignore 0.0s
=> => transferring context: 2B 0.0s
=> [internal] load build definition from Dockerfile 0.1s
=> => transferring dockerfile: 171B 0.0s
=> [internal] load metadata for docker.io/library/ubuntu:bionic 2.4s
=> [1/3] FROM docker.io/library/ubuntu:bionic@sha256:152dc042… 2.8s
=> => resolve docker.io/library/ubuntu:bionic@sha256:152dc04… 0.0s
=> => sha256:152dc042… 1.33kB / 1.33kB 0.0s
=> => sha256:dca176c9… 424B / 424B 0.0s
=> => sha256:f9a80a55… 2.30kB / 2.30kB 0.0s
=> => sha256:7c457f21… 25.69MB / 25.69MB 1.0s
=> => extracting sha256:7c457f21… 1.6s
=> [2/3] RUN apt update && apt install -y curl 22.4s
=> [3/3] RUN apt update && apt install -y nginx 21.6s
=> exporting to image 0.6s
=> => exporting layers 0.6s
=> => writing image sha256:9d34cdda… 0.0s
=> => naming to docker.io/<your_dockerhub_user>/nginx-hello-world
你可能已经注意到,容器的名字前面有一个前缀。那是你的 Docker Hub 账户名。镜像的名字结构是 <registry-url>/<account-name>/<container-image-name>:<version>。
这里,我们有以下内容:
-
registry-url:Docker 注册表的 URL,默认为docker.io -
account-name:拥有该镜像的用户或账户 -
container-image-name:容器镜像的名称 -
version:镜像版本
现在,让我们使用以下命令从镜像创建一个容器:
$ docker run -d -p 80:80 <your_dockerhub_user>/nginx-hello-world
092374c4501560e96a13444ce47cb978b961cf8701af311884bfe…
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
092374c45015 <your_dockerhub "nginx -g 28 seconds Up 27 0.0.0.0:80->80/ loving_
_user>/nginx- 'daemon of…" ago seconds tcp, :::80->80/tcp noether
hello-world
在这里,我们可以看到容器已经启动并运行。
如果我们运行 curl localhost,我们会得到默认的 nginx html 响应:
$ curl localhost
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
...
</body>
</html>
太好了!我们已经使用 Dockerfile 构建了我们的第一个镜像。
如果我们想根据需求自定义镜像怎么办?实际上,没有人会想要一个仅响应默认 Welcome to nginx! 消息的 NGINX 容器,因此让我们创建一个索引页面并使用它代替:
$ vim index.html
Hello World! This is my first docker image!
这个输出一个自定义消息,而不是默认的 NGINX HTML 页面。
我们都知道,默认的 NGINX 目录包含 index.html 文件,路径是 /var/www/html。如果我们可以将 index.html 文件复制到该目录中,应该就能解决我们的问题。
所以,修改 Dockerfile,使其包含以下内容:
$ vim Dockerfile
FROM ubuntu:bionic
RUN apt update && apt install -y curl
RUN apt update && apt install -y nginx
WORKDIR /var/www/html/
ADD index.html ./
CMD ["nginx", "-g", "daemon off;"]
在这里,我们向文件中添加了两个指令:WORKDIR 和 ADD。让我们理解每个指令的作用:
-
WORKDIR:此指令定义当前工作目录,在本例中为/var/www/html。Dockerfile 中的最后一个WORKDIR指令也指定了容器执行时的工作目录。因此,如果你exec进入一个运行中的容器,你将进入最后定义的WORKDIR。WORKDIR可以是绝对路径,也可以是相对于当前工作目录的相对路径。 -
ADD:此指令将本地文件添加到容器文件系统——在此案例中是工作目录。你也可以使用COPY指令代替ADD,虽然ADD提供了一些额外的功能,比如从 URL 下载文件,或使用 TAR 或 ZIP 等存档包。
当我们构建此文件时,我们期望index.html文件被复制到容器文件系统中的/var/www/html目录。让我们来看一下:
$ docker build -t <your_dockerhub_user>/nginx-hello-world .
[+] Building 1.6s (10/10) FINISHED
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 211B 0.0s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 2B 0.0s
=> [internal] load metadata for docker.io/library/ubuntu:bionic 1.4s
=> [1/5] FROM docker.io/library/ubuntu:bionic@sha256:152dc042… 0.0s
=> [internal] load build context 0.0s
=> => transferring context: 81B 0.0s
=> CACHED [2/5] RUN apt update && apt install -y curl 0.0s
=> CACHED [3/5] RUN apt update && apt install -y nginx 0s
=> [4/5] WORKDIR /var/www/html/ 0.0s
=> [5/5] ADD index.html ./ 0.0s
=> exporting to image 0.0s
=> => exporting layers 0.0s
=> => writing image sha256:cb2e67bd… 0.0s
=> => naming to docker.io/<your_dockerhub_user>/nginx-hello-world
这次,构建速度更快了!当我们执行 Docker 构建时,它使用了大量来自缓存的层。这就是分层架构的优势之一;你只需要构建变化的部分,其他部分则可以直接使用现有的。
提示
安装完包和依赖后,始终添加源代码。源代码经常变动,而包的内容大致保持不变。这将加快构建速度,并节省大量 CI/CD 时间。
让我们重新运行容器,看看会得到什么。请注意,重新运行前需要先删除旧的容器:
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
092374c45015 <your_dockerhub "nginx -g 28 seconds Up 27 0.0.0.0:80->80/ loving_
_user>/nginx- 'daemon of…" ago seconds tcp, :::80->80/tcp noether
hello-world
$ docker rm 092374c45015 -f
092374c45015
此时,我们无法再看到容器了。现在,让我们使用以下命令重新运行容器:
$ docker run -d -p 80:80 <your_dockerhub_user>/nginx-hello-world
cc4fe116a433c505ead816fd64350cb5b25c5f3155bf5eda8cede5a4…
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
cc4fe116a433 <your_dockerhub "nginx -g 52 seconds Up 50 0.0.0.0:80->80/ eager_
_user>/nginx- 'daemon of…" ago seconds tcp, :::80->80/tcp gates
hello-world
在这里,我们可以看到容器正在运行。让我们使用curl localhost看看会得到什么结果:
$ curl localhost
Hello World! This is my first docker image!
在这里,我们看到的是一个自定义消息,而不是默认的 NGINX HTML 响应!
现在这看起来已经足够好了,但我将讨论更多的指令,以使这个镜像更加可靠。首先,我们没有明确记录此容器应暴露的端口。虽然这样做也能正常工作,因为我们知道 NGINX 运行在80端口,但如果有人想使用你的镜像却不知道端口怎么办呢?在这种情况下,最好显式地定义端口。我们将使用EXPOSE指令来实现这一点。
提示
始终使用EXPOSE指令为你的镜像提供更多的清晰度和意义。
我们还需要定义如果有人发送docker stop命令时,容器进程应该采取的动作。虽然大多数进程会接受这个信号并杀死进程,但明确指定容器在接收到docker stop命令时应该发送什么STOPSIGNAL是有意义的。我们将使用STOPSIGNAL指令来实现这一点。
现在,虽然 Docker 监控容器进程并保持其运行,除非接收到SIGTERM信号或停止命令,但如果你的容器进程因为某些原因挂起会发生什么呢?当你的应用程序处于挂起状态时,Docker 仍然认为它在运行,因为你的进程依然在运行。因此,通过显式的健康检查来监控应用程序是有意义的。我们将使用HEALTHCHECK指令来实现这一点。
让我们将这些方面结合起来,看看在 Dockerfile 中会得到什么结果:
$ vim Dockerfile
FROM ubuntu:bionic
RUN apt update && apt install -y curl
RUN apt update && apt install -y nginx
WORKDIR /var/www/html/
ADD index.html ./
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
STOPSIGNAL SIGTERM
HEALTHCHECK --interval=60s --timeout=10s --start-period=20s --retries=3 CMD curl -f
localhost
虽然EXPOSE和STOPSIGNAL不言自明,但我们来看看HEALTHCHECK指令。HEALTHCHECK指令会运行一个名为curl -f localhost的命令(因此是CMD)。所以,在curl命令执行成功之前,这个容器会报告为健康状态。
HEALTHCHECK指令还包含以下可选字段:
-
--interval (默认值: 30s):两次健康检查之间的间隔时间。 -
--timeout (默认值: 30s):健康检查探针超时。如果健康检查超时,则表示健康检查失败。 -
--start-period (默认值: 0s):启动容器和第一次健康检查之间的时间间隔。这样可以确保容器在进行健康检查之前已启动。 -
--retries (默认值: 3):探针在宣布容器不健康之前会重试的次数。
现在,让我们构建这个容器:
$ docker build -t <your_dockerhub_user>/nginx-hello-world .
[+] Building 1.3s (10/10) FINISHED
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 334B 0.0s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 2B 0.0s
=> [internal] load metadata for docker.io/library/ubuntu:bionic 1.2s
=> [1/5] FROM docker.io/library/ubuntu:bionic@sha256:152dc0… 0.0s
=> [internal] load build context 0.0s
=> => transferring context: 31B 0.0s
=> CACHED [2/5] RUN apt update && apt install -y curl 0.0s
=> CACHED [3/5] RUN apt update && apt install -y nginx 0s
=> CACHED [4/5] WORKDIR /var/www/html/ 0.0s
=> CACHED [5/5] ADD index.html ./ 0.0s
=> exporting to image 0.0s
=> => exporting layers 0.0s
=> => writing image sha256:bba3123d… 0.0s
=> => naming to docker.io/<your_dockerhub_user>/nginx-hello-world
是时候运行它,看看结果如何:
$ docker run -d -p 80:80 <your_dockerhub_user>/nginx-hello-world
94cbf3fdd7ff1765c92c81a4d540df3b4dbe1bd9748c91e2ddf565d8…
现在我们已经成功启动了容器,接下来让我们尝试ps命令,看看会得到什么结果:
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
94cbf3fdd7ff <your_dockerhub "nginx -g 5 seconds Up 4 0.0.0.0:80->80/ wonderful_
_user>/nginx- 'daemon of…" ago (health: tcp, :::80->80/tcp hodgkin
hello-world starting)
正如我们所看到的,容器显示health: starting,这意味着健康检查尚未开始,我们正在等待启动时间到期。
让我们等一会儿,然后再试一次docker ps:
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
94cbf3fdd7ff <your_dockerhub "nginx -g 2 minutes Up 2 0.0.0.0:80->80/ wonderful_
_user>/nginx- 'daemon of…" ago (healthy) tcp, :::80->80/tcp hodgkin
hello-world
这次,它报告容器为健康状态。因此,我们的容器现在更可靠了,因为任何监控它的人都会知道应用程序的哪一部分是健康的,哪一部分不是。
这个健康检查只报告容器的健康状态,不会执行其他操作。你需要定期监控容器,并编写脚本来处理不健康的容器。
管理这种情况的一种方法是创建一个脚本,用于检查不健康的容器并重新启动它们。你可以在 crontab 中安排这个脚本。你也可以创建一个长时间运行的systemd脚本,不断轮询容器进程并检查健康状态。
提示
尽管使用HEALTHCHECK是一个不错的选择,但你应该避免在 Kubernetes 或类似的容器编排工具中运行容器时使用它。你应该改为使用存活探针和就绪探针。类似地,如果你使用 Docker Compose,也可以在其中定义健康检查,因此应使用它,而不是将健康检查嵌入容器镜像。
现在,让我们继续学习如何构建和管理 Docker 镜像。
构建和管理 Docker 镜像
在上一节中,我们构建了一些 Docker 镜像,到目前为止,你应该知道如何编写 Dockerfile 并从中创建 Docker 镜像。我们还讨论了一些最佳实践,简而言之,内容如下:
-
总是先添加那些不常更改的层,再添加可能频繁更改的层。例如,先安装你的包和依赖,再复制源代码。Docker 会从你更改的部分开始构建 Dockerfile,直到结束,因此,如果你修改了后面的某一行,Docker 会从缓存中获取所有已有的层。将更常变动的部分放在构建的后面,有助于减少构建时间,从而实现更快的 CI/CD 体验。
-
将多个命令合并为尽可能少的层。避免使用多个连续的
RUN指令。相反,使用&&子句将它们合并成一个RUN指令。这将有助于减少容器的总体体积。 -
仅将必需的文件添加到容器中。如果你已经将代码编译成二进制文件,那么在运行容器时,容器不需要沉重的包管理器和 Go 工具包。我们将在接下来的章节中详细讨论如何做到这一点。
Docker 镜像传统上是通过在 Dockerfile 中指定一系列步骤来构建的。但正如我们所知道的,Docker 是符合 DevOps 的,并从一开始就使用配置管理实践。大多数人会在 Dockerfile 中构建他们的代码。因此,我们也需要在构建上下文中包含编程语言库。通过一个简单的顺序 Dockerfile,这些编程语言工具和库最终会出现在容器镜像中。这些被称为单阶段构建,接下来我们将讨论这一点。
单阶段构建
让我们将一个简单的 Go 应用程序容器化,该程序在屏幕上打印 Hello, World!。虽然在这个应用程序中我使用的是 Golang,但这个概念是普遍适用的,无论使用什么编程语言。
本示例的相关文件位于本书 GitHub 仓库中的 ch4/go-hello-world/single-stage 目录下。
首先,让我们看一下 Go 应用程序文件 app.go:
package main
import "fmt"
func main() {
fmt.Println("Hello, World!")
}
Dockerfile 如下所示:
FROM golang:1.20.5
WORKDIR /tmp
COPY app.go .
RUN GOOS=linux go build -a -installsuffix cgo -o app . && chmod +x ./app
CMD ["./app"]
这是标准做法。我们使用 golang:1.20.5 基础镜像,声明一个 WORKDIR 为 /tmp,从主机文件系统将 app.go 复制到容器中,然后构建 Go 应用程序以生成一个二进制文件。最后,我们使用 CMD 指令来执行生成的二进制文件,当我们运行容器时,该文件将被执行。
让我们构建 Dockerfile:
$ docker build -t <your_dockerhub_user>/go-hello-world:single_stage .
[+] Building 10.3s (9/9) FINISHED
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 189B 0.0s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 2B 0.0s
=> [internal] load metadata for docker.io/library/golang:1.20.5 0.6s
=> [1/4] FROM docker.io/library/golang:1.20.5@sha256:4b1fc02d… 0.0s
=> [internal] load build context 0.0s
=> => transferring context: 27B 0.0s
=> [2/4] WORKDIR /tmp 0.0s
=> [3/4] COPY app.go . 0.0s
=> [4/4] RUN GO111MODULE=off GOOS=linux go build -a -installsuffix cgo -o app . && chmod
+x ./app 9.3s
=> exporting to image 0.3s
=> => exporting layers 0.3s
=> => writing image sha256:3fd3d261… 0.0s
=> => naming to docker.io/<your_dockerhub_user>/go-hello-world:single_stage
现在,让我们运行 Docker 镜像,看看我们能得到什么:
$ docker run <your_dockerhub_user>/go-hello-world:single_stage
Hello, World!
我们得到了预期的响应。现在,让我们运行以下命令来列出镜像:
$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
<your_dockerhub_user>
/go-hello-world single_stage 3fd3d26111a1 3 minutes ago 803MB
这个镜像好大啊!打印 Hello, World! 需要 803 MB。这不是构建 Docker 镜像的最有效方式。
在我们查看解决方案之前,先来了解一下为什么镜像一开始会这么臃肿。我们使用的是 Golang 基础镜像,它包含了完整的 Go 工具包,并生成一个简单的二进制文件。对于这个应用程序的运行,我们并不需要完整的 Go 工具包;它可以在 Alpine Linux 镜像中高效运行。
Docker 通过提供多阶段构建来解决这个问题。你可以将构建过程拆分成多个阶段,在其中一个阶段构建你的代码,然后在第二个阶段将构建好的代码导出到另一个上下文,该上下文从一个更轻的基础镜像开始,只包含运行代码所需的文件和组件。我们将在下一节中详细讨论这个过程。
多阶段构建
让我们按照多阶段构建过程修改 Dockerfile,看看结果如何。
本例的相关文件位于本书 GitHub 仓库中的 ch4/go-hello-world/multi-stage 目录下。
以下是 Dockerfile:
FROM golang:1.20.5 AS build
WORKDIR /tmp
COPY app.go .
RUN GO111MODULE=off GOOS=linux go build -a -installsuffix cgo -o app . && chmod +x ./app
FROM alpine:3.18.0
WORKDIR /tmp
COPY --from=build /tmp/app .
CMD ["./app"]
Dockerfile 包含两个 FROM 指令:FROM golang:1.20.5 AS build 和 FROM alpine:3.18.0。第一个 FROM 指令还包括一个 AS 指令,用于声明阶段并将其命名为 build。在此 FROM 指令之后所做的任何操作都可以通过 build 来访问,直到我们遇到另一个 FROM 指令,这将形成第二个阶段。由于第二个阶段是我们要运行镜像的地方,所以我们没有使用 AS 指令。
在第一阶段,我们使用 golang 基础镜像构建我们的 Golang 代码,生成二进制文件。
在第二阶段,我们使用 Alpine 基础镜像,并将构建阶段中的 /tmp/app 文件复制到当前阶段。这是我们在容器中运行所需的唯一文件。其他文件仅在构建过程中需要,用于在运行时膨胀我们的容器。
让我们构建镜像,看看得到什么:
$ docker build -t <your_dockerhub_user>/go-hello-world:multi_stage
[+] Building 12.9s (13/13) FINISHED
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 259B 0.0s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 2B 0.0s
=> [internal] load metadata for docker.io/library/alpine:3.18.0 2.0s
=> [internal] load metadata for docker.io/library/golang:1.20.5 1.3s
=> [build 1/4] FROM docker.io/library/golang:1.20.5@sha256:4b1fc02d… 0.0s
=> [stage-1 1/3] FROM docker.io/library/alpine:3.18.0@sha256:02bb6f42… 0.1s
=> => resolve docker.io/library/alpine:3.18.0@sha256:02bb6f42… 0.0s
=> => sha256:c0669ef3… 528B / 528B 0.0s
=> => sha256:5e2b554c… 1.47kB / 1.47kB 0.0s
=> => sha256:02bb6f42… 1.64kB / 1.64kB 0.0s
=> CACHED [build 2/4] WORKDIR /tmp 0.0s
=> [internal] load build context 0.0s
=> => transferring context: 108B 0.0s
=> [build 3/4] COPY app.go . 0.0s
=> [build 4/4] RUN GO111MODULE=off GOOS=linux go build -a -installsuffix cgo -o app . &&
chmod +x ./app 10.3s
=> [stage-1 2/3] WORKDIR /tmp 0.1s
=> [stage-1 3/3] COPY --from=build /tmp/app . 0.3s
=> exporting to image 0.1s
=> => exporting layers 0.1s
=> => writing image sha256:e4b793b3… 0.0s
=> => naming to docker.io/<your_dockerhub_user>/go-hello-world:multi_stage
现在,让我们运行容器:
$ docker run <your_dockerhub_user>/go-hello-world:multi_stage .
Hello, World!
我们得到了相同的输出,但这次占用的空间更小。让我们查看镜像以确认这一点:
$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
<your_dockerhub_user>
/go-hello-world multi_stage e4b793b39a8e 5 minutes ago 9.17MB
这个镜像仅占用 9.17 MB,而不是庞大的 803 MB。这是一个巨大的改进!我们已经将镜像大小减少了近 100 倍。
这就是我们在容器镜像中提高效率的方法。构建高效的镜像是运行生产就绪容器的关键,Docker Hub 上的大多数专业镜像都使用多阶段构建来创建高效的镜像。
提示
尽可能使用多阶段构建,以在镜像中包含最小的内容。如果可能,考虑使用 Alpine 基础镜像。
在下一节中,我们将讨论如何管理 Docker 镜像,最佳实践以及一些最常用的命令。
管理 Docker 镜像
在现代 DevOps 实践中,Docker 镜像通常是在开发者机器或 CI/CD 管道上构建的。这些镜像存储在容器注册中心,然后部署到多个预生产环境和生产机器上。这些机器可能运行 Docker 或一个容器编排工具,例如 Kubernetes。
为了高效使用镜像,我们必须理解如何标记它们。
主要情况下,Docker 会在你执行 Docker run 时拉取镜像一次。这意味着,一旦某个版本的镜像已经存在于机器上,Docker 就不会在每次运行时都重新拉取它,除非你显式地执行拉取操作。
要显式拉取镜像,你可以使用 docker pull 命令:
$ docker pull nginx
Using default tag: latest
latest: Pulling from library/nginx
f03b40093957: Pull complete
eed12bbd6494: Pull complete
fa7eb8c8eee8: Pull complete
7ff3b2b12318: Pull complete
0f67c7de5f2c: Pull complete
831f51541d38: Pull complete
Digest: sha256:af296b18…
Status: Downloaded newer image for nginx:latest
docker.io/library/nginx:latest
现在,如果我们尝试使用这个镜像启动容器,它将立即启动容器,而不拉取镜像:
$ docker run nginx
/docker-entrypoint.sh: /docker-entrypoint.d/ is not empty, will attempt to perform
configuration
/docker-entrypoint.sh: Looking for shell scripts in /docker-entrypoint.d/
/docker-entrypoint.sh: Launching /docker-entrypoint.d/10-listen-on-ipv6-by-default.sh
…
2023/06/10 08:09:07 [notice] 1#1: start worker processes
2023/06/10 08:09:07 [notice] 1#1: start worker process 29
2023/06/10 08:09:07 [notice] 1#1: start worker process 30
因此,在镜像上使用最新标签是一个坏主意,最佳实践是使用语义版本作为标签。主要有两个原因:
-
如果每次都构建最新镜像,像 Docker Compose 和 Kubernetes 这样的编排工具会认为镜像已经在你的机器上,并默认不拉取镜像。在 Kubernetes 上使用像
Always这样的镜像拉取策略,或者用脚本拉取镜像,会浪费网络带宽。还需要注意的是,Docker Hub 限制了你对开源镜像的拉取次数,因此你必须将拉取限制为必要时才进行。 -
Docker 标签允许你快速推出或回滚容器部署。如果你总是使用最新标签,新的构建将覆盖旧的构建,因此你无法将故障容器回滚到最后一个已知的良好版本。在生产环境中使用版本化的镜像也是一个好主意,以确保容器的稳定性。如果由于某些原因,你丢失了本地镜像并决定重新运行容器,可能无法获得与你之前运行的相同版本的软件,因为最新标签经常变化。因此,最好在生产环境中使用特定的容器版本以确保稳定性。
镜像由多个层组成,通常,容器的不同版本在你的服务器上是有关系的。随着时间的推移,新的镜像版本会在你的生产环境中推出,因此通过进行一些清理来移除旧镜像是最好的做法。这将回收容器镜像占用的一些宝贵空间,从而使文件系统更加干净。
要移除特定镜像,你可以使用 docker rmi 命令:
$ docker rmi nginx
Error response from daemon: conflict: unable to remove repository reference "nginx" (must
force) - container d5c84356116f is using its referenced image f9c14fe76d50
哦!我们遇到错误了,为什么呢?因为我们有一个正在运行并使用该镜像的容器。
提示
你不能移除当前正在使用的镜像。
首先,你需要停止并移除容器。然后,你可以使用前面的命令移除镜像。如果你想一气呵成,可以通过使用 -f 标志强制移除,这将停止容器,移除容器,并移除镜像。所以,除非你知道自己在做什么,否则不要使用 -f 标志:
$ docker rmi -f nginx
Untagged: nginx:latest
Untagged: nginx@sha256:af296b18…
Deleted: sha256:f9c14fe7…
我们已经构建了容器多次,但如果我们需要将其推送到 Docker Hub 或其他注册中心该怎么办?但在此之前,我们需要通过以下命令对其进行 Docker Hub 身份验证:
$ docker login
现在,你可以使用以下命令将镜像推送到 Docker Hub:
$ docker push <your_dockerhub_user>/nginx-hello-world:latest
The push refers to repository [docker.io/<your_dockerhub_user>/nginx-hello-world]
2b7de406bdcd: Pushed
5f70bf18a086: Pushed
845348333310: Pushed
96a9e6a097c6: Pushed
548a79621a42: Mounted from library/ubuntu
latest: digest: sha256:11ec56f0… size: 1366
这已经推送了四层,并将其余部分从 Ubuntu 挂载。我们使用了 Ubuntu 作为基础镜像,该镜像已经存在于 Docker Hub 上。
如果你有多个镜像标签,并且希望推送所有标签,那么可以在push命令中使用-a或--all-tags选项。这样会将该镜像的所有标签一起推送:
$ docker push -a <your_dockerhub_user>/go-hello-world
The push refers to repository [docker.io/<your_dockerhub_user>/go-hello-world]
9d61dbd763ce: Pushed
5f70bf18a086: Mounted from <your_dockerhub_user>/nginx-hello-world
bb01bd7e32b5: Mounted from library/alpine
multi_stage: digest: sha256:9e1067ca… size: 945
445ef31efc24: Pushed
d810ccdfdc04: Pushed
5f70bf18a086: Layer already exists
70ef08c04fa6: Mounted from library/golang
41cf9ea1d6fd: Mounted from library/golang
d4ebbc3dd11f: Mounted from library/golang
b4b4f5c5ff9f: Mounted from library/golang
b0df24a95c80: Mounted from library/golang
974e52a24adf: Mounted from library/golang
single_stage: digest: sha256:08b5e52b… size: 2209
当构建因为某种原因失败并且你修改了 Dockerfile 时,旧镜像的层可能会保持悬空。因此,定期清理悬空镜像是最佳实践。你可以使用docker images prune来完成这一操作:
$ docker images prune
REPOSITORY TAG IMAGE ID CREATED SIZE
在下一节中,我们将探讨提高 Docker 镜像效率的另一种方法:扁平化 Docker 镜像。
扁平化 Docker 镜像
Docker 本身使用分层文件系统,我们已经深入讨论过它为什么必要以及如何带来好处。然而,在某些特定的使用场景下,Docker 实践者观察到,拥有更少层的 Docker 镜像表现更好。你可以通过扁平化镜像来减少镜像中的层。然而,这仍然不是最佳实践,只有在你看到性能提升的情况下才应这么做,因为这会导致文件系统开销。
扁平化 Docker 镜像的步骤如下:
-
使用常规镜像运行一个 Docker 容器。
-
对正在运行的容器进行
docker export操作,将其导出为.tar文件。 -
对
.tar文件进行docker import操作,将其导入到另一个镜像中。
让我们使用nginx-hello-world镜像进行扁平化并导出到另一个镜像中;也就是说,<your_dockerhub_user>/nginx-hello-world:flat。
在继续之前,让我们查看最新镜像的历史记录:
$ docker history <your_dockerhub_user>/nginx-hello-world:latest
IMAGE CREATED CREATED BY SIZE COMMENT
bba3123dde01 2 hours ago HEALTHCHECK &
{["CMD-SHELL"
"curl -f localhos… 0B buildkit.dockerfile.v0
<missing> 2 hours ago STOPSIGNAL 0B
SIGTERM 0B buildkit.dockerfile.v0
<missing> 2 hours ago CMD ["nginx"
"-g" "daemon off;"] 0B buildkit.dockerfile.v0
<missing> 2 hours ago EXPOSE map[80/
tcp:{}] 0B buildkit.dockerfile.v0
<missing> 2 hours ago ADD index.html ./ #
buildkit 44B buildkit.dockerfile.v0
<missing> 2 hours ago WORKDIR /var/www/
html/ 0B buildkit.dockerfile.v0
<missing> 2 hours ago RUN /bin/sh -c apt
update && apt 57.2MB buildkit.dockerfile.v0
install -y…
<missing> 2 hours ago RUN /bin/sh -c apt
update && apt 59.8MB buildkit.dockerfile.v0
install -y…
<missing> 10 days ago /bin/sh -c #(nop) 0B
CMD ["/bin/bash"]
<missing> 10 days ago /bin/sh -c #(nop) ADD 63.2MB
file:3c74e7e08cbf9a876…
<missing> 10 days ago /bin/sh -c #(nop) LABEL 0B
org.opencontainers.…
<missing> 10 days ago /bin/sh -c #(nop) LABEL 0B
org.opencontainers.…
<missing> 10 days ago /bin/sh -c #(nop) ARG 0B
LAUNCHPAD_BUILD_ARCH
<missing> 10 days ago /bin/sh -c #(nop) 0B
ARG RELEASE
现在,让我们运行一个最新的 Docker 镜像:
$ docker run -d --name nginx <your_dockerhub_user>/nginx-hello-world:latest
e2d0c4b884556a353817aada13f0c91ecfeb01f5940e91746f168b…
接下来,让我们从正在运行的容器中导出:
$ docker export nginx > nginx-hello-world-flat.tar
将nginx-hello-world-flat.tar导入为一个新镜像;也就是说,<your_dockerhub_user>/nginx-hello-world:flat:
$ cat nginx-hello-world-flat.tar | \
docker import - <your_dockerhub_user>/nginx-hello-world:flat
sha256:57bf5a9ada46191ae1aa16bcf837a4a80e8a19d0bcb9fc…
现在,让我们列出镜像并看看得到的结果:
$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
<your_dockerhub_user>/ flat 57bf5a9ada46 34 seconds 177MB
nginx-hello-world ago
<your_dockerhub_user>/ latest bba3123dde01 2 hours
nginx-hello-world ago 180MB
在这里,我们可以看到扁平化后的镜像,并且它占用的空间比最新镜像少。如果查看它的历史记录,我们应该只看到一个层:
$ docker history <your_dockerhub_user>/nginx-hello-world:flat
IMAGE CREATED CREATED BY SIZE COMMENT
57bf5a9ada46 About a minute ago 177MB Imported from -
它已经将镜像进行了扁平化。但将 Docker 镜像扁平化是最佳实践吗?嗯,这取决于情况。让我们来了解一下什么时候以及如何扁平化 Docker 镜像,以及你需要考虑哪些因素:
-
是否有多个应用程序使用相似的基础镜像?如果是这样,扁平化镜像只会增加磁盘占用,因为你无法利用分层文件系统的优势。
-
考虑使用小型基础镜像(如 Alpine)作为扁平化镜像的替代方案。
-
多阶段构建对于大多数编译语言非常有用,并且可以显著减少镜像大小。
-
你还可以通过将多个步骤合并为单个
RUN指令,使用尽可能少的层来缩小镜像大小。 -
考虑一下扁平化镜像的好处是否超过其缺点,是否会带来显著的性能提升,以及性能是否对你的应用程序需求至关重要。
这些考虑因素将帮助你理解容器镜像的占用空间,并帮助你管理容器镜像。记住,尽管减少镜像的大小是理想的,但将其压缩应作为最后的手段。
到目前为止,我们使用的所有镜像都是从 Linux 发行版派生的,且总是使用某个发行版作为其基础镜像。你也可以在不使用 Linux 发行版作为基础镜像的情况下运行容器,以提高安全性。我们将在下一部分中探讨如何做到这一点。
使用无发行版镜像优化容器
无发行版容器是容器世界中的最新趋势之一。它们很有前景,因为它们考虑了为企业环境优化容器的各个方面。在优化容器时,你应该考虑三个重要因素——性能、安全性和成本。
性能
你不能凭空创建容器。你必须从容器注册表中下载镜像,然后从镜像中运行容器。每个步骤都涉及网络和磁盘 I/O。镜像越大,消耗的资源就越多,性能就越差。因此,更小的 Docker 镜像自然表现更好。
安全性
安全性是当前 IT 环境中最重要的方面之一。公司通常会专注于这一点,并投入大量的时间和资金。由于容器是一项相对较新的技术,它们容易受到黑客攻击,因此,适当保护你的容器至关重要。标准的 Linux 发行版包含了许多可以让黑客访问更多内容的组件,而如果你正确保护容器,它们本来是无法做到的。因此,你必须确保容器内只有必要的内容。
成本
更小的镜像也意味着更低的成本。容器的占用空间越小,你就可以在一台机器中容纳更多的容器,从而减少运行应用程序所需的机器数量。这意味着你可以节省大量随着时间积累的费用。
作为一名现代 DevOps 工程师,你必须确保你的镜像在所有这些方面都得到了优化。无发行版镜像有助于解决这些问题。因此,让我们了解一下什么是无发行版镜像以及如何使用它们。
无发行版镜像是最简化的镜像,仅包含你的应用程序、依赖项以及容器进程运行所需的文件。大多数情况下,你不需要像 apt 这样的包管理器或像 bash 这样的 shell。没有 shell 有其优势。例如,它能帮助你避免任何外部方在容器运行时获得访问权限。你的容器拥有较小的攻击面,因此不会有太多安全漏洞。
Google 在其官方 GCR 注册表中提供了无发行版镜像,这些镜像可以在他们的 GitHub 页面找到,链接为 github.com/GoogleContainerTools/distroless。让我们动手操作,看看我们能用它们做些什么。
本练习所需的资源在本书的 GitHub 仓库中的 ch4/go-hello-world/distroless 文件夹内。
让我们先创建一个 Dockerfile:
FROM golang:1.20.5 AS build
WORKDIR /tmp
COPY app.go .
RUN GO111MODULE=off GOOS=linux go build -a -installsuffix cgo -o app . && chmod +x ./app
FROM gcr.io/distroless/base
WORKDIR /tmp
COPY --from=build /tmp/app .
CMD ["./app"]
这个 Dockerfile 类似于 go-hello-world 容器的多阶段构建 Dockerfile,但它使用 gcr.io/distroless/base 作为基础镜像,而不是 alpine。这个镜像包含一个最简化的 Linux glibc 系统,不带包管理器或 shell。你可以用它来运行用 Go、Rust 或 D 等语言编译的二进制文件。
所以,先使用以下命令构建它:
$ docker build -t <your_dockerhub_user>/go-hello-world:distroless .
[+] Building 7.6s (14/14) FINISHED
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 268B 0.0s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 2B 0.0s
=> [internal] load metadata for gcr.io/distroless/base:latest 3.1s
=> [internal] load metadata for docker.io/library/golang:1.20.5 1.4s
=> [auth] library/golang:pull token for registry-1.docker.io 0.0s
=> [stage-1 1/3] FROM gcr.io/distroless/base@
sha256:73deaaf6a207c1a33850257ba74e0f196bc418636cada9943a03d7abea980d6d 3.2s
=> [build 1/4] FROM docker.io/library/golang:1.20.5@sha256:4b1fc02d 0.0s
=> [internal] load build context 0.0s
=> => transferring context: 108B 0.0s
=> CACHED [build 2/4] WORKDIR /tmp 0.0s
=> CACHED [build 3/4] COPY app.go . 0.0s
=> CACHED [build 4/4] RUN GO111MODULE=off GOOS=linux go build -a -installsuffix cgo -o
app . && chmod +x ./app 0.0s
=> [stage-1 2/3] WORKDIR /tmp 0.9s
=> [stage-1 3/3] COPY --from=build /tmp/app . 0.3s
=> exporting to image 0.1s
=> => exporting layers 0.1s
=> => writing image sha256:51ced401 0.0s
=> => naming to docker.io/<your_dockerhub_user>/go-hello-world:distroless
现在,让我们运行这个镜像,看看结果:
$ docker run <your_dockerhub_user>/go-hello-world:distroless
Hello, World!
它有效!让我们来看一下图像的大小:
$ docker images
REPOSITORY TAG MAGE ID CREATED SIZE
<your_dockerhub_user>/go-hello-world distroless 51ced401d7bf 6 minutes ago 22.3MB
它只有 22.3 MB。是的,比 Alpine 镜像稍大,但它不包含 shell,因此从这个角度看,它更安全。此外,还有适用于解释型编程语言(如 Python 和 Java)的 distroless 镜像,你可以使用这些代替包含工具包的庞大镜像。
Docker 镜像存储在 Docker 注册表中,我们已经使用 Docker Hub 一段时间了。在接下来的章节中,我们将了解它们是什么,以及我们存储镜像的选项。
理解 Docker 注册表
Docker 注册表 是一个无状态、高度可扩展的服务器端应用程序,用于存储和分发 Docker 镜像。该注册表在宽松的 Apache 许可证 下开源。它是一个存储和分发系统,所有 Docker 服务器都可以连接到它,并根据需要上传和下载镜像。它充当你的镜像分发站点。
一个 Docker 注册表包含多个 Docker 仓库。一个 Docker 仓库保存特定镜像的多个版本。例如,所有版本的 nginx 镜像都存储在 Docker Hub 中名为 nginx 的单一仓库内。
默认情况下,Docker 与其公共 Docker 注册表实例 Docker Hub 交互,Docker Hub 帮助你将镜像分发到更广泛的开源社区。
并非所有镜像都可以公开和开源,许多专有活动仍在进行中。Docker 允许你使用私有 Docker 注册表,这是一种可以在你自己的基础设施内托管的场景,称为 Docker Trusted Registry。有多个在线选项可用,包括使用 SaaS 服务,如 GCR,或在 Docker Hub 上创建私有仓库。
虽然 SaaS 选项易于使用且直观,但让我们考虑托管我们自己的私有 Docker 注册表。
托管你的私有 Docker 注册表
Docker 提供了一个镜像,你可以在任何安装了 Docker 的服务器上运行。一旦容器启动并运行,你可以将其用作 Docker 注册表。我们来看一下:
$ docker run -d -p 80:5000 --restart=always --name registry registry:2
Unable to find image 'registry:2' locally
2: Pulling from library/registry
8a49fdb3b6a5: Already exists
58116d8bf569: Pull complete
4cb4a93be51c: Pull complete
cbdeff65a266: Pull complete
6b102b34ed3d: Pull complete
Digest: sha256:20d08472…
Status: Downloaded newer image for registry:2
ae4c4ec9fc7b17733694160b5b3b053bd1a41475dc4282f3eccaa10…
由于我们知道注册表运行在本地主机上并监听端口80,我们现在尝试将镜像推送到这个注册表。首先,让我们标记镜像,指定localhost作为注册表。我们将在 Docker 标签前加上注册表位置,以便 Docker 知道将镜像推送到哪里。我们已经知道 Docker 标签的结构是<registry_url>/<user>/<image_name>:<image_version>。我们将使用docker tag命令给现有镜像另起一个名字,如下所示:
$ docker tag your_dockerhub_user>/nginx-hello-world:latest \
localhost/<your_dockerhub_user>/nginx-hello-world:latest
现在,我们可以继续将镜像推送到本地 Docker 注册表:
$ docker push localhost/<your_dockerhub_user>/nginx-hello-world:latest
The push refers to repository [localhost/your_dockerhub_user/nginx-hello-world]
2b7de406bdcd: Pushed
5f70bf18a086: Pushed
845348333310: Pushed
96a9e6a097c6: Pushed
548a79621a42: Pushed
latest: digest: sha256:6ad07e74… size: 1366
就是这样!简单至极!
还有其他考虑因素,因为这太过简化了。你还需要挂载卷;否则,在重启注册表容器时,你将丢失所有镜像。另外,目前没有身份验证机制,因此任何访问该服务器的人都可以推送或拉取镜像,但我们并不希望如此。此外,通信是不安全的,我们希望在传输过程中对镜像进行加密。
首先,让我们创建将要挂载到容器中的本地目录:
$ sudo mkdir -p /mnt/registry/certs
$ sudo mkdir -p /mnt/registry/auth
$ sudo chmod -R 777 /mnt/registry
现在,让我们生成一个htpasswd文件,为注册表添加身份验证。为此,我们将在新的 Docker 注册表容器内运行htpasswd命令,创建一个文件到我们的本地目录:
$ docker run --entrypoint htpasswd registry:2.7.0 \
-Bbn user pass > /mnt/registry/auth/htpasswd
下一步是生成一些自签名证书,以启用仓库的 TLS。输入服务器名称或 IP 地址时,输入完全限定域名 (FQDN)。你可以将其他字段留空,或者为它们添加合适的值:
$ openssl req -newkey rsa:4096 -nodes -sha256 -keyout \
/mnt/registry/certs/domain.key -x509 -days 365 -out /mnt/registry/certs/domain.crt
在我们继续之前,先删除现有的注册表:
$ docker rm -f registry
registry
现在,我们准备好启动我们的容器,并配置所需的设置:
$ docker run -d -p 443:443 --restart=always \
--name registry \
-v /mnt/registry/certs:/certs \
-v /mnt/registry/auth:/auth \
-v /mnt/registry/registry:/var/lib/registry \
-e REGISTRY_HTTP_ADDR=0.0.0.0:443 \
-e REGISTRY_HTTP_TLS_CERTIFICATE=/certs/domain.crt \
-e REGISTRY_HTTP_TLS_KEY=/certs/domain.key \
-e REGISTRY_AUTH=htpasswd \
-e "REGISTRY_AUTH_HTPASSWD_REALM=Registry Realm" \
-e REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd \
registry:2
02bf92c9c4a6d1d9c9f4b75ba80e82834621b1570f5f7c4a74b215960
容器现在已启动并运行。我们这次使用https,但在此之前,我们需要进行docker login到注册表。输入你在创建htpasswd文件时设置的用户名和密码(此例中为user和pass):
$ docker login https://localhost
Username: user
Password:
WARNING! Your password will be stored unencrypted in /root/
.docker/config.json.
Configure a credential helper to remove this warning. See
https://docs.docker.com/engine/reference/commandline/login/
#credentials-store
Login Succeeded
由于登录成功,我们可以继续将镜像推送到注册表:
$ docker push localhost/<your_dockerhub_user>/nginx-hello-world
The push refers to repository [localhost/<your_dockerhub_user>/nginx-hello-world]
2b7de406bdcd: Pushed
5f70bf18a086: Pushed
845348333310: Pushed
96a9e6a097c6: Pushed
548a79621a42: Pushed
latest: digest: sha256:6ad07e7425331456a3b8ea118bce36c82af242ec14072d483b5dcaa3bd607e65
size: 1366
这次,它按我们希望的方式工作。
其他公共注册表
除了在专用的 Docker 服务器上运行注册表外,其他云和本地部署选项也存在。
大多数公共云服务提供商都提供付费的在线注册表和容器托管解决方案,你可以在云端运行时轻松使用它们。以下是一些例子:
-
Amazon Elastic Container Registry (ECR):这是一个流行的 AWS 服务,如果你的基础设施运行在 AWS 上,你可以使用它。它是一个高可用、高性能、完全托管的解决方案。它可以托管公共和私有注册表,你只需为所使用的存储和传输到互联网的数据量付费。最棒的是,它可以与 AWS IAM 集成。
-
Google 容器注册表(GCR):由Google Cloud Storage(GCS)提供支持,如果你的基础设施运行在 GCP 上,GCR 是最好的选择之一。它支持公共和私有仓库,你只需要为 GCS 上的存储付费。
-
Azure 容器注册表(ACR):这是一个完全托管的、地理复制的容器注册表,仅支持私有注册表。如果你在 Azure 上运行基础设施,它是一个不错的选择。除了存储容器镜像外,它还存储 Helm charts 和其他有助于管理容器的工件。
-
Oracle Cloud 基础设施注册表:Oracle Cloud 基础设施注册表是一个高度可用的 Oracle 托管容器注册表。它可以托管公共和私有仓库。
-
CoreOS Quay:此服务支持 OAuth 和 LDAP 身份验证。它提供(付费)私有仓库和(免费)公共仓库,自动安全扫描,并通过与 GitLab、GitHub 和 Bitbucket 的集成进行自动镜像构建。
如果你不想选择云中的托管选项或不希望在本地运行,你还可以使用分发管理软件,如Sonatype Nexus或JFrog Artifactory。这两款工具都开箱即用地支持 Docker 注册表。你可以通过它们的华丽界面创建 Docker 注册表,然后使用docker login连接到注册表。
总结
在这一章中,我们已经覆盖了很多内容。此时,你应该能够从实际操作的角度理解 Docker。我们从 Docker 镜像开始,讲解了如何使用 Dockerfile 构建 Docker 镜像、Dockerfile 的组件和指令,以及如何通过遵循一些最佳实践来创建高效的镜像。我们还讨论了如何通过使用无发行版镜像来简化 Docker 镜像并提高容器安全性。最后,我们讨论了 Docker 注册表、如何在 Docker 服务器上运行私有 Docker 注册表,以及如何使用其他即插即用的解决方案,如 Sonatype Nexus 和 JFrog Artifactory。
以下是一些管理 Docker 容器的最佳实践的快速总结:
-
使用官方镜像:尽可能使用来自可信来源(如 Docker Hub)的官方 Docker 镜像。这些镜像得到了良好的维护,定期更新,通常遵循更好的安全实践。
-
最小化容器:遵循“每个容器一个服务”的原则。每个容器应该承担单一责任,这有助于维护性和可扩展性。
-
优化容器大小:尽可能保持容器轻量化。使用 Alpine Linux 或其他最小化的基础镜像,并删除不必要的文件和依赖。
-
使用环境变量:将配置和敏感数据存储在环境变量中,而不是硬编码到容器中。这可以提高可移植性和安全性。
-
持久化数据:使用 Docker 卷或绑定挂载将应用数据存储在容器外部。这样可以确保即使容器被替换或停止,数据仍然能够持久存在。
-
容器命名:为容器赋予有意义且唯一的名称。这有助于便于识别和故障排除。
-
资源限制:为容器设置资源限制(CPU 和内存),以防一个不正常的容器影响同一主机上的其他容器。
-
容器重启策略:定义重启策略,以决定容器在退出或崩溃时的行为。根据应用程序的需求选择适当的策略。
-
Docker Compose:使用 Docker Compose 来定义和管理多容器应用程序。它简化了复杂设置的部署和协调。
-
网络隔离:使用 Docker 网络来隔离容器并控制它们之间的通信。这增强了安全性和可管理性。
-
健康检查:在容器中实现健康检查,确保它们按预期运行。这有助于自动化监控和恢复。
-
stdout)和标准错误(stderr)流。这使得使用 Docker 的日志机制收集和分析日志变得更容易。 -
安全最佳实践:保持容器的安全补丁更新,避免以 root 身份运行容器,并遵循安全最佳实践以避免漏洞。
-
版本控制 Dockerfile:将 Dockerfile 存储在版本控制系统中(例如 Git),并定期审查和更新它们。
-
容器清理:定期移除未使用的容器、镜像和卷,以释放磁盘空间。考虑使用 Docker 内置的清理命令等工具。
-
编排工具:探索如 Kubernetes 或 Docker Swarm 等容器编排工具,以管理更大且更复杂的容器部署。
-
文档:维护清晰且最新的容器和镜像文档,包括如何运行它们、所需的环境变量以及其他任何配置细节。
-
备份与恢复:建立容器数据和配置的备份与恢复流程,以便在故障发生时迅速恢复。
-
监控与扩展:为容器实现监控和告警,确保它们平稳运行。使用扩展机制来应对增加的负载。
通过遵循这些最佳实践,您可以确保 Docker 容器环境是有序、安全、可维护和可扩展的。
在下一章,我们将深入探讨使用 Kubernetes 进行容器编排。
问题
-
Docker 镜像使用分层模型。(对/错)
-
如果一个容器正在使用某个镜像运行,您仍然可以从服务器上删除该镜像。(对/错)
-
如何从服务器中移除一个正在运行的容器?(选择两项)
A.
dockerrm <container_id>B.
docker rm -``f <container_id>C.
docker stop <container_id> && dockerrm <container_id>D.
docker stop -``f <container_id> -
以下哪个选项是容器构建的最佳实践?(选择四项)
A. 总是将不常更改的层添加到 Dockerfile 的开始部分。
B. 将多个步骤合并为单一指令,以减少层级。
C. 只在容器中使用必要的文件,以保持容器轻量并减少攻击面。
D. 在 Docker 标签中使用语义版本控制,避免使用最新版本。
E. 在容器中包含包管理器和 shell,这有助于调试正在运行的容器。
F. 仅在 Dockerfile 的开头使用
apt update。 -
你应始终将 Docker 镜像压缩成单层。(对/错)
-
不带发行版的容器包含一个 shell。(对/错)
-
改善容器效率的方法有哪些?(选择四项)
A. 尽可能使用较小的基础镜像,例如 Alpine。
B. 仅使用多阶段构建将所需的库和依赖项添加到容器中,省略不必要的重型工具包。
C. 尽可能使用不带发行版的基础镜像。
D. 简化 Docker 镜像。
E. 使用单阶段构建来包含包管理器和 shell,因为这有助于生产环境中的故障排除。
-
定期修剪 Docker 镜像是一种最佳实践。(对/错)
-
健康检查应始终包含在 Docker 镜像中。(对/错)
答案
-
对
-
错误 – 不能删除正在被运行容器使用的镜像。
-
B, C
-
A, B, C, D
-
错误 – 只有在提高性能的情况下,才应简化 Docker 镜像。
-
错误 – 不带发行版的容器不包含 shell。
-
A, B, C, D
-
对
-
错误 – 如果使用 Kubernetes 或 Docker Compose,请使用存活探针或通过 YAML 文件定义健康检查。
第二部分:容器编排与无服务器架构
本部分将基于第一部分,并向您介绍使用容器编排和无服务器技术管理容器。在本部分中,您将学习如何使用先进的工具和技术在本地和云端管理容器。
本部分包括以下章节:
-
第五章,使用 Kubernetes 进行容器编排
-
第六章,管理高级 Kubernetes 资源
-
第七章,容器即服务(CaaS)与无服务器架构
第五章:使用 Kubernetes 进行容器编排
在上一章中,我们介绍了创建和管理容器镜像的内容,讨论了容器镜像、Dockerfile 及其指令和组件。我们还讨论了编写 Dockerfile 的最佳实践,以及如何构建和管理高效的镜像。接着,我们探讨了扁平化 Docker 镜像,并详细研究了无发行版镜像,以提高容器安全性。最后,我们创建了一个私有 Docker 注册中心。
现在,我们将深入探讨容器编排。我们将学习如何使用最流行的容器编排工具——Kubernetes,来调度和运行容器。
在本章中,我们将涵盖以下主要主题:
-
什么是 Kubernetes,为什么我需要它?
-
Kubernetes 架构
-
安装 Kubernetes(Minikube 和 KinD)
-
理解 Kubernetes Pod
技术要求
本章假设您已在具有 sudo 权限的 Linux 机器上安装了 Docker。您可以参考第三章,使用 Docker 容器化,获取更多关于如何操作的细节。
您还需要克隆以下 GitHub 存储库以进行一些练习:github.com/PacktPublishing/Modern-DevOps-Practices-2e。
运行以下命令将存储库克隆到您的主目录,并使用 cd 进入 ch5 目录以访问所需的资源:
$ git clone https://github.com/PacktPublishing/Modern-DevOps-Practices-2e.git \
modern-devops
$ cd modern-devops/ch5
由于该存储库包含带占位符的文件,因此您必须将 <your_dockerhub_user> 字符串替换为您实际的 Docker Hub 用户名。请使用以下命令来替换占位符:
$ grep -rl '<your_dockerhub_user>' . | xargs sed -i -e \
's/<your_dockerhub_user>/<your actual docker hub user>/g'
什么是 Kubernetes,为什么我需要它?
到现在为止,您应该了解容器是什么以及如何使用 Docker 构建和运行容器。然而,我们使用 Docker 运行容器的方式从生产角度来看并不理想。让我给你提供一些考虑事项:
-
由于便携式容器可以在任何 Docker 机器上顺利运行,多个容器还共享服务器资源以优化资源消耗。现在,想象一个由数百个容器组成的微服务应用程序。您将如何选择在哪台机器上运行容器?如果您希望根据资源消耗动态调度容器到另一台机器上呢?
-
容器提供了水平扩展能力,因为您可以创建容器的副本,并在一组容器前面使用负载均衡器。一种方法是提前决定并部署所需数量的容器,但这不是最优的资源利用方式。如果我告诉你,你需要根据流量动态水平扩展容器——换句话说,当流量增加时,创建额外的容器实例来处理额外的负载,而当流量减少时,减少容器实例呢?
-
容器有健康检查报告,显示容器的健康状态。如果容器不健康,并且你想让它自动修复该怎么办?如果整个服务器宕机,你希望将该服务器上的所有容器调度到其他地方,会发生什么?
-
由于容器大多运行在服务器内,并且能够彼此看到,那么我如何确保只有必要的容器能够互相交互,这是我们通常在虚拟机中做的事情?我们不能妥协于安全性。
-
现代云平台允许我们运行自动扩展的虚拟机(VM)。从容器的角度来看,我们如何利用这一点?例如,如果我在夜间只需要一台虚拟机来容纳我的容器,而白天需要五台,我该如何确保在需要时动态分配这些机器?
-
如果多个容器是更广泛服务网格的一部分,你如何管理它们之间的网络连接?
所有这些问题的答案是一个容器编排工具,而最受欢迎且事实上的标准就是 Kubernetes。
Kubernetes 是一个开源的容器编排工具。一群谷歌工程师最初开发了它,然后将其开源并交给了云原生计算基金会(CNCF)。从那时起,Kubernetes 的热度未曾减退,而且这是有充分理由的——Kubernetes 与容器的结合彻底改变了技术思维方式以及我们看待基础设施的方式。Kubernetes 不再将服务器视为专门为某个应用程序服务的机器,或者作为应用程序的一部分,而是允许将服务器可视化为一个已安装容器运行时的实体。当我们将服务器视为标准设置时,我们就能在一群服务器的集群中运行几乎任何东西。因此,你不必为技术栈中的每个应用程序单独规划高可用性(HA)、灾难恢复(DR)和其他运营方面的问题。相反,你可以将所有服务器聚集成一个单位——Kubernetes 集群——并将所有应用程序容器化。然后,你可以将所有容器管理功能交给 Kubernetes 来处理。你可以在裸金属服务器、虚拟机(VM)上运行 Kubernetes,或者通过多种 Kubernetes 作为服务的产品,在云中运行它。
Kubernetes 通过提供开箱即用的高可用性(HA)、可扩展性和零停机时间来解决这些问题。它基本上执行以下功能来提供这些功能:
-
提供集中式控制平面与其交互:API 服务器暴露了一个有用的 API 列表,你可以通过它调用许多 Kubernetes 功能。它还提供了一个名为 kubectl 的 Kubernetes 命令行工具,方便你使用简单的命令与 API 进行交互。拥有一个集中式控制平面确保了你可以无缝地与 Kubernetes 进行交互。
-
与容器运行时交互以调度容器:当我们向kube-apiserver发送请求调度容器时,Kubernetes 会根据各种因素决定将容器调度到哪个服务器,然后通过kubelet组件与服务器的容器运行时进行交互。
-
在键值数据存储中存储期望的配置:Kubernetes 应用集群的预期配置,并将其存储在键值数据存储中——etcd。这样,Kubernetes 会持续确保集群中的容器保持在期望的状态。如果有任何偏离预期状态的情况,Kubernetes 会采取措施将其恢复到期望的配置。通过这种方式,Kubernetes 确保你的容器始终正常运行并保持健康。
-
提供网络抽象层和服务发现:Kubernetes 使用网络抽象层来允许容器之间的通信。因此,每个容器都会分配一个虚拟 IP,Kubernetes 确保一个容器可以从运行在不同服务器上的另一个容器访问。它通过在服务器之间使用覆盖网络提供必要的网络连接。从容器的角度来看,集群中的所有容器就像是在同一台服务器上运行一样。Kubernetes 还使用DNS来通过域名允许容器之间的通信。这样,容器可以通过使用域名而不是 IP 地址来相互交互,从而确保如果容器被重新创建且 IP 地址发生变化时,你不需要更改配置。
-
与云提供商交互:Kubernetes 与云提供商交互,以调度诸如负载均衡器和持久磁盘等对象。因此,如果你告诉 Kubernetes 你的应用程序需要持久化数据并定义了一个卷,Kubernetes 会自动向你的云提供商请求磁盘,并将其挂载到运行容器的地方。你还可以通过向 Kubernetes 请求将应用程序暴露在外部负载均衡器上。Kubernetes 会与云提供商交互,启动负载均衡器并将其指向你的容器。通过这种方式,你可以仅通过与 Kubernetes API 服务器交互来处理所有与容器相关的事务。
Kubernetes 包含多个组件,它们负责处理我们讨论的每个功能。现在,让我们来看看 Kubernetes 的架构,以了解每个组件的作用。
Kubernetes 架构
Kubernetes 是由一组节点组成的集群。在 Kubernetes 中,节点有两种可能的角色——控制平面节点和工作节点。控制平面节点控制 Kubernetes 集群,调度工作负载、监听请求以及其他帮助运行工作负载和使集群运作的方面。它们通常构成集群的大脑。
另一方面,工作节点是 Kubernetes 集群的动力源,为运行容器工作负载提供原始计算能力。
Kubernetes 架构通过 API 服务器遵循客户端-服务器模型。所有的交互,包括组件之间的内部交互,都通过 Kubernetes API 服务器进行。因此,Kubernetes API 服务器被称为 Kubernetes 控制平面的“大脑”。
Kubernetes 还有其他组件,但在深入细节之前,让我们通过下面的图表来了解高层次的 Kubernetes 架构:

图 5.1 – Kubernetes 集群架构
控制平面包含以下组件:
-
API 服务器:如前所述,API 服务器暴露了一组 API,供外部和内部参与者与 Kubernetes 进行交互。所有与 Kubernetes 的交互都通过 API 服务器进行,从前面的图示可以看出。如果将 Kubernetes 集群想象成一艘船,API 服务器就是船长。
-
控制器管理器:控制器管理器是船上的执行官,负责确保船长的命令在集群中得到遵守。从技术角度来看,控制器管理器读取当前状态和目标状态,并采取一切必要的行动将当前状态转变为目标状态。它包含一组控制器,这些控制器根据需要通过 API 服务器与 Kubernetes 组件进行交互。以下是其中的一些:
-
节点控制器:该控制器监控节点何时宕机,并通过与 Kube 调度器 通过 Kube API 服务器 进行交互,将 Pods 调度到健康的节点上。
-
复制控制器:该控制器确保集群中定义的正确数量的容器副本存在。
-
终端控制器:这些控制器帮助通过服务为你的容器提供终端。
-
服务账户和令牌控制器:这些控制器为新的 命名空间 创建默认的 账户 和 令牌。
-
-
云控制器管理器:这是一个可选的控制器管理器,若你在公共云上运行 Kubernetes(例如 AWS、Azure 或 GCP),则需要运行此控制器管理器。云控制器管理器与云提供商的 API 进行交互,来配置你在 Kubernetes 配置中声明的资源,如 持久磁盘 和 负载均衡器。
-
etcd:etcd 是船的日志簿。这里存储着所有关于预期配置的详细信息。从技术角度来看,这是一个键值存储,存储着所有期望的 Kubernetes 配置。控制器管理器会参考这个数据库中的信息来执行集群中的更改。
-
调度器:调度器就像船只的水手长。它们负责监督容器在船上的装卸过程。Kubernetes 调度器会根据资源的可用性、应用程序的高可用性以及其他因素,在合适的工作节点上调度容器。
-
kubelet:kubelet 就像船员一样。它们实际执行容器从船上装卸的操作。从技术角度看,kubelet 与底层的容器运行时交互,根据调度器的指令运行容器。虽然大多数 Kubernetes 组件可以作为容器运行,但 kubelet 是唯一作为 systemd 服务运行的组件。它们通常运行在工作节点上,但如果你计划将控制平面组件作为容器运行,那么 kubelet 也会在控制平面节点上运行。
-
kube-proxy:kube-proxy 在每个工作节点上运行,为容器提供与集群内外网络组件交互的功能。它们是促进 Kubernetes 网络通信的关键组件。
好吧,这涉及很多环节,但好消息是,有现成的工具可以帮助你设置,而部署 Kubernetes 集群非常简单。如果你在公共云上运行,几次点击即可完成,你可以使用云提供商的 Web UI 或 CLI 来快速部署。如果是本地安装,你可以使用kubeadm进行设置。步骤文档完善,易于理解,也不会太麻烦。
对于开发和 CI/CD 环境,你可以使用Minikube或Docker 中的 Kubernetes(KinD)。Minikube 可以直接在你的开发机器上运行单节点 Kubernetes 集群,将机器作为节点使用;它也可以通过将 Kubernetes 节点作为容器来运行多节点集群。另一方面,KinD 仅在单节点和多节点配置中将节点作为容器运行。在这两种情况下,你都需要一个具有必要资源的虚拟机,然后就可以开始了。
在下一部分,我们将使用 Minikube 启动一个单节点 Kubernetes 集群。
安装 Kubernetes(Minikube 和 KinD)
现在,让我们继续进行 Kubernetes 的安装。我们将从 Minikube 开始,帮助你快速入门,然后再了解 KinD。接下来,我们将在本章的其余部分使用 KinD。
安装 Minikube
我们将在与安装 Docker 相同的 Linux 机器上安装 Minikube,参考第三章,使用 Docker 进行容器化。因此,如果你还没有进行该操作,请前往第三章,使用 Docker 进行容器化,并按照提供的说明在你的机器上设置 Docker。
首先,我们将安装 kubectl。如前所述,kubectl 是与 Kubernetes API 服务器交互的命令行工具。在本书中,我们将多次使用 kubectl。
要下载最新版本的 kubectl,请运行以下命令:
$ curl -LO "https://storage.googleapis.com/kubernetes-release/release\
/$(curl -s https://storage.googleapis.com/kubernetes-release/release/stable.txt)\
/bin/linux/amd64/kubectl"
你也可以下载 kubectl 的特定版本。为此,请使用以下命令:
$ curl -LO https://storage.googleapis.com/kubernetes-release/release\
/v<kubectl_version>/bin/linux/amd64/kubectl
我们将在本章中使用最新版本。现在,让我们继续使二进制文件可执行,然后将其移动到系统的 PATH 中的任何目录:
$ chmod +x ./kubectl
$ sudo mv kubectl /usr/local/bin/
现在,让我们运行以下命令检查 kubectl 是否已成功安装:
$ kubectl version --client
Client Version: version.Info{Major:"1", Minor:"27", GitVersion:"v1.27.3"}
由于 kubectl 已成功安装,接下来你需要下载 minikube 二进制文件,并使用以下命令将其移动到系统路径中:
$ curl -Lo minikube \
https://storage.googleapis.com/minikube/releases/latest/minikube-linux-amd64
$ chmod +x minikube
$ sudo mv minikube /usr/local/bin/
现在,让我们通过运行以下命令来安装 Minikube 正常运行所需的包:
$ sudo apt-get install -y conntrack
最后,我们可以使用以下命令启动一个 Minikube 集群:
$ minikube start --driver=docker
Done! kubectl is now configured to use "minikube" cluster and "default" namespace by
default
由于 Minikube 现在已经启动并运行,我们将使用 kubectl 命令行工具与 Kube API 服务器交互,以管理 Kubernetes 资源。kubectl 命令具有标准结构,并且大多数情况下易于理解。其结构如下:
kubectl <verb> <resource type> <resource name> [--flags]
这里,我们有以下内容:
-
动词:要执行的操作——例如get(获取)、apply(应用)、delete(删除)、list(列出)、patch(修补)、run(运行)等 -
资源类型:要管理的 Kubernetes 资源,例如node(节点)、pod(容器组)、deployment(部署)、service(服务)等 -
资源名称:要管理的资源的名称
现在,让我们使用 kubectl 获取节点并检查我们的集群是否准备好运行容器:
$ kubectl get nodes
NAME STATUS ROLES AGE VERSION
minikube Ready control-plane 2m25s v1.26.3
在这里,我们可以看到这是一个运行版本 v1.26.3 的单节点 Kubernetes 集群。Kubernetes 现在已经启动并运行!
这个设置非常适合开发机器,开发人员可以在其上部署并测试他们正在开发的单个组件。
要停止 Minikube 集群并将其从机器中删除,你可以使用以下命令:
$ minikube stop
既然我们已经移除了 Minikube,接下来让我们看看另一个创建多节点 Kubernetes 集群的有趣工具。
安装 KinD
KinD 允许你在运行 Docker 的单个服务器上运行一个多节点的 Kubernetes 集群。我们知道,运行一个多节点的 Kubernetes 集群需要多台机器,但如何在单台服务器上运行一个多节点 Kubernetes 集群呢?答案很简单:KinD 使用 Docker 容器作为 Kubernetes 节点。因此,如果我们需要一个四节点的 Kubernetes 集群,KinD 会启动四个容器,它们表现得就像四个 Kubernetes 节点。就这么简单。
尽管你需要 Docker 来运行 KinD,但 KinD 内部使用 containerd 作为容器运行时,而不是 Docker。Containerd 实现了容器运行时接口,因此 Kubernetes 不需要任何专门的组件,如 dockershim,与其交互。这意味着,KinD 仍然能够与 Kubernetes 配合使用,因为 Docker 不再被支持作为 Kubernetes 的容器运行时。
由于 KinD 支持多节点 Kubernetes 集群,你可以将其用于开发活动,也可以用于 CI/CD 管道。实际上,KinD 重新定义了 CI/CD 管道,因为你不需要一个静态的 Kubernetes 环境来测试你的构建。KinD 启动速度快,这意味着你可以将 KinD 集群的引导过程集成到 CI/CD 管道中,在集群内运行并测试你的容器构建,然后将其销毁。这为开发团队提供了巨大的力量和速度。
重要
永远不要在生产环境中使用 KinD。Docker in Docker 的实现并不安全;因此,KinD 集群不应超出你的开发环境和 CI/CD 管道。
引导 KinD 只需几个命令。首先,我们需要下载 KinD,确保它可执行,然后使用以下命令将其移动到默认的 PATH 目录中:
$ curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.20.0/kind-linux-amd64
$ chmod +x kind
$ sudo mv kind /usr/local/bin/
要检查是否已安装 KinD,可以运行以下命令:
$ kind version
kind v0.20.0 go1.20.4 linux/amd64
现在,让我们引导一个多节点的 KinD 集群。首先,我们需要创建一个 KinD config 文件。KinD config 文件是一个简单的 YAML 文件,你可以在其中声明每个节点所需的配置。如果我们需要引导一个单控制平面和三个工作节点的集群,可以添加以下配置:
$ vim kind-config.yaml
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
- role: control-plane
- role: worker
- role: worker
- role: worker
你还可以使用多个控制平面节点来实现高可用配置,在控制平面角色的节点上使用多个节点项。现在,我们先使用单个控制平面和三个工作节点的配置。
要使用前述配置引导你的 KinD 集群,请运行以下命令:
$ kind create cluster --config kind-config.yaml
这样,我们的 KinD 集群已经启动并运行了。现在,让我们使用以下命令列出节点,确认集群状态:
$ kubectl get nodes
NAME STATUS ROLES AGE VERSION
kind-control-plane Ready control-plane 72s v1.27.3
kind-worker Ready <none> 47s v1.27.3
kind-worker2 Ready <none> 47s v1.27.3
kind-worker3 Ready <none> 47s v1.27.3
在这里,我们可以看到集群中有四个节点——一个控制平面和三个工作节点。现在集群已经准备好,我们将在下一个部分深入了解 Kubernetes,并看看一些最常用的 Kubernetes 资源。
理解 Kubernetes 的 pods
Kubernetes 的 pod 是 Kubernetes 应用程序的基本构建块。一个 pod 包含一个或多个容器,所有容器总是会调度到同一主机上。通常,pod 中只有一个容器,但在某些场景下,你需要在一个 pod 中调度多个容器。
要理解为什么 Kubernetes 最初采用 pod 的概念而不是使用容器,可能需要一些时间,但这是有原因的,随着你对工具的使用经验积累,你会理解其中的深意。现在,让我们来看一个简单的 pod 示例,以及如何在 Kubernetes 中调度它。
运行一个 pod
我们将首先使用简单的命令在 pod 中运行一个 NGINX 容器。然后,我们会看看如何以声明的方式进行操作。
要访问本节的资源,请cd到以下目录:
$ cd ~/modern-devops/ch5/pod/
要运行一个包含单个 NGINX 容器的 pod,请执行以下命令:
$ kubectl run nginx --image=nginx
要检查 pod 是否正在运行,可以运行以下命令:
$ kubectl get pod
NAME READY STATUS RESTARTS AGE
nginx 1/1 Running 0 26s
就是这样!正如我们所看到的,pod 现在正在运行。
要删除 pod,可以运行以下命令:
$ kubectl delete pod nginx
kubectl run 命令是创建 pod 的命令式方式,但与 Kubernetes 交互的另一种方式是使用声明性清单。docker compose。
提示
在预发布和生产环境中始终使用声明性方法创建 Kubernetes 资源。它们允许您将 Kubernetes 配置存储和版本化在诸如 Git 等源代码管理工具中,并启用 GitOps。在开发过程中,您可以使用命令式方法,因为命令比 YAML 文件具有更快的周转时间。
让我们看一个示例 pod 清单,nginx-pod.yaml:
apiVersion: v1
kind: Pod
metadata:
labels:
run: nginx
name: nginx
spec:
containers:
- image: nginx
imagePullPolicy: Always
name: nginx
resources:
limits:
memory: "200Mi"
cpu: "200m"
requests:
memory: "100Mi"
cpu: "100m"
restartPolicy: Always
让我们首先了解文件。文件包含以下内容:
-
apiVersion: 这定义了我们正在定义的资源版本。在这种情况下,作为 pod 的版本为v1。 -
kind: 这定义了我们要创建的资源类型 – 一个 pod。 -
metadata:metadata部分定义了围绕此资源的名称和标签。它有助于通过标签唯一标识资源并分组多个资源。 -
spec: 这是主要部分,我们在这里定义资源的实际规格。 -
spec.containers: 此部分定义形成 pod 的一个或多个容器。 -
spec.containers.name: 这是容器的名称,在本例中为nginx-container。 -
spec.containers.image: 这是容器镜像,在本例中是nginx。 -
spec.containers.imagePullPolicy: 这可以是Always(始终拉取)、IfNotPresent(仅在节点上未找到镜像时拉取)、或Never(从不尝试从注册表拉取镜像并完全依赖本地镜像)。 -
spec.containers.resources: 这定义了资源的请求和限制。 -
spec.containers.resources.limit: 这定义了资源限制。这是 pod 可以分配的最大资源量,如果资源消耗超出此限制,pod 将被驱逐。 -
spec.containers.resources.limit.memory: 这定义了内存限制。 -
spec.containers.resources.limit.cpu: 这定义了 CPU 限制。 -
spec.containers.resources.requests: 这定义了资源请求。这是在调度期间 pod 需要的最小资源量,如果节点无法分配这些资源,将不会被调度。 -
spec.containers.resources.requests.memory: 这定义了要请求的内存量。 -
spec.containers.resources.requests.cpu: 这定义了要请求的 CPU 核心数量。 -
spec.restartPolicy: 这定义了容器的重启策略 –Always(始终重启)、OnFailure(失败时重启)、或Never(从不重启)。这与 Docker 上的重启策略类似。
在 pod 清单上还有其他设置,但我们将根据进展情况逐步探讨。
重要提示
将 imagePullPolicy 设置为 IfNotPresent,除非你有充分的理由使用 Always 或 Never。这样可以确保你的容器快速启动,并且避免不必要地下载镜像。
在调度 pod 时,请始终使用资源请求和限制。这确保你的 pod 被调度到适当的节点,并且不会耗尽任何现有资源。你还可以在集群级别应用默认的资源策略,以确保如果开发人员由于某些原因忽略了这一部分,也不会造成任何损害。
让我们使用以下命令应用清单:
$ kubectl apply -f nginx-pod.yaml
我们创建的 pod 完全处于主机网络之外。它运行在容器网络内,默认情况下,Kubernetes 不允许任何 pod 暴露给主机网络,除非我们明确要暴露它。
访问 pod 有两种方式——使用 kubectl port-forward 进行端口转发,或者通过 Service 资源暴露 pod。
使用端口转发
在我们进入服务部分之前,让我们考虑一下使用 port-forward 选项。
要通过端口转发暴露 pod,请执行以下命令:
$ kubectl port-forward nginx 8080:80
Forwarding from 127.0.0.1:8080 -> 80
Forwarding from [::1]:8080 -> 80
提示信息停留在这里。这意味着它已打开端口转发会话,并在端口 8080 上监听。它将自动将收到的端口 8080 请求转发到 NGINX 的端口 80。
打开一个重复的终端会话,并在前述地址上执行 curl,查看我们会得到什么:
$ curl 127.0.0.1:8080
...
<title>Welcome to nginx!</title>
...
我们可以看到它正在工作,因为我们得到了默认的 NGINX 响应。
现在,这里有几点需要记住。
当我们使用 HTTP port-forward 时,我们是将请求从运行 kubectl 的客户端机器转发到 pod,类似于下图所示的内容:

图 5.2 – kubectl port-forward
当你运行 kubectl port-forward 时,kubectl 客户端通过 Kube API 服务器打开一个 TCP 隧道,然后 Kube API 服务器将连接转发到正确的 pod。由于 kubectl 客户端和 API 服务器之间的连接是加密的,因此这是一种非常安全的访问 pod 的方式,但在决定使用 kubectl port-forward 将 pod 暴露给外部世界之前,请三思。
有一些特定的使用场景适合使用 kubectl port-forward:
-
用于故障排除任何行为不正常的 pod。
-
用于访问 Kubernetes 内部服务,例如 Kubernetes 仪表盘——也就是说,当你不希望将服务暴露给外部世界,而只允许 Kubernetes 管理员和用户登录仪表盘时。假设只有这些用户可以通过
kubectl访问集群。
对于其他任何情况,你应该使用 Service 资源来暴露你的 pod,无论是内部还是外部。虽然我们将在下一章中讨论 Service 资源,但让我们先看一下可以对 pod 执行的几个操作。
故障排除 pods
类似于我们使用docker logs浏览容器日志的方式,我们可以使用kubectl logs命令浏览 Kubernetes pod 中容器的日志。如果 pod 中运行多个容器,我们可以使用-c标志来指定容器的名称。
要访问容器日志,运行以下命令:
$ kubectl logs nginx -c nginx
...
127.0.0.1 - - [18/Jun/2023:14:08:01 +0000] "GET / HTTP/1.1" 200 612 "-" "curl/7.47.0" "-"
由于 pod 只运行一个容器,我们无需指定-c标志,因此你可以使用以下命令:
$ kubectl logs nginx
可能有些情况,你可能需要获取运行中容器的 shell 并排查容器内发生了什么。在 Docker 中我们使用docker exec来实现这个操作。同样,在 Kubernetes 中我们可以使用kubectl exec来实现这一点。
运行以下命令以打开与容器的 shell 会话:
$ kubectl exec -it nginx -- /bin/bash
root@nginx:/# cd /etc/nginx/ && ls
conf.d fastcgi_params mime.types modules nginx.conf scgi_params uwsgi_params
root@nginx:/etc/nginx# exit
你甚至可以在不打开 shell 会话的情况下运行特定命令。例如,我们可以通过一行命令来执行前面的操作,类似以下内容:
$ kubectl exec nginx -- ls /etc/nginx
conf.d fastcgi_params mime.types modules nginx.conf scgi_params uwsgi_params
kubectl exec是一个重要命令,有助于我们故障排除容器。
提示
如果你在exec模式下修改容器中的文件或下载包,这些更改会在当前 pod 存活期间持续有效。一旦 pod 被删除,你将失去所有更改。因此,这并不是解决问题的好方法。你应当只使用exec来诊断问题,将正确的更改嵌入到新镜像中,然后重新部署。
在上一章中我们讨论了无发行版容器,它们由于安全原因不允许exec进入容器。对于无发行版容器,提供了调试镜像,可以让你打开一个 shell 会话进行故障排除。
提示
默认情况下,如果在构建镜像时没有在 Dockerfile 中指定用户,容器会以 root 用户运行。如果你想以特定用户运行 pod,可以在 pod 的安全上下文中设置runAsUser属性,但这并不是理想的做法。最佳实践是将用户信息嵌入到容器镜像中。
我们已经讨论了如何故障排除运行中的容器,但如果容器由于某些原因无法启动怎么办?
让我们来看以下示例:
$ kubectl run nginx-1 --image=nginx-1
现在,让我们尝试获取 pod 并亲自查看:
$ kubectl get pod nginx-1
NAME READY STATUS RESTARTS AGE
nginx-1 0/1 ImagePullBackOff 0 25s
哎呀!现在出现了一些错误,状态是ImagePullBackOff。嗯,似乎是镜像出了些问题。虽然我们知道问题出在镜像上,但我们希望了解真正的问题所在,因此,为了进一步了解此问题,我们可以使用以下命令描述 pod:
$ kubectl describe pod nginx-1
现在,这为我们提供了关于 pod 的大量信息,如果你查看events部分,你会找到一行特定信息,告诉我们 pod 出了什么问题:
Warning Failed 60s (x4 over 2m43s) kubelet Failed to pull image "nginx-
1": rpc error: code = Unknown desc = failed to pull and unpack image "docker.io/library/
nginx-1:latest": failed to resolve reference "docker.io/library/nginx-1:latest": pull
access denied, repository does not exist or may require authorization: server message:
insufficient_scope: authorization failed
所以,这条信息告诉我们,要么仓库不存在,要么仓库存在但为私有仓库,因此授权失败。
提示
你可以使用kubectl describe来查看大多数 Kubernetes 资源。它应该是你在故障排除时使用的第一个命令。
由于我们知道该镜像不存在,让我们将镜像更换为有效的镜像。我们必须删除 Pod,并使用正确的镜像重新创建它。
要删除 Pod,请运行以下命令:
$ kubectl delete pod nginx-1
要重新创建 Pod,请运行以下命令:
$ kubectl run nginx-1 --image=nginx
现在,让我们获取 Pod;它应该按如下方式运行:
$ kubectl get pod nginx-1
NAME READY STATUS RESTARTS AGE
nginx-1 1/1 Running 0 42s
由于我们已经解决了镜像问题,Pod 现在已经在运行。
到目前为止,我们已经能够使用 Pod 运行容器,但 Pod 是非常强大的资源,可以帮助你管理容器。Kubernetes Pod 提供了探针来确保应用程序的可靠性。我们将在下一节中详细介绍这一点。
确保 Pod 可靠性
我们在第四章《创建和管理容器镜像》中讨论了健康检查,我还提到过你不应该在 Docker 层面使用健康检查,而应该使用容器编排器提供的健康检查。Kubernetes 提供了三种探针来监控你的 Pod 健康状况——启动探针、存活探针和就绪探针。
以下图示展示了三种探针的图形化表示:

图 5.3 – Kubernetes 探针
让我们逐一查看每种探针,了解如何使用它们以及何时使用它们。
启动探针
Kubernetes 使用启动探针检查应用程序是否已启动。你可以在启动缓慢的应用程序上使用启动探针,或者在你不知道启动可能需要多长时间的情况下使用它。当启动探针处于活动状态时,它会禁用其他探针,以免它们干扰启动探针的操作。由于应用程序在启动探针报告之前并未启动,因此没有必要让其他探针处于活动状态。
就绪探针
就绪探针用来确认容器是否准备好接受请求。它们与启动探针有所不同,因为就绪探针不仅仅检查应用程序是否已启动,它还确保容器可以开始处理请求。当 Pod 中的所有容器都准备好时,Pod 才算是就绪。就绪探针确保在 Pod 没有准备好时不会向其发送流量。因此,它可以提供更好的用户体验。
存活探针
如果将 Pod 的 restartPolicy 字段设置为 Always 或 OnFailure,Kubernetes 会重新启动容器。因此,通过检测死锁并确保容器正在运行而不仅仅是报告运行状态,它提高了服务的可靠性。
现在,让我们通过一个例子来更好地理解探针。
探针实战
让我们改进最后的清单并添加一些探针,以创建以下 nginx-probe.yaml 清单文件:
...
startupProbe:
exec:
command:
- cat
- /usr/share/nginx/html/index.html
failureThreshold: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: 5
periodSeconds: 5
livenessProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: 5
periodSeconds: 3
restartPolicy: Always
该清单文件包含所有三种探针:
-
启动探针检查
/usr/share/nginx/html/index.html文件是否存在。它会以 10 秒的间隔连续检查 30 次,直到其中一次检查成功。一旦检测到文件,启动探针将停止进一步检查。 -
就绪探针检查端口
80上是否有监听器,并以HTTP 2xx – 3xx on path /响应。它最初等待 5 秒,然后每 5 秒检查一次 Pod。如果它收到2xx – 3xx响应,它会报告容器已就绪并接受请求。 -
存活探针检查 Pod 是否在
port80上并且path /路径下响应HTTP 2xx – 3xx。它最初等待 5 秒,然后每 3 秒检查一次容器。假设在一次检查中,它发现 Pod 未响应failureThreshold次(默认为3)。在这种情况下,它将杀死容器,并且 kubelet 将根据 Pod 的restartPolicy字段采取适当的操作。 -
让我们应用 YAML 文件,并使用以下命令查看 Pod 的启动过程:
$ kubectl delete pod nginx && kubectl apply -f nginx-probe.yaml && \
kubectl get pod -w
NAME READY STATUS RESTARTS AGE
nginx 0/1 Running 0 4s
nginx 0/1 Running 0 11s
nginx 1/1 Running 0 12s
正如我们所见,Pod 从运行状态变为就绪状态的过程非常迅速。大约需要 10 秒钟,因为就绪探针在 Pod 启动后的 10 秒开始生效。然后,存活探针继续监控 Pod 的健康状况。
现在,让我们做一些事情来破坏存活检查。假设有人通过 shell 进入容器并删除了一些重要的文件。你认为存活探针会如何反应?我们来看看。
让我们从容器中删除/usr/share/nginx/html/index.html文件,然后使用以下命令检查容器的行为:
$ kubectl exec -it nginx -- rm -rf /usr/share/nginx/html/index.html && \
kubectl get pod nginx -w
NAME READY STATUS RESTARTS AGE
nginx 1/1 Running 0 2m5s
nginx 0/1 Running 1 (2s ago) 2m17s
nginx 1/1 Running 1 (8s ago) 2m22s
因此,当我们观察 Pod 时,初次删除只有在 9 秒后才被检测到。这是因为存活探针。它尝试了 9 秒钟,也就是三次 periodSeconds,因为 failureThreshold 默认为 3,才会宣布 Pod 不健康并杀死容器。容器一被杀死,kubelet 就会重新启动它,因为 Pod 的 restartPolicy 字段设置为 Always。然后,我们看到启动和就绪探针开始生效,很快,Pod 就变为就绪状态。因此,无论发生什么情况,你的 Pod 都是可靠的,即使你的应用程序的某部分出现故障,它仍然能够正常工作。
提示
使用就绪探针和存活探针有助于提供更好的用户体验,因为没有请求会发送到尚未准备好处理任何请求的 Pod。如果你的应用程序没有正确响应,它将替换容器。如果多个 Pod 正在运行以处理请求,那么你的服务具有极强的弹性。
正如我们之前讨论的,Pod 可以包含一个或多个容器。让我们来看一些可能需要多个容器而非一个容器的使用场景。
Pod 多容器设计模式
你可以通过两种方式在 Pod 中运行多个容器——将容器作为初始化容器运行,或者将容器作为主容器的辅助容器运行。我们将在接下来的子章节中探讨这两种方法。
初始化容器
初始化容器在主容器启动之前运行,因此你可以在主容器接管之前使用它们初始化容器环境。以下是一些示例:
-
在使用非 root 用户启动容器之前,某些目录可能需要特定的所有权或权限设置
-
在启动 web 服务器之前,你可能想要克隆一个 Git 仓库
-
你可以添加启动延迟
-
你可以动态生成配置,例如针对那些在构建时不知道但在运行时应该知道的容器,它们可能需要动态连接到其他某个 pod。
提示
仅将 init 容器作为最后手段使用,因为它们会拖慢容器的启动时间。尽量在容器镜像内预先配置或定制它。
现在,让我们看一个示例,了解 init 容器的实际应用。
要访问此部分的资源,cd 进入以下路径:
$ cd ~/modern-devops/ch5/multi-container-pod/init/
让我们通过 nginx web 服务器提供 example.com 网站。在启动 nginx 之前,我们将获取 example.com 网页并将其保存为 index.html 到 nginx 默认的 HTML 目录中。
访问清单文件 nginx-init.yaml,它应包含以下内容:
…
spec:
containers:
- name: nginx-container
image: nginx
volumeMounts:
- mountPath: /usr/share/nginx/html
name: html-volume
initContainers:
- name: init-nginx
image: busybox:1.28
command: ['sh', '-c', 'mkdir -p /usr/share/nginx/html && wget -O /usr/share/nginx/html/index.html http://example.com']
volumeMounts:
- mountPath: /usr/share/nginx/html
name: html-volume
volumes:
- name: html-volume
emptyDir: {}
如果我们查看清单文件的 spec 部分,我们会看到以下内容:
-
containers:此部分定义了一个或多个构成 pod 的容器。 -
containers.name:这是容器的名称,在这种情况下是nginx-container。 -
containers.image:这是容器镜像,在这种情况下是nginx。 -
containers.volumeMounts:这定义了应挂载到容器的卷列表。它类似于我们在 第四章 中学习的内容,创建和管理容器镜像。 -
containers.volumeMounts.mountPath:这定义了挂载卷的路径,在这种情况下是/usr/share/nginx/html。我们将与 init 容器共享这个卷,以便当 init 容器从example.com下载index.html文件时,这个目录中会包含相同的文件。 -
containers.volumeMounts.name:这是卷的名称,在这种情况下是html-volume。 -
initContainers:此部分定义了一个或多个在主容器之前运行的 init 容器。 -
initContainers.name:这是 init 容器的名称,在这种情况下是init-nginx。 -
initContainers.image:这是 init 容器的镜像,在这种情况下是busybox:1.28。 -
initContainers.command:这是 busybox 应执行的命令。在这种情况下,'mkdir -p /usr/share/nginx/html && wget -O /usr/share/nginx/html/index.html [example.com](http://example.com)'将下载example.com的内容到/usr/share/nginx/html目录。 -
initContainers.volumeMounts:我们将在此容器上挂载与nginx-container中定义的相同的卷。因此,我们在该卷中保存的任何内容都会自动出现在nginx-container中。 -
initContainers.volumeMounts.mountPath:这定义了挂载卷的路径,在这种情况下是/usr/share/nginx/html。 -
initContainers.volumeMounts.name:这是卷的名称,在本例中是html-volume。 -
volumes:此部分定义了与 Pod 容器相关联的一个或多个卷。 -
volumes.name:这是卷的名称,在本例中是html-volume。 -
volumes.emptyDir:这定义了一个emptyDir卷。它类似于 Docker 中的tmpfs卷,因此它不是持久的,只在容器的生命周期内存在。
所以,让我们继续应用清单,并使用以下命令观察 Pod 的启动过程:
$ kubectl delete pod nginx && kubectl apply -f nginx-init.yaml && \
kubectl get pod nginx -w
NAME READY STATUS RESTARTS AGE
nginx 0/1 Init:0/1 0 0s
nginx 0/1 PodInitializing 0 1s
nginx 1/1 Running 0 3s
最初,我们可以看到 nginx Pod 显示状态为 Init:0/1。这意味着 1 个初始化容器中有 0 个开始初始化。过了一段时间后,我们可以看到 Pod 报告其状态为 PodInitializing,这意味着初始化容器已经开始运行。初始化容器成功运行后,Pod 报告为运行状态。
现在,一旦 Pod 开始运行,我们可以使用以下命令将容器的端口 80 转发到主机端口 8080:
$ kubectl port-forward nginx 8080:80
打开一个新的终端窗口,尝试通过以下命令使用 curl 访问本地主机的端口 8080:
$ curl localhost:8080
<title>Example Domain</title>
在这里,我们可以看到来自我们 web 服务器的示例域响应。这意味着初始化容器工作正常。
正如你现在可能已经理解的那样,初始化容器的生命周期在主容器启动之前结束,一个 Pod 可以包含一个或多个主容器。接下来,我们来看看我们可以在主容器中使用的一些设计模式。
大使模式
localhost 在任何地方。
现在,你可以采取两种方法:
-
你可以更改应用程序代码,并使用配置映射和机密(稍后会详细介绍)将数据库连接详情注入到环境变量中。
-
你可以继续使用现有代码,并使用第二个容器作为 Redis 数据库的 TCP 代理。该 TCP 代理将与配置映射和机密连接,并包含 Redis 数据库的连接详情。
提示
大使模式帮助开发人员专注于应用程序,而不必担心配置细节。如果你想将应用程序开发与配置管理解耦,可以考虑使用它。
第二种方法解决了我们希望进行完全相同迁移的问题。我们可以使用配置映射来定义特定环境的配置,而无需更改应用程序代码。以下图示展示了这种方法:

图 5.4 – 大使模式
在深入技术细节之前,我们先了解一下配置映射。
配置映射
配置映射 包含键值对,我们可以用于多种目的,例如定义特定环境的属性,或者在容器启动时或运行时注入外部变量。
配置映射的理念是将应用程序与配置解耦,并在 Kubernetes 层次上外部化配置。这类似于使用属性文件,例如,定义特定环境的配置。
以下图表对此进行了很好的解释:

图 5.5 – 配置映射
我们将使用ConfigMap在代理容器内定义外部 Redis 数据库的连接属性。
示例应用程序
我们将使用在第三章中使用的示例应用程序,使用 Docker 进行容器化,以及使用 Docker Compose 部署示例应用程序部分。源代码已复制到以下目录:
$ cd ~/modern-devops/ch5/multi-container-pod/ambassador
你可以查看 Flask 应用程序的app.py文件、requirements.txt文件和 Dockerfile,以了解该应用程序的功能。
现在,让我们使用以下命令构建容器:
$ docker build -t <your_dockerhub_user>/flask-redis .
让我们使用以下命令将其推送到我们的容器注册表:
$ docker push <your_dockerhub_user>/flask-redis
正如你可能注意到的,app.py代码将缓存定义为localhost:6379。我们将在localhost:6379上运行一个代理容器。代理将把连接隧道转发到其他地方运行的redis Pod。
首先,让我们使用以下命令创建redis Pod:
$ kubectl run redis --image=redis
现在,让我们通过Service资源将redis Pod 暴露给集群资源。这将允许集群中的任何 Pod 通过redis主机名与redis Pod 进行通信。我们将在下一章详细讨论 Kubernetes Service资源:
$ kubectl expose pod redis --port 6379
酷!现在 Pod 和Service资源已经启动并运行,让我们来处理代理模式。
我们首先需要定义两个配置映射。第一个描述redis主机和端口信息,第二个定义作为反向代理工作的模板nginx.conf文件。
redis-config-map.yaml文件如下所示:
apiVersion: v1
kind: ConfigMap
metadata:
name: redis-config
data:
host: "redis"
port: "6379"
上面的 YAML 文件定义了一个名为redis-config的配置映射,其中包含host和port属性。你可以拥有多个配置映射,每个环境一个。
nginx-config-map.yaml文件如下所示:
apiVersion: v1
kind: ConfigMap
metadata:
name: nginx-config
data:
nginx.conf: |
...
stream {
server {
listen 6379;
proxy_pass stream_redis_backend;
}
upstream stream_redis_backend {
server REDIS_HOST:REDIS_PORT;
}
}
该配置映射将nginx.conf模板作为配置映射值注入。此模板定义了我们的代理 Pod 配置,使其监听localhost:6379并将连接隧道转发到REDIS_HOST:REDIS_PORT。由于REDIS_HOST和REDIS_PORT值是占位符,我们必须用从redis-config配置映射中获得的正确值填充这些占位符。为此,我们可以将此文件挂载到一个卷上,然后进行操作。我们可以使用initContainer来使用正确的配置初始化代理。
现在,让我们查看 Pod 配置清单flask-ambassador.yaml。该 YAML 文件包含多个部分。首先,让我们看看containers部分:
...
spec:
containers:
- name: flask-app
image: <your_dockerhub_user>/flask-redis
- name: nginx-ambassador
image: nginx
volumeMounts:
- mountPath: /etc/nginx
name: nginx-volume
...
本节包含一个名为flask-app的容器,它使用我们在上一节中构建的<your_dockerhub_user>/flask-redis镜像。第二个容器是nginx-ambassador容器,它将充当代理与redis通信。因此,我们已将/etc/nginx目录挂载到一个卷中。此卷也挂载在初始化容器中,用于在nginx启动之前生成所需的配置。
以下是initContainers部分:
initContainers:
- name: init-nginx
image: busybox:1.28
command: ['sh', '-c', 'cp -L /config/nginx.conf /etc/nginx/nginx.conf && sed -i "s/
REDIS_HOST/${REDIS_HOST}/g" /etc/nginx/nginx.conf']
env:
- name: REDIS_HOST
valueFrom:
configMapKeyRef:
name: redis-config
key: host
- name: REDIS_PORT
valueFrom:
configMapKeyRef:
name: redis-config
key: port
volumeMounts:
- mountPath: /etc/nginx
name: nginx-volume
- mountPath: /config
name: config
本节定义了一个busybox容器——init-nginx。该容器需要生成与 Redis 通信的nginx-ambassador代理配置;因此,存在两个环境变量。这两个环境变量来自redis-config配置映射。此外,我们还从nginx-config配置映射中挂载了nginx.conf文件。初始化容器中的command部分使用这些环境变量替换nginx.conf文件中的占位符,之后我们就得到了与 Redis 后端的 TCP 代理。
volumes部分将nginx-volume定义为一个emptyDir卷,并将config卷挂载到nginx.conf文件,该文件位于nginx-config配置映射中:
volumes:
- name: nginx-volume
emptyDir: {}
- name: config
configMap:
name: nginx-config
items:
- key: "nginx.conf"
path: "nginx.conf"
现在,让我们开始分步应用 YAML 文件。
使用以下命令应用两个配置映射:
$ kubectl apply -f redis-config-map.yaml
$ kubectl apply -f nginx-config-map.yaml
使用以下命令应用 pod 配置:
$ kubectl apply -f flask-ambassador.yaml
使用以下命令获取 pod 查看配置是否正确:
$ kubectl get pod/flask-ambassador
NAME READY STATUS RESTARTS AGE
flask-ambassador 2/2 Running 0 10s
由于 pod 现在运行正常,让我们通过以下命令将5000端口转发到本地主机以进行一些测试:
$ kubectl port-forward flask-ambassador 5000:5000
现在,打开一个新的终端,并使用以下命令尝试在localhost:5000上进行curl测试:
$ curl localhost:5000
Hi there! This page was last visited on 2023-06-18, 16:52:28.
$ curl localhost:5000
Hi there! This page was last visited on 2023-06-18, 16:52:28.
$ curl localhost:5000
Hi there! This page was last visited on 2023-16-28, 16:52:32.
如我们所见,每次执行curl请求时,都会在屏幕上显示最后一次访问时间。Ambassador 模式正在正常工作。
这是一个简单的 Ambassador 模式示例。你可以进行更高级的配置,以细粒度控制你的应用如何与外界交互。你可以使用 Ambassador 模式来保护从容器传输的流量。它还简化了应用开发过程,开发团队无需担心这些细节。而运维团队可以使用这些容器更好地管理环境,而不必互相干扰。
提示
由于 Ambassador 模式通过代理隧道连接会增加一些开销,因此只有在管理上的好处超过因使用 Ambassador 容器而产生的额外成本时,才应使用此模式。
现在,让我们来看一下另一种多容器 pod 模式——sidecar 模式。
Sidecar 模式
旁车这个名字来源于摩托车的副驾驶厢。旁车并不会改变摩托车的核心功能,而且没有旁车,摩托车也能正常运行。旁车只是增加了一个额外的座位,提供一个功能,帮助你载一个额外的人。类似地,Pod 中的旁车是提供与主容器核心功能无关的辅助容器,增强主容器的功能。例如,日志记录和监控容器。将日志单独放入一个容器,有助于将日志职责与主容器解耦,从而即使主容器因为某些原因宕机时,你也能继续监控你的应用程序。
如果日志代码出现问题,并且不会导致整个应用程序崩溃,而只是影响日志容器,这时使用这种方式会很有帮助。你还可以使用旁车(sidecar)将辅助或相关容器与主容器放在一起,因为我们知道,同一个 Pod 内的容器共享同一台机器。
提示
只有在两个容器功能上有直接关系并且作为一个整体工作时,才使用多容器 Pod。
你还可以使用旁车将应用程序与秘密数据隔离开。例如,如果你正在运行一个需要访问特定密码才能运行的 Web 应用程序,最好将秘密数据挂载到旁车容器上,并通过旁车容器将密码提供给 Web 应用程序。这是因为如果有人访问了你的应用程序容器的文件系统,他们是无法获取到密码的,因为密码是由另一个容器提供的,具体如下面的图所示:

图 5.6 – 旁车模式
让我们实现前面的模式来更好地理解旁车。我们有一个与 Redis 旁车交互的 Flask 应用程序。我们将使用 Kubernetes 的 Secret 资源将一个名为 foobar 的秘密预先填充到 Redis 旁车中。
秘密
使用 base64 编码而不是 plaintext。虽然从安全角度来看,base64 编码并不会提供比 plaintext 更好的保护,但你应当使用秘密存储敏感信息,如密码。因为 Kubernetes 社区会在未来的版本中开发解决方案,加强对秘密数据的安全保护。如果你使用秘密存储,你将直接受益于这些改进。
提示
一般来说,始终使用秘密存储机密数据,如 API 密钥和密码,而配置映射则用于存储非敏感的配置数据。
要访问本节的文件,请前往以下目录:
$ cd ~/modern-devops/ch5/multi-container-pod/sidecar
现在,让我们继续看一下示例 Flask 应用程序。
示例应用程序
Flask 应用程序查询 Redis 旁车以获取密钥并将其作为响应返回。这样做并不理想,因为你不应该将秘密数据作为响应返回,但为了演示的目的,我们就照这样做。
所以,首先,让我们设计我们的旁车容器,让它在启动后预填充容器内的数据。
我们需要创建一个名为 secret 的密钥,其值为 foobar。现在,通过运行以下命令将 Redis 命令进行 base64 编码,将密钥设置到缓存中:
$ echo 'SET secret foobar' | base64
U0VUIHNlY3JldCBmb29iYXIK
现在我们有了 base64 编码的密钥,我们可以创建一个名为 redis-secret.yaml 的清单,并按照以下字符串创建:
apiVersion: v1
kind: Secret
metadata:
name: redis-secret
data:
redis-secret: U0VUIHNlY3JldCBmb29iYXIK
然后,我们需要构建 Redis 容器,以便在启动时创建此密钥。要访问本节的文件,请转到以下目录:
$ cd ~/modern-devops/ch5/multi-container-pod/sidecar/redis/
创建一个 entrypoint.sh 文件,如下所示:
redis-server --daemonize yes && sleep 5
redis-cli < /redis-master/init.redis
redis-cli save
redis-cli shutdown
redis-server
Shell 脚本查找 /redis-master 目录中的文件 init.redis,并在其上运行 redis-cli 命令。这意味着缓存将使用我们的密钥中定义的值进行预填充,前提是我们将密钥作为 /redis-master/init.redis 挂载。
然后,我们必须创建一个 Dockerfile,该文件将使用这个 entrypoint.sh 脚本,如下所示:
FROM redis
COPY entrypoint.sh /tmp/
CMD ["sh", "/tmp/entrypoint.sh"]
现在我们准备好了,可以构建并将代码推送到 Docker Hub:
$ docker build -t <your_dockerhub_user>/redis-secret .
$ docker push <your_dockerhub_user>/redis-secret
现在,我们已经准备好了 Redis 镜像,我们必须构建 Flask 应用程序镜像。要访问本节的文件,请cd进入以下目录:
$ cd ~/modern-devops/ch5/multi-container-pod/sidecar/flask
让我们首先查看 app.py 文件:
...
cache = redis.Redis(host='localhost', port=6379)
def get_secret():
try:
secret = cache.get('secret')
return secret
...
def index():
secret = str(get_secret().decode('utf-8'))
return 'Hi there! The secret is {}.\n'.format(secret)
代码很简单——它从缓存中获取密钥并将其作为响应返回。
我们还创建了与上一节相同的 Dockerfile。
因此,让我们构建并将容器镜像推送到 Docker Hub:
$ docker build -t <your_dockerhub_user>/flask-redis-secret .
$ docker push <your_dockerhub_user>/flask-redis-secret
现在我们的镜像准备好了,让我们看看 Pod 清单,flask-sidecar.yaml,该清单位于 ~/``modern-devops/ch5/multi-container-pod/sidecar/ 目录中:
...
spec:
containers:
- name: flask-app
image: <your_dockerhub_user>/flask-redis-secret
- name: redis-sidecar
image: <your_dockerhub_user>/redis-secret
volumeMounts:
- mountPath: /redis-master
name: secret
volumes:
- name: secret
secret:
secretName: redis-secret
items:
- key: redis-secret
path: init.redis
Pod 定义了两个容器——flask-app 和 redis-sidecar。flask-app 容器运行 Flask 应用程序,该应用程序将与 redis-sidecar 交互以获取密钥。redis-sidecar 容器已将 secret 卷挂载到 /redis-master。Pod 定义还包含一个名为 secret 的单个卷,卷指向 redis-secret 密钥,并将其作为文件 init.redis 挂载。
因此,最后,我们有一个文件 /redis-master/init.redis,正如我们所知,entrypoint.sh 脚本查找此文件并运行 redis-cli 命令,以预填充 Redis 缓存中的秘密数据。
让我们首先使用以下命令应用这个密钥:
$ kubectl apply -f redis-secret.yaml
然后,我们可以使用以下命令应用 flask-sidecar.yaml 文件:
$ kubectl apply -f flask-sidecar.yaml
现在,让我们使用以下命令获取 Pod:
$ kubectl get pod flask-sidecar
NAME READY STATUS RESTARTS AGE
flask-sidecar 2/2 Running 0 11s
由于 Pod 正在运行,现在是时候使用以下命令将其端口转发到主机:
$ kubectl port-forward flask-sidecar 5000:5000
现在,让我们打开一个重复的终端,运行 curl localhost:5000 命令,并看看我们得到了什么:
$ curl localhost:5000
Hi there! The secret is foobar.
正如我们所看到的,我们在响应中得到了密钥 foobar。边车工作正常!
现在,让我们先看另一个流行的多容器 Pod 模式——适配器模式。
适配器模式
正如其名称所示,适配器模式有助于将某些事物转换为符合标准的形式,比如手机和笔记本电脑的适配器,它们将我们的主要电源转换为设备可以使用的形式。适配器模式的一个很好的例子是转换日志文件,使其符合企业标准,并将其传输到你的日志分析解决方案中:

图 5.7 – 适配器模式
当你有一个异构解决方案输出多种格式的日志文件,但却只有一个日志分析解决方案,它只接受特定格式的消息时,这时就会有所帮助。实现这一目标有两种方法:一种是更改输出日志文件的代码以符合标准格式,另一种是使用适配器容器来执行转换。
让我们通过以下场景进一步理解它。
我们有一个应用程序,它不断输出没有日期前缀的日志文件。我们的适配器应该读取日志流,并在每次生成日志行时添加时间戳。
为此,我们将使用以下的 pod 清单文件,app-adapter.yaml:
...
spec:
volumes:
- name: logs
emptyDir: {}
containers:
- name: app-container
image: ubuntu
command: ["/bin/bash"]
args: ["-c", "while true; do echo 'This is a log line' >> /var/log/app.log; sleep
2;done"]
volumeMounts:
- name: logs
mountPath: /var/log
- name: log-adapter
image: ubuntu
command: ["/bin/bash"]
args: ["-c", "apt update -y && apt install -y moreutils && tail -f /var/log/app.log |
ts '[%Y-%m-%d %H:%M:%S]' > /var/log/out.log"]
volumeMounts:
- name: logs
mountPath: /var/log
该 pod 包含两个容器 —— 应用容器,它是一个简单的 Ubuntu 容器,每 2 秒输出一次 This is a log line,以及日志适配器容器,它持续地跟踪 app.log 文件,在每一行的开头添加时间戳,并将结果输出到 /var/log/out.log。这两个容器共享 /var/log 卷,该卷作为 emptyDir 卷挂载在两个容器中。
现在,让我们使用以下命令应用这个清单:
$ kubectl apply -f app-adapter.yaml
让我们等一会儿,通过以下命令检查 pod 是否在运行:
$ kubectl get pod app-adapter
NAME READY STATUS RESTARTS AGE
app-adapter 2/2 Running 0 8s
当 pod 正在运行时,我们现在可以通过以下命令进入日志适配器容器的 shell:
$ kubectl exec -it app-adapter -c log-adapter -- bash
当我们进入 shell 后,可以通过以下命令cd到/var/log目录,并列出其中的内容:
root@app-adapter:/# cd /var/log/ && ls
app.log apt/ dpkg.log out.log
正如我们所看到的,我们得到了app.log和out.log这两个文件。现在,让我们使用cat命令打印它们,看看会得到什么。
首先,使用以下命令cat app.log文件:
root@app-adapter:/var/log# cat app.log
This is a log line
This is a log line
This is a log line
在这里,我们可以看到一系列的日志行正在被打印。
现在,使用以下命令cat out.log文件,看看会得到什么:
root@app-adapter:/var/log# cat out.log
[2023-06-18 16:35:25] This is a log line
[2023-06-18 16:35:27] This is a log line
[2023-06-18 16:35:29] This is a log line
在这里,我们可以看到日志行前面有时间戳。这意味着适配器模式正常工作。然后,你可以将此日志文件导出到你的日志分析工具中。
总结
我们已经完成了这一关键章节的内容。我们已经涵盖了足够的内容,能够帮助你入门 Kubernetes,并理解并重视其最佳实践。
我们从 Kubernetes 及其必要性开始,然后讨论了使用 Minikube 和 KinD 引导 Kubernetes 集群。接着,我们了解了 pod 资源,讨论了如何创建和管理 pod,排除故障,确保应用程序的可靠性(使用探针),以及多容器设计模式,深入理解为什么 Kubernetes 使用 pod 而不是单纯的容器。
在下一章中,我们将深入探讨 Kubernetes 的高级方面,涵盖控制器、服务、入口、管理有状态应用程序以及 Kubernetes 命令行最佳实践。
问题
请回答以下问题,测试你对本章的知识掌握情况:
-
与 Kubernetes 的所有通信通过以下哪一项进行?
A. Kubelet
B. API 服务器
C. Etcd
D. 控制器管理器
E. 调度器
-
以下哪个负责确保集群处于期望状态?
A. Kubelet
B. API 服务器
C. Etcd
D. 控制器管理器
E. 调度器
-
以下哪个负责存储集群的期望状态?
A. Kubelet
B. API 服务器
C. Etcd
D. 控制器管理器
E. 调度器
-
Pod 可以包含多个容器。(正确/错误)
-
你可以使用端口转发来处理以下哪些使用场景?(选择两项)
A. 用于故障排除行为异常的 Pod
B. 用于将服务暴露到互联网
C. 用于访问系统服务,例如 Kubernetes 仪表盘
-
结合使用以下哪两种探针可以帮助确保即使应用程序有一些间歇性问题时,它仍然可靠?(选择两项)
A. 启动探针
B. 存活探针
C. 就绪探针
-
我们可以在生产环境中使用 KinD。(正确/错误)
-
以下哪个多容器模式用于作为前向代理?
A. 使者容器
B. 适配器
C. 边车容器
D. 初始化容器
答案
以下是本章问题的答案:
-
B
-
D
-
C
-
正确
-
A, C
-
B, C
-
错误
-
A
第六章:管理高级 Kubernetes 资源
在上一章中,我们介绍了 Kubernetes 及其需求,并讨论了如何使用 MiniKube 和 KinD 启动 Kubernetes 集群。接着,我们查看了 Pod 资源,讨论了如何创建和管理 Pods,如何排查问题,并如何通过探针确保应用程序的可靠性,还探讨了多容器设计模式,以理解 Kubernetes 为什么使用 Pods 而不是容器。我们还了解了 Secrets 和 ConfigMaps。
现在,我们将深入探讨 Kubernetes 的高级部分以及 Kubernetes 命令行最佳实践。
在本章中,我们将讨论以下主要内容:
-
高级 Kubernetes 资源的需求
-
Kubernetes 部署
-
Kubernetes 服务和入口
-
水平 Pod 自动扩展
-
管理有状态的应用程序
-
Kubernetes 命令行最佳实践、技巧和窍门
所以,让我们开始吧!
技术要求
对于本章,我们将为练习启动一个基于云的 Kubernetes 集群,Google Kubernetes Engine(GKE)。因为你将无法在本地系统中启动负载均衡器和 PersistentVolumes,所以在本章中我们不能使用 KinD 和 MiniKube。
目前,Google Cloud Platform(GCP)提供免费的 $300 试用,期限为 90 天,所以你可以前往 cloud.google.com/free 注册。
启动 GKE
注册并登录到控制台后,你可以打开 Google Cloud Shell CLI 来运行命令。
你需要先启用 GKE API,使用以下命令:
$ gcloud services enable container.googleapis.com
要创建一个三节点 GKE 集群,运行以下命令:
$ gcloud container clusters create cluster-1 --zone us-central1-a
就是这样!集群已成功启动并运行。
你还需要克隆以下 GitHub 仓库来进行一些练习:github.com/PacktPublishing/Modern-DevOps-Practices-2e。
运行以下命令将仓库克隆到你的主目录,并使用 cd 进入 ch6 目录以访问所需的资源:
$ git clone https://github.com/PacktPublishing/Modern-DevOps-Practices-2e.git \
modern-devops
$ cd modern-devops/ch6
现在,让我们了解一下为什么我们需要高级 Kubernetes 资源。
高级 Kubernetes 资源的需求
在上一章中,我们讨论了 pod,这是 Kubernetes 的基本构建块,为您的容器在 Kubernetes 环境中提供一切必需的内容。然而,单独的 pod 并不是那么有效。原因在于,尽管它们定义了容器应用及其规范,但它们不会复制、自动修复或维护特定状态。删除 pod 后,该 pod 就不存在了。您不能使用 pod 维护代码的多个版本,也不能使用 pod 进行发布和回滚。使用仅有的 pod 也不能根据流量自动缩放您的应用程序。Pod 无法让您将容器暴露给外部世界,也不提供负载均衡、基于内容和路径的路由、将持久数据存储到外部附加存储等流量管理功能。为了解决这些问题,Kubernetes 为我们提供了特定的高级资源,如 Deployments、Services、Ingresses、PersistentVolumes 和 claims,以及 StatefulSets。让我们在下一节中从 Kubernetes 部署开始。
Kubernetes 部署
让我们通过一个简单的类比来理解 Kubernetes 部署。
想象一下,您是一位在厨房里准备特定菜肴的厨师。您希望确保每次上菜时都能保持一致的完美,同时希望能够更改食谱而不引起混乱。
在 Kubernetes 的世界中,“Deployment” 就像是您的副厨。它帮助您轻松创建和管理您的 pod 的副本。
它是如何工作的:
-
创建一致性:您希望将您的菜肴服务给许多客人。因此,与其分别烹饪每份盘子,不如一次性准备一堆。所有的盘子都应该味道相同,完全按照预期来。Kubernetes 部署为您的 pod 做到了这一点,创建多个相同的 pod 副本,确保它们都具有相同的设置。
-
Deployment资源会逐个缓慢而谨慎地替换旧副本,以确保您的应用始终可用,您的访客(或用户)不会注意到任何中断。 -
优雅回滚:有时,实验并不如预期那样顺利,您可能需要回到原始食谱。就像在厨房里一样,Kubernetes 允许您将 pod 的版本回滚到之前的版本,如果新版本出现问题的话。
-
轻松扩展:想象一下您的餐厅突然迎来了一波顾客,您需要更多的盘子来盛放您的特色菜肴。Kubernetes 部署也能帮助您做到这一点。它可以快速创建更多的 pod 副本来处理增加的需求,并在事态平静下来时移除它们。
-
管理多个厨房:如果您有多家餐馆,您希望您的招牌菜在所有餐馆中味道一致。类似地,如果您在测试、开发和生产等不同环境中使用 Kubernetes,部署可以帮助保持一致性。
本质上,Kubernetes 的 Deployments 帮助管理你的 pod,就像副厨师管理厨房里做的菜肴一样。它们确保一致性、安全性和灵活性,确保你的应用顺利运行,并且可以在不打乱你 软件厨房 的情况下进行更新。
Kubernetes 中的容器应用部署是通过 Deployment 资源来完成的。Deployment 资源背后使用了 ReplicaSet 资源,因此在继续学习 Deployment 资源之前,了解 ReplicaSet 资源会很有帮助。
ReplicaSet 资源
ReplicaSet 资源是 Kubernetes 控制器,帮助你在给定时间运行多个 pod 副本。它们为容器工作负载提供水平扩展,形成容器的水平扩展集的基本构建块,一组相似的容器组合在一起作为一个单元运行。
ReplicaSet 资源定义了在给定时间运行的 pod 副本数目。Kubernetes 控制器随后会尝试维持副本,并在 pod 宕机时重新创建它。
你不应单独使用 ReplicaSet 资源,而应将其作为 Deployment 资源的后端。
为了更好理解,我们来看一个例子。要访问本节的资源,cd 到以下目录:
$ cd ~/modern-devops/ch6/deployments/
ReplicaSet 资源清单 nginx-replica-set.yaml 看起来是这样的:
apiVersion: apps/v1
kind: ReplicaSet
metadata:
name: nginx
labels:
app: nginx
spec:
replicas: 3
selector:
matchLabels:
app: nginx
template:
metadata:
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx
资源清单包括 apiVersion 和 kind,就像其他任何资源一样。它还包含一个 metadata 部分,用于定义资源的 name 和 labels 属性,类似于其他任何 Kubernetes 资源。
spec 部分包含以下属性:
-
replicas:这定义了通过选择器匹配的 pod 副本数目,在给定时间内运行。 -
selector:这定义了ReplicaSet资源将包含 pod 的基础。 -
selector.matchLabels:这定义了选择 pod 的标签及其值。因此,ReplicaSet资源将选择任何具有app:nginx标签的 pod。 -
template:这是一个可选部分,你可以使用它来定义 pod 模板。这个部分的内容非常类似于定义一个 pod,唯一不同的是它没有name属性,因为ReplicaSet资源会为 pod 生成动态名称。如果不包含这个部分,ReplicaSet资源仍会尝试获取具有匹配标签的现有 pod。但由于缺少模板,它不能创建新的 pod。因此,最佳实践是为ReplicaSet资源指定一个模板。
让我们继续应用这个清单,看看会得到什么:
$ kubectl apply -f nginx-replica-set.yaml
现在,让我们运行以下命令列出 ReplicaSet 资源:
$ kubectl get replicaset
NAME DESIRED CURRENT READY AGE
nginx 3 3 0 9s
对的——我们看到有三个期望的副本。目前,3 个副本正在运行,但 0 个副本准备好。让我们等一会儿,然后重新运行以下命令:
$ kubectl get replicaset
NAME DESIRED CURRENT READY AGE
nginx 3 3 3 1m10s
现在,我们看到 3 个已就绪的 pod 正在等待连接。接下来,让我们列出这些 pod,看看 ReplicaSet 资源在幕后做了什么,使用以下命令:
$ kubectl get pod
NAME READY STATUS RESTARTS AGE
nginx-6qr9j 1/1 Running 0 1m32s
nginx-7hkqv 1/1 Running 0 1m32s
nginx-9kvkj 1/1 Running 0 1m32s
有三个 nginx pod,每个名称以 nginx 开头,但以随机哈希值结尾。ReplicaSet 资源会在 ReplicaSet 资源名称的末尾附加一个随机哈希,以生成唯一的 pod。是的——Kubernetes 中每种资源的名称都应该是唯一的。
让我们继续使用以下命令删除 ReplicaSet 资源中的一个 pod,并查看结果:
$ kubectl delete pod nginx-9kvkj && kubectl get pod
pod "nginx-9kvkj" deleted
NAME READY STATUS RESTARTS AGE
nginx-6qr9j 1/1 Running 0 8m34s
nginx-7hkqv 1/1 Running 0 8m34s
nginx-9xbdf 1/1 Running 0 5s
我们看到,尽管我们删除了 nginx-9kvkj pod,但 ReplicaSet 资源已经用一个新 pod nginx-9xbdf 替代了它。这就是 ReplicaSet 资源的工作方式。
你可以像删除其他 Kubernetes 资源一样删除 ReplicaSet 资源。你可以运行命令 kubectl delete replicaset <ReplicaSet 名称> 来进行命令式删除,或者使用 kubectl delete -f <清单文件> 进行声明式删除。
我们使用之前的方法,通过以下命令删除 ReplicaSet 资源:
$ kubectl delete replicaset nginx
让我们运行以下命令检查 ReplicaSet 资源是否已被删除:
$ kubectl get replicaset
No resources found in default namespace.
在 default 命名空间中没有任何资源。这意味着 ReplicaSet 资源已被删除。
正如我们讨论的,ReplicaSet 资源不应单独使用,而应作为 Deployment 资源的后台。接下来我们来看一下 Kubernetes Deployment 资源。
Deployment 资源
Kubernetes Deployment 资源有助于管理容器应用程序的部署。它们通常用于管理无状态工作负载。尽管你仍然可以使用它们来管理有状态应用程序,但推荐的有状态应用程序处理方式是使用 StatefulSet 资源。
Kubernetes Deployments 使用 ReplicaSet 资源作为后台,资源链条如下图所示:

图 6.1 – Deployment 链接
让我们以之前的示例为基础,创建一个 nginx Deployment 资源清单——nginx-deployment.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx
labels:
app: nginx
spec:
replicas: 3
selector:
matchLabels:
app: nginx
template:
metadata:
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx
该清单与 ReplicaSet 资源非常相似,唯一不同的是 kind 属性——在这种情况下是 Deployment。
让我们通过以下命令应用该清单:
$ kubectl apply -f nginx-deployment.yaml
所以,Deployment 资源已经创建,让我们看看它创建的资源链条。通过以下命令运行 kubectl get 来列出 Deployment 资源:
$ kubectl get deployment
NAME READY UP-TO-DATE AVAILABLE AGE
nginx 3/3 3 3 6s
我们看到有一个名为 nginx 的 Deployment 资源,包含 3/3 个已就绪 pod 和 3 个最新的 pod。由于 Deployment 资源管理多个版本,UP-TO-DATE 表示最新的 Deployment 资源是否已成功滚动发布。我们将在后续部分详细讨论这一点。同时,它显示当前有 3 个可用的 pod。
由于我们知道Deployment资源会在后台创建ReplicaSet资源,我们可以使用以下命令查看ReplicaSet资源:
$ kubectl get replicaset
NAME DESIRED CURRENT READY AGE
nginx-6799fc88d8 3 3 3 11s
我们可以看到,Deployment资源创建了一个ReplicaSet资源,其名称以nginx开头,并以一个随机哈希值结尾。这是必要的,因为一个Deployment资源可能包含一个或多个ReplicaSet资源。我们将在后续部分了解这一点。
接下来是 pods,我们可以使用以下命令查看这些 pods:
$ kubectl get pod
NAME READY STATUS RESTARTS AGE
nginx-6799fc88d8-d52mj 1/1 Running 0 15s
nginx-6799fc88d8-dmpbn 1/1 Running 0 15s
nginx-6799fc88d8-msvxw 1/1 Running 0 15s
正如预期的那样,我们有三个 pod。每个 pod 的名称都以ReplicaSet资源名称开始,并以一个随机哈希值结束。这就是为什么你会在 pod 名称中看到两个哈希值。
假设你有一个新版本并希望部署一个新的容器镜像。那么,我们可以使用以下命令更新Deployment资源并使用新的镜像:
$ kubectl set image deployment/nginx nginx=nginx:1.16.1
deployment.apps/nginx image updated
要检查部署状态,可以运行以下命令:
$ kubectl rollout status deployment nginx
deployment "nginx" successfully rolled out
像刚才显示的这些命令,通常不会在生产环境中使用,因为它们缺乏使用 Git 版本控制的声明性清单所提供的审计追踪。然而,如果你选择使用这些命令,你始终可以使用以下命令记录上次发布的更改原因:
$ kubectl annotate deployment nginx kubernetes.io/change-cause\
="Updated nginx version to 1.16.1" --overwrite=true
deployment.apps/nginx annotated
要查看部署历史,可以运行以下命令:
$ kubectl rollout history deployment nginx
deployment.apps/nginx
REVISION CHANGE-CAUSE
1 <none>
2 Updated nginx version to 1.16.1
如我们所见,部署历史中有两个修订版本。修订版本1是最初的部署,修订版本2是由于我们运行了kubectl set image命令,正如CHANGE-CAUSE列中所显示的那样。
假设你在部署后发现了问题,并希望回滚到先前的版本。为了这样做,并重新检查部署的状态,可以运行以下命令:
$ kubectl rollout undo deployment nginx && kubectl rollout status deployment nginx
deployment.apps/nginx rolled back
Waiting for deployment "nginx" rollout to finish: 2 out of 3 new replicas have been
updated...
Waiting for deployment "nginx" rollout to finish: 1 old replicas are pending
termination...
deployment "nginx" successfully rolled out
最后,我们可以使用以下命令重新检查部署历史:
$ kubectl rollout history deployment nginx
deployment.apps/nginx
REVISION CHANGE-CAUSE
2 Updated nginx version to 1.16.1
3 <none>
然后我们得到修订版本3,其CHANGE-CAUSE值为<none>。在这种情况下,我们没有像上一个命令那样注释回滚。
提示
始终注释部署更新,因为这样可以更容易查看历史记录,了解部署了什么内容。
现在,让我们来看看一些常见的 Kubernetes 部署策略,以便了解如何有效地使用部署。
Kubernetes 部署策略
更新现有的部署需要指定一个新的容器镜像。这就是为什么我们首先对容器镜像进行版本控制,以便可以根据需要滚动发布和回滚应用程序更改的原因。由于我们所有的应用都运行在容器中——容器本质上是短暂的——这使得我们可以实现多种不同的部署策略。这里有几种部署策略,其中一些如下所示:
-
重新创建:这是所有方法中最简单的一种。删除旧的 pod 并部署一个新的。
-
滚动更新:在运行旧版本的同时,逐步推出新版本的 pods,并随着新 pods 的准备好,逐步删除旧的 pods。
-
蓝绿发布:这是一种派生的部署策略,我们同时运行两个版本,当需要时将流量切换到新版。
-
金丝雀发布:这适用于蓝绿部署(Blue/Green Deployments),其中我们在完全推出版本之前,将一定比例的流量切换到应用程序的新版。
-
A/B 测试:A/B 测试更多是一种应用于蓝绿部署的技术。这是当你希望将新版推出给一部分愿意的用户,并在完全推出新版之前研究他们的使用模式。Kubernetes 并没有开箱即用的 A/B 测试功能,但你可以依赖与 Kubernetes 配合良好的服务网格技术,如 Istio、Linkerd 和 Traefik。
Kubernetes 提供了两种开箱即用的部署策略——Recreate 和 RollingUpdate。
重建
Recreate 策略是最简单直接的部署策略。当你使用 Recreate 策略更新 Deployment 资源时,Kubernetes 会立即停止旧的 ReplicaSet 资源,并根据以下图示创建一个新的 ReplicaSet 资源,且具有所需数量的副本:

图 6.2 – Recreate 策略
Kubernetes 不会删除旧的 ReplicaSet 资源,而是将 replicas 设置为 0。这是为了能够快速回滚到旧版本。这种方法会导致停机时间,因此你只应在有约束的情况下使用。因此,这种策略不是 Kubernetes 的默认部署策略。
提示
如果你的应用程序不支持多个副本,或者它不支持超过一定数量的副本(例如需要维持法定人数的应用程序),或者它不支持同时运行多个版本,那么你可以使用 Recreate 策略。
让我们使用 Recreate 策略更新 nginx-deployment。让我们来看一下 nginx-recreate.yaml 文件:
...
spec:
replicas: 3
strategy:
type: Recreate
...
YAML 文件现在包含一个 strategy 部分,并且设置为 Recreate 类型。现在,让我们应用 nginx-recreate.yaml 文件,并使用以下命令查看 ReplicaSet 资源:
$ kubectl apply -f nginx-recreate.yaml && kubectl get replicaset -w
deployment.apps/nginx configured
NAME DESIRED CURRENT READY AGE
nginx-6799fc88d8 0 0 0 0s
nginx-6889dfccd5 0 3 3 7m42s
nginx-6889dfccd5 0 0 0 7m42s
nginx-6799fc88d8 3 0 0 1s
nginx-6799fc88d8 3 3 0 2s
nginx-6799fc88d8 3 3 3 6s
Deployment 资源会创建一个新的 ReplicaSet 资源——nginx-6799fc88d8——其期望副本数为 0。然后,它将旧的 ReplicaSet 资源的期望副本数设置为 0,并等待旧的 ReplicaSet 资源完全驱逐。接着,它会自动开始推出新的 ReplicaSet 资源,以达到期望的镜像。
滚动更新
当你使用 RollingUpdate 策略更新 Deployment 时,Kubernetes 会创建一个新的 ReplicaSet 资源,同时在新的 ReplicaSet 资源上启动所需数量的 pod,并逐渐关闭旧的 ReplicaSet 资源,正如以下图示所示:

图 6.3 – RollingUpdate 策略
RollingUpdate是默认的部署策略。除了那些无法容忍在给定时间内存在多个版本的应用外,大多数应用都可以使用RollingUpdate策略。
让我们使用RollingUpdate策略更新nginx的Deployment资源。我们将重新使用之前使用的标准nginx-deployment.yaml文件。使用以下命令并查看ReplicaSet资源会发生什么:
$ kubectl apply -f nginx-deployment.yaml && kubectl get replicaset -w
deployment.apps/nginx configured
NAME DESIRED CURRENT READY AGE
nginx-6799fc88d8 3 3 3 49s
nginx-6889dfccd5 1 1 1 4s
nginx-6799fc88d8 2 2 2 53s
nginx-6889dfccd5 2 2 2 8s
nginx-6799fc88d8 1 1 1 57s
nginx-6889dfccd5 3 3 3 11s
nginx-6799fc88d8 0 0 0 60s
如我们所见,旧的ReplicaSet资源—nginx-6799fc88d8—正在被下线,而新的ReplicaSet资源—nginx-6889dfccd5—正在被同时上线。
RollingUpdate策略还有两个选项—maxUnavailable和maxSurge。
当maxSurge定义了在给定时间内我们可以拥有的额外 Pod 的最大数量时,maxUnavailable定义了在给定时间内我们可以拥有的不可用 Pod 的最大数量。
提示
如果你的应用无法容忍超过某个数量的副本,请将maxSurge设置为0。如果你希望保持可靠性,并且应用可以容忍超过设定的副本数,请将maxUnavailable设置为0。你不能将这两个参数都设置为0,因为那样会使任何滚动尝试变得不可能。在设置maxSurge时,确保你的集群有足够的空闲容量来启动额外的 Pod,否则滚动更新将失败。
使用这些设置,我们可以创建不同类型的自定义滚动策略—接下来的部分将讨论一些流行的策略。
渐进式慢滚动
如果你有多个副本,但希望缓慢推出发布,观察应用是否有问题,并在需要时回滚部署,你应该使用这个策略。
让我们使用渐进式慢滚动策略创建一个nginx部署,nginx-ramped-slow-rollout.yaml:
...
spec:
replicas: 10
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
...
该清单与通用的部署非常相似,只不过它包含了10个副本和一个strategy部分。
strategy部分包含以下内容:
-
type:RollingUpdate -
rollingUpdate: 描述滚动更新属性的部分—maxSurge和maxUnavailable
现在,让我们应用 YAML 文件,并使用以下命令等待部署完全滚动到10个副本:
$ kubectl apply -f nginx-ramped-slow-rollout.yaml \
&& kubectl rollout status deployment nginx
deployment.apps/nginx configured
...
deployment "nginx" successfully rolled out
如我们所见,Pod 已经完全滚动完成。现在,让我们使用以下命令更新Deployment资源,换用不同的nginx镜像版本,看看会发生什么:
$ kubectl set image deployment nginx nginx=nginx:1.16.1 \
&& kubectl get replicaset -w
deployment.apps/nginx image updated
NAME DESIRED CURRENT READY AGE
nginx-6799fc88d8 10 10 10 3m51s
nginx-6889dfccd5 1 1 0 0s
nginx-6799fc88d8 9 10 10 4m
. . . . . . . . . . . . . . .
nginx-6889dfccd5 8 8 8 47s
nginx-6799fc88d8 2 3 3 4m38s
nginx-6889dfccd5 9 9 8 47s
nginx-6799fc88d8 2 2 2 4m38s
nginx-6889dfccd5 9 9 9 51s
nginx-6889dfccd5 10 9 9 51s
nginx-6799fc88d8 1 2 2 4m42s
nginx-6889dfccd5 10 10 10 55s
nginx-6799fc88d8 0 1 1 4m46s
nginx-6799fc88d8 0 0 0 4m46s
所以,我们在这里看到两个ReplicaSet资源—nginx-6799fc88d8和nginx-6889dfccd5。当nginx-6799fc88d8的 Pod 正在从10个 Pod 慢慢减少到0时,一次只减少一个,nginx-6889dfccd5的 Pod 则同时从0个 Pod 逐步增加到10个 Pod。在任何时刻,Pod 的数量都不会超过11。这是因为maxSurge设置为1,而maxUnavailable设置为0。这就是慢滚动的实际效果。
提示
渐进式慢滚动在我们希望在影响许多用户之前保持谨慎时非常有用,但这种策略非常慢,可能只适用于某些应用。
让我们看看更快推出的最佳努力控制策略,而不影响应用程序的可用性。
最佳努力控制的推出
Best-effort controlled rollout 帮助你以最佳努力的方式推出部署,你可以使用它来更快地推出你的发布,并确保你的应用程序可用。它还可以帮助处理那些在特定时间点不允许超过一定数量副本的应用程序。
我们将 maxSurge 设置为 0,并将 maxUnavailable 设置为适当的百分比,以确保在任何给定时间内仍然不可用。可以按照 pod 数量或百分比来指定。
小贴士
使用百分比是更好的选择,因为这样做的话,如果副本数量发生变化,你就不需要重新计算 maxUnavailable 参数了。
让我们来看看清单— nginx-best-effort-controlled-rollout.yaml:
...
spec:
replicas: 10
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 0
maxUnavailable: 25%
...
现在让我们应用 YAML 文件并看看我们得到了什么:
$ kubectl apply -f nginx-best-effort-controlled-rollout.yaml \
&& kubectl get replicaset -w
deployment.apps/nginx configured
NAME DESIRED CURRENT READY AGE
nginx-6799fc88d8 2 0 0 20m
nginx-6889dfccd5 8 8 8 16m
nginx-6799fc88d8 2 2 1 20m
nginx-6889dfccd5 7 8 8 16m
. . . . . . . . . . . . . . . .
nginx-6889dfccd5 1 1 1 16m
nginx-6799fc88d8 9 9 8 20m
nginx-6889dfccd5 0 1 1 16m
nginx-6799fc88d8 10 9 8 20m
nginx-6889dfccd5 0 0 0 16m
nginx-6799fc88d8 10 10 10 20m
因此,我们看到 ReplicaSet 资源进行滚动式推出,以便任何时候总体 pod 数量最多为 10,总体不可用 pod 数量永远不超过 25%。你还可以注意到,更新 Deployment 资源时并不会创建新的 ReplicaSet 资源,而是使用包含 nginx:latest 镜像的旧 ReplicaSet 资源。还记得我说过更新 Deployment 资源时不会删除旧 ReplicaSet 资源吗?
Deployment 资源本身是调度和管理 pod 的好方式。然而,我们忽视了 Kubernetes 中运行容器的一个重要部分——将它们暴露给内部或外部世界。Kubernetes 提供了几种资源来帮助适当地暴露你的工作负载——主要是 Service 和 Ingress 资源。让我们在下一节中看看这些。
Kubernetes Services 和 Ingresses
故事时间到!让我们简化 Kubernetes Services。
想象一下,你有一群喜欢从你的餐馆订餐的朋友。与其分别将每个订单送到他们家里,不如在他们的社区建立一个中央交付点。这个交付点(或中心)就是你的“服务”。
在 Kubernetes 中,一个 Service 就像是那个中央枢纽。它是你应用程序不同部分(比如你的网站、数据库或其他组件)之间进行通信的一种方式,即使它们在不同的容器或机器上也可以。它为它们提供了易于记忆的地址,以便彼此找到而不至于迷失。
Service 资源有助于将 Kubernetes 工作负载暴露到内部或外部世界。如我们所知,pods 是临时性的资源——它们可以出现也可以消失。每个 pod 都会分配一个独特的 IP 地址和主机名,但一旦 pod 被销毁,pod 的 IP 地址和主机名也会发生变化。假设你的一个 pod 想与另一个 pod 交互,但是由于 pod 的短暂特性,你无法配置一个合适的端点。如果你使用 IP 地址或主机名作为 pod 的端点,而该 pod 被销毁后,你将无法再连接到它。因此,单独暴露一个 pod 并不是一个好主意。
Kubernetes 提供了 Service 资源,以便为一组 pod 提供静态 IP 地址。除了通过单一静态 IP 地址暴露这些 pod 外,它还在轮询配置中为 pod 之间的流量提供负载均衡。它帮助均匀地分配流量到各个 pod,是暴露工作负载的默认方法。
Service 资源还会分配一个静态的 Service 资源 FQDN,而不是集群内的 IP 地址,以确保端点的容错性。
现在,回到 Service 资源,存在多种 Service 资源类型——ClusterIP、NodePort 和 LoadBalancer,每种类型都有各自的使用场景:

图 6.4 – Kubernetes 服务
让我们通过示例来理解这些内容。
ClusterIP 服务资源
ClusterIP Service 资源是默认的 Service 资源类型,用于在 Kubernetes 集群内部暴露 pods。无法从集群外部访问 ClusterIP Service 资源;因此,它们永远不会用于将 pods 暴露到外部世界。ClusterIP Service 资源通常用于暴露后端应用程序,如数据存储和数据库——即三层架构中的业务和数据层。
提示
在选择 Service 资源类型时,一般的经验法则是,始终从 ClusterIP Service 资源开始,必要时再进行修改。这将确保只有需要的服务暴露到外部。
为了更好地理解 ClusterIP Service 资源,让我们首先使用以下命令通过命令式方法创建一个 redis Deployment 资源:
$ kubectl create deployment redis --image=redis
让我们尝试使用 ClusterIP Service 资源暴露 redis 部署的 pods。要访问此部分的资源,请 cd 到以下目录:
$ cd ~/modern-devops/ch6/services/
首先让我们查看 Service 资源清单 redis-clusterip.yaml:
apiVersion: v1
kind: Service
metadata:
labels:
app: redis
name: redis
spec:
ports:
- port: 6379
protocol: TCP
targetPort: 6379
selector:
app: redis
Service 资源清单从 apiVersion 和 kind 开始,和其他资源一样。它有一个 metadata 部分,其中包含 name 和 labels。
spec 部分包含以下内容:
-
ports:该部分包括我们希望通过Service资源暴露的端口列表:A.
port:我们希望暴露的端口。B.
protocol:我们暴露的端口协议(TCP/UDP)。C.
targetPort:目标容器端口,暴露端口将把连接转发到该端口。这使我们能够拥有类似于 Docker 的端口映射。 -
selector:该部分包含用于选择 Pod 组的labels。
让我们使用以下命令应用Service资源清单,看看我们能得到什么:
$ kubectl apply -f redis-clusterip.yaml
让我们运行kubectl get命令列出Service资源并获取集群 IP:
$ kubectl get service redis
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
redis ClusterIP 10.12.6.109 <none> 6379/TCP 16s
我们看到一个redis的Service资源正在以ClusterIP类型运行。但由于这个 Pod 没有对外暴露,访问它的唯一方式是通过集群内的第二个 Pod。
让我们创建一个busybox Pod,以交互模式检查Service资源并使用以下命令运行一些测试:
$ kubectl run busybox --rm --restart Never -it --image=busybox
/ #
这样,我们就看到了一个提示。我们已经启动了busybox容器,并且现在正处于其中。我们将使用telnet应用程序检查 Pods 之间的连通性。
让我们通过以下命令 telnet 集群 IP 和端口,看看是否可达:
/ # telnet 10.96.118.99 6379
Connected to 10.96.118.99
从那里可以访问 IP/端口对。Kubernetes 还提供了内部 DNS 以促进服务发现并连接到Service资源。我们可以使用以下命令对集群 IP 进行反向nslookup,获取Service资源的 FQDN:
/ # nslookup 10.96.118.99
Server: 10.96.0.10
Address: 10.96.0.10:53
99.118.96.10.arpa name = redis.default.svc.cluster.local
如我们所见,IP 地址可以通过 FQDN 访问——redis.default.svc.cluster.local。我们可以根据我们的地理位置使用整个域名或其中的一部分。FQDN 由以下部分组成:<service_name>.<namespace>.svc.<cluster-domain>.local。
Kubernetes 至今一直使用default命名空间,并将继续使用。如果你的源 Pod 位于与Service资源相同的命名空间内,你可以使用service_name来连接到你的Service资源——像下面的示例一样:
/ # telnet redis 6379
Connected to redis
如果你想从位于不同命名空间的 Pod 调用Service资源,可以改用<service_name>.<namespace>,像下面的示例一样:
/ # telnet redis.default 6379
Connected to redis.default
一些服务网格(例如 Istio)支持多集群通信。在这种情况下,您还可以使用集群名称来连接到Service资源,但由于这是一个高级主题,超出了本讨论的范围。
提示
始终使用尽可能短的域名来表示端点,因为它允许在不同环境中更灵活地移动 Kubernetes 资源。
ClusterIP类型的服务非常适合暴露内部 Pod,但如果我们想将 Pod 暴露给外部世界呢?Kubernetes 提供了多种Service资源类型;首先让我们看看NodePort类型的Service资源。
NodePort Service 资源
NodePort Service资源用于将你的 Pods 暴露到外部。创建一个NodePort Service资源会启动一个ClusterIP类型的Service资源,并将ClusterIP端口映射到所有集群节点的随机高端口号(默认范围:30000-32767)。你也可以根据需要指定一个静态的NodePort端口号。因此,使用NodePort Service资源,你可以通过集群中任意节点的 IP 地址和该服务的NodePort来访问你的 Pods。
提示
虽然可以指定静态的NodePort端口号,但你应该避免使用它。这是因为你可能会与其他Service资源发生端口冲突,并且对配置和变更管理产生较高依赖。相反,保持简单,使用动态端口。
以 Flask 应用为例,创建一个flask-app Pod,并使用之前创建的redis Service资源作为其后端,然后我们将通过NodePort暴露该 Pod。
使用以下命令命令式地创建一个 Pod:
$ kubectl run flask-app --image=<your_dockerhub_user>/python-flask-redis
现在,既然我们已经创建了flask-app Pod,使用以下命令查看它的状态:
$ kubectl get pod flask-app
NAME READY STATUS RESTARTS AGE
flask-app 1/1 Running 0 19s
flask-app Pod 已经成功运行,并准备好接受请求。现在是时候理解NodePort类型的Service资源清单flask-nodeport.yaml了:
...
spec:
ports:
- port: 5000
protocol: TCP
targetPort: 5000
selector:
run: flask-app
type: NodePort
清单类似于ClusterIP清单,但包含一个type属性,用于指定Service资源类型——NodePort。
让我们应用这个清单,看看通过以下命令可以得到什么:
$ kubectl apply -f flask-nodeport.yaml
现在,让我们列出Service资源并获取NodePort类型的 Service,使用以下命令:
$ kubectl get service flask-app
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
flask-app NodePort 10.3.240.246 <none> 5000:32618/TCP 9s
我们可以看到类型现在是NodePort,容器端口5000映射到了节点端口32618。
如果你已经登录到任何 Kubernetes 节点,可以通过localhost:32618访问Service资源。但由于我们正在使用 Google Cloud Shell,我们需要 SSH 进入节点才能访问Service资源。
让我们首先列出节点,使用以下命令:
$ kubectl get nodes
NAME STATUS ROLES AGE VERSION
gke-node-1dhh Ready <none> 17m v1.26.15-gke.4901
gke-node-7lhl Ready <none> 17m v1.26.15-gke.4901
gke-node-zwg1 Ready <none> 17m v1.26.15-gke.4901
如我们所见,我们有三台节点。让我们通过以下命令 SSH 进入gke-node-1dhh节点:
$ gcloud compute ssh gke-node-1dhh
现在,既然我们处在gke-node-1dhh节点,使用以下命令 curl 访问localhost:32618:
$ curl localhost:32618
Hi there! This page was last visited on 2023-06-26, 08:37:50.
然后我们收到了一个响应!你可以 SSH 进入任意节点并使用 curl 访问该端点,应该能得到类似的响应。
要退出节点并返回到 Cloud Shell 提示符,运行以下命令:
$ exit
Connection to 35.202.82.74 closed.
你已回到 Cloud Shell 提示符。
提示
NodePort类型的Service资源是一个中介类型的资源。这意味着虽然它是提供外部服务的重要组成部分,但大多数时候它并不会单独使用。当你在云环境中运行时,你可以使用LoadBalancer类型的Service资源。即使是在本地部署环境下,也不建议为每个Service资源使用NodePort,而应该使用Ingress资源。
现在,让我们来看看广泛用于将你的 Kubernetes 工作负载暴露到外部的 LoadBalancer Service 资源。
LoadBalancer 服务资源
LoadBalancer Service 资源帮助在单一的负载均衡端点上暴露你的 Pod。这些 Service 资源只能在云平台及提供 Kubernetes 控制器来访问外部网络资源的平台上使用。LoadBalancer 服务实际上会启动一个 NodePort Service 资源,然后请求云 API 在节点端口前面启动一个负载均衡器。这样,它提供了一个单一的端点,供外部世界访问你的 Service 资源。
启动一个 LoadBalancer Service 资源很简单——只需将类型设置为 LoadBalancer。
让我们使用以下清单将 Flask 应用程序暴露为负载均衡器——flask-loadbalancer.yaml:
...
spec:
type: LoadBalancer
...
现在,让我们使用以下命令应用清单:
$ kubectl apply -f flask-loadbalancer.yaml
让我们使用以下命令让 Service 资源注意到这些更改:
$ kubectl get svc flask-app
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S)
flask-app LoadBalancer 10.3.240.246 34.71.95.96 5000:32618
Service 资源类型现在是 LoadBalancer。如你所见,它现在包含了一个外部 IP 以及集群 IP。
然后,你可以使用以下命令在外部 IP 的端口 5000 上执行 curl:
$ curl 34.71.95.96:5000
Hi there! This page was last visited on 2023-06-26, 08:37:50.
然后你会得到与之前相同的响应。你的 Service 资源现在已在外部运行。
提示
LoadBalancer Service 资源通常比较昂贵,因为每当新建一个资源时,它会在你的云服务提供商中启动一个网络负载均衡器。如果你的工作负载基于 HTTP,建议使用 Ingress 资源而不是 LoadBalancer 来节省资源成本并优化流量,因为它们会启动一个应用程序负载均衡器。
虽然 Kubernetes 服务是暴露你的容器应用程序内部和外部的基本构建块,Kubernetes 还提供了 Ingress 资源,用于对流量进行更精细的控制。让我们在下一部分中看看这一点。
Ingress 资源
想象一下,你的餐厅有一个漂亮的前门,顾客通过这个门进入。顾客通过这个主要入口进入餐厅后,可以到达餐厅的不同区域,比如就餐区或酒吧。这个入口就像你的“入口”。
在 Kubernetes 中,Ingress 就像那个前门。它帮助管理外部访问你集群内的服务。你不需要为每个服务单独暴露,你可以使用 Ingress 来决定外部用户如何访问你应用程序的不同部分。
简单来说,Kubernetes 服务就像是你应用程序不同部分的中央交付点,而 Ingress 就像是一个前门,帮助外部用户轻松找到并访问这些部分。
Ingress 资源充当 Kubernetes 中的反向代理。你不需要为你运行的每个应用程序都创建负载均衡器,因为负载均衡器通常转发流量,并不需要很高的计算能力。因此,为每个应用程序都启动负载均衡器并不明智。
因此,Kubernetes 提供了一种通过 Ingress 资源将外部流量路由到集群的方式。这些资源帮助你根据多个条件划分流量。以下是一些设定:
-
基于 URL 路径
-
基于主机名
-
两者的结合
以下图示展示了 Ingress 资源的工作原理:

图 6.5 – Kubernetes 入口资源
Ingress 资源需要一个入口控制器才能正常工作。虽然大多数云服务提供商已经安装了控制器,但你必须在本地或自管理的 Kubernetes 集群中安装入口控制器。有关安装入口控制器的详细信息,请参考 kubernetes.io/docs/concepts/services-networking/ingress-controllers/。你可以安装多个入口控制器,但你需要在清单中注解,明确指定 Ingress 资源应该使用哪个控制器。
对于本章,我们将在前面使用 Ingress 资源,并进行精确的逐一迁移。
为了理解 nginx 入口控制器在 GKE(或其他云)上的工作原理,让我们看看以下图示:

图 6.6 – GKE 上的 nginx 入口控制器
客户端通过一个由入口管理的负载均衡器连接到 Ingress 资源,流量会转发到充当负载均衡器后端的入口控制器。然后,入口控制器根据在 Ingress 资源上定义的路由规则将流量路由到正确的 Service 资源。
现在,让我们使用以下命令安装 nginx 入口控制器:
$ kubectl apply -f \
https://raw.githubusercontent.com/kubernetes/ingress-nginx\
/controller-v1.8.0/deploy/static/provider/cloud/deploy.yaml
这将启动几个资源,位于 ingress-nginx 命名空间下。最显著的是 ingress-nginx-controller Deployment,它通过 ingress-nginx-controller LoadBalancer Service 进行暴露。
现在,让我们通过 Ingress 资源暴露 flask-app Service,但在此之前,我们需要先将 flask-app Service 暴露为 ClusterIP,所以让我们使用以下命令应用相关清单:
$ kubectl apply -f flask-clusterip.yaml
下一步是定义一个 Ingress 资源。记住,由于 GKE 运行在公共云中,因此已经安装并运行了入口控制器。所以,我们可以简单地创建一个入口清单—flask-basic-ingress.yaml:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: flask-app
annotations:
kubernetes.io/ingress.class: "nginx"
spec:
defaultBackend:
service:
name: flask-app
port:
number: 5000
该资源定义了一个默认的后端,将所有流量传递到 flask-app pod,因此它是低效的,但为了简便起见,我们先看一下它。
使用以下命令应用清单:
$ kubectl apply -f flask-basic-ingress.yaml
现在,使用以下命令列出 Ingress 资源:
$ kubectl get ingress flask-app
NAME CLASS HOSTS ADDRESS PORTS AGE
flask-app <none> * 80 40s
我们可以看到 flask-app 的 Ingress 资源现在列出了 HOSTS *。这意味着它会监听所有主机上的所有地址。所以,任何不匹配其他 Ingress 规则的流量都会路由到这里。如前所述,我们需要 nginx-ingress-controller 服务的外部 IP 地址来调用通过 Ingress 暴露的所有服务。要获取 nginx-ingress-controller 服务的外部 IP 地址,请运行以下命令:
$ kubectl get svc ingress-nginx-controller -n ingress-nginx
NAME TYPE EXTERNAL-IP
ingress-nginx-controller LoadBalancer 34.120.27.34
我们看到为其分配了一个外部 IP 地址,接下来我们将使用它。
重要提示
记住,Ingress 规则传播到集群中需要一些时间,因此如果你在 curl 端点时最初收到错误,请等待 5 分钟,之后你应该会收到响应。
让我们使用以下命令 curl 这个 IP 地址,看看我们能得到什么:
$ curl 34.120.27.34
Hi there! This page was last visited on 2023-06-26, 09:28:26.
现在,使用以下命令清理 Ingress 资源:
$ kubectl delete ingress flask-app
简单的 Ingress 规则是适得其反的,因为它将所有流量都路由到一个 Service 资源。Ingress 的目的是使用单个负载均衡器将流量路由到多个目标。让我们来看两种实现方法——基于路径的路由和基于名称的路由。
基于路径的路由
假设我们有一个包含两个版本的应用程序,v1 和 v2,并希望它们都在单个端点上共存。在这种情况下,你可以使用 基于路径的路由。
让我们首先使用命令式方法创建这两个应用程序版本,运行以下命令:
$ kubectl run nginx-v1 --image=bharamicrosystems/nginx:v1
$ kubectl run nginx-v2 --image=bharamicrosystems/nginx:v2
现在,使用以下命令将这两个 Pod 暴露为 ClusterIP Service 资源:
$ kubectl expose pod nginx-v1 --port=80
$ kubectl expose pod nginx-v2 --port=80
然后,我们将使用以下清单文件 nginx-app-path-ingress.yaml 创建一个 Ingress 资源,该资源将暴露两个端点——<external-ip>/v1,该端点路由到 v1 Service 资源,以及 <external-ip>/v2,该端点路由到 v2 Service 资源:
...
spec:
rules:
- http:
paths:
- path: /v1
pathType: Prefix
backend:
service:
name: nginx-v1
port:
number: 80
- path: /v2
pathType: Prefix
backend:
service:
name: nginx-v2
port:
number: 80
Ingress 清单包含多个规则。http 规则有两个路径—/v1 和 /v2,其 pathType 值设置为 Prefix。因此,任何以 /v1 开头的 URL 流量将被路由到端口 80 上的 nginx-v1 Service 资源,任何到达 /v2 的流量则被路由到端口 80 上的 nginx-v2 Service 资源。
让我们使用以下命令应用该清单:
$ kubectl apply -f nginx-app-path-ingress.yaml
现在,运行以下命令列出 Ingress 资源:
$ kubectl get ingress nginx-app -w
NAME CLASS HOSTS ADDRESS PORTS AGE
nginx-app <none> * 34.120.27.34 80 114s
现在,我们有了外部 IP,可以使用以下命令 curl 这两个端点,看看我们能得到什么:
$ curl 34.120.27.34/v1/
This is version 1
$ curl 34.120.27.34/v2/
This is version 2
有时基于路径的路由并不总是可行,因为你可能不希望用户记住多个应用程序的路径。不过,你仍然可以通过单一的 Ingress 端点运行多个应用程序——也就是通过使用 基于名称的路由。
基于名称的路由
我们在发起 HTTP 请求时传递的 host 头部。Ingress 资源可以根据该头部进行路由。例如,如果我们想访问 v1 的 Service 资源,可以使用 v1.example.com,而访问 v2 的 Service 资源时,可以使用 v2.example.com URL。
接下来,让我们看一下 nginx-app-host-ingress.yaml 清单,以便更深入理解这个概念:
...
spec:
rules:
- host: v1.example.com
http:
paths:
- path: "/"
pathType: Prefix
backend:
service:
name: nginx-v1
port:
number: 80
- host: v2.example.com
http:
paths:
- path: "/"
pathType: Prefix
backend:
service:
name: nginx-v2
port:
number: 80
现在,清单中包含了多个主机——v1.example.com 路由到 nginx-v1,v2.example.com 路由到 nginx-v2。
现在,让我们应用这个清单并使用以下命令获取 Ingress:
$ kubectl apply -f nginx-app-host-ingress.yaml
$ kubectl get ingress
NAME HOSTS ADDRESS PORTS
nginx-app v1.example.com,v2.example.com 34.120.27.34 80
这次,我们看到两个主机被定义了,v1.example.com 和 v2.example.com,它们运行在同一个地址上。在访问这些端点之前,我们需要在 /etc/hosts 文件中添加一个条目,允许我们的机器将 v1.example.com 和 v2.example.com 解析到 Ingress 地址。
编辑 /etc/hosts 文件,并在末尾添加以下条目:
<Ingress_External_IP> v1.example.com v2.example.com
现在,让我们 curl 两个端点,看看会得到什么:
$ curl v1.example.com
This is version 1
$ curl v2.example.com
This is version 2
如我们所见,基于名称的路由正常工作!你可以通过结合多个主机和基于路径的路由来创建一个更动态的设置。
Service、Ingress、Pod、Deployment 和 ReplicaSet 资源帮助我们在 Kubernetes 中保持一定数量的副本,并帮助通过一个端点为它们提供服务。正如你可能已经注意到的,它们是通过 labels 和 matchLabels 属性的组合来关联的。下图将帮助你更好地理解这一点:

图 6.7 – 连接 Deployment、Service 和 Ingress
到目前为止,我们一直在手动缩放 Pods,但更好的方法是根据资源使用情况和流量自动伸缩副本。Kubernetes 提供了一个名为 HorizontalPodAutoscaler 的资源来处理这个需求。
水平 Pod 自动伸缩
想象一下,你是一个公园小吃摊的经理。在一个阳光明媚的日子里,很多人来享受公园,他们都想要小吃。现在,你有几个工人在小吃摊制作和提供小吃。
Kubernetes 中的水平 Pod 自动伸缩就像是拥有一群神奇的助手,他们根据需要多少人想要小吃(流量),来调整小吃制作员(pods)的数量。
以下是它的工作原理:
-
普通日子:在平常日子里,只有少数人来,可能只需要一两个小吃制作员。在 Kubernetes 的术语中,你只需要几个 Pod 来运行你的应用程序。
-
繁忙的日子:但是当是一个阳光明媚的周末,大家都涌向公园时,会有更多的人想要小吃。你的神奇助手(水平 Pod 自动伸缩)注意到需求的增加,他们说:“我们需要更多的小吃制作员!”因此,更多的小吃制作员(pods)会自动添加,以应对这股人潮。
-
缩小规模:当太阳落山,游客离开时,你不再需要那么多小吃制作员了。你的神奇助手看到需求减少,便说:“我们现在可以减少小吃制作员数量了。”因此,多余的小吃制作员(pods)会被移除,从而节省资源。
-
自动调整:这些神奇的助手会实时监控人流,并调整小吃制作员(pods)的数量。当需求增加时,他们会部署更多;当需求减少时,他们会移除一些。
以同样的方式,Kubernetes 水平 Pod 自动扩缩会监控你的应用程序有多繁忙。如果流量增加(更多人想要使用你的应用),它会自动添加更多的 Pod。如果流量减少,它会缩减 Pod 数量。这可以帮助你的应用程序在流量波动时自动调整,而不需要你手动操作。
所以,水平 Pod 自动扩缩(Horizontal pod autoscaling)就像拥有神奇的助手,确保你的应用程序有正确数量的工作者(Pods)来高效地处理流量(traffic)。
HorizontalPodAutoscaler 是一个 Kubernetes 资源,它可以帮助你根据定义的因素(最常见的是 CPU 和内存)更新 ReplicaSet 资源中的副本数。
为了更好地理解这一点,让我们创建一个 nginx 部署(Deployment),这一次我们将在 Pod 内部设置资源限制。资源限制是使 HorizontalPodAutoscaler 资源能够工作的关键因素。它依赖于限制的百分比利用率来决定何时启动新的副本。我们将使用以下 nginx-autoscale-deployment.yaml 清单文件,路径为 ~/modern-devops/ch6/deployments 来进行这个练习:
...
spec:
replicas: 1
template:
spec:
containers:
- name: nginx
image: nginx
resources:
limits:
cpu: 200m
memory: 200Mi
...
使用以下命令执行新的部署:
$ kubectl apply -f nginx-autoscale-deployment.yaml
让我们使用 LoadBalancer 类型的 Service 资源来暴露这个部署,并获取外部 IP:
$ kubectl expose deployment nginx --port 80 --type LoadBalancer
$ kubectl get svc nginx
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S)
nginx LoadBalancer 10.3.243.225 34.123.234.57 80:30099/TCP
现在,让我们对这个部署进行自动扩缩。Deployment 资源需要至少 1 个 Pod 副本,最多可以有 5 个 Pod 副本,同时保持平均 CPU 利用率为 25%。使用以下命令创建一个 HorizontalPodAutoscaler 资源:
$ kubectl autoscale deployment nginx --cpu-percent=25 --min=1 --max=5
现在我们已经创建了 HorizontalPodAutoscaler 资源,我们可以使用 Google Cloud Shell 中预安装的 hey 负载测试工具对应用程序进行负载测试。但是,在启动负载测试之前,打开一个复制的 Shell 会话,并使用以下命令查看 Deployment 资源:
$ kubectl get deployment nginx -w
打开另一个复制的 Shell 会话,并使用以下命令查看 HorizontalPodAutoscaler 资源:
$ kubectl get hpa nginx -w
现在,在原始窗口中,运行以下命令以启动负载测试:
$ hey -z 120s -c 100 http://34.123.234.57
它将开始一个持续 2 分钟的负载测试,10 个并发用户持续地攻击 Service。如果你打开正在监控 HorizontalPodAutoscaler 资源的窗口,你将看到以下输出。当我们开始执行负载测试时,平均利用率达到了 46%。HorizontalPodAutoscaler 资源等待一段时间后,增加副本数,首先是 2,然后是 4,最后是 5。当测试完成时,利用率迅速下降至 27%,25%,最终降至 0%。当利用率降至 0% 时,HorizontalPodAutoscaler 资源会逐渐减少副本数,从 5 减少到 1:
$ kubectl get hpa nginx -w
NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS AGE
nginx deployment/nginx <unknown>/25% 1 5 1 32s
nginx deployment/nginx 46%/25% 1 5 1 71s
nginx deployment/nginx 46%/25% 1 5 2 92s
nginx deployment/nginx 92%/25% 1 5 4 2m2s
nginx deployment/nginx 66%/25% 1 5 5 2m32s
nginx deployment/nginx 57%/25% 1 5 5 2m41s
nginx deployment/nginx 27%/25% 1 5 5 3m11s
nginx deployment/nginx 23%/25% 1 5 5 3m41s
nginx deployment/nginx 0%/25% 1 5 4 4m23s
nginx deployment/nginx 0%/25% 1 5 2 5m53s
nginx deployment/nginx 0%/25% 1 5 1 6m30s
同样地,我们将看到当 HorizontalPodAutoscaler 资源进行操作时,Deployment 的副本数发生变化:
$ kubectl get deployment nginx -w
NAME READY UP-TO-DATE AVAILABLE AGE
nginx 1/1 1 1 18s
nginx 1/2 1 1 77s
nginx 2/2 2 2 79s
nginx 2/4 2 2 107s
nginx 3/4 4 3 108s
nginx 4/4 4 4 109s
nginx 4/5 4 4 2m17s
nginx 5/5 5 5 2m19s
nginx 4/4 4 4 4m23s
nginx 2/2 2 2 5m53s
nginx 1/1 1 1 6m30s
除了 CPU 和内存之外,你还可以使用其他参数来扩展你的工作负载,例如网络流量。你还可以使用外部度量指标,比如延迟以及其他因素,来决定何时扩展你的流量。
提示
虽然你应该使用 HorizontalPodAutoscaler 资源来处理 CPU 和内存,但你也应考虑基于外部度量(如响应时间和网络延迟)进行扩展。这将确保更好的可靠性,因为这些直接影响客户体验,对你的业务至关重要。
到目前为止,我们一直在处理无状态的工作负载。然而,从实际的角度来看,一些应用程序需要保存状态。让我们来看一下管理有状态应用程序的一些考虑因素。
管理有状态的应用程序
想象一下,你是一个魔法图书馆的馆长。你有一堆存储着宝贵知识的魔法书。每本书都有独特的故事,并且被放在书架上的特定位置。这些书就像你的“有状态应用程序”,管理它们需要额外的细心。
在技术世界中管理有状态应用程序,就像在你的图书馆中照料这些魔法书一样。
以下是它的工作原理:
-
有状态的书籍:你书库中的一些书是“有状态的”。这意味着它们保存着随着时间变化的重要信息,例如书签或读者的笔记。
-
固定位置:就像每本书在书架上都有一个特定的位置一样,有状态的应用程序也必须位于特定的位置。它们可能需要放在某些机器上,或者使用特定的存储来确保数据的安全。
-
维护库存:你必须记住每本书的位置。同样,管理有状态应用程序意味着要记住它们的确切位置和配置。
-
小心处理:当有人借阅一本有状态的书时,你必须确保他们归还时书籍完好无损。同样,对于有状态的应用程序,你必须小心地处理更新和变更,以避免丢失重要数据。
-
备份法术:有时,你施放一个法术来创建一本书的副本,以防万一原本的书出了问题。对于有状态的应用程序,你会备份数据,以便在出现问题时恢复它。
-
谨慎移动:如果你需要重新安排图书馆的布局,你会一次移动一本书,以确保没有书籍丢失。同样,对于有状态的应用程序,如果你需要在机器或存储之间移动它们,必须谨慎操作,以避免数据丢失。
在技术的世界里,管理有状态应用程序意味着要特别小心那些保存重要数据的应用程序。你需要确保它们放置在正确的位置,仔细处理更新,并创建备份以确保宝贵的信息安全,就像你在神奇的图书馆中保护你的魔法书一样!
部署资源适用于无状态工作负载,因为它们在更新副本集资源时不需要考虑任何状态问题,但它们无法有效地与有状态工作负载一起工作。要管理此类工作负载,可以使用有状态副本集资源。
有状态副本集资源
有状态副本集资源有助于管理有状态应用程序。它们类似于部署资源,但与部署资源不同,它们还会跟踪状态,并且需要卷和服务资源来运行。有状态副本集资源为每个 pod 维护一个粘性标识符。这意味着一个 pod 上挂载的卷不能被另一个 pod 使用。在有状态副本集资源中,Kubernetes 通过为 pods 编号而不是生成随机哈希来为 pods 排序。有状态副本集资源中的 pods 也按顺序进行滚动更新和缩容。如果某个 pod 崩溃并被重新创建,则相同的卷会被挂载到该 pod 上。
下图展示了一个有状态副本集资源:

图 6.8 – 有状态副本集资源
有状态副本集资源具有稳定且唯一的网络标识符,因此,它需要一个无头服务资源。无头服务是没有集群 IP 的服务资源。相反,Kubernetes DNS 会将服务资源的 FQDN 直接解析到 pods。
由于有状态副本集资源需要持久化数据,因此它需要持久卷才能运行。因此,让我们看看如何使用 Kubernetes 管理卷。
管理持久卷
持久卷是 Kubernetes 资源,用于处理存储。它们可以帮助您管理和挂载硬盘、固态硬盘、文件存储以及其他块存储和网络存储实体。您可以手动配置持久卷,也可以在 Kubernetes 中使用动态配置。当使用动态配置时,Kubernetes 会通过云控制器管理器请求云提供商提供所需的存储。让我们看看两种方法,了解它们如何工作。
静态配置
使用磁盘信息创建的持久卷资源。然后,开发人员可以在他们的有状态副本集资源中使用此持久卷资源,如下图所示:

图 6.9 – 静态配置
现在让我们来看一个静态配置的示例。
要访问本节的资源,cd 到以下目录:
$ cd ~/modern-devops/ch6/statefulsets/
因此,我们首先需要在云平台中创建一个磁盘。由于我们使用的是 Google Cloud,我们将继续使用 gcloud 命令来完成此操作。
使用以下命令创建持久化区域磁盘。确保使用与您的 Kubernetes 集群相同的区域。由于我们使用的是us-central1-a区域的 Kubernetes 集群,接下来我们将使用相同的区域:
$ gcloud compute disks create nginx-manual \
--size 50GB --type pd-ssd --zone us-central1-a
Created [https://www.googleapis.com/compute/v1/projects/<project_id>/zones/us-central1-a/
disks/nginx-manual].
NAME ZONE SIZE_GB TYPE STATUS
nginx-manual us-central1-a 50 pd-ssd READY
由于磁盘现在已经准备好,我们可以从中创建一个持久卷资源。
清单文件nginx-manual-pv.yaml如下所示:
apiVersion: v1
kind: PersistentVolume
metadata:
name: nginx-manual-pv
labels:
usage: nginx-manual-disk
spec:
capacity:
storage: 50G
accessModes:
- ReadWriteOnce
gcePersistentDisk:
pdName: nginx-manual
fsType: ext4
spec部分包含capacity、accessModes以及它需要配置的磁盘类型。你可以为 PersistentVolumes 指定一个或多个访问模式:
-
ReadWriteOnce:每次只有一个 Pod 可以读取和写入磁盘;因此,你不能将这样的卷挂载到多个 Pod。 -
ReadOnlyMany:多个 Pod 可以同时读取同一个卷,但没有 Pod 可以写入该磁盘。 -
ReadWriteMany:多个 Pod 可以同时读取和写入同一个卷。
提示
并非所有类型的存储都支持所有的访问模式。你需要在初步需求分析和架构评估阶段决定卷的类型。
好的——现在我们来应用清单,通过以下命令来配置PersistentVolume资源:
$ kubectl apply -f nginx-manual-pv.yaml
现在,让我们使用以下命令检查 PersistentVolume 是否可用:
$ kubectl get pv
NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS
nginx-manual-pv 50G RWO Retain Available
由于 PersistentVolume 现在已经可用,我们必须创建一个无头的Service资源,帮助在StatefulSet资源中保持网络身份。以下是描述该资源的nginx-manual-service.yaml清单:
apiVersion: v1
kind: Service
metadata:
name: nginx-manual
labels:
app: nginx-manual
spec:
ports:
- port: 80
name: web
clusterIP: None
selector:
app: nginx-manual
它与常规的Service资源非常相似,只是我们将clusterIP设置为None。
现在,让我们使用以下命令来应用清单:
$ kubectl apply -f nginx-manual-service.yaml
随着Service资源的创建,我们可以创建一个使用已创建的PersistentVolume和Service资源的StatefulSet资源。StatefulSet资源清单nginx-manual-statefulset.yaml如下所示:
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: nginx-manual
spec:
selector:
matchLabels:
app: nginx-manual
serviceName: "nginx-manual"
replicas: 1
template:
metadata:
labels:
app: nginx-manual
spec:
containers:
- name: nginx
image: nginx
volumeMounts:
- name: html
mountPath: /usr/share/nginx/html
volumeClaimTemplates:
- metadata:
name: html
spec:
accessModes: [ "ReadWriteOnce" ]
resources:
requests:
storage: 40Gi
selector:
matchLabels:
usage: nginx-manual-disk
清单包含了多个部分。虽然大多数部分与Deployment资源清单类似,但它需要一个volume定义和一个单独的volumeClaimTemplates部分。volumeClaimTemplates部分包括accessModes、resources和selector部分。selector部分定义了matchLabels属性,用于选择特定的PersistentVolume资源。在这种情况下,它选择了我们之前定义的PersistentVolume资源。它还包含serviceName属性,定义了它将使用的无头Service资源。
现在,让我们继续使用以下命令应用清单:
$ kubectl apply -f nginx-manual-statefulset.yaml
现在,让我们检查一些元素,看看我们目前的进展。StatefulSet资源创建了一个PersistentVolumeClaim资源,用来声明我们之前创建的PersistentVolume资源。
使用以下命令获取PersistentVolumeClaim资源:
$ kubectl get pvc
NAME STATUS VOLUME CAPACITY ACCESS MODES
html-nginx-manual-0 Bound nginx-manual-pv 50G RWO
正如我们所看到的,StatefulSet资源已经创建了一个名为html-nginx-manual-0的PersistentVolumeClaim资源,该资源绑定到了nginx-manual-pv的PersistentVolume资源上。因此,手动配置已正确工作。
如果我们使用以下命令查询PersistentVolume资源,我们将看到其状态现在显示为Bound:
$ kubectl get pv
NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS
nginx-manual-pv 50G RWO Retain Bound
现在,让我们使用以下命令查看 Pod 的状态:
$ kubectl get pod
NAME READY STATUS RESTARTS AGE
nginx-manual-0 1/1 Running 0 14s
如我们所见,StatefulSet资源已经创建了一个 Pod,并附加了一个序列号,而不是随机哈希值。它希望在 Pod 之间保持顺序,并将之前挂载的相同卷挂载到这些 Pod 上。
现在,让我们打开 Pod 的 Shell,并使用以下命令在/usr/share/nginx/html目录中创建一个文件:
$ kubectl exec -it nginx-manual-0 -- /bin/bash
root@nginx-manual-0:/# cd /usr/share/nginx/html/
root@nginx-manual-0:/usr/share/nginx/html# echo 'Hello, world' > index.html
root@nginx-manual-0:/usr/share/nginx/html# exit
太好了!那么,让我们继续删除 Pod,并使用以下命令查看是否能在相同位置再次找到文件:
$ kubectl delete pod nginx-manual-0
$ kubectl get pod
NAME READY STATUS RESTARTS AGE
nginx-manual-0 1/1 Running 0 3s
$ kubectl exec -it nginx-manual-0 -- /bin/bash
root@nginx-manual-0:/# cd /usr/share/nginx/html/ && cat index.html
Hello, world
root@nginx-manual-0:/usr/share/nginx/html# exit
正如我们所见,即使我们删除了 Pod,文件仍然存在。
静态配置并不是最好的做法,因为你必须手动跟踪和配置卷。这涉及很多手动操作,可能容易出错。一些希望在开发和运维之间保持分隔的组织可能会使用这种技术。Kubernetes 允许这种配置。然而,对于更适合 DevOps 的组织来说,动态配置是更好的方法。
动态配置
动态配置是指 Kubernetes 通过与云服务提供商交互为你提供存储资源。当我们手动配置磁盘时,我们通过gcloud命令行与云 API 进行交互。如果你们的组织后来决定迁移到其他云服务提供商,这将破坏许多现有的脚本,你将不得不重新编写存储配置步骤。Kubernetes 本质上是可移植且平台无关的。你可以在任何云平台上以相同的方式配置资源。
但是,不同的云服务提供商有不同的存储方案。那么,Kubernetes 如何知道它需要配置什么样的存储呢?其实,Kubernetes 使用StorageClass资源来解决这个问题。StorageClass资源是 Kubernetes 资源,定义了在有人使用它时需要提供的存储类型。
下图展示了动态配置的示意:

图 6.10 – 动态配置
让我们看一个存储类清单示例,fast-storage-class.yaml,它在 GCP 中配置了一个 SSD:
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: fast
provisioner: kubernetes.io/gce-pd
parameters:
type: pd-ssd
StorageClass资源包含了一个供应者和该供应者所需的任何参数。你可能已经注意到,我使用了fast这个名称,而不是gce-ssd或类似的名称。原因是我们希望保持名称尽可能通用。
提示
保持通用的存储类名称,如fast、standard、block和shared,避免使用特定于云平台的名称。因为存储类名称会在持久化卷声明中使用,如果你迁移到另一个云服务提供商,你可能会需要修改大量清单来避免混淆。
让我们继续使用以下命令应用清单:
$ kubectl apply -f fast-storage-class.yaml
当StorageClass资源创建完毕后,让我们使用它来动态配置一个nginx的StatefulSet资源。
我们需要先创建一个Service资源清单,nginx-dynamic-service.yaml:
apiVersion: v1
kind: Service
metadata:
name: nginx-dynamic
labels:
app: nginx-dynamic
spec:
ports:
- port: 80
name: web
clusterIP: None
selector:
app: nginx-dynamic
该清单与手动 Service 资源非常相似。让我们继续使用以下命令来应用它:
$ kubectl apply -f nginx-dynamic-service.yaml
现在,让我们查看 StatefulSet 资源清单,nginx-dynamic-statefulset.yaml:
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: nginx-dynamic
spec:
...
serviceName: "nginx-dynamic"
template:
spec:
containers:
- name: nginx
image: nginx
volumeMounts:
- name: html
mountPath: /usr/share/nginx/html
...
volumeClaimTemplates:
- metadata:
name: html
spec:
storageClassName: "fast"
accessModes: [ "ReadWriteOnce" ]
resources:
requests:
storage: 40Gi
该清单与手动清单类似,但在 volumeClaimTemplates 部分包含 storageClassName 属性,并且缺少 selector 部分,因为我们正在动态配置存储。使用以下命令应用清单:
$ kubectl apply -f nginx-dynamic-statefulset.yaml
当创建 StatefulSet 资源时,让我们继续使用以下命令检查 PersistentVolumeClaim 和 PersistentVolume 资源:
$ kubectl get pvc
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS
html-nginx-dynamic-0 Bound pvc-6b78 40Gi RWO fast
$ kubectl get pv
NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM
pvc-6b78 40Gi RWO Delete Bound default/html-nginx-dynamic-0
我们可以看到声明已绑定到一个动态配置的持久卷。现在,让我们继续运行以下命令,以类似的方式测试这个 StatefulSet 资源。
让我们使用以下命令在 nginx-dynamic-0 pod 中创建一个文件:
$ kubectl exec -it nginx-dynamic-0 -- bash
root@nginx-dynamic-0:/# cd /usr/share/nginx/html/
root@nginx-dynamic-0:/usr/share/nginx/html# echo 'Hello, dynamic world' > index.html
root@nginx-dynamic-0:/usr/share/nginx/html# exit
现在,删除 pod,并再次打开一个 shell 会话,通过以下命令检查文件是否存在:
$ kubectl delete pod nginx-dynamic-0
$ kubectl get pod nginx-dynamic-0
NAME READY STATUS RESTARTS AGE
nginx-dynamic-0 1/1 Running 0 13s
$ kubectl exec -it nginx-dynamic-0 -- bash
root@nginx-dynamic-0:/# cd /usr/share/nginx/html/
root@nginx-dynamic-0:/usr/share/nginx/html# cat index.html
Hello, dynamic world
root@nginx-dynamic-0:/usr/share/nginx/html# exit
正如我们所见,即使删除了 pod,文件也存在于卷中。这就是动态配置生效的方式!
您可能已经注意到,本章节中我们多次使用了 kubectl 命令。在您进行一天中的各种活动时,尽可能使用快捷方式和最佳实践是有意义的。让我们看看在使用 kubectl 时的一些最佳实践。
Kubernetes 命令行最佳实践,技巧和窍门
对于经验丰富的 Kubernetes 开发人员和管理员,kubectl 是他们大部分时间运行的命令。以下步骤将简化您的生活,节省大量时间,让您专注于更重要的活动,并使您脱颖而出。
使用别名
大多数系统管理员出于一个很好的理由使用别名——它们节省宝贵的时间。Linux 中的别名是命令的不同名称,它们主要用于缩短最常用的命令;例如,ls -l 变成 ll。
您可以使用以下别名与 kubectl 一起使用,使生活更轻松。
k 代表 kubectl
是的——没错。通过以下别名,您可以使用 k 而不是输入 kubectl:
$ alias k='kubectl'
$ k get node
NAME STATUS ROLES AGE VERSION
kind-control-plane Ready master 5m7s v1.26.1
kind-worker Ready <none> 4m33s v1.26.1
这将节省大量时间和麻烦。
使用 kubectl --dry-run
kubectl --dry-run 可以帮助您从命令生成 YAML 清单,并节省大量输入时间。您可以编写一个命令来生成资源,并附加 --dry-run=client -o yaml 字符串以从该命令生成 YAML 清单。该命令不会在集群中创建资源,而是仅输出清单。以下命令将使用 --dry-run 生成 Pod 清单:
$ kubectl run nginx --image=nginx --dry-run=client -o yaml
apiVersion: v1
kind: Pod
metadata:
creationTimestamp: null
labels:
run: nginx
name: nginx
spec:
containers:
- image: nginx
name: nginx
resources: {}
dnsPolicy: ClusterFirst
restartPolicy: Always
status: {}
现在您有一个可以根据自己喜好编辑的骨架 YAML 文件。
现在,想象一天中多次输入此命令!在某些时候,这变得令人疲倦。为什么不使用以下别名缩短它呢?
$ alias kdr='kubectl --dry-run=client -o yaml'
您随后可以使用别名生成其他清单。
要生成一个Deployment资源清单,使用以下命令:
$ kdr create deployment nginx --image=nginx
你可以使用 dry run 来从命令式命令生成几乎所有资源。然而,有些资源没有命令式命令,例如DaemonSet资源。你可以为最接近的资源生成清单并对其进行修改,以适应此类资源。DaemonSet清单与Deployment清单非常相似,因此你可以生成一个Deployment清单并将其修改为适应DaemonSet清单。
现在,让我们来看看一些最常用的kubectl命令及其可能的别名。
kubectl apply和delete的别名
如果你使用清单,你通常会在集群中使用kubectl apply和kubectl delete命令,因此使用以下别名是很有意义的:
$ alias kap='kubectl apply -f'
$ alias kad='kubectl delete -f'
然后,你可以使用以下命令来应用或删除资源:
$ kap nginx-deployment.yaml
$ kad nginx-deployment.yaml
在排查容器问题时,我们大多数人使用busybox。让我们来看看如何优化它。
使用别名通过 busybox 排查容器问题
我们使用以下命令来打开busybox会话:
$ kubectl run busybox-test --image=busybox -it --rm --restart=Never -- <cmd>
现在,白天打开多个busybox会话可能会让人感到疲劳。怎样通过使用以下别名来最小化开销呢?
$ alias kbb='kubectl run busybox-test --image=busybox -it --rm --restart=Never --'
然后,我们可以使用以下命令打开一个新的busybox pod 的 shell 会话:
$ kbb sh
/ #
现在,这样更干净、更简单。同样,你还可以为其他经常使用的命令创建别名。以下是一个示例:
$ alias kgp='kubectl get pods'
$ alias kgn='kubectl get nodes'
$ alias kgs='kubectl get svc'
$ alias kdb='kubectl describe'
$ alias kl='kubectl logs'
$ alias ke='kubectl exec -it'
依此类推,根据你的需求。你也许已经习惯了在bash中使用自动补全功能,当你输入几个单词后按Tab键,命令会自动补全。kubectl也提供了命令的自动补全,但默认情况下并未启用。现在,让我们来看一下如何在bash中启用kubectl的自动补全功能。
使用kubectl的 bash 自动补全
要启用kubectl的bash自动补全功能,使用以下命令:
$ echo "source <(kubectl completion bash)" >> ~/.bashrc
该命令将kubectl的 completion bash命令作为源添加到你的.bashrc文件中。因此,下次你登录到 shell 时,应该就能使用kubectl自动补全功能了。这样在输入命令时可以节省大量时间。
总结
本章开始时,我们通过Deployment和ReplicaSet资源来管理 pods,并讨论了一些关键的 Kubernetes 部署策略。接着,我们研究了 Kubernetes 服务发现和模型,并理解了为什么需要一个单独的实体来将容器暴露给内部或外部世界。然后,我们了解了不同的Service资源及其使用场景。我们还讨论了Ingress资源,并介绍了如何使用它们为容器工作负载创建反向代理。接着,我们深入研究了 Horizontal Pod 自动扩展,并使用多个指标自动扩展 pods。
我们研究了状态考虑因素,并学习了如何使用 PersistentVolume、PersistentVolumeClaim 和 StorageClass 资源进行静态和动态存储配置,并讨论了一些围绕它们的最佳实践。我们还研究了 StatefulSet 资源,这些是帮助你调度和管理有状态容器的关键资源。最后,我们还探讨了有关 kubectl 命令行的一些最佳实践、技巧和窍门,以及如何有效地使用它们。
本章和上一章所涵盖的内容只是 Kubernetes 的核心。Kubernetes 是一个功能庞大的工具,足够写一本完整的书籍,因此这些章节只是给你概述了它的基本内容。请随时查阅 Kubernetes 官方文档的详细资源:kubernetes.io。
在下一章中,我们将深入探讨云计算世界,了解 容器即服务 (CaaS) 和无服务器容器服务。
问题
-
Kubernetes 部署在镜像更新时会删除旧的
ReplicaSet资源。(正确/错误) -
Kubernetes 支持的主要部署策略有哪些?(选择两个)
A. 重建
B. 滚动更新
C. 渐进式慢速发布
D. 最佳努力控制的滚动发布
-
你可以使用哪些类型的资源来将容器暴露到外部?(选择三个)
A.
ClusterIP 服务B.
NodePort 服务C.
LoadBalancer 服务D.
Ingress -
最好的实践是先从
ClusterIP服务开始,必要时再更改服务类型。(正确/错误) -
Deployment资源适用于有状态工作负载。(正确/错误) -
使用 Ingress 时,你可以运行哪些类型的工作负载?
A. HTTP
B. TCP
C. FTP
D. SMTP
-
你会为动态卷配置定义哪些资源?(选择两个)
A.
StorageClassB.
PersistentVolumeClaimC.
PersistentVolumeD.
StatefulSet -
为了使你的水平扩展更具意义,你应该使用哪些参数来扩展你的 Pod?(选择三个)
A. CPU
B. 内存
C. 外部指标,如响应时间
D. 每秒数据包(PPS)
-
在
Ingress资源中,路由形式有哪些?(选择两个)A. 简单
B. 基于路径的
C. 基于名称的
D. 复杂
答案
-
错误。一个镜像部署只是将旧的
ReplicaSet资源缩放到0。 -
A 和 B
-
B、C 和 D
-
正确
-
错误。请改用
StatefulSet资源。 -
A
-
A 和 B
-
A、B 和 C
-
B 和 C
第七章:容器即服务(CaaS)和容器的无服务器计算
在前两章中,我们介绍了 Kubernetes 及其如何帮助无缝管理容器。现在,让我们看看自动化和管理容器部署的其他方式——容器即服务(CaaS)和容器的无服务器计算。CaaS 提供基于容器的虚拟化,抽象了所有管理工作,帮助你管理容器而无需担心底层的基础设施和编排。
对于简单的部署和较少复杂的应用,CaaS 可以成为救世主。无服务器计算是一个广泛的术语,涵盖了无需我们担心背后基础设施的应用。它的一个额外好处是你可以完全专注于应用本身。我们将详细讨论 CaaS 技术,如Amazon Elastic Container Service(Amazon ECS)与Amazon Web Services Fargate(AWS Fargate),并简要讨论其他基于云的 CaaS 服务,如Azure Kubernetes Services(AKS)、Google Kubernetes Engine(GKE)和Google Cloud Run。然后,我们将深入探讨流行的开源无服务器 CaaS 解决方案——Knative。
本章我们将涵盖以下主要内容:
-
无服务器服务的需求
-
Amazon ECS 与弹性计算云(EC2)和 Fargate
-
其他 CaaS 服务
-
使用 Knative 的开源 CaaS
技术要求
本章的练习需要一个有效的 AWS 订阅。AWS 是市场上最受欢迎、功能最丰富的云平台。目前,AWS 为一些产品提供免费套餐。你可以在aws.amazon.com/free注册。尽管本章使用了一些付费服务,但我们会尽量减少在练习中使用的付费服务。
你还需要克隆以下 GitHub 仓库进行一些练习:
github.com/PacktPublishing/Modern-DevOps-Practices-2e
运行以下命令将仓库克隆到你的主目录。然后,cd到ch7目录以访问所需的资源:
$ git clone https://github.com/PacktPublishing/Modern-DevOps-Practices-2e.git \
modern-devops
$ cd modern-devops/ch7
由于仓库中包含带有占位符字符串的文件,你必须将<your_dockerhub_user>字符串替换为实际的 Docker Hub 用户。使用以下命令替换占位符:
$ find ./ -type f -exec sed -i -e \
's/<your_dockerhub_user>/<your actual docker hub user>/g' {} \;
那么,让我们开始吧!
无服务器服务的需求
到目前为止,许多组织一直在集中精力进行基础设施的提供和管理。他们优化围绕他们构建的应用程序的资源、机器和基础设施的数量。然而,他们应该集中精力做他们最擅长的事情——软件开发。除非你的组织打算投入大量资金组建一个昂贵的基础设施团队来做大量繁重的后台工作,否则你最好将精力集中在编写和构建高质量的应用程序上,而不是关注如何以及在哪里运行和优化它们。
无服务器服务为这个问题提供了缓解。你不再需要集中精力关注如何托管基础设施来运行应用程序,而是可以声明你想要运行的内容,无服务器服务会为你管理它。这对于那些没有预算投入大量资金进行基础设施建设的小型企业来说,已经成为一项福音,他们可以快速启动,而无需浪费太多时间搭建和维护基础设施来运行应用程序。
无服务器服务还提供容器和应用程序工作负载的自动部署和扩展。你可以在几分钟甚至几秒钟内从 0 扩展到 100 个实例。最棒的是,在某些服务中,你只需为实际使用的部分付费,而不是为分配的部分付费。
本章将重点介绍一个非常受欢迎的 AWS 容器管理服务——ECS以及 AWS 的容器无服务器服务——AWS Fargate。然后,我们将简要地考察其他云平台的相关服务,最后介绍一种开源的基于容器的无服务器解决方案——Knative。
现在,让我们继续了解 Amazon ECS。
Amazon ECS 与 EC2 和 Fargate
Amazon ECS 是 AWS 提供的一个容器编排平台。它易于使用和管理,在后台使用 Docker,并且可以将工作负载部署到Amazon EC2,这是基于虚拟机(VM)的解决方案,或者AWS Fargate,一种无服务器服务。
这是一个高度可扩展的解决方案,可以在几秒钟内部署容器。它使得托管、运行、停止和启动容器变得非常容易。正如 Kubernetes 提供pods一样,ECS 提供任务,帮助你运行容器工作负载。一个任务可以包含一个或多个根据逻辑关系分组的容器。你还可以将一个或多个任务分组为服务。服务类似于 Kubernetes 控制器,管理任务并确保所需数量的任务副本在正确的时间、正确的地方运行。ECS 使用简单的 API 调用提供许多功能,例如创建、更新、读取和删除任务和服务。
ECS 还允许你根据多个放置策略来放置容器,同时考虑高可用性(HA)和资源优化。你可以根据优先级(成本、可用性或两者的结合)调整放置算法。因此,你可以使用 ECS 运行一次性的批处理工作负载或长期运行的微服务,所有操作都可以通过简单易用的 API 接口完成。
ECS 架构
在我们深入了解 ECS 架构之前,了解一些常见的 AWS 术语是很重要的。我们先来看看一些 AWS 资源:
-
us-east-1、us-west-1、ap-southeast-1、eu-central-1等。 -
us-east-1a、us-east-1b等。 -
AWS 虚拟私有云(VPC):AWS VPC 是你在 AWS 内创建的一个隔离的网络资源。你将一个专用的私有 IP 地址范围关联到它,从中你的其他资源(如 EC2 实例)可以获取其 IP 地址。AWS VPC 跨越一个 AWS 区域。
-
子网:子网顾名思义是在 VPC 内的一个子网络。你必须将提供给 VPC 的 IP 地址范围细分并与子网关联。资源通常位于子网内,每个子网跨越一个可用区(AZ)。
-
路由表:AWS 路由表在 VPC 子网内以及与互联网之间路由流量。每个 AWS 子网通过子网路由表关联与路由表相关联。
-
互联网网关:互联网网关允许 AWS 子网与互联网之间的连接。
-
身份与访问管理(IAM):AWS IAM 帮助你控制用户和其他 AWS 资源对资源的访问。它们帮助你实现基于角色的访问控制(RBAC)以及最小权限原则(PoLP)。
-
Amazon EC2:EC2 允许你在子网内启动虚拟机,也称为实例。
-
AWS 自动扩展组(ASGs):AWS ASG 与 Amazon EC2 一起工作,为你的实例提供高可用性(HA)和可扩展性。它监控你的 EC2 实例,并确保始终有一定数量的健康实例在运行。它还会根据机器负载的增加自动扩展实例,以应对更多的流量。它使用实例配置文件和启动配置来决定新 EC2 实例的属性。
-
Amazon CloudWatch:Amazon CloudWatch 是一项监控和可观察性服务。它允许你收集、跟踪和监控指标、日志文件,并设置警报以在特定条件下采取自动化操作。CloudWatch 有助于理解应用程序的性能、健康状况和资源利用情况。
ECS 是一个基于云的区域服务。当你启动一个 ECS 集群时,实例会跨越多个可用区(AZ),在这些可用区中,你可以使用简单的清单调度任务和服务。ECS 清单非常类似于docker-compose YAML 清单,我们可以在其中指定要运行的任务以及构成服务的任务。
您可以在现有的 VPC 中运行 ECS。我们可以在 Amazon EC2 或 AWS Fargate 中调度任务。
您的 ECS 集群可以附加一个或多个 EC2 实例。您还可以选择通过在 EC2 实例中安装 ECS 节点代理将现有 EC2 实例附加到集群。该代理会将容器的状态和任务信息发送到 ECS 调度器。然后,它与容器运行时交互,以便在节点内调度容器。它们类似于 Kubernetes 生态系统中的kubelet。如果您在 EC2 实例中运行容器,您需要为分配给集群的 EC2 实例数量付费。
如果您打算使用 Fargate,基础设施将完全抽象化,您必须指定容器将消耗的 CPU 和内存数量。您支付的是容器实际消耗的 CPU 和内存,而不是您分配给机器的资源。
提示
尽管在 Fargate 中您只为实际消耗的资源付费,但它比在 EC2 上运行任务更昂贵,尤其是对于长时间运行的服务,如 Web 服务器。一个经验法则是将长时间运行的在线任务放在 EC2 中运行,而将批处理任务放在 Fargate 中运行。这样可以实现最佳的成本优化。
当我们调度一个任务时,AWS 会通过从容器注册表拉取所需的容器镜像,在托管的 EC2 或 Fargate 服务器上启动容器。每个任务都有一个附加的弹性网络接口(ENI)。多个任务会被组合成一个服务,该服务确保所有必需的任务同时运行。
Amazon ECS 使用任务调度器来调度集群上的容器。根据放置逻辑、可用性和成本要求,它会将容器放置在集群的适当节点上。调度器还确保在给定时间节点上运行所需数量的任务。
下图清晰地解释了 ECS 集群架构:

图 7.1 – ECS 架构
Amazon 提供了 ECS 的命令行界面(CLI)用于与 ECS 集群交互。它是一个简单的命令行工具,您可以用来管理 ECS 集群、创建和管理集群上的任务和服务。
现在,让我们继续安装 ECS CLI。
安装 AWS 和 ECS CLI
AWS CLI 作为deb包提供,包含在公共的apt仓库中。要安装它,请运行以下命令:
$ sudo apt update && sudo apt install awscli -y
$ aws --version
aws-cli/1.22.34 Python/3.10.6 Linux/5.19.0-1028-aws botocore/1.23.34
在 Linux 环境中安装 ECS CLI 非常简单。我们只需下载二进制文件并使用以下命令将其移至系统路径:
$ sudo curl -Lo /usr/local/bin/ecs-cli \
https://amazon-ecs-cli.s3.amazonaws.com/ecs-cli-linux-amd64-latest
$ sudo chmod +x /usr/local/bin/ecs-cli
运行以下命令来检查ecs-cli是否已正确安装:
$ ecs-cli --version
ecs-cli version 1.21.0 (bb0b8f0)
如我们所见,ecs-cli已成功安装在我们的系统上。
下一步是允许ecs-cli与您的 AWS API 连接。为此,您需要导出 AWS CLI 环境变量。运行以下命令进行操作:
$ export AWS_SECRET_ACCESS_KEY=...
$ export AWS_ACCESS_KEY_ID=...
$ export AWS_DEFAULT_REGION=...
一旦我们设置了环境变量,ecs-cli 将使用它们来进行 AWS API 的身份验证。在下一部分中,我们将使用 ECS CLI 启动一个 ECS 集群。
启动 ECS 集群
我们可以使用 ECS CLI 命令来启动 ECS 集群。你可以在 EC2 和 Fargate 上运行你的容器,因此首先我们将创建一个运行 EC2 实例的集群。然后,我们将在集群中添加 Fargate 任务。
要连接到你的 EC2 实例,你需要在 AWS 中生成一个密钥对。为此,请运行以下命令:
$ aws ec2 create-key-pair --key-name ecs-keypair
此命令的输出将提供密钥对的 JSON 文件。提取 JSON 文件的密钥材料,并将其保存在一个名为 ecs-keypair.pem 的单独文件中。记得在保存文件时将 \n 字符替换为换行符。
一旦我们生成了密钥对,我们可以使用以下命令通过 ECS CLI 创建一个 ECS 集群:
$ ecs-cli up --keypair ecs-keypair --instance-type t2.micro \
--size 2 --cluster cluster-1 --capability-iam
INFO[0002] Using recommended Amazon Linux 2 AMI with ECS Agent 1.72.0 and Docker version
20.10.23
INFO[0003] Created cluster cluster=cluster-1 region=us-east-1
INFO[0004] Waiting for your cluster resources to be created...
INFO[0130] Cloudformation stack status stackStatus=CREATE_IN_PROGRESS
VPC created: vpc-0448321d209bf75e2
Security Group created: sg-0e30839477f1c9881
Subnet created: subnet-02200afa6716866fa
Subnet created: subnet-099582f6b0d04e419
Cluster creation succeeded.
当我们执行此命令时,AWS 会在后台使用 CloudFormation 启动一堆资源。CloudFormation 是 AWS 的基础设施即代码(IaC)解决方案,它通过可重用的模板帮助你在 AWS 上部署基础设施。CloudFormation 模板包含多个资源,例如 VPC、安全组、VPC 内的子网、路由表、路由、子网路由表关联、互联网网关、IAM 角色、实例配置文件、启动配置、ASG、VPC 网关附件以及集群本身。ASG 包含两个正在运行并为集群提供服务的 EC2 实例。请保留输出的副本;稍后的练习中我们需要使用这些细节。
现在我们的集群已经启动,我们将启动我们的第一个任务。
创建任务定义
ECS 任务类似于 Kubernetes pod。它们是 ECS 的基本构建块,由一个或多个相关的容器组成。任务定义是 ECS 任务的蓝图,定义了 ECS 任务的外观。它们与 docker-compose 文件非常相似,且采用 YAML 格式编写。ECS 还使用所有版本的 docker-compose 允许我们定义任务。它们帮助你定义容器及其镜像、资源需求、运行位置(EC2 或 Fargate)、卷和端口映射以及其他网络需求。
提示
使用 docker-compose 清单来启动任务和服务是一个好主意,因为它将帮助你将配置与开放标准对齐。
任务是一个有限的过程,仅运行一次。即使它是一个长时间运行的过程,例如 Web 服务器,任务仍然只运行一次,等待长时间运行的进程结束(理论上会一直运行)。任务的生命周期遵循Pending(待处理) -> Running(运行中) -> Stopped(已停止)状态。因此,当你调度任务时,任务进入Pending状态,尝试从容器注册表中拉取镜像。然后,它尝试启动容器。一旦容器启动,它就进入Running状态。当容器执行完成或出错时,它将进入Stopped状态。一个启动错误的容器会直接从Pending状态转到Stopped状态。
现在,让我们继续在刚刚创建的 ECS 集群中部署一个nginx Web 服务器任务。
要访问本节的资源,cd到以下目录:
$ cd ~/modern-devops/ch7/ECS/tasks/EC2/
我们将在这里使用docker-compose任务定义。所以,让我们从定义以下docker-compose.yml文件开始:
version: '3'
services:
web:
image: nginx
ports:
- "80:80"
logging:
driver: awslogs
options:
awslogs-group: /aws/webserver
awslogs-region: us-east-1
awslogs-stream-prefix: ecs
YAML 文件定义了一个web容器,使用nginx镜像,主机端口80映射到容器端口80。它使用awslogs日志驱动程序,将日志流式传输到 Amazon CloudWatch。它会将日志流传输到us-east-1区域中的/aws/webserver日志组,并使用ecs流前缀。
任务定义还包括资源定义——也就是说,我们希望为任务保留的资源量。因此,我们将需要定义以下的ecs-params.yaml文件:
version: 1
task_definition:
services:
web:
cpu_shares: 100
mem_limit: 524288000
这个 YAML 文件定义了容器的cpu_shares(以毫核为单位)和mem_limit(以字节为单位)。现在,我们来看看如何将此任务调度为 EC2 任务。
在 ECS 上调度 EC2 任务
让我们使用ecs-cli应用配置并使用以下命令调度任务:
$ ecs-cli compose up --create-log-groups --cluster cluster-1 --launch-type EC2
现在任务已经调度并且容器正在运行,让我们列出所有任务以获取容器的详细信息,并查找它运行的位置。为此,运行以下命令:
$ ecs-cli ps --cluster cluster-1
Name State Ports TaskDefinition
cluster-1/fee1cf28/web RUNNING 34.237.218.7:80->80 EC2:1
如我们所见,Web 容器正在34.237.218.7:80的cluster-1上运行。现在,使用以下命令来 curl 此端点,看看我们能得到什么:
$ curl 34.237.218.7:80
<html>
<head>
<title>Welcome to nginx!</title>
...
</html>
这里,我们看到了默认的nginx首页!我们已经成功地在 ECS 上使用 EC2 启动类型调度了一个容器。你可能想要复制这个任务来处理更多的流量。这被称为水平扩展。我们将在下一部分看到如何实现。
扩展任务
我们可以使用ecs-cli轻松地扩展任务。使用以下命令将任务扩展到2:
$ ecs-cli compose scale 2 --cluster cluster-1 --launch-type EC2
现在,使用以下命令检查集群中是否有两个容器在运行:
$ ecs-cli ps --cluster cluster-1
Name State Ports TaskDefinition
cluster-1/b43bdec7/web RUNNING 54.90.208.183:80->80 EC2:1
cluster-1/fee1cf28/web RUNNING 34.237.218.7:80->80 EC2:1
如我们所见,集群上正在运行两个容器。现在,让我们查询 CloudWatch 以获取容器的日志。
从 CloudWatch 查询容器日志
要从 CloudWatch 查询日志,我们必须使用以下命令列出日志流:
$ aws logs describe-log-streams --log-group-name /aws/webserver \
--log-stream-name-prefix ecs | grep logStreamName
"logStreamName": "ecs/web/b43bdec7",
"logStreamName": "ecs/web/fee1cf28",
如我们所见,这里有两个日志流——每个任务一个。logStreamName 遵循 <log_stream_prefix>/<task_name>/<task_id> 的约定。因此,要获取 ecs/b43bdec7/web 的日志,请运行以下命令:
$ aws logs get-log-events --log-group-name/aws/webserver \
--log-stream ecs/web/b43bdec7
在此,您将在响应中看到以 JSON 格式显示的日志流。现在,让我们来看一下如何停止正在运行的任务。
停止任务
ecs-cli 使用友好的 docker-compose 语法进行所有操作。使用以下命令停止集群中的任务:
$ ecs-cli compose down --cluster cluster-1
让我们列出容器,查看任务是否已停止,使用以下命令:
$ ecs-cli ps --cluster cluster-1
INFO[0001] Stopping container... container=cluster-1/b43bdec7/web
INFO[0001] Stopping container... container=cluster-1/fee1cf28/web
INFO[0008] Stopped container... container=cluster-1/b43bdec7/web
desiredStatus=STOPPED lastStatus=STOPPED taskDefinition="EC2:1"
INFO[0008] Stopped container... container=cluster-1/fee1cf28/web
desiredStatus=STOPPED lastStatus=STOPPED taskDefinition="EC2:1"
如我们所见,两个容器都已停止。
在 EC2 上运行任务并不是一种无服务器的方式。您仍然需要配置和管理 EC2 实例,尽管 ECS 管理集群上的工作负载,但您仍然需要为已配置的 EC2 实例的资源支付费用。AWS 提供了 Fargate 作为一种无服务器解决方案,按资源消耗付费。让我们看看如何将相同的任务创建为 Fargate 任务。
在 ECS 上调度 Fargate 任务
在 Fargate 上调度任务与 EC2 非常相似。在这里,我们需要将launch-type值指定为FARGATE。
要在 Fargate 上调度相同的任务,请运行以下命令:
$ ecs-cli compose up --create-log-groups --cluster cluster-1 --launch-type FARGATE
FATA[0001] ClientException: Fargate only supports network mode 'awsvpc'.
哎呀!我们遇到问题了!它抱怨网络类型。对于 Fargate 任务,我们必须提供网络类型awsvpc,而不是默认的桥接网络。awsvpc网络是一种覆盖网络,实施了awsvpc网络类型。但在此之前,Fargate 任务需要一些配置。
要访问本节的资源,请cd进入以下目录:
$ cd ~/modern-devops/ch7/ECS/tasks/FARGATE/
首先,我们必须假设一个任务执行角色,以便 ECS 代理能够进行 AWS API 认证并与 Fargate 交互。
为此,请创建以下task-execution-assume-role.json文件:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "",
"Effect": "Allow",
"Principal": {
"Service": "ecs-tasks.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
}
然后,使用以下命令来假设任务执行角色:
$ aws iam --region us-east-1 create-role --role-name ecsTaskExecutionRole \
--assume-role-policy-document file://task-execution-assume-role.json
ECS 提供了一个默认的角色策略,名为AmazonECSTaskExecutionRolePolicy,其中包含多种权限,帮助您与 CloudWatch 和Elastic Container Registry(ECR)进行交互。以下 JSON 代码概述了该策略的权限:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"ecr:GetAuthorizationToken",
"ecr:BatchCheckLayerAvailability",
"ecr:GetDownloadUrlForLayer",
"ecr:BatchGetImage",
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": "*"
}
]
}
我们需要通过以下命令将该角色策略分配给我们之前假设的ecsTaskExecution角色:
$ aws iam attach-role-policy \
--policy-arn arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy \
--role-name ecsTaskExecutionRole
一旦我们将策略分配给ecsTaskExecution角色,我们需要在创建 ECS 集群时获取两个子网和安全组的 ID。您可以在创建集群时的命令行输出中找到这些详细信息。我们将在以下的ecs-params.yml文件中使用这些详细信息:
version: 1
task_definition:
task_execution_role: ecsTaskExecutionRole
ecs_network_mode: awsvpc
task_size:
mem_limit: 0.5GB
cpu_limit: 256
run_params:
network_configuration:
awsvpc_configuration:
subnets:
- "subnet-088b52c91a6f40fd7"
- "subnet-032cd63290da67271"
security_groups:
- "sg-097206175813aa7e7"
assign_public_ip: ENABLED
ecs-params.yml 文件包括我们创建的 task_execution_role 和设置为 awsvpc 的 ecs_network_mode,因为 Fargate 要求如此。我们已将 task_size 定义为拥有 0.5GB 的内存和 256 毫核 CPU。由于 Fargate 是一种无服务器解决方案,我们只需为所消耗的 CPU 核心和内存付费。run_params 部分包括 network_configuration,其中包含 awsvpc_configuration。在这里,我们指定了在创建 ECS 集群时创建的两个子网。我们还必须指定与 ECS 集群一起创建的 security_groups。
注意
使用你 ECS 集群的子网和安全组,而不是复制本示例中的内容。
现在我们已经准备好在 Fargate 上启动任务,让我们运行以下命令:
$ ecs-cli compose up --create-log-groups --cluster cluster-1 --launch-type FARGATE
现在,让我们通过以下命令检查任务是否成功运行:
$ ecs-cli ps --cluster cluster-1
Name State Ports TaskDefinition
cluster-1/8717a149/web RUNNING 3.80.173.230:80 FARGATE:1
如我们所见,任务正在 3.80.173.230:80 上运行,作为 Fargate 任务。让我们使用以下命令 curl 这个 URL,看看是否能收到响应:
$ curl 3.80.173.230:80
<html>
<head>
<title>Welcome to nginx!</title>
...
</body>
</html>
如我们所见,我们得到了默认的 nginx 首页。
现在,让我们继续使用以下命令删除我们创建的任务:
$ ecs-cli compose down --cluster cluster-1
如我们所知,任务有一个固定的生命周期,一旦停止,它就会停止。你无法再次启动相同的任务。因此,我们必须创建一个服务来确保始终有一定数量的任务在运行。我们将在下一节中创建一个服务。
在 ECS 上调度服务
ecs-cli 命令行。
提示
对于长期运行的应用程序(如 Web 服务器),始终使用服务。对于批处理任务,始终使用任务,因为我们不希望在任务结束后重新创建任务。
要将 nginx Web 服务器作为服务运行,我们可以使用以下命令:
$ ecs-cli compose service up --create-log-groups \
--cluster cluster-1 --launch-type FARGATE
INFO[0001] Using ECS task definition TaskDefinition="FARGATE:1"
INFO[0002] Auto-enabling ECS Managed Tags
INFO[0013] (service FARGATE) has started 1 tasks: (task 9b48084d). timestamp="2023-07-03
11:24:42 +0000 UTC"
INFO[0029] Service status desiredCount=1 runningCount=1 serviceName=FARGATE
INFO[0029] (service FARGATE) has reached a steady state. timestamp="2023-07-03 11:25:00
+0000 UTC"
INFO[0029] (service FARGATE) (deployment ecs-svc/94284856) deployment
completed. timestamp="2023-07-03 11:25:00 UTC"
INFO[0029] ECS Service has reached a stable state desiredCount=1 runningCount=1
serviceName=FARGATE
INFO[0029] Created an ECS service service=FARGATE taskDefinition="FARGATE:1"
从日志中我们可以看到,服务正在尝试确保任务的期望数量与任务的运行数量相匹配。如果你的任务被删除,ECS 会用一个新的任务替换它。
让我们列出任务,看看通过以下命令能得到什么:
$ ecs-cli ps --cluster cluster-1
Name State Ports TaskDefinition
cluster-1/9b48084d/web RUNNING 18.234.123.71:80 FARGATE:1
如我们所见,服务已创建一个新任务,并在 18.234.123.71:80 上运行。让我们尝试通过以下命令访问该 URL:
$ curl 18.234.123.71
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
...
</html>
我们在响应中得到默认的 nginx 首页。现在,让我们尝试浏览任务的日志。
使用 ECS CLI 浏览容器日志
除了使用 Amazon CloudWatch 外,你还可以使用便捷的 ECS CLI 来做到这一点,无论你的日志存储在哪里。这帮助我们从单一视图看到所有内容。
运行以下命令来执行此操作:
$ ecs-cli logs --task-id 9b48084d --cluster cluster-1
/docker-entrypoint.sh: /docker-entrypoint.d/ is not empty, will attempt to perform
configuration
/docker-entrypoint.sh: Launching /docker-entrypoint.d/10-listen-on-ipv6-by-default.sh
10-listen-on-ipv6-by-default.sh: info: Enabled listen on IPv6 in /etc/nginx/conf.d/
default.conf
/docker-entrypoint.sh: Launching /docker-entrypoint.d/20-envsubst-on-templates.sh
/docker-entrypoint.sh: Launching /docker-entrypoint.d/30-tune-worker-processes.sh
/docker-entrypoint.sh: Configuration complete; ready for start up
2023/07/03 11:24:57 [notice] 1#1: nginx/1.25.1
2023/07/03 11:24:57 [notice] 1#1: built by gcc 12.2.0 (Debian 12.2.0-14)
2023/07/03 11:24:57 [notice] 1#1: OS: Linux 5.10.184-175.731.amzn2.x86_64
2023/07/03 11:24:57 [notice] 1#1: getrlimit(RLIMIT_NOFILE): 65535:65535
2023/07/03 11:24:57 [notice] 1#1: start worker processes
2023/07/03 11:24:57 [notice] 1#1: start worker process 29
2023/07/03 11:24:57 [notice] 1#1: start worker process 30
13.232.8.130 - - [03/Jul/2023:11:30:38 +0000] "GET / HTTP/1.1" 200 615 "-" "curl/7.81.0"
"-"
如我们所见,我们可以浏览该服务正在运行的特定任务的日志。现在,让我们继续删除该服务。
删除 ECS 服务
要删除服务,运行以下命令:
$ ecs-cli compose service down --cluster cluster-1
INFO[0001] Deleted ECS service service=FARGATE
INFO[0001] Service status desiredCount=0 runningCount=1 serviceName=FARGATE
INFO[0006] Service status desiredCount=0 runningCount=0 serviceName=FARGATE
INFO[0006] (service FARGATE) has stopped 1 running tasks: (task
9b48084d11cf49be85141fd9bfe9e1c3). timestamp="2023-07-03 11:34:10 +0000 UTC"
INFO[0006] ECS Service has reached a stable state desiredCount=0 runningCount=0
serviceName=FARGATE
如我们所见,服务已被删除。
请注意,即使我们创建了多个任务实例,它们会运行在不同的 IP 地址上并且可以单独访问。然而,任务需要负载均衡,我们需要提供一个单一的端点。让我们来看一个可以用来管理这个问题的解决方案。
在 ECS 上运行负载均衡容器
负载均衡是多实例应用程序的重要功能。它们帮助我们在单一端点上提供应用服务。因此,你可以同时运行多个实例,而最终用户不需要关心他们访问的是哪个实例。AWS 提供了两种主要的负载均衡解决方案——第四层的网络负载均衡器(NLB)和第七层的应用负载均衡器(ALB)。
提示
虽然两种负载均衡器各有其使用场景,但第七层负载均衡器对于基于 HTTP 的应用程序具有显著优势。它提供了先进的流量管理功能,例如基于路径和主机的路由。
现在,让我们使用以下命令创建一个 ALB,将其用作我们任务的前端:
$ aws elbv2 create-load-balancer --name ecs-alb --subnets <SUBNET-1> <SUBNET-2> \
--security-groups <SECURITY_GROUP_ID> --region us-east-1
上述命令的输出包含了LoadBalancerARN和DNSName的值。我们将在后续步骤中使用它们,所以请保存输出的副本。
下一步是创建一个目标组。目标组定义了任务的组和它们将监听的端口,负载均衡器将把流量转发到该组。使用以下命令定义目标组:
$ aws elbv2 create-target-group --name target-group --protocol HTTP \
--port 80 --target-type ip --vpc-id <VPC_ID> --region us-east-1
你将在响应中获得targetGroupARN值。请妥善保管,因为我们将在下一步中需要它。
接下来,我们需要在负载均衡器上运行一个监听器。它应该将流量从负载均衡器转发到目标组。使用以下命令来实现:
$ aws elbv2 create-listener --load-balancer-arn <LOAD_BALANCER_ARN> \
--protocol HTTP --port 80 \
--default-actions Type=forward,TargetGroupArn=<TARGET_GROUP_ARN> \
--region us-east-1
你将在响应中获得listenerARN值。请妥善保管,因为我们将在下一步中需要它。
现在我们已经定义了负载均衡器,我们需要运行ecs-cli compose service up来部署我们的服务。我们还将提供目标组作为参数,以将我们的服务与负载均衡器关联。
要访问本节的资源,请cd到以下目录:
$ cd ~/modern-devops/ch7/ECS/loadbalancing/
运行以下命令:
$ ecs-cli compose service up --create-log-groups --cluster cluster-1 \
--launch-type FARGATE --target-group-arn <TARGET_GROUP_ARN> \
--container-name web --container-port 80
既然服务和任务已经在 Fargate 上运行,我们可以将服务扩展到三个所需任务。为此,请运行以下命令:
$ ecs-cli compose service scale 3 --cluster cluster-1
由于我们的服务已经扩展到三个任务,现在让我们访问在第一步中获取的负载均衡器 DNS 端点。这应该会为我们提供默认的nginx响应。运行以下命令:
$ curl ecs-alb-1660189891.us-east-1.elb.amazonaws.com
<html>
<head>
<title>Welcome to nginx!</title>
…
</html>
如我们所见,我们从负载均衡器获得了默认的nginx响应。这表明负载均衡工作正常!
ECS 提供了许多其他功能,如水平自动扩展、可自定义的任务调度算法等,但这些超出了本书的范围。请阅读 ECS 文档,了解该工具的其他方面。现在,让我们看看市场上其他流行的 CaaS 产品。
其他 CaaS 服务
Amazon ECS 提供了一种灵活的方式来管理你的容器工作负载。当你拥有一个较小、较简单的架构时,它非常适用,并且你不想增加使用像 Kubernetes 这样复杂的容器编排引擎的额外开销。
提示
如果你完全依赖于 AWS 并且没有未来的多云或混合云战略,ECS 是一个优秀的工具选择。Fargate 使得部署和运行容器变得更容易,无需担心背后的基础设施。
ECS 与 AWS 及其架构紧密耦合。为了解决这个问题,我们可以使用 AWS 内的托管服务,如弹性 Kubernetes 服务(EKS)。它提供了 Kubernetes API 来调度工作负载。这使得管理容器更加灵活,因为你可以轻松地启动 Kubernetes 集群,并使用一个标准的开源解决方案,在任何地方安装和运行。这不会将你绑定到特定的供应商。然而,EKS 比 ECS 略贵,并且增加了每小时0.10 美元的集群管理费用。但与它带来的好处相比,这点费用算不了什么。
如果你不是在 AWS 上运行,也有其他提供商的选择。下一个大三云服务提供商是 Azure,它提供了Azure Kubernetes 服务(AKS),这是一种托管的 Kubernetes 解决方案,可以帮助你在几分钟内开始使用。AKS 提供了一种完全托管的解决方案,支持按需事件驱动的弹性工作节点供应。它还与Azure DevOps良好集成,为你提供更快的端到端(E2E)开发体验。与 AWS 一样,Azure 也收取每小时0.10 美元的集群管理费用。
Google Kubernetes Engine(GKE)是最强大的 Kubernetes 平台之一。由于 Kubernetes 项目来自 Google,并且它是该项目在开源社区中的最大贡献者,GKE 通常会更快推出新版本,并且是第一个发布安全补丁的解决方案。此外,它是最具功能性的 Kubernetes 平台之一,提供了可自定义的解决方案,并提供了多个插件作为集群配置。因此,你可以选择在启动时安装哪些内容,并进一步加固集群。然而,所有这些都需要付出代价,因为 GKE 与 AWS 和 Azure 一样,收取每小时0.10 美元的集群管理费用。
如果你的架构不复杂,并且只需要管理少量容器,你可以使用 Google Cloud Run,而不必使用 Kubernetes。Google Cloud Run 是基于开源 Knative 项目构建的无服务器 CaaS 解决方案,帮助你运行容器而不受供应商锁定的限制。由于它是无服务器的,你只需为使用的容器数量及其资源利用付费。它是一个完全可扩展、与 Google Cloud 的 DevOps 和监控解决方案(如 Cloud Code、Cloud Build、Cloud Monitoring 和 Cloud Logging)良好集成的解决方案。最棒的是,它可以与 AWS Fargate 相媲美,并将所有基础设施工作抽象化。因此,它是一个最小运维或无运维的解决方案。
既然我们提到了 Knative 作为开源 CaaS 解决方案,接下来我们将更详细地讨论它。
基于 Knative 的开源 CaaS
正如我们所见,市场上已有多种供应商特定的 CaaS 服务。然而,大多数服务的问题在于它们绑定于单一的云服务提供商。我们的容器部署规范因此会变得供应商特定,最终导致供应商锁定。作为现代的 DevOps 工程师,我们必须确保所提议的解决方案最适合架构需求,而避免供应商锁定是最重要的要求之一。
然而,Kubernetes 本身并不是无服务器的。你必须定义基础设施,并且长期运行的服务在特定时间应该至少有一个实例运行。这使得管理微服务应用变得繁琐且资源密集。
等等!我们说过微服务有助于优化基础设施的使用。没错——这是正确的,但它们是在容器空间内实现这一点的。试想一下,你有一个共享的虚拟机集群,应用的不同部分会随流量进行扩展,而每个部分的应用会有不同的高峰和低谷。通过这种简单的多租户方式,可以节省大量基础设施资源。
然而,这也意味着每次都必须运行每个微服务的至少一个实例——即使没有任何流量!嗯,这并不是我们所期望的最佳利用方式。那怎样做才更好呢?如何在第一次请求时创建实例,而在没有流量时不创建实例呢?这样可以节省大量资源,特别是在空闲时。你可以拥有成百上千个微服务组成应用,而在空闲期间,这些微服务是没有实例的。如果将其与管理的 Kubernetes 服务结合,并通过流量自动扩展虚拟机实例,你就可以在空闲期间保持最少的实例。
在开源和云原生领域,曾有人尝试开发一种开源、供应商无关、无服务器框架用于容器。我们有 Knative,它是 云原生计算基金会 (CNCF) 采用的解决方案。
提示
Cloud Run 服务在后台使用 Knative。所以,如果你使用 Google Cloud,你可以使用 Cloud Run 来使用完全托管的无服务器服务。
要了解 Knative 是如何工作的,我们先来看看 Knative 的架构。
Knative 架构
Knative 项目结合了现有 CNCF 项目的元素,如 Kubernetes 和 kubectl 命令行。Knative 为开发者提供了其 API,开发者可以通过 kn 命令行工具使用它。用户通过 Istio 访问这些 API,而 Istio 的流量管理功能是 Knative 的一个关键组件。以下图表描述了这一过程:

图 7.2 – Knative 架构
Knative 由两个主要模块组成——serving 和 eventing。serving 模块帮助我们使用 HTTP/S 端点维护无状态应用程序,而 eventing 模块与事件引擎(如 Kafka 和 Google Pub/Sub)集成。由于我们主要讨论的是 HTTP/S 流量,本书将重点讨论 Knative 的 serving 模块。
Knative 维护服务 Pod,这些 Pod 帮助在工作负载 Pod 内路由流量,并使用 Istio Ingress Gateway 组件作为代理。它为你的服务提供一个虚拟端点并监听该端点。当它发现该端点有请求时,它会创建所需的 Kubernetes 组件来处理该流量。因此,Knative 具备从零工作负载 Pod 扩展的功能,因为当接收到流量时,它会启动一个 Pod。以下图示说明了这一过程:

图 7.3 – Knative 服务架构
Knative 端点由三部分组成——<app-name>、<namespace> 和 <custom-domain>。其中 name 和 namespace 类似于 Kubernetes 服务,而 custom-domain 是我们定义的。它可以是你所在组织的合法域名,也可以是 MagicDNS 解决方案,如 sslip.io,我们将在实践中使用。如果你使用的是自己组织的域名,你必须创建 DNS 配置,将该域名解析到 Istio Ingress Gateway 的 IP 地址。
现在,让我们继续安装 Knative。
对于练习,我们将使用 GKE。由于 GKE 是一个高度可靠的 Kubernetes 集群,因此它非常适合与 Knative 集成。如前所述,Google Cloud 提供 90 天 $300 的免费试用。如果你还没有注册,可以在 cloud.google.com/free 上注册。
启动 GKE
一旦你注册并进入控制台,你可以打开 Google Cloud Shell CLI 来运行以下命令。
你需要先使用以下命令启用 GKE API:
$ gcloud services enable container.googleapis.com
要创建一个从 1 个节点扩展到 5 个节点的自动扩展 GKE 集群,请运行以下命令:
$ gcloud container clusters create cluster-1 --num-nodes 2 \
--enable-autoscaling --min-nodes 1 --max-nodes 5 --zone us-central1-a
就这样!集群已启动并运行。
你还需要克隆以下 GitHub 仓库以进行一些练习:
github.com/PacktPublishing/Modern-DevOps-Practices-2e
运行以下命令将仓库克隆到你的主目录中。然后,cd 到 ch7 目录以访问所需资源:
$ git clone https://github.com/PacktPublishing/Modern-DevOps-Practices-2e.git \
modern-devops
既然集群已经启动并运行,让我们继续安装 Knative。
安装 Knative
我们将安装定义 Knative 资源作为 Kubernetes API 资源的 CRD。
要访问本节的资源,cd 到以下目录:
$ cd ~/modern-devops/ch7/knative/
运行以下命令安装 CRD:
$ kubectl apply -f \
https://github.com/knative/serving/releases/download/knative-v1.10.2/serving-crds.yaml
如我们所见,Kubernetes 已安装了一些 CRD。接下来,我们必须安装 Knative serving 模块的核心组件。使用以下命令来执行此操作:
$ kubectl apply -f \
https://github.com/knative/serving/releases/download/knative-v1.10.2/serving-core.yaml
既然核心服务组件已经安装,下一步是在 Kubernetes 集群中安装 Istio。为此,请运行以下命令:
$ curl -L https://istio.io/downloadIstio | sh -
$ sudo mv istio-*/bin/istioctl /usr/local/bin
$ istioctl install --set profile=demo -y
既然 Istio 已经安装,我们将等待 Istio Ingress Gateway 组件分配外部 IP 地址。运行以下命令检查,直到响应中返回外部 IP:
$ kubectl -n istio-system get service istio-ingressgateway
NAME TYPE EXTERNAL-IP PORT(S)
istio-ingressgteway LoadBalancer 35.226.198.46 15021,80,443
如我们所见,已分配给我们一个外部 IP——35.226.198.46。我们将在接下来的操作中使用此 IP。
现在,我们将使用以下命令安装 Knative Istio 控制器:
$ kubectl apply -f \
https://github.com/knative/net-istio/releases/download/knative-v1.10.1/net-istio.yaml
既然控制器已经安装,我们必须配置 DNS,以便 Knative 可以提供自定义端点。为此,我们可以使用 MagicDNS 解决方案,称为 sslip.io,你可以在实验中使用它。MagicDNS 解决方案将任何端点解析到子域中存在的 IP 地址。例如,35.226.198.46.sslip.io 会解析到 35.226.198.46。
注意
在生产环境中不要使用 MagicDNS。它是一个实验性的 DNS 服务,应该仅用于评估 Knative。
运行以下命令配置 DNS:
$ kubectl apply -f \
https://github.com/knative/serving/releases/download/knative-v1.10.2\
/serving-default-domain.yaml
如你所见,它提供了一个批处理作业,当 DNS 请求发生时会触发。
现在,让我们安装 HorizontalPodAutoscaler (HPA) 插件,以便在集群上随着流量自动扩展 pod。为此,请运行以下命令:
$ kubectl apply -f \
https://github.com/knative/serving/releases/download/knative-v1.10.2/serving-hpa.yaml
这完成了 Knative 的安装。
现在,我们需要安装并配置 kn 命令行工具。使用以下命令来执行此操作:
$ sudo curl -Lo /usr/local/bin/kn \
https://github.com/knative/client/releases/download/knative-v1.10.0/kn-linux-amd64
$ sudo chmod +x /usr/local/bin/kn
在下一节中,我们将部署第一个 Knative 应用程序。
在 Knative 上部署 Python Flask 应用程序
为了理解 Knative,让我们尝试构建并部署一个 Flask 应用程序,该应用程序在响应中输出当前的时间戳。让我们从构建应用开始。
构建 Python Flask 应用程序
我们需要创建几个文件来构建这样的应用。
app.py 文件如下所示:
import os
import datetime
from flask import Flask
app = Flask(__name__)
@app.route('/')
def current_time():
ct = datetime.datetime.now()
return 'The current time is : {}!\n'.format(ct)
if __name__ == "__main__":
app.run(debug=True,host='0.0.0.0')
我们将需要以下 Dockerfile 来构建这个应用:
FROM python:3.7-slim
ENV PYTHONUNBUFFERED True
ENV APP_HOME /app
WORKDIR $APP_HOME
COPY . ./
RUN pip install Flask gunicorn
CMD exec gunicorn --bind :$PORT --workers 1 --threads 8 --timeout 0 app:app
现在,让我们使用以下命令来构建 Docker 容器:
$ docker build -t <your_dockerhub_user>/py-time .
既然镜像已经准备好,我们让我们使用以下命令将其推送到 Docker Hub:
$ docker push <your_dockerhub_user>/py-time
既然我们已经成功推送了镜像,就可以在 Knative 上运行它了。
在 Knative 上部署 Python Flask 应用
我们可以使用 kn 命令行或创建一个清单文件来部署应用。使用以下命令来部署应用:
$ kn service create py-time --image <your_dockerhub_user>/py-time
Creating service 'py-time' in namespace 'default':
9.412s Configuration "py-time" is waiting for a Revision to become ready.
9.652s Ingress has not yet been reconciled.
9.847s Ready to serve.
Service 'py-time' created to latest revision 'py-time-00001' is available at URL:
http://py-time.default.35.226.198.46.sslip.io
如我们所见,Knative 已经部署了应用并提供了一个自定义端点。让我们使用 curl 访问该端点看看返回的结果:
$ curl http://py-time.default.35.226.198.46.sslip.io
The current time is : 2023-07-03 13:30:20.804790!
我们在响应中获得了当前时间。正如我们所知道的,Knative 应该会检测到 pod 没有流量并将其删除。让我们观察 pod 一段时间,看看会发生什么:
$ kubectl get pod -w
NAME READY STATUS RESTARTS AGE
py-time-00001-deployment-jqrbk 2/2 Running 0 5s
py-time-00001-deployment-jqrbk 2/2 Terminating 0 64s
如我们所见,在 1 分钟的不活动后,Knative 开始终止 pod。现在,这就是我们所说的从零扩展。
要永久删除服务,我们可以使用以下命令:
$ kn service delete py-time
我们刚才查看了以命令式的方式部署和管理应用程序。那么,如果我们想像之前那样声明配置该怎么办?我们可以创建一个 CRD 清单,使用由 apiVersion 提供的 Service 资源——serving.knative.dev/v1。
我们将创建以下名为 py-time-deploy.yaml 的清单文件:
apiVersion: serving.knative.dev/v1
kind: Service
metadata:
name: py-time
spec:
template:
spec:
containers:
- image: <your_dockerhub_user>/py-time
既然我们已经创建了这个文件,我们将使用 kubectl CLI 来应用它。这使得部署与 Kubernetes 保持一致。
注意
虽然这是一个 service 资源,但不要将其与典型的 Kubernetes Service 资源混淆。它是由 apiVersion serving.knative.dev/v1 提供的自定义资源。这就是为什么 apiVersion 非常重要。
让我们继续运行以下命令来实现:
$ kubectl apply -f py-time-deploy.yaml
service.serving.knative.dev/py-time created
到此,服务已经创建完成。要获取服务的端点,我们需要通过 kubectl 查询 ksvc 资源。运行以下命令来实现:
$ kubectl get ksvc py-time
NAME URL
py-time http://py-time.default.35.226.198.46.sslip.io
URL 是我们必须访问的自定义端点。让我们使用以下命令 curl 自定义端点:
$ curl http://py-time.default.35.226.198.46.sslip.io
The current time is : 2023-07-03 13:30:23.345223!
这次我们也得到了相同的响应!所以,如果你想继续使用 kubectl 来管理 Knative,你完全可以这么做。
Knative 根据接收到的负载帮助扩展应用——自动水平扩展。让我们对应用进行负载测试,以观察它的实际应用。
在 Knative 上进行应用负载测试
我们将使用 hey 工具进行负载测试。由于您的应用程序已经部署好,运行以下命令来进行负载测试:
$ hey -z 30s -c 500 http://py-time.default.35.226.198.46.sslip.io
执行完命令后,运行以下命令来查看当前运行的 py-time pod 实例:
$ kubectl get pod
NAME READY STATUS RESTARTS AGE
py-time-00001-deployment-52vjv 2/2 Running 0 44s
py-time-00001-deployment-bhhvm 2/2 Running 0 44s
py-time-00001-deployment-h6qr5 2/2 Running 0 42s
py-time-00001-deployment-h92jp 2/2 Running 0 40s
py-time-00001-deployment-p27gl 2/2 Running 0 88s
py-time-00001-deployment-tdwrh 2/2 Running 0 38s
py-time-00001-deployment-zsgcg 2/2 Running 0 42s
如我们所见,Knative 已经创建了七个 py-time pod 实例。这就是水平自动扩展的实际应用。
现在,让我们通过运行以下命令来查看集群节点:
$ kubectl get nodes
NAME STATUS AGE
gke-cluster-1-default-pool-353b3ed4-js71 Ready 3m17s
gke-cluster-1-default-pool-353b3ed4-mx83 Ready 106m
gke-cluster-1-default-pool-353b3ed4-vf7q Ready 106m
如我们所见,GKE 已经在节点池中创建了另一个节点,因为它接收到了额外的流量激增。这非常了不起,因为我们拥有 Kubernetes API 可以实现我们想要的功能。我们已经自动水平扩展了我们的 pod。我们还自动水平扩展了集群的工作节点。这意味着我们有了一个完全自动化的解决方案,可以运行容器,而不必担心管理的细节!这就是开源无服务器架构的实际应用!
总结
本章节介绍了 CaaS 和无服务器 CaaS 服务。这些服务帮助我们轻松管理容器应用,无需担心底层基础设施和它们的管理。我们以亚马逊的 ECS 为例,进行了深入讲解。接着,我们简要讨论了市场上其他可用的解决方案。
最后,我们介绍了 Knative,这是一个开源无服务器解决方案,适用于运行在 Kubernetes 之上的容器,并使用了许多其他开源 CNCF 项目。
在下一章节中,我们将深入探讨使用 Terraform 的基础设施即代码(IaC)。
问题
-
ECS 允许我们部署到以下哪些环境?(选择两个)
A. EC2
B. AWS Lambda
C. Fargate
D. 亚马逊 Lightsail
-
ECS 背后使用了 Kubernetes。(对/错)
-
我们应该始终使用 ECS 中的服务而不是任务来处理批处理作业。(对/错)
-
我们应该始终使用 Fargate 来处理批处理作业,因为它运行时间短,而且我们只需为在此期间消耗的资源付费。(对/错)
-
以下哪些是实现 Kubernetes API 的 CaaS 服务?(选择三个)
A. GKE
B. AKS
C. EKS
D. ECS
-
Google Cloud Run 是一项无服务器服务,背后使用了 Knative。(对/错)
-
以下哪一项是作为 Knative 模块提供的?(选择两个)
A. 服务管理
B. 事件管理
C. 计算
D. 容器
答案
-
A, C
-
错
-
错
-
对
-
A, B, C
-
对
-
A, B
第三部分:管理配置与基础设施
本部分深入探讨了公共云中的基础设施与配置管理,探索了各种能够实现基础设施自动化、配置管理和不可变基础设施的工具。
本部分包含以下章节:
-
第八章,使用 Terraform 实现基础设施即代码(IaC)
-
第九章,使用 Ansible 实现配置管理
-
第十章,使用 Packer 实现不可变基础设施
第八章:使用 Terraform 实现基础设施即代码(IaC)
云计算是当今推动 DevOps 实现的主要因素之一。关于云计算的初步担忧已经成为过去。随着一支安全和合规专家 24 小时驻守在云平台,组织现在比以往任何时候都更加信任公共云。与云计算一起,另一个热词也席卷了整个行业——基础设施即代码(IaC)。本章将重点讲解使用 Terraform 实现 IaC,到了本章结束时,你将理解这一概念,并拥有足够的 Terraform 实践经验,帮助你开始自己的旅程。
在本章中,我们将涵盖以下主要主题:
-
IaC 简介
-
设置 Terraform 和 Azure 提供者
-
理解 Terraform 工作流并使用 Terraform 创建你的第一个资源
-
Terraform 模块
-
Terraform 状态和后端
-
Terraform 工作空间
-
Terraform 输出、状态、控制台和图表
技术要求
本章中,你可以使用任何机器来运行 Terraform。Terraform 支持多个平台,包括 Windows、Linux 和 macOS。
你将需要一个有效的 Azure 订阅来进行练习。目前,Azure 提供为期 30 天的免费试用,并赠送价值 $200 的免费积分;你可以在 azure.microsoft.com/en-in/free 注册。
你还需要克隆以下 GitHub 仓库来进行一些练习:
github.com/PacktPublishing/Modern-DevOps-Practices-2e
运行以下命令将仓库克隆到你的主目录,并 cd 进入 ch8 目录以访问所需资源:
$ git clone https://github.com/PacktPublishing/Modern-DevOps-Practices-2e.git \
modern-devops
$ cd modern-devops/ch8
那么,让我们开始吧!
IaC 简介
基础设施即代码(IaC)是使用代码来定义基础设施的概念。虽然大多数人能将基础设施想象成某种有形的东西,但虚拟基础设施已经是司空见惯的存在,并且已经存在了大约二十年。云服务提供商提供了一个基于 Web 的控制台,通过它,你可以直观地管理你的基础设施。然而,这个过程不可重复且没有记录。
如果你在一个环境中使用控制台启动一组基础设施组件,并希望在另一个环境中复制它,那就相当于重复劳动。为了解决这个问题,云平台提供了 API 来操作云中的资源,并提供一些命令行工具帮助触发这些 API。你可以开始编写脚本,使用命令来创建基础设施,并将其参数化以便在另一个环境中使用同样的脚本。嗯,这样就解决问题了,对吧?
并不是完全如此!编写脚本是管理基础设施的一种命令式方式。尽管你仍然可以称之为 IaC,但它的问题在于没有有效地管理基础设施变更。让我举几个例子:
-
如果你需要修改脚本中已经存在的内容,怎么办?在脚本的中间某个地方进行更改,并重新运行整个脚本,可能会对你的基础设施造成混乱。命令式管理基础设施是不可幂等的。因此,管理更改会成为一个问题。
-
如果有人使用控制台手动更改了脚本管理的基础设施,怎么办?你的脚本能正确检测到吗?如果你想用脚本改变同样的内容,会发生什么?这很快就会变得杂乱无章。
-
随着混合云架构的出现,大多数组织都使用多个云平台来满足他们的需求。当你处于这种情况时,使用命令式脚本管理多个云很快就会变得成问题。不同的云平台有不同的与 API 交互的方式,并且有各自独特的命令行工具。
解决所有这些问题的方案是像 Terraform 这样的声明性 IaC 解决方案。HashiCorp 的 Terraform 是市场上最流行的 IaC 工具。它帮助你使用代码自动化和管理基础设施,并能在各种平台上运行。由于它是声明式的,你只需定义你需要的内容(期望的最终状态),而不需要描述如何实现它。它具有以下特性:
-
它通过提供程序支持多个云平台,并公开一个基于HashiCorp 配置语言(HCL)的单一声明性接口与其交互。因此,它允许你使用相似的语言和语法管理各种云平台。所以,团队中如果有几位 Terraform 专家,就能处理你所有的 IaC 需求。
-
它通过状态文件跟踪其管理的资源的状态,并支持本地和远程后端存储和管理这些状态文件。这有助于使 Terraform 配置具有幂等性。因此,如果有人手动更改了 Terraform 管理的资源,Terraform 可以在下次运行时检测到差异,并提示进行纠正操作,以将其恢复到定义的配置。管理员可以在应用更改之前吸收此更改或解决任何冲突。
-
它在基础设施管理中实现了 GitOps。通过 Terraform,你可以将基础设施配置与应用程序代码放在一起,使版本控制、管理和发布基础设施的方式与管理代码相同。你还可以通过拉取请求包含代码扫描和门控,以便在应用更高环境之前,有人可以审查并批准对更高环境的更改。确实是一项强大的功能!
Terraform 提供了多个版本——开源、云和企业版。开源版是一个简单的基于命令行界面(CLI)的工具,你可以在任何支持的操作系统(OS)上下载并使用。云和企业版更多的是对开源版的包装。它们提供基于 Web 的 GUI 和高级功能,如代码即策略(Sentinel)、成本分析、私有模块、GitOps和CI/CD 流水线。
本章将讨论开源提供和其核心功能。
Terraform 开源分为两个主要部分——Terraform Core 和 Terraform 提供者,如下图所示:

图 8.1 – Terraform 架构
让我们来看看这两个组件的功能:
-
Terraform Core 是我们用来与 Terraform 交互的 CLI。它有两个主要输入——你的 Terraform 配置文件和现有状态。然后,它会计算配置差异并应用它。
-
Terraform 提供者 是 Terraform 用来与云提供商交互的插件。提供者将 Terraform 配置转换为相应云的 REST API 调用,以便 Terraform 管理相关的基础设施。例如,如果你希望 Terraform 管理 AWS 基础设施,则必须使用 Terraform AWS 提供者。
现在让我们看看如何安装开源 Terraform。
安装 Terraform
安装 Terraform 非常简单;只需访问www.terraform.io/downloads.html,并根据你的平台按照说明进行操作。大部分内容将要求你下载一个二进制文件并将其移到系统路径中。
由于我们在本书中一直使用 Ubuntu,因此我将展示如何在 Ubuntu 上安装 Terraform。使用以下命令,借助 apt 包管理器来安装 Terraform:
$ wget -O- https://apt.releases.hashicorp.com/gpg | \
sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
$ echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] \
https://apt.releases.hashicorp.com $(lsb_release -cs) main" | \
sudo tee /etc/apt/sources.list.d/hashicorp.list
$ sudo apt update && sudo apt install terraform
使用以下命令检查 Terraform 是否已成功安装:
$ terraform version
Terraform v1.5.2
它显示 Terraform 已成功安装。Terraform 使用 Terraform 提供者与云提供商进行交互,接下来我们将在下一节中讨论这些内容。
Terraform 提供者
Terraform 采用去中心化架构。Terraform CLI 包含 Terraform 的核心功能,并提供与特定云提供商无关的所有功能,而 Terraform 提供者则为 Terraform CLI 和云提供商之间提供接口。这种去中心化的方式使得公共云供应商能够提供他们自己的 Terraform 提供者,从而使他们的客户可以使用 Terraform 来管理云中的基础设施。由于 Terraform 的流行,现在每个公共云提供商都必须提供 Terraform 提供者。
本章我们将与 Azure 进行交互,并使用 Azure Terraform 提供者来进行操作。
要访问本节的资源,请在终端中 cd 到以下目录:
$ cd ~/modern-devops/ch8/terraform-exercise/
在继续配置提供者之前,我们需要了解 Terraform 如何与 Azure API 进行身份验证和授权。
与 Azure 的身份验证和授权
与 Azure 进行身份验证和授权的最简单方法是使用 Azure CLI 登录到你的帐户。当你在 Terraform 文件中使用 Azure 提供程序时,它将自动充当你的帐户并执行所需的操作。现在,这听起来有点危险。管理员通常有很多访问权限,而有一个作为管理员的工具可能不是一个好主意。如果你想将 Terraform 插入到 CI/CD 流水线中怎么办?其实,还有另一种方法——通过使用 Azure 服务主体。Azure 服务主体允许你在不使用指定用户帐户的情况下访问所需功能。你可以对服务主体应用 最小权限原则,只提供必要的访问权限。
在配置服务主体之前,让我们在机器上安装 Azure CLI。为此,请运行以下命令:
$ curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash
上述命令将下载一个 shell 脚本,并使用 bash 执行它。脚本将自动下载并配置 Azure CLI。要确认 Azure CLI 是否安装成功,请运行以下命令:
$ az --version
azure-cli 2.49.0
我们已经看到 Azure CLI 在系统上正确安装。现在,让我们继续配置服务主体。
要配置 Azure 服务主体,请按照以下步骤操作。
使用以下命令登录 Azure,并按照命令提示的步骤操作。你必须浏览到指定的 URL 并输入给定的代码。登录后,你将获得一个包含一些详细信息的 JSON 响应,内容可能如下所示:
$ az login
To sign in, use a web browser to open the page https://microsoft.com/devicelogin and enter
the code XXXXXXXXX to authenticate:
[
{
"id": "00000000-0000-0000-0000-0000000000000",
...
}
]
记下id属性,它是订阅 ID,如果你有多个订阅,可以使用以下命令将其设置为正确的订阅:
$ export SUBSCRIPTION_ID="<SUBSCRIPTION_ID>"
$ az account set --subscription="$SUBSCRIPTION_ID"
使用以下命令创建一个contributor角色,允许 Terraform 管理订阅的基础设施。
提示
在授予服务主体访问权限时,遵循最小权限原则。不要因为未来可能需要某些权限就提前授予它们。如果未来需要任何访问权限,你可以稍后再授予。
我们使用 contributor 权限是为了简化,但也可以使用更细粒度的权限,这种权限应该被使用:
$ az ad sp create-for-rbac --role="Contributor" \
--scopes="/subscriptions/$SUBSCRIPTION_ID"
Creating 'Contributor' role assignment under scope '/subscriptions/<SUBSCRIPTION_ID>'
The output includes credentials that you must protect. Ensure you do not include these
credentials in your code or check the credentials into your source control (for more
information, see https://aka.ms/azadsp-cli):
{
"appId": "00000000-0000-0000-0000-0000000000000",
"displayName": "azure-cli-2023-07-02-09-13-40",
"password": "00000000000.xx-00000000000000000",
"tenant": "00000000-0000-0000-0000-0000000000000"
}
我们已经成功创建了appId、password和tenant。我们将需要这些信息来配置 Terraform 使用服务主体。在下一节中,我们将根据这些详细信息定义 Azure Terraform 提供程序。
使用 Azure Terraform 提供程序
在定义 Azure Terraform 提供程序之前,让我们了解什么构成了 Terraform 根模块。Terraform 根模块仅是你文件系统中的一个工作目录,包含一个或多个 .tf 文件,这些文件帮助你定义配置,并且通常是在这里运行 Terraform 命令。
Terraform 会扫描所有 .tf 文件,将它们合并,并在内部作为一个整体处理。因此,你可以根据需要将一个或多个 .tf 文件拆分开来。虽然没有为 .tf 文件命名的标准,但大多数约定使用 main.tf 作为主 Terraform 文件,其中定义资源,使用 vars.tf 文件来定义变量,使用 outputs.tf 文件来定义输出。
在本讨论中,让我们在工作目录中创建一个 main.tf 文件,并添加如下的 provider 配置:
terraform {
required_providers {
azurerm = {
source = "azurerm"
version = "=3.55.0"
}
}
}
provider "azurerm" {
subscription_id = var.subscription_id
client_id = var.client_id
client_secret = var.client_secret
tenant_id = var.tenant_id
features {}
}
上面的文件包含两个块。terraform 块包含 required_providers 块,声明了 azurerm 提供程序的 version 约束。provider 块声明了一个 azurerm 提供程序,它需要四个参数。
提示
始终约束提供程序版本,因为提供程序的发布没有通知,如果你不包含版本号,在你的机器上可用的功能可能在其他人的机器或 CI/CD 流水线中无法正常工作。使用版本约束可以避免破坏性变更,并保持对版本的控制。
你可能已经注意到,我们在前面的文件中声明了几个变量,而不是直接输入值。这背后有两个主要原因——我们希望使模板尽可能通用,以促进复用。所以,假设我们希望在另一个订阅中应用相似的配置,或者使用另一个服务主体,我们应该能够通过修改变量值来改变它。其次,我们不希望将 client_id 和 client_secret 存储在源代码管理中。这是一个不好的做法,因为我们将服务主体暴露给了不需要知道的人。
提示
永远不要将敏感数据存储在源代码控制中。相反,使用 tfvars 文件来管理敏感信息,并将其存储在像 HashiCorp Vault 这样的秘密管理系统中。
好的,既然我们已经定义了提供程序资源,并且属性值来自变量,接下来的步骤是声明变量。现在让我们看看如何操作。
Terraform 变量
要声明变量,我们需要创建一个 vars.tf 文件,并包含以下数据:
variable "subscription_id" {
type = string
description = "The azure subscription id"
}
variable "app_id" {
type = string
description = "The azure service principal appId"
}
variable "password" {
type = string
description = "The azure service principal password"
sensitive = true
}
variable "tenant" {
type = string
description = "The azure tenant id"
}
所以,我们在这里使用 variable 块定义了四个变量。变量块通常包含 type 和 description。type 属性定义了我们声明的变量的数据类型,默认为 string 数据类型。它可以是诸如 string、number、bool 这样的基本数据类型,或者是诸如 list、set、map、object 或 tuple 这样的复杂数据结构。稍后在练习中我们会详细讨论这些类型。description 属性提供了有关变量的更多信息,用户可以参考它以便更好地理解。
提示
始终从一开始就设置 description 属性,因为它对用户友好并促进模板的复用。
client_secret 变量还包含一个名为 sensitive 的第三属性,一个设置为 true 的布尔属性。当 sensitive 属性为 true 时,Terraform CLI 不会在屏幕输出中显示它。对于像密码和机密这样的敏感变量,强烈建议使用此属性。
提示
始终将敏感变量声明为 sensitive。这是因为如果在您的 CI/CD 管道中使用 Terraform,非特权用户可能通过查看日志访问敏感信息。
除其他三者外,名为 default 的属性将帮助您指定变量的默认值。默认值可帮助您为变量提供最佳可能的值,用户如有需要可以覆盖它们。
提示
在可能的情况下始终使用默认值,因为它们允许您向用户提供关于企业标准的软指导,并节省他们的时间。
接下来的步骤将是提供变量值。让我们来看看这一点。
提供变量值
在 Terraform 中提供变量值有几种方法:
-
-var标志及variable_name=variable_value字符串以提供这些值。 -
.tfvars(如果您喜欢 HCL)或.tfvars.json(如果您喜欢 JSON),通过命令行使用-var-file标志。 -
在 Terraform 工作区中使用
terraform.tfvars或以.auto.tfvars扩展名结束。Terraform 将自动扫描这些文件并从中获取值。 -
使用
TF_VAR_<var-name>结构包含变量值。 -
默认:当您在任何其他方式中不为变量提供值时运行 Terraform 计划时,Terraform CLI 将提示输入这些值,并且您必须手动输入它们。
如果使用多种方法提供同一变量的值,则在前述列表中的第一种方法对特定变量具有最高优先级。它会覆盖后面列出的任何定义。
我们将使用 terraform.tfvars 文件进行此活动,并为变量提供值。
将以下数据添加到 terraform.tfvars 文件中:
subscription_id = "<SUBSCRIPTION_ID>"
app_id = "<SERVICE_PRINCIPAL_APP_ID>"
password = "<SERVICE_PRINCIPAL_PASSWORD>"
tenant = "<TENANT_ID>"
如果要将 Terraform 配置提交到源代码控制中,请将该文件添加到忽略列表以避免意外提交。
如果使用 Git,请将以下内容添加到 .gitignore 文件中即可:
*.tfvars
.terraform*
现在,让我们继续查看 Terraform 工作流程,以进一步进行。
Terraform 工作流程
Terraform 工作流通常包括以下步骤:
-
init:在您的构建过程中多次初始化 Terraforminit命令,因为它不会更改您的工作区或状态。 -
plan:此命令会在请求的资源上运行一个预估计划。此命令通常与云提供商连接,然后检查 Terraform 管理的对象是否存在于云提供商中,以及它们是否与 Terraform 模板中定义的配置一致。然后,它会显示计划输出中的差异,管理员可以审查并在不满意时更改配置。如果满意,他们可以应用计划,将更改提交到云平台。plan命令不会对当前基础设施进行任何更改。 -
apply:此命令将增量配置应用到云平台。当你单独使用apply时,它会首先运行plan命令并要求确认。如果提供计划,它会直接应用该计划。你也可以在不运行计划的情况下使用apply,通过使用-auto-approve标志来自动批准。 -
destroy:destroy命令销毁 Terraform 管理的整个基础设施。因此,它并不是一个非常受欢迎的命令,在生产环境中很少使用。不过,这并不意味着destroy命令没有用处。假设你正在为临时目的搭建开发环境,之后不再需要它,那么通过此命令销毁你创建的所有内容只需要几分钟。
要访问本节的资源,cd进入以下目录:
$ cd ~/modern-devops/ch8/terraform-exercise
现在,让我们通过实际操作来详细了解这些内容。
terraform init
要初始化 Terraform 工作区,请运行以下命令:
$ terraform init
Initializing the backend...
Initializing provider plugins...
- Finding hashicorp/azurerm versions matching "3.63.0"...
- Installing hashicorp/azurerm v3.63.0...
- Installed hashicorp/azurerm v3.63.0 (signed by HashiCorp)
Terraform has created a lock file, .terraform.lock.hcl, to record the provider selections
it made previously. Include this file in your version control repository so that Terraform
can guarantee to make the same selections by default when you run terraform init in the
future.
Terraform has been successfully initialized!
由于 Terraform 工作区已被初始化,我们可以创建一个Azure 资源组来开始与云进行交互。
创建第一个资源 – Azure 资源组
我们必须在main.tf文件中使用azurerm_resource_group资源来创建 Azure 资源组。请将以下内容添加到main.tf文件中:
resource "azurerm_resource_group" "rg" {
name = var.rg_name
location = var.rg_location
}
由于我们使用了两个变量,因此我们需要声明它们,请将以下内容添加到vars.tf文件中:
variable "rg_name" {
type = string
description = "The resource group name"
}
variable "rg_location" {
type = string
description = "The resource group location"
}
接下来,我们需要将资源组名称和位置添加到terraform.tfvars文件中。因此,请将以下内容添加到terraform.tfvars文件中:
rg_name=terraform-exercise
rg_location="West Europe"
现在,我们已经准备好执行计划了,但在此之前,让我们使用terraform fmt将我们的文件格式化为标准格式。
terraform fmt
terraform fmt命令将.tf文件格式化为标准格式。使用以下命令格式化你的文件:
$ terraform fmt
terraform.tfvars
vars.tf
该命令列出了它已格式化的文件。下一步是验证你的配置。
terraform validate
terraform validate命令验证当前配置,并检查是否有任何语法错误。要验证配置,请运行以下命令:
$ terraform validate
Success! The configuration is valid.
成功输出表示我们的配置有效。如果有任何错误,它会在验证输出中突出显示。
提示
在每次执行 Terraform 计划之前,请始终运行fmt和validate。这可以节省大量规划时间,并帮助你保持配置的良好状态。
由于配置有效,我们可以准备运行一个计划。
terraform plan
要运行 Terraform 计划,请使用以下命令:
$ terraform plan
Terraform used the selected providers to generate the following execution plan. Resource
actions are indicated with the following symbols: + create
Terraform will perform the following actions:
# azurerm_resource_group.rg will be created
+ resource "azurerm_resource_group" "rg" {
+ id = (known after apply)
+ location = "westeurope"
+ name = "terraform-exercise"
}
Plan: 1 to add, 0 to change, 0 to destroy.
Note: You didn't use the -out option to save this plan, so Terraform can't guarantee to
take exactly these actions if you run terraform apply now.
plan输出告诉我们,如果我们立即运行terraform apply,它将创建一个名为terraform_exercise的单一资源组。它还输出了一条提示,说明由于我们没有保存此计划,后续的应用操作无法保证执行相同的操作。同时,事情可能已发生变化;因此,Terraform 将在应用时重新运行plan并提示我们输入yes。因此,如果不想遇到意外,应该将计划保存到文件中。
提示
始终将terraform plan输出保存到文件,并使用该文件来应用更改。这样可以避免最后一分钟的意外,避免背景中可能发生的变化导致apply没有按预期执行,尤其是在您的计划作为流程的一部分进行审查时。
所以,我们先使用以下命令将计划保存到文件中:
$ terraform plan -out rg_terraform_exercise.tfplan
这次,计划被保存为名为rg_terraform_exercise.tfplan的文件。我们可以使用这个文件来随后应用更改。
terraform apply
要使用plan文件应用更改,请运行以下命令:
$ terraform apply "rg_terraform_exercise.tfplan"
azurerm_resource_group.rg: Creating...
azurerm_resource_group.rg: Creation complete after 2s [id=/subscriptions/id/
resourceGroups/terraform-exercise]
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
就是这样!Terraform 已应用配置。让我们使用 Azure CLI 验证资源组是否已创建。
运行以下命令列出您订阅中的所有资源组:
$ az group list
...
"id": "/subscriptions/id/resourceGroups/terraform-exercise",
"location": "westeurope",
"name": "terraform-exercise",
...
我们看到资源组已创建并出现在列表中。
有时apply可能部分成功。在这种情况下,Terraform 会自动标记它认为未成功创建的资源。这些资源将在下一次运行时自动重新创建。如果您希望手动标记某个资源以供重新创建,可以使用terraform的taint命令:
$ terraform taint <resource>
假设我们希望销毁资源组,因为我们不再需要它。我们可以使用terraform destroy来完成。
terraform destroy
要销毁资源组,我们可以先运行一个推测性的计划。始终建议运行推测性计划,以确认我们需要销毁的内容是否包含在输出中,以免稍后出现意外。Terraform 就像 Linux 一样,没有撤销按钮。
要运行一个推测性的销毁计划,请使用以下命令:
$ terraform plan -destroy
Terraform used the selected providers to generate the following execution plan. Resource
actions are indicated with the following symbols:
- destroy
Terraform will perform the following actions:
# azurerm_resource_group.rg will be destroyed
- resource "azurerm_resource_group" "rg" {
- id = "/subscriptions/id/resourceGroups/terraform-exercise" -> null
- location = "westeurope" -> null
- name = "terraform-exercise" -> null
- tags = {} -> null
}
Plan: 0 to add, 0 to change, 1 to destroy.
如我们所见,由于资源组是 Terraform 管理的唯一资源,它已列出该资源作为将要销毁的资源。销毁资源有两种方式:使用terraform destroy单独运行,或使用out参数保存推测性计划后,运行terraform apply来应用销毁计划。
目前我们先使用第一种方法。
运行以下命令销毁所有由 Terraform 管理的资源:
$ terraform destroy
Terraform will perform the following actions:
# azurerm_resource_group.rg will be destroyed
Do you really want to destroy all resources?
Terraform will destroy all your managed infrastructure, as shown above. There is no undo.
Only yes will be accepted to confirm.
Enter a value:
现在,这次,Terraform 重新运行plan并提示输入一个值。它只会接受yes,然后按Enter键确认:
Enter a value: yes
azurerm_resource_group.rg: Destroying... [id=/subscriptions/id/resourceGroups/terraform-
exercise]
azurerm_resource_group.rg: Still destroying... [id=/subscriptions/id/resourceGroups/
terraform-exercise, 10s elapsed]
azurerm_resource_group.rg: Destruction complete after 1m20s
现在它已经销毁了资源组。
我们已经看过一个基础的根模块,并探讨了 Terraform 的工作流程。基础根模块帮助我们创建和管理资源,但缺少一个非常重要的特性——可重用性。Terraform 为我们提供了模块,允许我们对常见模板进行重用。在下一部分,我们将看看它。
Terraform 模块
Terraform 模块是可重用、可重复的模板。它们允许在基础设施提供过程中进行抽象,这在您的使用场景超出一些概念验证时非常需要。HashiCorp 将模块视为由专家设计,这些专家了解企业标准,并被希望在项目中应用企业标准基础设施的开发人员使用。这样,整个组织的事情都能保持标准化。这为开发人员节省时间,避免重复劳动。模块可以版本控制并通过 模块仓库 或版本控制系统分发。这同时为基础设施管理员提供了丰富的权限和控制。
在上一部分我们创建了资源组,接下来让我们将其模块化。在下一个练习中,为了访问该部分的资源,请执行以下操作:
$ cd ~/modern-devops/ch8/terraform-modules/
在此目录中,我们有以下目录结构:
.
├── main.tf
├── modules
│ └── resource_group
│ ├── main.tf
│ └── vars.tf
├── terraform.tfvars
└── vars.tf
如我们所见,根目录中我们仍然有 main.tf、terraform.tfvars 和 vars.tf 文件。然而,我们增加了一个 modules 目录,其中包含一个 resource_group 子目录,里面有一个 main.tf 文件和一个 vars.tf 文件。让我们来看一下这两个文件。
modules/resource_group/main.tf 文件如下所示:
resource "azurerm_resource_group" "rg" {
name = var.name
location = var.location
}
它只包含一个 azurerm_resource_group 资源,其名称和位置来自以下 modules/resource_group/vars.tf 文件中定义的 name 和 location 变量:
variable "name" {
type = string
description = "The resource group name"
}
variable "location" {
type = string
description = "The resource group location"
}
在根模块中,也就是当前目录,我们已修改 main.tf 文件,使其如下所示:
terraform {
required_providers {
...
}
}
provider "azurerm" {
...
}
module "rg" {
source = "./modules/resource_group"
name = var.rg_name
location = var.rg_location
}
如我们所见,我们没有直接在此文件中定义资源,而是定义了一个名为 rg 的模块,其 source 是 ./modules/resource_group。请注意,我们从根级别定义的变量(即 var.rg_name 和 var.rg_location)传递给模块中定义的变量,即 name 和 location 的值。
现在,让我们继续看看当我们初始化并应用这个配置时会发生什么。
运行以下命令来初始化 Terraform 工作区:
$ terraform init
Initializing the backend...
Initializing modules...
- rg in modules/resource_group
Initializing provider plugins...
...
Terraform has been successfully initialized!
如我们所见,Terraform 在初始化时已检测到新模块并对其进行了初始化。
提示
每当定义新模块时,您必须始终重新初始化 Terraform。
现在,让我们继续运行以下命令来进行计划:
$ terraform plan
Terraform will perform the following actions:
# module.rg.azurerm_resource_group.rg will be created
+ resource "azurerm_resource_group" "rg" {
+ id = (known after apply)
+ location = "westeurope"
+ name = "terraform-exercise"
}
Plan: 1 to add, 0 to change, 0 to destroy.
如我们所见,它将创建资源组。然而,这现在是模块 module.rg.azurerm_resource_group.rg 的一部分。要应用计划,让我们运行以下命令:
$ terraform apply
module.rg.azurerm_resource_group.rg: Creating...
module.rg.azurerm_resource_group.rg: Creation complete after 4s [id=/subscriptions/id/
resourceGroups/terraform-exercise]
资源组已创建!要销毁该资源组,让我们运行以下命令:
$ terraform destroy
通过使用模块,您可以简化基础设施的创建和管理,增强团队之间的协作,并建立一种一致的方法来以可扩展和可维护的方式部署资源。
提示
使用 Terraform 模块封装并重用基础设施配置,促进模块化和可重用性。
直到现在,我们已经看到 Terraform 创建和销毁资源,但 Terraform 如何知道它之前创建了什么以及需要销毁什么呢?嗯,它使用状态文件来解决这个问题。我们来看看。
管理 Terraform 状态
Terraform 使用状态文件来跟踪已部署的内容及其管理的资源。状态文件至关重要,因为它记录了 Terraform 维护的所有基础设施。如果丢失了状态文件,Terraform 将无法追踪已执行的操作,并且会将资源当作新的、需要重新创建的资源。因此,您应该像代码一样保护状态文件。
Terraform 将状态存储在后端。默认情况下,Terraform 将状态文件存储为 terraform.tfstate,并放置在 workspace 目录中,这称为本地后端。然而,这并不是管理状态的最佳方式。您不应将状态存储在本地系统中,原因有几个:
-
如果状态文件存储在某人的本地目录中,多个管理员无法共同操作同一基础设施。
-
本地工作站没有备份;因此,即使只有一个管理员在执行任务,丢失状态文件的风险仍然很高。
您可能会争辩说,我们可以通过将状态文件与 .tf 文件一起检查到源代码管理中来解决这些问题。不要这样做!状态文件是纯文本的,如果您的基础设施配置包含敏感信息,如密码,任何人都可以看到它。因此,您需要安全存储状态文件。此外,将状态文件存储在源代码管理中并不能提供状态锁定功能,如果多人同时修改状态文件,会导致冲突。
提示
永远不要将状态文件存储在源代码管理中。使用 .gitignore 文件条目跳过 terraform.tfstate 文件。
存储 Terraform 状态的最佳位置是远程云存储。Terraform 提供了远程后端来远程存储状态。您可以使用多种类型的远程后端。当编写本书时,plan 和 apply 在后端运行,只有 Terraform Cloud 和 Enterprise 支持这一功能。
提示
在选择状态存储方案时,您应该优先选择支持状态锁定的存储。这将允许多人操作资源而不互相干扰,一旦状态文件被锁定,其他人将无法获取它,直到锁定被释放。
由于我们使用的是 Azure,因此可以使用 Azure 存储来保存状态。其优点有三:
-
您的状态文件是集中管理的。您可以让多个管理员一起工作并管理相同的基础设施。
-
存储在休眠状态下是加密的。
-
你将获得自动备份、冗余和高可用性。
要访问本节的资源,cd到以下目录:
$ cd ~/modern-devops/ch8/terraform-backend/
现在,让我们使用azurerm后端,并使用 Azure 存储来持久化我们的 Terraform 状态。
使用 Azure 存储后端
由于如果我们使用 Terraform 来构建存储其状态的后端,我们将陷入“鸡和蛋”的局面,所以我们必须在不使用 Terraform 的情况下配置这部分内容。
因此,让我们使用az命令在一个 Terraform 不会管理的不同资源组中配置存储帐户。
创建 Azure 存储资源
让我们从定义一些变量开始:
-
$RESOURCE_GROUP=tfstate -
$STORAGE_ACCOUNT_NAME=tfstate$RANDOM -
$CONTAINER_NAME=tfstate
首先,使用以下命令创建资源组:
$ az group create --name $RESOURCE_GROUP --location westeurope
现在,让我们继续使用以下命令在资源组内创建一个存储帐户:
$ az storage account create --resource-group $RESOURCE_GROUP \
--name $STORAGE_ACCOUNT_NAME --sku Standard_LRS \
--encryption-services BLOB
下一步是使用以下命令获取帐户密钥:
$ ACCOUNT_KEY=$(az storage account keys list \
--resource-group tfstate --account-name $STORAGE_ACCOUNT_NAME \
--query '[0].value' -o tsv)
现在,我们可以使用以下命令创建一个 Blob 存储容器:
$ az storage container create --name $CONTAINER_NAME \
--account-name $STORAGE_ACCOUNT_NAME --account-key $ACCOUNT_KEY
如果我们收到created响应,则表示存储帐户已创建并准备就绪。现在,我们可以继续在 Terraform 中定义后端配置文件。
在 Terraform 中创建后端配置
在我们创建后端之前,我们需要STORAGE_ACCOUNT_NAME的值。要获取此值,请运行以下命令:
$ echo $STORAGE_ACCOUNT_NAME
tfstate28099
要在 Terraform 中创建后端配置,创建一个名为backend.tf的文件,并放在工作区内:
terraform {
backend "azurerm" {
resource_group_name = "tfstate"
storage_account_name = "tfstate28099"
container_name = "tfstate"
key = "example.tfstate"
}
}
在后端配置中,我们定义了resource_group_name后端,其中存在 Blob 存储实例 —— storage_account_name,container_name和key。key属性指定我们将用于定义此配置状态的文件名。你可能会使用 Terraform 管理多个项目,它们都需要单独的状态文件。因此,key属性定义了我们将为项目使用的状态文件的名称。这使得多个 Terraform 项目可以使用相同的 Azure Blob 存储来存储状态。
提示
始终使用项目的名称作为key的名称。例如,如果你的项目名称是foo,则将key命名为foo.tfstate。这样可以防止与其他项目发生潜在冲突,并且还可以让你快速找到状态文件。
要使用新的后端配置初始化 Terraform 工作区,请运行以下命令:
$ terraform init
Initializing the backend...
Backend configuration changed!
Terraform has detected that the configuration specified for the backend has changed.
Terraform will now check for existing state in the backends.
Successfully configured the backend azurerm! Terraform will automatically use this backend
unless the backend configuration changes.
当我们初始化时,Terraform 会检测到后端已更改,并检查现有后端中是否有任何内容。如果它找到内容,它会询问是否要将当前状态迁移到新后端。如果没有,它会自动切换到新后端,就像我们在这里看到的那样。
现在,让我们继续使用terraform plan命令来运行计划:
$ terraform plan
Acquiring state lock. This may take a few moments...
Terraform will perform the following actions:
# azurerm_resource_group.rg will be created
+ resource "azurerm_resource_group" "rg" {
...
}
Plan: 1 to add, 0 to change, 0 to destroy.
如我们所见,terraform plan 告诉我们,它将创建一个名为 terraform-exercise 的新资源组。让我们应用这个配置,这次使用auto-approve标志,这样计划就不会再次运行,Terraform 会立即应用更改,使用以下命令:
$ terraform apply -auto-approve
Acquiring state lock. This may take a few moments...
azurerm_resource_group.rg: Creating...
azurerm_resource_group.rg: Creation complete after 2s [id=/subscriptions/id/
resourceGroups/terraform-exercise]
Releasing state lock. This may take a few moments...
我们现在已经成功创建了资源。
现在,让我们去 Azure Blob 存储查看是否有一个 tfstate 文件,如下图所示:

图 8.2 – Terraform 状态
如我们所见,在 Blob 容器中有一个名为example.tfstate的文件。这就是远程存储的工作方式,现在任何有权限访问 Blob 存储实例的人都可以使用 Terraform 配置并进行更改。
到目前为止,我们一直在使用默认工作区管理资源,但如果有多个环境需要使用相同的配置进行控制怎么办?好吧,Terraform 为这些场景提供了工作区。
Terraform 工作区
软件开发需要多个环境。你在工作区内开发软件,将其部署到开发环境,进行单元测试,然后将经过测试的代码提升到测试环境。你的 QA 团队将在测试环境中对代码进行广泛的测试,一旦所有测试用例通过,你就可以将代码推广到生产环境。
这意味着你必须在所有环境中保持类似的基础设施。借助像 Terraform 这样的 IaC 工具,基础设施以代码的形式呈现,我们必须管理我们的代码以适应多个环境。但 Terraform 不仅仅是代码;它还包含状态文件,我们必须为每个环境维护状态文件。
假设你想创建三个资源组,分别是 terraform-exercise-dev、terraform-exercise-test 和 terraform-exercise-prod。每个资源组将包含一组相似的基础设施,具有类似的属性。例如,每个资源组都包括一个 Ubuntu 虚拟 机(VM)。
解决此问题的一种简单方法是创建如下结构:
├── dev
│ ├── backend.tf
│ ├── main.tf
│ ├── terraform.tfvars
│ └── vars.tf
├── prod
│ ├── backend.tf
│ ├── main.tf
│ ├── terraform.tfvars
│ └── vars.tf
└── test
├── backend.tf
├── main.tf
├── terraform.tfvars
└── vars.tf
你能看到重复吗?相同的文件多次出现,所有文件都包含相同的配置。唯一可能改变的是每个环境的 terraform.tfvars 文件。
所以,这听起来并不是一个很好的解决方法,这就是为什么 Terraform 为此提供了工作区。
Terraform 工作区仅仅是独立的状态文件。所以,你有一个配置和多个状态文件,每个环境都有一个。听起来很简单,对吧?让我们看一看。
使用 Terraform 工作区表示相同配置的另一种方式如下:
├── backend.tf
├── main.tf
├── terraform.tfvars
└── vars.tf
现在,这看起来很简单。它只包含一组文件。让我们逐一查看它们,以便更好地理解它们。
要访问本节的资源,请cd到以下位置:
$ cd ~/modern-devops/ch8/terraform-workspaces/
main.tf文件包含一个resource_group资源,其名称包括环境后缀,以及我们需要在该资源组内创建的其他资源,如 VNet、子网和 VM,类似以下内容:
...
resource "azurerm_resource_group" "main" {
name = "${var.rg_prefix}-${terraform.workspace}"
location = var.rg_location
}
resource "azurerm_virtual_network" "main" {
...
}
resource "azurerm_subnet" "internal" {
...
}
resource "azurerm_network_interface" "main" {
...
}
resource "azurerm_virtual_machine" "main" {
...
}
...
要访问工作区的名称,Terraform 提供了terraform.workspace变量,我们已使用该变量定义resource_group名称。所以,模板现在已准备好接受任何环境的配置,并且我们将为每个环境创建一个独立的资源组。
同时,更新backend.tf文件,添加我们在上一节中创建的tfstate容器名称,并使用以下命令初始化 Terraform 工作区:
$ terraform init
现在,一旦 Terraform 初始化完成,让我们使用以下命令创建一个dev工作区:
$ terraform workspace new dev
Created and switched to workspace "dev"!
你现在处于一个新的、空的工作区。工作区会隔离其状态,因此如果你运行terraform plan,Terraform 将不会看到此配置的任何现有状态。
所以,既然我们处于一个名为dev的新空工作区,让我们运行一个计划。
使用以下命令在dev环境中运行计划:
$ terraform plan -out dev.tfplan
Acquiring state lock. This may take a few moments...
Terraform will perform the following actions:
+ resource "azurerm_network_interface" "main" {
...
}
+ resource "azurerm_resource_group" "main" {
+ id = (known after apply)
+ location = "westeurope"
+ name = "terraform-ws-dev"
}
+ resource "azurerm_subnet" "internal" {
...
}
+ resource "azurerm_virtual_machine" "main" {
...
}
+ resource "azurerm_virtual_network" "main" {
...
}
Plan: 5 to add, 0 to change, 0 to destroy.
现在,让我们继续使用以下命令应用该计划:
$ terraform apply "dev.tfplan"
Acquiring state lock. This may take a few moments...
azurerm_resource_group.main: Creating...
azurerm_virtual_network.main: Creating...
azurerm_subnet.internal: Creating...
azurerm_network_interface.main: Creating...
azurerm_virtual_machine.main: Creating...
Apply complete! Resources: 5 added, 0 changed, 0 destroyed.
Releasing state lock. This may take a few moments...
由于dev计划已被应用,资源已创建在dev资源组中,我们来创建一个测试用的工作区:
$ terraform workspace new test
由于新工作区已创建,让我们使用以下命令在测试工作区运行计划,并将其保存到test.tfplan文件中:
$ terraform plan -out test.tfplan
...
+ resource "azurerm_resource_group" "main" {
+ id = (known after apply)
+ location = "westeurope"
+ name = "terraform-ws-test"
}
...
如我们所见,资源将被创建在terraform-ws-test资源组中。所以,让我们继续使用以下命令应用计划:
$ terraform apply test.tfplan
test计划也已经应用。现在让我们继续检查已创建的资源。
提示
Terraform 工作区非常适合为不同的环境(如开发、预生产和生产)维护独立的基础设施配置。这有助于防止意外的配置更改,并确保一致的设置。
检查资源
让我们使用az命令列出资源组。如我们所知,我们的资源组有一个前缀terraform-ws。因此,使用以下命令列出所有包含该前缀的资源组:
$ az group list | grep name | grep terraform-ws
"name": "terraform-ws-dev",
"name": "terraform-ws-test",
如我们所见,我们有两个资源组,terraform-ws-dev和terraform-ws-test。所以,两个资源组已成功创建。
你也可以在 Azure 门户中验证这一点,如下图所示:

图 8.3 – 资源组
现在,让我们使用 Azure 门户,通过点击terraform-ws-dev来检查terraform-ws-dev资源组中的资源:

图 8.4 – Terraform 开发资源组
我们在资源组中有一个虚拟网络、一个网络接口、一个操作系统磁盘和一台虚拟机。我们应该期待在terraform-ws-test资源组中有相同名称的类似资源。让我们来看看:

图 8.5 – Terraform 测试资源组
如我们所见,我们在 terraform-ws-test 资源组中也有类似的资源。
我们通过一个配置完成了所有这些操作,但由于它们是两组资源,每个工作空间应该有两个状态文件。让我们来看一下。
检查状态文件
如果我们使用本地后端来存储状态文件,我们将得到以下结构:
|-- terraform.tfstate.d
|-- dev
| `-- terraform.tfstate
`-- test
`-- terraform.tfstate
所以,Terraform 创建了一个名为 terrafom.tfstate.d 的目录;在这个目录下,它为每个工作空间创建了相应的子目录。在这些子目录中,它将每个工作空间的状态文件保存为 terraform.tfstate。
但由于我们使用的是远程后端,并且使用 Azure Blob 存储来存储它,让我们通过 Azure 控制台检查其中的文件:

图 8.6 – Terraform 工作空间状态
如我们所见,有两个状态文件,每个环境一个。因此,状态文件的后缀分别是 env:dev 或 env:test 字符串。这就是在 Azure Blob 存储中管理工作空间的方式。远程后端用于维护状态文件的结构取决于提供商插件,因此,管理多个后端的状态可能会有不同的方式。但是,Terraform CLI 会以相同的方式解释工作空间,无论后端如何,因此,从 CLI 角度来看,最终用户没有变化。
清理
现在,让我们继续清理这两个资源组,以避免不必要的费用。
由于我们已经在测试工作空间内,让我们运行以下命令销毁测试工作空间中的资源:
$ terraform destroy --auto-approve
现在,让我们使用以下命令切换到 dev 工作空间:
$ terraform workspace select dev
Switched to workspace "dev".
由于我们在 dev 工作空间中,使用以下命令销毁 dev 工作空间中的所有资源:
$ terraform destroy --auto-approve
稍后,我们应该能看到两个资源组已经消失。接下来,让我们在下一节中了解一些 Terraform 的高级概念。
Terraform 输出、状态、控制台和图表
虽然我们知道 Terraform 使用状态文件来管理资源,但让我们来看一些高级命令,帮助我们更好地理解和掌握 Terraform 状态的概念。
要访问本节中的资源,cd 到以下路径:
$ cd ~/modern-devops/ch8/terraform-workspaces/
现在,让我们来看一下我们的第一个命令——terraform output。
terraform 输出
到目前为止,我们已经看过了变量,但还没有讨论输出。Terraform 输出是 Terraform 配置的返回值,允许用户将配置导出给用户或任何可能使用当前模块的模块。
让我们继续上一个例子,在 outputs.tf 文件中添加一个输出变量,用来导出附加到虚拟机的网络接口的私有 IP:
output "vm_ip_addr" {
value = azurerm_network_interface.main.private_ip_address
}
现在,让我们继续应用配置:
$ terraform apply --auto-approve
...
Outputs:
vm_ip_addr = "10.0.2.4"
在 Terraform 应用配置后,它会在控制台结果的末尾显示输出。你可以随时运行以下命令来检查输出:
$ terraform output
vm_ip_addr = "10.0.2.4"
输出像其他内容一样存储在状态文件中,因此让我们来看一下如何使用 CLI 管理 Terraform 状态。
管理 Terraform 状态
Terraform 将其管理的配置存储在状态文件中,因此提供了一个用于高级状态管理的命令。terraform state命令帮助你管理当前配置的状态。尽管状态文件是纯文本格式的,你可以手动修改它,但推荐使用terraform state命令。
但在我们深入讨论细节之前,我们必须了解为什么要这么做。事情可能不会总是按计划进行,因此状态文件可能包含损坏的数据。你可能还想在应用某个资源后查看该资源的特定属性。状态文件可能需要进行调查,以便进行特定基础设施配置问题的根本原因分析。让我们看看最常见的使用场景。
查看当前状态
要查看当前状态,我们可以运行以下命令:
$ terraform show
这将输出所有 Terraform 创建和管理的资源,包括输出内容。当然,对于一些人来说,这可能会显得信息过载,我们可能只想查看 Terraform 管理的资源列表。
列出当前状态中的资源
要列出 Terraform 状态文件中的资源,可以运行以下命令:
$ terraform state list
azurerm_network_interface.main
azurerm_resource_group.main
azurerm_subnet.internal
azurerm_virtual_machine.main
azurerm_virtual_network.main
如我们所见,Terraform 管理着五个资源。你可能希望将某个资源从 Terraform 状态中移除。也许有人手动移除了某个资源,因为它不再需要,但它并未从 Terraform 配置中移除。
从状态中移除资源
要手动从 Terraform 状态文件中移除状态,必须使用terraform state rm <resource>命令。例如,要从 Terraform 状态中移除 Azure 虚拟机资源,可以运行以下命令:
$ terraform state rm azurerm_virtual_machine.main
Acquiring state lock. This may take a few moments...
Removed azurerm_virtual_machine.main
Successfully removed 1 resource instance(s).
Releasing state lock. This may take a few moments...
请记住,这仅仅是从状态文件中移除了该资源,实际在 Azure 上存在的资源并未受到影响。
可能会有某些情况,其中有人在 Azure 中手动创建了虚拟机,而我们现在希望 Terraform 来管理它。这种情况大多发生在棕地项目中。在这种情况下,我们必须在 Terraform 中声明相同的配置,然后将现有资源导入到 Terraform 状态中。为此,我们可以使用terraform import命令。
将现有资源导入 Terraform 状态
你可以使用terraform import命令将现有资源导入到 Terraform 状态中。terraform import命令的结构如下:
terraform import <resource> <resource_id>
例如,要将httpd虚拟机重新导入状态,可以运行以下命令:
$ terraform import azurerm_virtual_machine.main \
"/subscriptions/<SUBSCRIPTION_ID>/resourceGroups\
/terraform-ws-dev/providers/Microsoft.Compute/virtualMachines/httpd"
Acquiring state lock. This may take a few moments...
azurerm_virtual_machine.main: Importing from ID "/subscriptions/id/resourceGroups/
terraform-ws-dev/providers/Microsoft.Compute/virtualMachines/httpd"...
azurerm_virtual_machine.main: Import prepared!
Prepared azurerm_virtual_machine for import
azurerm_virtual_machine.main: Refreshing state... [id=/subscriptions/1de491b5-f572-
459b-a568-c4a35d5ac7a9/resourceGroups/terraform-ws-dev/providers/Microsoft.Compute/
virtualMachines/httpd]
Import successful!
要检查资源是否已导入状态,我们可以再次列出资源,使用以下命令:
$ terraform state list | grep azurerm_virtual_machine
azurerm_virtual_machine.main
如我们所见,VM 已经存在于状态文件中。如果我们想进一步了解资源,可以使用 terraform console。
terraform console
terraform console 命令提供了一个交互式控制台,用于调查状态文件、动态构建路径并在使用资源之前评估表达式。这是一个强大的工具,大多数高级 Terraform 用户都在使用它。例如,我们启动控制台并查看我们刚刚导入的 VM 资源配置。
使用以下命令启动控制台并获取 VM 的资源组和 id 值:
$ terraform console
Acquiring state lock. This may take a few moments...
> azurerm_virtual_machine.main.resource_group_name
"terraform-ws-dev"
> azurerm_virtual_machine.main.id
"/subscriptions/id/resourceGroups/terraform-ws-dev/providers/Microsoft.Compute/
virtualMachines/httpd"
> exit
Releasing state lock. This may take a few moments...
如我们所见,VM 位于正确的资源组中,我们确认导入操作是正确的。
Terraform 依赖关系和图形
Terraform 使用依赖模型来管理资源的创建和销毁顺序。有两种依赖关系——隐式和显式。直到现在,我们一直在使用隐式依赖关系,其中 VM 依赖于网络接口,网络接口依赖于子网,子网依赖于虚拟网络,所有这些资源都依赖于资源组。这些依赖关系通常发生在我们将一个资源的输出作为另一个资源的输入时。
然而,有时我们希望显式定义资源之间的依赖关系,尤其是当没有方法定义隐式依赖时。你可以使用 depends_on 属性来进行这种操作。
提示
除非必要,否则避免使用显式依赖关系,因为 Terraform 使用并行处理来管理资源。如果不需要显式依赖关系,它将加速 Terraform 运行,因为它可以并行处理多个资源。
为了可视化资源之间的依赖关系,我们可以从状态文件导出图形,并使用像Graphviz这样的工具将其转换为 PNG 文件。
运行以下命令导出依赖图:
$ terraform graph > vm.dot
然后我们可以使用 Graphviz 工具处理图形文件。在 Ubuntu 上安装该工具,运行以下命令:
$ sudo apt install graphviz -y
现在运行以下命令将图形文件转换为 PNG 文件:
$ cat vm.dot | dot -T png -o vm.png
图形可以在 github.com/PacktPublishing/Modern-DevOps-Practices-2e/blob/main/ch8/terraform-graph.png 处查看。现在,让我们继续查看如何清理资源。
清理资源
如我们所知,我们运行以下命令来清理资源:
$ terraform destroy --auto-approve
它将清除资源组中的资源,并在此之后删除资源组。
虽然使用 terraform destroy 可以轻松删除不需要的资源,但最好只在 dev 环境中使用它,绝不要在生产环境中使用。相反,你可以从配置中删除不需要的资源,然后运行 terraform apply。
总结
在本章中,我们讨论了 Terraform 的核心内容,并从实践角度理解了一些最常见的命令和功能。我们从理解 IaC 开始,介绍了 Terraform 作为 IaC 工具,安装了 Terraform,了解了 Terraform 提供程序,并使用 Azure Terraform 提供程序管理 Azure 中的基础设施。
接着,我们看了 Terraform 变量以及多种为变量提供值的方式。我们讨论了核心的 Terraform 工作流程以及你将用来管理基础设施的几个命令。然后,我们看了 Terraform 模块,接着看了 Terraform 状态,作为一个重要组成部分,帮助 Terraform 跟踪它所管理的基础设施。
我们了解了本地和远程状态存储,并使用 Azure Blob 存储作为远程状态后端。接着,我们讨论了 Terraform 工作空间,以及它们如何通过实践操作使我们能够使用相同的 Terraform 配置构建多个环境。
接着,我们看了一些关于 Terraform 状态的高级操作,使用了outputs、state和console命令。最后,我们查看了 Terraform 如何管理依赖关系,并使用graph命令查看了依赖图。
在下一章中,我们将深入讨论使用 Ansible 的配置管理。
问题
-
为什么我们应该限制提供程序版本?
-
你应该始终在 Terraform 计划之前使用
fmt和validate函数。(对/错) -
Terraform
plan命令做什么?(选择两个)A. 刷新当前状态与现有基础设施状态
B. 获取当前配置与预期配置之间的差异
C. 将配置应用于云端
D. 销毁云端的配置
-
terraform apply命令做什么?(选择三个)A. 刷新当前状态与现有基础设施
B. 获取当前配置与预期配置之间的差异
C. 将配置应用于云端
D. 销毁云端的配置
-
为什么你绝不应该将状态文件存储在源代码管理中?(选择两个)
A. 状态文件是纯文本的,因此你暴露了敏感信息给没有权限的用户。
B. 源代码管理不支持状态锁定,因此可能会导致用户之间的潜在冲突。
C. 多个管理员不能同时在同一配置上工作。
-
以下哪些是有效的 Terraform 远程后端?(选择五个)
A. S3
B. Azure Blob 存储
C. Artifactory
D. Git
E. HTTP
F. Terraform 企业版
-
哪个命令将在下次
apply中标记一个资源以供重建? -
如果你使用工作空间,本地后端中的状态文件存储在哪里?
-
我们应该使用什么命令将 Terraform 资源从状态中移除?
-
我们应该使用什么命令将现有的云资源导入到状态中?
答案
-
因为 Terraform 提供程序是单独发布到 Terraform CLI 的,不同版本可能会破坏现有配置
-
对
-
A, B
-
A, B, C
-
A, B
-
A, B, C, E, F
-
taint命令 -
terraform.tfstate.d -
terraform staterm <resource> -
terraform import <``resource> <id>
第九章:使用 Ansible 进行配置管理
在上一章中,我们介绍了基础设施即代码(IaC)与 Terraform,以及其核心概念、IaC 工作流、状态和调试技巧。现在,我们将深入探讨使用 Ansible 进行配置管理(CM)和配置即代码(CaC)。Ansible 是一种配置管理工具,帮助你将配置定义为幂等的代码块。
在本章中,我们将涵盖以下主要主题:
-
配置管理简介
-
设置 Ansible
-
Ansible 剧本简介
-
Ansible 剧本实战
-
设计可重用性
技术要求
你需要一个有效的 Azure 订阅才能进行本章的练习。目前,Azure 提供 30 天的免费试用,赠送 200 美元的免费信用额度,你可以在 azure.microsoft.com/en-in/free 注册。
你还需要克隆以下 GitHub 仓库以完成部分练习:
github.com/PacktPublishing/Modern-DevOps-Practices-2e
运行以下命令将仓库克隆到你的主目录,并cd进入ch9目录以访问所需资源:
$ git clone https://github.com/PacktPublishing/Modern-DevOps-Practices-2e.git \
modern-devops
$ cd modern-devops/ch9
你还需要在系统中安装 Terraform。有关安装和设置 Terraform 的更多详细信息,请参考 第八章,使用 Terraform 实现基础设施即代码(IaC)。
配置管理简介
在技术和系统管理领域,配置管理(CM)可以比作指挥家领导乐团的角色。想象一下,你在指导一群演奏不同乐器的音乐家。你的责任是确保每个人都能和谐地同步,遵循正确的乐谱,在恰当的时刻演奏各自的部分。
在技术和系统管理的背景下,配置管理(CM)是指巧妙地协调和监督计算机系统和软件的创建、更新和维护的实践,就像指挥家指挥乐队演奏精彩的音乐一样。
下面是其功能的分解:
-
标准化:就像音乐家使用相同的音符和音阶一样,配置管理(CM)确保组织内的所有计算机和软件都遵循标准化的配置。这种一致性减少了错误并增强了系统的可靠性。
-
自动化:在乐队中,音乐家不会在演出时手动调试他们的乐器。同样,配置管理工具自动化计算机系统的配置和维护,持续应用配置而无需人工干预。
-
版本控制:音乐家遵循特定的乐谱,如果发生更改,每个人都会收到更新的乐谱。配置管理保持系统配置的版本历史,简化了更改跟踪、回滚到先前的版本,并确保全局一致性。
-
效率:就像指挥家同步每个乐器的节奏一样,配置管理优化系统性能和资源分配。它确保软件和系统高效运行,并根据需要进行扩展。
-
合规性和安全性:类似于指挥家执行表演指导,配置管理(CM)确保遵循安全政策和最佳实践。它在维护一个安全且合规的 IT 环境中发挥着至关重要的作用。
-
故障排除:当表演过程中出现问题时,指挥家迅速识别并解决问题。配置管理工具帮助排查和修复与 IT 系统配置相关的问题。
为了更好地理解配置管理,让我们首先看一下传统的托管和管理应用程序的方式。我们首先从物理基础设施中创建一个虚拟机(VM),然后手动登录到虚拟机中。接着我们可以运行一组脚本或手动进行设置。至少,直到现在,我们一直是这样做的,甚至在本书中也是如此。
这种方法存在一些问题。让我们来看一些:
-
如果我们手动设置服务器,过程不可重复。例如,如果我们需要构建另一台具有类似配置的服务器,就必须重复整个过程来构建新的服务器。
-
即使我们使用脚本,这些脚本本身也不是幂等的。这意味着它们无法识别并仅在需要时应用增量配置。
-
典型的生产环境由多个服务器组成;因此,手动设置一切是一个劳动密集型任务,并且增加了繁重的工作量。软件工程师应该专注于自动化那些导致繁琐工作的过程。
-
虽然你可以将脚本存储在源代码管理中,但它们是命令式的。我们始终鼓励使用声明式的方式来管理事物。
现代的配置管理工具如 Ansible 通过提供以下好处解决了所有这些问题:
-
它们通过一组声明式代码片段来管理配置
-
你可以将代码存储在版本控制中
-
你可以从单个控制节点将代码应用到多个服务器
-
由于它们是幂等的,它们只应用增量配置
-
这是一个可重复的过程;你可以使用变量和模板将相同的配置应用于多个环境
-
它们提供部署编排,主要用于 CI/CD 流水线中
尽管市场上有许多提供配置管理的工具,如Ansible、Puppet、Chef和SaltStack,但 Ansible 是最受欢迎和最简单的工具。它更高效,且由于其简洁性,花费的时间比其他工具少。
这是一个开源的配置管理工具,使用 Python 构建,并由红帽公司拥有。它提供以下功能:
-
它帮助你自动化常规任务,如操作系统升级、补丁和备份,同时创建所有操作系统级别的配置,如用户、组、权限等。
-
配置使用简单的 YAML 语法编写
-
它通过 安全外壳(SSH)与管理节点通信并发送命令
-
命令在每个节点内按顺序以幂等方式执行
-
它通过并行连接节点来节省时间
让我们深入探讨使用 Ansible 作为配置管理和自动化工具的优点。以下是一些令人信服的因素:
-
简洁与易用性:Ansible 拥有简单的、易于理解的 YAML 语法,即使对于编码经验有限的人也能轻松掌握和使用。
-
无代理方式:Ansible 通过 SSH 或 WinRM 通信,避免在管理节点上安装代理,减少了开销和安全问题,关于这一点,我们将在讨论 Ansible 架构时进一步探讨。
-
幂等操作:Ansible 确保即使配置被重复应用,也能实现期望的系统状态,从而最小化意外更改的风险。
-
广泛采用:Ansible 拥有一个蓬勃发展的活跃用户社区,提供大量的文档、模块和针对各种使用案例的 playbook。
-
跨平台兼容性:Ansible 可以处理多种环境,管理不同的操作系统、云服务商、网络设备和基础设施组件,且只需一个工具。
-
无缝集成:Ansible 可以与其他工具无缝集成,包括 版本控制系统(VCS)、监控解决方案和 CI/CD 管道。
-
可扩展性:Ansible 能够轻松扩展,处理从小型到大型的各种环境,适用于企业和初创公司。
-
版本控制:基础设施配置存储在纯文本文件中,简化了变更管理、历史跟踪,并通过 Git 或类似的版本控制系统(VCS)进行协作。
-
自动化常规任务:Ansible 自动化软件安装、配置更新和补丁管理等重复性工作,为战略任务腾出时间。
-
安全性与合规性:使用 Ansible 的 基于角色的访问控制(RBAC)和集成的安全模块,可以在整个基础设施中一致地实施安全策略和合规标准。
-
回滚与恢复:Ansible 允许在出现问题时轻松回滚到之前的配置,减少停机时间并最小化变更的影响。
-
模块化与可重用性:Ansible 鼓励创建模块化、可重用的 playbook 和角色,推动一种有序高效的自动化方法。
-
支持性社区:受益于强大的 Ansible 社区,提供支持、文档以及贡献的角色和模块库。
-
成本效益:Ansible 是开源并且免费使用,相比其他自动化工具,大大降低了许可费用。
-
编排和工作流自动化:除了配置管理(CM)之外,Ansible 还可以编排复杂的工作流,包括应用部署和基础设施配置。
-
不可变基础设施:Ansible 支持不可变基础设施的概念,即更改时是通过重建组件而不是在原地修改它们。这使得部署更加可预测和可靠。
-
实时反馈:Ansible 提供实时反馈和报告,简化了自动化任务的监控和故障排除。
这些优势使得 Ansible 成为广泛 IT 环境和行业中配置管理、自动化和编排的热门选择。
Ansible 具有简单的架构。它有一个控制节点,负责管理多个受管节点。你所需要的只是一个控制节点服务器来安装 Ansible,以及用于通过控制节点管理的节点(也称为受管节点)。这些受管节点应该允许来自 Ansible 控制节点的 SSH 连接——如下图所示:

图 9.1 – Ansible 架构
现在,让我们继续看看如何使用 Ansible 安装和配置所需的配置。接下来的部分将介绍如何安装 Ansible。
配置 Ansible
我们需要在控制节点上安装并设置 Ansible,但在此之前,我们将需要启动三台服务器来开始活动——一台 Ansible 控制节点和两台受管节点。
配置库存
目的是建立一个包含Apache和MySQL的两层架构。所以,让我们使用 Terraform 启动三台服务器。
首先,让我们cd进入 Terraform 模板所在的目录,然后编辑 terraform.tfvars 文件,填写所需的详细信息。(有关如何获取属性的更多详细信息,请参考第八章,基础设施即代码 (IaC) 与 Terraform)
$ cd ~/modern-devops/ch9/setup-ansible-terraform
$ vim terraform.tfvars
然后,使用以下命令通过 Terraform 启动服务器:
$ terraform init
$ terraform plan -out ansible.tfplan
$ terraform apply ansible.tfplan
一旦 terraform apply 命令成功完成,我们将看到三台服务器——ansible-control-node、web 和 db,以及在 ansible-exercise 资源组内创建的相关资源。
terraform apply的输出还提供了 Ansible 控制节点和web虚拟机的公网 IP 地址。你应该在输出中看到我们获得的公网 IP 地址。
注意
Azure 可能需要一些时间来报告输出,如果在 terraform apply 时没有获得 IP 地址,你可以之后运行 terraform output 来获取详细信息。
Ansible 需要控制节点通过 SSH 与受管节点连接。现在,让我们继续并看看如何与我们的受管节点(也称为库存服务器)进行通信。
将 Ansible 控制节点与库存服务器连接
当我们使用 Terraform 提供基础设施时,我们已经在控制节点和受控节点之间设置了无密码 SSH。让我们看看我们是如何做到这一点的,以便更好地理解它。
我们在该子网内创建了control-node、web和db。如果我们查看 VM 资源配置,还可以看到custom_data字段,可用于将初始化脚本传递给 VM,如下所示:
resource "azurerm_virtual_machine" "control_node" {
name = "ansible-control-node"
...
os_profile {
...
custom_data = base64encode(data.template_file.control_node_init.rendered)
}
}
resource "azurerm_virtual_machine" "web" {
name = "web"
...
os_profile {
...
custom_data = base64encode(data.template_file.managed_nodes_init.rendered)
}
}
resource "azurerm_virtual_machine" "db" {
name = "db"
...
os_profile {
...
custom_data = base64encode(data.template_file.managed_nodes_init.rendered)
}
}
如我们所见,control_node虚拟机引用了data.template_file.control_node_init资源,而web和db节点引用了data.template_file.managed_nodes_init资源。这些是可以用于模板文件的template_file资源。让我们看一下资源如下:
data "template_file" "managed_nodes_init" {
template = file("managed-nodes-user-data.sh")
vars = {
admin_password = var.admin_password
}
}
data "template_file" "control_node_init" {
template = file("control-node-user-data.sh")
vars = {
admin_password = var.admin_password
}
}
正如我们所看到的,managed_nodes_init资源指向managed-nodes-user-data.sh文件,并向该文件传递一个admin_password变量。同样,control_node_init资源指向control-node-user-data.sh文件。让我们先看看managed-nodes-user-data.sh文件:
#!/bin/sh
sudo useradd -m ansible
echo 'ansible ALL=(ALL) NOPASSWD:ALL' | sudo tee -a /etc/sudoers
sudo su - ansible << EOF
ssh-keygen -t rsa -N '' -f ~/.ssh/id_rsa
printf "${admin_password}\n${admin_password}" | sudo passwd ansible
EOF
如我们所见,这是一个执行以下操作的 shell 脚本:
-
创建一个
ansible用户。 -
将用户添加到
sudoers列表。 -
为无密码认证生成
ssh密钥对。 -
设置
ansible用户的密码。
由于我们已经生成了ssh密钥对,因此我们需要在控制节点上进行相同的操作,并进行一些额外的配置。让我们看一下control-node-user-data.sh脚本,如下所示:
#!/bin/sh
sudo useradd -m ansible
echo 'ansible ALL=(ALL) NOPASSWD:ALL' | sudo tee -a /etc/sudoers
sudo su - ansible << EOF
ssh-keygen -t rsa -N '' -f ~/.ssh/id_rsa
sleep 120
ssh-keyscan -H web >> ~/.ssh/known_hosts
ssh-keyscan -H db >> ~/.ssh/known_hosts
sudo apt update -y && sudo apt install -y sshpass
echo "${admin_password}" | sshpass ssh-copy-id ansible@web
echo "${admin_password}" | sshpass ssh-copy-id ansible@db
EOF
该脚本执行以下操作:
-
创建一个
ansible用户 -
将用户添加到
sudoers列表 -
为无密码认证生成
ssh密钥对 -
将
web和db虚拟机添加到known_hosts文件中,以确保我们信任这两个主机 -
安装
sshpass实用程序以允许发送ssh公钥到web和db虚拟机 -
将
ssh公钥复制到web和db虚拟机以实现无密码连接
当 VM 创建时,这些文件会自动执行;因此,无密码 SSH 应已经起作用。因此,让我们使用上一步中获取的 IP 地址使用ansible-control-node。我们将使用在terraform.tfvars文件中配置的用户名和密码:
$ ssh ssh_admin@104.46.61.213
一旦你在控制节点服务器上,请切换用户到ansible,并尝试使用以下命令连接到web服务器:
$ sudo su - ansible
$ ssh web
如果你登陆到web服务器,无密码认证是正常工作的。
重复相同的步骤检查您是否可以与db服务器连接。
退出提示,直到您在控制节点。
现在,因为我们在控制节点上,让我们安装 Ansible。
在控制节点安装 Ansible
Ansible 需要一个 Linux/Unix 机器(最好是),并且应安装 Python 2.x或3.x。
由于 Ansible 控制节点运行在 Ubuntu 上,Ansible 提供了apt命令。
使用以下命令在服务器上安装 Ansible:
$ sudo apt update
$ sudo apt install software-properties-common -y
$ sudo apt-add-repository --yes --update ppa:ansible/ansible
$ sudo apt install ansible -y
要检查 Ansible 是否已成功安装,请运行以下命令:
$ ansible --version
ansible 2.9.27
正如我们所见,ansible 2.9.27已成功安装在您的控制节点上。
Ansible 使用清单文件来管理节点。因此,下一步是设置清单文件。
设置清单文件
在 Ansible 中的清单文件是一个文件,允许您根据角色对受管节点进行分组。例如,您可以定义诸如webserver和dbserver之类的角色,并将相关服务器分组。您可以使用 IP 地址、主机名或别名进行定义。
提示
始终使用别名,因为它们为 IP 地址和主机名更改提供了空间。
您可以使用分配给它们的角色在主机或一组主机上运行 Ansible 命令。可以有一个特定角色的服务器没有限制。如果您的服务器使用非标准 SSH 端口,您还可以在清单文件中使用该端口。
Ansible 清单文件的默认位置是/etc/ansible/hosts。如果查看/etc/ansible目录的所有权,则其所有者是root用户。出于安全目的,我们希望使用我们为此创建的ansible用户。因此,我们必须更改/etc/ansible目录及其子目录和文件的所有权为ansible。请使用以下命令执行此操作:
$ sudo chown -R ansible:ansible /etc/ansible
然后,我们可以将用户切换到ansible,并使用以下命令将包含所需文件的 Git 存储库克隆到控制服务器:
$ sudo su - ansible
$ git clone https://github.com/PacktPublishing/Modern-DevOps-Practices-2e.git \
modern-devops
$ cd ~/modern-devops/ch9/ansible-exercise
在我们的场景中,我们有一个名为web的 Web 服务器和一个名为db的数据库服务器。因此,如果您检查存储库中名为hosts的主机文件,您将看到以下内容:
[webservers]
web ansible_host=web
[dbservers]
db ansible_host=db
[all:vars]
ansible_python_interpreter=/usr/bin/python3
[all:vars]部分包含适用于所有组的变量。在这里,我们明确地将ansible_python_interpreter定义为python3,以便 Ansible 使用python3而不是python2。由于我们使用的是 Ubuntu,默认安装了python3,而python2已弃用。
我们还看到,我们没有直接使用web,而是指定了一个ansible_host部分。它将web定义为一个别名,指向一个名为web的主机。如果需要,您也可以使用 IP 地址而不是主机名。
提示
始终根据执行的功能对清单进行分组。这有助于我们对大量具有相似角色的机器应用相似的配置。
由于我们希望将配置与代码保持一致,因此我们希望保留在 Git 存储库本身内。因此,我们必须告诉 Ansible 清单文件位于非标准位置。为此,我们将创建一个 Ansible 配置文件。
设置 Ansible 配置文件
Ansible 配置文件定义了特定于我们设置的全局属性。以下是指定 Ansible 配置文件的方法,第一种方法会覆盖下一个——设置不会合并,所以请记住这一点:
-
通过设置环境变量
ANSIBLE_CONFIG,指向 Ansible 配置文件 -
通过在当前目录中创建
ansible.cfg文件 -
通过在当前用户的主目录中创建
ansible.cfg文件 -
通过在
/etc/ansible目录中创建ansible.cfg文件
提示
如果你管理多个应用,每个应用都有自己的 Git 仓库,那么在每个仓库中都有一个本地的 ansible.cfg 文件将有助于保持应用的去中心化。它还将启用 GitOps 并使 Git 成为单一的可信源。
所以,如果你检查当前目录中的 ansible.cfg 文件,你会看到以下内容:
[defaults]
inventory = ./hosts
host_key_checking = False
现在,为了检查我们的清单文件是否正确,让我们使用以下命令列出我们的清单:
$ ansible-inventory --list -y
all:
children:
dbservers:
hosts:
db:
ansible_host: db
ansible_python_interpreter: /usr/bin/python3
ungrouped: {}
webservers:
hosts:
web:
ansible_host: web
ansible_python_interpreter: /usr/bin/python3
我们看到有两个组——dbservers 包含 db,webservers 包含 web,每个组都使用 python3 作为 ansible_python_interpreter。
如果我们想查看所有主机,我们可以使用以下命令:
$ ansible --list-hosts all
hosts (2):
web
db
如果我们想列出所有具有 webservers 角色的主机,我们可以使用以下命令:
$ ansible --list-hosts webservers
hosts (1):
web
现在,让我们使用以下命令来检查 Ansible 是否能够连接到这些服务器:
$ ansible all -m ping
web | SUCCESS => {
"changed": false,
"ping": "pong"
}
db | SUCCESS => {
"changed": false,
"ping": "pong"
}
如我们所见,对于两个服务器,我们都得到了成功的响应。因此,我们已经完全设置好,可以开始定义配置。Ansible 提供了任务和模块来提供配置管理。让我们在下一节中详细了解这些内容。
Ansible 任务和模块
Ansible 任务是执行 Ansible 命令的基本构建块。Ansible 任务的结构如下所示:
$ ansible <options> <inventory>
Ansible 模块是用于特定功能的可重用代码,例如运行 shell 命令或创建和管理用户。你可以通过 Ansible 任务与 Ansible 模块一起使用来管理受管节点中的配置。例如,以下命令将在每个受管服务器上运行 uname 命令:
$ ansible -m shell -a "uname" all
db | CHANGED | rc=0 >>
Linux
web | CHANGED | rc=0 >>
Linux
因此,我们从 db 服务器和 web 服务器得到了回复,每个服务器提供了返回码 0 和输出 Linux。如果你查看命令,你会发现我们提供了以下标志:
-
-m:模块的名称(这里是shell模块) -
-a:模块的参数(在此例中为uname)
命令最后会指定我们想在哪个地方运行这个任务。由于我们指定了 all,因此任务将在所有服务器上运行。我们也可以将任务运行在单个服务器、一组服务器、角色或多个角色上,或者使用通配符选择我们想要的组合。
任务有三种可能的状态——SUCCESS、CHANGED 和 FAILURE。SUCCESS 状态表示任务成功完成,Ansible 没有进行任何操作。CHANGED 状态表示 Ansible 为了应用预期的配置,必须更改现有的配置,而 FAILURE 表示在执行任务时发生了错误。
Ansible 模块是可重用的脚本,我们可以用它们来定义服务器内的配置。每个模块针对配置管理(CM)的特定方面。模块在 Ansible 任务和剧本中都可以使用。有许多模块可供使用,它们可以在docs.ansible.com/ansible/latest/collections/index_module.html找到。你可以根据自己的需求和使用场景挑选并使用模块。
提示
由于 Ansible 是幂等的,始终使用特定于任务的模块,避免使用command和shell模块。例如,使用apt模块安装软件包,而不是使用command模块运行apt install <package> -y。如果你的剧本看起来像代码,那么你可能做错了根本性的事情。
当我们在设置服务器时有一系列步骤要跟随时,任务就没有意义。因此,Ansible 为这个活动提供了剧本。我们将在下一部分了解这一点。
Ansible 剧本介绍
想象一下你是指挥家,正在带领一个交响乐团。在这种情境下,Ansible 剧本就像是你的乐谱,指导每个音乐家在技术世界中创造一场和谐的自动化交响乐。
在技术和自动化领域,Ansible 剧本提供了以下功能:
-
自动化的乐谱:就像指挥家使用带有记谱符号的乐谱来指导每个乐器一样,Ansible 剧本包含了一系列指令和操作,用于编排特定的 IT 任务和配置,从软件部署到系统配置等。
-
和谐的引导:Ansible 剧本采取了类似的方式。你声明所需的 IT 状态,而 Ansible 扮演指挥的角色,确保所有必要的步骤都得到执行,就像指定“我希望演奏一场完美的音乐会”,而 Ansible 就是协调整个过程。
-
任务与重用性:Ansible 剧本被组织成任务和角色,就像乐谱和乐器一样。这些任务可以在多个剧本中重用,促进一致性并节省时间。
-
乐器选择与指挥:就像指挥家选择哪些乐器在何时演奏一样,剧本指定了哪些服务器或机器(清单)应执行任务。你可以指挥特定的服务器组或单独的机器。
-
和谐的执行:Ansible 能够巧妙地协调多台机器上的任务,就像指挥家协调不同音乐家之间的配合,创造出一首美丽的乐章。
-
精细调控的表现:如果在演奏过程中出现意外挑战,指挥家会调整并引导音乐家以确保演出完美无缺。同样,Ansible 剧本也包含了错误处理策略,以应对自动化过程中的意外问题。
Ansible 剧本是由任务集合组成的,这些任务会在被管理的节点中生成所需的配置。它们具有以下特点:
-
它们通过使用声明性步骤帮助管理多个远程服务器上的配置
-
它们使用一系列顺序的幂等步骤,符合预期配置的步骤不会再次应用。
-
剧本中的任务可以是同步的也可以是异步的
-
它们通过允许将步骤存储在简单的 YAML 文件中并保存在源代码控制中,从而实现 GitOps,提供 CaC。
Ansible 剧本由多个 play 组成,每个 play 映射到一组 hosts,使用 role,并包含实现目标所需的一系列 tasks,类似于以下图示:

图 9.2 – 剧本
以下的 ping.yaml 文件是一个简单的剧本示例,它会对所有服务器进行 ping 测试:
---
- hosts: all
tasks:
- name: Ping all servers
action: ping
YAML 文件包含了一个任务列表,如列表指令所示。每个任务包含一个 hosts 属性,定义了我们希望应用任务的角色。tasks 部分包含一个任务列表,每个任务都有 name 和 action 属性。在之前的示例中,我们有一个单一的任务,它会对所有服务器进行 ping 测试。
检查剧本语法
在应用剧本之前检查语法是一种最佳实践。要检查剧本的语法,请运行以下命令:
$ ansible-playbook ping.yaml --syntax-check
playbook: ping.yaml
语法是正确的,因为我们得到了包含剧本名称的响应。现在,让我们继续应用这个剧本。
应用第一个剧本
要应用剧本,请运行以下命令:
$ ansible-playbook ping.yaml
PLAY [all] *******************************************
TASK [Gathering Facts] *******************************
ok: [db]
ok: [web]
TASK [Ping all servers] ******************************
ok: [db]
ok: [web]
PLAY RECAP *******************************************
db : ok=2 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
web : ok=2 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
执行剧本有三个要素:
-
收集信息:Ansible 检查所有属于该角色的主机,登录到每个实例,并收集每个主机在执行 play 中的任务时使用的信息。
-
运行任务:然后,它运行每个剧本中定义的任务。
-
任务回顾:Ansible 会提供已执行任务的回顾,以及它在哪些主机上执行这些任务。这包括所有成功和失败的响应列表。
在我们研究了一个基础的剧本示例之后,我们必须了解如何有效地使用 Ansible 剧本。在接下来的部分中,让我们通过一个更好的示例来查看 Ansible 剧本的实际应用。
Ansible 剧本实际应用
让我们使用 Ansible 设置一个连接 MySQL 后端的 Apache 服务器,简而言之,这是一个 Linux、Apache、MySQL 和 PHP(LAMP)栈。
以下目录包含本节练习所需的所有资源:
$ cd ~/modern-devops/ch9/lamp-stack
我们创建了以下自定义的 index.php 页面,用于测试与 MySQL 数据库的连接,并显示是否能够连接:
...
<?php
mysqli_connect('db', 'testuser', 'Password@1') or die('Could not connect the database :
Username or password incorrect');
echo 'Database Connected successfully';
?>
...
我们根据 CM 所遵循的逻辑步骤创建了多个 Ansible 剧本。
在每次配置开始时更新软件包和仓库是一种很好的实践。因此,我们需要从这一步开始编写剧本。
更新软件包和仓库
由于我们使用的是 Ubuntu,我们可以使用apt模块来更新软件包。我们必须更新软件包和仓库,以确保所有的apt仓库都有最新的软件包索引,并避免在安装软件包时出现任何问题。以下剧本apt-update.yaml执行了更新:
---
- hosts: webservers:dbservers
become: true
tasks:
- name: Update apt packages
apt: update_cache=yes cache_valid_time=3600
YAML 文件以播放列表开始,在这种情况下包含一个播放。hosts属性定义了一个冒号分隔的roles/hosts清单,用于应用此剧本。在这种情况下,我们指定了webservers和dbservers。become属性指定是否要作为root用户执行播放。因此,由于我们将become设置为true,Ansible 将在所有播放任务中使用sudo权限。该播放包含一个任务——更新 apt 软件包。该任务使用apt模块,包含update_cache=yes。它将在所有具有webservers和dbservers角色的节点上运行apt update操作。下一步是安装软件包和服务。
安装应用程序软件包和服务
我们将使用apt模块在 Ubuntu 上安装软件包,并使用service模块启动并启用服务。
我们从使用以下install-webserver.yaml剧本在 Web 服务器上安装 Apache 开始:
---
- hosts: webservers
become: true
tasks:
- name: Install packages
apt:
name:
- apache2
- php
- libapache2-mod-php
- php-mysql
update_cache: yes
cache_valid_time: 3600
state: present
- name: Start and Enable Apache service
service: name=apache2 state=started enabled=yes
由于这个配置是为webservers准备的,我们在hosts属性中指定了这一点。tasks部分定义了两个任务——安装软件包和启动并启用 Apache 服务。安装软件包任务使用apt模块安装apache2、php、libapache2-mod-php和php-mysql。启动并启用 Apache 服务任务将启动并启用apache2服务。
类似地,我们将使用以下install-dbserver.yaml剧本安装并设置 MySQL 服务:
---
- hosts: dbservers
become: true
tasks:
- name: Install packages
apt:
name:
- python-pymysql
- mysql-server
update_cache: yes
cache_valid_time: 3600
state: present
- name: Start and enable MySQL service
service:
name: mysql
state: started
enabled: true
这个剧本将运行两个任务——安装软件包和启动并启用 MySQL 服务。安装软件包任务将使用apt模块安装python-mysql和mysql-server软件包。启动并启用 MySQL 服务任务将启动并启用 MySQL 服务。
配置应用程序
链中的下一步是配置应用程序。为此有两个剧本。第一个将配置webservers上的 Apache,第二个将配置dbservers上的 MySQL。
以下setup-webservers.yaml剧本将配置webservers:
---
- hosts: webservers
become: true
tasks:
- name: Delete index.html file
file:
path: /var/www/html/index.html
state: absent
- name: Upload application file
copy:
src: index.php
dest: /var/www/html
mode: 0755
notify:
- Restart Apache
handlers:
- name: Restart Apache
service: name=apache2 state=restarted
这个剧本会在所有具有webservers角色的节点上运行,剧本中有三个任务。删除 index.html 文件任务使用file模块从 Web 服务器中删除/var/www/html/index.html文件。因为我们使用的是index.php作为主页,而不是index.html。上传应用文件任务然后使用copy模块将index.php文件从 Ansible 控制节点复制到 Web 服务器上的/var/www/html目标路径,模式为0755。上传应用文件任务还包含一个notify动作,如果该任务的状态为CHANGED,它会调用重启 Apache处理程序。剧本中的handlers部分定义了监听通知事件的处理程序。在此场景中,如果上传应用文件任务发生变化,重启 Apache处理程序会被触发,并重启apache2服务。
我们将使用以下setup-dbservers.yaml剧本来配置dbservers上的 MySQL:
---
- hosts: dbservers
become: true
vars:
mysql_root_password: "Password@1"
tasks:
- name: Set the root password
copy:
src: client.my.cnf
dest: "/root/.my.cnf"
mode: 0600
notify:
- Restart MySQL
- name: Create a test user
mysql_user:
name: testuser
password: "Password@1"
login_user: root
login_password: "{{ mysql_root_password }}"
state: present
priv: '*.*:ALL,GRANT'
host: '%'
- name: Remove all anonymous user accounts
mysql_user:
name: ''
host_all: yes
state: absent
login_user: root
login_password: "{{ mysql_root_password }}"
notify:
- Restart MySQL
- name: Remove the MySQL test database
mysql_db:
name: test
state: absent
login_user: root
login_password: "{{ mysql_root_password }}"
notify:
- Restart MySQL
- name: Change bind address
lineinfile:
path: /etc/mysql/mysql.conf.d/mysqld.cnf
regexp: ^bind-address
line: 'bind-address = 0.0.0.0'
notify:
- Restart MySQL
handlers:
- name: Restart MySQL
service: name=mysql state=restarted
这个剧本稍微复杂一些,但我们可以将其分解成几部分,便于理解。
这个剧本中有一个vars部分,定义了mysql_root_password变量。在执行 MySQL 任务时我们需要这个密码。第一个任务是设置 root 密码。设置 root 密码的最佳方式是通过在 MySQL 中定义一个包含 root 凭证的/root/.my.cnf文件。我们将以下client.my.cnf文件通过copy模块复制到/root/.my.cnf:
[client]
user=root
password=Password@1
然后,创建测试用户任务使用mysql_user模块创建一个名为testuser的用户。该任务需要提供login_user和login_password属性的值,我们分别提供root和{{ mysql_root_password }}。接着,它会删除所有匿名用户,并删除测试数据库。然后,它使用lineinfile模块将绑定地址更改为0.0.0.0。lineinfile模块是一个强大的模块,通过先使用正则表达式在文件中查找内容,然后用行属性的值替换这些行来操作文件。所有这些任务会通知重启 MySQL处理程序,该处理程序会重启 MySQL 数据库服务。
结合剧本
由于我们已经编写了多个剧本,我们需要按顺序执行它们。在安装软件包和服务之前无法配置服务,而且在安装软件包之后再运行apt更新也没有意义。因此,我们可以创建一个包含多个剧本的剧本。
为此,我们创建了一个 YAML 文件playbook.yaml,其内容如下:
---
- import_playbook: apt-update.yaml
- import_playbook: install-webserver.yaml
- import_playbook: install-dbserver.yaml
- import_playbook: setup-webservers.yaml
- import_playbook: setup-dbservers.yaml
这个 YAML 文件包含了一系列的剧本,每个剧本都包含一个import_playbook语句。剧本按照文件中指定的顺序执行。现在,让我们继续执行这个剧本。
执行剧本
执行剧本很简单。我们将使用 ansible-playbook 命令后跟剧本的 YAML 文件。由于我们将剧本合并到 playbook.yaml 文件中,以下命令将执行剧本:
$ ansible-playbook playbook.yaml
PLAY [webservers:dbservers] **************************
TASK [Gathering Facts] *******************************
ok: [web]
ok: [db]
TASK [Update apt packages] ***************************
ok: [web]
ok: [db]
PLAY [webservers] ************************************
TASK [Gathering Facts] *******************************
ok: [web]
TASK [Install packages] ******************************
changed: [web]
TASK [Start and Enable Apache service] ***************
ok: [web]
PLAY [dbservers] *************************************
TASK [Gathering Facts] *******************************
ok: [db]
TASK [Install packages] ******************************
changed: [db]
TASK [Start and enable MySQL service] ****************
ok: [db]
PLAY [webservers] ************************************
TASK [Gathering Facts] *******************************
ok: [web]
TASK [Delete index.html file] ************************
changed: [web]
TASK [Upload application file] ***********************
changed: [web]
RUNNING HANDLER [Restart Apache] *********************
changed: [web]
PLAY [dbservers] *************************************
TASK [Gathering Facts] *******************************
ok: [db]
TASK [Set the root password] *************************
changed: [db]
TASK [Update the cnf file] ***************************
changed: [db]
TASK [Create a test user] ****************************
changed: [db]
TASK [Remove all anonymous user accounts] ************
ok: [db]
TASK [Remove the MySQL test database] ***************
ok: [db]
TASK [Change bind address] **************************
changed: [db]
RUNNING HANDLER [Restart MySQL] **********************
changed: [db]
PLAY RECAP *******************************************
db: ok=13 changed=6 unreachable=0 failed=0
skipped=0 rescued=0 ignored=0
web: ok=9 changed=4 unreachable=0 failed=0
skipped=0 rescued=0 ignored=0
如我们所见,配置同时应用于 webservers 和 dbservers,所以让我们向 web 服务器发送一个 curl 命令来查看我们得到的结果:
$ curl web
<html>
<head>
<title>PHP to MQSQL</title>
</head>
<body>Database Connected successfully</body>
</html>
如我们所见,数据库已成功连接!这证明设置已成功。
我们采用的问题解决方法并不是最好的,有几个原因。首先,剧本中有几个部分我们硬编码了值。虽然我们在一些剧本中使用了变量,但也在剧本内为变量赋值。这样做使得剧本不能成为重用的候选项。设计软件的最佳方式是始终考虑到可重用性。因此,我们有多种方法可以重新设计剧本,以促进可重用性。
设计以支持可重用性
Ansible 提供了用于将 Ansible 剧本转化为可重用模板的变量。你可以使用Jinja2标记在适当的位置替换变量,我们在上一个剧本中已经使用过它。现在,让我们来看一下 Ansible 变量、它们的类型以及如何使用它们。
Ansible 变量
Ansible 变量与其他任何变量一样,用于管理托管节点之间的差异。你可以使用相似的剧本来处理多个服务器,但有时配置上会有一些差异。Ansible 变量帮助你为剧本创建模板,使得它们可以在多个相似的系统中重用。你可以在多个地方定义变量:
-
在 Ansible 剧本的
vars部分内 -
在你的清单中
-
在可重用的文件或角色中
-
通过命令行传递变量
-
通过将任务的返回值赋给变量来注册变量
Ansible 变量名可以包含字母、数字和下划线。由于 Ansible 在后台使用 Python,变量名不能是 Python 的关键字。此外,变量名不能以数字开头,但可以以下划线开头。
你可以使用简单的键值对在 YAML 文件中定义变量,并遵循标准的 YAML 语法。
变量大致可以分为三种类型——简单变量、列表变量和字典变量。
简单变量
{{ var_name }}。你应该始终引用 Jinja 表达式,因为如果没有这些引号,YAML 文件将无法解析。
以下是一个简单变量声明的示例:
mysql_root_password: bar
这是你应该如何引用它的方式:
- name: Remove the MySQL test database
mysql_db:
name: test
state: absent
login_user: root
login_password: "{{ mysql_root_password }}"
现在,让我们来看一下列表变量。
列表变量
列表变量保存一个值的列表,你可以通过索引引用它们。你还可以在循环中使用列表变量。要定义一个列表变量,你可以使用标准的 YAML 列表语法,如以下示例所示:
region:
- europe-west1
- europe-west2
- europe-west3
要访问变量,我们可以使用索引格式,如以下示例所示:
region: " {{ region[0] }} "
Ansible 还支持更复杂的字典变量。让我们来看一下。
字典变量
字典变量 保存复杂的 键值对 组合,类似于 Python 中的字典。你可以使用标准的 YAML 语法定义字典变量,如下所示:
foo:
bar: one
baz: two
有两种方式来引用这些变量的值。例如,在点符号中,我们可以这样写:
bar: {{ foo.bar }}
在括号符号中,我们可以使用以下表达式表示相同的内容:
bar: {{ foo[bar] }}
我们可以像在 Python 中一样使用点符号或括号符号。
提示
尽管点符号和括号符号表示相同的内容,但括号符号更好。使用点符号时,某些键可能与 Python 字典的方法和属性冲突。
现在,让我们看看如何获取变量值。
获取变量值
虽然你可以手动定义变量并提供其值,但有时我们需要动态生成的值;例如,如果我们需要知道 Ansible 执行 playbook 的服务器主机名,或想要在变量中使用任务返回的特定值。Ansible 在收集事实阶段提供了一组变量和系统元数据,以满足前述需求。这有助于确定哪些变量可用以及如何使用它们。让我们了解如何收集这些信息。
使用 Ansible facts 查找元数据
Ansible facts 是与受管节点相关的元数据。Ansible 在 收集事实 阶段获取事实,我们可以直接在 playbook 中使用 facts 变量。我们可以使用 setup 模块作为 Ansible 任务来确定事实。例如,你可以运行以下命令以获取所有具有 webservers 角色的节点的 Ansible facts:
$ ansible -m setup webservers
web | SUCCESS => {
"ansible_facts": {
"ansible_all_ipv4_addresses": [
"10.0.2.5"
],
...
"ansible_hostname": "web",
如我们所见,我们得到了带有多个变量的 ansible_facts,这些变量与库存项相关。由于这里只有一台服务器,我们得到了 web 服务器的详细信息。在这一部分中,我们有一个名为 web 的 ansible_hostname 属性。如果需要,我们可以在 playbook 中使用该 ansible_hostname 属性。
有时,我们想要将某个任务的输出源到特定变量,以便在 playbook 的后续任务中使用该变量。让我们看看如何做到这一点。
注册变量
如果你的 playbook 中的某个任务需要前面任务的结果值,我们可以使用 register 属性。
以下目录包含本节练习的所有资源:
$ cd ~/modern-devops/ch9/vars-exercise
让我们看看以下示例 register.yaml 文件:
- hosts: webservers
tasks:
- name: Get free space
command: free -m
register: free_space
ignore_errors: true
- name: Print the free space from the previous task
debug:
msg: "{{ free_space }}"
该 playbook 包含两个任务。第一个任务使用 command 模块执行命令 free -m,并将结果注册到 free_space 变量中。随后的任务使用前一个任务的输出,利用 debug 模块将 free_space 作为消息打印到控制台。
让我们运行 playbook 来亲自看看:
$ ansible-playbook register.yaml
PLAY [webservers] ************************************
TASK [Gathering Facts] *******************************
ok: [web]
TASK [Get free space] ********************************
changed: [web]
TASK [Print the free space from the previous task] ***
ok: [web] => {
"msg": {
"stdout": " total used
free shared buff/cache available\nMem: 3.3G
170M 2.6G 2.2M 642M 3.0G\nSwap:
0B 0B 0B",
}
PLAY RECAP ****************************************
web: ok=3 changed=1 unreachable=0 failed=0 skipped=0
rescued=0 ignored=0
现在我们已经理解了变量,让我们看看其他方面,这些方面将帮助我们改进最后的 playbook。
Jinja2 模板
Ansible 允许使用动态 Jinja2 模板对文件进行模板化。你可以在文件中使用 Python 语法,开始符号是 {{,结束符号是 }}。这将允许你在运行时替换变量,并对变量进行复杂的计算。
为了进一步理解这一点,让我们修改 index.php 文件,在执行期间动态提供 MySQL 的用户名和密码:
...
<?php
mysqli_connect('db', '{{ mysql_user }}', '{{ mysql_password }}')
or die('Could not connect the database : Username or password
incorrect');
echo 'Database Connected successfully';
?>
...
正如我们所看到的,除了硬编码用户名和密码外,我们还可以使用模板在运行时替代变量值。这将使文件更加可重用,并且适应多个环境。Ansible 提供了一个关于代码重用的重要功能——Ansible 角色。让我们在下一节中进一步了解这一点。
Ansible 角色
好吧,最后的 playbook 看起来有些杂乱。它包含了很多文件,而且没有一个是可重用的。我们编写的代码只能以特定的方式设置配置。这对于小型团队和有限的配置管理可能有效,但对于大多数企业来说,情况并不像看起来那么简单。
Ansible 角色有助于标准化 Ansible 配置并促进重用。通过角色,你可以使用标准的目录结构自动加载 var 文件、handlers、tasks 和其他 Ansible 工件,这些目录相对于你的 playbook 进行组织。目录结构如下:
<playbook>.yaml
roles/
<role>/
tasks/
handlers/
library/
files/
templates/
vars/
defaults/
meta/
roles 目录包含多个子目录,每个子目录代表一个角色。每个角色目录包含多个标准子目录:
-
tasks: 该目录包含任务的 YAML 文件列表。它应该包含一个名为main.yaml(或main.yml或main)的文件,其中列出了所有任务或从目录中的其他文件导入任务。 -
handlers: 该目录包含与角色相关的处理程序列表,保存在一个名为main.yaml的文件中。 -
library: 该目录包含可以与角色一起使用的 Python 模块。 -
files: 该目录包含配置所需的所有文件。 -
templates: 该目录包含角色部署的 Jinja2 模板。 -
vars: 该目录包含一个main.yaml文件,文件中列出了与角色相关的变量。 -
defaults: 该目录包含一个main.yaml文件,文件中列出了与角色相关的默认变量,这些变量可以通过任何包含清单变量的其他变量轻松覆盖。 -
meta: 该目录包含与角色相关的元数据和依赖项,保存在main.yaml文件中。
一些最佳实践涉及通过文件夹结构来管理 Ansible 配置。接下来,我们来看看其中的一些实践。
提示
在选择 vars 和 defaults 目录时,基本的规则是将不会改变的变量放入 vars 目录。将可能改变的变量放入 defaults 目录。
因此,我们将尽可能使用defaults目录。关于角色,也有一些最佳实践我们应该遵循。让我们来看一下其中的一些。
提示
在设计角色时,考虑特定服务的完整生命周期,而不是构建整个堆栈——换句话说,不要使用lamp作为角色,而是使用apache和mysql角色。
我们将为我们的使用创建三个角色——common、apache和mysql。
提示
使用特定的角色,例如apache或mysql,而不是使用webserver或dbserver。典型的企业会有多种 Web 服务器和数据库技术的组合,因此,为角色使用通用名称会导致混淆。
以下目录包含本节练习的所有资源:
$ cd ~/modern-devops/ch9/lamp-stack-roles
以下是我们将遵循的目录结构:
├── ansible.cfg
├── hosts
├── output.log
├── playbook.yaml
我们将创建三个角色——apache、mysql和common。首先,让我们看看apache角色的目录结构:
└── roles
├── apache
│ ├── defaults
│ │ └── main.yaml
│ ├── handlers
│ │ └── main.yaml
│ ├── tasks
│ │ ├── install-apache.yaml
│ │ ├── main.yaml
│ │ └── setup-apache.yaml
│ └── templates
│ └── index.php.j2
还有一个适用于所有场景的common角色。以下目录结构定义了这一点:
├── common
│ └── tasks
│ └── main.yaml
最后,让我们通过以下目录结构定义mysql角色:
└── mysql
├── defaults
│ └── main.yaml
├── files
├── handlers
│ └── main.yaml
├── tasks
│ ├── install-mysql.yaml
│ ├── main.yaml
│ └── setup-mysql.yaml
└── templates
└── client.my.cnf.j2
apache目录包含以下内容:
-
我们使用了在上一个练习中创建的相同的
index.php文件,将其转换为一个名为index.php.j2的 Jinja2 模板,并将其复制到roles/apache/templates目录。 -
handlers目录包含一个main.yaml文件,该文件包含RestartApache处理程序。 -
tasks目录包含一个install-apache.yaml文件,其中包括安装 Apache 所需的所有任务。setup-apache.yaml文件包含设置 Apache 的任务列表,类似于我们在上一个练习中所做的。main.yaml文件包含两个文件中的任务,使用诸如以下的include指令:
---
- include: install-apache.yaml
- include: setup-apache.yaml
defaults目录包含main.yaml文件,该文件包含mysql_username和mysql_password变量及其默认值。
提示
尽可能少使用变量,并尝试为其设置默认值。以最小化自定义配置的方式为变量设置默认值。
mysql目录包含以下内容:
- 我们修改了
client.my.cnf并将其转换为一个j2文件。j2文件是一个 Jinja2 模板文件,我们将在角色中通过template模块在Set the root password任务中使用。该文件位于templates目录内:
[client]
user=root
password={{ mysql_root_password }}
如我们所见,我们通过 Jinja2 表达式提供密码。当我们通过 playbook 运行mysql角色时,mysql_root_password的值将会替换password部分。
-
handlers目录包含RestartMySQL处理程序。 -
tasks目录包含三个文件。install-mysql.yaml文件包含安装mysql的任务,setup-mysql.yaml文件包含设置mysql的任务。main.yaml文件使用include任务指令将这两个文件合并,如下所示:
---
- include: install-mysql.yaml
- include: setup-mysql.yaml
defaults目录包含一个main.yaml文件,其中列出了我们将在角色中使用的变量。在这种情况下,它只包含mysql_root_password的值。
common 目录包含一个名为 tasks 的子目录,其中有一个 main.yaml 文件,文件内有一个任务来执行 apt update 操作。
主目录包含 ansible.cfg、hosts 和 playbook.yaml 文件。虽然 hosts 和 ansible.cfg 文件与上一个练习相同,但 playbook.yaml 文件如下所示:
---
- hosts: webservers
become: true
roles:
- common
- apache
- hosts: dbservers
become: true
roles:
- common
- mysql
现在,playbook 已经变得简洁,包含了许多可重用的元素。它由两个 play 组成。第一个 play 会以 root 用户身份在所有 Web 服务器上运行,并应用 common 和 apache 角色。第二个 play 会以 root 用户身份在所有具有 dbservers 角色的节点上运行,并使用 common 和 mysql 角色。
提示
始终保持角色松散耦合。在前面的示例中,apache 角色与 mysql 没有依赖关系,反之亦然。这将使我们能够轻松地重用配置。
现在,让我们继续执行 playbook:
$ ansible-playbook playbook.yaml
PLAY [webservers]
...
PLAY [dbservers]
...
PLAY RECAP
db: ok=10 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
web: ok=7 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
正如我们所见,配置没有变化。我们以更好的方式应用了相同的配置。如果我们想与团队中的其他人共享我们的配置,我们可以共享 roles 目录,他们可以在自己的 playbook 中应用该角色。
在某些情况下,我们可能需要为 roles 部分中定义的变量使用不同的值。你可以通过使用 extra-vars 标志,在 playbook 中覆盖变量值,如下所示:
$ ansible-playbook playbook.yaml --extra-vars "mysql_user=foo mysql_password=bar@123"
当我们使用上述命令应用 playbook 时,我们会看到用户现在变成了 foo,并且在 Apache 和 MySQL 配置中密码更改为 bar@123:
...
PLAY RECAP
db: ok=9 changed=1 unreachable=0 failed= skipped=0 rescued=0 ignored=0
web: ok=7 changed=2 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
因此,如果我们运行 curl 命令到 Web 主机,我们会得到与之前相同的响应:
...
<body>Database Connected successfully</body>
...
我们的设置已经正确地与角色一起工作。我们通过遵循所有最佳实践并使用可重用的角色和模板设置了 Ansible playbook。这就是设计强大 Ansible playbook 的正确方法。
总结
在本章中,我们从实际操作的角度讨论了 Ansible 及其核心功能。我们首先理解了 CaC,了解了 Ansible 和 Ansible 架构,安装了 Ansible,理解了 Ansible 模块、任务和 playbooks,并应用了第一个 Ansible 配置。然后,我们通过 Ansible 变量、Jinja2 模板和角色促进了可重用性,并根据可重用性重新组织了我们的配置。我们还沿途探讨了几项最佳实践。
在下一章中,我们将结合 Terraform 和 Ansible 来启动一些有用的内容,并了解 HashiCorp 的 Packer 来创建不可变基础设施。
问题
-
最佳实践是尽量避免使用
command和shell模块。(对/错) -
别名有助于保持清单的通用性。(对/错)
-
ansible-playbook命令的作用是什么?A. 它在清单上运行临时任务。
B. 它在清单上运行一系列任务。
C. 它应用与 Playbook 配置的任务和剧本。
D. 它会销毁从托管节点获取的配置。
-
以下哪种技术有助于在 Ansible 配置中建立可重用性?(选择三个)
A. 使用变量。
B. 使用 Jinja2 模板。
C. 使用角色。
D. 使用任务。
-
在命名角色时,我们应该考虑什么?(选择两个)
A. 尽可能精确地命名角色。
B. 在思考角色时,考虑服务而不是整个堆栈。
C. 为角色使用通用名称。
-
如果变量的值可能会更改,应该在角色中的哪个目录中定义变量?
A.
defaultsB.
vars -
当与处理程序关联的任务的输出是什么时,处理程序会触发?
A.
SUCCESSB.
CHANGEDC.
FAILED -
SUCCESS状态表示任务未检测到任何更改的配置吗?(是/否) -
库存管理的最佳实践是什么?(选择三个)
A. 对每个环境使用单独的清单。
B. 按功能分组清单。
C. 使用别名。
D. 将清单文件保存在中央位置。
答案
-
True
-
True
-
C
-
A, B, 和 C
-
A, B
-
A
-
B
-
True
-
A, B, 和 C
第十章:使用 Packer 实现不可变基础设施
在上一章中,我们探讨了使用 Ansible 进行配置管理及其核心概念。我们还在 第八章 使用 Terraform 实现基础设施即代码 (IaC) 中讨论了 Terraform 和 IaC。本章中,我们将介绍使用这两种工具以及另一个工具——Packer——来配置基础设施。借助这三种工具,我们将在 Azure 上启动一个可扩展的Linux、Apache、MySQL、PHP(LAMP)堆栈。
在本章中,我们将涵盖以下主要内容:
-
使用 HashiCorp 的 Packer 实现不可变基础设施
-
创建 Apache 和 MySQL playbook
-
使用 Packer 和 Ansible 提供程序构建 Apache 和 MySQL 镜像
-
使用 Terraform 创建所需的基础设施
技术要求
你需要一个有效的 Azure 订阅才能完成本章的练习。目前,Azure 正在提供为期 30 天的免费试用,并赠送 200 美元的免费额度;请在 azure.microsoft.com/en-in/free 注册。
你还需要克隆以下 GitHub 仓库以完成部分练习:
github.com/PacktPublishing/Modern-DevOps-Practices-2e
运行以下命令,将仓库克隆到你的主目录中,并使用 cd 进入 ch10 目录以访问所需的资源:
$ git clone https://github.com/PacktPublishing/Modern-DevOps-Practices-2e.git \
modern-devops
$ cd modern-devops/ch10
你还需要在系统上安装Terraform和Ansible。有关安装和设置 Terraform 和 Ansible 的更多详细信息,请参阅 第八章 使用 Terraform 实现基础设施即代码 (IaC) 和 第九章 使用 Ansible 进行配置管理。
使用 HashiCorp 的 Packer 实现不可变基础设施
假设你是一本书的作者,需要对现有版本进行更改。当你需要进行修改,比如改进内容、修正问题并确保书籍内容是最新时,你不会直接编辑现有的书籍。相反,你会创建一个新的版本,加入所需的更新,同时保持现有版本不变,就像这本书的新版一样。这一概念与不可变基础设施相契合。
在 IT 和系统管理中,不可变基础设施是一种策略,其中,你不会对现有的服务器或虚拟机(VMs)进行修改,而是生成具有所需配置的全新实例。这些新实例会替代旧实例,而不是修改它们,类似于当你想要进行更改时,创建一本新版本的书籍。
其工作原理如下:
-
从零开始构建:当你需要更新基础设施的一部分时,避免直接对现有的服务器或机器进行修改。相反,你会从一个预先建立的模板(镜像)中创建新的实例,并包含更新的配置。
-
不可就地修改:就像不编辑现有的书籍一样,你应避免对当前服务器进行就地修改。这种做法减少了不可预见的变化或配置不一致的风险。
-
一致性:不可变基础设施确保每个服务器或实例都是相同的,因为它们都源自相同的模板。这种统一性对于确保可靠性和可预测性非常重要。
-
滚动更新:当需要实施更新时,你会以受控的方式系统地用新实例替换旧实例。这将最小化停机时间和潜在风险。
-
可扩展性:通过按需生成新实例,扩展基础设施变得轻松自如。这类似于在需求激增或事物过时时发布新版本的书籍。
-
回滚和恢复:如果更新过程中出现问题,你可以通过从已知的良好模板重新创建实例,迅速恢复到之前的版本。
因此,将不可变基础设施视为通过创建新的、改进的实例来维护基础设施的一种方式,而不是试图修订或修改现有的实例。这种方法提升了 IT 环境中的一致性、可靠性和可预测性。
为了进一步理解这一点,让我们考虑通过 Terraform 和 Ansible 设置应用程序的传统方法。我们会使用 Terraform 启动基础设施,然后使用 Ansible 在其上应用相关的配置。这就是我们在上一章所做的。虽然这是可行的方法,许多企业都在使用它,但有一种更好的方法,可以通过现代 DevOps 方法和不可变基础设施来实现。
不可变基础设施是一个突破性的概念,它的出现是因为可变基础设施所带来的问题。在可变基础设施的方法中,我们通常会在原地更新服务器。因此,当我们使用 Ansible 安装 Apache 并进一步自定义时,我们遵循的是可变过程。我们可能需要定期更新服务器、打补丁、将 Apache 升级到新版本,或更新应用程序代码。
这种方法的问题在于,虽然我们可以使用 Ansible(或类似工具,如Puppet、Chef和SaltStack)很好地管理它,但问题始终存在,即我们在生产环境中进行实时更改,这可能因各种原因出现问题。更糟糕的是,这可能会更新我们最初没有预见到或测试过的内容。我们也可能最终处于部分升级状态,且此状态可能难以回滚。
借助云提供的可扩展基础设施,你可以拥有一个动态的横向扩展模型,虚拟机根据流量进行扩展。因此,你可以实现最佳的基础设施利用率——用最少的投入获得最大的回报!传统方法的问题在于,即使我们使用 Ansible 将配置应用到新机器上,准备好镜像的速度仍然较慢。因此,扩展并不理想,特别是对于流量突增的情况。
不可变基础设施通过采用与我们在容器中使用的相同方法来帮助你解决这些问题——通过现代的 DevOps 工具和实践将配置直接烘焙到操作系统镜像中。不可变基础设施通过替换现有虚拟机而不是在原地进行更新来帮助你将经过测试的配置部署到生产环境。它启动更快,回滚也更容易。你还可以通过这种方法对基础设施变更进行版本管理。
HashiCorp 提供了一套出色的与基础设施和配置管理相关的 DevOps 产品。HashiCorp 提供 Packer 来帮助你通过直接将配置烘焙到虚拟机镜像中,从而创建不可变基础设施,而不是先使用通用的操作系统镜像创建虚拟机,再后续进行自定义的慢速过程。它的工作原理与 Docker 用于烘焙容器镜像的原理类似;也就是说,你定义一个模板(配置文件),指定源镜像、所需配置以及设置镜像上软件所需的任何提供步骤。然后,Packer 会通过创建一个临时实例来构建镜像,应用已定义的配置,并捕获机器镜像以供重复使用。
Packer 提供以下一些关键功能:
-
多平台支持:Packer 基于插件架构,因此可以用于为许多不同的云平台和本地平台创建虚拟机镜像,如 VMware、Oracle VirtualBox、Amazon EC2、Azure 的 ARM、Google Cloud Compute 以及 Docker 或其他容器运行时的容器镜像。
-
自动化:Packer 自动化镜像创建,消除了手动构建镜像的工作。它还帮助你实现多云战略,因为你可以使用单一配置为各种平台构建镜像。
-
促进 GitOps:Packer 配置是机器可读的,并且以 HCL 或 JSON 格式编写,因此可以轻松与代码一起存放。因此,这促进了 GitOps。
-
与其他工具的集成:Packer 与其他 HashiCorp 工具(如 Terraform 和 Vagrant)集成良好。
Packer 使用一个临时虚拟机来定制镜像。以下是 Packer 在构建自定义镜像时遵循的过程:
-
你从 Packer 配置的 HCL 文件开始,定义你想要启动的基础镜像以及构建镜像的地方。你还需要定义用于构建自定义镜像的提供者,如 Ansible,并指定要使用的剧本。
-
当你运行 Packer 构建时,Packer 使用配置文件中的细节,从基础镜像创建构建虚拟机,运行配置工具进行定制,关闭构建虚拟机,拍摄快照,并将其保存为磁盘镜像。最后,它将镜像保存在镜像仓库中。
-
你可以使用 Terraform 或其他工具,从自定义镜像构建虚拟机。
下图详细解释了该过程:

图 10.1 – Packer 构建过程
结果是你的应用程序启动迅速,扩展性非常好。对于配置中的任何更改,使用 Packer 和 Ansible 创建一个新的磁盘镜像,然后通过 Terraform 将更改应用到你的资源上。Terraform 会停止旧的虚拟机并启动新的虚拟机,应用新的配置。如果你能将其与容器部署工作流联系起来,你会更好地理解这一点。这就像在虚拟机世界中使用容器工作流一样!但不可变基础设施适合所有人吗?让我们来理解它最适合的场景。
何时使用不可变基础设施
决定切换到不可变基础设施是很困难的,特别是当你的运维团队将服务器视为宠物时。大多数人对于删除现有服务器并为每次更新创建新服务器的想法感到疑虑重重。嗯,当你第一次提出这个想法时,你需要做很多说服工作。然而,这并不意味着你必须使用不可变基础设施才能做好 DevOps。最终取决于你的使用场景。
让我们通过分析每种方法的优缺点来更好地理解它们。
可变基础设施的优点
我们先从可变基础设施的优点开始:
-
如果管理得当,可变基础设施的升级和变更速度较快。安全补丁的应用也更为迅速。
-
它更易于管理,因为我们不必担心为每次更新构建整个虚拟机镜像并重新部署它。
可变基础设施的缺点
接下来,让我们看看可变基础设施的缺点:
-
它最终会导致配置漂移。当人们开始在服务器上手动进行更改并且不使用配置管理工具时,之后你很难知道服务器在某个特定时间点上的状态。然后,你将不得不开始依赖快照。
-
在可变基础设施中,无法进行版本控制,回滚更改也很麻烦。
-
由于技术问题,例如网络不稳定、apt 仓库无响应等,可能会出现部分更新的情况。
-
由于更改直接应用到生产环境中,因此存在一定风险。你也有可能陷入一个难以排查的意外状态。
-
由于配置漂移,无法保证当前的配置与版本控制中记录的配置一致。因此,从零开始构建新服务器可能需要手动干预和全面测试。
同样,让我们看看不可变基础设施的优缺点。
不可变基础设施的优点
不可变基础设施的优点如下:
-
它消除了配置漂移,因为一旦部署了基础设施,基础设施就不能改变,任何更改都应通过 CI/CD 流程进行。
-
它对 DevOps 友好,因为每个构建和部署过程本质上遵循现代 DevOps 实践。
-
它使得离散版本控制成为可能,因为从镜像构建生成的每个镜像都可以进行版本控制并保存在镜像仓库中。这使得推出和回滚变得更加简单,并促进现代 DevOps 实践,如金丝雀和蓝绿部署以及 A/B 测试。
-
镜像是预构建和经过测试的,因此我们总是从不可变基础设施中获得可预测的状态。因此,我们从生产实施中减少了很多风险。
-
它有助于云上的水平扩展,因为您现在可以从预构建的镜像创建服务器,使得新的虚拟机启动更快且准备就绪。
不可变基础设施的缺点
不可变基础设施的缺点如下:
-
构建和部署不可变基础设施有些复杂,并且增加更新和管理紧急修复的速度比较慢。
-
生成和管理 VM 镜像存在存储和网络开销
因此,当我们看了两种方法的优缺点之后,最终取决于您当前如何进行基础设施管理以及您的最终目标。不可变基础设施有巨大的好处,因此,每个现代 DevOps 工程师都应该理解并尽可能实现它。然而,技术和流程约束阻止了人们的尝试 - 虽然一些约束与技术堆栈有关,但大多数仅与流程和官僚主义有关。不可变基础设施在需要一致可重现和异常可靠的部署时尤为有利。这种方法通过重建整个环境而不是调整现有元素,最小化了配置漂移的风险,并简化了更新过程。在微服务架构、容器编排以及需要快速扩展和能够回滚更改的场景中,特别有优势。
我们都知道 DevOps 不仅仅关乎工具,而是一种应该从高层发源的文化变革。如果不可能使用不可变基础设施,您总是可以在活跃服务器上使用像 Ansible 这样的配置管理工具。这在一定程度上使事物变得可管理。
现在,继续讲解 Packer,让我们看看如何安装它。
安装 Packer
您可以在各种平台上以多种方式安装 Packer。请参考developer.hashicorp.com/packer/downloads。由于 Packer 作为apt包可用,请使用以下命令在 Ubuntu Linux 上安装 Packer:
$ wget -O- https://apt.releases.hashicorp.com/gpg | sudo \
gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
$ echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] \
https://apt.releases.hashicorp.com $(lsb_release -cs) main" | \
sudo tee /etc/apt/sources.list.d/hashicorp.list
$ sudo apt update && sudo apt install -y packer
为了验证安装情况,请运行以下命令:
$ packer --version
1.9.2
如我们所见,Packer 安装成功。我们可以继续进行我们的下一个目标活动——创建 playbook。
创建 Apache 和 MySQL playbook
由于我们本章的目标是启动一个可扩展的 LAMP 堆栈,因此我们必须首先定义将在构建 VM 上运行的 Ansible playbook。我们已经在第九章,“使用 Ansible 的配置管理”中为 Apache 和 MySQL 创建了一些角色。我们将在此设置中使用相同的角色。
因此,我们将在 ch10 目录中拥有以下目录结构:
├── ansible
│ ├── dbserver-playbook.yaml
│ ├── roles
│ │ ├── apache
│ │ ├── common
│ │ └── mysql
│ └── webserver-playbook.yaml
├── packer
│ ├── dbserver.pkr.hcl
│ ├── plugins.pkr.hcl
│ ├── variables.pkr.hcl
│ ├── variables.pkrvars.hcl
│ └── webserver.pkr.hcl
└── terraform
├── main.tf
├── outputs.tf
├── terraform.tfvars
└── vars.tf
我们在 ansible 目录中有两个 playbook——webserver-playbook.yaml 和 dbserver-playbook.yaml。让我们分别看看它们,了解如何为 Ansible 编写 playbook。
webserver-playbook.yaml 文件内容如下:
---
- hosts: default
become: true
roles:
- common
- apache
dbserver-playbook.yaml 文件内容如下:
---
- hosts: default
become: true
roles:
- common
- mysql
如我们所见,两个 playbook 的 hosts 都设置为 default。这是因为我们不会为此 playbook 定义清单。相反,Packer 将使用构建 VM 来构建镜像并动态生成清单。
注意
Packer 还会忽略任务中的任何 remote_user 属性,并使用 Ansible provisioner 配置中的用户。
如我们在上一章中已经测试过此配置,现在我们需要做的就是定义 Packer 配置,接下来让我们在下一章节中进行操作。
使用 Packer 和 Ansible provisioner 构建 Apache 和 MySQL 镜像
现在,我们将使用 Packer 创建 Apache 和 MySQL 镜像。在定义 Packer 配置之前,我们有几个前提条件,以允许 Packer 构建自定义镜像。
前提条件
我们必须为 Packer 创建一个 Azure 服务主体,以便它与 Azure 进行交互并构建镜像。
首先,使用以下命令通过 Azure CLI 登录到你的 Azure 帐户:
$ az login
现在,使用以下命令将订阅设置为我们从 az login 命令响应中获取的订阅 ID,并将其存储为环境变量:
$ export SUBSCRIPTION_ID=<SUBSCRIPTION_ID>
接下来,让我们使用以下命令设置订阅 ID:
$ az account set --subscription="${SUBSCRIPTION_ID}"
然后,使用以下命令创建具有贡献者访问权限的服务主体:
$ az ad sp create-for-rbac --role="Contributor" \
--scopes="/subscriptions/${SUBSCRIPTION_ID}"
{"appId": "00000000-0000-0000-0000-00000", "name": "http://azure-
cli-2021-01-07-05-59-24", "password": "xxxxxxxxxxxxxxxxxxxxxxxx", "tenant": "00000000-
0000-0000-0000-0000000000000"}
我们已经成功创建了服务主体。响应的 JSON 包含了 appId、password 和 tenant 值,我们将在接下来的章节中使用这些值。
注意
你还可以重用我们在第八章,“使用 Terraform 进行基础设施即代码 (IaC)”中创建的服务主体。
现在,让我们继续在 packer/variables.pkrvars.hcl 文件中设置这些变量的值,具体内容如下:
client_id = "<VALUE_OF_APP_ID>"
client_secret = "<VALUE_OF_PASSWORD>"
tenant_id = "<VALUE_OF_TENANT>"
subscription_id = "<SUBSCRIPTION_ID>"
我们将在 Packer 构建中使用变量文件。我们还需要一个资源组来存储构建的镜像。
要创建资源组,请运行以下命令:
$ az group create -n packer-rg -l eastus
现在,让我们继续定义 Packer 配置。
定义 Packer 配置
Packer 允许我们在 JSON 和 HCL 文件中定义配置。由于 JSON 已被弃用且 HCL 是推荐格式,因此我们将使用 HCL 来定义 Packer 配置。
要访问本节的资源,请切换到以下目录:
$ cd ~/modern-devops/ch10/packer
我们将在 packer 目录中创建以下文件:
-
variables.pkr.hcl:包含我们在应用配置时使用的变量列表 -
plugins.pkr.hcl:包含 Packer 插件配置 -
webserver.pkr.hcl:包含构建 web 服务器镜像的 Packer 配置 -
dbserver.pkr.hcl:包含构建dbserver镜像的 Packer 配置 -
variables.pkrvars.hcl:包含variables.pkr.hcl文件中定义的 Packer 变量的值
variables.pkr.hcl 文件包含以下内容:
variable "client_id" {
type = string
}
variable "client_secret" {
type = string
}
variable "subscription_id" {
type = string
}
variable "tenant_id" {
type = string
}
variables.pkr.hcl 文件定义了一个用户变量列表,我们可以在 Packer 配置的 source 和 build 块中使用。我们定义了四个字符串变量——client_id、client_secret、tenant_id 和 subscription_id。我们可以通过使用在上一节中定义的 variables.pkrvars.hcl 变量文件来传递这些变量的值。
提示
始终通过外部变量提供敏感数据,如变量文件、环境变量或秘密管理工具,如 HashiCorp 的 Vault。绝不应将敏感信息与代码一起提交。
plugins.pkr.hcl 文件包含以下块:
packer:此部分定义了 Packer 的通用配置。在此案例中,我们定义了构建镜像所需的插件。这里定义了两个插件——ansible 和 azure。插件包含 source 和 version 属性,包含与技术组件交互所需的一切:
packer {
required_plugins {
ansible = {
source = "github.com/hashicorp/ansible"
version = "=1.1.0"
}
azure = {
source = "github.com/hashicorp/azure"
version = "=1.4.5"
}
}
}
webserver.pkr.hcl 文件包含以下几个部分:
source:source块包含我们用于构建虚拟机的配置。由于我们正在构建一个azure-arm镜像,我们将源定义如下:
source "azure-arm" "webserver" {
client_id = var.client_id
client_secret = var.client_secret
image_offer = "UbuntuServer"
image_publisher = "Canonical"
image_sku = "18.04-LTS"
location = "East US"
managed_image_name = "apache-webserver"
managed_image_resource_group_name = "packer-rg"
os_type = "Linux"
subscription_id = var.subscription_id
tenant_id = var.tenant_id
vm_size = "Standard_DS2_v2"
azure-arm and consists of client_id, client_secret, tenant_id, and subscription_id, which helps Packer authenticate with the Azure API server. These attributes’ values are sourced from the variables.pkr.hcl file.
Tip
The managed image name can also contain a version. That will help you build a new image for every new version you want to deploy.
* `build`: The `build` block consists of `sources` and `provisioner` attributes. It contains all the sources we want to use, and the `provisioner` attribute allows us to configure the build VM to achieve the desired configuration. We’ve defined the following `build` block:
build {
sources = ["source.azure-arm.webserver"]
provisioner "ansible" {
playbook_file = "../ansible/webserver-playbook.yaml"
}
../ansible/webserver-playbook.yaml。
提示
你可以在 `build` 块中指定多个源,每个源可以是相同或不同类型。类似地,我们可以拥有多个提供者,它们会并行执行。因此,如果你想为多个云提供商构建相同的配置,可以为每个云提供商指定多个源。
类似地,我们定义了以下 `dbserver.pkr.hcl` 文件:
source "azure-arm" "dbserver" {
...
managed_image_name = "mysql-dbserver"
...
}
build {
sources = ["source.azure-arm.dbserver"]
provisioner "ansible" {
playbook_file = "../ansible/dbserver-playbook.yaml"
}
}
`source` 块的配置与 web 服务器相同,除了 `managed_image_name`。`build` 块也类似于 web 服务器,但它使用的是 `../ansible/dbserver-playbook.yaml` playbook。
现在,让我们看看 Packer 的工作流以及如何使用它来构建镜像。
构建镜像的 Packer 工作流
Packer 工作流包括两个步骤——`init` 和 `build`。
正如我们所知,Packer 使用插件与云服务商进行交互;因此,我们需要安装这些插件。为此,Packer 提供了`init`命令。
让我们使用以下命令初始化并安装所需的插件:
$ packer init .
Installed plugin github.com/hashicorp/ansible v1.1.0 in "~/.config/packer/plugins/github.
com/hashicorp/ansible/packer-plugin-ansible_v1.1.0_x5.0_linux_amd64"
Installed plugin github.com/hashicorp/azure v1.4.5 in "~/.config/packer/plugins/github.
com/hashicorp/azure/packer-plugin-azure_v1.4.5_x5.0_linux_amd64"
如我们所见,插件现在已安装。让我们继续构建镜像。
我们使用`build`命令通过 Packer 创建镜像。由于我们需要传递值给变量,我们将通过命令行参数指定变量值,如以下命令所示:
$ packer build -var-file="variables.pkrvars.hcl" .
Packer 将使用`webserver`和`dbserver`配置构建并行堆栈。
Packer 首先创建临时资源组来启动暂存的 VM:
==> azure-arm.webserver: Creating resource group ...
==> azure-arm.webserver: -> ResourceGroupName : 'pkr-Resource-Group-7dfj1c2iej'
==> azure-arm.webserver: -> Location : 'East US'
==> azure-arm.dbserver: Creating resource group ...
==> azure-arm.dbserver: -> ResourceGroupName : 'pkr-Resource-Group-11xqpuxsm3'
==> azure-arm.dbserver: -> Location : 'East US'
Packer 接着验证并部署部署模板,并获取暂存 VM 的 IP 地址:
==> azure-arm.webserver: Validating deployment template ...
==> azure-arm.webserver: Deploying deployment template ...
==> azure-arm.webserver: -> DeploymentName : 'pkrdp7dfj1c2iej'
==> azure-arm.webserver: Getting the VM's IP address ...
==> azure-arm.webserver: -> IP Address : '104.41.158.85'
==> azure-arm.dbserver: Validating deployment template ...
==> azure-arm.dbserver: Deploying deployment template ...
==> azure-arm.dbserver: -> DeploymentName : 'pkrdp11xqpuxsm3'
==> azure-arm.dbserver: Getting the VM's IP address ...
==> azure-arm.dbserver: -> IP Address : '40.114.7.11'
然后,Packer 使用 SSH 连接到暂存的 VM,并使用 Ansible 为其配置:
==> azure-arm.webserver: Waiting for SSH to become available...
==> azure-arm.dbserver: Waiting for SSH to become available...
==> azure-arm.webserver: Connected to SSH!
==> azure-arm.dbserver: Connected to SSH!
==> azure-arm.webserver: Provisioning with Ansible...
==> azure-arm.dbserver: Provisioning with Ansible...
==> azure-arm.webserver: Executing Ansible: ansible-playbook -e packer_build_
name="webserver" -e packer_builder_type=azure-arm --ssh-extra-args '-o IdentitiesOnly=yes'
-e ansible_ssh_private_key_file=/tmp/ansible-key328774773 -i /tmp/packer-provisioner-
ansible747322992 ~/ansible/webserver-playbook.yaml
==> azure-arm.dbserver: Executing Ansible: ansible-playbook -e packer_build_
name="dbserver" -e packer_builder_type=azure-arm --ssh-extra-args '-o IdentitiesOnly=yes'
-e ansible_ssh_private_key_file=/tmp/ansible-key906086565 -i /tmp/packer-provisioner-
ansible3847259155 ~/ansible/dbserver-playbook.yaml
azure-arm.webserver: PLAY RECAP *********************************************************
**
azure-arm.webserver: default: ok=7 changed=5 unreachable=0 failed=0 skipped=0 rescued=0
ignored=0
azure-arm.dbserver: PLAY RECAP ***********************************************************
azure-arm.dbserver: default: ok=11 changed=7 unreachable=0 failed=0 skipped=0 rescued=0
ignored=0
一旦 Ansible 运行完成,Packer 会获取磁盘详情,捕获镜像,并在我们在 Packer 配置中指定的资源组中创建机器镜像:
==> azure-arm.webserver: Querying the machine's properties
==> azure-arm.dbserver: Querying the machine's properties
==> azure-arm.webserver: Querying the machine's additional disks properties ...
==> azure-arm.dbserver: Querying the machine's additional disks properties ...
==> azure-arm.webserver: Powering off machine ...
==> azure-arm.dbserver: Powering off machine ...
==> azure-arm.webserver: Generalizing machine ...
==> azure-arm.dbserver: Generalizing machine ...
==> azure-arm.webserver: Capturing image ...
==> azure-arm.dbserver: Capturing image ...
==> azure-arm.webserver: -> Image ResourceGroupName: 'packer-rg'
==> azure-arm.dbserver: -> Image ResourceGroupName: 'packer-rg'
==> azure-arm.webserver: -> Image Name: 'apache-webserver'
==> azure-arm.webserver: -> Image Location: 'East US'
==> azure-arm.dbserver: -> Image Name: 'mysql-dbserver'
==> azure-arm.dbserver: -> Image Location: 'East US'
最后,它移除部署对象和它所创建的临时资源组:
==> azure-arm.webserver: Deleting Virtual Machine deployment and its attached resources...
==> azure-arm.dbserver: Deleting Virtual Machine deployment and its attached resources...
==> azure-arm.webserver: Cleanup requested, deleting resource group ...
==> azure-arm.dbserver: Cleanup requested, deleting resource group ...
==> azure-arm.webserver: Resource group has been deleted.
==> azure-arm.dbserver: Resource group has been deleted.
然后,它会提供它所生成的工件列表:
==> Builds finished. The artifacts of successful builds are:
--> azure-arm: Azure.ResourceManagement.VMImage:
OSType: Linux
ManagedImageResourceGroupName: packer-rg
ManagedImageName: apache-webserver
ManagedImageId: /subscriptions/Id/resourceGroups/packer-rg/providers/Microsoft.Compute/
images/apache-webserver
ManagedImageLocation: West Europe
OSType: Linux
ManagedImageResourceGroupName: packer-rg
ManagedImageName: mysql-dbserver
ManagedImageId: /subscriptions/Id/resourceGroups/packer-rg/providers/Microsoft.Compute/
images/mysql-dbserver
如果我们查看`packer-rg`资源组,我们会发现其中有两个 VM 镜像:

图 10.2 – Packer 自定义镜像
我们已经成功地用 Packer 构建了自定义镜像!
小贴士
一旦镜像在资源组中创建,就无法使用相同的托管镜像名称重新运行 Packer。这是因为我们不希望意外覆盖现有镜像。虽然你可以通过使用`-force`标志与`packer build`来覆盖它,但应该在镜像名称中包含版本号,以便在资源组中允许多个版本的镜像存在。例如,使用`apache-webserver-0.0.1`而不是`apache-webserver`。
现在是使用这些镜像并用它们创建我们基础设施的时候了。
使用 Terraform 创建所需的基础设施
我们的目标是构建一个可扩展的 LAMP 堆栈,因此我们将定义一个我们创建的`apache-webserver`镜像和一个使用`mysql-dbserver`镜像的虚拟机。VM 规模集是一个 VM 的自动扩展组,它将根据流量横向扩展和收缩,就像我们在 Kubernetes 中使用容器时做的一样。
我们将创建以下资源:
+ 一个名为`lamp-rg`的新资源组
+ 一个名为`lampvnet`的虚拟网络位于资源组内
+ 一个名为`lampsub`的子网,位于`lampvnet`内
+ 在子网内,我们创建了一个包含以下内容的`db-nic`:
+ 一个名为`db-nsg`的网络安全组
+ 一个名为`db`的虚拟机,使用自定义的`mysql-dbserver`镜像
+ 然后,我们创建一个包括以下内容的 VM 规模集:
+ 一个名为`webnp`的网络配置文件
+ 一个后端地址池
+ 一个名为`web-lb`的负载均衡器
+ 附加到`web-lb`的公共 IP 地址
+ 一个检查`80`端口健康状况的 HTTP 探针
以下图形说明了拓扑结构:

图 10.3 – 可扩展 LAMP 堆栈拓扑图
要访问本节的资源,请切换到以下目录:
$ cd ~/modern-devops/ch10/terraform
我们使用以下 Terraform 模板,`main.tf`,来定义配置。
我们首先定义 Terraform 提供程序:
terraform {
required_providers {
azurerm = {
source = "azurerm"
}
}
}
provider "azurerm" {
subscription_id = var.subscription_id
client_id = var.client_id
client_secret = var.client_secret
tenant_id = var.tenant_id
}
然后我们定义自定义镜像数据源,以便在我们的配置中使用它们:
data "azurerm_image" "websig" {
name = "apache-webserver"
resource_group_name = "packer-rg"
}
data "azurerm_image" "dbsig" {
name = "mysql-dbserver"
resource_group_name = "packer-rg"
}
然后我们定义资源组、虚拟网络和子网:
resource "azurerm_resource_group" "main" {
name = var.rg_name
location = var.location
}
resource "azurerm_virtual_network" "main" {
name = "lampvnet"
address_space = ["10.0.0.0/16"]
location = var.location
resource_group_name = azurerm_resource_group.main.name
}
resource "azurerm_subnet" "main" {
name = "lampsub"
resource_group_name = azurerm_resource_group.main.name
virtual_network_name = azurerm_virtual_network.main.name
address_prefixes = ["10.0.2.0/24"]
}
由于 Apache Web 服务器将位于网络负载均衡器后面,我们将定义负载均衡器和我们将附加到其上的公共 IP 地址:
resource "azurerm_public_ip" "main" {
name = "webip"
location = var.location
resource_group_name = azurerm_resource_group.main.name
allocation_method = "Static"
domain_name_label = azurerm_resource_group.main.name
}
resource "azurerm_lb" "main" {
name = "web-lb"
location = var.location
resource_group_name = azurerm_resource_group.main.name
frontend_ip_configuration {
name = "PublicIPAddress"
public_ip_address_id = azurerm_public_ip.main.id
}
tags = {}
}
然后我们将定义一个后端地址池,附加到负载均衡器,以便我们可以在 Apache 虚拟机规模集中使用它:
resource "azurerm_lb_backend_address_pool" "bpepool" {
loadbalancer_id = azurerm_lb.main.id
name = "BackEndAddressPool"
}
我们将在端口`80`上定义一个 HTTP 探测器,用于健康检查,并将其附加到负载均衡器:
resource "azurerm_lb_probe" "main" {
loadbalancer_id = azurerm_lb.main.id
name = "http-running-probe"
port = 80
}
我们需要在负载均衡器上设置`80`端口,并将其与后端池虚拟机的`80`端口关联。我们还将在此配置中附加 HTTP 健康检查探测器:
resource "azurerm_lb_rule" "lbnatrule" {
resource_group_name = azurerm_resource_group.main.name
loadbalancer_id = azurerm_lb.main.id
name = "http"
protocol = "Tcp"
frontend_port = 80
backend_port = 80
backend_address_pool_ids = [ azurerm_lb_backend_address_pool.bpepool.id ]
frontend_ip_configuration_name = "PublicIPAddress"
probe_id = azurerm_lb_probe.main.id
}
现在,我们将在资源组内使用自定义镜像和之前定义的负载均衡器来定义虚拟机规模集:
resource "azurerm_virtual_machine_scale_set" "main" {
name = "webscaleset"
location = var.location
resource_group_name = azurerm_resource_group.main.name
upgrade_policy_mode = "Manual"
sku {
name = "Standard_DS1_v2"
tier = "Standard"
capacity = 2
}
storage_profile_image_reference {
id=data.azurerm_image.websig.id
}
然后我们继续定义操作系统磁盘和数据磁盘:
storage_profile_os_disk {
name = ""
caching = "ReadWrite"
create_option = "FromImage"
managed_disk_type = "Standard_LRS"
}
storage_profile_data_disk {
lun = 0
caching = "ReadWrite"
create_option = "Empty"
disk_size_gb = 10
}
操作系统配置文件定义了我们如何登录到虚拟机:
os_profile {
computer_name_prefix = "web"
admin_username = var.admin_username
admin_password = var.admin_password
}
os_profile_linux_config {
disable_password_authentication = false
}
然后我们定义一个网络配置文件,将规模集与之前定义的负载均衡器关联:
network_profile {
name = "webnp"
primary = true
ip_configuration {
name = "IPConfiguration"
subnet_id = azurerm_subnet.main.id
load_balancer_backend_address_pool_ids = [azurerm_lb_backend_address_pool.bpepool.id]
primary = true
}
}
tags = {}
}
现在,进入数据库配置,我们将首先为数据库服务器定义一个网络安全组,以允许从虚拟网络内的内部服务器访问端口`22`和`3306`:
resource "azurerm_network_security_group" "db_nsg" {
name = "db-nsg"
location = var.location
resource_group_name = azurerm_resource_group.main.name
security_rule {
name = "SSH"
priority = 1001
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "22"
source_address_prefix = "*"
destination_address_prefix = "*"
}
security_rule {
name = "SQL"
priority = 1002
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "3306"
source_address_prefix = "*"
destination_address_prefix = "*"
}
tags = {}
}
然后我们定义一个网络接口卡(NIC)为虚拟机提供内部 IP 地址:
resource "azurerm_network_interface" "db" {
name = "db-nic"
location = var.location
resource_group_name = azurerm_resource_group.main.name
ip_configuration {
name = "db-ipconfiguration"
subnet_id = azurerm_subnet.main.id
private_ip_address_allocation = "Dynamic"
}
}
然后我们将网络安全组与网络接口关联:
resource "azurerm_network_interface_security_group_association" "db" {
network_interface_id = azurerm_network_interface.db.id
network_security_group_id = azurerm_network_security_group.db_nsg.id
}
最后,我们将使用自定义镜像定义数据库虚拟机:
resource "azurerm_virtual_machine" "db" {
name = "db"
location = var.location
resource_group_name = azurerm_resource_group.main.name
network_interface_ids = [azurerm_network_interface.db.id]
vm_size = var.vm_size
delete_os_disk_on_termination = true
storage_image_reference {
id = data.azurerm_image.dbsig.id
}
storage_os_disk {
name = "db-osdisk"
caching = "ReadWrite"
create_option = "FromImage"
managed_disk_type = "Standard_LRS"
}
os_profile {
computer_name = "db"
admin_username = var.admin_username
admin_password = var.admin_password
}
os_profile_linux_config {
disable_password_authentication = false
}
tags = {}
}
现在,既然我们已经定义了所需的一切,请填写`terraform.tfvars`文件中的必要信息,然后使用以下命令初始化我们的 Terraform 工作区:
$ terraform init
由于 Terraform 已成功初始化,请使用以下命令应用 Terraform 配置:
$ terraform apply
Apply complete! Resources: 13 added, 0 changed, 0 destroyed.
Outputs:
web_ip_addr = "40.115.61.69"
由于 Terraform 已应用配置并提供了负载均衡器 IP 地址作为输出,我们使用该地址访问 Web 服务器:

图 10.4 – LAMP 堆栈正常工作
当我们收到`数据库成功连接`消息时,我们看到配置成功!我们已成功使用 Packer、Ansible 和 Terraform 创建了一个可扩展的 LAMP 堆栈。它结合了*基础设施即代码(IaC)*、*配置即代码*、*不可变基础设施* 和现代 DevOps 实践,创建了一个无须人工干预的无缝环境。
总结
在本章中,我们介绍了使用 Packer 构建不可变基础设施。我们使用 Packer 配合 Ansible 提供程序构建了用于 Apache 和 MySQL 的自定义镜像。然后使用这些自定义镜像通过 Terraform 创建了一个可扩展的 LAMP 堆栈。本章向你介绍了现代 DevOps 的时代,在这个时代中,一切都实现了自动化。我们遵循相同的原则来构建和部署各种类型的基础设施,无论是容器还是虚拟机。在下一章中,我们将讨论 DevOps 中最重要的主题之一——**持续集成**。
问题
1. 不可变基础设施有助于避免配置漂移。(正确/错误)
1. 最佳实践是从外部变量(如环境变量)或像 HashiCorp 的 Vault 这样的秘密管理工具中获取敏感数据。(正确/错误)
1. 我们需要对现有的 playbook 做哪些修改,以便 Packer 能够使用它们?
A. 从当前工作目录中删除任何现有的 `ansible.cfg` 文件。
B. 从当前工作目录中删除任何主机文件。
C. 在 playbook 中将 `hosts` 属性更新为默认值。
D. 以上都不是。
1. 以下哪些是使用 Ansible 提供程序与 Packer 配合使用时的限制?(选择两个)
A. 你不能将 Jinja2 宏原样传递到 Ansible playbook 中。
B. 你不能在 Ansible playbook 中定义 `remote_user`。
C. 你不能在 Ansible playbook 中使用 Jinja2 模板。
D. 你不能在 Ansible playbook 中使用角色和变量。
1. 在命名托管镜像时,我们应考虑哪些因素?(选择两个)
A. 尽可能具体地命名镜像。
B. 将版本作为镜像的一部分。
C. 不要将版本作为镜像名称的一部分。相反,总是使用 `-force` 标志来构建 Packer。
1. 使用多个提供程序时,如何将配置应用于构建虚拟机?
A. 按照 HCL 文件中的顺序逐一执行
B. 并行
1. 我们可以使用一组 Packer 文件,在多个云环境中构建具有相同配置的镜像。(正确/错误)
1. 虚拟机规模集提供了哪些功能?(选择两个)
A. 它帮助你根据流量水平扩展虚拟机实例。
B. 它帮助你自动修复故障虚拟机。
C. 它帮助你进行金丝雀发布。
D. 以上都不是。
答案
1. 正确
1. 正确
1. C
1. A、B
1. A、B
1. B
1. 正确
1. A、B、C
第四部分:使用 GitOps 交付应用程序
本节是本书的核心内容,阐明了在云中有效实施现代 DevOps 的各种工具和技术。以 GitOps 作为核心指导原则,我们将探讨各种工具和技术,帮助我们不断地构建、测试、保护并将应用程序部署到开发、测试和生产环境中。
本部分包含以下章节:
-
第十一章,使用 GitHub Actions 和 Jenkins 的持续集成
-
第十二章,使用 Argo CD 的持续部署/交付
-
第十三章,确保和测试你的 CI/CD 流水线
第十一章:使用 GitHub Actions 和 Jenkins 进行持续集成
在前面的章节中,我们讨论了几个单独的工具,这些工具将帮助我们实现现代 DevOps 的多个方面。现在,是时候看看如何将我们学到的所有工具和概念结合起来,创建一个持续集成(CI)流水线了。首先,我们将介绍一个基于微服务的示例博客应用程序——Blog App,然后看看一些流行的开源和基于 SaaS 的工具,它们可以帮助我们快速启动 CI。我们将从GitHub Actions开始,然后转到Jenkins与Kaniko。对于每个工具,我们都会为 Blog App 实现 CI。我们会尽量保持实现与云平台无关。由于我们从一开始就使用了GitOps方法,因此这里也将使用相同的方法。最后,我们将讨论一些与构建性能相关的最佳实践。
在本章中,我们将涵盖以下主要主题:
-
自动化的重要性
-
示例基于微服务的博客应用程序介绍——Blog App
-
使用 GitHub Actions 构建 CI 流水线
-
在Kubernetes上可扩展的 Jenkins 与 Kaniko
-
使用触发器自动化构建
-
构建性能的最佳实践
技术要求
对于本章,你需要克隆以下 GitHub 仓库,以便进行一些练习:github.com/PacktPublishing/Modern-DevOps-Practices-2e。
运行以下命令,将仓库克隆到你的主目录,并cd进入ch11目录以访问所需的资源:
$ git clone https://github.com/PacktPublishing/Modern-DevOps-Practices-2e.git \
modern-devops
$ cd modern-devops/ch11
那么,让我们开始吧!
自动化的重要性
自动化就像是有一支高效的机器人团队在为你工作,毫不疲倦地处理重复性、耗时且容易出错的任务。让我们简化一下自动化的意义:
-
效率:把它想象成拥有一个神奇的助手,可以在你完成任务的时间里迅速完成。自动化加速了重复性任务的执行,处理数据和运行命令比人类快得多。
-
一致性:人类可能会感到疲倦或分心,导致任务执行的不一致性。自动化保证任务每次都能按照预定规则一致地完成。
-
准确性:自动化操作没有人类可能经历的疲劳或失误。它精确地遵循指令,最小化错误发生的可能性,从而避免可能带来高昂代价的后果。
-
规模:无论是管理一个系统还是一千个系统,自动化都能轻松扩展操作,而无需额外的人力资源。
-
节省成本:通过减少对人工的依赖,自动化在时间和人力资源上带来了显著的成本节省。
-
风险降低:某些任务,例如数据备份和安全检查,虽然至关重要,但可能被人类忽视或跳过。自动化确保这些任务得到持续执行,从而减少风险。
-
更快的响应:自动化能够实时检测并响应问题。例如,它可以自动重启崩溃的服务器,或在高流量期间调整资源分配,确保用户体验不间断。
-
资源分配:自动化日常任务解放了人力资源,使其可以集中精力处理更具战略性和创造性的工作,这些工作需要批判性思维和决策能力。
-
合规性:自动化执行并监控政策和法规的合规性,减少法律和监管方面的潜在问题。
-
数据分析:自动化迅速处理和分析大量数据,促进数据驱动的决策和洞察。
-
24/7 运营:自动化不知疲倦地全天候工作,确保持续运营和可用性。
-
适应性:自动化可以重新编程以适应不断变化的需求和环境,使其具有灵活性和面向未来的能力。
在技术领域,自动化是现代 IT 运营的基石,涵盖了从自动化软件部署到管理云资源和配置网络设备。它使组织能够简化流程、提高可靠性,并在快速变化的数字化环境中保持竞争力。
本质上,自动化类似于一个极其高效、无错误、全天候运作的劳动力,使个人和组织能够以更少的努力完成更多的工作。
为了从自动化中获益,项目管理职能正在迅速被稀释,软件开发团队正在转型为敏捷团队,以迭代的方式交付 Sprint。因此,如果有新的需求,我们不会等到整个需求签署完毕后才开始设计、开发、QA 等工作。而是将软件拆解为可操作的功能模块,并以较小的部分交付,以便迅速获得价值和客户反馈。这意味着快速的软件开发,减少失败的风险。
好吧,团队已经变得更加敏捷,开发软件的速度更快了。但在软件开发生命周期(SDLC)的过程中,许多任务仍然是手动进行的,比如一些团队只有在完成整个开发周期后才生成代码构建,并在之后发现大量的错误。追踪最初是什么原因导致问题变得非常困难。
如果你在将代码提交到源代码控制系统时,就能知道构建失败的原因怎么办?如果你能在构建执行时立即理解软件未通过某些测试怎么办?嗯,这就是 CI 的精髓。
持续集成(CI)是一个过程,开发人员频繁地将代码提交到源代码仓库,可能一天多次。后台的自动化工具可以检测这些提交,然后构建、运行一些测试,并提前告知你提交是否引发了问题。这意味着开发人员、测试人员、产品负责人、运维团队以及所有相关人员都会知道是什么引发了问题,开发人员可以迅速修复。这在软件开发中形成了一个反馈循环。过去我们在软件开发中有一个手动的反馈循环,但它非常缓慢。所以,要么你得等很久才能开始下一任务,要么你做错了事情直到发现时已经为时太晚,无法撤销之前所做的所有工作。这样就增加了之前所有工作的返工量。
众所周知,在 SDLC(软件开发生命周期)中,越早修复漏洞成本越低。因此,持续集成(CI)的目标是尽早在 SDLC 中提供代码质量的持续反馈。这可以为开发人员和组织节省大量时间和金钱,避免在大部分代码已经经过测试时还需要修复发现的漏洞。因此,CI 帮助软件开发团队更快地开发出更好的软件。
既然我们提到了敏捷开发,接下来简要讨论一下它与 DevOps 的比较。敏捷是一种工作方式,对于实现敏捷所需的工具、技术和自动化并未明确说明。DevOps 是敏捷思维的延伸,帮助你有效地实施敏捷。DevOps 高度关注自动化,力求在可能的情况下避免手动操作。它还鼓励软件交付的自动化,旨在加强或替代传统工具和框架。随着现代 DevOps 的出现,特定的工具、技术和最佳实践简化了开发人员、质量保证人员和运维人员的工作。现代公共云平台和 DevOps 为团队提供了即用型的动态基础设施,帮助企业减少上市时间,并构建可扩展、弹性强、性能优越的基础设施,确保企业的系统在最小的停机时间内持续运行。
在第一章介绍现代 DevOps 时,我们提到它通常应用于现代云原生应用程序。为了演示这一点,我构建了一个基于微服务的博客应用示例。我们将在本书的这一章和未来的章节中使用该应用,以确保使用现代 DevOps 工具和实践无缝开发和交付该应用。接下来我们将查看这个示例应用。
微服务架构博客应用介绍 – 博客应用
博客应用是一个基于现代微服务架构的博客 Web 应用,允许用户创建、管理和互动博客帖子。它既适用于作者,也适用于读者。用户可以使用他们的电子邮件地址注册该平台,并开始写博客帖子。读者可以公开查看由多个作者创建的所有博客帖子,登录用户还可以提供评论和评分。
该应用是用一个流行的基于 Python 的 Web 框架Flask编写的,并使用MongoDB作为数据库。该应用被拆分成多个微服务,用于用户、帖子、评论和评分管理。还有一个独立的前端微服务,供用户进行交互。让我们来看看每个微服务:
-
用户管理:用户管理微服务提供创建用户账户、更新个人资料(姓名和密码)和删除用户账户的接口。
-
帖子管理:帖子管理微服务提供创建、列出、获取、更新和删除帖子的接口。
-
评论管理:评论管理微服务允许用户在帖子上添加评论,并对其进行更新和删除。它在内部与评分管理微服务交互,以管理与评论一起提供的评分。
-
评分管理:评分管理微服务管理与特定评论相关联的帖子评分。该微服务在内部由评论管理微服务调用,并不会暴露给前端微服务。
-
前端:前端微服务是一个基于Bootstrap构建的 Python Flask 用户界面应用,为用户提供丰富的交互式界面。它允许用户注册、登录、查看帖子并在帖子之间导航、编辑帖子、添加和更新评论以及管理个人资料。该微服务通过 HTTP 请求与后端微服务无缝交互。
用户、帖子、评论和评分微服务与MongoDB数据库进行交互。
下图展示了各服务之间的交互关系:

图 11.1 – 博客应用服务与交互
如我们所见,单个微服务之间相互解耦,因此可以独立扩展。它也很健壮,因为如果某个特定微服务出现故障,应用的其他部分仍然可以继续工作。各个微服务可以作为独立的组件进行开发和部署,从而增强了应用的灵活性和可维护性。这个应用是利用微服务构建现代功能丰富的 Web 应用的一个优秀示例。
现在,让我们为这个应用实现持续集成(CI)。为了实现 CI,我们需要一个 CI 工具。在接下来的部分中,我们将介绍一些流行的工具以及你可以选择的选项。
使用 GitHub Actions 构建 CI 管道
GitHub Actions 是一款基于 SaaS 的工具,随GitHub提供。因此,当你创建 GitHub 仓库时,开箱即用即可访问此服务。因此,GitHub Actions 是适合 CI/CD 新手的最佳工具之一,特别适合那些想要快速入门的人。GitHub Actions 可以帮助你自动化任务、构建、测试和部署代码,甚至简化工作流程,极大地简化开发者的工作。
以下是 GitHub Actions 能为你做的事情:
-
CI:GitHub Actions 可以在你推送更改到仓库时自动构建和测试代码。这确保了你的代码始终无误,并准备好进行部署。
-
CD:你可以使用 GitHub Actions 将应用程序部署到各种托管平台,如 AWS、Azure 和 GCP。这使你能够快速高效地向用户交付更新。
-
工作流自动化:你可以使用 GitHub Actions 创建自定义工作流,自动化开发过程中的重复任务。例如,你可以自动标记和分配问题,在特定事件触发时启动构建,或向团队发送通知。
-
自定义脚本:GitHub Actions 允许你运行自定义脚本和命令,完全控制自动化任务。无论是需要编译代码、运行测试还是执行部署脚本,GitHub Actions 都能处理。
-
npm用于部署到流行的云提供商。你可以轻松地将这些操作集成到你的工作流中。 -
定时任务:你可以安排在特定时间或间隔运行某些操作。这对于生成报告、发送提醒或在非高峰时段进行维护等任务非常有用。
-
多平台支持:GitHub Actions 支持多种编程语言、操作系统和云环境,这意味着你可以轻松构建和部署面向不同平台的应用程序。
-
集成:GitHub Actions 与 GitHub 仓库无缝集成,使其成为开发环境的自然扩展。你可以直接在仓库中使用 YAML 文件定义工作流。
GitHub Actions 通过自动化日常任务、确保代码质量并简化软件开发生命周期(SDLC),彻底改变了开发人员的工作方式。它是提升生产力和保持高质量代码的团队和个人开发者的宝贵工具。
现在,让我们为我们的示例博客应用创建一个 CI 管道。博客应用由多个微服务组成,每个微服务都运行在单独的Docker容器中。我们还为每个微服务编写了单元测试,可以运行这些测试来验证代码更改。如果测试通过,构建就会通过;否则,它将失败。
要访问本节的资源,请cd进入以下目录:
$ cd ~/modern-devops/blog-app
该目录包含多个微服务,其结构如下:
.
├── frontend
│ ├── Dockerfile
│ ├── app.py
│ ├── app.test.py
│ ├── requirements.txt
│ ├── static
│ └── templates
├── posts
│ ├── Dockerfile
│ ├── app.py
│ ├── app.test.py
│ └── requirements.txt
├── ratings ...
├── reviews ...
└── users ...
frontend目录包含app.py(Flask 应用程序代码)、app.test.py(Flask 应用程序的单元测试)、requirements.txt(包含应用所需的所有 Python 模块)和Dockerfile。它还包括一些其他目录,供此应用的用户界面元素使用。
app.py、app.test.py、requirements.txt和Dockerfile文件。
所以,让我们从切换到posts目录开始:
$ cd posts
由于我们知道 Docker 本身符合 CI 标准,我们可以使用Dockerfile本身来运行测试。让我们来研究一下 posts 服务的 Dockerfile:
FROM python:3.7-alpine
ENV FLASK_APP=app.py
ENV FLASK_RUN_HOST=0.0.0.0
RUN apk add --no-cache gcc musl-dev linux-headers
COPY requirements.txt requirements.txt
RUN pip install -r requirements.txt
EXPOSE 5000
COPY . .
RUN python app.test.py
CMD ["flask", "run"]
这个Dockerfile以python:3.7-alpine基础镜像开始,安装依赖,并将代码复制到工作目录。它运行app.test.py单元测试,检查如果我们部署代码是否能正常工作。最后,CMD命令定义了一个flask run命令,当我们启动容器时执行。
让我们构建我们的Dockerfile,看看会得到什么:
$ docker build --progress=plain -t posts .
#4 [1/6] FROM docker.io/library/python:3.7-alpine
#5 [internal] load build context
#6 [2/6] RUN apk add --no-cache gcc musl-dev linux-headers
#7 [3/6] COPY requirements.txt requirements.txt
#8 [4/6] RUN pip install -r requirements.txt
#9 [5/6] COPY . .
#10 [6/6] RUN python app.test.py
#10 0.676 -------------------------------------------------
#10 0.676 Ran 8 tests in 0.026s
#11 exporting to image
#11 naming to docker.io/library/posts done
如我们所见,它构建了容器,执行了测试,并返回了Ran 8 tests in 0.026s和OK消息。因此,我们可以使用Dockerfile来构建和测试这个应用程序。我们在docker build命令中使用了--progress=plain参数。这是因为我们希望看到逐步的日志输出,而不是 Docker 将进度合并为一条消息(这现在是默认行为)。
现在,让我们来看看 GitHub Actions,以及我们如何自动化这一步骤。
创建一个 GitHub 仓库
在我们能够使用 GitHub Actions 之前,我们需要创建一个 GitHub 仓库。因为我们知道每个微服务可以独立开发,所以我们将它们放在单独的 Git 仓库中。对于本次练习,我们将只关注posts微服务,其余部分留给你作为练习。
为此,请访问github.com/new并创建一个新的仓库。为其起一个合适的名字。对于本次练习,我将使用mdo-posts。
创建完成后,使用以下命令克隆仓库:
$ git clone https://github.com/<GitHub_Username>/mdo-posts.git
然后,使用以下命令将目录切换到仓库目录,并将app.py、app.test.py、requirements.txt和Dockerfile文件复制到仓库目录中:
$ cd mdo-posts
$ cp ~/modern-devops/blog-app/posts/* .
现在,我们需要创建一个 GitHub Actions 工作流文件。我们将在下一部分进行操作。
创建 GitHub Actions 工作流
GitHub Actions 工作流是一个简单的 YAML 文件,包含了构建步骤。我们必须在仓库的.github/workflows目录下创建此工作流。我们可以使用以下命令执行此操作:
$ mkdir -p .github/workflows
我们将使用以下 GitHub Actions 工作流文件build.yaml进行本次练习:
name: Build and Test App
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Login to Docker Hub
id: login
run: docker login -u ${{ secrets.DOCKER_USER }} -p ${{ secrets.DOCKER_PASSWORD }}
- name: Build the Docker image
id: build
run: docker build . --file Dockerfile --tag ${{ secrets.DOCKER_USER }}/
mdo-posts:$(git rev-parse --short "$GITHUB_SHA")
- name: Push the Docker image
id: push
run: docker push ${{ secrets.DOCKER_USER }}/mdo-posts:$(git rev-parse --short
"$GITHUB_SHA")
该文件包含以下内容:
-
name:工作流的名称——在这种情况下是Build and Test App。 -
on:这描述了此工作流何时运行。在这种情况下,如果在main分支上发送了push或pull请求,它将会运行。 -
jobs:GitHub Actions 工作流包含一个或多个作业,默认情况下,它们会并行运行。此属性包括所有作业。 -
jobs.build:这是一个执行容器构建的作业。 -
jobs.build.runs-on:这描述了构建作业将在哪个环境中运行。我们在这里指定了ubuntu-latest。这意味着该作业将在 Ubuntu 虚拟机上运行。 -
jobs.build.steps:这包含在作业中按顺序运行的步骤。构建作业由四个构建步骤组成:checkout,它将从您的仓库检出代码;login,它将登录 Docker Hub;build,它将在您的代码上运行 Docker 构建;以及push,它将您的 Docker 镜像推送到 Docker Hub。请注意,我们使用 Git 提交的 SHA 来标记镜像。这将构建与提交关联,使 Git 成为唯一的真实来源。 -
jobs.build.steps.uses:这是第一步,描述了您将在作业中运行的一个 action。Actions 是可以在管道中执行的可重用代码块。在此情况下,它运行checkoutaction。它将从当前触发 action 的分支中检出代码。
提示
始终使用带版本的 actions。这将防止您的构建因后续版本与管道不兼容而中断。
-
jobs.build.steps.name:这是您构建步骤的名称。 -
jobs.build.steps.id:这是您构建步骤的唯一标识符。 -
jobs.build.steps.run:这是它在构建步骤中执行的命令。
工作流中还包含 ${{ }} 内的变量。我们可以在工作流中定义多个变量,并在后续步骤中使用它们。在此案例中,我们使用了两个变量 – ${{ secrets.DOCKER_USER }} 和 ${{ secrets.DOCKER_PASSWORD }}。这些变量来自 GitHub secrets。
提示
最佳实践是使用 GitHub secrets 来存储敏感信息。切勿将这些详细信息直接存储在包含代码的仓库中。
您必须使用以下 URL 在仓库中定义两个 secrets:https://github.com/<your_user>/mdo-posts/settings/secrets/actions。
在仓库中定义两个 secrets:
DOCKER_USER=<Your Docker Hub username>
DOCKER_PASSWORD=<Your Docker Hub password>
现在,让我们通过以下命令将 build.yml 文件移到 workflows 目录:
$ mv build.yml .github/workflows/
现在,我们准备将代码推送到 GitHub。运行以下命令将更改提交并推送到您的 GitHub 仓库:
$ git add --all
$ git commit -m 'Initial commit'
$ git push
现在,前往 https://github.com/<your_user>/mdo-posts/actions。您应该会看到类似以下内容:

图 11.2 – GitHub Actions
如我们所见,GitHub 使用我们的工作流文件运行了一个构建,并且已经构建了代码并将镜像推送到Docker Hub。访问您的 Docker Hub 账户时,您应该能在账户中看到您的镜像:

图 11.3 – Docker Hub 镜像
现在,让我们尝试以某种方式破坏我们的代码。假设您的团队中的某个人更改了 app.py 代码,并且在 create_post 响应中不再返回 post,而是返回 pos。我们来看看在这种情况下会发生什么。
对 app.py 文件中的 create_post 函数进行以下更改:
@app.route('/posts', methods=['POST'])
def create_post():
...
return jsonify({'pos': str(inserted_post.inserted_id)}), 201
现在,使用以下命令将代码提交并推送到 GitHub:
$ git add --all
$ git commit -m 'Updated create_post'
$ git push
现在,前往 GitHub Actions,查找最新的构建。你将看到该构建会出错,并给出以下输出:

图 11.4 – GitHub Actions – 构建失败
正如我们所看到的,app.test.py 执行失败。这是由于测试用例失败,错误信息为 AssertionError: 'post' not found in {'pos': '60458fb603c395f9a81c9f4a'}。由于预期的 post 键未在输出 {'pos': '60458fb603c395f9a81c9f4a'} 中找到,测试用例失败,正如下面的截图所示:

图 11.5 – GitHub Actions – 测试失败
我们发现错误是在有人将有问题的代码推送到 Git 仓库时发生的。你现在能看到 CI 的好处了吗?
现在,让我们修复代码并再次提交代码。
修改 app.py 中的 create_post 函数,使其如下所示:
@app.route('/posts', methods=['POST'])
def create_post():
...
return jsonify({'post': str(inserted_post.inserted_id)}), 201
然后,使用以下命令将代码 commit 并 push 到 GitHub:
$ git add --all
$ git commit -m 'Updated create_post'
$ git push
这一次,构建将成功:

图 11.6 – GitHub Actions – 构建成功
你看到这有多简单吗?我们很快就开始了 CI,并在幕后实现了 GitOps,因为构建和测试代码所需的配置文件也与应用程序代码一起存放。
作为练习,针对 reviews、users、ratings 和 frontend 微服务重复相同的过程。你可以通过操作它们来理解其工作原理。
并不是每个人都使用 GitHub,因此对于他们来说,SaaS 提供的服务可能不是一个选择。因此,在下一节中,我们将看看最流行的开源 CI 工具:Jenkins。
在 Kubernetes 上可扩展的 Jenkins 与 Kaniko
想象一下,你正在运行一个车间,在这里你构建各种各样的机器。在这个车间里,你有一个神奇的传送带,叫做 Jenkins,用来组装这些机器。但是,为了让你的车间更加高效和适应性更强,你还有一支叫做 Kaniko 的小型机器人团队,帮助构建每台机器的各个部件。让我们把这个车间类比与技术世界进行对比:
-
可扩展的 Jenkins:Jenkins 是一个广泛使用的自动化服务器,有助于自动化各种任务,特别是与构建、测试和部署软件相关的任务。“可扩展的 Jenkins”意味着以一种配置 Jenkins 的方式,使其能够高效处理日益增长的工作负载,就像一个宽敞的车间,能够生产大量的机器。
-
Kubernetes:将 Kubernetes 想象成车间经理。它是一个编排平台,自动化部署、扩展和管理容器化应用程序的过程。Kubernetes 确保 Jenkins 和一队小型机器人(Kaniko)无缝协作,并能适应变化的需求。
-
Kaniko:Kaniko 相当于你的微型机器人团队。在容器化的背景下,Kaniko 是一个帮助构建容器镜像的工具,就像机器的各个部件一样。Kaniko 的特别之处在于,它无需对 Docker 守护进程有高级访问权限就能完成这一任务。与传统的容器构建工具不同,Kaniko 不需要特权,使得它成为构建容器时,尤其是在 Kubernetes 环境中,更安全的选择。
现在,让我们将这三种工具结合起来,看看我们能取得什么成果:
-
大规模构建容器:你的车间可以同时制造多个机器,这要归功于 Jenkins 和小型机器人。同样地,借助基于 Kubernetes 的 Jenkins 与 Kaniko,你可以高效并行地构建容器镜像。这种可扩展性在现代应用程序开发中至关重要,因为容器化在其中扮演了重要角色。
-
隔离性和安全性:就像 Kaniko 的小型机器人在受控环境中运行一样,Kaniko 确保容器镜像的构建在 Kubernetes 集群中以隔离且安全的方式进行。这意味着不同的团队或项目可以使用 Jenkins 和 Kaniko 而不会相互干扰各自的容器构建过程。
-
一致性和自动化:就像传送带(Jenkins)保证了机器组装的一致性一样,Kubernetes 上的 Jenkins 与 Kaniko 结合确保了容器镜像构建的一致性。自动化是这一配置的核心,简化了为应用程序构建和管理容器镜像的过程。
总结来说,基于 Kubernetes 的可扩展 Jenkins 与 Kaniko 的结合,指的是在 Kubernetes 环境中设置 Jenkins,通过 Kaniko 高效构建和管理容器镜像的实践。它能够实现容器镜像的持续、一致且安全的构建,完美契合现代软件开发的工作流。
因此,将 Jenkins、Kubernetes 和 Kaniko 类比为一个工作车间,生动地展示了这一配置如何简化容器镜像的构建,使其在当代软件开发实践中具备可扩展性、高效性和安全性。现在,让我们更深入地了解 Jenkins。
Jenkins是市场上最受欢迎的 CI 工具。它是开源的,安装简单,运行顺畅。Jenkins 是一个基于 Java 的工具,采用插件化架构,设计用于支持多种集成,例如与源代码管理工具如Git、SVN和Mercurial的集成,或与流行的构件库如Nexus和Artifactory的集成。它还与知名的构建工具如Ant、Maven和Gradle兼容,此外,还支持标准的 Shell 脚本和 Windows 批处理文件执行。
Jenkins 遵循控制器-代理模型。尽管从技术上讲,你可以在控制器机器本身上运行所有构建,但将 CI 构建任务分配给你网络中的其他服务器,形成分布式架构,显然更有意义。这样做可以避免控制器机器的过载。你可以用它来存储构建配置和其他管理数据,并管理整个 CI 构建集群,类似于以下图示的方式:

图 11.7 – 可扩展的 Jenkins
在上面的图示中,多个静态的 Jenkins 代理连接到 Jenkins 控制器。现在,这种架构运行良好,但它的可扩展性不足。现代 DevOps 强调资源的利用率,所以我们只希望在需要构建时才部署代理机器。因此,自动化构建流程,在需要时自动部署代理机器,是更好的做法。当部署新虚拟机时,这可能显得有些过头,因为即便是使用 Packer 制作的预构建镜像,配置新虚拟机也需要几分钟时间。更好的替代方案是使用容器。
Jenkins 与 Kubernetes 集成得相当好,允许你在 Kubernetes 集群上运行构建。这样,每当你在 Jenkins 上触发构建时,Jenkins 会指示 Kubernetes 创建一个新的代理容器,容器将连接到控制器机器并在其中运行构建。这就是最好的按需构建。以下图示详细展示了这一过程:

图 11.8 – 可扩展的 Jenkins CI 工作流
这听起来很棒,我们可以继续运行这个构建,但这种方法也存在问题。我们必须理解,Jenkins 控制器和代理作为容器运行,而不是完整的虚拟机。因此,如果我们想在容器内运行 Docker 构建,我们必须以特权模式运行该容器。这不是安全的最佳实践,而且你的管理员应该已经关闭了这个功能。这是因为以特权模式运行容器会将主机文件系统暴露给容器。一个能够访问你容器的黑客将拥有完全的访问权限,从而可以在系统中做任何事情。
为了解决这个问题,你可以使用如Kaniko这样的容器构建工具。Kaniko 是由 Google 提供的构建工具,帮助你在没有 Docker 守护进程的情况下构建容器,甚至不需要在容器中安装 Docker。这是运行构建的一个很好的方法,特别是在Kubernetes 集群中,能够创建一个可扩展的 CI 环境。它简单易用,不需要黑客手段,并且提供了一种安全的构建容器方式,正如我们将在后续章节中看到的那样。
本节将使用Google Kubernetes Engine(GKE)。如前所述,Google Cloud 提供价值 $300 的 90 天免费试用。如果您尚未注册,可以在cloud.google.com/free注册。
启动 Google Kubernetes Engine
一旦您注册并进入控制台,打开Google Cloud Shell CLI 来运行以下命令。
您需要首先使用以下命令启用 Kubernetes Engine API:
$ gcloud services enable container.googleapis.com
要创建一个从一个节点扩展到五个节点的两节点自动扩展 GKE 集群,请运行以下命令:
$ gcloud container clusters create cluster-1 --num-nodes 2 \
--enable-autoscaling --min-nodes 1 --max-nodes 5 --zone us-central1-a
就这样!集群将启动并运行。
您还必须克隆以下 GitHub 仓库,以获取提供的一些练习:github.com/PacktPublishing/Modern-DevOps-Practices-2e。
运行以下命令将仓库克隆到您的主目录,然后cd进入以下目录以访问所需的资源:
$ git clone https://github.com/PacktPublishing/Modern-DevOps-Practices-2e.git \
modern-devops
$ cd modern-devops/ch11/jenkins/jenkins-controller
我们将使用Jenkins 配置即代码(JCasC)功能来配置 Jenkins,因为它是一种声明性方式来管理您的配置,同时也支持 GitOps。您需要创建一个简单的 YAML 文件,包含所有必需的配置,然后将该文件复制到 Jenkins 控制器,在设置一个指向文件的环境变量后,Jenkins 将在启动时自动配置 YAML 文件中定义的所有方面。
让我们首先创建casc.yaml文件来定义我们的配置。
创建 Jenkins CaC(JCasC)文件
用于此目的的casc.yaml文件,我将解释其中的部分内容。让我们首先定义Jenkins 全局安全性。
配置 Jenkins 全局安全性
默认情况下,Jenkins 是不安全的——也就是说,如果您从官方 Docker 镜像启动一个基础版 Jenkins 并暴露它,任何人都可以对该 Jenkins 实例进行任何操作。为了确保我们保护它,我们需要以下配置:
jenkins:
remotingSecurity:
enabled: true
securityRealm:
local:
allowsSignup: false
users:
- id: ${JENKINS_ADMIN_ID}
password: ${JENKINS_ADMIN_PASSWORD}
authorizationStrategy:
globalMatrix:
permissions:
- "Overall/Administer:admin"
- "Overall/Read:authenticated"
在前面的配置中,我们定义了以下内容:
-
remotingSecurity:我们启用了此功能,它将确保 Jenkins 控制器与我们将动态创建的 Kubernetes 代理之间的通信安全。 -
securityRealm:我们已将安全领域设置为local,这意味着 Jenkins 控制器本身将负责所有认证和用户管理。我们也可以将其卸载到外部实体,如 LDAP:-
allowsSignup:此设置为false。这意味着您在 Jenkins 首页上看不到注册链接,Jenkins 管理员应手动创建用户。 -
users:我们将创建一个用户,其id和password分别来自两个环境变量,分别是JENKINS_ADMIN_ID和JENKINS_ADMIN_PASSWORD。
-
-
authorizationStrategy:我们定义了基于矩阵的授权策略,在该策略中,我们为admin提供管理员权限,为authenticated的非管理员用户提供读取权限。
同样,由于我们希望 Jenkins 在代理中执行所有构建而不是在控制器机器上执行,因此我们需要指定以下设置:
jenkins:
systemMessage: "Welcome to Jenkins!"
numExecutors: 0
我们将numExecutors设置为0,以允许在控制器上不执行任何构建,并在 Jenkins 欢迎界面上设置了systemMessage。
现在,我们已经设置了 Jenkins 控制器的安全方面,我们将配置 Jenkins 与 Kubernetes 集群连接。
将 Jenkins 与集群连接
我们将安装 Kubernetes 插件以将 Jenkins 控制器与集群连接起来。我们这样做是因为我们希望 Jenkins 动态为构建启动代理,作为 Kubernetes pod。
我们将首先在jenkins.clouds下创建一个kubernetes配置,如下所示:
jenkins
clouds:
- kubernetes:
serverUrl: "https://<kubernetes_control_plane_ip>"
jenkinsUrl: "http://jenkins-service:8080"
jenkinsTunnel: "jenkins-service:50000"
skipTlsVerify: false
useJenkinsProxy: false
maxRequestsPerHost: 32
name: "kubernetes"
readTimeout: 15
podLabels:
- key: jenkins
value: agent
...
由于配置中有一个名为<kubernetes_control_plane_ip>的占位符,我们必须将其替换为 Kubernetes 控制平面的 IP 地址。运行以下命令获取控制平面的 IP 地址:
$ kubectl cluster-info | grep "control plane"
Kubernetes control plane is running at https://35.224.6.58
现在,请使用以下命令将<kubernetes_control_plane_ip>占位符替换为您从前面命令中获取的实际 IP 地址:
$ sed -i 's/<kubernetes_control_plane_ip>/actual_ip/g' casc.yaml
让我们查看配置文件中的每个属性:
-
serverUrl: 这表示 Kubernetes 控制平面的服务器 URL,允许 Jenkins 控制器与 Kubernetes API 服务器通信。 -
jenkinsUrl: 这表示 Jenkins 控制器的 URL。我们将其设置为 http://jenkins-service:8080。 -
jenkinsTunnel: 这描述了代理 Pod 如何与 Jenkins 控制器连接。由于 JNLP 端口是50000,我们将其设置为jenkins-service:50000。 -
podLabels: 我们还设置了一些 Pod 标签,key=jenkins和value=agent。这些将设置在代理 Pod 上。
其他属性也设置为它们的默认值。
每个 Kubernetes 云配置都包括多个 Pod templates,描述了代理 Pod 的配置方式。配置如下:
- kubernetes:
...
templates:
- name: "jenkins-agent"
label: "jenkins-agent"
hostNetwork: false
nodeUsageMode: "NORMAL"
serviceAccount: "jenkins"
imagePullSecrets:
- name: regcred
yamlMergeStrategy: "override"
containers:
...
在这里,我们定义了以下内容:
-
模板的
name和label。我们将它们都设置为jenkins-agent。 -
hostNetwork: 这被设置为false,因为我们不希望容器与主机网络交互。 -
seviceAccount: 我们将其设置为jenkins,因为我们希望使用此服务账号与 Kubernetes 交互。 -
imagePullSecrets: 我们还提供了一个名为regcred的镜像拉取秘钥,用于与容器注册表进行身份验证以拉取jnlp镜像。
每个 Pod 模板还包含一个容器模板。我们可以使用以下配置来定义它:
...
containers:
- name: jnlp
image: "<your_dockerhub_user>/jenkins-jnlp-kaniko"
workingDir: "/home/jenkins/agent"
command: ""
args: ""
livenessProbe:
failureThreshold: 1
initialDelaySeconds: 2
periodSeconds: 3
successThreshold: 4
timeoutSeconds: 5
volumes:
- secretVolume:
mountPath: /kaniko/.docker
secretName: regcred
在这里,我们已经指定了以下内容:
-
name: 设置为jnlp。 -
image: 在这里,我们指定了我们将在下一节构建的Docker 代理镜像。请确保您使用以下命令将<your_dockerhub_user>占位符替换为您的 Docker Hub 用户名:
$ sed -i 's/<your_dockerhub_user>/actual_dockerhub_user/g' casc.yaml
-
workingDir: 设置为/home/jenkins/agent。 -
我们将
command和args字段都设置为空,因为我们不需要传递它们。 -
livenessProbe: 我们为代理 Pod 定义了一个活动探针。 -
volumes:我们已经将regcred密钥挂载到kaniko/.docker文件作为卷。由于regcred包含 Docker 仓库凭证,Kaniko 将使用此凭证连接到容器注册表。
现在我们的配置文件已经准备好,我们将在下一节安装 Jenkins。
安装 Jenkins
由于我们是在 Kubernetes 集群上运行,我们只需要 Docker Hub 上最新的官方 Jenkins 镜像。我们将根据需求定制镜像。
以下 Dockerfile 文件将帮助我们创建包含所需插件和初始配置的镜像:
FROM jenkins/jenkins
ENV CASC_JENKINS_CONFIG /usr/local/casc.yaml
ENV JAVA_OPTS -Djenkins.install.runSetupWizard=false
COPY casc.yaml /usr/local/casc.yaml
COPY plugins.txt /usr/share/jenkins/ref/plugins.txt
RUN jenkins-plugin-cli --plugin-file /usr/share/jenkins/ref/plugins.txt
Dockerfile 从 Jenkins 基础镜像开始。接着,我们声明了两个环境变量——CASC_JENKINS_CONFIG,它指向我们在上一节中定义的 casc.yaml 文件,以及 JAVA_OPTS,它告诉 Jenkins 不运行设置向导。然后,我们将 casc.yaml 和 plugins.txt 文件复制到 Jenkins 容器内的相应目录。最后,我们在 plugins.txt 文件上运行 jenkins-plugins-cli,以安装所需的插件。
plugins.txt 文件包含了我们在此设置中所需的所有 Jenkins 插件的列表。
小贴士
你可以根据需要通过更新 plugins.txt 文件来定制并安装更多插件,以满足控制器镜像的需求。
让我们使用以下命令从 Dockerfile 文件构建镜像:
$ docker build -t <your_dockerhub_user>/jenkins-controller-kaniko .
现在我们已经构建了镜像,使用以下命令登录并将镜像推送到 Docker Hub:
$ docker login
$ docker push <your_dockerhub_user>/jenkins-controller-kaniko
我们还需要构建 Jenkins 代理镜像以运行我们的构建。请记住,Jenkins 代理需要所有支持工具才能运行构建。你可以在以下目录找到代理所需的资源:
$ cd ~/modern-devops/ch11/jenkins/jenkins-agent
我们将使用以下 Dockerfile 来实现:
FROM gcr.io/kaniko-project/executor:v1.13.0 as kaniko
FROM jenkins/inbound-agent
COPY --from=kaniko /kaniko /kaniko
WORKDIR /kaniko
USER root
这个 Dockerfile 使用多阶段构建,从 kaniko 基础镜像开始,并将 kaniko 二进制文件从 kaniko 基础镜像复制到 inbound-agent 基础镜像中。让我们使用以下命令构建并推送容器:
$ docker build -t <your_dockerhub_user>/jenkins-jnlp-kaniko .
$ docker push <your_dockerhub_user>/jenkins-jnlp-kaniko
为了在 Kubernetes 集群上部署 Jenkins,我们将首先创建一个 jenkins 服务账号。通过集群角色绑定,一个 Kubernetes cluster-admin。一个 Kubernetes jenkins-sa-crb.yaml 清单描述了这一点。要访问这些资源,请运行以下命令:
$ cd ~/modern-devops/ch11/jenkins/jenkins-controller
要应用清单,运行以下命令:
$ kubectl apply -f jenkins-sa-crb.yaml
下一步是创建一个 PersistentVolumeClaim 资源来存储 Jenkins 数据,以确保 Jenkins 数据在 pod 生命周期之外仍然存在,并且即使删除 pod 后数据也会存在。
要应用清单,运行以下命令:
$ kubectl apply -f jenkins-pvc.yaml
然后,我们将创建一个 Kubernetes regcred 来帮助 Jenkins pod 与 Docker 仓库进行身份验证。使用以下命令来实现:
$ kubectl create secret docker-registry regcred --docker-username=<username> \
--docker-password=<password> --docker-server=https://index.docker.io/v1/
现在,我们将定义一个jenkins-deployment.yaml,它将运行 Jenkins 容器。Pod 使用jenkins服务账户,并通过名为jenkins-pv-claim的PersistentVolumeClaim资源定义一个jenkins-pv-storage,这是我们之前定义的。我们定义了一个 Jenkins 容器,使用我们创建的 Jenkins 控制器镜像。它暴露了 HTTP 端口8080供Web UI使用,暴露了端口50000供JNLP使用,代理将使用该端口与 Jenkins 控制器进行交互。我们还将把jenkins-pv-storage卷挂载到/var/jenkins_home,以便在 Pod 生命周期之外持久化 Jenkins 数据。我们在 Pod 镜像中指定regcred作为imagePullSecret属性。我们还使用initContainer将/var/jenkins_home的所有权分配给jenkins。
由于文件包含占位符,请使用以下命令将<your_dockerhub_user>替换为你的 Docker Hub 用户名,将<jenkins_admin_pass>替换为你选择的 Jenkins 管理员密码:
$ sed -i 's/<your_dockerhub_user>/actual_dockerhub_user/g' jenkins-deployment.yaml
使用以下命令应用清单:
$ kubectl apply -f jenkins-deployment.yaml
既然我们已经创建了部署,我们可以通过jenkins-svc.yaml清单暴露该部署。此服务在负载均衡器上暴露端口8080和50000。使用以下命令应用该清单:
$ kubectl apply -f jenkins-svc.yaml
让我们让服务找到外部 IP 并使用它来访问 Jenkins:
$ kubectl get svc jenkins-service
NAME EXTERNAL-IP PORT(S)
jenkins-service LOAD_BALANCER_EXTERNAL_IP 8080,50000
现在,要访问服务,请在浏览器窗口中输入http://<LOAD_BALANCER_EXTERNAL_IP>:8080:

图 11.9 – Jenkins 登录页面
如我们所见,我们被迎接到了一个登录页面。这意味着全球安全功能正常。让我们使用我们设置的管理员用户名和密码登录:

图 11.10 – Jenkins 首页
如我们所见,我们已成功登录到 Jenkins。现在,让我们继续创建我们的第一个 Jenkins 作业。
运行我们的第一个 Jenkins 作业
在创建我们的第一个作业之前,我们需要准备好仓库以运行该作业。我们将重用mdo-posts仓库。我们将把一个build.sh文件复制到仓库,该文件将为posts微服务构建容器镜像,并将其推送到 Docker Hub。
build.sh脚本接受IMAGE_ID和IMAGE_TAG作为参数。它将这些参数传递给Dockerfile,并使用以下代码将其推送到 Docker Hub:
IMAGE_ID=$1 && \
IMAGE_TAG=$2 && \
export DOCKER_CONFIG=/kaniko/.dockerconfig && \
/kaniko/executor \
--context $(pwd) \
--dockerfile $(pwd)/Dockerfile \
--destination $IMAGE_ID:$IMAGE_TAG \
--force
我们需要使用以下命令将此文件复制到我们的本地仓库:
$ cp ~/modern-devops/ch11/jenkins/jenkins-agent/build.sh ~/mdo-posts/
完成此操作后,进入本地仓库,即~/mdo-posts,然后提交并推送更改到 GitHub。完成此操作后,你就可以准备在 Jenkins 中创建一个作业了。
要在 Jenkins 中创建一个新作业,进入 Jenkins 首页并选择New Item | Freestyle Job。提供一个作业名称(最好与 Git 仓库名称相同),然后点击Next。
点击Source Code Management,选择Git,并添加你的 Git 仓库 URL,如下所示。指定你希望构建的分支:

图 11.11 – Jenkins 源代码管理配置
进入构建触发器,选择轮询 SCM,并添加以下详细信息:

图 11.12 – Jenkins – 构建触发器配置
然后,点击build.sh脚本,使用<your_dockerhub_user>/<image>参数和镜像标签。根据你的要求更改详细信息。完成后,点击保存:

图 11.13 – Jenkins – 执行 Shell 配置
现在,我们准备好构建这个任务了。为此,你可以进入任务配置并点击立即构建,或者推送一个更改到 GitHub。你应该能看到类似以下的内容:

图 11.14 – Jenkins 任务页面
Jenkins 会成功创建一个 Kubernetes 中的代理 Pod,并在其中运行此任务,很快,任务就会开始构建。点击构建 | 控制台输出。如果一切正常,你会看到构建成功,并且 Jenkins 已经构建了posts服务并在推送 Docker 镜像到注册表之前执行了单元测试:

图 11.15 – Jenkins 控制台输出
这样,我们就能够使用可扩展的 Jenkins 服务器运行 Docker 构建。如我们所见,我们已经在 SCM 设置中设置了轮询,每分钟检查一次是否有更改,如果有则构建任务。然而,这种方法消耗资源,并且从长远来看并不理想。试想一下,如果你有数百个任务与多个 GitHub 仓库交互,而 Jenkins 控制器每分钟都在轮询它们。更好的方法是,GitHub 可以在 Jenkins 上触发一个提交后 webhook。在这种情况下,Jenkins 会在仓库发生更改时构建任务。我们将在下一节中查看这种场景。
使用触发器自动化构建
触发 CI 构建的最佳方法是使用提交后 webhook。当我们查看 GitHub Actions 工作流时,已经看到过类似的例子。现在,让我们尝试通过 Jenkins 的触发器来自动化构建。为此,我们需要在 Jenkins 和 GitHub 两端进行一些更改。我们首先处理 Jenkins,然后配置 GitHub。
进入任务配置 | 构建触发器,并进行以下更改:

图 11.16 – Jenkins GitHub 钩子触发器
通过点击保存保存配置。现在,进入你的 GitHub 仓库,点击设置 | Webhooks | 添加 Webhook,并添加以下详细信息。然后,点击添加 Webhook:

图 11.17 – GitHub webhook
现在,推送更改到代码库。Jenkins 上的任务将开始构建:

图 11.18 – Jenkins GitHub webhook 触发器
这就是自动化构建触发器的实际操作。Jenkins 是市场上最流行的开源 CI 工具之一。它的最大优点是几乎可以在任何地方运行。然而,它也有一定的管理开销。你可能已经注意到,使用 GitHub Actions 启动构建是多么简单,但 Jenkins 稍微复杂一些。
其他一些 SaaS 平台也提供 CI 和 CD 服务。例如,如果你在 AWS 上运行,你可以使用其内置的 CI 服务,AWS Code Commit 和 Code Build;Azure 提供了完整的 CI 和 CD 服务套件,在其 Azure DevOps 中;GCP 提供了 Cloud Build 来完成这项工作。
无论你选择使用哪种工具,CI 都遵循相同的原则。它更多的是一个过程和你组织内的文化变革。现在,让我们来看看有关 CI 的一些最佳实践。
构建性能最佳实践
CI 是一个持续的过程,因此在任何给定时间,你的环境中会有许多并行的构建在运行。在这种情况下,我们可以通过一些最佳实践来优化这些构建。
目标是更快速的构建
你完成构建的速度越快,得到反馈的速度就越快,下一次迭代也能更迅速地进行。构建缓慢会拖慢你的开发团队的速度。采取措施确保构建更快速。例如,在 Docker 的情况下,使用较小的基础镜像是合理的,因为每次构建时都会从镜像注册表中下载代码。大多数构建使用相同的基础镜像也能加速构建时间。使用测试有帮助,但要确保它们不是长时间运行的。我们希望避免 CI 构建持续几个小时。因此,将长时间运行的测试卸载到另一个作业中,或者使用管道将是一个不错的选择。如果可能的话,可以并行运行活动。
始终使用提交后的触发器
提交后的触发器对你的团队帮助巨大。团队成员不需要登录 CI 服务器手动触发构建。这完全解耦了你的开发团队与 CI 管理。
配置构建报告
你不希望开发团队登录到 CI 工具并检查构建的运行情况。他们只想知道构建的结果和构建日志。因此,你可以配置构建报告,通过电子邮件或更好的方式(如使用 Slack 频道)发送构建状态。
自定义构建服务器的大小
并不是所有构建在相似类型的构建机器上都能以相同的方式工作。你可能需要根据构建环境的需求来选择机器。如果你的构建倾向于消耗更多的 CPU 而不是内存,那么选择这种机器来运行你的构建,而不是标准机器,将更为合理。
确保你的构建只包含你需要的内容
构建在网络间传输。你下载基础镜像,构建应用镜像,然后将其推送到容器注册中心。臃肿的镜像不仅占用大量网络带宽和传输时间,还可能使你的构建面临安全问题。因此,最佳实践始终是仅在构建中包含所需的内容,避免臃肿。你可以使用 Docker 的多阶段构建来处理这种情况。
并行化构建
同时运行测试和构建过程,以减少整体执行时间。利用分布式系统或基于云的 CI/CD 平台进行可扩展并行化,帮助高效处理更大的工作负载。
利用缓存
缓存依赖项和构建工件,以防止冗余的下载和构建,节省宝贵的时间。实现缓存机制,如 Docker 层缓存,或使用包管理器的内置缓存来最小化数据传输和构建步骤。
使用增量构建
配置 CI/CD 流水线以执行增量构建,仅重建自上次构建以来发生变化的部分。保持强大的版本控制实践,以准确追踪和识别更改。
优化测试
优先运行较快的单元测试,然后再运行较慢的集成或端到端测试。使用 TestNG、JUnit 或 PyTest 等测试框架来有效分类和并行化测试。
使用工件管理
高效地存储和管理构建工件,最好将其存储在专用的工件仓库中,如 Artifactory 或 Nexus。实施工件版本控制和保留策略,以保持工件仓库的整洁。
管理应用程序依赖关系
保持干净且简洁的依赖关系,以减少构建和测试时间。定期更新依赖项,以受益于性能提升和安全更新。
利用基础设施即代码
利用基础设施即代码(IaC)来一致性地配置和提供构建及测试环境。优化 IaC 模板,以最小化资源利用,确保高效的资源分配。
使用容器化管理构建和测试环境
容器化应用程序,并利用容器编排工具如 Kubernetes 高效管理测试环境。利用容器缓存加速镜像构建,提升资源利用率。
利用基于云的 CI/CD
考虑采用基于云的 CI/CD 服务,如 AWS CodePipeline、Google Cloud Build、Azure DevOps 或 Travis CI,以提升可扩展性和性能。利用按需云资源扩展并行化能力,适应不同的工作负载。
监控和分析 CI/CD 流水线
实施性能监控和分析工具,以识别 CI/CD 流水线中的瓶颈和改进领域。定期分析构建和测试日志,收集优化性能的洞察。
流水线优化
持续审查并优化 CI/CD 管道配置,提高效率和相关性。删除不再有显著贡献的多余步骤或阶段。
实施自动化清理
实施自动化清理程序,删除过时的构建产物、容器和虚拟机,避免资源堆积。定期清理旧的构建产物和未使用的资源,以保持环境整洁。
文档和培训
为你的 CI/CD 流程编写最佳实践和性能指南,确保整个团队始终如一地遵循这些标准。为团队成员提供培训和指导,使他们能够有效地实施和维护这些优化策略。
通过实施这些策略,你可以显著提升 CI/CD 管道的速度、效率和可靠性,从而促进更顺畅的软件开发和交付过程。这些是高层次的最佳实践,虽然不完全,但足够让你开始优化 CI 环境。
总结
本章介绍了持续集成(CI),你理解了持续集成的必要性以及容器应用程序的基本 CI 工作流。接着,我们了解了 GitHub Actions,使用它可以构建一个有效的 CI 管道。然后,我们探讨了 Jenkins 开源工具,并在 Kubernetes 上部署了一个可扩展的 Jenkins,结合 Kaniko 设置了 Jenkins 控制器-代理模式。接着,我们了解了如何在基于 GitHub Actions 和基于 Jenkins 的工作流中使用钩子来自动化构建。最后,我们学习了构建性能的最佳实践以及需要避免的事项。
到现在为止,你应该已经熟悉 CI 及其细节,并了解可以用来实现 CI 的各种工具。
在下一章,我们将深入探讨容器世界中的持续部署/交付。
问题
回答以下问题,测试你对本章内容的理解:
-
以下哪些是 CI 工具?(选择三个)
A. Jenkins
B. GitHub Actions
C. Kubernetes
D. AWS Code Build
-
配置提交后触发器是一种最佳实践。 (正确/错误)
-
Jenkins 是基于 SaaS 的 CI 工具。 (正确/错误)
-
Kaniko 需要 Docker 来构建容器。 (正确/错误)
-
Jenkins 代理节点需要哪些原因?(选择三个)
A. 它们使构建更具可扩展性
B. 它们帮助将管理功能从 Jenkins 控制器上卸载
C. 它们允许并行构建
D. 它们让 Jenkins 控制器更少忙碌
-
以下哪些是构建可扩展 Jenkins 服务器所需的?(选择三个)
A. Kubernetes 集群
B. Jenkins 控制器节点
C. Jenkins 代理节点
D. 与容器注册表交互的凭证
答案
以下是本章问题的答案:
-
A, B, D
-
正确
-
错误
-
错误
-
A, C, D
-
A, B, D
第十二章:使用 Argo CD 进行持续部署/交付
在上一章中,我们探讨了现代 DevOps 的一个关键方面——持续集成(CI)。CI 是大多数组织在采用 DevOps 时首先实施的内容,但事情并不止于 CI,CI 只会将经过测试的构建交付到工件仓库。而我们还希望将工件部署到我们的环境中。在本章中,我们将实现 DevOps 工具链的下一部分——持续 部署/交付(CD)。
本章我们将涵盖以下主要内容:
-
CD 和自动化的重要性
-
CD 模型和工具
-
博客应用及其部署配置
-
使用环境仓库进行持续声明式 IaC
-
Argo CD 简介
-
安装和设置 Argo CD
-
管理敏感配置和密钥
-
部署示例博客应用
技术要求
在本章中,我们将启动一个基于云的 Kubernetes 集群,Google Kubernetes Engine(GKE),用于练习。写作时,Google Cloud Platform(GCP)提供免费的 300 美元试用,时效为 90 天,因此你可以在console.cloud.google.com/注册一个账号。
你还需要克隆以下 GitHub 仓库来进行一些练习:github.com/PacktPublishing/Modern-DevOps-Practices。
运行以下命令将仓库克隆到你的主目录,并进入ch12目录以访问所需资源:
$ git clone https://github.com/PacktPublishing/Modern-DevOps-Practices-2e.git \
modern-devops
$ cd modern-devops/ch12
所以,让我们开始吧!
CD 和自动化的重要性
CD 构成了你 DevOps 工具链中的 Ops 部分。因此,在你的开发人员不断构建和推送代码,而 CI 管道负责构建、测试并将构建发布到工件仓库时,Ops 团队将把构建部署到测试和暂存环境。QA 团队是把关人,确保代码符合一定质量标准,只有通过后,Ops 团队才会将代码部署到生产环境。
现在,对于只实施 CI 部分的组织,其余活动仍然是手动的。例如,操作员需要拉取工件并手动运行命令来进行部署。因此,部署的速度将取决于 Ops 团队的可用性。由于部署是手动的,流程容易出错,人类在重复性工作中容易犯错。
现代 DevOps 的一个基本原则是避免繁琐工作。繁琐工作就是开发人员和运维人员日复一日进行的重复性工作,而这些工作可以通过自动化消除。这将帮助你的团队专注于更重要的事情。
通过持续交付,标准工具可以基于某些门控条件将代码部署到更高环境。CD 流水线将在测试构建到达工件库时触发,或者在 GitOps 的情况下,当检测到环境库中有任何变更时。然后,流水线会根据预设的配置决定在哪里以及如何部署代码。它还会确定是否需要手动检查,例如提出变更请求并检查是否已批准。
虽然持续部署和持续交付经常被混淆为相同的概念,但它们之间是有细微区别的。持续交付允许你的团队基于人工触发将经过测试的代码交付到你的环境中。因此,尽管你只需点击按钮就能将代码部署到生产环境中,但仍然需要某人在合适的时机(如维护窗口)发起部署。持续部署则更进一步,当它与 CI 过程集成时,一旦新的经过测试的构建可供使用,它会自动启动部署过程。不需要人工干预,持续部署只会在测试失败时停止。
监控工具构成了 DevOps 工具链的下一部分。运维团队可以通过管理他们的生产环境来获得反馈,并向开发人员提供改进的建议。这些反馈最终会进入开发的待办事项列表,开发人员可以在未来的发布中将其作为新特性交付。这样就完成了一个周期,现在你的团队可以持续不断地推出技术产品。
CD 提供了几个优势,其中一些如下:
-
更快的市场响应时间:CD 和 CI 减少了将新特性、增强功能和修复程序交付给最终用户的时间。这种敏捷性可以使你的组织在市场需求面前具有竞争优势,快速做出反应。
-
降低风险:通过自动化部署过程并频繁推送小规模的代码变更,你可以最小化大型、易出错部署的风险。漏洞和问题更容易被早期发现,并且回滚操作也可以更简单。
-
提高代码质量:频繁的自动化测试和质量检查是 CD 和 CI 的重要组成部分。这能提高代码质量,因为开发人员会被鼓励编写更简洁、更易维护的代码。任何问题都能更早地被发现和解决。
-
增强协作:CD 和 CI 促进了开发和运维团队之间的协作。它打破了传统的壁垒,鼓励跨职能团队合作,从而提升了沟通和理解。
-
提高效率和生产力:自动化重复性的任务,例如测试、构建和部署,使开发人员能够腾出时间专注于更有价值的任务,例如创建新特性和改进。
-
客户反馈:CD 允许您更快速地从真实用户那里收集反馈。通过频繁部署小的更改,您可以收集用户反馈并相应地调整开发工作,确保您的产品更好地满足用户需求。
-
持续改进:CD 促进了持续改进的文化。通过分析部署和监控的数据,团队可以识别出需要改进的领域,并对其流程进行迭代。
-
更好的安全性:频繁的更新意味着可以及时解决安全漏洞,减少攻击者的机会窗口。安全检查可以自动化并集成到 CI/CD 流水线中。
-
减少人工干预:CD 减少了在部署过程中对人工干预的需求。这降低了人为错误的可能性,并简化了发布流程。
-
可扩展性:随着产品的增长,开发人员数量的增加以及代码库复杂性的提高,CD 可以帮助保持可管理的开发流程。通过自动化许多发布和测试过程,它能够有效地扩展。
-
节省成本:虽然实现 CI/CD 需要在工具和流程上进行初期投资,但从长远来看,它可以通过减少大量的手动测试需求、降低与部署相关的错误并提高资源利用率,从而实现成本节省。
-
合规性和审计:对于有监管要求的组织,CD 可以通过提供详细的更改和部署历史记录来改善合规性,使得跟踪和审计代码变更变得更加容易。
需要注意的是,虽然 CD 和 CI 提供了许多优势,但它们也需要精心的规划、基础设施和文化变革才能发挥作用。
有多种模型和工具可以实现 CD。我们将在下一节中详细介绍其中的一些。
CD 模型和工具
一个典型的 CI/CD 工作流如以下图所示,以及随后的步骤:

图 12.1 – CI/CD 工作流
-
开发人员编写代码并将其推送到代码仓库(通常是 Git 仓库)。
-
您的 CI 工具构建代码,运行一系列测试,并将测试通过的构建推送到工件仓库。然后,您的 CD 工具获取该工件,并将其部署到测试和暂存环境中。根据您是否希望进行持续部署或交付,它会自动将工件部署到生产环境中。
好吧,您选择什么样的交付工具呢?让我们回顾一下我们在第十一章中讨论的例子,持续集成。我们选择了posts微服务应用,并使用如 GitHub Actions/Jenkins 这样的 CI 工具,利用Docker将其打包成容器并推送到我们的Docker Hub容器注册表。嗯,我们本可以使用相同的工具来部署到我们的环境中。
例如,如果我们想要部署到kubectl apply。我们可以使用任何这些工具来轻松完成,但我们选择不这么做。为什么?答案很简单——CI 工具是为了 CI 而设计的,如果你想用它们做其他事情,最终会遇到瓶颈。这并不意味着你不能用这些工具来做 CD,它仅适用于基于你所遵循的部署模型的某些用例。
根据你的应用、技术栈、客户需求、风险承受度和成本意识,存在多种部署模型。让我们来看一看一些业界常用的部署模型。
简单部署模型
简单部署模型是所有模型中最直接的一种:你在移除旧版本后,部署所需版本的应用程序。它完全替换了之前的版本,回滚则涉及在移除已部署版本后重新部署旧版本:

图 12.2 – 简单部署模型
由于这是一种简单的部署方式,你可以使用像Jenkins或GitHub Actions这样的 CI 工具来管理它。然而,简单部署模型并不是最理想的部署方法,因为它有一些固有的风险。这种变更具有破坏性,通常需要停机时间。这意味着在升级期间,你的服务会暂时无法提供给客户。对于没有 24/7 用户的组织来说,这可能是可以接受的,但中断会影响服务水平目标(SLOs)和服务水平协议(SLAs)的履行,特别是对于全球化的组织。即便没有相关协议,它们也会影响客户体验并损害组织声誉。
因此,为了应对这种情况,我们有一些复杂的部署模型。
复杂部署模型
复杂部署模型与简单部署模型不同,试图最小化应用中的中断和停机时间,使得发布过程更加顺畅,以至于大多数用户甚至没有注意到升级正在进行。业界流行的两种复杂部署方式是:让我们来看一看。
蓝绿部署
蓝绿部署(也称为红黑部署)是将新版本(绿)与现有版本(蓝)一起推出。然后,你可以进行完整性检查和其他活动,以确保一切正常。接着,你可以将流量从旧版本切换到新版本,并监控是否出现问题。如果遇到问题,你可以将流量切换回旧版本。否则,你可以继续运行最新版本并移除旧版本:

图 12.3 – 蓝绿部署
你可以通过金丝雀部署将蓝绿部署提升到下一个层次。
金丝雀部署和 A/B 测试
金丝雀部署与蓝绿部署类似,但通常用于高风险的升级。因此,像蓝绿部署一样,我们将新版本与现有版本一起部署。不同的是,我们不会立即将所有流量切换到最新版本,而是仅将流量切换给一小部分用户。在切换的过程中,我们可以通过日志和用户行为了解切换是否引起了问题。这就是所谓的 A/B 测试。在进行 A/B 测试时,我们可以根据位置、语言、年龄段或选择测试产品 Beta 版本的用户来针对特定的用户群体进行测试。这有助于组织收集反馈,而不会打扰到普通用户,并在对新版本满意后做出调整。你可以通过将所有流量切换到新版本并删除旧版本来使发布普遍可用:

图 12.4 – 金丝雀部署
虽然复杂的部署对用户的干扰最小,但通常使用传统的 CI 工具(如 Jenkins)来管理时非常复杂。因此,我们需要在这一点上正确配置工具。市场上有几种 CD 工具可供选择,包括 Argo CD、Spinnaker、Circle CI 和 AWS Code Deploy。由于本书的重点是 GitOps,而 Argo CD 是一个 GitOps 原生工具,因此在本章中,我们将重点讨论 Argo CD。在深入应用部署之前,让我们回顾一下我们要部署的内容。
博客应用及其部署配置
由于我们在上一章中讨论了博客应用,让我们再次看看这些服务及其交互:

图 12.5 – 博客应用及其服务和交互
到目前为止,我们已经创建了用于构建、测试和推送博客应用微服务容器的 CI 流水线。这些微服务需要在某个地方运行。因此,我们需要一个环境来运行它们。我们将在上一章中的 posts 微服务中部署该应用程序,我也将其余服务的构建作为一个练习留给你。假设你已经构建了它们,我们将需要以下资源以确保应用程序顺利运行:
-
MongoDB:我们将部署一个启用了认证的 MongoDB 数据库,且拥有 root 凭据。凭据将通过环境变量注入,这些环境变量来自 Kubernetes Secret 资源。我们还需要持久化我们的数据库数据,因此我们需要一个挂载到容器的 PersistentVolume,该卷将通过 PersistentVolumeClaim 动态提供。由于容器是有状态的,我们将使用 StatefulSet 来管理它,并因此使用一个无头 Service 来暴露数据库。
-
posts、reviews、ratings、和users微服务将通过环境变量注入的根凭证与 MongoDB 进行交互,这些凭证来自与 MongoDB 相同的Secret。我们将使用各自的Deployment资源来部署它们,并通过单独的ClusterIP Services来暴露它们。 -
前端:前端微服务不需要与 MongoDB 交互,因此不会与 Secret 资源交互。我们还将使用Deployment资源来部署该服务。由于我们希望将此服务暴露到互联网上,我们将为其创建一个LoadBalancer Service。
我们可以用以下图示总结这些方面:

图 12.6 – 博客应用 – Kubernetes 资源与交互
现在,由于我们遵循 GitOps 模型,我们需要将所有资源的清单存储在 Git 上。然而,由于 Kubernetes Secrets 本身并不安全,我们不能将它们的清单直接存储在 Git 上。相反,我们将使用另一个名为SealedSecrets的资源来安全地管理这些信息。
在第二章,使用 Git 和 GitOps 的源代码管理中,我们讨论了应用程序和环境仓库作为 GitOps 驱动的 CI 和 CD 的基础构建块。在前一章中,我们在 GitHub 上创建了一个应用程序仓库,并使用 GitHub Actions(以及 Jenkins)来构建、测试并将我们的应用程序容器推送到 Docker Hub。由于 CD 专注于 DevOps 中的 Ops 部分,我们需要一个环境仓库来实现这一点,所以接下来我们将创建我们的环境仓库。
使用环境仓库进行持续声明式 IaC
如今,我们已经知道,必须创建一个 GKE 集群来托管我们的微服务。到目前为止,我们一直在使用gcloud命令来完成此任务;然而,由于gcloud命令并不是声明式的,因此在实施 GitOps 时使用它们并不是理想的做法。相反,我们将使用Terraform来为我们创建 GKE 集群。这样可以确保我们能够使用 Git 环境仓库声明性地部署和管理集群。接下来,我们就来创建一个集群。
创建和设置我们的环境仓库
访问github.com,并使用您选择的名称创建一个仓库。对于本练习,我们将使用mdo-environments。完成后,访问 Google Cloud Shell,使用ssh-keygen命令生成一个ssh-key对,将公钥复制到 GitHub(参见第二章,使用 Git 和 GitOps 的源代码管理,获取逐步说明),然后使用以下命令克隆该仓库:
$ cd ~
$ git clone https://github.com/PacktPublishing/Modern-DevOps-Practices-2e.git \
modern-devops
$ git clone git@github.com:<your_account>/mdo-environments.git
$ cd mdo-environments
让我们复制一个 Terraform 的.gitignore文件,确保我们不会意外地提交 Terraform 状态、后端文件或.tfvars文件,使用以下命令:
$ cp -r ~/modern-devops/ch12/.gitignore .
现在,让我们使用以下命令将代码推送到 GitHub:
$ git add --all
$ git commit -m 'Added gitignore'
$ git push
现在我们已经推送了第一个文件并初始化了仓库,让我们根据环境来构建仓库结构。在环境仓库中我们将有两个分支——dev 和 prod。dev 分支中的所有配置将应用于 开发环境,而 prod 中的配置将应用于 生产环境。下图详细说明了这种方法:

图 12.7 – CD 流程
现有的仓库只有一个名为 master 的分支。然而,由于我们将在此仓库中管理多个环境,因此最好将 master 分支重命名为 prod。
访问 https://github.com/<your_user>/mdo-environments/branches,点击 prod 旁边的铅笔图标,然后点击 重命名分支。
现在我们已经重命名了分支,让我们移除现有的本地仓库,并使用以下命令重新克隆仓库:
$ cd ~ && rm -rf mdo-environments
$ git clone git@github.com:<your_account>/mdo-environments.git
$ cd mdo-environments
我们想从开发环境开始,因此最好从 prod 分支创建一个名为 dev 的分支。运行以下命令来实现:
$ git branch dev && git checkout dev
现在,我们可以开始在此目录中编写 Terraform 配置。配置文件位于 ~/modern-devops/ch12/mdo-environments/environments。使用以下命令将该目录中的所有内容复制到当前目录:
$ cp -r ~/modern-devops/ch12/environments/terraform .
$ cp -r ~/modern-devops/ch12/environments/.github .
在 terraform 目录中,有几个 Terraform 配置文件。
cluster.tf 文件包含了创建 Kubernetes 集群的配置。它大致如下:
resource "google_service_account" "main" {
account_id = "gke-${var.cluster_name}-${var.branch}-sa"
display_name = "GKE Cluster ${var.cluster_name}-${var.branch} Service Account"
}
resource "google_container_cluster" "main" {
name = "${var.cluster_name}-${var.branch}"
location = var.location
initial_node_count = 3
node_config {
service_account = google_service_account.main.email
oauth_scopes = [
"https://www.googleapis.com/auth/cloud-platform"
]
}
timeouts {
create = "30m"
update = "40m"
}
}
它创建了两个资源——一个 cloud platform OAuth 范围。
我们将服务账户命名为 cluster_name 和 branch 变量的组合。这是必要的,因为我们需要区分不同环境中的集群。所以,如果集群名称是 mdo-cluster,而 Git 分支是 dev,我们将有一个名为 gke-mdo-cluster-dev-sa 的服务账户。我们将在 GKE 集群上使用相同的命名约定。因此,集群名称将为 mdo-cluster-dev。
我们有一个 provider.tf 文件,包含了 provider 和 backend 配置。我们在这里使用的是远程后端,因为我们希望将 Terraform 状态存储在远程。此场景下,provider.tf 文件大致如下:
provider "google" {
project = var.project_id
region = "us-central1"
zone = "us-central1-c"
}
terraform {
backend "gcs" {
prefix = "mdo-terraform"
}
}
在这里,我们在 provider 配置中指定了默认的 region 和 zone。此外,我们声明了 gcs 后端,只包含了 prefix 属性,值为 mdo-terraform。我们可以使用前缀来分离配置,以便在一个存储桶中存储多个 Terraform 状态。我们故意没有提供 bucket 名称,那个我们会在运行时通过 -backend-config 在 terraform init 时提供。存储桶名称将是 tf-state-mdo-terraform-<PROJECT_ID>。
提示
由于 GCS 存储桶应该具有全球唯一的名称,因此建议使用类似tf-state-mdo-terraform-<PROJECT_ID>这样的名称,因为项目 ID 是全球唯一的。
我们还有一个 variables.tf 文件,声明了 project_id、branch、cluster_name 和 location 变量,如下所示:
variable project_id {}
variable branch {...
default = "dev"
}
variable cluster_name {...
default = "mdo-cluster"
}
variable "location" {...
default = "us-central1-a"
}
现在我们已经准备好 Terraform 配置文件,接下来我们需要一个工作流文件,可以应用到我们的 GCP 项目。为此,我们创建了以下 GitHub Actions 工作流文件,即 .github/workflows/create-cluster.yml:
name: Create Kubernetes Cluster
on: push
jobs:
deploy-terraform:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./terraform
steps:
- uses: actions/checkout@v2
- name: Install Terraform
id: install-terraform
run: wget -O terraform.zip https://releases.hashicorp.com/terraform/1.5.5/
terraform_1.5.5_linux_amd64.zip && unzip terraform.zip && chmod +x terraform && sudo mv
terraform /usr/local/bin
- name: Apply Terraform
id: apply-terraform
run: terraform init -backend-config="bucket=tf-state-mdo-terraform-${{ secrets.
PROJECT_ID }}" && terraform workspace select ${GITHUB_REF##*/} || terraform workspace new
${GITHUB_REF##*/} && terraform apply -auto-approve -var="project_id=${{ secrets.PROJECT_
ID }}" -var="branch=${GITHUB_REF##*/}"
env:
GOOGLE_CREDENTIALS: ${{ secrets.GCP_CREDENTIALS }}
这是一个两步的构建文件。第一步安装 Terraform,第二步应用 Terraform 配置。除此之外,我们在全局层面指定了./terraform作为工作目录。此外,我们在此文件中使用了一些秘密变量,即 GCP_CREDENTIALS,它是 Terraform 用来进行身份验证和授权 GCP API 的服务帐户密钥文件,以及 Google Cloud 的 PROJECT_ID。
我们还将存储桶名称提供为 tf-state-mdo-terraform-${{ secrets.PROJECT_ID }},以确保我们有一个唯一的存储桶名称。
由于我们使用 Terraform 工作区来管理多个环境,上述代码选择一个现有的 Terraform 工作区,工作区的名称由 ${GITHUB_REF##*/} 表示的分支名称决定,或者创建一个新的工作区。工作区在这里很重要,因为我们希望使用相同的配置,但为不同的环境使用不同的变量值。Terraform 工作区对应于环境,而环境对应于 Git 分支。所以,既然我们有 dev 和 prod 环境,我们也有对应的 Terraform 工作区和 Git 分支。
从 Terraform 和工作流配置中,我们可以推断出我们将需要以下内容:
-
用于 Terraform 进行身份验证和授权 GCP API 的 服务账户,以及我们需要添加为 GitHub 秘密的 JSON 密钥文件
-
我们将配置为 GitHub 秘密的 项目 ID
-
用作 Terraform 后端的 GCS 存储桶
所以,让我们继续在 GCP 中创建一个服务账户,这样 Terraform 就可以使用它来进行身份验证并授权访问 Google API。使用以下命令创建服务账户,提供相关的 身份与访问管理(IAM)权限,并下载凭证文件:
$ PROJECT_ID=<project_id>
$ gcloud iam service-accounts create terraform \
--description="Service Account for terraform" \
--display-name="Terraform"
$ gcloud projects add-iam-policy-binding $PROJECT_ID \
--member="serviceAccount:terraform@$PROJECT_ID.iam.gserviceaccount.com" \
--role="roles/editor"
$ gcloud iam service-accounts keys create key-file \
--iam-account=terraform@$PROJECT_ID.iam.gserviceaccount.com
在你的工作目录中,你会看到一个名为key-file的文件。现在,前往 https://github.com/<your_github_user>/mdo-environments/settings/secrets/actions/new,创建一个名为GCP_CREDENTIALS的秘密。在“值”字段中,打印出key-file文件,复制其内容并粘贴到 GitHub 秘密的 values 字段中。
接下来,创建另一个秘密,PROJECT_ID,并在 values 字段中指定你的 GCP 项目 ID。
接下来我们需要做的是为 Terraform 创建一个 GCS 存储桶,作为远程后端使用。为此,请运行以下命令:
$ gsutil mb gs://tf-state-mdo-terraform-${PROJECT_ID}
此外,我们需要启用 Terraform 用于创建资源的 GCP API。为此,运行以下命令:
$ gcloud services enable iam.googleapis.com container.googleapis.com
所以,现在所有先决条件都已经满足,我们可以将代码推送到仓库。运行以下命令来执行此操作:
$ git add --all
$ git commit -m 'Initial commit'
$ git push --set-upstream origin dev
一旦我们推送代码,GitHub Actions 工作流就会被触发。很快,工作流将应用配置并创建 Kubernetes 集群。结果应该如下所示:

图 12.8 – 使用 GitHub Actions 和 Terraform 的 GitOps
要验证集群是否已成功创建,运行以下命令:
$ gcloud container clusters list
NAME: mdo-cluster-dev
LOCATION: us-central1-a
MASTER_VERSION: 1.27.3-gke.100
MASTER_IP: x.x.x.x
MACHINE_TYPE: e2-medium
NODE_VERSION: 1.27.3-gke.100
NUM_NODES: 3
STATUS: RUNNING
如您所见,mdo-cluster-dev 集群已经在环境中成功运行。如果我们对 Terraform 配置进行任何更改,这些更改将会自动应用。我们已经成功使用环境仓库创建了环境。这就是 推送模型 GitOps 的应用。现在,我们需要在环境中运行应用程序;为了管理和部署应用程序,我们将需要一个专用的 CD 工具。如前所述,我们将使用 Argo CD,因此让我们来了解一下它。
Argo CD 简介
Argo CD 是一个开源的声明式、基于 GitOps 的持续交付(CD)工具,旨在自动化在 Kubernetes 集群上部署和管理应用程序及基础设施。Argo CD 作为一个强大的应用程序控制器,高效地管理并确保您的应用程序顺利、安全地运行。Argo CD 采用 基于拉取的 GitOps 模型,因此会定期轮询环境仓库,检测任何配置漂移。如果它发现 Git 中的状态与实际运行在环境中的应用程序状态之间有任何漂移,它将进行修正,更改以反映 Git 仓库中声明的期望配置。
Argo CD 明确针对 Kubernetes 环境进行定制,使其成为管理 Kubernetes 集群上应用程序的流行选择。
除了传统的 Kubernetes 清单 YAML 文件外,Argo CD 还支持多种替代方法来定义 Kubernetes 配置:
-
Helm 图表
-
Kustomize
-
Ksonnet
-
Jsonnet 文件
-
普通的 YAML/JSON 清单文件
-
通过插件与其他定制化的配置管理工具进行集成
在 Argo CD 中,您可以定义包含 源 和 目标 的应用程序。源指定与之关联的 Git 仓库的详细信息、清单、helm 图表或 kustomize 文件的位置,然后将这些配置应用到指定的目标环境。这使您能够监控 Git 仓库中特定分支、标签的变化,或跟踪特定版本。您还可以使用多种跟踪策略。
您可以访问一个用户友好的基于 Web 的 UI 和 命令行接口(CLI)与 Argo CD 进行交互。此外,Argo CD 通过同步钩子和应用操作提供应用程序状态报告。如果集群内直接进行的任何修改偏离了 GitOps 方法,Argo CD 会及时通知您的团队,可能通过 Slack 渠道。
以下图表概述了 Argo CD 的架构:

图 12.9 – Argo CD 架构
那么,废话不多说,我们开始启动 Argo CD。
安装和设置 Argo CD
安装 Argo CD 非常简单——我们只需将在线提供的 install.yaml 清单包应用到我们希望安装它的 Kubernetes 集群中,清单包地址为 github.com/argoproj/argo-cd/blob/master/manifests/install.yaml。如需更个性化的安装,请参考 argo-cd.readthedocs.io/en/stable/operator-manual/installation/。
由于我们本章使用的是 GitOps,因此不会手动部署 Argo CD。相反,我们将使用 Terraform 通过环境仓库来设置它。
本节的资源位于 ~/modern-devops/ch12/environments-argocd-app。我们将使用与之前相同的环境仓库来管理这个环境。
因此,让我们 cd 进入 mdo-environments 本地仓库,并运行以下命令:
$ cd ~/mdo-environments
$ cp -r ~/modern-devops/ch12/environments-argocd-app/terraform .
$ cp -r ~/modern-devops/ch12/environments-argocd-app/manifests .
$ cp -r ~/modern-devops/ch12/environments-argocd-app/.github .
现在,让我们看看目录结构,理解我们正在做什么:
.
├── .github
│ └── workflows
│ └── create-cluster.yml
├── manifests
│ └── argocd
│ ├── apps.yaml
│ ├── install.yaml
│ └── namespace.yaml
└── terraform
├── app.tf
├── argocd.tf
├── cluster.tf
├── provider.tf
└── variables.tf
如我们所见,结构与之前类似,只是做了一些改动。首先让我们看一下 Terraform 配置。
Terraform 修改
现在,terraform 目录下新增了两个文件:
-
argocd.tf:这包含了部署 Argo CD 的 Terraform 配置。 -
app.tf:这包含了配置 Argo CD 应用程序的 Terraform 配置。
让我们详细探讨这两个文件。
argocd.tf
该文件以 time_sleep 资源开始,并显式依赖 google_container_cluster 资源。集群创建后,它会休眠 30 秒,以便做好准备响应请求:
resource "time_sleep" "wait_30_seconds" {
depends_on = [google_container_cluster.main]
create_duration = "30s"
}
要连接 GKE,我们将使用由 terraform-google-modules/kubernetes-engine/google//modules/auth 提供的 gke_auth 模块。我们将显式添加对 time_sleep 模块的依赖,以确保身份验证在集群创建后 30 秒发生:
module "gke_auth" {
depends_on = [time_sleep.wait_30_seconds]
source = "terraform-google-modules/kubernetes-engine/google//modules/auth"
project_id = var.project_id
cluster_name = google_container_cluster.main.name
location = var.location
use_private_endpoint = false
}
现在我们已经通过 GKE 集群进行了身份验证,接下来需要应用清单将 Argo CD 部署到集群中。为此,我们将使用 gavinbunney/kubectl 插件 (registry.terraform.io/providers/gavinbunney/kubectl/latest/docs)。
我们首先定义一些数据源,帮助生成 Kubernetes 清单,然后应用它们来安装 Argo CD。我们将为命名空间和 Argo CD 应用创建两个指向 manifests/argocd 目录下 namespace.yaml 和 install.yaml 文件的 kubectl_file_documents 数据源:
data "kubectl_file_documents" "namespace" {
content = file("../manifests/argocd/namespace.yaml")
}
data "kubectl_file_documents" "argocd" {
content = file("../manifests/argocd/install.yaml")
}
使用这些数据源,我们可以为命名空间和 Argo CD 应用创建两个 kubectl_manifest 资源。这些资源将应用 GKE 集群中的清单:
resource "kubectl_manifest" "namespace" {
for_each = data.kubectl_file_documents.namespace.manifests
yaml_body = each.value
override_namespace = "argocd"
}
resource "kubectl_manifest" "argocd" {
depends_on = [
kubectl_manifest.namespace,
]
for_each = data.kubectl_file_documents.argocd.manifests
yaml_body = each.value
override_namespace = "argocd"
}
现在我们已将 Argo CD 安装配置添加完毕,还需要配置 Argo CD 应用程序。为此,我们有 app.tf 文件。
app.tf
类似于 Argo CD 配置,我们有一个从 manifests/argocd/apps.yaml 文件读取的 kubectl_file_documents 数据源;kubectl_manifest 资源将把清单应用到 Kubernetes 集群:
data "kubectl_file_documents" "apps" {
content = file("../manifests/argocd/apps.yaml")
}
resource "kubectl_manifest" "apps" {
depends_on = [
kubectl_manifest.argocd,
]
for_each = data.kubectl_file_documents.apps.manifests
yaml_body = each.value
override_namespace = "argocd"
}
我们还修改了 provider.tf 文件,因此接下来我们将探讨它。
provider.tf
在这个文件中,我们包括了 kubectl 提供者,如下所示:
...
provider "kubectl" {
host = module.gke_auth.host
cluster_ca_certificate = module.gke_auth.cluster_ca_certificate
token = module.gke_auth.token
load_config_file = false
}
terraform {
required_providers {
kubectl = {
source = "gavinbunney/kubectl"
version = ">= 1.7.0"
}
}...
}
现在,让我们检查一下清单目录。
Kubernetes 清单
清单目录包含我们将应用到 Kubernetes 集群的 Kubernetes 清单。由于我们正在首先设置 Argo CD,因此当前它只包含 argocd 目录;然而,我们将在本章后续扩展并添加更多目录。
manifests/argocd 目录包含以下文件:
-
namespace.yaml:创建argocd命名空间的清单,Argo CD 将在该命名空间中运行。 -
install.yaml:创建 Argo CD 应用的清单。该清单从官方 Argo CD 发布 URL 下载。 -
apps.yaml:此文件包含一个 Argo CD ApplicationSet 配置。
虽然 namespace.yaml 和 install.yaml 文件显而易见,但我们来详细讨论一下 apps.yaml 文件以及 Argo CD ApplicationSet 资源。
Argo CD Application 和 ApplicationSet
为了声明性地管理应用程序,Argo CD 使用 source 属性来指定它需要应用的内容,并使用 target 属性指定应用目标。一个 Application 资源只适用于一个应用程序。例如,要部署我们的 Blog 应用,我们需要像下面这样创建一个 Application 资源:
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: blog-app
namespace: argocd
spec:
project: default
source:
repoURL: https://github.com/<your_github_repo>/mdo-environments.git
targetRevision: HEAD
path: manifests/nginx
destination:
server: https://kubernetes.default.svc
syncPolicy:
automated:
selfHeal: true
此清单定义了一个 Argo CD Application 资源,包含以下部分:
-
project:我们可以将应用程序组织到不同的项目中。在这种情况下,我们将使用default项目。 -
source:此部分定义了 Argo CD 所需的配置,用于跟踪并从 Git 仓库拉取应用程序配置。通常包含repoURL、targetRevision和应用程序清单所在路径的path值。 -
destination:此部分定义了我们希望应用清单的target值,通常包含server部分,并包含 Kubernetes 集群的 URL。 -
syncPolicy:此部分定义了 Argo CD 在从 Git 仓库同步博客应用程序时应应用的策略,以及在检测到偏差时应该采取的措施。在之前的配置中,它会尝试自动纠正来自 Git 仓库的任何偏差,因为selfHeal已设置为true。
我们完全可以为每个应用程序定义多个应用程序清单。但是,对于较大的项目来说,这可能会成为一种负担。为了管理这一点,Argo CD 提供了一种通过 ApplicationSet 资源创建和管理应用程序的通用方法。
ApplicationSet 资源为我们提供了一种通过定义模式动态生成应用程序资源的方法。在我们的案例中,我们具有以下结构:
manifests
└── argocd
│ ├── apps.yaml
│ ├── install.yaml
│ └── namespace.yaml
└── blog-app
│ └── manifest.yaml
└── <other-app>
└── manifest.yaml
因此,逻辑上来说,对于 manifests 目录中的每个子目录,我们都需要创建一个新的应用程序,并以目录名称命名。相应的应用程序配置应从子目录中获取所有清单。
我们在apps.yaml文件中定义了以下ApplicationSet:
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
name: argo-apps
namespace: argocd
spec:
generators:
- git:
repoURL: https://github.com/<your_github_repo>/mdo-environments.git
revision: HEAD
directories:
- path: manifests/*
- path: manifests/argocd
exclude: true
template:
metadata:
name: '{{path.basename}}'
spec:
project: default
source:
repoURL: https://github.com/<your_github_repo>/mdo-environments.git
targetRevision: HEAD
path: '{{path}}'
destination:
server: https://kubernetes.default.svc
syncPolicy:
automated:
selfHeal: true
ApplicationSet 具有以下部分:
-
generators:此部分定义了 Argo CD 应如何生成应用程序资源。我们使用了git生成器,它包含repoURL、revision和directories部分。directories部分定义了我们希望从中获取应用程序的目录。我们已将其设置为manifests/*。因此,它将查找manifests目录中的每个子目录。我们还定义了一个排除目录manifests/argocd,因为我们不希望 Argo CD 管理部署自身的配置。 -
templates:此部分定义了创建应用程序的模板。如我们所见,内容与应用程序资源定义非常相似。对于metadata.name,我们指定了{{path.basename}},这意味着它将根据我们预期的子目录名称创建应用程序资源。template.spec.source.path属性包含相应应用程序清单的源路径,因此我们将其设置为{{path}}—— 即子目录。所以,我们将基于之前的目录结构生成blog-app和<other-app>应用程序。其余属性与我们之前讨论的应用程序资源相同。
现在我们已经配置好了安装和设置 Argo CD 所需的一切,让我们通过以下命令提交并推送此配置到远程仓库:
$ git add --all
$ git commit -m "Added argocd configuration"
$ git push
我们会看到 GitHub 在更新时运行 Actions 工作流并部署 Argo CD。一旦工作流成功完成,我们就可以访问 Argo CD Web UI。
访问 Argo CD Web UI
在我们可以访问 Argo CD Web UI 之前,我们必须通过 GKE 集群进行身份验证。为此,请运行以下命令:
$ gcloud container clusters get-credentials \
mdo-cluster-dev --zone us-central1-a --project $PROJECT_ID
要使用 Argo CD Web UI,您需要获取 argo-server 服务的外部 IP 地址。要获得该地址,请运行以下命令:
$ kubectl get svc argocd-server -n argocd
NAME TYPE EXTERNAL-IP PORTS AGE
argocd-server LoadBalaner 34.122.51.25 80/TCP,443/TCP 6m15s
我们现在知道 Argo CD 可以通过 34.122.51.25/ 访问。访问此链接后,您会注意到需要用户名和密码进行身份验证:

图 12.10 – Argo CD Web UI – 登录页面
Argo CD 默认提供一个初始的admin用户,该用户的密码以明文形式存储在argocd-initial-admin-secret Secret 资源中。虽然您可以使用此默认设置,但值得注意的是,它是从公开的 YAML 清单生成的。因此,建议您进行更新。要更新,请执行以下命令:
$ kubectl patch secret argocd-secret -n argocd \
-p '{"data": {"admin.password": null, "admin.passwordMtime": null}}'
$ kubectl scale deployment argocd-server --replicas 0 -n argocd
$ kubectl scale deployment argocd-server --replicas 1 -n argocd
现在,请等待两分钟,以便生成新的凭据。之后,执行以下命令以获取密码:
$ kubectl -n argocd get secret argocd-initial-admin-secret \
-o jsonpath="{.data.password}" | base64 -d && echo
现在,您已经拥有必要的凭据,请登录,您将看到以下页面:

图 12.11 – Argo CD Web UI – 首页
我们已经成功设置了 Argo CD。接下来的步骤是部署我们的应用程序;然而,正如我们所知,我们的应用程序使用 Kubernetes Secrets,而我们不能将其存储在 Git 中,因此我们需要找到一种机制来安全地存储它。为了解决这个问题,我们有 Bitnami 的 SealedSecret 资源。我们将在下一节中讨论这一点。
管理敏感配置和机密
Sealed Secrets 解决了 我可以在 Git 中管理所有 Kubernetes 配置,除了 Secrets 的问题。Sealed Secrets 作为存储敏感信息的安全容器。当您需要存储机密信息,如密码或密钥时,您将它们放入这些专门的封装中。只有 Kubernetes 中的 Sealed Secrets 控制器才能解锁并访问其中的内容。这确保了您的宝贵机密的最高安全性和保护。由 Bitnami Labs 创建并开源,它们帮助您使用非对称加密将 Kubernetes Secrets 加密为 Sealed Secrets,只有在集群中运行的 Sealed Secrets 控制器才能解密。这意味着您可以将 Sealed Secrets 存储在 Git 中,并使用 GitOps 设置一切,包括 Secrets。
Sealed Secrets 包含两个组件:
-
一个名为
kubeseal的客户端工具帮助我们从标准 Kubernetes Secret YAML 生成 Sealed Secrets -
集群端 Kubernetes 控制器/操作员解锁您的机密,并将密钥证书提供给客户端工具。
使用 Sealed Secrets 时的典型工作流如下图所示:

图 12.12 – Sealed Secrets 工作流
现在,让我们继续安装 Sealed Secrets 操作员。
安装 Sealed Secrets 操作员
要安装Sealed Secrets 操作符,你只需从最新版本的github.com/bitnami-labs/sealed-secrets/releases下载控制器清单。在编写本书时,最新的控制器清单为github.com/bitnami-labs/sealed-secrets/releases/download/v0.23.1/controller.yaml。
在manifest目录下创建一个名为sealed-secrets的新目录,并使用以下命令下载controller.yaml:
$ cd ~/mdo-environments/manifests & mkdir sealed-secrets
$ cd sealed-secrets
$ wget https://github.com/bitnami-labs/sealed-secrets\
/releases/download/v0.23.1/controller.yaml
然后,将更改提交并推送到远程仓库。大约五分钟后,Argo CD 会创建一个名为sealed-secrets的新应用程序并部署。你可以在 Argo CD Web UI 中查看:

图 12.13 – Argo CD Web UI – Sealed Secrets
在 Kubernetes 集群中,sealed-secrets-controller将在kube-system命名空间中可见。运行以下命令来检查:
$ kubectl get deployment -n kube-system sealed-secrets-controller
NAME READY UP-TO-DATE AVAILABLE AGE
sealed-secrets-controller 1/1 1 1 6m4s
如我们所见,控制器正在运行并已准备好。现在我们可以安装客户端工具kubeseal。
安装 kubeseal
要安装客户端工具,你可以访问github.com/bitnami-labs/sealed-secrets/releases,从页面中获取kubeseal安装二进制文件的链接。以下命令将会在你的系统中安装kubeseal 0.23.1:
$ KUBESEAL_VERSION='0.23.1'
$ wget "https://github.com/bitnami-labs/sealed-secrets/releases/download\
/v${KUBESEAL_VERSION:?}/kubeseal-${KUBESEAL_VERSION:?}-linux-amd64.tar.gz"
$ tar -xvzf kubeseal-${KUBESEAL_VERSION:?}-linux-amd64.tar.gz kubeseal
$ sudo install -m 755 kubeseal /usr/local/bin/kubeseal
$ rm -rf ./kubeseal*
要检查kubeseal是否已成功安装,请运行以下命令:
$ kubeseal --version
kubeseal version: 0.23.1
既然kubeseal已经安装完成,我们接下来就创建一个blog-app的 Sealed Secret。
创建 Sealed Secrets
要创建 Sealed Secret,我们必须定义 Kubernetes Secret 资源。mongodb-creds Secret 应包含一些键值对,键MONGO_INITDB_ROOT_USERNAME的值为root,键MONGO_INITDB_ROOT_PASSWORD的值为你希望设置的密码。
由于我们不希望将明文的 Secret 作为文件存储,首先我们将使用--dry-run和-o yaml标志创建一个名为mongodb-creds的 Kubernetes Secret 清单,然后将输出直接通过管道传送到kubeseal,以生成SealedSecret资源,命令如下:
$ kubectl create secret generic mongodb-creds \
--dry-run=client -o yaml --namespace=blog-app \
--from-literal=MONGO_INITDB_ROOT_USERNAME=root \
--from-literal=MONGO_INITDB_ROOT_PASSWORD=<your_pwd> \
| kubeseal -o yaml > mongodb-creds-sealed.yaml
这将生成mongodb-creds-sealed.yaml Sealed Secret,其内容如下:
apiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata:
name: mongodb-creds
namespace: blog-app
spec:
encryptedData:
MONGO_INITDB_ROOT_PASSWORD: AgB+tyskf72M/…
MONGO_INITDB_ROOT_USERNAME: AgA95xKJg8veOy8v/…
template:
metadata:
name: mongodb-creds
namespace: blog-app
如你所见,Sealed Secret 与 Secret 清单非常相似。然而,它并没有包含 Base64 编码的秘钥值,而是对其进行了加密,以便只有 Sealed Secrets 控制器才能解密。你可以轻松地将这个文件提交到版本控制中。接下来,我们就这么做。使用以下命令将 Sealed Secret YAML 文件移动到manifests/blog-app目录:
$ mkdir -p ~/mdo-environments/manifests/blog-app/
$ mv mongodb-creds-sealed.yaml ~/mdo-environments/manifests/blog-app/
现在我们已经成功生成了 Sealed Secret 并将其移动到manifests/blog-app目录,接下来我们将在下一节设置应用程序的其他部分。
部署示例博客应用
要部署示例博客应用,我们需要定义应用资源。我们已经讨论过应用的组成。我们将应用包定义为一个 Kubernetes 清单文件,名为blog-app.yaml。我们需要使用以下命令将此 YAML 文件复制到manifests/blog-app目录:
$ cp ~/modern-devops/ch12/blog-app/blog-app.yaml \
~/mdo-environments/manifests/blog-app/
我已经预先构建了微服务,并使用了所需的git-sha作为标签,就像我们在上一章中做的那样。你可以编辑 YAML 文件,并将每个应用的镜像替换为你的镜像。
完成后,提交并推送更改到mdo-environments仓库。
一旦你推送更改,你应该注意到blog-app应用在五分钟内会开始出现在 Argo CD UI 中:

图 12.14 – Argo CD Web UI – 应用
等待应用进度更新。一旦显示为绿色,你应该能在应用中看到以下内容:

图 12.15 – Argo CD Web UI – blog-app
现在应用已经完全同步,我们可以检查在blog-app命名空间中创建的资源。首先,使用以下命令列出服务:
$ kubectl get svc -n blog-app
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S)
frontend LoadBalancer 10.71.244.154 34.68.221.0 80:3203/TCP
mongodb ClusterIP None <none> 27017/TCP
posts ClusterIP 10.71.242.211 <none> 5000/TCP
ratings ClusterIP 10.71.244.78 <none> 5000/TCP
reviews ClusterIP 10.71.247.128 <none> 5000/TCP
users ClusterIP 10.71.241.25 <none> 5000/TCP
如我们所见,它列出了我们定义的所有服务。请注意,frontend 服务是LoadBalancer类型,并且有一个外部 IP。记下这个外部 IP,因为我们将用它来访问应用。
现在,让我们列出pods,看看所有微服务是否都正常运行:
$ kubectl get pod -n blog-app
NAME READY STATUS RESTARTS
frontend-7cbdc4c6cd-4jzdw 1/1 Running 0
mongodb-0 1/1 Running 0
posts-588d8bcd99-sphpm 1/1 Running 0
ratings-7dc45697b-wwfqd 1/1 Running 0
reviews-68b7f9cb8f-2jgvv 1/1 Running 0
users-7cdd4cd94b-g67zw 1/1 Running 0
如我们所见,所有的 Pods 都运行正常。请注意,mongodb-0 Pod 包含数字前缀,但其他 Pods 具有随机 UUID。你可能还记得,当我们创建mongodb-creds秘密时,它也已被创建:
$ kubectl get secret -n blog-app
NAME TYPE DATA AGE
mongodb-creds Opaque 2 80s
在这里,我们可以看到mongodb-creds秘密已经创建。这表明 SealedSecret 工作正常。
现在,让我们通过打开http://<frontend-svc-external-ip>来访问我们的应用。如果看到以下页面,说明应用已正确部署:

图 12.16 – 博客应用首页
作为练习,点击登录 > 还不是用户?创建账户,然后填写信息进行注册。你可以创建一个新帖子,添加评论,并提供评分。你还可以更新评论,删除评论,更新评分等。尝试使用应用,查看是否所有功能都正常工作。你应该能够看到类似以下内容:

图 12.17 – 博客应用文章
由于我们对应用程序很满意,我们可以从 dev 分支向 prod 分支发起拉取请求。一旦你合并了拉取请求,你会看到相似的服务出现在生产环境中。你也可以使用基于拉取请求的门控进行 CD。这确保了你的环境保持独立,尽管它们来自同一个仓库,只是来自不同的分支。
总结
本章涵盖了持续部署和交付,并理解了 CD 的必要性以及容器应用程序的基本 CD 工作流。我们讨论了几种现代部署策略,并了解了 CI 工具无法满足这些责任。利用 GitOps 原则,我们创建了一个环境仓库,并通过 GitHub Actions 使用基于推送的模型部署了基于 GKE 的环境。然后,我们看到了如何使用 Argo CD 作为我们的 CD 工具并安装它。为了避免将敏感信息(如密钥)提交到 Git 中,我们讨论了 Bitnami 的 Sealed Secrets。接着,我们使用 Argo CD 和 GitOps 部署了示例博客应用程序。
在下一章中,我们将探讨现代 DevOps 中的另一个重要方面——确保部署管道的安全。
问题
回答以下问题以测试你对本章内容的掌握:
-
以下哪些是 CD 工具?(选择三个)
A. Spinnaker
B. GitHub
C. Argo CD
D. AWS Code Deploy
-
CD 需要人工干预才能部署到生产环境。(正确/错误)
-
Argo CD 开箱即用支持蓝绿部署。(正确/错误)
-
你会使用什么来启动 Argo CD 的部署?
A. 手动触发管道
B. 提交更改到你的 Git 仓库
C. 使用 CI 触发 Argo CD 管道
D. Argo CD 管道不会响应外部刺激
-
Argo CD 的 ApplicationSet 帮助基于模板生成应用程序。(正确/错误)
-
你应该优先选择哪些分支名称用于你的环境仓库?
A.
dev、staging和prodB.
feature、develop和masterC.
release和main -
以下哪些部署模型是 Argo CD 使用的?
A. 推送模型
B. 拉取模型
C. 阶段性模型
-
你应该使用 Terraform 来安装 Argo CD,因为你可以将所有配置存储在 Git 中。(正确/错误)
-
Argo CD 可以从以下哪些来源同步资源?(选择两个)
A. Git 仓库
B. 容器注册表
C. JFrog Artifactory 的原始仓库
-
如果你在 Git 之外手动更改了一个资源,Argo CD 会怎么办?
A. Argo CD 会更改资源,使其与 Git 配置匹配
B. Argo CD 会通知你资源在 Git 之外发生了变化
C. Argo CD 什么也不做
-
你可以将 Sealed Secrets 提交到 Git 仓库中。(正确/错误)
答案
以下是本章问题的答案:
-
A、C 和 D
-
正确
-
正确
-
B
-
正确
-
A
-
B
-
正确
-
A, B
-
A
-
正确
第十三章:保护和测试您的 CI/CD 管道
在前几章中,我们探讨了持续集成 (CI) 和持续部署/交付 (CD) ,并将 GitOps 作为核心概念。这两个概念及其相关工具帮助我们更快速地交付更好的软件。然而,技术的一个关键方面是安全性和质量保障。虽然在 DevOps 的早期并没有考虑到安全性,但随着 DevSecOps 的出现,现代 DevOps 现在非常重视安全性。在本章中,我们将尝试理解容器应用程序安全性和测试的相关概念,以及如何在 CI 和 CD 中应用这些概念。
在本章中,我们将涵盖以下主要内容:
-
安全性和测试 CI/CD 管道
-
回顾博客应用程序
-
容器漏洞扫描
-
管理机密
-
二进制授权
-
使用拉取请求进行发布门控,并将我们的应用部署到生产环境
-
现代 DevOps 管道的安全性和测试最佳实践
技术要求
在本章中,我们将启动一个基于云的 Kubernetes 集群,Google Kubernetes Engine (GKE),用于练习。目前,Google Cloud Platform (GCP) 提供一个免费的 $300 试用,持续 90 天,因此你可以前往console.cloud.google.com/注册。
你还需要克隆以下 GitHub 仓库,以便进行一些练习:
github.com/PacktPublishing/Modern-DevOps-Practices-2e。
你可以使用 GCP 上提供的 Cloud Shell 服务来跟随本章内容。进入 Cloud Shell 并启动一个新会话。运行以下命令将仓库克隆到你的主目录,以便访问所需资源:
$ git clone https://github.com/PacktPublishing/Modern-DevOps-Practices-2e.git \
modern-devops
我们还需要设置项目 ID,并启用一些我们将在本章中使用的 GCP API。为此,请运行以下命令:
$ PROJECT_ID=<YOUR_PROJECT_ID>
$ gcloud services enable iam.googleapis.com \
container.googleapis.com \
binaryauthorization.googleapis.com \
containeranalysis.googleapis.com \
secretmanager.googleapis.com \
cloudresourcemanager.googleapis.com \
cloudkms.googleapis.com
接下来,让我们看看如何保护和测试 CI/CD 管道。
安全性和测试 CI/CD 管道
随着持续的网络威胁和网络安全专家与网络犯罪分子之间的持续斗争,安全性一直是大多数组织的首要任务,并且它也构成了成熟组织投资的一个重要部分。
然而,安全性伴随着成本。大多数组织都有网络安全团队定期审核他们的代码并提供反馈。然而,这一过程通常较慢,并且发生在大部分代码已经开发完成且难以修改时。
类似地,虽然大多数组织都强调自动化测试,但许多仍然严重依赖手动测试。手动测试不仅劳动密集,而且缺乏可重复性。DevOps 非常重视自动化测试,以确保每次发布时都能重复执行,能够发现现有问题并彻底测试新功能。此外,自动化对于高效地进行回归测试至关重要,因为在这种情况下,手动测试效率低下。
因此,在开发初期嵌入安全性和测试是现代 DevOps 的一个关键目标。将安全性与 DevOps 相结合催生了 DevSecOps 的概念,在这个概念中,开发人员、网络安全专家和运维团队协同工作,更快地创建更好、更安全的软件。
使用 CI/CD 流水线进行软件安全和测试可以带来许多显著的商业优势。首先,它通过保护敏感数据、防止漏洞并确保合规性来确保安全性。其次,它通过早期发现问题、一致性和更高的产品质量来提升质量和可靠性。这将通过减少返工、加速上市时间和优化资源使用来降低成本。此外,它通过增强韧性和进行压力测试来降低风险。更重要的是,它通过灾难恢复和高效的回滚程序来确保业务连续性。此外,它通过促进更快的创新和市场响应来提供竞争优势。最后,通过增强客户对产品和服务的信任以及保护品牌声誉,它提高了声誉和客户信任。总之,保护和测试 CI/CD 流水线不仅是技术上的必要性,也是战略性的商业迫切需求,它提升了安全性、质量和可靠性,同时降低了成本和风险,最终提高了客户满意度、业务连续性和市场中的竞争力。
在软件供应链中嵌入安全性的方式有很多种。某些方法可能包括静态代码分析、安全性测试,以及在过程中应用组织特定的安全策略,但安全的目的是不让开发速度变慢。我们可以用工具来代替人工输入,这些工具能够显著改善我们开发软件的安全性。类似地,测试不必是手动和缓慢的,相反,应该使用自动化来与 CI/CD 流水线无缝对接。
CI/CD 流水线是现代 DevOps 的核心特性之一,它协调所有流程并结合所有工具以更快交付更好的软件,但你如何确保它们的安全性呢?你可能想问以下问题:
-
如何扫描容器镜像以查找漏洞?
-
我如何安全地存储和管理敏感信息和机密?
-
如何确保在部署到生产环境之前测试我的应用程序?
-
如何确保只有经过测试和批准的容器映像才能部署到生产环境?
在本章中,我们将尝试使用最佳实践和工具来回答这些问题。作为参考,请查看以下工作流程图:

图 13.1 – 安全的 CI/CD 工作流程
如前图所示,我们需要修改 CI 管道以包括额外的漏洞扫描步骤。我们还需要两个 CD 管道,一个用于开发环境,另一个用于生产环境。为增强可重用性,我们将重新构造 GitHub Actions 工作流程。我们将工作流划分为父工作流和子工作流。让我们从检查用于开发环境的 CD 工作流开始,以获取概述:
name: Dev Continuous Delivery Workflow
on:
push:
branches: [ dev ]
jobs:
create-environment-and-deploy-app:
name: Create Environment and Deploy App
uses: ./.github/workflows/create-cluster.yml
secrets: inherit
run-tests:
name: Run Integration Tests
needs: [create-environment-and-deploy-app]
uses: ./.github/workflows/run-tests.yml
secrets: inherit
binary-auth:
name: Attest Images
needs: [run-tests]
uses: ./.github/workflows/attest-images.yml
secrets: inherit
raise-pull-request:
name: Raise Pull Request
needs: [binary-auth]
uses: ./.github/workflows/raise-pr.yml
secrets: inherit
工作流程从 name 开始,然后声明 on push branches dev。此配置确保工作流在每次推送到 dev 分支时触发。我们按顺序定义多个作业,每个作业都使用 needs 属性依赖于前一个作业。每个作业通过设置 uses 属性调用子工作流,并通过为 secrets 属性设置 inherit 来向这些子工作流提供 GitHub 秘密。
该工作流程完成以下任务:
-
设置开发 Kubernetes 集群,配置 Argo CD 和支持工具以建立环境,并部署示例博客应用程序。
-
在部署的博客应用上执行集成测试。
-
如果测试通过,它会利用二进制授权(详细信息将在后续提供)来证明图像,确保只有经过测试的构件允许部署到生产环境。
-
为部署到生产环境启动拉取请求。
类似地,我们有以下生产 CD 工作流文件:
name: Prod Continuous Delivery Workflow
on:
push:
branches: [ prod ]
jobs:
create-environment-and-deploy-app:
name: Create Environment and Deploy App
uses: ./.github/workflows/create-cluster.yml
secrets: inherit
run-tests:
name: Run Integration Tests
needs: [create-environment-and-deploy-app]
uses: ./.github/workflows/run-tests.yml
secrets: inherit
此工作流类似于开发工作流,但不包括 binary-auth 和 raise-pull-request 步骤,因为在这个阶段它们是不必要的。为了更好地理解它,让我们从检查开发工作流开始。开发工作流的初始步骤涉及创建环境并部署应用程序。但在继续之前,让我们在下一节重新审视博客应用程序。
重新审视博客应用程序
正如我们在上一章节中已经讨论的博客应用程序,让我们再次查看服务及其交互如下图所示:

图 13.2 – 博客应用服务和交互
我们已经使用 GitHub Actions 创建了 CI 和 CD 管道,用于构建、测试和推送我们的博客应用程序微服务容器,并在 GKE 集群中使用 Argo CD 部署它们。
如果你还记得,我们为应用程序创建了以下资源,以使其无缝运行:
-
MongoDB – 我们部署了一个启用了身份验证的 MongoDB 数据库,并设置了 root 凭证。凭证通过来自 Kubernetes Secret资源的环境变量注入。为了持久化数据库数据,我们创建了一个挂载到容器的PersistentVolume,并使用PersistentVolumeClaim动态提供它。由于容器是有状态的,我们使用了StatefulSet来管理它,因此需要一个无头的 Service 来暴露数据库。
-
帖子、评论、评分和用户 – 帖子、评论、评分和用户微服务通过来自同一Secret资源的环境变量注入的 root 凭证与 MongoDB 进行交互。我们使用各自的Deployment资源部署了这些服务,并通过各自的ClusterIP服务暴露它们。
-
Frontend – 前端微服务不需要与 MongoDB 交互,因此不与Secret资源发生交互。我们同样使用Deployment资源部署了该服务。由于我们希望将该服务暴露到互联网,我们为它创建了一个LoadBalancer Service。
我们可以通过以下图表来总结它们:

图 13.3 – 博客应用程序 – Kubernetes 资源与交互
在随后的章节中,我们将涵盖实施此工作流程的所有方面,从漏洞扫描开始。
容器漏洞扫描
完美的软件开发和维护成本高昂,每当有人对正在运行的软件进行更改时,破坏某些功能的可能性就很高。除了其他错误之外,更改还会引入大量软件漏洞。作为软件开发人员,您无法避免这些问题。网络安全专家和网络犯罪分子之间的斗争是持续进行的,并随着时间不断发展。每天都会发现并报告一组新的漏洞。
在容器中,漏洞可能在多个方面存在,且可能与您负责的部分完全无关。好吧,开发人员编写代码,优秀的开发人员会确保代码的安全性。但您永远不知道基础镜像中是否存在开发人员可能完全忽视的漏洞。在现代的 DevOps 中,漏洞是不可避免的,关键是尽可能地减少它们。我们应该减少漏洞,但手动处理漏洞是耗时的,容易导致繁琐的工作。
市场上有多个工具可以提供容器漏洞扫描服务。其中一些是开源工具,如Anchore、Clair、Dagda、OpenSCAP、Sysdig 的Falco,或者是可通过Google Container Registry(GCR)、Amazon Elastic Container Registry(ECR)和Azure Defender提供的SaaS服务。对于本章内容,我们将讨论Anchore Grype。
Anchore Grype (github.com/anchore/grype) 是一个容器漏洞扫描器,它会扫描你的镜像中的已知漏洞并报告其严重性。根据这些信息,你可以采取适当的措施,通过更换基础镜像或修改层来删除易受攻击的组件,从而防止漏洞的发生。
Anchore Grype 是一个简单的命令行界面(CLI)工具,可以作为二进制文件安装并在任何地方运行——无论是在本地系统还是 CI/CD 流水线中。你还可以配置它,如果漏洞级别超过特定阈值,自动使流水线失败,从而将安全性嵌入到你的自动化中——所有这些都不会给开发或安全团队带来麻烦。
现在,让我们继续看看 Anchore Grype 的实际操作情况。
安装 Anchore Grype
由于我们希望在 CI 流水线中实施漏洞扫描,让我们修改我们在第十一章中创建的 mdo-posts 仓库。
让我们首先使用以下命令克隆仓库,并 cd 进入 workflows 目录:
$ git clone git@github.com:<your_github_user>/mdo-posts.git
$ cd mdo-posts/.github/workflows/
Anchore Grype 在其 GitHub 仓库中提供了一个安装脚本,你可以下载并运行,它应该会为你设置好。我们将修改 build.yaml 文件,在 登录到 Docker Hub 步骤之前添加以下步骤,以便在 CI 工作流中安装 Grype:
- name: Install Grype
id: install-grype
run: curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s
-- -b /usr/local/bin
接下来,我们需要使用 Grype 扫描我们的镜像以检查漏洞。
扫描镜像
要运行容器漏洞扫描,我们可以使用以下命令:
$ grype <container-image>
这将报告镜像中漏洞的列表,并按严重性划分—Negligible(轻微)、Low(低)、Medium(中等)、High(高)、Critical(严重)或Unknown(未知)。我们还可以在 Grype 中设置阈值,当任何漏洞的级别等于或高于该阈值时使其失败。例如,如果我们不希望容器中出现任何Critical(严重)漏洞,我们可以使用以下命令:
$ grype -f critical <container-image>
为此,我们将在 build.yaml 文件中,在 构建 Docker 镜像 步骤后添加以下步骤:
- name: Scan Image for Vulnerabilities
id: vul-scan
run: grype -f critical ${{ secrets.DOCKER_USER }}/mdo-posts:$(git rev-parse --short
"$GITHUB_SHA")
由于我们已经做出了所有更改,让我们使用以下命令推送修改后的 CI 流水线:
$ cp ~/modern-devops/ch13/grype/build.yaml .
$ git add --all
$ git commit -m "Added grype"
$ git push
一旦我们推送镜像,我们将在 GitHub Actions 标签页中看到以下内容:

图 13.4 – 漏洞扫描失败
如我们所见,Grype 已报告了多个漏洞,其中一个为 Critical(严重)。它还导致了 CI 流水线失败。这就是自动化漏洞扫描的实际应用。它会发现漏洞,并且只有当构建满足最低安全标准时,才会将其发布到你的容器注册表中。
我们需要修复这里的问题,因此让我们查看一个更新的镜像,看看它是否能解决这个问题。因此,除了使用python:3.7-alpine,我们将使用python:alpine3.18。让我们这样做,并使用以下命令将代码推送到 GitHub:
$ cd ~/mdo-posts && cp ~/modern-devops/ch13/grype/Dockerfile .
$ git add --all
$ git commit -m "Updated base image"
$ git push
让我们重新查看 GitHub Actions,看看在build输出中得到什么:

图 13.5 – 漏洞扫描成功
这次漏洞扫描没有阻止我们的 CI 构建,因为没有发现Critical漏洞。
提示
随着时间的推移,不断更新基础镜像,因为更新后的镜像包含更少的漏洞,并修复了旧版中的漏洞。
现在我们已经确保镜像没有漏洞,我们的 CI 管道已经完成。您可以根据需要为其他微服务复制此过程。让我们继续讨论 CD 管道。
如果您记得,在上一章中,我们按照 GitOps 模式将所有资源的清单存储在 Git 上。然而,由于 Kubernetes Secrets 存在安全隐患,我们使用了SealedSecrets来安全地管理它们。
然而,由于以下固有问题,这可能并不是所有团队的理想解决方案:
-
SealedSecrets 依赖于加密它们的控制器。如果我们丢失了这个控制器,我们也失去了重新创建机密的能力,从而实质上丧失了该机密。
-
访问机密仅限于登录集群并使用
kubectl,这种方式不会为非管理员提供管理机密的能力。虽然这种方法可能适合某些团队,但可能不适合其他团队。
因此,我们将探索使用密钥管理工具来管理密钥,以建立一种标准化的方法,集中管理密钥,并对访问权限进行更精细的控制。让我们在下一节深入探讨这个话题。
管理机密
软件始终需要访问敏感信息,如用户数据、凭证、开放授权(OAuth)令牌、密码以及其他被称为机密的信息。在开发和管理软件时,确保这些方面的安全性一直是一个关注点。CI/CD 管道可能会处理这些信息,因为它们通过结合代码和来自多个源的其他依赖项来构建并交付工作软件,其中可能包括敏感信息。保持这些信息的安全性至关重要;因此,需要使用现代的 DevOps 工具和技术,将安全性嵌入到 CI/CD 管道中。
大多数应用程序代码都需要访问敏感信息。这些信息在 DevOps 领域被称为机密。机密是任何有助于某人证明其身份、进行身份验证以及授权特权账户、应用程序和服务的数据。以下是一些可能构成机密的候选项:
-
密码
-
API 令牌、GitHub 令牌以及其他任何应用程序密钥
-
安全外壳(SSH)密钥
-
传输层安全性(TLS)、安全套接字层(SSL)和相当好的隐私(PGP)私钥
-
一次性密码
一个好的例子是,容器需要访问 API 密钥以进行第三方 API 身份验证,或者需要用户名和密码来进行后端数据库的身份验证。开发人员需要理解在哪里以及如何存储机密,以确保它们不会无意中暴露给不应查看它们的人。
当我们运行 CI/CD 管道时,必须理解如何处理这些机密,因为在 CI/CD 管道中,我们从源代码开始构建一切。“不要将机密与代码一起存储”是我们都听过的重要建议。
提示
永远不要将硬编码的机密存储在 CI/CD 管道中,或将机密存储在源代码仓库中,如 Git。
我们如何在不将机密包含在代码中的情况下访问它们,从而运行完全自动化的 GitOps 基于的 CI/CD 管道呢?好吧,这就是我们需要弄清楚的事情。
提示
使用容器时,应该避免将机密嵌入到镜像中。虽然这是一个广为人知的建议,但许多开发人员无意中这么做,导致许多安全漏洞。这是非常不安全的,你应该避免这样做。
你可以通过使用某种形式的秘密管理解决方案来克服这个问题。秘密管理解决方案或密钥管理解决方案帮助存储和管理机密,并通过静态和传输加密保护它们。云服务提供商内部有秘密管理工具,如 GCP 中的秘密管理器和亚马逊 Web 服务(AWS),或者如果你希望使用与云无关的工具,可以使用第三方工具,如HashiCorp Vault。所有这些解决方案都提供 API 用于在运行时创建和查询机密,并通过 HTTPS 安全 API 以允许加密传输。这样,你就不需要将机密与代码一起存储或嵌入镜像中。
在本讨论中,我们将使用 GCP 提供的秘密管理器解决方案来存储机密,并在运行 CI/CD 管道时访问它们。秘密管理器是 Google Cloud 的秘密管理系统,它帮助你集中存储和管理机密。它非常安全,使用硬件安全模块(HSMs)进一步加固你的机密。
在本章中,我们将着眼于改进上章讨论的博客应用程序的 CI/CD 管道,并将使用相同的示例应用程序。因此,接下来我们将创建 Google Cloud Secret Manager 中的 mongodb-creds 机密。
在 Google Cloud Secret Manager 中创建一个机密
让我们创建一个名为 external-secrets 的机密,在其中我们将以 JSON 格式传递 MongoDB 凭据。为此,请运行以下命令:
$ echo -ne \
'{"MONGO_INITDB_ROOT_USERNAME": "root", "MONGO_INITDB_ROOT_PASSWORD": "itsasecret"}' \
| gcloud secrets create external-secrets --locations=us-central1 \
--replication-policy=user-managed --data-file=-
Created version [1] of the secret [external-secrets].
在前面的命令中,我们将包含 MONGO_INITDB_ROOT_USERNAME 和 PASSWORD 的 JSON 直接传递给 gcloud secrets create 命令。我们指定了一个特定的位置,以避免在其他区域重复它,从而节省成本。然而,强烈建议复制密钥,以防在区域性故障时发生潜在的丢失。该 JSON 被存储为密钥的新版本。Secret Manager 使用版本控制来管理密钥,因此分配给密钥(external-secrets)的任何新值都会被版本化并存储在 Secret Manager 中。你可以通过版本号或使用 latest 关键字来引用特定版本,以访问最新的版本。
从输出中可以看到,我们已经创建了密钥的第一个版本(版本 1)。通常,这是在开发阶段完成的,应该保持在 CI/CD 流程之外。你可以将 Secret 资源清单保存在 Secret Manager 中,而不是存储在源代码库中。
现在我们已经创建了密钥,我们必须在应用程序中访问它。为此,我们需要一个工具来访问 Kubernetes 集群中的 Secret Manager 中存储的密钥。为此,我们将使用External Secrets Operator。
使用 External Secrets Operator 访问外部密钥
External Secrets Operator (external-secrets.io/latest/) 是一个 Kubernetes 操作器,用于在 Kubernetes 集群中安全地管理外部密钥。它旨在自动检索和管理存储在外部密钥存储系统中的密钥,如 AWS Secret Manager、GCP Secret Manager、Hashicorp Vault 等,并将它们作为 Kubernetes Secrets 注入到 Kubernetes pod 中。操作器是一种扩展 Kubernetes 功能并自动化任务的方式。
它是如何工作的
External Secrets Operator 作为 Kubernetes 集群与外部密钥管理系统之间的桥梁。我们在 Kubernetes 集群中定义一个 ExternalSecret 自定义资源,操作器会监视它。当创建或更新 ExternalSecret 资源时,操作器与 ClusterSecretStore CRD 中指定的外部密钥存储交互,以检索密钥数据。然后,它创建或更新相应的 Kubernetes Secrets。该过程在下面的图示中展示:

图 13.6 – 外部密钥操作器
现在,这个过程有很多好处,其中一些如下:
-
增强安全性:密钥保存在专用的安全密钥存储中
-
自动化:自动化密钥的检索和轮换
-
简化部署:简化了 Kubernetes 应用程序中密钥的管理
-
兼容性:支持多种外部密钥存储,使其具有多功能性
现在,让我们继续在 Kubernetes 集群中安装 External Secrets Operator。
安装 External Secrets Operator
外部密钥操作符可通过 manifests/argocd/external-secrets.yaml 清单文件获取:
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: external-secrets
namespace: argocd
spec:
project: default
source:
chart: external-secrets/external-secrets
repoURL: https://charts.external-secrets.io
targetRevision: 0.9.4
helm:
releaseName: external-secrets
destination:
server: "https://kubernetes.default.svc"
namespace: external-secrets
应用清单在 default 项目中的 argocd 命名空间下创建了一个 external-secrets 应用。它从 external-secrets Helm 图表仓库下载 0.9.4 版本,并将图表部署到 Kubernetes 集群中的 external-secrets 命名空间。
要安装此应用,我们需要使用 Terraform 应用此清单。因此,我们在 app.tf 文件中做出如下输入:
data "kubectl_file_documents" "external-secrets" {
content = file("../manifests/argocd/external-secrets.yaml")
}
resource "kubectl_manifest" "external-secrets" {
depends_on = [
kubectl_manifest.argocd,
]
for_each = data.kubectl_file_documents.external-secrets.manifests
yaml_body = each.value
override_namespace = "argocd"
}
为了部署这一应用,我们必须将这些文件提交到源代码管理。让我们克隆上一章节中创建的 mdo-environments 仓库。
如果你没有按照上一章节的步骤操作,可以通过以下方法设置一个基础环境。如果你已经在第十二章中完成了环境设置,可以跳过下一节,继续进行持续部署/交付与 Argo CD。
设置基础环境
为了确保与上一章节的连续性,让我们首先创建一个服务帐户,以便 Terraform 可以与我们的 GCP 项目交互,使用以下命令:
$ gcloud iam service-accounts create terraform \
--description="Service Account for terraform" \
--display-name="Terraform"
$ gcloud projects add-iam-policy-binding $PROJECT_ID \
--member="serviceAccount:terraform@$PROJECT_ID.iam.gserviceaccount.com" \
--role="roles/editor"
$ gcloud iam service-accounts keys create key-file \
--iam-account=terraform@$PROJECT_ID.iam.gserviceaccount.com
你会在工作目录中看到一个名为 key-file 的文件。现在,在 GitHub 上创建一个名为 mdo-environments 的新仓库,并添加一个 README.md 文件,重命名 main 分支为 prod,并使用 GitHub 创建一个名为 dev 的新分支。然后访问 https://github.com/<your_github_user>/mdo-environments/settings/secrets/actions/new,创建一个名为 GCP_CREDENTIALS 的密钥。在值字段中,打印出 key-file 文件的内容,复制并粘贴到 GitHub 密钥的 values 字段中。
接下来,创建另一个密钥 PROJECT_ID,并在 values 字段中指定你的 GCP 项目 ID。
接下来,我们需要为 Terraform 创建一个 GCS 存储桶,作为远程后端使用。为此,运行以下命令:
$ gsutil mb gs://tf-state-mdo-terraform-${PROJECT_ID}
现在,所有前提条件都已满足,我们可以克隆我们的仓库并复制基础代码。运行以下命令来完成此操作:
$ cd ~ && git clone git@github.com:<your_github_user>/mdo-environments.git
$ cd mdo-environments/
$ git checkout dev
$ cp -r ~/modern-devops/ch13/baseline/* .
$ cp -r ~/modern-devops/ch13/baseline/.github .
既然我们已经设置了基础环境,接下来让我们继续使用 Terraform 安装外部密钥。
使用 Terraform 安装外部密钥
让我们配置本地仓库来安装外部密钥清单。为此,使用以下命令复制应用清单和 app.tf 文件:
$ cp ~/modern-devops/ch13/install-external-secrets/app.tf terraform/app.tf
$ cp ~/modern-devops/ch13/install-external-secrets/external-secrets.yaml \
manifests/argocd/
既然一切准备就绪,让我们使用以下命令提交并推送我们的代码:
$ git add --all
$ git commit -m "Install external secrets operator"
$ git push
一旦我们推送代码,就会看到 GitHub Actions 工作流被触发。要访问该工作流,请访问 https://github.com/<your_github_user>/mdo-environments/actions。不久后,工作流会应用配置,创建 Kubernetes 集群,并部署 Argo CD、Sealed Secrets 控制器和 External Secrets Operator。
一旦工作流成功执行,我们可以按照以下步骤访问 Argo Web UI。
我们首先需要对 GKE 集群进行身份验证。为此,运行以下命令:
$ gcloud container clusters get-credentials \
mdo-cluster-dev --zone us-central1-a --project $PROJECT_ID
要使用 Argo CD Web UI,您需要argo-server服务的外部 IP 地址。要获取该地址,请运行以下命令:
$ kubectl get svc argocd-server -n argocd
NAME TYPE EXTERNAL-IP PORTS AGE
argocd-server LoadBalaner 34.122.51.25 80/TCP,443/TCP 6m15s
所以,现在我们知道可以通过https://34.122.51.25/访问 Argo CD。
接下来,我们将运行以下命令来重置管理员密码:
$ kubectl patch secret argocd-secret -n argocd \
-p '{"data": {"admin.password": null, "admin.passwordMtime": null}}'
$ kubectl scale deployment argocd-server --replicas 0 -n argocd
$ kubectl scale deployment argocd-server --replicas 1 -n argocd
现在,允许两分钟来生成新的凭证。然后,执行以下命令来获取密码:
$ kubectl -n argocd get secret argocd-initial-admin-secret \
-o jsonpath="{.data.password}" | base64 -d && echo
现在我们已经拥有了凭证,登录后将看到以下页面:

图 13.7 – Argo CD Web UI – 主页
如我们所见,有三个应用程序——SealedSecret清单,它是我们在上一章中创建的,因为它是由不同的 Sealed Secrets 控制器生成的。
我们不需要 Sealed Secrets 操作员,我们将改用 Google Cloud Secret Manager。所以,让我们使用以下命令从集群中移除它:
$ rm -rf manifests/sealed-secrets
$ git add --all
$ git commit -m "Removed sealed secrets"
$ git push
我们已移除 Sealed Secrets 操作员,Argo CD Web UI 应该很快反映出这一变化。然而,博客应用程序将保持降级状态,因为mongodb-creds Secret 仍然缺失。在接下来的部分中,我们将使用 External Secrets Operator 生成mongodb-creds Secret。
使用 External Secrets Operator 生成 MongoDB Kubernetes Secret
为了生成mongodb-creds Secret,我们需要创建以下资源:
-
一个
Secret资源——这是一个标准的 Kubernetes Secret 资源,包含服务账户凭证,以便 Kubernetes 与 GCP Secret Manager 连接。 -
一个
ClusterSecretStore资源——该资源包含与密钥存储(在此情况下为 GCP Secret Manager)连接的配置,并使用Secret资源提供服务账户凭证。 -
一个
ExternalSecret资源——该资源包含配置,用于从密钥存储中提取的 Secret 生成所需的 Kubernetes Secret(mongodb-creds)。
所以,首先让我们定义Secret资源:
为了创建Secret资源,我们首先需要创建一个 GCP 服务账户,以便与 Secret Manager 交互,使用以下命令:
$ cd ~
$ gcloud iam service-accounts create external-secrets
由于我们遵循最小权限原则,我们将添加以下角色绑定,仅提供对external-secrets Secret 的访问,如下所示:
$ gcloud secrets add-iam-policy-binding external-secrets \
--member "serviceAccount:external-secrets@$PROJECT_ID.iam.gserviceaccount.com" \
--role "roles/secretmanager.secretAccessor"
现在,使用以下命令生成服务账户密钥文件:
$ gcloud iam service-accounts keys create key.json \
--iam-account=external-secrets@$PROJECT_ID.iam.gserviceaccount.com
现在,将key.json文件的内容复制到一个新的 GitHub Actions 密钥中,命名为GCP_SM_CREDENTIALS。我们将在运行时使用 GitHub Actions 动态设置此值;因此,以下密钥清单将包含一个占位符:
apiVersion: v1
data:
secret-access-credentials: SECRET_ACCESS_CREDS_PH
kind: Secret
metadata:
name: gcpsm-secret
type: Opaque
接下来,让我们看一下ClusterSecretStore资源:
apiVersion: external-secrets.io/v1alpha1
kind: ClusterSecretStore
metadata:
name: gcp-backend
spec:
provider:
gcpsm:
auth:
secretRef:
secretAccessKeySecretRef:
name: gcpsm-secret
key: secret-access-credentials
projectID: PROJECT_ID_PH
清单定义了以下内容:
-
一个名为
gcp-backend的ClusterSecretStore资源 -
使用
gcpsm类型的提供程序配置,利用我们之前定义的gcpsm-secret密钥中的身份验证信息
现在,让我们看一下ExternalSecret资源的清单:
apiVersion: external-secrets.io/v1alpha1
kind: ExternalSecret
metadata:
name: mongodb-creds
namespace: blog-app
spec:
secretStoreRef:
kind: SecretStore
name: gcp-backend
target:
name: mongodb-creds
data:
- secretKey: MONGO_INITDB_ROOT_USERNAME
remoteRef:
key: external-secrets
property: MONGO_INITDB_ROOT_USERNAME
- secretKey: MONGO_INITDB_ROOT_PASSWORD
remoteRef:
key: external-secrets
property: MONGO_INITDB_ROOT_PASSWORD
清单定义了一个具有以下规格的 ExternalSecret 资源:
-
它在
blog-app命名空间中名为mongodb-creds。 -
它引用了我们定义的
gcp-backendClusterSecretStore。 -
它将
MONGO_INITDB_ROOT_USERNAME从external-secretsSecret Manager 密钥映射到mongodb-credsKubernetes 密钥的MONGO_INITDB_ROOT_USERNAME键。它对MONGO_INITDB_ROOT_PASSWORD执行相同操作。
现在,让我们使用以下命令部署这些资源:
$ cd ~/mdo-environments
$ cp ~/modern-devops/ch13/configure-external-secrets/app.tf terraform/app.tf
$ cp ~/modern-devops/ch13/configure-external-secrets/gcpsm-secret.yaml \
manifests/argocd/
$ cp ~/modern-devops/ch13/configure-external-secrets/mongodb-creds-external.yaml \
manifests/blog-app/
$ cp -r ~/modern-devops/ch13/configure-external-secrets/.github .
$ git add --all
$ git commit -m "Configure External Secrets"
$ git push
这应该再次触发 GitHub Actions 流程,很快我们应该会看到创建了 ClusterSecretStore 和 ExternalSecret。要检查这一点,请运行以下命令:
$ kubectl get secret gcpsm-secret
NAME TYPE DATA AGE
gcpsm-secret Opaque 1 1m
$ kubectl get clustersecretstore gcp-backend
NAME AGE STATUS CAPABILITIES READY
gcp-backend 19m Valid ReadWrite True
$ kubectl get externalsecret -n blog-app mongodb-creds
NAME STORE REFRESHINTERVAL STATUS READY
mongodb-creds gcp-backend 1h0m0s SecretSynced True
$ kubectl get secret -n blog-app mongodb-creds
NAME TYPE DATA AGE
mongodb-creds Opaque 2 4m45s
这应该反映在 Argo CD 中的 blog-app 应用中,且应用应正常启动,如以下截图所示:

图 13.8 – 显示为健康状态的博客应用
然后,你可以通过以下命令获取前端服务的外部 IP,从而访问应用:
$ kubectl get svc -n blog-app frontend
NAME TYPE EXTERNAL-IP PORT(S) AGE
frontend LoadBalancer 34.122.58.73 80:30867/TCP 153m
你可以通过浏览器访问 http://<EXTERNAL_IP> 来访问应用:

图 13.9 – 博客应用主页
如我们所见,我们可以成功访问博客应用。这是正确的密钥管理,因为我们没有将密钥存储在源代码仓库(Git)中。我们在应用密钥时没有查看或记录密钥,这意味着日志中没有任何密钥的痕迹,只有具有访问该应用运行命名空间权限的应用或人员才能访问它。现在,让我们看看另一个关键方面:测试你的应用。
在 CD 流程中测试你的应用
直到现在,我们已在 Kubernetes 集群中部署了应用并手动验证它正在运行。接下来我们有两个选项:继续进行手动测试或创建自动化测试,也就是测试套件。虽然手动测试是传统方法,但 DevOps 强烈强调自动化测试,并将其集成到 CD 流程中。这样,我们可以消除许多重复性任务,通常被称为苦力活。
我们为应用开发了一个基于 Python 的集成测试套件,涵盖了各种场景。这个测试套件的一个重要优点是,它将应用视为一个黑箱。它不了解应用是如何实现的,而专注于模拟最终用户交互。这种方法提供了关于应用功能方面的宝贵见解。
此外,由于这是集成测试,它评估的是整个应用作为一个整体单元,而与我们在 CI 流程中进行的单元测试不同,后者是在隔离的情况下测试每个微服务。
不再拖延,让我们将集成测试集成到我们的 CD 管道中。
CD 流程更改
到目前为止,我们在 CD 流程中有以下内容:
.
├── create-cluster.yml
├── dev-cd-workflow.yaml
└── prod-cd-workflow.yaml
开发和生产 CD 流程都包含以下任务:
jobs:
create-environment-and-deploy-app:
name: Create Environment and Deploy App
uses: ./.github/workflows/create-cluster.yml
secrets: inherit
如我们所见,它们都调用了 create-cluster.yml 工作流,该工作流创建我们的环境并部署我们的应用程序。我们需要在 Dev 和 Prod 环境中运行集成测试,因此我们需要将两个工作流更改为包括运行集成测试步骤,如下所示:
run-tests:
name: Run Integration Tests
needs: [deploy-app]
uses: ./.github/workflows/run-tests.yml
secrets: inherit
如我们所见,该步骤调用了 run-tests.yml 工作流。那就是执行集成测试的工作流。让我们看看工作流,以便更好地理解:
name: Run Integration Tests
on: [workflow_call]
jobs:
test-application:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./tests
steps:
- uses: actions/checkout@v2
- name: Extract branch name
run: echo "branch=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" >> $GITHUB_OUTPUT
id: extract_branch
- id: gcloud-auth
name: Authenticate with gcloud
uses: 'google-github-actions/auth@v1'
with:
credentials_json: '${{ secrets.GCP_CREDENTIALS }}'
- name: Set up Cloud SDK
id: setup-gcloud-sdk
uses: 'google-github-actions/setup-gcloud@v1'
- name: Get kubectl credentials
id: 'get-credentials'
uses: 'google-github-actions/get-gke-credentials@v1'
with:
cluster_name: mdo-cluster-${{ steps.extract_branch.outputs.branch }}
location: ${{ secrets.CLUSTER_LOCATION }}
- name: Compute Application URL
id: compute-application-url
run: external_ip=$(kubectl get svc -n blog-app frontend --output jsonpath='{.status.
loadBalancer.ingress[0].ip}') && echo ${external_ip} && sed -i "s/localhost/${external_
ip}/g" integration-test.py
- id: run-integration-test
name: Run Integration Test
run: python3 integration-test.py
该工作流执行以下任务:
-
它仅通过
workflow调用触发。 -
它具有
./tests工作目录。 -
它检出已提交的代码。
-
它安装
gcloudCLI 并使用GCP_CREDENTIALS服务账户凭据进行 Google Cloud 身份验证。 -
它将
kubectl连接到 Kubernetes 集群,以检索应用程序的 URL。 -
使用应用程序的 URL,它执行集成测试。
现在,让我们继续更新工作流,并使用以下命令添加测试:
$ cp -r ~/modern-devops/ch13/integration-tests/.github .
$ cp -r ~/modern-devops/ch13/integration-tests/tests .
$ git add --all
$ git commit -m "Added tests"
$ git push
这应该再次触发 Dev CD GitHub Actions 工作流。您应该看到如下内容:

图 13.10 – 添加测试工作流运行
如我们所见,工作流中有两个步骤,且两者现在都已成功。要查看测试的内容,您可以点击运行集成测试步骤,它应该显示以下输出:

图 13.11 – 运行集成测试工作流步骤
如我们所见,运行集成测试步骤报告所有测试已通过。
当镜像在 CI/CD 工具链中构建、部署和测试时,工作流之间没有任何东西可以防止某人将镜像直接部署到 Kubernetes 集群中。您可能正在扫描所有镜像以寻找漏洞并加以修复,但某处可能有人绕过所有控制,直接将容器部署到集群中。那么,如何防止这种情况发生呢?答案就是通过二进制授权。我们将在下一节中探讨这一点。
二进制授权
二进制授权是一种部署时的安全机制,确保仅信任的二进制文件被部署到您的环境中。在容器和 Kubernetes 的背景下,二进制授权使用签名验证,确保只有由受信任的授权机构签名的容器镜像才会在 Kubernetes 集群中部署。
使用二进制授权可以更严格地控制在集群中部署的内容。它确保只有经过测试并且由特定授权机构(如安全工具或人员)批准和验证的容器才会出现在集群中。
二进制授权通过在集群内强制执行规则来工作,这意味着你可以创建规则集,仅允许由认证机构签名的镜像在集群中部署。在实际场景中,你的 质量保证(QA)团队可以成为一个很好的审核员。你也可以将认证嵌入到 CI/CD 流水线中。认证意味着你的镜像已经过测试并扫描了漏洞,并且已经通过了最低标准,准备部署到集群中。
GCP 提供了嵌入在 GKE 中的二进制授权,基于开源项目 Kritis (github.com/grafeas/kritis)。它使用 公钥基础设施(PKI)来认证和验证镜像——因此你的镜像是由审核员使用私钥签名的,Kubernetes 使用公钥来验证镜像。以下图表生动地解释了这一过程:

图 13.12 – 二进制授权过程
在实际操作中,我们将使用 Google Cloud KMS 设置二进制授权和 PKI。接下来,我们将为所有启用二进制授权的 GKE 集群创建一个 QA 审核员和认证策略,确保只有经过认证的镜像可以被部署。由于我们的应用程序现在已经通过测试,下一步是对经过测试的镜像进行认证。因此,让我们继续在下一部分中设置 Dev CD 工作流中的二进制授权。
设置二进制授权
由于我们从一开始就使用 GitOps,因此我们将使用 Terraform 来为我们设置二进制授权。我们将从设置一些 GitHub Actions 密码开始。请访问 https://github.com/<your_github_user>/mdo-environments/settings/secrets/actions 并创建以下密码:
ATTESTOR_NAME=quality-assurance-attestor
KMS_KEY_LOCATION=us-central1
KMS_KEYRING_NAME=qa-attestor-keyring
KMS_KEY_NAME=quality-assurance-attestor-key
KMS_KEY_VERSION=1
然后,我们将创建一个 binaryauth.tf 文件,其中包含以下资源。
我们将首先创建一个 Google KMS 密钥环。由于二进制授权利用 PKI 来创建和验证认证,因此这个密钥环将使我们的审核员能够为镜像的认证进行数字签名。请注意以下代码中定义的 count 属性。它确保仅在我们打算使用审核员来认证镜像的 dev 环境中创建:
resource "google_kms_key_ring" "qa-attestor-keyring" {
count = var.branch == "dev" ? 1 : 0
name = "qa-attestor-keyring"
location = var.region
lifecycle {
prevent_destroy = false
}
}
然后我们将使用 Google 提供的 binary-authorization Terraform 模块来创建我们的 quality-assurance 审核员。该审核员使用我们之前创建的 Google KMS 密钥环:
module "qa-attestor" {
count = var.branch == "dev" ? 1 : 0
source = "terraform-google-modules/kubernetes-engine/google//modules/binary-
authorization"
attestor-name = "quality-assurance"
project_id = var.project_id
keyring-id = google_kms_key_ring.qa-attestor-keyring[0].id
}
最后,我们将创建一个二进制授权策略,指定在部署容器时集群的行为。在此场景中,我们的目标是仅部署经过认证的镜像。然而,我们将做一些例外,允许使用 Google 提供的系统镜像、Argo CD 和 External Secrets Operator 镜像。我们将把 global_policy_evaluation_mode 属性设置为 ENABLE,以避免强制执行 Google 管理的系统镜像上的策略。
admission_whitelist_patterns部分定义了允许在没有验证的情况下部署的容器镜像模式。这包括适用于 Google 管理的系统镜像、Argo CD 注册表、外部 Secrets 注册表以及 Argo CD 使用的 Redis 容器的模式。
defaultAdmissionRule部分要求使用我们创建的验证器进行验证。因此,任何其他镜像都需要验证才能在集群中运行:
resource "google_binary_authorization_policy" "policy" {
count = var.branch == "dev" ? 1 : 0
admission_whitelist_patterns {
name_pattern = "gcr.io/google_containers/*"...
name_pattern = "gcr.io/google-containers/*"...
name_pattern = "k8s.gcr.io/**"...
name_pattern = "gke.gcr.io/**"...
name_pattern = "gcr.io/stackdriver-agents/*"...
name_pattern = "quay.io/argoproj/*"...
name_pattern = "ghcr.io/dexidp/*"...
name_pattern = "docker.io/redis[@:]*"...
name_pattern = "ghcr.io/external-secrets/*"
}
global_policy_evaluation_mode = "ENABLE"
default_admission_rule {
evaluation_mode = "REQUIRE_ATTESTATION"
enforcement_mode = "ENFORCED_BLOCK_AND_AUDIT_LOG"
require_attestations_by = [
module.qa-attestor[0].attestor
]
}
}
为了在集群中强制执行二进制授权策略,我们还必须启用二进制授权。为此,我们需要在cluster.tf文件中添加以下内容:
resource "google_container_cluster" "main" {
...
dynamic "binary_authorization" {
for_each = var.branch == "prod" ? [1] : []
content {
evaluation_mode = "PROJECT_SINGLETON_POLICY_ENFORCE"
}
}
...
}
这个动态块仅在分支名称为prod时创建。采用这种方法的原因是我们打算将代码部署到开发环境,而无需验证镜像,进行测试,成功后再验证镜像。因此,只有生产集群应该禁止未验证的镜像。为此,我们将在开发 CD 工作流中包含以下步骤:
binary-auth:
name: Attest Images
needs: [run-tests]
uses: ./.github/workflows/attest-images.yml
secrets: inherit
正如您所看到的,这会调用attest-images.yml工作流。让我们现在来看一下:
...
steps:
- uses: actions/checkout@v2
- id: gcloud-auth ...
- name: Set up Cloud SDK ...
- name: Install gcloud beta
id: install-gcloud-beta
run: gcloud components install beta
- name: Attest Images
run: |
for image in $(cat ./images); do
no_of_slash=$(echo $image | tr -cd '/' | wc -c)
prefix=""
if [ $no_of_slash -eq 1 ]; then
prefix="docker.io/"
fi
if [ $no_of_slash -eq 0 ]; then
prefix="docker.io/library/"
fi
image_to_attest=$image
if [[ $image =~ "@" ]]; then
echo "Image $image has DIGEST"
image_to_attest="${prefix}${image}"
else
echo "All images should be in the SHA256 digest format"
exit 1
fi
echo "Processing $image"
attestation_present=$(gcloud beta container binauthz attestations list
--attestor-project="${{ secrets.PROJECT_ID }}" --attestor="${{ secrets.ATTESTOR_NAME }}"
--artifact-url="${image_to_attest}")
if [ -z "${attestation_present// }" ]; then
gcloud beta container binauthz attestations sign-and-create --artifact-
url="${image_to_attest}" --attestor="${{ secrets.ATTESTOR_NAME }}" --attestor-project="${{
secrets.PROJECT_ID }}" --keyversion-project="${{ secrets.PROJECT_ID }}" --keyversion-
location="${{ secrets.KMS_KEY_LOCATION }}" --keyversion-keyring="${{ secrets.KMS_KEYRING_
NAME }}" --keyversion-key="${{ secrets.KMS_KEY_NAME }}" --keyversion="${{ secrets.KMS_KEY_
VERSION }}"
fi
done
该 YAML 文件执行多个任务,包括安装gcloud并与 GCP 进行身份验证。它还安装了gcloud beta CLI,最重要的是,它验证了镜像。
为了验证镜像,它会在blog-app.yaml清单中搜索所有镜像。对于每个镜像,它检查该镜像是否采用sha256摘要格式。如果是,它将继续验证该镜像。
值得注意的是,工作流验证镜像时,使用的是sha256摘要格式,而不是镜像定义中的标签。这个选择在使用二进制授权时至关重要。为什么?因为二进制授权要求使用sha256摘要而不是标签来部署镜像。这个预防措施非常必要,因为使用标签时,任何人都可以将一个不同的镜像与已验证镜像的标签关联,并将其推送到容器注册表。而摘要是由 Docker 镜像生成的哈希值。因此,只要镜像内容保持不变,摘要就保持相同。这可以防止任何绕过二进制授权控制的尝试。
以这种方式指定镜像的格式如下:
<repo_url>/<image_name>@sha256:<sha256-digest>
因此,在将更改推送到远程仓库之前,我们需要将镜像标签替换为sha256摘要。使用以下命令进行操作:
$ grep -ir "image:" ./manifests/blog-app |\
awk {'print $3'} | sort -t: -u -k1,1 > ./images
$ for image in $(cat ./images); do
no_of_slash=$(echo $image | tr -cd '/' | wc -c)
prefix=""
if [ $no_of_slash -eq 1 ]; then
prefix="docker.io/"
fi
if [ $no_of_slash -eq 0 ]; then
prefix="docker.io/library/"
fi
image_to_attest=$image
if [[ $image =~ "@" ]]; then
echo "Image $image has DIGEST"
image_to_attest="${prefix}${image}"
else
DIGEST=$(docker pull $image | grep Digest | awk {'print $2'})
image_name=$(echo $image | awk -F ':' {'print $1'})
image_to_attest="${prefix}${image_name}@${DIGEST}"
fi
escaped_image=$(printf '%s\n' "${image}" | sed -e 's/[]\/$*.^[]/\\&/g')
escaped_image_to_attest=$(printf '%s\n' "${image_to_attest}" | \
sed -e 's/[]\/$*.^[]/\\&/g')
echo "Processing $image"
grep -rl $image ./manifests | \
xargs sed -i "s/${escaped_image}/${escaped_image_to_attest}/g"
done
要验证更改是否成功,请运行以下命令:
$ cat manifests/blog-app/blog-app.yaml | grep "image:"
image: docker.io/library/mongo@sha256:2a1093b275d9bc...
image: docker.io/bharamicrosystems/mdo-posts@sha256:b5bc...
image: docker.io/bharamicrosystems/mdo-reviews@sha256:073..
image: docker.io/bharamicrosystems/mdo-ratings@sha256:271..
image: docker.io/bharamicrosystems/mdo-users@sha256:5f5a...
image: docker.io/bharamicrosystems/mdo-frontend@sha256:87..
正如我们所见,镜像已经更新。现在,让我们使用以下命令将更改推送到远程仓库:
$ cp ~/modern-devops/ch13/binaryauth/binaryauth.tf terraform/
$ cp ~/modern-devops/ch13/binaryauth/cluster.tf terraform/
$ cp ~/modern-devops/ch13/binaryauth/variables.tf terraform/
$ cp -r ~/modern-devops/ch13/binaryauth/.github .
$ git add --all
$ git commit -m "Enabled Binary Auth"
$ git push
现在,让我们回顾一下 GitHub Actions 上的开发 CD 工作流,我们应该观察以下内容:

图 13.13 – 开发 CD 工作流 – 验证镜像
正如所见,工作流已成功配置二进制授权并验证了我们的镜像。为了验证,请执行以下命令:
$ gcloud beta container binauthz attestations list \
--attestor-project="$PROJECT_ID" \
--attestor="quality-assurance-attestor" | grep resourceUri
resourceUri: docker.io/bharamicrosystems/mdo-ratings@
sha256:271981faefafb86c2d30f7d3ce39cd8b977b7dd07...
resourceUri: docker.io/library/mongo@sha256:2a1093b275d9bc546135ec2e2...
resourceUri: docker.io/bharamicrosystems/mdo-posts@
sha256:b5bc1fc976a93a88cc312d24916bd1423dbb3efe25e...
resourceUri: docker.io/bharamicrosystems/mdo-frontend@
sha256:873526fe6de10e04c42566bbaa47b76c18f265fd...
resourceUri: docker.io/bharamicrosystems/mdo-users@
sha256:5f5aa595bc03c53b86dadf39c928eff4b3f05533239...
resourceUri: docker.io/bharamicrosystems/mdo-reviews@
sha256:07370e90859000ff809b1cd1fd2fc45a14c5ad46e...
如我们所见,证明已经成功创建。在将应用程序部署到开发环境并进行测试后,所有镜像均已验证,我们现在可以继续将代码部署到生产环境。这涉及到将代码与prod分支合并,我们将为此实施拉取请求门控。
通过拉取请求进行发布门控并部署到生产环境
拉取请求门控的过程很简单。在开发 CD 工作流的末尾,我们将添加一个步骤来发起一个拉取请求,将dev分支合并到prod分支。此步骤需要人工审批才能继续合并拉取请求。这一步展示了不同组织可能采用不同方式来验证和推广经过测试的代码。一些可能选择自动合并,而其他可能优先考虑人工触发的操作。代码成功合并到prod分支后,会触发生产 CD 工作流。该工作流会创建生产环境并部署我们的应用程序,还会执行与在开发环境中运行的相同的集成测试,以确保生产环境中部署的应用程序保持完好。
这是我们将添加到开发 CD 工作流中的步骤:
raise-pull-request:
name: Raise Pull Request
needs: [binary-auth]
uses: ./.github/workflows/raise-pr.yml
secrets: inherit
如我们所见,这一步骤调用了raise-pr.yml文件。我们来看一下这个文件:
...
steps:
- uses: actions/checkout@v2
- name: Raise a Pull Request
id: pull-request
uses: repo-sync/pull-request@v2
with:
destination_branch: prod
github_token: ${{ secrets.GH_TOKEN }}
此工作流执行以下操作:
-
从仓库中检出代码
-
提交一个拉取请求以使用
GH_TOKEN密钥与prod分支合并
为了启用工作流的功能,我们需要定义一个 GitHub 令牌。这个令牌允许工作流代表当前用户创建拉取请求。以下是步骤:
-
在
mdo-environments仓库中创建一个新的令牌,并授予它读写拉取请求权限。这种方法符合最小权限原则,提供了更细粒度的控制。 -
创建令牌后,复制它。
-
现在,创建一个名为
GH_TOKEN的 GitHub Actions 密钥,并将复制的令牌粘贴为其值。你可以通过访问https://github.com/<your_github_user>/mdo-environments/settings/secrets/actions来完成此操作。
接下来,我们继续使用以下命令复制工作流文件:
$ cd ~/mdo-environments/.github/workflows
$ cp ~/modern-devops/ch13/raise-pr/.github/workflows/dev-cd-workflow.yml .
$ cp ~/modern-devops/ch13/raise-pr/.github/workflows/raise-pr.yml .
我们准备将这段代码推送到 GitHub 了。运行以下命令以提交并推送更改到你的 GitHub 仓库:
$ git add --all
$ git commit -m "Added PR Gating"
$ git push
这应该会触发 GitHub Actions 工作流在你的 GitHub 仓库中运行,你应该会看到类似如下内容:

图 13.14 – 提交拉取请求
GitHub 已经生成了一个拉取请求,将代码合并到prod分支,且开发 CD 工作流正按预期运行。现在我们可以审查拉取请求并将代码合并到prod分支。
合并代码并部署到生产环境
如前一节所示,Dev CD 工作流创建了我们的环境,部署了应用程序,进行了测试,并验证了应用程序镜像。然后,它自动发起了一个拉取请求,将代码合并到prod分支。
我们已进入prod分支。
既然我们知道拉取请求已经创建,让我们继续检查并批准它。为此,访问https://github.com/<your_github_user>/mdo-environments/pulls,你会找到该拉取请求。点击该拉取请求,你会看到以下内容:

图 13.15 – 拉取请求
我们看到拉取请求已经准备好合并。点击prod分支。
如果你访问https://github.com/<your_user>/mdo-environments/actions,你会发现 Prod CD 工作流已经触发。当你点击工作流时,你会看到类似以下的工作流运行:

图 13.16 – Prod CD 工作流
当我们合并了拉取请求时,它会自动触发 Prod CD 工作流,因为它会响应prod分支中的任何新变化。工作流完成了它的任务,构建了生产环境,部署了我们的应用程序,并对其进行了测试。请注意,这个集群启用了二进制授权。
为了确认二进制授权是否正常工作,让我们进行一些检查,确保无法部署未经验证的镜像。
首先,让我们使用以下命令与prod集群建立连接:
$ gcloud container clusters get-credentials \
mdo-cluster-prod --zone us-central1-a --project ${PROJECT_ID}
让我们尝试使用nginx镜像将一个 pod 部署到集群中。请使用以下命令:
$ kubectl run nginx --image=nginx
Error from server (VIOLATES_POLICY): admission webhook "imagepolicywebhook.image-policy.
k8s.io" denied the request: Image nginx denied by Binary Authorization default admission
rule. Image nginx denied by attestor projects/<PROJECT_ID>/attestors/quality-assurance-
attestor: Expected digest with sha256 scheme, but got tag or malformed digest
现在,正如预期的那样,部署失败了,但如果你检查失败的原因,还有一些需要注意的地方。失败发生是因为我们指定了一个标签,而不是sha256摘要。让我们再次尝试部署镜像,但这次使用摘要。
为了做到这一点,让我们通过以下命令获取镜像摘要并将其设置为名为DIGEST的变量:
$ DIGEST=$(docker pull nginx | grep Digest | awk {'print $2'})
现在,让我们使用摘要重新部署镜像,使用以下命令:
$ kubectl run nginx --image=nginx@$DIGEST
Error from server (VIOLATES_POLICY): admission webhook "imagepolicywebhook.image-
policy.k8s.io" denied the request: Image nginx@sha256:6926dd8... denied by Binary
Authorization default admission rule. Image nginx@sha256:6926dd8... denied by attestor
projects/<PROJECT_ID>/attestors/quality-assurance-attestor: No attestations found that
were valid and signed by a key trusted by the attestor
这一次,部署因正当理由被拒绝,确认了二进制授权功能正常。这确保了 Kubernetes 集群的安全,防止了未经验证的镜像被部署,并让你对环境拥有完全控制权。有了这个措施,任何问题都不会源自部署未经测试或存在漏洞的镜像。
我们已经涵盖了将安全性和质量保障集成到 CI/CD 流水线中的许多内容。现在,让我们探讨一些保护现代 DevOps 流水线的最佳实践。
现代 DevOps 流水线的安全性和测试最佳实践
工具并不是唯一能帮助你在 DevSecOps 旅程中取得进展的东西。这里有一些有用的建议,帮助你解决安全风险,并在你的组织内建立更安全的文化。
采纳 DevSecOps 文化
采用 DevSecOps 方法在实施现代 DevOps 中至关重要。因此,必须将安全性融入组织的文化中。你可以通过实现开发、运维和安全团队之间的有效沟通与协作来实现这一点。虽然大多数组织都有安全政策,但这些政策不能仅仅为了遵守规则和法规而执行。相反,员工应该进行跨技能培训和提升技能,以便采纳 DevSecOps 方法,并在开发初期就将安全性嵌入其中。安全团队需要学习如何编写代码并与 API 协作,而开发人员则需要理解安全性并利用自动化来实现这一点。
建立访问控制
你在本书中已经多次听说过最小权限原则(PoLP)。这正是你需要实施的措施,以提高安全性,这意味着你应该尽量只为人们完成工作所需的权限,而不授予其他不必要的权限。通过简化授予访问权限的过程,减少“以防万一”心态,让人们不会感到受到阻碍,进而避免他们寻求超出需求的权限。
实施左移策略
左移意味着在软件开发的早期阶段就将安全性嵌入软件中。这意味着安全专家需要与开发人员密切合作,使他们从一开始就能构建安全的软件。安全功能不仅仅是审查功能,还应与开发人员和架构师积极合作,开发安全强化的设计和代码。
一致地管理安全风险
你应该接受不可避免的风险,并在发生攻击时拥有标准操作程序(SOP)。你应该在软件开发和基础设施管理的各个方面(如配置管理、访问控制、漏洞测试、代码审查和防火墙)制定简明易懂的政策和实践,以确保从安全角度出发做好准备。
实施漏洞扫描
目前,开源软件正在快速增长,许多软件实现依赖于现成的开源框架、软件库和第三方软件,而这些软件并不提供任何保证或责任。虽然开源生态系统正在以前所未有的方式构建技术世界,但它也有自己的漏洞问题,你不希望在自己的软件中引入这些漏洞。漏洞扫描至关重要,因为扫描可以发现任何带有漏洞的第三方依赖,并在初期阶段提醒你。
自动化安全
安全性不应妨碍 DevOps 团队的速度;因此,为了跟上 DevOps 的快速节奏,你应该考虑将安全性嵌入到你的 CI/CD 流程中。你可以通过代码分析、漏洞扫描、配置管理和基础设施扫描,并使用策略即代码和二进制授权来确保只有经过测试并且安全的软件才能部署。自动化有助于在软件开发生命周期的早期发现潜在漏洞,从而降低软件开发和返工的成本。
类似地,QA 是软件交付的核心,而现代 DevSecOps 则高度重视自动化测试。以下是一些你可以遵循的建议,以实现现代化的测试方法。
在 CI/CD 管道中进行自动化测试
自动化测试是关键。这意味着要涵盖广泛的领域,从单元测试、集成测试到功能测试、安全测试和性能测试。目标是将这些测试无缝地嵌入到你的 CI/CD 管道中,确保持续不断的验证。在这个过程中,创建隔离且可重现的测试环境至关重要,以避免测试之间的相互干扰。在这方面,容器化和虚拟化等方法是环境隔离的宝贵工具。
有效管理你的测试数据
测试数据管理是另一个关键环节。有效处理测试数据至关重要,不仅要确保其一致性,还要保护数据隐私。在这方面,利用数据生成工具可以大大改变游戏规则,帮助你创建适合测试需求的数据集。此外,在处理敏感信息时,考虑数据匿名化是明智的做法。这能确保你在保持最高数据保护标准的同时,依然能够进行全面的测试。
测试应用的各个方面
CI 的关键是保持开发过程的顺畅进行。这包括频繁地合并代码并自动运行测试,确保代码库保持稳定。当测试失败时,立即关注并迅速解决问题至关重要。
端到端测试是确保整个应用工作流按预期运行的指南针。自动化框架在复制真实用户交互方面发挥着重要作用,使你能够全面评估应用的表现。
负载测试是流程中不可或缺的一部分,它评估应用在不同负载下的表现,提供关于应用的健壮性和容量的洞察。此外,可扩展性测试确保系统能够应对增长的需求,这是保证应用长期健康发展的重要因素。
实施混沌工程
纳入混沌工程实践是一种积极主动的策略,用于发现和解决潜在的系统弱点。通过进行受控实验,你可以评估系统的弹性,更好地为意外的挑战做好准备。这些实验通过故意在环境中引入混沌,观察系统的反应。这不仅帮助你识别弱点,还提供了关于如何增强系统的健壮性和可靠性的宝贵见解。
在测试时监控和观察应用程序
设置强大的监控和可观察性工具对于深入了解系统的性能和行为至关重要。这些工具允许你收集关键的指标、日志和追踪信息,从而提供应用程序健康状况和性能的全面视图。
在生产环境中进行有效测试
实施特性标志和金丝雀发布是测试新功能在真实生产环境中表现的明智策略,同时可以最小化风险。特性标志允许你在运行时启用或禁用某些功能,从而对它们的激活进行控制。金丝雀发布则涉及将新功能推出给少量用户,允许你在全面发布之前监控其影响。
通过利用特性标志,你可以将新功能推出给有限的用户群体,而不会影响整个用户基础。这种受控的方式使你能够观察用户互动、收集反馈并评估功能在实际场景中的表现。同时,金丝雀发布允许你将这些功能部署给一小部分代表性用户,从而监控他们的行为、收集性能数据并识别潜在问题。
至关重要的是,在此过程中持续监控是必不可少的。通过密切观察新功能的影响,你可以迅速发现可能出现的问题。如果发生问题,你可以通过简单地关闭特性标志或恢复到先前版本来灵活地回滚更改。这种迭代和谨慎的方法最大程度地减少了潜在问题的影响,确保了更加顺畅的用户体验,并维持生产环境的稳定性。
文档和知识共享
记录测试程序、测试用例和最佳实践对于确保开发和测试过程中的一致性和可靠性至关重要。全面的文档为团队成员提供了清晰的指导,说明如何进行测试、预期的结果以及应遵循的最佳实践。这些文档是新老团队成员宝贵的资源,促进了对测试程序的共同理解。
鼓励团队成员之间的知识共享进一步增强了团队的集体专业知识。通过促进开放的沟通和分享经验,团队成员可以互相学习,获得不同测试场景的见解,并发现应对常见挑战的创新解决方案。这种协作的环境促进了持续学习,并确保团队保持对软件测试领域最新发展和技术的了解。
通过遵循这些最佳实践,团队可以增强 CI/CD 管道的安全性和可靠性。适当的文档化流程和测试用例使得测试保持一致,从而减少将错误引入代码库的可能性。知识共享确保团队能够从成员的集体智慧和经验中受益,进而做出更明智的决策并高效解决问题。
此外,通过良好的文档化测试流程和传播最佳实践,能够有效管理安全风险。团队可以在开发过程中及早发现潜在的安全漏洞,从而在这些问题升级为重大威胁之前予以解决。定期的知识共享会议也可以包括关于安全最佳实践的讨论,确保团队成员了解最新的安全威胁和应对措施。
最终,这些最佳实践有助于建立一个强大的测试和开发文化。它们使团队能够更快、更自信地交付软件,因为他们知道 CI/CD 管道是安全、可靠的,并且能够应对现代软件开发的挑战。
总结
本章已经涵盖了 CI/CD 管道的安全性和测试,我们了解了围绕它的各种工具、技术和最佳实践。我们参考了一个安全的 CI/CD 工作流。然后,我们通过实际操作,理解了让它保持安全的各个方面,比如密钥管理、容器漏洞扫描和二进制授权。
运用本章学到的技能,你现在可以适当地保护你的 CI/CD 管道,并使你的应用程序更安全。
在下一章,我们将探索运行应用程序在生产环境中的操作元素以及关键性能指标。
问题
-
以下哪个是存储密钥的推荐位置?
A. 私有 Git 仓库
B. 公共 Git 仓库
C. Docker 镜像
D. 密钥管理系统
-
以下哪项是开源的密钥管理系统?
A. 密钥管理器
B. HashiCorp Vault
C. Anchore Grype
-
在你的 CD 管道的文件系统中下载一个密钥是一个好习惯吗?
-
哪种基础镜像通常被认为更安全,并且包含最少的漏洞?
A. Alpine
B. Slim
C. Buster
D. 默认
-
以下哪项关于二进制授权的说法是正确的?(选择两项)
A. 它会扫描你的镜像以检测漏洞。
B. 它仅允许部署经过验证的镜像。
C. 它防止人们绕过你的 CI/CD 管道。
答案
-
D
-
B
-
否
-
A
-
B 和 C
第五部分:在生产环境中操作应用
本部分提供了在生产环境中管理容器的全面指南。我们将首先介绍关键性能指标和可靠性原则,然后探索 Istio 以实现高级安全性、流量管理和可观察性。本节将帮助你掌握优化生产环境中基于容器的应用所需的关键技能。
本部分包括以下章节:
-
第十四章,理解生产服务的关键绩效指标(KPI)
-
第十五章,使用 Istio 操作生产环境中的容器
第十四章:理解生产服务的关键绩效指标(KPI)
在前几章中,我们讨论了现代 DevOps 的核心概念——持续集成(CI)和持续部署/交付(CD)。我们还探讨了各种工具和技术,这些工具和技术可以帮助我们在组织内实现一个成熟且安全的 DevOps 渠道。在这一章中,尽管重点是理论,我们将试图理解一些运营生产应用程序时的关键绩效指标(KPI)。
在这一章中,我们将讨论以下主要话题:
-
理解可靠性的重要性
-
服务水平目标(SLO)、服务水平协议(SLA)和服务水平指标(SLI)
-
错误预算
-
恢复时间目标(RPO)和恢复点目标(RTO)
-
在生产环境中运行分布式应用程序
那么,让我们开始吧!
理解可靠性的重要性
开发软件是一回事,而在生产环境中运行它是另一回事。这种差距的原因在于,大多数开发团队无法在非生产环境中模拟生产条件。因此,许多漏洞只有在软件已投入生产时才被发现。大多数遇到的问题都是非功能性问题——例如,服务可能无法随着额外的流量适当扩展,分配给应用程序的资源不足,导致网站崩溃,等等。这些问题需要得到管理,以提高软件的可靠性。
为了理解软件可靠性的重要性,我们来看一个零售银行应用的例子。软件可靠性对于多个原因至关重要:
-
用户满意度:可靠的软件能够确保良好的用户体验。用户希望软件能够按预期工作,而当它无法按预期工作时,可能会导致沮丧、失去信任以及软件或背后组织的声誉受损。对于银行的零售客户来说,这可能意味着客户无法进行必要的交易,因此可能会在支付和收款过程中遇到麻烦,导致用户满意度下降。
-
商业声誉:软件故障可能会损害公司的声誉和品牌形象。对于我们的银行来说,如果问题频繁出现,客户会寻找其他选择,导致客户流失和业务损失。
-
财务影响:软件故障可能会非常昂贵。它们可能导致销售损失、客户支持费用,甚至在软件故障导致用户损失或财务损害时,还可能引发法律责任。对于银行应用程序来说,特别关键的是,这涉及到客户的资金。如果交易未能及时完成,可能会导致客户流失,从长远来看,会对银行造成伤害。
-
竞争优势:可靠的软件可以提供竞争优势。用户更倾向于选择并坚持使用一款能持续满足他们需求和期望的银行在线银行软件。
-
生产力与效率:在组织内部,可靠的软件对保持生产力至关重要。试想一下,客户支持和前台工作人员在这种中断中的痛苦!你还需要更多资源来管理这些问题,这会干扰操作,导致时间和资源的浪费。
-
安全性:可靠的软件通常更安全。攻击者可以利用不可靠软件中的漏洞和错误。对于银行来说,安全性至关重要,因为任何安全漏洞都可能导致直接的财务损失。确保可靠性是网络安全的基础部分。
-
合规性:在一些行业,特别是银行业,有与软件可靠性相关的监管要求。未能满足这些要求可能导致法律和财务处罚。
-
客户信任:信任是软件使用中的关键因素,尤其是在银行应用的情况下。用户必须相信他们的资金和数据会被安全处理,并且软件会按预期执行。软件可靠性是建立和维持这种信任的关键因素。
-
可维护性:可靠的软件通常更易于维护。当软件不可靠时,修复漏洞和更新变得更加困难,这可能导致可靠性不断下降的恶性循环。
-
扩展性与增长:随着软件使用量的增加,可靠性变得更加关键。适用于小规模用户群体的软件,在没有适当的可靠性措施的情况下,可能难以满足大规模用户群体的需求。
总结来说,软件可靠性不仅仅是一个技术问题;它对用户满意度、商业成功甚至法律和财务方面都有深远的影响。因此,投资确保软件可靠性是组织的一项明智和战略性的决策。
历史上,运行和管理生产中的软件是运维团队的工作,至今大多数组织仍然如此。运维团队由一群系统管理员(SysAdmins)组成,他们必须处理运行生产中软件的日常问题。他们通过软件实现扩展和容错,修补和升级软件,处理支持票务,保持系统运行,确保软件应用程序的顺利运行。
我们都经历过开发和运维团队之间的鸿沟,每个团队都有自己的目标、规则和优先事项。通常,他们会因为开发团队受益的东西(软件更改和快速发布)给运维团队带来挑战(稳定性和可靠性)而发生冲突。
然而,DevOps 的出现改变了这一动态。用 Andrew Shafer 和 Patrick Debois 的话说,DevOps 是一种文化和实践,旨在弥合软件开发与运维之间的差距。
从运维的角度看待 DevOps,Google 提出了网站可靠性工程(SRE)作为一种体现 DevOps 原则的方法。它鼓励共享所有权,使用共同的工具和实践,并承诺从失败中学习,以防止问题反复出现。其主要目标是开发和维护一个可靠的应用程序,同时不牺牲交付速度——这一平衡曾被认为是矛盾的(即,更快地创建更好的软件)。
SRE 的理念是关于如果允许软件工程师来管理生产环境,会发生什么的新思考。因此,Google 为其运维团队设计了以下方法。
对于 Google 来说,加入 SRE 团队的理想候选人应该具备两个关键特征:
-
首先,他们很快对手动任务失去兴趣,寻求将其自动化的机会。
-
其次,他们具备开发软件解决方案所需的技能,即使面临复杂的挑战。
此外,SRE(网站可靠性工程)人员应与更广泛的开发团队共享学术和智力背景。本质上,SRE 工作,传统上由运维团队承担,是由具有强大软件专业知识的工程师来完成的。这一策略依赖于这些工程师天生的倾向和能力,设计并实施自动化解决方案,从而减少对人工劳动的依赖。
从设计上讲,SRE 团队保持着强大的工程聚焦。如果没有持续的工程努力,操作工作量会急剧增加,迫使团队扩大以应对日益增长的需求。相比之下,传统的以运维为中心的团队会直接按照服务的增长来扩展。如果他们支持的服务繁荣发展,操作需求将随流量增加而激增,迫使雇佣更多人员来执行重复性工作。
为了避免这种情况,负责服务管理的团队必须将编码纳入其职责范围;否则,他们将面临被淹没的风险。
因此,Google 为分配给所有 SRE 的总“运维”工作设定了 50% 的上限,包括处理工单、值班任务和手动工作等活动。这个限制保证了 SRE 团队将大量时间用于提升服务的稳定性和功能性。虽然这个上限作为一个上界存在,但理想的结果是,随着服务逐步发展为自我维持的状态,SRE 承担的操作性工作量最小化,主要从事开发工作。Google 的目标是创建不仅仅是自动化的系统,而是固有的自我调节系统。然而,实际问题如扩展和引入新功能持续对 SRE 提出挑战。
SRE 在其方法上非常细致,依赖可衡量的指标来跟踪向特定目标的进展。例如,简单地说一个网站运行缓慢在工程背景下是模糊且无帮助的。然而,声明响应时间的第 95 百分位已超出服务级目标(SLO)10%则提供了精确的信息。SRE 还专注于通过自动化减少重复性任务,这些任务被称为劳累,以防止倦怠。现在,让我们来看看一些关键的 SRE 性能指标。
理解 SLI、SLO 和 SLA
在网站可靠性领域,有三个关键参数指导 SRE:可用性指标 —— 服务级指标(SLI),可用性定义 —— SLO,以及不可用的后果 —— 服务级协议(SLA)。让我们首先详细探索 SLI。
SLI
SLI 作为可量化的可靠性指标。谷歌将其定义为“仔细定义的某个服务水平方面的定量衡量标准”。常见的例子包括请求延迟、失败率和数据吞吐量。SLI 特定于用户旅程,即用户为实现特定目标而执行的一系列操作。例如,我们示例中的博客应用的用户旅程可能包括创建一篇新的博客文章。
谷歌,作为 SRE 的初创倡导者,已确定了四个黄金信号,适用于大多数用户旅程:
-
延迟:衡量服务响应用户请求所需的时间
-
错误:表示失败请求的百分比,突显了服务可靠性的问题
-
流量:流量代表指向你服务的需求,反映了服务的使用情况
-
饱和度:饱和度评估你的基础设施组件的使用情况
谷歌推荐的一种计算 SLI 的方法是通过确定良好事件与有效事件的比率:
SLI = (Good Events * 100) / Valid Events
完美的 SLI 得分为 100,意味着一切正常,而得分为 0 则表示存在广泛的问题。
一个有价值的 SLI 应该与用户体验紧密对齐。例如,较低的 SLI 值应与客户满意度下降相对应。如果这种对齐缺失,则该 SLI 可能无法提供有意义的见解或不值得衡量。
让我们通过以下图形更好地理解这一点:

图 14.1 – 好的与不好的 SLI
正如我们所看到的,CPU 使用率 SLI 并不直接反映客户满意度;换句话说,除非 CPU 使用率超过 80% 阈值,否则增加 CPU 使用率与客户满意度下降之间没有直接关系。相比之下,延迟 SLI 与客户满意度直接相关,随着延迟的增加,客户满意度下降,特别是在 300ms 和 500ms 级别之后。因此,使用延迟作为 SLI 比使用 CPU 使用率更为合适。
同时,建议将 SLI 的数量限制在一个可管理的范围内。SLI 过多会导致团队混乱,并引发大量误报。最好专注于四个或五个与客户满意度直接相关的指标。例如,与其监控 CPU 和内存使用情况,不如优先考虑请求延迟和错误率等指标。
此外,优先考虑用户旅程至关重要,应该给予对客户影响较大的旅程更高的优先级,而对客户影响较小的旅程则给予较低的优先级。例如,确保我们的博客应用中创建和更新帖子体验的流畅性,比评论和评分服务更为关键。仅凭 SLI(服务级别指标)并没有太多意义,因为它们只是可衡量的指标。我们需要为 SLI 设定目标。因此,让我们来看看 SLO(服务级别目标)。
SLOs
谷歌对 SLO 的定义指出,它们“为你的服务可靠性设定了目标水平”。它们指定了考虑你的网站是否可靠所需遵循的 SLI 合规百分比。SLO 是通过结合一个或多个 SLI 来制定的。
例如,如果你有一个 SLI,要求在过去 15 分钟内请求延迟保持低于 500 毫秒,且按 95 百分位测量,那么一个 SLO 就需要在 99%的时间内满足该 SLI,以实现 99%的 SLO。
尽管每个组织都追求 100%的可靠性,但设定 100%的 SLO 并不是一个实际的目标。拥有 100%SLO 的系统往往成本高昂、技术复杂,并且对于大多数应用程序而言,用户接受度通常不需要如此高的可靠性。
在软件服务和系统领域,追求 100%的可用性通常是错误的,因为用户无法在一个 100%可用的系统和一个 99.999%可用的系统之间感知到任何实际差异。用户与服务之间存在多个中间系统,如他们的个人电脑、家庭 Wi-Fi、互联网服务提供商(ISP)和电力网,这些系统的可用性远低于 99.999%。因此,99.999%与 100%之间的微小差异在其他不可用来源的背景噪声中变得难以察觉。因此,投入大量精力去实现最后的 0.001%的可用性,对最终用户没有明显的好处。
根据这一理解,一个问题浮现:如果 100%不是一个合适的可靠性目标,那么系统的正确可靠性目标是什么?有趣的是,这并不是一个技术性的问题,而是一个与产品相关的问题,需要考虑以下几个因素:
-
用户满意度:确定与用户满意度相符的可用性水平,考虑用户的典型使用模式和期望。
-
替代方案:评估不满意用户在产品当前可用性水平不满意时是否会寻找替代方案,以及这些替代方案的可用性。
-
用户行为:研究用户在不同可用性水平下对产品的使用变化,认识到用户行为可能会因可用性的波动而发生变化。
此外,一个完全可靠的应用程序不留有引入新功能的空间,因为任何新的添加都有可能干扰现有服务。因此,必须在你的 SLO 中留出一定的容错空间。
SLO 代表内部目标,需要团队和内部利益相关者(包括开发人员、产品经理、SRE 和 CTO)之间达成共识。它们需要整个组织的承诺。未能满足 SLO 不会带来显式或隐式的惩罚。
例如,如果未满足 SLO,客户不能要求赔偿,但这可能会导致组织领导层的不满。这并不意味着未能满足 SLO 就应该没有后果。未达 SLO 通常会导致较少的变化和减少的功能开发,可能表明质量下降,且更加注重开发和测试职能。
SLO 应当是现实可行的,团队应积极努力达成它们。它们应与客户体验保持一致,确保当服务符合 SLO 时,客户不会察觉到任何服务质量问题。如果性能低于定义的 SLO,可能会影响客户体验,但不会到客户提出支持工单的程度。
一些组织实施两种类型的 SLO:可实现的和理想的。可实现的 SLO 代表整个团队应该达到的目标,而理想的 SLO 设定了更高的目标,是持续改进过程的一部分。
SLA
根据 Google 的说法,SLA 是“与用户的正式或隐式协议,概述了满足(或未能满足)所包含 SLO 时的后果。”
这些协议具有更为结构化的性质,代表了对客户做出的业务层级承诺,明确了如果组织未能履行 SLA 时将采取的措施。SLA 可以是显式的,也可以是隐式的。显式 SLA 涉及明确定义的后果,通常是通过服务信用的方式来补偿未达预期可靠性时的损失。隐式 SLA 则是通过评估对组织声誉的潜在损害和客户转向替代方案的可能性来进行评估。
SLA 通常设定在足以防止客户寻找替代方案的水平,因此,它们的阈值通常比 SLO 低。例如,在考虑请求延迟 SLI 时,SLO 可能定义为 300ms 的 SLI 值,而 SLA 可能设定为 500ms 的 SLI 值。这种区别源于 SLO 是与可靠性相关的内部目标,而 SLA 则是外部承诺。通过努力实现 SLO,团队自动满足 SLA,为组织提供了一层额外的保护,以防出现意外故障。
为了理解 SLIs、SLOs 和 SLAs 之间的关系,我们来看下图:

图 14.2 – SLIs、SLOs 和 SLAs
这张图展示了随着延迟水平变化,客户体验如何变化。如果我们将延迟 SLO 保持在 300ms 并满足它,一切正常!在 300ms 到 500ms 之间,客户开始感受到性能下降,但这不足以让他们失去冷静并开始提交支持工单。因此,将 SLA 设定为 500ms 是一个不错的策略。一旦超越 500ms 阈值,不满情绪就会出现,客户开始因服务延迟而提交支持工单。如果延迟超过 10s,那么这将成为运维团队的关注问题,一切都在着火。然而,正如我们所知,SLO 的措辞与我们在此想象的略有不同。当我们说我们有一个 300ms 延迟的 SLO 时,这并不意味着什么。一个现实的 SLO 对于要求 请求延迟在过去 15 分钟内保持低于 300ms 且按 95 分位测量 的 SLI 来说,就是要在 x% 的时间内 达到 SLI。那么这个 x 应该是多少呢?应该是 99%,还是 95%?我们该如何决定这个数字呢?要回答这个问题,我们需要看看 误差预算。
误差预算
根据 Liz Fong-Jones 和 Seth Vargo 的定义,误差预算表示 “产品团队和 SRE 团队之间共享的量化衡量标准,用于平衡创新 和稳定性。”
简而言之,误差预算量化了在引入新功能、进行服务维护、执行例行改进、管理网络和基础设施中断以及应对突发情况时,可以承受的风险水平。通常,监控系统会测量服务的正常运行时间,而 SLO 则设定了你希望达到的目标。误差预算是这两个指标之间的差值,表示在误差预算范围内可以用于发布新版本的时间。
这正是为什么通常不会一开始就设定100%的 SLO。错误预算起着至关重要的作用,帮助团队在创新与可靠性之间找到平衡。错误预算的基本理念来源于 SRE 视角,即故障是系统操作中自然且预期的一部分。因此,每当将新变化引入生产环境时,总会存在破坏服务的风险。因此,较高的错误预算允许引入更多的功能:
Error Budget = 100% — SLO
例如,如果你的 SLO 是99%,那么你的错误预算将是1%。如果你将此计算在一个月内,假设30 天/月和24 小时/天,那么你将有一个7.2 小时的错误预算,用于维护或其他活动。对于99.9%的 SLO,错误预算为每月43.2 分钟,而对于99.99%的 SLO,则为每月4.32 分钟。你可以参考下图获取更多细节:

图 14.3 – 错误预算与 SLO 比较
这些时间段表示实际的停机时间,但如果你的服务有冗余、高可用性措施和灾难恢复计划,那么你可以将这些时长延长,因为服务仍然可以正常运行,同时你可以修补或处理某一台服务器的问题。
现在,是否想在 SLO 中继续增加9,还是追求一个较低的数字,取决于你的终端用户、业务重要性和可用性需求。较高的 SLO 比较低的 SLO 更昂贵,且需要更多资源。然而,有时仅仅正确架构你的应用程序就能帮助你达到更好的 SLO 目标。
现在我们已经了解了 SLO、SLI、SLA 和错误预算,让我们谈谈灾难恢复。
灾难恢复、RTO 和 RPO
灾难恢复是一项综合策略,旨在确保组织在面对突发、破坏性事件(如自然灾害、网络攻击或系统故障)时的韧性。它涉及到必要的规划、政策、程序和技术,以快速有效地恢复关键的 IT 系统、数据和运营至正常状态。实施良好的灾难恢复计划能帮助企业最大限度地减少停机时间、数据丢失和财务影响,确保业务连续性,保护声誉,并迅速从逆境中恢复,从而最终保障企业的长期成功。
每个组织在不同程度上都包含了灾难恢复。有些选择定期备份或快照,而其他组织则投资于创建生产环境的故障切换副本。尽管故障切换副本提供了更高的韧性,但也会导致基础设施成本翻倍。组织采用的灾难恢复机制选择,依赖于两个关键的 KPI —— 恢复时间目标(RTO)和 恢复点目标(RPO)。
RTO 和 RPO 是灾难恢复和业务连续性规划中的关键指标。RTO 表示系统或应用程序的最大可接受停机时间,指定在发生中断后,系统应该在多长时间内恢复。它量化了服务不可用的可接受时长,并推动恢复工作的紧迫性。
另一方面,RPO 定义了在灾难发生时,最大可容忍的数据丢失量。它标示了数据必须恢复到的时间点,以确保业务连续性。实现较低的 RPO 意味着数据丢失最小化,通常通过频繁的数据备份和复制来实现。下图很好地解释了 RTO 和 RPO:

图 14.4 – RTO 和 RPO
较短的 RTO 和 RPO 需要一个更强大的灾难恢复计划,这将导致更高的基础设施和人力资源成本。因此,平衡 RTO 和 RPO 对于确保一个有韧性的 IT 基础设施至关重要。组织必须将其恢复策略与这些目标对齐,以最小化停机时间和数据丢失,从而在不可预见的中断期间保护业务运营和数据完整性。
运行分布式应用程序于生产环境中
到目前为止,我们一直在讨论运行应用程序于生产环境中的 KPI,并从 SRE 原则中获得灵感。现在,让我们理解如何将这些思路集中到一个地方,以便运行分布式应用程序于生产环境中。
分布式应用程序 或 微服务 与单体应用本质上不同。管理单体应用的工作是确保一个应用的所有操作方面,而随着微服务的出现,复杂性呈指数级增长。因此,我们应该采取不同的方式来处理它。
从 SRE 的角度来看,运行分布式应用程序于生产环境中意味着专注于确保应用程序的 可靠性、可扩展性 和 性能。以下是 SRE 如何处理这一任务:
-
SLO:SRE 从定义明确的 SLO 开始,SLO 规定了分布式应用程序所需的可靠性水平。SLO 指定了可接受的 延迟、错误率 和 可用性 水平。这些 SLO 在指导团队的工作以及判断系统是否达到了其可靠性目标方面起着至关重要的作用。
-
SLI:SRE 会建立 SLI,SLI 是用于衡量应用程序可靠性的可量化指标。这些指标可能包括响应时间、错误率和其他性能指标。SLI 提供了一种具体的方法来评估应用程序是否达到了其 SLO。
-
错误预算:错误预算是 SRE 中的一个关键概念。它们表示在违反 SLO(服务级别目标)之前,允许发生的停机或错误的最大数量。SRE 通过错误预算来平衡可靠性和创新。如果错误预算用尽,可能需要将重点放在稳定性和可靠性上,而不是推出新特性。
-
监控和警报:SRE 实施强大的监控和警报系统,持续跟踪应用程序的性能和健康状况。他们根据服务级指标(SLIs)和服务级目标(SLOs)设置警报,使他们能够主动响应事件或性能水平的偏差。在分布式应用领域,使用如Istio或Linkerd的服务网格可以提供帮助。它们帮助你通过单一视图来可视化应用程序的各个部分,并让你轻松监控应用程序并发出警报。
-
容量规划:SRE 确保支撑分布式应用程序的基础设施能够应对预期的负载和流量。他们进行容量规划,以便根据需要扩展资源,防止流量激增时出现性能瓶颈。借助现代公有云平台,自动化流量的可扩展性变得更加容易实现,尤其是在分布式应用的情况下。
-
自动化修复:自动化是 SRE 实践的基石。SRE 开发自动化系统用于事件响应和修复。这包括自动扩展、自愈机制和自动回滚程序,以最小化停机时间。
-
混沌工程:SRE 经常采用混沌工程实践,故意将受控故障引入系统。这有助于识别分布式应用中的弱点和漏洞,从而提前采取措施减轻潜在问题。一些最流行的混沌工程工具包括 Chaos Monkey、Gremlin、Chaos Toolkit、Chaos Blade、Pumba、ToxiProxy 和 Chaos Mesh。
-
值班和事件管理:SRE(站点可靠性工程师)保持值班轮换,以确保全天候覆盖。他们遵循明确的事件管理流程,迅速解决问题,并从事件中吸取经验教训,防止问题的重复发生。大多数 SRE 开发积压来自于此过程,因为他们从失败中学习,因此会自动化可重复的任务。
-
持续改进:SRE 是一种持续改进的文化。SRE 团队定期进行事件后复盘(PIRs)和根本原因分析(RCAs),以识别改进的领域。从事件中学到的经验教训被用来优化 SLOs,并提高应用程序的整体可靠性。
-
文档编写和知识共享:SRE 编写最佳实践、运行手册和操作流程。他们强调跨团队的知识共享,确保专业知识不被孤立,并且所有团队成员都能够有效地管理和排查分布式应用程序的问题。他们还致力于自动化运行手册,以确保手动过程保持在最低限度。
总结来说,SRE 在生产环境中运行分布式应用程序的方法专注于 可靠性、自动化 和 持续改进。它设定明确的目标,建立度量标准,并采用主动监控和事件管理实践,向最终用户提供高度可用和高性能的服务。
总结
本章介绍了 SRE 和在生产环境中运行服务的关键绩效指标(KPI)。我们从理解软件可靠性开始,探讨了如何利用 SRE 管理生产环境中的应用程序。我们讨论了 SRE 的三个关键参数:SLI、SLO 和 SLA。我们还探索了错误预算及其在系统变更引入中的重要性。接着,我们讲解了软件灾难恢复、RPO 和 RTO 以及它们如何定义我们的灾难恢复措施的复杂性或成本。最后,我们了解了 DevOps 或 SRE 如何使用这些概念来管理生产环境中的分布式应用。
在下一章中,我们将把所学的知识应用于实际,探索如何使用名为 Istio 的服务网格来管理所有这些方面。
问题
回答以下问题,测试您对本章内容的掌握情况:
-
以下哪项是 SLI 的良好示例?
A. 响应时间不应超过 300 毫秒。
B. 15 分钟窗口中的响应时间的第 95 百分位应不超过 300 毫秒。
C. 99% 的所有请求应在 300 毫秒内响应。
D. 故障数量不应超过 1%。
-
一个成熟的组织应当有 100% 的 SLO。(对/错)
-
SLO 与任何客户发起的惩罚性措施无关。(对/错)
-
在决定 SLO 时,您应考虑以下哪些因素?(选择三项)
A. 用户满意度
B. 替代方案
C. 用户行为
D. 系统容量
-
SLA 通常保持比 SLO 更严格的 SLI 值。(对/错)
-
在定义 SLI 时,您应考虑以下哪些因素?
A. CPU、内存和磁盘利用率
B. 延迟、错误、流量和饱和度
C. 利用率、容量和规模
-
1% 的错误预算每月提供多少停机时间?
A. 72 小时
B. 43.2 分钟
C. 7.2 小时
D. 4.32 分钟
-
SRE 是一名从事运维的开发人员。(对/错)
-
SRE 应分配多少最小时间用于开发工作?
A. 30%
B. 40%
C. 50%
D. 60%
答案
以下是本章问题的答案:
-
B
-
错
-
对
-
A, B, C
-
错
-
B
-
C
-
对
-
C
第十五章:使用 Istio 实施流量管理、安全性和可观察性
在上一章中,我们讨论了站点可靠性工程(SRE)及其如何通过 DevOps 实践帮助管理生产环境。在本章中,我们将深入探讨一种名为 Istio 的服务网格技术,它将帮助我们实施 SRE 实践,并在生产中更好地管理我们的应用。
在本章中,我们将讨论以下主要主题:
-
重新审视博客应用
-
服务网格简介
-
Istio 简介
-
了解 Istio 架构
-
安装 Istio
-
使用 Istio Ingress 来允许流量
-
使用 Istio 保护你的微服务
-
使用 Istio 管理流量
-
使用 Istio 观察流量并设置告警
技术要求
本章中,我们将启动一个基于云的 Kubernetes 集群——Google Kubernetes Engine(GKE),用于练习。在撰写本文时,Google Cloud Platform(GCP)提供了一个为期 90 天、价值 300 美元的免费试用,你可以在console.cloud.google.com/注册一个。
你还需要克隆以下 GitHub 仓库以完成一些练习:github.com/PacktPublishing/Modern-DevOps-Practices-2e。
你可以使用 GCP 提供的 Cloud Shell 来完成本章内容。进入 Cloud Shell 并启动一个新的会话。运行以下命令将仓库克隆到你的主目录中,以便访问所需资源:
$ git clone https://github.com/PacktPublishing/Modern-DevOps-Practices-2e.git \
modern-devops
我们还需要设置项目 ID,并启用一些我们将在本章中使用的 GCP API。为此,运行以下命令:
$ PROJECT_ID=<YOUR_PROJECT_ID>
$ gcloud services enable iam.googleapis.com \
container.googleapis.com \
binaryauthorization.googleapis.com \
containeranalysis.googleapis.com \
secretmanager.googleapis.com \
cloudresourcemanager.googleapis.com \
cloudkms.googleapis.com
如果你没有按照前几章的内容进行学习,想要快速开始,可以继续阅读下一个部分,设置基础环境,不过我强烈建议你先阅读前几章,了解整个流程。如果你已经跟着前几章的实操练习走了,那么可以跳过这一部分。
设置基础环境
为了确保与前几章的连续性,我们首先创建一个服务账户,用于 Terraform 与我们的 GCP 项目进行交互:
$ gcloud iam service-accounts create terraform \
--description="Service Account for terraform" --display-name="Terraform"
$ gcloud projects add-iam-policy-binding $PROJECT_ID \
--member="serviceAccount:terraform@$PROJECT_ID.iam.gserviceaccount.com" \
--role="roles/editor"
$ gcloud iam service-accounts keys create key-file \
--iam-account=terraform@$PROJECT_ID.iam.gserviceaccount.com
你会看到一个名为key-file的文件已经在你的工作目录中创建。现在,在 GitHub 上创建一个名为mdo-environments的新仓库,并添加一个README.md文件,重新命名main分支为prod,并使用 GitHub 创建一个名为dev的新分支。导航至https://github.com/<your_github_user>/mdo-environments/settings/secrets/actions/new,创建一个名为GCP_CREDENTIALS的密钥。在values字段中,打印key-file文件,复制其内容并粘贴到 GitHub 密钥的values字段中。
接下来,创建另一个密钥PROJECT_ID,并在values字段中指定你的 GCP 项目 ID。
接下来,我们需要为 Terraform 创建一个GCS bucket作为远程后端。为此,运行以下命令:
$ gsutil mb gs://tf-state-mdo-terraform-${PROJECT_ID}
接下来,我们需要设置我们的 Secrets Manager。让我们创建一个名为 external-secrets 的密钥,在其中传递 MongoDB 的凭证,格式为 JSON。为此,请运行以下命令:
$ echo -ne '{"MONGO_INITDB_ROOT_USERNAME": "root", \
"MONGO_INITDB_ROOT_PASSWORD": "itsasecret"}' | \
gcloud secrets create external-secrets --locations=us-central1 \
--replication-policy=user-managed --data-file=-
Created version [1] of the secret [external-secrets].
我们需要创建 Secret 资源,它将与 GCP 交互以获取存储的密钥。首先,我们需要使用以下命令创建一个 GCP 服务账户与 Secrets Manager 进行交互:
$ cd ~
$ gcloud iam service-accounts create external-secrets
由于我们遵循最小权限原则,我们将添加以下角色绑定,仅提供对 external-secrets 密钥的访问,具体如下:
$ gcloud secrets add-iam-policy-binding external-secrets \
--member "serviceAccount:external-secrets@$PROJECT_ID.iam.gserviceaccount.com" \
--role "roles/secretmanager.secretAccessor"
现在,让我们使用以下命令生成服务账户密钥文件:
$ gcloud iam service-accounts keys create key.json \
--iam-account=external-secrets@$PROJECT_ID.iam.gserviceaccount.com
接下来,将 key.json 文件的内容复制到一个新的 GitHub Actions 密钥 GCP_SM_CREDENTIALS 中。
我们还需要创建以下 GitHub Actions 密钥,以使二进制授权生效:
ATTESTOR_NAME=quality-assurance-attestor
KMS_KEY_LOCATION=us-central1
KMS_KEYRING_NAME=qa-attestor-keyring
KMS_KEY_NAME=quality-assurance-attestor-key
KMS_KEY_VERSION=1
由于工作流在结束时会自动提交拉取请求,我们需要定义一个 GitHub token。这个 token 允许工作流在创建拉取请求时代表当前用户执行操作。以下是步骤:
-
为
mdo-environments仓库创建一个具有“仓库”访问权限的新 token,授予它读写拉取请求权限。这种方法符合最小权限原则,提供更精细的控制。 -
创建 token 后,复制它。
-
现在,创建一个名为
GH_TOKEN的 GitHub Actions 密钥,并将复制的 token 作为值粘贴进去。
现在所有先决条件都已满足,我们可以克隆我们的仓库并复制基础代码。运行以下命令进行此操作:
$ cd ~ && git clone git@github.com:<your_github_user>/mdo-environments.git
$ cd mdo-environments/
$ git checkout dev
$ cp -r ~/modern-devops/ch15/baseline/* .
$ cp -r ~/modern-devops/ch15/baseline/.github .
现在我们处于基础阶段,让我们进一步了解本章将要部署和管理的示例博客应用。
重温博客应用
既然我们之前讨论过博客应用,那么让我们再看一遍它的服务及其交互:

图 15.1 – 博客应用及其服务和交互
到目前为止,我们已经使用 GitHub Actions 为构建、测试和推送博客应用微服务容器创建了 CI 和 CD 管道,并通过 Argo CD 在 GKE 集群中部署它们。
正如你可能记得的,我们为确保应用的顺利运行创建了以下资源:
-
MongoDB:我们部署了一个启用了身份验证的 MongoDB 数据库,并使用 root 凭证。凭证通过来自 Kubernetes Secret 资源的环境变量注入。为了持久化数据库数据,我们创建了一个挂载到容器的 PersistentVolume,并使用 PersistentVolumeClaim 动态供应它。由于容器是有状态的,我们使用 StatefulSet 来管理它,并因此使用无头 Service 来公开数据库。
-
帖子、评论、评分和用户:帖子、评论、评分和用户微服务通过根凭证与 MongoDB 交互,这些凭证通过来自同一Secret的环境变量注入。我们使用各自的Deployment资源部署它们,并通过单独的ClusterIP Services暴露它们。
-
前端:前端微服务不需要与 MongoDB 交互,因此没有与 Secret 资源的交互。我们也使用Deployment资源部署了该服务。由于我们希望将该服务暴露到互联网,我们为其创建了一个LoadBalancer Service。
我们可以通过以下图示来总结它们:

图 15.2 – 博客应用 – Kubernetes 资源和交互
现在我们了解了应用的结构,接下来我们了解一下服务网格是什么以及它在这个用例中的优势。
服务网格介绍
想象一下你处在一个繁忙的城市中,城市里有着复杂的道路和高速公路网络。你正在驾驶汽车,从城市的一侧开到另一侧。在这种情况下,你会接触到以下实体:
-
你的汽车:你的汽车代表计算机系统中的一个独立服务或应用。它有一个特定的目的,就像软件架构中的微服务或应用一样。
-
道路和高速公路:道路和高速公路就像是你应用中不同服务之间的网络连接和通信路径。服务需要互相交互和通信来执行各种功能,就像车辆需要道路从一个地方开到另一个地方。
-
交通灯和标志:交通灯、标志和交通规则帮助管理交通流量,确保车辆(服务)能够安全、高效地在城市中行驶。这些就像服务网格中的规则、协议和工具,调节服务之间的通信和数据交换。
-
交通控制中心:可以把交通控制中心看作是服务网格。它是一个集中式的系统,监控和管理城市的交通流量。类似地,服务网格是一个集中式基础设施,监督并促进服务之间的通信,确保它们能够可靠、安全地通信。
-
流量监控与优化:交通控制中心确保安全通行并能够优化交通流量。它可以重新规划车辆路线,避免拥堵或事故。在服务网格的上下文中,它能够优化数据和请求在服务之间的流动,确保高效和弹性的通信。
-
安全性和可靠性:在城市中,交通控制中心有助于防止事故,确保每个人都能安全到达目的地。同样,服务网格通过提供负载均衡、安全性和容错等功能,提高计算机系统的安全性和可靠性。
就像交通控制中心让你在复杂的城市中更容易、安全地出行一样,计算机系统中的服务网格简化并保护了不同服务之间的通信,确保数据和请求能够顺畅、可靠、安全地流动。
容器及其管理平台,如 Kubernetes,简化了我们处理微服务的方式。容器技术的引入在推广这一概念方面起到了关键作用,它使得各个应用组件能够作为独立的实体执行和扩展,每个组件都有一个隔离的运行环境。
尽管采用微服务架构提供了加速开发、增强系统稳定性、简化测试和能够独立扩展应用各个部分等优势,但它也有其挑战。管理微服务可能是一项复杂的工作。你不再处理单一的单体应用,而是拥有多个动态组件,每个组件都承担特定的功能。
在大规模应用的背景下,看到数百个微服务相互交互并不罕见,这可能会迅速变得令人不知所措。你的安全和运维团队可能会提出以下主要问题:
-
确保微服务之间的安全通信。你需要保护许多小服务,而不是保护一个单体应用。
-
在出现问题时,如何隔离一个有问题的微服务。
-
在全面发布前,以有限比例的流量进行部署测试,以建立信任。
-
整合现在分布在多个来源的应用日志。
-
监控服务的健康状况变得更加复杂,因为应用由许多组件组成。
虽然 Kubernetes 有效地解决了一些管理问题,但它主要作为容器编排平台,并且在这一角色中表现出色。然而,它并没有固有地解决微服务架构的所有复杂性,因为这些架构需要特定的解决方案。Kubernetes 本身并不提供强大的服务管理功能。
默认情况下,Kubernetes 容器之间的通信缺乏安全措施,强制在 Pod 之间使用 TLS 需要管理大量的 TLS 证书。Pod 之间的身份和访问管理也不是开箱即用的。
虽然像 Kubernetes 网络策略这样的工具可以用于在 Pod 之间实施防火墙,但它们在第 3 层而非第 7 层运作,而现代防火墙正是基于第 7 层操作。这意味着你可以识别流量的来源,但无法检查数据包,从而做出基于元数据的决策,例如基于 HTTP 头进行路由。
尽管 Kubernetes 提供了部署 Pod、进行 A/B 测试和金丝雀发布的方法,但这些过程通常涉及容器副本的扩展。例如,部署一个新版本的微服务并将 10% 的流量导向它,至少需要 10 个容器:9 个用于旧版本,1 个用于新版本。Kubernetes 在 Pod 之间平均分配流量,而没有智能流量拆分。
每个 Kubernetes 容器在 Pod 内保持独立的日志记录,因此需要一个定制的解决方案来捕获和汇总日志。
虽然 Kubernetes 仪表盘提供了监控 Pod 和检查其健康状况等功能,但它无法提供关于组件如何交互、Pod 之间流量分配情况或构成应用程序的容器链的信息。无法追踪流量在 Kubernetes Pod 中的流动意味着你无法确定请求在链中遇到故障的位置。
为了全面应对这些挑战,像 Istio 这样的服务网格技术可以提供极大的帮助。它可以有效应对 Kubernetes 中微服务管理的复杂性,并为安全通信、智能流量管理、监控等提供解决方案。让我们通过简要介绍来了解 Istio 服务网格是什么。
Istio 简介
Istio 是一种服务网格技术,旨在简化服务连接、安全性、治理和监控。
在微服务应用的背景下,每个微服务独立运行,使用容器,从而产生了一个复杂的交互网络。这就是服务网格发挥作用的地方,它简化了这些交互的发现、管理和控制,通常通过旁车代理来实现。让我一步一步为你解析。
想象一个标准的 Kubernetes 应用,由前端和后端 Pod 组成。Kubernetes 提供了使用 Kubernetes 服务和 CoreDNS 进行 Pod 之间的内建服务发现。因此,你可以使用服务名称将流量从一个 Pod 引导到另一个 Pod。然而,你对这些交互和运行时流量管理的控制将有限。
Istio 通过将旁车容器注入到 Pod 中,充当代理。你的容器通过这个代理与其他容器进行通信。这种架构使得所有请求都通过代理流动,从而使你能够控制流量并收集数据以供进一步分析。此外,Istio 提供了加密 Pod 之间通信的手段,并通过统一的控制平面实施身份和访问管理。
由于这种架构,Istio 拥有一系列核心功能,能够提升微服务环境中的流量管理、安全性和可观测性。
流量管理
Istio 通过利用 sidecar 代理(通常称为 envoy 代理)以及入口和出口网关,有效地管理流量。借助这些组件,Istio 使你能够塑造流量并定义服务交互规则。这包括实现诸如超时、重试、熔断器等功能,所有这些都可以通过控制面中的配置进行设置。
这些功能为智能实践提供了可能,比如A/B 测试、金丝雀部署和分阶段发布,以及基于百分比的流量划分。你可以无缝执行渐进式发布,从现有版本(Blue)过渡到新版本(Green),所有操作都可以通过用户友好的控制界面完成。
此外,Istio 允许你在生产环境中进行操作测试,通过实时流量镜像来测试实例。这使你能够收集实时数据并在问题影响应用程序之前识别潜在的生产问题。此外,你还可以基于地理位置或用户档案等因素,将请求路由到不同语言版本的微服务。
安全性
Istio 重视安全性,通过 envoy 代理保护你的微服务,并通过互信 TLS 在 pods 之间建立身份访问管理。这是一种强大的防御机制,能够防止中间人攻击,且在 pods 之间提供开箱即用的流量加密。这种互认证确保只有受信任的前端可以连接到后台,从而建立强大的信任关系。因此,即使其中一个 pod 被攻破,它也无法威胁到应用程序的其他部分。Istio 进一步增强了安全性,提供了细粒度的访问控制策略,并引入了目前 Kubernetes 中缺乏的审计工具,从而提升了集群的整体安全性。
可观察性
由于 envoy sidecar 的存在,Istio 能够敏锐地感知流经 pod 的流量,从而使你能够从服务中收集至关重要的遥测数据。这些丰富的数据有助于深入了解服务行为,并为应用程序未来的优化提供了一个窗口。此外,Istio 还整合了应用程序日志,并通过多个微服务实现流量追踪。这些功能使你能够更迅速地识别和解决问题,帮助你隔离有问题的服务并加快调试过程。
面向开发者的友好性
Istio 最显著的特点是,它能够减轻开发者在实现过程中管理安全性和操作复杂性的负担。
Istio 对 Kubernetes 的深刻理解使得开发者可以继续像标准 Kubernetes 部署那样构建应用程序。Istio 会无缝且自动地将 sidecar 容器注入到 pods 中,免去开发者对这些技术细节的担忧。
一旦这些边车容器被集成,运维和安全团队就可以介入,执行与流量管理、安全性及应用程序整体运营相关的政策。这为所有相关方创造了互利的局面。
Istio 使安全和运维团队能够高效地监管微服务应用程序,而不妨碍开发团队的生产力。这种协作方法确保组织内的每个团队都能保持其专业焦点,并有效地为应用程序的成功做出贡献。现在我们已经了解了 Istio,接下来我们将看看它的架构。
理解 Istio 架构
Istio 通过两个基本组件简化了微服务的管理:
-
数据平面:数据平面由 Istio 注入到微服务中的边车 envoy 代理组成。这些代理承担着在不同服务之间路由流量的关键角色,并收集重要的遥测数据,以便于监控和分析。
-
控制平面:控制平面充当指挥中心,指示数据平面如何有效地路由流量。它还负责配置细节的存储和管理,便于管理员与边车代理交互并控制 Istio 服务网格。本质上,控制平面是 Istio 的智能和决策中心。
类似地,Istio 管理两种类型的流量:
-
数据平面流量:这种流量由微服务之间交换的核心业务相关数据组成。它涵盖了应用程序处理的实际交互和事务。
-
控制平面流量:相反,控制平面流量由 Istio 组件之间的消息和通信组成,主要负责管理服务网格的行为。它充当着控制机制,协调微服务架构中的路由、安全性和整体功能。
下图详细描述了 Istio 架构:

图 15.3 – Istio 架构
正如我们在前面的图示中看到的,控制平面和数据平面是两个不同的部分,接下来我们将深入了解它们。
控制平面架构
Istio 将控制平面作为一个单独的 istiod 组件进行部署。Istio 控制平面,或称 istiod,包含多个关键组件,每个组件在管理服务网格中扮演着独特的角色。
Pilot
Pilot 作为服务网格的中央控制中心,使用 Envoy API 与 envoy sidecar 通信,并将 Istio 清单中指定的高级规则转换为 envoy 配置。Pilot 支持服务发现、智能流量管理和路由功能。它使您能够实施 A/B 测试、蓝绿部署、金丝雀发布等实践。此外,Pilot 通过配置 sidecar 处理超时、重试和断路等任务,增强了服务网格的弹性。它的一个显著特点是提供 Istio 配置与底层基础设施之间的桥梁,使 Istio 能够在多种平台上运行,如 Kubernetes、Nomad 和 Consul。无论平台如何,Pilot 都能确保一致的流量管理。
Citadel
Citadel 专注于在您的服务网格内进行身份和访问管理,促进 Kubernetes pod 之间的安全通信。它通过确保加密通信来保护您的 pod,即使您的开发人员设计的组件使用了不安全的 TCP 连接。Citadel 通过管理证书的复杂性简化了相互 TLS 的实现。它提供用户身份验证、凭证管理、证书处理和流量加密,确保 pod 在必要时能够安全地相互验证。
Galley
Galley 负责服务网格中的基本配置任务。它验证、处理并分发配置更改到 Istio 控制平面。例如,当您应用新的策略时,Galley 会摄取该配置,验证其准确性,处理并为目标组件准备,最后无缝地在服务网格中分发。简而言之,Galley 作为 Istio 控制平面与底层 API 之间的接口,促进了服务网格的顺利管理。
现在,让我们深入了解数据平面架构。
数据平面架构
Istio 的数据平面组件由 envoy 代理、入口网关 和 出口网关 组成。
Envoy 代理
Envoy 代理在启用服务网格的各个方面中发挥着至关重要的作用。这些 第 7 层 代理能够根据它们处理的消息内容做出重要决策,并且它们是唯一直接与您的业务流量交互的组件。以下是这些 envoy 代理如何贡献于 Istio 的功能:
-
流量控制:它们提供对服务网格内流量流动的细粒度控制,允许您为各种类型的流量定义路由规则,包括 HTTP、TCP、WebSockets 和 gRPC。
-
安全性和认证:Envoy 代理执行身份和访问管理,确保只有经过授权的 pod 可以相互交互。它们实现相互 TLS和流量加密,以防止中间人攻击,并提供如速率限制等特性,以防止超出预算的成本和拒绝服务攻击。
-
网络弹性:它们通过支持重试、故障转移、断路器和故障注入等特性来增强网络弹性,保持服务的可靠性和健壮性。
接下来,我们来看看入站和出站网关。
入站和出站网关
在 Istio 中,入站网关是一组一个或多个 envoy 代理,Pilot 在其部署时动态配置这些代理。这些 envoy 代理在控制和路由外部流量进入您的服务网格中至关重要,确保流量根据定义的路由规则和策略正确地指向相关服务。这种动态配置使得 Istio 能够有效地管理和保护外部流量流动,而无需大量的人工干预,确保您的应用程序可以在服务网格内高效、安全地运行。
出站网关与入站网关相似,但它们处理的是出站流量。为了更好地理解这一点,我们可以参考图 15.3,了解服务 A和服务 B之间的流量流向。
在这个架构中,服务网格中的流量遵循一个结构化的路径,通过入站、微服务(服务 A和服务 B)以及出站,确保高效的路由和安全措施。让我们分解一下流量包在服务网格中的流动过程。
入站
流量通过入站资源进入服务网格,入站资源本质上是一个 envoy 代理集群。Pilot 在部署时配置这些 envoy 代理。由于基于 Kubernetes 服务端点的配置,入站代理了解其后端服务。入站代理执行健康检查、负载均衡,并基于负载、数据包、配额和流量平衡等指标做出智能路由决策。
服务 A
一旦入站网关将流量路由到一个 pod,它会遇到服务 A pod 的边车代理容器,而不是实际的微服务容器。envoy 代理和微服务容器共享 pod 内的同一网络命名空间,并且具有相同的 IP 地址和 IP 表规则。envoy 代理控制 pod,处理通过它的所有流量。该代理与 Citadel 进行交互,执行策略,检查流量是否需要加密,并与后端 pod 建立 TLS 连接。
服务 B
服务 A 的加密数据包被发送到服务 B,服务 B 会遵循类似的步骤。服务 B 的代理通过与源代理进行 TLS 握手来验证发送者的身份。在建立信任后,数据包被转发到服务 B 的容器,继续流向出口层。
出口
出口资源管理着来自网格的出站流量。出口定义了哪些流量可以离开网格,并使用 Pilot 进行配置,类似于入口层。出口资源使得可以实施限制出站流量只流向必要服务的策略。
遥测数据收集
在这些步骤中,代理收集来自流量的遥测数据。这些遥测数据会发送到Prometheus进行存储和分析。这些数据可以在Grafana中可视化,提供对服务网格行为的洞察。遥测数据也可以发送到外部工具,如ELK,以便对收集的指标进行更深入的分析和机器学习应用。
这一结构化流程确保流量在服务网格中安全高效地流动,同时为监控、分析和决策过程提供宝贵的洞察。
现在我们已经理解了 Istio 的架构和特点,接下来让我们看看如何安装它。
安装 Istio
安装 Istio 的一般方式是通过提供的链接下载 Istio 并运行一个 shell,这将把 Istio 安装到我们的系统中,包括istioctl组件。然后,我们需要使用istioctl在 Kubernetes 集群中安装 Istio。然而,由于我们使用的是 GitOps,我们将使用 GitOps 原则来安装它。Istio 还提供了另一种安装方式——使用 Helm。由于我们知道 Argo CD 支持 Helm,因此我们将使用它。
因此,我们将创建新的 Argo CD 应用程序来部署它。我们将为istio-base创建一个 Argo CD 应用程序:
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: istio-base
namespace: argo
spec:
project: default
source:
chart: base
repoURL: https://istio-release.storage.googleapis.com/charts
targetRevision: 1.19.1
helm:
releaseName: istio-base
destination:
server: "https://kubernetes.default.svc"
namespace: istio-system
syncPolicy:
syncOptions:
- CreateNamespace=true
automated:
selfHeal: true
如我们所见,它将从istio-release.storage.googleapis.com/charts部署istio-base的v1.19.1 helm 图表到 Kubernetes 集群的istio-system命名空间。类似地,我们将使用以下配置将istiod部署到istio-system命名空间:
...
source:
chart: istiod
repoURL: https://istio-release.storage.googleapis.com/charts
targetRevision: 1.19.1
helm:
releaseName: istiod
destination:
server: "https://kubernetes.default.svc"
namespace: istio-system
...
最后,我们将使用以下配置在istio-ingress命名空间中安装istio-ingress组件:
...
source:
chart: gateway
repoURL: https://istio-release.storage.googleapis.com/charts
targetRevision: 1.19.1
helm:
releaseName: istio-ingress
destination:
server: "https://kubernetes.default.svc"
namespace: istio-ingress
...
我们还将在 Terraform 中定义配置,以便我们可以使用基于推送的 GitOps 自动创建我们的应用。因此,我们将以下内容附加到app.tf文件中:
data "kubectl_file_documents" "istio" {
content = file("../manifests/argocd/istio.yaml")
}
resource "kubectl_manifest" "istio" {
depends_on = [
kubectl_manifest.gcpsm-secrets,
]
for_each = data.kubectl_file_documents.istio.manifests
yaml_body = each.value
override_namespace = "argocd"
}
现在,我们可以提交并推送这些文件到我们的远程仓库,并等待 Argo CD 使用以下命令进行变更协调:
$ cd ~
$ cp -a ~/modern-devops/ch15/install-istio/app.tf \
~/mdo-environments/terraform/app.tf
$ cp -a ~/modern-devops/ch15/install-istio/istio.yaml \
~/mdo-environments/manifests/argocd/istio.yaml
$ git add --all
$ git commit -m "Install istio"
$ git push
一旦我们推送代码,我们将看到 GitHub Actions 工作流已触发。要访问该工作流,请前往https://github.com/<your_github_user>/mdo-environments/actions。不久后,工作流将应用配置并创建 Kubernetes 集群,部署 Argo CD、外部机密、我们的 Blog 应用和 Istio。
一旦工作流成功执行,我们必须访问 Argo Web UI。为此,我们需要使用 GKE 集群进行身份验证。执行以下命令来完成身份验证:
$ gcloud container clusters get-credentials \
mdo-cluster-dev --zone us-central1-a --project $PROJECT_ID
要使用 Argo CD Web UI,您需要argo-server服务的外部 IP 地址。要获取该地址,请运行以下命令:
$ kubectl get svc argocd-server -n argocd
NAME TYPE EXTERNAL-IP PORTS AGE
argocd-server LoadBalaner 34.122.51.25 80/TCP,443/TCP 6m15s
现在,我们知道可以通过https://34.122.51.25/访问 Argo CD。
接下来,我们将运行以下命令来重置管理员密码:
$ kubectl patch secret argocd-secret -n argocd \
-p '{"data": {"admin.password": null, "admin.passwordMtime": null}}'
$ kubectl scale deployment argocd-server --replicas 0 -n argocd
$ kubectl scale deployment argocd-server --replicas 1 -n argocd
现在,等待 2 分钟让新凭证生成。之后,执行以下命令以获取密码:
$ kubectl -n argocd get secret argocd-initial-admin-secret \
-o jsonpath="{.data.password}" | base64 -d && echo
现在我们已经有了凭证,可以登录了。我们将看到以下页面:

图 15.4 – Argo CD Web UI – 主页
如我们所见,Istio 应用程序已启动并运行。尽管 Istio 已经安装并运行,但除非我们要求 Istio 注入侧车,否则侧车不会被注入。接下来我们会讲解这个部分。
启用自动侧车注入
由于 envoy 侧车是 Istio 功能背后的关键技术,它们必须添加到现有的 Pod 中,以便 Istio 能够管理它们。更新每个 Pod 的配置以包含这些侧车可能具有挑战性。为了解决这个问题,Istio 提供了解决方案,通过启用这些侧车的自动注入功能。要在命名空间上启用自动侧车注入,我们必须添加一个标签——即istio-injection: enabled。为此,我们将修改blog-app.yaml文件,并将标签添加到命名空间资源中:
apiVersion: v1
kind: Namespace
metadata:
name: blog-app
labels:
istio-injection: enabled
...
现在,我们可以将此资源提交到 Git 并使用以下命令将更改推送到远程:
$ cd ~
$ cp -a ~/modern-devops/ch15/install-istio/blog-app.yaml \
~/mdo-environments/manifests/blog-app/blog-app.yaml
$ git add --all
$ git commit -m "Enable sidecar injection"
$ git push
在下一次 Argo CD 同步中,我们将很快找到附加到命名空间的标签。标签应用后,我们需要重新启动部署和有状态集,此时新 Pod 将启动并带有注入的侧车。使用以下命令进行操作:
$ kubectl -n blog-app rollout restart deploy frontend
$ kubectl -n blog-app rollout restart deploy posts
$ kubectl -n blog-app rollout restart deploy users
$ kubectl -n blog-app rollout restart deploy reviews
$ kubectl -n blog-app rollout restart deploy ratings
$ kubectl -n blog-app rollout restart statefulset mongodb
现在,让我们使用以下命令列出blog-app命名空间中的 Pod:
$ kubectl get pod -n blog-app
NAME READY STATUS RESTARTS AGE
frontend-759f58f579-gqkp9 2/2 Running 0 109s
mongodb-0 2/2 Running 0 98s
posts-5cdcb5cdf6-6wjrr 2/2 Running 0 108s
ratings-9888d6fb5-j27l2 2/2 Running 0 105s
reviews-55ccb7fbd9-vw72m 2/2 Running 0 106s
users-5dbd56c4c5-stgjp 2/2 Running 0 107s
如我们所见,Pod 现在显示两个容器,而不是一个。额外的容器是 envoy 侧车。Istio 的安装和设置已完成。
现在,我们的应用已经注入了 Istio 侧车,我们可以使用 Istio 入口控制器来允许流量访问我们的应用,当前该应用通过负载均衡服务暴露。
使用 Istio 入口控制器来允许流量
我们需要创建一个 Blog 应用的入口网关,将我们的应用与 Istio 入口网关关联起来。这是配置我们的应用通过 Istio 入口网关路由流量所必需的,因为我们希望利用 Istio 的流量管理和安全功能。
Istio 在安装过程中会部署 Istio 入口网关,并且默认将其暴露在负载均衡器上。要确定负载均衡器的 IP 地址和端口,你可以运行以下命令:
$ kubectl get svc istio-ingress -n istio-ingress
NAME EXTERNAL-IP PORT(S)
istio-ingress 34.30.247.164 80:30950/TCP,443:32100/TCP
如我们所见,Istio 在负载均衡器上暴露了多个端口,而我们的应用程序需要运行在端口 80,因此我们可以使用http://<IngressLoadBalancerExternalIP>:80来访问它。
下一步是使用这个入口网关并暴露我们的应用程序。为此,我们需要创建网关(Gateway)和虚拟服务(VirtualService)资源。
Istio 网关是一个自定义资源定义(CRD),它帮助你定义外部流量如何访问你的服务网格中的服务。它充当服务的入口点,并作为传入流量的负载均衡器。当外部流量到达网关时,它会根据指定的路由规则来决定如何将流量路由到相应的服务。
当我们定义一个 Istio 网关时,我们还需要定义一个使用该网关并描述流量路由规则的VirtualService资源。如果没有VirtualService资源,Istio 网关将不知道如何以及将流量路由到哪里。VirtualService资源不仅用于从网关路由流量,还用于在网格中的不同服务之间路由流量。它允许你定义复杂的路由规则,包括流量分配、重试、超时等。虚拟服务通常与特定的服务或工作负载关联,并决定如何将流量路由到这些服务。你可以使用虚拟服务来控制流量如何在服务的不同版本之间分配,从而支持 A/B 测试、金丝雀发布和蓝绿部署等实践。虚拟服务还可以根据 HTTP 头、路径或其他请求属性来路由流量。在当前的上下文中,我们将使用VirtualService资源根据路径过滤流量,并将它们全部路由到前端微服务。
让我们首先查看Gateway资源的定义:
apiVersion: networking.istio.io/v1alpha3
kind: Gateway
metadata:
name: blog-app-gateway
namespace: blog-app
spec:
selector:
istio: ingress
servers:
- port:
number: 80
name: http
protocol: HTTP
hosts:
- "*"
如我们所见,我们定义了一个使用 Istio 入口网关(由istio: ingress选择器定义)的Gateway资源,并监听 HTTP 端口80。它允许连接到所有主机,因为我们将其设置为"*"”。为了使网关正常工作,我们需要定义一个VirtualService`资源。接下来我们来看看这个资源:
apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
name: blog-app
namespace: blog-app
spec:
hosts:
- "*"
gateways:
- blog-app-gateway
http:
- match:
- uri:
exact: /
- uri:
prefix: /static
- uri:
prefix: /posts
- uri:
exact: /login
- uri:
exact: /logout
- uri:
exact: /register
- uri:
exact: /updateprofile
route:
- destination:
host: frontend
port:
number: 80
VirtualService资源监听所有主机,并如指定的那样应用于blog-app-gateway。它允许/static和/posts作为前缀(prefix)匹配。这意味着所有以这些路径开头的 URI 请求都会被路由。/login、/logout、/register、/updateprofile和/路径具有精确(exact)匹配,这意味着只有完全匹配的 URI 才会被允许。这些请求将被路由到frontend服务的端口80。
我们还必须修改blog-app.yaml文件中的frontend服务,将服务类型更改为ClusterIP。这样会将附加的负载均衡器从服务中移除,所有请求将通过入口网关路由。
现在,让我们通过以下命令来应用这些更改:
$ cd ~/mdo-environments
$ cp ~/modern-devops/ch15/istio-ingressgateway/gateway.yaml \
manifests/blog-app/gateway.yaml
$ cp ~/modern-devops/ch15/istio-ingressgateway/blog-app.yaml \
manifests/blog-app/blog-app.yaml
$ git add --all
$ git commit -m "Added gateway"
$ git push
我们将等待 5 分钟以便同步生效,之后可以访问http://<Ingress LoadBalancerExternalIP> 来访问我们的博客应用。你应该能看到以下页面。这表明应用程序运行正常:

图 15.5 – 博客应用 – 主页
你可以通过注册、登录、创建帖子和写评论来玩一下这个应用程序。尝试更新帖子和评论,看看应用的各个方面是否正常工作。现在,让我们看看我们微服务的安全性方面。
使用 Istio 保护你的微服务
在生产环境中运行微服务具有众多优势,如独立的可扩展性、增强的敏捷性、减少的变更范围、频繁的部署和可重用性。然而,它们也带来了独特的挑战,尤其是在安全方面。
在单体架构中,安全性关注的是保护单一应用程序。然而,在典型的企业级微服务应用中,可能需要数百个微服务相互安全地互动。Kubernetes 是托管和编排微服务的绝佳平台。然而,微服务之间的默认通信是不安全的,因为它们通常使用明文 HTTP。这可能无法满足你的安全需求。为了将与传统企业单体应用相同的安全原则应用到微服务中,必须确保以下几点:
-
加密通信:微服务之间的所有交互必须加密,以防止潜在的中间人攻击。
-
访问控制:需要实施访问控制机制,以确保只有经过授权的微服务才能相互通信。
-
遥测和审计日志:捕获、记录和审计遥测数据对于了解流量行为并主动检测入侵至关重要。
Istio 简化了处理这些安全问题,并提供了开箱即用的核心安全功能。通过 Istio,你可以强制执行强大的身份和访问管理、相互TLS和加密、认证和授权以及全面的审计日志—所有这些都在统一的控制平面内。这意味着你可以为你的微服务建立强大的安全实践,在动态分布的环境中提升应用程序的安全性和可靠性。
在 Istio 的背景下,你应该了解它会自动将边车代理注入到你的 Pod 中,并修改 Kubernetes 集群的 IP 表,以确保所有连接都通过这些代理进行。这种设置旨在默认强制启用 TLS 加密,增强你的微服务安全性,而无需特定配置。这些 Envoy 代理之间的通信会通过 TLS 自动加密。
尽管默认配置提供了基础的安全性,并有效防止了中间人攻击,但建议通过应用特定策略来进一步增强微服务的安全性。在深入了解详细功能之前,理解 Istio 中安全性如何运作是有益的。
Istio 包含以下关键组件来执行安全策略:
-
证书颁发机构(CA):此组件管理密钥和证书,确保在服务网格中进行安全和认证的通信。
-
配置 API 服务器:配置 API 服务器将身份验证策略、授权策略和安全命名信息分发给 Envoy 代理。这些策略定义了服务如何进行身份验证和授权,并管理安全通信。
-
边车代理:边车代理作为微服务的配套部署,对于强制执行安全策略至关重要。它们充当策略执行点,实施提供给它们的策略。
-
Envoy 代理扩展:这些扩展使得可以收集遥测数据和审计信息,提供流量行为的洞察,并帮助识别和缓解安全问题。
在这些组件协同工作下,Istio 为你的微服务提供了一个强大的安全框架,可以通过定义和执行特定的安全策略进一步优化,以满足你应用的需求。
由于我们的应用程序目前运行在 HTTP 上,因此在博客应用中实现 TLS 并通过 HTTPS 暴露是一个很好的主意。让我们从创建一个安全的入口网关开始。
创建安全的入口网关
安全的入口网关就是启用了 TLS 的入口网关。要在入口网关上启用 TLS,我们必须为其提供私钥和证书链。我们将在这个练习中使用自签名证书链,但在生产环境中你必须使用正确的 CA 证书链。CA 证书是由可信的 CA(如 Verisign 或 Entrust)授予的数字证书,属于公钥基础设施(PKI)的一部分。它在保障数字交互和交易的安全性与可靠性方面发挥着关键作用。
让我们从创建根证书和私钥开始,通过以下命令签署应用程序的证书:
$ openssl req -x509 -sha256 -nodes -days 365 \
-newkey rsa:2048 -subj '/O=example Inc./CN=example.com' \
-keyout example.com.key -out example.com.crt
使用生成的根证书,我们现在可以使用以下命令生成 服务器证书 和密钥:
$ openssl req -out blogapp.example.com.csr \
-newkey rsa:2048 -nodes -keyout blogapp.example.com.key \
-subj "/CN=blogapp.example.com/O=blogapp organization"
$ openssl x509 -req -sha256 -days 365 \
-CA example.com.crt -CAkey example.com.key -set_serial 1 \
-in blogapp.example.com.csr -out blogapp.example.com.crt
下一步是在 istio-ingress 命名空间中生成一个 Kubernetes TLS 秘密供我们的入口网关读取。然而,考虑到我们不希望将 TLS 密钥和证书存储在 Git 仓库中,我们将改用 Google Secrets Manager。因此,我们将运行以下命令来实现:
$ echo -ne "{\"MONGO_INITDB_ROOT_USERNAME\": \"root\", \
\"MONGO_INITDB_ROOT_PASSWORD\": \"itsasecret\", \
\"blogapptlskey\": \"$(base64 blogapp.example.com.key -w 0)\", \
\"blogapptlscert\": \"$(base64 blogapp.example.com.crt -w 0)\"}" | \
gcloud secrets versions add external-secrets --data-file=-
Created version [2] of the secret [external-secrets].
现在,我们必须创建一个外部秘密清单,从 Secrets Manager 获取密钥和证书,并生成 TLS 秘密。以下清单将帮助我们实现这一目标:
apiVersion: external-secrets.io/v1alpha1
kind: ExternalSecret
metadata:
name: blogapp-tls-credentials
namespace: istio-ingress
spec:
secretStoreRef:
kind: ClusterSecretStore
name: gcp-backend
target:
template:
type: kubernetes.io/tls
data:
tls.crt: "{{ .blogapptlscert | base64decode | toString }}"
tls.key: "{{ .blogapptlskey | base64decode | toString }}"
name: blogapp-tls-credentials
data:
- secretKey: blogapptlskey
remoteRef:
key: external-secrets
property: blogapptlskey
- secretKey: blogapptlscert
remoteRef:
key: external-secrets
property: blogapptlscert
现在,让我们在环境仓库中创建一个目录,并将外部秘密清单复制到其中。使用以下命令进行操作:
$ mkdir ~/mdo-environments/manifests/istio-ingress
$ cp ~/modern-devops/ch15/security/blogapp-tls-credentials.yaml \
~/mdo-environments/manifests/istio-ingress
接下来,我们需要修改入口网关资源以配置 TLS。为此,我们必须将 Gateway 资源修改为以下内容:
apiVersion: networking.istio.io/v1alpha3
kind: Gateway
metadata:
name: blog-app-gateway
namespace: blog-app
spec:
selector:
istio: ingress
servers:
- port:
number: 443
name: https
protocol: HTTPS
tls:
mode: SIMPLE
credentialName: blogapp-tls-credentials
hosts:
- "*"
网关配置与之前类似,但我们使用 port 443 来替代 port 80,用于 HTTPS。我们还有一个 tls 部分,并设置为 SIMPLE 模式,这意味着它是一个标准的 TLS 连接。我们已经指定了 credentialName,指向我们使用 TLS 密钥和证书创建的秘密。由于所有设置已就绪,让我们使用以下命令提交并推送代码:
$ cp ~/modern-devops/ch15/security/gateway.yaml \
~/mdo-environments/manifests/blog-app/
$ cp ~/modern-devops/ch15/security/run-tests.yml \
~/mdo-environments/.github/workflows/
$ git add --all
$ git commit -m "Enabled frontend TLS"
$ git push
等待 blog-app 同步。一旦完成,我们可以通过 https: //
尽管我们已经保护了进入服务网格的连接,但作为额外的安全层,保护所有内部服务之间的 TLS 通信也是非常重要的。接下来,我们将实施这一措施。
在服务网格中强制启用 TLS
如我们所知,默认情况下,Istio 为注入了 sidecar 代理的工作负载之间的通信提供 TLS 加密。然而,需要注意的是,这个默认设置处于兼容模式。在这种模式下,两个具有 sidecar 代理的服务之间的流量是加密的。但是,没有 sidecar 代理的工作负载仍然可以通过明文 HTTP 与后端微服务通信。这个设计选择是为了简化 Istio 的采用,因为新引入 Istio 的团队不需要立即解决将所有源流量启用 TLS 的问题。
让我们创建并进入 default 命名空间中的一个 Pod 的 shell。由于该命名空间没有自动的 sidecar 注入,后端流量将是明文的。然后,我们将在那里 curl frontend 微服务,看看是否能收到响应。运行以下命令进行操作:
$ kubectl run -it --rm --image=curlimages/curl curly -- curl -v http://frontend.blog-app
* Trying 10.71.246.145:80…
* Connected to frontend (10.71.246.145) port 80
> GET / HTTP/1.1
> Host: frontend
> User-Agent: curl/8.4.0
> Accept: */*
< HTTP/1.1 200 OK
< server: envoy
< date: Sat, 21 Oct 2023 07:19:18 GMT
< content-type: text/html; charset=utf-8
< content-length: 5950
< x-envoy-upstream-service-time: 32
<!doctype html>
<html la"g="en">
...
如我们所见,我们得到了 HTTP 200 响应。
这种方法在安全性和兼容性之间取得了平衡,允许逐步过渡到完全加密的通信模型。随着时间的推移,随着更多服务注入了边车代理,微服务应用程序的整体安全态势将得到改善。然而,由于我们是从头开始,强制执行严格的 TLS 来保护我们的 Blog 应用程序是有意义的。所以,让我们这样做。
要在工作负载、命名空间或整个集群启用严格的 TLS,Istio 提供了使用 PeerAuthentication 资源的对等身份验证策略。由于我们只需要在 Blog 应用程序上实现严格的 TLS,因此在命名空间级别启用它是有意义的。为此,我们将使用以下 PeerAuthentication 资源:
apiVersion: security.istio.io/v1beta1
kind: PeerAuthentication
metadata:
name: default
namespace: blog-app
spec:
mtls:
mode: STRICT
现在,让我们使用以下命令应用此配置:
$ cp ~/modern-devops/ch15/security/strict-mtls.yaml \
~/mdo-environments/manifests/blog-app/
$ git add --all
$ git commit -m "Enable strict TLS"
$ git push
一旦我们推送更改,Argo CD 应该会接收新配置并应用严格的 TLS 策略。等待 Argo CD 同步处于干净状态,并运行以下命令检查严格的 TLS 是否工作:
$ kubectl run -it --rm --image=curlimages/curl curly -- curl -v http://frontend.blog-app
* Trying 10.71.246.145:80...
* Connected to frontend.blog-app (10.71.246.145) port 80
> GET / HTTP/1.1
> Host: frontend.blog-app
> User-Agent: curl/8.4.0
> Accept: */*
* Recv failure: Connection reset by peer
* Closing connection
curl: (56) Recv failure: Connection reset by peer
如我们所见,请求已被拒绝,因为它是明文请求,后台将只允许 TLS。这表明严格的 TLS 工作正常。现在,让我们继续并进一步增强服务的安全性。
从我们的设计中,我们知道服务如何相互交互:
-
frontend微服务只能连接到posts、reviews和users微服务。 -
只有
reviews微服务可以连接到ratings微服务。 -
只有
posts、reviews、users和ratings微服务可以连接到mongodb数据库
因此,我们可以定义这些交互,并仅显式允许这些连接。因此,frontend 微服务将无法直接连接到 mongodb 数据库,即使它尝试这样做。
Istio 提供了 AuthorizationPolicy 资源来管理这个问题。让我们使用它来实现上述场景。
让我们从 posts 微服务开始:
apiVersion: security.istio.io/v1beta1
kind: AuthorizationPolicy
metadata:
name: posts
namespace: blog-app
spec:
selector:
matchLabels:
app: posts
action: ALLOW
rules:
- from:
- source:
principals: ["cluster.local/ns/blog-app/sa/frontend"]
AuthorizationPolicy 有多个部分。它从 name 和 namespace 开始,分别为 posts 和 blog-app。spec 部分包含 selector,在这里我们指定需要将此策略应用于所有具有 app: posts 标签的 pod。我们为此使用 ALLOW 动作。请注意,Istio 对所有匹配选择器的 pod 有一个隐式的 deny-all 策略,任何 ALLOW 规则都将在此基础上应用。任何不匹配 ALLOW 规则的流量将默认被拒绝。我们有规则来定义允许哪些流量;在这里,我们使用 from > source > principals,并将 frontend 服务帐户设置在此。因此,总结起来,这条规则将应用于 posts 微服务,并仅允许来自 frontend 微服务的流量。
同样,我们将对 reviews 微服务应用相同的策略,如下所示:
...
name: reviews
...
rules:
- from:
- source:
principals: ["cluster.local/ns/blog-app/sa/frontend"]
users 微服务也只需要接受来自 frontend 微服务的流量:
...
name: users
...
rules:
- from:
- source:
principals: ["cluster.local/ns/blog-app/sa/frontend"]
ratings 微服务应该仅接受来自 reviews 微服务的流量,因此我们将对主体部分进行一些小的修改,如下所示:
...
name: ratings
...
rules:
- from:
- source:
principals: ["cluster.local/ns/blog-app/sa/reviews"]
最后,mongodb 服务需要来自所有微服务的连接,除了 frontend,因此我们必须在主体部分指定多个条目:
...
name: mongodb
...
rules:
- from:
- source:
principals: ["cluster.local/ns/blog-app/sa/posts", "cluster.local/ns/blog-app/sa/
reviews", "cluster.local/ns/blog-app/sa/ratings", "cluster.local/ns/blog-app/sa/users"]
由于我们使用了服务帐户来了解请求的来源,因此我们还必须为相应的服务创建并分配服务帐户。所以,我们将修改 blog-app.yaml 文件,并为每个服务添加服务帐户,类似于以下内容:
apiVersion: v1
kind: ServiceAccount
metadata:
name: mongodb
namespace: blog-app
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
...
spec:
...
template:
...
spec:
serviceAccountName: mongodb
containers:
...
我已经在新的 blog-app.yaml 文件中复制了相同的设置。让我们提交更改并推送到 GitHub,以便我们可以将其应用到集群中:
$ cp ~/modern-devops/ch15/security/authorization-policies.yaml \
~/mdo-environments/manifests/blog-app/
$ cp ~/modern-devops/ch15/security/blog-app.yaml \
~/mdo-environments/manifests/blog-app/
$ git add --all
$ git commit -m "Added auth policies"
$ git push
现在,我们必须等待同步完成,然后验证设置。首先,我们将获取 frontend pod 的 shell,并尝试使用 wget 连接后端微服务。我们将尝试与每个微服务连接,看看结果。如果返回 HTTP 200 或 404,则表示后端允许连接;如果返回 HTTP 403 或 Error,则表示后端正在阻止连接。运行以下命令进行操作:
$ kubectl -n blog-app exec -it $(kubectl get pod -n blog-app | \
grep frontend | awk {'print $1'}) -- /bin/sh
/ # wget posts:5000
Connecting to posts:5000 (10.71.255.204:5000)
wget: server returned error: HTTP/1.1 404 Not Found
/ # wget reviews:5000
Connecting to reviews:5000 (10.71.244.177:5000)
wget: server returned error: HTTP/1.1 404 Not Found
/ # wget ratings:5000
Connecting to ratings:5000 (10.71.242.178:5000)
wget: server returned error: HTTP/1.1 403 Forbidden
/ # wget users:5000
Connecting to users:5000 (10.71.241.255:5000)
wget: server returned error: HTTP/1.1 404 Not Found
/ # wget mongodb:27017
Connecting to mongodb:27017 (10.68.0.18:27017)
wget: error getting response: Resource temporarily unavailable
/ # exit
command terminated with exit code 1
如我们所见,posts、reviews 和 users 微服务返回了 HTTP 404 响应。ratings 微服务返回了 403 Forbidden 响应,而 mongodb 服务报告资源不可用。这意味着我们的设置工作正常。
我们来试试 posts 微服务:
$ kubectl -n blog-app exec -it $(kubectl get pod -n blog-app | \
grep posts | awk {'print $1'}) -- /bin/sh
/ # wget mongodb:27017
Connecting to mongodb:27017 (10.68.0.18:27017)
saving to 'index.html'
index.html 100% |************| 85 0:00:00 ETA
'index.html' saved
/ # wget ratings:5000
Connecting to ratings:5000 (10.71.242.178:5000)
wget: server returned error: HTTP/1.1 403 Forbidden
/ # wget reviews:5000
Connecting to reviews:5000 (10.71.244.177:5000)
wget: server returned error: HTTP/1.1 403 Forbidden
/ # wget users:5000
Connecting to users:5000 (10.71.241.255:5000)
wget: server returned error: HTTP/1.1 403 Forbidden
/ # exit
command terminated with exit code 1
如我们所见,posts 微服务可以成功与 mongodb 通信,但其他微服务返回 403 Forbidden。这正是我们预期的结果。现在,我们来试试 reviews 微服务:
$ kubectl -n blog-app exec -it $(kubectl get pod -n blog-app | \
grep reviews | awk {'print $1'}) -- /bin/sh
/ # wget ratings:5000
Connecting to ratings:5000 (10.71.242.178:5000)
wget: server returned error: HTTP/1.1 404 Not Found
/ # wget mongodb:27017
Connecting to mongodb:27017 (10.68.0.18:27017)
saving to 'index.html'
index.html 100% |**********| 85 0:00:00 ETA
'index.html' saved
/ # wget users:5000
Connecting to users:5000 (10.71.241.255:5000)
wget: server returned error: HTTP/1.1 403 Forbidden
/ # exit
command terminated with exit code 1
如我们所见,reviews 微服务能够成功地与 ratings 微服务和 mongodb 连接,同时从其他微服务获得 403 响应。这正是我们预期的结果。现在,我们来检查 ratings 微服务:
$ kubectl -n blog-app exec -it $(kubectl get pod -n blog-app \
| grep ratings | awk {'print $1'}) -- /bin/sh
/ # wget mongodb:27017
Connecting to mongodb:27017 (10.68.0.18:27017)
saving to 'index.html'
index.html 100% |************| 85 0:00:00 ETA
'index.html' saved
/ # wget ratings:5000
Connecting to ratings:5000 (10.71.242.178:5000)
wget: server returned error: HTTP/1.1 403 Forbidden
/ # exit
command terminated with exit code 1
如我们所见,ratings 微服务只能成功连接到 mongodb 数据库,并且对其他服务返回 403 响应。
现在我们已经测试了所有服务,设置正常工作。我们的微服务已经得到了极大程度的安全保护!接下来,让我们看看使用 Istio 管理微服务的另一个方面——流量管理。
使用 Istio 管理流量
Istio 提供了强大的流量管理功能,构成了其核心功能的一部分。当您在 Kubernetes 环境中使用 Istio 进行微服务管理时,您可以精确控制这些服务之间的通信方式。这使您能够在服务网格中精确地定义流量路径。
以下是您可以使用的一些流量管理功能:
-
请求路由
-
故障注入
-
流量切换
-
TCP 流量切换
-
请求超时
-
电路断路
-
镜像
前一节使用了入口网关来启用流量进入我们的服务网格,并使用虚拟服务将流量分配到各个服务。在虚拟服务的情况下,流量分配默认是轮询方式。然而,我们可以通过目的地规则来更改这一点。这些规则为我们提供了对服务网格行为的精细控制,允许我们在 Istio 生态系统中更细粒度地管理流量。
在深入讨论之前,我们需要更新我们的博客应用程序,使其包含一个作为 ratings-v2 部署的新版本的 ratings 服务,该服务将返回黑色星星而不是橙色星星。我已经在代码库中更新了该清单。因此,我们只需要将其复制到 mdo-environments 仓库,提交并通过以下命令推送到远程:
$ cd ~/mdo-environments/manifests/blog-app/
$ cp ~/modern-devops/ch15/traffic-management/blog-app.yaml .
$ git add --all
$ git commit -m "Added ratings-v2 service"
$ git push
等待应用程序同步。完成后,我们需要做以下几件事:
-
转到博客应用主页 > 登录 > 还不是用户?创建帐户,然后创建一个新帐户。
-
点击 操作 标签 > 添加帖子,添加一个新帖子,选择标题和内容,然后点击提交。
-
使用添加评论文本框添加评论,提供评分,并点击提交。
-
再次点击帖子并访问我们创建的帖子。
现在,继续刷新页面。我们会看到,流量一半时间会得到橙色星星,另一半时间则是黑色星星。流量在 v1 和 v2 之间平分(即橙色和黑色星星):

图 15.6 – 循环路由
之所以发生这种情况,是因为缺少目的地规则,导致 Istio 无法区分 v1 和 v2。让我们为我们的微服务定义目的地规则,以此纠正问题,清楚地告诉 Istio 这些版本的区别。在我们的例子中,每个微服务有一个版本,除了 ratings 微服务,因此我们将相应地定义以下目的地规则。
让我们从定义 frontend 微服务的目的地规则开始:
apiVersion: networking.istio.io/v1alpha3
kind: DestinationRule
metadata:
name: frontend
namespace: blog-app
spec:
host: frontend
subsets:
- name: v1
labels:
version: v1
提供的 YAML 清单在 blog-app 命名空间内引入了一个名为 frontend 的 DestinationRule 资源。这个资源与名为 frontend 的主机相关联。随后,我们定义了标记为 v1 的子集,目标是带有 version: v1 标签的 pods。因此,配置我们的虚拟服务将流量定向到 v1 目的地时,将请求路由到带有 version: v1 标签的 pods。
同样的配置方式可以复制到 posts、users 和 reviews 微服务。但是,由于部署了两个版本,ratings 微服务需要稍微不同的配置,如下所示:
...
spec:
host: ratings
subsets:
- name: v1
labels:
version: v1
- name: v2
labels:
version: v2
ratings 微服务的 YAML 清单与其他微服务非常相似,唯一显著的区别是:它包含一个标记为 v2 的第二子集,对应着带有 version: v2 标签的 pods。
因此,路由到 v1 目标的请求会定向到所有具有 version: v1 标签的 Pod,而路由到 v2 目标的请求则会定向到标记为 version: v2 的 Pod。
为了在实际应用中说明这一点,我们将继续为每个微服务定义虚拟服务。我们的起点是定义 frontend 微服务的虚拟服务,如以下清单所示:
apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
name: frontend
namespace: blog-app
spec:
hosts:
- frontend
http:
- route:
- destination:
host: frontend
subset: v1
提供的 YAML 清单描述了一个名为 frontend 的 VirtualService 资源,位于 blog-app 命名空间内。该资源将主机 frontend 配置为具有 HTTP 路由目标,所有流量都被导向 frontend 主机,并指定了 v1 子集。因此,所有面向 frontend 主机的请求都会路由到我们之前定义的 v1 目标。
我们将为 posts、reviews 和 users 微服务复制这种配置方式,创建相应的 VirtualService 资源。对于 ratings 微服务,决定将所有流量路由到 v1(橙色星标)版本。因此,我们也为 ratings 微服务应用类似的 VirtualService 资源。
现在,让我们将清单复制到 mdo-environments 仓库,并使用以下命令提交并推送代码到远程仓库:
$ cd ~/mdo-environments/manifests/blog-app
$ cp ~/modern-devops/ch15/traffic-management/destination-rules.yaml .
$ cp ~/modern-devops/ch15/traffic-management/virtual-services-v1.yaml .
$ git add --all
$ git commit -m "Route to v1"
$ git push
等待 Argo CD 同步更改。现在,所有请求都将路由到 v1。因此,您将只看到评论中的橙色星标,如下图所示:

图 15.7 – 路由到 v1
现在,让我们尝试通过金丝雀发布方法发布 v2。
流量切换和金丝雀发布
假设你已经开发了一个新的微服务版本,并迫切希望将其推向用户群。然而,你显然会对其对整个服务的潜在影响感到谨慎。在这种情况下,你可以选择一种名为金丝雀发布(也称为蓝绿部署)的部署策略。
金丝雀发布的本质在于其渐进式方法。与其进行突兀的版本过渡,不如通过有条不紊地将流量从先前版本(称为蓝色)转移到新版本(绿色)。这种渐进的迁移方式允许你在将其应用到整个用户群体之前,先在有限的用户子集上彻底测试新版本的功能性和可靠性。这种方法能够最大程度地减少发布新特性或更新时的风险,并确保发布过程更加可控和安全。下图形象地展示了这一过程:

图 15.8 – 金丝雀发布
以下是金丝雀发布策略的工作原理:
-
初始发布:现有版本(称为基准或当前版本)继续服务大多数用户。
-
早期访问:一个小的用户或系统组,通常选定为具有代表性的样本,被认定为金丝雀组。他们将接收到新版本。
-
监控与评估:金丝雀组中软件的性能和行为会被密切监控。通过收集度量、日志和用户反馈,及时发现问题或异常。
-
逐步扩展:如果新版本在金丝雀组中表现稳定且符合预期,其曝光度将逐步扩展到更广泛的用户群体。此扩展可以分阶段进行,每个阶段将“晋升”一定百分比的用户使用新版本。
-
持续监控:在发布过程中,持续的监控和分析至关重要,以便及时识别并解决任何新出现的问题。如果发现问题,可以暂停或回滚发布,以保护大多数用户。
-
完全部署:一旦新版本通过金丝雀发布阶段成功验证,它将最终对所有用户开放。
所以,让我们将ratings-v2服务推送到20%的用户中。为此,我们将使用以下VirtualService资源:
...
http:
- route:
- destination:
host: ratings
subset: v1
weight: 80
- destination:
host: ratings
subset: v2
weight: 20
正如我们所看到的,我们已修改了ratings虚拟服务,引入了指向v2子集的第二个目标。在此配置中一个值得注意的新增项是引入了weight属性。对于v1目标,我们分配了80的权重,而v2目标的权重为20。这意味着20%的流量将被引导到ratings微服务的v2版本,从而提供了两版本之间的流量控制和可调分配。
让我们复制清单,然后使用以下命令将更改提交并推送到远程仓库:
$ cd ~/mdo-environments/manifests/blog-app
$ cp ~/modern-devops/ch15/traffic-management/virtual-services-canary.yaml \
virtual-services.yaml
$ git add --all
$ git commit -m "Canary rollout"
$ git push
完成 Argo CD 同步后,如果你刷新页面 10 次,你会观察到在其中的 10 次中,黑色星星会出现两次。这是金丝雀发布实践的一个典型例子。你可以继续监控应用程序,并逐步调整权重,将流量转向v2。金丝雀发布有效地降低了生产发布过程中的风险,提供了一种应对未知风险的方法,尤其是在实施重大变更时。
然而,还有一种方法可以在生产环境中测试你的代码,这种方法涉及使用实时流量,但不会将应用程序暴露给最终用户。这种方法称为流量镜像。我们将在接下来的讨论中深入探讨它。
流量镜像
流量镜像,也称为影像,是一种最近受到关注的概念。它是一种强大的方法,允许你在生产环境中评估发布,而不对最终用户造成任何风险。
传统上,许多企业维持了一个与生产环境高度相似的暂存环境。在这种设置中,运维团队将新版本发布到暂存环境,而测试人员生成合成流量来模拟真实世界的使用。这种方法为团队提供了一种评估代码在生产环境中表现的方式,评估其功能性和非功能性方面,在推广到生产环境之前。暂存环境充当性能、体量和操作验收测试的基础。虽然这种方法有其优点,但也并非没有挑战。维持静态测试环境是其中之一,这需要大量的成本和资源。创建和维护一个生产环境的副本需要一支工程师团队,从而导致高额的开销。
此外,合成流量通常偏离真实的实时流量,因为前者依赖于历史数据,而后者反映的是当前用户的交互。这种差异偶尔会导致某些场景被忽视。
另一方面,流量镜像提供了一种解决方案,它同样使得操作验收测试成为可能,同时更进一步。它允许你使用实时流量进行测试,而不会对终端用户产生任何影响。
下面是流量镜像的工作原理:
-
部署应用程序的新版本并激活流量镜像。
-
旧版本继续照常响应请求,同时将流量的异步副本发送到新版本。
-
新版本处理镜像流量,但不会对终端用户做出响应。
-
运维团队监控新版本的行为,并将任何问题报告给开发团队。
该过程如以下图所示:

图 15.9 – 流量镜像
流量镜像通过使团队能够发现传统暂存环境中可能隐藏的问题,彻底改变了测试过程。此外,你可以利用诸如 Prometheus 和 Grafana 等监控工具来记录和监控测试结果,提升发布的整体质量和可靠性。
现在,废话不多说,让我们为我们的ratings服务配置流量镜像。流量镜像通过VirtualService资源进行管理,因此让我们将ratings虚拟服务修改为以下内容:
...
http:
- route:
- destination:
host: ratings
subset: v1
weight: 100
mirror:
host: ratings
subset: v2
mirror_percent: 100
在此配置中,我们设置了一个单一的destination,目标为v1,weight值为100。此外,我们定义了一个mirror部分,将流量引导到ratings:v2,并设置mirror_percent为 100。这意味着,所有最初路由到ratings:v1的流量都被镜像并同时发送到v2。
让我们使用以下命令提交更改并将其推送到远程仓库:
$ cp ~/modern-devops/ch15/traffic-management/virtual-services-mirroring.yaml \
virtual-services.yaml
$ git add --all
$ git commit -m "Mirror traffic"
$ git push
在完成 Argo CD 同步过程后,我们将刷新页面五次。随后,我们可以使用以下命令检查ratings:v1服务的日志:
$ kubectl logs $(kubectl get pod -n blog-app | \
grep "ratings-" | awk '{print $1}') -n blog-app
127.0.0.6 - - [22/Oct/2023 08:33:19] "GET /review/6534cba72485f5a51cbdcef0/rating
HTTP/1.1" 200 -
127.0.0.6 - - [22/Oct/2023 08:33:19] "GET /review/6534cbb32485f5a51cbdcef1/rating
HTTP/1.1" 200 -
127.0.0.6 - - [22/Oct/2023 08:33:23] "GET /review/6534cba72485f5a51cbdcef0/rating
HTTP/1.1" 200 -
127.0.0.6 - - [22/Oct/2023 08:33:23] "GET /review/6534cbb32485f5a51cbdcef1/rating
HTTP/1.1" 200 -
127.0.0.6 - - [22/Oct/2023 08:33:25] "GET /review/6534cba72485f5a51cbdcef0/rating
HTTP/1.1" 200 -
在启用流量镜像后,预计在ratings:v1服务中观察到的相同日志集也会被镜像到ratings:v2服务中。为了确认这一点,我们可以使用以下命令列出ratings:v2服务的日志:
$ kubectl logs $(kubectl get pod -n blog-app | \
grep "ratings-v2" | awk '{print $1}') -n blog-app
127.0.0.6 - - [22/Oct/2023 08:33:19] "GET /review/6534cba72485f5a51cbdcef0/rating
HTTP/1.1" 200 -
127.0.0.6 - - [22/Oct/2023 08:33:19] "GET /review/6534cbb32485f5a51cbdcef1/rating
HTTP/1.1" 200 -
127.0.0.6 - - [22/Oct/2023 08:33:23] "GET /review/6534cba72485f5a51cbdcef0/rating
HTTP/1.1" 200 -
127.0.0.6 - - [22/Oct/2023 08:33:23] "GET /review/6534cbb32485f5a51cbdcef1/rating
HTTP/1.1" 200 -
127.0.0.6 - - [22/Oct/2023 08:33:25] "GET /review/6534cba72485f5a51cbdcef0/rating
HTTP/1.1" 200 -
确实,日志和时间戳完全匹配,提供了ratings:v1和ratings:v2中并发日志条目的清晰证据。这一观察有效地展示了镜像功能的运作,展示了如何在两个版本中复制流量以进行实时监控和分析。
流量镜像是一种非常有效的方法,用于识别传统基础设施设置中常常难以发现的问题。它是一种强大的方法,用于对软件版本进行操作验收测试。这种做法简化了测试并防范了潜在的客户事件和操作挑战。
Istio 提供了其他流量管理方面的功能,但本章无法涵盖所有内容。请随时访问 Istio 文档,探索它的其他功能:istio.io/latest/docs/tasks/traffic-management/。
如我们所知,Istio 利用 envoy 代理作为边车组件,配合你的微服务容器。鉴于这些代理在流量的引导和管理中发挥着核心作用,它们还会收集宝贵的遥测数据。
这些遥测数据随后被传输到 Prometheus,一个监控和告警工具,在那里它可以被存储并有效地可视化。Grafana 等工具通常与 Prometheus 一起使用,为这些遥测数据提供深刻且易于访问的可视化,帮助你有效地监控和管理服务网格。因此,我们将在下一节中进一步探讨 Istio 的可观察性部分。
使用 Istio 观察流量并发出警报
Istio 提供了多种工具,通过 Istio 附加组件来可视化流经我们网格的流量。Prometheus是遥测数据收集、存储和查询的核心层,Grafana和Kiali为我们提供了互动的图形工具,帮助我们与这些数据进行交互。
让我们通过以下命令安装可观察性附加组件来开始这一节:
$ cd ~
$ mkdir ~/mdo-environments/manifests/istio-system
$ cd ~/mdo-environments/manifests/istio-system/
$ cp ~/modern-devops/ch15/observability/*.yaml .
$ git add --all
$ git commit -m "Added observability"
$ git push
一旦我们推送代码,Argo CD 应该会创建一个新的istio-system命名空间并安装附加组件。安装完成后,我们可以从访问 Kiali 仪表盘开始。
访问 Kiali 仪表盘
Kiali 是一个强大的可观察性和可视化工具,专门用于微服务和服务网格管理。它提供了有关你的服务网格行为的实时洞察,帮助你高效地监控和故障排除问题。
由于 Kiali 服务部署在集群 IP 上,因此不对外暴露,我们需要做一个端口转发,以便使用以下命令访问 Kiali 仪表板:
$ kubectl port-forward deploy/kiali -n istio-system 20001:20001
一旦端口转发会话开始,点击 Google Cloud Shell 的网页预览图标,选择更改端口为 20001,然后点击预览。你将看到以下仪表板。该仪表板为我们提供了有关在服务网格中运行的应用程序的宝贵见解:

图 15.10 – Kiali 仪表板
为了可视化服务之间的交互,我们可以通过点击blog-app命名空间切换到图形视图。我们将看到以下仪表板,该仪表板提供了流量流动情况、成功流量百分比以及其他指标的准确视图:

图 15.11 – Kiali 服务交互图
虽然 Kiali 仪表板提供了有关我们的服务网格的宝贵见解,并帮助我们实时观察服务交互,但它缺乏提供高级监控和告警功能的能力。为此,我们可以使用 Grafana。
使用 Grafana 进行监控和告警
Grafana 是一个领先的开源平台,用于可观察性和监控,提供动态仪表板和强大的告警功能。它使用户能够可视化来自不同来源的数据,同时设置告警以主动检测问题。
由于我们已经安装了带有必要插件的 Grafana,让我们通过打开一个端口转发会话来访问它。确保终止现有的 Kiali 端口转发会话,或者使用不同的端口。运行以下命令来实现:
$ kubectl port-forward deploy/grafana -n istio-system 20001:3000
一旦端口转发会话开始,就像我们对 Kiali 所做的那样访问 Grafana 页面,然后进入首页 > 仪表板 > Istio > Istio 服务仪表板。我们应该能看到类似于以下的仪表板:

图 15.12 – Istio 服务仪表板
该仪表板提供了有关我们可能想要监控的一些标准 SLI(服务级指标)的丰富可视化,例如请求的成功率、持续时间、大小、流量和延迟。它帮助你细致地观察你的服务网格,并且你还可以通过使用Prometheus 查询语言(PromQL)构建额外的可视化,PromQL 学习和应用起来都很简单。
然而,监控和可视化必须与告警相结合,才能确保完整的可靠性。所以,让我们深入探讨一下。
使用 Grafana 进行告警
为了启动告警过程,建立清晰的标准至关重要。鉴于目前的流量较低,模拟准确的 SLO 违约可能具有挑战性。为简化起见,我们的告警标准将在流量超过每秒一个事务时触发。
该过程的初始阶段涉及编写查询以获取必要的度量指标。我们将使用以下查询来实现这一目标:
round(sum(irate(istio_requests_total{connection_security_policy="mutual_tls",destination_
service=~"frontend.blog-app.svc.cluster.local",reporter=~"destination",source_
workload=~"istio-ingress",source_workload_namespace=~"istio-ingress"}[5m])) by (source_
workload, source_workload_namespace, response_code), 0.001)
提供的查询确定了通过 Istio 入口网关传递到frontend微服务的所有事务的流量速率。
下一步是创建警报规则并应用查询。为此,导航至首页 > 告警 > 告警规则。然后,填写表单,如下方截图所示:

图 15.13 – 定义警报规则
警报规则配置为在 1 分钟的间隔内监控违反情况,持续 2 分钟。一旦设置了警报规则,触发警报就非常简单,只需每 1 到 2 分钟快速刷新博客应用首页 15 到 20 次即可。这一操作应该会激活警报。要观察此过程,导航至首页 > 告警 > 告警规则。你会发现警报在第一分钟内处于待处理状态。这意味着它已经在其中一次检查中检测到违反情况,并将在 2 分钟内等待另一次违反情况,之后触发警报。
在生产环境中,通常会设置较长的检查间隔,通常为 5 分钟,并设置 15 分钟的告警间隔。这种做法有助于避免对自我解决的短暂问题发出过多警报,从而确保 SRE 团队不会被虚假警报淹没。目标是保持平衡,避免团队将每个警报都视为潜在的虚假警报。以下截图显示了一个待处理的警报:

图 15.14 – 警报待处理
在 2 分钟的监控期结束后,你应该观察到警报被触发,如下方截图所示。这表示警报规则已经成功识别出持续违反定义标准的情况,并正在积极通知相关方或系统:

图 15.15 – 警报触发
由于在此上下文中未配置特定的警报通道,触发的警报将仅在 Grafana 仪表盘中可见。强烈建议设置专门的警报目标,将警报发送到指定的通道,使用如PagerDuty之类的工具通知值班工程师,或通过Slack通知你的值班团队。合适的警报通道确保相关个人或团队能及时收到关键问题通知,从而快速响应并解决问题。
总结
随着我们结束本章并总结本书,我们的旅程带领我们走过了各种各样的概念和功能。虽然本章我们已覆盖了大量内容,但必须认识到 Istio 是一项丰富而多面的技术,很难在一章内涵盖其所有复杂性。
本章标志着我们进入了服务网格的世界,阐明了它在微服务环境中的独特优势。我们的探索涉及 Istio 的各个方面,从安装 Istio 开始,到通过自动 sidecar 注入扩展我们的示例博客应用。随后,我们进入了安全部分,深入探讨了如何使用 mTLS 加强入口网关的安全性,强制微服务间使用严格的 mTLS,并利用授权策略来管理流量。
接着我们的旅程带领我们进入流量管理,我们介绍了诸如目标规则和虚拟服务等基本概念。这些概念使我们能够执行金丝雀发布和流量镜像,展示了受控部署和实时流量分析的强大功能。我们的探索最终进入了可观察性,我们利用 Kiali 仪表盘可视化服务交互,并深入探讨了使用 Grafana 进行高级监控和告警功能。
随着我们结束这段精彩的旅程,我想对你表达衷心的感谢,感谢你选择了这本书并陪伴我走过它的每一页。我相信你已经从书中的每个部分获得了愉悦和启发。我希望这本书能为你提供必要的技能,让你在不断发展的现代 DevOps 领域中脱颖而出。祝愿你在当前和未来的所有努力中取得最大的成功。
问题
回答以下问题以测试你对本章的理解:
-
在使用 GitOps 方法的可选选项中,你会选择哪种方式来安装 Istio?
A. Istioctl
B. Helm charts
C. Kustomize
D. Manifest bundle
-
要让 Istio 自动将 sidecar 注入到你的工作负载中,必须进行哪些配置?
A. 将
istio-injection-enabled: true标签应用到命名空间B. 不需要配置 — Istio 会自动将 sidecar 注入所有 pod
C. 修改清单以包含 Istio sidecar 并重新部署
-
Istio sidecar 会自动使用 mTLS 进行相互通信。(正确/错误)
-
哪种资源强制执行指定哪些服务可以相互通信的策略?
A.
AuthenticationPolicyB.
AuthorizationPolicyC.
PeerAuthentication -
以下哪项资源适用于金丝雀发布?(选择两项)
A.
VirtualServiceB.
IngressGatewayC.
DestinationRuleD.
Egress Gateway -
为什么在生产环境中使用流量镜像?(选择三项)
A. 实时监控生产环境性能和行为分析
B. 将流量路由到新版本并复制流量以测试后端服务的性能
C. 在不冒生产中断风险的情况下安全地测试更改或更新
D. 简化故障排除和调试,以便识别和解决问题
-
你会使用哪种可观察性工具来可视化实时服务交互?
A. Prometheus
B. Grafana
C. Kiali
D. Loki
答案
以下是本章问题的答案:
-
B
-
A
-
正确
-
B
-
A 和 C
-
A、C 和 D
-
C
附录:AI 在 DevOps 中的角色
随着 ChatGPT 推出的生成性 AI,人工智能(AI)的最新发展震动了科技行业。许多现有的 AI 参与者纷纷转型,且大多数公司现在都在寻找最佳方式将其应用到产品中。自然,DevOps 及其相关工具也不例外,AI 正在慢慢地在这一领域站稳脚跟,而这个领域历史上更多依赖传统的自动化方法。在我们深入探讨 AI 如何改变 DevOps 之前,让我们先了解什么是 AI。
本附录将涵盖以下主题:
-
什么是 AI?
-
AI 在 DevOps 无限循环中的角色
什么是 AI?
AI 模拟计算中的人类智能。你知道我们的计算机能做出惊人的事情,但它们需要告诉每一步该做什么,对吧?而 AI 不需要那样,它通过大量的信息学习,就像我们从经验中学习一样。这样,它就能独立发现模式,并做出决策,而不需要每次都有人告诉它该做什么。这使得 AI 变得智能,因为它能够不断学习新事物,并在其执行的任务中不断变得更好。
想象一下,如果你的计算机能够从它所看到的每一件事中学习,就像你从周围的一切中记住一样。这就是 AI 的工作原理——它是计算机变得更智能的一种方式。AI 不再需要逐步指令,它可以从大量信息中学习。这使得它非常擅长在数据中发现模式并做出决策。而在 DevOps 中,AI 可以提供极大的帮助!我们接下来来看一下。
AI 在 DevOps 无限循环中的角色
正如我们已经知道的那样,DevOps 实践通常遵循一个无限循环,而不是传统的线性软件交付路径,如下图所示:

图 A.1 – DevOps 无限循环
DevOps 实践极大地强调自动化,以确保这个无限循环顺利运行,我们需要工具。大多数这些工具帮助构建、部署和运营软件。通常,你会在集成开发环境(IDE)中开始编写代码,然后将代码提交到如 Git 这样的中央源代码仓库。会有一个持续集成流水线,它从你的 Git 仓库构建代码并将其推送到制品仓库。你的 QA 团队可能会编写自动化测试,以确保在使用持续部署流水线将制品部署到更高环境之前进行测试。
在 AI 出现之前,设置所有工具链并操作它们依赖于传统的编码方法;也就是说,你仍然需要编写代码来自动化过程,而自动化会更可预测地按照指令执行。然而,随着 AI 的出现,事情正在发生变化。
AI 正在通过自动化任务、预测故障和优化性能来改变 DevOps。换句话说,通过利用 AI 的能力,DevOps 团队可以实现更高的效率,减少错误,并更快、更可靠地交付软件。
下面是 AI 在 DevOps 中的一些关键角色:
-
自动化重复任务:AI 可以自动化重复且乏味的任务,如代码测试、部署和基础设施供应。这让 DevOps 工程师能够专注于更具战略性和创造性的工作,如开发新特性和改进应用性能。
-
预测和防止故障:AI 可以分析大量数据,包括日志、性能指标和用户反馈,以识别模式并预测潜在故障。这种主动的方式使 DevOps 团队能够在问题影响用户或导致重大中断之前就进行处理。
-
优化资源利用率:AI 可以分析资源使用数据,优化基础设施分配并防止资源瓶颈。这确保了应用程序能够获得所需的资源以实现最佳性能,从而减少停机时间并提高整体系统效率。
-
增强安全性:AI 可以通过分析网络流量、识别异常行为并标记可疑活动来检测和防止安全威胁。这帮助 DevOps 团队保持强大的安全态势并保护敏感数据。
-
改善协作与沟通:AI 可以通过提供实时见解、自动化工作流和启用无缝沟通渠道来促进 DevOps 团队之间的协作与沟通。这打破了信息孤岛,促进了更具凝聚力的 DevOps 文化。
让我们看看 DevOps 无限循环的各个领域,看看 AI 如何影响它们。
代码开发
在这个领域,我们看到了生成式 AI 和其他 AI 技术的最大影响。AI 通过自动化任务如代码生成、错误检测、优化和测试,彻底改变了代码开发。通过自动完成功能、错误检测算法和预测分析,AI 加速了编码过程,提升了代码质量,并确保了更好的性能,同时帮助文档编写和代码安全分析。它的角色贯穿于从辅助编写代码到预测问题,最终简化软件开发生命周期,赋能开发者创造更高效、更可靠和更安全的应用程序。
许多工具在代码开发中应用了 AI,其中最受欢迎的工具之一是GitHub Copilot。
GitHub Copilot 是 GitHub 和 OpenAI 的合作成果,推出了一项代码补全功能,利用 OpenAI 的 Codex。Codex 在 GitHub 上大量的代码库中进行训练,能够根据当前文件的内容和光标位置快速生成代码。它兼容流行的代码编辑器,如 Visual Studio Code、Visual Studio、Neovim 和 JetBrains IDEs,并支持 Python、JavaScript、TypeScript、Ruby 和 Go 等语言。
受到 GitHub 和用户们的共同赞誉,Copilot 能生成完整的代码行、函数、测试和文档。其功能依赖于所提供的上下文以及 GitHub 上开发者的广泛代码贡献,无论其软件许可如何。被微软称为世界上首个 AI 配对程序员,它是一个付费工具,提供 60 天试用期后,每位用户每月收取 10 美元或每年 100 美元的订阅费。
使用 Copilot,你可以通过先写下你打算做的事情的注释来开始,它会为你生成所需的代码。这大大加快了开发速度,大多数时候,你只需要审查和测试你的代码,看看它是否按预期运行。的确是一股强大的力量!它能够优化现有代码并通过生成代码片段提供反馈。它还可以扫描你的代码,查找安全漏洞,并建议替代方案。
如果你不想支付那 10 美元,你也可以选择一些免费的替代工具,如 Tabnine、Captain Stack、GPT-Code Clippy、Second Mate 和 Intellicode。付费替代工具包括亚马逊的 Code Whisperer 和谷歌的 基于 ML 的 代码补全。
AI 工具不仅有助于增强开发工作流,还能帮助软件测试和质量保证。接下来我们来看看这一点。
软件测试与质量保证
传统上,软件测试更倾向于手动方式,因为大多数开发者并不希望将软件测试作为全职职业。尽管自动化测试近年来逐渐普及,但知识差距仍然制约着大多数组织的这一进程。因此,AI 将对测试功能产生重大影响,因为它弥补了人类与机器之间的差距。
集成 AI 的测试技术正在革新 软件测试生命周期(STLC)的每个阶段,其中一些如下:
-
测试脚本生成:传统上,创建测试脚本是一个耗时的过程,需要对系统有深入的理解。现在,AI 和 机器学习(ML)通过分析需求、现有测试用例和应用行为,快速生成更优化的测试脚本,提供可直接使用的模板,内含预配置的代码片段和详尽的注释,并使用 自然语言处理(NLP)技术将简单语言指令转化为完整的测试脚本。
-
测试数据生成:AI 装备的测试工具提供详细且丰富的测试数据,以实现全面覆盖。它们通过从现有数据集中生成合成数据来针对特定测试目标,转换数据以创建多样的测试场景,优化现有数据以提高精度和相关性,以及扫描大规模代码库以理解上下文。
-
智能测试执行:AI 通过自动分类和组织测试用例,针对不同设备、操作系统和配置智能地选择测试,并巧妙地执行关键功能的回归测试,从而缓解了测试执行的挑战。
-
智能测试维护:AI/机器学习通过实施自我修复机制处理选择器故障,并分析 UI 和代码变更关系,以识别受影响的区域,从而最大限度减少测试维护的挑战。
-
根本原因分析:AI 通过分析日志、性能指标和异常来帮助理解和修复问题,准确找出影响区域,追溯问题到受影响的用户故事和功能需求,并利用知识库进行全面的根本原因分析。
市场上有多个工具可以帮助你实现这一切,其中一些最流行的工具如下:
-
Katalon 平台:一款全面的质量管理工具,简化了跨多个应用和环境的测试创建、执行和维护。它拥有 AI 功能,如TrueTest、StudioAssist、自我修复、视觉测试和AI 驱动的测试失败分析。
-
TestCraft:基于Selenium构建,TestCraft 提供了手动和自动化测试功能,具有用户友好的界面和 AI 驱动的元素识别,支持多浏览器并行运行测试。
-
Applitools:以其基于 AI 的视觉测试而闻名,Applitools 高效地识别视觉缺陷,监控应用程序的视觉方面,并使用 AI 和机器学习提供准确的视觉测试分析。
-
功能:利用 AI/机器学习进行功能、性能和负载测试,简单易用,支持通过简单的英语输入创建测试、自动修复、测试分析和多浏览器支持。
-
Mabl:一款 AI 驱动的工具,提供低代码测试、直观智能、数据驱动功能、端到端测试以及有价值的洞察生成,促进团队协作。
-
AccelQ:自动化 UI、移动端、API 和 PC 软件的测试设计、计划和执行,具备自动化测试生成、预测分析和全面的测试管理功能。
-
Testim:通过机器学习加速测试创建和维护,允许快速的端到端测试创建、智能定位器用于可靠测试,并结合录制功能和编码来创建强大的测试。
正如我们已经看到的,AI 在开发和测试中的好处,我们接下来看看软件交付。
持续集成和交付
在持续集成(CI)和持续交付(CD)中,人工智能通过优化和自动化软件开发流程的各个阶段,带来了变革性的优势。人工智能增强了持续集成,通过自动化代码分析、识别模式和预测潜在的集成问题。它通过分析代码变化、建议适当的测试用例并促进更快的集成周期,从而简化了流程。通过机器学习(ML),人工智能可以理解过去构建的历史数据,识别导致失败的模式,从而帮助更高效地调试和改善代码质量。
在持续交付(CD)中,人工智能通过自动化发布策略、预测性能瓶颈并建议优化方案,以实现更顺畅的交付。它分析部署模式、用户反馈和系统性能数据,推荐最有效的交付路线。此外,人工智能驱动的 CD 工具提高了风险预测能力,帮助团队预见潜在的部署失败,并在生产环境受到影响之前做出明智决策以减轻风险。最终,人工智能在持续集成/持续交付(CI/CD)中的作用加速了开发周期,提升了软件质量,并增强了软件发布的可靠性。
以下是一些用于软件发布和交付的人工智能驱动工具:
-
Harness:Harness 利用人工智能自动化软件交付流程,包括持续集成、部署和验证。它通过机器学习分析部署管道中的模式,预测潜在问题,并优化发布策略,以提高效率和可靠性。
-
GitClear:GitClear 采用人工智能算法分析代码库,提供关于开发者生产力、代码贡献和团队表现的洞察。它帮助了解代码库变化、识别瓶颈并优化开发工作流。
-
Jenkins:得益于其插件架构,广泛使用的自动化服务器 Jenkins 采用了大量人工智能插件和扩展,增强了其在 CI/CD 中的能力。人工智能驱动的插件通过分析历史数据,帮助自动化任务、优化构建时间并预测构建失败。
-
CircleCI:CircleCI 整合了人工智能和机器学习,以优化 CI/CD 工作流。它分析构建日志,识别导致失败的模式,并提供改善构建性能和可靠性的建议。
这些人工智能驱动的工具通过自动化任务、优化工作流、预测问题并提供有价值的洞察,改善了软件发布和交付过程的速度、质量和可靠性,从而帮助做出更好的决策。
现在,让我们看一下流程中的下一阶段——软件运维。
软件运维
AI 在现代软件运维中至关重要,彻底改变了系统监控、管理和优化的方式。通过利用机器学习算法,AI 帮助自动化日常任务,如监控系统性能、分析日志和实时识别异常。它通过检测系统故障前的模式来实现预测性维护,从而进行主动干预,防止潜在的停机。此外,AI 驱动的工具通过关联警报、优先处理关键问题和提供可操作的见解来简化事件管理,增强了软件运维的整体弹性和可靠性。
此外,AI 通过分析大量数据来增强决策过程,识别趋势、预测资源需求,并优化基础设施利用率。AI 通过其持续学习的能力适应变化的环境,使软件运维团队能够应对不断变化的挑战和复杂性。总体而言,AI 在软件运维中的作用确保了更高的效率、提升的系统性能和主动的问题解决,从而显著地促进了 IT 基础设施的无缝运行。
以下是一些在软件运维中使用的 AI 驱动工具:
-
Dynatrace:Dynatrace 利用 AI 进行应用性能监控和管理。它使用 AI 算法分析大量数据,提供应用性能的实时洞察,识别瓶颈,并在问题影响最终用户之前预测潜在问题。
-
PagerDuty:PagerDuty 集成了 AI 驱动的事件管理、警报和值班调度。它使用机器学习关联事件和警报,减少噪音并为关键事件提供智能通知。
-
Opsani:Opsani 利用 AI 实现云应用的自主优化。它分析应用性能,动态调整配置,并优化资源以最大化性能和成本效益。
-
Moogsoft:Moogsoft 提供 AI 驱动的 IT 运维和 AIOps 平台。它使用机器学习检测异常、关联事件并自动化事件解决,帮助团队主动管理和解决复杂 IT 环境中的问题。
-
Sumo Logic:Sumo Logic 使用 AI 进行日志管理、监控和分析。它利用机器学习识别日志和操作数据中的模式、异常和安全威胁,从而实现主动故障排除和安全事件检测。
-
New Relic:New Relic 利用 AI 进行应用和基础设施监控。其 AI 驱动的平台有助于识别性能问题、预测系统行为,并优化资源利用,以提高应用性能。
-
LogicMonitor:LogicMonitor 利用 AI 进行基础设施监控和可观察性分析。它分析度量数据和性能数据,提供系统健康状况的洞察,预测潜在问题,并在复杂环境中优化资源分配。
-
OpsRamp:OpsRamp 利用 AI 进行 IT 运维管理,提供监控、事件管理和自动化功能。它使用机器学习(ML)来检测异常、自动化常规任务并优化工作流,从而提高运维效率。
这些 AI 驱动的工具有助于自动化任务、预测和防止问题、优化资源分配,并提高软件运维中整体系统的可靠性。
AI 在 DevOps 实践中的集成仍处于初级阶段,但其潜在影响巨大。通过自动化任务、优化流程和增强协作,AI 可以彻底改变软件的开发、部署和管理方式。随着 AI 技术的不断发展,我们可以期待看到更多 AI 被用于改进 DevOps 过程的方式。
总结
AI 通过在每个开发和运维周期阶段注入智能,彻底改变了 DevOps 实践。它简化了流程,提高了效率,并确保开发和运维团队之间的协作更加顺畅。AI 自动化常规任务,预测潜在瓶颈,优化工作流,改变了软件的构建、测试、部署和监控方式。从自动化代码分析到预测系统故障,AI 通过加快决策过程、减少错误以及促进更加敏捷和响应迅速的软件开发环境,赋能 DevOps。
本质上,AI 充当着一个无声的伙伴,持续从数据中学习,提出改进建议,并帮助 DevOps 团队在问题影响软件性能之前预见并解决问题。它是推动敏捷性和创新的催化剂,使 DevOps 从仅仅是团队之间的协作,发展为 AI 提升开发和运维双方能力的共生关系,为更高效、可靠的软件交付铺平道路。


浙公网安备 33010602011771号