Kubernetes-开发者指南-全-

Kubernetes 开发者指南(全)

原文:Kubernetes for Developers

译者:飞龙

协议:CC BY-NC-SA 4.0

前置内容

前言

在 2016 年的圣诞节假期,我坐下来学习 Kubernetes。我很快就要加入 Google Kubernetes Engine (GKE) 团队,需要快速了解这个平台的工作原理。我之前使用过几个平台即服务 (PaaS) 环境,以可扩展的方式部署无状态应用程序,我对 Kubernetes 提供的机会感到兴奋:能够运行任何可以打包进容器的灵活性,以及从无状态到有状态应用程序和批处理作业的各种工作负载结构的通用性。像许多人一样,我发现入门时有一点点学习曲线。

到 2017 年初,我已经积累了一些知识,现在我是 GKE 团队的产品经理,经常与开发者互动。其中一位开发者是我的兄弟,他正在将他的初创公司从 PaaS 迁移到 Kubernetes。我能够分享我所学到的一切,并希望帮助他避免我犯的一些新手错误。但我希望有一种更简单的方式来传递这些知识:专门为那些希望部署到生产级系统而不需要一开始就学习整个系统的应用开发者编写的内 容。

这本书本质上是我希望那些时刻拥有的书。我不假设读者对 Kubernetes 或甚至容器有任何先前的知识。本书通过引导你开发并部署一个简单的容器化应用程序,从在线启动和运行开始,然后逐渐添加额外的 Kubernetes 概念,如有状态应用程序和后台任务队列,来构建你的知识体系。

我的希望是给你信心,让你能够将你的应用程序部署到 Kubernetes 上,同时挑战“Kubernetes 很难”的观念。它确实是一个庞大而复杂的系统,但正如我希望说服你的那样,它的广泛范围为你提供了许多强大的功能,当你有复杂的工作负载要部署时,这些功能将非常有用。但那可以稍后再说。你不需要一次性学习所有内容!事实上,部署无状态应用程序相当简单,所以从那里开始,在你需要的时候再添加其他内容。

到本书结束时,你可能会欣赏这个平台的灵活性,当你有那些在更多专用平台(如许多 PaaS 环境)上难以满足的要求时,我相信你会这样。

写技术书让我有些紧张,尤其是考虑到这需要多年的时间,我担心技术会变化太多,部分内容会过时,我不得不不断重写以跟上进度。幸运的是,我可以报告说,关于我所写的每一个普遍可用(GA)的概念,特别是具有 v1 版本的 Kubernetes API,内容都保持最新。这次经历让我对 Kubernetes 在您自己的项目中使用的稳定性以及这本书的持久性都充满了希望。在我撰写内容时,我涵盖了一些非 GA API,它们确实发生了很大的变化。幸运的是,在我完成写作时,所有内容都已达到 GA,我能够更新内容以使用这些稳定的版本(基于这一点,我也会尽量避免使用 beta API,除非是为了实验)。

当我加入 GKE 团队时,Kubernetes 正在迅速获得人气,但尚未无处不在。我记得我对能够参与看起来是开源软件的关键部分感到兴奋,但同时也有一些不确定性,不知道事情会如何发展(因此,有机会以积极的方式产生影响)。好吧,在那段时间里,我们基本上看到每个云平台都提供了自己的托管平台(幸运的是,它们之间具有高度的兼容性,并且有一种维护软件兼容性的文化),Kubernetes 也成为了人们在生产中编排容器的首选方式。我很享受作为 Kubernetes 社区的一员,并参与这样一个关键的计算平台。我希望这本书能帮助你在自己的 Kubernetes 之旅中取得成功!

致谢

首先,我想感谢我的开发编辑 Elesha Hyde,他教会了我如何将技术内容编织成故事,并对最终产品产生了重大影响。我要感谢我的技术发展编辑 Shawn Smith,感谢您对早期草稿的详细审查和您的建议,这有助于完善叙事。感谢 Michael Stephens,四年前当我向您推销这本书时,您对我写作的信心。还要感谢 Manning 出版社的每一个人,是你们帮助这本书成为现在的样子:Deirdre Hiam,我的项目编辑;Alisa Larson,我的校对编辑;以及 Mike Beady,我的校对员。在开始这个项目之前,我并不知道与你们所有人合作会对书籍的质量产生多大的贡献,但很快这一点就变得明显了。

向所有审稿人致谢:阿马尔·马尼甘丹、安德烈斯·萨科、阿里埃尔·加米诺、阿图尔·S·科特、贝基·胡特、邦妮·马莱克、查斯·西利维斯、克里斯·亨尼根、克里斯·维纳、康纳·雷德蒙德、大卫·帕库德、达维德·菲奥伦蒂诺、迪皮卡、乔治·托马斯、朱利奥·拉蒂尼、格雷戈里奥·皮科利、盖·恩杰恩、汉斯·多纳、哈里纳特·马莱帕利、伊奥安尼斯·波利佐斯、贾维德·阿萨罗夫、胡安·吉梅内斯、朱利恩·波希、卡梅什·加内桑、卡蒂凯亚拉贾恩·拉贾恩、凯卢姆·普拉巴瑟·塞纳纳亚克、凯文·约翰逊、科斯马斯·查齐米查利斯、克日什托夫·卡米切克、马克·德尚普斯、迈克尔·布莱特、迈克·赖特、米切尔·福克斯、纳吉布·阿里夫、皮埃尔-米歇尔·安塞尔、拉胡尔·莫杜普、拉贾谢兰·加内斯瓦兰、拉姆巴布·波萨、理查德·沃恩、罗伯特·基尔蒂、萨塔杜鲁·罗伊、塞巴斯蒂安·捷克、塞尔吉乌·波帕、西梅翁·莱泽宗、苏哈斯·克里什纳亚亚、巴希鲁丁·艾哈迈德、文卡特什·桑达拉莫里、瓦尔德马尔·莫德泽列夫斯基、扎兰·索莫盖瓦里,你们的建议帮助使这本书变得更好。

我非常感谢曼宁的技术团队,他们构建了强大的工具,为曼宁早期访问计划(MEAP)提供动力,这意味着我可以在内容仍在进行中时分享内容,并直接从我的早期读者那里获得反馈。还要感谢在书还只完成了一半时通过 MEAP 购买这本书的每个人。

感谢我的工作团队:德鲁·布拉德斯托克和约查伊·基里亚蒂,他们相信我并支持我全身心投入这个 120%的项目,以及你们创造的团队环境,让做事情变得有趣。感谢蒂姆·霍金、陈·戈德堡、凯尔西·海托沃、埃里克·布赖尔、杰里米·奥尔斯泰德-汤普森、杰齐·福里西亚尔茨、布莱恩·格兰特,他们对我影响深远,以及阿帕尔娜·辛哈,她早期就对我下了赌注。

托姆·爱德华兹是一位摄影师和站点可靠性工程师,他在一次 KubeCon 期间为我拍摄了个人照片;请查看他的作品在tomedwardsphotography.com。谢谢,兄弟!

最后,感谢我的家人支持我完成这个项目。阿伦和艾希莉,尽管你们太小,不知道我在做什么,但每天我们一起走到 Imua,我可以在你们附近玩耍的同时,安静地工作在稿件上,这些时刻我将永远珍视。感谢我的妻子,菲奥娜,感谢你支持我所有的努力,在我需要的时候给我现实感。感谢先琼,感谢你支持我们的家庭——没有你的帮助,我几乎没时间做任何事情。感谢朱莉、格雷厄姆和劳拉,感谢你们从小培养我对技术的兴趣,以及我的兄弟姐妹,杰西卡和彼得,感谢你们始终相互支持,以及我们的各种努力。

关于这本书

您是否想在云中托管一个应用程序,在云环境中拥有处理您需求变化的能力,并具有大规模扩展的潜力?本书将为您提供所需的知识,无论应用程序是 Python、Java、Ruby 还是其他任何语言,都可以自信地将它部署到云中,在专业级平台上使用容器和 Kubernetes 满足您现在的需求,并适应未来的发展。

如果您没有准备好的应用程序可以部署,本书提供了一个供您使用的示例应用程序。无需具备 Docker 或 Kubernetes 的先验知识。本书结束时,您应该有信心在生产环境中部署工作负载到 Kubernetes,从无状态应用程序到批处理作业和数据库。

适合阅读本书的人群

目前市面上有许多关于 Kubernetes 的书籍,它们针对不同的受众。本书专门为开发者编写,旨在涵盖一段旅程,类似于:“我笔记本电脑上有一堆代码。我如何将其发布到世界,并且在一个如果我的产品成为热门产品就可以扩展的平台上进行发布?”当我学习 Kubernetes 时,我希望有一个端到端的演示,展示如何将我的代码放入 Kubernetes,更新,平稳运行而无需干预,并在需要时准备好扩展。我希望这本书能为您做到这一点。

如果您对容器不熟悉,无需担心,因为本书包含了一章关于应用程序容器化的内容,这样您就可以准备好部署。如果您已经熟悉 Docker 和容器,并希望立即开始部署到 Kubernetes,您可以直接跳到第三章。

本书是如何组织的

本书分为两部分。第一部分旨在向您介绍 Kubernetes 的基础知识,从构建容器并在 Kubernetes 中运行它们开始。您将学习如何设置正确的资源并配置它们以充分利用 Kubernetes 的自动化操作,当然,还包括如何更新您的应用程序。如果您目标是部署无状态应用程序到 Kubernetes,这可能就是您所需要的:

  • 第一章是 Kubernetes 及其优势的高级概述。

  • 第二章是针对应用程序开发者的 Docker 速成课程。如果您已经熟悉,可以自由跳过。

  • 第三章将帮助您开始您的第一个 Kubernetes 部署。部署、暴露给互联网,并更新。现在您已经通过 Kubernetes 上线了。

  • 第四章增加了对更可靠部署的必要健康检查。

  • 第五章帮助您合理调整工作负载,使其获得所需的资源而不会浪费。

第二部分更深入地探讨了 Kubernetes 的生产方面。您将学习关于扩展应用程序、配置内部服务以及部署工作负载结构,如有状态应用程序和后台处理队列。本书通过涵盖配置即代码、持续部署和安全考虑来结束。

  • 第六章是关于扩展(和缩减):手动、自动化、节点和 Pods——这里都有。

  • 第七章讨论了如何配置内部服务和微服务架构,并介绍了基于 HTTP 的负载均衡。

  • 第八章介绍了如何在您的负载中指示容器的特定硬件要求,以及如何在节点上分组或分散 Pods。

  • 第九章帮助您设置有状态的工作负载,如数据库。

  • 第十章是关于请求/响应链之外发生的所有活动,包括任务队列和批量作业。

  • 第十一章是 GitOps 的介绍,涵盖了如何为不同环境使用命名空间,并将配置视为代码。

  • 第十二章以几个值得开发者考虑的安全主题结束本书。

关于代码

本书中的代码在 Apache 2.0 许可下是开源的,可以在github.com/WilliamDenniss/kubernetes-for-developers上完整下载。

示例按章节编号组织到文件夹中。Kubernetes 配置可以在以章节或子章节命名的子文件夹中找到,以及一个描述性后缀。例如,出现在第三章第 3.2 节的配置可以在 Chapter03/3.2_DeployingToKubernetes 中找到。示例容器化应用程序在全书构建,可以在相关章节文件夹中找到。例如,它首先出现在第二章,位于 Chapter02/timeserver,并在第四章更新为 Chapter04/timeserver2,等等。

每个代码列表都以文件的完整路径命名,便于参考。示例命令,贯穿全书,从基础文件夹开始,因此您可以按顺序外阅读;只需在开始新部分时将命令行 shell 重置到基础目录,然后就可以继续了。

Mac、Linux 和 Windows 都可以用来运行本书中的示例,设置说明在第二章和第三章中提供。

对于 Google Cloud 用户,可以使用基于浏览器的 Cloud Shell(https://cloud.google.com/shell)来运行本书中的每个示例指令,包括使用 Docker 和 minikube 的示例,无需在本地安装任何东西,除了 Docker Desktop 的本地 Kubernetes 环境(请按照 minikube 的说明操作)。

本书包含许多源代码示例,既有编号列表,也有与普通文本并列的。在两种情况下,源代码都以固定宽度字体如这样格式化,以将其与普通文本区分开来。有时代码也会加粗以突出显示章节中从先前步骤更改的代码,例如当新功能添加到现有代码行时。

在许多情况下,原始源代码已被重新格式化;我们添加了换行符并重新调整了缩进,以适应书籍中的可用页面空间。在极少数情况下,即使这样也不够,列表中还包括了行续接标记(➥)。此外,当代码在文本中描述时,源代码中的注释通常已从列表中删除。许多列表旁边都有代码注释,突出显示重要概念。

您可以从本书的 liveBook(在线)版本中获取可执行的代码片段,网址为 livebook.manning.com/book/kubernetes-for-developers。书中示例的完整代码可在 Manning 网站 www.manning.com/books/kubernetes-for-developers 和 GitHub github.com/WilliamDenniss/kubernetes-for-developers 上下载。

liveBook 讨论论坛

购买《Kubernetes for Developers》包括免费访问 liveBook,Manning 的在线阅读平台。使用 liveBook 的独家讨论功能,您可以在全球范围内或针对特定章节或段落附加评论。为自己做笔记、提出和回答技术问题,以及从作者和其他用户那里获得帮助都非常简单。要访问论坛,请访问 livebook.manning.com/book/kubernetes-for-developers/discussion。您还可以在 livebook.manning.com/discussion 上了解更多关于 Manning 的论坛和行为准则。

曼宁对读者的承诺是提供一个平台,在这个平台上,读者之间以及读者与作者之间可以进行有意义的对话。这并不是对作者参与特定数量活动的承诺,作者对论坛的贡献仍然是自愿的(且未支付报酬)。我们建议您尝试向作者提出一些挑战性的问题,以免他的兴趣转移!只要书籍在印刷中,论坛和先前讨论的存档将可通过出版社的网站访问。

其他在线资源

作者关于 Kubernetes 和 GKE 主题的更多内容可在 wdenniss.com/kubernetes 找到。

本书中的图示所使用的资源图标来自 Kubernetes 作者的 Kubernetes 图标集,许可协议为 CC BY 4.0。它们可以在github.com/kubernetes/community/找到。

Kubernetes 标志由 Kubernetes 作者设计,并许可协议为 CC BY 4.0。它可以在github.com/kubernetes/kubernetes/与 Kubernetes 本身的源代码一起下载。

关于作者

William Denniss 是谷歌的产品经理,他在谷歌 Kubernetes Engine (GKE) 上工作。他与他人共同创立了 GKE 的 Autopilot 体验,构建了一个完全管理的 Kubernetes 平台,该平台提供完整的 Kubernetes 体验,无需管理底层计算节点。他坚信开放标准和开源软件可以推动行业发展,他在 GKE 团队的第一个项目是与 Kubernetes 社区和云原生计算基金会合作,创建认证 Kubernetes 兼容性计划,以鼓励 Kubernetes 提供商之间的广泛兼容性。

2014 年加入谷歌,他最初在身份空间工作,目标是改善用户在移动设备上与身份系统交互的方式。他编写了目前最佳实践 OAuth for Native Apps,作为 RFC 8252 发布,并共同创立了开源库 AppAuth,用于 iOS、Android 和 JavaScript,以提供该最佳实践的共同实现。

他喜欢通过教学来学习,并花费大量业余时间编码和迭代各种项目。如果他有设备,他很可能正在为它编写代码,无论是高中时的图形计算器,运行 Windows、Linux 或 Mac 的计算机(在各个时期),2000 年代的 PlayStation Portable,还是自 iPhone 3G 以来使用的 iPhone。最终,项目需要服务器组件,这首先让他十年前开始在平台即服务(PaaS)上部署代码,并激发了他后来在有机会时从事 Kubernetes 的工作的兴趣,以帮助使具有类似要求的开发者生活更加轻松。

他的产品管理超级能力是成为他构建的产品热情的用户。

关于封面插图

《Kubernetes for Developers》封面上的图像是由 Dominic Serres 创作的插图,标题为“一位与战舰船员一起的船员”,创作于 1777 年。这幅艺术品属于描绘英国皇家海军制服的图像系列,从本插图中的水手到中尉等级,再到海军上将。插图中的水手穿着一件短蓝色夹克,配有布覆盖的按钮, scalloped 海员袖口,三粒扣闭合,以及一件朴素的白色背心。他还穿着从腰部延伸到小腿顶部的裙子裤子,朴素的白色长袜,以及带有白色金属搭扣的鞋子。

在难以区分一本计算机书籍与另一本的时候,Manning 通过基于历史人物和过去服饰的丰富多样性设计的书封面,庆祝了计算机行业的创新精神和主动性,就像这样的插图一样,将它们重新带回生活。

第一部分:Kubernetes 入门

Kubernetes 是一个用于编排容器的流行平台。本书的第一部分揭示了 Kubernetes 是什么以及为什么它有用。它还涵盖了基础知识,例如如何将应用程序容器化以便在环境中使用,以及如何将您的第一个容器化应用程序部署到 Kubernetes 中并在互联网上使其可用。您将学习如何配置应用程序以利用 Kubernetes 自动化,确保一切正常运行而无需停机,包括在应用程序更新期间,以及如何确定分配给每个容器的资源。

到最后,您将拥有一个在 Kubernetes 中运行且可供全球访问的无状态容器化应用程序(如果您希望如此)。您将了解如何配置它,以便 Kubernetes 可以在您无需密切监控的情况下保持其在线状态,即使您的应用程序崩溃或集群升级。

您可能听说过 Kubernetes 使用起来有点复杂,这在很大程度上是由于系统具有大量功能。我希望在这一部分向您展示,实际上将简单的无状态应用程序部署到 Kubernetes 并将其实时发布是非常容易的。您不需要在第一天就学习 Kubernetes 的所有复杂功能,尽管了解它们的存在在您需要时是件好事。部署无状态应用程序是一个很好的起点。

1 Kubernetes 用于应用部署

本章涵盖

  • 将应用打包在容器中的好处

  • Kubernetes 为何是部署容器的理想平台

  • 决定何时使用 Kubernetes

周五下午 5 点,你过去一年一直在工作的产品突然走红。你需要快速扩展一切。你的应用及其运行的平台能否扩展 100 倍,准备好捕捉你的成功,还是你将陷入脆弱的代码和缺乏弹性的平台,这意味着你所有的努力都将付诸东流?

你在流行的应用平台上构建了一个令人惊叹的无状态应用,该平台能够快速扩展,一切运行良好。然而,有一天,你的业务需求发生了变化,你突然需要运行一个定制的有状态应用来处理一些关键业务数据或配置一个夜间批量处理管道。这些新工作负载能否无缝地与现有的工作负载结合,还是你需要从头开始或拼凑多个不同的系统?

Kubernetes 正迅速成为运行各种形状和大小的工作负载的行业标准,通过承诺解决这些担忧以及更多问题。它使你能够以快速扩展的能力启动容器化应用,同时,它还能处理从无状态应用到有状态数据库、具有短暂存储的批量作业等各种复杂的部署模式。Kubernetes 是在谷歌发明、开源并由 Spotify¹、CapitalOne²、OpenAI³等无数组织使用⁴的,它是一个开放、供应商无关且经过充分验证的平台,对于云部署来说,就像 Linux 对于操作系统一样。

然而,所有这些灵活性都伴随着一定的学习曲线。作为一个能够处理如此多不同部署结构的通用平台,Kubernetes 的学习可能会让人感到畏惧。不过,我要告诉你的是,(a)它并没有人们想象中那么难,(b)学习它是值得的。如果你从基础知识开始,并逐渐添加新的结构(这正是本书的结构),它就会变得更容易接近。你可以用几行 YAML 部署一个无状态应用,并从那里开始构建你的知识。

作为一名专业人士,当你面临问题,比如如何最好地部署你的应用时,我相信正确的答案并不总是选择可以解决你当前问题的最简单选项,而是投入时间去学习一个能够满足你当前和未来需求、让你随着需求的发展而发展技能和职业的平台。Kubernetes 就是这样一种平台。你可以通过一些简单的部署在几小时内启动并运行,同时知道有大量的功能可供你学习和应用,当你需要时,无论何时。

如果您已经接受了 Kubernetes 的概念,我建议跳到第二章开始构建 Docker 镜像,如果您已经知道 Docker 容器是什么,并想开始部署到 Kubernetes,请直接跳到第三章。本章的其余部分将涵盖为什么 Kubernetes 和容器对于应用程序部署如此受欢迎的原因。

1.1 为什么选择容器?

Kubernetes 是一个容器部署平台。所有部署到 Kubernetes 中的代码,就像您的应用程序一样,需要首先打包成容器。什么是容器,为什么要费心去使用它们呢?

容器是打包和运行应用程序的现代方式。除非您在每个主机上运行一个应用程序(这相当低效),否则您通常希望有一种方法将多个应用程序部署到一台机器或一组机器上。有哪些选择?

在虚拟机(VMs)出现之前,通常是将每个应用程序安装到共享主机上的不同目录中,每个应用程序都在单独的端口上提供服务。这带来了一些问题,因为各种应用程序在共享依赖项和机器资源(如 CPU、内存和可用端口)时需要相互协作。这也可能难以扩展:如果您有一个应用程序突然接收了更多流量,您如何仅扩展该应用程序,而让其他应用程序保持不变?

最近,随着虚拟机(VMs)的出现,解决方案是将每个应用程序打包成独立的虚拟机。这样,每个应用程序都有自己的操作系统环境,以便可以隔离依赖项并分配资源。然而,由于每个虚拟机都具有独立主机的复杂性,因此现在您需要为每个应用程序维护操作系统和所有包,这具有很高的开销且难以维护。

这就引出了容器。容器是一种将您的应用程序及其所需的依赖项打包到隔离环境中托管的方法,类似于虚拟机,但无需为应用程序安装和管理操作系统。

图 1.1 展示了托管服务的演变,从在单个主机上运行多个工作负载,到在单独的虚拟机上运行,最后到容器。容器提供了虚拟机的大部分优势,但无需运行另一个操作系统内核,这使得它们成为逻辑上的现代前进路径。

01-01

图 1.1 共享托管架构的演变

1.1.1 容器优势

人们选择容器的一些主要原因包括语言灵活性(能够在容器平台上运行任何语言或环境)、轻量级隔离(在不使用虚拟机的情况下保护工作负载免受彼此干扰)、开发者效率(将生产环境更接近开发环境并允许轻松设置)以及可重复性(在容器构建文件中记录创建环境的步骤)。

语言灵活性

容器使你摆脱了部署系统中的语言或库要求。你可以带来任何语言并更新任何包。你不再被锁定在特定的语言和版本中,或者被一些可能在操作系统几年前的版本中发货的关键依赖项所困扰,就像你可能在传统的平台即服务(PaaS)平台上一样。

在同一主机上运行的两个容器之间没有共享库,这意味着一个容器的配置不会干扰另一个。需要两个不同版本的 Java 或一些随机依赖项?没问题。这种隔离不仅限于容器的库:每个容器都可以使用完全不同的基础操作系统和包管理器——例如,一个使用 Ubuntu 和 APT,而另一个使用 CentOS 和 RPM。这种灵活性使得从多个服务(称为微服务的模式)中构建系统变得简单,每个服务由不同的团队维护,具有自己的依赖项或语言。容器将这些不同的应用程序依赖项相互隔离,使得在同一个主机上运行它们变得简单(图 1.2)。

01-02

图 1.2 具有不同语言的四个容器共享主机

无开销的隔离

在过去,为了在同一个主机上运行多个应用程序之间的隔离,你会使用虚拟机(VMs)。虚拟机在镜像大小和 CPU/内存资源开销方面都比较重,因为每个虚拟机都会复制内核和大部分操作系统。虽然容器比虚拟机轻,但它们仍然提供了大部分相同的资源隔离优势。你可以在 Kubernetes 上将容器限制为仅使用主机的一些资源,系统将限制它们使用更多资源。这最终意味着你可以在单个主机上打包更多的应用程序,从而降低你的基础设施成本(图 1.3)。

01-03

图 1.3 在同一主机上运行的四个容器,完全隔离但共享内核

开发者效率

使容器通过隔离依赖项在生产中变得出色的特性,也使它们在开发中变得出色,因为你可以在一台机器上开发无数的应用程序,而无需为每个应用程序配置主机的依赖项(图 1.4)。除了直接在 Linux 上开发 Linux 应用程序外,使用 Docker,你还可以使用 macOS 或 Windows 工作站来开发 Linux 容器,而无需为这些平台创建应用程序的原生版本,从而消除了开发中的平台特定配置。

01-04

图 1.4 具有两个基于容器的项目的开发机器

现在您也不再需要为开发者提供大量的设置说明来开始工作,因为设置现在就像安装 Docker、检出代码、构建和运行一样简单。在团队内部或为不同团队工作多个项目现在也变得简单,因为每个项目都很好地隔离在其容器中,无需特定的主机配置。

使用容器,您的开发和生产应用程序看起来非常相似,甚至可以是完全相同的容器——不再有开发特定的特性阻碍,比如 MacOS 有不同的 MySQL 库或代码打包生产时的微妙差异。试图诊断生产问题?下载那个确切的容器,在您的开发环境中运行它,看看出了什么问题(图 1.5)。

01-05

图 1.5 同一个容器在生产环境和开发环境中的应用

可重现性

容器还使您更容易重现应用程序环境。想象一下,您有一个部署了应用程序的虚拟机,您需要为安全的 HTTPs 连接配置传输层安全性(TLS)。您通过 SSH 连接到生产主机,并将 TLS 证书添加到一个文件夹中。它不起作用,所以您将其添加到另一个文件夹。很快,它们就在三个文件夹中,而且它开始工作了,所以您没有去动它。一年后,您需要更新 TLS 证书。您能记得如何操作,以及三个位置中的哪一个需要更新吗?

容器解决了这个问题。而不是通过 SSH 和调整状态,您会在容器中添加 TLS 证书作为构建步骤。如果不起作用,您会调整那个构建步骤直到它起作用,但关键是要只保留真正起作用的步骤(或步骤)。在这个步骤中添加的文件也很好地与系统其他部分隔离,所以本质上您是在捕捉基于系统的增量,或差异——只是您需要做的那些修改。这意味着一年后当您需要更新证书时,您只需替换证书文件并重新运行容器构建,它就会放在正确的位置。

下面的列表提供了一个 Dockerfile 的伪代码示例——即用纯英语表达的配置容器的代码(在第二章中,我们将使用一些真实的例子)。

列表 1.1 第一章/1.1.1_ 伪代码 Dockerfile

Use the Ubuntu OS
Copy and configure TLS certificate
Copy the application

注意,将 Docker 作为创建容器的工具并不完美地适用于可重复性。例如,使用apt-get安装依赖项的命令是在一个实时系统上运行的,因此你实际上不会为相同的输入获得相同的输出,因为那些依赖系统(如 APT 仓库)可能在构建之间发生了变化。像 Bazel 这样的工具,由谷歌开源,旨在解决这个问题以及更多,但它们也带来了自己的复杂性,并且更推荐用于复杂的企业部署。尽管存在这种限制,但 Docker 的构建系统仍然比试图回忆一年前当你通过 SSH 连接到那个 Linux 盒子来修复问题时所做的事情要强得多,而且对于大多数项目来说已经足够好了。

1.2 为什么选择 Kubernetes?

如果容器听起来像是一个打包您应用程序的好主意,你仍然需要一种实际运行和管理这些容器的方法。当然,你可以在每个主机上运行一个或几个容器,就像从文件夹或虚拟机镜像中运行多个不同的应用程序一样,但以这种方式运行往往会创建特殊的机器,并限制了你的扩展能力,因为配置和管理主机需要大量的手动操作。

更好的选择是拥有一个共享的机器(节点)池(集群),并使用所谓的容器编排器(如 Kubernetes)来运行资源池上的容器。这样,机器作为一个组一起管理,其中没有任何一个需要赋予任何特殊意义。如果其中一个失败了,另一个会来填补空缺。这种模式让你摆脱了单机业务,并允许你比团队规模更快地扩展你的应用程序。

以前,能够在大规模上灵活编排容器的系统是大型公司的专属。特别是,公共云上的托管 Kubernetes 服务使得这种操作模式对所有规模的项目都变得可访问,从在一台机器上运行的单个容器应用程序到由不同团队在 15000 台机器的巨兽上运行的多个微服务集合。

Kubernetes 还使得为您的应用程序实现高可用性变得容易。如图 1.6 所示,我们可以在多个可用区部署相同的服务,即使整个区域丢失也不会导致停机。使用手动部署系统,这可能很复杂,但我们可以通过简单地定义我们想要看到的内容(在这种情况下,跨多个区域的容器)在 Kubernetes 中快速实现这种部署模式。第 8.2.1 节对此进行了介绍。

01-06

图 1.6 一个在三个区域中运行的 Kubernetes 集群,管理四个服务

最好的部分是,在 Kubernetes 中更新服务只需要更改一行配置,Kubernetes 会根据你的要求自动将更新部署到每个区域。Kubernetes 平台本身的更新也以类似、自动化的方式进行(前提是你使用的是托管平台,它会处理这一点),节点会逐渐被更新版本所取代,你的工作负载会被迁移以避免停机。如果你的应用程序规模不大,不需要高可用性的多区域部署,无需担心:Kubernetes 也可以在小规模上运行,并且当你需要时可以扩展。

Kubernetes 因其自动化了在资源池上调度和运行容器的许多操作方面而受到欢迎,并为开发者提供了看似恰到好处的抽象级别。它既不是那么低级,以至于你担心单个机器,同时也不是那么高级,以至于限制了你可以部署的工作负载。

1.2.1 可组合的构建块

在 Kubernetes 中,容器被分组为所谓的 Pods。Pod 简单地是一组被一起调度并被视为单一单元的容器。通常,Pod 只是一个容器,但在你的应用程序由多个连接部分组成的情况下,它也可能是多个容器。从概念上讲,Pod 是你的应用程序及其依赖项。服务用于向 Pod 组提供连接性,无论是在集群内部还是外部。图 1.7 展示了部署到 Kubernetes 集群中的典型应用程序的资源。

01-07

图 1.7 一个运行着两个不同应用容器的 Kubernetes 集群,通过负载均衡暴露

Kubernetes 有几个更高阶的工作负载构建块,本书中会对其进行描述,这些构建块封装了 Pods。对于无状态应用程序,你会创建一个 Deployment 对象,它封装了 Pod 定义(指定你的容器版本),在这里你指定你想要多少个副本(实例)。在这些所有情况下,Kubernetes 会为你完成繁重的任务,在集群中找到空间来放置 Pods,以满足你的要求。

你可以在 Kubernetes 配置中描述的工作负载类型范围广泛,包括以下内容:

  • 无状态应用程序

  • 具有持久状态的数据库和其他应用程序

  • 以前在虚拟机中配置的应用程序

  • 你希望按特定时间表运行的批处理过程

  • 你想要一次性运行的批处理任务,例如训练机器学习模型

在所有情况下,应用程序都被容器化并分组在 Pods 中,你通过配置文件向 Kubernetes 描述你想要如何运行你的工作负载。

1.2.2 特性和优势

在本节中,我们将讨论一些人们选择 Kubernetes 来部署他们的容器的主要原因。

自动化操作

只要正确配置你的部署,Kubernetes 就会为你自动化各种操作方面。在节点上运行的进程会重新启动崩溃的容器,同时存活性和就绪性探针会继续监控容器的健康和提供实时流量的能力。你可以在部署上配置 Pod 自动扩展器,根据如利用率等指标自动增加副本的数量。

Kubernetes 本身不会修复计算节点级别的问题。然而,你可以选择一个提供此类自动化的托管平台。以 Google Kubernetes Engine 的 Autopilot 模式为例:它会自动为你的 Pods 提供计算能力,根据你更改副本数量的情况自动扩展和缩减,并在需要时修复和升级节点。

高可扩展性

无论你的应用程序大小如何,你都需要考虑它如何进行扩展。无论是部署一个大型企业级应用程序,还是你是一个自筹资金的初创公司,你都需要一个能够随着你一起扩展的解决方案。当你需要扩展的时候,并不是开始思考你将如何扩展的时候!

创造一个成功的产品已经足够困难;在你成功的时刻——当每个人都试图使用你的产品而敲你的门时——你最不希望看到的是你的应用程序离线。在那个时刻,甚至在未来几个月和几年里,你可能无法完全重新架构你的应用程序以实现扩展。

Kubernetes 可以处理任何大小的应用程序。你可以有一个单节点集群,只有一个 CPU 和大量内存,或者一个多千节点的巨兽,就像 Niantic 在推出 Pokémon Go 时使用的拥有数万个核心。当然,你的应用程序本身需要具有使其能够扩展的特性,以及任何依赖项,尤其是数据库依赖项,但至少你可以放心,你的计算平台会随着你一起扩展。

工作负载抽象

抽象层是很好的,直到它们不是。找到工具来抽象你不想关心的事情,同时不隐藏你关心的细节,这是一个挑战。但是,根据我的经验,Kubernetes 最接近于实现这一点。

基础设施即服务(IaaS)是硬件级别的抽象。你不需要与实际带有旋转磁盘和网络卡的机器交互,而是与一个提供实现相同接口的软件的 API 进行交互。

与之相比,Kubernetes 是一个工作负载级别的抽象。这意味着你用工作负载术语描述你的应用程序。例如,我有一个需要以分布式方式运行的服务器;我有一个需要连接特定磁盘卷的数据库;我有一个需要在每个节点上运行的日志工具;或者也许我有一个电影需要渲染,一次渲染一帧,在可用的最便宜的资源上。所有这些部署结构和更多都可以在 Kubernetes 中本地表示。

Kubernetes 在计算实例(虚拟机)之上提供了一个层,让你无需管理或关心单个机器。你指定你的容器需要哪些资源:CPU、内存、磁盘、GPU 等。一个管理的 Kubernetes 平台通常也会提供计算能力来处理你的工作负载。你不需要担心单个机器,但仍然可以做一些你期望在机器级别做的事情,比如写入持久性本地磁盘,这些任务直到最近在这个抽象级别通常是不可能的。

抽象层通过不干扰你的应用程序(图 1.8)而保持相当干净。与许多传统的 PaaS 环境不同,Kubernetes 不会修改你的应用程序的运行方式;例如,没有代码被注入或更改,对应用程序可以做什么的限制也非常少。如果应用程序可以在容器中运行,那么它很可能可以在 Kubernetes 上运行。

01-08

图 1.8 不同计算层之间关注点分离的示意图

声明式配置

Kubernetes 使用声明式资源模型。你在配置(主要是 YAML 文件)中描述你的工作负载,系统会努力执行你的配置并使其成为现实。例如,如果在 Deployment 中你指定你想要三个由负载均衡器连接到外部世界的应用副本(复制品),Kubernetes 将在你的集群中找到空间来运行这三个副本并附加一个负载均衡器。Kubernetes 不仅最初放置这些副本,而且它将继续监控它们,并在发生崩溃或故障的情况下尝试保持它们运行。

声明式配置很有用,因为它允许你描述你希望的状态(例如,运行我的应用程序的三个副本)并让 Kubernetes 完成实际产生该状态的工作,而不是发出命令式命令(例如,创建我的应用程序的三个副本)然后你自己进行监控和调整(比如查询当前运行的应用程序副本数量并相应调整)。

成本效益

Kubernetes 将最低层的计算构建块(虚拟机)进行管理,使其变得易于管理。在过去,你可能出于维护原因,每个虚拟机分配一个应用程序,而 Kubernetes 允许你在一个机器上高效地托管多个应用程序实例或应用程序,以实现高效率(所谓装箱)。使用通用构建块(原始计算节点)与强大的工作负载编排相结合,通常从价格角度使 Kubernetes 具有吸引力。

除了装箱之外,资源池也是 Kubernetes 的另一个好处,可以提高效率。你可以配置你的工作负载,使其具有一定数量的保证资源,当出现使用高峰时,利用其他容器已预留但尚未使用的容量。

可扩展性

当你需要做 Kubernetes 无法做到的事情时,你可以获取或甚至编写自己的 Kubernetes 风格的 API 来实现它。这并不是针对每个人,而且绝对不是部署大多数工作负载(如无状态或有状态 Web 应用程序)所必需的,但当你需要添加特定的业务逻辑或 Kubernetes 不支持的新结构时,它可能非常有用。自定义资源定义对象和操作符模式允许你创建自己的 Kubernetes 风格的 API。

开源

Kubernetes 是开源的,并且作为托管服务在所有主要云平台上提供。尽管存在许多不同的平台、发行版和安装程序,但大多数此类服务都通过了云原生计算基金会认证计划⁶的认证,该计划围绕工作负载的可移植性和兼容性提供了一些保证。实际上,一个产品要包含名称Kubernetes(例如 Google Kubernetes Engine),唯一的方法是正式通过这些测试。

你也可以从头开始自己运行 Kubernetes。如果你自己运行 Kubernetes,那么代码的质量对你来说很重要。并非所有开源项目都是平等的。虽然开源通常可以让你摆脱专有锁定,但除非有一个强大的社区,否则你可能会发现自己需要自己维护它(你使用它,你就拥有它)。例外是像 Linux 这样的大型、维护良好的开源项目,如此多的人依赖它,如此多的人使用它,以至于你可以放心,你不需要接管维护。幸运的是,作为领先的开放源代码容器编排器,Kubernetes 属于这一类别。

提示:虽然你可以在公共云或树莓派集群上自己托管 Kubernetes,但我通常不推荐这种做法用于生产环境(即除了学习如何管理集群之外的情况)。花时间做你最擅长的事情——构建出色的应用程序——并让其他人帮你处理运行 Kubernetes 的细节。

除了项目本身是开源的,Kubernetes 还有一个充满活力的社区。有开源工具可以完成几乎所有的事情,所以你通常可以选择使用托管服务或自己部署开源工具。这与过去 PaaS 系统中仅有的专有市场形成对比,在那里,任何类型的组件的唯一选择都是付费的。你是否从托管监控工具中获得了价值?使用专有产品。只想自己管理?去安装开源的 Prometheus。Kubernetes 拥有大量不断增长的使用者,所以无论是什么主题,你都应该能在 Stack Overflow 或像这本书这样的书中找到帮助。

定制化工作流程

Kubernetes 对如何设置开发工作流程持中立态度。想要“git push 即部署”风格的流程?有很多种方法可以实现,其中一些只需要最少的设置。通常,你会从一系列 CI/CD 构建块开始,将它们组装成你想要的流程,从简单的推送部署到具有准入控制、自动注入密钥和安全扫描的复杂管道。缺点是它不像传统的 PaaS 那样即买即用,但本书将向你展示它并非那么难上手。

尤其对于大型团队来说,Kubernetes 在这个领域提供的灵活性通常是一个巨大的优势。拥有中央核心平台团队的公司将为他们的应用程序开发团队创建有观点的管道。这个管道可以用来确保围绕安全、资源使用等方面的一些开发实践。

1.2.3 Kubernetes 与平台即服务(PaaS)的比较

应用程序部署的另一种方法是使用 PaaS。PaaS 通过为你处理大量的打包和部署方面,使大规模部署应用程序代码变得容易。只要你的应用程序在语言、依赖项、如何处理状态等方面符合 PaaS 提供的范围,你就可以将每个应用程序部署到 PaaS 中,而不必担心底下的机器。

然而,当你需要高度定制依赖项时,比如使用特定版本的 Java,会发生什么?你能否在无状态的前端旁边托管有状态的后端?当你有多个应用程序,每个都需要许多副本时,这是否具有成本效益?在某个点上,PaaS 的局限性可能会变得阻碍重重,一旦你离开了 PaaS 世界,你就必须从头开始——这是一个令人畏惧的前景。

传统的 PaaS 通常学习速度快,但随着你的成熟,速度会减慢,如果你超出了系统的功能,需要从头开始,可能会遇到一个潜在的悬崖。Kubernetes 的学习曲线在开始时较慢,但随着你的成长,可能性却很广泛(图 1.9)。

01-09

图 1.9 使用传统 PaaS 和 Kubernetes 提高开发者效率

如果你使用 PaaS 并且一切都很顺利,可能没有必要迁移到 Kubernetes。然而,我常见的一个问题是,团队达到一定程度的复杂性,他们的需求超过了 PaaS 的能力。处于这种位置的最可怕的事情之一是你不能简单地“打破玻璃”并假设自己有更多的控制权。通常,你需要重新架构整个系统,甚至失去你原本满意的部分,来构建你需要的新部分。在这本书中,我将向你展示 Kubernetes 如何以比专用 PaaS 略高的复杂性运行 PaaS 类型的工作负载,以及如何运行各种其他工作负载结构,如具有状态的工作负载、后台处理和批处理作业,这些都可以通过使你能够实现更复杂的产品需求来为你未来的成功奠定基础。

简单性一词

我喜欢说,要警惕那些让简单的事情更容易但让复杂的事情更难的工具。当然,当某件事能帮助你更快地上手时,那很好,但它是否让你处于良好的状态,拥有完成工作的正确知识和工具?Kubernetes 足够简单,可以开始使用,同时足够强大,可以满足你随着成长和扩展的需求。在选择你的平台时,优先考虑使复杂任务成为可能,而不是使简单任务变得更加容易。

Kubernetes 将使你能够运行一个简单的、12 因素无状态应用程序;迁移之前安装在虚拟机上的定制状态应用程序;甚至运行你自己的数据库。抽象层不会限制你可以做什么,同时仍然允许你只使用最初需要的部分来开始。

一些更现代的 PaaS 支持容器,因此你可以在那里运行并享受到两者的最佳结合:容器的灵活性和易于部署。一个缺点是,即使是现代的 PaaS 也对你可以运行的工作负载类型有许多限制。例如,它能否运行一个带有基于块的卷的具有状态的应用程序,就像你需要迁移遗留应用程序时可能需要的那样?或者运行一个没有托管服务存在的定制数据库?我建议你仔细考虑你当前和未来的需求,并选择一个能够随着你的成长而扩展的平台。

1.2.4 何时(不)使用 Kubernetes

和大多数工具一样,Kubernetes 的目标是提高你的效率——在这种情况下,管理你的应用程序部署。最好忽略炒作,真正考虑 Kubernetes 是否会帮助你或阻碍你运行服务的能力。托管 Kubernetes 平台存在是为了保持你的集群组件平稳运行,但请注意,运行像 Kubernetes 这样的通用平台有一些开销。操作任务包括为容器分配 CPU 和内存资源、更新部署、配置你的网络,以及在不中断运行服务的情况下保持一切更新。

如果您现在和未来都能准确预测业务需求的确切范围,并且不需要 Kubernetes 提供的灵活性,不关心 Kubernetes 生态系统的供应商可移植性,并且可以将您的应用程序架构完美地适应更特殊用途平台的期望,那么请继续使用它!说实话,您可能会更容易一些。

我通常不推荐在存在完全托管等效软件的情况下使用 Kubernetes 进行部署。例如,为什么要在 Kubernetes 中运行 SQL 数据库,当您的云提供商可以为您做这件事时?有些情况下,自我管理变得令人向往,但总的来说,我相信如果托管服务存在,您应该使用它!

尽管如此,Kubernetes 在一些事情上确实非常出色,比如在高密度下运行无状态应用程序;混合多个工作负载,如现代无状态应用程序和遗留有状态单体;将服务从过时的系统迁移到统一平台;处理高性能计算,如数据分析和机器学习的批处理作业;当然,运行大量微服务。在这些情况下,Kubernetes 通过提高效率、统一您的托管平台、自动化您的系统以及运行您的批处理作业,带来了很多好处。

Kubernetes 确实引入了新的管理开销级别,这需要考虑。如果您将当前正在进行的操作(假设它运行良好)直接扔到 Kubernetes 上,可能会简单地用另一个问题替换一个问题。您可能需要仔细考虑的情况包括:如果无状态平台已经满足您的需求,那么替换它;以及移动具有良好建立部署模式的标准有状态工作负载,如 SQL 数据库。虽然您可能会在 Kubernetes 中看到这样的工作负载的好处,但优势可能不会那么多,因此权衡需要更加仔细地考虑。

为了做出决定,我建议权衡将迁移到容器并统一您的计算平台,围绕一个适合各种工作负载的部署系统所带来的好处,以及管理 Kubernetes 所需的额外知识。如果您一开始就是一堆在各个阶段损坏的定制虚拟机上运行的服务,那么这很可能不是一个艰难的选择。同样,如果您已经超过了您的 PaaS 或有一个高度熟练的团队希望使用现代工具更快地部署,那么就去做吧。但那个运行得如丝般顺滑的 MySQL 集群,在定制的集群设置下具有四个 9 的可靠性?也许现在让它保持现状是可行的。

转向 Kubernetes 不需要是一个全有或全无的决定。我建议从那些最有意义的负载开始,随着您和您的团队在操作 Kubernetes 方面积累知识,逐步迁移它们。

摘要

  • 容器是运行应用程序的现代方式,它能够在同一主机上运行多个应用程序之间实现隔离,并且与虚拟机相比具有低开销。

  • Kubernetes 是容器化应用程序的部署平台。

  • Kubernetes 有一定的学习曲线,但它允许你表达大量的部署结构,并负责配置基础设施和保持应用程序运行。

  • 托管平台(如 Google Kubernetes Engine)承担了管理 Kubernetes 的管理负担,让你可以专注于应用程序部署。

  • 应用开发者可以专注于使用 Kubernetes 术语描述他们的应用程序配置,之后系统将负责以你描述的方式运行它。

  • Kubernetes 的一个关键好处是,它允许你随着需求的演变而增长;你很可能不需要因为新要求(如应用程序需要其自己的本地状态)而更改平台。

  • 当需求增加需要扩展时,Kubernetes 可以帮助你以高效的方式完成。


(1.) cloud.google.com/learn/what-is-kubernetes

(2.) kubernetes.io/case-studies/spotify/

(3.) kubernetes.io/case-studies/capital-one/

(4.) kubernetes.io/case-studies/openai/

(5.) cloud.google.com/blog/products/gcp/bringing-pokemon-go-to-life-on-google-cloud

(6.) www.cncf.io/certification/software-conformance/

2 容器化应用程序

本章涵盖

  • 如何容器化应用程序

  • 在本地运行您的容器

  • 在容器上下文中执行命令

将应用程序容器化——即,将应用程序及其依赖项打包到可执行容器中——在采用 Kubernetes 之前是一个必要的步骤。好消息是,容器化应用程序的好处不仅在于能够将其部署到 Kubernetes 中;它本身就是一个有价值的步骤,因为您正在打包应用程序的依赖项,然后可以在任何地方运行它,而无需在主机机器上安装这些依赖项。

无论您如何部署您的应用程序,将其容器化意味着您的开发人员可以使用 Docker 在本地开始工作,使他们能够在不进行除安装 Docker 之外任何设置的情况下开始新的项目。它提供了在不同应用程序之间轻松切换环境,因为环境是完全隔离的(见图 2.1)。即使您最终没有使用容器将应用程序部署到生产环境中,这些特性也使其成为提高开发者生产力的宝贵方式(尽管您可能也希望这样做)。

02-01

图 2.1 比较开发机器上容器化和非容器化多个项目

将您的应用程序打包到容器中意味着所有依赖项和配置都由容器配置文件——Dockerfile——捕获,而不是由 bash 脚本、基于文本的指令、人类记忆和其他非标准配置系统混合而成。这使得在单个主机机器上部署多个应用程序成为可能,无需担心它们会相互干扰,并且比完整虚拟化具有更高的性能和更低的开销。

2.1 构建 Docker 容器

让我们拿一个应用程序并将其放入容器中。

2.1.1 开发者设置

Docker 作为一种开发者工具,可在大多数平台上使用 Docker Desktop(www.docker.com/products/docker-desktop)进行分发,其中包含一些方便的实用工具,例如本地 Kubernetes 环境(在第三章中介绍)。对于 Linux(包括 Windows Subsystem for Linux [WSL]),您还可以单独安装 Docker Engine。

Mac

在 Mac 上,只需安装 Docker Desktop。

Windows

在 Windows 上,我强烈建议首先配置 WSL(learn.microsoft.com/en-us/windows/wsl/install)。您需要的是 WSL 2,这样 Docker 才能使用它。安装了 WSL 2 后,您还可以安装 Linux 的发行版,如 Ubuntu(mng.bz/pP40),它提供了一个 bash shell,并且是运行本节中展示的示例的便捷方式。一旦配置了 WSL,安装 Docker Desktop。

Linux

对于 Linux,除了 Docker Desktop 之外,还有一个选项——Docker Engine。你可以在这里找到各种平台(包括 Ubuntu)的说明:docs.docker.com/engine/install/ubuntu/。当你通过 WSL 使用 Linux 时,Docker Engine 也是一个选项。

2.1.2 在 Docker 中运行命令

在我们构建自己的应用程序容器之前,为了探索 Docker 的工作原理,我们可以在 Docker 中启动一个容器化的 Linux shell,如下所示:

$ docker run -it ubuntu bash
root@18e78382b32e:/# 

这将下载基础ubuntu镜像,启动一个容器,并对其运行 bash 命令。-it参数使其成为一个交互式 bash 终端。现在我们已经在容器中,我们运行的任何东西都会在容器中发生。

由于我们将在 Ubuntu 上构建应用程序,让我们安装语言包。我将在本章的许多示例中使用 Python,但这个概念同样适用于任何其他语言。

在容器 shell 中运行以下两个命令:

apt-get update
apt-get install -y python3

现在我们可以尝试交互式地运行 Python,例如:

# python3
>>> print("Hello Docker")
Hello Docker
>>> exit()
#

我们可以将这个最基本的命令捕获到我们自己的 Python 脚本中:

# echo 'print("Hello Docker")' > hello.py
# python3 hello.py 
Hello Docker

当你在容器中玩够了,使用exit退出。

这的好处是我们在容器中安装了 Python 并运行了我们的 Python 命令,而不是在本地系统中。Docker 的run命令实际上创建了一个容器,从我们的镜像中。镜像ubuntu是一个预先构建的文件系统,容器进程在其中运行。当我们退出与容器的交互会话时,它将被停止,但你可以很容易地再次启动它,使用docker ps -a获取容器 ID,docker start $CONTAINER_ID来启动它,以及docker attach $CONTAINER_ID来重新连接我们的 shell:

$ docker ps -a
CONTAINER ID   IMAGE     COMMAND    CREATED         STATUS                  
c5e023cab033   ubuntu    "bash"     5 minutes ago   Exited (0) 1 second ago

$ CONTAINER_ID=c5e023cab033
$ docker start $CONTAINER_ID
$ docker attach $CONTAINER_ID
# echo "run more commands"
# exit

运行了很多 Docker 容器后,你最终会得到一个相当大的停止容器列表(以及大量的硬盘空间被使用)。为了清理这些通常不需要保留的镜像,在任何时候,运行:

docker system prune -a

容器镜像与容器实例

在 Docker 术语中,容器镜像是文件工件(无论是从本节中提到的注册表中下载,还是本地构建),而容器实例(或简称容器)是容器的调用。在 Kubernetes 中,配置仅指镜像,而容器实例在运行时创建,并且是短暂的(当 Pod 停止时它们会被删除)。在本地使用 Docker 时,实例概念很重要,因为每次调用都会创建一个持久化的容器实例,所以最终你需要清理它们以恢复磁盘空间。

通过这些步骤,我们现在拥有了一个 Linux 环境,我们可以用它来测试和运行随机命令,而无需在我们的本地机器上安装任何东西(除了 Docker)。想要两个配置不同的 Linux 容器环境?不用担心——只需运行另一个容器!

如果您之前曾经设置过虚拟机(VM),您将非常欣赏设置速度有多快!容器易于创建。正如您将在下一节中看到的那样,它们也易于构建和扩展。

2.1.3 构建我们自己的镜像

在上一节中,我们启动了一个 Linux 容器,安装了 Python,并创建了一个简单的 Python 脚本,我们在容器中运行了这个脚本。假设我们想要使其可重复。也就是说,我们想要在我们的容器镜像中捕获容器的配置(安装 Python)和我们的应用程序(Python 脚本)。这样的镜像非常有用,这样我们就不必记住我们采取的步骤,而且其他人也可以构建我们的神奇应用程序!

虽然这个示例只使用了简单的 Python 脚本,但您可以想象应用程序可以变得多大和多复杂,您想要让它变得多大。它不仅限于 Python;这些步骤适用于任何解释型语言(有关如何处理编译应用程序的说明,请参阅 2.1.7 节)。只需将 Python 配置替换为您使用的任何语言即可。

构建我们的容器镜像以便进行可重复的应用程序部署的过程使用一个名为 Dockerfile 的配置文件。Dockerfile 是一组用于构建容器的程序性指令。将其想象成一个配置带有你的应用程序及其依赖项的虚拟机镜像的 bash 脚本;只是输出是一个容器镜像。

运行示例

本书列出的示例 Docker 应用程序和 Kubernetes 配置可以在源代码库中找到。克隆仓库并切换到根目录,如下所示:

git clone https://github.com/WilliamDenniss/kubernetes-for-developers.git
cd kubernetes-for-developers

示例按章节和节进行排列。例如,第二章的代码在名为 Chapter02 的文件夹中,第 2.1.3 节的示例在 2.1.3_Dockerfile 文件夹中。每个代码列表都包括样本文件的路径,以便您可以找到它。

给出的 shell 命令从根样本代码文件夹(kubernetes-for-developers,如果您按照之前的命令克隆了仓库)开始,所以在运行任何示例或探索代码后,只需切换回该目录,您就应该准备好继续您离开的地方并跟随下一个示例。

我们将从上一节中创建的基本 Python 程序开始,如下所示。

列表 2.1 Chapter02/2.1.3_Dockerfile/hello.py

print("Hello Docker")

要为这个脚本构建一个容器镜像,你需要创建一个 Dockerfile,选择一个基础容器镜像作为起点,配置 Python,并添加程序。目前,我们将从通用的基础镜像ubuntu开始,它提供了一个容器化的 Linux 环境。以下列表显示了一个基本的 Dockerfile 来捕获这些步骤。

列表 2.2 Chapter02/2.1.3_Dockerfile/Dockerfile

FROM ubuntu                      ❶
RUN apt-get update               ❷
RUN apt-get install -y python3   ❷
COPY . /app                      ❸
WORKDIR /app                     ❹

❶ 指定基础容器镜像

❷ 配置我们的环境

❸ 将应用程序复制到容器中

❹ 设置当前工作目录

构建这个容器,并将其命名为(标记为)hello,如下所示:

cd Chapter02/2.1.3_Dockerfile/
docker build . -t hello

一旦构建完成,我们就可以在名为hello的容器上运行python3 hello.py命令,如下所示:

$ docker run hello python3 hello.py
Hello Docker

注意到我们 Dockerfile 中的命令与上一节中使用的命令基本相同。我们不是直接启动ubuntu容器镜像,而是将其用作 Dockerfile 的基础镜像。然后我们运行与之前相同的两个apt-get命令来安装 Python,将我们的 Python 脚本复制到镜像中,并指定默认工作目录以指示命令将运行的位置。此外,请注意,运行代码的命令仍然是python3 hello.py;只是现在它被添加了前缀以在新的容器镜像中运行。

我们现在将构建的环境和我们的脚本封装到一个整洁的包中,我们可以自己使用和运行,也可以与他人分享。容器最奇妙的地方在于它们封装了配置步骤以及程序本身。最好的部分是,当ubuntu基础镜像更新时,我们只需再次运行那个build命令就可以重新构建我们的镜像。

将其与在你的开发机器上安装 Python 并本地运行所有内容进行比较。首先,如果你那样做了,现在 Python 就已经安装了。你可能会很高兴 Python 已经安装,但想象一个更复杂的应用程序,它带来了数十个工具和库。你真的想在你的系统上安装所有这些吗?此外,如果你正在开发几个不同的应用程序,每个应用程序都有自己的依赖项,或者有相同的依赖项但需要特定版本的依赖项,使得同时满足两个应用程序的依赖项变得不可能(这种情况有时被称为“依赖地狱”)?

容器通过在各自的容器镜像中隔离应用程序及其依赖项来解决这个问题。你可以愉快地在多个项目中工作,与你的开发团队共享 Dockerfile,并将容器镜像上传到你的生产环境,而不会弄乱你的开发机器。

对于像 Ruby 这样的不同语言,设置相当相似,如下面的两个列表所示。

列表 2.3 Chapter02-ruby/2.1.3_Dockerfile/hello.rb

puts "Hello Docker"

列表 2.4 Chapter02-ruby/2.1.3_Dockerfile/Dockerfile

FROM ubuntu
RUN apt-get update
RUN apt-get install -y ruby
COPY . /app
WORKDIR /app

运行时,唯一的区别是传递的命令:

$ cd Chapter02-ruby/2.1.3_Dockerfile
$ docker build . -t hello_ruby
$ docker run hello_ruby ruby hello.rb
Hello Docker

2.1.4 使用基础镜像

上一节使用 Linux 容器ubuntu作为基础来配置我们的基于 Linux 的应用。基础镜像,包括ubuntu和其他如centosalpine等发行版,是配置任何基于 Linux 应用的良好起点。然而,为了方便,容器社区已经创建了几个更具体的镜像,这些镜像针对各种语言和环境进行了设计。

我们不需要自己将 Python 安装到ubuntu基础镜像中,我们可以直接从python镜像开始,并节省一些步骤。额外的优势是,这些基础镜像通常由专家创建,因此它们配置良好,可以运行 Python 应用程序。以下列表显示了相同的容器,但以python基础镜像开始。

列表 2.5 第二章/2.1.4_BaseImage/Dockerfile

FROM python:3
COPY . /app
WORKDIR /app 

更简单吗?构建和运行与之前相同:

$ cd Chapter02/2.1.4_BaseImage
$ docker build . -t hello2
$ docker run hello2 python3 hello.py
Hello Docker

究竟什么是基础镜像?

在本例中使用的基础镜像python,*实际上是通过 Dockerfile 构建的,并配置了一个包含运行 Python 程序所需所有内容的运行环境。对于来自 Docker Hub 的容器镜像,它们的 Dockerfile 源代码是链接的,这样你可以看到它们是如何组成的。基础镜像通常以另一个基础镜像开始,以此类推,直到一个以完全空容器scratch开始的镜像。

如果你使用的是 Ruby 而不是 Python,设置相当类似。只需使用ruby基础镜像,如下面的两个列表所示。

列表 2.6 第二章-ruby/2.1.4_BaseImage/hello.rb

puts "Hello Docker"

列表 2.7 第二章-ruby/2.1.4_BaseImage/Dockerfile

FROM ruby
COPY . /app
WORKDIR /app

构建和运行:

$ cd Chapter02-ruby/2.1.4_BaseImage
$ docker build . -t hello_ruby2
$ docker run hello_ruby2 ruby hello.rb
Hello Docker

不仅存在操作系统和语言特定的基础镜像。如果你使用的是 Apache 这样的环境,你可以从httpd基础镜像开始。有时你可能会遇到多个可以作为基础镜像的情况。最好的经验法则是选择可以节省最多配置的镜像(你总是可以从未选择的镜像的 Dockerfile 中借鉴!)。

基础镜像——或者至少你可以复制的公共示例——几乎为每种常见的语言、环境和开源应用程序都存在。在从头开始构建自己的镜像之前,明智的做法是在 Docker Hub 或 Google 中搜索基础镜像,看看是否有人为你所在的环境提供了一个可以使用的示例,作为起点。

2.1.5 添加默认命令

通常,在容器中执行的命令(在之前的 Python 示例中为python3 hello.py)每次都是相同的。你可以在 Dockerfile 中指定它,而不是每次都重复它。

列表 2.8 第二章/2.1.5_DefaultCommand/Dockerfile

FROM python:3
COPY . /app
WORKDIR /app 
CMD python3 hello.py

要构建和运行此容器,请在命令行中执行以下操作:

$ cd Chapter02/2.1.5_DefaultCommand
$ docker build . -t hello3
$ docker run hello3       
Hello Docker

与我们之前在 Dockerfile 中使用的其他行不同,CMD是独特的,因为它实际上并不改变容器构建的方式。它只是保存了在没有指定命令的情况下调用docker run时将执行的默认命令。这并不会阻止你覆盖它并在运行时执行不同的命令。现在命令已在 Dockerfile 中指定,要构建和运行此程序的 Ruby 版本,也只需简单地docker run $IMAGE_NAME

$ cd Chapter02-ruby/2.1.5_DefaultCommand
$ docker build . -t hello_ruby3
$ docker run hello_ruby3
Hello Docker

2.1.6 添加依赖项

大多数非平凡的应用程序都将有自己的依赖项,这些依赖项不包括在基础镜像中。为了加载这些依赖项,你可以在容器构建过程中运行命令来配置镜像,以满足你的需求。这就是我们在前面的示例中向 Linux 基础镜像添加 Python 的方法,这种方法可以用来安装应用程序需要的所有依赖项。如果你的应用程序连接到 MariaDB 数据库,你可能构建的容器如下所示。

列表 2.9 第二章/2.1.6_ 依赖项/Dockerfile

FROM python:3
RUN apt-get update                      ❶
RUN apt-get install -y mariadb-client   ❶
COPY . /app
WORKDIR /app 
CMD python3 hello.py

❶ 使用 apt-get 配置你的 Linux 容器,使其包含你的应用程序所需的一切。

python 基础镜像是从 Debian 构建的,Debian 是广泛用于容器的 Linux 发行版,它使用 apt-get 软件包管理器,因此我们可以使用 apt-get 安装我们需要的几乎所有其他依赖项。

你也不一定只能使用 apt-get。比如说,你有一个创建 PDF 文件的服务,你需要包含一个 Unicode 字体。你可以构建一个包含 Google 的 Noto 免费字体的镜像,如下面的列表所示。

列表 2.10 第二章/2.1.6_ 依赖项-2/Dockerfile

FROM python:3
RUN apt-get update
RUN apt-get install -y libarchive-tools                ❶
RUN mkdir -p ~/.fonts; cd ~/.fonts                     ❷
RUN curl "https://noto-website-2.storage.googleapis\ ❸
.com/pkgs/Noto-hinted.zip" | bsdtar -xvf- ❸
RUN fc-cache -f -v                                     ❹
COPY . /app
WORKDIR /app
CMD python3 hello.py

❶ 安装 bsdtar

❷ 创建一个新的目录并切换到它。注意如何在同一行上组合多个命令。

❸ 下载字体包并提取它

❹ 安装字体

容器通常有许多依赖项,你可以以这种方式配置操作系统的任何部分,例如安装字体或 TLS 证书。

2.1.7 在 Docker 中编译代码

对于需要编译的程序,如 Java、.NET、Swift 和 C++,怎么办?显然,在 Dockerfile 中仅使用 COPY 命令是不够的,除非你已经有现成的编译二进制文件。

在本地预编译应用程序是一个选项,但为什么不使用 Docker 来编译你的应用程序呢!让我们重新实现我们的“Hello World”示例,用 Java 编写并编译到我们的容器中,如下面的两个列表所示。

列表 2.11 第二章/2.1.7_ 编译代码/Hello.java

class Hello {
    public static void main(String[] args) {
        System.out.println("Hello Docker");
    }
}

列表 2.12 第二章/2.1.7_ 编译代码/Dockerfile

FROM openjdk
COPY . /app
WORKDIR /app
RUN javac Hello.java ❶
CMD java Hello

❶ 编译命令

这个 Dockerfile 与之前的类似:我们以 OpenJDK (openjdk) 基础镜像开始,并复制应用程序。然而,在这种情况下,我们将使用 RUN 命令来构建应用程序,前面加上一个 WORKDIR 指令来指定这个操作(以及随后的操作)应该在哪里执行。

要构建和运行此示例,请执行以下操作:

$ cd Chapter02/2.1.7_CompiledCode
$ docker build . -t compiled_code
$ docker run compiled_code
Hello Docker

另一个示例在第二章-swift/2.1.7_ 编译代码文件夹中,它可以用相同的方式构建和运行。

2.1.8 使用多阶段构建编译代码

使用 RUN 来编译代码或执行其他操作是一个可行的途径;然而,缺点是最终你会在容器镜像中配置所需的工具来执行 RUN 命令。这些工具最终会与任何源代码一起出现在最终的容器镜像中。

例如,如果你查看上一节中创建的镜像并运行ls

$ docker run compiled_code ls
Dockerfile
Hello.class
Hello.java

你会看到源代码仍然存在。此外,Java 编译器(javac)仍然存在于镜像中,即使它将不再被使用(我们在运行应用程序时不需要编译器)。

这种将容器镜像的责任混合在一起——既用于构建又用于运行——并不是最佳选择。这不仅让所有额外的二进制文件膨胀了容器镜像,还无谓地增加了容器的攻击面(因为现在容器中运行的任何进程都有一个编译器可用)。你可以使用一些额外的 Docker 命令(例如,删除源代码,卸载不再需要的工具)来清理容器,但这并不总是实用的,尤其是如果所有这些额外的工具都来自基础镜像。

解决这个问题的更好方法是使用多阶段容器构建(图 2.2)。使用多阶段构建,我们首先配置一个临时容器,其中包含构建程序所需的一切,然后我们配置一个最终容器,其中包含运行程序所需的一切。这保持了关注点的分离,并将它们干净利落地隔离到各自的容器中。

02-02

图 2.2 多阶段容器构建,其中使用了一个中间容器来构建二进制文件

让我们重新整理上一节中的示例,使其使用多阶段 Dockerfile 进行构建。

列表 2.13 Chapter02/2.1.8_MultiStage/Dockerfile

FROM openjdk:11 AS buildstage                   ❶
COPY . /app
WORKDIR /app
RUN javac Hello.java

FROM openjdk:11-jre-slim                        ❷
COPY --from=buildstage /app/Hello.class /app/   ❸
WORKDIR /app
CMD java Hello

❶ 构建容器被命名为 buildstage,其责任是构建代码。

❷ 运行时容器使用了一个精简的基础镜像,没有包含编译工具

❸ 使用--from=来引用构建容器中的文件。

从这个示例中可以看出,似乎有一个 Dockerfile 中包含了两个(每个都以FROM命令开始)。第一个配置和构建纯粹是为了编译应用程序,使用完整的openjdk基础镜像,其中包含 Java 编译器,而第二个只包含运行应用程序所需的内容,并从jre基础镜像构建,该镜像只包含 Java 运行时环境。

这个 Dockerfile 产生的最终产物是一个生产容器,它只包含编译后的 Java 类和运行它所需的依赖项。构建应用程序的第一个容器的中间产物在构建完成后实际上被丢弃(技术上,它被保存在你的 docker 缓存中,但没有任何部分包含在最终产物中,你会在生产中使用)。

要运行此示例,请执行以下操作:

$ cd Chapter02/2.1.8_MultiStage
$ docker build . -t compiled_code2
$ docker run compiled_code2
Hello Docker

如果我们在这个新的容器上运行ls命令,我们可以看到其中只有编译后的代码:

$ docker run compiled_code2 ls
Hello.class

在 Chapter02-swift/2.1.8_MultiStage 文件夹中给出了另一个示例,它使用多阶段构建过程编译服务器端 Swift 应用程序。它可以以相同的方式构建和运行。

2.2 服务器应用程序的容器化

上一节中的示例都是一些运行一次然后退出的简单程序。这是一个适用于命令行程序、批处理工作负载,甚至在函数即服务环境中处理请求的容器用例。然而,在 Kubernetes 中部署的最常见工作负载之一是 HTTP 服务——也就是说,一个监听并处理传入请求的应用程序(即一个网络服务器)。

从 Docker 的角度来看,服务器应用程序与其他任何应用程序没有区别。由于你很可能希望容器持续运行(以便它可以处理请求),因此在启动和连接到容器的方式上可能有一些差异。你很可能还希望从你的本地机器转发端口,以便你可以连接到它。

2.2.1 容器化应用程序服务器

到目前为止,示例程序一直是一个基本的“Hello World”Python 脚本。为了演示如何容器化 HTTP 服务器,我们需要一个实际上是 HTTP 服务器的例子!列表 2.14 是一个 Python 中裸骨 HTTP 服务器的示例,它返回当前的日期和时间。不必太担心代码本身。这本书是语言无关的,这里使用的 Python 纯粹是一个例子。你可以将这些原则应用到任何 HTTP 服务器上。

列表 2.14 Chapter02/timeserver/server.py

from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
from datetime import datetime

class RequestHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        self.send_response(200)
        self.send_header('Content-type', 'text/plain')
        self.end_headers()
        now = datetime.now()
        response_string = now.strftime("The time is %-I:%M %p, UTC.")
        self.wfile.write(bytes(response_string, "utf-8")) 

def startServer():
    try:
        server = ThreadingHTTPServer(('', 80), RequestHandler)
        print("Listening on " + ":".join(map(str, server.server_address)))
        server.serve_forever()
    except KeyboardInterrupt:
        server.shutdown()

if __name__== "__main__":
    startServer()

容器化这个服务器应用程序与早期的命令行程序非常相似,如下所示。

列表 2.15 Chapter02/timeserver/Dockerfile

FROM python:3.12
ENV PYTHONUNBUFFERED 1
COPY . /app
WORKDIR /app 
CMD python3 server.py

容器化自己的应用程序

如果你正在容器化自己的应用程序,请遵循以下通用步骤:

  1. 找到一个理想的基镜像,尽可能多地提供你的配置。对于一个 Ruby on Rails 应用程序,从ruby开始,而不是更通用的ubuntu。对于 Django,使用python,依此类推。

  2. 配置任何你需要的应用程序特定依赖项(通过RUN语句,就像我们之前做的那样)。

  3. 复制你的应用程序。

我发现谷歌搜索在这方面真的是你的朋友。除非你在做一些新颖和异国情调的事情,否则可能有人已经找到了并分享了一个示例 Dockerfile,说明了如何使用你的框架配置应用程序。如果你使用像 Django、Ruby on Rails、WordPress、Node.JS 或 SpringBoot 这样的流行框架,我可以肯定地说,有很多资源可以借鉴。每个应用程序都是不同的——你的依赖项并不总是与别人的完全匹配——但你可以通过这种方式获得巨大的优势。

现在我们有了 HTTP 服务器应用程序,我们可以像往常一样构建它:

$ cd Chapter02/timeserver
$ docker build . -t timeserver

运行它这次有些不同,因为我们需要从主机机器转发端口到容器,这样我们实际上可以在浏览器中尝试这个应用程序。让我们将本地机器上的端口 8080 转发到应用程序监听的容器的端口 80:

$ docker run -it -p 8080:80 timeserver
Listening on 0.0.0.0:80

现在你应该能够浏览到 http://localhost:8080 并查看应用程序。或者,使用curl,执行以下操作:

$ curl http://localhost:8080
The time is 1:30 PM, UTC.

-it 参数(实际上是两个参数,但通常一起使用)允许我们通过发送 SIGTERM(通常是 Ctrl/Command+C)来终止。这使得典型的开发者循环(构建-运行-修复-重复)变得简单(运行,Ctrl+C,修复,重复)。或者,你可以使用 docker run -d -p 8080:80 timeserver 在后台运行 Docker。如果不使用 -it,你需要手动停止进程:使用 docker ps 列出进程,然后使用 docker stop $CONTAINER_ID 停止它,或者使用 docker stop $(docker ps -q) 停止所有正在运行的容器。

为了一个整洁的开发循环,我喜欢使用以下一行命令,它将一次性构建并运行镜像。当你需要重新构建时,只需按下 Ctrl+C(或等效键),使用上箭头显示最后使用的命令,然后按 Enter 重新执行。务必在构建阶段查看控制台输出,以检查是否有任何错误,否则它将运行最后一个构建的镜像:

docker build . -t timeserver; docker run -it -p 8080:80 timeserver

就这样!我们现在有一个在 Docker 中运行的应用程序容器。在第 2.3 节中,我介绍了如何使用 Docker Compose 配置和运行本地调试设置(如果你的应用程序由几个不同的容器组成,这很有用),在下一章中,我将介绍如何将这个 Web 应用程序部署到 Kubernetes。

2.2.2 调试

如果你配置 Dockerfile 后遇到应用程序运行困难的问题,进入容器环境进行探索以查看哪里出错可能很有用。当容器正在运行时,你可以使用之前的指令从新的控制台窗口进入正在运行的容器,如下所示:

$ docker ps
CONTAINER ID    IMAGE       COMMAND                 CREATED         STATUS
6989d3097d6b    timeserver  "/bin/sh -c 'python3..."  2 minutes ago   Up 2 min

$ CONTAINER_ID=6989d3097d6b
$ docker exec -it $CONTAINER_ID sh
# ls
Dockerfile  server.py
# exit
$

你也可以运行除 sh 之外的其他任何命令;例如,在一个 Ruby on Rails 项目中,你可能会在这里运行 bundle exec rails console 来直接启动 rails 控制台,而不需要中间步骤。

我不会列出每个 Docker 命令,因为文档在这方面做得很好,但我发现另一个特别有用的调试命令是 docker cp。它允许你在主机和容器之间复制文件。以下是一个示例:

docker cp server.py $CONTAINER_ID:/app

或者,要从容器中复制文件,请执行以下操作:

docker cp $CONTAINER_ID:/app/server.py .

如果你通过 exec 运行命令或复制文件来修复任何问题,请确保在 Dockerfile 中捕获更改。Dockerfile 是你的主要规范,而不是容器实例。如果你依赖于对容器实例的手动更改,那么它并不比我们正在远离的旧“进入虚拟机并更改内容”模式更好。

2.3 使用 Docker Compose 进行本地测试

到目前为止,我们已经构建了一个容器镜像,并准备好开始使用 Kubernetes。如果你愿意,可以跳到下一章,立即将新构建的容器部署到云或本地 Kubernetes 环境中。本节介绍了在部署到 Kubernetes 之前,如何使用 Docker Compose 进行本地容器测试和开发。

在上一节中,我们使用 Docker 启动了我们的服务器应用,并将端口转发到主机进行测试。在开发过程中使用这种方法进行测试有几个缺点。每次测试时,您都必须设置要转发的端口,如果您正在开发一个包含几个容器的应用,那么配置正确的端口并使一切正常运行可能会变得复杂。

这就是 Docker Compose 的用武之地。Compose 是一个迷你容器编排器,可以在逻辑组中启动和销毁多个容器,并在运行之间保留运行时设置,这对于本地测试非常有用。要使用 Compose 运行 2.2.1 节中的 Web 服务器容器,我们可以配置一个 docker-compose.yaml 文件,如下所示。

列表 2.16 第二章/2.3_Compose/docker-compose.yaml

services:
  web:
    build: ../timeserver         ❶
    command: python3 server.py   ❷
    ports:                       ❸
      - "8080:80"                ❸

❶ 包含要构建的 docker 容器的目录路径

❷ 在容器上运行的命令。如果 Dockerfile 指定了 CMD,则可以省略。

❸ 将端口从本地机器转发到容器。在这种情况下,本地机器上的 8080 端口将被转发到容器的 80 端口。

要构建和运行容器,请执行以下操作:

cd Chapter02/2.3_Compose
docker compose build
docker compose up

在开发过程中,我倾向于将这两个步骤合并为一个,这样我就可以创建一个紧凑的重构循环:

docker compose build; docker compose up

使用这种简单的配置,无需记住启动和测试应用的特定 Docker 命令——所有内容都整齐地存储在 compose 文件中。在这个例子中,这主要是一些要转发的端口,但随着您添加更多配置和依赖项,这种好处将变得明显。

2.3.1 本地映射文件夹

之前,我们使用docker cp将文件复制到容器实例中。Compose 的一个非常有用的功能是您实际上可以将本地文件夹直接映射到容器中。换句话说,容器不会包含您应用的副本,它实际上只是链接到您硬盘上的应用文件夹。在开发过程中,这非常有用,因为它允许您直接从桌面工作在容器中的文件,而无需来回复制文件或重建容器。

回想一下列表 2.15 中的 Dockerfile,我们的服务器应用被复制到容器内的/app 目录。我们现在想做的就是在相同的目录下使用卷绑定将我们的本地目录挂载到容器中,如下所示。

列表 2.17 第二章/2.3.1_ 卷挂载/docker-compose.yaml

services:
  frontend:
    build: .
    command: python3 server.py
 volumes:         ❶
 - type: bind ❶
 source: . ❶
 target: /app ❶
    environment:
 PYTHONDONTWRITEBYTECODE: 1 ❷
    ports:
      - "8080:80"

❶ 将本地容器构建目录绑定到容器的/app 目录(与 Dockerfile 匹配)

❷ 设置一个新的环境变量,以便 Python 可以重新加载我们的源代码

使用这种卷绑定,我们的本地机器上的文件将用于构建容器时复制的文件。当我们本地更新这些文件时,更改可以立即在容器中读取,无需重新构建。对于 Python、Ruby 和 PHP 等解释型语言以及 HTML 和 CSS 等标记语言来说,这意味着您可能有一个设置,只需在编辑器中点击保存,然后在浏览器中重新加载页面,就可以有一个非常紧密的开发循环。

注意:对于编译代码,这可能没有太大帮助。您可以在本地构建二进制文件,将其替换到容器中,但如果您更喜欢通过 Docker(或者您的本地环境和容器之间存在架构差异)构建一切,那么这可能没有帮助。对于编译代码,我建议使用其他开发工具,如 Skaffold¹ 以提供紧密的开发循环。

然而,要让这个示例应用程序工作有一个技巧。我们的 Python 代码默认情况下,在发生更改时不会从磁盘重新加载。因此,虽然我们可以修改源代码,但一旦容器运行,它就不会有任何效果。这同样适用于许多其他构建系统。

让我们更新 Python 时间服务器应用程序以支持在运行时重新加载代码,并在 Compose 中配置本地挂载。这里的步骤将因语言和框架而异。对于 Python,我们可以使用 reloading 库,以便在每次有新请求时从磁盘重新加载我们的 GET 函数。

列表 2.18 第二章/2.3.1_VolumeMount/server.py

from reloading import reloading
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
from datetime import datetime

class RequestHandler(BaseHTTPRequestHandler):
 @reloading  ❶
    def do_GET(self):
        self.send_response(200)
        self.send_header('Content-type', 'text/plain')
        self.end_headers()
        now = datetime.now()
        response_string = now.strftime("The time is %-I:%M %p, UTC.")
        self.wfile.write(bytes(response_string,"utf-8")) 

def startServer():
    try:
        server = ThreadingHTTPServer(('',80), RequestHandler)
        print("Listening on " + ":".join(map(str, server.server_address)))
        server.serve_forever()
    except KeyboardInterrupt:
        server.shutdown()

if __name__== "__main__":
    startServer()

❶ 通过在我们的方法中添加 @reloading 标签,每次运行时它都会从磁盘重新加载,这样我们就可以在运行时更改我们的 do_GET 函数。

由于我们使用了一个新的库,我们还需要在 Dockerfile 中添加这个依赖项。

列表 2.19 第二章/2.3.1_VolumeMount/Dockerfile

FROM python:3
RUN pip install reloading
ENV PYTHONUNBUFFERED 1
COPY . /app
WORKDIR /app 
CMD python3 server.py

在我们的应用程序配置为从磁盘重新加载文件后,我们现在可以像以前一样使用 Compose 运行它:

$ cd Chapter02/2.3.1_VolumeMount
$ docker compose build; docker compose up
Creating network "231_volumemount_default" with the default driver
Creating 231_volumemount_frontend_1 ... done
Attaching to 231_volumemount_frontend_1

如前所述,浏览到 http://localhost:8080/ 上的应用程序。这次,打开 2.3.1_VolumeMount/server.py 代码(列表 2.18)并修改响应。例如,我们可以通过将第 12 行替换为以下内容来将响应更改为 24 小时时间:

response_string = now.strftime("The time is %H:%M, UTC.")

在您的编辑器中保存文件并重新加载页面。您应该会在响应中看到新的文本:

$ curl http://localhost:8080
The time is 17:23, UTC.

在这个例子中,我们不得不做一些代码更改才能使其工作,但如果你使用的是标准开发框架,那么这很可能不是必要的,因为你将能够配置它以自动执行重新加载。

能够将本地文件夹映射到容器中,创建一个与在代码编辑器中点击保存然后重新加载浏览器页面一样快的开发循环,这必须是我最喜欢的 Docker Compose 和容器的一般特性之一。您拥有容器的一切好处,您不需要在本地安装开发工具,效率与您在本地运行时没有任何构建步骤一样。

绑定是双向的。如果你在绑定卷内的容器中进行了任何更改,它将反映在你的本地磁盘上。当你想在容器中运行命令并保存它们的输出时,这非常有用。实际上,使用这种方法,你可以完全避免在本地机器上安装开发者工具。例如,Rails 开发者有时会在他们的项目目录中运行 gem update rails 来保持框架更新。使用卷绑定,你可以在容器中运行它,并在你的硬盘上获得已更改的包列表,以便提交到版本控制。

2.3.2 添加服务依赖项

在你的应用程序完全独立的情况下,恭喜你,你已经完成了。然而,在其余的时间里,你很可能会需要其他服务来与你的应用程序一起启动。这些可能包括你构建的其他独立服务器,或者可能由你的云服务提供商运行的标准组件,比如数据库。在这两种情况下,你都可以在 Compose 中添加这些依赖项来创建一个本地开发环境。

Compose 或 Kubernetes 用于本地开发?

为什么选择使用 Compose 而不是直接使用 Kubernetes 来为开发搭建依赖服务呢?Kubernetes 当然可以用于本地开发,如果你想要复制生产环境,它无疑是最佳选择(第三章包含关于本地开发的章节)。然而,Compose 在这项任务中之所以受欢迎,主要是因为它的简单性。

如果你只需要少量依赖服务,那么 Compose 的本地设置非常简单,对于许多简单应用程序来说就是这样。在生产环境中,你不仅仅是在一台机器上运行几个单实例服务,这是一个不同(更复杂)的故事,这正是 Kubernetes 之所以出色的地方。

这种双重性意味着在本地开发中使用 Compose 和在生产中使用 Kubernetes 是很常见的。这也意味着你的运行时配置实际上是重复的,但由于这个配置有两个不同的目的——开发和生产——所以即使全部都在 Kubernetes 中,它也可能不会看起来完全相同。我建议简单地使用任何能让开发者生活更轻松的方法。

可以轻松地将多个服务添加到 Compose 中。这些服务可以引用标准镜像(例如,对于像 MySQL 这样的依赖项)或你电脑上的其他项目。一个常见的项目结构是有一个根文件夹用于所有服务,每个服务都在子文件夹中检出,并且有一个 Docker Compose 文件可以引用它们。

列表 2.20 提供了一个包含两个容器化服务的 Compose 文件示例:我们本地构建的应用程序和一个使用公共 MySQL 容器的数据库实例。这里的演示应用程序实际上并没有使用 MySQL,但希望你能看到添加应用程序所需依赖项有多容易。你可以在这里添加所有需要的服务,包括多个本地构建的容器和多个外部镜像。

列表 2.20 Chapter02/2.3.2_MultipleServices/docker-compose.yaml

services:
  frontend:                                        ❶
    build: ../timeserver                           ❶
    command: python3 server.py                     ❶
    environment:                                   ❶
      PYTHONDONTWRITEBYTECODE: 1                   ❶
    ports:                                         ❶
      - "8080:80"                                  ❶

  db:
    image: mysql:5.7                               ❷
    volumes:                                       ❷
      - db_data:/var/lib/mysql                     ❷
    restart: always                                ❷
    environment:                                   ❷
      MYSQL_ROOT_PASSWORD: super secret password   ❷
      MYSQL_DATABASE: my_database                  ❷
      MYSQL_USER: dev_user                         ❷
      MYSQL_PASSWORD: another secret password      ❷
volumes:                                           ❸
    db_data:                                       ❸

❶ 我们本地构建的应用程序;你可以拥有多个本地构建的应用程序。

❷ 运行公共图像的服务;你也可以拥有多个这样的服务。

❸ 为开发数据库定义卷,以便它在重启之间持续存在

这也是使用 Compose 而不是仅使用 Docker 进行本地测试的关键原因之一——能够通过单个命令启动完整的测试环境并将其拆除。

提示:当配置你的应用程序用于本地开发和生产时,所有配置更改都应通过环境变量进行。甚至一个表示proddev以选择要使用哪个配置文件的单个环境变量也足够了。配置不应以将需要在不同环境之间修改的方式嵌入到容器中。这允许你在所有环境中重用相同的容器,这也意味着你在测试生产工件。

2.3.3 模拟外部依赖

如果到目前为止,你一直在针对远程依赖项(如云存储 API)进行测试,那么现在可能是一个好时机,看看你是否可以用模拟来替换这些远程依赖项。模拟是外部依赖项相同 API 的轻量级实现,通过提供本地服务来加速开发和测试。

在过去,你可能被迫寻找与你的应用程序编写语言相同(出于实际原因,如不想为一个项目支持多个不同的环境)的模拟。容器的一个好处是,就像你可能不关心你消费的云服务是用什么语言编写的,你也不再需要关心你的模拟是用什么语言编写的,因为你不需要维护环境——它在自己的容器中运行。

这也带来了高质量模拟的机会,这些模拟实际上只是生产中使用的相同 API 的更轻量级实现。就像在列表 2.20 中我们使用容器中的真实 MySQL(而不是模拟)一样,你可以使用真实对象存储提供商进行测试,即使你最终使用像 Google Cloud Storage 或 Amazon S3 这样的云服务提供商服务。

以对象存储为例,假设你的应用程序使用与 S3 兼容的 API 进行云存储(例如,使用 S3 本身或许多支持该 API 的对象存储之一,如 Google Cloud Storage)。为了快速迭代而设置本地模拟,你可以获取一个像 Adobe 的 S3Mock²这样的容器,但使用容器,使用一个完整的 S3 兼容的本地存储解决方案(如 MinIO³)同样容易。MinIO 实际上不是一个模拟——在你想管理自己的块存储服务的情况下,你可以将其部署到生产环境中——但你仍然可以使用它作为一个高质量的模拟,并获得如方便的用户界面等好处。

对象存储的 S3 API 的普遍性

就像 SQL 标准化的数据库查询语言一样,S3 的 API 对于对象存储提供商来说出奇地受欢迎。例如,Google Cloud、Azure 以及(当然)AWS 都实现了 S3 API,以及大多数其他云和几个裸金属存储选项。这种普遍性的好处是您可以轻松地在提供商之间切换,并从多个伪造中选择以进行本地开发。

之前,我讨论了容器如何使混合和匹配服务变得容易,所有这些服务都在它们自己的环境中运行。在这里,我们看到这种能力如何使开发变得更好。您不必自己实现伪造或为您的环境找到原始的伪造,您可以使用与生产中相同的服务本地使用(例如 MySQL)或找到您使用的另一个云服务的生产级替代品(例如 MinIO 作为云对象存储的替代)。让我们将 MinIO 作为另一个服务添加到我们的 Docker Compose 文件中。

列表 2.21 第二章/2.3.3_Fakes/docker-compose.yaml

services:

  storage:                                           ❶
 image: minio/minio            ❶
 command: minio server /data            ❶
 volumes:            ❶
 - storage_data:/data            ❶
 restart: always            ❶
 environment:            ❶
 MINIO_ACCESS_KEY: fakeaccesskey            ❶
 MINIO_SECRET_KEY: fakesecretkey            ❶
 ports:            ❶
 - "9000:9000"            ❶

  frontend:
    build: ../timeserver
    command: python3 server.py
    environment:
      PYTHONDONTWRITEBYTECODE: 1
      S3_ENDPOINT:            http://storage:9000 ❷
 S3_USE_PATH_STYLE:      1 ❷
 S3_ACCESS_KEY_ID:       fakeaccesskey ❷
 S3_SECRET_ACCESS_KEY:   fakesecretkey         ❷
    ports:
      - "8080:80"

volumes:
    db_data:
    storage_data:

❶ 新的存储伪造

❷ 配置为使用新伪造的应用程序

通常,对于用作伪造的服务,例如我们在这里使用 MinIO,您可以指定它将接受的访问密钥,然后只需将这些相同的密钥作为环境变量指定给应用程序即可。

摘要

  • 容器化将您的应用程序的构建、环境和配置捕获到一个标准化的格式中,然后可以像虚拟机一样进行隔离运行,但无需虚拟机的开销。

  • 容器化是采用 Kubernetes 的关键步骤,因为这是 Kubernetes 支持的执行环境。

  • 不仅用于生产,容器还有助于开发者同时处理多个项目,无需担心环境冲突,也不需要复杂的设置说明。

  • 构建容器镜像的过程使用一个名为 Dockerfile 的配置文件,其中包含用于构建您容器的一系列过程指令。

  • 要构建 Dockerfile,首先从满足您需求的最完整的基镜像开始,然后配置您的应用程序及其依赖项。

  • 多阶段构建对于编译型应用程序很有用。

  • 在多阶段容器构建中,我们首先配置一个临时容器,其中包含构建程序所需的一切,然后配置另一个容器,其中包含运行程序所需的一切。这保持了关注点的分离,并将它们干净地隔离到各自的容器中。

  • Docker Compose 是一个轻量级的容器编排器,可以为多个服务提供一个基于容器的快速开发环境。

  • 使用 Compose 映射本地文件夹可以实时编辑非编译应用程序,创建紧密的开发循环。

  • 在测试期间,容器允许使用高质量的伪造外部依赖项,这些依赖项实际上是您在生产中使用的相同 API 的轻量级实现,例如容器中的 MySQL 或真实的对象存储提供商。


^(1.) skaffold.dev/

(2.) github.com/adobe/S3Mock

(3.) min.io/

3 部署到 Kubernetes

本章涵盖

  • 与指定和托管应用程序部署相关的 Kubernetes 概念

  • 在云平台上将容器化应用程序部署到 Kubernetes

  • 使用应用程序容器的新版本更新部署

  • 在本地运行 Kubernetes 版本进行测试和开发

在上一章中,我们介绍了如何容器化你的应用程序。如果你在那里停止,你将有一个便携且可重复的环境,更不用说方便的开发者设置。然而,当你进入生产时,你可能会有困难来扩展那个应用程序。

对于超简单部署,如果你不介意每个虚拟机(VM)运行一个容器,你可能会直接将容器部署到 VM 上,然后根据需要扩展你的 VM。你会得到一些容器的好处,比如方便的打包。然而,如果你像大多数人一样,有多个不同的服务要部署,你可能需要更灵活的东西。

这就是容器编排器如 Kubernetes 发挥作用的地方。容器编排只是指处理多个不同机器上多个不同容器调度和监控的工具。它允许你主要从应用程序部署的角度工作——容器及其部署属性,例如应该有多少个容器副本(实例);以及高可用性(跨故障域扩展)、服务网络等要求——而不是过度关注底层计算的配置。

能够方便地管理共享计算资源池中的多个服务,当运行多个应用程序或采用如微服务这样的模式时,其中应用程序的不同部分被分别部署和管理,这会给你带来效率。你还可以混合不同类型的部署,从无状态应用程序到有状态数据库、批处理作业等——而不必过多担心每个容器最终实际运行在哪个机器上。

3.1 Kubernetes 架构

Kubernetes 是一个抽象层,它位于原始计算原语(如 VM 或裸机)和负载均衡器之上的工作负载级别。VM 被称为 节点,并排列成 集群。容器(一个或多个)被组合成一个称为 Pod 的调度单元。网络通过 服务 进行配置。其他更高阶的构建块,如 Deployment,存在以使 Pod 更容易管理。在我们部署第一个工作负载之前,让我们探索这个架构的一些基本构建块。

3.1.1 Kubernetes 集群

Kubernetes 集群是由节点组成的集合,这些节点是运行容器的计算实例。最常见的是虚拟机,但它们也可以是裸机(非虚拟化)机器。每个节点都运行一个特殊的 Kubernetes 进程,称为kubelet,它负责与控制平面(Kubernetes 编排过程)通信并管理在节点上通过容器运行时运行的容器的生命周期。除了操作系统、kubelet 和容器运行时环境之外,其他进程,包括您的工作负载和一些负责日志记录和监控的系统组件,都在容器中运行,如图 3.1 所示。

03-01

图 3.1 在虚拟机上运行的过程,Kubernetes 称之为节点

在集群中,一个或多个(在高可用模式下操作时)节点具有特殊角色,如图 3.2 所示:运行 Kubernetes 编排程序本身。构成控制平面的特殊节点负责

  • 运行 API,您可以使用它通过工具(如 Kubernetes 命令行界面(CLI)工具)与集群交互

  • 存储集群的状态

  • 协调集群中的所有节点以调度(启动、停止、重启)它们上的容器

03-02

图 3.2 自管理的 Kubernetes 集群,包含控制平面和工作节点

在大多数云环境中,控制平面作为托管服务提供。在这样的环境中,控制平面节点通常对用户不可见,控制平面可能运行在节点上是一个实现细节。在这些环境中,您通常会认为集群是托管控制平面与工作节点组成的,如图 3.3 所示。

03-03

图 3.3 基于云的 Kubernetes 集群,节点连接到托管控制平面

工作节点(在此简称为节点)负责管理运行中的容器的生命周期,包括启动和停止容器等任务。控制平面将指示节点运行特定的容器,但容器的实际执行则是节点的责任。节点也会自行采取一些行动,而无需向控制平面汇报,例如重启已崩溃的容器或在节点内存不足时回收内存。

总体而言,控制平面和节点组成了Kubernetes 集群,并提供了可以在其上调度你的工作负载的 Kubernetes 平台。集群本身是由你用来运行 Kubernetes 的平台提供商提供的,它负责创建集群资源,如节点。本书旨在面向开发者,主要关注使用Kubernetes 集群来运行你的工作负载,而不是平台提供商的任务(这些任务更多地属于云提供商领域),为开发者提供这项服务。

3.1.2 Kubernetes 对象

一旦集群创建完成,你主要通过创建、检查和修改通过 Kubernetes API 的 Kubernetes 对象与 Kubernetes 交互。这些对象中的每一个都代表系统中的特定部署结构。例如,有一个对象代表一组容器(Pod),一个代表一组 Pod(Deployment),一个用于网络服务,等等。甚至节点也被表示为一个对象,你可以查询它来查看当前状态的一些方面,例如正在使用多少资源。要将典型的无状态 Web 应用程序部署到集群中,你将使用三个对象:Pod、Deployment(它封装 Pod)和服务。

Pod

Pod 仅仅是容器的一个集合。通常,Pod 将只是一个单个容器,但在紧密耦合的容器需要一起部署的情况下,Pod 可能会有多个容器(图 3.4)。

03-04

图 3.4 Kubernetes Pod,可以有一个或多个容器

Pod 被用作 Kubernetes 中的主要调度单元。它包含你的应用程序及其容器,是 Kubernetes 根据你所需的资源在节点上调度的计算单元。例如,如果你的工作负载需要两个 CPU 核心来运行,你可以在 Pod 定义中指定这一点,Kubernetes 将找到一个有两个可用 CPU 资源的机器。

一个 Pod 有多少个容器?

除了存在多个容器之间紧密耦合依赖的简单情况外,大多数容器都是独立部署的,每个 Pod 一个容器。你可能有多容器的情况包括所谓的 sidecars,其中第二个容器用于授权、日志记录或其他功能,以及其他多个容器紧密耦合的情况,这样它们可以从一起部署中受益。

如果你检查节点上运行的过程,你不会看到 Pod 本身,只会看到来自容器的许多进程(图 3.5)。Pod 只是容器的一个逻辑分组。是 Kubernetes 将这些容器绑定在一起,确保它们共享一个共同的生存周期:它们一起创建;如果一个失败,它们会一起重启;并且它们会一起终止。

03-05

图 3.5 节点上运行的多个 Pod

Deployment

虽然你可以指示 Kubernetes 直接运行 Pods,但你很少这样做。应用程序会崩溃,机器会失败,因此 Pods 需要重新启动或重新调度。与其直接调度 Pods,不如将它们包装成一个更高阶的对象,该对象管理 Pods 的生命周期。

对于需要持续运行的 Web 服务器等应用程序,该对象是 Deployment。其他选项包括在第十章中介绍的用于运行批处理过程的 Job。在 Deployment 中,你指定希望运行的 Pod 副本数量以及其他信息,例如如何滚动更新。

与 Kubernetes 中的所有对象一样,Deployment(图 3.6)是系统期望状态的规范,Kubernetes 试图实现这一状态。你可以指定诸如 Pod 的副本数量等信息,以及我们在后续章节中将要讨论的 Pod 在集群中分布的详细要求。Kubernetes 在尝试提供你所请求的内容的同时,持续地将观察到的状态与期望状态进行协调。例如,如果某个 Pod 在部署后某个时间点变得不可用,比如运行它的节点失败,Kubernetes 会观察到运行的 Pods 数量少于期望值,并调度新的 Pod 实例以满足你的要求。这些自动化的扩展和修复操作是使用 Deployment 而不是直接运行 Pods 来管理服务生命周期的首要原因。

03-06

图 3.6 包含三个 Pod foo-app 副本的 Deployment

服务

服务是暴露在一系列 Pods 上运行的应用程序作为网络服务的方式。服务提供了一个单一的寻址机制,并在 Pods 之间分配负载(图 3.7)。服务有自己的内部 IP 地址和 DNS 记录,可以在集群内运行的其他 Pods 中引用,也可以分配一个外部 IP 地址。

03-07

图 3.7 Kubernetes 服务

3.2 部署应用程序

让我们从部署一个应用程序并在互联网上使其可用开始。稍后,我们将使用新版本更新它。换句话说,我们将使用 Kubernetes 在前一个章节中讨论的对象执行基本的应用程序开发-发布-更新周期:一个 Pod,它将由 Deployment 管理,并通过 Service 暴露。

3.2.1 创建集群

在部署应用程序之前,你需要一个 Kubernetes 集群来使用。我建议在公共云上创建一个,因为这样设置起来更方便,人们可以立即查看你的作品,因为你可以为部署的任何服务共享一个公共 IP。许多云服务提供商都提供免费试用,以帮助在学习过程中降低成本。

使用本地 Kubernetes 集群进行开发是另一种选择,但本地 Kubernetes 集群的环境与基于云的集群之间有一些固有的差异,尤其是在像负载均衡这样的问题上。我更喜欢学习第一天就能在生产环境中使用的环境,因此我建议选择一个云提供商并从那里开始。

倾向于使用本地集群?

如果你更愿意使用本地的 Kubernetes 发行版,我已经为你准备好了。按照第 3.4 节的步骤操作,将你的 kubectl 命令连接到本地集群,然后再回到第 3.2.3 节继续部署到 Kubernetes 的内容。

只要注意,当你准备部署你自己的本地构建的容器镜像时,有一些在 3.4 节中概述的考虑因素,以确保 Kubernetes 可以找到你的镜像,并且由于缺乏公共负载均衡器,你访问你创建的任何服务的方式将不同(也在该节中概述)。

最后,你只需要一个托管在某处的 Kubernetes 集群和认证到该集群使用的 Kubernetes 命令行工具 kubectl(发音为:“cube cuttle”),就可以运行这本书中的几乎所有示例了。接下来的两个步骤将使用 Google Cloud,但我也会在过程中提供如何替换你选择的平台的说明。

Google Kubernetes Engine

Google Kubernetes Engine (GKE) 是第一个推向市场的 Kubernetes 产品,由于其成熟度和易用性,成为尝试 Kubernetes 的热门选择。我在 GKE 团队工作,对这个平台最为了解,因此我将在这本书中需要特定平台要求的一些地方使用它。

我写这本书是为了在任何你找到 Kubernetes 的地方都能使用,我预计无论你是使用 GKE、OpenShift、Azure Kubernetes Service (AKS)、Elastic Kubernetes Service (EKS) 还是其他任何 Kubernetes 平台和发行版,这本书都将对你学习 Kubernetes 有用。在少数几个地方,平台会发挥作用(比如现在创建集群的时候),在这些情况下,我会用 GKE 的说明来演示操作,但我也会提供如何在其他平台上找到等效操作的指南。

在任何云上创建 Kubernetes 集群

在此设置部分之后,你只需要运行本章示例的 kubectl 工具已经认证到你所选择的 Kubernetes 集群。创建和认证 kubectl 是目标,正如你将看到的 GKE 一样,这可以通过两个命令完成。你可以用你选择的平台的等效集群创建和认证命令替换这些命令。

要在任何提供程序上运行以下示例,请遵循您选择的提供程序的集群创建指南,然后继续到第 3.2.2 节。上传容器也是另一个特定于提供程序的操作,但我已经为您提供了如何在任何平台上完成此操作的通用提示。

要开始使用 GKE,您需要一个 Google 账户(如果您有@gmail.com 电子邮件地址,那么您就有 Google 账户)。请访问console.cloud.google.com/,选择您的账户,并查看条款。如果您尚未激活免费试用,请激活它,或者添加账单信息以便您能够运行这些示例(如果您希望在本地上运行示例,您也可以按照第 3.4 节中的步骤操作以获取仅本地的集群)。

在您的账户设置完成后,转到控制台中的 GKE(直接链接:console.cloud.google.com/kubernetes)并创建一个集群。我建议使用自动模式,该模式会为您处理节点的供应和管理。使用自动模式,您可以设置名称,选择一个区域(如图 3.8 所示),并将网络和高级设置保留为默认值。

03-08

图 3.8 GKE Autopilot 的集群创建用户界面

接下来,设置命令行工具。您需要云提供程序 CLI(在本例中为gcloud用于 Google Cloud)来执行集群操作,如创建和认证,以及kubectl用于与 Kubernetes API 交互。在cloud.google.com/sdk/install下载gcloud CLI 并按照安装说明操作。

安装完成后,运行gcloud init命令进行登录。如果您有多个 Google 账户,请确保选择您之前创建集群时使用的相同账户:

gcloud init

Kubernetes CLI,kubectl,可以独立安装(按照kubernetes.io/docs/tasks/tools/中的说明操作)或通过gcloud安装。安装方式无关紧要,但鉴于本例使用gcloud,我们可以方便地使用它来安装kubectl,如下所示:

gcloud components install kubectl

一旦集群准备就绪且gcloud已配置,请在 UI 中点击“连接”,并将提供的 gcloud 命令(如图 3.9 所示)复制到您的 shell 中以认证kubectl。或者,使用您自己的集群详细信息运行以下命令:

CLUSTER_NAME=my-cluster
REGION=us-west1
gcloud container clusters get-credentials $CLUSTER_NAME --region $REGION

03-09

图 3.9 GKE 的集群连接用户界面

该命令是 Google Cloud 世界和 Kubernetes 之间的粘合剂,并使用正确的凭据对kubectl CLI 进行认证以访问您的 GKE 集群。

在 CLI 中创建集群

而不是使用用户界面,您可以从命令行执行创建和连接步骤,如下所示:

CLUSTER_NAME=my-cluster
REGION=us-west1
gcloud container clusters create-auto $CLUSTER_NAME --region $REGION
gcloud container clusters get-credentials $CLUSTER_NAME --region $REGION

在你的集群创建并kubectl认证后,你就可以开始使用你的第一个应用程序了!为了确保一切正常,运行kubectl get pods。它应该指出没有资源(因为我们还没有部署任何 Pods):

$ kubectl get pods
No resources found in default namespace.

如果你遇到错误,很可能是你的集群没有正确创建或认证。尝试重复之前的步骤或查找错误信息。

3.2.2 上传你的容器

到目前为止,我们创建的容器都存储和运行在本地的机器上。在你可以将容器部署到云中运行的 Kubernetes 之前,你需要将你的容器镜像上传到容器注册库。这只是一个存储容器镜像数据并提供 Kubernetes 获取镜像的方式的地方。大多数注册库支持公共镜像选项,任何人都可以使用(如开源项目和书籍的示例),或者私有镜像,这需要认证(你将使用它来为你的专有应用程序)。

如果你愿意,你可以跳过此步骤并使用以下示例中引用的公开可用的镜像。然而,我建议你构建并上传自己的容器来使用,这样你就可以在需要的时候部署自己的应用程序。

Docker Hub 作为一个容器注册库的选择很受欢迎,尤其是在公共容器镜像方面。这包括基础镜像(如我们在上一章中使用的那些),开源软件如 MariaDB,或者可能是你希望与世界分享的自己的软件和演示。你还可以从 Docker Hub(和其他注册库)访问任何 Kubernetes 平台的私有容器镜像,只需进行一些额外的配置来设置凭证。

对于大多数希望保持其镜像私有的用户来说,使用云提供商的容器注册库是默认选择,因为这通常在镜像拉取时间、减少网络数据成本和简化认证方面提供了效率。对于 Google Cloud,那是 Artifact Registry;在 Amazon Web Services (AWS)上,它是 Amazon Elastic Container Registry;在 Azure 上,它是 Azure Container Registry;等等。

一旦你选择了你偏好的位置,按照以下步骤上传你的容器。

账户设置

要开始,如果你还没有账户,首先在你偏好的提供商处创建一个账户,然后创建一个仓库,你将在其中上传图片。对于 Docker Hub,请访问hub.docker.com/,登录,然后导航到创建仓库。

对于 Artifact Registry,请访问console.cloud.google.com/artifacts,并在你希望的位置创建一个类型为 Docker 的新仓库。注意生成的路径,它看起来可能像us-docker.pkg.dev/my-project/my-repository

认证

接下来,你想要验证docker命令行工具,以便它可以上传镜像到你的新创建的仓库。按照你的容器仓库的说明来验证docker命令行工具。

要在 Docker Hub 中这样做,你会运行

docker login

对于工件仓库,回想一下你之前创建的仓库路径。取该路径的主机部分(例如,us-docker.pkg.dev),然后运行以下命令将凭证助手安装到 Docker 工具中,以便你可以将镜像上传到那里。你可以多次运行此命令,每次运行针对你使用的每个单独的主机:

HOST_NAME=us-docker.pkg.dev
gcloud auth configure-docker $HOST_NAME

提示:使用你选择的云提供商验证 Docker 通常是一个简单的操作。只需查找配置 Docker CLI 的正确凭证的云特定命令。搜索查询“使用[你的云提供商]容器仓库验证 Docker”应该可以解决问题!

标签

当你构建镜像时,它们会被分配一个基于随机哈希的名称,例如82ca16cefe84。通常,添加一个具有一定意义的自定义标签是一个好主意,这样你可以轻松地引用自己的镜像。在前一章中,我们使用了这些标签,这样我们就可以使用像docker run timeserver这样的友好名称在本地运行我们的镜像,而不是使用docker run 82ca16cefe84

当你将容器上传到容器仓库时,标签具有额外的含义。你必须使用遵循容器仓库指定的特定路径约定的名称来标记镜像,以便它知道将镜像存储在哪个账户和路径中(以及让你的本地 Docker 客户端知道上传到哪个仓库)。当你上传到这些仓库时,使用像timeserver这样的简单名称来标记你的镜像将不起作用。

Docker Hub 使用以下约定

docker.io/$USERNAME/$REPOSITORY_NAME:$VERSION_TAG

其中$USERNAME是你的 Docker 用户名,$REPOSITORY_NAME是你创建在 Docker Hub 中的仓库名称,$VERSION_TAG是一个任意字符串(通常包括一个数字)。结合我的情况,我的用户名是“wdenniss”,我的仓库是“timeserver”,我得到的字符串是docker.io/wdenniss/timeserver:1

版本标签

版本标签是一个无结构的字符串,用于引用镜像的版本。约定是使用版本号(可能构造为 major.minor .patch)和可选后缀:例如,2.2.12.1.52.1.5-beta。可以使用特殊的版本标签latest来引用运行容器时最新的镜像,但在标记上传的镜像时不要使用latest,因为它会被容器仓库自动应用。

每个仓库都有自己的格式。对于 Google Cloud 的工件仓库,其格式由以下结构组成:

$LOCATION-docker.pkg.dev/$PROJECT_ID/$REPOSITORY_NAME/
$VERSION_TAG

在 UI 控制台中创建 Artifact Registry 仓库后,您应该看到字符串的前一部分显示出来(例如,us-docker.pkg.dev/wdenniss/ts),您可以复制(或者您也可以使用前面的公式构建字符串)。在这个前缀后面,添加您喜欢的任何镜像名称和标记,例如timeserver:1。将其组合起来,对我来说,看起来如下所示:

us-docker.pkg.dev/wdenniss/ts/timeserver:1

容器仓库标记约定

每个私有容器仓库都有自己的魔法字符串连接,您需要创建正确的标记,而且它们都不同。例如,Azure^a 记录了$REGISTRY_NAME.azurecr.io/$REPOSITORY_NAME:$VERSION_TAG,AWS^b 记录了$AWS_ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com/$REPOSITORY_NAME: $VERSION_TAG. 我可以肯定的是:确保您遵循您所使用的容器仓库的指南;否则,Kubernetes 将不知道将镜像推送到哪里。我使用的搜索词是“[cloud provider] registry container tag name”。

^a mng.bz/o1YD

^b mng.bz/nWOd

一旦确定了要使用的正确镜像标记(在后续示例中我们将称之为$IMAGE_TAG),您就可以为上传标记任何现有的 Docker 镜像。要将我们在早期章节中构建的其中一个镜像上传到容器仓库,您可以引用其之前的标记并添加容器仓库标记(镜像可以有多个标记)。如果您在 2.2.1 节中使用docker build . -t timeserver构建了示例,这个镜像将具有timeserver标记,这意味着我们可以将其重新标记为容器仓库,如下所示:

IMAGE_TAG=us-docker.pkg.dev/wdenniss/ts/timeserver:1
docker tag timeserver $IMAGE_TAG

注意:如果您收到“没有这样的镜像”错误,请继续阅读,因为我们即将从头开始再次构建它。

您可以查看生成的镜像列表,如下所示:

$ docker images
REPOSITORY                                TAG     IMAGE ID      CREATED
timeserver                                latest  c07e34564aa0  2 minutes ago
us-docker.pkg.dev/wdenniss/ts/timeserver  1       c07e34564aa0  2 minutes ago
python                                    3.10    cf0643aafe49  1 days ago

您还可以根据镜像 ID(docker tag $IMAGE_ID $IMAGE_TAG)查找现有镜像并对其进行标记,但我建议在构建时进行标记以避免混淆。实际上,我通常发现简单地重新构建镜像比事后尝试找到正确的镜像 ID 要快得多。

要构建和标记示例容器,请将$IMAGE_TAG替换为您自己的仓库镜像名称,然后从根示例目录运行

IMAGE_TAG=us-docker.pkg.dev/wdenniss/ts/timeserver:1
cd Chapter02/timeserver
docker build . -t $IMAGE_TAG

推送

一旦我们的仓库设置完成,Docker 认证就绪,并且您的镜像已标记,您可以使用以下命令将镜像推送到仓库

docker push $IMAGE_TAG 

之前的认证步骤在 Docker 配置中安装了一个辅助程序,使 Docker 能够与您的云容器仓库进行通信,无论是什么。如果您收到“权限拒绝”错误,那么要么您没有正确认证 Docker,要么您的镜像标记字符串构建错误。请验证您是否已将 Docker 认证到适当的仓库并设置了正确的镜像标记。请参考您选择的容器仓库的最新文档以获取指导。

如果一切顺利,你应该会看到以下输出。特别注意最后一行,这是任何认证错误将显示的位置:

$ docker push $IMAGE_TAG
The push refers to repository [us-docker.pkg.dev/wdenniss/ts/timeserver]
9ab1337ca015: Pushed
3eaafa0b4285: Layer already exists
a6a5635d5171: Layer already exists
8c25977a7f15: Layer already exists
1cad4dc57058: Layer already exists
4ff8844d474a: Layer already exists
b77487480ddb: Layer already exists
cd247c0fb37b: Layer already exists
cfdd5c3bd77e: Layer already exists
870a241bfebd: Layer already exists
1: digest: sha256:edb99776ae47b...97f7a9f1864afe7 size: 2425

一旦镜像已上传,你现在就可以将你的代码部署到 Kubernetes 中了!

3.2.3 部署到 Kubernetes

在创建集群并使用 kubectl 进行认证后,我们可以部署我们的第一个应用程序。为此,我们将创建一个恰如其分的 Deployment 对象。Kubernetes 使用声明式配置,你在配置文件中声明你想要的状态(例如,“我想要在集群中运行 3 个我的容器副本”),然后提交该配置到集群,Kubernetes 将努力满足你指定的要求。

对于配置文件,大多数开发者使用 YAML,因为它更容易手动编辑。JSON 是另一个选项(主要用于自动化访问),并且某些配置可以命令式地创建(在第 3.3 节中讨论)。列表 3.1 是来自第二章的 timeserver 应用程序的极简 Deployment 规范。它引用了一个由包含的样本应用程序构建的公共容器镜像,我已经将其上传到 Docker Hub。如果你有自己的镜像,例如在上一节中推送到容器仓库的镜像,请编辑此文件,并用你的镜像替换我的镜像。

列表 3.1 第三章/3.2_DeployingToKubernetes/deploy.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: timeserver
spec:
  replicas: 3                                    ❶
  selector:
    matchLabels:
      pod: timeserver-pod
  template:
    metadata:
      labels:
        pod: timeserver-pod
    spec:
      containers:
      - name: timeserver-container
        image: docker.io/wdenniss/timeserver:1   ❷

❶ 部署多少个 Pod 副本(实例)

❷ 部署和运行哪个容器镜像

此清单将创建我们容器的三个副本。稍后,我们将看到如何配置负载均衡器来将这些三个运行实例的传入请求进行分配。在这个极简的 Deployment 配置示例中,最重要的三条线是名称,这是检查、修改和删除 Deployment 所必需的;副本数量;以及容器名称。其余部分基本上是使一切工作的粘合剂(别担心,我还会解释粘合剂是如何工作的)。

容器镜像路径就像一个 URL,它引用了查找容器的位置。如果你按照上一节上传了你的容器,你已经在那个步骤中有了这个镜像路径。我的带有 docker.io 前缀的容器镜像可在 Docker Hub 上找到,这是一个流行的托管公共镜像的地方,包括基础镜像。需要注意的是,如果你看到没有域的镜像路径,例如 ubuntuwdenniss/timeserver,它只是指在 Docker Hub 上托管的镜像的简写。

因此,这就是 Deployment。让我们在集群中创建它。从根样本目录中运行

cd Chapter03/3.2_DeployingToKubernetes/
kubectl create -f deploy.yaml

这指示 Kubernetes 创建配置文件中定义的对象。如果你在部署后需要做出更改(例如更改镜像版本),你可以在本地进行更改,并使用以下命令更新集群中的 Deployment

kubectl apply -f deploy.yaml

要观察 Deployment 的状态,请运行

$ kubectl get deploy
NAME         READY   UP-TO-DATE   AVAILABLE   AGE
timeserver   3/3     3            3           36s

如前所述,Deployment 是你所需要求的声明性语句,例如,“3 个副本的此 Pod。”当你创建 Deployment 并系统返回成功响应时,这仅仅意味着 Kubernetes 接受了你的 Deployment 以进行调度——并不意味着它已经按照你期望的方式完成了调度。使用 kubectl get 查询 Deployment 将会显示当前状态,例如有多少个 Pod 准备好服务流量(READY 列中的数字),以及稍后,当你更新 Deployment 时,有多少个 Pod 在新版本部署过程中运行最新版本(UP-TO-DATE 列中的数字)。要查看构成你的 Deployment 的 Pod 的更多详细信息,你也可以查询这些 Pod 本身:

$ kubectl get pods
NAME                          READY   STATUS    RESTARTS   AGE
timeserver-6df7df9cbb-7g4tx   1/1     Running   0          68s
timeserver-6df7df9cbb-kjg4d   1/1     Running   0          68s
timeserver-6df7df9cbb-lfq6w   1/1     Running   0          68s

注意:如果这里显示 Pod 处于挂起状态,这可能意味着你的集群没有足够的资源。在动态配置的环境中,通常只需等待一分钟或更长时间就足够看到它们被调度。如果它们仍然处于挂起状态,请查看“故障排除:卡在挂起状态”部分中的后续建议。

kubectl get pods 命令返回活动命名空间中所有 Pod 的状态,所以一旦你有很多 Deployment,这可能会变得有些混乱。相反,你可以使用更详细的形式,其中你传递 Pod 的标签(在第 3.2.4 节中讨论过)作为选择器。以下是一个使用示例 Deployment 标签的完整示例:

$ kubectl get pods --selector=pod=timeserver-pod
NAME                          READY   STATUS    RESTARTS   AGE
timeserver-6df7df9cbb-7g4tx   1/1     Running   0          2m13s
timeserver-6df7df9cbb-kjg4d   1/1     Running   0          2m13s
timeserver-6df7df9cbb-lfq6w   1/1     Running   0          2m13s

一旦 Pod 运行起来,我们就可以与之交互了!为了连接到我们刚刚创建的 Deployment 并访问之前创建公共 IP 之前部署的服务器,我们可以简单地从我们的本地机器转发一个端口到容器,如下所示:

$ kubectl port-forward deploy/timeserver 8080:80
Forwarding from 127.0.0.1:8080 -> 80
Forwarding from [::1]:8080 -> 80

这允许你通过浏览 http://localhost:8080 来从 localhost 与 Deployment 交互。当你尝试容器化应用程序时,你可以在新的命令行外壳中查看日志输出,如下所示:

$ kubectl logs -f deploy/timeserver
Found 3 pods, using pod/timeserver-8bbb895dc-kgl8l
Listening on 0.0.0.0:80
127.0.0.1 - - [09:59:08] “GET / HTTP/1.1” 200 -

使用 -f(跟随)参数的日志命令将从 Deployment 中的一个 Pod 流式传输日志。在你的应用程序启动时将一条语句记录到 stdout 中是一个好主意,就像这里用“Listening on 0.0.0.0:80”所做的那样,这样你可以确保容器确实按照预期启动。

在 Kubernetes 中,你执行的大多数操作都不是瞬时的。创建一个 Pod 需要时间来配置新的计算能力(这取决于你使用的 Kubernetes 平台),从容器仓库下载容器,并启动你的容器。如果一切顺利,你应在几分钟内就有正在运行的容器。

当一切顺利时,你的部署中的 Pod 将报告一个状态(使用kubectl get pods查询时),状态为Running。你可能还会看到其他状态,如Pending,当它在等待容量时,以及ContainerCreating,一旦容器被调度到你的节点并启动。令人困惑的是,有时一个 Pod 可能会卡在Pending状态——这是一个有点模糊的状态——并且可能还有其他错误。以下是一些常见的错误情况列表。

故障排除:镜像拉取错误(ErrImagePull/ErrImagePullBackoff)

此错误表明 Kubernetes 无法下载容器镜像。这通常意味着在你的配置中镜像名称拼写错误,镜像不存在于镜像仓库中,或者你的集群没有访问仓库所需的凭据。

检查你的镜像拼写,并验证该镜像是否存储在你的仓库中。为了快速修复以使部署运行,可以尝试使用我提供的公共容器镜像。你可以使用kubectl apply -f deploy.yaml来应用你对部署配置所做的任何修复。

故障排除:卡在挂起状态

如果你看到一个 Pod 在Pending状态中挂起一分钟以上,通常意味着 Kubernetes 调度器无法在你的集群中找到空间来部署 Pod。通常,这个问题可以通过向你的集群添加额外的资源来解决,比如添加一个额外的或更大的计算节点。

你可以通过以下方式“描述”挂起的 Pod 来查看其详细信息:

$ kubectl get pods
NAME                          READY   STATUS    RESTARTS   AGE
timeserver-6df7df9cbb-7g4tx   1/1     Pending   0          1m16s

$ POD_NAME=timeserver-6df7df9cbb-7g4tx
$ kubectl describe pod $POD_NAME

Events部分包含 Kubernetes 遇到的所有错误列表。如果你尝试调度一个部署但没有可用资源,你会看到一个警告,例如FailedScheduling。以下是我看到的一个 Pod 尝试调度但资源不足的事件文本:

Warning  FailedScheduling  26s (x2 over 26s)  default-scheduler
➥ 0/2 nodes are available: 2 Insufficient cpu.

只要至少有一个你的 Pod 处于Running状态,你现在就不必担心,因为只要有一个 Pod 存在来响应请求,你的服务应该仍然可以运行。然而,如果它们都处于挂起状态,你可能需要采取行动——很可能是通过添加更多的计算资源。

故障排除:容器崩溃(CrashLoopBackOff)

另一个常见的错误是容器崩溃。容器崩溃可能有各种原因,包括容器启动失败(例如,由于配置错误)或者容器启动后不久就崩溃。

在 Kubernetes 部署的范围内,任何终止的容器进程都算作崩溃——即使是以成功的退出代码终止的。部署是为长时间运行的过程设计的,而不是一次性任务(Kubernetes 确实有一种表示应作为一次性任务调度的 Pod 的方法,那就是 Job 对象,在第十章中介绍)。

在像我们在这里部署的 Deployment 管理的 Pod 中,容器偶尔崩溃会被优雅地处理,通过重启它。实际上,当你运行 kubectl get pods 时,你可以看到容器重启了多少次。你可以有一个每小时崩溃一次的容器,从 Kubernetes 的角度来看,这完全没问题;它将继续重启它,并继续其愉快的旅程。

然而,一个在启动时立即崩溃或在启动后很快崩溃的容器会被放入一个指数退避循环中,在这种循环中,Kubernetes 不是持续不断地重启它(消耗系统的资源),而是在重启尝试之间引入一个指数级增加的延迟(例如,10 秒,然后 20 秒,40 秒,以此类推)。

当容器第一次崩溃时,它将有一个类似于 RunContainerError(对于在启动时出错的容器)的状态,或者对于退出的容器是 Completed。一旦崩溃重复了两次,状态将变为 CrashLoopBackOff。可能性很大,任何处于 CrashLoopBackOff 状态的容器都存在需要你注意的问题。一种可能性是,当外部依赖(如数据库)未满足时,容器可能会退出,在这种情况下,你应该确保外部服务正在运行并且可以连接到。

要调试崩溃的容器,我总是从像早期问题中那样使用 kubectl describe pod $POD_NAME 开始,查看那里的事件以获取线索。容器的日志也是另一个很好的检查点。你可以使用 kubectl logs $POD_NAME 来检索这些日志。在处理崩溃的容器时,你可能希望查看容器在 之前 实例化时的日志(在崩溃后重启之前),以查看崩溃时打印的任何错误,因为这通常将指示原因。为此,将 --previous(或仅 -p)添加到你的日志请求中:

kubectl logs -p $POD_NAME

3.2.4 PodSpec

值得花点时间理解 Deployment 对象是如何组成的,因为它实际上封装了一个具有自己规范的 Pod 对象。你将在 Kubernetes 中其他更高阶的工作负载类型(如 Job)中看到这种模式的重复。这也很重要,因为我们通过引用 Pod 而不是 Deployment 来公开 Deployment。

当你创建一个包含三个副本的 Deployment 时,实际上你是在指示 Kubernetes Deployment 控制器创建和管理三个 Pod。Deployment 控制器管理这些 Pod 的生命周期,包括当你使用新的容器更新 Deployment 时用新版本替换它们,以及重新安排因计划内或计划外维护事件而被驱逐的 Pod。图 3.10 展示了此对象组成的视觉分解。

03-10

图 3.10 Pod 对象嵌入在 Deployment 对象中

Kubernetes API 文档中将 Pod 对象模板称为 PodSpec。实际上,您可以将其提取出来单独运行。为此,您需要提供一个标题,指定此对象是 Pod 类型而不是 Deployment 类型;然后,您可以将 template 下的整个 YAML 复制到配置的根目录中,如下面的列表所示。

列表 3.2 第三章/3.2.4_ThePodSpec/pod.yaml

apiVersion: v1
kind: Pod
metadata:
  name: timeserver
  labels:
    pod: timeserver-pod
spec:
  containers:
  - name: timeserver-container
    image: docker.io/wdenniss/timeserver:1

您可以直接创建这个 Pod。这样的 Pod 不会被任何 Kubernetes 控制器管理。如果它们崩溃,将会重启,但如果由于升级事件或节点故障等原因被驱逐,它们将不会被重新调度。这就是为什么通常您不会直接调度 Pod,而是使用更高阶的对象,如 Deployment,或者,正如我们将在后面的章节中看到的,StatefulSet、Job 以及其他。

注意:Kubernetes 中这种对象组合的关键要点之一是,每次您在 Deployment 等对象中看到 PodSpec 时,都知道它携带了 Pod 的所有功能。这意味着您可以查看 Pod 的文档,并使用管理对象 Pod 模板中的任何值。

PodSpec 包含了关于您应用程序的关键信息,包括构成它的容器或容器。这些容器中的每一个都有自己的名称(因此您可以在多容器 Pod 中引用单个容器),以及最重要的字段:容器镜像路径。还有许多可选字段,包括一些重要的字段来指定健康检查和资源需求,这些内容将在接下来的章节中介绍。

在 Deployment 及其嵌入的 PodSpec 中也有一些看似重复的标签。Deployment 的规范有一个 selector matchLabels 部分,PodSpec 有一个 metadata labels 部分,两者都包含相同的键值对 pod: timeserver-pod。那么这里发生了什么?

嗯,由于 Pod 对象在创建后实际上存在某种程度的独立性(它被创建为一个由 Deployment 控制器管理的独立对象),我们需要一种方式来引用它。Kubernetes 通过要求 Pod 有一个标签(这是一个任意的键值对),并且从 Deployment 中引用(选择)相同的标签来解决此问题。这实际上是绑定两个对象的粘合剂。如图 3.11 所示,在图表中更容易可视化。

03-11

图 3.11 部署的选择器和 Pod 模板标签之间的关系

这个过程可能看起来是不必要的:既然 PodSpec 已经嵌入到 Deployment 中,Kubernetes 不能为我们做这个对象链接吗?你需要手动指定这些标签的原因是,它们在直接在其他对象中引用 Pods 时扮演着重要的角色。例如,在下一节中,当我们配置网络服务时,它直接引用了 Deployment 的 Pods,而不是 Deployment 本身。书中稍后讨论的其他概念也是如此,例如 Pod 故障预算(PDB)。通过指定你的 Pod 标签,你将知道在这些其他对象中应该引用哪个标签。Pod 是 Kubernetes 中的基本执行和调度单元,而 Deployment 只是创建、管理和与 Pods 交互的多种方式之一。

对于键值标签本身,它是完全任意的。你可以使用 foo: bar 对 Kubernetes 来说都一样。我使用了 pod: timeserver-pod,因为我发现当在其它对象中选择 Pods 时,这样读起来很好。很多文档都使用类似 app: timeserver 的格式。我避免重复使用 Deployment (timeserver) 的名称作为标签的值,以避免产生误解,即 Deployment 的名称与 Pod 标签有任何关联(因为实际上没有)。

因此,这就是使用嵌入 PodSpec 的方式构建 Deployment 对象。我希望这有助于理解对象组合以及 Pod 的引用方式。在下一节中,我们将向世界展示这个 Deployment,它将通过标签引用 Pod。

3.2.5 发布你的服务

当你的容器成功部署后,毫无疑问你将想要与之交互!每个 Pod 都会被分配一个自己的集群本地(内部)IP 地址,这可以用于集群内 Pods 之间的通信。你也可以直接在互联网上以及节点的 IP 地址上(使用 hostPort 字段)公开 Pods,但除非你正在编写实时游戏服务器,否则这很少是你要做的事情。通常情况下,尤其是在使用 Deployment 时,你将把你的 Pods 聚合成一个服务,该服务提供一个单一的访问点,具有内部(和可选的)IP,并在你的 Pods 之间进行负载均衡。即使你只有一个 Pod 的 Deployment,你仍然需要创建一个服务来提供一个稳定的地址。

除了负载均衡之外,服务还跟踪哪些 Pods 正在运行并且能够接收流量。例如,虽然你可能已经在你的 Deployment 中指定了三个副本,但这并不意味着三个副本将始终可用。如果节点正在升级,可能只有两个副本,或者在你部署 Deployment 的新版本时,可能会有超过三个副本。服务只会将流量路由到正在运行的 Pods(在下一章中,我们将介绍一些关键信息,你需要提供这些信息以确保其顺利工作)。

服务在集群内部使用,用于实现多个应用程序(所谓的微服务架构)之间的通信,并为此提供方便的功能,如服务发现。这个主题在第七章中详细讨论。现在,让我们专注于使用服务,并通过指定一个LoadBalancer类型的服务将你的新应用程序暴露给互联网,以便最终用户可以使用它。与 Deployment 一样,我们将从 YAML 配置开始。

列表 3.3 第三章/3.2_Kubernetes 部署/service.yaml

apiVersion: v1
kind: Service
metadata:
  name: timeserver
spec:
  selector:               ❶
    pod: timeserver-pod   ❶
  ports:
  - port: 80              ❷
    targetPort: 80        ❸
    protocol: TCP         ❹
  type: LoadBalancer      ❺

❶ 流量将被路由到具有此标签的 Pod

❷ 服务将公开的端口

❸ 容器的目标端口,流量将被转发到该端口

❹ 网络协议

❺ 服务类型;在这种情况下,是外部负载均衡器

端口列表允许你配置为服务用户公开哪个端口(port)以及将流量发送到的 Pod 的哪个端口(targetPort)。这允许你,比如说,在端口 80(默认的 HTTP 端口)上公开一个服务,并将其连接到运行在端口 8080 的应用程序。

Kubernetes 中的每个 Pod 和服务都有自己的内部集群 IP,因此你不需要担心 Pod 之间的端口冲突。因此,你可以将你的应用程序运行在任何你喜欢的端口上(例如 HTTP 服务的端口 80),并且为了简单起见,可以将porttargetPort设置为相同的数字,就像上一个示例中那样。如果你这样做,你可以完全省略targetPort,因为默认情况下会使用port值。

所有服务(除了在第九章中提到的无头服务)都会分配一个内部、集群本地的 IP 地址,集群中的 Pod 可以使用这个 IP 地址。如果你像上一个示例中那样指定type: LoadBalancer,那么还会额外分配一个外部 IP 地址。

注意,这个服务有一个名为selector的部分,就像我们的 Deployment 一样。服务并不引用 Deployment,实际上对 Deployment 一无所知。相反,它引用了具有给定标签的 Pod 集合(在这种情况下,将是我们的 Deployment 创建的 Pod)。再次强调,如图 3.12 所示,这更容易可视化。

03-12

图 3.12 服务与其目标 Pod(选择器)之间的关系

与 Deployment 对象不同,selector部分没有matchLabels子部分。然而,它们是等效的。Deployment 只是在 Kubernetes 中使用了一种更新、更易于表达的语法。Deployment 和 Service 中的选择器达到了相同的结果:指定对象引用的 Pod 集合。

使用以下命令在你的集群上创建服务对象

cd Chapter03/3.2_DeployingToKubernetes
kubectl create -f service.yaml

注意,创建命令(kubectl create)对于 Deployment 和服务是相同的。所有 Kubernetes 对象都可以使用四个kubectl命令进行创建、读取、更新和删除(所谓的 CRUD 操作):kubectl createkubectl getkubectl applykubectl delete

要查看服务状态,你可以调用kubectl get命令来获取对象类型,如下所示:

$ kubectl get service
NAME         TYPE           CLUSTER-IP     EXTERNAL-IP    PORT(S)        AGE
kubernetes   ClusterIP      10.22.128.1    <none>         443/TCP        1h
timeserver   LoadBalancer   10.22.129.13   203.0.113.16   80:30701/TCP   26m

注意你的服务在那里列出(在这个例子中是timeserver),以及另一个名为kubernetes的服务。如果显示kubernetes服务,你可以忽略它,因为那是运行在你集群中的 Kubernetes API 服务本身。你也可以使用kubectl get service $SERVICE_NAME来指定你感兴趣的服务。

如果输出中的External IP显示为Pending,这仅仅意味着外部 IP 正在等待负载均衡器上线。这种情况通常需要一两分钟,所以除非已经这样了一段时间,否则无需急于调试为什么它处于挂起状态。与其反复重复之前的get命令,不如通过添加--watch/-w标志(即kubectl get service -w)来流式传输任何状态变化。运行该命令,几分钟后,你应该会看到输出指示你的服务现在有一个外部 IP。

注意:要分配外部 IP,你必须在一个云提供商上运行 Kubernetes,因为提供商在幕后提供可外部路由的网络负载均衡器。如果你在本地开发,请参阅第 3.4.3 节了解如何使用kubectl port-forward等工具连接。

一旦 IP 上线,尝试通过访问 URL 来访问服务。根据前面的示例输出,这意味着访问http://203.0.113.16(但用kubectl get service获取的你的自己的外部 IP 替换它)。curl工具非常适合从命令行测试 HTTP 请求(curl http://203.0.113.16);在浏览器中查看效果一样好:

$ curl http://203.0.113.16
The time is 7:01 PM, UTC.

故障排除:无法连接

导致“无法连接”错误的两个常见原因是(1)选择器不正确和(2)你的端口设置错误。请三倍检查选择器是否与你的部署 Pod 模板中的标签匹配。验证目标端口确实是你容器监听的端口(容器启动时的调试信息中打印的端口可以是一个帮助验证的好方法),并且你正在从浏览器连接到正确的端口。

检查你是否可以直接通过kubectl的端口转发功能连接到你的 Pod 的targetPort。如果你无法直接连接到 Pod,那么问题可能就出在 Pod 本身。如果连接成功,问题可能是服务定义不正确。你可以通过以下命令设置端口转发到部署中的某个 Pod:

kubectl port-forward deploy/$DEPLOYMENT_NAME $FROM_PORT:$TO_PORT

其中,$FROM_PORT是你将使用的本地端口,$TO_PORT是你服务中定义的targetPort。使用我们之前的例子,这将是这样:

kubectl port-forward deploy/timeserver 8080:80

然后,浏览到 http://localhost:8080。这将自动选择部署中的一个 Pod(绕过服务)。你也可以直接指定要连接的特定 Pod:

kubectl port-forward pod/$POD_NAME $FROM_PORT:$TO_PORT

故障排除:外部 IP 挂起

获取外部 IP 可能需要一点时间,所以请给它几分钟。验证您的云提供商是否会为类型为 LoadBalanacer 的服务提供外部 IP。请查阅提供商的文档以获取有关在 Kubernetes 中设置负载均衡器的任何附加信息。

如果您在本地运行或只想尝试服务而不等待外部 IP,您可以将您的机器上的端口转发到服务,如下所示:

kubectl port-forward service/$SERVICE_NAME $FROM_PORT:$TO_PORT

3.2.6 与部署交互

在开发过程中,能够与容器交互以运行命令或复制文件来来回去是非常方便的。幸运的是,Kubernetes 使这几乎与 Docker 一样简单。

运行一次性命令

正如我们可以在 Docker 镜像上使用 docker exec 命令(在第二章中介绍)运行一次性命令一样,我们也可以使用 kubectl exec 在我们的 Pod 上运行一次性命令。用于诊断容器问题的常见命令是 sh,它将在容器上提供一个交互式 shell(假设容器中提供了 sh)。从那里,您可以执行您需要在容器内进行的任何其他调试步骤。

技术上,exec 是针对 Pod 运行的,但我们可以指定 Deployment 而不是特定的 Pod,kubectl 将随机选择一个 Pod 来运行该命令:

$ kubectl exec -it deploy/timeserver -- sh
# echo "Testing exec"
Testing exec

您可以使用这种方式在容器上运行任何命令,例如:

$ kubectl exec -it deploy/timeserver -- echo "Testing exec"
Testing exec

将文件复制到/从容器

同样,类似于 Docker,kubectl 有一个 cp 命令允许您在您的系统和容器之间复制文件。此命令要求您的容器镜像中存在 tar 二进制文件。这可以在您想要下载应用程序日志或其他诊断信息时很有用。默认路径是容器的当前工作目录,所以如果您在容器中有一个名为“example.txt”的文件,您可以将它复制到您的机器上,如下所示:

kubectl cp $POD_NAME:example.txt example.txt

您还可以在相反方向复制文件:

kubectl cp example.txt $POD_NAME:.

3.2.7 更新您的应用程序

现在您的应用程序已经部署并发布到全世界,毫无疑问您会想要能够更新它。对示例应用程序进行代码更改,然后使用新的版本标签构建并推送容器镜像到容器仓库。例如,如果您之前使用的是 us-docker.pkg.dev/wdenniss/ts/timeserver:1,您的新镜像可以是 us-docker.pkg.dev/wdenniss/ts/timeserver:2。您可以随意命名这个标签,但使用版本号是一个好习惯。

一旦容器镜像已推送到仓库(如我们在 3.2.2 节中所做的那样),使用列表 3.1 中的新镜像名称更新 deploy.yaml 文件——例如(强调部分):

列表 3.4 第三章/3.2.7_ 更新/deploy.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: timeserver
spec:
  replicas: 3
  selector:
    matchLabels:
      pod: timeserver-pod
  template:
    metadata:
      labels:
        pod: timeserver-pod
    spec:
      containers:
      - name: timeserver-container
 image: docker.io/wdenniss/timeserver:2 ❶

❶ 新镜像版本

保存文件并使用以下命令将更改应用到您的集群中

$ kubectl apply -f deploy.yaml 
deployment.apps/timeserver configured

当你应用这个更改时,会发生一些有趣的事情。还记得 Kubernetes 如何不断寻求执行你的要求,将系统观察到的状态驱动到你所需要的状态吗?好吧,既然你刚刚声明部署现在正在使用版本标签为 2 的镜像,并且所有 Pod 当前都标记为 1,Kubernetes 将寻求更新实时状态,以便所有 Pod 都是当前版本。

我们可以通过运行 kubectl get deploy 来看到这个功能的具体实现。以下是一些示例输出:

$ kubectl get deploy
NAME         READY   UP-TO-DATE   AVAILABLE   AGE
timeserver   3/3     1            3           10m

READY 列显示有多少 Pod 在处理流量以及我们请求了多少个。在这种情况下,所有三个都准备好了。然而,UP-TO-DATE 列却表明,这些 Pod 中只有一个处于当前版本。这是因为,为了避免一次性替换所有 Pod 导致应用程序出现停机时间,默认情况下,Pod 会通过所谓的滚动更新策略进行更新——即一次更新一个或几个。

滚动更新和其他部署策略将在下一章中详细说明,以及需要配置的重要健康检查,以避免在部署过程中出现故障。目前,只需知道 Kubernetes 将执行你的更改,并将旧的 v1 Pods 替换为新的 v2 Pods。

一旦 UP-TO-DATE 的数量等于 READY 的数量,部署就完成了。你还可以通过 kubectl get pods 来观察正在创建和替换的各个 Pod,这将显示部署中所有 Pod 的列表,包括新的和旧的。

监视部署

由于 kubectl get 命令的输出显示的是瞬时的信息,但部署是持续变化的,大多数操作员都会以自动化的方式监视部署,避免需要不断重新运行相同的命令。Kubernetes 包含一个这样的选项,即 --watch/-w 标志,它可以添加到大多数 kubectl 命令中,例如 kubectl get pods -wkubectl get deploy -w。当指定了 watch 时,任何状态的变化都会流式传输到控制台输出。

watch 标志的缺点是它有点混乱输出。如果你有很多 Pod 发生变化,你会看到一行行打印出来,很容易失去对系统当前状态的视线。我的偏好是使用 Linux 的 watch 命令。与 watch 标志不同,watch 命令会刷新整个输出,可选地显示当前更新和上次更新之间的更改。这个命令在大多数 Linux 发行版、macOS 和 Windows Subsystem for Linux (WSL) 中都可用,可以在你获取软件包的地方找到。

当安装了 watch 之后,你只需将其添加到任何 kubectl 命令之前,例如

watch kubectl get deploy

我最喜欢的 watch 标志是 -d,它将突出显示任何更改:

watch -d kubectl get deploy

在打开一个用于监视每个命令的终端窗口(或 tmux 会话窗口)的情况下,你可以仅使用 watchkubectl 组装一个实时状态仪表板。

监视部署

之前讨论的kubectl get deploykubectl get pods命令分别返回当前命名空间中的所有 Deployment 和 Pod。随着你创建更多的 Deployment,你可能只想指定你感兴趣的资源:

kubectl get deploy $DEPLOYMENT_NAME

对象的名称可以在文件顶部的元数据部分的name字段中找到。从单个 Deployment 中查看所有 Pod 可能有点棘手;然而,你可以使用标签选择器来获取一组 Pod 的状态

kubectl get pods --selector=pod=timeserver-pod

其中pod=timeserver-pod是在 Deployment 中指定的标签选择器。

3.2.8 清理

清理我们创建的对象有几种方法。你可以按对象类型和名称进行删除:

 kubectl delete deploy timeserver 
deployment.apps "timeserver" deleted
$ kubectl delete service timeserver 
service "timeserver" deleted
$ kubectl delete pod timeserver 
pod "timeserver" deleted

注意:你不需要删除由其他对象(如 Deployment)管理的 Pod,只需删除你手动创建的 Pod。删除 Deployment 将自动删除它所管理的所有 Pod。

或者,你可以通过引用单个配置文件或配置文件目录来删除对象:

$ cd Chapter03
$ kubectl delete -f 3.2_DeployingToKubernetes 
deployment.apps "timeserver" deleted
service "timeserver" deleted
$ kubectl delete -f 3.2.4_ThePodSpec/pod.yaml
pod "timeserver" deleted

如果你删除后改变了主意,你可以简单地再次创建它们(例如,kubectl create -f 3.2_DeployingToKubernetes)。这就是捕获你的配置在文件中的美妙之处:你不需要记住你对实时状态所做的任何调整,因为一切首先都在配置中更新。

集群本身通常会产生费用,所以一旦你一天的工作完成,你也可以考虑将其删除。这可以通过大多数云提供商的 UI 控制台完成。如果你使用 GKE 并通过命令行,你可以运行gcloud container clusters delete $CLUSTER_NAME --region $REGION。即使集群中没有运行任何 Pod 或 Service,节点本身通常也会产生费用(除非你使用像 GKE Autopilot 这样的平台),但删除集群应该也会清理它们。如果你保留集群并且使用按节点计费的平台,除了你的 Kubernetes 对象外,还要注意你的节点资源,这样你只有你需要的东西。

提示:本书的其余部分将假设你知道如何删除你不想保留的资源。当你尝试本书(以及其他地方)的示例时,请记住这些步骤,并确保删除你创建的任何不再需要的对象,以释放资源并减少你的账单!

3.3 命令式命令

Kubernetes 提供了两种与系统交互的方法:声明式,其中你在配置文件中指定(声明)你想要的状态,并将这些配置应用到集群中;以及命令式,其中你一次指令 API 一个命令(命令式)来执行你的愿望。配置驱动的声明式模型是大多数从业者(包括我自己)强烈首选的方法,也是你将在工作场所最常遇到的方法。

实际上,可以使用我们的容器创建 Deployment 并仅使用命令式命令将其暴露到互联网上。为了完整性,以下是这样做的方法(假设在 3.2.8 节中清理步骤之后删除了前面的示例):

  1. 创建 Deployment:

    $ kubectl create deployment timeserver \
        --image=docker.io/wdenniss/timeserver:1
    deployment.apps/timeserver created
    
  2. 在端口 80 上创建一个类型为 LoadBalancer 的服务以暴露此服务:

    $ kubectl expose deployment timeserver --type=LoadBalancer --port 80
    service/timeserver exposed 
    
  3. 观察结果:

    $ kubectl get deploy,svc
    NAME                           READY   UP-TO-DATE   AVAILABLE   AGE
    deployment.apps/timeserver     1/1     1            1           4m49s
    
    NAME                   TYPE           CLUSTER-IP      EXTERNAL-IP   AGE
    service/kubernetes     ClusterIP      10.22.128.1     <none>        5m2
    service/timeserver     LoadBalancer   10.22.130.202   <pending>     31s
    
  4. 更新 Deployment 中的容器为新版本:

    $ kubectl set image deployment timeserver timeserver=wdenniss/timeserver:2
    deployment.apps/timeserver image updated
    

与使用有时略显冗长的配置文件控制 Kubernetes 相比,这种选项乍一看可能看起来更简单。然而,有很好的理由选择基于配置的方法。第一个原因是可重复性。假设你需要在其他环境中重现配置,比如生产环境和预发布环境,这是一个相当常见的用例。使用声明式方法,你只需在新环境中应用完全相同的配置(进行任何必要的调整)。如果你选择了命令式路线,你需要记住命令,可能将它们存储在 bash 脚本中。

改变也更为困难。使用配置文件时,如果你需要更改设置,只需更新配置并重新应用即可,之后 Kubernetes 将尽职尽责地执行你的愿望。使用基于命令的方法,每个更改都是一个不同的命令:使用 kubectl set image 更改镜像,使用 kubectl scale 更改副本数量,等等。你还面临命令可能失败的风险,这可能是因为网络超时,而使用配置,更改将在下一次应用时被捕获。第十一章介绍了将配置文件视为应用程序源代码的方式,这是一种所谓的 GitOps 或配置即代码方法,其中命令式命令根本不是一种选择。

如果你遇到一个以前用命令式命令构建的系统,不要害怕,因为可以使用 kubectl get -o yaml $RESOURCE_TYPE $RESOURCE_NAME 从集群中导出配置。然而,当从运行中的集群导出此类配置时,你需要移除一些额外的字段(在 11.1.2 节中介绍)。幸运的是,切换永远不会太晚,因为无论你使用声明式还是命令式命令,Kubernetes 都会以相同的方式存储对象。

3.4 本地 Kubernetes 环境

到目前为止,本章已经使用基于云的 Kubernetes 提供商作为部署环境。当然,你可以在本地运行 Kubernetes。我选择以公共云提供商而不是本地开发集群作为起点,是为了展示如何在 Kubernetes 上进行部署,因为我假设,对于大多数人来说,目标是发布你的服务并使其超出你自己的机器可访问的范围。确实,如果你按照本章的示例顺序进行,那么恭喜你:你现在可以使用 Kubernetes 将你的应用程序部署到全世界!在未来的章节中,你将学习如何使它们投入运营、扩展规模以及更多。

然而,本地 Kubernetes 开发集群确实有其位置。在开发过程中,当你希望在 Kubernetes 集群中快速部署和迭代代码时,它们非常有用,尤其是当你的应用程序由几个不同的服务组成时。它们是尝试和学习 Kubernetes 构造的好地方,无需支付云服务费用,并且是本地测试部署配置的便捷选项。

在非生产级环境中使用 Kubernetes 在机器上本地运行与使用具有动态预配的生产级云服务相比,存在许多差异。在云中,你可以使用地理区域内分布的多个机器进行大规模扩展,而你的本地机器资源是固定的。在云中,你可以为你的服务获取生产级可路由的公共 IP 地址——在你的本地机器上则不然。由于这些差异以及更多,我相信直接在目标产品环境中学习效率更高。因此,本书的重点是生产级集群。话虽如此,只要你能理解这些差异,本地开发集群确实是一个非常有用的工具。

你需要 Kubernetes 集群来进行应用程序开发吗?

由于你在生产部署中使用 Kubernetes,并不意味着在应用程序开发期间也必须使用 Kubernetes。我观察到的一个相当常见的应用程序开发模式是使用 Docker Compose(在第 2.3 节中介绍)进行本地开发和测试,然后将应用程序部署到 Kubernetes 进行生产。

Docker Compose 对于开发只有少量服务依赖的应用程序来说效果相当不错。缺点是你需要为应用程序配置定义两次(一次是使用 Compose 进行开发,一次是在 Kubernetes 中进行生产),但对于只有少量服务依赖的应用程序来说,这种开销微乎其微。优点是 Docker 为开发提供了一些有用的工具,特别是能够将本地文件夹挂载到容器中,这意味着对于像 Python 和 Ruby 这样的解释型语言,你可以在不重新构建容器的情况下更改代码。由于你可以跳过所有与生产相关的配置,如副本数量和资源需求,因此配置起来也很简单。

强调 Compose 能够将你的本地应用文件夹挂载为可读写卷,无需重新构建容器即可编辑代码,从你在容器中运行的命令(如日志文件)中获取输出,并在你的开发文件夹中直接执行数据库升级,其有用性不容小觑。Kubernetes 确实有一些工具可以平衡这个领域,如 Skaffold,它为你提供了一个与 Kubernetes(本地或云)作为目标紧密的开发循环,但 Docker 在开发者中享有良好的声誉是有原因的。

正如我经常说的,使用最适合的工具。决定是使用本地 Kubernetes 集群还是 Docker Compose 设置来开发应用程序,并使用最适合你的方法。即使你选择使用 Compose 进行应用程序开发,你仍然可以利用本地 Kubernetes 集群进行部署测试。

运行本地 Kubernetes 集群有多种选项。其中最受欢迎的是 Docker Desktop 和 Minikube。实际上,如果你已经安装了 Docker Desktop,那么你已经有了一个本地的单节点 Kubernetes 集群!Minikube 是由 Kubernetes 项目创建的,设置起来也很简单,并提供了一些更高级的选项,如多个节点,这在你想测试更高级的 Kubernetes 构造,如 Pod 扩散策略和亲和力(第八章)时很有用。

3.4.1 Docker Desktop 的 Kubernetes 集群

Docker Desktop 自带单节点 Kubernetes 开发环境。如果你已经安装了 Docker Desktop,那么你已经有了一个本地的 Kubernetes 环境。按照 docs.docker.com/desktop/kubernetes/ 中的说明,只需两步即可开始使用:

  1. 在 Docker Desktop 设置中启用 Kubernetes 并确保其正在运行。

  2. 使用 kubectl 切换到 Docker Desktop 集群。

注意:Docker 的本地 Kubernetes 选项包含在“Docker Desktop”产品中。如果你是通过 Linux 上的 Docker Engine 安装使用 Docker,则没有这项功能。

一旦 Docker Desktop 启用了 Kubernetes,你可以查看上下文并切换到它:

kubectl config get-contexts
kubectl config use-context docker-desktop

实际上,你可以使用这些命令切换到之前连接过的任何集群,包括本章之前使用过的云平台。任何时候你想切换集群,只需运行

kubectl config get-contexts
kubectl config use-context $CONTEXT

我发现当频繁在集群之间切换时,那两个命令输入起来有点繁琐,所以我强烈推荐 kubectx 工具 (github.com/ahmetb/kubectx),它可以使切换上下文变得更快。要使用 kubectx 切换上下文,请使用

kubectx
kubectx $CONTEXT

如果你在 Docker Desktop 上遇到任何问题,那么调试菜单中的“重启 Kubernetes 集群”和“清理/清除数据”选项将是你的朋友。

3.4.2 Minikube

Minikube 是本地测试的另一个优秀选择,它通过提供一个多节点环境,允许你测试更多的 Kubernetes 功能。它由开源 Kubernetes 社区维护。按照 minikube.sigs.k8s.io/docs/start/ 中的说明为你的系统安装 Minikube。

安装完成后,要启动一个虚拟的多节点集群(我推荐这样做,因为它更接近生产环境中的 Kubernetes 环境),请运行 minikube start 并传递你想要的节点数量:

minikube start --nodes 3

start 命令将自动配置 kubectl 以使用 Minikube 上下文,这意味着任何 kubectl 命令都将作用于 Minikube 集群。要更改上下文回到不同的集群,例如你的生产集群,请使用前一小节中描述的 kubectl configkubectx 命令。

一旦 Minikube 运行起来,你就可以像使用常规 Kubernetes 集群一样使用它,按照本章中的说明进行操作。在开始使用之前,为了验证一切按预期运行,请运行 kubectl get nodes 来检查你是否可以连接到集群:

$ kubectl get nodes
NAME           STATUS   ROLES           AGE     VERSION
minikube       Ready    control-plane   4m54s   v1.24.3
minikube-m02   Ready    <none>          4m32s   v1.24.3
minikube-m03   Ready    <none>          3m58s   v1.24.3

如果你已经完成使用 Minikube 并想恢复你机器的 CPU 和内存资源,请运行 minikube stop。要删除所有数据并为下一次使用不同设置(如不同的节点数量)的新 Minikube 集群腾出空间,请使用 minikube delete

3.4.3 使用你的本地 Kubernetes 集群

kubectl 设置为指向你偏好的本地 Kubernetes 集群时,你可以使用本章前面展示的相同 kubectl 命令在本地部署你的应用程序。然而,有两个重要的区别,那就是你如何暴露和访问服务以及如何引用本地构建的容器镜像。要部署本章中的示例应用程序,从示例根目录运行

$ cd Chapter03/3.2_DeployingToKubernetes
$ kubectl create -f .
deployment.apps/timeserver created
service/timeserver created

声明式配置的好处

在整本书中,示例都是使用声明式配置而不是 imperative 命令给出的。换句话说,要创建一个 Deployment,我们首先创建 Deployment 的配置,然后应用它,而不是直接使用 kubectl 创建 Deployment。

这种方法的好处之一是,你可以在本地测试你的配置,然后有信心将其部署到生产环境中,而不需要记住一大堆一次性命令。注意我们如何将相同的配置文件部署到本地集群和到生产集群。真 neat!

访问服务

与在云 Kubernetes 提供商上开发不同,当在本地创建 LoadBalancer 类型的服务时,你不会得到一个外部 IP。对于 Docker Desktop、Minikube 以及实际上任何 Kubernetes 集群,你也可以使用 kubectl 将你的本地机器的端口转发到集群内部的服务。这对于针对本地 Kubernetes 集群进行测试和调试云集群非常有用。要本地暴露服务,请使用

kubectl port-forward service/$SERVICE_NAME $FROM_PORT:$TO_PORT

其中 FROM_PORT 是你将在本地访问服务的端口,而 TO_PORT 是服务的 IP 地址。在我们的演示中,选择 8080 作为高级端口,命令可能看起来如下所示:

kubectl port-forward service/timeserver 8080:80

然后,你可以浏览到 http://localhost:8080 来连接到服务。port-forward 有很多有用的标志¹,包括 --address 0.0.0.0,以便绑定到所有网络接口,这样你就可以从网络上的其他设备访问转发的服务(如果你的防火墙允许这样做)。端口转发对于调试在云 Kubernetes 平台上运行的服务也非常有用。

Minikube 提供了一种额外的路由流量到你的服务的方法²。你可以通过以下方式访问:

minikube service $SERVICE_NAME

对于前面章节中的示例,那将是

minikube service timeserver

从 Docker 本地访问 Kubernetes 服务

你是否在 Kubernetes 中运行一个服务,出于某种原因你想直接从运行在 Kubernetes 外部的 Docker 容器访问它?例如,你正在 Docker 中进行一些快速迭代,并想访问 Kubernetes 中已建立的服务。

解决方案很简单。将服务转发,使得端口在你的本地机器上是开放的,就像之前描述的那样。然后你可以在 Docker 中直接运行的容器中引用它,使用 host.docker.internal 在你转发的任何端口上。host.docker.internal 是容器如何与本地机器上的服务通信的方式,由于你已将端口转发到本地机器,连接可以通过。

例如,假设你在 Kubernetes 中部署 Redis(见第九章),并使用 kubectl port-forward service/timeserver 6379:6379 转发端口。然后你想要从运行 Python 的本地 Docker 容器连接到它。你可以使用 redis.Redis(host='host.docker.internal', port='6379') 来实现。祝您编码愉快!

部署本地镜像

默认情况下,本地 Kubernetes 集群将尝试从互联网拉取容器镜像——表现得就像一个生产 Kubernetes 集群一样。对于公共镜像,如 ubuntu 或我的示例镜像 docker.io/wdenniss/timeserver,一切都会正常工作。但是,为了向本地集群提供你自己构建的本地镜像,你需要采取额外步骤。当然,你可以像在生产环境中一样将它们上传到公共容器注册库,这样你的本地集群就会像在生产环境中一样拉取它们。

然而,在开发过程中上传你构建的每个镜像,却有点麻烦。这会减慢你的开发速度,因为你需要等待推送和拉取。此外,除非你使用公共镜像,否则你需要提供凭证,以便你的本地集群可以访问它们(通常当你从 Kubernetes 提供商的容器注册库拉取私有镜像时,这一步骤会为你完成)。

要使您的本地集群使用本地镜像,您需要修改您的 Kubernetes 部署配置文件的两个地方。首先,添加 imagePullPolicy 参数并将其设置为 Never,其次,使用不带任何仓库前缀的本地镜像名称引用您的镜像。

本地构建的镜像的路径只是它们的仓库和版本标签,没有仓库 URL 前缀。如果您已经使用 docker build . -t timeserver 构建了一个镜像,就像我们在第二章中所做的那样,您可以在配置文件中将此引用为 image: timeserver:latest(使用 latest 作为版本标签将给我们最新的构建镜像)。运行 docker images 查看可用本地镜像列表。以下是一个引用此本地构建镜像的 Deployment 示例:

列表 3.5 第三章/3.4.3_ 本地开发/deploy.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: timeserver
spec:
  replicas: 3
  selector:
    matchLabels:
      pod: timeserver-pod
  template:
    metadata:
      labels:
        pod: timeserver-pod
    spec:
      containers:
      - name: timeserver-container
 image: timeserver:latest ❶
 imagePullPolicy: Never  ❷

❶ 对本地可用镜像的引用

❷ 镜像拉取策略阻止 Kubernetes 尝试从远程位置获取此本地镜像。

提示:仅将 imagePullPolicy: Never 配置应用于您计划本地提供的镜像。您不希望将此设置在远程镜像上,因为它们不会被拉取,并且会因 ErrImageNeverPull 状态而出现错误。如果您看到这个错误,这意味着镜像尚未本地可用,但部署已被配置为使用本地镜像。

如果您使用的是 Minikube,还有一步。虽然 Docker Desktop 可以访问您使用 Docker 本地构建的所有镜像,但 Minikube 不能(它有自己的独立容器运行时,并且不会与您本地的 Docker 安装共享镜像)。要将您想要推送到 Minikube 的本地镜像推送到 Minikube,只需运行以下命令

minikube image load $REPOSITORY:$TAG

例如

minikube image load timeserver:latest

然后,像之前一样使用 kubectl 应用您的更改:

kubectl apply -f deploy.yaml

摘要

  • Kubernetes 集群由控制平面和运行您的容器的节点组成。

  • 您通过 Kubernetes API 与集群交互,通常使用命令行工具 kubectl

  • 要将您自己的应用程序部署到 Kubernetes,首先,将容器镜像上传到容器仓库。

  • 使用如 Deployment 这样的对象指定工作负载,它封装了一个 Pod,该 Pod 定义了您的容器。

  • 服务用于创建网络端点并将容器暴露到互联网。

  • Pod 通过标签被其他对象(如 Deployment 和 Service)引用。

  • Kubernetes 使用声明性配置,通常是 YAML 格式的配置文件。

  • 您通过配置指定您的需求,Kubernetes 控制器会持续尝试实现和满足这些需求。

  • 更新应用程序就像使用新容器版本修改配置并将更改应用到集群一样简单。

  • Kubernetes 将比较配置版本之间的更改,并实现任何指定的更改。


^ (1.) kubernetes.io/docs/reference/generated/kubectl/kubectl-commands#port-forward

kubernetes.io/docs/setup/learning-environment/minikube/#services

4 自动化操作

本章涵盖

  • 创建持久、可靠的部署

  • 让 Kubernetes 在无需您干预的情况下保持应用运行

  • 无停机更新应用

Kubernetes 可以自动化许多操作,如容器崩溃时重启容器,以及在硬件故障的情况下迁移应用。因此,Kubernetes 可以帮助使您的部署更加可靠,而无需您 24/7 进行监控。这些自动化操作是 Kubernetes 的核心价值主张之一,理解它们是充分利用 Kubernetes 提供的一切的必要步骤。

Kubernetes 可以通过启动新版本并监控其状态,确保在删除旧版本之前它已准备好服务流量,从而帮助您在不出现中断和故障的情况下更新应用。

为了帮助 Kubernetes 在正常操作和升级期间保持应用的无停机运行,您需要通过称为健康检查的过程提供有关应用状态的信息。在下一节中,我们将介绍如何将各种健康检查添加到您的应用中,在稍后的章节中,我们将介绍如何使用 Kubernetes 内置的滚动策略来更新应用,而不会出现故障或停机。

4.1 带健康检查的自动化正常运行时间

有些条件 Kubernetes 可以自行检测和修复。如果您的应用崩溃,Kubernetes 会自动重启它。同样,如果运行容器的节点失败或被移除,Kubernetes 会注意到您的部署缺少副本,并在集群中可用空间上启动新的副本。

但其他类型的应用故障怎么办,比如挂起的进程、停止接受连接的 Web 服务,或者当外部服务变得不可访问时依赖该服务的应用?Kubernetes 可以优雅地检测并尝试从所有这些条件中恢复,但它需要您提供有关应用健康状况以及它是否准备好接收流量的信号。提供这些信号的过程称为健康检查,Kubernetes 将其称为活跃性和就绪性探测

由于 Kubernetes 无法知道平台上每个运行的服务处于关闭或开启、准备好或未准备好接收流量的具体含义,因此应用必须自行实现此测试。简单来说,探测会查询容器状态,容器会检查其内部状态,如果一切正常则返回成功代码。如果请求超时(例如,如果应用负载过重)或容器本身确定存在问题(例如,关键依赖项问题),则探测被视为失败。

4.1.1 活跃性和就绪性探测

在 Kubernetes 中,容器的健康状态由两个独立的探针确定:生存性,它确定容器是否正在运行,以及就绪性,它指示容器何时能够接收流量。这两个探针使用相同的技巧进行检查,但 Kubernetes 使用探针结果的方式不同(见表 4.1)。

表 4.1 生存性和就绪性的区别

生存性 就绪性
语义含义 容器是否正在运行? 容器是否准备好接收流量?
探针失败超过阈值的含义 Pod 被终止并替换。 Pod 在探针通过之前被移除以接收流量。
从失败的探针中恢复的时间 慢:Pod 在失败时重新调度并需要时间启动。 快:Pod 已经运行,一旦探针通过即可立即接收流量。
容器启动时的默认状态 通过(活动)。 失败(未就绪)。

存在两种探针类型的原因有几个。一个是启动状态。注意生存性探针从通过或活动状态开始(假设容器在 Pod 证明其不活动之前是活动的),而就绪性探针从未就绪状态开始(假设容器在证明其能够服务流量之前无法服务流量)。

没有就绪性检查,Kubernetes 没有办法知道容器何时准备好接收流量,因此它必须假设容器在启动的那一刻就绪,并且它将立即被添加到服务的负载均衡轮询中。大多数容器需要数十秒甚至数分钟才能启动——因此立即发送流量会导致启动期间一些流量损失。就绪性检查通过仅在内部测试通过时才报告“就绪”来解决此问题。

同样,与生存性检查一样,需要容器重启的条件可能与指示容器未准备好接收流量的条件不同。最好的例子是等待外部依赖项(如数据库连接)的容器。直到容器建立了数据库连接,它才不应该提供服务(因此它是未就绪的),但内部容器是良好的。您不希望太急切地替换此容器,以便它有足够的时间建立其依赖的数据库连接。

存在两种类型探针的其他原因包括敏感性和恢复时间。就绪性检查通常调整得很快,以便快速将 Pod 从负载均衡器中移除(因为这是一种快速且成本低的操作来启动),并在检查再次通过时将其添加回去,而生存性检查通常调整得稍微不那么急迫,因为重新创建容器所需的时间更长。

4.1.2 添加就绪性探针

对于一个网络服务,一个基本的健康检查可以简单地测试“服务是否在处理流量?”在为你的服务构建一个专门的健康检查端点之前,你可以在服务上找到任何返回 HTTP 200 状态码的端点,并将其用作健康检查。

如果根路径在所有响应中都返回 HTTP 200,你就可以直接使用该路径。由于在我们的示例容器中根路径表现是这样的,所以下面的就绪检查将正常工作。

列表 4.1 第四章/4.1.2_Readiness/deploy.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: timeserver
spec:
  replicas: 3
  selector:
    matchLabels:
      pod: timeserver-pod
  template:
    metadata:
      labels:
        pod: timeserver-pod
    spec:
      containers:
      - name: timeserver-container
        image: docker.io/wdenniss/timeserver:1
 readinessProbe:
 initialDelaySeconds: 15 ❶
 periodSeconds: 30     ❷
 httpGet:      ❸
 path: /      ❸
 port: 80      ❸
 scheme: HTTP      ❸
 timeoutSeconds: 2     ❹
 failureThreshold: 1  ❺
 successThreshold: 1   ❻

❶ 初始延迟后

❷ 每 30 秒

❸ 执行此 HTTP 请求。

❹ 2 秒后超时。

❺ 将一个错误响应视为容器未就绪。

❻ 将一个成功的响应视为容器在被视为未就绪后已准备好。

从根目录更新timeserver Deployment:

cd Chapter04/4.1.2_Readiness
kubectl apply -f deploy.yaml

现在,任何容器未能响应就绪检查时,该 Pod 将被临时从服务中移除。假设你有三个 Pod 副本,其中一个未能响应。任何访问服务的流量都将被路由到剩余的两个健康 Pod。一旦 Pod 返回成功(在这种情况下是一个 HTTP 200 响应),它将被重新加入到服务中。

这种就绪检查在更新期间尤为重要,因为你不希望 Pod 在启动时接收流量(因为这些请求将失败)。通过正确实现的就绪检查,你可以实现零停机更新,因为流量只被路由到已就绪的 Pod,而不是正在创建的 Pod。

观察差异

如果你想通过自己的实验来查看有无就绪检查之间的差异,请尝试以下测试。在一个 shell 窗口中,创建一个没有就绪检查的 Deployment(让我们使用第三章中的那个):

cd Chapter03/3.2_DeployingToKubernetes
kubectl create -f .

等待服务被分配一个外部 IP:

kubectl get svc -w

现在,设置你的 IP,并在单独的控制台窗口中设置对服务端点的监视:

EXTERNAL_IP=203.0.113.16
watch -n 0.25 -d curl "http://$EXTERNAL_IP"

在第一个窗口中,触发一个回滚:

kubectl rollout restart deploy timeserver

随着 Pod 的重启,你应该在 curl 窗口中看到一些间歇性的连接问题。

现在更新 Deployment 以包含就绪检查(如本节中所示)并应用:

cd ../../
cd Chapter04/4.1.2_Readiness
kubectl apply -f deploy.yaml

这次,由于部署有一个就绪检查,你不应该在curl窗口中看到任何连接问题。

4.1.3 添加存活探针

存活探针与就绪探针具有相同的规范,但使用键livenessProbe指定。另一方面,探针的使用方式相当不同。就绪探针的结果决定了 Pod 是否接收流量,而失败的存活探针将导致 Pod 重启(一旦达到失败阈值)。

我们在前一节中添加到部署的就绪检查是基本的,因为它只是使用了服务的根路径而不是一个专门的端点。我们现在可以继续这种做法,并在以下示例中使用就绪探测的相同端点作为活跃探测,进行一些小的修改以增加故障容忍度。由于容器在活跃探测失败时达到阈值会重启,并且需要一些时间才能恢复,我们不希望活跃探测被设置为过于敏感。让我们为我们的部署添加一个活跃探测,如果它失败 180 秒(在 30 秒间隔内六次失败),则将其重启。

列表 4.2 第四章/4.1.3_Liveness/deploy.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: timeserver
spec:
  replicas: 3
  selector:
    matchLabels:
      pod: timeserver-pod
  template:
    metadata:
      labels:
        pod: timeserver-pod
    spec:
      containers:
      - name: timeserver-container
        image: docker.io/wdenniss/timeserver:1
        readinessProbe:
          initialDelaySeconds: 15
          periodSeconds: 30
          httpGet:
            path: /
            port: 80
            scheme: HTTP
          timeoutSeconds: 2
          failureThreshold: 1
          successThreshold: 1
 livenessProbe:            ❶
 initialDelaySeconds: 30 ❷
 periodSeconds: 30      ❸
 httpGet:        ❹
 path: / ❹
 port: 80 ❹
 scheme: HTTP ❹
 timeoutSeconds: 5      ❺
 failureThreshold: 10  ❻
 successThreshold: 1       ❼

❶ 这次指定一个活跃探测

❷ 在初始延迟 30 秒后

❸ 每 30 秒

❹ 执行此 HTTP 请求。

❺ 5 秒后超时(比就绪检查更宽容)。

❻ 连续 10 次错误响应以指示容器未就绪。

❼ 考虑一个成功的响应以指示容器在被视为未就绪后已就绪。

使用这些最新的更改更新timeserver部署:

cd Chapter04/4.1.3_Liveness
kubectl apply -f deploy.yaml

现在,您的部署有了就绪和活跃探测。即使这些基本的探测也能极大地提高部署的可靠性。如果您就此止步,这可能对于一个基本的应用程序来说已经足够了。下一节将详细说明一些进一步的设计考虑,以确保您的探测在生产使用中更加稳固。

4.1.4 设计良好的健康检查

在使用现有端点,就像我们在前两个章节中所做的那样,尽管健康检查路径总比没有好,但通常最好为您的应用程序添加专用的健康检查端点。这些健康检查应该实现就绪和活跃的具体语义,并尽可能轻量。如果不理解活跃和就绪之间的语义差异,可能会因为重启过多和级联故障而看到不稳定性。此外,如果您正在重用其他端点,那么它可能比所需的更重。为什么要在整个 HTML 页面渲染的成本上付费,而一个简单的 HTTP 头部响应就足够了呢?

在创建 HTTP 端点以实现这些检查时,考虑任何正在测试的外部依赖项非常重要。通常,您不希望在活跃探测中检查外部依赖项;相反,它应该只测试容器本身是否正在运行(假设您的容器将重试其外部连接的连接)。对于运行良好的容器或仅因为无法连接到另一个有问题的服务而重启的容器,实际上并没有太多价值。这可能导致不必要的重启,从而产生波动并导致级联故障,尤其是如果您有一个复杂的依赖图。然而,对于活跃探测中不测试依赖项的原则有一个例外,我将在后面的章节中介绍。

由于存活探测仅测试服务器是否响应,结果可以且应该是极其简单的,通常只是一个 HTTP 200 状态响应,甚至可以没有响应体文本。如果请求能够到达服务器代码,那么它必须是活跃的,这已经足够了。

对于就绪性探测而言,通常希望它们测试它们的外部依赖,如数据库连接(见图 4.1)。假设你有三个 Pod 副本,但只有两个可以连接到你的数据库。只让那些两个完全功能的 Pod 在负载均衡器轮询中是有意义的。测试连接的一种方法是在就绪性检查中从数据库中查找单行。

04-01

图 4.1 存活性和就绪性检查以及外部依赖

例如,数据库连接检查的伪代码可能看起来像这样

result = sql.execute("SELECT id FROM users LIMIT 1;")
if result:
  http_response(200, "Ready")
else:
  http_response(503, "Not Ready")

执行一个简单的 SQL 查询应该足以确保数据库既已连接又可响应。与其使用SELECT查询,你还可以执行任何其他数据库操作,但我个人更喜欢SELECT语句的合法性。如果它有效,我就有信心其他查询也会有效。

Python 的timeserver示例应用没有数据库依赖。但是,让我们重构代码以包括特定的路径,我们将它们命名为/healthz/readyz,因为为这些探测保留专用端点是最佳实践。

列表 4.3 第四章/timeserver2/server.py

from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
from datetime import datetime

class RequestHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        match self.path:
            case '/':
                now = datetime.now()
                response_string = now.strftime("The time is %-I:%M %p, UTC.")
                self.respond_with(200, response_string)
            case '/healthz': ❶
 self.respond_with(200, "Healthy") ❶
 case '/readyz': ❶
 dependencies_connected = True ❶
 # TODO: actually verify any dependencies ❶
 if (dependencies_connected): ❶
 self.respond_with(200, "Ready") ❶
 else: ❶
 self.respond_with(503, "Not Ready") ❶
            case _:
                self.respond_with(404, "Not Found")

    def respond_with(self, status_code: int, content: str) -> None:
        self.send_response(status_code)
        self.send_header('Content-type', 'text/plain')
        self.end_headers()
        self.wfile.write(bytes(content, "utf-8")) 

def startServer():
    try:
        server = ThreadingHTTPServer(('', 80), RequestHandler)
        print("Listening on " + ":".join(map(str, server.server_address)))
        server.serve_forever()
    except KeyboardInterrupt:
        server.shutdown()

if __name__== "__main__":
    startServer()

❶ 新的健康检查路径

在更新了这些新端点的部署配置之后,我们得到以下列表中的代码。

列表 4.4 第四章/4.1.4_ 良好的健康检查/deploy.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: timeserver
spec:
  replicas: 3
  selector:
    matchLabels:
      pod: timeserver-pod
  template:
    metadata:
      labels:
        pod: timeserver-pod
    spec:
      containers:
      - name: timeserver-container
        image: docker.io/wdenniss/timeserver:2
        readinessProbe:
          initialDelaySeconds: 15
          periodSeconds: 30
          httpGet:
 path: /readyz     ❶
            port: 80
            scheme: HTTP
          timeoutSeconds: 2
          failureThreshold: 1
          successThreshold: 1
        livenessProbe:
          initialDelaySeconds: 30
          periodSeconds: 30
          httpGet:
 path: /healthz ❶
            port: 80
            scheme: HTTP
          timeoutSeconds: 5
          failureThreshold: 3      ❷
          successThreshold: 1

❶ 更新后的端点

❷ 现在存活探测变得轻量级,我们可以降低失败阈值。

以通常的方式应用这个新配置。你的应用程序可能具有更复杂的就绪性和存活性逻辑。这里的healthz端点可能适用于许多 HTTP 应用程序(简单地测试 HTTP 服务器是否响应请求就足够了)。然而,每个具有数据库等依赖的应用程序都应该定义自己的就绪性测试,以确定你的应用程序是否真正准备好服务用户请求。

4.1.5 重新调度未就绪的容器

前一节详细介绍了在 Kubernetes 中设置存活和就绪检查的标准方法,其中你只需验证就绪检查中的服务依赖。不测试存活检查中的依赖可能会出现一个有问题的条件。通过将关注点分离为就绪性(“容器是否准备好接收流量?”)和存活性(“容器是否正在运行?”),可能会出现容器正在运行,但由于容器重试逻辑中的错误,外部连接从未解决的情况。换句话说,你的容器可能会永远处于未就绪状态,这可能需要重启来解决。

记得我们通常不在存活性检查中测试就绪性,因为这可能会导致 Pod 被太快地重新创建,没有为外部依赖项的解决提供任何时间。然而,如果 Pod 长时间不可用,重新创建这个 Pod 可能是有意义的。有时最好的办法就是关掉它再打开它!

不幸的是,Kubernetes 没有直接表达这种逻辑的方法,但很容易将其添加到我们自己的存活性检查中,以便如果 Pod 在一段时间内没有就绪,它就会失败。你可以简单地记录每个就绪性成功响应的时间,然后如果时间过长(例如,5 分钟),就失败存活性检查。以下列表提供了将此逻辑简单实现到timeserver容器的示例。

列表 4.5 第四章/timeserver3/server.py

from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
from datetime import datetime, timedelta

last_ready_time = datetime.now()                                             ❶

class RequestHandler(BaseHTTPRequestHandler):

    def do_GET(self):
 global last_ready_time

        match self.path:
            case '/':
                now = datetime.now()
                response_string = now.strftime("The time is %-I:%M %p, UTC.")
                self.respond_with(200, response_string)
            case '/healthz':
 if (datetime.now() > last_ready_time + timedelta(minutes=5)):❷
 self.respond_with(503, "Not Healthy") ❷
 else: ❷
 self.respond_with(200, "Healthy") ❷
            case '/readyz':
                dependencies_connected = True 
                # TODO: actually verify any dependencies
                if (dependencies_connected):
 last_ready_time = datetime.now()                      ❸
                    self.respond_with(200, "Ready")
                else:
                    self.respond_with(503, "Not Ready")
            case _:
                self.respond_with(404, "Not Found")

    def respond_with(self, status_code: int, content: str) -> None:
        self.send_response(status_code)
        self.send_header('Content-type', 'text/plain')
        self.end_headers()
        self.wfile.write(bytes(content, "utf-8")) 

def startServer():
    try:
        server = ThreadingHTTPServer(('', 80), RequestHandler)
        print("Listening on " + ":".join(map(str, server.server_address)))
        server.serve_forever()
    except KeyboardInterrupt:
        server.shutdown()

if __name__== "__main__":
    startServer()

❶ “最后就绪”时间初始化为当前时间,以便在启动后允许 5 分钟。

❷ 如果自上次成功的就绪性结果(或自启动以来)已过去 5 分钟,则失败存活性检查。

❸ 每次就绪性通过时,时间都会更新。

如果容器在给定时间内没有就绪,存活性检查最终会失败,这给了它重启的机会。现在,我们拥有了两者之最:我们不在存活性检查中测试外部依赖项,但在就绪性检查中测试。这意味着当依赖项未连接时,我们的容器不会收到流量,但它也不会重启,这给了它一些时间来自我修复。但是,如果在 5 分钟后容器仍然没有就绪,它将失败存活性检查并被重启。

实现这一目标(在长时间不可用后重启容器)的另一种方法是同时使用存活性和就绪性探针的存活性端点,但具有不同的容忍度。也就是说,例如,就绪性检查在 30 秒后失败,但存活性检查仅在 5 分钟后失败。这种方法仍然给容器一些时间来解决任何相互依赖的服务,在最终重启之前,这可能会表明容器本身存在问题。这种技术从技术上讲不是 Kubernetes 的惯用方法,因为你在存活性检查中仍在测试依赖项,但它完成了工作。

总之,这两个探针对于向 Kubernetes 提供它需要的信息以自动化应用程序的可靠性至关重要。理解它们之间的区别并实施适当的检查,考虑到应用程序的具体细节,是至关重要的。

4.1.6 探针类型

到目前为止,示例都假设了 HTTP 服务,因此探针被实现为 HTTP 请求。Kubernetes 可以用于托管许多不同类型的服务,以及没有任何服务端点的批处理作业。幸运的是,有几种方法可以公开健康检查。

HTTP

对于提供 HTTP 服务的任何容器,建议使用 HTTP。服务公开一个端点,例如/healthz。HTTP 200 响应表示成功;任何其他响应(或超时)表示失败。

TCP

对于除了 HTTP 之外的基于 TCP 的服务(例如,SMTP 服务),建议使用 TCP。如果可以打开连接,则探针成功。

readinessProbe:
  initialDelaySeconds: 15
  periodSeconds: 30
 tcpSocket:    ❶
 port: 25 ❶
  successThreshold: 1
  failureThreshold: 1

❶ TCP 探针规范

Bash 脚本

对于不提供 HTTP 或 TCP 服务的任何容器,如不运行服务端点的批处理作业,建议使用 bash 脚本。Kubernetes 将执行您指定的脚本,允许您执行所需的任何测试。非零退出代码表示失败。第 10.4 节有一个后台任务存活探针的完整示例。

4.2 更新运行中的应用程序

实施就绪性检查后,现在您可以无停机地推出应用程序更改。Kubernetes 在更新期间使用就绪性检查来确定新 Pod 何时准备好接收流量,并根据您设置的参数控制部署的速度。您可以选择几种不同的部署策略,每种策略都有其自身的特点。

4.2.1 滚动更新策略

Kubernetes 提供的默认零停机更新策略是滚动更新。在滚动更新中,会以组的形式创建具有新版本的新 Pod(组的大小是可调的)。Kubernetes 等待新组的 Pod 变得可用,然后终止运行旧版本相同数量的 Pod,重复此过程,直到所有 Pod 都运行新版本(图 4.2)。

04-02

图 4.2 滚动更新期间的 Pod 状态。使用此策略,在部署完成之前,请求可以由应用程序的旧版或新版提供服务。

此策略的目标有两个:

  • 在部署过程中提供连续的运行时间

  • 在更新过程中尽可能少地使用额外资源

重要的是,使用此策略时,您的应用程序的两个版本(旧版和新版)需要能够共存,因为它们将同时运行一段时间。也就是说,您的后端或任何其他依赖项必须能够处理这两个不同的版本,并且当用户进行不同请求时,可能会得到交替的版本。想象一下,重新加载页面看到新版本,然后再次重新加载看到旧版本。根据您拥有的副本数量,滚动更新可能需要一段时间才能完成(因此,任何回滚也可能需要一段时间)。

让我们配置我们的 Deployment 以使用以下列表中的滚动更新策略。

列表 4.6 Chapter04/4.2.1_RollingUpdate/deploy.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: timeserver
spec:
  replicas: 3
  selector:
    matchLabels:
      pod: timeserver-pod
  strategy:
 type: RollingUpdate ❶
 rollingUpdate: ❷
 maxSurge: 2 ❷
 maxUnavailable: 1  ❷
  template:
    metadata:
      labels:
        pod: timeserver-pod
    spec:
      containers:
      - name: timeserver-container
        image: docker.io/wdenniss/timeserver:3

❶ 滚动更新策略

❷ 可选配置

可以使用maxSurgemaxUnavailable选项来控制部署的速度。

MaxSurge

maxSurge 控制在滚动更新过程中你愿意创建多少额外的 Pods。例如,如果你设置了副本数量为 5,并且 maxSurge2,那么可能会有七个 Pods(不同版本)被调度。

权衡是,这个数字越高,滚动更新完成得越快,但(暂时)使用的资源也越多。如果你高度优化成本,可以将 maxSurge 设置为 0。或者,对于大型部署,你可以在滚动更新期间暂时增加集群中可用的资源,通过添加节点,并在滚动更新完成后移除它们。

最大不可用数

maxUnavailable 设置了在更新过程中可以不可用的最大 Pod 数量(也接受百分比值,并向下取整到最接近的整数)。如果你已经调整了副本数量以处理预期的流量,你可能不希望将此值设置得比 0 高得多,因为在更新期间你的服务质量可能会下降。这里的权衡是,值越高,一次可以替换的 Pods 越多,滚动更新完成得越快,但暂时会减少能够处理流量的就绪 Pods 数量。

滚动更新可能与降低可用性的其他事件同时发生,例如节点故障。因此,对于生产工作负载,我建议将 maxUnavailable 设置为 0。需要注意的是,如果你将其设置为 0 而你的集群没有可调度的资源,滚动更新将会卡住,你将看到 Pod 处于 Pending 状态,直到资源变得可用。当 maxUnavailable0 时,maxSurge 不能也为 0,因为为了保持完全可用性,系统需要暂时增加副本数量,以便为新 Pods 提供就绪时间。

建议

滚动更新是大多数服务的一个很好的策略。对于生产服务,maxUnavailable 最好设置为 0maxSurge 至少应该是 1,或者如果你有足够的备用容量并且希望更快地滚动更新,可以设置得更高。

使用滚动更新部署更改

一旦你的 Deployment 配置为使用滚动更新,部署你的更改就像更新 Deployment 清单(例如,使用新的容器版本)并用 kubectl apply 应用更改一样简单。对 Deployment 所做的几乎所有更改,包括就绪和存活检查,也都是版本化的,并且会像新的容器镜像版本一样进行滚动更新。如果你需要,你也可以使用 kubectl rollout restart deploy $DEPLOYMENT_NAME 强制进行滚动更新,而无需在 Deployment 对象中做任何更改。

4.2.2 重新创建策略

另一种方法——有些人可能会说是老式方法——是直接切换应用程序,删除旧版本的所有 Pod 并安排新版本的替换。与这里讨论的其他策略不同,这种方法不是零停机时间。它几乎肯定会导致一些不可用(见图 4.3)。如果有适当的就绪性检查,这种停机时间可以短到启动第一个 Pod 的时间,前提是它能在那一刻处理客户端流量。这种策略的好处是它不需要新旧版本之间的兼容性(因为两个版本不会同时运行),也不需要任何额外的计算能力(因为它是一个直接替换)。

04-03

图 4.3 使用重新创建策略的 Pod 状态。在这种类型的滚动更新中,应用将经历一段完全停机的时间和一段容量下降的时间。

这种策略可能适用于开发和测试环境,以避免需要过度配置计算能力来处理滚动更新,并提高速度,但除此之外,通常应避免使用。要使用此策略,在列表 4.6 中给出的 strategy 字段中,你将声明:

  strategy:
    type: Recreate

4.2.3 蓝绿策略

蓝绿策略是一种滚动更新策略,其中新应用程序版本与现有版本一起部署(见图 4.4)。这些版本被命名为“蓝色”和“绿色”。当新版本完全部署、测试并准备好使用时,服务将切换。如果出现问题,可以立即切换回旧版本。经过一段时间,如果一切看起来都很好,可以删除旧版本。与前面的两种策略不同,旧版本仍然可以提供服务,只有在新版本经过验证(并且通常涉及人类决策)后才会被删除。

04-04

图 4.4 蓝绿滚动更新中的 Pod 状态。与之前的策略不同,有两个操作点,其他系统(可能包括人类操作者)会做出决策。

这种策略的好处包括以下内容:

  • 一次只运行一个版本的程序,以确保用户体验的一致性。

  • 滚动更新非常快(在几秒内完成)。

  • 回滚操作也很快。

其缺点包括以下内容:

  • 它暂时消耗了双倍的计算资源。

  • 它不是由 Kubernetes Deployments 直接支持的。

这种方法是一种高级推出策略,在大规模部署中很受欢迎。通常还包括其他几个过程。例如,当新版本准备好时,可以先由一组内部用户进行测试,然后在外部流量的百分比之前进行测试,在 100% 切换之前——这个过程被称为 金丝雀分析。切换后,通常有一个时间段,在新版本在旧版本缩放之前继续评估(这可能需要几天)。当然,同时保持两个版本缩放会增加资源使用量,但权衡是可以在那个窗口期间实现几乎即时的回滚。

与前两种策略——滚动更新和重新创建——不同,Kubernetes 并没有内置对蓝/绿部署的支持。通常,用户会使用额外的工具来帮助处理这种部署的复杂性。这些工具包括 Istio,用于在细粒度级别分割流量,以及 Spinnaker,用于通过金丝雀分析和决策点帮助自动化部署管道。

尽管缺乏内置支持,但在 Kubernetes 中执行蓝/绿部署是可能的。如果没有上述工具来帮助处理管道和流量分割,这将是一个稍微手动的过程,并且会失去一些好处,比如能够在极小比例的生产流量上进行金丝雀分析。然而,这并不意味着它难以实现。

回想一下我们在第三章中部署的部署和服务。对于这个应用程序采用蓝/绿策略只需要有一个额外的部署。复制部署,给其中一个添加后缀 -blue,另一个添加 -green。这个后缀应该应用于部署的名称和 Pod 的标签。然后您可以通过选择带有 -blue-green 标签的 Pod 来通过服务引导流量。

在这种情况下,您在部署配置中指定的更新策略是 Recreate 策略。由于只有非活动部署中的 Pod 被更新,删除所有旧版本并创建带有新版本的 Pod 不会导致停机,并且比滚动更新更快。

服务的选择器用于决定将流量路由到哪个版本(图 4.5)。在这个双部署系统中,一个版本是活动的,另一个在任何给定时间都不是活动的。服务通过标签选择器选择活动部署的 Pod。

04-05

图 4.5 一个服务在两个不同的部署之间交替,每个部署都有一个不同的容器版本。

使用蓝/绿部署推出新版本的步骤如下:

  1. 识别非活动部署(服务未选择的那个)。

  2. 使用新的容器镜像版本更新非活动部署的镜像路径。

  3. 等待部署完全推出(使用 kubectl get deploy)。

  4. 更新服务的选择器,使其指向新版本的 Pod 标签。

更新步骤是通过修改相关资源的 YAML 配置并使用 kubectl apply 应用更改来执行的。下次您想对此应用程序进行更改发布时,步骤相同,但标签会反转(如果上一次更新中蓝色是活动的,则绿色将是下一次的活动)。

如前所述,此策略将 Deployment 使用的 Pod 数量翻倍,这可能会影响您的资源使用。为了最小化资源成本,当您当前没有进行发布时,可以将非活动 Deployment 缩放到 0,当您即将进行发布时,将其缩放回与活动版本匹配。您可能还需要调整集群中的节点数量(在第六章中介绍)。

4.2.4 选择滚动发布策略

对于大多数 Deployment,内置的滚动发布策略应该足够。使用 RollingUpdate 作为在 Kubernetes 上实现零停机时间更新的简单方法。为了实现零停机时间或中断,您还需要实施就绪检查;否则,流量可能会在容器完全启动之前发送到您的容器。您的应用程序的两个版本可以同时处理流量,因此您必须考虑到这一点来设计属性,如数据格式。能够支持至少当前版本和上一个版本通常是良好的实践,因为它还允许您在出现问题的情况下回滚到上一个版本。

当您真的不希望同时运行两个应用程序版本时,Recreate 策略非常有用。它可以用于像遗留的单实例服务这样的东西,其中一次只能存在一个副本。

蓝绿是一种高级策略,需要额外的工具或流程,但具有几乎即时切换的优势,同时提供了两个世界的最佳之处,即一次只有一个版本是活动的,但没有 Recreate 策略的停机时间。我建议从内置策略开始,但记住当您需要更多功能时可以考虑这个策略。

摘要

  • Kubernetes 提供了许多工具来帮助您保持您的部署运行和更新。

  • 定义健康检查很重要,这样 Kubernetes 就有信号来通过重启卡住或无响应的容器来保持您的应用程序运行。

  • Kubernetes 使用存活探针来确定您的应用程序何时需要重启。

  • 就绪探针控制哪些副本从服务接收流量,这在更新期间尤其重要,可以防止请求丢失。

  • Kubernetes 还可以帮助您在不中断服务的情况下更新应用程序。

  • RollingUpdate 是 Kubernetes 中的默认滚动发布策略,在最小化额外资源使用的同时,提供零停机时间的发布。

  • Recreate 是一种替代的滚动发布策略,它通过一些停机时间进行就地更新,但不会使用额外的资源。

  • 蓝绿部署是一种 Kubernetes 不支持直接使用的滚动发布策略,但仍然可以通过标准的 Kubernetes 结构来执行。

  • 蓝绿部署提供了一些最高品质的保证,但更为复杂,并且暂时将部署所需的资源数量翻倍。

5 资源管理

本章涵盖

  • Kubernetes 如何在您的集群中分配资源

  • 将工作负载配置为仅请求所需的资源

  • 过度承诺资源以提高您的成本/性能比

  • 通过内部并发平衡 Pod 副本数

第二章介绍了容器是新的隔离级别,每个容器都有自己的资源,第三章讨论了 Kubernetes 中的可调度单元——Pod(它本身是一组容器的集合)。本章介绍了根据资源需求和您需要提供给系统的信息,如何将 Pod 分配到机器上,以便您的 Pod 能够获得所需的资源。了解 Pod 如何分配到节点有助于您在资源请求、爆发、过载、可用性和可靠性等方面做出更好的架构决策。

5.1 Pod 调度

Kubernetes 调度程序执行基于资源的 Pod 到节点的分配,实际上是整个系统的核心。当您将配置提交给 Kubernetes(如我们在第三章和第四章中所做的那样),是调度程序负责找到集群中具有足够资源并负责启动和运行 Pod 中容器的节点(图 5.1)。

05-01

图 5.1 对用户应用的配置做出响应,调度程序在节点上创建 Pod 副本。

调度程序和相关组件的工作并不止于此。在部署对象(我们在本书中一直使用)的情况下,它持续监控系统,目标是使系统状态符合您的要求。换句话说,如果您的部署请求两个 Pod 副本,调度程序不仅会创建这些副本然后忘记它们;它还会持续验证是否仍然有两个副本在运行。如果发生某些情况(例如,由于某些故障,节点消失),它会尝试在新的位置调度 Pod,以确保您的期望状态(在这种情况下,两个副本)仍然得到满足(图 5.2)。

05-02

图 5.2 如果其中一个节点出现问题,Kubernetes 控制平面的健康检查失败。因此,调度程序在健康节点上创建一个新的 Pod 副本。

由于节点故障而由调度程序重新创建 Pod 的行为与我们在上一章中讨论的 Pod 重启是不同的。由于存活性或就绪性失败而导致的 Pod 重启由 kubelet 在节点上本地处理,而调度程序负责监控节点的健康状态,并在检测到问题时重新分配 Pod。

由于集群中的每个节点都受可用资源的限制,并且 Pod 本身可能具有不同的资源需求,因此调度器的一个重要职责是找到足够的空间来运行您的 Pod(图 5.3)。在决定将您的 Pod 的容器放置在集群中的位置时,它考虑了多个调度维度,无论是首次部署还是响应中断,如图 5.2 所示。

05-03

图 5.3 根据资源需求在两个节点上分配了五个容器

调度器的任务是找到集群中合适的位置来放置 Pod,基于其资源需求和(我们将在第八章中介绍)任何其他放置要求。任何无法在集群中放置的 Pod 将具有Pending状态(如果您有 Pod 长时间保持此状态,请参阅第三章中标题为“故障排除:卡在挂起状态”的建议)。

5.1.1 指定 Pod 资源

您通过在 Deployment 清单中指定资源请求来向调度器提供它做出调度决策所需的信息(以及具有嵌入式 Pod 规范的其他工作负载类型)。到目前为止,本书中的示例尚未指定其资源需求,但对于生产级部署,需要添加这些信息。一个需要 20%的 CPU 核心时间和 200MiB 内存的 Pod 将按照以下列表进行指定。

列表 5.1 第五章/5.1.1_PodResources/deploy_requests.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: timeserver
spec:
  replicas: 3
  selector:
    matchLabels:
      pod: timeserver-pod
  template:
    metadata:
      labels:
        pod: timeserver-pod
    spec:
      containers:
      - name: timeserver-container
        image: docker.io/wdenniss/timeserver:3
 resources:
 requests: ❶
 cpu: 200m ❶
 memory: 250Mi ❶

❶ 此容器的资源请求

这里示例中的200m代表 200 毫核——即一个核心的 20%。您也可以使用浮点数(例如,0.2);然而,在 Kubernetes 实践中,使用毫核是非常常见的。内存的Mi后缀表示米贝(MiB),而Gi表示吉贝(1,024 的幂)。MG表示兆字节和吉字节(1,000 的幂)。

指定资源很重要,因为它为 Kubernetes 提供了将 Pod 需求与节点容量匹配所需的信息。某些 Pod 未指定资源意味着它们将被随机放置在节点上。比较图 5.4 中的并排图。在左侧,我们有五个 Pod 根据其需求被放置在两个节点上,而在右侧,有三个 Pod 没有指定资源,所以它们只是被扔在与其他 Pod 相同的节点上。注意,当未指定资源时,节点资源的一半被分配给了相同的 5 个 Pod。这里的风险是,未指定资源规格的 Pod 可能会资源不足,或者如果它们使用的内存超过节点上的可用内存,可能会被驱逐。

05-04

图 5.4 比较了所有 Pod 都有资源请求和只有一些 Pod 有资源请求时的 Pod 分配情况。没有资源请求的 Pod 将根据最佳努力原则共享剩余容量,不考虑它们的实际需求。

到目前为止,Kubernetes 的 Pod 放置可能听起来相当简单——我们只是在请求与资源之间进行配对。如果考虑到突发的能力——即消耗比请求更多的资源,这实际上会变得简单。很多时候,一个进程可能不需要它请求的所有资源。如果节点上的其他 Pod 可以在临时基础上使用这部分容量,那不是很好吗?这正是 Kubernetes 所提供的,并且它通过限制进行配置。一个 Pod(如下面的列表所示)声明了它们请求的资源,这些资源用于调度,并设置了限制,这些限制在 Pod 被调度和运行后约束了使用的资源。

列表 5.2 第五章/5.1.1_PodResources/deploy_requests_limits.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: timeserver
spec:
  replicas: 3
  selector:
    matchLabels:
      pod: timeserver-pod
  template:
    metadata:
      labels:
        pod: timeserver-pod
    spec:
      containers:
      - name: timeserver-container
        image: docker.io/wdenniss/timeserver:3
        resources:
          requests:
            cpu: 200m
            memory: 250Mi
 limits: ❶
 cpu: 300m ❶
 memory: 400Mi ❶

❶ 该容器的资源限制。容器可以使用高达 CPU 核心的 30%和 400 MiB 的内存。

在节点上放置 Pod 时,调度器只考虑 Pod 的资源请求(在调度时根本不考虑限制)。然而,请求和限制都会对运行 Pod 的性能产生影响。

一旦运行,超出其内存限制的 Pod 将被重启,而超出其 CPU 限制的 Pod 将被限制。这些操作直接在节点上由 kubelet 处理。

在资源争用的情况下,超出其内存请求的 Pod 可能会被驱逐(有关如何选择驱逐 Pod 的详细信息,请参阅 5.1.3 节),而超出其 CPU 请求的 Pod 将被限制在请求的 CPU 上。

由于这些值在 Pod 的调度和运行中起着如此重要的作用,因此对于 Pod 中的容器设置请求和限制是最佳实践。但您如何确定设置它们的值呢?请继续阅读,了解请求和限制如何相互作用以形成服务质量(QoS)类,以及您如何测量应用程序的性能以确定要设置的值。

5.1.2 服务质量

当请求高于限制,或者根本未设置时,会引入一个新的问题:当这些 Pod 消耗了过多的资源(最常见的是过多的内存),并且需要被驱逐以回收资源时,您应该怎么做?为了解决这个问题,Kubernetes 会对 Pod 进行排序,以确定先移除哪个。

在规划您的工作负载时,请考虑它们所需的服务质量。Kubernetes 提供了三个服务质量级别:保证的、可突发的和尽力而为的。

保证类

在保证类 Pod 中,限制被设置为等于请求。这种配置是最稳定的,因为 Pod 保证获得它请求的资源——不多也不少。如果您的 Pod 有多个容器,它们都必须满足这一要求,Pod 才能被认为是保证的。

保证类 Pod 将在不同条件下始终有相同数量的资源可用,并且它们不会被从节点上驱逐,因为它们不可能使用比已安排的更多资源。

可突发现类

可扩展类 Pod 的限制设置高于请求,并且可以在资源可用的情况下“突发”临时增加(例如,来自其他未使用所有请求或节点上未分配空间的 Pod)。您需要小心处理这些 Pod,因为可能会有一些不可预见的结果,例如意外依赖突发。比如说,一个 Pod 落在空节点上,可以尽情地突发。然后,在稍后的某个时候,它被重新调度到资源较少的另一个节点上;性能现在将不同。因此,在多种条件下测试可扩展 Pod 非常重要。如果一个 Pod 有多个容器,并且它不符合保证类的标准,且其中任何一个容器设置了请求,则该 Pod 被认为是可扩展的。除非它们超过了一个不可压缩资源(如内存)的请求,否则这些 Pod 不会因驱逐而受到威胁。

尽力而为

没有设置任何请求或限制的 Pod 被认为是“尽力而为”的,并且会被调度到 Kubernetes 希望的地方。这种设置是所有类别中最低的,我强烈建议不要使用这种模式。您可以通过设置非常低的请求来实现类似的结果,这比闭着眼睛希望结果最好要明确得多。

当考虑 Pod 的稳定性时,至少将资源请求设置为足够高的值,以便为它们提供运行资源,并避免完全不设置任何资源请求。对于高优先级、关键工作负载,应始终将限制设置为请求,以确保保证性能。这些 Pod 在资源竞争时是第一个被从节点驱逐的。

5.1.3 驱逐、优先级和抢占

在资源有限(如内存)的竞争情况下(例如,太多 Pod 同时尝试增加内存使用),Kubernetes 将通过一种称为驱逐的过程,通过移除使用超出其请求分配的资源(Pod)来回收资源。因此,确保 Pod 资源得到充分指定非常重要。属于管理型工作负载结构(如 Deployment)的驱逐 Pod 将在集群中重新调度,通常是在另一个节点上。但是,如果您的 Pod 被频繁驱逐,可能会降低工作负载的可用性,这也是您应该增加资源请求的信号。

驱逐

保证类 Pod 在资源竞争时永远不会被驱逐,因此为了实现坚不可摧的部署,始终将 Pod 的限制设置为与其请求相等,以将它们定义为保证类。本节其余部分将讨论在考虑驱逐时非保证类 Pod 的排名方式以及如何影响排序。

在寻找要驱逐的 Pod 时,Kubernetes 首先考虑那些使用比其请求量更多资源的 Pod,并按其优先级数字排序,然后按 Pod 使用的额外资源(竞争资源)量排序。由于最佳努力 QoS 类 Pod 没有资源请求,它们将是首先被驱逐的(从使用最多资源的 Pod 开始)。默认情况下,具有相同优先级数字(0)的所有 Pod 以及具有相同优先级的 Pod,其使用量超过请求量的多少将用于对它们进行排名,如图 5.5 所示。

05-05

图 5.5 具有相同优先级的 Pod 的驱逐顺序

被驱逐的错误状态

如果您查询您的 Pod 并看到Evicted状态,这表明调度器驱逐了一个 Pod,因为它使用了比请求量更多的资源。如果这种情况偶尔发生,这可能是可以接受的,但如果您看到频繁的驱逐,请增加您容器请求的资源量,并检查您是否需要向您的集群添加更多计算能力。

优先级

优先级只是一个整数(介于01,000,000,000之间),您可以通过优先级类将其分配给 Pod 以改变其排名。图 5.6 显示了将优先级数字分配给 Pods 的驱逐顺序,如图 5.5 所示。如您所见,驱逐首先按优先级排序,然后按使用量超过请求量的多少排序。那些未使用超过其请求量的 Pod 不会面临驱逐风险,无论其优先级如何。

05-06

图 5.6 具有多个优先级值的 Pod 的驱逐顺序

要创建自己的优先级级别,您首先需要创建一个 PriorityClass 对象。

列表 5.3 第五章/5.1.3_ 优先级/priorityclass.yaml

apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
  name: high-priority
value: 1000000              ❶
preemptionPolicy: Never     ❷
globalDefault: false        ❸
description: "Critical services."

❶ 优先级整数

❷ 如果集群中没有可用容量,则此优先级类不会导致低优先级 Pod 被驱逐。

❸ 是否应将此优先级类设置为默认值

然后将 PriorityClass 对象分配给一个 Pod。

列表 5.4 第五章/5.1.3_ 优先级/deploy.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: timeserver
spec:
  replicas: 3
  selector:
    matchLabels:
      pod: timeserver-pod
  template:
    metadata:
      labels:
        pod: timeserver-pod
    spec:
 priorityClassName: high-priority  ❶
      containers:
      - name: timeserver-container
        image: docker.io/wdenniss/timeserver:3
        resources:
          requests:
            cpu: 200m
            memory: 250Mi

❶ 为此部署使用的优先级类

优先级数字在调度期间也被使用。如果您有很多 Pod 等待调度,调度器将首先调度优先级最高的 Pod。使用优先级来控制调度顺序对于确定哪些批处理作业应该首先执行特别有用(批处理作业在第十章中介绍)。

预占

当单独使用时,优先级有助于对工作负载进行排序,以便更重要的工作负载首先被调度,最后被驱逐。然而,可能存在一种情况,即集群在一段时间内没有足够的资源,高优先级 Pod 被留在Pending状态,而低优先级 Pod 已经运行。

如果您希望高优先级工作负载主动提升低优先级工作负载,而不是等待容量释放,您可以通过更改PriorityClass中的preemptionPolicy字段来添加抢占行为,如下面的列表所示。

列表 5.5 Chapter05/5.1.3_Priority/priorityclass-preemption.yaml

apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
  name: high-priority-preemption
value: 1000000
preemptionPolicy: PreemptLowerPriority     ❶
globalDefault: false
description: "Critical services."

❶ 如果集群中没有可用容量,此优先级类将导致低优先级 Pod 被驱逐。

幸运的是,Kubernetes 不会忘记由于驱逐或抢占而被从节点移除的 Pod,只要这些 Pod 属于部署或其他受管理的工作负载类型。这些 Pod 将被返回到Pending状态,并在集群有足够容量时重新调度。这也是您始终应使用类似 Deployment 这样的工作负载结构的重要原因之一,因为以这种方式驱逐的独立 Pod 将不会被重新调度。

何时使用优先级和抢占

优先级和抢占是 Kubernetes 的有用功能,由于它们对驱逐和调度的影响,因此理解它们非常重要。在花费太多时间配置所有部署的优先级之前,我建议您优先确保您的 Pod 请求和限制是适当的,因为这是最重要的配置。

当您在处理多个部署并试图通过过度提交来节省资金,即通过从集群中榨取每一滴计算能力时,优先级和抢占才能真正发挥作用,这需要您有一种方式来表示 Pod 的相对重要性以解决资源争用。我不建议从这个设计开始,因为您只是在增加复杂性。更简单的方法是分配足够的资源来充分调度所有工作负载,然后在以后进行微调以从集群中挤出更多效率。再次强调,确保您的关键服务性能的最简单方法是适当地设置资源请求,并在您的集群中拥有足够的节点以便它们都能被调度。

5.2 计算 Pod 资源

在上一节中,我们讨论了为什么对于获得最可靠的运行体验来说,为 Pod 设置适当的资源请求和限制很重要。但您如何确定最佳值呢?关键在于运行并观察您的 Pod。

Kubernetes 自带了一个资源使用监控工具kubectl top,您可以使用它来查看 Pod 和节点使用的资源。我们将重点关注 Pod,因为这是我们设置正确资源请求所需了解的内容。

首先,部署一个资源请求过高的 Pod。这个 Pod 可能已经在生产中部署了——毕竟,对于性能来说,通常可以高估所需的资源(尽管对于预算来说并不总是这样)。这个练习的目标是先从高开始,观察 Pod 的实际使用情况,然后将请求配对以提供正确的资源并避免浪费。

在你充分了解 Pod 需要多少资源之前,最好保持限制未设置(允许它使用节点上的所有空闲资源)。这并不能完全解决设置一些资源请求的需求,因为你更希望一开始就分配比所需更多的专用容量。

列表 5.6 Chapter05/5.2_ResourceUsageTest/deploy.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: timeserver
spec:
  replicas: 1                      ❶
  selector:
    matchLabels:
      pod: timeserver-pod
  template:
    metadata:
      labels:
        pod: timeserver-pod
    spec:
      containers:
      - name: timeserver-container
        image: docker.io/wdenniss/timeserver:3
        resources:                 ❷
          requests:
            cpu: 200m
            memory: 250Mi

❶ 将副本设置为 1 进行负载测试

❷ 正在测试的 Pod。未设置资源限制,因此我们可以分析使用情况。

运行 kubectl top pods(你可能需要等待一分钟左右数据可用),并注意启动资源使用情况,特别是内存。了解 Pod 启动所需的资源量是有用的,因为这将是如果你选择使用可弹性的 QoS 的下限。

备注:如果你使用 Minikube 并且收到类似 error: Metrics API not available 的错误,你可以使用 minikube addons enable metrics-server 启用指标。

现在,直接向 Pod 施加足够的负载以模拟真实世界的使用。性能工具如 Apache Bench(与 Apache¹ 一起安装)在这里很有帮助。以下是一个 Apache Bench 命令示例,它将使用 20 个线程生成总共 10,000 个请求。你通常想运行这个测试一段时间(比如 5 分钟),以便更容易观察峰值:

kubectl get svc
EXTERNAL_IP=203.0.113.16
ab -n 10000 -c 20 http://$EXTERNAL_IP/

你还可以观察一个 Pod 接收正常的生产负载。由于你不想在生产环境中过度限制 Pod 直到你知道它需要多少资源,你应该先高估资源请求,然后测量实际使用情况。一旦你有了对实际需求的良好衡量,你可以在以后调整请求并适当调整。

在你的 Pod 下加负载时,再次运行 kubectl top pods(记住,可能需要一分钟左右的时间来反映最新值,所以保持你的负载模拟运行)。输出将类似于:

$ kubectl top pod
NAME                          CPU(cores)    MEMORY(bytes)   
timeserver-dd88988f5-tq2d9    145m          11Mi

一旦你对自己的 Pod 完成测试,你应该有像表 5.1 中显示的值(此表中的数据纯粹是示例)。

表 5.1 启动和负载下的内存和 CPU 使用情况

CPU (核心) 内存 (字节)
启动 20m 200Mi
正常负载下 200m 400Mi

可能需要重复这个过程几次,并获取你的 Pod 在不同负载(例如,低、正常和高流量)和时间框架下的值。多个时间框架(例如,启动后直接、启动后 1 小时、启动后 1 天)有助于考虑使用量的潜在增长(例如,内存泄漏)。因此,你可能会得到像表 5.2 中那样的结果。

表 5.2 测试后的内存和 CPU 使用情况

CPU (核心) 内存 (字节)
启动 20m 400Mi
正常负载下 200m 500Mi
高负载下 850m 503Mi
1 小时后 210m 505Mi
1 天后 200m 600Mi

5.2.1 设置内存请求和限制

拥有这些数据在手,你应该如何设置你的资源请求?首先,你现在有一个绝对的下限:400 MiB。由于你只能保证获得你的资源请求,并且你知道你的 Pod 在负载下使用 400 MiB,将这个值设置得更低很可能会导致你的 Pod 被 OOMKilled(因内存不足而被终止)。如果你设置了更高的资源限制,你可能不会立即看到它,但当你知道你需要它时,你不想依赖额外的容量。

这使 400 MiB 成为正确的请求吗?可能不是。首先,你肯定想要有一个缓冲区,比如 10%。此外,你可以看到一小时后,使用了 505 MiB,所以这可能是更好的起始下限(在考虑缓冲区之前)。但是,它需要是 600 MiB 吗?我们看到,一天后,Pod 需要这么多,可能是由于某个地方的泄漏。这个答案取决于具体情况。你当然可以设置这个更高的限制,然后你可以有信心你的 Pod 可以运行一天。然而,由于 Kubernetes 会自动重启崩溃的容器(包括由于系统因内存不足而移除它们的情况),因此系统在一天后重启泄漏进程以回收内存可能是可以接受的,甚至可能是理想的。

当内存泄漏是可以接受的

Instagram 众所周知^a 禁用了 Python 的垃圾回收以获得 10% 的 CPU 性能提升。虽然这可能不是对每个人都适用,但它是一个值得考虑的有趣模式。如果所有的事情都是自动发生的,并且有数千个副本,那么一个进程随着时间的推移而膨胀并在重启时进行清理,这真的那么重要吗?可能不是。

Kubernetes 会自动重启崩溃的容器(包括由于系统因内存不足而移除它们的情况),这使得实现这种模式变得相对容易。我不会在没有彻底调查的情况下推荐这种策略,但我确实认为如果你的应用程序有一个缓慢的泄漏,这可能不是你最需要修复的最高优先级错误。

重要的是,你需要确保至少给容器分配足够的资源,以便它能启动并运行一段时间。否则,你可能会陷入 OOMKill 冲突循环,这对任何人来说都不是什么乐事。拥有足够的副本(下一节将介绍)也是避免用户可见故障的重要因素。

^a instagram-engineering.com/dismissing-python-garbage-collection-at-instagram-4dca40b29172

使用你收集的数据,通过查看你的 Pod 在负载下的内存使用情况来找到下限,并添加一个合理的缓冲区(至少 10%)。根据这个示例数据,我会选择 505 MiB * 1.1 ≈ 555 MiB。你知道这足以让 Pod 在负载下至少运行一小时,还有一点富余。根据你的预算和风险概况,你可以相应地调整这个数字(数值越高,风险越低,但成本越高)。

因此,请求至少需要覆盖 Pod 的稳定状态。那么内存限制呢?假设您的数据是稳定的,覆盖了所有情况(即,在您观察期间没有执行的高内存代码路径),我不会将其设置得比一天内的值高太多。设置过高的限制(比如说,是限制的两倍或更高)实际上帮助不大,因为您已经测量了 Pod 在一天内需要的内存量。如果您确实有内存泄漏,当达到限制时系统重启 Pod 可能比允许 Pod 过度增长更好。

另一个选择是将限制设置为保证 QoS 类的请求。这种策略的优点是无论节点上运行着什么,都能保证 Pod 的恒定性能。在这种情况下,您应该给 Pod 留一点额外的资源缓冲,因为 Pod 一旦超出请求量就会被终止。

5.2.2 设置 CPU 请求和限制

与内存不同,CPU 是可压缩的。换句话说,如果应用程序没有得到它需要的所有 CPU 资源,它只是运行得更慢。这与内存有很大不同:如果应用程序耗尽内存,它将会崩溃。您仍然可能希望给应用程序足够的 CPU 资源。否则,性能会下降,但与内存相比,不需要有那么多额外容量的缓冲。

在我们应用示例数据表 5.2 中,我们可以看到稳定状态大约是 CPU 的 200 mCPU。这似乎是您 CPU 请求的一个很好的起点。如果您想省钱并且可以接受性能下降,您可以将它设置得稍低一些。

CPU 限制是 Kubernetes 可以提高您资源效率的领域,因为您可以将限制设置得高于请求,以使您的应用程序在需要时能够消耗节点上的未使用周期。与内存一样,Kubernetes 只保证请求的 CPU,但通常允许 Pod 利用节点上的未使用容量以运行得更快是很不错的。对于花费大量时间等待外部依赖(例如,等待数据库响应)的 Web 应用程序,节点上通常会有额外的 CPU 容量,活跃请求可以利用这些容量。

与内存一样,将限制设置得高于请求(即可爆发的 QoS 类)的缺点是您的性能不会是恒定的。在空节点上运行的爆发性 Pod 将比在 Pod 密集的节点上拥有更多的资源。虽然,通常来说,能够通过消耗节点上的未使用容量来处理流量爆发是很不错的,但如果恒定性能很重要,将限制设置为请求可能更可取。

5.2.3 通过超分配 CPU 降低成本

降低成本的一种策略是在节点上过度承诺 CPU 资源。这种过度承诺是通过将 CPU 请求设置为一个低值(低于 Pod 实际需要的值)来实现的,因此可以在节点上放置比如果将 CPU 请求设置为实际使用量更多的 Pod。

这种策略可以节省资金,但有一个明显的性能缺点。然而,对于被认为是高度突发性的工作负载,这可以是一个非常理想的策略。假设你正在托管数百个低流量网站。每个网站每小时可能只收到几个请求,只需要在那个时间使用 CPU。对于这种部署,每个应用程序可以有一个 50m 的 CPU 请求(允许每个核心调度 20 个 Pod)和 1000m 的限制(允许它临时爆满到满核心)。

使这种过度承诺策略有效的关键是深入了解机器上运行的其他内容。如果你有信心大多数网站大部分时间都是空闲的,这种方法可能可行。然而,如果所有 Pod 中的容器需要同时爆发,性能可能会下降。这种类型的设置意味着你的容器不再相互隔离:现在,你需要相应地了解和计划节点的组成。然而,这是可以做到的。

当然,最安全的做法是完全不进行过度承诺。一个合理的折衷方案是不要过度承诺太多。通过将 Pod 的 CPU 资源限制设置得高于它们的请求,可以以机会主义的方式减少延迟。然而,你应该将 CPU 资源请求设置得足够高,以便处理合理的基线负载,这样过剩的容量就不会被依赖。

5.2.4 平衡 Pod 副本和内部 Pod 并发

现在你已经了解了资源请求如何影响你的 Pod 的调度以及它们获取的资源,考虑 Pod 内部的并发性就很有意义了。Pod 的并发性(例如,应用程序运行了多少个进程/线程)会影响资源的大小,通过在 Pod 内部使用并发而不是 Pod 副本,可以在效率和持久性之间进行权衡。

如果你来自一个应用安装成本较高的环境,无论是服务器上的货币成本还是配置实例的时间,你的应用可能已经通过使用线程或派生配置了大量的内部并发。在 Kubernetes 世界中,由于资源效率高,并发工作者仍然具有优势。我不会将当前有 10 个工作者的 Pod 部署为 10 个副本,每个副本只有一个工作者。容器内部的并发性在内存使用上非常高效,因为派生共享了应用程序二进制文件使用的一些内存,而线程共享的更多。CPU 也在工作者之间池化,这对于典型的 Web 应用程序来说很有用,因为它们在等待外部依赖上花费了大量的时间——这意味着通常有额外的容量可以同时处理多个请求。

在单个 Pod 中平衡并发工作者的好处是,Pod 的副本越多,它的耐用性就越高。例如,假设你有一个 Pod 的两个副本,每个副本有 18 个工作者,总共处理 36 个并发连接,如图 5.7 所示。如果其中的一个 Pod 崩溃(或者因为未通过第四章中设置的健壮性检查而重启),在 Pod 重启之前,你的一半容量将离线。更好的方法可能是拥有六个 Pod 副本,每个副本有六个工作者,这样仍然保持了一些容器间的并发性,同时增加了一些冗余。

05-07

图 5.7 36 个工作者可能的两种部署比较

为了达到正确的平衡,可以使用一个简单的启发式方法。考虑你需要为用户服务的总工作者数量,以及其中有多少可以在任何给定时间内离线而不会对用户产生明显影响。一旦计算出可以离线的数量——使用我们之前的例子,假设在问题被发现之前,36 个工作者中有 16% 可以离线——那么你可以在单个 Pod 中集中的最多工作者数量是 16%,即六个。

简而言之,你拥有的 Pod 副本越多,设计就越安全,但在资源使用效率方面就越低。因此,考虑如何平衡你自己的可用性和资源需求是值得的。

在平衡您拥有的 Pod 副本数量之后,另一个提高可用性的重要属性是确保您的 Pod 分布在多个节点上。毕竟,如果您设计了多个副本,但所有这些副本都在同一个节点上运行,那么如果该节点出现故障,您仍然面临单点故障的风险。幸运的是,大多数 Kubernetes 平台(包括 Google Kubernetes Engine)默认启用了 Pod 扩散策略,该策略将在所有可用节点和多个区域(对于区域集群的情况)上扩散 Pod。要获得此默认行为,通常只需确保您在集群的不同区域中有一定数量的节点。如果您想深入了解节点放置和 Pod 扩散拓扑,第八章将为您解答。

摘要

  • Kubernetes 调度器位于系统的核心,负责在您的基础设施上为您的 Deployment 的 Pod 找到合适的家。

  • 调度器会尝试在给定的节点上放置尽可能多的 Pod,前提是 Pod 的容器设置了适当的资源请求。

  • Kubernetes 通过 Pod 的资源请求和限制来管理资源的分配、过度承诺和回收。

  • 使用突发来过度承诺资源可以节省资源,但会引入性能可变性。

  • 您的工作负载对请求和限制的指定设置了它们所接收的质量服务(QoS)。

  • 在设计工作负载时,副本数量和 Pod 内部线程/进程工作计数之间存在可用性/资源使用权衡。

  • 大多数平台默认启用 Pod 扩散,以确保副本通常不会放置在同一个节点上,从而避免单点故障。请确保您在集群中有几个节点以实现更高的可用性。


^(1)httpd.apache.org/docs/2.4/install.html

第二部分 进入生产阶段

现在你已经学习了 Kubernetes 的基础知识,例如创建和部署容器、设置资源限制以及配置自动化存活和就绪探针,现在是时候将事情提升到下一个层次了。本部分涵盖了你在 Kubernetes 上构建生产系统所需了解的内容。这包括诸如手动和自动化扩展你的应用程序、设计应用程序以便它能够首先进行扩展;将多个服务连接在一起,这些服务可能由不同的团队管理;以及将 Kubernetes 配置存储在与你的代码一起的同时保持一切更新和安全。

在第一部分中涵盖的无状态部署之外,还介绍了额外的作业选项,包括那些需要状态(附加磁盘)、后台队列、批处理作业以及在每个节点上运行的守护进程 Pod。你将学习如何通知 Kubernetes 你的调度要求,例如分散你的 Pod 或将它们定位在一起,以及如何针对特定的硬件要求,如 Arm 架构、GPU 和 Spot 计算资源。

6 扩展

本章涵盖

  • 手动扩展 Pods 和节点

  • 使用 CPU 利用率和其他指标动态扩展 Pod 副本

  • 利用托管平台根据您的 Pods 所需资源添加和删除节点

  • 使用低优先级占位符 Pods 来提供突发容量

  • 设计应用程序以便它们可以扩展

现在我们已经部署了应用程序,并设置了健康检查以保持其运行而无需干预,现在是时候考虑如何进行扩展了。我将其命名为“扩展”,因为我认为每个人都非常关心当应用程序取得巨大成功,您需要为所有新用户提供服务时,系统架构是否能够处理扩展。但别担心,我还会介绍如何缩小规模,以便您在淡季节省费用。

最终的目标是,通过自动扩展来使我们的部署投入运行。这样,我们就可以在澳大利亚的海滩上熟睡或放松,而我们的应用程序可以动态地响应流量峰值。为了达到这个目标,我们需要确保应用程序能够扩展,了解 Kubernetes 集群中 Pods 和节点的扩展交互,并确定正确的指标来配置自动扩展器为我们完成所有这些工作。

6.1 扩展 Pods 和节点

将您的应用程序容器化并在 Kubernetes 上部署,这是构建能够扩展并支持您增长的应用程序部署的巨大一步。现在,让我们来了解一下,当成功时刻到来,流量增加时,如何实际进行扩展(并在淡季降低规模以节省一些费用)。

在 Kubernetes 中,您需要扩展的基本资源有两个:您的应用程序(Pods)以及它们运行的计算资源(节点)。使生活变得复杂的是,您扩展这些资源的方式是分开的,尽管需求(例如,更多的应用程序容量)在一定程度上是相关的。仅仅扩展 Pods 是不够的,因为它们会耗尽运行所需的计算资源,同样,仅仅扩展节点也是不够的,因为这只会增加空余容量。需要同时扩展并且保持正确的比例。幸运的是,有一些工具可以使您的生活更轻松(以及一些完全自动化的平台,它们会为您处理一切),以下讨论中我会涉及这些内容。

首先,为了处理更多流向您应用程序的流量,您需要增加 Pod 副本的数量。从手动方法开始,您可以通过以下方式更新您的 Deployment 配置以实现所需的副本数。

列表 6.1 第六章/6.1_Replicas/deploy.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: timeserver
spec:
 replicas: 6      ❶
  selector:
    matchLabels:
      pod: timeserver-pod
  template:
    metadata:
      labels:
        pod: timeserver-pod
    spec:
      containers:
      - name: timeserver-container
        image: docker.io/wdenniss/timeserver:3
        resources:
          requests:
            cpu: 200m
            memory: 250Mi

❶ 副本字段指定了您希望运行的 Pod 的副本数量。

如同往常,您可以使用 kubectl 命令 apply -f deploy.yaml 来应用您对配置所做的更改。

kubectl 还提供了一个方便的命令行命令,可以达到相同的效果:

kubectl scale deployment timeserver --replicas=6

然而,如果你尝试添加太多的副本,你很快就会在你的集群中没有足够的空间来调度这些 Pod。这就是节点扩展发挥作用的地方。当你运行kubectl get pods时,你会知道你已经没有空间了,因为会有很多 Pod 被列为Pending

Pods 可能因为多种原因处于Pending状态,其中最常见的原因(如果 Pod 处于这种状态一分钟或更长时间)是资源不足。本质上,资源不足是一个未满足的条件,Pod 将保持Pending状态,直到条件得到满足。如果 Pod 有依赖关系(例如,需要部署在另一个尚未创建的 Pod 所在的节点上),也可能存在其他未满足的条件。为了消除歧义,使用kubectl describe pod $POD_NAME描述 Pod 并查看事件。如果你看到一个事件,例如带有类似Insufficient CPU消息的FailedScheduling,你很可能需要添加更多的节点。

无节点 Kubernetes

我想花一点时间来谈谈无节点 Kubernetes 平台。在我看来,理想的云 Kubernetes 平台是一个你不必过多担心节点的平台。毕竟,如果你使用云服务,为什么不有一个根据 Pod 需求提供所需节点资源的平台,这样你就可以更多地专注于创建优秀的应用和服务呢?

在我作为谷歌云产品经理的角色中,这正是我和我的团队一起构建的产品。我们将其命名为GKE Autopilot。这是一个让开发者从担心节点中解放出来的平台。使用 GKE Autopilot,你可以创建标准的 Kubernetes 工作负载,如 Deployments、StatefulSets 和 Jobs,指定副本计数和所需的 CPU 和内存资源。然后 Autopilot 会为你提供必要的计算资源来运行 Pod,并代表你管理计算容量。这有两个关键优势:它通过消除在 Pod 和节点中定义计算需求的需要来提高开发者的效率,并通过显著减少节点管理的负担来提高运营效率。

Autopilot 与众不同的一个特点是 Kubernetes 节点概念仍然保持一定的相关性。许多与节点相关的调度逻辑(如第八章中提到的拓扑分布、亲和性和反亲和性)仍然相关,并且仍然可以使用。Autopilot 在无节点意义上意味着你不再需要担心节点是如何提供或管理的,但它并没有完全抽象或隐藏节点。毕竟,某处确实有一台机器在运行你的代码,这可能在诸如故障域或希望为了降低延迟而将 Pod 放置在一起等方面具有物理相关性。

我认为 Autopilot 具有最佳的设计,它为你提供了所需的节点级控制,同时仍然消除了操作和管理这些节点的负担。不再需要关心你有多少节点,它们的大小和形状,它们是否健康,以及它们是否处于空闲或未充分利用状态。

如果你使用 GKE Autopilot 或类似的平台,你可以基本上忽略本章中关于扩展 节点 的所有内容,而纯粹关注扩展 Pods。使用 Autopilot,手动或自动(例如使用水平 Pod 自动扩展器)扩展 Pods 是你需要做的所有事情,因为系统会为你配置必要的节点资源,无需任何额外配置。

要扩展节点,你需要查阅你的 Kubernetes 提供商的平台文档,因为 Kubernetes 本身并不编排节点。在 Google Kubernetes Engine (GKE) 的情况下,如果你使用 Autopilot,节点将自动配置,你可以直接跳到第 6.2 节。对于具有节点池的 GKE 集群,命令看起来是这样的:

gcloud container clusters resize $CLUSTER_NAME \
  --node-pool $NODE_POOL_NAME \
  --num-nodes $NODE_COUNT

缩小扩展使用相同的命令执行。当你缩小节点时,根据你的提供商,你应该能够运行与扩展时相同的命令。集群首先隔离节点以防止新的 Pods 被调度到它们上,然后排空节点,在给 Pods 时间优雅关闭的同时驱逐所有 Pods。在 Deployment 或其他高级工作负载结构中管理的 Pods 将在其他节点上重新调度。

手动隔离和排空节点

如果你想要观察节点缩小时的操作,你可以使用以下命令手动隔离、排空和移除节点:

$ kubectl get nodes
NAME                                       STATUS  ROLES   AGE  VERSION
gke-cluster-1-default-pool-f1e6b3ef-3o5d   Ready   <none>  7d   v1.27.3-gke.100
gke-cluster-1-default-pool-f1e6b3ef-fi16   Ready   <none>  7d   v1.27.3-gke.100
gke-cluster-1-default-pool-f1e6b3ef-yc82   Ready   <none>  7d   v1.27.3-gke.100

$ NODE_NAME=gke-cluster-1-default-pool-f1e6b3ef-3o5d

$ kubectl cordon $NODE_NAME
node/gke-cluster-1-default-pool-f1e6b3ef-3o5d cordoned

$ kubectl drain $NODE_NAME --ignore-daemonsets --delete-emptydir-data
node/gke-cluster-1-default-pool-f1e6b3ef-3o5d already cordoned
evicting pod default/timeserver-784d5485d9-mrspm
evicting pod kube-system/metrics-server-v0.5.2-66bbcdbffc-78gl7
pod/timeserver-784d5485d9-mrspm evicted
pod/metrics-server-v0.5.2-66bbcdbffc-78gl7 evicted
node/gke-cluster-1-default-pool-f1e6b3ef-3o5d drained

注意,通过 kubectl 删除节点并不总是删除底层的虚拟机,这意味着你仍然可能需要为此付费!如果你使用 kubectl delete node $NODE_NAME 删除节点,请跟进以确保虚拟机也被删除。在 Autopilot 模式下的 GKE 中,隔离和排空足以将节点从使用中移除,系统会为你处理删除。对于基于节点的 GKE 集群,请确保你自己删除虚拟机,例如:

$ gcloud compute instances delete $NODE_NAME --zone us-west1-c

通常,当你在缩小节点时,集群会自动执行这些操作,所以你通常不需要自己运行它们;然而,如果你需要移除一个表现不佳的节点,这些操作会很有用。

因此,这就是你手动扩展 Pods 和节点的方式。继续阅读,了解如何通过水平 Pod 自动扩展来自动化这两个操作以扩展 Pods,以及通过集群自动扩展来扩展节点(对于提供此功能的云提供商)。

6.2 水平 Pod 自动扩展

在 Kubernetes 中,扩展应用程序的 Pod 副本数量被称为 水平 Pod 自动扩展。它是水平的,因为您正在增加副本数量以服务增加的流量,而不是垂直的,垂直意味着增加每个副本可用的资源。通常,为了扩展系统,您希望进行水平扩展。

Kubernetes 包含一个名为水平 Pod 自动扩展器(HPA)的功能,这是一个系统,您指定一个 Pod 指标(如 CPU 使用率)进行观察和目标,以及一些扩展限制(最小和最大副本数)。然后 HPA 将尝试通过创建和删除 Pod 来满足您的指标。在 CPU 的情况下,如果您的目标是,比如说,20% 的 CPU 利用率,当您的平均利用率(跨所有 Pod)超过 20%(Pod 在其资源请求中请求的)时,HPA 将添加副本,当它低于 20% 时将其删除。这些操作受您提供的最小和最大限制以及冷却期的影响,以避免过多的波动。我们可以在以下列表中为我们的部署创建一个 HPA。

列表 6.2 第六章/6.2_HPA/hpa.yaml

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: timeserver
spec:
  minReplicas: 1                 ❶
  maxReplicas: 10                ❷
  metrics:
  - resource:
      name: cpu
      target:
        averageUtilization: 20   ❸
        type: Utilization
    type: Resource
  scaleTargetRef:                ❹
    apiVersion: apps/v1          ❹
    kind: Deployment             ❹
    name: timeserver             ❹

❶ 最小副本数

❷ 最大副本数

❸ CPU 利用率目标。当 Pod 的 CPU 利用率高于此值时,HPA 将创建更多副本

❹ 将要扩展的部署

您也可以强制创建它。像往常一样,我更喜欢配置方法,因为它使得稍后编辑事物变得更容易。但这里提供了等效的强制命令,以保持完整性:

kubectl autoscale deployment timeserver --cpu-percent=20 --min=1 --max=10

为了测试这一点,我们需要让 CPU 真的忙碌起来。使用以下两个列表,让我们向 timeserver 应用程序添加一个非常 CPU 密集型的路径:计算 π。

列表 6.3 第六章/timeserver4/pi.py

from decimal import *

# Calculate pi using the Gregory-Leibniz infinity series
def leibniz_pi(iterations):

  precision = 20
  getcontext().prec = 20
  piDiv4 = Decimal(1)
  odd = Decimal(3)

  for i in range(0, iterations):
    piDiv4 = piDiv4 - 1/odd
    odd = odd + 2
    piDiv4 = piDiv4 + 1/odd
    odd = odd + 2

  return piDiv4 * 4

列表 6.3 是我们计算 π 的方法,列表 6.4 显示了向 server.py 添加新的 URL 路径,并调用它。

列表 6.4 第六章/timeserver4/server.py

from pi import *

# ...

case '/pi':                          ❶
    pi = leibniz_pi(1000000)         ❶
    self.respond_with(200, str(pi))  ❶

❶ 新的 HTTP 路径

以下列表提供了一个修订后的部署,它引用了带有新路径的容器的新版本。为了与 HPA 正确工作,设置资源请求非常重要,我们在第五章中添加了这些请求,并且在这里也存在。

列表 6.5 第六章/6.2_HPA/deploy.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: timeserver
spec:
  replicas: 1                                    ❶
  selector:
    matchLabels:
      pod: timeserver-pod
  template:
    metadata:
      labels:
        pod: timeserver-pod
    spec:
      containers:
      - name: timeserver-container
 image: docker.io/wdenniss/timeserver:4 ❷
        resources:                               ❸
          requests:                              ❸
            cpu: 250m                            ❸
            memory: 250Mi                        ❸

❶ 初始副本数设置为 1

❷ 新的应用程序版本

❸ 资源请求对于 HPA 正确工作很重要。

我们现在可以创建部署、服务和 HPA:

$ cd Chapter06/6.2_HPA
$ kubectl create -f deploy.yaml -f service.yaml -f hpa.yaml
deployment.apps/timeserver created
service/timeserver created
horizontalpodautoscaler.autoscaling/timeserver created
$ kubectl get svc -w
NAME         TYPE           CLUSTER-IP      EXTERNAL-IP     PORT(S)        AGE
kubernetes   ClusterIP      10.22.128.1     <none>          443/TCP        6m35s
timeserver   LoadBalancer   10.22.131.179   <pending>       80:32650/TCP   18s
timeserver   LoadBalancer   10.22.131.179   203.0.113.16    80:32650/TCP   26s

当您等待外部 IP 分配时,您可以使用以下命令开始监视 Pod 的 CPU 利用率(我建议将其放入新窗口):

kubectl top pods

一旦您有了外部 IP,在端点生成一些负载。Apache Bench,您可以在大多数系统上安装,对此效果很好。以下命令将同时发送 50 个请求到我们的端点,直到发送了 10,000 个请求——这应该足够了:

EXTERNAL_IP=203.0.113.16
ab -n 10000 -c 5 http://$EXTERNAL_IP/pi

您可以使用以下方式查看部署的扩展状态:

kubectl get pods -w

Linux 的 watch 命令方便地使用单个命令监视所有资源(kubectl 本身无法做到这一点):

watch -d kubectl get deploy,hpa,pods

如果一切顺利,你应该观察到 CPU 利用率随着 kubectl top pods 命令的可见性而增加,并且更多的 Pod 副本被创建。一旦你停止向端点发送负载(例如,通过中断 ab 或等待其完成),你应该观察到这些副本逐渐被移除。

你可能会观察到,在响应高请求负载进行扩容时,副本的调度速度比在请求停止时缩容时移除副本要快。这只是系统在移除容量时稍微谨慎一些,以避免需求激增时的波动。以下是我样本运行的情况:

$ kubectl get deploy,hpa,pods 
NAME                         READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/timeserver   2/6     6            2           7m7s

NAME            REFERENCE              TARGETS   MINPODS  MAXPODS  REPLICAS
hpa/timeserver  Deployment/timeserver  100%/30%  1        6        6   
NAME                             READY   STATUS              RESTARTS   AGE
pod/timeserver-b8789946f-2b969   1/1     Running             0          7m7s
pod/timeserver-b8789946f-fzbnk   0/1     Pending             0          96s
pod/timeserver-b8789946f-httwn   1/1     Running             0          96s
pod/timeserver-b8789946f-vvnhj   0/1     Pending             0          96s
pod/timeserver-b8789946f-xw9zf   0/1     ContainerCreating   0          36s
pod/timeserver-b8789946f-zbzw9   0/1     ContainerCreating   0          36s

这里展示的 HPA 使用 CPU 指标效果相当不错,但有一个问题:你的工作负载可能不是 CPU 密集型的。与演示中使用的 CPU 密集型请求不同,许多 HTTP 服务花费大量时间等待外部服务,如数据库。这些部署可能需要使用其他指标进行缩放,例如每秒请求次数(RPS),而不是 CPU 利用率。Kubernetes 提供了两个内置指标:CPU(在先前的示例中演示)和内存。它不直接支持 RPS 这样的指标,但可以通过使用由你的监控服务公开的自定义和外部指标进行配置。下一节将介绍这种情况。

那么,垂直 Pod 自动缩放呢?

垂直 Pod 自动缩放(VPA)是一种概念,通过调整 Pod 的 CPU 和内存资源来垂直缩放 Pod。在 Kubernetes 中的实现通过观察 Pod 资源使用情况并在时间动态地改变 Pod 的资源请求来实现 VPA。Kubernetes 并不提供内置的 VPA 实现,尽管有一个开源实现^a,以及包括 GKE 在内的云提供商提供他们自己的版本。

由于 VPA 可以自动确定 Pod 的资源请求,它可以节省你一些精力并提供一些资源效率。如果你需要 Pod 的资源请求随时间动态调整(对于资源需求波动很大的 Pod),这也是合适的工具。

使用 VPA 会增加其自身的复杂性,并且不一定总是与 HPA 玩得很好。我首先会关注设置合适的 Pod 资源请求和副本的水平缩放。

^a github.com/kubernetes/autoscaler

6.2.1 外部指标

一个流行的缩放指标是每秒请求次数(RPS)。使用 RPS 指标进行缩放的基础是测量你的应用程序实例每秒可以处理多少请求(副本的容量)。然后,将当前请求的数量除以这个值, voila,你就有了所需的副本数量:

replica_count = RPS ÷ replica_capacity

RPS 指标的好处是,如果你确信你的应用程序可以处理你对其测试的 RPS,那么你可以确信它可以在负载下进行扩展,因为自动扩展器的任务是提供足够的容量。

事实上,即使你不在进行 自动 扩展,这个指标也是一个非常好的规划容量的方法。你可以测量副本的容量,预测流量,并相应地增加副本。但是,使用 Kubernetes,我们也可以配置一个带有 RPS 指标的 HPA 以进行自动扩展。

现在,在这种情况下,我们将使用 HPA 的 外部指标 属性。这个问题的一个问题是,正如其名称所暗示的,这个指标是从集群外部获取的。所以,如果你使用的是与我示例中使用的不同的监控解决方案,你需要查找相关的 RPS 指标。幸运的是,RPS 是一个非常常见的指标,任何值得信赖的监控解决方案都会提供它。

在之前的章节中,我们讨论了几种通过所谓的第 4 层负载均衡器(在 TCP/IP 层运行)和所谓的第 7 层 Ingress(在 HTTP 层运行)将流量引入集群的不同方法。由于 请求 是 HTTP 概念,你需要使用 Ingress 来获取这个指标。Ingress 将在下一章中深入讨论;现在,只需知道这个对象可以查看和检查你的 HTTP 流量,因此可以暴露你接收到的请求数量指标。

对于这个例子,如下两个列表所示,我们将使用相同的部署,但通过一个类型为 NodePort 的服务(而不是之前章节中的 LoadBalancer 类型)将其暴露在 Ingress 上。

列表 6.6 第六章/6.2.1_ExternalMetricGCP/service.yaml

apiVersion: v1
kind: Service
metadata:
  name: timeserver-internal   ❶
spec:
  selector:
    pod: timeserver-pod
  ports:
  - port: 80
    targetPort: 80
    protocol: TCP
  type: NodePort              ❷

❶ 此内部服务的名称

❷ 使用 NodePort 类型用于 Ingress。

列表 6.7 第六章/6.2.1_ExternalMetricGCP/ingress.yaml

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: timeserver-ingress
spec:
  rules:
  - http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: timeserver-internal   ❶
            port:
              number: 80

❶ 引用列表 6.6 中的内部服务

如果你正在使用 Google Cloud,一旦你将转发规则名称替换为你自己的,以下 HPA 定义就可以从 Ingress 中获取 RPS 指标。

列表 6.8 第六章/6.2.1_ExternalMetricGCP/hpa.yaml

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: timeserver-autoscaler
spec:
  minReplicas: 1
  maxReplicas: 6
 metrics: ❶
 - type: External ❶
 external: ❶
 metric: ❶
 name: loadbalancing.googleapis.com|https|request_count ❶
 selector: ❶
 matchLabels: ❶
 resource.labels.forwarding_rule_name: "k8s2-fr-21mgs2fl" ❶
 target: ❶
 type: AverageValue ❶
 averageValue: 5 ❶
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: timeserver

❶ 外部指标

forwarding_rule_name 是指标服务器知道你正在谈论哪个 Ingress 对象的方式。你可以完全省略 selector 字段,但这样它将匹配所有 Ingress 对象——这可能不是你想要的。

使问题复杂化的是,这个转发规则名称是一个平台特定的资源名称,而不是 Kubernetes 对象名称(在这个例子中,该名称由 GKE 自动设置)。为了发现平台资源名称,在等待几分钟以配置你的 Ingress 之后,你可以描述你的 Ingress 对象:

$ kubectl describe ingress timeserver-ingress
Name:             timeserver-ingress
Namespace:        default
Address:          203.0.113.16
Default backend:  default-http-backend:80 (10.22.0.202:8080)
Rules:
  Host        Path  Backends
  ----        ----  --------
  *           
              /   timeserver-internal:80 (10.22.0.130:80,10.22.0.131:80,10.22.0.196:80 + 1 more...)
Annotations:  ingress.kubernetes.io/backends:
                {"k8s -be-32730":"HEALTHY","k8s1-a5225067":"HEALTHY"}
              ingress.kubernetes.io/forwarding-rule: k8s2-fr-21mgs2fl   ❶
              ingress.kubernetes.io/target-proxy: k8s2-tp-21mgs2fl
              ingress.kubernetes.io/url-map: k8s2-um-21mgs2flEvents:
  Type    Reason  Age                    From                     Message
  ----    ------  ----                   ----                     -------
  Normal  Sync    6m28s (x31 over 5h6m)  loadbalancer-controller  Scheduled for sync

❶ 转发规则名称

另一种查询此信息的方法,这对于配置自动化工具非常重要,是了解数据在对象结构中的位置,并使用 kubectl 的 JsonPath 格式:

$ kubectl get ingress -o=jsonpath="{.items[0].metadata.annotations['ingress\.
➥ kubernetes\.io\/forwarding-rule ']}"
k8s2-fr-21mgs2fl

小贴士:我通过首先查询 Ingress 的 -o=json 版本,然后通过查看 JsonPath 文档、Stack Overflow 和试错来构建 JsonPath 表达式。

一旦对象准备就绪,还有最后一步,即确保您的集群中的工作负载已启用 Cloud Monitoring,并安装一些粘合剂,以便 HPA 可以访问指标。按照¹中的说明安装自定义指标 - Stackdriver 适配器.

在我们的部署、服务类型为 NodePort、入口、HPA 和指标适配器都配置完成后,我们现在可以尝试一下!生成一些对入口的请求(替换您的入口 IP,通过 kubectl get ingress 获取):

ab -n 100000 -c 100 http://203.0.113.16/

此外,在另一个窗口中,观察扩展:

$ kubectl get hpa,ingress,pods
NAME             REFERENCE              TARGETS   MINPODS MAXPODS REPLICAS
hpa/timeserver   Deployment/timeserver  94%/30%   1       6       4       

NAME                         CLASS    HOSTS   ADDRESS          PORTS
ingress/timeserver-ingress   <none>   *       203.0.113.16     80   

NAME                             READY   STATUS              RESTARTS   AGE
pod/timeserver-b8789946f-8dpmg   1/1     Running             0          5h51m
pod/timeserver-b8789946f-gsrt5   0/1     ContainerCreating   0          110s
pod/timeserver-b8789946f-sjvqb   1/1     Running             0          110s
pod/timeserver-b8789946f-vmhsw   0/1     ContainerCreating   0          110s

你可能会注意到,验证系统是否按预期运行已经更容易了。Apache Bench 允许你指定并发请求;你可以看到它们花费了多长时间(因此可以计算 RPS)并查看副本数量以确定是否正确。这个过程对于 CPU 指标来说有点困难,为了测试,你可能需要尽可能让 Pod 变得忙碌,就像我们在上一个示例中所做的那样。基于 用户请求 的扩展属性是它成为流行指标的一个原因。

观察和调试

要查看 HPA 在做什么,你可以运行 kubectl describe hpa。特别注意 ScalingActive 条件。如果它是 False,这通常意味着你的指标未激活,这可能由多种原因造成:(1)指标适配器未安装(或未认证),(2)你的指标名称或选择器错误,或者(3)尚无针对给定指标的监控样本。请注意,即使配置正确,在没有监控数据样本的情况下(例如,没有请求),你也会看到 False,所以在进一步调查之前,请确保向端点发送一些请求并等待一分钟或两分钟,以便数据通过。

平均值 vs. 值

在上一个示例中,我们使用了 targetAverageValuetargetAverageValue 是该指标的每个 Pod 的目标值。targetValue 是一个替代方案,它是目标绝对值。由于 RPS 容量是在每个 Pod 级别计算的,所以我们想要的是 targetAverageValue

其他指标

在处理后台任务(在第十章中介绍)时,另一个流行的外部度量标准是 Pub/Sub 队列长度。Pub/Sub 是一个排队系统,允许您有一个需要执行的工作队列,您可以在 Kubernetes 中设置一个工作负载来处理该队列。对于此类设置,您可能希望通过添加或删除 Pod 副本(可以处理队列的工作者)来对队列大小做出反应。您可以在 GKE 文档中找到一个完整的示例²;本质上,它类似于之前的 HPA,只是度量不同:

metric:
  name: pubsub.googleapis.com|subscription|num_undelivered_messages
  selector:
    matchLabels:
      resource.labels.subscription_id: echo-read

此配置包括度量名称和度量资源标识符——在这种情况下,是 Google Cloud Pub/Sub 订阅标识符。

其他监控解决方案

外部度量标准是您应该能够为 任何 Kubernetes 监控系统配置的。虽然之前给出的示例使用了 Google Cloud 上的 Cloud Monitoring,但如果您使用 Prometheus 或其他云监控系统,同样的原则也应该适用。要开始,您需要确定(1)如何为您的监控解决方案安装度量适配器,(2)在该系统中度量名称是什么,以及(3)正确选择度量资源的方式。

6.3 节点自动缩放和容量规划

使用水平 Pod 自动缩放是一种根据需求自动缩放您的 Pod 的好方法。然而,如果您必须手动缩放节点以添加和删除容量,仍然需要人工介入。常见的集群自动缩放器集群功能使您能够根据需求缩放节点,并且与 HPA 配合良好以缩放 Pod。

6.3.1 集群自动缩放

集群自动缩放不是 Kubernetes 基础 API 的一部分,而是一个可选组件。幸运的是,大多数云服务提供商都提供(或提供类似功能)并将其作为平台的一个属性来构建,该属性可以为您自动缩放节点数量,让您只需关注您的应用程序及其副本数量。由于此功能依赖于平台,具体的实现会有所不同(并非所有提供商都提供此功能)。搜索“[产品名称] 集群自动缩放器”以找到相关文档。

在 GKE 的情况下,如果您使用操作模式的 Autopilot,集群将内置节点预配和自动缩放;无需进一步配置。对于具有节点池的 GKE 集群,您可以在创建节点池或更新现有节点池时配置自动缩放。

当使用集群自动缩放时,您可以专注于缩放您的工作负载,让集群自动响应(如图 6.1)。这非常方便,因为它可以在缩放现有工作负载和部署新工作负载时解决 Pending Pods 问题。不过,请阅读您提供商的具体实现细节,以了解哪些情况不受覆盖(例如,如何处理太大而无法适应当前节点配置的 Pods)。

06-01

图 6.1 集群自动扩展器监视挂起的 Pod,并在需要时创建新节点。

传统的集群自动扩展器可能只能添加现有预定义配置的新节点,需要你定义你希望使用的每个可能的节点类型,所以请务必阅读文档。如果你使用 Autopilot(无需配置;这是出厂设置的工作方式)或基于节点的模式并配置了节点自动供应,GKE 可以添加任何类型的新节点。

集群自动扩展和其他可以自动添加和删除节点的提供者工具通过允许你主要忽略节点并专注于自己的 Pod 来使你的生活变得更轻松。当与基于 Pod 的扩展如 HorizontalPodAutoscaler 配合使用时,你可以有一个相当不干预、自动化的部署。

6.3.2 集群自动扩展的备用容量

与手动添加节点相比,自动扩展节点的缺点之一是有时自动扩展器可能会调整得“太”好,导致没有备用容量。这可以很好地降低成本,但会使启动新 Pod 的速度变慢,因为容量需要在 Pod 启动之前配置。

添加新节点然后启动 Pod 比向现有节点添加新 Pod 要慢。节点需要配置和启动,而那些被调度到现有节点的 Pod 只需拉取容器并启动——如果容器已经在缓存中,它们甚至可以立即开始启动。如图 6.2 所示,新调度的 Pod 必须等待容量配置完毕后才能开始启动。

06-02

图 6.2 使用自动扩展动态添加容量以适应新调度的 Pod

一种在保持自动扩展器的同时解决这两个问题的方法是通过使用低优先级的占位符 Pod。这个 Pod 本身不执行任何操作,只是保留容量(保持额外的节点处于待机状态)。这个 Pod 的优先级很低,所以当你的工作负载扩展时,它们可以抢占这个 Pod 并使用节点容量(图 6.3)。

06-03

图 6.3 使用占位符 Pod 进行自动扩展,允许使用备用容量快速启动新 Pod

要创建我们的占位符 Pod 部署,首先,我们需要一个 PriorityClass。这个优先级类应该有一个低于零的优先级(我们希望其他所有优先级类都能被它抢占),如下所示。

列表 6.9 第六章/6.3.2_PlaceholderPod/placeholder-priority.yaml

apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
  name: placeholder-priority
value: -10                      ❶
preemptionPolicy: Never         ❷
globalDefault: false
description: "Placeholder Pod priority."

❶ 占位符 Pod 的低优先级值

❷ 不会抢占其他 Pod

现在,我们可以创建我们的“不执行任何操作”的容器部署,如下所示。

列表 6.10 第六章/6.3.2_PlaceholderPod/placeholder-deploy.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: placeholder
spec:
  replicas: 10                                  ❶
  selector:
    matchLabels:
      pod: placeholder-pod
  template:
    metadata:
      labels:
        pod: placeholder-pod
    spec:
      priorityClassName: placeholder-priority   ❷
      terminationGracePeriodSeconds: 0          ❸
      containers:
      - name: ubuntu
        image: ubuntu
        command: ["sleep"]                      ❹
        args: ["infinity"]                      ❹
        resources:
          requests:
            cpu: 200m                           ❺
            memory: 250Mi                       ❺

❶ 你想要多少个副本?这,加上 CPU 和内存请求,决定了占位符 Pod 提供的头空间容量的大小。

❷ 使用我们刚刚创建的优先级类

❸ 我们希望这个 Pod 立即关闭,没有任何宽限期。

❹ 这是我们的“什么都不做”命令。

❺ 占位符 Pod 将保留的资源。这应该等于你希望替换此 Pod 的最大 Pod。

当你自己创建时,考虑你需要多少个副本以及每个副本的大小(内存和 CPU 请求)。大小应该至少与你的最大常规 Pod 大小相同;否则,当占位符 Pod 被抢占时,你的工作负载可能无法适应空间。同时,不要增加太多大小;如果你希望保留额外容量,使用比标准工作负载、Pod 大得多的副本可能更好。

为了让这些占位符 Pod 被你安排的其他 Pod 抢占,那些 Pod 需要有优先级类,其值更高,并且没有preemptionPolicyNever。幸运的是,默认优先级类有一个value0preemptionPolicyPreemptLowerPriority,所以默认情况下,所有其他 Pod 都会替换我们的占位符 Pod。

为了表示 Kubernetes 默认值作为其自己的优先级类,它看起来就像列表 6.11。由于你实际上不需要更改默认值,所以我不必麻烦配置这个。但是,如果你正在创建自己的优先级类,你可以使用这个列表作为参考(只是不要将globalDefault设置为true,除非你真的打算这样做)。再次强调,为了使占位符 Pod 抢占工作,务必不要preemptionPolicy设置为Never

列表 6.11 Chapter06/6.3.2_PlaceholderPod/default-priority.yaml

apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
  name: default-priority
value: 0                                         ❶
preemptionPolicy: PreemptLowerPriority           ❷
globalDefault: true                              ❸
description: "The global default priority. Will preempt the placeholder Pods."

❶ 优先级值高于占位符 Pod

❷ 将抢占其他 Pod

❸ 设置为默认值,以便其他 Pod 抢占占位符 Pod。

类似这样的部署封装的占位符 Pod 对于提供恒定的扩展空间很有用,为你提供一定量的容量,以便快速调度。或者,你可以将它们封装在 Job 中,用于一次性容量配置,CronJob 以按计划配置容量,或者作为独立 Pod 运行。

6.4 构建可扩展的应用程序

扩展你的应用程序只是方程的一部分。应用程序本身需要考虑扩展来构建。即使你可能还没有到达需要担心这些问题的增长点,我相信你需要扩展的时候,不是设计如何扩展的时候!

当你的应用程序是具有不可预测增长的应用(例如,具有潜在无限用户的初创公司)时,你真的需要提前规划以避免“成功失败”的场景。这就是在你成功的突破时刻,应用程序失败,因为它无法处理规模。由于你不知道这个突破时刻何时会发生,你需要提前为此做好准备。并不是每个初创公司都会有一个突破时刻,但如果你有,你希望准备好利用这个机会;否则,一切可能都徒劳无功。

幸运的是,通过选择 Kubernetes 来编排你的容器,你已经在为可扩展的应用程序打下了坚实的基础。在设计应用程序时,还有一些其他因素需要考虑,这些因素在很大程度上与 Kubernetes 独立。大多数可扩展设计原则都适用于 Kubernetes 和非 Kubernetes 环境,但我会介绍一些在 Kubernetes 上构建可扩展应用程序时值得记住的最佳实践。在开发应用程序时关注一些可扩展性原则,在未来你的突破时刻到来并需要将其扩展到极致时可能会很重要。

6.4.1 避免状态

能够扩展的一个重要方面是避免在应用程序中存在本地状态。无状态设计是指你的应用程序运行的每个副本(实例)都可以在没有参考任何存储在另一个实例上的本地数据的情况下服务任何传入的请求。本地短暂存储可以用于临时数据处理,只要它不在副本之间共享,并且不需要为下一个请求提供可用性。

注意:我认为应用程序的无状态属性是流行的十二要素应用程序设计方法中最重要的因素。(12factor.net/processes)。无状态应用程序更容易扩展和维护,因为每个实例都可以独立地处理任何请求。

与传统的宿主机不同,在 Kubernetes 中,容器写入磁盘的所有数据默认都是短暂的(当容器终止或重启时会被删除)。你可以使用持久卷和 StatefulSet 构造(见第九章)来创建有状态的应用程序,但默认情况下,容器被视为无状态的,你通常希望保持这种状态以便进行扩展。

而不是在 Kubernetes 中管理磁盘上存储状态,使用外部数据存储来存储数据,例如 SQL 和 NoSQL 数据库用于结构化数据,对象存储用于文件,以及像 Redis 这样的内存数据库用于会话状态。为了支持你的扩展能力,选择托管服务(而不是自托管)并确保你选择的服务可以处理你的潜在增长。

这并不是说所有状态都是坏的。毕竟,你需要某个地方来存储你的状态,有时这可能需要是一个自托管的应用程序。当你创建这样的应用程序时,请确保选择高度可扩展的解决方案,例如具有成功记录的流行开源解决方案(例如 Redis)。

关系型数据库的陷阱

如果你使用像 MySQL 或 PostgreSQL 这样的关系型数据库来存储数据,那么有相当多的潜在陷阱值得注意。

驯服你的查询

不言而喻,低效的查询会导致低效的扩展,随着数据量的增加和请求数量的增加而变慢。为了保持控制,我建议记录并分析你的查询,并在开发早期阶段就开始(你不想等到你的应用成为热门后才查看查询!)。

你不能提高你没有衡量的东西,所以记录每个请求中执行的 SQL 查询的性能是最重要的第一步。寻找生成大量查询或慢查询的请求,并从这里开始。

MySQL 和 PostgreSQL 都支持 EXPLAIN 命令,这可以帮助分析特定查询的性能。提高性能的常见策略包括为常用搜索列添加索引和减少需要执行的 JOIN 数量。MySQL 的文档“优化 SELECT 语句”^a 详细介绍了许多不同的优化策略。

避免 N+1 查询

即使你的查询非常高效,你对数据库的每个查询都会产生开销。理想情况下,你的应用程序处理的每个请求都应该执行固定数量的查询,无论显示多少数据。

如果你有一个渲染对象列表的请求,你理想上希望不为列表中的每个对象生成单独的查询。这通常被称为 N+1 查询问题(当问题发生时,通常有一个查询用于获取列表,然后为列表中的每个项目 [N 个项目] 有一个查询)。

这种反模式在那些使用对象关系映射(ORM)并且具有父子对象懒加载功能的系统中尤为常见。使用懒加载渲染一对多关系中的子对象通常会导致 N+1 查询(一个查询用于父对象,N 个查询用于 N 个子对象),这些查询将出现在你的日志中。幸运的是,在这样系统中通常有方法可以提前表明你打算访问子对象,以便可以将查询批量处理。

这种 N+1 查询情况通常可以被优化为固定数量的查询,无论是通过 JOIN 来在列表查询中返回子对象,还是两个查询:一个用于获取记录集,另一个用于获取该集中子对象的详细信息。记住,目标是每个请求都只有少量固定的查询,特别是查询的数量不应该与展示的记录数量成线性关系。

使用只读副本进行 SELECT 查询

减轻主数据库压力的最好方法之一是创建一个只读副本。在云环境中,这通常非常简单。将所有只读查询发送到你的只读副本(或副本!)以减轻主读/写实例的负载。

在你实际上需要读取副本之前,考虑到这种模式来设计你的应用程序,你可以在应用程序中设置到同一数据库的两个数据库连接,使用第二个来模拟读取副本。设置只具有读取权限的自己的只读连接的用户。稍后,当你需要部署实际的读取副本时,你只需更新第二个连接的实例地址,然后就可以继续了!

递增主键

如果你真的取得了巨大成功,你可能会后悔使用递增主键。它们是扩展的问题,因为它们假设一个单一的可写数据库实例(阻碍了水平扩展)并且在插入时需要加锁,这会影响性能(即,你不能同时插入两个记录)。

这实际上只在大规模时才是一个问题,但值得记住,因为当你突然需要扩展时,重新架构事情会更困难。解决这个问题的常见方法是使用全局 UUID(例如,8fe05a6e-e65d-11ea-b0da-00155d51dc33),这是一个 128 位的数字,通常以十六进制字符串的形式显示,可以由任何客户端(包括在用户设备上运行的代码)唯一生成。

当 Twitter 需要扩展时,它选择创建自己的全局递增 ID 以保留它们可排序的特性(即,较新的推文具有更高的 ID 号),你可以在他们的文章“宣布 Snowflake”中了解更多信息。^b

另一方面,你可能出于美学原因更喜欢保留递增主键,例如当记录 ID 暴露给用户时(如推文 ID 的情况)或为了简单起见。即使你计划保留递增主键一段时间,你仍然可以在一开始就采取的一个步骤是不在它们不会增加任何价值的地方使用自动递增主键,比如用户会话对象——也许不是每个表都需要递增主键。

^a dev.mysql.com/doc/refman/8.0/en/select-optimization.html

^b blog.twitter.com/engineering/en_us/a/2010/announcing-snowflake.html

6.4.2 微服务架构

构建你的应用程序的一种方法是将服务拆分为多个服务,这通常被描述为使用微服务架构。这种方法基本上就是创建几个内部服务来执行不同的任务,并通过远程过程调用(本质上是一个 HTTP 请求)从其他服务调用这些函数。这与将完整程序逻辑放在单个容器中的单体服务设计方法形成对比。

虽然将单一架构拆分为多个较小的服务有一些好处,但也存在一些缺点,所以我并不主张仅仅为了使用微服务架构。好处包括可以为每个服务使用不同的编程语言,独立开发(例如,由不同的团队),以及独立扩展。缺点包括更复杂的调试和集成测试,因为你现在有更多的组件,需要一种方法来追踪系统中的请求。

微服务与单一架构

你应该构建微服务还是单一架构?为了这次辩论,我不会在本书中详细讨论,但让我分享两个关于这个话题的观点,由你自己来判断。

David Heinemeier Hansson (DHH) 在他的文章“宏伟的单一架构”^a 中写道,微服务适合大型科技公司,而对于大多数小型团队来说,单一架构可能更合适。他的论点是,尽管微服务在某些情况下可能具有优势,但并非总是那么明确,尤其是对于小型团队来说,这种开销(尤其是额外的开销)并不值得。

James Lewis 和 Martin Fowler 在他们的文章“微服务”^b 中阐述了对微服务的深思熟虑且平衡的观点。其中一个突出的好处是产品心态,即内部团队专注于构建和管理自己的组件,这种去中心化的方法允许团队做出自己的架构决策。

^a m.signalvnoise.com/the-majestic-monolith/

^b martinfowler.com/articles/microservices.html

无论你是否完全采用微服务,我想强调的关键点是,如果你有多个服务,你可以单独扩展它们。当然,即使你除了主应用程序外只有一个内部服务,这也是正确的——没有必要让每个端点都成为自己的服务以从这种架构中受益。例如,假设你有一个主要提供 HTML 和 JSON 请求的 Web 应用程序,但有一个端点执行一些需要比平均请求更多内存的实时图形工作。可能值得创建一个单独的部署(甚至可以使用相同的容器)来提供服务端点,这样你就可以单独扩展它,并且稍微隔离它。

有几种实现方式。你可以有一个单独的前端来调用内部服务,如图 6.4 所示,或者你可以让最终用户直接连接到这个新服务,如图 6.5 所示。

06-04

图 6.4 由同一前端提供服务的两个 HTTP 路径,该前端与内部服务通信

06-05

图 6.5 由不同服务提供的两个路径

无论你是全情投入微服务,还是将单个服务拆分出来由其单独可扩展的部署处理,使用多种编程语言,或者运行内部开发和开源软件来提供你的应用程序,你最终都会在 Kubernetes 中创建内部服务。内部服务使用私有集群 IP 地址进行配置,并由集群中的其他服务调用以提供这种架构。下一章将介绍如何配置此类内部服务。

6.4.3 背景任务

另一个帮助你扩展的重要因素是避免进行任何重量级的内联处理。例如,假设你有一个端点返回图像的缩略图,并且如果缓存中没有该缩略图,则会生成缩略图。你可以将此逻辑内联,即用户请求缩略图,服务通过返回缓存中的缩略图或在没有缓存的情况下生成一个来响应。这种设计的问题在于,从缓存中提供缩略图应该非常快,而创建缩略图则不是。如果有很多请求进来,都需要创建缩略图,服务器可能会变慢或崩溃。此外,由于某些请求很轻量,而其他请求则非常重,因此很难进行扩展。你可以扩展此服务,但仍然可能不幸地将所有重请求都导向单个实例。

解决方案是使用第十章详细介绍的背景任务模式。基本上,当需要重量级处理时,而不是直接进行,你安排一个任务,并向客户端返回一个状态码,指示它应该重试请求。有一个配置了处理此任务队列的容器,可以根据当前队列长度准确地进行扩展。因此,请求进来,导致缓存未命中并排队。如果一切顺利,当客户端在短时间内自动重试请求后,缩略图将已由后台队列处理并准备好提供服务——对用户来说,这是一个类似的结果,需要额外构建一个后台队列和具有重试逻辑的客户端,但具有更好的可扩展性。

摘要

  • Kubernetes 非常适合帮助你进行扩展;一些最大的应用程序都在 Kubernetes 上运行。

  • 为了充分利用这种架构,从一开始就设计你的应用程序,使其能够进行横向扩展。

  • 水平 Pod 自动扩展器可以根据需要提供新的 Pod,与集群自动扩展协同工作,形成一个完整的自动扩展解决方案。

  • 你不仅限于 CPU 指标,可以根据你的监控解决方案导出的任何指标来扩展你的 Pod。

  • 集群自动扩展功能(如果由你的提供商支持)可以根据需要提供新的节点。

  • 占位符 Pod 可以在自动扩展的同时增加容量空间。

  • 考虑将您的应用程序拆分为微服务,或者简单地托管同一应用程序的多个部署,以便允许独立的扩展组。


cloud.google.com/kubernetes-engine/docs/tutorials/autoscaling-metrics

cloud.google.com/kubernetes-engine/docs/tutorials/autoscaling-metrics#pubsub

7 内部服务和负载均衡

本章涵盖

  • 创建内部服务

  • 在 Kubernetes 中在 Pod 和服务的虚拟 IP 地址之间路由数据包

  • 发现内部服务的 IP 地址

  • 使用 Ingress 配置 HTTP 负载均衡器

  • 配置 TLS 证书以创建 HTTPS 端点

内部服务是一种通过将应用程序拆分为多个较小的服务来扩展您开发和服务应用程序的方式。这些单独的服务可以处于不同的开发周期中(可能由不同的团队完成)并使用彼此完全不同的编程语言和技术。毕竟,只要您可以将它容器化,您就可以在 Kubernetes 中运行它。您不再需要担心您的应用程序部署平台是否可以运行您需要运行的内容。

在本章中,我们将探讨如何在集群中配置和发现内部服务,以及 Kubernetes 如何为这些服务分配集群本地 IP 地址并实现内部网络路由,以便其他集群中的 Pod 可以访问它们。我们还将探讨如何使用 Ingress 在单个外部 IP 上公开多个服务,以及 Ingress 如何处理 TLS 终止,这样您就可以为应用程序提供 HTTPS 端点,而无需在应用程序中配置 TLS 证书。

7.1 内部服务

有许多原因需要创建完全属于您集群内部的服务的。可能您采用了微服务架构,或者您正在集成开源服务,或者您只是想连接两个用不同语言编写的应用程序。

在第三章中,我介绍了 LoadBalancer 类型的服务,作为在公共 IP 上获取外部流量的方式。服务还用于连接内部服务,但使用集群 IP 地址。Kubernetes 支持几种不同的服务类型;用于内部服务的是 ClusterIPNodePort

ClusterIP 类型为您在 Kubernetes 集群中提供了一个虚拟 IP 地址。此 IP 地址可以从您集群中的任何 Pod 访问(例如,从您的主要应用程序)。NodePort 类型还在每个集群节点上保留了一个高端口号,允许您从集群外部访问它(因为节点的 IP 地址可以从网络直接访问)。内部集群通信通常使用 ClusterIP,而 NodePort 用于路由外部流量,包括与 Ingress 一起使用。在这两种情况下,Kubernetes 都会配置网络以代理请求到支持服务的 Pod。

注意实际上,三种服务类型都获得集群 IP,而不仅仅是 ClusterIP 类型。NodePortLoadBalancer 类型获得了 ClusterIP 类型之外的 附加 访问方法,并且也可以通过集群 IP 访问。

7.1.1 Kubernetes 集群网络

现在可能是快速介绍 Kubernetes 网络的好时机。每个 Pod 都有自己的 IP 地址,并且可以直接与集群中的所有其他 Pod 通信,而无需 NAT(网络地址转换)。Pod 内的容器共享相同的 IP。Kubernetes 的这个特性使得 Pod 的行为有点像虚拟机,这很方便,因为您不需要担心节点上 Pod 之间的端口冲突(即,多个 Pod 可以在端口 80 上运行容器)。节点有自己的 IP,该 IP 分配给虚拟机的网络接口,而 Pod IP 使用一个虚拟网络接口,其中流量通过节点的接口进行路由。

服务(除了在第九章中提到的无头服务)被分配了一个虚拟 IP。这个虚拟 IP 不会路由到单个 Pod 或节点,而是使用节点上的某些网络粘合剂来平衡支持服务的 Pod 之间的流量。这种网络粘合剂由 Kubernetes(使用 iptables 或 IP 虚拟服务[IPVS])提供,并处理流量路由。节点上维护着一个支持服务的 Pod 及其 IP 列表,用于此路由。

当从 Pod 通过集群 IP 或节点端口向服务发起请求时,该请求首先由节点上的网络粘合剂处理,该粘合剂拥有来自 Kubernetes 控制平面的每个属于该服务的 Pod 的更新列表(以及这些 Pod 所在的节点)。它将随机选择一个 Pod IP,并通过其节点将该请求路由到该 Pod(图 7.1)。幸运的是,所有这些操作都非常顺畅;您的应用程序可以简单地使用服务的 IP 发起一个类似HTTP GET的请求,一切都会如您所期望的那样运行。

07-01

图 7.1 展示了名为 Robohash 的内部服务的 IP 路由。Frontend-1 Pod 向服务发起内部请求。节点上的 iptables 路由粘合剂有一个服务 Pod 列表,该列表由 Kubernetes 控制平面提供,并随机选择名为Robohash-2的 Pod。请求随后通过该 Pod 所在的节点路由到该 Pod(图 7.1)。然后,请求通过其节点路由到该 Pod。

这意味着当您需要部署内部服务时,您可以通过创建一个类型为ClusterIP的服务来实现,从而获得一个其他 Pod(如您的应用程序的前端)可以无缝通信的 IP 地址。这个 IP 地址会自动平衡内部服务所有 Pod 副本之间的负载。作为开发者,您通常不需要担心使这一切成为可能的网络粘合剂,但希望本节至少为您提供了对它是如何工作的基本理解。

7.1.2 创建内部服务

现在你可能已经对 Kubernetes 网络内部的工作原理有了更深的理解,让我们构建一个可以被集群中其他 Pod 使用的内部服务。作为一个例子,让我们在我们的应用程序中部署一个新的内部服务。为此,我将使用一个叫做 Robohash 的 neat 开源库,它可以基于哈希(比如 IP 的哈希)为用户生成可爱的机器人头像。对于你自己的部署,内部服务可以是像头像生成器这样简单的东西,也可以是应用程序的其他部分,甚至是整个数据库部署。以下列表显示了新容器的部署。

列表 7.1 第七章/7.1_ 内部服务/robohash-deploy.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: robohash
spec:
  replicas: 1
  selector:
    matchLabels:
      app: robohash
  template:
    metadata:
      labels:
        app: robohash
    spec:
      containers:
      - name: robohash-container
        image: wdenniss/robohash:1    ❶

❶ Robohash 容器

这次,我们不会使用类型为LoadBalancer的 Service 将此服务暴露给世界,而是将其保持为内部服务,使用类型为ClusterIP的 Service。以下列表提供了我们的 Robohash 部署的内部服务定义。

列表 7.2 第七章/7.1_ 内部服务/robohash-service.yaml

apiVersion: v1
kind: Service
metadata:
  name: robohash-internal
spec:
  selector:
    app: robohash
  ports:
  - port: 80
    targetPort: 80
    protocol: TCP
 type: ClusterIP ❶

❶ 定义一个本地服务

由于这不是像我们在第三章中使用的那种LoadBalancer类型的 Service,它没有外部 IP。在创建这两个资源之后,要尝试它,你可以使用kubectl端口转发:

kubectl port-forward service/robohash-internal 8080:80

现在,你可以在本地机器上浏览到 http://localhost:8080 并检查该服务。要生成一个测试头像,尝试类似 http://localhost:8080/example 的地址。你应该会看到一个自动生成的机器人头像图像,如图 7.2 所示。

07-02

图 7.2 示例机器人头像(机器人部件由 Zikri Kader 设计,由 Robohash.org 组装,并授权于 CC-BY)

接下来,让我们使用这个内部服务从另一个服务——我们的前端——构建我们的微服务架构(图 7.3)!

07-03

图 7.3 简单微服务配置的序列图

要从其他 Pod 访问此内部服务,你可以引用其集群 IP。要查看分配的集群 IP,查询服务:

$ kubectl get service
NAME                TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)    
robohash-internal   ClusterIP   10.63.254.218   <none>        80/TCP

在这种情况下,你可以从给定集群 IP(在之前的输出中显示为10.63.254.218)上的其他 Pod 访问该服务,例如通过向http://10.63.254.218/example发送一个HTTP GET请求。这个地址只能在集群内的其他 Pod 中访问。

7.1.3 服务发现

在上一个示例中,我们使用了kubectl get service来查找分配给我们的服务的内部集群 IP 地址。虽然你可以简单地取出这个 IP 地址并将其硬编码到你的应用程序中,但这样做并不利于可移植性。你可能希望在几个不同的地方部署相同的应用程序,比如在本地开发机器上、在预发布环境中,以及在生产环境中(如何设置这些不同的环境在第十一章中有介绍)。如果你直接引用 IP 地址,每次都需要更新你的代码。

最好从需要调用服务的 Pod 动态发现 IP 地址,就像我们使用 kubectl 发现 IP 地址一样。Kubernetes 为 Pods 提供了两种进行服务发现的方式:使用 DNS 查询或环境变量。DNS 查询在集群范围内工作,而环境变量仅适用于同一命名空间内的 Pods。

使用环境变量进行服务发现

Kubernetes 自动为每个服务创建一个环境变量,用集群 IP 填充它,并在创建服务后的每个 Pod 中提供 IP 地址。变量遵循命名转换规则,我们的示例 robohash-internal 服务获得环境变量 ROBOHASH_INTERNAL_SERVICE_HOST

而不是找出正确的转换,你可以通过在 Pod 上运行 env 命令(使用 exec,输出被截断)来查看你的 Pod 可用的所有此类环境变量的列表:

$ kubectl get pods
NAME                        READY   STATUS    RESTARTS   AGE
robohash-6c96c64448-7fn24   1/1     Running   0          2d23h

$ kubectl exec robohash-6c96c64448-7fn24 -- env
ROBOHASH_INTERNAL_PORT_80_TCP_ADDR=10.63.243.43
ROBOHASH_INTERNAL_PORT_80_TCP=tcp://10.63.243.43:80
ROBOHASH_INTERNAL_PORT_80_TCP_PROTO=tcp
ROBOHASH_INTERNAL_SERVICE_PORT=80
ROBOHASH_INTERNAL_PORT=tcp://10.63.243.43:80
ROBOHASH_INTERNAL_PORT_80_TCP_PORT=80
ROBOHASH_INTERNAL_SERVICE_HOST=10.63.243.43

这种方法的优点是它非常快。环境变量只是字符串常量,没有依赖于 Pod 本身之外的外部依赖。这也让你可以指定任何你喜欢的 DNS 服务器来处理 Pod 的其他请求(例如,8.8.8.8)。缺点是只有与 Pod 同一命名空间中的服务会被填充到环境变量中,并且顺序很重要:服务必须在 Pod 之前创建,这样 Pod 才能获取到服务的环境变量。

如果你发现自己处于需要重启 Deployment 的 Pods 以获取服务更改的情况,你可以使用以下命令(不需要更改 Pod):

kubectl rollout restart deployment $DEPLOYMENT_NAME

引用这些变量的一个常见方式是在 Deployment 中定义自己的环境变量,提供内部服务的完整 HTTP 端点。这允许你的容器更加便携,能够在 Kubernetes 之外运行(例如,在 Docker Compose 中)。以下列表显示了如何将自动生成的环境变量(ROBOHASH_INTERNAL_SERVICE_HOST)的值嵌入到自己的自定义环境变量(AVATAR_ENDPOINT)中,该变量最终将由你的应用程序使用。

列表 7.3 Chapter07/7.1_InternalServices/timeserver-deploy-env.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: timeserver
spec:
  replicas: 1
  selector:
    matchLabels:
      pod: timeserver-pod
  template:
    metadata:
      labels:
        pod: timeserver-pod
    spec:
      containers:
      - name: timeserver-container
        image: docker.io/wdenniss/timeserver:5
 env:
 - name: AVATAR_ENDPOINT
 value: http://$(ROBOHASH_INTERNAL_SERVICE_HOST)   ❶

❶ 使用环境变量进行服务发现

使用这种额外的间接层,其中我们的自定义环境变量引用 Kubernetes 的环境变量,现在我们可以将这个容器独立运行在 Docker 中(只需在运行内部服务的任何地方用内部服务的端点填充 AVATAR_ENDPOINT)或切换到基于 DNS 的查找。

总结来说,环境变量发现有几个优点:

  • 极快性能(它们是字符串常量)

  • 不依赖于其他 DNS Kubernetes 组件

它也有一些缺点:

  • 仅适用于同一命名空间中的 Pods

  • Pods 必须在服务创建之后创建

使用 DNS 进行服务发现

发现服务的另一种方式是通过集群的内部 DNS 服务。对于在 Pod 所在的不同命名空间中运行的服务,这是唯一的发现选项。服务的名称作为 DNS 主机暴露,因此您可以对robohash-internal(或使用http://robohash-internal作为您的 HTTP 路径)进行 DNS 查找,它将解析。当从其他命名空间调用服务时,请附加命名空间——例如,使用robohash-internal.default来调用default命名空间中的服务robohash-internal

这种方法的唯一缺点是,由于需要 DNS 查找,解析 IP 地址会稍微慢一些。在许多 Kubernetes 集群中,这个 DNS 服务将在同一节点上运行,所以它相当快;在其他集群中,可能需要跳转到运行在不同节点上的 DNS 服务或托管 DNS 服务,因此请确保缓存结果。

由于我们之前将端点 URL 设置为 Deployment 的环境变量,我们可以轻松地更新该变量,这次给它提供服务名称(http://robohash-internal)。完整的 Deployment 将如下所示。

列表 7.4 第七章/7.1_InternalServices/timeserver-deploy-dns.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: timeserver
spec:
  replicas: 1
  selector:
    matchLabels:
      pod: timeserver-pod
  template:
    metadata:
      labels:
        pod: timeserver-pod
    spec:
      containers:
      - name: timeserver-container
        image: docker.io/wdenniss/timeserver:5
 env:
 - name: AVATAR_ENDPOINT
 value: http://robohash-internal    ❶

❶ 使用 DNS 进行服务发现

总结来说,基于 DNS 的服务发现有几个优点:

  • 可以从集群中的任何命名空间调用

  • 没有顺序依赖

它也有一些缺点:

  • 比使用环境变量(这是一个常量)略慢

  • 依赖于内部 DNS 服务

因此,使用环境变量和 DNS 查找是我们前端服务发现内部服务的内部 Pod IP 的两种方式,而不是将 IP 地址硬编码。由于这些发现方法具有 Kubernetes 特定性,建议您像示例中那样将路径作为环境变量提供给容器。然后,您可以在 Kubernetes 外部运行容器时轻松提供完全不同的路径。

将所有这些放在一起

让我们从 timeserver 应用向新的端点/avatar上的内部 Robohash 服务发起调用。这个新端点所做的只是从内部服务读取一个图像并将其返回。

列表 7.5 第七章/timeserver5/server.py

import urllib.request
import os
import random

# ...

case '/avatar':
    url = os.environ['AVATAR_ENDPOINT'] + "/" + str(random.randint(0, 100))
    try:
        with urllib.request.urlopen(url) as f:
            data = f.read()
            self.send_response(200)
            self.send_header('Content-type', 'image/png')
            self.end_headers()
            self.wfile.write(data) 
    except urllib.error.URLError as e:
        self.respond_with(500, e.reason)

# ...

现在我们应用程序实际上使用了内部服务,我们可以将其全部部署到 Kubernetes 中:

$ cd Chapter07/7.1_InternalServices
$ kubectl create -f robohash-deploy.yaml
deployment.apps/robohash created
$ kubectl create -f robohash-service.yaml
service/robohash-internal created
$ kubectl create -f timeserver-deploy-dns.yaml
deployment.apps/timeserver created
$ kubectl create -f timeserver-service.yaml
service/timeserver created

$ kubectl get svc/timeserver
NAME         TYPE           CLUSTER-IP      EXTERNAL-IP    PORT(S)        AGE
timeserver   LoadBalancer   10.22.130.155   203.0.113.16   80:32131/TCP   4m25s

$ open "http://203.0.113.16/avatar"

等待外部 IP 配置完成,然后尝试使用/avatar URL。您应该会看到一个机器人头像。将 timeserver-deploy-dns.yaml 替换为 timeserver-deploy-env.yaml 以使用具有相同结果的替代发现方法。

我们现在正在使用微服务架构!使用这种技术,您可以拥有多个可以单独部署和管理的内部服务(可能由不同的团队管理)。您可以使用开源工具添加单独的服务,或者简单地汇集您用不同语言编写的应用程序的不同组件。

7.2 入口:HTTP(S)负载均衡

到目前为止,在本书中,我们一直在使用类型为 LoadBalancer 的服务创建外部 IP。这为您提供了一个所谓的第 4 层(L4)负载均衡器,它在网络层平衡请求,并且可以与各种协议(例如,TCP、UDP、SCTP)一起工作。您使用所需的协议和端口配置服务,然后您会得到一个将在您的 Pods 上平衡流量的 IP。如果您通过负载均衡器公开 HTTP 服务,您需要实现自己的 TLS 终止处理(即配置证书并运行 HTTPS 端点),并且所有到该端点的流量都将被路由到一组 Pods(基于 matchLabels 规则)。没有直接在同一个负载均衡器上公开两个或更多独立服务的选项(尽管可以在内部代理请求到另一个服务)。

当您专门发布 HTTP 应用程序时,您可能可以从所谓的第 7 层(L7)负载均衡器中获得更多实用功能,该负载均衡器在 HTTP 请求层进行平衡,并且可以执行更多复杂的功能,例如终止 HTTPS 连接(这意味着它将为您处理 HTTPS 的细节),并执行基于路径的路由,以便您可以使用多个服务为单个域名主机提供服务。在 Kubernetes 中,HTTP 负载均衡器是通过入口对象创建的。

入口允许您在单个外部 IP 后面放置多个内部服务,并实现负载均衡。您可以根据它们的 URI 路径(/foo/bar)、主机名(foo.example.combar.example.com)或两者(图 7.4)将 HTTP 请求定向到不同的后端服务。能够在单个 IP 上运行多个服务,并且可能在不同域名下提供不同路径的能力是入口独有的,因为如果您像前几章中那样使用类型为 LoadBalancer 的独立服务公开它们,则服务将具有不同的 IP 地址,需要不同的域名(例如,使用 foo.example.com 来访问一个,使用 bar.example.com 来访问另一个)。

07-04

图 7.4 入口的规则列表,或 URL 映射,允许一个 HTTP 负载均衡器处理多个服务的流量。

入口能够将多个服务放置在单个主机下的属性,在扩展您的应用程序时非常有用。当您需要将服务拆分成多个服务以提高开发效率(例如,团队想要管理自己的部署生命周期)或进行扩展(例如,能够单独扩展应用程序的某些方面)时,您可以使用入口来路由请求,同时不更改任何面向公众的 URL。例如,假设您的应用程序有一个特别占用 CPU 的路径。您可能希望将其移动到自己的服务中,以便它可以单独扩展。入口允许您无缝地对最终用户进行此类更改。

列表 7.6 提供了一个示例 Ingress,其中路由由不同的后端提供服务。在这个例子中,我们将暴露根路径(/)上的内部 Timeserver 服务,以及 /robohash 上的内部 Robohash 服务。

列表 7.6 第七章/7.2_Ingress/ingress_path.yaml

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: timeserver-ingress
spec:
  rules:
  - http:
      paths:
 - path: /         ❶
        pathType: Prefix
        backend:
          service:
            name: timeserver-internal
            port:
              number: 80
 - path: /robohash ❷
        pathType: Prefix
        backend:
          service:
            name: robohash-internal
            port:
              number: 80

❶ 第一路径,由 timeserver-internal 服务处理

❷ 第二路径,由 robohash-internal 服务处理

列表 7.7 展示了使用不同主机的变体。这些主机也可以使用列表 7.6 中的格式拥有多个路径。

列表 7.7 第七章/7.2_Ingress/ingress_host.yaml

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: timeserver-ingress
spec:
  rules:
 - host: timeserver.example.com  ❶
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: timeserver-internal
            port:
              number: 80
 - host: robohash.example.com  ❷
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: robohash-internal
            port:
              number: 80

❶ 第一主机,由 timeserver-internal 服务处理

❷ 第二主机,由 robohash-internal 服务处理

Ingress 引用了指定为 NodePort 类型的服务作为内部服务,如下列所示。[*]

列表 7.8 第七章/7.2_Ingress/timeserver-service-internal.yaml

apiVersion: v1
kind: Service
metadata:
  name: timeserver-internal
spec:
  selector:
    pod: timeserver-pod
  ports:
  - port: 80
    targetPort: 80
    protocol: TCP
 type: NodePort  ❶

❶ 要使用 Ingress 中的内部服务,它需要是 NodePort 类型

可以通过 pathType 属性配置 Ingress 对象以执行精确匹配(即,只有与给定路径完全匹配的请求将被路由到服务)或前缀匹配(即,所有与路径前缀匹配的请求都将被路由)。这里我不会详细介绍,因为官方文档已经做得很好。值得重现的一个方面是关于多个匹配的规则:

在某些情况下,Ingress 内部的多个路径将匹配一个请求。在这些情况下,优先级将首先给予最长匹配的路径。如果两个路径仍然完全匹配,则优先级将给予具有精确路径类型的路径,而不是前缀路径类型。¹

正如你在列表 7.6 中所见,存在一个 / 路径和一个 /robohash 的第二个路径。对 /robohash 的请求将被路由到第二个服务,即使它也匹配第一个路径。如果你过去使用过其他路由机制(如 Apache URL 重写),通常优先级会给予第一个匹配的规则——但在 Kubernetes 中并非如此,其中较长的匹配规则会获得优先级。我发现这种设计很方便,因为它很好地符合开发者的意图。

要部署此示例,如果之前运行的示例仍在运行,请先删除它(kubectl delete -f Chapter07/7.1_InternalServices),然后运行以下命令:

$ cd Chapter07/7.2_Ingress 
$ kubectl create -f robohash-deploy.yaml 
deployment.apps/robohash created
$ kubectl create -f robohash-service.yaml 
service/robohash-internal created
$ kubectl create -f timeserver-deploy-dns.yaml 
deployment.apps/timeserver created
$ kubectl create -f timeserver-service-internal.yaml 
service/timeserver-internal created
$ kubectl create -f ingress_path.yaml
ingress.networking.k8s.io/timeserver-ingress created

$ kubectl get ing -w
NAME                 CLASS    HOSTS   ADDRESS        PORTS   AGE
timeserver-ingress   <none>   *                      80      4s
timeserver-ingress   <none>   *       203.0.113.20   80      100s

一旦你的 Ingress 有了一个 IP,你就可以浏览它。尝试 /robohash 路径通过 Ingress 连接到 Robohash 服务。请注意,支持 Ingress 的资源可能需要一些额外的时间来配置。即使你已经有了 IP 地址并浏览了它,你可能会看到一段时间的 404 错误。我建议大约 5 分钟后再试一次,以便给云服务提供商一些时间来更新 Ingress。

要调试 Ingress 的问题,可以使用 kubectl describe ingress。以下是我描述 Ingress 时的所见,当时它已经分配了 IP,但尚未就绪:

$ kubectl describe ingress
Name:             timeserver-ingress
Namespace:        default
Address:          203.0.113.20
Default backend:  default-http-backend:80 (10.22.0.130:8080)
Rules:
  Host        Path  Backends
  ----        ----  --------
  *           
              /           timeserver-internal:80 (10.22.0.135:80)
              /robohash   robohash-internal:80 (10.22.1.4:80)
Annotations:  ingress.kubernetes.io/backends:                      ❶
 {"k8s-be-32730--a52250670846a599":"Unknown", ❶
                "k8s1-a5225067":"Unknown","k8s1-a5225067-default-timeser ...
              ingress.kubernetes.io/forwarding-rule: k8s2-fr-21mgs2fl
              ingress.kubernetes.io/target-proxy: k8s2-tp-21mgs2fl
              ingress.kubernetes.io/url-map: k8s2-um-21mgs2fl
Events:
  Type    Reason     From          Message
  ----    ------     ----          -------
  Normal  Sync       loadbalancer  UrlMap "k8s2-um-21mgs2fl" created
  Normal  Sync       loadbalancer  TargetProxy "k8s2-tp-21mgs2fl" created
  Normal  Sync       loadbalancer  ForwardingRule "k8s2-fr-21mgs2fl" created
  Normal  IPChanged  loadbalancer  IP is now 203.0.113.20
  Normal  Sync       loadbalancer  Scheduled for sync

❶ 后端状态未知

以下是在等待几分钟后的状态。注意注释如何从Unknown变为HEALTHY。之后,我能够浏览到 IP 并访问服务:

$ kubectl describe ing
Name:             timeserver-ingress
Namespace:        default
Address:          203.0.113.20
Default backend:  default-http-backend:80 (10.22.0.130:8080)
Rules:
  Host        Path  Backends
  ----        ----  --------
  *           
              /           timeserver-internal:80 (10.22.0.135:80)
              /robohash   robohash-internal:80 (10.22.1.4:80)
Annotations:  ingress.kubernetes.io/backends: ❶
 {"k8s-be-32730--a52250670846a599":"HEALTHY", ❶
                "k8s1-a5225067":"HEALTHY","k8s1-a5225067-default-timeser...
              ingress.kubernetes.io/forwarding-rule: k8s2-fr-21mgs2fl
              ingress.kubernetes.io/target-proxy: k8s2-tp-21mgs2fl
              ingress.kubernetes.io/url-map: k8s2-um-21mgs2fl
Events:
  Type    Reason     From          Message
  ----    ------     ----          -------
  Normal  Sync       loadbalancer  UrlMap "k8s2-um-21mgs2fl" created
  Normal  Sync       loadbalancer  TargetProxy "k8s2-tp-21mgs2fl" created
  Normal  Sync       loadbalancer  ForwardingRule "k8s2-fr-21mgs2fl" created
  Normal  IPChanged  loadbalancer  IP is now 203.0.113.20
  Normal  Sync       loadbalancer  Scheduled for sync

❶ 后端状态现在是健康的

节省成本技巧:使用 Ingress 保存 IP

Ingress 的一个好处是,通过使用基于主机的路由,你可以托管多个服务,所有这些服务都使用相同的公网 IP 地址。Ingress 会检查 HTTP 请求中的Host头,并根据此进行流量路由。这与类型为LoadBalancer的服务形成对比,其中每个服务都分配了自己的 IP 地址,并且不执行基于 HTTP 请求的路由。

云服务提供商通常根据负载均衡规则收费,这大致等同于分配了多少个负载均衡的外部 IP 地址。通过使用 Ingress 将多个服务组合成一个,而不是每个服务都使用自己的 IP 进行暴露,你可能会节省一些费用。

如果你的云服务提供商将 HTTP 负载均衡器(Ingress)和网络负载均衡器(类型为LoadBalancer的服务)分开管理,并且有最低规则费用(例如,在撰写本文时,谷歌云有最低规则费用),那么你可能想要在需要超过最低费用之前,只使用其中之一。

另一个技巧,但我不推荐的是运行自己的 Ingress 控制器。这种技术(本书未涉及)意味着部署一个开源组件作为负载均衡器来实现 Kubernetes Ingress 功能,覆盖云提供商的默认实现。这种方法意味着 Ingress 对象和类型为LoadBalancer的服务对象在计费上被视为相同的规则类型,如果你需要两者,这可以节省一些费用,但有一个牺牲:你现在需要自己管理这个组件。你是 Kubernetes Ingress 控制器调试方面的专家吗?根据我的经验,最好是全盘使用标准的 Ingress 对象,或者如果你需要节省费用,就坚持使用纯负载均衡器。

7.2.1 使用 TLS 保护连接

Ingress 的另一个有用特性是它会为你执行 TLS 加密。现代 Web 应用程序通常以安全的 HTTPS 应用程序的形式托管,使用 TLS,这对于安全性很重要,但会给应用程序服务器带来一些开销。根据你使用的服务器中间件,你可能通过让 Ingress 负载均衡器处理 TLS 连接(充当所谓的 TLS 终止器)并通过 HTTP 与后端通信(当然是通过云提供商的网络安全网络,如图 7.5 所示)来获得性能提升。如果你愿意,Ingress 可以重新加密流量并通过 HTTPS 连接到你的服务,但没有任何选项可以直接将未修改的加密流量从客户端直接传递到后端。为此,你需要使用类型为LoadBalancer的服务,就像我们在第三章中所做的那样。

07-05

图 7.5 入口终止 HTTPS (TLS)流量,并将其通过普通 HTTP 或 HTTPS 连接转发到服务 Pod。

现在 Ingress 正在终止你的 TLS 连接,你需要使用证书来设置它。如果你像我一样,已经在不同的系统上做过几次,你可能对这一步感到担忧。幸运的是,Kubernetes 让这个过程变得非常简单!

你只需要将你的证书和密钥作为 Kubernetes 密钥导入,然后在 Ingress 配置中引用该密钥。Kubernetes 密钥只是你集群中的一个数据对象,用于包含像 TLS 密钥这样的东西。

要做到这一点,通常你会遵循证书颁发机构的说明来创建证书,最终产品将包括我们需要的两个文件:你创建的私钥和证书颁发机构签发的证书。

为了演示目的,我们可以创建自己的自签名证书来代替受信任的证书。请注意,虽然这将为连接提供相同的加密,但没有身份验证,你会在浏览器中看到一些令人恐惧的消息。以下命令将创建这样的证书:

# create a private key
openssl genrsa -out example.key 2048

# create a certificate request for 'example.com'
openssl req -new -key example.key -out example.csr \
    -subj "/CN=example.com"

# self-issue an untrusted certificate
openssl x509 -req -days 365 -in example.csr -signkey \
    example.key -out example.crt

一旦你有了私钥和证书,无论是根据前面的说明创建的还是根据证书颁发机构的说明创建的,你现在可以创建 Kubernetes 密钥了:

kubectl create secret tls my-tls-cert --cert example.crt --key example.key

你可能会注意到这里的强制kubectl create命令。这是我推荐使用强制命令而不是在文件中定义配置的少数几次之一,因为它比手动创建对象并 Base64 编码所有数据要简单。如果你想查看这个命令创建的配置,你可以很容易地使用kubectl get -o yaml secret my-tls-cert来查看。

最后一步是在我们的 Ingress 中引用这个密钥,如下所示。

列表 7.9 第七章/7.2.1_TLS/ingress_tls.yaml

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: timeserver-tls
spec:
 tls:
 - secretName: my-tls-cert ❶
  rules:
  - host: example.com
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: timeserver-internal
            port:
              number: 80
  - http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: robohash-internal
            port:
              number: 80

❶ 引用 TLS 密钥

引用上一节中创建的NodePort类型的服务,我们可以使用 TLS 密钥创建这个新的 Ingress:

$ cd Chapter07/7.2.1_TLS/
$ kubectl create -f ingress_tls.yaml 
ingress.networking.k8s.io/timeserver-tls created
$ kubectl get ing
NAME             CLASS    HOSTS         ADDRESS          PORTS     AGE
timeserver-tls   <none>   example.com   203.0.113.15     80, 443   9m15s
$ open "https://203.0.113.15"

记住,即使 Ingress 已经收到了 IP,预配步骤也可能需要一段时间。如果你使用的是自签名证书,你会在浏览器中看到一些令人恐惧的警告。

要测试这个 Ingress 中的域名路由(例如示例中的example.com),你需要配置你使用的域名的 DNS,并使用 Ingress 的 IP。要本地测试,你也可以编辑你的 hosts 文件并添加 IP 和域名(要找到如何操作的说明,可以在“如何在<你的操作系统版本>中更新 hosts 文件”的 Google 搜索中找到答案!)。你可以使用kubectl get ingress来找到 Ingress 的 IP。以下是我的 Ingress 对象的外观,以及我添加到本地 hosts 文件中的条目:

$ kubectl get ingress
NAME               CLASS    HOSTS         ADDRESS        PORTS     AGE
timeserver-tls     <none>   example.com   203.0.113.15   80, 443   82m

$ cat /etc/hosts
# ...
203.0.113.15 example.com

现在,假设你已经配置了你的主机,你应该能够浏览到 https://example.com。如果你生成一个自签名证书,你会得到一个令人恐惧的浏览器错误,在这种情况下,点击通过是可以的。要实际上将你的服务发布到世界,你需要返回并从实际的证书颁发机构请求一个证书,并使用它来创建 TLS 秘密。

再次强调,Kubernetes 的好处在于所有这些配置都是以 Kubernetes 对象的形式存在,而不是主机上的随机文件,这使得在其他地方重现环境变得简单直接。

使用 GKE?尝试使用托管证书

之前的说明是关于向你的 Kubernetes Ingress 对象添加经过验证的 CA 证书。如果你使用 Google Kubernetes Engine (GKE) 并希望采用更简单的方法,你可以使用托管证书。

使用托管证书,你可以跳过 CA 签名步骤以及将你的私钥和证书复制到 Kubernetes 作为秘密的过程。相反,你首先需要向 Google(在 Google Cloud 控制台中)证明你对域的所有权,创建一个 GKE 特定的 ManagedCertificate 对象,列出你希望为哪些(子)域名提供证书,然后在你的 Ingress 中引用该对象。Google 将自动提供和管理证书。这一切都很简单,所以我会让官方文档^a 成为你的指南。

^a cloud.google.com/kubernetes-engine/docs/how-to/managed-certs

摘要

  • 当你的需求超出单个容器可以托管的内容时,Kubernetes 提供了多种工具来创建、发现、连接和公开多个服务。

  • 内部服务是一种连接各种工作负载的方式,这些工作负载可以用不同的语言编写,处于不同的发布计划,或者简单地需要独立扩展。

  • 内部服务可以暴露在集群 IP 上,允许它们被集群中的其他 Pods 调用。

  • Kubernetes 提供了两种服务发现形式来查找这些内部服务 IP:环境变量和 DNS。

  • Ingress 可以用来通过单个 IP 向互联网公开多个内部服务,路由通过路径或主机名执行。

  • Ingress 是一个 HTTP(S) 负载均衡器,可以配置多个 TLS 证书以执行 TLS 终止。

  • 通过在负载均衡器层执行 TLS 终止,你可以节省应用程序的配置工作量并减少 CPU 负载。


^(1.) kubernetes.io/docs/concepts/services-networking/ingress/#multiple-matches

8 节点特征选择

本章涵盖

  • 选择具有特定硬件属性的节点

  • 使用污点(taints)和容忍(tolerations)来管理具有特殊硬件的节点上的调度行为

  • 在离散节点上保持工作负载分离

  • 使用高可用性部署策略避免单点故障

  • 在节点上将一些 Pod 分组在一起,同时避免包含特定其他 Pod 的节点

到目前为止,本书将集群中的计算节点——负责实际运行您的容器的机器——视为平等。不同的 Pod 可能需要更多或更少的 CPU,但它们都在底层相同类型的节点上运行。

云计算的一个基本属性是,即使您在使用一个抽象平台,该平台为您处理许多底层计算预留(如 Kubernetes 平台所能做到的),您仍然可能在一定程度上关心实际运行您工作负载的服务器。无服务器是一个很好的概念,但最终,工作负载是在计算机上运行的,您并不总是能摆脱该机器的特性,也不总是想这么做。

这就是节点特征选择发挥作用的地方。在托管平台上,包括 Google Kubernetes Engine (GKE),节点有各种各样的不同硬件和配置选项。节点的 CPU 可以是 x86 架构或 Arm。它可能是 AMD 或 Intel。如果需要,节点可以连接昂贵的硬件,如 GPU,或者可以在低价的 Spot 预留模式下运行,在节省资金的同时承担中断的风险。您可能并不总是需要关心这些元素,但它们可能很有用,比如使用 Spot 节省资金,或者当您需要 GPU 来运行 AI/ML 工作负载时至关重要。

另一个需要注意的方面是,Kubernetes 在同一节点上运行多个 Pod,这是一种称为装箱的技术。在同一硬件上运行多个容器可以帮助您节省资金,并且对于需要临时使用其他 Pod 的预留容量的突发情况特别有用。装箱的缺点是存在单点故障的可能性。幸运的是,Kubernetes 内置了一种称为 pod spread topology 的方法,以避免同一节点上相同 Pod 的副本集中。在本章中,您将学习如何根据节点的特征选择节点,将 Pod 分组在一起,并将它们分散开来。

8.1 节点特征选择

并非所有计算节点都是相同的。您可能有一些需要额外硬件的工作负载,例如更高性能的 CPU 和 GPU,或者运行在 Spot 预留模型中的属性。一些节点运行 Linux,而其他节点运行 Windows。一些 CPU 使用 x86 架构;其他使用 Arm 等等。就像过去我们可能会将工作负载放置在具有特定功能的机器上一样,我们可以在 Kubernetes 中通过节点选择和亲和性做到这一点。

8.1.1 节点选择器

在 Kubernetes 中,节点功能通过节点标签来区分。您从 Pod 中选择特定节点功能的方式是通过节点选择或节点亲和性。节点选择和亲和性只是表达所需节点标签(因此是功能)的 Pod 所需节点的方式。

以一个需要运行在基于 Arm 的节点上的 Pod 为例。基于 Arm 的节点被标记为众所周知的标签kubernetes.io/arch: arm64(众所周知的标签是在开源中定义的,旨在在不同提供商之间保持一致性)。我们可以使用节点选择器或节点亲和性来定位该标签,并确保我们的 Pod 只运行在基于 Arm 的节点上。在下面的列表中,工作负载选择arm64架构以防止 Pod 被调度到任何其他类型的 CPU 架构。

列表 8.1 第八章/8.1.1_ 节点选择/deploy_nodeselector.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: timeserver
spec:
  replicas: 1
  selector:
    matchLabels:
      pod: timeserver-pod
  template:
    metadata:
      labels:
        pod: timeserver-pod
    spec:
 nodeSelector: ❶
 kubernetes.io/arch: arm64 ❶
      containers:
      - name: timeserver-container
        image: docker.io/wdenniss/timeserver:5

❶ 选择具有 arm64 架构的节点

通过节点亲和性以更详细的方式表达相同的要求。

列表 8.2 第八章/8.1.1_ 节点选择/deploy_nodeaffinity.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: timeserver
spec:
  replicas: 1
  selector:
    matchLabels:
      pod: timeserver-pod
  template:
    metadata:
      labels:
        pod: timeserver-pod
    spec:
 affinity: ❶
 nodeAffinity: ❶
 requiredDuringSchedulingIgnoredDuringExecution: ❶
 nodeSelectorTerms: ❶
 - matchExpressions: ❶
 - key: kubernetes.io/arch ❶
 operator: In ❶
 values: ❶
 - arm64 ❶
      containers:
      - name: timeserver-container
        image: docker.io/wdenniss/timeserver:5

❶ 另一种选择具有 arm64 架构的节点的方法

这两个之前的部署配置将实现完全相同的结果:仅将 Pod 放置在基于 Arm 的节点上(要验证 Pod 的放置位置,可以使用kubectl get pods -o wide查询,然后使用kubectl describe node $NODE_NAME | grep arch检查节点)。节点亲和性方法的优点,以及您会使用它的原因,是它允许表达更复杂的逻辑,我将在下一节中详细介绍。

在您的 PodSpecs 中要求这些与功能相关的节点标签是第一步,但您需要一种方法来实际部署具有该功能的节点(即,拥有您所选择的标签)。始终如一,节点的提供及其相关功能是在平台级别完成的。如果您正在使用完全管理的平台,如 Autopilot 模式下的 GKE,只需指定带有功能标签的节点选择器就足够了,以获取具有那些功能的节点,前提是平台提供了该功能。在更传统的 Kubernetes 平台上,您需要独立提供具有那些功能的节点,例如,通过创建具有所需属性的节点池或节点组。

要找出支持哪些功能,提供商的文档是最好的。然而,如果您在集群中有一个具有所需属性的节点,您也可以检查它并查看可用的标签:

kubectl describe nodes

这里是 GKE 上运行的一个基于 Arm 的节点的一些标签输出示例:

Labels:             cloud.google.com/compute-class=Scale-Out
                    cloud.google.com/gke-boot-disk=pd-standard
                    cloud.google.com/gke-container-runtime=containerd
                    cloud.google.com/gke-image-streaming=true
                    cloud.google.com/gke-max-pods-per-node=32
                    cloud.google.com/gke-nodepool=nap-19wjaxds
                    cloud.google.com/gke-os-distribution=cos
                    cloud.google.com/machine-family=t2a
 kubernetes.io/arch=arm64          ❶
                    kubernetes.io/hostname=gk3-autopilot-cluster-4-nap-19wja
                    kubernetes.io/os=linux
                    node.kubernetes.io/instance-type=t2a-standard-4
                    node.kubernetes.io/masq-agent-ds-ready=true
                    topology.gke.io/zone=us-central1-f
                    topology.kubernetes.io/region=us-central1
                    topology.kubernetes.io/zone=us-central1-f

❶ 列表 8.1 和 8.2 中引用的节点标签

8.1.2 节点亲和性和反亲和性

节点亲和性非常灵活,可以做到比要求一组标签更多的事情。例如,使用In运算符,你可以指定一个可能的值列表。假设你想要选择 x86 或 Arm 作为架构;你可以通过节点亲和性提供可能的值列表来实现,如下所示。

列表 8.3 Chapter08/8.1.2_NodeAffinity/deploy_nodeaffinity_multi.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: timeserver
spec:
  replicas: 6
  selector:
    matchLabels:
      pod: timeserver-pod
  template:
    metadata:
      labels:
        pod: timeserver-pod
    spec:
 affinity: ❶
 nodeAffinity: ❶
 requiredDuringSchedulingIgnoredDuringExecution: ❶
 nodeSelectorTerms: ❶
 - matchExpressions: ❶
 - key: kubernetes.io/arch ❶
 operator: In ❶
 values: ❶
 - arm64 ❶
 - amd64 ❶
      containers:
      - name: timeserver-container
        image: docker.io/wdenniss/timeserver:5
          resources:
            requests:
              cpu: 500m

❶ 此 Pod 可以在 arm64(Arm)或 amd64(x86)架构上运行。

虽然在列表 8.1 中使用的nodeSelector字段可以在多个条件下进行选择,但所有条件都必须满足,Pod 才能被调度。这里使用的In逻辑允许在不同的值上进行调度,这是节点亲和性的独特之处。你可以在matchExpressions下添加额外的表达式来要求多个条件满足。

使用operator逻辑,可以通过NotIn将表达式转换为反亲和性(即避免具有给定标签的节点),这将确保 Pod 不会落在具有指定标签的节点上(见表 8.1)。

表 8.1 操作符逻辑

操作符 描述
In 节点标签的值是给定选项之一。
NotIn 给定的值不在你提供的列表中。
Exists 节点上存在标签键(任何值)。
DoesNotExist 节点上不存在标签键。
Gt 给定的值大于节点标签中的值。
Lt 给定的值小于节点标签中的值。

节点亲和性的另一个好处是,你可以创建偏好而不是必需的规则来表示一组偏好。例如,如果你的容器是多架构的,可以在 x86 或 Arm 上运行,但你更喜欢尽可能使用 Arm(例如,出于成本原因),那么你可以在以下列表中表达这一点。

列表 8.4 Chapter08/8.1.2_NodeAffinity/deploy_nodeaffinity_preferred.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: timeserver
spec:
  replicas: 6
  selector:
    matchLabels:
      pod: timeserver-pod
  template:
    metadata:
      labels:
        pod: timeserver-pod
    spec:
 affinity: ❶
 nodeAffinity: ❶
 preferredDuringSchedulingIgnoredDuringExecution: ❶
 - weight: 100 ❶
 preference: ❶
 matchExpressions: ❶
 - key: kubernetes.io/arch ❶
 operator: In ❶
 values: ❶
 - arm64 ❶
      containers:
      - name: timeserver-container
        image: docker.io/wdenniss/timeserver:5
        resources:
          requests:
            cpu: 500m

❶ 偏好调度在 arm64 上,但如果 arm64 不可用,则会在任何节点上调度。

偏好亲和性的注意事项

这种preferredDuringSchedulingIgnoredDuringExecution逻辑有时可能会产生令人惊讶的结果。当你在节点上有现有的未分配容量时,偏好排序是有效的,但当没有未分配的偏好类型容量且需要新节点时,它与集群自动扩展的交互可能与你所期望的相反。例如,如果你的集群中现有的节点上有任何未分配的容量,即使是不偏好的类型,Kubernetes 实际上会首先在那里调度 Pod,然后平台才会启动以添加你偏好的类型的节点。

原因在于,Kubernetes 调度器,负责将 Pod 放置在节点上,以及平台自动扩展器(一个常见的平台组件,负责添加新节点),它们在某种程度上是独立操作的。在平台层面,一个典型的节点自动扩展器会寻找可以调度的挂起 Pod,如果增加了更多容量。但由于 Kubernetes 调度器首先启动并将 Pod 放置在不太受欢迎但可用的容量上,自动扩展器就没有机会进行操作了。

当使用云提供商时,你通常只需要求你需要的功能,并依赖他们将有能力满足这些需求的事实。

8.1.3 通过污点化节点来防止默认调度

通常,当你有一组具有特殊特征的节点,你可能希望默认防止 Pod 被调度到这些节点上。以 Arm 架构为例:由于并非所有容器镜像都支持它,你可能想配置你的集群,以便 Arm 架构的节点默认不用于调度,除非工作负载明确表示支持。其他例子包括当你有一个具有特殊硬件(如 GPU)的节点,你需要只为将使用此硬件的 Pod 保留时,或者当你有可以突然关闭的 Spot 计算时,并非所有工作负载都可能对此做出良好响应。

当你可以通过节点反亲和性(即使用NotIn操作符的节点亲和性规则)注释每个 Pod 来避免这些节点,这确实很费时。相反,Kubernetes 允许你“污点化”一个节点,以防止 Pod 默认被调度到它上面。其工作原理是这样的:你污点化具有特殊特征且不应默认被调度的节点。然后,你“容忍”这个污点,仅针对那些可以在这些节点上运行的工作负载的 PodSpec。

以为例,我们可以单独污点化节点以观察其效果。在生产环境中你通常不会这样做,但这是一种不错的实验方法。对于这个演示,我们可以使用 Minikube(在第三章中介绍)并按照以下方式污点化一个节点:

$ minikube start --nodes 3
Done! kubectl is now configured to use "minikube" cluster

$ kubectl get nodes
NAME           STATUS     ROLES           AGE   VERSION
minikube       Ready      control-plane   77s   v1.24.3
minikube-m02   Ready      <none>          55s   v1.24.3
minikube-m03   NotReady   <none>          19s   v1.24.3

$ NODE_NAME=minikube-m02
$ kubectl taint nodes $NODE_NAME spot=true:NoSchedule
node/minikube-m02 tainted

TIP 如果稍后你想移除污点,可以使用kubectl taint nodes $NODE_NAME spot-

在这个例子中,spot=true是我们为污点赋予的名称,并在稍后标记 Pod 为能够容忍这个污点时使用。NoSchedule关键字表示这个污点效果期望的行为(即没有容忍的 Pod 不应被调度)。有其他替代的NoSchedule行为,但我不建议使用它们。PreferNoSchedule是一个创建软规则的选项,听起来可能很有用;然而,如果你的主要目标是避免在节点类别上调度 Pod,软规则无法实现这一点,并且可能会使调试更困难。有时,有一个需要分配资源的未调度 Pod 比在特殊污点机器上调度它并引起其他未指定的问题要好。

当你在托管 Kubernetes 平台上操作时,你不太可能像上一个例子那样单独标记节点。通常,污点适用于具有相同特征的一组节点,节点在升级或维修事件期间会定期更换,这意味着任何单个节点的污点标记都会被撤销。寻找平台提供商的 API,该 API 允许你标记节点组,以便污点将应用于组中的所有节点,并在升级期间持续存在。

GKE 中的节点污点

当使用 GKE 的 Autopilot 模式时,节点污点完全是自动的。当你选择特定的(非默认)功能,如 Spot 计算或 Arm 架构时,配置的节点会自动标记污点。方便的是,Pod 也会修改以容忍自动污点,所以你只需要选择功能即可。这种 Pod 的自动修改是通过一个准入控制器(在第十二章中介绍)完成的,该控制器由平台安装和维护。

当使用 GKE 与节点池时,你可以在创建节点池时标记节点池。例如,如果你正在创建一个由虚拟机组成的节点池,你可以配置所有节点如下进行污点标记:

gcloud container node-pools create $NODE_POOL_NAME --cluster $CLUSTER_NAME \
  --spot --node-taints spot=true:NoSchedule

如果你的整个集群都由 Spot 节点组成,通常不需要污点,因为没有必要区分节点。

一旦你标记了节点,如果你调度一个工作负载,你会注意到它不会被调度到这些节点上(使用kubectl get pods -o wide来查看 Pod 落在哪个节点上)。为了使工作负载可以在你刚刚标记的节点上调度,工作负载需要更新以容忍污点,如下所示。

列表 8.5 第八章/8.1.3_Taints/deploy_tolerate_spot.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: timeserver
spec:
  replicas: 3
  selector:
    matchLabels:
      pod: timeserver-pod
  template:
    metadata:
      labels:
        pod: timeserver-pod
    spec:
 tolerations: ❶
 - key: spot ❶
 value: "true" ❶
      containers:
      - name: timeserver-container
        image: docker.io/wdenniss/timeserver:5

❶ 此工作负载可以容忍具有 spot=true 污点的节点,因此可能被调度到这些节点上。

单独的容忍并不会强制 Pod 只被调度到污点节点上;它仅仅允许它在那里被调度。Pod 将被调度到哪个节点将由几个其他因素决定,如可用容量。因此,具有容忍的 Pod 可以落在无污点节点上,也可以落在它们可以容忍污点的节点上,如图 8.1 所示。

08-01

图 8.1 此集群有一个低可用性的 Spot 虚拟机和两个标准节点。批处理工作负载 Pod 可以容忍污点,因此可以调度到这两个节点上,而应用部署 Pod 则不行,所以它们只能调度到无污点节点上。

通常,你会将污点和容忍与节点选择器或节点亲和力结合使用,以确保特定的 Pod 集,并且只有这个 Pod 集,将在相关的节点上运行。一个很好的例子是 GPU 工作负载:这些工作负载必须只在具有 GPU 的节点上运行,你不想非 GPU 工作负载占用那个宝贵空间(图 8.2)。

08-02

图 8.2 这个集群有一个特殊的节点带有 GPU 和两个标准节点。GPU 节点被标记为污点,以防止标准工作负载被调度到它上面。GPU 工作负载容忍污点,因此可以调度到 GPU 节点,并使用节点选择器来确保它只在这个节点上调度。

容忍所有污点

一些工作负载——最常见的是作为 DaemonSet(在第十二章中介绍)部署的工作负载——需要在每个节点上运行,并且必须设计为处理集群的所有配置。这些工作负载通常容忍所有污点,如下面的列表所示。

列表 8.6 第八章/8.1.3_ 污点/daemonset_tolerate_all_taints.yaml

apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: example-ds
spec:
  selector:
    matchLabels:
      pod: example-pod
  template:
    metadata:
      labels:
        pod: example-pod
    spec:
 tolerations: ❶
 - effect: NoExecute ❶
 operator: Exists ❶
 - effect: NoSchedule ❶
 operator: Exists ❶
      containers:
      - image: ubuntu
        command: ["sleep", "infinity"]
        name: ubuntu-container

❶ 容忍所有污点

只要注意,当你这样做时,你的 Pod 实际上需要在集群现在和未来可能存在的所有节点类型上运行。当添加像基于 Arm 的节点这样的功能时,这可能会成为问题,因为需要为 Arm 架构特别构建容器。如果出现需要将 Pod 调度到所有节点上,无论是否有污点,除了具有特定标签(如 Arm 架构)的污点的情况,你可以通过将容忍度与节点反亲和性规则相结合来实现,如下一个列表所示。

列表 8.7 第八章/8.1.3_ 污点/daemonset_tolerate_antiaffinity.yaml

apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: example-ds
spec:
  selector:
    matchLabels:
      pod: example-pod
  template:
    metadata:
      labels:
        pod: example-pod
    spec:
      tolerations:                                          ❶
      - effect: NoExecute                                   ❶
        operator: Exists                                    ❶
      - effect: NoSchedule                                  ❶
        operator: Exists                                    ❶
 affinity: ❷
 nodeAffinity: ❷
 requiredDuringSchedulingIgnoredDuringExecution: ❷
 nodeSelectorTerms: ❷
 - matchExpressions: ❷
 - key: kubernetes.io/arch ❷
 operator: NotIn ❷
 values: ❷
 - arm64 ❷
      containers:
      - image: ubuntu
        command: ["sleep", "infinity"]
        name: ubuntu-container

❶ 容忍所有污点...

❷ ...但不要在基于 Arm 的节点上调度

8.1.4 工作负载分离

污点、容忍度和节点选择器的另一个用途是分离工作负载。到目前为止,我们介绍的节点选择用例都是围绕基于特性的选择——需要 Arm 架构、Spot 计算、GPU 节点等。

节点选择不仅限于节点特性,还可以用于在节点上分离工作负载。虽然你可以使用 Pod 反亲和性(在第 8.2.3 节中介绍)来防止特定的 Pod 被放置在同一位置,但有时仅仅将工作负载保持在它们各自专门的节点组上是有帮助的。

我多次听到的这个要求来自运行批处理工作负载的人,这些工作负载由协调器 Pod(调度工作)和工作 Pod(执行工作)组成。他们更喜欢将这两个角色的 Pod 分别放在它们自己的节点上,这样任何针对工作 Pod 的节点自动扩展,这些 Pod 往往来去不定,就不会影响协调器 Pod 的扩展,这些 Pod 往往相对稳定。另一个例子是嘈杂邻居问题,其中两个 Pod 可能会在节点上竞争资源,如果分离的话会工作得更好。

要实现工作负载分离,我们可以结合迄今为止使用的一些技术,以及自定义节点标签。节点获得一个标签和一个污点,而工作负载则获得对该标签的容忍度和选择器,这共同意味着工作负载将独立地(可能与其他具有相同选择器和容忍度的工作负载共享)在节点组上调度。

下面的列表提供了一个具有任意容忍和节点选择器的示例部署,以实现工作负载分离。为了方便,我们将使用相同的键/值对("group=1")来表示这两个元素,尽管请注意,它们在 Kubernetes 中是不同的概念。

列表 8.8 Chapter08/8.1.4_WorkloadSeparation/deploy_group1.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: timeserver1
spec:
  replicas: 5
  selector:
    matchLabels:
      pod: timeserver1-pod
  template:
    metadata:
      labels:
        pod: timeserver1-pod
    spec:
 tolerations: ❶
 - key: group ❶
 operator: Equal ❶
 value: "1" ❶
 effect: NoSchedule ❶
 nodeSelector:     ❷
 group: "1" ❷
      containers:
      - name: timeserver-container
        image: docker.io/wdenniss/timeserver:5

❶ 容忍 group=1 污点

❷ 选择 group=1 标签

此外,为了演示,我们可以在文件 deploy_group2.yaml 中复制这个部署,使用 "group=2" 作为容忍和选择器的键/值:

      tolerations:
      - key: group           ❶
        operator: Equal      ❶
        value: "2"           ❶
        effect: NoSchedule   ❶
      nodeSelector:          ❷
        group: "2"           ❷

❶ 容忍 group=2 污点

❷ 选择 group=2 标签

要将这些部署的 Pods 部署在离散的节点集上,我们需要有被污点化的节点来防止其他 Pods 落在这些节点上,并且被标记以便我们的工作负载可以针对它们。如果您跳过标记节点,这些部署将永远不会被调度,因为没有节点满足节点选择器要求。如果您标记了节点但没有污点化它们,这些工作负载将可以调度并且通过节点选择器彼此分离。然而,由于没有污点来阻止它们,其他随机的 Pods 也可能落在它们上面。

在 GKE Autopilot 上的工作负载分离

如果您在 GKE 的自动驾驶模式下部署先前的工作负载,将自动配置具有所需标签和污点的节点!这是因为这个无节点操作平台正在根据您的 Pod 要求进行操作,并提供匹配的节点,所以您不需要做任何事情。在一个传统的 Kubernetes 平台上,您需要自己创建具有这些属性的节点。

在您管理的 Kubernetes 环境中,您需要提供具有正确污点和标签的节点以实现工作负载分离。使用 Minikube 进行演示,我们可以直接污点并标记节点。请注意,在托管平台上,您通常在节点池或组级别操作节点,并使用平台 API 提供节点,因此请在该 API 中查找标签和污点参数:

$ minikube start --nodes 3                               ❶

$ kubectl get nodes                                      ❷
NAME           STATUS   ROLES           AGE   VERSION
minikube       Ready    control-plane   67s   v1.24.3
minikube-m02   Ready    <none>          46s   v1.24.3
minikube-m03   Ready    <none>          24s   v1.24.3
$ kubectl taint nodes minikube-m02 group=1:NoSchedule    ❸
$ kubectl label node minikube-m02 group=1                ❸

$ kubectl taint nodes minikube-m03 group=2:NoSchedule    ❹
$ kubectl label node minikube-m03 group=2                ❹

❶ 创建一个新的 Minikube 集群。

❷ 查看节点。

❸ 为 group 1 污点并标记 m02 节点。

❹ 为 group 2 污点并标记 m03 节点。

污点(以及匹配的容忍和节点选择器)都是必需的(因为它们服务于不同的目的)。污点阻止除了可以容忍污点的工作负载之外的所有工作负载落在其上,而标签可以用来确保工作负载不会落在任何其他节点上(例如,没有任何污点的节点)。为了方便,我使用了相同的键/值对("group=1")来表示污点和标签,但这并不一定必须如此。

在配置好我们的集群后,我们可以部署我们的工作负载分离部署并查看结果。特别注意 Pods 落在哪个节点上:

$ kubectl create -f Chapter08/8.1.4_WorkloadSeparation
deployment.apps/timeserver1 created
deployment.apps/timeserver2 created

$ kubectl get pods -o wide
NAME                          READY  STATUS   RESTARTS  AGE   NODE
timeserver1-75b69b5795-9n7ds  1/1    Running  0         2m2s  minikube-m02 ❶
timeserver1-75b69b5795-kft64  1/1    Running  0         2m2s  minikube-m02 ❶
timeserver1-75b69b5795-mnc4j  1/1    Running  0         2m2s  minikube-m02 ❶
timeserver1-75b69b5795-msg9v  1/1    Running  0         2m2s  minikube-m02 ❶
timeserver1-75b69b5795-r8r9t  1/1    Running  0         2m2s  minikube-m02 ❶
timeserver2-6cbf875b6b-6wm7w  1/1    Running  0         2m2s  minikube-m03 ❷
timeserver2-6cbf875b6b-dtnhm  1/1    Running  0         2m2s  minikube-m03 ❷
timeserver2-6cbf875b6b-fd6vh  1/1    Running  0         2m2s  minikube-m03 ❷
timeserver2-6cbf875b6b-q6fk8  1/1    Running  0         2m2s  minikube-m03 ❷
timeserver2-6cbf875b6b-zvk72  1/1    Running  0         2m2s  minikube-m03 ❷

❶ timeserver1 的 Pods 正在 minikube-m02 节点上运行

❷ timeserver2 的 Pods 正在 minikube-m03 节点上运行

一旦你完成了 minikube 集群,你可以删除它的所有痕迹:

minikube delete

8.2 放置 Pod

在一个 Pod 失败健康检查或出现内存泄漏并需要重启的情况下,拥有多个 Pod 副本是一种良好的实践。除了副本数量如何影响可用性(在第 5.2.4 节中讨论)之外,考虑在哪里放置这些 Pod 也很重要。

如果你有一个 Pod 的 10 个副本,但它们都在单个节点上,你将受到该节点故障的影响。扩展到典型的云拓扑,如果你的所有节点都在单个可用区中,你将面临区域故障的风险。你应该花费多少时间和金钱来防范这些条件,这是一个你需要根据自己的生产保证和预算来做出的选择,因为天空才是极限(例如,你是否采用多云?)。

我将专注于一些合理且经济的策略,以在已有的节点上分散你的 Pod。你可以不额外付费就做到这一点,并且这会给你带来一些额外的可用性。

8.2.1 构建高可用部署

到目前为止,我们已经了解了如何使用资源请求将 Pod 分配给节点。然而,还有其他维度需要考虑。为了使你的应用程序具有高可用性,理想的情况是副本不会都落在同一个节点上。比如说,你有一个小的 Pod(100 mCPU,100 MiB)和三个副本。这三个副本可以很容易地都放在同一个节点上。但是,如果那个节点发生故障,部署就会离线。

最好的做法是将调度器将这些 Pod 分散到你的集群中!幸运的是,Kubernetes 有一个内置的方式来实现这一点,称为拓扑分布约束(图 8.3)。拓扑分布约束旨在将你的节点分散到故障域中,例如节点或整个区域;可以指定多个约束,这样你就可以在节点和区域或任何其他由你的提供商定义的故障域中分散。

08-03

图 8.3 带和不带拓扑约束的单个工作负载的 Pod 放置

nOTE 许多 Kubernetes 提供商为工作负载部署提供一些默认拓扑分布,包括 GKE。如果你信任默认设置在大多数情况下做正确的事情,你可以自由跳过本节。无论如何,我都包括了这个信息,因为我认为了解为什么事情会这样工作是有帮助的,所以我认为了解 Pod 为什么会在节点之间分散是很重要的。也有可能使用本章中的技术来修改默认策略,例如,对关键任务部署施加更严格的限制,并将拓扑分布应用于默认不获得它们的对象,如作业(在第十章中介绍)。

要覆盖特定工作负载的分布拓扑,你可以添加topologySpreadConstraints键,就像我在以下列表中所做的那样。

列表 8.9 第八章/8.2.1_TopologySpread/deploy_topology.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: timeserver
spec:
  replicas: 3
  selector:
    matchLabels:
      pod: timeserver-pod
  template:
    metadata:
      labels:
        pod: timeserver-pod
    spec:
 topologySpreadConstraints:            ❶
 - maxSkew: 1                          ❷
 topologyKey: kubernetes.io/hostname ❸
 whenUnsatisfiable: ScheduleAnyway ❹
 labelSelector:             ❺
 matchLabels:             ❺
 pod: timeserver-pod             ❺
      containers:
      - name: timeserver-container
        image: docker.io/wdenniss/timeserver:5
        resources:
          requests:
            cpu: 200m
            memory: 250Mi

❶ 添加的拓扑约束

❷ 复制副本最大不平衡数

❸ 用于拓扑的节点标签

❹ 当无法满足拓扑要求时的行为

❺ 另一个标签选择器设置为该模板的元数据标签

在此示例中,我们使用topologyKey参数针对kubernetes.io/hostname拓扑,这意味着 Kubernetes 将考虑所有具有相同kubernetes.io/hostname键值的标签节点视为相等。由于没有两个节点应该具有相同的主机名,这导致了一个节点级别的扩展目标。

为了使此配置生效——我必须强调这一点——您必须确保您的集群中的节点实际上具有在topologyKey中指定的标签(在我的例子中是kubernetes.io/hostname)。有一些众所周知的标签,¹就像我这里使用的那样,但无法保证您的 Kubernetes 平台会使用它们。因此,通过运行kubectl describe node并查看您的节点具有的标签来验证。

在示例中查看其余的设置,我使用了maxSkew1的最小偏斜。因此,最多只能有一级不平衡,这意味着任何节点最多只能比其他节点多一个 Pod。

whenUnsatisfiable参数控制当约束无法满足时(例如,节点完全被其他 Pod 填满)会发生什么。选项有ScheduleAnywayDoNotSchedule,其行为是显而易见的。DoNotSchedule在测试时很有用,因为它使得更容易看到规则何时生效,但在生产环境中,ScheduleAnyway将更安全。虽然ScheduleAnyway使规则成为一个“软”规则,但 Kubernetes 仍会尽力满足您的需求,我认为这比完全未安排副本要好,尤其是当我们的目标是提高副本的高可用性时!

最后一个字段是一个带有子matchLabels组的labelSelector,您可能还记得第三章的内容。Kubernetes 在这里没有简单的自引用功能确实令人沮丧;也就是说,为什么您需要这个,因为它已经嵌入在 Pod 的规范中?无论如何,matchLabels应该与您在 Deployment 中已经指定的内容相同。

在此基础上,让我们继续部署此示例并验证结果放置是否符合预期。为了演示这一点,我们需要一个包含几个节点的集群和一个没有任何默认扩展行为的集群。GKE 自带默认节点和区域扩展,因此在该平台上此设置不是必需的;了解幕后发生的事情或如果您需要微调行为,这仍然是有益的。为了尝试此操作并查看不同拓扑之间的差异,我建议使用配置了三个节点的 Minikube:

minikube start --nodes 3
cd Chapter08/8.2.1_TopologySpread 
kubectl create -f deploy_topology.yaml
kubectl get pods -o wide

查看图 8.4 中的NODE列,您应该看到三个单独的节点(假设您的集群中有三个节点)。

08-04

图 8.4:使用topologySpreadConstraints的部署,框内显示了唯一节点

nOTE 拓扑扩展是在调度时间的一个约束;换句话说,它只在 Pod 放置到节点上时考虑。一旦所有副本都在运行,如果拓扑发生变化(例如,添加了一个节点),正在运行的 Pod 不会被移动。如果需要,你可以通过将更改部署进行滚动更新来重新部署 Pod,这将再次应用调度规则,因此任何拓扑变化都会被考虑。

从图 8.4 的输出中我们可以看到,我们的三个 Pod 分别被调度在不同的节点上。为了比较,部署相同的部署但不包含topologySpreadConstraints字段,你会注意到 Pod 可以聚集在同一个节点上。如果你观察到即使没有设置拓扑,Pod 也会分散,那么这很可能是由于集群默认设置。

topologySpreadConstraints字段可以与任何节点标签一起使用,因此另一种常见策略是在区域(如果你有一个多区域集群)之间进行扩展。为此,你可以重复之前的例子,但使用基于区域的键,其中topology.kubernetes.io/zone是标准化且广为人知的键。但再次提醒,请检查你的节点实际上是否有这个标签;否则,它将没有任何效果,或者根据你如何配置whenUnsatisfiable字段,它可能会阻止整个调度。

8.2.2 将相互依赖的 Pod 进行协同定位

在某些情况下,你可能会有紧密耦合的 Pod,你希望它们存在于同一台物理机器上(图 8.5)。特别“健谈”的服务(即,它们进行很多跨服务过程调用)通常是这种类型架构的候选者。比如说,你有一个前端和一个后端,它们之间有很多通信。你可能希望将它们配对在同一个节点上,以减少网络延迟和流量。

08-05

图 8.5:在同一个节点上调度了与后端 Pod 相同的三个前端 Pod,使用 Pod 亲和性

这种部署结构可以通过 Pod 亲和性规则实现。本质上,其中一个部署——使用之前的例子,可能是前端——获得一个规则告诉调度器,“只将这个 Pod 放置在具有后端 Pod 的节点上。”让我们假设我们有一个如以下列表所示的后端部署。

列表 8.10:Chapter08/8.2.2_Colocation/backend.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: mariadb
spec:
  replicas: 1
  selector:
    matchLabels:
      pod: mariadb-pod
  template:
    metadata:
      labels:              ❶
        pod: mariadb-pod   ❶
    spec:
      containers:
      - name: mariadb-container
        image: mariadb
        env:
        - name: MARIADB_RANDOM_ROOT_PASSWORD
          value: "1"

❶ 将用于亲和性的标签。

这个部署没有任何特别之处;它遵循我们一直在使用的相同模式。这个 Pod 将被放置在集群中的任何可用空间上。

现在,对于前端部署,我们希望它被放置在包含后端部署 Pod 实例的节点上,我们可以使用以下列表中的配置。

列表 8.11 第八章/8.2.2_ 同地部署/frontend.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: timeserver
spec:
  replicas: 3
  selector:
    matchLabels:
      pod: timeserver-pod
  template:
    metadata:
      labels:
        pod: timeserver-pod
    spec:
      containers:
      - name: timeserver-container
        image: docker.io/wdenniss/timeserver:5
 affinity: ❶
 podAffinity: ❶
 requiredDuringSchedulingIgnoredDuringExecution: ❶
 - labelSelector: ❶
 matchExpressions:❶
 - key: pod❶
 operator: In❶
 values:❶
 - mariadb-pod❶
 topologyKey: "kubernetes.io/hostname" ❶

❶ Pod 亲和度规则

此规范要求调度器将此 Pod 定位到具有现有带有标签 pod: mariadb-pod 的 Pod 的指定拓扑结构中的节点。如果您在之前章节使用的相同 minikube 集群中创建这两个对象,您将注意到所有四个 Pod 都被放置在同一个节点上(即后端 Pod 被调度所在的节点)。由于示例中的拓扑结构是节点拓扑(使用主机名的已知标签),应用程序将只被调度到具有目标 Pod 的节点上。如果使用区域拓扑(使用区域已知标签,如 8.2.1 中讨论的),Pod 将被放置在具有目标标签的任何节点上。

要使这种同地部署成为一个软(或尽力)要求,以便即使无法满足要求,您的 Pod 仍然可以被调度,可以使用 preferredDuringSchedulingIgnoredDuringExecution 而不是 requiredDuringSchedulingIgnoredDuringExecution。当使用首选亲和度时,需要在规范中添加一些额外的字段,例如规则的权重(如果表达多个偏好,则用于对优先级进行排序)。

如您所见,Kubernetes 确实非常灵活,允许您创建绑定或仅作为指南的调度规则,并以无数种方式(节点和区域是两种常见选择)指定您首选的拓扑结构。事实上,这种灵活性可能会导致您在选择时感到困惑。对于大多数部署,我建议一开始使用 Pod 亲和度,而是将这些技术放在您的“口袋”里,并在您需要解决特定问题时(例如,希望将 Pod 部署在单个节点上以减少服务间延迟)应用它们。

8.2.3 避免某些 Pod

在 8.2.1 节中,我介绍了如何使用拓扑分布将来自 相同 工作负载部署的 Pod 分散开来,以避免单点故障。那么对于相关(因此您希望它们分散)但分别部署(拓扑分布不适用)的 Pod 呢?例如,假设您有一个后端服务的 Deployment 和一个单独的缓存服务的 Deployment,并且您希望它们分散部署。

对于此目的,您可以使用 Pod 反亲和度。这简单地将之前章节中的 Pod 亲和度规则反转,以便 Pod 将被调度到其他节点或您选择的拓扑结构。

列表 8.12 第八章/8.2.3_Pod 反亲和度/frontend.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: timeserver
spec:
  replicas: 3
  selector:
    matchLabels:
      pod: timeserver-pod
  template:
    metadata:
      labels:
        pod: timeserver-pod
    spec:
      containers:
      - name: timeserver-container
        image: docker.io/wdenniss/timeserver:5
      affinity:
 podAntiAffinity:              ❶
          requiredDuringSchedulingIgnoredDuringExecution:
          - labelSelector:
              matchExpressions:
              - key: pod
                operator: In
                values:
                - mariadb-pod
            topologyKey: "kubernetes.io/hostname"

❶ 上一个示例中的 Pod 亲和度规则被反转,因此现在这个 Pod 将明确避免具有 pod: mariadb-pod 标签的 Pod 所在的节点。

所有这些结构都可以一起使用,因此你可以拥有一个广泛地试图将 Pods 分开的拓扑结构,并使用亲和规则进行精细控制。只需小心确保你的规则实际上是可以满足的;否则,你最终会得到未安排的 Pods。正如前一小节中提到的常规亲和性一样,你也可以通过指定preferredDuringSchedulingIgnoredDuringExecution而不是requiredDuringSchedulingIgnoredDuringExecution来使用软规则。当你这样做的时候,你可能想先使用规则的要求版本进行测试,以确保在切换到首选版本之前你的labelSelector字段设置正确。下一节将提供一些设置这些规则的调试技巧。

8.3 调试放置问题

Pod 放置是一个相当复杂的话题,所以如果你遇到问题不要感到惊讶。最常见的问题发生在你需要一个标签,但你的所有节点都没有,或者在无节点平台的情况下,一个平台不支持的功能的标签。这样的 Pod 永远不会被安排。以下几节将突出一些你可能遇到的一些常见问题以及如何解决它们。

8.3.1 放置规则似乎不起作用

如果你的放置规则在测试中似乎不起作用,我首先建议确保你没有使用任何软(首选)放置规则。这些规则意味着当规则无法满足时,调度器基本上会忽略你的规则,这对测试来说并不太好。在将它们改为软规则之前,最好验证所有规则都在正常工作。

使用只有几个节点且没有软规则的小集群,你应该能够观察到放置功能的效果。通过故意尝试安排违反规则的 Pod 来验证规则是否得到执行。它们的状态应该是Pending,因为约束无法满足。

8.3.2 Pods 处于挂起状态

处于Pending状态的 Pod 意味着调度器找不到它们合适的位置。在第三章中,我们讨论了在集群没有足够的资源来放置 Pod 的情况下出现的这个错误。一旦你配置了放置规则,Pod 可能无法被安排,因为规则无法满足。要找出原因(即哪个规则无法满足),描述 Pod。请注意,你需要以 Pod 级别进行此操作——Deployment 本身不会显示任何错误消息,尽管它会指示所需的副本数量没有达到:

kubectl get pods
kubectl describe Pod $POD_NAME

以下是一个在可用的节点有一个 Pod 无法容忍的污点的情况的输出示例。要么将容忍度添加到 Pod 中,要么添加更多没有污点的节点。一些示例错误包括以下内容:

Events:
  Type     Reason            Age               From               Message
  ----     ------            ----              ----               -------
Warning    FailedScheduling  4s                default-scheduler  0/1 nodes are available: 1 node(s) had taints that the pod didn't tolerate.

这里有一些 Pod 的亲和性或反亲和性规则无法满足的情况的输出。审查并修改规则,然后再次尝试:

  Type     Reason            Age               From               Message
  ----     ------            ----              ----               -------
Warning    FailedScheduling  17s (x3 over 90s) default-scheduler  0/1 nodes are available: 1 node(s) didn't match pod affinity/anti-affinity, 1 node(s) didn't match pod anti-affinity rules.

摘要

  • 您可以从 Pod 规范中选择或避免具有特定硬件属性的节点。

  • 具有特殊特性的节点可以通过污点(taint)来防止默认调度。为在这些节点上运行的 Pod 配置容忍污点(tolerate the taint)。

  • 污点、容忍、节点标签和选择器可以组合使用,以保持某些工作负载彼此分离。

  • 使用多个副本和良好配置的拓扑分布策略构建高可用性部署。

  • 可以通过 Pod 亲和性将相互受益的 Pod 放置在同一位置。

  • 不希望与某些 Pod 共存的 Pod 可以配置 Pod 反亲和性。


[1](https://kubernetes.io/docs/reference/kubernetes-api/labels-annotations-taints/) kubernetes.io/docs/reference/kubernetes-api/labels-annotations-taints/

9 状态化应用

本章涵盖

  • Kubernetes 用于表示磁盘和状态的结构

  • 将持久存储添加到 Pods

  • 使用 StatefulSet 以具有领导者角色的方式部署多 Pod 状态化应用

  • 通过将 Kubernetes 对象重新链接到磁盘资源来迁移和恢复数据

  • 为 Pods 提供大型的临时存储卷

状态化应用(即具有附加存储的工作负载)终于在 Kubernetes 中找到了归宿。虽然无状态应用因其部署简便性和高可扩展性而常受到赞誉,这得益于无需附加和管理存储的需求,但这并不意味着状态化应用没有其位置。无论你是部署复杂的数据库还是将旧状态化应用从虚拟机(VM)迁移过来,Kubernetes 都能为你提供支持。

使用持久卷,您可以将状态化存储附加到任何 Kubernetes Pod。当涉及到具有状态的多副本工作负载时,正如 Kubernetes 提供了 Deployment 作为管理无状态应用的高级结构一样,StatefulSet 存在就是为了提供状态化应用的高级管理。

9.1 卷、持久卷、持久卷声明和存储类

要在 Kubernetes 中开始存储状态,在继续到更高层次的状态化集结构之前,需要了解一些关于卷(磁盘)管理的基本概念。就像节点是 Kubernetes 对 VM 的表示一样,Kubernetes 也有自己的磁盘表示。

9.1.1 卷

Kubernetes 为 Pods 提供了功能,允许它们挂载卷。什么是 ?文档这样描述它:

在其核心,卷只是一个目录,可能包含一些数据,这些数据对 Pod 中的容器是可访问的。这个目录是如何形成的,支持它的介质以及它的内容是由特定卷类型使用的¹决定的。

Kubernetes 随带一些内置的卷类型,其他类型可以通过您的平台管理员通过存储驱动程序添加。您可能会经常遇到的一种类型是 emptyDir,这是一个与节点生命周期相关的临时卷,ConfigMap,它允许您在 Kubernetes 清单中指定文件,并将它们作为磁盘上的文件呈现给您的应用程序,以及云提供商的持久存储磁盘。

EmptyDir 卷

内置的卷类型 emptyDir 是一种临时卷,它从节点的启动磁盘上分配空间。如果 Pod 被删除或移动到另一个节点,或者节点本身变得不健康,所有数据都会丢失。那么它的好处是什么呢?

Pods 可以有多个容器,并且emptyDir挂载可以在它们之间共享。所以当你需要在容器之间共享数据时,你会在 Pod 中的每个容器中定义一个emptyDir卷并将其挂载(见 9.1 列表)。数据也会在容器重启之间持久化,只是不是之前提到的所有事件。这对于像磁盘缓存这样的临时数据很有用,如果数据在 Pod 重启之间被保留,但长期存储不是必需的。

列表 9.1 第九章/9.1.1_ 卷/emptydir_pod.yaml

apiVersion: v1
kind: Pod
metadata:
  name: emptydir-pod
  labels:
    pod: timeserver-pod
spec:
  containers:
  - name: timeserver-container
    image: docker.io/wdenniss/timeserver:5
    volumeMounts:
    - name: cache-volume        ❶
      mountPath: /app/cache/    ❶
  volumes:
  - name: cache-volume          ❷
    emptyDir: {}                ❷

❶ 挂载路径

❷ 卷定义

为什么这被称为emptyDir?因为数据存储在节点上最初为空的目录中。在我看来,这是一个误称,但你又能怎么办呢?

提示:如果你在寻找工作负载的临时空间,请参阅 9.4 节关于通用临时卷,这是一种更现代的方法,可以在不依赖于主机卷的情况下获得临时存储。

对于一个实际示例,请参阅 9.2.2 节,其中使用emptyDir在同一个 Pod 中的两个容器之间共享数据,其中一个容器是一个首先运行的 init 容器,可以为主容器执行设置步骤。

ConfigMap 卷

ConfigMap 是一个有用的 Kubernetes 对象。你可以在一个地方定义键值对,并从多个其他对象中引用它们。你还可以使用它们来存储整个文件!通常,这些文件将是配置文件,如 MariaDB 的my.cnf,Apache 的httpd.conf,Redis 的redis.conf等。你可以将 ConfigMap 作为卷挂载,这允许从容器中读取它定义的文件。ConfigMap 卷是只读的。

这种技术特别适用于定义用于公共容器镜像的配置文件,因为它允许你提供配置,而无需扩展镜像本身。例如,要运行 Redis,你可以引用官方的 Redis 镜像,只需将你的配置文件挂载到 ConfigMap 中,Redis 期望的位置即可——无需构建自己的镜像仅为了提供这个文件。有关使用通过 ConfigMap 卷指定的自定义配置文件配置 Redis 的示例,请参阅 9.2.1 和 9.2.2 节。

云提供商卷

更适用于构建有状态的应用程序,在这些应用程序中,你通常不希望使用临时或只读卷,是将云提供商的磁盘作为卷挂载。无论你在哪里运行 Kubernetes,你的提供商都应该已经将驱动程序提供给集群,允许你挂载持久存储,无论是 NFS 还是基于块的(通常是两者都有)。

09-01

图 9.1 挂载云提供商卷的 Pod

例如,以下列表提供了在 Google Kubernetes Engine (GKE)中运行的 MariaDB Pod 的规范,该 Pod 在/var/lib/mysql挂载 GCE 持久磁盘以进行持久存储,如图 9.1 所示。

列表 9.2 第九章/9.1.1_ 卷/mariadb_pod.yaml

apiVersion: v1
kind: Pod
metadata:
  name: mariadb-demo
  labels:
    app: mariadb
spec:
  nodeSelector:
    topology.kubernetes.io/zone: us-west1-a   ❶
  containers:
  - name: mariadb-container
    image: mariadb:latest
    volumeMounts:                             ❷
    - mountPath: /var/lib/mysql               ❷
      name: mariadb-volume                    ❷
    env:
    - name: MARIADB_ROOT_PASSWORD
      value: "your database password"
  volumes:
  - name: mariadb-volume
    gcePersistentDisk:
      pdName: mariadb-disk                    ❸
      fsType: ext4

❶ 节点选择器针对磁盘存在的区域,以确保 Pod 将在该区域创建

❷ 磁盘将要挂载的目录

❸ Google Cloud 资源持久磁盘的名称

与我们接下来将要介绍的更自动化和云无关的方法不同,这种方法与您的云提供商相关联,并需要手动创建磁盘。您需要确保存在指定名称的磁盘,您需要通过外部方式(即使用您的云提供商的工具)创建它,并且磁盘和 Pod 都在同一个区域。在这个例子中,我使用 nodeSelector 来定位磁盘的区域,这对于存在于多个区域的任何 Kubernetes 集群来说都很重要;否则,您的 Pod 可能会被调度到与磁盘不同的区域。

使用以下命令可以创建本例中使用的磁盘:

gcloud compute disks create --size=10GB --zone=us-west1-a mariadb-disk

注意:本例及其伴随的特定于云提供者的说明提供是为了完整性,并说明如何开发卷,但这不是推荐使用卷的方式。继续阅读以了解使用 PersistentVolumes 和 StatefulSet 创建磁盘的更好、平台无关的方法!

由于我们正在手动创建此磁盘,请密切关注资源创建的位置。之前命令中的区域和通过 nodeSelector 配置设置的区域需要匹配。如果您看到您的 Pod 卡在 Container Creating 状态,请检查事件日志以获取答案。以下是一个我没有在正确项目中创建磁盘的情况:

$ kubectl get events -w

0s Warning FailedAttachVolume pod/mariadb-demo
AttachVolume.Attach failed for volume "mariadb-volume" : GCE persistent disk
not found: diskName="mariadb-disk" zone="us-west1-a"

直接挂载卷的缺点是磁盘需要在 Kubernetes 之外创建,这意味着以下:

  • 创建 Pod 的用户必须具有创建磁盘的权限,这并不总是如此。

  • 存在于 Kubernetes 配置之外且需要记住并手动运行的步骤。

  • 卷描述符是平台相关的,因此这个 Kubernetes YAML 文件不可移植,且在其他提供者上无法使用。

自然地,Kubernetes 为这种不可移植性提供了解决方案。通过使用 Kubernetes 提供的持久卷抽象,您可以简单地请求所需的磁盘资源,并且它们将为您配置,无需执行任何外部步骤。请继续阅读。

9.1.2 持久卷和声明

为了以更平台无关的方式管理卷,Kubernetes 提供了更高层次的原始操作:PersistentVolume(PV)和 PersistentVolumeClaim(PVC)。Pod 不是直接链接到卷,而是引用一个 PersistentVolumeClaim 对象,该对象以平台无关的术语定义了 Pod 所需的磁盘资源(例如,“1 GB 的存储空间”)。磁盘资源本身在 Kubernetes 中使用 PersistentVolume 对象表示,就像 Kubernetes 中的节点表示 VM 资源一样。当创建 PersistentVolumeClaim 时,Kubernetes 将寻求通过创建或匹配它来提供声明中请求的资源,并将这两个对象绑定在一起(图 9.2)。一旦绑定,PV 和 PVC,现在相互引用,通常在底层磁盘被删除之前保持链接。

09-02

图 9.2 一个引用 PersistentVolumeClaim 的 Pod,该 PersistentVolumeClaim 被绑定到 PersistentVolume,后者引用一个磁盘

这种拥有请求资源的需求和表示资源可用性的对象的行为,类似于 Pod 请求计算资源(如 CPU 和内存),而集群找到具有这些资源的节点来调度 Pod 的方式。这也意味着存储请求是以平台无关的方式定义的。与直接使用云提供商的磁盘不同,当使用 PersistentVolumeClaim 时,只要平台支持持久存储,您的 Pods 可以部署在任何地方。

让我们重写上一节中的 Pod,使用 PersistentVolumeClaim 请求为我们的 Pod 请求一个新的 PersistentVolume。这个 Pod 将挂载一个连接到 /var/lib/mysql 的外部磁盘,这是 MariaDB 存储其数据的地方。

列表 9.3 Chapter09/9.1.2_PersistentVolume/pvc-mariadb.yaml

apiVersion: v1
kind: Pod
metadata:
  name: mariadb-demo
  labels:
    app: mariadb
spec:
  containers:
  - name: mariadb-container
    image: mariadb:latest
    volumeMounts:                   ❶
    - mountPath: /var/lib/mysql     ❶
      name: mariadb-volume          ❶
    resources:                      ❷
      requests:                     ❷
        cpu: 1                      ❷
        memory: 4Gi                 ❷
    env:
    - name: MARIADB_ROOT_PASSWORD
      value: "your database password"
  volumes:
  - name: mariadb-volume
    persistentVolumeClaim:          ❸
      claimName: mariadb-pv-claim   ❸
---
apiVersion: v1
kind: PersistentVolumeClaim         ❹
metadata:
  name: mariadb-pv-claim
spec:
  accessModes:
  - ReadWriteOnce
  resources:                        ❺
    requests:                       ❺
      storage: 2Gi                  ❺

❶ PVC 支持的卷将被挂载的 MariaDB 数据目录

❷ Pod 请求的计算资源

❸ 使用持久化卷声明对象而不是磁盘资源

❹ 持久化卷声明对象

❺ Pod 请求的存储资源

PersistentVolumeClaim 定义中,我们请求了 2 GiB 的存储空间,并指定了所需的 accessModeReadWriteOnce 访问模式适用于像传统硬盘一样行为的卷,其中您的存储被挂载到单个 Pod 以进行读写访问,这是最常见的方式。accessMode 的其他选择包括 ReadOnlyMany,它可以用来挂载跨多个 Pod 共享的现有数据卷,以及 ReadWriteMany 用于挂载文件存储(如 NFS),在这种情况下,多个 Pod 可以同时进行读写(这是一种相当特殊的模式,仅由少数存储驱动程序支持)。在本章中,目标是使用基于传统块存储的持久化应用,因此 ReadWriteOnce 被用于整个过程中。

如果你的提供商支持动态配置,则会创建一个由磁盘资源支持的PersistentVolume来满足PersistentVolumeClaim请求的存储,之后PersistentVolumeClaimPersistentVolume将被绑定在一起。PersistentVolume的动态配置行为通过StorageClass定义,我们将在下一节中介绍。GKE 和几乎每个提供商都支持动态配置,并将有一个默认的存储类,因此列表 9.3 中的先前 Pod 定义几乎可以在任何地方部署。

在罕见的情况下,如果你的提供商不支持动态配置,你(或集群操作员/管理员)将需要手动创建足够的资源来满足PersistentVolumeClaim请求的PersistentVolume(图 9.3)。Kubernetes 仍然会执行将声明与手动创建的 PersistentVolumes 的卷匹配的配对。

09-03

图 9.3 动态配置系统中PersistentVolumeClaimPersistentVolume的生命周期

如前例中定义的PersistentVolumeClaim可以被视为对资源的请求。当它与PersistentVolume资源匹配并绑定时,对资源的声明发生,这两个资源相互链接。本质上,PersistentVolumeClaim的生命周期从请求开始,在绑定时成为声明。

我们可以将其留在这里,但由于你的宝贵数据将存储在这些磁盘上,让我们深入了解这种绑定是如何工作的。如果我们创建列表 9.3 中的资源,然后查询绑定后的PersistentVolumeClaim的 YAML,你会看到它已经更新了volumeName。这个volumeName是它链接到的PersistentVolume的名称,现在它声称。以下是它的样子(省略了一些冗余信息以提高可读性):

$ kubectl create -f Chapter09/9.1.2_PersistentVolume/pvc-mariadb.yaml
pod/mariadb-demo created
persistentvolumeclaim/mariadb-pv-claim created

$ kubectl get -o yaml pvc/mariadb-pv-claim
apiVersion: v1
PersistentVolumeClaim
metadata:
  name: mariadb-pv-claim
spec:
  accessModes:
  - ReadWriteOnce
  resources:
    requests:
      storage: 2Gi
  storageClassName: standard-rwo
  volumeMode: Filesystem
 volumeName: pvc-ecb0c9ed-9aee-44b2-a1e5-ff70d9d3823a ❶
status:
  accessModes:
  - ReadWriteOnce
  capacity:
    storage: 2Gi
  phase: Bound

❶ 当前对象绑定的PersistentVolume

我们可以使用kubectl get -o yaml pv $NAME查询此配置中命名的PersistentVolume,我们会看到它直接链接回 PVC。以下是我的查询结果:

$ kubectl get -o yaml pv pvc-ecb0c9ed-9aee-44b2-a1e5-ff70d9d3823a
apiVersion: v1
kind: PersistentVolume
metadata:
  name: pvc-ecb0c9ed-9aee-44b2-a1e5-ff70d9d3823a
spec:
  accessModes:
  - ReadWriteOnce
  capacity:
    storage: 2Gi
  claimRef:
    apiVersion: v1
    kind: PersistentVolumeClaim
 name: mariadb-pv-claim         ❶
 namespace: default ❶
  csi:
    driver: pd.csi.storage.gke.io
    fsType: ext4
    volumeAttributes:
      storage.kubernetes.io/csiProvisionerIdentity: 1615534731524-8081-pd.
➥ csi.storage.gke.io
    volumeHandle: projects/gke-autopilot-test/zones/us-west1-b/disks/pvc-ecb
➥ 0c9ed-9aee-44b2-a1e5-ff70d9d3823a     ❷
  persistentVolumeReclaimPolicy: Delete
  storageClassName: standard-rwo
  volumeMode: Filesystem
status:
  phase: Bound                           ❸

❶ 当前PersistentVolume绑定的PersistentVolumeClaim

❷ 指向底层磁盘资源的指针

❸ 状态现在是已绑定。

将这些并排放置有助于可视化,所以请查看图 9.4。

09-04

图 9.4 在PersistentVolume配置后,PersistentVolumeClaimPersistentVolume的绑定情况

PersistentVolumeClaim 在这里经历了真正的蜕变,从对资源的请求变成了对特定磁盘资源的声明,该资源将包含你的数据。这与其他我能想到的 Kubernetes 对象真的不太一样。虽然通常 Kubernetes 会在对象上添加字段并执行操作,但像这样的变化很少,它从一个通用的存储请求和表示开始,最终变成了一个绑定状态对象。

对于 PersistentVolumeClaim 的典型生命周期有一个例外,那就是当你有现有数据希望挂载到 Pod 中时。在这种情况下,你创建 PersistentVolumeClaim 和 PersistentVolume 对象,它们已经相互指向,因此它们在创建时立即绑定。这种情况在 9.3 节中讨论了迁移和恢复磁盘,包括一个完整的数据恢复场景。

本地测试 MariaDB Pod

想要连接到 MariaDB 并检查一切是否设置正确?很简单。只需将 mariadb 容器的端口转发到你的机器:

kubectl port-forward pod/mariadb-demo 3306:3306

然后,从本地 mysql 客户端连接到它。没有客户端?你可以通过 Docker 运行一个!

docker run --net=host -it --rm mariadb mariadb -h localhost -P 3306 \
-u root -p

数据库密码可以在 Pod 的环境变量中找到(见 9.3 列表)。一旦连接,你可以运行一个 SQL 查询来测试它,例如:

MariaDB [(none)]> SELECT user, host FROM mysql.user;
+-------------+-----------+
| User        | Host      |
+-------------+-----------+
| root        | %         |
| healthcheck | 127.0.0.1 |
| healthcheck | ::1       |
| healthcheck | localhost |
| mariadb.sys | localhost |
| root        | localhost |
+-------------+-----------+
6 rows in set (0.005 sec)

MariaDB [(none)]> CREATE DATABASE foo;
Query OK, 1 row affected (0.006 sec)

MariaDB [(none)]> exit
Bye

9.1.3 存储类

到目前为止,我们一直依赖于平台提供者的默认动态预配行为。但如果我们想在绑定过程中更改我们想要的磁盘类型怎么办?或者,如果删除 PersistentVolumeClaim,数据会怎样?这就是存储类发挥作用的地方。

存储类是一种描述可以从 PersistentVolumeClaims 请求的不同类型的动态存储的方式,以及以这种方式请求的卷应该如何配置。你的 Kubernetes 集群可能已经定义了一些。让我们用kubectl get storageclass来查看它们(为了可读性,输出中已删除一些列):

$ kubectl get storageclass
NAME                     PROVISIONER             RECLAIMPOLICY
premium-rwo              pd.csi.storage.gke.io   Delete       
standard                 kubernetes.io/gce-pd    Delete       
standard-rwo (default)   pd.csi.storage.gke.io   Delete       

在上一节中创建 Pod 并使用 PersistentVolumeClaim 时,使用了默认的存储类(在本例中为standard-rwo)。如果你回到之前查看绑定的 PersistentVolumeClaim 对象,你会在storageClassName配置下看到这个存储类。

这是一个很好的开始,你可能不需要做太多改变,但有一个方面可能值得审查。如果你阅读了之前kubectl get storageclass命令输出的RECLAIMPOLICY列,你可能注意到它表示Delete。这意味着如果 PVC 被删除,绑定的 PV 及其背后的磁盘资源也将被删除。如果你的有状态工作负载主要是缓存服务,存储非关键数据,这可能没问题。然而,如果你的工作负载存储的是独特且宝贵的数据,这种默认行为并不理想。

Kubernetes 还提供了一个Retain回收策略,这意味着在删除 PVC 时,底层磁盘资源不会被删除。这允许你保留磁盘,并将其绑定到一个新的 PV 和 PVC 上,甚至可能是你在一个完全独立的集群中创建的(你可能会在迁移工作负载时这样做)。Retain的缺点,以及为什么它通常不是默认设置,是你需要手动删除你不想保留的磁盘,这对测试和开发,或者具有临时数据(如缓存)的工作负载来说并不理想。

要构建我们自己的 StorageClass,最简单的方法是从现有的一个开始,将其用作模板,例如当前的默认设置。我们可以按照以下方式导出前面列出的默认 StorageClass。如果你的 StorageClass 名称与我的不同,将standard-rwo替换为你想要修改的存储类:

kubectl get -o yaml storageclass standard-rwo > storageclass.yaml

现在我们可以自定义并设置至关重要的Retain回收策略。由于我们想要创建一个新的策略,因此给它一个新的名称并删除uid和其他不需要的元数据字段也很重要。完成这些步骤后,我得到了以下列表。

列表 9.4 第九章/9.1.3_StorageClass/storageclass.yaml

apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  annotations:
    storageclass.kubernetes.io/is-default-class: "true"  ❶
  name: example-default-rwo
parameters:
  type: pd-balanced                                      ❷
provisioner: pd.csi.storage.gke.io
reclaimPolicy: Retain                                    ❸
volumeBindingMode: WaitForFirstConsumer
allowVolumeExpansion: true

❶ 设置为默认的可选注释

❷ 设置存储类型的平台特定值

❸ 配置为在删除 PV 时保留磁盘

你可以直接在包含storageClassName字段的任何 PersistentVolumeClaim 对象或模板中引用你的新 StorageClass。这是一个很好的选择,比如说,如果你只想为少数几个工作负载使用保留回收策略。

可选地,你可以通过添加列表 9.4 中显示的is-default-class注释来设置一个新的默认存储类。如果你想更改默认设置,你需要将当前默认设置标记为非默认。你可以使用kubectl edit storageclass standard-rwo编辑它,或者使用以下单行命令修补它。再次提醒,将standard-rwo替换为你默认类的名称:

kubectl patch storageclass standard-rwo -p '{"metadata": {"annotations":
➥ {"storageclass.kubernetes.io/is-default-class":"false"}}}'

准备就绪后,使用kubectl create -f storageclass.yaml创建新的存储类。如果你更改了默认设置,任何新创建的 PersistentVolume 都将使用你的新 StorageClass。

通常会有多个存储类,具有不同的性能和保留特性,为不同类型的数据定义。例如,你可能有一个数据库的临界生产数据,它需要快速存储并保留,缓存数据可以从高性能中受益但可以被删除,以及用于批处理的临时存储,可以使用平均性能的磁盘且不需要保留。根据你的偏好选择一个好的默认设置,并通过在 PersistentVolumeClaim 中指定storageClassName手动引用其他存储类。

9.1.4 单 Pod 有状态工作负载部署

利用 PersistentVolumeClaims,我们可以通过将我们的 Pod 包裹在一个 Deployment 中来简单地部署一个单副本有状态的工作负载。即使对于单个副本的 Pod,使用 Deployment 的好处是,如果 Pod 被终止,它将被重新创建。

列表 9.5 第 09/9.1.4_Deployment_MariaDB/mariadb-deploy.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: mariadb-demo
spec:
  replicas: 1
  selector:
    matchLabels:
      app: mariadb
  strategy:            ❶
    type: Recreate     ❶
  template:            ❷
    metadata:
      labels:
        app: mariadb
    spec: 
      containers:
      - name: mariadb-container
        image: mariadb:latest
        volumeMounts:
        - mountPath: /var/lib/mysql
          name: mariadb-volume
        resources:
          requests:
            cpu: 1
            memory: 4Gi
        env:
        - name: MARIADB_ROOT_PASSWORD
          value: "your database password"
      volumes:
      - name: mariadb-volume
        persistentVolumeClaim:
          claimName: mariadb-pvc
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: mariadb-pvc
spec:
  accessModes:
  - ReadWriteOnce
  resources:
    requests:
      storage: 2Gi

❶ 重新创建用于防止在滚动部署期间多个副本尝试挂载同一卷的策略。

❷ Pod 模板规范与第 9.1.2 节中显示的规范相同。

因此,这就是我们得到的结果。这是一个 MariaDB 数据库的单 Pod 部署,它附加了一个磁盘,即使整个 Kubernetes 集群被删除,这个磁盘也不会被删除,这要归功于我们在上一节中创建的默认存储类中的 Retain 策略。

如果您想尝试这个数据库,为它创建一个 Service(第 09/9.1.4_ Deployment_MariaDB/service.yaml 章节)。一旦 Service 创建成功,您就可以从本地客户端连接到数据库(参见侧边栏“在本地测试 MariaDB Pod”),或者您可以尝试容器化的 phpMyAdmin(参见伴随本书的代码仓库中的 Bonus/phpMyAdmin 文件夹)。

在 Kubernetes 中运行数据库

在您决定在 Kubernetes 中管理自己的 MariaDB 数据库之前,您可能希望寻找云提供商提供的托管解决方案。我知道直接在 Kubernetes 中部署很有吸引力,因为创建这样的数据库相对容易,正如我演示的那样。然而,运营成本会在您必须对其进行安全、更新和管理时出现。通常,我建议将 Kubernetes 的有状态工作负载功能保留用于定制或定制的服务,或者您的云提供商不作为托管服务提供的那些服务。

如本节所示,我们可以通过使用 PersistentVolumeClaims 挂载卷来使我们的工作负载具有状态。然而,使用 Pod 和 Deployment 对象进行此操作限制了我们对单副本有状态工作负载的限制。这可能对一些人来说已经足够了,但如果你有一个具有多个副本的复杂有状态工作负载,如 Elasticsearch 或 Redis,你会怎么办?你可以尝试将多个 Deployment 连接起来,但幸运的是,Kubernetes 有一个高级构建块,专门用于表示这种类型的工作负载,称为 StatefulSet。

9.2 StatefulSet

我们已经看到如何在 Kubernetes 中的 Pod 中添加持久存储——这是一个有用的功能,因为 Pod 是 Kubernetes 中的基本构建块,并且它们被用于许多不同的工作负载结构中,如 Deployments(第三章)和 Jobs(第十章)。现在,您可以为它们中的任何一个添加持久存储,并在需要的地方构建有状态 Pod,只要所有实例的卷规范相同即可。

工作负载构造(如 Deployment)的限制是所有 Pod 共享相同的规范,这为具有ReadWriteOnce访问方法的传统卷创建问题,因为它们只能由单个实例挂载。当你的 Deployment 中只有一个副本时,这是可以接受的,但如果你创建第二个副本,那么该 Pod 将无法创建,因为卷已经被挂载。

幸运的是,Kubernetes 有一个高级工作负载构造,当我们需要多个 Pod 且每个 Pod 都有自己的磁盘时(这是一个高度常见的模式),它使我们的生活变得更简单。就像 Deployment 是一个高级构造,用于管理持续运行的服务(通常是无状态的)一样,StatefulSet 是为管理有状态服务提供的构造。

StatefulSet 为构建此类服务提供了一些有用的属性。您可以在 PodSpec 中定义一个卷模板,而不是引用单个卷,Kubernetes 将为每个 Pod 创建一个新的 PersistentVolumeClaim (PVC)(从而解决了使用 Deployment 与卷的问题,其中每个实例都获得了完全相同的 PVC)。StatefulSet 为每个 Pod 分配一个稳定的标识符,它与特定的 PVC 相关联,并在创建、扩展和更新期间提供排序保证。使用 StatefulSet,您可以通过使用此稳定的标识符来协调多个 Pod,并可能为每个 Pod 分配不同的角色。

9.2.1 部署 StatefulSet

将其付诸实践,让我们看看两个流行的有状态工作负载——MariaDB 和 Redis——以及如何将它们作为 StatefulSet 部署。一开始,我们将保持单个 Pod 的 StatefulSet,这是在不涉及多个角色的情况下最容易演示的。下一节将添加具有不同角色的额外副本,以充分利用 StatefulSet 的功能。

MariaDB

首先,让我们将上一节中创建的单 Pod MariaDB Deployment 转换为使用 StatefulSet,并利用 PVC 模板来避免我们需要自己创建单独的PVC对象。

列表 9.6 Chapter09/9.2.1_StatefulSet_MariaDB/mariadb-statefulset.yaml

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: mariadb
spec:
  selector:                               ❶
    matchLabels:                          ❶
      app: mariadb-sts                    ❶
serviceName: mariadb-service              ❷
  replicas: 1
  template:
    metadata:
      labels:
        app: mariadb-sts
    spec:
      terminationGracePeriodSeconds: 10   ❸
      containers:
      - name: mariadb-container
        image: mariadb:latest
        volumeMounts:
        - name: mariadb-pvc               ❹
          mountPath: /var/lib/mysql
        resources:
          requests:
            cpu: 1
            memory: 4Gi
        env:
        - name: MARIADB_ROOT_PASSWORD
          value: "your database password"
  volumeClaimTemplates:                   ❺
  - metadata:                             ❺
      name: mariadb-pvc                   ❺
    spec:                                 ❺
      accessModes:                        ❺
      - ReadWriteOnce                     ❺
      resources:                          ❺
        requests:                         ❺
          storage: 2Gi                    ❺
---
apiVersion: v1                            ❻
kind: Service                             ❻
metadata:                                 ❻
  name: mariadb-service                   ❻
spec:                                     ❻
  ports:                                  ❻
  - port: 3306                            ❻
  clusterIP: None                         ❻
  selector:                               ❻
    app: mariadb-sts                      ❻

❶ StatefulSet 使用与 Deployments 相同的匹配标签模式,这在第三章中已有讨论。

❷ 这是对无头服务的引用,该服务在文件底部定义。

❸ StatefulSet 要求设置一个优雅的终止期。这是 Pod 在终止前必须自行退出的秒数。

❹ 现在在 volumeClaimTemplates 部分定义的 MariaDB 数据卷挂载

❺ 使用 StatefulSet,我们可以定义一个 PersistentVolumeClaim 的模板,就像我们定义 Pod 副本的模板一样。此模板用于创建 PersistentVolumeClaims,将每个 Pod 副本与一个关联。

❻ 为此 StatefulSet 的无头服务

这个 StatefulSet 规范与上一节中相同 MariaDB Pod 的 Deployment 规范有何不同?除了不同的对象元数据外,有两个关键变化。第一个区别是 PersistentVolumeClaim 的配置方式。在上一节中使用时,它被定义为独立的对象。在 StatefulSet 中,这被整合到定义本身中,在volumeClaimTemplates下,就像 Deployment 有一个 Pod 模板一样。在每个 Pod 中,StatefulSet 将根据此模板创建一个 PersistentVolumeClaim。对于单 Pod StatefulSet,你最终得到类似的结果(但不需要定义单独的 PersistentVolumeClaim 对象),这在创建多个副本的 StatefulSet 时变得至关重要。图 9.5 显示了在 Deployment 中使用的 PersistentVolumeClaimvolumeClaimTemplates在 StatefulSet 中使用)并并排展示。

09-05

图 9.5 PersistentVolumeClaim 与volumeClaimTemplates

在创建 StatefulSet 之后查询 PVC,你会看到其中一个是用此模板创建的(为了可读性移除了一些列):

$ kubectl get pvc
NAME                    STATUS   VOLUME     CAPACITY   ACCESS MODES
mariadb-pvc-mariadb-0   Bound    pvc-71b1e  2Gi        RWO         

主要区别在于,使用模板创建的 PVC 附加了 Pod 名称(在第一个 Pod 的情况下为mariadb-0)。因此,它不再是mariadb-pvc(索赔模板的名称),而是mariadb-pvc-mariadb-0(索赔模板名称和 Pod 名称的组合)。

与 Deployment 相比的另一个区别是,在 StatefulSet 中通过serviceName: mariadb-service行引用了服务,并定义如下:

apiVersion: v1
kind: Service
metadata:
mariadb-service 
spec:
  ports:
  - port: 3306
  clusterIP: None
  selector:
    app: mariadb-sts

此服务与书中迄今为止所介绍的服务略有不同,因为它被称为无头服务(在规范中的clusterIP: None表示)。与其他服务类型不同,不会创建虚拟集群 IP 来在 Pods 之间平衡流量。如果你查询此服务的 DNS 记录(例如,通过进入 Pod 并运行host mariadb-service),你会注意到它仍然返回一个A记录。这个记录实际上是 Pod 本身的 IP 地址,而不是虚拟集群 IP。对于具有多个 Pod 的无头服务(如 Redis StatefulSet;参见下一节),查询服务将返回多个A记录(即每个 Pod 一个)。

无头 Service 的另一个有用属性是 StatefulSet 中的 Pods 获得它们自己的稳定网络标识。由于 StatefulSet 中的每个 Pod 都是唯一的,并且每个都附加了自己的卷,因此能够单独地访问它们是有用的。这与 Deployment 中的 Pods 不同,Deployment 中的 Pods 被设计成是相同的,因此对于任何给定的请求,连接到哪一个并不重要。为了便于直接连接到 StatefulSet 中的 Pods,每个都分配了一个递增的整数值,称为序号(0、1、2 等等)。如果 StatefulSet 中的 Pod 在中断后重新创建,它将保留相同的序号,而那些在 Deployment 中被替换的则被分配一个新的随机名称。

可以使用它们的序号通过构造 $STATEFULSET_NAME-$POD_ORDINAL.$SERVICE_NAME. 来访问 StatefulSet 中的 Pods。在这个例子中,我们的单个 Pod 可以使用 DNS 地址 mariadb-0.mariadb-service 来引用。从命名空间外部,你可以附加命名空间(就像任何 Service 一样)。例如,对于名为 production 的命名空间,Pod 可以通过 mariadb-0-mariadb-service.production.svc 来访问。

要尝试运行在 StatefulSet 中的这个 MariaDB 实例,我们可以转发端口并使用 kubectl port-forward sts/mariadb 3306:3306 在本地连接,但为了更有趣,让我们在集群中运行一个临时的 Pod 来创建 MariaDB 客户端,并使用服务主机名进行连接。

kubectl run my -it --rm --restart=Never --pod-running-timeout=3m \
  --image mariadb -- mariadb -h mariadb-0.mariadb-service -P 3306 -u root -p

这在集群中创建了一个运行 MariaDB 客户端的 Pod,该客户端配置为连接到我们的 StatefulSet 中的主 Pod。它是临时的,一旦你退出交互会话,它就会被删除,这使得它成为在集群内部执行一次性调试的便捷方式。当 Pod 准备就绪时,在列表 9.6 中输入 MARIADB_ROOT_PASSWORD 环境变量中找到的数据库密码,你现在可以执行数据库命令。当你完成时,输入 exit 结束会话。

Redis

另一个我们可以使用的例子是 Redis。Redis 是 Kubernetes 中非常流行的负载部署,有许多不同的可能用途,通常包括缓存和其他实时数据存储和检索需求。对于这个例子,让我们想象一个数据不是特别珍贵的缓存用例。你仍然希望将数据持久化到磁盘,以避免在重启时重建缓存,但不需要进行备份。以下是为此类应用程序提供的完全可用的单个 Pod Redis 设置,适用于 Kubernetes。

要配置 Redis,我们首先定义我们的配置文件,我们可以将其挂载为容器中的卷。

列表 9.7 第九章/9.2.1_StatefulSet_Redis/redis-configmap.yaml

apiVersion: v1
kind: ConfigMap
metadata:
  name: redis-config
data:
  redis.conf: |
    bind 0.0.0.0        ❶
    port 6379           ❷
    protected-mode no   ❸
    appendonly yes      ❹
    dir /redis/data     ❺

❶ 绑定到所有接口,以便其他 Pods 可以连接

❷ 要使用的端口

❸ 禁用保护模式,以便集群中的其他 Pods 可以无密码连接

❹ 启用将日志附加到磁盘以持久化数据

❺ 指定数据目录

这个配置的关键是我们将 Redis 状态持久化到/redis/data目录,这样如果 Pod 被重新创建,它就可以被重新加载,接下来我们需要配置卷以挂载到该目录。

这个例子没有为 Redis 配置身份验证,这意味着集群中的每个 Pod 都将具有读写访问权限。如果你将此示例用于生产集群,请考虑你希望如何配置集群。

现在让我们继续创建一个将引用此配置并将/redis/data目录作为持久卷挂载的有状态副本集。

列表 9.8 Chapter09/9.2.1_StatefulSet_Redis/redis-statefulset.yaml

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: redis
spec:
  selector:
    matchLabels:
      app: redis-sts
  serviceName: redis-service
  replicas: 1                              ❶
  template:
    metadata:
      labels:
        app: redis-sts
    spec:
      terminationGracePeriodSeconds: 10
      containers:
      - name: redis-container
        image: redis:latest
        command: ["redis-server"]
        args: ["/redis/conf/redis.conf"]
        volumeMounts:
        - name: redis-configmap-volume     ❷
          mountPath: /redis/conf/          ❷
        - name: redis-pvc                  ❸
          mountPath: /redis/data           ❸
        resources:
          requests:
            cpu: 1
            memory: 4Gi
      volumes:
      - name: redis-configmap-volume
        configMap:                         ❹
          name: redis-config               ❹
  volumeClaimTemplates:
  - metadata:
      name: redis-pvc
    spec:
      accessModes: [ "ReadWriteOnce" ]
      resources:
        requests:
          storage: 1Gi
---
apiVersion: v1
kind: Service
metadata:
  name: redis-service
spec:
  ports:
  - port: 6379
  clusterIP: None
  selector:
    app: redis-sts

❶ 1 个副本,因为这是一个单角色有状态副本集

❷ 将列表 9.7 中的配置文件挂载到容器中的目录。

❸ 在 volumeClaimTemplates 部分定义的 Redis 数据卷挂载

❹ 引用了列表 9.7 中定义的 ConfigMap 对象

与 MariaDB 有状态副本集相比,除了应用特定的差异(如使用的不同端口、容器镜像以及将配置映射挂载到/redis/conf)之外,设置是相似的。

在创建 Chapter09/9.2.1_StatefulSet_Redis 中的资源后,为了连接到 Redis 并验证其是否正常工作,你可以将端口转发到你的本地机器,并使用 redis-cli 工具连接,如下所示:

$ kubectl port-forward pod/redis-0 6379:6379
Forwarding from 127.0.0.1:6379 -> 6379

$ docker run --net=host -it --rm redis redis-cli
27.0.0.1:6379> INFO
# Server
redis_version:7.2.1
127.0.0.1:6379> exit

这就是单个副本有状态副本集的两个例子。即使只有一个副本,使用 Deployment 来处理这种工作负载也更加方便,因为 Kubernetes 可以自动处理创建持久卷声明。

如果你删除了有状态副本集对象,持久卷声明对象将保留。如果你随后重新创建有状态副本集,它将重新附加到相同的持久卷声明,因此不会丢失任何数据。不过,根据存储类配置,删除持久卷声明对象本身可能会删除底层数据。如果你关心存储的数据(例如,不仅仅是可以重新创建的缓存),请确保遵循第 9.1.3 节中的步骤来设置一个存储类,以便在持久卷声明对象因任何原因被删除时保留底层云资源。

如果我们增加这个有状态副本集的副本数量,它将为我们提供带有自己存储卷的新 Pod,但这并不意味着它们会自动相互通信。对于这里定义的 Redis 有状态副本集,增加副本数量只会给我们更多的单个 Redis 实例。下一节将详细介绍如何在单个有状态副本集中设置多 Pod 架构,其中每个独特的 Pod 根据 Pod 的序号配置不同,并相互连接。

9.2.2 部署多角色有状态副本集

当你需要多个 Pod 时,有状态集的真正威力才显现出来。在设计将使用有状态集的应用程序时,有状态集内的 Pod 副本需要相互了解并作为有状态应用程序设计的一部分进行通信。这是使用有状态集类型的优势,因为每个 Pod 都在称为序号的集合中获得一个唯一的标识符。你可以使用这种唯一性和保证的顺序来为集合中的不同唯一 Pod 分配不同的角色,并通过更新甚至删除和重新创建来关联相同的持久磁盘。

在此示例中,我们将从上一节中的单个 Pod Redis 有状态集转换为三个 Pod 的设置,通过引入副本角色。Redis 使用主/从复制策略,由一个主 Pod(在第 9.2.1 节中,这是唯一的 Pod)和具有副本角色的额外 Pods 组成(不要与 Kubernetes 中的“副本”混淆,它指的是有状态集或部署中的所有 Pods)。

在上一节示例的基础上,我们将保持主 Pod 的相同 Redis 配置,并为副本添加一个额外的配置文件,该文件包含对主 Pod 地址的引用。列表 9.9 是定义这两个配置文件的 ConfigMap。

列表 9.9 第九章/9.2.2_ 有状态集 _Redis_Multi/redis-configmap.yaml

apiVersion: v1
kind: ConfigMap
metadata:
  name: redis-role-config
data:
  primary.conf: |                          ❶
    bind 0.0.0.0
    port 6379
    protected-mode no
    appendonly yes
    dir /redis/data
  replica.conf: |                          ❷
 replicaof redis-0.redis-service 6379 ❸
    bind 0.0.0.0
    port 6379
    protected-mode no
    appendonly yes
    dir /redis/data

❶ 配置映射中的第一个文件,用于配置主角色

❷ 配置映射中的第二个文件,用于配置副本角色

❸ 配置 Redis 副本来通过其名称引用主 Pod

ConfigMaps 只是我们定义两个配置文件的便捷方式,每个角色一个。我们同样可以使用 Redis 基础镜像构建自己的容器,并将这两个文件放入其中。但由于我们只需要这种唯一定制,所以在这里定义它们并将其挂载到我们的容器中会更简单。

接下来,我们将更新有状态集工作负载以使用 init 容器(即在 Pod 初始化期间运行的容器)来设置每个 Pod 副本的角色。在这个 init 容器中运行的脚本查找正在初始化的 Pod 的序号以确定其角色,并复制该角色的相关配置——回想一下,有状态集的一个特殊功能是每个 Pod 都分配了一个唯一的序号。我们可以使用 0 的序数值来指定主角色,而将剩余的 Pod 分配给副本角色。

这种技术可以应用于具有多个角色的各种不同有状态工作负载。如果你在寻找 MariaDB,Kubernetes 文档中提供了一个非常好的指南²。

列表 9.10 第九章/9.2.2_ 有状态集 _Redis_Multi/redis-statefulset.yaml

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: redis
spec:
  selector:
    matchLabels:
      app: redis-sts
  serviceName: redis-service
  replicas: 3                                 ❶
  template:
    metadata:
      labels:
        app: redis-sts
    spec:
      terminationGracePeriodSeconds: 10
      initContainers:
      - name: init-redis                      ❷
        image: redis:latest
        command:                              ❸
        - bash                                ❸
        - "-c"                                ❸
        - |
          set -ex
          # Generate server-id from Pod ordinal index.
          [[ `hostname` =~ -([0-9]+)$ ]] || exit 1
          ordinal=${BASH_REMATCH[1]}
          echo "ordinal ${ordinal}"
          # Copy appropriate config files from config-map to emptyDir.
          mkdir -p /redis/conf/
          if [[ $ordinal -eq 0 ]]; then
            cp /mnt/redis-configmap/primary.conf /redis/conf/redis.conf
          else
            cp /mnt/redis-configmap/replica.conf /redis/conf/redis.conf
          fi
          cat /redis/conf/redis.conf
        volumeMounts:
        - name: redis-config-volume          ❹
          mountPath: /redis/conf/            ❹
        - name: redis-configmap-volume       ❺
          mountPath: /mnt/redis-configmap    ❺
      containers:
      - name: redis-container                ❻
        image: redis:latest
        command: ["redis-server"]
        args: ["/redis/conf/redis.conf"]
        volumeMounts:
        - name: redis-config-volume          ❼
          mountPath: /redis/conf/            ❼
        - name: redis-pvc                    ❽
          mountPath: /redis/data             ❽
        resources:
          requests:
            cpu: 1
            memory: 4Gi
      volumes:
      - name: redis-configmap-volume
        configMap:                           ❾
          name: redis-role-config            ❾
      - name: redis-config-volume            ❿
        emptyDir: {}                         ❿
  volumeClaimTemplates:
  - metadata:
      name: redis-pvc
    spec:
      accessModes: [ "ReadWriteOnce" ]
      resources:
        requests:
          storage: 1Gi
---
apiVersion: v1
kind: Service
metadata:
  name: redis-service
spec:
  ports:
  - port: 6379
  clusterIP: None
  selector:
    app: redis-sts

❶ 多角色有状态集的 3 个副本

❷ 在启动时运行一次的初始化容器,用于将配置文件从 ConfigMap 挂载复制到 emptyDir 挂载

❸ 运行以下脚本。

❹ 与主容器共享的 emptyDir 挂载

❺ 使用列表 9.9 中的 2 个文件配置 ConfigMap 挂载

❻ 主 Redis 容器

❼ 与 init 容器共享的 emptyDir 挂载

❽ 在 volumeClaimTemplates 部分定义的 Redis 数据卷挂载

❾ 引用列表 9.9 中定义的 ConfigMap 对象

❿ 定义 emptyDir 卷

这里有一些需要解释的内容,让我们仔细看看。与我们的单实例 Redis StatefulSet 的主要区别是存在一个init容器。正如其名称所暗示的,这个init容器在 Pod 的初始化阶段运行。它挂载两个卷,ConfigMap 和一个新的卷redis-config-volume

        volumeMounts:
        - name: redis-config-volume
          mountPath: /redis/conf/
        - name: redis-configmap-volume
          mountPath: /mnt/redis-configmap

redis-config-volume类型为emptyDir,允许容器之间共享数据,但如果 Pod 被重新调度(与 PersistentVolume 不同),则不会持久化数据。我们只使用这个emptyDir卷来存储配置的副本,这对于它是理想的。init容器运行一个包含在 YAML 中的 bash 脚本:

        command:
        - bash
        - "-c"
        - |
          set -ex
          # Generate server-id from Pod ordinal index.
          [[ `hostname` =~ -([0-9]+)$ ]] || exit 1
          ordinal=${BASH_REMATCH[1]}
          # Copy appropriate config files from config-map to emptyDir.
          mkdir -p /redis/conf/
          if [[ $ordinal -eq 0 ]]; then
            cp /mnt/redis-configmap/primary.conf /redis/conf/redis.conf
          else
            cp /mnt/redis-configmap/replica.conf /redis/conf/redis.conf
          fi

此脚本将根据 Pod 的序号从ConfigMap卷(挂载在/mnt/redis-configmap)复制两个不同的配置之一到这个共享的emptyDir卷(挂载在/redis/conf)。也就是说,如果 Pod 是redis-0,则复制primary.conf文件;对于其余的,复制replica.conf

主容器随后在/redis/conf上挂载相同的redis-config-volume emptyDir卷。当 Redis 进程启动时,它将使用位于/redis/conf/redis.conf的任何配置。

要尝试它,您可以使用端口转发/本地客户端组合连接到主 Pod,或者按照前几节中所述创建一个短暂的 Pod。我们还可以通过 exec 直接连接,快速写入一些数据,如下所示:

$ kubectl exec -it redis-0 -- redis-cli
127.0.0.1:6379> SET capital:australia "Canberra"
OK
127.0.0.1:6379> exit

然后连接到一个副本并读取它:

$ kubectl exec -it redis-1 -- redis-cli
Defaulted container "redis-container" out of: redis-container, init-redis (init)
127.0.0.1:6379> GET capital:australia
"Canberra"

副本为只读,因此您无法直接写入数据:

127.0.0.1:6379> SET capital:usa "Washington"
(error) READONLY You can't write against a read only replica.
127.0.0.1:6379> exit

9.3 迁移/恢复磁盘

现在我知道你在想什么:我真的可以信任 Kubernetes 来处理我宝贵的数据吗?这里有点太神奇了;如果 Kubernetes 集群消失了,我怎么能确信我的数据是安全且可恢复的?

建立一些信心的时候到了。让我们在 Kubernetes 中创建一个有状态的工作负载。然后,我们将完全删除与之相关的所有 Kubernetes 对象,并尝试从头开始重新创建该工作负载,将其重新链接到底层云磁盘资源。

有一个事情需要非常注意,那就是通常情况下,默认情况下,Kubernetes 创建的磁盘资源如果删除了相关的绑定 PersistentVolumeClaim,就会被删除,因为它们配置了reclaimPolicy设置为Delete。删除 StatefulSet 本身不会删除相关的 PersistentVolumeClaim 对象,这很有用,因为它迫使你在不再需要数据时手动删除这些对象,但删除 PersistentVolumeClaim 对象将会删除底层的磁盘资源,而且这并不难做到(例如,通过将--all传递给相关的kubectl delete命令)。

因此,如果你重视你的数据,首要任务是确保创建用于你宝贵数据的磁盘时使用的 StorageClass 的reclaimPolicy设置为Retain,而不是Delete。这样,当 Kubernetes 对象被删除时,将保留底层云盘,允许你手动在相同或不同的集群中重新创建 PersistentVolumeClaim-PersistentVolume 配对(我将演示)。

要运行这个实验,请从第 9.2.2 节部署 Redis 示例,使用默认存储类或显式配置为保留数据的存储类。为了验证创建 PersistentVolumes 后的状态,使用kubectl get pv来检查,如果需要,使用kubectl edit pv $PV_NAME来修改persistentVolumeReclaimPolicy字段。

在我们的 reclaim 策略设置正确后,我们现在可以添加一些数据,以验证我们在删除 Kubernetes StatefulSet 后恢复其能力。要添加数据,首先进入主 Pod 并运行redis-cli工具。你可以使用以下命令完成这两步:

kubectl exec -it redis-0 -- redis-cli

连接后,我们可以添加一些数据。如果你之前没有使用过 Redis,不用担心这个问题——我们只是添加一些琐碎的数据来证明我们可以恢复它。这个示例数据是一些世界首都的键值对:

127.0.0.1:6379> SET capital:australia "Canberra"
OK
127.0.0.1:6379> SET capital:usa "Washington"
OK

如果你愿意,此时你可以删除 StatefulSet 并重新创建它。然后,回到 CLI 并测试数据。以下是方法:

$ cd Chapter09/9.2.2_StatefulSet_Redis_Multi/
$ kubectl delete -f redis-statefulset.yaml
service "redis-service" deleted
statefulset.apps "redis" deleted

$ kubectl create -f redis-statefulset.yaml 
service/redis-service created
statefulset.apps/redis created

$ kubectl exec -it redis-0 -- redis-cli
127.0.0.1:6379> GET capital:usa
"Washington"

这之所以有效(即数据被持久化),是因为当 StatefulSet 重新创建时,它引用了包含 Redis 启动时加载数据的相同 PersistentVolumeClaim,因此 Redis 可以从上次停止的地方继续运行。

到目前为止一切顺利。现在让我们采取一个更激进的步骤,删除 PVC 和 VC,并尝试重新创建。如果你喜欢,可以在一个全新的集群中重新创建,以模拟整个集群被删除的情况。只需确保使用相同的云区域,以便可以访问磁盘。

在我们删除这些对象之前,让我们保存它们的配置。这并不是绝对必要的;你当然可以在需要时从头开始重新创建它们,但这将有助于节省时间。使用以下命令列出并保存对象(输出已截断以提高可读性):

$ kubectl get pvc,pv
NAME                                      STATUS   VOLUME      
persistentvolumeclaim/redis-pvc-redis-0   Bound    pvc-64b52138
persistentvolumeclaim/redis-pvc-redis-1   Bound    pvc-4530141b
persistentvolumeclaim/redis-pvc-redis-2   Bound    pvc-5bbf6729

NAME                            STATUS   CLAIM                    
persistentvolume/pvc-4530141b   Bound    default/redis-pvc-redis-1
persistentvolume/pvc-5bbf6729   Bound    default/redis-pvc-redis-2
persistentvolume/pvc-64b52138   Bound    default/redis-pvc-redis-0

$ kubectl get -o yaml persistentvolumeclaim/redis-pvc-redis-0 > pvc.yaml
$ PV_NAME=pvc-64b52138
$ kubectl get -o yaml persistentvolume/$PV_NAME > pv.yaml

现在,让我们采取一个更激进的步骤:删除命名空间中的所有 StatefulSets、PVCs 和 PVs。

kubectl delete statefulset,pvc,pv --all

警告:仅在测试集群中运行该注释!它将删除命名空间中该类型的所有对象,而不仅仅是示例 Redis 部署。

由于StorageClass上的Retain策略(希望您按照说明使用了具有Retain的存储类!),云磁盘资源仍然存在。现在的问题只是手动创建一个 PV 来链接到该磁盘,以及一个 PVC 来链接到该磁盘。

这里是我们所知道的信息(图 9.6):

  • 我们知道(或可以找出)底层磁盘资源的名称来自我们的云提供商

  • 我们知道 StatefulSet 将要消耗的 PVC 的名称(redis-pvc-redis-0

09-06

图 9.6 已知值和我们需要重新创建的对象

因此,我们需要创建一个名为redis-pvc-redis-0的 PVC,并将其绑定到一个引用磁盘的 PV 上。重要的是,PVC 需要命名 PV,PV 需要定义绑定的 PVC;否则,PVC 可能会绑定到不同的 PV,PV 也可能被不同的 PVC 绑定。

使用kubectl create -f pv.yamlkubectl create -f pvc.yaml直接从我们的保存配置创建对象不起作用。该配置还导出了绑定的状态,它使用在从配置删除和创建对象时不会传递的唯一标识符。如果您不修改地创建这些对象,您会看到 PVC 的StatusLost,PV 的状态是Released——这不是我们想要的。

为了解决这个问题,我们只需要从保存的配置中移除绑定状态和uid

  1. 编辑 PV(我们导出到pv.yaml的配置)并做出两个更改:

    1. claimRef部分移除uid字段(claimRef是指向 PVC 的指针;问题是 PVC 的uid已更改)。

    2. storageClassName设置为空字符串""(我们手动配置,不想使用storageClass)。

  2. 编辑 PVC(我们导出到pvc.yaml的配置)并做出两个更改:

    1. 删除注释pv.kubernetes.io/bind-completed: "yes"(这个 PVC 需要重新绑定,而这个注释将阻止这种情况)。

    2. storageClassName设置为空字符串""(与上一步相同的原因)。

或者,如果您是从头开始重新创建此配置,关键是 PVC 的volumeName需要设置为 PV 的volumeName,PV 的claimRef需要引用 PVC 的名称和命名空间,并且两者都具有storageClassName""

逐行可视化会更直观。图 9.7 是基于我在运行此测试并按照之前概述的文档移除字段时导出的配置。

09-07

图 9.7 预链接的 PVC 和 PV 对象

准备就绪后,您可以在集群中创建这两个配置文件,然后使用kubectl get pvc,pv检查它们的状态。

如果一切顺利,PV 的状态应该显示为Bound,而 PVC 则显示为Pending(在等待 StatefulSet 创建时)。如果其中一个或两个都列为PendingReleased,请返回并检查它们是否与所有必要的信息正确链接,且没有额外的信息。是的,遗憾的是,这确实有些麻烦,但如果底层的云资源仍然存在(由于你在 StorageClass 上使用了Retain策略,对吧?),这些对象是可以重新绑定的。这就是成功的样子(为了可读性移除了一些列):

$ kubectl create -f pv.yaml 
persistentvolume/pvc-f0fea6ae-e229 created

$ kubectl get pv
NAME                RECLAIM POLICY   STATUS      CLAIM                    
pvc-f0fea6ae-e229   Retain           Available   default/redis-pvc-redis-0

$ kubectl create -f pvc.yaml 
persistentvolumeclaim/redis-pvc-redis-0 created

$ kubectl get pv,pvc
NAME                   RECLAIM POLICY   STATUS   CLAIM                    
pv/pvc-f0fea6ae-e229   Retain           Bound    default/redis-pvc-redis-0

NAME                                      STATUS    VOLUME           
persistentvolumeclaim/redis-pvc-redis-0   Pending   pvc-f0fea6ae-e229

一旦你手动创建了 PVC 和 PV 对象,就是时候重新创建 StatefulSet 了。正如我们之前在删除和重新创建 StatefulSet 时测试的那样,只要 PVC 存在且名称符合预期,它就会重新附加到 StatefulSet 的 Pod 上。StatefulSet 创建的 PVC 名称是确定的,因此当我们重新创建 StatefulSet 时,它会看到现有的 PVC 对象并引用它们,而不是创建新的。基本上,一切都应该和之前使用相同名称重新创建这些对象时一样工作。

注意,在这个例子中,尽管 StatefulSet 有三个 PVC,因此有三个相关的磁盘,但我们只手动恢复了其中一个磁盘——即连接到主 Pod 的磁盘。Redis 副本将自动从该源重新创建其数据。当然,你也可以手动重新链接所有三个磁盘。

$ kubectl create -f redis-statefulset.yaml
service/redis-service created
statefulset.apps/redis created

$ kubectl get pv,pvc,pods
NAME                     RECLAIM POLICY   STATUS   CLAIM                    
pv/pvc-f0fea6ae-e229     Retain           Bound    default/redis-pvc-redis-0
pv/pvc-9aabd1b8-ca4f     Retain           Bound    default/redis-pvc-redis-1
pv/pvc-db153655-88c2     Retain           Bound    default/redis-pvc-redis-2

NAME                                      STATUS    VOLUME           
persistentvolumeclaim/redis-pvc-redis-0   Bound     pvc-f0fea6ae-e229
persistentvolumeclaim/redis-pvc-redis-1   Bound     pvc-9aabd1b8-ca4f
persistentvolumeclaim/redis-pvc-redis-2   Bound     pvc-db153655-88c2

NAME          READY      STATUS    RESTARTS         AGE
pod/redis-0   1/1        Running   0                15m
pod/redis-1   1/1        Running   0                13m
pod/redis-2   1/1        Running   0                11m

StatefulSet 创建完成后,所有的 PV 和 PVC 对象都显示为Bound。一旦 StatefulSet 部署完成,让我们进入其中一个副本,看看我们之前创建的数据是否还在那里:

$ kubectl exec -it redis-1 -- redis-cli
127.0.0.1:6379> GET capital:australia
"Canberra"

如果你能够读取之前写入 Redis 的数据,恭喜你!你已经从头恢复了 StatefulSet。同样的技术可以用来将磁盘迁移到新集群中的 StatefulSet。只需遵循这些步骤,但在新集群中创建对象。请注意磁盘的位置,因为通常集群需要位于同一区域。

我希望这一节已经让你对使用Retain策略时数据的持久性有了信心。正如所示,你可以完全删除所有对象(甚至整个集群),并从头开始重新创建所有链接。这确实有些费劲,但却是可行的。为了减少工作量,建议(但不是必需的)导出你的 PVC 和 PV 对象的配置,并将它们存储在你的配置仓库中,以便将来更快地重新创建这些对象。

9.4 通用临时卷用于临时空间

到目前为止,我们已经使用了PersistentVolumes 和 PersistentVolumesClaims来为有状态服务提供支持。那么,当您只需要一个很大的磁盘来进行一些临时计算——例如数据处理任务的临时空间时怎么办?在本章的开头,emptyDir被提及为一种临时空间的选择,但它有一些缺点。具体来说,您需要在节点上预分配存储空间才能使用emptyDir,这需要预先规划节点启动磁盘的大小(而且在没有暴露节点的平台上可能根本不可能做到)。通用临时卷是通过以与持久卷相同的方式挂载附加卷来获取临时空间的一种方法。

当您需要处理大量临时数据时,使用临时卷而不是emptyDir有许多好处。由于与启动磁盘独立,您可以在不进行预先规划的情况下动态分配非常大的空间(例如,在撰写本文时,Google Cloud 支持高达 64 TB)。您还可以挂载多个卷,因此这个限制是每个卷的限制。您还可以访问不同的存储类,并在存储类上配置不同的属性,例如,配置一个比节点自己的启动磁盘性能更高的 SSD 磁盘。以下列表提供了一个示例。

列表 9.11 第九章/9.4_ 临时卷/ephemeralvolume_pod.yaml

apiVersion: v1
kind: Pod
metadata:
  name: ephemeralvolume-pod
  labels:
    pod: timeserver-pod
spec:
  containers:
  - name: timeserver-container
    image: docker.io/wdenniss/timeserver:1
 volumeMounts:
 - mountPath: "/scratch"               ❶
 name: scratch-volume ❶
 volumes:
 - name: scratch-volume ❷
 ephemeral: ❷
 volumeClaimTemplate: ❷
 metadata: ❷
 labels: ❷
 type: scratch-volume ❷
 spec: ❷
 accessModes: [ "ReadWriteOnce" ] ❷
 storageClassName: "ephemeral" ❷
 resources: ❷
 requests: ❷
 storage: 1Ti ❷

❶ 临时卷的挂载点

❷ 定义一个 1TB 的临时卷

当使用通用临时卷时,您想要确保您的存储类已设置 reclaim 策略为Delete。否则,临时存储将被保留,这并不是真正的目的。以下列表提供了一个这样的 StorageClass。

列表 9.12 第九章/9.4_ 临时卷/ephemeral_storageclass.yaml

apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: ephemeral
parameters:
  type: pd-ssd                           ❶
provisioner: pd.csi.storage.gke.io
reclaimPolicy: Delete                    ❷
volumeBindingMode: WaitForFirstConsumer
allowVolumeExpansion: true

❶ 根据临时存储的性能要求设置磁盘类型。

❷ 使用 Delete reclaimPolicy 是因为这是为临时使用而设计的。

将这些放在一起,让我们运行、检查并清理示例。以下命令(从示例根目录运行)显示了创建的 Pod,其中附加了一个 1TB 磁盘,然后删除了它,这清理了所有资源(输出被截断以提高可读性):

$ kubectl create -f Chapter09/9.4_EphemeralVolume
storageclass.storage.k8s.io/ephemeral created
pod/ephemeralvolume-pod created

$ kubectl get pod,pvc,pv
NAME                      READY   STATUS    RESTARTS   AGE
pod/ephemeralvolume-pod   1/1     Running   0          34s

NAME                STATUS   VOLUME    CAPACITY   ACCESS MODES   STORAGECLASS
pvc/scratch-volume  Bound    pvc-a5a2  1Ti        RWO            ephemeral

NAME          CAPACITY   RECLAIM POLICY   STATUS   CLAIM
pv/pvc-a5a2   1Ti        Delete           Bound    default/scratch-volume

$ kubectl exec -it ephemeralvolume-pod -- df -h
Filesystem      Size  Used Avail Use% Mounted on
overlay          95G  4.6G   90G   5% /
/dev/sdb       1007G   28K 1007G   1% /scratch    ❶

$ kubectl delete pod ephemeralvolume-pod
pod "ephemeralvolume-pod" deleted

$ kubectl get pod,pvc,pv
No resources found

❶ 1TiB 卷可供使用。

如您所见,使用临时卷时,删除 Pod 将删除相关的 PVC 对象。这与 StatefulSet 不同,当删除 StatefulSet 时,需要手动删除 PVC 对象。您还可以将此 Pod 包装在一个 Deployment 中,其中每个副本都将获得自己的临时卷。

摘要

  • Kubernetes 不仅限于运行无状态工作负载;它还可以巧妙地处理有状态工作负载。

  • Kubernetes 支持多种类型的卷,包括直接从云提供商资源挂载持久磁盘的能力。

  • PersistentVolume 和 PersistentVolumeClaim,连同 StorageClass 一起,是 Kubernetes 动态分配磁盘资源的抽象层,并使工作负载可移植。

  • StatefulSet 是为运行有状态工作负载而设计的高级工作负载类型,具有诸如能够为每个副本定义不同角色等优势。

  • PersistentVolume 和 PersistentVolumeClaim 对象具有复杂的生命周期,从请求开始,然后绑定成一个单独的逻辑对象。

  • 可以配置 StorageClasses 以启用具有您首选选项的动态存储——最重要的是,如果 Kubernetes 对象被删除,应该保留云提供商磁盘资源的选项。

  • 即使集群中的所有对象都被删除,只要使用保留回收策略,数据仍然可以恢复。

  • 通用临时卷提供了一种使用挂载磁盘作为临时擦除空间的方法。


(1.) kubernetes.io/docs/concepts/storage/volumes/

(2.) kubernetes.io/docs/tasks/run-application/run-replicated-stateful-application/

10. 后台处理

本章涵盖

  • 如何在 Kubernetes 中处理后台任务

  • Kubernetes Job 和 CronJob 对象

  • 在何时以及何时不使用 Job 对象来处理自己的批量处理工作负载

  • 使用 Redis 创建自定义任务队列

  • 使用 Kubernetes 实现后台处理任务队列

在前面的章节中,我们探讨了开发暴露在 IP 地址上的服务,无论是提供公共地址上的外部服务还是集群本地 IP 上的内部服务。但关于所有可能需要执行但不直接属于请求-响应链的其他计算怎么办,比如调整大量图像、发送设备通知、运行 AI/ML 训练作业、处理财务数据或逐帧渲染电影?这些通常作为后台任务处理,这些任务是接受输入并产生输出,但不属于用户请求的同步处理。

您可以使用 Deployment 或 Kubernetes Job 对象来处理后台任务。Deployment 对于像大多数 Web 应用程序运行图像调整等持续运行的任务队列来说非常理想。Kubernetes Job 结构非常适合运行一次性维护任务、周期性任务(通过 CronJob)以及在有一定工作量需要完成时处理批量工作负载。

术语:任务或工作

实践者通常在提及后台计算(例如,作业队列、任务队列、后台作业、后台任务)时将术语任务工作互换使用。由于 Kubernetes 有一个名为 Job 的对象,为了减少歧义,我在提及该对象本身时始终使用Job,而在提及一般后台处理概念(无论其实现方式如何)时使用任务(例如,后台任务、任务队列)。

本章介绍了使用 Deployment 和 Job 进行后台任务处理。到本章结束时,您将能够为 Web 应用程序配置一个连续的后台任务处理队列,并使用 Kubernetes 定义具有最终状态的批量工作负载,包括周期性和一次性任务。

10.1 后台处理队列

在野外部署的大多数 Web 应用程序都有一个主要的后台任务处理组件,用于处理在短 HTTP 请求-响应时间窗口内无法完成的处理任务。谷歌进行的研究表明,页面加载时间越长,用户“跳出”(即离开页面并去其他地方)的可能性就越高,观察到“当页面加载时间从 1 秒增加到 3 秒时,跳出概率增加 32%。”¹。因此,在用户等待时尝试进行任何重负载通常是一个错误;相反,应该将任务放入后台队列,并让用户了解其进度。页面加载速度需要让每个前端开发者到后端开发者都牢记在心;这是一个集体责任。

加载页面所需的时间有很多,许多方面,如图片大小和 JavaScript,都不在 Kubernetes 的范围内。当您在 Kubernetes 中查看工作负载部署时,需要考虑的一个相关指标是首次字节时间(TTFB)。这是您的 Web 服务器完成请求处理并客户端开始下载响应的时间。为了实现低整体页面加载时间,减少 TTFB 时间是至关重要的,并且需要在亚秒内响应。这基本上排除了任何作为请求一部分的行内数据处理。需要创建 ZIP 文件供用户使用或缩小他们刚刚上传的图片?最好不要在请求本身中执行这些操作。

因此,常见的模式是运行一个持续的后台处理队列。Web 应用程序将无法行内处理的任务,如数据处理,转交给后台队列(图 10.1)。在后台队列处理期间,Web 应用程序可能会显示一个旋转器或其他 UI 提示,当结果准备好时给用户发邮件,或者简单地提示用户稍后再回来。您如何架构用户交互取决于您。在这里我们将介绍如何在 Kubernetes 中部署这种类型的后台处理任务队列。

10-01

图 10.1 前端 Web 服务器与后台任务队列

回想一下,部署(如第三章所述)是 Kubernetes 中的一个工作负载结构,其目的是维护一组持续运行的 Pods。对于后台任务处理队列,您需要一组持续运行的 Pods 来作为您的任务工作者。所以这是一个匹配!部署中的 Pods 不会通过服务暴露出来并不重要;关键是您希望至少有一个工作者持续运行。您将像更新服务于前端请求的部署一样更新这个部署,使用新的容器版本并对其进行扩展和缩减,所以我们迄今为止学到的所有内容都可以同样应用于后台任务工作者的部署。

10.1.1 创建自定义任务队列

在您的任务处理部署中部署的工作节点 Pods 有一个简单的角色:接收输入并产生输出。但它们从哪里获取输入呢?为了这个目的,您需要一个队列,应用程序的其他组件可以在此队列中添加任务。这个队列将存储待处理任务的列表,工作节点 Pods 将处理这些任务。对于后台队列有许多现成的解决方案(其中一些我在第 10.1.4 节中提到),但要最好地理解它们是如何工作的,让我们自己创建一个吧!

对于队列数据存储,我们将使用我们在上一章中创建的相同的 Redis 工作负载部署。Redis 内置了对队列的支持,这使得它非常适合这项任务(并且许多现成的解决方案也使用 Redis)。我们的任务处理系统设计相当简单:Web 应用程序角色将任务入队到 Redis(我们可以通过手动添加任务来模拟这个角色),然后我们的 Deployment 中的工作 Pod 从队列中弹出任务,执行工作,并等待下一个任务。

Redis 中的队列

Redis 自带了几个方便的数据结构。我们在这里使用的是队列。在这个队列结构上,我们将使用两个函数来实现 FIFO(先进先出)的顺序,这在后台队列中很典型(即按添加顺序处理项目):RPUSH用于将项目添加到队列的末尾,BLPOP用于从队列的前端弹出项目,如果没有可用项目则阻塞。

10-01_UN01

如果你把队列想象成从右到左,最右边的项目在队列的末尾,最左边的项目在队列的前端,那么LR函数前缀将更有意义(RPUSH用于在右侧推送对象,BLPOP用于以阻塞方式弹出队列中最左边的项目)。额外的B前缀表示函数的阻塞形式(在这种情况下,LPOP的阻塞版本),这将导致它在队列为空时等待项目,而不是立即返回 nil。我们可以在自己的重试循环中简单地使用LPOP,但阻塞在响应上可以避免“忙等待”,这样可以节省更多资源,并且我们可以将这项任务留给 Redis。

作为具体但简单的例子,我们的任务将接受一个整数n作为输入,并使用莱布尼茨级数公式进行n次迭代来计算π(以这种方式计算π时,迭代次数越多,最终结果越准确)。在实际应用中,你的任务将完成你需要它完成的任意处理,并且可能以 URL 或键值参数字典作为输入。概念是相同的。

创建工作容器

在我们到达 Kubernetes Deployment 之前,我们需要创建我们的工作容器。我将再次使用 Python 作为这个示例,因为我们可以在几行 Python 代码中实现一个完整的任务队列。这个容器的完整代码可以在本书附带源代码的 Chapter10/pi_worker 文件夹中找到。它由三个 Python 文件组成,如下所示。

列表 10.1 是工作函数,其中发生实际计算。它没有意识到自己在队列中;它只是进行处理。在你的情况下,你会用你需要做的任何计算来替换这个(例如,创建 ZIP 文件或调整图像大小)。

列表 10.1 第十章/pi_worker/pi.py

from decimal import *

# Calculate pi using the Gregory-Leibniz infinity series
def leibniz_pi(iterations):

  precision = 20
  getcontext().prec = 20
  piDiv4 = Decimal(1)
  odd = Decimal(3)

  for i in range(0, iterations):
    piDiv4 = piDiv4 - 1/odd
    odd = odd + 2
    piDiv4 = piDiv4 + 1/odd
    odd = odd + 2

  return piDiv4 * 4

然后,在列表 10.2 中,我们有我们的工作实现,它将取队列头部的任务对象,并使用需要完成的工作的参数调用 leibniz_pi 函数执行工作。对于你的实现,你排队的对象只需要包含任务的相关函数参数,比如创建或处理图像的详细信息。将队列处理逻辑与工作函数分开是有用的,这样后者可以在其他环境中重用。

列表 10.2 第十章/pi_worker/pi_worker.py

import os
import redis
from pi import *

redis_host = os.environ.get('REDIS_HOST')   ❶
assert redis_host != None
r = redis.Redis(host=redis_host,            ❷
                port='6379',                ❷
                decode_responses=True)      ❷

print("starting")
while True:
  task = r.blpop('queue:task')              ❸
  iterations = int(task[1])
  print("got task: " + str(iterations))
  pi = leibniz_pi(iterations)               ❹
  print (pi) 

❶ 从环境变量中检索 Redis 主机(由列表 10.5 中的 Deployment 提供)。

❷ 连接到 Redis 服务。

❸ 弹出下一个任务(如果没有任务在队列中,则阻塞)。

❹ 执行工作。

要弹出基于 Redis 的队列,我们使用 Redis 的 BLPOP 命令,它将获取列表中的第一个元素,如果队列为空,则阻塞并等待添加更多任务。为了使这个产品级应用更完善,我们还需要做更多的事情,比如添加信号处理以处理 Pod 终止的情况(在第 10.1.2 节中介绍),但现在这已经足够了。

最后,在列表 10.3 中,我们有一个小脚本,用于向这个队列添加一些工作。在现实世界中,你将根据需要排队任务(通过使用带有任务参数的 RPUSH 调用),例如,在用户上传图片时响应性地排队调整图片大小的任务。在我们的演示中,我们可以用一些随机值初始化我们的任务队列。下面的列表将使用随机值(值在 1 到 100 万之间)创建 10 个示例任务。

列表 10.3 第十章/pi_worker/add_tasks.py

import os
import redis
import random

redis_host = os.environ.get('REDIS_HOST')
assert redis_host != None
r = redis.Redis(host=redis_host,
                port='6379',
                decode_responses=True)

random.seed()
for i in range(0, 10):                     ❶
  rand = random.randint(10,100)
  iterations = rand * 100000               ❷
  r.rpush('queue:task', iterations)        ❸
  print("added task: " + str(iterations))

print("queue depth", str(r.llen('queue:task')))
print ("done")

❶ 循环 10 次添加 10 个任务。

❷ 创建一个随机任务参数。

❸ 将任务添加到队列。

rpush 方法(对应 RPUSH²)将给定的值(在我们的例子中,是一个整数)添加到由键指定的列表中(在我们的例子中,键是 "queue:task")。如果你之前没有使用过 Redis,你可能期望更复杂的东西,但创建队列只需要这些。不需要预先配置或模式。

将这三个 Python 脚本打包成一个容器相当简单。如列表 10.4 所示,我们可以使用官方的 Python 基础镜像并添加 Redis 依赖(如果你需要复习如何构建这样的容器,请参阅第二章)。对于默认容器入口点,我们将使用 python3 pi_worker.py 运行我们的工作进程。

列表 10.4 第十章/pi_worker/Dockerfile

FROM python:3
RUN pip install redis     ❶
COPY . /app
WORKDIR /app 
CMD python3 pi_worker.py  ❷

❶ 包含 Redis 依赖。

❷ 运行工作进程。

在创建我们的 Python 工作容器后,我们现在可以进入部署到 Kubernetes 的有趣部分了!

部署到 Kubernetes

图 10.2 展示了 Kubernetes 架构的样子:我们有运行 Redis 的 StatefulSet 和运行工作 Pod 的 Deployment。还有一个添加任务的 Web 应用程序角色,但在这个例子中我们将手动进行。

10-02

图 10.2 背景处理任务队列的 Kubernetes 架构

我们的工人 Pod 将以一个希望现在熟悉的 Deployment 配置(来自第三章)进行部署。我们将通过一个环境变量传入 Redis 主机的位置,该变量引用内部服务主机(如第 7.1.3 节所述)。

列表 10.5 第十章/10.1.1_ 任务队列/deploy_worker.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: pi-worker
spec:
  replicas: 2
  selector:
    matchLabels:
      pod: pi
  template:
    metadata:
      labels:
        pod: pi
    spec:
      containers:
      - name: pi-container
        image: docker.io/wdenniss/pi_worker:1   ❶
        env:
        - name: REDIS_HOST
          value: redis-0.redis-service          ❷
        - name: PYTHONUNBUFFERED                ❸
          value: "1"

❶ 工人容器镜像

❷ 主要 Redis Pod 的 Kubernetes 服务主机名

❸ 环境变量,指示 Python 立即输出所有打印语句

注意,与本书中用于公开 web 服务的其他 Deployment 相比,这个 Deployment 没有任何特别之处。它只是一组我们碰巧赋予任务工作者角色的 Pod。

我们的工人 Pod 期待一个 Redis 实例,所以让我们先部署它。我们可以使用第九章中的那个;9.2.1_ 有状态集 Redis 和 9.2.2 有状态集 _Redis_Multi 文件夹中的解决方案都适用于我们的目的。从代码样本根目录,只需运行:

$ kubectl create -f Chapter09/9.2.2_StatefulSet_Redis_Multi
configmap/redis-config created
service/redis-service created
statefulset.apps/redis created

$ kubectl get pods
NAME      READY   STATUS     RESTARTS   AGE
redis-0   1/1     Running    0          20s
redis-1   1/1     Running    0          13s
redis-2   0/1     Init:0/1   0          7s

现在创建我们的工人部署:

kubectl create -f Chapter10/10.1.1_TaskQueue/deploy_worker.yaml

最后,验证一切是否运行正常。你应该看到五个正在运行的 Pod:

$ kubectl get pods
NAME                         READY   STATUS    RESTARTS   AGE
pi-worker-55477bdf7b-7rmhp   1/1     Running   0          2m5s
pi-worker-55477bdf7b-ltcsd   1/1     Running   0          2m5s
redis-0                      1/1     Running   0          3m41s
redis-1                      1/1     Running   0          3m34s
redis-2                      1/1     Running   0          3m28s

观察滚动进度

我在这里展示的 get 命令,如 kubectl get pods,给你一个时间点的状态。回想一下第三章,有两个很好的选项来监视你的滚动:你可以在 kubectl 命令中附加 -w,这是 Kubernetes 的内置监视选项(例如,kubectl get pods -w),或者你可以使用我最喜欢的 Linux watch 命令。我使用 watch -d kubectl get pods,这将每 2 秒刷新状态,并突出显示更改。你还可以自定义刷新率。为了在书中保持语法简单,我不会在每个我分享的命令中添加监视,但请记住,它们是可以使用的。

现在我们已经部署了应用程序,我们可以查看日志以了解它在做什么。Kubernetes 中没有内置的方法可以同时流式传输多个 Pod(如我们的两个工人)的日志,但通过指定 Deployment,系统将随机选择一个并跟随其日志:

$ kubectl logs -f deployment/pi-worker
Found 2 pods, using pod/pi-worker-55477bdf7b-7rmhp
starting

如果你只想查看部署中所有 Pod 的日志而不进行流式传输,也可以通过引用 PodSpec 中的元数据标签来完成,在我们的情况下是 pod=pi

$ kubectl logs --selector pod=pi
starting
starting

无论你以何种方式查看日志,我们都可以看到 Pod 打印了 starting 并没有其他内容,因为我们的 Pod 正在等待任务被添加到队列中。让我们添加一些任务供它处理。

向队列添加工作

通常,添加工作到后台队列的将是 web 应用程序或另一个进程。所有 web 应用程序需要做的只是调用 redis.rpush('queue:task', object) 带有表示任务的对象

对于这个例子,我们可以运行我们包含在容器中(列表 10.3)的add_tasks.py脚本来安排一些任务。我们可以在我们的 Pod 之一上执行一次性命令:

$ kubectl exec -it deploy/pi-worker -- python3 add_tasks.py
added task: 9500000
added task: 3800000
added task: 1900000
added task: 3600000
added task: 1200000
added task: 8600000
added task: 7800000
added task: 7100000
added task: 1400000
added task: 5600000
queue depth 8
done

注意,当我们在这里传递deploy/pi-worker时,exec将随机选择我们的 Pod 之一来运行实际命令(这甚至可以是处于Terminating状态的 Pod,所以请小心!)您也可以使用kubectl exec -it $POD_NAME -- python3 add_tasks.py直接在您选择的 Pod 上运行命令。

查看工作情况

在队列中添加任务后,我们可以观察我们的一个工作 Pod 的日志,以了解它们的运行情况:

$ kubectl logs -f deployment/pi-worker
Found 2 pods, using pod/pi-worker-54dd47b44c-bjccg
starting
got task: 9500000
3.1415927062213693620
got task: 8600000
3.1415927117293246813
got task: 7100000
3.1415927240123234505

此工作 Pod 正在获取任务(即使用 Gregory-Leibniz 无穷级数算法的n次迭代来计算π)并执行工作。

10.1.2 工作 Pod 中的信号处理

有一个需要注意的事项是,之前的工人实现没有 SIGTERM 处理,这意味着当 Pod 需要替换时,它不会优雅地关闭。Pod 可能被终止的原因有很多,包括更新部署或 Kubernetes 节点升级,所以这是一个非常重要的信号需要处理。

在 Python 中,我们可以通过实现一个 SIGTERM 处理程序来实现这一点,该处理程序将指示我们的工作员在完成当前任务后终止。我们还将添加一个超时到我们的队列弹出调用,以便工作员可以更频繁地检查状态。对于您自己的工作,查找如何在您选择的语言中实现 SIGTERM 信号处理。让我们在以下列表中添加终止处理,以便在接收到 SIGTERM 时关闭工作员。

列表 10.6 第十章/pi_worker2/pi_worker.py

import os
import signal
import redis
from pi import *

redis_host = os.environ.get('REDIS_HOST')
assert redis_host != None
r = redis.Redis(host=redis_host,
                port='6379',
                decode_responses=True)
running = True                                 ❶

def signal_handler(signum, frame): ❷
 print("got signal") ❷
 running = False ❷

signal.signal(signal.SIGTERM, signal_handler) ❷

print("starting")
while running:
 task = r.blpop('queue:task', 5)            ❸
 if task != None:
    iterations = int(task[1])
    print("got task: " + str(iterations))
    pi = leibniz_pi(iterations)
    print (pi)

❶ 如果信号处理程序将此变量设置为 false,则退出循环,而不是无限循环。

❷ 当接收到 SIGTERM 时,注册信号处理程序以将运行状态设置为 false。

❸ 弹出下一个任务,但现在如果队列为空,则只等待 5 秒钟(以允许检查运行条件)

然后,在更新的部署中部署此修订版,指定新镜像以及terminationGracePeriodSeconds以请求 2 分钟来处理 SIGTERM,通过完成当前工作并退出。

列表 10.7 第十章/10.1.2_TaskQueue2/deploy_worker.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: pi-worker
spec:
  replicas: 2
  selector:
    matchLabels:
      pod: pi
  template:
    metadata:
      labels:
        pod: pi
    spec:
      containers:
      - name: pi-container
 image: docker.io/wdenniss/pi_worker:2 ❶
        env:
        - name: REDIS_HOST
          value: redis-0.redis-service
        - name: PYTHONUNBUFFERED
          value: "1"
        resources:                             ❷
          requests:                            ❷
            cpu: 250m                          ❷
            memory: 250Mi                      ❷
 terminationGracePeriodSeconds: 120 ❸

❶ 具有 SIGTERM 处理的新的应用程序版本

❷ 添加资源请求,以便它可以与 HPA 一起工作

❸ 请求 120 秒的优雅终止期,以便容器在接收到 SIGTERM 后有足够的时间关闭。

一起,Pod 中的信号处理和终止宽限期意味着,一旦接收到 SIGTERM,此 Pod 将停止接受新的工作,并将有 120 秒的时间来处理任何当前的工作。根据您自己的工作负载需要调整terminationGracePeriodSeconds的值。

在这里,我们还没有考虑一些其他事情。例如,如果工作器在处理任务时崩溃,那么该任务就会丢失,因为它将从队列中移除但未完成。此外,只有最小程度的可观察性和其他功能。前一个示例的目标不是提供一个完整的队列系统,而是从概念上展示它们是如何工作的。您可以继续实现容错和其他功能,或者采用开源的后台任务队列,让它为您完成这些工作。这个选择由您自己决定。

10.1.3 扩展工作 Pod

扩展工作 Pod 的技术对于任何 Deployment 都是一样的,如第六章所述。您可以手动设置副本数量或使用水平 Pod 自动扩展器 (HPA)。由于我们的示例工作负载是 CPU 密集型的,因此 CPU 指标非常适合使用 HPA 进行扩展,所以现在让我们设置一个。

列表 10.8 第十章第 10.1.3_HPA/worker_hpa.yaml

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: pi-worker-autoscaler
spec:
  scaleTargetRef:         ❶
    apiVersion: apps/v1   ❶
    kind: Deployment      ❶
    name: pi-worker       ❶
  minReplicas: 2
  maxReplicas: 10
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 20

❶ 引用列表 10.7 中的 Deployment

此代码将使我们的部署扩展到 2 到 10 个 Pod 之间,目标是使 Pod 平均使用其请求的 CPU 资源的 20%。创建 HPA 如下:

kubectl create -f Chapter10/10.1.3_HPA

在设置了 HPA 之后,您可以重复第 10.1.1 节中的“添加任务”步骤,并观察 HPA 执行其操作。kubectl get 命令支持多种资源类型,因此您可以运行 kubectl get pods,hpa,我通常在 Linux watch 命令前加上前缀,以观察所有组件的交互:

$ kubectl exec -it deploy/pi-worker -- python3 add_tasks.py
$ kubectl get pods,hpa
NAME                             READY   STATUS    RESTARTS   AGE
pod/pi-worker-54dd47b44c-22x9b   1/1     Running   0          2m42s
pod/pi-worker-54dd47b44c-9wppc   1/1     Running   0          2m27s
pod/pi-worker-54dd47b44c-bjccg   1/1     Running   0          13m
pod/pi-worker-54dd47b44c-f79hx   1/1     Running   0          2m42s
pod/pi-worker-54dd47b44c-fptj9   1/1     Running   0          2m27s
pod/pi-worker-54dd47b44c-hgbqd   1/1     Running   0          2m27s
pod/pi-worker-54dd47b44c-lj2bk   1/1     Running   0          2m27s
pod/pi-worker-54dd47b44c-wc267   1/1     Running   0          2m10s
pod/pi-worker-54dd47b44c-wk4dg   1/1     Running   0          2m10s
pod/pi-worker-54dd47b44c-x2s4m   1/1     Running   0          13m
pod/redis-0                      1/1     Running   0          56m
pod/redis-1                      1/1     Running   0          56m
pod/redis-2                      1/1     Running   0          56m

NAME                  REFERENCE             TARGETS MINPODS MAXPODS REPLICAS
pi-worker-autoscaler  Deployment/pi-worker  66%/20% 2       10      10      

10.1.4 开源任务队列

到目前为止,我们一直在构建自己的任务队列。我发现亲自动手了解事物的工作原理是最好的。然而,您可能不需要从头开始自己实现任务队列,因为其他人已经为您完成了这项工作。

对于 Python,RQ³ 是一个流行的选择,它允许您基本上使用一堆参数来排队一个函数调用。甚至不需要将此函数包装在一个符合所需协议的对象中。

对于 Ruby 开发者来说,GitHub 团队创建的 Resque⁴ 是一个流行的选择。Resque 中的任务只是实现了 perform 方法的 Ruby 类。Ruby on Rails 框架通过其 Active Job 框架使得 Resque(以及其他任务队列实现)特别容易使用,该框架允许 Resque(以及其他任务队列实现)作为排队后端使用。

在出去构建自己的队列之前,我建议您查看这些选项以及更多内容。如果您必须自己构建某些内容,或者现成的选项根本不够用,我希望您从早期的示例中看到了,至少开始起来是相当直接的。

10.2 工作

Kubernetes 提供了一种使用 Job 结构定义有限工作集的方法。Job 和 Deployment 都可以用于在 Kubernetes 中处理批量作业和后台处理。关键区别在于,Job 是设计来处理有限的工作集,并且可能不需要像 Redis 这样的队列数据结构,而 Deployment 是用于持续运行的后台队列,它需要某种类型的队列结构来进行协调(就像我们在第 10.1 节中所做的那样)。你还可以使用 Jobs 来运行一次性任务和周期性任务,如维护操作,这在 Deployment 中是不合理的(Deployment 会在 Pod 完成后重启 Pod)。

你可能想知道为什么在 Kubernetes 中需要一个单独的结构来运行一次性的任务,因为独立的 Pods 也能做到这一点。虽然确实可以安排一个 Pod 来执行任务并在完成后关闭,但没有控制器来确保任务实际上完成了。例如,如果 Pod 在有机会完成之前因为维护事件而被驱逐,这种情况就会发生。Job 通过在 Pod 旁边添加一些有用的结构来确保任务完成,包括在任务失败或被驱逐时重新安排任务,以及跟踪多个完成和并行性的可能性。

最后,Job 只是 Kubernetes 中用于管理 Pods 的另一个高级工作负载控制器,就像 Deployment 和 StatefulSet 一样。所有三个都创建 Pods 来运行你的实际代码,只是控制器提供的调度和管理逻辑不同。Deployments 用于创建一组持续运行的 Pods;StatefulSet 用于具有唯一序号的 Pods,可以通过持久卷模板附加磁盘;而 Jobs 用于应该运行到完成的 Pods,可能需要多次运行。

10.2.1 使用 Jobs 运行一次性任务

Jobs 对于运行一次性任务非常出色。假设你想执行一个维护任务,比如清理缓存或其他本质上只是在你容器中运行命令的任务。而不是在现有的 Pod 上使用 kubectl exec,你可以安排一个 Job 来以单独的过程运行任务,并为其分配自己的资源,确保操作将按请求完成(或报告失败状态),并使其易于重复。

exec 命令实际上应该仅用于调试正在运行的 Pods。如果你使用 exec 来执行维护任务,你的任务将与 Pod 共享资源,这并不理想。Pod 可能没有足够的资源来同时处理两者,并且你正在影响性能。通过将任务移动到 Job,它们将获得自己的 Pod 和自己的资源分配。

配置代码用于维护任务

在整本书中,我一直强调捕获配置中的所有内容是多么重要。通过将常规维护任务作为 Job 来捕获,而不是有一个要复制/粘贴的 shell 命令列表,你正在构建一个可重复的配置。如果你遵循 GitOps 方法,其中生产变更通过 Git 进行(下一章将介绍),你的维护任务可以通过你通常的代码审查流程部署到生产环境中。

在上一节中,我们需要在容器中执行一个命令来向我们的队列添加一些工作,我们使用了kubectl exec在现有的 Pod 上运行python3 add_tasks.py。让我们升级添加工作的过程,使其成为一个具有自己 Pod 的 Job。以下 Job 定义可以用来在我们的名为pi的容器上执行python3 add_tasks.py任务。

列表 10.9 第十章/10.2.1_Job/job_addwork.yaml

apiVersion: batch/v1
kind: Job
metadata:
  name: addwork
spec:
  backoffLimit: 2
  template:
    spec:
      containers:
      - name: pi-container
        image: docker.io/wdenniss/pi_worker:2    ❶
        command: ["python3",  "add_tasks.py"]    ❷
        env:
        - name: REDIS_HOST
          value: redis-0.redis-service
        - name: PYTHONUNBUFFERED
          value: "1"
      restartPolicy: Never

❶ 引用与工作节点相同的容器镜像...

❷ ...但对于 addwork Job 指定了不同的运行命令

spec模式中的template内部的spec可能看起来很熟悉,这是因为这个对象就像 Deployment 和 StatefulSet 一样嵌入了一个 PodSpec 模板(见图 10.3,展示了对象组成)。Pod 的所有参数都可以在这里使用,例如资源请求和环境变量,只有少数几个参数组合在 Job 上下文中没有意义。

10-03

图 10.3 Job 的对象组成

我们 Job 的 PodSpec 与我们的 Deployment 中的 PodSpec 具有相同的环境变量。这就是 Kubernetes 对象组合的好处:无论 Pod 在哪里嵌入,规范都是相同的。其他的不同之处在于restartPolicybackoffLimit字段。

Pod 的restartPolicy是 Job 中嵌入的 PodSpec 的一个属性,它决定了节点上的 kubelet 是否会重启因错误退出的容器。对于 Job,这可以设置为OnFailure,如果容器失败则重启,或者设置为Never来忽略失败。对于 Job 来说,Always选项没有意义,因为这会重启一个成功的 Pod,而这并不是 Job 设计的目的(这更属于 Deployment 的领域)。

倒计时限制是 Job 的一部分,它决定了尝试运行 Job 的次数。这包括崩溃和节点故障。例如,如果 Job 崩溃两次然后因为节点维护而被驱逐,这算作三次重启。一些实践者喜欢在开发期间使用Never,因为这更容易调试并查看所有失败的 Pod 以及查询它们的日志。

创建 Job 就像创建任何其他 Kubernetes 对象一样,然后观察进度:

$ kubectl create -f Chapter10/10.2.1_Job/job_addwork.yaml
job.batch/addwork created

$ kubectl get job,pods
NAME                COMPLETIONS   DURATION   AGE
job.batch/addwork   1/1           3s         9s

NAME                             READY   STATUS      RESTARTS   AGE
pod/addwork-99q5k                0/1     Completed   0          9s
pod/pi-worker-6f6dfdb548-7krpm   1/1     Running     0          7m2s
pod/pi-worker-6f6dfdb548-pzxq2   1/1     Running     0          7m2s
pod/redis-0                      1/1     Running     0          8m3s
pod/redis-1                      1/1     Running     0          7m30s
pod/redis-2                      1/1     Running     0          6m25s 

如果 Job 成功,我们可以观察我们的工作节点 Pod,它们应该会因新添加的工作而变得忙碌。如果你之前部署了 HPA,那么你很快就会看到新容器被创建,就像我这里做的那样:

$ kubectl get pods,hpa
NAME                             READY   STATUS              RESTARTS   AGE
pod/addwork-99q5k                0/1     Completed           0          58s
pod/pi-worker-6f6dfdb548-7krpm   1/1     Running             0          7m51s
pod/pi-worker-6f6dfdb548-h6pld   0/1     ContainerCreating   0          8s
pod/pi-worker-6f6dfdb548-pzxq2   1/1     Running             0          7m51s
pod/pi-worker-6f6dfdb548-qpgxp   1/1     Running             0          8s
pod/redis-0                      1/1     Running             0          8m52s
pod/redis-1                      1/1     Running             0          8m19s
pod/redis-2                      1/1     Running             0          7m14s

NAME                  REFERENCE            TARGETS   MINPODS MAXPODS REPLICAS
pi-worker-autoscaler  Deployment/pi-worker 100%/20%  2       10      2  

关于 Job 有一点需要注意:无论 Job 是否已完成,你都无法使用相同的名称(即重复该操作)再次调度它,除非先删除它。这是因为尽管工作现在已经完成,但 Job 对象仍然存在于 Kubernetes 中。你可以像删除任何通过配置创建的对象一样删除它:

kubectl delete -f Chapter10/10.2.1_Job/job_addwork.yaml

总结一下,Job 是当你有一些任务或工作要完成时使用的。我们的例子是执行一个简单的命令。然而,这也可以是一个漫长且复杂的计算任务。如果你需要运行一个一次性后台进程,只需将其容器化,在 Job 中定义它,并调度它。当 Job 报告自己为 Completed(通过成功退出状态终止)时,工作就完成了。

我们没有使用来运行一次性任务的 Job 参数是 completionsparallelism。这些参数允许你使用单个 Job 对象描述来处理一批任务,这将在第 10.3 节中介绍。在我们到达那里之前,让我们看看如何定期调度 Job。

10.2.2 使用 CronJobs 调度任务

在上一节中,我们创建了一个合适的 Kubernetes 对象来封装我们在集群上手动执行的命令。现在,团队中的任何开发者都可以通过创建 Job 对象来执行这个任务,而不是需要记住一个复杂的 exec 命令。

那么,对于需要定期在固定间隔运行的任务怎么办?Kubernetes 提供了 CronJob。CronJob 封装了一个 Job 对象,并添加了一个频率参数,允许你设置每天或每小时(或任何你喜欢的间隔)的频率来运行 Job,如下面的列表所示。这是调度像每日缓存清理这样的任务的一种流行方式。

列表 10.10 第十章/10.2.2_CronJob/cronjob_addwork.yaml

apiVersion: batch/v1
kind: CronJob
metadata:
  name: addwork
spec:
  schedule: "*/5 * * * *"   ❶
  jobTemplate:
    spec:                   ❷
      backoffLimit: 2
      template:
        spec:
          containers:
          - name: pi-container
            image: docker.io/wdenniss/pi_worker:2
            command: ["python3",  "add_tasks.py"]
            env:
            - name: REDIS_HOST
              value: redis-0.redis-service
            - name: PYTHONUNBUFFERED
              value: "1"
          restartPolicy: Never 

❶ 运行 Job 的 cron 调度计划

❷ 与列表 10.9 类似的 Job 规范

你可能会注意到,我们只是将 Job 的整个规范(即 spec 字典)从列表 10.9 下的这个 CronJob 的 spec 字典中复制过来,作为 jobTemplate 键,并添加了一个额外的规范级别字段名为 schedule。回想一下,Job 有自己的模板用于将要创建的 Pods,这些 Pods 也有自己的规范。

因此,CronJob 包含一个 Job 对象,而该对象反过来又包含一个 Pod。通过对象组合可视化这一点可能会有所帮助,所以请看一下图 10.4。

10-04

图 10.4 CronJob 的对象组合

Kubernetes 中的对象组合

在所有规范和模板嵌入其他模板和规范的情况下,有时感觉就像在 Kubernetes 中一直向下是乌龟。这里有一个 CronJob,其规范包含在计划上运行的 Job 的模板,它本身包含具有自己规范的 Pod 的模板。这可能会让人感到困惑和重复,或者两者兼而有之,但这种方法有一个巨大的好处。在查看 API 文档时,你可以在jobTemplate中使用 Job 的任何字段,就像你可以在spec部分中使用 Pod 的任何字段一样。Kubernetes 对象是由其他对象的组合构建的。

一些术语值得学习:当一个 Pod 嵌入到另一个对象中时,我们称嵌入 Pod 的规范为PodSpec(例如,Deployment 包含一个 PodSpec)。当那个高级对象的控制器在集群中创建 Pod 时,该 Pod 与任何其他 Pod 都相同,包括那些直接使用它们自己的规范创建的 Pod。唯一的区别是,由控制器(如 Job 或 Deployment)创建的 Pod 将继续被该控制器观察(即,如果它们失败,则重新创建它们,等等)。

那么这就是它的组成方式。关于schedule字段,这是 CronJob 对规范的贡献?schedule是我们定义频率的地方,使用古老的 Unix cron 格式。cron 格式非常具有表现力。在列表 10.10 中,*/5 * * * *表示“每 5 分钟”。你可以配置像“每 30 分钟运行一次”(*/30 * * * *)、午夜运行(* 0 * * **)、周一下午 4:00(0 16 * * 1)等时间表。我建议使用可视化的 cron 编辑器(通过谷歌搜索“cron editor”应该可以解决问题)来验证你偏好的表达式,而不是等待一周来验证你想要每周运行的 Job 实际上是否运行了。

创建新的 CronJob:

$ kubectl create -f Chapter10/10.2.2_CronJob/cronjob_addwork.yaml
cronjob.batch/addwork created

$ kubectl get cronjob,job
NAME                    SCHEDULE      SUSPEND   ACTIVE   LAST SCHEDULE   AGE
cronjob.batch/addwork   */5 * * * *   False     0        <none>          58s

等待几分钟(在这个例子中,Job 每 5 分钟创建一次,即:00,05,等等),然后你可以看到 Job 及其产生的 Pod:

$ kubectl get cronjob,job,pods
NAME                    SCHEDULE      SUSPEND   ACTIVE   LAST SCHEDULE   AGE
cronjob.batch/addwork   */5 * * * *   False     0        2m38s           3m11s

NAME                         COMPLETIONS   DURATION   AGE
job.batch/addwork-27237815   1/1           107s       2m38s

NAME                             READY   STATUS      RESTARTS   AGE
pod/addwork-27237815-b44ws       0/1     Completed   0          2m38s
pod/pi-worker-6f6dfdb548-5czkc   1/1     Running     5          14m
pod/pi-worker-6f6dfdb548-gfkcq   1/1     Running     0          7s
pod/pi-worker-6f6dfdb548-pl584   1/1     Running     0          7s
pod/pi-worker-6f6dfdb548-qpgxp   1/1     Running     5          25m
pod/redis-0                      1/1     Running     0          14m
pod/redis-1                      1/1     Running     0          33m
pod/redis-2                      1/1     Running     0          32m

CronJob 将按计划生成一个新的 Job,反过来,这个 Job 将生成一个新的 Pod。你可以检查这些历史 Job,因为它们保持Complete状态。CronJobSpec 中的successfulJobsHistoryLimitfailedJobsHistoryLimit选项可以用来控制保留多少个历史 Job。

时区

注意,CronJob 将在你的集群时区运行,对于许多平台,包括 Google Kubernetes Engine(GKE),将是 UTC。使用的时区是系统 Kubernetes 控制器组件的时区,该组件运行在控制平面。如果你在一个托管平台上,可能无法直接查询控制平面节点,但可以检查工作节点,它们可能使用相同的时区。以下是如何创建一个一次性 Pod 来运行 Linux date命令然后以粗体输出结果的步骤:

$ kubectl run date --restart=Never -it --rm --image ubuntu -- date +%Z
UTC

10.3 使用 Job 进行批量任务处理

如果你有一批工作想要作为常规或一次性事件处理,会怎样?如第 10.1 节所述,如果你想要一个持续运行的任务队列,那么部署实际上是正确的 Kubernetes 对象。但是,如果你有一批有限的工作要处理,那么 Job 是理想的 Kubernetes 结构。

如果你有一个像我们在第 10.1 节中那样的动态工作队列数据结构,但希望当队列空时你的工作者完全关闭,那么 Job 可以做到这一点。使用部署,你需要一个单独的系统(如HorizontalPodAutoscaler)来上下调整工作者 Pod 的数量,例如,当队列中没有更多工作时要这样做。当使用 Job 时,工作者 Pod 本身可以向 Job 控制器发出信号,表明工作已完成,它们应该关闭并回收资源。

使用 Job 的另一种方式是在静态工作队列上运行它,这样根本不需要数据库。比如说,你知道你需要处理队列中的 100 个任务。你可以运行 Job 100 次。当然,问题是 Job 系列中的每个 Pod 实例化都需要知道要运行哪 100 个任务,这就是索引 Job 发挥作用的地方。

在本节中,我将介绍动态和静态任务处理方法。

10.3.1 使用 Job 进行动态队列处理

让我们重新设计第 10.1 节中的动态队列,使用 Job 而不是部署。部署和 Job 都允许创建多个 Pod 工作者,并且两者在发生故障时都会重新创建 Pod。然而,部署没有 Pod“完成”的注释(即,以成功退出状态终止)。你给部署的任何副本数,它都会努力保持始终运行。另一方面,当由 Job 管理的 Pod 以成功退出代码(例如,exit 0)终止时,它向 Job 控制器表明工作已成功完成,Pod 不会被重新启动。

Job 的这一特性允许个别工作者在完成工作时发出信号,这使得 Job 非常有用。如果你使用的是动态 Kubernetes 环境,例如具有自动缩放(包括 Autopilot 模式下的 GKE)的环境,那么 Job 允许你“设置并忘记”工作,你安排它,一旦完成,资源消耗就会降到零。请注意,一旦完成,你不能将其缩放回原大小,但你可以删除并重新创建它,这本质上启动了一个新的处理队列。

为了使我们的任务工作者容器在 Job 环境中正确工作,我们需要为队列变为空时添加一个成功退出条件。以下列表显示了我们的修订后的工作者代码。

列表 10.11 第十章/pi_worker3/pi_worker.py

import os
import signal
import redis
from pi import *

redis_host = os.environ.get('REDIS_HOST')
assert redis_host != None
r = redis.Redis(host=redis_host,
                port='6379',
                decode_responses=True)
running = True

def signal_handler(signum, frame):
  print("got signal")
  running = False

signal.signal(signal.SIGTERM, signal_handler)

print("starting")
while running:
  task = r.blpop('queue:task', 5)
  if task != None:
    iterations = int(task[1])
    print("got task: " + str(iterations))
    pi = leibniz_pi(iterations)
    print (pi)
 else: ❶
 if os.getenv('COMPLETE_WHEN_EMPTY', '0') != '0': ❶
 print ("no more work") ❶
 running = False ❶

exit(0)                                               ❷

❶ 当配置为 COMPLETE_WHEN_EMPTY=1 时,当队列空时不要等待新任务。

❷ 0 退出代码向 Kubernetes 表明 Job 已成功完成。

在我们的工作容器设置正确地以作业上下文行为后,我们可以创建一个 Kubernetes 作业来运行它。在部署中,我们使用replica字段来控制同时运行的 Pod 数量,而在作业中,则是parallelism参数,它基本上做的是同样的事情。

列表 10.12 第十章/10.3.1_JobWorker/job_worker.yaml

apiVersion: batch/v1
kind: Job
metadata:
  name: jobworker
spec:
  backoffLimit: 2
  parallelism: 2                               ❶
  template:
    metadata:
      labels:
        pod: pi
    spec:
      containers:
      - name: pi-container
        image: docker.io/wdenniss/pi_worker:3  ❷
        env:
        - name: REDIS_HOST
          value: redis-0.redis-service
        - name: PYTHONUNBUFFERED
          value: "1"
        - name: COMPLETE_WHEN_EMPTY            ❸
          value: "1"
      restartPolicy: OnFailure

❶ 同时运行多少个 Pod 来处理这个任务队列

❷ 带有完成逻辑的新容器版本

❸ 指定以启用工作者中的完成行为的环境变量

如果您将列表 10.12 中作为作业创建的工作者的 PodSpec 与列表 10.7 中作为部署创建的工作者的 PodSpec 进行比较,您会注意到除了添加了COMPLETE_WHEN_EMPTY环境变量和restartPolicy字段之外,内嵌的 PodSpec(template下的字段)是相同的。添加重启策略是因为 Pod 的默认值Always不适用于旨在终止的作业。使用OnFailure重启策略,只有在工作者 Pod 崩溃而没有返回成功时,工作者 Pod 才会重启,这通常是期望的。我们并不严格需要这个工作者的作业版本的标签元数据,但它可以像之前讨论的那样(即使用kubectl logs --selector pod=pi)同时查询多个 Pod 的日志。

准备中

在运行基于作业的任务工作者的版本之前,删除之前的部署版本的工作者,并移除旧的“addwork”作业和 CronJob,以便它们可以再次运行:

$ cd Chapter10

$ kubectl delete -f 10.1.2_TaskQueue2
deployment.apps "pi-worker" deleted

$ kubectl delete -f 10.2.1_Job
job.batch "addwork" deleted
$ kubectl delete -f 10.2.2_CronJob
cronjob.batch "addwork" deleted

由于我们的基于 Redis 的队列可能有一些现有的作业,您也可以使用 LTRIM Redis 命令来重置它:

$ kubectl exec -it pod/redis-0 -- redis-cli ltrim queue:task 0 0
OK

如果您更喜欢交互式运行redis-cli来重置队列,也可以:

$ kubectl exec -it pod/redis-0 -- redis-cli
127.0.0.1:6379> LTRIM queue:task 0 0
OK

让我们尝试这个基于作业的工作者。首先,我们可以像之前一样向我们的 Redis 队列添加一些工作:

$ cd Chapter10

$ kubectl create -f 10.2.1_Job
job.batch/addwork created

$ kubectl get job,pod
NAME                COMPLETIONS   DURATION   AGE
job.batch/addwork   0/1           19s        19s

NAME                READY   STATUS              RESTARTS   AGE
pod/addwork-l9fgg   0/1     ContainerCreating   0          19s
pod/redis-0         1/1     Running             0          19h
pod/redis-1         1/1     Running             0          19h
pod/redis-2         1/1     Running             0          19h

TIP 您不能两次创建相同的作业对象,即使第一个实例已经运行并完成。要重新运行作业,首先删除它然后再创建。

一旦这个addwork作业完成,我们可以运行新的作业队列来处理工作。与之前不同,这里的顺序很重要,因为如果队列中没有工作,作业工作者将退出,所以请确保在运行作业队列之前addwork已经完成。观察状态如下:

$ kubectl get job,pod
NAME                COMPLETIONS   DURATION   AGE
job.batch/addwork   1/1           22s        36s

NAME                READY   STATUS      RESTARTS   AGE
pod/addwork-l9fgg   0/1     Completed   0          37s
pod/redis-0         1/1     Running     0          19h
pod/redis-1         1/1     Running     0          19h
pod/redis-2         1/1     Running     0          19h

一旦我们在addwork任务上看到Completed状态,我们就可以继续安排作业队列:

$ kubectl create -f 10.3.1_JobWorker
job.batch/jobworker created

$ kubectl get job,pod
NAME                  COMPLETIONS   DURATION   AGE
job.batch/addwork     1/1           22s        3m45s
job.batch/jobworker   0/1 of 2      2m16s      2m16s

NAME                  READY   STATUS      RESTARTS   AGE
pod/addwork-l9fgg     0/1     Completed   0          3m45s
pod/jobworker-swb6k   1/1     Running     0          2m16s
pod/jobworker-tn6cd   1/1     Running     0          2m16s
pod/redis-0           1/1     Running     0          19h
pod/redis-1           1/1     Running     0          19h
pod/redis-2           1/1     Running     0          19h

接下来应该发生的事情是,工作 Pods 将处理队列,当队列为空时,工作者将完成他们目前正在处理的任务,然后成功退出。如果您想监控队列深度以了解何时应该结束工作,您可以在 Redis 队列上运行LLEN来观察当前队列长度:

$ kubectl exec -it pod/redis-0 -- redis-cli llen queue:task
(integer) 5

当它达到零时,您应该观察到 Pod 进入Completed状态。请注意,它们不会立即进入这个状态,而是在完成它们正在处理的最后一个任务之后:

$ kubectl get job,pod
NAME                  COMPLETIONS   DURATION   AGE
job.batch/addwork     1/1           22s        3m45s
job.batch/jobworker   0/1 of 2      2m16s      2m16s

NAME                  READY   STATUS      RESTARTS   AGE
pod/addwork-l9fgg     0/1     Completed   0          10m09s
pod/jobworker-swb6k   1/1     Completed   0          8m40s
pod/jobworker-tn6cd   1/1     Completed   0          8m40s
pod/redis-0           1/1     Running     0          19h
pod/redis-1           1/1     Running     0          19h
pod/redis-2           1/1     Running     0          19h

记住,如果你想重新运行任何作业,你首先需要删除它们,然后再重新创建,即使作业已经完成且没有 Pod 在运行。为了再次运行之前的演示,请删除两个作业(添加工作负载的作业和运行工作者的作业)并重新创建它们:

$ kubectl delete -f 10.2.1_Job
job.batch "addwork" deleted
$ kubectl delete -f 10.3.1_JobWorker
job.batch "jobworker" deleted
$ kubectl create -f 10.2.1_Job
job.batch/addwork created
$ kubectl create -f 10.3.1_JobWorker
job.batch/jobworker created

10.3.2 使用作业进行静态队列处理

有多种方法可以在 Kubernetes 中使用静态队列运行作业,而不是像我们在上一节中那样使用 Redis 这样的动态队列来存储任务列表。当使用静态队列时,队列长度是事先已知的,并作为作业本身的一部分进行配置,并为每个任务创建一个新的 Pod。你不需要让任务工作者一直运行直到队列为空,而是预先定义了工作者 Pod 的实例化次数。

做这件事的主要原因是为了避免容器需要理解如何从动态队列中拉取任务,对于现有的容器来说,这通常意味着需要付出努力来添加这项功能。缺点是通常需要在 Kubernetes 端进行额外的配置。这实际上是将配置负担从工作容器转移到了 Kubernetes 对象上。

注意,即使你有不能修改执行工作的容器的需求,这并不意味着你必须使用静态队列。你可以在 Pod 中有多个容器,并让其中一个容器执行出队操作,然后将参数传递给另一个容器。

那么如何在 Kubernetes 配置中表示静态工作队列呢?有几个不同的选项,其中三个我将在这里概述。

使用索引的静态队列

在我看来,索引作业是静态队列选项中最有趣的一个。当你事先知道要处理多少个任务以及任务列表是易于索引的时候,它们非常有用。一个例子是渲染动画电影。你知道帧数(队列长度),并且可以轻松地将每个实例的帧数(即渲染帧的队列索引)传递给它们。

Kubernetes 将根据你指定的总次数(completions)运行作业,为每个任务创建一个 Pod。每次运行时,它都会给作业下一个索引(通过环境变量 $JOB_COMPLETION_INDEX 提供)。如果你的工作是自然索引的(例如,在动画电影中渲染帧),这将非常有效!你可以轻松地指示 Kubernetes 运行作业 30,000 次(即渲染 30,000 帧),并且它会为每个 Pod 提供帧号。另一个明显的方法是使用某种数据结构(例如,YAML 编码的任务数组或纯文本,每行一个任务)为每个作业提供完整的工作列表,Kubernetes 提供索引。然后作业可以使用索引在列表中查找任务。

以下列表提供了一个简单输出帧号的索引作业的配置示例。你可以自己替换实际的渲染电影逻辑。

列表 10.13 第十章/10.3.2_IndexedJob/indexed_job.yaml

apiVersion: batch/v1
kind: Job
metadata:
  name: echo
spec:
  completions: 5                                                    ❶
  parallelism: 3                                                    ❷
  completionMode: Indexed                                           ❸
  template:
    metadata:
      labels:
        pod: framerender
    spec:
      restartPolicy: Never
      containers:
      - name: 'worker'
        image: 'docker.io/library/busybox'
        command: ["echo", "render frame: $(JOB_COMPLETION_INDEX)"]  ❹

❶ 运行作业的次数(索引的上限)

❷ 并行运行的 Worker Pods 数量

❸ 以索引模式运行,通过环境变量传递 JOB_COMPLETION_INDEX。

❹ 输出当前索引的命令

运行并观察此作业:

$ kubectl create -f 10.3.2_IndexedJob 
job.batch/echo created

$ kubectl get job,pods
NAME             COMPLETIONS   DURATION   AGE
job.batch/echo   5/5           20s        58s

NAME               READY   STATUS      RESTARTS   AGE
pod/echo-0-r8v52   0/1     Completed   0          58s
pod/echo-1-nxwsm   0/1     Completed   0          58s
pod/echo-2-49kz2   0/1     Completed   0          58s
pod/echo-3-9lvt2   0/1     Completed   0          51s
pod/echo-4-2pstq   0/1     Completed   0          45s

要检查日志,请执行以下操作:

$ kubectl logs --selector pod=framerender
render frame: 0
render frame: 1
render frame: 2
render frame: 3
render frame: 4

您的应用程序可以直接使用此环境变量,或者您可以使用一个init容器来获取索引并执行为主容器执行工作所需的任何配置步骤——例如,通过构建一个将要运行的脚本。

带有消息队列服务的静态队列

另一种不需要修改容器的方法是填充一个消息队列,并让每个 Pod 从该队列中提取工作。由于容器可以通过 Kubernetes 配置中的环境变量获取所需的参数,因此可以构建一个作业,其中容器对队列一无所知。它仍然是“静态”的,因为您必须提前声明有多少个任务,并为每个任务运行一个 worker Pod,但它也需要一个数据结构(即消息队列)。Kubernetes 文档使用 RabbitMQ 作为消息队列,出色地演示了这种方法。⁶

通过脚本实现的静态队列

另一个选项是使用脚本为队列中的每个任务创建一个单独的作业。基本上,如果您有 100 个任务要完成,您会设置一个脚本来遍历您的任务定义并创建 100 个单独的作业,为每个作业提供它需要的特定输入数据。这对我来说是个人最不喜欢的选项,因为它有点难以管理。想象一下,您将所有这些工作排队,然后想要取消它。与该节中所有其他示例不同,您需要删除 100 个作业,因此您可能需要更多的脚本来完成这项工作,以此类推。再次强调,Kubernetes 文档有一个很好的演示,如果您感兴趣,可以查看它。⁷

10.4 背景任务的存活性探测

就像处理 HTTP 流量的容器一样,执行任务的容器(无论配置为 Deployment 还是 Job)也应该有存活性探测。存活性探测为 Kubernetes 提供所需的信息,以便重启运行但未按预期执行(例如,进程挂起或外部依赖失败)的容器。kubelet 会自动重启崩溃的容器(除非 PodSpec 的restartPolicy字段设置为Never),但没有存活性探测,它无法知道您的进程是否挂起或未按预期执行。

在第四章,我们讨论了在 HTTP 服务工作负载的上下文中准备就绪和活动性探测。对于后台任务,我们可以忽略准备就绪,因为后台任务没有服务可以基于准备就绪添加或删除,我们只需关注活动性。与服务工作负载一样,活动性可以用来检测后台任务中的停滞或挂起容器,这样 Kubernetes 就可以重新启动它们。

由于后台任务没有 HTTP 或 TCP 端点可用于活动性探测,这留下了基于命令的探测选项。你可以指定在容器上运行的任何命令,如果它以成功退出,则容器被认为是活动的。但应该使用什么命令呢?一种方法是为任务定期将当前时间戳写入文件,然后有一个脚本检查该时间戳的新鲜度,这可以用作活动性命令。

让我们为我们的任务工作容器配置这样的活动性探测。首先,我们需要一个函数,将当前时间(作为 Unix 时间戳)写入文件。以下列表实现了这一点。

列表 10.14 第十章/pi_worker4/liveness.py

import os
import time

def update_liveness():

    timestamp = int(time.time())                     ❶
    with open("logs/lastrun.date", "w") as myfile:   ❷
      myfile.write(f"{timestamp}")                   ❷

❶ 当前时间作为 Unix 时间戳

❷ 将时间戳写入文件 logs/lastrun.date。

然后,我们需要在 worker 运行循环的各个阶段调用这个 update_liveness() 方法,以表明进程仍然处于活动状态。显然,放置它的最佳位置就在主循环中。如果你有一个非常长的运行任务,你可能还想在更多的地方添加它。以下列表显示了在 pi_worker.py 中添加此方法的位置(请参阅随书提供的源代码以获取完整文件)。

列表 10.15 第十章/pi_worker4/pi_worker.py

from liveness import *

# ...

while running:
 update_liveness() ❶
  task = r.blpop('queue:task', 5)

# ...

❶ 在主运行循环中标记任务为“活动”。

接下来,我们需要一个在容器中可以由 liveness 命令引用的 bash 脚本,以确定此时间戳的新鲜度。列表 10.16 是这样一个脚本。它接受两个参数:要读取的文件(变量 $1)和认为结果处于活动状态所需考虑的秒数(变量 $2)。它将文件内容与当前时间进行比较,如果认为时间戳是新鲜的,则返回成功(退出代码 0),如果不新鲜,则返回失败(非零退出代码)。示例用法是 ./health_check.sh logs/lastrun .date 300,如果写入 lastrun.date 文件的时间戳在当前时间的 300 秒(5 分钟)内,则返回成功。请参阅随书提供的源代码以获取包括输入验证在内的完整文件。

列表 10.16 第十章/pi_worker4/check_liveness.sh

#!/bin/bash

# ...

if ! rundate=$(<$1); then                           ❶
  echo >&2 "Failed: unable to read logfile"         ❶
  exit 2                                            ❶
fi                                                  ❶

curdate=$(date +'%s')                               ❷

time_difference=$((curdate-rundate))                ❸

if [ $time_difference -gt $2 ]                      ❹
then                                                ❹
  echo >&2 "Liveness failing, timestamp too old."   ❹
  exit 1                                            ❹
fi                                                  ❹

exit 0                                              ❺

❶ 读取时间戳文件(由输入参数 $1 指定),如果不存在则退出错误

❷ 获取当前时间戳。

❸ 比较两个时间戳

❹ 如果进程的时间戳早于阈值(参数 $2),则返回错误状态码。

❺ 返回成功退出状态。

通过任务 worker 写入时间戳和 bash 脚本来检查它,最后一步是更新我们的工作负载定义,通过livenessProbe字段添加存活探针。该字段是 PodSpec 的一部分,因此可以添加到任务 worker 的 Deployment 或 Job 版本中。以下列表将 worker Deployment 从列表 10.7 更新,以添加存活探针。

列表 10.17 第十章/10.4_ 任务存活/deploy_worker.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: pi-worker
spec:
  replicas: 2
  selector:
    matchLabels:
      pod: pi
  template:
    metadata:
      labels:
        pod: pi
    spec:
      containers:
      - name: pi-container
        image: docker.io/wdenniss/pi_worker:4   ❶
        env:
        - name: REDIS_HOST
          value: redis-0.redis-service
        - name: PYTHONUNBUFFERED
          value: "1"
 livenessProbe:              ❷
 initialDelaySeconds: 60
 periodSeconds: 30
 exec:
 command: ["./check_liveness.sh", "logs/lastrun.date", "300"]
 successThreshold: 1
 timeoutSeconds: 1
      terminationGracePeriodSeconds: 120

❶ 带有存活探针逻辑的新容器版本

❷ 新的存活探针

将所有这些放在一起,我们现在有一个检测任务 worker 中挂起进程的过程。如果 worker 未能将更新的时间戳写入文件,且在所需的新鲜度时间阈值内(示例中设置为 300 秒),存活探针命令将返回失败状态,并且 Pod 将被重启

确保您的 worker 更新这个时间戳的频率高于指定的频率。如果您有一个非常长的运行任务,要么增加新鲜度时间阈值,要么在任务处理期间多次更新时间戳文件,而不是像我们在列表 10.15 中所做的那样只在循环中更新。另一个考虑因素是确保只有在 worker 正常行为时才写入时间戳。例如,您可能不会在异常处理程序中调用update_liveness()

注意:在这个例子中,被认为是过时的(不活跃的)阈值与存活探针运行的频率(periodSeconds字段)无关。如果您需要增加阈值,请增加作为存活探针第三个值的秒数——即["./check_liveness.sh", "logs/lastrun.date", "300"]中的"300"

通过将这个存活探针配置到后台任务中,现在 Kubernetes 有了它需要的信息,以更少的干预来保持您的代码运行。

摘要

  • Kubernetes 有几种不同的选项来处理后台任务队列和批量作业。

  • 部署可以用来构建一个持续运行的任务队列,利用像 Redis 这样的队列数据结构进行协调。

  • 许多网站运行的后台处理,用于卸载计算密集型请求,通常作为部署来运行。

  • Kubernetes 还有一个专门的 Job 对象用于运行任务。

  • Jobs 可以用于一次性任务,例如手动维护任务。

  • CronJob 可以用来安排作业运行,例如,每天进行清理任务。

  • 任务可以使用来自动处理任务队列并在工作完成后自我终止,例如在运行一次性或周期性批量作业时。

  • 与基于部署的后台队列不同,Job 可以用来在静态队列上调度工作,避免需要像 Redis 这样的队列数据结构。

  • 存活检查对于处理后台任务的 Pod 仍然相关,用于检测卡住/挂起的进程,并且可以使用基于命令的存活检查进行配置。


(1.) Google/SOASTA 研究所,2017 年。www.thinkwithgoogle.com/marketing-strategies/app-and-mobile/page-load-time-statistics/

(2.) redis.io/commands/rpush

(3.) python-rq.org/

(4.) github.com/resque/resque

(5.) kubernetes.io/docs/reference/kubernetes-api/workload-resources/cron-job-v1/#CronJobSpec

(6.) kubernetes.io/docs/tasks/job/coarse-parallel-processing-work-queue/

(7.) kubernetes.io/docs/tasks/job/parallel-processing-expansion/

11 GitOps:配置即代码

本章涵盖

  • 使用命名空间和配置文件来复制环境

  • 将 Kubernetes 工作负载配置视为源代码的好处

  • 使用 Git pull 请求来驱动操作

  • 在版本控制中不存储明文形式的机密信息

你可能已经注意到,在这本书中,我们一直在编写大量的 YAML 配置文件。在不编写配置文件的情况下,你可以使用命令式 kubectl 命令(如 kubectl run)与大多数 Kubernetes 对象进行交互,这些命令的学习难度相对较低。那么,为什么我一直在使用基于声明的配置方法呢?一个原因是当你将应用程序部署到生产环境时,你可以开始将配置视为代码,使用版本控制和代码审查。

另一个原因是它允许你轻松地使用相同的配置启动多个环境。比如说,你想要一个尽可能相似的开发测试环境和生产环境,以便进行更好的测试。在你的工作负载定义在配置文件中时,可以轻松地复制这些环境。Kubernetes 的命名空间功能使得无需担心名称冲突即可实现这一点。

11.1 使用命名空间的生产和开发测试环境

当你为应用程序准备生产环境时,你可能会想要创建一个开发测试环境,以便在更新实时生产应用程序之前测试更改。Kubernetes 通过命名空间使这变得简单。

命名空间,正如其名所示,在单个逻辑空间内提供名称的唯一性。因此,你可以设置一个生产命名空间和一个开发测试命名空间,并在每个命名空间中设置 Deployment foo-deployment 和 Service foo-service(如图 11.1)。这避免了你需要过度修改配置以适应不同环境的需求,例如创建不同名称的 foo-staging-deploymentfoo-staging-service,并且提供了一些防止意外更改的保护,因为默认情况下,kubectl 命令仅应用于当前活动的命名空间。

11-01

图 11.1 一个具有两个命名空间的 Kubernetes 集群。请注意,这些命名空间中的 Deployment 和 Service 对象名称相同。

你的生产环境和开发测试环境之间的主要配置差异通常是诸如规模(副本数量)和任何未作为命名空间一部分部署的服务的外部服务凭证等事项。

提示:随着你的应用程序工作负载的复杂性或不同配置环境的数量增加,你可能希望考虑使用模板引擎,如 Kustomize 或 Helm。

要创建一个名为 staging 的命名空间以托管应用程序的新实例,你可以运行

kubectl create namespace staging

要与此命名空间交互,你可以在运行的每个kubectl命令中添加--namespace staging(或简写为-n staging)或更改kubectl上下文,以便所有命令都在这个新命名空间中运行。我强烈推荐后者,因为你不想忘记-n标志并意外在错误的命名空间中运行命令。最好每次都切换上下文。你可以使用kubectl get namespace列出可用的命名空间,然后设置上下文为你的选择:

kubectl config set-context --current --namespace=staging

当列出命名空间时,你可能注意到 Kubernetes 自带了一些命名空间。kube-system是系统 Pod 所在的地方——除非你清楚自己在做什么,否则最好不要触碰这个。default是默认用户命名空间,使用它是可以的,但我建议创建你自己的专用命名空间,每个应用环境一个。

我觉得kubectl上下文设置命令很繁琐,并强烈推荐设置你的 shell,使用一个工具使其更简单。我使用的是kubectx + kubens。¹ 安装kubens后,你可以运行kubens来列出命名空间,并使用以下命令设置上下文:

kubens staging

另一个包含的实用工具kubectx,可以用来在完全不同的集群之间快速切换。这些脚本只是较长kubectl config set-context命令的简写,所以一旦设置了上下文,你就可以像平常一样使用kubectl

11.1.1 部署到我们的新命名空间

一旦创建了命名空间,你就可以轻松地从配置中部署应用程序。这就是为什么这本书在每种情况下都使用配置文件的原因。你不必重新运行一系列强制命令来重新创建你的工作负载,你只需从包含你的配置文件的文件夹中运行以下命令:

kubectl apply -f .

如果你需要更改配置或将其部署到另一个命名空间,你只需每次重新运行该命令即可推出你的更改。

实际上,在 Kubernetes 中使用命名空间创建新环境非常简单,以至于如果你过去在其他平台上共享单个预发布环境,你可能看到拥有许多不同环境的一些好处。你可以为每个开发者或团队创建一个命名空间,一个用于预发布,另一个用于集成测试,等等。通常,命名空间是免费的(当然,复制你的 Pod 所使用的计算资源除外)。

11.1.2 从集群同步变更

但对于任何在配置之外强制执行的更改怎么办?也许你使用kubectl scale扩展了工作负载,使用kubectl set-image更改了镜像,或者使用kubectl run创建了 Deployment。这种情况是会发生的;我不会评判。

Kubernetes 允许你使用--output参数(简写为-o)在任意的get请求中查看和导出配置。例如,要获取 Deployment 的最新 YAML 配置,可以使用以下命令:

kubectl get deploy timeserver -o yaml                             ❶

kubectl get deploy timeserver -o yaml > timeserver-exported.yaml  ❷

❶ 以 YAML 格式查看 Deployment。

❷ 将 Deployment YAML 配置管道传输到文件。

但问题是 Kubernetes 添加了许多你实际上不希望在磁盘配置中出现的额外字段,比如状态消息等等。曾经有一个方便的 --export 选项可以去除这些字段,但遗憾的是它已被弃用。因此,确定哪些行可以删除以及哪些需要保留需要一点技巧。但你可以将这种方式得到的 YAML 文件与本书中的文件进行比较,以查看哪些行是重要的。

如果你计划在多个命名空间中使用配置,这是常见的,你肯定希望删除 metadata namespace 字段。移除它将允许你在当前命名空间中部署配置(保留它将意味着任何更改都会更新在指定命名空间中的对象)。我看到保持命名空间的风险是你可能会意外地将一些配置设置在生产命名空间中的预发布文件夹中。第 11.3 节讨论了关于在不同命名空间中滚动部署的一些安全策略,但它依赖于在资源对象中直接指定命名空间。

为了保持清洁,可以考虑移除的其它字段来自 metadata 部分,包括字段 uidresourceVersiongenerationcreationTimestamp 以及整个 status 部分。这些字段不会阻止你在其他命名空间或集群中重用配置,但它们在其部署上下文之外并没有实际意义,因此最好将其排除在版本控制之外,以避免混淆。

11.2 Kubernetes 方式的配置即代码

当你在源代码中遇到错误时,你可以检查版本历史记录以查看代码何时被更改(例如使用 git loggit blame),有时可能会回滚提交以回到之前的工作状态。当你将配置视为代码(通过将其提交到版本控制系统)时,你可以执行类似操作,但针对的是生产系统。

如果你有一个代码审查流程,你可以用同样的流程来审查 Kubernetes 配置。毕竟,配置对运行系统的影响和代码一样大。在配置仓库上进行代码审查可以帮助在它们被部署之前捕捉到错误。例如,如果你在配置更改中意外删除了 Deployment 的所有副本,你的同事在代码审查过程中有机会捕捉到这个错误。

你会发现这种模式在所有主要的互联网公司中都有应用。例如,大多数 Google 服务都是在单个代码仓库中开发和部署的,² 因此服务配置紧挨着代码。代码和服务配置遵循完全相同的代码审查实践,尽管可以批准合并更改的所有者(工程师)名单可能不同。

虽然没有义务像谷歌一样将配置存储在与代码相同的仓库中,但这主要是一个品味(以及无休止的技术辩论)的问题。我将在这里展示的用于在 Git 中存储 Kubernetes 配置的模型只是我找到对我有效的一个例子,但你应该根据自己的工程实践进行调整。

我使用单个 Git 仓库来表示单个集群中部署的所有 Kubernetes 对象。在这个仓库中,每个 Kubernetes 命名空间都有一个文件夹,这些文件夹中包含了命名空间中对象的 YAML 文件(图 11.2)。另一种选择是为每个命名空间使用单独的分支,这有一些很好的特性,比如能够将更改从预发布合并到生产。然而,由于可能有一些你不想合并的更改,这可能会变得混乱(例如,你不会希望不小心将仅用于预发布的更改合并到生产中)。

11-02

图 11.2 Git 仓库文件夹结构和与 Kubernetes 命名空间的关系

这里是一个示例目录布局:

/_debug        ❶
/_cluster      ❷
/staging       ❸
/production    ❸

❶ 任何你希望存储供所有开发者使用的调试脚本目录

❷ 集群配置(例如,命名空间配置文件)。这些文件仅在集群创建期间使用。

❸ 任何你希望存储供所有开发者使用的调试脚本目录

该仓库中的每个目录都映射到一个 Kubernetes 命名空间。这种 1:1 映射的优点是它允许你自信地执行kubectl apply -f .命令,将目录中的所有更改部署到活动命名空间。克隆环境就像复制整个文件夹并将其部署到其自己的命名空间一样简单。

对于较小规模的工作负载部署,共享多个环境命名空间的集群是很常见的。共享集群可以减少管理多个集群的直接成本和运营开销,并允许工作负载共享一个共同的计算资源池。随着部署规模的扩大,可能需要将环境分离到它们自己的集群中,以提供额外的访问控制和资源隔离级别(图 11.3)。好消息是配置仓库并不关心这些命名空间的位置;它们存在于不同的集群中是完全没问题的。

11-03

图 11.3 多集群环境配置的配置仓库

一旦你的配置仓库设置完成,开发过程看起来就像这样:

  1. 对所需环境的配置进行更改。

  2. 提交这些更改。

  3. 通过设置当前命名空间上下文并运行kubectl apply -f .在匹配的目录中来更新实时状态。

通过这种方式,您正在遵循配置即代码的模式,但您还可以做更多。到目前为止的设置中存在的一个危险是您可能会意外地将配置从一个文件夹滚动到错误的命名空间。接下来的几节将介绍如何安全地滚动发布并避免这个问题,以及如何提升到完整的 GitOps 流程,其中配置仓库的 git push 自动触发滚动发布。

11.3 安全地滚动发布

当您的配置作为代码仓库设置好后,现在有一个问题,那就是如何最好地在仓库中滚动更改。当然,您可以简单地检出仓库并运行 kubectl apply -f .,就像我们之前做的那样,但这可能很危险。您可能会意外地将错误的配置部署到错误的命名空间。由于我们在多个环境中重复使用对象名称,这确实可能很糟糕。此外,没有阻止您直接更改实时集群状态而不将配置更改提交到仓库。

要解决错误的命名空间问题,我建议设置一些防护措施以避免意外将错误的配置部署到错误的命名空间。我们之前简单地运行 kubectl apply -f .,现在将其包装在一个脚本中,该脚本执行检查以确保您正在部署到正确的命名空间。如果我们命名文件夹与命名空间相同,那么检查就很简单:如果当前命名空间等于文件夹名称,则部署;否则,不部署。以下列表提供了一个示例脚本,该脚本比较当前目录名称与当前命名空间,如果不匹配则退出错误状态。

列表 11.1 第十一章/gitops/gitops_check.sh

#! /bin/bash

CURRENT_DIR=`echo "${PWD##*/}"`                                      ❶
CURRENT_NAMESPACE=`kubectl config view --minify                      ❷
➥ -o=jsonpath='{.contexts[0].context.namespace}'`                   ❷

if [ "$CURRENT_DIR" != "$CURRENT_NAMESPACE" ]; then                  ❸
    >&2 echo "Wrong namespace (currently $CURRENT_NAMESPACE but" \   ❸
             "$CURRENT_DIR expected)"                                ❸
    exit 1                                                           ❸
fi                                                                   ❸

exit 0                                                               ❹

❶ 获取当前路径的最后一个目录组件。

❷ 获取当前命名空间。

❸ 如果此脚本从与命名空间名称不匹配的目录中运行,则退出错误。

❹ 否则,以成功状态退出。

然后,您可以在任何其他脚本中使用这个脚本,如下面的滚动发布脚本所示。您不会直接运行 kubectl apply -f .,而是运行这个脚本,该脚本会验证正确的目录/命名空间组合。

列表 11.2 第十一章/gitops/production/rollout.sh

#! /bin/sh

if [ $(../gitops_check.sh; echo $?) != 0 ]; then exit 1; fi   ❶

kubectl apply -f .                                            ❷

❶ 验证目录名称是否与命名空间匹配。

❷ 正常运行 kubectl apply。

第十一章/gitops 中的示例中提供了一个完整的 GitOps 文件夹结构,包括这些脚本。

当然,这并不是唯一的选择。另一种方法是在您的滚动发布脚本中设置所需的命名空间,然后进行部署。只是确保如果 set namespace 步骤失败,整个过程将退出。

虽然这些脚本可以工作,但您需要确保您的配置文件中没有直接指定 metadata namespace 字段。如果它们设置了命名空间,它将忽略当前上下文,因此脚本不会阻止该情况下的更新。

要真正遵循 GitOps 方法,你将需要添加一个额外的保证,即始终部署的配置与存储库中实际存在的配置相匹配。解决这个问题的最佳方法是完全移除人工干预,并配置一个部署管道或 GitOps 操作员。

11.3.1 部署管道

部署管道简单来说是一组基于代码存储库触发器运行的函数——例如,“当代码被推送到配置存储库时,将配置部署到 Kubernetes 集群”(图 11.4)。使用管道可以保证正在部署的配置与提交的内容相匹配。如果操作员在部署后需要做出额外的更改(例如,更正错误),他们将在配置代码存储库中像平常一样进行更改:推送更改并再次触发管道部署。

11-04

图 11.4 Kubernetes 的持续部署管道

配置好你的管道后,你可以通过将 Git 存储库中的代码合并到生产环境中来推送生产内容(即 Git 驱动的操作,或 GitOps)。关键是不直接在集群上做出任何更改;所有更改都通过配置存储库和持续部署管道进行。

11.3.2 使用 Cloud Build 进行持续部署

在实践中实现部署管道,市场上有很多产品。对于 Google Kubernetes Engine (GKE)用户来说,一个选项是 Cloud Build。你可以设置一个触发器,以便当你的配置存储库被推送到时,它会运行kubectl apply -f .

要设置它,请按照以下步骤操作:

  1. 为 Cloud Build 服务账户配置 IAM 权限³,以授予其在你的 GKE 集群上执行操作的权限。

  2. 创建一个新的触发器(设置为在配置存储库被推送到时触发)。

在你的存储库中添加一个 Cloud Build 配置文件,例如以下列表中的文件,并在触发器中引用它。

列表 11.3 第十一章/11.3.2_CloudBuild/cloudbuild-deploy.yaml

steps:
- name: 'gcr.io/cloud-builders/kubectl'
  id: Deploy
  args:
  - 'apply'
  - '-f'
  - '$FOLDER'
  env:
  - 'CLOUDSDK_COMPUTE_REGION=us-west1'
  - 'CLOUDSDK_CONTAINER_CLUSTER=my-cluster'

这只是对持续交付的表面探讨。如果你使用 Cloud Build,你可以参考优秀的“使用 Cloud Build 进行 GitOps 风格的持续交付”指南⁴,它进一步深入并设置了一个完整的端到端 CI/CD 流程。

持续校准

这里描述的方法可以通过使用 GitOps 操作员进一步改进。这是一个在集群中运行的控制系统循环,它不断将集群中运行的内容与配置存储库中存在的内容进行校准。最终结果是类似于之前描述的事件驱动管道,其优点是当出现偏差时可以执行额外的校准,而管道方法依赖于 Git 推送事件来触发。Flux (fluxcd.io/)就是这样一种 GitOps 操作员。

11.4 密钥

Git 仓库是一个存储 Kubernetes 配置的好地方,但有一些数据可能不应该存储在那里:如数据库密码和 API 密钥这样的机密值。如果这些机密被嵌入到代码本身或环境变量中,这意味着任何可以访问你的源代码的人都可以获得这些机密。一个改进的方法是,只有那些可以访问你的生产系统的人才能访问这些数据。当然,你可以更进一步,但在本章关于 GitOps 的背景下,我将专注于如何将你的机密与代码和配置仓库分离。

Kubernetes 实际上有一个用于存储机密的对象,恰当地命名为 Secrets。这些对象提供了一种方式,可以将诸如凭证和密钥等信息以与工作负载本身配置分离的方式提供给工作负载。

11.4.1 基于字符串(密码)的机密

如果你一直将密码等机密像明文环境变量一样嵌入到工作负载配置中,现在就是迁移到 Secrets 的好时机。假设我们有一个值为secret_value的机密(实际上,这可能是从你的云服务提供商那里获得的一个密钥)。我们可以将我们的secret_value封装到一个 Kubernetes Secret 对象中,如下所示。

列表 11.4 Chapter11/11.4.1_StringSecrets/secret.yaml

apiVersion: v1
kind: Secret
metadata:
  name: secrets-production
type: Opaque
stringData:
  SECRET_KEY: secret_value
  ANOTHER_KEY: another_secret_value

机密可以作为文件挂载到容器中或作为环境变量提供给 Pods。你会使用文件方法来访问作为配置文件(例如,私有 SSL 密钥)的机密数据,而使用环境变量来处理数据库密码等项。由于列表 11.4 中的机密是一个简单的字符串,我们将使用环境变量方法在下面的列表中引用它(有关基于文件的机密示例,请参阅 11.4.3 节)。

列表 11.5 Chapter11/11.4.1_StringSecrets/deploy.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: timeserver
spec:
  replicas: 1
  selector:
    matchLabels:
      pod: timeserver-pod
  template:
    metadata:
      labels:
        pod: timeserver-pod
    spec:
      containers:
      - name: timeserver-container
        image: docker.io/wdenniss/timeserver:5
        env:
        - name: AVATAR_ENDPOINT            ❶
          value: http://robohash-internal  ❶
       - name: SECRET_KEY ❷
 valueFrom: ❷
 secretKeyRef: ❷
 name: secrets-production ❷
 key: SECRET_KEY ❷

❶ 一个普通的纯文本环境变量

❷ 从机密中填充的环境变量

为了验证一切是否正确工作,请在 Chapter11/11.4.1_StringSecrets 文件夹中创建 Secret 和 Deployment 对象,并运行

$ kubectl exec deploy/timeserver -- env
SECRET_KEY=secret_value

你应该在输出中看到这个机密。我们的应用程序现在可以通过SECRET_KEY环境变量访问这个机密。

11.4.2 Base64 编码的机密

你可能会遇到 Kubernetes 文档和其他资源,它们展示了机密值以 base64 编码的形式(使用data字段而不是stringData)。这不是为了安全起见(base64 是一种编码,而不是加密),而是为了能够表示那些难以在 YAML 中定义的数据。

我通常不会默认使用 base64 编码密钥,因为我觉得这主要只是模糊了数据,并没有增加多少价值。然而,如果你有一个难以在 YAML 中表示的字符串,比如你的密钥是一个完整的文件,那么对数据进行 base64 编码是有意义的。以下列表提供了 11.4 列表中显示的 SECRET_KEY 密钥的等效 base64 编码表示。

列表 11.6 第十一章/11.4.2_Base64Secrets/secret-base64.yaml

apiVersion: v1
kind: Secret
metadata:
  name: secrets-production
type: Opaque
data:
  SECRET_KEY: c2VjcmV0X3ZhbHVlCg==

要进行编码和解码,在任何类 Unix 系统上,你可以执行以下操作:

$ echo "secret_value" | base64
c2VjcmV0X3ZhbHVlCg==

$ echo "c2VjcmV0X3ZhbHVlCg==" | base64 -d
secret_value

如果你有一些需要 base64 编码的值和一些不需要的值,你可以在同一个配置文件中包含 datastringData。你还可以在每个 Kubernetes Secret 对象中存储多个密钥(每行一个)。以下列表提供了一个示例,定义了三个密钥,其中两个使用纯文本,一个使用 base64。

列表 11.7 第十一章/11.4.2_Base64Secrets/secrets-multiple.yaml

apiVersion: v1
kind: Secret
metadata:
  name: secrets-production
type: Opaque
stringData:
  SECRET_KEY: secret_value
  ANOTHER_KEY: another_value
data:
  ENCODED_KEY: 
    VGhpcyBzdHJpbmcKbWlnaHQgYmUgaGFyZCB0byByZXByZXNlbnQgaW4gWUFNTCDwn5iFCg==

如果你通过命令行从服务器检索密钥,你会得到 base64 编码的形式,并且需要解码它们以查看纯文本值(然而,它们已经解码并提供给应用程序代码)。

我个人为每个命名空间创建一个密钥对象,每个对象包含多个密钥。然而,我将它们存储在与配置文件的其他部分分开的仓库中。在第 11.4.4 节中,我将讨论一些选项,如何在仍然使用 GitOps 方法的同时,将密钥存储在主配置仓库之外。

11.4.3 基于文件的密钥

有时你可能会处理一些密钥,你希望将它们作为文件而不是环境变量的字符串从应用程序中访问。Kubernetes 在这里也为你提供了支持。创建密钥实际上是一样的,但我将提供一个多行文本文件的全新示例,因为这种数据在 YAML 中的表示有一些细微差别。

假设我们需要存储一个私钥。以下是一个使用 openssl genrsa 256 -out example.key 生成的示例(通常你会使用 2048 位或更高的密钥,但为了简洁,我将使用 256 位):

列表 11.8 第十一章/11.4.3_ 文件密钥/example.key

-----BEGIN RSA PRIVATE KEY-----
MIGsAgEAAiEA4TneQFg/UMsVGrAvsm1wkonC/5jX+ykJAMeNffnlPQkCAwEAAQIh
ANgcs+MgClkXFQAP0SSvmJRmnRze3+zgUbN+u+rrYNRlAhEA+K0ghKRgKlzVnOxw
qltgTwIRAOfb8LCVNf6FAdD+bJGwHycCED6YzfO1sONZBQiAWAf6Am8CEQDIEXI8
fVSNHmp108UNZcNLAhEA3hHFV5jZppEHHHLy4F9Dnw==
-----END RSA PRIVATE KEY-----

该文件的数据可以用以下方式在 YAML 中表示。注意至关重要的管道字符,它将保留数据值中的行结束符。

列表 11.9 第十一章/11.4.3_ 文件密钥/secret_file.yaml

apiVersion: v1
kind: Secret
metadata:
  name: secret-files
type: Opaque
stringData:
  example.key: |
    -----BEGIN RSA PRIVATE KEY-----
    MIGsAgEAAiEA4TneQFg/UMsVGrAvsm1wkonC/5jX+ykJAMeNffnlPQkCAwEAAQIh
    ANgcs+MgClkXFQAP0SSvmJRmnRze3+zgUbN+u+rrYNRlAhEA+K0ghKRgKlzVnOxw
    qltgTwIRAOfb8LCVNf6FAdD+bJGwHycCED6YzfO1sONZBQiAWAf6Am8CEQDIEXI8
    fVSNHmp108UNZcNLAhEA3hHFV5jZppEHHHLy4F9Dnw==
    -----END RSA PRIVATE KEY-----

如果你现在已经厌倦了与 YAML 语法搏斗,你可以通过使用 cat example.key | base64 对文件数据进行 base64 编码,并像以下列表(为了可读性,数据已截断)那样表示。注意,整个 base64 字符串放在一行上(没有换行符!)。

列表 11.10 第十一章/11.4.3_ 文件密钥/secret_file_base64.yaml

apiVersion: v1
kind: Secret
metadata:
  name: secret-files
type: Opaque
data:
  example.key: LS0tLS1CRUdJTiBSU0EgUFJJVk...U0EgUFJJVkFURSBLRVktLS0tLQo=

手动创建这些密钥配置文件确实有点繁琐。一个更自动化的方法是使用 kubectl 为你创建文件。以下命令将创建相同的功能输出(注意,为了可读性,base64 字符串已截断):

$ cd Chapter11/11.4.3_FileSecrets
$ kubectl create secret generic secret-files \
      --from-file=example.key=./example.key --dry-run=client -o yaml

apiVersion: v1
data:
  example.key: LS0tLS1CRUdJTiBSU0EgUFJJVk...U0EgUFJJVkFURSBLRVktLS0tLQo=
kind: Secret
metadata:
  creationTimestamp: null
  name: secret-files

--dry-run=client -o yaml部分的意思是实际上你不会在服务器上创建机密,而是将其作为 YAML 输出(供你放入配置文件中,稍后用kubectl apply -f secret.yaml应用到服务器上)。省略--dry-run将直接在集群上创建机密(即创建 Kubernetes 对象的命令式风格)。实际上,本节中给出的每个示例都可以写成命令式的kubectl命令,但声明式、配置驱动的集群操作方法具有持久的好处,包括本章前面提到的那些。

一旦创建,你就可以将 Secret 中的所有文件挂载为容器中的一个文件夹。以下列表将我们的secret-files Secret 挂载到位置/etc/config。每个数据键都挂载为其自己的文件。在我们的例子中,只有一个:example.key

列表 11.11 第十一章/11.4.3_ 文件机密/deploy.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: timeserver
spec:
  replicas: 1
  selector:
    matchLabels:
      pod: timeserver-pod
  template:
    metadata:
      labels:
        pod: timeserver-pod
    spec:
      containers:
      - name: timeserver-container
        image: docker.io/wdenniss/timeserver:5
        volumeMounts:
        - name: secret-volume
          mountPath: "/etc/config"   ❶
          readOnly: true
      volumes:
      - name: secret-volume          ❷
        secret:                      ❷
          secretName: secret-files   ❷

❶ 文件 secrets 将被挂载的路径。

❷ 对 Secret 对象的引用

为了验证一切是否正确工作,创建 Secret 和 Deployment 对象,并使用exec列出目录。你应该看到我们的文件example.key

$ kubectl exec deploy/timeserver -- ls /etc/config
example.key

要查看文件本身,将ls命令替换为cat /etc/config/example.key。现在你可以将代码指向这个文件,就像指向系统上的任何其他文件一样。

11.4.4 Secrets 和 GitOps

使用 Secrets 只是方程的一部分。现在,你需要弄清楚如何存储它们。如果你将它们放在同一个配置仓库中,那么你实际上可以直接使用普通的环境变量,并跳过上一节中的步骤。这个问题没有银弹,但这里有一些想法,按复杂度递增的顺序列出。

独立仓库

一个简单的选择是为你的机密信息创建一个独立的配置仓库,其用户权限少于常规仓库。你仍然可以享受配置代码的所有好处(例如,代码审查、回滚等),但可以限制受众。如果你操作一个具有细粒度访问控制的仓库,你可以在该仓库的一个受控文件夹中放置机密信息。

这个仓库的一个合理位置是与你的云提供商的生产资源一起,具有与生产环境相同的安全控制。由于任何有权访问你的生产环境的人实际上已经有了这些机密,因此这种模型不会为账户被破坏的人提供任何额外的访问权限。

密封机密

Sealed Secrets⁵ 项目采用了一种有趣的方法:你在将秘密存储到 Git 之前对其进行加密(这样就没有人能读取它们),然后由集群中的控制器使用私钥进行解密。虽然你最终还是面临着存储私钥的问题,但这意味着加密的秘密可以包含在主配置仓库中,从而获得所有相关的优势,例如回滚。

秘密服务

另一个选项是运行一个独立的服务,该服务可以将秘密注入到你的集群中。HashiCorp 的 Vault⁶ 是这个概念的流行实现,如果你希望自行运行,它也是开源的。

摘要

  • 使用命名空间来区分不同的环境,如生产环境和预发布环境,以及不同的应用程序。

  • 将配置视为代码允许你轻松地复制和维护多个环境。

  • 通过将 Kubernetes 配置存储在版本控制中,就像存储代码一样(包括进行代码审查,如果你这样做的话),遵循配置即代码的方法论。

  • 不要直接对实时集群进行更改,而是首先更改配置,提交到版本控制,然后应用这些更改。

  • 可以使用部署管道在将更改提交并推送到配置仓库时自动部署这些更改。

  • 可以使用像 Flux 这样的 GitOps 操作员来提供配置仓库的持续协调。

  • 使用 Kubernetes Secrets 存储敏感信息,如数据库密钥,在单独的 Kubernetes 对象中。以限制访问的方式存储该配置。


github.com/ahmetb/kubectx

research.google/pubs/pub45424/

cloud.google.com/build/docs/securing-builds/configure-access-for-cloud-build-service-account

cloud.google.com/kubernetes-engine/docs/tutorials/gitops-cloud-build

github.com/bitnami-labs/sealed-secrets

www.vaultproject.io/

12 保护 Kubernetes

本章涵盖了

  • 保持集群更新和打补丁

  • 管理中断

  • 使用 DaemonSets 将节点代理部署到每个节点

  • 以非 root 用户运行容器

  • 使用准入控制器验证和修改 Kubernetes 对象

  • 执行 Pod 安全标准

  • 使用 RBAC 控制命名空间访问

到目前为止,这本书主要关注将不同类型的软件部署到 Kubernetes 集群中。在本章的最后,我将介绍一些保持一切安全的关键主题。安全是一个巨大的领域,Kubernetes 也不例外。如果你将代码部署到由其他团队管理的 Kubernetes 集群,那么你很幸运——你可能不需要担心这些主题中的某些。对于既负责运维又是集群操作员的开发者来说,保护集群和更新集群是一个关键责任。

除了保持集群更新、处理中断、部署节点代理和构建非 root 容器之外,本章还介绍了为开发团队创建专用命名空间的过程以及如何具体授权访问该命名空间。这是我在公司观察到的相当常见的模式,其中几个团队共享集群。

12.1 保持更新

Kubernetes 的表面面积很大。这里有运行在控制平面和用户节点上的 Linux 内核和 Kubernetes 软件。然后,还有你自己的容器以及所有依赖项,包括基础镜像。这意味着有很多东西需要保持更新并保护免受漏洞的侵害。

12.1.1 集群和节点更新

对于 Kubernetes 操作员来说,一个关键任务是确保你的集群和节点保持更新。这有助于缓解 Kubernetes 以及运行在节点上的操作系统的已知漏洞。

与本书迄今为止讨论的大多数主题不同,集群和节点的更新实际上不是 Kubernetes API 的一部分。它位于平台级别,因此你需要查阅你的 Kubernetes 平台文档。幸运的是,如果你使用的是托管平台,这应该很简单。如果你是通过在 VM 上手动安装(我不推荐这样做)来艰难地运行 Kubernetes,那么这些更新将是一个重大的负担,因为你现在是你自己的 Kubernetes 平台提供者。

更新 Google Kubernetes Engine

在 GKE 的情况下,保持更新很容易。只需注册三个发布渠道之一:稳定版、常规版或快速版。安全补丁会迅速推广到所有渠道。不同的是,你将何时获得 Kubernetes 和 GKE 平台的其他新功能。

当注册到发布渠道时,集群版本和节点都会自动保持更新。不推荐使用较老的静态版本选项,因为你需要手动跟踪更新。

12.1.2 更新容器

保持 Kubernetes 集群更新并不是你需要做的唯一更新。安全漏洞通常存在于基础镜像(如 Ubuntu)的组件中。由于你的容器化应用程序是基于这些基础镜像构建的,它可能会继承其中存在的漏洞。

解决方案是定期重建和更新你的容器,特别是如果你发现使用的基镜像中存在任何漏洞。许多开发者和企业使用漏洞扫描器(通常在已知漏洞在公共漏洞和暴露系统(CVE)中记录后被称为 CVE 扫描器)来检查构建的容器,以确定是否存在任何报告的漏洞,从而优先重建和部署。

在更新你的容器时,确保指定包含最新修复的基镜像。通常,这可以通过仅指定你使用的基镜像的次要版本而不是特定补丁版本来实现。你可以使用 latest 标签来达到这个目的,但这样可能会引入一些不希望的功能变更。

例如,以 Python 基础镜像为例。¹ 对于任何给定的 Python 版本(比如,v3.10.2),你有一系列不同的选择:3.10.2-bullseye3.10-bullseye3-bullseyebullseyebullseye 指的是它使用的 Debian 版本)。你也可以使用 latest。对于遵循语义版本控制(semver)原则的镜像,我通常会推荐使用 major.minor 版本——在这个例子中,3.10-bullseye。这允许你自动获取 v3.10 的补丁,同时避免破坏性变更。缺点是,你需要注意 3.10 的支持何时终止并进行迁移。选择主要版本(即,在这个例子中的 3-bullseye)将提供更长的支持,但会有稍微更高的破坏风险。从理论上看,使用 semver,你应该安全地使用主要版本,因为变更应该是向后兼容的,但在实践中,我发现使用次要版本更安全。使用 latest,虽然从安全角度来看很好,但由于向后不兼容的变更风险极高,通常不推荐这样做。

无论你如何配置 Dockerfile,关键原则是经常重建,引用最新的基础镜像,频繁推出工作负载的更新,并使用 CVE 扫描来查找过时的容器。为了减少应用程序容器中的潜在漏洞,进一步缓解措施是构建极其轻量级的容器,只包含运行你的应用程序及其依赖项所需的绝对最小内容。使用典型的基镜像,如 Ubuntu,包括包管理器和各种软件包,这使得生活变得容易,但也增加了漏洞表面积。你的容器中来自其他来源的代码越少,由于在该代码中发现漏洞而需要更新的次数就越少,你可能会暴露的缺陷也就越少。

在 2.1.8 节的多阶段构建中使用的 Dockerfile 通过使用一个容器来构建你的代码,另一个容器来运行代码,应用了这一原则。为了减少潜在的攻击面,关键是选择尽可能瘦的运行时基础镜像作为容器构建的第二阶段。Google 有一个开源项目 distroless²,用于帮助提供超轻量级的运行时容器。以下列表提供了 distroless 项目的构建 Java 容器示例,在第二步引用了 Google 提供的无分发镜像。

列表 12.1 https://github.com/GoogleContainerTools/distroless/tree/main/examples/java/Dockerfile

FROM openjdk:11-jdk-slim-bullseye AS build-env    ❶
COPY . /app/examples
WORKDIR /app
RUN javac examples/*.java
RUN jar cfe main.jar examples.HelloJava examples/*.class 

FROM gcr.io/distroless/java11-debian11            ❷
COPY --from=build-env /app /app
WORKDIR /app
CMD ["main.jar"]

❶ 使用常规的 OpenJDK 镜像来构建代码。

❷ 使用无分发的 Java 镜像来运行代码。

12.1.3 处理中断

在所有这些更新之后,你可能想知道你的运行工作负载会发生什么。当你更新时,Pods 被删除和重新创建是不可避免的。这显然会非常干扰那些 Pod 中运行的工作负载,但幸运的是,Kubernetes 有多种方法来减少这种干扰,并可能消除任何不良影响。

准备性检查

首先,如果你还没有设置准备性检查(就像我们在第四章中做的那样),现在是时候回去做了,因为这绝对是至关重要的。Kubernetes 依赖于你的容器报告其何时准备好,如果你不这样做,它将假设在进程开始运行的那一刻它就准备好了,这很可能是 你的应用程序完成初始化并实际上准备好服务生产流量之前。你的 Pods 被移动得越多,比如在更新期间,如果没有实施适当的准备性检查,错误请求就会更多,因为它们会击中尚未准备好的 Pods。

信号处理和优雅终止

正如就绪性检查用于确定应用程序何时准备好启动一样,优雅终止被 Kubernetes 用来知道应用程序何时准备好停止。在作业的情况下,它可能有一个需要一段时间才能完成的过程,你可能不希望简单地终止该过程,如果可以避免的话。即使是具有短暂请求的生命周期 Web 应用程序也可能遭受突然终止,导致请求失败。

为了防止这些问题,处理应用程序代码中的 SIGTERM 事件以启动关闭过程,并设置一个足够长的优雅终止窗口(通过terminationGracePeriodSeconds配置)来完成终止是很重要的。Web 应用程序应该处理 SIGTERM,在所有当前请求完成后关闭服务器,而批处理作业理想情况下应该完成他们正在执行的工作,并且不启动任何新任务。

在某些情况下,你可能有一个正在执行长时间运行的任务的作业,如果被中断,就会丢失其进度。在这些情况下,你可能会设置一个非常长的优雅终止窗口,使得应用程序接受 SIGTERM 信号,但仍然像以前一样继续尝试完成当前任务。托管平台可能对系统起源的干扰的优雅终止窗口的长度有限制。

第 10.1.2 节提供了在作业上下文中处理 SIGTERM 和terminationGracePeriodSeconds配置的示例。相同的原理适用于其他工作负载类型。

滚动更新

当你更新部署或有状态集(例如,更新基础镜像)中的容器时,滚动更新由你的滚动更新策略控制。在第四章中介绍的滚动更新是推荐的策略,通过批量更新 Pod 以最小化更新工作负载时的中断。对于部署,请确保配置部署的maxSurge参数,这将通过临时增加 Pod 副本数来进行滚动更新,这比减少副本数对可用性更安全。

Pod 中断预算

当节点更新时,这个过程不会像部署的更新那样经过相同的滚动更新过程。以下是它是如何工作的。首先,节点被隔离以防止新的 Pod 部署在其上。然后节点被排空,Pod 从这个节点删除并在另一个节点上重新创建。默认情况下,Kubernetes 将一次性从节点删除所有 Pod,并且(在 Pod 由部署等工作负载资源管理的情况下)将它们调度到其他地方。请注意,它并不是首先将它们调度到其他地方然后再删除。如果单个部署的多个副本在同一节点上运行,当它们同时被驱逐时,这可能导致不可用,如图 12.1 所示。

12-01

图 12.1 无 Pod 中断预算的节点删除。节点上的所有 Pod 将同时变得不可用。

为了解决从包含多个 Pods 的同一 Deployment 中排空节点可能会降低你的 Deployment 可用性的问题(意味着运行副本太少),Kubernetes 有一个名为 Pod Disruption Budgets(PDBs)的功能。PDBs 允许你通知 Kubernetes 你愿意让你的 Pods 中有多少或多少百分比不可用,以便你的工作负载仍然按你设计的方式运行。

列表 12.2 第十二章/12.1_PDB/pdb.yaml

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: timeserver-pdb
spec:
  maxUnavailable: 1          ❶
  selector:                  ❷
    matchLabels:             ❷
      pod: timeserver-pod    ❷

❶ 声明在故障期间可以不可用的 Pods 的最大数量

❷ 通过标签选择 Pods

将此 PDB 部署到你的集群将确保在故障期间不会有多于一个的你的 Pod 不可用,如图 12.2 所示。另一种配置使用minAvailable来设置你需要多少个副本。我更喜欢maxUnavailable,因为它与扩展配合得更好。如果你使用minAvailable,你可能需要随着副本数量的增加来调整该值,以保持所需的最低可用性,这只会增加额外的工作。

12-02

图 12.2 使用 PDB,Kubernetes 将在删除其他 Pod 之前等待部署中所需数量的 Pod 可用,从而减少中断。

注意:PDB 可以防止自愿驱逐,例如在节点升级期间,但不能防止所有可能的故障情况,例如如果节点突然失败。

使用 PDB 处理中断的过程与滚动更新避免同时删除太多 Pods 的方式有些相似。为了确保你的应用程序在由你发起的更新期间保持可用,并且由集群更新引发的中断,你需要同时配置滚动更新和 PDB。

12.2 使用 DaemonSet 部署节点代理

这本书涵盖了多种高级工作负载结构,这些结构封装了具有特定目标的 Pods,例如用于应用部署的 Deployment、用于数据库部署的 StatefulSet 以及用于周期性任务的 CronJob。DaemonSet 是另一种工作负载类型,它允许你在每个节点上运行 Pod。

你什么时候需要这些?这几乎完全是出于集群操作的原因,比如日志记录、监控和安全。作为一个应用开发者,DaemonSet 通常不是你的首选工作负载结构。由于可以在集群 IP 上内部暴露服务,你集群中的任何 Pod 都可以与任何你创建的服务通信,因此你不需要在每个节点上运行服务,只是为了在集群内部使其可用。如果你需要能够连接到 localhost 上的服务,你可以通过类型为NodePort的服务在虚拟上做到这一点。DaemonSets 通常用于当你需要在节点级别执行操作时,比如读取负载日志或观察性能,这完全属于系统管理领域。

DaemonSet 通常是如何部署日志记录、监控和安全供应商的软件。该软件执行诸如从节点读取日志并将其上传到中央日志解决方案、查询 kubelet API 以获取性能指标(如运行中的 Pod 数量、它们的启动时间等)以及安全监控容器和主机行为等操作。这些都是需要在每个节点上运行的 Pod 的示例,以便收集产品运行所需的数据。

典型的集群将运行一些在 kube-system 中的 DaemonSet,如下所示,这是一个来自 GKE 集群的简略列表,它提供了日志记录、监控和集群 DNS 等功能:

$ kubectl get daemonset -n kube-system
NAMESPACE     NAME                         
kube-system   filestore-node               
kube-system   fluentbit-gke                
kube-system   gke-metadata-server          
kube-system   gke-metrics-agent            
kube-system   kube-proxy                   
kube-system   metadata-proxy-v0.1          
kube-system   netd                         
kube-system   node-local-dns               
kube-system   pdcsi-node                   

通常,应用程序开发者不会直接创建 DaemonSet,而是会使用供应商提供的现成产品。尽管如此,以下列表是一个简单的 DaemonSet,它从节点读取日志到标准输出(stdout)。

列表 12.3 第十二章/12.2_DaemonSet/logreader.yaml

apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: logreader
spec:
  selector:
    matchLabels:
      pod: logreader-pod
  template:
    metadata:
      labels:
        ds: logreaderpod
    spec:
      containers:
      - image: ubuntu
        command:                                            ❶
        - bash                                              ❶
        - "-c"                                              ❶
        - |                                                 ❶
          tail -f /var/log/containers/*_kube-system_*.log   ❶
        name: logreader-container
        resources:
          requests:
            cpu: 50m                                        ❷
            memory: 100Mi
            ephemeral-storage: 100Mi
        volumeMounts:                                       ❸
        - name: logpath                                     ❸
          mountPath: /var/log                               ❸
          readOnly: true                                    ❸
      volumes:                                              ❹
      - hostPath:                                           ❹
          path: /var/log                                    ❹
        name: logpath                                       ❹

❶ 从节点读取并输出 kube-system 容器的日志

❷ DaemonSet 通常使用低资源请求。

❸ 将“logpath”卷挂载到 /var/log。

❹ 从主机上的 /var/log 定义“logpath”卷。

要创建 DaemonSet,请使用

$ kubectl create -f Chapter12/12.2_DaemonSet/logreader.yaml
daemonset.apps/logreader created

一旦 Pods 准备就绪,我们可以流式传输日志:

$ kubectl get pods
NAME              READY   STATUS    RESTARTS   AGE
logreader-2nbt4   1/1     Running   0          4m14s

$ kubectl logs -f logreader-2nbt4 --tail 10
==> /var/log/containers/filestore-node_kube-system_gcp-filestore-1b5.log <==
lock is held by gk3-autopilot-cluster-2sc2_e4337a2e and has not yet expired

在实践中,你可能会在部署日志记录、监控和安全解决方案时遇到 DaemonSet。

12.3 Pod 安全上下文

PodSpec 有一个 securityContext 属性,其中定义了 Pod 及其容器的安全属性。如果你的 Pod 需要执行某种管理功能(例如,它可能是执行节点级操作的 DaemonSet 的一部分),你将在这里定义它需要的各种权限。例如,以下是一个请求节点权限的 DaemonSet 中的 Pod:

列表 12.4 第十二章/12.3_PodSecurityContext/admin-ds.yaml

apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: admin-workload
spec:
  selector:
    matchLabels:
      name: admin-app
  template:
    metadata:
      labels:
        name: admin-app
    spec:
      containers:
      - name: admin-container
        image: ubuntu
 command: ["sleep", "infinity"]
 securityContext:
 privileged: true

通过这种访问权限,Pod 实际上具有 root 权限,例如,可以将节点的宿主文件系统挂载到容器中,如下所示:

$ kubectl exec -it admin-workload-px6xg -- bash
root@admin-workload-px6xg:/# df
Filesystem    1K-blocks    Used      Available   Use%   Mounted on
overlay       98831908     4652848   94162676    5%     /
tmpfs         65536        0         65536       0%     /dev
/dev/sda1     98831908     4652848   94162676    5%     /etc/hosts
shm           65536        0         65536       0%     /dev/shm
root@admin-workload-px6xg:/# mkdir /tmp/host
root@admin-workload-px6xg:/# mount /dev/sda1 /tmp/host
root@admin-workload-px6xg:/# cd /tmp/host
root@admin-workload-px6xg:/tmp/host# ls
dev_image  etc  home  lost+found  var  var_overlay  vmlinuz_hd.vblock
root@admin-workload-px6xg:/tmp/host#

如果你在没有权限的容器上尝试相同的操作,挂载将会失败。

作为在 Kubernetes 上运行的应用程序的开发者,你更有可能使用 securityContext 属性来 限制 你的 Pod 可以使用的功能,以降低风险。与前面的示例形成对比,以下是一个 PodSpec,它具有受限权限,以非 root 用户身份运行且无法提升权限。

列表 12.5 第十二章/12.3_PodSecurityContext/pod.yaml

apiVersion: v1
kind: Pod
metadata:
  name: ubuntu
  labels:
    pod: ubuntu-pod
spec:
  containers:
  - name: ubuntu-container
    image: ubuntu
    command: ["sleep", "infinity"]
 securityContext:
 runAsNonRoot: true
 runAsUser: 1001
 allowPrivilegeEscalation: false
 capabilities:
 drop:
 - ALL

默认情况下,任何 Pod 都可以自由请求它想要的任何能力,甚至 root 访问权限(除非您的 Kubernetes 平台对此进行了限制,就像一些无节点平台所做的那样)。作为集群操作员,您可能希望限制这一点,因为这基本上意味着任何拥有 kubectl 访问集群的人都有 root 权限。此外,还有一些其他推荐的原则用于强化集群,例如不作为 root 用户运行容器(这与在节点上拥有 root 权限是不同的),这在先前的示例中的runAsNonRoot: true配置中得到强制执行。

以下章节涵盖了这些主题,从如何构建容器使其不需要以 root 用户身份运行开始,以及作为集群管理员,如何强制集群用户采用此和其他期望的安全设置。

12.4 非 root 容器

部署容器时,一个常见的安全建议是以非 root 用户身份运行它们。这样做的原因是,尽管有所有这些花哨的包装,Linux 容器基本上只是应用了沙箱技术的宿主上运行的进程(如 Linux cgroups 和 namespaces)。如果您的容器被构建为使用 root 用户运行,这是默认设置,它实际上在节点上作为 root 运行,只是沙箱化。容器沙箱意味着进程没有 root 访问权限,但它仍在 root 用户下运行。问题是,尽管沙箱防止进程获得 root 访问权限,但如果由于底层 Linux 容器化技术的错误存在“容器逃逸”漏洞,沙箱化的容器进程可以获得与它运行的用户的相同权限。这意味着如果容器以 root 身份运行,容器逃逸将给节点带来完整的 root 访问权限——这并不理想。

由于 Docker 默认将所有进程作为 root 运行,这意味着任何容器逃逸漏洞都可能带来问题。虽然此类漏洞相对较少见,但它们确实存在,并且根据被称为深度防御的安全原则,最好对其进行防护。深度防御意味着尽管容器隔离可以在您的应用程序被入侵时保护主机,但理想情况下,您应该有进一步的防御层以防该保护被突破。在这种情况下,深度防御意味着以非 root 用户身份运行容器,因此即使攻击者能够突破您的容器并利用 Linux 中的容器逃逸漏洞,他们也不会在节点上获得提升的权限。他们需要串联另一个漏洞来提升他们的权限,从而形成三层防御(您的应用程序、Linux 容器化和 Linux 用户权限)。

注意:您可能想知道如果不以 root 用户运行容器进程是最佳实践,那么为什么 Docker 在构建容器时默认使用 root 用户?答案是开发者便利性。在容器中以 root 用户身份操作很方便,因为您可以使用特权端口(如默认的 HTTP 端口 80 等低于 1024 的端口),并且您不必处理任何文件夹权限问题。正如您将在本节后面看到的那样,以非 root 用户构建和运行容器可能会引入一些需要解决的错误。如果您从一开始就采用这个原则,那么在问题出现时,您可能不会觉得修复这些问题那么困难,而且回报是向您的系统添加一层额外的防御。

在 Kubernetes 中防止容器以 root 用户运行很简单,尽管(我们很快就会看到)问题在于并非所有容器都设计为以这种方式运行,可能会失败。您可以在 Kubernetes 中注释您的 Pod 以防止它们以 root 用户运行。因此,为了达到不作为 root 用户运行的目标,第一步就是简单地添加这个注释!如果您正在为更广泛的团队配置 Kubernetes 集群,或者您是该团队的一员,正在使用这样的配置集群,可以使用 Kubernetes 准入控制器自动将此注释添加到每个 Pod(见第 12.5.1 节)。最终结果是一样的,所以在这个演示中,我们只需手动添加。以下部署强制执行防止容器以 root 用户运行的最佳实践。

列表 12.6 第十二章/12.4_ 非 root 容器/1_permission_error/deploy.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: timeserver
spec:
  replicas: 1
  selector:
    matchLabels:
      pod: timeserver-pod
  template:
    metadata:
      labels:
        pod: timeserver-pod
    spec:
      containers:
      - name: timeserver-container
        image: docker.io/wdenniss/timeserver:6
 securityContext: ❶
 runAsNonRoot: true ❶

❶ 防止以 root 用户运行此容器。

不幸的是,我们还没有完成,因为容器本身没有配置非 root 用户来运行。如果您尝试创建此部署,Kubernetes 将强制执行securityContext,并阻止容器以 root 用户运行。以下是在尝试创建此部署时您将看到的截断输出。

$ kubectl get pods         
NAME                            READY  STATUS                       RESTARTS
timeserver-pod-fd574695c-5t92p  0/1    CreateContainerConfigError   0       

$ kubectl describe pod timeserver-pod-fd574695c-5t92p
Name:         timeserver-pod-fd574695c-5t92p
Events:
  Type     Reason     Age                From     Message
  ----     ------     ----               ----     -------
  Warning  Failed     10s (x3 over 23s)  kubelet  Error: container has 
 runAsNonRoot and image will run as root

要解决这个问题,您需要配置 Pod 将运行的用户。root 用户总是用户 0,所以我们只需要设置任何其他用户编号;我将选择用户 1001。这可以在 Dockerfile 中使用USER 1001声明,或者在 Kubernetes 配置中使用runAsUser: 1001。当两者都存在时,Kubernetes 配置具有优先级,类似于 Kubernetes PodSpec 中的command参数覆盖 Dockerfile 中存在的CMD。以下是 Dockerfile 选项:

FROM python:3
COPY . /app
WORKDIR /app
RUN mkdir logs
CMD python3 server.py
USER 1001

或者,您可以在 PodSpec 中通过在安全上下文部分添加一个额外的字段来指定它:

列表 12.7 第十二章/12.4_ 非 root 容器/1_permission_error/deploy-runas.yaml

# ... 
securityContext:
  runAsNonRoot: true
 runAsUser: 1001

这两种方法都有效,但我推荐你在 Kubernetes 侧进行配置,因为这样可以更好地将你的开发和生产环境分开。如果你在 Dockerfile 中指定了运行用户,并想在 Kubernetes 之外本地运行容器并尝试挂载卷,你可能会遇到像 Docker 问题#2259³这样的问题,这阻止你以非 root 用户身份挂载卷,这是一个 7 年以上的问题。由于原始的安全担忧仅与生产环境有关,为什么不让整个“以非 root 用户身份运行”的问题也归咎于生产环境呢?幸运的是,在 Docker 本地以最大便利性让容器以 root 用户身份运行,在生产环境中在 Kubernetes 中以非 root 用户身份运行,以更好地进行深度防御。

指定runAsUser: 1001就足以以非 root 用户身份运行我们的容器。只要容器能够以非 root 用户身份运行,你的任务就完成了。大多数公共、知名的容器应该被设计成以非 root 用户身份运行,但你的容器可能不是这种情况。

在我们的示例容器的情况下,它没有被设计成以非 root 用户身份运行,需要修复。以非 root 用户身份运行容器时的两个主要区别是,你不能监听特权端口(即 1 到 1023 之间的端口),并且默认情况下你没有对容器可写层的写入访问权限(这意味着默认情况下,你不能写入任何文件!)。这对于版本 6 的 Timeserver 示例应用(第十二章/timeserver6/server.py)来说是个问题,它监听端口 80 并将日志文件写入/app/logs

更新容器以以非 root 用户身份运行

如果你使用列表 12.7 中指定的runAsUser部署修订版的 Deployment,你将看到在部署时没有CreateContainerConfigError错误,但容器本身正在崩溃。当你将容器运行的用户更改为非 root 用户后,容器开始崩溃,这很可能是与该更改相关的权限错误。在你开始调试非 root 用户错误之前,确保你的容器以 root 用户身份运行良好;否则,问题可能是完全无关的。

对于以非 root 用户身份运行的容器,调试权限问题的步骤可能会有所不同,但让我们通过我们的示例应用来了解一下如何找到并修复这两个常见的错误。以下是我看到的这个崩溃容器的输出和截断日志:

$ kubectl get pods
NAME                               READY   STATUS             RESTARTS      AGE
timeserver-demo-774c7f5ff9-fq94k   0/1     CrashLoopBackOff   5 (47s ago)   4m4s

$ kubectl logs timeserver-demo-76ddf6d5c-7s9zc
Traceback (most recent call last):
  File "/app/server.py", line 23, in <module>
    startServer()
  File "/app/server.py", line 17, in startServer
 server = ThreadingHTTPServer(('',80), RequestHandler)
  File "/usr/local/lib/python3.9/socketserver.py", line 452, in __init__
    self.server_bind()
  File "/usr/local/lib/python3.9/http/server.py", line 138, in server_bind
    socketserver.TCPServer.server_bind(self)
  File "/usr/local/lib/python3.9/socketserver.py", line 466, in server_bind
    self.socket.bind(self.server_address)
PermissionError: [Errno 13] Permission denied

幸运的是,Kubernetes 中的端口问题是一个简单的修复,不会对最终用户产生影响。我们可以更改容器使用的端口,同时保持负载均衡器使用的标准端口 80。首先,让我们更新容器使用的端口。

列表 12.8 第十二章/timeserver7/server.py

//...

def startServer():
    try:
 server = ThreadingHTTPServer(('',8080), RequestHandler)
        print("Listening on " + ":".join(map(str, server.server_address)))
        server.serve_forever()
    except KeyboardInterrupt:
        server.shutdown()

if __name__== "__main__":
    startServer()

如果我们正在更改应用程序的端口,我们需要更新我们的 Kubernetes 服务配置,通过更新 targetPort 来匹配新的端口。幸运的是,我们不需要更改服务的端口号,因为服务网络粘合剂由 Kubernetes 提供,并且不以特定用户身份运行,因此它可以使用低于 1024 的端口。

列表 12.9 第十二章第 12.4 节/非 root 容器/2_fixed/service.yaml

apiVersion: v1
kind: Service
metadata:
  name: timeserver
spec:
  selector:
    pod: timeserver-pod
  ports:
  - port: 80
 targetPort: 8080  ❶
    protocol: TCP
  type: LoadBalancer 

❶ 定位新的容器端口

一旦解决了套接字问题,并重新运行应用程序,当应用程序尝试将日志写入磁盘上的日志文件时,将遇到另一个错误。这个错误并没有阻止应用程序启动,但在请求时遇到了。查看这些日志,我看到

$ kubectl logs timeserver-demo-5fd5f6c7f9-cxzrb
10.22.0.129 - - [24/Mar/2022 02:10:43] “GET / HTTP/1.1” 200 -
Exception occurred during processing of request from (‘10.22.0.129’, 41702)
Traceback (most recent call last):
  File  “/usr/local/lib/python3.10/socketserver.py”, line 683, in
    process_request_thread
    self.finish_request(request, client_address)
  File “/usr/local/lib/python3.10/socketserver.py”, line 360, in
    finish_request
    self.RequestHandlerClass(request, client_address, self)
  File “/usr/local/lib/python3.10/socketserver.py”, line 747, in
    __init__
    self.handle()
  File “/usr/local/lib/python3.10/http/server.py”, line 425, in
    handle
    self.handle_one_request()
  File “/usr/local/lib/python3.10/http/server.py”, line 413, in
    handle_one_request
    method()
  File “/app/server.py”, line 11, in do_GET
    with open(“logs/log.txt”, “a”) as myfile:
PermissionError: [Errno 13] Permission denied: ‘logs/log.txt’

如果在以非 root 身份运行并写入文件时遇到权限拒绝错误,这是您的文件夹权限没有正确设置给非 root 用户的明确迹象。

解决这个问题的最简单方法是将相关文件夹的组权限设置好。我喜欢使用组权限,因为我们可以使用相同的组(即,组 0)在本地使用 Docker 运行,并在生产中部署到 Kubernetes,而无需在 Dockerfile 中进行针对特定环境的更改。让我们更新 Dockerfile 以给予组 0 写入权限。

列表 12.10 第十二章 timeserver7/Dockerfile

FROM python:3.12
ENV PYTHONUNBUFFERED 1
COPY . /app
WORKDIR /app
RUN mkdir logs
RUN chgrp -R 0 logs \ ❶
 && chmod -R g+rwX logs ❶
CMD python3 server.py

❶ 更新日志文件夹的权限

如果您想在本地使用 Docker 以非 root 用户运行容器进行测试,然后再部署到 Kubernetes,您可以在运行时设置用户:docker run --user 1001:0 $CONTAINER_NAME

因此,这就是我们——经过修订的容器(发布为版本 7)现在作为非 root 用户愉快地运行。将第十二章第 12.4 节/非 root 容器/2_fixed 中的配置部署以查看其运行。如果您想查看为使容器和配置以非 root 身份运行所做的所有更改,请比较前后差异:

cd Chapter12
diff -u timeserver6 timeserver7
diff -u 12.4_NonRootContainers/1_permission_error \
        12.4_NonRootContainers/2_fixed

12.5 准入控制器

在上一节中,我们将 runAsNonRoot 添加到我们的 Pod 中,以防止它以 root 身份运行,但我们手动完成了这项操作。如果我们希望所有 Pods 都有这个设置,理想情况下,我们能够配置集群拒绝任何没有此配置的 Pod,或者甚至自动添加它。

这就是准入控制器发挥作用的地方。准入控制器是一段代码,当您创建一个对象时,例如使用 kubectlcreate 命令(如图 12.3),将通过 webhook 执行。它们有两种类型:验证型和突变型。验证型准入 webhook 可以接受或拒绝 Kubernetes 对象——例如,拒绝没有 runAsNonRoot 的 Pods。突变型准入 webhook 可以在对象到来时更改对象——例如,将 runAsNonRoot 设置为 true

12-03

图 12.3 被调度 Pod 的准入过程

您可以编写自己的准入控制器来实现所需的操作,但根据您希望实现的目标,您可能不需要这样做。Kubernetes 自带了一个准入控制器,并且可能还有其他商业或开源部署可用。

12.5.1 Pod 安全准入

编写准入控制器并非易事。您需要配置证书,构建一个可以作为 webhook 设置的应用程序,该 webhook 符合 Kubernetes 的请求/响应 API,并有一个开发流程来保持其与 Kubernetes 的更新,Kubernetes 更新相当频繁。好消息是,大多数开发者不需要编写自己的准入控制器。您通常会使用第三方提供的或包含在 Kubernetes 中的那些。

Kubernetes 包括可以强制执行安全策略的准入控制器,例如要求runAsNonRoot。在 Kubernetes v1.25 之前,PodSecurityPolicy 曾承担这一职责,但从未离开测试版,并且已被移除。自 Kubernetes v1.25 以来,Pod 安全准入是推荐通过准入控制器强制执行安全策略的方式。您甚至可以将它手动部署到运行较旧版本 Kubernetes 或该功能未由平台运营商启用的集群中。

Pod 安全标准

Pod 安全标准定义了三个安全策略级别,这些级别适用于命名空间级别:

  • 特权—Pod 拥有无限制的管理访问权限,并且可以获取节点的 root 访问权限。

  • 基准—Pod 不能提升权限以获得管理访问权限。

  • 受限—强制执行加固(即深度防御)的最佳实践,在基准配置文件之上添加额外的保护层,包括限制以 root 用户身份运行。

基本上,privileged权限应仅授予系统工作负载;baseline提供了安全和兼容性之间的良好平衡;而restricted则在一定程度上牺牲了兼容性,以提供更深入的安全防御,例如需要确保所有容器都能以非 root 用户身份运行,如第 12.4 节所述。

创建具有 Pod 安全的命名空间

为了与本章的运行示例保持一致并实现最安全的配置文件,让我们创建一个应用restricted策略的命名空间。这将要求 Pod 以非 root 用户身份运行,并强制执行其他几个安全最佳实践。

首先,创建一个新的命名空间并应用restricted策略。我们可以将其命名为team1,因为这个命名空间可以成为假设的team1部署代码的地方。

列表 12.11 第十二章/12.5_PodSecurityAdmission/namespace.yaml

apiVersion: v1
kind: Namespace
metadata:
  name: team1
  labels:
    pod-security.kubernetes.io/enforce: restricted
    pod-security.kubernetes.io/enforce-version: v1.28

这两个标签设置了我们要实施的策略以及将要实施的策略版本。enforce-version 标签存在,因为策略实际执行的定义可能会随着新安全风险的发现而演变。例如,你不必将特定版本固定为 v1.28,你可以指定 latest 以应用最新的策略。然而,政策在 Kubernetes 版本之间的变化可能会破坏现有的工作负载,因此建议始终选择一个特定版本。理想情况下,你会在生产环境更新 enforce-version 之前,在一个暂存命名空间或集群中测试较新的策略版本以验证它们。

让我们创建这个命名空间:

kubectl create -f Chapter12/12.5_PodSecurityAdmission/namespace.yaml
kubectl config set-context --current --namespace=team1

现在,如果我们尝试部署第三章中未设置 runAsNonRoot 的 Pod,Pod 将会被拒绝:

$ kubectl create -f Chapter03/3.2.4_ThePodSpec/pod.yaml
Error from server (Forbidden): error when creating 
"Chapter03/3.2.4_ThePodspec/pod.yaml": admission webhook
"pod-security-webhook.kubernetes.io" denied the request: pods "timeserver"
is forbidden: violates PodSecurity "restricted:v1.28":
allowPrivilegeEscalation != false (container "timeserver-container" must set
securityContext.allowPrivilegeEscalation=false), unrestricted capabilitie
 (container "timeserver-container" must set
 securityContext.capabilities.drop=["ALL"]), runAsNonRoot != true (pod or
 container "timeserver-container" must setsecurityContext.runAsNonRoot=true)

如果我们添加适当的 securityContext(见 12.12),以满足 Pod 安全性接受策略,我们的 Pod 将会被接受。同时,使用上一节中设计为以 root 用户运行的新容器也非常重要,以确保它在这些新条件下能够正确运行。

列表 12.12 Chapter12/12.5_PodSecurityAdmission/nonroot_pod.yaml

apiVersion: v1
kind: Pod
metadata:
  name: timeserver-pod
spec:
  securityContext:
    seccompProfile:
      type: RuntimeDefault
  containers:
  - name: timeserver-container
    image: docker.io/wdenniss/timeserver:7
 securityContext: ❶
 runAsNonRoot: true ❶
 allowPrivilegeEscalation: false ❶
 runAsUser: 1001 ❶
 capabilities: ❶
 drop: ❶
 - ALL ❶

❶ 受限配置文件所需的安全上下文

创建这个非 root Pod 应该现在可以成功:

$ kubectl create -f Chapter12/12.5_PodSecurityAdmission/nonroot_pod.yaml
pod/timeserver-pod created

调试 Deployments 的 Pod 接受拒绝问题

本节中的两个示例使用了独立的 Pods,而不是 Deployments。我这样做的原因是当 Pod 的接受被拒绝时更容易调试。一旦你确认它作为一个独立的 Pod 运作正常,你就可以将其 PodSpec 嵌入你选择的任何 Deployments 中。

如果你创建了一个违反安全约束的 Deployment,你不会在控制台上看到错误打印,就像我在尝试直接创建 Pod 时的例子一样。这是 Kubernetes 实现 Deployment 的一个不幸事实。创建 Deployment 对象本身是成功的,所以你不会在控制台上看到错误。然而,当 Deployment 然后尝试创建其 Pods 时,它们将会失败。此外,由于 Deployment 实际上在底层创建了一个名为 ReplicaSet 的对象来管理特定版本的 Pods,所以如果你描述 Deployment 对象而不是检查其 ReplicaSet,你甚至找不到这个错误。

我在书中还没有提到 ReplicaSet,因为它基本上是实现细节。基本上,ReplicaSet 是一个管理一组 Pods 的工作负载结构。Deployment 通过为每个部署的版本创建一个新的 ReplicaSet 来使用它们。所以当你进行滚动更新时,Deployment 实际上会有两个 ReplicaSets,

一个用于旧版本,一个用于新版本;这些版本会逐步扩展以实现滚动更新。通常,这个实现细节并不重要,这就是为什么我在书中至今没有花时间讨论它,但这里是一个例外,因为 ReplicaSet 是这个特定错误隐藏的地方。

这并不完全简单,但以下是如何调试这类问题的方法。通常,当你创建一个 Deployment 时,它将创建 Pod。如果你运行kubectl get pods,你应该看到很多 Pod。现在,这些 Pod 可能并不总是Ready——它们可能Pending的原因有很多(在某些情况下,它们可能永远卡在Pending状态,如第 3.2.3 节所述),但这些 Pod 对象通常至少会存在一些状态。如果你调用kubectl get pods却看不到你的 Deployment 的任何 Pod 对象,这可能意味着这些 Pod 在准入过程中被拒绝,这就是为什么没有 Pod 对象的原因。

由于是 Deployment 拥有的 ReplicaSet 实际创建了 Pod,你需要使用kubectl describe replicaset(简称kubectl describe rs)来描述 ReplicaSet 以查看错误。以下是一个示例,输出被截断以显示感兴趣的错误消息:

$ kubectl create -f Chapter03/3.2_DeployingToKubernetes/deploy.yaml
deployment.apps/timeserver created

$ kubectl get deploy
NAME         READY   UP-TO-DATE   AVAILABLE   AGE
timeserver   0/3     0            0           12s

$ kubectl get pods
No resources found in myapp namespace.
$ kubectl get rs
NAME                   DESIRED   CURRENT   READY   AGE
timeserver-5b4fc5bb4   3         0         0       31s

$ kubectl describe rs
Events:
  Type     Reason        Age                  From                   Message
  ----     ------        ----                 ----                   -------
  Warning FailedCreate 36s             replicaset-controller
  Error creating: admission webhook "pod-security-webhook.kubernetes.io"
  denied the request: pods "timeserver-5b4fc5bb4-hvqcm" is forbidden:
  violates PodSecurity "restricted:v1.28": allowPrivilegeEscalation != false
  (container "timeserver-container" must set
  securityContext.allowPrivilegeEscalation=false), unrestricted capabilities
  (container "timeserver-container" must set
  securityContext.capabilities.drop=["ALL"]), runAsNonRoot != true (pod or
  container "timeserver-container" must set
  securityContext.runAsNonRoot=true)

当你完成时,你可以按照以下方式删除此命名空间及其所有资源:

$ kubectl delete ns team1
namespace "team1" deleted

12.5.2 在安全性与兼容性之间取得平衡

在上一节中,我们使用了受限Pod 安全配置文件的例子,并配置了我们的容器以非 root 用户身份运行。希望这已经给了你信心,能够以高度安全的方式运行容器。虽然这是最佳实践,并且在像受监管行业这样的情况下可能需要,但它与开发便利性之间存在明显的权衡,并且可能并不总是实际可行。最终,这取决于你、你的安全团队,也许还有你的监管机构,来决定你满意的哪个安全配置文件。我并不一定建议将每个 Kubernetes 工作负载都放入具有受限配置文件的命名空间中。我确实建议你为你在集群中部署的每个非管理性工作负载使用基准配置,因为它有助于保护你的集群,以防万一你的某个容器被入侵,而且不应与普通应用程序造成任何不兼容性。需要特权配置文件的管理性工作负载应在自己的命名空间中运行,与普通工作负载分开。

12.6 基于角色的访问控制

假设你需要 Pod 以非 root 身份运行(第 12.4 节)并设置一个准入控制器,使用 Pod 安全准入(第 12.5 节)来强制执行此要求。这听起来很棒,前提是你信任你的集群的所有用户不会搞砸任何事情并移除这些限制,无论是意外还是故意。为了实际强制执行准入控制器的要求并创建一个分层用户权限设置,包括平台操作员角色(可以配置命名空间和控制器)和开发者角色(可以部署到命名空间,但不能移除准入控制器),你可以使用基于角色的访问控制(RBAC)。

RBAC 是一种控制集群用户访问权限的方式。一种常见的设置是为团队中的开发者提供对集群中特定命名空间的访问权限,并配置所有所需的 Pod 安全策略。这给了他们在命名空间内部署他们喜欢的内容的自由,前提是它符合已设定的安全要求。这种方式仍然遵循 DevOps 原则,因为开发者是进行部署的人,只是有一些安全措施。

RBAC 通过两个 Kubernetes 对象类型在命名空间级别进行配置:Role 和 RoleBinding。Role 是你为命名空间定义特定角色的地方,如开发者角色。RoleBinding 是你将此角色分配给集群中的主体(即你的开发者身份)的地方。还有集群级别的版本,ClusterRole 和 ClusterRoleBinding,它们的行为与其命名空间级别的对应物相同,只是它们在集群级别授予访问权限。

命名空间角色

在 Role 中,你指定 API 组(s)、该组内的资源(s)以及你授予访问权限的动词(s)。访问是累加的(没有减法选项),所以你定义的所有内容都授予访问权限。由于我们的目标是创建一个 Role,让开发者能够访问他们命名空间内的几乎所有内容 除了 修改命名空间本身和删除 Pod 安全注释,以下列表是一个可以达成此目标的 Role。

列表 12.13 第十二章/12.6_RBAC/role.yaml

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: developer-access
  namespace: team1
rules:
  - apiGroups:
    - ""                      ❶
    resources:
    - namespaces              ❷
    verbs: ["get"]            ❷
  - apiGroups:                ❸
    - ""                      ❸
    resources:                ❸
    - events                  ❸
    - pods                    ❸
    - pods/log                ❸
    - pods/portforward        ❸
    - services                ❸
    - secrets                 ❸
    - configmaps              ❸
    - persistentvolumeclaims  ❸
    verbs: ["*"]              ❸
  - apiGroups:
    - apps                    ❹
    - autoscaling             ❺
    - batch                   ❻
    - networking.k8s.io       ❼
    - policy                  ❽
    resources: ["*"]
    verbs: ["*"]

❶ 这里的空字符串表示核心 API 组。

❷ 允许开发者查看命名空间资源但不能编辑它

❸ 授予开发者对核心工作负载类型的完全访问权限

❹ apps 包括像 Deployment 这样的资源。

❺ autoscaling 包括像 HorizontalPodAutoscaler 这样的资源。

❻ batch 包括 Job 工作负载。

❼ networking.k8s.io 是必需的,以便开发者可以配置 Ingress。

❽ policy 是配置 PodDisruptionBudgets 所必需的。

此角色授予对 team1 命名空间的访问权限,并允许用户在核心 API 组中修改 Pods、Services、Secrets 和 ConfigMaps,以及在 apps、autoscaling、batch、networking.k8s.io 和 policy 组中的所有资源。这个特定的权限集将允许开发者部署本书中的几乎所有 YAML 文件,包括 Deployment、StatefulSet、Service、Ingress、Horizontal Pod Autoscaler 和 Job 对象。重要的是,namespaces 资源未列在核心 API 组中(这是与空字符串 "" 列出的组),因此用户将无法修改命名空间。

一旦 Role 存在,为了将此 Role 授予我们的开发者,我们可以使用 RoleBinding,其中主体是我们的用户。

列表 12.14 第十二章/12.6_RBAC/rolebinding.yaml

kind: RoleBinding
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  name: developerA
  namespace: team1
roleRef:
  kind: Role
  name: developer-access         ❶
  apiGroup: rbac.authorization.k8s.io
subjects:
# Google Cloud user account
- kind: User
  name: example@gmail.com        ❷

❶ 引用列表 12.13 中的 Role

❷ 将此设置为开发者的身份标识。对于 GKE,这是一个具有 Kubernetes Engine Cluster Viewer IAM 角色访问权限的 Google 用户。

注意,User主题中可接受的价值受您的 Kubernetes 平台和您配置的任何身份系统管理。使用 Google Cloud,这里的名称可以是任何 Google 用户,通过他们的电子邮件地址引用。RBAC 授权用户执行角色中指定的操作。然而,用户还需要能够对集群进行认证。在 Google Cloud 的例子中,这是通过将 Kubernetes Engine 集群查看器等角色分配给用户来实现的。此角色包括container.clusters.get权限,允许用户在不实际在集群内部获得任何权限的情况下对集群进行认证(允许您使用 RBAC 配置精细的权限)。这里的具体步骤将根据您的平台提供商而有所不同。

认证与授权的区别

认证(AuthN)是用户向系统展示其身份凭证的手段。在这种情况下,能够对集群进行认证意味着用户可以通过kubectl检索到访问集群的凭证。授权(AuthZ)是在集群内授予用户访问权限的过程。根据您的平台 IAM 系统的不同,应该可以允许用户对集群进行认证(例如,获取使用kubectl的凭证),但实际上无法执行任何操作(没有授权)。然后您可以使用 RBAC 授予您想要的精确授权。在 GKE 的例子中,在 IAM 权限(在 Kubernetes 之外)中授予用户 Kubernetes Engine 集群查看器角色,将允许他们进行认证,之后您可以使用 RBAC 和这里展示的示例授权他们访问特定的资源。再次强调,根据您的特定 Kubernetes 平台,就像 GKE 的情况一样,某些 IAM 角色可能会授予用户除了您在这里设置的 RBAC 规则之外的某些资源的授权。在 GKE 中,项目级查看器角色就是这样一个例子,它将允许用户无需特定的 RBAC 规则即可查看集群中的大多数资源。

作为集群管理员,创建命名空间和这两个对象:

$ cd Chapter12/12.6_RBAC/
$ kubectl create ns team1
namespace/team1 created
$ kubectl create -f role.yaml 
krole.rbac.authorization.k8s.io/developer-access created
$ kubectl create -f rolebinding.yaml 
rolebinding.rbac.authorization.k8s.io/developerA created

在集群中部署了此角色和绑定后,我们的开发者用户应该能够在team1命名空间中部署本书中的大多数代码,但具体不能更改任何其他命名空间或编辑team1命名空间本身。为了进行有意义的实验,您需要在 RoleBinding 中将实际用户设置为User主题——例如,一个测试开发者账户)。

要验证 RBAC 是否配置正确,通过作为subjects字段中指定的用户对集群进行认证来切换到测试开发者账户。一旦以我们的开发者用户身份认证,尝试在默认命名空间中部署某些内容,它应该会失败,因为没有授予任何 RBAC 权限:

$ kubectl config set-context --current --namespace=default
$ kubectl create -f Chapter03/3.2_DeployingToKubernetes/deploy.yaml
Error from server (Forbidden): error when creating 
"Chapter03/3.2_DeployingToKubernetes/deploy.yaml": deployments.apps is 
forbidden: User "example@gmail.com" cannot create resource "deployments" in
API group "apps" in the namespace "default": requires one of
["container.deployments.create"] permission(s).

切换到 team1 命名空间,我们之前已经为这个测试用户配置了该角色,现在我们应该能够创建 Deployment:

$ kubectl config set-context --current --namespace=team1
Context "gke_project-name_us-west1_cluster-name" modified.
$ kubectl create -f Chapter03/3.2_DeployingToKubernetes/deploy.yaml
deployment.apps/timeserver created

虽然这个开发者现在可以在命名空间中部署东西,但如果他们尝试编辑命名空间以获得特权 Pod 安全级别,他们将受到对命名空间资源编辑权限不足的限制:

$ kubectl label --overwrite ns team1 pod-security.kubernetes.io/enforce=privileged
Error from server (Forbidden): namespaces "team1" is forbidden: User
"example@gmail.com" cannot patch resource "namespaces" in API group "" in
the namespace "team1": requires one of ["container.namespaces.update"]
permission(s).

集群角色

到目前为止,我们已经设置了一个 Role 和 RoleBinding,以授予开发者访问特定命名空间的权限。有了这个 Role,他们可以部署本书中的大部分配置。然而,他们无法做到的是创建 PriorityClass(第六章)、创建 StorageClass(第九章)或列出集群中的 PersistentVolumes(第九章)。这些资源被认为是集群级别的对象,因此我们不能修改之前创建的命名空间特定的 Role 来授予该权限。相反,我们需要一个单独的 ClusterRole 和 ClusterRole binding 来授予额外的访问权限。

确定要授予的权限

我在这里做了工作,提供了一个涵盖所有部署书中代码所需权限的 Role 定义,但可能还有其他缺失的权限,您需要在您自己的工作负载部署的上下文中授予开发者。为了确定您需要授予哪些组、资源和动词,您可以查阅 API 文档。当调试权限错误时——比如说开发者抱怨他们没有所需的访问权限——您可以简单地检查错误信息。考虑以下示例:

$ kubectl create -f Chapter06/6.3.2_PlaceholderPod/placeholder-priority.yaml
Error from server (Forbidden): error when creating "placeholder-priority.yaml":
priorityclasses.scheduling.k8s.io is forbidden: User "example@gmail.com"
cannot create resource "priorityclasses" in API group "scheduling.k8s.io"
at the cluster scope: RBAC: clusterrole.rbac.authorization.k8s.io
"developer-cluster-access" not found requires one of
["container.priorityClasses.create"] permission(s).

要将此权限添加到 Role 中,我们可以看到组是 scheduling.k8s.io,资源是 priorityClasses,动词是 create,RBAC 范围是 clusterrole。因此,在 ClusterRole 定义中添加一个具有这些值的规则。

下一个列表显示了一个 ClusterRole,以提供创建 StorageClass 和 PriorityClass 对象所需的其他权限。

列表 12.15 第十二章/12.6_RBAC/clusterrole.yaml

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: developer-cluster-access
rules:
- apiGroups:               ❶
  - scheduling.k8s.io      ❶
  resources:               ❶
  - priorityclasses        ❶
  verbs: ["*"]             ❶
- apiGroups:               ❷
  - storage.k8s.io         ❷
  resources:               ❷
  - storageclasses         ❷
  verbs: ["*"]             ❷
- apiGroups:               ❸
  - ""                     ❸
  resources:               ❸
  - persistentvolumes      ❸
  - namespaces             ❸
  verbs: ["get", "list"]   ❸

❶ 授予开发者修改集群中所有 PriorityClasses 的访问权限

❷ 授予开发者修改集群中所有 StorageClasses 的访问权限

❸ 授予开发者只读访问权限以查看和列出 PersistentVolumes 和 Namespaces

下一个列表显示了 ClusterRoleBinding,将其绑定到我们的测试用户,这与之前使用的 RoleBinding 非常相似。

列表 12.16 第十二章/12.6_RBAC/clusterrolebinding.yaml

kind: ClusterRoleBinding
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  name: developerA
  namespace: team1
roleRef:
  kind: ClusterRole                    ❶
  name: developer-cluster-access       ❶
  apiGroup: rbac.authorization.k8s.io
subjects:
- kind: User
  name: example@gmail.com              ❷

❶ 引用了列表 12.15 中的 ClusterRole

❷ 将此设置为开发者的身份。对于 GKE,这是一个具有 Kubernetes Engine 集群查看 IAM 角色访问权限的 Google 用户。

通过这些额外的集群角色和绑定,我们的开发者应该能够执行本书中的每一个操作。

身份联合

为了使 RBAC 能够将你的开发人员身份作为用户和组进行引用,你的集群需要了解如何验证你的开发人员的身份。在 GKE 的情况下,当启用 RBAC 的 Google Groups 功能时,它能够原生地理解用户字段中的 Google 用户以及 Google 组。根据你的平台和你的企业身份提供者,你可能已经拥有了类似的访问权限,或者你可能需要设置它。这种设置超出了本书的范围,但你可能考虑配置 OpenID Connect(OIDC)集成,以便 RBAC 可以引用你的身份系统提供的身份。

此外,当使用提供组支持的标识系统插件时,你不需要列出每个用户作为我们的角色绑定主体,而可以指定一个单一的组。

应用 Pod 安全配置文件

本节中的命名空间是在没有使用 Pod 安全的情况下创建的。如果我们回到配置命名空间,使用 12.5 节中的 Pod 安全标签,它将锁定此命名空间到受限的 Pod 安全配置文件,并且由于 RBAC 的存在,我们的开发人员将无法修改这个限制。任务完成。

ServiceAccounts 的 RBAC

在本节中的示例中,我们使用了基于用户的 RBAC(基于角色的访问控制),因为我们的开发人员是集群的实际人类用户。RBAC 的另一个常见用例是授予对服务的访问权限——即在集群中运行的代码。

假设你有一个 Pod 属于集群中的 Deployment,该 Deployment 需要访问 Kubernetes API——比如说,它在监控另一个 Deployment 的 Pod 状态。为了给这个机器用户授予访问权限,你可以创建一个 Kubernetes ServiceAccount,然后在你的 RBAC 绑定主体中引用它,而不是用户。

你可能会看到一些为人类用户设置 ServiceAccounts 的文档,其中用户随后下载服务账户的证书以与 Kubernetes 交互。虽然这是一种配置开发人员并绕过设置身份联合的需要的办法,但它不建议这样做,因为它位于你的身份系统之外。例如,如果开发人员离职并且他们的账户在身份系统中被暂停,他们为 ServiceAccount 下载的令牌仍然有效。更好的做法是正确配置身份联合,并且只为人类用户使用用户主体,这样如果用户在身份系统中被暂停,他们的 Kubernetes 访问也将被撤销。再次强调,像 Google Cloud 这样的托管平台使这种集成变得简单;对于其他平台,你可能需要做一些设置才能使其工作。

Kubernetes ServiceAccounts 旨在当你有一个例如集群内的 Pod 需要对其自己的 Kubernetes API 进行访问时使用。比如说,你想创建一个 Pod 来监控另一个 Deployment。你可以创建一个 ServiceAccount 作为 RoleBinding 的主题,并将该服务账户分配给 Pod。然后,Pod 可以在进行 API 调用时利用这些凭据,包括使用 kubectl。

12.7 下一步

Pod 安全准入可以用来控制 Pod 在节点上的权限,而 RBAC 管理用户可以在集群中管理哪些资源。这是一个好的开始;然而,如果你需要在网络和容器级别进行进一步隔离,你还可以做更多。

12.7.1 网络策略

默认情况下,每个 Pod 都可以与集群中的其他所有 Pod 通信。这很有用,因为它允许在不同命名空间中工作的团队共享服务,但这也意味着 Pod,包括可能被破坏的 Pod,可以访问其他内部服务。为了控制网络和其他 Pod 的流量,包括限制命名空间中的 Pod 访问其他命名空间的 Pod 的能力,你可以配置网络策略。⁵

网络策略的工作方式是,如果没有网络策略应用于 Pod(通过选择它),则允许所有流量(默认情况下没有网络策略,因此允许所有流量)。然而,一旦网络策略选择了 Pod 进行入站或出站流量,则除了你明确允许的流量之外,所有选择方向的流量都将被拒绝。这意味着要拒绝特定目的地的出站流量,你需要构建一个详尽的允许列表,这需要理解 Pod 的要求。

例如,为了限制对其他命名空间中 Pod 的流量,你可能创建一个规则来允许命名空间内的流量和对公共互联网的流量。由于这样的规则集省略了其他命名空间的 Pod,因此该流量将被拒绝,从而实现目标。

网络策略的这一特性,即拒绝除你明确允许的所有流量之外的所有流量,意味着你需要仔细研究所需的访问权限(包括可能的一些特定平台要求),并且可能需要一些尝试和错误才能正确设置。我在wdenniss.com/networkpolicy上发布了一系列关于这个主题的文章,可以帮助你开始。

12.7.2 容器隔离

容器化提供了一定程度的进程与节点的隔离,Pod 安全准入允许您限制容器拥有的访问权限,但有时会出现所谓的容器逃逸漏洞,这可能导致进程获得节点级访问权限。可以在容器和主机之间添加额外的隔离层,以提供比仅容器化更多的深度防御。这种隔离通常伴随着性能损失,这也是为什么您通常看不到默认配置的原因。如果您在集群中运行不受信任的代码,例如,在多租户系统中,用户提供自己的容器,那么您几乎肯定需要一个额外的隔离层。

您可以通过定义一个安全的 RuntimeClass 来配置 Pod 以实现额外的隔离。⁶一个流行的选择是由 Google 开发和开源的 gVisor⁷,它实现了 Linux 内核 API 并拦截容器和系统内核之间的系统调用,以提供一个隔离的沙盒。

12.7.3 集群加固

我希望这一章在您开发和部署应用程序到 Kubernetes 时提供了一些实际的安全考虑,并且可能发现自己正在操作具有 RBAC 权限和受限准入规则(如运行非 root 容器)的集群。对于集群管理员来说,加固集群及其操作环境(如网络、节点和云资源)的更广泛主题是一个漫长的过程,其中许多考虑因素都特定于您选择的精确平台。

我建议阅读最新的加固信息,并搜索“Kubernetes 加固指南”。由于许多内容取决于您的特定操作系统环境,一个好的起点是阅读您特定平台的加固指南,例如来自 GKE 的加固您的集群安全⁸。安全领域不断演变,因此请确保通过权威来源了解最新的最佳实践。

摘要

  • 保持您的集群及其节点更新对于减轻安全漏洞至关重要。

  • Docker 基本镜像也引入了他们自己的攻击面,需要监控和更新已部署的容器,CI/CD 系统可以帮助解决这个问题。

  • 使用尽可能小的基本镜像可以帮助减少攻击面,降低应用程序更新的频率,以减轻安全漏洞。

  • DaemonSets 可用于在每个节点上运行 Pod,通常用于在集群中配置日志记录、监控和安全软件。

  • Pod 安全上下文是配置 Pod 以具有提升或受限权限的方式。

  • 准入控制器可用于在创建 Kubernetes 对象时进行更改并强制执行要求,包括 Pod 安全上下文。

  • Kubernetes 附带了一个名为 Pod Security admission 的准入控制器,允许您强制执行安全配置文件,如基线,以减轻大多数已知攻击,以及限制,以在 Pod 上强制执行安全最佳实践。

  • RBAC 是一种基于角色的权限系统,允许具有集群管理员角色的用户向系统中的开发者授予细粒度的访问权限,例如限制一个团队访问特定的命名空间。

  • 默认情况下,Pod 可以与集群中的所有 Pod 进行通信。可以使用网络策略来控制 Pod 的网络访问。

  • 为了提供另一层隔离,尤其是如果您在集群中运行不受信任的代码,请应用一个如 gVisor 的 RuntimeClass。

  • 请查阅您平台上的 Kubernetes 加固指南,以获取全面和针对特定平台的安全考虑。


^(1.)hub.docker.com/_/python

^(2.)github.com/GoogleContainerTools/distroless

^(3.)github.com/moby/moby/issues/2259

^(4.)kubernetes.io/docs/concepts/security/pod-security-standards/

^(5.)kubernetes.io/docs/concepts/services-networking/network-policies/

^(6.)kubernetes.io/docs/concepts/containers/runtime-class/

^(7.)gvisor.dev/

^(8.)cloud.google.com/kubernetes-engine/docs/how-to/hardening-your-cluster


  1. 1 ↩︎

posted @ 2025-11-16 08:57  绝不原创的飞龙  阅读(3)  评论(0)    收藏  举报