Kubernetes-模式第二版-全-
Kubernetes 模式第二版(全)
原文:
zh.annas-archive.org/md5/9751bda20cca9fd13c2d27dcff413cec译者:飞龙
前言
当 Craig、Joe 和我近八年前启动 Kubernetes 时,我认为我们都意识到它改变了全球软件开发和交付方式的潜力。我不认为我们知道,甚至希望相信,这种转变会来得如此迅速。Kubernetes 现在是开发跨主要公共云、私有云和裸机环境中便携可靠系统的基础。然而,即使 Kubernetes 已经变得无处不在,以至于您可以在云中不到五分钟内启动一个集群,确定创建了该集群后该何去何从仍然不那么明显。我们看到 Kubernetes 的运营化取得了如此重大的进展是非常棒的,但这只是解决方案的一部分。它是构建应用程序的基础,并提供了大量的 API 和工具库来构建这些应用程序,但它对于应用架构师或开发人员如何将这些不同的组件结合成满足其业务需求和目标的完整可靠系统提供的提示或指导甚少。
尽管通过类似系统的过去经验或试错可以获得对如何处理您的 Kubernetes 集群的必要视角和经验,但无论是时间成本还是交付给最终用户的系统质量,这都是昂贵的。当您开始在类似 Kubernetes 这样的系统之上交付关键任务服务时,通过试错学习的方式简直是太费时间,并且会导致实际的停机和中断问题。
这就是为什么 Bilgin 和 Roland 的书如此宝贵。Kubernetes 模式使您能够从我们编码到 Kubernetes API 和工具中的先前经验中学习。Kubernetes 是社区在各种不同环境中构建和交付许多不同可靠分布式系统经验的副产品。Kubernetes 中添加的每个对象和功能代表了为软件设计师解决特定需求而设计和定制的基础工具。本书解释了 Kubernetes 中的概念如何解决现实世界的问题,以及如何调整和使用这些概念来构建您今天正在开发的系统。
在开发 Kubernetes 时,我们始终说我们的北极星是将分布式系统开发变成 CS 101 课程。如果我们成功实现了这一目标,像这样的书籍就是这类课程的教科书。Bilgin 和 Roland 捕捉了 Kubernetes 开发人员的基本工具,并将它们分解成易于理解和消化的部分。当您完成本书时,您将意识到不仅可以在 Kubernetes 中使用哪些组件,还可以了解使用这些组件构建系统的“为什么”和“如何”。
Brendan Burns
Kubernetes 联合创始人
前言
近年来,微服务和容器的主流采用彻底改变了软件设计、开发和运行的方式。今天的应用程序优化了可用性、可扩展性和上市速度。在新需求的推动下,现代应用程序需要一组不同的模式和实践。本书旨在帮助开发人员探索和学习使用 Kubernetes 创建云原生应用程序的最常见模式。首先,让我们简要了解本书的两个主要组成部分:Kubernetes 和设计模式。
Kubernetes
Kubernetes 是一个容器编排平台。Kubernetes 的起源可以追溯到谷歌数据中心,谷歌的内部容器编排平台 Borg。
Kubernetes 从一开始就吸引了大量用户社区,并且贡献者数量增长迅速。今天,Kubernetes 被认为是 GitHub 上最受欢迎的项目之一。可以说,Kubernetes 是最常用和功能最丰富的容器编排平台。Kubernetes 也构成了其他基于它构建的平台的基础。其中最著名的 Platform-as-a-Service 系统之一是 Red Hat OpenShift,为 Kubernetes 提供各种附加功能。这些只是我们选择 Kubernetes 作为本书云原生模式参考平台的一些原因。
这本书假设你已经掌握了一些 Kubernetes 的基础知识。在第一章,我们回顾了核心 Kubernetes 概念,并为后续的模式奠定了基础。
设计模式
设计模式的概念可以追溯到 20 世纪 70 年代,起源于建筑领域。建筑师和系统理论家克里斯托弗·亚历山大及其团队在 1977 年出版了开创性作品一种模式语言(牛津大学出版社),描述了用于创建城镇、建筑和其他建设项目的建筑模式。后来,这个想法被新兴的软件行业采纳。在这个领域最著名的书籍是由 Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides 合著的设计模式——可复用面向对象软件的元素(Addison-Wesley),被称为四人帮。当我们谈论著名的单例、工厂或委托模式时,正是因为这部定义性作品。自那以后,许多其他优秀的模式书籍已经为不同领域撰写,具有不同的粒度级别,例如由 Gregor Hohpe 和 Bobby Woolf(Addison-Wesley)编写的企业集成模式或由 Martin Fowler(Addison-Wesley)编写的企业应用架构模式。
简而言之,模式描述了问题的可重复解决方案。^(1) 这个定义适用于我们在本书中描述的模式,除了我们的解决方案可能没有那么多的变化。模式不同于配方,因为它不是提供解决问题的逐步说明,而是提供解决整个类似问题的蓝图。例如,亚历山大模式啤酒厅描述了应如何建造公共饮酒厅,其中“陌生人和朋友是喝酒的伙伴”,而不是“孤独者的锚”。按照这种模式建造的所有大厅看起来都不同,但共享共同特征,如为四到八人的小组提供的开放式凹室,以及一百人可以聚会享受饮料、音乐和其他活动的地方。
但是,一个模式不仅仅提供了一个解决方案。它还涉及形成一种语言。这本书中的模式形成了一种密集的、以名词为中心的语言,其中每个模式都有一个独特的名称。当这种语言确立时,这些名称在人们谈论这些模式时会自动唤起类似的心理表征。例如,当我们谈论一张桌子时,任何讲英语的人都会认为我们在谈论一块有四条腿和一个顶部的木头,你可以放东西的地方。在软件工程中,当讨论“工厂”时也会发生同样的事情。在面向对象编程语言的背景下,我们立刻将“工厂”与产生其他对象的对象联系起来。因为我们立刻知道模式背后的解决方案,我们可以继续解决尚未解决的问题。
模式语言还具有其他特征。例如,模式是相互关联的,可以重叠,从而覆盖大部分问题空间。另外,正如原著《模式语言》中所阐述的,模式具有不同的粒度和范围。更通用的模式涵盖广泛的问题空间,并提供解决问题的粗略指导。细粒度的模式提供非常具体的解决方案建议,但适用范围较窄。本书包含各种模式,许多模式相互引用,甚至可能包含其他模式作为解决方案的一部分。
模式的另一个特征是它们遵循严格的格式。然而,每个作者定义不同的形式;不幸的是,并没有关于如何布置模式的共同标准。Martin Fowler 在《编写软件模式》中对模式语言使用的格式给出了很好的概述。
本书结构
我们选择了本书的简单模式格式。我们没有遵循任何特定的模式描述语言。对于每个模式,我们使用以下结构:
名称
每个模式都有一个名称,也是章节的标题。名称是模式语言的核心。
问题
本节提供了更广泛的背景和详细描述模式空间。
解决方案
本节展示了模式如何以 Kubernetes 特定方式解决问题。本节还包含了与其他相关模式的交叉引用,这些模式或者相关,或者作为给定模式的一部分。
讨论
本节包括关于给定上下文中解决方案优缺点的讨论。
更多信息
这一最终部分包含了与模式相关的其他信息来源。
我们按以下方式组织了本书中的模式:
-
第一部分,“基础模式”,涵盖了 Kubernetes 的核心概念。这些是构建基于容器的云原生应用程序的基本原则和实践。
-
第二部分,“行为模式”,描述了建立在基础模式之上的模式,并添加了管理各种类型容器的运行时概念。
-
第三部分,“结构模式”,包含与在 Kubernetes 平台的原子 Pod 中组织容器相关的模式。
-
第四部分,“配置模式”,深入探讨了在 Kubernetes 中处理应用程序配置的各种方式。这些是细粒度的模式,包括了连接应用程序到其配置的具体方法。
-
第五部分,“安全模式”,讨论了将应用程序容器化并部署在 Kubernetes 上时出现的各种安全问题。
-
第六部分,“高级模式”,包含高级概念的集合,例如平台本身如何扩展或如何在集群内直接构建容器镜像。
根据上下文的不同,相同的模式可能适用于多个类别。每个模式章节都是独立完整的;您可以单独阅读章节,且顺序不限。
本书适合读者:
本书面向希望设计和开发云原生应用,并将 Kubernetes 作为平台的开发者。对于那些对容器和 Kubernetes 概念有基本了解,并希望深入学习的读者来说,本书最为适合。然而,要理解用例和模式,并不需要了解 Kubernetes 的低级细节。架构师、顾问和其他技术人员也会从这里描述的可重复使用模式中受益。
本书基于真实项目的用例和经验教训。它是多年工作积累的最佳实践和模式的结晶。我们希望帮助您理解以 Kubernetes 为先的思维方式,并创建更好的云原生应用,而不是重复造轮子。它以轻松的风格撰写,类似于一系列可独立阅读的文章。
让我们简要看看本书不包括的内容:
-
本书不是 Kubernetes 的介绍,也不是参考手册。我们涉及许多 Kubernetes 特性,并对其进行一些详细解释,但我们专注于这些特性背后的概念。第一章,“介绍”,简要回顾 Kubernetes 基础知识。如果您正在寻找一本全面的 Kubernetes 书籍,我们强烈推荐由 Marko Lukša(Manning Publications)撰写的 Kubernetes in Action。
-
本书不是关于如何逐步设置 Kubernetes 集群的指南。每个示例假设您已经搭建好 Kubernetes。您可以尝试多种示例。如果您有兴趣学习如何设置 Kubernetes 集群,我们推荐阅读Kubernetes: Up and Running,由 Brendan Burns、Joe Beda、Kelsey Hightower 和 Lachlan Evenson(O’Reilly)共同撰写。
-
本书不涉及为其他团队操作和管理 Kubernetes 集群。我们故意跳过 Kubernetes 的管理和运维方面,并从开发者的视角深入探讨 Kubernetes。本书可以帮助运维团队了解开发者如何使用 Kubernetes,但不足以进行 Kubernetes 集群的管理和自动化。如果您有兴趣学习如何操作 Kubernetes 集群,我们推荐阅读Kubernetes 最佳实践,由 Brendan Burns、Eddie Villalba、Dave Strebel 和 Lachlan Evenson(O’Reilly)共同撰写。
您将学到什么
这本书中有很多发现。一些模式乍一看可能像是 Kubernetes 手册的摘录,但仔细观察后,你会发现这些模式是从概念角度呈现的,这在其他相关主题的书籍中找不到。其他模式则通过详细的步骤解释如何解决具体问题,就像第四部分,“配置模式”中的情况一样。在一些章节中,我们解释了 Kubernetes 的一些特性,这些特性不太适合作为模式定义的一部分。不要过分纠结它是一个模式还是一个特性。在所有章节中,我们从第一原理出发看待所涉及的力量,并专注于用例、经验教训和最佳实践。这才是其中的有价值的部分。
无论模式的粒度如何,你都将学习每个特定模式所提供的 Kubernetes 的全部内容,同时提供大量例子来说明概念。所有这些例子都经过测试,我们告诉你如何获取完整的源代码在“使用代码示例”中。
第二版中的新内容
自四年前第一版问世以来,Kubernetes 生态系统一直在持续增长。因此,已经发布了许多 Kubernetes 版本,并且用于使用 Kubernetes 的工具和模式已经成为事实上的标准。
幸运的是,我们书中描述的大部分模式经受住了时间的考验并仍然有效。因此,我们更新了这些模式,增加了适用于 Kubernetes 1.26 版本的新功能,并移除了过时和废弃的部分。大部分情况下,只需要进行小的改动,除了第二十九章,“弹性扩展”,以及第三十章,“镜像构建器”,由于这些领域的新发展,它们经历了重大变化。
另外,我们还包含了五种新的模式,并引入了一个新的类别,第五部分,“安全模式”,这填补了第一版的空白,并为开发人员提供了重要的安全相关模式。
我们的 GitHub 示例 已经更新并扩展了。最后,我们为读者增加了 50%的内容供他们享受。
本书中使用的约定
本书中使用了以下排版约定:
斜体
指示新术语、URL、电子邮件地址、文件名和文件扩展名。
固定宽度
用于程序清单,以及段落中引用程序元素,如变量或函数名、数据库、数据类型、环境变量、语句和关键字。
正如前文所述,模式形成了一个简单而相互连接的语言。为了强调这种模式的网络,每个模式都是大写并设置为斜体(例如,Sidecar)。当一个模式名称同时也是 Kubernetes 的核心概念(比如 Init Container 或 Controller)时,我们仅在直接引用模式本身时使用这种特定格式。在有意义的情况下,我们还会将模式章节进行相互链接以便于导航。
我们还使用以下约定:
-
你在 Shell 或编辑器中输入的所有内容都将以
constant width font字体呈现。 -
Kubernetes 资源名称总是以大写字母显示(例如,Pod)。如果资源是像 ConfigMap 这样的组合名称,则保留其原样,以清楚表明它指的是 Kubernetes 概念,而不是更自然的“config map”。
-
有时,Kubernetes 资源名称与“service”或“node”等常见概念完全相同。在这些情况下,我们仅在引用资源本身时使用资源名称格式。
提示
这个元素表示一个提示或建议。
注意
这个元素表示一般性说明。
警告
这个元素表示一个警告或注意事项。
使用代码示例
每个模式都配备了完全可执行的示例,你可以在附带的网页中找到它们。你可以在每章的“更多信息”部分找到每个模式示例的链接。
“更多信息”部分还包含与模式相关的进一步信息的链接。我们会在示例存储库中更新这些列表。
本书中所有示例代码的源代码都可以在GitHub上找到。存储库和网站还提供指针和说明,说明如何获取一个 Kubernetes 集群来尝试这些示例。请查看提供的资源文件,它们包含许多有价值的注释,可以进一步帮助理解示例代码。
许多示例使用一个名为random-generator的 REST 服务,在调用时返回随机数。它是专门为本书的示例设计的。你可以在GitHub找到其源代码,其容器镜像k8spatterns/random-generator托管在Docker Hub上。
我们使用 JSON 路径表示法来描述资源字段(例如,.spec.replicas指向资源的spec部分的replicas字段)。
如果你在示例代码或文档中发现问题,或者有问题,请不要犹豫在GitHub 问题跟踪器上开启一个工单。我们会监视这些 GitHub 问题,并乐意回答任何问题。
所有示例代码都按照知识共享署名 4.0 国际许可证(CC BY 4.0)进行分发。该代码可供商业和非商业项目使用、分享和适应。但是,如果您复制或重新分发示例代码,应该将归属归还给本书。
此归属可以是对书籍的引用,包括书名、作者、出版社和 ISBN,例如“Kubernetes Patterns,第 2 版,作者 Bilgin Ibryam 和 Roland Huß(O’Reilly)。版权所有 2023 年 Bilgin Ibryam 和 Roland Huß,978-1-098-13168-5。” 或者,附上一个链接到相关网站,并加上版权声明和许可证链接。
我们也欢迎代码贡献!如果您认为我们可以改进我们的示例,我们很乐意听取您的意见。只需打开一个 GitHub 问题或创建一个拉取请求,让我们开始交流。
O’Reilly 在线学习
注意
近 40 年来,O’Reilly Media为企业提供技术和商业培训、知识和见解,帮助公司取得成功。
我们独特的专家和创新者网络通过图书、文章、会议和我们的在线学习平台分享他们的知识和专长。O’Reilly 的在线学习平台为您提供按需访问实时培训课程、深入学习路径、交互式编码环境以及来自 O’Reilly 和其他 200 多家出版商的大量文本和视频。有关更多信息,请访问http://oreilly.com。
如何联系我们
请将有关本书的评论和问题发送至出版商:
-
O’Reilly Media, Inc.
-
1005 Gravenstein Highway North
-
加利福尼亚州塞巴斯托波尔 95472
-
800-998-9938(美国或加拿大)
-
707-829-0515(国际或本地)
-
707-829-0104(传真)
我们为这本书创建了一个网页,列出勘误、示例和其他信息。您可以访问此页面https://oreil.ly/kubernetes_patterns-2e。
发送电子邮件至bookquestions@oreilly.com以评论或询问有关本书的技术问题。
有关我们的图书和课程的新闻和信息,请访问https://oreilly.com。
在 LinkedIn 上找到我们:https://linkedin.com/company/oreilly-media
在 Twitter 上关注我们:https://twitter.com/oreillymedia
在 YouTube 上观看我们:https://youtube.com/oreillymedia
在 Twitter 上关注作者:https://twitter.com/bibryam,https://twitter.com/ro14nd
在 Mastodon 上关注作者:https://fosstodon.org/@bilgin,https://hachyderm.io/@ro14nd
在 GitHub 上找到作者:https://github.com/bibryam,https://github.com/rhuss
关注他们的博客:https://www.ofbizian.com,https://ro14nd.de
致谢
Bilgin 对他美妙的妻子 Ayshe 永远感激,感谢她在他又一本书的创作过程中给予的无尽支持和耐心。他也感谢他可爱的女儿 Selin 和 Esin,她们总是知道如何让他笑容满面。你们对他来说意义非凡。最后,Bilgin 要感谢他的梦幻般的合著者 Roland,使这个项目成为现实。
Roland 深深感谢妻子 Tanja 在写作过程中始终如一的支持和包容,他还感谢儿子 Jakob 的鼓励。此外,Roland 希望特别感谢 Bilgin,因为他出色的见解和写作让这本书得以完成。
创建这本书的两个版本是一段漫长的多年旅程,我们要感谢那些在这条正确道路上一直陪伴我们的审阅者。
对于第一版,特别感谢 Paolo Antinori 和 Andrea Tarocchi 在整个旅程中对我们的帮助。非常感谢 Marko Lukša、Brandon Philips、Michael Hüttermann、Brian Gracely、Andrew Block、Jiri Kremser、Tobias Schneck 和 Rick Wagner,他们用他们的专业知识和建议支持了我们。最后但同样重要的是,要感谢我们的编辑 Virginia Wilson、John Devins、Katherine Tozer、Christina Edwards 以及 O’Reilly 的所有出色人士,他们帮助我们把这本书完成到最后。
完成第二版绝非易事,我们感谢所有在完成中支持我们的人。我们要特别感谢我们的技术审阅者 Ali Ok、Dávid Šimanský、Zbyněk Roubalík、Erkan Yanar、Christoph Stäbler、Andrew Block 和 Adam Kaplan,以及我们的开发编辑 Rita Fernando,在整个过程中她们的耐心和鼓励。我们还要向 O’Reilly 的生产团队表示赞赏,特别是 Beth Kelly、Kim Sandoval 和 Judith McConville,在完成书稿时他们的细致关注。
我们要特别感谢 Abhishek Koserwal 在《第二十六章,“访问控制”》(ch26.html#AccessControl)中不懈而专注的努力。他的贡献正是我们最需要的时候,并产生了深远影响。
^(1) Alexander 和他的团队在建筑学的原始含义中定义如下:“每个模式描述了在我们的环境中反复出现的问题,然后描述了解决该问题的核心方法,使得您可以无数次地使用这个解决方案,而不必完全相同地再做一次。”(A Pattern Language,Christopher Alexander 等人,1977 年。)
第一章:介绍
在本书的这一介绍性章节中,我们通过解释用于设计和实现云原生应用的一些核心 Kubernetes 概念,为接下来的内容设定了背景。理解这些新的抽象以及来自本书的相关原则和模式对于构建可以由 Kubernetes 自动化操作的分布式应用至关重要。
本章不是理解后续描述的模式的先决条件。熟悉 Kubernetes 概念的读者可以跳过本章,直接进入感兴趣的模式类别。
通往云原生的路径
微服务是创建云原生应用程序的最流行的架构风格之一。它们通过业务能力的模块化来解决软件复杂性,并通过操作复杂性交换开发复杂性。这就是为什么成功使用微服务的关键先决条件是创建可以通过 Kubernetes 进行规模化操作的应用程序。
作为微服务运动的一部分,有大量关于从头开始创建微服务或将单体应用拆分为微服务的理论、技术和补充工具。这些实践大多基于领域驱动设计(Eric Evans 著,Addison-Wesley 出版)以及有界上下文和聚合的概念。有界上下文通过将大型模型划分为不同组件来处理大模型,而聚合则有助于将有界上下文进一步分组为具有定义事务边界的模块。然而,除了这些业务域考虑因素外,对于每个分布式系统——无论它是否基于微服务——还存在其外部结构和运行时耦合的技术考虑。容器和容器编排器(如 Kubernetes)引入了新的原语和抽象,以解决分布式应用的关注点,在此我们讨论了在将分布式系统部署到 Kubernetes 中时需要考虑的各种选项。
在本书中,我们通过将容器视为黑盒子来审视容器和平台的交互。然而,我们创建了这一节来强调放入容器的内容的重要性。容器和云原生平台为您的分布式应用程序带来了巨大的好处,但如果您将垃圾放入容器中,您将会在规模上获得分布式垃圾。图 1-1 展示了创建优秀云原生应用所需的技能组合及 Kubernetes 模式的适用位置。

图 1-1. 通往云原生的路径
从高层次上看,创建优秀的云原生应用程序需要熟悉多种设计技术:
-
在最低的代码层面,你定义的每个变量、创建的每个方法以及决定实例化的每个类,在长期维护应用程序中都起着作用。无论你使用何种容器技术和编排平台,开发团队及其创建的工件将产生最大的影响。培养努力编写清晰代码、具备适量自动化测试、持续重构以提高代码质量,并以软件工艺精神为指导原则的开发人员非常重要。
-
领域驱动设计是从业务角度来看待软件设计的方法,旨在尽可能保持架构与现实世界的接近。这种方法最适合面向对象的编程语言,但也有其他良好的方式来为实际问题建模和设计软件。一个具有正确业务和交易边界、易于消费的接口和丰富 API 的模型,是未来成功容器化和自动化的基础。
-
六边形架构及其变体,如洋葱和清洁架构,通过解耦应用程序组件并为其交互提供标准化接口,提高了应用程序的灵活性和可维护性。通过将系统的核心业务逻辑与周围基础设施解耦,六边形架构使得将系统移植到不同环境或平台更加容易。这些架构与领域驱动设计相辅相成,并帮助将应用程序代码组织成具有明确边界和外部化基础设施依赖的结构。
-
微服务架构风格和十二要素应用方法迅速发展成为创建分布式应用程序的标准,并提供了有价值的设计原则和实践。应用这些原则可以创建出针对规模、弹性和变化速度优化的实现,这些是今天任何现代软件的常见要求。
-
容器迅速成为打包和运行分布式应用程序的标准方式,无论是微服务还是函数。创建模块化、可重用的容器,这些容器在云原生环境中表现良好,是另一个基本前提。云原生是一个术语,用于描述自动化容器化应用程序的原则、模式和工具。我们将云原生与Kubernetes交替使用,后者是当今最流行的开源云原生平台。
在本书中,我们不涵盖干净的代码、领域驱动设计、六边形架构或微服务。我们仅专注于解决容器编排关注的模式和实践。但是,为了这些模式能够有效,你的应用程序需要从内部使用干净的代码实践、领域驱动设计、六边形架构等方法,像隔离外部依赖和微服务原则等其他相关设计技术。
分布式原语
为了解释我们所说的新抽象和原语,我们在这里将它们与众所周知的面向对象编程(OOP),特别是 Java 进行比较。在 OOP 的宇宙中,我们有诸如类、对象、包、继承、封装和多态等概念。然后 Java 运行时提供了特定的功能和保证,来管理我们的对象及整个应用的生命周期。
Java 语言和 Java 虚拟机(JVM)提供了本地、进程内的构建块来创建应用程序。Kubernetes 通过提供一套新的分布式原语和运行时,为跨多个节点和进程扩展的分布式系统建设增添了全新的维度。有了 Kubernetes 的支持,我们不仅依赖于本地原语来实现整个应用行为。
我们仍然需要使用面向对象的构建模块来创建分布式应用的组件,但我们也可以使用 Kubernetes 原语来处理某些应用行为。Table 1-1 显示了在 JVM 和 Kubernetes 中,本地和分布式原语如何以不同方式实现各种开发概念。
表 1-1. 本地和分布式原语
| 概念 | 本地原语 | 分布式原语 |
|---|---|---|
| 行为封装 | 类 | 容器镜像 |
| 行为实例 | 对象 | 容器 |
| 可重用单元 | .jar | 容器镜像 |
| 组合 | 类 A 包含类 B | Sidecar 模式 |
| 继承 | 类 A 继承自类 B | 容器的 FROM 父镜像 |
| 部署单元 | .jar/.war/.ear | Pod |
| 构建时/运行时隔离 | 模块,包,类 | 命名空间,Pod,容器 |
| 初始化前提条件 | 构造函数 | Init 容器 |
| 后初始化触发器 | Init-method | postStart |
| 预销毁触发器 | Destroy-method | preStop |
| 清理过程 | finalize(), shutdown hook |
- |
| 异步和并行执行 | ThreadPoolExecutor, ForkJoinPool |
Job |
| 定期任务 | Timer, ScheduledExecutorService |
CronJob |
| 后台任务 | 守护线程 | DaemonSet |
| 配置管理 | System.getenv(), Properties |
ConfigMap, Secret |
进程内原语和分布式原语有共同点,但不能直接比较和替换。它们在不同的抽象级别上运行,并具有不同的前提条件和保证。有些原语应该一起使用。例如,我们仍然必须使用类来创建对象并将它们放入容器镜像中。但是,一些其他原语如 Kubernetes 中的 CronJob 可以完全替代 Java 中的ExecutorService行为。
接下来,让我们看看一些 Kubernetes 中特别适合应用开发者的分布式抽象和原语。
容器
容器是基于 Kubernetes 的云原生应用的构建模块。如果我们将其与 OOP 和 Java 进行比较,容器镜像就像类,而容器则像对象。就像我们可以扩展类以重用和改变行为一样,我们可以有扩展其他容器镜像以重用和改变行为的容器镜像。同样,我们可以进行对象组合并使用功能,我们可以通过将容器放入 Pod 并使用协作容器来进行容器组合。
如果我们继续比较,Kubernetes 就像是分布在多个主机上的 JVM,并且负责运行和管理容器。Init 容器有点类似于对象构造函数;DaemonSet 则类似于在后台运行的守护线程(例如 Java 垃圾收集器)。一个 Pod 类似于一个控制反转(IoC)上下文(例如 Spring 框架),在这里多个运行中的对象共享管理的生命周期并可以直接访问彼此。
这种类比不能太深入,但关键是容器在 Kubernetes 中扮演着基础角色,创建模块化、可重用、单一目的的容器镜像对于任何项目的长期成功,甚至容器生态系统作为整体都是至关重要的。除了提供打包和隔离的技术特性外,容器镜像在分布式应用程序中代表什么,以及其在其中的作用是什么?以下是如何看待容器的几点建议:
-
容器镜像是解决单一关注点的功能单元。
-
容器镜像由一个团队拥有并有其自己的发布周期。
-
容器镜像是自包含的,定义并携带其运行时依赖项。
-
容器镜像是不可变的,一旦构建完成,就不会更改;它是经过配置的。
-
容器镜像定义其资源需求和外部依赖项。
-
容器镜像具有明确定义的 API 以公开其功能。
-
一个容器通常作为单个 Unix 进程运行。
-
容器是可丢弃的,可以随时进行扩展或缩减。
除了所有这些特性之外,一个合适的容器镜像是模块化的。它是参数化的,并且为在不同环境中运行而重用而创建。拥有小型、模块化和可重用的容器镜像会在长期内创建更专业化和稳定的容器镜像,类似于编程语言世界中优秀的可重用库。
Pod
观察容器的特性,我们可以看到它们非常适合实现微服务原则。一个容器镜像提供了一个功能单元,归属于一个团队,有独立的发布周期,并提供部署和运行时隔离。大多数情况下,一个微服务对应一个容器镜像。
然而,大多数云原生平台提供了另一种原语来管理一组容器的生命周期——在 Kubernetes 中称为 Pod。Pod 是一组容器的调度、部署和运行时隔离的原子单位。Pod 中的所有容器总是被调度到同一台主机上,一起部署和扩展,并且还可以共享文件系统、网络和进程命名空间。这种联合生命周期允许 Pod 中的容器通过文件系统或通过本地主机或宿主机进程通信机制(如果需要,例如出于性能原因)相互交互。对于一个应用程序来说,Pod 也代表了一个安全边界。虽然可能在同一个 Pod 中有具有不同安全参数的容器,但通常所有容器都具有相同的访问级别、网络分割和身份。
正如您在 图 1-2 中看到的,在开发和构建时,一个微服务对应于一个团队开发和发布的容器镜像。但在运行时,一个微服务由 Pod 表示,它是部署、放置和扩展的单位。运行容器的唯一方式——无论是为了扩展还是迁移——都是通过 Pod 抽象。有时一个 Pod 包含多个容器。在这样的例子中,一个容器化的微服务在运行时使用辅助容器,正如 第十六章,“Sidecar” 中所示。

图 1-2. Pod 作为部署和管理单元
容器、Pod 及其独特特性为设计基于微服务的应用程序提供了一组新的模式和原则。我们看到了设计良好的容器的一些特性;现在让我们来看看 Pod 的一些特性:
-
Pod 是调度的原子单位。这意味着调度器试图找到一个满足 Pod 所有容器需求的主机(我们在第十五章,“初始化容器”中详细讨论了一些关于初始化容器的具体内容)。如果创建一个具有多个容器的 Pod,调度器需要找到一个具有足够资源来满足所有容器需求的主机。这个调度过程在第六章,“自动化部署”中描述。
-
一个 Pod 确保容器的共存。由于共存,同一 Pod 中的容器有额外的方式进行互操作。最常见的通信方式包括使用共享的本地文件系统交换数据,使用本地主机网络接口,或使用一些主机进程间通信(IPC)机制进行高性能互动。
-
一个 Pod 具有 IP 地址、名称和端口范围,所有属于它的容器共享这些信息。这意味着同一 Pod 中的容器必须仔细配置,以避免端口冲突,就像并行运行的 Unix 进程在共享主机的网络空间时需要小心一样。
Pod 是 Kubernetes 的原子单位,您的应用程序驻留在其中,但您不直接访问 Pod —— 这就是服务进入场景的地方。
服务
Pod 是短暂的。它们会因各种原因随时出现和消失(例如,扩展和缩减,失败的容器健康检查,节点迁移)。只有在调度和在节点上启动后,Pod 的 IP 地址才能被知晓。如果运行 Pod 的节点不再健康,Pod 可以被重新调度到另一个节点。这意味着应用程序的生命周期中可能会改变 Pod 的网络地址,需要另一个基元进行发现和负载均衡。
这就是 Kubernetes 服务发挥作用的地方。服务是 Kubernetes 的另一个简单但强大的抽象,将服务名称永久绑定到 IP 地址和端口号。因此,服务表示访问应用程序的命名入口点。在最常见的情况下,服务作为一组 Pod 的入口点,但并非总是如此。服务是一个通用的基元,也可以指向 Kubernetes 集群外提供的功能。因此,服务基元可用于服务发现和负载均衡,允许在不影响服务使用者的情况下更改实现和扩展。我们在第十三章,“服务发现”中详细解释了服务。
标签
我们已经看到,微服务是一个在构建时是容器镜像,但在运行时由 Pod 表示的概念。那么由多个微服务组成的应用程序是什么呢?在这里,Kubernetes 提供了另外两个基元,可以帮助您定义应用程序的概念:标签和命名空间。
在微服务出现之前,一个应用程序对应于一个单一的部署单元,有一个单一的版本控制方案和发布周期。应用程序在 .war、.ear 或其他某种打包格式中有一个单一的文件。但后来,应用程序被拆分为微服务,这些微服务可以独立开发、发布、运行、重启或扩展。在微服务中,应用程序的概念减弱了,没有关键的工件或在应用程序级别执行的活动。但是,如果你仍然需要一种方法来指示一些独立服务属于一个应用程序,标签 可以被使用。让我们想象一下,我们已经将一个单体应用程序拆分为三个微服务,另一个拆分为两个微服务。
现在我们有五个 Pod 定义(可能还有许多 Pod 实例),从开发和运行时的角度来看是独立的。然而,我们可能仍然需要指示前三个 Pod 代表一个应用程序,而另外两个 Pod 代表另一个应用程序。即使 Pod 可能是独立的,为了提供业务价值,它们可能彼此依赖。例如,一个 Pod 可能包含负责前端的容器,另外两个 Pod 则负责提供后端功能。如果其中任何一个 Pod 停止运行,从业务角度来看,应用程序就变得无用了。使用标签选择器使我们能够查询和识别一组 Pod,并将其作为一个逻辑单元进行管理。图 1-3 展示了如何使用标签将分布式应用程序的各部分分组成特定的子系统。

图 1-3. 作为 Pod 应用标识的标签
以下是标签可能有用的几个示例:
-
ReplicaSets 使用标签来保持特定 Pod 的一些实例在运行。这意味着每个 Pod 定义都需要一组唯一的标签组合用于调度。
-
标签也被调度器广泛使用。调度器使用标签来将 Pod 放置在满足 Pod 要求的节点上,以实现共存或扩展。
-
一个标签可以指示一组 Pod 的逻辑分组,并为它们提供一个应用程序标识。
-
除了上述典型用例外,标签还可用于存储元数据。很难预测标签可能用于什么,但最好有足够的标签来描述 Pod 的所有重要方面。例如,有标签用于指示应用程序的逻辑组、业务特性和关键性,特定的运行时平台依赖如硬件架构或位置偏好都是有用的。
后来,这些标签可以由调度程序用于更精细的调度,或者可以从命令行使用相同的标签来管理规模化的匹配 Pod。但是,不要过度添加太多标签。如果需要,您可以随时稍后添加它们。删除标签更为风险,因为没有直接的方法来找出标签用于何处,以及这样的操作可能会导致什么意外效果。
命名空间
另一个可以帮助管理一组资源的原始 Kubernetes命名空间。正如我们所描述的,命名空间可能看起来类似于标签,但实际上,它是一个具有不同特性和目的的非常不同的原语。
Kubernetes 命名空间允许您将一个 Kubernetes 集群(通常跨多个主机)分成资源的逻辑池。命名空间为 Kubernetes 资源提供作用域,并提供了在集群子集中应用授权和其他策略的机制。命名空间最常见的用例是表示不同的软件环境,如开发、测试、集成测试或生产环境。命名空间还可以用于实现多租户,并为团队工作空间、项目甚至特定应用程序提供隔离。但是,对于某些环境的更大隔离,命名空间是不够的,通常会使用单独的集群。通常情况下,有一个非生产 Kubernetes 集群用于某些环境(开发、测试和集成测试),另一个生产 Kubernetes 集群用于性能测试和生产环境。
让我们看看命名空间的一些特性以及它们如何在不同场景中帮助我们:
-
命名空间作为一个 Kubernetes 资源进行管理。
-
命名空间为诸如容器、Pod、服务或副本集等资源提供作用域。资源的名称在命名空间内必须是唯一的,但在命名空间之间不必如此。
-
默认情况下,命名空间为资源提供作用域,但没有任何机制来隔离这些资源,防止一个资源访问另一个资源。例如,来自开发命名空间的 Pod 可以访问生产命名空间的另一个 Pod,只要知道 Pod 的 IP 地址。“通过创建轻量级多租户解决方案的命名空间跨命名空间的网络隔离在第二十四章,“网络分段”中有描述。
-
其他一些资源,如命名空间、节点和持久卷,不属于命名空间,应具有唯一的整个集群范围名称。
-
每个 Kubernetes 服务都属于一个命名空间,并且会获得相应的域名服务(DNS)记录,其中命名空间以
<service-name>.<namespace-name>.svc.cluster.local的形式存在于每个属于给定命名空间的服务的 URL 中。这就是命名空间在每个服务的 URL 中至关重要的原因。 -
ResourceQuotas 提供了限制每个命名空间的聚合资源消耗的约束。通过 ResourceQuotas,集群管理员可以控制命名空间中允许的每种类型对象的数量。例如,开发者命名空间可能只允许五个 ConfigMaps、五个 Secrets、五个 Services、五个 ReplicaSets、五个 PersistentVolumeClaims 和十个 Pods。
-
ResourceQuotas 还可以限制我们在给定命名空间中可以请求的计算资源总和。例如,在容量为 32 GB RAM 和 16 个核心的集群中,可以为生产命名空间分配 16 GB RAM 和 8 个核心,为暂存环境分配 8 GB RAM 和 4 个核心,为开发分配 4 GB RAM 和 2 个核心,测试命名空间也是相同。脱离底层基础设施的形状和限制施加资源约束的能力是无价的。
讨论
我们只简要介绍了本书中使用的一些主要 Kubernetes 概念。然而,开发人员日常使用的基元还有更多。例如,如果您创建一个容器化服务,可以使用大量 Kubernetes 抽象来获取 Kubernetes 的所有优势。请记住,这些只是应用开发人员用于将容器化服务集成到 Kubernetes 中的少数对象。还有许多其他概念,主要由集群管理员用于管理 Kubernetes。图 1-4 概述了对开发者有用的主要 Kubernetes 资源。

图 1-4. 开发者的 Kubernetes 概念
随着时间的推移,这些新的基元产生了解决问题的新方法,其中一些重复的解决方案变成了模式。在本书中,我们不会详细描述每个 Kubernetes 资源,而是专注于作为模式证明的概念。
更多信息
第一部分:基础模式
基础模式 描述了容器化应用必须遵守的几个基本原则,以成为良好的云原生应用。遵循这些原则将有助于确保您的应用程序适合在 Kubernetes 等云原生平台上进行自动化操作。
下列章节中描述的模式代表了分布式容器化 Kubernetes 原生应用的基础构建模块:
-
第二章,“可预测需求”,解释了为什么每个容器都应声明其资源需求,并保持在指定的资源边界内。
-
第三章,“声明式部署”,描述了可以以声明方式表达的不同应用部署策略。
-
第四章,“健康探针”,规定每个容器都应实现特定的 API,以帮助平台观察和维护应用程序的健康状态。
-
第五章,“管理生命周期”,解释了为什么容器应该有一种方式读取来自平台的事件,并通过响应这些事件来符合标准。
-
第六章,“自动化放置”,介绍了 Kubernetes 调度算法及如何从外部影响放置决策的方式。
第二章:可预测需求
在共享云环境中成功部署、管理和共存应用程序的基础取决于识别和声明应用程序的资源需求和运行时依赖。这种可预测需求模式表明了您应如何声明应用程序的需求,无论是硬运行时依赖还是资源需求。声明您的需求对于 Kubernetes 在集群中找到适当位置以运行您的应用程序至关重要。
问题
Kubernetes 可以管理用不同编程语言编写的应用程序,只要这些应用程序可以在容器中运行。然而,不同语言具有不同的资源需求。通常,编译语言运行速度更快,通常比即时运行时或解释语言需要更少的内存。考虑到同一类别中许多现代编程语言具有类似的资源需求,从资源消耗的角度来看,更重要的方面是应用程序的领域、业务逻辑和实际实现细节。
除了资源需求外,应用程序运行时还依赖于平台管理的能力,如数据存储或应用程序配置。
解决方案
了解容器的运行时需求主要有两个重要原因。首先,通过定义所有运行时依赖和资源需求,Kubernetes 可以智能地决定在集群中何处放置容器,以实现最高效的硬件利用率。在具有不同优先级的大量进程共享资源的环境中,确保成功共存的唯一方法是提前了解每个进程的需求。然而,智能放置只是其中一方面。
容器资源配置文件对于容量规划也是至关重要的。根据特定服务的需求和服务的总数,我们可以为不同的环境做一些容量规划,并提出最具成本效益的主机配置文件,以满足整个集群的需求。服务资源配置文件和容量规划手段长期成功管理集群密不可分。
在深入了解资源配置文件之前,让我们看看如何声明运行时依赖。
运行时依赖
最常见的运行时依赖之一是文件存储,用于保存应用程序状态。容器文件系统是临时的,在容器关闭时会丢失。Kubernetes 提供了卷作为 Pod 级别的存储工具,可在容器重新启动时保留数据。
最直接的卷类型是 emptyDir,它与 Pod 同存亡。当 Pod 被移除时,其内容也会丢失。该卷需要由另一种存储机制支持,以便在 Pod 重启时生存下来。如果您的应用程序需要读取或写入文件到这种长期存储中,必须在容器定义中明确声明这种依赖关系,如示例 2-1 所示。
示例 2-1. 对 PersistentVolume 的依赖
apiVersion: v1
kind: Pod
metadata:
name: random-generator
spec:
containers:
- image: k8spatterns/random-generator:1.0
name: random-generator
volumeMounts:
- mountPath: "/logs"
name: log-volume
volumes:
- name: log-volume
persistentVolumeClaim: 
claimName: random-generator-log
PersistentVolumeClaim (PVC) 的依赖要求其存在且已绑定。
调度器评估 Pod 需要的卷类型,这影响 Pod 放置的位置。如果 Pod 需要的卷在集群的任何节点上都没有提供,那么根本不会调度该 Pod。卷是运行时依赖关系的一个例子,它影响 Pod 可以运行在什么样的基础设施上,以及是否可以被调度。
当您要求 Kubernetes 通过 hostPort 在主机系统上的特定端口上公开容器端口时,会发生类似的依赖关系。使用 hostPort 在集群中的每个节点上保留端口,并且每个节点最多只能调度一个 Pod。由于端口冲突,您可以扩展到 Kubernetes 集群中的节点数量相同的 Pod。
配置是另一种依赖类型。几乎每个应用程序都需要一些配置信息,Kubernetes 提供的推荐解决方案是通过 ConfigMaps。您的服务需要一种消耗设置的策略——通过环境变量或文件系统。无论哪种情况,这都会将容器对命名 ConfigMaps 的命名引入运行时依赖。如果未创建所有预期的 ConfigMaps,容器会被调度到节点上,但不会启动。
与 ConfigMaps 类似,Secrets 提供了一种稍微更安全的方法来向容器分发特定于环境的配置。使用 Secret 的方式与 ConfigMaps 的方式相同,并且使用 Secret 从容器到命名空间引入了相同类型的依赖关系。
ConfigMaps 和 Secrets 在第二十章,“配置资源”中有更详细的解释,示例 2-2 展示了这些资源作为运行时依赖的使用方式。
示例 2-2. 对 ConfigMap 的依赖
apiVersion: v1
kind: Pod
metadata:
name: random-generator
spec:
containers:
- image: k8spatterns/random-generator:1.0
name: random-generator
env:
- name: PATTERN
valueFrom:
configMapKeyRef: 
name: random-generator-config
key: pattern
对 ConfigMap random-generator-config 的强制依赖。
虽然创建 ConfigMap 和 Secret 对象是我们必须执行的简单部署任务,但集群节点提供存储和端口号。其中一些依赖限制了 Pod 可以调度的位置(如果有的话),而其他依赖可能会阻止 Pod 启动。在设计带有这些依赖的容器化应用程序时,始终要考虑它们将在运行时创建的约束条件。
资源配置文件
指定诸如 ConfigMap、Secret 和卷等容器依赖关系非常简单。我们需要更多的思考和实验来确定容器的资源需求。在 Kubernetes 的上下文中,计算资源被定义为容器可以请求、分配和消耗的东西。这些资源被分类为可压缩(即可以被限制,如 CPU 或网络带宽)和不可压缩(即不能被限制,如内存)。
区分可压缩和不可压缩资源的区别非常重要。如果您的容器消耗了太多的可压缩资源,例如 CPU,它们将被限制,但如果它们使用了太多的不可压缩资源(如内存),它们将被终止(因为没有其他方法要求应用程序释放已分配的内存)。
根据您的应用程序的性质和实现细节,您必须指定所需的最小资源量(称为 requests)以及它可以增长到的最大量(limits)。每个容器定义可以指定它所需的 CPU 和内存量,形式为请求和限制。在高级别上,requests/limits 的概念类似于软限制/硬限制。例如,类似地,我们通过使用 -Xms 和 -Xmx 命令行选项为 Java 应用程序定义堆大小。
requests 量(但不包括 limits)在调度 Pod 到节点时由调度程序使用。对于给定的 Pod,调度程序仅考虑仍然具有足够容量来容纳 Pod 及其所有容器的节点,通过汇总请求的资源量。从这个意义上说,每个容器的 requests 字段影响 Pod 是否可以被调度。
Example 2-3. 资源限制
apiVersion: v1
kind: Pod
metadata:
name: random-generator
spec:
containers:
- image: k8spatterns/random-generator:1.0
name: random-generator
resources:
requests: 
cpu: 100m
memory: 200Mi
limits: 
memory: 200Mi
初始的 CPU 和内存资源请求。
直到我们希望我们的应用程序增长到最大的上限。我们故意不指定 CPU 的限制。
下面的资源类型可以作为 requests 和 limits 规范中的键使用:
memory
该类型用于应用程序的堆内存需求,包括配置为 medium: Memory 的 emptyDir 类型卷。内存资源是不可压缩的,因此超出配置的内存限制的容器将触发 Pod 被驱逐;即,它可能会被删除并且可能重新创建在另一个节点上。
cpu
cpu 类型用于指定应用程序所需的 CPU 循环范围。然而,它是一个可压缩资源,这意味着在节点超分配的情况下,所有运行容器的分配 CPU 槽位将按照其指定的请求进行限制。因此,强烈建议您为 CPU 资源设置 requests,但是不要设置 limits,这样它们可以从所有多余的 CPU 资源中受益,否则这些资源将被浪费。
ephemeral-storage
每个节点都有一些专门用于临时存储的文件系统空间,用于保存日志和可写容器层。未存储在内存文件系统中的 emptyDir 卷也会使用临时存储。使用此请求和限制类型,您可以指定应用程序的最小和最大需求。ephemeral-storage 资源是不可压缩的,如果 Pod 使用的存储空间超过其 limit 中指定的值,则会导致 Pod 从节点驱逐。
hugepage-<size>
Huge pages 是大的、连续预分配的内存页,可以作为卷挂载。根据您的 Kubernetes 节点配置,有多种大小的 huge pages 可供选择,如 2 MB 和 1 GB 的页。您可以指定请求和限制,以确定您想要消耗某种类型的 huge pages 的数量(例如,hugepages-1Gi: 2Gi 表示请求两个 1 GB 的 huge pages)。Huge pages 不能超分配,因此请求和限制必须相同。
根据您是否指定了 requests、limits 或两者,平台提供三种类型的服务质量(QoS):
Best-Effort
对于其容器未设置任何请求和限制的 Pod,其 QoS 是 Best-Effort。这样的 Best-Effort Pod 被认为是最低优先级的,当放置 Pod 的节点耗尽不可压缩资源时,很可能首先被杀死。
Burstable
对于 requests 和 limits 值不相等(并且如预期的那样,limits 大于 requests)的 Pod 被标记为 Burstable。这样的 Pod 具有最小的资源保证,但也愿意在可用时消耗更多资源直到其 limit。当节点处于不可压缩资源压力下时,这些 Pod 如果没有 Best-Effort Pod,则可能会被杀死。
Guaranteed
对于具有相等的 request 和 limit 资源的 Pod 属于 Guaranteed QoS 类别。这些是最高优先级的 Pod,在 Best-Effort 和 Burstable Pod 之前保证不会被杀死。对于您的应用程序的内存资源,这种 QoS 模式是最佳选择,因为它涉及最少的意外并避免由于内存不足而触发的驱逐。
因此,您为容器定义或省略的资源特性直接影响其 QoS,并定义 Pod 在资源匮乏情况下的相对重要性。考虑到这一后果,请定义您的 Pod 资源需求。
Pod 优先级
我们解释了容器资源声明如何定义 Pods 的 QoS 并在资源匮乏时影响 Kubelet 杀死 Pod 中的容器的顺序。另外两个相关概念是 Pod 优先级 和 抢占。Pod 优先级 允许您指示 Pod 相对于其他 Pods 的重要性,这影响 Pods 被调度的顺序。让我们在 示例 2-4 中看看它是如何运作的。
示例 2-4. Pod 优先级
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
name: high-priority 
value: 1000 
globalDefault: false 
description: This is a very high-priority Pod class
---
apiVersion: v1
kind: Pod
metadata:
name: random-generator
labels:
env: random-generator
spec:
containers:
- image: k8spatterns/random-generator:1.0
name: random-generator
priorityClassName: high-priority 
优先级类对象的名称。
对象的优先级值。
globalDefault 设为 true 用于未指定 priorityClassName 的 Pods。只能有一个 PriorityClass 的 globalDefault 设为 true。
用于此 Pod 的优先级类,如在 PriorityClass 资源中定义。
我们创建了一个 PriorityClass,这是一个非命名空间对象,用于定义基于整数的优先级。我们的 PriorityClass 名为 high-priority,优先级为 1,000。现在我们可以通过其名称将此优先级分配给 Pods,例如 priorityClassName: high-priority。PriorityClass 是一种指示 Pods 相对重要性的机制,其中更高的值表示更重要的 Pods。
Pod 优先级影响调度程序在节点上放置 Pods 的顺序。首先,优先级准入控制器使用 priorityClassName 字段为新 Pods 填充优先级值。当多个 Pods 等待放置时,调度程序首先按最高优先级对待处理 Pods 排序。任何等待处理的 Pod 都会在排队中的任何其他优先级较低的 Pod 之前被选中,并且如果没有阻止其调度的约束条件,则会被调度。
现在来看关键部分。如果没有节点具有足够的容量来放置一个 Pod,则调度程序可以抢占(移除)节点上的优先级较低的 Pods,以释放资源并放置优先级较高的 Pods。因此,如果满足所有其他调度要求,则具有更高优先级的 Pod 可能比具有较低优先级的 Pods 更早地被调度。该算法有效地使集群管理员能够通过允许调度程序驱逐优先级较低的 Pods 为更关键的工作负载腾出空间,并首先放置它们。如果无法调度一个 Pod,则调度程序继续安排其他优先级较低的 Pods。
假设您希望您的 Pod 以特定优先级调度,但不想驱逐任何现有的 Pods。在这种情况下,您可以将 PriorityClass 标记为字段 preemptionPolicy: Never。分配到此优先级类别的 Pods 将不会触发任何正在运行的 Pods 的驱逐,但仍会根据其优先级值进行调度。
Pod QoS(前文讨论过)和 Pod 优先级是两个独立的特性,它们没有连接并且只有少量重叠。QoS 主要由 Kubelet 在可用计算资源不足时用于保护节点稳定性。Kubelet 在驱逐之前首先考虑 QoS,然后再考虑 Pods 的 PriorityClass。另一方面,调度程序的驱逐逻辑在选择抢占目标时完全忽略 Pods 的 QoS。调度程序尝试选择一组具有最低优先级的 Pods,以满足等待放置的更高优先级 Pods 的需求。
当 Pod 指定了优先级时,可能会对被驱逐的其他 Pod 产生不良影响。例如,尽管尊重了 Pod 的优雅终止策略,但如第十章,“单例服务”所述的 PodDisruptionBudget 并不受保证,这可能会破坏依赖于一组 Pod 团队的较低优先级集群应用。
另一个问题是恶意或不知情的用户创建具有最高优先级的 Pods 并驱逐所有其他 Pods。为防止这种情况发生,ResourceQuota 已扩展以支持 PriorityClass,并且较高优先级的数字保留用于不应通常被抢占或驱逐的关键系统 Pods。
总之,应谨慎使用 Pod 优先级,因为用户指定的数值优先级会指导调度程序和 Kubelet 放置或终止哪些 Pods,用户可能会利用这一点进行操作。任何更改都可能影响多个 Pods,并可能阻止平台提供可预测的服务级别协议。
项目资源
Kubernetes 是一个自助服务平台,使开发人员可以在指定的隔离环境中按其需求运行应用程序。然而,在共享的多租户平台上工作也需要特定边界和控制单元,以防止某些用户消耗平台的所有资源。其中一种工具是 ResourceQuota,它提供了命名空间中限制聚合资源消耗的约束条件。通过 ResourceQuotas,集群管理员可以限制在命名空间中消耗的计算资源总和(如 CPU、内存)和存储。它还可以限制创建在命名空间中的对象总数(如 ConfigMaps、Secrets、Pods 或 Services)。示例 2-5 展示了限制某些资源使用的实例。请参阅官方 Kubernetes 文档中有关 Resource Quotas 的完整支持资源列表,您可以使用 ResourceQuotas 限制这些资源的使用。
示例 2-5. 资源约束的定义。
apiVersion: v1
kind: ResourceQuota
metadata:
name: object-counts
namespace: default 
spec:
hard:
pods: 4 
limits.memory: 5Gi 
应用资源约束的命名空间。
允许此命名空间中有四个活跃的 Pods。
此命名空间中所有 Pods 的内存限制总和不能超过 5 GB。
在这一领域的另一个有用工具是 LimitRange,它允许你为每种资源类型设置资源使用限制。除了指定不同资源类型的最小和最大允许量及这些资源的默认值之外,它还允许你控制 requests 和 limits 之间的比例,也称为过度分配级别。示例 2-6 展示了一个 LimitRange 和可能的配置选项。
示例 2-6. 允许和默认资源使用限制的定义。
apiVersion: v1
kind: LimitRange
metadata:
name: limits
namespace: default
spec:
limits:
- min: 
memory: 250Mi
cpu: 500m
max: 
memory: 2Gi
cpu: 2
default: 
memory: 500Mi
cpu: 500m
defaultRequest: 
memory: 250Mi
cpu: 250m
maxLimitRequestRatio: 
memory: 2
cpu: 4
type: Container 
请求和限制的最小值。
请求和限制的最大值。
在未指定限制时的默认值。
在未指定请求时的默认值。
最大比例限制/请求,用于指定允许的过度分配级别。在这里,内存限制不能大于内存请求的两倍,而 CPU 限制可以高达 CPU 请求的四倍。
类型可以是 Container、Pod(所有容器的合并),或 PersistentVolumeClaim(为请求持久卷指定范围)。
LimitRange 有助于控制容器资源配置文件,以便没有容器需要比集群节点提供的资源更多。LimitRange 还可以防止集群用户创建消耗大量资源的容器,使得节点无法为其他容器分配资源。考虑到 requests(而不是 limits)是调度程序用于放置的主要容器特征,LimitRequestRatio 允许你控制容器的 requests 和 limits 之间的差异量。requests 和 limits 的大差距增加了在节点上过度分配的可能性,并且当许多容器同时需要比最初请求的资源更多时,可能会降低应用程序的性能。
请记住,其他共享的节点级资源,如进程 ID(PIDs),可能在达到任何资源限制之前耗尽。Kubernetes 允许您为系统使用保留一些节点 PIDs,并确保它们永远不会被用户工作负载耗尽。类似地,Pod PID 限制允许集群管理员限制 Pod 中运行的进程数量。由于这些设置是由集群管理员设置为 Kubelet 配置选项,而不是由应用程序开发人员使用,我们在此不对其进行详细审查。
容量规划
考虑到容器在不同环境中可能具有不同的资源配置文件和各种实例数量,显然,多用途环境的容量规划并不简单。例如,在非生产集群上,为了实现最佳硬件利用率,您可能主要有 Best-Effort 和 Burstable 类型的容器。在这样一个动态的环境中,许多容器同时启动和关闭,即使一个容器在资源饥饿时被平台终止,也不是致命的。在生产集群中,我们希望事情更稳定和可预测,容器可能主要是 Guaranteed 类型,部分可能是 Burstable 类型。如果一个容器被终止,这很可能是集群容量应增加的迹象。
表 2-1 展示了几个具有 CPU 和内存需求的服务。
表 2-1. 容量规划示例
| Pod | CPU 请求 | 内存请求 | 内存限制 | 实例数 |
|---|---|---|---|---|
| A | 500 m | 500 Mi | 500 Mi | 4 |
| B | 250 m | 250 Mi | 1000 Mi | 2 |
| C | 500 m | 1000 Mi | 2000 Mi | 2 |
| D | 500 m | 500 Mi | 500 Mi | 1 |
| 总计 | 4000 m | 5000 Mi | 8500 Mi | 9 |
当然,在实际场景中,您使用 Kubernetes 等平台的更可能原因是有更多的服务需要管理,其中一些即将退役,一些仍处于设计和开发阶段。即使它是一个不断变化的目标,基于先前描述的类似方法,我们可以计算每个环境所有服务所需的总资源量。
在不同的环境中,请记住,容器的数量各不相同,甚至可能需要留出一些空间用于自动扩展、构建作业、基础设施容器等。根据这些信息和基础设施提供者,您可以选择提供所需资源的最具成本效益的计算实例。
讨论
容器不仅用于进程隔离和作为打包格式,还是成功容量规划的构建块,具有确定的资源配置文件。执行一些早期测试,以了解每个容器的资源需求,并将该信息作为未来容量规划和预测的基础。
Kubernetes 可以通过 垂直 Pod 自动缩放器(VPA)来帮助你,在一段时间内监控 Pod 的资源消耗,并推荐请求和限制。VPA 在 “垂直 Pod 自动缩放” 中有详细描述。
然而,更重要的是,资源配置文件是应用程序与 Kubernetes 通信以帮助调度和管理决策的方式。如果你的应用程序没有提供任何 requests 或 limits,那么 Kubernetes 只能将你的容器视为在集群变满时会被丢弃的不透明盒子。因此,对于每个应用程序来说,思考并提供这些资源声明几乎是强制性的。
现在你知道如何调整我们的应用程序大小,在 第三章,“声明式部署”,你将学习在 Kubernetes 上安装和更新我们的应用程序的多种策略。
更多信息
第三章:声明式部署
声明式部署 模式的核心是 Kubernetes 的 Deployment 资源。这种抽象封装了一组容器的升级和回滚过程,并使其执行成为可重复和自动化的活动。
问题
我们可以自助方式在命名空间中为隔离环境提供资源,并通过调度程序将应用程序放置在这些环境中,几乎不需要人为干预。但随着微服务数量的增加,持续更新并用新版本替换它们也成为了越来越大的负担。
将服务升级到下一个版本涉及到启动新版本的 Pod、优雅地停止旧版本的 Pod、等待并验证其成功启动,有时在失败时回滚到先前版本。这些活动通过允许一些停机时间但不运行并发服务版本,或者在更新过程中由于两个版本的服务同时运行而导致资源使用增加来执行。手动执行这些步骤可能会导致人为错误,并且正确地脚本化可能需要大量的工作量,这些很快就会将发布过程变成瓶颈。
解决方案
幸运的是,Kubernetes 也实现了应用程序的自动化升级。使用 Deployment 的概念,我们可以描述应用程序如何更新,使用不同的策略并调整更新过程的各个方面。如果考虑到每个微服务实例的每个发布周期都要执行多次 Deployment(这取决于团队和项目,可以从几分钟到几个月不等),这也是 Kubernetes 提供的另一种节省工作的自动化。
在 第二章,“可预测需求” 中,我们看到,为了有效地执行其工作,调度程序需要主机系统上充足的资源、适当的放置策略以及具有充分定义的资源配置文件的容器。类似地,为了正确执行 Deployment 的工作,它期望容器成为良好的云原生公民。在 Deployment 的核心是可预测地启动和停止一组 Pod 的能力。为了让这一切正常工作,容器本身通常会监听并遵循生命周期事件(如 SIGTERM;参见 第五章,“托管生命周期”)并提供健康检查端点,如 第四章,“健康探测” 中描述的那样,指示它们是否成功启动。
如果一个容器准确覆盖了这两个方面,平台就可以干净地关闭旧容器并通过启动更新实例来替换它们。然后,更新过程的所有其他方面都可以以声明方式定义,并作为一个具有预定义步骤和预期结果的原子操作执行。让我们看看容器更新行为的选项。
滚动部署
在 Kubernetes 中,更新应用程序的声明方式是通过 Deployment 的概念。在幕后,Deployment 创建支持基于集合的标签选择器的 ReplicaSet。此外,Deployment 抽象允许您使用 RollingUpdate(默认)和 Recreate 等策略来塑造更新过程的行为。示例 3-1 显示了为滚动更新策略配置 Deployment 的重要部分。
示例 3-1. 滚动更新的 Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: random-generator
spec:
replicas: 3 
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1 
maxUnavailable: 1 
minReadySeconds: 60 
selector:
matchLabels:
app: random-generator
template:
metadata:
labels:
app: random-generator
spec:
containers:
- image: k8spatterns/random-generator:1.0
name: random-generator
readinessProbe: 
exec:
command: [ "stat", "/random-generator-ready" ]
三个副本的声明。进行滚动更新需要多于一个副本才有意义。
在更新期间,除了指定的副本数之外,可以临时运行的 Pod 数量。在这个例子中,最多可以有四个副本。
在更新期间可能不可用的 Pod 数量。在这种情况下,更新期间可能一次只有两个 Pod 可用。
所有就绪探针在滚出的 Pod 上必须健康的秒数,直到推出继续。
对于滚动部署而言,健康探针至关重要,以确保零停机时间—不要忘记它们(见第 4 章,“健康探针”)。
RollingUpdate 策略的行为确保更新过程中没有停机时间。在幕后,Deployment 实现通过创建新的 ReplicaSets 并替换旧容器来执行类似的操作。这里的一个增强功能是,使用 Deployment,可以通过 maxSurge 和 maxUnavailable 字段控制新容器的推出速率。
这两个字段可以是 Pods 的绝对数或相对百分比,应用于 Deployment 配置的副本数,并向上(maxSurge)或向下(maxUnavailable)舍入到下一个整数值。默认情况下,maxSurge 和 maxUnavailable 都设置为 25%。
另一个影响推出行为的重要参数是 minReadySeconds。此字段指定 Pod 的准备探针需要成功运行的秒数,直到 Pod 自身在推出中被视为可用。增加此值可确保应用程序 Pod 在继续推出之前成功运行一段时间。此外,较大的 minReadySeconds 间隔有助于调试和探索新版本。当更新步骤之间的间隔较大时,可能更容易利用 kubectl rollout pause。
图 3-1 展示了滚动更新过程。

图 3-1. 滚动部署
要触发声明性更新,您有三个选项:
-
使用
kubectl replace用新版本的 Deployment 替换整个 Deployment。 -
使用
kubectl patch或交互式编辑 (kubectl edit) Deployment 来设置新版本的容器镜像。 -
使用
kubectl set image在 Deployment 中设置新的镜像。
另请参阅我们仓库中的 完整示例,该示例演示了这些命令的用法,并展示了如何使用 kubectl rollout 监控或回滚升级。
除了解决部署服务的命令式方法的缺点之外,Deployment 还具有以下优点:
-
Deployment 是 Kubernetes 资源对象,其状态完全由 Kubernetes 在内部管理。整个更新过程在服务器端执行,无需客户端交互。
-
Deployment 的声明性质指定了部署状态应该如何看起来,而不是达到那个状态所需的步骤。
-
Deployment 定义是一个可执行的对象,不仅仅是文档。它可以在达到生产环境之前在多个环境中进行试验和测试。
-
更新过程也完全记录和版本化,包括暂停、继续和回滚到以前的版本的选项。
固定部署
RollingUpdate 策略对于确保更新过程中零停机时间很有用。然而,这种方法的副作用是在更新过程中可能会同时运行两个版本的容器。这可能会对服务消费者造成问题,特别是当更新过程在服务 API 中引入了不兼容的变化,而客户端无法处理这些变化时。对于这种情况,可以使用 Recreate 策略,如 图 3-2 所示,它可以解决这些问题。

图 3-2. 使用 Recreate 策略进行固定部署
Recreate 策略的效果是将 maxUnavailable 设置为声明的副本数。这意味着它首先终止当前版本的所有容器,然后在旧容器被驱逐时同时启动所有新容器。这个序列的结果是在停止所有旧版本容器的同时发生停机时间,而且没有新容器准备好处理传入请求。积极的一面是,两个不同版本的容器不会同时运行,因此服务消费者一次只能连接一个版本。
蓝绿发布
蓝绿部署 是一种用于在生产环境中部署软件的发布策略,通过最小化停机时间和降低风险来实现。Kubernetes 的部署抽象是一个基本概念,它允许您定义 Kubernetes 如何从一个版本过渡到另一个版本的不可变容器。我们可以将部署原语作为构建块,与其他 Kubernetes 原语一起实现这种更高级的发布策略。
蓝绿部署如果没有像服务网格或 Knative 这样的扩展,需要手动完成。从技术上讲,它通过创建第二个部署来运行最新版本的容器(我们称之为 绿色),但此时这些容器尚未处理任何请求。在此阶段,原始部署中的旧 Pod 副本(称为 蓝色)仍在运行并处理活动请求。
一旦我们确信新版本的 Pod 已经健康且准备好处理实时请求,我们就会将流量从旧 Pod 副本切换到新的副本。在 Kubernetes 中,可以通过更新服务的选择器来匹配新容器(标记为绿色)来完成此操作。如图 3-3 所示,一旦绿色(v1.1)容器处理了所有流量,蓝色(v1.0)容器就可以被删除,释放资源供未来的蓝绿部署使用。

图 3-3. 蓝绿部署
蓝绿部署的一个好处是一次只有一个应用程序版本在提供请求,这降低了通过服务消费者处理多个并发版本的复杂性。缺点是在蓝色和绿色容器都在运行时,需要两倍的应用程序容量。此外,在过渡期间,长时间运行的进程和数据库状态漂移可能会导致重大的复杂性。
金丝雀发布
金丝雀发布 是一种通过仅替换少部分旧实例来温和地将新版本的应用程序部署到生产环境的方式。这种技术通过让只有一部分消费者能访问更新版本,来减少引入新版本到生产环境中的风险。当我们对服务的新版本和它在一小部分用户中的表现感到满意时,我们可以在此金丝雀发布后的附加步骤中,将所有旧实例替换为新版本。图 3-4 展示了金丝雀发布的实施过程。
在 Kubernetes 中,可以通过创建具有小副本计数的新部署来实现此技术,该部署可用作金丝雀实例。在此阶段,服务应将一些消费者定向到更新的 Pod 实例。金丝雀发布后,一旦我们确信新的 ReplicaSet 的所有工作都如预期那样正常运行,我们就会将新的 ReplicaSet 扩展起来,将旧的 ReplicaSet 缩减到零。在某种程度上,我们正在执行一个受控且经过用户测试的增量部署。

图 3-4. 金丝雀发布
讨论
部署原语是 Kubernetes 将手动更新应用程序这一繁琐过程转变为声明性活动的一个例子,可重复和自动化。开箱即用的部署策略(滚动和重新创建)控制新旧容器的替换,而高级发布策略(蓝绿部署和金丝雀发布)则控制新版本如何对服务消费者可用。后两种发布策略基于人类决策作为过渡触发器,因此 Kubernetes 并未完全自动化,而需要人类交互。图 3-5 总结了部署和发布策略,展示了过渡期间的实例计数。

图 3-5. 部署和发布策略
所有软件都是不同的,部署复杂系统通常需要额外的步骤和检查。本章讨论的技术涵盖了 Pod 更新过程,但不包括更新和回滚其他 Pod 依赖项,如 ConfigMaps、Secrets 或其他相关服务。
今天一种可行的方法是创建一个脚本来管理使用本书讨论的部署和其他原语更新服务及其依赖关系的过程。然而,这种描述单个更新步骤的命令式方法并不符合 Kubernetes 的声明性特性。
作为替代,出现了在 Kubernetes 之上的更高级别的声明性方法。在下文中描述了最重要的平台。这些技术与运算符(参见第 28 章,“运算符”)一起工作,它们采用部署过程的声明性描述,并在服务器端执行必要的操作,其中一些还包括在更新错误时自动回滚。对于高级、生产就绪的发布场景,建议查看其中一个扩展。
无论您使用哪种部署策略,对于 Kubernetes 来说,了解应用程序 Pod 何时处于运行状态非常重要,以执行达到定义的目标部署状态所需的步骤序列。下一模式,健康探针,在第 4 章描述了您的应用程序如何向 Kubernetes 通报其健康状态。
更多信息
第四章:健康探针
Health Probe 模式表明应用程序如何将其健康状态传达给 Kubernetes。为了实现完全自动化,云原生应用程序必须通过允许推断其状态来高度可观察,以便 Kubernetes 可以检测应用程序是否启动并且是否准备好响应请求。这些观察结果会影响 Pod 的生命周期管理以及流量路由到应用程序的方式。
问题
Kubernetes 定期检查容器进程状态,并在检测到问题时重新启动它。然而,从实践中我们知道,仅检查进程状态不足以确定应用程序的健康状态。在许多情况下,应用程序可能会挂起,但其进程仍在运行。例如,Java 应用程序可能会抛出 OutOfMemoryError,但 JVM 进程仍在运行。或者,应用程序可能会因进入无限循环、死锁或某种 thrashing(缓存、堆、进程)而冻结。为了检测这些情况,Kubernetes 需要一种可靠的方式来检查应用程序的健康状态——即不是理解应用程序内部的工作方式,而是检查应用程序是否按预期运行,并能够为消费者提供服务。
解决方案
软件行业已经接受了一个事实,即编写无 bug 的代码是不可能的。此外,与分布式应用程序一起工作时,故障的可能性进一步增加。因此,处理故障的重点已经从避免转移到检测故障和恢复上。检测故障并非一项可以为所有应用程序统一执行的简单任务,因为每个人对故障有不同的定义。而且,不同类型的故障需要不同的纠正措施。瞬态故障可能会在足够的时间内自行恢复,而其他一些故障可能需要重新启动应用程序。让我们看看 Kubernetes 用于检测和纠正故障的检查方法。
进程健康检查
进程健康检查 是 Kubelet 对容器进程定期执行的最简单健康检查。如果容器进程未运行,将在分配了 Pod 的节点上重新启动容器。因此,即使没有其他健康检查,通过这种通用检查,应用程序的健壮性也会稍有提升。如果您的应用程序能够检测到任何类型的故障并自行关闭,进程健康检查就足够了。然而,对于大多数情况来说,这还不够,还需要其他类型的健康检查。
存活性探针
如果您的应用程序遇到死锁,从进程健康检查的角度来看它仍然是健康的。为了检测这种类型的问题以及根据您的应用程序业务逻辑检测任何其他类型的故障,Kubernetes 提供了存活性探测——由 Kubelet 代理执行的定期检查,要求您的容器确认其仍然是健康的。重要的是从外部执行健康检查而不是在应用程序本身中执行,因为某些故障可能会阻止应用程序看门狗报告其故障。关于纠正措施,此健康检查类似于进程健康检查,因为如果检测到故障,容器将被重新启动。但它在选择用于检查应用程序健康状况的方法方面提供了更大的灵活性,具体如下:
HTTP 探测
对容器 IP 地址执行 HTTP GET 请求,并期望返回成功的 HTTP 响应代码(200 到 399 之间)。
TCP Socket 探测
假设成功的 TCP 连接。
执行探测
在容器的用户和内核命名空间中执行任意命令,并期望成功的退出代码(0)。
gRPC 探测
利用 gRPC 对健康检查的内在支持。
除了探测操作外,健康检查行为还受以下参数影响:
initialDelaySeconds
指定等待第一个存活性探测检查之前的秒数。
periodSeconds
存活性探测检查之间的秒数间隔。
timeoutSeconds
在探测检查返回前允许的最大时间,超过此时间将视为失败。
failureThreshold
指定探测检查连续失败多少次后,容器被视为不健康并需要重新启动。
显示了一个基于 HTTP 的存活性探测示例,见示例 4-1。
示例 4-1. 带有存活性探测的容器
apiVersion: v1
kind: Pod
metadata:
name: pod-with-liveness-check
spec:
containers:
- image: k8spatterns/random-generator:1.0
name: random-generator
env:
- name: DELAY_STARTUP
value: "20"
ports:
- containerPort: 8080
protocol: TCP
livenessProbe:
httpGet: 
path: /actuator/health
port: 8080
initialDelaySeconds: 30 
向健康检查端点进行 HTTP 探测。
在进行第一次存活性检查之前等待 30 秒,以便应用程序有些时间进行预热。
根据您的应用性质,您可以选择最适合您的方法。由应用程序决定是否认为自己是健康的。但请注意,未通过健康检查的结果是容器将重新启动。如果重新启动容器没有帮助,那么健康检查失败将毫无意义,因为 Kubernetes 会在不修复潜在问题的情况下重新启动您的容器。
可用性探测
存活检查通过终止不健康的容器并用新的替换来保持应用程序健康。但有时,当容器不健康时,重新启动可能无济于事。一个典型的例子是容器仍在启动中,尚未准备好处理任何请求。另一个例子是应用程序仍在等待依赖项(如数据库)可用。此外,容器可能会因负载过重而过载,增加延迟,因此你希望它在一段时间内对额外负载进行屏蔽,并指示它在负载减少之前不可用。
对于这种情况,Kubernetes 有就绪探测。执行就绪检查的方法(HTTP、TCP、Exec、gRPC)和定时选项与存活检查相同,但纠正措施不同。与重新启动容器不同,失败的就绪探测会导致容器从服务端点中移除,并且不接收任何新的流量。就绪探测信号容器何时准备好,以便在受到来自服务的请求之前有一些时间预热。它还有助于在后期阶段屏蔽容器免受流量影响,因为就绪探测定期执行,类似于存活检查。示例 4-2 展示了如何通过探测应用程序创建的文件的存在来实现就绪探测,以指示应用程序已准备好运行。
示例 4-2. 带就绪探测的容器
apiVersion: v1
kind: Pod
metadata:
name: pod-with-readiness-check
spec:
containers:
- image: k8spatterns/random-generator:1.0
name: random-generator
readinessProbe:
exec: 
command: [ "stat", "/var/run/random-generator-ready" ]
检查应用程序创建的文件的存在,以指示它已准备好提供服务。如果文件不存在,stat会返回错误,导致就绪检查失败。
再次强调,由健康检查的实现决定何时应用程序准备好执行其工作,以及何时应该让其独自运行。虽然进程健康检查和存活检查旨在通过重新启动容器来从故障中恢复,但就应用程序而言,就绪检查为其提供了时间,并期望其能够自行恢复。请记住,Kubernetes 试图防止容器在收到 SIGTERM 信号后继续接收新请求(例如在关闭时),无论就绪检查在接收 SIGTERM 信号后是否仍然通过。
在许多情况下,存活和就绪探测执行相同的检查。然而,有了就绪探测,您的容器有时间启动。只有通过就绪检查,部署才被视为成功,以便例如,具有旧版本的 Pod 可以作为滚动更新的一部分终止。
对于需要很长时间初始化的应用程序,很可能在启动完成之前未能通过存活检查导致容器重新启动。为了防止这些意外关闭,可以使用启动探测来指示启动何时完成。
启动探测
活跃性探针也可以仅用于通过延长检查间隔、增加重试次数和在初始活跃性探针检查中添加较长延迟来允许较长的启动时间。然而,这种策略并不理想,因为这些定时参数也将适用于启动后阶段,并且将阻止应用程序在发生致命错误时快速重新启动。
当应用程序启动需要几分钟(例如,Jakarta EE 应用服务器)时,Kubernetes 提供了启动探针。
启动探针配置与活跃性探针相同,但允许探针动作和定时参数的不同值。 periodSeconds 和 failureThreshold 参数的配置值比相应的活跃性探针大得多,以考虑较长的应用程序启动时间。只有启动探针报告成功后,才会调用活跃性和可用性探针。如果启动探针在配置的失败阈值内不成功,则容器将重新启动。
虽然启动和活跃性探针可以使用相同的探测动作,但通常通过启动探针检查存在的标记文件指示成功启动。
示例 4-4 是一个典型的 Jakarta EE 应用服务器启动时间较长的示例。
示例 4-4. 带有启动和活跃性探针的容器
apiVersion: v1
kind: Pod
metadata:
name: pod-with-startup-check
spec:
containers:
- image: quay.io/wildfly/wildfly 
name: wildfly
startupProbe:
exec:
command: [ "stat", "/opt/jboss/wildfly/standalone/tmp/startup-marker" ] 
initialDelaySeconds: 60 
periodSeconds: 60
failureThreshold: 15
livenessProbe:
httpGet:
path: /health
port: 9990
periodSeconds: 10 
failureThreshold: 3
JBoss WildFly Jakarta EE 服务器启动可能需要一些时间。
WildFly 在成功启动后创建的标记文件。
定时参数指定容器在启动探针在首次检查后未通过之后的 15 分钟内应重新启动(60 秒的暂停直到第一次检查,然后最多 15 次检查,每次间隔 60 秒)。
活跃性探针的定时参数要小得多,如果后续活跃性探针在 20 秒内失败,则会重新启动(每次间隔 10 秒的三次重试)。
活跃性、可用性和启动探针是云原生应用程序自动化的基本构建块。诸如 Quarkus SmallRye Health、Spring Boot Actuator、WildFly Swarm health check、Apache Karaf health check 或 Java 的 MicroProfile 规范等应用框架提供了健康探针的实现。
讨论
要完全自动化,云原生应用程序必须通过提供管理平台读取和解释应用程序健康状态的手段来进行高度观察。健康检查在部署、自愈、扩展等活动的自动化中起着基本作用。然而,还有其他方法可以使您的应用程序提供关于其健康状况更多的可见性。
达到此目的的显而易见且古老的方法是通过日志记录。对于容器来说,将任何重要事件记录到系统输出和系统错误,并将这些日志收集到中央位置进行进一步分析,是一个良好的实践。通常情况下,日志不用于自动采取操作,而是用于引发警报和进一步的调查。日志的更有用的方面是故障的事后分析和检测不明显的错误。
除了记录到标准流之外,将容器退出的原因记录到 /dev/termination-log 也是一种良好的实践。这个位置是容器在永久消失之前声明其最后遗愿的地方。^(1) 图 4-1 展示了容器与运行时平台进行通信的可能选项。

图 4-1. 容器可观察性选项
容器通过将其视为不透明系统的统一方式提供了打包和运行应用程序的方法。然而,任何旨在成为云原生成员的容器必须为运行时环境提供 API,以观察容器的健康状况并据此采取行动。这种支持是统一方式自动化容器更新和生命周期的基本先决条件,从而提高系统的弹性和用户体验。在实践中,这意味着,作为最低要求,您的容器化应用程序必须为不同类型的健康检查(活跃性和就绪性)提供 API。
更好的行为应用程序还必须通过集成跟踪和度量收集库(如 OpenTracing 或 Prometheus)提供其他手段,使管理平台能够观察容器化应用程序的状态。将您的应用程序视为不透明系统,但实现所有必要的 API,以帮助平台以最佳方式观察和管理您的应用程序。
下一个模式,托管生命周期,也涉及应用程序与 Kubernetes 管理层之间的通信,但角度相反。它关注的是应用程序如何被告知重要的 Pod 生命周期事件。
更多信息
^(1) 或者,您可以将 Pod 的 .spec.containers.terminationMessagePolicy 字段更改为 FallbackToLogsOnError,在这种情况下,当 Pod 终止时,日志的最后一行用作 Pod 的状态消息。
第五章:托管生命周期
由云原生平台管理的容器化应用程序无法控制其生命周期,为了成为良好的云原生公民,它们必须监听管理平台发出的事件,并相应地调整其生命周期。托管生命周期 模式描述了应用程序如何以及应该如何响应这些生命周期事件。
问题
在 第四章,“健康探测” 中,我们解释了为什么容器必须为不同的健康检查提供 API。健康检查 API 是平台持续探测以获取应用程序洞察的只读端点。这是平台从应用程序中提取信息的机制。
除了监视容器的状态外,平台有时可能会发出命令,并期望应用程序对其做出反应。受政策和外部因素驱动,云原生平台可能会决定随时启动或停止其管理的应用程序。这取决于容器化应用程序确定哪些事件对其重要,并如何做出响应。实际上,这是平台用于与应用程序通信和发送命令的 API。此外,应用程序可以选择从生命周期管理中受益,或者如果它们不需要此服务,则可以忽略它。
解决方案
我们发现仅检查进程状态并不足以判断应用程序的健康状况。这就是为什么存在用于监视容器健康的不同 API 的原因。同样地,仅使用进程模型来运行和停止进程也不够。现实世界的应用程序需要更精细的交互和生命周期管理能力。一些应用程序需要预热,一些应用程序需要温和且干净的关闭过程。出于这些以及其他用例的考虑,平台会发出一些事件,如 图 5-1 所示,容器可以选择监听并根据需要做出响应。

图 5-1. 托管容器生命周期
应用程序的部署单元是 Pod。正如您已经知道的那样,Pod 由一个或多个容器组成。在 Pod 级别,还有其他构造,比如我们在 第十五章,“Init 容器” 中介绍的 init 容器,它们可以帮助管理容器生命周期。本章描述的事件和钩子都应用于单个容器级别,而不是 Pod 级别。
SIGTERM 信号
每当 Kubernetes 决定关闭一个容器,无论是因为其所属的 Pod 正在关闭,还是因为失败的存活探测导致容器重启,容器都会收到 SIGTERM 信号。SIGTERM 是对容器进行轻微提示,要求其在 Kubernetes 发送更突然的 SIGKILL 信号之前进行干净的关闭。一旦收到 SIGTERM 信号,应用程序应尽快关闭。对于某些应用程序来说,这可能是快速终止,而对于其他应用程序可能需要完成正在进行的请求、释放打开的连接和清理临时文件,这可能需要更长的时间。在所有情况下,响应 SIGTERM 是以干净的方式关闭容器的正确时机。
SIGKILL 信号
如果容器进程在收到 SIGTERM 信号后没有关闭,将通过后续的 SIGKILL 信号强制关闭。Kubernetes 不会立即发送 SIGKILL 信号,而是在发出 SIGTERM 信号后默认等待 30 秒。此优雅期可以通过 .spec.terminationGracePeriodSeconds 字段在每个 Pod 中定义,但无法保证,因为在向 Kubernetes 发送命令时可以覆盖它。设计和实现容器化应用程序的目标应该是短暂的,具有快速启动和关闭过程。
PostStart 钩子
仅使用进程信号管理生命周期有一定限制。这就是为什么 Kubernetes 提供了额外的生命周期钩子,如 postStart 和 preStop 的原因。包含 postStart 钩子的 Pod 清单看起来像 示例 5-1 中的那样。
示例 5-1. 带有 postStart 钩子的容器
apiVersion: v1
kind: Pod
metadata:
name: post-start-hook
spec:
containers:
- image: k8spatterns/random-generator:1.0
name: random-generator
lifecycle:
postStart:
exec:
command: 
- sh
- -c
- sleep 30 && echo "Wake up!" > /tmp/postStart_done
postStart 命令等待 30 秒。sleep 仅是对任何可能在此时运行的长时间启动代码进行模拟。此外,它使用触发文件与主应用程序同步,后者并行启动。
postStart 命令在容器创建后执行,与主容器进程异步进行。即使应用程序初始化和预热逻辑的大部分可以作为容器启动步骤的一部分实现,postStart 仍然涵盖了一些用例。postStart 操作是一个阻塞调用,容器状态保持 Waiting 直到 postStart 处理程序完成,从而保持 Pod 状态为 Pending。postStart 的这种特性可以用来延迟容器的启动状态,同时允许主容器进程初始化的时间。
另一个使用 postStart 的例子是在 Pod 不满足某些前提条件时阻止容器启动。例如,当 postStart 钩子通过返回非零退出码指示错误时,Kubernetes 会终止主容器进程。
postStart 和 preStop 钩子调用机制类似于第四章,“健康探针”中描述的健康探针,并支持这些处理程序类型:
exec
直接在容器中运行命令
httpGet
对一个 Pod 容器打开的端口执行 HTTP GET 请求
在 postStart 钩子中执行的关键逻辑要非常小心,因为它的执行没有任何保证。由于钩子与容器进程并行运行,可能会在容器启动之前执行钩子。此外,钩子旨在具有至少一次语义,因此实现必须注意重复执行。另一个要记住的方面是平台不会对未到达处理程序的失败 HTTP 请求进行任何重试尝试。
PreStop 钩子
preStop 钩子是在容器终止之前发送到容器的阻塞调用。它具有与 SIGTERM 信号相同的语义,并且应该用于在无法响应 SIGTERM 时启动容器的优雅关闭。在发送删除容器的调用到容器运行时之前,preStop 操作在示例 5-2 中必须完成,这会触发 SIGTERM 通知。
示例 5-2. 带有 preStop 钩子的容器
apiVersion: v1
kind: Pod
metadata:
name: pre-stop-hook
spec:
containers:
- image: k8spatterns/random-generator:1.0
name: random-generator
lifecycle:
preStop:
httpGet: 
path: /shutdown
port: 8080
调用运行在应用程序内部的 /shutdown 端点。
即使 preStop 是阻塞的,但在其上保持或返回不成功的结果并不会阻止删除容器和终止进程。preStop 钩子只是优雅应用程序关闭的方便替代方法,没有其他作用。它还提供了与我们之前介绍的 postStart 钩子相同的处理程序类型和保证。
其他生命周期控制
到目前为止,我们专注于允许您在容器生命周期事件发生时执行命令的钩子。但是,另一种机制不是在容器级别而是在 Pod 级别,允许您执行初始化指令。
我们在第十五章中深入描述了初始化容器模式,但在这里我们简要描述它以与生命周期钩子进行比较。与常规应用容器不同,初始化容器按顺序运行,直到完成,并在 Pod 中的任何应用容器启动之前运行。这些保证允许您使用初始化容器执行 Pod 级别的初始化任务。生命周期钩子和初始化容器在不同的粒度上运行(分别在容器级别和 Pod 级别),有时可以互换使用,或在其他情况下互补使用。表 5-1 总结了两者之间的主要区别。
表 5-1. 生命周期钩子和初始化容器
| 方面 | 生命周期钩子 | 初始化容器 |
|---|---|---|
| 激活时间 | 容器生命周期阶段。 | Pod 生命周期阶段。 |
| 启动阶段操作 | 一个postStart命令。 |
执行一系列initContainers的列表。 |
| 关闭阶段操作 | 一个preStop命令。 |
没有等效特性。 |
| 时间保证 | postStart命令与容器的ENTRYPOINT同时执行。 |
所有初始化容器必须在任何应用程序容器启动之前成功完成。 |
| 使用场景 | 执行特定于容器的非关键启动/关闭清理。 | 使用容器执行类似工作流的顺序操作;重复使用容器执行任务。 |
如果需要更多控制来管理应用程序容器的生命周期,有一种高级技术可以重写容器的入口点,有时也称为Commandlet模式。当 Pod 中的主要容器必须按特定顺序启动并且需要额外的控制级别时,此模式特别有用。基于 Kubernetes 的流水线平台如 Tekton 和 Argo CD 要求按顺序执行共享数据的容器,并支持并行运行的附加 sidecar 容器(我们在第十六章,“Sidecar”中更详细地讨论了 sidecars)。
对于这些场景,仅使用一系列初始化容器是不够的,因为初始化容器不允许 sidecars。作为替代方案,可以使用一种称为entrypoint 重写的高级技术,以允许对 Pod 主容器进行精细化的生命周期控制。每个容器镜像定义了一个默认在容器启动时执行的命令。在 Pod 规范中,您还可以直接在 Pod 规范中定义此命令。entrypoint 重写的想法是用一个通用的包装命令替换这个命令,该命令调用原始命令并处理生命周期相关的问题。这个通用命令是从另一个容器镜像中注入的,然后再启动应用程序容器之前。
这个概念最好通过一个例子来解释。 示例 5-3 展示了一个典型的 Pod 声明,该声明启动了一个带有给定参数的单个容器。
示例 5-3. 启动带有命令和参数的简单 Pod
apiVersion: v1
kind: Pod
metadata:
name: simple-random-generator
spec:
containers:
- image: k8spatterns/random-generator:1.0
name: random-generator
command:
- "random-generator-runner" 
args: 
- "--seed"
- "42"
容器启动时执行的命令。
提供给入口点命令的额外参数。
现在的技巧是用一个通用的监管程序来包装给定的命令random-generator-runner,该监管程序负责处理生命周期方面的问题,如对SIGTERM或其他外部信号的反应。 示例 5-4 展示了一个包含初始化容器以安装监管程序的 Pod 声明,然后启动监管程序来监视主应用程序。
示例 5-4. 使用监管程序包装原始 entrypoint 的 Pod
apiVersion: v1
kind: Pod
metadata:
name: wrapped-random-generator
spec:
volumes:
- name: wrapper 
emptyDir: { }
initContainers:
- name: copy-supervisor 
image: k8spatterns/supervisor
volumeMounts:
- mountPath: /var/run/wrapper
name: wrapper
command: [ cp ]
args: [ supervisor, /var/run/wrapper/supervisor ]
containers:
- image: k8spatterns/random-generator:1.0
name: random-generator
volumeMounts:
- mountPath: /var/run/wrapper
name: wrapper
command:
- "/var/run/wrapper/supervisor" 
args: 
- "random-generator-runner"
- "--seed"
- "42"
创建一个新的emptyDir卷来共享监控守护程序。
用于将监控守护程序复制到应用容器的初始容器。
在示例 5-3 中定义的原始命令randomGenerator被来自共享卷的监控守护程序所取代。
原始命令规范变为监控命令的参数。
对于基于 Kubernetes 的应用程序而言,这种入口重写特别有用,它会以编程方式创建和管理 Pod,例如 Tekton,在运行持续集成(CI)流水线时创建 Pod。这样,它们可以更好地控制何时启动、停止或在 Pod 中链式连接容器。
关于使用哪种机制没有严格的规则,除非需要特定的定时保证。我们可以完全跳过生命周期钩子和初始容器,而使用一个 bash 脚本来执行容器启动或关闭命令中的特定操作。这是可能的,但它会将容器与脚本紧密耦合,并将其变成维护的噩梦。我们也可以使用 Kubernetes 生命周期钩子执行一些操作,如本章所述。或者,我们甚至可以进一步运行执行单个操作的容器,使用初始容器或注入监控守护程序以实现更复杂的控制。在这一序列中,选项需要越来越多的努力,但同时提供更强的保证并支持重用。
理解容器和 Pod 生命周期的各个阶段和可用钩子对于创建由 Kubernetes 管理的应用程序至关重要。
讨论
云原生平台提供的主要好处之一是能够可靠和可预测地在可能不稳定的云基础设施上运行和扩展应用程序。这些平台为在其上运行的应用程序提供一组约束和合同。对应用程序而言,遵守这些合同以从云原生平台提供的所有功能中受益至关重要。处理和响应这些事件确保您的应用程序可以在最小影响消费服务的情况下优雅地启动和关闭。目前,在其基本形式中,这意味着容器应该像任何设计良好的 POSIX 进程一样行为。未来,可能会有更多事件提供提示,告知应用程序何时将被扩展或要求释放资源以防止关闭。理解应用程序生命周期不再由个人控制,而是完全由平台自动化管理是至关重要的。
除了管理应用程序生命周期外,像 Kubernetes 这样的编排平台的另一个重要职责是在节点群中分发容器。下一个模式,自动化放置,解释了从外部影响调度决策的选项。
更多信息
第六章:自动化部署
自动化部署 是 Kubernetes 调度器的核心功能,用于将新的 Pod 分配给符合容器资源请求并遵守调度策略的节点。此模式描述了 Kubernetes 调度算法的原则以及如何从外部影响部署决策。
问题
一个合理大小的基于微服务的系统由数十甚至数百个隔离的进程组成。容器和 Pod 提供了打包和部署的良好抽象,但并没有解决将这些进程放置在合适节点上的问题。对于数量庞大且不断增长的微服务,单独将它们分配和放置到节点上是一项不可管理的活动。
容器彼此之间存在依赖关系,与节点之间存在依赖关系,并且存在资源需求,所有这些都随时间而变化。集群上可用的资源也随时间变化,通过缩小或扩展集群或通过已放置容器来消耗它。我们放置容器的方式也影响分布系统的可用性、性能和容量。所有这些都使得将容器调度到节点成为一个动态目标。
解决方案
在 Kubernetes 中,将 Pod 分配给节点是由调度器完成的。它是 Kubernetes 的一个高度可配置的部分,并且仍在不断演进和改进中。在本章中,我们将涵盖主要的调度控制机制,影响部署位置的驱动因素,为何选择其中一种选项以及由此产生的后果。Kubernetes 调度器是一个强大且节省时间的工具。它在整个 Kubernetes 平台中起着基础性作用,但类似于其他 Kubernetes 组件(API 服务器、Kubelet),它可以独立运行或完全不使用。
在非常高的层次上,Kubernetes 调度器执行的主要操作是从 API 服务器检索每个新创建的 Pod 定义,并将其分配给节点。它为每个 Pod 找到最合适的节点(只要有这样的节点),无论是用于初始应用程序部署、扩展还是在将应用程序从不健康节点移动到健康节点时。它通过考虑运行时依赖关系、资源需求以及高可用性的指导策略来实现这一点;通过水平扩展 Pods 进行分布;并且通过在性能和低延迟交互方面将 Pods 放置在附近。然而,为了使调度器能够正确地执行其工作并允许声明式部署,它需要具有可用容量的节点和已声明资源配置和指导策略的容器。让我们更详细地看看每个方面。
可用节点资源
首先,Kubernetes 集群需要具有足够资源容量的节点来运行新的 Pods。每个节点都有用于运行 Pods 的可用容量,并且调度器确保为 Pod 请求的容器资源总和小于可分配的节点容量。考虑到一个专门用于 Kubernetes 的节点,其容量使用以下公式计算,详见 示例 6-1。
示例 6-1. 节点容量
*Allocatable* [capacity for application pods] =
*Node Capacity* [available capacity on a node]
- *Kube-Reserved* [Kubernetes daemons like kubelet, container runtime]
- *System-Reserved* [Operating System daemons like sshd, udev]
- *Eviction Thresholds* [Reserved memory to prevent system OOMs]
如果不为操作系统和 Kubernetes 本身的系统守护程序保留资源,则 Pods 可以被调度到节点的全部容量,这可能导致 Pods 和系统守护程序竞争资源,从而导致节点上的资源匮乏问题。即使如此,节点上的内存压力也可能通过 OOMKilled 错误影响运行在其上的所有 Pods,或导致节点暂时下线。OOMKilled 是 Linux 内核因内存不足而终止进程时显示的错误消息。当可用内存低于保留值时,驱逐阈值是 Kubelet 为保留节点上的内存并尝试驱逐 Pods 的最后手段。
此外,请注意,如果容器运行在不受 Kubernetes 管理的节点上,则这些容器使用的资源不会反映在 Kubernetes 对节点容量计算中。一个解决方法是运行一个占位 Pod,它什么都不做,但只有对应于未跟踪的容器资源使用量的 CPU 和内存请求。这样的 Pod 只创建用于表示和保留未跟踪容器的资源消耗,并帮助调度器构建节点的更好资源模型。
容器资源需求
有效的 Pod 放置的另一个重要要求是定义容器的运行时依赖和资源需求。我们在 第二章,“可预测的需求” 中更详细地讨论了这一点。它归结为具有声明其资源配置文件(使用 request 和 limit)以及存储或端口等环境依赖项的容器。只有这样,Pods 才能被最优地分配到节点上,并且在高峰使用期间可以运行而不相互影响或面临资源匮乏问题。
调度器配置
下一个拼图是为您的集群需求配置正确的过滤或优先级配置。调度程序已配置了一组默认的断言和优先级策略,适用于大多数用例。在 Kubernetes v1.23 之前的版本中,可以使用调度策略来配置调度器的断言和优先级。Kubernetes 的新版本使用调度配置文件来实现相同的效果。这种新方法将调度过程的不同步骤暴露为扩展点,并允许您配置覆盖步骤的默认实现的插件。示例 6-2 演示了如何使用自定义插件覆盖从 score 步骤中的 PodTopologySpread 插件。
示例 6-2. 调度器配置
apiVersion: kubescheduler.config.k8s.io/v1
kind: KubeSchedulerConfiguration
profiles:
- plugins:
score: 
disabled:
- name: PodTopologySpread 
enabled:
- name: MyCustomPlugin 
weight: 2
在此阶段的插件为每个通过过滤阶段的节点提供评分。
此插件实现了稍后在本章中将看到的拓扑扩展约束。
在前一步中禁用的插件被新插件所替换。
注意
只有管理员作为集群配置的一部分才能定义调度程序插件和自定义调度程序。作为在集群上部署应用程序的普通用户,您只需引用预定义的调度程序。
默认情况下,调度器使用默认的调度器配置文件和默认插件。还可以在集群上运行多个调度器,或在调度器上运行多个配置文件,并允许 Pod 指定使用哪个配置文件。每个配置文件必须具有唯一的名称。然后,在定义 Pod 时,您可以添加字段 .spec.schedulerName ,并指定要使用的配置文件名称到 Pod 的规范中,Pod 将由所需的调度器配置文件处理。
调度过程
根据放置策略,Pod 被分配给具有特定容量的节点。为了完整起见,图 6-1 在高层次上展示了这些元素如何组合以及 Pod 在调度过程中经历的主要步骤。

图 6-1. 一个 Pod 到节点的分配过程
一旦创建了尚未分配到节点的 Pod,调度程序就会与所有可用节点一起选取它以及一组过滤和优先级策略。在第一阶段,调度程序应用过滤策略并删除不符合条件的所有节点。满足 Pod 调度要求的节点称为可行节点。在第二阶段,调度程序运行一组函数对剩余的可行节点进行评分,并按权重对它们进行排序。在最后阶段,调度程序通知 API 服务器有关分配决策的结果,这是调度过程的主要结果。整个过程也称为调度、放置、节点分配或绑定。
在大多数情况下,最好让调度程序完成 Pod 到节点的分配,而不是微观管理放置逻辑。但是,在某些情况下,您可能希望强制将 Pod 分配到特定节点或节点组。可以使用节点选择器来执行此分配。.spec.nodeSelector Pod 字段指定了必须作为节点上标签存在的键值对映射,以使节点有资格运行 Pod。例如,假设您想要强制将 Pod 运行在具有 SSD 存储或 GPU 加速硬件的特定节点上。使用在示例 6-3 中具有匹配 disktype: ssd 的 nodeSelector 的 Pod 定义,只有带有 disktype=ssd 标签的节点才有资格运行该 Pod。
示例 6-3. 基于可用磁盘类型的节点选择器
apiVersion: v1
kind: Pod
metadata:
name: random-generator
spec:
containers:
- image: k8spatterns/random-generator:1.0
name: random-generator
nodeSelector:
disktype: ssd 
必须匹配的一组节点标签,以便将该 Pod 视为此节点的节点。
除了向节点指定自定义标签之外,您还可以使用每个节点上都存在的一些默认标签。每个节点都有一个唯一的 kubernetes.io/hostname 标签,可以通过其主机名将 Pod 放置在节点上。还有其他默认标签,指示操作系统、架构和实例类型,这些标签对于放置也可能很有用。
节点亲和性
Kubernetes 支持许多更灵活的配置调度过程的方法。其中一种功能是节点亲和性,它是前面描述的节点选择器方法的更具表现力的方式,允许将规则指定为必需或首选。必需规则必须满足才能将 Pod 调度到节点,而首选规则只是通过增加匹配节点的权重来表示偏好,而不会使它们成为必需的。此外,节点亲和性功能通过使用诸如 In、NotIn、Exists、DoesNotExist、Gt 或 Lt 等运算符使语言更具表现力,极大地扩展了您可以表达的约束类型。示例 6-4 演示了如何声明节点亲和性。
示例 示例 6-4. 具有节点亲和性的 Pod
apiVersion: v1
kind: Pod
metadata:
name: random-generator
spec:
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution: 
nodeSelectorTerms:
- matchExpressions: 
- key: numberCores
operator: Gt
values: [ "3" ]
preferredDuringSchedulingIgnoredDuringExecution: 
- weight: 1
preference:
matchFields:
- key: metadata.name
operator: NotIn
values: [ "control-plane-node" ]
containers:
- image: k8spatterns/random-generator:1.0
name: random-generator
节点必须具有超过三个核心(由节点标签表示)才能考虑在调度过程中。如果节点上的条件发生变化,则在执行过程中不会重新评估该规则。
匹配标签。在此示例中,匹配所有具有值大于 3 的标签 numberCores 的节点。
软要求,这是带有权重的选择器列表。对于每个节点,计算所有匹配选择器的权重之和,并选择值最高的节点,只要它符合硬性要求。
Pod 亲和性和反亲和性
Pod 亲和性 是一种更强大的调度方式,当 nodeSelector 不够用时应该使用。该机制允许您根据标签或字段匹配来限制 Pod 可以在哪些节点上运行。它不允许您表达 Pod 之间的依赖关系以指导 Pod 应该相对于其他 Pod 放置在哪里。为了表达如何将 Pod 分散以实现高可用性,或者将其打包和共同放置以提高延迟,您可以使用 Pod 亲和性和反亲和性。
节点亲和性在节点粒度上起作用,但 Pod 亲和性不限于节点,并且可以根据节点上已运行的 Pod 在各种拓扑级别上表达规则。使用 topologyKey 字段和匹配标签,可以强制执行更精细的规则,结合节点、机架、云服务提供商区域和区域等领域的规则,如 示例 6-5 所示。
示例 6-5. 具有 Pod 亲和性的 Pod
apiVersion: v1
kind: Pod
metadata:
name: random-generator
spec:
affinity:
podAffinity:
requiredDuringSchedulingIgnoredDuringExecution: 
- labelSelector: 
matchLabels:
confidential: high
topologyKey: security-zone 
podAntiAffinity: 
preferredDuringSchedulingIgnoredDuringExecution: 
- weight: 100
podAffinityTerm:
labelSelector:
matchLabels:
confidential: none
topologyKey: kubernetes.io/hostname
containers:
- image: k8spatterns/random-generator:1.0
name: random-generator
关于目标节点上运行的其他 Pod 的 Pod 放置的必要规则。
标签选择器,用于找到要与之共存的 Pod。
具有标签 confidential=high 的 Pod 所在的节点应该带有 security-zone 标签。此处定义的 Pod 被调度到具有相同标签和值的节点。
反亲和性规则用于找到不应放置 Pod 的节点。
规则描述 Pod 不应(但可以)放置在任何具有标签 confidential=none 的 Pod 的节点上。
类似于节点亲和性,Pod 亲和性和反亲和性也有硬性和软性要求,分别称为 requiredDuringSchedulingIgnoredDuringExecution 和 preferredDuringSchedulingIgnoredDuringExecution。与节点亲和性类似,IgnoredDuringExecution 后缀存在于字段名称中,这是为了未来的可扩展性。目前,如果节点上的标签发生更改并且亲和性规则不再有效,Pod 仍然继续运行,^(1) 但将来,也可能考虑运行时的更改。
拓扑传播约束
Pod 亲和规则允许将无限多的 Pod 放置在单个拓扑中,而 Pod 反亲和性则禁止将 Pod 放置在同一拓扑中。拓扑传播约束使您可以更细粒度地控制如何在集群中均匀分布 Pod,并实现更好的集群利用率或应用程序的高可用性。
让我们看一个例子来理解拓扑传播约束如何帮助解决问题。假设我们有一个具有两个副本和两个节点集群的应用程序。为了避免停机时间和单点故障,我们可以使用 Pod 反亲和规则,防止将 Pod 放置在同一节点上,并将其分布到两个节点上。虽然这种设置有其合理性,但它将阻止您执行滚动升级,因为第三个替换的 Pod 无法由于 Pod 反亲和性约束放置在现有节点上。我们将不得不添加另一个节点或将部署策略从滚动更改为重新创建。在资源不足时,拓扑传播约束将是更好的解决方案,因为它们允许您在集群中的 Pod 分布不均匀时容忍一定程度的不平衡。示例 6-6 允许将第三个滚动部署 Pod 放置在两个节点中的一个,因为它允许不均匀—即一个 Pod 的偏斜。
示例 6-6. 具有拓扑传播约束的 Pod
apiVersion: v1
kind: Pod
metadata:
name: random-generator
labels:
app: bar
spec:
topologySpreadConstraints: 
- maxSkew: 1 
topologyKey: topology.kubernetes.io/zone 
whenUnsatisfiable: DoNotSchedule 
labelSelector: 
matchLabels:
app: bar
containers:
- image: k8spatterns/random-generator:1.0
name: random-generator
Pod 拓扑传播约束在 Pod 规范的 topologySpreadConstraints 字段中定义。
maxSkew 定义了在拓扑中 Pod 可不均匀分布的最大程度。
拓扑域是您基础设施的逻辑单元。topologyKey 是 Node 标签的关键字,其中相同的值被视为属于同一拓扑。
当 maxSkew 无法满足时,whenUnsatisfiable 字段定义了应采取的操作。DoNotSchedule 是阻止调度 Pod 的硬性约束,而 ScheduleAnyway 是软性约束,它会优先调度减少集群不平衡的节点。
labelSelector 匹配此选择器的 Pod 被分组并计数,以满足约束条件。
在本文撰写时,拓扑分布约束是一个正在不断发展的功能。内置的集群级拓扑分布约束允许基于默认的 Kubernetes 标签进行某些不平衡,并使您能够遵守或忽略节点亲和性和污点策略。
污点和宽容性
基于污点和宽容性的高级功能控制 Pod 可以调度和允许运行的位置。而节点亲和性是允许 Pod 选择节点的属性,污点和宽容性则相反。它们允许节点控制哪些 Pod 应该或不应该在其上调度。污点 是节点的特征,当存在时,它会阻止 Pod 在节点上调度,除非 Pod 对该污点具有宽容性。从这个意义上说,污点和宽容性可以被视为一种 选择加入,允许在默认情况下不可用于调度的节点上进行调度,而亲和性规则则是一种 选择退出,通过显式选择在哪些节点上运行来排除所有未选定的节点。
通过使用 kubectl 将污点添加到节点:kubectl taint nodes control-plane-node node-role.kubernetes.io/control-plane="true":NoSchedule,其效果如 示例 6-7 所示。通过在 Pod 中添加匹配的宽容性,如 示例 6-8 所示。请注意,在 示例 6-7 的 taints 部分和 示例 6-8 的 tolerations 部分中,key 和 effect 的值是相同的。
示例 6-7. 带污点的节点
apiVersion: v1
kind: Node
metadata:
name: control-plane-node
spec:
taints: 
- effect: NoSchedule
key: node-role.kubernetes.io/control-plane
value: "true"
标记此节点为不可调度,除非 Pod 对此污点具有宽容性。
示例 6-8. Pod 对节点污点的宽容
apiVersion: v1
kind: Pod
metadata:
name: random-generator
spec:
containers:
- image: k8spatterns/random-generator:1.0
name: random-generator
tolerations:
- key: node-role.kubernetes.io/control-plane 
operator: Exists
effect: NoSchedule 
宽容(即考虑调度)带有关键字node-role.kubernetes.io/control-plane的节点。在生产集群中,此污点设置在控制平面节点上,以防止在该节点上调度 Pod。此类宽容性允许此 Pod 安装在控制平面节点上。
仅当污点指定为NoSchedule时才能容忍。此处该字段可以为空,在这种情况下,宽容性适用于每种效果。
存在硬污点以阻止在节点上调度(effect=NoSchedule),软污点尝试避免在节点上调度(effect=PreferNoSchedule),以及可以从节点上驱逐已运行的 Pods 的污点(effect=NoExecute)。
污点和宽容性允许处理复杂的用例,例如专用节点供一组特定的 Pods 使用,或通过对这些节点进行污点处理来强制驱逐问题节点上的 Pods。
您可以根据应用程序的高可用性和性能需求影响其位置,但尽量不要限制调度程序太多,并使自己陷入无法调度更多 Pods 和有太多搁置资源的境地。例如,如果您的容器资源需求过于粗粒度,或者节点太小,则可能导致节点中存在未使用的搁置资源。
在图 6-2 中,我们可以看到节点 A 有 4GB 的内存,因为没有剩余的 CPU 可用于放置其他容器,所以无法利用。创建具有较小资源需求的容器可能有助于改善这种情况。另一个解决方案是使用 Kubernetes 的descheduler,它有助于整理节点并提高其利用率。

图 6-2。安排到节点和搁置资源的进程
一旦 Pod 分配给节点,调度程序的工作就完成了,除非删除并重新创建没有节点分配的 Pod,否则不会更改 Pod 的位置。正如您所见,随着时间的推移,这可能导致资源碎片化和集群资源的利用不足。另一个潜在问题是调度器的决策是基于其在调度新 Pod 时的集群视图。如果集群是动态的,并且节点的资源配置文件发生变化或添加了新节点,则调度程序不会纠正其先前的 Pod 位置。除了更改节点容量外,您还可以更改影响位置的节点标签,但不会纠正过去的位置。
所有这些情况都可以通过调度程序来解决。 Kubernetes 调度程序是一个可选功能,通常作为作业运行,当集群管理员决定是整理和碎片整理集群的好时机时,重新安排 Pods。调度程序配备了一些预定义的策略,可以启用、调整或禁用。
无论使用哪种策略,调度程序避免驱逐以下内容:
-
节点或集群关键的 Pods
-
不受 ReplicaSet、Deployment 或 Job 管理的 Pods,因为这些 Pods 无法重新创建
-
由 DaemonSet 管理的 Pods
-
带有本地存储的 Pods
-
具有 PodDisruptionBudget 的 Pods,驱逐将违反其规则
-
带有非空
DeletionTimestamp字段设置的 Pods -
取消调度 Pod 本身(通过将自身标记为关键 Pod 实现)
当然,所有驱逐操作都会尊重 Pods 的 QoS 级别,首先选择Best-Efforts Pods,然后是Burstable Pods,最后是Guaranteed Pods 作为驱逐的候选对象。有关这些 QoS 级别的详细解释,请参阅第二章,“可预测的需求”。
讨论
放置是将 Pod 分配给节点的艺术。您希望尽可能少地干预,因为多个配置的组合很难预测。在更简单的场景中,根据资源约束调度 Pod 应该是足够的。如果您遵循《第二章,“可预测的需求”》的指南,声明容器的所有资源需求,调度器将完成其工作,并将 Pod 放置在可能性最大的节点上。
然而,在更现实的场景中,您可能希望根据其他约束条件(如数据局部性、Pod 位置关联性、应用程序高可用性和有效的集群资源利用率)将 Pod 安排到特定的节点上。在这些情况下,有多种方法可以引导调度器朝向所需的部署拓扑进行调度。
图 6-3 展示了 Kubernetes 中不同调度技术的一种思考和理解方法。

图 6-3. Pod 到 Pod 和 Pod 到节点的依赖关系
从识别 Pod 与节点之间的力量和依赖关系开始(例如,基于专用硬件能力或有效资源利用率)。使用以下节点亲和性技术将 Pod 定向到所需的节点,或者使用反亲和性技术将 Pod 从不希望的节点中移开:
nodeName
这个字段提供了将 Pod 硬编码到节点的最简单形式。这个字段应该由调度器理想地填充,调度器是通过策略驱动而不是手动节点分配。通过这种方法将 Pod 分配到节点可以防止将 Pod 调度到任何其他节点。如果指定的节点没有容量,或者节点不存在,Pod 将永远不会运行。这将我们带回到 Kubernetes 之前的时代,当我们明确需要指定运行应用程序的节点时。手动设置此字段不是 Kubernetes 的最佳实践,应仅作为例外使用。
nodeSelector
节点选择器是一个标签映射。为了使 Pod 有资格在节点上运行,Pod 必须具有节点上指定的键值对作为标签。在 Pod 和节点上放置一些有意义的标签(无论如何都应该这样做),节点选择器是控制调度器选择的最简单的推荐机制之一。
节点亲和性
此规则改进了手动节点分配方法,并允许 Pod 使用逻辑运算符和约束表达对节点的依赖性,提供细粒度的控制。它还提供了控制节点亲和性约束严格程度的软和硬调度要求。
Taints 和 tolerations
Taints 和 tolerations 允许节点控制应该或不应该调度到它们上的 Pod,而无需修改现有的 Pod。默认情况下,没有为节点污点设置 tolerations 的 Pod 将被拒绝或从节点中驱逐。Taints 和 tolerations 的另一个优点是,如果通过添加具有新标签的新节点来扩展 Kubernetes 集群,你不需要在所有 Pod 上添加新标签,而只需要在应该放置在新节点上的 Pod 上添加标签。
一旦在 Kubernetes 术语中表达了 Pod 与节点之间的期望关联,就可以识别不同 Pod 之间的依赖关系。使用 Pod 亲和性技术来将紧密耦合的应用程序放置在同一节点上以进行 Pod 集中,使用 Pod 反亲和性技术来将 Pod 分布在节点上,以避免单点故障:
Pod 亲和性和反亲和性
这些规则允许基于 Pod 对其他 Pod 的依赖而不是节点进行调度。亲和性规则有助于在低延迟和数据局部性需求的情况下,在同一拓扑上放置由多个 Pod 组成的紧密耦合应用程序堆栈。另一方面,反亲和性规则可以在集群中不同故障域之间分布 Pod,以避免单点故障,或者通过避免将资源密集型 Pod 放置在同一节点上来防止竞争资源。
拓扑分布约束
要使用这些功能,平台管理员必须为节点打标签,并提供拓扑信息,如区域、区域或其他用户定义的域。然后,创建 Pod 配置的工作负载作者必须了解底层集群拓扑,并指定拓扑分布约束。您还可以指定多个拓扑分布约束,但必须满足所有约束才能放置 Pod。您必须确保它们不会彼此冲突。您还可以将此功能与 NodeAffinity 和 NodeSelector 结合使用,以过滤需要应用均匀性的节点。在这种情况下,务必理解其差异:多个拓扑分布约束是独立计算结果集并生成 AND 连接的结果,而与 NodeAffinity 和 NodeSelector 结合使用则过滤节点约束的结果。
在某些情况下,所有这些调度配置可能不足以表达定制的调度需求。在这种情况下,您可能需要定制和调整调度器配置,甚至提供一个能够理解您自定义需求的自定义调度器实现。
调度器调优
默认调度器负责将新的 Pod 放置到集群中的节点上,并且执行得很好。然而,可以修改一个或多个阶段在过滤和优先级排序阶段中的操作。这种具有扩展点和插件的机制专门设计用于允许进行小的修改,而无需完全实现一个新的调度器。
自定义调度器
如果前述方法都不够理想,或者您有复杂的调度需求,您也可以编写自己的自定义调度器。自定义调度器可以代替或与标准 Kubernetes 调度器并行运行。一个混合方法是使用“调度器扩展器”进程,这个进程在进行调度决策的最后阶段被标准 Kubernetes 调度器调用。这样一来,您不必实现一个完整的调度器,只需提供 HTTP API 来过滤和优先考虑节点。拥有自己的调度器的优势在于,您可以考虑 Kubernetes 集群之外的因素,如硬件成本、网络延迟以及在分配 Pods 到节点时的更好利用率。您还可以在默认调度器旁边使用多个自定义调度器,并配置每个 Pod 使用哪个调度器。每个调度器可以针对一组 Pods 使用不同的策略。
总结一下,有许多方法可以控制 Pod 的放置方式,选择合适的方法或者结合多种方法可能会让人感到不知所措。本章的要点是:定义并声明容器资源配置文件,并为了最佳资源消耗驱动调度结果对 Pods 和节点进行标记。如果这些方法不能实现预期的调度结果,可以从小的迭代变化开始尝试。努力实现对 Kubernetes 调度器的最小策略影响,以表达节点依赖性和 Pod 之间的依赖关系。
更多信息
^(1) 然而,如果节点标签改变,并允许未调度的 Pods 匹配它们的节点亲和选择器,这些 Pods 将被调度到该节点上。
第二部分:行为模式
该类别中的模式专注于 Pod 之间的通信和交互与管理平台之间的交互。根据使用的管理控制器类型,Pod 可能会一直运行直至完成,或者被调度定期运行。它可以作为守护进程运行,或确保其副本的唯一性保证。在 Kubernetes 上运行 Pod 的不同方式,选择正确的 Pod 管理原语需要理解它们的行为。在接下来的章节中,我们探讨以下模式:
-
第七章,“批处理作业”,描述了如何隔离一个原子工作单元并运行直至完成。
-
第八章,“周期性作业”,允许根据时间事件触发执行工作单元。
-
第九章,“守护进程服务”,允许您在应用程序 Pod 放置之前,在特定节点上运行以基础设施为焦点的 Pod。
-
第十章,“单例服务”,确保同一时间只有一个服务实例处于活动状态,同时保持高可用性。
-
第十一章,“无状态服务”,描述了管理相同应用程序实例所使用的基本组件。
-
第十二章,“有状态服务”,讲述了如何使用 Kubernetes 创建和管理分布式有状态应用程序。
-
第十三章,“服务发现”,解释了客户端服务如何发现和消费提供服务的实例。
-
第十四章,“自我感知”,描述了向应用程序注入自省和元数据的机制。
第七章:批处理作业
批处理作业模式适用于管理隔离的原子工作单元。它基于 Job 资源,在分布式环境中可靠地运行短暂的 Pods 直到完成。
问题
Kubernetes 中用于管理和运行容器的主要原语是 Pod。有多种创建 Pods 的方式,具有不同的特性:
裸露 Pod
可以手动创建一个 Pod 来运行容器。但是,当运行此类 Pod 的节点发生故障时,不会重新启动该 Pod。除了开发或测试目的外,不鼓励以这种方式运行 Pods。此机制也被称为非托管或裸露 Pods。
ReplicaSet
此控制器用于创建和管理预期持续运行的 Pods(例如,运行 Web 服务器容器)。它在任何给定时间保持一组稳定的副本 Pods 并保证一定数量的相同 Pods 的可用性。详细介绍 ReplicaSets 见第十一章,“无状态服务”。
DaemonSet
此控制器在每个节点上运行一个单独的 Pod,并用于管理平台功能,如监控、日志聚合、存储容器等。详细讨论见第九章,“守护进程服务”。
这些 Pods 的一个共同特点是它们代表长时间运行的进程,不打算在一定时间后停止。然而,在某些情况下,需要可靠地执行预定义的有限工作单元,然后关闭容器。为此任务,Kubernetes 提供了 Job 资源。
解决方案
Kubernetes Job 类似于 ReplicaSet,因为它创建一个或多个 Pods 并确保它们成功运行。然而,不同之处在于,一旦期望数量的 Pods 成功终止,Job 被视为完成,不会启动额外的 Pods。一个 Job 的定义看起来像示例 7-1。
示例 7-1. Job 规范
apiVersion: batch/v1
kind: Job
metadata:
name: random-generator
spec:
completions: 5 
parallelism: 2 
ttlSecondsAfterFinished: 300 
template:
metadata:
name: random-generator
spec:
restartPolicy: OnFailure 
containers:
- image: k8spatterns/random-generator:1.0
name: random-generator
command: [ "java", "RandomRunner", "/numbers.txt", "10000" ]
Job 应该运行五个 Pods 完成,所有 Pods 必须成功。
两个 Pod 可以并行运行。
在回收之前,保留 Pods 五分钟(300 秒)。
对于 Job,指定 restartPolicy 是强制的。可能的值为 OnFailure 或 Never。
Job 和 ReplicaSet 定义之间的一个关键区别是 .spec.template.spec.restartPolicy。ReplicaSet 的默认值是 Always,适合必须一直运行的长时间进程。对于 Job,Always 不允许使用,唯一可能的选项是 OnFailure 或 Never。
那么,为什么要费心创建一个仅运行一次的 Job 而不使用裸露 Pods?使用 Jobs 提供了许多可靠性和可扩展性的优势,使它们成为首选选项:
-
A Job is not an ephemeral in-memory task but a persisted one that survives cluster restarts.
-
When a Job is completed, it is not deleted but is kept for tracking purposes. The Pods that are created as part of the Job are also not deleted but are available for examination (e.g., to check the container logs). This is also true for bare Pods but only for
restartPolicy: OnFailure. You can still remove the Pods of a Job after a certain time by specifying.spec.ttlSecondsAfterFinished. -
A Job may need to be performed multiple times. Using the
.spec.completionsfield, it is possible to specify how many times a Pod should complete successfully before the Job itself is done. -
When a Job has to be completed multiple times, it can also be scaled and executed by starting multiple Pods at the same time. That can be done by specifying the
.spec.parallelismfield. -
A Job can be suspended by setting the field
.spec.suspendtotrue. In this case, all active Pods are deleted and restarted if the Job is resumed (i.e.,.spec.suspendset tofalseby the user). -
If the node fails or when the Pod is evicted for some reason while still running, the scheduler places the Pod on a new healthy node and reruns it. Bare Pods would remain in a failed state as existing Pods are never moved to other nodes.
All of this makes the Job primitive attractive for scenarios requiring some guarantees for the completion of a unit of work.
The following two fields play major roles in the behavior of a Job:
.spec.completions
Specifies how many Pods should run to complete a Job.
.spec.parallelism
Specifies how many Pod replicas could run in parallel. Setting a high number does not guarantee a high level of parallelism, and the actual number of Pods may still be fewer (and in some corner cases, more) than the desired number (e.g., because of throttling, resource quotas, not enough completions left, and other reasons). Setting this field to 0 effectively pauses the Job.
图 7-1 显示了在 示例 7-1 中定义的具有完成计数为 5 和并行性为 2 的作业的处理过程。

图 7-1. 具有固定完成计数的并行批处理作业
Based on these two parameters, there are the following types of Jobs:
Single Pod Jobs
This type is selected when you leave out both .spec.completions and .spec.parallelism or set them to their default values of 1. Such a Job starts only one Pod and is completed as soon as the single Pod terminates successfully (with exit code 0).
Fixed completion count Jobs
对于固定完成计数的作业,应将.spec.completions设置为所需的完成数。您可以设置.spec.parallelism,或者将其未设置,它将默认为 1。这种作业在.spec.completions数量的 Pod 成功完成后被视为已完成。示例 7-1 展示了这种模式的实际操作,当我们预先知道工作项的数量并且单个工作项的处理成本足以使用专用 Pod 时,这是最佳选择。
工作队列作业
对于工作队列作业,需要将.spec.completions设置为未设置,并将.spec.parallelism设置为大于 1 的数字。当至少一个 Pod 成功终止且所有其他 Pod 也终止时,工作队列作业被视为已完成。这种设置要求 Pod 之间进行协调,并确定每个 Pod 正在处理的内容,以便它们可以协调完成。例如,当队列中存储了一个固定但未知数量的工作项时,并行的 Pod 可以逐个获取这些工作项来处理。首个检测到队列为空并成功退出的 Pod 表明作业已完成。作业控制器等待所有其他 Pod 也终止。由于一个 Pod 处理多个工作项,这种作业类型非常适合细粒度的工作项,即当一个 Pod 处理一个工作项的开销不合理时。
索引作业
类似于工作队列作业,您可以将工作项分配给单独的作业,而无需外部工作队列。当使用固定完成计数并将完成模式.spec.completionMode设置为Indexed时,作业的每个 Pod 都会获得一个从 0 到.spec.completions - 1的关联索引。分配的索引可通过 Pod 注释batch.kubernetes.io/job-completion-index(请参阅第十四章,“自我意识”,了解如何从代码中访问此注释)或直接通过环境变量JOB_COMPLETION_INDEX获取,该变量设置为与此 Pod 关联的索引。有了这个索引,应用程序可以选择关联的工作项而无需任何外部同步。示例 7-2 展示了一个根据作业索引逐个处理单个文件行的作业。更实际的例子是,使用索引作业进行视频处理,其中并行的 Pod 正在处理从索引计算出的某个帧范围。
示例 7-2. 一个根据作业索引选择其工作项的索引作业
apiVersion: batch/v1
kind: Job
metadata:
name: file-split
spec:
completionMode: Indexed 
completions: 5 
parallelism: 5
template:
metadata:
name: file-split
spec:
containers:
- image: alpine
name: split
command: 
- "sh"
- "-c"
- |
start=$(expr $JOB_COMPLETION_INDEX \* 10000) 
end=$(expr $JOB_COMPLETION_INDEX \* 10000 + 10000)
awk "NR>=$start && NR<$end" /logs/random.log \ 
> /logs/random-$JOB_COMPLETION_INDEX.txt
volumeMounts:
- mountPath: /logs 
name: log-volume
restartPolicy: OnFailure
启用索引完成模式。
并行运行五个 Pod 以完成。
执行一个打印给定文件/logs/random.log中一系列行的 Shell 脚本。预计该文件包含 50,000 行数据。
计算起始和结束行号。
使用 awk 打印一系列行号(NR 是在文件迭代过程中 awk 的内部行号)。
挂载来自外部卷的输入数据。这里没有显示卷;您可以在示例存储库中找到完整的工作定义。
如果您有一个无限流的工作项需要处理,其他控制器如 ReplicaSet 更适合管理处理这些工作项的 Pods。
讨论
Job 抽象是一个相当基础但也是基本的原语,其他原语如 CronJobs 就是基于它的。Jobs 帮助将孤立的工作单元转变为可靠且可扩展的执行单元。但是,Job 并不规定您应该如何将可单独处理的工作项映射到 Jobs 或 Pods 中。这是您在考虑每个选项的利弊后需要确定的事项:
每个工作项一个 Job
这个选项需要创建 Kubernetes Jobs 的开销,同时意味着平台必须管理大量消耗资源的 Jobs。当每个工作项都是必须记录、跟踪或独立扩展的复杂任务时,这个选项非常有用。
一个工作项适合所有工作
对于不需要由平台独立跟踪和管理的大量工作项,这个选项是正确的。在这种情况下,必须通过批处理框架从应用程序内部管理工作项。
Job 原语仅提供调度工作项的最基本功能。任何复杂的实现都必须将 Job 原语与批处理应用程序框架(例如,在 Java 生态系统中,我们有 Spring Batch 和 JBeret 作为标准实现)结合起来,以实现期望的结果。
并非所有服务都必须全天候运行。一些服务必须按需运行,一些在特定时间运行,一些定期运行。使用 Jobs 可以在需要时运行 Pods,并且只在任务执行期间运行。Jobs 被调度到具有所需容量的节点上,满足 Pod 放置策略,并考虑其他容器依赖性考虑因素。对于短暂任务使用 Jobs 而不是使用长时间运行的抽象(例如 ReplicaSet),可以为平台上的其他工作负载节省资源。所有这些使得 Jobs 成为一个独特的基元,而 Kubernetes 则支持多样化的工作负载。
更多信息
第八章:周期性作业
周期性作业模式通过添加时间维度扩展了批处理作业模式,并允许通过时间事件触发执行工作单元。
问题
在分布式系统和微服务的世界中,有一个明显的趋势,即使用 HTTP 和轻量级消息传递进行实时和事件驱动的应用程序交互。然而,无论软件开发的最新趋势如何,作业调度都有着悠久的历史,而且仍然很重要。周期性作业通常用于自动化系统维护或管理任务。它们还与需要定期执行特定任务的业务应用程序相关联。这里的典型例子包括通过文件传输进行业务-to-业务集成,通过数据库轮询进行应用程序集成,发送新闻通讯邮件以及清理和归档旧文件。
处理系统维护目的的周期性作业的传统方式是使用专门的调度软件或 cron。然而,对于简单用例,专用软件可能昂贵,而在单个服务器上运行的 cron 作业难以维护并且代表单点故障。这就是为什么开发人员经常倾向于实现既能处理调度方面又能执行需要执行的业务逻辑的解决方案。例如,在 Java 世界中,像 Quartz、Spring Batch 以及使用 ScheduledThreadPoolExecutor 类的自定义实现可以运行时间任务。但与 cron 类似,这种方法的主要困难在于使调度能力具有弹性和高可用性,这导致资源消耗很高。此外,使用这种方法,基于时间的作业调度程序是应用程序的一部分,并且为了使调度程序高度可用,整个应用程序必须高度可用。通常,这涉及运行应用程序的多个实例,并同时确保只有一个实例处于活动状态并调度作业,这涉及领导选举和其他分布式系统挑战。
最终,一个简单的服务每天只需复制几个文件可能最终需要多个节点、分布式领导选举机制等。Kubernetes CronJob 实现通过允许使用众所周知的 cron 格式调度作业资源来解决了所有这些问题,并且让开发人员只需关注实现要执行的工作,而不是时间调度方面。
解决方案
在第七章,“批处理作业”中,我们看到了 Kubernetes Jobs 的用例和能力。所有这些也适用于本章,因为 CronJob 原语建立在 Job 之上。CronJob 实例类似于 Unix crontab(cron 表)的一行,并管理作业的时间方面。它允许在指定时间周期性地执行作业。参见示例 8-1 以查看样本定义。
示例 8-1. CronJob 资源
apiVersion: batch/v1
kind: CronJob
metadata:
name: random-generator
spec:
schedule: "*/3 * * * *" 
jobTemplate:
spec:
template: 
spec:
containers:
- image: k8spatterns/random-generator:1.0
name: random-generator
command: [ "java", "RandomRunner", "/numbers.txt", "10000" ]
restartPolicy: OnFailure
每三分钟运行一次的 Cron 表达式。
使用与常规作业相同的规范的作业模板。
除了作业规范外,CronJob 还有额外的字段来定义其时间特性:
.spec.schedule
用于指定作业计划的 crontab 条目(例如,0 * * * * 表示每小时运行一次)。您还可以使用 @daily 或 @hourly 等快捷方式。请参阅 CronJob 文档 获取所有可用选项。
.spec.startingDeadlineSeconds
如果错过了计划的执行时间,启动作业的截止时间(以秒计)。在某些用例中,任务只有在特定时间内执行才有效,而且如果执行延迟,可能会由于计算资源不足或其他依赖关系缺失而导致任务无法按时执行。例如,如果由于缺乏计算资源或其他缺失的依赖关系而未能在期望的时间内执行作业,则最好跳过执行,因为其应处理的数据已经过时。不要使用少于 10 秒的截止时间,因为 Kubernetes 每 10 秒才会检查一次作业状态。
.spec.concurrencyPolicy
指定如何管理由同一 CronJob 创建的作业的并发执行。默认行为 Allow 即使前一个作业尚未完成也会创建新的作业实例。如果不希望出现这种情况,可以选择如果当前作业尚未完成,则跳过下次运行 Forbid 或取消当前正在运行的作业并启动新作业 Replace。
.spec.suspend
暂停所有后续执行而不影响已启动的执行。请注意,这与作业的 .spec.suspend 不同,因为它会暂停新作业的启动,而不是作业本身。
.spec.successfulJobsHistoryLimit 和 .spec.failedJobsHistoryLimit
定义如何保留完成和失败作业以进行审核目的的字段。
CronJob 是一种非常专门化的原语,仅在工作单元具有时间维度时适用。即使 CronJob 不是通用的原语,它也是 Kubernetes 能力如何相互构建并支持非云本地使用案例的一个很好的例子。
讨论
正如你所见,CronJob 是一个相当简单的原语,为现有的作业定义添加了集群化、类似 cron 的行为。但是,当它与其他原语如 Pod、容器资源隔离以及 Kubernetes 的其他特性(比如第六章,“自动放置”或第四章,“健康探针”中描述的特性)结合使用时,它就变成了一个非常强大的作业调度系统。这使得开发人员能够专注于问题领域,并实现一个仅负责执行业务逻辑的容器化应用程序。调度是在应用程序之外完成的,作为平台的一部分,具有其所带来的所有附加好处,如高可用性、弹性、容量和基于策略的 Pod 放置。当然,类似于作业实现时,当实现一个 CronJob 容器时,你的应用程序必须考虑重复运行、无运行、并行运行或取消的所有边界和失败情况。
更多信息
第九章:Daemon Service
Daemon Service 模式允许您在目标节点上放置和运行优先级高、基础设施相关的 Pods。主要由管理员使用,以增强 Kubernetes 平台的能力。
问题
在软件系统中,daemon 的概念存在于多个级别。在操作系统级别,daemon 是一个长时间运行的、自我恢复的计算机程序,作为后台进程运行。在 Unix 系统中,daemon 的名称以 d 结尾,如 httpd、named 和 sshd。在其他操作系统中,使用替代术语如 services-started tasks 和 ghost jobs。
不管这些程序被称为什么,它们的共同特征是作为进程运行,并且通常不与监视器、键盘和鼠标交互,并在系统启动时启动。在应用程序级别也存在类似的概念。例如,在 Java 虚拟机中,daemon 线程在后台运行,并为用户线程提供支持服务。这些 daemon 线程优先级低,后台运行,并且在应用程序生命周期中没有参与,并执行垃圾回收或 finalization 等任务。
同样地,Kubernetes 也有 DaemonSet 的概念。考虑到 Kubernetes 是一个跨多个节点分布的分布式平台,并且其主要目标是管理应用程序 Pods,DaemonSet 由在集群节点上运行的 Pods 表示,并为集群的其余部分提供一些后台功能。
解决方案
ReplicaSet 及其前身 ReplicationController 是控制结构,负责确保特定数量的 Pods 正在运行。这些控制器不断监视运行中的 Pods 列表,并确保实际的 Pods 数量始终与期望的数量匹配。在这方面,DaemonSet 是一个类似的结构,负责确保一定数量的 Pods 始终在运行。不同之处在于前两者运行特定数量的 Pods,通常由应用程序要求驱动,用于高可用性和用户负载,而不考虑节点数。
另一方面,DaemonSet 不受消费者负载的驱动,来决定运行多少个 Pod 实例以及在哪里运行。它的主要目的是在每个节点或特定节点上保持运行一个单独的 Pod。接下来我们看一个如下所示的 Example 9-1 中的 DaemonSet 定义。
Example 9-1. DaemonSet 资源
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: random-refresher
spec:
selector:
matchLabels:
app: random-refresher
template:
metadata:
labels:
app: random-refresher
spec:
nodeSelector: 
feature: hw-rng
containers:
- image: k8spatterns/random-generator:1.0
name: random-generator
command: [ "java", "RandomRunner", "/numbers.txt", "10000", "30" ]
volumeMounts: 
- mountPath: /host_dev
name: devices
volumes:
- name: devices
hostPath: 
path: /dev
仅使用标签 feature 设置为值 hw-rng 的节点。
DaemonSets 通常会挂载节点文件系统的一部分,以执行维护操作。
用于直接访问节点目录的 hostPath。
鉴于这种行为,守护进程集的主要候选对象通常是基础架构相关的进程,例如集群存储提供者、日志收集器、度量导出器,甚至 kube-proxy,这些进程执行集群范围的操作。守护进程集和副本集管理方式有很多不同之处,但主要的区别如下:
-
默认情况下,守护进程集在每个节点上放置一个 Pod 实例。可以通过使用
nodeSelector或affinity字段来控制和限制放置在节点子集上的 Pod。 -
守护进程集创建的 Pod 已经指定了
nodeName。因此,守护进程集无需存在 Kubernetes 调度器来运行容器。这也允许您使用守护进程集来运行和管理 Kubernetes 组件。 -
守护进程集创建的 Pod 可以在调度器启动之前运行,这使它们能够在任何其他 Pod 被放置到节点上之前运行。
-
由于调度器未被使用,节点的
unschedulable字段不会被守护进程集控制器所尊重。 -
由守护进程集创建的 Pod 的
RestartPolicy只能设置为Always或者不指定,默认为Always。这是为了确保当存活探针失败时,容器将被终止并始终重新启动。 -
守护进程集管理的 Pod 只能在目标节点上运行,因此在许多控制器中被视为具有较高优先级。例如,去调度器将避免驱逐这些 Pod,集群自动伸缩器将单独管理它们,等等。
守护进程集的主要用例是在集群中某些节点上运行系统关键的 Pod。守护进程集控制器通过直接将 Pod 分配给节点的方式,通过设置 Pod 规范的nodeName字段,确保所有符合条件的节点运行 Pod 的副本。这允许守护进程集的 Pod 在默认调度器启动之前就能够被调度,并且使其免受用户配置的任何调度器自定义的影响。只要节点上有足够的资源并且在放置其他 Pod 之前完成了,这种方法就能够正常工作。当节点资源不足时,守护进程集控制器无法为该节点创建 Pod,并且无法进行任何释放节点资源的操作,如抢占。守护进程集控制器和调度器中调度逻辑的重复创建了维护挑战。守护进程集的实现也无法从新调度器的新特性(如亲和性、反亲和性和抢占)中获益。因此,从 Kubernetes v1.17 及更新版本开始,守护进程集通过设置nodeAffinity字段而非nodeName字段来为守护进程集的 Pod 进行调度,使用默认调度器进行调度成为运行守护进程集的必备依赖项,但同时也将污点、容忍性、Pod 优先级和抢占引入守护进程集,并在资源匮乏时改善在所需节点上运行守护进程集 Pod 的整体体验。
通常,DaemonSet 在每个节点或节点子集上创建一个单独的 Pod。基于此,有几种方法可以访问由 DaemonSet 管理的 Pods:
服务
创建一个带有与 DaemonSet 相同的 Pod 选择器的服务,并使用该服务来访问通过随机节点进行负载均衡的守护 Pod。
DNS
创建一个无头服务,其 Pod 选择器与 DaemonSet 相同,可用于从 DNS 中检索包含所有 Pod IP 和端口的多个 A 记录。
使用 hostPort 的节点 IP
DaemonSet 中的 Pods 可以指定 hostPort 并通过节点 IP 地址和指定的端口可达。由于节点 IP、hostPort 和 protocol 的组合必须是唯一的,可以安排 Pod 的位置有限。
此外,DaemonSets Pods 中的应用可以将数据推送到已知位置或外部服务,不需要消费者直接访问 DaemonSets Pods。
静态 Pods 可用于启动 Kubernetes 系统进程或其他容器的容器化版本。然而,与静态 Pods 相比,DaemonSets 与平台的其余部分更好地集成,并推荐使用它们。
讨论
使用其他方法在每个节点上运行守护进程的方式,但它们都有局限性。静态 Pod 由 Kubelet 管理,但无法通过 Kubernetes API 进行管理。裸 Pod(没有控制器的 Pod)如果意外删除或终止,将无法生存,也无法在节点故障或节点维护中生存。诸如 upstartd 或 systemd 的初始化脚本需要不同的工具链进行监控和管理,并且无法从用于应用工作负载的 Kubernetes 工具中获益。所有这些都使得 Kubernetes 和 DaemonSet 成为运行守护进程过程的一个吸引人选项。
在本书中,我们描述的模式和 Kubernetes 特性主要由开发人员使用,而不是平台管理员。DaemonSet 处于中间位置,更倾向于管理员工具箱,但我们在这里包括它,因为它也与应用开发人员相关。DaemonSets 和 CronJobs 也是 Kubernetes 将单节点概念(如 crontab 和守护脚本)转换为用于管理分布式系统的多节点集群基元的完美示例。这些是开发人员必须熟悉的新分布式概念。
更多信息
第十章:单例服务
单例服务模式确保在任何时候只有一个应用实例处于活动状态,并且具有高可用性。该模式可以从应用程序内部实现,也可以完全委托给 Kubernetes。
问题
Kubernetes 提供的主要功能之一是轻松和透明地扩展应用程序。Pod 可以通过诸如 kubectl scale 这样的单个命令或者通过像 ReplicaSet 这样的控制器定义声明式地扩展,甚至可以根据应用程序负载动态扩展,正如我们在第二十九章 “弹性扩展”中描述的那样。通过运行多个相同服务的实例(不是 Kubernetes Service,而是由 Pod 表示的分布式应用程序的组件),系统通常会提高吞吐量和可用性。可用性提高是因为如果某个服务实例变得不健康,请求调度器将将未来的请求转发到其他健康的实例。在 Kubernetes 中,多个实例是 Pod 的副本,而 Service 资源负责请求分发和负载均衡。
然而,在某些情况下,只允许同时运行一个服务实例。例如,如果服务中有一个周期性执行的任务,并且有多个相同服务的实例,每个实例都会在预定间隔触发任务,导致重复执行,而不是按预期只触发一个任务。另一个例子是一个服务在特定资源(文件系统或数据库)上执行轮询,我们希望确保只有一个实例甚至一个线程执行轮询和处理。第三种情况发生在我们必须以保持顺序的方式从消息代理中消费消息,并且使用单线程消费者同时也是单例服务。
在所有这些类似情况中,我们需要控制同时处于活动状态的服务实例数量(通常只需要一个),同时确保高可用性,无论启动并保持运行的实例数量有多少。
解决方案
运行多个相同 Pod 的副本创建了一个 活跃-活跃 的拓扑结构,其中所有服务实例都是活跃的。我们需要的是一个 活跃-被动 的拓扑结构,其中只有一个实例是活跃的,其他所有实例都是被动的。从根本上说,可以通过应用外和应用内的锁定在两个可能的层次上实现这一目标。
应用外锁定
正如其名称所示,这种机制依赖于一个管理进程,该进程位于应用程序外部,以确保应用程序只运行单个实例。应用程序本身对此约束并不知情,并且作为单例实例运行。从这个角度来看,它类似于由管理运行时(如 Spring 框架)仅实例化一次的 Java 类。类的实现不知道它是作为单例运行的,也不包含任何代码结构来防止多次实例化。
图 10-1 展示了如何通过 StatefulSet 或 ReplicaSet 控制器实现应用程序外的锁定,其中只有一个副本。

图 10-1. 应用程序外的锁定机制
在 Kubernetes 中实现这一点的方法是启动单个 Pod。仅此活动并不确保单例 Pod 具有高可用性。我们还必须通过诸如 ReplicaSet 的控制器将该单例 Pod 背后的实现转换为高可用单例。这种拓扑结构并非完全是 主备模式(没有备用实例),但效果相同,因为 Kubernetes 确保始终运行一个 Pod 实例。此外,由于控制器执行健康检查(如 第 4 章,“健康探测” 中描述的方式)并在发生故障时修复 Pod,因此单个 Pod 实例具有高可用性。
使用这种方法需要特别关注副本数,不要因意外操作而改变它。在本节中,您将了解到我们如何通过 PodDisruptionBudget 自愿减少副本数,但没有平台级机制可以防止副本数的增加。
并不完全准确地说一直只有一个实例在运行,特别是在出现问题时。Kubernetes 的原语如 ReplicaSet 更倾向于可用性而非一致性——这是实现高可用和可扩展分布式系统的有意决策。这意味着 ReplicaSet 应用的是“至少”而不是“至多”的语义来管理其副本。如果我们将 ReplicaSet 配置为单例模式,即 replicas: 1,控制器会确保始终至少运行一个实例,但偶尔可能会有更多实例存在。
这里最常见的边界情况是,一个具有控制器管理的 Pod 的节点变得不健康并与 Kubernetes 集群的其余部分断开连接。在这种情况下,ReplicaSet 控制器会在健康节点上启动另一个 Pod 实例(假设有足够的容量),而不确保断开连接节点上的 Pod 被关闭。同样地,当更改副本数或将 Pod 重新定位到不同节点时,Pod 数量可能会暂时超过所需数量。这种临时增加是为了确保高可用性并避免中断,这在无状态和可扩展的应用程序中是必要的。
单例(Singleton)虽然可以具备弹性和恢复能力,但从定义上来说,它们并不具备高可用性。单例通常更注重一致性而非可用性。在 Kubernetes 中,同样偏向一致性而提供严格单例保证的资源是 StatefulSet。如果 ReplicaSets 对你的应用程序未提供所需的保证,并且你有严格的单例要求,那么 StatefulSets 可能是答案。StatefulSets 专为有状态应用程序设计,提供许多功能,包括更强的单例保证,但也带来了更高的复杂性。我们在 第十二章,“有状态服务” 中详细讨论了单例的相关问题并深入介绍了 StatefulSets。
通常,在 Kubernetes 上运行的单例应用程序会在 Pod 中向消息代理、关系型数据库、文件服务器或其他运行在其他 Pod 或外部系统上的系统打开出站连接。然而,偶尔你的单例 Pod 可能需要接受传入连接,在 Kubernetes 上启用这一功能的方法是通过 Service 资源。
我们在 第十三章,“服务发现” 中深入探讨了 Kubernetes 服务,但在这里让我们简要讨论适用于单例的部分。一个常规的服务(使用 type: ClusterIP)会创建一个虚拟 IP,并在其选择器匹配的所有 Pod 实例之间进行负载均衡。然而,通过 StatefulSet 管理的单例 Pod 只有一个 Pod 和一个稳定的网络标识。在这种情况下,最好创建一个无头服务(通过设置 type: ClusterIP 和 clusterIP: None)。它被称为无头,因为这样的服务没有虚拟 IP 地址,kube-proxy 不处理这些服务,并且平台不进行代理。
然而,这样的 Service 仍然很有用,因为具有选择器的无头 Service 在 API 服务器中创建端点记录,并为匹配的 Pod(s) 生成 DNS A 记录。因此,对 Service 的 DNS 查找不返回其虚拟 IP,而是返回与后端 Pod(s) 的 IP 地址。这使得可以通过 Service 的 DNS 记录直接访问单例 Pod,而无需通过 Service 的虚拟 IP。例如,如果我们创建一个名为 my-singleton 的无头 Service,则可以使用 my-singleton.default.svc.cluster.local 直接访问 Pod 的 IP 地址。
总之,对于至少需要一个实例的非严格单例,定义一个带有一个副本的 ReplicaSet 就足够了。这种配置有利于可用性,并确保至少有一个可用实例,有时在某些极端情况下可能会有更多。对于具有 At-Most-One 要求和更好性能的严格单例,推荐使用 StatefulSet 和无头 Service。使用 StatefulSet 将有利于一致性,并确保存在一个最多一个实例,有时在某些极端情况下可能一个实例都没有。您可以在第十二章,“Stateful Service”中找到一个完整的示例,需要将副本数更改为一个以使其成为单例。
应用内锁定
在分布式环境中,控制服务实例数量的一种方法是通过分布式锁,如图 10-2 所示。每当服务实例或实例内的组件被激活时,它可以尝试获取锁,如果成功,则服务变为活动状态。任何后续的服务实例如果无法获取锁,则等待并持续尝试获取锁,以防当前活动服务释放锁。
许多现有的分布式框架使用此机制来实现高可用性和韧性。例如,消息代理 Apache ActiveMQ 可以在高可用的主备拓扑结构中运行,其中数据源提供了共享锁。第一个启动的代理实例会获取锁并成为活动实例,任何后续启动的实例则变为被动实例并等待锁释放。这种策略确保只有一个活动的代理实例,并且对故障具有韧性。

图 10-2. 应用内锁定机制
我们可以将这种策略与面向对象世界中的经典 Singleton 进行比较:Singleton是存储在静态类变量中的对象实例。在此示例中,类知道自己是单例,并且编写方式不允许同一进程中实例化多个实例。在分布式系统中,这意味着容器化应用本身必须以一种方式编写,不允许同时存在多个活动实例,无论启动了多少个 Pod 实例。要在分布式环境中实现此目标,首先需要一个分布式锁实现,例如 Apache ZooKeeper、HashiCorp 的 Consul、Redis 或 etcd。
在使用 ZooKeeper 的典型实现中,使用临时节点,只要客户端会话存在,节点就存在,并且在会话结束时删除。第一个启动的服务实例在 ZooKeeper 服务器上启动会话,并创建临时节点以成为活动节点。同一集群中的所有其他服务实例变为被动状态,并等待临时节点被释放。这是基于 ZooKeeper 的实现确保整个集群中只有一个活动服务实例,从而实现主备故障转移行为。
在 Kubernetes 世界中,与其仅管理用于锁定功能的 ZooKeeper 集群,更好的选择是利用通过 Kubernetes API 暴露的 etcd 功能,并在主节点上运行。etcd 是一个分布式键值存储,使用 Raft 协议维护其复制状态,并提供实施领导者选举的必要构建块。例如,Kubernetes 提供了 Lease 对象,用于节点心跳和组件级领导者选举。对于每个节点,都有一个匹配名称的 Lease 对象,并且每个节点上的 Kubelet 通过更新 Lease 对象的renewTime字段来保持心跳运行。Kubernetes 控制平面使用此信息来确定节点的可用性。在高度可用的集群部署场景中,Kubernetes Lease 还用于确保仅有单个控制平面组件(如 kube-controller-manager 和 kube-scheduler)处于活动状态,而其他实例保持待命。
另一个例子是 Apache Camel,在其中具有 Kubernetes 连接器,还提供了领导者选举和单例能力。此连接器更进一步,而不是直接访问 etcd API,而是使用 Kubernetes API 利用 ConfigMaps 作为分布式锁。它依赖于 Kubernetes 对编辑诸如 ConfigMaps 等资源的乐观锁定保证,其中只有一个 Pod 可以一次更新一个 ConfigMap。Camel 实现利用此保证确保只有一个 Camel 路由实例处于活动状态,任何其他实例必须等待并获取锁定才能激活。这是一个锁的自定义实现,但实现了相同的目标:当有多个具有相同 Camel 应用程序的 Pod 时,只有一个成为活动单例,其他的处于被动模式等待。
Dapr 项目提供了更通用的单例服务模式实现。Dapr 的分布式锁构建模块提供了 API(HTTP 和 gRPC),可替换实现以实现对共享资源的互斥访问。其思想是每个应用程序确定锁授予访问权限的资源。然后,同一应用程序的多个实例使用命名锁来独占访问共享资源。在任何时刻,只有一个应用程序实例可以持有命名锁。所有其他应用程序实例无法获取该锁,因此不允许访问共享资源,直到通过解锁或锁超时释放该锁。由于其基于租约的锁定机制,如果应用程序获取锁定,遇到异常并且无法释放锁定,则在一定时间后使用租约自动释放锁定。这在应用程序故障时防止资源死锁。在这个通用的分布式锁 API 背后,Dapr 将配置为使用某种存储和锁实现。应用程序可以使用此 API 实现对共享资源或应用内单例的访问。
使用 Dapr、ZooKeeper、etcd 或任何其他分布式锁实现的实现类似于上述描述的实现:应用程序的一个实例成为领导者并激活自身,而其他实例则处于被动状态并等待锁定。这确保即使启动了多个 Pod 副本并且所有副本都健康、启动并运行,也只有一个服务是活动的并作为单例执行业务功能,其他实例则等待获取锁定,以防领导者失败或关闭。
Pod Disruption Budget
虽然单例服务和领导者选举试图限制同时运行的服务实例的最大数量,但 Kubernetes 的 PodDisruptionBudget 功能提供了一种互补且有些相反的功能——限制同时处于维护状态的实例数量。
在其核心,PodDisruptionBudget 确保在任何时间点不会自愿从节点上驱逐一定数量或百分比的 Pod。这里的“自愿”意味着可以延迟一段时间的驱逐,例如通过为维护或升级(kubectl drain)而触发的驱逐,或者通过集群缩减,而不是节点变得不健康,这是无法预测或控制的。
Example 10-1 中的 PodDisruptionBudget 适用于与其选择器匹配的 Pod,并确保始终有两个 Pod 可用。
示例 10-1. PodDisruptionBudget
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: random-generator-pdb
spec:
selector:
matchLabels: 
app: random-generator
minAvailable: 2 
选择器以计算可用 Pod 的数量。
必须至少有两个 Pod 可用。您还可以指定一个百分比,如 80%,以配置只有匹配 Pod 的 20%可能被驱逐。
除了.spec.minAvailable之外,还有使用.spec.maxUnavailable的选项,该选项指定驱逐后可以不可用的 Pod 数量。与.spec.minAvailable类似,它可以是绝对数量或百分比,但有一些额外的限制。在单个 PodDisruptionBudget 中只能指定.spec.minAvailable或.spec.maxUnavailable之一,然后它只能用于控制具有相关控制器(如 ReplicaSet 或 StatefulSet)的 Pod 的驱逐。对于不由控制器管理的 Pod(也称为裸露或naked Pods),应考虑 PodDisruptionBudget 的其他限制。
PodDisruptionBudget 对于需要始终运行最少副本以确保法定人数的基于法定人数的应用程序非常有用。或者当应用程序提供关键流量时,它永远不应低于总实例数的一定百分比。
在单例的上下文中,PodDisruptionBudget 也很有用。例如,将maxUnavailable设置为0或将minAvailable设置为100%将防止任何自愿驱逐。将工作负载的自愿驱逐设置为零将其转变为不可驱逐的 Pod,并将永远阻止节点的排空。这可以用作在集群操作员必须在意外驱逐非高可用性 Pod 之前与单例工作负载所有者联系停机的过程中的一步。StatefulSet 与 PodDisruptionBudget 以及无头 Service 是在运行时控制和帮助实例计数的 Kubernetes 原语,并且在本章中值得一提。
讨论
如果您的使用场景需要强的单例保证,那么您无法依赖于副本集之外的锁定机制。Kubernetes 副本集旨在保持其 Pod 的可用性,而不是确保 Pod 的最多一次语义。因此,有许多故障场景下会出现短时间内两个 Pod 并发运行(例如,当运行单例 Pod 的节点与集群的其余部分断开连接时——例如,当替换已删除的 Pod 实例时)。如果这不可接受,请使用有状态集,或调查提供您更大控制权的应用内锁定选项,这些选项可以增强领导选举过程的保证。后者还减轻了由于更改副本数导致 Pod 不小心扩展的风险。您可以将其与 Pod 中断预算结合使用,防止自愿驱逐和中断您的单例工作负载。
在其他情况下,仅容器化应用程序的一部分应是单例。例如,有可能是一个提供 HTTP 端点的容器化应用程序,它是安全的,可以扩展为多个实例,但还有一个必须是单例的轮询组件。使用外部锁定方法将阻止整个服务的扩展。在这种情况下,我们要么必须拆分单例组件的部署单元,以保持其为单例(理论上很好,但不总是实际可行或值得付出额外的开销),要么使用应用内锁定机制,只锁定必须是单例的组件。这将允许我们透明地扩展整个应用程序,使 HTTP 端点被扩展,其他部分作为活动-被动的单例。
更多信息
第十一章:无状态服务
无状态服务模式描述了如何创建和操作由相同的短暂副本组成的应用程序。这些应用程序最适合于动态云环境,可以快速扩展并保持高可用性。
问题
微服务架构风格是实现新的云原生应用的主要选择。这种架构的驱动原则之一是如何处理单一关注点,如何管理自己的数据,以及如何有一个良好封装的部署边界等。通常,这类应用还遵循十二因素应用原则,这使它们可以在动态云环境中与 Kubernetes 轻松操作。
应用一些这些原则需要理解业务域、确定服务边界,或在服务实现过程中应用领域驱动设计或类似的方法。实施其他一些原则可能涉及使服务短暂化,这意味着服务可以在没有副作用的情况下创建、扩展和销毁。当服务是无状态而不是有状态时,更容易解决这些后者关注点。
无状态服务在服务间交互时不会在实例内部保留任何状态。在我们的背景下,这意味着如果容器在其内部存储(内存或临时文件系统)中不保存任何来自请求的信息(这些信息对于处理未来请求是关键的),则容器是无状态的。无状态进程不会存储或引用过去请求的任何信息,因此每个请求都像从头开始一样。相反,如果进程需要存储这样的信息,则应将其存储在外部存储中,例如数据库、消息队列、挂载文件系统或其他可以被其他实例访问的数据存储中。一个好的思维实验是想象你的服务实例部署在不同节点上,有一个负载均衡器将请求随机分发给这些实例,而没有任何粘性会话(即客户端和特定服务实例之间没有关联)。如果服务能够在这种设置中完成其目的,它很可能是无状态的服务(或者它有一种状态在实例之间分发的机制,比如数据网格)。
无状态服务由相同、可替换的实例组成,通常将状态转移到外部永久存储系统,并使用负载均衡器在它们之间分配传入请求。在本章中,我们将具体看到哪些 Kubernetes 抽象可以帮助操作这样的无状态应用程序。
解决方案
在第三章,“声明式部署”中,您学习了如何使用部署(Deployment)的概念来控制应用程序如何升级到下一个版本,使用RollingUpdate和Recreate策略。但这只涉及部署的升级方面。从更广泛的角度来看,部署代表了集群中部署的应用程序。Kubernetes 没有Application或Container作为顶级实体的概念。相反,一个应用程序通常由由 ReplicaSet、Deployment 或 StatefulSet 管理的一组 Pod 组成,结合 ConfigMap、Secret、Service、PersistentVolumeClaim 等。用于管理无状态 Pod 的控制器是 ReplicaSet,但它是部署使用的低级内部控制结构。部署是推荐的用户面向抽象化,用于创建和更新无状态应用程序,它在后台创建和管理 ReplicaSets。当部署提供的更新策略不合适,或需要自定义机制,或根本不需要控制更新过程时,应使用 ReplicaSet。
实例
ReplicaSet 的主要目的是确保任何给定时间运行指定数量的相同 Pod 副本。ReplicaSet 定义的主要部分包括副本数指示应该维护多少 Pod、选择器指定如何识别其管理的 Pod,以及用于创建新 Pod 副本的 Pod 模板。然后,ReplicaSet 根据需要创建和删除 Pod 以维护所需的副本数量,使用给定的 Pod 模板,正如示例 11-1 所示。
示例 11-1. 用于无状态 Pod 的 ReplicaSet 定义
apiVersion: apps/v1
kind: ReplicaSet
metadata:
name: rg
labels:
app: random-generator
spec:
replicas: 3 
selector: 
matchLabels:
app: random-generator
template: 
metadata:
labels:
app: random-generator
spec:
containers:
- name: random-generator
image: k8spatterns/random-generator:1.0
维护运行的 Pod 副本的期望数量。
用于识别要管理的 Pod 的标签选择器。
模板指定用于创建新 Pod 的数据。
当 ReplicaSet 需要创建新的 Pod 以满足所需的副本数时,将使用该模板。但 ReplicaSet 并不局限于管理模板中指定的 Pod。如果一个裸 Pod 没有所有者引用(意味着它不由控制器管理),并且它与标签选择器匹配,那么它将通过设置所有者引用被获取,并由 ReplicaSet 管理。这种设置可能导致 ReplicaSet 拥有由不同方式创建的非相同的一组 Pod,并终止超过声明的副本计数的现有裸 Pod。为了避免这种不良副作用,建议确保裸 Pod 不具有与 ReplicaSet 选择器匹配的标签。
无论您是直接创建 ReplicaSet 还是通过部署,最终结果都是创建并维护所需数量的相同 Pod 副本。使用部署的附加好处是,我们可以控制副本如何升级和回滚,我们在第三章,“声明式部署”中详细描述了这一点。接下来,根据我们在第六章,“自动化部署”中介绍的策略,将副本调度到可用节点。ReplicaSet 的工作是在需要时重新启动容器,并在副本数量增加或减少时扩展或缩减。通过这种行为,部署和 ReplicaSet 可以自动化无状态应用程序的生命周期管理。
网络
由 ReplicaSet 创建的 Pod 是暂时的,可能随时消失,例如当 Pod 因资源匮乏而被驱逐或因运行 Pod 的节点失败时。在这种情况下,ReplicaSet 将创建一个新的 Pod,该 Pod 将具有新的名称、主机名和 IP 地址。如果应用程序是无状态的,正如我们在本章前面所定义的,新请求应该像处理任何其他 Pod 一样处理从新创建的 Pod 进来。
根据容器内的应用程序如何连接到其他系统以接受请求或轮询消息的方式,例如,您可能需要一个 Kubernetes 服务。如果应用程序正在启动到消息代理或数据库的出站连接,并且这是它交换数据的唯一方式,那么就不需要 Kubernetes 服务。但更常见的是,无状态服务通过同步请求/响应驱动的协议(如 HTTP 和 gRPC)被其他服务联系。由于 Pod IP 地址在每次 Pod 重启时都会更改,因此最好使用基于 Kubernetes 服务的固定 IP 地址,以便服务使用者可以使用。Kubernetes 服务具有在服务生命周期内不变的固定 IP 地址,并确保客户端请求始终在实例之间进行负载均衡,并路由到健康且准备好接受请求的 Pod。我们在第十三章,“服务发现”中涵盖了不同类型的 Kubernetes 服务。在示例 11-2 中,我们使用一个简单的服务在集群内部向其他 Pod 公开 Pod。
示例 11-2. 暴露一个无状态服务
apiVersion: v1
kind: Service
metadata:
name: random-generator 
spec:
selector: 
app: random-generator
ports:
- port: 80
targetPort: 8080
protocol: TCP
可用于访问匹配 Pod 的服务名称。
从 ReplicaSet 匹配 Pod 标签的选择器。
此示例中的定义将创建名为 random-generator 的服务,该服务在端口 80 上接受 TCP 连接,并将其路由到所有匹配的带有选择器 app: random-generator 的 Pod 的端口 8080。一旦创建了服务,它将被分配一个 clusterIP,该 IP 只能从 Kubernetes 集群内部访问,并且只要服务定义存在,该 IP 将保持不变。这充当了所有匹配的 Pod 的永久入口点,这些 Pod 是短暂的并且具有变化的 IP 地址。
请注意,部署和生成的副本集仅负责维护与标签选择器匹配的无状态 Pod 的所需数量。它们不知道任何可能将流量引导到相同一组 Pod 或不同组合的 Kubernetes 服务。
存储
少数无状态服务不需要任何状态,只能基于每个请求提供的数据处理请求。大多数无状态服务需要状态,但它们是无状态的,因为它们将状态转移到某些其他有状态系统或数据存储,例如文件系统。无论是由 ReplicaSet 创建的 Pod 还是其他方式创建的,都可以通过卷声明并使用文件存储。可以使用不同类型的卷存储状态。其中一些是特定于云提供商的存储,而其他允许挂载网络存储甚至共享节点上的文件系统。在本节中,我们将介绍 persistentVolumeClaim 卷类型,它允许您使用手动或动态配置的持久存储。
持久卷(PV)代表 Kubernetes 集群中的存储资源抽象,其生命周期独立于任何正在使用它的 Pod 生命周期。Pod 不能直接引用 PV;然而,Pod 使用持久卷声明(PVC)来请求并绑定到 PV,后者指向实际的持久存储。这种间接连接允许关注点分离,并使 Pod 生命周期与 PV 解耦。集群管理员可以配置存储供应并定义 PV。创建 Pod 定义的开发人员可以使用 PVC 使用存储。通过这种间接方式,即使删除了 Pod,PV 的所有权仍然附加到 PVC 并继续存在。示例 11-3 展示了可以在 Pod 模板中使用的存储声明。
示例 11-3. 用于 PersistentVolume 的声明。
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: random-generator-log 
spec:
storageClassName: "manual"
accessModes:
- ReadWriteOnce 
resources:
requests:
storage: 1Gi 
可以从 Pod 模板中引用的声明名称。
表示只有一个节点可以挂载该卷进行读写操作。
请求 1 GiB 的存储空间。
一旦定义了 PVC,就可以通过persistentVolumeClaim字段从 Pod 模板引用它。PersistentVolumeClaim 的一个有趣字段是accessModes。它控制存储如何挂载到节点并被 Pod 消耗。例如,网络文件系统可以挂载到多个节点,并且可以同时允许多个应用程序读取和写入。其他存储实现一次只能挂载到单个节点,并且只能被调度在该节点上的 Pod 访问。让我们看看 Kubernetes 提供的不同accessModes:
ReadWriteOnce
这代表一种一次只能挂载到单个节点的卷。在此模式下,运行在节点上的一个或多个 Pod 可以执行读取和写入操作。
ReadOnlyMany
卷可以挂载到多个节点,但只允许所有 Pod 进行只读操作。
ReadWriteMany
在此模式下,卷可以被多个节点挂载,并允许读取和写入操作。
ReadWriteOncePod
注意到到目前为止描述的所有访问模式都提供了每个节点的粒度。即使ReadWriteOnce允许同一节点上的多个 Pod 同时从同一卷读取和写入。只有ReadWriteOncePod访问模式保证只有一个 Pod 可以访问卷。在最多允许一个写入应用程序访问数据以确保数据一致性的情况下,这是无价的。请谨慎使用此模式,因为它会将您的服务转变为单例,并阻止扩展。如果另一个 Pod 副本使用相同的 PVC,该 Pod 将无法启动,因为 PVC 已被另一个 Pod 使用。截至目前,ReadWriteOncePod也不支持抢占,这意味着优先级较低的 Pod 将保留存储空间,并且不会被抢占以让出节点给等待相同ReadWriteOncePod声明的优先级较高的 Pod。
在一个 ReplicaSet 中,所有 Pod 都是相同的;它们共享相同的 PVC 并引用相同的 PV。这与下一章中介绍的 StatefulSets 形成对比,在 StatefulSets 中,为每个有状态的 Pod 副本动态创建 PVC。这是 Kubernetes 中处理无状态和有状态工作负载的主要区别之一。
讨论
复杂的分布式系统通常由多个服务组成,其中一些将是有状态的并执行某种形式的分布式协调,一些可能是短暂的作业,一些可能是高度可扩展的无状态服务。无状态服务由相同、可互换、短暂和可替换的实例组成。它们非常适合处理短暂的请求,并且可以在实例之间没有任何依赖关系的情况下快速扩展和缩减。正如在图 11-1 中所示,Kubernetes 提供了许多有用的原语来管理这些应用程序。

图 11-1. Kubernetes 上的分布式无状态应用程序
在最低层次上,Pod 抽象确保一个或多个容器通过活力检查始终处于运行状态。基于此,ReplicaSet 还确保在健康节点上始终运行所需数量的无状态 Pod。部署自动化处理了 Pod 副本的升级和回滚机制。当有流量进入时,Service 抽象会发现并将流量分发到通过健康性检查的 Pod 实例。当需要持久文件存储时,PVC 可以请求并挂载存储。
尽管 Kubernetes 提供了这些构建模块,但不会强制执行它们之间的直接关系。你需要负责将它们组合以适应应用的特性。你必须理解活力检查和 ReplicaSet 如何控制 Pod 的生命周期,以及它们与就绪探针和服务定义的关系,后者控制流量如何指向 Pod。你还需要理解 PVC 和 accessMode 如何控制存储挂载和访问方式。当 Kubernetes 的原语不够用时,你应该了解如何与其他框架(如 Knative 和 KEDA)结合,以及如何实现自动伸缩,甚至将无状态应用程序转换为无服务器。后面的框架在 第二十九章,“弹性伸缩” 中有详细介绍。
更多信息
第十二章:有状态服务
分布式有状态应用程序需要持久标识、网络、存储和序列。有状态服务模式描述了提供这些构建块及强保证的 StatefulSet 原语,非常适合管理有状态应用程序。
问题
我们已经看到许多 Kubernetes 原语用于创建分布式应用程序:带有健康检查和资源限制的容器,具有多个容器的 Pod,动态的整个集群放置,批处理作业,定时作业,单例等等。这些原语的共同特征是它们将托管应用程序视为由相同、可交换和可替换容器组成的无状态应用,并符合十二因素应用原则。
有一个平台负责处理无状态应用的放置、弹性和扩展,这对提升显著,但仍需考虑大部分工作负载:有状态应用,其中每个实例都是独特的,具有长期特性。
在现实世界中,每个高度可扩展的无状态服务背后通常是一个有状态服务,通常以数据存储的形式存在。在 Kubernetes 初期,缺乏对有状态工作负载的支持时,解决方案是将无状态应用放置在 Kubernetes 上,以获得云原生模型的好处,并将有状态组件保留在集群外,无论是在公共云还是本地硬件上,通过传统的非云原生机制进行管理。考虑到每个企业都有大量的有状态工作负载(传统和现代),Kubernetes 对有状态工作负载的不支持是一个显著的限制,尽管它被认为是一个通用的云原生平台。
那么,有状态应用程序的典型要求是什么呢?我们可以通过使用 Deployment 部署诸如 Apache ZooKeeper、MongoDB、Redis 或 MySQL 等有状态应用程序,这样可以创建一个 ReplicaSet,并设置 replicas=1 以确保其可靠性,使用 Service 发现其终端点,并使用 PersistentVolumeClaim(PVC)和 PersistentVolume(PV)作为其状态的永久存储。
虽然这在单实例有状态应用程序中大体上是真的,但并非完全正确,因为 ReplicaSet 不保证 At-Most-One 语义,并且副本的数量可能会暂时变化。对于分布式有状态应用程序,这种情况可能非常危险,导致数据丢失。而且,当涉及由多个实例组成的分布式有状态服务时,主要的挑战在于来自基础架构的多方面保证。让我们看看分布式有状态应用程序最常见的长期持久性先决条件。
存储
我们可以轻松增加 ReplicaSet 中的副本数量,并最终得到一个分布式有状态应用程序。然而,在这种情况下,我们如何定义存储需求呢?通常情况下,像之前提到的那样的分布式有状态应用程序需要为每个实例提供专用的持久化存储。具有 replicas=3 的 ReplicaSet 和 PVC 定义将导致所有三个 Pod 连接到同一个 PV。虽然 ReplicaSet 和 PVC 确保实例处于运行状态并且存储已附加到实例所调度的任何节点,但存储并非专用,而是在所有 Pod 实例之间共享。
另一种解决方案是让应用程序实例共享存储,并使用应用程序内的机制将存储拆分为子文件夹并无冲突地使用。虽然可行,但这种方法会导致单点故障存在于单一存储中。此外,由于 Pod 数量在扩展期间变化,这种方法容易出现错误,并可能在扩展期间导致严重的数据损坏或丢失挑战。
另一个解决方法是为分布式有状态应用程序的每个实例单独创建一个 replicas=1 的 ReplicaSet。在这种情况下,每个 ReplicaSet 将获得其 PVC 和专用存储。这种方法的缺点是其工作量大:扩展需要创建新的 ReplicaSet、PVC 或 Service 定义集合。这种方法缺乏一个单一的抽象来管理所有状态应用程序实例。
网络
与存储要求类似,分布式有状态应用程序需要稳定的网络标识。除了将特定于应用程序的数据存储到存储空间中外,有状态应用程序还存储诸如主机名和其对等体的连接详细信息等配置详细信息。这意味着每个实例应该以可预测的地址可达,这种地址在 ReplicaSet 中的 Pod IP 地址中不会动态更改。在这里,我们可以再次通过一个解决方案来满足这个需求:为每个 ReplicaSet 创建一个 Service 并设置 replicas=1。然而,管理这样的设置是手工操作,而且应用程序本身无法依赖于稳定的主机名,因为每次重新启动后都会更改,并且也不知道其从何处访问到的 Service 名称。
身份
正如您从上述需求中可以看到的,集群化的有状态应用程序严重依赖于每个实例拥有其长期存储和网络标识。这是因为在有状态应用程序中,每个实例都是唯一的并且知道自己的身份,其主要组成部分是长期存储和网络坐标。除此之外,我们还可以添加实例的身份/名称(一些有状态应用程序需要唯一的持久名称),在 Kubernetes 中就是 Pod 名称。使用 ReplicaSet 创建的 Pod 将具有随机名称,并且在重新启动后不会保留该身份。
序数性
除了独特且长期的身份外,集群化有状态应用的实例在实例集合中有一个固定位置。这种顺序通常影响实例的缩放顺序,但也可以用于数据分发或访问,以及集群内行为定位,如锁定、单例或领导者。
其他要求
集群化有状态应用的共同需求包括稳定和长期的存储、网络、身份和序号。管理有状态应用还涉及许多其他具体需求,这些需求因案例而异。例如,一些应用程序具有仲裁的概念,需要始终可用的最小实例数量;有些对序号敏感,有些可以进行并行部署;有些容忍重复实例,而有些则不行。计划处理所有这些特例并提供通用机制是不可能的任务,这就是为什么 Kubernetes 还允许您创建 CustomResourceDefinitions(CRD)和运算符来管理具有定制需求的应用程序。第 28 章 解释了运算符模式。
我们已经看到管理分布式有状态应用的一些常见挑战以及一些不太理想的解决方案。接下来,让我们看看 Kubernetes 原生机制如何通过 StatefulSet 原语来满足这些需求。
解决方案
为了解释 StatefulSet 提供的管理有状态应用的功能,我们有时将其行为与 Kubernetes 用于运行无状态工作负载的已熟悉 ReplicaSet 原语进行比较。在许多方面,StatefulSet 用于管理宠物,而 ReplicaSet 用于管理牲畜。宠物与牲畜是 DevOps 世界中著名(但也颇有争议)的类比:相同和可替换的服务器被称为牲畜,需要个别关怀的非交换性独特服务器被称为宠物。类似地,StatefulSet(最初受该类比启发并命名为 PetSet)设计用于管理非交换性 Pod,而 ReplicaSet 用于管理相同可替换的 Pod。
让我们探讨 StatefulSets 的工作原理以及它们如何满足有状态应用的需求。示例 12-1 是我们的随机生成器服务作为一个 StatefulSet。^(1)
示例 12-1. 用于有状态应用的 StatefulSet 定义
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: rg 
spec:
serviceName: random-generator 
replicas: 2 
selector:
matchLabels:
app: random-generator
template:
metadata:
labels:
app: random-generator
spec:
containers:
- image: k8spatterns/random-generator:1.0
name: random-generator
ports:
- containerPort: 8080
name: http
volumeMounts:
- name: logs
mountPath: /logs
volumeClaimTemplates: 
- metadata:
name: logs
spec:
accessModes: [ "ReadWriteOnce" ]
resources:
requests:
storage: 10Mi
StatefulSet 的名称用作生成节点名称的前缀。
引用 示例 12-2 中定义的强制服务。
StatefulSet 中有两个 Pod 成员,命名为 rg-0 和 rg-1。
为每个 Pod 创建 PVC 的模板(类似于 Pod 的模板)。
不必逐行解释 Example 12-1 中的定义,我们探讨该 StatefulSet 定义所提供的整体行为和保证。
存储
虽然并非总是必需,但大多数状态应用程序保存状态,因此需要基于每个实例的专用持久存储。在 Kubernetes 中,通过 PVs 和 PVCs 请求和关联持久存储与 Pod 的方式。为了像创建 Pods 一样创建 PVCs,StatefulSet 使用了 volumeClaimTemplates 元素。这个额外的属性是 StatefulSet 和 ReplicaSet 之间的主要区别之一,后者使用了 persistentVolumeClaim 元素。
StatefulSet 不是通过引用预定义的 PVC,而是在 Pod 创建过程中通过 volumeClaimTemplates 动态创建 PVCs。这种机制允许每个 Pod 在初始创建期间以及通过改变 StatefulSet 的 replicas 数量进行扩展时获得自己的专用 PVC。
正如你可能意识到的,我们说过 PVCs 是与 Pods 一起创建并关联的,但我们并没有提及 PVs。这是因为 StatefulSet 不以任何方式管理 PVs。Pods 的存储必须由管理员预先提供或根据请求的存储类由 PV 提供程序按需提供,并准备好供状态型 Pods 使用。
注意这里的不对称行为:扩展 StatefulSet(增加 replicas 数量)会创建新的 Pods 和相关的 PVCs。缩减则会删除 Pods,但不会删除任何 PVCs(或 PVs),这意味着 PVs 无法被回收或删除,Kubernetes 也无法释放存储空间。这种行为是设计上的选择,基于对状态应用程序存储的重要性的假设,意外的缩减不应造成数据丢失。如果你确定状态应用程序已经有意地进行了缩减,并且已经将数据复制/转移至其他实例,你可以手动删除 PVC,这样可以允许后续的 PV 回收。
网络
每个由 StatefulSet 创建的 Pod 都有一个稳定的标识,由 StatefulSet 的名称和序数索引(从 0 开始)生成。根据前面的示例,两个 Pods 的名称分别是 rg-0 和 rg-1。Pod 的名称是以可预测的格式生成的,这与 ReplicaSet 的 Pod 名称生成机制不同,后者包含一个随机后缀。
专用可扩展的持久存储是状态应用程序的重要组成部分,网络也是如此。
在 Example 12-2 中,我们定义了一个 headless Service。在 headless Service 中,clusterIP 被设置为 None,这意味着我们不希望 kube-proxy 处理该 Service,也不需要集群 IP 分配或负载均衡。那么为什么我们需要一个 Service?
示例 12-2. 访问 StatefulSet 的 Service
apiVersion: v1
kind: Service
metadata:
name: random-generator
spec:
clusterIP: None 
selector:
app: random-generator
ports:
- name: http
port: 8080
声明该 Service 为 headless。
通过 ReplicaSet 创建的无状态 Pod 被假定为相同的,请求可以落在任意一个上(因此使用常规 Service 进行负载均衡)。但是有状态 Pod 互不相同,我们可能需要通过其坐标访问特定的 Pod。
具有选择器的无头服务(请注意 .selector.app == random-generator)正是这样做的。这种服务会在 API 服务器中创建端点记录,并创建 DNS 条目以返回 A 记录(地址),直接指向支持服务的 Pod。简而言之,每个 Pod 都会获得一个 DNS 条目,客户端可以以可预测的方式直接访问它。例如,如果我们的 random-generator 服务属于 default 命名空间,则可以通过其完全限定域名 rg-0.random-generator.default.svc.cluster.local 访问我们的 rg-0 Pod,其中 Pod 的名称会添加到服务名称前面。这种映射允许集群应用程序的其他成员或其他客户端根据需要访问特定的 Pod。
我们还可以执行服务的 DNS 查找(例如,通过 dig SRV random-generator.default.svc.cluster.local)并发现所有注册到 StatefulSet 管理的服务的正在运行的 Pod。如果任何客户端应用程序需要这样做,此机制允许动态集群成员发现。无头服务与 StatefulSet 的关联不仅基于选择器,而且 StatefulSet 还应通过其名称链接回服务,如 serviceName: "random-generator"。
通过 volumeClaimTemplates 定义专用存储不是强制性的,但通过 serviceName 字段链接到服务是必需的。在创建 StatefulSet 之前,管理服务必须存在,并负责集合的网络身份。如果需要,您始终可以创建其他类型的服务,以便在您希望的情况下负载均衡您的有状态 Pod。
如图 12-1 所示,StatefulSets 提供了在分布式环境中管理有状态应用所需的一组构建块和保证行为。您的任务是选择并以有意义的方式为您的有状态用例使用它们。

图 12-1. Kubernetes 上的分布式有状态应用
身份
身份 是所有其他 StatefulSet 保证构建的元构建块。基于 StatefulSet 名称生成可预测的 Pod 名称和身份。然后,我们使用该身份命名 PVC,并通过无头服务到达特定的 Pod 等。在创建 Pod 之前,您可以预测每个 Pod 的身份,并在需要时在应用程序本身中使用该知识。
Ordinality
按定义,分布式有状态应用由多个实例组成,这些实例是独特且不可交换的。除了它们的独特性,实例还可以根据它们的实例化顺序/位置彼此相关,这就是顺序性要求的地方。
从 StatefulSet 的角度来看,顺序性只在扩展期间起作用。Pods 的名称带有序数后缀(从 0 开始),并且 Pod 的创建顺序也定义了 Pods 的缩放顺序(逆序,从 n – 1 到 0)。
如果我们创建一个具有多个副本的 ReplicaSet,Pods 将一起被调度和启动,而不必等待第一个成功启动(处于运行和就绪状态,如 第四章,“健康探测” 中所述)。 Pods 启动和准备就绪的顺序不被保证。当我们缩减 ReplicaSet(通过更改 replicas 计数或删除它)时,属于 ReplicaSet 的所有 Pods 将同时开始关闭,没有任何排序和依赖关系。这种行为可能更快完成,但对于涉及实例间数据分区和分发的有状态应用并不理想。
为了在缩放时允许正确的数据同步,StatefulSet 默认执行顺序启动和关闭。这意味着 Pods 从第一个(索引 0)开始启动,只有当该 Pod 成功启动后,才会调度下一个(索引 1),并且顺序继续。在缩减时,顺序相反—首先关闭具有最高索引的 Pod,只有当它成功关闭后,才停止具有较低索引的 Pod。这个顺序持续,直到索引 0 的 Pod 被终止。
其他特性
StatefulSets 具有其他可定制的方面,以适应有状态应用的需求。每个有状态应用都是独特的,在尝试将其适应 StatefulSet 模型时需要仔细考虑。让我们看看一些可能在驯服有状态应用时有用的 Kubernetes 特性:
分区更新
我们之前描述了在扩展 StatefulSet 时的顺序保证。至于更新已运行的有状态应用(例如通过修改 .spec.template 元素),StatefulSets 允许分阶段的发布(如金丝雀发布),这可以保证某些实例保持完整,同时对其余实例应用更新。
通过使用默认的滚动更新策略,您可以通过指定 .spec.updateStrategy.rollingUpdate.partition 数字来分区实例。该参数(默认值为 0)指示 StatefulSet 在更新时应分区的序数。如果指定了该参数,则所有序数大于或等于 partition 的 Pod 将被更新,而所有序数小于该值的 Pod 则不会被更新。即使 Pod 被删除,Kubernetes 也会以先前版本重新创建它们。该功能可以支持对集群化状态应用进行部分更新(例如确保保持法定人数),然后通过将 partition 设置回 0 将更改推广到集群的其余部分。
并行部署
当我们将 .spec.podManagementPolicy 设置为 Parallel 时,StatefulSet 将并行启动或终止所有 Pod,并且在移动到下一个 Pod 之前不会等待 Pod 运行并准备好或完全终止。如果您的状态应用程序不需要顺序处理,这个选项可以加快操作过程的速度。
At-Most-One 保证
独特性是状态应用实例的基本属性之一,Kubernetes 通过确保 StatefulSet 的两个 Pod 没有相同的标识或绑定到相同的 PV 来保证这种独特性。相比之下,ReplicaSet 为其实例提供 At-Least-X-Guarantee。例如,具有两个复本的 ReplicaSet 试图始终保持至少两个实例运行。即使偶尔可能会使该数字增加,控制器的优先级也不是让 Pod 数量低于指定数量。当一个 Pod 正被新的 Pod 替换且旧 Pod 仍未完全终止时,或者 Kubernetes 节点处于 NotReady 状态但仍有运行中的 Pod 时,可能会运行多于指定数量的复本。在这种情况下,ReplicaSet 的控制器会在健康节点上启动新的 Pod,这可能导致运行中的 Pod 多于预期数量。这在 At-Least-X 的语义范围内是可以接受的。
另一方面,StatefulSet 控制器会进行一切可能的检查,以确保没有重复的 Pod — 因此有 At-Most-One 保证。除非确认旧实例完全关闭,否则它不会重新启动 Pod。当节点故障时,除非 Kubernetes 能够确认 Pod(甚至可能是整个节点)已关闭,否则不会在不同节点上调度新的 Pod。StatefulSet 的 At-Most-One 语义规定了这些规则。
仍然有可能破坏这些保证,并在 StatefulSet 中出现重复的 Pod,但这需要积极的人工干预。例如,在物理节点仍在运行时从 API 服务器中删除不可达节点资源对象将会破坏此保证。这样的操作应仅在确认节点已死亡或已关机且没有 Pod 进程在其上运行时执行。或者,例如,当您强制删除带有 kubectl delete pods <pod> --grace-period=0 --force 的 Pod 时,它不会等待 Kubelet 确认 Pod 已终止。此操作会立即从 API 服务器中清除 Pod,并导致 StatefulSet 控制器启动替换 Pod,可能导致重复。
我们在第 Chapter 10, “Singleton Service” 中深入讨论了实现单例模式的其他方法。
讨论
在本章中,我们看到了在云原生平台上管理分布式有状态应用程序的标准需求和挑战。我们发现处理单实例有状态应用程序相对较容易,但处理分布式状态是一个多维挑战。虽然我们通常将“状态”与“存储”联系在一起,但在这里,我们看到了状态的多个方面及其如何需要不同的保证来自不同的有状态应用程序。在这个领域,StatefulSets 是一个优秀的原语,用于通用实现分布式有状态应用程序。它解决了持久存储、网络(通过服务)、身份、顺序性和其他几个方面的需求。它为以自动化方式管理有状态应用程序提供了一组良好的构建块,使它们成为云原生世界中的一流公民。
StatefulSets 是一个良好的起点和进步,但是有状态应用程序的世界是独特且复杂的。除了为云原生世界设计的可以适应 StatefulSet 的有状态应用程序外,还存在大量未为云原生平台设计并具有更多需求的传统有状态应用程序。幸运的是,Kubernetes 对此也有解决方案。Kubernetes 社区意识到,与其通过 Kubernetes 资源建模不同的工作负载并通过通用控制器实现其行为,不如允许用户实现其自定义控制器,甚至进一步允许通过自定义资源定义和操作员来建模应用程序资源的行为。
在第二十七章和第二十八章中,您将学习有关相关的控制器和操作员模式,这些模式更适合于在云原生环境中管理复杂的有状态应用程序。
更多信息
^(1) 假设我们已经发明了一种在分布式随机数生成器 (RNG) 集群中生成随机数的高度复杂的方法,该集群中的多个服务实例充当节点。当然,这并不是真的,但为了这个例子,这个故事已经足够了。
第十三章:服务发现
服务发现模式提供了一个稳定的端点,通过这个端点,服务的消费者可以访问提供服务的实例。为此,Kubernetes 提供了多种机制,取决于服务的消费者和生产者是位于集群内还是集群外。
问题
部署在 Kubernetes 上的应用程序很少独立存在,通常它们需要与集群内的其他服务或集群外的系统进行交互。交互可以在服务内部发起,也可以通过外部刺激。内部发起的交互通常通过轮询消费者进行:在启动后或稍后,应用程序连接到另一个系统并开始发送和接收数据。典型示例包括在 Pod 中运行的应用程序连接到文件服务器并开始消费文件,或者消息连接到消息代理并开始接收或发送消息,或者使用关系数据库或键值存储的应用程序开始读取或写入数据。
这里的关键区别在于 Pod 中运行的应用程序在某些时候决定打开对另一个 Pod 或外部系统的传出连接,并开始在任一方向上交换数据。在这种情况下,我们的应用程序没有外部刺激,并且在 Kubernetes 中我们不需要任何额外的设置。
要实现 第七章,“批处理作业” 或 第八章,“周期性作业” 中描述的模式,我们经常使用这种技术。此外,在 DaemonSet 或 ReplicaSet 中运行的长期 Pod 有时会主动通过网络连接到其他系统。Kubernetes 工作负载的更常见用例是当我们有长期运行的服务期待外部刺激时,最常见的形式是来自集群内其他 Pod 或外部系统的传入 HTTP 连接。在这些情况下,服务消费者需要一种机制来发现由调度器动态放置并有时弹性缩放的 Pod。
如果我们不得不自己跟踪、注册和发现动态 Kubernetes Pod 的端点,那将是一个巨大的挑战。这就是为什么 Kubernetes 通过不同的机制实现了服务发现模式,我们将在本章中探讨这些机制。
解决方案
如果我们看看“Kubernetes 之前的时代”,服务发现的最常见机制是通过客户端发现。在这种架构中,当服务消费者需要调用可能扩展到多个实例的另一个服务时,服务消费者会有一个能够查看注册表以查找服务实例并选择其中一个进行调用的发现代理。经典地,这可能通过消费者服务内嵌的代理(如 ZooKeeper 客户端、Consul 客户端或 Ribbon)或者通过另一个共同进程查找注册表中的服务来完成,如 图 13-1 所示。

图 13-1. 客户端服务发现
在“Kubernetes 之后的时代”,分布式系统的许多非功能性责任,如放置、健康检查、治理和资源隔离,都移入了平台,服务发现和负载均衡也如此。如果我们使用面向服务的架构(SOA)中的定义,服务提供者实例仍然必须在提供服务能力的同时向服务注册表注册自己,而服务消费者必须访问注册表中的信息以达到服务。
在 Kubernetes 的世界中,所有这些都是在幕后进行的,以便服务消费者调用一个固定的虚拟服务端点,该端点可以动态发现作为 Pod 实现的服务实例。图 13-2 展示了 Kubernetes 如何实现注册和查找。

图 13-2. 服务器端服务发现
乍一看,服务发现 可能看起来是一个简单的模式。然而,可以使用多种机制来实现这种模式,这取决于服务消费者是在集群内还是外部,以及服务提供者是在集群内还是外部。
内部服务发现
假设我们有一个 web 应用程序,想要在 Kubernetes 上运行。一旦我们创建了一个带有几个副本的 Deployment,调度器将 Pod 放置在适合的节点上,并在启动之前为每个 Pod 分配一个集群内部 IP 地址。如果另一个 Pod 中的客户端服务希望消费 web 应用程序的端点,则没有一种简单的方法可以提前知道服务提供者 Pod 的 IP 地址。
这个挑战正是 Kubernetes 服务资源所要解决的。它为提供相同功能的一组 Pod 提供了一个恒定和稳定的入口点。创建服务的最简单方法是通过 kubectl expose,它为部署或副本集的一个或多个 Pod 创建服务。该命令创建一个虚拟 IP 地址,称为 clusterIP,并从资源中提取 Pod 选择器和端口号来创建服务定义。然而,为了对定义拥有完全控制,我们可以像示例 13-1 中展示的那样手动创建服务。
示例 13-1. 一个简单的服务
apiVersion: v1
kind: Service
metadata:
name: random-generator
spec:
selector: 
app: random-generator
ports:
- port: 80 
targetPort: 8080 
protocol: TCP
选择匹配 Pod 标签。
可以联系到该服务的端口。
Pod 正在监听的端口。
在本示例中的定义将创建一个名为random-generator的服务(名称对于后续的发现很重要),并且type: ClusterIP(这是默认设置),接受端口 80 上的 TCP 连接并将其路由到所有具有选择器app: random-generator的匹配 Pod 上的端口 8080。无论 Pod 何时或如何创建,任何匹配的 Pod 都成为路由目标,如图 13-3 所示。

图 13-3. 内部服务发现
在这里需要记住的关键点是,一旦创建服务,它会被分配一个 clusterIP,只能从 Kubernetes 集群内部访问(因此得名),并且只要服务定义存在,该 IP 就保持不变。但是,集群内的其他应用程序如何找出这个动态分配的 clusterIP 呢?有两种方法:
通过环境变量进行发现
当 Kubernetes 启动一个 Pod 时,它的环境变量将被填充为所有到目前为止存在的服务的细节。例如,我们的random-generator服务在端口 80 上监听,并注入到任何新启动的 Pod 中,正如示例 13-2 中展示的环境变量一样。运行该 Pod 的应用程序将知道它需要消费的服务名称,并可以编码以读取这些环境变量。这种查找是一种简单的机制,可用于任何语言编写的应用程序,并且在 Kubernetes 集群外进行开发和测试时也很容易模拟。这种机制的主要问题是对服务创建的时间依赖性。由于无法将环境变量注入到已运行的 Pod 中,因此仅在在 Kubernetes 中创建服务后启动的 Pod 才能使用服务坐标。这要求在启动依赖于服务的 Pod 之前定义服务,或者如果情况不是这样,则需要重新启动 Pod。
示例 13-2. 在 Pod 中自动设置的与服务相关的环境变量
RANDOM_GENERATOR_SERVICE_HOST=10.109.72.32
RANDOM_GENERATOR_SERVICE_PORT=80
通过 DNS 查找进行发现
Kubernetes 运行一个 DNS 服务器,所有 Pod 都会自动配置为使用它。此外,当创建新服务时,它会自动获得一个新的 DNS 记录条目,所有 Pod 都可以开始使用。假设客户端知道要访问的服务的名称,它可以通过完全限定域名(FQDN)来访问服务,例如random-generator.default.svc.cluster.local。这里,random-generator是服务的名称,default是命名空间的名称,svc表示它是一个服务资源,cluster.local是集群特定的后缀。如果需要,我们可以省略集群后缀,以及在同一命名空间内访问服务时省略命名空间。
DNS 发现机制不会受到基于环境变量的机制的缺点的影响,因为 DNS 服务器允许所有 Pod 立即查找所有服务,只要服务定义了。但是,如果服务消费者需要使用非标准或未知的端口号,仍然可能需要使用环境变量来查找要使用的端口号。
这里是type: ClusterIP的服务的一些其他高级特性,其他类型也建立在此基础之上:
多个端口
单个服务定义可以支持多个源端口和目标端口。例如,如果您的 Pod 同时支持端口 8080 上的 HTTP 和端口 8443 上的 HTTPS,则无需定义两个服务。一个单独的服务可以在端口 80 和 443 上同时公开这两个端口,例如。
会话亲和性
当有新的请求时,默认情况下,服务会随机选择一个 Pod 进行连接。这可以通过sessionAffinity: ClientIP来改变,这样来自同一客户端 IP 的所有请求将粘附到同一个 Pod 上。请记住,Kubernetes 服务执行 L4 传输层负载平衡,无法查看网络数据包并执行如基于 HTTP Cookie 的会话亲和性等应用级别负载平衡。
readiness 探测
在第四章,“健康探测”中,您学习了如何为容器定义readinessProbe。如果一个 Pod 定义了 readiness 检查,并且它们失败了,即使标签选择器匹配该 Pod,该 Pod 也会从服务终端点列表中移除。
虚拟 IP
当我们创建一个type: ClusterIP的服务时,它会获得一个稳定的虚拟 IP 地址。但是,这个 IP 地址不对应任何网络接口,也不存在于现实中。每个节点上运行的 kube-proxy 会选择这个新服务,并更新节点的 iptables,添加规则以捕获发送到此虚拟 IP 地址的网络数据包,并替换为选定的 Pod IP 地址。iptables 中的规则不会添加 ICMP 规则,而只会添加在服务定义中指定的协议,如 TCP 或 UDP。因此,不可能ping服务的 IP 地址,因为该操作使用 ICMP。
选择 ClusterIP
在创建服务时,我们可以通过字段.spec.clusterIP指定要使用的 IP。它必须是有效的 IP 地址,并且在预定义范围内。虽然不建议这样做,但在处理配置为使用特定 IP 地址的遗留应用程序或希望重用现有 DNS 条目时,此选项可能会很方便。
带有type: ClusterIP的 Kubernetes 服务仅限于集群内访问;它们用于通过匹配选择器发现 Pod,并且是最常用的类型。接下来,我们将介绍其他类型的服务,这些服务允许发现手动指定的端点。
手动服务发现
当我们创建一个带有selector的服务时,Kubernetes 会在端点资源列表中跟踪匹配和准备就绪的 Pod 列表。对于示例 13-1,您可以使用kubectl get endpoints random-generator检查代表服务创建的所有端点。除了将连接重定向到集群内的 Pod 之外,我们还可以将连接重定向到外部 IP 地址和端口。我们可以通过省略服务的selector定义,并手动创建端点资源来实现这一点,就像示例 13-3 中所示。
示例 13-3. 没有选择器的服务
apiVersion: v1
kind: Service
metadata:
name: external-service
spec:
type: ClusterIP
ports:
- protocol: TCP
port: 80
接下来,在示例 13-4 中,我们定义了一个包含目标 IP 和端口的端点资源,其名称与服务相同。
示例 13-4. 外部服务的端点
apiVersion: v1
kind: Endpoints
metadata:
name: external-service 
subsets:
- addresses:
- ip: 1.1.1.1
- ip: 2.2.2.2
ports:
- port: 8080
名称必须与访问这些端点的服务匹配。
此服务也仅限于集群内访问,并且可以通过环境变量或 DNS 查找方式消耗。不同之处在于端点列表是手动维护的,并且这些值通常指向集群外的 IP 地址,如图 13-4 所示。
尽管连接到外部资源是此机制最常见的用途,但这并不是唯一的用途。端点可以保存 Pod 的 IP 地址,但不能保存其他服务的虚拟 IP 地址。服务的一个好处是,它允许您添加和删除选择器,并指向外部或内部提供者,而无需删除导致服务 IP 地址更改的资源定义。因此,服务消费者可以继续使用首次指向的相同服务 IP 地址,而实际的服务提供者实现则可以从本地迁移到 Kubernetes,而不影响客户端。

图 13-4. 手动服务发现
在这种手动目标配置类别中,还有一种服务类型,如示例 13-5 所示。
示例 13-5. 带有外部目标的服务
apiVersion: v1
kind: Service
metadata:
name: database-service
spec:
type: ExternalName
externalName: my.database.example.com
ports:
- port: 80
此服务定义也没有selector,但其类型为ExternalName。这与实现角度来看是一个重要的区别。此服务定义使用 DNS 将externalName指向的内容映射为database-service.<namespace>.svc.cluster.local,现在指向my.database.example.com。这是使用 DNS CNAME 创建外部端点的别名的一种方式,而不是通过 IP 地址通过代理。但从根本上说,这是提供给位于集群外部的端点的 Kubernetes 抽象的另一种方式。
从集群外部进行服务发现
本章讨论的服务发现机制都使用指向 Pod 或外部端点的虚拟 IP 地址,虚拟 IP 地址本身只能从 Kubernetes 集群内部访问。然而,Kubernetes 集群并不与世界脱节,除了从 Pod 连接到外部资源之外,非常常见的情况是相反的——外部应用程序希望到达由 Pod 提供的端点。让我们看看如何使 Pod 对位于集群外部的客户端可访问。
创建服务并将其暴露在集群外的第一种方法是通过type: NodePort。在示例 13-6 中的定义与之前创建的服务相同,为匹配选择器app: random-generator的 Pod 提供服务,接受端口 80 上的连接,并将每个连接路由到所选 Pod 的端口 8080。然而,除此之外,该定义还在所有节点上预留了端口 30036,并将传入的连接转发到该服务。这一预留使得服务可以通过虚拟 IP 地址在内部访问,同时也可以通过每个节点上的专用端口在外部访问。
示例 13-6. 类型为 NodePort 的服务
apiVersion: v1
kind: Service
metadata:
name: random-generator
spec:
type: NodePort 
selector:
app: random-generator
ports:
- port: 80
targetPort: 8080
nodePort: 30036 
protocol: TCP
在所有节点上打开端口。
指定一个固定端口(需要可用)或者省略以分配一个随机选择的端口。
虽然这种暴露服务的方法(如图 13-5 所示)看起来是一种不错的方法,但它也有缺点。

图 13-5. 节点端口服务发现
让我们看一些它的显著特点:
端口号
不使用nodePort: 30036选择特定端口,可以让 Kubernetes 在其范围内选择一个空闲端口。
防火墙规则
由于此方法在所有节点上开放端口,您可能需要配置额外的防火墙规则以允许外部客户端访问节点端口。
节点选择
外部客户端可以连接到集群中的任何节点。但是,如果节点不可用,客户端应用程序有责任连接到另一个健康的节点。为此,最好在节点前放置一个负载均衡器,选择健康节点并执行故障转移。
Pod 选择
当客户端通过节点端口打开连接时,它会被路由到一个随机选择的 Pod,该 Pod 可能位于打开连接的同一节点上,也可能位于另一个节点上。通过向服务定义中添加externalTrafficPolicy: Local,可以避免这种额外的跳转,并始终强制 Kubernetes 选择在打开连接的节点上的 Pod。设置此选项后,Kubernetes 不允许您连接到其他节点上的 Pod,这可能会成为一个问题。为了解决这个问题,您必须确保每个节点上都有 Pod(例如,通过使用守护服务),或者确保客户端知道哪些节点上有健康的 Pod。
源地址
发送到不同类型服务的数据包的源地址存在一些特殊性。具体来说,当我们使用类型NodePort时,客户端地址会被源 NAT 化,这意味着包含客户端 IP 地址的网络数据包的源 IP 地址会被替换为节点的内部地址。例如,当客户端应用程序向节点 1 发送数据包时,它会将源地址替换为自己的节点地址,将目标地址替换为 Pod 的地址,并将数据包转发到 Pod 所在的节点 2。当 Pod 接收到网络数据包时,源地址不等于原始客户端的地址,而是与节点 1 的地址相同。为了防止这种情况发生,我们可以像前面描述的那样设置externalTrafficPolicy: Local,只将流量转发到位于节点 1 上的 Pod。
为外部客户端执行服务发现的另一种方法是通过负载均衡器。您已经看到type: NodePort服务是如何在常规type: ClusterIP服务的基础上构建的,同时在每个节点上开放一个端口。这种方法的局限性在于我们仍然需要一个负载均衡器,以便客户端应用程序选择一个健康的节点。LoadBalancer服务类型解决了这个限制。
除了创建常规服务并在每个节点上开放一个端口,就像type: NodePort一样,它还使用云提供商的负载均衡器将服务外部暴露。图 13-6 展示了这种设置:专有负载均衡器作为 Kubernetes 集群的网关。

图 13-6. 负载均衡器服务发现
因此,这种类型的服务仅在云提供商支持 Kubernetes 并提供负载均衡器时才起作用。我们可以通过指定LoadBalancer类型创建一个带有负载均衡器的服务。然后 Kubernetes 将在.spec和.status字段中添加 IP 地址,如示例 13-7 所示。
示例 13-7. 类型为 LoadBalancer 的服务
apiVersion: v1
kind: Service
metadata:
name: random-generator
spec:
type: LoadBalancer
clusterIP: 10.0.171.239 
loadBalancerIP: 78.11.24.19
selector:
app: random-generator
ports:
- port: 80
targetPort: 8080
status: 
loadBalancer:
ingress:
- ip: 146.148.47.155
Kubernetes 在可用时分配clusterIP和loadBalancerIP。
status字段由 Kubernetes 管理,并添加 Ingress IP。
有了这个定义,外部客户端应用程序可以打开到负载均衡器的连接,负载均衡器选择一个节点并定位 Pod。负载均衡器的配置和服务发现的确切方式在各个云提供商之间有所不同。一些云提供商允许您定义负载均衡器地址,而另一些则不允许。一些提供机制以保留源地址,而一些则用负载均衡器地址替换它。您应该检查您选择的云提供商提供的具体实现。
注意
还有另一种类型的服务可用:headless服务,您不需要请求专用 IP 地址。您可以通过在服务的spec部分中指定clusterIP None来创建一个 headless 服务。对于 headless 服务,支持的 Pods 将添加到内部 DNS 服务器中,最适合用于实现 StatefulSets 服务,详细信息请参见第十二章,“有状态服务”。
应用层服务发现
与迄今为止讨论的机制不同,Ingress 不是一种服务类型,而是一个独立的 Kubernetes 资源,位于服务前面,作为智能路由器和集群入口。Ingress 通常通过外部可访问的 URL 提供基于 HTTP 的服务访问,包括负载平衡、TLS 终止和基于名称的虚拟主机,但也有其他专门的 Ingress 实现。要使 Ingress 正常工作,集群必须运行一个或多个 Ingress 控制器。一个展示单一服务的简单 Ingress 示例如示例 13-8 所示。
示例 13-8. Ingress 定义
apiVersion: networking.k8s.io/v1beta1
kind: Ingress
metadata:
name: random-generator
spec:
backend:
serviceName: random-generator
servicePort: 8080
根据运行 Kubernetes 的基础设施和 Ingress 控制器的实现方式,此定义分配一个外部可访问的 IP 地址,并在端口 80 上公开random-generator服务。但这与具有type: LoadBalancer的服务并没有太大的不同,后者需要每个服务定义一个外部 IP 地址。Ingress 的真正优势在于重用单个外部负载均衡器和 IP 来服务多个服务,并降低基础设施成本。一个简单的扇出配置,根据 HTTP URI 路径将单个 IP 地址路由到多个服务,看起来像示例 13-9。
示例 13-9. Nginx Ingress 控制器的定义
apiVersion: networking.k8s.io/v1beta1
kind: Ingress
metadata:
name: random-generator
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
spec:
rules: 
- http:
paths:
- path: / 
backend:
serviceName: random-generator
servicePort: 8080
- path: /cluster-status 
backend:
serviceName: cluster-status
servicePort: 80
Ingress 控制器专用规则,根据请求路径分发请求。
将每个请求重定向到 Service random-generator…
… 除了 /cluster-status,它将转到另一个 Service。
假设 Ingress 配置正确,除了常规的 Ingress 定义外,控制器可能需要通过注释传递额外的配置。前述定义将配置一个负载均衡器,并获得一个外部 IP 地址,该 IP 地址服务于两个路径下的两个服务,如 图 13-7 所示。
Ingress 是 Kubernetes 上最强大同时也是最复杂的服务发现机制。它最适用于在同一 IP 地址下暴露多个服务,并且所有服务使用相同的 L7(通常是 HTTP)协议。

图 13-7. 应用层服务发现
讨论
在本章中,我们介绍了 Kubernetes 上最受欢迎的服务发现机制。从集群内部动态 Pod 的发现始终通过 Service 资源实现,尽管不同的选项可能导致不同的实现方式。Service 抽象是一种高级的云本地化配置方式,用于配置如虚拟 IP 地址、iptables、DNS 记录或环境变量等低级细节。从集群外部进行服务发现建立在 Service 抽象之上,重点是将服务暴露给外部世界。虽然 NodePort 提供了暴露服务的基础功能,但高可用设置需要与平台基础设施提供商集成。
表格 13-1 总结了 Kubernetes 中实现服务发现的各种方式。本表旨在将本章中的各种服务发现机制从简单到复杂进行整理。希望它能帮助您建立心理模型并更好地理解它们。
表格 13-1. 服务发现机制
| 名称 | 配置 | 客户端类型 | 摘要 |
|---|---|---|---|
| ClusterIP |
type: ClusterIP
.spec.selector
| 内部 | 最常见的内部发现机制 |
|---|---|
| 手动 IP |
type: ClusterIP
kind: Endpoints
| 内部 | 外部 IP 发现 |
|---|---|
| 手动 FQDN |
type: ExternalName
.spec.externalName
| 内部 | 外部 FQDN 发现 |
|---|---|
| 无头服务 |
type: ClusterIP
.spec.clusterIP: None
| 内部 | 基于 DNS 的发现,没有虚拟 IP |
|---|---|
| NodePort | type: NodePort |
| LoadBalancer | type: LoadBalancer |
| Ingress | kind: Ingress |
本章全面概述了 Kubernetes 中用于访问和发现服务的所有核心概念。然而,旅程并不止步于此。通过 Knative 项目,引入了在 Kubernetes 之上帮助应用程序开发人员进行高级服务和事件处理的新原语。
在服务发现模式下,Knative Serving子项目特别引人注目,因为它引入了一个新的与此处介绍的服务相同类型的服务资源(但具有不同的 API 组)。Knative Serving 不仅支持应用程序修订,还支持在负载均衡器后面对服务进行非常灵活的扩展。我们在“Knative”中简要介绍了一下 Knative Serving,但详细讨论超出了本书的范围。在“更多信息”中,您将找到指向有关 Knative 的详细信息的链接。
更多信息
第十四章:自我意识
一些应用程序需要自我意识,并需要关于自身的信息。自我意识模式描述了 Kubernetes 向下 API,为应用程序提供了一种简单的内省和元数据注入机制。
问题
对于大多数用例,云原生应用程序是无状态且可丢弃的,没有与其他应用程序相关的身份。然而,有时甚至这些类型的应用程序也需要有关自身及其运行环境的信息。这可能包括仅在运行时已知的信息,如 Pod 名称、Pod IP 地址和应用程序所在的主机名。或者,在 Pod 级别定义的其他静态信息,例如特定资源请求和限制,或者用户在运行时可能更改的注解和标签等动态信息。
例如,根据容器提供的资源,您可能希望调整应用程序线程池大小,或更改垃圾收集算法或内存分配。您可能希望在记录信息或将指标发送到中央服务器时使用 Pod 名称和主机名。您可能希望发现同一命名空间中具有特定标签的其他 Pod,并将它们加入到集群应用程序中。对于这些和其他用例,Kubernetes 提供了向下 API。
解决方案
我们描述的要求及其后续解决方案不仅适用于容器,而且存在于任何资源元数据动态变化的环境中。例如,AWS 提供实例元数据和用户数据服务,可以从任何 EC2 实例查询 EC2 实例本身的元数据。同样,AWS ECS 提供 API,容器可以查询并检索有关容器集群的信息。
Kubernetes 的方法更加优雅且易于使用。向下 API允许您通过环境变量和文件将有关 Pod 的元数据传递给容器和集群。这些是我们用于从 ConfigMaps 和 Secrets 传递应用程序相关数据的相同机制。但在这种情况下,数据不是我们创建的。相反,我们指定我们感兴趣的键,Kubernetes 动态填充值。图 14-1 概述了向下 API 如何将资源和运行时信息注入到感兴趣的 Pod 中。

图 14-1 应用程序内省机制
这里的主要观点是,通过向下 API,元数据被注入到您的 Pod 中,并在本地提供。应用程序无需使用客户端与 Kubernetes API 交互,可以保持对 Kubernetes 的不可知状态。让我们看看如何通过环境变量在示例 14-1 中请求元数据是多么简单。
示例 14-1 环境变量来自向下 API
apiVersion: v1
kind: Pod
metadata:
name: random-generator
spec:
containers:
- image: k8spatterns/random-generator:1.0
name: random-generator
env:
- name: POD_IP
valueFrom:
fieldRef: 
fieldPath: status.podIP
- name: MEMORY_LIMIT
valueFrom:
resourceFieldRef:
containerName: random-generator 
resource: limits.memory
环境变量 POD_IP 是从此 Pod 的属性设置的,并在 Pod 启动时出现。
将环境变量 MEMORY_LIMIT 设置为此容器的内存资源限制的值;实际限制声明未在此处显示。
在此示例中,我们使用 fieldRef 访问 Pod 级别的元数据。fieldRef.fieldPath 中显示的键既可用作环境变量,也可用作 downwardAPI 卷。
Table 14-1. fieldRef.fieldPath 中可用的 Downward API 信息
| Name | Description |
|---|---|
spec.nodeName |
托管 Pod 的节点名称 |
status.hostIP |
托管 Pod 的节点的 IP 地址 |
metadata.name |
Pod 名称 |
metadata.namespace |
Pod 所在的命名空间 |
status.podIP |
Pod IP 地址 |
spec.serviceAccountName |
Pod 使用的 ServiceAccount |
metadata.uid |
Pod 的唯一标识符 |
metadata.labels['*key*'] |
Pod 标签 key 的值 |
metadata.annotations['*key*'] |
Pod 注释 key 的值 |
与 fieldRef 类似,我们使用 resourceFieldRef 来访问属于 Pod 的容器资源规范的元数据。这些元数据特定于容器,并且使用 resourceFieldRef.container 指定。当作为环境变量使用时,默认使用当前容器。resourceFieldRef.resource 的可能键显示在 Table 14-2 中。资源声明在 Chapter 2, “Predictable Demands” 中解释。
Table 14-2. resourceFieldRef.resource 中可用的 Downward API 信息
| Name | Description |
|---|---|
requests.cpu |
容器的 CPU 请求 |
limits.cpu |
容器的 CPU 限制 |
requests.memory |
容器的内存请求 |
limits.memory |
容器的内存限制 |
requests.hugepages-<size> |
容器的巨页请求(例如,requests.hugepages-1Gi) |
limits.hugepages-<size> |
容器的巨页限制(例如,limits.hugepages-1Gi) |
requests.ephemeral-storage |
容器的临时存储请求 |
limits.ephemeral-storage |
容器的临时存储限制 |
用户可以在 Pod 运行时更改某些元数据,如标签和注释。除非重启 Pod,否则环境变量不会反映这样的更改。但是 downwardAPI 卷可以反映标签和注释的更新。除了前面描述的各个字段之外,downwardAPI 卷还可以将所有 Pod 标签和注释捕获到具有 metadata.labels 和 metadata.annotations 引用的文件中。 Example 14-2 展示了如何使用这样的卷。
Example 14-2. 通过卷使用 Downward API
apiVersion: v1
kind: Pod
metadata:
name: random-generator
spec:
containers:
- image: k8spatterns/random-generator:1.0
name: random-generator
volumeMounts:
- name: pod-info 
mountPath: /pod-info
volumes:
- name: pod-info
downwardAPI:
items:
- path: labels 
fieldRef:
fieldPath: metadata.labels
- path: annotations 
fieldRef:
fieldPath: metadata.annotations
从向下 API 获取的值可以作为文件挂载到 Pod 中。
labels 文件按行保存所有标签,格式为name=value。当标签发生变化时,此文件会更新。
annotations 文件以与标签相同的格式保存所有注释。
使用卷时,如果 Pod 在运行时元数据发生变化,这些变化会反映在卷文件中。但仍然需要消费应用程序检测文件更改并相应地读取更新的数据。如果应用程序没有实现这样的功能,可能仍然需要重新启动 Pod。
讨论
通常,应用程序需要具有自我感知能力,并了解自身及其运行环境的信息。Kubernetes 提供了非侵入式的内省和元数据注入机制。向下 API 的一个缺点是它提供了一组固定的可引用键。如果您的应用程序需要更多数据,特别是关于其他资源或集群相关元数据的信息,必须在 API 服务器上查询。许多应用程序使用此技术查询 API 服务器,以发现同一命名空间中具有特定标签或注释的其他 Pod。然后应用程序可以与发现的 Pod 形成集群并同步状态。监控应用程序也使用此技术发现感兴趣的 Pod,然后开始对其进行仪表化。
有许多客户端库可用于不同语言与 Kubernetes API 服务器交互,以获取更多超出向下 API 提供的自我引用信息。
更多信息
第三部分:结构模式
容器镜像和容器类似于面向对象世界中的类和对象。容器镜像是实例化容器的蓝图。但这些容器并不孤立运行;它们在称为 Pods 的其他抽象中运行,在这里它们与其他容器交互。
该类别中的模式专注于在 Pod 中结构化和组织容器,以满足不同的用例。Pod 提供了独特的运行时能力。影响 Pod 中容器的力量导致了下面章节讨论的模式:
-
第十五章,“初始化容器”,引入了一个用于初始化相关任务的生命周期,与主应用责任分离。
-
第十六章,“边车”,描述了如何在不改变现有容器的情况下扩展和增强其功能。
-
第十七章,“适配器”,将异构系统转换为符合统一接口的一致性结构,可以被外部世界消费。
-
第十八章,“大使”,描述了一个代理,解耦了对外部服务的访问。
第十五章:初始化容器
初始化容器模式通过为初始化相关任务提供独立的生命周期,使关注点分离,与主应用容器的生命周期不同步。在本章中,我们将深入研究这一基础的 Kubernetes 概念,在许多其他模式中需要初始化逻辑时使用。
问题
初始化在许多编程语言中是一个普遍关注的问题。某些语言将其作为语言的一部分来处理,而某些使用命名约定和模式来指示构造为初始化器。例如,在 Java 编程语言中,要实例化一个需要一些设置的对象,我们使用构造函数(或者对于更复杂的用例,使用静态块)。构造函数保证作为对象内部的第一件事运行,并且由运行时管理者保证仅运行一次(这只是一个示例;我们这里不详细介绍不同语言和特殊情况)。此外,我们可以使用构造函数来验证必填参数等前提条件。我们还使用构造函数用传入的参数或默认值初始化实例字段。
初始化容器类似于但位于 Pod 级别,而不是 Java 类级别。因此,如果在 Pod 中有一个或多个表示主应用程序的容器,则这些容器可能在启动之前有先决条件。这些可能包括在文件系统上设置特殊权限、数据库架构设置或应用程序种子数据安装。此外,这种初始化逻辑可能需要工具和库,这些工具和库不能包含在应用程序镜像中。出于安全原因,应用程序镜像可能没有权限执行初始化活动。或者,您可能希望推迟应用程序的启动,直到满足外部依赖关系。对于所有这些用例,Kubernetes 使用初始化容器作为该模式的实现,允许将初始化活动与主应用程序职责分离。
解决方案
Kubernetes 中的初始化容器是 Pod 定义的一部分,它们将 Pod 中的所有容器分为两组:初始化容器和应用容器。所有初始化容器按顺序逐个执行,它们全部必须成功终止,然后才能启动应用容器。在这个意义上,初始化容器就像 Java 类中的构造器指令,帮助对象初始化。另一方面,应用容器并行运行,启动顺序是任意的。执行流程在 图 15-1 中演示。

图 15-1. Pod 中的初始化和应用容器
通常情况下,期望 init 容器小巧、快速运行并成功完成,除非使用 init 容器延迟 Pod 启动以等待依赖项,此时它可能不会在依赖项满足之前终止。如果 init 容器失败,整个 Pod 将重新启动(除非标记为 RestartNever),导致所有 init 容器再次运行。因此,为了防止任何副作用,使 init 容器具有幂等性是一个良好的实践。
一方面,init 容器与应用容器具有相同的能力:它们都是同一 Pod 的一部分,因此共享资源限制、卷和安全设置,并最终被放置在同一节点上。另一方面,它们具有稍微不同的生命周期、健康检查和资源处理语义。对于 init 容器,没有 livenessProbe、readinessProbe 或 startupProbe,因为所有 init 容器必须在 Pod 启动过程中的应用容器继续之前成功终止。
Init 容器还会影响 Pod 资源需求的计算方式,用于调度、自动扩展和配额管理。考虑到 Pod 中所有容器执行的顺序(首先,init 容器按顺序运行,然后所有应用容器并行运行),有效的 Pod 级请求和限制值将成为以下两组最高值之一:
-
最高的 init 容器请求/限制值
-
所有应用容器请求/限制值的总和
这种行为的一个后果是,如果您的 init 容器需求高而应用容器需求低,则影响调度的 Pod 级请求和限制值将基于 init 容器的较高值,如 图 15-2 所示。

图 15-2. 有效的 Pod 请求/限制计算
尽管 init 容器运行时间短且大部分时间节点上有可用容量,但此设置并不高效。其他 Pod 不能使用该容量。
此外,init 容器使关注点分离,允许您保持容器的单一目的性。应用工程师可以创建应用容器,专注于应用逻辑。部署工程师可以编写 init 容器,专注于配置和初始化任务。我们在 示例 15-1 中演示了这一点,其中有一个基于 HTTP 服务器提供文件的应用容器。
容器提供了通用的 HTTP 服务能力,并且不假设用于不同用例中的服务文件的来源。在同一个 Pod 中,一个初始化容器提供了 Git 客户端的能力,其唯一目的是克隆一个 Git 仓库。由于这两个容器都属于同一个 Pod,它们可以访问同一个卷来共享数据。我们使用相同的机制来从初始化容器向应用容器共享克隆的文件。
示例 15-1 展示了将数据复制到空卷的初始化容器。
示例 15-1. 初始化容器
apiVersion: v1
kind: Pod
metadata:
name: www
labels:
app: www
spec:
initContainers:
- name: download
image: axeclbr/git
command: 
- git
- clone
- https://github.com/mdn/beginner-html-site-scripted
- /var/lib/data
volumeMounts: 
- mountPath: /var/lib/data
name: source
containers:
- name: run
image: docker.io/centos/httpd
ports:
- containerPort: 80
volumeMounts: 
- mountPath: /var/www/html
name: source
volumes: 
- emptyDir: {}
name: source
克隆外部 Git 仓库到挂载目录中。
由初始化容器和应用容器共享的卷。
在节点上用于数据共享的空目录。
我们也可以通过使用 ConfigMap 或 PersistentVolumes 实现相同的效果,但是这里我们想演示初始化容器的工作原理。这个例子展示了初始化容器与主容器共享卷的典型用法模式。
提示
为了调试初始化容器的结果,可以临时用一个虚拟的 sleep 命令替换应用容器的命令,这样您有时间检查情况。如果初始化容器启动失败,导致配置丢失或损坏,这个技巧尤其有用。在 Pod 声明中使用以下命令,可以进入 Pod 来调试由 kubectl exec -it *<pod>* sh 挂载的卷:
command:
- /bin/sh
- "-c"
- "sleep 3600"
使用 Sidecar 可以达到类似的效果,如下文所述的 第十六章,“Sidecar”,其中 HTTP 服务器容器和 Git 容器作为应用容器并行运行。但是使用 Sidecar 方法时,无法确保哪个容器会首先运行,并且 Sidecar 适用于容器持续并行运行的情况。如果需要保证初始化和数据持续更新,则还可以同时使用 Sidecar 和初始化容器。
讨论
那么为什么将 Pod 中的容器分成两组?为什么不仅仅使用一个应用容器,并在需要时在 Pod 中使用一点脚本进行初始化?答案是这两组容器具有不同的生命周期、目的甚至在某些情况下还有不同的作者。
在应用容器之前运行初始化容器,更重要的是,使初始化容器按阶段运行,只有当前初始化容器成功完成时才能继续进行,这意味着在初始化的每一步,您可以确保前一步已成功完成,并且可以继续下一个阶段。 相比之下,应用容器并行运行,并且不像初始化容器提供类似的保证。 有了这个区别,我们可以创建专注于初始化或应用任务的容器,并通过将它们组织在具有可预测保证的 Pod 中,在不同的上下文中重复使用它们。
更多信息
第十六章:Sidecar
一个 sidecar 容器可以在不更改现有容器的情况下扩展和增强其功能。Sidecar 模式是允许单一用途容器紧密协作的基本容器模式之一。在本章中,您将学习有关基本 sidecar 概念的所有内容。专门的后续模式 Adapter 和 Ambassador 分别在第十七章和第十八章中讨论。
问题
容器是一种流行的打包技术,允许开发人员和系统管理员以统一的方式构建、发布和运行应用程序。容器代表着功能单元的自然边界,具有独特的运行时、发布周期、API 和团队所有权。一个合适的容器就像一个单独的 Linux 进程一样——解决一个问题,并且做得很好——并且是可替换和可重用的概念创建的。这最后一部分至关重要,因为它允许我们通过利用现有的专门容器来更快地构建应用程序。
如今,要进行 HTTP 调用,我们不必编写客户端库,而是可以使用现有的库。同样,要为网站提供服务,我们不必为 web 服务器创建容器,而是可以使用现有的容器。这种方法使开发人员可以避免重复发明轮子,并创建一个维护较少、质量更好的容器生态系统。然而,拥有单一用途的可重用容器需要扩展容器功能的方式以及容器之间协作的手段。sidecar 模式描述了这种协作方式,其中一个容器增强另一个现有容器的功能。
解决方案
在第一章中,我们描述了 Pod 原语如何允许我们将多个容器组合成一个单元。在幕后,在运行时,Pod 也是一个容器,但它是以暂停的状态启动的(确切地说是通过 pause 命令),在所有其他容器之前启动。它除了在整个 Pod 生命周期内持有应用程序容器使用的所有 Linux 命名空间之外,什么也不做。除了这个实现细节,更有趣的是 Pod 抽象提供的所有特性。
Pod 是一种基本的原语,在许多云原生平台中以不同的名称存在,但都具有类似的功能。作为部署单元的 Pod 对其内的容器施加了特定的运行时约束。例如,所有容器都部署在同一节点上,并且它们共享相同的 Pod 生命周期。此外,Pod 允许其内的容器共享卷,并且可以通过本地网络或主机 IPC 进行通信。这些是用户将一组容器放入 Pod 中的原因。Sidecar(有时也称为 Sidekick)用于描述将一个容器放入 Pod 中以扩展和增强另一个容器行为的场景。
典型的示例展示了 HTTP 服务器和 Git 同步器的模式。HTTP 服务器容器专注于仅通过 HTTP 提供文件服务,并不知道文件如何或从何处获取。类似地,Git 同步器容器的唯一目标是将数据从 Git 服务器同步到本地文件系统。一旦同步完成,它并不关心接下来发生的事情,它唯一关心的是保持本地文件夹与远程 Git 服务器同步。Example 16-1 展示了一个 Pod 定义,配置了这两个容器以使用卷进行文件交换。
示例 16-1. 带有 Sidecar 的 Pod
apiVersion: v1
kind: Pod
metadata:
name: web-app
spec:
containers:
- name: app
image: docker.io/centos/httpd 
ports:
- containerPort: 80
volumeMounts:
- mountPath: /var/www/html 
name: git
- name: poll
image: axeclbr/git 
volumeMounts:
- mountPath: /var/lib/data 
name: git
env:
- name: GIT_REPO
value: https://github.com/mdn/beginner-html-site-scripted
command:
- "sh"
- "-c"
- "git clone $(GIT_REPO) . && watch -n 600 git pull"
workingDir: /var/lib/data
volumes:
- emptyDir: {}
name: git
主应用容器通过 HTTP 提供文件服务。
Sidecar 容器并行运行,并从 Git 服务器拉取数据。
用于在 app 和 poll 容器中作为共享位置交换数据的位置。
此示例显示了 Git 同步器如何增强 HTTP 服务器的行为以及保持同步。我们也可以说这两个容器是协作且同等重要的,但在 Sidecar 模式中,存在一个主容器和一个辅助容器来增强集体行为。通常,主容器是容器列表中列出的第一个,并且代表默认容器(例如,当我们运行命令 kubectl exec 时)。
这种简单的模式,如图 Figure 16-1 所示,允许容器在运行时进行协作,同时为两个容器分离关注点,这些容器可能由不同团队拥有,使用不同的编程语言,具有不同的发布周期等。它还促进了容器的可替代性和重用性,例如 HTTP 服务器和 Git 同步器可以在其他应用程序和不同配置中重复使用,可以作为 Pod 中的单个容器或与其他容器协作。

图 16-1. Sidecar 模式
讨论
之前我们说过,容器镜像就像类,容器就像面向对象编程(OOP)中的对象。如果我们继续这个类比,扩展容器以增强其功能类似于 OOP 中的继承,而在 Pod 中协作多个容器则类似于 OOP 中的组合。虽然两种方法都允许代码重用,但继承涉及容器之间更紧密的耦合,并代表容器之间的“是一个”关系。
另一方面,Pod 中的组合表示一种“拥有关系”,更灵活,因为它不会在构建时将容器耦合在一起,这使您可以稍后在 Pod 定义中交换容器。使用组合方法,您可以运行多个(进程)容器,像主应用程序容器一样进行健康检查、重启和资源消耗。现代边车容器体积小,消耗资源少,但您必须决定是否值得运行单独的进程,还是将其合并到主容器中。
我们看到两种主流的使用边车的方法:透明边车对应用程序是不可见的,显式边车则通过定义良好的 API 与主应用程序交互。使节代理是透明边车的一个例子,它与主容器一起运行,并通过提供诸如传输层安全性(TLS)、负载均衡、自动重试、断路器、全局速率限制、L7 流量的可观察性、分布式跟踪等共同功能来抽象网络。通过透明地附加边车容器并拦截主容器的所有传入和传出流量,所有这些功能对应用程序都是可用的。这与面向方面的编程类似,在此编程模型中,通过额外的容器,我们向 Pod 引入了正交的能力,而不必触及主容器。
使用边车架构的显式代理示例是 Dapr。Dapr 边车容器被注入到 Pod 中,并提供可靠的服务调用、发布-订阅、对外系统的绑定、状态抽象、可观察性、分布式跟踪等功能。Dapr 与 Envoy 代理的主要区别在于,Dapr 不拦截应用程序的所有网络流量。相反,Dapr 的特性通过 HTTP 和 gRPC API 暴露给应用程序调用或订阅。
更多信息
第十七章:适配器
适配器模式将异构的容器化系统转换为符合统一接口的一致格式,可被外部世界消费的标准化和规范化格式。适配器模式继承了边车模式的所有特征,但其单一目的是提供对应用程序的适配访问。
问题
容器允许我们以统一的方式打包和运行使用不同库和语言编写的应用程序。如今,多个团队使用不同技术创建由异构组件组成的分布式系统已经很普遍。这种异构性在其他系统需要以统一方式处理所有组件时可能会导致困难。适配器模式通过隐藏系统复杂性并提供统一访问来解决这一问题。
解决方案
最好的方式来说明适配器模式是通过一个例子。成功运行和支持分布式系统的一个重要前提是提供详细的监控和警报。此外,如果我们有一个由多个服务组成的分布式系统需要监控,我们可以使用外部监控工具从每个服务中轮询指标并记录它们。
然而,使用不同语言编写的服务可能没有相同的能力,并且可能不以监控工具期望的相同格式暴露指标。这种多样性为监控来自一个期望整个系统统一视图的单一监控解决方案的异构应用程序创建了挑战。通过适配器模式,可以通过将各种应用程序容器的指标导出为一个标准格式和协议,提供统一的监控接口。在图 17-1 中,适配器容器将本地存储的指标信息转换为监控服务器理解的外部格式。

图 17-1. 适配器模式
采用这种方法,每个由 Pod 代表的服务,除了主应用程序容器外,还会有另一个容器,负责读取定制的应用程序特定指标,并以监控工具可理解的通用格式公开这些指标。我们可以有一个适配器容器,它知道如何通过 HTTP 导出基于 Java 的指标,以及另一个位于不同 Pod 中的适配器容器,它通过 HTTP 公开基于 Python 的指标。对于监控工具,所有指标都将通过 HTTP 以及统一的、规范化的格式可用。
对于这种模式的具体实现,让我们将示例随机生成器应用程序中显示的适配器添加到图 17-1。在正确配置的情况下,它会写出一个包含随机数生成器及其创建时间的日志文件。我们希望用 Prometheus 监视这个时间。不幸的是,日志格式与 Prometheus 期望的格式不匹配。此外,我们需要通过 HTTP 端点提供此信息,以便 Prometheus 服务器可以抓取该值。
对于这种用例,适配器非常合适:一个旁路容器启动一个小型 HTTP 服务器,并在每个请求时读取自定义日志文件,并将其转换为 Prometheus 可理解的格式。示例 17-1 展示了带有这种适配器的部署。这种配置允许解耦的 Prometheus 监视设置,而无需主应用程序了解 Prometheus 的任何信息。书中 GitHub 仓库中的完整示例演示了这一设置以及 Prometheus 安装。
示例 17-1. 提供符合 Prometheus 的输出的适配器
apiVersion: apps/v1
kind: Deployment
metadata:
name: random-generator
spec:
replicas: 1
selector:
matchLabels:
app: random-generator
template:
metadata:
labels:
app: random-generator
spec:
containers:
- image: k8spatterns/random-generator:1.0 
name: random-generator
env:
- name: LOG_FILE 
value: /logs/random.log
ports:
- containerPort: 8080
protocol: TCP
volumeMounts: 
- mountPath: /logs
name: log-volume
# --------------------------------------------
- image: k8spatterns/random-generator-exporter 
name: prometheus-adapter
env:
- name: LOG_FILE 
value: /logs/random.log
ports:
- containerPort: 9889
protocol: TCP
volumeMounts: 
- mountPath: /logs
name: log-volume
volumes:
- name: log-volume 
emptyDir: {}
主应用程序容器,随机生成服务暴露在 8080 端口。
包含有关随机数生成的时间信息的日志文件路径。
与 Prometheus 适配器容器共享的目录。
Prometheus 导出程序镜像,导出端口 9889。
与主应用程序记录日志的同一日志文件路径。
适配器容器中也装载了共享卷。
文件通过节点文件系统的emptyDir卷共享。
另一个这种模式的用途是日志记录。不同的容器可能以不同的格式和详细级别记录信息。使用描述在第十四章中的自我感知模式,适配器可以规范化这些信息,清理它们,通过上下文信息丰富它们,然后使其可供集中日志聚合器获取。
讨论
适配器是在第十六章中解释的旁路模式的一个特例。它充当反向代理,隐藏了复杂性,通过统一接口向异构系统提供服务。使用不同于通用旁路模式的独特名称,使我们能更精确地传达这种模式的目的。
在下一章中,您将了解另一个旁路变体:大使模式,作为与外部世界的代理。
更多信息
第十八章:大使
大使模式是一个专门的旁路器,负责隐藏外部复杂性并提供统一接口,用于访问 Pod 外部的服务。在本章中,您将看到大使模式如何作为代理,将主容器与直接访问外部依赖项解耦。
问题
容器化服务并不孤立存在,很多时候需要访问其他可能难以可靠访问的服务。访问其他服务的困难可能是由于动态和变化的地址、需要对集群服务实例进行负载平衡、不可靠的协议或困难的数据格式等原因。理想情况下,容器应该是单一用途的,并且可以在不同的上下文中重复使用。但是,如果我们有一个容器提供一些业务功能,并以特殊方式消耗外部服务,那么这个容器将具有不止一个职责。
消耗外部服务可能需要一个特殊的服务发现库,我们不希望将其放入我们的容器中。或者我们可能希望通过使用不同类型的服务发现库和方法来交换不同类型的服务。这种抽象和隔离访问外部服务逻辑的技术是这种大使模式的目标。
解决方案
为了演示这种模式,我们将为应用程序使用缓存。在开发环境中访问本地缓存可能只是一个简单的配置,但在生产环境中,我们可能需要一个客户端配置,可以连接到不同的缓存分片。另一个例子是通过在注册表中查找并执行客户端服务发现来消耗服务。第三个例子是通过不可靠的协议(如 HTTP)消耗服务,因此为了保护我们的应用程序,我们必须使用断路器逻辑、配置超时、执行重试等。
在所有这些情况下,我们可以使用一个大使容器来隐藏访问外部服务的复杂性,并通过 localhost 为主应用程序容器提供简化的视图和访问。图 18-1 和 18-2 显示了一个大使 Pod 如何通过连接到监听本地端口的大使容器来解耦对键值存储的访问。在 图 18-1 中,我们可以看到数据访问如何被委派给完全分布式的远程存储,如 etcd。

图 18-1. 访问远程分布式缓存的大使
为了开发目的,此大使容器可以轻松与本地运行的内存键值存储(例如 memcached,如图 18-2 所示)进行交换。

图 18-2. 访问本地缓存的大使
示例 18-1 展示了一个与 REST 服务并行运行的 ambassador。在返回其响应之前,REST 服务通过将生成的数据发送到固定的 URL:localhost:9009来记录数据。Ambassador 进程监听此端口并处理数据。在此示例中,它仅将数据打印到控制台,但也可以执行更复杂的操作,比如将数据转发到完整的日志基础设施。对于 REST 服务来说,日志数据的处理方式并不重要,您可以通过重新配置 Pod 而不接触主容器,轻松地更换 ambassador。
示例 18-1. Ambassador 处理日志输出
apiVersion: v1
kind: Pod
metadata:
name: random-generator
labels:
app: random-generator
spec:
containers:
- image: k8spatterns/random-generator:1.0 
name: main
env:
- name: LOG_URL 
value: http://localhost:9009
ports:
- containerPort: 8080
protocol: TCP
- image: k8spatterns/random-generator-log-ambassador 
name: ambassador
主应用程序容器提供 REST 服务以生成随机数。
通过本地主机与 ambassador 通信的连接 URL。
Ambassador 并行运行并侦听 9009 端口(该端口不对 Pod 外部公开)。
讨论
从更高的层次来看,Ambassador 模式是 Sidecar 模式的一种。Ambassador 和 sidecar 的主要区别在于,ambassador 不会通过附加功能增强主应用程序,而是仅作为对外智能代理(有时这种模式也被称为 Proxy 模式)。这种模式对于难以通过现代网络概念(如监视、日志记录、路由和弹性模式)修改和扩展的传统应用程序非常有用。
Ambassador 模式的好处类似于 Sidecar 模式——两者都允许您保持容器的单一用途和可重复使用性。通过这种模式,我们的应用程序容器可以专注于其业务逻辑,并将消费外部服务的责任和具体细节委托给另一个专门的容器。这还允许您创建专门的、可重复使用的 ambassador 容器,可以与其他应用程序容器组合使用。
更多信息
第四部分:配置模式
每个应用程序都需要配置,而最简单的方法是将配置存储在源代码中。然而,这种方法的副作用是代码和配置的生命周期紧密相关。我们需要灵活性来适应配置,而无需修改应用程序并重新创建其容器映像。事实上,将代码和配置混合在一起是持续交付方法的反模式,其中应用程序只创建一次,然后在部署管道的各个阶段中不经修改地移动,直到达到生产环境。实现代码和配置分离的方法是使用外部配置数据,每个环境的数据都不同。以下章节中的模式都是关于使用外部配置自定义和适应各种环境的应用程序:
-
第十九章,“环境变量配置”,使用环境变量存储配置数据。
-
第二十章,“配置资源”,使用像 ConfigMaps 或 Secrets 这样的 Kubernetes 资源来存储配置信息。
-
第二十一章,“不可变配置”,通过将大型配置集成容器中,在运行时将其链接到应用程序,实现了配置的不可变性。
-
第二十二章,“配置模板”,适用于需要管理大量仅略有不同的多个环境的配置文件时非常有用。
第十九章:环境变量配置
在这种 环境变量配置 模式中,我们探讨了配置应用程序的最简单方法。对于一小组配置值,将它们放入广泛支持的环境变量中是外部化配置的最简单方式。我们将看到在 Kubernetes 中声明环境变量的不同方法,但同时也会看到使用环境变量进行复杂配置的限制。
问题
每个非平凡的应用程序都需要一些配置来访问数据源、外部服务或生产级调整。我们早在 十二要素应用宣言 发布之前就知道,在应用程序中硬编码配置是一件坏事。相反,应将配置 外部化,以便在构建应用程序之后甚至可以进行更改。这为容器化应用程序提供了更多价值,促进共享不可变应用程序构件。但在容器化世界中,如何才能做到最好呢?
解决方案
十二要素应用宣言建议使用环境变量存储应用程序配置。这种方法简单且适用于任何环境和平台。每个操作系统都知道如何定义环境变量并将其传播给应用程序,每种编程语言也允许轻松访问这些环境变量。可以说环境变量具有普遍适用性。在使用环境变量时,典型的使用模式是在构建时定义硬编码的默认值,然后在运行时进行覆盖。让我们看看在 Docker 和 Kubernetes 中如何实现这一点的具体示例。
对于 Docker 镜像,可以直接在 Dockerfile 中使用 ENV 指令定义环境变量。可以逐行定义,也可以一行定义多个,就像 示例 19-1 中展示的那样。
示例 19-1. 带有环境变量的 Dockerfile 示例
FROM openjdk:11
ENV PATTERN "EnvVar Configuration"
ENV LOG_FILE "/tmp/random.log"
ENV SEED "1349093094"
# Alternatively:
ENV PATTERN="EnvVar Configuration" LOG_FILE=/tmp/random.log SEED=1349093094
...
那么在此类容器中运行的 Java 应用程序可以通过调用 Java 标准库轻松访问这些变量,如 示例 19-2 所示。
示例 19-2. 在 Java 中读取环境变量
public Random initRandom() {
long seed = Long.parseLong(System.getenv("SEED"));
return new Random(seed); 
}
使用 EnvVar 初始化随机数生成器的种子。
直接运行这样的镜像将使用默认的硬编码值。但在大多数情况下,您希望从镜像外部覆盖这些参数。
在直接使用 Docker 运行这样的镜像时,可以通过调用 Docker 的命令行来设置环境变量,如 示例 19-3 所示。
Example 19-3. 在启动 Docker 容器时设置环境变量
docker run -e PATTERN="EnvVarConfiguration" \
-e LOG_FILE="/tmp/random.log" \
-e SEED="147110834325" \
k8spatterns/random-generator:1.0
对于 Kubernetes,这些类型的环境变量可以直接在控制器的 Pod 规范中设置,如部署或副本集中所示(如 示例 19-4 所示)。
示例 19-4. 配置环境变量的部署
apiVersion: v1
kind: Pod
metadata:
name: random-generator
spec:
containers:
- image: k8spatterns/random-generator:1.0
name: random-generator
env:
- name: LOG_FILE
value: /tmp/random.log 
- name: PATTERN
valueFrom:
configMapKeyRef: 
name: random-generator-config 
key: pattern 
- name: SEED
valueFrom:
secretKeyRef: 
name: random-generator-secret
key: seed
使用文字值的 EnvVar。
来自 ConfigMap 的 EnvVar。
ConfigMap 的名称。
ConfigMap 中查找 EnvVar 值的键。
从 Secret 中获取的 EnvVar(查找语义与 ConfigMap 相同)。
在这样的 Pod 模板中,您不仅可以直接附加值到环境变量(例如 LOG_FILE),还可以委托给 Kubernetes Secrets 和 ConfigMaps。ConfigMap 和 Secret 的间接性的优势在于可以独立于 Pod 定义管理环境变量。在 第二十章,“配置资源” 中详细解释了 Secret 和 ConfigMap 及其优缺点。
在前面的示例中,SEED 变量来自 Secret 资源。虽然这是 Secret 的一个完全有效的用法,但也很重要指出环境变量不是安全的。将敏感且可读的信息放入环境变量中使得这些信息易于阅读,甚至可能泄露到日志中。
您还可以通过 envFrom 导入特定 Secret 或 ConfigMap 的 所有 值,而不是单独引用 Secrets 或 ConfigMaps 的配置值。我们在 第二十章,“配置资源” 中详细解释了此字段,在详细讨论 ConfigMaps 和 Secrets 时会谈到。
可与环境变量一起使用的另外两个有价值的功能是 downward API 和 dependent variables。您在 第十四章,“自我意识” 中已经学习了有关 downward API 的全部内容,现在让我们看一下 示例 19-5 中的 dependent variables,这使您可以引用先前定义的变量作为其他条目值定义中的值。
示例 19-5. Dependent environment variables
apiVersion: v1
kind: Pod
metadata:
name: random-generator
spec:
containers:
- image: k8spatterns/random-generator:1.0
name: random-generator
env:
- name: PORT
value: "8181"
- name: IP 
valueFrom:
fieldRef:
fieldPath: status.podIP
- name: MY_URL
value: "https://$(IP):$(PORT)" 
使用 downward API 获取 Pod 的 IP。详细讨论 downward API,请参阅 第十四章,“自我意识”。
包含先前定义的环境变量 IP 和 PORT 来构建 URL。
使用 $(...) 表示法,您可以引用在 env 列表中先前定义的环境变量或来自 envFrom 导入的变量。Kubernetes 将在容器启动期间解析这些引用。不过要注意顺序:如果引用列表中稍后定义的变量,它将不会被解析,并且 $(...) 引用将直接采用字面量。此外,您还可以在 Pod 命令中使用此语法引用环境变量,如 示例 19-6 所示。
示例 19-6. 在容器命令定义中使用环境变量
apiVersion: v1
kind: Pod
metadata:
name: random-generator
spec:
containers:
- name: random-generator
image: k8spatterns/random-generator:1.0
command: [ "java", "RandomRunner", "$(OUTPUT_FILE)", "$(COUNT)" ] 
env: 
- name: OUTPUT_FILE
value: "/numbers.txt"
- name: COUNT
valueFrom:
configMapKeyRef:
name: random-config
key: RANDOM_COUNT
启动容器的启动命令的参考环境变量。
命令中替换的环境变量的定义。
讨论
环境变量易于使用,每个人都知道它们。这个概念与容器无缝映射,并且每个运行时平台都支持环境变量。但是环境变量不安全,仅适用于相当数量的配置值。当需要配置大量不同的参数时,管理所有这些环境变量变得笨拙。
在这些情况下,许多人使用额外的间接级别,并将配置放入各种配置文件中,每个环境一个文件。然后使用单个环境变量选择其中一个文件。Spring Boot 的Profiles就是这种方法的一个示例。由于这些配置文件通常存储在应用程序自身内部,即在容器内部,这将配置与应用程序紧密耦合。这经常导致开发和生产的配置最终并排存放在同一个 Docker 镜像中,每次更改任何一个环境都需要重新构建镜像。我们不推荐这种设置(配置应始终外部化),但这种解决方案表明环境变量仅适用于小到中等配置集。
在更复杂的配置需求出现时,下一章节描述的配置资源、不可变配置和配置模板是良好的替代方案。
环境变量是通用的,因此我们可以在不同层次设置它们。这种选择导致配置定义的碎片化,并且很难追踪给定环境变量的设置位置。当没有一个集中的地方定义所有环境变量时,调试配置问题就变得困难。
环境变量的另一个缺点是它们只能在应用程序启动之前设置,不能稍后更改。一方面,不能在运行时“热”更改配置是一个缺点,以调整应用程序。然而,许多人认为这是一个优点,因为它促进了配置的不可变性。这里的不可变性意味着你丢弃正在运行的应用程序容器,并启动一个具有修改配置的新副本,很可能使用滚动更新等平滑的部署策略。这样,你始终处于定义明确和良好已知的配置状态。
环境变量使用简单,但主要适用于简单用例,并且对于复杂配置需求有限制。下面的模式展示了如何克服这些限制。
更多信息
第二十章:配置资源
Kubernetes 提供了用于常规和机密数据的原生配置资源,允许您将配置生命周期与应用程序生命周期解耦。配置资源 模式解释了 ConfigMap 和 Secret 资源的概念,以及如何使用它们,以及它们的局限性。
问题
“EnvVar Configuration” 模式的一个显著缺点,讨论见 第十九章,是它仅适用于少量变量和简单配置。另一个缺点是,由于环境变量可以在各种地方定义,因此很难找到变量的定义。即使找到了,也不能完全确定它不会在其他位置被覆盖。例如,在 Kubernetes 部署资源中,OCI 镜像内定义的环境变量可能在运行时被替换。
通常,将所有配置数据放在一个地方而不是分散在各种资源定义文件中更好。但是,将整个配置文件的内容放入环境变量中是没有意义的。因此,一些额外的间接方式可以提供更多灵活性,这正是 Kubernetes 配置资源所提供的。
解决方案
Kubernetes 提供了专用的配置资源,比纯环境变量更灵活。这些是用于一般用途和敏感数据的 ConfigMap 和 Secret 对象。
我们可以以相同的方式使用两者,因为它们都提供键值对的存储和管理。当我们描述 ConfigMaps 时,大多数时间也可以应用于 Secrets。除了实际数据编码(Secrets 的 Base64 编码外),在使用 ConfigMaps 和 Secrets 时没有技术上的区别。
一旦创建了 ConfigMap 并存储了数据,我们可以以两种方式使用 ConfigMap 的键:
-
作为 环境变量 的引用,其中键是环境变量的名称。
-
作为映射到 Pod 中挂载卷的 文件。键用作文件名。
当通过 Kubernetes API 更新 ConfigMap 时,挂载的 ConfigMap 卷中的文件也会更新。因此,如果应用程序支持配置文件的热重载,它可以立即从此类更新中受益。但是,使用 ConfigMap 条目作为环境变量时,更新不会反映出来,因为环境变量在进程启动后无法更改。
除了 ConfigMap 和 Secret,另一种选择是直接将配置存储在外部卷中,然后挂载。
以下示例集中于 ConfigMap 的使用,但它们也可以用于 Secrets。但是有一个重要区别:Secrets 的值必须进行 Base64 编码。
ConfigMap 资源在其 data 部分包含键值对,如 示例 20-1 所示。
示例 20-1. ConfigMap 资源
apiVersion: v1
kind: ConfigMap
metadata:
name: random-generator-config
data:
PATTERN: Configuration Resource 
application.properties: |
# Random Generator config
log.file=/tmp/generator.log
server.port=7070
EXTRA_OPTIONS: "high-secure,native"
SEED: "432576345"
ConfigMaps 可以作为环境变量和挂载文件访问。我们建议在 ConfigMap 中使用大写键来表示 EnvVar 的用法,并在用作挂载文件时使用适当的文件名。
我们在这里看到,ConfigMap 还可以承载完整配置文件的内容,例如本示例中的 Spring Boot application.properties。您可以想象,对于非平凡的用例,此部分可能会变得相当大!
不必手动创建完整的资源描述符,我们也可以使用kubectl创建 ConfigMaps 或 Secrets。对于前述示例,等效的kubectl命令如示例 20-2 所示。
示例 20-2. 从文件创建 ConfigMap
kubectl create cm spring-boot-config \
--from-literal=PATTERN="Configuration Resource" \
--from-literal=EXTRA_OPTIONS="high-secure,native" \
--from-literal=SEED="432576345" \
--from-file=application.properties
然后可以在各处读取此 ConfigMap——在定义环境变量的每个地方,正如示例 20-3 所示。
示例 20-3. 从 ConfigMap 设置的环境变量
apiVersion: v1
kind: Pod
metadata:
name: random-generator
spec:
containers:
- env:
- name: PATTERN
valueFrom:
configMapKeyRef:
name: random-generator-config
key: PATTERN
....
如果一个 ConfigMap 有许多条目需要作为环境变量使用,使用特定的语法可以节省大量的输入。与单独指定每个条目不同,在env部分的前述示例中,envFrom允许您公开所有具有也可以用作有效环境变量的键的 ConfigMap 条目。我们可以以前缀的形式添加这个,如示例 20-4 所示。任何不能作为环境变量使用的键都会被忽略(例如,"illeg.al")。当指定了具有重复键的多个 ConfigMaps 时,envFrom中的最后一个条目优先。而且,直接使用env设置的任何同名环境变量具有更高的优先级。
示例 20-4. 将 ConfigMap 的所有条目设置为环境变量
apiVersion: v1
kind: Pod
metadata:
name: random-generator
spec:
containers:
envFrom: 
- configMapRef:
name: random-generator-config
prefix: CONFIG_ 
检索可以用作环境变量名称的 ConfigMap random-generator-config 的所有键。
使用CONFIG_前缀所有适合的 ConfigMap 键。通过在示例 20-1 中定义的 ConfigMap,这将导致三个公开的环境变量:CONFIG_PATTERN_NAME、CONFIG_EXTRA_OPTIONS和CONFIG_SEED。
Secrets,与 ConfigMaps 一样,也可以作为环境变量使用,无论是每个条目还是所有条目。要访问 Secret 而不是 ConfigMap,请用secretKeyRef替换configMapKeyRef。
当将 ConfigMap 用作卷时,它的完整内容将投射到此卷中,其中键用作文件名。参见示例 20-5。
示例 20-5. 将 ConfigMap 挂载为卷
apiVersion: v1
kind: Pod
metadata:
name: random-generator
spec:
containers:
- image: k8spatterns/random-generator:1.0
name: random-generator
volumeMounts:
- name: config-volume
mountPath: /config
volumes:
- name: config-volume
configMap: 
name: random-generator-config
一个由 ConfigMap 支持的卷将包含与条目数量相同的文件,其中映射的键作为文件名,映射的值作为文件内容。
作为卷挂载的示例例子 20-1 在 /config 文件夹中生成四个文件:一个 application.properties 文件,其中包含在 ConfigMap 中定义的内容,以及 PATTERN、EXTRA_OPTIONS 和 SEED 文件,每个文件都有单行内容。
配置数据的映射可以通过向卷声明添加额外属性来更加精细地调整。与其将所有条目都映射为文件,您还可以单独选择应该暴露的每个键、文件名以及可用的权限。示例 20-6 展示了如何精细选择 ConfigMap 的哪些部分作为卷暴露。
示例 20-6. 有选择地将 ConfigMap 条目作为卷暴露
apiVersion: v1
kind: Pod
metadata:
name: random-generator
spec:
containers:
- image: k8spatterns/random-generator:1.0
name: random-generator
volumeMounts:
- name: config-volume
mountPath: /config
volumes:
- name: config-volume
configMap:
name: random-generator-config
items: 
- key: application.properties 
path: spring/myapp.properties
mode: 0400
要暴露为卷的 ConfigMap 条目列表。
仅将 application.properties 从 ConfigMap 暴露在路径 spring/myapp.properties 下,文件模式为 0400。
如您所见,对 ConfigMap 的更改会直接反映在作为文件包含 ConfigMap 内容的投影卷中。应用程序可以监视这些文件并立即获取任何更改。这种热重载非常有用,可以避免应用程序的重新部署,从而避免服务中断。另一方面,这些实时更改并未被任何地方跟踪,容易在重新启动期间丢失。这些临时更改可能导致配置漂移,难以检测和分析。这就是为什么许多人更喜欢一种不可变配置,一旦部署后就保持不变。我们在第二十一章,“不可变配置”中专门介绍了这一范式,但使用 ConfigMap 和 Secrets 也有一种简单的方法可以轻松实现这一点。
自版本 1.21 起,Kubernetes 支持 ConfigMaps 和 Secrets 的 immutable 字段,如果设置为 true,则阻止资源在创建后更新。除了防止不必要的更新外,使用不可变的 ConfigMaps 和 Secrets 还显著提升了集群的性能,因为 Kubernetes API 服务器不需要监视这些不可变对象的更改。示例 20-7 展示了如何声明一个不可变的 Secret。一旦在集群上存储了这样一个 Secret,唯一更改它的方法是删除并重新创建更新后的 Secret。任何引用此 Secret 的运行中 Pod 也需要重新启动。
示例 20-7. 不可变的 Secret
apiVersion: v1
kind: Secret
metadata:
name: random-config
data:
user: cm9sYW5k
immutable: true 
声明 Secret 的可变性的布尔标志(默认为 false)。
讨论
ConfigMaps 和 Secrets 允许您将配置信息存储在专用资源对象中,通过 Kubernetes API 很容易管理。使用 ConfigMaps 和 Secrets 的最大优势是,它们将配置数据的定义与其使用分离。这种解耦允许我们独立管理使用配置的对象,而不依赖于配置的定义。ConfigMaps 和 Secrets 的另一个好处是它们是平台的固有特性。不像需要类似 第二十一章,“不可变配置” 中的自定义构造。
然而,这些配置资源也有它们的限制:Secrets 的大小限制为 1 MB,无法存储任意大的数据,也不适合非配置应用数据。您也可以在 Secrets 中存储二进制数据,但由于必须进行 Base64 编码,只能使用约 700 KB 的数据。现实世界的 Kubernetes 集群还会对每个命名空间或项目可以使用的 ConfigMap 数量设置单独的配额,因此 ConfigMap 不是万能的解决方案。
接下来的两章介绍如何通过使用不可变配置和配置模板模式来处理大型配置数据。
更多信息
第二十一章:不可变配置
不可变配置 模式提供了两种使配置数据不可变的方法,以确保应用程序的配置始终处于良好已知和记录的状态。通过此模式,我们不仅可以使用不可变和版本化的配置数据,还可以克服存储在环境变量或 ConfigMaps 中的配置数据的大小限制。
问题
正如您在 第十九章,“EnvVar 配置” 中看到的那样,环境变量提供了一种简单的方法来配置基于容器的应用程序。尽管它们易于使用并且得到普遍支持,但一旦环境变量的数量超过某个阈值,管理它们就变得困难。
这种复杂性可以通过使用 配置资源 在一定程度上加以处理,如 第二十章,“配置资源” 中所述,自 Kubernetes 1.21 起可以声明为 不可变。然而,ConfigMaps 仍然存在大小限制,因此如果您处理大型配置数据(例如机器学习上下文中的预计算数据模型),即使标记为不可变,ConfigMaps 也不适用。
不可变性 意味着应用程序启动后我们无法更改配置,以确保配置数据始终保持良好定义的状态。此外,不可变配置可以放入版本控制,并遵循变更控制流程。
解决方案
有几种选项可以解决配置不可变性的问题。最简单和首选的选项是使用在声明中标记为不可变的 ConfigMaps 或 Secrets。您在 第二十章 中已了解到不可变的 ConfigMaps。如果您的配置适合 ConfigMap 并且易于维护,则应首选 ConfigMaps。然而,在实际场景中,配置数据量可能会迅速增加。尽管 WildFly 应用服务器配置可能仍适合于 ConfigMap,但它非常庞大。当您需要将 XML 或 YAML 嵌套到 YAML 中时,情况变得非常复杂——也就是说,当您的配置内容也是 YAML 并且将其嵌入到 ConfigMaps 的 YAML 部分中时。编辑器对这种用例的支持有限,因此您必须非常小心地处理缩进,即使如此,您可能会多次出错(相信我们!)。另一个噩梦是需要在单个 ConfigMap 中维护数十个或数百个条目,因为您的应用程序需要许多不同的配置文件。虽然可以通过良好的工具来在一定程度上减少这种痛苦,但像预训练的机器学习数据模型这样的大型配置数据集因为后端大小限制为 1 MB 而无法使用 ConfigMap。
为了解决复杂配置数据的问题,我们可以将所有特定环境配置数据放入一个单独的被动数据镜像中,这个镜像可以像常规容器镜像一样分发。在运行时,应用程序和数据镜像进行链接,以便应用程序可以从数据镜像中提取配置。通过这种方式,可以轻松地为各种环境制作不同的配置数据镜像。这些镜像将所有特定环境的配置信息组合起来,并且可以像其他容器镜像一样进行版本控制。
创建这样一个数据镜像非常简单,因为它只是包含数据的简单容器镜像。挑战在于启动期间的链接步骤。我们可以根据平台使用各种方法。
Docker 卷
在探讨 Kubernetes 之前,让我们先回顾一下纯 Docker 的情况。在 Docker 中,容器可以通过 卷(volume) 共享来自容器的数据。在 Dockerfile 中使用 VOLUME 指令,可以指定一个稍后可以共享的目录。在启动过程中,容器内该目录的内容会复制到共享目录中。如 图 21-1 所示,这种卷链接是从专用配置容器向其他应用容器共享配置信息的优秀方式。

图 21-1. 使用 Docker 卷进行不可变配置
让我们看一个例子。对于开发环境,我们创建一个包含开发人员配置的 Docker 镜像,并创建一个带有挂载点 /config 的卷。我们可以使用 Dockerfile-config 创建这样的镜像,如 示例 21-1 所示。
示例 21-1. 用于配置镜像的 Dockerfile
FROM scratch
ADD app-dev.properties /config/app.properties 
VOLUME /config 
添加指定属性。
创建卷并将属性复制到其中。
现在我们使用 Docker CLI 创建镜像本身和 Docker 容器,如 示例 21-2 所示。
示例 21-2. 构建配置 Docker 镜像
docker build -t k8spatterns/config-dev-image:1.0.1 -f Dockerfile-config .
docker create --name config-dev k8spatterns/config-dev-image:1.0.1 .
最后一步是启动应用程序容器并将其连接到此配置容器(示例 21-3)。
示例 21-3. 使用配置容器链接启动应用程序容器
docker run --volumes-from config-dev k8spatterns/welcome-servlet:1.0
应用程序镜像期望其配置文件位于 /config 目录中,即由配置容器暴露的卷。当您将此应用程序从开发环境移至生产环境时,您只需更改启动命令。无需修改应用程序镜像本身。相反,您只需将应用容器与生产配置容器进行卷链接,如 示例 21-4 所示。
示例 21-4. 在生产环境中使用不同的配置
docker build -t k8spatterns/config-prod-image:1.0.1 -f Dockerfile-config .
docker create --name config-prod k8spatterns/config-prod-image:1.0.1 .
docker run --volumes-from config-prod k8spatterns/welcome-servlet:1.0
Kubernetes 初始容器
在 Kubernetes 中,Pod 内部的卷共享非常适合这种链接配置和应用容器的方式。然而,如果我们想将 Docker 卷链接技术转移到 Kubernetes 世界中,我们会发现目前 Kubernetes 并不支持容器卷。考虑到讨论的年龄和实现此功能的复杂性与其受益有限之间的关系,容器卷可能不会很快到来。
因此容器可以共享(外部)卷,但它们目前无法直接共享位于容器内部的目录。要在 Kubernetes 中使用不可变配置容器,我们可以使用来自第十五章的 Init Containers 模式,在启动期间初始化空的共享卷。
在 Docker 示例中,我们将配置 Docker 镜像基于scratch,这是一个空的 Docker 镜像,没有操作系统文件。我们不需要其他内容,因为我们只希望通过 Docker 卷共享配置数据。但是对于 Kubernetes 初始容器,我们需要基础镜像的帮助,将配置数据复制到共享的 Pod 卷中。这种情况下一个好的选择是busybox,它仍然很小,但允许我们使用简单的 Unix cp命令来完成此任务。
那么在底层如何初始化带有配置的共享卷?让我们看一个例子。首先,我们需要再次创建一个配置镜像,使用 Dockerfile,就像示例 21-5 中那样。
示例 21-5. 开发配置镜像
FROM busybox
ADD dev.properties /config-src/demo.properties
ENTRYPOINT [ "sh", "-c", "cp /config-src/* $1", "--" ] 
在这里使用一个 shell 以解析通配符。
与 示例 21-1 中的普通 Docker 情况唯一的区别是,我们有一个不同的基础镜像,并且我们添加了一个 ENTRYPOINT,在容器镜像启动时将属性文件复制到作为参数给出的目录中。现在可以在部署的.template.spec中的初始容器中引用此镜像(见示例 21-6)。
示例 21-6. 在初始容器中将配置复制到目标的部署
initContainers:
- image: k8spatterns/config-dev:1
name: init
args:
- "/config"
volumeMounts:
- mountPath: "/config"
name: config-directory
containers:
- image: k8spatterns/demo:1
name: demo
ports:
- containerPort: 8080
name: http
protocol: TCP
volumeMounts:
- mountPath: "/var/config"
name: config-directory
volumes:
- name: config-directory
emptyDir: {}
部署的 Pod 模板规范包含一个卷和两个容器:
-
卷
config-directory的类型是emptyDir,因此它将作为空目录在托管此 Pod 的节点上创建。 -
Kubernetes 在启动期间调用的初始容器是从我们刚刚创建的镜像构建的,并且我们设置了一个单一参数
/config,被镜像的ENTRYPOINT使用。此参数指示初始容器将其内容复制到指定的目录。目录/config是从卷config-directory挂载的。 -
应用容器挂载卷
config-directory以访问初始容器复制的配置。
图 21-2 说明了应用程序容器如何通过共享卷访问由 init 容器创建的配置数据。

图 21-2. 具有 init 容器的不可变配置
现在,要从开发环境更改配置到生产环境,我们只需交换 init 容器的镜像即可。我们可以通过更改 YAML 定义或使用kubectl进行更新来实现这一点。但是,必须为每个环境编辑资源描述符并不理想。如果您正在使用 Red Hat OpenShift,一个 Kubernetes 的企业分发版,OpenShift 模板可以帮助解决此问题。OpenShift 模板可以从单个模板创建不同环境的不同资源描述符。
OpenShift 模板
OpenShift 模板是常规的资源描述符,可以进行参数化。正如在示例 21-7 中看到的那样,我们可以轻松地将配置图像用作参数。
示例 21-7. 用于参数化配置图像的 OpenShift 模板
apiVersion: v1
kind: Template
metadata:
name: demo
parameters:
- name: CONFIG_IMAGE 
description: Name of configuration image
value: k8spatterns/config-dev:1
objects:
- apiVersion: apps/v1
kind: Deployment
// ....
spec:
template:
metadata:
// ....
spec:
initContainers:
- name: init
image: ${CONFIG_IMAGE} 
args: [ "/config" ]
volumeMounts:
- mountPath: /config
name: config-directory
containers:
- image: k8spatterns/demo:1
// ...
volumeMounts:
- mountPath: /var/config
name: config-directory
volumes:
- name: config-directory
emptyDir: {}
模板参数CONFIG_IMAGE声明。
使用模板参数。
我们在此处仅显示完整描述符的片段,但您可以快速识别我们在 init 容器声明中引用的CONFIG_IMAGE参数。如果我们在 OpenShift 集群上创建此模板,可以通过调用oc来实例化它,如示例 21-8 所示。
示例 21-8. 应用 OpenShift 模板以创建新应用程序
oc new-app demo -p CONFIG_IMAGE=k8spatterns/config-prod:1
运行此示例的详细说明以及完整的部署描述符通常可以在我们的示例 Git 存储库中找到。
讨论
对于不可变配置模式使用数据容器确实有些复杂。只有在不适合您的用例使用不可变 ConfigMaps 和 Secret 时才使用这些。
数据容器具有一些独特的优势:
-
环境特定的配置封装在容器中。因此,它可以像任何其他容器镜像一样进行版本控制。
-
以这种方式创建的配置可以分发到容器注册表。即使不访问集群,也可以检查配置。
-
配置是不可变的,持有配置的容器镜像也是如此:配置的更改需要版本更新和新的容器镜像。
-
当配置数据太复杂而无法放入环境变量或 ConfigMaps 时,配置数据镜像非常有用,因为它可以容纳任意大的配置数据。
如预期的那样,不可变配置模式也具有某些缺点:
-
由于需要构建并通过注册表分发额外的容器镜像,因此复杂度较高。
-
它未解决围绕敏感配置数据的任何安全问题。
-
由于 Kubernetes 工作负载实际上不支持镜像卷,因此本文描述的技术仍然局限于那些从初始化容器复制数据到本地卷的开销可以接受的用例。我们希望未来能够直接将容器镜像挂载为卷,但截至 2023 年,只有实验性的 CSI 支持可用。
-
Kubernetes 案例中需要额外的初始化容器处理,因此我们需要为不同环境管理不同的部署对象。
总的来说,你应该仔细评估是否真的需要这样复杂的方法。
处理大型配置文件的另一种方法是使用配置模板模式,这是下一章的主题,用于描述只在环境之间略有不同的配置文件。
更多信息
第二十二章:配置模板
配置模板模式使您能够在应用程序启动期间创建和处理大型复杂配置。生成的配置特定于目标运行时环境,这反映在处理配置模板时使用的参数中。
问题
在第二十章,“配置资源”中,您看到如何使用 Kubernetes 的原生资源对象 ConfigMap 和 Secret 配置应用程序。但是有时配置文件可能变得很大且复杂。直接将配置文件放入 ConfigMap 可能会出现问题,因为它们必须正确嵌入在资源定义中。我们需要小心,避免使用特殊字符如引号并破坏 Kubernetes 资源语法。配置大小也是考虑的因素,因为 ConfigMap 或 Secret 的所有值的总和有 1 MB 的限制(这是底层后端存储 etcd 强加的限制)。
大型配置文件通常在不同的执行环境中只有细微的差异。这种相似性导致 ConfigMap 中存在大量的重复和冗余数据,因为每个环境大部分数据都是相同的。本章探讨的配置模板模式正是针对这些特定用例的问题。
解决方案
为了减少重复,将只有不同的配置数值(如数据库连接参数)存储在 ConfigMap 中或直接存储在环境变量中是有意义的。在容器启动期间,使用配置模板处理这些数值,以创建完整的配置文件(例如 WildFly 的standalone.xml)。在应用初始化期间,有许多工具如Tiller(Ruby)或Gomplate(Go)可用于处理模板。图 22-1 展示了一个配置模板示例,其中填充了来自环境变量或挂载卷的数据。
在应用程序启动之前,完全处理的配置文件被放置在一个位置,可以像任何其他配置文件一样直接使用。
有两种技术可以在运行时进行实时处理:
-
我们可以将模板处理器作为
Dockerfile的一部分添加到ENTRYPOINT中,使模板处理直接成为容器镜像的一部分。此处的入口点通常是一个脚本,首先执行模板处理,然后启动应用程序。模板的参数来自环境变量。 -
使用 Kubernetes,更好的初始化方法是在 Pod 的 init 容器中运行模板处理器,并为 Pod 中的应用程序容器创建配置。Init Container模式在第十五章中详细描述。
对于 Kubernetes,init 容器方法最具吸引力,因为我们可以直接使用 ConfigMaps 来作为模板参数。这种技术在 Figure 22-1 中有所说明。

图 22-1. 配置模板
应用程序的 Pod 定义至少包含两个容器:一个用于模板处理的 init 容器和一个用于应用程序容器。init 容器不仅包含模板处理器,还包括配置模板本身。除了容器之外,此 Pod 还定义了两个卷:一个卷用于模板参数,由 ConfigMap 支持,以及一个 emptyDir 卷用于在 init 容器和应用程序容器之间共享处理后的模板。
使用此设置,在此 Pod 启动期间执行以下步骤:
-
启动 init 容器,并运行模板处理器。处理器从其镜像获取模板,并从挂载的 ConfigMap 卷获取模板参数,并将结果存储在
emptyDir卷中。 -
init 容器完成后,应用程序容器启动并从
emptyDir卷加载配置文件。
下面的示例使用一个 init 容器来管理两个环境的完整的 WildFly 配置文件集:开发环境和生产环境。它们非常相似,只有细微的差别。事实上,在我们的示例中,它们仅在日志记录方式上有所不同:每个日志行分别以 DEVELOPMENT: 或 PRODUCTION: 开头。
您可以在书籍的示例 GitHub 仓库 中找到完整示例以及完整的安装说明。(我们仅在此处展示主要概念;有关技术细节,请参阅源代码库。)
Example 22-1 中的日志模式存储在 standalone.xml 中,我们通过使用 Go 模板语法进行参数化。
示例 22-1. 日志配置模板
....
<formatter name="COLOR-PATTERN">
<pattern-formatter pattern="{{(datasource "config").logFormat}}"/>
</formatter>
....
这里我们使用 Gomplate 作为模板处理器,它使用 data source 的概念来引用要填充的模板参数。在我们的情况下,这个数据源来自挂载到 init 容器的 ConfigMap 支持的卷。在这里,ConfigMap 包含一个键为 logFormat 的条目,从中提取实际的格式。
有了这个模板,我们现在可以为 init 容器创建 Docker 镜像。这个镜像的 Dockerfile 非常简单(Example 22-2)。
示例 22-2. 用于模板镜像的简单 Dockerfile
FROM k8spatterns/gomplate
COPY in /in
基础镜像 k8spatterns/gomplate 包含模板处理器和一个入口脚本,默认使用以下目录:
-
/in 包含 WildFly 配置模板,包括参数化的 standalone.xml。这些直接添加到镜像中。
-
/params 用于查找 Gomplate 数据源,这些是 YAML 文件。此目录从支持 ConfigMap 的 Pod 卷挂载。
-
/out 是存储处理后文件的目录。此目录挂载在 WildFly 应用容器中,用于配置。
我们示例的第二个成分是包含参数的 ConfigMap。在示例 22-3 中,我们只使用一个简单的键-值对文件。
示例 22-3. 创建 ConfigMap,其中包含要填入配置模板的值
kubectl create configmap wildfly-cm \
--from-literal='config.yml=logFormat: "DEVELOPMENT: %-5p %s%e%n'
最后,我们需要 WildFly 服务器的 Deployment 资源(示例 22-4)。
示例 22-4. 带有模板处理器作为 init 容器的 Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
example: cm-template
name: wildfly-cm-template
spec:
replicas: 1
template:
metadata:
labels:
example: cm-template
spec:
initContainers:
- image: k8spatterns/example-config-cm-template-init 
name: init
volumeMounts:
- mountPath: "/params" 
name: wildfly-parameters
- mountPath: "/out" 
name: wildfly-config
containers:
- image: jboss/wildfly:10.1.0.Final
name: server
command:
- "/opt/jboss/wildfly/bin/standalone.sh"
- "-Djboss.server.config.dir=/config"
volumeMounts:
- mountPath: "/config" 
name: wildfly-config
volumes: 
- name: wildfly-parameters
configMap:
name: wildfly-cm
- name: wildfly-config
emptyDir: {}
包含已从示例 22-2 创建的配置模板的镜像。
参数从声明在
中的 wildfly-parameters 卷挂载。
写出处理后模板的目标目录。这是从一个空卷挂载的。
挂载生成的完整配置文件的目录为 /config。
参数的 ConfigMap 和用于共享处理后配置的空目录的卷声明。
这个声明有点复杂,因此让我们详细看看:Deployment 规范包含一个 Pod,其中包含我们的 init 容器、应用容器和两个内部 Pod 卷:
-
第一个卷
wildfly-parameters引用了参数值的 ConfigMapwildfly-cm,我们在示例 22-3 中创建的。 -
另一个卷最初是空目录,并在 init 容器和 WildFly 容器之间共享。
如果启动此 Deployment,将发生以下情况:
-
创建一个 init 容器,并执行其命令。此容器从 ConfigMap 卷中获取 config.yml,从 /in 目录填充模板,并将处理后的文件存储在 /out 目录中。/out 目录是挂载
wildfly-config的位置。 -
当 init 容器完成后,WildFly 服务器启动,并选择从 /config 目录查找完整配置。同样,/config 是共享卷
wildfly-config,包含处理后的模板文件。
需要注意的是,当从开发环境转到生产环境时,我们不必更改这些 Deployment 资源描述符。唯一不同的是模板参数的 ConfigMap。
使用这种技术,可以轻松创建无重复的配置,而无需复制和维护重复的大型配置文件。^(1) 例如,当所有环境的 WildFly 配置发生更改时,只需更新初始化容器中的单个模板文件。当然,这种方法在维护上具有显著优势,因为不存在配置漂移的风险。
小贴士
在处理像这样的模式中,例如与 Pods 和卷一起工作时,如果事情不按预期工作,如何调试并不明显。因此,如果您想要检查处理过的模板,请查看节点上的目录 /var/lib/kubelet/pods/{podid}/volumes/kubernetes.io~empty-dir/,因为它包含了一个 emptyDir 卷的内容。或者,当 Pod 运行时,只需 kubectl exec 进入 Pod,并检查挂载的目录(在我们的示例中为 /config)是否创建了任何文件。
讨论
配置模板模式建立在配置资源模式之上,特别适用于需要在不同环境中操作具有类似复杂配置的应用程序。然而,配置模板的设置更为复杂,包含更多可能出错的部分。只有当您的应用程序需要大量配置数据时才使用它。这类应用程序通常需要大量配置数据,其中只有很小一部分依赖于环境。即使最初直接复制整个配置到特定环境的 ConfigMap 中可以工作,但这会增加配置维护的负担,因为随着时间的推移,配置注定会发生分歧。对于这种情况,这种模板方法是完美的。
如果您在 Red Hat OpenShift 上运行,这是一个企业级 Kubernetes 发行版,您可以使用OpenShift 模板来参数化资源描述符。这种方法虽然不能解决大型配置集的挑战,但对于将相同的部署资源应用于稍有不同的环境仍然非常有帮助。
更多信息
^(1) DRY 是 “Don’t Repeat Yourself” 的首字母缩略词。
第五部分:安全模式
安全是一个广泛的主题,对软件开发生命周期的所有阶段都有影响,从开发实践、构建时的镜像扫描,到部署时通过准入控制器加强集群硬化,再到运行时的威胁检测。安全也涉及软件堆栈的所有层面,从云基础设施安全、集群安全、容器安全,到代码安全,也被称为云原生安全的 4C。在本节中,我们关注应用程序与 Kubernetes 在安全角度的交集,如在图 V-1 中展示的。

图 V-1. 安全模式
我们首先描述进程封装模式,以限制应用程序在其运行的节点上允许执行的操作。然后我们探讨网络分割技术,限制一个 Pod 可以与其他 Pods 交流的方式。在安全配置模式中,我们讨论 Pod 内应用程序如何安全访问和使用配置。最后,我们描述访问控制模式——应用程序如何在更复杂的场景中进行身份验证并与 Kubernetes API 服务器交互。这些内容为您提供了在 Kubernetes 上运行应用程序的主要安全维度的概述,接下来的章节我们将详细讨论相关模式:
-
第二十三章,“进程封装”,描述了将进程限制到其享有的最低权限的方法。
-
第二十四章,“网络分割”,应用网络控制以限制 Pod 可以参与的流量。
-
第二十五章,“安全配置”,帮助安全地保留和使用敏感配置数据。
-
第二十六章,“访问控制”,允许用户和应用负载对 Kubernetes API 服务器进行身份验证和交互。
第二十三章:进程约束
本章描述了帮助将最小权限原则应用于将进程限制到其运行所需最小权限的技术。进程约束 模式通过限制攻击面和创建防线,使应用程序更加安全。它还防止任何流氓进程超出其指定边界运行。
问题
Kubernetes 工作负载的主要攻击向量之一是通过应用程序代码。许多技术可以帮助改进代码安全性。例如,静态代码分析工具可以检查源代码中的安全漏洞。动态扫描工具可以模拟恶意攻击者,旨在通过已知服务攻击,如 SQL 注入(SQLi)、跨站请求伪造(CSRF)和跨站脚本(XSS)攻击来入侵系统。然后还有工具定期扫描应用程序的依赖项,查找安全漏洞。作为镜像构建过程的一部分,会扫描已知漏洞的容器。通常通过检查基础镜像及其所有软件包与跟踪有漏洞软件包的数据库来完成这一步骤。这些只是创建安全应用程序和防范恶意行为者、受损用户、不安全容器映像或有漏洞依赖项所涉及的步骤之一。
无论有多少检查措施,新的代码和新的依赖项都可能引入新的漏洞,无法保证完全没有风险存在。如果没有运行时进程级安全控制措施,恶意行为者可以入侵应用程序代码,试图控制主机或整个 Kubernetes 集群。本章中将探讨的机制展示了如何将容器限制在其运行所需的权限范围内,并应用最小权限原则。通过这种方式,Kubernetes 配置作为另一道防线,限制任何流氓进程并防止其超出指定边界运行。
解决方案
通常,诸如 Docker 这样的容器运行时会分配容器将具有的默认运行时权限。当容器由 Kubernetes 管理时,将应用于容器的安全配置由 Kubernetes 控制,并通过 Pod 的安全上下文配置和容器规范向用户公开。Pod 级别的配置适用于 Pod 的卷和所有容器,而容器级别的配置则适用于单个容器。当在 Pod 和容器级别同时设置相同配置时,容器规范中的值优先生效。
作为创建云原生应用程序的开发人员,通常不需要处理许多细粒度的安全配置,而是应将它们作为全局策略进行验证和强制执行。在创建专用基础设施容器(如构建系统和其他需要对底层节点具有更广泛访问权限的插件)时通常需要进行细粒度调整。因此,我们将仅审查对在 Kubernetes 上运行典型云原生应用程序有用的常见安全配置。
以非 root 用户身份运行容器
容器镜像具有一个用户,并且可以选择性地具有一个组,用于运行容器进程。这些用户和组用于控制对文件、目录和卷挂载的访问权限。在某些其他容器中,不会创建用户,并且容器镜像默认以 root 用户身份运行。在其他情况下,容器镜像中创建了一个用户,但未设置为默认要运行的用户。可以通过在运行时使用securityContext覆盖用户来纠正这些情况,如示例 23-1 所示。
示例 23-1。为 Pod 的容器设置用户和组
apiVersion: v1
kind: Pod
metadata:
name: web-app
spec:
securityContext:
runAsUser: 1000 
runAsGroup: 2000 
containers:
- name: app
image: k8spatterns/random-generator:1.0
指示要运行容器进程的 UID。
指定要运行容器进程的 GID。
该配置强制 Pod 中的任何容器以用户 ID 1000 和组 ID 2000 运行。当您想要交换容器镜像中指定的用户时,这是有用的。但是在设置这些值并在运行时决定要运行镜像的用户时也存在风险。通常,用户与包含具有与容器镜像中指定的所有权 ID 相同的文件的目录结构一起设置。为了避免由于权限不足而导致运行时失败,您应该检查容器镜像文件,并使用定义的用户 ID 和组 ID 运行容器。这是防止容器以 root 用户身份运行并将其匹配到镜像中预期用户的一种方式。
不需要指定用户 ID 来确保容器不以 root 用户身份运行,一种更不具侵入性的方法是将.spec.securityContext.runAsNonRoot标志设置为true。设置后,Kubelet 将在运行时验证并阻止任何以 root 用户(即 UID 0 的用户)启动的容器。这种机制不会改变用户,只是确保容器以非 root 用户身份运行。如果需要以 root 用户身份运行以访问容器中的文件或卷,可以通过运行一个可以短暂以 root 用户身份运行的 init 容器来限制对 root 的暴露,并在应用容器启动为非 root 用户之前更改文件访问模式。
容器可能不以 root 用户身份运行,但通过特权升级可以获得类似 root 的能力。这与在 Linux 上使用 sudo 命令并以 root 权限执行命令最为类似。在容器中防止这种情况的方法是将 .spec.containers[].securityContext.allowPrivilegeEscalation 设置为 false。这种配置通常没有副作用,因为如果一个应用程序设计为以非 root 用户身份运行,它在其生命周期中不应该需要特权升级。
在 Linux 系统中,root 用户具有特殊的权限和特权,阻止 root 用户拥有容器进程、提升权限以成为 root 或通过 init 容器限制 root 用户的生命周期,将有助于防止容器逃逸攻击,并确保遵循一般的安全实践。
限制容器的能力
本质上,一个容器是在节点上运行的一个进程,它可以拥有进程具备的相同特权。如果进程需要进行内核级别的调用,它需要拥有相应的特权才能成功执行。可以通过两种方式实现这一点:要么以 root 用户身份运行容器,从而授予容器所有特权;要么分配应用程序运行所需的特定能力。
设置了 .spec.containers[].securityContext.privileged 标志的容器本质上相当于主机上的 root 用户,并绕过了内核权限检查。从安全角度来看,这个选项将您的容器与主机系统捆绑在一起,而不是隔离它们。因此,此标志通常为具有管理能力的容器设置,例如操纵网络堆栈或访问硬件设备。避免使用特权容器,并为需要的容器赋予特定的内核能力是更好的方法。在 Linux 中,通常与 root 用户相关联的权限被分为不同的能力,可以独立地启用和禁用。查找容器具有哪些能力并不简单。您可以采用白名单方法,在不具备任何能力的情况下启动容器,并根据容器内每个用例逐渐添加所需的能力。您可能需要安全团队的帮助,或者可以使用 SELinux 等工具以宽容模式,并检查应用程序的审计日志,以了解它是否需要某些能力。
为了使容器更加安全,应该尽量减少所需运行权限。容器运行时会为容器分配一组默认权限(能力)。与您的预期相反,如果.spec.containers[].securityContext.capabilities部分为空,则容器运行时定义的默认能力集通常比大多数进程需要的要丰富得多,这将使其容易受到攻击。锁定容器攻击面的良好安全实践是丢弃所有权限,并仅添加所需权限,如示例 23-2 所示。
示例 23-2. 设置 Pod 权限
apiVersion: v1
kind: Pod
metadata:
name: web-app
spec:
containers:
- name: app
image: docker.io/centos/httpd
securityContext:
capabilities:
drop: [ 'ALL' ] 
add: ['NET_BIND_SERVICE'] 
移除容器运行时为容器分配的所有默认能力。
仅添加回NET_BIND_SERVICE能力。
在此示例中,我们丢弃所有能力,并仅添加回NET_BIND_SERVICE能力,该能力允许绑定到低于 1024 的特权端口号。解决此场景的另一种方法是使用绑定到非特权端口号的容器替换该容器。
如果未配置 Pod 的安全上下文或设置过于宽松,则该 Pod 更容易被攻击者利用。将容器的能力限制到最低限度可以作为对已知攻击的额外防线。当容器进程不具有特权或其能力受到严格限制时,恶意用户在攻击应用程序后更难控制主机。
避免可变容器文件系统
通常情况下,容器化应用程序不应能够写入容器文件系统,因为容器是临时的,任何状态在重启后都将丢失。正如第十一章,“无状态服务”中所讨论的,状态应写入外部持久性方法,例如数据库或文件系统。日志应写入标准输出或转发到远程日志收集器。此类应用程序可以通过具有只读容器文件系统进一步限制容器的攻击面。只读文件系统将防止任何恶意用户篡改应用程序配置或在磁盘上安装其他可用于进一步利用的可执行文件。实现此目的的方法是将.spec.containers[].securityContext.readOnlyRootFile设置为true,这将在运行时将容器的根文件系统挂载为只读。这样可以防止对容器根文件系统的任何写入,并执行不可变基础设施的原则。
securityContext字段中的完整值列表有许多其他项,并且可以在 Pod 和容器配置之间有所不同。本书的范围超出了覆盖所有安全配置的范围。另外两个必须检查的安全上下文选项是seccompProfile和seLinuxOptions。前者是 Linux 内核功能,可用于限制容器中运行的进程仅调用可用系统调用的子集。这些系统调用配置为配置文件,并应用于容器或 Pod。
后一选项,seLinuxOptions,可以为 Pod 内所有容器以及卷分配自定义 SELinux 标签。SELinux 使用策略来定义哪些进程可以访问系统中的其他标记对象。在 Kubernetes 中,它通常用于以限制的方式标记容器镜像,以便进程仅访问镜像内的文件。当主机环境支持 SELinux 时,可以严格执行以拒绝访问,或者可以配置为宽松模式以记录访问违规。
为每个 Pod 或容器配置这些字段会导致它们容易受到人为错误的影响。不幸的是,设置它们通常是工作负载作者的责任,这些作者通常不是组织中的安全专家。这就是为什么还有集群级别、由集群管理员定义的基于策略驱动的手段,用于确保命名空间中的所有 Pods 符合最低安全标准。接下来让我们简要回顾一下。
强制执行安全策略
到目前为止,我们已经探讨了使用securityContext定义作为 Pod 和容器规范的一部分来设置容器运行时的安全参数。这些规范是针对每个 Pod 单独创建的,并且通常是通过更高层次的抽象(如 Deployments、Jobs 和 CronJobs)间接创建的。但是,集群管理员或安全专家如何确保一组 Pods 遵循某些安全标准呢?答案在于 Kubernetes Pod 安全标准(PSS)和 Pod 安全 Admission(PSA)控制器。PSS 定义了关于安全策略的共同理解和一致语言,而 PSA 则有助于强制执行这些策略。这种方式,策略独立于底层执行机制,并且可以通过 PSS 或其他第三方工具应用。这些策略分为三个安全配置文件,从高度允许到高度限制,如下所示:
特权
这是一个权限最广的不受限制的配置文件。它被故意保持开放,并为信任用户和基础设施工作负载提供默认允许机制。
基线
这个配置文件适用于普通的非关键应用工作负载。它具有最小限制策略,并在采纳度和预防已知特权升级之间提供了平衡。例如,它不允许特权容器、某些安全功能以及securityContext字段外的其他配置。
受限制
这是遵循最新安全强化最佳实践的最严格配置文件,以牺牲采纳度为代价。它适用于安全关键应用程序以及较低信任级别的用户。在基线配置文件的基础上,它对我们之前审查的字段施加了限制,如allowPrivilegeEscalation、runAsNonRoot、runAsUser以及其他容器配置。
PodSecurityPolicy 是 Kubernetes v1.25 中替换为 PSA 的遗留安全策略实施机制。未来,您可以使用第三方接入插件或内置的 PSA 控制器为每个命名空间强制执行安全标准。安全标准通过标签应用于 Kubernetes 命名空间,定义了前述标准级别以及在检测到潜在违规时采取的一个或多个操作。以下是您可以采取的操作:
警告
策略违规将允许用户显示警告。
审计
策略违规将允许带有审计日志条目的 Pod。
强制执行
任何策略违规都将导致 Pod 被拒绝。
有了这些定义的选项,示例 23-3 创建了一个拒绝任何不符合 基线 标准的 Pods 的命名空间,并为不满足 受限制 标准要求的 Pods 生成警告。
示例 23-3. 为命名空间设置安全标准
apiVersion: v1
kind: Namespace
metadata:
name: baseline-namespace
labels:
pod-security.kubernetes.io/enforce: baseline 
pod-security.kubernetes.io/enforce-version: v1.25 
pod-security.kubernetes.io/warn: restricted 
pod-security.kubernetes.io/warn-version: v1.25
标签提示 PSA 控制器拒绝违反 基线 标准的 Pods。
安全标准要求的版本(可选)。
标签提示 PSA 控制器警告违反 受限制 标准的 Pods。
此示例创建一个新的命名空间,并配置安全标准,以应用于此命名空间中将创建的所有 Pods。还可以更新命名空间的配置或将策略应用于一个或所有现有命名空间。有关如何以最少分布方式执行此操作的详细信息,请查看“更多信息”。
讨论
Kubernetes 中的一个常见安全挑战是运行传统应用程序,这些应用程序在设计或容器化时未考虑 Kubernetes 安全控制。在 Kubernetes 发行版或具有严格安全策略的环境中运行特权容器可能会面临挑战。了解 Kubernetes 如何在运行时进行进程限制并配置安全边界,如图 23-1 所示,将帮助您更安全地创建在 Kubernetes 上运行的应用程序。重要的是要意识到容器不仅仅是一种打包格式和资源隔离机制,当正确配置时,它还是一道安全防护墙。

图 23-1. 进程限制模式
将安全考虑和测试实践向左移的倾向,包括使用生产安全标准在 Kubernetes 中部署,越来越受欢迎。这些做法有助于在开发周期的早期识别和解决安全问题,避免最后一刻的意外。
注意
左移 是指尽早而非晚做事情。它是指在描述开发和部署过程的时间轴上向左移动。在我们的背景下,左移意味着开发人员在开发应用程序时已经考虑到操作安全性。在Devopedia上查看有关左移模型的更多细节。
在本章中,我们希望为您创建安全的云原生应用程序提供了足够的思路。本章中的指南将帮助您设计和实施不会向本地文件系统写入或需要 root 权限的应用程序(例如,在容器化应用程序时,确保容器具有指定的非 root 用户)并配置安全上下文。我们希望您明确了解您的应用程序确切需要什么,并仅为其授予最低权限。我们还旨在帮助您在工作负载和主机之间建立边界,减少容器权限,并配置运行时环境以在发生违规时限制资源利用。在这方面,“进程限制”模式确保“容器内发生的一切都留在容器内”,包括任何安全漏洞。
更多信息
第二十四章:网络分割
Kubernetes 是一个很好的平台,用于运行通过网络相互通信的分布式应用程序。默认情况下,Kubernetes 中的网络空间是平面的,这意味着集群中的每个 Pod 都可以连接到其他任何 Pod。在本章中,我们将探讨如何为提升安全性和轻量级多租户模型而结构化这个网络空间。
问题
Namespace 是 Kubernetes 的一个关键部分,允许您将工作负载分组在一起。然而,它们只提供了一个分组概念,对与特定 Namespace 关联的容器施加了隔离约束。在 Kubernetes 中,每个 Pod 都可以与集群中的任何其他 Pod 进行通信,无论它们的 Namespace 如何。这种默认行为具有安全性影响,特别是当由不同团队运行的多个独立应用程序在同一个集群中时。
限制从 Pod 到网络的访问对增强应用程序安全至关重要,因为不是每个人都可以通过入口访问您的应用程序。对于 Pod 的出站 egress 网络流量也应该限制在必需的范围内,以减小安全漏洞的影响范围。
网络分割在多租户设置中扮演着重要角色,多个方进行在同一集群中共享。例如,下面的侧边栏讨论了 Kubernetes 上多租户的一些挑战,比如为应用程序创建网络边界。
过去,塑造网络拓扑主要是由管理人员负责,他们管理防火墙和 iptable 规则。这种模型的挑战在于管理人员需要理解应用程序的网络需求。此外,在具有许多依赖关系的微服务世界中,网络图可能变得非常复杂,需要深入了解应用程序的领域知识。在这方面,开发人员必须与管理员沟通并同步依赖关系的信息。DevOps 设置可以帮助,但网络拓扑的定义仍然远离应用程序本身,并且随时间动态变化。
那么,在 Kubernetes 世界中定义和建立网络分割是什么样子的呢?
解决方案
好消息是,Kubernetes 左移了这些网络任务,使得使用 Kubernetes 的开发者可以完全定义他们应用程序的网络拓扑。您已经在 第二十三章 简要描述了这个过程模型,当时我们讨论了 进程容器化 模式。
网络分割 模式的核心是我们作为开发人员如何通过创建 "应用防火墙" 来定义我们应用程序的网络分割。
有两种方法可以实现这一功能,它们是互补的并可以一起应用。第一种方法是通过使用操作 L3/L4 网络层的核心 Kubernetes 特性来实现。通过定义 NetworkPolicy 类型的资源,开发人员可以为工作负载 Pod 创建入站和出站防火墙规则。
另一种方法涉及使用服务网格,目标是 L7 协议层,特别是基于 HTTP 的通信。这允许基于 HTTP 动词和其他 L7 协议参数进行过滤。我们将在本章后面探讨 Istio 的 AuthenticationPolicy。
首先,让我们专注于如何使用 NetworkPolicies 来定义应用程序的网络边界。
网络策略
NetworkPolicy 是 Kubernetes 的一种资源类型,允许用户为 Pod 定义入站和出站网络连接的规则。这些规则就像是自定义防火墙,确定哪些 Pod 可以访问以及它们可以连接到哪些目的地。用户定义的规则由 Kubernetes 内部网络使用的容器网络接口(CNI)插件捡起。然而,并非所有 CNI 插件都支持 NetworkPolicies;例如,流行的 Flannel CNI 插件不支持它,但像 Calico 这样的其他插件支持。所有托管的 Kubernetes 云服务提供商都支持 NetworkPolicy(直接或通过配置插件),以及像 Minikube 这样的其他发行版。
NetworkPolicy 的定义包括对 Pod 的选择器以及入站(ingress)或出站(egress)规则列表。
Pod 选择器 用于匹配应用 NetworkPolicy 的 Pod。这种选择通过使用标签完成,标签是附加到 Pod 的元数据。标签允许对 Pod 进行灵活和动态的分组,这意味着可以将相同的 NetworkPolicy 应用于共享相同标签且在同一命名空间内运行的多个 Pod。Pod 选择器在 “标签” 中有详细描述。
入站 和 出站 规则列表定义了允许与匹配 Pod 进行连接的入站和出站连接。这些规则指定了允许从哪些源和目的地连接到 Pod,以及从 Pod 连接到哪里。例如,一条规则可以允许来自特定 IP 地址或地址范围的连接,或者阻止连接到特定目的地。
让我们从 示例 24-1 中的简单示例开始,该示例仅允许从后端 Pod 访问所有数据库 Pod,而不允许其他内容。
示例 24-1. 简单的 NetworkPolicy 允许入站流量
kind: NetworkPolicy
apiVersion: networking.k8s.io/v1
metadata:
name: allow-database
spec:
podSelector: 
matchLabels:
app: chili-shop
id: database
ingress: 
- from:
- podSelector: 
matchLabels:
app: chili-shop
id: backend
匹配所有具有标签 id: database 和 app: chili-shop 的 Pod 选择器。所有这些 Pod 都受到此 NetworkPolicy 的影响。
允许入站流量的源列表。
允许所有 backend 类型的 Pod 访问所选数据库 Pod 的 Pod 选择器。
图 24-1 展示了后端 Pod 如何访问数据库 Pod,但前端 Pod 不能。

图 24-1. 入口流量的 NetworkPolicy
NetworkPolicy 对象是命名空间范围的,并且仅匹配来自 NetworkPolicy 所在命名空间的 Pod。不幸的是,目前没有办法为所有命名空间定义集群范围的默认值。然而,像 Calico 这样的一些 CNI 插件支持用于定义集群范围行为的客户扩展。
使用标签进行网络段定义
在 示例 24-1 中,我们可以看到如何使用标签选择器动态定义 Pod 的组。这是 Kubernetes 中的一个强大概念,允许用户轻松创建不同的网络段。
开发人员通常是最了解哪些 Pod 属于特定应用程序以及它们如何相互通信的人。通过精心打标签,用户可以直接将分布式应用程序的依赖图转换为 NetworkPolicies。这些策略可以用来定义应用程序的网络边界,具有明确定义的入口和出口点。
使用标签创建网络分割时,通常会为应用程序中的所有 Pod 打上唯一的 app 标签。可以在 NetworkPolicy 的选择器中使用 app 标签来确保覆盖应用程序的所有 Pod。例如,在 示例 24-1 中,使用 app 标签和值 chili-shop 定义了网络段。
有两种常见的方法可以一致地标记工作负载:
-
使用工作负载唯一标签,可以直接建模应用程序组件之间的依赖关系图,例如其他微服务或数据库。例如,在 示例 24-1 中,使用标签
type来识别应用程序组件。只有一种类型的工作负载(例如 Deployment 或 StatefulSet)预计会携带标签type: database。 -
在更松散的方法中,您可以定义需要附加到扮演特定角色的每个工作负载的特定
角色或权限标签。 示例 24-2 展示了这种设置的示例。这种方法更灵活,允许添加新的工作负载而无需更新 NetworkPolicy。然而,直接连接工作负载的更直接方法通常更容易理解,只需查看 NetworkPolicy 而无需查找适用于角色的所有工作负载。
示例 24-2. 基于角色的网络段定义
kind: Pod
metadata:
label:
app: chili-shop
id: backend
role-database-client: 'true' 
role-cache-client: 'true'
....
---
kind: NetworkPolicy
apiVersion: networking.k8s.io/v1
metadata:
name: allow-database-client
spec:
podSelector:
matchLabels:
app: chili-shop
id: database 
ingress:
- from:
- podSelector:
matchLabels:
app: chili-shop
role-database-client: 'true' 
添加所有使得此后端 Pod 能够访问请求服务的角色。
选择器匹配数据库 Pod,即带有标签id: database的 Pod。
每个作为数据库客户端(role-database-client: 'true')的 Pod 都被允许向后端 Pod 发送流量。
默认的拒绝所有策略
在示例 24-1 和 24-2 中,我们已经看到如何为一组选定的 Pod 单独配置允许的传入连接。只要不要忘记配置一个 Pod,这种设置就可以正常工作,因为当命名空间中未配置 NetworkPolicy 时,默认模式不限制传入和传出流量(允许所有)。此外,对于将来可能创建的 Pod,需要记住添加相应的 NetworkPolicy 可能会有问题。
因此,强烈建议从拒绝所有的策略开始,如示例 24-3 所示。
示例 24-3. 拒绝所有入口流量的策略
kind: NetworkPolicy
apiVersion: networking.k8s.io/v1
metadata:
name: deny-all
spec:
podSelector: {} 
ingress: [] 
空选择器匹配每个 Pod。
空的入口规则列表意味着所有传入流量被丢弃。
允许入口的列表设置为空列表([]),这意味着没有入口规则允许传入流量。请注意,空列表[]与具有单个空元素的列表[ {} ]不同,后者实现了完全相反的效果,因为单个空规则匹配所有内容。
Ingress
示例 24-1 覆盖了覆盖入口流量的策略的主要用例。我们已经解释了podSelector字段,并且给出了一个匹配允许发送流量到配置下的 Pod 的ingress列表的示例。只要列表中的任何配置的入口规则匹配,选定的 Pod 就可以接收流量。
除了选择 Pod 外,您还有其他选项来配置入口规则。我们已经看到了from字段用于可以包含podSelector以选择通过此规则的所有 Pod 的入口规则。此外,可以提供namespaceSelector来选择应该应用podSelector以识别可以发送流量的 Pod 的命名空间。
表 24-1 显示了podSelector和namespaceSelector各种组合的效果。结合这两个字段允许非常灵活的设置。
表 24-1. 设置podSelector和namespaceSelector的组合({}: 空, {...}: 非空, ---: 未设置)
| podSelector | namespaceSelector | 行为 |
|---|---|---|
| {} | {} | 每个命名空间中的每个 Pod |
| {} | 匹配命名空间中的每个 Pod | |
| 所有命名空间中的每个匹配的 Pod | ||
| 匹配命名空间中的每个匹配 Pod | ||
| --- | { … } / {} | 匹配命名空间/所有命名空间中的每个 Pod |
| { … } / {} | --- | NetworkPolicy 的命名空间中的匹配 Pod/每个 Pod |
作为从集群中选择 Pods 的替代方法,可以使用 ipBlock 字段指定一系列 IP 地址。我们在 Example 24-5 中展示 IP 范围。
另一种选择是将流量限制为仅限于选定 Pod 的特定端口。我们可以使用包含所有允许端口的 ports 字段来指定此列表。
出口
不仅可以管理传入流量,还可以管理 Pod 发送的任何请求的出站流量。出口规则与入口规则具有相同的选项。与入口规则一样,建议从非常严格的策略开始。然而,拒绝所有出站流量并不实际。每个 Pod 需要与系统命名空间中的 Pods 进行交互以进行 DNS 查找。此外,如果我们使用入口规则限制入站流量,我们将不得不为源 Pods 添加镜像出站规则。因此,让我们务实地允许集群内的所有出口,禁止集群外的所有内容,并让入口规则定义网络边界。
Example 24-4 显示了此类规则的定义。
示例 24-4. 允许所有内部出口流量
kind: NetworkPolicy
apiVersion: networking.k8s.io/v1
metadata:
name: egress-allow-internal-only
spec:
policyTypes: 
- Egress
podSelector: {} 
egress:
- to:
- namespaceSelector: {} 
仅将 Egress 添加为策略类型;否则,Kubernetes 将假定您要指定入站和出站。
将 NetworkPolicy 应用于 NetworkPolicy 的命名空间中的所有 Pods。
允许每个其他命名空间中的每个 Pod 出口。
Figure 24-2 说明了此 NetworkPolicy 的影响,以及它如何阻止 Pods 连接到外部服务。

图 24-2. 仅允许内部出口流量的 NetworkPolicy
NetworkPolicy 中的 policyTypes 字段确定策略影响的流量类型。它是一个列表,可以包含 Egress 和/或 Ingress 元素,并指定策略中包含的规则。如果省略该字段,则根据 ingress 和 egress 规则部分的存在确定默认值:
-
如果存在
ingress部分,则policyTypes的默认值为[Ingress]。 -
如果提供了
egress部分,则policyTypes的默认值为[Ingress, Egress],无论是否提供了ingress规则。
这种默认行为意味着,要定义仅出口的策略,必须显式将 policyTypes 设置为 [Egress],如 Example 24-4。如果未这样做,将意味着空的 ingress 规则集,实际上禁止所有入站流量。
在对集群内部出口流量施加此限制后,我们可以选择性地激活某些可能需要集群外部网络访问的 Pod 对外部 IP 地址的访问。在 示例 24-5 中定义了一个允许外部出口访问的 IP 范围块。
示例 24-5. NetworkPolicy 允许访问所有 IP 地址,但有一些例外。
kind: NetworkPolicy
apiVersion: networking.k8s.io/v1
metadata:
name: allow-external-ips
spec:
podSelector: {}
egress:
- to:
- ipBlock:
cidr: 0.0.0.0/0 
except:
- 192.168.0.0/16 
- 172.23.42.0/24
允许访问所有 IP 地址…
…除了属于这些子网的 IP 地址。
如果决定选择更严格的出口规则,并且还想限制集群的内部出口流量,则需要注意一些问题。首先,始终允许对 kube-system 命名空间中的 DNS 服务器访问至关重要。最佳配置是允许 UDP 和 TCP 的端口 53 对系统命名空间中的所有端口进行访问。
对于运算符和控制器,Kubernetes API 服务器需要是可访问的。不幸的是,在 kube-system 命名空间中没有唯一的标签可以选择 API 服务器,因此应在 API 服务器的 IP 地址上进行过滤。最佳方法是从默认命名空间中的 kubectl get endpoints -n default kubernetes 获取 kubernetes 端点中获取 IP 地址。
工具
设置网络拓扑结构时,由于涉及创建许多 NetworkPolicy 资源,情况会迅速变得复杂。最好从一些简单的用例开始,然后根据特定需求进行调整。Kubernetes 网络策略配方 是一个很好的起点。
通常,NetworkPolicy 与应用程序架构一起定义。然而,有时候必须将策略模式适应现有解决方案。在这种情况下,策略顾问工具可能会很有帮助。它们通过记录播放典型用例时的网络活动来工作。具备良好测试覆盖率的全面集成测试套件有助于捕捉涉及网络连接的所有边缘情况。截至 2023 年,有几种工具可以帮助您审核网络流量以创建网络策略。
Inspektor Gadget 是一个出色的工具套件,用于调试和检查 Kubernetes 资源。它完全基于 eBPF 程序,支持内核级别的可观测性,并提供了从内核特性到高级 Kubernetes 资源的桥梁。Inspektor Gadget 的一个特性是监视网络活动,并记录生成 Kubernetes 网络策略所需的所有 UDP 和 TCP 流量。这种技术效果很好,但取决于覆盖用例的质量和深度。
另一个基于 eBPF 的优秀平台是 Cilium,它具有专用的审计模式,可跟踪所有网络流量并与给定的网络策略匹配。通过启用拒绝所有策略并启用审计模式,Cilium 将记录所有策略违规情况,但不会否则阻止流量。审计报告有助于创建适合所执行流量模式的适当 NetworkPolicy。
这些只是关于策略推荐、仿真和审计工具丰富且日益增长的景观的两个例子。
现在你已经看到我们如何在 TCP/UDP 和 IP 层面为应用程序建模网络边界,让我们在 OSI 栈的一些更高层次上移动。
认证策略
目前为止,我们看到了如何在 TCP/IP 层面控制 Pod 之间的网络流量。然而,基于更高级别协议参数的网络限制有时是有益的。这种高级网络控制需要了解像 HTTP 这样的更高级别协议,并具备检查进出流量的能力。Kubernetes 并未在开箱即用中支持此功能。幸运的是,一个完整的插件家族扩展了 Kubernetes 以提供这种功能:服务网格。
我们选择 Istio 作为我们示例的服务网格,但你会发现其他服务网格中类似的功能。我们不会深入讨论服务网格或 Istio,而是专注于 Istio 的一个特定自定义资源,帮助我们在 HTTP 协议级别上塑造网络段。
Istio 具有丰富的功能集,用于启用身份验证、通过 mTLS 进行传输安全、通过 CERT 旋转进行身份管理以及授权。
与其他 Kubernetes 扩展一样,Istio 通过引入自己的 CustomResourceDefinitions (CRDs) 利用 Kubernetes API 机制,这在 第二十八章,“Operator” 中有详细解释。在 Istio 中,授权是通过 AuthorizationPolicy 资源配置的。虽然 AuthorizationPolicy 只是 Istio 安全模型中的一个组件,但它可以单独使用,并允许基于 HTTP 对网络空间进行分区。
AuthorizationPolicy 的架构与 NetworkPolicy 非常相似,但更灵活,并包括 HTTP 特定的过滤器。NetworkPolicy 和 AuthorizationPolicy 应该一起使用。当必须同时检查和验证两个配置时,这可能会导致一个棘手的调试设置。只有当 NetworkPolicy 和 AuthorizationPolicy 定义所跨越的两个用户定义的防火墙允许时,流量才会通过到一个 Pod。
一个 AuthorizationPolicy 是一个命名空间资源,包含一组规则,用于控制是否允许或拒绝在 Kubernetes 集群中特定一组 Pods 的流量。该策略由以下三个部分组成:
选择器
指定策略适用于哪些 Pod。如果未指定选择器,则策略适用于同一命名空间中的所有 Pod。如果策略在 Istio 的根命名空间(istio-system)中创建,则适用于所有命名空间中的所有匹配 Pod。
操作
定义与符合规则的流量应执行的操作。可能的操作包括ALLOW、DENY、AUDIT(仅用于日志记录)和CUSTOM(用于用户定义的操作)。
规则列表
这些规则用于评估传入的流量。必须满足所有规则才能执行操作。每个规则有三个组成部分:一个from字段,指定请求的来源;一个to字段,指定请求必须匹配的 HTTP 操作;以及一个可选的when字段,用于附加条件(例如,与请求关联的身份必须匹配特定值)。
示例 24-6 展示了一个典型的例子,允许监控运算符访问应用程序端点以收集度量数据。
示例 24-6. 用于 Prometheus 设置的授权
apiVersion: security.istio.io/v1beta1
kind: AuthorizationPolicy
metadata:
name: prometheus-scraper
namespace: istio-system 
spec:
selector: 
matchLabels:
has-metrics: "true"
action: ALLOW 
rules:
- from: 
- source:
namespace: ["prometheus"]
to:
- operation: 
methods: [ "GET" ]
paths: ["/metrics/*"]
当在命名空间istio-system中创建时,策略适用于所有命名空间中的所有匹配 Pod。
该策略适用于所有带有has-metrics标签设置为true的 Pod。
如果规则匹配,则操作应允许请求通过。
来自prometheus命名空间的每个请求…
…可以对/metrics端点执行 GET 请求。
在示例 24-6 中,每个带有标签has-metrics: "true"的 Pod 允许从prometheus命名空间的每个 Pod 到其/metrics端点的流量。
仅当默认情况下拒绝所有请求时,此策略才会生效。对于 NetworkPolicy 来说,最佳的起点是定义一个拒绝所有策略,如示例 24-7 所示,然后有选择性地构建网络拓扑以允许专用路由。
示例 24-7. 拒绝所有策略作为默认设置
apiVersion: security.istio.io/v1beta1
kind: AuthorizationPolicy
metadata:
name: deny-all
namespace: istio-system 
spec:
selector: {} 
action: DENY 
rules: [{}] 
该策略适用于所有命名空间,因为它是在istio-system中创建的。
空选择器始终匹配。
拒绝所有匹配选择器的 Pod 的访问。
一个始终匹配的空规则。
注意
Istio 遵循 NetworkPolicy 的语义,空规则列表[]永远不匹配。相反,只有一个空规则的列表[{}]总是匹配。
借助适当的标记方案的帮助,AuthorizationPolicy 帮助定义了应用程序的网络段,这些网络段彼此独立且隔离。我们在 “带标签的网络段定义” 中所说的一切也同样适用于此处。
然而,当我们向规则添加身份检查时,AuthorizationPolicy 也可以用于应用程序级授权。与我们在 第二十六章 “访问控制” 中描述的授权的一个关键区别在于,AuthorizationPolicy 关注的是应用程序授权,而 Kubernetes RBAC 模型则关注保护对 Kubernetes API 服务器的访问。访问控制主要有助于操作员监视其自定义资源。
讨论
在计算机早期,网络拓扑是由物理布线和交换机等设备定义的。这种方法安全但不太灵活。随着虚拟化的出现,这些设备被软件支持的构造物所取代,以提供网络安全性。软件定义网络(SDN)是一种允许网络管理员通过抽象化低级功能来管理网络服务的计算机网络架构类型。通常通过分离控制平面(负责如何传输数据的决策)和数据平面(实际发送数据)来实现这种抽象化。即使使用了 SDN,管理员仍然需要设置和重新安排网络边界以有效管理网络。
Kubernetes 通过 Kubernetes API 将其扁平的集群内部网络覆盖与用户定义的网络段进行了重叠。这是网络用户界面演变的下一步。它将责任转移给了了解其应用程序安全要求的开发人员。这种左移的方法在微服务世界中尤其有益,该世界中存在许多分布式依赖和复杂的连接网络。用于 L3/L4 网络分段的 NetworkPolicies 和用于更精细控制网络边界的 AuthenticationPolicies 是实施此网络分段模式所必需的。
随着基于 eBPF 的平台在 Kubernetes 之上的出现,支持寻找合适的网络模型得到了额外的支持。Cilium 是将 L3/L4 和 L7 防火墙结合到单一 API 中的一个例子,使得在未来版本的 Kubernetes 中更容易实现本章描述的模式。
更多信息
^(1) OSI 网络栈的第三级和第四级主要涉及 IP 和 TCP/UDP。
^(2) eBPF 最初是“扩展伯克利数据包过滤器”的缩写,但现在已经成为一个独立的术语。
第二十五章:安全配置
现实世界中的应用程序都不是孤立存在的。相反,每个应用程序都以某种方式连接到外部系统。这些外部系统可能包括由大型云提供商提供的增值服务、您的服务连接的其他微服务或数据库。无论您的应用程序连接到哪些远程服务,您可能需要进行身份验证,这涉及发送诸如用户名和密码或其他安全令牌等凭据。这些机密信息必须安全地存储在靠近您的应用程序的地方。本章的“安全配置”模式就是要探讨在运行在 Kubernetes 上时保持凭证尽可能安全的最佳方法。
问题
正如您在 第二十章,“配置资源” 中所学到的,尽管其名称暗示着 Secret 资源是加密的,但实际上它们只是 Base64 编码的。尽管如此,Kubernetes 通过所描述的技术尽最大努力限制对 Secret 内容的访问,详见 “Secrets 有多安全?”。
然而,一旦 Secret 资源存储在集群外部,它们就会暴露且易受攻击。随着 GitOps 作为部署和维护服务器端应用程序的主流范式的出现,这一安全挑战变得更加紧迫。秘密应该存储在远程 Git 存储库中吗?如果是这样,那么它们就不能以未加密形式存储。然而,当这些秘密以加密形式提交到 Git 等源代码管理系统时,在进入 Kubernetes 集群的过程中,它们何时解密?
即使凭证以加密形式存储在集群内部,也不能保证没有其他人可以访问这些机密信息。尽管您可以通过 RBAC 规则对 Kubernetes 资源进行细粒度访问控制^(1),但至少有一个人可以访问集群中存储的所有数据:您的集群管理员。您可能能够信任集群管理员,也可能不能。这完全取决于您的应用程序运行的上下文。您是否在由他人操作的云中运行 Kubernetes 集群?或者您的应用程序部署在大型公司范围的 Kubernetes 平台上,您需要知道谁在管理此集群?根据这些信任边界和保密要求,需要不同的解决方案。
在集群内部,Secrets 是 Kubernetes 提供的用于保护配置的答案。我们在 第二十章,“配置资源” 中详细讨论了 Secrets,现在让我们看看如何通过额外技术来改善 Secrets 的各种安全方面。
解决方案
对于安全配置来说,最直接的解决方案是在应用程序内部解码加密信息。这种方法始终有效,不仅在 Kubernetes 上运行时有效。但是,在你的代码中实现这一点需要相当大的工作量,并且将你的业务逻辑与配置安全相关的方面耦合在一起。在 Kubernetes 上有更好、更透明的方法来实现这一点。
在 Kubernetes 上支持安全配置大致可分为两类:
集群外加密
这将加密的配置信息存储在 Kubernetes 之外,即使未授权的人也可以读取。在进入集群之前(例如,通过 API 服务器应用资源时)或者集群内部通过一个持续运行的操作器进程,会将其转换为 Kubernetes Secrets。
集中式秘密管理
这使用专门的服务,这些服务已经由云提供商提供(例如 AWS Secrets Manager 或 Azure Key Vault),或者是内部金库服务的一部分(例如 HashiCorp Vault),用于存储机密配置数据。
尽管在集群外加密技术最终会创建一个 Secret 在集群中,供你的应用程序使用,但 Kubernetes 附加的外部秘密管理系统(SMSs)支持使用各种其他技术将机密信息传递到部署的工作负载中。
集群外加密
集群外技术的要义很简单:从集群外部获取秘密和机密数据,并将其转换为 Kubernetes Secret。已经有很多项目在实现这种技术。本章将着眼于三个最显著的项目(截至 2023 年):Sealed Secrets、External Secrets 和 sops。
密封的密钥
帮助加密秘密的最古老的 Kubernetes 附加组件之一是 Sealed Secrets,由 Bitnami 在 2017 年引入。其思想是将 Secret 的加密数据存储在一个 CustomResourceDefinition(CRD)SealedSecret 中。在后台,一个操作器监视这些资源,并为每个 SealedSecret 创建一个 Kubernetes Secret,其中包含解密后的内容。要详细了解 CRD 和操作器的一般情况,请查看第二十八章,“操作器”。虽然解密是在集群内部进行的,但 加密 是在外部通过一个名为 kubeseal 的 CLI 工具进行的,该工具将一个 Secret 转换为一个可以安全存储在类似 Git 的源代码管理系统中的 SealedSecret。
图 25-1 展示了 Sealed Secrets 的设置。

图 25-1. 密封的密钥
Secrets 使用 AES-256-GCM 对称加密作为会话密钥加密,会话密钥使用 RSA-OAEP 非对称加密,与 TLS 相同的设置。
秘密私钥存储在集群内部,并由 SealedSecret Operator 自动创建。管理员需要负责备份并根据需要轮转此密钥。kubeseal 使用的公钥可以直接从集群获取,也可以直接从文件访问。您还可以安全地将公钥与您的 SealedSecret 一起存储在 Git 中。
当创建 SealedSecret 时,SealedSecrets 支持三个范围可供选择:
严格模式
这将冻结 SealedSecret 的命名空间和名称。这意味着您只能在任何目标集群中以与原始 Secret 相同的名称和相同的命名空间中创建 SealedSecret。这是默认行为。
命名空间范围
这允许您将 SealedSecret 应用于与初始 Secret 不同的名称,但仍将其固定在相同的命名空间。
集群范围
这允许您将 SealedSecret 应用于不同的命名空间,正如最初创建的那样,名称也可以更改。
在使用 kubeseal 创建 SealedSecret 时可以选择这些范围。但是,您也可以在加密前或直接在 SealedSecret 上使用 表 25-1 中列出的注释添加非严格范围。
表 25-1. 注释
| 注释 | 值 | 描述 |
|---|---|---|
| sealedsecrets.bitnami.com/namespace-wide | "true" |
当设置为 true 时启用命名空间范围——即不同名称但相同命名空间 |
| sealedsecrets.bitnami.com/cluster-wide | "true" |
当设置为 true 时启用集群范围——即加密后可以更改 SealedSecret 的名称和命名空间 |
示例 25-1 展示了使用 kubeseal 创建的 SealedSecret,可以直接存储在 Git 中。
示例 25-1. 使用 kubeseal 创建的 SealedSecret
# Command to create this sealed secret:
# kubeseal --scope cluster-wide -f mysecret.yaml 
apiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata:
annotations:
sealedsecrets.bitnami.com/cluster-wide: "true" 
name: DB-credentials
spec:
encryptedData:
password: AgCrKIIF2gA7tSR/gqw+FH6cEV..wPWWkHJbo= 
user: AgAmvgFQBBNPlt9Gmx..0DNHJpDIMUGgwaQroXT+o=
使用命令从 mysecret.yaml 中存储的秘密创建一个 SealedSecret。
注释表明这个 SealedSecret 可以有任何名称并且可以应用于任何命名空间。
为了演示,这里只是对密钥值进行了单独加密。
Sealed Secret 是一种工具,允许您将加密的秘密存储在公开可用的位置,例如 GitHub 存储库。正确备份秘密密钥非常重要,因为如果卸载操作员,则无法解密秘密。Sealed Secrets 的一个潜在缺点是,它们需要在集群中持续运行服务器端操作员才能执行解密操作。
外部 Secrets
外部秘密操作员是一个 Kubernetes 操作员,集成了越来越多的外部 SMS。外部秘密与密封密钥的主要区别在于,您不管理加密数据存储,而是依赖外部 SMS 来执行加密、解密和安全持久化等工作。这样,您就可以从云 SMS 的所有功能中受益,例如密钥轮换和专用用户界面。SMS 还提供了一个很好的分离关注点的方式,使不同的角色可以分别管理应用部署和秘密。
图 25-2 展示了外部秘密的架构。

图 25-2. 外部秘密
一个中央操作员协调两个自定义资源:
-
SecretStore 是保存访问外部 SMS 类型和配置的资源。示例 25-2 提供了一个连接到 AWS Secret Manager 的存储示例。
-
ExternalSecret 引用了一个 SecretStore,并且操作员将创建一个对应的 Kubernetes Secret,其中填充了从外部 SMS 中获取的数据。例如,示例 25-3 引用了 AWS Secret Manager 中的一个秘密,并在指定的目标 Secret 中公开该值。
示例 25-2. 连接到 AWS Secret Manager 的 SecretStore
apiVersion: external-secrets.io/v1beta1
kind: SecretStore
metadata:
name: secret-store-aws
spec:
provider:
aws: 
service: SecretsManager
region: us-east-1
auth:
secretRef:
accessKeyIDSecretRef: 
name: awssm-secret
key: access-key
secretAccessKeySecretRef:
name: awssm-secret
key: secret-access-key
Provider aws 配置了使用 AWS Secret Manager。
引用一个包含与 AWS Secret Manager 通信所需的访问密钥的 Secret。名为 awssm-secret 的 Secret 包含用于对 AWS Secret Manager 进行身份验证的 access-key 和 secret-access-key。
示例 25-3. 将被转换为 Secret 的 ExternalSecret
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: db-credentials
spec:
refreshInterval: 1h
secretStoreRef: 
name: secret-store-aws
kind: SecretStore
target:
name: db-credentials-secrets 
creationPolicy: Owner
data:
- key: cluster/db-username 
name: username
- key: cluster/db-password
name: password
SecretStore 对象的引用,该对象包含与 AWS Secret Manager 的连接参数。
要创建的 Secret 的名称。
在 AWS Secret Manager 下查找的将要被查找的用户名,并在生成的 Secret 中放置在键 username 下。
在定义外部秘密数据映射到镜像秘密内容方面,您有很大的灵活性,例如可以使用模板创建具有特定结构的配置。有关更多信息,请参阅外部秘密文档。与客户端解决方案相比,这种解决方案的一个显著优势是只有服务器端操作员知道用于对外部 SMS 进行身份验证的凭据。
外部 Secrets Operator 项目合并了几个其他同步项目。在 2023 年,它已经成为映射和同步外部定义秘密到 Kubernetes Secret 的主要解决方案。然而,它与一直运行的服务器端组件具有相同的成本。
Sops
在 GitOps 的世界中,是否需要服务器端组件来处理 Secrets,其中所有资源都存储在 Git 存储库中?幸运的是,存在完全在 Kubernetes 集群之外工作的解决方案。Mozilla 提供了一种名为 sops(“Secret OPerationS”)的纯客户端解决方案。Sops 并非专为 Kubernetes 设计,但允许您加密和解密任何 YAML 或 JSON 文件,以安全地存储它们在源代码存储库中。它通过加密文档的所有值但保持键不变来实现这一点。
我们可以使用多种方法来使用 sops 进行加密:
-
通过
age进行非对称本地加密,并在本地存储密钥。 -
将密钥存储在集中式密钥管理系统(KMS)中。支持的平台包括 AWS KMS、Google KMS 和 Azure Key Vault 作为外部云提供商,以及 HashiCorp Vault 作为您可以自行托管的 SMS。这些平台的身份管理允许对加密密钥进行精细的访问控制。
Sops 是一个 CLI 工具,您可以在本地计算机上或集群内(例如作为 CI 流水线的一部分)运行。特别是对于后一种用例,如果您在其中一个大型云平台上运行,利用它们的 KMS 提供了平滑的集成。
Figure 25-3 说明了 sops 如何在客户端处理加密和解密。

Figure 25-3. 用于解密和加密资源文件的 sops
Example 25-4 展示了如何使用 sops 创建 ConfigMap 的加密版本的示例。^(2) 本示例使用 age 和一个新生成的密钥对进行加密,应安全地存储。
Example 25-4. 使用 sops 创建加密密钥的示例
$ age-keygen -o keys.txt 
Public key: age1j49ugcg2rzyye07ksyvj5688m6hmv
$ cat configmap.yaml 
apiVersion: v1
kind: ConfigMap
metadata:
name_unencrypted: db-auth 
data:
# User and Password
USER: "batman"
PASSWORD: "r0b1n"
$ sops --encrypt \ 
--age age1j49ugcg2rzyye07ksyvj5688m6hmv \
configmap.yaml > configmap_encrypted.yaml
$ cat configmap_encrypted.yaml
apiVersion: ENC[AES256_GCM,data:...,iv:...,tag:...,type:str] 
kind: ENC[AES256_GCM,data:...,iv:...,tag:...,type:str]
metadata:
name_unencrypted: db-auth 
data:
#ENC[AES256_GCM,data:...,iv:...,tag:...,type:comment]
USER: ENC[AES256_GCM,data:...,iv:...,tag:...=,type:str]
PASSWORD: ENC[AES256_GCM,data:...,iv:...,tag:...,type:str]
sops: 
age:
- recipient: age1j49ugcg2rzyye07ksyvj5688m6hmv
enc: | 
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBqems3QkU4aXRyQWxaNER1
TTdqcUZTeXFXNWhSY0E1T05XMUhVUzFjR1FnCmdMZmhlSlZCRHlqTzlNM0E1Z280
Y0tqQ2VKYXdxdDZIZHpDbmxTYzhQSTgKLS0tIHlBYmloL2laZlA4Q05DTmRwQ0ls
bURoU2xITHNzSXp5US9mUUV0Z0RackkKFtH+uNNe3A13pzSvHjT6n3q9av0pN7Nb
i3AULtKvAGs6oAnH8qYbnwoj3qt/LFfnbqfeFk1zC2uqNONWkKxa2Q==
-----END AGE ENCRYPTED FILE-----
last modified: "2022-09-20T09:56:49Z"
mac: ENC[AES256_GCM,data:...,iv:...,tag:...,type:str]
unencrypted_suffix: _unencrypted
使用 age 创建一个秘密密钥并将其存储在 keys.txt 中。
要加密的 ConfigMap。
name 字段更改为 name_unencrypted 以防止其被加密。
使用 age 密钥的公共部分调用 sops,并将结果存储在 configmap_encrypted.yml 中。
每个值都替换为 ENC[...] 的加密版本(为了可读性,输出已缩短)。
ConfigMap 的 name 保持不变。
追加一个额外的 sops 部分,包含解密所需的元数据。
用于对称解密的加密会话密钥。此密钥本身由age进行非对称加密。
正如您所见,ConfigMap 资源的每个值都被加密,即使是那些不是机密的值,如资源类型或资源名称。您可以通过在键后添加_unencrypted后缀(解密时会自动去除)来跳过特定值的加密。
生成的configmap_encrypted.yml可以安全地存储在 Git 或任何其他源代码管理工具中。正如示例 25-5 中所示,您需要私钥来解密加密的 ConfigMap 以应用到集群。
示例 25-5. 解密经 sops 编码的资源并应用到 Kubernetes
$ export SOPS_AGE_KEY_FILE=keys.txt 
$ sops --decrypt configmap_encrypted.yaml | kubectl apply -f - 
configmap/db-auth created
解密会话密钥的关键步骤是使用私钥来解密。
解密并应用到 Kubernetes。请注意,在 sops 解密期间,每个资源键上的_unencrypted后缀都会被移除。
Sops 是一个出色的解决方案,可以轻松实现 GitOps 风格的秘密集成,无需担心安装和维护 Kubernetes 插件。但是,请注意,一旦配置被交给集群,任何具有高级访问权限的人员可以直接通过 Kubernetes API 读取这些数据。
如果这是您无法容忍的事情,我们需要深入研究工具箱,并重新审视集中式 SMS。
集中式秘密管理
正如在《“秘密有多安全?”》中解释的那样,秘密尽可能安全。尽管如此,任何具有集群范围读取权限的管理员都可以读取所有未加密存储的秘密。根据您与集群操作者的信任关系和安全要求,这可能是个问题或不是问题。
除了将个别秘密处理集成到您的应用代码中之外,另一个选择是将安全信息保持在集群外部的外部 SMS 中,并通过安全通道按需请求机密信息。
这类 SMS 的数量在不断增加,每个云提供商都提供其变体。我们不会详细介绍每个单独提供的内容,而是专注于这些系统如何集成到 Kubernetes 中的机制。您可以在“更多信息”中找到截至 2023 年的相关产品列表。
秘密存储 CSI 驱动程序
容器存储接口(CSI)是 Kubernetes API,用于向容器化应用程序公开存储系统。CSI 显示了第三方存储提供程序插入新类型的存储系统以便作为 Kubernetes 卷挂载的路径。在本模式的上下文中特别感兴趣的是Secrets Store CSI Driver。这个由 Kubernetes 社区开发和维护的驱动程序允许访问各种集中式 SMS,并将它们作为常规的 Kubernetes 卷挂载。与描述在第二十章,“配置资源”中的挂载的秘密卷的不同之处在于,没有任何内容存储在 Kubernetes etcd 数据库中,而是安全地存储在集群外部。
Secrets Store CSI Driver 支持主要云供应商(AWS、Azure 和 GCP)和 HashiCorp Vault 的 SMS。
通过 CSI 驱动程序连接秘密管理器的 Kubernetes 设置涉及执行这两个管理任务:
-
安装 Secrets Store CSI Driver 并配置访问特定 SMS 的步骤。安装过程需要集群管理员权限。
-
配置访问规则和策略。需要完成几个特定于提供程序的步骤,但结果是将 Kubernetes 服务账户映射到允许访问秘密的秘密管理器特定角色。
图 25-4 显示了启用具有 HashiCorp Vault 后端的 Secrets Store CSI Driver 所需的整体设置。

图 25-4. Secrets Store CSI Driver
设置完成后,使用秘密卷非常简单。首先,必须定义一个 SecretProviderClass,如例 25-6 所示。在此资源中,选择秘密管理器的后端提供程序。在我们的示例中,我们选择了 HashiCorp 的 Vault。在parameters部分中,添加了提供程序特定的配置,其中包含与 Vault 的连接参数、要模拟的角色以及 Kubernetes 将挂载到 Pod 中的秘密信息的指针。
例 25-6. 访问秘密管理器的配置示例
apiVersion: secrets-store.csi.x-k8s.io/v1
kind: SecretProviderClass
metadata:
name: vault-database
spec:
provider: vault 
parameters:
vaultAddress: "http://vault.default:8200" 
roleName: "database" 
objects: |
- objectName: "database-password" 
secretPath: "secret/data/database-creds" 
secretKey: "password" 
使用的提供者类型(截至 2023 年为azure、gcp、aws或vault)。
到 Vault 服务实例的连接 URL。
Vault 特定的认证角色包含允许连接的 Kubernetes 服务账户。
应该映射到挂载卷中的文件名称。
存储在保险库中的秘密路径。
从 Vault 秘密中选择的密钥。
当作为 Pod 卷使用时,此秘密管理器配置可以通过其名称进行引用。示例 25-7 展示了一个挂载在 示例 25-6 配置的秘密的 Pod。一个关键的方面是服务账户 vault-access-sa,该 Pod 使用该账户运行。必须在 Vault 侧配置此服务账户以成为 SecretProviderClass 中引用的 database 角色的一部分。
您可以在我们完整的工作和自包含的示例中找到此 Vault 配置,以及设置说明。
示例 25-7. Pod 从 Vault 挂载 CSI 卷
kind: Pod
apiVersion: v1
metadata:
name: shell-pod
spec:
serviceAccountName: vault-access-sa 
containers:
- image: k8spatterns/random
volumeMounts:
- name: secrets-store
mountPath: "/secrets-store" 
volumes:
- name: secrets-store
csi: 
driver: secrets-store.csi.k8s.io
readOnly: true
volumeAttributes:
secretProviderClass: "vault-database" 
用于对 Vault 进行身份验证的服务账户。
用于挂载秘密的目录。
CSI 驱动程序的声明,指向秘密存储 CSI 驱动程序。
引用提供到 Vault 服务的 SecretProviderClass。
尽管 CSI Secret Storage 驱动程序的设置非常复杂,但使用起来很简单,您可以避免在 Kubernetes 中存储机密数据。但是,比仅使用 Secrets 更复杂,因此出错的可能性更大,而且故障排除更为困难。
让我们看看通过众所周知的 Kubernetes 抽象向应用程序提供秘密的最终替代方案。
Pod 注入
如前所述,应用程序始终可以通过专有客户端库访问外部 SMS。这种方法的缺点在于,您仍然需要存储用于访问 SMS 的凭据,并且在您的代码中为特定的 SMS 添加了硬依赖。将秘密信息投影到可作为文件看到的卷中以供部署的应用程序使用的 CSI 抽象更为解耦。
备选方案利用了本书中描述的其他众所周知的模式:
-
Init Container(见第十五章)从 SMS 获取机密数据,然后将其复制到由应用程序容器挂载的共享本地卷中。主容器启动之前只获取一次机密数据。
-
Sidecar(见第十六章)将秘密数据从 SMS 同步到本地临时卷,应用程序也可以访问该卷。Sidecar 方法的好处在于,它可以在 SMS 开始轮换秘密时在本地更新秘密。
您可以自行利用这些模式为您的应用程序提供支持,但这很繁琐。更好的方法是让外部控制器注入初始化容器或 sidecar 到您的应用程序中。
一个很好的注入器示例是 HashiCorp 的Vault Sidecar Agent Injector。这个注入器被实现为所谓的mutating webhook,这是控制器的一种变体(见第二十七章,“Controller”),允许在创建时修改任何资源。当 Pod 规范包含特定的 vault 注解时,vault 控制器将修改此规范,添加一个用于与 Vault 同步的容器,并挂载用于秘密数据的卷。
图 25-5 展示了这种对用户完全透明的技术。
尽管您仍然需要安装 Vault Injector 控制器,但与为特定 SMS 产品的提供程序部署连接 CSI 秘密存储卷相比,它的移动部件更少。然而,您只需读取文件而无需使用专有客户端库即可访问所有秘密。

图 25-5. Vault 注入器
讨论
现在我们已经看到了许多使访问您的机密信息更安全的方法,问题是,哪种方法是最好的?
像往常一样,这取决于:
-
如果您的主要目标是以简单的方式加密存储在公共可读位置(如远程 Git 存储库)中的 Secrets,那么Sops提供的纯客户端加密是完美的选择。
-
External Secrets Operator实现的秘密同步在分离从远程 SMS 检索凭据并使用它们的关注点时是一个很好的选择。
-
由Secret Storage CSI Providers提供的秘密信息的临时卷投影是您在希望确保除了用于访问外部 vault 的访问令牌之外在集群中永久存储任何机密信息都不被存储时的正确选择。
-
像Vault Sidecar Agent Injector这样的侧边注入器有利于防止直接访问 SMS。它们易于接近,但由于安全注解泄漏到应用部署中,模糊了开发人员和管理员之间的界限。
请注意,所列出的项目截至 2023 年在写作时最为突出。技术不断发展变化,因此在您阅读本书时,可能会有新的竞争者(或一些现有项目可能已经停止)。然而,使用的技术(客户端加密、秘密同步、卷投影和侧边注入)是通用的,并将成为未来解决方案的一部分。
但最后要明确警告:无论您如何安全地访问和保护您的秘密配置,如果有恶意意图的人拥有对您的集群和容器的完全 root 访问权限,总会存在获取这些数据的手段。通过在 Kubernetes Secret 抽象上添加额外的层,这种模式使这类利用变得尽可能困难。
更多信息
-
Alex Soto Bueno 和 Andrew Block 的 Kubernetes Secrets Management(Manning,2022)
-
Secret 管理系统:
^(1) RBAC 规则在 第二十六章,“访问控制” 中有详细解释。
^(2) 在现实世界中,你应该为这种机密信息使用一个 Secret,但这里我们使用 ConfigMap 来演示你可以使用 任何 资源文件与 sops 一起使用。
第二十六章:访问控制
随着世界越来越依赖云基础架构和容器化,安全性的重要性不可低估。2022 年,安全研究人员做出了令人担忧的发现:由于错误配置,近 100 万个 Kubernetes 实例暴露在互联网上。^(1) 利用专门的安全扫描器,研究人员能够轻松访问这些易受攻击的节点,突显了保护 Kubernetes 控制平面的严格访问控制措施的必要性。但是,虽然开发人员通常关注应用程序级授权,但有时也需要使用第二十八章中的操作者模式扩展 Kubernetes 的能力。在这些情况下,Kubernetes 平台上的访问控制变得至关重要。在本章中,我们深入探讨了访问控制模式,并探索了 Kubernetes 授权的概念。面对潜在的风险和后果,确保 Kubernetes 部署的安全性从未如此重要。
问题
当涉及操作应用程序时,安全性是一个关键问题。在安全性的核心是两个重要的概念:认证和授权。
认证 关注操作的主体,即操作的谁,并防止未经授权的访问。授权 则涉及确定对资源允许执行哪些操作的权限。
在本章中,我们将简要讨论身份验证,因为它主要是一个涉及将各种身份管理技术与 Kubernetes 集成的管理问题。另一方面,开发人员通常更关心授权,例如谁可以在集群中执行哪些操作以及访问应用程序的特定部分。
为了保护运行在 Kubernetes 顶部的应用程序的访问权限,开发人员必须考虑一系列安全策略,从简单的基于 Web 的认证到涉及外部提供商进行身份和访问管理的复杂单点登录场景。同时,对 Kubernetes API 服务器的访问控制对运行在 Kubernetes 上的应用程序同样重要。
错误配置的访问可能导致特权升级和部署失败。高特权的部署可以访问或修改其他部署的配置和资源,增加了集群妥协的风险。^(2) 开发人员理解管理员设置的授权规则,并在进行配置更改和部署新工作负载以符合组织范围政策时考虑安全性非常重要。
此外,随着越来越多的 Kubernetes 本地应用程序通过自定义资源定义(CRD)扩展 Kubernetes API 并向用户提供服务,如“控制器和操作员分类”所述,访问控制变得更加关键。Kubernetes 模式,如第二十七章,“控制器”和第二十八章,“操作员”,需要高权限来观察整个集群资源的状态,因此至关重要的是进行精细化的访问管理和限制,以限制任何潜在安全漏洞可能带来的影响。
解决方案
每个请求到 Kubernetes API 服务器必须经过三个阶段:认证、授权和准入控制,如图 26-1 所示。

图 26-1. 请求到 Kubernetes API 服务器必须经过这些阶段
一旦请求通过下面描述的认证和授权阶段,最终由准入控制器进行最终检查后,请求最终才会被处理。让我们分别看看这些阶段。
认证
如前所述,我们不会深入讨论认证,因为它主要是管理问题。但了解 Kubernetes 提供的可插拔认证策略是有好处的,让管理员可以配置:
使用 OIDC Authenticator 的 Bearer Tokens(OpenID Connect)
OpenID Connect(OIDC)的 Bearer Tokens 可以对客户端进行认证并授予对 API 服务器的访问权限。OIDC 是一种标准协议,允许客户端与支持 OIDC 的 OAuth2 提供者进行认证。客户端在其请求的 Authorization 标头中发送 OIDC 令牌,API 服务器验证该令牌以允许访问。有关整个流程,请参阅 Kubernetes 文档中的OpenID Connect Tokens。
客户端证书(X.509)
通过使用客户端证书,客户端向 API 服务器提供一个 TLS 证书,然后对其进行验证并用于授权访问。
认证代理
此配置选项是指使用自定义认证代理来验证客户端的身份,然后才允许访问 API 服务器。代理充当客户端和 API 服务器之间的中介,并在允许访问之前执行认证和授权检查。
静态令牌文件
Token 也可以存储在标准文件中,并用于认证。在此方法中,客户端向 API 服务器提供一个令牌,然后用于查找令牌文件并搜索匹配项。
Webhook Token 认证
Webhook 可以对客户端进行身份验证,并授予对 API 服务器的访问权限。在这种方法中,客户端在其请求的授权头部发送一个令牌,API 服务器将该令牌转发给预先配置的 Webhook 进行验证。如果 Webhook 返回有效响应,则客户端被授予访问 API 服务器的权限。这种技术类似于 Bearer Token 选项,不同之处在于可以使用外部自定义服务进行令牌验证。
Kubernetes 允许同时使用多个身份验证插件,如 Bearer Tokens 和客户端证书。如果 Bearer Token 策略验证了请求,Kubernetes 将不会检查客户端证书,反之亦然。不幸的是,这些策略评估的顺序不是固定的,因此无法知道哪一个会首先被检查。在评估策略时,一旦成功,过程将停止,并且 Kubernetes 将请求转发到下一个阶段。
身份验证后,将开始授权过程。
授权
Kubernetes 提供 RBAC 作为管理系统访问权限的标准方式。RBAC 允许开发者以精细化的方式控制和执行操作。Kubernetes 中的授权插件还提供了易于插拔的特性,允许用户在默认 RBAC 和其他模型(如基于属性的访问控制(ABAC)、Webhook 或委托给自定义权威)之间进行切换。
基于属性的访问控制(ABAC)方法需要一个文件,其中包含以 JSON 每行一条的策略。然而,这种方法需要服务器重新加载以应用任何更改,这可能是一个缺点。这种静态特性是 ABAC 授权仅在某些情况下使用的原因之一。
相反,几乎每个 Kubernetes 集群都使用默认的基于 RBAC 的访问控制,我们在“基于角色的访问控制”中详细描述了该控制方式。
在本章的其余部分关注授权之前,让我们快速看一下准入控制器执行的最后阶段。
准入控制器
准入控制器是 Kubernetes API 服务器的一个特性,允许您拦截对 API 服务器的请求,并根据这些请求执行附加操作。例如,您可以使用它们来强制执行策略、执行验证和修改传入的资源。
Kubernetes 使用准入控制器插件来实现各种功能。这些功能范围从在特定资源上设置默认值(如持久卷上的默认存储类),到验证(如 Pod 的允许资源限制),通过调用外部 Webhook 实现。
这些外部 Webhook 可以配置为专用资源,并用于验证(ValidatingWebhookConfiguration)和更新(MutatingWebhookConfiguration)API 资源。有关配置此类 Webhook 的详细信息,请参阅 Kubernetes 文档“动态 Admission 控制”。
我们在这里不会详细介绍 Admission controllers,因为它们大多是一个管理概念,还有很多其他好的资源专门描述了 Admission controllers(参见“更多信息”)。
相反,本章的剩余部分,我们将专注于授权方面,以及如何为保护对 Kubernetes API 服务器的访问配置精细化的权限模型。
正如提到的,认证有两个基本部分和授权:谁,由一个主体表示,可以是一个人或者一个工作负载身份,以及什么,代表这些主体可以触发 Kubernetes API 服务器上的操作。在接下来的章节中,我们先讨论谁,然后再深入讨论什么的细节。
主体
主体 关注的是谁,与请求到 Kubernetes API 服务器相关联的身份。在 Kubernetes 中,有两种类型的主体,如图 26-2 所示:人类用户和代表 Pod 工作负载身份的服务账户。

图 26-2. 主体(用户或服务账户)请求到 API Server
人类用户和服务账户可以分别分组为用户组和服务账户组,这些组可以作为单一主体,其中组的所有成员共享相同的权限模型。我们将在本章后面讨论组,但首先,让我们仔细看看人类用户在 Kubernetes API 中的表示。
用户
与 Kubernetes 中许多其他实体不同,人类用户不作为显式资源在 Kubernetes API 中定义。这种设计决策意味着您无法通过 API 调用管理用户。认证和映射到用户主体是在通常的 Kubernetes API 机制之外由外部用户管理完成的。
正如我们所见,Kubernetes 支持多种方法来验证外部用户的身份。每个组件都知道如何在成功验证后提取主体信息。虽然每个认证组件的机制都不同,但它们最终会创建相同的用户表示,并将其添加到实际的 API 请求中,在后续阶段进行验证,如图例子 26-1 所示。
例子 26-1. 成功验证外部用户的表示
alice,4bc01e30-406b-4514,"system:authenticated,developers","scopes:openid"
这个逗号分隔的列表表示用户,并包含以下部分:
-
用户名(
alice) -
唯一用户标识符(UID)(
4bc01e30-406b-4514) -
该用户所属的组列表(
system:authenticated,developers) -
附加信息作为逗号分隔的键值对 (
scopes:openid)
此信息由授权插件根据用户关联的授权规则或其所属用户组进行评估。在 示例 26-1 中,用户名为 alice 的用户具有与 system:authenticated 组和 developers 组关联的默认访问权限。额外的信息 scope:openid 表示正在使用 OIDC 验证用户身份。
某些用户名专为 Kubernetes 内部使用保留,并由特殊前缀 system: 区分。例如,用户名 system:anonymous 代表对 Kubernetes API 服务器的匿名请求。建议避免以 system: 前缀创建自己的用户或组,以避免冲突。表 26-1 列出了在 Kubernetes 中用于内部组件间通信时使用的默认用户名。
表 26-1. Kubernetes 中的默认用户名
| 用户名 | 用途 |
|---|---|
system:anonymous |
表示对 Kubernetes API 服务器的匿名请求 |
system:apiserver |
表示 API 服务器本身 |
system:kube-proxy |
表示 kube-proxy 服务的进程身份 |
system:kube-controller-manager |
表示控制器管理器的用户代理 |
system:kube-scheduler |
表示调度程序的用户 |
虽然 Kubernetes 集群的外部用户管理和认证可能因具体设置而异,但对 Pod 的工作负载身份的管理是 Kubernetes API 的标准化部分,并在所有集群中保持一致。
Service 账户
Kubernetes 中的服务账户代表集群内的非人类实体,并用作工作负载标识。它们与 Pod 关联,允许 Pod 内的运行进程与 Kubernetes API 服务器通信。与 Kubernetes 可以对人类用户进行身份验证的多种方式不同,服务账户始终使用 OpenID Connect 握手 和 JSON Web Tokens 来证明其身份。
Kubernetes 中的服务账户通过 API 服务器使用以下格式的用户名进行身份验证:system:serviceaccount:<namespace>:<name>。例如,如果在 default 命名空间中有一个名为 random-sa 的服务账户,则服务账户的用户名将是 system:serviceaccount:default:random-sa。
ServiceAccount 是 Kubernetes 的标准资源,如 示例 26-2 所示。
示例 26-2. ServiceAccount 定义
apiVersion: v1
kind: ServiceAccount
metadata:
name: random-sa 
namespace: default
automountServiceAccountToken: false 
...
服务账户的名称。
指示是否默认将服务账户令牌挂载到 Pod 中的标志。默认设置为 true。
ServiceAccount 具有简单的结构,并为 Pod 在与 Kubernetes API 服务器通信时所需的所有身份相关信息提供服务。每个命名空间都有一个默认的 ServiceAccount,名称为default,用于标识未定义关联 ServiceAccount 的任何 Pod。
每个 ServiceAccount 都有一个与之关联的 JWT,完全由 Kubernetes 后端管理。每个 Pod 的关联 ServiceAccount 令牌会自动挂载到每个 Pod 的文件系统中。示例 26-3 显示了 Kubernetes 自动为每个创建的 Pod 添加的相关部分。
示例 26-3. ServiceAccount 令牌作为 Pod 的文件挂载
apiVersion: v1
kind: Pod
metadata:
name: random
spec:
serviceAccountName: default 
containers:
volumeMounts:
- mountPath: /var/run/secrets/kubernetes.io/serviceaccount 
name: kube-api-access-vzfp7 
readOnly: true
...
volumes:
- name: kube-api-access-vzfp7
projected: 
defaultMode: 420
sources:
- serviceAccountToken:
expirationSeconds: 3600 
path: token 
...
serviceAccountName用于设置服务帐户的名称(serviceAccount是serviceAccountName的已弃用别名)。
/var/run/secrets/kubernetes.io/serviceaccount是服务帐户令牌挂载的目录。
Kubernetes 为自动生成的卷分配一个随机的 Pod 唯一名称。
一个投影体积直接将 ServiceAccount 令牌注入文件系统。
令牌的过期时间以秒计。此时间后,令牌过期,并且挂载的令牌文件将使用新令牌更新。
文件名将包含令牌的名称。
要查看挂载的令牌,我们可以在运行中的 Pod 中执行对挂载文件的cat,如示例 26-4 所示。
示例 26-4. 打印出服务帐户 JWT(输出已缩短)
$ kubectl exec random -- \
cat /var/run/secrets/kubernetes.io/serviceaccount/token
eyJhbGciOiJSUzI1NiIsImtpZCI6InVHYV9NZEVYOEZteUNUZFl...
在示例 26-3 中,令牌作为投影体积装载到 Pod 中。投影体积允许您合并多个卷源,如 Secret 和 ConfigMap 卷(在第二十章,“配置资源”中描述),进入单个目录。通过这种卷类型,ServiceAccount 令牌也可以直接映射到 Pod 的文件系统,使用serviceAccountToken子类型。该方法有几个好处,包括通过消除令牌中间表示的需要来减少攻击面,并提供设置令牌过期时间的能力,Kubernetes 令牌控制器将在过期后进行轮换。此外,注入到 Pod 中的令牌仅在 Pod 存在期间有效,进一步减少未经授权查看服务帐户令牌的风险。
在 Kubernetes 1.24 之前,秘密用于表示这些令牌,并直接通过secret卷类型挂载,其缺点是寿命长且缺乏轮换。由于新的投影卷类型,令牌仅对 Pod 可用,并且不作为附加资源公开,这减少了攻击面。您仍然可以手动创建一个包含 ServiceAccount 令牌的秘密,如示例 26-5 所示。
示例 26-5. 为 ServiceAccount random-sa创建一个秘密
apiVersion: v1
kind: Secret
type: kubernetes.io/service-account-token 
metadata:
name: random-sa
annotations:
kubernetes.io/service-account.name: "random-sa" 
一种特殊类型,表示这个秘密用于保存 ServiceAccount。
引用应添加其令牌的 ServiceAccount。
Kubernetes 将 token 和用于验证的公钥填充到秘密中。此秘密的生命周期现在与 ServiceAccount 本身绑定。如果删除了 ServiceAccount,Kubernetes 也会删除这个秘密。
ServiceAccount 资源有两个额外的字段,用于指定用于拉取容器映像的凭据和定义允许挂载的秘密:
映像拉取秘密
映像拉取秘密允许工作负载在拉取映像时与私有注册表进行身份验证。通常,您需要在 Pod 规范的字段.spec.imagePullSecrets中手动指定拉取秘密。但是,Kubernetes 通过允许直接将拉取秘密附加到顶级字段imagePullSecrets的 ServiceAccount 中提供了一种快捷方式。与 ServiceAccount 关联的每个 Pod 在创建时都将自动将拉取秘密注入其规范中。这种自动化消除了在每次在命名空间中创建新 Pod 时手动包含映像拉取秘密的需求,从而减少了所需的手动工作。
可挂载的秘密
ServiceAccount 资源中的secrets字段允许您指定与 ServiceAccount 关联的 Pod 可以挂载的秘密。您可以通过向 ServiceAccount 添加kubernetes.io/enforce-mountable-secrets注释来启用此限制。如果将此注释设置为true,则只允许 Pod 挂载列出的秘密。
组
Kubernetes 中的用户和服务账户都可以属于一个或多个组。组是由认证系统附加到请求的,用于授予所有组成员权限。如示例 26-1 中所见,组名是表示组名称的纯字符串。
正如前面提到的,组可以由身份提供者自由定义和管理,以创建具有相同权限模型的主体组。Kubernetes 中还隐含定义了一组预定义的组,其名称以system:为前缀。这些预定义组列在表 26-2 中。
我们将看到如何在“角色绑定”中使用组名来授予所有组成员的权限。
表 26-2. Kubernetes 中的系统组
| 组 | 用途 |
|---|---|
system:unauthenticated |
分配给每个未经身份验证请求的组 |
system:authenticated |
分配给已认证用户的组 |
system:masters |
其成员对 Kubernetes API 服务器拥有无限制访问权限的组 |
system:serviceaccounts |
集群中所有服务账户的组 |
system:serviceaccounts:<namespace> |
此命名空间中所有服务账户的组 |
现在您已经清楚了解用户、服务账户和组,让我们检查如何将这些主体与定义其允许执行的操作的角色关联到 Kubernetes API 服务器。
基于角色的访问控制
在 Kubernetes 中,角色定义了主体可以对特定资源执行的具体操作。然后,您可以将这些角色分配给主体,如用户或服务账户,如“主体”中所述,通过使用角色绑定。角色和角色绑定是可以像任何其他资源一样创建和管理的 Kubernetes 资源。它们与特定命名空间绑定,并适用于其资源。
图 26-3 展示了主体、角色和角色绑定之间的关系。

图 26-3. 角色、角色绑定和主体之间的关系
在 Kubernetes RBAC 中,了解主体与角色之间存在多对多的关系很重要。这意味着单个主体可以具有多个角色,并且单个角色可以应用于多个主体。使用包含对主体列表和特定角色的引用的角色绑定来建立主体与角色之间的关系。
RBAC 概念通过具体示例最好解释。示例 26-6 展示了 Kubernetes 中角色的定义。
示例 26-6. 允许访问核心资源的角色
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: developer-ro 
namespace: default 
rules:
- apiGroups:
- "" 
resources: 
- pods
- services
verbs: 
- get
- list
- watch
用于引用此角色的角色名称。
此角色适用的命名空间。角色始终与命名空间关联。
空字符串表示核心 API 组。
规则适用的 Kubernetes 核心资源列表。
API 操作由与该角色关联的主体允许的动词表示。
在示例 26-6 中定义的角色指定了与该角色关联的任何用户或服务账户可以对 Pod 和 Service 执行只读操作。
然后,可以在 示例 26-7 中显示的 RoleBinding 中引用此 Role,以授予用户 alice 和 ServiceAccount contractor 的访问权限。
示例 26-7. RoleBinding 规范
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: dev-rolebinding
subjects: 
- kind: User 
name: alice
apiGroup: "rbac.authorization.k8s.io"
- kind: ServiceAccount 
name: contractor
apiGroup: ""
roleRef:
kind: Role 
name: developer-ro
apiGroup: rbac.authorization.k8s.io
要连接到 Role 的主题列表。
名为 alice 的用户的人类用户引用。
名为 contractor 的 Service account。
引用已在 示例 26-6 中定义的名为 developer-ro 的 Role。
现在您已经对主题、Roles 和 RoleBindings 之间的关系有了基本的了解,让我们深入探讨 Roles 和 RoleBindings 的具体细节。
Role
Kubernetes 中的 Roles 允许您为一组 Kubernetes 资源或子资源定义一组允许的操作。在 Kubernetes 资源上的典型活动包括以下内容:
-
获取 Pods
-
删除 Secrets
-
更新 ConfigMaps
-
创建 ServiceAccounts
你已经在 示例 26-6 中看到了一个 Role。除了名称和命名空间等元数据外,Role 定义还包括一系列规则,描述了可以访问哪些资源。
只需匹配一个请求以授予对此 Role 的访问权限。每个 rule 描述了三个字段:
apiGroups
此列表用于代替单个值,因为通配符可以指定多个 API 组的所有资源。例如,空字符串 ("") 用于核心 API 组,其中包含主要的 Kubernetes 资源,如 Pods 和 Services。通配符字符 (*) 可以匹配集群感知的所有可用 API 组。
资源
此列表指定了 Kubernetes 应授予访问权限的资源。每个条目应属于至少一个配置的 apiGroups。单个 * 通配符条目表示允许来自所有配置的 apiGroups 的所有资源。
动词
在系统中允许的操作通过类似于 HTTP 方法的动词来定义。这些动词包括对资源的 CRUD 操作(CRUD 代表创建-读取-更新-删除,描述了你可以对持久化实体执行的常规读写操作),以及针对集合的单独操作,例如 list 和 deletecollection。此外,watch 动词允许访问资源变更事件,并与使用 get 直接读取资源的操作分开。对于运营人员来说,watch 动词对于接收他们正在管理的资源的当前状态通知至关重要。第二十七章,“Controller” 和 第二十八章,“Operator” 中有更多相关内容。表格 26-3 列出了最常见的动词。还可以使用 * 通配符字符来允许针对给定规则配置的所有资源的所有操作。
表 26-3. Kubernetes CRUD 操作的 HTTP 请求方法映射
| 动词 | HTTP 请求方法 |
|---|---|
| get, watch, list | GET |
| create | POST |
| patch | PATCH |
| update | PUT |
| delete, delete collection | DELETE |
通配符权限使得能够在不单独列出每个选项的情况下更轻松地定义所有操作。角色的 rule 元素的所有属性都允许使用 * 通配符,该通配符匹配所有内容。示例 26-8 允许在核心和 networking.k8s.io API 组中的所有资源上执行所有操作。如果使用通配符,此列表应仅包含此通配符作为其唯一条目。
Example 26-8. 资源和允许操作的通配符权限
rules:
- apiGroups:
- ""
- "networking.k8s.io"
resources:
- "*" 
verbs:
- "*" 
所有列出的 API 组中的所有资源,核心和 networking.k8s.io。
对这些资源允许执行所有操作。
通配符帮助开发人员快速配置规则。但它们伴随着特权升级的安全风险。这种更广泛的特权可能导致安全漏洞,并允许用户执行可能危及 Kubernetes 集群或引起不必要更改的任何操作。
现在我们已经深入探讨了 Kubernetes RBAC 模型中角色(what)和主体(who)的内容,让我们更详细地看看如何结合这两个概念与 RoleBindings。
RoleBinding
在示例 26-7 中,我们看到了 RoleBindings 如何将一个或多个主体链接到给定的角色。
每个 RoleBinding 可以将一组主体连接到一个角色。subjects 列表字段以资源引用为元素。这些资源引用具有 name 字段以及用于定义引用的资源类型的 kind 和 apiGroup 字段。
RoleBinding 中的主体可以是以下类型之一:
用户
用户是由 API 服务器认证的人或系统,如“用户”中所述。用户条目具有固定的 apiGroup 值为 rbac.authorization.k8s.io。
组
组是用户的集合,如“组”所解释的那样。对于用户,组条目带有 rbac.authorization.k8s.io 作为 apiGroup。
ServiceAccount
我们在“Service accounts”中深入讨论了 ServiceAccount。ServiceAccounts 属于核心 API 组,由空字符串 ("") 表示。ServiceAccounts 的一个独特之处在于它是唯一可以携带 namespace 字段的主体类型。这使您可以授予对其他命名空间中 Pod 的访问权限。
表 26-4 总结了 RoleBinding subject 列表条目的可能字段值。
表 26-4. RoleBinding subjects 列表中元素的可能类型
| 类型 | API 组 | 命名空间 | 描述 |
|---|---|---|---|
| User | rbac.authorization.k8s.io | N/A | name 是对用户的引用。 |
| Group | rbac.authorization.k8s.io | N/A | name 是用户组的引用。 |
| ServiceAccount | “” | Optional | name 是配置的命名空间中 ServiceAccount 资源的引用。 |
RoleBinding 的另一端指向一个单独的 Role。这个 Role 可以是与 RoleBinding 相同命名空间内的 Role 资源,也可以是集群中多个绑定共享的 ClusterRole 资源。ClusterRoles 的详细描述在 “ClusterRole” 中。
类似于主体列表,Role 引用由 name、kind 和 apiGroup 指定。Table 26-5 展示了 roleRef 字段可能的值。
Table 26-5. RoleBinding 中 roleRef 字段的可能类型
| Kind | API Group | Description |
|---|---|---|
| Role | rbac.authorization.k8s.io | name 是同一命名空间中 Role 的引用。 |
| ClusterRole | rbac.authorization.k8s.io | name 是集群范围内 ClusterRole 的引用。 |
ClusterRole
Kubernetes 中的 ClusterRoles 类似于常规 Roles,但是应用于整个集群而不是特定的命名空间。它们有两个主要用途:
-
保护集群范围的资源,例如 CustomResourceDefinitions 或 StorageClasses。这些资源通常在集群管理员级别管理,并需要额外的访问控制。例如,开发人员可能对这些资源具有读取访问权限,但需要帮助编写访问权限。ClusterRoleBindings 用于授予主体对集群范围资源的访问权限。
-
定义跨命名空间共享的典型 Role。正如我们在 “RoleBinding” 中看到的,RoleBindings 只能引用同一命名空间中定义的 Roles。ClusterRoles 允许您定义一般访问控制角色(例如,对所有资源的只读访问的 “view” 角色),这些角色可以在多个 RoleBindings 中使用。
Example 26-9 展示了一个可以在多个 RoleBindings 中重复使用的 ClusterRole。它与 Role 具有相同的模式,只是忽略了任何 .meta.namespace 字段。
Example 26-9. ClusterRole
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: view-pod 
rules:
- apiGroups: 
- ""
resources:
- pods
verbs:
- get
- list
ClusterRole 的名称,但没有命名空间声明。
允许对所有 Pods 进行读操作的规则。
Figure 26-4 展示了一个单一 ClusterRole 如何在不同命名空间的多个 RoleBindings 中共享。在这个例子中,ClusterRole 允许 ServiceAccount 在 test 命名空间中读取 dev-1 和 dev-2 命名空间中的 Pods。

Figure 26-4. 在多个命名空间中共享 ClusterRole
使用单个 ClusterRole 在多个 RoleBindings 中允许您创建可以轻松重复使用的典型访问控制方案。例如,Table 26-6 包含 Kubernetes 默认提供的一些有用的面向用户的 ClusterRoles 的选择。您可以使用kubectl get clusterroles命令查看 Kubernetes 集群中可用的 ClusterRoles 的完整列表,或参考Kubernetes 文档获取默认 ClusterRoles 的列表。
表 26-6. 标准用户界面 ClusterRoles
| ClusterRole | 用途 |
|---|---|
view |
允许在命名空间中大多数资源上进行读取,但不包括 Role、RoleBinding 和 Secret |
edit |
允许在命名空间中读取和修改大多数资源,但不包括 Role 和 RoleBinding |
admin |
授予对命名空间中所有资源的完全控制权限,包括 Role 和 RoleBinding |
cluster-admin |
授予对所有命名空间资源的完全控制权限,包括整个集群范围的资源 |
有时,您可能需要结合两个 ClusterRoles 中定义的权限。一种方法是创建引用这两个 ClusterRoles 的多个 RoleBinding。然而,还有一种更优雅的方法可以使用聚合来实现这一点。
要使用聚合功能,您可以定义一个具有空rules字段和填充的aggregationRule字段的 ClusterRole,其中包含一组标签选择器。然后,每个其他具有匹配这些选择器的标签的 ClusterRole 定义的规则将被合并并用于填充聚合 ClusterRole 的rules字段。
注意
当您设置aggregationRule字段时,您正在将rules字段的所有权交给 Kubernetes,Kubernetes 将完全管理它。因此,对规则字段的任何手动更改都将被聚合 ClusterRole 中选择的 ClusterRoles 的聚合规则不断覆盖。
此聚合技术允许您通过组合更小、更专注的 ClusterRoles 来动态且优雅地构建大型规则集。
示例 26-10(#ex-accesscontrol-clusterrole-aggregation)显示了默认的view角色如何使用聚合来获取带有rbac.authorization.k8s.io/aggregate-to-view标签的更具体的 ClusterRoles。view角色本身还具有rbac.authorization.k8s.io/aggregate-to-edit标签,该标签由edit角色使用,以包含来自viewClusterRole 的聚合规则。
示例 26-10. 聚合 ClusterRole
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: view
labels:
rbac.authorization.k8s.io/aggregate-to-edit: "true" 
aggregationRule:
clusterRoleSelectors:
- matchLabels:
rbac.authorization.k8s.io/aggregate-to-view: "true" 
rules: [] 
此标签将 ClusterRole 公开为符合包含在edit角色中的资格。
所有匹配此选择器的 ClusterRoles 将被选中用于viewClusterRole。请注意,如果您希望向viewClusterRole 添加额外权限,则无需更改此 ClusterRole 声明—您可以创建具有适当标签的新 ClusterRole。
rules 字段将由 Kubernetes 管理,并填充聚合规则。
此技术允许您通过聚合一组基本的 ClusterRoles 快速组合更专业化的 ClusterRoles。示例 26-10 还演示了如何嵌套聚合以构建权限规则集的继承链。
由于所有用户界面的默认 ClusterRoles 使用了这种聚合技术,您可以通过简单地添加标准 ClusterRoles 的聚合触发标签(例如 view、edit 和 admin)来快速接入自定义资源的权限模型(如 第二十八章,“操作员” 中描述的)。
现在我们已经介绍了如何使用 ClusterRoles 和 RoleBindings 创建灵活和可重用的权限模型,那么谜题的最后一块是使用 ClusterRoleBindings 建立集群范围的访问规则。
ClusterRoleBinding
ClusterRoleBinding 的模式与 RoleBinding 的模式类似,但它忽略了 namespace 字段。在 ClusterRoleBinding 中定义的规则适用于集群中的所有命名空间。
示例 26-11 显示了一个将 ServiceAccount test-sa 与在 示例 26-9 中定义的 ClusterRole view-pod 连接起来的 ClusterRoleBinding。
示例 26-11. ClusterRoleBinding
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: test-sa-crb
subjects: 
- kind: ServiceAccount
name: test-sa
namespace: test
roleRef: 
kind: ClusterRole
name: view-pod
apiGroup: rbac.authorization.k8s.io
连接来自 test 命名空间的 ServiceAccount test-sa。
允许每个命名空间使用 ClusterRole view-pod 的规则。
在 ClusterRole view-pod 中定义的规则适用于集群中的所有命名空间,因此与 ServiceAccount test-sa 关联的任何 Pod 都可以读取每个命名空间中的所有 Pods,如 图 26-5 所示。然而,使用 ClusterRoleBindings 需要谨慎,因为它们在整个集群范围内授予了广泛的权限。因此,建议您仔细考虑是否需要使用 ClusterRoleBinding。
使用 ClusterRoleBinding 可能很方便,因为它会自动向新创建的命名空间授予权限。然而,通常更好地使用每个命名空间的单独 RoleBindings 可以更精细地控制权限。这额外的工作允许您省略特定命名空间(例如 kube-system)的未经授权访问。

图 26-5. 用于读取所有 Pods 的 ClusterRoleBinding
ClusterRoleBindings 应仅用于管理集群范围的资源,如节点、命名空间、自定义资源定义或甚至 ClusterRoleBindings。
这些最终警告结束了我们对 Kubernetes RBAC 世界的探索之旅。这个机制很强大,但理解它有时也很复杂,甚至更难调试。下面的边栏给出了一些帮助您更好理解给定 RBAC 设置的提示。
最后一节将讨论一些关于正确使用 Kubernetes RBAC 的通用提示。
讨论
Kubernetes RBAC 是控制访问 API 资源的强大工具。然而,了解要使用哪些定义对象以及如何组合它们以适应特定的安全设置可能是具有挑战性的。以下是一些指导方针,帮助您在这些决策中导航:
-
如果要保护特定命名空间中的资源,请使用具有与用户或 ServiceAccount 连接的 RoleBinding 的 Role。ServiceAccount 不必在同一命名空间中,这允许您授予来自其他命名空间的 Pods 的访问权限。
-
如果要在多个命名空间中重用相同的访问规则,请使用具有定义这些共享访问规则的 ClusterRole 的 RoleBinding。
-
如果要扩展一个或多个现有的预定义 ClusterRoles,请创建一个新的 ClusterRole,并在其中添加一个
aggregationRule字段,该字段引用您希望扩展的 ClusterRoles,并将权限添加到rules字段中。 -
如果要授予用户或 ServiceAccount 访问所有命名空间中特定类型的所有资源,请使用 ClusterRole 和 ClusterRoleBinding。
-
如果要管理对集群范围资源(如 CustomResourceDefinition)的访问权限,请使用 ClusterRole 和 ClusterRoleBinding。
我们已经看到了如何使用 RBAC 定义细粒度权限并对其进行管理。它可以通过确保应用的权限不留下升级路径的空白来降低风险。另一方面,定义任何广泛开放的权限可能会导致安全升级。让我们通过总结一些通用的 RBAC 建议来结束这一章节:
避免使用通配符权限。
我们建议在组成 Kubernetes 集群中的细粒度访问控制时遵循最小特权原则。为了避免意外操作,在定义 Role 和 ClusterRoles 时避免使用通配符权限。在极少数情况下,使用通配符可能是有道理的(即,为了保护 API 组的所有资源),但建立一个通常的“无通配符”策略,并为合理的例外情况放宽限制是一个良好的做法。
避免使用 cluster-admin ClusterRole。
拥有高权限的 ServiceAccounts 可以让您在任何资源上执行操作,比如修改权限或查看任何命名空间中的 secrets,这可能会导致严重的安全问题。因此,永远不要将 cluster-admin ClusterRole 分配给 Pod。绝对不要。
不要自动挂载 ServiceAccount 令牌。
默认情况下,ServiceAccount 的令牌被挂载到容器的文件系统中,路径为 /var/run/secrets/kubernetes.io/serviceaccount/token。如果这样的 Pod 被 compromise,任何攻击者都可以使用 Pod 关联的 ServiceAccount 的权限与 API 服务器通信。然而,许多应用程序在业务运作中并不需要该令牌。对于这种情况,可以通过将 ServiceAccount 的字段 automountServiceAccountToken 设置为 false 来避免挂载令牌。
Kubernetes 的 RBAC 是一种灵活且强大的方法,用于控制对 Kubernetes API 的访问。因此,即使您的应用程序不直接与 API Server 交互来安装应用程序并将其连接到其他 Kubernetes 服务器,访问控制 也是一种保护应用程序操作的有价值模式。
更多信息
^(1) 详见博文 “暴露的 Kubernetes 集群”.
^(2) 在节点上具有升级权限的攻击者可以 compromise 整个 Kubernetes 集群。
第六部分:高级模式
此类别中的模式涵盖了一些复杂的主题,这些主题不适合其他任何类别。例如控制器或操作者这类模式是永恒的,Kubernetes 本身就是建立在它们之上的。然而,其他一些模式实现仍在不断演变。为了跟上这一变化,我们将保持我们的在线示例更新,并反映这一领域的最新发展。
在接下来的章节中,我们将探讨这些高级模式:
-
第二十七章,“控制器”,对 Kubernetes 本身至关重要,展示了如何通过自定义控制器扩展平台。
-
第二十八章,“操作者”,将控制器与自定义领域特定资源结合起来,以自动化形式封装操作知识。
-
第二十九章,“弹性扩展”,描述了 Kubernetes 如何通过多维度缩放来处理动态负载。
-
第三十章,“镜像构建器”,将构建应用程序镜像的方面移到集群本身。
第二十七章:控制器
控制器主动监视和维护一组 Kubernetes 资源,使其保持在所需状态。Kubernetes 的核心是一组控制器,它们定期监视和协调应用程序的当前状态与声明的目标状态。在本章中,我们将看到如何利用控制器模式来扩展平台以满足我们的需求。
问题
你已经看到 Kubernetes 是一个复杂而全面的平台,它提供了许多开箱即用的功能。然而,它是一个通用的编排平台,不能涵盖所有应用用例。幸运的是,它提供了自然的扩展点,可以在经过验证的 Kubernetes 构建块之上优雅地实现特定用例。
这里主要涉及的主要问题是如何在不改变和破坏 Kubernetes 的情况下扩展它,以及如何利用其能力进行自定义用例的使用。
Kubernetes 的设计基于声明性资源中心 API。那么,什么是声明式?与命令式方法相对,声明式方法不是告诉 Kubernetes 如何行动,而是描述目标状态应该如何展示。例如,当我们扩展一个部署时,我们不会通过告诉 Kubernetes “创建一个新的 Pod” 来主动创建新的 Pod。相反,我们通过 Kubernetes API 修改部署资源的 replicas 属性来设定所需数量。
那么,新的 Pods 是如何创建的呢?这是由控制器在内部完成的。对资源状态进行任何更改(比如修改部署的 replicas 属性值)时,Kubernetes 会创建一个事件并广播给所有感兴趣的监听器。这些监听器可以通过修改、删除或创建新的资源来作出响应,从而创建其他事件,例如 Pod 创建事件。这些事件可能再次被其他控制器接收,执行它们特定的动作。
整个过程也被称为状态协调,其中目标状态(所需副本数)与当前状态(实际运行实例)不同,控制器的任务是协调并再次达到所需的目标状态。从这个角度来看,Kubernetes 本质上是一个分布式状态管理器。你给它一个组件实例的期望状态,它会尽力维持该状态,以应对任何变化。
现在,我们如何在不修改 Kubernetes 代码的情况下,钩入这个协调过程并创建一个适合我们特定需求的控制器?
解决方案
Kubernetes 集成了一组内置控制器,用于管理标准 Kubernetes 资源,如 ReplicaSets、DaemonSets、StatefulSets、Deployments 或 Services。这些控制器作为控制器管理器的一部分运行,控制器管理器作为控制平面节点上的独立进程或 Pod 部署。这些控制器彼此之间并不知道对方。它们运行在无尽的对比循环中,监视它们的资源的实际状态和期望状态,并相应地采取行动,使实际状态接近期望状态。
但是,除了这些开箱即用的控制器外,Kubernetes 的事件驱动架构还允许我们本地插入其他自定义控制器。自定义控制器可以通过对状态变更事件做出响应来增加行为的额外功能,方式与内部控制器相同。控制器的一个共同特征是它们是响应式的,并且会对系统中的事件做出反应,以执行它们的特定操作。在高层次上,这个协调过程包括以下主要步骤:
观察
通过观察 Kubernetes 在观察到资源变化时发出的事件,发现实际状态。
分析
确定与期望状态的差异。
行动
执行操作以将实际状态驱动到期望状态。
例如,ReplicaSet 控制器会监视 ReplicaSet 资源的变化,分析需要运行多少个 Pod,并通过向 API 服务器提交 Pod 定义来执行操作。然后 Kubernetes 后端负责在节点上启动请求的 Pod。
图 27-1 显示一个控制器如何注册自己作为事件监听器,以检测受管资源的变化。它观察当前状态,并通过调用 API 服务器来改变它,以接近目标状态(如果需要)。

图 27-1. 观察-分析-行动循环
控制器是 Kubernetes 控制平面的一部分,很早就清楚它们也允许您通过自定义行为扩展平台。此外,它们已成为扩展平台和启用复杂应用生命周期管理的标准机制。因此,诞生了一代新的更复杂的控制器,称为 操作员。从进化和复杂性的角度来看,我们可以将主动协调组件分为两组:
控制器
一个简单的协调过程,监视和处理标准 Kubernetes 资源。通常,这些控制器增强平台行为并添加新的平台功能。
操作员
一个复杂的协调过程,与 CustomResourceDefinitions (CRDs) 交互,这些 CRDs 是 操作员 模式的核心。通常,这些操作员封装复杂的应用程序领域逻辑,并管理完整的应用程序生命周期。
如前所述,这些分类有助于逐步引入新概念。在这里,我们关注更简单的控制器,在 第二十八章 中介绍 CRD 并逐步构建 操作员 模式。
为了避免多个控制器同时作用于相同的资源,控制器使用在 第十章 中解释的 单例服务 模式。大多数控制器部署方式与 Deployment 类似,但只有一个副本,因为 Kubernetes 在资源级别使用乐观锁定来防止并发问题。最终,控制器只是在后台永久运行的应用程序。
由于 Kubernetes 本身是用 Go 语言编写的,并且用于访问 Kubernetes 的完整客户端库也是用 Go 语言编写的,因此许多控制器也是用 Go 语言编写的。然而,您可以通过向 Kubernetes API Server 发送请求,用任何编程语言编写控制器。稍后我们将看到一个纯 shell 脚本编写的控制器示例,在 示例 27-1 中。
最简单的控制器扩展了 Kubernetes 管理资源的方式。它们操作与 Kubernetes 内部控制器操作标准 Kubernetes 资源相同的标准资源,并执行类似的任务,但对集群的用户是不可见的。控制器评估资源定义并有条件地执行一些操作。尽管它们可以监视并针对资源定义中的任何字段执行操作,但元数据和 ConfigMaps 最适合这个目的。选择存储控制器数据的地方时需要考虑以下几点:
标签
作为资源元数据的一部分,标签可以被任何控制器监视。它们在后端数据库中被索引,并且可以在查询中高效地搜索。当需要类似选择器的功能时(例如,匹配服务或部署的 Pod 时),应使用标签。标签的一个限制是只能使用带有限制的字母数字名称和值。请参阅 Kubernetes 文档,了解标签允许的语法和字符集。
注解
注解是标签的一个很好的替代品。如果值不符合标签值的语法限制,则必须使用注解而不是标签。注解不被索引,因此我们将注解用于控制器查询中不用作键的非标识信息。相比于将任意元数据放入标签,偏好注解的另一个优点是它不会对内部 Kubernetes 性能产生负面影响。
配置映射
有时控制器需要额外信息,这些信息无法很好地放在标签或注释中。在这种情况下,可以使用 ConfigMaps 来保存目标状态定义。这些 ConfigMaps 然后被控制器监视和读取。然而,CRDs 更适合设计定制目标状态规范,并且推荐使用而不是普通的 ConfigMaps。然而,要注册 CRDs,您需要提升的集群级权限。如果您没有这些权限,ConfigMaps 仍然是 CRDs 的最佳替代方案。我们将在 第二十八章,“Operator” 中详细解释 CRDs。
这里有几个相当简单的示例控制器,您可以作为此模式的样本实现进行学习:
jenkins-x/exposecontroller
这个控制器 监视 Service 定义,如果检测到元数据中名为 expose 的注解,控制器会自动为 Service 提供外部访问的 Ingress 对象。当某人移除 Service 时,它还会移除 Ingress 对象。这个项目现在已存档,但仍然是实现简单控制器的良好示例。
stakater/Reloader
这是 一个控制器,它监视 ConfigMap 和 Secret 对象的更改,并执行与它们关联的工作负载的滚动升级,这些工作负载可以是 Deployment、DaemonSet、StatefulSet 和其他工作负载资源。我们可以将此控制器用于那些不能够监视 ConfigMap 并动态更新自身配置的应用程序。尤其是当 Pod 将此 ConfigMap 作为环境变量使用,或者当您的应用程序无法快速可靠地在不重新启动的情况下即时更新自身时。作为概念验证,我们使用简单的 shell 脚本在 示例 27-2 中实现了类似的控制器。
Flatcar Linux Update Operator
这是一个 控制器,当检测到 Node 资源对象上特定注解时,它会重新启动运行在 Flatcar Container Linux 上的 Kubernetes 节点。
现在让我们看一个具体的例子:一个由单个 shell 脚本组成的控制器,它监视 Kubernetes API 上 ConfigMap 资源的更改。如果我们在这样的 ConfigMap 上注释了 k8spatterns.io/podDeleteSelector,则选定的所有 Pod 在 ConfigMap 更改时都会被删除。假设我们使用高阶资源如 Deployment 或 ReplicaSet 支持这些 Pod,则这些 Pod 将重新启动并获取更改后的配置。
例如,下面的 ConfigMap 将由我们的控制器监视更改,并重新启动所有具有标签 app 值为 webapp 的 Pod。示例 27-1 中的 ConfigMap 在我们的 Web 应用程序中用于提供欢迎消息。
示例 27-1. Web 应用程序使用的 ConfigMap
apiVersion: v1
kind: ConfigMap
metadata:
name: webapp-config
annotations:
k8spatterns.io/podDeleteSelector: "app=webapp" 
data:
message: "Welcome to Kubernetes Patterns !"
用作选择器的注解,用于在示例 27-2 中找到要重新启动的应用程序 Pod。
我们的控制器 shell 脚本现在评估此 ConfigMap。您可以在我们的 Git 存储库中找到其完整源代码。简而言之,控制器启动了一个挂起 GET HTTP 请求,以打开一个无限的 HTTP 响应流,以观察 API Server 推送给我们的生命周期事件。这些事件以简单的 JSON 对象形式存在,然后被分析以检测变更的 ConfigMap 是否带有我们的注解。随着事件的到达,控制器通过删除所有与注解值匹配的 Pod 来执行操作。让我们更详细地看看控制器是如何工作的。
此控制器的主要部分是协调循环,它侦听 ConfigMap 生命周期事件,如示例 27-2 所示。
示例 27-2. 控制器脚本
namespace=${WATCH_NAMESPACE:-default} 
base=http://localhost:8001 
ns=namespaces/$namespace
curl -N -s $base/api/v1/${ns}/configmaps?watch=true | \ while read -r event 
do
# ... done
要监视的命名空间(如果未指定,则为default)。
通过在同一 Pod 中运行的代理访问 Kubernetes API。
循环,监听 ConfigMaps 上的事件。
环境变量WATCH_NAMESPACE指定控制器应该监视的 ConfigMap 更新所在的命名空间。我们可以在控制器自身的部署描述符中设置此变量。在我们的示例中,我们使用第十四章,“自我感知”中描述的 Downward API,监视我们部署控制器的命名空间,如示例 27-3 中作为控制器部署的一部分的配置。
示例 27-3. 从当前命名空间中提取的WATCH_NAMESPACE。
env:
- name: WATCH_NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
使用此命名空间,控制器脚本构建到 Kubernetes API 端点的 URL 来监视 ConfigMaps。
注意
注意,在示例 27-2 中的watch=true查询参数中。此参数指示 API Server 不关闭 HTTP 连接,而是立即将事件沿响应通道发送(挂起 GET 或 Comet 是此类技术的其他名称)。该循环读取每个到达的事件作为单个要处理的项目。
正如你所看到的,我们的控制器通过 localhost 联系 Kubernetes API Server。我们不会直接在 Kubernetes API 控制平面节点上部署此脚本,但是脚本中如何使用 localhost 呢?你可能已经猜到,这里另一个模式发挥作用。我们将此脚本与一个大使容器一起部署在 Pod 中,该容器在 localhost 上公开端口 8001 并将其代理到真正的 Kubernetes 服务。有关大使模式的更多详细信息,请参见第十八章。稍后在本章中,我们将详细查看带有此大使的实际 Pod 定义。
当然,这种方式监视事件并不十分健壮。连接随时可能停止,因此应该有一种方法重新启动循环。此外,可能会错过事件,因此生产级控制器不仅应该监视事件,而且不时应该查询 API 服务器以获取整个当前状态,并将其用作新的基础。出于展示模式的考虑,这已经足够好了。
在循环内,执行示例 27-4 中显示的逻辑。
示例 27-4. 控制器对账循环
curl -N -s $base/api/v1/${ns}/configmaps?watch=true | \ while read -r event
do
type=$(echo "$event" | jq -r '.type') 
config_map=$(echo "$event" | jq -r '.object.metadata.name')
annotations=$(echo "$event" | jq -r '.object.metadata.annotations')
if [ "$annotations" != "null" ]; then
selector=$(echo $annotations | \ 
jq -r "\
to_entries |\
.[] |\
select(.key == \"k8spatterns.io/podDeleteSelector\") |\
.value |\
@uri \
")
fi
if [ $type = "MODIFIED" ] && [ -n "$selector" ]; then 
pods=$(curl -s $base/api/v1/${ns}/pods?labelSelector=$selector |\
jq -r .items[].metadata.name)
for pod in $pods; do 
curl -s -X DELETE $base/api/v1/${ns}/pods/$pod
done
fi
done
从事件中提取 ConfigMap 的类型和名称。
提取 ConfigMap 上所有带有k8spatterns.io/podDeleteSelector键的注解。有关此jq表达式的解释,请参见下面的侧边栏。
如果事件指示 ConfigMap 的更新并且我们的注解已附加,则查找所有匹配此标签选择器的 Pod。
删除所有与选择器匹配的 Pod。
首先,脚本提取指定 ConfigMap 操作的事件类型。然后,我们使用jq派生注解。jq是一个从命令行解析 JSON 文档的优秀工具,并且该脚本假设它在运行脚本的容器中可用。
如果 ConfigMap 有注解,我们会检查k8spatterns.io/podDeleteSelector注解,使用更复杂的jq查询。此查询的目的是将注解值转换为可以在下一步 API 查询选项中使用的 Pod 选择器。例如,注解k8spatterns.io/podDeleteSelector: "app=webapp"被转换为app%3Dwebapp,这个转换是通过jq执行的,如果您对此提取方式感兴趣,接下来会进行详细解释。
如果脚本可以提取selector,我们现在可以直接使用它来选择要删除的 Pod。首先,我们查找所有与该选择器匹配的 Pod,然后逐个使用直接的 API 调用进行删除。
当然,这个基于 Shell 脚本的控制器不适用于生产环境(例如,事件循环可能随时停止),但它很好地展示了基本概念,没有过多的样板代码。
剩下的工作是创建资源对象和容器映像。控制器脚本本身存储在一个名为config-watcher-controller的 ConfigMap 中,如果需要的话可以很容易地进行后续编辑。
我们使用 Deployment 来为我们的控制器创建一个包含两个容器的 Pod:
-
一个 Kubernetes API 大使容器,在本地主机的 8001 端口上通过 localhost 公开 Kubernetes API。镜像
k8spatterns/kubeapi-proxy是一个带有本地kubectl安装的 Alpine Linux,并启动了带有正确 CA 和令牌挂载的kubectl proxy。最初的版本 kubectl-proxy 由 Marko Lukša 编写,他在《Kubernetes 实战》中介绍了这个代理。 -
执行刚创建的 ConfigMap 中脚本的主容器。在这里,我们使用一个带有
curl和jq安装的 Alpine 基础镜像。
您可以在示例 Git 仓库 中找到 k8spatterns/kubeapi-proxy 和 k8spatterns/curl-jq 镜像的 Dockerfile。
现在我们已经有了我们 Pod 的镜像,最后一步是通过使用 Deployment 部署控制器。我们可以在 示例 27-5 中看到 Deployment 的主要部分(完整版本可在我们的示例仓库中找到)。
示例 27-5. 控制器部署
apiVersion: apps/v1
kind: Deployment
# ....
spec:
template:
# ...
spec:
serviceAccountName: config-watcher-controller 
containers:
- name: kubeapi-proxy 
image: k8spatterns/kubeapi-proxy
- name: config-watcher 
image: k8spatterns/curl-jq
# ...
command: 
- "sh"
- "/watcher/config-watcher-controller.sh"
volumeMounts: 
- mountPath: "/watcher"
name: config-watcher-controller
volumes:
- name: config-watcher-controller 
configMap:
name: config-watcher-controller
具有适当权限以监视事件和重新启动 Pod 的 ServiceAccount。
用于将本地主机代理到 Kubeserver API 的大使容器。
包含所有工具并挂载控制器脚本的主容器。
启动命令调用控制器脚本。
映射到保存我们脚本的 ConfigMap 的卷。
将 ConfigMap 支持的卷挂载到主 Pod 中。
正如您所看到的,我们从先前创建的 ConfigMap 中挂载 config-watcher-controller-script 并直接将其用作主容器的启动命令。为简单起见,我们省略了任何活跃性和就绪性检查以及资源限制声明。此外,我们需要一个 ServiceAccount config-watcher-controller,允许其监视 ConfigMaps。有关完整的安全设置,请参阅示例仓库。
让我们看看控制器的工作情况。为此,我们使用了一个简单的 Web 服务器,它将环境变量的值作为唯一的内容进行提供。基础镜像使用纯粹的 nc(netcat)来提供内容。您可以在示例仓库中找到此镜像的 Dockerfile。我们使用 ConfigMap 和 Deployment 部署 HTTP 服务器,如 示例 27-6 中所示。
示例 27-6. 带有 Deployment 和 ConfigMap 的示例 Web 应用程序
apiVersion: v1
kind: ConfigMap 
metadata:
name: webapp-config
annotations:
k8spatterns.io/podDeleteSelector: "app=webapp" 
data:
message: "Welcome to Kubernetes Patterns !" 
---
apiVersion: apps/v1
kind: Deployment 
# ...
spec:
# ...
template:
spec:
containers:
- name: app
image: k8spatterns/mini-http-server 
ports:
- containerPort: 8080
env:
- name: MESSAGE 
valueFrom:
configMapKeyRef:
name: webapp-config
key: message
用于保存提供数据的 ConfigMap。
触发重新启动 Web 应用程序 Pod 的注解。
在 HTTP 响应中用于 Web 应用程序的消息。
Web 应用程序的 Deployment。
使用 netcat 进行 HTTP 服务的简化镜像。
用作 HTTP 响应正文并从所监视的 ConfigMap 中获取的环境变量。
这结束了我们在纯 shell 脚本中实现的 ConfigMap 控制器的示例。尽管这可能是本书中最复杂的示例,但它也显示出编写基本控制器并不需要太多工作。
显然,对于真实场景,你会使用提供更好错误处理能力和其他高级特性的真实编程语言来编写这种类型的控制器。
讨论
总结一下,控制器是一个主动的协调过程,监视感兴趣对象的世界期望状态和实际状态。然后,它发送指令尝试改变世界的当前状态,使其更接近期望的状态。Kubernetes 使用这种机制来管理其内部控制器,并且你也可以通过自定义控制器重用相同的机制。我们演示了编写自定义控制器所涉及的内容,以及它如何功能和扩展 Kubernetes 平台。
控制器之所以可能,是因为 Kubernetes 架构具有高度模块化和事件驱动的特性。这种架构自然地导致控制器作为扩展点采用解耦和异步的方法。这里的重大好处在于,我们在 Kubernetes 本身与任何扩展之间建立了精确的技术边界。然而,控制器异步性的一个问题是,由于事件流不总是直接的,它们通常很难进行调试。因此,你无法轻松地在控制器中设置断点来停止一切以检查特定情况。
在第二十八章中,你将了解相关的操作员模式,它基于这种控制器模式,并提供了一种更加灵活的方式来配置操作。
更多信息
第二十八章:操作者
操作者是一个控制器,使用 CRD 将特定应用程序的操作知识封装为算法化和自动化形式。操作者 模式允许我们扩展前一章中的 控制器 模式,以提供更大的灵活性和表达能力。
问题
您在 Chapter 27, “Controller” 中学到了如何以简单和解耦的方式扩展 Kubernetes 平台。然而,对于扩展用例,普通的自定义控制器并不够强大,因为它们仅限于监视和管理 Kubernetes 内部资源。此外,有时我们希望向 Kubernetes 平台添加新概念,这需要额外的领域对象。例如,假设我们选择 Prometheus 作为监控解决方案,并希望以明确定义的方式将其添加为 Kubernetes 的监控设施。如果我们能够有一个 Prometheus 资源来描述我们的监控设置及所有部署细节,类似于我们定义其他 Kubernetes 资源的方式,那不是很棒吗?此外,我们是否可以有与我们需要监控的服务相关的资源(例如,具有标签选择器)?
这些情况恰恰是 CRD 资源非常有帮助的用例。它们通过将自定义资源添加到您的 Kubernetes 集群中,并像使用本地资源一样使用它们,扩展了 Kubernetes API。自定义资源与操作这些资源的控制器一起形成 操作者 模式。
这句Jimmy Zelinskie 的引用可能最能描述操作者的特征:
操作者是一个 Kubernetes 控制器,它理解两个领域:Kubernetes 和其他领域。通过结合这两个领域的知识,它可以自动化通常需要理解这两个领域的人类操作者来执行的任务。
解决方案
正如您在 Chapter 27, “Controller” 中看到的那样,我们可以有效地对默认 Kubernetes 资源的状态变化做出反应。现在您了解了 操作者 模式的一半,让我们看看另一半——使用 CRD 资源在 Kubernetes 上表示自定义资源。
自定义资源定义(CRD)
通过 CRD,我们可以扩展 Kubernetes 来管理我们在 Kubernetes 平台上的领域概念。自定义资源像其他资源一样通过 Kubernetes API 进行管理,并最终存储在后端存储 etcd 中。
前述场景实际上是由 CoreOS Prometheus 操作者利用这些新的自定义资源实现的,以实现将 Prometheus 无缝集成到 Kubernetes 中。Prometheus CRD 在 Example 28-1 中定义,并且解释了 CRD 的大多数可用字段。
Example 28-1. CustomResourceDefinition
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: prometheuses.monitoring.coreos.com 
spec:
group: monitoring.coreos.com 
names:
kind: Prometheus 
plural: prometheuses 
scope: Namespaced 
versions: 
- name: v1 
storage: true 
served: true 
schema:
openAPIV3Schema: .... 
名称。
属于的 API 组。
Kind 用于标识此资源的实例。
创建复数形式的命名规则,用于指定这些对象的列表。
范围 —— 资源是可以在整个集群中创建还是仅限于命名空间。
此 CRD 可用的版本。
支持版本的名称。
必须有一个版本作为后端存储定义中使用的存储版本。
此版本是否通过 REST API 提供。
用于验证的 OpenAPI V3 模式(此处未显示)。
还可以指定一个 OpenAPI V3 模式,以允许 Kubernetes 验证自定义资源。对于简单的用例,可以省略此模式,但对于生产级别的 CRD,应提供此模式,以便早期检测配置错误。
此外,Kubernetes 允许我们通过 spec 字段的 subresources 指定 CRD 的两种可能子资源:^(1)
缩放
通过此属性,CRD 可以指定如何管理其副本数量。此字段可用于声明 JSON 路径,指定此自定义资源的期望副本数的路径:保存实际运行副本数量的属性路径,以及一个可选的标签选择器路径,该选择器可用于查找自定义资源实例的副本。通常情况下,此标签选择器是可选的,但如果要将此自定义资源与 第二十九章,“弹性扩展” 中解释的 HorizontalPodAutoscaler 一起使用,则是必需的。
状态
设置此属性后,将可以使用新的 API 调用仅更新资源的 status 字段。此 API 调用可以单独进行安全保护,并允许操作员反映资源的实际状态,这可能与 spec 字段中声明的状态不同。当整体更新自定义资源时,任何发送的 status 部分都将被忽略,这与标准 Kubernetes 资源的情况类似。
示例 28-2 展示了一个潜在的子资源路径,也用于常规 Pod。
示例 28-2. 用于 CustomResourceDefinition 的子资源定义
kind: CustomResourceDefinition
# ...
spec:
subresources:
status: {}
scale:
specReplicasPath: .spec.replicas 
statusReplicasPath: .status.replicas 
labelSelectorPath: .status.labelSelector 
JSON 路径到声明副本的数量。
JSON 路径到活动副本的数量。
JSON 路径到查询活动副本数量的标签选择器。
一旦定义了 CRD,我们可以轻松创建这样的资源,就像 示例 28-3 中所示。
示例 28-3. Prometheus 自定义资源
apiVersion: monitoring.coreos.com/v1
kind: Prometheus
metadata:
name: prometheus
spec:
serviceMonitorSelector:
matchLabels:
team: frontend
resources:
requests:
memory: 400Mi
metadata 部分具有与任何其他 Kubernetes 资源相同的格式和验证规则。 spec 包含特定于 CRD 的内容,并且 Kubernetes 根据 CRD 中给定的验证规则进行验证。
单独的自定义资源没有太大的用处,除非有一个主动组件来对其进行操作。为了赋予它们意义,我们再次需要我们众所周知的控制器,它监视这些资源的生命周期,并根据资源中的声明采取行动。
控制器和操作员分类
在我们深入编写我们的操作员之前,让我们先看看控制器、操作员,尤其是 CRD 的几种分类。根据操作员的操作,广义上,这些分类如下:
安装 CRD
用于在 Kubernetes 平台上安装和操作应用程序。典型的例子是 Prometheus CRD,我们可以用它来安装和管理 Prometheus 本身。
应用程序 CRD
相比之下,这些用于表示特定于应用程序领域的概念。这种类型的 CRD 允许应用程序与 Kubernetes 进行深度集成,涉及将 Kubernetes 与应用程序特定的领域行为结合起来。例如,ServiceMonitor CRD 被 Prometheus 操作员用来注册特定的 Kubernetes 服务,以便 Prometheus 服务器进行抓取。Prometheus 操作员负责相应地调整 Prometheus 服务器的配置。
注意
注意,在这种情况下,Prometheus 操作员可以作用于不同类型的 CRD。这两类 CRD 之间的边界不太明确。
在我们对控制器和操作员的分类中,操作员是使用 CRD 的一种控制器。^(2) 然而,即使这种区分也有些模糊,因为它们之间存在变化。
一个例子是一个控制器,它将 ConfigMap 用作 CRD 的一种替代。这种方法在默认的 Kubernetes 资源不足以满足需求,但创建 CRD 又不可行的场景中是有意义的。在这种情况下,ConfigMap 是一个很好的中间地带,允许在 ConfigMap 的内容中封装领域逻辑。使用普通 ConfigMap 的一个优点是,你无需像注册 CRD 那样拥有集群管理员权限。在某些集群设置中,你可能根本无法注册这样的 CRD(例如在像 OpenShift Online 这样的公共集群上运行时)。
然而,即使你用一个普通的 ConfigMap 替换 CRD,并将其用作你的特定领域配置时,你仍然可以使用“观察-分析-操作”的概念。缺点是,你不会像对于 CRD 那样得到诸如 kubectl get 这样的重要工具支持;你在 API 服务器级别上没有验证,也不支持 API 版本控制。此外,对于 ConfigMap 的 status 字段建模,你没有太多的影响力,而对于 CRD,你可以自由定义自己希望的状态模型。
CRD 的另一个优点是,您可以基于 CRD 的类型拥有精细的权限模型,可以单独调整,正如在第二十六章,“访问控制”中所解释的那样。当您的所有域配置封装在 ConfigMaps 中时,这种 RBAC 安全性是不可能的,因为一个命名空间中的所有 ConfigMaps 共享相同的权限设置。
从实现的角度来看,重要的是我们是将控制器实现为限制其使用到普通的 Kubernetes 对象,还是拥有控制器管理的自定义资源。在前一种情况下,我们已经在选择的 Kubernetes 客户端库中拥有了所有类型。对于 CRD 情况,我们不能直接获得类型信息,可以选择使用无模式的方法来管理 CRD 资源,或者根据 CRD 定义中包含的 OpenAPI 模式自定义定义自定义类型。支持有类型 CRD 的程度因客户端库和使用的框架而异。
图 28-1 展示了我们从更简单的资源定义选项开始分类的控制器和操作者,到更高级的边界,其中控制器和操作者之间的边界是使用自定义资源。

图 28-1. 控制器和操作者的光谱
对于运算符来说,甚至有更高级的 Kubernetes 扩展钩子选项。当 Kubernetes 管理的 CRD 无法充分表示问题域时,您可以通过其自己的聚合层扩展 Kubernetes API。我们可以将自定义实现的APIService资源添加为 Kubernetes API 的新 URL 路径。
要连接由 Pod 支持的 Service 与APIService,您可以使用类似于示例 28-4 中显示的资源。
示例 28-4. 使用自定义 APIService 进行 API 聚合
apiVersion: apiregistration.k8s.io/v1beta1
kind: APIService
metadata:
name: v1alpha1.sample-api.k8spatterns.io
spec:
group: sample-api.k8spattterns.io
service:
name: custom-api-server
version: v1alpha1
除了服务和 Pod 的实现之外,我们还需要一些额外的安全配置来设置 Pod 运行的 ServiceAccount。
设置完成后,每个对 API 服务器https://<api server ip>/apis/sample-api.k8spatterns.io/v1alpha1/namespaces/<ns>/...的请求都将定向到我们自定义的服务实现。由此自定义服务实现处理这些请求,包括持久化通过此 API 管理的资源。这种方法与前面的 CRD 情况不同,其中 Kubernetes 本身完全管理自定义资源。
通过自定义 API 服务器,您可以获得更多的自由度,这使您能够超越观察资源生命周期事件。另一方面,您还必须实现更多的逻辑,因此对于典型的用例来说,处理普通 CRD 的操作者通常已经足够好。
API 服务器功能的详细探索超出了本章的范围。官方文档以及完整的sample-apiserver有更详细的信息。此外,您可以使用apiserver-builder库,它有助于实现 API 服务器聚合。
现在,让我们看看如何使用 CRDs 开发和部署操作员。
操作员开发和部署
有几个工具包和框架可用于开发操作员。在创建操作员方面,三个主要项目如下:
-
Kubebuilder 由 Kubernetes 自身的 SIG API Machinery 开发
-
操作员框架,一个 CNCF 项目
-
来自 Google Cloud Platform 的 Metacontroller
我们简要介绍每个子组件,为您开发和维护自己的操作员提供一个良好的起点。
Kubebuilder
Kubebuilder,由 SIG API Machinery 的一个项目,^(4) 是通过 CustomResourceDefinitions 创建 Kubernetes API 的框架和库。
它配备了出色的文档,还涵盖了编程 Kubernetes 的一般方面。Kubebuilder 的重点是通过在 Kubernetes API 顶部添加更高级别的抽象来创建基于 Golang 的操作员,以消除部分开销。它还提供新项目的脚手架支持,并支持单个操作员可以监视的多个 CRD。其他项目可以将 Kubebuilder 作为库消费,它还提供了插件架构以扩展对 Golang 以外的语言和平台的支持。对于针对 Kubernetes API 的编程,Kubebuilder 是一个很好的起点。
操作员框架
操作员框架为开发操作员提供了广泛支持。它提供了几个子组件:
-
操作员 SDK提供了访问 Kubernetes 集群的高级 API 和启动操作员项目的脚手架。
-
操作员生命周期管理器管理操作员及其 CRD 的发布和更新。您可以将其视为一种“操作员操作员”。
-
操作员中心是一个公开可用的操作员目录,专门用于分享社区构建的操作员。
注意
在 2019 年的第一版书籍中,我们提到 Kubebuilder 和 Operator-SDK 的高度特性重叠,并推测这两个项目最终可能会合并。事实证明,社区选择了一种不同的策略:所有重叠部分已移至 Kubebuilder,而 Operator-SDK 现在将 Kubebuilder 作为依赖项使用。这一举措是社区驱动的开源项目的力量和自我修复效果的良好例子。关于 Kubebuilder 和 Operator-SDK 之间关系的文章提供了更多信息,可参阅 文章 “Kubebuilder 和 Operator-SDK 之间的区别是什么?”。Operator-SDK 提供了开发和维护 Kubernetes 运算符所需的一切。它建立在 Kubebuilder 之上,直接使用 Kubebuilder 进行 Golang 编写的运算符的脚手架和管理。此外,它还利用 Kubebuilder 的插件系统,用于基于其他技术创建运算符。截至 2023 年,Operator-SDK 还提供用于基于 Ansible Playbook 或 Helm Charts 以及使用 Quarkus 运行时的 Java 运算符的插件。在脚手架项目时,SDK 还添加了与 Operator Lifecycle Manager 和 Operator Hub 集成的适当钩子。
运算符生命周期管理器(OLM)在使用运算符时提供了宝贵的帮助。CRD 的一个问题是,这些资源只能在整个集群范围内注册,并且需要集群管理员权限。虽然普通的 Kubernetes 用户通常可以管理他们被授予访问权限的所有命名空间的所有方面,但他们不能仅仅通过与集群管理员的交互来使用运算符。
为了简化这种交互,OLM 是在后台以服务账号运行的集群服务,具有安装 CRD 权限。专用的 CRD 名为 ClusterServiceVersion(CSV)与 OLM 注册,并允许我们指定部署运算符及其关联的 CRD 定义。一旦创建了这样的 CSV,OLM 的一部分就会等待该 CRD 及其所有依赖的 CRD 注册完毕。如果情况属实,OLM 将部署 CSV 中指定的运算符。然后,OLM 的另一部分可用于代表非特权用户注册这些 CRD。这种方法是允许普通集群用户安装其运算符的一种优雅方式。
运算符可以轻松发布到 Operator Hub。Operator Hub 可以方便地发现和安装运算符。从运算符的 CSV 中提取的类似元数据的名称、图标、描述等内容,将在友好的 Web UI 中呈现。Operator Hub 还引入了 channels 的概念,允许您提供不同的流(如“稳定版”或“Alpha 版”),用户可以订阅以获取各种成熟度级别的自动更新。
Metacontroller
Metacontroller 与其他两个操作器构建框架非常不同,因为它通过扩展 Kubernetes 的 API 来封装编写自定义控制器的常见部分。它的工作方式类似于 Kubernetes 控制器管理器,通过运行多个控制器来动态定义,而非硬编码,这些控制器是通过 Metacontroller 特定的 CRD 进行定义的。换句话说,它是一个委托控制器,调用服务提供实际控制器逻辑。
另一种描述 Metacontroller 的方式是作为声明性行为。虽然 CRD 允许我们在 Kubernetes API 中存储新类型,但 Metacontroller 使得定义标准或自定义资源的行为变得容易。
当我们通过 Metacontroller 定义控制器时,我们必须提供一个仅包含特定于我们控制器的业务逻辑的函数。Metacontroller 处理与 Kubernetes API 的所有交互,代表我们运行协调循环,并通过 Webhook 调用我们的函数。Webhook 会以定义良好的负载调用,描述 CRD 事件。当函数返回值时,我们返回应创建(或删除)的 Kubernetes 资源的定义,代表我们的控制器函数。
这种委托允许我们在任何能理解 HTTP 和 JSON 的语言中编写函数,而且不依赖于 Kubernetes API 或其客户端库。这些函数可以托管在 Kubernetes 上,也可以托管在 Functions-as-a-Service 提供程序上,或者其他地方。
在这里我们无法详细展开,但如果您的使用情况涉及通过简单的自动化或编排扩展和定制 Kubernetes,并且不需要任何额外的功能,则应该看看 Metacontroller,特别是当您希望使用 Go 以外的语言实现业务逻辑时。一些控制器示例将演示如何仅使用 Metacontroller 实现 StatefulSet、Blue-Green 部署、Indexed Job 和 Service per Pod。
示例
让我们看一个具体的操作器示例。我们在 Chapter 27, “Controller” 中扩展我们的示例,并引入了一种名为 ConfigWatcher 的 CRD 类型。此 CRD 的实例然后指定要监视的 ConfigMap 的引用,并指定如果此 ConfigMap 更改时应重启哪些 Pod。通过这种方法,我们消除了 ConfigMap 对 Pod 的依赖,因为我们不必修改 ConfigMap 本身以添加触发注解。此外,在控制器示例中,我们的简单基于注解的方法也可以将单个 ConfigMap 连接到单个应用程序。使用 CRD,可以实现 ConfigMap 和 Pod 的任意组合。
Example 28-5 中展示了 ConfigWatcher 自定义资源。
Example 28-5. 简单的 ConfigWatcher 资源
apiVersion: k8spatterns.io/v1
kind: ConfigWatcher
metadata:
name: webapp-config-watcher
spec:
configMap: webapp-config 
podSelector: 
app: webapp
参考要监视的 ConfigMap。
使用标签选择器确定要重启的 Pod。
在这个定义中,属性configMap引用要监视的ConfigMap的名称。字段podSelector是一组标签及其值,用于识别要重启的 Pod。
我们使用 CRD 定义这个自定义资源的类型(显示在 Example 28-6 中)。
Example 28-6. ConfigWatcher CRD
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: configwatchers.k8spatterns.io
spec:
scope: Namespaced 
group: k8spatterns.io 
names:
kind: ConfigWatcher 
singular: configwatcher 
plural: configwatchers
versions:
- name: v1 
storage: true
served: true
schema:
openAPIV3Schema: 
type: object
properties:
configMap:
type: string
description: "Name of the ConfigMap"
podSelector:
type: object
description: "Label selector for Pods"
additionalProperties:
type: string
连接到一个命名空间。
专用 API 组。
此 CRD 的唯一类型。
资源的标签如在kubectl等工具中使用。
初始版本。
这个 CRD 的 OpenAPI V3 模式规范。
为了使我们的操作员能够管理此类型的自定义资源,我们需要将一个带有适当权限的 ServiceAccount 附加到操作员的 Deployment 上。为此任务,我们引入了一个专用角色,稍后在 RoleBinding 中使用它将其附加到 ServiceAccount 中的 Example 28-7 中。我们在 Chapter 26, “Access Control”中更详细地解释了 ServiceAccounts、Roles 和 RoleBindings 的概念和用法。现在,只需知道 Example 28-6 中的角色定义授予对任何 ConfigWatcher 资源实例的所有 API 操作权限即可。
Example 28-7. 角色定义允许访问自定义资源
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: config-watcher-crd
rules:
- apiGroups:
- k8spatterns.io
resources:
- configwatchers
- configwatchers/finalizers
verbs: [ get, list, create, update, delete, deletecollection, watch ]
有了这些 CRD,我们现在可以像 Example 28-5 中那样定义自定义资源。
要理解这些资源,我们必须实现一个控制器,评估这些资源并在 ConfigMap 更改时触发 Pod 重启。
我们在这里扩展了 Example 27-2 中的控制器脚本,并调整了控制器脚本中的事件循环。
在 ConfigMap 更新的情况下,我们不再检查特定注解,而是查询所有 ConfigWatcher 类型的资源,并检查修改后的 ConfigMap 是否包含作为configMap值的资源。Example 28-8 展示了协调循环。有关完整示例,请参阅我们的 Git 仓库,其中还包括安装此操作员的详细说明。
Example 28-8. WatchConfig 控制器协调循环
curl -Ns $base/api/v1/${ns}/configmaps?watch=true | \ 
while read -r event
do
type=$(echo "$event" | jq -r '.type')
if [ $type = "MODIFIED" ]; then 
watch_url="$base/apis/k8spatterns.io/v1/${ns}/configwatchers"
config_map=$(echo "$event" | jq -r '.object.metadata.name')
watcher_list=$(curl -s $watch_url | jq -r '.items[]') 
watchers=$(echo $watcher_list | \ 
jq -r "select(.spec.configMap == \"$config_map\") | .metadata.name")
for watcher in watchers; do 
label_selector=$(extract_label_selector $watcher)
delete_pods_with_selector "$label_selector"
done
fi
done
启动一个观察流以监视给定命名空间的 ConfigMap 更改。
仅检查MODIFIED事件。
获取所有安装的 ConfigWatcher 自定义资源的列表。
从列表中提取所有引用此 ConfigMap 的 ConfigWatcher 元素。
对于每个找到的 ConfigWatcher,通过选择器删除配置的 Pod。这里为了清晰起见省略了计算标签选择器和删除 Pod 的逻辑。请参考我们的 Git 仓库中的示例代码进行完整实现。
至于控制器示例,这个控制器可以通过我们示例 Git 仓库提供的样例 Web 应用程序进行测试。与此 Deployment 的唯一区别是,我们使用未注释的 ConfigMap 作为应用程序配置。
尽管我们的运算符功能相当完善,但很明显,我们基于 shell 脚本的运算符仍然相当简单,不涵盖边缘或错误情况。你可以在实际应用中找到更多有趣的生产级示例。
在 Operator Hub 找到真实运算符的标准位置。这个目录中的运算符都基于本章涵盖的概念。我们已经看到 Prometheus 运算符如何管理 Prometheus 的安装。另一个基于 Golang 的运算符是 etcd 运算符,用于管理 etcd 键值存储并自动化操作任务,如数据库的备份和恢复。
如果你正在寻找用 Java 编程语言编写的运算符,Strimzi Operator 是一个管理像 Apache Kafka 这样复杂消息系统的运算符的绝佳示例。另一个用于 Java 的运算符的好起点是 Operator-SDK 的 Java Operator Plugin。截至 2023 年,它仍然是一个年轻的倡议;了解如何创建基于 Java 的运算符的最佳入口是解释创建完全可工作运算符的 教程。
讨论
虽然我们已经学习了如何扩展 Kubernetes 平台,但运算符并不是万能的解决方案。在使用运算符之前,你应该仔细查看你的使用情况,确定它是否适合 Kubernetes 的范式。
在许多情况下,一个使用标准资源工作的普通控制器就足够了。这种方法的优势在于注册 CRD 不需要任何集群管理员权限,但在安全性和验证方面存在一些限制。
运算符非常适合建模与声明式 Kubernetes 资源处理方式紧密配合的自定义领域逻辑,具有响应式控制器。
具体来说,考虑在你的应用程序域中使用带有 CRDs 的运算符的任何以下情况:
-
你希望与已经存在的 Kubernetes 工具集成紧密,例如
kubectl。 -
你正在进行一个全新项目,可以从头开始设计应用程序。
-
你受益于 Kubernetes 概念,如资源路径、API 组、API 版本控制,特别是命名空间。
-
你希望为访问 API 提供良好的客户端支持,包括监视、身份验证、基于角色的授权以及元数据的选择器。
如果您的自定义用例符合这些标准,但需要更灵活地实现和持久化自定义资源,考虑使用自定义 API 服务器。但是,您也不应将 Kubernetes 扩展点视为解决所有问题的灵丹妙药。
如果您的用例不是声明式的,如果要管理的数据不适合 Kubernetes 资源模型,或者您不需要与平台紧密集成,可能更适合编写独立的 API 并使用经典的 Service 或 Ingress 对象进行公开。
Kubernetes 文档本身还有一个章节,建议何时使用控制器、运算符、API 聚合或自定义 API 实现。
更多信息
^(1) Kubernetes 子资源是提供资源类型内进一步功能的额外 API 端点。
^(2) 是-一个强调运算符和控制器之间的继承关系,即运算符具有控制器的所有特性加上更多一点。
^(3) 但是,在设计您的 CRD 时,您应该注意常见的API 约定,特别是 status 和其他字段。遵循常见的社区约定可以使人们和工具更容易读取您的新 API 对象。
^(4) 特别兴趣小组(SIGs)是 Kubernetes 社区组织特性领域的方式。您可以在 Kubernetes 社区网站找到当前 SIGs 的列表。
第二十九章:弹性伸缩
弹性伸缩模式涵盖了多维度的应用扩展:通过调整 Pod 副本数量进行水平扩展,通过调整 Pod 的资源需求进行垂直扩展,以及通过改变集群节点数量来扩展集群本身。虽然所有这些操作都可以手动执行,但在本章中,我们将探讨 Kubernetes 如何根据负载自动执行扩展。
问题
Kubernetes 自动化地编排和管理由大量不可变容器组成的分布式应用,通过维护其声明式表达的期望状态。然而,由于许多工作负载的季节性变化,通常随时间变化,确定期望状态应如何看起来并不容易。准确地确定一个容器需要多少资源以及一个服务在某个特定时间需要多少副本来满足服务级别协议,需要时间和精力。幸运的是,Kubernetes 可以轻松地修改容器的资源、服务的期望副本,或者集群中节点的数量。这些变化可以手动进行,或者根据特定规则,在完全自动化的方式下执行。
Kubernetes 不仅可以保持固定的 Pod 和集群设置,还可以监视外部负载和与容量相关的事件,分析当前状态,并根据期望性能自动扩展。这种观察是 Kubernetes 根据实际使用度量而非预期因素来适应和获取反脆弱特性的一种方式。让我们探索可以实现这种行为的不同方式,以及如何结合各种扩展方法以获得更好的体验。
解决方案
扩展任何应用有两种主要方法:水平和垂直。在 Kubernetes 中,水平扩展意味着创建更多 Pod 的副本。垂直扩展则意味着向由 Pod 管理的运行容器提供更多资源。尽管在纸面上看起来很简单,但在共享云平台上创建一个适合自动扩展的应用配置,同时不影响其他服务和集群本身,需要进行大量的试验和错误。作为一直以来的做法,Kubernetes 提供了各种功能和技术来找到我们应用的最佳设置,我们在这里简要探讨一下。
手动水平扩展
手动扩展方法,顾名思义,基于操作人员向 Kubernetes 发出命令。在缺少自动扩展或用于逐步发现和调整应用程序在长时间内与慢变化负载匹配的最佳配置时,可以使用这种方法。手动方法的一个优势是它还允许预见性而不仅仅是反应性的变化:了解季节性和预期的应用负载,您可以提前扩展它,而不是通过自动扩展来对已经增加的负载做出反应。我们可以以两种方式进行手动扩展。
命令式扩展
控制器如 ReplicaSet 负责确保始终运行特定数量的 Pod 实例。因此,扩展 Pod 就像简单地更改所需副本数量一样简单。假设存在名为random-generator的 Deployment,可以通过一条命令将其扩展到四个实例,如示例 29-1 所示。
示例 29-1. 在命令行上扩展 Deployment 的副本
kubectl scale random-generator --replicas=4
在这种改变后,ReplicaSet 可能会创建额外的 Pod 以进行扩展,或者如果 Pod 多于期望值,则删除它们以进行缩减。
声明性扩展
虽然使用 scale 命令非常简单,适用于对紧急情况做出快速反应,但它不会在集群外保留此配置。通常,所有 Kubernetes 应用程序的资源定义都将存储在包含副本数的源控制系统中。从其原始定义重新创建 ReplicaSet 将使副本数更改为其先前的数目。为了避免这种配置漂移并引入用于反向传播更改的操作流程,最好是在 ReplicaSet 或其他定义中以声明性方式更改所需的副本数,并将更改应用于 Kubernetes,如示例 29-2 所示。
示例 29-2. 使用 Deployment 来声明性地设置副本数量
kubectl apply -f random-generator-deployment.yaml
我们可以扩展管理多个 Pod 的资源,如 ReplicaSets、Deployments 和 StatefulSets。请注意在扩展带有持久存储的 StatefulSet 时的非对称行为。正如第十二章,“有状态服务”中描述的那样,如果 StatefulSet 具有.spec.volumeClaimTemplates元素,它将在扩展时创建 PVC,但在缩减时不会删除它们,以保护存储免受删除。
另一个可以扩展但遵循不同命名约定的 Kubernetes 资源是 Job 资源,我们在第七章,“批处理作业”中描述过。通过更改.spec.parallelism字段而不是.spec.replicas,可以扩展作业以同时执行多个相同 Pod 的实例。然而,语义效果是相同的:增加处理单元,这些单元作为单个逻辑单元。
注意
用于描述资源字段的是 JSON 路径表示法。例如,.spec.replicas 指向资源的 spec 部分的 replicas 字段。
无论是命令式还是声明式的手动缩放样式,都期望人类观察或预期应用程序负载的变化,做出扩展决策,并将其应用到集群中。它们具有相同的效果,但不适合经常变化且需要持续适应的动态工作负载模式。接下来,让我们看看如何自动化缩放决策本身。
水平 Pod 自动缩放
许多工作负载具有动态的特性,随时间变化,这使得固定的扩展配置变得困难。但是,像 Kubernetes 这样的云原生技术使您能够创建适应不断变化负载的应用程序。Kubernetes 中的自动缩放允许我们定义一个不固定但确保足够处理不同负载的应用程序容量。实现这种行为的最简单方法是使用 HorizontalPodAutoscaler(HPA)来水平扩展 Pod 的数量。HPA 是 Kubernetes 的一个固有部分,不需要任何额外的安装步骤。HPA 的一个重要限制是它不能将 Pod 缩减到零,以确保不使用已部署工作负载时不会消耗任何资源。幸运的是,Kubernetes 的附加组件提供了零缩放功能,并将 Kubernetes 转变为真正的无服务器平台。Knative 和 KEDA 是此类 Kubernetes 扩展中最显著的两个。我们将在“Knative”和“KEDA”中详细讨论它们,但首先让我们看看 Kubernetes 如何提供即插即用的水平自动缩放功能。
Kubernetes HorizontalPodAutoscaler
通过示例来最好地解释 HPA。可以使用 示例 29-3 中的命令为 random-generator 部署创建 HPA。为了使 HPA 生效,重要的是 Deployment 声明 .spec.resources.requests 作为 CPU 的限制,如 第二章,“可预测的需求” 中所述。另一个要求是启用指标服务器,这是资源使用数据的集群级聚合器。
示例 29-3. 在命令行上创建 HPA 定义
kubectl autoscale deployment random-generator --cpu-percent=50 --min=1 --max=5
上述命令将创建如示例 29-4 所示的 HPA 定义。
示例 29-4. HPA 定义
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: random-generator
spec:
minReplicas: 1 
maxReplicas: 5 
scaleTargetRef: 
apiVersion: apps/v1
kind: Deployment
name: random-generator
metrics:
- resource:
name: cpu
target:
averageUtilization: 50 
type: Utilization
type: Resource
应始终运行的最小 Pod 数量。
HPA 可以扩展的最大 Pod 数量。
用于关联此 HPA 的对象引用。
期望的 CPU 使用率作为 Pod 请求的 CPU 资源的百分比。例如,当 Pod 的 .spec.resources.requests.cpu 为 200m 时,如果平均使用了超过 100m 的 CPU(= 50%),则会发生扩展。
这个定义指示 HPA 控制器保持一个到五个 Pod 实例,以保持 Pod CPU 使用率在 Pod 的 .spec.resources.requests 声明中指定的 CPU 资源限制的大约 50%。虽然可以将这样的 HPA 应用于支持 scale 子资源的任何资源,如 Deployments、ReplicaSets 和 StatefulSets,但必须考虑副作用。在更新期间,Deployments 会创建新的 ReplicaSets,但不会复制任何 HPA 定义。如果将 HPA 应用于由 Deployment 管理的 ReplicaSet,它不会复制到新的 ReplicaSet 中,并且将会丢失。更好的技术是将 HPA 应用于更高级别的 Deployment 抽象,这样可以将 HPA 保留和应用于新的 ReplicaSet 版本。
现在,让我们看看 HPA 如何取代人工操作员以确保自动缩放。在高层次上,HPA 控制器连续执行以下步骤:
-
它从 Kubernetes Metrics API 检索关于需要根据 HPA 定义进行缩放的 Pod 的指标。指标不是直接从 Pod 中读取的,而是从服务聚合的 Metrics API(甚至是自定义和外部指标,如果配置为这样做)中获取的。从 Metrics API 获取 Pod 级别的资源指标,并从 Kubernetes 的 Custom Metrics API 中获取所有其他指标。
-
它根据当前指标值和目标指标值计算所需的副本数。以下是公式的简化版本:
例如,如果有一个单独的 Pod,其当前 CPU 使用率指标值为指定的 CPU 资源请求值的 90%^(1),并且期望值为 50%,则副本的数量将加倍,如 。实际的实现更为复杂,因为它必须考虑多个运行中的 Pod 实例,涵盖多种指标类型,并考虑许多边界情况和波动值。例如,如果指定了多个指标,则 HPA 将分别评估每个指标,并提出最大值。在所有计算完成后,最终输出是一个表示期望的副本数量的单个整数,以保持测量值低于期望阈值值。
自动缩放资源的replicas字段将根据计算出的数量进行更新,其他控制器将通过它们各自的工作部分来实现和保持新的期望状态。图 29-1 展示了 HPA 的工作方式:监视指标并相应地更改声明的副本数量。

图 29-1. 水平 Pod 自动缩放机制
自动缩放是 Kubernetes 的一个包含许多低级细节的领域,每个细节都可能对自动缩放的整体行为产生重大影响。因此,本书无法涵盖所有细节,但“更多信息”提供了该主题的最新更新信息。
广义上,有以下几种指标类型:
标准指标
这些指标被声明为.spec.metrics.resource[].type等于Resource,表示资源使用指标,如 CPU 和内存。它们是通用的,并且适用于同一名称下的任何集群上的任何容器。您可以像前面的示例中那样指定它们为百分比,也可以指定为绝对值。在两种情况下,值都基于保证的资源量,即容器资源的requests值而不是limits值。这些是由度量服务器组件提供的最易于使用的度量类型,可以作为集群附加组件启动。
自定义指标
这些具有.spec.metrics.resource[].type等于Object或Pod的指标需要更高级的集群监控设置,这些设置可能因集群而异。如其名称所示,带有 Pod 类型的自定义指标描述了特定于 Pod 的指标,而 Object 类型则可以描述任何其他对象。自定义指标在聚合的 API 服务器下提供,位于custom.metrics.k8s.ioAPI 路径下,并由不同的指标适配器(如 Prometheus、Datadog、Microsoft Azure 或 Google Stackdriver 等)提供。
外部指标
此类别用于描述不属于 Kubernetes 集群的资源指标。例如,您可能有一个 Pod,它从基于云的队列服务中消费消息。在这种情况下,您会希望根据队列深度来扩展消费者 Pod 的数量。这样的指标将由类似于自定义指标的外部指标插件填充。只能将一个外部指标端点连接到 Kubernetes API 服务器。要使用来自多个不同外部系统的指标,需要额外的聚合层,例如 KEDA(参见“KEDA”)。
正确设置自动缩放并不容易,并且需要一些试验和调整。在设置 HPA 时需要考虑以下几个主要方面:
指标选择
自动缩放周围可能是最关键的决策之一是使用哪些指标。对于 HPA 来说,指标值与 Pod 副本数之间必须有直接的关联。例如,如果选择的指标是每秒查询量(例如每秒 HTTP 请求),增加 Pod 的数量会导致平均查询量下降,因为查询被分发到更多的 Pod。如果指标是 CPU 使用率,情况也是如此,因为查询率和 CPU 使用率之间存在直接的关联(增加查询数量会导致 CPU 使用率增加)。对于内存消耗等其他指标,情况则不同。内存的问题在于,如果一个服务消耗了一定量的内存,启动更多的 Pod 实例可能不会导致内存减少,除非应用程序是集群化的并且意识到其他实例并具有分配和释放内存的机制。如果内存没有释放并反映在指标中,HPA 将会试图创建越来越多的 Pod 以减少内存消耗,直到达到上限副本阈值,这可能不是期望的行为。因此,选择一个与 Pod 数量直接(最好是线性地)相关的指标。
防止抖动
HPA 应用各种技术来避免在负载不稳定时导致副本数量波动的冲突决策快速执行。例如,在扩展时,当 Pod 初始化时,HPA 忽略高 CPU 使用率样本,确保对增加负载的平滑反应。在缩减时,为了避免响应短期使用量下降而缩小规模,控制器在可配置的时间窗口内考虑所有规模建议,并选择窗口内的最高建议。所有这些使得 HPA 在处理随机指标波动时更加稳定。
延迟反应
基于指标值触发扩展操作是一个多步骤过程,涉及多个 Kubernetes 组件。首先是 cAdvisor(容器顾问)代理,定期收集 Kubelet 的指标。然后指标服务器定期收集来自 Kubelet 的指标。HPA 控制器循环也定期运行并分析收集到的指标。HPA 缩放公式引入了一些延迟反应,以防止波动/抖动(如前一点所述)。所有这些活动累积成为原因和缩放反应之间的延迟。通过引入更多的延迟来调整这些参数会使得 HPA 的响应性降低,但减少延迟会增加平台的负载并增加抖动。配置 Kubernetes 来平衡资源和性能是一个持续的学习过程。
在 Kubernetes 中调整 HPA 的自动缩放算法可能很复杂。为了帮助解决这个问题,Kubernetes 在 HPA 规范中提供了 .spec.behavior 字段。该字段允许您在扩展 Deployment 中的副本数量时自定义 HPA 的行为。
对于每个缩放方向(向上或向下),您可以使用 .spec.behavior 字段来指定以下参数:
policies
这些描述了在给定时间段内扩展副本的最大数量。
stabilizationWindowSeconds
这指定了 HPA 将不再做进一步缩放决策的条件。设置此字段可以帮助防止 HP 迅速在副本数量上下波动。
Example 29-5 显示了如何配置行为。所有行为参数也可以通过 kubectl autoscale 在 CLI 中配置。
Example 29-5. 自动缩放算法的配置
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
...
spec:
...
behavior:
scaleDown: 
stabilizationWindowSeconds: 300 
policies:
- type: Percent 
value: 10
periodSeconds: 60
scaleUp: 
policies:
- type: Pods 
value: 4
periodSeconds: 15
在缩减时的缩放行为。
为防止波动,下缩放决策的最小窗口为 5 分钟。
在一分钟内最多减少当前副本的 10%。
在扩展时的缩放行为。
在 15 秒内最多扩展四个 Pod。
请参考 Kubernetes 文档中关于 配置扩展行为 的所有详细信息和使用示例。
虽然 HPA 非常强大,并且涵盖了自动缩放的基本需求,但它缺少一个关键功能:即在应用程序不使用时将所有 Pod 缩减至零。这一点很重要,以免基于内存、CPU 或网络使用产生任何费用。然而,缩减到零并不难;困难的部分是再次唤醒并通过触发器(如传入的 HTTP 请求或要处理的事件)至少扩展到一个 Pod。
下面的两节介绍了用于启用零缩放的两个最显著的基于 Kubernetes 的附加组件:Knative 和 KEDA。了解 Knative 和 KEDA 不是替代方案,而是互补解决方案至关重要。这两个项目涵盖了不同的用例,可以理想地结合使用。正如我们将看到的那样,Knative 专门用于无状态 HTTP 应用程序,并提供超出 HPA 能力的自动缩放算法。另一方面,KEDA 是一种拉取式方法,可以由许多不同的源触发,例如 Kafka 主题中的消息或 IBM MQ 队列。
让我们仔细看看 Knative 和 KEDA。
Knative
Knative 是谷歌在 2018 年发起的 CNCF 项目,得到了来自 IBM、VMware 和 Red Hat 等供应商的广泛行业支持。这个 Kubernetes 插件包括三个部分:
Knative Serving
这是一个简化的应用程序部署模型,具有复杂的自动缩放和流量分割功能,包括零缩放。
Knative 事件驱动
这提供了创建事件网格所需的一切,以连接产生 CloudEvents 的事件源与消费这些事件的接收器。这些接收器通常是 Knative Serving 服务。
Knative 函数
这是从源代码构建 Knative Serving 服务的脚手架和构建工具。它支持多种编程语言,并提供类似于 AWS Lambda 的编程模型。
在本节中,我们将专注于 Knative Serving 及其用于使用 HTTP 提供服务的应用程序的自动缩放器。对于这些工作负载,CPU 和内存是仅间接相关到实际使用的度量。一个更好的度量是每个 Pod 的并发请求数量,即并行处理的请求。
注意
Knative 可以使用的另一个基于 HTTP 的度量是每秒请求数(rps)。但是,这个度量并不反映单个请求的成本,因此并发请求通常是更好的度量,因为它们捕捉请求的频率和持续时间。您可以为每个应用程序单独选择扩展度量或作为全局默认设置。
基于并发请求的自动缩放决策与 HTTP 请求处理的延迟有更好的相关性,而基于 CPU 或内存消耗的扩展则无法提供。
历史上,Knative 曾作为 Kubernetes 中 HPA 的自定义度量适配器实现。然而,为了在影响扩展算法时拥有更大的灵活性,并避免只能在 Kubernetes 集群中注册单个自定义度量适配器的瓶颈,后来它发展出自己的实现。
虽然 Knative 仍支持使用 HPA 根据内存或 CPU 使用情况进行扩展,但现在它专注于使用自己的自动缩放实现,称为 Knative Pod Autoscaler(KPA)。这使 Knative 能够更好地控制扩展算法,并优化以满足应用程序的需求。
KPA 的架构显示在图 29-2 中。

图 29-2. Knative Pod Autoscaler
三个组件一起用于自动缩放服务:
激活器
这是一个位于应用程序前端的代理,即使应用程序缩减到零个 Pod 时也始终可用。当应用程序缩减到零时,如果有第一个请求进入,请求将被缓冲,并且应用程序将至少扩展到一个 Pod。重要的是,在冷启动期间,所有传入请求都将被缓冲,以确保不丢失请求。
队列代理
队列代理是一个大使边车,在应用的 Pod 中由 Knative 控制器注入,其拦截请求路径以收集与自动缩放相关的度量,如并发请求。
自动缩放器
这是在后台运行的服务,负责根据从激活器和队列代理获取的数据做出缩放决策。自动缩放器设置应用程序 ReplicaSet 中的副本计数。
可以通过多种方式配置 KPA 算法,以优化任何工作负载和流量形状的自动缩放行为。表 29-1 展示了通过注释调整单个服务的 KPA 的一些配置选项。类似的配置选项也存在于全局默认配置中,这些配置存储在 ConfigMap 中。您可以在Knative 文档中找到所有自动扩展配置选项的完整集合。该文档详细介绍了 Knative 缩放算法,例如通过在并发请求增加超过阈值时更积极地扩展来处理突发工作负载。
表 29-1. 重要的 Knative 扩展参数。autoscaling.knative.dev/,通用注释前缀,已被省略。
| 注释 | 描述 | 默认值 |
|---|---|---|
target |
每个副本可以处理的同时请求数量。这是一个软限制,在流量突发情况下可能会暂时超过。.spec.concurrencyLimit用作无法超过的硬限制。 |
100 |
target-utilization-percentage |
如果已达到并发限制的此分数,则开始创建新的副本。 | 70 |
min-scale |
要保留的最小副本数。如果设置为大于零的值,则应用程序永远不会缩减到零。 | 0 |
max-scale |
副本数的上限;零表示无限扩展。 | 0 |
activation-scale |
从零开始扩展时要创建的副本数。 | 1 |
scale-down-delay |
在缩小之前必须保持的缩小条件的持续时间。有助于在缩小为零之前保持副本的热状态,以避免冷启动时间。 | 0s |
window |
用于平均度量指标以提供缩放决策输入的时间窗口长度。 | 60s |
示例 29-6 展示了一个 Knative 服务,部署了一个示例应用程序。它看起来类似于 Kubernetes 的部署(Deployment)。然而,在幕后,Knative 操作员创建了必要的 Kubernetes 资源,以将您的应用程序公开为 Web 服务,即 ReplicaSet、Kubernetes 服务和用于将应用程序暴露到集群外部的 Ingress。
示例 29-6. Knative 服务
apiVersion: serving.knative.dev/v1 
kind: Service
metadata:
name: random
annotations:
autoscaling.knative.dev/target: "80" 
autoscaling.knative.dev/window: "120s"
spec:
template:
spec:
containers:
- image: k8spatterns/random 
Knative 还使用 Service 作为资源名称,但 API 组core的 Kubernetes 服务不同于 API 组serving.knative.dev。
调整自动扩展算法的选项。请参阅表 29-1 获取可用选项。
Knative 服务的唯一必需参数是对容器镜像的引用。
我们在这里仅简要提及 Knative。在操作 Knative 自动缩放器方面,还有很多内容可供您参考。请查看在线文档了解更多 Knative Serving 的功能,例如用于我们在第三章,“声明式部署”中描述的复杂部署方案的流量分割。此外,如果您正在遵循事件驱动架构(EDA)范例来开发应用程序,Knative Eventing 和 Knative Functions 也有很多提供。
KEDA
Kubernetes 事件驱动自动缩放(KEDA)是另一个重要的基于 Kubernetes 的自动缩放平台,支持零缩放,但其范围与 Knative 有所不同。虽然 Knative 支持基于 HTTP 流量的自动缩放,但 KEDA 是一种基于拉取的方法,根据来自不同系统的外部指标进行缩放。Knative 和 KEDA 之间有很好的协作,只有少量重叠^(3),因此您可以同时使用这两个附加组件。
那么,什么是 KEDA?KEDA 是一个 CNCF 项目,由 Microsoft 和 Red Hat 在 2019 年创建,包括以下组件:
-
KEDA 操作员调和了一个 ScaledObject 自定义资源,将缩放目标(例如 Deployment 或 StatefulSet)与一个通过所谓的scaler连接到外部系统的自动缩放触发器连接起来。它还负责配置 HPA,使用由 KEDA 提供的外部指标服务。
-
KEDA 的指标服务在 Kubernetes API 聚合层注册为 APIService 资源,以便 HPA 可以将其用作外部指标服务。
图 29-3 展示了 KEDA 操作员、指标服务和 Kubernetes HPA 之间的关系。

图 29-3. KEDA 自动缩放组件
虽然 Knative 是一个完整的解决方案,完全替代了基于消耗的自动缩放器 HPA,但 KEDA 是一个混合解决方案。KEDA 的自动缩放算法区分两种情况:
-
从零副本到一个的缩放激活(0 ↔ 1):当 KEDA 操作员检测到使用的缩放器指标超过某一阈值时,由 KEDA 操作员自身执行此操作。
-
在运行时进行扩展和收缩(1 ↔ n):当工作负载已经活动时,HPA 接管并根据 KEDA 提供的外部指标进行缩放。
KEDA 的核心元素是 ScaledObject 自定义资源,由用户提供以配置基于 KEDA 的自动缩放,并起到类似 HorizontalPodAutoscaler 资源的作用。一旦 KEDA 操作员检测到 ScaledObject 的新实例,它将自动创建一个 HorizontalPodAutoscaler 资源,该资源使用 KEDA 指标服务作为外部指标提供程序和缩放参数。
Example 29-7 显示了如何基于 Apache Kafka 主题中的消息数量来扩展部署。
Example 29-7. ScaledObject 定义
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: kafka-scaledobject
spec:
scaleTargetRef:
name: kafka-consumer 
pollingInterval: 30 
triggers:
- type: kafka 
metadata:
bootstrapServers: bootstrap.kafka.svc:9092 
consumerGroup: my-group
topic: my-topic
提到应该自动缩放的名为kafka-consumer的部署。您还可以在此处指定其他可扩展的工作负载;部署是默认选择。
在动作阶段(从零扩展),每 30 秒轮询度量值。在此示例中,它是 Kafka 主题中的消息数量。
选择 Apache Kafka 缩放器。
Apache Kafka 缩放器的配置选项——即如何连接到 Kafka 集群以及要监视的主题。
KEDA 提供许多开箱即用的缩放器,可以选择连接到外部系统以进行自动缩放刺激。您可以从KEDA 主页获取完整的直接支持的缩放器列表。此外,您可以通过提供一个通过基于 gRPC 的 API 与 KEDA 通信的外部服务,轻松集成自定义缩放器。
当您需要根据外部系统中持有的工作项(如您的应用程序消费的消息队列)来进行扩展时,KEDA 是一个很好的自动缩放解决方案。在某种程度上,此模式与第七章,“批处理作业”的一些特性相似:工作负载仅在有工作时运行,在空闲时不消耗任何资源。两者都可以扩展以并行处理工作项。不同之处在于,KEDA ScaledObject 会自动进行扩展,而对于 Kubernetes 的Job,您必须手动确定并行参数。使用 KEDA,您还可以基于外部工作负载的可用性自动触发 Kubernetes Jobs。ScaledJob 自定义资源正是为此目的而设计,以便在满足扩展器激活阈值时启动 Job 资源,而不是将副本从 0 扩展到 1。请注意,Job 中的parallelism字段仍然是固定的,但是自动缩放发生在 Job 资源级别上(即 Job 资源本身起到副本的作用)。
Table 29-2 总结了 HPA、Knative 和 KEDA 之间的独特功能和差异。
表 29-2. Kubernetes 上的水平自动缩放配置选项
| HPA | Knative | KEDA | |
|---|---|---|---|
| 缩放指标 | 资源使用率 | HTTP 请求 | 外部度量,如消息队列积压 |
| 零缩放 | 否 | 是 | 是 |
| 类型 | 拉取 | 推送 | 拉取 |
| 典型用例 | 稳定流量 Web 应用程序,批处理 | 快速扩展的无服务器应用程序,无服务器函数 | 消息驱动的微服务 |
现在,我们已经看到了通过 HPA、Knative 和 KEDA 进行水平扩展的所有可能性,让我们看看一种完全不同的扩展方式,它不会改变并行运行副本的数量,而是允许您的应用程序自由增长和收缩。
垂直 Pod 自动缩放
与无状态服务相比,水平扩展优于垂直扩展,因为它更少会造成中断。对于有状态服务来说,情况并非如此,垂直扩展可能更可取。垂直扩展有助于根据实际负载模式调整服务的资源需求的其他场景。我们已经讨论过,在负载随时间变化时,确定 Pod 副本的正确数量可能是困难甚至不可能的。垂直扩展也面临着识别容器的正确 requests 和 limits 的挑战。Kubernetes 垂直 Pod 自动缩放器(VPA)旨在通过根据实际使用反馈自动调整和分配资源的过程来应对这些挑战。
正如我们在 第二章,“可预测的需求” 中看到的,Pod 中的每个容器都可以指定其 CPU 和内存的 requests,这影响 Pod 的调度位置。从某种意义上说,Pod 的资源 requests 和 limits 形成了 Pod 与调度器之间的合同,这会确保分配一定量的资源或防止 Pod 被调度。将内存的 requests 设置得太低可能导致节点过于密集,从而出现内存不足的错误或由于内存压力而导致工作负载被驱逐。如果 CPU 的 limits 设置过低,可能会发生 CPU 饥饿和工作负载性能不佳。另一方面,指定过高的资源 requests 会分配不必要的容量,导致资源浪费。准确设置资源 requests 非常重要,因为它们影响集群利用率和水平扩展的有效性。让我们看看 VPA 如何解决这个问题。
在安装了 VPA 和度量服务器的集群上,我们可以使用 VPA 定义来演示 Pods 的垂直自动缩放,如 示例 29-8。
示例 29-8. VPA
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
name: random-generator-vpa
spec:
targetRef: 
apiVersion: apps/v1
kind: Deployment
name: random-generator
updatePolicy:
updateMode: "Off" 
引用到包含选择器以识别要管理的 Pods 的更高级别资源。
VPA 将如何应用更改的更新策略。
VPA 定义有以下主要部分:
目标参考
目标参考指向一个更高级别的资源,控制 Pods,例如 Deployment 或 StatefulSet。从这个资源中,VPA 查找标签选择器,以识别应该处理的 Pods。如果参考指向不包含这样的选择器的资源,则会在 VPA 状态部分报告错误。
更新策略
更新策略控制 VPA 如何应用更改。 Initial 模式允许您仅在 Pod 创建时分配资源请求,而不是以后。默认的 Auto 模式允许在 Pod 创建时为 Pod 分配资源,并且可以在 Pod 的生命周期内更新 Pod,通过逐出和重新调度 Pod。值 Off 禁用对 Pod 的自动更改,但允许您建议资源值。这是一种在不直接应用更改的情况下发现容器适当大小的试运行。
VPA 定义还可以具有资源策略,影响 VPA 如何计算推荐的资源(例如,通过设置每个容器的资源下限和上限边界)。
根据配置的 .spec.updatePolicy.updateMode,VPA 涉及不同的系统组件。所有三个 VPA 组件——推荐器、准入插件和更新器——都是解耦且独立的,并且可以替换为替代实现。生成推荐的模块是推荐器,受 Google 的 Borg 系统启发。该实现分析容器在负载下的实际资源使用情况,持续一段时间(默认为八天),生成直方图,并选择该期间的高百分位值。除了指标外,还考虑了资源和特别是内存相关的 Pod 事件,例如驱逐和 OutOfMemory 事件。
在我们的示例中,我们选择了 .spec.updatePolicy.updateMode 等于 Off,但还有两个其他选项可供选择,每个选项对扩展的 Pod 造成不同程度的潜在中断。让我们看看不同的 updateMode 值如何工作,从不造成中断到更具破坏性的顺序:
关闭
VPA 推荐器收集 Pod 的指标和事件,然后生成推荐。 VPA 的推荐始终存储在 VPA 资源的 status 部分。然而,这就是 Off 模式的功能范围。它分析并生成推荐,但不将其应用于 Pod。这种模式有助于了解 Pod 资源消耗情况,而不引入任何变化和造成中断。如果需要,用户可以自行决定是否应用推荐。
初始
在此模式下,VPA 进一步进行。除了推荐器组件执行的活动外,还激活了 VPA 准入控制器,仅将推荐应用于新创建的 Pod。例如,如果手动扩展 Pod,由 Deployment 更新或由于任何原因驱逐并重新启动 Pod,则 VPA 准入控制器将更新 Pod 的资源请求值。
这个控制器是一个变异接受 Webhook,它会覆盖与 VPA 资源相关联的新匹配 Pod 的requests。这种模式不会重新启动运行中的 Pod,但它仍然部分干扰,因为它改变了新创建 Pod 的资源请求。这反过来可能会影响新 Pod 的调度位置。更糟糕的是,如果集群上没有足够的容量,应用推荐的资源请求后,Pod 可能根本无法被调度到任何节点。
重新创建和自动
除了之前描述的推荐创建及其在新创建的 Pod 上的应用之外,在这种模式下,VPA 还会激活其更新的组件。Recreate更新模式强制驱逐并重新启动部署中的所有 Pod 以应用 VPA 的建议,而Auto更新模式据说在未来的 Kubernetes 版本中将支持资源限制的就地更新而无需重新启动 Pod。截至 2023 年,Auto的行为与Recreate相同,因此这两种更新模式可能会带来干扰,并可能导致之前描述的意外调度问题。
Kubernetes 旨在管理具有不可变 Pod spec定义的不可变容器,如图 29-4 所示。虽然这简化了水平扩展,但对于垂直扩展,如需要 Pod 删除和重新创建,这可能会影响调度并引起服务中断。即使 Pod 在缩减时想要释放已分配的资源而不造成中断,这也是真实的。
另一个问题是 VPA 和 HPA 的共存,因为这些自动缩放器目前不相互感知,这可能导致不希望的行为。例如,如果 HPA 正在使用 CPU 和内存等资源指标,而 VPA 也影响相同的值,则可能导致水平扩展的 Pod 也同时进行垂直扩展(因此会双倍扩展)。
我们不能在这里详细讨论。虽然它仍在不断发展中,但值得关注 VPA,因为它是一个有潜力显著改善资源消耗的功能。

图 29-4. 垂直 Pod 自动缩放机制
集群自动缩放
本书中的模式主要使用针对已经设置的 Kubernetes 集群的开发人员的 Kubernetes 基元和资源,这通常是一个运维任务。由于这是与工作负载的弹性和扩展相关的主题,我们将简要介绍 Kubernetes 集群自动缩放器(CA)。
云计算的一个原则是按需使用资源。我们可以在需要时消费云服务,且仅使用所需的量。在高峰时段,CA 可以与运行 Kubernetes 的云提供商进行交互,请求额外的节点;在其他时间关闭空闲节点,从而降低基础设施成本。虽然 HPA 和 VPA 执行 Pod 级别的扩展,并确保集群内服务能力的弹性,但 CA 则提供节点的扩展能力,以确保集群容量的弹性。
CA 是一个 Kubernetes 的附加组件,必须在云计算基础设施上开启和配置,其中节点可以按需配置和下线,并支持 Kubernetes CA,如 AWS、IBM Cloud Kubernetes Service、Microsoft Azure 或 Google Compute Engine。
CA 主要执行两种操作:向集群添加新节点或从集群中移除节点。让我们看看这些操作是如何执行的:
添加新节点(扩展)
如果您的应用具有变化负载(一天中的繁忙时段、周末或假期季节和其他时间负载较少),您需要可变的容量来满足这些需求。您可以从云提供商购买固定的容量以覆盖高峰时段,但在较不繁忙的时段支付固定成本会减少云计算的优势。这正是 CA 真正有用的地方。
当一个 Pod 被水平或垂直地扩展,无论是手动操作还是通过 HPA 或 VPA,副本都必须分配到有足够 CPU 和内存容量以满足要求的节点上。如果集群中没有节点有足够的容量来满足 Pod 的所有需求,该 Pod 将被标记为 unschedulable,并保持等待状态,直到找到这样的节点。CA 监控这些 Pod,以确定是否添加新节点可以满足这些 Pod 的需求。如果答案是肯定的,它会调整集群大小并容纳等待的 Pods。
CA 不能通过随机节点来扩展集群—它必须从集群正在运行的可用节点组中选择一个节点。^(4) 它假设节点组中的所有机器具有相同的容量和相同的标签,并且它们运行由本地清单文件或 DaemonSets 指定的相同 Pods。这种假设对于 CA 估算新节点将为集群添加多少额外 Pod 容量是必要的。
如果多个节点组都能满足等待的 Pods 的需求,CA 可以配置为通过不同的策略选择一个节点组,称为 expanders。一个 expander 可以通过优先考虑最低成本或最小资源浪费来扩展节点组,容纳大多数 Pods,或者仅仅是随机选择。在成功选择节点之后,云服务提供商应在几分钟内为 API 服务器注册一个新的 Kubernetes 节点,并准备好托管等待的 Pods。
删除节点(缩减)
在不造成服务中断的情况下缩减 Pods 或节点始终更为复杂,并且需要进行许多检查。如果不需要扩展并且标识出节点不需要,则 CA 执行缩减。如果节点满足以下主要条件,则节点有资格进行缩减:
-
其容量超过一半未使用,即节点可分配资源容量的所有请求 CPU 和内存的总和少于节点资源容量的 50%。
-
节点上的所有可移动 Pod(非通过清单文件本地运行或由 DaemonSets 创建的 Pod)都可以放置在其他节点上。为证明此点,CA 进行调度模拟,并确定每个可能被驱逐 Pod 的未来位置。Pod 的最终位置仍由调度程序确定,并可能不同,但模拟确保了 Pod 的备用容量。
-
没有其他原因阻止节点删除,如通过注释排除节点从缩减中。
-
不能被迁移的 Pod 包括具有无法满足的 PodDisruptionBudget、具有本地存储的 Pod、阻止驱逐的注释的 Pod、没有控制器创建的 Pod 或系统 Pod。
所有这些检查都是为了确保不删除任何无法在不同节点上启动的 Pod。如果所有前述条件在一段时间内为真(默认为 10 分钟),则节点符合删除条件。将节点标记为不可调度,并将其上的所有 Pod 移动到其他节点来删除节点。
图 29-5 总结了 CA 如何与云提供商和 Kubernetes 交互,以扩展集群节点。

图 29-5. 集群自动扩展机制
正如您现在可能已经了解的那样,扩展 Pods 和节点是解耦但互补的过程。HPA 或 VPA 可分析使用情况指标和事件,并扩展 Pods。如果集群容量不足,CA 介入并增加容量。在由批处理作业、定期任务、持续集成测试或其他需要临时增加容量的高峰任务导致集群负载异常时,CA 也是有帮助的。它可以增加和减少容量,并在云基础设施成本上实现显著节省。
缩放级别
在本章中,我们探讨了各种技术,以满足部署工作负载的不断变化的资源需求。虽然人工操作员可以手动执行此处列出的大部分活动,但这与云原生思维不一致。为了实现大规模分布式系统管理,自动化重复活动是必不可少的。首选方法是自动缩放,使人工操作员能够专注于 Kubernetes Operator 尚不能自动化的任务。
让我们按照从更精细到更粗粒度的顺序回顾所有的扩展技术,如 图 29-6 所示。

图 29-6. 应用程序缩放级别
应用程序调优
在最精细的级别,有一种应用程序调优技术我们在本章中没有涵盖,因为它不是与 Kubernetes 相关的活动。然而,您可以采取的第一个行动是调整运行在容器中的应用程序以最佳利用分配的资源。这种活动并非每次服务扩展时都要执行,但必须在投入生产之前进行。例如,对于 Java 运行时,可以通过配置更改而不是代码更改来调整线程池的大小,以最佳利用容器获取的可用 CPU 资源份额,然后调整不同的内存区域,如堆、非堆和线程堆栈大小的值。
容器本机应用程序使用启动脚本,根据分配的容器资源而不是共享的整个节点容量,可以计算出线程数和应用程序内存大小的良好默认值。使用这种脚本是一个很好的第一步。您还可以进一步使用技术和库,如 Netflix 自适应并发限制库,其中应用程序可以通过自我分析和适应动态计算其并发限制。这是一种应用程序内自动缩放的方式,无需手动调优服务。
调优应用程序可能会导致类似代码更改的回归,并且必须进行一定程度的测试。例如,更改应用程序的堆大小可能会导致其因 OutOfMemory 错误而被终止,水平缩放无法帮助解决此问题。另一方面,垂直或水平扩展 Pods,或者提供更多节点,如果你的应用程序未正确消耗为容器分配的资源,则效果不佳。因此,在此级别进行规模调整可能会影响所有其他扩展方法,并且可能会造成干扰,但至少必须执行一次以获得最佳的应用程序行为。
垂直 Pod 自动缩放
假设应用程序有效地消耗了容器资源,下一步是在容器中设置正确的资源请求和限制。之前我们已经探讨了 VPA 如何自动化发现和应用由实际消耗驱动的最优值的过程。这里的一个重要问题是 Kubernetes 要求删除并从头开始创建 Pods,这可能导致服务短暂或意外的中断。为资源匮乏的容器分配更多资源可能会使 Pod 无法调度,并进一步增加其他实例的负载。增加容器资源可能还需要对应用程序进行调优,以最佳利用增加的资源。
水平 Pod 自动缩放
前面两种技术是一种垂直扩展的形式;通过调整现有 Pod 而不改变其数量,我们希望获得更好的性能。接下来的两种技术是一种水平扩展的形式:我们不触及 Pod 的规格,但是改变 Pod 和节点的数量。这种方法减少了引入任何回归和中断的机会,并允许更简单的自动化。HPA、Knative 和 KEDA 是最流行的水平扩展形式。最初,HPA 仅通过 CPU 和内存指标支持提供了最小功能。现在它使用自定义和外部指标来支持更高级别的扩展用例,允许基于具有改进成本关联的指标进行扩展。
假设您已经执行了前面两种方法,用于确定应用程序设置本身的良好值,并确定了容器的资源消耗,从那时起,您可以启用 HPA,并使应用程序适应不断变化的资源需求。
集群自动缩放
在 HPA 和 VPA 中描述的扩展技术仅在集群容量边界内提供弹性。只有在 Kubernetes 集群内有足够的空间时,才能应用它们。CA 在集群容量级别引入了灵活性。CA 是其他扩展方法的补充,但完全解耦。它不关心额外容量需求的原因,也不关心为什么有未使用的容量,或者是人为操作员还是自动缩放器在改变工作负载配置文件。CA 可以扩展集群以确保所需的容量,或者缩小以节省一些资源。
讨论
弹性和不同的扩展技术是 Kubernetes 中仍在积极发展的领域。例如,VPA 仍处于实验阶段。此外,随着无服务器编程模型的普及,缩放到零和快速缩放已成为优先考虑的事项。Knative 和 KEDA 是 Kubernetes 的附加组件,正好满足了提供基础以实现零缩放的需求,正如我们在 “Knative” 和 “KEDA” 中简要描述的那样。这些项目正在快速发展,并引入非常激动人心的新的云原生基元。我们正在密切关注这个领域,并建议您也关注 Knative 和 KEDA。
鉴于分布式系统的期望状态规范,Kubernetes 可以创建并维护它。它还通过持续监控和自我修复来提高可靠性和抗故障能力,并确保其当前状态与期望状态一致。尽管对于今天的许多应用来说,一个具有弹性和可靠性的系统已经足够了,但 Kubernetes 更进一步。一个小而正确配置的 Kubernetes 系统在面对重载时不会崩溃,而是会扩展 Pods 和节点。因此,在面对这些外部压力时,系统会变得更大更强,而不是更脆弱,这赋予了 Kubernetes 抗脆弱的能力。
更多信息
^(1) 对于多个运行中的 Pods,平均 CPU 利用率被用作 currentMetricValue。
^(2) CloudEvents 是一个 CNCF 标准,用于描述云环境中事件的格式和元数据。
^(3) 初始时,KEDA 不支持 HTTP 触发的自动缩放,尽管现在有一个 KEDA HTTP add-on,但其仍处于早期阶段(截至 2023 年),需要复杂的设置,并且需要大幅赶上 Knative 自带的 KPA 的成熟度。
^(4) 节点组不是 Kubernetes 的固有概念(即没有 NodeGroup 资源),但在 CA 和集群 API 中被用作描述共享某些特性的节点的抽象概念。
第三十章:镜像构建器
Kubernetes 是一个通用的编排引擎,不仅适用于运行应用程序,还适用于构建容器镜像。镜像构建器模式解释了为什么在集群内构建容器镜像是有意义的,以及今天在 Kubernetes 中存在哪些创建镜像的技术。
问题
到目前为止,本书中的所有模式都是关于在 Kubernetes 上运行应用程序的。您已经学会了如何开发和准备应用程序以成为良好的云原生公民。但是,构建应用程序本身呢?传统的方法是在集群外构建容器镜像,将其推送到注册表,并在 Kubernetes 部署描述符中引用它们。然而,在集群内构建具有几个优势。
如果您公司的政策允许,只有一个集群对一切都是有利的。在一个地方构建和运行应用程序可以大大减少维护成本。它还简化了容量规划并减少了平台资源的开销。
通常使用像 Jenkins 这样的持续集成(CI)系统来构建镜像。使用 CI 系统进行构建是一个调度问题,需要有效地为构建作业找到空闲的计算资源。Kubernetes 的核心是一个高度复杂的调度器,非常适合解决这类调度挑战。
一旦我们转向持续交付(CD),从构建镜像转向运行容器,如果构建发生在同一个集群内,两个阶段共享相同的基础设施,并且容易过渡。例如,假设发现了一个新的安全漏洞在所有应用程序使用的基础镜像中。当您的团队解决了这个问题后,您必须重建所有依赖于此基础镜像的应用程序镜像,并使用新镜像更新正在运行的应用程序。在实现镜像构建器模式时,集群同时知道镜像的构建和部署,如果基础镜像发生变化,可以自动重新部署。在“OpenShift Build”,我们将看到 OpenShift 如何实现这样的自动化。
在平台上看到构建镜像的好处后,让我们看看在 Kubernetes 集群中创建镜像的技术有哪些存在。
解决方案
截至 2023 年,存在一整套在集群中构建容器镜像的技术。虽然所有技术的目标都是构建镜像,但每种工具都添加了一些独特的功能,使其适用于特定的情况。
图 30-1 包含了截至 2023 年在 Kubernetes 集群中构建容器镜像的基本技术。

图 30-1 Kubernetes 内的容器镜像构建
本章节简要概述了大多数这些技术。你可以通过跟随“更多信息”中的链接找到关于这些工具更多的细节。请注意,尽管这里描述的许多工具已经成熟并用于生产项目中,但不能保证在您阅读这些文字时这些项目仍然存在。在使用之前,您应该检查项目是否仍然活跃并得到支持。
对这些工具进行分类并不简单,因为它们在某种程度上重叠或相互依赖。每个工具都有其独特的重点,但对于集群内构建,我们可以识别出以下高级分类:
容器镜像构建器
这些工具在集群内创建容器镜像。这些工具之间存在一定的重叠,各有所异,但全部都可以在非特权访问的情况下运行。你也可以将这些工具作为 CLI 程序在集群外运行。这些构建器的唯一目的是创建容器镜像,但它们并不关心应用程序的重新部署。
构建编排
这些工具在更高的抽象层上运作,并最终触发容器镜像构建器来创建镜像。它们还支持构建相关任务,如在构建完镜像后更新部署描述符。如前所述的 CI/CD 系统是编排器的典型例子。
容器镜像构建器
从集群内部构建镜像的一个基本前提是在没有特权访问节点主机的情况下创建镜像。存在各种工具满足此前提条件,并且可以根据容器镜像的规范和构建方式进行粗略分类。
基于 Dockerfile 的构建器
下列构建器基于众所周知的 Dockerfile 格式来定义构建指令。它们在 Dockerfile 级别上是兼容的,并且要么完全不依赖与后台守护程序交互,要么通过 REST API 与在非特权模式下运行的构建进程远程交流:
Buildah 和 Podman
Buildah 及其姊妹项目 Podman 是强大的工具,用于构建符合 OCI 规范的镜像,而无需 Docker 守护程序。它们在容器内部创建镜像,然后将其推送到镜像注册表中。Buildah 和 Podman 在功能上有重叠,Buildah 专注于构建容器镜像(尽管 Podman 也可以通过包装 Buildah API 创建容器镜像)。在这篇自述文件中更清楚地描述了它们的区别。
Kaniko
Kaniko 是 Google Cloud Build 服务的一个支柱,并且专门用于在 Kubernetes 中作为构建容器运行。在构建容器内部,Kaniko 仍然以 UID 0 运行,但持有容器本身的 Pod 是非特权的。这一要求阻止了在禁止容器中以 root 用户身份运行的集群中使用 Kaniko,比如 OpenShift。我们可以在“构建 Pod”中看到 Kaniko 的实际应用。
BuildKit
Docker 将其构建引擎拆分为一个独立的项目,BuildKit,可以独立于 Docker 使用。它继承了 Docker 的客户端-服务器架构,通过后台运行的 BuildKit 守护进程等待构建作业。通常情况下,这个守护进程直接在触发构建的容器中运行,但也可以在 Kubernetes 集群中运行,以支持分布式无根构建。BuildKit 引入了低级构建(LLB)定义格式,并支持多个前端。LLB 允许复杂的构建图,并可以用于任意复杂的构建定义。BuildKit 还支持超出原始 Dockerfile 规范的功能。除了 Dockerfile 外,BuildKit 还可以使用其他前端通过 LLB 定义容器镜像的内容。
多语言构建器
许多开发者只关心他们的应用程序被打包为容器镜像,而不太关心这是如何完成的。为了满足这种情况,存在多语言构建器以支持多种编程平台。它们检测现有项目,如 Spring Boot 应用程序或通用的 Python 构建,并选择一个有意见的镜像构建流程。
Buildpacks 自 2012 年以来存在,并最初由 Heroku 引入,允许将开发者的代码直接推送到其平台。Cloud Foundry 接纳了这个思路,并创建了 Buildpack 的分支,最终形成了被广泛认为是平台即服务(PaaS)黄金标准的 cf push 成语。2018 年,各种 Buildpack 的分支统一在 CNCF 的旗下,并被称为 Cloud Native Buildpacks (CNB)。除了为不同的编程语言提供个别的构建包外,CNB 还引入了一个生命周期,用于将源代码转换为可执行的容器镜像。
生命周期大致可以分为三个主要阶段:^(1)
-
在 detect 阶段,CNB 会迭代一个配置好的构建包列表。每个构建包可以决定它是否适合给定的源代码。例如,基于 Java 的构建包在检测到 Maven 的 pom.xml 后会响应。
-
所有在 detect 阶段存活下来的构建包将在 build 阶段被调用,为最终的、可能已编译的工件提供它们的部分。例如,Node.js 应用的构建包会调用
npm install来获取所有必需的依赖项。 -
CNB 生命周期的最后一步是将结果 export 到最终的 OCI 镜像,并将其推送到注册表。
CNB 针对两种用户群体。主要受众包括开发者,他们希望将他们的代码部署到 Kubernetes 或任何其他基于容器的平台上。另一个是Buildpack 作者,他们创建单个 Buildpack,并将它们组合成所谓的builders。您可以从预制的 Buildpacks 和 builders 列表中进行选择,或者为您和您的团队创建自己的 Buildpacks。然后,开发者可以通过在其源代码上运行 CNB 生命周期时引用它们来选择这些 Buildpacks。有多种工具可用于执行此生命周期;您可以在Cloud Native Buildpacks 网站上找到完整的列表。
对于在 Kubernetes 集群中使用 CNB,以下任务非常有帮助:
-
pack是一个 CLI 命令,用于在本地配置和执行 CNB 生命周期。它需要访问像 Docker 或 Podman 这样的 OCI 容器运行时引擎来运行包含要使用的 Buildpacks 列表的 Builder 镜像。 -
像 Tekton 构建任务或通过 GitHub actions 直接调用从配置的 Builder 镜像中调用生命周期的 CI 步骤。
-
kpack配备了一个操作器,允许您在 Kubernetes 集群中配置和运行 buildpacks。CNB 的所有核心概念,如 Builder 或 Buildpacks,都直接反映为 CustomResourceDefinitions。kpack目前尚未成为 CNB 项目的一部分,但自 2023 年以来即将被吸收。
许多其他平台和项目已经采用 CNB 作为其首选的构建平台。例如,Knative Functions 在将 Function 代码转换为容器镜像并部署为 Knative 服务之前,使用 CNB 作为其内部的转换工具。
OpenShift 的 Source-to-Image(S2I)是另一种具有见解的构建方法,使用 builder 镜像。S2I 直接从您的应用程序源代码生成可执行的容器镜像。我们将在“OpenShift 构建”中详细研究 S2I。
专门的 builders
最后,专门的 builders 具有一种有见解的创建镜像的方式,适用于特定情况。虽然它们的范围狭窄,但它们的强烈意见允许高度优化的构建流程,增加灵活性并减少构建时间。所有这些 builders 都执行无根权限构建。它们在本地创建包含应用程序工件的容器镜像层,并直接推送到容器镜像注册表:
Jib
Jib 是一个纯 Java 库和构建扩展,与 Java 构建工具(如 Maven 或 Gradle)很好地集成。它直接为 Java 构建工件、依赖项和其他静态资源创建单独的镜像层,以优化镜像重建时间。与其他构建工具一样,它直接与容器镜像注册表通信,用于生成的镜像。
ko
对于从 Golang 源代码创建镜像,ko 是一个很好的工具。它可以直接从远程 Git 存储库创建镜像,并在构建和推送到注册表后更新 Pod 规范以指向该镜像。
Apko
Apko 是一种独特的构建器,它使用 Alpine 的 Apk 包作为构建块,而不是 Dockerfile 脚本。这种策略允许在创建多个相似镜像时轻松重用构建块。
此列表仅选择了许多专业化构建技术中的一部分。它们都对它们可以构建的内容有着非常狭窄的范围。这种主观化方法的优势在于,它们可以优化构建时间和镜像大小,因为它们精确地了解它们操作的领域,并能做出明确的假设。
现在我们已经看到了一些构建容器镜像的方法,让我们跳到更高的抽象级别,看看如何在更广泛的上下文中嵌入实际的构建。
构建编排器
构建编排器是诸如 Tekton、Argo CD 或 Flux 等 CI 和 CD 平台。这些平台涵盖了应用程序的整个自动化管理生命周期,包括构建、测试、发布、部署、安全扫描等。有一些优秀的书籍涵盖了这些平台并将其整合在一起,所以我们在这里不会详细介绍。
除了通用的 CI 和 CD 平台外,我们还可以使用更专业的编排器来创建容器镜像:
OpenShift 构建
在 Kubernetes 集群中构建镜像的最古老和最成熟的方法之一是 OpenShift 构建 子系统。它允许您以多种方式构建镜像。我们将在“OpenShift 构建”中更详细地了解 OpenShift 构建镜像的方式。
kbld
kbld 是 Carvel 工具集的一部分,用于在 Kubernetes 上构建、配置和部署。kbld 负责使用我们在“容器镜像构建器”中描述的构建技术之一构建容器,并更新资源描述符,引用已构建的镜像。更新 YAML 文件的技术与 ko 的工作方式非常相似:kbld 查找image字段,并将它们的值设置为新构建镜像的坐标。
Kubernetes 作业
您还可以使用标准的 Kubernetes 作业触发“容器镜像构建器”中的任何一个构建器进行构建。详细描述作业的内容在第七章,“批处理作业”中。这样的作业包装了一个构建 Pod 规范,用于定义运行时部分。构建 Pod 从远程源代码库获取源代码,并使用集群内的一个构建器创建适当的镜像。我们将在“构建 Pod”中看到这样的 Pod 在运行中的情况。
构建 Pod
要挖掘典型的集群内构建的基本要素,让我们从最小化开始,使用 Kubernetes Pod 执行完整的构建和部署周期。这些构建步骤在图 30-2 中有所说明。

图 30-2. 集群内使用构建 Pod 构建容器镜像
以下任务代表了所有构建编排器的特征,并涵盖了创建容器镜像的所有方面:
-
从给定的远程 Git 仓库检出源代码。
-
对于编译语言,在容器内执行本地构建。
-
使用“容器镜像构建器”中描述的一种技术构建应用程序。
-
将镜像推送到远程镜像注册表。
-
可选地,使用新的镜像引用更新部署,这将触发按第三章,“声明式部署”描述的策略重新部署应用程序。
在我们的示例中,构建 Pod 使用了如第十五章,“初始化容器”所述的初始化容器,以确保构建步骤按顺序运行。在实际场景中,您会使用像 Tekton 这样的 CI 系统来指定和顺序执行这些任务。
完整的构建 Pod 定义显示在示例 30-1 中。
示例 30-1. 使用 Kaniko 构建 Pod
apiVersion: v1
kind: Pod
metadata:
name: build
spec:
initContainers:
- name: git-sync 
image: k8s.gcr.io/git-sync/git-sync
args: [
"--one-time",
"--depth", "1",
"--root", "/workspace",
"--repo", "https://github.com/k8spatterns/random-generator.git",
"--dest", "main",
"--branch", "main"]
volumeMounts: 
- name: source
mountPath: /workspace
- name: build 
image: gcr.io/kaniko-project/executor
args:
- "--context=dir:///workspace/main/"
- "--destination=index.docker.io/k8spatterns/random-generator-kaniko"
- "--image-name-with-digest-file=/workspace/image-name"
securityContext:
privileged: false 
volumeMounts:
- name: kaniko-secret 
mountPath: /kaniko/.docker
- name: source 
mountPath: /workspace
containers:
- name: image-update 
image: k8spatterns/image-updater
args:
- "random"
- "/opt/image-name"
volumeMounts:
- name: source
mountPath: /opt
volumes:
- name: kaniko-secret 
secret:
secretName: registry-creds
items:
- key: .dockerconfigjson
path: config.json
- name: source 
emptyDir: {}
serviceAccountName: build-pod 
restartPolicy: Never 
用于从远程 Git 仓库获取源代码的初始化容器。
存储源代码的卷。
Kaniko 作为构建容器,将创建的镜像作为共享工作空间中的引用存储。
构建正在以非特权方式运行。
将用于推送到 Docker Hub 注册表的秘密挂载在一个已知路径上,以便 Kaniko 可以找到它。
挂载共享工作空间以获取源代码。
使用 Kaniko 构建的镜像引用更新部署random的容器。
带有 Docker Hub 凭证的秘密卷。
将共享卷定义为节点本地文件系统上的空目录。
允许修补部署资源的 ServiceAccount。
永不重新启动此 Pod。
这个示例比较复杂,让我们将其分解为三个主要部分。
首先,在能够构建容器镜像之前,需要获取应用程序代码。在大多数情况下,源代码从远程 Git 仓库获取,但也有其他技术可用。为了开发方便,可以从本地机器获取源代码,这样就不必访问远程源代码仓库,并避免通过触发提交来搞乱提交历史。由于构建发生在集群内部,因此必须以某种方式将该源代码上传到构建容器中。另一种可能性是通过容器镜像打包和分发源代码,并通过容器镜像注册表分发。
在示例 30-1 中,我们使用一个初始化容器从源 Git 存储库中获取源代码,并将其存储在类型为emptyDir的共享 Pod 卷source中,以便后续构建过程可以获取它。
接下来,在检索应用程序代码之后,实际的构建过程开始。在我们的示例中,我们使用Kaniko,它使用常规的 Dockerfile,并且可以完全无特权地运行。我们再次使用一个初始化容器,以确保只有在完全获取源代码后才开始构建。容器镜像会在本地磁盘上创建,并且我们还配置了 Kaniko 将生成的镜像推送到远程 Docker 注册表。
用于推送到注册表的凭据从 Kubernetes Secret 中获取。我们在第二十章,“配置资源”中详细解释了 Secrets。
幸运的是,对于针对 Docker 注册表的认证的特定情况,我们可以直接从kubectl获得支持,以创建存储此配置的密钥,其格式是众所周知的。
kubectl create secret docker-registry registry-creds \
--docker-username=k8spatterns \
--docker-password=********* \
--docker-server=https://index.docker.io/
对于示例 30-1,将此类密钥挂载到给定路径下的构建容器中,以便 Kaniko 在创建镜像时可以获取它。在第二十五章,“安全配置”中,我们解释了如何安全地存储此类密钥,以防止伪造。
最后一步是使用新创建的镜像更新现有的 Deployment。现在,在 Pod 的实际应用程序容器中执行此任务。^(2) 所引用的镜像来自我们的示例仓库,仅包含一个kubectl二进制文件,该文件通过以下调用修补指定的 Deployment 以使用新的镜像名称,如示例 30-2 所示。
示例 30-2. 更新 Deployment 中的镜像字段
IMAGE=$(cat $1) 
PATCH=<<EOT  [{
"op": "replace",
"path": "/spec/template/spec/containers/0/image",
"value": "$IMAGE"
}]
EOT
kubectl patch deployment $2 \ 
--type="json" \
--patch=$PATCH
检索之前构建步骤中存储的镜像名称,位于文件/opt/image-name中。此文件作为该脚本的第一个参数提供。
用于更新 Pod 规范以使用新镜像引用的 JSON 路径。
为给定的第二个参数(在我们的示例中是random)打补丁,并触发新的滚动更新。
Pod 分配的 ServiceAccount build-pod 已设置为可以写入此 Deployment。有关为 ServiceAccount 分配权限的说明,请参阅第二十六章,“访问控制”。在 Deployment 中更新镜像引用后,会执行如第三章,“声明式部署”所述的滚动更新。
您可以在书籍的示例仓库中找到完全可用的设置。构建 Pod 是在集群内进行构建和重部署的最简单方式。如前所述,它仅用于说明目的。
对于真实世界的用例,您应该使用像 Tekton 这样的 CI/CD 解决方案或整个构建编排平台,如我们现在描述的 OpenShift 构建。
OpenShift 构建
红帽 OpenShift 是 Kubernetes 的企业分发版。除了支持 Kubernetes 支持的一切外,它还增加了一些企业相关功能,如集成容器镜像注册表、单点登录支持和新用户界面,并为 Kubernetes 添加了本地镜像构建能力。OKD 是上游开源社区版分发,包含所有 OpenShift 特性。
OpenShift 构建是通过 Kubernetes 直接构建图像的首个集群集成方式。它支持多种构建图像的策略:
源到镜像(S2I)
获取应用程序源代码,并利用特定于语言的 S2I 构建器镜像创建可运行的构件,然后将图像推送到集成注册表。
Docker 构建
使用一个 Dockerfile 加上一个上下文目录,并像 Docker 守护程序一样创建一个镜像。
流水线构建
通过允许用户配置 Tekton 流水线来映射内部管理的 Tekton 的构建作业之间的映射。
自定义构建
为您提供如何创建图像的完全控制权。在自定义构建中,您必须在构建容器内自行创建图像并将其推送到注册表。
执行构建的输入可以来自不同的来源:
Git
通过远程 URL 指定的仓库,从中提取源代码。
Dockerfile
一个 Dockerfile 直接存储为构建配置资源的一部分。
镜像
另一个容器镜像,从中提取文件以进行当前构建。此源类型允许 链式构建,如 Example 30-4 所示。
密钥
为构建提供机密信息的资源。
二进制
来自外部的所有输入的来源。必须在启动构建时提供此输入。
我们可以在构建策略中使用哪些输入来源的选择取决于构建策略。二进制 和 Git 是互斥的源类型。所有其他源可以组合或单独使用。我们稍后将在 Example 30-3 中看到这是如何工作的。
所有构建信息都定义在一个名为 BuildConfig 的中心资源对象中。我们可以通过直接将其应用于集群或使用 CLI 工具 oc(OpenShift 版本的 kubectl)来创建此资源。oc 支持用于定义和触发构建的特定于构建的命令。
在我们查看 BuildConfig 之前,我们需要了解两个与 OpenShift 特定的附加概念。
ImageStream 是一个 OpenShift 资源,引用一个或多个容器镜像。这有点类似于 Docker 仓库,后者也包含具有不同标签的多个镜像。OpenShift 将实际标记的镜像映射到 ImageStreamTag 资源,以便 ImageStream(仓库)具有对 ImageStreamTags(已标记镜像)的引用列表。为什么需要这种额外的抽象?因为它允许 OpenShift 在镜像在注册表中更新为 ImageStreamTag 时发出事件。图像在构建期间创建或将图像推送到 OpenShift 内部注册表时创建。这样,构建或部署控制器可以监听这些事件并触发新的构建或开始部署。
注:
要将 ImageStream 连接到部署,OpenShift 使用 DeploymentConfig 资源,而不是直接使用 Kubernetes 的 Deployment 资源来使用容器镜像引用。但是,您仍然可以通过添加一些OpenShift 特定的注解在 OpenShift 中使用普通的 Deployment 资源与 ImageStreams。
另一个概念是触发器,我们可以将其视为事件的一种监听器。一个可能的触发器是imageChange,它对因 ImageStreamTag 更改而发布的事件做出反应。作为响应,这样的触发器可以导致另一个镜像的重新构建或使用此镜像重新部署 Pods。您可以在OpenShift 文档中了解有关触发器及其可用类型的更多信息。
Source-to-Image
让我们快速看一下 S2I 构建器镜像的样子。我们不会在这里详细展开,但 S2I 构建器镜像是一个标准的容器镜像,其中包含一组 S2I 脚本。它与 Cloud Native Buildpacks 非常相似,但生命周期简单得多,只知道两个必需的命令:
assemble
构建开始时调用的脚本。它的任务是获取一个配置输入源提供的源代码,必要时进行编译,并将最终产物复制到适当的位置。
run
用作此镜像的入口点。OpenShift 在部署镜像时调用此脚本。此运行脚本使用生成的产物来提供应用服务。
可选地,您还可以编写脚本以提供使用消息,保存所谓的增量构建中的生成产物,这些产物可在后续构建运行中由assemble脚本访问,或者添加一些健全性检查。
让我们仔细看看图 30-3 中的 S2I 构建。S2I 构建包含两个组成部分:构建器镜像和源输入。当启动构建时,S2I 构建系统将两者结合在一起——无论是因为接收到触发事件还是手动启动。例如,当构建镜像完成编译源代码时,容器将提交为一个镜像,并推送到配置的 ImageStreamTag。此镜像包含已编译和准备好的构件,并将镜像的run脚本设置为入口点。

图 30-3. 使用 Git 源作为输入的 S2I 构建
示例 30-3 展示了一个简单的 Java S2I 构建,使用了 Java S2I 镜像。该构建接受一个源和构建器镜像,并生成一个推送到 ImageStreamTag 的输出镜像。可以通过oc start-build手动启动它,或者在构建器镜像变更时自动启动。
示例 30-3. 使用 Java 构建器镜像的 S2I 构建
apiVersion: build.openshift.io/v1
kind: BuildConfig
metadata:
name: random-generator-build
spec:
source: 
git:
uri: https://github.com/k8spatterns/random-generator
strategy: 
sourceStrategy:
from:
kind: DockerImage
name: fabric8/s2i-java
output: 
to:
kind: ImageStreamTag
name: random-generator-build:latest
triggers: 
- type: GitHub
github:
secretReference: my-secret
引用源代码以获取;在本例中,从 GitHub 中提取。
sourceStrategy切换到 S2I 模式,并直接从 Docker Hub 中选择构建器镜像。
要更新的 ImageStreamTag 是生成的镜像。这是在运行assemble脚本后提交的构建器容器。
当仓库中的源代码更改时,自动重新构建。
S2I 是创建应用程序镜像的强大机制,比纯 Docker 构建更安全,因为构建过程完全受信任的构建器镜像控制。但是,这种方法仍然存在一些缺点。
对于复杂的应用程序,S2I 可能会很慢,特别是当构建需要加载许多依赖项时。如果没有任何优化,S2I 将在每次构建时重新加载所有依赖项。对于使用 Maven 构建的 Java 应用程序,不存在像本地构建时的缓存。为了避免反复下载半个互联网,建议您设置一个集群内部的 Maven 仓库,用作缓存。然后,必须配置构建器镜像以访问此公共存储库,而不是从远程存储库下载构件。
减少构建时间的另一种方法是使用 S2I 的增量构建,它允许您重用先前 S2I 构建中创建或下载的构件。然而,大量数据会从先前生成的镜像复制到当前构建容器中,性能收益通常不会比使用保存依赖项的集群本地代理更好。
另一个 S2I 的缺点是生成的镜像还包含整个构建环境。^(3)这个事实不仅增加了应用镜像的大小,也增加了潜在攻击的面积,因为构建工具也可能变得脆弱。
为了摆脱不必要的构建工具如 Maven,OpenShift 提供了串行构建,它接收 S2I 构建的结果并创建一个精简的运行时镜像。我们将在“串行构建”中查看串行构建。
Docker 构建
OpenShift 还支持直接在集群内进行 Docker 构建。Docker 构建通过直接挂载 Docker 守护程序的套接字到构建容器中来工作,然后用于docker build。Docker 构建的源是一个 Dockerfile 和一个保存上下文的目录。您还可以使用一个引用任意镜像的Image源,从中可以将文件复制到 Docker 构建上下文目录中。正如下一节中提到的,这种技术与触发器一起可以用于串行构建。
或者,您可以使用标准的多阶段 Dockerfile 来分离构建和运行时部分。我们的示例存储库包含一个完整工作的多阶段 Docker 构建示例,其结果与下一节描述的串行构建相同。
串行构建
图 30-4 展示了串行构建的机制。串行构建包括一个初始的 S2I 构建,该构建创建运行时的构件,如二进制可执行文件。这个构件然后被第二个构建所使用,通常是一个 Docker 构建。

图 30-4. 使用 S2I 进行编译和 Docker 构建创建应用镜像的串行构建
示例 30-4 展示了第二个构建配置的设置,该配置使用在示例 30-3 中生成的 JAR 文件。最终推送到 ImageStream random-generator-runtime的镜像可以在 DeploymentConfig 中用于运行应用程序。
注意
在示例 30-4 中使用的触发器监视 S2I 构建的结果。这个触发器导致每次运行 S2I 构建时都重新构建这个运行时镜像,以便保持两个 ImageStream 始终同步。
示例 30-4. 用于创建应用镜像的 Docker 构建
apiVersion: build.openshift.io/v1
kind: BuildConfig
metadata:
name: runtime
spec:
source:
images:
- from: 
kind: ImageStreamTag
name: random-generator-build:latest
paths:
- sourcePath: /deployments/.
destinationDir: "."
dockerfile: |- 
FROM openjdk:17
COPY *.jar /
CMD java -jar /*.jar
strategy: 
type: Docker
output: 
to:
kind: ImageStreamTag
name: random-generator:latest
triggers: 
- imageChange:
automatic: true
from:
kind: ImageStreamTag
name: random-generator-build:latest
type: ImageChange
Image 源引用了包含 S2I 构建运行结果的 ImageStream,并选择了镜像中包含已编译 JAR 存档的目录。
Dockerfile 源用于从 S2I 构建生成的 ImageStream 复制 JAR 存档的 Docker 构建。
strategy选择了一个 Docker 构建。
当 S2I 结果 ImageStream 变化后自动重新构建 —— 在成功的 S2I 运行后编译 JAR 归档。
注册监听器以获取图像更新,并在 ImageStream 添加新图像时重新部署。
您可以在我们的示例存储库中找到带有安装说明的完整示例。
如前所述,OpenShift 构建以及其最突出的 S2I 模式是在 OpenShift 集群内安全构建容器镜像的最古老和最成熟的方式之一。
讨论
在集群内构建容器镜像的两种方式已经介绍完毕。普通构建 Pod 展示了每个构建系统需要执行的最关键任务:从源代码获取、从您的源代码创建可运行的 artifact、创建包含应用程序 artifact 的容器镜像、将此镜像推送到镜像注册表,最后更新任何部署,以从该注册表中获取新创建的镜像。这个例子并不适合直接生产使用,因为它包含太多现有构建编排工具更有效地处理的手动步骤。
OpenShift 构建系统很好地展示了在同一集群中构建和运行应用程序的主要优势之一。通过 OpenShift 的 ImageStream 触发器,您可以连接多个构建并重新部署您的应用程序,如果一个构建更新了您应用程序的容器镜像。构建与部署之间更好的集成是持续交付的重要一步。OpenShift 使用 S2I 的构建是一种经过验证和成熟的技术,但只有在使用 Kubernetes 的 OpenShift 分发时才能使用 S2I。
截至 2023 年的集群内构建工具的景观非常丰富,并包含许多令人兴奋的技术部分重叠。因此,您可以预期会有一些整合,但随着时间的推移,新的工具将会出现,因此我们将看到更多实现镜像构建器模式的实现。
更多信息
-
镜像构建器:
-
构建编排器:
^(1) CNB 覆盖更多阶段。整个生命周期在构建包网站上有详细解释。
^(2) 在这里,我们也可以再次选择一个初始化容器,并使用一个无操作的应用程序容器,但由于应用程序容器仅在所有初始化容器完成后才启动,因此放置容器的位置并不重要。在所有情况下,三个指定的容器都是依次运行的。
^(3) 这与云原生构建包不同,后者在其堆栈中使用单独的运行时镜像来承载最终的构件。
第三十二章:结语
Kubernetes 是在规模上部署和管理容器化分布式应用程序的领先平台。然而,这些集群内应用程序依赖于集群外的资源,包括数据库、文档存储、消息队列和其他云服务。Kubernetes 并不仅限于管理单个集群内的应用程序。Kubernetes 还可以通过各种云服务的操作者编排集群外资源。这使得 Kubernetes API 不仅可以成为集群内容器的期望状态的唯一“真相源”,还可以成为集群外资源的期望状态的唯一“真相源”。如果您已经熟悉 Kubernetes 模式和操作应用程序的实践,您可以利用这些知识来管理和使用外部资源。
Kubernetes 集群的物理边界并不总是符合所需的应用程序边界。组织经常需要在多个数据中心、云和 Kubernetes 集群之间部署应用程序,出于各种原因,如扩展、数据局部性、隔离等。通常,同一应用程序或一组应用程序必须部署到多个集群中,这需要多集群部署和编排。Kubernetes 经常嵌入在各种第三方服务中,用于在多个集群中操作应用程序。这些服务利用 Kubernetes API 作为控制平面,每个集群作为数据平面,使 Kubernetes 能够跨多个集群扩展其功能。
如今,Kubernetes 已经超越了仅仅是一个容器编排器的角色。它能够管理集群内、集群外和多集群资源,使其成为一种多功能且可扩展的操作模型,用于管理多种资源。其声明式 YAML API 和异步协调过程已经成为资源编排范式的代名词。其自定义资源(CRDs)和操作者(Operators)已成为合并领域知识与分布式系统的常见扩展机制。我们相信,大多数现代应用程序将运行在提供 Kubernetes API 或受 Kubernetes 抽象和模式强烈影响的运行时平台上。如果你是一个开发这类应用程序的软件开发人员,你必须精通现代编程语言,以实现业务功能,同时也要了解云原生技术。Kubernetes 模式将成为整合应用程序与运行时平台的必备常识。熟悉 Kubernetes 模式将使您能够在任何环境中创建和运行应用程序。
我们覆盖的内容
在本书中,我们涵盖了 Kubernetes 中最流行的模式,分组如下:
-
基础模式代表容器化应用必须遵循的原则,以成为良好的云原生应用。无论应用的性质和你可能面临的约束是什么,你都应该力求遵循这些准则。遵循这些原则将有助于确保你的应用程序适合在 Kubernetes 上进行自动化。
-
行为模式描述了 Pod 与管理平台之间的通信机制和交互方式。根据工作负载的类型,一个 Pod 可能会作为批处理作业运行直至完成,或者定期调度运行。它可以作为无状态或有状态服务运行,也可以作为守护服务或单实例运行。选择正确的管理原语将帮助您以所需的保证运行 Pod。
-
结构模式专注于在 Pod 中结构化和组织容器,以满足不同的使用场景。拥有良好的云原生容器是第一步,但这并不足够。重用容器并将它们组合成 Pod 以实现期望的结果是下一步。
-
配置模式涵盖了在云上为不同配置需求定制和调整应用程序的过程。每个应用程序都需要进行配置,没有一种方法适合所有情况。我们从最常见的模式到最专业化的模式进行探索。
-
安全模式描述了如何在与 Kubernetes 交互时限制应用程序。容器化应用程序也有安全维度,我们涵盖了与节点的应用程序交互、与其他 Pod 的交互、Kubernetes API 服务器以及安全配置。
-
高级模式探索了不适合其他类别的更复杂的主题。其中一些模式,如控制器,已经很成熟——Kubernetes 本身就是建立在它之上的——而一些模式仍在不断发展,可能在你阅读本书时已经发生了变化。但这些模式涵盖了云原生开发人员应熟悉的基本思想。
最后的话
就像所有好东西一样,这本书也已经结束了。我们希望你喜欢阅读本书,并且它改变了你对 Kubernetes 的看法。我们真诚地相信,Kubernetes 及其衍生的概念将像面向对象编程概念一样基础。这本书是我们尝试创建《设计模式》四人帮的容器编排版本。我们希望这不是结束,而是你的 Kubernetes 之旅的开始;对我们来说,它确实如此。
祝你在使用 kubectl 时愉快。


浙公网安备 33010602011771号