Kubernetes-编程指南-全-

Kubernetes 编程指南(全)

原文:zh.annas-archive.org/md5/d5c502c7bedcf678429ed98a4448b171

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

欢迎来到编程 Kubernetes,感谢选择花费一些时间与我们在一起。在我们深入讨论之前,让我们迅速澄清一些行政和组织上的事务。我们还将分享撰写本书的动机。

适合阅读本书的对象

你可能是一名正在转向云原生的开发者,或者是希望从 Kubernetes 中获得最大利益的 AppOps 或命名空间管理员。原始设置已经不能满足你的需求,你可能已经了解了扩展点。很好,你来对地方了。

我们为什么写这本书

我们两个从 2015 年初开始贡献于、撰写关于、教授和使用 Kubernetes。我们开发了一些用于 Kubernetes 的工具和应用,并多次举办了关于在 Kubernetes 上进行开发的研讨会。有一天我们说,“为什么不写一本书呢?”这样更多的人可以异步地按照自己的节奏学习如何编程 Kubernetes。于是我们在这里。希望你阅读本书能和我们写作时一样有趣。

生态系统

从更大的视角来看,Kubernetes 生态系统仍处于早期阶段。虽然 Kubernetes 在 2018 年初已经确立自己作为管理容器(及其生命周期)的行业标准,但仍然需要关于如何编写本地应用的良好实践。基本的构建块,如client-go,自定义资源和云原生编程语言,已经就绪。然而,大部分知识还是部落性质的,分散在人们的头脑中,并且散布在成千上万个 Slack 频道和 StackOverflow 答案中。

注意事项

在撰写本文时,Kubernetes 1.15 是最新的稳定版本。编译的示例应该能在较旧的版本(至少 1.12)上运行,但我们基于更新版本的库来编写代码,对应的是 1.14 版本。一些更高级的 CRD 功能需要在 1.13 或 1.14 版本的集群上运行,在第九章中甚至需要 1.15 版本。如果你无法访问到足够新的集群,强烈建议在本地工作站上使用Minikubekind

您需要了解的技术

这本中级书籍需要读者对几个开发和系统管理概念有最基本的了解。在深入阅读之前,你可能需要复习一下以下内容:

包管理

本书中的工具通常有多个依赖项,您需要通过安装一些软件包来满足这些依赖项。因此,您需要了解您机器上的软件包管理系统。它可能是 Ubuntu/Debian 系统上的apt,CentOS/RHEL 系统上的yum,或者 macOS 上的portbrew。无论哪种情况,请确保您知道如何安装、升级和删除软件包。

Git

Git 已经确立了作为分布式版本控制标准的地位。如果你已经熟悉 CVS 和 SVN 但尚未使用 Git,那么你应该尝试一下。使用 Git 进行版本控制,作者 Jon Loeliger 和 Matthew McCullough(O’Reilly)是一个很好的开始。结合 Git,GitHub 网站是一个开始使用托管库的绝佳资源。要了解 GitHub,请查看它们的培训课程以及相关的交互式教程

Go

Kubernetes 是用Go 语言编写的。在过去几年中,Go 已经成为许多初创公司和许多系统相关的开源项目的首选新编程语言。本书不是关于教你 Go 语言,而是展示如何使用 Go 编程 Kubernetes。你可以通过各种资源学习 Go 语言,从Go 官网的在线文档到博客文章、演讲和大量的书籍。

本书使用的约定

本书中使用的以下印刷体约定:

斜体

表示新术语、URL、电子邮件地址、文件名和文件扩展名。

等宽字体

用于程序清单,以及段落内引用程序元素,如变量或函数名,数据库,数据类型,环境变量,语句和关键字。同时也用于命令和命令行输出。

等宽粗体

显示用户应直接输入的命令或其他文本。

等宽斜体

显示应该用用户提供的值或由上下文确定的值替换的文本。

提示

这个元素表示提示或建议。

注意

这个元素表示一般性的说明。

警告

这个元素表示警告或注意。

使用代码示例

这本书的目的是帮助你完成工作。你可以在 GitHub 组织这本书的页面找到整本书中使用的代码样例。

通常情况下,如果本书提供了示例代码,你可以在你的程序和文档中使用它们。除非你要复制代码的大部分内容,否则无需联系我们请求许可。例如,编写一个使用本书中几段代码的程序不需要许可。售卖或分发包含 O'Reilly 书籍示例的 CD-ROM 需要许可。引用本书并引用示例代码来回答问题不需要许可。将本书的大量示例代码整合到产品文档中需要许可。

我们感激但不要求署名。署名通常包括标题,作者,出版商和 ISBN。例如:“使用 Kubernetes 进行编程,作者 Michael Hausenblas 和 Stefan Schimanski(O’Reilly)。版权所有 2019 Michael Hausenblas 和 Stefan Schimanski。”

如果您认为您对代码示例的使用超出了公平使用或上述授权,请随时通过permissions@oreilly.com联系我们。

本书中使用的 Kubernetes 清单、代码示例和其他脚本可以通过GitHub获取。您可以克隆这些仓库,转到相关章节和示例,直接使用这些代码。

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/pr-kubernetes

电子邮件bookquestions@oreilly.com以评论或提问关于本书的技术问题。

欲了解更多关于我们的图书、课程、会议和新闻的信息,请访问我们的网站:http://www.oreilly.com

在 Facebook 上找到我们:http://facebook.com/oreilly

在 Twitter 上关注我们:http://twitter.com/oreillymedia

在 YouTube 上观看我们:http://www.youtube.com/oreillymedia

致谢

衷心感谢 Kubernetes 社区开发了如此出色的软件,并且是一群了不起的人们——开放、友善,总是乐于助人。此外,我们非常感谢我们的技术审阅者:Ahmed Belgana、Michael Gasch、Dimitris Gkanatsios、Mingding Han、Jess Males、Max Neunhöffer、Ewout Prangsma 和 Adrien Trouillaud。你们提供了非常宝贵和可操作的反馈,使这本书更易读和有用。感谢你们的时间和努力!

Michael 衷心感谢他出色且支持他的家人:我聪明而有趣的妻子 Anneliese;我们的孩子 Saphira、Ranya 和 Iannis;以及我们几乎仍然是小狗的 Snoopy。

Stefan 想要感谢他的妻子,Clelia,每当他再次“在写书”的时候,她总是非常支持和鼓励他。没有她,这本书就不会存在。如果你在书中发现错别字,很有可能它们是由两只猫,Nino 和 Kira,自豪地贡献的。

最后但同样重要的是,两位作者感谢 O'Reilly 团队,特别是 Virginia Wilson,她在书写过程中引导我们,确保我们按时交付,并且达到了预期的质量要求。

第一章:引言

对于不同的人,编程 Kubernetes 可能意味着不同的事情。在本章中,我们首先会确定本书的范围和重点。此外,我们将分享对我们操作环境的一组假设,以及您最好带来的东西,以便从本书中获益最大化。我们将定义编程 Kubernetes 的确切含义,Kubernetes 本地应用程序是什么,以及通过具体示例来了解它们的特征。我们将讨论控制器和操作员的基础知识,以及事件驱动的 Kubernetes 控制平面的工作原理。准备好了吗?让我们开始吧。

编程 Kubernetes 意味着什么?

我们假设您可以访问运行中的 Kubernetes 集群,如 Amazon EKS、Microsoft AKS、Google GKE 或 OpenShift 的某个提供方。

小贴士

您将会在本地开发相当一段时间,即您开发的 Kubernetes 集群是本地的,而不是在云端或数据中心。在本地开发时,您有多种选择。根据您的操作系统和其他偏好,您可以选择以下一种(或多种)解决方案来本地运行 Kubernetes:kindk3dDocker Desktop.^(1)

我们还假设您是一位 Go 程序员,即您具有使用 Go 编程语言的经验或至少基本了解。如果您不符合以上任何假设,现在是一个很好的时机来进行培训:对于 Go 语言,我们推荐 The Go Programming Language 由 Alan A. A. Donovan 和 Brian W. Kernighan 撰写(Addison-Wesley),以及 Concurrency in Go 由 Katherine Cox-Buday 撰写(O’Reilly)。对于 Kubernetes,可以查阅以下一本或多本书籍:

注意

为什么我们专注于使用 Go 编程 Kubernetes?好吧,这里可能有一个类比会有帮助:Unix 是用 C 编程语言编写的,如果您想为 Unix 编写应用程序或工具,您将默认使用 C。此外,即使您想使用其他语言来扩展和定制 Unix,您至少也需要能够阅读 C 语言。

现在,Kubernetes 和许多相关的云原生技术,从容器运行时到监控如 Prometheus,都是用 Go 编写的。我们相信大多数原生应用将会基于 Go,因此在本书中我们重点关注它。如果你更喜欢其他语言,请关注 kubernetes-client GitHub 组织。将来可能会包含你喜欢的编程语言的客户端。

在本书的上下文中,“编程 Kubernetes”指的是以下内容:你将要开发一个直接与 API 服务器交互的 Kubernetes 原生应用,查询资源的状态和/或更新它们的状态。我们不是指运行诸如 WordPress 或 Rocket Chat 或你最喜欢的企业 CRM 系统等现成的应用,通常称为 商业可用现成的 (COTS) 应用。此外,在第七章中,我们并不真正关注操作问题,而主要关注开发和测试阶段。总之,本书关注的是开发真正的云原生应用。图 1-1 可能会帮助你更好地理解。

在 Kubernetes 上运行的不同类型的应用

图 1-1. 在 Kubernetes 上运行的不同类型的应用

正如你所见,这里有不同的风格可供选择:

  1. 将类似 Rocket Chat 的 COTS 应用运行在 Kubernetes 上。这种应用本身并不知道它运行在 Kubernetes 上,通常也不需要知道。Kubernetes 控制应用的生命周期 — 找到运行节点、拉取镜像、启动容器、执行健康检查、挂载卷等等 — 就是这样。

  2. 拿一个定制的应用,比如你从头开始编写的东西,无论是否考虑将 Kubernetes 作为运行时环境,并在 Kubernetes 上运行它。与 COTS 的情况相同的操作方式适用。

  3. 本书关注的案例是一个云原生或 Kubernetes 原生应用,它完全意识到自己正在 Kubernetes 上运行,并在一定程度上利用 Kubernetes 的 API 和资源。

你开发针对 Kubernetes API 的付出将会得到回报:一方面,你获得了可移植性,因为你的应用现在可以在任何环境中运行(从本地部署到任何公共云提供商),另一方面,你受益于 Kubernetes 提供的干净、声明式的机制。

现在我们转向一个具体的例子。

一个激励性的例子

要展示一个 Kubernetes 原生应用的强大,假设你想实现 at — 也就是在给定时间安排命令的执行

我们称这个为 cnat 或云原生 at,它的工作原理如下。假设你想在 2019 年 7 月 3 日凌晨 2 点执行命令 echo "Kubernetes native rocks!"。你可以用 cnat 来做到:

$ cat cnat-rocks-example.yaml
apiVersion: cnat.programming-kubernetes.info/v1alpha1
kind: At
metadata:
  name: cnrex
spec:
  schedule: "2019-07-03T02:00:00Z"
  containers:
  - name: shell
    image: centos:7
    command:
    - "bin/bash"
    - "-c"
    - echo "Kubernetes native rocks!"

$ kubectl apply -f cnat-rocks-example.yaml
cnat.programming-kubernetes.info/cnrex created

在幕后,涉及以下组件:

  • 一个名为cnat.programming-kubernetes.info/cnrex的自定义资源,代表调度。

  • 一个控制器,在正确的时间执行计划的命令。

此外,kubectl 的 CLI UX 插件将非常有用,允许通过命令如kubectl at "02:00 Jul 3" echo "Kubernetes native rocks!"进行简单处理。本书不会涉及此内容,但您可以参考《Kubernetes 文档》的说明

在整本书中,我们将使用此示例来讨论 Kubernetes 的各个方面,其内部工作原理以及如何进行扩展。

对于第八章和第九章的更高级示例,我们将在集群中模拟一个比萨餐厅,其中包含比萨和配料对象。详细信息请参见“示例:比萨餐厅”。

扩展模式

Kubernetes 是一个功能强大且本质上可扩展的系统。通常,有多种方式可以自定义和/或扩展 Kubernetes:使用配置文件和标志控制平面组件如kubelet或 Kubernetes API 服务器,并通过多个定义的扩展点:

  • 所谓的云提供商,传统上作为控制器管理器的一部分。从 1.11 版本开始,Kubernetes 通过提供一个自定义 cloud-controller-manager 进程来支持云,使得离线开发成为可能。云提供商允许使用特定于云提供商的工具,如负载均衡器或虚拟机(VM)。

  • 二进制 kubelet 插件用于网络设备(如 GPU)、存储容器运行时

  • 二进制 kubectl插件

  • 在 API 服务器中访问扩展,例如使用webhook 进行动态准入控制(参见第九章)。

  • 自定义资源(参见第四章)和自定义控制器;请参阅下一节。

  • 自定义 API 服务器(参见第八章)。

  • 调度程序扩展,例如使用webhook来实现您自己的调度决策。

  • 使用 webhook 进行身份验证

在本书的背景下,我们专注于自定义资源、控制器、webhook 和自定义 API 服务器,以及 Kubernetes 的扩展模式。如果您对其他扩展点感兴趣,如存储或网络插件,请查阅官方文档

现在,您已经对 Kubernetes 扩展模式和本书的范围有了基本了解,让我们进入 Kubernetes 控制平面的核心部分,看看我们如何进行扩展。

控制器和操作员

在本节中,您将了解 Kubernetes 中控制器和运算符的工作原理。

根据 Kubernetes 术语表控制器 实现了一个控制循环,通过 API 服务器监视集群的共享状态,并进行更改,试图将当前状态向期望状态移动。

在深入研究控制器的内部工作之前,让我们先定义术语:

  • 控制器可以对核心资源(例如部署或服务)进行操作,这些资源通常是控制平面中的 Kubernetes 控制器管理器 的一部分,或者可以监视和操作用户定义的自定义资源。

  • 运算符是编码了一些操作知识的控制器,例如应用程序生命周期管理,以及在 第四章 中定义的自定义资源。

自然地,考虑到后者的概念基于前者,我们将首先讨论控制器,然后再讨论运算符的更专业案例。

控制循环

通常,控制循环如下所示:

  1. 首选事件驱动地读取资源状态(使用监视,如 第三章 中讨论的),详见 “事件” 和 “边缘驱动触发器与级别驱动触发器”。

  2. 更改集群对象或集群外部世界的对象状态。例如,启动一个 pod,创建一个网络端点或查询云 API。详见 “更改集群对象或外部世界”。

  3. 通过 API 服务器在 etcd 中更新资源状态的步骤 1。详见 “乐观并发性”。

  4. 重复循环;返回到步骤 1。

无论您的控制器有多复杂或简单,这三个步骤——读取资源状态 ˃ 改变世界 ˃ 更新资源状态——始终保持不变。让我们深入了解 Kubernetes 控制器中这些步骤的实际实现。控制循环显示在 图 1-2 中,显示了典型的运行部件,控制器的主循环位于中间。这个主循环在控制器进程内持续运行。该进程通常在集群中的一个 pod 内运行。

Kubernetes 控制循环

图 1-2. Kubernetes 控制循环概述

从架构角度来看,控制器通常使用以下数据结构(在 第三章 中详细讨论):

信息员

信息员以可扩展和可持续的方式监视资源的期望状态。它们还实现了重新同步机制(详见“信息员和缓存”),强制周期性协调,并经常用于确保集群状态和内存中缓存的假定状态不会偏离(例如由于错误或网络问题)。

工作队列

本质上,工作队列是一个组件,事件处理器可以使用它来处理状态变化的排队,并帮助实现重试。在client-go中,通过workqueue package(参见“Work Queue”)提供此功能。在更新世界或写入状态时(循环中的步骤 2 和 3),或仅因为我们必须因其他原因重新考虑资源时,资源可以重新排队。

想要更正式地讨论 Kubernetes 作为声明式引擎和状态转换的话题,请阅读“Kubernetes 的机制” by Andrew Chen and Dominik Tornow。

现在让我们更详细地了解控制循环,从 Kubernetes 事件驱动架构开始。

事件

Kubernetes 控制平面大量使用事件和松耦合组件的原则。其他分布式系统使用远程过程调用(RPC)来触发行为,但 Kubernetes 不是这样。Kubernetes 控制器监视 API 服务器中 Kubernetes 对象的更改:添加、更新和删除。当发生这样的事件时,控制器执行其业务逻辑。

例如,为了通过部署启动一个 pod,多个控制器和其他控制平面组件需要协同工作:

  1. 部署控制器(位于kube-controller-manager内部)通过部署通知器(deployment informer)注意到用户创建了一个部署。它根据自身的业务逻辑创建一个副本集。

  2. 副本集控制器(同样位于kube-controller-manager内部)通过副本集通知器注意到新的副本集,随后运行其业务逻辑,创建一个 pod 对象。

  3. 调度器(位于kube-scheduler二进制文件内)——也是一个控制器——通过 pod 通知器注意到一个spec.nodeName字段为空的 pod。其业务逻辑将该 pod 放入调度队列。

  4. 与此同时,另一个控制器kubelet通过其 pod 通知器注意到了新的 pod。但新 pod 的spec.nodeName字段为空,因此与kubelet的节点名称不匹配。它会忽略该 pod 并继续休眠(直到下一个事件)。

  5. 调度器从工作队列中取出 pod,并根据具有足够空闲资源的节点更新 pod 的spec.nodeName字段,并将其写入 API 服务器。

  6. kubelet再次因 pod 更新事件而唤醒。它再次比较spec.nodeName与自身节点名称。名称匹配,因此kubelet启动 pod 的容器,并通过将此信息写入 pod 状态回写到 API 服务器来报告容器已启动。

  7. 副本集控制器注意到更改的 pod,但没有任何操作。

  8. 最终,pod 终止。kubelet将注意到这一点,从 API 服务器获取 pod 对象,并在 pod 的状态中设置“terminated”条件,然后将其写回 API 服务器。

  9. 复制集控制器注意到已终止的 pod,并决定必须替换此 pod。它在 API 服务器上删除已终止的 pod 并创建一个新的 pod。

  10. 等等。

正如您所见,许多独立的控制循环纯粹通过 API 服务器上的对象更改及这些更改触发的事件进行通信。

这些事件通过监视器(参见 “监视器”)从 API 服务器发送到控制器内的通知器,即监视事件的流连接。所有这些对用户而言大部分是不可见的。甚至 API 服务器审计机制也不会使这些事件可见;只有对象更新可见。但是,当控制器对事件作出反应时,通常会使用日志输出。

如果您想了解更多关于事件的信息,请阅读迈克尔·加斯希的博客文章 “Kubernetes 的 DNA:事件”,在这里他提供了更多背景和示例。

边缘驱动与级别驱动触发器

让我们稍微退后一步,更抽象地看待我们如何在控制器中实现业务逻辑的结构,以及为什么 Kubernetes 选择使用事件(即状态更改)来驱动其逻辑。

有两个原则选项可以检测状态变化(事件本身):

边缘驱动触发器

在状态变化发生时刻,会触发一个处理程序,例如从无 pod 到 pod 运行。

级别驱动触发器

定期检查状态,并且如果满足某些条件(例如 pod 运行),则触发处理程序。

后者是轮询的一种形式。随着对象数量的增加,它的扩展性不佳,控制器注意到更改的延迟取决于轮询间隔和 API 服务器的响应速度。涉及许多异步控制器时,正如在 “事件” 中描述的那样,结果是系统需要很长时间来实现用户的需求。

前者选项在处理许多对象时效率更高。延迟主要取决于控制器处理事件的工作线程数。因此,Kubernetes 基于事件(即边缘驱动触发器)。

在 Kubernetes 控制平面中,许多组件更改 API 服务器上的对象,每次更改都会导致一个事件(即边缘)。我们称这些组件为事件源事件生产者。另一方面,在控制器的上下文中,我们对消费事件感兴趣——也就是何时以及如何对事件(通过通知器)做出反应。

在分布式系统中,有许多并行运行的参与者,并且事件以任意顺序异步到来。当我们有一个有缺陷的控制器逻辑、略微错误的状态机或外部服务故障时,很容易丢失事件,意味着我们不能完全处理它们。因此,我们必须更深入地研究如何处理错误。

在 图 1-3 中,您可以看到不同的工作策略:

  1. 边缘驱动逻辑的示例,可能会错过第二次状态变化。

  2. 边缘触发逻辑的示例,在处理事件时总是获取最新状态(即级别)。换句话说,逻辑是边缘触发的,但是级别驱动。

  3. 边缘触发、级别驱动逻辑的示例,附加重新同步。

触发选项(边缘 vs. 级别)

图 1-3. 触发选项(边缘驱动 vs. 级别驱动)

策略 1 在处理丢失的事件时表现不佳,无论是因为网络故障导致丢失事件,还是因为控制器本身存在 bug 或某些外部云 API 失效。想象一下,复制集控制器仅在 pod 终止时才会替换 pods。丢失的事件意味着复制集将始终以更少的 pods 运行,因为它从不调和整个状态。

策略 2 在接收到另一个事件时从这些问题中恢复,因为它基于集群中的最新状态实现其逻辑。例如,在 replica set 控制器的情况下,它将始终将指定的复制数量与集群中运行的 pods 进行比较。当丢失事件时,它将在接收到下一个 pod 更新时替换所有丢失的 pods。

策略 3 增加了持续重新同步(例如,每五分钟一次)。如果没有 pod 事件发生,它至少每五分钟进行一次调和,即使应用程序运行非常稳定,也不会导致多个 pod 事件。

鉴于纯边缘驱动触发器的挑战,Kubernetes 控制器通常实现第三种策略。

如果你想了解触发器的起源以及在 Kubernetes 中使用调和级触发的动机,请阅读 James Bowes 的文章,《Kubernetes 中的级触发和调和》。

这结束了对检测外部变化和对其作出反应的不同抽象方式的讨论。控制循环中 图 1-2 的下一步是更改集群对象或按照规范更改外部世界。我们现在来看一下。

更改集群对象或外部世界

在此阶段,控制器更改其监督的对象的状态。例如,在 控制器管理器 中的 ReplicaSet 控制器正在监督 pods。在每个事件(边缘触发)中,它将观察其 pods 的当前状态,并将其与期望的状态(级别驱动)进行比较。

由于改变资源状态的行为是特定于领域或任务的,我们无法提供太多指导。相反,我们将继续关注我们之前介绍的ReplicaSet控制器。ReplicaSet在部署中使用,其相应控制器的底线是:维护指定数量的相同 Pod 副本。也就是说,如果 Pod 少于用户指定的数量——例如因为某个 Pod 已经死亡或者复制值已增加——控制器将启动新的 Pod。然而,如果 Pod 过多,它将选择一些 Pod 进行终止。控制器的整个业务逻辑可通过replica_set.go获得,以下 Go 代码节选处理了状态变化(已编辑以增强清晰度):

// manageReplicas checks and updates replicas for the given ReplicaSet.
// It does NOT modify <filteredPods>.
// It will requeue the replica set in case of an error while creating/deleting pods.
func (rsc *ReplicaSetController) manageReplicas(
	filteredPods []*v1.Pod, rs *apps.ReplicaSet,
) error {
    diff := len(filteredPods) - int(*(rs.Spec.Replicas))
    rsKey, err := controller.KeyFunc(rs)
    if err != nil {
        utilruntime.HandleError(
        	fmt.Errorf("Couldn't get key for %v %#v: %v", rsc.Kind, rs, err),
        )
        return nil
    }
    if diff < 0 {
        diff *= -1
        if diff > rsc.burstReplicas {
            diff = rsc.burstReplicas
        }
        rsc.expectations.ExpectCreations(rsKey, diff)
        klog.V(2).Infof("Too few replicas for %v %s/%s, need %d, creating %d",
        	rsc.Kind, rs.Namespace, rs.Name, *(rs.Spec.Replicas), diff,
        )
        successfulCreations, err := slowStartBatch(
        	diff,
        	controller.SlowStartInitialBatchSize,
        	func() error {
        		ref := metav1.NewControllerRef(rs, rsc.GroupVersionKind)
                err := rsc.podControl.CreatePodsWithControllerRef(
            	    rs.Namespace, &rs.Spec.Template, rs, ref,
                )
                if err != nil && errors.IsTimeout(err) {
                	return nil
                }
                return err
            },
        )
        if skippedPods := diff - successfulCreations; skippedPods > 0 {
            klog.V(2).Infof("Slow-start failure. Skipping creation of %d pods," +
            	" decrementing expectations for %v %v/%v",
            	skippedPods, rsc.Kind, rs.Namespace, rs.Name,
            )
            for i := 0; i < skippedPods; i++ {
                rsc.expectations.CreationObserved(rsKey)
            }
        }
        return err
    } else if diff > 0 {
        if diff > rsc.burstReplicas {
            diff = rsc.burstReplicas
        }
        klog.V(2).Infof("Too many replicas for %v %s/%s, need %d, deleting %d",
        	rsc.Kind, rs.Namespace, rs.Name, *(rs.Spec.Replicas), diff,
        )

        podsToDelete := getPodsToDelete(filteredPods, diff)
        rsc.expectations.ExpectDeletions(rsKey, getPodKeys(podsToDelete))
        errCh := make(chan error, diff)
        var wg sync.WaitGroup
        wg.Add(diff)
        for _, pod := range podsToDelete {
            go func(targetPod *v1.Pod) {
                defer wg.Done()
                if err := rsc.podControl.DeletePod(
                	rs.Namespace,
                	targetPod.Name,
                	rs,
                ); err != nil {
                    podKey := controller.PodKey(targetPod)
                    klog.V(2).Infof("Failed to delete %v, decrementing " +
                    	"expectations for %v %s/%s",
                    	podKey, rsc.Kind, rs.Namespace, rs.Name,
                    )
                    rsc.expectations.DeletionObserved(rsKey, podKey)
                    errCh <- err
                }
            }(pod)
        }
        wg.Wait()

        select {
        case err := <-errCh:
            if err != nil {
                return err
            }
        default:
        }
    }
    return nil
}

你可以看到,控制器在这一行中计算规范和当前状态之间的差异 diff := len(filteredPods) - int(*(rs.Spec.Replicas)),然后根据这两种情况实现了两种情况:

  • diff < 0: 复本过少;必须创建更多的 Pod。

  • diff > 0: 复本过多;必须删除 Pod。

它还实现了一种策略,选择在getPodsToDelete中删除它们最不具有破坏性的 Pod。

然而,改变资源状态并不一定意味着这些资源必须是 Kubernetes 集群的一部分。换句话说,控制器可以改变位于 Kubernetes 之外的资源状态,比如云存储服务。例如,AWS 服务操作器允许你管理 AWS 资源。除了其他功能外,它允许你管理 S3 存储桶——也就是说,S3 控制器正在监督一个存在于 Kubernetes 之外的资源(S3 存储桶),并且状态变化反映了其生命周期中的具体阶段:一个 S3 存储桶被创建,最终删除。

这应该能够让你相信,通过自定义控制器,你不仅可以管理核心资源(如 Pods)和自定义资源(如我们的cnat示例),甚至可以管理存在于 Kubernetes 之外的计算或存储资源。这使得控制器成为非常灵活和强大的集成机制,提供了一种统一的方式来跨平台和环境使用资源。

乐观并发

在“控制循环”中,我们在第 3 步讨论了一个控制器——在根据规范更新集群对象和/或外部世界之后——将结果写入在第 1 步触发控制器运行的资源的状态中。

这以及实际上任何其他写入(也在第 2 步中)都可能出错。在分布式系统中,这个控制器可能只是更新资源的众多控制器之一。由于写入冲突,并发写入可能会失败。

为了更好地理解正在发生的情况,让我们退后一步,看看图 1-4。

分布式系统中的调度架构

图 1-4. 分布式系统中的调度架构

该源代码将 Omega 的并行调度器架构定义如下:

我们的解决方案是围绕共享状态构建的新并行调度器架构,使用无锁乐观并发控制,以实现实施的可扩展性和性能的可伸缩性。这种架构正在被用于 Omega,Google 的下一代集群管理系统。

虽然 Kubernetes 从Borg继承了许多特性和经验教训,但这种特定的事务控制平面特性来自于 Omega:为了在没有锁的情况下执行并发操作,Kubernetes API 服务器使用乐观并发。

简而言之,这意味着如果 API 服务器检测到并发的写入尝试,它将拒绝后面两次写入操作中的后者。然后由客户端(控制器、调度器、kubectl等)来处理冲突,并可能重试写入操作。

以下演示了在 Kubernetes 中乐观并发的思想:

var err error
for retries := 0; retries < 10; retries++ {
    foo, err = client.Get("foo", metav1.GetOptions{})
    if err != nil {
        break
    }

    <update-the-world-and-foo>

    _, err = client.Update(foo)
    if err != nil && errors.IsConflict(err) {
        continue
    } else if err != nil {
        break
    }
}

代码展示了一个重试循环,在每次迭代中获取最新的对象foo,然后尝试将世界和foo的状态更新为匹配foo的规范。在Update调用之前进行的更改是乐观的。

client.Get调用返回的对象foo包含一个资源版本(嵌入ObjectMeta结构体中—请参阅ObjectMeta了解详细信息),这将告诉etcdclient.Update调用后的写操作时,集群中的另一个参与者写入了foo对象。如果是这种情况,我们的重试循环将会遇到资源版本冲突错误。这意味着乐观并发逻辑失败了。换句话说,client.Update调用也是乐观的。

注意

资源版本实际上是etcd的键/值版本。每个对象的资源版本在 Kubernetes 中是一个包含整数的字符串。这个整数直接来自于etcdetcd维护一个计数器,每当修改保存对象序列化的键的值时,该计数器都会增加。

在整个 API 机制代码中,资源版本(更或多或少地)像是一个任意字符串进行处理,但其中有些排序规则。整数存储的事实仅仅是当前etcd存储后端的实现细节。

让我们看一个具体的例子。想象一下,您的客户端不是唯一一个修改 pod 的集群中的参与者。还有另一个参与者,即kubelet,因为容器不断崩溃,它会不断修改某些字段。现在,您的控制器像这样读取 pod 对象的最新状态:

kind: Pod
metadata:
  name: foo
  resourceVersion: 57
spec:
  ...
status:
  ...

现在假设控制器需要几秒钟来更新其对世界的更新。七秒钟后,它尝试更新它读取的 pod——例如,设置一个注释。与此同时,kubelet注意到另一个容器重新启动并更新了 pod 的状态以反映这一情况;即resourceVersion已增加到 58。

您的控制器发送的更新请求中的对象具有resourceVersion: 57。API 服务器尝试使用该值设置该 pod 的etcd键。etcd注意到资源版本不匹配,并报告说 57 与 58 冲突。更新失败。

这个例子的要点是,对于您的控制器,您需要负责实施重试策略,并适应如果乐观操作失败的情况。您永远不知道谁还在操作状态,无论是其他自定义控制器还是诸如部署控制器之类的核心控制器。

这的精髓是:在控制器中冲突错误是完全正常的。始终预期它们并优雅地处理它们

乐观并发控制在基于级别逻辑上非常合适,因为通过使用级别逻辑,您可以简单地重新运行控制循环(参见“边缘驱动触发器与级别驱动触发器”)。该循环的另一次运行将自动撤销先前失败的乐观尝试所做的更改,并尝试将世界更新到最新状态。

让我们继续讨论定制控制器(以及定制资源)的具体案例:操作器。

操作器

Kubernetes 中的操作器的概念由 CoreOS 在 2016 年引入。在他的重要博文中,“介绍操作器:将操作知识编入软件”,CoreOS 的 CTO Brandon Philips 这样定义操作器:

网站可靠性工程师(SRE)是一个通过编写软件来操作应用程序的人。他们是一位工程师、开发人员,了解如何专门为特定应用程序领域开发软件。结果产生的软件将应用程序的操作领域知识编程其中。

[…]

我们称这种新型软件类为操作器。操作器是一种特定于应用程序的控制器,通过扩展 Kubernetes API 代表 Kubernetes 用户创建、配置和管理复杂状态应用程序的实例。它基于基本的 Kubernetes 资源和控制器概念,但包括领域或应用程序特定的知识,以自动化常见任务。

在本书的背景下,我们将使用 Philips 所描述的操作器,并更正式地要求满足以下三个条件(另见图 1-5):

  • 有一些特定领域的操作知识您想要自动化。

  • 对于这些运营知识的最佳实践已被公认,并可以明确说明,例如在 Cassandra 操作员的情况下,何时以及如何重新平衡节点,或者在服务网格的操作员的情况下,如何创建路由。

  • 操作员在运算符的上下文中运送的工件有:

    • 一组自定义资源定义(CRD),捕获特定领域的模式和遵循这些 CRD 的自定义资源,在实例级别上代表感兴趣的领域。

    • 自定义控制器负责管理自定义资源,可能还包括核心资源。例如,自定义控制器可以启动一个 pod。

操作员的概念

图 1-5. 操作员的概念

操作员已经从 2016 年的概念工作和原型设计发展到了 2019 年初由红帽(2018 年收购 CoreOS 并继续推动这一理念)推出的OperatorHub.io。请见图 1-6,显示了 2019 年中期的这个中心截图,展示了大约 17 个可以使用的操作员。

OperatorHub.io 的截图

图 1-6. OperatorHub.io 截图

摘要

在本书的第一章中,我们定义了书籍的范围及对您的期望。我们解释了在本书背景下什么是编程 Kubernetes,并在为后续示例做准备时,还提供了对控制器和操作员的高级介绍。

现在您已经了解了本书的预期和您可以从中获益的方式,让我们深入研究。在下一章中,我们将更详细地了解 Kubernetes API,API 服务器的内部工作原理,以及如何使用诸如curl等命令行工具与 API 进行交互。

^(1) 欲了解更多相关主题,请参阅 Megan O’Keefe 的“A Kubernetes Developer Workflow for MacOS”Medium,2019 年 1 月 24 日;以及 Alex Ellis 的博客文章,“Be KinD to yourself”,2018 年 12 月 14 日。

^(2) 来源:由 Malte Schwarzkopf 等人在 2013 年 Google AI 发表的文章“Omega: Flexible, Scalable Schedulers for Large Compute Clusters”

第二章:Kubernetes API 基础

在本章中,我们将深入介绍 Kubernetes API 的基础知识。这包括深入了解 API 服务器的内部工作、API 本身以及如何从命令行与 API 交互。我们将向你介绍 Kubernetes API 概念,如资源和种类,以及分组和版本化。

API 服务器

Kubernetes 由一组具有不同角色的节点(集群中的机器)组成,如图 2-1 所示:控制平面位于主节点上,包括 API 服务器、控制器管理器和调度器。API 服务器是中央管理实体,也是唯一直接与分布式存储组件 etcd 通信的组件。

API 服务器有以下核心责任:

  • 为了提供 Kubernetes API。这个 API 在集群内部被主控组件、工作节点和你的 Kubernetes 原生应用使用,同时也可以被 kubectl 等客户端外部使用。

  • 代理集群组件,例如 Kubernetes 仪表板,或者流式传输日志、服务端口或服务 kubectl exec 会话。

提供 API 意味着:

  • 读取状态:获取单个对象,列出它们,并流式传输更改

  • 操作状态:创建、更新和删除对象

状态通过 etcd 持久化。

Kubernetes 架构概述

图 2-1. Kubernetes 架构概述

Kubernetes 的核心是其 API 服务器。但 API 服务器是如何工作的呢?我们首先将 API 服务器视为一个黑盒,深入研究其 HTTP 接口,然后再深入了解 API 服务器的内部工作原理。

API 服务器的 HTTP 接口

从客户端的角度来看,API 服务器公开一个具有 JSON 或 protocol buffer(简称 protobuf)有效载荷的 RESTful HTTP API,主要用于集群内部通信,出于性能考虑。

API 服务器的 HTTP 接口处理 HTTP 请求,使用以下HTTP 动词(或 HTTP 方法)查询和操作 Kubernetes 资源:

  • HTTP GET 动词用于检索具有特定资源的数据(例如某个 Pod)或资源的集合或列表(例如命名空间中的所有 Pod)。

  • HTTP POST 动词用于创建资源,例如服务或部署。

  • HTTP PUT 动词用于更新已存在的资源,例如修改一个 Pod 的容器镜像。

  • HTTP PATCH 动词用于对现有资源进行部分更新。阅读 Kubernetes 文档中的 “使用 JSON 合并补丁更新部署” 以了解更多可用的策略和影响。

  • HTTP DELETE 动词用于以不可恢复的方式销毁资源。

如果你查看,比如 Kubernetes 1.14 API 参考,你可以看到不同的 HTTP 动词的操作。例如,要列出当前命名空间中的 Pods,并且相当于kubectl -n *THENAMESPACE* get pods的 CLI 命令,您需要发出GET /api/v1/namespaces/*THENAMESPACE*/pods(参见图 2-2)。

API 服务器 HTTP 接口示例:列出给定命名空间中的 Pods

图 2-2. API 服务器 HTTP 接口示例:列出给定命名空间中的 Pods

想要了解如何从 Go 程序调用 API 服务器 HTTP 接口的简介,请参阅“客户端库”。

API 术语

在我们深入讨论 API 业务之前,让我们首先定义在 Kubernetes API 服务器上下文中使用的术语:

种类

实体的类型。每个对象都有一个字段Kind(JSON 中的小写kind,Golang 中的大写Kind),告诉客户端(例如kubectl)它代表了例如一个 Pod。种类有三种类别:

  • 对象表示系统中的持久实体,例如PodEndpoints。对象具有名称,其中许多对象存在于命名空间中。

  • 列表是一个或多个实体种类的集合。列表具有一组有限的公共元数据。例如,PodListNodeList。当你执行kubectl get pods时,就是这样。

  • 专用种类用于对象的特定操作以及用于非持久性实体,例如/binding/scale。对于发现,Kubernetes 使用APIGroupAPIResource;对于错误结果,它使用Status

在 Kubernetes 程序中,种类直接对应于一个 Golang 类型。因此,作为 Golang 类型,种类是单数形式并以大写字母开头。

API 组

一个逻辑相关的Kind集合。例如,所有像JobScheduledJob这样的批处理对象都在批处理 API 组中。

版本

每个 API 组可以存在多个版本,大多数情况下都是如此。例如,一个组首先出现为v1alpha1,然后晋升为v1beta1,最后毕业为v1。在一个版本(例如v1beta1)中创建的对象可以在每个支持的版本中检索。API 服务器执行无损转换以按请求的版本返回对象。从集群用户的角度看,版本只是相同对象的不同表示。

提示

在集群中,“一个对象在v1,另一个对象在v1beta1”这种说法是不成立的。相反,每个对象可以作为v1v1beta1表示返回,根据集群用户的需求。

资源

通常是小写的复数词(例如pods),用于标识系统中某种对象类型的一组 HTTP 端点(路径),公开了特定对象类型的 CRUD(创建、读取、更新、删除)语义。常见的路径包括:

  • 根,例如…/pods,列出该类型的所有实例

  • 一个路径用于单个命名资源,例如…/pods/nginx

典型情况下,每个这些端点返回和接收一种类型(第一个情况下是PodList,第二个情况下是Pod)。但在其他情况下(例如错误情况),将返回一种Status类型的对象。

除了具有完整 CRUD 语义的主资源外,资源还可以具有进一步的端点以执行特定操作(例如,…/pod/nginx/port-forward…/pod/nginx/exec…/pod/nginx/logs)。我们称这些为子资源(参见“子资源”)。通常这些实现自定义协议而不是 REST,例如通过 WebSockets 进行的某种类型的流连接或命令式 API。

提示

资源和种类经常混淆。注意清晰的区别:

  • 资源对应于 HTTP 路径。

  • 种类是由这些端点返回和接收的对象类型,以及持久存储到etcd中。

资源始终属于 API 组和版本,统称为GroupVersionResource(或 GVR)。GVR 唯一定义了 HTTP 路径。例如,在default命名空间中的具体路径将是/apis/batch/v1/namespaces/default/jobs。图 2-3 显示了一个命名空间资源(Job)的 GVR 示例。

Kubernetes API—Group, Version, Resource (GVR)

图 2-3. Kubernetes API—GroupVersionResource (GVR)

jobs GVR 示例相比,像节点或命名空间本身这样的集群范围资源在路径中不包含$NAMESPACE部分。例如,nodes GVR 示例可能如下所示:/api/v1/nodes。请注意,命名空间出现在其他资源的 HTTP 路径中,但它们也是资源本身,可通过/api/v1/namespaces访问。

类似于 GVR,每个种类都属于一个 API 组,被版本化,并通过GroupVersionKind(GVK)进行标识。

GVK 和 GVR 是相关联的。GVK 在由 GVR 标识的 HTTP 路径下提供服务。将 GVK 映射到 GVR 的过程称为 REST 映射。我们将在“REST 映射”中看到在 Golang 中实现 REST 映射的RESTMappers

从全局角度看,API 资源空间在逻辑上形成一个树,顶级节点包括/api/apis以及一些非分层的端点,如/healthz/metrics。此 API 空间的一个示例呈现如图 2-4。请注意,确切的形状和路径取决于 Kubernetes 版本,而这些版本随着年份的推移趋于稳定。

一个示例 Kubernetes API 空间

图 2-4. 一个示例 Kubernetes API 空间

Kubernetes API 版本控制

出于可扩展性原因,Kubernetes 支持在不同的 API 路径下使用多个 API 版本,例如/api/v1/apis/extensions/v1beta1。不同的 API 版本意味着不同的稳定性和支持水平:

  • Alpha级别(例如,v1alpha1)通常默认禁用;支持某项功能可能随时停止且无需通知,并且仅应在短期测试集群中使用。

  • Beta级别(例如v2beta3)默认启用,表示代码经过了良好的测试;但是,对象的语义可能会在后续的 beta 或稳定版本中以不兼容的方式发生变化。

  • 稳定(通常可用或 GA)级别(例如v1)将出现在后续许多版本的发布软件中。

让我们来看看 HTTP API 空间是如何构建的:在顶层,我们区分核心组(即位于/api/v1下的所有内容)和路径形式为/apis/\(`NAME`/\)`VERSION的命名组。

注意

核心组位于/api/v1下,并且不是如人们所期望的位于/apis/core/v1下,这是由于历史原因。在引入 API 组概念之前,核心组已经存在。

还有第三种类型的 HTTP 路径—不与资源对齐的路径,API 服务器公开:全局集群实体,如/metrics/logs/healthz。此外,API 服务器支持观察;也就是说,而不是在设定的间隔内轮询资源,您可以在某些请求中添加?watch=true,API 服务器会进入观察模式

声明性状态管理

大多数 API 对象在资源的期望状态和对象当前时间的状态之间做出了区分。规范(或简称规范)是资源期望状态的完整描述,通常存储在稳定存储中,通常是etcd

注意

为什么我们说“通常是etcd“?嗯,有一些 Kubernetes 发行版和提供,如k3s或 Microsoft 的 AKS,已经或正在用其他替代etcd。由于 Kubernetes 控制平面的模块化架构,这完全没问题。

让我们在 API 服务器的上下文中更详细地讨论规范(期望状态)与状态(观察状态)之间的区别。

规范描述了资源的期望状态,您需要通过命令行工具(如kubectl)或通过您的 Go 代码以编程方式提供。状态描述了资源的观察或实际状态,并由控制平面管理,可以是核心组件(如控制器管理器)或您自己的自定义控制器管理(参见“控制器和操作员”)。例如,在部署中,您可能指定希望应用程序始终运行 20 个副本。控制平面中的部署控制器,作为控制器管理器的一部分,读取您提供的部署规范,并创建一个副本集,然后负责管理副本:它创建相应数量的 Pod,最终(通过kubelet)在工作节点上启动容器。如果任何副本失败,部署控制器会在状态中通知您。这就是我们所说的声明性状态管理—即声明期望的状态,然后让 Kubernetes 处理剩余部分。

我们将在下一节看到声明性状态管理的实际操作,从命令行开始探索 API。

使用命令行访问 API

在本节中,我们将使用 kubectlcurl 来演示 Kubernetes API 的使用。如果您对这些 CLI 工具不熟悉,现在是安装并尝试它们的好时机。

首先,让我们查看资源的期望状态和观察到的状态。我们将使用控制平面组件作为例子,这个组件在每个集群中都可能可用,即 kube-system 命名空间中的 CoreDNS 插件(旧版 Kubernetes 使用 kube-dns)(此输出已经过大量编辑以突出重要部分)。

$ kubectl -n kube-system get deploy/coredns -o=yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: coredns
  namespace: kube-system
  ...
spec:
  template:
    spec:
      containers:
      - name: coredns
        image: 602401143452.dkr.ecr.us-east-2.amazonaws.com/eks/coredns:v1.2.2
  ...
status:
  replicas: 2
  conditions:
  - type: Available
    status: "True"
    lastUpdateTime: "2019-04-01T16:42:10Z"
  ...

正如您从这个 kubectl 命令中可以看到的,在部署的 spec 部分,您可以定义诸如要使用的容器镜像以及要并行运行多少个副本之类的特性,在 status 部分,您可以了解当前时间点有多少个副本实际在运行。

为了进行与 CLI 相关的操作,本章的剩余部分我们将使用批量操作作为运行示例。让我们从在终端中执行以下命令开始:

$ kubectl proxy --port=8080
Starting to serve on 127.0.0.1:8080

此命令将 Kubernetes API 代理到我们的本地机器,并处理认证和授权部分。它允许我们通过 HTTP 直接发出请求,并收到 JSON 负载作为返回。让我们通过启动第二个终端会话来执行 v1 查询:

$ curl http://127.0.0.1:8080/apis/batch/v1
{
  "kind": "APIResourceList",
  "apiVersion": "v1",
  "groupVersion": "batch/v1",
  "resources": [
    {
      "name": "jobs",
      "singularName": "",
      "namespaced": true,
      "kind": "Job",
      "verbs": [
        "create",
        "delete",
        "deletecollection",
        "get",
        "list",
        "patch",
        "update",
        "watch"
      ],
      "categories": [
        "all"
      ]
    },
    {
      "name": "jobs/status",
      "singularName": "",
      "namespaced": true,
      "kind": "Job",
      "verbs": [
        "get",
        "patch",
        "update"
      ]
    }
  ]
}
提示

您不必与 kubectl proxy 命令一起使用 curl 来直接访问 Kubernetes API 的 HTTP API。您可以使用 kubectl get --raw 命令:例如,将 curl http://127.0.0.1:8080/apis/batch/v1 替换为 kubectl get --raw /apis/batch/v1

将此与 v1beta1 版本进行比较,注意当查看 http://127.0.0.1:8080/apis/batch v1beta1 时,您可以获取批处理 API 组的支持版本列表:

$ curl http://127.0.0.1:8080/apis/batch/v1beta1
{
  "kind": "APIResourceList",
  "apiVersion": "v1",
  "groupVersion": "batch/v1beta1",
  "resources": [
    {
      "name": "cronjobs",
      "singularName": "",
      "namespaced": true,
      "kind": "CronJob",
      "verbs": [
        "create",
        "delete",
        "deletecollection",
        "get",
        "list",
        "patch",
        "update",
        "watch"
      ],
      "shortNames": [
        "cj"
      ],
      "categories": [
        "all"
      ]
    },
    {
      "name": "cronjobs/status",
      "singularName": "",
      "namespaced": true,
      "kind": "CronJob",
      "verbs": [
        "get",
        "patch",
        "update"
      ]
    }
  ]
}

如您所见,v1beta1 版本还包含带有 CronJob 类型的 cronjobs 资源。在撰写本文时,cron 作业尚未升级到 v1

如果您想了解集群中支持的 API 资源(包括其类型、是否命名空间以及其短名称(主要用于 kubectl 命令行),您可以使用以下命令:

$ kubectl api-resources
NAME                   SHORTNAMES APIGROUP NAMESPACED   KIND
bindings                                   true Binding
componentstatuses      cs                  false ComponentStatus
configmaps             cm                  true ConfigMap
endpoints              ep                  true Endpoints
events                 ev                  true Event
limitranges            limits              true LimitRange
namespaces             ns                  false Namespace
nodes                  no                  false Node
persistentvolumeclaims pvc                 true PersistentVolumeClaim
persistentvolumes      pv                  false PersistentVolume
pods                   po                  true Pod
podtemplates                               true PodTemplate
replicationcontrollers rc                  true ReplicationController
resourcequotas         quota               true ResourceQuota
secrets                                    true Secret
serviceaccounts        sa                  true ServiceAccount
services               svc                 true Service
controllerrevisions               apps     true ControllerRevision
daemonsets             ds         apps     true DaemonSet
deployments            deploy     apps     true Deployment
...

下面是一个相关的命令,用于确定集群中支持的不同资源版本的列表:

$ kubectl api-versions
admissionregistration.k8s.io/v1beta1
apiextensions.k8s.io/v1beta1
apiregistration.k8s.io/v1
apiregistration.k8s.io/v1beta1
appmesh.k8s.aws/v1alpha1
appmesh.k8s.aws/v1beta1
apps/v1
apps/v1beta1
apps/v1beta2
authentication.k8s.io/v1
authentication.k8s.io/v1beta1
authorization.k8s.io/v1
authorization.k8s.io/v1beta1
autoscaling/v1
autoscaling/v2beta1
autoscaling/v2beta2
batch/v1
batch/v1beta1
certificates.k8s.io/v1beta1
coordination.k8s.io/v1beta1
crd.k8s.amazonaws.com/v1alpha1
events.k8s.io/v1beta1
extensions/v1beta1
networking.k8s.io/v1
policy/v1beta1
rbac.authorization.k8s.io/v1
rbac.authorization.k8s.io/v1beta1
scheduling.k8s.io/v1beta1
storage.k8s.io/v1
storage.k8s.io/v1beta1
v1

API 服务器如何处理请求

现在您已经了解了外部 HTTP 接口,让我们专注于 API 服务器的内部工作。图 2-5 展示了 API 服务器中请求处理的高级概述。

Kubernetes API 服务器请求处理概述

图 2-5. Kubernetes API 服务器请求处理概述

那么,当 HTTP 请求到达 Kubernetes API 时实际上发生了什么?从高层次来看,以下交互发生:

  1. HTTP 请求由注册在DefaultBuildHandlerChain()中的一系列过滤器处理。此链在k8s.io/apiserver/pkg/server/config.go中定义,并且稍后将详细讨论。它在该请求上应用一系列的过滤器操作。如果过滤器通过并向上下文附加了相应的信息—确切地说是ctx.RequestInfo,其中ctx是 Go 语言中的上下文(例如,认证用户)—或者如果请求未通过过滤器,则返回适当的 HTTP 响应代码,说明原因(例如,如果用户认证失败,则返回401响应)。

  2. 接下来,根据 HTTP 路径,k8s.io/apiserver/pkg/server/handler.go中的多路复用器将 HTTP 请求路由到相应的处理程序。

  3. 为每个 API 组注册了一个处理程序—详细信息请参阅k8s.io/apiserver/pkg/endpoints/groupversion.gok8s.io/apiserver/pkg/endpoints/installer.go。它接收 HTTP 请求以及上下文(例如用户和访问权限),从etcd存储中检索并传递请求的对象。

现在让我们更详细地看一下DefaultBuildHandlerChain()server/config.go中设置的过滤器链,以及每个过滤器中发生的事情:

func DefaultBuildHandlerChain(apiHandler http.Handler, c *Config) http.Handler {
    h := WithAuthorization(apiHandler, c.Authorization.Authorizer, c.Serializer)
    h = WithMaxInFlightLimit(h, c.MaxRequestsInFlight,
          c.MaxMutatingRequestsInFlight, c.LongRunningFunc)
    h = WithImpersonation(h, c.Authorization.Authorizer, c.Serializer)
    h = WithAudit(h, c.AuditBackend, c.AuditPolicyChecker, LongRunningFunc)
    ...
    h = WithAuthentication(h, c.Authentication.Authenticator, failed, ...)
    h = WithCORS(h, c.CorsAllowedOriginList, nil, nil, nil, "true")
    h = WithTimeoutForNonLongRunningRequests(h, LongRunningFunc, RequestTimeout)
    h = WithWaitGroup(h, c.LongRunningFunc, c.HandlerChainWaitGroup)
    h = WithRequestInfo(h, c.RequestInfoResolver)
    h = WithPanicRecovery(h)
    return h
}

所有的包都在k8s.io/apiserver/pkg中。更具体地说:

WithPanicRecovery()

负责恢复和记录恐慌。定义在server/filters/wrap.go中。

WithRequestInfo()

RequestInfo附加到上下文中。定义在endpoints/filters/requestinfo.go中。

WithWaitGroup()

将所有非长时间运行的请求添加到等待组中;用于优雅关闭。定义在server/filters/waitgroup.go中。

WithTimeoutForNonLongRunningRequests()

对非长时间运行的请求(如大多数GETPUTPOSTDELETE请求)设置超时,与观察和代理请求等长时间运行的请求形成对比。定义在server/filters/timeout.go中。

WithCORS()

提供了一个CORS实现。CORS 是跨源资源共享的缩写,是一种机制,允许嵌入在 HTML 页面中的 JavaScript 向与其来源不同的域名发出 XMLHttpRequests。定义在server/filters/cors.go中。

WithAuthentication()

尝试将给定请求验证为人类或机器用户,并将用户信息存储在提供的上下文中。验证成功后,将从请求中删除Authorization HTTP 头部。如果身份验证失败,则返回 HTTP 401状态码。定义在endpoints/filters/authentication.go中。

WithAudit()方法

为所有传入请求装饰处理程序,并记录审计日志信息。审计日志条目包含请求的源 IP、执行操作的用户和请求的命名空间。定义在admission/audit.go中。

WithImpersonation()方法

通过检查尝试更改用户的请求来处理用户模拟(类似于sudo)。定义在endpoints/filters/impersonation.go中。

WithMaxInFlightLimit()方法

限制正在处理的请求数量。定义在server/filters/maxinflight.go中。

WithAuthorization()方法

通过调用授权模块检查权限,并将所有已授权的请求传递给多路复用器,该复用器将请求分派给正确的处理程序。如果用户权限不足,则返回 HTTP 403状态码。如今的 Kubernetes 使用基于角色的访问控制(RBAC)。定义在endpoints/filters/authorization.go中。

在通过通用处理程序链之后(图 2-5 中的第一个框),实际的请求处理开始执行(即,执行请求处理程序的语义):

  • 直接处理非 RESTful API 的请求,如//version/apis/healthz等。

  • RESTful 资源的请求进入由以下组成的请求管道:

    审批

    对象经过审批链。该链具有大约 20 个不同的审批插件。^(1) 每个插件可以是变异阶段的一部分(见图 2-5 中的第三个框),验证阶段的一部分(见图中的第四个框),或者两者兼而有之。

    在变异阶段,可以更改传入请求的有效负载;例如,根据审批配置将镜像拉取策略设置为AlwaysIfNotPresentNever

    第二个审批阶段纯粹用于验证;例如,验证 Pod 中的安全设置,或在创建该命名空间中的对象之前验证命名空间的存在。

    验证

    对象经过大型验证逻辑进行检查,该逻辑对系统中的每种对象类型都存在。例如,字符串格式检查以验证服务名称中仅使用有效的 DNS 兼容字符,或验证 Pod 中所有容器名称的唯一性。

    etcd支持的 CRUD 逻辑

    在这里,我们看到的不同动词在“API 服务器的 HTTP 接口”中得到了实现;例如,更新逻辑从 etcd 读取对象,检查没有其他用户以“乐观并发”的方式修改该对象,如果没有,则将请求对象写入 etcd

我们将在接下来的章节中更详细地探讨这些步骤;例如:

自定义资源

“验证自定义资源”中的验证,“Admission Webhooks”中的接收,以及第四章中的一般 CRUD 语义。

Golang 原生资源

“验证”中的验证,“Admission”中的接收,以及“Registry and Strategy”中 CRUD 语义的实现。

总结

在本章中,我们首先将 Kubernetes API 服务器作为一个黑盒进行讨论,并查看了它的 HTTP 接口。然后你学习了如何在命令行中与这个黑盒交互,最后我们打开了这个黑盒,探索其内部工作。到现在,你应该知道 API 服务器的内部工作原理,以及如何使用 CLI 工具 kubectl 进行资源的探索和操作。

现在是时候告别命令行上的手动交互,开始使用 Go 编程接口服务器访问:介绍 client-go,Kubernetes“标准库”的核心。

^(1) 在 Kubernetes 1.14 集群中,这些(按此顺序)是:AlwaysAdmitNamespaceAutoProvisionNamespaceLifecycleNamespaceExistsSecurityContextDenyLimitPodHardAntiAffinityTopologyPodPresetLimitRangerServiceAccountNodeRestrictionTaintNodesByConditionAlwaysPullImagesImagePolicyWebhookPodSecurityPolicyPodNodeSelectorPriorityDefaultTolerationSecondsPodTolerationRestrictionDenyEscalatingExecDenyExecOnPrivilegedEventRateLimitExtendedResourceTolerationPersistentVolumeLabelDefaultStorageClassStorageObjectInUseProtectionOwnerReferencesPermissionEnforcementPersistentVolumeClaimResizeMutatingAdmissionWebhookValidatingAdmissionWebhookResourceQuota,和 AlwaysDeny

第三章:client-go 的基础知识

现在我们将专注于 Go 中的 Kubernetes 编程接口。您将学习如何访问 Kubernetes API 中的众所周知的原生类型,例如 pods、services 和 deployments。在后续章节中,这些技术将扩展到用户定义的类型。然而,在此之前,我们首先集中讨论每个 Kubernetes 集群都提供的所有 API 对象。

仓库

Kubernetes 项目在 GitHub 上的 kubernetes 组织下提供了许多第三方可消费的 Git 仓库。您需要将所有这些仓库使用域别名 k8s.io/…(而不是 github.com/kubernetes/…)导入到您的项目中。我们将在以下部分介绍其中最重要的仓库。

客户端库

Kubernetes 中使用 Go 编程接口主要由 k8s.io/client-go 库组成(简称为 client-go)。client-go 是一个典型的 Web 服务客户端库,支持所有官方 Kubernetes API 类型。它可以用来执行常见的 REST 动词:

  • 创建

  • 获取

  • 列表

  • 更新

  • 删除

  • 修补

每一个这些 REST 动词都是使用“API 服务器的 HTTP 接口”实现的。此外,还支持动词 Watch,这是 Kubernetes 类似 API 的特殊功能,也是与其他 API 的主要区别之一。

client-go 在 GitHub 上可用(参见图 3-1),在 Go 代码中使用 k8s.io/client-go 包名。它与 Kubernetes 本身并行发布;也就是说,对于每个 Kubernetes 1.x.y 发布,都有一个与之匹配的 client-go 发布,带有相应的标签 kubernetes-1.x.y

在 Github 上的  仓库

图 3-1. GitHub 上的 client-go 仓库

另外,还有一个语义版本控制方案。例如,client-go 9.0.0 对应 Kubernetes 1.12 发布,client-go 10.0.0 对应 Kubernetes 1.13,依此类推。未来可能会有更精细的发布。除了 Kubernetes API 对象的客户端代码,client-go 还包含许多通用库代码。这也用于用户定义的 API 对象在第四章中使用。参见图 3-1 以获取包列表。

尽管所有包都有其用途,但大部分与 Kubernetes API 进行交互的代码将使用 tools/clientcmd/kubeconfig 文件设置客户端,以及用于实际 Kubernetes API 客户端的 kubernetes/。我们很快将看到执行此操作的代码。在此之前,让我们快速浏览其他相关的仓库和包。

Kubernetes API 类型

正如我们所见,client-go 拥有客户端接口。用于 pods、services 和 deployments 等 Kubernetes API Go 类型的对象位于它们自己的仓库中。在 Go 代码中,它被访问为 k8s.io/api

Pod 是遗留 API 组的一部分(通常也称为“核心”组)版本 v1。因此,Pod Go 类型位于 k8s.io/api/core/v1,其他 Kubernetes 中的所有 API 类型类似。参见 图 3-2 获取包列表,其中大多数对应于 Kubernetes API 组及其版本。

实际的 Go 类型包含在一个 types.go 文件中(例如 k8s.io/api/core/v1/types.go)。此外,还有其他文件,其中大多数是由代码生成器自动生成的。

GitHub 上的 API 仓库

图 3-2. GitHub 上的 API 仓库

API 机制

最后但同样重要的是,有第三个仓库称为 API Machinery,在 Go 中被用作 k8s.io/apimachinery。它包括所有用于实现类似 Kubernetes API 的通用构建模块。API 机制不仅限于容器管理,例如,它也可以用于构建在线商店或任何其他业务特定域的 API。

尽管如此,在 Kubernetes 原生 Go 代码中会遇到许多 API 机制包。其中一个重要的是 k8s.io/apimachinery/pkg/apis/meta/v1.。它包含许多通用的 API 类型,如 ObjectMetaTypeMetaGetOptionsListOptions(见 图 3-3)。

GitHub 上的 API 机制仓库

图 3-3. GitHub 上的 API 机制仓库

创建和使用客户端

现在我们知道了创建 Kubernetes 客户端对象的所有构建模块,这意味着我们可以访问 Kubernetes 集群中的资源。假设您可以在本地环境中访问集群(即 kubectl 正确设置并配置了凭据),以下代码演示了如何在 Go 项目中使用 client-go

import (
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    "k8s.io/client-go/tools/clientcmd"
    "k8s.io/client-go/kubernetes"
)

kubeconfig = flag.String("kubeconfig", "~/.kube/config", "kubeconfig file")
flag.Parse()
config, err := clientcmd.BuildConfigFromFlags("", *kubeconfig)
clientset, err := kubernetes.NewForConfig(config)

pod, err := clientset.CoreV1().Pods("book").Get("example", metav1.GetOptions{})

代码导入 meta/v1 包以访问 metav1.GetOptions。此外,它从 client-go 导入 clientcmd 以读取和解析 kubeconfig(即包含服务器名称、凭据等的客户端配置)。然后它导入 client-gokubernetes 包,其中包含用于 Kubernetes 资源的客户端集。

kubeconfig 文件的默认位置在用户的主目录中的 .kube/config。这也是 kubectl 获取 Kubernetes 集群凭据的地方。

然后使用 clientcmd.BuildConfigFromFlags 读取并解析 kubeconfig。我们在代码中省略了强制的错误处理,但 err 变量通常会包含例如 kubeconfig 格式不正确的语法错误。由于语法错误在 Go 代码中很常见,因此应适当检查此类错误,如下所示:

config, err := clientcmd.BuildConfigFromFlags("", *kubeconfig)
if err != nil {
    fmt.Printf("The kubeconfig cannot be loaded: %v\n", err
    os.Exit(1)
}

clientcmd.BuildConfigFromFlags 我们得到一个 rest.Config,你可以在 k8s.io/client-go/rest 包中找到)。这个配置传递给 kubernetes.NewForConfig 以创建实际的 Kubernetes 客户端集。它被称为 客户端集,因为它包含多个客户端,用于访问所有原生 Kubernetes 资源。

在集群中的 Pod 内运行二进制文件时,kubelet 将自动将一个服务帐户挂载到容器中的 /var/run/secrets/kubernetes.io/serviceaccount。它替换了刚提到的 kubeconfig 文件,可以通过 rest.InClusterConfig() 方法轻松转换为 rest.Config。你经常会找到以下组合:rest.InClusterConfig()clientcmd.BuildConfigFromFlags(),包括对 KUBECONFIG 环境变量的支持。

config, err := rest.InClusterConfig()
if err != nil {
    // fallback to kubeconfig
    kubeconfig := filepath.Join("~", ".kube", "config")
    if envvar := os.Getenv("KUBECONFIG"); len(envvar) >0 {
        kubeconfig = envvar
    }
    config, err = clientcmd.BuildConfigFromFlags("", kubeconfig)
    if err != nil {
        fmt.Printf("The kubeconfig cannot be loaded: %v\n", err
        os.Exit(1)
    }
}

在以下示例代码中,我们选择 v1 版本的核心组 clientset.CoreV1(),然后访问命名空间 "book" 中的 pod "example"

 pod, err := clientset.CoreV1().Pods("book").Get("example", metav1.GetOptions{})

注意,只有最后一个函数调用 Get 实际访问了服务器。CoreV1Pods 都选择了客户端,并且仅为以下 Get 调用设置了命名空间(这通常被称为 生成器模式,在这种情况下用于构建请求)。

Get 调用向服务器发送 HTTP GET 请求,路径为 /api/v1/namespaces/book/pods/example,该路径在 kubeconfig 中设置。如果 Kubernetes API 服务器以 HTTP 状态码 200 响应,则响应体将携带编码后的 Pod 对象,可以是 JSON 格式(这是 client-go 的默认传输格式)或者协议缓冲区格式。

注意

你可以通过在创建客户端之前修改 REST 配置来为原生 Kubernetes 资源客户端启用协议缓冲区支持:

cfg, err := clientcmd.BuildConfigFromFlags("", *kubeconfig)
cfg.AcceptContentTypes = "application/vnd.kubernetes.protobuf,
 application/json"
cfg.ContentType = "application/vnd.kubernetes.protobuf"
clientset, err := kubernetes.NewForConfig(cfg)

注意,第四章 中介绍的自定义资源不支持协议缓冲区。

版本控制和兼容性

Kubernetes API 是有版本的。在前一节中我们看到 pods 属于核心组的 v1 版本。实际上,核心组目前只存在一个版本。但也有其他组,例如 apps 组,在 v1v1beta2v1beta1(截至本文撰写时)中存在。如果你查看 k8s.io/api/apps 包,你会找到所有这些版本的 API 对象。在 k8s.io/client-go/kubernetes/typed/apps 包中,你会看到所有这些版本的客户端实现。

所有这些仅涉及客户端端。它并未涉及 Kubernetes 集群及其 API 服务器的任何内容。使用客户端与 API 服务器不支持的 API 组版本将导致失败。客户端是硬编码到一个版本中的,应用程序开发人员必须选择正确的 API 组版本以便与手头的集群进行通信。有关 API 组兼容性保证的更多信息,请参见 “API 版本和兼容性保证”。

兼容性的第二个方面是client-go与 API 服务器的元 API 功能。例如,有用于 CRUD 动词的选项结构体,比如CreateOptionsGetOptionsUpdateOptionsDeleteOptions。另一个重要的是ObjectMeta(在ObjectMeta中详细讨论),它是每种类型的一部分。所有这些都经常通过新功能进行扩展;在它们的字段中,注释指定何时将功能视为 alpha 或 beta。与任何其他 API 字段一样,相同的 API 兼容性保证适用于它们。

在接下来的示例中,DeleteOptions 结构体在包k8s.io/apimachinery/pkg/apis/meta/v1/types.go中定义:

// DeleteOptions may be provided when deleting an API object.
type DeleteOptions struct {
    TypeMeta `json:",inline"`

    GracePeriodSeconds *int64 `json:"gracePeriodSeconds,omitempty"`
    Preconditions *Preconditions `json:"preconditions,omitempty"`
    OrphanDependents *bool `json:"orphanDependents,omitempty"`
    PropagationPolicy *DeletionPropagation `json:"propagationPolicy,omitempty"`

    // When present, indicates that modifications should not be
    // persisted. An invalid or unrecognized dryRun directive will
    // result in an error response and no further processing of the
    // request. Valid values are:
    // - All: all dry run stages will be processed
    // +optional
    DryRun []string `json:"dryRun,omitempty" protobuf:"bytes,5,rep,name=dryRun"`
}

最后一个字段,DryRun,在 Kubernetes 1.12 中作为 alpha 版本添加,在 1.13 中作为 beta 版本(默认启用)。在较早的版本中,API 服务器无法理解它。根据功能,传递这样的选项可能会被简单地忽略或者甚至拒绝。因此,拥有一个与集群版本不相距太远的client-go版本非常重要。

提示

哪些字段在哪个质量级别可用的参考是k8s.io/api中的源代码,例如,在release-1.13分支中,为 Kubernetes 1.13 标记为 alpha 的字段已被标注。

生成的 API 文档更便于使用。尽管如此,它的信息与k8s.io/api中的相同。

最后但同样重要的是,许多 alpha 和 beta 功能都有相应的功能门(请查看主要来源)。这些功能在问题中有追踪。

集群与client-go版本之间的正式保证支持矩阵已在client-goREADME中发布(参见表 3-1)。

表 3-1. client-go 与 Kubernetes 版本的兼容性

Kubernetes 1.9 Kubernetes 1.10 Kubernetes 1.11 Kubernetes 1.12 Kubernetes 1.13 Kubernetes 1.14 Kubernetes 1.15
client-go 6.0 +– +– +– +– +– +–
client-go 7.0 +– +– +– +– +– +–
client-go 8.0 +– +– +– +– +– +–
client-go 9.0 +– +– +– +– +– +–
client-go 10.0 +– +– +– +– +– +–
client-go 11.0 +– +– +– +– +– +–
client-go 12.0 +– +– +– +– +– +–
client-go HEAD +– +– +– +– +– +– +–
  • ✓: client-go 和 Kubernetes 版本在功能和 API 组版本上是相同的。

  • +client-go 具有功能或 API 组版本,可能在 Kubernetes 集群中不存在。这可能是因为 client-go 中添加了新功能,或者因为 Kubernetes 删除了旧的、不推荐使用的功能。然而,它们共享的所有内容(即大多数 API)都将正常工作。

  • client-go 明知与 Kubernetes 集群不兼容。

从 表 3-1 中可以得出结论,client-go 库与其相应的集群版本兼容。在版本不一致的情况下,开发人员必须仔细考虑他们使用的功能和 API 组是否在应用程序所连接的集群版本中得到支持。

在 表 3-1 中列出了 client-go 的版本。我们在 “客户端库” 中简要提到,client-go 使用语义化版本控制(semver),每次增加 Kubernetes 的小版本时,增加 client-go 的主版本。client-go 1.0 是为 Kubernetes 1.4 发布的,现在我们在 Kubernetes 1.15 时的 client-go 版本为 12.0(截至撰写本文时)。

此 semver 仅适用于 client-go 本身,不适用于 API Machinery 或 API 仓库。相反,后者使用 Kubernetes 版本进行标记,如 图 3-4 所示。查看 “Vendoring” 以了解它在项目中对 k8s.io/client-gok8s.io/apimachineryk8s.io/api 的意义。

Client-go 版本控制

图 3-4. client-go 版本控制

API 版本和兼容性保证

正如前一节所示,如果您的代码面向不同的集群版本,选择正确的 API 组版本可能至关重要。Kubernetes 使用常见的版本控制方案,其中包括 alpha、beta 和 GA(一般可用)版本。

模式为:

  • v1alpha1v1alpha2v2alpha1 等被称为 alpha 版本,并被认为是不稳定的。这意味着:

    • 它们可能随时以任何不兼容的方式消失或更改。

    • 数据可能会在 Kubernetes 的不同版本之间丢失、丢弃或变得不可访问。

    • 默认情况下,它们通常是禁用的,除非管理员手动选择启用。

  • v1beta1v1beta2v2beta1 等被称为 beta 版本。它们正在通向稳定性,这意味着:

    • 它们将与相应的稳定 API 版本并行存在至少一个 Kubernetes 发布周期。

    • 它们通常不会以不兼容的方式更改,但没有严格的保证。

    • 存储在 beta 版本中的对象不会丢失或变得不可访问。

    • Beta 版本通常在集群中默认启用。但这可能取决于使用的 Kubernetes 分发版或云提供商。

  • v1v2 等是稳定的、通用可用的 API;也就是说:

    • 它们将保留下去。

    • 它们将保持兼容性。

提示

Kubernetes 在这些经验法则背后有一个正式的废弃政策。您可以在Kubernetes 社区的 GitHub上找到关于哪些 API 构造被视为兼容的更多详细信息。

关于 API 组版本,有两个重要的要点需要记住:

  • API 组版本适用于整个 API 资源,例如 pods 或 services 的格式。除了 API 组版本外,API 资源可能还有单独版本化的字段;例如,在其 Go 内联代码文档中,稳定的 API 中的字段可能被标记为 alpha 质量。对于这些字段,将适用于与 API 组相同的规则。例如:

    • 稳定 API 中的 alpha 字段可能会变得不兼容,丢失数据或随时消失。例如,ObjectMeta.Initializers 字段,从未超出 alpha 阶段,将在不久的将来消失(在 1.14 版本中已弃用):

      // DEPRECATED - initializers are an alpha field and will be removed
      // in v1.15.
      Initializers *Initializers `json:"initializers,omitempty"
      
    • 通常情况下,默认情况下将禁用它,并且必须使用 API 服务器功能门限进行启用,例如:

      type JobSpec struct {
          ...
          // This field is alpha-level and is only honored by servers that
          // enable the TTLAfterFinished feature.
          TTLSecondsAfterFinished *int32 `json:"ttlSecondsAfterFinished,omitempty"
      }
      
    • API 服务器的行为将因字段而异。如果未启用相应的功能门限,则某些 alpha 字段将被拒绝,而某些将被忽略。这在字段描述中有文档记录(参见前面示例中的 TTLSecondsAfterFinished)。

  • 此外,API 组版本在访问 API 时起到了作用。在同一资源的不同版本之间,API 服务器会进行即时转换。也就是说,您可以访问在一个版本(例如 v1beta1)中创建的对象,而无需在应用程序中进行任何其他工作,即在任何其他支持的版本(例如 v1)中。这对于构建向后和向前兼容的应用程序非常方便。

    • 存储在 etcd 中的每个对象都以特定版本存储。默认情况下,这称为该资源的存储版本。虽然存储版本可以在 Kubernetes 的不同版本中更改,但在撰写本文时,存储在 etcd 中的对象不会自动更新。因此,集群管理员必须确保在更新 Kubernetes 集群时及时进行迁移,以防止旧版本支持被弃用。目前没有通用的迁移机制,并且迁移因 Kubernetes 发行版而异。

    • 对于应用程序开发人员而言,这些操作工作实际上并不重要。即时转换将确保应用程序对集群中的对象有一个统一的视图。应用程序甚至不会注意到使用的是哪个存储版本。存储版本控制对编写的 Go 代码是透明的。

Kubernetes 中的 Go 对象

在“创建和使用客户端”中,我们看到如何为核心组创建客户端,以便访问 Kubernetes 集群中的 pods。接下来,我们希望更详细地查看在 Go 世界中的 pod — 或者说任何其他 Kubernetes 资源的 — 是什么。

Kubernetes 资源——或更确切地说,作为 API 服务器资源提供的对象——作为结构体表示。根据问题中的种类,它们的字段当然不同。但另一方面,它们共享一个通用结构。

从类型系统的角度来看,Kubernetes 对象实现了一个名为runtime.Object的 Go 接口,位于 k8s.io/apimachinery/pkg/runtime 包中,实际上非常简单:

// Object interface must be supported by all API types registered with Scheme.
// Since objects in a scheme are expected to be serialized to the wire, the
// interface an Object must provide to the Scheme allows serializers to set
// the kind, version, and group the object is represented as. An Object may
// choose to return a no-op ObjectKindAccessor in cases where it is not
// expected to be serialized.
type Object interface {
    GetObjectKind() schema.ObjectKind
    DeepCopyObject() Object
}

在这里,schema.ObjectKind(来自 k8s.io/apimachinery/pkg/runtime/schema 包)是另一个简单的接口:

// All objects that are serialized from a Scheme encode their type information.
// This interface is used by serialization to set type information from the
// Scheme onto the serialized version of an object. For objects that cannot
// be serialized or have unique requirements, this interface may be a no-op.
type ObjectKind interface {
    // SetGroupVersionKind sets or clears the intended serialized kind of an
    // object. Passing kind nil should clear the current setting.
    SetGroupVersionKind(kind GroupVersionKind)
    // GroupVersionKind returns the stored group, version, and kind of an
    // object, or nil if the object does not expose or provide these fields.
    GroupVersionKind() GroupVersionKind
}

换句话说,Go 中的 Kubernetes 对象是一个数据结构,可以:

  • 返回设置 GroupVersionKind

  • 进行深度复制

深度复制 是数据结构的克隆,不与原始对象共享任何内存。它在代码需要变异对象而不修改原始对象时使用。有关在 Kubernetes 中实现深度复制的详细信息,请参阅 “全局标签”。

简而言之,一个对象存储其类型并允许克隆。

TypeMeta

虽然 runtime.Object 只是一个接口,但我们想知道它是如何实际实现的。来自 k8s.io/api 的 Kubernetes 对象通过嵌入来实现 schema.ObjectKind 的类型获取器和设置器,该结构从 k8s.io/apimachinery/meta/v1 包中的 metav1.TypeMeta 结构中继承:

// TypeMeta describes an individual object in an API response or request
// with strings representing the type of the object and its API schema version.
// Structures that are versioned or persisted should inline TypeMeta.
//
// +k8s:deepcopy-gen=false
type TypeMeta struct {
    // 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.
    // +optional
    Kind string `json:"kind,omitempty" protobuf:"bytes,1,opt,name=kind"`

    // 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.
    // +optional
    APIVersion string `json:"apiVersion,omitempty"`
}

有了这些,Go 中的 pod 声明看起来像这样:

// Pod is a collection of containers that can run on a host. This resource is
// created by clients and scheduled onto hosts.
type Pod struct {
    metav1.TypeMeta `json:",inline"`
    // Standard object's metadata.
    // +optional
    metav1.ObjectMeta `json:"metadata,omitempty"`

    // Specification of the desired behavior of the pod.
    // +optional
    Spec PodSpec `json:"spec,omitempty"`

    // Most recently observed status of the pod.
    // This data may not be up to date.
    // Populated by the system.
    // Read-only.
    // +optional
    Status PodStatus `json:"status,omitempty"`
}

正如你所见,TypeMeta 被嵌入其中。此外,pod 类型具有 JSON 标签,也声明了 TypeMeta 是内联的。

注意

这个 ",inline" 标签实际上在 Golang 的 JSON 解/编码器中是多余的:嵌入结构体会自动内联。

这在 YAML 解/编码器 go-yaml/yaml 中是不同的,该解/编码器在 Kubernetes 初期代码中与 JSON 并行使用。我们继承了 那个时代的内联标签,但今天它仅仅是文档,没有任何实际作用。

k8s.io/apimachinery/pkg/runtime/serializer/yaml 中的 YAML 序列化器使用 sigs.k8s.io/yaml 的编组和解组函数。而这些又通过 interface{} 编码和解码 YAML,并使用 Golang API 结构的 JSON 编码器和解码器。

这与 pod 的 YAML 表示相匹配,所有 Kubernetes 用户都知道:^(2)

apiVersion: v1
kind: Pod
metadata:
  namespace: default
  name: example
spec:
  containers:
  - name: hello
    image: debian:latest
    command:
    - /bin/sh
    args:
    - -c
    - echo "hello world"; sleep 10000

版本存储在 TypeMeta.APIVersion 中,种类存储在 TypeMeta.Kind 中。

当在 “创建和使用客户端” 中运行示例以从集群获取一个 pod 时,请注意客户端返回的 pod 对象实际上未设置种类和版本。基于 client-go 的应用程序约定这些字段在内存中为空,并且只有在被编组为 JSON 或 protobuf 时才会填充实际值。这由客户端自动完成,更准确地说是由版本化序列化器完成。

换句话说,基于client-go的应用程序会检查对象的 Golang 类型,以确定手头的对象。在其他框架中可能会有所不同,比如操作员 SDK(参见“操作员 SDK”)。

ObjectMeta

除了TypeMeta之外,大多数顶层对象都具有类型为metav1.ObjectMeta的字段,同样来自k8s.io/apimachinery/pkg/meta/v1包:

type ObjectMeta struct {
    Name string `json:"name,omitempty"`
    Namespace string `json:"namespace,omitempty"`
    UID types.UID `json:"uid,omitempty"`
    ResourceVersion string `json:"resourceVersion,omitempty"`
    CreationTimestamp Time `json:"creationTimestamp,omitempty"`
    DeletionTimestamp *Time `json:"deletionTimestamp,omitempty"`
    Labels map[string]string `json:"labels,omitempty"`
    Annotations map[string]string `json:"annotations,omitempty"`
    ...
}

在 JSON 或 YAML 中,这些字段位于metadata下。例如,对于先前的 pod,metav1.ObjectMeta存储:

metadata:
  namespace: default
  name: example

通常情况下,它包含所有的元级信息,如名称、命名空间、资源版本(不要与 API 组版本混淆)、几个时间戳以及著名的标签和注释,这些都是ObjectMeta的一部分。详细讨论ObjectMeta字段,请参阅“类型解剖”。

在“乐观并发”中已经讨论过资源版本。在client-go代码中几乎不会读取或写入。但它是使整个系统工作的 Kubernetes 字段之一。resourceVersion作为ObjectMeta的一部分,因为每个具有嵌入ObjectMeta的对象对应于etcd中的键,其中resourceVersion值起源于此。

spec 和 status

最后,几乎每个顶级对象都有一个spec和一个status部分。这一约定来自 Kubernetes API 的声明性质:spec是用户的愿望,而status是这个愿望的结果,通常由系统中的控制器填充。有关 Kubernetes 中控制器的详细讨论,请参阅“控制器和操作员”。

系统中specstatus约定只有几个例外,例如核心组中的端点或类似ClusterRole的 RBAC 对象。

客户端集

在“创建和使用客户端”的介绍示例中,我们看到kubernetes.NewForConfig(config)给了我们一个客户端集。客户端集提供对多个 API 组和资源的客户端访问。对于来自k8s.io/client-go/kuberneteskubernetes.NewForConfig(config),我们获得了所有在k8s.io/api中定义的 API 组和资源的访问权限。这是整个由 Kubernetes API 服务器提供的资源集,除了一些例外,如APIServices(用于聚合 API 服务器)和CustomResourceDefinition(参见第四章)。

在第五章中,我们将解释这些客户端集是如何从 API 类型(k8s.io/api,在这种情况下)实际生成的。具有自定义 API 的第三方项目使用的不仅仅是 Kubernetes 客户端集。所有客户端集共同拥有的是 REST 配置(例如,由clientcmd.BuildConfigFromFlags("", *kubeconfig)返回,就像示例中的那样)。

Kubernetes 本地资源的k8s.io/client-go/kubernetes/typed中的客户端集主接口如下所示:

type Interface interface {
    Discovery() discovery.DiscoveryInterface
    AppsV1() appsv1.AppsV1Interface
    AppsV1beta1() appsv1beta1.AppsV1beta1Interface
    AppsV1beta2() appsv1beta2.AppsV1beta2Interface
    AuthenticationV1() authenticationv1.AuthenticationV1Interface
    AuthenticationV1beta1() authenticationv1beta1.AuthenticationV1beta1Interface
    AuthorizationV1() authorizationv1.AuthorizationV1Interface
    AuthorizationV1beta1() authorizationv1beta1.AuthorizationV1beta1Interface

    ...
}

在此接口中曾经存在未版本化的方法——例如,只有 Apps() appsv1.AppsV1Interface——但它们在 Kubernetes 1.14 及基于 client-go 11.0 之后已被弃用。如前所述,明确指定应用程序使用的 API 组的版本被视为一种良好的实践。

每个客户端集还可以访问发现客户端(它将被 RESTMappers 使用;参见 “REST 映射” 和 “使用命令行访问 API”)。

在每个 GroupVersion 方法的背后(例如 AppsV1beta1),我们找到 API 组的资源——例如:

type AppsV1beta1Interface interface {
    RESTClient() rest.Interface
    ControllerRevisionsGetter
    DeploymentsGetter
    StatefulSetsGetter
}

其中 RESTClient 是一个通用的 REST 客户端,每个资源都有一个接口,如下所示:

// DeploymentsGetter has a method to return a DeploymentInterface.
// A group's client should implement this interface.
type DeploymentsGetter interface {
    Deployments(namespace string) DeploymentInterface
}

// DeploymentInterface has methods to work with Deployment resources.
type DeploymentInterface interface {
    Create(*v1beta1.Deployment) (*v1beta1.Deployment, error)
    Update(*v1beta1.Deployment) (*v1beta1.Deployment, error)
    UpdateStatus(*v1beta1.Deployment) (*v1beta1.Deployment, error)
    Delete(name string, options *v1.DeleteOptions) error
    DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error
    Get(name string, options v1.GetOptions) (*v1beta1.Deployment, error)
    List(opts v1.ListOptions) (*v1beta1.DeploymentList, error)
    Watch(opts v1.ListOptions) (watch.Interface, error)
    Patch(name string, pt types.PatchType, data []byte, subresources ...string)
        (result *v1beta1.Deployment, err error)
    DeploymentExpansion
}

根据资源的范围——即集群范围还是命名空间范围——访问器(这里是 DeploymentGetter)可能有也可能没有 namespace 参数。

DeploymentInterface 提供对资源支持的所有动词的访问。其中大多数都是不言自明的,但需要额外评论的将在接下来描述。

状态子资源:UpdateStatus

部署有所谓的状态子资源。这意味着 UpdateStatus 使用一个额外的 HTTP 终端点,后缀为 /status。虽然在 /apis/apps/v1beta1/namespaces/ns/deployments/name** 终端点上的更新只能更改部署的规范,但在 /apis/apps/v1beta1/namespaces/ns/deployments/name/status 终端点上的更新只能更改对象的状态。这在为规范更新(由人类完成)和状态更新(由控制器完成)设置不同权限时非常有用。

默认情况下,client-gen(参见 “client-gen 标签”)会生成 UpdateStatus() 方法。方法的存在并不保证资源实际上支持子资源。在处理 CRD 时这一点非常重要,见 “子资源”。

列出和删除

DeleteCollection 允许我们一次性删除命名空间中的多个对象。ListOptions 参数允许我们使用字段标签选择器定义要删除的对象:

type ListOptions struct {
    ...

    // A selector to restrict the list of returned objects by their labels.
    // Defaults to everything.
    // +optional
    LabelSelector string `json:"labelSelector,omitempty"`
    // A selector to restrict the list of returned objects by their fields.
    // Defaults to everything.
    // +optional
    FieldSelector string `json:"fieldSelector,omitempty"`

    ...
}

Watches

Watch 为所有对象的更改(添加、删除和更新)提供事件接口。来自 k8s.io/apimachinery/pkg/watch 的返回的 watch.Interface 如下所示:

// Interface can be implemented by anything that knows how to watch and
// report changes.
type Interface interface {
    // Stops watching. Will close the channel returned by ResultChan(). Releases
    // any resources used by the watch.
    Stop()

    // Returns a chan which will receive all the events. If an error occurs
    // or Stop() is called, this channel will be closed, in which case the
    // watch should be completely cleaned up.
    ResultChan() <-chan Event
}

watch 接口的结果通道返回三种类型的事件:

// EventType defines the possible types of events.
type EventType string

const (
    Added    EventType = "ADDED"
    Modified EventType = "MODIFIED"
    Deleted  EventType = "DELETED"
    Error    EventType = "ERROR"
)

// Event represents a single event to a watched resource.
// +k8s:deepcopy-gen=true
type Event struct {
    Type EventType

    // Object is:
    //  * If Type is Added or Modified: the new state of the object.
    //  * If Type is Deleted: the state of the object immediately before
    //    deletion.
    //  * If Type is Error: *api.Status is recommended; other types may
    //    make sense depending on context.
    Object runtime.Object
}

虽然直接使用这个接口很诱人,但实际上不鼓励这样做,而是推荐使用 informers(参见 “Informers 和缓存”)。Informers 是这个事件接口和带有索引查找的内存缓存的组合。这是观察事件最常见的用例。在幕后,informers 首先调用客户端的 List 方法获取所有对象的集合(作为缓存的基线),然后调用 Watch 方法更新缓存。它们可以正确处理错误条件——即从网络问题或其他集群问题中恢复。

客户端扩展

DeploymentExpansion 实际上是一个空接口。它用于以声明方式添加自定义客户端行为,但在当前 Kubernetes 中很少使用。相反,客户端生成器允许我们以声明方式添加自定义方法(参见 “client-gen Tags”)。

再次注意,DeploymentInterface 中的所有方法既不期望 TypeMeta 字段 KindAPIVersion 中的有效信息,也不在 Get()List() 方法中设置这些字段(另见 TypeMeta)。这些字段仅在传输时填充真实的值。

客户端选项

值得注意的是,在创建客户端集时,我们可以设置不同的选项。在 “版本和兼容性” 之前的注释中,我们看到我们可以切换到原生 Kubernetes 类型的 protobuf 传输格式。Protobuf 比 JSON 更高效(在空间和客户端与服务器的 CPU 负载方面),因此更可取。

出于调试目的和指标的可读性,区分访问 API 服务器的不同客户端通常很有帮助。为此,我们可以在 REST 配置中设置 用户代理 字段。默认值是 binary/version (os/arch) kubernetes/commit,例如,kubectl 将使用类似 kubectl/v1.14.0 (darwin/amd64) kubernetes/d654b49 的用户代理。如果这种模式不足以满足设置要求,可以进行定制,如下所示:

cfg, err := clientcmd.BuildConfigFromFlags("", *kubeconfig)
cfg.AcceptContentTypes = "application/vnd.kubernetes.protobuf,application/json"
cfg.UserAgent = fmt.Sprintf(
    "book-example/v1.0 (%s/%s) kubernetes/v1.0",
    runtime.GOOS, runtime.GOARCH
)
clientset, err := kubernetes.NewForConfig(cfg)

REST 配置中经常被覆盖的其他值包括客户端端的 速率限制超时

// Config holds the common attributes that can be passed to a Kubernetes
// client on initialization.
type Config struct {
    ...

    // QPS indicates the maximum QPS to the master from this client.
    // If it's zero, the created RESTClient will use DefaultQPS: 5
    QPS float32

    // Maximum burst for throttle.
    // If it's zero, the created RESTClient will use DefaultBurst: 10.
    Burst int

    // The maximum length of time to wait before giving up on a server request.
    // A value of zero means no timeout.
    Timeout time.Duration

    ...
}

QPS 值默认为每秒 5 个请求,突发为 10

超时在客户端 REST 配置中没有默认值,至少 Kubernetes API 服务器会在 60 秒后超时每个不是 长期运行 请求的请求。长期运行请求可以是观察请求或对子资源如 /exec/portforward/proxy 的无边界请求。

Informers 和 缓存

“客户端集” 中的客户端接口包括 Watch 动词,提供了一个事件接口,用于响应对象的变更(添加、移除、更新)。Informers 为观察的最常见用例提供了更高级的编程接口:内存缓存和通过名称或其他属性在内存中快速索引查找对象。

每当控制器需要对象时都会访问 API 服务器,这会给系统带来很大的负载。使用内存中的缓存通过 informers 是解决这个问题的方案。此外,informers 几乎可以实时地响应对象的变更,而不需要轮询请求。

图 3-5 展示了 informers 的概念模式;特别是它们:

  • 作为事件从 API 服务器获取输入。

  • 提供一个名为 Lister 的类似客户端的接口,用于从内存缓存中获取和列出对象。

  • 注册用于添加、移除和更新的事件处理程序。

  • 使用 store 实现内存缓存。

Informers

图 3-5. Informers

通知器还具有先进的错误处理行为:当长时间运行的监视连接断开时,它们通过尝试另一个监视请求来从中恢复,捕捉事件流而不会丢失任何事件。如果中断时间很长,并且 API 服务器在新的监视请求成功之前因etcd从其数据库中清除了事件而丢失了事件,则通知器将重新列出所有对象。

除了重新列出,还有一个可配置的重新同步周期,用于在内存缓存和业务逻辑之间进行协调:每当此周期经过时,将为所有对象调用注册的事件处理程序。常见的值以分钟为单位(例如,10 分钟或 30 分钟)。

警告

重新同步仅在内存中进行,不会触发对服务器的调用。这曾经有所不同,但因为监视机制的错误行为得到了改进,使得重新列出不再必要,最终进行了更改

所有这些先进且经过实战验证的错误处理行为是使用通知器而不是直接使用客户端Watch()方法部署自定义逻辑的一个很好的理由。通知器在 Kubernetes 自身的各个地方都被使用,并且是 Kubernetes API 设计中的主要架构概念之一。

虽然通知器优于轮询,但它们会给 API 服务器带来负载。一个二进制文件应该仅实例化每个 GroupVersionResource 一个通知器。为了简化通知器的共享,我们可以使用共享的通知器工厂来实例化通知器。

共享的通知器工厂允许在应用程序中为相同资源共享通知器。换句话说,不同的控制循环可以在幕后共用同一个对 API 服务器的监视连接。例如,kube-controller-manager,Kubernetes 集群的主要组件之一(参见“API 服务器”),有多个两位数的控制器。但对于每个资源(例如 pods),在进程中只有一个通知器。

提示

总是使用共享的通知器工厂来实例化通知器。不要试图手动实例化通知器。开销很小,一个不使用共享通知器的复杂控制器二进制文件可能在某个地方为同一资源打开多个监视连接。

从 REST 配置开始(参见“创建和使用客户端”),使用客户端集轻松创建共享的通知器工厂。通知器由代码生成器生成,并作为client-go的一部分提供给标准 Kubernetes 资源在k8s.io/client-go/informers中:

import (
    ...
    "k8s.io/client-go/informers"
)
...
clientset, err := kubernetes.NewForConfig(config)
informerFactory := informers.NewSharedInformerFactory(clientset, time.Second*30)
podInformer := informerFactory.Core().V1().Pods()
podInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
    AddFunc: func(new interface{}) {...},
    UpdateFunc: func(old, new interface{}) {...},
    DeleteFunc: func(obj interface{}) {...},
})
informerFactory.Start(wait.NeverStop)
informerFactory.WaitForCacheSync(wait.NeverStop)
pod, err := podInformer.Lister().Pods("programming-kubernetes").Get("client-go")

本示例展示了如何获取 pods 的共享通知器。

您可以看到,通知器允许为三种情况添加更新删除添加事件处理程序。这些通常用于触发控制器的业务逻辑,即再次处理特定对象(参见“控制器和操作员”)。通常,这些处理程序只是将修改后的对象添加到工作队列中。

还要注意,可以添加许多事件处理程序。整个共享 Informer 工厂概念之所以存在,是因为在具有多个控制循环的控制器二进制文件中,每个循环都安装事件处理程序来将对象添加到它们自己的工作队列中。

在注册处理程序之后,共享 Informer 工厂必须启动。在幕后有 Go 协程来实际调用 API 服务器。Start方法(带有停止通道以控制生命周期)启动这些 Go 协程,而WaitForCacheSync()方法则使代码等待第一个List调用完成。如果控制器逻辑要求填充缓存,则WaitForCacheSync调用是必不可少的。

通常,手表背后的事件接口会导致某种程度的滞后。在进行适当的容量规划设置时,这种滞后并不严重。当然,使用度量衡来测量这种滞后是一个好的实践。但无论如何,这种滞后是存在的,因此应用程序的逻辑必须构建得足够健壮,以免影响代码的行为。

警告

Informer 的滞后可能导致控制器在直接使用client-go在 API 服务器上进行更改时与 Informer 知道的世界状态之间发生竞争。

如果控制器更改一个对象,则同一进程中的 Informer 必须等待相应事件到达并更新内存存储。这个过程不是即时的,在前一个更改可见之前可能会通过另一个触发器启动另一个控制器工作循环。

在这个示例中,30 秒的重新同步间隔会导致所有事件的完整集合发送到注册的UpdateFunc,以便控制器逻辑能够将其状态与 API 服务器的状态协调。通过比较ObjectMeta.resourceVersion字段,可以区分真实更新和重新同步。

提示

选择一个良好的重新同步间隔取决于上下文。例如,30 秒相当短。在许多情况下,几分钟,甚至 30 分钟,是一个不错的选择。在最坏的情况下,30 分钟意味着需要 30 分钟才能通过协调来修复代码中的错误(例如,由于错误处理不当而丢失信号)。

还要注意,在例子中调用Get("client-go")的最后一行纯粹是在内存中进行的;没有访问 API 服务器。在内存存储中的对象不能直接修改。相反,必须使用客户端集来访问资源进行任何写操作。然后,Informer 将从 API 服务器获取事件并更新其内存存储。

在示例中,Informer 构造函数NewSharedInformerFactory会在存储中缓存所有命名空间中资源的所有对象。如果这对应用程序来说太多,还有一个更灵活的替代构造函数:

// NewFilteredSharedInformerFactory constructs a new instance of
// sharedInformerFactory. Listers obtained via this sharedInformerFactory will be
// subject to the same filters as specified here.
func NewFilteredSharedInformerFactory(
    client versioned.Interface, defaultResync time.Duration,
    namespace string,
    tweakListOptions internalinterfaces.TweakListOptionsFunc
) SharedInformerFactor

type TweakListOptionsFunc func(*v1.ListOptions)

它允许我们指定一个命名空间,并传递一个TweakListOptionsFunc,它可以改变用于通过客户端的ListWatch调用列出和监视对象的ListOptions结构体。例如,它可以用来设置标签字段选择器

Informers 是控制器的构建块之一。在第六章中,我们将看到一个基于client-go的典型控制器的样子。在客户端和 Informers 之后,第三个主要的构建块是工作队列。现在让我们来看一下。

工作队列

工作队列是一种数据结构。您可以向队列中添加元素并取出元素,按照队列预定义的顺序。严格来说,这种类型的队列被称为优先级队列client-go提供了一个强大的实现,用于构建控制器在k8s.io/client-go/util/workqueue

更确切地说,该软件包包含了几个用于不同目的的变体。所有变体实现的基本接口看起来是这样的:

type Interface interface {
    Add(item interface{})
    Len() int
    Get() (item interface{}, shutdown bool)
    Done(item interface{})
    ShutDown()
    ShuttingDown() bool
}

在这里,Add(item)添加一个项目,Len()返回长度,Get()返回具有最高优先级的项目(并阻塞直到可用)。每个由Get()返回的项目在控制器处理完毕后需要调用Done(item)。与此同时,重复的Add(item)只会将项目标记为脏,直到调用了Done(item)后重新添加它。

以下队列类型是从这个通用接口派生的:

  • DelayingInterface 可以在稍后时间添加项目。这使得在失败后重新排队项目更容易,而不会陷入热循环:

    type DelayingInterface interface {
        Interface
        // AddAfter adds an item to the workqueue after the
        // indicated duration has passed.
        AddAfter(item interface{}, duration time.Duration)
    }
    
  • RateLimitingInterface 限制添加到队列中的项目。它扩展了DelayingInterface

    type RateLimitingInterface interface {
        DelayingInterface
    
        // AddRateLimited adds an item to the workqueue after the rate
        // limiter says it's OK.
        AddRateLimited(item interface{})
    
        // Forget indicates that an item is finished being retried.
        // It doesn't matter whether it's for perm failing or success;
        // we'll stop the rate limiter from tracking it. This only clears
        // the `rateLimiter`; you still have to call `Done` on the queue.
        Forget(item interface{})
    
        // NumRequeues returns back how many times the item was requeued.
        NumRequeues(item interface{}) int
    }
    

    这里最有趣的是Forget(item)方法:它重置给定项目的退避。通常,在成功处理项目后会调用它。

    可以将速率限制算法传递给构造函数NewRateLimitingQueue。同一软件包中定义了几种速率限制器,如BucketRateLimiterItemExponentialFailureRateLimiterItemFastSlowRateLimiterMaxOfRateLimiter。有关更多详细信息,请参阅软件包文档。大多数控制器将只使用DefaultControllerRateLimiter() *RateLimiter函数,这给出:

    • 以 5 毫秒开始的指数退避,最高达 1,000 秒,每次错误时延迟加倍。

    • 每秒最大 10 个项目和 100 个项目突发

根据上下文,您可能希望自定义这些值。对于某些控制器应用程序来说,每个项目的最大 1,000 秒退避是很多的。

深入了解 API Machinery

API Machinery 存储库实现了 Kubernetes 类型系统的基础。但是,这个类型系统到底是什么?类型首先是什么?

术语类型实际上在 API Machinery 的术语中并不存在。相反,它指的是种类

种类

类型被分为 API 组并进行版本化,正如我们在 “API Terminology” 中已经看到的那样。因此,API Machinery 代码库中的核心术语是 GroupVersionKind,简称为 GVK

在 Go 语言中,每个 GVK 对应于一个 Go 类型。相反,一个 Go 类型可以属于多个 GVK。

类型并不正式地一对一映射到 HTTP 路径。许多类型有用于访问给定类型对象的 HTTP REST 终点。但也有一些没有任何 HTTP 终点的类型(例如,admission.k8s.io/v1beta1.AdmissionReview,用于调用 webhook)。还有一些类型从多个终点返回,例如,meta.k8s.io/v1.Status,它由所有终点返回以报告非对象状态,如错误。

根据惯例,类型采用类似单词的 CamelCase 格式,并且通常是单数形式。根据上下文,它们的具体格式不同。对于 CustomResourceDefinition 类型,它必须是 DNS 路径标签(RFC 1035)。

资源

与类型并行的是资源,正如我们在 “API Terminology” 中看到的那样,还有一个 resource 概念。资源再次分组并进行版本化,导致术语 GroupVersionResource,简称为 GVR

每个 GVR 对应一个 HTTP(基本)路径。GVR 用于标识 Kubernetes API 的 REST 终点。例如,GVR apps/v1.deployments 映射到 /apis/apps/v1/namespaces/namespace/deployments

客户端库使用此映射来构造访问 GVR 的 HTTP 路径。

按照惯例,资源是小写且复数形式,通常对应于并行类型的复数词。它们必须符合 DNS 路径标签格式(RFC 1025)。由于资源直接映射到 HTTP 路径,这一点并不奇怪。

REST 映射

GVK 到 GVR 的映射称为 REST 映射

RESTMapper 是一个 Golang 接口,它使我们能够请求 GVK 的 GVR:

RESTMapping(gk schema.GroupKind, versions ...string) (*RESTMapping, error)

右侧的 RESTMapping 类型看起来像这样:

type RESTMapping struct {
    // Resource is the GroupVersionResource (location) for this endpoint.
    Resource schema.GroupVersionResource.

    // GroupVersionKind is the GroupVersionKind (data format) to submit
    // to this endpoint.
    GroupVersionKind schema.GroupVersionKind

    // Scope contains the information needed to deal with REST Resources
    // that are in a resource hierarchy.
    Scope RESTScope
}

此外,RESTMapper 提供了许多便利函数:

// KindFor takes a partial resource and returns the single match.
// Returns an error if there are multiple matches.
KindFor(resource schema.GroupVersionResource) (schema.GroupVersionKind, error)

// KindsFor takes a partial resource and returns the list of potential
// kinds in priority order.
KindsFor(resource schema.GroupVersionResource) ([]schema.GroupVersionKind, error)

// ResourceFor takes a partial resource and returns the single match.
// Returns an error if there are multiple matches.
ResourceFor(input schema.GroupVersionResource) (schema.GroupVersionResource, error)

// ResourcesFor takes a partial resource and returns the list of potential
// resource in priority order.
ResourcesFor(input schema.GroupVersionResource) ([]schema.GroupVersionResource, error)

// RESTMappings returns all resource mappings for the provided group kind
// if no version search is provided. Otherwise identifies a preferred resource
// mapping for the provided version(s).
RESTMappings(gk schema.GroupKind, versions ...string) ([]*RESTMapping, error)

在这里,部分 GVR 意味着并非所有字段都已设置。例如,想象一下你输入 **kubectl get pods**。在这种情况下,组和版本是缺失的。具有足够信息的 RESTMapper 可能仍然能够将其映射到 v1 Pods 类型。

对于前述部署示例,一个了解部署的 RESTMapper(稍后会详细介绍这意味着什么)将 apps/v1.Deployment 映射到 apps/v1.deployments 作为命名空间资源。

RESTMapper 接口有多种不同的实现方式。对于客户端应用程序来说,最重要的是基于发现的 DeferredDiscoveryRESTMapper,位于包 k8s.io/client-go/restmapper 中:它使用来自 Kubernetes API 服务器的发现信息动态构建 REST 映射。它还可以处理像自定义资源这样的非核心资源。

Scheme

我们在 Kubernetes 类型系统的上下文中想要展示的最后一个核心概念是方案,位于包 k8s.io/apimachinery/pkg/runtime 中。

一个方案连接了 Golang 的世界和独立于实现的 GVK 的世界。方案的主要特性是将 Golang 类型映射到可能的 GVK:

func (s *Scheme) ObjectKinds(obj Object) ([]schema.GroupVersionKind, bool, error)

正如我们在 “在 Go 中的 Kubernetes 对象” 中看到的,一个对象可以通过 GetObjectKind() schema.ObjectKind 方法返回其组和种类。然而,大多数时间这些值都是空的,因此在标识方面相当无用。

相反,方案通过反射获取给定对象的 Golang 类型,并将其映射到该 Golang 类型的注册 GVK(s)。为此,当然,这些 Golang 类型必须像这样注册到方案中:

scheme.AddKnownTypes(schema.GroupVersionKind{"", "v1", "Pod"}, &Pod{})

方案不仅用于注册 Golang 类型及其 GVK,还用于存储转换函数和默认值(见 图 3-6)。我们将在 第八章 更详细地讨论转换和默认值。它是实现编码器和解码器的数据源。

该方案将 Golang 数据类型与 GVK、转换和默认值连接起来

图 3-6. 该方案将 Golang 数据类型与 GVK、转换和默认值连接起来

对于 Kubernetes 核心类型,client-go 客户端集中有一个预定义的方案,位于包 k8s.io/client-go/kubernetes/scheme 中,并预先注册了所有类型。实际上,每个由 client-gen 代码生成器生成的客户端集都有子包 scheme,其中包含客户端集中所有组和版本中的所有类型。

通过这个方案,我们结束了对 API Machinery 概念的深入探讨。如果你只记住这些概念中的一件事,那就是 图 3-7。

从 Golang 类型到 GVK 再到 GVR 再到 HTTP 路径 —— API Machinery 的核心概述

图 3-7. 从 Golang 类型到 GVK、GVR 再到 HTTP 路径 —— API Machinery 的核心概述

Vendoring

在本章中,我们看到 k8s.io/client-gok8s.io/apik8s.io/apimachinery 在 Golang 中的 Kubernetes 编程中占据了核心位置。Golang 使用 vendoring 将这些库包含在第三方应用程序源代码库中。

在 Golang 社区中,依赖管理一直是一个动态的目标。在撰写本文时,有几种常见的依赖管理工具,如godepsdepglide。同时,Go 1.12 开始支持 Go 模块,这可能会成为将来 Go 社区的标准依赖管理方法,但目前在 Kubernetes 生态系统中尚未准备好。

大多数项目现在使用depglide。Kubernetes 本身在github.com/kubernetes/kubernetes上已经在 1.15 开发周期中转向了 Go 模块。以下评论适用于所有这些依赖管理工具。

k8s.io/**仓库中,每个支持的依赖版本的真实来源是已发布的Godeps/Godeps.json*文件。强调任何其他依赖选择都可能破坏库的功能性是非常重要的。

查看“客户端库”获取有关k8s.io/client-gok8s.io/apik8s.io/apimachinery的发布标签及其兼容性的更多信息。

glide

使用glide的项目可以利用其读取任何依赖变更时的Godeps/Godeps.json文件的能力。这已被证明非常可靠:开发者只需声明正确的k8s.io/client-go版本,glide将选择正确版本的k8s.io/apimachineryk8s.io/api和其他依赖项。

对于 GitHub 上的一些项目,glide.yaml文件可能如下所示:

package: github.com/book/example
import:
- package: k8s.io/client-go
  version: v10.0.0
...

通过执行glide install -v,将k8s.io/client-go及其依赖项下载到本地vendor/包中。这里的-v表示从依赖库的vendor/包中删除。这对我们的目的是必需的。

如果通过编辑glide.yaml更新到新版本的client-go,则执行glide update -v将下载新的依赖项,并确保版本正确。

dep

dep通常被认为比glide更强大和先进。长期以来,它被视为生态系统中glide的继任者,并且似乎注定成为 Go 的终极依赖管理工具。在撰写本文时,其未来尚不明朗,而 Go 模块似乎是前进的道路。

client-go的上下文中,要特别注意dep的一些限制:

  • dep在首次运行dep init时会读取Godeps/Godeps.json

  • dep在后续的dep ensure -update调用中不会读取Godeps/Godeps.json

这意味着当client-go版本在Godep.toml中更新时,依赖解析很可能是错误的。这是不幸的,因为它要求开发者显式地并通常是手动地声明所有依赖关系。

一个工作和一致的Godep.toml文件看起来像这样:

[[constraint]]
  name = "k8s.io/api"
  version = "kubernetes-1.13.0"

[[constraint]]
  name = "k8s.io/apimachinery"
  version = "kubernetes-1.13.0"

[[constraint]]
  name = "k8s.io/client-go"
  version = "10.0.0"

[prune]
  go-tests = true
  unused-packages = true

# the following overrides are necessary to enforce
# the given version, even though our
# code does not import the packages directly.
[[override]]
  name = "k8s.io/api"
  version = "kubernetes-1.13.0"

[[override]]
  name = "k8s.io/apimachinery"
  version = "kubernetes-1.13.0"

[[override]]
  name = "k8s.io/client-go"
  version = "10.0.0"
警告

Gopkg.toml 不仅为 k8s.io/apimachineryk8s.io/api 声明了显式版本,还为它们提供了覆盖。当项目启动时没有显式导入这两个仓库的包时,这是必要的。在这种情况下,如果没有这些覆盖,dep 将从一开始忽略约束,并且开发人员将从一开始就得到错误的依赖项。

即使这里显示的 Gopkg.toml 文件在技术上也不正确,因为它是不完整的,没有声明对 client-go 所需所有其他库的依赖关系。在过去,一个上游库损坏了 client-go 的编译。因此,如果使用 dep 进行依赖管理,务必要做好这种情况的准备。

Go Modules

Go 模块是 Golang 中依赖管理的未来。它们在 Go 1.11 中引入了初步支持,并在 1.12 版本中进一步稳定。像 go rungo get 这样的多个命令通过设置 GO111MODULE=on 环境变量来支持 Go 模块。在 Go 1.13 中,这将成为默认设置。

Go 模块由项目根目录中的 go.mod 文件驱动。以下是我们 github.com/programming-kubernetes/pizza-apiserver 项目的 go.mod 文件节选,详见第八章。

module github.com/programming-kubernetes/pizza-apiserver

require (
    ...
    k8s.io/api v0.0.0-20190222213804-5cb15d344471 // indirect
    k8s.io/apimachinery v0.0.0-20190221213512-86fb29eff628
    k8s.io/apiserver v0.0.0-20190319190228-a4358799e4fe
    k8s.io/client-go v2.0.0-alpha.0.0.20190307161346-7621a5ebb88b+incompatible
    k8s.io/klog v0.2.1-0.20190311220638-291f19f84ceb
    k8s.io/kube-openapi v0.0.0-20190320154901-c59034cc13d5 // indirect
    k8s.io/utils v0.0.0-20190308190857-21c4ce38f2a7 // indirect
    sigs.k8s.io/yaml v1.1.0 // indirect
)

client-go v11.0.0—与 Kubernetes 1.14 匹配—以及早期版本并没有明确支持 Go 模块。尽管如此,你仍然可以在 Kubernetes 库中使用 Go 模块,正如前面的例子所示。

只要 client-go 和其他 Kubernetes 仓库没有发布 go.mod 文件(至少在 Kubernetes 1.15 之前),就必须手动选择正确的版本。换句话说,你需要完整列出与 client-goGodeps/Godeps.json 依赖修订版本匹配的所有依赖项。

还要注意前面示例中不太易读的修订版本。它们是从现有标签派生的伪版本,或者如果没有标签,则使用 v0.0.0 作为前缀。更糟糕的是,你可以在该文件中引用带有标签的版本,但是 Go 模块命令会在下一次运行时用伪版本替换它们。

client-go v12.0.0—与 Kubernetes 1.15 匹配—我们将提供一个 go.mod 文件,并停止支持所有其他供应工具(请参阅相应的提案文件)。所提供的 go.mod 文件包含所有依赖项,因此你的项目 go.mod 文件不再需要手动列出所有传递依赖项。在后续版本中,可能会更改标签方案,以修复难看的伪修订版本,并用正确的语义版本标签替换它们。但在撰写本文时,这仍未完全实施或决定。

摘要

本章我们重点讨论了使用 Go 编程的 Kubernetes API。我们讨论了如何访问那些与每个 Kubernetes 集群一起提供的核心类型的 Kubernetes API 对象。

现在我们已经介绍了 Kubernetes API 的基础知识及其在 Go 中的表示。现在我们准备进入自定义资源的话题,这是运营商的支柱之一。

^(1) 参见“API 术语”。

^(2) kubectl explain pod 允许您查询 API 服务器以获取对象的模式,包括字段文档。

第四章:使用自定义资源

在本章中,我们向您介绍自定义资源(CR),这是 Kubernetes 生态系统中广泛使用的核心扩展机制之一。

自定义资源用于小型内部配置对象,没有对应的控制器逻辑——纯粹以声明方式定义。但是对于许多希望提供 Kubernetes 本地 API 体验的严肃开发项目而言,自定义资源也扮演着核心角色。例如服务网格,如 Istio、Linkerd 2.0 和 AWS App Mesh,它们都以自定义资源为核心。

还记得 第 1 章 中的“激励性例子”吗?其核心是像这样的 CR:

apiVersion: cnat.programming-kubernetes.info/v1alpha1
kind: At
metadata:
  name: example-at
spec:
  schedule: "2019-07-03T02:00:00Z"
status:
  phase: "pending"

自 Kubernetes 1.7 版本以来,每个 Kubernetes 集群都可以使用自定义资源。它们存储在与主 Kubernetes API 资源相同的 etcd 实例中,并由相同的 Kubernetes API 服务器提供。如 图 4-1 所示,如果资源不属于以下任何一种,请求将回退到 apiextensions-apiserver,它通过 CRD 定义的资源。

  • 由聚合 API 服务器处理(参见 第 8 章)。

  • 原生 Kubernetes 资源。

Kubernetes API 服务器内部的 API 扩展 API 服务器

图 4-1. Kubernetes API 服务器内部的 API 扩展 API 服务器

CustomResourceDefinition(CRD)本身也是 Kubernetes 资源。它描述了集群中可用的 CR。对于前面的示例 CR,相应的 CRD 如下所示:

apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition
metadata:
  name: ats.cnat.programming-kubernetes.info
spec:
  group: cnat.programming-kubernetes.info
  names:
    kind: At
    listKind: AtList
    plural: ats
    singular: at
  scope: Namespaced
  subresources:
    status: {}
  version: v1alpha1
  versions:
  - name: v1alpha1
    served: true
    storage: true

CRD 的名称——在本例中为 ats.cnat.programming-kubernetes.info——必须与复数名称和组名匹配。它将 API 组 cnat.programming-kubernetes.info 中的 At CR 定义为命名空间资源,称为 ats

如果此 CRD 在集群中创建,kubectl 将自动检测到该资源,用户可以通过以下方式访问它:

$ kubectl get ats
NAME                                         CREATED AT
ats.cnat.programming-kubernetes.info         2019-04-01T14:03:33Z

发现信息

在幕后,kubectl 使用来自 API 服务器的发现信息来了解新资源。让我们更深入地了解这个发现机制。

在增加 kubectl 的详细级别后,我们可以看到它如何学习有关新资源类型的信息:

$ kubectl get ats -v=7
... GET https://XXX.eks.amazonaws.com/apis/cnat.programming-kubernetes.info/
                                      v1alpha1/namespaces/cnat/ats?limit=500
... Request Headers:
... Accept: application/json;as=Table;v=v1beta1;g=meta.k8s.io,application/json
      User-Agent: kubectl/v1.14.0 (darwin/amd64) kubernetes/641856d
... Response Status: 200 OK in 607 milliseconds
NAME         AGE
example-at   43s

发现的详细步骤包括:

  1. 最初,kubectl 不知道 ats

  2. 因此,kubectl 通过 /apis 发现端点询问所有现有的 API 组。

  3. 接下来,kubectl 通过 /apis/group version 组发现端点询问所有现有 API 组中的资源。

  4. 然后,kubectl 将给定的类型 ats 转换为三元组:

    • 组(这里是 cnat.programming-kubernetes.info

    • 版本(这里是 v1alpha1

    • 资源(这里是 ats)。

发现端点提供了在最后一步进行转换所需的所有必要信息:

$ http localhost:8080/apis/
{
  "groups": [{
    "name": "at.cnat.programming-kubernetes.info",
    "preferredVersion": {
      "groupVersion": "cnat.programming-kubernetes.info/v1",
      "version": "v1alpha1“
    },
    "versions": [{
      "groupVersion": "cnat.programming-kubernetes.info/v1alpha1",
      "version": "v1alpha1"
    }]
  }, ...]
}

$ http localhost:8080/apis/cnat.programming-kubernetes.info/v1alpha1
{
  "apiVersion": "v1",
  "groupVersion": "cnat.programming-kubernetes.info/v1alpha1",
  "kind": "APIResourceList",
  "resources": [{
    "kind": "At",
    "name": "ats",
    "namespaced": true,
    "verbs": ["create", "delete", "deletecollection",
      "get", "list", "patch", "update", "watch"
    ]
  }, ...]
}

这一切都由发现 RESTMapper 实现。我们还在 “REST Mapping” 中看到了这种非常常见的 RESTMapper 类型。

警告

kubectl CLI 还在~/.kubectl中维护资源类型的缓存,以便在每次访问时无需重新检索发现信息。此缓存每 10 分钟失效一次。因此,CRD 的更改可能会在相应用户的 CLI 中显示最多 10 分钟后。

类型定义

现在让我们更详细地看看 CRD 和提供的功能:如cnat示例中所示,CRD 是由 Kubernetes API 服务器进程内的apiextensions.k8s.io/v1beta1 API 组提供的 Kubernetes 资源。

CRD 的模式看起来像这样:

apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition
metadata:
  name: *`name`*
spec:
  group: *`group` `name`*
  version: *`version` `name`*
  names:
    kind: *`uppercase` `name`*
    plural: *`lowercase` `plural` `name`*
    singular: *`lowercase` `singular` `name`* # defaulted to be lowercase kind
    shortNames: *`list` `of` `strings` `as` `short` `names`* # optional
    listKind: *`uppercase` `list` `kind`* # defaulted to be *`kind`*List
    categories: *`list` `of` `category` `membership` `like` `"all"`* # optional
  validation: # optional
    openAPIV3Schema: *`OpenAPI` `schema`* # optional
  subresources: # optional
    status: {} # to enable the status subresource (optional)
    scale: # optional
      specReplicasPath: *`JSON` `path` `for` `the` `replica` `number` `in` `the` `spec` `of` `the`
                        `custom` `resource`*
      statusReplicasPath: *`JSON` `path` `for` `the` `replica` `number` `in` `the` `status` `of`
                          `the` `custom` `resource`*
      labelSelectorPath: *`JSON` `path` `of` `the` `Scale.Status.Selector` `field` `in` `the`
                         `scale` `resource`*
  versions: # defaulted to the Spec.Version field
  - name: *`version` `name`*
    served: *`boolean` `whether` `the` `version` `is` `served` `by` `the` `API` `server`* # defaults to false
    storage: *`boolean` `whether` `this` `version` `is` `the` `version` `used` `to` `store` `object`*
  - ...

许多字段是可选的或具有默认值。我们将在以下部分更详细地解释这些字段。

创建 CRD 对象后,kube-apiserver内部的apiextensions-apiserver将检查名称,并确定它们是否与其他资源冲突或是否在自身中保持一致。几分钟后,它将在 CRD 的状态中报告结果,例如:

apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition
metadata:
  name: ats.cnat.programming-kubernetes.info
spec:
  group: cnat.programming-kubernetes.info
  names:
    kind: At
    listKind: AtList
    plural: ats
    singular: at
  scope: Namespaced
  subresources:
    status: {}
  validation:
    openAPIV3Schema:
      type: object
      properties:
        apiVersion:
          type: string
        kind:
          type: string
        metadata:
          type: object
        spec:
          properties:
            schedule:
              type: string
          type: object
        status:
          type: object
  version: v1alpha1
  versions:
  - name: v1alpha1
    served: true
    storage: true
status:
    acceptedNames:
      kind: At
      listKind: AtList
      plural: ats
      singular: at
    conditions:
    - lastTransitionTime: "2019-03-17T09:44:21Z"
      message: no conflicts found
      reason: NoConflicts
      status: "True"
      type: NamesAccepted
    - lastTransitionTime: null
      message: the initial names have been accepted
      reason: InitialNamesAccepted
      status: "True"
      type: Established
    storedVersions:
    - v1alpha1

您可以看到规范中缺少的名称字段被默认,并反映在状态中作为接受的名称。此外,设置了以下条件:

  • NamesAccepted描述规范中给定的名称是否一致且无冲突。

  • Established描述 API 服务器在status.acceptedNames下提供给定资源的名称。

请注意,某些字段可以在创建 CRD 之后很长时间内更改。例如,您可以添加短名称或列。在这种情况下,可以建立一个 CRD,即使用旧名称提供服务,尽管规范名称存在冲突。因此,NamesAccepted条件将为 false,规范名称和接受名称将不同。

自定义资源的高级特性

在本节中,我们讨论自定义资源的高级特性,例如验证或子资源。

验证自定义资源

CR 可以在创建和更新期间由 API 服务器进行验证。这是基于OpenAPI v3 模式在 CRD 规范中的validation字段指定的。

当请求创建或改变 CR 时,规范中的 JSON 对象将根据此规范进行验证,如果出现错误,则冲突字段将以 HTTP 代码400响应返回给用户。图 4-2 显示了在apiextensions-apiserver内的请求处理程序中进行验证的位置。

更复杂的验证可以通过验证入站 Webhook 实现,即在一个图灵完备的编程语言中。图 4-2 显示了这些 Webhook 在本节描述的基于 OpenAPI 的验证之后直接调用的情况。在 “Admission Webhooks” 中,我们将看到如何实现和部署 Admission Webhook。在那里,我们将探讨需要考虑其他资源的验证,因此远远超出了 OpenAPI v3 验证的范围。幸运的是,对于许多用例来说,OpenAPI v3 模式已经足够了。

apiextensions-apiserver 处理程序堆栈中的验证步骤

图 4-2. apiextensions-apiserver 处理程序堆栈中的验证步骤。

OpenAPI 模式语言基于 JSON Schema 标准,它使用 JSON/YAML 来表达模式。以下是一个示例:

type: object
properties:
  apiVersion:
    type: string
  kind:
    type: string
  metadata:
    type: object
  spec:
    type: object
    properties:
      schedule:
        type: string
        pattern: "^\d{4}-([0]\d|1[0-2])-([0-2]\d|3[01])..."
      command:
        type: string
    required:
    - schedule
    - command
  status:
    type: object
    properties:
      phase:
        type: string
required:
- metadata
- apiVersion
- kind
- spec

该模式指定该值实际上是一个 JSON 对象;^(1) 也就是说,它是一个字符串映射,而不是一个切片或者像数字那样的值。此外,除了自定义资源隐式定义的 metadatakindapiVersion 外,还有两个额外的属性:specstatus

每个也是一个 JSON 对象。spec 也有两个必填字段 schedulecommand,它们都是字符串。schedule 必须匹配 ISO 日期的模式(这里用一些正则表达式进行了概述)。可选的 status 属性有一个称为 phase 的字符串字段。

手动创建 OpenAPI 模式可能会很繁琐。幸运的是,通过代码生成正在进行的工作使这个过程变得更加简单:Kubebuilder 项目——参见 “Kubebuilder”——开发了 sig.k8s.io/controller-tools 中的 crd-gen,并逐步扩展到可以在其他环境中使用。生成器 crd-schema-gencrd-gen 在这个方向上的一个分支。

短名称和类别

与原生资源类似,自定义资源可能具有较长的资源名称。它们在 API 级别上非常好用,但在 CLI 中键入时可能很繁琐。CRs 也可以有短名称,比如原生资源 daemonsets,可以使用 kubectl get ds 来查询。这些短名称也称为别名,每个资源可以有任意数量的别名。

要查看所有可用的短名称,请使用如下命令:kubectl api-resources

$ kubectl api-resources
NAME                   SHORTNAMES  APIGROUP NAMESPACED  KIND
bindings                                    true Binding
componentstatuses      cs                   false ComponentStatus
configmaps             cm                   true ConfigMap
endpoints              ep                   true Endpoints
events                 ev                   true Event
limitranges            limits               true LimitRange
namespaces             ns                   false Namespace
nodes                  no                   false Node
persistentvolumeclaims pvc                  true PersistentVolumeClaim
persistentvolumes      pv                   false PersistentVolume
pods                   po                   true Pod
statefulsets           sts         apps     true StatefulSet
...

同样,kubectl 通过发现信息了解有关短名称的信息(参见 “Discovery Information”)。以下是一个示例:

apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition
metadata:
  name: ats.cnat.programming-kubernetes.info
spec:
  ...
  shortNames:
  - at

之后,kubectl get at 将列出命名空间中的所有 cnat CR。

此外,CRs——与任何其他资源一样——可以成为类别的一部分。最常见的用法是 all 类别,例如 kubectl get all。它列出集群中所有面向用户的资源,如 pods 和 services。

在集群中定义的 CR 可以通过 categories 字段加入类别或创建自己的类别:

apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition
metadata:
  name: ats.cnat.programming-kubernetes.info
spec:
  ...
  categories:
  - all

使用这种方法,kubectl get all还将在命名空间中列出cnat CR。

打印列

kubectl CLI 工具使用服务器端打印来呈现kubectl get的输出。这意味着它查询 API 服务器以获取要显示的列和每行中的值。

自定义资源还支持通过additionalPrinterColumns定义服务器端打印列。它们称为“附加”列,因为第一列始终是对象的名称。这些列定义如下:

apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition
metadata:
  name: ats.cnat.programming-kubernetes.info
spec:
  additionalPrinterColumns: (optional)
  - name: *`kubectl` `column` `name`*
    type: *`OpenAPI` `type` `for` `the` `column`*
    format: *`OpenAPI` `format` `for` `the` `column`* (optional)
    description: *`human-readable` `description` `of` `the` `column`* (optional)
    priority: *`integer,` `always` `zero` `supported` `by` `kubectl`*
    JSONPath: *`JSON` `path` `inside` `the` `CR` `for` `the` `displayed` `value`*

name字段是列名,type是 OpenAPI 类型,如规范中的data types部分定义,format(如同一文档中定义的)是可选的,可能会被kubectl或其他客户端解释。

此外,description是一个可选的人类可读字符串,用于文档目的。priority控制了kubectl的显示详细模式。在撰写本文时(使用 Kubernetes 1.14),仅支持零,并且所有优先级高于零的列都将隐藏。

最后,JSONPath定义了要显示的值。它是 CR 内的简单 JSON 路径。这里的“简单”意味着它支持对象字段语法,如.spec.foo.bar,但不支持更复杂的循环数组或类似的 JSON 路径。

使用这种方法,可以像这样扩展介绍中的示例 CRD,添加additionalPrinterColumns

additionalPrinterColumns: #(optional)
- name: schedule
  type: string
  JSONPath: .spec.schedule
- name: command
  type: string
  JSONPath: .spec.command
- name: phase
  type: string
  JSONPath: .status.phase

然后kubectl将以以下方式呈现cnat资源:

$ kubectl get ats
NAME  SCHEDULER             COMMAND             PHASE
foo   2019-07-03T02:00:00Z  echo "hello world"  Pending

接下来,我们来看看子资源。

子资源

我们在“状态子资源:UpdateStatus”中简要提到了子资源。子资源是特殊的 HTTP 端点,使用附加到正常资源的 HTTP 路径后缀。例如,Pod 的标准 HTTP 路径是/api/v1/namespace/namespace/pods/name。Pod 具有多个子资源,如/logs/portforward/exec/status。相应的子资源 HTTP 路径如下:

  • /api/v1/namespace/namespace/pods/name/logs

  • /api/v1/namespace/namespace/pods/name/portforward

  • /api/v1/namespace/namespace/pods/name/exec

  • /api/v1/namespace/namespace/pods/name/status

子资源端点使用与主资源端点不同的协议。

在撰写本文时,自定义资源支持两个子资源:/scale/status。两者都是选择性的,即在 CRD 中必须显式启用它们。

状态子资源

/status子资源用于将 CR 实例的用户提供的规范与控制器提供的状态分开。这样做的主要动机是特权分离:

  • 用户通常不应编写状态字段。

  • 控制器不应编写规范字段。

访问控制的 RBAC 机制不允许在该级别的规则。这些规则始终是每个资源独立的。/status 子资源通过提供两个独立的端点来解决这个问题。每个端点可以独立地通过 RBAC 规则进行控制。这通常被称为 spec-status split 的例子是对 ats 资源的规则,仅适用于 /status 子资源(而 "ats" 将匹配主资源):

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata: ...
rules:
- apiGroups: [""]
  resources: ["ats/status"]
  verbs: ["update", "patch"]

拥有 /status 子资源的资源(包括自定义资源)语义发生了变化,同样适用于主资源端点:

  • 在创建期间(在创建期间状态只是被丢弃)和更新期间,它们忽略在主 HTTP 端点上对状态的更改。

  • 同样地,/status 子资源端点忽略负载状态以外的更改。在 /status 端点上的创建操作是不可能的。

  • 每当 metadata 以外和 status 以外的内容发生变化(尤其是规范中的变化),主资源端点将增加 metadata.generation 的值。这可用作控制器的触发器,指示用户的需求已变更。

注意通常在更新请求中同时发送 specstatus,但从技术上讲,你可以在请求负载中省略另一个相应的部分。

还要注意 /status 端点将忽略状态以外的所有内容,包括标签或注解的元数据更改。

自定义资源的 spec-status 分离启用如下:

apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition
spec:
  subresources:
    status: {}
  ...

在这里注意,在该 YAML 片段中,status 字段被分配为空对象。这是设置一个没有其他属性的字段的方法。只需写

subresources:
  status:

会导致验证错误,因为在 YAML 中结果为 status 的值是 null

警告

启用 spec-status 分离对 API 是一个破坏性的变更。旧控制器将写入主要的端点。他们不会注意到从启用分离的那一刻起状态总是被忽略。同样,新控制器在分离启用之前不能写入新的 /status 端点。

在 Kubernetes 1.13 及更高版本中,可以按版本配置子资源。这使我们可以引入 /status 子资源而不会引入破坏性变更:

apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition
spec:
  ...
  versions:
  - name: v1alpha1
    served: true
    storage: true
  - name: v1beta1
    served: true
    subresources:
      status: {}

这使得 v1beta1 可以使用 /status 子资源,但 v1alpha1 不行。

注意

乐观并发语义(参见 “乐观并发”)与主资源端点相同;即 statusspec 共享相同的资源版本计数器,因此 /status 更新可能由于对主资源的写入而发生冲突,反之亦然。换句话说,存储层没有 specstatus 的分离。

缩放子资源

自定义资源的第二个子资源是/scale/scale子资源是资源的(投影)^(2)视图,允许我们仅查看和修改复制品值。这个子资源对于 Kubernetes 中的部署和副本集等资源是众所周知的,这些资源显然可以进行水平扩展和缩减。

kubectl scale命令使用/scale子资源;例如,以下命令将修改给定实例中指定的复制品值:

$ kubectl scale --replicas=3 *`your-custom-resource`* -v=7
I0429 21:17:53.138353   66743 round_trippers.go:383] PUT
https://*`host`*/apis/*`group`*/v1/*`your-custom-resource`*/scale

apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition
spec:
  subresources:
    scale:
      specReplicasPath: .spec.replicas
      statusReplicasPath: .status.replicas
      labelSelectorPath: .status.labelSelector
  ...

更新复制品值的过程中,写入了spec.replicas并在GET期间从那里返回。

不能通过/status子资源更改标签选择器,只能读取。它的目的是为控制器提供信息,以计算满足此选择器的相应对象。例如,ReplicaSet控制器计算满足此选择器的相应 Pod 数。

标签选择器是可选的。如果您的自定义资源语义不适合标签选择器,只需不为其指定 JSON 路径即可。

在前述kubectl scale --replicas=3 ...示例中,值3被写入spec.replicas。当然也可以使用其他简单的 JSON 路径;例如,根据上下文,spec.instancesspec.size将是一个合理的字段名称。

从端点读取或写入的对象的种类是来自autoscaling/v1 API 组的Scale。它的外观如下:

type Scale struct {
    metav1.TypeMeta `json:",inline"`
    // Standard object metadata; More info: https://git.k8s.io/
    // community/contributors/devel/api-conventions.md#metadata.
    // +optional
    metav1.ObjectMeta `json:"metadata,omitempty"`

    // defines the behavior of the scale. More info: https://git.k8s.io/community/
    // contributors/devel/api-conventions.md#spec-and-status.
    // +optional
    Spec ScaleSpec `json:"spec,omitempty"`

    // current status of the scale. More info: https://git.k8s.io/community/
    // contributors/devel/api-conventions.md#spec-and-status. Read-only.
    // +optional
    Status ScaleStatus `json:"status,omitempty"`
}

// ScaleSpec describes the attributes of a scale subresource.
type ScaleSpec struct {
    // desired number of instances for the scaled object.
    // +optional
    Replicas int32 `json:"replicas,omitempty"`
}

// ScaleStatus represents the current status of a scale subresource.
type ScaleStatus struct {
    // actual number of observed instances of the scaled object.
    Replicas int32 `json:"replicas"`

    // label query over pods that should match the replicas count. This is the
    // same as the label selector but in the string format to avoid
    // introspection by clients. The string will be in the same
    // format as the query-param syntax. More info about label selectors:
    // http://kubernetes.io/docs/user-guide/labels#label-selectors.
    // +optional
    Selector string `json:"selector,omitempty"`
}

一个实例看起来像这样:

metadata:
  name: *`cr-name`*
  namespace: *`cr-namespace`*
  uid: *`cr-uid`*
  resourceVersion: *`cr-resource-version`*
  creationTimestamp: *`cr-creation-timestamp`*
spec:
  replicas: 3
  status:
    replicas: 2
    selector: "environment = production"

注意,乐观并发语义对主资源和/scale子资源是相同的。也就是说,主资源的写入可能与/scale写入冲突,反之亦然。

开发者对自定义资源的看法

可以使用多种客户端从 Golang 访问自定义资源。我们将集中讨论以下内容:

  • 使用client-go动态客户端(参见“动态客户端”)

  • 使用类型化的客户端:

使用哪种客户端主要取决于要编写的代码的上下文,特别是实现逻辑的复杂性和要求(例如,是否需要动态和支持编译时未知的 GVK)。

前述客户端列表:

  • 降低处理未知 GVK 的灵活性。

  • 类型安全性的增加。

  • 增加提供的 Kubernetes API 功能的完整性。

动态客户端

k8s.io/client-go/dynamic中的动态客户端完全与已知的 GVKs 无关。它甚至不使用除unstructured.Unstructured外的任何 Go 类型,后者仅包装了json.Unmarshal及其输出。

动态客户端既不使用方案(scheme)也不使用 RESTMapper。这意味着开发者必须通过提供 GVR 形式的资源(参见“资源”)手动提供关于类型的所有知识:

schema.GroupVersionResource{
  Group: "apps",
  Version: "v1",
  Resource: "deployments",
}

如果存在 REST 客户端配置(参见“创建和使用客户端”),动态客户端可以在一行中创建:

client, err := NewForConfig(cfg)

对给定 GVR 的 REST 访问同样简单:

client.Resource(gvr).
   Namespace(namespace).Get("foo", metav1.GetOptions{})

这为您提供了在给定命名空间中的部署foo

注意

您必须了解资源的范围(即它是命名空间范围的还是集群范围的)。集群范围的资源只需省略Namespace(namespace)调用。

动态客户端的输入和输出是*unstructured.Unstructured—即一个包含与json.Unmarshal在解组时输出相同数据结构的对象:

  • 对象由map[string]interface{}表示。

  • 数组由[]interface{}表示。

  • 原始类型是stringboolfloat64int64

方法UnstructuredContent()提供对非结构化对象内部数据结构的访问(我们也可以直接访问Unstructured.Object)。同一包中有助手函数使得字段的检索变得容易,并且可以对对象进行操作,例如:

name, found, err := unstructured.NestedString(u.Object, "metadata", "name")

返回部署名称——在这种情况下是"foo"。如果字段确实存在(不仅仅是空的,而是确实存在),则foundtrueerr报告如果现有字段的类型是意外的(在这种情况下不是字符串)。其他帮助函数是通用的,一次是结果的深度拷贝,一次没有:

func NestedFieldCopy(obj map[string]interface{}, fields ...string)
  (interface{}, bool, error)
func NestedFieldNoCopy(obj map[string]interface{}, fields ...string)
  (interface{}, bool, error)

还有其他的类型化变体,如果类型转换失败则返回错误:

func NestedBool(obj map[string]interface{}, fields ...string) (bool, bool, error)
func NestedFloat64(obj map[string]interface{}, fields ...string)
  (float64, bool, error)
func NestedInt64(obj map[string]interface{}, fields ...string) (int64, bool, error)
func NestedStringSlice(obj map[string]interface{}, fields ...string)
  ([]string, bool, error)
func NestedSlice(obj map[string]interface{}, fields ...string)
  ([]interface{}, bool, error)
func NestedStringMap(obj map[string]interface{}, fields ...string)
  (map[string]string, bool, error)

最后是一个通用的设置器:

func SetNestedField(obj, value, path...)

动态客户端在 Kubernetes 中本身用于通用控制器,例如垃圾回收控制器,它删除其父对象消失的对象。垃圾回收控制器可以处理系统中的任何资源,因此广泛使用动态客户端。

类型化客户端

类型化客户端不使用类似map[string]interface{}的通用数据结构,而是使用真正的 Golang 类型,这些类型对于每个 GVK 都是不同的和特定的。它们更容易使用,具有显著增加的类型安全性,并且使代码更加简洁和可读。但缺点是,它们不太灵活,因为处理的类型必须在编译时已知,并且这些客户端是生成的,这增加了复杂性。

在进入类型化客户端的两个实现之前,让我们深入了解 Golang 类型系统中的种类表示(参见“深入理解 API 机制”了解 Kubernetes 类型系统背后的理论)。

类型的解剖

类型以 Golang 结构体表示。通常,结构体的命名与其类型相同(尽管从技术上讲不一定如此),并且放置在与手头 GVK 的组和版本对应的包中。一个常见的约定是将 GVK group/version.Kind放入一个 Go 包中:

pkg/apis/*group*/*version*

并在文件types.go中定义一个 Golang 结构体Kind

每个对应于 GVK 的 Golang 类型都嵌入来自包k8s.io/apimachinery/pkg/apis/meta/v1TypeMeta结构体。 TypeMeta仅由KindApiVersion字段组成:

type TypeMeta struct {
    // +optional
    APIVersion string `json:"apiVersion,omitempty" yaml:"apiVersion,omitempty"`
    // +optional
    Kind string `json:"kind,omitempty" yaml:"kind,omitempty"`
}

此外,每个顶级类型——即具有自己的端点和因此一个(或多个)对应的 GVR(参见“REST 映射”)——必须存储名称,对于命名空间资源的命名空间,以及一系列非常多的进一步的元层字段。所有这些都存储在一个名为ObjectMeta的结构体中,位于包k8s.io/apimachinery/pkg/apis/meta/v1中:

type ObjectMeta struct {
    Name string `json:"name,omitempty"`
    Namespace string `json:"namespace,omitempty"`
    UID types.UID `json:"uid,omitempty"`
    ResourceVersion string `json:"resourceVersion,omitempty"`
    CreationTimestamp Time `json:"creationTimestamp,omitempty"`
    DeletionTimestamp *Time `json:"deletionTimestamp,omitempty"`
    Labels map[string]string `json:"labels,omitempty"`
    Annotations map[string]string `json:"annotations,omitempty"`
    ...
}

还有一些额外的字段。我们强烈建议您仔细阅读详细的内联文档,因为它很好地展示了 Kubernetes 对象的核心功能。

Kubernetes 顶层类型(即具有嵌入的TypeMetaObjectMeta,并且在这种情况下被持久化到etcd中)在外观上非常相似,因为它们通常具有一个spec和一个status。参见来自k8s.io/kubernetes/apps/v1/types.go的部署示例:

type Deployment struct {
    metav1.TypeMeta `json:",inline"`
    metav1.ObjectMeta `json:"metadata,omitempty"`

    Spec DeploymentSpec `json:"spec,omitempty"`
    Status DeploymentStatus `json:"status,omitempty"`
}

虽然specstatus类型的实际内容在不同类型之间有显著差异,但是将其分为specstatus在 Kubernetes 中是一个常见的主题或约定,尽管从技术上讲并非必须如此。因此,跟随 CRD 的这种结构是一个良好的做法。某些 CRD 功能甚至要求这种结构;例如,用于自定义资源的/status子资源(见“状态子资源”)——当启用时——始终仅适用于自定义资源实例的status子结构。它无法重命名。

Golang 包结构

正如我们所见,Golang 类型传统上放置在包pkg/apis/group/version的文件types.go中。除了该文件外,我们还要浏览几个文件。一些是由开发人员手动编写的,而一些是使用代码生成器生成的。有关详细信息,请参阅第五章。

doc.go文件描述了 API 的目的,并包含一些包全局的代码生成标签:

// Package v1alpha1 contains the cnat v1alpha1 API group
//
// +k8s:deepcopy-gen=package
// +groupName=cnat.programming-kubernetes.info
package v1alpha1

接下来,register.go包括帮助程序,将自定义资源的 Golang 类型注册到方案中(参见“Scheme”):

package *`version`*

import (
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    "k8s.io/apimachinery/pkg/runtime"
    "k8s.io/apimachinery/pkg/runtime/schema"

    group "*`repo`*/pkg/apis/*`group`*"
)

// SchemeGroupVersion is group version used to register these objects var SchemeGroupVersion = schema.GroupVersion{
    Group: group.GroupName,
    Version: "*`version`*",
}

// Kind takes an unqualified kind and returns back a Group qualified GroupKind func Kind(kind string) schema.GroupKind {
    return SchemeGroupVersion.WithKind(kind).GroupKind()
}

// Resource takes an unqualified resource and returns a Group // qualified GroupResource func Resource(resource string) schema.GroupResource {
    return SchemeGroupVersion.WithResource(resource).GroupResource()
}

var (
    SchemeBuilder = runtime.NewSchemeBuilder(addKnownTypes)
    AddToScheme   = SchemeBuilder.AddToScheme
)

// Adds the list of known types to Scheme. func addKnownTypes(scheme *runtime.Scheme) error {
    scheme.AddKnownTypes(SchemeGroupVersion,
        &SomeKind{},
        &SomeKindList{},
    )
    metav1.AddToGroupVersion(scheme, SchemeGroupVersion)
    return nil
}

然后,zz_generated.deepcopy.go 在自定义资源的 Golang 顶层类型上定义了深度复制方法(例如,在前面的示例代码中的 SomeKindSomeKindList)。此外,所有子结构(如 specstatus)也变得可以深度复制。

因为示例中在 doc.go 中使用了标签 +k8s:deepcopy-gen=package,所以深度复制生成是基于 opt-out 基础的;也就是说,对于不使用 +k8s:deepcopy-gen=false 退出的包中的每种类型,都会生成 DeepCopy 方法。详见 第五章 和特别是 “deepcopy-gen Tags” 获取更多细节。

通过 client-gen 生成的类型化客户端

使用 API 包 pkg/apis/group/version,客户端生成器 client-gen 创建了一个类型化的客户端(详见 第五章,特别是 “client-gen Tags”),默认情况下在 pkg/generated/clientset/versioned 中(在旧版生成器中是 pkg/client/clientset/versioned)。更准确地说,生成的顶层对象是一个客户端集。它涵盖了多个 API 组、版本和资源。

顶层文件 如下所示:

// Code generated by client-gen. DO NOT EDIT.

package versioned

import (
    discovery "k8s.io/client-go/discovery"
    rest "k8s.io/client-go/rest"
    flowcontrol "k8s.io/client-go/util/flowcontrol"

    cnatv1alpha1 ".../cnat/cnat-client-go/pkg/generated/clientset/versioned/
)

type Interface interface {
    Discovery() discovery.DiscoveryInterface
    CnatV1alpha1() cnatv1alpha1.CnatV1alpha1Interface
}

// Clientset contains the clients for groups. Each group has exactly one
// version included in a Clientset.
type Clientset struct {
    *discovery.DiscoveryClient
    cnatV1alpha1 *cnatv1alpha1.CnatV1alpha1Client
}

// CnatV1alpha1 retrieves the CnatV1alpha1Client
func (c *Clientset) CnatV1alpha1() cnatv1alpha1.CnatV1alpha1Interface {
    return c.cnatV1alpha1
}

// Discovery retrieves the DiscoveryClient
func (c *Clientset) Discovery() discovery.DiscoveryInterface {
   ...
}

// NewForConfig creates a new Clientset for the given config.
func NewForConfig(c *rest.Config) (*Clientset, error) {
    ...
}

客户端集由接口 Interface 表示,并为每个版本的 API 组客户端接口提供访问,例如,在此示例代码中是 CnatV1alpha1Interface

type CnatV1alpha1Interface interface {
    RESTClient() rest.Interface
    AtsGetter
}

// AtsGetter has a method to return a AtInterface.
// A group's client should implement this interface.
type AtsGetter interface {
    Ats(namespace string) AtInterface
}

// AtInterface has methods to work with At resources.
type AtInterface interface {
    Create(*v1alpha1.At) (*v1alpha1.At, error)
    Update(*v1alpha1.At) (*v1alpha1.At, error)
    UpdateStatus(*v1alpha1.At) (*v1alpha1.At, error)
    Delete(name string, options *v1.DeleteOptions) error
    DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error
    Get(name string, options v1.GetOptions) (*v1alpha1.At, error)
    List(opts v1.ListOptions) (*v1alpha1.AtList, error)
    Watch(opts v1.ListOptions) (watch.Interface, error)
    Patch(name string, pt types.PatchType, data []byte, subresources ...string)
        (result *v1alpha1.At, err error)
    AtExpansion
}

可以使用 NewForConfig 辅助函数创建客户端集的实例。这类似于讨论的核心 Kubernetes 资源的客户端,详见 “Creating and Using a Client”:

import (
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    "k8s.io/client-go/tools/clientcmd"

    client "github.com/.../cnat/cnat-client-go/pkg/generated/clientset/versioned"
)

kubeconfig = flag.String("kubeconfig", "~/.kube/config", "kubeconfig file")
flag.Parse()
config, err := clientcmd.BuildConfigFromFlags("", *kubeconfig)
clientset, err := client.NewForConfig(config)

ats := clientset.CnatV1alpha1Interface().Ats("default")
book, err := ats.Get("kubernetes-programming", metav1.GetOptions{})

正如您所见,代码生成机制使我们可以以与核心 Kubernetes 资源完全相同的方式编写自定义资源的逻辑。还提供了像 informers 这样的高级工具;在 第五章 中查看 informer-gen

Operator SDK 和 Kubebuilder 的 controller-runtime 客户端

为了完整起见,我们想快速查看第三个客户端,它被列为 “A Developer’s View on Custom Resources” 中的第二个选项。controller-runtime 项目提供了操作员解决方案 Operator SDK 和 Kubebuilder 的基础,包括一个使用在 “Anatomy of a type” 中介绍的 Go 类型的客户端。

与以前 “Typed client created via client-gen” 生成的客户端相比,以及与 “Dynamic Client” 类似,这个客户端是一个实例,能够处理注册在给定方案中的任何类型。

它使用来自 API 服务器的发现信息将种类映射到 HTTP 路径。请注意,第六章 将更详细地讨论此客户端作为这两个操作员解决方案的一部分的使用方式。

下面是如何使用 controller-runtime 的快速示例:

import (
    "flag"

    corev1 "k8s.io/api/core/v1"
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    "k8s.io/client-go/kubernetes/scheme"
    "k8s.io/client-go/tools/clientcmd"

    runtimeclient "sigs.k8s.io/controller-runtime/pkg/client"
)

kubeconfig = flag.String("kubeconfig", "~/.kube/config", "kubeconfig file path")
flag.Parse()
config, err := clientcmd.BuildConfigFromFlags("", *kubeconfig)

cl, _ := runtimeclient.New(config, client.Options{
    Scheme: scheme.Scheme,
})
podList := &corev1.PodList{}
err := cl.List(context.TODO(), client.InNamespace("default"), podList)

客户端对象的List()方法接受任何在给定方案中注册的runtime.Object,在这种情况下,是从client-go借用的所有标准 Kubernetes 种类。在内部,客户端使用给定的方案将 Golang 类型*corev1.PodList映射到 GVK。在第二步中,List()方法使用发现信息来获取 pods 的 GVR,即schema.GroupVersionResource{"", "v1", "pods"},因此访问/api/v1/namespace/default/pods来获取传递命名空间中 pods 的列表。

同样的逻辑可以用于自定义资源。主要区别在于使用包含传递的 Go 类型的自定义方案:

import (
    "flag"

    corev1 "k8s.io/api/core/v1"
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    "k8s.io/client-go/kubernetes/scheme"
    "k8s.io/client-go/tools/clientcmd"

    runtimeclient "sigs.k8s.io/controller-runtime/pkg/client"
    cnatv1alpha1 "github.com/.../cnat/cnat-kubebuilder/pkg/apis/cnat/v1alpha1"
)

kubeconfig = flag.String("kubeconfig", "~/.kube/config", "kubeconfig file")
flag.Parse()
config, err := clientcmd.BuildConfigFromFlags("", *kubeconfig)

crScheme := runtime.NewScheme()
cnatv1alpha1.AddToScheme(crScheme)

cl, _ := runtimeclient.New(config, client.Options{
    Scheme: crScheme,
})
list := &cnatv1alpha1.AtList{}
err := cl.List(context.TODO(), client.InNamespace("default"), list)

注意List()命令的调用方式完全没有变化。

想象一下,您编写了一个操作器,该操作器使用此客户端访问许多不同种类的资源。使用“通过 client-gen 创建的类型化客户端”,您将不得不将许多不同的客户端传递给操作器,使得管道代码非常复杂。相比之下,这里介绍的controller-runtime客户端只是一个适用于所有种类的对象。

所有三种类型的客户端都有它们的用途,具体取决于它们被使用的上下文中的优势和劣势。在处理未知对象的通用控制器中,只能使用动态客户端。在类型安全性对于强制代码正确性有很大帮助的控制器中,生成的客户端非常合适。Kubernetes 项目本身有很多贡献者,因此代码的稳定性非常重要,即使它被许多人扩展和重写。如果方便性和高速度以及最小化的管道代码是重要的话,controller-runtime客户端就非常适合。

摘要

我们在本章向您介绍了自定义资源,这是 Kubernetes 生态系统中使用的中心扩展机制。到目前为止,您应该对它们的特性和限制以及可用的客户端有了很好的理解。

现在让我们继续进行用于管理上述资源的代码生成。

^(1) 不要在这里混淆 Kubernetes 和 JSON 对象。后者只是 JSON 上下文中字符串映射的另一个术语,在 OpenAPI 中使用。

^(2) 这里的“投影”意味着scale对象是主资源的投影,它仅显示特定字段并隐藏其他所有内容。

第五章:自动化代码生成

在本章中,您将学习如何在 Go 项目中使用 Kubernetes 代码生成器以一种自然的方式编写自定义资源。代码生成器在实现本机 Kubernetes 资源时被广泛使用,我们也将在这里使用完全相同的生成器。

为什么要代码生成

Go 是一种设计简单的语言。它缺乏高级或者类似元编程的机制来以通用的方式表达对不同数据类型的算法。在 Go 中,“Go way” 是使用外部代码生成。

Kubernetes 开发的早期阶段,随着系统增加更多资源,越来越多的代码需要重写。代码生成大大简化了这些代码的维护。早期,创建了Gengo 库,并最终基于 Gengo 开发了k8s.io/code-generator,作为一个可以外部使用的生成器集合。我们将在接下来的章节中使用这些生成器来处理 CRs。

调用生成器

通常情况下,几乎每个控制器项目中调用代码生成器的方式都是大致相同的。只有包、组名和 API 版本不同。从构建系统调用 k8s.io/code-generator/generate-groups.sh 或类似 hack/update-codegen.sh 的 bash 脚本是向 CR Go 类型添加代码生成的最简单方法(参见该书的 GitHub 仓库)。

请注意,由于非常特殊的需求和历史原因,一些项目直接调用生成器二进制文件。对于构建 CRs 控制器的用例,直接从 k8s.io/code-generator 仓库调用 generate-groups.sh 脚本要简单得多:

$ vendor/k8s.io/code-generator/generate-groups.sh all \
    github.com/programming-kubernetes/cnat/cnat-client-go/pkg/generated
    github.com/programming-kubernetes/cnat/cnat-client-go/pkg/apis \
    cnat:v1alpha1 \
    --output-base "${GOPATH}/src" \
    --go-header-file "hack/boilerplate.go.txt"

在这里,all 表示调用所有四个用于 CRs 的标准代码生成器:

deepcopy-gen

生成 func (t *T) DeepCopy() *Tfunc (t *T) DeepCopyInto(*T) 方法。

client-gen

创建类型化的客户端集。

informer-gen

为 CRs 创建 informers,提供基于事件的接口以便在服务器上对 CRs 的更改做出响应。

lister-gen

为 CRs 创建 listers,为 GETLIST 请求提供只读缓存层。

最后两个方法是构建控制器的基础(参见“控制器和操作员”)。这四个代码生成器构成了使用相同机制和包构建功能齐全、生产就绪的控制器的强大基础,与 Kubernetes 上游控制器使用的相同。

注意

k8s.io/code-generator 中还有一些其他生成器,主要用于其他情境。例如,如果您构建自己的聚合 API 服务器(参见第八章),您将同时使用内部类型和版本化类型,并且必须定义默认函数。那么,您可以通过调用 k8s.io/code-generator/generate-internal-groups.sh 脚本来访问这两个生成器,它们将变得相关:

conversion-gen

创建用于在内部类型和外部类型之间进行转换的函数。

defaulter-gen

处理某些字段的默认值。

现在让我们详细看看 generate-groups.sh 的参数:

  • 生成的客户端、列表和通知者的目标包名是第二个参数。

  • API 组的基础包是第三个参数。

  • 第四个参数是 API 组与其版本的空格分隔列表。

  • --output-base 作为一个标志传递给所有生成器,用来定义给定包所在的基础目录。

  • --go-header-file 允许我们将版权头放入生成的代码中。

一些生成器,比如 deepcopy-gen,直接在 API 组包内部创建文件。这些文件遵循一个以 zz_generated. 前缀命名的标准命名方案,因此很容易通过版本控制系统(例如 .gitignore 文件)排除它们,尽管大多数项目决定检查生成的文件,因为围绕代码生成器的 Go 工具尚未很好地发展。^(1)

如果项目遵循 k8s.io/sample-controller 的模式 —— sample-controller 是一个蓝图项目,复制了 Kubernetes 自身构建的许多控制器的模式 —— 那么代码生成从以下内容开始:

$ hack/update-codegen.sh

在 “Following sample-controller” 中的 sample-controller+client-go 变体中,cnat 示例沿着这条路线前进。

提示

通常,除了 hack/update-codegen.sh 脚本之外,还有一个名为 hack/verify-codegen.sh 的第二个脚本。

此脚本调用 hack/update-codegen.sh 脚本并检查是否有任何更改,如果生成的文件中有任何文件不是最新的,则以非零返回码终止。

在持续集成(CI)脚本中非常有帮助:如果开发人员意外修改了文件或者文件仅仅是过时的,CI 将注意到并提出投诉。

使用标签控制生成器

虽然某些代码生成器的行为是通过命令行标志控制的(如前面描述的特别是要处理的包),但更多的属性是通过 标签 在您的 Go 文件中控制的。标签是以下格式的特殊格式化 Go 注释:

// +some-tag
// +some-other-tag=value

有两种类型的标签:

  • 文件 doc.gopackage 行上方的全局标签

  • 类型声明(例如结构定义)之前的本地标签

根据问题中的标签,注释的位置可能很重要。

全局标签

全局标签写入包的 doc.go。一个典型的 pkg/apis/group/version/doc.go 文件如下所示:

// +k8s:deepcopy-gen=package

// Package v1 is the v1alpha1 version of the API.
// +groupName=cnat.programming-kubernetes.info
package v1alpha1

此文件的第一行告诉deepcopy-gen默认为该包中的每种类型创建深度复制方法。如果您有不需要、不希望或甚至不可能进行深度复制的类型,您可以通过本地标签// +k8s:deepcopy-gen=false为其选择退出。如果您没有启用包范围的深度复制,您必须通过// +k8s:deepcopy-gen=true为每种所需类型选择深度复制。

第二个标签,// +groupName=example.com,定义了完全限定的 API 组名。如果 Go 父包名称与组名不匹配,则此标签是必需的。

这里显示的文件实际上来自cnat client-go示例 pkg/apis/cnat/v1alpha1/doc.go文件(请参阅“跟踪示例控制器”)。在那里,cnat是父包,但cnat.programming-kubernetes.info是组名。

使用// +groupName标签,客户端生成器(请参阅“通过 client-gen 创建的类型化客户端”)将生成一个使用正确 HTTP 路径 /apis/foo.project.example.com的客户端。除了+groupName之外,还有+groupGoName,它定义了一个自定义的 Go 标识符(用于变量和类型名称),用于替代父包名称。例如,生成器默认使用大写的父包名称作为标识符,在我们的示例中是Cnat。一个更好的标识符将是CNAt,代表“Cloud Native At”。使用// +groupGoName=CNAt,我们可以使用它而不是Cnat(尽管在此示例中我们没有这样做——我们仍然使用了Cnat),client-gen生成的结果将如下所示:

type Interface interface {
    Discovery() discovery.DiscoveryInterface
    CNatV1() atv1alpha1.CNatV1alpha1Interface
}

本地标签

本地标签可以直接写在 API 类型的上方,也可以写在其上方的第二个注释块中。以下是cnat示例types.go文件中的主要类型:

// AtSpec defines the desired state of At
type AtSpec struct {
    // Schedule is the desired time the command is supposed to be executed.
    // Note: the format used here is UTC time https://www.utctime.net
    Schedule string `json:"schedule,omitempty"`
    // Command is the desired command (executed in a Bash shell) to be executed.
    Command string `json:"command,omitempty"`
    // Important: Run "make" to regenerate code after modifying this file
}

// AtStatus defines the observed state of At
type AtStatus struct {
    // Phase represents the state of the schedule: until the command is executed
    // it is PENDING, afterwards it is DONE.
    Phase string `json:"phase,omitempty"`
    // Important: Run "make" to regenerate code after modifying this file
}

// +genclient
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object

// At runs a command at a given schedule.
type At struct {
    metav1.TypeMeta   `json:",inline"`
    metav1.ObjectMeta `json:"metadata,omitempty"`

    Spec   AtSpec   `json:"spec,omitempty"`
    Status AtStatus `json:"status,omitempty"`
}

// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object

// AtList contains a list of At
type AtList struct {
    metav1.TypeMeta `json:",inline"`
    metav1.ListMeta `json:"metadata,omitempty"`
    Items           []At `json:"items"`
}

在以下各节中,我们将详细介绍本示例的标签。

提示

在这个示例中,API 文档位于第一个注释块中,而我们将标签放在第二个注释块中。如果您使用某些工具来提取 Go doc 注释,这有助于将标签排除在 API 文档之外。

deepcopy-gen 标签

通常通过全局// +k8s:deepcopy-gen=package标签为所有类型启用深度复制方法(请参阅“全局标签”),即通过可能的选择退出。然而,在前述示例文件中(实际上是整个包中),所有 API 类型都需要深度复制方法。因此,我们不必在本地选择退出。

如果我们在 API 类型包中有一个辅助结构体(通常不鼓励这样做以保持 API 包的清洁),我们将不得不禁用深度复制生成。例如:

// +k8s:deepcopy-gen=false
//
// Helper is a helper struct, not an API type.
type Helper struct {
    ...
}

runtime.Object 和 DeepCopyObject

有一个需要更多解释的特殊深度复制标签:

// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object

在《Go 中的 Kubernetes 对象》中,我们看到runtime.Object必须实现DeepCopyObject() runtime.Object方法。原因是 Kubernetes 内部的通用代码必须能够创建对象的深拷贝。这个方法允许了这一点。

DeepCopyObject()方法除了调用生成的DeepCopy方法外什么也不做。后者的签名因类型而异(DeepCopy() *T 取决于 T)。前者的签名始终是 DeepCopyObject() runtime.Object

func (in *T) DeepCopyObject() runtime.Object {
    if c := in.DeepCopy(); c != nil {
        return c
    } else {
        return nil
    }
}

在你的顶层 API 类型上方放置本地标签 // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object 可以生成带有deepcopy-gen的这种方法。这告诉deepcopy-genruntime.Object创建这样一个方法,称为DeepCopyObject()

提示

在前面的例子中,AtAtList都是顶层类型,因为它们作为runtime.Object使用。

作为一个经验法则,顶层类型是那些嵌入了metav1.TypeMeta的类型。

其他接口需要一种方式进行深拷贝。例如,如果 API 类型有一个接口类型的字段Foo,通常情况下就会是这样:

type SomeAPIType struct {
  Foo Foo `json:"foo"`
}

正如我们所见,API 类型必须能够进行深拷贝,因此字段Foo也必须进行深拷贝。在不使用类型转换的情况下,您如何以通用方式实现这一点,而不是将DeepCopyFoo() Foo添加到Foo接口中呢?

type Foo interface {
    ...
    DeepCopyFoo() Foo
}

在那种情况下,可以使用相同的标签:

// +k8s:deepcopy-gen:interfaces=<package>.Foo
type FooImplementation struct {
    ...
}

在 Kubernetes 源码中,实际上有几个超出runtime.Object范围的示例使用了此标签:

// +k8s:deepcopy-gen:interfaces=.../pkg/registry/rbac/reconciliation.RuleOwner
// +k8s:deepcopy-gen:interfaces=.../pkg/registry/rbac/reconciliation.RoleBinding

client-gen 标签

最后,有许多标签来控制client-gen,我们在早期关于AtAtList的示例中看到了其中之一:

// +genclient

它告诉client-gen为此类型创建一个客户端(这总是可选择的)。请注意,您不必,实际上不得将其放在 API 对象的List类型之上。

在我们的cnat示例中,我们使用/status子资源并使用客户端的UpdateStatus方法来更新 CR 的状态(参见“状态子资源”)。存在没有状态或没有规范-状态分离的 CR 实例。在这些情况下,以下标签避免了生成UpdateStatus()方法:

// +genclient:noStatus
警告

没有这个标签,client-gen将盲目地生成UpdateStatus()方法。然而,重要的是要理解,仅当 CustomResourceDefinition 清单中实际启用了/status子资源时,规范-状态分离才起作用(参见“子资源”)。

仅仅是客户端中存在该方法本身并没有什么影响。在没有清单中的更改的情况下,对它的请求甚至会失败。

客户端生成器必须选择正确的 HTTP 路径,无论是否有命名空间。对于集群范围的资源,您必须使用此标签:

// +genclient:nonNamespaced

默认生成的是命名空间客户端。同样,这必须与 CRD 清单中的范围设置相匹配。对于特定用途的客户端,您可能还希望详细控制提供的 HTTP 方法。例如,您可以通过使用一些标签来实现这一点:

// +genclient:noVerbs
// +genclient:onlyVerbs=create,delete
// +genclient:skipVerbs=get,list,create,update,patch,delete,watch
// +genclient:method=Create,verb=create,
// result=k8s.io/apimachinery/pkg/apis/meta/v1.Status

前三个应该相当容易理解,但最后一个需要一些解释。

此标签上方写的类型将仅用于创建,并不会返回 API 类型本身,而是 metav1.Status。对于 CR 来说,这并没有太多意义,但对于用 Go 编写的用户提供的 API 服务器(参见 第八章),这些资源可能存在,并且实际中确实存在。

// +genclient:method= 标签的一个常见用例是添加一个用于缩放资源的方法。在 “Scale subresource” 中,我们描述了如何为 CR 启用 /scale 子资源。以下标签创建相应的客户端方法:

// +genclient:method=GetScale,verb=get,subresource=scale,\
//    result=k8s.io/api/autoscaling/v1.Scale
// +genclient:method=UpdateScale,verb=update,subresource=scale,\
//    input=k8s.io/api/autoscaling/v1.Scale,result=k8s.io/api/autoscaling/v1.Scale

第一个标签创建了 getter GetScale。第二个创建了 setter UpdateScale

注意

所有 CR /scale 子资源都接收并输出来自 autoscaling/v1 组的 Scale 类型。在 Kubernetes API 中,出于历史原因,有些资源使用其他类型。

informer-gen 和 lister-gen

informer-genlister-gen 处理 client-gen// +genclient 标签。没有其他配置可做。每个选择客户端生成的类型都会自动匹配客户端的 Informers 和 Listers(如果通过 k8s.io/code-generator/generate-group.sh 脚本调用整个生成器套件)。

Kubernetes 生成器的文档有很大的改进空间,并且肯定会随着时间的推移逐步完善。有关不同生成器的更多信息,通常最好查看 Kubernetes 本身的示例,例如,k8s.io/apiOpenShift API types。这两个存储库都有许多高级用例。

此外,不要犹豫地查看生成器本身。deepcopy-gen 在其 main.go 文件中有一些可用的文档。client-genKubernetes 贡献者文档 中有一些可用的文档。informer-genlister-gen 目前没有进一步的文档,但 generate-groups.sh 显示了每个生成器如何被调用的方法。

总结

在本章中,我们向您展示了如何为 CR 使用 Kubernetes 代码生成器。现在,我们转向更高级别的抽象工具——即编写自定义控制器和操作员的解决方案,这使您能够专注于业务逻辑。

^(1) Go 工具不会在需要时自动运行生成,也缺乏定义源文件和生成文件之间依赖关系的方法。

第六章:解决方案编写操作员

到目前为止,我们已经在“控制器和操作员”的概念层面上看过自定义控制器和操作员,以及在第五章中,如何使用 Kubernetes 代码生成器——这是一种处理该主题的较低级别方法。在本章中,我们将详细介绍三种编写自定义控制器和操作员的解决方案,并讨论一些更多的替代方法。

在本章讨论的解决方案中使用其中一个应该有助于您避免编写大量重复的代码,并使您能够专注于业务逻辑,而不是样板代码。这应该能够帮助您更快地入门并提高工作效率。

注意

总体而言,操作员以及我们在本章中讨论的工具截至 2019 年中仍在快速发展。尽管我们尽力,但这里显示的某些命令和/或它们的输出可能会更改。请考虑到这一点,并确保您始终使用相应工具的最新版本,并关注相应的问题跟踪器、邮件列表和 Slack 频道。

尽管在线上有可比较的资源可以比较我们在这里讨论的解决方案,但我们不会向您推荐特定的解决方案。不过,我们鼓励您自行评估和比较它们,并选择最适合您的组织和环境的解决方案。

准备工作

我们将使用cnat(云原生的 at,我们在“一个激励示例”中介绍过)作为本章中不同解决方案的运行示例。如果您想跟随我们一起学习,请注意我们假设您:

  1. 已经安装并正确设置了 Go 版本 1.12 或更高版本。

  2. 拥有一个 Kubernetes 集群,版本为 1.12 或更高版本——可以是本地的,例如通过kindk3d,也可以是通过您喜欢的云提供商远程的,并且配置了kubectl以访问它。

  3. git clone我们的GitHub 代码库。完整、可运行的源代码以及以下部分显示的必要命令都可以在那里找到。请注意,我们这里展示的是从零开始的工作方式。如果您只想查看结果而不是自己执行步骤,请随时克隆代码库并仅运行安装 CRD、安装 CR 和启动自定义控制器的命令。

在处理完这些杂务事项后,让我们开始编写操作员吧:我们将在本章涵盖sample-controller、Kubebuilder 和 Operator SDK。

准备好了吗?我们开始吧——此处有双关语!

跟随sample-controller

让我们从基于k8s.io/sample-controller实现cnat开始,它直接使用client-go库来实现。(请查看k8s.io/code-generator用于生成类型化客户端、通知器、列表器和深拷贝函数。每当你的自定义控制器中的 API 类型发生变化,例如在自定义资源中添加一个新字段,你都必须使用update-codegen.sh脚本(还可以在 GitHub 中查看其源码)重新生成上述源文件。

警告

你可能已经注意到本书中始终使用k8s.io作为基础 URL。我们在第三章介绍了它的用法;作为提醒,它实际上是kubernetes.io的别名,在 Go 包管理的上下文中解析为github.com/kubernetes。请注意,k8s.io不具有自动重定向功能。例如,k8s.io/sample-controller实际上意味着你应该查看github.com/kubernetes/sample-controller,等等。

好的,让我们使用cnat操作符来实现,使用client-go,遵循sample-controller。(参见我们仓库中的对应目录。)

引导

要开始,请执行go get k8s.io/sample-controller以获取源码和依赖项到你的系统中,应该位于$GOPATH/src/k8s.io/sample-\controller目录中。

如果你从零开始,将sample-controller目录的内容复制到你选择的目录中(例如,在我们的仓库中使用cnat-client-go),然后可以运行以下命令序列来构建和运行基础控制器(默认实现,尚未涉及cnat业务逻辑):

# build custom controller binary:
$ go build -o cnat-controller .

# launch custom controller locally:
$ ./cnat-controller -kubeconfig=$HOME/.kube/config

这个命令将启动自定义控制器,并等待你注册 CRD 并创建自定义资源。现在让我们来做这件事,并看看会发生什么。在第二个终端会话中,输入:

$ kubectl apply -f artifacts/examples/crd.yaml

确保 CRD 已正确注册并可用,如下所示:

$ kubectl get crds
NAME                            CREATED AT
foos.samplecontroller.k8s.io    2019-05-29T12:16:57Z

请注意,你可能会看到其他 CRD,这取决于你使用的 Kubernetes 发行版;但是,至少应该列出foos.samplecontroller.k8s.io

接下来,我们创建示例自定义资源foo.samplecontroller.k8s.io/example-foo并检查控制器是否正常工作:

$ kubectl apply -f artifacts/examples/example-foo.yaml
foo.samplecontroller.k8s.io/example-foo created

$ kubectl get po,rs,deploy,foo
NAME                                           READY   STATUS    RESTARTS   AGE
pod/example-foo-5b8c9679d8-xjhdf               1/1     Running   0          67s

NAME                                           DESIRED   CURRENT   READY AGE
replicaset.extensions/example-foo-5b8c9679d8   1         1         1     67s

NAME                                           READY   UP-TO-DATE   AVAILABLE   AGE
deployment.extensions/example-foo              1/1     1            1           67s

NAME                                           AGE
foo.samplecontroller.k8s.io/example-foo        67s

太棒了,它按预期工作!现在我们可以继续实现实际的cnat特定业务逻辑。

业务逻辑

要启动业务逻辑的实现,我们首先将现有的pkg/apis/samplecontroller目录重命名为pkg/apis/cnat,然后按以下步骤创建我们自己的 CRD 和自定义资源:

$ cat artifacts/examples/cnat-crd.yaml
apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition
metadata:
  name: ats.cnat.programming-kubernetes.info
spec:
  group: cnat.programming-kubernetes.info
  version: v1alpha1
  names:
    kind: At
    plural: ats
  scope: Namespaced

$ cat artifacts/examples/cnat-example.yaml
apiVersion: cnat.programming-kubernetes.info/v1alpha1
kind: At
metadata:
  labels:
    controller-tools.k8s.io: "1.0"
  name: example-at
spec:
  schedule: "2019-04-12T10:12:00Z"
  command: "echo YAY"

请注意,每当 API 类型发生变化时,例如当你向At CRD 中添加新字段时,你必须执行update-codegen.sh脚本,如下所示:

$ ./hack/update-codegen.sh

这将自动生成以下内容:

  • pkg/apis/cnat/v1alpha1/zz_generated.deepcopy.go

  • pkg/generated/*

在业务逻辑方面,我们需要实现运算符中的两个部分:

  • types.go中,我们修改了AtSpec结构体,包括相应的字段,如schedulecommand。请注意,每当您在此处进行更改时,都必须运行update-codegen.sh以重新生成依赖文件。

  • controller.go中,我们修改了NewController()syncHandler()函数,并添加了辅助函数,包括创建 pod 和检查调度时间。

types.go中,请注意代表At资源三个阶段的三个常量:在PENDING状态中直到调度时间,然后RUNNING直至完成,最后处于DONE状态:

// +genclient
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object

const (
    PhasePending = "PENDING"
    PhaseRunning = "RUNNING"
    PhaseDone    = "DONE"
)

// AtSpec defines the desired state of At
type AtSpec struct {
    // Schedule is the desired time the command is supposed to be executed.
    // Note: the format used here is UTC time https://www.utctime.net
    Schedule string `json:"schedule,omitempty"`
    // Command is the desired command (executed in a Bash shell) to be
    // executed.
    Command string `json:"command,omitempty"`
}

// AtStatus defines the observed state of At
type AtStatus struct {
    // Phase represents the state of the schedule: until the command is
    // executed it is PENDING, afterwards it is DONE.
    Phase string `json:"phase,omitempty"`
}

请注意,显式使用构建标签+k8s:deepcopy-gen:interfaces(参见第五章),以便自动生成相应的源代码。

现在我们可以开始实现自定义控制器的业务逻辑了。也就是说,我们在controller.go中实现了三个阶段之间的状态转换——从PhasePendingPhaseRunning再到PhaseDone

在“工作队列”中,我们介绍并解释了client-go提供的工作队列。现在我们可以将这些知识投入实践:在controller.goprocessNextWorkItem()函数中——更确切地说,在第 176 到 186 行——您可以找到以下(生成的)代码:

if when, err := c.syncHandler(key); err != nil {
    c.workqueue.AddRateLimited(key)
    return fmt.Errorf("error syncing '%s': %s, requeuing", key, err.Error())
} else if when != time.Duration(0) {
    c.workqueue.AddAfter(key, when)
} else {
    // Finally, if no error occurs we Forget this item so it does not
    // get queued again until another change happens.
    c.workqueue.Forget(obj)
}

此片段展示了我们(尚未编写的)自定义syncHandler()函数(稍后解释)如何被调用并涵盖这三种情况:

  1. 第一个if分支通过调用AddRateLimited()函数重新排队项目,处理瞬态错误。

  2. 第二个分支,即else if,通过调用AddAfter()函数重新排队项目,以避免热循环。

  3. 最后一个情况,即else,是项目已成功处理并通过调用Forget()函数丢弃。

现在我们已经对通用处理有了清晰的理解,让我们继续讨论业务逻辑特定的功能。其中关键的是前述的syncHandler()函数,我们在这里实现了自定义控制器的业务逻辑。其函数签名如下:

// syncHandler compares the actual state with the desired state and attempts
// to converge the two. It then updates the Status block of the At resource
// with the current status of the resource. It returns how long to wait
// until the schedule is due.
func (c *Controller) syncHandler(key string) (time.Duration, error) {
    ...
}

syncHandler()函数实现了以下状态转换:^(1)

...
// If no phase set, default to pending (the initial phase):
if instance.Status.Phase == "" {
    instance.Status.Phase = cnatv1alpha1.PhasePending
}

// Now let's make the main case distinction: implementing
// the state diagram PENDING -> RUNNING -> DONE
switch instance.Status.Phase {
case cnatv1alpha1.PhasePending:
    klog.Infof("instance %s: phase=PENDING", key)
    // As long as we haven't executed the command yet, we need
    // to check if it's time already to act:
    klog.Infof("instance %s: checking schedule %q", key, instance.Spec.Schedule)
    // Check if it's already time to execute the command with a
    // tolerance of 2 seconds:
    d, err := timeUntilSchedule(instance.Spec.Schedule)
    if err != nil {
        utilruntime.HandleError(fmt.Errorf("schedule parsing failed: %v", err))
        // Error reading the schedule - requeue the request:
        return time.Duration(0), err
    }
    klog.Infof("instance %s: schedule parsing done: diff=%v", key, d)
    if d > 0 {
        // Not yet time to execute the command, wait until the
        // scheduled time
        return d, nil
    }

    klog.Infof(
       "instance %s: it's time! Ready to execute: %s", key,
       instance.Spec.Command,
    )
    instance.Status.Phase = cnatv1alpha1.PhaseRunning
case cnatv1alpha1.PhaseRunning:
    klog.Infof("instance %s: Phase: RUNNING", key)

    pod := newPodForCR(instance)

    // Set At instance as the owner and controller
    owner := metav1.NewControllerRef(
        instance, cnatv1alpha1.SchemeGroupVersion.
        WithKind("At"),
    )
    pod.ObjectMeta.OwnerReferences = append(pod.ObjectMeta.OwnerReferences, *owner)

    // Try to see if the pod already exists and if not
    // (which we expect) then create a one-shot pod as per spec:
    found, err := c.kubeClientset.CoreV1().Pods(pod.Namespace).
        Get(pod.Name, metav1.GetOptions{})
    if err != nil && errors.IsNotFound(err) {
        found, err = c.kubeClientset.CoreV1().Pods(pod.Namespace).Create(pod)
        if err != nil {
            return time.Duration(0), err
        }
        klog.Infof("instance %s: pod launched: name=%s", key, pod.Name)
    } else if err != nil {
        // requeue with error
        return time.Duration(0), err
    } else if found.Status.Phase == corev1.PodFailed ||
        found.Status.Phase == corev1.PodSucceeded {
        klog.Infof(
            "instance %s: container terminated: reason=%q message=%q",
            key, found.Status.Reason, found.Status.Message,
        )
        instance.Status.Phase = cnatv1alpha1.PhaseDone
    } else {
        // Don't requeue because it will happen automatically
        // when the pod status changes.
        return time.Duration(0), nil
    }
case cnatv1alpha1.PhaseDone:
    klog.Infof("instance %s: phase: DONE", key)
    return time.Duration(0), nil
default:
    klog.Infof("instance %s: NOP")
    return time.Duration(0), nil
}

// Update the At instance, setting the status to the respective phase:
_, err = c.cnatClientset.CnatV1alpha1().Ats(instance.Namespace).
    UpdateStatus(instance)
if err != nil {
    return time.Duration(0), err
}

// Don't requeue. We should be reconcile because either the pod or
// the CR changes.
return time.Duration(0), nil

此外,为了设置 informer 和整个控制器,我们在NewController()中实现了以下内容:

// NewController returns a new cnat controller
func NewController(
    kubeClientset kubernetes.Interface,
    cnatClientset clientset.Interface,
    atInformer informers.AtInformer,
    podInformer corev1informer.PodInformer) *Controller {

    // Create event broadcaster
    // Add cnat-controller types to the default Kubernetes Scheme so Events
    // can be logged for cnat-controller types.
    utilruntime.Must(cnatscheme.AddToScheme(scheme.Scheme))
    klog.V(4).Info("Creating event broadcaster")
    eventBroadcaster := record.NewBroadcaster()
    eventBroadcaster.StartLogging(klog.Infof)
    eventBroadcaster.StartRecordingToSink(&typedcorev1.EventSinkImpl{
        Interface: kubeClientset.CoreV1().Events(""),
    })
    source := corev1.EventSource{Component: controllerAgentName}
    recorder := eventBroadcaster.NewRecorder(scheme.Scheme, source)

    rateLimiter := workqueue.DefaultControllerRateLimiter()
    controller := &Controller{
        kubeClientset: kubeClientset,
        cnatClientset: cnatClientset,
        atLister:      atInformer.Lister(),
        atsSynced:     atInformer.Informer().HasSynced,
        podLister:     podInformer.Lister(),
        podsSynced:    podInformer.Informer().HasSynced,
        workqueue:     workqueue.NewNamedRateLimitingQueue(rateLimiter, "Ats"),
        recorder:      recorder,
    }

    klog.Info("Setting up event handlers")
    // Set up an event handler for when At resources change
    atInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
        AddFunc: controller.enqueueAt,
        UpdateFunc: func(old, new interface{}) {
            controller.enqueueAt(new)
        },
    })
    // Set up an event handler for when Pod resources change
    podInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
        AddFunc: controller.enqueuePod,
        UpdateFunc: func(old, new interface{}) {
            controller.enqueuePod(new)
        },
    })
    return controller
}

我们还需要两个进一步的辅助函数来使其工作:一个计算直到调度时间的时间,形式如下:

func timeUntilSchedule(schedule string) (time.Duration, error) {
    now := time.Now().UTC()
    layout := "2006-01-02T15:04:05Z"
    s, err := time.Parse(layout, schedule)
    if err != nil {
        return time.Duration(0), err
    }
    return s.Sub(now), nil
}

另外一个则使用busybox容器镜像创建一个带有执行命令的 pod:

func newPodForCR(cr *cnatv1alpha1.At) *corev1.Pod {
    labels := map[string]string{
        "app": cr.Name,
    }
    return &corev1.Pod{
        ObjectMeta: metav1.ObjectMeta{
            Name:      cr.Name + "-pod",
            Namespace: cr.Namespace,
            Labels:    labels,
        },
        Spec: corev1.PodSpec{
            Containers: []corev1.Container{
                {
                    Name:    "busybox",
                    Image:   "busybox",
                    Command: strings.Split(cr.Spec.Command, " "),
                },
            },
            RestartPolicy: corev1.RestartPolicyOnFailure,
        },
    }
}

我们将在本章后面的syncHandler()函数中重复使用这两个辅助函数和业务逻辑的基本流程,请确保您熟悉它们的详细内容。

注意,从 At 资源的角度来看,Pod 是一个次要资源,控制器必须确保清理这些 Pod,否则可能导致孤立的 Pod。

现在,sample-controller 是一个学习如何制作香肠的好工具,但通常情况下,您希望专注于创建业务逻辑,而不是处理样板代码。为此,您可以选择两个相关项目:Kubebuilder 和 Operator SDK。让我们分别看看它们以及如何使用它们实现 cnat

Kubebuilder

Kubebuilder,由 Kubernetes 特别兴趣小组(SIG)API Machinery 拥有和维护,是一款工具和一组库,能够让您以简单高效的方式构建运算符。深入了解 Kubebuilder 最佳资源是在线的 Kubebuilder 书籍,该书籍详细介绍了其组件和用法。不过,我们在这里将专注于如何使用 Kubebuilder 实现我们的 cnat 运算符(请参阅我们 Git 仓库中的 对应目录)。

首先,让我们确保已安装所有依赖项,即 depkustomize(参见 “Kustomize”)以及 Kubebuilder 本身

$ dep version
dep:
 version     : v0.5.1
 build date  : 2019-03-11
 git hash    : faa6189
 go version  : go1.12
 go compiler : gc
 platform    : darwin/amd64
 features    : ImportDuringSolve=false

$ kustomize version
Version: {KustomizeVersion:v2.0.3 GitCommit:a6f65144121d1955266b0cd836ce954c04122dc8
          BuildDate:2019-03-18T22:15:21+00:00 GoOs:darwin GoArch:amd64}

$ kubebuilder version
Version: version.Version{
  KubeBuilderVersion:"1.0.8",
  KubernetesVendor:"1.13.1",
  GitCommit:"1adf50ed107f5042d7472ba5ab50d5e1d357169d",
  BuildDate:"2019-01-25T23:14:29Z", GoOs:"unknown", GoArch:"unknown"
}

我们将逐步指导您从头开始编写 cnat 运算符的步骤。首先,在您选择的目录(我们在我们的仓库中使用 cnat-kubebuilder)中创建基础目录,这将作为以后所有命令的基础。

警告

在撰写本文时,Kubebuilder 正在迁移到新版本(v2)。由于尚未稳定,我们展示的是(稳定的)版本 v1 的命令和设置。

启动

要启动 cnat 运算符,我们使用 init 命令,如下(请注意,根据您的环境,此过程可能需要几分钟):

$ kubebuilder init \
              --domain programming-kubernetes.info \
              --license apache2 \
              --owner "Programming Kubernetes authors"
Run `dep ensure` to fetch dependencies (Recommended) [y/n]?
y
dep ensure
Running make...
make
go generate ./pkg/... ./cmd/...
go fmt ./pkg/... ./cmd/...
go vet ./pkg/... ./cmd/...
go run vendor/sigs.k8s.io/controller-tools/cmd/controller-gen/main.go all
CRD manifests generated under 'config/crds'
RBAC manifests generated under 'config/rbac'
go test ./pkg/... ./cmd/... -coverprofile cover.out
?       github.com/mhausenblas/cnat-kubebuilder/pkg/apis        [no test files]
?       github.com/mhausenblas/cnat-kubebuilder/pkg/controller  [no test files]
?       github.com/mhausenblas/cnat-kubebuilder/pkg/webhook     [no test files]
?       github.com/mhausenblas/cnat-kubebuilder/cmd/manager     [no test files]
go build -o bin/manager github.com/mhausenblas/cnat-kubebuilder/cmd/manager

完成此命令后,Kubebuilder 将生成一堆文件,从自定义控制器到示例 CRD。您的基础目录现在应该看起来像下面这样(为清晰起见,不包括庞大的 vendor 目录):

$ tree -I vendor
.
├── Dockerfile
├── Gopkg.lock
├── Gopkg.toml
├── Makefile
├── PROJECT
├── bin
│   └── manager
├── cmd
│   └── manager
│       └── main.go
├── config
│   ├── crds
│   ├── default
│   │   ├── kustomization.yaml
│   │   ├── manager_auth_proxy_patch.yaml
│   │   ├── manager_image_patch.yaml
│   │   └── manager_prometheus_metrics_patch.yaml
│   ├── manager
│   │   └── manager.yaml
│   └── rbac
│       ├── auth_proxy_role.yaml
│       ├── auth_proxy_role_binding.yaml
│       ├── auth_proxy_service.yaml
│       ├── rbac_role.yaml
│       └── rbac_role_binding.yaml
├── cover.out
├── hack
│   └── boilerplate.go.txt
└── pkg
    ├── apis
    │   └── apis.go
    ├── controller
    │   └── controller.go
    └── webhook
        └── webhook.go

13 directories, 22 files

接下来,我们使用 create api 命令创建 API,也就是自定义控制器(这比之前的命令更快,但仍需一段时间):

$ kubebuilder create api \
              --group cnat \
              --version v1alpha1 \
              --kind At
Create Resource under pkg/apis [y/n]?
y
Create Controller under pkg/controller [y/n]?
y
Writing scaffold for you to edit...
pkg/apis/cnat/v1alpha1/at_types.go
pkg/apis/cnat/v1alpha1/at_types_test.go
pkg/controller/at/at_controller.go
pkg/controller/at/at_controller_test.go
Running make...
go generate ./pkg/... ./cmd/...
go fmt ./pkg/... ./cmd/...
go vet ./pkg/... ./cmd/...
go run vendor/sigs.k8s.io/controller-tools/cmd/controller-gen/main.go all
CRD manifests generated under 'config/crds'
RBAC manifests generated under 'config/rbac'
go test ./pkg/... ./cmd/... -coverprofile cover.out
?       github.com/mhausenblas/cnat-kubebuilder/pkg/apis        [no test files]
?       github.com/mhausenblas/cnat-kubebuilder/pkg/apis/cnat   [no test files]
ok      github.com/mhausenblas/cnat-kubebuilder/pkg/apis/cnat/v1alpha1  9.011s
?       github.com/mhausenblas/cnat-kubebuilder/pkg/controller  [no test files]
ok      github.com/mhausenblas/cnat-kubebuilder/pkg/controller/at       8.740s
?       github.com/mhausenblas/cnat-kubebuilder/pkg/webhook     [no test files]
?       github.com/mhausenblas/cnat-kubebuilder/cmd/manager     [no test files]
go build -o bin/manager github.com/mhausenblas/cnat-kubebuilder/cmd/manager

让我们来看看有哪些变化,重点放在两个已更新并添加内容的目录上:

$ tree config/ pkg/
config/
├── crds
│   └── cnat_v1alpha1_at.yaml
├── default
│   ├── kustomization.yaml
│   ├── manager_auth_proxy_patch.yaml
│   ├── manager_image_patch.yaml
│   └── manager_prometheus_metrics_patch.yaml
├── manager
│   └── manager.yaml
├── rbac
│   ├── auth_proxy_role.yaml
│   ├── auth_proxy_role_binding.yaml
│   ├── auth_proxy_service.yaml
│   ├── rbac_role.yaml
│   └── rbac_role_binding.yaml
└── samples
    └── cnat_v1alpha1_at.yaml
pkg/
├── apis
│   ├── addtoscheme_cnat_v1alpha1.go
│   ├── apis.go
│   └── cnat
│       ├── group.go
│       └── v1alpha1
│           ├── at_types.go
│           ├── at_types_test.go
│           ├── doc.go
│           ├── register.go
│           ├── v1alpha1_suite_test.go
│           └── zz_generated.deepcopy.go
├── controller
│   ├── add_at.go
│   ├── at
│   │   ├── at_controller.go
│   │   ├── at_controller_suite_test.go
│   │   └── at_controller_test.go
│   └── controller.go
└── webhook
    └── webhook.go

11 directories, 27 files

注意在 config/crds/ 中新增了 cnat_v1alpha1_at.yaml,这是自定义资源定义(CRD)的一部分;同时,在 config/samples/ 中也有一个名为 cnat_v1alpha1_at.yaml 的文件(是的,同名),表示了该 CRD 的一个示例实例。此外,在 pkg/ 目录下我们看到了一些新文件,其中最重要的是 apis/cnat/v1alpha1/at_types.gocontroller/at/at_controller.go,我们接下来将修改它们。

接下来,我们在 Kubernetes 中创建一个专用的命名空间cnat,并将其用作默认命名空间,设置上下文如下(作为一个良好的实践,始终使用专用命名空间而不是default):

$ kubectl create ns cnat && \
  kubectl config set-context $(kubectl config current-context) --namespace=cnat

我们使用以下命令安装 CRD:

$ make install
go run vendor/sigs.k8s.io/controller-tools/cmd/controller-gen/main.go all
CRD manifests generated under 'config/crds'
RBAC manifests generated under 'config/rbac'
kubectl apply -f config/crds
customresourcedefinition.apiextensions.k8s.io/ats.cnat.programming-kubernetes.info created

现在我们可以在本地启动操作者:

$ make run
go generate ./pkg/... ./cmd/...
go fmt ./pkg/... ./cmd/...
go vet ./pkg/... ./cmd/...
go run ./cmd/manager/main.go
{"level":"info","ts":1559152740.0550249,"logger":"entrypoint",
  "msg":"setting up client for manager"}
{"level":"info","ts":1559152740.057556,"logger":"entrypoint",
  "msg":"setting up manager"}
{"level":"info","ts":1559152740.1396701,"logger":"entrypoint",
  "msg":"Registering Components."}
{"level":"info","ts":1559152740.1397,"logger":"entrypoint",
  "msg":"setting up scheme"}
{"level":"info","ts":1559152740.139773,"logger":"entrypoint",
  "msg":"Setting up controller"}
{"level":"info","ts":1559152740.139831,"logger":"kubebuilder.controller",
  "msg":"Starting EventSource","controller":"at-controller",
  "source":"kind source: /, Kind="}
{"level":"info","ts":1559152740.139929,"logger":"kubebuilder.controller",
  "msg":"Starting EventSource","controller":"at-controller",
  "source":"kind source: /, Kind="}
{"level":"info","ts":1559152740.139971,"logger":"entrypoint",
  "msg":"setting up webhooks"}
{"level":"info","ts":1559152740.13998,"logger":"entrypoint",
  "msg":"Starting the Cmd."}
{"level":"info","ts":1559152740.244628,"logger":"kubebuilder.controller",
  "msg":"Starting Controller","controller":"at-controller"}
{"level":"info","ts":1559152740.344791,"logger":"kubebuilder.controller",
  "msg":"Starting workers","controller":"at-controller","worker count":1}

将终端会话保持运行状态,并在新会话中安装 CRD,验证它,并像这样创建样本自定义资源:

$ kubectl apply -f config/crds/cnat_v1alpha1_at.yaml
customresourcedefinition.apiextensions.k8s.io/ats.cnat.programming-kubernetes.info
configured

$ kubectl get crds
NAME                                   CREATED AT
ats.cnat.programming-kubernetes.info   2019-05-29T17:54:51Z

$ kubectl apply -f config/samples/cnat_v1alpha1_at.yaml
at.cnat.programming-kubernetes.info/at-sample created

如果您现在查看make run运行的会话输出,您应该注意到以下输出:

...
{"level":"info","ts":1559153311.659829,"logger":"controller",
  "msg":"Creating Deployment","namespace":"cnat","name":"at-sample-deployment"}
{"level":"info","ts":1559153311.678407,"logger":"controller",
  "msg":"Updating Deployment","namespace":"cnat","name":"at-sample-deployment"}
{"level":"info","ts":1559153311.6839428,"logger":"controller",
  "msg":"Updating Deployment","namespace":"cnat","name":"at-sample-deployment"}
{"level":"info","ts":1559153311.693443,"logger":"controller",
  "msg":"Updating Deployment","namespace":"cnat","name":"at-sample-deployment"}
{"level":"info","ts":1559153311.7023401,"logger":"controller",
  "msg":"Updating Deployment","namespace":"cnat","name":"at-sample-deployment"}
{"level":"info","ts":1559153332.986961,"logger":"controller",#
  "msg":"Updating Deployment","namespace":"cnat","name":"at-sample-deployment"}

这告诉我们整体设置是成功的!既然我们完成了搭建并成功启动了cnat操作者,现在可以继续执行实际的核心任务:使用 Kubebuilder 实现cnat业务逻辑。

业务逻辑

首先,我们将更改config/crds/cnat_v1alpha1_at.yamlconfig/samples/cnat_v1alpha1_at.yaml以适应我们自己的cnat CRD 和自定义资源值定义,重用与“Following sample-controller”中相同的结构。

就业务逻辑而言,操作者有两个部分需要实现:

  • pkg/apis/cnat/v1alpha1/at_types.go中,我们修改了AtSpec结构体,包括相关字段如schedulecommand。请注意,每当您在这里更改了某些内容时,都必须运行make来重新生成依赖文件。Kubebuilder 使用 Kubernetes 生成器(在第五章中描述)并提供自己的生成器集(例如,用于生成 CRD 清单)。

  • pkg/controller/at/at_controller.go中,我们修改Reconcile(request reconcile.Request)方法以在Spec.Schedule定义的时间创建一个 Pod。

at_types.go中:

const (
    PhasePending = "PENDING"
    PhaseRunning = "RUNNING"
    PhaseDone    = "DONE"
)

// AtSpec defines the desired state of At
type AtSpec struct {
    // Schedule is the desired time the command is supposed to be executed.
    // Note: the format used here is UTC time https://www.utctime.net
    Schedule string `json:"schedule,omitempty"`
    // Command is the desired command (executed in a Bash shell) to be executed.
    Command string `json:"command,omitempty"`
}

// AtStatus defines the observed state of At
type AtStatus struct {
    // Phase represents the state of the schedule: until the command is executed
    // it is PENDING, afterwards it is DONE.
    Phase string `json:"phase,omitempty"`
}

at_controller.go中,我们实现了三个阶段之间的状态转换,从PENDINGRUNNING再到DONE

func (r *ReconcileAt) Reconcile(req reconcile.Request) (reconcile.Result, error) {
    reqLogger := log.WithValues("namespace", req.Namespace, "at", req.Name)
    reqLogger.Info("=== Reconciling At")
    // Fetch the At instance
    instance := &cnatv1alpha1.At{}
    err := r.Get(context.TODO(), req.NamespacedName, instance)
    if err != nil {
        if errors.IsNotFound(err) {
            // Request object not found, could have been deleted after
            // reconcile request—return and don't requeue:
            return reconcile.Result{}, nil
        }
            // Error reading the object—requeue the request:
        return reconcile.Result{}, err
    }

    // If no phase set, default to pending (the initial phase):
    if instance.Status.Phase == "" {
        instance.Status.Phase = cnatv1alpha1.PhasePending
    }

    // Now let's make the main case distinction: implementing
    // the state diagram PENDING -> RUNNING -> DONE
    switch instance.Status.Phase {
    case cnatv1alpha1.PhasePending:
        reqLogger.Info("Phase: PENDING")
        // As long as we haven't executed the command yet, we need to check if
        // it's already time to act:
        reqLogger.Info("Checking schedule", "Target", instance.Spec.Schedule)
        // Check if it's already time to execute the command with a tolerance
        // of 2 seconds:
        d, err := timeUntilSchedule(instance.Spec.Schedule)
        if err != nil {
            reqLogger.Error(err, "Schedule parsing failure")
            // Error reading the schedule. Wait until it is fixed.
            return reconcile.Result{}, err
        }
        reqLogger.Info("Schedule parsing done", "Result", "diff",
            fmt.Sprintf("%v", d))
        if d > 0 {
            // Not yet time to execute the command, wait until the scheduled time
            return reconcile.Result{RequeueAfter: d}, nil
        }
        reqLogger.Info("It's time!", "Ready to execute", instance.Spec.Command)
        instance.Status.Phase = cnatv1alpha1.PhaseRunning
    case cnatv1alpha1.PhaseRunning:
        reqLogger.Info("Phase: RUNNING")
        pod := newPodForCR(instance)
        // Set At instance as the owner and controller
        err := controllerutil.SetControllerReference(instance, pod, r.scheme)
        if err != nil {
            // requeue with error
            return reconcile.Result{}, err
        }
        found := &corev1.Pod{}
        nsName := types.NamespacedName{Name: pod.Name, Namespace: pod.Namespace}
        err = r.Get(context.TODO(), nsName, found)
        // Try to see if the pod already exists and if not
        // (which we expect) then create a one-shot pod as per spec:
        if err != nil && errors.IsNotFound(err) {
            err = r.Create(context.TODO(), pod)
            if err != nil {
            // requeue with error
                return reconcile.Result{}, err
            }
            reqLogger.Info("Pod launched", "name", pod.Name)
        } else if err != nil {
            // requeue with error
            return reconcile.Result{}, err
        } else if found.Status.Phase == corev1.PodFailed ||
                  found.Status.Phase == corev1.PodSucceeded {
            reqLogger.Info("Container terminated", "reason",
                found.Status.Reason, "message", found.Status.Message)
            instance.Status.Phase = cnatv1alpha1.PhaseDone
        } else {
            // Don't requeue because it will happen automatically when the
            // pod status changes.
            return reconcile.Result{}, nil
        }
    case cnatv1alpha1.PhaseDone:
        reqLogger.Info("Phase: DONE")
        return reconcile.Result{}, nil
    default:
        reqLogger.Info("NOP")
        return reconcile.Result{}, nil
    }

    // Update the At instance, setting the status to the respective phase:
    err = r.Status().Update(context.TODO(), instance)
    if err != nil {
        return reconcile.Result{}, err
    }

    // Don't requeue. We should be reconcile because either the pod
    // or the CR changes.
    return reconcile.Result{}, nil
}

注意在最后的Update调用中,操作的是/status子资源(参见“状态子资源”),而不是整个 CR。因此,我们遵循了规范状态分离的最佳实践。

现在,一旦创建了 CR example-at,我们就会看到本地执行操作者的以下输出:

$ make run
...
{"level":"info","ts":1555063897.488535,"logger":"controller",
  "msg":"=== Reconciling At","namespace":"cnat","at":"example-at"}
{"level":"info","ts":1555063897.488621,"logger":"controller",
  "msg":"Phase: PENDING","namespace":"cnat","at":"example-at"}
{"level":"info","ts":1555063897.4886441,"logger":"controller",
  "msg":"Checking schedule","namespace":"cnat","at":"example-at",
  "Target":"2019-04-12T10:12:00Z"}
{"level":"info","ts":1555063897.488703,"logger":"controller",
  "msg":"Schedule parsing done","namespace":"cnat","at":"example-at",
  "Result":"2019-04-12 10:12:00 +0000 UTC with a diff of 22.511336s"}
{"level":"info","ts":1555063907.489264,"logger":"controller",
  "msg":"=== Reconciling At","namespace":"cnat","at":"example-at"}
{"level":"info","ts":1555063907.489402,"logger":"controller",
  "msg":"Phase: PENDING","namespace":"cnat","at":"example-at"}
{"level":"info","ts":1555063907.489428,"logger":"controller",
  "msg":"Checking schedule","namespace":"cnat","at":"example-at",
  "Target":"2019-04-12T10:12:00Z"}
{"level":"info","ts":1555063907.489486,"logger":"controller",
  "msg":"Schedule parsing done","namespace":"cnat","at":"example-at",
  "Result":"2019-04-12 10:12:00 +0000 UTC with a diff of 12.510551s"}
{"level":"info","ts":1555063917.490178,"logger":"controller",
  "msg":"=== Reconciling At","namespace":"cnat","at":"example-at"}
{"level":"info","ts":1555063917.4902349,"logger":"controller",
  "msg":"Phase: PENDING","namespace":"cnat","at":"example-at"}
{"level":"info","ts":1555063917.490247,"logger":"controller",
  "msg":"Checking schedule","namespace":"cnat","at":"example-at",
  "Target":"2019-04-12T10:12:00Z"}
{"level":"info","ts":1555063917.490278,"logger":"controller",
  "msg":"Schedule parsing done","namespace":"cnat","at":"example-at",
  "Result":"2019-04-12 10:12:00 +0000 UTC with a diff of 2.509743s"}
{"level":"info","ts":1555063927.492718,"logger":"controller",
  "msg":"=== Reconciling At","namespace":"cnat","at":"example-at"}
{"level":"info","ts":1555063927.49283,"logger":"controller",
  "msg":"Phase: PENDING","namespace":"cnat","at":"example-at"}
{"level":"info","ts":1555063927.492857,"logger":"controller",
  "msg":"Checking schedule","namespace":"cnat","at":"example-at",
  "Target":"2019-04-12T10:12:00Z"}
{"level":"info","ts":1555063927.492915,"logger":"controller",
  "msg":"Schedule parsing done","namespace":"cnat","at":"example-at",
  "Result":"2019-04-12 10:12:00 +0000 UTC with a diff of -7.492877s"}
{"level":"info","ts":1555063927.4929411,"logger":"controller",
  "msg":"It's time!","namespace":"cnat","at":
  "example-at","Ready to execute":"echo YAY"}
{"level":"info","ts":1555063927.626236,"logger":"controller",
  "msg":"=== Reconciling At","namespace":"cnat","at":"example-at"}
{"level":"info","ts":1555063927.626303,"logger":"controller",
  "msg":"Phase: RUNNING","namespace":"cnat","at":"example-at"}
{"level":"info","ts":1555063928.07445,"logger":"controller",
  "msg":"Pod launched","namespace":"cnat","at":"example-at",
  "name":"example-at-pod"}
{"level":"info","ts":1555063928.199562,"logger":"controller",
  "msg":"=== Reconciling At","namespace":"cnat","at":"example-at"}
{"level":"info","ts":1555063928.199645,"logger":"controller",
  "msg":"Phase: DONE","namespace":"cnat","at":"example-at"}
{"level":"info","ts":1555063937.631733,"logger":"controller",
  "msg":"=== Reconciling At","namespace":"cnat","at":"example-at"}
{"level":"info","ts":1555063937.631783,"logger":"controller",
  "msg":"Phase: DONE","namespace":"cnat","at":"example-at"}
...

要验证我们的自定义控制器是否完成了其工作,请执行:

$ kubectl get at,pods
NAME                                                  AGE
at.cnat.programming-kubernetes.info/example-at        11m

NAME                 READY   STATUS        RESTARTS   AGE
pod/example-at-pod   0/1     Completed     0          38s

太棒了!example-at-pod已经创建,现在是时候看操作的结果了:

$ kubectl logs example-at-pod
YAY

当您完成开发自定义控制器时,使用如此展示的本地模式时,您可能希望构建一个容器镜像。此自定义控制器容器镜像随后可以在 Kubernetes 部署中使用,例如。您可以使用以下命令生成容器镜像并将其推送到仓库quay.io/pk/cnat

$ export IMG=quay.io/pk/cnat:v1

$ make docker-build

$ make docker-push

接下来我们转向 Operator SDK,它与 Kubebuilder 的代码库和 API 有些共享。

Operator SDK

为了更容易构建 Kubernetes 应用程序,CoreOS/Red Hat 组合了 Operator Framework。 其中的一部分是 Operator SDK,它使开发人员能够构建操作者,而无需深入了解 Kubernetes API。

Operator SDK 提供了构建、测试和打包操作者的工具。 虽然 SDK 提供了更多功能,特别是关于测试的功能,但我们在这里专注于使用 SDK 实现我们的 cnat 操作者(参见 我们 Git 仓库中对应的目录)。

首先确保 安装 Operator SDK,并检查所有依赖项是否可用:

$ dep version
dep:
 version     : v0.5.1
 build date  : 2019-03-11
 git hash    : faa6189
 go version  : go1.12
 go compiler : gc
 platform    : darwin/amd64
 features    : ImportDuringSolve=false

 $ operator-sdk --version
operator-sdk version v0.6.0

引导

现在是时候引导 cnat 操作者了:

$ operator-sdk new cnat-operator && cd cnat-operator

接下来,与 Kubebuilder 非常相似,我们添加一个 API——或者简单地说:初始化自定义控制器,如下所示:

$ operator-sdk add api \
               --api-version=cnat.programming-kubernetes.info/v1alpha1 \
               --kind=At

$ operator-sdk add controller \
               --api-version=cnat.programming-kubernetes.info/v1alpha1 \
               --kind=At

这些命令生成了必要的样板代码,以及一些辅助函数,例如深度复制函数 DeepCopy()DeepCopyInto()DeepCopyObject()

现在我们可以将自动生成的 CRD 应用到 Kubernetes 集群中:

$ kubectl apply -f deploy/crds/cnat_v1alpha1_at_crd.yaml

$ kubectl get crds
NAME                                             CREATED AT
ats.cnat.programming-kubernetes.info             2019-04-01T14:03:33Z

让我们在本地启动我们的 cnat 自定义控制器。 通过这样做,它可以开始处理请求:

$ OPERATOR_NAME=cnatop operator-sdk up local --namespace "cnat"
INFO[0000] Running the operator locally.
INFO[0000] Using namespace cnat.
{"level":"info","ts":1555041531.871706,"logger":"cmd",
  "msg":"Go Version: go1.12.1"}
{"level":"info","ts":1555041531.871785,"logger":"cmd",
  "msg":"Go OS/Arch: darwin/amd64"}
{"level":"info","ts":1555041531.8718028,"logger":"cmd",
  "msg":"Version of operator-sdk: v0.6.0"}
{"level":"info","ts":1555041531.8739321,"logger":"leader",
  "msg":"Trying to become the leader."}
{"level":"info","ts":1555041531.8743382,"logger":"leader",
  "msg":"Skipping leader election; not running in a cluster."}
{"level":"info","ts":1555041536.1611362,"logger":"cmd",
  "msg":"Registering Components."}
{"level":"info","ts":1555041536.1622112,"logger":"kubebuilder.controller",
  "msg":"Starting EventSource","controller":"at-controller",
  "source":"kind source: /, Kind="}
{"level":"info","ts":1555041536.162519,"logger":"kubebuilder.controller",
  "msg":"Starting EventSource","controller":"at-controller",
  "source":"kind source: /, Kind="}
{"level":"info","ts":1555041539.978822,"logger":"metrics",
  "msg":"Skipping metrics Service creation; not running in a cluster."}
{"level":"info","ts":1555041539.978875,"logger":"cmd",
  "msg":"Starting the Cmd."}
{"level":"info","ts":1555041540.179469,"logger":"kubebuilder.controller",
  "msg":"Starting Controller","controller":"at-controller"}
{"level":"info","ts":1555041540.280784,"logger":"kubebuilder.controller",
  "msg":"Starting workers","controller":"at-controller","worker count":1}

我们的自定义控制器将保持在此状态,直到我们创建了 CR ats.cnat.programming-kubernetes.info。 那么让我们开始吧:

$ cat deploy/crds/cnat_v1alpha1_at_cr.yaml
apiVersion: cnat.programming-kubernetes.info/v1alpha1
kind: At
metadata:
  name: example-at
spec:
  schedule: "2019-04-11T14:56:30Z"
  command: "echo YAY"

$ kubectl apply -f deploy/crds/cnat_v1alpha1_at_cr.yaml

$ kubectl get at
NAME                                             AGE
at.cnat.programming-kubernetes.info/example-at   54s

业务逻辑

在业务逻辑方面,我们需要实现操作者的两个部分:

  • pkg/apis/cnat/v1alpha1/at_types.go 中,我们修改了 AtSpec 结构以包括相应的字段,如 schedulecommand,并使用 operator-sdk generate k8s 重新生成代码,以及使用 operator-sdk generate openapi 命令处理 OpenAPI 部分。

  • pkg/controller/at/at_controller.go 中,我们修改了 Reconcile(request reconcile.Request) 方法,以在 Spec.Schedule 定义的时间创建一个 pod。

更详细地查看引导代码中的更改(专注于相关部分)。 在 at_types.go 中:

// AtSpec defines the desired state of At
// +k8s:openapi-gen=true
type AtSpec struct {
    // Schedule is the desired time the command is supposed to be executed.
    // Note: the format used here is UTC time https://www.utctime.net
    Schedule string `json:"schedule,omitempty"`
    // Command is the desired command (executed in a Bash shell) to be executed.
    Command string `json:"command,omitempty"`
}

// AtStatus defines the observed state of At
// +k8s:openapi-gen=true
type AtStatus struct {
    // Phase represents the state of the schedule: until the command is executed
    // it is PENDING, afterwards it is DONE.
    Phase string `json:"phase,omitempty"`
}

at_controller.go 中,我们实现了三个阶段的状态图,从 PENDINGRUNNINGDONE

注意

controller-runtime 是另一个 SIG API Machinery 拥有的项目,旨在提供一套通用的低级功能,用于以 Go 包的形式构建控制器。 更多细节请参见 第四章。

由于 Kubebuilder 和 Operator SDK 都使用控制器运行时,Reconcile() 函数实际上是相同的:

func (r *ReconcileAt) Reconcile(request reconcile.Request) (reconcile.Result, error) {
    *`the``-``same``-``as``-``for``-``kubebuilder`*
}

一旦创建了 CR example-at,我们就可以看到本地执行操作者的以下输出:

$ OPERATOR_NAME=cnatop operator-sdk up local --namespace "cnat"
INFO[0000] Running the operator locally.
INFO[0000] Using namespace cnat.
...
{"level":"info","ts":1555044934.023597,"logger":"controller_at",
  "msg":"=== Reconciling At","namespace":"cnat","at":"example-at"}
{"level":"info","ts":1555044934.023713,"logger":"controller_at",
  "msg":"Phase: PENDING","namespace":"cnat","at":"example-at"}
{"level":"info","ts":1555044934.0237482,"logger":"controller_at",
  "msg":"Checking schedule","namespace":"cnat","at":
  "example-at","Target":"2019-04-12T04:56:00Z"}
{"level":"info","ts":1555044934.02382,"logger":"controller_at",
  "msg":"Schedule parsing done","namespace":"cnat","at":"example-at",
  "Result":"2019-04-12 04:56:00 +0000 UTC with a diff of 25.976236s"}
{"level":"info","ts":1555044934.148148,"logger":"controller_at",
  "msg":"=== Reconciling At","namespace":"cnat","at":"example-at"}
{"level":"info","ts":1555044934.148224,"logger":"controller_at",
  "msg":"Phase: PENDING","namespace":"cnat","at":"example-at"}
{"level":"info","ts":1555044934.148243,"logger":"controller_at",
  "msg":"Checking schedule","namespace":"cnat","at":"example-at",
  "Target":"2019-04-12T04:56:00Z"}
{"level":"info","ts":1555044934.1482902,"logger":"controller_at",
  "msg":"Schedule parsing done","namespace":"cnat","at":"example-at",
  "Result":"2019-04-12 04:56:00 +0000 UTC with a diff of 25.85174s"}
{"level":"info","ts":1555044944.1504588,"logger":"controller_at",
  "msg":"=== Reconciling At","namespace":"cnat","at":"example-at"}
{"level":"info","ts":1555044944.150568,"logger":"controller_at",
  "msg":"Phase: PENDING","namespace":"cnat","at":"example-at"}
{"level":"info","ts":1555044944.150599,"logger":"controller_at",
  "msg":"Checking schedule","namespace":"cnat","at":"example-at",
  "Target":"2019-04-12T04:56:00Z"}
{"level":"info","ts":1555044944.150663,"logger":"controller_at",
  "msg":"Schedule parsing done","namespace":"cnat","at":"example-at",
  "Result":"2019-04-12 04:56:00 +0000 UTC with a diff of 15.84938s"}
{"level":"info","ts":1555044954.385175,"logger":"controller_at",
  "msg":"=== Reconciling At","namespace":"cnat","at":"example-at"}
{"level":"info","ts":1555044954.3852649,"logger":"controller_at",
  "msg":"Phase: PENDING","namespace":"cnat","at":"example-at"}
{"level":"info","ts":1555044954.385288,"logger":"controller_at",
  "msg":"Checking schedule","namespace":"cnat","at":"example-at",
  "Target":"2019-04-12T04:56:00Z"}
{"level":"info","ts":1555044954.38534,"logger":"controller_at",
  "msg":"Schedule parsing done","namespace":"cnat","at":"example-at",
  "Result":"2019-04-12 04:56:00 +0000 UTC with a diff of 5.614691s"}
{"level":"info","ts":1555044964.518383,"logger":"controller_at",
  "msg":"=== Reconciling At","namespace":"cnat","at":"example-at"}
{"level":"info","ts":1555044964.5184839,"logger":"controller_at",
  "msg":"Phase: PENDING","namespace":"cnat","at":"example-at"}
{"level":"info","ts":1555044964.518566,"logger":"controller_at",
  "msg":"Checking schedule","namespace":"cnat","at":"example-at",
  "Target":"2019-04-12T04:56:00Z"}
{"level":"info","ts":1555044964.5186381,"logger":"controller_at",
  "msg":"Schedule parsing done","namespace":"cnat","at":"example-at",
  "Result":"2019-04-12 04:56:00 +0000 UTC with a diff of -4.518596s"}
{"level":"info","ts":1555044964.5186849,"logger":"controller_at",
  "msg":"It's time!","namespace":"cnat","at":"example-at",
  "Ready to execute":"echo YAY"}
{"level":"info","ts":1555044964.642559,"logger":"controller_at",
  "msg":"=== Reconciling At","namespace":"cnat","at":"example-at"}
{"level":"info","ts":1555044964.642622,"logger":"controller_at",
  "msg":"Phase: RUNNING","namespace":"cnat","at":"example-at"}
{"level":"info","ts":1555044964.911037,"logger":"controller_at",
  "msg":"=== Reconciling At","namespace":"cnat","at":"example-at"}
{"level":"info","ts":1555044964.9111192,"logger":"controller_at",
  "msg":"Phase: RUNNING","namespace":"cnat","at":"example-at"}
{"level":"info","ts":1555044966.038684,"logger":"controller_at",
  "msg":"=== Reconciling At","namespace":"cnat","at":"example-at"}
{"level":"info","ts":1555044966.038771,"logger":"controller_at",
  "msg":"Phase: DONE","namespace":"cnat","at":"example-at"}
{"level":"info","ts":1555044966.708663,"logger":"controller_at",
  "msg":"=== Reconciling At","namespace":"cnat","at":"example-at"}
{"level":"info","ts":1555044966.708749,"logger":"controller_at",
  "msg":"Phase: DONE","namespace":"cnat","at":"example-at"}
...

在这里,您可以看到我们操作者的三个阶段:从 PENDING 到时间戳 1555044964.518566,然后到 RUNNING,最后到 DONE

要验证我们自定义控制器的功能并检查操作结果,请输入:

$ kubectl get at,pods
NAME                                                  AGE
at.cnat.programming-kubernetes.info/example-at        23m

NAME                 READY   STATUS        RESTARTS   AGE
pod/example-at-pod   0/1     Completed     0          46s

$ kubectl logs example-at-pod
YAY

当您完成开发自定义控制器,并像这里展示的那样使用本地模式时,您可能希望构建一个容器镜像。这个自定义控制器容器镜像随后可以用于例如 Kubernetes 部署。您可以使用以下命令生成容器镜像:

$ operator-sdk build $REGISTRY/PROJECT/IMAGE

这里有更多关于 Operator SDK 和相关示例的资源:

结束本章时,让我们看看编写自定义控制器和操作器的一些替代方法。

其他方法

除了或可能与我们讨论过的方法结合使用外,您可能还想看看以下项目、库和工具:

元控制器

Metacontroller 的基本思想是为您提供状态和变更的声明性规范,与基于水平触发的协调循环相接口,JSON 为基础。也就是说,您会收到描述观察状态的 JSON,并返回描述期望状态的 JSON。这对于在动态脚本语言(如 Python 或 JavaScript)中快速开发自动化特别有用。除了简单的控制器外,Metacontroller 还允许您将 API 组合成更高级的抽象,例如BlueGreenDeployment

KUDO

类似于 Metacontroller,KUDO 提供了一个声明性方法来构建 Kubernetes 操作员,涵盖整个应用程序生命周期。简而言之,这是 Mesosphere 在 Apache Mesos 框架中的经验移植到 Kubernetes 中。KUDO 具有很强的见解性,但也很容易使用,几乎不需要编码;基本上,您只需指定一组带有定义执行逻辑的 Kubernetes 清单即可。

Rook 操作器套件

这是一个用于实现操作器的常见库。它起源于 Rook 操作器,但已经独立成为一个单独的项目。

ericchiang/k8s

这是由 Eric Chiang 创造的一个简化的 Go 客户端,使用 Kubernetes 协议缓冲支持生成。它的行为类似于官方的 Kubernetes client-go,但只导入了两个外部依赖项。尽管它在某些方面存在限制,例如在集群访问配置方面,但它是一个易于使用的简单 Go 包。

kutil

AppsCode 通过 kutil 提供 Kubernetes client-go 的附加组件。

基于 CLI 客户端的方法

一种客户端方法,主要用于实验和测试,是以编程方式利用 kubectl(例如,kubecuddler 库)。

注意

虽然本书侧重于使用 Go 编程语言编写运算符,但你也可以用其他语言编写运算符。两个显著的例子是 Flant 的Shell-operator,允许你使用经典的 Shell 脚本编写运算符,以及 Zalando 的Kopf(Kubernetes 运算符框架),一个 Python 框架和库。

如本章开头所述,运算符领域正在迅速发展,越来越多的从业者通过代码和最佳实践分享他们的知识,因此请密切关注这里的新工具。确保查阅在线资源和论坛,例如 Kubernetes Slack 上的#kubernetes-operators#kubebuilder#client-go-docs频道,以了解新方法和/或讨论问题,在遇到困难时寻求帮助。

采用和未来方向

到底哪种编写运算符的方法最受欢迎和广泛使用,目前还没有定论。在 Kubernetes 项目的背景下,涉及 CRs 和控制器的多个 SIGs 活动。主要的利益相关者是 SIG API Machinery,拥有 CRs 和控制器,并负责Kubebuilder项目。Operator SDK 已经加大了与 Kubebuilder API 对齐的努力,因此两者有很多重叠之处。

摘要

在本章中,我们看了几种不同的工具,让你能更高效地编写自定义控制器和运算符。传统上,只有跟随sample-controller是唯一的选择,但是有了 Kubebuilder 和 Operator SDK,你现在有了两个选项,可以让你专注于自定义控制器的业务逻辑,而不必处理样板文件。而且幸运的是,这两个工具共享了很多 API 和代码,所以从一个工具转换到另一个工具不应该太困难。

现在,让我们看看如何交付我们的劳动成果,即如何打包和发布我们一直在编写的控制器。

^(1) 这里仅展示相关部分;函数本身有很多其他的样板代码与我们的目的无关。

第七章:控制器和操作符的发布

现在您已经熟悉了自定义控制器的开发,让我们进入如何使您的自定义控制器和操作符达到生产就绪的话题。在本章中,我们将讨论控制器和操作符的操作方面,向您展示如何打包它们,引导您运行控制器的最佳实践,并确保您的扩展点不会破坏您的 Kubernetes 集群,无论是从安全性还是性能方面。

生命周期管理和打包

在本节中,我们考虑操作符的生命周期管理。也就是说,我们将讨论如何打包和发布您的控制器或操作符,以及如何处理升级问题。当您准备将您的操作符交付给用户时,您需要一种方法让他们安装它。为此,您需要打包相应的工件,例如定义控制器二进制文件(通常作为 Kubernetes 部署的 YAML 清单),以及 CRD 和与安全相关的资源,如服务账户和必要的 RBAC 权限。一旦您的目标用户运行了某个版本的操作符,您还希望有一个机制来升级控制器,考虑版本控制和潜在的零停机升级。

让我们从低 hanging 的水果开始:打包和交付您的控制器,以便用户可以以简单的方式安装它。

打包:挑战

虽然 Kubernetes 用清单定义资源,通常以 YAML 编写,声明资源状态的低级接口,但这些清单文件存在缺陷。在打包容器化应用的上下文中最重要的是,YAML 清单是静态的;也就是说,YAML 清单中的所有值都是固定的。这意味着,例如,如果您想在 部署清单 中更改容器映像,您必须创建一个新的清单。

让我们看一个具体的例子。假设您有以下 Kubernetes 部署编码在一个名为 mycontroller.yaml 的 YAML 清单中,代表您希望用户安装的自定义控制器:

apiVersion: apps/v1beta1
kind: Deployment
metadata:
  name: mycustomcontroller
spec:
  replicas: 1
  template:
    metadata:
      labels:
        app: customcontroller
    spec:
      containers:
      - name: thecontroller
        image: example/controller:0.1.0
        ports:
        - containerPort: 9999
        env:
        - name: REGION
          value: eu-west-1

想象一下环境变量 REGION 定义了您的控制器的某些运行时属性,例如其他服务的可用性,比如托管服务网格。换句话说,虽然 eu-west-1 的默认值可能是合理的,但用户可以并且很可能会根据自己的偏好或政策进行覆盖。

现在,考虑到 YAML 清单 mycontroller.yaml 本身是一个静态文件,在编写时定义了所有的值——而像 kubectl 这样的客户端并不本质上支持清单中的可变部分——那么在运行时如何让用户提供变量值或覆盖现有值呢?也就是说,在上述示例中,用户如何在安装时将 REGION 设置为比如 us-east-2,使用(例如)kubectl apply

为了克服在 Kubernetes 中构建时静态 YAML 清单的限制,有几种选项可以模板化清单(例如 Helm),或者根据用户提供的值或运行时属性启用变量输入(例如 Kustomize)。

Helm

Helm,自称为 Kubernetes 的包管理器,最初由 Deis 开发,现在是 Cloud Native Computing Foundation (CNCF) 的项目,主要贡献者包括微软、谷歌和 Bitnami(现为 VMware 的一部分)。

Helm 通过定义和应用所谓的图表,有效地参数化 YAML 清单,帮助您安装和升级 Kubernetes 应用程序。以下是 示例图表模板 的摘录:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "flagger.fullname" . }}
...
spec:
  replicas: 1
  strategy:
    type: Recreate
  selector:
    matchLabels:
      app.kubernetes.io/name: {{ template "flagger.name" . }}
      app.kubernetes.io/instance: {{ .Release.Name }}
  template:
    metadata:
      labels:
        app.kubernetes.io/name: {{ template "flagger.name" . }}
        app.kubernetes.io/instance: {{ .Release.Name }}
    spec:
      serviceAccountName: {{ template "flagger.serviceAccountName" . }}
      containers:
        - name: flagger
          securityContext:
            readOnlyRootFilesystem: true
            runAsUser: 10001
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"

如您所见,变量以 {{ ._Some.value.here_ }} 格式编码,这恰好是 Go 模板

要安装图表,可以运行 helm install 命令。虽然 Helm 有几种查找和安装图表的方法,但最简单的方法是使用官方稳定版图表之一:

# get the latest list of charts:
$ helm repo update

# install MySQL:
$ helm install stable/mysql
Released smiling-penguin

# list running apps:
$ helm ls
NAME             VERSION   UPDATED                   STATUS    CHART
smiling-penguin  1         Wed Sep 28 12:59:46 2016  DEPLOYED  mysql-0.1.0

# remove it:
$ helm delete smiling-penguin
Removed smiling-penguin

要打包您的控制器,您需要为其创建一个 Helm 图表,并将其发布到某个地方,默认情况下发布到通过 Helm Hub 索引和访问的公共存储库,如 图 7-1 所示。

Helm Hub 屏幕截图,显示公开可用的 Helm 图表

图 7-1. Helm Hub 屏幕截图显示公开可用的 Helm 图表

欲了解如何创建 Helm 图表的更多指导,请随意查阅以下资源:

Helm 非常流行,部分原因是其对最终用户的易用性。然而,一些人认为当前的 Helm 架构存在 安全风险。好消息是社区正在积极努力解决这些问题。

Kustomize

Kustomize 提供了一种声明性的方法来定制 Kubernetes 清单文件的配置,遵循熟悉的 Kubernetes API。它于 2018 年中期 推出,现在是一个 Kubernetes SIG CLI 项目。

您可以在您的机器上 安装 Kustomize,作为独立工具使用,或者,如果您使用的 kubectl 版本较新(1.14 以上),可以使用 -k 命令行标志来激活它,它会随 kubectl 一起发货。

因此,Kustomize 允许您定制原始的 YAML 清单文件,而无需修改原始清单。但是实际上是如何工作的呢?假设您想打包我们的 cnat 自定义控制器;您将定义一个名为 kustomize.yaml 的文件,内容如下:

imageTags:
  - name: quay.io/programming-kubernetes/cnat-operator
    newTag: 0.1.0
resources:
- cnat-controller.yaml

现在您可以将此应用于 cnat-controller.yaml 文件,比如以下内容:

apiVersion: apps/v1beta1
kind: Deployment
metadata:
  name: cnat-controller
spec:
  replicas: 1
  template:
    metadata:
      labels:
        app: cnat
    spec:
      containers:
      - name: custom-controller
        image: quay.io/programming-kubernetes/cnat-operator

使用 kustomize build 命令,且保持 cnat-controller.yaml 文件不变!输出如下:

apiVersion: apps/v1beta1
kind: Deployment
metadata:
  name: cnat-controller
spec:
  replicas: 1
  template:
    metadata:
      labels:
        app: cnat
    spec:
      containers:
      - name: custom-controller
        image: quay.io/programming-kubernetes/cnat-operator:0.1.0

然后,kustomize build 的输出可以例如用于 kubectl apply 命令中,并自动应用所有的 自定义内容

有关 Kustomize 更详细的使用方法,请查看以下资源:

鉴于 kubectl 中对 Kustomize 的原生支持,用户数量可能会不断增加。请注意,虽然它解决了一些问题(自定义),但是像验证和升级这样的生命周期管理其他领域可能需要您结合像 Google 的 CUE 这样的语言使用 Kustomize。

总结这个打包主题,让我们回顾一些从业者使用的其他解决方案。

其他打包选项

一些显著的替代品,以及许多其他 在野外 的选择。

UNIX 工具

要自定义原始 Kubernetes 清单的值,可以使用一系列 CLI 工具,例如 sedawkjq 在 shell 脚本中。这是一个流行的解决方案,至少在 Helm 出现之前是最广泛使用的选项之一,因为它最大程度上减少了依赖并且在 *nix 环境中相当可移植。

传统的配置管理系统

您可以使用任何传统的配置管理系统,如 Ansible、Puppet、Chef 或 Salt,来打包和交付您的操作员。

云原生语言

一种被称为 云原生编程语言 的新一代语言,如 Pulumi 和 Ballerina,允许在其他功能中打包和管理基于 Kubernetes 的应用程序的生命周期。

ytt

使用 ytt,你还有另一种选项,即使用一种修改版的 Google 配置语言 Starlark 作为 YAML 模板工具,它在语义上操作 YAML 结构并专注于可重用性。

Ksonnet

Kubernetes 清单的配置管理工具,最初由 Heptio(现在是 VMware)开发,Ksonnet 已经被弃用,并且不再积极开发,因此使用它需自担风险。

在 Jesse Suen 的文章 “Kubernetes 配置管理的现状:一个未解之谜” 中进一步了解这里讨论的选项。

现在,我们已经总结了一般的打包选项,让我们看看打包和发布控制器和操作员的最佳实践。

打包最佳实践

在打包和发布您的操作符时,请确保您了解以下最佳实践。这些适用于无论您选择哪种机制(Helm、Kustomize、shell 脚本等):

  • 提供适当的访问控制设置:这意味着为控制器定义一个专用的服务帐户,并基于最低权限原则分配 RBAC 权限;有关详细信息,请参阅 “正确的权限获取”。

  • 考虑您的自定义控制器的范围:它是否将管理单个命名空间中的 CR,还是多个命名空间?查看 Alex Ellis 的 Twitter 对话,了解不同方法的利弊。

  • 测试并分析您的控制器,以便了解其资源占用和可伸缩性。例如,Red Hat 在 OperatorHub 的 贡献 指南中提供了详细的要求和说明。

  • 确保 CRD 和控制器有良好的文档,最好包括在 godoc.org 上的内联文档和一组使用示例;参见 Banzai Cloud 的 bank-vaults 操作符以获取灵感。

生命周期管理

与打包/部署相比,更广泛和全面的方法是生命周期管理。基本思想是考虑整个供应链,从开发到部署再到升级,并尽可能自动化。在这方面,CoreOS(后来是 Red Hat)再次领先:将操作符的逻辑应用于其生命周期管理。换句话说:为了安装和稍后升级操作符的自定义控制器,您需要一个专门的操作符,它知道如何处理操作符。确实,操作符框架的一部分——也就是我们在 “操作符 SDK” 中讨论过的操作符生命周期管理器(OLM)。

Jimmy Zelinskie,OLM 背后的主要人物之一,这样表述

OLM 为操作符的作者做了很多工作,但它也解决了一个重要的问题,即很多人尚未考虑过的:如何有效管理随时间推移的 Kubernetes 的一级扩展?

简而言之,OLM 提供了一种声明式的方法来安装和升级操作符及其依赖项,这与 Helm 等打包解决方案是互补的。您可以选择是否采用完整的 OLM 解决方案,或者为版本控制和升级挑战创建一个临时解决方案;然而,您应该在这里有一些策略。对于某些领域——例如 Red Hat 操作符中心的 认证流程,即使您不打算使用该中心,对于任何非平凡的部署场景来说,这不仅是推荐的,而且是强制的。

适合生产环境的部署

在这一节中,我们将讨论如何使您的自定义控制器和操作符达到生产就绪状态。以下是一个高级检查清单:

  • 使用 Kubernetes 部署或 DaemonSets 来监控您的自定义控制器,这样它们在失败时会自动重启——而它们确实会失败。

  • 通过专用端点实施健康检查,包括存活性和就绪性探测。这与前面的步骤结合起来,可以使您的操作更具弹性。

  • 考虑使用主从/备用模型,以确保即使您的控制器 Pod 崩溃,也能有其他人接管。但请注意,同步状态是一个非常不简单的任务。

  • 提供访问控制资源,例如服务账号和角色,应用最小权限原则;详情请参见“权限管理最佳实践”。

  • 考虑自动化构建,包括测试。更多技巧可参见“自动化构建与测试”。

  • 积极应对监控和日志记录;请参见“自定义控制器与可观测性”了解具体内容及操作方法。

我们还建议您阅读上述文章“Kubernetes 运算符开发指南以提升可用性”以了解更多信息。

权限管理最佳实践

您的自定义控制器是 Kubernetes 控制平面的一部分。它需要读取资源状态,在 Kubernetes 内外(可能)创建资源,并且与自身资源的状态进行通信。为了实现这一切,自定义控制器需要通过一组基于角色的访问控制(RBAC)相关设置来获取适当的权限。正确设置这些是本节的主题。

首先要做的是:始终为运行您的控制器创建一个专用服务账号。换句话说:永远不要在命名空间中使用default服务账号。^(1)

为了使您的工作更轻松,您可以定义一个ClusterRole,其中包含必要的 RBAC 规则,并通过RoleBinding将其绑定到特定命名空间,有效地在命名空间之间重用该角色,如使用 RBAC 授权条目中所述。

遵循最小权限原则,仅分配控制器执行其工作所需的权限。例如,如果控制器只管理 Pods,则无需为其提供列出或创建部署或服务的权限。此外,请确保控制器无权安装 CRD 和/或入站 Webhooks。换句话说,控制器不应该具有管理 CRD 和 Webhooks 的权限。

通常用于创建自定义控制器的常用工具,如在第六章中讨论的,通常提供生成 RBAC 规则功能。例如,Kubebuilder 生成以下RBAC 资源,以及一个运算符:

$ ls -al rbac/
total 40
drwx------  7 mhausenblas  staff   224 12 Apr 09:52 .
drwx------  7 mhausenblas  staff   224 12 Apr 09:55 ..
-rw-------  1 mhausenblas  staff   280 12 Apr 09:49 auth_proxy_role.yaml
-rw-------  1 mhausenblas  staff   257 12 Apr 09:49 auth_proxy_role_binding.yaml
-rw-------  1 mhausenblas  staff   449 12 Apr 09:49 auth_proxy_service.yaml
-rw-r--r--  1 mhausenblas  staff  1044 12 Apr 10:50 rbac_role.yaml
-rw-r--r--  1 mhausenblas  staff   287 12 Apr 10:50 rbac_role_binding.yaml

查看自动生成的 RBAC 角色和绑定可发现细粒度的设置。在rbac_role.yaml文件中,您可以找到:

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  creationTimestamp: null
  name: manager-role
rules:
- apiGroups:
  - apps
  resources:
  - deployments
  verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups:
  - apps
  resources:
  - deployments/status
  verbs: ["get", "update", "patch"]
- apiGroups:
  - cnat.programming-kubernetes.info
  resources:
  - ats
  verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups:
  - cnat.programming-kubernetes.info
  resources:
  - ats/status
  verbs: ["get", "update", "patch"]
- apiGroups:
  - admissionregistration.k8s.io
  resources:
  - mutatingwebhookconfigurations
  - validatingwebhookconfigurations
  verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups:
  - ""
  resources:
  - secrets
  verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups:
  - ""
  resources:
  - services
  verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]

查看 Kubebuilder 在 v1 中生成的这些权限,您可能会有些惊讶。^(2) 我们当然也是:最佳实践告诉我们,如果控制器没有非常好的理由,它不应能够:

  • 通常在代码中仅读取资源,例如,如果仅观察服务和部署,则在角色中删除 createupdatepatchdelete 动词。

  • 访问所有机密;也就是说,始终将其限制在最少量必要的机密集合上。

  • 编写 MutatingWebhookConfigurationsValidatingWebhookConfigurations。这相当于获得对集群中任何资源的访问权限。

  • 编写 CustomResourceDefinition。请注意,刚刚显示的集群角色不允许此操作,但仍然重要的是要在这里提到:CRD 的创建应由单独的流程完成,而不是由控制器本身完成。

  • 编写非管理的外部资源的 /status 子资源(见 “子资源”)。例如,这里的部署不由 cnat 控制器管理,不应纳入范围。

当然,Kubebuilder 实际上无法理解您的控制器代码实际在做什么。因此,生成的 RBAC 规则过于宽松并不奇怪。我们建议仔细检查权限并将其减少到绝对最小,遵循前述检查表。

警告

读取系统中所有机密的访问权限使控制器可以访问所有服务账户令牌。这相当于可以访问集群中所有密码。对 MutatingWebhookConfigurationsValidatingWebhookConfigurations 具有写入权限允许您拦截和操作系统中的每个 API 请求。这打开了 Kubernetes 集群中 rootkit 的大门。这两者显然非常危险,并被认为是反模式,最好避免使用。

要避免过多权限——即仅授予绝对必要的访问权限——可以考虑使用 audit2rbac。该工具利用审计日志生成适当的权限集,从而实现更安全的设置,减少未来的麻烦。

rbac_role_binding.yaml 中可以学到:

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  creationTimestamp: null
  name: manager-rolebinding
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: manager-role
subjects:
- kind: ServiceAccount
  name: default
  namespace: system

有关 RBAC 和其周围工具的更多最佳实践,请查看 RBAC.dev,这是一个专注于 Kubernetes 中 RBAC 的网站。现在让我们转向自定义控制器的测试和性能考虑。

自动化构建和测试

作为云原生领域的最佳实践,考虑自动构建您的自定义控制器。这通常称为持续构建持续集成(CI),包括单元测试、集成测试、构建容器镜像,甚至可能是健全或 烟雾 测试。云原生计算基金会(CNCF)维护一个互动 列表,列出了许多可用的开源 CI 工具。

在构建控制器时,请记住它应尽可能少地消耗计算资源,同时为尽可能多的客户提供服务。每个 CR(根据您定义的 CRD)都是客户的代理。但是,您如何知道它消耗了多少资源,是否存在内存泄漏,以及其扩展性如何?

一旦您的自定义控制器开发稳定下来,您可以并且应该进行多项测试。这些测试可以包括以下内容,但不限于此:

  • Kubernetes 本身 以及 kboom 工具中发现的性能相关测试可以为您提供有关扩展和资源占用的数据。

  • 长时间使用测试(例如,Kubernetes 中使用的测试)旨在揭示任何资源泄漏,如文件或主内存。

作为最佳实践,这些测试应该成为您 CI 流水线的一部分。换句话说,从第一天开始自动化构建自定义控制器、测试和打包。为了一个具体的示例设置,我们建议您查看 Marko Mudrinić 的优秀文章 “在 CI 中生成 Kubernetes 集群进行集成和端到端测试”

接下来,我们将探讨提供有效故障排除基础的最佳实践:内置支持可观察性。

自定义控制器和可观察性

在本节中,我们将专注于您自定义控制器的可观察性方面,特别是日志记录和监控。

日志记录

确保提供足够的日志信息来帮助 故障排除(在生产环境中)。通常在容器化设置中,日志信息被发送到 stdout,可以使用 kubectl logs 命令以每个 Pod 的方式消费,或者以聚合形式提供。云提供商特定的解决方案,如 Google Cloud 中的 Stackdriver 或 AWS 中的 CloudWatch,或者像 Elasticsearch-Logstash-Kibana/Elasticsearch-Fluentd-Kibana 这样的定制解决方案。还可以参考 Sébastien Goasguen 和 Michael Hausenblas(O’Reilly)的 Kubernetes Cookbook 来获取有关此主题的技巧。

让我们来看一下我们的 cnat 自定义控制器日志的一个示例摘录:

{ "level":"info",
  "ts":1555063927.492718,
  "logger":"controller",
  "msg":"=== Reconciling At" }
{ "level":"info",
  "ts":1555063927.49283,
  "logger":"controller",
  "msg":"Phase: PENDING" }
{ "level":"info",
  "ts":1555063927.492857,
  "logger":"controller",
  "msg":"Checking schedule" }
{ "level":"info",
  "ts":1555063927.492915,
  "logger":"controller",
  "msg":"Schedule parsing done" }

记录日志的方法:通常情况下,我们倾向于使用结构化日志和可调整的日志级别,至少包括debuginfo。在 Kubernetes 代码库中广泛使用的有两种方法,除非你有充分的理由不使用,否则应考虑使用这些方法:

  • logger接口,例如在httplog.go中找到,以及一个具体类型(respLogger),用于捕捉状态和错误。

  • klog,Google 的glog的分支,是 Kubernetes 中广泛使用的结构化记录器,尽管它有其特殊性,但了解它是值得的。

记录日志的内容:确保对业务逻辑操作的正常情况有详细的日志信息。例如,从我们的 Operator SDK 实现的cnat控制器中,在at_controller.go中设置日志记录如下:

reqLogger := log.WithValues("namespace", request.Namespace, "at", request.Name)

在业务逻辑中的Reconcile(request reconcile.Request)函数:

case cnatv1alpha1.PhasePending:
  reqLogger.Info("Phase: PENDING")
  // As long as we haven't executed the command yet, we need to check if it's
  // already time to act:
  reqLogger.Info("Checking schedule", "Target", instance.Spec.Schedule)
  // Check if it's already time to execute the command with a tolerance of
  // 2 seconds:
  d, err := timeUntilSchedule(instance.Spec.Schedule)
  if err != nil {
    reqLogger.Error(err, "Schedule parsing failure")
    // Error reading the schedule. Wait until it is fixed.
    return reconcile.Result{}, err
  }
  reqLogger.Info("Schedule parsing done", "Result", "diff", fmt.Sprintf("%v", d))
  if d > 0 {
    // Not yet time to execute the command, wait until the scheduled time
    return reconcile.Result{RequeueAfter: d}, nil
  }
  reqLogger.Info("It's time!", "Ready to execute", instance.Spec.Command)
  instance.Status.Phase = cnatv1alpha1.PhaseRunning

这段 Go 代码片段给出了一个很好的日志记录示例,特别是何时使用reqLogger.InforeqLogger.Error

在处理完日志记录 101 后,让我们继续讨论一个相关主题:指标!

监控、仪表和审计

一个优秀的开源、容器就绪的监控解决方案,您可以在各种环境(本地和云端)中使用,是Prometheus。对每个事件进行警报并不实际,因此您可能需要考虑谁需要了解何种类型的事件。例如,您可以制定一个策略,由基础设施管理员处理与节点或命名空间相关的事件,并且由命名空间管理员或开发人员接收有关 pod 级事件的页面通知。在这种情况下,为了可视化收集的指标,最流行的解决方案肯定是Grafana,参见图 7-2,这是来自Prometheus 文档的示例,展示了在 Grafana 中可视化的 Prometheus 指标。

如果您正在使用服务网格,例如基于Envoy 代理(例如 Istio 或 App Mesh),或者 Linkerd,那么通常会自动提供监控,或者通过最少的配置努力即可实现。否则,您将需要使用相应的库,例如由Prometheus提供的库,自己在代码中公开相关的指标。在这种情况下,您可能还希望了解从 2019 年初开始引入的初创服务网格接口(SMI)项目,旨在基于 CR 和控制器为服务网格提供标准化接口。

Prometheus metrics visualized in Grafana

图 7-2. Prometheus 指标在 Grafana 中的可视化

另一个 Kubernetes 通过 API 服务器提供的有用功能是审计,它允许您记录影响集群的一系列活动。审计策略中提供了不同的策略,从不记录日志到记录事件元数据、请求体和响应体。您可以选择简单的日志后端,也可以使用 Webhook 与第三方系统集成。

概要

本章重点讨论如何通过讨论控制器和操作员的操作方面使您的操作员达到生产就绪状态,包括打包、安全性和性能。

现在我们已经介绍了编写和使用自定义 Kubernetes 控制器和操作员的基础知识,现在我们转向另一种扩展 Kubernetes 的方式:开发自定义 API 服务器。

^(1) 另请参阅 Luc Juggery 的文章“Kubernetes Tips: 使用 ServiceAccount”,详细讨论了服务账户的使用。

^(2) 不过,我们确实对 Kubebuilder 项目提出了Issue 748

第八章:自定义 API 服务器

作为 CustomResourceDefinitions 的替代方案,可以使用自定义 API 服务器。自定义 API 服务器可以像主 Kubernetes API 服务器一样为 API 组提供资源服务。与 CRD 不同,自定义 API 服务器几乎没有限制,可以做任何事情。

本章首先列出了为什么 CRD 可能不适合您的用例的一些原因。它描述了聚合模式,该模式使得通过自定义 API 服务器扩展 Kubernetes API 表面成为可能。最后,您将学习如何使用 Golang 实际实现自定义 API 服务器。

自定义 API 服务器的用例

可以用自定义 API 服务器替代 CRD。它可以做任何 CRD 能做的事情,并提供几乎无限的灵活性。当然,这也带来了成本:开发和运维的复杂性。

让我们看一下在本文撰写时(Kubernetes 1.14 为稳定版本),CRD 的一些限制:

  • 使用 etcd 作为它们的存储介质(或者 Kubernetes API 服务器使用的任何东西)。

  • 不支持 protobuf,只支持 JSON。

  • 仅支持两种子资源:/status/scale(参见“子资源”)。

  • 不支持优雅的删除。^(1) 尽管 Finalizers 可以模拟这一过程,但不允许自定义优雅删除时间。

  • 会显著增加 Kubernetes API 服务器的 CPU 负载,因为所有算法都是通用实现的(例如验证)。

  • 仅为 API 端点实现标准的 CRUD 语义。

  • 不支持资源共存(即不同 API 组中的资源或不同名称的资源共享存储)。^(2)

相比之下,自定义 API 服务器没有这些限制:

  • 可以使用任何存储介质。例如,有以下自定义 API 服务器:

    • 指标 API 服务器,它将数据存储在内存中以实现最大的性能。

    • API 服务器可以镜像 OpenShift 中 Docker 注册表的自定义 API 对象。

    • API 服务器将数据写入时间序列数据库。

    • API 服务器可以镜像云 API。

    • API 服务器可以镜像其他 API 对象,例如 OpenShift 中镜像 Kubernetes 命名空间的项目。

  • 可以像所有本机 Kubernetes 资源一样提供 protobuf 支持。为此,您必须使用 go-to-protobuf 创建一个 .proto 文件,然后使用 protobuf 编译器 protoc 生成序列化器,最后将其编译成二进制文件。

  • 可以提供任何自定义子资源;例如,Kubernetes API 服务器提供 /exec/logs/port-forward 等,大多数使用非常自定义的协议,如 WebSockets 或 HTTP/2 流式传输。

  • 可以实现优雅的删除,就像 Kubernetes 对于 Pod 所做的那样。kubectl 等待删除操作,用户甚至可以提供自定义的优雅终止期。

  • 可以使用 Golang 以最高效的方式实现所有操作,如验证、准入和转换,而无需通过 webhook 回程,这可以减少进一步的延迟。这对于高性能用例或对象数量众多的情况至关重要。想象一下在拥有数千个节点和两个数量级更多的 Pod 的大集群中,Pod 对象的情况。

  • 可以实现自定义语义,例如在核心 v1 Service类型中对服务 IP 进行原子预留。在创建服务时,会分配一个唯一的服务 IP 并直接返回。在请求管道中,虽然可以使用准入 webhook 实现特殊语义,但这些 webhook 无法可靠地知道传递的对象实际上是创建还是更新的:它们乐观地调用,但如果请求失败,后续步骤可能会取消请求。换句话说:webhook 中的副作用很棘手,因为如果请求失败,则没有撤销触发器。

  • 可以为具有共同存储机制(即公共etcd键路径前缀)但存在于不同 API 组或命名不同的资源提供服务。例如,Kubernetes 将部署和其他资源存储在 API 组extensions/v1中,然后将它们移动到更具体的 API 组,如apps/v1

换句话说,自定义 API 服务器是在 CRD 仍然有限的情况下解决方案的情况。在过渡场景中,当转移到新的语义时重要的是不破坏资源兼容性时,自定义 API 服务器通常更加灵活。

示例:披萨餐厅

为了学习如何实现自定义 API 服务器,在本节中,我们将看一个示例项目:一个实现披萨餐厅 API 的自定义 API 服务器。让我们看看需求。

我们希望在restaurant.programming-kubernetes.info API 组中创建两种类型:

Topping

披萨配料(例如:萨拉米、马苏里拉奶酪或番茄)

Pizza

餐厅提供的披萨类型

配料是集群范围的资源,仅包含一个浮点值,用于配料单位的成本。一个实例如下:

apiVersion: restaurant.programming-kubernetes.info/v1alpha1
kind: Topping
metadata:
  name: mozzarella
spec:
  cost: 1.0

每个披萨可以有任意数量的配料;例如:

apiVersion: restaurant.programming-kubernetes.info/v1alpha1
kind: Pizza
metadata:
  name: margherita
spec:
  toppings:
  - mozzarella
  - tomato

配料列表是有序的(就像在 YAML 或 JSON 中的任何列表一样),但顺序对类型的语义并不真正重要。客户在任何情况下都会得到相同的披萨。我们希望允许列表中的重复项,以便允许例如额外加奶酪的披萨。

所有这些都可以通过 CRD 轻松实现。现在让我们添加一些超出基本 CRD 功能的需求:^(3)

  • 我们希望在披萨规格中仅允许有相应的Topping对象的配料。

  • 我们还希望假设我们首先将此 API 引入为v1alpha1版本,但最终发现我们希望在同一 API 的v1beta1版本中有另一种配料表示。

换句话说,我们希望拥有两个版本,并在它们之间实现无缝转换。

该 API 的完整实现作为自定义 API 服务器可以在本书的 GitHub 存储库中找到。在本章的其余部分,我们将深入探讨该项目的所有主要部分,并了解其工作原理。在此过程中,您将看到前一章节中呈现的许多概念以不同的方式:即,也是 Kubernetes API 服务器背后的 Golang 实现。还会更清晰地看到一些在 CRDs 中突出的设计决策。

因此,我们强烈建议您阅读本章,即使您不打算使用自定义 API 服务器。也许未来这里呈现的概念也会适用于 CRDs,那么了解自定义 API 服务器的知识对您也会有所帮助。

架构:聚合

在进入技术实现细节之前,我们希望在 Kubernetes 集群的背景下,从更高层次来看自定义 API 服务器架构。

自定义 API 服务器是服务 API 组的进程,通常使用通用 API 服务器库k8s.io/apiserver构建。这些进程可以在集群内部或外部运行。在前一种情况下,它们在 Pod 内运行,并带有前端服务。

主要的 Kubernetes API 服务器称为kube-apiserver,始终是kubectl和其他 API 客户端的第一个接触点。由自定义 API 服务器提供的 API 组由kube-apiserver进程代理到自定义 API 服务器进程。换句话说,kube-apiserver进程了解所有自定义 API 服务器及其服务的 API 组,以便能够将正确的请求代理到它们。

执行代理的组件位于kube-apiserver进程内部,称为kube-aggregator。代理 API 请求到自定义 API 服务器的过程称为API 聚合

让我们更深入地了解针对自定义 API 服务器的请求路径,但是进入 Kubernetes API 服务器的 TCP 套接字(参见图 8-1):

  1. 请求由 Kubernetes API 服务器接收。

  2. 它们通过处理程序链传递,包括身份验证、审计日志记录、模拟、最大并发限制、授权等等(图仅为草图,不完整)。

  3. 由于 Kubernetes API 服务器知道聚合的 API,它可以拦截指向 HTTP 路径/apis/聚合 API 组名称*的请求。

  4. Kubernetes API 服务器将请求转发到自定义 API 服务器。

Kubernetes 主 API 服务器与集成的

图 8-1. Kubernetes 主 API 服务器 kube-apiserver 与集成的 kube-aggregator

kube-aggregator代理 API 组版本的 HTTP 路径下的请求(即 /apis/group-name/version)。它不需要知道 API 组版本中实际提供的资源。

相比之下,kube-aggregator本身为所有聚合的自定义 API 服务器服务发现端点 /apis/apis/group-name(它使用下文解释的定义顺序),并在不与聚合的自定义 API 服务器通信的情况下返回结果。而是使用来自APIService资源的信息。让我们详细了解此过程。

API 服务

Kubernetes API 服务器要了解自定义 API 服务器提供的 API 组,必须在apiregistration.k8s.io/v1 API 组中创建一个APIService对象。这些对象仅列出 API 组和版本,不包括资源或任何进一步的细节:

apiVersion: apiregistration.k8s.io/v1beta1
kind: APIService
metadata:
  name: *`name`*
spec:
  group: *`API-group-name`*
  version: *`API-group-version`*
  service:
    namespace: *`custom-API-server-service-namespace`*
    name: *`-API-server-service`*
  caBundle: *`base64-caBundle`*
  insecureSkipTLSVerify: *`bool`*
  groupPriorityMinimum: 2000
  versionPriority: 20

名称是任意的,但为了清晰起见,建议您使用标识 API 组名称和版本的名称,例如group-name-version

服务可以是集群中的普通ClusterIP服务,或者可以是带有给定 DNS 名称的ExternalName服务,用于集群外的自定义 API 服务器。在这两种情况下,端口必须是 443。在撰写本文时不支持其他服务端口。服务目标端口映射允许为自定义 API 服务器 pod 选择任意选择的、最好是非限制性的更高端口,因此这不是一个主要限制。

证书颁发机构(CA)捆绑包用于 Kubernetes API 服务器信任所联系的服务。请注意,API 请求可能包含机密数据。为避免中间人攻击,强烈建议设置caBundle字段,而不使用insecureSkipTLSVerify替代方案。对于任何生产集群,包括证书轮换机制,这尤为重要。

最后,在APIService对象中有两个优先级。这些具有一些棘手的语义,详见APIService类型的 Golang 代码文档:

// GroupPriorityMininum is the priority this group should have at least. Higher
// priority means that the group is preferred by clients over lower priority ones.
// Note that other versions of this group might specify even higher
// GroupPriorityMinimum values such that the whole group gets a higher priority.
//
// The primary sort is based on GroupPriorityMinimum, ordered highest number to
// lowest (20 before 10). The secondary sort is based on the alphabetical
// comparison of the name of the object (v1.bar before v1.foo). We'd recommend
// something like: *.k8s.io (except extensions) at 18000 and PaaSes
// (OpenShift, Deis) are recommended to be in the 2000s
GroupPriorityMinimum int32 `json:"groupPriorityMinimum"`

// VersionPriority controls the ordering of this API version inside of its
// group. Must be greater than zero. The primary sort is based on
// VersionPriority, ordered highest to lowest (20 before 10). Since it's inside
// of a group, the number can be small, probably in the 10s. In case of equal
// version priorities, the version string will be used to compute the order
// inside a group. If the version string is "kube-like", it will sort above non
// "kube-like" version strings, which are ordered lexicographically. "Kube-like"
// versions start with a "v", then are followed by a number (the major version),
// then optionally the string "alpha" or "beta" and another number (the minor
// version). These are sorted first by GA > beta > alpha (where GA is a version
// with no suffix such as beta or alpha), and then by comparing major version,
// then minor version. An example sorted list of versions:
// v10, v2, v1, v11beta2, v10beta3, v3beta1, v12alpha1, v11alpha2, foo1, foo10.
VersionPriority int32 `json:"versionPriority"`

换句话说,GroupPriorityMinimum值决定了组的优先级。如果不同版本的多个APIService对象不同,将选择最高值。

第二个优先级仅用于定义动态客户端首选使用的首选版本之间的顺序。

这是原生 Kubernetes API 组的GroupPriorityMinimum值列表:

var apiVersionPriorities = map[schema.GroupVersion]priority{
    {Group: "", Version: "v1"}: {group: 18000, version: 1},
    {Group: "extensions", Version: "v1beta1"}: {group: 17900, version: 1},
    {Group: "apps", Version: "v1beta1"}:                         {group: 17800, version: 1},
    {Group: "apps", Version: "v1beta2"}:                         {group: 17800, version: 9},
    {Group: "apps", Version: "v1"}:                              {group: 17800, version: 15},
    {Group: "events.k8s.io", Version: "v1beta1"}:                {group: 17750, version: 5},
    {Group: "authentication.k8s.io", Version: "v1"}:             {group: 17700, version: 15},
    {Group: "authentication.k8s.io", Version: "v1beta1"}:        {group: 17700, version: 9},
    {Group: "authorization.k8s.io", Version: "v1"}:              {group: 17600, version: 15},
    {Group: "authorization.k8s.io", Version: "v1beta1"}:         {group: 17600, version: 9},
    {Group: "autoscaling", Version: "v1"}:                       {group: 17500, version: 15},
    {Group: "autoscaling", Version: "v2beta1"}:                  {group: 17500, version: 9},
    {Group: "autoscaling", Version: "v2beta2"}:                  {group: 17500, version: 1},
    {Group: "batch", Version: "v1"}:                             {group: 17400, version: 15},
    {Group: "batch", Version: "v1beta1"}:                        {group: 17400, version: 9},
    {Group: "batch", Version: "v2alpha1"}:                       {group: 17400, version: 9},
    {Group: "certificates.k8s.io", Version: "v1beta1"}:          {group: 17300, version: 9},
    {Group: "networking.k8s.io", Version: "v1"}:                 {group: 17200, version: 15},
    {Group: "networking.k8s.io", Version: "v1beta1"}:            {group: 17200, version: 9},
    {Group: "policy", Version: "v1beta1"}:                       {group: 17100, version: 9},
    {Group: "rbac.authorization.k8s.io", Version: "v1"}:         {group: 17000, version: 15},
    {Group: "rbac.authorization.k8s.io", Version: "v1beta1"}:    {group: 17000, version: 12},
    {Group: "rbac.authorization.k8s.io", Version: "v1alpha1"}:   {group: 17000, version: 9},
    {Group: "settings.k8s.io", Version: "v1alpha1"}:             {group: 16900, version: 9},
    {Group: "storage.k8s.io", Version: "v1"}:                    {group: 16800, version: 15},
    {Group: "storage.k8s.io", Version: "v1beta1"}:               {group: 16800, version: 9},
    {Group: "storage.k8s.io", Version: "v1alpha1"}:              {group: 16800, version: 1},
    {Group: "apiextensions.k8s.io", Version: "v1beta1"}:         {group: 16700, version: 9},
    {Group: "admissionregistration.k8s.io", Version: "v1"}:      {group: 16700, version: 15},
    {Group: "admissionregistration.k8s.io", Version: "v1beta1"}: {group: 16700, version: 12},
    {Group: "scheduling.k8s.io", Version: "v1"}:                 {group: 16600, version: 15},
    {Group: "scheduling.k8s.io", Version: "v1beta1"}:            {group: 16600, version: 12},
    {Group: "scheduling.k8s.io", Version: "v1alpha1"}:           {group: 16600, version: 9},
    {Group: "coordination.k8s.io", Version: "v1"}:               {group: 16500, version: 15},
    {Group: "coordination.k8s.io", Version: "v1beta1"}:          {group: 16500, version: 9},
    {Group: "auditregistration.k8s.io", Version: "v1alpha1"}:    {group: 16400, version: 1},
    {Group: "node.k8s.io", Version: "v1alpha1"}:                 {group: 16300, version: 1},
    {Group: "node.k8s.io", Version: "v1beta1"}:                  {group: 16300, version: 9},
}

因此,对于类似 PaaS 的 API,使用2000意味着它们被放置在此列表的末尾。^(4)

API 组的顺序在kubectl中的 REST 映射过程中起到作用(参见“REST 映射”)。这意味着它实际上对用户体验产生影响。如果存在冲突的资源名称或简称,则具有最高GroupPriorityMinimum值的组将获胜。

此外,在使用自定义 API 服务器替换 API 组版本的特殊情况下,此优先级排序可能有用。例如,您可以通过将自定义 API 服务放置在比上表中的值较低的位置来替换原生 Kubernetes API 组为修改后的 API 组(出于任何原因)。

再次注意,Kubernetes API 服务器不需要知道任何发现端点 /apis/apis/group-name 的资源列表,也不需要代理。资源列表仅通过第三个发现端点 /apis/group-name/version 返回。但正如我们在前一节中看到的,此端点由聚合的自定义 API 服务器提供,而不是由kube-aggregator提供。

自定义 API 服务器的内部结构

自定义 API 服务器与组成 Kubernetes API 服务器的大部分部件相似,尽管当然具有不同的 API 组实现,并且没有嵌入式kube-aggregator或嵌入式apiextension-apiserver(用于服务 CRD)。这导致几乎与 图 8-1 中显示的架构图片相同:

基于 k8s.io/apiserver 的聚合自定义 API 服务器

图 8-2。基于 k8s.io/apiserver 的聚合自定义 API 服务器。

我们观察到一些事情。一个聚合的 API 服务器:

  • 具有与 Kubernetes API 服务器相同的基本内部结构。

  • 具有自己的处理程序链,包括身份验证、审计、冒充、最大并发限制和授权(我们将在本章中详细解释为什么这是必要的;例如,参见“委托授权”)。

  • 具有自己的资源处理程序管道,包括解码、转换、准入、REST 映射和编码。

  • 调用准入 Webhook。

  • 可能会写入etcd(尽管它可以使用不同的存储后端)。etcd 集群不必与 Kubernetes API 服务器使用的相同。

  • 拥有自己的方案和注册表实现用于自定义 API 组。注册表实现可能有所不同,并且可以根据需要进行定制。

  • 再次进行认证。通常进行客户端证书认证和基于令牌的认证,并通过TokenAccessReview请求回调到 Kubernetes API 服务器。我们将在稍后更详细地讨论认证和信任架构。

  • 自己进行审计。这意味着 Kubernetes API 服务器会审计某些字段,但仅限于元级别。对象级别的审计是在聚合的自定义 API 服务器中完成的。

  • 使用SubjectAccessReview请求对 Kubernetes API 服务器进行自身认证。我们将稍后更详细地讨论授权。

委托认证和信任

基于k8s.io/apiserver构建的聚合自定义 API 服务器与 Kubernetes API 服务器使用相同的认证库。它可以使用客户端证书或令牌对用户进行身份验证。

因为聚合自定义 API 服务器在 Kubernetes API 服务器之后架构上(即 Kubernetes API 服务器接收请求并将其代理到聚合自定义 API 服务器),请求已经由 Kubernetes API 服务器进行了身份验证。 Kubernetes API 服务器将身份验证结果(即用户名和组成员资格)存储在 HTTP 请求头中,通常为X-Remote-UserX-Remote-Group(可以使用--requestheader-username-headers--requestheader-group-headers标志进行配置)。

聚合自定义 API 服务器必须知道何时信任这些头部;否则,任何其他调用者都可以声称已进行了身份验证并且可以设置这些头部。这由特殊的请求头客户端 CA 处理。它存储在配置映射kube-system/extension-apiserver-authentication(文件名requestheader-client-ca-file)中。这里是一个示例:

apiVersion: v1
kind: ConfigMap
metadata:
  name: extension-apiserver-authentication
  namespace: kube-system
data:
  client-ca-file: |
    -----BEGIN CERTIFICATE-----
    ...
    -----END CERTIFICATE-----
  requestheader-allowed-names: '["aggregator"]'
  requestheader-client-ca-file: |
    -----BEGIN CERTIFICATE-----
    ...
    -----END CERTIFICATE-----
  requestheader-extra-headers-prefix: '["X-Remote-Extra-"]'
  requestheader-group-headers: '["X-Remote-Group"]'
  requestheader-username-headers: '["X-Remote-User"]'

有了这些信息,使用默认设置的聚合自定义 API 服务器将进行身份验证:

  • 使用与给定client-ca-file匹配的客户端证书的客户端

  • 客户端由 Kubernetes API 服务器预认证,其请求使用给定的requestheader-client-ca-file进行转发,并且其用户名和组成员资格存储在给定的 HTTP 头X-Remote-GroupX-Remote-User中。

最后但同样重要的是,有一个名为TokenAccessReview的机制,它将承载令牌(通过 HTTP 头部Authorization: bearer *token*接收)发送回 Kubernetes API 服务器,以验证它们是否有效。令牌访问审查机制默认情况下是禁用的,但可以选择启用;请参阅“选项和配置模式及启动管道”。

我们将在接下来的部分看到委派身份验证是如何实际设置的。虽然我们在这里详细介绍了这个机制,在聚合自定义 API 服务器内部,这大部分都是由k8s.io/apiserver库自动完成的。但了解幕后发生的事情在涉及安全性时确实很有价值。

委派授权

身份验证完成后,必须对每个请求进行授权。授权基于用户名和组列表。Kubernetes 中的默认授权机制是基于角色的访问控制(RBAC)。

RBAC 将身份映射到角色,并将角色映射到授权规则,最终接受或拒绝请求。我们这里不会详细讨论 RBAC 授权对象,如角色和集群角色,或角色绑定和集群角色绑定的所有细节(请参阅“正确设置权限”了解更多)。从架构的角度来看,了解聚合的自定义 API 服务器通过 SubjectAccessReview 委托授权来授权请求就足够了。它不会自己评估 RBAC 规则,而是将评估委托给 Kubernetes API 服务器。

现在让我们更详细地查看委托授权。

主题访问审查请求是从聚合的自定义 API 服务器发送到 Kubernetes API 服务器(如果在其授权缓存中找不到答案时)。以下是这样一个审查对象的示例:

apiVersion: authorization.k8s.io/v1
kind: SubjectAccessReview
spec:
  resourceAttributes:
    group: apps
    resource: deployments
    verb: create
    namespace: default
    version: v1
    name: example
  user: michael
  groups:
  - system:authenticated
  - admins
  - authors

Kubernetes API 服务器接收到这些信息后,评估集群中的 RBAC 规则并做出决策,返回一个带有状态字段设置的 SubjectAccessReview 对象;例如:

apiVersion: authorization.k8s.io/v1
kind: SubjectAccessReview
status:
  allowed: true
  denied: false
  reason: "rule foo allowed this request"

请注意,alloweddenied 可能都是 false。这意味着 Kubernetes API 服务器无法做出决策,此时聚合的自定义 API 服务器中的另一个授权器可以做出决策(API 服务器实现一个授权链,逐个查询,委托授权是该链中的一个授权器)。这可以用于建模非标准授权逻辑,即在某些情况下没有 RBAC 规则,而是使用外部授权系统。

为了性能原因,委托授权机制在每个聚合的自定义 API 服务器中维护一个本地缓存。默认情况下,它使用以下方式缓存 1,024 个授权条目:

  • 允许授权请求的有效期为 5 分钟

  • 拒绝授权请求的有效期为 30

可以通过 --authorization-webhook-cache-authorized-ttl--authorization-webhook-cache-unauthorized-ttl 进行这些值的自定义。

我们将在接下来的部分中看到如何在代码中设置委托授权。同样,与认证一样,在聚合的自定义 API 服务器中,委托授权大多是由 k8s.io/apiserver 库自动完成的。

编写自定义 API 服务器

在前面的部分中,我们看了聚合 API 服务器的架构。在本节中,我们想要查看在 Golang 中实现聚合自定义 API 服务器的具体实现。

主要的 Kubernetes API 服务器是通过 k8s.io/apiserver 库实现的。自定义 API 服务器将使用完全相同的代码。主要区别在于我们的自定义 API 服务器将在集群中运行。这意味着它可以假定集群中有一个 kube-apiserver 可用,并使用它进行委托授权和检索其他 kube 本地资源。

我们还假设etcd集群已经准备好,并且可以被聚合的自定义 API 服务器使用。重要的是这个etcd是专用的还是与 Kubernetes API 服务器共享的并不重要。我们的自定义 API 服务器将使用不同的etcd键空间以避免冲突。

本章中的代码示例引用了Github 上的示例代码,所以请参考完整的源代码。我们这里只展示了最有趣的摘录,但您可以随时查看完整的示例项目,进行实验,并且非常重要的是在真实集群中运行它以进行学习。

这个pizza-apiserver项目实现了在“示例:Pizza 餐厅”中显示的示例 API。

选项和配置模式以及启动管道

  1. k8s.io/apiserver库使用选项和配置模式来创建运行中的 API 服务器。

我们将从一些绑定到标志的选项结构开始。从k8s.io/apiserver获取它们,并添加我们的自定义选项。从k8s.io/apiserver获取的选项结构可以根据特殊用例在代码中进行调整,并且提供的标志可以应用到一个标志集中,以便用户访问。

示例中,我们从RecommendedOptions开始,非常简单地基于这一切。这些推荐的选项设置了一切需要的内容,以便为简单 API 的“正常”聚合自定义 API 服务器使用:

import (
    ...
    informers "github.com/programming-kubernetes/pizza-apiserver/pkg/
 generated/informers/externalversions"
)

const defaultEtcdPathPrefix = "/registry/restaurant.programming-kubernetes.info"

type CustomServerOptions struct {
    RecommendedOptions *genericoptions.RecommendedOptions
    SharedInformerFactory informers.SharedInformerFactory
}

func NewCustomServerOptions(out, errOut io.Writer) *CustomServerOptions {
    o := &CustomServerOptions{
        RecommendedOptions: genericoptions.NewRecommendedOptions(
            defaultEtcdPathPrefix,
            apiserver.Codecs.LegacyCodec(v1alpha1.SchemeGroupVersion),
            genericoptions.NewProcessInfo("pizza-apiserver", "pizza-apiserver"),
        ),
    }

    return o
}

CustomServerOptions嵌入了RecommendedOptions并在顶部添加了一个字段。NewCustomServerOptions是填充CustomServerOptions结构体默认值的构造函数。

让我们来看一些更有趣的细节:

  • defaultEtcdPathPrefix是我们所有键的etcd前缀。作为键空间,我们使用/registry/pizza-apiserver.programming-kubernetes.info,明显区别于 Kubernetes 的键。

  • SharedInformerFactory是全局共享的通知器工厂,用于我们自己的 CR,以避免相同资源的不必要的通知器(参见图 3-5)。请注意,它是从我们项目中生成的通知器代码导入的,而不是从client-go导入的。

  • NewRecommendedOptions为聚合的自定义 API 服务器设置了一切,使用了默认值。

让我们快速看一下NewRecommendedOptions

return &RecommendedOptions{
    Etcd:           NewEtcdOptions(storagebackend.NewDefaultConfig(prefix, codec)),
    SecureServing:  sso.WithLoopback(),
    Authentication: NewDelegatingAuthenticationOptions(),
    Authorization:  NewDelegatingAuthorizationOptions(),
    Audit:          NewAuditOptions(),
    Features:       NewFeatureOptions(),
    CoreAPI:        NewCoreAPIOptions(),
    ExtraAdmissionInitializers:
      func(c *server.RecommendedConfig) ([]admission.PluginInitializer, error) {
          return nil, nil
      },
    Admission:      NewAdmissionOptions(),
    ProcessInfo:    processInfo,
    Webhook:        NewWebhookOptions(),
}

所有这些都可以根据需要进行调整。例如,如果需要自定义默认的服务端口,可以设置RecommendedOptions.SecureServing.SecureServingOptions.BindPort

让我们简要地浏览现有的选项结构:

  • Etcd配置存储栈,用于读取和写入etcd

  • SecureServing配置了所有关于 HTTPS 的内容(即端口、证书等)。

  • Authentication设置了委托身份验证,如“委托身份验证和信任”中描述的。

  • Authorization设置了委托授权,如“委托授权”中描述的。

  • Audit 设置审计输出堆栈。默认情况下已禁用,但可以设置为输出审计日志文件或将审计事件发送到外部后端。

  • Features 配置 alpha 和 beta 特性的功能开关。

  • CoreAPI 包含访问主 API 服务器的 kubeconfig 文件的路径。默认情况下,这使用集群内配置。

  • Admission 是一堆变更和验证准入插件的堆栈,用于处理每个传入的 API 请求。可以通过自定义代码中的自定义准入插件来扩展它,或者可以调整自定义 API 服务器的默认准入链。

  • ExtraAdmissionInitializers 允许我们添加更多的准入初始化器。初始化器通过自定义 API 服务器的管道实现,例如,通过控制器或客户端。查看“准入”以了解更多关于自定义准入的信息。

  • ProcessInfo 包含事件对象创建的信息(即,进程名称和命名空间)。我们已将其设置为 pizza-apiserver 的值。

  • Webhook 配置 webhooks 的操作方式(例如,用于认证和准入 webhook 的通用设置)。对于在集群内运行的自定义 API 服务器,它设置了良好的默认值。对于集群外的 API 服务器,这是配置它如何访问 webhook 的地方。

选项与标志耦合;换句话说,它们通常处于与标志相同的抽象级别。作为一个经验法则,选项不保存“运行中”数据结构。它们在启动期间使用,然后转换为配置或服务器对象,然后运行。

可以通过 Validate() error 方法验证选项。此方法还将检查用户提供的标志值是否合乎逻辑。

可以通过设置默认值来完成选项,这些值不应出现在标志的帮助文本中,但是对于获得完整的选项集是必要的。

选项通过 Config() (*apiserver.Config, error) 方法转换为服务器配置(“config”)。这是通过从推荐的默认配置开始,然后将选项应用于其上来完成的:

func (o *CustomServerOptions) Config() (*apiserver.Config, error) {
    err := o.RecommendedOptions.SecureServing.MaybeDefaultWithSelfSignedCerts(
        "localhost", nil, []net.IP{net.ParseIP("127.0.0.1")},
    )
    if err != nil {
        return nil, fmt.Errorf("error creating self-signed cert: %v", err)
    }

    [... omitted o.RecommendedOptions.ExtraAdmissionInitializers ...]

    serverConfig := genericapiserver.NewRecommendedConfig(apiserver.Codecs)
    err = o.RecommendedOptions.ApplyTo(serverConfig, apiserver.Scheme);
    if err != nil {
        return nil, err
    }

    config := &apiserver.Config{
        GenericConfig: serverConfig,
        ExtraConfig:   apiserver.ExtraConfig{},
    }
    return config, nil
}

在此创建的配置包含可运行的数据结构;换句话说,配置是运行时对象,与选项形成对比,后者对应标志。o.RecommendedOptions.SecureServing.MaybeDefaultWithSelfSignedCerts 这一行在用户未传递用于预生成证书的标志时创建自签名证书。

正如我们所描述的,genericapiserver.NewRecommendedConfig 返回一个默认的推荐配置,而 RecommendedOptions.ApplyTo 则根据标志(和其他定制选项)对其进行更改。

pizza-apiserver 项目本身的配置结构只是我们示例自定义 API 服务器的 RecommendedConfig 的一个包装:

type ExtraConfig struct {
    // Place your custom config here.
}

type Config struct {
    GenericConfig *genericapiserver.RecommendedConfig
    ExtraConfig   ExtraConfig
}

// CustomServer contains state for a Kubernetes custom api server.
type CustomServer struct {
    GenericAPIServer *genericapiserver.GenericAPIServer
}

type completedConfig struct {
    GenericConfig genericapiserver.CompletedConfig
    ExtraConfig   *ExtraConfig
}

type CompletedConfig struct {
    // Embed a private pointer that cannot be instantiated outside of
    // this package.
    *completedConfig
}

如果需要运行中自定义 API 服务器的更多状态,ExtraConfig 就是放置它的地方。

类似于选项结构体,配置有一个Complete() CompletedConfig方法,用于设置默认值。因为必须实际调用Complete()来完成底层配置,通常通过引入未导出的completedConfig数据类型来通过类型系统强制执行。这里的想法是只有调用Complete()才能将Config转换为completeConfig。如果未执行此调用,编译器将报错:

func (cfg *Config) Complete() completedConfig {
    c := completedConfig{
        cfg.GenericConfig.Complete(),
        &cfg.ExtraConfig,
    }

    c.GenericConfig.Version = &version.Info{
        Major: "1",
        Minor: "0",
    }

    return completedConfig{&c}
}

最后,通过New()构造函数可以将完成的配置转换为CustomServer运行时结构体:

// New returns a new instance of CustomServer from the given config.
func (c completedConfig) New() (*CustomServer, error) {
    genericServer, err := c.GenericConfig.New(
        "pizza-apiserver",
        genericapiserver.NewEmptyDelegate(),
    )
    if err != nil {
        return nil, err
    }

    s := &CustomServer{
        GenericAPIServer: genericServer,
    }

    [ ... omitted API installation ...]

    return s, nil
}

注意我们在这里有意省略了 API 安装部分。我们将在“API 安装”中回到这一点(即在启动期间如何将注册表连接到自定义 API 服务器中)。注册表实现 API 组的 API 和存储语义。我们将在“注册表和策略”中看到这一点,用于餐厅 API 组。

CustomServer对象最终可以通过Run(stopCh <-chan struct{}) error方法启动。这由我们示例中选项的Run方法调用。也就是说,CustomServerOptions.Run

  • 创建配置

  • 完成配置

  • 创建CustomServer

  • 调用CustomServer.Run

这是代码:

func (o CustomServerOptions) Run(stopCh <-chan struct{}) error {
    config, err := o.Config()
    if err != nil {
        return err
    }

    server, err := config.Complete().New()
    if err != nil {
        return err
    }

    server.GenericAPIServer.AddPostStartHook("start-pizza-apiserver-informers",
        func(context genericapiserver.PostStartHookContext) error {
            config.GenericConfig.SharedInformerFactory.Start(context.StopCh)
            o.SharedInformerFactory.Start(context.StopCh)
            return nil
        },
    )

    return server.GenericAPIServer.PrepareRun().Run(stopCh)
}

PrepareRun()调用连接了 OpenAPI 规范,并可能执行其他 API 安装后操作。调用它后,Run方法启动实际服务器,它会阻塞直到stopCh关闭。

此示例还连接了一个名为start-pizza-apiserver-informers后启动钩子。顾名思义,后启动钩子在 HTTPS 服务器启动并侦听之后调用。在这里,它启动了共享的通知器工厂。

注意,即使是由自定义 API 服务器本身提供的本地进程内通知器也会通过 HTTPS 与本地主机接口通信。因此,在服务器启动并且 HTTPS 端口正在侦听之后启动它们是有意义的。

还要注意/healthz端点仅在所有后启动钩子成功完成后才返回成功。

有了所有小的管道组件就位,pizza-apiserver项目将所有内容封装到cobra命令中:

// NewCommandStartCustomServer provides a CLI handler for 'start master' command
// with a default CustomServerOptions.
func NewCommandStartCustomServer(
    defaults *CustomServerOptions,
    stopCh <-chan struct{},
) *cobra.Command {
    o := *defaults
    cmd := &cobra.Command{
        Short: "Launch a custom API server",
        Long:  "Launch a custom API server",
        RunE: func(c *cobra.Command, args []string) error {
            if err := o.Complete(); err != nil {
                return err
            }
            if err := o.Validate(); err != nil {
                return err
            }
            if err := o.Run(stopCh); err != nil {
                return err
            }
            return nil
        },
    }

    flags := cmd.Flags()
    o.RecommendedOptions.AddFlags(flags)

    return cmd
}

使用NewCommandStartCustomServer,进程的main()方法非常简单:

func main() {
    logs.InitLogs()
    defer logs.FlushLogs()

    stopCh := genericapiserver.SetupSignalHandler()
    options := server.NewCustomServerOptions(os.Stdout, os.Stderr)
    cmd := server.NewCommandStartCustomServer(options, stopCh)
    cmd.Flags().AddGoFlagSet(flag.CommandLine)
    if err := cmd.Execute(); err != nil {
        klog.Fatal(err)
    }
}

特别注意调用SetupSignalHandler:它连接 Unix 信号处理。在收到SIGINT(在终端按下 Ctrl-C 触发)和SIGKILL时,关闭停止通道。停止通道传递给运行中的自定义 API 服务器,当停止通道关闭时,它将关闭。因此,主循环将在收到信号时启动关闭过程。这种关闭是优雅的,因为会在终止之前完成正在运行的请求(默认情况下最多 60 秒),还确保所有请求都发送到审计后端,不会丢失审计数据。在所有这些操作完成后,cmd.Execute()将返回,进程将终止。

第一次启动

现在我们已经准备好首次启动自定义 API 服务器了。假设你已经配置了一个集群在 ~/.kube/config,你可以用它进行委托的认证和授权:

$ cd $GOPATH/src/github.com/programming-kubernetes/pizza-apiserver
$ etcd &
$ go run . --etcd-servers localhost:2379 \
    --authentication-kubeconfig ~/.kube/config \
    --authorization-kubeconfig ~/.kube/config \
    --kubeconfig ~/.kube/config
I0331 11:33:25.702320   64244 plugins.go:158]
  Loaded 3 mutating admission controller(s) successfully in the following order:
     NamespaceLifecycle,MutatingAdmissionWebhook,PizzaToppings.
I0331 11:33:25.702344   64244 plugins.go:161]
  Loaded 1 validating admission controller(s) successfully in the following order:
     ValidatingAdmissionWebhook.
I0331 11:33:25.714148   64244 secure_serving.go:116] Serving securely on [::]:443

它将启动并开始提供通用 API 端点的服务:

$ curl -k https://localhost:443/healthz
ok

我们也可以列出发现端点,但结果还不是很令人满意——因为我们还没有创建 API,所以发现结果为空:

$ curl -k https://localhost:443/apis
{
  "kind": "APIGroupList",
  "groups": []
}

让我们从更高层次来看:

  • 我们已经使用推荐的选项和配置启动了自定义 API 服务器。

  • 我们有一个包括委托认证、委托授权和审计的标准处理器链。

  • 我们有一个运行的 HTTPS 服务器,并为通用端点 /logs/metrics/version/healthz/apis 提供服务。

图 8-3 以一万英尺的高度展示了这一情况。

没有 API 的自定义 API 服务器

图 8-3. 没有 API 的自定义 API 服务器

内部类型和转换

现在我们已经设置了一个运行中的自定义 API 服务器,现在是时候实际实现 API 了。在这之前,我们必须理解 API 版本及其在 API 服务器内部处理方式。

每个 API 服务器为多个资源和版本提供服务(见 图 2-3)。某些资源有多个版本。为了支持多个版本的资源,API 服务器在各版本之间进行转换。

为了避免版本之间必要转换的二次增长,API 服务器在实现实际 API 逻辑时使用 内部版本。内部版本也经常被称为 hub 版本,因为它是每个其他版本都转换到和从中的中心(见 图 8-4)。内部 API 逻辑仅为该 hub 版本实现一次。

从 hub 版本到其他版本的转换

图 8-4. 从 hub 版本到其他版本的转换

图 8-5 展示了 API 服务器在 API 请求生命周期中如何利用内部版本:

  • 用户使用特定版本(例如,v1)发送请求。

  • API 服务器解码有效负载并将其转换为内部版本。

  • API 服务器通过审核和验证传递内部版本。

  • API 逻辑在注册表中为内部版本实现。

  • etcd 读取和写入版本化对象(例如,v2—存储版本);也就是说,它从内部版本转换到目标版本。

  • 最终,结果被转换为请求的版本,本例中为 v1

API 对象在请求生命周期中的转换

图 8-5. API 对象在请求生命周期中的转换

在内部中心版本与外部版本之间的每条边缘上,都会进行一次转换。在 图 8-6 中,您可以计算每个请求处理程序的转换次数。在写操作(如创建和更新)中,至少会执行四次转换,如果集群中部署了准入 Webhook,则可能会执行更多转换。正如您所见,转换是每个 API 实现中至关重要的操作。

请求生命周期中的转换和默认值

图 8-6. 请求生命周期中的转换和默认值

除了转换外,图 8-6 还显示了默认值的设置时机。默认值是填充未指定字段值的过程。默认值与转换高度耦合,并且始终在外部版本从用户请求、从etcd或从准入 Webhook 接收时进行,但从中心转换到外部版本时则不会进行。

警告

对于 API 服务器机制而言,转换至关重要。同样重要的是,所有转换(前后)必须正确,即符合双向可逆的概念。双向可逆意味着我们可以在版本图中(从 图 8-4 开始)的随机值之间前后转换,而且不会丢失任何信息;换句话说,转换是双射的或一对一的。例如,我们必须能够从一个随机(但有效的)v1对象转换到内部中心类型,然后转换到v1alpha1,再次转换到内部中心类型,然后再次转换回v1。得到的对象必须等同于原始对象。

实现类型的双向转换通常需要深思熟虑;它几乎总是驱动新版本的 API 设计,并影响旧类型的扩展,以存储新版本所携带的信息。

简而言之:正确实现双向转换是困难的——有时非常困难。请参阅 “双向转换测试” 以了解如何有效地测试双向转换。

默认逻辑可能会在 API 服务器的生命周期内发生变化。想象一下,您向类型添加了一个新字段。用户可能在磁盘上存储了旧对象,或者etcd可能存储了旧对象。如果该新字段具有默认值,当将旧存储对象发送到 API 服务器时,或者当用户从etcd检索到其中一个旧对象时,该字段值会被设置。在请求处理过程中,API 服务器的默认过程设置字段值,看起来就像新字段一直存在一样。

编写 API 类型

正如我们所看到的,要向自定义 API 服务器添加 API,我们必须编写内部中心版本类型和外部版本类型,并在它们之间进行转换。现在我们将为 比萨示例项目 进行详细讨论。

API 类型传统上放置在项目的 pkg/apis/group-name 包中,内部类型为 pkg/apis/group-name/types.go,外部版本为 pkg/apis/group-name/version/types.go)。因此,对于我们的示例,pkg/apis/restaurantpkg/apis/restaurant/v1alpha1/types.gopkg/apis/restaurant/v1beta1/types.go

转换将在 pkg/apis/group-name/version/zz_generated.conversion.go(用于 conversion-gen 输出)和开发者编写的自定义转换的 pkg/apis/group-name/version/conversion.go 中创建。

类似地,defaulter-gen 输出的默认代码将被创建在 pkg/apis/group-name/version/zz_generated.defaults.go 和开发者编写的自定义默认代码中的 pkg/apis/group-name/version/defaults.go。在我们的示例中,pkg/apis/restaurant/v1alpha1/defaults.gopkg/apis/restaurant/v1beta1/defaults.go 都有。

我们在“转换”和“默认情况”中详细讨论转换和默认情况。

除了转换和默认情况外,我们已经在“类型解剖”中为自定义资源定义的大部分过程已经见过了。在我们自定义的 API 服务器中,外部版本的原生类型完全以相同的方式定义。

另外,我们有 pkg/apis/group-name/types.go 用于内部类型,即中心类型。两者的主要区别在于后者的 register.go 文件中的 SchemeGroupVersion 引用了 runtime.APIVersionInternal(这是 "__internal" 的快捷方式)。

// SchemeGroupVersion is group version used to register these objects
var SchemeGroupVersion = schema.GroupVersion{Group: GroupName, Version:
runtime.APIVersionInternal}

pkg/apis/group-name/types.go 和外部类型文件之间的另一个区别是缺乏 JSON 和 protobuf 标签。

提示

一些生成器使用 JSON 标签来检测 types.go 文件是否为外部版本或内部版本。因此,在复制和粘贴外部类型以创建或更新内部类型时,务必删除这些标签。

最后但同样重要的是,有一个助手将 API 组的所有版本安装到一个方案中。这个助手通常放置在 pkg/apis/group-name/install/install.go 中。对于我们的自定义 API 服务器 pkg/apis/restaurant/install/install.go,看起来就像这样简单:

// Install registers the API group and adds types to a scheme
func Install(scheme *runtime.Scheme) {
    utilruntime.Must(restaurant.AddToScheme(scheme))
    utilruntime.Must(v1beta1.AddToScheme(scheme))
    utilruntime.Must(v1alpha1.AddToScheme(scheme))
    utilruntime.Must(scheme.SetVersionPriority(
        v1beta1.SchemeGroupVersion,
        v1alpha1.SchemeGroupVersion,
    ))
}

由于我们有多个版本,必须定义优先级。此顺序将用于确定资源的默认存储版本。它曾经在内部客户端(返回内部版本对象的客户端;参见注释“过去的版本化客户端和内部客户端”)中也起过版本选择的作用。但是内部客户端已被弃用,即将消失。甚至 API 服务器内部的代码将来也将使用外部版本客户端。

转换

转换将一个版本中的对象转换为另一个版本中的对象。转换通过转换函数实现,其中一些是手动编写的(按照惯例放置在pkg/apis/group-name/version/conversion.go中),其他则由conversion-gen自动生成(按照惯例放置在pkg/apis/group-name/version/zz_generated.conversion.go中)。

通过方案(见“Scheme”)使用Convert()方法启动转换,传递源对象in和目标对象out

func (s *Scheme) Convert(in, out interface{}, context interface{}) error

context描述如下:

// ...an optional field that callers may use to pass info to conversion functions.

它仅在非常特殊的情况下使用,并且通常为nil。稍后在本章中,我们将查看转换函数作用域,这使我们能够从转换函数内部访问此上下文。

要进行实际的转换,方案了解所有 Golang API 类型、它们的 GroupVersionKinds 以及在 GroupVersionKinds 之间的转换函数。为此,conversion-gen通过本地方案构建器注册生成的转换函数。在我们的示例自定义 API 服务器中,zz_generated.conversion.go文件以如下方式开始:

func init() {
    localSchemeBuilder.Register(RegisterConversions)
}

// RegisterConversions adds conversion functions to the given scheme.
// Public to allow building arbitrary schemes.
func RegisterConversions(s *runtime.Scheme) error {
    if err := s.AddGeneratedConversionFunc(
        (*Topping)(nil),
        (*restaurant.Topping)(nil),
        func(a, b interface{}, scope conversion.Scope) error {
            return Convert_v1alpha1_Topping_To_restaurant_Topping(
                a.(*Topping),
                b.(*restaurant.Topping),
                scope,
            )
        },
    ); err != nil {
        return err
    }
    ...
    return nil
}

...

函数Convert_v1alpha1_Topping_To_restaurant_Topping()是由生成的。它接收一个v1alpha1对象并将其转换为内部类型。

注意

前面复杂的类型转换将有类型的转换函数变成一个统一类型的func(a, b interface{}, scope conversion.Scope) error。该方案使用后者的类型,因为它可以在不使用反射的情况下调用它们。由于反射需要许多必要的分配,因此反射速度较慢。

conversion.go中手动编写的转换在生成过程中优先,这意味着如果在包中找到符合Convert_source-package-basename_KindTo_target-package-basename_Kind命名模式的手动编写函数,conversion-gen将跳过类型的生成。例如:

func Convert_v1alpha1_PizzaSpec_To_restaurant_PizzaSpec(
    in *PizzaSpec,
    out *restaurant.PizzaSpec,
    s conversion.Scope,
) error {
    ...

    return nil
}

在最简单的情况下,转换函数只是从源对象复制值到目标对象。但对于前面的例子,将v1alpha1披萨规范转换为内部类型,简单的复制是不够的。我们必须适应不同的结构,实际上看起来像下面这样:

func Convert_v1alpha1_PizzaSpec_To_restaurant_PizzaSpec(
    in *PizzaSpec,
    out *restaurant.PizzaSpec,
    s conversion.Scope,
) error {
    idx := map[string]int{}
    for _, top := range in.Toppings {
        if i, duplicate := idx[top]; duplicate {
            out.Toppings[i].Quantity++
            continue
        }
        idx[top] = len(out.Toppings)
        out.Toppings = append(out.Toppings, restaurant.PizzaTopping{
            Name: top,
            Quantity: 1,
        })
    }

    return nil
}

显然,没有代码生成可以如此聪明地预见用户在定义这些不同类型时的意图。

在转换过程中,请注意源对象绝不能被改变。但是,如果类型匹配,将源数据结构在目标对象中完全正常并且通常出于性能原因是强烈建议的。

这是如此重要,以至于我们在警告中再次强调它,因为它不仅对转换的实现有影响,还对调用者和转换输出的消费者有影响。

警告

转换函数不能改变源对象,但允许输出与源对象共享数据结构。这意味着转换输出的消费者必须确保不要改变对象,如果不希望改变原始对象,则需要进行深度拷贝。

例如,假设您在内部版本中有一个pod *core.Pod,并将其转换为v1版本的podv1 *corev1.Pod,并且改变了结果的podv1。这可能也会改变原始的pod。如果pod来自于一个 informer,这是非常危险的,因为 informer 具有共享缓存,而改变pod会使缓存不一致。

因此,要注意这种转换特性,如有必要请进行深度拷贝,以避免不必要和潜在的危险变异。

尽管数据结构的共享会带来一些风险,但在许多情况下也可以避免不必要的分配。生成的代码会比较源结构和目标结构,并使用 Golang 的unsafe包通过简单的类型转换将指向具有相同内存布局的结构体的指针进行转换。因为在我们的示例中,内部类型和v1beta1类型的 pizza 具有相同的内存布局,所以我们得到了这个:

func autoConvert_restaurant_PizzaSpec_To_v1beta1_PizzaSpec(
    in *restaurant.PizzaSpec,
    out *PizzaSpec,
    s conversion.Scope,
) error {
    out.Toppings = *(*[]PizzaTopping)(unsafe.Pointer(&in.Toppings))
    return nil
}

在机器语言级别上,这是一个 NOOP 操作,因此速度非常快。在这种情况下,它避免了分配一个切片并从in复制项目到out

最后但并非最不重要,关于转换函数的第三个参数的一些说明:转换范围conversion.Scope

转换范围提供对多个转换元值的访问。例如,它允许我们通过以下方式访问传递给方案的Convert(in, out interface{}, context interface{}) error方法的context值:

s.Meta().Context

它还允许我们通过s.Convert来调用子类型的方案转换,或者在完全不考虑注册的转换函数的情况下通过s.DefaultConvert

在大多数转换情况下,实际上根本不需要使用范围。为了简单起见,可以忽略其存在,直到遇到需要比源对象和目标对象更多上下文的棘手情况为止。

默认值

默认值是 API 请求生命周期中设置省略对象(来自客户端或etcd)中省略字段的默认值的步骤。例如,一个 pod 有一个restartPolicy字段。如果用户没有指定它,该值将默认为Always

想象一下,我们正在使用一个非常旧的 Kubernetes 版本,大约在 2014 年左右。该字段restartPolicy刚刚在当时的最新版本中引入到系统中。在升级集群后,etcd中有一个没有restartPolicy字段的 pod。一个kubectl get pod将从etcd中读取旧的 pod,并且默认值代码会添加默认值Always。从用户的角度来看,旧的 pod 突然具有了新的restartPolicy字段,这看起来像是魔术般的变化。

参考图 8-6 以查看 Kubernetes 请求流水线中当前进行默认设置的位置。请注意,仅为外部类型执行默认操作,而不是内部类型。

现在让我们看一下执行默认设置的代码。默认设置是由k8s.io/apiserver代码通过方案启动的,类似于转换。因此,我们必须为我们的自定义类型在方案中注册默认函数。

同样地,大多数默认代码只是通过defaulter-gen二进制生成的。它遍历 API 类型,并在pkg/apis/group-name/version/zz_generated.defaults.go中创建默认函数。默认情况下,此代码除了调用子结构的默认函数外,不执行任何操作。

您可以通过遵循默认函数命名模式SetDefaults*Kind*来定义自己的默认逻辑:

func SetDefaults*`Kind`*(obj **`Type`*) {
    ...
}

此外,与转换不同的是,我们必须手动在本地方案构建器上调用生成函数的注册。不幸的是,这不会自动完成:

func init() {
    localSchemeBuilder.Register(RegisterDefaults)
}

在这里,RegisterDefaults在包pkg/apis/group-name/version/zz_generated.defaults.go内生成。

对于默认代码,了解用户何时设置了字段,何时未设置至关重要。在许多情况下,这一点并不明显。

Golang 对于每种类型都有零值,并在传递的 JSON 或 protobuf 中找不到字段时设置它们。想象一个布尔字段foo的默认值为true。零值为false。不幸的是,不清楚false是由于用户的输入设置的,还是因为false只是布尔值的零值。

为了避免这种情况,通常必须在 Golang API 类型中使用指针类型(例如,在前面的情况下使用*bool)。用户提供的false将导致非nil布尔指针指向false值,用户提供的true将导致非nil布尔指针指向true值。未提供字段将导致nil。这可以在默认代码中检测到:

func SetDefaults*`Kind`*(obj **`Type`*) {
    if obj.Foo == nil {
        x := true
        obj.Foo = &x
    }
}

这提供了期望的语义:“foo 默认为 true。”

提示

这种使用指针的技巧适用于诸如字符串之类的原始类型。对于映射和数组,要在不识别nil映射/数组和空映射/数组的情况下实现往返性通常很困难。因此,Kubernetes 中大多数映射和数组的默认函数在这两种情况下都应用默认值,以解决编码和解码错误。

往返测试

正确进行转换很困难。往返测试是检查在随机测试中转换是否按计划进行并且在从所有已知组版本转换时不丢失数据的重要工具。

往返测试通常与install.go文件一起放置(例如,pkg/apis/restaurant/install/roundtrip_test.go),并且只需从 API Machinery 调用往返测试函数:

import (
    ...
    "k8s.io/apimachinery/pkg/api/apitesting/roundtrip"
    restaurantfuzzer "github.com/programming-kubernetes/pizza-apiserver/pkg/apis/
 restaurant/fuzzer"
)

func TestRoundTripTypes(t *testing.T) {
    roundtrip.RoundTripTestForAPIGroup(t, Install, restaurantfuzzer.Funcs)
}

在内部,RoundTripTestForAPIGroup 调用使用 Install 函数将 API 组安装到临时方案中。然后,使用给定的 fuzzer 在内部版本中创建随机对象,然后将它们转换为某些外部版本,再转换回内部版本。结果对象必须与原始对象相等。此测试将在所有外部版本中重复数百次或数千次。

fuzzer 是一个函数,返回内部类型及其子类型的随机函数切片。在我们的示例中,fuzzer 放置在包 pkg/apis/restaurant/fuzzer/fuzzer.go 中,并为 spec 结构体提供了一个随机化器:

// Funcs returns the fuzzer functions for the restaurant api group.
var Funcs = func(codecs runtimeserializer.CodecFactory) []interface{} {
    return []interface{}{
        func(s *restaurant.PizzaSpec, c fuzz.Continue) {
            c.FuzzNoCustom(s) // fuzz first without calling this function again

            // avoid empty Toppings because that is defaulted
            if len(s.Toppings) == 0 {
                s.Toppings = []restaurant.PizzaTopping{
                    {"salami", 1},
                    {"mozzarella", 1},
                    {"tomato", 1},
                }
            }

            seen := map[string]bool{}
            for i := range s.Toppings {
                // make quantity strictly positive and of reasonable size
                s.Toppings[i].Quantity = 1 + c.Intn(10)

                // remove duplicates
                for {
                    if !seen[s.Toppings[i].Name] {
                        break
                    }
                    s.Toppings[i].Name = c.RandString()
                }
                seen[s.Toppings[i].Name] = true
            }
        },
    }
}

如果没有提供随机化器函数,则底层库 github.com/google/gofuzz 将尝试通用地对对象进行随机化,为基本类型设置随机值,并递归地深入指针、结构体、映射和切片,最终调用开发人员提供的自定义随机化器函数。

当为某种类型编写随机化函数时,首先调用 c.FuzzNoCustom(s) 是很方便的。它会随机化给定的对象 s,并调用子结构的自定义函数,但不会对 s 本身进行处理。然后开发人员可以限制和修复随机值,使对象有效。

警告

尽可能将 fuzzer 设计得尽可能通用,以覆盖尽可能多的有效对象。如果 fuzzer 过于限制,测试覆盖率将变差。在 Kubernetes 的开发过程中,许多情况下由于现有的 fuzzer 不足以捕捉到回归问题。

另一方面,fuzzer 只需考虑验证对象,它们是外部版本中可定义的实际对象的投影。通常需要限制 c.FuzzNoCustom(s) 设置的随机值,使随机对象有效。例如,如果一个字符串包含 URL,则无需为任意值进行往返转换,如果验证将拒绝任意字符串的话。

我们之前的 PizzaSpec 示例首先调用 c.FuzzNoCustom(s),然后通过以下方式修复对象:

  • 对于配料的 nil 情况进行默认设置

  • 为每种配料设置合理的数量(如果不这样做,在将其转换为 v1alpha1 时,将会增加复杂性,并且会在字符串列表中引入高数量)

  • 规范化配料名称,因为我们知道在 pizza 规范中重复的配料永远不会往返(对于内部类型,注意 v1alpha1 类型存在重复)

验证

在反序列化、默认设置和转换为内部版本后不久,传入对象将被验证。图 8-5 早些时候展示了在执行实际创建或更新逻辑之前,如何在变异准入插件和验证准入插件之间进行验证。

这意味着只需为内部版本实现一次验证,而不是为所有外部版本实现。这具有明显的实施工作节省优势,并确保各版本之间的一致性。另一方面,这意味着验证错误不涉及外部版本。实际上,这在 Kubernetes 资源中可以观察到,但实际上并不是什么大问题。

在本节中,我们将看一下验证函数的实现。将自定义 API 服务器中的连接——也就是从配置通用注册表的策略中调用验证——将在下一节中讨论。换句话说,图 8-5 在视觉上略显简单化。

目前,仅需看一下策略内部验证的入口点即可:

func (pizzaStrategy) Validate(
    ctx context.Context, obj runtime.Object,
) field.ErrorList {
    pizza := obj.(*restaurant.Pizza)
    return validation.ValidatePizza(pizza)
}

这会调用验证包中 API 组 pkg/apis/*group*/*validation*Validate*Kind*(obj **Kind*) field.ErrorList 验证函数。

验证函数返回一个错误列表。它们通常以相同的风格编写,将返回值追加到错误列表中,同时递归地深入类型,每个结构体一个验证函数:

// ValidatePizza validates a Pizza.
func ValidatePizza(f *restaurant.Pizza) field.ErrorList {
    allErrs := field.ErrorList{}

    errs := ValidatePizzaSpec(&f.Spec, field.NewPath("spec"))
    allErrs = append(allErrs, errs...)

    return allErrs
}

// ValidatePizzaSpec validates a PizzaSpec.
func ValidatePizzaSpec(
    s *restaurant.PizzaSpec,
    fldPath *field.Path,
) field.ErrorList {
    allErrs := field.ErrorList{}

    prevNames := map[string]bool{}
    for i := range s.Toppings {
        if s.Toppings[i].Quantity <= 0 {
            allErrs = append(allErrs, field.Invalid(
                fldPath.Child("toppings").Index(i).Child("quantity"),
                s.Toppings[i].Quantity,
                "cannot be negative or zero",
            ))
        }
        if len(s.Toppings[i].Name) == 0 {
            allErrs = append(allErrs, field.Invalid(
                fldPath.Child("toppings").Index(i).Child("name"),
                s.Toppings[i].Name,
                "cannot be empty",
            ))
        } else {
            if prevNames[s.Toppings[i].Name] {
                allErrs = append(allErrs, field.Invalid(
                    fldPath.Child("toppings").Index(i).Child("name"),
                    s.Toppings[i].Name,
                    "must be unique",
                ))
            }
            prevNames[s.Toppings[i].Name] = true
        }
    }

    return allErrs
}

注意如何使用 ChildIndex 调用维护字段路径。字段路径是 JSON 路径,在错误发生时打印。

常常存在一组额外的验证函数,这些函数与更新有所不同(而之前的函数用于创建)。在我们的示例 API 服务器中,可能如下所示:

func (pizzaStrategy) ValidateUpdate(
    ctx context.Context,
    obj, old runtime.Object,
) field.ErrorList {
    objPizza := obj.(*restaurant.Pizza)
    oldPizza := old.(*restaurant.Pizza)
    return validation.ValidatePizzaUpdate(objPizza, oldPizza)
}

这可用于验证不更改只读字段。通常,更新验证也会调用正常的验证函数,并且仅添加与更新相关的检查。

注意

在创建时,验证是限制对象名称的正确位置——例如,只能是单词,或者不包含任何非字母数字字符。

实际上,任何 ObjectMeta 字段在技术上都可以以自定义方式限制,尽管对于许多字段来说并非理想,因为这可能会破坏核心 API 机制行为。一些资源限制名称,例如,因为名称会显示在其他系统或其他需要特殊格式名称的上下文中。

即使在自定义 API 服务器中有特定的 ObjectMeta 验证,通用注册表也会在自定义验证通过后对其进行通用规则验证。这使我们能够首先从自定义代码返回更具体的错误消息。

注册表和策略

到目前为止,我们已经看到了如何定义和验证 API 类型。下一步是为这些 API 类型的 REST 逻辑实现。图 8-7 显示了注册表作为 API 组实现的核心部分。k8s.io/apiserver 中的通用 REST 请求处理程序代码会调用注册表。

资源存储和通用注册表

图 8-7. 资源存储和通用注册表

通用注册表

REST 逻辑通常由所谓的通用注册表实现。正如其名称所示,它是 k8s.io/apiserver/pkg/registry/rest 包中注册表接口的通用实现。

通用注册表实现了“普通”资源的默认 REST 行为。几乎所有 Kubernetes 资源使用此实现。仅少数不持久化对象的资源(例如SubjectAccessReview;参见“委托授权”)具有自定义实现。

k8s.io/apiserver/pkg/registry/rest/rest.go 中,您将找到许多接口,它们大致对应于 HTTP 动词和某些 API 功能。如果注册表实现了某个接口,则 API 端点代码将提供某些 REST 功能。因为通用注册表实现了大多数 k8s.io/apiserver/pkg/registry/rest 接口,因此使用它的资源将支持所有默认的 Kubernetes HTTP 动词(参见“API 服务器的 HTTP 接口”)。以下是已实现的接口列表,包括 Kubernetes 源代码中的 GoDoc 描述:

CollectionDeleter

可以删除一组 RESTful 资源的对象

Creater

可以创建 RESTful 对象的实例的对象

CreaterUpdater

必须支持创建和更新操作的存储对象

Exporter

知道如何为导出剥离 RESTful 资源的对象

Getter

可以检索命名的 RESTful 资源的对象

GracefulDeleter

知道如何传递删除选项以允许延迟删除 RESTful 对象的对象

Lister

可以检索符合提供的字段和标签条件的资源的对象

Patcher

支持获取和更新的存储对象

Scoper

必须指定并指示资源所在范围的对象

Updater

可以更新 RESTful 对象实例的对象

Watcher

一个所有存储对象都应实现的对象,希望通过Watch API 提供监视变更能力

让我们来看看其中一个接口,Creater

// Creater is an object that can create an instance of a RESTful object.
type Creater interface {
    // New returns an empty object that can be used with Create after request
    // data has been put into it.
    // This object must be a pointer type for use with Codec.DecodeInto([]byte,
    // runtime.Object)
    New() runtime.Object

    // Create creates a new version of a resource.
    Create(
        ctx context.Context,
        obj runtime.Object,
        createValidation ValidateObjectFunc,
        options *metav1.CreateOptions,
    ) (runtime.Object, error)
}

实现此接口的注册表将能够创建对象。与NamedCreater相反,新对象的名称要么来自ObjectMeta.Name,要么通过ObjectMeta.GenerateName生成。如果注册表实现了NamedCreater,则名称也可以通过 HTTP 路径传递。

重要的是要理解,实现的接口决定了在安装 API 到自定义 API 服务器时将支持哪些动词。请参见“API 安装”以了解在代码中如何实现这一点。

策略

通用注册表可以使用称为策略的对象在一定程度上进行定制。 正如我们在“验证”中看到的,策略提供了到功能的回调,例如验证。

策略实现了此处列出的 REST 策略接口及其 GoDoc 描述(请参阅k8s.io/apiserver/pkg/registry/rest获取定义):

RESTCreateStrategy

定义了最小验证、接受的输入和名称生成行为,以创建符合 Kubernetes API 约定的对象。

RESTDeleteStrategy

定义了符合 Kubernetes API 约定的对象的删除行为。

RESTGracefulDeleteStrategy

必须由支持优雅删除的注册表实现。

GarbageCollectionDeleteStrategy

必须由默认要孤立依赖项的注册表实现。

RESTExportStrategy

定义了如何导出 Kubernetes 对象。

RESTUpdateStrategy

定义了最小验证、接受的输入和名称生成行为,以更新符合 Kubernetes API 约定的对象。

让我们再次看看创建情况的策略:

type RESTCreateStrategy interface {
    runtime.ObjectTyper
    // The name generator is used when the standard GenerateName field is set.
    // The NameGenerator will be invoked prior to validation.
    names.NameGenerator

    // NamespaceScoped returns true if the object must be within a namespace.
    NamespaceScoped() bool
    // PrepareForCreate is invoked on create before validation to normalize
    // the object. For example: remove fields that are not to be persisted,
    // sort order-insensitive list fields, etc. This should not remove fields
    // whose presence would be considered a validation error.
    //
    // Often implemented as a type check and an initailization or clearing of
    // status. Clear the status because status changes are internal. External
    // callers of an api (users) should not be setting an initial status on
    // newly created objects.
    PrepareForCreate(ctx context.Context, obj runtime.Object)
    // Validate returns an ErrorList with validation errors or nil. Validate
    // is invoked after default fields in the object have been filled in
    // before the object is persisted. This method should not mutate the
    // object.
    Validate(ctx context.Context, obj runtime.Object) field.ErrorList
    // Canonicalize allows an object to be mutated into a canonical form. This
    // ensures that code that operates on these objects can rely on the common
    // form for things like comparison. Canonicalize is invoked after
    // validation has succeeded but before the object has been persisted.
    // This method may mutate the object. Often implemented as a type check or
    // empty method.
    Canonicalize(obj runtime.Object)
}

嵌入的ObjectTyper识别对象;也就是说,它检查请求中的对象是否受注册表支持。 这对于创建正确类型的对象非常重要(例如,通过“foo”资源,只应创建“Foo”资源)。

NameGenerator显然是从ObjectMeta.GenerateName字段生成名称。

通过NamespaceScoped,该策略可以通过返回falsetrue支持整个集群或命名空间资源。

在验证之前,使用传入的对象调用PrepareForCreate方法。

我们之前在“验证”中见过的Validate方法:这是验证函数的入口点。

最后,Canonicalize方法对切片进行规范化(例如,排序)。

将策略连接到通用注册表中。

策略对象被插入到通用注册表实例中。 这是我们在GitHub上自定义 API 服务器的 REST 存储构造函数:

// NewREST returns a RESTStorage object that will work against API services.
func NewREST(
    scheme *runtime.Scheme,
    optsGetter generic.RESTOptionsGetter,
) (*registry.REST, error) {
    strategy := NewStrategy(scheme)

    store := &genericregistry.Store{
        NewFunc:       func() runtime.Object { return &restaurant.Pizza{} },
        NewListFunc:   func() runtime.Object { return &restaurant.PizzaList{} },
        PredicateFunc: MatchPizza,

        DefaultQualifiedResource: restaurant.Resource("pizzas"),

        CreateStrategy: strategy,
        UpdateStrategy: strategy,
        DeleteStrategy: strategy,
    }
    options := &generic.StoreOptions{
        RESTOptions: optsGetter,
        AttrFunc: GetAttrs,
    }
    if err := store.CompleteWithOptions(options); err != nil {
        return nil, err
    }
    return &registry.REST{store}, nil
}

它实例化了通用注册表对象genericregistry.Store并设置了几个字段。 这些字段中的许多是可选的,如果开发人员未设置它们,store.CompleteWithOptions将使用默认值。

通过NewStrategy构造函数首次实例化自定义策略,然后将其插入到注册表中以进行createupdatedelete操作。

此外,NewFunc设置为创建新对象实例,并设置了NewListFunc字段以创建新对象列表。 PredicateFunc将选择器(可以传递到列表请求)转换为谓词函数,过滤运行时对象。

返回的对象是一个 REST 注册表,在我们的示例项目中只是一个围绕通用注册表对象的简单包装:

type REST struct {
  *genericregistry.Store
}

通过这样做,我们已经准备好实例化我们的 API 并将其连接到自定义 API 服务器中。在接下来的部分中,我们将看到如何创建 HTTP 处理程序。

API 安装

要在 API 服务器中激活 API,需要两个步骤:

  1. 必须将 API 版本安装到 API 类型(以及转换和默认函数)的服务器方案中。

  2. 必须将 API 版本安装到服务器 HTTP 多路复用器(mux)中。

第一步通常是在 API 服务器引导过程的某个中心位置使用init函数完成的。在我们示例的自定义 API 服务器中,这在pkg/apiserver/apiserver.go中完成,其中定义了serverConfigCustomServer对象(参见“选项和配置模式以及启动流程”):

import (
    ...
    "k8s.io/apimachinery/pkg/runtime"
    "k8s.io/apimachinery/pkg/runtime/serializer"

    "github.com/programming-kubernetes/pizza-apiserver/pkg/apis/restaurant/install"
)

var (
    Scheme = runtime.NewScheme()
    Codecs = serializer.NewCodecFactory(Scheme)
)

然后,对于每个应该提供服务的 API 组,我们调用Install()函数:

func init() {
    install.Install(Scheme)
}

由于技术原因,我们还必须向方案中添加一些与发现相关的类型(这可能会在将来的k8s.io/apiserver版本中消失):

func init() {
    // we need to add the options to empty v1
    // TODO: fix the server code to avoid this
    metav1.AddToGroupVersion(Scheme, schema.GroupVersion{Version: "v1"})
    // TODO: keep the generic API server from wanting this
    unversioned := schema.GroupVersion{Group: "", Version: "v1"}
    Scheme.AddUnversionedTypes(unversioned,
        &metav1.Status{},
        &metav1.APIVersions{},
        &metav1.APIGroupList{},
        &metav1.APIGroup{},
        &metav1.APIResourceList{},
    )
}

通过这样做,我们已在全局方案中注册了我们的 API 类型,包括转换和默认函数。换句话说,图 8-3 的空方案现在已了解关于我们类型的所有内容。

第二步是将 API 组添加到 HTTP mux 中。嵌入到我们的CustomServer结构体中的通用 API 服务器代码提供了InstallAPIGroup(apiGroupInfo *APIGroupInfo) error方法,该方法为 API 组设置了整个请求流水线。

唯一需要做的就是提供一个正确填充的APIGroupInfo结构体。我们在completedConfig类型的构造函数New() (*CustomServer, error)中完成了这一步:

// New returns a new instance of CustomServer from the given config.
func (c completedConfig) New() (*CustomServer, error) {
    genericServer, err := c.GenericConfig.New("pizza-apiserver",
      genericapiserver.NewEmptyDelegate())
    if err != nil {
        return nil, err
    }

    s := &CustomServer{
        GenericAPIServer: genericServer,
    }

    apiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo(restaurant.GroupName,
      Scheme, metav1.ParameterCodec, Codecs)

    v1alpha1storage := map[string]rest.Storage{}

    pizzaRest := pizzastorage.NewREST(Scheme, c.GenericConfig.RESTOptionsGetter)
    v1alpha1storage["pizzas"] = customregistry.RESTInPeace(pizzaRest)

    toppingRest := toppingstorage.NewREST(
        Scheme, c.GenericConfig.RESTOptionsGetter,
    )
    v1alpha1storage["toppings"] = customregistry.RESTInPeace(toppingRest)

    apiGroupInfo.VersionedResourcesStorageMap["v1alpha1"] = v1alpha1storage

    v1beta1storage := map[string]rest.Storage{}

    pizzaRest = pizzastorage.NewREST(Scheme, c.GenericConfig.RESTOptionsGetter)
    v1beta1storage["pizzas"] = customregistry.RESTInPeace(pizzaRest)

    apiGroupInfo.VersionedResourcesStorageMap["v1beta1"] = v1beta1storage

    if err := s.GenericAPIServer.InstallAPIGroup(&apiGroupInfo); err != nil {
        return nil, err
    }

    return s, nil
}

APIGroupInfo具有对我们在“注册表和策略”中定制的通用注册表的引用。对于每个组版本和资源,我们使用实现的构造函数创建注册表的实例。

customregistry.RESTInPeace包装器只是一个辅助工具,当注册表构造函数返回错误时会引发恐慌:

func RESTInPeace(storage rest.StandardStorage, err error) rest.StandardStorage {
    if err != nil {
        err = fmt.Errorf("unable to create REST storage: %v", err)
        panic(err)
    }
    return storage
}

注册表本身是版本无关的,因为它操作内部对象;请参考图 8-5。因此,我们为每个版本调用相同的注册表构造函数。

调用InstallAPIGroup最终使我们的完整自定义 API 服务器准备好为我们的自定义 API 组提供服务,就像图 8-7 中早先展示的那样。

在完成所有这些繁重的流程之后,现在是时候看到我们新 API 组的实际效果了。为此,我们启动服务器,如“首次启动”所示。但这次发现信息不再是空的,而是显示我们新注册的资源:

$ curl -k https://localhost:443/apis
{
  "kind": "APIGroupList",
  "groups": [
    {
      "name": "restaurant.programming-kubernetes.info",
      "versions": [
        {
          "groupVersion": "restaurant.programming-kubernetes.info/v1beta1",
          "version": "v1beta1"
        },
        {
          "groupVersion": "restaurant.programming-kubernetes.info/v1alpha1",
          "version": "v1alpha1"
        }
      ],
      "preferredVersion": {
        "groupVersion": "restaurant.programming-kubernetes.info/v1beta1",
        "version": "v1beta1"
      },
      "serverAddressByClientCIDRs": [
        {
          "clientCIDR": "0.0.0.0/0",
          "serverAddress": ":443"
        }
      ]
    }
  ]
}

通过这样做,我们几乎已经达到了为服务餐厅 API 的目标。我们已经连接了 API 组版本,转换已完成,并且验证工作正常。

缺少的是检查披萨中提到的配料是否实际存在于集群中。我们可以将此添加到验证函数中。但传统上这些只是格式验证函数,是静态的,不需要其他资源来运行。

相比之下,更复杂的检查是在准入阶段实现的——这是下一节的主题。

准入

每个请求在解组、默认化和转换为内部类型后都会通过一系列准入插件链;请参考图 8-2。更准确地说,请求会经过两次准入:

  • 变异插件

  • 验证插件

准入插件可以既变异又验证,因此可能会被准入机制调用两次:

  • 一旦进入变异阶段,便按顺序调用所有变异插件。

  • 一次在验证阶段,为所有验证插件(可能并行)调用

更准确地说,一个插件可以同时实现变异和验证准入接口,分别有两种不同的方法。

注意

在分为变异和验证之前,每个插件只调用一次。几乎不可能监视每个插件所做的每个变异,并且因此什么顺序准入插件使得对用户行为一致性具有意义。

这种两步架构至少确保所有插件在最后进行验证,从而保证一致性。

此外,链(即两个准入阶段的插件顺序)是相同的。插件始终同时启用或禁用这两个阶段。

准入插件,至少在 Golang 中如本章节所述的那样实现的,与内部类型一起工作。相比之下,webhook 准入插件(参见“准入 Webhook”)基于外部类型,并且在向 webhook 和回传(在变异 webhook 的情况下)的途中进行转换。

但是说了这么多理论,让我们进入代码。

实现

准入插件是实现以下类型的类型:

  • 准入插件接口Interface

  • 可选的MutatingInterface

  • 可选的ValidatingInterface

所有三者都可以在包k8s.io/apiserver/pkg/admission中找到:

// Operation is the type of resource operation being checked for
// admission control
type Operation string.

// Operation constants
const (
    Create  Operation = "CREATE"
    Update  Operation = "UPDATE"
    Delete  Operation = "DELETE"
    Connect Operation = "CONNECT"
)

// Interface is an abstract, pluggable interface for Admission Control
// decisions.
type Interface interface {
    // Handles returns true if this admission controller can handle the given
    // operation where operation can be one of CREATE, UPDATE, DELETE, or
    // CONNECT.
    Handles(operation Operation) bool.
}

type MutationInterface interface {
    Interface

    // Admit makes an admission decision based on the request attributes.
    Admit(a Attributes, o ObjectInterfaces) (err error)
}

// ValidationInterface is an abstract, pluggable interface for Admission Control
// decisions.
type ValidationInterface interface {
    Interface

    // Validate makes an admission decision based on the request attributes.
    // It is NOT allowed to mutate.
    Validate(a Attributes, o ObjectInterfaces) (err error)
}

您可以看到Interface方法Handles负责对操作进行过滤。通过Admit调用变异插件,通过Validate调用验证插件。

ObjectInterfaces提供对通常由方案实现的辅助工具的访问:

type ObjectInterfaces interface {
    // GetObjectCreater is the ObjectCreater for the requested object.
    GetObjectCreater() runtime.ObjectCreater
    // GetObjectTyper is the ObjectTyper for the requested object.
    GetObjectTyper() runtime.ObjectTyper
    // GetObjectDefaulter is the ObjectDefaulter for the requested object.
    GetObjectDefaulter() runtime.ObjectDefaulter
    // GetObjectConvertor is the ObjectConvertor for the requested object.
    GetObjectConvertor() runtime.ObjectConvertor
}

通过AdmitValidate(或两者)传递给插件的属性基本上包含从请求中提取的所有对实施高级检查重要的信息:

// Attributes is an interface used by AdmissionController to get information
// about a request that is used to make an admission decision.
type Attributes interface {
    // GetName returns the name of the object as presented in the request.
    // On a CREATE operation, the client may omit name and rely on the
    // server to generate the name. If that is the case, this method will
    // return the empty string.
    GetName() string
    // GetNamespace is the namespace associated with the request (if any).
    GetNamespace() string
    // GetResource is the name of the resource being requested. This is not the
    // kind. For example: pods.
    GetResource() schema.GroupVersionResource
    // GetSubresource is the name of the subresource being requested. This is a
    // different resource, scoped to the parent resource, but it may have a
    // different kind.
    // For instance, /pods has the resource "pods" and the kind "Pod", while
    // /pods/foo/status has the resource "pods", the sub resource "status", and
    // the kind "Pod" (because status operates on pods). The binding resource for
    // a pod, though, may be /pods/foo/binding, which has resource "pods",
    // subresource "binding", and kind "Binding".
    GetSubresource() string
    // GetOperation is the operation being performed.
    GetOperation() Operation
    // IsDryRun indicates that modifications will definitely not be persisted for
    // this request. This is to prevent admission controllers with side effects
    // and a method of reconciliation from being overwhelmed.
    // However, a value of false for this does not mean that the modification will
    // be persisted, because it could still be rejected by a subsequent
    // validation step.
    IsDryRun() bool
    // GetObject is the object from the incoming request prior to default values
    // being applied.
    GetObject() runtime.Object
    // GetOldObject is the existing object. Only populated for UPDATE requests.
    GetOldObject() runtime.Object
    // GetKind is the type of object being manipulated. For example: Pod.
    GetKind() schema.GroupVersionKind
    // GetUserInfo is information about the requesting user.
    GetUserInfo() user.Info

    // AddAnnotation sets annotation according to key-value pair. The key
    // should be qualified, e.g., podsecuritypolicy.admission.k8s.io/admit-policy,
    //  where "podsecuritypolicy" is the name of the plugin, "admission.k8s.io"
    // is the name of the organization, and "admit-policy" is the key
    // name. An error is returned if the format of key is invalid. When
    // trying to overwrite annotation with a new value, an error is
    // returned. Both ValidationInterface and MutationInterface are
    // allowed to add Annotations.
    AddAnnotation(key, value string) error
}

在变异情况下——也就是在实现Admit(a Attributes) error方法时——属性可能会发生变异,或者更准确地说,从GetObject() runtime.Object返回的对象可能会发生变异。

在验证情况下,不允许变异。

在这两种情况下,都允许调用 AddAnnotation(key, value string) error,这使我们能够添加注释,最终出现在 API 服务器的审计输出中。这有助于理解为何准入插件会变异或拒绝请求。

通过从 AdmitValidate 返回非nil错误来发出拒绝信号。

提示

对于变异准入插件来说,验证变异的变化也是个好习惯。原因是其他插件,包括 Webhook 准入插件,可能会添加进一步的变化。如果一个准入插件保证某些不变量已被满足,只有验证步骤才能确保这一点。

准入插件必须从 admission.Interface 接口实现 Handles(operation Operation) bool 方法。同一包中有一个叫做 Handler 的助手。可以使用 NewHandler(ops ...Operation) *Handler 来实例化它,并通过将 Handler 嵌入到自定义准入插件中来实现 Handles 方法:

type CustomAdmissionPlugin struct {
    *admission.Handler
    ...
}

准入插件应始终首先检查传递对象的 GroupVersionKind:

func (d *PizzaToppingsPlugin) Admit(
    a admission.Attributes,
    o ObjectInterfaces,
) error {
    // we are only interested in pizzas
    if a.GetKind().GroupKind() != restaurant.Kind("Pizza") {
        return nil
    }

    ...
}

对于验证案例也是类似的:

func (d *PizzaToppingsPlugin) Validate(
    a admission.Attributes,
    o ObjectInterfaces,
) error {
    // we are only interested in pizzas
    if a.GetKind().GroupKind() != restaurant.Kind("Pizza") {
        return nil
    }

    ...
}

完整的示例准入实现如下所示:

// Admit ensures that the object in-flight is of kind Pizza.
// In addition checks that the toppings are known.
func (d *PizzaToppingsPlugin) Validate(
    a admission.Attributes,
    _ admission.ObjectInterfaces,
) error {
    // we are only interested in pizzas
    if a.GetKind().GroupKind() != restaurant.Kind("Pizza") {
        return nil
    }

    if !d.WaitForReady() {
        return admission.NewForbidden(a, fmt.Errorf("not yet ready"))
    }

    obj := a.GetObject()
    pizza := obj.(*restaurant.Pizza)
    for _, top := range pizza.Spec.Toppings {
        err := _, err := d.toppingLister.Get(top.Name)
        if err != nil && errors.IsNotFound(err) {
            return admission.NewForbidden(
                a,
                fmt.Errorf("unknown topping: %s", top.Name),
            )
        }
    }

    return nil
}

它采取以下步骤:

  1. 检查传递的对象是否是正确的类型

  2. 在 informer 就绪之前禁止访问

  3. 通过配料 informer 列表检查每个比萨说明中提到的配料是否实际上作为群集中的 Topping 对象存在

注意这里,列表是 informer 内存存储的接口。因此,这些 Get 调用将非常快。

注册

必须注册准入插件。这通过一个 Register 函数来完成:

func Register(plugins *admission.Plugins) {
    plugins.Register(
        "PizzaTopping",
        func(config io.Reader) (admission.Interface, error) {
            return New()
        },
    )
}

此函数添加到 RecommendedOptions 的插件列表中(见 “Options and Config Pattern and Startup Plumbing”):

func (o *CustomServerOptions) Complete() error {
    // register admission plugins
    pizzatoppings.Register(o.RecommendedOptions.Admission.Plugins)

    // add admisison plugins to the RecommendedPluginOrder
    oldOrder := o.RecommendedOptions.Admission.RecommendedPluginOrder
    o.RecommendedOptions.Admission.RecommendedPluginOrder =
        append(oldOrder, "PizzaToppings")

    return nil
}

在这里,RecommendedPluginOrder 列表预先填充了通用的准入插件,每个 API 服务器都应该保持启用,以便成为集群中良好的 API 约定公民。

最好不要触及顺序。一个原因是正确排序远非易事。当然,如果严格需要插件行为,可以在列表的位置添加自定义插件,而不是在列表末尾。

自定义 API 服务器的用户可以通过常规的准入链配置标志(例如 --disable-admission-plugins)来禁用自定义准入插件。我们默认启用自己的插件,因为我们没有明确禁用它。

可以使用配置文件配置准入插件。为此,我们解析先前展示的 Register 函数中 io.Reader 的输出。--admission-control-config-file 允许我们向插件传递配置文件,如下所示:

kind: AdmissionConfiguration
apiVersion: apiserver.k8s.io/v1alpha1
plugins:
- name: CustomAdmissionPlugin
  path: custom-admission-plugin.yaml

或者,我们可以进行内联配置,将所有准入配置放在一个地方:

kind: AdmissionConfiguration
apiVersion: apiserver.k8s.io/v1alpha1
plugins:
- name: CustomAdmissionPlugin
  configuration:
    *`your-custom-yaml-inline-config`*

我们简要提到我们的 Admission 插件使用配料 informer 来检查披萨中提到的配料是否存在。我们还没有讨论如何将其连接到 Admission 插件中。现在让我们来做这个。

资源的连接

Admission 插件通常需要客户端、informer 或其他资源来实现它们的行为。我们可以使用插件初始化器来进行这些资源的连接。

有许多标准的插件初始化器。如果您的插件希望被它们调用,它必须实现具有回调方法的特定接口(有关详细信息,请参阅 k8s.io/apiserver/pkg/admission/initializer):

// WantsExternalKubeClientSet defines a function that sets external ClientSet
// for admission plugins that need it.
type WantsExternalKubeClientSet interface {
    SetExternalKubeClientSet(kubernetes.Interface)
    admission.InitializationValidator
}

// WantsExternalKubeInformerFactory defines a function that sets InformerFactory
// for admission plugins that need it.
type WantsExternalKubeInformerFactory interface {
    SetExternalKubeInformerFactory(informers.SharedInformerFactory)
    admission.InitializationValidator
}

// WantsAuthorizer defines a function that sets Authorizer for admission
// plugins that need it.
type WantsAuthorizer interface {
    SetAuthorizer(authorizer.Authorizer)
    admission.InitializationValidator
}

// WantsScheme defines a function that accepts runtime.Scheme for admission
// plugins that need it.
type WantsScheme interface {
    SetScheme(*runtime.Scheme)
    admission.InitializationValidator
}

实现其中一些插件,插件在启动期间被调用,以获取访问 Kubernetes 资源或 API 服务器全局架构的权限。

此外,预期要实现 admission.InitializationValidator 接口以进行最终检查,确保插件已正确设置:

// InitializationValidator holds ValidateInitialization functions, which are
// responsible for validation of initialized shared resources and should be
// implemented on admission plugins.
type InitializationValidator interface {
    ValidateInitialization() error
}

标准初始化器很好用,但我们需要访问配料 informer。因此,让我们看看如何添加我们自己的初始化器。一个初始化器由以下组成:

  • Wants* 接口(例如 WantsRestaurantInformerFactory),应由 Admission 插件实现:

    // WantsRestaurantInformerFactory defines a function that sets
    // InformerFactory for admission plugins that need it.
    type WantsRestaurantInformerFactory interface {
        SetRestaurantInformerFactory(informers.SharedInformerFactory)
        admission.InitializationValidator
    }
    
  • 初始化器结构体,实现 admission.PluginInitializer

    func (i restaurantInformerPluginInitializer) Initialize(
        plugin admission.Interface,
    ) {
        if wants, ok := plugin.(WantsRestaurantInformerFactory); ok {
            wants.SetRestaurantInformerFactory(i.informers)
        }
    }
    

    换句话说,Initialize() 方法检查传递的插件是否实现了相应的自定义初始化器 Wants* 接口。如果是这样,初始化器将在插件上调用该方法。

  • 将初始化器构造函数连接到 RecommendedOptions.Extra\AdmissionInitializers(参见“选项和配置模式以及启动配置”)的管道资源:

    func (o *CustomServerOptions) Config() (*apiserver.Config, error) {
        ...
        o.RecommendedOptions.ExtraAdmissionInitializers =
            func(c *genericapiserver.RecommendedConfig) (
                []admission.PluginInitializer, error,
            ) {
                client, err := clientset.NewForConfig(c.LoopbackClientConfig)
                if err != nil {
                    return nil, err
                }
                informerFactory := informers.NewSharedInformerFactory(
                    client, c.LoopbackClientConfig.Timeout,
                )
                o.SharedInformerFactory = informerFactory
                return []admission.PluginInitializer{
                    custominitializer.New(informerFactory),
                }, nil
            }
    
        ...
    }
    

    此代码为餐厅 API 组创建了一个环回客户端,创建了相应的 informer 工厂,将其存储在选项 o 中,并返回了一个用于它的插件初始化器。

如约,Admission 是实现完成餐厅 API 组自定义 API 服务器的最后一步。现在我们希望看到它在实际 Kubernetes 集群中的运行情况,而不是在本地机器上人为地模拟。这意味着我们必须看一下聚合的自定义 API 服务器的部署。

部署自定义 API 服务器

在“API 服务”中,我们看到了 APIService 对象,用于将自定义 API 服务器的 API 组版本注册到 Kubernetes API 服务器内部的聚合器中:

apiVersion: apiregistration.k8s.io/v1beta1
kind: APIService
metadata:
  name: *`name`*
spec:
  group: *`API-group-name`*
  version: *`API-group-version`*
  service:
    namespace: *`custom-API-server-service-namespace`*
    name: *`custom-API-server-service`*
  caBundle: *`base64-caBundle`*
  insecureSkipTLSVerify: *`bool`*
  groupPriorityMinimum: 2000
  versionPriority: 20

APIService 对象指向一个服务。通常,这个服务将是一个普通的集群 IP 服务:也就是说,自定义 API 服务器部署在集群中,使用 pods。该服务将请求转发到 pods。

让我们看看 Kubernetes 清单以实现这一点。

部署清单

我们有以下清单(在GitHub 上的示例代码找到),它们将成为自定义 API 服务的集群内部部署的一部分:

  • 一个 APIService 适用于版本 v1alpha1

    apiVersion: apiregistration.k8s.io/v1beta1
    kind: APIService
    metadata:
      name: v1alpha1.restaurant.programming-kubernetes.info
    spec:
      insecureSkipTLSVerify: true
      group: restaurant.programming-kubernetes.info
      groupPriorityMinimum: 1000
      versionPriority: 15
      service:
        name: api
        namespace: pizza-apiserver
      version: v1alpha1
    

    …和 v1beta1

    apiVersion: apiregistration.k8s.io/v1beta1
    kind: APIService
    metadata:
      name: v1alpha1.restaurant.programming-kubernetes.info
    spec:
      insecureSkipTLSVerify: true
      group: restaurant.programming-kubernetes.info
      groupPriorityMinimum: 1000
      versionPriority: 15
      service:
        name: api
        namespace: pizza-apiserver
      version: v1alpha1
    

    注意这里我们设置了 insecureSkipTLSVerify。这对开发来说是可以接受的,但对于任何生产部署来说都不够。我们将看到如何在 “证书和信任” 中修复这个问题。

  • 一个在集群中运行的自定义 API 服务器实例前面的 Service

    apiVersion: v1
    kind: Service
    metadata:
      name: api
      namespace: pizza-apiserver
    spec:
      ports:
      - port: 443
        protocol: TCP
        targetPort: 8443
      selector:
        apiserver: "true"
    
  • 一个 Deployment(如此所示)或者 DaemonSet 用于自定义 API 服务器的 pod:

    apiVersion: apps/v1
    kind: Deployment
    metadata:
      name: pizza-apiserver
      namespace: pizza-apiserver
      labels:
        apiserver: "true"
    spec:
      replicas: 1
      selector:
        matchLabels:
          apiserver: "true"
      template:
        metadata:
          labels:
            apiserver: "true"
        spec:
          serviceAccountName: apiserver
          containers:
          - name: apiserver
            image: quay.io/programming-kubernetes/pizza-apiserver:latest
            imagePullPolicy: Always
            command: ["/pizza-apiserver"]
            args:
            - --etcd-servers=http://localhost:2379
            - --cert-dir=/tmp/certs
            - --secure-port=8443
            - --v=4
          - name: etcd
            image: quay.io/coreos/etcd:v3.2.24
            workingDir: /tmp
    
  • 用于服务和部署的命名空间:

    apiVersion: v1
    kind: Namespace
    metadata:
      name: pizza-apiserver
    spec: {}
    

通常情况下,聚合 API 服务器部署在一些专门用于控制平面 pod 的节点上,通常称为 masters。在这种情况下,使用 DaemonSet 在每个主节点上运行一个自定义 API 服务器实例是个不错的选择。这样可以实现高可用设置。请注意,API 服务器是无状态的,这意味着它们可以轻松地部署多次,而不需要领导选举。

有了这些清单,我们几乎完成了。但通常情况下,安全部署还需要更多的思考。您可能已经注意到,通过前面部署定义的 pod 使用了一个自定义服务账号 apiserver。这可以通过另一个清单创建:

kind: ServiceAccount
apiVersion: v1
metadata:
  name: apiserver
  namespace: pizza-apiserver

此服务账号需要一些权限,我们可以通过 RBAC 对象添加。

设置 RBAC

API 服务的服务账号首先需要一些通用权限来参与:

命名空间的生命周期

只能在现有的命名空间中创建对象,并且在删除命名空间时会删除这些对象。为此,API 服务器必须获取、列出和监视命名空间。

准入 webhook

通过 MutatingWebhookConfigurationsValidatedWebhookConfigurations 配置的准入 webhook 从每个 API 服务器独立调用。为此,我们的自定义 API 服务器的准入机制必须获取、列出和监视这些资源。

我们通过创建 RBAC 集群角色来配置两者:

kind: ClusterRole
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  name: aggregated-apiserver-clusterrole
rules:
- apiGroups: [""]
  resources: ["namespaces"]
  verbs: ["get", "watch", "list"]
- apiGroups: ["admissionregistration.k8s.io"]
  resources: ["mutatingwebhookconfigurations", "validatingwebhookconfigurations"]
  verbs: ["get", "watch", "list"]

并通过 ClusterRoleBinding 将其绑定到我们的服务账号 apiserver

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: pizza-apiserver-clusterrolebinding
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: aggregated-apiserver-clusterrole
subjects:
- kind: ServiceAccount
  name: apiserver
  namespace: pizza-apiserver

对于委托的身份验证和授权,服务账号必须绑定到预先存在的 RBAC 角色 extension-apiserver-authentication-reader

apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: pizza-apiserver-auth-reader
  namespace: kube-system
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: Role
  name: extension-apiserver-authentication-reader
subjects:
- kind: ServiceAccount
  name: apiserver
  namespace: pizza-apiserver

还有预先存在的 RBAC 集群角色 system:auth-delegator

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: pizza-apiserver:system:auth-delegator
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: system:auth-delegator
subjects:
- kind: ServiceAccount
  name: apiserver
  namespace: pizza-apiserver

不安全地运行自定义 API 服务器

现在所有清单都已就绪并且 RBAC 已设置好,让我们将 API 服务器部署到真实的集群中。

GitHub 仓库的检出,并使用具有 cluster-admin 权限配置的 kubectl(因为 RBAC 规则永远不会升级访问权限):

$ cd $GOPATH/src/github.com/programming-kubernetes/pizza-apiserver
$ cd artifacts/deployment
$ kubectl apply -f ns.yaml # create the namespace first
$ kubectl apply -f .       # creating all manifests described above

现在自定义 API 服务器正在启动:

$ kubectl get pods -A
NAMESPACE       NAME                            READY STATUS            AGE
pizza-apiserver pizza-apiserver-7779f8d486-8fpgj 0/2  ContainerCreating 1s
$ # some moments later
$ kubectl get pods -A
pizza-apiserver pizza-apiserver-7779f8d486-8fpgj 2/2  Running           75s

当它运行时,我们会再次检查 Kubernetes API 服务器是否正在聚合(即请求代理)。首先通过 APIService 检查 Kubernetes API 服务器是否认为我们的自定义 API 服务器可用:

$ kubectl get apiservices v1alpha1.restaurant.programming-kubernetes.info
NAME                                            SERVICE             AVAILABLE
v1alpha1.restaurant.programming-kubernetes.info pizza-apiserver/api True

看起来不错。让我们尝试列出披萨,启用日志记录以查看是否有什么问题:

$ kubectl get pizzas --v=7
...
... GET https://localhost:58727/apis?timeout=32s
...
... GET https://localhost:58727/apis/restaurant.programming-kubernetes.info/
                                v1alpha1?timeout=32s
...
... GET https://localhost:58727/apis/restaurant.programming-kubernetes.info/
                                v1beta1/namespaces/default/pizzas?limit=500
... Request Headers:
...  Accept: application/json;as=Table;v=v1beta1;g=meta.k8s.io, application/json
...  User-Agent: kubectl/v1.15.0 (darwin/amd64) kubernetes/f873d2a
... Response Status: 200 OK in 6 milliseconds
No resources found.

这看起来非常不错。我们看到kubectl查询发现信息以找出披萨是什么。它查询restaurant.programming-kubernetes.info/v1beta1 API 来列出披萨。毫不奇怪,目前还没有。但我们当然可以改变这一点:

$ cd ../examples
$ # install toppings first
$ ls topping* | xargs -n 1 kubectl create -f
$ kubectl create -f pizza-margherita.yaml
pizza.restaurant.programming-kubernetes.info/margherita created
$ kubectl get pizza -o yaml margherita
apiVersion: restaurant.programming-kubernetes.info/v1beta1
kind: Pizza
metadata:
  creationTimestamp: "2019-05-05T13:39:52Z"
  name: margherita
  namespace: default
  resourceVersion: "6"
  pizzas/margherita
  uid: 42ab6e88-6f3b-11e9-8270-0e37170891d3
spec:
  toppings:
  - name: mozzarella
    quantity: 1
  - name: tomato
    quantity: 1
status: {}

这看起来很棒。但玛格丽特披萨很简单。让我们通过创建一个不列出任何配料的空披萨来尝试默认值的效果:

apiVersion: restaurant.programming-kubernetes.info/v1alpha1
kind: Pizza
metadata:
  name: salami
spec:

我们的默认值应将其转换为一款意大利辣香肠披萨。让我们试试:

$ kubectl create -f empty-pizza.yaml
pizza.restaurant.programming-kubernetes.info/salami created
$ kubectl get pizza -o yaml salami
apiVersion: restaurant.programming-kubernetes.info/v1beta1
kind: Pizza
metadata:
  creationTimestamp: "2019-05-05T13:42:42Z"
  name: salami
  namespace: default
  resourceVersion: "8"
  pizzas/salami
  uid: a7cb7af2-6f3b-11e9-8270-0e37170891d3
spec:
  toppings:
  - name: salami
    quantity: 1
  - name: mozzarella
    quantity: 1
  - name: tomato
    quantity: 1
status: {}

这看起来像是一款美味的意大利辣香肠披萨。

现在让我们检查我们的自定义准入插件是否正常工作。我们首先删除所有披萨和配料,然后尝试重新创建披萨:

$ kubectl delete pizzas --all
pizza.restaurant.programming-kubernetes.info "margherita" deleted
pizza.restaurant.programming-kubernetes.info "salami" deleted
$ kubectl delete toppings --all
topping.restaurant.programming-kubernetes.info "mozzarella" deleted
topping.restaurant.programming-kubernetes.info "salami" deleted
topping.restaurant.programming-kubernetes.info "tomato" deleted
$ kubectl create -f pizza-margherita.yaml
Error from server (Forbidden): error when creating "pizza-margherita.yaml":
 pizzas.restaurant.programming-kubernetes.info "margherita" is forbidden:
   unknown topping: mozzarella

没有马格丽塔披萨,没有马苏里拉奶酪,就像在任何一家好的意大利餐厅里一样。

看起来我们已经完成了在“示例:一家披萨餐厅”中描述的实现。但还不完全。安全性。再次。我们还没有处理适当的证书。一个恶意的披萨销售商可能会试图在我们的用户和自定义 API 服务器之间插入。因为 Kubernetes API 服务器只接受任何服务证书而不进行检查。让我们解决这个问题。

证书和信任

APIService对象包含caBundle字段。这配置了聚合器(在 Kubernetes API 服务器内部)信任自定义 API 服务器的方式。这个 CA 捆绑包含用于验证聚合 API 服务器是否具有其所声明的身份的证书(和中间证书)。对于任何严肃的部署,请将相应的 CA 捆绑包放入此字段中。

警告

虽然在APIService中允许insecureSkipTLSVerify以禁用证书验证,但在生产设置中使用这个方法是个坏主意。Kubernetes API 服务器将请求发送到受信任的聚合 API 服务器。将insecureSkipTLSVerify设置为true意味着任何其他参与者都可以声称自己是聚合 API 服务器。这显然是不安全的,不应在生产环境中使用。

自定 API 服务器到 Kubernetes API 服务器的逆信任及其请求的预身份验证在“委托认证和信任”中有描述。我们不必做任何额外的事情。

回到披萨的例子:要使其安全,我们需要一个自定义 API 服务器部署中的服务证书和密钥。我们将两者放入serving-cert秘密,并将其挂载到/var/run/apiserver/serving-cert/tls.{crt,key}的容器中。然后我们使用tls.crt文件作为APIService中的 CA。所有这些都可以在GitHub 上的示例代码中找到。

证书生成逻辑在Makefile中编写。

请注意,在实际场景中,我们可能会有某种类型的集群或公司 CA 可以插入APIService中。

要查看其运行情况,可以从新的集群开始,或者只需重用以前的集群并应用新的安全清单:

$ cd ../deployment-secure
$ make
openssl req -new -x509 -subj "/CN=api.pizza-apiserver.svc"
  -nodes -newkey rsa:4096
  -keyout tls.key -out tls.crt -days 365
Generating a 4096 bit RSA private key
......................++
................................................................++
writing new private key to 'tls.key'
...
$ ls *.yaml | xargs -n 1 kubectl apply -f
clusterrolebinding.rbac.authorization.k8s.io/pizza-apiserver:system:auth-delegator unchanged
rolebinding.rbac.authorization.k8s.io/pizza-apiserver-auth-reader unchanged
deployment.apps/pizza-apiserver configured
namespace/pizza-apiserver unchanged
clusterrolebinding.rbac.authorization.k8s.io/pizza-apiserver-clusterrolebinding unchanged
clusterrole.rbac.authorization.k8s.io/aggregated-apiserver-clusterrole unchanged
serviceaccount/apiserver unchanged
service/api unchanged
secret/serving-cert created
apiservice.apiregistration.k8s.io/v1alpha1.restaurant.programming-kubernetes.info configured
apiservice.apiregistration.k8s.io/v1beta1.restaurant.programming-kubernetes.info configured

请注意证书中正确的通用名称 CN=api.pizza-apiserver.svc。Kubernetes API 服务器将请求代理到 api/pizza-apiserver 服务,因此其 DNS 名称必须放入证书中。

我们再次检查,确实已禁用了 APIService 中的 insecureSkipTLSVerify 标志:

$ kubectl get apiservices v1alpha1.restaurant.programming-kubernetes.info -o yaml
apiVersion: apiregistration.k8s.io/v1
kind: APIService
metadata:
  name: v1alpha1.restaurant.programming-kubernetes.info
  ...
spec:
  caBundle: LS0tLS1C...
  group: restaurant.programming-kubernetes.info
  groupPriorityMinimum: 1000
  service:
    name: api
    namespace: pizza-apiserver
  version: v1alpha1
  versionPriority: 15
status:
  conditions:
  - lastTransitionTime: "2019-05-05T14:07:07Z"
    message: all checks passed
    reason: Passed
    status: "True"
    type: Available
artifacts/deploymen

看起来如预期:insecureSkipTLSVerify 已经消失,并且 caBundle 字段填写了我们证书的 base64 值。而且:服务仍然可用。

现在让我们看看 kubectl 是否仍然可以查询 API:

$ kubectl get pizzas
No resources found.
$ cd ../examples
$ ls topping* | xargs -n 1 kubectl create -f
topping.restaurant.programming-kubernetes.info/mozzarella created
topping.restaurant.programming-kubernetes.info/salami created
topping.restaurant.programming-kubernetes.info/tomato created
$ kubectl create -f pizza-margherita.yaml
pizza.restaurant.programming-kubernetes.info/margherita created

margherita 披萨回来了。这次它完全安全了。恶意披萨销售者无法发动中间人攻击。享受您的美食!

共享 etcd

使用 RecommendOptions(参见 “Options and Config Pattern and Startup Plumbing”)的聚合 API 服务器使用 etcd 进行存储。这意味着任何自定义 API 服务器的部署都需要可用的 etcd 集群。

该集群可以是集群内部的——例如,使用 etcd operator 部署。此操作者允许我们以声明性方式启动和管理 etcd 集群。操作者将进行更新、扩展和备份操作。这大大减少了运维开销。

或者,可以使用集群控制平面的 etcd(即 kube-apiserveretcd)。根据环境——自部署、本地部署或像 Google 容器引擎(GKE)这样的托管服务——这可能是可行的,或者可能完全不可能,因为用户根本无法访问集群(如 GKE 的情况)。在可行的情况下,自定义 API 服务器必须使用与 Kubernetes API 服务器或其他 etcd 消费者不同的键路径。在我们的示例自定义 API 服务器中,看起来是这样的:

const defaultEtcdPathPrefix =
    "/registry/pizza-apiserver.programming-kubernetes.github.com"

func NewCustomServerOptions() *CustomServerOptions {
    o := &CustomServerOptions{
        RecommendedOptions: genericoptions.NewRecommendedOptions(
            defaultEtcdPathPrefix,
            ...
        ),
    }

    return o
}

etcd 路径前缀与使用不同组 API 名称的 Kubernetes API 服务器路径不同。

最后但并非最不重要,etcd 可以进行代理。项目 etcdproxy-controller 利用操作者模式实现了这一机制;也就是说,etcd 代理可以自动部署到集群,并通过 EtcdProxy 对象进行配置。

etcd 代理将自动执行键映射,因此保证 etcd 键前缀不会冲突。这使我们能够为多个聚合 API 服务器共享 etcd 集群,而无需担心一个聚合 API 服务器读取或更改另一个的数据。在需要共享 etcd 集群的环境中(例如由于资源限制或为了避免运维开销),这将提高安全性。

根据上下文,必须选择其中之一的选项。最后,聚合 API 服务器当然也可以使用其他存储后端,至少在理论上是可以的,因为需要大量自定义代码来实现 k8s.io/apiserver 存储接口。

摘要

这是一章相当大的内容,你已经看到了结尾。你对 Kubernetes 中的 API 有了很多背景知识,以及它们是如何实现的。

我们看到自定义 API 服务器如何适配到 Kubernetes 集群的架构中。我们看到自定义 API 服务器如何接收来自 Kubernetes API 服务器的代理请求。我们看到 Kubernetes API 服务器如何预认证这些请求,以及如何实现 API 组,包括外部版本和内部版本。我们学习了如何将对象解码为 Golang 结构体,如何设置默认值,如何转换为内部类型,以及如何经历准入和验证,最终到达注册表。我们看到了如何将策略插入到通用注册表中来实现类似 Kubernetes 的“正常” REST 资源,以及如何添加自定义准入,并如何使用自定义初始化器配置自定义准入插件。现在我们知道如何进行所有的管道配置来启动一个带有多版本 API 组的自定义 API 服务器,以及如何使用 APIServices 在集群中部署 API 组。我们看到了如何配置 RBAC 规则以允许自定义 API 服务器执行其工作。我们讨论了 kubectl 如何查询 API 组。最后,我们学习了如何使用证书来保护与自定义 API 服务器的连接。

这是很多内容。现在你对 Kubernetes 中的 API 以及它们是如何实现的有了更好的理解,希望你能够有动力去做以下一项或多项:

  • 实现您自己的自定义 API 服务器

  • 了解 Kubernetes 的内部工作原理

  • 未来为 Kubernetes 做贡献

我们希望你觉得这是一个很好的起点。

^(1) 优雅删除意味着客户端可以在删除调用中传递一个优雅删除期间。实际的删除由控制器异步完成(例如 kubelet 用于 Pod),通过强制删除。这样可以让 Pod 有时间干净地关闭。

^(2) Kubernetes 使用共存来迁移资源(例如,从 extensions/v1beta1 API 组迁移到特定主题的 API 组(例如 apps/v1)。CRD 没有共享存储的概念。

^(3) 我们将在 第九章 中看到,在最新的 Kubernetes 版本中可用的 CRD 转换和准入 Webhook 也允许我们向 CRD 添加这些功能。

^(4) PaaS 指的是平台即服务。

第九章:高级自定义资源

在本章中,我们将介绍有关 CR(Custom Resources)的高级主题:版本控制、转换和准入控制器。

有了多个版本,CRD(Custom Resource Definitions)变得更加严肃,并且在开发、维护以及操作上复杂度显著增加。我们将这些功能称为“高级”,因为它们将 CRD 从纯粹声明式的清单转移到 Golang 的世界(即一个真正的软件开发项目)。

即使您不计划构建自定义 API 服务器,而是打算直接转向 CRD,我们强烈建议不要跳过第八章。高级 CRD 的许多概念都直接源于自定义 API 服务器的世界,并且受其启发。阅读第八章将大大有助于理解本章内容。

所有这里展示和讨论的示例代码都可以通过GitHub 仓库获取。

自定义资源版本控制

在第八章中,我们看到资源如何通过不同的 API 版本提供。例如,自定义 API 服务器的示例中,pizza 资源同时存在于版本 v1alpha1v1beta1 中(参见“示例:比萨饼餐厅”)。在自定义 API 服务器内部,请求中的每个对象首先从 API 终端版本转换为内部版本(参见“内部类型和转换”和图 8-5),然后再转换回外部版本以进行存储和返回响应。转换机制由转换函数实现,其中一些是手动编写的,一些是自动生成的(参见“转换”)。

API 版本控制是一种强大的机制,可以在保持与旧客户端兼容性的同时调整和改进 API。在 Kubernetes 中,版本控制无处不在,将 alpha API 推广到 beta,最终到普遍可用(GA)起着核心作用。在此过程中,API 经常会更改结构或进行扩展。

长期以来,版本控制是仅通过聚合 API 服务器作为第八章中所示的功能。任何严肃的 API 最终都需要版本控制,因为与 API 的使用者断兼容性是不可接受的。

幸运的是,最近在 Kubernetes 中已经添加了 CRD 的版本控制——在 Kubernetes 1.14 中作为 alpha 版本,而在 1.15 中提升为 beta 版本。请注意,转换需要 OpenAPI v3 验证模式,这些模式是 结构化的(参见 “验证自定义资源”)。结构化模式基本上就是像 Kubebuilder 等工具产生的内容。我们将在 “结构化模式” 中讨论技术细节。

我们将展示版本控制在这里的运作方式,因为在不久的将来,它将在 CR 的许多重要应用中发挥核心作用。

重新审视比萨餐厅

为了了解 CR 转换的工作原理,我们将重新实现比萨餐厅示例(见 第八章),这次完全使用 CRD——即不涉及聚合 API 服务器。

对于转换,我们将专注于 Pizza 资源:

apiVersion: restaurant.programming-kubernetes.info/v1alpha1
kind: Pizza
metadata:
  name: margherita
spec:
  toppings:
  - mozzarella
  - tomato

此对象在 v1beta1 版本中应该有不同的表示形式来表示配料片:

apiVersion: restaurant.programming-kubernetes.info/v1beta1
kind: Pizza
metadata:
  name: margherita
spec:
  toppings:
  - name: mozzarella
    quantity: 1
  - name: tomato
    quantity: 1

v1alpha1 中,重复的配料用于表示额外的奶酪比萨,而在 v1beta1 中,我们通过为每个配料使用数量字段来实现。配料的顺序并不重要。

我们想要实现这个转换——从 v1alpha1 转换到 v1beta1 并反向。不过,在此之前,让我们将 API 定义为 CRD。请注意,我们不能在同一集群中同时拥有聚合的 API 服务器和相同 GroupVersion 的 CRD。因此,在继续进行这些 CRD 之前,请确保从 第八章 中删除 APIServices。

apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition
metadata:
  name: pizzas.restaurant.programming-kubernetes.info
spec:
  group: restaurant.programming-kubernetes.info
  names:
    kind: Pizza
    listKind: PizzaList
    plural: pizzas
    singular: pizza
  scope: Namespaced
  version: v1alpha1
  versions:
  - name: v1alpha1
    served: true
    storage: true
    schema: ...
  - name: v1beta1
    served: true
    storage: false
    schema: ...

CRD 定义了两个版本:v1alpha1v1beta1。我们将前者设置为存储版本(参见 图 9-1),这意味着要存储在 etcd 中的每个对象都首先转换为 v1alpha1

转换和存储版本

图 9-1. 转换和存储版本

由于当前定义的 CRD,我们可以创建一个 v1alpha1 对象,并作为 v1beta1 检索它,但两个 API 端点返回相同的对象。显然,这不是我们想要的。但很快我们将改进这一点。

但在此之前,我们将在集群中设置 CRD 并创建一个玛格丽特比萨:

apiVersion: restaurant.programming-kubernetes.info/v1alpha1
kind: Pizza
metadata:
  name: margherita
spec:
  toppings:
  - mozzarella
  - tomato

我们先注册前面的 CRD,然后创建玛格丽特对象:

$ kubectl create -f pizza-crd.yaml
$ kubectl create -f margherita-pizza.yaml

如预期的那样,我们得到了相同的对象来表示两个版本:

$ kubectl get pizza margherita -o yaml
apiVersion: restaurant.programming-kubernetes.info/v1beta1
kind: Pizza
metadata:
  creationTimestamp: "2019-04-14T11:39:20Z"
  generation: 1
  name: margherita
  namespace: pizza-apiserver
  resourceVersion: "47959"
  selfLink: /apis/restaurant.programming-kubernetes.info/v1beta1/namespaces/pizza-apiserver/
  pizzas/margherita
  uid: f18427f0-5ea9-11e9-8219-124e4d2dc074
spec:
  toppings:
  - mozzarella
  - tomato

Kubernetes 使用规范的版本排序方式,即:

v1alpha1

不稳定:可能随时消失或更改,并且通常默认禁用。

v1beta1

朝向稳定:至少在一个版本中与 v1 并行存在;契约:没有不兼容的 API 更改。

v1

稳定或一般可用(GA):将长期保留,并且兼容。

GA 版本排在前面,然后是 beta 版本,再然后是 alpha 版本,主版本高到低排序,次版本也是如此。不符合此模式的每个 CRD 版本都排在最后,按字母顺序排列。

在我们的案例中,前面的 kubectl get pizza 返回 v1beta1,尽管创建的对象是版本 v1alpha1

CR 的转换 Webhook 架构

现在让我们添加从 v1alpha1v1beta1 的转换并返回。Kubernetes 中通过 webhook 实现 CRD 转换。图 9-2 显示了流程:

  1. 客户端(例如我们的 kubectl get pizza margherita)请求一个版本。

  2. etcd 已经将对象存储在某个版本中。

  3. 如果版本不匹配,存储对象将被发送到 webhook 服务器进行转换。Webhook 返回一个包含转换后对象的响应。

  4. 转换后的对象被发送回客户端。

CR 的转换 Webhook

图 9-2. 转换 Webhook

我们必须实现这个 webhook 服务器。在此之前,让我们查看一下 webhook API。Kubernetes API 服务器在 API 组 apiextensions.k8s.io/v1beta1 中发送一个 ConversionReview 对象:

type ConversionReview struct {
    metav1.TypeMeta `json:",inline"`
    Request *ConversionRequest
    Response *ConversionResponse
}

请求字段设置在发送到 webhook 的有效负载中。响应字段设置在响应中。

请求如下所示:

type ConversionRequest struct {
    ...

    // `desiredAPIVersion` is the version to convert given objects to.
    // For example, "myapi.example.com/v1."
    DesiredAPIVersion string

    // `objects` is the list of CR objects to be converted.
    Objects []runtime.RawExtension
}

DesiredAPIVersion 字符串具有我们从 TypeMeta 知道的通常 apiVersion 格式:group/version

对象字段有多个对象。它是一个切片,因为对于比萨的一个列表请求,Webhook 将接收一个转换请求,此切片是列表请求的所有对象。

Webhook 进行转换并设置响应:

type ConversionResponse struct {
    ...

    // `convertedObjects` is the list of converted versions of `request.objects`
    // if the `result` is successful otherwise empty. The webhook is expected to
    // set apiVersion of these objects to the ConversionRequest.desiredAPIVersion.
    // The list must also have the same size as input list with the same objects
    // in the same order (i.e. equal UIDs and object meta).
    ConvertedObjects []runtime.RawExtension

    // `result` contains the result of conversion with extra details if the
    // conversion failed. `result.status` determines if the conversion failed
    // or succeeded. The `result.status` field is required and represents the
    // success or failure of the conversion. A successful conversion must set
    // `result.status` to `Success`. A failed conversion must set `result.status`
    // to `Failure` and provide more details in `result.message` and return http
    // status 200\. The `result.message` will be used to construct an error
    // message for the end user.
    Result metav1.Status
}

结果状态告诉 Kubernetes API 服务器转换是否成功。

但是在请求管道中我们的转换 webhook 实际上是在什么时候被调用的?我们可以期待什么样的输入对象?为了更好地理解这一点,请看 图 9-3 中的一般请求管道:所有这些实心和条纹圆圈都是在 k8s.io/apiserver 代码中进行转换的地方。

CR 的转换 Webhook 调用

图 9-3. CR 的转换 Webhook 调用

与聚合自定义 API 服务器相比(参见 “内部类型和转换”),CR 不使用内部类型,而是直接在外部 API 版本之间进行转换。因此,在 图 9-4 中,只有那些黄色圆圈实际上在进行转换;实心圆圈对于 CRD 来说是 NOOP。换句话说:CRD 转换仅在 etcd 之间进行。

CR 的转换发生的位置

图 9-4. CR 的转换发生的位置

因此,我们可以假设我们的 webhook 将从请求管道中的这两个位置被调用(参见 图 9-3)。

还要注意,对冲突的补丁请求会自动重试(更新无法重试,并直接向调用者返回错误)。每次重试都涉及对 etcd 进行读取和写入(图 9-3 中的黄色圆圈),因此每次迭代会导致 Webhook 的两次调用。

警告

所有关于转换“关键性”的警告也适用于此处:转换必须正确。错误会迅速导致数据丢失和 API 不一致的行为。

在开始实现 Webhook 之前,关于 Webhook 能做什么和必须避免的最后几句话:

  • 请求和响应中对象的顺序不能改变。

  • ObjectMeta 除了标签和注释之外,不能被改变。

  • 转换要么全部成功,要么全部失败,不能部分成功。

转换 Webhook 实现

理论已经讲述完毕,我们准备开始实现 Webhook 项目。您可以在 仓库 找到源代码,其中包括:

  • 一个作为 HTTPS Web 服务器的 Webhook 实现

  • 多个端点:

    • /convert/v1beta1/pizzav1alpha1v1beta1 之间转换 pizza 对象。

    • /admit/v1beta1/pizzaspec.toppings 字段默认为 mozzarella, tomato, salami。

    • /validate/v1beta1/pizza 验证每个指定的配料是否有对应的 toppings 对象。

最后两个端点是准入 Webhook,在 “准入 Webhook” 中将详细讨论。同一个 Webhook 二进制文件将同时服务于准入和转换。

这些路径中的 v1beta1 不应与我们餐厅 API 组的 v1beta1 混淆,而是作为我们支持的 apiextensions.k8s.io API 组版本的 Webhook。有一天,该 Webhook API 的 v1 版本将被支持,^(1) 届时我们将添加对应的 v1 作为另一个端点,以支持旧(即今天的)和新的 Kubernetes 集群。可以在 CRD 清单中指定 Webhook 支持的版本。

让我们看看这个转换 Webhook 是如何工作的。之后,我们将深入探讨如何将 Webhook 部署到真实的集群中。再次注意,Webhook 转换在 1.14 版本中仍处于 alpha 阶段,必须手动启用 CustomResourceWebhookConversion 功能开关,但在 1.15 版本中作为 beta 版本可用。

设置 HTTPS 服务器

第一步是启动支持传输层安全性(TLS)的 Web 服务器(即 HTTPS)。Kubernetes 中的 Webhook 需要 HTTPS。甚至转换 Webhook 需要证书,这些证书由 Kubernetes API 服务器针对 CRD 对象中提供的 CA bundle 进行成功检查。

在示例项目中,我们使用了k8s.io/apiserver中的安全服务库。它提供了您在部署kube-apiserver或聚合 API 服务器二进制文件时可能习惯的所有 TLS 标志和行为。

k8s.io/apiserver安全服务代码遵循options-config模式(参见“选项和配置模式及启动管道”)。将该代码轻松嵌入到您自己的二进制文件中非常简单:

func NewDefaultOptions() *Options {
    o := &Options{
        *options.NewSecureServingOptions(),
    }
    o.SecureServing.ServerCert.PairName = "pizza-crd-webhook"
    return o
}

type Options struct {
    SecureServing options.SecureServingOptions
}

type Config struct {
    SecureServing *server.SecureServingInfo
}

func (o *Options) AddFlags(fs *pflag.FlagSet) {
    o.SecureServing.AddFlags(fs)
}

func (o *Options) Config() (*Config, error) {
    err := o.SecureServing.MaybeDefaultWithSelfSignedCerts("0.0.0.0", nil, nil)
    if err != nil {
        return nil, err
    }

    c := &Config{}

    if err := o.SecureServing.ApplyTo(&c.SecureServing); err != nil {
        return nil, err
    }

    return c, nil
}

在二进制的主函数中,这个Options结构体被实例化并连接到一个标志集:

opt := NewDefaultOptions()
fs := pflag.NewFlagSet("pizza-crd-webhook", pflag.ExitOnError)
globalflag.AddGlobalFlags(fs, "pizza-crd-webhook")
opt.AddFlags(fs)
if err := fs.Parse(os.Args); err != nil {
    panic(err)
}

// create runtime config
cfg, err := opt.Config()
if err != nil {
    panic(err)
}

stopCh := server.SetupSignalHandler()

...

// run server
restaurantInformers.Start(stopCh)
if doneCh, err := cfg.SecureServing.Serve(
    handlers.LoggingHandler(os.Stdout, mux),
    time.Second * 30, stopCh,
); err != nil {
    panic(err)
} else {
    <-doneCh
}

在这三个路径的位置,我们使用以下方法设置 HTTP 多路复用器:

// register handlers
restaurantInformers := restaurantinformers.NewSharedInformerFactory(
    clientset, time.Minute * 5,
)
mux := http.NewServeMux()
mux.Handle("/convert/v1beta1/pizza", http.HandlerFunc(conversion.Serve))
mux.Handle("/admit/v1beta1/pizza", http.HandlerFunc(admission.ServePizzaAdmit))
mux.Handle("/validate/v1beta1/pizza",
    http.HandlerFunc(admission.ServePizzaValidation(restaurantInformers)))
restaurantInformers.Start(stopCh)

由于位于/validate/v1beta1/pizza路径的 pizza 验证 webhook 必须知道集群中现有的 topping 对象,我们为restaurant.programming-kubernetes.info API 组实例化了一个共享 informer 工厂。

现在我们来看看实际的转换 webhook 实现在conversion.Serve背后的实现。这是一个普通的 Golang HTTP 处理函数,意味着它会获取请求和响应写入器作为参数。

请求体包含来自 API 组apiextensions.k8s.io/v1beta1ConversionReview对象。因此,我们必须首先从请求中读取主体,然后解码字节切片。我们通过使用 API Machinery 的反序列化器来实现这一点:

func Serve(w http.ResponseWriter, req *http.Request) {
    // read body
    body, err := ioutil.ReadAll(req.Body)
    if err != nil {
        responsewriters.InternalError(w, req,
          fmt.Errorf("failed to read body: %v", err))
        return
    }

    // decode body as conversion review
    gv := apiextensionsv1beta1.SchemeGroupVersion
    reviewGVK := gv.WithKind("ConversionReview")
    obj, gvk, err := codecs.UniversalDeserializer().Decode(body, &reviewGVK,
        &apiextensionsv1beta1.ConversionReview{})
    if err != nil {
        responsewriters.InternalError(w, req,
          fmt.Errorf("failed to decode body: %v", err))
        return
    }
    review, ok := obj.(*apiextensionsv1beta1.ConversionReview)
    if !ok {
        responsewriters.InternalError(w, req,
          fmt.Errorf("unexpected GroupVersionKind: %s", gvk))
        return
    }
    if review.Request == nil {
        responsewriters.InternalError(w, req,
          fmt.Errorf("unexpected nil request"))
        return
    }

    ...
}

这段代码使用了来自一个方案的编解码工厂codecs。这个方案必须包括apiextensions.k8s.io/v1beta1的类型。我们还添加了我们的餐厅 API 组的类型。传递的ConversionReview对象将会在一个runtime.RawExtension类型中嵌入我们的 pizza 类型,稍后会详细说明这一点。

首先,让我们创建我们的方案和编解码工厂:

import (
    apiextensionsv1beta1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1"
    "github.com/programming-kubernetes/pizza-crd/pkg/apis/restaurant/install"
    ...
)

var (
    scheme = runtime.NewScheme()
    codecs = serializer.NewCodecFactory(scheme)
)

func init() {
    utilruntime.Must(apiextensionsv1beta1.AddToScheme(scheme))
    install.Install(scheme)
}

runtime.RawExtension是包含在另一个对象字段中的类似 Kubernetes 的对象的包装器。其结构实际上非常简单:

type RawExtension struct {
    // Raw is the underlying serialization of this object.
    Raw []byte `protobuf:"bytes,1,opt,name=raw"`
    // Object can hold a representation of this extension - useful for working
    // with versioned structs.
    Object Object `json:"-"`
}

另外,runtime.RawExtension具有两种特殊的 JSON 和 protobuf 编组方法。此外,还存在围绕动态转换为runtime.Object的特殊逻辑,即在转换为内部类型时的自动编码和解码。

在 CRD 的情况下,我们没有内部类型,因此转换的魔法不起作用。只有RawExtension.Raw填充了发送到 webhook 进行转换的 pizza 对象的 JSON 字节切片。因此,我们将需要解码这个字节切片。再次注意,一个ConversionReview可能携带多个对象,因此我们必须循环遍历所有这些对象:

// convert objects
review.Response = &apiextensionsv1beta1.ConversionResponse{
    UID: review.Request.UID,
    Result:  metav1.Status{
        Status: metav1.StatusSuccess,
    },
}
var objs []runtime.Object
for _, in := range review.Request.Objects {
    if in.Object == nil {
        var err error
        in.Object, _, err = codecs.UniversalDeserializer().Decode(
            in.Raw, nil, nil,
        )
        if err != nil {
            review.Response.Result = metav1.Status{
                Message: err.Error(),
                Status:  metav1.StatusFailure,
            }
            break
        }
    }

    obj, err := convert(in.Object, review.Request.DesiredAPIVersion)
    if err != nil {
        review.Response.Result = metav1.Status{
            Message: err.Error(),
            Status:  metav1.StatusFailure,
        }
        break
    }
    objs = append(objs, obj)
}

convert调用实际执行了in.Object的转换,目标版本是所需的 API 版本。请注意,当第一个错误发生时,我们会立即中断循环。

最后,在ConversionReview对象中设置Response字段,并将其作为请求的响应体写回,使用 API Machinery 的响应写入器,再次使用我们的编解码工厂创建序列化器:

if review.Response.Result.Status == metav1.StatusSuccess {
    for _, obj = range objs {
        review.Response.ConvertedObjects =
          append(review.Response.ConvertedObjects,
            runtime.RawExtension{Object: obj},
          )
    }
}

// write negotiated response
responsewriters.WriteObject(
    http.StatusOK, gvk.GroupVersion(), codecs, review, w, req,
)

现在,我们必须实现实际的 Pizza 转换。在上面的所有这些管道之后,转换算法是最简单的部分。它只需检查我们是否确实获得了已知版本的 Pizza 对象,然后执行从v1beta1v1alpha1和反向的转换:

func convert(in runtime.Object, apiVersion string) (runtime.Object, error) {
    switch in := in.(type) {
    case *v1alpha1.Pizza:
        if apiVersion != v1beta1.SchemeGroupVersion.String() {
            return nil, fmt.Errorf("cannot convert %s to %s",
              v1alpha1.SchemeGroupVersion, apiVersion)
        }
        klog.V(2).Infof("Converting %s/%s from %s to %s", in.Namespace, in.Name,
            v1alpha1.SchemeGroupVersion, apiVersion)

        out := &v1beta1.Pizza{
            TypeMeta: in.TypeMeta,
            ObjectMeta: in.ObjectMeta,
            Status: v1beta1.PizzaStatus{
                Cost: in.Status.Cost,
            },
        }
        out.TypeMeta.APIVersion = apiVersion

        idx := map[string]int{}
        for _, top := range in.Spec.Toppings {
            if i, duplicate := idx[top]; duplicate {
                out.Spec.Toppings[i].Quantity++
                continue
            }
            idx[top] = len(out.Spec.Toppings)
            out.Spec.Toppings = append(out.Spec.Toppings, v1beta1.PizzaTopping{
                Name: top,
                Quantity: 1,
            })
        }

        return out, nil

    case *v1beta1.Pizza:
        if apiVersion != v1alpha1.SchemeGroupVersion.String() {
            return nil, fmt.Errorf("cannot convert %s to %s",
              v1beta1.SchemeGroupVersion, apiVersion)
        }
        klog.V(2).Infof("Converting %s/%s from %s to %s",
          in.Namespace, in.Name, v1alpha1.SchemeGroupVersion, apiVersion)

        out := &v1alpha1.Pizza{
            TypeMeta: in.TypeMeta,
            ObjectMeta: in.ObjectMeta,
            Status: v1alpha1.PizzaStatus{
                Cost: in.Status.Cost,
            },
        }
        out.TypeMeta.APIVersion = apiVersion

        for i := range in.Spec.Toppings {
            for j := 0; j < in.Spec.Toppings[i].Quantity; j++ {
                out.Spec.Toppings = append(
                  out.Spec.Toppings, in.Spec.Toppings[i].Name)
            }
        }

        return out, nil

    default:
    }
    klog.V(2).Infof("Unknown type %T", in)
    return nil, fmt.Errorf("unknown type %T", in)
}

请注意,在转换的两个方向上,我们只需复制TypeMetaObjectMeta,将 API 版本更改为所需的版本,然后转换配料片,这实际上是对象结构上唯一不同的部分。

如果存在更多版本,则需要在它们之间进行双向转换。或者,当然,我们可以像聚合 API 服务器中那样使用中心版本(参见“内部类型和转换”),而不是实现从所有支持的外部版本到所有支持的版本的转换。

部署转换 Webhook

现在我们想要部署转换 Webhook。您可以在GitHub上找到所有清单。

CRD 的转换 Webhook 在集群中启动,并放置在服务对象后面,该服务对象由 CRD 清单中转换 Webhook 规范引用:

apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition
metadata:
  name: pizzas.restaurant.programming-kubernetes.info
spec:
  ...
  conversion:
    strategy: Webhook
    webhookClientConfig:
      caBundle: *`BASE64-CA-BUNDLE`*
      service:
        namespace: pizza-crd
        name: webhook
        path: /convert/v1beta1/pizza

CA 捆绑证书必须与 Webhook 使用的服务证书相匹配。在我们的示例项目中,我们使用Makefile使用 OpenSSL 生成证书,并通过文本替换将它们插入清单中。

在这里请注意,Kubernetes API 服务器假定 Webhook 支持 CRD 的所有指定版本。每个 CRD 只可能有一个这样的 Webhook。但是由于 CRD 和转换 Webhook 通常由同一个团队拥有,这应该足够了。

还要注意,当前apiextensions.k8s.io/v1beta1 API 中服务端口必须为 443。但是,该服务可以映射到 Webhook Pod 使用的任何端口。在我们的示例中,我们将 443 映射到由 Webhook 二进制服务的 8443 端口。

观看转换的实际操作

现在我们理解了转换 Webhook 的工作原理以及它如何连接到集群,让我们看看它的实际效果。

我们假设您已经检出了示例项目。此外,我们假设您有一个启用 Webhook 转换的集群(在 1.14 集群中通过特性门启用,或者通过默认启用 Webhook 转换的 1.15+集群)。获取此类集群的一种方法是通过kind 项目,该项目支持 Kubernetes 1.14.1,并提供本地kind-config.yaml文件以启用 Webhook 转换的 Alpha 特性门(“什么是编程 Kubernetes?”链接了其他一些开发集群的选项):

kind: Cluster
apiVersion: kind.sigs.k8s.io/v1alpha3
kubeadmConfigPatchesJson6902:
- group: kubeadm.k8s.io
  version: v1beta1
  kind: ClusterConfiguration
  patch: |
    - op: add
      path: /apiServer/extraArgs
      value: {}
    - op: add
      path: /apiServer/extraArgs/feature-gates
      value: CustomResourceWebhookConversion=true

然后我们可以创建一个集群:

$ kind create cluster --image kindest/node-images:v1.14.1 --config kind-config.yaml
$ export KUBECONFIG="$(kind get kubeconfig-path --name="kind")"

现在我们可以部署我们的清单

$ cd pizza-crd
$ cd manifest/deployment
$ make
$ kubectl create -f ns.yaml
$ kubectl create -f pizza-crd.yaml
$ kubectl create -f topping-crd.yaml
$ kubectl create -f sa.yaml
$ kubectl create -f rbac.yaml
$ kubectl create -f rbac-bind.yaml
$ kubectl create -f service.yaml
$ kubectl create -f serving-cert-secret.yaml
$ kubectl create -f deployment.yaml

这些清单包含以下文件:

ns.yaml

创建pizza-crd命名空间。

pizza-crd.yaml

restaurant.programming-kubernetes.info API 组中指定了 pizza 资源,具有v1alpha1v1beta1版本,并且像之前展示的 webhook 转换配置。

topping-crd.yaml

在同一 API 组中指定了配料 CR,但仅限于v1alpha1版本。

sa.yaml

引入了webhook服务帐户。

rbac.yaml

定义了一个角色以读取、列出和监视配料。

rbac-bind.yaml

将先前的 RBAC 角色绑定到webhook服务帐户。

service.yaml

定义了webhook服务,将 webhook pod 的端口 443 映射到 8443 端口。

serving-cert-secret.yaml

包含了用于 webhook pod 的服务证书和私钥。证书也直接用作前述 pizza CRD 清单中的 CA 捆绑。

deployment.yaml

启动 webhook pods,传递--tls-cert-file--tls-private-key的服务证书密钥。

之后,我们终于可以创建一个玛格丽特披萨:

$ cat  ../examples/margherita-pizza.yaml
apiVersion: restaurant.programming-kubernetes.info/v1alpha1
kind: Pizza
metadata:
  name: margherita
spec:
  toppings:
  - mozzarella
  - tomato
$ kubectl create ../examples/margherita-pizza.yaml
pizza.restaurant.programming-kubernetes.info/margherita created

现在,通过转换 webhook 的设置,我们可以在两个版本中检索相同的对象。首先显式地在v1alpha1版本中:

$ kubectl get pizzas.v1alpha1.restaurant.programming-kubernetes.info \
    margherita -o yaml
apiVersion: restaurant.programming-kubernetes.info/v1alpha1
kind: Pizza
metadata:
  creationTimestamp: "2019-04-14T21:41:39Z"
  generation: 1
  name: margherita
  namespace: pizza-crd
  resourceVersion: "18296"
  pizzas/margherita
  uid: 15c1c06a-5efe-11e9-9230-0242f24ba99c
spec:
  toppings:
  - mozzarella
  - tomato
status: {}

然后v1beta1版本的同一个对象显示了不同的配料结构:

$ kubectl get pizzas.v1beta1.restaurant.programming-kubernetes.info \
    margherita -o yaml
apiVersion: restaurant.programming-kubernetes.info/v1beta1
kind: Pizza
metadata:
  creationTimestamp: "2019-04-14T21:41:39Z"
  generation: 1
  name: margherita
  namespace: pizza-crd
  resourceVersion: "18296"
  pizzas/margherita
  uid: 15c1c06a-5efe-11e9-9230-0242f24ba99c
spec:
  toppings:
  - name: mozzarella
    quantity: 1
  - name: tomato
    quantity: 1
status: {}

与此同时,在 webhook pod 的日志中,我们看到了这个转换调用:

I0414 21:46:28.639707       1 convert.go:35] Converting pizza-crd/margherita
  from restaurant.programming-kubernetes.info/v1alpha1
  to restaurant.programming-kubernetes.info/v1beta1
10.32.0.1 - - [14/Apr/2019:21:46:28 +0000]
  "POST /convert/v1beta1/pizza?timeout=30s HTTP/2.0" 200 968

因此,webhook 正在按预期工作。

准入 Webhooks

在“自定义 API 服务器用例”中,我们讨论了在哪些场景下聚合 API 服务器比使用 CR 更好的选择。提到的许多原因是关于使用 Golang 实现特定行为的自由,而不是受限于 CRD 清单中的声明式功能。

我们在前一节中已经看到了如何使用 Golang 构建 CRD 转换 webhook。类似的机制也用于向 CRD 添加自定义准入,同样是在 Golang 中实现。

基本上,我们与聚合 API 服务器中的自定义准入插件有着相同的自由度(参见“准入”):有变异和验证准入 webhook,并且它们在与本机资源相同的位置调用,如图 9-5 所示。

CR 请求管道中的准入

图 9-5. CR 请求管道中的准入

我们在“验证自定义资源”中看到基于 OpenAPI 的 CRD 验证。在图 9-5 中,“验证”框中执行验证。在此之后调用验证准入 webhook,变异准入 webhook 在此之前调用。

准入 webhook 几乎放在准入插件顺序的末尾,quota 之前。准入 webhook 在 Kubernetes 1.14 中是 beta 版,因此在大多数集群中可用。

提示

对于审核 webhooks API 的 v1 版本,计划允许通过审核链进行最多两次通过。这意味着更早的审核插件或 webhook 可以依赖于稍后插件或 webhook 的输出,到某个程度上。因此,在将来,这个机制将变得更加强大。

餐厅示例中的审核要求

餐厅示例中使用审核来完成多个任务:

  • 如果 spec.toppingsnil 或为空,则默认为 mozzarella、tomato 和 salami。

  • 从 CR JSON 中删除未知字段,并且不要在 etcd 中持久化。

  • spec.toppings 必须仅包含具有相应 topping 对象的配料。

前两个用例是变异的;第三个用例纯粹是验证的。因此,我们将使用一个变异 webhook 和一个验证 webhook 来实现这些步骤。

注意

正在进行 通过 OpenAPI v3 验证模式进行本地默认值设置 的工作。OpenAPI 有一个 default 字段,API 服务器将来会应用它。此外,通过一个称为修剪的机制,未知字段的删除将成为每个资源的标准行为,由 Kubernetes API 服务器执行(http://bit.ly/2Xzt2wm)。

在 Kubernetes 1.15 中,修剪作为 beta 版本提供。默认值计划在 1.16 的 beta 版本中可用。当目标集群中同时可用这两个功能时,可以完全不需要任何 webhook 来实现前述列表中的两个用例。

审核 Webhook 架构

审核 webhooks 在结构上与我们在本章前面看到的转换 webhooks 非常相似。

它们被部署在集群中,在服务映射端口 443 的背后,映射到一些 pod 的端口,并且通过 API 组 admission.k8s.io/v1beta1 中的审核对象 AdmissionReview 进行调用:

---
// AdmissionReview describes an admission review request/response.
type AdmissionReview struct {
    metav1.TypeMeta `json:",inline"`
    // Request describes the attributes for the admission request.
    // +optional
    Request *AdmissionRequest `json:"request,omitempty"`
    // Response describes the attributes for the admission response.
    // +optional
    Response *AdmissionResponse `json:"response,omitempty"`
}
---

AdmissionRequest 包含我们从审核属性(参见 “实施”)中熟悉的所有信息:

// AdmissionRequest describes the admission.Attributes for the admission request.
type AdmissionRequest struct {
    // UID is an identifier for the individual request/response. It allows us to
    // distinguish instances of requests which are otherwise identical (parallel
    // requests, requests when earlier requests did not modify etc). The UID is
    // meant to track the round trip (request/response) between the KAS and the
    // WebHook, not the user request. It is suitable for correlating log entries
    // between the webhook and apiserver, for either auditing or debugging.
    UID types.UID `json:"uid"`
    // Kind is the type of object being manipulated.  For example: Pod
    Kind metav1.GroupVersionKind `json:"kind"`
    // Resource is the name of the resource being requested.  This is not the
    // kind.  For example: pods
    Resource metav1.GroupVersionResource `json:"resource"`
    // SubResource is the name of the subresource being requested.  This is a
    // different resource, scoped to the parent resource, but it may have a
    // different kind. For instance, /pods has the resource "pods" and the kind
    // "Pod", while /pods/foo/status has the resource "pods", the sub resource
    // "status", and the kind "Pod" (because status operates on pods). The
    // binding resource for a pod though may be /pods/foo/binding, which has
    // resource "pods", subresource "binding", and kind "Binding".
    // +optional
    SubResource string `json:"subResource,omitempty"`
    // Name is the name of the object as presented in the request.  On a CREATE
    // operation, the client may omit name and rely on the server to generate
    // the name.  If that is the case, this method will return the empty string.
    // +optional
    Name string `json:"name,omitempty"`
    // Namespace is the namespace associated with the request (if any).
    // +optional
    Namespace string `json:"namespace,omitempty"`
    // Operation is the operation being performed
    Operation Operation `json:"operation"`
    // UserInfo is information about the requesting user
    UserInfo authenticationv1.UserInfo `json:"userInfo"`
    // Object is the object from the incoming request prior to default values
    // being applied
    // +optional
    Object runtime.RawExtension `json:"object,omitempty"`
    // OldObject is the existing object. Only populated for UPDATE requests.
    // +optional
    OldObject runtime.RawExtension `json:"oldObject,omitempty"`
    // DryRun indicates that modifications will definitely not be persisted
    // for this request.
    // Defaults to false.
    // +optional
    DryRun *bool `json:"dryRun,omitempty"`
}

相同的 AdmissionReview 对象用于变异和验证审核 webhooks。唯一的区别在于,在变异情况下,AdmissionResponse 可以有一个 patch 字段和 patchType 字段,在 webhook 响应后在 Kubernetes API 服务器内应用。在验证情况下,这两个字段在响应时保持空白。

对于我们这里的目的,最重要的字段是 Object 字段,与前面的转换 webhook 一样,使用 runtime.RawExtension 类型来存储 pizza 对象。

对于更新请求,我们还会获取旧对象,并且可以检查那些本应为只读但在请求中被更改的字段。在我们的示例中,我们并未这样做。但是在 Kubernetes 中,您会遇到许多这样实现逻辑的情况,例如对于 pod 的大多数字段,一旦创建后就无法更改 pod 的命令。

在 Kubernetes 1.14 中,由变异 Webhook 返回的补丁必须是 JSON Patch 类型(参见 RFC 6902)。此补丁描述了如何修改对象以满足所需的不变性。

注意,在验证每个变异 Webhook 更改时,最佳做法是在验证 Webhook 的最后验证,至少如果这些强制属性对行为至关重要的话。想象一下,某个其他变异 Webhook 触及对象中的相同字段。然后,您不能确定变异更改是否会在变异准入链的末尾生效。

目前变异 Webhook 中除了字母顺序外没有顺序。未来可能会有讨论改变这一点。

对于验证 Webhook,顺序显然无关紧要,Kubernetes API 服务器甚至并行调用验证 Webhook 以减少延迟。相比之下,变异 Webhook 会给通过它们的每个请求增加延迟,因为它们是顺序调用的。

常见的延迟(当然,这严重依赖于环境)大约在 100 毫秒左右。因此,依次运行许多 Webhook 会导致用户在创建或更新对象时经历相当的延迟。

注册准入 Webhook

准入 Webhook 并未在 CRD 清单中注册。原因是它们不仅适用于 CRD,还适用于任何类型的资源。您甚至可以向标准 Kubernetes 资源添加自定义准入 Webhook。

只有注册对象:MutatingWebhookRegistrationValidatingWebhookRegistration。它们仅在种类名称上有所不同:

apiVersion: admissionregistration.k8s.io/v1beta1
kind: MutatingWebhookConfiguration
metadata:
  name: restaurant.programming-kubernetes.info
webhooks:
- name: restaurant.programming-kubernetes.info
  failurePolicy: Fail
  sideEffects: None
  admissionReviewVersions:
  - v1beta1
  rules:
  - apiGroups:
    - "restaurant.programming-kubernetes.info"
    apiVersions:
    - v1alpha1
    - v1beta1
    operations:
    - CREATE
    - UPDATE
    resources:
    - pizzas
  clientConfig:
    service:
      namespace: pizza-crd
      name: webhook
      path: /admit/v1beta1/pizza
    caBundle: *`CA-BUNDLE`*

这在本章开头为我们的 pizza-crd Webhook 注册了变异准入,用于我们的两个版本的资源 pizza,API 组 restaurant.programming-kubernetes.info,以及 HTTP 动词 CREATEUPDATE(也包括补丁)。

在 Webhook 配置中有进一步的方式来限制匹配的资源,例如命名空间选择器(例如,排除控制平面命名空间以避免引导问题)以及带有通配符和子资源的更高级资源模式。

最后但同样重要的是失败模式,它可以是 FailIgnore。它指定了如果无法访问 Webhook 或因其他原因失败时该怎么做。

警告

如果错误部署,准入 Webhook 可能会破坏集群。准入 Webhook 匹配核心类型可能使整个集群无法操作。必须特别注意为非 CRD 资源调用准入 Webhook。

特别是,最好的做法是排除控制平面和 Webhook 资源本身的 Webhook。

实施准入 Webhook

通过我们在本章开头对转换 Webhook 的工作,添加准入能力并不困难。我们还看到了路径 /admit/v1beta1/pizza/validate/v1beta1/pizzapizza-crd-webhook 二进制文件的主函数中注册:

mux.Handle("/admit/v1beta1/pizza", http.HandlerFunc(admission.ServePizzaAdmit))
mux.Handle("/validate/v1beta1/pizza", http.HandlerFunc(
admission.ServePizzaValidation(restaurantInformers)))

两个 HTTP 处理程序实现的第一部分看起来几乎与转换 Webhook 相同:

func ServePizzaAdmit(w http.ResponseWriter, req *http.Request) {
    // read body
    body, err := ioutil.ReadAll(req.Body)
    if err != nil {
        responsewriters.InternalError(w, req,
          fmt.Errorf("failed to read body: %v", err))
        return
    }

    // decode body as admission review
    reviewGVK := admissionv1beta1.SchemeGroupVersion.WithKind("AdmissionReview")
    decoder := codecs.UniversalDeserializer()
    into := &admissionv1beta1.AdmissionReview{}
    obj, gvk, err := decoder.Decode(body, &reviewGVK, into)
    if err != nil {
        responsewriters.InternalError(w, req,
          fmt.Errorf("failed to decode body: %v", err))
        return
    }
    review, ok := obj.(*admissionv1beta1.AdmissionReview)
    if !ok {
        responsewriters.InternalError(w, req,
          fmt.Errorf("unexpected GroupVersionKind: %s", gvk))
        return
    }
    if review.Request == nil {
        responsewriters.InternalError(w, req,
          fmt.Errorf("unexpected nil request"))
        return
    }

    ...
}

在验证 Webhook 的情况下,我们必须连接通知器(用于检查集群中的配料是否存在)。只要通知器未同步,我们就返回内部错误。未同步的通知器具有不完整的数据,因此可能不知道配料并且可能会拒绝比萨,尽管它们是有效的:

func ServePizzaValidation(informers restaurantinformers.SharedInformerFactory)
    func (http.ResponseWriter, *http.Request)
{
    toppingInformer := informers.Restaurant().V1alpha1().Toppings().Informer()
    toppingLister := informers.Restaurant().V1alpha1().Toppings().Lister()

    return func(w http.ResponseWriter, req *http.Request) {
        if !toppingInformer.HasSynced() {
            responsewriters.InternalError(w, req,
              fmt.Errorf("informers not ready"))
            return
        }

        // read body
        body, err := ioutil.ReadAll(req.Body)
        if err != nil {
            responsewriters.InternalError(w, req,
              fmt.Errorf("failed to read body: %v", err))
            return
        }

        // decode body as admission review
        gv := admissionv1beta1.SchemeGroupVersion
        reviewGVK := gv.WithKind("AdmissionReview")
        obj, gvk, err := codecs.UniversalDeserializer().Decode(body, &reviewGVK,
            &admissionv1beta1.AdmissionReview{})
        if err != nil {
            responsewriters.InternalError(w, req,
              fmt.Errorf("failed to decode body: %v", err))
            return
        }
        review, ok := obj.(*admissionv1beta1.AdmissionReview)
        if !ok {
            responsewriters.InternalError(w, req,
              fmt.Errorf("unexpected GroupVersionKind: %s", gvk))
            return
        }
        if review.Request == nil {
            responsewriters.InternalError(w, req,
              fmt.Errorf("unexpected nil request"))
            return
        }

        ...
    }
}

就像在 Webhook 转换案例中一样,我们设置了方案和编解码器工厂,其中包括准入 API 组和我们的餐厅 API 组:

var (
    scheme = runtime.NewScheme()
    codecs = serializer.NewCodecFactory(scheme)
)

func init() {
    utilruntime.Must(admissionv1beta1.AddToScheme(scheme))
    install.Install(scheme)
}

通过这两个,我们从 AdmissionReview 中解码嵌入的比萨对象(这次只有一个,没有切片):

// decode object
if review.Request.Object.Object == nil {
    var err error
    review.Request.Object.Object, _, err =
      codecs.UniversalDeserializer().Decode(review.Request.Object.Raw, nil, nil)
    if err != nil {
        review.Response.Result = &metav1.Status{
            Message: err.Error(),
            Status:  metav1.StatusFailure,
        }
        responsewriters.WriteObject(http.StatusOK, gvk.GroupVersion(),
          codecs, review, w, req)
        return
    }
}

然后我们可以执行实际的突变准入(两个 API 版本的 spec.toppings 默认值):

orig := review.Request.Object.Raw
var bs []byte
switch pizza := review.Request.Object.Object.(type) {
case *v1alpha1.Pizza:
    // default toppings
    if len(pizza.Spec.Toppings) == 0 {
        pizza.Spec.Toppings = []string{"tomato", "mozzarella", "salami"}
    }
    bs, err = json.Marshal(pizza)
    if err != nil {
        responsewriters.InternalError(w, req,
          fmt.Errorf"unexpected encoding error: %v", err))
        return
    }

case *v1beta1.Pizza:
    // default toppings
    if len(pizza.Spec.Toppings) == 0 {
        pizza.Spec.Toppings = []v1beta1.PizzaTopping{
            {"tomato", 1},
            {"mozzarella", 1},
            {"salami", 1},
        }
    }
    bs, err = json.Marshal(pizza)
    if err != nil {
        responsewriters.InternalError(w, req,
          fmt.Errorf("unexpected encoding error: %v", err))
        return
    }

default:
    review.Response.Result = &metav1.Status{
        Message: fmt.Sprintf("unexpected type %T", review.Request.Object.Object),
        Status:  metav1.StatusFailure,
    }
    responsewriters.WriteObject(http.StatusOK, gvk.GroupVersion(),
      codecs, review, w, req)
    return
}

或者,我们可以使用转换 Webhook 中的转换算法,然后仅为其中一个版本实现默认值。两种方法都可行,哪种更合理取决于上下文。在这里,默认值足够简单,可以实现两次。

最后一步是计算补丁——原始对象(存储在 orig 中作为 JSON)与新的默认对象之间的差异:

// compare original and defaulted version
ops, err := jsonpatch.CreatePatch(orig, bs)
if err != nil {
    responsewriters.InternalError(w, req,
        fmt.Errorf("unexpected diff error: %v", err))
    return
}
review.Response.Patch, err = json.Marshal(ops)
if err != nil {
    responsewriters.InternalError(w, req,
    fmt.Errorf("unexpected patch encoding error: %v", err))
    return
}
typ := admissionv1beta1.PatchTypeJSONPatch
review.Response.PatchType = &typ
review.Response.Allowed = true

我们使用 JSON-Patch 库(Matt Baird 的一个分支,具有 关键修复)从原始对象 orig 和修改后的对象 bs 中提取补丁,两者都作为 JSON 字节切片传递。或者,我们可以直接操作未类型化的 JSON 数据并手动创建 JSON-Patch。同样,这取决于上下文。使用差异库很方便。

然后,就像在 Webhook 转换中一样,我们通过使用之前创建的编解码器工厂向响应编写器写入响应来结束:

responsewriters.WriteObject(
    http.StatusOK, gvk.GroupVersion(), codecs, review, w, req,
)

验证 Webhook 非常相似,但它使用共享通知器中的配料列表器来检查配料对象的存在:

switch pizza := review.Request.Object.Object.(type) {
case *v1alpha1.Pizza:
    for _, topping := range pizza.Spec.Toppings {
        _, err := toppingLister.Get(topping)
        if err != nil && !errors.IsNotFound(err) {
            responsewriters.InternalError(w, req,
              fmt.Errorf("failed to lookup topping %q: %v", topping, err))
            return
        } else if errors.IsNotFound(err) {
            review.Response.Result = &metav1.Status{
                Message: fmt.Sprintf("topping %q not known", topping),
                Status:  metav1.StatusFailure,
            }
            responsewriters.WriteObject(http.StatusOK, gvk.GroupVersion(),
              codecs, review, w, req)
            return
        }
    }
    review.Response.Allowed = true
case *v1beta1.Pizza:
    for _, topping := range pizza.Spec.Toppings {
        _, err := toppingLister.Get(topping.Name)
        if err != nil && !errors.IsNotFound(err) {
            responsewriters.InternalError(w, req,
              fmt.Errorf("failed to lookup topping %q: %v", topping, err))
            return
        } else if errors.IsNotFound(err) {
            review.Response.Result = &metav1.Status{
                Message: fmt.Sprintf("topping %q not known", topping),
                Status:  metav1.StatusFailure,
            }
            responsewriters.WriteObject(http.StatusOK, gvk.GroupVersion(),
              codecs, review, w, req)
            return
        }
    }
    review.Response.Allowed = true
default:
    review.Response.Result = &metav1.Status{
        Message: fmt.Sprintf("unexpected type %T", review.Request.Object.Object),
        Status:  metav1.StatusFailure,
    }
}
responsewriters.WriteObject(http.StatusOK, gvk.GroupVersion(),
      codecs, review, w, req)

准入 Webhook 正在起作用

我们通过在集群中创建两个注册对象来部署两个准入 Webhook:

$ kubectl create -f validatingadmissionregistration.yaml
$ kubectl create -f mutatingadmissionregistration.yaml

之后,我们不能再创建具有未知配料的比萨了。

$ kubectl create -f ../examples/margherita-pizza.yaml
Error from server: error when creating "../examples/margherita-pizza.yaml":
  admission webhook "restaurant.programming-kubernetes.info" denied the request:
    topping "tomato" not known

与此同时,在 Webhook 日志中我们看到:

I0414 22:45:46.873541       1 pizzamutation.go:115] Defaulting pizza-crd/ in
  version admission.k8s.io/v1beta1, Kind=AdmissionReview
10.32.0.1 - - [14/Apr/2019:22:45:46 +0000]
  "POST /admit/v1beta1/pizza?timeout=30s HTTP/2.0" 200 871
10.32.0.1 - - [14/Apr/2019:22:45:46 +0000]
  "POST /validate/v1beta1/pizza?timeout=30s HTTP/2.0" 200 956

在创建示例文件夹中的配料后,我们可以再次创建马格里塔比萨:

$ kubectl create -f ../examples/topping-tomato.yaml
$ kubectl create -f ../examples/topping-salami.yaml
$ kubectl create -f ../examples/topping-mozzarella.yaml
$ kubectl create -f ../examples/margherita-pizza.yaml
pizza.restaurant.programming-kubernetes.info/margherita created

最后但同样重要的是,让我们检查默认值是否按预期工作。我们想要创建一个空的比萨:

apiVersion: restaurant.programming-kubernetes.info/v1alpha1
kind: Pizza
metadata:
  name: salami
spec:

这应该默认为一份萨拉米比萨,而且确实是:

$ kubectl create -f ../examples/empty-pizza.yaml
pizza.restaurant.programming-kubernetes.info/salami created
$ kubectl get pizza salami -o yaml
apiVersion: restaurant.programming-kubernetes.info/v1beta1
kind: Pizza
metadata:
  creationTimestamp: "2019-04-14T22:49:40Z"
  generation: 1
  name: salami
  namespace: pizza-crd
  resourceVersion: "23227"
  uid: 962e2dda-5f07-11e9-9230-0242f24ba99c
spec:
  toppings:
  - name: tomato
    quantity: 1
  - name: mozzarella
    quantity: 1
  - name: salami
    quantity: 1
status: {}

啊,这里有一份所有我们期望的配料的萨拉米比萨。享受吧!

在结束本章之前,我们希望关注一个 apiextensions.k8s.io/v1 API 组版本(即非测试版,普遍可用版)的 CRDs —— 也就是结构模式的引入。

结构模式与自定义资源定义的未来

从 Kubernetes 1.15 开始,OpenAPI v3 验证模式(参见“验证自定义资源”)在 CRD 中将扮演更加核心的角色,因为如果使用了这些新特性中的任何一个,指定模式将成为强制要求。

  • CRD 转换(参见图 9-2)

  • 修剪(参见“对比修剪与保留未知字段”)

  • 默认值(参见“默认值”)

  • OpenAPI 模式 发布

严格来说,模式的定义仍然是可选的,每个现有的 CRD 都将继续工作,但没有模式的 CRD 将被排除在任何新特性之外。

此外,指定的模式必须遵循某些规则,以确保指定的类型确实符合 Kubernetes API 约定 的理念。我们称之为 结构模式

结构模式

结构模式是符合以下规则的 OpenAPI v3 验证模式(参见“验证自定义资源”):

  1. 指定模式为根、对象节点的每个指定字段(通过 OpenAPI 中的 propertiesadditionalProperties)以及数组节点中的每个项目(通过 OpenAPI 中的 items),要求非空类型(通过 type 在 OpenAPI 中),但有以下例外情况:

    • 带有 x-kubernetes-int-or-string: true 的节点

    • 带有 x-kubernetes-preserve-unknown-fields: true 的节点

  2. 对于对象中的每个字段和数组中的每个项目,它在 allOfanyOfoneOfnot 中设置,模式还指定了那些逻辑结合点之外的字段/项目。

  3. 模式未在 allOfanyOfoneOfnot 中设置 descriptiontypedefaultadditionPropertiesnullable,但对于 x-kubernetes-int-or-string: true 的两种模式有例外情况(参见“IntOrString 和 RawExtensions”)。

  4. 如果指定了 metadata,则只允许对 metadata.namemetadata.generateName 的限制。

这是一个非结构化示例:

properties:
  foo:
    pattern: "abc"
  metadata:
    type: object
    properties:
      name:
        type: string
        pattern: "^a"
      finalizers:
        type: array
        items:
          type: string
          pattern: "my-finalizer"
anyOf:
- properties:
    bar:
      type: integer
      minimum: 42
  required: ["bar"]
  description: "foo bar object"

由于以下违规,它不是结构模式:

  • 根处的类型缺失(规则 1)。

  • 缺少 foo 的类型(规则 1)。

  • anyOf 内部的 bar 在外部未指定(规则 2)。

  • anyOfbartype(规则 3)。

  • 描述在 anyOf 中设置(规则 3)。

  • 可能不会限制 metadata.finalizer(规则 4)。

相反,以下对应的模式是结构化的:

type: object
description: "foo bar object"
properties:
  foo:
    type: string
    pattern: "abc"
  bar:
    type: integer
  metadata:
    type: object
    properties:
      name:
        type: string
        pattern: "^a"
anyOf:
- properties:
    bar:
      minimum: 42
  required: ["bar"]

违反结构模式规则将在 CRD 的 NonStructural 条件中报告。

请自行验证 “验证自定义资源” 中的 cnat 示例的模式以及 pizza CRD 示例 中的模式是否确实是结构化的。

对比修剪与保留未知字段

CRD 传统上会将任何(可能已验证的)JSON 存储为etcd中的原样。这意味着未指定的字段(如果有 OpenAPI v3 验证模式)将被持久化。这与像 pod 这样的本机 Kubernetes 资源形成对比。如果用户指定了一个字段spec.randomField,这将被 API 服务器 HTTPS 端点接受,但在将该 pod 写入etcd之前会被丢弃(我们称之为修剪)。

如果定义了结构化的 OpenAPI v3 验证模式(在全局spec.validation.openAPIV3Schema或每个版本中),我们可以通过将spec.preserveUnknownFields设置为false来启用修剪(在创建和更新时丢弃未指定的字段)。

让我们看看cnat示例。^(2) 有一个 Kubernetes 1.15 集群,我们启用修剪:

apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition
metadata:
  name: ats.cnat.programming-kubernetes.info
spec:
  ...
  preserveUnknownFields: false

然后我们尝试创建一个具有未知字段的实例:

apiVersion: cnat.programming-kubernetes.info/v1alpha1
kind: At
metadata:
  name: example-at
spec:
  schedule: "2019-07-03T02:00:00Z"
  command: echo "Hello, world!"
  someGarbage: 42

如果我们使用kubectl get at example-at检索此对象,我们会看到someGarbage值被丢弃:

apiVersion: cnat.programming-kubernetes.info/v1alpha1
kind: At
metadata:
  name: example-at
spec:
  schedule: "2019-07-03T02:00:00Z"
  command: echo "Hello, world!"

我们说someGarbage已经被修剪了。

从 Kubernetes 1.15 开始,apiextensions/v1beta1中提供了修剪功能,但默认为关闭;也就是说,spec.preserveUnknownFields默认为true。在apiextensions/v1中,不允许创建具有spec.preserveUnknownFields: true的新 CRD。

控制修剪

在 CRD 中使用spec.preserveUnknownField: false,这将启用该类型和所有版本的所有 CR 的修剪。但是,可以通过在 OpenAPI v3 验证模式中使用x-kubernetes-preserve-unknown-fields: true来选择退出对 JSON 子树的修剪:

type: object
properties:
  json:
    x-kubernetes-preserve-unknown-fields: true

字段json可以存储任何 JSON 值,而不会被修剪。

可以部分指定允许的 JSON:

type: object
properties:
  json:
    x-kubernetes-preserve-unknown-fields: true
    type: object
    description: this is arbitrary JSON

采用这种方法,仅允许对象类型值。

启用每个指定属性(或additionalProperties)的修剪:

type: object
properties:
  json:
    x-kubernetes-preserve-unknown-fields: true
    type: object
    properties:
      spec:
        type: object
        properties:
          foo:
            type: string
          bar:
            type: string

有了这个,值是:

json:
  spec:
    foo: abc
    bar: def
    something: x
  status:
    something: x

将被修剪为:

json:
  spec:
    foo: abc
    bar: def
  status:
    something: x

这意味着在指定的spec对象中,something字段被修剪了(因为指定了spec),但外部的所有内容都没有。status未指定,因此status.*something*不会被修剪。

IntOrString 和 RawExtensions

有些情况下,结构模式表达力不够。其中之一是多态字段,即可以是不同类型的字段。我们从本机 Kubernetes API 类型中知道IntOrString

可以使用x-kubernetes-int-or-string: true指令在 CRDs 中有IntOrString。类似地,可以使用x-kubernetes-embedded-object: true声明runtime.RawExtensions

例如:

type: object
properties:
  intorstr:
    type: object
    x-kubernetes-int-or-string: true
  embedded:
    x-kubernetes-embedded-object: true
    x-kubernetes-preserve-unknown-fields: true

这声明了:

  • 一个名为intorstr的字段,其中包含整数或字符串

  • 一个名为embedded的字段,其中包含类似于完整的 pod 规范的 Kubernetes 对象

有关这些指令的所有详细信息,请参阅官方 CRD 文档

我们想讨论的最后一个依赖于结构模式的主题是默认值。

默认值

在原生 Kubernetes 类型中,默认某些值是常见的。在 Kubernetes 1.15 之前,CRDs 的默认设置只能通过变更接受 Webhooks 实现(参见“Admission Webhooks”)。然而,从 Kubernetes 1.15 开始,通过前一节中描述的 OpenAPI v3 模式直接向 CRDs 添加了默认支持(请参阅设计文档)。

注意

到 1.15 版本为止,这仍然是一个 alpha 功能,默认情况下在特性门控CustomResourceDefaulting后面是禁用的。但是随着在 1.16 中晋升为 beta,它将在 CRDs 中变得普遍。

为了默认某些字段,只需在 OpenAPI v3 模式中使用 default 关键字指定默认值。当您向类型添加新字段时,这非常有用。

从“验证自定义资源”的cnat示例的模式开始,假设我们想要使容器镜像可自定义,但默认为busybox镜像。为此,我们向 OpenAPI v3 模式添加了 string 类型的image字段,并将其默认设置为busybox

type: object
properties:
  apiVersion:
    type: string
  kind:
    type: string
  metadata:
    type: object
  spec:
    type: object
    properties:
      schedule:
        type: string
        pattern: "^\d{4}-([0]\d|1[0-2])-([0-2]\d|3[01])..."
      command:
        type: string
      image:
        type: string
        default: "busybox"
    required:
    - schedule
    - command
  status:
    type: object
    properties:
      phase:
        type: string
required:
- metadata
- apiVersion
- kind
- spec

如果用户创建实例而没有指定镜像,该值会自动设置:

apiVersion: cnat.programming-kubernetes.info/v1alpha1
kind: At
metadata:
  name: example-at
spec:
  schedule: "2019-07-03T02:00:00Z"
  command: echo "hello world!"

在创建时,这将自动转换为:

apiVersion: cnat.programming-kubernetes.info/v1alpha1
kind: At
metadata:
  name: example-at
spec:
  schedule: "2019-07-03T02:00:00Z"
  command: echo "hello world!"
  image: busybox

这看起来非常方便,显著提升了 CRDs 的用户体验。更重要的是,所有存储在etcd中的旧对象在从 API 服务器读取时将自动继承新字段^(3)。

请注意,etcd中持久化的对象不会被重写(即不会自动迁移)。换句话说,读取时默认值仅在需要更新对象的另一原因时添加,并且仅在对象被更新时持久化。

概述

Admission 和 conversion webhooks 将 CRDs 带入了一个完全不同的层次。在这些功能之前,CRs 主要用于小型、不那么严肃的用例,通常用于配置和内部应用程序,API 兼容性并不那么重要。

使用 Webhooks 后,CRs 看起来更像是本地资源,具有长寿命周期和强大的语义。我们已经看到了如何在不同资源之间实现依赖关系以及如何设置字段的默认值。

到这一步,您可能已经对这些功能在现有 CRDs 中的应用场景有了很多想法。我们很想看到社区基于这些功能未来的创新。

^(1) apiextensions.k8s.ioadmissionregistration.k8s.io 都计划在 Kubernetes 1.16 中晋升为 v1。

^(2) 我们使用cnat示例而不是披萨示例,因为前者的结构简单—例如,只有一个版本。当然,所有这些都可以扩展到多个版本(即一个模式版本)。

^(3) 例如,通过kubectl get ats -o yaml

附录 A. 资源

一般

书籍

教程和示例

文章

仓库

posted @ 2025-11-15 13:07  绝不原创的飞龙  阅读(0)  评论(0)    收藏  举报