Kubernetes-生产指南-全-
Kubernetes 生产指南(全)
原文:
zh.annas-archive.org/md5/3b49b762795988314fbf3277344c6b45译者:飞龙
序言
Kubernetes 自公开发布以来已经超过六年了。我参与了初始阶段,并且实际上提交了 Kubernetes 项目的第一个提交。(这并没有听起来那么令人印象深刻!那只是一个为公开发布创建干净代码库的维护任务。)我可以自信地说,Kubernetes 取得的成功是我们所希望但并不完全预料到的。这一成功基于一个大型社区的奉献和欢迎贡献者,以及一群能够弥合与现实世界之间鸿沟的实践者。
我很幸运能够与《生产 Kubernetes》的作者合作,他们在我与共同创立的初创公司(Heptio),致力于将 Kubernetes 带入典型企业的使命中,留下了深刻的印象。Heptio 的成功很大程度上归功于我的同事们努力与使用 Kubernetes 解决实际问题的真实用户直接联系。我对每一位同事都心怀感激。这本书捕捉了这种实地经验,为团队提供了在生产环境中真正让 Kubernetes 发挥作用所需的工具。
我的整个职业生涯都致力于构建面向应用团队和开发者的系统。从 Microsoft Internet Explorer 开始,然后是 Windows Presentation Foundation,再到云端的 Google Compute Engine 和 Kubernetes。我一次又一次地看到那些构建平台的人遭受我所称的“平台建设者的诅咒”。构建平台的人通常关注更长时间的视角和构建一个有望持续数十年的基础的挑战。但是这种关注会使他们忽视用户此刻正在遇到的问题。我们常常忙于构建某事物,却没有时间和问题去真正使用我们正在构建的东西。
打败平台建设者诅咒的唯一方法是积极寻求来自我们平台建设者泡泡之外的信息。这就是 Heptio 田野工程团队(后来是 VMware Kubernetes 架构团队—KAT)为我所做的事情。除了帮助各行各业的广泛客户成功使用 Kubernetes 外,该团队还是实际应用我们平台“理论”的关键窗口。
Kubernetes 周围建立起来的繁荣生态系统和云原生计算基金会(CNCF)进一步加剧了这一问题。这包括 CNCF 的项目以及更大范围内的项目。我将这个生态系统描述为“美丽的混沌”。这是一个具有不同重叠程度和成熟度的项目丛林。这就是创新的样子!但是,就像探索丛林一样,探索这个生态系统需要投入时间和精力,并伴随着风险。对于 Kubernetes 的新用户来说,通常没有时间或能力成为更大生态系统的专家。
Production Kubernetes 绘制了该生态系统的各个部分,展示了在什么情况下使用单个工具和项目是合适的,并演示了如何评估适合读者面临问题的正确工具。这些建议不仅仅是告诉读者使用特定工具,它是一个更大的框架,用于理解一类工具解决的问题,知道是否存在这个问题,熟悉不同方法的优缺点,并提供实际建议来启动。对于那些希望将 Kubernetes 应用于生产环境的人来说,这些信息非常宝贵!
总结一下,我想对 Josh、Rich、Alex 和 John 表示衷心的“谢谢”。他们的经验直接帮助了许多客户取得成功,让我对我们六年前开始的事情有了更多的了解,现在通过这本书,将为无数更多的用户提供关键建议。
Joe Beda
VMware Tanzu 的首席工程师,Kubernetes 的联合创始人,
西雅图,2021 年 1 月
前言
Kubernetes 是一项非常强大的技术,并且在流行度上迅速崛起。它已经成为我们管理软件部署方式上真正进步的基础。在 Kubernetes 出现之时,虽然 API 驱动的软件和分布式系统已经得到了确立,但尚未被广泛采用。它出色地实现了这些原则,这些原则是其成功的基石,但它也带来了一些至关重要的其他东西。在不久的过去,只有拥有最杰出工程团队的大型科技公司才能实现自动收敛于声明的期望状态的软件。如今,由于 Kubernetes 项目,高可用、自我修复、自动扩展的软件部署对每个组织来说都是可实现的。在我们面前有一个未来,软件系统能接受我们的广泛高级指令,并执行它们以提供预期的结果,通过发现条件、应对变化的障碍和自动修复问题,无需我们干预。而且,这些系统比我们手工操作更快速、更可靠。Kubernetes 已经让我们离这个未来更近了一步。然而,这种强大和能力是以一些额外复杂性为代价的。我们决定撰写这本书,是因为我们希望分享我们的经验,帮助他人应对这种复杂性。
如果你想要使用 Kubernetes 构建一个符合生产标准的应用平台,你应该阅读这本书。如果你正在寻找一本帮助你入门 Kubernetes 或者关于 Kubernetes 工作原理的书籍,那么这本书不适合。关于这些主题有大量信息可以在其他书籍、官方文档以及无数的博客文章和源代码中找到。我们建议你在阅读本书的同时进行自己的研究和测试,以验证我们讨论的解决方案,因此我们很少深入进行逐步教程样式的例子。我们尽量涵盖必要的理论,并把大部分实现留给读者自己来练习。
在这本书中,你将会找到关于选项、工具、模式和实践的指导。重要的是,理解作者如何看待构建应用平台的实践。我们是工程师和架构师,被派往许多财富 500 强公司,帮助他们从构想到生产的平台愿景。自从 Kubernetes 在 2015 年达到1.0版本以来,我们就把它作为实现这一目标的基础。我们尽可能地专注于模式和哲学,而不是工具,因为新的工具出现得比我们写书更快!然而,我们不可避免地必须用当下最合适的工具来演示这些模式。
我们已成功引导团队通过云原生之旅,彻底改变他们构建和交付软件的方式。话虽如此,我们也曾经历失败。失败的一个常见原因是组织对 Kubernetes 能解决什么存在误解。这就是为什么我们早期深入探讨这个概念的原因。在此期间,我们发现几个领域对我们的客户尤其有趣。帮助客户在他们通往生产环境的道路上更进一步,甚至帮助他们定义这条道路的对话已成常态。这些对话变得如此普遍,以至于我们决定也许是时候写一本书了!
尽管我们一次又一次地与组织一同走向生产,但其中有一个关键的一致性特征。这就是,无论我们多么希望如此,道路永远不会看起来相同。基于此,我们想要设定一个期望:如果你打算通过这本书找到通往生产环境的“5 步计划”或“每个 Kubernetes 用户应该知道的 10 件事”,你将会感到沮丧。我们在这里讨论许多决策点和我们见过的陷阱,并在适当时用具体的例子和轶事加以支持。最佳实践确实存在,但必须始终从实用主义的角度来看待。没有一种适合所有情况的方法,“这取决于情况”是你将不可避免地面对的许多问题的完全有效的答案。
话虽如此,我们强烈建议你挑战本书!在与客户合作时,我们总是鼓励他们挑战和完善我们的指导。知识是流动的,我们始终根据新特性、信息和约束更新我们的方法。你应该继续这种趋势;随着云原生空间的不断发展,你肯定会决定采取与我们推荐的不同路径。我们在这里告诉你我们曾经走过的路,这样你就可以权衡我们的观点和你自己的观点。
本书中使用的约定
本书中使用以下排版约定:
Italic
表示新术语、网址、电子邮件地址、文件名和文件扩展名。
Constant width
用于程序清单,以及在段落内引用程序元素,如变量或函数名、数据库、数据类型、环境变量、语句和关键字。
Constant width bold
显示用户应按字面意义输入的命令或其他文本。
Constant width italic
显示应由用户提供的值或由上下文确定的值替换的文本。
Kubernetes 的类型名称采用大写,如 Pod、Service 和 StatefulSet。
提示
此元素表示提示或建议。
注意
此元素表示一般提示。
警告
此元素表示警告或注意。
使用代码示例
补充材料(代码示例、练习等)可在 https://github.com/production-kubernetes 下载和讨论。
如果您有技术问题或使用代码示例时遇到问题,请发送电子邮件至 bookquestions@oreilly.com。
这本书旨在帮助您完成工作。一般情况下,如果书中提供了示例代码,您可以在自己的程序和文档中使用它们,无需事先联系我们获得许可,除非您要复制代码的大部分。例如,编写一个使用本书中多个代码片段的程序不需要许可。销售或分发从 O’Reilly 图书中提取的示例代码需要获得许可。引用本书并引用示例代码来回答问题不需要许可。将本书大量示例代码整合到产品文档中需要获得许可。
我们感谢您的支持,但通常不要求署名。署名通常包括标题、作者、出版商和 ISBN。例如:“Production Kubernetes by Josh Rosso, Rich Lander, Alexander Brand, and John Harris (O’Reilly). Copyright 2021 Josh Rosso, Rich Lander, Alexander Brand, and John Harris, 978-1-492-09231-5.”
如果您认为使用代码示例超出了公平使用范围或上述许可,请随时通过 permissions@oreilly.com 联系我们。
O’Reilly 在线学习
注意
超过 40 年来,O’Reilly Media 一直为公司提供技术和商业培训、知识和洞察力,帮助它们取得成功。
我们独特的专家和创新者网络通过书籍、文章和我们的在线学习平台分享他们的知识和专业知识。O’Reilly 的在线学习平台让您随时访问现场培训课程、深入学习路径、交互式编码环境以及来自 O’Reilly 和其他 200 多家出版商的大量文本和视频。获取更多信息,请访问:http://oreilly.com。
如何联系我们
有关此书的评论和问题,请联系出版社:
-
O’Reilly Media, Inc.
-
1005 Gravenstein Highway North
-
Sebastopol, CA 95472
-
800-998-9938(美国或加拿大)
-
707-829-0515(国际或本地)
-
707-829-0104(传真)
我们为这本书创建了一个网页,上面列出了勘误、示例和任何额外信息。您可以访问这个页面:https://oreil.ly/production-kubernetes。
通过电子邮件 bookquestions@oreilly.com 发表评论或提出关于本书的技术问题。
有关我们的书籍和课程的新闻和信息,请访问 http://oreilly.com。
在 Facebook 上找到我们:http://facebook.com/oreilly
在 Twitter 关注我们:http://twitter.com/oreillymedia
在 YouTube 观看我们:http://youtube.com/oreillymedia
致谢
作者们感谢 Katie Gamanji、Michael Goodness、Jim Weber、Jed Salazar、Tony Scully、Monica Rodriguez、Kris Dockery、Ralph Bankston、Steve Sloka、Aaron Miller、Tunde Olu-Isa、Alex Withrow、Scott Lowe、Ryan Chapple 和 Kenan Dervisevic 对手稿的评论和反馈。感谢 Paul Lundin 在书籍开发过程中的鼓励,并且在 Heptio 建立了出色的 Field Engineering 团队。团队中的每个人都通过协作和开发了我们接下来 450 页所涵盖的许多想法和经验做出了贡献。最后,感谢 VMware 的 Joe Beda、Scott Buchanan、Danielle Burrow 和 Tim Coventry-Cox 在我们启动和开发此项目时的支持。最后,感谢 O’Reilly 的 John Devins、Jeff Bleiel 和 Christopher Faucher 持续的支持和反馈。
作者们还要特别感谢以下人士:
Josh:我要感谢 Jessica Appelbaum,在我撰写本书期间给予我的大力支持,特别是提供的蓝莓煎饼。我还要感谢我的妈妈 Angela 和爸爸 Joe,在我成长过程中为我提供坚实的支持。
Rich:我要感谢我的妻子 Taylor 和孩子们 Raina、Jasmine、Max 和 John,在我撰写本书期间给予我的支持和理解。我还要感谢我的妈妈 Jenny 和爸爸 Norm,他们是我很好的榜样。
Alexander:我爱你,感谢我的不可思议的妻子 Anais,在我撰写本书期间给予我的极大支持。我还感谢我的家人、朋友和同事,是他们帮助我成为今天的我。
John:我要感谢我美丽的妻子 Christina,在我撰写本书期间给予我的爱和耐心。也感谢我的亲密朋友和家人多年来的支持和鼓励。
第一章:通向生产的路径
多年来,全球各组织广泛采用了 Kubernetes。它的流行无疑是由容器化工作负载和微服务的广泛使用加速的。随着运维、基础设施和开发团队达到需要构建、运行和支持这些工作负载的关键时刻,许多团队将 Kubernetes 视为解决方案的一部分。相对于其他大规模开源项目如 Linux,Kubernetes 是一个相对年轻的项目。根据我们与许多客户的经验,对于大多数 Kubernetes 用户来说,现在仍处于早期阶段。虽然许多组织已经在使用 Kubernetes,但达到生产环境的组织远远不多,更别提在大规模上运行的了。在本章中,我们将为许多工程团队在 Kubernetes 上的旅程奠定基础。具体来说,我们将探讨在定义通向生产路径时考虑的一些关键问题。
定义 Kubernetes
Kubernetes 是一个平台吗?基础设施?一个应用程序?有许多思想领袖可以为你提供他们对 Kubernetes 是什么的精确定义。而不是增加这些意见的堆砌,让我们把精力集中在澄清 Kubernetes 解决的问题上。一旦定义了这些问题,我们将探讨如何在这个特性集的基础上构建,以推动我们朝向生产结果的方向发展。"生产 Kubernetes" 的理想状态意味着我们已经达到了工作负载成功提供生产流量的状态。
Kubernetes 这个名称可能有点像一个总称。在 GitHub 上快速浏览可以发现,kubernetes 组织(截至本文撰写时)包含了 69 个仓库。然后还有 kubernetes-sigs,大约有 107 个项目。更别提数百个云原生计算基金会(CNCF)在这个领域中运作的项目!在本书中,Kubernetes 将专指核心项目。那么,核心是什么?核心项目包含在 kubernetes/kubernetes 仓库中。这是大多数 Kubernetes 集群中的关键组件的所在地。当运行包含这些组件的集群时,我们可以期待以下功能:
-
在许多主机上调度工作负载
-
提供一个声明式、可扩展的 API,用于与系统进行交互
-
提供一个 CLI,
kubectl,供人类与 API 服务器进行交互。 -
从当前对象状态到期望状态的协调
-
提供基本的服务抽象以帮助路由请求到工作负载和从工作负载中请求
-
提供多个接口以支持可插拔的网络、存储等
这些功能创建了项目本身声称的 生产级容器编排器。简单来说,Kubernetes 提供了一种方式,让我们在多个主机上运行和调度容器化工作负载。在深入研究过程中,请记住这个主要能力。随着时间的推移,我们希望证明这种能力虽然基础,但只是我们走向生产的一部分旅程。
核心组件
提供我们讨论过的功能的组件是什么?正如我们所提到的,核心组件驻留在 kubernetes/kubernetes 代码库中。我们中的许多人以不同的方式使用这些组件。例如,运行托管服务(如 Google Kubernetes Engine(GKE))的人可能会在主机上找到每个组件。其他人可能会从仓库下载二进制文件或从供应商那里获取签名版本。无论如何,任何人都可以从 kubernetes/kubernetes 代码库下载 Kubernetes 发行版。下载并解压发行版后,可以使用 cluster/get-kube-binaries.sh 命令检索二进制文件。这将自动检测您的目标架构并下载服务器和客户端组件。让我们在以下代码中查看这一点,然后探索关键组件:
$ ./cluster/get-kube-binaries.sh
Kubernetes release: v1.18.6
Server: linux/amd64 (to override, set KUBERNETES_SERVER_ARCH)
Client: linux/amd64 (autodetected)
Will download kubernetes-server-linux-amd64.tar.gz from https://dl.k8s.io/v1.18.6
Will download and extract kubernetes-client-linux-amd64.tar.gz
Is this ok? [Y]/n
在下载的服务器组件内,可能保存在 server/kubernetes-server-${ARCH}.tar.gz 中,您将找到组成 Kubernetes 集群的关键项目:
API 服务器
所有 Kubernetes 组件和用户的主要交互点。在这里,我们获取、添加、删除和变更对象。API 服务器将状态委托给后端,最常见的是 etcd。
kubelet
与 API 服务器通信的主机代理,用于报告节点的状态并理解应该在其上调度什么工作负载。它与主机的容器运行时(如 Docker)通信,以确保为节点调度的工作负载已启动并保持健康。
控制器管理器
一组控制器,打包在单个二进制文件中,负责协调 Kubernetes 中许多核心对象的调和。当声明所需状态时,例如 Deployment 中的三个副本,控制器会处理创建新 Pod 来满足此状态。
调度器
根据其认为最佳节点的位置确定工作负载应在何处运行。它使用过滤和评分来做出这些决策。
Kube Proxy
实现 Kubernetes 服务,提供可以路由到后端 Pod 的虚拟 IP。这是通过主机上的数据包过滤机制(如 iptables 或 ipvs)实现的。
虽然不是详尽的列表,但这些是构成我们讨论的核心功能的主要组件。从架构上看,图 1-1 展示了这些组件如何协同工作。
注意
Kubernetes 架构有许多变体。例如,许多集群将 kube-apiserver、kube-scheduler 和 kube-controller-manager 作为容器运行。这意味着控制平面也可以运行容器运行时、kubelet 和 kube-proxy。这类部署考虑将在下一章中详细介绍。

图 1-1. 组成 Kubernetes 集群的主要组件。虚线边框表示非核心 Kubernetes 组件。
超越编排——扩展功能
有些地方 Kubernetes 做的不仅仅是工作负载编排。正如提到的,kube-proxy 组件编程主机以为工作负载提供虚拟 IP(VIP)体验。因此,内部 IP 地址被建立并路由到一个或多个底层的 Pod。这个问题显然超出了运行和调度容器化工作负载的范围。从理论上讲,而不是将其作为核心 Kubernetes 的一部分实现,该项目可以定义一个服务 API,并要求插件来实现服务抽象。这种方法要求用户在生态系统中选择各种插件。
许多 Kubernetes API,如 Ingress 和 NetworkPolicy,都采用了这种模型。例如,在 Kubernetes 集群中创建一个 Ingress 对象并不保证会采取行动。换句话说,虽然 API 存在,但它不是核心功能。团队必须考虑他们希望插入的技术来实现此 API。对于 Ingress,许多人使用像 ingress-nginx 这样的控制器,在集群中运行。它通过读取 Ingress 对象并为指向 Pod 的 NGINX 实例创建 NGINX 配置来实现 API。然而,ingress-nginx 只是众多选项之一。Project Contour 实现了相同的 Ingress API,但它使用 envoy 这个代理来执行。由于这种可插拔模型,团队有多种选择。
Kubernetes 接口
在扩展功能的思想上,我们现在应该探讨接口。Kubernetes 接口使我们能够定制和构建核心功能。我们认为接口是如何与某物交互的定义或契约。在软件开发中,这类似于定义类或结构体可以实现的功能。在像 Kubernetes 这样的系统中,我们部署满足这些接口的插件,提供诸如网络功能等功能。
这种接口/插件关系的一个具体示例是 容器运行时接口 (CRI)。在 Kubernetes 初始阶段,只支持单一容器运行时 Docker。虽然今天许多集群仍然使用 Docker,但越来越多的人开始关注使用诸如 containerd 或 CRI-O 等替代方案。图 1-2 显示了这两个容器运行时之间的关系。

图 1-2. 两个工作负载节点运行两种不同的容器运行时。kubelet 发送在 CRI 中定义的命令,如 CreateContainer,并期望运行时满足请求并做出响应。
在许多接口中,例如 CreateContainerRequest 或 PortForwardRequest,命令都作为远程过程调用 (RPC) 发出。对于 CRI,通信是通过 GRPC 进行的,kubelet 期望得到诸如 CreateContainerResponse 和 PortForwardResponse 的响应。在 图 1-2 中,你还会注意到满足 CRI 的两种不同模型。CRI-O 是从头开始作为 CRI 的实现构建的。因此,kubelet 直接向其发出这些命令。containerd 支持一个插件,作为 kubelet 和其自身接口之间的桥梁。无论具体的架构如何,关键在于使容器运行时执行,而不需要 kubelet 对每种可能的运行时都具有操作知识。这一概念是我们在架构、构建和部署 Kubernetes 集群时如何使用接口的强大之处。
随着时间的推移,我们甚至看到一些功能从核心项目中移除,转而采用插件模型。这些功能历史上存在于“in-tree”内,即在kubernetes/kubernetes的代码库中。一个例子是云提供商集成(CPIs)。大多数 CPIs 传统上被整合到组件中,如 kube-controller-manager 和 kubelet。这些集成通常处理诸如供应负载均衡器或公开云提供商元数据等问题。有时,在创建容器存储接口(CSI)之前,这些提供商提供块存储并使其对运行在 Kubernetes 中的工作负载可用。这么多功能都驻留在 Kubernetes 中,更不用说它需要为每个可能的提供商重新实现!作为更好的解决方案,支持被移至其自己的接口模型,例如kubernetes/cloud-provider,可以由多个项目或供应商实现。除了在 Kubernetes 代码库中减少蔓延外,这还使得 CPI 功能能够在核心 Kubernetes 集群之外管理。这包括常见的流程,如升级或修补漏洞。
今天,有几个接口使得在 Kubernetes 中进行定制化和附加功能成为可能。以下是一个高层次列表,我们将在本书的各章节中详细展开讨论:
-
容器网络接口(CNI)使网络提供商能够定义从 IPAM 到实际数据包路由的操作方式。
-
容器存储接口(CSI)使得存储提供商能够满足集群内工作负载的请求。通常用于像 ceph、vSAN 和 EBS 这样的技术。
-
容器运行时接口(CRI)支持各种运行时,常见的包括 Docker、containerd 和 CRI-O。它还促进了非传统运行时的增多,例如利用 KVM 提供最小化虚拟机的 firecracker。
-
服务网格接口(SMI)是最新一批涉及 Kubernetes 生态系统的接口之一。它希望在定义流量策略、遥测和管理等方面推动一致性。
-
云提供商接口(CPI)使 VMware、AWS、Azure 等提供商能够为其云服务与 Kubernetes 集群编写集成点。
-
开放容器倡议运行时规范(OCI)标准化了镜像格式,确保从一个工具构建的容器镜像在符合规范时可以在任何符合 OCI 的容器运行时中运行。虽然与 Kubernetes 没有直接关联,但它在推动可插拔容器运行时(CRI)的愿望方面提供了辅助。
Kubernetes 概述
现在我们已经聚焦于 Kubernetes 的范围。它是一个容器编排器,还带有一些额外的功能。它还可以通过利用插件到接口来进行扩展和定制。对于许多寻求优雅运行其应用程序手段的组织来说,Kubernetes 可能是基础性的。然而,请让我们稍作停顿。如果我们将用于在您的组织中运行应用程序的当前系统替换为 Kubernetes,那就足够了吗?对于我们许多人来说,当前“应用平台”组成的部件和机制要复杂得多。
历史上,我们看到很多组织在持有“Kubernetes”战略的观点时或者他们认为 Kubernetes 将是推动他们如何构建和运行软件的充分手段时,经历了很多痛苦。Kubernetes 是一种技术,是一种很棒的技术,但它真的不应该成为您在现代基础设施、平台和/或软件领域所追寻的重点。如果这显而易见,我们深感抱歉,但是您会惊讶地发现,有多少高管或更高级别的架构师认为 Kubernetes 本身就是问题的答案,而实际上他们的问题围绕应用交付、软件开发或组织/人员问题。Kubernetes 最好被视为您拼图的一部分,它使您能够为应用程序提供平台。我们一直在围绕应用平台的这个概念打转,接下来我们将深入探讨。
定义应用平台
在我们的生产路径中,关键是考虑应用平台的概念。我们定义应用平台为运行工作负载的可行场所。就像本书中的大多数定义一样,如何满足这一点将因组织而异。目标结果将对业务的不同部分产生广泛和有吸引力的影响,例如,开发者的满意度、操作成本的降低以及软件交付中更快的反馈循环等。应用平台通常是我们发现自己处于应用程序和基础设施交集的地方。开发者体验(devx)等问题通常是这一领域的关键要素之一。
应用平台有各种各样的形式和大小。一些主要是抽象的基础问题,如 IaaS(例如 AWS)或编排器(例如 Kubernetes)。Heroku 是这种模型的一个很好的例子。使用 Heroku,你可以轻松地将使用 Java、PHP 或 Go 等语言编写的项目部署到生产环境中,只需一个命令即可。除了您的应用程序之外,还运行着许多平台服务,否则您需要自己操作。这些服务包括指标收集、数据服务和持续交付(CD)。它还为您提供了运行高可用工作负载的基本组件,可以轻松扩展。Heroku 是否使用 Kubernetes?它是否在自己的数据中心运行,或者在 AWS 之上运行?这些细节对于 Heroku 用户来说并不重要。重要的是将这些问题委托给一个提供商或平台,使开发人员能够更多地专注于解决业务问题。这种方法并不局限于云服务。RedHat 的 OpenShift 采用了类似的模型,其中 Kubernetes 更多是一种实现细节,开发人员和平台运营商与其上的一组抽象交互。
为什么不在这里停下?如果像 Cloud Foundry、OpenShift 和 Heroku 这样的平台已经为我们解决了这些问题,那为什么还要费心使用 Kubernetes?许多预构建的应用平台的一个重要权衡是需要遵循它们对世界的看法。将对底层系统的所有权委托给其他人,会显著减轻你的运维负担。同时,如果平台处理诸如服务发现或秘密管理等问题的方式不符合你的组织需求,你可能没有足够的控制权来解决这些问题。此外,还存在供应商或观点锁定的概念。随着抽象的出现,关于应用程序应如何架构、打包和部署的观点也会随之而来。这意味着将工作负载迁移到另一个系统可能并不容易。例如,将工作负载从 Google Kubernetes Engine(GKE)移动到 Amazon Elastic Kubernetes Engine(EKS)比从 EKS 移动到 Cloud Foundry 要容易得多。
方法的光谱
现在清楚地看到,建立成功的应用平台有几种方法。为了演示起见,让我们做一些大胆的假设,并评估不同方法之间的理论权衡。对于我们合作的平均公司,比如中大型企业,图 1-3 展示了对方法的任意评估。
在左下方的四分之一中,我们看到部署 Kubernetes 集群本身,涉及的工程工作相对较少,特别是当像 EKS 这样的托管服务为您处理控制平面时。这些在生产就绪性方面较低,因为大多数组织会发现,在 Kubernetes 之上还需要做更多工作。但是,也有使用情况,比如团队为其工作负载使用专用集群,可能仅仅使用 Kubernetes 就足够了。

图 1-3. 提供应用平台给开发者的多种选择。
在右下角,我们有更加成熟的平台,这些平台提供了开箱即用的端到端开发者体验。Cloud Foundry 就是解决许多应用平台关注点的一个很好的例子。在 Cloud Foundry 中运行软件更多是确保软件符合其观点。另一方面,OpenShift 对于大多数人来说比仅仅 Kubernetes 更加适合生产环境,它有更多的决策点和设置考虑。这种灵活性是一个好处还是一个麻烦?这是您需要考虑的一个关键问题。
最后,在右上角,我们有在 Kubernetes 之上构建应用平台。相对于其他选项,至少从平台角度来看,这无疑需要最大的工程投入。然而,利用 Kubernetes 的可扩展性意味着你可以创建与开发者、基础设施和业务需求对齐的东西。
对齐您的组织需求
图表 Figure 1-3 中缺少的是第三维度,即展示方法与您需求对齐程度的 z 轴。让我们考虑另一个视觉表现。 Figure 1-4 描绘了考虑平台与组织需求对齐时可能的情况。

图 1-4. 这些选项与您组织需求对齐的增加复杂性,即 z 轴。
在平台的需求、特性和行为方面,建设平台几乎总是最为一致的选择。或者至少是最具备一致性的选择。这是因为你可以构建任何东西!如果你想在 Kubernetes 上内部重新实现 Heroku,并对其能力进行轻微调整,从技术上讲是可行的。然而,成本与回报应该与其他轴(x 和 y)权衡。让我们通过考虑下一代平台的以下需求来使这个练习更具体:
-
法规要求您主要在本地运行
-
需要支持裸金属设备以及您的 vSphere 启用数据中心
-
希望支持开发者将应用程序打包成容器的不断增长需求
-
需要构建自助式 API 机制,摆脱基于“票证”的基础设施供应
-
希望确保您构建的 API 不依赖于特定供应商,并且不会因为您过去迁移这类系统而花费了数百万。
-
开放以支持多种堆栈产品的企业支持,但不愿承诺对整个堆栈按节点、核心或应用实例授权的模型。
我们必须了解我们的工程成熟度、建立和赋权团队的愿望以及可用资源,以确定构建应用平台是否是一个明智的举措。
总结应用平台
确实,什么构成应用平台仍然相当模糊。我们专注于一系列我们认为可以为团队带来超越工作负载编排的体验的平台。我们还表明,Kubernetes 可以定制和扩展以达到类似的结果。通过将我们的思维推进到“我如何获得一个 Kubernetes”的问题之外,而是关注“当前的开发者工作流程、痛点和需求”,平台和基础设施团队将更成功地构建他们的产品。专注于后者,我们认为,您更有可能规划出正确的上线路径并实现不平凡的采用。归根结底,我们希望满足基础设施、安全性和开发者的要求,以确保我们的客户——通常是开发者——获得符合其需求的解决方案。通常情况下,我们不希望简单地提供一个“强大”的引擎,让每个开发者都必须在其上构建自己的平台,正如在图 1-5 中开玩笑地描述的那样。

图 1-5. 当开发者期望一个端到端的体验(例如一辆可驾驶的汽车)时,不要期望一个仅有引擎而没有车架、轮子等的东西足够。
在 Kubernetes 上构建应用平台
现在我们已经确定了 Kubernetes 在我们通往生产路径中的一个部分。因此,人们会合理地问:“那么 Kubernetes 只是少了一些东西吗?” Unix 哲学中“让每个程序做好一件事”这一原则对 Kubernetes 项目是一个令人向往的追求。我们相信它最好的特性很大程度上是它所没有的!特别是在被那些试图为你解决全球问题的一刀切平台所伤害后。Kubernetes 在成为一个出色的编排器的同时,明确了它可以如何在其上构建的清晰接口。这可以类比为家的基础。
一个良好的基础应该在结构上是稳固的,可以在其上建造,并为路由工具提供适当的接口。尽管重要,但仅有一个基础很少能成为我们的应用程序生活的宜居之地。通常情况下,我们需要在基础之上存在某种形式的家。在讨论像 Kubernetes 这样的基础之上的构建之前,让我们考虑一个像预装家具公寓一样的场景,如图 1-6 所示。

图 1-6. 一个可以立即入住的公寓。类似于 Heroku 等平台即服务选项。由 Jessica Appelbaum 插图。
此选项与我们的示例(如 Heroku)类似,无需额外工作即可居住。当然,我们可以在内部定制体验,但是许多问题已经为我们解决了。只要我们对租金的价格感到满意,并愿意遵循其中的不可协商意见,我们就可以在第一天就取得成功。
回到 Kubernetes,我们将其比作是一个基础,现在可以在其上构建一个适合居住的家,如图 1-7 所示。

图 1-7. 建造一座房子。类似于建立一个应用平台,其中 Kubernetes 是基础。Jessica Appelbaum 插图。
在规划、工程和维护的代价下,我们可以构建出色的平台来运行整个组织的工作负载。这意味着我们完全控制输出中的每个元素。房子可以且应该根据未来租户(我们的应用程序)的需求进行定制。现在让我们分解使这一切成为可能的各种层面和考虑因素。
从底层开始
首先,我们必须从底层开始,包括 Kubernetes 预期运行的技术。通常这是数据中心或云提供商,提供计算、存储和网络。一旦建立,Kubernetes 就可以在其上启动。几分钟内,您可以在底层基础设施之上拥有运行的集群。有几种引导 Kubernetes 的方法,我们将在第二章中详细介绍它们。
从 Kubernetes 集群存在的角度来看,我们接下来需要查看一个概念流程,以确定我们应该在其上构建什么。关键的关节在图 1-8 中表示。

图 1-8. 我们的团队可能在使用 Kubernetes 进行生产路径时经历的流程。
从 Kubernetes 存在的角度来看,您可以期望迅速收到如下问题:
-
“如何确保工作负载之间的流量完全加密?”
-
“如何确保出口流量通过网关,保证一致的源 CIDR?”
-
“如何为应用程序提供自助跟踪和仪表盘?”
-
“如何让开发人员在不成为 Kubernetes 专家的情况下上船?”
这个列表可能是无穷无尽的。通常我们需要确定哪些需求在平台级别解决,哪些在应用级别解决。关键在于深入了解现有工作流程,以确保我们构建的东西符合当前的期望。如果我们无法满足这一功能集,这将对开发团队产生什么影响?接下来我们可以开始在 Kubernetes 之上构建一个平台。在这样做时,关键是我们要与愿意尽早上手并理解体验的开发团队保持配合,以便基于快速反馈做出明智的决策。在达到生产环境后,这种流程不应该停止。平台团队不应该期望交付的东西是开发者将会使用几十年的静态环境。为了取得成功,我们必须始终与我们的开发团队保持一致,了解存在哪些问题或潜在缺失功能可能会增加开发速度。开始考虑我们应该期望开发人员与 Kubernetes 互动的程度是一个很好的起点。这就是我们应该期望多大程度上,或者多小程度上抽象的概念。
抽象光谱
在过去,我们听过类似“如果你的应用开发人员知道他们在使用 Kubernetes,那么你就失败了!”这样的装腔作势。特别是在构建产品或服务时,底层编排技术对最终用户来说可能无关紧要。也许你正在构建一个支持多种数据库技术的数据库管理系统(DBMS)。无论数据库的分片还是实例是通过 Kubernetes、Bosh 还是 Mesos 运行,对你的开发人员来说可能并不重要!然而,将这种哲学从推特上整体引入到你团队的成功标准中是一件危险的事情。当我们在 Kubernetes 上叠加组件并构建平台服务以更好地服务我们的客户时,我们将面临许多决策点,以确定适当的抽象看起来如何。图 1-9 提供了这一光谱的可视化。

图 1-9. 光谱的各种端点。从为每个团队提供自己的 Kubernetes 集群开始,到完全将 Kubernetes 从用户抽象出来,通过平台即服务(PaaS)提供。
这可能是让平台团队夜不能寐的问题。提供抽象化确实有很多优点。像 Cloud Foundry 这样的项目提供了一个完全成熟的开发者体验——例如,在一个 cf push 的上下文中,我们可以将一个应用程序构建、部署,并让其提供生产流量服务。以此目标和体验作为主要关注点,随着 Cloud Foundry 在 Kubernetes 上的进一步支持,我们预计会看到这种过渡更多地成为一个实施细节,而不是功能集的变化。我们还看到的另一个模式是希望在公司内提供不仅仅是 Kubernetes 的选择,但不让开发人员明确选择技术的愿望。例如,一些公司既有 Mesos 的足迹,又有 Kubernetes 的足迹。然后他们构建了一个抽象化,使得工作负载的落地选择变得透明,而不将这个责任放在应用程序开发者身上。这也防止了技术锁定的发生。这种方法的一个折衷是在两个操作方式不同的系统上构建抽象化需要大量的工程努力和成熟度。此外,虽然开发人员从 Kubernetes 或 Mesos 交互的负担中得到了缓解,但他们需要理解如何使用一个抽象化的公司特定系统。在开源的现代时代,来自整个堆栈的开发人员对于不在组织之间转化的系统学习并不感兴趣。最后,我们看到的一个陷阱是对抽象化的痴迷导致无法暴露 Kubernetes 的关键特性。随着时间的推移,这可能会变成一个试图跟上项目并且潜在地使您的抽象化变得和它所抽象的系统一样复杂的猫鼠游戏。
另一方面,希望为开发团队提供自助集群的平台组织也是一个很好的模式。这确实是一个很好的模式。这将把 Kubernetes 成熟性的责任放在开发团队身上。他们是否了解 Deployments、ReplicaSets、Pods、Services 和 Ingress APIs 的工作原理?他们是否了解如何设置 millicpus 以及资源过量提交的工作原理?他们是否知道如何确保配置有多个副本的工作负载始终安排在不同的节点上?如果是的话,这是一个完美的机会,可以避免在应用平台上过度设计,而是让应用团队从 Kubernetes 层面开始处理。
开发团队拥有自己集群的这种开发模式相对较少见。即使有一支具备 Kubernetes 背景的团队,他们也不太可能希望抽出时间来确定如何管理其 Kubernetes 集群的生命周期,尤其是在升级时。Kubernetes 提供了很多强大的功能,但对于许多开发团队来说,期望他们成为 Kubernetes 专家并在发布软件的同时处理这些事务是不现实的。正如你将在接下来的章节中发现的那样,抽象化并不是一个二进制的决定。在许多时候,我们可以根据具体情况做出明智的抉择,确定在哪些地方使用抽象化是有意义的。我们将确定在哪些地方我们可以为开发人员提供适当的灵活性,同时简化他们完成工作的能力。
确定平台服务
在构建 Kubernetes 之上,关键的决定是相对于在应用程序级别解决的功能应该内建到平台中。通常情况下,这是一个需要按情况评估的问题。例如,假设每个 Java 微服务都实现了一个库,用于在服务之间实现互相认证的互斥 TLS(mTLS),以及在网络上传输数据的加密。作为平台团队,我们需要深入了解这种用法,以确定我们是否应该在平台级别提供或实施这种功能。许多团队希望通过在集群中实施一种称为服务网格的技术来解决这个问题。通过权衡利弊,我们可以得出以下考虑。
引入服务网格的优点:
-
Java 应用程序不再需要捆绑库来实现 mTLS。
-
非 Java 应用程序可以参与相同的 mTLS/加密系统。
-
应用团队减少了解决问题的复杂性。
引入服务网格的缺点:
-
运行服务网格并非小事。这是另一个具有操作复杂性的分布式系统。
-
服务网格通常引入远远超出身份验证和加密的功能。
-
网格的身份验证 API 可能与现有应用程序使用的后端系统不同步。
权衡这些优缺点,我们可以得出结论,决定在平台级别解决这个问题是否值得努力。关键在于我们不需要也不应该在新平台中解决每一个应用程序的问题。这是在你阅读本书中的许多章节时需要考虑的另一个平衡。我们将分享几个建议、最佳实践和指导,但像任何事情一样,你应该根据业务需求的优先级评估每一个。
构建基础
让我们通过明确地识别你在构建平台时将拥有的关键构建块来结束这一章。这包括从基础组件到可选平台服务的一切。
图 1-10 中的组件对不同的受众具有不同的重要性。

图 1-10。建立应用平台所涉及的许多关键构建块。
一些组件,如容器网络和容器运行时,对于每个集群都是必需的,考虑到一个不能运行工作负载或允许它们通信的 Kubernetes 集群将不会很成功。您可能会发现一些组件在是否应该实现的问题上存在差异。例如,如果应用程序已经从外部秘密管理解决方案获取其密钥,则可能不打算实现秘密管理作为平台服务。
一些领域,如安全性,明显缺失于图 1-10。这是因为安全性不是一个特性,而是从 IAAS 层以上的所有内容如何实现的结果。让我们在高层次上探讨这些关键领域,理解到我们将在本书中更深入地探讨它们。
IAAS/数据中心和 Kubernetes
IAAS/数据中心和 Kubernetes 构成了我们在本章中多次提到的基础层。我们并不是要贬低这一层,因为它的稳定性将直接影响我们平台的稳定性。然而,在现代环境中,我们花费的时间远少于确定支持 Kubernetes 的架构的机架架构,而是更多地花费在选择各种部署选项和拓扑结构之间。基本上,我们需要评估如何提供和使 Kubernetes 集群可用。
容器运行时
容器运行时将在每个主机上管理我们的工作负载的生命周期。这通常使用可以管理容器的技术来实现,例如 CRI-O、containerd 和 Docker。通过容器运行时接口(CRI),我们能够选择这些不同的实现方式。除了这些常见的示例外,还有支持特定要求的专用运行时,例如希望在微型虚拟机中运行工作负载的需求。
容器网络
我们选择的容器网络通常涉及工作负载的 IP 地址管理(IPAM)和路由协议,以促进通信。常见的技术选择包括 Calico 或 Cilium,这要归功于容器网络接口(CNI)。通过将容器网络技术插入集群,kubelet 可以请求工作负载的 IP 地址。一些插件甚至进一步在 Pod 网络的顶部实现服务抽象。
存储集成
存储集成涵盖了当主机磁盘存储无法满足需求时的应对措施。在现代 Kubernetes 中,越来越多的组织将有状态的工作负载部署到其集群中。这些工作负载需要一定程度的保证,在应用程序故障或重新调度事件中状态将是弹性的。存储可以由常见的系统提供,如 vSAN、EBS、Ceph 等等。通过容器存储接口(CSI),我们可以选择各种后端以满足应用程序请求的存储需求。类似于 CNI 和 CRI,我们能够在集群中部署插件,理解如何满足应用程序请求的存储需求。
服务路由
服务路由是我们在 Kubernetes 中运行的工作负载之间流量的促进。Kubernetes 提供了一个 Service API,但这通常只是支持更丰富的路由能力的第一步。服务路由建立在容器网络基础上,并创建了更高级别的功能,如第 7 层路由、流量模式等。许多时候,这些功能是使用称为 Ingress 控制器的技术实现的。在服务路由的深层次上,有各种服务网格技术。这种技术具备完整的功能,例如服务到服务的 mTLS、可观察性以及支持应用程序机制,如断路器。
机密管理
机密管理涵盖了工作负载所需的敏感数据的管理和分发。Kubernetes 提供了一个 Secrets API,可以与敏感数据交互。然而,默认情况下,许多集群的机密管理和加密能力不足以满足多个企业的需求。这主要是关于深度防御的讨论。在简单级别上,我们可以确保数据在存储之前进行加密(静态加密)。在更高级别上,我们可以与各种专注于机密管理的技术集成,如 Vault 或 Cyberark。
身份验证
身份验证涵盖了对人员和工作负载的身份验证。集群管理员最常见的初始要求之一是如何对用户进行身份验证,例如 LDAP 或云提供商的 IAM 系统。除了人类,工作负载可能希望识别自己以支持零信任网络模型,其中工作负载的冒充要困难得多。这可以通过集成身份提供者并使用诸如 mTLS 之类的机制来实现,以验证工作负载的身份。
授权/准入控制
授权是在我们可以验证人类或工作负载的身份之后的下一步。当用户或工作负载与 API 服务器交互时,我们如何允许或拒绝他们访问资源?Kubernetes 提供了一种基于资源/动作级别控制的 RBAC 功能,但是在我们组织内部特定于授权的自定义逻辑又如何处理呢?准入控制是我们可以进一步推进的地方,通过构建验证逻辑,这可以简单到查看静态规则列表,也可以动态调用其他系统来确定正确的授权响应。
软件供应链
软件供应链涵盖了将软件从源代码到运行时的整个生命周期。这涉及到持续集成(CI)和持续交付(CD)周围的常见问题。许多时候,开发人员主要与他们在这些系统中建立的流水线进行交互。确保 CI/CD 系统与 Kubernetes 良好集成可能对平台的成功至关重要。除了 CI/CD,还涉及到有关工件存储、从漏洞角度的安全性以及确保在集群中运行的镜像完整性的问题。
Observability
Observability 是一个涵盖所有帮助我们理解集群中发生情况的术语。这包括系统和应用程序层面。通常,我们认为可观察性涵盖三个关键领域。这些领域是日志、指标和追踪。日志通常涉及将工作负载上的日志数据转发到目标后端系统。通过这个系统,我们可以聚合和分析日志,以便消化。指标涉及捕获某个时间点表示状态的数据。我们经常将这些数据聚合或抓取到某个系统进行分析。追踪由于需要理解组成我们应用程序堆栈的各个服务之间的交互而日益普及。当追踪数据被收集时,它可以被提取到一个聚合系统,通过某种上下文或关联 ID 显示请求或响应的生命周期。
开发者抽象化
开发者抽象化是我们为了使开发人员在我们的平台上成功而设置的工具和平台服务。正如前面讨论的,抽象化方法存在于一个光谱上。一些组织将选择使 Kubernetes 的使用对开发团队完全透明化。其他团队则选择暴露 Kubernetes 提供的许多强大控制选项,并为每个开发人员提供显著的灵活性。解决方案还倾向于关注开发者入职体验,确保他们能够获得访问和安全控制他们可以在平台上利用的环境。
总结
在本章中,我们探讨了涵盖 Kubernetes、应用平台甚至在 Kubernetes 上构建应用平台的各种想法。希望这让你开始思考,关于如何更好地理解如何在这个强大的工作负载编排器上构建应用的各种领域。在本书的剩余部分中,我们将深入探讨这些关键领域,并提供见解、轶事和建议,进一步拓展你在平台构建方面的视角。让我们开始这条通往生产之路吧!
第二章:部署模型
在生产环境中使用 Kubernetes 的第一步显而易见:使 Kubernetes 存在。这包括安装系统以供应 Kubernetes 集群,并管理未来的升级。由于 Kubernetes 是一个分布式软件系统,部署 Kubernetes 主要归结为软件安装的过程。与大多数其他软件安装相比的重要区别在于,Kubernetes 与基础设施是内在联系的。因此,软件安装和安装它的基础设施需要同时解决。
在本章中,我们首先解决部署 Kubernetes 集群及应该如何充分利用托管服务和现有产品或项目的初步问题。对于那些大量利用现有服务、产品和项目的人来说,本章大部分内容可能不感兴趣,因为本章约 90% 的内容涵盖了如何处理定制自动化的方法。如果你正在评估用于部署 Kubernetes 的工具,本章仍然可能感兴趣,以便你可以思考可用的不同方法。对于那些处于少数需要为部署 Kubernetes 构建定制自动化的位置的人来说,我们将解决总体架构问题,包括对 etcd 的特殊考虑以及如何管理各种受管理集群。我们还将探讨管理各种软件安装的有用模式以及基础设施依赖项,并分解各种集群组件,并揭示它们如何相互配合。我们还将探讨管理你安装到基础 Kubernetes 集群的附加组件的方法,以及升级 Kubernetes 和构成你的应用平台的附加组件的策略。
托管服务与自主部署
在我们深入讨论 Kubernetes 的部署模型之前,我们应该先思考是否需要一个完整的 Kubernetes 部署模型的概念。云服务提供商提供的托管 Kubernetes 服务大多数情况下可以减轻部署的烦恼。你仍然需要开发可靠的、声明式的系统来供应这些托管的 Kubernetes 集群,但将大部分集群如何启动的细节抽象出来可能是有利的。
托管服务
使用托管 Kubernetes 服务的理由归结为节省工程努力。在正确管理 Kubernetes 的部署和生命周期中需要考虑相当大的技术设计和实施。记住,Kubernetes 只是你的应用平台的一个组成部分——容器编排器。
实质上,通过托管服务,您可以获得一个 Kubernetes 控制平面,随时可以连接工作节点。缓解了扩展、确保可用性和管理控制平面的责任。这些都是重大关注点。此外,如果您已经使用云服务提供商的现有服务,您将会占据先机。例如,如果您使用亚马逊网络服务(AWS)并已经使用 Fargate 进行无服务器计算,Identity and Access Management (IAM) 进行基于角色的访问控制,以及 CloudWatch 进行可观察性,您可以利用这些服务与其 Elastic Kubernetes Service (EKS),解决应用平台中的多个问题。
这与使用托管数据库服务并无二致。如果您的核心问题是为您的业务需求提供服务的应用程序,并且该应用程序需要关系数据库,但是您无法为聘请专门的数据库管理员提供理由,那么向云服务提供商支付获取数据库的费用将会大大提升效率。您可以更快地启动和运行。托管服务提供商将代表您管理可用性,进行备份并执行升级。在许多情况下,这是一个明显的好处。但是,总是存在权衡。
自行部署
使用托管 Kubernetes 服务可以节省成本,但代价是失去灵活性和自由。其中一部分是厂商锁定的威胁。托管服务通常由云基础设施提供商提供。如果您大量投资于使用特定供应商的基础设施,那么您设计的系统和利用的服务很可能不会是供应商中立的。令人担忧的是,如果他们未来提高价格或者服务质量下降,您可能会发现自己陷入困境。您为处理那些您没有时间处理的问题支付的专家现在可能会对您的命运具有危险的影响力。
当然,您可以通过使用多个提供商的托管服务来进行多样化,但是它们之间在暴露 Kubernetes 功能的方式上可能存在差异,而且暴露的功能可能会成为一个尴尬的不一致性问题。
因此,您可能更喜欢自行部署 Kubernetes。Kubernetes 上有大量的旋钮和控制杆可供调整。这种可配置性使其既灵活又强大。如果您投资于理解和管理 Kubernetes 本身,应用平台世界将为您敞开大门。您将能够实现任何功能,满足任何需求。而且,您将能够在基础设施提供商之间无缝实现,无论是公共云提供商还是您自己的私有数据中心中的服务器。一旦考虑到不同基础设施的不一致性,平台中显露的 Kubernetes 功能将保持一致。使用您平台的开发人员将不会关心——甚至可能不知道——提供底层基础设施的是谁。
只需记住开发人员只关心平台的功能,而不关心底层基础设施或提供者是谁。如果你控制可用的功能,并且你提供的功能在各个基础设施提供者之间是一致的,你就有自由向开发人员提供卓越的体验。你将控制你使用的 Kubernetes 版本。你将访问控制平面组件的所有标志和功能。你将访问安装在底层机器上的软件以及写入磁盘的静态 Pod 清单。你将拥有一个强大而危险的工具,用于努力赢得开发人员的支持。但绝不能忽视你需要深入学习这个工具的责任。不这样做会使自己和他人受伤。
做出决定
当你开始旅程时,通向荣耀的道路很少是清晰的。如果你在选择托管 Kubernetes 服务或自行部署集群之间犹豫不决,你离 Kubernetes 旅程的开始比辉煌的最终结论更近。而选择托管服务还是自行部署的决定足够基础,它将对你的业务产生长期影响。因此,以下是一些指导原则,帮助这个过程。
如果你遇到以下情况,你应该倾向于使用托管服务:
-
对理解 Kubernetes 的想法听起来非常艰难
-
管理关键对你的业务成功至关重要的分布式软件系统听起来很危险
-
由供应商提供的功能限制带来的不便似乎可以应对
-
你相信你的托管服务供应商能够响应你的需求并成为良好的商业伙伴
如果你遇到以下情况,你应该倾向于自行部署 Kubernetes:
-
由供应商施加的限制让你感到不安
-
你对提供云计算基础设施的企业巨头缺乏信任或完全不信任
-
你对围绕 Kubernetes 构建平台的强大力量感到兴奋
-
你很乐意利用这个惊人的容器编排器来为开发人员提供愉快的体验
如果你决定使用托管服务,考虑跳过本章剩余大部分内容。 “附加组件” 和 “触发机制” 对你的使用场景仍然适用,但本章的其他部分则不适用。另一方面,如果你想管理自己的集群,请继续阅读!接下来我们将更深入地探讨应考虑的部署模型和工具。
自动化
如果您打算为您的 Kubernetes 集群设计部署模型,自动化的主题至关重要。任何部署模型都需要以此作为指导原则。消除人力劳动是降低成本和提高稳定性的关键。人力成本高昂。为工程师支付工资以执行例行琐碎操作,意味着资金无法用于创新。此外,人类是不可靠的。他们会犯错误。在一系列步骤中的一个错误可能会引入不稳定性,甚至阻止系统工作。通过软件系统自动化部署的前期工程投入将在未来节省大量的劳动和故障排除中带来回报。
如果您决定管理自己的集群生命周期,您必须制定适合您的策略。您可以选择使用预先构建的 Kubernetes 安装程序,或者从头开始开发自己的定制自动化。这个决定与选择托管服务与自行搭建之间有相似之处。一条路可以赋予您强大的权力、控制和灵活性,但代价是工程投入。
预构建安装程序
现在有无数个开源和企业支持的 Kubernetes 安装程序可供选择。举个例子:目前 CNCF 的网站 上列出了 180 个 Kubernetes 认证服务提供商。有些您需要付费,并有经验丰富的现场工程师帮助您启动以及支持团队在需要时提供支持。其他安装程序需要研究和实验才能理解和使用。通常情况下,那些您需要付费的安装程序可以一键完成从零到 Kubernetes 的过程。如果您符合提供的建议和可用的选择,并且您的预算能够承担支出,这种安装程序方法可能非常合适。在撰写本文时,使用预构建安装程序是我们在实际应用中最常见的方法。
自定义自动化
即使使用预构建安装程序,通常也需要一定量的定制自动化。这通常是与团队现有系统集成的形式。然而,在本节中,我们讨论的是开发自定义 Kubernetes 安装程序。
如果您刚开始使用 Kubernetes 或正在改变您的 Kubernetes 策略方向,自建的自动化路径可能只有在以下所有条件都适用时才是您的选择:
-
您不仅有一两个工程师投入到这个工作中。
-
您的员工拥有深入的 Kubernetes 经验
-
您有特殊需求,没有任何托管服务或预构建安装程序能够很好地满足
大部分本章的内容适用于以下情况之一:
-
您适合构建定制自动化的使用案例
-
您正在评估安装程序,并希望更深入地了解良好的模式是什么样的。
这让我们深入了解如何构建定制自动化来安装和管理 Kubernetes 集群的详细信息。在所有这些考虑的基础上,你应该清楚理解你的平台需求。这些需求主要应由你的应用开发团队驱动,特别是那些将是最早采用者的团队。不要陷入在真空中构建平台的陷阱中,没有与平台的消费者密切合作。将早期的预发布版本提供给开发团队进行测试。培养一个有效的反馈循环来修复错误和添加功能。你的平台成功采用依赖于此。
接下来我们将讨论在任何实施开始之前应考虑的架构问题。这包括 etcd 的部署模型,将部署环境分成层级,解决管理大量集群的挑战,以及可能用于托管工作负载的节点池类型。之后,我们将详细介绍 Kubernetes 的安装细节,首先是基础设施依赖项,然后是安装在集群虚拟或物理机器上的软件,最后是构成 Kubernetes 集群控制平面的容器化组件。
架构与拓扑
本节涵盖了对用于配置和管理 Kubernetes 集群的系统具有广泛影响的架构决策。它们包括 etcd 的部署模型以及你必须考虑该平台组件的独特问题。其中包括如何根据其服务级别目标 (SLO) 将管理的各种集群组织成层级的主题。我们还将讨论节点池的概念以及它们如何在给定集群中用于不同目的。最后,我们将讨论您可以用于集群的联合管理和部署到它们的软件的方法。
etcd 部署模型
作为 Kubernetes 集群中对象的数据库,etcd 应受到特别关注。etcd 是一个分布式数据存储,使用共识算法在多台机器上维护集群状态的副本。这引入了节点在 etcd 集群中维持一致性的网络考虑因素。它具有独特的网络延迟要求,在设计网络拓扑时需要考虑。我们将在本节中讨论这个主题,并且还会看一下 etcd 部署模型的两个主要架构选择:专用与共存以及在容器中运行还是直接安装在主机上。
网络考虑因素
etcd 的默认设置是针对单个数据中心的延迟设计的。如果将 etcd 分布到多个数据中心,应测试成员之间的平均往返时间,并根据需要调整 etcd 的心跳间隔和选举超时时间。我们强烈反对跨不同地区分布 etcd 集群的使用。如果使用多个数据中心以提高可用性,它们至少应在同一地区内的近距离范围内。
专用与共同托管
我们经常收到有关部署方式的常见问题,即是应该为 etcd 提供专用机器,还是与 API 服务器、调度器、控制器管理器等共同托管在控制平面机器上。首先要考虑的是您将管理的集群大小,即每个集群运行的工作节点数量。关于集群大小的权衡将在本章后面讨论。您对此的看法主要决定了是否为 etcd 分配专用机器。显然,etcd 非常重要。如果 etcd 的性能受损,您控制集群资源的能力也将受到影响。只要您的工作负载不依赖于 Kubernetes API,它们就不应受到影响,但保持控制平面的健康状态仍然非常重要。
如果您开车在街上行驶,而方向盘停止工作,即使汽车仍在驶向前方,这也是很危险的。因此,如果您要为 etcd 放置读写需求(这是大型集群所需的),最好为它们专门分配机器,以消除与其他控制平面组件的资源争用。在这种情况下,“大型”集群取决于正在使用的控制平面机器的大小,但对于超过 50 个工作节点的任何内容,都应考虑此问题。如果计划使用超过 200 个工作节点的集群,则最好直接计划专用的 etcd 集群。如果计划较小的集群,请节省管理开销和基础设施成本,选择共同托管的 etcd。Kubeadm 是一个流行的 Kubernetes 引导工具,您可能会使用它;它支持此模型并将处理相关的问题。
容器化与宿主机
下一个常见的问题是关于是否在主机上安装 etcd 还是在容器中运行。让我们先解决简单的答案:如果您以协同方式运行 etcd,请在容器中运行它。当使用 kubeadm 进行 Kubernetes 引导时,此配置得到支持并经过充分测试。这是您的最佳选择。另一方面,如果您选择在专用机器上运行 etcd,您有以下选项:您可以在主机上安装 etcd,这样可以将其整合到机器映像中并消除在主机上运行容器运行时的额外问题。或者,如果在容器中运行,则最有用的模式是在机器上安装容器运行时和 kubelet,并使用静态清单来启动 etcd。这样做的好处是遵循与其他控制平面组件相同的模式和安装方法。在复杂系统中使用重复模式是有用的,但这个问题主要是一个偏好问题。
集群层次
按层次组织您的集群是我们在该领域几乎普遍看到的一种模式。这些层次通常包括测试、开发、暂存和生产。有些团队称这些为不同的“环境”。然而,这是一个广义术语,可能有不同的含义和影响。我们在这里将使用术语层次来具体讨论不同类型的集群。特别是,我们讨论与集群相关的服务级目标(SLO)和服务级协议(SLA),以及集群的用途,以及集群在应用程序生产路径中的位置,如果有的话。对于不同组织来说,这些层次的具体样貌可能有所不同,但也存在共同的主题,我们将描述这四个层次通常的含义。在所有层次上使用相同的集群部署和生命周期管理系统。在较低的层次大量使用这些系统将有助于确保它们在应用于关键生产集群时能按预期工作:
测试
测试层中的集群是单租户、临时集群,通常会应用生存期(TTL),以便在指定的时间后自动销毁,通常不超过一周。这些集群由平台工程师经常启动,用于测试他们正在开发的特定组件或平台功能。当本地集群不足以进行本地开发时,开发人员也可能使用测试层,或者在本地集群测试后进行下一步操作。当应用开发团队最初在 Kubernetes 上容器化和测试其应用程序时,这种情况更为常见。这些集群没有 SLO 或 SLA。这些集群将使用平台的最新版本,或者可能选择使用预-Alpha 版本。
开发
开发级别集群通常是“永久”集群,没有生存时间限制(TTL)。它们是多租户的(适用时)并具有生产集群的所有功能。它们用于应用程序的第一轮集成测试,并用于测试应用工作负载与平台 alpha 版本的兼容性。开发层次还用于应用开发团队的一般测试和开发。这些集群通常有一个服务水平目标(SLO),但没有与之相关的正式协议。在开发集群上运行应用程序时,可用性目标通常会接近生产级别,至少在工作时间内,因为停机将影响开发人员的生产力。相比之下,这些应用程序在开发集群上运行时没有 SLO 或 SLA,并且经常更新且处于不断变化中。这些集群将运行平台的正式发布的 alpha 和/或 beta 版本。
暂存
与开发层次类似,暂存层次中的集群也是永久集群,通常由多个租户共享使用。它们用于在推出到生产之前进行最终集成测试和批准。它们由不积极开发正在运行的软件的利益相关者使用。这些利益相关者包括项目经理、产品所有者和高管。这也可能包括需要访问软件预发布版本的客户或外部利益相关者。暂存层次的服务水平目标通常与开发集群相似。如果外部利益相关者或付费客户正在访问集群上的工作负载,则暂存层次可能会有与之关联的正式 SLA。如果平台团队严格遵循向后兼容性,这些集群将运行正式发布的 beta 版本。如果无法保证向后兼容性,则暂存集群应运行与生产中使用的平台相同的稳定版本。
生产
生产级别集群是赚钱的主力军。这些集群用于面向客户、产生收入的应用程序和网站。这里只运行经过批准、生产就绪、稳定的软件发布版本。而且只使用经过全面测试和批准的平台稳定发布版本。使用详细定义良好的服务水平目标(SLOs)并进行跟踪。通常会适用具有法律约束力的服务级别协议(SLAs)。
节点池
节点池是在单个 Kubernetes 集群内将节点类型组合在一起的一种方法。这些类型的节点可以通过它们的独特特征或它们扮演的角色来分组在一起。在深入了解节点池的细节之前了解使用节点池的权衡是很重要的。这种权衡通常围绕在单个集群内使用多个节点池与配置单独的不同集群之间的选择之间展开。如果您使用节点池,您将需要在您的工作负载上使用节点选择器,以确保它们最终落入适当的节点池中。您还可能需要使用节点污点来防止没有节点选择器的工作负载误入不应该落地的位置。此外,您集群内节点的扩展变得更加复杂,因为您的系统必须监视不同的池并分别扩展每个池。另一方面,如果您使用不同的集群,则将这些问题转移到集群管理和软件联邦化问题上。您将需要更多的集群。并且您需要将您的工作负载正确地定位到正确的集群上。表 2-1 总结了使用节点池的利弊。
表 2-1. 节点池的利弊
| 优点 | 缺点 |
|---|---|
| 减少管理的集群数量 | 工作负载的节点选择器通常是必需的 |
| 较少的工作负载目标集群数量 | 需要应用和管理节点污点 |
| 更复杂的集群扩展操作 |
特征为基础的节点池是由具有某些特定工作负载所需组件或属性的节点组成的。例如,存在像图形处理单元(GPU)这样的专用设备。另一个特征的例子可能是它使用的网络接口类型。还有一个例子可能是机器上内存与 CPU 比例。我们稍后将在“基础设施”部分更深入地讨论为什么您可能使用具有不同资源比率的节点。现在只需说,所有这些特征都适合不同类型的工作负载,如果您在同一群集中同时运行它们,您将需要将它们分组到池中,以管理不同的 Pods 落地位置。
角色为基础的节点池是一个具有特定功能的节点池,您经常希望它免受资源争用的影响。切出角色为基础的池中的节点并不一定具有特殊特性,只是具有不同的功能。一个常见的例子是将一个节点池专门用于集群中的入口层。在入口池的示例中,专用池不仅可以隔离工作负载免受资源争用的影响(在这种情况下特别重要,因为目前无法为网络使用提供资源请求和限制),而且简化了网络模型以及暴露给集群外源流量的特定节点。与基于特性的节点池相比,这些角色通常不是您可以将其转移到不同集群中的问题,因为这些机器在特定集群的功能中起着重要作用。也就是说,请确保您将节点切出到池中有充分的理由。不要随意创建池。Kubernetes 集群已经足够复杂了。不要比必要的生活更加复杂。
请记住,无论您是否使用节点池,您都很可能需要解决多集群管理问题。几乎没有哪家企业使用 Kubernetes 不会积累大量的不同集群。这种情况有许多不同的原因。因此,如果您有意引入基于特性的节点池,请考虑投入工程力量来开发和完善您的多集群管理。然后,您就可以顺利地使用不同的集群为您需要提供的不同机器特性。
集群联合
集群联合广泛指的是如何集中管理您控制下的所有集群。Kubernetes 就像是一种令人愉快的罪恶感。一旦您发现自己有多么喜欢它,您就无法只用一个。但是,同样地,如果您不控制好这个习惯,它可能会变得混乱。联合策略是企业管理其软件依赖关系的方法,以免其螺旋式增长成为代价高昂、具有破坏性的瘾。
一个常见且有用的方法是从区域开始联合,然后扩展至全球。这可以减少联合集群的影响范围,并减少计算负载。当您开始联合工作时,您可能没有全球存在或基础设施量来证明多级联合方法的必要性,但请将其作为设计原则记在心中,以防未来需要。
让我们讨论一些与这一领域相关的重要主题。在本节中,我们将探讨管理集群如何帮助整合和集中区域服务。我们将考虑如何整合各个集群中工作负载的度量标准。我们还将讨论这如何影响以集中管理方式部署在不同集群中的工作负载。
管理集群
管理集群的含义如其字面意思:管理其他集群的 Kubernetes 集群。组织发现随着使用的扩展以及管理的集群数量的增加,他们需要利用软件系统来实现平稳运行。并且,顾名思义,他们通常使用基于 Kubernetes 的平台来运行这些软件。Cluster API 已成为实现此目的的流行项目。它是一组使用自定义资源(如集群和机器资源)的 Kubernetes 运算符,用于表示其他 Kubernetes 集群及其组件。常见的模式是将 Cluster API 组件部署到管理集群中,以便为其他工作负载集群部署和管理基础设施。
然而,以这种方式使用管理集群也存在缺陷。在生产环境和其他环境之间严格分离是通常明智的做法。因此,组织通常会为生产环境专门设立管理集群。这进一步增加了管理集群的开销。另一个问题是集群自动缩放,这是一种根据工作负载规模调整工作节点的方法。集群自动缩放器通常在进行扩展事件的集群中运行,以便监视需要扩展事件的条件。但管理集群包含负责管理工作节点供应和注销的控制器。这为任何使用集群自动缩放器的工作负载集群引入了对管理集群的外部依赖,如图 2-1 所示。如果在需要集群扩展以满足需求的繁忙时段管理集群不可用会怎样?

图 2-1. 集群自动缩放器访问管理集群以触发扩展事件。
一种解决方法是在工作负载集群中以自包含的方式运行 Cluster API 组件。在这种情况下,集群和机器资源也将驻留在工作负载集群中。你仍然可以使用管理集群来创建和删除集群,但工作负载集群变得相对自治,减少了对管理集群的外部依赖,例如自动缩放,如图 2-2 所示。

图 2-2. 集群自动缩放器访问本地 Cluster API 组件执行扩展事件。
此模式的另一个显著优势是,如果集群中的任何其他控制器或工作负载需要访问 Cluster API 自定义资源中包含的元数据或属性,则可以通过本地 API 读取该资源来访问它们。无需访问管理集群 API。例如,如果您有一个命名空间控制器,根据其是否位于开发或生产集群中而改变其行为,那么这是可以包含在代表其所在集群的 Cluster 资源中的信息。
此外,管理集群通常还托管由各个其他集群中的系统访问的共享或区域服务。这些不太是管理功能。管理集群通常只是运行这些共享服务的逻辑位置。这些共享服务的示例包括 CI/CD 系统和容器注册表。
可观察性
在管理大量集群时,出现的一个挑战是从整个基础设施中收集指标,并将它们(或其子集)带入到中央位置。提供您管理的集群和工作负载健康状况清晰图像的高级可测量数据点是集群联合管理的一个关键问题。Prometheus 是一个成熟的开源项目,许多组织用来收集和存储指标。无论您是否使用它,它用于联合的模型非常有用,并值得研究,以便在可能的情况下与您使用的工具进行复制。它通过允许联合 Prometheus 服务器从其他低级 Prometheus 服务器中抓取指标的子集,支持区域联合方法。因此,它将适应您采用的任何联合策略。第九章 更深入地探讨了这个主题。
分布式软件部署
当管理各种远程集群时,另一个重要问题是如何管理将软件部署到这些集群中。能够管理集群本身是一回事,但组织将端用户工作负载部署到这些集群中是另一回事。毕竟,这些集群的存在目的是这些工作负载。也许您有必须在多个区域部署以确保可用性的关键高价值工作负载。或者,您只需根据不同集群的特性组织工作负载的部署位置。如何做出这些决定是一个具有挑战性的问题,这从解决方案的相对缺乏共识可以看出。
Kubernetes 社区长期以来一直在尝试以广泛适用的方式解决这个问题。最近的一个实现是KubeFed。它还涉及集群配置,但我们更关心那些面向多个集群的工作负载定义。一个有用的设计概念是,可以联合任何在 Kubernetes 中使用的 API 类型。例如,你可以使用联合版本的 Namespace 和 Deployment 类型来声明如何将资源应用于多个集群。这是一个强大的概念,因为你可以在一个管理集群中集中创建一个 FederatedDeployment 资源,并使其在其他集群中作为多个远程 Deployment 对象被创建。然而,我们预计未来在这一领域会看到更多进展。目前,在现场管理此问题的最常见方式仍然是配置 CI/CD 工具以在部署工作负载时针对不同的集群。
现在我们已经涵盖了将如何组织和管理你的集群群体的广泛架构问题,让我们详细探讨基础设施问题。
基础设施
Kubernetes 的部署是一个依赖于 IT 基础设施的软件安装过程。一个 Kubernetes 集群可以在个人笔记本电脑上使用虚拟机或 Docker 容器启动。但这只是为了测试目的的模拟。对于生产使用,需要有各种基础设施组件,并且它们通常作为 Kubernetes 部署的一部分提供。
一个有用的生产就绪 Kubernetes 集群需要一定数量的计算机连接到网络上运行。为了保持术语一致,我们将使用“机器”一词来指代这些计算机。这些机器可以是虚拟的或物理的。重要的问题是你能够配置这些机器,并且一个主要的关注点是将它们联机的方法。
你可能需要购买硬件并将其安装在数据中心中。或者,你可以简单地向云服务提供商请求所需的资源,根据需要启动机器。无论哪种方式,你都需要机器以及正确配置的网络,这在你的部署模型中需要考虑到。
作为自动化工作中的重要一环,务必慎重考虑基础设施管理的自动化。远离通过在线向导中的表单点击等手动操作。倾向于使用声明性系统,该系统通过调用 API 来实现相同的结果。这种自动化模型需要能够根据需要提供服务器、网络和相关资源,就像云提供商(如亚马逊 Web 服务、微软 Azure 或 Google 云平台)一样。然而,并非所有环境都有 API 或 Web 用户界面来启动基础设施。大量的生产工作负载在由将要使用它们的公司购买和安装的数据中心中运行。这需要在安装和运行 Kubernetes 软件组件之前进行,这一点非常重要,我们需要做出这种区分,并确定适用于每种情况的模型和模式。
下一节将讨论在裸金属上运行 Kubernetes 与在虚拟机上使用作为 Kubernetes 集群节点的挑战。然后,我们将讨论集群大小的权衡和对集群生命周期管理的影响。随后,我们将审视你应考虑的计算和网络基础设施的关注点。最后,这将引导我们探讨一些针对 Kubernetes 集群的基础设施管理自动化的具体策略。
裸金属与虚拟化
当探索 Kubernetes 时,许多人会思考虚拟机层的相关性是否必要。难道容器不基本上做同样的事情吗?你是否会实质上运行两层虚拟化?答案是,并非必然如此。Kubernetes 的倡议可以在裸金属或虚拟化环境中大获成功。选择正确的部署介质至关重要,应通过各种技术解决方案的问题和团队在这些技术上的成熟度来决定。
虚拟化革命改变了世界如何配置和管理基础设施。历史上,基础设施团队使用诸如 PXE 引导主机、管理服务器配置以及使附属硬件(如存储)可用的方法。现代虚拟化环境将所有这些抽象为 API 的背后,可以在不知道底层硬件是什么样子的情况下自由地配置、变更和删除资源。这种模型在数据中心内通过诸如 VMware 等供应商以及在云中运行的情况下得到了验证。由于这些进展,许多在云原生世界中操作基础设施的新人可能永远不会了解到某些底层硬件问题。图 2-3 中的图示并不详尽地表示虚拟化和裸金属之间的差异,而更多地展示了互动点如何倾向于不同。

图 2-3. 在配置和管理裸金属计算基础设施与虚拟机时管理员互动的比较。
虚拟化模型的好处远远超出了使用统一 API 与其交互的便利性。在虚拟化环境中,我们有利于在硬件服务器内构建许多虚拟服务器,使我们能够将每台计算机切片成完全隔离的机器,我们可以:
-
轻松创建和克隆机器和机器镜像
-
在同一台服务器上运行多个操作系统
-
根据应用程序需求优化服务器使用,分配不同数量的资源
-
更改资源设置而不中断服务器
-
通过编程方式控制硬件服务器可以访问的内容,例如,网卡
-
每台服务器运行独特的网络和路由配置
此灵活性还使我们能够在更小的范围内确定运行问题。例如,我们现在可以升级一台主机而不影响运行在硬件上的所有其他主机。此外,在虚拟化环境中,通常更有效地创建和销毁服务器的许多机制是可用的。虚拟化有其自己的一套权衡。通常情况下,在远离金属时运行会产生开销。许多超高性能的应用程序,如交易应用程序,可能更喜欢在裸金属上运行。运行虚拟化栈本身也会产生开销。在边缘计算中,例如电信运营商运行其 5G 网络的情况下,他们可能希望直接在硬件上运行。
现在我们已经完成了虚拟化革命的简要回顾,让我们看看在使用 Kubernetes 和容器抽象时,这对我们的影响,因为这些要求我们的交互点甚至更高层次。图 2-4 通过操作员的视角展示了这一点,Kubernetes 层下的计算机被视为“计算之海”,其中工作负载可以定义它们需要的资源,并将适当地调度。

图 2-4. 使用 Kubernetes 时操作员交互。
需要注意的是,Kubernetes 集群与底层基础设施有几个集成点。例如,许多使用 CSI 驱动程序与存储提供商集成。有多个基础设施管理项目允许从提供商请求新主机并加入集群。并且,大多数情况下,组织依赖于云提供商集成(CPI),这些集成会执行额外的工作,例如在集群外部配置负载均衡器以路由流量。
从本质上讲,当放弃虚拟化技术时,基础设施团队会失去许多便利之处——这些便利 Kubernetes 本质上 无法解决。然而,有几个与裸金属相关的项目和集成点使这一领域变得更加有前景。主要云提供商正在提供裸金属选项,并且像 Packet(最近被Equinix Metal收购)这样的裸金属专用 IaaS 服务正在获得市场份额。裸金属的成功并非没有挑战,包括:
明显更大的节点
更大的节点通常导致每个节点更多的 Pod。当需要在原地升级时,需要排空节点以进行升级,这可能触发 1000 多次重新调度事件。
动态扩展
根据工作负载或流量需求快速启动新节点的方法。
镜像供应
快速制作和分发机器镜像,以尽可能使集群节点不可变。
缺乏负载均衡器 API
需要从集群外部提供入口路由到集群内的 Pod 网络。
较少复杂的存储集成
解决将网络附加存储传递给 Pod 的问题。
多租户安全问题
当使用虚拟化管理程序时,我们有幸确保安全敏感的容器运行在专用的虚拟化管理程序上。具体来说,我们可以任意地划分物理服务器,并基于此进行容器调度决策。
这些挑战是完全可以解决的。例如,缺乏负载均衡器集成可以通过项目如 kube-vip 或 metallb 来解决。与 ceph 集群集成可以解决存储集成的问题。然而,关键在于容器并不是一种新型虚拟化技术。在底层,容器(在大多数实现中)使用 Linux 内核原语使进程在主机上感觉被隔离。有无数的权衡继续解包,但本质上,我们在选择云提供商(虚拟化)、本地虚拟化和裸金属之间时的指导是考虑基于您的技术要求和组织的运营经验来选择哪种选项最合适。如果 Kubernetes 被认为是虚拟化堆栈的替代方案,请重新考虑 Kubernetes 究竟解决了什么问题。记住,学习操作 Kubernetes 并使团队能够操作 Kubernetes 已经是一项任务。增加完全改变您在其下管理基础设施的复杂性将显著增加您的工程投入和风险。
集群规模
您计划使用的 Kubernetes 部署模型和基础设施规划中至关重要的是您计划使用的集群大小。我们经常被问到:“生产集群应该有多少个工作节点?”这是一个与“需要多少个工作节点来满足工作负载?”完全不同的问题。如果您计划使用一个单一的生产集群来管理所有工作负载,那么这两个问题的答案将是相同的。然而,这是一种我们在实际中从未见到的理想情况。正如 Kubernetes 集群允许您将服务器机器视为牲畜一样,现代 Kubernetes 安装方法和云提供商允许您将集群本身视为牲畜。而每个使用 Kubernetes 的企业至少都有一小群集。
更大的集群提供以下优势:
更好的资源利用率
每个集群都伴随着控制平面的开销。这包括 etcd 和诸如 API 服务器等组件。此外,您会在每个集群中运行各种平台服务;例如,通过 Ingress 控制器进行代理。这些组件会增加开销。较大的集群可以最小化这些服务的复制。
较少的集群部署
如果您在自己的裸金属计算基础设施上运行,而不是从云提供商或本地虚拟化平台按需配置,根据需求启动和关闭集群以及根据需求调整集群规模会变得不太可行。如果您执行的部署策略较少,那么您的集群部署策略可以少一些自动化。完全自动化集群部署的工程投入可能比管理较少自动化策略的工程投入要大。
简化的集群和工作负载管理配置文件
如果您的生产集群较少,那么您用于分配、联邦化和管理这些问题的系统就不需要像流水线一样精简和复杂。跨集群和工作负载管理涉及到一系列复杂和具有挑战性的问题。社区一直在致力于解决这些问题。大型企业的大团队已经在为此投入了大量资源,打造了定制系统。尽管如此,这些努力迄今为止收效甚微。
较小的集群提供以下好处:
较小的影响范围
集群故障会影响较少的工作负载。
租户灵活性
Kubernetes 提供了构建多租户平台所需的所有机制。然而,在某些情况下,通过为特定租户配置一个新的集群,您可以节省大量工程资源。例如,如果一个租户需要访问类似自定义资源定义的集群范围资源,而另一个租户需要严格的隔离保证以满足安全和/或合规要求,那么为这些团队专门配置集群可能是合理的,尤其是如果他们的工作负载需要大量计算资源。
规模调优更少
随着集群扩展到数百个工作节点,我们经常会遇到需要解决的规模问题。这些问题因情况而异,但控制平面的瓶颈可能会出现。需要在故障排除和集群调优上投入工程资源。较小的集群显著减少了这些开支。
升级选项
使用较小的集群更容易进行集群替换以进行升级。集群替换确实伴随着自身的挑战,在本章后面的 “升级” 中有详细介绍,但在许多情况下,这种替换策略是一种吸引人的升级选项,而操作较小的集群甚至可以使其更加吸引人。
节点池替代方案
如果您有诸如 GPU 或内存优化节点等特殊问题的工作负载,并且您的系统可以轻松容纳大量较小的集群,那么运行专用集群以满足这些特殊需求将会变得轻松。这减轻了如前文所述管理多个节点池的复杂性。
计算基础设施
很显然,Kubernetes 集群需要机器。毕竟,管理这些机器的池是其核心目的。早期的考虑是选择什么类型的机器。多少核心?多少内存?多少本地存储?网络接口的等级如何?是否需要像 GPU 这样的专用设备?所有这些都是由您计划运行的软件需求驱动的问题。工作负载是计算密集型的吗?还是内存消耗大?您是否正在运行需要 GPU 的机器学习或 AI 工作负载?如果您的用例非常典型,即您的工作负载很好地适应了通用机器的计算与内存比例,并且如果您的工作负载在其资源消耗配置中变化不大,那么这将是一个相对简单的过程。然而,如果您有更不典型和更多样化的软件需要运行,那么这将会更复杂一些。让我们考虑一下考虑为您的集群选择的不同类型的机器:
etcd 机器(可选)
这是一种可选的机器类型,仅适用于为您的 Kubernetes 集群运行专用的 etcd 集群。我们在前面的部分已经讨论过这种选择的权衡。这些机器应优先考虑磁盘读写性能,因此永远不要使用旧的机械硬盘。即使在专用机器上运行 etcd,也要考虑将存储盘专用于 etcd,以便 etcd 不会因操作系统或其他程序对磁盘的使用而受到影响。还要考虑网络性能,包括网络中的接近程度,以减少给定 etcd 集群中机器之间的网络延迟。
控制平面节点(必需)
这些机器将专用于运行集群的控制平面组件。它们应该是大小和数量根据集群预期大小以及容错要求进行调整的通用机器。在更大的集群中,API 服务器将具有更多的客户端并管理更多的流量。这可以通过每台机器提供更多的计算资源或更多的机器来实现。然而,像调度程序和控制器管理器这样的组件在任何给定时间只有一个活跃的领导者。增加这些组件的容量不能通过更多副本来实现,就像 API 服务器可以的那样。如果这些组件由于资源匮乏而需求增加,则必须使用更多的计算资源垂直扩展。此外,如果您在这些控制平面机器上共存 etcd,则上述关于 etcd 机器的注意事项也适用。
工作节点(必需)
这些是承载非控制平面工作负载的通用机器。
内存优化节点(可选)
如果您的工作负载的内存配置不适合通用工作节点,您应该考虑一个内存优化的节点池。例如,如果您正在使用 AWS 通用 M5 实例类型的工作节点,其 CPU:内存比例为 1CPU:4GiB,但是您的工作负载以 1CPU:8GiB 的比例消耗资源,那么在此比例下预留资源时,这些工作负载将会留下未使用的 CPU。这种低效可以通过使用 AWS 的内存优化节点,比如 R5 实例类型(其 CPU:内存比例为 1CPU:8GiB),来克服。
计算优化节点(可选)
或者,如果您的工作负载符合计算优化节点的配置文件,比如 AWS 的 C5 实例类型,具有 1CPU:2GiB,您应该考虑添加一个使用这些机型的节点池,以提高效率。
专用硬件节点(可选)
一个常见的硬件需求是 GPU。如果您有需要专用硬件的工作负载(例如机器学习),在您的集群中添加一个节点池,并为适当的工作负载定位到这些节点,将会非常有效。
网络基础设施
网络通常被视为一个实施细节,但它可能对您的部署模型产生重要影响。首先,让我们来审视您需要考虑和设计的元素。
可路由性
您几乎肯定不希望您的集群节点暴露在公共互联网上。几乎从不有可能通过随时可以从任何地方连接到这些节点来解决获得访问这些节点的问题,但是一个良好安全的堡垒主机或跳板机将允许 SSH 访问,并进而允许您连接到集群节点,是一个很低的障碍。
然而,还有更微妙的问题需要回答,比如您的私有网络上的网络访问。您的网络上将有需要与集群相互连接的服务。例如,需要与存储阵列、内部容器注册表、CI/CD 系统、内部 DNS、私有 NTP 服务器等进行连接是很常见的。您的集群通常还需要访问公共资源,比如公共容器注册表,即使是通过出站代理。
如果出站公共互联网访问不可行,那些资源(例如开源容器映像和系统包)将需要以其他方式提供。倾向于简单且一致有效的系统。如果可能的话,远离毫无意义的基础设施部署的强制性要求和人工批准。
冗余性
使用可用性区域(AZs)尽可能地维持系统的可用性。为了明确起见,可用性区域是一个具有独立电源和备份以及与公共互联网独立连接的数据中心。在一个共享电源的数据中心中的两个子网并不构成两个可用性区域。然而,两个相对接近且之间有低延迟、高带宽网络连接的不同数据中心确实构成一对可用性区域。拥有两个可用性区域是不错的选择,三个更好。更多取决于您需要准备的灾难级别。数据中心有可能出现故障。在一个区域内多个数据中心同时遭遇故障是可能的,但很少见,通常表明需要考虑您的工作负载有多关键。您是否在运行对国家防御必不可少的工作负载或在线商店?还要考虑您需要冗余的位置。您是为工作负载建立冗余,还是为集群控制平面建立冗余?根据我们的经验,跨可用性区域运行 etcd 是可接受的,但在这样做时,请重新审视“网络注意事项”。请记住,将控制平面分布在多个可用性区域可以提供集群的冗余控制。除非您的工作负载依赖于控制平面(应尽量避免),否则您的工作负载的可用性不会受控制平面故障的影响。受影响的将是您能否对正在运行的软件进行任何更改。控制平面故障不是小事。这是一个高优先级的紧急情况。但这与用户面临的工作负载中断不同。
负载均衡
您将需要一个负载均衡器用于 Kubernetes API 服务器。您能否在您的环境中以编程方式提供负载均衡器?如果可以,您将能够在部署集群控制平面的过程中启动和配置它。请仔细考虑到您集群 API 的访问策略以及随后您的负载均衡器将位于哪些防火墙之后。您几乎肯定不会将其暴露在公共互联网上。对您集群控制平面的远程访问通常更常见的方式是通过 VPN 提供对您集群所在的本地网络的访问。另一方面,如果您有公开的工作负载,您将需要一个单独且独立的负载均衡器,连接到您集群的入口。在大多数情况下,此负载均衡器将为您集群中的各种工作负载提供所有传入请求的服务。部署一个负载均衡器和集群入口对每个需要从集群外部接收请求的工作负载都没有多大价值。如果运行专用的 etcd 集群,请勿在 Kubernetes API 和 etcd 之间放置负载均衡器。API 使用的 etcd 客户端将处理与 etcd 的连接,而无需负载均衡器。
自动化策略
在自动化 Kubernetes 集群的基础设施组件时,您需要做一些战略决策。我们将其分为两类,第一类是您可以利用的现有工具。然后,我们将讨论 Kubernetes 运算符在这方面的应用。认识到自动化能力对于裸金属安装将大不相同,我们将从您是否具有用于机器配置或将其包含在 Kubernetes 部署池中的 API 的假设开始。如果不是这种情况,您将需要通过手动操作填补空白,直到您具有程序化访问和控制。让我们从您可能可以利用的一些工具开始。
基础设施管理工具
如Terraform和CloudFormation for AWS等工具允许您声明计算和网络基础设施的期望状态,然后应用该状态。它们使用数据格式或配置语言,允许您在文本文件中定义所需的结果,然后告诉一款软件满足这些文本文件中声明的期望状态。
它们的优势在于它们使用工程师可以轻松采用并获得结果的工具。它们擅长简化相对复杂的基础设施供应过程。当您需要重复复制一组预设基础设施,并且基础设施实例之间没有太多变化时,它们表现出色。它非常适合不可变基础设施的原则,因为可重复性是可靠的,基础设施的替换而不是变异变得相当可管理。
当基础设施需求变得显著复杂、动态且依赖于可变条件时,这些工具的价值开始下降。例如,如果您正在设计跨多个云提供商的 Kubernetes 部署系统,这些工具将变得繁琐。JSON 和配置语言等数据格式不擅长表达条件语句和循环函数。这正是通用编程语言发挥作用的地方。
在开发阶段,基础设施管理工具被广泛成功使用。在某些场所,它们确实用于管理生产环境。但随着时间的推移,它们变得难以操作,并经常带来一种几乎无法还清的技术债务。基于这些原因,强烈建议考虑使用或开发 Kubernetes 运算符来实现此目的。
Kubernetes 运算符
如果基础设施管理工具限制了使用通用编程语言编写软件的必要性,那么这种软件应该采取什么形式?您可以编写一个 Web 应用程序来管理您的 Kubernetes 基础设施。或者是一个命令行工具。如果考虑为此目的编写定制软件开发,请务必考虑 Kubernetes 操作员。
在 Kubernetes 的背景下,操作员使用自定义资源和自定义构建的 Kubernetes 控制器来管理系统。控制器使用一种强大和可靠的管理状态方法。当您创建 Kubernetes 资源的实例时,API 服务器通过其监视机制通知负责该资源类型的控制器,然后使用资源规范中声明的期望状态作为指导来实现期望的状态。因此,通过引入代表基础设施关注点的新资源类型并开发 Kubernetes 操作员来管理这些基础设施资源的状态是非常强大的。关于 Kubernetes 操作员的主题在第十一章中有更深入的讨论。
这正是 Cluster API 项目所做的。它是一组 Kubernetes 操作员,可用于管理 Kubernetes 集群的基础设施。您可以利用这个开源项目来达成您的目标。事实上,我们建议您在开始类似项目之前检查该项目,看看它是否符合您的需求。如果不符合您的要求,您的团队是否能参与到该项目的开发中,以帮助开发您需要的功能和/或支持的基础设施提供商?
Kubernetes 提供了优秀的选项来自动化管理容器化软件部署。同样,通过使用 Kubernetes 操作员,它为集群基础设施自动化策略提供了显著的好处。强烈建议使用并在可能的情况下贡献给 Cluster API 等项目。如果您有自定义需求并且更喜欢使用基础设施管理工具,您当然可以选择这个选项取得成功。然而,由于使用配置语言和格式而不是完整功能的编程语言,您的解决方案将具有较少的灵活性和更多的变通方法。
机器安装
当为您的集群启动机器时,它们将需要操作系统,安装某些软件包,并编写配置。您还需要某种实用工具或程序来确定环境和其他变量的值,应用它们,并协调启动 Kubernetes 容器化组件的过程。
在这里通常有两种广泛使用的策略:
-
配置管理工具
-
机器镜像
配置管理
像 Ansible、Chef、Puppet 和 Salt 这样的配置管理工具在软件安装在虚拟机上并直接在主机上运行的世界中变得非常流行。这些工具非常适用于自动化配置多个远程机器。它们采用各种不同的模型,但通常管理员可以声明性地规定机器的外观,并以自动化方式应用这些规定。
这些配置管理工具的优秀之处在于它们允许您可靠地确保机器的一致性。每台机器可以安装一个有效相同的软件和配置。通常通过声明性的配方或 playbook 来完成,这些都被提交到版本控制中。所有这些使它们成为一个积极的解决方案。
在 Kubernetes 世界中,它们在带上线集群节点的速度和可靠性方面表现不佳。如果您用于将新的工作节点加入集群的过程包括配置管理工具执行安装从网络连接拉取资产的包,那么您将为该集群节点加入过程添加显著的时间。此外,配置和安装中会发生错误。从暂时不可用的包仓库到丢失或不正确的变量,任何事情都可能导致配置管理过程失败。这会完全打断集群节点的加入。如果您依赖该节点加入资源受限的自动伸缩集群,您可能会引发或延长可用性问题。
机器镜像
使用机器镜像是一个更优的选择。如果使用包含所有必需软件的机器镜像,那么一旦机器启动,软件就准备好运行了。不需要依赖网络进行包安装,也不需要可用的包仓库。机器镜像提高了节点加入集群的可靠性,并显著缩短了节点准备接受流量的时间。
这种方法的额外之处在于,您通常可以使用您熟悉的配置管理工具来构建机器镜像。例如,使用HashiCorp 的 Packer,您可以使用 Ansible 构建一个 Amazon 机器镜像,并在需要时准备将预构建的镜像应用于您的实例。运行 Ansible playbook 构建机器镜像时发生错误并不是什么大不了的事情。相比之下,如果发生打断工作节点加入集群的 playbook 错误,可能会引发重大的生产事件。
您可以——也应该——继续将用于构建的资源放在版本控制中,安装的所有方面都可以保持声明性,并对检查仓库的任何人都是清晰的。每当需要进行升级或安全补丁时,可以更新资产,提交,并且在合并后理想情况下自动运行管道。
有些决策涉及困难的权衡。有些则显而易见。这就是其中之一。使用预构建的机器映像。
需要安装什么
那么您需要在机器上安装什么呢?
首先要明显的是,您需要一个操作系统。Linux 发行版通常与 Kubernetes 一起使用和测试是一个安全的选择。RHEL/CentOS 或 Ubuntu 是简单的选择。如果您对其他发行版有企业支持,或者对其充满热情,并且愿意在测试和开发上投入一些额外时间,那也可以。如果您选择了专为容器设计的发行版,比如Flatcar Container Linux,则额外加分。
要继续显而易见的顺序,您将需要像 docker 或 containerd 这样的容器运行时。在运行容器时,必须有一个容器运行时。
接下来是 kubelet。这是 Kubernetes 与其编排的容器之间的接口。这是安装在协调容器的机器上的组件。Kubernetes 是一个容器化的世界。现代的约定是 Kubernetes 本身运行在容器中。话虽如此,kubelet 是作为主机上的常规二进制文件或进程运行的组件之一。曾经尝试过将 kubelet 作为容器运行,但那只会使事情变得更复杂。不要这样做。在主机上安装 kubelet,并在容器中运行其余的 Kubernetes。这种心理模型清晰而实用性也是成立的。
到目前为止,我们有一个 Linux 操作系统,一个容器运行时来运行容器,以及 Kubernetes 与容器运行时之间的接口。现在我们需要一些能够引导 Kubernetes 控制平面的东西。kubelet 可以运行容器,但没有控制平面,它还不知道要启动哪些 Kubernetes Pod。这就是 kubeadm 和静态 Pod 的作用。
Kubeadm 远非唯一能够执行此引导过程的工具。但它在社区中得到了广泛的采用,并成功地在许多企业生产系统中使用。它是一个命令行程序,部分地将生成必要的静态 Pod 清单以启动和运行 Kubernetes。kubelet 可以配置为监视主机上的一个目录,并运行发现的任何 Pod 清单中的 Pod。Kubeadm 将适当地配置 kubelet,并根据需要生成清单。这将引导核心、必要的 Kubernetes 控制平面组件,特别是 etcd、kube-apiserver、kube-scheduler 和 kube-controller-manager。
此后,kubelet 将获得所有进一步创建 Pod 的指令,这些指令是从提交到 Kubernetes API 的清单中生成的。此外,kubeadm 将生成引导令牌,您可以使用这些令牌安全地将其他节点加入到您的全新闪亮的集群中。
最后,您将需要某种引导实用程序。Cluster API 项目使用 Kubernetes 自定义资源和控制器来实现这一点。但是,也可以在主机上安装一个命令行程序。该实用程序的主要功能是调用 kubeadm 并管理运行时配置。当机器启动时,提供给实用程序的参数允许其配置 Kubernetes 的引导。例如,在 AWS 中,您可以利用用户数据运行引导实用程序,并传递参数,这些参数将通知应将哪些标志添加到 kubeadm 命令中,或者应在 kubeadm 配置文件中包含什么。最少需要包含一个运行时配置,告知引导实用程序是否使用 kubeadm init 创建新的集群,或使用 kubeadm join 将机器加入现有集群。还应包括一个安全位置,用于存储引导令牌(如果正在初始化)或检索引导令牌(如果正在加入)。这些令牌确保只有经批准的机器连接到您的集群,因此请小心处理。要明确了解您需要为引导实用程序提供哪些运行时配置,请通过 kubeadm 手动安装 Kubernetes,并详细记录在官方文档中。随着您完成这些步骤,您将清楚地知道在您的环境中满足要求所需的内容。图 2-5 描述了在新的 Kubernetes 集群中启动新机器创建第一个控制平面节点所涉及的步骤。

图 2-5. 启动机器初始化 Kubernetes。
现在我们已经讨论了作为 Kubernetes 集群一部分使用的机器上安装什么,让我们继续讨论在容器中运行的软件,以形成 Kubernetes 控制平面。
容器化组件
用于启动集群的静态清单应包括控制平面的这些基本组件:etcd、kube-apiserver、kube-scheduler 和 kube-controller-manager。根据需要提供额外的自定义 Pod 清单,但严格限制它们只能是在 Kubernetes API 可用或注册到联合系统中之前绝对需要运行的 Pod。如果工作负载可以通过 API 服务器后续安装,请这样做。任何使用静态清单创建的 Pod 只能通过编辑机器上的那些静态清单来管理,这样做的可访问性要低得多,并且更容易自动化。
如果使用强烈推荐的 kubeadm,将在使用 kubeadm init 初始化控制平面节点时创建控制平面的静态清单,包括 etcd。可以使用 kubeadm 配置文件将这些组件的任何标志规范传递给 kubeadm。在上一节中讨论的调用 kubeadm 的引导实用程序可以编写一个模板化的 kubeadm 配置文件,例如。
避免直接使用引导工具自定义静态 Pod 清单。如果确实必要,可以使用 kubeadm 执行单独的静态清单创建和集群初始化步骤,这将为您提供在需要时注入自定义的机会,但仅在非常重要且无法通过 kubeadm 配置实现时才这样做。更简单、更不复杂的 Kubernetes 控制平面引导将更为健壮、更快,并且在 Kubernetes 版本升级时更不容易出现问题。
Kubeadm 还将生成用于安全连接控制平面组件所需的自签名 TLS 资产。同样,避免对此进行调整。如果您有安全要求要求使用您的企业 CA 作为信任源,那么可以这样做。如果这是一个要求,重要的是能够自动获取中间授权。请记住,如果您的集群引导系统是安全的,则控制平面使用的自签名 CA 的信任将是安全的,并且仅对单个集群的控制平面有效。
现在我们已经讨论了安装 Kubernetes 的要点,让我们深入探讨一旦您有了运行中的集群就会出现的重要问题。我们将从在 Kubernetes 上安装必要的插件的方法开始。这些插件是除了 Kubernetes 之外,您需要的组件,以交付一个生产就绪的应用程序平台。然后我们将进入关于进行平台升级的问题和策略。
插件
集群插件广泛涵盖添加到 Kubernetes 集群上的平台服务层。本节不涵盖作为集群插件安装的什么内容,这基本上是本书其余章节的主题。相反,本节讨论如何安装这些组件,这些组件将使您的原始 Kubernetes 集群变成一个生产就绪、开发者友好的平台。
将添加到集群中的插件视为部署模型的一部分。插件安装通常构成集群部署的最后阶段。这些插件应与 Kubernetes 集群本身一起管理和版本化。考虑将 Kubernetes 和构成平台的插件作为一个包,一起进行测试和发布是很有用的,因为某些平台组件之间不可避免地存在版本和配置依赖关系。
Kubeadm 安装了“必需”的插件,这些插件是通过 Kubernetes 项目的一致性测试所必需的,包括集群 DNS 和 kube-proxy,后者实现了 Kubernetes 服务资源。然而,还有许多同样关键的组件在 kubeadm 完成工作后需要应用。最突出的例子是容器网络接口插件。如果没有 Pod 网络,你的集群将无法发挥作用。可以说,你最终会得到一个需要添加到集群中的重要组件列表,通常以 DaemonSets、Deployments 或 StatefulSets 的形式添加到你在 Kubernetes 上构建的平台功能中。
早些时候,在“架构和拓扑”中,我们讨论了集群联合和新集群注册到该系统中。这通常是插件安装的先决条件,因为安装的系统和定义通常驻留在管理集群中。
无论使用哪种架构,一旦注册完成,可以触发集群插件的安装。这个安装过程将是一系列调用集群 API 服务器来为每个组件创建所需的 Kubernetes 资源。这些调用可以来自集群外部或内部的系统。
安装插件的一种方法是使用连续交付流水线,使用诸如 Jenkins 等现有工具。在这种情况下,“连续”部分与软件更新无关,而是安装的新目标。CI 和 CD 中的“连续”通常指的是一旦新变更已经合并到版本控制的源代码分支中,就自动部署软件。触发安装集群插件软件到新部署的集群是一个完全不同的机制,但它在流水线中通常包含安装所需功能的能力。实现的唯一需要是在响应新集群创建时调用运行流水线的调用,以及执行正确安装所需的任何变量。
另一种更符合 Kubernetes 本机的方法是使用 Kubernetes 运算符来执行任务。这种更高级的方法涉及通过一个或多个自定义资源扩展 Kubernetes API,允许你定义集群的插件组件及其版本。它还涉及编写控制器逻辑,根据自定义资源中定义的规范执行插件组件的正确安装。
这种方法的有用之处在于为集群中的附加组件提供了一个集中且清晰的真实来源。但更重要的是,它提供了以程序方式管理这些附加组件的生命周期的机会。缺点是开发和维护更复杂软件的复杂性。如果你决定承担这种复杂性,应该是因为你将实施那些能显著减少未来人力劳动的日常 2 升级和持续管理。如果你只停留在日常 1 的安装阶段,没有开发逻辑和功能来管理升级,那么你将为很少的持续收益承担显著的软件工程成本。Kubernetes 运算符在通过它们的监视功能管理代表所需状态的自定义资源的持续运营管理中提供了最大的价值。
明确一点,附加组件运算符的概念并不一定完全独立于诸如 CI/CD 之类的外部系统。事实上,它们更有可能同时使用。例如,你可以使用 CD 流水线来安装运算符和附加组件的自定义资源,然后让运算符接管。此外,运算符可能需要获取用于安装的清单,可能来自包含附加组件 Kubernetes 模板清单的代码库。
使用运算符这种方式可以减少外部依赖,从而提高可靠性。但是,外部依赖无法完全消除。只有当你有了熟悉 Kubernetes 运算符模式并有经验利用它的工程师时,才应该使用运算符来解决附加组件的问题。否则,在你推进团队在这一领域的知识和经验之时,还是坚持使用团队熟悉的工具和系统为好。
这就把我们带到了“第 1 天”关注点的结论:安装 Kubernetes 集群及其附加组件的系统。现在我们将转向“第 2 天”关注升级问题。
升级
集群生命周期管理与集群部署密切相关。集群部署系统不一定需要考虑未来的升级,但存在足够重叠的关注点,使其建议进行考虑。至少,在投入生产之前,您的升级策略必须得到解决。能够部署平台而没有升级和维护能力,最多是危险的。当您看到生产工作负载运行在远低于最新发布版本的 Kubernetes 上时,您正在看到一个在升级能力被添加到系统之前已部署到生产环境的集群部署系统的结果。当您首次将生产工作负载部署到运行时,会花费相当多的工程预算来解决您发现缺失的功能或您的团队在其上受伤的尖锐边缘。随着时间的推移,这些功能将被添加,尖锐的边缘将被消除,但重点是它们自然而然地会优先考虑,而升级策略则在待办列表中变得陈旧。及早预算这些第二天的关注点。您的未来自己会感谢您。
处理升级问题时,我们首先要考虑版本化您的平台,以帮助确保平台本身和将使用它的工作负载的依赖关系得到充分理解。我们还将讨论如何在出现问题时计划回滚以及验证一切按计划进行的测试。最后,我们将比较和对比升级 Kubernetes 的具体策略。
平台版本化
首先,对你的平台进行版本管理,并记录该平台上所有软件的版本。这包括机器操作系统版本以及安装在其中的所有软件包,如容器运行时。显然还包括正在使用的 Kubernetes 版本。同时,还应包括每个附加组件的版本,这些组件构成了你的应用平台。团队通常会采纳 Kubernetes 版本以便每个人都知道应用平台的 1.18 版本使用的 Kubernetes 版本为 1.18,避免额外的心理负担或查找。这与仅仅进行版本管理和记录相比,显得微不足道。使用团队偏好的任何系统。但是,一定要有系统、记录系统,并且严格遵循。我唯一对将平台版本与该系统的任何组件版本固定联系的反对意见是,这可能偶尔会引起混淆。例如,如果由于安全漏洞需要更新容器运行时版本,则应在平台版本中反映这一点。如果使用语义化版本约定,这可能看起来像是一个 bug 修复版本号的变更。这可能会与 Kubernetes 本身的版本变更混淆,例如,v1.18.5 → 1.18.6。考虑给你的平台分配独立的版本号,特别是如果采用遵循主版本/次版本/bug 修复约定的语义化版本化。几乎所有软件都有自己独立的版本,并依赖其他软件及其版本。如果你的平台遵循相同的约定,那么所有工程师都能立即理解其含义。
计划失败
从假设在升级过程中会出现问题开始。设想自己处于必须从灾难性故障中恢复的情况,并以此恐惧和痛苦作为充分准备的动力。构建自动化系统以获取和恢复你的 Kubernetes 资源备份,包括直接 etcd 快照以及通过 API 获取的 Velero 备份。对应用程序使用的持久数据也要做同样的操作。直接解决关键应用程序及其依赖项的灾难恢复。对于复杂、有状态、分布式应用程序,仅恢复应用程序状态和 Kubernetes 资源而不考虑顺序和依赖关系可能不足够。头脑风暴所有可能的故障模式,并开发自动化的恢复系统来处理和测试它们。对于最关键的工作负载及其依赖项,考虑准备好待机集群进行故障切换,并在可能的情况下自动化和测试这些切换。
仔细考虑回滚路径。如果升级引发了错误或故障,而你又不能立即诊断出问题,有回滚选项是很好的保险措施。复杂的分布式系统可能需要时间来排查故障,并且由于生产中断的压力和干扰,可能需要更多时间。当处理复杂的基于 Kubernetes 的平台时,预定的操作手册和自动化回退选项比以往任何时候都更为重要。但要实际和现实。在现实世界中,回滚并不总是一个好选择。例如,如果你在升级过程中已经进行了足够多的变更,那么回滚所有先前的更改可能是个糟糕的主意。事先考虑清楚这些问题,知道哪些是不可回退的点,并在执行这些操作之前进行战略规划。
集成测试
拥有一个包含所有组件版本的良好文档化版本控制系统是一回事,但如何管理这些版本又是另一回事。在基于 Kubernetes 的平台这样复杂的系统中,确保每次一切都按预期集成并正常工作是一项巨大挑战。平台内所有组件之间的兼容性至关重要,但平台上运行的工作负载与平台本身之间的兼容性也必须进行测试和确认。为了减少平台兼容性可能带来的问题,倾向于使你的应用程序对平台不可知,但在利用平台功能时,应用工作负载可能会带来巨大价值的许多情况也应考虑在内。
所有平台组件的单元测试都很重要,还有其他所有有效的软件工程实践。但集成测试同样至关重要,尽管可能更具挑战性。一个非常好的工具,可以帮助这种努力,是 Sonobuoy 符合性测试实用工具。它最常用于运行上游 Kubernetes 端到端测试,以确保您有一个正常运行的集群;即,所有集群组件按预期一起工作。通常,团队会在新集群供应后运行 Sonobuoy 扫描,以自动化通常是手动检查控制平面 Pod 并部署测试工作负载的过程,以确保集群正常运行。然而,我建议进一步采取一些步骤。开发自己的插件,测试平台特定功能和特性。测试对您组织需求关键的操作。并定期运行这些扫描。使用 Kubernetes CronJob 至少运行一部分插件,如果不是全部测试套件。虽然这今天不能完全开箱即用,但通过一些工程工作可以实现,并且非常值得:将扫描结果公开为可以显示在仪表板上并进行警报的指标。这些符合性扫描基本上可以测试分布式系统的各个部分是否协同工作,以生成您期望的功能和特性,并构成一种非常强大的自动化集成测试方法。
再次强调,集成测试必须扩展到运行在平台上的应用程序。不同的应用开发团队将采用不同的集成测试策略,这可能大部分超出了平台团队的掌控范围,但强烈倡导这样做。在尽可能接近生产环境的集群上运行集成测试,但稍后会详细说明。对于利用平台功能的工作负载来说,这将更为关键。Kubernetes 运算符是这一点的一个引人注目的例子。它们扩展了 Kubernetes API,并自然地与平台深度集成。如果您使用运算符来部署和管理您组织软件系统的任何部分的生命周期,那么跨平台版本进行集成测试尤为重要,特别是涉及 Kubernetes 版本升级时。
策略
我们将讨论三种升级基于 Kubernetes 的平台的策略:
-
集群替换
-
节点替换
-
原地升级
我们将按照成本最高、风险最低到成本最低、风险最高的顺序处理它们。与大多数事物一样,存在权衡,消除了一刀切、普遍理想解决方案的机会。需要考虑成本和收益,以找到适合您需求、预算和风险容忍度的正确解决方案。此外,在每种策略中,还有各种自动化和测试程度,再次取决于工程预算、风险容忍度和升级频率。
请记住,这些策略并不是互斥的。您可以使用组合。例如,您可以对专用的 etcd 集群执行就地升级,然后对其余的 Kubernetes 集群使用节点替换。您也可以在风险容忍度不同的不同层次上使用不同的策略。但建议在生产环境中使用相同的策略,以便您首先在开发和演示环境中彻底测试过的方法。
无论您采用哪种策略,一些原则始终不变:彻底测试并尽可能自动化。如果您构建自动化执行操作并在测试、开发和演示集群中彻底测试该自动化,您的生产升级就不太可能给最终用户带来问题,也不太可能给您的平台运维团队带来压力。
集群替换
集群替换是成本最高、风险最低的解决方案。它的风险低,因为它遵循应用于整个集群的不可变基础设施原则。升级是通过在旧集群旁边部署一个全新的集群来完成的。工作负载从旧集群迁移到新集群。随着工作负载的迁移,新升级的集群根据需要进行扩展。旧集群的工作节点在工作负载迁移完成后进行缩减。但在整个升级过程中,完全不同的新集群会增加相关的成本。新集群的扩展和旧集群的缩减可以减轻这种成本,也就是说,如果您要升级一个 300 节点的生产集群,您不需要一开始就为其提供 300 个节点的新集群。您可以先为其提供,比如说,20 个节点的集群。当迁移了少量工作负载后,您可以缩减使用较少的旧集群,并扩展新集群以适应其他新增的工作负载。集群自动伸缩和过量配置可以使这一过程非常流畅,但仅仅升级通常不足以成为使用这些技术的充分理由。在处理集群替换时存在两个常见的挑战。
第一项是管理入口流量。随着工作负载从一个集群迁移到下一个集群,流量需要重新路由到新的升级集群。这意味着公开的工作负载的 DNS 不解析到集群入口,而是解析到一个全局服务负载均衡器(GSLB)或反向代理,然后再将流量路由到集群入口。这为您提供了一个管理流量路由到多个集群的点。
另一个是持久存储的可用性。如果使用存储服务或设备,同样的存储需要从两个集群都可以访问。如果使用公共云提供商的托管服务,例如数据库服务,必须确保同样的服务从两个集群都可以访问。在私有数据中心,这可能涉及网络和防火墙问题。在公共云中,这将涉及网络和可用区的问题;例如,AWS EBS 卷是从特定的可用区可用的。而 AWS 中的托管服务通常与特定的虚拟私有云(VPC)相关联。因此,出于这个原因,您可能考虑为多个集群使用单个 VPC。通常 Kubernetes 安装程序假设每个集群一个 VPC,但这并不总是最佳模型。
接下来,您将关注工作负载的迁移。主要是指 Kubernetes 资源本身——部署(Deployments)、服务(Services)、配置映射(ConfigMaps)等。您可以通过以下两种方式进行工作负载迁移:
-
重新部署来自已声明的真实来源
-
将现有资源从旧集群复制过来
第一种选项可能涉及将部署管道指向新集群,并让其重新部署相同的资源到新集群中。这假设您在版本控制中拥有资源定义的真实来源是可靠的,并且没有进行原地更改。实际上,这种情况相当罕见。通常,人类、控制器和其他系统都进行了原地更改和调整。如果情况是这样的话,您需要选择第二种选项,并复制现有资源并将其部署到新集群中。这就是像 Velero 这样的工具极为有价值的地方。Velero 更常被吹捧为备份工具,但它作为迁移工具的价值可能更高。Velero 可以对集群中的所有资源或子集进行快照。因此,如果您一次迁移一个命名空间的工作负载,您可以在迁移时对每个命名空间进行快照,并以高度可靠的方式将这些快照恢复到新集群中。它并非直接从 etcd 数据存储中获取这些快照,而是通过 Kubernetes API,因此只要您可以为 Velero 提供对两个集群的 API 服务器的访问权限,这种方法就非常有用。图 2-6 展示了这种方法。

图 2-6. 使用 Velero 在集群之间迁移工作负载的备份和恢复。
节点替换
节点替换选项代表了成本和风险之间的中间地带。这是一种常见的方法,并受 Cluster API 支持。如果你管理较大的集群并且兼容性问题已经得到了充分理解,那么这是一个可以接受的选项。这种方法的最大风险之一是兼容性问题,因为在集群服务和工作负载方面,你正在原地升级控制平面。如果你在原地升级 Kubernetes,并且你的某个工作负载正在使用的 API 版本不再存在,你的工作负载可能会出现故障。有几种方法可以减轻这种风险:
-
阅读 Kubernetes 发布说明。在部署包含 Kubernetes 版本升级的新版本平台之前,务必彻底阅读 CHANGELOG。那里详细记录了任何 API 弃用或移除的信息,因此你将有充足的提前通知。
-
在生产之前进行彻底的测试。在开发和分级集群中广泛运行你平台的新版本,然后再推广到生产环境。在发布后不久在开发环境中运行最新版本的 Kubernetes,并且你将能够彻底测试,同时在生产环境中仍然运行最新版本的 Kubernetes。
-
避免与 API 的紧密耦合。这不适用于在你的集群中运行的平台服务。那些服务天生就需要与 Kubernetes 紧密集成。但是,尽可能使你的最终用户和生产工作负载与平台无关。不要将 Kubernetes API 作为依赖。例如,你的应用程序不应该知道 Kubernetes Secrets 的存在。它应该只是消耗一个环境变量或读取一个对它可见的文件。这样,只要用于部署应用程序的清单得到升级,应用程序工作负载本身将继续正常运行,不受 API 更改的影响。如果你发现希望在工作负载中利用 Kubernetes 特性,请考虑使用 Kubernetes operator。operator 的故障不应影响你应用程序的可用性。operator 的故障将是一个需要紧急解决的问题,但你的客户或最终用户不应该感知到这个问题,这是完全不同的情况。
节点替换选项在你提前构建并经过充分测试和验证的机器镜像时非常有益。然后你可以启动新的机器,并轻松将它们加入集群。这个过程将会很快,因为所有更新的软件,包括操作系统和软件包,已经安装好了,而且部署这些新机器的过程可以与原始部署使用的过程大致相同。
当替换集群的节点时,请从控制平面开始。如果你运行一个专用的 etcd 集群,请从那里开始。集群的持久化数据至关重要,必须小心处理。如果在升级第一个 etcd 节点时遇到问题,如果你准备充分,中止升级将相对不麻烦。如果你升级了所有的工作节点和 Kubernetes 控制平面,然后发现在升级 etcd 时出现问题,你就会处于无法实际回滚整个升级的情况——你需要优先解决实时问题。你失去了中止整个过程、重新整理、重新测试并稍后继续的机会。你需要解决那个问题,或者至少要切实确保你可以安全地将现有版本保持不变一段时间。
对于专用的 etcd 集群,请考虑采用减量方式替换节点;即先删除一个节点,然后添加升级后的替代节点,而不是先添加一个节点到集群,然后再删除旧节点。这种方法使你有机会保持每个 etcd 节点的成员列表不变。例如,向三节点 etcd 集群添加第四个成员将需要更新所有 etcd 节点的成员列表,并需要重新启动。如果可能的话,通过删除一个成员并用具有相同 IP 地址的新成员替换它,会显得少打扰得多。关于 etcd 的升级文档非常出色,可能会导致你考虑对 etcd 进行原地升级。这将需要在适用的情况下在机器上进行原地升级操作系统和软件包,但通常会非常可接受且完全安全。
对于控制平面节点,它们可以通过增量方式进行替换。在安装了升级的 Kubernetes 二进制文件(kubeadm、kubectl、kubelet)的新机器上使用 kubeadm join 和 --control-plane 标志。当每个控制平面节点上线并确认运行正常后,可以排空一个旧版本节点,然后将其删除。如果你在控制平面节点上运行 etcd,则在确认运行正常性和根据需要管理集群成员时包括 etcd 检查和 etcdctl。
然后可以继续替换工作节点。这可以通过增量或减量的方式进行——一次一个或一次多个。这里的一个主要关注点是集群的利用率。如果你的集群利用率很高,你会希望在排空和移除现有节点之前添加新的工作节点,以确保你有足够的计算资源来支持被重新安置的 Pod。同样,一个好的模式是使用已安装所有更新软件的机器镜像,在线并使用 kubeadm join 添加到集群中。并且,这可以使用与集群部署中使用的许多相同机制来实现。图 2-7 说明了逐个替换控制平面节点和分批替换工作节点的操作。

图 2-7. 在集群中替换节点执行升级。
原地升级
在资源受限环境中,原地升级适合于无法替换节点的情况。回滚路径更为复杂,因此风险更高。但通过全面的测试可以缓解这种风险。同时也要记住,生产配置下的 Kubernetes 是一个高可用系统。如果逐个节点进行原地升级,则风险会降低。因此,如果使用类似 Ansible 这样的配置管理工具来执行升级操作的步骤,请抵制在生产环境中一次性对所有节点进行操作的诱惑。
对于 etcd 节点,请遵循该项目的文档,逐个节点脱机,执行 OS、etcd 和其他软件包的升级,然后将其重新联机。如果在容器中运行 etcd,请考虑在将成员脱机之前预拉取相关镜像,以减少停机时间。
对于 Kubernetes 控制平面和工作节点,如果使用 kubeadm 初始化集群,那么在升级时也应使用该工具。上游文档详细说明了如何从 1.13 版本开始对每个次要版本进行此过程。总而言之,始终要计划失败,尽可能自动化,并进行广泛测试。
这就结束了升级选项。现在,让我们回到开始的话题——您用来触发这些集群配置和升级选项的机制。我们将此主题放在最后讨论,因为它需要我们在本章节中所涵盖的所有内容的背景信息。
触发机制
现在我们已经看过 Kubernetes 部署模型中需要解决的所有问题,考虑触发安装和管理自动化的机制就显得非常有用,无论采用何种形式。无论是使用 Kubernetes 托管服务、预构建的安装程序,还是自行定制的基础自动化,如何触发集群构建、集群扩展和集群升级都非常重要。
Kubernetes 安装程序通常有一个 CLI 工具,可用于启动安装过程。然而,仅仅使用该工具会使您在没有单一真相源或集群清单记录的情况下管理您的集群清单变得困难。
近年来,GitOps 方法变得越来越流行。在这种情况下,真相源是包含集群配置的代码仓库。当提交新集群的配置时,触发自动化以部署新集群。当更新现有配置时,触发自动化以更新集群,可能扩展工作节点数量或升级 Kubernetes 和集群附加组件。
另一种更符合 Kubernetes 本地化的方法是在 Kubernetes 自定义资源中表示集群及其依赖关系,然后使用 Kubernetes 运算符根据这些自定义资源中声明的状态来 provisioning 集群。这是像 Cluster API 这样的项目采取的方法。在这种情况下,真相的源头是存储在管理集群的 etcd 中的 Kubernetes 资源。然而,通常会使用不同区域或层级的多个管理集群。在这里,可以结合使用 GitOps 方法,其中集群资源清单存储在源代码控制中,流水线将清单提交到适当的管理集群。通过这种方式,您可以同时获得 GitOps 和 Kubernetes 本地化世界的优势。
摘要
当开发 Kubernetes 的部署模型时,请仔细考虑可以利用的托管服务或现有的 Kubernetes 安装程序(免费和许可)。保持自动化作为构建所有系统的指导原则。深入了解所有架构和拓扑相关的问题,特别是如果有需要满足的非常规要求时。深思熟虑基础设施依赖项以及如何将它们整合到部署流程中。仔细考虑如何管理将构成集群的机器。理解将构成集群控制平面的容器化组件。制定安装集群附加组件的一致模式,以提供应用平台的基本功能。在将生产工作负载放入集群之前,版本化您的平台并确保您的第二天管理和升级路径已就位。
第三章:容器运行时
Kubernetes 是一个容器编排器。然而,Kubernetes 本身并不知道如何创建、启动和停止容器。相反,它将这些操作委托给一个可插拔的组件,称为容器运行时。容器运行时是一种软件,用于在集群节点上创建和管理容器。在 Linux 中,容器运行时使用一组内核原语,如控制组(cgroups)和命名空间,从容器镜像生成一个进程。实质上,Kubernetes,尤其是 kubelet,与容器运行时共同工作来运行容器。
正如我们在第一章中讨论的那样,构建基于 Kubernetes 平台的组织面临多种选择。选择使用哪种容器运行时就是其中之一。选择是很好的,因为它让您可以根据自己的需求定制平台,从而促进创新和可能原本无法实现的高级用例。然而,考虑到容器运行时的基本性质,为什么 Kubernetes 不提供一个实现?为什么选择提供一个可插拔的接口,并将责任转移给另一个组件?
要回答这些问题,我们将回顾一下容器的历史以及我们如何到达现在的地步。我们将首先讨论容器的出现以及它们如何改变软件开发的格局。毕竟,没有它们,可能 Kubernetes 并不存在。然后,我们将讨论开放容器倡议(OCI),这是因为需要在容器运行时、镜像和其他工具周围进行标准化而产生的。我们将回顾 OCI 的规范及其与 Kubernetes 的关系。在 OCI 之后,我们将讨论专用于 Kubernetes 的容器运行时接口(CRI)。CRI 是 kubelet 与容器运行时之间的桥梁。它规定了容器运行时必须实现的接口,以便与 Kubernetes 兼容。最后,我们将讨论如何为您的平台选择运行时,并回顾 Kubernetes 生态系统中的可用选项。
容器的出现
控制组(cgroups)和命名空间是实现容器所需的主要组成部分。Cgroups 对进程可以使用的资源量(如 CPU、内存等)施加限制,而命名空间控制进程可以看到的内容(如挂载点、进程、网络接口等)。这两个基本原语自 2008 年以来就已经存在于 Linux 内核中。在命名空间的情况下,更早些时候已经存在。那么,为什么像今天这样的容器才会在多年后变得流行起来呢?
要回答这个问题,我们首先需要考虑软件和 IT 行业当时周围的环境。首先要考虑的一个因素是应用程序的复杂性。应用程序开发人员使用面向服务的架构构建应用程序,甚至开始接受微服务。这些架构为组织带来了各种好处,如可维护性、可扩展性和生产力。然而,它们也导致应用程序组成部分数量的激增。有意义的应用程序可能涉及数十个服务,可能使用多种语言编写。可以想象,开发和发布这些应用程序是(并且继续是)复杂的。另一个要记住的因素是软件迅速成为企业的差异化因素。您能越快推出新功能,您的竞争力就越强。以可靠方式部署软件对企业至关重要。最后,公共云作为托管环境的出现是另一个重要因素。开发人员和运维团队必须确保应用程序在所有环境中表现一致,从开发者的笔记本电脑到运行在他人数据中心的生产服务器。
记住这些挑战,我们可以看到环境已经成熟,可以进行创新。进入 Docker。Docker 使容器变得普及。他们构建了一个抽象层,使开发人员可以通过易于使用的 CLI 构建和运行容器。开发人员无需了解利用容器技术所需的低级内核构造,他们只需在终端中输入 docker run。
虽然并非解决我们所有问题的答案,但容器改善了软件开发生命周期的许多阶段。首先,容器和容器镜像允许开发人员编码应用程序的环境。开发人员不再需要处理缺失或不匹配的应用程序依赖关系。其次,容器通过为测试应用程序提供可复制的环境影响了测试。最后,容器使得将软件部署到生产环境变得更加容易。只要在生产环境中有一个 Docker 引擎,应用程序就可以以最小的摩擦部署。总体而言,容器帮助组织以更加可重复和高效的方式从零到生产部署软件。
容器的出现也催生了一个充满不同工具、容器运行时、容器镜像注册表等多样化生态系统。这个生态系统受到了良好的接受,但也带来了一个新挑战:如何确保所有这些容器解决方案彼此兼容?毕竟,封装性和可移植性保证是容器的主要好处之一。为了解决这一挑战并促进容器的采用,业界汇聚在 Linux 基金会的旗帜下共同合作,推出了一个开放源代码规范:Open Container Initiative。
开放容器倡议
随着容器在行业中持续流行,清楚地需要制定标准和规范以确保容器运动的成功。Open Container Initiative(OCI)是一个于 2015 年成立的开源项目,旨在协作制定关于容器的规范。这一倡议的重要创始人包括 Docker(将 runc 捐赠给 OCI)和 CoreOS(通过 rkt 推动容器运行时的发展)。
OCI 包括三个规范:OCI 运行时规范、OCI 镜像规范和 OCI 分发规范。这些规范促进了围绕容器和容器平台(如 Kubernetes)的开发和创新。此外,OCI 的目标是允许最终用户以便捷和互操作的方式使用容器,使他们在必要时更轻松地在产品和解决方案之间进行迁移。
在接下来的章节中,我们将探讨运行时和镜像规范。我们不会深入讨论分发规范,因为它主要涉及容器镜像注册表。
OCI Runtime 规范
OCI 运行时规范决定如何以兼容 OCI 的方式实例化和运行容器。首先,规范描述了容器配置的模式。模式包括容器的根文件系统、运行命令、环境变量、要使用的用户和用户组、资源限制等信息。以下摘录是从 OCI 运行时规范获取的容器配置文件的简化示例:
{
"ociVersion": "1.0.1",
"process": {
"terminal": true,
"user": {
"uid": 1,
"gid": 1,
"additionalGids": [
5,
6
]
},
"args": [
"sh"
],
"env": [
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"TERM=xterm"
],
"cwd": "/",
...
},
...
"mounts":
{
"destination": "/proc",
"type": "proc",
"source": "proc"
},
...
},
...
}
运行时规范还确定了容器运行时必须支持的操作。这些操作包括创建、启动、终止、删除和状态(提供容器状态信息)。除了操作外,运行时规范描述了容器的生命周期及其在不同阶段的进展。生命周期阶段包括(1)creating,即容器运行时正在创建容器时的活动状态;(2)created,即运行时已完成create操作时的状态;(3)running,即容器进程已启动并正在运行时的状态;以及(4)stopped,即容器进程已完成运行时的状态。
OCI 项目还包括 runc,这是一个实现 OCI 运行时规范的低级容器运行时。其他高级容器运行时如 Docker、containerd 和 CRI-O 使用 runc 根据 OCI 规范生成容器,如 [图 3-1 所示。利用 runc,容器运行时可以专注于诸如拉取镜像、配置网络、处理存储等高级功能,同时遵循 OCI 运行时规范。

图 3-1. Docker Engine、containerd 和其他运行时根据 OCI 规范使用 runc 生成容器。
OCI 镜像规范
OCI 镜像规范关注容器镜像。规范定义了一个清单、一个可选的镜像索引、一组文件系统层和一个配置。镜像清单描述了镜像。它包括指向镜像配置、镜像层列表和可选的注释映射的指针。以下是从 OCI 镜像规范获取的示例清单:
{
"schemaVersion": 2,
"config": {
"mediaType": "application/vnd.oci.image.config.v1+json",
"size": 7023,
"digest": "sha256:b5b2b2c507a0944348e0303114d8d93aaaa081732b86451d9bce1f4..."
},
"layers": [
{
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"size": 32654,
"digest": "sha256:9834876dcfb05cb167a5c24953eba58c4ac89b1adf57f28f2f9d0..."
},
{
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"size": 16724,
"digest": "sha256:3c3a4604a545cdc127456d94e421cd355bca5b528f4a9c1905b15..."
},
{
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"size": 73109,
"digest": "sha256:ec4b8955958665577945c89419d1af06b5f7636b4ac3da7f12184..."
}
],
"annotations": {
"com.example.key1": "value1",
"com.example.key2": "value2"
}
}
镜像索引 是一个顶级清单,用于创建多平台容器镜像。镜像索引包含指向每个平台特定清单的指针。以下是从规范中获取的示例索引。请注意索引如何指向两个不同的清单,一个是 ppc64le/linux,另一个是 amd64/linux:
{
"schemaVersion": 2,
"manifests": [
{
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"size": 7143,
"digest": "sha256:e692418e4cbaf90ca69d05a66403747baa33ee08806650b51fab...",
"platform": {
"architecture": "ppc64le",
"os": "linux"
}
},
{
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"size": 7682,
"digest": "sha256:5b0bcabd1ed22e9fb1310cf6c2dec7cdef19f0ad69efa1f392e9...",
"platform": {
"architecture": "amd64",
"os": "linux"
}
}
],
"annotations": {
"com.example.key1": "value1",
"com.example.key2": "value2"
}
}
每个 OCI 镜像清单都引用一个容器镜像配置。配置包括镜像的入口点、命令、工作目录、环境变量等。容器运行时在实例化镜像时使用此配置。以下摘录显示了容器镜像配置的部分内容,为了简洁起见省略了一些字段:
{
"architecture": "amd64",
"config": {
...
"ExposedPorts": {
"53/tcp": {},
"53/udp": {}
},
"Tty": false,
"OpenStdin": false,
"StdinOnce": false,
"Env": [
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
],
"Cmd": null,
"Image": "sha256:7ccecf40b555e5ef2d8d3514257b69c2f4018c767e7a20dbaf4733...",
"Volumes": null,
"WorkingDir": "",
"Entrypoint": [
"/coredns"
],
"OnBuild": null,
"Labels": null
},
"created": "2020-01-28T19:16:47.907002703Z",
...
OCI 镜像规范还描述了如何创建和管理容器镜像层。层实质上是包含文件和目录的 TAR 存档。规范定义了不同的层媒体类型,包括未压缩层、gzip 压缩层和不可分发层。每个层都通过摘要唯一标识,通常是层内容的 SHA256 摘要。如前所述,容器镜像清单引用一个或多个层。这些引用使用 SHA256 摘要指向特定的层。最终容器镜像文件系统是应用每个层后的结果,如清单中所列。
OCI 镜像规范至关重要,因为它确保容器镜像在不同工具和基于容器的平台上是可移植的。该规范支持开发不同的镜像构建工具,如 kaniko 和 Buildah 用于用户空间容器构建,Jib 用于基于 Java 的容器,以及 Cloud Native Buildpacks 用于简化和自动化构建。我们将在 第十五章 中探讨其中一些工具。总体而言,该规范确保 Kubernetes 可以运行不管使用何种构建工具生成的容器镜像。
容器运行时接口
正如我们在之前的章节中讨论过的,Kubernetes 提供了许多扩展点,允许您构建一个定制的应用平台。其中一个最关键的扩展点是容器运行时接口 (CRI)。CRI 在 Kubernetes v1.5 中引入,旨在支持包括 CoreOS 的 rkt 和基于虚拟化的运行时,如 Intel 的 Clear Containers(后来成为 Kata Containers)在内的不断增长的容器运行时生态系统。
在 CRI 出现之前,为新的容器运行时添加支持需要发布新版本的 Kubernetes 并对 Kubernetes 代码库有深入了解。一旦建立了 CRI,容器运行时开发人员只需遵循接口即可确保与 Kubernetes 的兼容性。
总体而言,CRI 的目标是将容器运行时的实现细节与 Kubernetes 分离,更具体地说是 kubelet。这是依赖反转原则的一个典型示例。kubelet 从具有分散在各处的容器运行时特定代码和 if 语句的实现逐步演变为依赖接口的更精简实现。因此,CRI 减少了 kubelet 实现的复杂性,同时使其更具可扩展性和可测试性。这些都是设计良好的软件的重要特性。
CRI 使用 gRPC 和 Protocol Buffers 实现。该接口定义了两个服务:RuntimeService 和 ImageService。kubelet 利用这些服务与容器运行时交互。RuntimeService 负责所有与 Pod 相关的操作,包括创建 Pod、启动和停止容器、删除 Pod 等。ImageService 则涉及容器镜像操作,包括在节点上列出、拉取和删除容器镜像等。
虽然我们可以在本章节详细描述 RuntimeService 和 ImageService 的 API,但更有用的是理解 Kubernetes 中可能最重要操作的流程:在节点上启动一个 Pod。因此,让我们在接下来的部分探索 kubelet 与容器运行时通过 CRI 的交互。
启动一个 Pod
注:
以下描述基于 Kubernetes v1.18.2 和 containerd v1.3.4。这些组件使用 CRI 的 v1alpha2 版本。
一旦 Pod 被调度到一个节点上,kubelet 会与容器运行时一起工作,启动 Pod。正如前文所述,kubelet 通过 CRI 与容器运行时进行交互。在这种情况下,我们将探讨 kubelet 与 containerd CRI 插件之间的交互。
containerd CRI 插件启动一个 gRPC 服务器,监听一个 Unix 套接字。默认情况下,此套接字位于 /run/containerd/containerd.sock。kubelet 配置为通过此套接字与 containerd 进行交互,使用 container-runtime 和 container-runtime-endpoint 命令行标志:
/usr/bin/kubelet
--container-runtime=remote
--container-runtime-endpoint=/run/containerd/containerd.sock
... other flags here ...
要启动 Pod,kubelet 首先使用 RuntimeService 的 RunPodSandbox 方法创建一个 Pod 沙箱。因为一个 Pod 由一个或多个容器组成,所以必须首先创建沙箱,以建立 Linux 网络命名空间(等等)供所有容器共享。调用此方法时,kubelet 向 containerd 发送元数据和配置,包括 Pod 的名称、唯一 ID、Kubernetes 命名空间、DNS 配置等。一旦容器运行时创建了沙箱,运行时会返回一个 Pod 沙箱 ID,kubelet 使用该 ID 在沙箱中创建容器。
一旦沙箱可用,kubelet 通过 ImageService 的 ImageStatus 方法检查节点上是否存在容器镜像。ImageStatus 方法返回关于镜像的信息。当镜像不存在时,该方法返回 null,并且 kubelet 继续拉取镜像。当需要时,kubelet 使用 ImageService 的 PullImage 方法拉取镜像。一旦运行时拉取了镜像,它会返回镜像的 SHA256 摘要,kubelet 然后使用该摘要创建容器。
在创建沙箱和拉取镜像后,kubelet 使用 RuntimeService 的 CreateContainer 方法在沙箱中创建容器。kubelet 向容器运行时提供沙箱 ID 和容器配置。容器配置包括您可能期望的所有信息,包括容器镜像摘要、命令和参数、环境变量、卷挂载等。在创建过程中,容器运行时生成一个容器 ID,并将其传递回 kubelet。这个 ID 是您在 Pod 的状态字段下容器状态中看到的那个:
containerStatuses:
- containerID: containerd://0018556b01e1662c5e7e2dcddb2bb09d0edff6cf6933...
image: docker.io/library/nginx:latest
然后,kubelet 继续使用 RuntimeService 的 StartContainer 方法启动容器。在调用此方法时,它使用容器运行时传递的容器 ID。
就是这样!在本节中,我们学习了 kubelet 如何使用 CRI 与容器运行时交互。我们特别关注了启动 Pod 时调用的 gRPC 方法,其中包括 ImageService 和 RuntimeService 上的方法。这两个 CRI 服务除了 Pod 和容器管理(即 CRUD)方法外,还提供了 kubelet 用于完成其他任务的其他方法。除了执行容器内命令(Exec 和 ExecSync)、附加到容器(Attach)、转发特定容器端口(PortForward)等任务外,CRI 还定义了其他方法。
选择运行时
考虑到 CRI 的可用性,平台团队在选择容器运行时时有了灵活性。然而,事实上,在过去几年中,容器运行时已经成为一个实施细节。如果您使用 Kubernetes 发行版或利用托管的 Kubernetes 服务,则容器运行时很可能会为您选择。即使是像 Cluster API 这样的社区项目也是如此,它提供预先构建的包含容器运行时的节点镜像。
话虽如此,如果您确实有选择运行时的选项或者有专用运行时的用例(例如基于 VM 的运行时),您应该具备足够的信息来做出决策。在本节中,我们将讨论选择容器运行时时应考虑的因素。
在帮助现场组织时,我们通常会问的第一个问题是他们对容器运行时有何经验。在大多数情况下,有长期容器历史的组织使用 Docker,并熟悉 Docker 的工具链和用户体验。虽然 Kubernetes 支持 Docker,但我们不鼓励使用它,因为它具有一些 Kubernetes 不需要的扩展功能,例如构建镜像、创建容器网络等。换句话说,完整的 Docker 守护程序对于 Kubernetes 来说过于臃肿。好消息是 Docker 在幕后使用 containerd,这是社区中最普遍的容器运行时之一。不利之处是平台操作员必须学习 containerd 的 CLI。
另一个要考虑的因素是支持的可用性。根据您获取 Kubernetes 的位置,您可能会获得容器运行时的支持。诸如 VMware 的 Tanzu Kubernetes Grid、RedHat 的 OpenShift 等 Kubernetes 发行版通常会预装特定的容器运行时。除非您有极其强烈的理由选择其他选项,否则应坚持这种选择。在这种情况下,请确保您理解使用不同容器运行时的支持影响。
与支持密切相关的是容器运行时的一致性测试。Kubernetes 项目,特别是节点特别兴趣小组(sig-node),定义了一组 CRI 验证测试和节点一致性测试,以确保容器运行时兼容并如预期般行为。这些测试是每个 Kubernetes 发布的一部分,某些运行时可能比其他运行时有更多的覆盖。可以想象,测试覆盖越多越好,因为运行时的任何问题都会在 Kubernetes 发布过程中被捕捉到。社区通过 Kubernetes 测试网格 提供所有测试和结果。在选择运行时时,应考虑容器运行时的一致性测试以及其与整体 Kubernetes 项目的关系。
最后,你应确定你的工作负载是否需要比 Linux 容器提供的更强的隔离保证。虽然不常见,但有些用例需要工作负载的 VM 级别隔离,比如执行不受信任的代码或运行需要强大的多租户保证的应用程序。在这些情况下,你可以利用专用运行时,比如 Kata Containers。
现在我们已经讨论了在选择运行时时应考虑的因素,让我们回顾一下最常见的容器运行时:Docker、containerd 和 CRI-O。我们还将探讨 Kata Containers,以了解如何在虚拟机中运行 Pod,而不是 Linux 容器。最后,虽然 Virtual Kubelet 不是容器运行时或实现 CRI 的组件,但我们将了解它,因为它提供了在 Kubernetes 上运行工作负载的另一种方式。
Docker
Kubernetes 通过称为 dockershim 的 CRI 适配器支持 Docker Engine 作为容器运行时。这个适配器是内置到 kubelet 中的一个组件。本质上,它是一个实现我们在本章前面描述的 CRI 服务的 gRPC 服务器。dockershim 的存在是因为 Docker Engine 没有实现 CRI。为了不特别处理所有 kubelet 代码路径以与 CRI 和 Docker Engine 一起工作,dockershim 作为一个外观,kubelet 可以通过它与 Docker 通信,通过 CRI。dockershim 处理 CRI 调用到 Docker Engine API 调用的转换。图 3-2 描述了 kubelet 如何通过 shim 与 Docker 交互。

图 3-2. kubelet 与 Docker Engine 通过 dockershim 之间的交互。
正如我们在本章前面提到的,Docker 在底层利用 containerd。因此,kubelet 的传入 API 调用最终会被转发到 containerd,containerd 开始容器。最终,生成的容器结束在 containerd 而不是 Docker 守护程序下:
systemd
└─containerd
└─containerd-shim -namespace moby -workdir ...
└─nginx
└─nginx
从故障排除的角度来看,您可以使用 Docker CLI 列出并检查在给定节点上运行的容器。虽然 Docker 没有 Pod 的概念,但 dockershim 将 Kubernetes 命名空间、Pod 名称和 Pod ID 编码到容器的名称中。例如,以下列表显示属于default命名空间中名为nginx的 Pod 的容器。Pod 基础设施容器(即暂停容器)在名称中具有k8s_POD_前缀:
$ docker ps --format='{{.ID}}\t{{.Names}}' | grep nginx_default
3c8c01f47424 k8s_nginx_nginx_default_6470b3d3-87a3-499c-8562-d59ba27bced5_3
c34ad8d80c4d k8s_POD_nginx_default_6470b3d3-87a3-499c-8562-d59ba27bced5_3
您还可以使用 containerd CLI ctr来检查容器,尽管其输出不如 Docker CLI 输出友好。Docker Engine 使用名为moby的 containerd 命名空间:
$ ctr --namespace moby containers list
CONTAINER IMAGE RUNTIME
07ba23a409f31bec7f163a... - io.containerd.runtime.v1.linux
0bfc5a735c213b9b296dad... - io.containerd.runtime.v1.linux
2d1c9cb39c674f75caf595... - io.containerd.runtime.v1.linux
...
最后,如果节点上有crictl可用,您可以使用它。crictl实用程序是由 Kubernetes 社区开发的命令行工具。它是用于通过 CRI 与容器运行时进行交互的 CLI 客户端。尽管 Docker 不实现 CRI,您仍然可以使用crictl与 dockershim Unix 套接字一起使用:
$ crictl --runtime-endpoint unix:///var/run/dockershim.sock ps --name nginx
CONTAINER ID IMAGE CREATED STATE NAME POD ID
07ba23a409f31 nginx@sha256:b0a... 3 seconds ago Running nginx ea179944...
containerd
在构建基于 Kubernetes 的平台时,containerd 可能是我们在现场遇到的最常见的容器运行时。撰写本文时,containerd 是基于 Cluster API 的节点映像中的默认容器运行时,并且在各种托管的 Kubernetes 提供中可用(例如,AKS、EKS 和 GKE)。
containerd 容器运行时通过 containerd CRI 插件实现 CRI。CRI 插件是自 containerd v1.1 起可用的本地 containerd 插件,并且默认启用。containerd 通过 Unix 套接字/run/containerd/containerd.sock公开其 gRPC API。kubelet 在运行 Pod 时使用此套接字与 containerd 进行交互,如图 3-3 所示。

图 3-3. kubelet 与 containerd 通过 containerd CRI 插件之间的交互。
生成的容器的进程树与使用 Docker Engine 时的进程树完全相同。这是预期的,因为 Docker Engine 使用 containerd 来管理容器:
systemd
└─containerd
└─containerd-shim -namespace k8s.io -workdir ...
└─nginx
└─nginx
要检查节点上的容器,您可以使用 containerd CLI ctr。与 Docker 相反,由 Kubernetes 管理的容器位于名为k8s.io的 containerd 命名空间中,而不是moby:
$ ctr --namespace k8s.io containers ls | grep nginx
c85e47fa... docker.io/library/nginx:latest io.containerd.runtime.v1.linux
使用crictl CLI 与 containerd 通过 containerd 的 Unix 套接字进行交互:
$ crictl --runtime-endpoint unix:///run/containerd/containerd.sock ps
--name nginx
CONTAINER ID IMAGE CREATED STATE NAME POD ID
c85e47faf3616 4bb46517cac39 39 seconds ago Running nginx 73caea404b92a
CRI-O
CRI-O 是专为 Kubernetes 设计的容器运行时。从名称中您可能能够看出,它是 CRI 的一个实现。因此,与 Docker 和 containerd 相比,它不适用于 Kubernetes 以外的用途。在撰写本文时,CRI-O 容器运行时的主要使用者之一是 RedHat OpenShift 平台。
与 containerd 类似,CRI-O 通过 Unix 套接字暴露 CRI。 kubelet 使用套接字与 CRI-O 进行交互,通常位于 /var/run/crio/crio.sock。 图 3-4 展示了 kubelet 直接通过 CRI 与 CRI-O 交互的过程。

图 3-4. kubelet 与 CRI-O 使用 CRI API 的交互。
在生成容器时,CRI-O 实例化了一个名为 conmon 的进程。 Conmon 是容器监视器。 它是容器进程的父进程,并处理多个问题,例如提供连接到容器的方式,将容器的 STDOUT 和 STDERR 流存储到日志文件中,并处理容器的终止:
systemd
└─conmon -s -c ed779... -n k8s_nginx_nginx_default_e9115... -u8cdf0c...
└─nginx
└─nginx
因为 CRI-O 被设计为 Kubernetes 的低级组件,所以 CRI-O 项目不提供 CLI。 话虽如此,您可以像对待其他实现 CRI 的容器运行时一样使用 crictl 与 CRI-O 进行交互:
$ crictl --runtime-endpoint unix:///var/run/crio/crio.sock ps --name nginx
CONTAINER IMAGE CREATED STATE NAME POD ID
8cdf0c... nginx@sha256:179... 2 minutes ago Running nginx eabf15237...
Kata Containers
Kata Containers 是一个开源的专用运行时,使用轻量级虚拟机而不是容器来运行工作负载。 该项目源于两个先前基于 VM 的运行时的合并:Intel 的 Clear Containers 和 Hyper.sh 的 RunV。
由于使用了虚拟机,Kata 提供比 Linux 容器更强的隔离保证。 如果您有安全要求,禁止工作负载共享 Linux 内核,或者有 cgroup 隔离无法满足的资源保证要求,那么 Kata Containers 可能是一个很好的选择。 例如,Kata 容器的常见用例是运行运行不受信任代码的多租户 Kubernetes 集群。 云提供商如 百度云 和 华为云 在其云基础设施中使用 Kata Containers。
要在 Kubernetes 中使用 Kata Containers,仍然需要一个可插拔的容器运行时放置在 kubelet 和 Kata 运行时之间,如 图 3-5 所示。 原因在于 Kata Containers 没有实现 CRI。 取而代之,它利用现有的容器运行时(如 containerd)来处理与 Kubernetes 的交互。 为了与 containerd 集成,Kata Containers 项目实现了 containerd 运行时 API,特别是 v2 containerd-shim API。

图 3-5. kubelet 与 Kata Containers 之间通过 containerd 的交互。
因为节点上需要并且可用 containerd,因此可以在同一节点上运行 Linux 容器 Pod 和基于 VM 的 Pod。 Kubernetes 提供了一种称为 Runtime Class 的机制,用于配置和运行多个容器运行时。 使用 RuntimeClass API,可以在同一 Kubernetes 平台上提供不同的运行时,使开发人员可以选择更适合其需求的运行时。 下面的片段是 Kata Containers 运行时的示例 RuntimeClass:
apiVersion: node.k8s.io/v1beta1
kind: RuntimeClass
metadata:
name: kata-containers
handler: kata
要在kata-containers运行时下运行一个 Pod,开发者必须在他们 Pod 的规格中指定运行时类名:
apiVersion: v1
kind: Pod
metadata:
name: kata-example
spec:
containers:
- image: nginx
name: nginx
runtimeClassName: kata-containers
Kata Containers 支持不同的虚拟化程序来运行工作负载,包括 QEMU,NEMU,和 AWS Firecracker。例如,当使用 QEMU 时,我们可以在使用 kata-containers 运行时类的 Pod 启动后看到一个 QEMU 进程:
$ ps -ef | grep qemu
root 38290 1 0 16:02 ? 00:00:17
/snap/kata-containers/690/usr/bin/qemu-system-x86_64
-name sandbox-c136a9addde4f26457901ccef9de49f02556cc8c5135b091f6d36cfc97...
-uuid aaae32b3-9916-4d13-b385-dd8390d0daf4
-machine pc,accel=kvm,kernel_irqchip
-cpu host
-m 2048M,slots=10,maxmem=65005M
...
虽然 Kata Containers 提供了一些有趣的功能,但我们认为它是一个小众产品,并且没有看到它在实际场景中的应用。话虽如此,如果你在 Kubernetes 集群中需要 VM 级别的隔离保证,那么 Kata Containers 值得一试。
虚拟 Kubelet
虚拟 Kubelet 是一个开源项目,行为类似于 kubelet,但在后端提供可插拔 API。虽然它本身不是一个容器运行时,但其主要目的是公开替代运行时以运行 Kubernetes Pods。由于虚拟 Kubelet 的可扩展架构,这些替代运行时本质上可以是任何能运行应用程序的系统,如无服务器框架、边缘框架等。例如,如图 3-6 所示,虚拟 Kubelet 可以在 Azure 容器实例或 AWS Fargate 等云服务上启动 Pods。

图 3-6. 虚拟 Kubelet 在云服务(如 Azure 容器实例、AWS Fargate 等)上运行 Pods。
虚拟 Kubelet 社区提供了多种提供者,如果符合你的需求,你可以利用它们,包括 AWS Fargate、Azure 容器实例、HashiCorp Nomad 等。如果你有更具体的用例,你也可以实现自己的提供者。实现一个提供者涉及使用虚拟 Kubelet 库编写一个 Go 程序,用于处理与 Kubernetes 的集成,包括节点注册、运行 Pods 和导出 Kubernetes 期望的 API。
虽然虚拟 Kubelet 可以启用有趣的场景,但我们还没有在实际场景中遇到需要它的用例。话虽如此,了解其存在是有益的,你应该将其纳入你的 Kubernetes 工具箱。
摘要
容器运行时是基于 Kubernetes 平台的基础组件。毕竟,在没有容器运行时,是不可能运行容器化工作负载的。正如我们在本章学到的那样,Kubernetes 使用容器运行时接口(CRI)与容器运行时进行交互。CRI 的主要优点之一是其可插拔性,这使您可以选择最适合您需求的容器运行时。为了让您了解生态系统中的不同容器运行时选项,我们讨论了一些在实际场景中常见的选项,如 Docker、containerd 等。了解这些不同的选择并进一步探索它们的能力,应该有助于您选择满足应用平台需求的容器运行时。
第四章:容器存储
虽然 Kubernetes 最初是在无状态工作负载领域发展起来的,但运行有状态服务变得越来越普遍。甚至像数据库和消息队列这样复杂的有状态工作负载也在 Kubernetes 集群中找到了应用的方式。为了支持这些工作负载,Kubernetes 需要提供超越临时选项的存储能力。也就是说,系统需要在应用崩溃或工作负载被重新调度到不同主机时提供增强的韧性和可用性。
在本章中,我们将探讨我们的平台如何为应用程序提供存储服务。我们将首先讨论应用程序持久性和存储系统期望的关键问题,然后再深入讨论 Kubernetes 中可用的存储原语。随着我们探讨更高级的存储需求,我们将看到 容器存储接口 (CSI) 的使用,它使我们能够与各种存储提供者进行集成。最后,我们将探讨使用 CSI 插件为我们的应用程序提供自助存储。
注意
存储本身就是一个广阔的主题。我们的意图是为您提供足够的细节,以便您能够为工作负载做出明智的存储决策。如果存储不是您的背景,强烈建议与您的基础设施/存储团队共同讨论这些概念。Kubernetes 不能取代您组织中对存储专业知识的需求!
存储考虑事项
在深入研究 Kubernetes 存储模式和选项之前,我们应该退后一步,分析潜在的存储需求周围的一些关键考虑因素。在基础设施和应用程序级别,思考以下需求是非常重要的。
-
访问模式
-
卷扩展
-
动态供给
-
备份和恢复
-
块、文件和对象存储
-
临时数据
-
选择提供者
访问模式
应用程序可以支持三种访问模式:
ReadWriteOnce (RWO)
单个 Pod 可以对卷进行读写操作。
ReadOnlyMany (ROX)
多个 Pod 可以读取卷。
ReadWriteMany (RWX)
多个 Pod 可以对卷进行读写操作。
对于云原生应用程序,RWO 模式是最常见的模式。当利用常见的提供者如 Amazon Elastic Block Storage (EBS) 或 Azure Disk Storage 时,你只能选择 RWO,因为磁盘只能附加到一个节点。虽然这种限制可能看起来有问题,但大多数云原生应用程序在这种存储环境中表现最佳,因为卷是独占的,提供高性能的读写能力。
我们经常发现遗留应用程序需要 RWX 的需求。通常,它们被设计为假定可以访问网络文件系统(NFS)。当服务需要共享状态时,通常有比通过 NFS 共享数据更优雅的解决方案;例如,使用消息队列或数据库。此外,如果应用程序希望共享数据,通常最好通过 API 公开此数据,而不是授予访问其文件系统的权限。这使得许多情况下对于 RWX 的使用都显得可疑。除非 NFS 是正确的设计选择,否则平台团队可能面临是否提供兼容 RWX 的存储或要求其开发人员重新架构应用程序的艰难选择。如果决定支持 ROX 或 RWX 是必需的,那么可以集成几个提供者,如亚马逊弹性文件系统(EFS)和Azure 文件共享。
卷扩展
随着时间的推移,应用程序可能会开始填满其卷。这可能会带来挑战,因为用更大的卷替换卷将需要数据迁移。其中一个解决方案是支持卷扩展。从像 Kubernetes 这样的容器编排器的角度来看,这涉及到几个步骤:
-
从编排器(例如,通过 PersistentVolumeClaim)请求额外的存储。
-
通过存储提供者扩展卷的大小。
-
扩展文件系统以利用更大的卷。
一旦完成,Pod 将能够访问额外的空间。这个功能取决于我们选择的存储后端以及 Kubernetes 中的集成是否能够促成前面的步骤。本章稍后我们将探讨一个卷扩展的示例。
卷配置
您可以选择两种配置模型:动态配置和静态配置。静态配置假定卷是在节点上为 Kubernetes 创建的。动态配置是指在集群内运行驱动程序,并可以通过与存储提供者通信来满足工作负载的存储请求。在这两种模型中,如果可能的话,动态配置是首选。通常,选择两者之间的一个取决于您的底层存储系统是否有兼容 Kubernetes 的驱动程序。我们稍后会深入讨论这些驱动程序。
备份与恢复
备份是存储的最复杂方面之一,尤其是自动恢复是一个要求。一般来说,备份是数据的副本,用于在数据丢失时使用。通常,我们根据存储系统的可用性保证平衡备份策略。例如,尽管备份始终重要,但在我们的存储系统具有复制保证的情况下,它们的重要性较低,硬件丢失不会导致数据丢失。另一个考虑因素是应用程序可能需要不同的程序来促进备份和恢复。通常,我们可以随时备份整个集群并进行恢复的想法是一个天真的观点,或者至少需要大量的工程努力才能实现。
决定由谁负责应用程序的备份和恢复可能是组织内最具挑战性的讨论之一。可以说,将恢复功能作为平台服务提供可能是一个“好处”。然而,当我们涉及到应用程序特定的复杂性时,这可能会引发问题,例如当一个应用程序无法重新启动并且需要进行只有开发人员知道的操作时。
Project Velero 是 Kubernetes 状态和应用状态的最受欢迎的备份解决方案之一。如果您希望在集群之间迁移或恢复它们,Velero 可以备份 Kubernetes 对象。此外,Velero 支持调度卷快照。在本章节深入讨论卷快照时,我们会了解到,安排和管理快照的能力并不是自动完成的。更进一步,我们通常提供快照基元,但需要定义围绕它们的编排流程。最后,Velero 支持备份和恢复钩子。这些钩子允许我们在执行备份或恢复之前在容器中运行命令。例如,某些应用可能需要在进行备份之前停止流量或触发刷新。Velero 通过钩子使这成为可能。
块设备和文件与对象存储
我们的应用程序期望的存储类型对选择适当的底层存储和 Kubernetes 集成至关重要。应用程序使用的最常见的存储类型是文件存储。文件存储是一个带有文件系统的块设备。这使得应用程序可以按照我们在任何操作系统上熟悉的方式写入文件。
在文件系统的底层是一个块设备。与在其上建立文件系统不同,我们可以提供设备,以便应用程序可以直接与原始块通信。文件系统在写入数据时固有地增加了开销。在现代软件开发中,我们很少会担心文件系统的开销。但是,如果您的用例需要直接与原始块设备进行交互,某些存储系统可以支持这一点。
最后一种存储类型是对象存储。对象存储在文件的意义上有所偏离,因为它没有传统的层次结构。对象存储使开发人员能够获取非结构化数据,为其添加唯一标识符,添加一些元数据,并存储它。云提供商的对象存储,例如Amazon S3,已成为组织托管图像、二进制文件等的流行位置。这种流行度得益于其功能齐全的 Web API 和访问控制。对象存储通常由应用程序本身进行交互,应用程序使用库进行身份验证和与提供程序的交互。由于对象存储的交互接口标准化程度较低,因此不常见将其集成为应用程序可以透明交互的平台服务。
短暂数据
虽然存储可能意味着超出 Pod 生命周期的持久性水平,但支持短暂数据使用的有效用例是存在的。默认情况下,写入自己文件系统的容器将利用短暂存储。如果容器重新启动,这些存储将丢失。emptyDir 卷类型用于能够抵御重新启动的短暂存储。这不仅对容器重新启动具有韧性,而且可以用于在同一 Pod 中的容器之间共享文件。
短暂数据的最大风险是确保您的 Pod 不会占用主机存储容量过多。尽管每个 Pod 的 4Gi 数字可能看起来并不多,但考虑一个节点可以运行数百个,有时甚至数千个 Pod。Kubernetes 支持限制命名空间中的 Pod 可用短暂存储的累计量。这些问题的配置在第十二章中有详细介绍。
选择存储提供者
可供选择的存储提供者众多。选项从您可能自行管理的存储解决方案,例如 Ceph,到像 Google Persistent Disk 或 Amazon Elastic Block Store 这样的完全托管系统。选项的差异远远超出了本书的范围。然而,我们建议了解存储系统的能力以及这些能力与 Kubernetes 容易集成的情况。这将使您能够评估一种解决方案相对于另一种解决方案在满足应用程序需求方面的表现。此外,在可能的情况下,如果您正在管理自己的存储系统,请考虑使用您已经具有操作经验的内容。将 Kubernetes 引入新的存储系统将为您的组织增加大量新的运营复杂性。
Kubernetes 存储基元
Kubernetes 提供了多个原语来支持工作负载存储。这些原语为我们提供了构建复杂存储解决方案所需的基础。在本节中,我们将使用一个例子来介绍 PersistentVolumes、PersistentVolumeClaims 和 StorageClasses,将快速预配置的存储分配给容器。
持久卷和声明
在 Kubernetes 中,卷和声明是存储的基础。这些通过 PersistentVolume 和 PersistentVolumeClaim API 公开。PersistentVolume 资源表示 Kubernetes 中已知的存储卷。假设管理员已经准备好一个节点,提供了 30Gi 的快速本地存储在 /mnt/fast-disk/pod-0。为了在 Kubernetes 中表示这个卷,管理员可以创建一个 PersistentVolume 对象:
apiVersion: v1
kind: PersistentVolume
metadata:
name: pv0
spec:
capacity:
storage: 30Gi 
volumeMode: Filesystem 
accessModes:
- ReadWriteOnce 
storageClassName: local-storage 
local:
path: /mnt/fast-disk/pod-0
nodeAffinity: 
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- test-w
此卷中可用的存储量。用于确定声明是否能绑定到此卷。
指定卷是 块设备 还是文件系统。
指定卷的访问模式。包括 ReadWriteOnce、ReadMany 和 ReadWriteMany。
将此卷与存储类关联,用于将最终的声明与此卷配对。
标识应该将此卷关联到哪个节点。
如您所见,PersistentVolume 包含有关卷实现的详细信息。为了提供更高一层的抽象,引入了 PersistentVolumeClaim,它根据其请求绑定到适当的卷。通常情况下,这将由应用团队定义,并添加到他们的 Namespace,并从他们的 Pod 引用:
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: pvc0
spec:
storageClassName: local-storage 
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 30Gi 
---
apiVersion: v1
kind: Pod
metadata:
name: task-pv-pod
spec:
volumes:
- name: fast-disk
persistentVolumeClaim:
claimName: pvc0 
containers:
- name: ml-processer
image: ml-processer-image
volumeMounts:
- mountPath: "/var/lib/db"
name: fast-disk
检查是否有一个类为 local-storage,访问模式为 ReadWriteOnce 的卷。
绑定到一个具有 >= 30Gi 存储量的卷。
声明此 Pod 作为 PersistentVolumeClaim 的消费者。
基于 PersistentVolume 的 nodeAffinity 设置,Pod 将自动调度到具有此卷的主机上。开发者无需额外配置亲和性。
这个过程展示了管理员如何将这些存储提供给开发人员的非常手动的流程。我们称之为静态配置。通过适当的自动化,这可以成为向 Pod 暴露快速磁盘的可行方式。例如,可以部署本地持久卷静态提供程序到集群中,以检测预分配的存储并自动将其暴露为持久卷。它还提供一些生命周期管理功能,例如在销毁持久卷索赔时删除数据。
警告
有多种方法可以实现本地存储,这可能导致不良实践。例如,允许开发人员使用hostPath似乎很有吸引力,而不需要预先配置本地存储。hostPath 允许您指定绑定到主机的路径,而不必使用 PersistentVolume 和 PersistentVolumeClaim。这可能是一个巨大的安全风险,因为它允许开发人员绑定到主机上的目录,这可能会对主机和其他 Pod 产生负面影响。如果您希望为开发人员提供能够经受 Pod 重启但不能承受 Pod 被删除或移动到不同节点的临时存储,可以使用EmptyDir。这将在由 Kube 管理的文件系统中分配存储,并对 Pod 透明。
存储类
在许多环境中,期望节点提前准备好磁盘和卷是不现实的。这些情况通常需要动态配置,可以根据我们索赔的需要提供卷。为了促进这种模型,我们可以向开发人员提供存储类。这些使用StorageClass API 定义。假设您的集群在 AWS 上运行,并且想要动态地向 Pod 提供 EBS 卷,可以添加以下 StorageClass:
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: ebs-standard 
annotations:
storageclass.kubernetes.io/is-default-class: true 
provisioner: kubernetes.io/aws-ebs 
parameters: 
type: io2
iopsPerGB: "17"
fsType: ext4
可以从索赔引用的 StorageClass 名称。
将此 StorageClass 设置为默认值。如果索赔未指定类,则将使用此值。
使用aws-ebs提供程序根据索赔创建卷。
用于配置特定于提供程序的卷配置。
通过提供多个 StorageClass,您可以向开发人员提供各种存储选项。这包括在单个集群中支持多个提供者,例如同时运行 Ceph 和 VMware vSAN。或者,您可以通过同一提供者提供不同层次的存储。例如,提供更便宜的存储选项以及更昂贵的选项。不幸的是,Kubernetes 缺乏细粒度的控制来限制开发人员可以请求的类别。可以实现控制作为验证入场控制,这在第八章中有详细介绍。
Kubernetes 提供各种各样的提供者,包括 AWS EBS、Glusterfs、GCE PD、Ceph RBD 等。从历史上看,这些提供者是在内核中实现的。这意味着存储提供者需要在核心 Kubernetes 项目中实现其逻辑。然后,此代码将与相关的 Kubernetes 控制平面组件一起发布。
这种模型存在几个缺点。首先,存储提供者无法独立管理。对提供者的所有更改都必须与 Kubernetes 发布版本绑定。此外,每个 Kubernetes 部署都会包含不必要的代码。例如,运行 AWS 的集群仍包含用于与 GCE PDs 交互的提供者代码。很快就显而易见,将这些提供者集成外部化并弃用内核功能具有高度价值。FlexVolume 驱动程序是最初旨在解决这一问题的一种基于外部实现规范。然而,FlexVolumes 已进入维护模式,以支持我们接下来要讨论的容器存储接口(CSI)。
容器存储接口(CSI)
容器存储接口是我们如何为工作负载提供块和文件存储的答案。CSI 的实现被称为驱动程序,这些驱动程序具有与存储提供者通信的操作知识。这些提供者涵盖从诸如Google 持久磁盘等云系统到由您部署和管理的存储系统(例如Ceph)。这些驱动程序由存储提供者在项目中实现,这些项目独立于集群之外完全管理。
在高层次上,CSI 的实现包括控制器插件和节点插件。CSI 驱动程序开发人员在实现这些组件时具有很大的灵活性。通常,实现会将控制器和节点插件捆绑在同一个二进制文件中,并通过环境变量(例如 X_CSI_MODE)启用任一模式。唯一的期望是驱动程序在 kubelet 中注册,并实现 CSI 规范中的端点。
控制器服务负责管理存储提供程序中卷的创建和删除。此功能扩展到(可选)功能,例如获取卷快照和扩展卷。节点服务负责准备卷以供节点上的 Pod 使用。通常意味着设置挂载点并报告有关节点上卷的信息。节点和控制器服务还实现了报告插件信息、能力以及插件是否健康的身份服务。因此,图 4-1 描述了部署了这些组件的集群架构。

图 4-1. 集群运行 CSI 插件。驱动程序以节点和控制器模式运行。控制器通常作为 Deployment 运行。节点服务部署为 DaemonSet,在每个主机上放置一个 Pod。
让我们更深入地了解这两个组件,即控制器和节点。
CSI 控制器
CSI 控制器服务提供管理持久存储系统中卷的 API。Kubernetes 控制平面 不直接 与 CSI 控制器服务交互。相反,由 Kubernetes 存储社区维护的控制器会对 Kubernetes 事件做出反应,并将其转换为 CSI 指令,例如在创建新的 PersistentVolumeClaim 时的 CreateVolumeRequest。由于 CSI 控制器服务通过 UNIX sockets 公开其 API,控制器通常作为 CSI 控制器服务的 sidecar 部署。有多个外部控制器,每个控制器具有不同的行为:
external-provisioner
创建 PersistentVolumeClaims 时,请求从 CSI 驱动程序创建卷。一旦在存储提供程序中创建卷,此提供程序将在 Kubernetes 中创建 PersistentVolume 对象。
external-attacher
监视 VolumeAttachment 对象,声明应将卷附加到节点或从节点分离。向 CSI 驱动程序发送附加或分离请求。
external-resizer
检测 PersistentVolumeClaims 中存储大小的变化。向 CSI 驱动程序发送扩展请求。
external-snapshotter
创建 VolumeSnapshotContent 对象时,会向驱动程序发送快照请求。
注意
在实现 CSI 插件时,开发者并非必须使用上述控制器。但是,鼓励使用这些控制器,以防止在每个 CSI 插件中重复逻辑。
CSI 节点
节点插件通常运行与控制器插件相同的驱动程序代码。但是,“节点模式”中的运行意味着专注于任务,例如挂载附加的卷、建立其文件系统以及将卷挂载到 Pod。这些行为的请求通过 kubelet 完成。除了驱动程序外,通常还在 Pod 中包括以下 sidecars:
node-driver-registrar
发送 注册请求 到 kubelet 以使其意识到 CSI 驱动程序。
活动探针
报告 CSI 驱动程序的健康状况。
实施存储即服务
现在我们已经涵盖了应用程序存储的关键考虑因素,Kubernetes 中可用的存储原语以及使用 CSI 进行驱动程序集成。现在是时候将这些想法结合起来,看一看提供开发人员存储即服务的实现。我们希望以声明方式提供请求存储并使其可用于工作负载的方法。我们还希望动态实现这一点,不需要管理员预先配置和附加卷。相反,我们希望根据工作负载的需求随需提供。
为了开始这个实现,我们将使用亚马逊网络服务(AWS)。此示例集成了 AWS 的 弹性块 存储系统。如果您选择的提供商不同,这些内容的大部分仍然相关!我们只是使用这个提供商作为所有部分如何组合在一起的具体示例。
接下来我们将深入探讨集成/驱动程序的安装,向开发人员公开存储选项,使用工作负载消耗存储空间,调整卷大小以及获取卷快照。
安装
安装过程相对直接,主要包括两个关键步骤:
-
配置访问提供商。
-
将驱动程序组件部署到集群中。
在本例中,供应商 AWS 将要求驱动程序标识自身,确保其具有适当的访问权限。在这种情况下,我们有三个可选项可供选择。其中一种方法是更新 实例配置文件 的 Kubernetes 节点。这将使我们不必担心 Kubernetes 级别的凭证,但将为能够访问 AWS API 的工作负载提供通用权限。第二种,也可能是最安全的选择是引入一个身份服务,可以为特定工作负载提供 IAM 权限。一个示例项目是 kiam。这种方法在 第十章 中有详细讲解。最后,您可以在挂载到 CSI 驱动程序的密钥中添加凭证。在这种模型中,密钥将如下所示:
apiVersion: v1
kind: Secret
metadata:
name: aws-secret
namespace: kube-system
stringData:
key_id: "AKIAWJQHICPELCJVKYNU"
access_key: "jqWi1ut4KyrAHADIOrhH2Pd/vXpgqA9OZ3bCZ"
警告
该帐户将有权访问操作底层存储系统。应仔细管理对此密钥的访问。有关更多信息,请参阅 第七章。
有了这样的配置,CSI 组件可以安装。首先,控制器作为一个 Deployment 安装。在运行多个副本时,它将使用领导选举确定哪个实例应处于活动状态。然后安装节点插件,它作为 DaemonSet 形式在每个节点上运行一个 Pod。初始化后,节点插件的实例将向其 kubelet 注册。kubelet 然后通过为每个 Kubernetes 节点创建一个 CSINode 对象来报告启用 CSI 的节点。一个三节点集群的输出如下:
$ kubectl get csinode
NAME DRIVERS AGE
ip-10-0-0-205.us-west-2.compute.internal 1 97m
ip-10-0-0-224.us-west-2.compute.internal 1 79m
ip-10-0-0-236.us-west-2.compute.internal 1 98m
如我们所见,列出了三个节点,每个节点上注册了一个驱动程序。检查一个 CSINode 的 YAML 文件揭示了以下内容:
apiVersion: storage.k8s.io/v1
kind: CSINode
metadata:
name: ip-10-0-0-205.us-west-2.compute.internal
spec:
drivers:
- allocatable:
count: 25 
name: ebs.csi.aws.com
nodeID: i-0284ac0df4da1d584
topologyKeys:
- topology.ebs.csi.aws.com/zone 
最大允许在此节点上的卷数。
当选择节点用于工作负载时,此值将传递给 CreateVolumeRequest,以便驱动程序知道在哪里创建卷。对于存储系统而言,集群中的节点无法访问相同的存储是很重要的。例如,在 AWS 中,当 Pod 被调度到可用区时,卷必须在相同的区域创建。
另外,驱动程序已在集群中正式注册。详细信息可以在 CSIDriver 对象中找到:
apiVersion: storage.k8s.io/v1
kind: CSIDriver
metadata:
name: aws-ebs-csi-driver 
labels:
app.kubernetes.io/name: aws-ebs-csi-driver
spec:
attachRequired: true 
podInfoOnMount: false 
volumeLifecycleModes:
- Persistent 
代表此驱动程序的提供者名称。此名称将绑定到我们为平台用户提供的存储类。
指定必须在挂载卷之前完成附加操作。
在设置挂载时,不需要将 Pod 元数据传递为上下文。
永久卷的默认模型。内联支持可以通过将此选项设置为Ephemeral来启用。在短暂模式中,存储预期只持续与 Pod 一样长的时间。
到目前为止,我们探讨的设置和对象都是我们引导过程的产物。CSIDriver 对象使得可以更轻松地发现驱动程序的详细信息,并且包含在驱动程序的部署包中。CSINode 对象由 kubelet 管理。通用的注册器旁路进程包含在节点插件 Pod 中,并从 CSI 驱动程序获取详细信息,并将驱动程序注册到 kubelet 中。然后 kubelet 报告每个主机上可用的 CSI 驱动程序数量。Figure 4-2 展示了这个引导过程。

图 4-2. CSIDriver 对象已部署并包含在包中,同时节点插件与 kubelet 注册。这反过来创建/管理 CSINode 对象。
暴露存储选项
为了为开发者提供存储选项,我们需要创建 StorageClasses。对于这种情况,我们假设有两种类型的存储我们想要暴露。第一个选项是提供便宜的磁盘,可以用于工作负载持久性需求。许多时候,应用程序不需要 SSD,因为它们只是持久化一些不需要快速读写的文件。因此,便宜的磁盘(HDD)将是默认选项。然后,我们希望提供更快的 SSD,每 GB 配置一个定制的IOPS。Table 4-1 展示了我们的提供;价格反映了本文撰写时的 AWS 成本。
Table 4-1. 存储提供
| 提供名称 | 存储类型 | 每卷最大吞吐量 | AWS 成本 |
|---|---|---|---|
| default-block | HDD(优化) | 40–90 MB/s | $0.045 每 GB 每月 |
| performance-block | SSD(io1) | ~1000 MB/s | $0.125 每 GB 每月 + $0.065 每预配的 IOPS 每月 |
为了创建这些产品,我们将为每个创建一个存储类。在每个存储类内部有一个parameters字段。这是我们可以配置以满足 Table 4-1 中功能的设置的地方。
kind: StorageClass
apiVersion: storage.k8s.io/v1
metadata:
name: default-block 
annotations:
storageclass.kubernetes.io/is-default-class: "true" 
provisioner: ebs.csi.aws.com 
allowVolumeExpansion: true 
volumeBindingMode: WaitForFirstConsumer 
parameters:
type: st1 
---
kind: StorageClass 
apiVersion: storage.k8s.io/v1
metadata:
name: performance-block
provisioner: ebs.csi.aws.com
parameters:
type: io1
iopsPerGB: "20"
这是我们为平台用户提供的存储提供的名称。它将被从 PeristentVolumeClaims 引用。
这将设置提供作为默认选项。如果创建 PersistentVolumeClaim 时未指定 StorageClass,则将使用default-block。
映射到应执行哪个 CSI 驱动程序。
允许通过更改 PersistentVolumeClaim 来扩展卷大小。
在 Pod 消耗 PersistentVolumeClaim 之前不要预留卷。这将确保在安排的 Pod 的适当可用性区域中创建卷。它还防止了孤立的 PVC 在 AWS 中创建卷,这些卷将会被计费。
指定驱动程序应获取什么类型的存储以满足要求。
第二类,调整为高性能 SSD。
消费存储
在前面的部分准备好之后,我们现在可以让用户使用这些不同类别的存储了。我们将首先查看开发人员请求存储时的体验。然后我们将详细介绍如何满足这些请求。首先,让我们看看当列出可用的 StorageClasses 时开发者会得到什么:
$ kubectl get storageclasses.storage.k8s.io
NAME PROVISIONER RECLAIMPOLICY VOLUMEBINDINGMODE
default-block (default) ebs.csi.aws.com Delete Immediate
performance-block ebs.csi.aws.com Delete WaitForFirstConsumer
ALLOWVOLUMEEXPANSION
true
true
警告
通过允许开发人员创建 PVCs,我们将允许他们引用任何StorageClass。如果这是一个问题,您可能希望考虑实施验证入场控制来评估请求是否合适。这个主题在 Chapter 8 中有详细介绍。
假设开发者想要为一个应用程序提供更便宜的 HDD 和更高性能的 SSD。在这种情况下,将创建两个 PersistentVolumeClaims。我们将分别称之为pvc0和pvc1:
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: pvc0 
spec:
resources:
requests:
storage: 11Gi
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: pvc1
spec:
resources:
requests:
storage: 14Gi
storageClassName: performance-block 
这将使用默认存储类(default-block),并假设其他默认设置如 RWO 和文件系统存储类型。
确保向驱动程序请求performance-block而不是default-block。
根据 StorageClass 的设置,这两个将表现出不同的供应行为。高性能存储(来自pvc1)在 AWS 中被创建为一个未附加的卷。这种卷可以快速附加并且随时可用。默认存储(来自pv0)将处于Pending状态,集群会等待直到一个 Pod 使用 PVC 来在 AWS 中配置存储。虽然当一个 Pod 最终使用声明时,这将需要更多的工作来配置,但您不会因为未使用的存储而被计费!Kubernetes 中声明与 AWS 中卷之间的关系可以在 图 4-3 中看到。

图 4-3. pv1 在 AWS 中被配置为一个卷,并且 CSIVolumeName 被传播以便于关联。直到一个 Pod 引用它之前,pv0 将不会创建相应的卷。
现在假设开发者创建了两个 Pod。一个 Pod 引用pv0,而另一个引用pv1。一旦每个 Pod 被调度到一个节点上,卷将附加到该节点以供使用。对于pv0,在此之前还需要在 AWS 中创建卷。当 Pod 被调度并且卷被附加后,文件系统就建立起来,存储就被挂载到容器中。由于这些是持久卷,我们现在引入了一种模式,即使 Pod 被重新调度到另一个节点,卷也可以随之移动。我们已经在 图 4-4 中展示了我们如何满足自服务存储请求的端到端流程。

图 4-4. 驱动程序和 Kubernetes 协作满足存储请求的端到端流程。
注意
事件在调试与 CSI 的存储交互中特别有帮助。因为为了满足 PVC,需要按顺序进行供应、附加和挂载,您应该查看这些对象上的事件,以便不同组件报告它们所做的工作。使用kubectl describe -n $NAMESPACE pvc $PVC_NAME可以轻松查看这些事件。
调整大小
调整大小是aws-ebs-csi-driver中支持的特性。在大多数 CSI 实现中,external-resizer控制器用于检测持久卷声明对象的更改。当检测到大小变化时,它会转发给驱动程序,驱动程序将扩展卷。在这种情况下,运行在控制器插件中的驱动程序将利用 AWS EBS API 进行扩展。
扩展 EBS 卷后,新空间不立即可用于容器。这是因为文件系统仍然仅占用原始空间。为了扩展文件系统,我们需要等待节点插件驱动实例扩展文件系统。所有这些操作都可以在不终止 Pod 的情况下完成。文件系统的扩展可以在节点插件的 CSI 驱动程序的以下日志中看到:
mount_linux.go: Attempting to determine if disk "/dev/nvme1n1" is formatted
using blkid with args: ([-p -s TYPE -s PTTYPE -o export /dev/nvme1n1])
mount_linux.go: Output: "DEVNAME=/dev/nvme1n1\nTYPE=ext4\n", err: <nil>
resizefs_linux.go: ResizeFS.Resize - Expanding mounted volume /dev/nvme1n1
resizefs_linux.go: Device /dev/nvme1n1 resized successfully
警告
Kubernetes 不支持缩小 PVC 的 size 字段。除非 CSI 驱动程序提供此类解决方法,否则您可能无法在不重新创建卷的情况下缩小卷的大小。在扩展卷时,请牢记这一点。
快照
为了方便容器使用的卷数据的定期备份,提供了快照功能。功能通常分为两个控制器,分别负责两个不同的 CRD。CRD 包括 VolumeSnapshot 和 VolumeContentSnapshot。在高层次上,VolumeSnapshot 负责卷的生命周期。基于这些对象,VolumeContentSnapshot 由 external-snapshotter 控制器管理。该控制器通常作为 CSI 控制器插件的 sidecar 运行,并将请求转发给驱动程序。
注意
在撰写本文时,这些对象实现为 CRD,而不是核心 Kubernetes API 对象。这要求 CSI 驱动程序或 Kubernetes 发行版预先部署 CRD 定义。
与通过 StorageClasses 提供存储类似,通过引入 Snapshot 类提供快照功能。以下 YAML 表示了此类:
apiVersion: snapshot.storage.k8s.io/v1beta1
kind: VolumeSnapshotClass
metadata:
name: default-snapshots
driver: ebs.csi.aws.com 
deletionPolicy: Delete 
委托快照请求的驱动程序。
当删除 VolumeSnapshot 时是否应删除 VolumeSnapshotContent。实际上,可能会删除实际卷(具体取决于供应商的支持)。
在应用程序的命名空间和 PersistentVolumeClaim 中,可能会创建一个 VolumeSnapshot。示例如下:
apiVersion: snapshot.storage.k8s.io/v1beta1
kind: VolumeSnapshot
metadata:
name: snap1
spec:
volumeSnapshotClassName: default-snapshots 
source:
persistentVolumeClaimName: pvc0 
指定要使用的类别,以通知驱动程序使用。
指定要进行快照的卷索赔。
此对象的存在将通知需要创建 VolumeSnapshotContent 对象。此对象的作用域为整个集群。检测到 VolumeSnapshotContent 对象将导致请求创建快照,并通过与 AWS EBS 通信来满足驱动程序的请求。满足后,VolumeSnapshot 将报告 ReadyToUse。 图 4-5 展示了各种对象之间的关系。

图 4-5. 组成快照流的各种对象及其关系。
有了快照后,我们可以探索数据丢失的场景。无论原始卷是因为意外删除、故障或因 PersistentVolumeClaim 的意外删除而移除,我们都可以恢复数据。为此,创建一个带有指定spec.dataSource的新 PersistentVolumeClaim。dataSource支持引用 VolumeSnapshot,可以将数据填充到新声明中。以下清单从先前创建的快照中恢复:
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: pvc-reclaim
spec:
accessModes:
- ReadWriteOnce
storageClassName: default-block
resources:
requests:
storage: 600Gi
dataSource:
name: snap1 
kind: VolumeSnapshot
apiGroup: snapshot.storage.k8s.io
VolumeSnapshot 实例引用 EBS 快照以填充新的 PVC。
一旦重新创建 Pod 以引用这个新声明,最后一个快照状态将返回到容器中!现在我们可以访问所有用于创建强大的备份和恢复解决方案的基本元素。解决方案可以从通过 CronJob 调度快照、编写自定义控制器,到使用工具如Velero定期备份 Kubernetes 对象及数据卷。
摘要
在本章中,我们探讨了各种容器存储主题。首先,我们希望深入了解应用程序的需求,以便最好地指导我们的技术决策。然后,我们希望确保我们的底层存储提供商能够满足这些需求,并且我们有操作经验(在需要时)。最后,我们应确立编排器与存储系统之间的集成,确保开发人员可以获取所需的存储,而无需精通底层存储系统。
第五章:Pod 网络
自网络的早期以来,我们一直关注如何促进主机之间的通信。这些问题包括唯一地标识主机、在网络之间路由数据包以及已知路由的传播。在过去的十多年中,软件定义网络(SDN)通过解决这些在日益动态的环境中的问题而迅速增长。无论是在您的数据中心使用 VMware NSX 还是在云中使用 Amazon VPC,您很可能是 SDN 的消费者之一。
在 Kubernetes 中,这些原则和愿望仍然适用。虽然我们的单元从主机转移到 Pod,但我们需要确保我们的工作负载具有可寻址性和路由性。此外,考虑到 Pod 作为软件在我们的主机上运行,我们通常会建立完全基于软件定义的网络。
本章将探讨 Pod 网络的概念。我们将首先解释一些必须理解和考虑的关键网络概念,然后我们将介绍容器网络接口(CNI),根据您的网络需求选择网络实现。最后,我们将研究生态系统中的常见插件,如 Calico 和 Cilium,以使权衡更加具体化。最终,您将更加能够就应用平台的正确网络解决方案和配置做出决策。
注意
网络本身是一个广泛的主题。我们的意图是为您提供足够的信息,以便在 Pod 网络上做出知情的决策。如果您的背景不是网络,强烈建议您与您的网络团队一起学习这些概念。Kubernetes 并不消除您组织中需要具备网络专业知识的必要性!
网络考虑事项
在深入讨论 Pod 网络的实施细节之前,我们应该首先考虑几个关键领域。这些领域包括:
-
IP 地址管理(IPAM)
-
路由协议
-
封装和隧道
-
工作负载的路由性
-
IPv4 和 IPv6
-
加密的工作负载流量
-
网络策略
了解了这些领域后,您可以开始确定适合您平台的正确网络解决方案。
IP 地址管理
为了与 Pod 进行通信,我们必须确保它们具有唯一的可寻址性。在 Kubernetes 中,每个 Pod 都会分配一个 IP 地址。这些 IP 地址可能是集群内部的,也可能是外部可路由的。每个 Pod 拥有自己的地址简化了网络模型,因为我们不必担心共享 IP 上的端口冲突。然而,这种每个 Pod 拥有独立 IP 的模型也带来了它自己的挑战。
Pods 可以被视为短暂的实体。具体来说,它们容易因集群的需求或系统故障而重新启动或重新调度。这要求 IP 分配能够快速执行,并且集群 IP 池的管理必须高效。这种管理通常称为IPAM,并不限于 Kubernetes。在我们深入研究容器网络方法时,我们将探讨多种实现 IPAM 的方式。
警告
这种工作负载 IP 的短暂期望在某些传统工作负载中会引发问题,例如将自身固定到特定 IP 并期望其保持静态的工作负载。根据您的容器网络实现(本章后面将介绍),您可以为特定工作负载明确保留 IP。但是,除非必要,我们建议不要采用这种模型。有许多功能强大的服务发现或 DNS 机制可以帮助工作负载有效解决此问题。请查看第六章以获取示例。
IPAM 是基于您选择的 CNI 插件实现的。这些插件中有一些关于 Pod IPAM 的共同点。首先,创建集群时可以指定 Pod 网络的无类域间路由(CIDR)。如何设置取决于您如何引导 Kubernetes。在使用 kubeadm 的情况下,可以传递一个标志,如下所示:
kubeadm init --pod-network-cidr 10.30.0.0/16
这实际上设置了 kube-controller-manager 上的 --cluster-cidr 标志。Kubernetes 随后将为每个节点分配此集群 CIDR 的一部分。默认情况下,每个节点分配 /24。但是,可以通过在 kube-controller-manager 上设置 --node-cidr-mask-size-ipv4 和/或 --node-cidr-mask-size-ipv6 标志来控制这一点。具有此分配的节点对象如下:
apiVersion: v1
kind: Node
metadata:
labels:
kubernetes.io/arch: amd64
kubernetes.io/hostname: test
kubernetes.io/os: linux
manager: kubeadm
name: master-0
spec:
podCIDR: 10.30.0.0/24 
podCIDRs:
- 10.30.0.0/24 
这个字段存在是为了兼容性。podCIDRs后来被引入为一个数组,支持在单个节点上支持双栈(IPv4 和 IPv6 CIDR)。
分配给此节点的 IP 范围是 10.30.0.0 - 10.30.0.255。这是用于 Pods 的 254 个地址,来自于 10.30.0.0/16 集群 CIDR 中可用的 65,534 个地址之一。
这些值是否在 IPAM 中使用取决于 CNI 插件。例如,Calico 检测并遵守此设置,而 Cilium 则提供一个选项,可以独立于 Kubernetes 管理 IP 池(默认情况下),或者遵守这些分配。在大多数 CNI 实现中,重要的是您的 CIDR 选择不要与集群的主机/节点网络重叠。但是,假设您的 Pod 网络将保持集群内部,所选的 CIDR 可以与集群外部的网络空间重叠。图 5-1 展示了这些不同 IP 空间之间的关系及分配示例。
注意
设置集群的 Pod CIDR 多大通常取决于您的网络模型。在大多数部署中,Pod 网络完全是集群内部的。因此,Pod CIDR 可以非常大以适应未来的规模。当 Pod CIDR 能够路由到更大的网络时,因此消耗地址空间,您可能需要更谨慎地考虑。将每个节点的 Pod 数量乘以最终节点数量可以给出一个粗略的估计。默认情况下,kubelet 可以在每个节点上配置的 Pod 数量为 110。

图 5-1. 主机网络、Pod 网络和每个[主机]本地 CIDR 的 IP 空间和 IP 分配。
路由协议
一旦地址为 Pods,我们需要确保能够理解到它们的路由。这就是路由协议发挥作用的地方。可以将路由协议视为传播到各个地方和从各个地方路由的不同方式。引入路由协议通常会启用动态路由,相对于配置静态路由。在 Kubernetes 中,当不利用封装(在下一节中介绍)时,理解多种路由变得重要,因为网络通常不知道如何路由工作负载 IP。
边界网关协议(BGP)是分发工作负载路由的最常用的协议之一。它在诸如Calico和Kube-Router的项目中使用。BGP 不仅能够在集群中通信工作负载路由,而且其内部路由器还可以与外部路由器进行对等连接。这样可以使外部网络结构意识到如何路由到 Pod IP。在诸如 Calico 的实现中,BGP 守护进程作为 Calico Pod 的一部分运行。这个 Pod 在每个主机上运行。随着对工作负载的路由变得已知,Calico Pod 修改内核路由表以包括每个潜在工作负载的路由。这提供了通过工作负载 IP 进行原生路由的功能,特别是当在相同的 L2 段中运行时。图 5-2 展示了这种行为。

图 5-2. 使用其 BGP 对等体共享路由的calico-pod。然后相应地编程内核路由表。
警告
看起来将 Pod IP 路由到更大的网络可能是有吸引力的,但应仔细考虑。有关更多详情,请参阅“工作负载可路由性”。
在许多环境中,原生路由到工作负载 IP 是不可能的。此外,诸如 BGP 的路由协议可能无法与底层网络集成;这种情况在运行在云提供商网络时很常见。例如,让我们考虑一个 CNI 部署,在这个部署中,我们希望支持原生路由并通过 BGP 共享路由。在 AWS 环境中,这可能由于两个原因而失败:
源/目标检查已启用
这确保了命中主机的数据包具有目标主机的目的地(和源 IP)。如果不匹配,数据包将被丢弃。此设置可以禁用。
数据包需要穿越子网
如果数据包需要离开子网,底层 AWS 路由器将评估目标 IP。当存在 Pod IP 时,将无法进行路由。
在这些场景中,我们看到了隧道协议的应用。
封装和隧道
隧道协议使您能够以大部分未知于底层网络的方式运行您的 Pod 网络。这是通过封装来实现的。顾名思义,封装涉及将一个数据包(内部数据包)放入另一个数据包(外部数据包)中。内部数据包的源 IP 和目标 IP 字段将引用工作负载(Pod)的 IP 地址,而外部数据包的源 IP 和目标 IP 字段将引用主机/节点的 IP 地址。当数据包离开节点时,对于底层网络来说,它将像任何其他数据包一样,因为工作负载特定数据位于有效负载中。有许多隧道协议,如 VXLAN、Geneve 和 GRE。在 Kubernetes 中,VXLAN 已成为网络插件中最常用的方法之一。图 5-3 展示了通过 VXLAN 传输封装数据包。
正如您所看到的,VXLAN 将整个以太网帧封装在 UDP 数据包中。这本质上提供了一个完全虚拟化的二层网络,通常称为覆盖网络。覆盖网络下方的网络,称为基础网络,不关心覆盖网络。这是隧道协议的主要优势之一。

图 5-3. VXLAN 封装用于跨主机移动内部数据包,用于工作负载。网络只关心外部数据包,因此不需要了解工作负载 IP 及其路由。
通常情况下,您根据环境的要求/能力选择是否使用隧道协议。封装的优点是在许多场景中都能工作,因为覆盖网络与基础网络被抽象化。然而,这种方法也有一些关键的缺点:
流量可能更难理解和排除故障
包内嵌包可能在网络故障排除时增加额外复杂性。
封装/解封装会产生处理成本
当数据包离开主机时,它必须进行封装,当进入主机时,它必须进行解封装。虽然可能很小,但这将增加相对于本地路由的开销。
数据包将会变得更大
由于数据包的嵌套,它们在通过网络传输时会变得更大。这可能需要调整最大传输单元(MTU)以确保其适合网络。
工作负载路由能力
在大多数集群中,Pod 网络是集群内部的。这意味着 Pod 可以直接彼此通信,但外部客户端不能直接到达 Pod IP。考虑到 Pod IP 是临时的,直接与 Pod 的 IP 进行通信通常是不良实践。依赖服务发现或负载均衡机制来抽象底层 IP 更为可取。内部 Pod 网络的一个巨大优势是它不会占用组织内宝贵的地址空间。许多组织管理地址空间以确保地址在公司内保持唯一。因此,当你要求每个 Kubernetes 集群引导时使用 /16 空间(65,536 个 IP)时,你肯定会受到质疑!
当 Pod 不直接可路由时,我们有几种模式来促进外部流量到 Pod IP。通常我们会在一些专用节点的主机网络上暴露一个入口控制器。然后,一旦数据包进入入口控制器代理,它可以直接路由到 Pod IP,因为它参与了 Pod 网络。一些云服务提供商甚至包括(外部)负载均衡器集成,自动将这一切连接在一起。我们在第六章中探讨了多种这样的入口模型及其权衡。
有时,需求要求 Pod 能够路由到更大的网络。有两种主要方法可以实现这一点。第一种是使用与底层网络直接集成的网络插件。例如,AWS 的 VPC CNI 将多个次要 IP 地址附加到每个节点并分配给 Pod。这样,每个 Pod 就可以像 EC2 主机一样路由。这种模型的主要缺点是它会消耗你子网/VPC 中的 IP。第二个选项是通过诸如 BGP 的路由协议传播路由到 Pod。某些使用 BGP 的插件甚至可以使你可以使 Pod 网络的子集可路由,而不是必须暴露整个 IP 空间。
警告
避免使你的 Pod 网络在绝对必要之外变得可外部路由。我们经常看到传统应用程序驱动对可路由 Pod 的渴望。例如,考虑一个基于 TCP 的工作负载,其中客户端必须固定在同一个后端。通常,我们建议更新应用程序以适应容器网络范式,使用服务发现并可能重新设计后端,以避免需要客户端-服务器亲和(如果可能的话)。尽管暴露 Pod 网络看起来像是一个简单的解决方案,但这样做会增加 IP 空间的消耗,并可能使 IPAM 和路由传播配置变得更加复杂。
IPv4 和 IPv6
当前绝大多数集群仅运行 IPv4。然而,我们注意到在某些客户端(如电信运营商),确保多个工作负载的可寻址性是至关重要的。Kubernetes 从 1.16 版本开始支持通过dual-stack。在撰写本文时,双栈功能仍处于 alpha 版本。双栈功能使您能够在集群中配置 IPv4 和 IPv6 地址空间。
如果您的使用场景需要 IPv6,可以轻松启用,但需要几个组件保持一致:
-
尽管仍处于 alpha 版本,但 kube-apiserver 和 kubelet 必须启用一个功能开关。
-
kube-apiserver、kube-controller-manager 和 kube-proxy 需要额外配置以指定 IPv4 和 IPv6 空间。
在上述配置完成后,您将在每个节点对象上看到两个 CIDR 分配:
spec:
podCIDR: 10.30.0.0/24
podCIDRs:
- 10.30.0.0/24
- 2002:1:1::/96
CNI 插件的 IPAM 负责确定是否为每个 Pod 分配 IPv4、IPv6 或两者皆有。
加密的工作负载流量
Pod 与 Pod 之间的流量通常不会默认进行加密。这意味着未加密的数据包(如 TLS)在传输过程中可能会被截获为明文。许多网络插件支持在传输过程中加密流量。例如,Antrea 在使用 GRE 隧道时支持使用IPsec进行加密。Calico 可以通过利用节点的WireGuard安装来加密流量。
启用加密可能看起来是一个明显的选择。然而,需要考虑权衡。我们建议与您的网络团队交流,了解今天在主机到主机的流量处理中是否已加密数据。例如,当服务之间是否都通过 TLS 进行通信?您是否计划利用服务网格,其中工作负载代理使用 mTLS?如果是这样,是否需要在服务代理和 CNI 层面进行加密?虽然加密将增加防御深度,但也会增加网络管理和故障排除的复杂性。最重要的是,需要加密和解密数据包将影响性能,从而降低潜在的吞吐量。
网络策略
一旦 Pod 网络连接完毕,逻辑上的下一步是考虑如何设置网络策略。网络策略类似于防火墙规则或安全组,我们可以定义允许的入口和出口流量。Kubernetes 提供了一个 NetworkPolicy API,作为核心网络 API 的一部分。任何集群都可以向其添加策略。但是,实施策略是 CNI 提供者的责任。这意味着运行不支持 NetworkPolicy 的 CNI 提供者的集群,例如 flannel,将接受 NetworkPolicy 对象但不会执行它们。如今,大多数 CNI 都对 NetworkPolicy 有一定程度的支持。那些不支持的可以通常与插件(例如 Calico)一起使用,插件运行在仅提供策略执行的模式下。
Kubernetes 内部提供的 NetworkPolicy 又增加了一层类似防火墙样式规则管理的机制。例如,许多网络通过分布式防火墙或安全组机制提供子网或主机级别的规则。虽然这些现有解决方案通常不具有对 Pod 网络的可见性,这限制了在设置基于 Pod 工作负载通信规则时可能期望的粒度。Kubernetes NetworkPolicy 的另一个引人注目的方面是,与 Kubernetes 中的大多数对象一样,它是以声明方式定义的,并且我们认为相对于大多数防火墙管理解决方案来说,更易于管理!因此,我们通常建议考虑在 Kubernetes 层面实施网络策略,而不是尝试使现有防火墙解决方案适应这种新的范式。这并不意味着您应该放弃您现有的主机到主机防火墙解决方案。更多的是,让 Kubernetes 处理工作负载内部策略。
如果您选择使用 NetworkPolicy,重要的是要注意这些策略是 命名空间范围 的。默认情况下,当不存在 NetworkPolicy 对象时,Kubernetes 允许与工作负载之间的所有通信。设置策略时,您可以选择策略适用于哪些工作负载。当存在时,默认行为将反转,任何未被策略允许的出口和入口流量将被阻止。这意味着 Kubernetes NetworkPolicy API 仅指定了允许的流量。此外,命名空间中的策略是累加的。考虑以下配置了入口和出口规则的 NetworkPolicy 对象:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: team-netpol
namespace: org-1
spec:
podSelector: {} 
policyTypes:
- Ingress
- Egress
ingress: 
- from:
- ipBlock:
cidr: 10.40.0.0/24
ports:
- protocol: TCP
port: 80
egress:
- to: 
ports:
- protocol: UDP
port: 53
- to: 
- namespaceSelector:
matchLabels:
name: org-2
- podSelector:
matchLabels:
app: team-b
ports:
- protocol: TCP
port: 80
空的 podSelector 意味着此策略适用于该命名空间中的所有 Pod。或者,您可以根据标签进行匹配。
此入口规则允许来自 IP 范围为 10.40.0.0/24 的源的流量,协议为 TCP,端口为 80。
此出口规则允许来自工作负载的 DNS 流量。
此出口规则限制向 org-2 命名空间中标有 team-b 标签的工作负载发送 TCP 协议且目标端口为 80 的流量。
随着时间的推移,我们发现 NetworkPolicy API 对某些用例有所限制。一些常见的需求包括:
-
复杂的条件评估
-
基于 DNS 记录解析 IP 地址
-
L7 规则(主机、路径等)
-
集群范围的策略,使全局规则能够生效,而不是需要在每个命名空间中复制这些规则。
为了满足这些需求,一些 CNI 插件提供了更强大的策略 API。使用供应商特定的 API 的主要折衷是,您的规则不再跨插件可移植。在本章稍后的部分,我们将探讨 Calico 和 Cilium 的示例。
摘要:网络考虑因素
在前面的章节中,我们已经涵盖了关键的网络考虑因素,这些将帮助您就 Pod 网络策略做出明智的决策。在深入讨论 CNI 和插件之前,让我们回顾一些关键的考虑领域:
-
每个集群的 Pod CIDR 应该有多大?
-
您的底层网络对未来 Pod 网络有什么网络约束?
-
如果使用 Kubernetes 管理的服务或供应商提供的服务,支持哪些网络插件?
-
您的基础设施是否支持诸如 BGP 的路由协议?
-
是否可以通过网络路由未封装(原生)的数据包?
-
使用隧道协议(封装)是可取的还是必需的?
-
您是否需要支持(外部可路由的)Pods?
-
运行 IPv6 是否是您工作负载的要求?
-
您希望在哪些级别执行网络策略或防火墙规则?
-
您的 Pod 网络是否需要在传输时加密流量?
有了这些问题的答案,您可以开始学习如何插入正确的技术来解决这些问题,即容器网络接口 (CNI)。
容器网络接口 (CNI)
到目前为止讨论的所有考虑都清楚地表明,不同的用例需要不同的容器网络解决方案。在 Kubernetes 的早期阶段,大多数集群都在运行名为 flannel 的网络插件。随着时间的推移,诸如 Calico 等解决方案变得更加流行。这些新的插件带来了创建和运行网络的不同方法。这促使了像 Kubernetes 这样的系统如何请求其工作负载的网络资源的标准化。这个标准被称为 Container Networking Interface (CNI)。今天,与 Kubernetes 兼容的所有网络选项都符合这一接口。与容器存储接口 (CSI) 和容器运行时接口 (CRI) 类似,这为我们的应用程序平台的网络堆栈提供了灵活性。
CNI 规范定义了几个关键操作:
ADD
添加一个容器到网络并返回相关接口、IP 及其他信息。
DELETE
从网络中删除一个容器并释放所有相关资源。
CHECK
验证容器的网络设置是否正确,并在出现问题时返回错误。
VERSION
返回插件支持的 CNI 版本。
此功能是通过安装在主机上的二进制文件实现的。kubelet 将根据其在主机上预期的配置与适当的 CNI 二进制文件通信。这个配置文件的示例如下:
{
"cniVersion": "0.4.0", 
"name": "dbnet", 
"type": "bridge",
"bridge": "cni0",
"args": {
"labels" : {
"appVersion" : "1.0"
}
},
"ipam": { 
"type": "host-local",
"subnet": "10.1.0.0/16",
"gateway": "10.1.0.1"
}
}
此插件期望使用的 CNI(规范)版本进行通信。
发送网络设置请求的 CNI 驱动(二进制文件)。
在 CNI 插件不处理 IPAM 时指定要使用的 IPAM 驱动程序。
注意
CNI conf目录中可能存在多个 CNI 配置。它们按字典顺序评估,并使用第一个配置。
除了 CNI 配置和 CNI 二进制文件外,大多数插件还在每个处理接口附加和 IPAM 之外的主机上运行一个 Pod。这包括路由传播和网络策略编程等职责。
CNI 安装
每个参与 Pod 网络的节点必须安装 CNI 驱动程序。此外,必须建立 CNI 配置。通常在部署 CNI 插件时进行安装。例如,当部署 Cilium 时,将创建一个 DaemonSet,它在每个节点上放置一个cilium Pod。此 Pod 具有一个 PostStart 命令,运行内置脚本install-cni.sh。此脚本首先安装回环驱动程序以支持lo接口。然后安装cilium驱动程序。该脚本的执行概念如下(为简洁起见,此示例已大大简化):
# Install CNI drivers to host
# Install the CNI loopback driver; allow failure
cp /cni/loopback /opt/cin/bin/ || true
# install the cilium driver
cp /opt/cni/bin/cilium-cni /opt/cni/bin/
安装后,kubelet 仍然需要知道要使用哪个驱动程序。它将在/etc/cni/net.d/(可通过标志配置)中查找 CNI 配置。同样的install-cni.sh脚本将其添加如下:
cat > /etc/cni/net.d/05-cilium.conf <<EOF
{
"cniVersion": "0.3.1",
"name": "cilium",
"type": "cilium-cni",
"enable-debug": ${ENABLE_DEBUG}
}
EOF
为了演示这些操作的顺序,让我们看看一个新引导的单节点集群。此集群使用kubeadm引导。检查所有 Pod 后发现core-dns Pods 未运行:
NAMESPACE NAME READY STATUS RESTARTS AGE
kube-system coredns-f9fd979d6-26lfr 0/1 Pending 0 3m14s
kube-system coredns-f9fd979d6-zqzft 0/1 Pending 0 3m14s
kube-system etcd-test 1/1 Running 0 3m26s
kube-system kube-apiserver-test 1/1 Running 0 3m26s
kube-system kube-controller-manager-test 1/1 Running 0 3m26s
kube-system kube-proxy-xhh2p 1/1 Running 0 3m14s
kube-system kube-scheduler-test 1/1 Running 0 3m26s
在分析预定运行core-dns的主机上的 kubelet 日志后,清楚地表明缺少 CNI 配置导致容器运行时无法启动 Pod:
注意
在集群引导后,DNS 未启动的情况是 CNI 问题最常见的指标之一。另一个症状是节点报告NotReady状态。
# journalctl -f -u kubelet
-- Logs begin at Sun 2020-09-27 15:40:13 UTC. --
Sep 27 17:11:18 test kubelet[2972]: E0927 17:11:18.817089 2972 kubelet.go:2103]
Container runtime network not ready: NetworkReady=false
reason:NetworkPluginNotReady message:docker: network plugin is not ready: cni
config uninitialized
Sep 27 17:11:19 test kubelet[2972]: W0927 17:11:19.198643 2972 cni.go:239]
Unable to update cni config: no networks found in /etc/cni/net.d
注意
诸如 kube-apiserver 和 kube-controller-manager 这样的 Pod 之所以成功启动,是因为它们使用了主机网络。由于它们利用主机网络而不依赖于 Pod 网络,因此不会受到 core-dns 所见行为的影响。
Cilium 可以通过从 Cilium 文档中的 YAML 文件应用到集群来部署。这样做会在每个节点上部署上述的 cilium Pod,并运行 cni-install.sh 脚本。检查 CNI bin 和配置目录,我们可以看到安装的组件:
# ls /opt/cni/bin/ | grep -i cilium
cilium-cni
# ls /etc/cni/net.d/ | grep -i cilium
05-cilium.conf
有了这个配置,kubelet 和容器运行时按预期运行。最重要的是,core-dns Pod 正在运行!图 5-4 展示了我们在本节中所涵盖的关系。

图 5-4. Docker 用于运行容器。kubelet 与 CNI 交互,附加网络接口并配置 Pod 的网络。
虽然此示例通过 Cilium 进行安装探索,但大多数插件遵循类似的部署模型。选择插件的关键理由基于 “网络考虑” 中的讨论。考虑到这一点,我们将转向探索一些 CNI 插件,以更好地理解不同的方法。
CNI 插件
现在我们将探讨几种 CNI 的实现方式。与其他接口(如 CRI)相比,CNI 拥有最多的选项之一。因此,我们在涵盖的插件方面不会穷尽一切,并鼓励您探索更多内容。我们选择以下插件作为客户中最常见且足够独特以展示各种方法的因素。
注意
Pod 网络是任何 Kubernetes 集群的基础。因此,您的 CNI 插件将处于关键路径上。随着时间推移,您可能希望更改您的 CNI 插件。如果发生这种情况,我们建议重建集群,而不是进行原地迁移。在这种方法中,您会启动一个新的集群,使用新的 CNI。然后,根据您的架构和操作模型,将工作负载迁移到新集群。虽然可以进行原地 CNI 迁移,但这带来了非常重要的风险,应仔细权衡我们的建议。
Calico
Calico 是云原生生态系统中一个成熟的 CNI 插件。Project Calico是支持此 CNI 插件的开源项目,Tigera是提供企业功能和支持的商业公司。Calico 大量使用 BGP 在节点之间传播工作负载路由,并与更大的数据中心结构集成。除了安装 CNI 二进制文件外,Calico 还在每个主机上运行一个calico-node代理。该代理使用 BIRD 守护程序促进节点之间的 BGP 对等体系,并使用 Felix 代理将已知路由编程到内核路由表中。这种关系在图 5-5 中有所展示。

图 5-5. Calico 组件关系展示 BGP 对等体系以及相应地通信路由和 iptables 与内核路由表的编程。
对于 IPAM,Calico 最初遵循描述在“IP 地址管理”中的cluster-cidr设置。然而,它的功能远不止依赖于每个节点的 CIDR 分配。Calico 创建称为IPPools的 CRD,这在 IPAM 中提供了很大的灵活性,特别是支持以下功能:
-
配置每个节点的块大小
-
指定 IP 池适用于哪些节点
-
将 IPPools 分配给命名空间,而不是节点
-
配置路由行为
配合每个集群可以有多个池的能力,您在 IPAM 和网络架构上有很大的灵活性。默认情况下,集群运行单个 IP 池,如下所示:
apiVersion: projectcalico.org/v3
kind: IPPool
metadata:
name: default-ipv4-ippool
spec:
cidr: 10.30.0.0/16 
blockSize: 29 
ipipMode: Always 
natOutgoing: true
集群的 Pod 网络 CIDR。
每个节点级别 CIDR 分配的大小。
封装模式。
Calico 提供多种路由集群内部数据包的方式。这包括:
本地
不封装数据包。
IP-in-IP
简单封装。IP 数据包放置在另一个数据包的负载中。
VXLAN
高级封装。整个 L2 帧被封装在一个 UDP 数据包中。建立虚拟 L2 覆盖。
你的选择往往取决于你的网络支持能力。正如在“路由协议”中所描述的,本地路由很可能提供最佳性能、最小的数据包大小和最简单的故障排除体验。然而,在许多环境中,特别是涉及多个子网的情况下,这种模式是不可能的。封装方法在大多数环境中都有效,特别是 VXLAN。此外,VXLAN 模式不需要使用 BGP,这可以解决 BGP 对等连接被阻止的环境。Calico 封装方法的一个独特特性是,它可以专门用于跨越子网边界的流量。这使得在子网内进行路由时接近本地性能,同时不会破坏子网外的路由。通过将 IP 池的ipipMode设置为CrossSubnet即可启用此功能。图 5-6 展示了这种行为。

图 5-6. 启用 CrossSubnet IP-in-IP 模式时的流量行为。
对于保持启用 BGP 的 Calico 部署,默认情况下,由于calico-node Pod 中内置了 BGP 守护程序,不需要额外工作。在更复杂的架构中,组织使用此 BGP 功能作为引入路由反射器的一种方式,特别是在大规模使用默认的全网格方法受限时。除了路由反射器,还可以配置对网络路由器的对等连接,从而使整个网络了解到 Pod IP 的路由。这一切都可以使用 Calico 的 BGPPeer CRD 进行配置,如下所示:
apiVersion: projectcalico.org/v3
kind: BGPPeer
metadata:
name: external-router
spec:
peerIP: 192.23.11.100 
asNumber: 64567 
nodeSelector: routing-option == 'external' 
与之对等连接的设备的 IP。
集群的自治系统标识。
应与此设备进行对等连接的集群节点。此字段是可选的。当省略时,BGPPeer 配置被视为全局。仅在某些节点集应提供唯一路由功能(例如提供可路由的 IP 时)时,不对等全局是明智的选择。
在网络策略方面,Calico 完全实现了 Kubernetes NetworkPolicy API。Calico 提供了两个额外的 CRD 以增强功能。这些包括(projectcalico.org/v3).NetworkPolicy和GlobalNetworkPolicy。这些 Calico 特定的 API 看起来类似于 Kubernetes NetworkPolicy,但具有更强大的规则和更丰富的表达式用于评估。此外,支持策略排序和应用层策略(需要与 Istio 集成)。GlobalNetworkPolicy 特别有用,因为它在集群范围内应用策略。这使得实现微分段化等模型变得更加容易,其中所有流量默认被拒绝,根据工作负载需求打开出口/入口。您可以应用一个 GlobalNetworkPolicy,拒绝除 DNS 等关键服务之外的所有流量。然后,在 Namespace 级别,您可以相应地开放对入口和出口的访问。如果没有 GlobalNetworkPolicy,我们将需要在每个Namespace 中添加和管理拒绝所有规则。
注意
在历史上,Calico 使用 iptables 来实现数据包路由决策。对于服务,Calico 依赖 kube-proxy 进行编程,以解析服务的端点。对于网络策略,Calico 编程 iptables 确定数据包是否允许进入或离开主机。在撰写本文时,Calico 引入了 eBPF 数据平面选项。我们预计随着时间的推移,Calico 使用的更多功能将转移到这种模型中。
Cilium
相对于 Calico,Cilium 是一种较新的 CNI 插件。它是第一个利用扩展伯克利数据包过滤器(eBPF)的 CNI 插件。这意味着它可以在不离开内核空间的情况下处理数据包。与eXpress Data Path(XDP)配对,可以在 NIC 驱动程序中建立钩子以进行路由决策。这使得数据包接收时能够立即进行路由决策。
作为一种技术,eBPF 已经在 Facebook 和 Netflix 等组织中展示了性能和规模。使用 eBPF,Cilium 能够宣称在可伸缩性、可观察性和安全性方面具有增强功能。与 BPF 的深度集成意味着,像 NetworkPolicy 执行这样的常见 CNI 问题不再通过用户空间中的 iptables 处理。相反,广泛使用的eBPF maps使决策能够快速发生,并且随着添加更多规则而扩展。Figure 5-7 显示了安装了 Cilium 后的堆栈的高级概述。

图 5-7. Cilium 与内核级别的 eBPF 映射和程序交互。
对于 IPAM,Cilium 遵循将 IPAM 委托给云提供商集成或自行管理的模型。在 Cilium 管理 IPAM 的最常见情况下,它将为每个节点分配 Pod CIDR。默认情况下,Cilium 将独立管理这些 CIDR,而不依赖于 Kubernetes 节点分配。节点级别的地址将在CiliumNode CRD 中公开。这将提供更大的 IPAM 管理灵活性,并且是首选的方法。如果希望保持基于 Kubernetes 的 Pod CIDR 默认分配的 CIDR 分配方式,Cilium 提供了kubernetes IPAM 模式。这将依赖于每个节点分配的 Pod CIDR,该 CIDR 在节点对象中公开。以下是CiliumNode对象的示例。您可以期望在集群中的每个节点上存在这样一个对象:
apiVersion: cilium.io/v2
kind: CiliumNode
metadata:
name: node-a
spec:
addresses:
- ip: 192.168.122.126 
type: InternalIP
- ip: 10.0.0.245
type: CiliumInternalIP
health:
ipv4: 10.0.0.78
ipam:
podCIDRs:
- 10.0.0.0/24 
此工作负载节点的 IP 地址。
分配给此节点的 CIDR。可以使用 Cilium 的配置控制此分配的大小,例如cluster-pool-ipv4-mask-size: "24"。
类似于 Calico,Cilium 提供了封装和本地路由模式。默认模式是封装。Cilium 支持使用隧道协议 VXLAN 或 Geneve。只要主机到主机的路由可达,此模式应该适用于大多数网络。要以本地模式运行,必须在某个级别上理解 Pod 路由。例如,Cilium 支持使用 AWS 的 ENI 进行 IPAM。在这种模型中,Pod 的 IP 已知于 VPC 并且天然可路由。要在 Cilium 管理的 IPAM 下运行本地模式,假设集群运行在相同的 L2 段内,可以在 Cilium 的配置中添加auto-direct-node-routes: true。然后 Cilium 将相应地编程主机的路由表。如果跨越 L2 网络,则可能需要引入额外的路由协议,如 BGP 来分发路由。
在网络策略方面,Cilium 可以强制执行 Kubernetes 的NetworkPolicy API。作为此策略的替代,Cilium 提供了自己的CiliumNetworkPolicy和CiliumClusterwideNetworkPolicy。这两者之间的关键区别在于策略的范围。CiliumNetworkPolicy 是命名空间范围的,而 CiliumClusterwideNetworkPolicy 是整个集群范围的。这两者都具有超出 Kubernetes NetworkPolicy 功能的增强功能。除了支持基于标签的第三层策略外,它们还支持基于 DNS 解析和应用级(第 7 层)请求的策略。
虽然大多数 CNI 插件不涉及服务,Cilium 提供了一个完整的 kube-proxy 替代方案。此功能内置于部署在每个节点的 cilium-agent 中。在部署此模式时,您需要确保集群中不存在 kube-proxy,并且在 Cilium 中将 KubeProxyReplacement 设置为 strict。在使用此模式时,Cilium 将通过 eBPF 映射为服务配置路由,使解析速度达到 O(1)。这与 kube-proxy 实现服务在 iptables 链中有所不同,在规模或服务高变动时可能会出现问题。此外,Cilium 提供的 CLI 在诸如服务或网络策略的故障排除时提供了良好的体验。您可以直接查询系统,而无需解释 iptables 链:
kubectl exec -it -n kube-system cilium-fmh8d -- cilium service list
ID Frontend Service Type Backend
[...]
7 192.40.23.111:80 ClusterIP 1 => 10.30.0.28:80
2 => 10.30.0.21:80
Cilium 的使用 eBPF 程序和映射使其成为一种非常引人注目和有趣的 CNI 选项。通过继续利用 eBPF 程序,引入了更多与 Cilium 集成的功能,例如提取流数据、策略违规等。为了提取并展示这些宝贵的数据,引入了 hubble。它利用 Cilium 的 eBPF 程序为运维人员提供了 UI 和 CLI。
最后,我们应该提到,Cilium 提供的 eBPF 功能可以与许多现有的 CNI 提供程序并行运行。这是通过在其 CNI 链接模式下运行 Cilium 实现的。在此模式下,如 AWS 的 VPC CNI 这样的现有插件将处理路由和 IPAM。Cilium 的责任将专门是其各种 eBPF 程序提供的功能,包括网络可观察性、负载均衡和网络策略强制执行。在您无法完全在环境中运行 Cilium 或希望在当前 CNI 选择旁边测试其功能时,这种方法可能更可取。
AWS VPC CNI
AWS 的 VPC CNI 展示了与迄今为止讨论的内容非常不同的方法。它不是将 Pod 网络独立于节点网络运行,而是完全将 Pod 集成到相同的网络中。由于不引入第二个网络,因此不再需要关注路由分发或隧道协议的问题。当为 Pod 提供 IP 后,它就像 EC2 主机一样成为网络的一部分。它受到与子网中任何其他主机相同的 路由表 影响。亚马逊称之为本地 VPC 网络。
对于 IPAM,守护进程将在 Kubernetes 节点上附加第二个弹性网络接口(ENI)。然后,它将维护一个次要 IP 地址池,这些 IP 地址最终会附加到 Pods 上。每个节点可用的 IP 数量取决于 EC2 实例的大小。这些 IP 通常是 VPC 内的“私有”IP 地址。正如本章前面提到的,这将消耗 VPC 中的 IP 空间,并使 IPAM 系统比完全独立的 Pod 网络更加复杂。但是,由于我们没有引入新的网络,流量路由和故障排除显著简化了!图 5-8 展示了使用 AWS VPC CNI 的 IPAM 设置。

图 5-8. IPAM 守护程序负责维护 ENI 和次要 IP 地址池。
注意
使用 ENI 将影响您可以在每个节点上运行的 Pod 数量。AWS 在其 GitHub 页面上维护了一个列表,将实例类型与最大 Pod 数量进行了对应。
Multus
到目前为止,我们已经涵盖了特定的 CNI 插件,这些插件将一个接口附加到一个 Pod 上,从而使其在网络上可用。但是,如果一个 Pod 需要连接到多个网络怎么办?这就是 Multus CNI 插件发挥作用的地方。虽然不是非常常见,但在电信行业中存在一些使用案例,这些案例要求它们的网络功能虚拟化(NFV)将流量路由到特定的专用网络。
Multus 可以被视为启用多个其他 CNI 的 CNI。在这种模型中,Multus 成为 Kubernetes 交互的 CNI 插件。Multus 配置了一个默认网络,通常是预期用于促进 Pod 与 Pod 通信的网络。这甚至可能是本章中我们讨论过的插件之一!然后,Multus 支持通过指定其他可以用于将另一个接口附加到 Pod 的插件来配置次要网络。Pods 可以像这样被注释:k8s.v1.cni.cncf.io/networks: sriov-conf 来附加额外的网络。图 5-9 展示了这种配置的结果。

图 5-9. 多网络 Multus 配置的流量流向。
额外的插件
插件的选择很广泛,我们只覆盖了非常小的一部分。然而,本章介绍的插件确实识别了一些插件中的关键差异。大多数替代方案采用不同的方法来实现网络功能,但许多核心原则保持不变。以下列表识别了一些额外的插件,并对其网络方法给予了一小部分示例:
数据平面通过 Open vSwitch 实现。提供高性能路由以及检查流数据的能力。
提供许多机制来路由流量的覆盖网络,例如使用 OVS 模块的快速数据路径选项来保持内核中的数据包处理。
简单的第三层网络用于 Pods 和早期的一个 CNI。它支持多种后端配置,但通常配置为使用 VXLAN。
总结
Kubernetes/容器网络生态系统充满了选择。这是件好事!正如我们在本章中所介绍的,网络需求在不同组织之间可能有显著差异。选择一个 CNI 插件可能是您最终应用平台中最基础的考虑之一。虽然探索如此多的选择可能会让人感到不知所措,但我们强烈建议您努力更好地理解您环境和应用程序的网络需求。通过深入了解,正确的网络插件选择应该就会水到渠成!
第六章:服务路由
服务路由是基于 Kubernetes 的平台的关键能力。虽然容器网络层负责连接 Pod 的底层原语,但开发人员需要更高级的机制来互连服务(即东西向服务路由),以及将应用程序暴露给其客户端(即南北向服务路由)。服务路由涵盖了三个提供这种机制的关注点:Services、Ingress 和服务网格。
Services 提供了一种将一组 Pod 视为单个单元或网络服务的方式。它们提供了负载均衡和路由功能,支持应用程序在集群中的水平扩展。此外,Services 还提供了应用程序可以用来发现和与它们的依赖关系交互的服务发现机制。最后,Services 还提供第 3/4 层机制,将工作负载暴露给集群外的网络客户端。
Ingress 处理集群中的南北向路由。它充当进入集群中运行的工作负载的入口,主要是 HTTP 和 HTTPS 服务。Ingress 提供第 7 层负载均衡功能,使得比 Services 更精细的流量路由成为可能。流量的负载均衡由 Ingress 控制器处理,该控制器必须安装在集群中。Ingress 控制器利用诸如 Envoy、NGINX 或 HAProxy 等代理技术。控制器从 Kubernetes API 获取 Ingress 配置,并相应地配置代理。
服务网格是一个提供高级路由、安全性和可观察性功能的服务路由层。它主要关注东西向服务路由,但某些实现也可以处理南北向路由。网格中的服务通过增强连接的代理相互通信。代理的使用使网格具有吸引力,因为它们可以增强工作负载而无需更改源代码。
本章详细介绍了在生产 Kubernetes 平台中至关重要的服务路由能力。首先,我们将讨论服务(Services)、不同的服务类型以及它们的实现方式。接下来,我们将探索入口(Ingress)、Ingress 控制器以及在生产环境中运行 Ingress 需要考虑的不同因素。最后,我们将介绍服务网格(service mesh)、它们在 Kubernetes 上的工作原理以及在生产平台中采用服务网格时需要考虑的因素。
Kubernetes 服务
Kubernetes 的 Service 在服务路由中具有基础性作用。Service 是一个网络抽象层,提供跨多个 Pod 的基本负载均衡。在大多数情况下,集群中运行的工作负载使用 Services 来相互通信。由于 Pod 的可替代性质,使用 Services 而不是 Pod IP 更为推荐。
在本节中,我们将审查 Kubernetes 服务及其不同的服务类型。我们还将查看 Endpoints,这是与服务密切相关的另一个 Kubernetes 资源。然后,我们将深入探讨服务的实现细节并讨论 kube-proxy。最后,我们将讨论服务发现以及在集群内 DNS 服务器的考虑事项。
服务抽象
服务是 Kubernetes 中的核心 API 资源,用于在多个 Pod 之间负载均衡流量。服务在 OSI 模型的 L3/L4 层进行负载均衡。它接收带有目标 IP 和端口的数据包,并将其转发到后端 Pod。
负载均衡器通常具有前端和后端池。服务也是如此。服务的前端是 ClusterIP。ClusterIP 是集群内可访问的虚拟 IP 地址(VIP)。工作负载使用此 VIP 与服务进行通信。后端池是满足服务 Pod 选择器的一组 Pod。这些 Pod 接收发送到 Cluster IP 的流量。图 6-1 描述了服务的前端及其后端池。

图 6-1. 服务具有前端和后端池。前端是 ClusterIP,而后端是一组 Pod。
服务 IP 地址管理
正如我们在上一章讨论的那样,在部署 Kubernetes 时,您配置了两个 IP 地址范围。一方面,Pod IP 范围或 CIDR 块为集群中的每个 Pod 提供 IP 地址。另一方面,服务 CIDR 块为集群中的服务提供 IP 地址。这个 CIDR 是 Kubernetes 用来为服务分配 ClusterIP 的范围。
API 服务器处理 Kubernetes 服务的 IP 地址管理(IPAM)。创建服务时,API 服务器(借助 etcd 的帮助)从服务 CIDR 块中分配一个 IP 地址,并将其写入服务的 ClusterIP 字段。
创建服务时,您还可以在服务规范中指定 ClusterIP。在这种情况下,API 服务器确保所请求的 IP 地址可用,并在服务 CIDR 块内。话虽如此,显式设置 ClusterIP 是一种反模式。
Service 资源
服务资源包含给定服务的配置,包括名称、类型、端口等。示例 6-1 是其 YAML 表示中命名为 nginx 的示例服务定义。
示例 6-1. 在 ClusterIP 上公开 NGINX 的服务定义
apiVersion: v1
kind: Service
metadata:
name: nginx
spec:
selector: 
app: nginx
ports: 
- protocol: TCP 
port: 80 
targetPort: 8080 
clusterIP: 172.21.219.227 
type: ClusterIP
Pod 选择器。Kubernetes 使用此选择器查找属于此服务的 Pod。
可通过服务访问的端口。
Kubernetes 支持服务中的 TCP、UDP 和 SCTP 协议。
可以访问服务的端口。
后端 Pod 正在侦听的端口,这可以不同于服务暴露的端口(上面的port字段)。
Kubernetes 为该服务分配的 ClusterIP。
服务的 Pod 选择器确定属于该服务的 Pod。Pod 选择器是一组键/值对,Kubernetes 会将其与同一 Namespace 中的 Pod 进行匹配。如果一个 Pod 的标签中有相同的键/值对,Kubernetes 会将该 Pod 的 IP 地址添加到服务的后端池中。后端池的管理由 Endpoints 控制器通过 Endpoints 资源处理。我们稍后在本章节会详细讨论 Endpoints。
服务类型
到目前为止,我们主要讨论了默认的 ClusterIP 服务类型。Kubernetes 还提供了多种服务类型,除了 Cluster IP 外还提供了其他附加功能。在本节中,我们将讨论每种服务类型及其用途。
ClusterIP
我们在之前的章节中已经讨论过这种服务类型。回顾一下,ClusterIP 服务创建一个虚拟 IP 地址(VIP),由一个或多个 Pod 支持。通常,这个 VIP 仅对运行在集群内的工作负载可用。图 6-2 展示了一个 ClusterIP 服务。

图 6-2. ClusterIP 服务是一个虚拟 IP,可供集群内的工作负载访问。
NodePort
NodePort Service 在需要将服务暴露给集群外的网络客户端时非常有用,比如运行在虚拟机中的现有应用程序或 Web 应用程序的用户。
如其名称所示,NodePort 服务在所有集群节点上的一个端口上暴露服务。端口从可配置的端口范围中随机分配。一旦分配,集群中的所有节点都会监听该端口上的连接。图 6-3 展示了一个 NodePort 服务。

图 6-3. NodePort 服务在所有集群节点上打开一个随机端口。集群外的客户端可以通过此端口访问服务。
NodePort 服务的主要挑战在于客户端需要知道服务的节点端口号以及至少一个集群节点的 IP 地址才能访问该服务。这是有问题的,因为节点可能会失败或从集群中移除。
解决这个挑战的一种常见方法是在 NodePort 服务前使用外部负载均衡器。通过这种方式,客户端不需要知道集群节点的 IP 地址或服务的端口号。相反,负载均衡器作为服务的单一入口点。
这种解决方案的缺点在于,您需要不断管理外部负载均衡器并更新其配置。开发人员创建了新的 NodePort 服务?那就创建一个新的负载均衡器。您在集群中添加了新节点?那就将新节点添加到所有负载均衡器的后端池中。
在大多数情况下,使用 NodePort 服务都有更好的替代方案。LoadBalancer 服务就是其中之一,我们将在接下来讨论它。另一个选择是 Ingress 控制器,在本章后面的 “Ingress” 中我们将进一步探讨。
LoadBalancer
LoadBalancer 服务建立在 NodePort 服务基础之上,以解决部分问题。从本质上讲,LoadBalancer 服务在内部是一个 NodePort 服务。然而,LoadBalancer 服务具有额外的功能,需要由控制器来满足。
控制器,也称为云提供商集成,负责自动将 NodePort 服务与外部负载均衡器连接起来。换句话说,控制器负责根据集群中 LoadBalancer 服务的配置创建、管理和配置外部负载均衡器。控制器通过与提供或配置负载均衡器的 API 进行交互来实现这一点。此交互在 Figure 6-4 中有所描述。

图 6-4. LoadBalancer 服务利用云提供商集成创建外部负载均衡器,将流量转发到节点端口。在节点级别上,该服务与 NodePort 相同。
Kubernetes 针对多个云提供商内置了控制器,包括 Amazon Web Services (AWS)、Google Cloud 和 Microsoft Azure。这些集成控制器通常被称为内树云提供商,因为它们是在 Kubernetes 源代码树内实现的。
随着 Kubernetes 项目的发展,出树云提供商作为内树提供商的替代方案出现了。出树提供商使得负载均衡器供应商能够提供其 LoadBalancer 服务控制循环的实现。目前,Kubernetes 同时支持内树和出树提供商。然而,鉴于内树提供商已被弃用,社区正在迅速采纳出树提供商。
ExternalName
ExternalName 服务类型不执行任何形式的负载均衡或代理。相反,ExternalName 服务主要是在集群 DNS 中实现的服务发现构造。ExternalName 服务将集群服务映射到一个 DNS 名称。由于没有涉及负载均衡,这种类型的服务缺乏 ClusterIP。
ExternalName 服务在不同场景中非常有用。 例如,分阶段的应用程序迁移工作可以从 ExternalName 服务中受益。 如果您将应用程序的某些组件迁移到 Kubernetes 并将一些依赖项保留在外部,则可以在完成迁移时使用 ExternalName 服务作为桥梁。 一旦整个应用程序迁移完成,您可以将服务类型更改为 ClusterIP,而无需更改应用程序部署。
虽然在创造性方式中非常有用,但 ExternalName 服务可能是使用最少的服务类型之一。
无头服务
与 ExternalName 服务类似,无头服务类型不分配 ClusterIP 或提供任何负载平衡。 无头服务仅作为注册服务及其端点在 Kubernetes API 和集群 DNS 服务器中的一种方式。
当应用程序需要连接服务的特定副本或 Pod 时,无头服务非常有用。 这类应用程序可以使用服务发现找到服务背后所有 Pod 的 IP,然后建立到特定 Pod 的连接。
支持的通信协议
Kubernetes 服务支持一组特定的协议:TCP、UDP 和 SCTP。 在服务资源中列出的每个端口都指定了端口号和协议。 服务可以使用不同协议公开多个端口。 例如,以下代码片段显示了 kube-dns 服务的 YAML 定义。 请注意,端口列表包括 TCP 端口 53 和 UDP 端口 53:
apiVersion: v1
kind: Service
metadata:
labels:
k8s-app: kube-dns
kubernetes.io/cluster-service: "true"
kubernetes.io/name: KubeDNS
name: kube-dns
namespace: kube-system
spec:
clusterIP: 10.96.0.10
ports:
- name: dns
port: 53
protocol: UDP
targetPort: 53
- name: dns-tcp
port: 53
protocol: TCP
targetPort: 53
- name: metrics
port: 9153
protocol: TCP
targetPort: 9153
selector:
k8s-app: kube-dns
type: ClusterIP
正如我们到目前为止讨论的那样,服务负载均衡流量跨越 Pod。 服务 API 资源代表负载均衡器的前端。 后端或在负载均衡器后面的 Pod 集合由 Endpoints 资源和控制器跟踪,接下来我们将讨论这些。
端点
Endpoints 资源是参与 Kubernetes 服务实现的另一个 API 对象。 每个服务资源都有一个相应的 Endpoints 资源。 如果您回忆起负载均衡器的类比,可以将 Endpoints 对象视为接收流量的 IP 地址池。 图 6-5 显示了服务与端点之间的关系。

图 6-5. 服务和端点资源之间的关系。
Endpoints 资源
例如,示例 6-1 中 nginx 服务的 Endpoints 资源可能如下所示(已删除一些无关字段):
apiVersion: v1
kind: Endpoints
metadata:
labels:
run: nginx
name: nginx
namespace: default
subsets:
- addresses:
- ip: 10.244.0.10
nodeName: kube03
targetRef:
kind: Pod
name: nginx-76df748b9-gblnn
namespace: default
- ip: 10.244.0.9
nodeName: kube04
targetRef:
kind: Pod
name: nginx-76df748b9-gb7wl
namespace: default
ports:
- port: 8080
protocol: TCP
在本例中,有两个支持 nginx 服务的 Pod。 发送到 nginx ClusterIP 的网络流量在这两个 Pod 之间进行负载平衡。 还要注意端口为 8080 而不是 80。 此端口与服务中指定的 targetPort 字段匹配。 这是后端 Pod 正在侦听的端口。
端点控制器
关于 Endpoints 资源的一个有趣之处是,在创建 Service 时,Kubernetes 会自动创建它。这在你通常与之交互的其他 API 资源中略有不同。
Endpoints 控制器负责创建和维护 Endpoints 对象。每当创建一个 Service 时,Endpoints 控制器都会创建相应的 Endpoints 资源。更重要的是,它还根据需要更新 Endpoints 对象中的 IP 列表。
控制器使用 Service 的 Pod 选择器来查找属于该 Service 的 Pod。一旦获取了一组 Pod,控制器就会获取 Pod 的 IP 地址,并相应地更新 Endpoints 资源。
Endpoints 资源中的地址可以分为两组:(ready) addresses 和 notReadyAddresses。Endpoints 控制器通过检查对应 Pod 的 Ready 条件来确定地址是否准备就绪。而 Pod 的 Ready 条件又取决于多个因素。例如,其中一个因素是 Pod 是否已调度。如果 Pod 处于挂起状态(未调度),其 Ready 条件为 false。最终,只有在 Pod 正在运行并通过其就绪探针时,它才被认为是准备就绪的。
Pod 的就绪状态和就绪探针
在前一节中,我们讨论了 Endpoints 控制器如何确定 Pod IP 地址是否准备好接收流量。但 Kubernetes 如何知道 Pod 是否准备好?
Kubernetes 使用两种互补的方法来确定 Pod 的就绪状态:
平台信息
Kubernetes 管理的工作负载信息丰富。例如,系统知道 Pod 是否成功调度到节点上。它还知道 Pod 的容器是否正在运行。
就绪探针
开发人员可以在其工作负载上配置就绪探针。设置后,kubelet 定期探测工作负载,以确定其是否准备好接收流量。相比基于平台信息确定就绪状态,通过探测 Pod 就绪更为强大,因为探针可以检查应用程序特定的问题。例如,探针可以检查应用程序的内部初始化过程是否已完成。
就绪探针至关重要。没有它们,集群可能会将流量路由到可能无法处理的工作负载,导致应用程序错误和终端用户的不满。确保您在部署到 Kubernetes 的应用程序中始终定义就绪探针。在第十四章中,我们将进一步讨论就绪探针。
EndpointSlices 资源
EndpointSlices 资源是 Kubernetes v1.16 中的一项优化。它解决了在大型集群部署中可能出现的 Endpoints 资源的可伸缩性问题。让我们来看看这些问题,并探讨 EndpointSlices 是如何帮助解决的。
要实现服务并使其可路由,集群中的每个节点都会监视 Endpoints API 并订阅变化。每当 Endpoints 资源更新时,必须将其传播到集群中的所有节点才能生效。扩展事件是一个很好的例子。每当 Endpoints 资源中的 Pod 集合发生变化时,API 服务器会将整个更新后的对象发送到所有集群节点。
由于多种原因,这种处理 Endpoints API 的方法在较大的集群中效果不佳:
-
大型集群包含许多节点。集群中的节点越多,当 Endpoints 对象发生变化时,需要发送的更新就越多。
-
集群越大,您可以托管的 Pod(和服务)就越多。随着 Pod 数量的增加,Endpoints 资源更新的频率也增加。
-
随着服务中属于的 Pod 数量增加,Endpoints 资源的大小也会增加。较大的 Endpoints 对象需要更多的网络和存储资源。
EndpointSlices 资源通过将端点集合分割到多个资源中来修复这些问题。Kubernetes 不再将所有 Pod IP 地址放在单个 Endpoints 资源中,而是将地址分布在各种 EndpointSlice 对象中。默认情况下,EndpointSlice 对象仅限于 100 个端点。
让我们探讨一个场景,以更好地理解 EndpointSlices 的影响。假设一个具有 10,000 个端点的服务,这将导致 100 个 EndpointSlice 对象。如果移除其中一个端点(例如由于规模缩小事件),API 服务器会将受影响的 EndpointSlice 对象发送到每个节点。将具有 100 个端点的单个 EndpointSlice 发送要比将具有数千个端点的单个 Endpoints 资源效率高得多。
总之,EndpointSlices 资源通过将大量的端点分割为一组 EndpointSlice 对象来改进 Kubernetes 的可扩展性。如果您运行的平台具有数百个端点的服务,您可能会从 EndpointSlice 的改进中受益。根据您的 Kubernetes 版本,EndpointSlice 功能是可选的。如果您运行的是 Kubernetes v1.18,您必须在 kube-proxy 中设置一个功能标志才能启用 EndpointSlice 资源的使用。从 Kubernetes v1.19 开始,默认情况下将启用 EndpointSlice 功能。
服务实施详细信息
到目前为止,我们已经讨论了 Kubernetes 集群中的服务、Endpoints 及其提供的功能。但是 Kubernetes 如何实现服务?它是如何工作的?
在本节中,我们将讨论在 Kubernetes 中实现服务时可用的不同方法。首先,我们将讨论整体的 kube-proxy 架构。接下来,我们将审查不同的 kube-proxy 数据平面模式。最后,我们将讨论 kube-proxy 的替代方案,如能够接管 kube-proxy 职责的 CNI 插件。
Kube-proxy
Kube-proxy 是在每个集群节点上运行的代理程序。它的主要责任是通过监视 API 服务器的 Services 和 Endpoints,并编程 Linux 网络堆栈(例如使用 iptables)来处理数据包,从而使 Services 对运行在本地节点上的 Pods 可用。
注意
在历史上,kube-proxy 充当节点上运行的 Pods 与 Services 之间的网络代理。这也是 kube-proxy 名称的由来。然而,随着 Kubernetes 项目的发展,kube-proxy 不再充当代理,而是更多地成为节点代理或本地化控制平面。
Kube-proxy 支持三种操作模式:userspace、iptables 和 IPVS。由于 iptables 和 IPVS 更优,所以用户空间代理模式很少使用。因此,我们只会在本章的后续部分涵盖 iptables 和 IPVS 模式。
Kube-proxy:iptables 模式
在撰写本文时(Kubernetes v1.18),iptables 模式是默认的 kube-proxy 模式。可以肯定地说,iptables 模式是当前集群安装中最普遍的模式。
在 iptables 模式下,kube-proxy 利用 iptables 的网络地址转换 (NAT) 功能。
ClusterIP Services
为了实现 ClusterIP Services,kube-proxy 编程 Linux 内核的 NAT 表以对目标为 Services 的数据包执行目标地址 NAT (DNAT)。DNAT 规则将数据包的目标 IP 地址替换为 Service 端点的 IP 地址(Pod 的 IP 地址)。替换后,网络处理该数据包,就像它最初发送到 Pod 一样。
为了在多个 Service 端点之间负载均衡流量,kube-proxy 使用多个 iptables 链:
Services chain
包含每个 Service 规则的顶级链。每个规则检查数据包的目标 IP 是否与 Service 的 ClusterIP 匹配。如果匹配,则将数据包发送到特定于 Service 的链。
特定于 Service 的链
每个 Service 都有自己的 iptables 链。此链包含每个 Service 端点的规则。每个规则使用 statistic iptables 扩展随机选择一个目标端点。每个端点被选中的概率为 1/n,其中 n 是端点的数量。一旦选择了端点,数据包就会被发送到 Service 端点链。
Service 端点链
每个 Service 端点都有一个执行 DNAT 的 iptables 链。
下面列出的 iptables 规则示例显示了一个 ClusterIP Service 的示例。该 Service 名为 nginx,有三个端点(为简洁起见,删除了多余的 iptables 规则):
$ iptables --list --table nat
Chain KUBE-SERVICES (2 references) 
target prot opt source destination
KUBE-MARK-MASQ tcp -- !10.244.0.0/16 10.97.85.96
/* default/nginx: cluster IP */ tcp dpt:80
KUBE-SVC-4N57TFCL4MD7ZTDA tcp -- anywhere 10.97.85.96
/* default/nginx: cluster IP */ tcp dpt:80
KUBE-NODEPORTS all -- anywhere anywhere
/* kubernetes service nodeports; NOTE: this must be the last rule in
this chain */ ADDRTYPE match dst-type LOCAL
Chain KUBE-SVC-4N57TFCL4MD7ZTDA (1 references) 
target prot opt source destination
KUBE-SEP-VUJFIIOGYVVPH7Q4 all -- anywhere anywhere /* default/nginx: */
statistic mode random probability 0.33333333349
KUBE-SEP-Y42457KCQHG7FFWI all -- anywhere anywhere /* default/nginx: */
statistic mode random probability 0.50000000000
KUBE-SEP-UOUQBAIW4Z676WKH all -- anywhere anywhere /* default/nginx: */
Chain KUBE-SEP-UOUQBAIW4Z676WKH (1 references) 
target prot opt source destination
KUBE-MARK-MASQ all -- 10.244.0.8 anywhere /* default/nginx: */
DNAT tcp -- anywhere anywhere /* default/nginx: */
tcp to:10.244.0.8:80
Chain KUBE-SEP-VUJFIIOGYVVPH7Q4 (1 references)
target prot opt source destination
KUBE-MARK-MASQ all -- 10.244.0.108 anywhere /* default/nginx: */
DNAT tcp -- anywhere anywhere /* default/nginx: */
tcp to:10.244.0.108:80
Chain KUBE-SEP-Y42457KCQHG7FFWI (1 references)
target prot opt source destination
KUBE-MARK-MASQ all -- 10.244.0.6 anywhere /* default/nginx: */
DNAT tcp -- anywhere anywhere /* default/nginx: */
tcp to:10.244.0.6:80
这是顶级链。它包含集群中所有 Services 的规则。请注意,KUBE-SVC-4N57TFCL4MD7ZTDA 规则指定目标 IP 为 10.97.85.96。这是 nginx Service 的 ClusterIP。
这是 nginx Service 的链。请注意,每个 Service 端点都有一个匹配规则的概率。
这条链路对应于一个服务端点(SEP 代表服务端点)。该链路中的最后一条规则执行 DNAT,将数据包转发到端点(或 Pod)。
NodePort 和 LoadBalancer 服务
在使用 NodePort 和 LoadBalancer 服务时,kube-proxy 配置 iptables 规则类似于用于 ClusterIP 服务的规则。主要区别在于规则基于它们的目标端口号匹配数据包。如果匹配,则规则将数据包发送到服务特定的链路,其中进行 DNAT。下面的片段显示了 NodePort 服务nginx监听端口 31767 的 iptables 规则。
$ iptables --list --table nat
Chain KUBE-NODEPORTS (1 references) 
target prot opt source destination
KUBE-MARK-MASQ tcp -- anywhere anywhere /* default/nginx: */
tcp dpt:31767
KUBE-SVC-4N57TFCL4MD7ZTDA tcp -- anywhere anywhere /* default/nginx: */
tcp dpt:31767 
Kube-proxy 为 NodePort 服务在KUBE-NODEPORTS链中编程 iptables 规则。
如果数据包的目标端口是tcp: 31767,则将其发送到服务特定的链路。这个链路是我们在前一个代码片段的 callout 2 中看到的服务特定的链路。
除了编程 iptables 规则外,kube-proxy 还打开分配给 NodePort 服务的端口并保持其开放。从路由的角度来看,保持端口开放没有实际功能,它只是阻止其他进程声明该端口。
使用 NodePort 和 LoadBalancer 服务时需要考虑的一个关键因素是服务的外部流量策略设置。外部流量策略确定服务是否将外部流量路由到节点本地端点(externalTrafficPolicy: Local)或整个集群范围的端点(externalTrafficPolicy: Cluster)。每种策略都有其优点和权衡,接下来会讨论。
当策略设置为Local时,服务将流量路由到运行在接收流量的节点上的端点(Pods)。路由到本地端点有两个重要的好处。首先,没有涉及 SNAT,因此源 IP 被保留,可供工作负载使用。其次,在将流量转发到另一个节点时,没有额外的网络跳跃。话虽如此,Local策略也有缺点。主要是,到达缺少服务端点的节点的流量会被丢弃。因此,通常将Local策略与对节点进行健康检查的外部负载均衡器结合使用。当节点没有服务端点时,负载均衡器不会将流量发送到该节点,因为健康检查失败。图 6-6 (#loadbalancer_service_with_local_external_traffic_policy) 说明了此功能。Local策略的另一个缺点是可能导致应用负载不平衡。例如,如果一个节点有三个服务端点,则每个端点接收到 33%的流量。如果另一个节点只有一个端点,则它接收到 100%的流量。可以通过使用反亲和性规则扩展 Pods 或使用 DaemonSet 来调度 Pods 来减轻这种不平衡。

图 6-6. 使用 Local 外部流量策略的负载均衡服务。外部负载均衡器对节点运行健康检查。任何没有服务端点的节点都将从负载均衡器的后端池中移除。
如果您有一个处理大量外部流量的服务,通常使用 Local 外部流量策略是正确的选择。但是,如果没有可用的负载均衡器,应使用 Cluster 外部流量策略。使用此策略,流量将在集群中的所有端点间进行负载均衡,如 图 6-7 所示。可以想象,负载均衡会导致源 IP 的丢失(由于 SNAT)。它还可能导致额外的网络跳跃。但是,Cluster 策略不会丢弃外部流量,无论端点 Pod 运行在何处。

图 6-7. 使用 Cluster 外部流量策略的负载均衡服务。没有本地端点的节点将流量转发到另一节点上的端点。
连接跟踪(conntrack)
当内核的网络堆栈对要发送到服务的数据包执行目的地址转换(DNAT)时,会向连接跟踪(conntrack)表中添加一个条目。该表跟踪执行的转换,以便对发送到同一服务的任何额外数据包应用相同的转换。该表还用于在将响应数据包发送到源 Pod 之前移除响应数据包中的 NAT。
表中的每个条目将预转换协议、源 IP、源端口、目的 IP 和目的端口映射到后转换协议、源 IP、源端口、目的 IP 和目的端口。(条目包含其他信息,但在此上下文中不相关。)图 6-8 描述了一个跟踪从 Pod (192.168.0.9) 到服务 (10.96.0.14) 的连接的表条目。注意 DNAT 后目的 IP 和端口的变化。

图 6-8. 跟踪连接(conntrack)表条目,跟踪来自 Pod (192.168.0.9) 到服务 (10.96.0.14) 的连接。
提示
当连接跟踪表填满时,内核开始丢弃或拒绝连接,这可能对某些应用程序造成问题。如果您正在运行处理大量连接的工作负载,并且注意到连接问题,可能需要调整节点上连接跟踪表的最大大小。更重要的是,应当监视连接跟踪表的使用情况,并在表接近填满时发出警报。
掩码(Masquerade)
您可能已经注意到我们在上一个示例中忽略了 KUBE-MARK-MASQ iptables 规则。这些规则适用于从集群外部到达节点的数据包。为了正确路由这些数据包,服务 fabric 需要在将其转发到另一个节点时对其进行伪装/源 NAT。否则,响应数据包将包含处理请求的 Pod 的 IP 地址。数据包中的 Pod IP 将导致连接问题,因为客户端发起连接到节点而不是 Pod。
Masquerading 也用于从集群中出口流量。当 Pod 连接到外部服务时,源 IP 必须是运行 Pod 的节点的 IP 地址,而不是 Pod 的 IP 地址。否则,网络会丢弃响应数据包,因为它们的目标 IP 地址将是 Pod 的 IP 地址。
性能问题
iptables 模式至今已经在 Kubernetes 集群中发挥了重要作用,并继续如此。尽管如此,您应该注意某些性能和可扩展性限制,特别是在大型集群部署中可能会出现这些问题。
考虑到 iptables 规则的结构和工作方式,每当 Pod 向 Service 建立新连接时,初始数据包会遍历 iptables 规则,直到匹配其中之一。在最坏的情况下,数据包需要遍历整个 iptables 规则集。
当 iptables 模式处理数据包时,其时间复杂度为 O(n)。换句话说,iptables 模式的性能随着集群中服务数量的增加呈线性增长。随着服务数量的增加,连接到服务的性能变差。
或许更重要的是,对 iptables 规则的更新在大规模情况下也会受到影响。因为 iptables 规则不是增量的,kube-proxy 需要为每次更新写入整个表格。在某些情况下,这些更新甚至可能需要几分钟来完成,这会导致将流量发送到过时的端点。此外,kube-proxy 在这些更新期间需要保持 iptables 锁(/run/xtables.lock),这可能会与需要更新 iptables 规则的其他进程(例如 CNI 插件)发生争用。
线性扩展是任何系统的不良特性。尽管如此,根据 Kubernetes 社区的 测试 结果,除非运行具有成千上万个服务的集群,否则您不应该注意到性能下降。然而,如果您在这种规模下操作,您可能会从 kube-proxy 的 IPVS 模式中受益,我们将在以下章节讨论。
Kube-proxy:IP 虚拟服务器(IPVS)模式
IPVS 是嵌入到 Linux 内核中的负载均衡技术。Kubernetes 在 kube-proxy 中增加了对 IPVS 的支持,以解决 iptables 模式的可扩展性和性能问题。
正如前一节所讨论的,iptables 模式使用 iptables 规则来实现 Kubernetes 服务。这些 iptables 规则存储在列表中,在最坏的情况下,数据包需要完整遍历这些规则。IPVS 并不会遇到这个问题,因为它最初是为负载均衡场景设计的。
Linux 内核中的 IPVS 实现使用哈希表来查找数据包的目的地。当建立新连接时,IPVS 不会遍历服务列表,而是立即根据服务 IP 地址找到目标 Pod。
让我们讨论 IPVS 模式下的 kube-proxy 如何处理每种 Kubernetes 服务类型。
ClusterIP 服务
在处理具有 ClusterIP 的服务时,ipvs 模式下的 kube-proxy 会执行一些操作。首先,它将 ClusterIP 服务的 IP 地址添加到节点上称为 kube-ipvs0 的虚拟网络接口中,如以下片段所示:
$ ip address show dev kube-ipvs0
28: kube-ipvs0: <BROADCAST,NOARP> mtu 1500 qdisc noop state DOWN group default
link/ether 96:96:1b:36:32:de brd ff:ff:ff:ff:ff:ff
inet 10.110.34.183/32 brd 10.110.34.183 scope global kube-ipvs0
valid_lft forever preferred_lft forever
inet 10.96.0.10/32 brd 10.96.0.10 scope global kube-ipvs0
valid_lft forever preferred_lft forever
inet 10.96.0.1/32 brd 10.96.0.1 scope global kube-ipvs0
valid_lft forever preferred_lft forever
更新虚拟接口后,kube-proxy 使用 ClusterIP 服务的 IP 地址创建一个 IPVS 虚拟服务。最后,对于每个服务端点,它将一个 IPVS 真实服务器添加到 IPVS 虚拟服务中。以下片段显示了具有三个端点的 ClusterIP 服务的 IPVS 虚拟服务和真实服务器:
$ ipvsadm --list --numeric --tcp-service 10.110.34.183:80
Prot LocalAddress:Port Scheduler Flags
-> RemoteAddress:Port Forward Weight ActiveConn InActConn
TCP 10.110.34.183:80 rr 
-> 192.168.89.153:80 Masq 1 0 0 
-> 192.168.89.154:80 Masq 1 0 0
-> 192.168.89.155:80 Masq 1 0 0
这是 IPVS 虚拟服务。其 IP 地址是 ClusterIP Service 的 IP 地址。
这是 IPVS 的一个真实服务器之一。它对应于服务端点(Pod)之一。
NodePort 和 LoadBalancer 服务
对于 NodePort 和 LoadBalancer 服务,kube-proxy 为服务的集群 IP 创建一个 IPVS 虚拟服务。Kube-proxy 还为每个节点的 IP 地址和回环地址创建一个 IPVS 虚拟服务。例如,以下片段显示了监听 TCP 端口 30737 的 NodePort 服务所创建的 IPVS 虚拟服务清单:
ipvsadm --list --numeric
IP Virtual Server version 1.2.1 (size=4096)
Prot LocalAddress:Port Scheduler Flags
-> RemoteAddress:Port Forward Weight ActiveConn InActConn
TCP 10.0.99.67:30737 rr 
-> 192.168.89.153:80 Masq 1 0 0
-> 192.168.89.154:80 Masq 1 0 0
-> 192.168.89.155:80 Masq 1 0 0
TCP 10.110.34.183:80 rr 
-> 192.168.89.153:80 Masq 1 0 0
-> 192.168.89.154:80 Masq 1 0 0
-> 192.168.89.155:80 Masq 1 0 0
TCP 127.0.0.1:30737 rr 
-> 192.168.89.153:80 Masq 1 0 0
-> 192.168.89.154:80 Masq 1 0 0
-> 192.168.89.155:80 Masq 1 0 0
TCP 192.168.246.64:30737 rr 
-> 192.168.89.153:80 Masq 1 0 0
-> 192.168.89.154:80 Masq 1 0 0
-> 192.168.89.155:80 Masq 1 0 0
IPVS 虚拟服务监听在节点的 IP 地址上。
IPVS 虚拟服务监听在服务的 ClusterIP 地址上。
IPVS 虚拟服务监听在 localhost 上。
IPVS 虚拟服务监听在节点的第二网络接口上。
在没有 kube-proxy 的情况下运行
历史上,kube-proxy 是所有 Kubernetes 部署的一个重要组件。它是使 Kubernetes 服务正常工作的关键组成部分。然而,随着社区的发展,我们可能开始看到一些不运行 kube-proxy 的 Kubernetes 部署。这是如何实现的?谁来处理服务?
随着扩展的伯克利数据包过滤器(eBPF)的出现,诸如 Cilium 和 Calico 等 CNI 插件可以吸收 kube-proxy 的职责。这些 CNI 插件不再使用 iptables 或 IPVS 处理服务,而是直接将服务编程到 Pod 网络数据平面中。使用 eBPF 可以提高 Kubernetes 中服务的性能和可扩展性,因为 eBPF 实现使用哈希表进行端点查找。它还改进了服务更新处理,因为它能够高效地处理单个服务的更新。
消除 kube-proxy 的需求并优化服务路由是一个值得称道的成就,尤其是对于大规模操作的用户。然而,在生产环境中运行这些解决方案仍处于早期阶段。例如,Cilium 实现要求较新的内核版本来支持无 kube-proxy 的部署(在撰写本文时,最新的 Cilium 版本是 v1.8)。同样,Calico 团队不鼓励在生产环境中使用 eBPF,因为它仍处于技术预览阶段(在撰写本文时,最新的 Calico 版本是 v3.15.1)。随着时间的推移,我们预计 kube-proxy 的替代方案会变得更加普遍。Cilium 甚至支持在其他 CNI 插件旁边运行其代理替代功能(称为 CNI chaining)。
服务发现
服务发现提供了一种机制,使应用程序能够发现网络上可用的服务。虽然不是 路由 关注的内容,但服务发现与 Kubernetes 服务密切相关。
平台团队可能会疑惑是否需要在集群中引入专用的服务发现系统,例如 Consul。虽然这是可能的,但通常是不必要的,因为 Kubernetes 为运行在集群中的所有工作负载提供了服务发现。在本节中,我们将讨论 Kubernetes 中可用的不同服务发现机制:基于 DNS 的服务发现、基于 API 的服务发现和基于环境变量的服务发现。
使用 DNS
Kubernetes 为运行在集群内部的工作负载提供了基于 DNS 的服务发现。符合规范的 Kubernetes 部署运行一个与 Kubernetes API 集成的 DNS 服务器。今天最常用的 DNS 服务器是 CoreDNS,这是一个开源的、可扩展的 DNS 服务器。
CoreDNS 在 Kubernetes API 服务器中监视资源。对于每个 Kubernetes 服务,CoreDNS 创建具有以下格式的 DNS 记录:<service-name>.<namespace-name>.svc.cluster.local。例如,名为 nginx 的服务在 default 命名空间中将获得 DNS 记录 nginx.default.svc.cluster.local。但是 Pod 如何使用这些 DNS 记录?
为了启用基于 DNS 的服务发现,Kubernetes 为 Pod 配置 CoreDNS 作为 DNS 解析器。在设置 Pod 的沙盒时,kubelet 会将 /etc/resolv.conf 写入容器中,指定 CoreDNS 作为命名服务器,并将配置文件注入容器中。Pod 的 /etc/resolv.conf 文件如下所示:
$ cat /etc/resolv.conf
search default.svc.cluster.local svc.cluster.local cluster.local
nameserver 10.96.0.10
options ndots:5
根据这个配置,Pod 在尝试按名称连接到服务时会向 CoreDNS 发送 DNS 查询。
解析器配置中另一个有趣的技巧是使用 ndots 和 search 简化 DNS 查询。当 Pod 想要连接同一命名空间中存在的服务时,可以将服务的名称用作域名,而不是完全限定域名(nginx.default.svc.cluster.local):
$ nslookup nginx
Server: 10.96.0.10
Address: 10.96.0.10#53
Name: nginx.default.svc.cluster.local
Address: 10.110.34.183
同样,当 Pod 想要连接另一个命名空间中的服务时,可以通过将命名空间名称附加到服务名称来实现:
$ nslookup nginx.default
Server: 10.96.0.10
Address: 10.96.0.10#53
Name: nginx.default.svc.cluster.local
Address: 10.110.34.183
考虑 ndots 配置对与集群外部服务通信的应用程序的影响。ndots 参数指定域名中必须出现多少个点号才能被视为绝对或完全限定名称。当解析非完全限定名称时,系统会使用 search 参数中的条目进行多次查找,如下例所示。因此,当应用程序解析非完全限定的集群外部名称时,解析器会在尝试将名称解析为绝对名称之前向集群 DNS 服务器发出多个无用的请求。为了避免此问题,可以通过在应用程序中添加 . 结尾的完全限定域名来使用完全限定域名。另外,您还可以通过 Pod 规范中的 dnsConfig 字段调整 Pod 的 DNS 配置。
下面的代码片段展示了 ndots 配置对解析外部名称的 Pod 的影响。请注意,在解析少于配置的 ndots 的点数的名称时,会导致多次 DNS 查询,而解析绝对名称则只产生单个查询:
$ nslookup -type=A google.com -debug | grep QUESTIONS -A1 
QUESTIONS:
google.com.default.svc.cluster.local, type = A, class = IN
--
QUESTIONS:
google.com.svc.cluster.local, type = A, class = IN
--
QUESTIONS:
google.com.cluster.local, type = A, class = IN
--
QUESTIONS:
google.com, type = A, class = IN
$ nslookup -type=A -debug google.com. | grep QUESTIONS -A1 
QUESTIONS:
google.com, type = A, class = IN
尝试解析少于 5 个点的名称(非完全限定)。解析器执行多次查找,每个查找项在 /etc/resolv.conf 的 search 字段中。
尝试解析完全限定名称。解析器执行单次查找。
总体而言,通过 DNS 进行服务发现非常有用,因为它降低了应用程序与 Kubernetes 服务交互的障碍。
使用 Kubernetes API
Kubernetes 中发现服务的另一种方法是使用 Kubernetes API。社区维护着不同语言的各种 Kubernetes 客户端库,包括 Go、Java、Python 等。一些应用程序框架(如 Spring)也通过 Kubernetes API 支持服务发现。
在特定情况下,使用 Kubernetes API 进行服务发现非常有用。例如,如果您的应用程序需要在服务端点更改时立即知道,它们将从监视 API 中受益。
通过 Kubernetes API 执行服务发现的主要缺点是将应用程序紧密耦合到底层平台。理想情况下,应用程序应该不了解平台的存在。如果选择使用 Kubernetes API 进行服务发现,请考虑构建一个接口,将 Kubernetes 的详细信息从业务逻辑中抽象出来。
使用环境变量
Kubernetes 将环境变量注入到 Pod 中以促进服务发现。对于每个 Service,Kubernetes 根据 Service 的定义设置多个环境变量。例如,监听端口 80 的 nginx ClusterIP Service 的环境变量如下:
NGINX_PORT_80_TCP_PORT=80
NGINX_SERVICE_HOST=10.110.34.183
NGINX_PORT=tcp://10.110.34.183:80
NGINX_PORT_80_TCP=tcp://10.110.34.183:80
NGINX_PORT_80_TCP_PROTO=tcp
NGINX_SERVICE_PORT=80
NGINX_PORT_80_TCP_ADDR=10.110.34.183
此方法的缺点是环境变量无法在不重启 Pod 的情况下更新。因此,必须在 Pod 启动之前准备好服务。
DNS 服务性能
如前所述,在您的平台上为工作负载提供基于 DNS 的服务发现非常重要。随着集群规模和应用程序数量的增长,DNS 服务可能成为瓶颈。在本节中,我们将讨论您可以使用的技术,以提供高性能的 DNS 服务。
每个节点上的 DNS 缓存
Kubernetes 社区维护名为 NodeLocal DNSCache 的 DNS 缓存插件。该插件在每个节点上运行 DNS 缓存,以解决多个问题。首先,缓存减少了 DNS 查询的延迟,因为工作负载可以从本地缓存中获取答案(假设命中缓存),而不是向 DNS 服务器请求(可能在另一个节点上)。其次,由于大部分时间工作负载使用缓存,CoreDNS 服务器的负载减少了。最后,在缓存未命中的情况下,本地 DNS 缓存在向中央 DNS 服务发出 DNS 查询时将其升级为 TCP。使用 TCP 而不是 UDP 提高了 DNS 查询的可靠性。
DNS 缓存作为 DaemonSet 在集群中运行。每个 DNS 缓存副本拦截其节点上发起的 DNS 查询。无需更改应用程序代码或配置即可使用该缓存。NodeLocal DNSCache 插件的节点级架构如 图 6-9 所示。

图 6-9. NodeLocal DNSCache 插件的节点级架构。DNS 缓存拦截 DNS 查询,并在有缓存命中时立即响应。在缓存未命中的情况下,DNS 缓存将查询转发给集群 DNS 服务。
自动扩展 DNS 服务器部署
除了在集群中运行节点本地 DNS 缓存外,您还可以根据集群的大小自动调整 DNS 部署。请注意,此策略不利用水平 Pod 自动缩放器。相反,它使用集群比例自动缩放器,根据集群中的节点数量来调整工作负载。
集群比例自动缩放器作为一个 Pod 在集群中运行。它有一个配置标志来设置需要自动缩放的工作负载。要自动缩放 DNS,必须将目标标志设置为 CoreDNS(或 kube-dns)部署。一旦运行,自动缩放器每 10 秒(默认)轮询 API 服务器以获取集群中节点和 CPU 核心的数量。然后,如果需要,它会调整 CoreDNS 部署的副本数。所需的副本数由可配置的副本-节点比率或副本-核心比率控制。要使用的比率取决于您的工作负载及其对 DNS 的需求程度。
在大多数情况下,使用节点本地 DNS 缓存足以提供可靠的 DNS 服务。但是,当自动缩放具有足够范围的最小和最大节点的集群时,自动缩放 DNS 是另一种可用的策略。
Ingress
正如我们在第五章中讨论的那样,运行在 Kubernetes 中的工作负载通常无法从集群外访问。如果您的应用程序没有外部客户端,则这不是问题。批处理工作负载是这类应用程序的一个很好的例子。然而,现实情况是,大多数 Kubernetes 部署托管具有端用户的 Web 服务。
Ingress 是将运行在 Kubernetes 中的服务暴露给集群外客户端的一种方法。尽管 Kubernetes 默认不支持 Ingress API,但它是任何基于 Kubernetes 的平台的标配。通常情况下,现成的 Kubernetes 应用程序和集群附加组件都期望在集群中运行 Ingress 控制器。此外,您的开发人员需要它来成功地在 Kubernetes 中运行其应用程序。
本节旨在指导您在平台中实现 Ingress 时必须考虑的因素。我们将审查 Ingress API、您将遇到的最常见的 Ingress 流量模式以及在基于 Kubernetes 的平台中 Ingress 控制器的关键角色。我们还将讨论不同的部署 Ingress 控制器的方法及其权衡。最后,我们将解决您可能遇到的常见挑战,并探讨与生态系统中其他工具的有益集成。
Ingress 的案例
Kubernetes 服务已经提供了将流量路由到 Pod 的方法,那么为什么还需要额外的策略来实现相同的功能呢?尽管我们喜欢保持平台简单,但事实是服务存在重要的限制和缺点:
有限的路由能力
服务根据传入请求的目标 IP 和端口路由流量。这对于小型和相对简单的应用程序可能很有用,但对于更大规模的基于微服务的应用程序,这种方法很快就会失效。这些类型的应用程序需要更智能的路由功能和其他高级能力。
成本
如果您在云环境中运行,集群中每个 LoadBalancer 服务都会创建一个外部负载均衡器,例如 AWS 中的 ELB。为平台中每个服务运行单独的负载均衡器可能很快变得成本高昂。
Ingress 解决了这些限制。不再局限于 OSI 模型的第 3/4 层负载均衡,Ingress 在第 7 层提供负载均衡和路由功能。换句话说,Ingress 在应用层操作,这导致了更先进的路由特性。
Ingress 的另一个好处是无需多个负载均衡器或平台入口点。由于 Ingress 提供的先进路由能力,例如根据 Host 头部路由 HTTP 请求的能力,您可以将所有服务流量路由到单一入口点,并让 Ingress 控制器处理流量的复用。这极大地降低了将流量引入您的平台的成本。
平台只需一个入口点,也减少了非云部署的复杂性。不再需要管理多个带有大量 NodePort 服务的外部负载均衡器,只需操作一个外部负载均衡器即可将流量路由到 Ingress 控制器。
尽管 Ingress 解决了与 Kubernetes 服务相关的大部分问题,但后者仍然是必需的。Ingress 控制器本身运行在平台内部,因此需要暴露给存在于外部的客户端。您可以使用 Service(无论是 NodePort 还是 LoadBalancer)来实现。此外,大多数 Ingress 控制器在处理 HTTP 流量时表现出色。如果想要能够托管使用其他协议的应用程序,可能需要根据 Ingress 控制器的功能来决定是否同时使用 Service。
Ingress API
Ingress API 允许应用团队公开其服务,并根据其需求配置请求路由。由于 Ingress 主要关注 HTTP 路由,因此 Ingress API 资源提供了根据传入 HTTP 请求属性路由流量的不同方法。
一种常见的路由技术是根据 HTTP 请求的 Host 头部路由流量。例如,根据以下 Ingress 配置,Host 头部设置为 bookhotels.com 的 HTTP 请求将路由到一个服务,而目标为 bookflights.com 的请求将路由到另一个服务:
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: hotels-ingress
spec:
rules:
- host: bookhotels.com
http:
paths:
- path: /
backend:
serviceName: hotels
servicePort: 80
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: flights-ingress
spec:
rules:
- host: bookflights.com
http:
paths:
- path: /
backend:
serviceName: flights
servicePort: 80
在 Kubernetes 中,将应用程序托管在集群范围域名的特定子域名上是一种常见的方法。在这种情况下,你为平台分配一个域名,每个应用程序都有一个子域名。延续前面旅行主题的例子,旅行预订应用程序基于子域名的路由示例可以是 hotels.cluster1.useast.example.com 和 flights.cluster1.useast.example.com。基于子域名的路由是你可以采用的最佳策略之一。它还可以支持其他有趣的用例,比如在特定租户域名上托管软件即服务 (SaaS) 应用程序的租户(例如 tenantA.example.com 和 tenantB.example.com)。我们将在后面的章节进一步讨论如何实现基于子域名的路由。
Ingress API 支持超越基于主机的路由的功能。通过 Kubernetes 项目的演进,Ingress 控制器扩展了 Ingress API。不幸的是,这些扩展是通过注解而不是通过演变 Ingress 资源来完成的。使用注解的问题在于它们没有模式。这可能导致用户体验不佳,因为 API 服务器无法捕捉到配置错误。为了解决这个问题,一些 Ingress 控制器提供了自定义资源定义 (CRD)。这些资源具有定义良好的 API,提供了通过 Ingress 无法获得的功能。例如,Contour 提供了一个名为 HTTPProxy 的自定义资源。虽然利用这些 CRD 使您能够访问更广泛的功能,但如果需要,您可能会放弃更换 Ingress 控制器的能力。换句话说,您会“锁定”自己到特定的控制器中。
Ingress 控制器及其工作原理
如果你还记得第一次与 Kubernetes 玩耍的情景,你可能会遇到 Ingress 的一个令人困惑的场景。你下载了一堆示例 YAML 文件,其中包括一个 Deployment 和一个 Ingress,并将它们应用到你的集群。你注意到 Pod 很好地启动了,但是却无法访问它。实际上,Ingress 资源什么也没有做。你可能会想,这是怎么回事?
Ingress 是 Kubernetes 中留给平台构建者实现的 API 之一。换句话说,Kubernetes 暴露了 Ingress 接口,并期望另一个组件提供实现。这个组件通常被称为 Ingress controller。
Ingress 控制器是运行在集群中的平台组件。控制器负责监视 Ingress API,并根据 Ingress 资源中定义的配置进行操作。在大多数实现中,Ingress 控制器与反向代理配对,如 NGINX 或 Envoy。这种两组件架构与其他软件定义网络系统类似,其中控制器是 Ingress 控制器的控制平面组件,而代理是数据平面组件。图 6-10 展示了 Ingress 控制器的控制平面和数据平面。

图 6-10. Ingress 控制器监视 API 服务器中的各种资源,并相应地配置代理。代理处理传入的流量,并根据 Ingress 配置转发到 Pod。
Ingress 控制器的控制平面连接到 Kubernetes API,并监视各种资源,如 Ingress、Services、Endpoints 等。每当这些资源发生变化时,控制器会收到监视通知,并配置数据平面以依据 Kubernetes API 中声明的期望状态进行操作。
数据平面处理网络流量的路由和负载平衡。正如之前提到的,数据平面通常使用现成的代理实现。
因为 Ingress API 建立在 Service 抽象之上,Ingress 控制器可以选择通过 Services 转发流量,或直接发送到 Pods。大多数 Ingress 控制器选择后者。它们不使用 Service 资源,仅用于验证 Ingress 资源中引用的 Service 是否存在。在路由方面,大多数控制器将流量转发到对应 Endpoints 对象中列出的 Pod IP 地址。直接将流量路由到 Pod 可以绕过 Service 层,从而降低延迟并添加不同的负载平衡策略。
Ingress 流量模式
Ingress 的一个重要方面是每个应用程序都可以根据自己的需求配置路由。通常,每个应用程序在处理传入流量时有不同的需求。有些可能需要在边缘进行 TLS 终止。有些可能希望自己处理 TLS,而另一些可能根本不支持 TLS(希望不是这种情况)。
在本节中,我们将探讨我们遇到的常见 Ingress 流量模式。这应该让您了解 Ingress 能为开发人员提供什么,并且 Ingress 如何适应您的平台需求。
HTTP 代理
HTTP 代理是 Ingress 的核心功能。该模式涉及暴露一个或多个基于 HTTP 的服务,并根据 HTTP 请求的属性进行流量路由。我们已经讨论了基于 Host 头的路由。其他可能影响路由决策的属性包括 URL 路径、请求方法、请求头等,具体取决于 Ingress 控制器。
下面的 Ingress 资源将 app1 服务暴露在 app1.example.com 上。任何具有匹配 Host HTTP 头的传入请求都将发送到 app1 Pod。
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: app1
spec:
rules:
- host: app1.example.com
http:
paths:
- path: /
backend:
serviceName: app1
servicePort: 80
应用以上配置后,数据平面流向如图 6-11 所示。

图 6-11. 客户端到目标 Pod 通过 Ingress 控制器的 HTTP 请求路径。
使用 TLS 的 HTTP 代理
支持 TLS 加密对于 Ingress 控制器来说是基本要求。从路由的角度来看,这种 Ingress 流量模式与 HTTP 代理相同。然而,客户端通过安全的 TLS 连接与 Ingress 控制器通信,而不是明文 HTTP。
下面的示例显示了一个将 app1 以 TLS 方式暴露的 Ingress 资源。控制器从引用的 Kubernetes Secret 获取 TLS 服务证书和密钥。
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: app1
spec:
tls:
- hosts:
- app1.example.com
secretName: app1-tls-cert
rules:
- host: app1.example.com
http:
paths:
- path: /
backend:
serviceName: app1
servicePort: 443
当涉及到 Ingress 控制器与后端服务之间的连接时,Ingress 控制器支持不同的配置。外部客户端与控制器之间的连接是安全的(TLS),而控制器与后端应用程序之间的连接则不一定要安全。控制器与后端之间的连接是否安全取决于应用程序是否监听 TLS 连接。默认情况下,大多数 Ingress 控制器会终止 TLS 并通过非加密连接转发请求,如图 6-12 所示。

图 6-12. Ingress 控制器通过终止 TLS 处理 HTTPS 请求,并将请求转发到后端 Pod,使用非加密连接。
在需要与后端建立安全连接的情况下,Ingress 控制器会在边缘终止 TLS 连接,并与后端建立新的 TLS 连接(如图 6-13 所示)。重新建立 TLS 连接有时对某些应用程序不合适,例如那些需要与其客户端进行 TLS 握手的应用程序。在这些情况下,我们将在后面进一步讨论 TLS 穿透作为可行的替代方案。

图 6-13. Ingress 控制器在处理 HTTPS 请求时终止 TLS 并与后端 Pod 建立新的 TLS 连接。
第 3/4 层代理
尽管 Ingress API 的主要重点是第 7 层代理(HTTP 流量),一些 Ingress 控制器可以在第 3/4 层代理(TCP/UDP 流量)中代理流量。如果需要暴露不使用 HTTP 的应用程序,则这可能很有用。在评估 Ingress 控制器时,您必须牢记这一点,因为各个控制器对第 3/4 层代理的支持程度不同。
代理 TCP 或 UDP 服务的主要挑战在于,Ingress 控制器通常仅监听有限数量的端口,通常是 80 和 443。可以想象,如果没有区分流量的策略,将不可能在同一端口上暴露不同的 TCP 或 UDP 服务。不同的 Ingress 控制器通过不同的方式解决了这个问题。例如,Contour 支持仅代理使用 Server Name Indication(SNI)TLS 扩展的 TLS 加密 TCP 连接。这样做的原因是 Contour 需要知道流量的目的地。在使用 SNI 时,目标域名在 TLS 握手的 ClientHello 消息中是可用的(未加密)。由于 TLS 和 SNI 依赖于 TCP,Contour 不支持 UDP 代理。
下面是一个示例 HTTPProxy 自定义资源,Contour 支持此资源。在第 3/4 层代理的常见情况下,自定义资源提供比 Ingress API 更好的体验:
apiVersion: projectcontour.io/v1
kind: HTTPProxy
metadata:
name: tcp-proxy
spec:
virtualhost:
fqdn: tcp.bearcanoe.com
tls:
secretName: secret
tcpproxy:
services:
- name: tcp-app
port: 8080
在上述配置中,Contour 读取 SNI 扩展中的服务器名称,并将流量代理到后端 TCP 服务。图 6-14 展示了此功能。

图 6-14. Ingress 控制器检查 SNI 头以确定后端,终止 TLS 连接,并将 TCP 流量转发到 Pod。
其他 Ingress 控制器会公开配置参数,您可以使用这些参数告知底层代理为第 3/4 层代理绑定附加端口。然后,您将这些额外的端口映射到集群中运行的特定服务。这是由社区主导的 NGINX Ingress 控制器在第 3/4 层代理中采用的方法。
第 3/4 层代理的常见用例是 TLS 穿透。TLS 穿透涉及一个应用程序暴露 TLS 端点,并需要直接与客户端进行 TLS 握手处理。正如我们在“使用 TLS 进行 HTTP 代理”的模式中讨论的那样,Ingress 控制器通常会终止面向客户端的 TLS 连接。这种 TLS 终止是必需的,以便 Ingress 控制器可以检查 HTTP 请求,否则该请求将会被加密。然而,使用 TLS 穿透时,Ingress 控制器不会终止 TLS,而是将安全连接代理到后端 Pod。图 6-15 描述了 TLS 穿透。

图 6-15。当启用 TLS 透传时,入口控制器检查 SNI 头部以确定后端,并相应地转发 TLS 连接。
选择一个入口控制器
有几种入口控制器可以选择。根据我们的经验,NGINX 入口控制器是最常用的之一。但这并不意味着它对你的应用平台最好。其他选择包括 Contour、HAProxy、Traefik 等等。与本书主题一致,我们的目标不是告诉你应该使用哪个,而是为你提供做出这个决定所需的信息。我们还将突出显示适用时的重大权衡。
退一步来看,入口控制器的主要目标是处理应用程序流量。因此,在选择入口控制器时,自然而然地转向应用程序作为主要因素。具体来说,你的应用程序需要哪些特性和要求?以下是你可以从应用支持的角度评估入口控制器的标准列表:
-
应用程序是否公开 HTTPS 端点?它们是否需要直接与客户端处理 TLS 握手,或者在边缘终止 TLS 会合适?
-
应用程序使用哪些 SSL 密码?
-
应用程序是否需要会话亲和性或粘性会话?
-
应用程序是否需要高级请求路由能力,例如基于 HTTP 头部的路由、基于 Cookie 的路由、基于 HTTP 方法的路由等等?
-
应用程序是否需要不同的负载均衡算法要求,例如轮询、加权最少请求或随机?
-
应用程序是否需要支持跨源资源共享(CORS)?
-
应用程序是否将身份验证问题卸载到外部系统?一些入口控制器提供身份验证功能,你可以利用这些功能提供应用程序之间的通用认证机制。
-
是否有应用程序需要公开 TCP 或 UDP 端点?
-
应用程序是否需要能力来限制传入流量的速率?
除了应用程序需求之外,另一个关键考虑因素是你的组织对数据平面技术的经验。如果你已经对特定的代理非常熟悉,通常从那里开始是一个安全的选择。你将已经对它的工作原理有了很好的理解,更重要的是,你将了解它的局限性以及如何进行故障排除。
支持性是另一个需要考虑的关键因素。入口是你平台的一个重要组成部分。它存在于你的客户和他们试图访问的服务之间的中间。当入口控制器出现问题时,你希望在面对停机时能够获得所需的支持。
最后,请记住您可以使用 Ingress 类在平台上运行多个 Ingress 控制器。这样做会增加平台的复杂性和管理难度,但在某些情况下是必要的。您的平台越受欢迎,并且运行的生产工作负载越多,他们对您的 Ingress 层的需求就越多。完全有可能您最终会有一组无法通过单个 Ingress 控制器满足的需求。
Ingress 控制器部署注意事项
无论是哪种 Ingress 控制器,在部署和运行 Ingress 层时都需要考虑一些因素。这些考虑因素中的一些也可能对平台上运行的应用程序产生影响。
专用的 Ingress 节点
将一组节点专门用于运行 Ingress 控制器,并因此作为集群“边缘”的节点,是一种我们发现非常成功的模式。图 6-16 展示了这种部署模式。起初,使用专用的 Ingress 节点可能会显得浪费。然而,我们的理念是,如果您有能力运行专用的控制平面节点,那么您可能也有能力为集群上所有工作负载的关键路径层专用节点。为 Ingress 使用专用节点池带来了显著的好处。

图 6-16. 专用的 Ingress 节点专门为 Ingress 控制器保留。Ingress 节点充当集群的“边缘”或 Ingress 层。
主要好处是资源隔离。尽管 Kubernetes 支持配置资源请求和限制,但我们发现平台团队在设置这些参数时可能会遇到困难。特别是当平台团队在他们的 Kubernetes 之旅的开始阶段,并且不了解支持资源管理的实现细节时(例如,完全公平调度器、cgroups)。此外,在撰写本文时,Kubernetes 不支持网络 I/O 或文件描述符的资源隔离,这使得保证这些资源的公平共享变得具有挑战性。
在专用节点上运行 Ingress 控制器的另一个原因是合规性。我们发现许多组织拥有预设的防火墙规则和其他安全实践,这些规则可能与 Ingress 控制器不兼容。在这些环境中,专用的 Ingress 节点非常有用,因为通常更容易为集群节点的一个子集获取异常,而不是全部节点。
最后,在裸金属或本地安装中限制运行 Ingress 控制器的节点数量可能会有所帮助。在这种部署中,Ingress 层面由硬件负载均衡器作为前端。在大多数情况下,这些是传统的负载均衡器,缺乏 API,并且必须静态配置以将流量路由到特定的后端集合。少量的 Ingress 节点可以简化这些外部负载均衡器的配置和管理。
总体而言,将节点专用于 Ingress 可以帮助提高性能、合规性和管理外部负载均衡器。实施专用 Ingress 节点的最佳方法是对 Ingress 节点进行标签和污点设置。然后,将 Ingress 控制器部署为一个 DaemonSet,该 DaemonSet (1) 允许容忍该污点,并且 (2) 具有一个节点选择器,指定目标为 Ingress 节点。采用这种方法时,必须考虑 Ingress 节点的故障处理,因为 Ingress 控制器不会在未保留给 Ingress 的节点上运行。在理想情况下,故障节点会自动替换为可以继续处理 Ingress 流量的新节点。
绑定到主机网络
要优化 Ingress 流量路径,可以将 Ingress 控制器绑定到底层主机的网络上。通过这样做,传入请求会绕过 Kubernetes Service 体系结构,直接到达 Ingress 控制器。在启用主机网络时,请确保 Ingress 控制器的 DNS 策略设置为 ClusterFirstWithHostNet。以下代码片段显示了 Pod 模板中的主机网络和 DNS 策略设置:
spec:
containers:
- image: nginx
name: nginx
dnsPolicy: ClusterFirstWithHostNet
hostNetwork: true
尽管直接在主机网络上运行 Ingress 控制器可以增加性能,但必须记住,这样做会移除 Ingress 控制器与节点之间的网络命名空间边界。换句话说,Ingress 控制器可以完全访问主机上所有网络接口和可用的网络服务。这对于 Ingress 控制器的威胁模型有影响。换句话说,它降低了在数据平面代理漏洞案例中对敌对行动进行侧向移动的门槛。此外,绑定到主机网络是一个特权操作。因此,Ingress 控制器需要提升的特权或例外来作为特权工作负载运行。
即便如此,我们发现将 Ingress 控制器绑定到主机网络是值得权衡的,并且通常是暴露平台 Ingress 控制器的最佳方式。Ingress 流量直接到达控制器的网关,而不是经过 Service 栈(如 “Kubernetes Services” 中讨论的可能不太理想)。
Ingress 控制器和外部流量策略
如果未正确配置,使用 Kubernetes Service 来暴露 Ingress 控制器会影响 Ingress 数据平面的性能。
如果您回顾一下“Kubernetes Services”,一个服务的外部流量策略决定如何处理来自集群外部的流量。如果您正在使用 NodePort 或 LoadBalancer 服务来暴露 Ingress 控制器,请确保将外部流量策略设置为Local。
使用Local策略可以避免不必要的网络跳跃,因为外部流量到达本地 Ingress 控制器而不是跳到另一个节点。此外,Local策略不使用 SNAT,这意味着客户端 IP 地址对处理请求的应用程序是可见的。
跨失败域分布 Ingress 控制器
为确保 Ingress 控制器群的高可用性,使用 Pod 反亲和性规则将 Ingress 控制器分布在不同的故障域中。
DNS 及其在入口中的作用
正如我们在本章中讨论的那样,运行在平台上的应用程序共享 Ingress 数据平面,因此共享平台网络的单个入口点。随着请求的到来,Ingress 控制器的主要责任是消除流量的歧义并根据 Ingress 配置进行路由。
确定请求的目标的主要方式之一是通过目标主机名(在 HTTP 的情况下是Host头部,在 TCP 的情况下是 SNI),这使得 DNS 成为您的 Ingress 实现的一个重要组成部分。我们将讨论 DNS 和 Ingress 时可用的两种主要方法。
通配符 DNS 记录
我们持续采用的最成功模式之一是将一个域名分配给环境,并通过将子域名分配给不同的应用程序来切割它。我们有时称之为“基于子域的路由”。这种模式的实现涉及创建一个通配符 DNS 记录(例如,*.bearcanoe.com),将其解析为集群的 Ingress 层。通常情况下,这是一个位于 Ingress 控制器前面的负载均衡器。
使用通配符 DNS 记录为您的 Ingress 控制器带来了几个好处:
-
应用程序可以使用其子域名下的任何路径,包括根路径(
/)。开发者不必花费工程师的时间来让他们的应用程序在子路径上运行。在某些情况下,应用程序期望在根路径上托管,并且在其他情况下则无法正常工作。 -
DNS 的实现相对简单。不需要在 Kubernetes 和您的 DNS 提供商之间进行集成。
-
单个通配符 DNS 记录消除了使用每个应用程序的不同域名可能出现的 DNS 传播问题。
Kubernetes 和 DNS 整合
与使用通配符 DNS 记录的替代方法是将您的平台与 DNS 提供商集成。Kubernetes 社区维护了一个名为 external-dns 的控制器,提供了这种集成。如果您使用受支持的 DNS 提供商,请考虑使用此控制器自动创建域名。
正如您可能期望的那样,external-dns 不断协调您上游 DNS 提供程序中的 DNS 记录和在 Ingress 资源中定义的配置。换句话说,external-dns 根据 Ingress API 中发生的更改创建、更新和删除 DNS 记录。External-dns 需要两个信息来配置 DNS 记录,这两个信息都是 Ingress 资源的一部分:所需的主机名在 Ingress 规范中,目标 IP 地址在 Ingress 资源的状态字段中。
如果需要支持多个域名,将平台与您的 DNS 提供程序集成可能很有用。控制器负责根据需要自动创建 DNS 记录。然而,重要的是要记住以下权衡:
-
您必须将额外的组件(external-dns)部署到您的集群中。额外的插件会给您的部署带来更多复杂性,因为您需要操作、维护、监控、版本化和升级平台中的一个额外组件。
-
如果 external-dns 不支持您的 DNS 提供程序,您必须开发自己的控制器。构建和维护控制器需要工程努力,这些努力本应用于更高价值的工作。在这些情况下,最好简单实现一个通配符 DNS 记录。
处理 TLS 证书
Ingress 控制器需要证书及其相应的私钥以通过 TLS 提供应用程序服务。根据您的 Ingress 策略,管理证书可能会很麻烦。如果您的集群托管单个域名并实现基于子域的路由,则可以使用单个通配符 TLS 证书。然而,在某些情况下,集群托管跨多个域的应用程序,这使得有效管理证书变得具有挑战性。此外,您的安全团队可能不赞成使用通配符证书。无论如何,Kubernetes 社区已经围绕一个名为 cert-manager 的证书管理插件进行了集体努力。
cert-manager 是在您的集群中运行的控制器。它安装了一组 CRD,通过 Kubernetes API 实现对证书颁发机构(CA)和证书的声明式管理。更重要的是,它支持不同的证书发行者,包括基于 ACME 的 CA、HashiCorp Vault、Venafi 等。它还提供了一个扩展点来在必要时实现自定义发行者。
cert-manager 的证书铸造功能围绕着发行者和证书展开。cert-manager 有两种发行者自定义资源。发行者资源代表在特定 Kubernetes 命名空间中签署证书的 CA。如果要跨所有命名空间发行证书,可以使用 ClusterIssuer 资源。以下是一个使用存储在 Kubernetes 秘钥中的私钥 platform-ca-key-pair 的示例 ClusterIssuer 定义:
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: prod-ca-issuer
spec:
ca:
secretName: platform-ca-key-pair
cert-manager 的优点在于它与 Ingress API 集成,自动为 Ingress 资源签发证书。例如,给定以下 Ingress 对象,cert-manager 会自动创建适用于 TLS 的证书密钥对:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
annotations:
cert-manager.io/cluster-issuer: prod-ca-issuer 
name: bearcanoe-com
spec:
tls:
- hosts:
- bearcanoe.com
secretName: bearcanoe-cert-key-pair 
rules:
- host: bearcanoe.com
http:
paths:
- path: /
backend:
serviceName: nginx
servicePort: 80
cert-manager.io/cluster-issuer 注解告诉 cert-manager 使用 prod-ca-issuer 来签发证书。
Cert-manager 将证书和私钥存储在名为 bearcanoe-cert-key-pair 的 Kubernetes Secret 中。
在幕后,cert-manager 处理证书请求过程,包括生成私钥,创建证书签名请求(CSR),并将 CSR 提交给 CA。一旦签发者签发证书,cert-manager 将其存储在 bearcanoe-cert-key-pair 证书中。然后,Ingress 控制器可以获取证书并开始通过 TLS 提供应用程序。图 6-17 更详细地描述了该过程。

图 6-17. Cert-manager 监视 Ingress API,并在 Ingress 资源具有 cert-manager.io/cluster-issuer 注解时向证书颁发机构请求证书。
如您所见,cert-manager 简化了 Kubernetes 上的证书管理。我们遇到的大多数平台都以某种方式使用 cert-manager。如果您在平台上使用 cert-manager,请考虑使用诸如 Vault 等外部系统作为 CA。将 cert-manager 与外部系统集成,而不是使用由 Kubernetes Secret 支持的 CA,是一种更强大和安全的解决方案。
服务网格
随着行业继续采用容器和微服务,服务网格变得非常流行。虽然术语“服务网格”相对较新,但它所包含的概念并非如此。服务网格是服务路由、负载平衡和遥测的预先存在的理念的再次整合。在容器和 Kubernetes 兴起之前,超大规模的互联网公司实现了服务网格的前身,因为他们在微服务方面遇到了挑战。例如,Twitter 开发了 Finagle,一个 Scala 库,所有微服务都内嵌了该库。它处理负载平衡、熔断、自动重试、遥测等功能。Netflix 开发了 Hystrix,一个类似的 Java 库。
容器和 Kubernetes 改变了格局。服务网格不再像它们的前身那样是特定语言的库。今天,服务网格本身就是分布式系统。它们包含一个控制平面,该控制平面配置一组代理,这些代理实现了数据平面。路由、负载平衡、遥测和其他功能都内置于代理中,而不是应用程序中。向代理模型的转移使更多的应用程序能够利用这些功能,因为不需要对代码进行更改来参与网格。
服务网格提供了一系列广泛的功能,可以分为三大支柱:
路由和可靠性
高级流量路由和可靠性功能,例如流量转移、流量镜像、重试和断路器。
安全性
身份和访问控制功能,支持服务之间的安全通信,包括身份、证书管理和双向 TLS(mTLS)。
可观测性
自动收集来自网格中所有交互的指标和跟踪信息。
在本章的其余部分中,我们将更详细地讨论服务网格。然而,在这样做之前,让我们回到本书的中心主题,并问:“我们需要服务网格吗?”一些组织将服务网格视为实现前述功能的灵丹妙药,因此服务网格越来越受欢迎。然而,我们发现组织应仔细考虑采用服务网格的影响。
何时(不)使用服务网格
服务网格可以为应用平台及其上运行的应用程序提供巨大的价值。它提供了一组吸引力强大的功能,您的开发人员会很喜欢。与此同时,服务网格带来了大量复杂性,您必须处理这些复杂性。
Kubernetes 是一个复杂的分布式系统。到目前为止,在本书中,我们已经涉及了构建基于 Kubernetes 的应用平台所需的一些基本组件,还有很多章节尚未讲解。事实是,构建一个成功的基于 Kubernetes 的应用平台是一项艰巨的工作。在考虑使用服务网格时,请记住这一点。在您开始 Kubernetes 之旅时实施服务网格将会减慢您的步伐,甚至可能导致失败。
在实地工作中,我们亲眼见证了这些情况。我们与平台团队合作过,他们被服务网格的诱人功能所蒙蔽。诚然,这些功能将使他们的平台对开发人员更具吸引力,从而增加平台的采用率。然而,时间是重要的。在开始考虑服务网格之前,等到在生产环境中获得操作经验。
或许更重要的是你理解你的需求或者你试图解决的问题。把车放在马前面不仅会增加平台失败的几率,还会导致工程资源的浪费。这种错误的一个典型例子是一个组织在开发基于 Kubernetes 的平台时,还未投入生产就盲目采用了服务网格。“我们需要服务网格,因为它提供的所有功能我们都需要”,他们说。十二个月后,他们唯一使用的功能只有网格的 Ingress 能力。没有双向 TLS,没有复杂的路由,没有跟踪。只有 Ingress。为了使专用的 Ingress 控制器准备投入生产的工程资源远远少于全功能网格的实现。在将最小可行产品投入生产后,逐步增加功能也有其积极的一面。
在阅读完本节后,你可能会觉得我们认为应用平台中没有服务网格的位置。恰恰相反,如果有需求,服务网格可以解决大量问题,并且如果能够充分利用,它可以带来巨大的价值。最终,我们发现一个成功的服务网格实现取决于选择合适的时机和出于正确的动机。
服务网格接口(SMI)
Kubernetes 提供了各种可插拔组件的接口。这些接口包括容器运行时接口(CRI)、容器网络接口(CNI)等。正如本书所述,正是这些接口使 Kubernetes 成为一个可扩展的基础设施。服务网格正在逐步成为 Kubernetes 平台的重要组成部分。因此,服务网格社区合作建立了服务网格接口(SMI)。
与我们已经讨论过的其他接口类似,SMI 指定了 Kubernetes 与服务网格之间的交互。然而,SMI 与其他 Kubernetes 接口不同的是,它不是核心 Kubernetes 项目的一部分。相反,SMI 项目利用自定义资源定义(CRD)来指定接口。SMI 项目还包括用于实现接口的库,例如用于 Go 的 SMI SDK。
SMI 通过一组 CRD 覆盖了我们在前一节讨论的三大支柱。Traffic Split API 关注于跨多个服务的流量路由和分割。它支持基于百分比的流量分割,从而实现不同的部署场景,如蓝绿部署和 A/B 测试。以下片段展示了一个 TrafficSplit 的示例,用于执行“flights” Web 服务的金丝雀部署:
apiVersion: split.smi-spec.io/v1alpha3
kind: TrafficSplit
metadata:
name: flights-canary
namespace: bookings
spec:
service: flights 
backends: 
- service: flights-v1
weight: 70
- service: flights-v2
weight: 30
客户连接的顶层服务(即flights.bookings.cluster.svc.local)。
接收流量的后端服务。v1 版本接收 70% 的流量,而 v2 版本接收其余的流量。
流量访问控制和流量规格 API 一起实现安全功能,如访问控制。流量访问控制 API 提供 CRD 来控制网格中允许的服务交互。借助这些 CRD,开发人员可以指定访问控制策略,确定哪些服务可以在什么条件下进行通信(例如允许的 HTTP 方法列表)。流量规格 API 提供了描述流量的方法,包括用于 HTTP 流量的HTTPRouteGroup CRD 和用于 TCP 流量的TCPRoute。与流量访问控制 CRD 一起,这些在应用程序级别应用策略。
例如,以下 HTTPRouteGroup 和 TrafficTarget 允许来自 bookings 服务到 payments 服务的所有请求。HTTPRouteGroup 资源描述流量,而 TrafficTarget 指定了源和目标服务:
apiVersion: specs.smi-spec.io/v1alpha3
kind: HTTPRouteGroup
metadata:
name: payment-processing
namespace: payments
spec:
matches:
- name: everything 
pathRegex: ".*"
methods: ["*"]
---
apiVersion: access.smi-spec.io/v1alpha2
kind: TrafficTarget
metadata:
name: allow-bookings
namespace: payments
spec:
destination: 
kind: ServiceAccount
name: payments
namespace: payments
port: 8080
rules: 
- kind: HTTPRouteGroup
name: payment-processing
matches:
- everything
sources: 
- kind: ServiceAccount
name: flights
namespace: bookings
允许在这个 HTTPRouteGroup 中的所有请求。
目标服务。在本例中,使用payments命名空间中payments服务账户的 Pod。
控制源和目标服务之间流量的 HTTPRouteGroups。
源服务。在本例中,使用bookings命名空间中flights服务账户的 Pod。
最后,流量度量 API 提供了服务网格的遥测功能。这个 API 与其他 API 稍有不同,因为它定义的是输出而不是提供输入的机制。流量度量 API 定义了公开服务度量标准。需要这些度量标准的系统,如监控系统、自动缩放器、仪表板等,可以以标准化的方式使用它们。以下片段显示了一个展示两个 Pod 之间流量度量指标的示例 TrafficMetrics 资源:
apiVersion: metrics.smi-spec.io/v1alpha1
kind: TrafficMetrics
resource:
name: flights-19sk18sj11-a9od2
namespace: bookings
kind: Pod
edge:
direction: to
side: client
resource:
name: payments-ks8xoa999x-xkop0
namespace: payments
kind: Pod
timestamp: 2020-08-09T01:07:23Z
window: 30s
metrics:
- name: p99_response_latency
unit: seconds
value: 13m
- name: p90_response_latency
unit: seconds
value: 7m
- name: p50_response_latency
unit: seconds
value: 3m
- name: success_count
value: 100
- name: failure_count
value: 0
SMI 是 Kubernetes 社区中最新的接口之一。尽管仍在开发和迭代中,但它描绘了我们作为一个社区未来的图景。与 Kubernetes 中的其他接口一样,SMI 使平台构建者能够使用便携和与提供者无关的 API 来提供服务网格,进一步增加了 Kubernetes 的价值、灵活性和能力。
数据平面代理
服务网格的数据平面是连接服务的一组代理。Envoy 代理是云原生生态系统中最流行的服务代理之一。最初由 Lyft 开发,自 2016 年末开源以来,迅速成为云原生系统中的重要组成部分。
Envoy 用于 Ingress 控制器(Contour)、API 网关(Ambassador、Gloo)以及服务网格(Istio、OSM)。
Envoy 之所以成为优秀的构建块之一,原因之一在于其支持通过 gRPC/REST API 实现动态配置。早期的开源代理程序在 Kubernetes 这样动态环境下并未设计得如 Envoy 那样,它们使用静态配置文件,需要重启才能生效配置更改。相反,Envoy 提供了 xDS(discovery service)API,用于动态配置(详见 图 6-18)。此外,Envoy 还支持热重启功能,允许在重新初始化时不中断任何活动连接。

图 6-18. Envoy 通过 XDS API 支持动态配置。Envoy 连接到配置服务器并使用 LDS、RDS、EDS、CDS 和其他 xDS API 请求其配置。
Envoy 的 xDS 是一组 API 集合,包括 Listener Discovery Service (LDS)、Cluster Discovery Service (CDS)、Endpoints Discovery Service (EDS)、Route Discovery Service (RDS) 等。一个 Envoy configuration server 实现这些 API,并作为 Envoy 动态配置的源头。在启动期间,Envoy 连接到配置服务器(通常通过 gRPC)并订阅配置更改。随着环境变化,配置服务器会向 Envoy 流式传输变更。让我们更详细地审视 xDS API。
LDS API 配置了 Envoy 的 Listeners。Listeners 是代理的入口点,Envoy 可以打开多个 Clients 可以连接的 Listener。典型示例是监听端口 80 和 443 以处理 HTTP 和 HTTPS 流量。
每个 Listener 都有一组过滤器链,决定如何处理传入的流量。HTTP 连接管理器过滤器利用 RDS API 获取路由配置。路由配置告诉 Envoy 如何路由传入的 HTTP 请求,提供有关虚拟主机和请求匹配(基于路径、基于标头等)的详细信息。
路由配置中的每条路由引用了一个 Cluster。Cluster 是属于同一服务的一组 Endpoints。Envoy 使用 CDS 和 EDS API 发现 Cluster 和 Endpoints。有趣的是,EDS API 实际上没有 Endpoint 对象,而是使用 ClusterLoadAssignment 对象将 Endpoints 分配给 Clusters。
虽然深入探讨 xDS API 的细节值得一本专门的书籍,但我们希望前述概述能让您了解 Envoy 的工作原理及其功能。总结一下,监听器绑定到端口并接受来自客户端的连接。监听器具有过滤器链,决定如何处理传入连接。例如,HTTP 过滤器检查请求并将其映射到集群。每个集群有一个或多个端点,最终接收并处理流量。图 6-19 展示了这些概念的图形表示及其相互关系。

图 6-19. Envoy 配置,监听器绑定到端口 80. 监听器具有 HTTP 连接管理器过滤器,引用路由配置。路由配置匹配以/前缀开头的请求,并将其转发到my_service集群,该集群有三个端点。
Kubernetes 上的服务网格
在前面的部分,我们讨论了服务网格的数据平面如何为服务之间提供连接。我们还谈到了 Envoy 作为数据平面代理,以及它通过 xDS API 支持动态配置的方式。要在 Kubernetes 上构建服务网格,我们需要一个控制平面,根据集群内部的情况配置网格的数据平面。控制平面需要理解服务、端点、Pod 等概念。此外,它需要暴露 Kubernetes 自定义资源,供开发人员用来配置服务网格。
Kubernetes 中最流行的服务网格实现之一是 Istio。Istio 实现了基于 Envoy 的服务网格的控制平面。控制平面实现在一个名为 istiod 的组件中,它本身有三个主要子组件:Pilot、Citadel 和 Galley。Pilot 是一个 Envoy 配置服务器,它实现了 xDS API,并将配置流式传输到与应用程序并行运行的 Envoy 代理。Citadel 负责网格内的证书管理,它颁发证书用于建立服务身份和相互 TLS。最后,Galley 与 Kubernetes 等外部系统交互以获取配置。它抽象了底层平台并为其他 istiod 组件翻译配置。图 6-20 显示了 Istio 控制平面组件之间的交互。

图 6-20. Istio 控制平面交互。
Istio 在配置服务网格的数据平面之外还提供了其他功能。首先,Istio 包括一个变更接受的 Webhook,将 Envoy Sidecar 注入到 Pod 中。参与网格的每个 Pod 都有一个处理所有入站和出站连接的 Envoy Sidecar。变更接受的 Webhook 提升了平台上的开发体验,开发人员无需手动将 Sidecar 代理添加到所有应用程序部署清单中,平台会自动以选择加入和退出的模式注入 Sidecar。尽管如此,仅仅注入 Envoy 代理 Sidecar 到工作负载旁边并不意味着工作负载会自动开始通过 Envoy 发送流量。因此,Istio 使用一个 init-container 安装 iptables 规则,拦截 Pod 的网络流量并将其路由到 Envoy。以下摘录(为简洁起见进行了修剪)显示了 Istio init-container 的配置:
...
initContainers:
- args:
- istio-iptables
- --envoy-port 
- "15001"
- --inbound-capture-port 
- "15006"
- --proxy-uid
- "1337"
- --istio-inbound-interception-mode
- REDIRECT
- --istio-service-cidr 
- '*'
- --istio-inbound-ports 
- '*'
- --istio-local-exclude-ports
- 15090,15021,15020
image: docker.io/istio/proxyv2:1.6.7
imagePullPolicy: Always
name: istio-init
...
Istio 安装了一个 iptables 规则,捕获所有出站流量并将其发送到该端口的 Envoy。
Istio 安装了一个 iptables 规则,捕获所有入站流量并将其发送到该端口的 Envoy。
要重定向到 Envoy 的 CIDR 列表。在这种情况下,我们重定向所有 CIDR。
要重定向到 Envoy 的端口列表。在这种情况下,我们重定向所有端口。
现在我们已经讨论了 Istio 的架构,让我们来讨论一些通常使用的服务网格特性。我们在现场遇到的更常见的需求之一是服务间的身份验证和服务间流量的加密。这个功能由 SMI 中的流量访问控制 API 提供支持。Istio 和大多数服务网格实现都使用双向 TLS 来实现这一点。在 Istio 的情况下,默认情况下,所有参与网格的服务都启用了双向 TLS。工作负载将未加密的流量发送到 Sidecar 代理。Sidecar 代理升级连接到 mTLS 并将其发送到另一端的 Sidecar 代理。默认情况下,服务仍然可以从网格外的其他服务接收非 TLS 流量。如果您想强制所有交互使用 mTLS,Istio 支持 STRICT 模式,该模式配置网格中的所有服务只接受 TLS 加密的请求。例如,您可以在 istio-system 命名空间中的配置中全局启用严格的 mTLS:
apiVersion: "security.istio.io/v1beta1"
kind: "PeerAuthentication"
metadata:
name: "default"
namespace: "istio-system"
spec:
mtls:
mode: STRICT
流量管理是服务网格处理的另一个关键问题。流量管理在 SMI 的 Traffic Split API 中被捕获,尽管 Istio 的流量管理功能更为先进。除了流量分割或转移外,Istio 还支持故障注入、熔断、镜像和更多功能。当涉及到流量转移时,Istio 使用两个独立的自定义资源进行配置:VirtualService 和 DestinationRule。
-
VirtualService 资源在网格中创建服务并指定流量路由方式。它指定了服务的主机名以及控制请求目的地的规则。例如,VirtualService 可以将 90% 的流量发送到一个目的地,将其余的发送到另一个目的地。一旦 VirtualService 评估了规则并选择了目的地,它会将流量发送到 DestinationRule 的特定子集之一。
-
DestinationRule 资源列出了特定服务可用的“真实”后端。每个后端都在单独的子集中。每个子集可以有自己的路由配置,如负载均衡策略、双向 TLS 模式等。
举例来说,让我们考虑一个场景,我们想要逐步推出服务的第二个版本。我们可以使用以下 DestinationRule 和 VirtualService 来实现这一目标。DestinationRule 创建了两个服务子集:v1 和 v2。VirtualService 引用了这些子集。它将 90% 的流量发送到 v1 子集,将 10% 的流量发送到 v2 子集:
apiVersion: networking.istio.io/v1alpha3
kind: DestinationRule
metadata:
name: flights
spec:
host: flights
subsets:
- name: v1
labels:
version: v1
- name: v2
labels:
version: v2
---
apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
name: flights
spec:
hosts:
- flights
http:
- route:
- destination:
host: flights
subset: v1
weight: 90
- destination:
host: flights
subset: v2
weight: 10
服务可观测性是另一个常被追求的特性。由于网格中所有服务之间都有代理,因此推导服务级别的指标是直接的。开发人员可以获取这些指标,而无需为其应用程序进行工具化。这些指标以 Prometheus 格式公开,可供广泛的监控系统使用。以下是由 sidecar 代理捕获的示例指标(为简洁起见移除了一些标签)。该指标显示,来自航班预订服务到支付处理服务的成功请求数量为 7183:
istio_requests_total{
connection_security_policy="mutual_tls",
destination_service_name="payments",
destination_service_namespace="payments",
destination_version="v1",
request_protocol="http",
...
response_code="200",
source_app="bookings",
source_version="v1",
source_workload="bookings-v1",
source_workload_namespace="flights"
} 7183
总体而言,Istio 提供了所有在 SMI 中捕获的特性。但是,它尚未实现 SMI API(Istio v1.6)。SMI 社区维护了一个 适配器,您可以使用它将 SMI API 与 Istio 兼容。我们主要讨论 Istio,因为这是我们在现场最常遇到的服务网格。话虽如此,Kubernetes 生态系统中还有其他网格,包括 Linkerd、Consul Connect、Maesh 等。这些实现之间变化的一件事是数据平面架构,我们将在接下来讨论。
数据平面架构
服务网格是服务之间可以用来通信的高速公路。为了进入这条高速公路,服务使用作为匝道的代理。在数据平面方面,服务网格遵循旁路代理或节点代理两种架构模型之一。
旁路代理
在两种架构模型中,旁路代理是最常见的模型之一。正如我们在前一节中讨论的那样,Istio 使用这种模型来实现其通过 Envoy 代理的数据平面。Linkerd 也采用了这种方法。实质上,遵循此模式的服务网格在工作负载的 Pod 内部部署代理,与服务一起运行。一旦部署,旁路代理拦截进出服务的所有通信,如图 6-21 所示。

图 6-21. 参与网格的 Pod 具有拦截 Pod 网络流量的旁路代理。
与节点代理方法相比,旁路代理架构在数据平面升级时对服务的影响可能更大。升级涉及滚动所有服务的 Pod,因为没有办法在不重新创建 Pod 的情况下升级旁路代理。
节点代理
节点代理是一种备选的数据平面架构。与向每个服务注入旁路代理不同,服务网格由每个节点上运行的单个代理组成。每个节点代理处理其节点上运行的所有服务的流量,如图 6-22 所示。遵循此架构的服务网格包括 Consul Connect 和 Maesh。Linkerd 的第一个版本也使用了节点代理,但是在第二个版本中项目已经转向了旁路代理模型。
与旁路代理架构相比,节点代理方法对服务可能有更大的性能影响。因为代理由节点上所有服务共享,服务可能会遭受邻居干扰问题,代理也可能成为网络瓶颈。

图 6-22. 节点代理模型涉及处理节点上所有服务流量的单个服务网格代理。
采用服务网格
采用服务网格可能看起来像是一项艰巨的任务。你应该将其部署到现有集群吗?如何避免影响已经运行的工作负载?如何有选择地引入服务进行测试?
在本节中,我们将探讨在引入服务网格到应用平台时您应该考虑的不同因素。
优先考虑支柱之一
首先要做的事情之一是优先考虑服务网格的某一个支柱。这样做将使您能够从实施和测试的角度缩小范围。根据您的需求(如果您正在采用服务网格,那么您已经建立了需求,对吧?),例如,您可能会将双向 TLS 作为第一个支柱优先考虑。在这种情况下,您可以专注于部署支持此功能所需的 PKI。无需担心设置跟踪堆栈或花费开发周期测试流量路由和管理。
焦点放在其中一个支柱上使您能够了解网格在您的平台上的行为,并获得运营专业知识。一旦您感到舒适,可以根据需要实施额外的支柱。实际上,如果您采用分阶段部署而不是一次性实施,您将更加成功。
部署到新集群还是现有集群?
根据您的平台生命周期和拓扑结构,您可能可以选择将服务网格部署到新的、全新的集群,或者将其添加到现有集群中。在可能的情况下,最好选择新集群路线。这样可以消除对正在现有集群中运行的应用程序可能造成的任何潜在干扰。如果您的集群是短暂存在的,将服务网格部署到新集群应该是一个自然的选择。
在必须将服务网格引入现有集群的情况下,请务必在开发和测试层进行广泛测试。更重要的是,在上线到暂存和生产层之前,提供一个允许开发团队实验和测试其服务与网格配合使用的入门窗口。最后,提供一个机制,允许应用程序选择加入网格。启用选择加入机制的常见方式是提供一个 Pod 注释。例如,Istio 提供了一个注释(sidecar.istio.io/inject),该注释决定平台是否应该将 sidecar 代理注入工作负载中,如下片段中所示:
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx
spec:
template:
metadata:
annotations:
sidecar.istio.io/inject: "true"
spec:
containers:
- name: nginx
image: nginx
处理升级
在将服务网格作为平台的一部分提供时,您必须制定一个稳固的升级策略。请记住,服务网格数据平面处于连接您的服务的关键路径上,包括您集群的边缘(无论您使用的是网格的入口网关还是其他入口控制器)。当存在影响网格代理的 CVE 时会发生什么?您如何有效地处理升级?不要在不了解这些问题并没有建立良好升级策略的情况下采用服务网格。
升级策略必须考虑到控制平面和数据平面。控制平面升级的风险较小,因为网格的数据平面应该继续在没有控制平面的情况下运行。话虽如此,不要忽略控制平面升级。你应该理解控制平面与数据平面的版本兼容性。如果可能,按照 Istio 项目推荐的暗能升级模式进行操作。同时,请确保检查任何服务网格自定义资源定义(CRD)更改及其是否会影响你的服务。
由于平台上运行的代理数量较多,并且代理正在处理服务流量,因此数据平面升级更为复杂。当代理作为侧卡运行时,为了升级代理,必须重新创建整个 Pod,因为 Kubernetes 不支持容器就地升级。无论是进行完整的数据平面升级还是新数据平面代理的缓慢滚动升级,取决于升级背后的原因。如果你升级数据平面以处理代理中的漏洞,你必须重新创建参与网格的每个 Pod,以解决漏洞。正如你所想的,这可能对某些应用程序造成干扰。如果另一方面,你升级是为了利用新的功能或修复漏洞,可以让代理的新版本在 Pod 创建或移动到集群中时逐步推出。这种较慢的且不具破坏性的升级导致了代理的版本增长,只要服务网格支持,这可能是可以接受的。无论你是出于何种原因进行升级,总是要利用开发和测试层练习和验证服务网格升级。
另一个需要牢记的是,网格通常仅支持一系列 Kubernetes 版本。Kubernetes 升级如何影响你的服务网格?使用服务网格会妨碍你尽早升级 Kubernetes 吗?考虑到 Kubernetes API 相对稳定,这应该不会发生。然而,这是可能的,并且需要注意。
资源开销
使用服务网格的一个主要权衡因素是它所携带的资源开销,尤其是在侧卡架构下。正如我们讨论过的,服务网格会向集群中的每个 Pod 注入一个代理。为了完成工作,代理消耗了其他服务可用的资源(CPU 和内存)。在采用服务网格时,你必须理解这种开销,是否值得进行权衡。如果你在数据中心运行集群,那么这个开销可能是可接受的。然而,这种开销可能会阻止你在资源约束更为紧张的边缘部署中使用服务网格。
更重要的是,服务网格在服务之间引入了延迟,因为服务调用在源服务和目标服务上都经过代理。虽然服务网格中使用的代理通常性能很高,但理解它们引入的延迟开销以及您的应用程序能否在这种开销下运行是非常重要的。
在评估服务网格时,花些时间调查其资源开销是非常重要的。更好的方法是通过与您的服务一起运行性能测试,了解在负载下网格的行为。
用于互相 TLS 的证书颁发机构
服务网格的身份特性通常基于 X.509 证书。网格中的代理使用这些证书在服务之间建立互相 TLS(mTLS)连接。
在能够利用服务网格的 mTLS 功能之前,您必须建立证书管理策略。虽然网格通常负责签发服务证书,但确定证书颁发机构(CA)是由您决定的。在大多数情况下,服务网格使用自签名证书作为 CA。但是,成熟的服务网格允许您在必要时使用自己的 CA。
由于服务网格处理服务间的通信,使用自签名 CA 是足够的。CA 本质上是对应用程序及其客户端不可见的实现细节。尽管如此,安全团队可能不赞成使用自签名 CA。在采用服务网格时,请确保将安全团队引入讨论。
如果使用自签名 CA 来进行 mTLS 不可行,您将不得不提供一个 CA 证书和密钥,以供服务网格用于签发证书。或者,在集成可用时,您可以与 Vault 等外部 CA 进行集成。
多集群服务网格
一些服务网格提供多集群功能,您可以使用这些功能将网格扩展到多个 Kubernetes 集群中。这些功能的目标是通过一个对应用程序透明的安全通道连接运行在不同集群中的服务。多集群网格增加了平台的复杂性。它们可能会对性能和故障域有影响,开发人员可能需要了解这些影响。总之,虽然创建多集群网格可能看起来很有吸引力,但在成功在单个集群中运行服务网格之前,应该避免使用它们。
概要
在构建基于 Kubernetes 的应用程序平台时,服务路由是一个关键问题。服务提供了第三/四层的路由和负载均衡功能给应用程序。它们使应用程序能够与集群中的其他服务通信,而无需担心 Pod IP 的变化或者集群节点的故障。此外,开发人员可以使用 NodePort 和 LoadBalancer 服务将其应用程序暴露给集群外的客户端。
Ingress 构建在服务之上,以提供更丰富的路由能力。开发人员可以使用 Ingress API 根据应用级别的关注点来路由流量,例如请求的 Host 头或客户端尝试访问的路径。在使用 Ingress 资源之前,您必须部署 Ingress 控制器来满足 Ingress API 的需求。一旦安装完成,Ingress 控制器将处理传入的请求,并根据 API 中定义的 Ingress 配置来路由这些请求。
如果您拥有基于微服务的大型应用组合,您的开发人员可能会从服务网格的能力中受益。在使用服务网格时,服务通过增强交互的代理彼此通信。服务网格可以提供多种功能,包括流量管理、双向 TLS、访问控制、自动服务指标收集等。与 Kubernetes 生态系统中的其他接口一样,服务网格接口(SMI)旨在使平台运营商能够使用服务网格而不将自己限制在特定的实现上。然而,在采用服务网格之前,请确保您的团队具备操作经验,以管理 Kubernetes 之上的额外分布式系统。
第七章:机密管理
在任何应用程序堆栈中,我们几乎肯定会遇到机密数据。这是应用程序希望保持秘密的数据。通常,我们将机密与凭据关联起来。通常这些凭据用于访问集群内或外的系统,如数据库或消息队列。当使用私钥时,我们也会遇到机密数据,这些私钥可能支持我们的应用程序与其他应用程序进行双向 TLS 通信。这些类型的问题在第十一章中有详细讨论。机密的存在带来了许多需要考虑的操作问题,例如:
机密轮换策略
机密可以保留多长时间之前必须更改?
密钥(加密)轮换策略
假设机密数据在持久化到磁盘之前在应用层进行了加密,那么加密密钥可以在多长时间内保留不变?
机密存储策略
存储机密数据需要满足哪些要求?您是否需要将机密持久化到隔离的硬件中?您的机密管理解决方案是否需要与硬件安全模块(HSM)集成?
补救计划
如果机密或加密密钥泄露,您计划如何进行补救?您的计划或自动化是否可以在不影响应用程序的情况下运行?
一个很好的起点是确定为应用程序提供机密管理的哪一层。一些组织选择不在平台级别解决此问题,而是期望应用团队动态地将机密注入其应用程序中。例如,如果一个组织正在运行像 Vault 这样的机密管理系统,应用程序可以直接与 API 通信以进行身份验证和检索机密。应用程序框架甚至可以提供库直接与这些系统通信。例如,Spring 提供了 spring-vault 项目来对接 Vault,检索机密并直接将其值注入 Java 类中。尽管可以在应用程序层面完成此操作,但许多平台团队希望提供企业级别的机密能力作为平台服务,使应用程序开发人员无需关心机密是如何到达那里或者在幕后使用了哪个外部提供者(例如 Vault)。
在本章中,我们将深入探讨如何在 Kubernetes 中处理机密数据。我们将从 Kubernetes 运行的较低级别层开始,逐步向上工作到 Kubernetes 公开的 API,使机密数据可供工作负载使用。像本书中的许多主题一样,您会发现这些考虑和建议存在一个光谱上——一个端点包括您愿意相对于工程投入和风险承受能力有多安全,另一个端点侧重于您希望为使用此平台的开发人员提供什么级别的抽象。
深度防御
保护我们机密数据的关键在于我们愿意采取多深的措施来确保其安全。尽管我们很想说我们总是选择最安全的选项,但现实是我们做出合理的决策,保持“足够安全”,并在时间上加以加固。这伴随着风险和技术债务,这是我们工作的本质。然而,不可否认的是对“足够安全”判断失误可能很快让我们声名大噪,但不是以好的方式。在接下来的章节中,我们将深入探讨这些安全层次,并指出一些最关键的要点。
防御可以从物理层面开始。一个典型例子是谷歌。它有多篇白皮书,甚至还有一段YouTube视频介绍其数据中心安全方法。这包括金属探测器、能够阻止半挂卡车的车辆屏障,以及多层楼宇安全措施以进入数据中心。这种注意细节的扩展超出了现场设备的范围。当驱动器退役时,谷歌授权员工将数据清零,然后可能粉碎和撕碎驱动器。虽然物理安全的主题很有趣,但本书不会深入讨论数据中心物理安全问题,而是云服务提供商为确保其硬件安全所采取的步骤令人惊叹,而这只是个开始。
假设人类在磁盘被清零或压碎之前某种方式获取了访问权限。大多数云服务提供商和数据中心通过确保磁盘在静止状态下进行加密来保护其物理磁盘。提供商可能使用他们自己的加密密钥和/或允许客户提供自己的密钥,这使得提供商几乎无法访问您的未加密数据。这是深度防御的一个完美例子。我们从物理层面、数据中心内部和扩展到对物理磁盘上数据加密,关闭了内部恶意行为者进一步处理用户数据的机会。
磁盘加密
让我们更仔细地看看磁盘加密领域。有多种加密磁盘的方法。在 Linux 中,用于全块加密的常见方法是利用 Linux Unified Key System (LUKS)。LUKS 与 dm-crypt 加密子系统配合工作,在 Linux 内核 2.6 版本以来就可用。对于像 vSAN、ceph 或 Gluster 等专用存储系统,每种都提供一种或多种加密静止状态的方式。在云提供商中,默认的加密行为可能会有所不同。例如,对于 AWS,您应该查阅其文档以启用弹性块存储的加密。AWS 提供了启用默认加密的能力,这是我们推荐的最佳实践设置。而 Google Cloud 则以加密静止状态作为其默认模式。与 AWS 类似,它可以配置密钥管理服务 (KMS),从而使您能够自定义加密行为,例如提供自己的加密密钥。
不论你对云服务提供商或数据中心运营商的信任程度如何,我们强烈建议将数据加密设为默认做法。加密数据在静止状态下意味着数据是存储加密的。这不仅有助于减少攻击向量,还能提供一定的保护,防止可能的错误。例如,虚拟机技术使得创建主机快照变得非常简单。快照最终成为像任何其他文件一样的数据,太容易意外暴露于内部或外部网络。为了多层防御的精神,我们应该保护自己免受这种情况的影响,即使通过用户界面或 API 字段选择了错误按钮,泄漏的数据对于没有私钥访问权限的人是毫无用处的。图 7-1 显示了如何轻松切换这些权限的用户界面。

图 7-1. AWS 快照上的权限设置,警告称“公开”将允许其他人从此快照创建卷,并有可能访问数据。
传输安全
对于加密的静止数据有了更好的理解,那么处于活动状态中的数据呢?尽管尚未探索 Kubernetes 密钥管理架构,让我们来看看在服务之间传输时,密钥可能经过的路径。图 7-2 展示了一些密钥在网络主机之间移动的交互点。

图 7-2. 展示密钥可能通过网络传输的交点的图示。
图表显示了秘密数据通过网络传输到不同主机的情况。无论我们的静态加密策略有多强大,如果任何交互点没有通过 TLS 进行通信,我们就暴露了我们的秘密数据。正如所示 图 7-2,这包括人与系统的交互,kubectl,以及系统与系统之间的交互,kubelet 到 API 服务器。总之,与 API 服务器和 etcd 的通信必须专门通过 TLS 进行。我们不会花太多时间讨论这个需求,因为几乎每种 Kubernetes 集群的安装或引导模式都是默认的。通常,您可能唯一希望执行的配置就是提供一个证书颁发机构(CA)来生成证书。但请记住,这些证书是 Kubernetes 系统组件内部使用的。有了这个理解,您可能不需要覆盖 Kubernetes 将生成的默认 CA。
应用加密
应用加密是在我们的系统组件或运行在 Kubernetes 中的工作负载内执行的加密。应用加密本身可以有多个层级。例如,Kubernetes 中的工作负载可以在将数据持久化到 Kubernetes 之前加密数据,然后 Kubernetes 可能再次加密数据,并将其持久化到 etcd,在那里它将在文件系统级别加密。前两个加密点被认为是“应用级别”的。这是很多层加密啊!
尽管我们并不总是会在这么多层次上进行加密或解密,但至少在应用级别对数据进行加密是有意义的。考虑到我们迄今为止讨论的内容:TLS 上的加密和静态数据的加密。如果我们止步于此,我们已经有了一个不错的开始。当机密数据在传输时,它将被加密,并且在物理磁盘上也将被加密。但在运行系统时怎么办呢?尽管存储到磁盘的比特可能已经加密,如果用户能够访问系统,他们可能能够读取这些数据!想象一下你的加密桌面电脑,你可能在一个 dotfile 中保存了敏感凭据(我们都这样做过)。如果我偷走了你的电脑并尝试通过“滑雪”磁盘来访问这些数据,我将无法获取我想要的信息。然而,如果我成功地启动你的计算机并“以你的身份”登录,我现在可以完全访问这些数据。
应用程序加密是在用户空间级别使用密钥加密数据的行为。在这个计算机示例中,我可以使用一个(强大的)密码保护的 gpg 密钥来加密那个 dotfile,需要我的用户在可以使用它之前对其进行解密。编写一个简单的脚本可以自动化这个过程,你可以用更深的安全模型来做。作为登录为您的攻击者,即使解密密钥是无用的,因为没有密码它只是无用的位。这个考虑对 Kubernetes 也是适用的。我们将假定在您的集群中设置了两个事物:
-
在 Kubernetes 使用的文件系统和/或存储系统中启用了静态加密。
-
为所有 Kubernetes 组件和 etcd 启用了 TLS。
有了这个,让我们开始探讨 Kubernetes 应用层的加密。
Kubernetes Secret API
Kubernetes Secret API 是 Kubernetes 中最常用的 API 之一。虽然我们可以以多种方式填充 Secret 对象,但 API 为工作负载提供了一种一致的方式与秘密数据交互。秘密对象与 ConfigMaps 非常相似。它们在工作负载如何消耗对象的机制上也有类似的机制,通过环境变量或卷数据。考虑以下 Secret 对象:
apiVersion: v1
kind: Secret
metadata:
name: mysecret
type: Opaque
data:
dbuser: aGVwdGlvCg==
dbkey: YmVhcmNhbm9lCg==
在data字段中,dbuser和dbkey是 base64 编码的。所有 Kubernetes 秘密数据都是。如果您希望向 API 服务器提交非编码字符串数据,可以使用以下stringData字段:
apiVersion: v1
kind: Secret
metadata:
name: mysecret
type: Opaque
stringData:
dbuser: heptio
dbkey: bearcanoe
在应用时,stringData将在 API 服务器上编码并应用于 etcd。一个常见的误解是,Kubernetes 将此数据编码为安全实践。事实并非如此。秘密数据可以包含各种奇怪的字符或二进制数据。为了确保它被正确存储,它被 base64 编码。默认情况下,确保秘密不被泄露的关键机制是 RBAC。理解 RBAC 动词在秘密中的意义对于防止引入攻击向量至关重要。
get
通过名称检索已知秘密的数据。
list
获取所有秘密和/或秘密数据的列表。
watch
查看任何秘密更改和/或更改为秘密数据。
正如你可以想象的那样,小的 RBAC 错误,比如给用户列出访问权限,会暴露命名空间中的每一个秘密,或者更糟糕的是整个集群如果意外使用了 ClusterRoleBinding。事实上,在许多情况下,用户根本不需要这些权限。这是因为用户的 RBAC 并不决定工作负载可以访问哪些秘密。通常,kubelet 负责使秘密对容器中的 Pod 可用。总之,只要您的 Pod 引用了有效的秘密,kubelet 将通过您指定的方式使其可用。有几种选项可以在工作负载中公开秘密,下面将介绍。
秘密消费模型
对于希望使用秘密的工作负载,有几种选择。如何摄取秘密数据的偏好可能取决于应用程序。但是,选择的方法存在一些权衡。在接下来的几节中,我们将看一下在工作负载中消费秘密数据的三种方法。
环境变量
可以将秘密数据注入到环境变量中。在工作负载的 YAML 中,可以指定任意键和对秘密的引用。对于已预期通过减少应用程序代码的需求而在 Kubernetes 上移动的工作负载,这可能是一个不错的功能。考虑以下 Pod 示例:
apiVersion: v1
kind: Pod
metadata:
name: nginx
spec:
containers:
- name: nginx
image: nginx
env:
- name: USER 
valueFrom:
secretKeyRef:
name: mysecret 
key: dbuser 
- name: PASS
valueFrom:
secretKeyRef:
name: mysecret
key: dbkey
在应用程序中可用的环境变量键。
Kubernetes 中秘密对象的名称。
应该注入到USER变量中的秘密对象中的键。
在环境变量中暴露秘密的缺点在于它们无法进行热重新加载。在秘密对象更改后,直到重新创建 Pod,更改才会生效。这可能通过手动干预或系统事件(如需要重新调度)发生。此外,有人认为环境变量中的秘密比从卷挂载中读取的方式不安全。这一点可能会有争议,但公平地指出一些常见的泄露机会是值得的。即在检查进程或容器运行时时,可能会以明文方式看到环境变量。此外,一些框架、库或语言可能支持调试或崩溃模式,其中它们会将环境变量输出到日志中。在使用环境变量之前,应考虑这些风险。
卷
或者,秘密对象可以通过卷注入。在工作负载的 YAML 中,配置了一个卷,其中引用了秘密。应将秘密注入到的容器使用volumeMount引用该卷:
apiVersion: v1
kind: Pod
metadata:
name: nginx
spec:
containers:
- name: nginx
image: nginx
volumeMounts:
- name: creds 
readOnly: true
mountPath: "/etc/credentials" 
volumes: 
- name: creds
secret:
secretName: mysecret
可挂载的 Pod 级别卷。指定的名称必须在挂载中引用。
要挂载到容器文件系统中的卷对象。
挂载在容器文件系统中的位置。
使用此 Pod 清单,秘密数据在/etc/credentials下可用,秘密对象中的每个键/值对都有自己的文件:
root@nginx:/# cat /etc/credentials/db
dbkey dbuser
通过卷的方法最大的好处是可以动态更新秘密,而无需重启 Pod。当看到秘密发生变化时,kubelet 将重新加载秘密,并且在容器的文件系统中显示为已更新。需要强调的是,kubelet 在 Linux 上使用 tmpfs 确保秘密数据仅存储在内存中。我们可以通过查看 Linux 主机上的挂载表文件来验证这一点:
# grep 'secret/creds' secret/creds
tmpfs
/var/lib/kubelet/pods/
e98df9fe-a970-416b-9ddf-bcaff15dff87/volumes/
kubernetes.io~secret/creds tmpfs rw,relatime 0 0
如果从此主机上移除 nginx Pod,则此挂载将被丢弃。在考虑这种模型时,特别需要注意的是,秘密数据永远不应该很大。理想情况下,它仅包含凭据或密钥,永远不应用作伪数据库。
从应用程序的角度来看,只需在目录或文件上进行简单的监控,然后将值重新注入应用程序即可处理秘密变更。无需理解或与 Kubernetes API 服务器通信。我们在许多工作负载中看到了这种模式的成功应用。
客户端 API 消费
最后的消费模型,客户端 API 消费,并不是核心 Kubernetes 功能。这种模型要求应用程序与 kube-apiserver 通信以检索 Secret(s) 并将其注入应用程序。有许多框架和库可以使应用程序轻松与 Kubernetes 通信。例如,对于 Java,Spring 的 Spring Cloud Kubernetes 将此功能引入到了 Spring 应用程序中。它将常用的 Spring PropertySource 类型连接到 Kubernetes,以检索 Secret 和/或 ConfigMap。
现在我们已经介绍了在工作负载级别消费秘密的方式,是时候谈谈存储秘密数据了。
etcd 中的秘密数据
像大多数 Kubernetes 对象一样,Secrets 存储在 etcd 中。默认情况下,在将 Secrets 持久化到 etcd 之前,Kubernetes 层不进行任何加密。图 7-3 展示了从清单到 etcd 的秘密流程。

图 7-3. Kubernetes 中默认的秘密数据流程(有时 colocated etcd 运行在单独的主机上)。
虽然 Kubernetes 并不加密秘密数据,但这并不意味着在获得硬件访问权限后可以访问数据。请记住,可以通过诸如 Linux 统一密钥设置(LUKS)之类的方法对磁盘上的数据进行加密,这样即使物理访问硬件,也只能访问到加密数据。对于许多云提供商和企业数据中心来说,这是默认的操作模式。然而,如果我们获得了对运行 etcd 的服务器的ssh访问权限,并且有特权或者可以升级权限来查看其文件系统,则我们可能会获取到秘密数据。
对于某些情况,这种默认模型可能是可接受的。etcd 可以在 Kubernetes API 服务器外部运行,确保至少由一个分区隔离。在这种模型中,攻击者可能需要获取 etcd 节点的 root 访问权限,找到数据位置,然后从 etcd 数据库中读取秘密。另一个入口点将是对手获取 API 服务器的 root 访问权限,找到 API 服务器和 etcd 证书,然后冒充 API 服务器与 etcd 通信以读取秘密。这两种情况都假设可能存在其他漏洞。例如,攻击者必须获取正在运行控制平面组件的内部网络或子网的访问权限。此外,他们需要获取适当的密钥以便ssh进入节点。坦率地说,在这种情况之前,RBAC 错误或应用程序的妥协将暴露秘密更有可能。
为了更好地理解威胁模型,让我们通过一个示例来说明攻击者如何获取秘密的过程。假设攻击者通过 SSH 访问并获得了 kube-apiserver 节点的 root 访问权限。攻击者可以设置以下脚本:
#!/bin/bash
# Change this based on location of etcd nodes
ENDPOINTS='192.168.3.43:2379'
ETCDCTL_API=3 etcdctl \
--endpoints=${ENDPOINTS} \
--cacert="/etc/kubernetes/pki/etcd/ca.crt" \
--cert="/etc/kubernetes/pki/apiserver-etcd-client.crt" \
--key="/etc/kubernetes/pki/apiserver-etcd-client.key" \
${@}
在此片段中看到的证书和密钥位置是 Kubernetes 通过 kubeadm 引导时的默认设置,这也是许多工具如 cluster-api 所使用的。etcd 将秘密数据存储在目录/registry/secrets/\({NAMESPACE}/\){SECRET_NAME}中。使用此脚本获取名为login1的秘密将如下所示:
# ./etcctl-script get /registry/secrets/default/login1
/registry/secrets/default/login1
k8s
v1Secret
login1default"*$6c991b48-036c-48f8-8be3-58175913915c2bB
0kubectl.kubernetes.io/last-applied-configuration{"apiVersion":"v1","data":
{"dbkey":"YmVhcmNhbm9lCg==","dbuser":"aGVwdGlvCg=="},"kind":"Secret",
"metadata":{"annotations":{},"name":"login1","namespace":"default"},
"type":"Opaque"}
z
dbkey
bearcanoe
dbuserheptio
Opaque"
通过这样,我们成功地泄露了秘密login1。
即使存储未加密的秘密可能被接受,许多平台运营商选择不止步于此。Kubernetes 支持几种在 etcd 内部加密数据的方式,从而进一步加深了您在秘密保护方面的防御深度。这些方式包括支持在静止状态下加密(即在 Kubernetes 层进行加密)之前在 etcd 中存储秘密的模型。这些模型包括静态密钥加密和信封加密。
静态密钥加密
Kubernetes API 服务器支持在静止状态下加密秘密。这通过向 Kubernetes API 服务器提供加密密钥来实现,它将用于在将所有秘密对象持久化到 etcd 之前对其进行加密。图 7-4 展示了在使用静态密钥加密时秘密的流动过程。

图 7-4. 在 API 服务器上使用的加密密钥与在将秘密存储到 etcd 之前对其进行加密之间的关系。
放置在EncryptionConfiguration中的密钥用于在秘密对象通过 API 服务器时进行加密和解密。如果攻击者访问了 etcd,他们将看到其中的加密数据,这意味着秘密数据并未泄露。可以使用各种提供者(包括 secretbox、aescbc 和 aesgcm)创建密钥。
每个提供商都有自己的权衡,我们建议与您的安全团队合作选择合适的选项。Kubernetes 问题#81127 对于这些提供商的一些考虑是一个很好的阅读资料。如果您的企业需要符合 Federal Information Processing Standards(FIPS)等标准,应仔细考虑这些选择。在我们的示例中,我们将使用 secretbox,它作为一种性能良好且安全的加密提供程序。
要设置静态密钥加密,我们必须生成一个 32 字节的密钥。我们的加密和解密模型是对称的,因此一个单独的密钥可以同时用于两个目的。如何生成密钥可能会因企业而异。如果我们对其熵满意,可以在 Linux 主机上轻松使用/dev/urandom:
head -c 32 /dev/urandom | base64
使用这个密钥数据,应该在运行 kube-apiserver 的所有节点上添加 EncryptionConfiguration。如果使用 Cluster API,应使用配置管理工具如 ansible 或 KubeadmConfigSpec 添加此静态文件。这确保了密钥可以添加、删除和轮转。以下示例假定配置存储在/etc/kubernetes/pki/secrets/encryption-config.yaml中:
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
- resources:
- secrets
providers:
- secretbox:
keys:
- name: secret-key-1
secret: u7mcOcHKbFh9eVluB18hbFIsVfwpvgbXv650QacDYXA==
# identity is a required (default) provider
- identity: {}
提供者列表是有序的,这意味着加密将始终使用第一个密钥,解密将按密钥列表的顺序尝试。身份是默认的明文提供者,应该放在最后。如果它是第一个,密码将不会加密。
要遵循上述配置,必须更新 kube-apiserver 的每个实例以在本地加载 EncryptionConfiguration。在/etc/kubernetes/manifests/kube-apiserver.yaml中,可以添加参数如下。
--encryption-provider-config=/etc/kubernetes/pki/secrets/encryption-config.yaml
一旦 kube-apiserver(s)重新启动,此更改将生效,并且在发送到 etcd 之前将加密密码。kube-apiserver 的重启可能是自动的。例如,当使用静态 Pod 来运行 API 服务器时,对清单文件的更改将触发重新启动。一旦您经过实验阶段,建议预先使用此文件预配置主机,并确保默认情况下启用encryption-provider。可以使用配置管理工具如 Ansible 或者使用 cluster-api,在 kubeadmConfigSpec 中设置该静态文件来添加 EncryptionConfiguration 文件。请注意,这种 cluster-api 方法将 EncryptionConfiguration 放在用户数据中;确保用户数据已加密!通过向 ClusterConfiguration 中的 apiServer 添加参数,可以通过向 API 服务器添加encryption-provider-config标志来完成。假设您正在使用 kubeadm,否则,请根据启动服务器的机制确保标志存在。
要验证加密,您可以将一个新的 secret 对象应用到 API 服务器上。假设秘密的名称是login2,使用上一节的脚本,我们可以如下检索它:
# ./etcctl-script get /registry/secrets/default/login2
/registry/secrets/default/login2
k8s:enc:secretbox:v1:secret-key-1:^Dʜ
HN,lU/:L kdR<_h (fO$V
y.
r/m
MٜjVĄGP<%B0kZHY}->q|&c?a\i#xoZsVXd+8_rCצgcjMv<X5N):MQ'7t
'pLBxqݡ)b݉/+r49ޓ`f
6(iciQⰪſ$'.ejbprλ=Cp+R-D%q!r/pbv1_.izyPlQ)1!7@X\0
EiĿr(dwlS
这里我们可以看到数据完全加密在 etcd 中。请注意,有元数据指定了使用的提供者(secretbox)和密钥(secret-key-1)进行加密。这对 Kubernetes 很重要,因为它同时支持多个提供者和密钥。任何在设置加密密钥之前创建的对象,假设login1,可以查询并仍然以明文显示:
# ./etcctl-script get /registry/secrets/default/login1
/registry/secrets/default/login1
k8s
这演示了两个重要概念。其一,login1 未 加密。虽然加密密钥已就位,但只有新创建或修改的秘密对象将使用此密钥进行加密。其二,当通过 kube-apiserver 返回时,不存在提供者/密钥映射,也不会尝试解密。后一概念很重要,因为强烈建议您在定义的时间内轮换加密密钥。假设您每三个月轮换一次。当三个月过去后,我们将如下更改 EncryptionConfiguation:
- secretbox:
keys:
- name: secret-key-2
secret: xgI5XTIRQHN/C6mlS43MuAWTSzuwkGSvIDmEcw6DDl8=
- name: secret-key-1
secret: u7mcOcHKbFh9eVluB18hbFIsVfwpvgbXv650QacDYXA=
关键在于不要删除secret-key-1。虽然它将不会用于新的加密,但它用于以前由它加密的现有秘密对象的解密!删除此密钥将阻止 API 服务器向客户端返回秘密对象,例如login2。由于此密钥是第一个,它将用于所有新的加密。更新秘密对象时,它们将随时间重新使用此新密钥进行重新加密。在此之前,原始密钥可以作为后备解密选项保留在列表中。如果删除该密钥,您将从客户端看到以下响应:
Error from server (InternalError): Internal error occurred: unable to transform
key "/registry/secrets/default/login1": no matching key was found for the
provided Secretbox transformer
信封加密
Kubernetes 1.10 及更高版本支持与 KMS 集成以实现信封加密。信封加密涉及两个密钥:密钥加密密钥(KEK)和数据加密密钥(DEK)。KEK 存储在 KMS 中并且只有在 KMS 提供者被 ompromise 的情况下才会有风险。KEK 用于加密 DEK,DEK 负责加密 Secret 对象。每个 Secret 对象都有其自己独特的 DEK 用于加密和解密数据。由于 DEK 由 KEK 加密,它们可以与数据本身一起存储,从而使 kube-apiserver 无需了解许多密钥。从架构上讲,信封加密的流程将看起来像[图 7-5 中显示的图表。

图 7-5. 使用信封加密加密秘密的流程。KMS 层位于集群外。
根据 KMS 提供者的不同,此流程的工作方式可能会有所不同,但通常这演示了信封加密的功能。这种模型有多个好处:
-
KMS 外部到 Kubernetes,通过隔离增强安全性。
-
KEK 的集中化使密钥轻松旋转。
-
DEK 和 KEK 的分离意味着秘密数据永远不会被发送到或被 KMS 知道
-
KMS 仅关注解密 DEK。
-
DEK 的加密意味着它们很容易与其密钥一起存储,使得与其密钥相关的管理变得容易。
提供者插件通过在运行 kube-apiserver 的主节点上运行实现 gRPC 服务器的特权容器来工作,该容器可以与远程 KMS 通信。然后,类似于在上一节设置加密时,必须向主节点添加一个 EncryptionConfiguration,其中包含与 KMS 插件通信的设置:
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
- resources:
- secrets
providers:
- kms:
name: myKmsPlugin
endpoint: unix:///tmp/socketfile.sock
cachesize: 100
timeout: 3s
# required, but not used for encryption
- identity: {}
假设 EncryptionConfiguration 保存在每个主节点的/etc/kubernetes/pki/secrets/encryption-config.yaml,则必须更新 kube-apiserver 的参数以包含以下内容:
--encryption-provider-config=/etc/kubernetes/pki/secrets/encryption-config.yaml
更改该值应重新启动 kube-apiserver。如果没有重新启动,则需要重新启动才能生效。
从设计角度来看,这是一个可行的模型。然而,KMS 插件的实现非常稀缺,现有的实现也比较不成熟。在我们撰写本书时,以下数据点是真实的。对于 aws-encryption-provider(AWS)或 k8s-cloudkms-plugin(Google),都没有标记的发布版本。Azure 的插件 kubernetes-kms 存在显著的限制,比如不支持密钥轮换。因此,除了在如 GKE 这样的托管服务中运行,其中 KMS 插件是自动可用且受 Google 支持的情况下,使用可能会不稳定。最后,唯一的跨云提供商通用的 KMS 插件是 kubernetes-vault-kms-plugin,但它只部分实现,并且已经归档(放弃)。
外部提供者
Kubernetes 并不是我们认为的企业级秘密存储系统。虽然它确实提供了 Secret API,可用于诸如服务账户之类的东西,但对于企业秘密数据,它可能表现不佳。使用它来存储应用程序秘密并没有什么本质上的问题,只要理解风险和选项,这正是本章节迄今为止主要描述的内容!然而,许多客户需要比 Secret API 能提供的更多,特别是那些在金融服务等行业工作的用户。这些用户需要诸如与硬件安全模块(HSM)集成和高级密钥轮换策略之类的能力。
我们的建议通常是从 Kubernetes 提供的功能开始,并查看是否适用于加固其安全性(即加密)的方法。正如前一节所述,提供信封加密的 KMS 加密模型在 etcd 中保护秘密数据的安全性方面具有相当强大的功能。如果需要进一步扩展(我们通常会这样做),我们会寻找工程团队已有操作知识的秘密管理工具。在生产环境中运行秘密管理系统可能是一项具有挑战性的任务,类似于运行任何需要不仅高可用性而且需要保护免受潜在攻击者侵害的有状态服务。
金库
Vault 是 HashiCorp 推出的开源项目。在我们的客户中,当涉及到秘密管理解决方案时,它迄今为止是最受欢迎的项目。Vault 在云原生领域找到了几种集成方式。已经在提供类似 Spring 和 Kubernetes 本身的框架中进行了工作,提供了一流的集成。一个新兴的模式是在 Kubernetes 中运行 Vault,并允许 Vault 使用 TokenReview API 对抗 Kubernetes API Server 进行身份验证请求。接下来,我们将探讨两个常见的 Kubernetes 集成点,包括 sidecar 和 initContainer 注入以及一个更新的方法,CSI 集成。
Cyberark
Cyberark 是另一个我们与客户见到的流行选择。作为一家公司,它已经存在了一段时间,并且我们经常发现已有的投资和希望将 Kubernetes 与其集成的愿望。Cyberark 提供凭据提供程序和动态访问提供程序(DAP)。DAP 提供多种企业机制,Kubernetes 管理员可能希望与之集成。与 Vault 类似,它支持与应用程序一起使用 initContainers 与 DAP 进行通信的能力。
注入集成
一旦外部密钥存储对 Kubernetes 中的工作负载可用,有几种检索选项。本节介绍了这些方法、我们的建议以及权衡。我们将介绍每种设计方法来使用秘密并描述 Vault 的实现。
当可能时,此方法运行一个 initContainer 和/或 sidecar 容器与外部秘密存储通信。通常,秘密被注入到 Pod 的文件系统中,使其对所有运行在 Pod 中的容器可用。我们强烈推荐此方法。其主要优点是完全将秘密存储与应用程序解耦。但是,这确实使平台更复杂,因为现在 Kubernetes 平台的服务之一是促进秘密注入。
Vault 实现这一模型的方式是使用指向 vault-agent-injector 的 MutatingWebhook。随着 Pod 的创建,基于注释,vault-agent-injector 添加一个 initContainer(用于检索初始秘密)和一个 sidecar 容器以保持必要时更新的秘密。图 7-6 展示了 Pod 和 Vault 之间交互流的过程。

图 7-6. Sidecar 注入架构。除了 my-app-container,所有 Vault Pod 都作为 sidecar 运行。
将注入这些特定于 vault 的容器的 MutatingWebhook 的配置如下所示:
apiVersion: admissionregistration.k8s.io/v1
kind: MutatingWebhookConfiguration
metadata:
labels:
app.kubernetes.io/instance: vault
app.kubernetes.io/managed-by: Helm
app.kubernetes.io/name: vault-agent-injector
name: vault-agent-injector-cfg
webhooks:
- admissionReviewVersions:
- v1beta1
clientConfig:
caBundle: REDACTED
service:
name: vault-agent-injector-svc
namespace: default
path: /mutate
port: 443
failurePolicy: Ignore
matchPolicy: Exact
name: vault.hashicorp.com
namespaceSelector: {}
objectSelector: {}
reinvocationPolicy: Never
rules:
- apiGroups:
- ""
apiVersions:
- v1
operations:
- CREATE
- UPDATE
resources:
- pods
scope: '*'
sideEffects: Unknown
timeoutSeconds: 30
MutatingWebhook 在每个 Pod 创建或更新事件时被调用。虽然每个 Pod 都会进行评估,但并不是每个 Pod 都会被改变或者注入 vault-agent。vault-agent-injector 在每个 Pod 规范中寻找两个注释:
vault.hashicorp.com/agent-inject: "true"
指示注入器包含一个 vault-agent initContainer,该容器检索秘密并将其写入 Pod 的文件系统,以在其他容器启动之前完成。
vault.hashicorp.com/agent-inject-status: "update"
指示注入器包含一个运行在工作负载旁边的 vault-agent sidecar。它将在 Vault 中的秘密更改时更新该秘密。此模式下仍然运行 initContainer。此参数是可选的,当不包括时,不会添加 sidecar。
当 vault-agent-injector 根据vault.hashicorp.com/agent-inject: "true"进行突变时,将添加以下内容:
initContainers:
- args:
- echo ${VAULT_CONFIG?} | base64 -d > /tmp/config.json
- vault agent -config=/tmp/config.json
command:
- /bin/sh
- -ec
env:
- name: VAULT_CONFIG
value: eyJhd
image: vault:1.3.2
imagePullPolicy: IfNotPresent
name: vault-agent-init
securityContext:
runAsGroup: 1000
runAsNonRoot: true
runAsUser: 100
volumeMounts:
- mountPath: /vault/secrets
name: vault-secrets
当 vault-agent-injector 看到注解vault.hashicorp.com/agent-inject-status: "update"时,将添加以下内容:
containers:
#
# ORIGINAL WORKLOAD CONTAINER REMOVED FOR BREVITY
#
- name: vault-agent
args:
- echo ${VAULT_CONFIG?} | base64 -d > /tmp/config.json
- vault agent -config=/tmp/config.json
command:
- /bin/sh
- -ec
env:
- name: VAULT_CONFIG
value: asdfasdfasd
image: vault:1.3.2
imagePullPolicy: IfNotPresent
securityContext:
runAsGroup: 1000
runAsNonRoot: true
runAsUser: 100
volumeMounts:
- mountPath: /vault/secrets
name: vault-secrets
存在代理后,它们将基于 Pod 的注解检索和下载秘密,例如请求从 Vault 获取数据库秘密的以下注解:
vault.hashicorp.com/agent-inject-secret-db-creds: "serets/db/creds"
默认情况下,秘密值将被持久化,就像打印出 Go map 一样。从语法上看,它如下所示。所有秘密都放在/vault/secrets中:
key: map[k:v],
key: map[k:v]
为了确保秘密的格式对使用最佳,Vault 支持将模板添加到 Pod 的注解中。这使用了标准的 Go 模板。例如,要创建一个 JDBC 连接字符串,可以将以下模板应用于名为creds的秘密:
spec:
template:
metadata:
annotations:
vault.hashicorp.com/agent-inject: "true"
vault.hashicorp.com/agent-inject-status: "update"
vault.hashicorp.com/agent-inject-secret-db-creds: "secrets/db/creds"
vault.hashicorp.com/agent-inject-template-db-creds: |
{{- with secret "secrets/db/creds" -}}
jdbc:oracle:thin:{{ .Data.data.username }}/{{ .Data.data.password }}
{{- end }}
在此模型中的主要复杂性区域是请求 Pod 的身份验证和授权。Vault 提供了多种认证方法。在 Kubernetes 中运行 Vault,特别是在此 sidecar 注入模型中,您可能希望设置 Vault 针对 Kubernetes 进行身份验证,以便 Pod 可以将其现有的服务账户令牌作为身份提供。设置此身份验证机制如下所示:
# from within a vault container
vault write auth/kubernetes/config \
kubernetes_host="https://$KUBERNETES_PORT_443_TCP_ADDR:443" \ 
kubernetes_ca_cert=@/var/run/secrets/kubernetes.io/serviceaccount/ca.crt \
token_reviewer_jwt=\
"$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)" 
此环境变量默认应出现在 Vault Pod 中。
此 Pod 的服务账户令牌位置,用于对抗 Kubernetes API 服务器进行 TokenReview 请求时进行身份验证。
当秘密请求进入 Vault 时,Vault 可以通过与 Kubernetes TokenReview API 通信来验证请求者的身份。假设身份已验证,Vault 接着必须确定服务账户是否被授权访问秘密。必须在 Vault 中配置和维护这些授权策略和服务账户与策略之间的绑定。在 Vault 中,策略编写如下:
# from within a vault container
vault policy write team-a - <<EOF
path "secret/data/team-a/*" {
capabilities = ["read"]
}
EOF
这在 Vault 中创建了一个名为team-a的策略,它提供对secret/data/team-a/中所有秘密的读取访问权限:
vault policy list
default
team-a
root
最后一步是将请求者的服务账户与策略关联,以便 Vault 授权访问:
vault write auth/kubernetes/role/database \
bound_service_account_names=webapp \ 
bound_service_account_namespaces=team-a \ 
policies=team-a \ 
ttl=20m 
请求者的服务账户名称。
请求者的命名空间。
绑定以将此帐户关联到一个或多个策略。
Vault 特定授权令牌应该存活多久。一旦过期,将再次执行认证/授权。
我们迄今探索的 Vault 特定过程可能适用于任何种类的外部秘密管理存储。在处理超出 Kubernetes 核心的系统时,你将面临一定程度的身份和授权集成开销。
CSI 集成
秘密存储集成的较新方法是利用 secrets-store-csi-driver。在撰写本文时,这是 kubernetes-sigs 内的 Kubernetes 子项目。此方法使得可以在更低级别上集成秘密管理系统。具体来说,它使得 Pod 能够访问外部托管的秘密,而无需运行 sidecar 或 initContainer 将秘密数据注入到 Pod 中。结果是秘密交互更像是一个平台服务,而不是应用程序需要集成的东西。secrets-store-csi-driver 在每个主机上运行一个驱动 Pod(作为 DaemonSet),类似于你期望 CSI 驱动程序与存储提供程序合作的方式。
然后,驱动程序依赖于负责在外部系统中查找秘密的提供程序。在 Vault 的情况下,这将涉及在每台主机上安装 vault-provider 二进制文件。二进制文件的位置应该是驱动程序的 provider-dir 挂载设置的地方。这个二进制文件可能已经存在于主机上,或者通常通过类似于 DaemonSet 的进程进行安装。整体架构看起来与 图 7-7 所示的接近。

第 7-7 图。CSI 驱动程序的交互流程。
这是一个基于其用户体验和抽象秘密提供程序能力而显有前景的相对较新的方法。然而,它确实带来了额外的挑战。例如,当 Pod 本身没有请求秘密时,身份如何处理?这是驱动程序和/或提供程序必须解决的问题,因为它们代表 Pod 发出请求。目前,我们可以查看主要的 API,其中包括 SecretProviderClass。要与 Vault 等外部系统进行交互,SecretProviderClass 将如下所示:
apiVersion: secrets-store.csi.x-k8s.io/v1alpha1
kind: SecretProviderClass
metadata:
name: apitoken
spec:
provider: vault
parameters:
roleName: "teama"
vaultAddress: "https://vault.secret-store:8000" 
objects: |
array:
- |
objectPath: "/secret/team-a" 
objectName: "apitoken" 
objectVersion: ""
这是 Vault 的位置,应该是服务名称 (vault) 后跟命名空间 secret-store。
这是 Vault 中写入 Key/Value 对象的路径。
这是在 team-a 中查找的实际对象。
有了 SecretProviderClass,Pod 可以如下消费和引用此内容:
kind: Pod
apiVersion: v1
metadata:
name: busybox
spec:
containers:
- image:
name: busybox
volumeMounts:
- name: secrets-api
mountPath: "/etc/secrets/apitoken"
readOnly: true
volumes:
- name: secrets-api
csi:
driver: secrets-store.csi.k8s.com
readOnly: true
volumeAttributes:
secretProviderClass: "apitoken"
当此 Pod 启动时,驱动程序和提供程序尝试检索秘密数据。假设对外部提供者的身份验证和授权成功,秘密数据将以卷挂载的形式出现,就像任何 Kubernetes 密钥一样。从节点上的驱动 Pod,您可以查看日志,看到发送给提供程序的命令:
level=info msg="provider command invoked: /etc/kubernetes/
secrets-store-csi-providers/vault/provider-vault --attributes [REDACTED]
--secrets [REDACTED] [--targetPath /var/lib/kubelet/pods/
643d7d88-fa58-4f3f-a7eb-341c0adb5a88/volumes/kubernetes.io~csi/
secrets-store-inline/mount --permission 420]"
总结一下,secret-store-csi-driver 是值得关注的一种方法。随着项目的稳定和提供者开始成熟,随着时间的推移,我们可能会看到这种方法在构建基于 Kubernetes 的应用程序平台时变得普遍起来。
在声明式世界中的 Secrets
应用部署、持续集成和持续交付的共同愿景是纯粹地转向声明式模型。这与 Kubernetes 中使用的模型相同,在这里你声明一个期望的状态,随着时间推移,控制器会努力协调期望状态与当前状态。对于应用开发者和 DevOps 团队来说,这些愿景通常表现为一个称为 GitOps 的模式。大多数 GitOps 方法的核心是将一个或多个 git 仓库作为工作负载的真实来源。当在某个分支或标签上看到提交时,它可以被构建和部署流程接收,通常在集群内完成。最终目标是使可用的工作负载能够接收流量。类似 GitOps 的模型在第十五章中有更详尽的讨论。
当采用纯粹声明式方法时,秘密数据会带来独特的挑战。当然,您可以将配置与代码一起提交,但是应用程序使用的凭据和密钥怎么办?我们觉得在提交中出现 API 密钥可能会让某些人感到不安。有一些解决方法。当然,其中之一是将秘密数据保留在声明式模型之外,并且对 GitOps 的神灵忏悔你的罪行。另一种方法是考虑“封存”您的秘密数据,以一种访问数据不会暴露有意义值的方式,这将在下一节中探讨。
密封 Secrets
我们如何真正地封存一个秘密?这个概念并不新鲜。使用非对称加密,我们可以确保一种方法来加密秘密,将其提交到指定位置,而不必担心有人暴露数据。在这种模型中,我们有一个加密密钥(通常是公钥)和一个解密密钥(通常是私钥)。其思想是,由加密密钥创建的任何秘密,如果没有私钥被泄露,其值就无法被破解。当然,我们需要确保在这种模型中安全的许多事情,例如选择一个可信赖的密码算法,确保私钥始终安全,并建立加密密钥和秘密数据轮换策略。我们将在即将到来的章节中探讨的模型是,在集群中生成私钥时,开发者可以分发他们自己的加密密钥,用于他们的秘密数据。
密封的 Secrets 控制器
Bitnami-labs/sealed-secrets 是一个常用的开源项目,用于实现上述描述。然而,如果您选择替代工具或自行构建,关键概念不太可能发生重大变化。
该项目的关键组件是运行在集群内部的 sealed-secret-controller。默认情况下,它会生成执行加密和解密所需的密钥。在客户端,开发人员使用名为 kubeseal 的命令行实用程序。由于我们使用的是非对称加密,kubeseal 只需知道公钥(用于加密)。一旦开发人员使用它加密其数据,他们甚至无法直接解密这些值。为了开始工作,我们首先将控制器部署到集群中:
kubectl apply -f
https://github.com/bitnami-labs/sealed-secrets/releases/\
download/v0.9.8/controller.yaml
默认情况下,控制器将为我们创建加密和解密密钥。但是,也可以使用自己的证书。公(证书)和私(键)存储在 kube-system/sealed-secret-key 下的 Kubernetes Secret 中。下一步是允许开发人员检索加密密钥,以便他们开始工作。这不应通过直接访问 Kubernetes Secret 来完成。相反,控制器公开了一个端点,可以用于检索加密密钥。如何访问此服务取决于您,但客户端需要能够使用以下命令调用它,其流程在图 7-8 中有详细介绍:
kubeseal --fetch-cert

图 7-8. Sealed-secret-controller 架构。
一旦在 kubeseal 中加载了公钥,就可以生成包含(加密的)秘密数据的 SealedSecret CRD。这些 CRD 存储在 etcd 中。sealed-secret-controller 通过标准的 Kubernetes Secrets 使秘密数据可用。为确保 SealedSecret 数据正确转换为 Secret,您可以在 SealedSecret 对象中指定模板。
您可以从 Kubernetes Secret 开始,就像任何其他的一样:
apiVersion: v1
kind: Secret
metadata:
name: mysecret
type: Opaque
data:
dbuser: aGVwdGlvCg==
dbkey: YmVhcmNhbm9lCg==
要“密封”秘密,您可以对其运行 kubeseal,并生成 JSON 中的加密输出:
kubeseal mysecret.yaml
{
"kind": "SealedSecret",
"apiVersion": "bitnami.com/v1alpha1",
"metadata": {
"name": "mysecret",
"namespace": "default",
"creationTimestamp": null
},
"spec": {
"template": {
"metadata": {
"name": "mysecret",
"namespace": "default",
"creationTimestamp": null
},
"type": "Opaque"
},
"encryptedData": {
"dbkey": "gCHJL+3bTRLw6vL4Gf......",
"dbuser": "AgCHJL+3bT......"
}
},
"status": {
}
}
前面的 SealedSecret 对象可以放置在任何地方。只要 sealed-secret-controller 持有的密封密钥未被泄露,数据就是安全的。在这个模型中,特别重要的是轮换,这在后续部分中有详细介绍。
一旦应用,流程和存储看起来如图 7-9 所描述。
由 sealed-secret-controller 制作的 Secret 对象归其相应的 SealedSecret CRD 所有:
ownerReferences:
- apiVersion: bitnami.com/v1alpha1
controller: true
kind: SealedSecret
name: mysecret
uid: 49ce4ab0-3b48-4c8c-8450-d3c90aceb9ee

图 7-9. 围绕管理密封和非密封秘密的 Sealed-secret-controller 交互。
这意味着如果删除 SealedSecret,则其对应的 Secret 对象将被垃圾收集。
密钥更新
如果密封的密钥泄漏(可能是由于 RBAC 配置错误),则应该视每个密钥为已泄漏。特别重要的是要定期更新密封密钥,并理解“更新”的范围。默认行为是每 30 天更新一次此密钥。它不会替换现有的密钥,而是追加到能够解封数据的现有密钥列表中。然而,新密钥用于所有新的加密活动。最重要的是,现有的密封密钥不会重新加密。
如果发生密钥泄漏事件,您应该:
-
立即旋转您的加密密钥。
-
旋转所有现有的秘密。
-
请记住,仅重新加密是不够的。例如,某人可以轻松地进入 git 历史记录,找到旧的加密资产,并使用已泄露的密钥对其进行操作。一般来说,您应该有密码和密钥的旋转和更新策略。
SealedSecrets 使用一个技巧,在加密过程中使用 Namespace。这提供了一种隔离机制,使得 SealedSecret 真正属于其创建的 Namespace,而不能仅仅在它们之间移动。一般来说,这种默认行为是最安全的,应该保持不变。然而,它确实支持可配置的访问策略,这在 sealed-secrets 文档中有所覆盖。
多集群模型
封密模型的另一个关键考虑因素是涉及多个集群的部署拓扑。这些拓扑中的许多将集群视为临时的。在这些情况下,可能更难以运行封密样式的控制器,因为——除非您在它们之间共享私钥——否则现在您需要担心为每个集群使用唯一的密钥。此外,开发人员获取加密密钥的交互点(如前述章节所述)从一个集群变为多个集群。虽然这并不是一个不可能解决的问题,但值得考虑。
秘密的最佳实践
应用程序对秘密的消耗高度依赖于所涉及的语言和框架。尽管差异很大,但我们推荐通用的最佳实践,并鼓励应用程序开发人员考虑。
始终审核秘密交互
Kubernetes 集群应配置为启用审核。审核允许您指定围绕特定资源发生的事件。这将告诉您资源何时以及由谁进行了交互。对于变更,它还将详细说明发生了什么变化。审计秘密事件对于响应访问问题至关重要。有关审核的详细信息,请参阅集群审核文档。
不要泄漏秘密
虽然泄漏机密从未是理想的情况,但在多租户 Kubernetes 环境中,重要的是考虑如何可能泄漏机密。一个常见的情况是意外记录密钥。例如,我们曾在平台工程师构建操作者时几次看到这种情况(在 第十一章 中有介绍)。这些操作者通常处理他们正在管理的系统以及可能需要连接的外部系统的密钥。在开发阶段,为了调试的目的,记录这些秘密数据是很常见的。日志输出到 stdout/stderr,并且在许多基于 Kubernetes 的平台上,会转发到日志分析平台。这意味着秘密可能以明文形式通过许多环境和系统传递。
Kubernetes 主要是一个声明式系统。开发人员编写的清单文件很容易包含秘密数据,特别是在测试时。开发人员应谨慎操作,确保在测试期间使用的秘密不会提交到源代码版本库中。
更倾向于使用卷而不是环境变量
访问 Kubernetes 提供的秘密数据的最常见方法是将值传播到环境变量或卷中。对于大多数应用程序,应该更倾向于使用卷。环境变量通过各种方式泄漏的可能性较高,例如在测试期间执行的 echo 命令,或者框架在启动或崩溃期间自动转储环境变量。这并不意味着卷能完全解决这些问题!
除了安全性外,对于应用程序开发人员的关键好处在于,当密钥发生更改时,卷会自动更新;这将支持如令牌等秘密的热重载。要使环境变量发生密钥更改,必须重新启动 Pod。
使秘密存储提供者对您的应用程序保持未知
应用程序可以采取几种方法来检索和使用所需的机密信息。这些方法可以从在业务逻辑中调用密钥存储,到期望在启动时设置环境变量。遵循关注分离的哲学,我们建议以一种方式实现机密信息的消费,使得应用程序不必关心是 Kubernetes、Vault 还是其他提供者在管理密钥。这样做可以使您的应用程序具备可移植性和平台无关性,并减少应用程序交互的复杂性。复杂性减少是因为应用程序要从提供者那里获取密钥,它需要理解如何与提供者通信,并能够进行身份验证以进行通信。
要实现这种供应商无关的实现,应用程序应优先从环境变量或卷中加载密钥。正如我们之前所说,卷是最理想的选择。在这种模型中,应用程序将假定在一个或多个卷中存在密钥。由于卷可以动态更新(无需 Pod 重启),如果需要密钥的热重载,应用程序可以监视文件系统。通过从容器的本地文件系统消费,无论后备存储是 Kubernetes 还是其他方式,都没有关系。
一些应用程序框架(如 Spring)包括库,用于直接与 API 服务器通信并自动注入密钥和配置。尽管这些工具很方便,但考虑刚刚讨论的要点,以确定哪些方法对您的应用程序最有价值。
摘要
在本章中,我们探讨了 Kubernetes Secret API、与密钥交互的方式、存储密钥的方法、如何密封密钥以及一些最佳实践。有了这些知识,重要的是我们考虑我们有兴趣保护的深度,并据此确定如何优先解决每一层的问题。
第八章:准入控制
本书中我们已多次提到 Kubernetes 灵活、模块化的设计是其伟大优势之一。合理的默认设置可以被替换、增强或基于其上构建,为平台消费者提供替代或更完整的体验。准入控制是特别受益于这一灵活设计目标的领域之一。准入控制关注的是在对象被持久化到 etcd 之前 验证和修改 Kubernetes API 服务器的请求。这种能力以细粒度和控制拦截对象打开了许多有趣的用例。例如:
-
确保正在被删除(处于终止状态)的命名空间中不能创建新对象。
-
强制新 Pod 不以 root 用户身份运行
-
确保一个命名空间中所有 Pod 使用的内存总和不超过用户定义的限制。
-
确保 Ingress 规则不能被意外覆盖。
-
向每个 Pod 添加一个 Sidecar 容器(例如 Istio)
首先,我们将高层次地查看准入控制链,这是所有发送到 API 服务器的请求都要经过的过程。然后我们将继续介绍内置的控制器。这些内置的准入控制器可以通过 API 服务器的标志启用和禁用,并支持前面提到的一些用例。其他用例则需要更加定制的实现,并通过灵活的 Webhook 模型集成。我们将花大量时间深入探讨 Webhook 模型,因为它为将准入控制集成到集群中提供了最强大和灵活的选项。最后,我们将介绍 Gatekeeper,这是一个倾向于的开源项目,实现了 Webhook 模型,并提供额外的用户友好功能。
注意
在本章的更深部分,我们将深入了解用 Go 编程语言编写的一些代码。Kubernetes 和许多其他云原生工具之所以选择 Go 语言实现,是因为它快速的开发速度、强大的并发原语和清晰的设计。虽然了解 Go 并不是理解本章大部分内容的必要条件(但如果你对 Kubernetes 工具感兴趣,我们建议你去了解它),我们将讨论定制与现成工具选择时需要开发技能的权衡。
Kubernetes 准入链
在我们更详细地了解各个控制器的功能和机制之前,让我们首先了解 Kubernetes API 服务器的请求和响应流程,如图 8-1 所示。

图 8-1 准入链。
最初,当请求到达 API 服务器时,它们会经过身份验证和授权,以确保客户端是有效的,并能根据配置的 RBAC 规则执行请求的操作(例如,在特定命名空间创建一个 Pod)。
在下一阶段,请求通过突变接入控制器,由图 8-1 中最左边的蓝色框表示。这些可以是内置控制器或对外部(外部树)突变 Webhook 的调用(我们稍后将讨论这些内容)。这些控制器能够在传递到后续阶段之前修改资源属性。作为为什么这可能有用的示例,让我们考虑 Service Account 控制器(默认情况下内置并启用)。当提交 Pod 时,Service Account 控制器会检查 Pod 的规范,以确保它已设置了serviceAccount(SA)字段。如果没有,它会添加该字段,并将其设置为 Namespace 的default SA。它还添加ImagePullSecrets和一个 Volume,以允许 Pod 访问其Service Account 令牌。
然后,请求将经过模式验证,以确保所提交的对象根据定义的模式是有效的。这里确保像强制字段这样的内容已设置。这种顺序很重要,因为它意味着我们可以在验证对象之前设置突变接入控制器中的字段。
在对象持久化到 etcd 之前的最后阶段,它必须通过验证接入控制器,由图 8-1 中最右边的蓝色框表示。这些可以是内置控制器或对外部(外部树)验证 Webhook 的调用(我们稍后将简要介绍这些内容)。这些验证控制器与突变控制器不同,因为它们只能批准或拒绝请求,不能修改负载。它们与之前的模式验证步骤不同,因为它们关注的是操作逻辑验证,而不是标准化模式。
一个示例验证接入控制器是NamespaceLifecycle控制器。它有几个与命名空间相关的工作,但我们将查看的是它拒绝在当前正在删除的命名空间中创建新对象的请求。我们可以在此代码片段中看到这种行为:
// ensure that we're not trying to create objects in terminating Namespaces if a.GetOperation() == admission.Create {
if namespace.Status.Phase != v1.NamespaceTerminating {
return nil 
}
err := admission.NewForbidden(a, fmt.Errorf("unable to create new content in
namespace %s because it is being terminated", a.GetNamespace()))
if apierr, ok := err.(*errors.StatusError); ok {
apierr.ErrStatus.Details.Causes = append(apierr.ErrStatus.Details.Causes,
metav1.StatusCause{
Type: v1.NamespaceTerminatingCause,
Message: fmt.Sprintf("namespace %s is being terminated", a.GetNamespace()),
Field: "metadata.namespace",
})
}
return err 
}
如果操作是创建,但命名空间当前未终止,请不要返回错误。请求将通过此控制器。
否则,返回一个 API 错误,说明命名空间正在终止。如果返回错误,则请求被拒绝。
注意
要使请求通过并将对象持久化到 etcd 中,它必须由所有验证接入控制器批准。要拒绝它,只需一个控制器拒绝即可。
内置接入控制器
当 Kubernetes 初次发布时,用户只能使用少量接口来插入或扩展外部功能,例如容器网络接口(CNI)。其他与云提供商、存储提供商的集成,以及准入控制器的实现,都是嵌入到核心 Kubernetes 代码中的,通常被描述为内置。随着时间推移,项目试图增加可插拔接口的数量,我们看到了容器存储接口(CSI)的创建,以及向外部云提供商的转移。
准入控制器是仍然有许多核心功能内置的一个领域。Kubernetes 附带许多不同的准入控制器,可以通过配置 API 服务器标志来启用或禁用。对于那些历史上没有权限配置这些标志的云托管 Kubernetes 平台的用户,这种模型已经证明是有问题的。PodSecurityPolicy(PSP)是一个示例,它可以在整个集群中启用高级和强大的安全功能,但不默认启用,因此排除了用户从中获益的可能性。
然而,准入控制正在缓慢地遵循将代码从 API 服务器移出并向增加可插拔性的趋势发展。这一过程的开始是通过添加变异和验证 Webhook 来实现的。这两个灵活的准入控制器允许我们指定 API 服务器应该转发请求(符合特定条件的请求),并将准入决策委托给外部 Webhook。我们将在下一节详细讨论这些内容。
在这个过程中的另一步是宣布废弃当前的 PodSecurityPolicy 内置控制器。虽然有多种方法来替代它,我们认为 PSP 的实现将委托给外部准入控制器,因为社区继续将代码移出内置。事实上,我们相信更多内置准入控制器最终会被移出内置。这些将被推荐使用第三方工具或标准化组件来替换,这些组件存在于 Kubernetes 上游组织中,但不在核心代码库中,从而允许用户在需要时选择合理的默认选项。
注意
一些内置准入控制器的子集默认启用。这些被设计为大多数集群中运行良好的合理默认值。我们不会在此重复列表,但您应注意确保启用您需要的控制器。此外,请注意此功能的用户体验可能有点令人困惑。要启用额外的(非默认)控制器,您必须使用--enable-admission-plugins标志向 API 服务器添加,要禁用默认控制器,您必须指定--disable-admission-plugins列表参数。
官方 Kubernetes 文档中有大量关于内部控制器的信息,因此我们不会在这里详细介绍它们。准入控制器的真正威力是通过两个特殊的验证和变异 Webhook 启用的,这是我们接下来要讨论的地方!
Webhook
注意
所有的准入控制器都位于请求发送到 Kubernetes API 服务器的关键路径上。它们的作用范围各不相同,因此并非所有请求都会被拦截,但在启用和/或注入它们时,您一定要注意这一点。在讨论 Webhook 准入控制器时尤为重要,原因有二。首先,由于它们位于外部且必须通过 HTTPS 调用,因此会增加延迟。其次,它们具有广泛的功能潜力,甚至可能调用第三方系统。必须极为小心地确保准入控制器尽可能高效地执行,并在最早的机会返回。
Webhook 是一种特殊类型的准入控制器。我们可以配置 Kubernetes API 服务器,向外部 Webhook 终端点发送 API 请求,并接收一个决策(原始请求是否应允许、拒绝或更改/变异)的响应。出于多种原因,这非常强大:
-
接收 Web 服务器可以用任何能够暴露 HTTPS 监听器的语言编写。我们可以利用可能可用的 Web 框架、库和专业知识来实现我们需要进行准入决策的任何逻辑。
-
它们可以在集群内或集群外运行。如果要在集群内运行它们,我们可以利用可用的发现和操作者原语,或者例如实现可重用功能的无服务器函数。
-
我们能够调用 Kubernetes 外部的系统和数据存储来进行策略决策。例如,我们可以查询集中的安全系统,检查 Kubernetes 清单中是否批准使用特定镜像。
注释
API 服务器将通过 TLS 调用 Webhook,因此 Webhook 必须提供 Kubernetes API 所信任的证书。通常通过在集群中部署 Cert Manager 并自动生成证书来实现这一点。如果在集群外运行,则需要提供 Kubernetes API 服务器信任的证书,可以来自公共根 CA 或 Kubernetes 知道的某个内部 CA。
要使 Webhook 模型正常工作,必须为 API 服务器和 Webhook 服务器之间交换的请求和响应消息定义一个明确定义的模式。在 Kubernetes 中,这被定义为 AdmissionReview 对象,是一个包含有关请求信息的 JSON 负载,包括:
-
API 版本、组和类型
-
元数据,如名称和命名空间,以及用于与响应决策进行关联的唯一 ID
-
尝试的操作(例如,CREATE)
-
有关发起请求的用户信息,包括他们的组成员身份
-
是否为干预运行请求(这一点很重要,因为后面在讨论设计考虑时会看到)
-
实际资源
所有这些信息可以被接收 Webhook 使用以计算接受决策。一旦决定,服务器需要用自己的 AdmissionReview 消息做出响应(这次包括一个response字段)。它将包含:
-
请求的唯一 ID(用于关联)
-
是否应允许请求继续进行
-
可选的自定义错误状态和消息
验证 Webhook 无法修改发送到它们的请求,并且只能接受或拒绝原始对象。这一限制使它们相当有限;但是,在确保应用到集群的对象符合安全标准(特定用户 ID、无主机挂载等)或包含所有所需的元数据(内部团队标签、注解等)时,它们非常合适。
在变异 Webhook 的情况下,响应结构也可以包括一个补丁集(如果需要)。这是一个包含有效 JSONPatch 结构的 base64 编码字符串,封装了请求在提交到 API 服务器之前应进行的更改。如果你想详细了解 AdmissionReview 对象的所有字段和结构,那么官方文档在这里做得非常好。
变异控制器的一个简单示例可能是向 Pod 或 Deployments 添加一组包含团队或工作负载特定元数据的标签。你可能会遇到的变异控制器的另一个更复杂但常见的用法是在许多服务网格实现中注入 sidecar 代理。其工作方式是服务网格(例如 Istio)运行一个 Admission 控制器,该控制器变异 Pod 规范以添加一个将参与网格数据平面的 sidecar 容器。此注入默认情况下发生,但可以通过 Namespace 或 Pod 级别的注解进行覆盖,以提供额外的控制。
这种模型是丰富 Deployments 以增强功能的有效方式,但将这种复杂性隐藏起来以改善最终用户体验。然而,与许多决策一样,这可能是一把双刃剑。变异控制器的一个缺点是,从最终用户的视角来看,对象被应用到集群中,而这些对象与他们最初创建的对象不一致,如果用户不知道集群上正在运行变异控制器,可能会造成混淆。
配置 Webhook Admission 控制器
集群管理员可以使用 MutatingWebhookConfiguration 和 ValidatingWebhookConfiguration 类型来指定动态 Webhook 的配置。下面是一个带注释的示例,简要描述相关部分。在接下来的部分中,我们将深入探讨一些字段的更高级考虑事项。
apiVersion: admissionregistration.k8s.io/v1
kind: MutatingWebhookConfiguration
metadata:
name: "test-mutating-hook"
webhooks:
- name: "test-mutating-hook"
rules: 
- apiGroups: [""]
apiVersions: ["v1"]
operations: ["CREATE"] 
resources: ["pods"] 
scope: "Namespaced" 
clientConfig: 
service:
namespace: test-ns
name: test-service
path: /test-path
port: 8443
caBundle: "Ci0tLS0tQk...tLS0K" 
admissionReviewVersions: ["v1", "v1beta1"] 
sideEffects: "None" 
timeoutSeconds: "5" 
reinvocationPolicy: "IfNeeded" 
failurePolicy: "Fail" 
匹配规则。该 webhook 应发送到哪个 API / 类型 / 版本 / 操作。
应该触发调用 webhook 的操作。
要针对的类型。
应该针对 Namespace 范围还是集群范围的资源。
描述 API 服务器如何连接到 webhook。在本例中,它位于集群中的test-service.test-ns.svc。
一个 PEM 编码的 CA bundle,用于验证 webhook 的服务器证书。
声明该 webhook 支持的admissionReviewVersions。
描述 webhook 是否具有对外部系统的外部副作用(调用/依赖)。
等待多长时间才能触发failurePolicy。
这个 webhook 是否可以重新调用(这可能发生在其他 webhook 调用之后)。
webhook 是否应该失败“开”还是“关”。这对安全有影响。
正如您在上述配置中所看到的,我们可以非常精确地选择要拦截的请求,以我们的准入 webhook。例如,如果我们只想目标创建 Secrets 的请求,我们可以使用以下规则:
# <...snip...>
rules: 
- apiGroups: [""]
apiVersions: ["v1"]
operations: ["CREATE"] 
resources: ["secrets"] 
scope: "Namespaced" 
# <...snip...>
我们还可以结合 Namespace 或对象选择器,以进一步细化控制粒度。这些允许我们指定任意数量的 Namespace 来目标和/或具有特定标签的对象;例如,在以下代码片段中,我们只选择那些在具有标签webhook: enabled的 Namespace 中的 Secrets:
# <...snip...>
namespaceSelector:
matchExpressions:
- key: webhook
operator: In
values: ["enabled"]
# <...snip...>
webhook 设计考虑因素
在编写和实施准入 webhook 时需要注意几个因素。我们将在下一节详细讨论这些因素如何影响一些实际场景,但在高层次上,您应该注意以下几点:
失败模式
如果无法访问 webhook 或向 API 服务器发送未知响应,则视为失败。管理员必须选择在此情况下通过将failurePolicy字段设置为Ignore(允许请求)或Fail(拒绝请求)来选择是失败“开”还是“关”。
警告
对于安全相关(或关键功能)的 webhook,Fail是最安全的选项。对于非关键的 hook,Ignore可能安全(可能与一个协调控制器作为备份一起使用)。结合这些推荐与本列表中讨论的性能项。
排序
关于 API 服务器请求流程的第一要点是,变更 Webhook 将在验证 Webhook 被调用之前(可能超过一次)之前被调用。这很重要,因为它使验证 Webhook(可能基于安全要求拒绝请求)始终可以在应用资源之前看到最终版本。
不能保证按特定顺序调用变更 Webhook,并且如果后续钩子修改请求,可能会多次调用。可以通过指定reinvocationPolicy来修改这一点,但理想情况下,Webhook 应设计为幂等,以确保顺序不影响其功能。
性能
Webhook 作为流向 API 服务器的请求关键路径的一部分被调用。如果一个 Webhook 很关键(与安全相关)并且失败后关闭(如果发生超时,则拒绝请求),则应考虑设计具有高可用性的解决方案。正如我们的一位尊敬的前同事经常评论的那样,如果用户在应用程序中不小心使用,接受控制可能会成为作为服务的瓶颈。
如果 Webhook 需要大量资源和/或具有外部依赖项,则应考虑 Webhook 被调用的频率以及将功能添加到关键路径中的性能影响。在这些情况下,可能更倾向于编写在集群中仅协调对象一次的控制器。在编写 Webhook 配置时,应尽可能缩小作用范围,以确保不会不必要地或在不相关资源上调用它们。
副作用
一些 Webhook 可能负责根据对 Kubernetes API 的请求修改外部资源(例如,云提供商中的某些资源)。这些 Webhook 应意识到并尊重dryRun选项,并在启用时跳过对外部状态的修改。 Webhook 负责声明它们是否没有副作用,或通过设置sideEffects字段来尊重此选项。有关此字段的有效选项以及每个选项的行为的详细信息在官方文档中有详细说明。
编写变更 Webhook
在本节中,我们将探讨两种编写变更接受 Webhook 的方法。首先,我们将简要讨论使用通用 HTTPS 处理程序实现的方法。然后,我们将深入探讨一个真实用例,同时涵盖旨在帮助团队开发 Kubernetes 控制器组件的 controller-runtime 上游项目。
在本节中的两种解决方案都需要熟练掌握 Go 语言(用于控制器运行时)或其他编程语言。在某些情况下,这种要求可能妨碍了创建和实现准入控制器。如果您的团队没有经验或需要编写定制的 Webhook,本章的最后一节提供了一种不需要编程知识的可配置准入策略解决方案。
简单的 HTTPS 处理程序
准入控制器的 Webhook 模型的一个优势是,我们能够使用任何语言从头开始实现它们。我们这里使用的示例是用 Go 语言编写的,但任何支持 TLS 启用的 HTTP 处理和 JSON 解析的语言都是可以接受的。
这种编写 Webhook 的方式能够最大程度地与当前使用的堆栈集成,但需要付出许多高级抽象的代价(尽管具有成熟的 Kubernetes 客户端库的语言可以缓解这一点)。
如本节介绍中所述,准入控制 Webhook 接收并返回 API 服务器的请求。这些消息的模式是众所周知的,因此可以接收请求并手动修改对象(通过补丁)。
作为这种方法的具体示例,让我们深入了解一下AWS IAM Roles for Service Accounts mutating webhook。此 Webhook 用于将 Projected Volume 注入具有可用于 AWS 服务认证的 Service Account 令牌的 Pod 中。(有关此用例的安全方面的更多细节,请参阅第十章。)
// <...snip...> type patchOperation struct { 
Op string `json:"op"`
Path string `json:"path"`
Value interface{} `json:"value,omitempty"`
}
volume := corev1.Volume{ 
Name: m.volName,
VolumeSource: corev1.VolumeSource{
Projected: &corev1.ProjectedVolumeSource{
Sources: []corev1.VolumeProjection{
{
ServiceAccountToken: &corev1.ServiceAccountTokenProjection{
Audience: audience,
ExpirationSeconds: &m.Expiration,
Path: m.tokenName,
},
},
},
},
},
}
patch := []patchOperation{ 
{
Op: "add",
Path: "/spec/volumes/0",
Value: volume,
},
}
if pod.Spec.Volumes == nil { 
patch = []patchOperation{
{
Op: "add",
Path: "/spec/volumes",
Value: []corev1.Volume{
volume,
},
},
}
}
patchBytes, err := json.Marshal(patch) 
// <...snip...>
定义一个patchOperation结构体,该结构体将被编组为 JSON 以便回应 Kubernetes API 服务器。
用相关的 ServiceAccountToken 内容构造Volume结构体。
使用先前构建的 Volume 内容创建一个patchOperation实例。
如果当前没有 Volume,则创建该键并添加先前构建的 Volume 内容。
创建包含补丁内容的 JSON 对象。
注意,此准入 Webhook 的实际实现包括一些额外功能,这些功能也增加了补丁集(例如添加环境变量),但出于本示例的目的,我们将忽略这些。完成补丁集后,我们需要返回包含我们的补丁集的 AdmissionResponse 对象(下面代码段中的Patch字段):
return &v1beta1.AdmissionResponse{
Allowed: true,
Patch: patchBytes,
PatchType: func() *v1beta1.PatchType {
pt := v1beta1.PatchTypeJSONPatch
return &pt
}(),
}
我们可以在这个例子中看到,需要大量的手工工作来生成补丁集并构造适当的响应给 API 服务器。 即使在使用 Go 语言中的一些 Kubernetes 库时,这种情况也存在。 但是,我们省略了一大部分需要处理错误、优雅关闭、HTTP 头处理等的支持代码。
虽然这种方法为我们提供了最大的灵活性,但它需要更多的领域知识,并且在实现和维护上更加复杂。 这种权衡对于大多数读者来说将会非常熟悉,因此在评估您特定的用例和内部专业知识时需要谨慎考虑。
在下一节中,我们将看到一种方法,它减少了大量的样板和定制工作,而采用了实现上游辅助框架 controller-runtime。
控制器运行时
在本节中,我们将深入研究上游项目controller-runtime,并看看它在原生 Kubernetes 客户端库之上提供的抽象,以便更加流畅地编写准入控制器。 为了提供更多细节,我们将使用一个我们构建的开源控制器来满足社区需求,作为说明 controller-runtime 的一些优势的方式,并覆盖之前讨论的一些技术和陷阱。 虽然为简洁起见,我们在某种程度上简化了控制器的功能和代码,但核心的基本思想仍然存在。
我们将要讨论的控制器是一个 webhook,旨在执行以下操作:
-
观察 Cluster API VSphereMachine 对象。
-
基于可配置字段,在外部 IPAM 系统(在本例中为 Infoblox)中分配一个 IP 地址。
-
将分配的 IP 插入到 VSphereMachine 的静态 IP 字段中。
-
允许变异请求通过到 Kubernetes API 服务器,由 Cluster API 控制器执行并持久化到 etcd 中。
对于一些原因,这个用例是一个使用 controller-runtime 构建的自定义(变异 webhook)的好选择:
-
我们需要变异请求以在请求到达 API 服务器之前添加一个 IP 地址之前(否则会出错)。
-
我们正在调用一个外部系统(Infoblox),因此可以利用其 Go 库进行交互。
-
少量的样板代码允许新的社区和/或客户端开发者理解和扩展功能。
注意
虽然超出了本章节的范围,但我们伴随这个 webhook 一起编写了一个在集群中运行的控制器。 当您的 webhook 与并且修改或依赖于外部状态(在本例中为 Infoblox)进行交互时,这一点非常重要,因为您应该不断地协调那个状态,而不是仅依赖于准入时看到的状态。 在构建变异准入 webhook 时需要考虑这一点,并且如果需要额外的组件,可能会增加解决方案的复杂性。
Controller-runtime webhooks 必须实现一个Handle方法,其签名为:
func (w *Webhook) Handle(
ctx context.Context,
req admission.Request) admission.Response
admission.Request对象是对 webhook 接收的原始 JSON 的抽象,并提供对原始应用对象、执行的操作(例如CREATE)和许多其他有用的元数据的简便访问:
vm := &v1alpha3.VSphereMachine{} 
err := w.decoder.DecodeRaw(req.Object, vm) 
if err != nil {
return admission.Errored(http.StatusBadRequest, err) 
}
创建一个新的 VSphereMachine 对象。
使用内置解码器将请求中的原始对象解码为 Go VSphereMachine 对象。
使用便捷方法Errored来构建并返回错误响应,如果解码步骤返回错误。
在返回响应之前,可以对请求中的vm对象进行任何修改或验证。在下面的示例中,我们检查 VSphereMachine 对象上是否存在infoblox注解(表示我们的 webhook 应采取行动)。这是在 webhook 早期执行的重要步骤,因为如果不需要采取任何操作,我们可以从任何进一步的逻辑中快速退出。如果注解不存在,则使用方便的Allowed方法尽快将未修改的对象返回给 API 服务器。正如我们在“Webhook 设计考虑”中讨论的那样,webhook 是 API 请求的关键路径,我们在其中执行的任何操作都应尽可能快:
if _, ok := vm.Annotations["infoblox"]; !ok {
return admission.Allowed("Nothing to do")
}
假设我们应该处理此请求,并且前面的逻辑没有触发,我们从 Infoblox(未显示)中检索一个 IP 地址,并直接将其写入vm对象:
vm.Spec.VirtualMachineCloneSpec.Network.Devices[0].IPAddrs[0] = ipFromInfoblox 
marshaledVM, err := json.Marshal(vm) 
if err != nil { 
return admission.Errored(http.StatusInternalServerError, err)
}
return admission.PatchResponseFromRaw(req.Object.Raw, marshaledVM) 
在vm对象上设置 IP 字段,从而改变它。
将vm对象编组为 JSON,准备发送回 API 服务器。
如果编排失败,我们将使用我们之前看到的便捷错误处理方法。
另一个便捷方法PatchReponseFromRaw,将响应发送回去。我们稍后会更详细地讨论这一点。
注意
有些情况下,您可能希望并/或需要拦截对 API 服务器的DELETE请求。其中一个示例可能是清理一些与集群中资源相关的外部状态。虽然这可以在 webhook 中完成,但您应考虑您是“失败开放”还是“失败关闭”,以及在前一种情况下存在不对齐状态的风险。理想情况下,删除逻辑应使用finalizer和在集群中运行的自定义控制器来实现,以保证清理工作。
在前面的片段中,我们看到了另一个 controller-runtime 的便利方法PatchResponseFromRaw。此方法将在发送正确序列化的响应之前,自动计算所需的 JSONPatch 差异,这些差异是在原始原始对象和我们修改的对象之间。与前一节中更手动的方法相比,这是一种去除一些样板代码、使我们的控制器代码更精简的好方法。
在简单的验证挂钩情况下,我们还可以利用像admission.Allowed()和admission.Denied()这样的便利函数,这些函数可以在处理所需逻辑后使用。
注意
如果我们作为准入控制器的一部分操作外部状态,我们需要注意并检查req.DryRun条件。如果设置了这个条件,用户只执行干跑(dry run),不会执行实际请求,我们应确保我们的控制器在这种情况下不会改变外部状态。
Controller-runtime 为构建准入控制器提供了非常强大的基础,使我们能够专注于要实现的逻辑,减少样板代码的使用。然而,这需要编程专业知识,并且准入逻辑被混淆在控制器代码内部,可能会给最终用户带来更多困惑或意外的结果。
在本章的下一节中,我们将介绍一种新兴的模型,该模型集中策略逻辑,并引入一种标准语言来编写决策规则。在这一领域出现的工具力求将自定义控制器的灵活性与更少技术操作员和/或最终用户的更高可用性功能结合起来。
集中化策略系统
到目前为止,我们已经看过多种不同的方法来实现和配置准入控制器。每种方法都有其特定的权衡考虑因素,在选择采用它们时必须加以考虑。在本节的最后部分,我们将介绍一种新兴的模型,将策略逻辑集中到一个地方,并使用标准化语言来表达允许/拒绝规则。这种模型有两个主要优点:
-
创建准入控制器不需要编程知识,因为我们可以使用特定的策略语言表达规则(而不是通用编程语言)。这也意味着逻辑的更改不需要每次重建和重新部署控制器。
-
策略和规则存储在一个单一位置(在大多数情况下是集群本身),便于查看、编辑和审计。
此模型正在通过几个开源工具进行构建和实施,通常由两个组件组成:
-
一个能够表达条件的策略/查询语言,判断对象是否应该被接受或拒绝。
-
一个位于集群中作为准入控制器的控制器。该控制器的任务是评估针对进入 API 服务器的对象的策略/规则,并做出接受或拒绝的决策。
在本章的其余部分,我们将专注于称为Gatekeeper的这种集中式策略模型的最流行实现,尽管其他工具如Kyverno也正在获得关注。Gatekeeper 建立在一个名为 Open Policy Agent(OPA)的低级工具之上。OPA 是一个开源策略引擎,将用 Rego 语言编写的策略应用于摄取的 JSON 文档并返回结果。
一个调用应用可以通过接收结果并决定如何继续(做出策略决策)来利用 OPA。我们从本章了解到,Kubernetes 有一个标准模式用于发送请求和接收准入决策响应,因此这似乎立即是一个很有前景的选择。然而,OPA 本身是平台/上下文无关的,只是一个在 JSON 上操作的策略引擎。我们需要一个控制器作为 OPA 引擎和 Kubernetes 之间的接口。Gatekeeper 是履行这一接口角色并在模板和可扩展性方面提供一些额外的 Kubernetes 本地功能的工具,以便平台操作员更轻松地编写和应用策略。Gatekeeper 被部署到集群作为准入控制器,允许用户编写规则在 Rego 中做出有关应用到集群的 Kubernetes 资源的准入策略决策。
Gatekeeper 使得集群操作员可以创建并公开预设策略模板作为ConstraintTemplate CRD。这些模板为可以接受自定义输入参数的特定约束创建新的 CRD(类似于函数)。这种方法非常强大,因为最终用户可以使用自己的值创建约束的实例,Gatekeeper 将其作为集群准入控制的一部分使用。
注意
出于本节后部详细列出的一些原因,您应该意识到 Gatekeeper 目前默认情况下是开放失败的。这可能会带来严重的安全影响,因此在将这些解决方案投入生产之前,您应该仔细了解每种方法的权衡(本章及大部分官方文档中详细说明)。
我们在现场实施的一个常见规则是确保团队无法覆盖现有的 Ingress 资源。这在大多数 Kubernetes 集群中是一个要求,一些 Ingress 控制器(例如 Contour)提供了保护机制以防止此类问题。然而,如果您的工具不具备这种机制,您可以使用 Gatekeeper 来强制执行此规则。这种情况是一系列在官方 Gatekeeper 文档中维护的常见策略库之一。
在这种情况下,需要基于 外部存在于被应用到集群的对象 的数据做出策略决策。我们需要直接查询 Kubernetes,以了解已存在的 Ingress 资源,并能够检查它们周围的元数据,以与正在应用的对象进行比较。
让我们举一个更复杂的例子来建立在这些想法之上,并且我们将逐步实施每个资源的实现。在这种情况下,我们将用正则表达式模式注释一个 Namespace,并确保在该 Namespace 中应用的任何 Ingress 符合该正则表达式。我们之前提到过,Gatekeeper 需要集群信息可用于制定策略决策。这是通过定义同步配置来实现的,以指定应同步到 Gatekeeper 缓存的 Kubernetes 中的哪些资源(以提供可查询的数据源):
apiVersion: config.gatekeeper.sh/v1alpha1
kind: Config
metadata:
name: config
namespace: "gatekeeper-system"
spec:
sync: 
syncOnly:
- group: "extensions"
version: "v1beta1"
kind: "Ingress"
- group: "networking.k8s.io"
version: "v1beta1"
kind: "Ingress"
- group: ""
version: "v1"
kind: "Namespace"
sync 部分指定 Gatekeeper 应缓存的所有 Kubernetes 资源,以协助进行策略决策。
注意
缓存存在的目的是消除 Gatekeeper 不断查询 Kubernetes API 服务器以获取所需资源的需要。然而,Gatekeeper 基于 过时 数据做出决策的潜力是存在的。为了减轻这一点,存在一种审核功能,定期针对现有资源运行策略,并在每个约束的 status 字段中记录违规。应监控这些以确保通过的违规(可能是由于过时的缓存读取)不被忽略。
一旦配置应用完成,管理员就可以创建 ConstraintTemplate。此资源定义了策略的主要内容以及管理员或其他运营商可以提供/覆盖的任何输入参数:
apiVersion: templates.gatekeeper.sh/v1beta1
kind: ConstraintTemplate
metadata:
name: limitnamespaceingress
spec:
crd:
spec:
names:
kind: LimitNamespaceIngress
listKind: LimitNamespaceIngressList
plural: limitnamespaceingresss
singular: limitnamespaceingress
validation:
# Schema for the `parameters` field in the constraint
openAPIV3Schema:
properties: 
annotation:
type: string
targets: 
- target: admission.k8s.gatekeeper.sh
rego: |
package limitnamespaceingress
violation[{"msg": msg}] {
cluster := data.inventory.cluster.v1
namespace := cluster.Namespace[input.review.object.metadata.namespace]
regex := namespace.metadata.annotations[input.parameters.annotation]
hosts := input.review.object.spec.rules[_].host
not re_match(regex, hosts)
msg := sprintf("Only ingresses matching %v in namespace %v allowed",
[regex ,input.review.object.metadata.namespace])
}
properties 部分定义了将可用于每个规则实例化中注入 Rego 策略的输入参数。
targets 部分包含我们策略规则的 Rego 代码。我们不会在这里深入讨论 Rego 语法,但请注意,输入参数通过 input.parameters.<parameter_name>(在本例中为 annotation)被引用。
自定义输入参数中的 annotation 允许用户指定 Gatekeeper 应从中提取正则表达式模式的特定注释名称。如果任何语句返回 False,Rego 将不会触发违规。在这种情况下,我们正在检查主机是否与正则表达式匹配,因此为了确保不触发违规,我们需要使用 not 来反转 re_match(),以确保正向匹配不触发违规,而是 允许 请求通过准入控制。
最后,我们创建了上述策略的一个实例,以配置 Gatekeeper 在准入控制的一部分针对特定资源应用它。LimitNamespaceIngress 对象指定规则应适用于 apiGroups 的 Ingress 对象,并指定 allowed-ingress-pattern 作为应检查正则表达式模式的注解(这是可自定义的输入参数):
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: LimitNamespaceIngress
metadata:
name: limit-namespace-ingress
spec:
match:
kinds:
- apiGroups: ["extensions", "networking.k8s.io"]
kinds: ["Ingress"]
parameters:
annotation: allowed-ingress-pattern
最后,Namespace 对象本身应用了自定义注解和模式。这里我们在 allowed-ingress-pattern 字段中指定了正则表达式 \w\.my-namespace\.com:
apiVersion: v1
kind: Namespace
metadata:
annotations:
# Note regex special character escaping
allowed-ingress-pattern: \w\.my-namespace\.com
name: ingress-test
设置步骤现在已经全部完成。我们可以开始添加 Ingress 对象,并且我们配置的规则将针对它们进行评估,然后允许或拒绝持久性/创建 Ingress:
# FAILS because the host doesn't match the pattern above
apiVersion: networking.k8s.io/v1beta1
kind: Ingress
metadata:
name: test-1
namespace: ingress-test
spec:
rules:
- host: foo.other-namespace.com
http:
paths:
- backend:
serviceName: service1
servicePort: 80
---
# SUCCEEDS as the pattern matches
apiVersion: networking.k8s.io/v1beta1
kind: Ingress
metadata:
name: test-2
namespace: ingress-test
spec:
rules:
- host: foo.my-namespace.com
http:
paths:
- backend:
serviceName: service2
servicePort: 80
第二个 Ingress 将成功,因为 spec.rules.host 符合 ingress-test Namespace 上 allowed-ingress-pattern 注解中指定的正则表达式模式。然而,第一个 Ingress 不匹配,导致错误:
Error from server ([denied by limit-namespace-ingress] Only ingresses with
host matching \w\.my-namespace\.com are allowed in namespace ingress-test):
error when creating "ingress.yaml": admission webhook "validation.gatekeeper.sh"
denied the request: [denied by limit-namespace-ingress] Only ingresses with host
matching \w\.my-namespace\.com are allowed in namespace ingress-test
Gatekeeper 有许多优点:
-
可扩展的
ConstraintTemplate模型允许管理员定义常见策略,并在整个组织中共享/重用它们作为库。 -
虽然它确实需要 Rego 知识,但不需要额外的编程语言经验,降低了政策设计和创建的准入门槛。
-
底层技术(OPA)在社区中已经相当成熟并得到良好支持。Gatekeeper 是一个较新的层,但在早期得到了强有力的支持。
-
将所有策略强制执行到一个准入控制器中使我们能够访问集中的审计日志,这在受监管的环境中通常很重要。
Gatekeeper 的主要弱点是它目前无法对请求进行 变异。虽然它支持外部数据源(通过各种方法),但实施起来可能会很麻烦。这些问题将来势必会得到解决,但如果您在这些领域有强烈的要求,很可能需要实施前面章节中描述的一种自定义准入控制解决方案。
在使用 Gatekeeper(以及任何通用准入控制器)时的最后一点考虑是,这些工具捕获的请求范围可能非常广泛。这是它们能够发挥作用的必要条件,因为可以编写涵盖许多不同对象的规则,并且控制器本身需要包含一个权限的超集以能够捕获它们。然而,这有几个重要影响:
-
如前所述,这些工具位于关键路径上。如果控制器或您的配置存在错误或其他问题,则可能会导致广泛的停机。
-
作为前一点的延续,因为控制器拦截了对控制平面的请求,管理员在执行补救步骤时可能也会暂时被排除在外。这在资源对集群操作(例如网络资源等)重要和/或必不可少的情况下尤为重要。
-
广泛的范围要求必须将广泛的 RBAC 策略附加到准入控制器/策略服务器上。如果这个软件存在漏洞,那么潜在的破坏行为可能是显著的。
注意
您应避免配置准入 Webhook 来拦截针对 kube-system 命名空间的资源。该命名空间中的对象通常对集群的操作非常重要,意外的变异或拒绝这些对象可能会在集群中造成严重问题。
摘要
在本章中,我们涵盖了许多控制哪些对象被允许进入 Kubernetes 集群的方式。就像本书涵盖的许多关注点一样,每种方式都有其特定的权衡和决策,需要考虑到您的个人需求。准入控制是一个领域,需要更加仔细的检查和更深入的知识,因为它在集群和工作负载安全领域的应用非常广泛。
内置控制器提供了一组可靠的功能,但可能并不完全满足您的所有需求。我们预计越来越多的操作会转移到外部(out-of-tree)控制器,利用变异和验证 Webhook 的能力。在短期内,您可能会发现需要构建自己的 Webhook(无论是从头开始还是使用框架)来实现更复杂的功能。然而,随着类似 Gatekeeper 这样的广泛准入策略工具变得更加成熟,我们认为这是增加价值的关键点。
第九章:可观测性
观察任何软件系统的能力是至关重要的。如果您不能检查正在运行的应用程序的状态,您就无法有效地管理它们。这就是我们通过可观测性来解决的问题:我们用于理解我们负责的正在运行的软件状态的各种机制和系统。我们应该承认,在这种情况下,我们并未遵循控制理论对可观测性的定义。我们选择使用这个术语仅仅是因为它变得流行,并且我们希望人们能够轻松理解我们的意图。
可观测性的组成部分可以分为三类:
日志记录
聚合并存储由程序编写的日志事件消息
指标
收集时间序列数据,将其显示在仪表板上,并对其进行警报
追踪
捕获跨多个不同工作负载的请求数据
在本章中,我们将讨论如何在基于 Kubernetes 的平台中实现有效的可观测性,以便您可以安全地管理生产中的平台及其托管的工作负载。首先,我们将探讨日志记录,并检查聚合日志并将其转发到公司的日志后端的系统。接下来,我们将讨论如何收集指标、如何可视化数据以及如何对其进行警报。最后,我们将探讨通过分布式系统跟踪请求,以便更好地理解在由不同工作负载组成的应用程序运行时发生的情况。让我们开始日志记录,并覆盖那里通常成功的模型。
日志记录机制
本节涵盖了在基于 Kubernetes 的平台中的日志记录问题。我们主要处理从您的平台组件和租户工作负载捕获、处理和转发日志到存储后端的机制。
从前,我们在生产中运行的软件通常将日志写入磁盘上的文件中。聚合日志——如果有的话——是一个更简单的过程,因为工作负载较少,而且这些工作负载的实例也较少,与今天的系统相比。在容器化的世界中,我们的应用程序通常将日志记录到标准输出和标准错误输出,就像交互式命令行界面一样。事实上,即使在容器变得普遍之前,这已经成为现代面向服务的软件的最佳实践。在云原生软件生态系统中,有更多不同的工作负载和每个工作负载的实例,但它们也是短暂的,通常没有挂载磁盘以保存日志——因此,远离将日志写入磁盘的做法。这引入了在收集、聚合和存储日志方面的挑战。
通常,单个工作负载将具有多个副本,并且可能有多个不同的组件需要检查。如果没有集中式日志聚合,分析(查看和解析)此场景中的日志将变得非常繁琐,甚至几乎不可能。考虑必须分析具有数十个副本的工作负载的日志。在这些情况下,具有允许您跨副本搜索日志条目的中心收集点至关重要。
在讨论日志机制时,我们首先看一下在您的平台中捕获和路由容器化工作负载的日志策略。这包括 Kubernetes 控制平面和平台实用程序的日志,以及平台租户的日志。在本节中,我们还将涵盖 Kubernetes API 服务器审计日志以及 Kubernetes 事件。最后,我们将讨论在日志数据中发现条件时的警报概念以及替代策略。我们不会涵盖日志的存储,因为大多数企业都有一个我们将集成的日志后端,这通常不是基于 Kubernetes 平台本身的关注点。
容器日志处理
让我们看看基于 Kubernetes 平台的容器化工作负载的三种日志处理方式:
应用程序转发
直接从应用程序将日志发送到后端。
旁车处理
使用旁车来管理应用程序的日志。
节点代理转发
在每个节点上运行一个 Pod,为该节点上所有容器的日志转发到后端。
应用程序转发
在这种情况下,应用程序需要与后端日志存储集成。开发人员必须将此功能集成到他们的应用程序中并维护该功能。如果日志后端发生变化,可能需要更新应用程序。由于日志处理几乎是普遍存在的,因此将其从应用程序中卸载显得更加合理。在大多数情况下,应用程序转发并不是一个好的选择,并且在生产环境中很少见。只有在将遗留应用程序迁移到已经与日志后端集成的基于 Kubernetes 平台时,才显得合理。
旁车处理
在此模型中,应用程序在一个容器中运行,并将日志写入 Pod 的共享存储中的一个或多个文件。同一 Pod 中的另一个容器(旁车)读取这些日志并处理它们。旁车使用以下两种方式处理日志:
-
直接将它们转发到日志存储后端
-
将日志写入标准错误和标准输出
直接转发到后端是旁车日志处理的主要用例。这种方法并不常见,通常是一个临时解决方案,平台没有日志聚合系统的情况下使用。
在侧车将日志写入标准输出和标准错误时,为了利用节点代理转发(在下一节中介绍),它会这样做。这也是一种不常见的方法,只有在运行一个无法将日志写入标准输出和标准错误的应用程序时才有用。
节点代理转发
使用节点代理转发,集群中每个节点上都运行一个日志处理工作负载,读取容器运行时写入的每个容器的日志文件,并将日志转发到后端存储。
这是我们通常推荐的模型,迄今为止,也是最常见的实现。这是一个有用的模式,因为:
-
在日志转发器和后端之间有一个集成点,而不是不同的 sidecar 或应用程序必须维护该集成。
-
标准化过滤、附加元数据和转发到多个后端的配置是集中化的。
-
kubelet 或容器运行时负责处理日志轮换。如果应用程序在容器内部写入日志文件,则应用程序本身或 sidecar(如果有)需要处理日志轮换。
用于此节点代理日志转发的主流工具是 Fluentd 和 Fluent Bit。如其名称所示,它们是相关的项目。Fluentd 是原始的,主要由 Ruby 编写,并且有一个丰富的插件生态系统围绕它。Fluent Bit 是针对嵌入式 Linux 等环境的更轻量级解决方案的需求而来。它是用 C 编写的,内存占用比 Fluentd 小得多,但可用的插件数量没有那么多。
我们向平台工程师提供的一般指导是,在选择日志聚合和转发工具时,使用 Fluent Bit,除非 Fluentd 有引人注目的插件功能。如果发现需要利用 Fluentd 插件,请考虑将其作为集群范围的聚合器与 Fluent Bit 作为节点代理一起运行。在此模型中,您使用 Fluent Bit 作为节点代理,它作为 DaemonSet 部署。Fluent Bit 将日志转发到在集群中以 Deployment 或 StatefulSet 运行的 Fluentd。Fluentd 进行进一步的标记并将日志路由到一个或多个开发者访问的后端。图 9-1 说明了这种模式。

图 9-1. 从容器化应用程序聚合日志到后端。
虽然我们强烈推荐节点代理转发方法,但值得指出集中日志聚合可能出现的问题。这会为每个节点引入单点故障,或者如果在堆栈中使用集群范围的聚合器,那么整个集群会有单点故障。如果您的节点代理由于一个工作负载的过度记录而变得不堪重负,这可能会影响该节点上所有工作负载的日志收集。如果您在部署中运行 Fluentd 集群范围聚合器,它将使用其 Pod 中的临时存储层作为缓冲区。如果在它能够将缓冲区中的日志刷新之前被杀死,您将丢失日志。因此,考虑将其作为 StatefulSet 运行,以便在 Pod 停止时不会丢失这些日志。
Kubernetes 审计日志
本节介绍从 Kubernetes API 收集审计日志的方法。这些日志提供了一种查找集群中谁做了什么的方式。在生产环境中,您应该启用这些日志,以便在出现问题时进行根本原因分析。您可能还有要求的合规性。
API 服务器启用并配置了标志,允许您捕获发送到其的每个请求的每个阶段的日志,包括请求和响应体。实际情况下,您可能不希望记录每个请求。由于向 API 服务器发出大量调用,因此将有大量的日志条目需要存储。您可以使用审计策略中的规则来限定您希望 API 服务器为其编写日志的请求和阶段。如果没有任何审计策略,API 服务器实际上不会写入任何日志。使用--audit-policy-file标志告知 API 服务器您的审计策略位于控制平面节点的文件系统上。示例 9-1 展示了几个规则,说明了策略规则的工作原理,以便您可以限制日志信息的量,而不会排除重要数据。
示例 9-1. 示例审计策略
apiVersion: audit.k8s.io/v1
kind: Policy
rules:
- level: None 
users: ["system:kube-proxy"]
verbs: ["watch"]
resources:
- group: "" # core
resources: ["endpoints", "services", "services/status"]
- level: Metadata 
resources:
- group: ""
resources: ["secrets", "configmaps"]
- group: authentication.k8s.io
resources: ["tokenreviews"]
omitStages:
- "RequestReceived"
- level: Request 
verbs: ["get", "list", "watch"]
resources:
- group: ""
- group: "apps"
- group: "batch"
omitStages:
- "RequestReceived"
- level: RequestResponse 
resources:
- group: ""
- group: "apps"
- group: "batch"
omitStages:
- "RequestReceived"
# Default level for all other requests.
- level: Metadata 
omitStages:
- "RequestReceived"
None 审计级别意味着 API 服务器不会记录与此规则匹配的事件。因此,当用户 system:kube-proxy 请求对列出的资源进行监视时,该事件不会被记录。
Metadata 级别意味着只记录请求的元数据。当 API 服务器收到列出的资源的任何请求时,它将记录哪个用户对什么资源进行了什么类型的请求,但不记录请求或响应的正文。RequestReceived 阶段不会被记录。这意味着当首次接收请求时不会写入单独的日志条目。在开始长时间监视的响应后,它将写入日志条目。在完成向客户端的响应后,它将写入日志条目。并且将记录发生的任何 panic。但在首次接收请求时不会记录日志条目。
Request 级别将指示 API 服务器记录请求元数据和请求主体,但不记录响应主体。因此,当任何客户端发送获取、列表或观察请求时,可能包含对象的冗长响应主体不会被记录。
RequestResponse 级别记录了最多的信息:请求元数据、请求主体和响应主体。此规则列出了与之前相同的 API 组。因此,实际上,此规则表示如果请求不是用于这些组中的资源的获取、列表或观察,则还记录响应主体。实际上,这成为列出的组的默认日志级别。
对于未匹配到先前规则的任何其他资源,都将应用此默认值,该值表示在接收到请求时跳过附加日志消息,并仅记录请求元数据以及排除的请求和响应主体。
与系统中的其他日志一样,您希望将审计日志转发到某个后端。您可以使用本章前面介绍的应用程序转发或节点代理转发策略。许多相同的原则和模式适用。
对于应用程序转发方法,您可以配置 API 服务器将日志直接发送到 webhook 后端。在这种情况下,您需要通过标志告知 API 服务器配置文件所在位置,该配置文件包含连接地址和凭据。此配置文件使用 kubeconfig 格式。您需要花一些时间调整缓冲和批处理的配置选项,以确保所有日志都到达后端。例如,如果设置了太低的缓冲区大小以及溢出导致事件被丢弃。
对于节点代理转发,您可以让 API 服务器将日志文件写入控制平面节点上的文件系统。您可以向 API 服务器提供标志以配置文件路径、最长保留期、最大文件数和最大日志文件大小。在这种情况下,您可以使用 Fluent Bit 和 Fluentd 等工具聚合和转发日志。如果您已经使用这些工具管理先前讨论过的节点代理转发的日志,则这很可能是一个不错的模式。
Kubernetes 事件
在 Kubernetes 中,事件是一种原生资源。它们是平台组件通过 Kubernetes API 向不同对象公开发生情况的一种方式。实际上,它们是一种平台日志。与其他日志不同,它们通常不存储在日志后端。它们存储在 etcd 中,并且默认保留一个小时。平台操作员和用户通常在想要收集针对对象采取的操作信息时使用它们。示例 9-2 展示了描述新创建的 Pod 时提供的事件。
示例 9-2. 提供的带有 Pod 描述的事件
$ kubectl describe pod nginx-6db489d4b7-q8ppw
Name: nginx-6db489d4b7-q8ppw
Namespace: default
...
Events:
Type Reason Age From
---- ------ ---- ----
Message
-------
Normal Scheduled <unknown> default-scheduler
Successfully assigned default/nginx-6db489d4b7-q8ppw
Normal Pulling 34s kubelet, ip-10-0-0-229.us-east-2.compute.internal
Pulling image "nginx"
Normal Pulled 30s kubelet, ip-10-0-0-229.us-east-2.compute.internal
Successfully pulled image "nginx"
Normal Created 30s kubelet, ip-10-0-0-229.us-east-2.compute.internal
Created container nginx
Normal Started 30s kubelet, ip-10-0-0-229.us-east-2.compute.internal
Started container nginx
您也可以直接检索与示例 9-3 中显示的相同事件。在这种情况下,它包括直接检索到的命名空间的 ReplicaSet 和 Deployment 资源的事件,以及我们在描述该资源时看到的 Pod 事件。
示例 9-3. 直接检索的命名空间事件
$ kubectl get events -n default
LAST SEEN TYPE REASON OBJECT
MESSAGE
2m5s Normal Scheduled pod/nginx-6db489d4b7-q8ppw
Successfully assigned default/nginx-6db489d4b7-q8ppw
2m5s Normal Pulling pod/nginx-6db489d4b7-q8ppw
Pulling image "nginx"
2m1s Normal Pulled pod/nginx-6db489d4b7-q8ppw
Successfully pulled image "nginx"
2m1s Normal Created pod/nginx-6db489d4b7-q8ppw
Created container nginx
2m1s Normal Started pod/nginx-6db489d4b7-q8ppw
Started container nginx
2m6s Normal SuccessfulCreate replicaset/nginx-6db489d4b7
Created pod: nginx-6db489d4b7-q8ppw
2m6s Normal ScalingReplicaSet deployment/nginx
Scaled up replica set nginx-6db489d4b7 to 1
鉴于 Kubernetes 事件可通过 Kubernetes API 获取,完全可以构建自动化来监视和响应特定事件。然而,实际上,我们并不经常看到这样做。您还可以通过将其公开为指标的事件导出器利用它们。有关更多关于 Prometheus 导出器的信息,请参见“Prometheus”。
日志警报
应用程序日志公开了有关软件行为的重要信息。当发生需要调查的意外故障时,它们尤其有价值。这可能会使您发现导致问题的事件模式。如果您希望设置针对日志中公开的事件的警报,请首先考虑使用指标而不是日志。如果您公开代表该行为的指标,可以针对其实施警报规则。与日志消息相比,日志警报的可靠性较低,因为它们更容易发生变化。对日志消息文本的轻微更改可能会意外地破坏使用它的警报。
安全性影响
不要忘记考虑用户对后端聚合的各种日志的访问权限。您可能不希望生产 API 服务器审计日志对所有人都可访问。您可能拥有包含只有特权用户应该访问的信息的敏感系统。这可能会影响日志的标记或需要使用多个后端,从而影响您的转发配置。
现在我们已经讨论了管理平台及其租户日志涉及的各种机制,让我们继续讨论指标和警报。
指标
指标和警报服务对于平台的可用性至关重要。指标允许我们在时间轴上绘制测量数据,并识别表明不良或意外行为的偏差。它们帮助我们了解我们的应用程序发生了什么,通知我们它们是否按预期行为,并为我们提供有关如何解决问题或改进工作负载管理的见解。并且,关键是,指标为我们提供了有用的测量来进行警报。失败的通知,或者更好地说是即将发生的失败的警告,给了我们避免和/或最小化停机时间和错误的机会。
在本节中,我们将介绍如何使用 Prometheus 作为平台服务提供度量和警报功能。这里有很多细节需要探讨,我们在进行时参考特定的软件堆栈将会很有帮助。这并不意味着你不能或不应该使用其他解决方案。在许多情况下,Prometheus 可能不是正确的解决方案。然而,Prometheus 确实提供了一个优秀的模型来解决这个主题。无论你使用的具体工具是什么,Prometheus 模型都为你提供了一个清晰的实现参考,将指导你如何处理这个主题。
首先,我们将简要介绍 Prometheus 是什么,它如何收集度量标准以及它提供的功能。然后我们将讨论各种一般性子主题,包括长期存储和推送度量标准的用例。接下来,我们将讨论自定义度量生成和收集,以及跨你的基础设施组织和联合度量收集。此外,我们将深入探讨警报功能,并使用度量标准进行可操作的展示和费用分摊数据。最后,我们将详细解析 Prometheus 堆栈的各个组件,并说明它们如何配合工作。
Prometheus
Prometheus 是一种开源度量工具,已成为基于 Kubernetes 平台的普遍开源解决方案。控制平面组件公开 Prometheus 度量标准,并且几乎每个生产集群都使用 Prometheus 导出器从底层节点等获取度量标准。因此,许多企业系统,如 Datadog、New Relic 和 VMware Tanzu Observability,支持消耗 Prometheus 度量标准。
Prometheus 度量标准只是一种用于时间序列数据的标准格式,实际上可以被任何系统使用。Prometheus 采用抓取模型,通过它从目标中收集度量标准。因此,应用程序和基础设施通常不会将度量标准发送到任何地方,它们在端点上公开度量标准,从中 Prometheus 可以抓取它们。这种模型消除了应用程序除了要以何种格式呈现数据外,对度量系统了解任何内容的责任。
采用这种度量收集模型,它能够处理大量数据,其数据模型中使用标签以及 Prometheus 查询语言(PromQL),使其成为动态、云原生环境中的优秀度量工具。可以轻松引入和监控新的工作负载。从应用程序或系统公开 Prometheus 度量标准,向 Prometheus 服务器添加抓取配置,并使用 PromQL 将原始数据转化为有意义的洞察和警报。这些都是 Prometheus 成为 Kubernetes 生态系统中如此受欢迎选择的核心原因之一。
Prometheus 提供了几个关键的度量功能:
-
使用其抓取模型从目标中收集度量标准
-
将度量标准存储在时间序列数据库中
-
根据警报规则发送警报,通常发送到稍后在本章讨论的 Alertmanager
-
Prometheus为其他组件提供了 HTTP API,以访问其存储的指标数据。
-
提供了一个仪表板,可用于执行临时指标查询并获取各种状态信息。
大多数团队在开始时使用 Prometheus 进行指标收集,并结合 Grafana 进行可视化。然而,在生产环境中,对系统的有组织使用可能会对较小的团队构成挑战。你将不得不解决指标的长期存储问题,随着指标数量的增长而扩展 Prometheus,以及组织指标系统的联合。这些问题都不容易解决,并随着时间的推移需要持续管理。因此,如果随着系统规模扩展,指标堆栈的维护变得繁琐,你可以迁移到其中一个商业系统,而无需更改使用的指标类型。
长期存储
值得注意的是,Prometheus 并不适用于指标的长期存储。相反,它支持写入远程端点,并且有多种解决方案可用于此类集成。在提供应用程序平台的一部分作为指标解决方案时,你需要回答的问题涉及数据保留。你是否只在生产环境提供长期存储?如果是,在非生产环境中 Prometheus 层面提供哪些保留期?如何向用户展示长期存储中的指标?像Thanos和Cortex这样的项目提供了工具堆栈来帮助解决这些问题。只需记住你的平台租户如何能够利用这些系统,并让他们知道可以期待什么保留策略。
推送指标
并非所有的工作负载都适合抓取模型。在这些情况下,Prometheus Pushgateway可能会被使用。例如,一个在完成工作后关闭的批处理工作负载可能不会给 Prometheus 服务器收集所有指标的机会,因此这种情况下,批处理工作负载可以将其指标推送到 Pushgateway,Pushgateway 会将这些指标暴露给 Prometheus 服务器来检索。因此,如果您的平台支持需要这种支持的工作负载,您可能需要将 Pushgateway 部署为您的指标堆栈的一部分,并发布信息以便租户利用它。他们需要知道它在集群中的位置以及如何使用其类似 REST 的 HTTP API。图 9-2 展示了一个短暂工作负载利用 Prometheus 客户端库将指标推送到 Pushgateway 的示例。这些指标然后由 Prometheus 服务器抓取。

图 9-2. 短暂工作负载使用的 Pushgateway。
自定义指标
Prometheus 指标可以被应用程序本地暴露。许多专为基于 Kubernetes 平台运行的应用程序专门开发的应用程序就是这样做的。有几个官方支持的客户端库,以及一些社区支持的库。使用这些库,你的应用开发人员可能会发现轻松地为抓取暴露自定义 Prometheus 指标。这在第十四章中有详细讨论。
或者,当应用程序或系统不支持 Prometheus 指标时,可以使用导出器。导出器从应用程序或系统收集数据点,然后将其作为 Prometheus 指标暴露出来。一个常见的例子是 Node Exporter。它收集硬件和操作系统指标,然后将这些指标暴露给 Prometheus 服务器进行抓取。有一些社区支持的导出器适用于各种流行工具,你可能会发现其中一些有用。
一旦部署了暴露自定义指标的应用程序,下一步就是将该应用程序添加到 Prometheus 服务器的抓取配置中。这通常通过 Prometheus Operator 使用的 ServiceMonitor 自定义资源来完成。有关 Prometheus Operator 的更多信息,请参阅“指标组件”,但现在知道可以使用自定义的 Kubernetes 资源来指示操作员基于它们的命名空间和标签自动发现服务就足够了。
简而言之,在可能的情况下,对你开发的软件进行内部仪表化。在无法进行本地仪表化时,开发或利用导出器。并使用方便的自动发现机制收集暴露的指标,以提供对系统的可见性。
警告
虽然在 Prometheus 数据模型中使用标签是强大的,但权力与责任同在。你可能会因此自讨苦吃。如果过度使用标签,你的 Prometheus 服务器的资源消耗可能会变得难以承受。熟悉指标高基数的影响,并查阅 Prometheus 文档中的仪表化指南。
组织与联邦
处理指标可能特别耗费计算资源,因此将这种计算负载细分可以帮助管理 Prometheus 服务器的资源消耗。例如,可以使用一个 Prometheus 服务器收集平台的指标,使用其他 Prometheus 服务器收集应用程序或节点指标的自定义指标。在更大的集群中特别适用,那里有更多的抓取目标和更大量的指标需要处理。
然而,这样做会导致可以查看数据的位置分散。解决这个问题的一种方法是通过联邦。总体而言,联邦是指将数据和控制集中到一个中心化系统中。Prometheus 联邦涉及将来自各种 Prometheus 服务器的重要指标收集到一个中央 Prometheus 服务器中。这通过与用于收集工作负载指标的相同抓取模型完成。Prometheus 服务器可以从另一个 Prometheus 服务器中抓取指标的目标之一。
这可以在单个 Kubernetes 集群中进行,也可以在多个 Kubernetes 集群之间进行。这提供了非常灵活的模型,可以根据管理 Kubernetes 集群的模式组织和整合您的指标系统。包括分层或分级的联邦。图 9-3 展示了一个全局 Prometheus 服务器从不同数据中心的 Prometheus 服务器中抓取指标,这些服务器反过来从其集群中的目标中抓取指标的示例。

图 9-3. Prometheus 联邦。
尽管 Prometheus 联邦功能强大且灵活,但管理起来可能复杂且繁重。一种相对较新的解决方案,可以有效地从所有 Prometheus 服务器收集指标,是 Thanos,一个在 Prometheus 基础上构建联邦功能的开源项目。Prometheus Operator 支持 Thanos,并可以在现有的 Prometheus 安装上进行扩展。Cortex 是另一个在 CNCF 中有前景的项目。Thanos 和 Cortex 都是 CNCF 中的孵化项目。
仔细规划 Prometheus 服务器的组织和联邦,以支持平台采用增长时的扩展和扩展操作。对于租户的消费模型要进行仔细考虑。避免他们使用多种不同的仪表板来访问其工作负载的指标。
警报
Prometheus 使用警报规则从指标生成警报。当警报触发时,通常会发送到配置的 Alertmanager 实例。使用 Prometheus Operator 部署 Alertmanager 并配置 Prometheus 将警报发送到 Alertmanager 时相对不复杂。Alertmanager 将处理警报并与消息系统集成,以便通知工程师存在的问题。图 9-4 展示了将平台控制面和租户应用的不同 Prometheus 服务器使用共同的 Alertmanager 处理警报并通知接收者的情况。

图 9-4. 警报组件。
总体而言,要小心不要过度报警。过多的关键警报会使您的值班工程师感到疲惫不堪,并且虚假阳性的噪音可能会淹没实际的关键事件。因此,请花时间调整警报以使其有用。在警报的注释中添加有用的描述,以便工程师在接到问题警报时能够理解情况。考虑包含指向运行手册或其他文档的链接,以帮助解决警报事件。
除了平台的警报外,还要考虑如何向您的租户公开警报,以便他们可以针对其应用程序的指标设置警报。这包括为他们提供向 Prometheus 添加警报规则的方法,更详细地介绍在“度量组件”中。还包括通过 Alertmanager 设置通知机制,以便应用团队根据他们设置的规则接收警报。
死亡开关
有一个特别的警报值得关注,因为它具有普遍适用性并且尤为关键。如果您的度量和警报系统出现故障会发生什么?您将如何收到那个事件的警报?在这种情况下,您需要设置一个在正常运行条件下定期触发的警报,如果这些警报停止,则发出关键警报,让值班人员知道您的度量和/或警报系统已经停止运行。PagerDuty提供了一个名为 Dead Man’s Snitch的集成,提供了这个功能。或者,您可以设置一个使用 Webhook 警报到您安装的系统的自定义解决方案。无论具体实现细节如何,请确保在警报系统离线时能够紧急通知您。
Showback 和 Chargeback
秀回是一种常用术语,用于描述组织单位或其工作负载的资源使用情况。成本回收是将这种资源使用与成本关联起来。这些是有意义的、可操作的指标数据的完美例子。
Kubernetes 提供了动态管理应用开发团队使用的计算基础设施的机会。如果这些即时可用的资源没有得到有效管理,可能会出现集群扩展和资源利用率低的问题。对于企业来说,优化部署基础设施和工作负载的流程非常有利。然而,这种优化也可能导致浪费。因此,许多组织都要求他们的团队和业务部门对使用情况进行秀回和成本回收的账务管理。
为了能够收集相关指标,工作负载需要用一些有用的标签标记,例如“团队”或“所有者”名称或标识符。我们建议在您的组织中建立一个标准化的系统,并使用准入控制来强制平台租户部署的所有 Pod 使用这样的标签。偶尔还有其他有用的识别工作负载的方法,例如通过命名空间,但标签是最灵活的。
实施显示回溯的两种一般方法:
请求
请求基于团队为 Pod 中每个容器定义的资源请求来预留的资源。
资源消耗
消耗基于团队实际通过资源请求或实际使用的内容,以较高者为准。
按请求进行显示回溯
基于请求的方法利用了工作负载定义的聚合资源请求。例如,如果一个具有 10 个副本的 Deployment 每个副本请求 1 个 CPU 核心,那么认为它在运行时每个单位时间使用了 10 个核心。在这种模型中,如果一个工作负载突破了其请求并且平均每个副本使用了 1.5 个核心,那么额外消耗的 5 个核心不会归属于工作负载。这种方法的优势在于它基于调度器可以在集群中的节点上分配的资源。调度器将资源请求视为节点上的预留容量。如果一个节点有未使用的空闲资源,并且工作负载突破了使用了这些本来未被使用的容量,那么该工作负载就免费获得了这些资源。在图 9-5 中的实线表示使用此方法的工作负载的 CPU 资源。超过请求的消耗是不归属的。

图 9-5. 基于 CPU 请求的显示回溯。
按消耗进行显示回溯
在基于消耗的模型中,一个工作负载将被分配其资源请求的使用量或其实际使用量中较高的那个。采用这种方法,如果一个工作负载通常并一贯地使用超过其请求的资源,它将显示出实际消耗的资源。这种方法将消除通过设置低资源请求来规避系统的可能激励因素。这可能更可能导致超负荷节点上的资源争用。在图 9-6 中的实线表示使用这种基于消耗的方法分配给工作负载的 CPU 资源。在这种情况下,超过请求的消耗是被归因的。

图 9-6. 基于 CPU 消耗的显示回溯,当超出请求时。
在“指标组件”中,我们将介绍 kube-state-metrics,这是一个提供与 Kubernetes 资源相关的指标的平台服务。如果您将 kube-state-metrics 作为指标堆栈的一部分使用,您将可以获取以下资源请求的指标:
-
CPU:
kube_pod_container_resource_requests -
内存:
kube_pod_container_resource_requests_memory_bytes
可通过以下度量指标获取资源使用情况:
-
CPU:
container_cpu_usage_seconds_total -
内存:
container_memory_usage_bytes
最后,在展示时,您应该决定是使用 CPU 还是内存来确定工作负载的展示。为此,请计算工作负载占集群资源总量的百分比,分别计算 CPU 和内存。应用较高的值,因为如果集群的 CPU 或内存用尽,则无法托管更多工作负载。例如,如果一个工作负载使用集群 CPU 的 1%和集群内存的 3%,那么它实际上使用了集群的 3%,因为没有更多内存的集群无法托管更多工作负载。这也将帮助您决定是否应使用不同的节点配置文件来匹配它们托管的工作负载,这一点在“基础设施”中有讨论。
收费
一旦我们解决了展示成本,由于我们有了应用成本的指标,因此收费变得可能。如果使用公共云提供商,机器的成本通常会非常直接。如果您购买自己的硬件,可能会复杂一些,但不管怎样,您都需要提出两个成本值:
-
每单位 CPU 的时间成本
-
每单位内存的时间成本
将这些成本应用到确定的展示价值上,您就有了一个内部向平台租户收费的模型。
网络和存储
到目前为止,我们已经研究了工作负载使用的计算基础设施的展示和收费。这涵盖了我们在现场看到的大多数用例。然而,有些工作负载消耗大量的网络带宽和磁盘存储。这种基础设施可以在运行某些应用程序的真实成本中起到重要作用,并且在这些情况下应予考虑。模型将大体相同:收集相关指标,然后决定是按照预留的资源、消耗的资源,还是两者的组合进行收费。如何收集这些指标将取决于用于该基础设施的系统。
到目前为止,我们已经介绍了 Prometheus 的工作原理以及您在深入部署组件详细信息之前应掌握的一般主题。接下来是对在 Prometheus 指标堆栈中常用的那些组件进行介绍。
指标组件
在本节中,我们将检查部署和管理指标堆栈的一个非常常见的方法中的组件。我们还将介绍一些可供您使用的管理工具及其如何组合在一起。图 9-7 展示了 Prometheus 指标堆栈中组件的常见配置。它不包括 Prometheus 操作员,后者是用于部署和管理此堆栈的实用程序,而不是堆栈本身的一部分。该图包括一些自动缩放组件,以说明 Prometheus 适配器的角色,即使这里不涵盖自动缩放。有关该主题的详细信息,请参阅第十三章。

图 9-7。Prometheus 指标堆栈中的常见组件。
Prometheus 操作员
Prometheus Operator 是一个 Kubernetes 操作器,帮助部署和管理用于平台本身及租户工作负载的 Kubernetes 度量系统的各个组件。关于 Kubernetes 操作器的更多信息,请参见“操作器模式”。Prometheus Operator 使用多个自定义资源来表示 Prometheus 服务器:Alertmanager 部署、抓取配置(通知 Prometheus 要从中抓取指标的目标)以及用于记录指标和对其进行警报的规则。这极大地减少了在平台中部署和配置 Prometheus 服务器时的工作量。
这些自定义资源对平台工程师非常有用,同时也为您的平台租户提供了非常重要的接口。如果他们需要专用的 Prometheus 服务器,可以通过向其命名空间提交 Prometheus 资源来实现。如果他们需要向现有的 Prometheus 服务器添加警报规则,可以通过 PrometheusRule 资源实现。
相关的kube-prometheus 项目是使用 Prometheus Operator 的绝佳起点。它提供了用于完整度量堆栈的一组清单。它包括 Grafana 仪表板配置,可以方便地进行实用的可视化。但是将其视为一个起点,并理解系统,以便根据您的需求来塑造它,使其适合您的要求,以便在生产中对您的系统进行全面的度量和警报。
本节的其余部分涵盖了您将在 kube-prometheus 部署中获得的组件,以便您可以清楚地理解和根据自身需求定制这些组件。
Prometheus 服务器
在您的集群中使用 Prometheus Operator,您可以创建 Prometheus 自定义资源,以促使操作员为 Prometheus 服务器创建一个新的 StatefulSet。示例 9-4 是用于 Prometheus 资源的示例清单。
示例 9-4. Prometheus 清单示例
apiVersion: monitoring.coreos.com/v1
kind: Prometheus
metadata:
name: platform
namespace: platform-monitoring
labels:
monitor: platform
owner: platform-engineering
spec:
alerting: 
alertmanagers:
- name: alertmanager-main
namespace: platform-monitoring
port: web
image: quay.io/prometheus/prometheus:v2.20.0 
nodeSelector:
kubernetes.io/os: linux
replicas: 2
resources:
requests:
memory: 400Mi
ruleSelector: 
matchLabels:
monitor: platform
role: alert-rules
securityContext:
fsGroup: 2000
runAsNonRoot: true
runAsUser: 1000
serviceAccountName: platform-prometheus
version: v2.20.0
serviceMonitorSelector: 
matchLabels:
monitor: platform
告知 Prometheus 的配置以便发送警报。
Prometheus 使用的容器镜像。
告知 Prometheus Operator 适用于此 Prometheus 服务器的 PrometheusRules。任何带有这些标签的 PrometheusRule 将被应用于此 Prometheus 服务器。
对于 ServiceMonitors,这与ruleSelector对 PrometheusRules 的作用相同。任何具有此标签的 ServiceMonitor 资源都将用于通知此 Prometheus 服务器的抓取配置。
Prometheus 自定义资源允许平台操作员轻松部署 Prometheus 服务器以收集指标。如 “组织和联邦” 所述,在任何给定的集群中,将指标收集和处理负载分割到多个 Prometheus 部署中可能是有用的。这种模型依赖于使用自定义 Kubernetes 资源来快速部署 Prometheus 服务器的能力。
在某些用例中,使用 Prometheus Operator 快速部署 Prometheus 服务器的能力也对平台租户公开是有帮助的。一个团队的应用程序可能会产生大量指标,这将超出现有 Prometheus 服务器的处理能力。您可能希望将团队的指标收集和处理包括在其资源预算中,因此在其命名空间中拥有一个专用的 Prometheus 服务器可能是一个有用的模型。并非每个团队都愿意采用这种部署和管理自己 Prometheus 资源的方式。许多团队可能需要进一步的抽象细节,但这是一个可以考虑的选项。如果采用这种模型,请不要忽视这将为仪表板和指标收集的警报,以及联邦和长期存储引入的额外复杂性。
部署 Prometheus 服务器只是一回事,但对它们的配置进行持续管理是另一回事。为此,Prometheus Operator 还有其他自定义资源,最常见的是 ServiceMonitor。当创建一个 ServiceMonitor 资源时,Prometheus Operator 会更新相关 Prometheus 服务器的抓取配置。示例 9-5 展示了一个 ServiceMonitor,将创建一个抓取配置,用于从 Kubernetes API 服务器收集 Prometheus 的指标。
示例 9-5. ServiceMonitor 资源的示例清单
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
labels:
k8s-app: apiserver
monitor: platform 
name: kube-apiserver
namespace: platform-monitoring
spec:
endpoints: 
- bearerTokenFile: /var/run/secrets/kubernetes.io/serviceaccount/token
interval: 30s
port: https
scheme: https
tlsConfig:
caFile: /var/run/secrets/kubernetes.io/serviceaccount/ca.crt
serverName: kubernetes
jobLabel: component 
namespaceSelector: 
matchNames:
- default
selector: 
matchLabels:
component: apiserver
provider: kubernetes
这是 示例 9-1 中 Prometheus 清单中 serviceMonitorSelector 所指的标签。
endpoints 提供有关要使用的端口以及如何连接 Prometheus 将从中抓取指标的实例的配置。本示例指示 Prometheus 使用 HTTPS 进行连接,并提供证书颁发机构和服务器名称来验证连接端点。
在 Prometheus 的术语中,“job” 是服务实例的集合。例如,单独的 apiserver 就是一个“实例”。集群中的所有 apiserver 共同构成一个“job”。此字段指示哪个标签包含应在 Prometheus 中用于 job 的名称。在这种情况下,job 将是 apiserver。
namespaceSelector 指示 Prometheus 在哪些命名空间中查找要为此目标抓取指标的服务。
selector 通过 Kubernetes Service 上的标签实现服务发现。换句话说,任何包含指定标签的 Service(在默认命名空间中)都将用于查找要从中抓取指标的目标。
Prometheus 服务器中的抓取配置也可以通过 PodMonitor 资源来管理,用于监控一组 Pods(而不是通过 ServiceMonitor 监控的服务),以及用于监控 Ingresses 或静态目标的 Probe 资源。
PrometheusRule 资源指示操作员生成一个规则文件,其中包含用于记录指标和基于指标发出警报的规则。 示例 9-6 展示了一个 PrometheusRule 配置文件的示例,其中包含一个记录规则和一个警报规则。 这些规则将放置在 ConfigMap 中,并挂载到 Prometheus 服务器的 Pod/s 中。
示例 9-6. PrometheusRule 资源的示例配置文件
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
labels:
monitor: platform
role: alert-rules 
name: sample-rules
namespace: platform-monitoring
spec:
groups:
- name: kube-apiserver.rules
rules:
- expr: | 
sum by (code,resource) (rate(
apiserver_request_total{job="apiserver",verb=~"LIST|GET"}[5m]
))
labels:
verb: read
record: code_resource:apiserver_request_total:rate5m
- name: kubernetes-apps
rules:
- alert: KubePodNotReady 
annotations:
description: Pod {{ $labels.namespace }}/{{ $labels.pod }} has been in a
non-ready state for longer than 15 minutes.
summary: Pod has been in a non-ready state for more than 15 minutes.
expr: |
sum by (namespace, pod) (
max by(namespace, pod) (
kube_pod_status_phase{job="kube-state-metrics", phase=~"Pending|Unknown"}
) * on(namespace, pod) group_left(owner_kind) topk by(namespace, pod) (
1, max by(namespace, pod, owner_kind) (kube_pod_owner{owner_kind!="Job"})
)
) > 0
for: 15m
labels:
severity: warning
这是 示例 9-1 中 Prometheus 配置文件中 ruleSelector 所引用的标签。
这是一个关于在 5 分钟内所有 Kubernetes API 服务器实例的总 LIST 和 GET 请求的记录规则示例。 它使用 API 服务器暴露的 apiserver_request_total 指标上的表达式,并存储一个名为 code_resource:apiserver_request_total:rate5m 的新指标。
如果任何 Pod 处于非就绪状态超过 15 分钟,这里是一个警报规则,将提示 Prometheus 发送警报。
使用 Prometheus Operator 和这些自定义资源来管理 Prometheus 服务器及其配置已被证明是一种非常有用的模式,并在该领域中变得非常普遍。 如果您将 Prometheus 作为主要指标工具使用,我们强烈推荐使用它。
Alertmanager
下一个主要组件是 Alertmanager。 这是一个独立的工作负载,用于处理警报并将其路由到构成与值班工程师通信的接收者。 Prometheus 具有警报规则,以响应可测量条件触发警报。 这些警报会发送到 Alertmanager,那里它们将被分组和去重,以避免在影响多个副本或组件的故障发生时向人员发送大量警报。 然后通过配置的接收者发送通知。 接收者是支持的通知系统,如电子邮件、Slack 或 PagerDuty。 如果要实现不受支持或自定义的通知系统,Alertmanager 有一个 webhook 接收器,允许您提供一个 URL,Alertmanager 将向其发送带有 JSON 负载的 POST 请求。
使用 Prometheus Operator 时,可以通过配置文件部署一个新的 Alertmanager,就像 示例 9-7 中所示。
示例 9-7. Alertmanager 资源的示例配置文件
apiVersion: monitoring.coreos.com/v1
kind: Alertmanager
metadata:
labels:
alertmanager: main
name: main
namespace: platform-monitoring
spec:
image: quay.io/prometheus/alertmanager:v0.21.0
nodeSelector:
kubernetes.io/os: linux
replicas: 2 
securityContext:
fsGroup: 2000
runAsNonRoot: true
runAsUser: 1000
serviceAccountName: alertmanager-main
version: v0.21.0
可能需要多个副本来请求以高可用配置部署 Alertmanager。
虽然这个自定义资源为您提供了非常方便的方法来部署 Alertmanager 实例,但在集群中几乎没有必要部署多个 Alertmanager,特别是因为它可以部署在高可用配置中。您可以考虑为多个集群使用一个集中式 Alertmanager,但每个集群一个 Alertmanager 是明智的,因为这样可以减少每个集群的外部依赖。利用集群的公共 Alertmanager 为租户提供了使用单个 PrometheusRule 资源配置其应用程序的新警报规则的机会。在这种模型中,每个 Prometheus 服务器被配置为向集群的 Alertmanager 发送警报。
Grafana
对于平台操作员能够推理出在基于复杂 Kubernetes 的平台中发生的情况至关重要,关键是从存储在 Prometheus 中的数据构建图表和仪表板。Grafana是一个开源的可视化层,已成为查看 Prometheus 指标的默认解决方案。kube-prometheus 项目提供了多种仪表板作为基础和起点,更不用说社区中还有许多其他可用的仪表板。当然,您也可以自由地构建自己的图表,以显示您管理的任何系统的时间序列数据的情况。
对于应用团队来说,可视化指标也是至关重要的。这与您如何部署您的 Prometheus 服务器相关。如果您在集群中利用多个 Prometheus 实例,那么您如何向平台的租户公开收集的指标呢?一方面,为每个 Prometheus 服务器添加一个 Grafana 仪表板可能是一个有用的模式。这可能会提供方便的关注点分离。然而,另一方面,如果用户发现他们不得不经常登录到多个不同的仪表板,这可能会很麻烦。在这种情况下,您有两个选择:
-
使用联邦机制从不同服务器收集指标并汇总到单一服务器,然后为一组系统的指标访问添加仪表盘。这是像 Thanos 这样的项目所采用的方法。
-
将多个数据源添加到单个 Grafana 仪表板。在这种情况下,一个单一的仪表板展示了来自多个 Prometheus 服务器的指标。
选择要么在联邦 Prometheus 实例中增加复杂性,要么管理更复杂的 Grafana 配置。还要考虑使用联邦服务器选项时的额外资源消耗,但如果可以接受,这主要是一种偏好问题。
如果您在集群中使用单个 Prometheus 服务器,并且您的平台操作员和租户都去同一个地方获取指标,那么您需要考虑关于查看和编辑仪表盘的权限。您可能需要根据您的用例适当地配置组织、团队和用户。
节点导出器
Node exporter 是通常作为 Kubernetes DaemonSet 运行的节点代理,用于收集机器和操作系统的度量标准。它提供主机级别的 CPU、内存、磁盘 I/O、磁盘空间、网络统计和文件描述符信息等度量标准,默认情况下收集的仅是其中的一部分。正如前面提到的,这是一个导出器的最常见示例之一。Linux 系统并不原生导出 Prometheus 度量标准,节点导出器知道如何从内核收集这些度量标准,然后将其公开供 Prometheus 抓取。任何时候您想要通过 Prometheus 监控类 Unix 系统的系统和硬件时,它都非常有用。
kube-state-metrics
kube-state-metrics 提供与各种 Kubernetes 资源相关的度量标准。它本质上是一个导出器,用于从 Kubernetes API 收集资源信息。例如,kube-state-metrics 公开了 Pod 的启动时间、状态、标签、优先级类、资源请求和限制等信息;这些信息通常需要使用 kubectl get 或 kubectl describe 命令来收集。这些度量标准对于检测关键集群条件非常有用,例如卡在崩溃循环中的 Pod 或接近资源配额的命名空间。
Prometheus 适配器
Prometheus 适配器 在此处包括,因为它是 kube-prometheus 堆栈的一部分。然而,它不是一个导出器,也不涉及 Prometheus 的核心功能。相反,Prometheus 适配器是 Prometheus 的一个客户端。它从 Prometheus API 中检索度量标准,并通过 Kubernetes 的度量标准 API 提供给用户。这使得工作负载的自动伸缩功能成为可能。更多关于自动伸缩的信息,请参考 第十三章。
如您所见,构建生产级别的度量标准和警报系统有许多组件。我们已经看过如何通过 Prometheus 和 kube-prometheus 堆栈(包括 Prometheus Operator)来实现这一点,以减轻管理这些问题的繁琐工作。既然我们已经涵盖了日志和度量标准,现在让我们来看一下追踪。
分布式追踪
追踪通常指一种特殊的事件捕获,用于跟踪执行路径。尽管追踪可以适用于单个软件,但本节讨论的是跨多个工作负载的分布式追踪,用于微服务架构中跟踪请求。已经采用分布式系统的组织从这项技术中获益良多。在本节中,我们将讨论如何将分布式追踪作为平台服务提供给您的应用团队。
分布式追踪与日志记录和指标的重要区别在于,应用程序和平台之间的追踪技术必须兼容。只要应用程序将日志记录到 stdout 和 stderr,平台服务聚合日志并不关心应用程序内部如何编写日志。而常见的指标如 CPU 和内存消耗可以从工作负载中收集,而无需专门的仪表化。然而,如果一个应用程序使用的客户端库与平台提供的追踪系统不兼容,追踪将根本无法工作。因此,在这一领域,平台和应用程序开发团队之间的紧密协作至关重要。
在讨论分布式追踪时,我们将首先看一下 OpenTracing 和 OpenTelemetry 规范,以及在讨论追踪时使用的一些术语。然后,我们将涵盖一些流行的用于追踪的项目中常见的组件。之后,我们将触及启用追踪所需的应用程序仪表化,以及使用服务网格的影响。
OpenTracing 和 OpenTelemetry
OpenTracing是用于分布式追踪的开源规范,有助于生态系统在实现标准上达成共识。该规范围绕三个重要概念展开,这些概念对理解追踪至关重要:
追踪
当分布式应用程序的最终用户发出请求时,该请求会经过处理请求并参与满足客户请求的不同服务。追踪表示整个事务,并且是我们有兴趣分析的实体。一个追踪由多个跨度组成。
跨度
处理请求的每个不同服务代表一个跨度。在工作负载边界内发生的操作构成了一个跨度,它们是追踪的一部分。
标签
标签是附加到跨度的元数据,用于在追踪中对其进行上下文化,并提供可搜索的索引。
当可视化追踪时,它们通常会包括每个单独的追踪,并明确指出系统中哪些组件对性能影响最大。它们还有助于追踪错误发生的位置及其如何影响应用程序的其他组件。
最近,OpenTracing 项目与 OpenCensus 合并形成了OpenTelemetry。在撰写本文时,OpenTelemetry 的支持在 Jaeger 中仍处于实验阶段,这是对采纳的公正指标,但可以合理预期 OpenTelemetry 将成为事实标准。
追踪组件
要将分布式跟踪作为平台服务提供,需要一些平台组件。我们将在这里讨论的模式适用于开源项目,如Zipkin和Jaeger,但这些模型通常也适用于其他实现 OpenTracing 标准的项目和商业支持产品。
代理人
分布式应用程序中的每个组件将为每个处理的请求输出一个跨度。代理充当应用程序可以向其发送跨度信息的服务器。在基于 Kubernetes 的平台中,通常会有一个节点代理运行在集群中的每台机器上,并接收该节点上工作负载的所有跨度。代理将批处理后的跨度转发到中央收集器。
收集器
收集器处理这些跨度并将它们存储在后端数据库中。它负责验证、索引和执行任何转换,然后将跨度持久化到存储中。
存储
支持的数据库因项目而异,但通常会选择Cassandra和Elasticsearch。即使在采样时,分布式跟踪系统也会收集大量数据。使用的数据库需要能够处理和快速搜索这些大量的数据,以产生有用的分析。
API
正如你可能期望的那样,下一个组件是一个 API,允许客户端访问存储的数据。它向其他工作负载或可视化层公开跟踪和它们的跨度。
用户界面
这是你平台租户的实际情况。这个可视化层查询 API 并向应用开发人员显示收集的数据。工程师可以在这里查看有用的图表,分析他们的系统和分布式应用程序。
图 9-8 说明了这些跟踪组件及其彼此之间的关系,以及常见的部署方法。

图 9-8. 跟踪平台服务的组件。
应用程序工具化
为了将这些跨度收集并归纳到跟踪中,应用程序必须被工具化以提供这些信息。因此,从你的应用开发团队那里获取认同是至关重要的。如果应用程序没有提供所需的原始数据,即使是世界上最好的跟踪平台服务也是无用的。第十四章更深入地讨论了这个主题。
服务网格
如果使用服务网格,您可能希望将网格数据包含在您的追踪中。服务网格实现了代理,用于处理进出工作负载的请求,获取这些代理的时间跟踪跨度有助于理解它们如何影响性能。请注意,即使使用服务网格,您的应用程序仍需要进行仪表化。请求头需要通过追踪从一个服务请求传播到下一个服务请求。服务网格在第六章中有详细介绍。
摘要
可观测性是平台工程的核心关注点。可以说,如果没有解决可观测性问题,任何应用平台都无法被视为生产就绪。作为基线,确保能够可靠地从容器化工作负载中收集日志,并将其与来自 Kubernetes API 服务器的审计日志一起转发到日志后端。还需考虑指标和警报作为最低要求。收集 Kubernetes 控制平面暴露的指标,将其显示在仪表板上,并对其进行警报。与应用开发团队合作,为其应用程序添加指标仪表化,以便在适用时暴露和收集这些指标。最后,如果您的团队已经采用了微服务架构,还需与应用开发团队合作,为其应用程序添加追踪功能,并安装平台组件以利用这些信息。有了这些系统,您将能够看到运行情况并优化操作,以改善性能和稳定性。
第十章:身份
在设计和实施 Kubernetes 平台时,确立用户和应用程序工作负载的身份是一个关键问题。没有人希望因系统被入侵而登上新闻头条。因此,我们必须确保只有适当特权的实体(人类或应用程序)可以访问特定的系统或执行某些操作。为此,我们需要确保已经实施了认证和授权系统。作为复习:
-
“认证”是建立应用程序或用户身份的过程。
-
“授权”是在认证后确定应用程序或用户能够执行什么操作的过程。
本章专注于认证。这并不意味着授权不重要,我们会在适当的地方简要提及。欲了解更多信息,您应当详细研究 Kubernetes 中的基于角色的访问控制(RBAC)(有许多优秀的资源可用),并确保为您自己的应用程序实施一个坚固的策略,以便了解您可能部署的任何外部应用程序所需的权限。
为了认证的目的,建立身份是几乎每个分布式系统的关键要求。每个人都用过的一个简单例子是用户名和密码。这些信息一起识别你作为系统的用户。因此,在这种背景下,“身份”需要具备几个属性:
-
它需要是可验证的。如果用户输入他们的用户名和密码,我们需要能够访问数据库或真实数据源,比对数值以确保其正确性。在 TLS 证书的情况下,我们需要能够验证该证书是否由可信的颁发证书机构(CA)颁发。
-
它需要是唯一的。如果提供给我们的身份不唯一,我们无法特定识别持有者。然而,我们只需要在“我们期望的范围内”保持唯一性,例如用户名或电子邮件地址。
在处理授权问题之前,建立身份也是一个至关重要的先决条件。在我们确定应授予资源访问的范围之前,我们需要唯一标识验证系统的实体。
Kubernetes 集群通常为多个租户提供服务,在单个集群中部署和操作多个应用程序的多个用户和团队。解决 Kubernetes 中的租户问题会带来挑战(本书中涵盖了许多),其中之一是身份认证。考虑到必须考虑的权限和资源矩阵,我们必须解决许多部署和配置场景。开发团队应该可以访问其应用程序。运维团队应该可以访问所有应用程序,并可能需要访问平台服务。应用程序间的通信应该受到限制。接下来又该考虑什么?共享服务?安全团队?部署工具?
这些都是常见的问题,会给集群配置和维护增加显著的复杂性。请记住,我们还必须找到方法及时更新这些权限。这些问题很容易出错。但好消息是,Kubernetes 具有使我们能够与外部系统集成,并以安全方式建模身份和访问控制的能力。
在本章中,我们将首先讨论用户身份以及在 Kubernetes 中对用户进行认证的不同方法。然后,我们将进入选项和模式,以在 Kubernetes 集群中建立应用程序身份。我们将了解如何将应用程序认证到 Kubernetes API 服务器(用于编写与 Kubernetes 直接交互的工具,例如操作员)。我们还将涵盖如何建立独特的应用程序身份,使这些应用程序能够在集群内互相认证,同时也能认证到外部服务如 AWS。
用户身份
在本节中,我们将讨论在您的 Kubernetes 集群中实施强大的用户身份系统的方法和模式。在这个上下文中,我们将用户定义为直接与集群交互的人员(通过 Kubectl CLI 或 API)。身份的属性(在前一节中描述)对用户和应用程序身份都是通用的,但某些方法会有所不同。例如,我们始终希望我们的身份是可验证和唯一的;然而,对于使用 OpenID Connect(OIDC)的用户和使用服务账号令牌的应用程序,这些属性的实现方式将有所不同。
认证方法
Kubernetes 操作员可以使用多种不同的认证方法,每种方法都有其优势和劣势。与本书的核心主题保持一致,了解您的特定用例是至关重要的,评估什么对您有效,与您的系统集成,提供用户体验(UX),并提供组织所需的安全姿态。
在这一节中,我们将介绍建立用户身份的每种方法及其权衡,同时描述我们在现场实施的一些常用模式。这里描述的一些方法是特定于平台的,并与某些云供应商提供的功能相关联,而其他方法则是平台无关的。系统如何与您现有的技术架构集成良好,绝对会影响是否采纳它。权衡是在新工具提供的额外功能与与现有堆栈集成的易维护性之间。
除了提供身份外,这里描述的某些方法还可能提供加密。例如,公钥基础设施(PKI)方法描述的流程提供了可以用于互联传输层安全性(mTLS)通信的证书。但是,加密并非本章的重点,而是身份授予方法的附带好处。
共享密钥
共享密钥是由调用实体和服务器共同持有的唯一信息片段(或集合)。例如,当应用程序需要连接到 MySQL 数据库时,它可以使用用户名和密码组合进行身份验证。此方法要求双方以某种形式访问该组合。您必须在 MySQL 中创建一个包含该信息的条目,然后将密钥分发给可能需要它的任何调用应用程序。图 10-1 显示了此模式,后端应用程序存储了需要由前端提供以获取访问权限的有效凭据。

图 10-1. 共享密钥流程。
Kubernetes 提供了两种选项,允许您使用共享密钥模型对 API 服务器进行身份验证。在第一种方法中,您可以向 API 服务器提供一个逗号分隔值(CSV)映射用户名(以及可选的组)到静态令牌的列表。当您要对 API 服务器进行身份验证时,可以在 HTTP 授权头中提供令牌作为 Bearer 令牌。Kubernetes 将把请求视为来自映射用户,并相应地操作。
另一种方法是向 API 服务器提供一个用户名(以及可选的组)和密码映射的 CSV。使用此方法配置后,用户可以在 HTTP 基本授权头中提供 Base64 编码的凭据。
注意
Kubernetes 没有称为用户或组的资源或对象。这些只是在 RBAC RoleBindings 内部标识目的的预定义名称。用户可以从静态文件映射到令牌或密码(如前所述),可以从 x509 证书的 CN 中提取,或者可以作为 OAuth 请求的字段读取,等等。确定用户和组的方法完全取决于正在使用的身份验证方法,而 Kubernetes 没有办法在内部定义或管理它们。在我们看来,这种模式是 API 的一个优点,因为它允许我们插入各种不同的实现,并将这些问题委托给专门设计来处理它们的系统。
这两种方法都存在严重的弱点,不建议使用。其中一些弱点是由于 Kubernetes 的具体实现,而一些则是共享密钥模型固有的问题,我们将很快讨论它们。在 Kubernetes 中,主要问题包括:
-
静态令牌和/或密码文件必须以明文形式存储在 API 服务器可访问的某个位置。这比起初看起来的风险要小,因为如果有人能够 compromise API 服务器并访问该节点,你将有更大的问题要担心,而不仅仅是一个未加密的密码文件。然而,Kubernetes 安装大多是自动化的,设置所需的所有资产应存储在一个仓库中。这个仓库必须是安全的、经过审计和更新的。这打开了另一个潜在的疏忽或不良实践泄露凭据的可能区域。
-
静态令牌和用户名/密码组合均没有过期日期。如果任何凭据被泄露,必须快速识别并通过移除相关凭据并重新启动 API 服务器来修复漏洞。
-
对这些凭据文件的任何修改都要求 API 服务器重新启动。在实践中(以及孤立情况下),这相当简单。然而,许多组织正逐渐从手动干预转向其运行软件和服务器的过程。现在,改变配置大多是重新构建和重新部署的过程,而不仅仅是通过 SSH 进入机器(牛群而非宠物)。因此,修改 API 服务器配置并重新启动进程可能是一个更为复杂的操作。
除了刚刚描述的特定于 Kubernetes 的劣势外,共享密钥模型还遭受另一个缺点的困扰。如果我是一个不受信任的实体,我如何首先在第一次中进行身份验证,以接收适当的身份?我们将更详细地讨论这个安全引入问题以及如何在“应用程序/工作负载身份验证”中解决它。
公钥基础设施
注意
本节假设您已经熟悉 PKI 概念。
PKI 模型使用证书和密钥来唯一标识和认证用户到 Kubernetes。Kubernetes 广泛使用 PKI 来保护系统的所有核心组件之间的通信。可以通过多种方式配置证书颁发机构(CA)和证书,但我们将演示使用 kubeadm 的方式,这是现场中最常见的方法(也是上游 Kubernetes 的事实标准安装方法)。
在安装 Kubernetes 集群后,通常会得到一个 kubeconfig 文件,其中包含kubernetes-admin用户的详细信息。这个文件本质上是集群的根密钥。通常,这个 kubeconfig 文件称为admin.conf,类似于这样:
apiVersion: v1
clusters:
- cluster:
certificate-authority-data: <.. SNIP ...>
server: https://127.0.0.1:32770
name: kind-kind
contexts:
- context:
cluster: kind-kind
user: kind-kind
name: kind-kind
current-context: kind-kind
kind: Config
preferences: {}
users:
- name: kind-kind
user:
client-certificate-data: <.. SNIP ...>
client-key-data: <.. SNIP ...>
要确定将用于向集群进行身份验证的用户,我们需要首先对client-certificate-data字段进行 base64 解码,然后使用类似openssl的工具显示其内容:
Certificate:
Data:
Version: 3 (0x2)
Serial Number: 2587742639643938140 (0x23e98238661bcd5c)
Signature Algorithm: sha256WithRSAEncryption
Issuer: CN=kubernetes
Validity
Not Before: Jul 25 19:48:42 2020 GMT
Not After : Jul 25 19:48:44 2021 GMT
Subject: O=system:masters, CN=kubernetes-admin
Subject Public Key Info:
Public Key Algorithm: rsaEncryption
Public-Key: (2048 bit)
Modulus:
<.. SNIP ...>
Exponent: 65537 (0x10001)
X509v3 extensions:
X509v3 Key Usage: critical
Digital Signature, Key Encipherment
X509v3 Extended Key Usage:
TLS Web Client Authentication
Signature Algorithm: sha256WithRSAEncryption
<.. SNIP ...>
我们从证书中看到,它是由 Kubernetes CA 签发的,并且在system:masters组中标识用户为kubernetes-admin(主题CN字段)。在使用 x509 证书时,任何存在的组织(O=)将被 Kubernetes 视为用户应该被视为其中一部分的组。我们将在本章后面讨论关于用户和组配置以及权限的一些高级方法。
在前面的示例中,我们看到了kubernetes-admin用户的默认配置,这是一个保留的默认名称,使其具有集群范围的管理特权。看看如何配置证书的配发以识别其他常规系统用户,并可以使用 RBAC 系统为他们分配适当的权限也会很有用。使用内置资源,Kubernetes 可以帮助我们进行证书工件的配发和维护,虽然这是一个艰巨的任务。
为了使下面描述的 CSR 流程能够正常运行,控制器管理器需要配置--cluster-signing-cert-file和--cluster-signing-key-file参数,如下所示:
spec:
containers:
- command:
- kube-controller-manager
- --cluster-signing-cert-file=/etc/kubernetes/pki/ca.crt
- --cluster-signing-key-file=/etc/kubernetes/pki/ca.key
# Additional flags removed for brevity
image: k8s.gcr.io/kube-controller-manager:v1.17.3
任何具有适当 RBAC 权限的实体都可以向 Kubernetes API 提交证书签名请求对象。如果用户应该能够自行提交,这意味着我们需要为用户提供提交这些请求的机制。一种做法是明确配置权限,允许system:anonymous用户和/或system:unauthenticated组提交和检索 CSR。
如果没有这样做,任何未经身份验证的用户都基本上无法启动允许他们进行身份验证的流程。但是,我们应该谨慎对待这种方法,因为我们绝不希望向未经身份验证的用户提供访问 Kubernetes API 服务器的权限。因此,为 CSRs 提供自服务的常见方法是,在 Kubernetes 之上提供一个薄抽象或门户,并具有适当的权限运行。用户可以使用其他凭据(通常是 SSO)登录门户,并启动此 CSR 流程(如图 10-2 所示)。

图 10-2. CSR 流程。
在这种流程中,用户可以在本地生成私钥,然后通过门户提交。或者,门户可以为每个用户生成私钥,并在批准证书后将其返回给用户。生成可以使用openssl或任何其他工具/库进行。CSR 应包含用户希望编码到其 x509 证书中的元数据,包括他们的用户名和任何其他应该成为一部分的组。以下示例创建一个证书请求,将用户标识为john:
$ openssl req -new -key john.key -out john.csr -subj "/CN=john"
$ openssl req -in john.csr -text
Certificate Request:
Data:
Version: 0 (0x0)
Subject: CN=john
Subject Public Key Info:
Public Key Algorithm: rsaEncryption
Public-Key: (1024 bit)
Modulus:
<.. SNIP ...>
Exponent: 65537 (0x10001)
Attributes:
a0:00
Signature Algorithm: sha256WithRSAEncryption
<.. SNIP ...>
在生成 CSR 之后,我们可以通过我们的门户将其提交给集群中的 CertificateSigningRequest 资源。以下是请求的示例,作为 YAML 对象,但我们的门户通常会通过 Kubernetes API 程序化地应用它,而不是手动构建 YAML:
cat <<EOF | kubectl apply -f -
apiVersion: certificates.k8s.io/v1beta1
kind: CertificateSigningRequest
metadata:
name: john
spec:
request: $(cat john.csr | base64 | tr -d '\n')
usages:
- client auth
EOF
这在 Kubernetes 中创建一个具有pending状态的 CSR 对象,等待批准。此 CSR 对象包含(base64 编码的)签名请求和请求者的用户名。如果使用服务账户令牌进行身份验证访问 Kubernetes API(如 Pod 在自动化流程中会这样),则用户名将是服务账户名。在以下示例中,我作为kubernetes-admin用户通过 Kubernetes API 进行了认证,并显示在请求者字段中。如果使用门户,我们会看到分配给该门户组件的服务账户。
$ kubectl get csr
NAME AGE REQUESTOR CONDITION
my-app 17h kubernetes-admin Pending
在请求待定时,用户尚未获得任何证书。下一阶段涉及集群管理员(或具有适当权限的用户)批准 CSR。如果可以程序化确定用户的身份,批准也可能是自动化的。批准将向用户发放一个证书,用于在 Kubernetes 集群上声明身份。因此,执行验证提交请求者是其声称的身份非常重要。这可以通过向 CSR 添加一些额外的标识元数据,并通过自动化流程验证信息与声称的身份进行对比,或通过一个独立的流程验证用户的身份来实现。
一旦 CSR 被批准,证书(在 CSR 的status字段中)就可以被检索和使用(与它们的私钥一起)用于与 Kubernetes API 进行 TLS 通信。在我们的门户实现中,CSR 将由门户系统拉取,并在用户重新登录并重新检查门户后为请求用户提供:
apiVersion: certificates.k8s.io/v1beta1
kind: CertificateSigningRequest
metadata:
name: my-app
# Additional fields removed for brevity
status:
certificate: <.. SNIP ...>
conditions:
- lastUpdateTime: "2020-03-04T15:45:30Z"
message: This CSR was approved by kubectl certificate approve.
reason: KubectlApprove
type: Approved
解码证书时,我们可以看到它包含 CN 字段中的相关身份信息(john):
Certificate:
Data:
Version: 3 (0x2)
Serial Number:
66:82:3f:cc:10:3f:aa:b1:df:5b:c5:42:cf:cb:5b:44:e1:45:49:7f
Signature Algorithm: sha256WithRSAEncryption
Issuer: CN=kubernetes
Validity
Not Before: Mar 4 15:41:00 2020 GMT
Not After : Mar 4 15:41:00 2021 GMT
Subject: CN=john
Subject Public Key Info:
Public Key Algorithm: rsaEncryption
Public-Key: (2048 bit)
Modulus:
<.. SNIP ...>
Exponent: 65537 (0x10001)
X509v3 extensions:
X509v3 Extended Key Usage:
TLS Web Client Authentication
X509v3 Basic Constraints: critical
CA:FALSE
X509v3 Subject Key Identifier:
EE:8E:E5:CC:98:41:78:4A:AE:32:75:52:1C:DC:DD:D0:9B:95:E0:81
Signature Algorithm: sha256WithRSAEncryption
<.. SNIP ...>
最后,我们可以制作一个 kubeconfig,其中包含我们的私钥和批准的证书,使我们能够作为john用户与 Kubernetes API 服务器通信。从前面的 CSR 流程中获得的证书将放入 kubeconfig 中显示的client-certificate-data字段:
apiVersion: v1
clusters:
- cluster:
certificate-authority-data: <.. SNIP ...>
server: https://127.0.0.1:32770
name: kind-kind
contexts:
- context:
cluster: kind-kind
user: kind-kind
name: kind-kind
current-context: kind-kind
kind: Config
preferences: {}
users:
- name: kind-kind
user:
client-certificate-data: <.. SNIP ...>
client-key-data: <.. SNIP ...>
我们在现场看到这种模型的实施,其中自动化系统基于一些可验证的 SSO 凭据或其他认证方法提供证书。当自动化时,这些系统可能会成功,但我们不建议使用。将 x509 证书作为 Kubernetes 用户的主要认证方法会引入许多问题:
-
通过 Kubernetes CSR 流程提供的证书在到期之前无法吊销。目前在 Kubernetes 中没有支持证书吊销列表或在线证书状态协议(OSCP)装订的支持。
-
需要额外的 PKI 进行提供、支持和维护,除了创建和维护负责基于外部认证提供证书的组件。
-
x509 证书具有到期时间戳,应保持相对较短,以减少密钥/证书对被 compromise 的风险。这种短暂的生命周期意味着证书的大量更换,并且必须定期将其分发给用户,以确保对集群的持续访问。
-
需要一种方式来验证任何请求证书的人的身份。在自动化系统中,可以通过外部可验证的元数据来设计此类验证方式。在缺乏此类元数据的情况下,离线验证通常太耗时,不实用,特别是考虑到先前提到的证书的短生命周期。
-
证书局限于一个集群。在现场,我们看到许多(10 至 100 个)Kubernetes 集群跨项目和组存在。为每个集群需要独特的凭据会增加存储和维护相关凭据的复杂性。这导致用户体验下降。
警告
即使不将证书作为主要认证方法,也要记住将admin.conf kubeconfig 保存在安全的地方。如果由于某些原因其他认证方法不可用,这可以作为管理员备用解决方案来访问集群。
OpenID Connect (OIDC)
我们认为,在设置 Kubernetes 用户身份验证和身份时,最好的选择是与现有的单点登录系统或提供者集成。几乎每个组织都已经有了诸如 Okta、Auth0、Google,甚至是内部 LDAP/AD 之类的解决方案,提供了一个统一的地方供用户进行身份验证并获得对内部系统的访问。对于像身份验证这样安全性是一个重要因素的情况,外包复杂性是一个稳妥的选择,除非您有非常专业的需求。
这些系统有很多优点。它们建立在广为人知和广泛支持的标准上。它们将用户帐户和对单个安全系统的访问的所有管理都集中到一个良好安全的系统中,使得维护和删除帐户/访问变得简单。此外,当使用通用 OIDC 框架时,它们还允许用户访问下游应用程序而不向这些系统暴露凭据。另一个优点是,许多跨多个环境的 Kubernetes 集群可以利用单个身份提供者,从而减少了集群配置之间的差异。
Kubernetes 直接支持 OIDC 作为认证机制(如图 10-3 所示)。如果您的组织本身就提供了相关 OIDC 端点,那么配置 Kubernetes 以利用这一点就很简单。

图 10-3。OIDC 流程。摘自官方 Kubernetes 文档。
然而,在某些情况下,可能需要或期望使用一些额外的工具来提供附加功能或改善用户体验。首先,如果您的组织有多个身份提供者,则有必要使用 OIDC 聚合器。Kubernetes 仅支持在其配置选项中定义单个身份提供者,而 OIDC 聚合器可以作为多个其他提供者(无论是 OIDC 还是其他方法)的单个中间人。我们以前在云原生计算基金会内使用过 Dex(一个沙盒项目),并且取得了成功,尽管其他流行的选项如 Keycloak 和 UAA 也提供了类似的功能。
注意 注意
请记住,身份验证是集群访问的关键路径。Dex、Keycloak 和 UAA 都可以根据不同程度进行配置,当实施这些解决方案时,您应该优化可用性和稳定性。这些工具是额外的维护负担,必须进行配置、更新和保护。在实践中,我们总是强调理解和拥有您的环境和集群引入的任何额外复杂性的必要性。
虽然配置 API 服务器以利用 OIDC 是直截了当的,但必须注意为集群用户提供无缝的用户体验。OIDC 解决方案将返回一个标识我们的令牌(在成功登录后);然而,为了访问并在集群上执行操作,我们需要一个格式正确的 kubeconfig。当我们在现场遇到这种用例时,我们的同事开发了一个简单的 Web UI,称为 Gangway,用于通过 OIDC 提供者自动化登录过程,并从返回的令牌生成符合标准的 kubeconfig(包括相关的端点和证书)。
尽管 OIDC 是我们首选的身份验证方法,但并不适用于所有情况,可能需要使用次要方法。OIDC(如规范中定义的)要求用户直接通过身份提供者的 Web 接口登录。这是为了明显的原因,以确保用户只向受信任的提供者提供凭据,而不是向消费应用程序提供。这种要求在机器人用户需要访问系统的情况下可能会引起问题。这对于自动化工具如 CI/CD 系统和其他无法响应基于 Web 的凭据挑战的系统是常见的。
在这些情况下,我们看到了几种不同的模型/解决方案:
-
在机器人用户绑定到集中管理的账户的情况下,可以实现一个 kubectl 认证插件,该插件会登录到外部系统并接收一个令牌作为响应。Kubernetes 可以配置以通过 webhook 令牌认证器方法验证此令牌。这种方法可能需要一些定制编码来创建令牌生成器/webhook 服务器。
-
对于其他情况,我们看到用户回退到使用基于证书的身份验证,适用于不需要集中管理的机器人账户。当然,这意味着您需要管理证书的签发和轮换,但不需要任何定制组件。
-
另一个手动但有效的替代解决方案是为工具创建一个服务账户,并利用为 API 访问生成的令牌。如果工具在集群内运行,它可以直接使用挂载到 Pod 中的凭据。如果工具在集群外运行,我们可以手动复制并粘贴令牌到工具可访问的安全位置,并在进行 kubectl 或 API 调用时使用。服务账户的更多细节在 “Service Account Tokens (SAT)” 中有详细介绍。
为用户实施最小特权权限
现在我们已经看到了实现身份验证和认证的不同方式,让我们转向授权的相关主题。在本书的范围之外深入讨论如何配置跨集群的 RBAC 不是我们的目的。这将在应用程序、环境和团队之间有显著差异。然而,我们确实想描述一种成功实施的模式,围绕最小权限原则设计管理访问角色。
无论您选择了每个团队一个集群的方法还是多租户集群的方法,您都可能在操作团队上有超级管理员用户,负责配置、升级和维护环境。尽管个别团队应根据其所需的访问权限进行限制,这些管理员将对整个集群拥有完全控制权,因此更有可能意外执行破坏性操作。
在理想情况下,所有集群访问和操作都应由自动化流程(如 GitOps 或类似工具)执行。然而,从实际操作角度来看,我们经常看到用户单独访问集群,并发现以下模式是限制潜在问题的有效方式。有时候,将管理员角色直接绑定到特定操作员的用户名/身份上是很诱人的,但可能因为加载了错误的 kubeconfig 而误删重要内容。在此之前,这种情况不应该发生,但却经常发生!
Kubernetes 支持模拟(impersonation)的概念,通过这种方式,我们可以创建一个行为与 Linux 系统上的sudo非常接近的体验,通过限制用户的默认权限并要求他们提升权限来执行敏感命令。从实际操作角度来看,我们希望这些用户默认情况下可以查看所有内容,但必须有意提升权限才能进行写操作。这种模式显著降低了发生上述情况的可能性。
让我们详细讨论一下如何实现刚才描述的权限提升模式。我们假设我们操作团队的用户身份都是 Kubernetes 中ops-team组的一部分。正如前面提到的,Kubernetes 本身没有定义组的概念,所以我们指的是那些在其 Kubernetes 身份中具有额外属性(如 x509 证书、OIDC 声明等)以标识他们属于该组的用户。
我们创建了 ClusterRoleBinding,允许ops-team组的用户访问view内置 ClusterRole,这是我们默认只读访问的基础:
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: cluster-admin-view
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: view
subjects:
- apiGroup: rbac.authorization.k8s.io
kind: Group
name: ops-team
现在我们创建一个 ClusterRoleBinding,允许我们的 cluster-admin 用户在集群上拥有 cluster-admin ClusterRole 权限。请记住,我们不直接将此 ClusterRole 绑定到我们的 ops-team 组。没有用户可以直接标识为 cluster-admin 用户;这将是一个被模拟的用户,并且他们的权限将被另一个经过身份验证的用户假定。
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: cluster-admin-crb
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: cluster-admin
subjects:
- apiGroup: rbac.authorization.k8s.io
kind: User
name: cluster-admin
最后,我们创建一个名为 cluster-admin-impersonator 的 ClusterRole,允许模拟 cluster-admin 用户,并创建一个 ClusterRoleBinding,将该功能绑定到 ops-team 组中的每个人:
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: cluster-admin-impersonator
rules:
- apiGroups: [""]
resources: ["users"]
verbs: ["impersonate"]
resourceNames: ["cluster-admin"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: cluster-admin-impersonate
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: cluster-admin-impersonator
subjects:
- apiGroup: rbac.authorization.k8s.io
kind: Group
name: ops-team
现在让我们使用 ops-team 组中的用户(john)的 kubeconfig,看看特权的提升在实践中如何运作:
$ kubectl get configmaps
No resources found.
$ kubectl create configmap my-config --from-literal=test=test
Error from server (Forbidden): configmaps is forbidden: User "john"
cannot create resource "configmaps" in API group "" in the namespace "default"
$ kubectl --as=cluster-admin create configmap my-config --from-literal=test=test
configmap/my-config created
我们使用前述设置用于管理员用户,尽管为每个用户实施类似的模式(拥有 team-a 组、team-a 视图角色和 team-a 管理员用户)是一种可靠的模式,可以消除许多潜在的昂贵错误。此外,刚才描述的模仿方法的一大优点是,所有这些都记录在 Kubernetes 审计日志中,因此我们可以看到原始用户登录,模拟集群管理员,然后采取行动。
应用程序/工作负载身份
在前一节中,我们看到了 Kubernetes 的人类用户建立身份的主要方法和模式,以及他们如何对集群进行身份验证。在这一节中,我们将看看如何为在集群中运行的工作负载建立身份。我们将检查三个主要的用例:
-
工作负载将自己标识给集群内的其他工作负载,可能为它们之间建立互相认证以提供额外的安全性。
-
工作负载标识自身以获得适当的访问 Kubernetes API 本身。这是自定义控制器常见的用例,需要监视和操作 Kubernetes 资源。
-
工作负载标识自身并向集群外部的服务进行身份验证。这可能是集群外的任何内容,但主要是运行在 AWS、GCP 等上的云供应商服务。
在 “网络身份” 中,我们将看看两种最流行的容器网络接口(CNI)工具(Calico 和 Cilium),并看看它们如何分配身份和限制访问,主要用于我们刚刚描述的第一个用例。
其次,我们将转向服务账户令牌(SAT)和预计服务账户令牌(PSAT)。这些是灵活且重要的 Kubernetes 原语,除了是工作负载之间身份识别(第一个用例)的主要机制外,还是工作负载向 Kubernetes API 本身标识的主要机制(第二个用例)。
接下来我们将讨论应用程序身份由平台本身提供的选项。我们在现场看到的最常见用例是需要访问 AWS 服务的工作负载,并且我们将看看今天可能的三种主要方法。
最后,我们将扩展平台中介的身份概念,考虑旨在在多个平台和环境中提供一致身份模型的工具。这种方法的灵活性可以用来涵盖我们提到的所有用例,并展示这是一个非常强大的能力。
在实施本节描述的任何模式之前,您应该确实评估与建立工作负载身份相关的需求。通常,建立这种能力是一项高级活动,大多数组织可能最初不需要解决这个问题。
共享密钥
大多数关于用户身份共享密钥的讨论也适用于应用程序身份;然而,基于现场经验,还有一些额外的细微差别和指导。
一旦我们在客户端和服务器上有已知的密钥,我们如何在到期时安全地旋转它们?理想情况下,我们希望这些密钥有一个固定的生命周期,以减少如果某个密钥被破坏可能造成的潜在损害。此外,因为它们是共享的,它们需要重新分发到客户端应用程序和服务器上。Hashicorp 的 Vault 是一个著名的企业秘密存储示例,具有与许多解决此重新同步问题的工具集成。然而,Vault 也受到我们在 “用户身份” 中首次遇到的安全引入问题的困扰。
这是我们在试图确保共享密钥在我们建立身份和认证模型之前(鸡和蛋问题)安全分发到客户端和服务实体的问题。任何尝试在两个实体之间最初种子化密钥的行为都可能被破坏,打破了我们对身份和独特认证的保证。
尽管已经讨论了缺陷,共享密钥在一个方面具有强大的优势,即几乎所有用户和应用程序都支持并理解该模型。这使得它成为跨平台操作的强大选择。我们将在本章后面看到如何通过更高级的身份验证方法来解决 Vault 和 Kubernetes 的安全引入问题。一旦 Vault 使用这些方法进行了安全配置,它就是一个很好的选择(我们已经多次实施过),因为许多共享密钥的问题都得到了缓解。
网络身份
网络原语,如 IP 地址、VPN、防火墙等,历来被用作控制应用程序对各种服务访问权限的一种形式。然而,在云原生生态系统中,这些方法正在失效,范式也在发生变化。根据我们的经验,教育整个组织的团队(尤其是网络和安全团队)了解这些变化,以及如何调整实践来适应和接纳这些变化至关重要。但往往会遇到团队对安全性和/或控制方面的担忧而产生抵制。实际上,如果有必要,几乎可以实现任何姿态,但需要花时间了解团队的实际需求,而不是陷入实施细节中。
在基于容器的环境中,工作负载共享网络堆栈和基础设备。工作负载日益短暂,经常在节点之间移动。这导致 IP 地址和网络变化的不断变动。
在多云和 API 驱动的世界中,网络不再是主要边界。常见情况是跨多个提供商对外部服务进行调用,每个提供商可能需要一种方式来证明我们调用应用程序的身份。
现有的传统(平台级别)网络原语(主机 IP 地址、防火墙等)已不再适用于建立工作负载身份,如有使用,也只应作为深度防御的附加层次。这并不是说网络原语总体上不好,而是它们必须具有额外的工作负载上下文才能发挥作用。在本节中,我们将探讨 CNI 选项如何为 Kubernetes 集群提供身份,并如何最好地利用它们。通过结合从 Kubernetes API 检索的网络原语和元数据,CNI 提供者能够对请求进行上下文化处理并提供身份。我们将简要介绍一些最流行的 CNI 实现,并看看它们能提供哪些功能。
Calico
Calico在 OSI 模型的第 3 层(网络层)和第 4 层(传输层)提供网络策略强制执行,使用户能够基于它们的命名空间、标签和其他元数据限制 Pod 之间的通信。此强制执行是通过修改网络配置(如iptables/ipvs)来允许/禁止 IP 地址来实现的。
当与Envoy 代理(无论是独立的 Envoy 还是作为服务网格(如Istio)的一部分部署)结合使用时,Calico 还支持使用称为 Dikastes 的组件基于服务账户做出策略决策。此方法使得能够基于应用程序协议的属性(如头部等)和相关的加密身份(证书等),在第 7 层(应用层)执行强制执行。
默认情况下,Istio(Envoy)只会执行 mTLS 并确保工作负载呈现由 Istio CA(Citadel)签名的证书。Dikastes 作为 Envoy 的插件以 sidecar 形式运行,正如我们可以在 Figure 10-3 中的架构图中看到的那样。Envoy 在查询 Dikastes 决定是否接受或拒绝请求之前会验证 CA。Dikastes 基于用户定义的 Calico NetworkPolicy 或 GlobalNetworkPolicy 对象作出决策:
apiVersion: projectcalico.org/v3
kind: GlobalNetworkPolicy
metadata:
name: summary
spec:
selector: app == 'summary'
ingress:
- action: Allow
source:
serviceAccounts:
names: ["customer"]
NamespaceSelector: app == 'bank'
egress:
- action: Allow
前述规则指定该策略适用于任何带有标签 app: summary 的 Pod,并限制了从 customer Service Account 调用的 Pod 的访问权限(在带有标签 app: bank 的 Namespace 中)。这有效是因为 Calico 控制平面(Felix 节点代理)通过协调正在特定 Service Account 下运行的 Pod 及其 IP 地址来计算规则,随后通过 Unix 套接字将此信息同步到 Dikastes。
这种离线验证在 Istio 环境中尤为重要,因为它有助于减轻潜在的攻击向量。Istio 将每个 Service Account 的 PKI 资产存储在集群中的一个 Secret 中。如果没有这种额外验证,攻击者如果能够窃取该 Secret,就能够伪装成所断言的 Service Account(通过展示这些 PKI 资产),尽管可能并非以该账户的身份运行。

图 10-4. 使用 Envoy 的 Dikastes 架构图。
如果您的团队已经在使用 Calico,那么 Dikastes 可以在深度防御中提供额外的安全层,绝对值得考虑。然而,这需要 Istio 或其他类似的服务网格解决方案(例如独立的 Envoy)在环境中可用并运行,以验证工作负载所呈现的身份。这些主张无法独立进行加密验证,而是依赖于网格与每个连接的服务的存在。这本身增加了相当大的复杂性,应仔细评估其权衡。这种方法的一个优势是 Calico 和 Istio 都是跨平台的,因此此设置可用于在环境内运行的 Kubernetes 应用程序的身份验证(而某些选项则仅适用于 Kubernetes)。
Cilium
像 Calico 一样,Cilium 也提供第 3 和第 4 层的网络策略执行,使用户能够根据其 Namespace 和其他元数据(例如标签)限制 Pod 之间的通信。Cilium 还支持在第 7 层应用策略,并通过 Service Account 限制对服务的访问,而无需额外的工具支持。
与 Calico 不同,Cilium 中的执行并不基于 IP 地址(和更新节点网络配置)。相反,Cilium 为每个唯一的 Pod/端点(基于多个选择器)计算身份,并将这些身份编码到每个数据包中。然后,它使用 eBPF 内核钩子在数据路径的各个点上根据这些身份来执行是否允许数据包通过。
让我们简要探讨一下 Cilium 如何计算端点(Pod)的身份。以下是列出一个应用程序的 Cilium 端点的输出代码。我们省略了片段中的标签列表,但在列表的最后一个 Pod (deathstar-657477f57d-zzz65) 中添加了一个额外的标签,这个标签在其他四个 Pod 中不存在。因此,我们可以看到最后一个 Pod 因此被分配了一个不同的身份。除了这个单一不同的标签外,部署中的所有 Pod 都共享一个命名空间、服务账户和几个其他任意的 Kubernetes 标签。
$ kubectl exec -it -n kube-system cilium-oid9h -- cilium endpoint list
NAMESPACE NAME ENDPOINT ID IDENTITY ID
default deathstar-657477f57d-jpzgb 1474 1597
default deathstar-657477f57d-knxrl 2151 1597
default deathstar-657477f57d-xw2tr 16 1597
default deathstar-657477f57d-xz2kk 2237 1597
default deathstar-657477f57d-zzz65 1 57962
如果我们移除了不同的标签,deathstar-657477f57d-zzz65 Pod 将被重新分配与其四个同行相同的身份。这种粒度的控制赋予了我们在为单个 Pod 分配身份时的强大和灵活性。
Cilium 实现了 Kubernetes 本机的 NetworkPolicy API,并像 Calico 一样以 CiliumNetworkPolicy 和 CiliumClusterwideNetworkPolicy 对象的形式公开了更完整的功能:
apiVersion: "cilium.io/v2"
kind: CiliumNetworkPolicy
metadata:
name: "k8s-svc-account"
spec:
endpointSelector:
matchLabels:
io.cilium.k8s.policy.serviceaccount: leia
ingress:
- fromEndpoints:
- matchLabels:
io.cilium.k8s.policy.serviceaccount: luke
toPorts:
- ports:
- port: '80'
protocol: TCP
rules:
http:
- method: GET
path: "/public$"
在这个例子中,我们使用特殊的 io.cilium.k8s.policy.* 标签选择器来定位集群中特定的 Service Account。然后,Cilium 使用其身份注册表(我们之前看到的)根据需要限制/允许访问。在所示的策略中,我们限制了对具有 leia Service Account 的 Pod 在端口 80 上 /public 路径的访问。我们只允许来自具有 luke Service Account 的 Pod 的访问。
与 Calico 一样,Cilium 是跨平台的,因此可以在 Kubernetes 和非 Kubernetes 环境中使用。Cilium 需要 在每个连接的服务中存在以验证身份,因此您的网络设置的整体复杂性可能会因此增加。但是,Cilium 不需要服务网格组件来运行。
Service Account Tokens (SAT)
注意
Service Account 是 Kubernetes 中为一组 Pod 提供身份的基元。每个 Pod 都在一个 Service Account 下运行。如果管理员没有预先创建并分配给 Pod 的 Service Account,则它们将被分配到所在命名空间的默认 Service Account。
Service Account tokens 是作为 Kubernetes Secrets 创建的 JSON Web Tokens (JWT)。每个 Service Account(包括默认的 Service Account)都有一个相应的 Secret,其中包含 JWT。除非另有规定,否则这些令牌被挂载到每个在该 Service Account 下运行的 Pod 中,并可用于向 Kubernetes API(以及本节显示的其他服务)发出请求。
Kubernetes 服务账户为一组工作负载分配身份提供了一种方式。然后,在集群内可以应用基于角色的访问控制(RBAC)规则来限制特定服务账户的访问范围。服务账户通常是 Kubernetes 自身在集群内部身份验证 API 访问的方式:
apiVersion: v1
kind: ServiceAccount
metadata:
name: default
namespace: default
secrets:
- name: default-token-mf9v2
当创建服务账户时,还会创建一个相关的秘密,其中包含标识该账户的唯一 JWT:
apiVersion: v1
data:
ca.crt: <.. SNIP ...>
namespace: ZGVmYXVsdA==
token: <.. SNIP ...>
kind: Secret
metadata:
annotations:
kubernetes.io/service-account.name: default
kubernetes.io/service-account.uid: 59aee446-b36e-420f-99eb-a68895084c98
name: default-token-mf9v2
namespace: default
type: kubernetes.io/service-account-token
默认情况下,如果 Pod 未指定要使用的特定服务账户,则 Pod 将自动获取其命名空间中的default服务账户令牌挂载。为确保所有服务账户令牌都明确挂载到 Pod 并且其访问范围被明确定义和理解(而不是回退并假设默认值),可以(而且应该)禁用此功能。
要为 Pod 指定服务账户,请在 Pod 规范的serviceAccountName字段中使用:
apiVersion: v1
kind: Pod
metadata:
name: my-pod
spec:
serviceAccountName: my-pod-sa
# Additional fields removed for brevity
这将导致服务账户的秘密(包含令牌)被挂载到 Pod 的/var/run/secrets/kubernetes.io/serviceaccount/目录中。应用程序可以检索令牌并在集群中向其他应用程序/服务发出请求。
目标应用程序可以通过调用 Kubernetes TokenReview API 来验证提供的令牌:
curl -X "POST" "https://<kubernetes API IP>:<kubernetes API Port>\
/apis/authentication.k8s.io/v1/tokenreviews" \
-H 'Authorization: Bearer <token>' \ 
-H 'Content-Type: application/json; charset=utf-8' \
-d $'{
"kind": "TokenReview",
"apiVersion": "authentication.k8s.io/v1",
"spec": {
"token": "<token to verify>"  }
}'
此令牌是挂载到目标应用程序 Pod 中的秘密,使其能够与 API 服务器通信。
此令牌是调用应用程序呈现作为身份证明的令牌。
Kubernetes API 将响应关于要验证的令牌的元数据,以及是否已经验证过的信息:
{
"kind": "TokenReview",
"apiVersion": "authentication.k8s.io/v1",
"metadata": {
"creationTimestamp": null
},
"spec": {
"token": "<token to verify>"
},
"status": {
"authenticated": true,
"user": {
"username": "system:serviceaccount:default:default",
"uid": "4afdf4d0-46d2-11e9-8716-005056bf4b40",
"groups": [
"system:serviceaccounts",
"system:serviceaccounts:default",
"system:authenticated"
]
}
}
}
上述流程显示在图 10-5 中。

图 10-5. 服务账户令牌。
Kubernetes 服务账户令牌自早期以来就是 Kubernetes 的一部分,并以可消耗的 JWT 格式与平台紧密集成。作为操作员,我们对其有效性也有相当严格的控制,因为如果删除服务账户或秘密,则会使令牌无效。但是,它们有一些特性使其作为标识符的使用不够理想。最重要的是,这些令牌仅限于特定服务账户的范围,因此无法验证更精细的范围内的任何内容,例如 Pod 或单个容器。如果要使用和验证令牌作为客户端身份的形式,则还需要向我们的应用程序添加功能,包括使用自定义组件调用 TokenReview API。
令牌也仅限于单个集群,因此我们无法将一个集群发行的服务帐户令牌作为从其他集群调用的服务的身份文件使用,而无需暴露每个集群的 TokenReview API 并对请求起源集群的一些附加元数据进行编码。所有这些都会给设置增加显著的复杂性,因此我们建议不要选择这种用于跨集群服务身份验证的方法。
注意
为了确保权限可以以适当的细粒度方式授予应用程序,应为每个需要访问 Kubernetes API 服务器的工作负载创建唯一的服务帐户。此外,如果工作负载不需要访问 Kubernetes API 服务器,请通过在ServiceAccount对象上指定automountServiceAccountToken: false字段来禁用服务帐户令牌的挂载。
例如,可以在命名空间的默认服务帐户上设置此项,以禁用凭证令牌的自动挂载。此字段也可以设置在Pod对象上,但请注意,如果两个地方都设置了,则以Pod字段为准。
投影的服务帐户令牌(PSAT)
从 Kubernetes v1.12 开始,还提供了一种可用的附加身份验证方法,该方法建立在服务帐户令牌的思想上,但旨在解决一些弱点(例如缺乏 TTL、广泛作用域和持久性)。
为了使 PSAT 流程正常工作,Kubernetes API 服务器需要配置显示的参数键(所有这些都是可配置的):
spec:
containers:
- command:
- kube-apiserver
- --service-account-signing-key-file=/etc/kubernetes/pki/sa.key
- --service-account-key-file=/etc/kubernetes/pki/sa.pub
- --service-account-issuer=api
- --service-account-api-audiences=api
# Additional flags removed for brevity
image: k8s.gcr.io/kube-apiserver:v1.17.3
建立和验证身份的流程与 SAT 方法类似。但是,不是让我们的 Pod/应用程序读取自动挂载的服务帐户令牌,而是将投影的服务帐户令牌作为卷挂载。这也会向 Pod 注入令牌,但是您可以为令牌指定 TTL 和自定义受众:
apiVersion: v1
kind: Pod
metadata:
name: test
labels:
app: test
spec:
serviceAccountName: test
containers:
- name: test
image: ubuntu:bionic
command: ['sh', '-c', 'echo Hello Kubernetes! && sleep 3600']
volumeMounts:
- mountPath: /var/run/secrets/tokens
name: app-token
volumes:
- name: app-token
projected:
sources:
- serviceAccountToken:
audience: api 
expirationSeconds: 600
path: app-token
audience字段很重要,因为它防止目标应用程序使用来自调用应用程序的令牌并尝试冒充调用应用程序。根据目标应用程序的不同,受众应该始终正确范围化。在这种情况下,我们将范围限定为与 API 服务器本身通信。
注意
当使用 PSAT 时,必须创建并使用指定的服务帐户。Kubernetes 不会为命名空间默认服务帐户挂载 PSAT。
调用应用程序可以读取投影令牌,并在集群内的请求中使用它。目标应用程序可以通过调用TokenReview API 并传递接收到的令牌来验证令牌。使用 PSAT 方法,审查还将验证 TTL 未过期,并返回关于呈现应用程序的其他元数据,包括特定的 Pod 信息。这提供了比普通 SAT(仅断言服务帐户)更紧密的范围。
// Additional fields removed for brevity
"extra": {
"authentication.kubernetes.io/pod-name": ["test"],
"authentication.kubernetes.io/pod-uid":
["8b9bc1be-c71f-4551-aeb9-2759887cbde0"]
}
如图 Figure 10-6 所示,SAT 和 PSAT 流程本身没有实质性区别(除了服务器验证 audience 字段),只在于令牌所断言的身份的有效性和粒度。audience 字段非常重要,因为它标识了令牌的预期接收者。根据 JWT 官方规范,API 将拒绝目标与 API 服务器配置中指定的不匹配的令牌。

Figure 10-6. 预投影服务账户令牌。
预投影服务账户令牌是 Kubernetes 功能集的一个相对较新但非常强大的补充。它们本身与平台本身紧密集成,提供可配置的 TTL,并且具有紧密的范围(个别 Pod)。它们还可以用作构建更强大模式的基础模块(正如我们将在后面的章节中看到的)。
平台中介节点身份
在所有工作负载均运行在同质平台(例如 AWS)的情况下,平台本身可以根据其拥有的有关工作负载的上下文元数据来确定和分配身份。
身份不是由工作负载本身断言的,而是由一个带外提供者基于其属性确定的。该提供者返回给工作负载一个凭据来证明身份,可以用于与平台上的其他服务通信。其他服务可以轻松验证该凭据,因为它们也位于同一基础平台上。
在 AWS 上,EC2 实例可以请求凭据以连接到不同的服务,例如 S3 存储桶。AWS 平台检查实例的元数据,并可以向实例提供特定角色的凭据,以便进行连接,如 Figure 10-7 所示。

Figure 10-7. 平台中介身份。
注意
请记住,平台仍需对请求执行 授权,以确保使用的身份具有适当的权限。此方法仅用于 认证 请求。
许多云供应商在本节描述的功能上提供了相似的功能。我们选择关注适用于并与 Amazon Web Services(AWS)集成的工具,因为这是我们在现场中最常见的供应商。
AWS 平台认证方法/工具
AWS 通过 EC2 元数据 API 在节点级别提供了强大的身份解决方案。这是一个平台介导的系统的例子,平台(AWS)能够根据多个内在属性确定调用实体的身份,而无需实体自己声明任何凭据/身份声明。然后,平台可以向实例(例如角色形式)提供安全凭据,使其能够访问由相关策略定义的任何服务。总体而言,这被称为身份和访问管理(IAM)。
此模型支撑了 AWS(以及许多其他供应商)如何向其自己的云服务提供安全访问的方式。然而,随着容器和其他多租户应用模型的兴起,这种基于每个节点的身份验证系统出现了问题,需要额外的工具和替代方法。
在本节中,我们将介绍我们在领域中遇到的三种主要工具选项。我们将涵盖 kube2iam 和 kiam,这两个独立的工具共享相似的实现模型(因此具有类似的优缺点)。我们还将描述为何今天我们不推荐使用这些工具,并且为何您应该考虑更集成的解决方案,例如我们介绍的最终选项,即服务帐户的 IAM 角色(IRSA)。
kube2iam
kube2iam 是一个充当正在运行的工作负载与 AWS EC2 元数据 API 之间代理的开源工具。其架构显示在 图 10-8 中。
注意
kube2iam 要求集群中的每个节点能够假定 Pods 可能需要的所有角色的超集。这种安全模型意味着,如果发生容器突破,所提供的访问范围可能会非常庞大。因此,强烈建议不要使用 kube2iam。我们在这里讨论它是因为我们经常在现场遇到它,并希望在深入了解实现的限制之前确保您了解它。

图 10-8. kube2iam 架构和数据流。
kube2iam Pods 通过 DaemonSet 在每个节点上运行。每个 Pod 注入一个 iptables 规则以捕获对元数据 API 的出站流量,并将其重定向到该节点上正在运行的 kube2iam 实例。
希望与 AWS API 交互的 Pods 应在 spec 中的注释中指定要假定的角色。例如,在以下部署规范中,您可以看到角色在 iam.amazonaws.com/role 注释中指定:
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx-deployment
spec:
replicas: 3
template:
metadata:
annotations:
iam.amazonaws.com/role: <role-arn>
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx:1.9.1
ports:
- containerPort: 80
kiam
与 kube2iam 类似,kiam 是一个开源工具,用作 AWS EC2 元数据 API 的代理,尽管其架构(以及因此而得到的安全模型)有所不同并略有改进,如 图 10-9 所示。
注意
虽然比 kube2iam 更安全,kiam 也引入了一个潜在的严重安全漏洞。本节描述了对该漏洞的缓解措施,但在使用 kiam 时仍应谨慎并理解攻击向量。

图 10-9. kiam 架构和数据流。
kiam 同时具有服务器和代理组件。代理作为 DaemonSet 运行在集群中的每个节点上。服务器组件可以(并且应该)限制为仅适用于控制平面节点或群集节点的子集。代理捕获 EC2 元数据 API 请求并将其转发给服务器组件,以完成与 AWS 的适当身份验证。只有服务器节点需要访问以承担 AWS IAM 角色(再次,这是可能需要的所有角色的超集),如 图 10-10 所示。

图 10-10. kiam 流程。
在这种模型中,应设置控制措施以确保没有工作负载能够在服务器节点上运行(从而获取无限制的 AWS API 访问)。像 kube2iam 一样,通过在 Pod 上注释所需的角色来实现角色的承担:
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx-deployment
spec:
replicas: 3
template:
metadata:
annotations:
iam.amazonaws.com/role: <role-arn>
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx:1.9.1
ports:
- containerPort: 80
虽然安全模型比 kube2iam 更好,但 kiam 仍存在潜在的攻击向量,即如果用户能够直接调度 Pod 到节点(通过填充其 nodeName 字段,绕过 Kubernetes 调度器和任何潜在的防护措施),则他们将能够无限制地访问 EC2 元数据 API。
对此问题的缓解措施是运行一个变更或验证入站 Webhook,确保在向 Kubernetes API 发送 Pod 创建和更新请求时,nodeName 字段未预填充。
kiam 为使单个 Pod 能够访问 AWS API 提供了一个强大的解决方案,采用现有 AWS 用户熟悉的模型(角色承担)。在许多情况下,只要在使用前采取了前述缓解措施,这是一个可行的解决方案。
IAM Roles for Service Accounts (IRSA)
从 2019 年底以来,AWS 提供了 Kubernetes 和 IAM 之间的本地集成,称为 IAM Roles for Service Accounts (IRSA)。
在高层次上,IRSA 提供了一种类似于 kiam 和 kube2iam 的体验,用户可以使用 AWS IAM 角色对他们的 Pod 进行注解以指定其所需承担的角色。尽管实现方式有很大不同,但消除了早期方法的安全顾虑。
AWS IAM 支持将身份联合到第三方 OIDC 提供者,例如 Kubernetes API 服务器。正如您在 PSAT 中已经看到的那样,Kubernetes 能够为每个 Pod 创建和签署短期令牌。
AWS IRSA 将这些特性与在其 SDK 中的额外凭据提供程序结合起来,调用 sts:AssumeRoleWithWebIdentity,传递 PSAT。PSAT 和所需的角色需要作为 Pod 内的环境变量注入(有一个基于所需的 serviceAccountName 将自动执行此操作的 Webhook):
apiVersion: apps/v1
kind: Pod
metadata:
name: myapp
spec:
serviceAccountName: my-serviceaccount
containers:
- name: myapp
image: myapp:1.2
env:
- name: AWS_ROLE_ARN
value: "arn:aws:iam::123456789012:role/\
eksctl-irptest-addon-iamsa-default-my-\
serviceaccount-Role1-UCGG6NDYZ3UE"
- name: AWS_WEB_IDENTITY_TOKEN_FILE
value: /var/run/secrets/eks.amazonaws.com/serviceaccount/token
volumeMounts:
- mountPath: /var/run/secrets/eks.amazonaws.com/serviceaccount
name: aws-iam-token
readOnly: true
volumes:
- name: aws-iam-token
projected:
defaultMode: 420
sources:
- serviceAccountToken:
audience: sts.amazonaws.com
expirationSeconds: 86400
path: token
Kubernetes 并不原生暴露.well-known OIDC 端点,因此需要额外的工作来配置公共位置(静态 S3 存储桶),以便 AWS IAM 使用 Kubernetes 的公共服务账号签名密钥验证令牌。
经验证后,AWS IAM 响应应用程序的请求,将 PSAT 交换为所需的 IAM 角色凭据,如图 10-11 所示。

图 10-11. 服务账号的 IAM 角色。
尽管 IRSA 的设置有些笨拙,但它具有所有 Pod IAM 角色假设方法中最佳的安全模型。
对于已经利用 AWS 服务的组织来说,IRSA 是一个强有力的选择,因为它使用的模式和基元将与您的运营和开发团队熟悉。所采用的模型(将服务账号映射到 IAM 角色)也是一种易于理解且具有强大安全模型的模型。
主要缺点是,如果未使用亚马逊弹性 Kubernetes 服务(EKS),则 IRSA 部署和配置起来可能有些繁琐。然而,Kubernetes 本身的最新添加将减轻部分技术挑战,比如将 Kubernetes 本身暴露为 OIDC 提供者。
正如我们在本节中看到的,通过一个通用平台(在本例中为 AWS)调解身份具有许多优点。在下一节中,我们将深入探讨旨在实现相同模型但能够跨多个底层平台运行的工具。
使用 SPIFFE 和 SPIRE 的跨平台身份
安全生产身份框架(SPIFFE)是一个标准,指定了身份(SPIFFE 可验证身份文档,SVID)的语法,可以利用现有的加密格式,如 x509 和 JWT。它还指定了一些用于提供和消费这些身份的 API。SPIFFE ID 的形式为spiffe://trust-domain/hierarchical/workload,spiffe://后的所有部分都是可以在多种方式中使用的任意字符串标识符(尽管创建某种层次结构是最常见的)。
SPIFFE 运行时环境(SPIRE)是 SPIFFE 的参考实现,具有多个 SDK 和集成,允许应用程序使用(提供和消费)SVID。
本节将假设同时使用 SPIFFE 和 SPIRE,除非另有说明。
架构和概念
SPIRE 运行一个服务器组件,作为身份签发机构,并维护所有工作负载身份及其颁发身份文档所需条件的注册表。
SPIRE 代理作为 DaemonSet 在每个节点上运行,它们通过 Unix 套接字公开 API,以便工作负载通过它请求身份。代理还配置为对 kubelet 具有只读访问权限,以确定节点上的 Pod 的元数据。SPIRE 架构显示在图 10-12 中。

图 10-12. SPIRE 架构。取自官方 SPIRE 文档。
当代理上线时,它们通过一种称为节点认证的过程验证并向服务器注册自己(如图 10-13 所示)。此过程利用环境上下文(例如,AWS EC2 元数据 API 或 Kubernetes PSATs)来识别节点并为其分配 SPIFFE ID。然后服务器以 x509 SVID 的形式向节点发出身份。以下是节点的注册示例:
/opt/spire/bin/spire-server entry create \
-spiffeID spiffe://production-trust-domain/nodes \
-selector k8s_psat:cluster:production-cluster \
-selector k8s_psat:agent_ns:spire \
-selector k8s_psat:agent_sa:spire-agent \
-node
这告诉 SPIRE 服务器,对于任何满足所指定选择器的代理 Pod 的节点,都要分配 SPIFFE ID spiffe://production-trust-domain/nodes;在这种情况下,我们选择 Pod 在 production-cluster 上在 spire-agent 服务账户下运行的 SPIRE 命名空间中(通过 PSAT 验证)。

图 10-13. 节点认证。取自官方 SPIRE 文档。
当工作负载上线时,它们调用节点本地的工作负载 API 请求 SVID。SPIRE 代理使用其在平台上可用的信息(来自内核、kubelet 等)来确定调用工作负载的属性。此过程称为工作负载认证(如图 10-14 所示)。然后 SPIRE 服务器根据已知的工作负载标识基于其选择器匹配属性,并返回一个 SVID 给工作负载(通过代理),可用于对其他系统进行身份验证:
/opt/spire/bin/spire-server entry create \
-spiffeID spiffe://production-trust-domain/service-a \
-parentID spiffe://production-trust-domain/nodes \
-selector k8s:ns:default \
-selector k8s:sa:service-a \
-selector k8s:pod-label:app:frontend \
-selector k8s:container-image:docker.io/johnharris85/service-a:v0.0.1
这告诉 SPIRE 服务器,对于任何满足以下条件的工作负载,都要分配 SPIFFE ID spiffe://production-trust-domain/service-a:
-
正在运行在 ID 为
spiffe://production-trust-domain/nodes的节点上。 -
正在运行在
default命名空间中。 -
正在
service-a服务账户下运行。 -
具有 Pod 标签
app: frontend。 -
使用
docker.io/johnharris85/service-a:v0.0.1镜像构建。

图 10-14. 工作负载认证。取自官方 SPIRE 文档。
警告
请注意,工作负载 attestor 插件可以使用其服务账户查询 kubelet(以发现有关工作负载的信息)。然后 kubelet 使用 TokenReview API 来验证持有者令牌。这需要连接到 Kubernetes API 服务器。因此,API 服务器停机可能会中断工作负载认证。
--authentication-token-webhook-cache-ttl kubelet 标志控制 kubelet 缓存 TokenReview 响应的时间长度,可以帮助缓解这个问题。然而,不建议设置过长的缓存 TTL 值,因为这可能影响权限撤销。有关更多详细信息,请参阅 SPIRE 工作负载认证者文档。
本节描述的模式在尝试为工作负载构建强大的身份系统时具有显著优势,无论是在 Kubernetes 内外。SPIFFE 规范利用了在 x509 和 JWT 中广为人知和广泛支持的密码学标准,SPIRE 实现还支持许多不同的应用程序集成方法。另一个关键特性是通过将预期服务账户令牌与其自身的选择器结合来将身份范围化到非常细粒度的能力,以识别单个 Pod。这在存在边车容器的 Pod 中尤其有用,并且每个容器需要不同级别的访问时。
这种方法无疑是最费时的,并且需要在工具化和环境中维护另一个组件的专业知识和努力。虽然可能需要注册每个工作负载,但这可以自动化(社区已在自动注册工作负载的领域进行工作)。
SPIFFE/SPIRE 在工作负载应用程序中有多个集成点。选择适当的集成点取决于对平台耦合程度的期望以及用户对环境的控制量。
直接应用访问
SPIRE 为 Go、C 和 Java 提供 SDK,供应用程序直接集成 SPIFFE 工作负载 API 使用。这些 SDK 封装了现有的 HTTP 库,但提供了获取和验证身份的本地支持。以下是在 Go 中调用 Kubernetes 服务 service-b 并期望特定 SPIFFE ID(通过 x509 SVID)的示例:
err := os.Setenv("SPIFFE_ENDPOINT_SOCKET",
"unix:///run/spire/sockets/agent.sock")
conn, err := spiffe.DialTLS(ctx, "tcp", "service-b",
spiffe.ExpectPeer("spiffe://production-trust-domain/service-b"))
if err != nil {
log.Fatalf("Unable to create TLS connection: %v", err)
}
SPIRE 代理还为那些希望与平台紧密集成但在没有 SDK 可用的语言中工作的用户提供了 gRPC API。
对于最终用户应用程序,不建议直接集成(如本小节中所述)的原因如下:
-
它紧密地将应用程序与平台/实现耦合。
-
需要将 SPIRE 代理 Unix 套接字挂载到 Pod 中。
-
它不容易扩展。
如果正在构建一些中间平台工具,直接使用这些库是合适的主要领域,它们包装或扩展了工具集的某些现有功能。
边车代理
SPIRE 原生支持 Envoy SDS API,用于发布证书供 Envoy 代理消费。Envoy 然后可以使用 SVID x509 证书与其他服务建立 TLS 连接,并使用信任捆绑包验证传入连接。
Envoy 还支持验证仅特定 SPIFFE ID(编码到 SVID 中)应能够连接的功能。有两种方法实现此验证:
-
在 Envoy 配置中指定
verify_subject_alt_name值的列表。 -
通过利用 Envoy 的外部授权 API 将准入决策委托给外部系统(例如,Open Policy Agent)。以下是一个实现此目的的 Rego 策略示例:
package envoy.authz
import input.attributes.request.http as http_request
import input.attributes.source.address as source_address
default allow = false
allow {
http_request.path == "/api"
http_request.method == "GET"
svc_spiffe_id == "spiffe://production-trust-domain/frontend"
}
svc_spiffe_id = client_id {
[_, _, uri_type_san] := split(
http_request.headers["x-forwarded-client-cert"], ";")
[_, client_id] := split(uri_type_san, "=")
}
在此示例中,Envoy 对请求的 TLS 证书与 SPIRE 信任捆绑进行验证,然后将授权委托给 Open Policy Agent(OPA)。Rego 策略检查 SVID,如果 SPIFFE ID 匹配 spiffe://production-trust-domain/frontend,则允许请求。此流程的架构显示在 图 10-15 中。
警告
这种方法将 OPA 插入关键请求路径中,因此在设计流程/架构时应考虑这一点。

图 10-15. SPIRE 与 Envoy。
服务网格(Istio)
Istio 的 CA 为所有服务账户创建 SVID,将 SPIFFE ID 编码为格式 spiffe://cluster.local/ns/<namespace>/sa/<service_account>。因此,Istio 网格中的服务可以利用 SPIFFE-aware 端点。
注意
虽然服务网格不在本章的讨论范围内,但许多服务网格尝试解决身份验证和认证问题。大多数尝试包括或基于本章详细介绍的方法和工具。
其他应用程序集成方法
除了刚讨论的主要方法外,SPIRE 还支持以下功能:
-
将 SVID 和信任捆绑直接拉取到文件系统,使应用程序能够检测更改并重新加载。虽然这样可以使应用程序在某种程度上对 SPIRE 保持不可知,但也会打开从文件系统中窃取证书的攻击向量。
-
Nginx 模块允许从 SPIRE 流式传输证书(类似于之前描述的 Envoy 集成)。有定制的 Nginx 模块使用户能够指定应允许连接到服务器的 SPIFFE ID。
与秘密存储(Vault)的集成
当应用程序需要从 HashiCorp Vault 获得一些共享秘密材料时,SPIRE 可用于解决安全引入问题。可以配置 Vault 使用 OIDC 联合身份验证与 SPIRE 服务器作为 OIDC 提供者来对客户端进行身份验证。
Vault 中的角色可以绑定到特定主体(SPIFFE ID),因此当工作负载从 SPIRE 请求 JWT SVID 时,可以有效地获取角色和因此访问 Vault 的凭证。
与 AWS 的集成
SPIRE 还可以用于建立身份并向 AWS 服务进行认证。该过程利用了 AWS IRSA 和 Vault 部分中的 OIDC 联合身份认证思想。工作负载请求 JWT SVID,然后 AWS 通过对联合 OIDC 提供程序(SPIRE 服务器)验证以验证它们。这种方法的缺点是,SPIRE 必须是公开可访问的,以便 AWS 发现验证 JWT 所需的 JSON Web Key Set(JWKS)材料。
摘要
在本章中,我们深入探讨了我们在现场成功看到和实施的模式和工具。
身份是一个多层次的主题,随着您对不同模式的复杂性以及其与每个个体组织需求的契合程度感到更加舒适,您的方法也会随之演变。通常在用户身份方面,您可能已经有某种第三方 SSO,但直接通过 OIDC 将其集成到 Kubernetes 可能看起来并不简单。在这些情况下,我们看到 Kubernetes 通常独立于主要组织身份策略之外。根据需求,这可能是可以接受的,但直接集成将提供更大的环境可见性和控制,特别是对于具有多个集群的情况。
在工作负载/应用程序方面,我们经常看到这被视为事后处理(超出默认的服务账户)。同样地,根据内部需求,这可能是可以接受的。确实,对于工作负载身份的强大解决方案的实施,无论是集群内还是跨平台,有时会引入显著的复杂性,并需要深入了解外部工具。然而,当组织在 Kubernetes 方面达到一定成熟度时,我们认为实施本章描述的模式可以显著提升您的 Kubernetes 环境的安全姿态,并在发生违规事件时提供额外的深度防御层。
第十一章:构建平台服务
平台服务是安装的组件,用于为应用平台添加功能。它们通常作为容器化工作负载部署到某个*-system命名空间,并由平台工程团队维护。这些平台服务与应用开发团队维护的工作负载有所区别。
云原生生态系统中有许多项目可用作应用平台的一部分。此外,有大量供应商愿意提供平台服务解决方案。请在成本效益分析通过的地方使用它们。它们甚至可能将您带到应用平台的目的地。但我们发现,基于 Kubernetes 平台的企业用户通常会构建定制组件。您可能需要将基于 Kubernetes 的平台与某些现有的内部系统集成。您可能需要满足一些独特而复杂的工作负载要求。您可能需要考虑到一些罕见或特定于业务需求的边缘情况。无论情况如何,本章将详细讨论如何通过定制解决方案扩展您的应用平台,以填补这些空白。
构建定制平台服务的核心思想是消除人力劳动。这不仅仅是自动化。自动化是基石,但自动化组件的整合是灰泥。系统间的平稳可靠交互既具有挑战性又至关重要。API 驱动软件的概念之所以强大,是因为它促进了软件系统的集成。这也是为什么 Kubernetes 取得了如此广泛的应用:它使您的整个平台都能实现 API 驱动行为,而无需为每个添加到平台的软件构建和公开 API。此类软件可以通过管理核心资源或添加自定义资源来利用 Kubernetes API,以表示新对象的状态。如果我们在构建平台服务时遵循这些集成自动化的模式,我们就有可能消除巨大的人力劳动。如果我们在这方面取得成功,我们将为创新、发展和进步开辟更大的机会。
在本章中,我们将探讨如何扩展 Kubernetes 控制平面。我们正在采用 Kubernetes 使用的有效工程模式,并利用这些模式来构建这些系统。在本章的大部分内容中,我们将探讨 Kubernetes 运算符、它们的设计模式和用例,以及如何开发它们。然而,首先我们需要了解 Kubernetes 的扩展点,以便保持构建平台服务的整体视图。我们需要明确的上下文,并应用与整体系统和谐的解决方案。最后,我们将研究如何扩展可能是生态系统中最重要的 Kubernetes 控制器:调度器。
扩展点
Kubernetes 是一个极具扩展性的系统。这无疑是其最强大的特性之一。在软件开发中的一个常见的关键错误是试图添加功能以满足每一个可能的用例。系统很快就会变成一个选项迷宫,到达结果的路径不清晰。此外,随着内部依赖关系的增长和系统组件之间脆弱的连接,系统往往变得不稳定,可靠性逐渐下降。Unix 哲学的核心原则之一是做好一件事并使其可互操作。Kubernetes 永远无法满足用户在编排其容器化工作负载时可能遇到的每一个需求。构建这样一个系统是不可能的。它提供的核心功能已经足够具有挑战性了。实际上,即使是具有相对狭窄关注点的 Kubernetes,也是一个相对复杂的分布式软件系统。它不可能满足每一个需求,也不需要,因为它提供了扩展点,允许专门的解决方案满足专门的需求,这些解决方案可以很容易地集成。它可以被扩展和定制,以满足你可能有的几乎所有需求。
我们称之为插件扩展的上下文,这些扩展满足 Kubernetes 定义的接口,在本书的其他部分已广泛涵盖,一些流行的 Webhook 扩展解决方案也是如此。我们在这里简要回顾它们,以围绕运算符扩展主题画一个轮廓,本章将在其中花费大量时间。
插件扩展
这是一类广泛的扩展,通常帮助将 Kubernetes 与重要且经常是运行在 Kubernetes 上的工作负载不可或缺的相邻系统集成起来。它们是第三方可以使用来实现解决方案的规范,而不是实现本身:
网络
容器网络接口(CNI)定义了插件必须满足的接口,以为容器提供网络连接。存在许多插件来满足这一要求,但它们都必须满足 CNI 的标准。本主题在第五章中有所涉及。
存储
容器存储接口(CSI)提供了一种系统,用于向容器化工作负载公开存储系统。同样,有许多不同的卷插件从不同提供商公开存储。这个主题在 第四章 中进行了探讨。
容器运行时
容器运行时接口(CRI)定义了容器运行时需要暴露的操作标准,使得 kubelet 不关心使用的运行时是什么。历史上 Docker 是最流行的,但现在有其他具有自身优势的运行时也变得流行起来。我们在 第三章 中详细讨论了这个话题。
设备
Kubernetes 设备插件框架允许工作负载访问底层节点上的设备。我们在实际应用中发现的最常见的例子是用于计算密集型工作负载的图形处理单元(GPU)。通常为具有这些专用设备的节点集群添加节点池,以便将工作负载分配给它们。关于这个主题,请参阅 第二章。
这些插件的开发通常由支持或销售与集成的产品的供应商进行。根据我们的经验,很少有平台开发人员在这个领域构建自定义解决方案。相反,通常是评估可用选项并利用符合您需求的选项。
Webhook 扩展
Webhook 扩展作为 Kubernetes API 服务器的后端服务器,用于满足核心 API 功能的自定义版本。每个请求到达 API 服务器时,都经历了几个步骤。客户端经过身份验证以确保他们被允许访问(AuthN)。API 检查客户端是否被授权执行他们请求的操作(AuthZ)。API 服务器根据启用的准入插件对资源进行修改。验证资源模式,并通过验证准入控制执行任何专门或自定义的验证。图 11-1 描述了客户端、Kubernetes API 和 API 利用的 webhook 扩展之间的关系。
认证扩展
认证扩展,例如 OpenID Connect(OIDC),提供了将请求身份验证任务卸载到 API 服务器的机会。这个主题在 第十章 中有详细介绍。
您还可以让 API 服务器调用 webhook 来授权经过身份验证的用户对资源可以采取的操作。尽管 Kubernetes 内置了强大的基于角色的访问控制系统,这种实现方式并不常见。但是,如果您发现这种系统因某种原因不足够,您可以选择这种选项。
准入控制
Admission 控制是一个特别有用且广泛使用的扩展点。如果启用,当向 API 服务器发送请求执行操作时,API 服务器会根据验证和变更的 admission webhook 配置调用任何适用的 admission webhooks。有关此主题的详细信息,请参见 第八章。

图 11-1. Webhook 扩展是 Kubernetes API 服务器利用的后端服务器。
操作符扩展
操作符是 API 服务器的客户端,而不是后端 webhook。如 图 11-2 所示,软件操作符作为 Kubernetes API 的客户端与之交互,就像人工操作员一样。这些软件操作符通常称为 Kubernetes 操作符,遵循官方文档中的 操作符模式。它们的主要目的是减轻人工操作员的工作负担,并代表他们执行操作。这些操作符扩展遵循与核心 Kubernetes 控制平面组件相同的工程原理。在将操作符扩展作为平台服务开发时,想象它们是你应用平台控制平面的自定义扩展。

图 11-2. 操作符扩展是 Kubernetes API 服务器的客户端。
操作符模式
你可以说操作符模式归根结底就是用 Kubernetes 扩展 Kubernetes。我们创建新的 Kubernetes 资源,并开发 Kubernetes 控制器来协调其中定义的状态。我们使用称为自定义资源定义(CRD)的 Kubernetes 资源来定义我们的新资源。这些 CRD 创建新的 API 类型,并告诉 Kubernetes API 如何验证这些新对象。然后,我们采用使 Kubernetes 控制器如此高效的相同原理和设计,并利用这些原则构建系统的软硬件扩展。这两个核心机制是我们在构建操作符时使用的:自定义资源和控制器。
操作符的概念由 CoreOS 的创始人之一 Brandon Phillips 于 2016 年 11 月引入。早期对操作符的定义是操作符是一个特定于应用的控制器,用于管理复杂的有状态应用程序。这个定义仍然非常有用,但多年来有所扩展,现在 Kubernetes 文档将使用 CRD 的任何控制器归类为操作符。这个更通用的定义是我们将在构建平台服务时使用的定义。你的平台服务可能不是“复杂的有状态应用程序”,但仍然可以从使用这种强大的操作符模式中受益。
接下来的部分将介绍 Kubernetes 控制器,这些控制器提供了我们将在自定义控制器中使用的功能模型。然后我们将研究存储期望状态和现有状态的自定义资源,这些资源由我们的控制器协调。
Kubernetes 控制器
控制器的核心功能和功能由 Kubernetes 提供。它们监视资源类型,并响应资源的创建、变更和删除,以实现期望的状态。例如,kube-controller-manager 预装的控制器会监视 ReplicaSet 资源类型。当创建 ReplicaSet 时,控制器会创建与 ReplicaSet 中的 replicas 字段定义的数量相同的 Pod。稍后,如果更改该值,控制器将创建或删除 Pod 以满足新的期望状态。
这种监视机制是所有 Kubernetes 控制器功能的核心。它是由 Kubernetes API 服务器暴露给需要响应资源更改的控制器的 etcd 功能。控制器与 API 服务器保持连接,这使得 API 服务器能够在关心或管理的资源发生更改时通知控制器。
这种机制能够提供非常强大的行为。用户可以通过提交资源清单声明系统的期望状态。负责实现期望状态的控制器收到通知后开始工作,以使现有状态与声明的期望状态匹配。此外,除了用户提交清单外,控制器还可以执行相同的操作,从而触发其他控制器的操作。通过这种方式,您可以建立一个控制器系统,提供稳定可靠的复杂功能。
这些控制器的一个重要特点是,如果它们由于某些障碍无法满足期望的状态,它们将继续尝试在无限循环中操作。尝试满足期望状态之间的时间间隔可能随着时间的推移而增加,以便不对系统施加不必要的负荷,但它们依然会尝试。这提供了一种自我修复行为,在复杂的分布式系统中尤为重要。
例如,调度器负责将 Pod 分配给集群中的节点。调度器只是另一个 Kubernetes 控制器,但其任务非常重要且复杂。如果没有足够的计算资源可用于一个或多个 Pod,它们将进入“Pending”状态,调度器将继续尝试以一定的间隔调度 Pod。因此,如果在任何时候释放了计算资源或添加了计算节点,该 Pod 将被调度并运行。例如,如果另一个批处理工作负载完成并释放了资源,或者如果集群自动缩放器添加了一些工作节点,则“Pending” Pod 将被分配,无需运营人员进一步操作。
在遵循操作者模式来构建应用平台的扩展时,使用 Kubernetes 控制器所采用的设计原则至关重要:(1)监视 Kubernetes API 中的资源,以便在其期望状态发生更改时得到通知;以及(2)在非终止循环中努力协调现有状态与期望状态。
自定义资源
Kubernetes API 的最重要特性之一是其能够扩展它将识别的资源类型。如果您提交一个有效的 CRD,您将立即拥有一个新的自定义 API 类型可供使用。CRD 包含了您在自定义资源中所需的所有字段,无论是在spec中,您可以在那里提供资源的期望状态,还是在status中,您可以记录关于观察到的现有状态的重要信息。
在进一步深入这个主题之前,让我们简要回顾一下 Kubernetes 资源。在本章中,我们将大量讨论资源,因此确保我们对此主题非常清楚是很重要的。当我们在 Kubernetes 中谈论 资源 时,我们指的是用于记录状态的对象。一个常见资源的示例是 Pod 资源。当您创建 Pod 清单时,您正在定义将成为 Pod 资源的属性。当您使用 kubectl apply -f pod.yaml 或类似命令将其提交到 API 服务器时,您正在创建 Pod API 类型的实例。一方面,您有 API 类型或“种类”,它指的是在 CRD 中提供的对象的定义和形式。另一方面,我们有资源,它是该种类的实例或实例化。Pod 是一个 API 类型或种类。您使用名称“my-app”创建的 Pod 是一个 Kubernetes 资源。
不同于关系数据库,其中对象之间的关系由数据库中的外键记录和链接,Kubernetes API 中的每个对象都是独立存在的。关系是使用标签和选择器建立的,控制器的工作是管理以这种方式定义的关系。您无法像使用结构化查询语言(SQL)那样查询 etcd 中的相关对象。因此,当我们谈论资源时,我们指的是命名空间、Pod、部署、机密、配置映射等的实际实例。当我们谈论自定义资源时,我们指的是使用 CRD 添加和定义的用户定义资源。当您创建 CRD 时,您定义了新的 API 类型,使您能够像管理其他核心 Kubernetes 资源一样创建和管理自定义资源。
CRD 使用 Open API v3 模式规范来定义字段。这允许特性,如将字段设置为可选或必需,以及设置默认值。这将为 API 服务器提供验证指令,当它收到创建或更新您的自定义资源的请求时。此外,您还可以对 API 进行分组以改善逻辑组织,并且非常重要的是,也可以对您的 API 类型进行版本控制。
为了说明 CRD 是什么以及生成的自定义资源清单是什么样子,让我们看一个虚构的 WebApp API 类型的示例。在此示例中,WebApp 资源包括一个由以下六个 Kubernetes 资源组成的 Web 应用程序的期望状态:
部署
为客户提供用户界面、处理请求并将数据存储在关系数据库中的无状态应用
StatefulSet
提供 Web 应用程序的持久数据存储的关系数据库
ConfigMap
包含状态应用的配置文件,该文件被挂载到每个部署的 Pod 中
Secret
包含应用程序连接到其数据库所需的凭据
Service
路由流量到部署的后端 Pod。
Ingress
包含路由规则,使 Ingress 控制器能够正确路由客户端请求到集群中
创建 WebApp 资源将促使 WebApp 操作员创建这些各种子资源。这些创建的资源将构成一个完整、功能齐全的 Web 应用实例,为企业的最终用户和客户提供服务。
示例 11-1 展示了定义新 WebApp API 类型的 CRD 的样例。
示例 11-1. WebApp CRD 清单
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: webapps.workloads.apps.acme.com 
spec:
group: workloads.apps.acme.com
names: 
kind: WebApp
listKind: WebAppList
plural: webapps
singular: webapp
scope: Namespaced
versions:
- name: v1alpha1
schema:
openAPIV3Schema:
description: WebApp is the Schema for the webapps API
properties:
apiVersion:
description: 'APIVersion defines the versioned schema of this
representation of an object. Servers should convert recognized
schemas to the latest internal value, and may reject unrecognized
values.'
type: string
kind:
description: 'Kind is a string value representing the REST resource this
object represents. Servers may infer this from the endpoint the client
submits requests to. Cannot be updated. In CamelCase.'
type: string
metadata:
type: object
spec:
description: WebAppSpec defines the desired state of WebApp
properties:
deploymentTier: 
enum:
- dev
- stg
- prod
type: string
webAppHostname:
type: string
webAppImage:
type: string
webAppReplicas: 
default: 2
type: integer
required: 
- deploymentTier
- webAppHostname
- webAppImage
type: object
status:
description: WebAppStatus defines the observed state of WebApp
properties:
created:
type: boolean
type: object
type: object
served: true
storage: true
自定义资源定义的名称,与自定义资源本身的名称不同。
自定义资源的名称,与定义不同。这包括名称的变体,如复数形式。
deploymentTier 字段必须包含 enum 列出的值之一。当 API 服务器接收到创建或更新此自定义资源实例的请求时,将执行此验证。
webAppReplicas 字段包含一个默认值,如果未提供该字段,则会应用该默认值。
此处列出了必填字段。注意 webAppReplicas 未包括在内且具有默认值。
现在让我们看看一个 WebApp 的清单会是什么样子。在将示例 11-2 中显示的清单提交给 API 服务器之前,您必须先创建示例 11-1 中显示的 CRD,以便 Kubernetes 有一个针对它的 API。否则,它将无法识别您试图创建的内容。
示例 11-2. WebApp 资源清单的示例
apiVersion: workloads.apps.acme.com/v1alpha1
kind: WebApp
metadata:
name: webapp-sample
spec:
webAppReplicas: 2 
webAppImage: registry.acme.com/app/app:v1.4
webAppHostname: app.acme.com
deploymentTier: dev 
此清单指定了可选字段的默认值,如果需要显式指定,则是不必要的但可行的。
此字段的允许值之一被使用。任何不被允许的值将促使 API 服务器拒绝带有错误的请求。
当此 WebApp 清单提交到 Kubernetes API 时,WebApp 运算符将通过其监视收到通知,表示已创建 WebApp 类型的新实例。它将通过调用 API 服务器来创建各种需要的子资源,以启动 Web 应用程序的实例,从而实现清单中表达的所需状态。
警告
虽然自定义资源模型功能强大,但不要过度使用它。不要将自定义资源用作最终用户应用程序的主要数据存储。Kubernetes 是一个容器编排系统。etcd 应该存储您的软件部署的状态,而不是应用程序的内部持久数据。这样做会给集群的控制平面带来重大负荷。坚持使用关系数据库、对象存储或适合应用程序的任何数据存储。让控制平面管理软件部署。
运算符使用案例
在开发基于 Kubernetes 的平台时,运算符为向该平台添加功能提供了引人注目的模型。如果您可以在自定义资源的字段中表示所需实现的系统状态,并且可以通过使用 Kubernetes 控制器调和变更以产生价值,那么运算符通常是一个很好的选择。
除了平台功能之外,运算符还可用于简化在平台上管理软件部署。它们可以提供通用抽象的便利,或者可以根据特定复杂应用程序的需求进行定制构建。无论哪种情况,使用软件来管理软件部署非常有用。
在本节中,我们将讨论您可以考虑在平台上使用的三种一般运算符类别:
-
平台实用工具
-
通用工作负载运算符
-
应用特定运算符
平台实用工具
运算符在开发您的平台时可以非常有用。它们允许您向集群添加功能,并在 Kubernetes 的基础上构建功能,以一种与控制平面无缝集成和利用的方式。有大量的开源项目利用运算符在 Kubernetes 上提供平台服务。这些项目已经可用,无需您开发。我们在讨论构建它们的章节中提到它们的原因是它们帮助建立一个良好的心理模型,以了解它们的工作方式。如果您发现自己不得不开发定制平台实用工具,查看现有成功项目将是有帮助的。
-
Prometheus 运算符 允许您在平台上提供度量收集、存储和警报服务。在 第九章 中,我们深入探讨了可以从该项目中获得的价值。
-
cert-manager 提供作为服务的证书管理功能。通过提供 x509 证书的创建和更新服务,它消除了显著的重复工作和潜在的停机风险。
这些开源解决方案是社区中可用的示例。还有无数供应商可以提供和支持类似的平台工具。然而,当不存在或不适合解决方案时,企业有时会构建自己的定制平台工具。
我们在现场常见的定制平台工具的一个常见示例是命名空间操作器。我们发现组织通常有一套标准资源,每个命名空间都会创建这些资源,例如 ResourceQuotas、LimitRanges 和 Roles。使用控制器来处理为每个命名空间创建这些资源的例行琐事已经是一种有用的模式。在后面的部分,我们将使用命名空间操作器的概念作为一个例子,以展示构建操作器时的一些实现细节。
通用工作负载操作器
应用开发人员的核心能力和关注点是为其构建的软件增加稳定性和功能。不是编写用于 Kubernetes 部署的 YAML。学习如何正确定义资源限制和请求,学习如何将 ConfigMap 卷或 Secret 作为环境变量挂载,学习如何使用标签选择器将服务与 Pod 关联起来——这些事情都不能为他们的软件增加功能或稳定性。
在已经为部署工作负载开发了常见模式的组织中,以通用方式抽象复杂性的模型具有相当大的潜力。这在已经采用微服务架构的组织中尤其相关。在这些环境中,可能会部署大量具有不同功能但部署模式非常相似的软件组件。
例如,如果您的公司有大量由 Deployment、Service 和 Ingress 资源组成的工作负载,可能存在可以将这些对象的大部分资源清单抽象为操作器的模式。在每种情况下,Service 引用 Deployment 的 Pod 上的标签。在每种情况下,Ingress 引用 Service 名称。所有这些细节都可以由操作器轻松处理——确保这些细节正确无误是琐事的定义。
应用特定操作器
这种类型的操作器直接触及 Kubernetes 操作员的核心:自定义资源与自定义 Kubernetes 控制器结合,用于管理复杂的有状态应用程序。它们专为管理特定应用程序而构建。社区中此模型的流行示例包括各种数据库操作器。我们有 Cassandra、Elasticsearch、MySQL、MariaDB、PostgreSQL、MongoDB 等操作器。通常,它们处理初始部署以及配置更新、备份和升级等第二天管理的问题。
过去几年里,为流行的社区或供应商支持的项目开发的操作员已经变得越来越受欢迎。这种方法仍处于内部企业应用的初期阶段。在您的组织内部开发和维护复杂的有状态应用程序的情况下,可能会有利于使用特定于应用的操作员。例如,如果您的公司维护类似电子商务网站、交易处理应用程序或库存管理系统等提供关键业务功能的应用程序,您可能需要考虑这个选项。特别是当这些工作负载被广泛部署并频繁更新时,有很大的机会减少人为工作的投入,尤其是在部署和日常管理方面。
这并不是说这些特定于应用程序的操作员是管理工作负载的普遍正确选择。对于更简单的用例,它们可能过于复杂了。生产就绪的操作员开发并不是一件轻松的事情,所以需要权衡利弊。您在日常工作中管理应用程序的部署和日常管理工作花费了多少时间?与长期的例行工作相比,构建操作员的工程成本可能更低吗?像 Helm 或 Kustomize 这样的现有工具是否能提供足够的自动化来有效减少这些例行工作?
操作员开发
开发 Kubernetes 操作员任务并不轻松,特别是当涉及全功能、特定于应用的操作员时。将这类更复杂的项目投入生产的工程投入可能是相当可观的。与其他软件开发类型类似,如果初次涉足这个领域,建议从较简单的项目开始,同时熟悉有用的模式和成功的策略。在本节中,我们将讨论一些工具和设计策略,这些将有助于使操作员开发更高效、更成功。
我们将介绍一些您可以利用的特定项目,来帮助开发这些工具。然后,我们将详细分解设计和实现这类软件的过程。我们将包含一些代码片段来说明相关概念和最佳实践。
操作员开发工具
如果你有一个为自定义 Kubernetes 运算符创建强有力使用案例的需求,有几个社区项目可能对你的努力非常有帮助。如果你自己或团队中有经验丰富的 Go 程序员,熟悉 Kubernetes 的 client-go 库以及开发 Kubernetes 运算符,你完全可以从头开始编写你的运算符。然而,每个运算符都有共同的组件,使用工具来生成样板源代码和实用程序是经验丰富的运算符开发人员普遍使用的便利工具,它们可以节省时间。软件开发工具包(SDK)和框架在符合你正在开发的软件模式时会很有帮助。然而,如果它们做出的假设不适合你的目的,它们可能会成为一种麻烦。如果你的项目符合使用一个或多个自定义资源来定义配置,并使用自定义控制器来实现与这些对象相关联行为的标准模型,那么我们在这里讨论的工具很可能会对你有所帮助。
Kubebuilder
Kubebuilder 可以被描述为构建 Kubernetes API 的 SDK。这是一个恰当的描述,但不完全符合你可能期望的预期。使用 kubebuilder 从命令行工具开始,你可以用它来生成样板。它会生成源代码、一个 Dockerfile、一个 Makefile,以及示例 Kubernetes 清单——所有你需要为每个类似项目编写的东西。因此,它在启动项目方面节省了大量时间。
Kubebuilder 还利用了一个称为 controller-runtime 的相关项目中的一系列工具。CLI 生成的源代码中包含了所需的导入和常见实现。这些工具有助于处理控制器的日常繁重工作并与 Kubernetes API 交互。它帮助设置共享缓存和客户端,以便有效地与 API 服务器进行交互。缓存允许你的控制器列出和获取对象,而无需为每个查询向 API 服务器发送新请求,从而减轻 API 服务器的负载并加快协调速度。Controller-runtime 还提供了在资源更改等事件发生时触发协调请求的机制。这些协调默认会为父自定义资源触发。通常情况下,也应在控制器创建的子资源发生更改时触发它们,函数可用来执行此操作。如果在高可用(HA)模式下运行控制器,controller-runtime 提供了启用领导选举以确保任何给定时间只有一个控制器处于活动状态的机会。此外,controller-runtime 还包括一个实现 Webhooks 的包,通常用于准入控制。最后,该库包含编写结构化日志和暴露 Prometheus 指标以进行可观察性的设施。
如果你是一个具有 Kubernetes 经验的 Go 程序员,Kubebuilder 是一个很好的选择。即使你是一位有经验的软件开发者但是对 Go 编程语言还不熟悉,Kubebuilder 也是一个不错的选择。但它只适用于 Go,不支持其他语言。
提示
如果你打算为 Kubernetes 开发工具,如果你还不懂 Go 语言,你应该强烈考虑学习它。当然你也可以使用其他语言。毕竟 Kubernetes 提供了一个 REST API。并且官方支持的客户端库有 Python、Java、C#、JavaScript 和 Haskell,更不用说许多其他社区支持的库了。如果你有使用这些语言的重要理由,你肯定可以取得成功。然而,Kubernetes 本身是用 Go 编写的,在 Kubernetes 的世界里,Go 语言的生态系统非常丰富且得到了良好的支持。
Kubebuilder 的一个特性是它可以生成 CRD,这使得它成为一个节省时间的工具。手动编写 CRD 的清单可不是闹着玩的。用于定义这些自定义 API 的 OpenAPI v3 规范非常详细和复杂。Kubebuilder CLI 将生成文件,你可以在其中定义自定义 API 类型的字段。你可以向结构定义中添加各种字段,并使用特殊标记来提供元数据,如默认值。然后你可以使用 make 目标生成 CRD 清单。非常方便。
关于 make 目标,除了生成 CRD 外,你还可以生成 RBAC 和示例自定义资源清单,在开发集群中安装自定义资源,为你的运算符构建和发布镜像,并在开发过程中对本地集群运行你的控制器。所有这些繁琐、耗时任务的便利确实提高了生产力,尤其是在项目早期。
出于这些原因,我们推荐使用 Kubebuilder 来构建运算符。它已经被成功采用和应用在多种项目中。
Metacontroller
如果你对除了 Go 之外的某种特定编程语言感到舒适,并且有理由坚持使用它,那么在开发运算符时另一个有用的选择是 Metacontroller。这是一种完全不同的开发和部署运算符的方法,但如果你希望使用各种语言并计划在平台上部署多个自定义内部运算符,那么它值得考虑。有经验的 Kubernetes 编程工程师有时也会使用 Metacontroller 进行原型设计,然后在设计和实现细节明确之后使用 Kubebuilder 完成最终项目。这也揭示了 Metacontroller 的一个优点:一旦在集群中安装了 Metacontroller 插件,启动速度很快。
这就是 Metacontroller 的本质:一个集群附加组件,抽象出与 Kubernetes API 的交互。您的工作是编写控制器 webhook,其中包含您的控制器逻辑。Metacontroller 将其称为lambda 控制器。您的 lambda 控制器负责决定如何处理其关心的资源。Metacontroller 监视管理的资源,并在需要做出决策时通过 HTTP 调用通知您的控制器。Metacontroller 本身使用自定义资源来定义您的 webhook 的特性,例如其 URL 和它所管理的资源。因此,一旦 Metacontroller 在您的集群中运行,添加控制器就包括部署您的 lambda 控制器 webhook,并添加 Metacontroller 自定义资源;例如,组合控制器资源。而您的新控制器所需做的就是公开一个端点,接受来自 Metacontroller 的请求,解析包含相关 Kubernetes 资源对象的 JSON 负载,然后返回带有任何要发送到 Kubernetes API 的更改的响应。图 11-3 展示了使用 Metacontroller 时这些组件如何交互。

图 11-3. Metacontroller 为您的 lambda 控制器抽象了 Kubernetes API。
Metacontroller 不帮助您创建可能需要添加到您的集群中的任何 CRD。在这方面,您是独立的。如果您正在编写一个响应核心 Kubernetes 资源变化的控制器,这不是问题。但是,如果您正在开发自定义资源,那么 Kubebuilder 在这一领域具有显著优势。
运算符框架
运算符框架是由红帽发起的一套开源工具,现在作为 CNCF 的孵化项目。该框架便于运算符的开发。它包括运算符 SDK,使用 Go 编写运算符时提供类似 Kubebuilder 的功能。与 Kubebuilder 类似,它提供了一个 CLI 来为项目生成样板。它还使用 controller-runtime 工具来帮助集成 Kubernetes API。除了 Go 项目,运算符 SDK 还允许开发人员使用 Helm 或 Ansible 来管理操作。该框架还包括运算符生命周期管理器,它提供了安装和升级运算符的抽象。该项目还维护了一个运算符中心,为用户发现他们使用的软件的运算符提供了一种方式。我们在现场尚未遇到使用这些工具的平台团队。作为由红帽维护的项目,它在基于 Red Hat 的 Kubernetes 提供方 OpenShift 的用户中可能更为常见。
数据模型设计
就像您可能通过定义 Web 应用程序将使用的数据库模式来开始设计应用程序一样,在构建操作员时,一个很好的起点是操作员将使用的自定义资源的数据模型。事实上,在开始之前,您可能已经对自定义资源规范中需要哪些字段有了一些想法。一旦您意识到需要解决的问题或填补的空白,定义所需状态的对象的属性就会开始成形。
在本章早些时候的示例中,Namespace 操作员可以作为一个将创建各种资源的操作员开始:LimitRange、ResourceQuota、Roles 和 NetworkPolicies,以及一个新的应用开发团队的 Namespace。您可能希望立即将团队负责人绑定到 namespace-admin 角色,然后将 Namespace 的管理工作交给该人。这自然会导致您向自定义资源的规范中添加一个 adminUsername 字段。自定义资源清单可能看起来像 示例 11-3。
Example 11-3. AcmeNamespace 资源清单的示例
apiVersion: tenancy.acme.com/v1alapha1
kind: AcmeNamespace
metadata:
name: team-x
spec:
namespaceName: app-y 
adminUsername: sam 
Namespace 的一个任意名称——在本例中,它将托管一个工作负载,“app-y”。
此用户名应与公司的身份提供程序使用的用户名相对应,通常是 Active Directory 系统或类似系统。
提交示例 11-3 中的清单将导致用户名 sam 被添加到一个角色绑定的主体中,该角色绑定将其绑定到 namespace-admin 角色,具体方式如示例 11-4 所示。
Example 11-4. 为 team-x 的 AcmeNamespace 创建角色和角色绑定的示例
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: namespace-admin
namespace: app-y
rules:
- apiGroups:
- "*"
resources:
- "*"
verbs:
- "*"
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: namespace-admin
namespace: app-y
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: namespace-admin
subjects:
- kind: User
name: sam 
namespace: app-y
在 AcmeNamespace 清单中提供的 adminUsername 将被插入到此处,以绑定到 namespace-admin 角色。
在考虑所需行为时,将 Sam 绑定到 namespace-admin 角色所需的数据变得非常清晰:Sam 的用户名和 Namespace 的名称。因此,从明显的数据片段开始,这些数据片段是为了提供功能而需要的,定义您的 CRD 规范中的字段。在 Kubebuilder 项目中的示例可能类似于 示例 11-5。
Example 11-5. AcmeNamespaceSpec 的类型定义
// api/v1alpha1/acmenamespace_types.go
...
// AcmeNamespaceSpec defines the desired state of AcmeNamespace
type AcmeNamespaceSpec struct {
// The name of the namespace
NamespaceName string `json:"namespaceName"`
// The username for the namespace admin
AdminUsername string `json:"adminUsername"`
...
这是 Kubebuilder 将用来生成您的 CRD 清单以及用于测试和演示的样例 AcmeNamespace 清单的源代码。
现在我们有了一个数据模型,我们认为可以充分管理我们想要的行为状态,是时候开始编写控制器了。在开发过程中,我们可能会发现我们的数据模型不够用,需要额外的字段来实现我们想要的结果。但这是一个很好的起点。
逻辑实现
逻辑是在我们的控制器中实现的。控制器的主要工作是管理一个或多个自定义资源。当使用类似 Kubebuilder 和 Metacontroller 这样的工具时,控制器将监视这些受管理的自定义资源。即使只使用 client-go 库,GitHub 仓库中也有优秀的代码示例可供参考,这部分也非常简单易懂。通过对其自定义资源的监视,控制器将收到关于此类资源的任何更改的通知。在这一点上,控制器的工作如下:
-
收集系统现有状态的准确图像
-
检查系统的期望状态
-
采取必要的操作来使现有状态与期望状态协调一致
现有状态
实际上,控制器可以从三个地方收集现有状态信息:
-
您自定义资源的
status -
集群中的其他相关资源
-
集群外或其他系统中的相关条件
status 字段为 Kubernetes 中的控制器提供了记录观察到的现有状态的位置。例如,Kubernetes 在一些资源(如 Pods 和 Namespaces)上使用 status.phase 字段来跟踪资源是否处于 Running(对于 Pods)或 Active(对于 Namespaces)状态。
让我们回到一个命名空间操作符的例子。控制器收到一个新的 AcmeNamespace 资源及其规范的通知。控制器不能假设这是一个新资源,然后机械地创建子命名空间和角色资源。如果这只是一个已存在的资源,只是进行了一些更改呢?尝试再次创建子资源将从 Kubernetes API 得到错误。然而,按照前面 Kubernetes 的例子,如果我们在 CRD 的 status 中包含一个 phase 字段,控制器可以检查它来评估现有状态。当首次创建时,控制器会发现 status.phase 字段为空。这将告诉控制器这是一个新资源的创建,应该继续创建所有子资源。一旦所有子资源通过 API 成功响应创建,控制器可以将 status.phase 字段填充为 Created 值。然后,如果后来更改了 AcmeNamespace 资源,当控制器收到通知时,它可以从此字段中看到它已经被之前创建过,并继续进行其他协调步骤。
到目前为止,使用status.phase字段来确定现有状态有一个关键缺陷。它假设控制器本身永远不会失败。如果在创建子资源时遇到问题怎么办?例如,控制器收到新的 AcmeNamespace 通知,创建了子命名空间,但在创建相关的角色资源之前崩溃了。当控制器重新启动时,它会发现 AcmeNamespace 资源在status.phase字段中没有Created,尝试创建子命名空间并在没有令人满意的方式下失败。为了防止这种情况发生,控制器可以在发现新的 AcmeNamespace 已创建时,作为第一步向status.phase添加CreationInProgress值。这样,如果在创建过程中发生故障,当控制器重新启动并看到CreationInProgress阶段时,它将知道无法仅通过status准确确定现有状态。这时它需要查看集群中的其他相关资源来确定现有状态。
当无法从 AcmeNamespace 的status确定现有状态时,它可以查询 API 服务器或者更好地查询 API 服务器中对象的本地缓存,以便获取其关心的条件。如果发现 AcmeNamespace 的阶段设置为CreationInProgress,它可以开始查询 API 服务器,检查其期望存在的子资源是否存在。在我们使用的故障示例中,它将查询子命名空间是否存在,发现它确实存在,然后继续。它将查询角色资源,发现不存在,然后继续创建这些资源。通过这种方式,我们的控制器可以容忍失败。我们应该始终假设会发生故障,并相应地开发控制器逻辑。
此外,有时我们的控制器会对集群外的现有状态感兴趣。云基础设施控制器是一个很好的例子。必须从集群外的云提供商 API 中查询基础设施系统的状态。现有状态可能会高度依赖于问题操作器的目的,并且通常会很清楚。
期望状态
系统的期望状态在相关资源的spec中表达。在我们的命名空间操作器中,由namespaceName提供的期望状态告诉控制器结果命名空间的metadata.name字段应该是什么。adminUsername字段确定了namespace-admin角色绑定的subjects[0].name应该是什么。这些是期望状态直接映射到子资源字段的示例。通常,实现方式不那么直接。
我们之前在本章中的 AcmeStore 示例中使用了 deploymentTier 字段的示例。它允许用户指定一个单一变量,用于通知控制器逻辑要使用的默认值。我们可以将类似的想法应用到 Namespace 操作符上。我们的新修改的 AcmeNamespace 清单可能看起来像 示例 11-6。
示例 11-6. 添加了新字段的 AcmeNamespace 清单
apiVersion: tenancy.acme.com/v1alapha1
kind: AcmeNamespace
metadata:
name: team-x
spec:
namespaceName: app-y
adminUsername: sam
deploymentTier: dev 
AcmeNamespace API 类型的数据模型的新添加。
这将促使控制器创建一个类似于 示例 11-7 的 ResourceQuota。
示例 11-7. 为 team-x 的 AcmeNamespace 创建的 ResourceQuota
apiVersion: v1
kind: ResourceQuota
metadata:
name: dev
spec:
hard:
cpu: "5"
memory: 10Gi
pods: "10"
默认的 deploymentTier: prod ResourceQuota 可能看起来像 示例 11-8。
示例 11-8. 当 deploymentTier: prod 在 AcmeNamespace 中设置时创建的另一种 ResourceQuota
apiVersion: v1
kind: ResourceQuota
metadata:
name: prod
spec:
hard:
cpu: "500"
memory: 200Gi
pods: "100"
协调
在 Kubernetes 中,协调是将现有状态更改为匹配所需状态的过程。这可以是像 kubelet 请求容器运行时停止与已删除 Pod 关联的容器那样简单。或者它可以更复杂,例如操作员根据表示有状态应用程序的自定义资源创建一系列新资源。这些是通过创建或删除表示所需状态的资源来触发的协调示例。但很多时候,协调涉及对变更的响应。
简单的变更示例是如果你将 Deployment 资源上的副本数从 5 更新为 10。现有状态是工作负载的 5 个 Pod。期望的状态是 10 个 Pod。在这种情况下,Deployment 控制器执行的协调包括更新相关 ReplicaSet 上的副本数。然后 ReplicaSet 控制器通过创建 5 个新的 Pod 资源来协调状态,这些资源依次由调度程序安排,从而促使适用的 kubelet 从容器运行时请求新的容器。
另一个稍微复杂的变更示例是如果你在 Deployment 规范中更改镜像。这通常是为了更新正在运行的应用程序的版本。默认情况下,Deployment 控制器将执行滚动更新以协调状态。它将为新版本的应用程序创建一个 新 的 ReplicaSet,增加新 ReplicaSet 上的副本数,并减少旧 ReplicaSet 上的副本数,以便逐个替换 Pod。一旦所有新的镜像版本以所需数量的副本运行,协调工作就完成了。
对于管理自定义资源的自定义控制器,协调看起来会因自定义资源代表的内容不同而大不相同。但有一件事情应该保持不变,那就是如果由于超出控制器域的条件导致协调失败,它应该无限重试。一般来说,协调循环应该在迭代之间实现递增延迟。例如,如果可以合理地期望集群中的其他系统正在主动协调阻止控制器完成其操作的状态,那么可能在 1 秒后重试。但是,为了防止不必要的资源消耗,建议在每次迭代之间按指数增加延迟,直到达到某个合理的限制,比如说,5 分钟。在那时,控制器将每隔 5 分钟重试一次协调。这允许在限制资源消耗和网络流量的情况下无人参与解决系统问题。
实现细节
大致而言,为了实现 Namespace 操作员的初始控制器功能,我们希望能够:
-
编写或生成类似之前示例的简明 AcmeNamespace 清单
-
将清单提交到 Kubernetes API
-
通过创建一个 Namespace、ResourceQuota、LimitRange、角色和角色绑定来让控制器响应。
在 kubebuilder 项目中,创建这些资源的逻辑将存在于 Reconcile 方法中。创建一个带有控制器的 Namespace 的初始实现可能看起来像是 示例 11-9。
示例 11-9. AcmeNamespace 控制器的 Reconcile 方法
// controllers/acmenamespace_controller.go
package controllers
import (
"context"
"github.com/go-logr/logr"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
tenancyv1alpha1 "github.com/lander2k2/namespace-operator/api/v1alpha1"
)
...
func (r *AcmeNamespaceReconciler) Reconcile(req ctrl.Request) (ctrl.Result, error) {
ctx := context.Background()
log := r.Log.WithValues("acmenamespace", req.NamespacedName)
var acmeNs tenancyv1alpha1.AcmeNamespace 
r.Get(ctx, req.NamespacedName, &acmeNs) 
nsName := acmeNs.Spec.NamespaceName
adminUsername := acmeNs.Spec.AdminUsername
ns := &corev1.Namespace{ 
ObjectMeta: metav1.ObjectMeta{
Name: nsName,
Labels: map[string]string{
"admin": adminUsername,
},
},
}
if err := r.Create(ctx, ns); err != nil { 
log.Error(err, "unable to create namespace")
return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}
...
表示已创建、更新或删除的 AcmeNamespace 对象的变量。
获取来自请求的 AcmeNamespace 对象的内容。为简洁起见,省略了错误捕获。
创建新的 Namespace 对象。
在 Kubernetes API 中创建新的 Namespace 资源。
这个简化的片段演示了控制器创建新的 Namespace。向控制器添加 Namespace 管理员的角色和角色绑定将类似,如 示例 11-10 所示。
示例 11-10. AcmeNamespace 控制器创建角色和角色绑定
// controllers/acmenamespace_controller.go
...
role := &rbacv1.Role{
ObjectMeta: metav1.ObjectMeta{
Name: "namespace-admin",
Namespace: nsName,
},
Rules: []rbacv1.PolicyRule{
{
APIGroups: []string{"*"},
Resources: []string{"*"},
Verbs: []string{"*"},
},
},
}
if err := r.Create(ctx, role); err != nil {
log.Error(err, "unable to create namespace-admin role")
return ctrl.Result{}, err
}
binding := &rbacv1.RoleBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "namespace-admin",
Namespace: nsName,
},
RoleRef: rbacv1.RoleRef{
APIGroup: "rbac.authorization.k8s.io",
Kind: "Role",
Name: "namespace-admin",
},
Subjects: []rbacv1.Subject{
{
Kind: "User",
Name: adminUsername,
Namespace: nsName,
},
},
}
if err := r.Create(ctx, binding); err != nil {
log.Error(err, "unable to create namespace-admin role binding")
return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}
...
在此时,我们能够提交一个 AcmeNamespace 清单到 API,我们的 Namespace 操作器将创建 Namespace,Namespace 管理员的角色以及绑定到我们提供的用户名的 RoleBinding。正如我们之前讨论的,当我们创建一个新的 AcmeNamespace 时,这将正常工作,但是在以后的任何时间尝试协调它时会出现问题。如果 AcmeNamespace 以任何方式被更改,这将发生。如果控制器由于任何原因重新启动,也会发生这种情况。当控制器重新启动时,它必须重新列出和协调所有现有资源,以防发生更改。因此,在这一点上,简单地重启我们的控制器将使其中断。让我们通过添加对状态字段的简单使用来修复这个问题。首先,示例 11-11 展示了向AcmeNamespaceStatus添加字段的过程。
示例 11-11. 向 AcmeNamespace 状态添加字段
// api/v1alpha1/acmenamespace_types.go
// AcmeNamespaceStatus defines the observed state of AcmeNamespace
type AcmeNamespaceStatus struct {
// Tracks the phase of the AcmeNamespace
// +optional
// +kubebuilder:validation:Enum=CreationInProgress;Created
Phase string `json:"phase"`
}
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
...
现在,我们可以像在示例 11-12 中展示的那样,在我们的控制器中利用这个领域。
示例 11-12. 在 AcmeNamespace 控制器中使用新的状态字段
// controllers/acmenamespace_controller.go
...
const (
statusCreated = "Created"
statusInProgress = "CreationInProgress"
)
...
func (r *AcmeNamespaceReconciler) Reconcile(req ctrl.Request) (ctrl.Result, error) {
...
switch acmeNs.Status.Phase {
case statusCreated:
// do nothing
log.Info("AcmeNamespace child resources have been created")
case statusInProgress:
// TODO: query and create as needed
log.Info("AcmeNamespace child resource creation in progress")
default:
log.Info("AcmeNamespace child resources not created")
// set status to statusInProgress
acmeNs.Status.Phase = statusInProgress
if err := r.Status().Update(ctx, &acmeNs); err != nil {
log.Error(err, "unable to update AcmeNamespace status")
return ctrl.Result{}, err
}
// create namespace, role and role binding
...
// set status to statusCreated
acmeNs.Status.Phase = statusCreated
if err := r.Status().Update(ctx, &acmeNs); err != nil {
log.Error(err, "unable to update AcmeNamespace status")
return ctrl.Result{}, err
}
}
return ctrl.Result{}, nil
}
...
现在我们有一个可以安全重启的控制器。它现在还具有一个系统的开始,用于检查现有状态,使用自定义资源的状态并执行基于该现有状态的协调步骤。
我们通常还应该为子资源设置所有权。如果我们将 AcmeNamespace 资源设置为 Namespace、Role 和 RoleBinding 的所有者,那么我们只需删除所有者 AcmeNamespace 资源即可删除所有子资源。这种所有权将由 API 服务器管理。即使控制器没有运行,如果删除所有者 AcmeNamespace 资源,子资源也会被删除。
这引出了我们的 AcmeNamespace API 类型的作用域问题。在使用 Kubebuilder 时,默认为 Namespaced 作用域。但是,Namespace 作用域的 API 类型不能成为集群作用域资源(如 Namespace)的所有者。通过 Kubebuilder,我们可以使用方便的标记来生成带有适当作用域的 CRD 清单,如示例 11-13 所示。
示例 11-13. 在 Kubebuilder 项目中更新 API 定义
// api/v1alpha1/acmenamespace_types.go package v1alpha1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN!
// NOTE: json tags are required. Any new fields you add must have json tags // for the fields to be serialized.
// AcmeNamespaceSpec defines the desired state of AcmeNamespace type AcmeNamespaceSpec struct {
// The name of the namespace NamespaceName string `json:"namespaceName"`
// The username for the namespace admin AdminUsername string `json:"adminUsername"`
}
// AcmeNamespaceStatus defines the observed state of AcmeNamespace type AcmeNamespaceStatus struct {
// Tracks the phase of the AcmeNamespace // +optional // +kubebuilder:validation:Enum=CreationInProgress;Created Phase string `json:"phase"`
}
// +kubebuilder:resource:scope=Cluster 
// +kubebuilder:object:root=true // +kubebuilder:subresource:status
// AcmeNamespace is the Schema for the acmenamespaces API type AcmeNamespace struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec AcmeNamespaceSpec `json:"spec,omitempty"`
Status AcmeNamespaceStatus `json:"status,omitempty"`
}
// +kubebuilder:object:root=true
// AcmeNamespaceList contains a list of AcmeNamespace type AcmeNamespaceList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []AcmeNamespace `json:"items"`
}
func init() {
SchemeBuilder.Register(&AcmeNamespace{}, &AcmeNamespaceList{})
当使用make manifests生成清单时,此标记将设置 CRD 的正确作用域。
这将生成一个看起来像示例 11-14 的 CRD。
示例 11-14. 用于 AcmeNamespace API 类型的集群范围 CRD
---
apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: (devel)
creationTimestamp: null
name: acmenamespaces.tenancy.acme.com
spec:
group: tenancy.acme.com
names:
kind: AcmeNamespace
listKind: AcmeNamespaceList
plural: acmenamespaces
singular: acmenamespace
scope: Cluster 
subresources:
status: {}
validation:
openAPIV3Schema:
description: AcmeNamespace is the Schema for the acmenamespaces API
properties:
apiVersion:
description: 'APIVersion defines the versioned schema of this
representation of an object. Servers should convert recognized
schemas to the latest internal value, and may reject unrecognized
values.'
type: string
kind:
description: 'Kind is a string value representing the REST resource this
object represents. Servers may infer this from the endpoint the client
submits requests to. Cannot be updated. In CamelCase.'
type: string
metadata:
type: object
spec:
description: AcmeNamespaceSpec defines the desired state of AcmeNamespace
properties:
adminUsername:
description: The username for the namespace admin
type: string
namespaceName:
description: The name of the namespace
type: string
required:
- adminUsername
- namespaceName
type: object
status:
description: 'AcmeNamespaceStatus defines the observed state of
AcmeNamespace'
properties:
phase:
description: Tracks the phase of the AcmeNamespace
enum:
- CreationInProgress
- Created
type: string
type: object
type: object
version: v1alpha1
versions:
- name: v1alpha1
served: true
storage: true
status:
acceptedNames:
kind: ""
plural: ""
conditions: []
storedVersions: []
资源作用域已正确设置。
现在我们可以将 AcmeNamespace 设置为所有子资源的所有者。这将在每个子资源的 metadata 中引入一个ownerReferences字段。此时,我们的Reconcile方法看起来像示例 11-15。
示例 11-15. 设置 AcmeNamespace 子资源的所有权
func (r *AcmeNamespaceReconciler) Reconcile(req ctrl.Request) (ctrl.Result, error) {
ctx := context.Background()
log := r.Log.WithValues("acmenamespace", req.NamespacedName)
var acmeNs tenancyv1alpha1.AcmeNamespace
if err := r.Get(ctx, req.NamespacedName, &acmeNs); err != nil {
if apierrs.IsNotFound(err) { 
log.Info("resource deleted")
return ctrl.Result{}, nil
} else {
return ctrl.Result{}, err
}
}
nsName := acmeNs.Spec.NamespaceName
adminUsername := acmeNs.Spec.AdminUsername
switch acmeNs.Status.Phase {
case statusCreated:
// do nothing log.Info("AcmeNamespace child resources have been created")
case statusInProgress:
// TODO: query and create as needed log.Info("AcmeNamespace child resource creation in progress")
default:
log.Info("AcmeNamespace child resources not created")
// set status to statusInProgress acmeNs.Status.Phase = statusInProgress
if err := r.Status().Update(ctx, &acmeNs); err != nil {
log.Error(err, "unable to update AcmeNamespace status")
return ctrl.Result{}, err
}
ns := &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
Name: nsName,
Labels: map[string]string{
"admin": adminUsername,
},
},
}
// set owner reference for the namespace 
err := ctrl.SetControllerReference(&acmeNs, ns, r.Scheme)
if err != nil {
log.Error(err, "unable to set owner reference on namespace")
return ctrl.Result{}, err
}
if err := r.Create(ctx, ns); err != nil {
log.Error(err, "unable to create namespace")
return ctrl.Result{}, err
}
role := &rbacv1.Role{
ObjectMeta: metav1.ObjectMeta{
Name: "namespace-admin",
Namespace: nsName,
},
Rules: []rbacv1.PolicyRule{
{
APIGroups: []string{"*"},
Resources: []string{"*"},
Verbs: []string{"*"},
},
},
}
// set owner reference for the role 
err = ctrl.SetControllerReference(&acmeNs, role, r.Scheme)
if err != nil {
log.Error(err, "unable to set owner reference on role")
return ctrl.Result{}, err
}
if err := r.Create(ctx, role); err != nil {
log.Error(err, "unable to create namespace-admin role")
return ctrl.Result{}, err
}
binding := &rbacv1.RoleBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "namespace-admin",
Namespace: nsName,
},
RoleRef: rbacv1.RoleRef{
APIGroup: "rbac.authorization.k8s.io",
Kind: "Role",
Name: "namespace-admin",
},
Subjects: []rbacv1.Subject{
{
Kind: "User",
Name: adminUsername,
Namespace: nsName,
},
},
}
// set owner reference for the role binding 
err = ctrl.SetControllerReference(&acmeNs, binding, r.Scheme);
if err != nil {
log.Error(err, "unable to set reference on role binding")
return ctrl.Result{}, err
}
if err := r.Create(ctx, binding); err != nil {
log.Error(err, "unable to create role binding")
return ctrl.Result{}, err
}
// set status to statusCreated acmeNs.Status.Phase = statusCreated
if err := r.Status().Update(ctx, &acmeNs); err != nil {
log.Error(err, "unable to update AcmeNamespace status")
return ctrl.Result{}, err
}
}
return ctrl.Result{}, nil
}
...
检查资源是否未找到,因此我们不会尝试在 AcmeNamespace 已被删除时进行协调。
在 Namespace 上设置所有者引用。
在 Role 上设置所有者引用。
在 RoleBinding 上设置所有者引用。
请注意,我们不得不添加错误检查以查看是否找到了 AcmeNamespace 资源。这是因为当它被删除时,由于不再存在所需的状态来协调,正常的协调将失败。在这种情况下,我们在子资源上放置了一个所有者引用,因此 API 服务器会处理删除事件的协调状态。
这说明了协调必须不对现有状态进行假设的观点。协调在以下情况下触发:
-
控制器启动或重新启动
-
创建资源
-
对资源进行更改,包括控制器自身进行的更改
-
删除资源
-
定期与 API 进行重新同步以确保系统的准确视图
为此,请确保您的协调不对触发协调的事件做出假设。使用status字段,在其他资源中确定相关条件,并据此进行协调。
管理入场网络钩子
如果您发现自定义资源需要默认值或验证,而这些内容无法使用创建新 API 类型的 CRD 中的 OpenAPI v3 规范实现,您可以转向验证和变异入场网络钩子。Kubebuilder CLI 提供了一个create webhook命令,专门用于通过生成样板代码来加快您的启动速度。
验证 Webhook 的一个示例可能与我们的 Namespace 操作员示例及其 AcmeNamespace 资源有关,在验证adminUsername字段时尤其有用。作为便利,您的 Webhook 可以调用公司身份提供程序,以确保提供的用户名有效,从而防止需要人工干预来纠正错误。
默认示例可以将deploymentTier默认为最常见、成本最低的dev选项。当您对自定义资源数据模型进行更改以添加新字段时,这尤其有用,用于保持与现有资源定义的向后兼容性。
管理入场网络钩子通常不包括在操作员的原型或预阿尔法版本中,但在项目的稳定版本中,通常在优化用户体验时发挥作用。第八章深入讨论了入场控制的主题。
最终处理器
我们已经看过将自定义资源设置为子资源所有者的示例,以确保在删除父自定义资源时它们将被删除。然而,这种机制并不总是足够的。如果自定义资源与集群中其他资源存在关系,其中所有权不合适,或者在删除自定义资源时需要更新集群外的条件,那么使用终结器可能非常重要。
终结器被添加到资源的元数据中,如示例 11-16 所示。
示例 11-16. 带有终结器的 AcmeNamespace 清单
apiVersion: tenancy.acme.com/v1alapha1
kind: AcmeNamespace
metadata:
name: team-x
finalizers:
- namespace.finalizer.tenancy.acme.com 
spec:
namespaceName: app-y
adminUsername: sam
作为终结器使用的字符串值。
作为您的终结器使用的字符串值对系统中除您的控制器之外的任何其他内容都不重要。只需使用一个安全地确保在其他控制器需要向同一资源应用终结器时独特的值即可。
当资源上存在任何终结器时,API 服务器将不会删除该资源。如果收到删除请求,它将更新资源以在其元数据中添加deletionTimestamp字段。这对资源的更新将触发控制器中的协调。需要在控制器的Reconcile方法中添加对deletionTimestamp的检查,以便完成任何预删除操作。完成后,您的控制器可以移除终结器。这将告知 API 服务器它现在可以删除该资源。
常见的预删除操作示例发生在集群外的系统中。在命名空间操作器示例中,如果存在跟踪命名空间使用情况并在删除命名空间时需要更新的公司计费系统,那么一个终结器可以提示您的操作器在删除命名空间之前更新外部系统。其他常见的示例包括工作负载使用托管服务,例如数据库或对象存储,作为应用堆栈的一部分。当删除应用程序实例时,这些托管服务实例可能也需要清理。
扩展调度器
调度器在 Kubernetes 中提供核心功能。Kubernetes 的价值主张的一个重要部分是抽象化用于运行工作负载的机器池。调度器确定 Pod 的运行位置。可以说,与 kubelet 一起,这两个控制器形成了 Kubernetes 的核心,其它所有内容都围绕它们构建。调度器是应用程序平台的一个基石平台服务。在本节中,我们将探讨定制、扩展和替换调度器的行为。
记住核心控制平面组件(如调度程序)与本章前面所检查的自定义操作员之间的类比是有帮助的。在这两种情况下,我们都在处理管理 Kubernetes 资源的 Kubernetes 控制器。对于我们的自定义操作员,我们开发全新的自定义控制器,而调度程序是一个与每个 Kubernetes 集群一起部署的核心控制器。对于我们的自定义操作员,我们设计并创建新的自定义资源,而调度程序管理核心的 Pod 资源。
我们发现 Kubernetes 用户很少有需要扩展调度程序或修改其行为的情况。然而,考虑到调度程序对集群功能的重要性,了解调度程序如何做出调度决策以及如何在需要时修改这些决策是明智的。值得再次强调的是:Kubernetes 的一大特点是其可扩展性和模块化性。如果您发现调度程序不能满足您的需求,可以修改或增强其行为,甚至完全替换它。
在探索这个主题时,我们将研究调度程序如何确定在哪里分配 Pod,以便了解每个调度决策的内部机制,然后看看我们如何通过调度策略影响这些决策。我们还将讨论运行多个调度程序的选项,甚至编写您自己的自定义调度程序。
断言和优先级
在我们探讨如何扩展或修改调度程序之前,我们首先需要了解调度程序如何做出决策。调度程序使用两步流程来确定将 Pod 调度到哪个节点。
第一步是过滤。在这一步中,调度程序使用多个断言来过滤出不适合托管 Pod 的节点。例如,有一个断言检查要调度的 Pod 是否容忍节点的污点。控制平面节点通常使用污点来确保常规工作负载不会在此调度。如果一个 Pod 没有容忍性,任何带有污点的节点都将被过滤掉,不符合 Pod 的合格目标。另一个断言检查确保节点具有足够的 CPU 和内存资源来满足这些资源的请求。如果节点资源不足以满足 Pod 规范,预期将其过滤为不合格的主机。当所有断言检查节点的合格性时,过滤步骤完成。此时如果没有合格的节点,Pod 将保持在Pending状态,直到条件改变,例如集群中增加了一个新的合格节点。如果节点列表中只有一个节点,可能会在这一点上进行调度。如果有多个合格的节点,调度程序将继续进行第二步。
第二步是评分。这一步使用优先级来确定哪个节点最适合特定的 Pod。一个提高节点评分的优先级是,节点上存在 Pod 使用的容器镜像。另一个提高节点评分的优先级是,没有任何与待调度的 Pod 共享相同服务的 Pod。也就是说,调度器将尝试将共享服务的 Pod 分布在多个节点上,以提高节点故障容忍性。评分步骤也是实现 Pod 上 preferred... 亲和规则的地方。在评分步骤结束时,每个符合条件的节点都有一个相关的评分。评分最高的节点被认为是 Pod 的最佳选择,并将其调度到该节点上。
调度策略
调度策略用于配置调度器要使用的谓词和优先级。您可以编写一个包含调度策略的配置文件,并将其保存到控制平面节点上,然后通过提供调度器 --policy-config-file 标志来提供给调度器,但更推荐的方法是使用 ConfigMap。提供调度器 --policy-configmap 标志,然后可以通过 API 服务器更新调度策略。请注意,如果选择使用 ConfigMap 方法,您可能需要更新 system:kube-scheduler ClusterRole,以添加获取 ConfigMaps 的规则。
警告
在撰写本文时,调度器的 --policy-config-file 和 --policy-configmap 标志仍在使用,但在官方文档中已标记为不推荐使用。因此,如果您正在实施新的自定义调度行为,建议使用下一节讨论的调度配置文件,而不是这里讨论的策略。
例如,示例 11-17 中的策略 ConfigMap 将使节点仅在具有键为 selectable 的标签时才能被 Pod 通过 nodeSelector 选择。
示例 11-17. 定义调度策略的 ConfigMap 示例
apiVersion: v1
kind: ConfigMap
metadata:
name: scheduler-policy-config
namespace: kube-system
data:
policy.cfg: |+ 
apiVersion: v1
kind: Policy
predicates:
- name: "PodMatchNodeSelector" 
argument:
labelsPresence:
labels:
- "selectable" 
presence: true 
调度策略的文件名调度器将预期使用。
实现 nodeSelectors 的谓词名称。
您希望用于为选择添加约束的标签键。例如,在此示例中,如果节点没有此标签键存在,则不会被 Pod 选择。
这表明提供的标签必须存在。如果设置为 false,则必须不存在。如果使用 presence: true 的示例配置,则没有标签 selectable: "" 的节点将无法被 Pod 选择。
使用此调度策略,仅当节点同时具有 device: gpu 和 selectable: "" 标签时,才会将示例 11-18 中定义的 Pod 调度到符合条件的节点。
示例 11-18. 使用nodeSelector字段来指导调度的 Pod 清单
apiVersion: v1
kind: Pod
metadata:
name: terminator
spec:
containers:
- image: registry.acme.com/skynet/t1000:v1
name: terminator
nodeSelector:
device: gpu
调度配置文件
调度配置文件允许您启用或禁用编译到调度器中的插件。您可以通过在运行调度器时将文件名传递给--config标志来指定配置文件。这些插件实现了各种扩展点,包括但不限于我们之前介绍的过滤器和评分步骤。根据我们的经验,很少需要以这种方式定制调度器。但是,如果您发现有这样的需要,请查阅 Kubernetes 文档获取详细说明。
多个调度器
需要注意的是,您不限于使用一个调度器。您可以部署任意数量的调度器,这些调度器可以是具有不同策略和配置文件的 Kubernetes 调度器,甚至是自定义构建的调度器。如果运行多个调度器,您可以在 Pod 的规格中提供schedulerName,以确定哪个调度器为该 Pod 执行调度。鉴于遵循这种多调度器模型的复杂性增加,请考虑为具有此类专用调度要求的工作负载使用专用集群。
自定义调度器
如果在使用策略和配置文件的情况下仍无法使用 Kubernetes 调度器,您可以开发并使用自己的调度器。这将涉及开发一个控制器,该控制器监视 Pod 资源,每当创建新的 Pod 时,确定 Pod 应该在何处运行,并更新该 Pod 的nodeName字段。尽管这个范围很窄,但这并不是一个简单的练习。正如我们在本节中看到的那样,核心调度器是一个复杂的控制器,定期评估多个复杂因素来做出调度决策。如果您的需求足够特殊以至于需要一个自定义调度器,那么您可能需要花费大量的工程工作来优化其行为。我们建议只有在用现有调度器尝试了所有选项并且在项目中具有深入的 Kubernetes 专业知识时,才继续采用这种方法。
总结
理解 Kubernetes 提供的扩展点及如何最佳添加满足租户需求的平台服务至关重要。研究操作员模式及 Kubernetes 操作员的用例。如果发现有必要构建操作员,请决定使用何种开发工具和语言,设计您的自定义资源数据模型,然后构建一个 Kubernetes 控制器来管理该自定义资源。最后,如果默认调度器行为不符合您的要求,请查看调度策略和配置文件以修改其行为。在极端情况下,您可以选择开发自己的自定义调度器来替换或与默认调度器并行运行。
使用本章节中提出的原则和实践,您不再受制于社区或公司供应商提供的工具和软件。如果您遇到重要需求,而现有解决方案尚不存在,您可以利用现有工具和指南添加任何您的业务需要的专门平台服务。
第十二章:多租户
在构建基于 Kubernetes 的生产应用平台时,您必须考虑如何处理将在平台上运行的租户。正如我们在本书中讨论过的那样,Kubernetes 提供了一组基础功能,可以用来实现许多需求。工作负载的租赁也不例外。Kubernetes 提供了各种调控手段,可以确保租户可以安全地共存于同一平台上。话虽如此,Kubernetes 并未定义租户。租户可以是一个应用程序、一个开发团队、一个业务单元或其他内容。租户的定义由您和您的组织来决定,我们希望本章能够帮助您完成这项任务。
一旦确定了您的租户是谁,您必须确定是否应该让多个租户在同一平台上运行。根据我们帮助大型组织构建应用程序平台的经验,我们发现平台团队通常有兴趣运行多租户平台。话虽如此,这个决定牢固地植根于不同租户的性质以及它们之间的信任关系。例如,提供共享应用程序平台的企业与为外部客户提供容器即服务的公司是两回事。
在本章中,我们将首先探讨您可以在 Kubernetes 中实现的租户隔离程度。您的工作负载的性质以及您的具体需求将决定您需要提供多少隔离。隔离越强,您在这方面的投资就越大。然后,我们将讨论 Kubernetes 命名空间,这是实现 Kubernetes 中大部分多租户功能的基础构建块。最后,我们将深入探讨您可以利用的不同 Kubernetes 功能,这些功能可以在多租户集群中隔离租户,包括基于角色的访问控制(RBAC)、资源请求和限制、Pod 安全策略等等。
隔离程度
Kubernetes 适合各种租户模型,每种模型都有其利弊。确定要实施的模型的最关键因素是您的工作负载所需的隔离程度。例如,运行由不同第三方开发的不受信任代码通常需要比托管您组织内部应用程序更强大的隔离。总体而言,您可以遵循两种租户模型:单租户集群和多租户集群。让我们讨论每种模型的优势和劣势。
单租户集群
单租户集群模型(如 图 12-1 所示)在租户之间提供了最强的隔离,因为集群资源不共享。这种模型相当吸引人,因为您不必解决可能出现的复杂多租户问题。换句话说,没有租户隔离问题需要解决。

图 12-1. 每个租户在单独的集群中运行(CP 代表控制平面节点)。
单租户集群在租户数量较少时是可行的。然而,这种模型可能会面临以下几个不利因素:
资源开销
每个单租户集群都必须运行自己的控制平面,在大多数情况下,这至少需要三个专用节点。租户越多,用于集群控制平面的资源也就越多——这些资源本来可以用来运行工作负载。除了控制平面外,每个集群还承载一组工作负载以提供平台服务。这些平台服务也会带来开销,因为它们本来可以在多租户集群中不同租户之间共享。监控工具、策略控制器(如 Open Policy Agent)和 Ingress 控制器都是很好的例子。
增加的管理复杂性
对于平台团队来说,管理大量集群可能成为一个挑战。每个集群都需要部署、跟踪、升级等操作。想象一下在数百个集群中修复安全漏洞的过程。为了有效地完成这些任务,平台团队需要投资于先进的工具。
尽管刚提到的缺点,我们在实地中看到了许多成功的单租户集群实现。随着类似 Cluster API 这样的集群生命周期工具的成熟,单租户模型变得更易于采纳。尽管如此,我们在现场的大部分工作重点是帮助组织实施多租户集群,我们将在接下来讨论这一点。
多租户集群
承载多个租户的集群可以解决我们之前讨论过的单租户集群的不利因素。与为每个租户部署和管理一个集群相比,平台团队可以专注于更少数量的集群,这降低了资源开销和管理复杂性(如 图 12-2 中所示)。尽管如此,这其中存在一定的权衡。实施多租户集群更为复杂和微妙,因为您必须确保租户可以共存而不相互影响。

图 12-2. 多个租户共享的单个集群(CP 代表控制平面节点)。
多租户有两种广义的形式,即软多租户和硬多租户。软多租户有时被称为“多团队”,假设租户之间存在一定程度的信任。这种模型通常在租户属于同一组织时是可行的。例如,托管不同租户的企业应用平台通常可以假设采用软多租户的姿态。这是因为租户有动机成为良好的邻居,以推动他们的组织取得成功。尽管意图是积极的,但考虑到可能出现的意外问题(例如漏洞、错误等),租户隔离仍然是必要的。
另一方面,硬多租户模型建立在租户之间没有信任的基础上。从安全的角度来看,租户甚至被视为对手,以确保正确的隔离机制得以实施。一个运行来自不同组织的不受信任代码的平台就是一个很好的例子。在这种情况下,租户之间强大的隔离至关重要,以确保他们可以安全地共享集群。
延续我们在第一章中的住房类比主题,我们可以说软多租户模型相当于一家人一起生活。他们共享厨房、客厅和公共设施,但每个家庭成员都有自己的卧室。相比之下,硬多租户模型更像是一个公寓楼。多个家庭共享建筑物,但每个家庭都在一个锁定的前门后面生活。
虽然软多租户和硬多租户模型可以帮助引导关于多租户平台的讨论,但实施起来并不那么明确。事实上,多租户最好描述为一个光谱。一端是完全没有隔离。租户可以在平台上自由操作并消耗所有资源。另一端是完全的租户隔离,其中租户在平台的所有层面都严格控制和隔离。
正如您可以想象的那样,在没有租户隔离的生产多租户平台是不可行的。同时,建立具有完全租户隔离的多租户平台可能是一个昂贵(甚至是徒劳)的尝试。因此,找到适合您的工作负载和整个组织的多租户光谱中的甜蜜点是非常重要的。
要确定您的工作负载所需的隔离级别,您必须考虑基于 Kubernetes 平台可以应用隔离的不同层次:
工作负载平面
工作负载平面由工作负载运行的节点组成。在多租户场景中,工作负载通常会在共享的节点池中进行调度。在这个级别的隔离涉及节点资源的公平共享、安全性和网络边界等方面。
控制平面
控制平面包括构成 Kubernetes 集群的各种组件,如 API 服务器、控制器管理器和调度器。Kubernetes 提供了不同的机制来在这个层次上隔离租户,包括授权(即 RBAC)、准入控制和 API 优先级与公平性。
平台服务
平台服务包括集中日志记录,监控,入口,集群内 DNS 等等。根据工作负载的不同,这些服务或能力可能需要一定程度的隔离。例如,您可能希望阻止租户查看彼此的日志或通过集群的 DNS 服务器发现彼此的服务。
Kubernetes 提供了不同的原语,您可以使用这些原语在每个层次上实现隔离。在深入讨论这些之前,我们将讨论 Kubernetes Namespace,这是允许您在集群中隔离租户的基础边界。
Namespace 边界
Namespaces 允许在 Kubernetes API 中实现多种不同的功能。它们允许您组织您的集群,强制执行策略,控制访问等等。更重要的是,它们是实现多租户 Kubernetes 平台的关键构建块,因为它们提供了在集群中引入和隔离租户的基础。
然而,在涉及租户隔离时,重要的是要记住 Namespace 是 Kubernetes 控制平面中的逻辑构造。没有额外的策略或配置,Namespace 对工作负载平面没有任何影响。例如,属于不同 Namespace 的工作负载可能会在同一节点上运行,除非设置了高级调度约束。最终,Namespace 只是附加到 Kubernetes API 资源的元数据。
话虽如此,本章将探讨的许多隔离机制都依赖于 Namespace 构造。RBAC、资源配额和网络策略就是这些机制的例子。因此,在设计租户策略时的首要决策之一就是确定如何利用 Namespaces。在帮助现场组织时,我们看到了以下几种方法:
每个团队一个 Namespace
在这种模型中,每个团队在集群中有一个单独的 Namespace。这种方法使得对特定团队应用策略和配额变得简单。然而,如果一个团队拥有许多服务,存在于单个 Namespace 中可能会具有挑战性。总体来看,我们发现这种模型对于刚开始使用 Kubernetes 的小型组织是可行的。
每个应用程序一个 Namespace
这种方法为集群中的每个应用程序提供了一个命名空间,从而更容易应用特定于应用程序的策略和配额。缺点是,这种模型通常导致租户可以访问多个命名空间,这可能会使租户入驻流程复杂化,并且难以应用租户级别的策略和配额。尽管如此,对于构建多租户平台的大型组织和企业来说,这种方法可能是最可行的。
每层级别一个命名空间
该模式通过使用命名空间建立了不同的运行时层(或环境)。通常情况下,我们避免使用这种方法,因为我们更倾向于为开发、测试和生产层使用单独的集群。
使用的方法主要取决于您的隔离要求和组织结构。如果您倾向于每个团队一个命名空间的模型,请记住命名空间中的所有资源都可以被团队中的所有成员或命名空间中的工作负载访问。例如,假设 Alice 和 Bob 在同一个团队,如果他们都被授权获取团队命名空间中的 Secrets,那么无法阻止 Alice 查看 Bob 的 Secrets。
Kubernetes 中的多租户
到目前为止,我们已经讨论了构建基于 Kubernetes 平台的不同租户模型。在本章的其余部分,我们将重点介绍多租户集群以及您可以利用的各种 Kubernetes 功能,以安全有效地托管您的租户。在阅读这些部分时,您会发现我们在其他章节中已经涵盖了一些这些功能。在这些情况下,我们将再次概述它们,但我们会专注于它们的多租户方面。
首先,我们将关注控制平面层中可用的隔离机制。主要包括 RBAC、资源配额和验证入口 Webhook。然后,我们将转向工作负载平面,讨论资源请求和限制、网络策略以及 Pod 安全策略。最后,我们将涉及监控和集中日志记录作为设计多租户的示例平台服务。
基于角色的访问控制(RBAC)
在同一集群中托管多个租户时,您必须在 API 服务器层强制执行隔离,以防止租户修改不属于他们的资源。RBAC 授权机制使您能够配置这些策略。正如我们在第十章中讨论的那样,API 服务器支持不同的机制来建立用户或租户的身份。一旦建立了身份,租户的身份就会传递到 RBAC 系统,该系统决定是否授权租户执行请求的操作。
在将租户引入集群时,可以授予他们访问一个或多个命名空间的权限,以便他们可以创建和管理 API 资源。为了授权每个租户,必须将角色或 ClusterRole 与他们的身份绑定。这通过 RoleBinding 资源实现。以下片段展示了一个示例 RoleBinding,授予 app1-viewer 组对 app1 命名空间的查看访问权限。除非有充分的使用案例,否则避免为租户使用 ClusterRoleBindings,因为它会授权租户在所有命名空间中利用绑定角色。
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: viewers
namespace: app1
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: view
subjects:
- apiGroup: rbac.authorization.k8s.io
kind: Group
name: app1-viewer
你会注意到示例中,RoleBinding 引用了名为 view 的 ClusterRole。这是 Kubernetes 中提供的一个内置角色。Kubernetes 提供了一组内置角色,涵盖了常见的使用案例:
查看
视图角色授予租户对命名空间范围内资源的只读访问权限。例如,该角色可以绑定到团队中的所有开发人员,因为它允许他们在生产集群中检查和排查他们的资源。
编辑
编辑角色允许租户创建、修改和删除命名空间范围内的资源,以及查看这些资源。鉴于此角色的能力,该角色的绑定高度依赖于你的应用部署方法。
管理员
除了查看和编辑资源外,管理员角色还可以创建角色和角色绑定。通常将此角色绑定到租户管理员以委派命名空间管理问题。
这些内置角色是一个很好的起点。话虽如此,它们可能被认为过于宽泛,因为它们授予对 Kubernetes API 中大量资源的访问权限。为了遵循最小权限原则,可以创建严格范围的角色,允许完成任务所需的最小资源集和操作。然而,请注意,这可能会导致管理开销,因为你可能需要管理许多唯一的角色。
警告
在大多数 Kubernetes 部署中,租户通常被授权列出集群上所有命名空间。如果你需要防止租户了解其他存在的命名空间,这会成为问题,因为目前使用 Kubernetes RBAC 系统无法实现此目的。如果确实有此要求,必须构建一个更高级的抽象来处理它(OpenShift 的 Project 资源是解决这个问题的一个示例抽象)。
在同一集群中运行多个租户时,RBAC 是必需的。它在控制平面层提供隔离,这对防止租户查看和修改彼此资源至关重要。在构建基于 Kubernetes 的多租户平台时,请务必利用 RBAC。
资源配额
作为提供多租户平台的平台运营商,你需要确保每个租户得到有限集群资源的适当份额。否则,一个野心勃勃(或者说是恶意的)租户可能会消耗整个集群资源,从而有效地使其他租户资源匮乏。
为了限制资源消耗,您可以使用 Kubernetes 的资源配额功能。资源配额适用于命名空间级别,可以限制两种资源。一方面,您可以控制命名空间中可用的计算资源量,如 CPU、内存和存储。另一方面,您可以限制可以在命名空间中创建的 API 对象的数量,如 Pod、Service 等。一个常见的场景是限制云环境中负载均衡服务的数量,因为这可能会变得昂贵。
由于配额应用于命名空间级别,您的命名空间策略影响如何配置配额。如果租户可以访问单个命名空间,则为每个租户在其命名空间中创建资源配额是直接的。当租户可以访问多个命名空间时,情况就会变得更复杂。在这种情况下,您需要额外的自动化或额外的控制器来跨不同命名空间强制执行配额。(分层命名空间控制器是解决这个问题的一种尝试)。
为了进一步探索资源配额,在下面的示例中展示了一个将命名空间限制为最多消耗 1 个 CPU 和 512 MiB 内存的资源配额:
apiVersion: v1
kind: ResourceQuota
metadata:
name: cpu-mem
namespace: app1
spec:
hard:
requests.cpu: "1"
requests.memory: 512Mi
limits.cpu: "1"
limits.memory: 512Mi
当app1命名空间中的 Pod 开始被调度时,配额会相应地被消耗。例如,如果我们创建一个请求 0.5 个 CPU 和 256 MiB 内存的 Pod,我们可以看到更新后的配额如下:
$ kubectl describe resourcequota cpu-mem
Name: cpu-mem
Namespace: app1
Resource Used Hard
-------- ---- ----
limits.cpu 500m 1
limits.memory 512Mi 512Mi
requests.cpu 500m 1
requests.memory 512Mi 512Mi
尝试超出配置配额的资源将被一个准入控制器阻止,如下错误消息所示。在这种情况下,我们试图消耗 2 个 CPU 和 2 GiB 内存,但受配额限制:
$ kubectl apply -f my-app.yaml
Error from server (Forbidden):
error when creating "my-app.yaml": pods "my-app" is forbidden:
exceeded quota: cpu-mem,
requested: limits.cpu=2,limits.memory=2Gi,
requests.cpu=2,requests.memory=2Gi,
used: limits.cpu=0,limits.memory=0,
requests.cpu=0,requests.memory=0,
limited: limits.cpu=1,limits.memory=512Mi,
requests.cpu=1,requests.memory=512Mi
正如您所看到的,资源配额赋予了您控制租户如何消耗集群资源的能力。在运行多租户集群时,它们非常关键,因为它们确保租户可以安全地共享集群的有限资源。
准入 Webhook
Kubernetes 有一组内置的准入控制器,您可以使用这些控制器来执行策略。我们刚刚介绍的资源配额功能是使用准入控制器实现的。虽然内置控制器帮助解决了常见的用例,但我们通常发现组织需要扩展准入层以进一步隔离和限制租户。
验证和变更准入 Webhook 是一种机制,允许您将自定义逻辑注入准入流程。我们不会深入探讨这些 Webhook 的实现细节,因为我们已经在第八章中涵盖了它们。相反,我们将探讨一些我们在现场通过自定义准入 Webhook 解决的多租户使用案例:
标准化标签
您可以使用验证入场 Webhook 强制所有 API 对象上的一组标准标签。例如,您可以要求所有资源都具有 owner 标签。具有一组标准标签很有用,因为标签提供了查询集群的方法,甚至支持更高级别的功能,如网络策略和调度约束。
需要字段
就像强制执行一组标准标签一样,您可以使用验证入场 Webhook 将某些资源的字段标记为必填。例如,您可以要求所有租户设置其 Ingress 资源的 https 字段。或者要求租户始终在其 Pod 规范中设置就绪和存活探针。
设置防护栏
Kubernetes 拥有一系列广泛的功能,您可能希望限制甚至禁用其中的某些功能。Webhook 可让您在特定功能周围设置防护栏。例如,禁用特定的服务类型(例如 NodePorts),不允许节点选择器,控制 Ingress 主机名等。
多命名空间资源配额
我们在现场经历过组织需要跨多个命名空间强制实施资源配额的情况。您可以使用自定义入场 Webhook/控制器来实现此功能,因为 Kubernetes 中的 ResourceQuota 对象是命名空间范围的。
总体而言,入场 Webhook 是在您的多租户集群中强制自定义策略的好方法。而像 Open Policy Agent (OPA) 和 Kyverno 这样的策略引擎的出现使得实施它们变得更加简单。考虑利用这些引擎来隔离和限制您集群中的租户。
资源请求和限制
Kubernetes 将工作负载安排到共享的集群节点池中。通常,来自不同租户的工作负载被安排到同一节点上,因此共享节点的资源。确保资源公平共享是运行多租户平台时最关键的问题之一。否则,租户可能会对共享同一节点的其他租户产生负面影响。
Kubernetes 中的资源请求和限制是在计算资源方面将租户相互隔离的机制。资源请求通常在 Kubernetes 调度器级别满足(CPU 请求也会在运行时反映出来,我们稍后会看到)。相比之下,资源限制是在节点级别使用 Linux 控制组(cgroups)和 Linux 完全公平调度器(CFS)实现的。
注意
虽然请求和限制为生产工作负载提供了足够的隔离,但应注意,在容器化环境中,这种隔离不如由虚拟化程序提供的严格。确保在给定 Kubernetes 节点上负载多个工作负载的情况下进行实验并理解其影响。
除了提供资源隔离外,资源请求和限制还确定了 Pod 的服务质量(QoS)类。QoS 类很重要,因为它决定了 kubelet 在节点资源不足时驱逐 Pod 的顺序。Kubernetes 提供以下 QoS 类:
Guaranteed
CPU 限制等于 CPU 请求,内存限制等于内存请求的 Pod。这必须对所有容器成立。Kubelet 很少会驱逐保证型 Pod。
Burstable
不符合 Guaranteed 标准且至少有一个容器具有 CPU 或内存请求的 Pod。Kubelet 根据它们消耗超出请求的资源量驱逐 Burstable Pod。消耗超出请求的 Pod 将在消耗接近其请求的 Pod 之前被驱逐。
BestEffort
没有 CPU 或内存限制或请求的 Pod。这些 Pod 以“尽力而为”的方式运行。它们是 kubelet 驱逐的首选对象。
注意
Pod 驱逐是一个复杂的过程。除了使用 QoS 类来对 Pod 进行排名外,kubelet 在进行驱逐决策时还考虑 Pod 的优先级。Kubernetes 文档中有一篇出色的文章详细讨论了“资源不足”处理。
现在我们知道资源请求和限制提供了租户隔离,并确定了 Pod 的 QoS 类,让我们深入讨论资源请求和限制的详细信息。尽管 Kubernetes 支持请求和限制不同的资源,我们将专注于 CPU 和内存,这是所有工作负载在运行时都需要的基本资源。让我们首先讨论内存请求和限制。
每个 Pod 中的容器都可以指定内存请求和限制。当设置了内存请求后,调度器会将它们相加以获取 Pod 的总体内存请求量。有了这些信息,调度器会找到一个具备足够内存容量来托管该 Pod 的节点。如果集群中没有节点具备足够的内存,该 Pod 将保持在等待状态。一旦被调度,Pod 中的容器则确保获得请求的内存量。
Pod 的内存请求代表内存资源的保证下限。但是,如果节点上有可用的额外内存,它们可以消耗额外的内存。这是有问题的,因为 Pod 使用了调度器可以分配给其他工作负载或租户的内存。当新 Pod 被调度到同一节点上时,这些 Pod 可能会争夺内存。为了尊重两个 Pod 的内存请求,超出其请求的 Pod 将被终止。图 12-3 描述了这个过程。

图 12-3. 当 Pod 消耗超过其请求的内存时,将终止以回收内存以供新 Pod 使用。
为了控制租户可以消耗的内存量,我们必须在工作负载上包含内存限制,这些限制强制执行给定工作负载可用内存的上限。如果工作负载试图消耗超过限制的内存,则会终止该工作负载。这是因为内存是一种不可压缩的资源。无法对内存进行节流,因此在节点的内存争用时必须终止进程。以下片段显示了一个因内存不足而被杀死(OOMKilled)的容器。请注意输出的“Last State”部分中的“Reason”:
$ kubectl describe pod memory
Name: memory
Namespace: default
Priority: 0
... <snip> ...
Containers:
stress:
... <snip> ...
Last State: Terminated
Reason: OOMKilled
Exit Code: 1
Started: Fri, 23 Oct 2020 10:11:51 -0400
Finished: Fri, 23 Oct 2020 10:11:56 -0400
Ready: True
Restart Count: 1
Limits:
memory: 100Mi
Requests:
memory: 100Mi
我们在实际场景中经常遇到的一个常见问题是,是否应该允许租户将内存限制设置得高于请求。换句话说,节点是否应该在内存上进行超额订阅。这个问题归结为节点密度和稳定性之间的权衡。当您超额订阅节点时,您增加了节点密度,但降低了工作负载的稳定性。正如我们所见,当内存争用时,消耗超过其请求的内存的工作负载会被终止。在大多数情况下,我们鼓励平台团队避免超额订阅节点,因为他们通常认为稳定性比紧密打包节点更重要。特别是在托管生产工作负载的集群中。
现在我们已经介绍了内存请求和限制,让我们将讨论重点转移到 CPU 上。与内存不同,CPU 是一种可压缩资源。当 CPU 争用时,可以对进程进行节流。因此,CPU 请求和限制比内存请求和限制略显复杂。
CPU 请求和限制使用 CPU 单位来指定。在大多数情况下,1 个 CPU 单位相当于 1 个 CPU 核心。请求和限制可以是分数(例如,0.5 CPU),并且可以通过添加m后缀以毫秒表示。1 个 CPU 单位等于 1000m CPU。
当 Pod 中的容器指定 CPU 请求时,调度程序将找到具有足够容量放置 Pod 的节点。放置后,kubelet 将请求的 CPU 单位转换为 cgroup CPU 份额。CPU 份额是 Linux 内核中授予 cgroup(即 cgroup 内进程)CPU 时间的机制。以下是 CPU 份额的关键方面:
-
CPU 份额是相对的。1000 个 CPU 份额并不意味着 1 个 CPU 核心或 1000 个 CPU 核心。相反,CPU 容量按其相对份额在所有 cgroup 中进行比例分配。例如,考虑两个处于不同 cgroup 中的进程。如果进程 1(P1)有 2000 份额,而进程 2(P2)有 1000 份额,则 P1 将获得两倍于 P2 的 CPU 时间。
-
CPU 份额仅在 CPU 争用时生效。如果 CPU 未被充分利用,则不会对进程进行节流,进程可以消耗额外的 CPU 周期。根据前面的例子,只有当 CPU 完全忙碌时,P1 才会获得比 P2 两倍的 CPU 时间。
CPU 份额(CPU 请求)提供了在同一节点上运行不同租户所需的 CPU 资源隔离。只要租户声明 CPU 请求,CPU 容量就会根据这些请求进行共享。因此,租户无法通过获取 CPU 时间来使其他租户饥饿。
CPU 限制的工作方式不同。它们设定了每个容器可以使用的 CPU 时间的上限。 Kubernetes 利用完全公平调度器(CFS)的带宽控制功能来实现 CPU 限制。 CFS 带宽控制使用时间周期来限制 CPU 消耗。每个容器在可配置周期内获得一个配额。该配额确定每个周期可以消耗多少 CPU 时间。如果容器用尽了配额,则在剩余的周期内容器被节流。
默认情况下,Kubernetes 将周期设置为 100 毫秒。具有 0.5 个 CPU 限制的容器每 100 毫秒获得 50 毫秒的 CPU 时间,如图 12-4 所示。具有 3 个 CPU 限制的容器在每个 100 毫秒周期内获得 300 毫秒的 CPU 时间,有效地允许容器每 100 毫秒消耗多达 3 个 CPU。

Figure 12-4. 一个在具有 100 毫秒 CFS 周期和 50 毫秒 CPU 配额的 cgroup 中运行的进程的 CPU 消耗和节流情况。
由于 CPU 限制的性质,它们有时会导致意外行为或意外的节流现象。这通常发生在多线程应用程序中,它们可以在周期的最开始消耗整个配额。例如,具有 1 个 CPU 限制的容器每 100 毫秒获得 100 毫秒的 CPU 时间。假设容器有 5 个线程在使用 CPU,则容器在 20 毫秒内消耗完 100 毫秒的配额,并在剩余的 80 毫秒内被节流。这在图 12-5 中有所描述。

Figure 12-5. 多线程应用程序在 100 毫秒周期的前 20 毫秒内消耗整个 CPU 配额。
强制执行 CPU 限制对于最小化应用程序性能的变化非常有用,特别是在跨不同节点运行多个副本时。这种性能变化源于没有 CPU 限制,副本可以突发并消耗空闲的 CPU 周期,这些周期可能在不同的时间可能可用。通过将 CPU 限制设置为 CPU 请求,您消除了这种变化,因为工作负载准确获得了它们请求的 CPU。 (Google 和 IBM 发表了一篇关于更详细讨论 CFS 带宽控制的优秀白皮书。)同样,CPU 限制在性能测试和基准测试中起着关键作用。如果没有任何 CPU 限制,您的基准测试将产生无法确定的结果,因为可用于工作负载的 CPU 会根据它们被调度到的节点和可用的空闲 CPU 量而变化。
如果您的工作负载需要对 CPU 的访问具有可预测性(例如,对延迟敏感的应用程序),将 CPU 的限制设置为 CPU 请求的大小是有帮助的。否则,在 CPU 循环上设置上限是不必要的。当节点上的 CPU 资源存在竞争时,CPU 共享机制确保工作负载根据其容器的 CPU 请求获取公平的 CPU 时间份额。当 CPU 没有竞争时,空闲的 CPU 循环并不会浪费,因为工作负载可以机会性地利用它们。
网络策略
在大多数部署中,Kubernetes 假设平台上运行的所有 Pod 可以相互通信。正如您可以想象的那样,这种立场对于多租户集群是有问题的,因为您可能希望在租户之间强制执行网络层面的隔离。NetworkPolicy API 是您可以利用的机制,以确保在网络层面上租户彼此隔离。
我们在第五章中探讨了网络策略,讨论了容器网络接口(CNI)插件在执行网络策略中的作用。在本节中,我们将讨论默认拒绝所有的网络策略模型,这是网络策略的常见方法,特别是在多租户集群中。
作为平台操作员,您可以在整个集群中建立默认的拒绝所有网络策略。通过这样做,您采取了关于网络安全和隔离的最严格立场,因为一旦租户加入平台,他们就完全隔离。此外,您要求租户声明其工作负载的网络交互,这有助于提高其应用程序的网络安全性。
当涉及实施默认的拒绝所有策略时,您可以选择两种不同的路径,每种路径都有其优缺点。第一种方法利用了 Kubernetes 中可用的 NetworkPolicy API。由于这是一个核心 API,这种实现在不同的 CNI 插件之间是可移植的。然而,由于 NetworkPolicy 对象是命名空间范围的,这要求您创建和管理多个默认的拒绝所有 NetworkPolicy 资源,每个命名空间一个。此外,因为租户需要授权来创建自己的 NetworkPolicy 对象,您必须实现额外的控制(通常通过之前讨论过的准入 Webhook)来防止租户修改或删除默认的拒绝所有策略。以下代码片段显示了一个默认的拒绝所有 NetworkPolicy 对象。空的 Pod 选择器选择命名空间中的所有 Pod:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: tenant-a
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
另一种方法是利用 CNI 插件特定的自定义资源定义(CRD)。一些 CNI 插件,如 Antrea、Calico 和 Cilium,提供了 CRD,使您能够指定集群级或“全局”网络策略。这些 CRD 帮助您减少默认拒绝所有策略的实施和管理复杂性,但它们将您绑定到特定的 CNI 插件。以下片段显示了一个示例 Calico GlobalNetworkPolicy CRD,实现了默认拒绝所有策略。
apiVersion: projectcalico.org/v3
kind: GlobalNetworkPolicy
metadata:
name: default-deny
spec:
selector: all()
types:
- Ingress
- Egress
注意
通常,实施默认拒绝所有网络策略会例外允许基本网络流量,例如向集群的 DNS 服务器发出的 DNS 查询。此外,它们不适用于 kube-system Namespace 和任何其他系统级 Namespace,以防止破坏集群。上述代码中的 YAML 片段未解决这些问题。
与大多数选择一样,是使用内置 NetworkPolicy 对象还是 CRD 之间存在可移植性和简单性的权衡。根据我们的经验,我们发现通过利用特定于 CNI 的 CRD 所获得的简单性通常是值得的,考虑到切换 CNI 插件是一种不常见的事件。话虽如此,未来您可能无需做出这种选择,因为 Kubernetes 网络特别兴趣小组(sig-network)正在考虑演变 NetworkPolicy API 以支持集群范围的网络策略。
一旦默认的拒绝所有策略生效,租户就有责任在网络结构中打洞,以确保他们的应用程序能够正常运行。他们通过使用 NetworkPolicy 资源来实现这一点,在其中指定适用于他们工作负载的入站和出站规则。例如,以下片段展示了可以应用于 Web 服务的 NetworkPolicy。它允许来自 Web 前端的入站流量,并允许连接到其数据库的出站流量。
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: webservice
namespace: reservations
spec:
podSelector:
matchLabels:
role: webservice
policyTypes:
- Ingress
- Egress
ingress:
- from:
- podSelector:
matchLabels:
role: frontend
ports:
- protocol: TCP
port: 8080
egress:
- to:
- podSelector:
role: database
ports:
- protocol: TCP
port: 3306
实施默认拒绝所有网络策略是一种重要的租户隔离机制。在构建基于 Kubernetes 的平台时,我们强烈建议您遵循这种模式,特别是如果您计划托管多个租户。
Pod 安全策略
Pod 安全策略(PSPs)是另一种重要机制,确保租户可以安全共存于同一集群中。PSPs 在运行时控制 Pod 的关键安全参数,例如它们作为特权用户运行的能力,访问主机卷,绑定到主机网络等。没有 PSPs(或类似的策略执行机制),工作负载可以在集群节点上做几乎任何事情。
Kubernetes 使用准入控制器来强制执行大多数通过 PSP 实施的控制。(有时,要求非 root 用户由 kubelet 在下载镜像后验证容器的运行时用户来执行。)启用准入控制器后,除非 PSP 允许,否则创建 Pod 的尝试将被阻止。 示例 12-1 展示了我们通常在多租户集群中定义为 默认 策略的限制性 PSP。
示例 12-1. 示例限制性 PodSecurityPolicy
apiVersion: policy/v1beta1
kind: PodSecurityPolicy
metadata:
name: default
annotations:
seccomp.security.alpha.kubernetes.io/allowedProfileNames: |
'docker/default,runtime/default'
apparmor.security.beta.kubernetes.io/allowedProfileNames: 'runtime/default'
seccomp.security.alpha.kubernetes.io/defaultProfileName: 'runtime/default'
apparmor.security.beta.kubernetes.io/defaultProfileName: 'runtime/default'
spec:
privileged: false 
allowPrivilegeEscalation: false
requiredDropCapabilities:
- ALL
volumes: 
- 'configMap'
- 'emptyDir'
- 'projected'
- 'secret'
- 'downwardAPI'
- 'persistentVolumeClaim'
hostNetwork: false 
hostIPC: false
hostPID: false
runAsUser:
rule: 'MustRunAsNonRoot' 
seLinux:
rule: 'RunAsAny' 
supplementalGroups: 
rule: 'MustRunAs'
ranges:
- min: 1
max: 65535
fsGroup: 
rule: 'MustRunAs'
ranges:
- min: 1
max: 65535
readOnlyRootFilesystem: false
不允许特权容器。
控制 Pods 可以使用的卷类型。
阻止 Pods 绑定到底层主机的网络堆栈。
确保容器以非 root 用户身份运行。
此策略假设节点使用的是 AppArmor 而不是 SELinux。
指定容器可以使用的允许组 ID。 禁止使用 root gid (0)。
控制应用于卷的组 ID。 禁止使用 root gid (0)。
允许 Pod 存在的 PSP 是不够的,Pod 必须被授权使用这个 PSP。 PSP 的授权是通过 RBAC 处理的。 如果它们的 Service Account 被授权使用 PSP,则 Pod 可以使用 PSP。 如果创建 Pod 的执行者被授权使用 PSP,Pod 也可以使用 PSP。 然而,考虑到 Pod 很少由集群用户创建,使用 Service Accounts 进行 PSP 授权是更常见的方法。 以下代码片段显示了授权特定 PSP sample-psp 使用的角色和角色绑定:
kind: Role
apiVersion: rbac.authorization.k8s.io/v1
metadata:
name: sample-psp
rules:
- apiGroups: ['policy']
resources: ['podsecuritypolicies']
resourceNames: ['sample-psp']
verbs: ['use']
---
apiVersion: rbac.authorization.k8s.io/v1beta1
kind: RoleBinding
metadata:
name: sample-psp
subjects:
- kind: ServiceAccount
name: my-app
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: sample-psp
在大多数情况下,平台团队负责创建和管理 PSP,并启用租户使用它们。 在设计策略时,始终遵循最小权限原则。 仅允许 Pods 完成其工作所需的最小权限集和能力。 作为起点,我们通常建议创建以下策略:
默认
默认策略可供集群中所有租户使用。 它应该是一个阻止所有特权操作、放弃所有 Linux 能力、不允许作为 root 用户运行等策略。(有关此策略的 YAML 定义,请参见 示例 12-1。)要使其成为默认策略,您可以授权集群中所有 Pod 使用此 PSP,方法是使用 ClusterRole 和 ClusterRoleBinding。
Kube-system
kube-system 策略适用于存在于 kube-system 命名空间中的系统组件。由于这些组件的性质,此策略需要比默认策略更宽松。例如,它必须允许 Pod 挂载hostPath卷并以 root 身份运行。与默认策略相反,RBAC 授权是通过针对 kube-system 命名空间中所有服务账户范围的 RoleBinding 实现的。
网络
网络策略针对集群的网络组件,如 CNI 插件。这些 Pod 需要更多的特权来操作集群节点的网络堆栈。为了将此策略隔离到网络 Pod,创建一个 RoleBinding,仅授权网络 Pod 的服务账户使用该策略。
有了这些策略,租户可以将非特权工作负载部署到集群中。如果有一个需要额外特权的工作负载,您必须确定是否可以容忍在同一集群中运行该特权工作负载的风险。如果可以,创建一个针对该工作负载量身定制的不同策略。授予工作负载所需的特权,并仅授权该工作负载的服务账户使用 PSP。
PSP 是多租户平台中的关键执行机制。它们控制租户在运行时可以做什么和不能做什么,因为它们与其他租户一起在共享节点上运行。在构建平台时,您应该利用 PSP 来确保租户被隔离并受到保护。
注意
Kubernetes 社区正在讨论是否从核心项目中移除 PodSecurityPolicy API 和准入控制器的可能性。如果移除,您可以利用诸如Open Policy Agent或Kyverno之类的策略引擎来实现类似的功能。
多租户平台服务
除了在平台上隔离 Kubernetes 控制平面和工作负载平面外,您还可以在提供的不同服务中强制执行隔离。这些服务包括日志记录、监控、入口等。在实施此隔离的一个重要决定因素是您用于提供服务的技术。在某些情况下,工具或技术可能会直接支持多租户,从而极大简化您的实施。
另一个需要考虑的重要问题是在这一层是否需要隔离租户。租户之间查看彼此的日志和指标是否可以?他们可以自由地发现彼此的服务吗?他们可以共享入口数据路径吗?回答这些类似的问题将有助于澄清您的需求。最终,这归结为您在平台上托管的租户之间的信任级别。
在帮助平台团队时,我们经常遇到的典型情况是使用 Prometheus 进行多租户监控。Prometheus 默认情况下不支持多租户。指标被摄取并存储在单个时间序列数据库中,任何具有 Prometheus HTTP 端点访问权限的人都可以访问。换句话说,如果 Prometheus 实例从多个租户那里抓取指标,就没有办法阻止不同租户看到彼此的数据。为了解决这个问题,我们需要为每个租户部署单独的 Prometheus 实例。
在解决这个问题时,我们通常利用 prometheus-operator。正如在 第九章 中讨论的那样,prometheus-operator 允许您使用自定义资源定义来部署和管理多个 Prometheus 实例。借助这一能力,您可以提供一个监控平台服务,安全地支持各种租户。租户完全隔离,因为他们会得到一个包括 Prometheus、Grafana、Alertmanager 等在内的专用监控堆栈。
根据平台的目标用户体验,您可以允许租户使用运算符部署他们自己的 Prometheus 实例,或者在租户入驻时自动创建实例。当平台团队有能力时,我们推荐后者,因为它减轻了平台租户的负担,并提供了更好的用户体验。
集中式日志记录是另一个可以考虑多租户实现的平台服务。通常,这涉及将不同租户的日志发送到不同的后端或数据存储中。大多数日志转发器都有路由功能,可以用来实现多租户解决方案。
在 Fluentd 和 Fluent Bit 的情况下,当配置转发器时,您可以利用它们基于标签的路由功能。以下代码片段显示了一个样例 Fluent Bit 输出配置,将 Alice 的日志(alice-ns 命名空间中的 Pod)路由到一个后端,将 Bob 的日志(bob-ns 命名空间中的 Pod)路由到另一个后端:
[OUTPUT]
Name es
Match kube.var.log.containers.**alice-ns**.log
Host alice.es.internal.cloud.example.com
Port ${FLUENT_ELASTICSEARCH_PORT}
Logstash_Format On
Replace_Dots On
Retry_Limit False
[OUTPUT]
Name es
Match kube.var.log.containers.**bob-ns**.log
Host bob.es.internal.cloud.example.com
Port ${FLUENT_ELASTICSEARCH_PORT}
Logstash_Format On
Replace_Dots On
Retry_Limit False
除了在后端隔离日志外,您还可以实现速率限制或节流,以防止一个租户占用日志转发基础设施。Fluentd 和 Fluent Bit 都有您可以使用的插件来执行这些限制。最后,如果有适当的用例需要,您可以利用日志运算符来支持更高级的用例,例如通过 Kubernetes CRD 公开日志配置。
平台服务层的多租户有时会被平台团队忽视。在构建多租户平台时,请考虑您的需求及其对您想要提供的平台服务的影响。在某些情况下,这可能会推动对平台基础设施方案和工具的决策。
总结
Workload tenancy is a crucial concern you must consider when building a platform atop Kubernetes. On one hand, you can operate single-tenant clusters for each of your platform tenants. While this approach is viable, we discussed its downsides, including resource and management overhead. The alternative is multitenant clusters, where tenants share the cluster’s control plane, workload plane, and platform services.
When hosting multiple tenants on the same cluster, you must ensure tenant isolation such that tenants cannot negatively affect each other. We discussed the Kubernetes Namespace as the foundation upon which we can build the isolation. We then discussed many of the isolation mechanisms available in Kubernetes that allow you to build a multitenant platform. These mechanisms are available in different layers, mainly the control plane, the workload plane, and the platform services.
The control plane isolation mechanisms include RBAC to control what tenants can do, resource quotas to divvy up the cluster resources, and admission webhooks to enforce policy. On the workload plane, you can segregate tenants by using Resource Requests and Limits to ensure fair-sharing of node resources, Network Policies to segment the Pod network, and Pod Security Policies to limit Pods capabilities. Finally, when it comes to platform services, you can leverage different technologies to implement multitenant offerings. We explored monitoring and centralized logging as example platform service that you can build to support multiple tenants.
第十三章:自动缩放
自动缩放工作负载容量的能力是云原生系统的一个引人注目的好处之一。如果您的应用程序面临容量需求显著变化,自动缩放可以降低成本并减少在管理这些应用程序时的工程工作量。自动缩放是在无需人工干预的情况下增加和减少工作负载容量的过程。这始于利用度量标准来提供应用程序容量应何时扩展的指示器。它包括调整响应这些指标的设置。最终目标是通过系统来实际扩展和收缩应用程序可用的资源,以适应其必须执行的工作。
尽管自动缩放可以提供很好的好处,但重要的是要认识到何时不使用自动缩放。自动缩放为应用程序管理引入了复杂性。除了初始设置外,您很可能需要重新审视和调整自动缩放机制的配置。因此,如果应用程序的容量需求变化不显著,为应用程序能够处理的最高流量预留资源可能是完全可以接受的。如果您的应用程序负载在可预见的时间发生变化,那么在这些时间手动调整容量的工作量可能微不足道,以至于投资于自动缩放可能不值得。与几乎所有技术一样,只有在长期利益超过系统设置和维护的情况下才能利用它们。
我们将把自动缩放的主题划分为两个广泛的类别:
工作负载自动缩放
对个别工作负载容量的自动化管理
集群自动缩放
对托管工作负载的底层平台容量的自动化管理
在我们检视这些方法时,请记住自动缩放的主要动机:
成本管理
当您从公共云提供商租用服务器或为使用虚拟化基础设施而内部计费时,这点最为相关。集群自动缩放允许您动态调整所支付的机器数量。为了实现基础设施的弹性,您将需要利用工作负载自动缩放来管理集群中相关应用程序的容量。
容量管理
如果您有一组静态的基础设施可以利用,自动缩放为您提供了动态管理固定容量分配的机会。例如,为您的业务最终用户提供服务的应用程序通常在最繁忙的时间和日子会有高峰。工作负载自动缩放允许应用程序在需要时动态扩展其容量,并在集群中占用大量资源。它还允许它收缩并为其他工作负载腾出空间。也许您有可以在非高峰时期利用未使用计算资源的批处理工作负载。集群自动缩放可以消除大量人工管理计算基础设施容量的工作量,因为您的集群使用的机器数量会在无人干预的情况下进行调整。
对于负载和流量波动的应用程序,自动缩放是非常有效的。如果没有自动缩放,您有两个选择:
-
过度配置你的应用容量,增加业务成本。
-
提醒您的工程师进行手动缩放操作,增加运维中的额外劳动成本。
在本章中,我们首先探讨如何实现自动缩放,并设计软件以利用这些系统。然后我们将深入探讨特定系统的细节,这些系统可以用于在基于 Kubernetes 的平台中自动缩放我们的应用程序。这将包括水平和垂直自动缩放,包括触发缩放事件所应使用的指标。我们还将研究如何按比例缩放工作负载以适应集群本身,以及您可能考虑的自定义自动缩放示例。最后,在“集群自动缩放”中,我们将讨论扩展平台本身的缩放,以便它能够适应其托管的工作负载的需求显著变化。
缩放类型
在软件工程中,缩放通常分为两类:
水平缩放
这涉及更改工作负载的相同副本数量。这可以是特定应用程序的 Pod 的数量,也可以是托管应用程序的集群中的节点数量。未来提到水平缩放将使用术语“外扩”或“内缩”来指示增加或减少 Pod 或节点的数量。
垂直缩放
这涉及修改单个实例的资源容量。对于应用程序来说,这意味着更改应用程序容器的资源请求和/或限制。对于集群的节点来说,通常涉及改变可用的 CPU 和内存资源的数量。未来提到垂直缩放将使用术语“上升”或“下降”来表示这些变化。
对于需要动态扩展的系统,即频繁、显著变化负载的系统,尽可能偏好水平扩展。垂直扩展受限于可用的最大机器。此外,通过垂直扩展增加容量需要应用程序重新启动。即使在虚拟化环境中,动态扩展机器也是可能的,但 Pod 需要重新启动,因为此时不能动态更新资源请求和限制。与之相比,水平扩展不需要现有实例重新启动,并且通过添加副本动态增加容量。
应用程序架构
自动扩展的主题对面向服务的系统尤为重要。将应用程序分解为不同组件的好处之一是能够独立扩展应用程序的不同部分。在云原生出现之前,我们在 n 层架构中已经做到了这一点。将网页应用程序与其关系数据库分离并独立扩展已成常态。通过微服务架构,我们可以进一步扩展。例如,企业网站可能有一个支持在线商店的服务,与一个服务提供博客文章的服务不同。在进行市场活动时,可以扩展在线商店而不影响博客服务,博客服务可能保持不变。
有了独立扩展不同服务的机会,您能够更有效地利用工作负载使用的基础设施。但是,您引入了管理多个独立工作负载的管理开销。自动化这一扩展过程变得非常重要。在某一时刻,它变得至关重要。
自动扩展非常适合那些具有小型、敏捷的工作负载,其镜像大小小且启动速度快。如果将容器镜像拉到特定节点所需时间短,并且一旦创建容器后应用程序启动时间也很短,则工作负载可以迅速响应扩展事件。可以更快速地调整容量。镜像大小超过一千兆字节且启动脚本运行几分钟的应用程序,则不太适合响应负载变化。在设计和构建应用程序时请牢记这一点。
还需注意的是,自动扩展将涉及停止应用程序实例。当然,这不适用于工作负载的扩展。然而,扩展之后,也必须缩回。这将涉及停止正在运行的实例。而对于垂直扩展的工作负载,需要重新启动以更新资源分配。无论哪种情况,你的应用程序能够优雅地关闭的能力都是很重要的。详细讨论请参见第十四章。
现在我们已经解决了需要注意的设计问题,让我们深入探讨在 Kubernetes 集群中自动扩展工作负载的详细信息。
工作负载自动扩展
本节将重点讨论自动扩展应用程序工作负载。这涉及监控某些指标并在没有人为干预的情况下调整工作负载容量。虽然这听起来像是一种设定并忘记的操作,但不要这样对待,特别是在初始阶段。即使在负载测试自动缩放配置之后,您仍需要确保生产环境中的行为符合您的意图。负载测试并不总能准确模拟生产条件。因此,一旦投入生产使用,您将希望检查应用程序,以验证它是否在正确的阈值上进行扩展,并满足效率和最终用户体验的目标。强烈建议设置警报,以便在出现重要的扩展事件时得到通知,并根据需要查看和调整其行为。
本节大部分内容将涉及水平 Pod 自动缩放器和垂直 Pod 自动缩放器。这些是在 Kubernetes 上自动扩展工作负载时最常用的工具。我们还将深入探讨工作负载使用的指标以触发扩展事件的情况,以及在考虑自定义应用程序指标用于此目的时应考虑的内容。我们还将探讨集群比例自动缩放器以及适合使用该工具的用例。最后,我们将简要讨论超出这些特定工具范围的自定义方法。
水平 Pod 自动缩放器
水平 Pod 自动缩放器(HPA)是在基于 Kubernetes 平台上自动扩展工作负载时最常用的工具。它由 Kubernetes 原生支持,通过水平 Pod 自动缩放器资源和捆绑到 kube-controller-manager 中的控制器实现。如果您使用 CPU 或内存消耗作为工作负载自动缩放的度量标准,使用 HPA 的门槛就很低。
在这种情况下,您可以使用 Kubernetes Metrics Server 将 PodMetrics 提供给 HPA。Metrics Server 从集群中的 kubelet 收集容器的 CPU 和内存使用情况指标,并通过 PodMetrics 资源在资源指标 API 中提供这些指标。Metrics Server 利用 Kubernetes API 聚合层。对于 API 组和版本 metrics.k8s.io/v1beta1 的资源请求将被代理到 Metrics Server。
图 13-1 展示了组件如何执行这一功能。度量服务器收集平台上容器的资源使用度量数据。它从集群中每个节点上运行的 kubelet 获取数据,并使这些数据对需要访问它的客户端可用。HPA 控制器每 15 秒默认从 Kubernetes API 服务器查询一次资源使用数据。Kubernetes API 代理请求到度量服务器,并提供所请求的数据。HPA 控制器保持对 HorizontalPodAutoscaler 资源类型的监视,并使用在那里定义的配置来确定应用程序副本数量是否合适。示例 13-1 展示了如何进行此决定。应用程序通常使用 Deployment 资源进行定义,当 HPA 控制器确定需要调整副本数时,它通过 API 服务器更新相关的 Deployment。随后,Deployment 控制器响应并更新 ReplicaSet,从而改变 Pod 的数量。

图 13-1. 水平 Pod 自动扩展。
HPA 的期望状态在 HorizontalPodAutoscaler 资源中声明,如下例所示。targetCPUUtilizationPercentage 用于确定目标工作负载的副本数。
示例 13-1. Deployment 和 HorizontalPodAutoscaler 配置示例。
apiVersion: apps/v1
kind: Deployment
metadata:
name: sample
spec:
selector:
matchLabels:
app: sample
template:
metadata:
labels:
app: sample
spec:
containers:
- name: sample
image: sample-image:1.0
resources:
requests:
cpu: "100m" 
---
apiVersion: autoscaling/v1
kind: HorizontalPodAutoscaler
metadata:
name: sample
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: sample
minReplicas: 1 
maxReplicas: 3 
targetCPUUtilizationPercentage: 75 
度量使用的 resources.requests 必须为设置值。
副本数永远不会低于此值进行缩减。
副本数永远不会超出此值进行扩展。
期望的 CPU 利用率。如果实际利用率显著超出此值,则副本数将增加;如果显著低于,则减少。
注意
如果您有使用多个度量触发扩展事件的用例,例如 CPU 和 内存,您可以使用 autoscaling/v2beta2 API。在这种情况下,HPA 控制器将根据每个度量单独计算合适的副本数,然后应用最高值。
这是最常见和广泛使用的自动扩展方法,适用性广泛,并且相对简单实现。但是,理解此方法的局限性非常重要:
并非所有工作负载都可以进行水平扩展。
对于无法在不同实例之间共享负载的应用程序,水平扩展毫无用处。这对某些有状态工作负载和领导选举应用程序是正确的。对于这些用例,您可以考虑使用垂直 Pod 自动扩展。
集群大小将限制扩展能力。
当应用程序扩展时,可能会耗尽集群工作节点中的可用容量。这可以通过提前配置足够的容量来解决,使用警报来提示平台操作员手动添加容量,或者使用集群自动缩放来解决,这在本章的另一节中进行了讨论。
CPU 和内存可能不是用于扩展决策的正确指标。
如果您的工作负载暴露了更好地标识需要进行扩展的自定义指标,则可以使用它。我们将在本章的后面部分讨论这种用例。
警告
避免根据不总是与应用程序负载放置成比例变化的指标自动缩放工作负载。最常见的自动缩放指标是 CPU。但是,如果特定工作负载的 CPU 随增加的负载并未显著变化,而是更直接地与增加的负载成比例地消耗内存,则不要使用 CPU。一个不太明显的例子是如果工作负载在启动时消耗了额外的 CPU。在正常运行期间,CPU 可能是一个完全有用的自动缩放触发器。然而,在启动时的 CPU 峰值将被 HPA 解释为触发缩放事件,即使流量未引起峰值。可以通过 kube-controller-manager 标志来减轻这种情况,例如 --horizontal-pod-autoscaler-cpu-initialization-period,它将提供一个启动宽限期,或者 --horizontal-pod-autoscaler-sync-period,它允许您增加缩放评估之间的时间。但请注意,这些标志是设置在 kube-controller-manager 上的。这将影响整个集群中所有 HPAs,这将影响那些启动时 CPU 消耗不高的工作负载。您可能会减少整个集群中工作负载的 HPA 响应性。如果您发现您的团队在使用变通方法使 CPU 消耗作为自动缩放需求的触发器,请考虑使用更具代表性的自定义指标。例如,收到的 HTTP 请求数量可能会更好地作为衡量标准。
这就结束了水平 Pod 自动缩放器。接下来,我们将看一下 Kubernetes 中另一种可用的自动缩放形式:竖直 Pod 自动缩放。
竖直 Pod 自动缩放器
出于早前在 “扩展类型” 中讨论的原因,竖直扩展工作负载是一个较少见的需求。此外,在 Kubernetes 中实现竖直缩放的自动化更为复杂。虽然 HPA 包含在核心 Kubernetes 中,但 VPA 需要通过部署三个不同的控制器组件以及度量服务器来实现。因此,竖直 Pod 自动缩放器(VPA) 比 HPA 更少被使用。
VPA 由三个不同的组件组成:
推荐器
基于 PodMetrics 资源中 Pod 的使用情况,确定最佳的容器 CPU 和/或内存请求值。
准入插件
当根据推荐者的建议创建新 Pod 时,会变异资源请求和限制。
更新程序
将 Pod 驱逐,以便由准入插件应用更新后的值。
Figure 13-2 说明了组件与 VPA 的交互。

图 13-2. 垂直 Pod 自动缩放。
在 VerticalPodAutoscaler 资源中声明 VPA 的期望状态,如示例 Example 13-2 所示。
Example 13-2. 一个 Pod 资源和配置垂直自动缩放的 VerticalPodAutoscaler 资源
apiVersion: v1
kind: Pod
metadata:
name: sample
spec:
containers:
- name: sample
image: sample-image:1.0
resources: 
requests:
cpu: 100m
memory: 50Mi
limits:
cpu: 100m
memory: 50Mi
---
apiVersion: "autoscaling.k8s.io/v1beta2"
kind: VerticalPodAutoscaler
metadata:
name: sample
spec:
targetRef:
apiVersion: "v1"
kind: Pod
name: sample
resourcePolicy:
containerPolicies:
- containerName: '*' 
minAllowed: 
cpu: 100m
memory: 50Mi
maxAllowed: 
cpu: 1
memory: 500Mi
controlledResources: ["cpu", "memory"] 
updatePolicy:
updateMode: Recreate 
当更新值时,VPA 将保持请求:限制比例。在这个保证的 QOS 示例中,对请求的任何更改都将导致限制的相同更改。
这个缩放策略将适用于每个容器——在这个示例中只有一个。
资源请求不会设置低于这些值。
资源请求不会设置超过这些值。
指定正在自动缩放的资源。
有三种 updateMode 选项。Recreate 模式将激活自动缩放。Initial 模式将应用准入控制以在创建时设置资源值,但不会驱逐任何 Pod。Off 模式将建议资源值,但永远不会自动更改它们。
我们很少在现场看到 VPA 处于完全的 Recreate 模式。但是,在 Off 模式下使用它也是有价值的。在应用发布到生产之前,建议进行全面的负载测试和应用程序分析,但这并非总是现实。在企业环境中,由于截止日期的存在,工作负载通常在理解资源消耗配置文件之前就已部署到生产中。这通常导致过度请求资源作为一种安全措施,这往往导致基础设施的低利用率。在这些情况下,VPA 可以用来推荐值,然后由工程师评估并手动更新一旦生产负载已应用,他们可以放心工作负载不会在峰值使用时间被驱逐,这对于尚未优雅关闭的应用程序尤为重要。但是,由于 VPA 推荐值,它节省了一些审查资源使用度量和确定最佳值的工作。在这种用例中,它不是一个自动缩放器,而是一个资源调整辅助工具。
要从处于 Off 模式的 VPA 获取建议,请运行 kubectl describe vpa <vpa name>。您将在 Status 部分获得类似于 Example 13-3 的输出。
Example 13-3. 垂直 Pod 自动缩放建议
Recommendation:
Container Recommendations:
Container Name: coredns
Lower Bound:
Cpu: 25m
Memory: 262144k
Target:
Cpu: 25m
Memory: 262144k
Uncapped Target:
Cpu: 25m
Memory: 262144k
Upper Bound:
Cpu: 427m
Memory: 916943343
它将为每个容器提供建议。使用 Target 值作为 CPU 和内存请求的基线建议。
使用自定义指标进行自动缩放
如果 CPU 和内存消耗不是缩放特定工作负载的好指标,您可以利用自定义指标作为替代方案。我们仍然可以使用 HPA 等工具。但是,我们将更改用于触发自动缩放的指标来源。第一步是从应用程序中公开适当的自定义指标。第十四章 解释了如何执行此操作。
接下来,您需要将自定义指标公开给自动缩放器。这将需要一个自定义指标服务器,用于替代之前介绍过的 Kubernetes Metrics Server。一些供应商,如 Datadog,在 Kubernetes 中提供了执行此操作的系统。您还可以使用 Prometheus 进行此操作,假设您有一个 Prometheus 服务器在抓取和存储应用程序的自定义指标,这在 第十章 中有所介绍。在这种情况下,我们可以使用 Prometheus 适配器 提供自定义指标。
Prometheus 适配器将从 Prometheus 的 HTTP API 中检索自定义指标,并通过 Kubernetes API 公开这些指标。与 Metrics Server 类似,Prometheus 适配器使用 Kubernetes API 聚合来指示 Kubernetes 代理请求以获取指向 Prometheus 适配器的指标 API。事实上,除了自定义指标 API 外,Prometheus 适配器还实现了资源指标 API,允许您完全用 Prometheus 适配器替换 Metrics Server 的功能。此外,它还实现了外部指标 API,提供了根据集群外部指标调整应用程序规模的机会。
在利用自定义指标进行水平自动缩放时,Prometheus 从应用程序中抓取这些指标。Prometheus 适配器从 Prometheus 获取这些指标,并通过 Kubernetes API 服务器公开它们。HPA 查询这些指标,并相应地调整应用程序规模,如图 13-3 所示。
尽管以这种方式利用自定义指标引入了一些额外的复杂性,但如果您已经从工作负载中公开有用的指标并使用 Prometheus 对其进行监视,则用 Prometheus 适配器替换 Metrics Server 不是一个巨大的飞跃。而且它开启的额外自动缩放机会使其值得考虑。

图 13-3. 使用自定义指标的水平 Pod 自动缩放。
集群比例自动缩放器
集群比例自动扩展器(CPA)是一种水平工作负载自动扩展器,根据集群中节点(或节点子集)的数量来扩展副本。因此,与 HPA 不同,它不依赖任何度量 API。因此,它不依赖于 Metrics Server 或 Prometheus Adapter。此外,它不是使用 Kubernetes 资源进行配置,而是使用标志来配置目标工作负载和用于缩放配置的 ConfigMap。图 13-4 说明了 CPA 的简化操作模型。

图 13-4. 集群比例自动扩展。
CPA 具有较窄的使用案例。通常需要根据集群比例扩展的工作负载通常限于平台服务。在考虑 CPA 时,评估是否 HPA 能提供更好的解决方案,特别是如果您已经与其他工作负载一起使用 HPA。如果您已经使用 HPAs,则已经部署了 Metrics Server 或 Prometheus Adapter 来实现必要的度量 API。因此,部署另一个自动扩展器以及随之而来的维护开销可能不是最佳选择。或者,如果在尚未使用 HPAs 的集群中,并且 CPA 提供所需功能,则由于其简单的操作模型,它变得更具吸引力。
CPA 使用的两种扩展方法有:
-
线性方法会按照集群中节点或核心数的数量直接扩展您的应用程序。
-
阶梯方法使用阶梯函数来确定节点:副本和/或核心:副本的比例。
我们已经看到 CPA 在诸如集群 DNS 这样的服务中成功使用,允许集群扩展到数百个工作节点。在这种情况下,服务在 5 个节点时的流量和需求将与 300 个节点时大不相同,因此这种方法非常有用。
自定义自动扩展
关于工作负载自动扩展,到目前为止,我们已经讨论了社区中可用的一些具体工具:HPA、VPA 和 CPA,以及 Metrics Server 和 Prometheus Adapter。但是,扩展您的工作负载不仅限于这些工具集。任何您可以采用的自动化方法,实现您需要的扩展行为,都属于同一类别。例如,如果您知道应用程序流量在哪些天和时间增加,您可以实施像 Kubernetes CronJob 这样简单的解决方案,更新相关部署的副本计数。实际上,如果您能利用像这样的简单、直接的方法,更倾向于选择更简单的解决方案。少运动部件的系统更不太可能产生意外结果。
这些是自动缩放工作负载的方法。我们已经探讨了几种使用核心 Kubernetes、社区开发的附加组件和定制解决方案的方法。接下来,我们将看一看如何对托管这些工作负载的基础结构进行自动缩放:即 Kubernetes 集群本身。
集群自动伸缩器
Kubernetes 集群自动伸缩器(CA) 提供了一种自动化方案,用于横向扩展集群中的工作节点。它解决了 HPA 的一些限制,并可以显著减少围绕 Kubernetes 基础设施的容量和成本管理的工作量。
随着平台团队采用基于 Kubernetes 的平台,您需要在新租户上线时管理集群的容量。这可能是一个手动的常规审查过程。也可以是基于警报驱动的,通过使用使用率指标的警报规则通知您需要增加或移除工作节点的情况。或者您可以完全自动化操作,只需添加和移除租户,让 CA 管理集群扩展以适应需求。
此外,如果您正在利用工作负载自动缩放,并且资源消耗波动显著,CA 的作用会更加突出。当 HPA 管理的工作负载负载增加时,其副本数量将增加。如果您的集群计算资源不足,一些 Pod 将无法调度并保持在Pending状态。CA 会检测到这种确切条件,计算出满足需求的节点数量,并向您的集群添加新节点。图 图 13-5 显示了集群扩展以容纳横向扩展应用程序的情况。

图 13-5. 集群自动伸缩器响应 Pod 副本扩展而扩展节点。
另一方面,当负载减少并且 HPA 缩减应用程序的 Pod 时,CA 将寻找长时间未被充分利用的节点。如果可以将低利用率节点上的 Pod 重新调度到集群中的其他节点,CA 将取消分配低利用率节点以进行集群缩减。
当您调用这种动态管理工作节点的方法时,请记住它将不可避免地重新分配 Pod 在节点间的分布。Kubernetes 调度器通常会在 Pod 刚创建时在工作节点间均匀地分布 Pod。然而,一旦 Pod 开始运行,决定其所属的调度决策将不会重新评估,除非它被驱逐。因此,当特定应用程序水平扩展然后再次缩减时,您可能会发现 Pod 在工作节点上分布不均匀。在某些情况下,可能会导致一个部署的多个副本聚集在几个节点上。如果这对工作负载的节点故障容忍性构成威胁,您可以使用 Kubernetes descheduler 根据不同的策略将它们驱逐出去。一旦被驱逐,Pod 将被重新调度。这将有助于重新平衡它们在节点间的分布。我们尚未发现许多情况下确实存在这样的迫切需求,但这是一个可用的选择。
如果您正在考虑集群自动缩放,您需要计划基础设施管理方面的问题。首先,您需要使用项目存储库中记录的受支持的云提供商之一。接下来,您将需要授权 CA 为您创建和销毁机器。
如果您使用 Cluster API 项目与 CA 配合使用,那么这些基础设施管理问题将有所变化。Cluster API 使用其自己的 Kubernetes 操作符来管理集群基础设施。在这种情况下,CA 不再直接连接云提供商来添加和删除工作节点,而是将此操作卸载给 Cluster API。CA 只需更新 MachineDeployment 资源中的副本数,这由 Cluster API 控制器来协调。这消除了需要使用与 CA 兼容的云提供商的必要性(但是,您需要检查是否有适用于您云提供商的 Cluster API 提供程序)。权限问题也被转移到 Cluster API 组件上处理。从许多方面来看,这是一个更好的模型。然而,Cluster API 通常使用管理集群进行实现。这为集群自动缩放引入了应该考虑的外部依赖关系。有关此主题的进一步信息,请参阅 “管理集群”。
CA 的扩展行为是可以配置的。CA 使用在项目的 GitHub 上的常见问题解答 中记录的标志进行配置。示例 13-4 展示了针对 AWS 的 CA 部署清单,并包括如何设置一些常见标志的示例。
示例 13-4. CA 部署清单针对亚马逊 Web 服务自动缩放组
apiVersion: apps/v1
kind: Deployment
metadata:
name: aws-cluster-autoscaler
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: "aws-cluster-autoscaler"
template:
metadata:
labels:
app.kubernetes.io/name: "aws-cluster-autoscaler"
spec:
containers:
- name: aws-cluster-autoscaler
image: "us.gcr.io/k8s-artifacts-prod/autoscaling/cluster-autoscaler:v1.18"
command:
- ./cluster-autoscaler
- --cloud-provider=aws 
- --namespace=kube-system
- --nodes=1:10:worker-auto-scaling-group 
- --logtostderr=true
- --stderrthreshold=info
- --v=4
env:
- name: AWS_REGION
value: "us-east-2"
livenessProbe:
httpGet:
path: /health-check
port: 8085
ports:
- containerPort: 8085
配置所支持的云服务提供商;在这种情况下是 AWS。
此标志配置 CA 以更新名为worker-auto-scaling-group的 AWS 自动扩展组。它允许 CA 在此组中的机器数量在 1 和 10 之间进行扩展。
集群自动缩放可以非常有用。它解锁了云原生基础设施提供的引人注目的好处之一。然而,它引入了不小的复杂性。在依赖其自主管理生产环境中关键业务平台的扩展之前,请确保进行负载测试并深入了解系统的行为。一个重要的考虑因素是清楚地了解您的集群将达到的上限。如果您的平台托管大量工作负载能力,并且允许您的集群扩展到数百个节点,请了解在平台组件开始引入瓶颈之前它将扩展到的位置。有关集群大小的更多讨论可以在第二章中找到。
集群自动缩放的另一个考虑因素是在需要时集群将扩展的速度。这是过度配置可能有所帮助的地方。
集群过度配置
重要的是要记住,集群自动缩放器响应因集群中计算资源不足而无法调度的Pending Pod。因此,在 CA 采取扩展集群节点的操作时,您的集群已经满了。这意味着,如果管理不当,您的扩展工作负载可能会因为新节点变得可用以进行调度所需的时间而受到容量不足的影响。这是集群过度配置器可以帮助的地方。
首先,重要的是要了解新节点启动、加入集群并准备接受工作负载需要多长时间。一旦理解了这一点,您可以针对您的情况找到最佳解决方案:
-
将目标利用率在您的 HPAs 中设置得足够低,以便在应用程序达到最大容量之前充分扩展您的工作负载。这可以提供缓冲区,允许时间来配置节点。这样可以减轻过度配置集群的需要,但如果需要考虑负载特别急剧增加的情况,可能需要将目标利用率设置得过低以防止容量短缺。这将导致您在长期过度配置工作负载能力以应对罕见事件的情况。
-
另一种解决方案是使用集群过度配置。通过这种方法,您可以将空节点准备好以提供工作负载扩展的缓冲区。这将减轻在准备高负载事件时人为地将 HPAs 的目标利用率设置得过低的需求。
集群过度配置通过部署执行以下操作的 Pod 来工作:
-
请求足够的资源来为节点预留几乎所有资源
-
不消耗实际资源
-
使用优先级类使它们在任何其他 Pod 需要时立即被驱逐
将过度配置器 Pod 上的资源请求设置为保留整个节点,然后通过调整过度配置器 Deployment 上的副本数量来调整待命节点的数量。通过简单增加过度配置器 Deployment 上的副本数量,可以实现特定事件或营销活动的过度配置。
图 13-6 展示了这一过程。此图示仅显示了一个 Pod 副本,但可以根据需要提供足够的缓冲区以应对扩展事件。

图 13-6. 集群过度配置。
现在过度配置器 Pod 占用的节点处于待命状态,随时准备为集群中的另一个 Pod 所需。您可以通过创建一个优先级类,并将其应用于过度配置器 Deployment,来实现这一点,其中 value: -1 将使所有其他工作负载默认具有更高的优先级。如果来自另一个工作负载的 Pod 需要资源,过度配置器 Pod 将立即被驱逐,为扩展工作负载腾出空间。过度配置器 Pod 将进入 Pending 状态,这将触发集群自动缩放器来准备一个新节点待命,如图 13-7. 使用集群过度配置扩展 所示。

图 13-7. 使用集群过度配置扩展。
使用集群自动缩放器和集群过度配置器,您可以有效地水平扩展您的 Kubernetes 集群,这与水平扩展工作负载非常匹配。我们在这里没有涵盖垂直扩展集群,因为我们还没有发现通过水平扩展无法解决的用例。
概要
如果您的应用程序需要显著变化的容量需求,请尽量倾向于使用水平扩展。如果可能的话,开发能够自动扩展的应用程序,并确保其能够频繁停止和启动,并暴露自定义指标,如果 CPU 或内存不是触发扩展的良好指标。测试您的自动扩展以确保其行为符合预期,以优化效率和最终用户体验。如果您的工作负载将超出集群容量,请考虑自动扩展集群本身。如果您的扩展事件特别急剧,请考虑使用集群过度配置器将节点放置在待命状态。
第十四章:应用考虑因素
当涉及到 Kubernetes 可以运行和管理的应用程序类型时,它相当灵活。除了操作系统和处理器类型的限制外,Kubernetes 基本上可以运行任何东西。大型单体应用、分布式微服务、批处理工作负载,应有尽有。Kubernetes 对工作负载施加的唯一要求是它们以容器镜像的形式分发。话虽如此,您可以采取一些步骤来使您的应用程序成为更好的 Kubernetes 公民。
在本章中,我们将讨论重点转移到应用程序,而不是平台。如果您是平台团队的一员,请不要跳过本章。虽然您可能认为这仅适用于开发人员,但它也适用于您。作为平台团队的成员,您很可能会构建应用程序,为平台上提供定制服务。即使您不这样做,本章的讨论也将帮助您更好地与使用平台的开发团队对齐,甚至教育那些可能对基于容器的平台不熟悉的团队。
本章涵盖了在 Kubernetes 上运行应用程序时应考虑的各种因素。主要包括:
-
将应用程序部署到平台上,并管理部署清单的机制,如模板化和打包。
-
配置应用程序的方法,例如使用 Kubernetes API(ConfigMaps/Secrets),并集成外部系统进行配置和密钥管理。
-
Kubernetes 的功能,可提高工作负载的可用性,如预停止容器钩子、优雅终止和调度约束。
-
State probes,这是 Kubernetes 的一个功能,使您能够向平台展示应用程序的健康信息。
-
资源请求和限制对确保应用程序在平台上正常运行至关重要。
-
日志、指标和跟踪作为调试、故障排除和有效操作工作负载的机制。
将应用程序部署到 Kubernetes
一旦您的应用程序被容器化并存储在容器镜像注册表中,您就可以准备将其部署到 Kubernetes 上。在大多数情况下,部署应用程序涉及编写描述运行应用程序所需的 Kubernetes 资源的 YAML 清单,例如 Deployments、Services、ConfigMaps、CRDs 等。然后,您将清单发送到 API 服务器,Kubernetes 会处理其余工作。使用原始 YAML 清单是入门的好方法,但在将应用程序部署到不同集群或环境时,它很快可能变得不切实际。您很可能会遇到类似以下问题:
-
在演练和生产环境中运行时如何提供不同的凭证?
-
在不同数据中心部署时如何使用不同的镜像注册表?
-
如何在开发和生产环境中设置不同的副本计数?
-
如何确保不同清单中的所有端口号匹配?
清单不胜枚举。虽然您可以为每个问题解决方案创建多组清单,但排列组合使得管理变得相当具有挑战性。在本节中,我们将讨论您可以采取的方法来解决清单管理问题。主要包括清单模板化和为 Kubernetes 打包应用程序。然而,我们不会详细讨论社区中可用的各种工具。我们经常发现,团队在考虑不同选择时陷入分析瘫痪。我们的建议是选择某种方法,然后继续解决更高价值的问题。
模板化部署清单
模板化涉及在您的部署清单中引入占位符。占位符提供了一种机制,可以根据需要注入值,而不是在清单中硬编码值。例如,以下模板化清单使您可以将复制数量设置为不同的值。也许在开发环境中您只需要一个副本,但在生产环境中需要五个副本。
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app: nginx
name: nginx
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
app: nginx
template:
metadata:
labels:
app: nginx
spec:
containers:
- image: nginx
name: nginx
Kubernetes 应用程序打包
创建自包含软件包是另一种可以用来部署应用程序并解决清单管理问题的机制。打包解决方案通常建立在模板化的基础上,但它们引入了其他有用的附加功能,例如能够将包推送到兼容 OCI 的注册表、生命周期管理钩子等等。
软件包是消耗由第三方维护的软件或将软件交付给第三方的重要机制。如果您已经使用 Helm 将软件安装到 Kubernetes 集群中,则已经利用了打包的好处。如果您对 Helm 不熟悉,以下片段为您展示了安装软件包所需的步骤:
$ helm repo add hashicorp https://helm.releases.hashicorp.com
"hashicorp" has been added to your repositories
$ helm install vault hashicorp/vault
如您所见,软件包是在 Kubernetes 上部署和管理软件的好方法。话虽如此,当涉及到需要高级生命周期管理的复杂应用程序时,软件包可能会显得力不从心。对于这样的应用程序,我们发现操作员是更好的解决方案。我们在第二章中详细讨论了操作员。尽管该章节侧重于平台服务,但所讨论的概念在构建用于复杂应用程序的操作员时同样适用。
配置和密钥摄入
应用程序通常具有告知它们在运行时如何行为的配置。配置通常包括日志记录级别、依赖项的主机名(例如,数据库的 DNS 记录)、超时等内容。其中一些设置可能包含敏感信息,例如密码,通常称为密钥。在本节中,我们将讨论在基于 Kubernetes 平台的应用程序配置的不同方法。首先,我们将审查 Kubernetes 核心中提供的 ConfigMap 和 Secret API。然后,我们将探讨与外部系统集成的 Kubernetes API 替代方法。最后,我们将根据实地经验提供关于这些方法的指导,介绍哪些方法最有效。
在深入讨论之前,值得一提的是,您应该避免将配置或密钥捆绑在应用程序的容器镜像中。应用程序二进制文件与其配置的紧密耦合会破坏运行时配置的目的。此外,对于密钥而言,这种做法还存在安全问题,因为镜像可能被不应访问密钥的角色访问到。您应该利用平台特性在运行时注入配置,而不是将配置包含在镜像中。
Kubernetes 的 ConfigMaps 和 Secrets
ConfigMaps 和 Secrets 是 Kubernetes API 中的核心资源,使您能够在运行时配置您的应用程序。与 Kubernetes 中的任何其他资源一样,它们通过 API 服务器创建,并通常以 YAML 形式声明,例如以下示例:
apiVersion: v1
kind: ConfigMap
metadata:
name: my-config
data:
debug: "false"
让我们讨论如何在您的应用程序中使用 ConfigMaps 和 Secrets。
第一种方法是将 ConfigMaps 和 Secrets 作为文件挂载到 Pod 文件系统中。在构建 Pod 规范时,您可以添加引用 ConfigMaps 或 Secrets 的卷,并将它们挂载到容器的特定位置。例如,以下片段定义了一个 Pod,将名为 my-config 的 ConfigMap 挂载到名为 my-app 的容器中,路径为 /etc/my-app/config.json:
apiVersion: v1
kind: Pod
metadata:
name: my-app
spec:
containers:
- image: my-app
name: my-app:v0.1.0
volumeMounts:
- name: my-config
mountPath: /etc/my-app/config.json
volumes:
- name: my-config
configMap:
name: my-config
利用卷挂载是在消费 ConfigMaps 和 Secrets 时的首选方法。原因在于 Pod 中的文件是动态更新的,这使您能够重新配置应用程序而无需重新启动应用程序或重新创建 Pod。尽管如此,应用程序必须支持这一功能。应用程序必须监视磁盘上的配置文件,并在文件更改时应用新配置。许多库和框架使实现此功能变得容易。当这不可行时,您可以引入一个 sidecar 容器来监视配置文件,并在有新配置可用时向主进程发信号(例如,使用 SIGHUP)。
通过环境变量消费 ConfigMaps 和 Secrets 是另一种可以使用的方法。如果您的应用程序通过环境变量来期望配置,这是一个自然的方法。环境变量还可以在需要通过命令行标志提供设置时提供帮助。在以下示例中,Pod 使用名为my-config的 ConfigMap 设置了DEBUG环境变量,该 ConfigMap 有一个名为debug的键,其包含以下值:
apiVersion: v1
kind: Pod
metadata:
name: my-app
spec:
containers:
- name: my-app
image: my-app:v0.1.0
env:
- name: DEBUG
valueFrom:
configMapKeyRef:
name: my-config
key: debug
使用环境变量的一个缺点是,对于 ConfigMaps 或 Secrets 的更改在运行的 Pod 中不会反映出来,直到它重新启动。这对某些应用程序可能并非问题,但您应该牢记这一点。另一个缺点,主要针对 Secrets,是某些应用程序或框架在启动时或崩溃时可能会将环境细节转储到日志中。这会造成安全风险,因为秘密可能会不经意地泄露到日志文件中。
这两种首选的 ConfigMap 和 Secret 消费方法依赖于 Kubernetes 将配置注入到工作负载中。另一个选择是应用程序与 Kubernetes API 通信来获取其配置。应用程序不使用配置文件或环境变量,而是直接从 Kubernetes API 服务器读取 ConfigMaps 和 Secrets。应用程序还可以监听 API,以便在配置更改时执行操作。开发人员可以使用许多 Kubernetes 库或 SDK 来实现此功能,或者利用支持此功能的应用程序框架,如 Spring Cloud Kubernetes。
虽然利用 Kubernetes API 进行应用程序配置可能很方便,但我们发现有一些重要的缺点需要考虑。首先,连接到 API 服务器以获取配置会在应用程序和 Kubernetes 平台之间创建紧密耦合关系。这种耦合会引发一些有趣的问题。如果 API 服务器宕机会发生什么?当平台团队升级 API 服务器时,您的应用程序会经历停机时间吗?
其次,应用程序要从 API 获取其配置时,需要凭据,并且需要具有适当的权限。这些要求增加了部署的复杂性,因为现在您必须提供一个服务账户,并为您的工作负载定义 RBAC 角色。
最后,越多应用程序使用此方法获取配置,API 服务器上的负载就越大。由于 API 服务器是集群控制平面的关键组件,这种应用程序配置方法可能与集群的整体可扩展性相冲突。
总的来说,当涉及到消费 ConfigMaps 和 Secrets 时,我们更喜欢使用卷挂载和环境变量,而不是直接集成 Kubernetes API。这样一来,应用程序与底层平台保持解耦。
从外部系统获取配置
当涉及配置应用程序时,ConfigMaps 和 Secrets 非常方便。它们内置于 Kubernetes API 中,可以随时供您使用。话虽如此,配置和密钥在 Kubernetes 出现之前就一直是应用程序开发者面临的问题。虽然 Kubernetes 提供了解决这些问题的功能,但并没有阻止您使用外部系统。
在现场遇到的外部配置或密钥管理系统中,最普遍的一个例子是HashiCorp Vault。Vault 提供了在 Kubernetes Secrets 中无法获得的高级密钥管理功能。例如,Vault 提供动态密钥、密钥轮换、基于时间的令牌等功能。如果您的应用程序已经利用了 Vault,那么在 Kubernetes 上运行应用程序时可以继续使用。即使尚未使用 Vault,评估其作为 Kubernetes Secrets 更强大的替代方案也是值得的。我们在第七章中广泛讨论了密钥管理考虑因素和 Vault 与 Kubernetes 的集成。如果您想了解更多关于 Kubernetes 中的密钥管理以及 Vault 集成的低级细节,请查阅该章节。
在利用外部系统进行配置或密钥时,我们发现尽可能将集成任务转移到平台上是有益的。与 Vault 等外部系统的集成可以作为平台服务提供,以将 Secrets 作为 Pods 中的卷或环境变量暴露出来。平台服务抽象了外部系统,并使您的应用程序能够消费 Secret,而无需担心集成的实现细节。总体而言,利用这样的平台服务可以降低应用程序的复杂性,并实现应用程序间的标准化。
处理重新调度事件
Kubernetes 是一个高度动态的环境,在这里工作负载会因各种原因而被移动。集群节点可能会来来去去;它们可能会耗尽资源,甚至失败。平台团队可以排空、隔离或删除节点以执行集群生命周期操作(例如升级)。这些都是可能导致您的工作负载被终止并重新调度的情况的示例,还有许多其他情况。
不论原因如何,Kubernetes 的动态特性都可能影响您应用的可用性和操作。尽管应用架构对干扰影响最大,但 Kubernetes 中的功能可以帮助您最小化这种影响。本节将探讨这些功能。首先,我们将深入了解预停止容器生命周期钩子。如其名称所示,这些钩子允许您在 Kubernetes 停止容器之前采取行动。然后,我们将讨论如何优雅地关闭容器,包括在应用程序内响应关闭事件的信号处理。最后,我们将审查 Pod 反亲和规则,这是一个可以用来跨故障域分布应用程序的机制。正如前面提到的,这些机制可以帮助最小化干扰的影响,但不能消除故障的潜在可能性。阅读本节时请牢记这一点。
预停止容器生命周期钩子
Kubernetes 可以出于任何原因终止工作负载。如果您需要在终止容器之前执行操作,可以利用预停止容器生命周期钩子。Kubernetes 提供两种类型的钩子。exec生命周期钩子在容器内部运行命令,而HTTP生命周期钩子则针对您指定的端点(通常是容器本身)发出 HTTP 请求。使用哪种钩子取决于您的具体要求和您试图实现的目标。
Contour中的预停止钩子是展示预停止钩子强大功能的一个绝佳示例。为了避免中断进行中的客户端请求,Contour 包含一个容器预停止钩子,告诉 Kubernetes 在停止容器之前执行一个命令。以下是来自 Contour Deployment YAML 文件的预停止钩子配置片段:
# <... snip ...>
spec:
containers:
- command:
- /bin/contour
args:
- envoy
- shutdown-manager
image: docker.io/projectcontour/contour:main
lifecycle:
preStop:
exec:
command:
- /bin/contour
- envoy
- shutdown
# <... snip ...>
容器预停止钩子使您能够在 Kubernetes 停止容器之前采取操作。它们允许您运行容器内存在但不是运行进程的命令或脚本。需要牢记的一个关键考虑因素是这些钩子仅在计划的生命周期或重新调度事件面前执行。例如,如果节点失败,这些钩子将不会运行。此外,作为预停止钩子的一部分执行的任何操作受 Pod 的优雅关闭期限的控制,我们将在接下来讨论。
优雅的容器关闭
在执行预停止钩子(如果提供)后,Kubernetes 通过向工作负载发送 SIGTERM 信号启动容器关闭过程。此信号告知容器它即将被停止。同时,它开始计时终止关闭期限,默认为 30 秒。您可以使用 Pod 规范的terminationGracePeriodSeconds字段来调整此期限。
在优雅终止期间,应用程序可以在关闭之前完成任何必要的操作。根据应用程序的不同,这些操作可以包括持久化数据、关闭开放连接、将文件刷新到磁盘等。完成这些操作后,应用程序应以成功的退出代码退出。优雅终止的示例在图 14-1 中有所说明,我们可以看到 kubelet 发送 SIGTERM 信号并等待容器在优雅终止期间内终止。
如果应用程序在终止期间内关闭,Kubernetes 将完成关闭流程并继续。否则,它将通过发送 SIGKILL 信号强制停止进程。图 14-1 还显示了这种强制终止在图表右下角。

图 14-1. Kubernetes 中的应用程序终止。kubelet 首先向工作负载发送 SIGTERM 信号,并等待配置的优雅终止期间。如果进程在期间结束后仍在运行,kubelet 将发送 SIGKILL 信号终止进程。
要使您的应用程序能够优雅地终止,它必须处理 SIGTERM 信号。每种编程语言或框架都有其配置信号处理程序的方法。有些应用程序框架甚至可能会为您处理这些事务。以下代码片段展示了一个配置 SIGTERM 信号处理程序的 Go 应用程序,在接收到信号后停止应用程序的 HTTP 服务器:
func main() {
// App initialization code here...
httpServer := app.NewHTTPServer()
// Make a channel to listen for an interrupt or terminate signal
// from the OS.
// Use a buffered channel because the signal package requires it.
shutdown := make(chan os.Signal, 1)
signal.Notify(shutdown, os.Interrupt, syscall.SIGTERM)
// Start the application and listen for errors
errors := make(chan error, 1)
go httpServer.ListenAndServe(errors)
// Block main and waiting for shutdown.
select {
case err := <-errors:
log.Fatalf("http server error: %v", err)
case <-shutdown:
log.Printf("shutting down http server")
httpServer.Shutdown()
}
}
在 Kubernetes 上运行应用程序时,我们建议您为 SIGTERM 信号配置信号处理程序。即使没有关闭操作需要执行,处理信号也会使您的工作负载成为更好的 Kubernetes 元素,因为它减少了停止应用程序所需的时间,从而为其他工作负载释放资源。
满足可用性要求
容器预停钩和优雅终止与应用程序的单个实例或副本相关。如果您的应用程序具有水平扩展性,那么您很可能在集群中运行多个副本以满足可用性要求。运行多个工作负载实例可以提高容错能力。例如,如果群集节点故障并将其中一个应用程序实例带走,其他副本可以继续工作。但是,请注意,如果这些副本运行在相同的故障域中,则拥有多个副本并不能提高可用性。
确保您的 Pods 分布在不同故障域的一种方法是使用 Pod 反亲和性规则。通过 Pod 反亲和性规则,您告诉 Kubernetes 调度器,您希望根据 Pod 定义中定义的约束条件来调度您的 Pods。更具体地说,您要求调度器避免将 Pod 放置在已经运行负载副本的节点上。考虑一个具有三个副本的 Web 服务器。为了确保这三个副本不会放置在同一故障域中,您可以像下面的片段中使用 Pod 反亲和性规则一样。在这种情况下,反亲和性规则告诉调度器,它应优先将 Pods 放置在按照集群节点上的 zone 标签确定的区域内:
# ... <snip> ...
affinity:
PodAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchExpressions:
- key: "app"
operator: In
values:
- my-web-server
topologyKey: "zone"
# ... <snip> ...
除了 Pod 反亲和性之外,Kubernetes 还提供了 Pod 拓扑扩展约束,这是在将 Pods 分布在不同故障域方面对 Pod 反亲和性规则的改进。反亲和性规则的问题在于无法保证 Pods 在域内均匀分布。您可以“偏好”根据拓扑键调度它们,或者可以保证每个故障域内只有一个副本。
Pod 拓扑扩展约束提供了一种方式告诉调度器如何分布您的工作负载。与 Pod 反亲和性规则类似,它们仅对需要调度的新 Pods 进行评估,因此不会对已存在的 Pods 进行反向强制执行。以下代码片段展示了一个 Pod 拓扑扩展约束的示例,该约束导致 Pods 在不同区域(基于节点的 zone 标签)中分布。如果无法满足约束条件,则不会调度 Pods。
# ... <snip> ...
spec:
topologySpreadConstraints:
- maxSkew: 1
topologyKey: zone
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
foo: bar
# ... <snip> ...
当运行多个应用程序实例时,应利用这些 Pod 放置特性来提高应用程序对基础设施故障的容忍度。否则,您面临的风险是 Kubernetes 可能以未能实现所需故障容忍度的方式调度您的工作负载。
状态探针
Kubernetes 使用许多信号来确定平台上运行的应用程序的状态和健康状况。在健康方面,Kubernetes 将工作负载视为不透明的盒子。它知道进程是否运行。尽管这些信息很有帮助,但通常不足以有效地运行和管理应用程序。这就是探针的作用所在。探针为 Kubernetes 提供了增加应用程序状态可见性的功能。
Kubernetes 提供三种探针类型:活跃探针(liveness)、就绪探针(readiness)和启动探针(startup)。在详细讨论每种类型之前,让我们回顾一下所有探针类型共有的不同探测机制:
Exec
kubelet 在容器内部执行命令。如果命令返回零退出代码,则探针被视为成功。否则,kubelet 将容器视为不健康。
HTTP
kubelet 向 Pod 中的端点发送 HTTP 请求。只要 HTTP 响应代码大于或等于 200 且小于 400,探针就被视为成功。
TCP
kubelet 在可配置端口与容器建立 TCP 连接。如果连接成功建立,则容器被视为健康。
除了共享探测机制外,所有探测都有一组通用的参数,您可以根据工作负载调整探测。这些参数包括成功和失败的阈值、超时时间等。Kubernetes 文档详细描述了每个设置,因此我们在这里不会深入讨论它们。
活跃探测
活跃探测帮助 Kubernetes 了解集群中 Pod 的健康状况。在节点级别,kubelet 会持续探测配置了活跃探测的 Pod。当活跃探测超过失败阈值时,kubelet 将认为 Pod 不健康,并重新启动它。图 14-2 显示了一个展示 HTTP 活跃探测的流程图。kubelet 每 10 秒探测一次容器。如果 kubelet 发现最近的 10 次探测失败,它将重新启动容器。

图 14-2. 显示基于 HTTP 的活跃探测的流程图,间隔为 10 秒。如果连续失败 10 次,则认为 Pod 不健康,kubelet 会重新启动它。
提示
鉴于活跃探测失败会导致容器重新启动,我们通常建议活跃探测实现不要检查工作负载的外部依赖项。通过将活跃探测限制在您的工作负载范围内,并且不检查外部依赖项,您可以防止可能发生的级联故障。例如,与数据库交互的服务不应将“数据库可用性”检查作为其活跃探测的一部分,因为重新启动工作负载通常不会解决问题。如果应用程序检测到与数据库的问题,应用程序可以进入只读模式或优雅地禁用依赖于数据库的功能。另一种选择是使应用程序在其就绪探测中失败,我们将在下面讨论。
就绪探测
就绪探测可能是 Kubernetes 中最常见和最重要的探测类型,特别是用于处理请求的服务。Kubernetes 使用就绪探测来控制是否将服务流量路由到 Pod。因此,就绪探测为应用程序提供了一种机制,告知平台它们已准备好接受请求。
与生存探测一样,kubelet 负责对应用程序进行探测,并根据探测结果更新 Pod 的状态。当探测失败时,平台将失败的 Pod 从可用端点列表中移除,有效地将流量重定向到其他准备好的副本。图 14-3 显示了一个解释基于 HTTP 的可用性探测的流程图。探测具有 5 秒的初始延迟和 10 秒的探测周期。在启动时,应用程序只有在可用性探测成功后才开始接收流量。然后,如果连续两次探测失败,平台将停止向 Pod 发送流量。
在部署服务型工作负载时,请确保配置一个可用性探测,以避免将请求发送到无法处理的副本上。可用性探测不仅在 Pod 启动时至关重要,在 Pod 的生命周期中也很重要,以防止将客户端路由到已变得不可用的副本上。

图 14-3. 显示基于 HTTP 的可用性探测流程图,间隔为 10 秒。如果连续两次探测失败,Pod 被视为未准备好,并从准备好的端点集中移除。
启动探测
自 Kubernetes 的第一个版本以来,生存探测和可用性探测一直可用。随着系统的普及,社区认识到需要实现一种额外的探测方法,即启动探测。启动探测为启动缓慢的应用程序提供了额外的初始化时间。与生存探测类似,启动探测失败会导致容器重新启动。然而,与生存探测不同的是,启动探测仅在成功之前执行,此后由生存探测和可用性探测接管。
如果你想知道为什么仅仅有一个生存探测还不够,让我们考虑一个平均需要 300 秒初始化时间的应用程序。你确实可以使用一个等待 300 秒的生存探测来停止容器。在启动期间,这个生存探测可以工作。但是当应用程序运行后呢?如果应用程序进入不健康状态,平台将等待 300 秒后再重启它!这就是启动探测解决的问题。它在启动期间看管工作负载,然后自动退出。图 14-4 显示了一个流程图,详细说明了一个类似我们刚讨论的启动探测。它的失败阈值是 30 次,探测周期是 10 秒。

图 14-4. 显示基于 HTTP 的启动探测流程图,探测周期为 10 秒。如果探测返回成功响应,则禁用启动探测并启用生存/可用性探测。否则,如果连续失败 30 次,则 kubelet 重新启动 Pod。
尽管启动探针对某些应用程序可能有用,但我们通常建议除非绝对必要,否则应避免使用它们。我们发现,大多数情况下使用活跃性和就绪性探针是合适的。
实施探针
现在我们已经讨论了不同的探针类型,让我们深入探讨在您的应用程序中如何处理它们,特别是活跃性探针和就绪性探针。我们知道,失败的活跃性探针会导致平台重新启动 Pod,而失败的就绪性探针会阻止流量路由到 Pod。鉴于这些不同的结果,我们发现大多数同时使用活跃性和就绪性探针的应用程序应该配置不同的探针端点或命令。
理想情况下,只有在存在需要重新启动的问题时,例如死锁或其他永久阻止应用程序进展的条件时,活跃性探针才会失败。通常,暴露 HTTP 服务器的应用程序会实现一个无条件返回 200 状态代码的活跃性端点。只要 HTTP 服务器健康且应用程序能够响应,就无需重新启动它。
与活跃性端点相比,就绪性端点可以检查应用程序内部的不同条件。例如,如果应用程序在启动时预热内部缓存,则在缓存未预热时就绪性端点可以返回 false。另一个例子是服务过载,这种情况下应用程序可以通过失败就绪性探针来卸载负载。可以想象,检查的条件因应用程序而异。但通常,它们都是随时间推移解决的临时条件。
总结一下,我们通常建议对处理请求的工作负载使用就绪性探针,因为在其他应用程序类型(如控制器、作业等)中,就绪性探针是没有意义的。至于活跃性探针,我们建议仅在重新启动应用程序有助于解决问题时考虑使用。最后,我们倾向于避免启动探针,除非绝对必要。
Pod 资源请求与限制
Kubernetes 的主要功能之一是在集群节点上调度应用程序。调度过程包括寻找能够托管工作负载的候选节点,除了其他因素外,还需要找到足够的资源来容纳工作负载。为了有效地放置工作负载,Kubernetes 调度器首先需要知道您的应用程序的资源需求。通常,这些资源包括 CPU 和内存,但也可以包括其他资源类型,如临时存储甚至自定义或扩展资源。
除了调度应用程序外,Kubernetes 还需要在运行时提供资源信息,以确保这些资源。毕竟,平台上有限的资源需要在应用程序之间共享。提供资源需求对于您的应用程序能够使用这些资源至关重要。
在本节中,我们将讨论资源请求和资源限制,以及它们如何影响您的应用程序。我们不会深入讨论平台如何实现资源请求和限制的详细信息,因为我们已经在第十二章中讨论过。
资源请求
资源请求指定应用程序运行所需的最小资源量。在大多数情况下,部署应用程序到 Kubernetes 时,您应该指定资源请求。通过这样做,您可以确保您的工作负载在运行时能够访问所请求的资源。如果您不指定资源请求,当节点上的资源竞争时,您可能会发现应用程序的性能显著下降。甚至可能会因为节点需要回收内存以供其他工作负载使用而终止您的应用程序。图 14-5 显示了一个应用程序被终止的情况,因为另一个带有内存请求的工作负载开始消耗额外内存。

图 14-5. Pod 1 和 Pod 2 共享节点的内存。每个 Pod 最初消耗了 500 MiB 总内存中的 200 MiB。当 Pod 1 需要消耗额外内存时,Pod 2 因其规格中没有内存请求而被终止。Pod 2 进入崩溃循环,因为它无法分配足够内存启动。
资源请求的主要挑战之一是找到合适的数字。如果您正在部署现有应用程序,则可能已经有数据可以分析以确定应用程序的资源请求,例如应用程序随时间的实际利用率或托管其的 VM 大小。当您没有历史数据时,您将不得不使用合理的猜测,并随时间收集数据。另一个选项是使用垂直 Pod 自动缩放器(VPA),它可以建议 CPU 和内存请求的值,并随时间调整这些值。有关 VPA 的更多信息,请参见第十三章。
资源限制
资源限制允许您指定工作负载可以消耗的最大资源量。您可能想知道为什么要设置人为限制。毕竟,资源越多越好。尽管对于某些工作负载来说是正确的,但无限制地访问资源可能会导致性能不可预测,因为 Pod 在资源可用时可以访问额外资源,但当其他 Pod 需要节点上的资源时则不能。内存情况更糟。鉴于内存是不可压缩的资源,平台在需要回收被机会性消耗的内存时别无选择,只能终止 Pod。
在处理应用程序日志时,首先要弄清楚的一件事是 什么 应该包含在日志中。尽管开发团队通常有他们自己的哲学,但我们发现他们往往在日志记录方面过度。如果记录过多,会导致噪音过多,可能会错过重要信息。相反,如果记录过少,可能会影响有效地排查应用程序故障。像大多数事情一样,在这里需要取得平衡。
如果你尝试针对工作负载运行性能测试或基准测试,限制也很重要。可以想象,每次测试运行将在不同时间调度在不同节点上的 Pod 上执行。如果工作负载上未强制执行资源限制,则测试结果可能会因为工作负载超出资源请求而高度变化,特别是当节点有空闲资源时。
通常情况下,你应该将资源限制设置为与资源请求相等,这样可以确保你的应用程序在旁边运行的其他 Pod 发生变化时仍然拥有相同数量的资源。
在设置资源限制时需要考虑的一个重要因素是是否需要将这些限制传播到工作负载本身。Java 应用程序是一个很好的例子。如果应用程序使用较旧的 Java 版本(JDK 版本 8u131 或更早版本),则需要将内存限制传播到 Java 虚拟机(JVM)。否则,JVM 将不知道限制并尝试消耗超出允许的内存。对于 Java 应用程序,可以使用 JAVA_OPTIONS 环境变量配置 JVM 的内存设置。另一个选项,虽然并非总是可行,是更新 JVM 版本,因为较新版本可以在容器中检测到内存限制。如果部署的应用程序利用运行时环境,请考虑是否需要传播资源限制以便应用程序理解它们。
应用程序日志对于开发和生产环境中应用程序的故障排除和调试至关重要。运行在 Kubernetes 上的应用程序应尽可能日志记录到标准输出和标准错误流(STDOUT/STDERR)。这不仅简化了应用程序的复杂性,而且从平台的角度来看,也是将日志发送到中央位置时的最简解决方案。我们在 第九章 中讨论了这个问题,我们还讨论了不同的日志处理策略、系统和工具。在本节中,我们将涉及一些在考虑应用程序日志时需要考虑的因素。首先,我们将讨论首先应该记录什么。然后,我们将讨论非结构化与结构化日志。最后,我们将讨论通过在日志消息中包含上下文信息来提高日志的实用性。
何时记录日志
应用程序日志
在与应用团队合作时,我们发现一个有用的经验法则是询问是否可以采取行动来确定是否记录某条消息。如果答案是肯定的,这是一个很好的指示,值得记录该消息。否则,这表明该日志消息可能没有用处。
非结构化与结构化日志的对比
应用程序日志可以分为非结构化或结构化。如其名称所示,非结构化日志是缺乏特定格式的文本字符串。它们可以说是最常见的日志类型,因为团队不需要预先进行任何计划。尽管团队可能有一些通用的指导方针,但开发人员可以按照自己喜欢的格式记录消息。
结构化日志与非结构化日志相反,其具有预定义的字段,在记录事件时必须提供这些字段。通常格式为 JSON 行或键-值行(例如,time="2015-08-09T03:41:12-03:21" msg="hello world!" thread="13" batchId="5")。结构化日志的主要优点在于其采用机器可读格式,便于查询和分析。话虽如此,结构化日志往往较难以人类可读的方式呈现,因此在实施应用程序日志记录时必须仔细权衡这一利弊。
日志中的上下文信息
日志的主要目的是提供关于应用程序在某一时间点发生了什么的见解。也许您正在解决生产环境中的问题,或者您正在执行根本原因分析,以了解为什么会发生某些事情。要完成这些任务,通常需要日志消息中的上下文信息,除了事件本身。
以支付应用为例。当应用程序请求服务管道遇到错误时,除了记录错误本身外,尝试包括围绕错误的上下文信息。例如,如果出现错误是因为未找到收款人,则包括收款人姓名或 ID、尝试进行付款的用户 ID、付款金额等。这样的上下文信息将提升您解决问题和预防此类问题的能力。话虽如此,在日志中避免包含敏感信息。您不希望泄露用户的密码或信用卡信息。
暴露指标
除了日志外,指标提供了关于应用程序行为的关键见解。一旦您拥有应用程序指标,您可以配置警报以在应用程序需要关注时通知您。此外,通过随时间聚合指标,您可以发现随着软件新版本的发布而出现的趋势、改进和退化。本节讨论了应用程序仪表化及您可以捕获的一些指标,包括 RED(速率、错误、持续时间)、USE(利用率、饱和度、错误)和特定于应用程序的指标。如果您对启用监控的平台组件以及有关指标的更多讨论感兴趣,请查阅 第九章。
仪表化应用程序
在大多数情况下,平台可以测量和展示有关应用程序外部可见行为的指标。例如 CPU 使用率、内存使用率、磁盘 IOPS 等指标可以直接从运行应用程序的节点获取。虽然这些指标很有用,但是为了从内部暴露关键指标,对应用程序进行仪表化是值得的。
Prometheus 是 Kubernetes 平台上最流行的监控系统之一,我们在现场经常遇到它。我们在 第九章 中广泛讨论了 Prometheus 及其组件。在本节中,我们将重点讨论为 Prometheus 仪表化应用程序的相关内容。
Prometheus 通过 HTTP 请求在可配置的端点(通常为 /metrics)从您的应用程序中提取指标。这意味着您的应用程序必须暴露此端点以供 Prometheus 抓取。更重要的是,端点的响应必须包含 Prometheus 格式的指标。根据您想要监视的软件类型,可以采取两种方法来暴露指标:
本地仪表化
该选项涉及使用 Prometheus 客户端库对应用程序进行仪表化,以便从应用程序进程内部暴露指标。当您可以控制应用程序源代码时,这是一个很好的方法。
外部导出器
这是一个额外的进程,与您的工作负载并行运行,转换预先存在的指标并以兼容 Prometheus 的格式暴露它们。这种方法最适合无法直接进行仪表化的现成软件,并且通常使用 sidecar 容器模式实现。例如 NGINX Prometheus Exporter 和 MySQL Server Exporter 就是这种情况的例子。
Prometheus 仪表化库支持四种指标类型:计数器(Counters)、仪表(Gauges)、直方图(Histograms)和摘要(Summaries)。计数器是只能增加的指标,而仪表是可以上升或下降的指标。直方图和摘要比计数器和仪表更高级。直方图将观察结果放入可配置的桶中,您可以使用这些桶来计算 Prometheus 服务器上的分位数(例如 95th 百分位数)。摘要与直方图类似,但它们在滑动时间窗口内在客户端上计算分位数。Prometheus 文档更深入地解释了这些指标类型。
使用 Prometheus 库对应用程序进行仪表化有三个主要步骤。让我们通过一个 Go 服务的仪表化示例来详细了解。首先,您需要启动一个 HTTP 服务器来暴露 Prometheus 需要抓取的指标。该库提供了一个 HTTP 处理程序,负责将指标编码成 Prometheus 格式。添加处理程序会类似于以下内容:
func main() {
// app code...
http.Handle("/metrics",
promhttp.HandlerFor(
prometheus.DefaultGatherer,
promhttp.HandlerOpts{},
))
log.Fatal(http.ListenAndServe("localhost:8080", nil))
}
接下来,您需要创建并注册指标。例如,如果您想暴露一个名为items_handled_total的计数器指标,您可以使用类似以下的代码:
// create the counter
var totalItemsHandled = prometheus.NewCounter(
prometheus.CounterOpts{
Name: "items_handled_total",
Help: "Total number of queue items handled.",
},
)
// register the counter
prometheus.MustRegister(totalItemsHandled)
最后,根据应用程序的实际情况更新指标。继续上面的计数器示例,您将使用计数器的Inc()方法来增加它:
func handleItem(item Item) {
// item handling code...
// increment the counter as we handle items
totalItemsHandled.Inc()
}
使用 Prometheus 库来对应用程序进行仪表化相对简单。更复杂的任务是确定应用程序应该公开的指标。在接下来的几节中,我们将讨论可以作为选择指标起点的不同方法或哲学。
USE 方法
USE 方法由Brendan Gregg提出,侧重于系统资源。使用此方法时,您可以捕获应用程序使用的每个资源的利用率、饱和度和错误(USE)。这些资源通常包括 CPU、内存、磁盘等,也可以包括存在于应用软件内部的资源,如队列、线程池等。
RED 方法
与 USE 方法相比,RED 方法更注重服务本身,而不是底层资源。最初由Tom Wilkie提出,RED 方法捕捉服务处理的请求速率、错误和持续时间。对于在线服务来说,RED 方法可能更合适,因为这些指标可以揭示用户的体验以及他们如何从自己的角度看待服务。
四个黄金信号
您还可以采用另一种哲学,即测量四个黄金信号,正如谷歌在《网站可靠性工程》(O’Reilly)中提出的那样。谷歌建议您为每个服务测量四个关键信号:延迟、流量、错误和饱和度。您可能会注意到,这些信号与 RED 方法捕获的指标有些相似,但增加了饱和度。
应用特定指标
USE 方法、RED 方法和四个黄金信号捕获了适用于大多数(如果不是所有)应用程序的通用指标。还有一类指标显示了特定于应用程序的信息。例如,将商品添加到购物车需要多长时间?或者客户与代理人连接需要多长时间?通常,这些指标与业务关键绩效指标(KPI)相关联。
无论您选择哪种方法,从应用程序导出指标对其成功至关重要。一旦您可以访问这些指标,您可以构建仪表板来可视化系统的行为,设置警报以在发生问题时通知负责团队,并执行趋势分析以推导能够推进组织的业务智能。
为分布式跟踪服务添加仪表
分布式跟踪使您能够分析由多个服务组成的应用程序。它们提供了在请求穿越组成应用程序的不同服务时执行流程的可见性。正如在第九章中讨论的那样,基于 Kubernetes 的平台可以使用诸如Jaeger或Zipkin等系统作为平台服务提供分布式跟踪。然而,与监控和指标类似,您必须为服务添加仪表以利用分布式跟踪。在本节中,我们将探讨如何使用 Jaeger 和OpenTracing来为服务添加仪表。首先,我们将讨论如何初始化跟踪器。然后,我们将深入探讨如何在服务中创建跨度。跨度是构成分布式跟踪的命名、计时操作的基本单元。最后,我们将探讨如何从一个服务传播跟踪上下文。我们将使用 Go 语言和 Go 库作为示例,但这些概念适用于其他编程语言。
初始化跟踪器
在能够在服务内创建跨度之前,您必须初始化跟踪器。初始化的一部分涉及根据应用程序运行的环境配置跟踪器。跟踪器需要知道服务名称、发送跟踪信息的 URL 等。对于这些设置,我们建议使用 Jaeger 客户端库的环境变量。例如,您可以使用JAEGER_SERVICE_NAME环境变量设置服务名称。
除了配置追踪器外,你还可以在初始化追踪器时将其与你的度量和日志库集成。追踪器使用度量库来发布关于追踪器活动的度量,例如跟踪和采样的跨度数量,成功报告的跨度数量等。另一方面,当遇到错误时,追踪器利用日志库来记录日志。你还可以配置追踪器来记录跨度,这在开发中非常有用。
要在 Go 服务中初始化 Jaeger 追踪器,你需要向应用程序添加类似以下的代码。在这种情况下,我们使用 Prometheus 作为度量库和 Go 的标准日志库:
package main
import (
"log"
jaeger "github.com/uber/jaeger-client-go"
"github.com/uber/jaeger-client-go/config"
"github.com/uber/jaeger-lib/metrics/prometheus"
)
func main() {
// app initialization code...
metricsFactory := prometheus.New() 
cfg := config.Configuration{} 
tracer, closer, err := cfg.NewTracer( 
config.Metrics(metricsFactory),
config.Logger(jaeger.StdLogger),
)
if err != nil {
log.Fatalf("error initializing tracer: %v", err)
}
defer closer.Close()
// continue main()... }
创建一个 Prometheus 的度量工厂,供 Jaeger 使用来发布度量。
创建一个默认的 Jaeger 配置,不要硬编码配置(使用环境变量替代)。
从配置创建一个新的追踪器,并提供度量工厂和 Go 标准库日志记录器。
有了初始化的追踪器,我们可以开始在我们的服务中创建跨度。
创建跨度
现在我们有了一个追踪器,我们可以开始在我们的服务中创建跨度。假设服务在请求处理流程的中间位置,服务需要从前一个服务的传入跨度信息中反序列化,并创建一个子跨度。我们的示例是一个 HTTP 服务,因此跨度上下文通过 HTTP 头部传播。以下代码从头部提取上下文信息并创建一个新的跨度。请注意,我们在前一节初始化的追踪器必须在作用域内:
package main
import (
"github.com/opentracing/opentracing-go"
"github.com/opentracing/opentracing-go/ext"
"net/http"
)
func (s server) handleListPayments(w http.ResponseWriter, req *http.Request) {
spanCtx, err := s.tracer.Extract( 
opentracing.HTTPHeaders,
opentracing.HTTPHeadersCarrier(req.Header),
)
if err != nil {
// handle the error }
span := opentracing.StartSpan( 
"listPayments",
ext.RPCServerOption(spanCtx),
)
defer span.Finish()
}
从 HTTP 头部提取上下文信息。
使用提取的跨度上下文创建一个新的跨度。
当服务处理请求时,它可以为我们刚刚创建的跨度添加子跨度。例如,假设服务调用一个执行 SQL 查询的函数。我们可以使用以下代码为该函数创建一个子跨度,并将操作名称设置为 listPayments:
func listPayments(ctx context.Context) ([]Payment, error) {
span, ctx := opentracing.StartSpanFromContext(ctx, "listPayments")
defer span.Finish()
// run sql query
}
传播上下文
到目前为止,我们在同一个服务或进程内创建了跨度。当其他服务参与请求处理时,我们需要通过网络传播跟踪上下文给另一端的服务。如前文所述,你可以使用 HTTP 头部来传播上下文。
OpenTracing 库提供了一些辅助函数,你可以用来将上下文注入到 HTTP 头部中。以下代码展示了一个使用 Go 标准库 HTTP 客户端来创建和发送请求的示例:
import (
"github.com/opentracing/opentracing-go"
"github.com/opentracing/opentracing-go/ext"
"net/http"
)
// create an HTTP request req, err := http.NewRequest("GET", serviceURL, nil)
if err != nil {
// handle error }
// inject context into the request's HTTP headers ext.SpanKindRPCClient.Set(span) 
ext.HTTPUrl.Set(span, url)
ext.HTTPMethod.Set(span, "GET")
span.Tracer().Inject( 
span.Context(),
opentracing.HTTPHeaders,
opentracing.HTTPHeadersCarrier(req.Header),
)
// send the request resp, err := http.DefaultClient.Do(req)
添加一个标签,将该跨度标记为服务调用的客户端端点。
将跨度上下文注入到请求的 HTTP 头中。
正如我们在这些章节中讨论的那样,为了对应用程序进行跟踪,需要初始化跟踪器,在服务内创建跨度,并将跨度上下文传播到其他服务。还有其他功能需要您探索,包括标记、日志记录和行李。如果平台团队提供跟踪作为平台服务,那么现在您已经了解如何利用它了。
概要
有多项措施可以使您的应用程序在 Kubernetes 中运行更加顺畅。尽管大多数需要投入时间和精力来实施,但我们发现它们对于实现应用程序的生产级成果至关重要。在将应用程序引入到您的平台时,请务必考虑本章提供的指导,包括在运行时注入配置和密钥、指定资源请求和限制、使用探针公开应用程序健康信息,以及为应用程序添加日志、度量和跟踪。
第十五章:软件供应链
实施 Kubernetes 平台绝不应该是你团队或公司的目标(假设你不是供应商或顾问!)。对于一个完全致力于 Kubernetes 的书籍来说,这可能听起来是一个奇怪的说法,但让我们稍作停顿。所有公司都是为了提供他们的核心竞争力而存在。这可能是电子商务平台、SaaS 监控系统,或者是一个保险网站。像 Kubernetes 这样的平台(以及几乎任何其他工具)存在的目的是促进核心业务价值的交付,这一点在设计和实施 IT 解决方案时经常被团队忽视。
怀着这样的情感,本章将专注于在 Kubernetes 上将代码从开发者手中推送到生产环境的实际过程。为了最好地覆盖我们认为相关的每个阶段,我们将遵循许多人熟悉的管道模型。
首先,我们将讨论构建容器镜像(我们部署的资产)时的一些考虑因素。如果您已经在使用 Kubernetes 或其他容器平台,您可能已经熟悉本节中的一些概念,但希望我们能涵盖一些您可能尚未考虑的问题。如果您对容器是新手,那么这将是一个从您当前构建软件的方式(WAR 文件、Go 二进制文件等)到考虑容器镜像及其构建和维护细微差别的范式转变。
一旦我们构建好我们的资产,我们就需要一个地方来存储它们。我们将讨论容器注册表(例如 DockerHub、Harbor、Quay)及其在选择时认为重要的功能。容器注册表的许多属性与安全性相关,我们将讨论像图像扫描、更新和签名等选项。
最后,我们将花一些时间来审查持续交付,并了解这些实践及其相关工具如何与 Kubernetes 交集。我们将探讨一些新兴的想法,如 GitOps(通过同步集群状态从 git 仓库进行部署),以及更传统的命令式流水线方法。
即使您尚未运行 Kubernetes,您可能已经考虑过并/或解决了所有上述高层次领域(构建、资产存储、部署)。每个人都有现有工具和方法的投资和专业知识,我们很少遇到组织希望从头开始全新构建其整个软件供应链的情况。本章我们将尝试强调的一点是,在管道中存在清晰的交接点,我们可以为每个阶段选择最有效的方法。就像本书涵盖的许多主题一样,完全有可能(也是建议)在保持专注于提供业务价值的同时,实施渐进的正向变革。
构建容器镜像
在容器之前,我们会将应用程序打包为二进制、压缩资产或原始源代码,以便部署到服务器上。这些应用程序可以独立运行或嵌入到应用服务器中。除了应用程序本身外,我们还需要确保环境包含了正确的依赖项和配置,以便在目标环境中成功运行。
在基于容器的环境中,容器镜像是可部署的资产。它不仅包含应用程序二进制本身,还包括执行环境和所有相关的依赖项。镜像本身是一组压缩的“文件系统层”,以及一些元数据,这些层共同遵循开放容器倡议(OCI)镜像规范。这是云原生社区内的一种约定标准,旨在确保可以以多种不同方式实现镜像构建(我们将在接下来的章节中看到一些方式),同时生成的构件可以被所有不同的容器运行时运行(更多关于此的信息可以在第三章找到)。
典型情况下,构建容器镜像涉及创建描述镜像的 Dockerfile,并使用 Docker 引擎执行 Dockerfile。话虽如此,在不同场景下,有一系列工具生态系统(每个都有其独特的方法),可以用来创建容器镜像。借用 BuildKit(Docker 构建的一个工具)的一个概念,我们可以从“前端”和“后端”角度来思考构建。其中,“前端”是定义应该用来构建镜像的高层过程的方法,例如 Dockerfile 或 Buildpack(本章后面会详细介绍)。而“后端”则是实际的构建引擎,它接受由“前端”生成的定义,并在文件系统上执行命令以构建镜像。
在许多情况下,“后端”是 Docker 守护程序,但这并非所有情况都适用。例如,如果我们想要在 Kubernetes 中运行构建,我们需要在容器内部运行 Docker 守护程序(Docker in Docker),或者将 Docker Unix 套接字从主机机器挂载到构建容器中。这两种方法都有缺点,尤其是后者可能会存在安全问题。为了应对这些问题,出现了其他构建后端,如 Kaniko。Kaniko 使用相同的“前端”(Dockerfile),但在内部使用不同的技术来创建镜像,使其成为在 Kubernetes Pod 中运行的可靠选择。在决定如何构建镜像时,您应该回答以下问题:
-
我们能否以 root 用户身份运行构建器?
-
我们是否可以接受挂载 Docker 套接字?
-
我们是否关心运行守护程序?
-
我们是否希望将构建容器化?
-
我们是否希望在 Kubernetes 的工作负载中运行它们?
-
我们打算如何充分利用层缓存?
-
我们的工具选择将如何影响分发构建?
-
我们想要使用什么前端或镜像定义机制?支持什么?
在本节中,我们首先会涵盖构建容器镜像(云原生构建包)时遇到的一些模式和反模式,希望这些内容能帮助您在构建更好的容器镜像的道路上更进一步。然后,我们将审查另一种构建容器镜像的替代方法,以及如何将所有这些技术集成到流水线中。
组织早期经常遇到的一个问题是,谁应该负责构建镜像?在 Docker 变得流行之初,它主要被作为一个面向开发者的工具。根据我们的经验,规模较小的组织仍然让开发人员负责编写 Dockerfile 并定义其应用镜像的构建过程。然而,随着组织试图大规模采用容器(和 Kubernetes),让个别开发者或开发团队各自创建自己的 Dockerfile 变得不可持续。首先,这给开发者带来额外的工作,使他们远离其核心责任;其次,导致生成的镜像差异巨大,几乎没有标准化。
因此,我们看到一种趋势是将构建过程从开发团队中抽象出来,转移责任给运维和平台团队来实现源到镜像的模式和工具化,这些工具接收代码仓库作为输入,并能够生成准备通过流水线的容器镜像。我们将在“云原生构建包”中更详细地讨论这种模式。与此同时,我们通常看到平台团队开展研讨会或协助开发团队使用 Dockerfile 和镜像创建。随着组织规模的扩展,这可能是一个有效的第一步,但通常不可持续,因为开发团队与平台人员的比例不匹配。
金基础镜像反模式
在现场工作中,我们遇到了几种反模式,这些反模式通常是团队没有调整思维方式,不能接纳在容器和云原生领域中出现的模式所导致的。其中可能最常见的是预定的金镜像的概念。场景是,在预容器环境中,特定的基础镜像(例如预配置的 CentOS 基础镜像)会被批准在组织内使用,并且所有进入生产的应用程序都必须基于该镜像。这种方法通常是出于安全原因,因为镜像中的工具和库已经经过了充分的审查。然而,当转向容器时,团队发现自己被限制在重新发明轮子中,从第三方和供应商那里拉取有用的上游镜像,并将其重新基于自己的应用程序和配置。
这引入了一些相关问题。首先,需要额外的工作将上游镜像转换为内部定制版本。其次,现在内部平台团队有责任存储和维护这些内部镜像。由于这种情况可能会扩展(考虑到在典型环境中使用的镜像数量),这种方法通常会导致更糟糕的安全姿态,因为更新频率较低(如果有的话),这是由于额外的工作所致。
我们在这一领域的建议通常是与安全团队合作,并确定金镜像服务的具体要求。通常会适用以下几点:
-
确保特定的代理程序/软件已安装
-
确保不存在可疑的库
-
确保用户帐户具有正确的权限
通过理解限制背后的原因,我们可以将这些要求编码为工具,这些工具将在流水线中运行,拒绝或警报非符合的镜像,并保持期望的安全姿态,同时广泛允许团队重复使用来自上游社区的镜像(及其背后的工作)。我们将更深入地研究一个示例工作流程,详见“镜像注册表”。
指定基础操作系统的一个更有说服力的原因之一是确保组织中存在操作知识以便在需要时进行故障排除。然而,深入挖掘后,这并不像看起来那么有用。很少需要exec到容器中以排查特定问题,即使需要,Linux-based 操作系统之间的差异对所需的支持类型来说也是相当微不足道的。此外,越来越多的应用程序被打包成超轻量级的 scratch 或 distroless 镜像,以减少容器内的开销。
应避免尝试将所有上游/供应商镜像重构为自己的基础镜像,原因见本节描述。但我们并不断言维护一组内部精选的基础镜像是一个坏主意。这些镜像可以作为构建自己应用的基础,并且我们将在下一节讨论构建这些内部基础时的一些考虑因素。
选择基础镜像
容器的基础镜像决定了应用容器镜像要构建在哪些底层。基础镜像至关重要,因为它通常包含操作系统的库和工具,这些将成为你的应用容器镜像的一部分。如果在选择基础镜像时不慎,它可能会包含不必要的库和工具,这不仅会使容器镜像变得臃肿,还可能成为安全漏洞的来源。
根据您的组织成熟度和安全态势,可能在选择基础镜像时无法自由选择。我们与许多有责任策划和维护一组批准的基础镜像的专门团队合作过。话虽如此,如果您有选择权或是审核基础镜像团队的一部分,考虑以下指导原则会很有帮助:
-
确保镜像由信誉良好的供应商发布。您不希望使用来自随机 DockerHub 用户的基础镜像。毕竟,这些镜像将是大多数甚至所有应用程序的基础。
-
了解更新周期,优先选择持续更新的镜像。如前所述,基础镜像通常包含需要在发现新漏洞时进行修补的库和工具。
-
优先选择具有开源构建过程或规范的镜像。通常这是一个 Dockerfile,您可以检查以了解镜像的构建方式。
-
避免使用带有不必要工具或库的镜像。优先选择提供小型基础的最小镜像,以便开发者在需要时进行构建。
大多数情况下,如果您正在构建自己的镜像,我们建议使用 scratch 或 distroless 作为一个坚实的选择,因为它们体现了前述原则。scratch 镜像绝对不包含任何内容,因此对于简单的静态二进制来说,scratch 可能是最精简的镜像。但是,如果您需要根 CA 证书或其他资产,则需要将其复制进去,这是需要考虑的问题。distroless 基础镜像在大多数情况下是我们推荐的,因为它们预先创建了一些合理的用户(如 nonroot、nobody 等)和一组基础库,具体取决于所选的基础镜像的类型。distroless 还有几种特定语言的基础变体供您选择。
在接下来的几节中,我们将继续讨论最佳实践模式,首先要重视为应用程序指定适当的用户身份。
运行时用户
由于容器隔离模型(主要是容器共享底层 Linux 内核),容器的运行时用户具有一些开发者未考虑到的重要影响。在大多数情况下,如果未指定容器的运行时用户,进程将以 root 用户身份运行。这是有问题的,因为它增加了容器的攻击面。例如,如果攻击者成功攻击应用程序并逃离容器,他们可能会在底层主机上获得 root 访问权限。
在构建容器映像时,考虑容器的运行时用户非常关键。应用程序是否需要以 root 用户身份运行?应用程序是否依赖于/etc/passwd文件的内容?您是否需要向容器映像添加非 root 用户?在回答这些问题时,请确保在容器映像的配置中指定运行时用户。如果您使用 Dockerfile 来构建映像,您可以使用USER指令来指定运行时用户,例如下面的示例,该示例使用nonroot用户和组 ID(默认作为 distroless 镜像集的一部分配置)来运行my-app二进制文件:
FROM gcr.io/distroless/base
USER nonroot:nonroot
COPY ./my-app /my-app
CMD ["./my-app", "serve"]
尽管您可以在 Kubernetes 部署清单中指定运行时用户,但将其定义为容器映像规范的一部分非常有价值,因为这会导致生成自我记录的容器映像。这还确保开发人员在其本地或开发环境中与容器一起工作时使用相同的用户和组 ID。
固定包版本
如果您的应用程序利用外部包,您很可能会使用apt、yum或apk等包管理器安装它们。在构建容器映像时,重要的是要固定或指定这些包的版本。例如,以下示例显示了一个依赖于 imagemagick 的应用程序。Dockerfile 中的apk指令将 imagemagick 固定为与应用程序兼容的版本:
FROM alpine:3.12
<...snip...>
RUN ["apk", "add", "imagemagick=7.0.10.25-r0"]
<...snip...>
如果您未指定包版本,可能会获取不同的包,这可能会破坏您的应用程序。因此,在容器映像中安装包时,请始终指定包的版本。这样做可以确保您的容器映像生成是可重复的,并且生成具有兼容包版本的容器映像。
构建映像与运行时映像
除了打包应用程序以供部署外,开发团队还可以利用容器来构建其应用程序。容器可以提供一个明确定义的构建环境,可以编码到 Dockerfile 中,例如。这对开发人员很有用,因为他们不需要在其系统中安装构建工具。更重要的是,容器可以在整个开发团队及其持续集成(CI)系统中提供标准化的构建环境。
使用容器构建应用程序虽然很有用,但重要的是要区分构建容器映像和运行时映像。构建映像包含编译应用程序所需的所有工具和库,而运行时映像包含要部署的应用程序。例如,在 Java 应用程序中,我们可能有一个构建映像,其中包含 JDK、Gradle/Maven 以及所有的编译和测试工具。然后我们的运行时映像可以仅包含 Java 运行时和我们的应用程序。
鉴于应用程序通常在运行时不需要构建工具,运行时镜像不应包含这些工具。这将导致更轻量的容器镜像,分发更快,并具有更紧凑的攻击面。如果您使用 docker 构建镜像,可以利用其多阶段构建功能将构建与运行时镜像分开。以下代码段显示了一个用于 Go 应用程序的 Dockerfile。构建阶段使用golang镜像,其中包含 Go 工具链,而运行时阶段使用 scratch 基础镜像,并且只包含应用程序二进制文件:
# Build stage
FROM golang:1.12.7 as build 
WORKDIR /my-app
COPY go.mod . 
RUN go mod download
COPY main.go .
ENV CGO_ENABLED=0
RUN go build -o my-app
# Deploy stage
FROM gcr.io/distroless/base 
USER nonroot:nonroot 
COPY --from=build --chown=nonroot:nonroot /my-app/my-app /my-app 
CMD ["/my-app"]
主要的golang镜像包含所有的 Go 构建工具,在运行时不需要。
我们首先复制go.mod文件并下载,以便在代码更改但依赖项不变时可以缓存此步骤。
我们可以使用distroless作为运行时镜像,以便利用最小化的基础镜像,但不包含不必要的额外依赖。
如果可能,我们希望以非 root 用户身份运行我们的应用程序。
只有编译后的文件(my-app)从构建阶段复制到部署阶段。
注
容器运行单个进程,通常没有监控程序或初始化系统。因此,您需要确保信号被正确处理,并且孤立的进程被正确重新父化和收回。有几个最小化的初始化脚本可以满足这些要求,并作为应用程序实例的引导程序。
云原生构建包
另一种构建容器镜像的方法涉及分析应用程序源代码的工具,并自动生成容器镜像。与特定于应用程序的构建工具类似,这种方法极大简化了开发者的体验,因为开发者不必创建和维护 Dockerfile。云原生构建包是这种方法的一个实现,其高级流程如图 15-1 所示。

图 15-1. 构建包流程。
Cloud Native Buildpacks(CNB)是 Buildpacks 的一种面向容器的实现,这是 Heroku 和 Cloud Foundry 多年来用来为这些平台打包应用程序的技术。在 CNB 的情况下,它将应用程序打包成 OCI 容器镜像,准备在 Kubernetes 上运行。为了构建镜像,CNB 分析应用程序源代码并相应地执行 buildpacks。例如,如果你的源代码中存在 Go 文件,则会执行 Go buildpack。类似地,如果 CNB 发现了 pom.xml 文件,则会执行 Maven(Java)buildpack。所有这些都是在幕后进行的,开发人员可以使用名为 pack 的 CLI 工具启动这个过程。这种方法的优点在于 buildpacks 的范围严格限定,这样可以构建遵循最佳实践的高质量镜像。
除了改善开发人员的体验和降低平台采用门槛外,平台团队还可以利用自定义 buildpacks 来强制执行政策、确保合规性,并标准化在其平台上运行的容器镜像。
总的来说,提供一个从源代码构建容器镜像的解决方案可能是一个值得尝试的努力。此外,我们发现,这种解决方案的价值随着组织规模的增长而增加。归根结底,开发团队希望专注于在应用程序中构建价值,而不是如何将其容器化。
镜像注册表
如果您已经在使用容器,那么您可能有一个偏好的注册表。这是利用 Docker 和 Kubernetes 的核心要求之一,因为我们需要一个地方来存储在一台机器上构建的镜像,并希望在许多其他机器上运行(无论是独立还是在集群中)。与镜像类似,OCI 也定义了注册表操作的标准规范(以确保互操作性),并且有许多专有和开源的解决方案可用,其中大多数共享一组共同的核心功能。大多数镜像注册表由三个主要组件组成:服务器(用于用户界面和 API 逻辑)、blob 存储(用于镜像本身)和数据库(用于用户和镜像元数据)。通常情况下,存储后端是可配置的,这可能会影响您设计注册表架构的方式。我们稍后会详细讨论这一点。
在本节中,我们将看看注册表提供的一些最重要的特性以及将它们集成到您的流水线中的一些模式。我们不会深入研究任何特定的注册表实现,因为功能通常是相似的;然而,根据您现有的设置或要求,可能有些场景您可能希望朝某个方向倾斜。
如果您已经利用像 Artifactory 或 Nexus 这样的构件存储库,您可能希望利用它们的镜像托管能力来方便管理。同样,如果您的环境大量依赖云服务,使用像 AWS Elastic Container Registry(ECR)、Google Container Registry(GCR)或 Azure Container Registry(ACR)这样的云提供商注册表可能会带来成本效益。
在选择注册表时另一个关键因素是您的环境和集群的拓扑结构、架构和故障域。您可以选择在每个故障域放置注册表以确保高可用性。在这样做时,您需要决定是否希望使用集中的 blob 存储,还是希望在每个区域设置 blob 存储并在注册表之间设置镜像复制。镜像复制是大多数注册表的功能之一,允许您将镜像推送到一组注册表中的一个并自动将该镜像推送到该组中的其他注册表。即使您选择的注册表不直接支持此功能,使用流水线工具(例如 Jenkins)和在每次镜像推送时触发的 Webhook,设置基本的复制也是相当简单的。
在选择单个注册表与多个注册表时,也受到您需要支持多少吞吐量的影响。在每次代码提交时触发代码和镜像构建的数千名开发人员组织中,同时进行的操作(拉取和推送)数量可能相当可观。因此,重要的是要理解,尽管镜像注册表在管道中只起到有限作用,但它不仅在生产部署中,而且在开发活动中都是关键路径的一部分。它是必须像其他关键组件一样进行监控和维护,以实现高水平的服务可用性。
许多注册表的构建旨在轻松在集群或容器化环境中运行。这种方法(我们将在“持续交付”中再次讨论)具有许多优点。主要优势在于,我们能够利用 Kubernetes 内的所有基元和约定来保持服务运行、可发现和易配置。这里的明显缺点是,现在我们依赖集群内部的一个服务来提供镜像以启动该集群内的新服务。更常见的是看到注册表在共享服务集群上运行,并具有故障转移系统以备份集群,以确保始终有一个注册表实例能够提供服务请求。
我们通常也会看到在 Kubernetes 之外运行的注册表,作为一个更独立的引导组件,所有集群都需要它。这通常是组织已经在使用现有的 Artifactory 或另一个注册表实例,并重新用于镜像托管的情况。在这里,使用云注册表也是一种常见模式,尽管您还需要注意它们的可用性保证(因为同样的拓扑问题也适用)和可能的额外延迟。
在接下来的小节中,我们将讨论选择和使用注册表时的一些最常见关注点。这些关注点都与安全有关,因为保护我们的软件供应链围绕着我们部署的构件(镜像)展开。首先,我们将讨论漏洞扫描以及如何确保我们的镜像不包含已知的安全缺陷。然后,我们将描述一种常用的隔离流程,可以有效地将外部/供应商镜像引入我们的环境。最后,我们将讨论镜像的信任和签名。这是许多组织感兴趣的一个领域,但上游的工具和方法仍在成熟中。
漏洞扫描
扫描已知漏洞是大多数镜像注册表的一个关键能力。通常,扫描本身以及常见漏洞和曝光(CVE)数据库被委托给第三方组件。Clair 是一个流行的开源选择,在许多情况下,它是可插拔的,如果您有特定的需求的话。
每个组织对于在考虑 CVE 评分时什么构成可接受风险都有自己的要求。注册表通常会公开控制功能,允许您禁用拉取包含超过定义分数阈值的 CVE 的镜像。此外,将 CVE 添加到允许列表的能力对于绕过在您的环境中标记但不相关的问题,或者对于被视为可接受风险和/或没有发布和可用修复的 CVE 非常有用。
初始拉取时的静态扫描可以作为起点,但是如果在已经使用的环境中随着时间的推移发现了漏洞,会发生什么?可以定期安排扫描以检测这些变化,但接下来我们需要制定更新和替换镜像的计划。自动进行修复(修补)并推送更新镜像可能是诱人的选择,也有解决方案始终尝试保持镜像更新。然而,这可能会带来问题,因为镜像更新可能会引入不兼容的更改,甚至破坏正在运行的应用程序。这些自动化镜像更新系统可能也会超出您指定的部署变更流程,并且在环境中可能难以审计。即使是阻塞镜像拉取(前面描述过的)也可能会引起问题。如果核心应用程序的镜像发现了新的 CVE 并且突然禁止拉取,这可能会导致应用程序的可用性问题,特别是如果这些工作负载被调度到新节点并且镜像无法拉取。正如我们在本书中多次讨论过的那样,在实施每种解决方案时(在本例中是安全性与可用性之间),理解遇到的权衡是至关重要的,并做出经过深思熟虑和充分记录的决策。
比起简短描述的自动修复模型,更常见的模型是对镜像漏洞扫描进行警报和/或监控,并将其提升给运维和安全团队。根据您选择的注册表提供的功能,警报实施方式可能会有所不同。某些注册表可以配置在扫描完成时触发 Webhook 调用,载荷包括受影响镜像和发现的 CVE 的详细信息。其他可能会公开一组可抓取的指标,其中包含镜像和 CVE 的详细信息,可以使用标准工具进行警报(详见第九章有关指标和警报工具的更多详细信息)。虽然这种方法需要更多的手动干预,但它允许您在环境中查看镜像的安全状态,并在何时以及如何进行修补方面具有更多控制权。
一旦我们获得了镜像的 CVE 信息,就可以根据漏洞的影响程度决定是否以及何时修补镜像。如果需要修补并更新镜像,我们可以通过常规部署流水线触发更新、测试和部署。这确保了我们具有完全的透明性和审计能力,并且所有这些变更都通过我们的常规流程进行。稍后在本章中我们将详细讨论 CI/CD 和部署模型。
虽然本小节介绍的静态镜像漏洞扫描是组织软件供应链中常见的一部分,但它只是容器安全策略中应该是深层防御战略的一层。镜像可能在部署后下载恶意内容,或者容器化应用程序可能在运行时被篡改/劫持。因此,实施某种形式的运行时扫描至关重要。在更简单的形式中,这可以采用对运行容器进行周期性文件系统扫描的形式,以确保不会在部署后引入易受攻击的二进制文件和/或库。然而,为了更强大的保护,有必要限制容器能够执行的操作和行为。这消除了 CVE 被发现和修补时可能发生的游戏,而是集中于容器化应用程序应具备的能力。运行时扫描是一个更大的主题,我们在这里没有足够的空间来全面覆盖,但您应该查看像Falco和Aqua Security 套件这样的工具。
隔离工作流程
正如提到的,大多数注册表提供了扫描已知漏洞并限制镜像拉取的机制。但是,在使用镜像之前可能还有其他要求必须满足。我们也遇到过开发人员无法直接从公共互联网拉取镜像,必须使用内部注册表的情况。这两种情况都可以通过使用具有隔离工作流管道的多注册表设置来解决,接下来我们会描述这一流程。
首先,我们可以为开发人员提供一个自助门户来请求镜像。像 ServiceNow 或 Jenkins 任务在这里都很合适,我们已经见过很多次了。聊天机器人也可以为开发人员提供更无缝的集成,并且越来越受欢迎。一旦请求镜像,它将自动拉取到一个隔离注册表,可以在镜像上运行检查,并且管道可以启动环境来拉取和验证镜像是否符合特定标准。
一旦检查通过,镜像可以被签名(这是可选的,请参阅“镜像签名”获取更多信息),并推送到已批准的注册表。开发人员也可以被通知(通过聊天机器人、或者更新的工单/任务等)镜像已被批准(或拒绝,并说明理由)。整个流程可以在图 15-2 中查看。

图 15-2. 隔离流程。
可以将此流程与准入控制器结合使用,以确保只允许签名的镜像或来自特定注册表的镜像在集群上运行。
镜像签名
随着应用程序越来越依赖于越来越多的外部依赖项(无论是代码库还是容器镜像),供应链安全问题变得更加普遍。
当讨论图像时经常提到的安全功能之一是签名的概念。简单来说,签名的概念是,图像发布者可以在将其推送到注册表之前,通过生成图像的哈希并将其与其身份关联起来进行加密签名。然后用户可以通过验证签名的哈希与发布者的公钥进行验证来验证图像的真实性。
这个工作流程很吸引人,因为它意味着我们可以在软件供应链的开始创建一个图像,并在每个管道阶段之后进行签名。也许我们可以在测试完成后签名,然后在通过发布管理团队批准部署后再次签名。然后在部署时,我们可以根据是否由我们指定的各方签名来控制将图像部署到生产环境中。我们不仅确保它已通过这些批准,而且确保它与现在推广到生产环境的相同图像。这个高级流程如图 Figure 15-3 所示。

图 15-3. 签名流程。
该领域的主要项目是 Notary,最初由 Docker 开发,建立在安全分发软件更新的框架 The Update Framework(TUF)之上。
尽管有其好处,由于几个原因,我们在现场并未看到图像签名的广泛采用。首先,Notary 包括服务器和多个数据库等多个组件。这些都是需要安装、配置和维护的额外组件。不仅如此,由于签名和验证图像通常是软件部署中的关键路径,因此 Notary 系统必须配置为高可用性和韧性。
其次,Notary 要求每个图像都使用全局唯一名称(GUN)进行标识,该名称包含注册表 URL 作为名称的一部分。如果您有多个注册表(例如缓存、边缘位置),这会使签名变得更加棘手,因为签名与注册表绑定,不能移动/复制。
最后,Notary 和 TUF 要求在签名过程中使用不同的密钥对。每个密钥具有不同的安全要求,并且在安全漏洞发生时更换密钥对可能具有挑战性。虽然它提供了学术上设计良好的解决方案,但当前的 Notary/TUF 实现对许多刚刚开始适应某些基础技术的组织来说是门槛太高了。因此,许多组织并未准备好为了签名工作流提供的额外安全性益处而交换更多的便利和知识。
在撰写本文时,正在进行开发和发布第二版 Notary 的工作。这个更新版本应该通过解决刚讨论的许多问题来改善用户体验,例如通过将 OCI 映像与签名捆绑在一起,从而减少密钥管理的复杂性和消除签名不可转移的约束。
已经有几个现有项目实施了准入 Webhook,将检查映像以确保它们在允许在 Kubernetes 集群中运行之前已经签名。一旦问题得到解决,我们预计签名将成为软件供应链中更经常实施的属性,而这些签名准入 Webhook 也将进一步成熟。
持续交付
在前面的章节中,我们详细讨论了将源代码转换为容器映像的过程。我们还研究了映像存储的位置以及在选择和部署映像注册表时需要做出的架构和过程决策。在最后这一节中,我们将转向检查将这些早期步骤与实际将映像部署到可能的多个 Kubernetes 集群(测试、预发布、生产)联系起来的整个流水线的审查。
在查看那些已经熟悉的命令驱动的管道之前,我们将覆盖如何将构建过程集成到自动化管道中。最后,我们将研究 GitOps 领域出现的一些原则和工具,这是一种相对较新的部署方法,利用版本控制存储库作为应部署到我们环境的资产的真实来源。
值得注意的是,持续交付是一个非常广阔的领域,也是许多书籍的唯一主题。在本节中,我们假设读者已经了解 CD 原则的一些知识,并且我们将专注于如何在 Kubernetes 和相关工具中实现这些原则。
将构建集成到管道中
对于本地开发和测试阶段,开发人员可以在本地使用 Docker 构建映像。然而,超出这些早期阶段之外,组织将希望在由提交代码到中央版本控制存储库触发的自动化管道的一部分中执行构建。我们将在本章后面讨论更多关于实际映像部署的高级模式,但在本节中,我们希望纯粹关注如何使用云原生流水线自动化工具在集群中运行构建阶段。
我们通常希望通过代码提交触发新的镜像构建。一些管道工具会间歇性地轮询一组配置的存储库,并在检测到更改时触发任务运行。在其他情况下,可能通过从版本控制系统中的 webhook 触发一个进程来启动。我们将使用 Tekton 的几个示例来说明本节中的一些概念,Tekton 是一个流行的开源管道工具,专为在 Kubernetes 上运行而设计。Tekton(以及许多其他本地于 Kubernetes 的工具)利用 CRD 来描述管道中的组件。在以下代码中,我们可以看到(经过大量编辑的)Task CRD 实例,可以在多个管道中重复使用。Tekton 维护一个常见操作的目录(例如在以下代码片段中显示的克隆 git 存储库),可以在您自己的管道中使用:
apiVersion: tekton.dev/v1beta1
kind: Task
metadata:
name: git-clone
spec:
workspaces:
- name: output
description: "The git repo will be cloned onto the \
volume backing this workspace"
params:
- name: url
description: git url to clone
type: string
- name: revision
description: git revision to checkout (branch, tag, sha, ref…)
type: string
default: master
<...snip...>
results:
- name: commit
description: The precise commit SHA that was fetched by this Task
steps:
- name: clone
image: "gcr.io/tekton-releases/github.com/tektoncd/\
pipeline/cmd/git-init:v0.12.1"
script: |
CHECKOUT_DIR="$(workspaces.output.path)/$(params.subdirectory)"
<...snip...>
/ko-app/git-init \
-url "$(params.url)" \
-revision "$(params.revision)" \
-refspec "$(params.refspec)" \
-path "$CHECKOUT_DIR" \
-sslVerify="$(params.sslVerify)" \
-submodules="$(params.submodules)" \
-depth "$(params.depth)"
cd "$CHECKOUT_DIR"
RESULT_SHA="$(git rev-parse HEAD | tr -d '\n')"
EXIT_CODE="$?"
if [ "$EXIT_CODE" != 0 ]
then
exit $EXIT_CODE
fi
# Make sure we don't add a trailing newline to the result!
echo -n "$RESULT_SHA" > $(results.commit.path)
正如在前面的部分中提到的,有许多不同的构建 OCI 镜像的方法。其中一些需要 Dockerfile,而另一些则不需要。您可能还需要执行构建的其他操作。几乎所有管道工具都公开了阶段、步骤或任务的概念,允许用户配置可以链接在一起的离散功能块。以下代码片段显示了一个使用 Cloud Native Buildpacks 构建镜像的示例Task定义:
apiVersion: tekton.dev/v1beta1
kind: Task
metadata:
name: buildpacks-phases
labels:
app.kubernetes.io/version: "0.1"
annotations:
tekton.dev/pipelines.minVersion: "0.12.1"
tekton.dev/tags: image-build
tekton.dev/displayName: "buildpacks-phases"
spec:
params:
- name: BUILDER_IMAGE
description: "The image on which builds will run \
(must include lifecycle and compatible buildpacks)."
- name: PLATFORM_DIR
description: The name of the platform directory.
default: empty-dir
- name: SOURCE_SUBPATH
description: "A subpath within the `source` input \
where the source to build is located."
default: ""
resources:
outputs:
- name: image
type: image
workspaces:
- name: source
steps:
<...snip...>
- name: build
image: $(params.BUILDER_IMAGE)
imagePullPolicy: Always
command: ["/cnb/lifecycle/builder"]
args:
- "-app=$(workspaces.source.path)/$(params.SOURCE_SUBPATH)"
- "-layers=/layers"
- "-group=/layers/group.toml"
- "-plan=/layers/plan.toml"
volumeMounts:
- name: layers-dir
mountPath: /layers
- name: $(params.PLATFORM_DIR)
mountPath: /platform
- name: empty-dir
mountPath: /tekton/home
<...snip...>
然后,我们可以将这个任务(及其他任务)与我们的输入存储库一起作为Pipeline的一部分进行绑定(此处未显示)。这涉及将我们之前克隆的 git 存储库的工作空间与我们的构建包构建器将用作源的工作空间进行映射。我们还可以指定在过程结束时将图像推送到注册表。
这种方法的灵活性(可配置的任务块)意味着管道成为在 Kubernetes 上定义构建流程的强大工具。我们可以向构建添加测试和/或检查阶段,或者某种静态代码分析。如果需要的话,我们还可以轻松地向我们的镜像添加签名步骤(如"镜像签名"中所述)。我们还可以定义自己的任务来运行其他构建工具,例如 Kaniko 或 BuildKit(如果不像本例中那样使用构建包)。
基于推送的部署
在前一节中,我们看到了如何在管道中自动化构建。在本节中,我们将看到如何将其扩展到实际执行部署到集群以及您希望实现的这些类型的自动交付管道中的一些模式。
由于我们之前看到的任务/步骤驱动方法的灵活性(几乎每个工具都有),在流水线末端创建一个步骤来读取新创建(并推送)图像的标签并更新部署变得微不足道。可以通过直接在集群中使用kubectl set image命令更新部署来实现这一点,并且一些文章/教程仍然展示了这种方法。一个更好的选择是让我们的流水线将图像标签的变化写回描述部署的 YAML 文件中,然后将此更改提交到版本控制中。然后我们可以触发kubectl apply命令来执行这一更改。后一种方法更可取,因为在这种情况下我们可以将我们的 YAML 文件视为集群的近似真实源(我们将在“GitOps”中进一步讨论这一点),但前者在迁移到这种基于 Kubernetes 的自动化流水线类型时是一个可以接受的迭代步骤。
在将应用程序部署到 Kubernetes 时,我们有两种不同类型的工件需要考虑:用于应用程序及其构建和部署的代码和配置,以及用于构建它的配置和部署它的配置。我们经常被要求权衡如何最好地组织这些工件,有些人喜欢将与应用程序相关的所有东西放在单个树中,而另一些人则喜欢将它们分开。
我们的建议通常是选择后者的路径,原因如下:
-
每个问题通常是组织中不同领域或团队的责任。开发人员应该了解其应用程序将如何部署,并对过程有所贡献,但围绕大小、环境、机密注入等的配置主要是平台或运维团队的责任。
-
代码存储库与包含部署流水线工件、机密和环境配置的存储库在安全权限和审计要求上可能有所不同。
一旦我们将部署配置放在一个单独的存储库中,就很容易理解部署流水线可能首先检出此存储库,然后运行图像标签的更新(使用sed或类似的工具),最后将更改提交回 git,以确保这是我们的真实源。然后我们可以在更改的清单上运行kubectl apply -f命令。这种命令式(或推送驱动)模型提供了很好的可审计性,因为我们可以利用版本控制系统提供的内置报告和日志记录功能,轻松查看更改如何通过我们的流水线流动,如图 15-4 所示。

图 15-4. 推送驱动的部署。
根据组织内部的自动化程度,您可能希望通过流水线处理环境之间的推广,甚至在不同的 Kubernetes 集群中执行部署。大多数工具都有实现这一目标的方法,有些工具对此的本地支持可能更好。然而,这是一个领域,其中命令式流水线模型在实施上可能更具挑战性,因为我们必须为每个希望用作目标的集群保持库存(和凭据)。
这种命令式方法的另一个挑战是,如果由于某种原因中断了流水线,我们需要确保重新启动或调解回健康状态。我们还需要在部署流水线上保持监控和警报(无论它们是如何实现的),以确保我们在出现部署问题时能够意识到。
部署模式
我们在上一节的结尾简要提到了监控流水线以确保其成功完成的需要。然而,在部署新版本的应用程序时,我们还需要一种方法来监控它们的健康状态,并决定是否需要解决问题或回滚到之前的工作状态。
组织可能希望实施的几种模式。有一些书籍专门讨论这些模式,但我们将在这里简要介绍它们,以展示如何在 Kubernetes 中实现它们:
金丝雀部署
金丝雀发布是指将应用程序的新版本部署到集群,并将小部分流量(基于元数据、用户或其他属性)指向新版本。可以密切监控此过程,以确保新版本(金丝雀)行为与先前版本相同,或至少不会导致错误场景。随着信心的增加,可以逐步增加流量的百分比。
蓝绿部署
这种方法类似于金丝雀部署,但涉及更大规模的流量切换。可以通过多个集群来实现(一个是旧版本,蓝色,另一个是新版本,绿色),也可以在同一个集群中实现。这里的想法是我们可以测试服务部署是否按预期工作,并在切换流量到新版本之前在非用户面向的环境中进行一些测试。如果我们看到错误率升高,我们可以减少流量。当然,这种方法还有一些微妙之处,因为你的应用程序可能需要优雅地处理状态、会话和其他问题。
A/B 测试
类似于金丝雀部署,我们可以推出一个应用程序的版本,该版本可能包含一些针对一部分消费者的不同行为。我们可以收集新版本的使用模式的指标和分析,以决定是回滚还是继续前进,或扩展实验。
这些模式使我们朝着能够将应用程序的部署与向消费者的发布解耦的期望状态前进,通过控制何时启用功能和/或新版本来减少在我们的环境中部署更改的风险。这些实践在减少部署变更风险方面非常有效。
大多数这些模式通过某种网络流量转移实现。在 Kubernetes 中,我们拥有一些非常丰富的网络原语和功能,使得这些模式的实现成为可能。一个开源工具,它在各种服务网格解决方案之上启用这些模式的是 Flagger。Flagger 作为 Kubernetes 集群中的一个控制器运行,并监视 Deployment 资源的 image 字段的更改。它通过以编程方式配置底层服务网格来适当地转移流量,公开了许多可调整的选项来启用前述模式。它还增加了监视新版本发布健康状况的能力,并在需要时继续或停止并回滚部署过程。
我们确实看到了深入研究 Flagger 和该领域其他解决方案的价值。然而,考虑到它们依赖的额外复杂性(大多数模式需要服务网格)和引入的复杂性,我们更常见地将这些方法作为组织 Kubernetes 旅程的第二或第三阶段来考虑。
GitOps
到目前为止,我们已经看过如何将一个基于推送的部署阶段加入到您的 Kubernetes 交付流水线中。在部署领域出现的一个新兴替代模型是 GitOps。与向集群推出变更的命令式模型不同,GitOps 模型包括一个控制器,不断地将 git 仓库的内容与运行在集群中的资源进行协调,如图 15-5 所示。这个模型使其与 Kubernetes 本身提供的控制循环协调体验密切相关。GitOps 空间的两个主要工具是 ArgoCD 和 Flux,两个团队正在共同开发一个共同的引擎来支持他们各自的工具。

图 15-5. GitOps 流程。
这种模型有几个主要优点:
-
它是声明性的,因此部署本身的任何问题(例如工具崩溃等)或者随意删除部署将导致(尝试)协调到一个良好状态。
-
Git 成为我们的唯一真理源,我们可以利用现有的工具专业知识和熟悉度,除了默认获取更改的强大审计日志。我们可以使用拉取请求工作流作为我们集群更改的门控,并通过大多数版本控制系统提供的扩展点(Webhooks、工作流、Actions 等)根据需要集成外部工具。
然而,这种模型并非没有缺点。对于那些真正希望将 git 作为他们唯一的真相来源的组织来说,这意味着将秘密数据保存在版本控制中。在过去几年中,出现了几个项目来解决这个问题,其中最著名的是 Bitnami 的 Sealed Secrets。该项目允许将加密版本的 Secrets 提交到仓库,然后在应用到集群时解密(以便应用程序可以访问)。我们在第五章中详细讨论了这种方法。
我们还需要确保监控当前状态同步的健康情况。如果流水线是基于推送的并且失败了,我们将在流水线中看到一个失败。然而,由于 GitOps 方法是声明性的,我们需要确保如果观察到的状态(在集群中)和声明的状态(在 git 中)长时间保持分歧,我们会收到警报。
GitOps 的广泛采纳是我们在这个领域看到的一个趋势,尽管它显然是从传统的推送模型转变而来的范式转变。并非所有的应用程序都能直接以平面应用 YAML 资源的方式部署,可能需要在组织转向这种方法时最初构建一些顺序和一些脚本。
还要注意可能会创建、修改或删除资源的工具,在它们的生命周期中,有时需要进行一些调整以符合 GitOps 模型。一个例子是在集群中运行并监视特定 CRD,然后通过 Kubernetes API 直接创建多个其他资源的控制器。如果以严格模式运行,GitOps 工具可能会删除这些动态创建的资源,因为它们不在单一真实来源(git 仓库)中。当然,在大多数情况下,删除未知资源可能是可取的,并且这是 GitOps 的积极特性之一。然而,您绝对要注意可能会故意从 git 仓库之外引起变化的情况,这可能会破坏模型并需要解决。
总结
在本章中,我们讨论了将源代码放入容器并部署到 Kubernetes 集群的过程。您可能已经熟悉的许多阶段和原则(构建/测试、CI、CD 等)在容器/Kubernetes 环境中仍然适用,但使用的工具不同。同时,一些概念(如 GitOps)可能是新的,这些概念建立在 Kubernetes 自身的概念基础之上,以增强现有部署模式中的可靠性和安全性。
在这个领域有许多工具可以实现多种不同的流程和模式。然而,本章的关键要点之一应该是决定在组织中不同群体之间暴露这个流水线的各个部分的程度的重要性。也许开发团队已经参与了 Kubernetes,并且足够自信地编写构建和部署工件(或者至少有重要的输入)。或者可能希望将所有底层细节抽象化,以减轻开发团队的扩展和标准化问题,但这可能会增加平台团队在建立相关基础和自动化方面的负担。
第十六章:平台抽象化
许多时候,我们看到组织采用“建好了,他们就会来”的方法来设计和构建 Kubernetes 平台。然而,这种哲学通常充满风险,因为它经常未能满足与平台互动的许多团队(如开发、信息安全、网络等)的关键需求,导致重新工作和额外的努力。重要的是要让其他团队跟上步伐,并确保所构建的平台符合预期。
在本章中,我们将探讨在设计其他团队(特别是开发人员)的 Kubernetes 平台的入门和使用体验时应考虑的一些角度。首先,我们将从一些更为哲学的角度来讨论问题,即开发人员应该了解多少关于 Kubernetes?然后,我们将讨论如何为开发人员构建一个平稳的入门通道,让他们开始部署到 Kubernetes 并自行部署集群。最后,我们将重新审视我们在第一章中提到的复杂度谱系,并看看我们可以采取的一些抽象层次。我们的目标是在向开发团队提供 Kubernetes 平台时,在复杂性和灵活性之间取得良好的平衡,这些团队对底层实现有不同程度的知识和期望参与。
本章的许多内容在本书的其他地方已经涵盖,我们会在适当的时候进行引用。在这里,我们的目标是从增加团队协作和构建一个能够满足组织中所有人需求的平台的具体立场来覆盖各个方面。尽管表面上这可能看起来是一个轻松的主题,但所讨论的问题往往是许多公司最难克服的障碍之一,可以决定 Kubernetes 应用平台的成功采用。
平台暴露
在本书中,我们多次谈到在设计和实施 Kubernetes 平台时评估个人需求的重要性,并在不同领域提出问题。一个主要问题将决定许多选择,即决定您希望开发团队对底层 Kubernetes 系统和资源有多少了解。有几个因素将影响这个决定。
Kubernetes 是一项相对较新的技术。在某些情况下,采用 Kubernetes 的推动力来自基础架构方面,旨在简化基础设施使用和提高效率,或者标准化工作负载。在其他情况下,推动力可能来自渴望实施能够容纳并加速他们开发和部署云原生应用的新技术的开发团队。无论推动力来自何方,都会影响到其他团队,不论是适应新范式、学习新工具,还是在与新平台互动时用户体验的改变。
在某些组织中,有一个强烈的要求,即开发团队不应接触底层平台。这一驱动因素是,开发人员应专注于提供业务价值,而不被正在开发的平台的实现细节所分心。这种方法确实有其价值,但根据我们的经验,我们并不总是完全同意。例如,开发人员至少需要了解一些基本平台的知识,才能有效地开发针对其的应用程序。这并不意味着增加应用程序与平台的耦合度,而是纯粹理解如何最大化平台的能力。第十四章 更详细地讨论了这种应用程序与平台关系。
要使“开发人员无需接触”的方法成功,平台团队必须具备足够的能力。首先,因为他们将完全负责维护和支持环境;其次,该团队还负责构建必要的抽象层,以便开发人员能够与平台无缝互动。这一点非常重要,即使开发人员没有直接接触 Kubernetes,他们仍然需要方法来分析应用程序的性能、调试问题和故障排除。如果给开发人员在集群中使用 kubectl 命令暴露了太多底层细节,那么就需要一个中间层,使开发人员能够在将应用程序推向生产环境的同时不被实现细节所淹没。在第九章 中,我们详细介绍了许多有效暴露调试工具给开发团队的主要方法。
在一些组织中,仅简化开发人员的故障排除体验可能是不够的。将应用程序部署到 Kubernetes 可能也很复杂,可能需要许多组件。也许一个应用程序需要一个 StatefulSet、PersistentVolumeClaim、Service 和 ConfigMap 才能成功部署。当不希望向开发人员暴露这些概念时,平台团队可能会更进一步,在这个领域创建抽象化。这可以通过自助服务管道或构建自定义资源来实现(这在第十一章中有详细介绍)。我们将在本章后面讨论这两种方法。
决定暴露平台多少的一个限制因素是创建抽象化的团队的技能和经验。例如,如果平台团队想要采用自定义资源路线,他们将需要一些开发技能和对 Kubernetes API 最佳实践的了解。如果他们没有这些,你可能会受限于可以构建的抽象化内容,并因此不得不向开发团队暴露更多平台内部的细节。
在接下来的部分中,我们将看到一些团队如何向开发人员(以及其他最终用户)提供自助服务模型,以便简化和标准化集群和应用程序的部署。
自助服务入职
在一个组织的 Kubernetes 旅程早期阶段,平台团队可能会负责为所有需要的团队提供集群的供应和配置。他们可能还至少负责帮助将应用程序部署到这些集群中。根据采用的租户模型(关于工作负载租户模型的更多信息,请参阅第十二章),在这个设置过程中会有不同的要求。在单租户模型中,集群的供应和配置可能会更加简单,具有一组通用权限、核心服务(日志记录、监控、入口等)和访问(例如单点登录)设置。然而,在多租户集群中,我们可能需要为每个团队和应用程序创建多个附加组件(例如命名空间、网络策略、配额等)来进行接入。
然而,随着组织的扩展,手动进行供应和配置已经不再可持续。这代表了平台团队的重复劳动,并阻碍了等待手动任务完成的开发团队。一旦达到一定的成熟水平,通常会看到团队开始向其内部用户提供某种形式的自助服务。通过现有的 CI/CD 工具或流程如 Jenkins 或 GitLab 提供这种服务是一种有效的方式。这两种工具都允许在执行时轻松创建管道,并提供额外定制输入的能力。
工具如kubeadm和 Cluster API 的成熟使得集群创建的自动化相对简单且一致。团队可以公开可调参数,如集群名称和大小,例如,流水线可以调用这些工具以使用合理的默认值为请求团队提供集群的凭证或访问权限。像许多其他事物一样,这种自动化可以根据您的选择变得非常复杂。我们已经看到的流水线可以根据请求用户的 LDAP 信息自动创建负载均衡器和 DNS,甚至可以使用相关成本中心自动标记底层基础设施。大小可以向用户开放,但根据团队、环境或项目的情况受到一定范围的限制。我们甚至可以根据应用程序的分类或安全配置选择在私有云还是公有云中进行提供。对于平台团队来说,有各种可能性来创建一个灵活而强大的自动化供给过程,以供开发团队使用。
对于多租户场景,我们不会创建集群,而是创建命名空间及其所有关联对象,以便为新应用程序提供软隔离环境。同样,我们可以使用类似的流水线方法,但这次允许开发团队选择他们的应用程序将部署到的集群(或集群)。在基本水平上,我们希望生成以下内容:
命名空间
应用程序所居住的地方,并为我们的其他组件提供逻辑隔离。
RBAC
确保只有适当授权的群组可以访问其应用程序命名空间中的资源。
网络策略
确保应用程序仅允许与自身或其他共享集群服务通信,但不与同一集群上的其他应用程序通信(如果需要)。
配额
限制一个命名空间或应用程序在集群中消耗的资源量,降低可能出现“吵闹邻居”情况的潜力。
限制范围
为命名空间中创建的特定对象设置合理的默认值。
Pod 安全策略
确保工作负载符合合理的默认安全设置,比如不以 root 用户身份运行。
虽然这些不是每种场景中都必需或必要的,但结合起来,它们使集群管理员和平台团队能够为新的开发团队创建无缝的入职体验和部署环境,而无需手动干预。
随着组织在使用和了解 Kubernetes 方面的成熟度,这些流水线可以以 Kubernetes 原生方式使用运算符实现。例如,我们可以定义一个Team资源,其结构如下:
apiVersion: examples.namespace-operator.io/v1
kind: Team
metadata:
name: team-a
spec:
owner: Alice
resourceQuotas:
pods: "50"
storage: "300Gi"
在这个例子中,我们可能定义一个特定的Team,我们希望为其配置一个所有者(用户)和一些资源配额。我们在集群中的控制器将负责读取此对象,并创建相关的命名空间、RBAC 资源和配额,并将它们绑定在一起。这种方法非常强大,因为它允许我们与 Kubernetes API 紧密集成,并公开一种管理和协调资源的原生方式。例如,如果意外删除了一个角色或者修改了一个配额,控制器将能够自动修复情况。这些更高级别的资源类型(如Team或Application)也非常适合启动集群,只需添加几个团队对象和我们的控制器,就能自动化所有相关配置,准备好供使用。
我们肯定可以更深入地探索这个自动化设置。例如,让我们考虑一些可能需要为新应用程序配置的可观察性工具。也许我们的团队控制器可以为新团队或应用程序生成并提交定制的仪表板,并让 Grafana 自动重新加载它们。我们可能会为新团队或命名空间动态添加新的警报目标到 Alertmanager 中。我们可以在这些更简单、更用户友好的入职抽象背后创建非常强大的功能。
抽象谱系
在第一章中,我们介绍了抽象谱系的概念。在图 16-1 中,我们扩展了原始概念,并增加了一些具体的抽象层级。
在前面的章节中,我们讨论了一些可能影响您选择谱系位置的哲学决策和组织约束。在本节中,我们将详细介绍从左侧(无抽象)到右侧(完全抽象平台)的谱系,并在此过程中讨论一些选项和权衡。

图 16-1. 抽象谱系。
命令行工具
通过本地命令行工具暴露 Kubernetes API,我们处于谱系的最左端,没有任何抽象层级。在一些组织中,kubectl将是开发人员与 Kubernetes 交互的主要入口点。这可能是由于约束(平台团队的支持不足)或选择(熟悉并希望直接在 Kubernetes 上工作)。集群上可能仍然有一些自动化或防护措施,但开发人员将使用本地工具与其进行交互。
尽管您的开发团队可能对 Kubernetes 有一些了解,但这种方法也存在一些缺点:
-
需要设置和配置多个集群的认证方法可能会增加手动工作量。这包括在多个集群之间切换上下文,并确保用户始终定位到预期的集群。
-
kubectl命令的输出格式可能难以查看和处理。默认情况下,我们获得表格输出,但可以将其整理成不同的格式,并通过外部工具如jq进行管道传输,以更简洁地显示信息。但这需要开发人员了解kubectl的选项以及如何使用它们(还要了解外部工具)。 -
原始的
kubectl向用户开放了 Kubernetes 的所有调整和控制选项,没有进行抽象化或中介。因此,我们不仅需要确保有适当的 RBAC 规则来限制未授权访问,还需要一个审核控制层来审查所有进入 API 服务器的请求。
各种工具可以增强这种体验。有许多kubectl插件可以在本地 shell 中提供更好的用户体验,比如kubens和kubectx,分别提供更好的命名空间和上下文可视性。还有一些插件可以聚合多个 Pod 的日志,或为应用程序健康提供终端用户界面。虽然这些工具不是高级工具,但它们可以消除常见问题点,避免开发人员必须了解底层实现细节的繁琐。
还有一些插件将与外部认证系统集成,简化认证流程,将 kubeconfig、证书、令牌等复杂性从用户身上抽象出来。这是一个我们经常看到的领域,原始工具得到了一些增强,因为使开发人员能够安全访问多个集群(特别是那些动态出现和消失的集群)是具有挑战性的。在非关键环境中,访问可能基于密钥对(必须生成、管理和分发),而在更稳定的环境中,访问可能与单点登录系统相关联。我们已经为几个客户开发了命令行实用程序,根据本地用户的登录凭据从中央集群注册表中提取凭据。
此外,你可以选择 Airbnb 的路线。在最近的 QCon 演讲,Melanie Cebula 分享了 Airbnb 构建更高级工具集的方法,包括独立的二进制文件和kubectl插件,用于与其集群交互,连接到镜像构建、部署等方面。
开发人员的另一类工具是提供集群图形界面交互的额外类别。最近流行的选择包括Octant和Lens。与 Kubernetes 仪表板在集群中运行不同,这些工具在本地工作站上运行,并利用kubeconfig访问集群。对于那些希望看到集群及其应用程序的视觉表示的平台新手开发人员来说,这些工具可以是一个很好的入门工具。改善客户端体验是组织简化开发人员与 Kubernetes 交互的第一步。
通过模板化来抽象
将单个应用程序部署到 Kubernetes 可能需要创建多个 Kubernetes 对象。例如,一个简单的 Wordpress 应用可能需要以下内容:
Deployment
用于描述 Wordpress 实例的镜像、命令和属性。
StatefulSet
用于将 MySQL 部署为 Wordpress 的数据存储。
服务
用于为 Wordpress 和 MySQL 提供发现和负载平衡。
PVC
用于动态创建 Wordpress 数据的卷。
ConfigMaps
用于同时管理 Wordpress 和 MySQL 的配置。
Secrets
用于同时保存 Wordpress 和 MySQL 的管理凭据。
在这个列表中,我们有近 10 种不同的对象,都是为了支持一个非常小的应用程序。不仅如此,还需要配置它们的细微差别和专业知识。例如,使用 StatefulSet 时,我们需要创建一个特殊的无头服务来作为其前端。我们希望我们的开发人员能够将他们的应用程序部署到集群中,而无需知道如何自己创建和配置所有这些不同的 Kubernetes 对象类型。
在部署这些应用程序时,我们可以通过仅暴露一小部分输入并在后台生成其余的样板来简化用户体验。这种方法不需要开发人员了解所有对象中的所有字段,但仍然暴露了一些底层对象,并使用了比纯 kubectl 更高级别的工具。在这个领域具有一定成熟度的工具是像 Helm 和 Kustomize 这样的模板化工具。
Helm
在过去几年中,Helm 已经成为 Kubernetes 生态系统中一个流行的工具。我们意识到它不仅仅是模板化,而且在我们的经验中,模板化用例(接着是编辑和应用清单)比其生命周期管理功能更为引人注目。
以下是 Wordpress Helm 图表的一个片段(描述应用程序的包),描述了一个服务:
ports:
- name: http
port: {{ .Values.service.port }}
targetPort: http
这个模板并不直接暴露给开发人员,而是使用从其他地方注入或定义的值。在 Helm 的情况下,这些值可以通过命令行传递,或者更常见地通过一个 values 文件:
## Kubernetes configuration
## For minikube, set this to NodePort, elsewhere use LoadBalancer or ClusterIP
##
service:
type: LoadBalancer
## HTTP Port
##
port: 80
图表包含一个默认的Values.yaml文件,其中包含合理的设置,但开发人员可以提供一个覆盖文件,在其中仅修改他们需要的设置。这允许通过模板进行强大的定制,而无需深入了解。Helm 不仅仅是模板化值的功能,还包含基本逻辑操作的功能,允许通过值文件中的单个调整生成或修改底层模板中的大段内容。
例如,在刚才显示的示例值文件中,有一个声明type: Loadbalancer。这直接注入到模板的几个地方,但也负责通过条件语句和内置函数触发更复杂的模板化,如下面的代码片段所示:
spec:
type: {{ .Values.service.type }}
{{- if (or (eq .Values.service.type "LoadBalancer")
(eq .Values.service.type "NodePort")) }}
externalTrafficPolicy: {{ .Values.service.externalTrafficPolicy | quote }}
{{- end }}
{{- if (and (eq .Values.service.type "LoadBalancer")
.Values.service.loadBalancerSourceRanges) }}
loadBalancerSourceRanges:
{{- with .Values.service.loadBalancerSourceRanges }}
{{ toYaml . | indent 4 }}
{{- end }}
{{- end }}
这种内联逻辑看起来可能很复杂,当然也有它的批评者。然而,复杂性是由图表的创建者拥有的,而不是最终用户开发团队。模板中复杂 YAML 结构的构建是从值文件中的单个type键开始的,这是开发人员修改配置的接口。可以在运行时指定值文件,因此可以为不同的集群、团队或环境使用不同的文件(带有不同的配置)。
使用 Helm 来配置和部署第三方和内部应用程序可以是抽象化开发人员一些底层平台的有效第一步,使他们能够更专注于他们真正需要的选项。然而,仍然存在一些缺点。接口(Values.yaml)仍然是 YAML,如果开发人员需要探索模板以理解更改的影响,可能会给用户带来不友好的体验(尽管良好的文档可以缓解这一点)。
对于那些希望更进一步的人来说,我们看到了开发的工具,可以将这些可调整项目抽象到用户界面中。这使得底层实现更加原生,但用户体验可以根据受众的需求进行定制。例如,可以将工作流集成到现有的部署工具(如 Jenkins)或者一个类似于票据服务的工具中,但底层的输出仍然可以是 Kubernetes 清单,然后应用于集群。虽然功能强大,但这些模型的维护可能变得复杂,并且抽象化最终可能会泄露到用户那里。
最近出现的对这种模型的有趣看法是由 Ambassador Labs 的 K8s Initializer 实现的。使用基于浏览器的 UI 工作流程,用户被问及关于他们想要部署的服务类型和目标平台的多个问题。然后,该站点生成一个可下载的包,用户可以将其应用到集群并应用所有的定制。
所有的模板方法都有相同的优缺点。我们仍然处理应用于集群的 Kubernetes 原生对象。例如,当输出我们的 Helm 文件时,使用完成的值仍然暴露于服务、StatefulSets 等。这不是对平台的完全抽象,因此开发人员仍需要具有一定水平的底层知识。然而,另一方面,这也是这种方法的优势(无论是使用 Helm 还是来自 K8s Initializer 的更抽象方法)。如果上游的 Helm 图表或 Initializer 没有输出我们需要的内容,我们仍然可以在应用到集群之前完全灵活地修改结果。
Kustomize
Kustomize 是一个灵活的工具,可以作为独立工具使用,也可以作为 kubectl 的一部分,用于对任何 Kubernetes YAML 对象中的字段进行任意的添加、删除和修改。它不是一个模板工具,但在由 Helm 模板化的一组清单上使用时非常有用,用于修改 Helm 本身没有暴露的字段。
基于前述原因,我们认为 Helm 作为一个模板工具,通过类似 Kustomize 的附加自定义进一步定制,是一个非常强大的抽象。这种方法位于抽象范围的中间某处,通常是组织的最佳选择。在接下来的部分中,我们将进一步向抽象范围的右侧移动,看看如何开始通过定制资源封装特定于每个组织/用例的底层对象。
抽象 Kubernetes 原语
正如本书中多次提到的那样,Kubernetes 提供了一组原始对象和 API 模式。结合使用这些,我们可以构建更高级的抽象和自定义资源,以捕捉那些没有内置的类型和想法。2019 年末,社交媒体公司 Pinterest 发布了一篇有趣的博文,描述了它如何创建 CRD(及其相关的控制器),将其内部工作负载建模为一种方式,将 Kubernetes 原生构建模块抽象出来,远离开发团队。Pinterest 总结了其采用这种方法的理由如下:
另一方面,Kubernetes 原生的工作负载模型,如部署、作业和守护进程集,不足以对我们自己的工作负载进行建模。可用性问题是采用 Kubernetes 的巨大障碍。例如,我们听到服务开发人员抱怨缺少或配置错误的 Ingress 会损坏其端点。我们还看到批处理作业用户使用模板工具生成数百个相同作业规范的副本,最终导致调试噩梦。
Lida Li、June Liu、Rodrigo Menezes、Suli Xu、Harry Zhang 和 Roberto Rodriguez Alcala;“在 Pinterest 构建 Kubernetes 平台”
在以下代码片段中,PinterestService 是 Pinterest 的自定义内部资源的一个示例。这个包含 25 行代码的对象会创建多个 Kubernetes 原生对象,如果直接创建的话,会超过 350 行代码:
apiVersion: pinterest.com/v1
kind: PinterestService
metadata:
name: exampleservice
project: exampleproject
namespace: default
spec:
iamrole: role1
loadbalancer:
port: 8080
replicas: 3
sidecarconfig:
sidecar1:
deps:
- example.dep
sidecar2:
log_level: info
template:
spec:
initcontainers:
- name: init
image: gcr.io/kuar-demo/kuard-amd64:1
containers:
- name: init
image: gcr.io/kuar-demo/kuard-amd64:1
这是我们在前一节看到的模板化模型的延伸,其中只暴露给最终用户某些输入。然而,在这种情况下,我们可以构建一个在应用程序上下文中有意义的输入对象(而不是相对非结构化的 Values.yaml 文件),并且更容易被开发人员理解。虽然使用这种方法仍然可能发生泄漏的抽象,但由于平台团队(创建 CRDs/运算符)完全控制如何创建和修改基础资源,这种情况发生的可能性较小,而不必像 Helm 方法那样在现有对象的约束内工作。他们还可以通过通用编程语言(与 Helm 的内置函数不同)使用控制器来构建更复杂的逻辑。然而,正如我们之前讨论的那样,这需要平台团队现在具备编程专业知识。要深入了解创建平台服务和运算符,请参阅第十一章。
通过使用运算符,我们还可以调用外部 API,将更丰富的功能集成到我们的抽象对象类型中。例如,一个客户端有一个内部 DNS 系统,所有应用程序都需要在此系统中注册,以便正确工作并对外部客户端可见。以前的流程是开发人员访问 Web 门户,并手动添加他们服务的位置以及需要从分配的 DNS 名称转发的端口。我们有几个选项来增强开发人员的体验。
如果我们正在使用原生的 Kubernetes 对象(例如本例中的 Ingress),我们可以创建一个运算符,该运算符将读取应用的 Ingress 上的特殊注解,并自动将应用程序注册到 DNS 服务中。可能看起来像这样:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: my-app
annotations:
company.ingress.required: true
spec:
rules:
- host: "my-app"
http:
paths:
- path: /
backend:
service:
name: my-app
port:
number: 8000
我们的控制器将读取 company.ingress.required: true 注解,并根据应用程序的名称、Namespace 或其他一些元数据来注册相应的 DNS 记录,还可能根据某些规则修改主机字段。虽然这减少了开发人员所需的大量手动工作(创建记录),但仍需要一些了解/创建 Kubernetes 对象(在这种情况下是 Ingress)。从这个角度来看,它更符合前一节描述的抽象级别。
另一个选项是使用像PinterestService这样的自定义资源。我们已经封装了所有我们需要的信息,并且可以通过我们的操作员创建 Ingress,以及配置像 DNS 系统这样的外部服务。我们没有泄露任何底层抽象到开发人员,并且在实现中具有完全的灵活性。
即使我们在本节讨论的自定义资源和操作员方法中,仍然向开发团队公开了平台的一些核心机制。我们需要指定有效的 Kubernetes 元数据、API 版本和资源类型。我们还暴露了 YAML(除非我们还提供管道、包装器或 UI 来构建它)及其相关的怪癖。在下一节(也是最后一节)中,我们将完全向右在我们的抽象光谱上移动,并讨论一些选项,允许开发人员直接从他们的应用程序代码到平台,甚至不需要了解 Kubernetes。
使 Kubernetes 不可见
在前面的部分中,我们已经在抽象的光谱上从左到右移动,从最不抽象的(原始kubectl访问)到最终完全抽象的平台。在本节中,我们将讨论一些情况和工具,在这些情况下开发人员甚至不知道他们在使用 Kubernetes,他们与平台的唯一接口(或多或少)是提交/推送代码,这使他们能够保持相对狭窄(和深入)的关注,而不暴露于平台细微差别。
类似 Heroku 这样的 SaaS 提供商和 Cloud Foundry 这样的工具在 10 多年前就推广了以开发人员为中心的推送体验。其概念是,一旦配置好工具(),将提供平台即服务(PaaS,现在是一个模糊的术语),其中包含所有必要的互补组件,以便应用程序能够正常运行(可观察性堆栈、某种形式的路由/流量管理、软件目录等),并且允许开发人员简单地将代码推送到源代码库。平台内的专门组件将设置适当的资源限制,根据需要提供环境以运行代码,并将标准 PaaS 组件组合在一起,以实现简化的最终用户体验。
您可能会认为这里与 Kubernetes 存在一些交叉点,它还为我们提供了一些原语来启用一些类似的功能。当最初构建 PaaS 平台时,Docker 和 Kubernetes 还不存在,更基本的容器化工作负载的普及非常有限。因此,这些工具是从头开始为基于虚拟机的环境而构建的。我们现在越来越多地看到这些工具(以及其他新工具)被移植或重写为 Kubernetes,正是因为我们早些时候确定的原因。Kubernetes 提供非常强大的机械基础、API 约定和原始原语,以构建这些基于它的更高级平台。
Kubernetes 经常被批评的一个观点是,它为环境引入了相当数量的额外复杂性,这对运维和开发团队来说都是一个挑战(除了还必须处理容器的范式转变)。然而,这种观点忽略了 Kubernetes 的一个主要目标,即作为一个构建平台的平台(正如联合创始人 Joe Beda 多次表达的)。复杂性总是会存在,但通过其架构决策和基本原理,Kubernetes 允许我们将复杂性抽象化给平台开发者、供应商和开源社区,让他们在 Kubernetes 之上构建无缝的开发和部署体验。
我们已经提到了 Cloud Foundry,这可能是最受欢迎和成功的开源 PaaS(现已移植到 Kubernetes),还有其他相当成熟的选择,如 Google App Engine(以及一些其他无服务器技术)和 RedHat OpenShift 的部分内容。除了这些,随着这个领域的成熟,我们看到了更多的平台出现。其中一个受欢迎的平台是Backstage,最初由 Spotify 创建。现在是一个 CNCF 沙盒项目,它是一个构建门户的平台,为开发人员提供定制的抽象,用于部署和管理应用程序。就在我们撰写本章的同时,HashiCorp(开发者多个云原生开源工具,如 Vault 和 Consul)刚刚宣布了 Project Waypoint,这是一个新工具,用于将最终用户与底层部署平台分离,并为开发团队提供高级抽象。在他们的宣布博客文章中,他们写道:
我们之所以创建 Waypoint,是因为开发人员只想部署。
Mitchell Hashimoto,“宣布 HashiCorp Waypoint”
Waypoint 旨在封装软件开发的构建、部署和发布阶段。使用 Waypoint,开发人员仍然需要创建(或得到帮助创建)描述其过程的配置文件,类似于 Dockerfile,但以最简单的方式描述完整的阶段,仅征求必要的输入。这种配置文件的示例如下:
project = "example-nodejs"
app "example-nodejs" {
labels = {
"service" = "example-nodejs",
"env" = "dev"
}
build {
use "pack" {}
registry {
use "docker" {
image = "example-nodejs"
tag = "1"
local = true
}
}
}
deploy {
use "kubernetes" {
probe_path = "/"
}
}
release {
use "kubernetes" {
}
}
}
注意,Waypoint 的方法仍然是将一些复杂性推给开发人员(编写此文件);但是,他们已经将大量决策抽象化了。抽象化平台并不总是意味着所有复杂性从过程中消失,或者没有人需要学习新东西。相反,在这种情况下,我们可以在抽象的正确层次引入一个新的简化接口,达到速度和灵活性的最佳平衡点。在 Waypoint 的情况下,甚至可以在部署和发布阶段切换底层平台以使用像 Hashicorp 自己的 Nomad 或其他编排引擎。所有底层的细节和逻辑都被平台抽象化了。随着 Kubernetes 和其他平台的演变和变得更加稳定和无趣(有人会争论我们已经接近那里),真正的创新将继续在开发更高级平台的过程中,以更好地支持开发团队快速交付商业价值。
摘要
在本章中,我们讨论了平台团队可以向其用户(通常是开发团队)提供的不同抽象层,并介绍了用于实施这些层的常见工具和模式。也许比任何其他领域都多,这里是组织文化、历史、工具、技能集等都将影响您所选择的任何决策和权衡的地方,我们几乎与每一个客户在解决本章描述的问题时选择了略有不同的方式。
强调虽然我们经常宣扬开发团队不必过多关注底层部署平台的价值,但这并不意味着这总是正确的选择,或者开发人员不应该了解其应用程序运行的位置和方式。了解这些信息对于能够利用平台的特定功能或者调试软件问题至关重要。正如常言道,保持良好的平衡通常是最成功的前进方式。


浙公网安备 33010602011771号