控制器(Controller)主动监控并维护一组 Kubernetes 资源,使其处于理想状态。Kubernetes 本身的核心是由一组控制器组成的,这些控制器定期观察并协调应用的当前状态和声明的目标状态。在这一章中,我们将看到如何利用这个核心概念来扩展平台形式以满足我们的需求。

问题描述

你已经看到,Kubernetes 是一个复杂而全面的平台,它提供了许多开箱即用的功能。然而,它是一个通用的编排平台,并不能覆盖所有的应用用例。幸运的是,它提供了天然的扩展点,特定的用例可以在成熟的 Kubernetes 构件上优雅地实现。

这里出现的主要问题是如何在不改变和破坏 Kubernetes 的情况下扩展 Kubernetes,以及如何将其功能用于自定义用例。

根据设计,Kubernetes 是基于以资源为中心的声明式 API。我们所说的声明式到底是什么意思?相对于命令式方法,声明式方法不会告诉 Kubernetes 应该如何行动,而是描述目标状态应该是怎样的。例如,当我们扩展 Deployment 时,我们不会通过告诉 Kubernetes “创建一个新的 Pod” 来主动创建新的 Pod。相反,我们通过 Kubernetes API 将 Deployment 资源的 replicas 属性更改为所需数量。

:::info 那么,新的 Pod 是如何创建的呢?这是由控制器在内部完成的。对于资源状态的每一次变化(比如改变 Deployment 的 replicas 属性值),Kubernetes 都会创建一个事件,并将其广播给所有感兴趣的听众。然后,这些听众可以通过修改、删除或创建新资源来做出反应。反过来,它又会创建其他事件,比如 Pod 创建的事件。然后,这些事件有可能被其他控制器再次接收,并执行它们的具体操作。

整个过程也就是所谓的状态调和(Reconciliation),目标状态(所需副本数量)与当前状态(实际运行的实例)不同,控制器的任务就是调和并再次达到所需的目标状态。从这个角度看,Kubernetes 本质上代表了一个分布式状态管理器。你给它一个组件实例的期望状态,如果有什么变化,它就会试图维持这个状态。:::

现在我们如何在不修改 Kubernetes 代码的情况下钩入这个调和过程,并创建一个为我们的特定需求定制的控制器?

解决方案

Kubernetes 自带一组内置控制器,用于管理标准的 Kubernetes 资源,如 ReplicaSet、DaemonSet、StatefulSet、Deployment 或 Service。这些控制器作为控制器管理器的一部分运行,控制器管理器部署在主节点上(作为一个独立的进程或 Pod)。这些控制器之间互不相识。它们在一个无休止的调节循环中运行,以监控它们的资源的实际和期望状态,并采取相应的行动,使实际状态更接近期望状态。

然而,除了这些开箱即用的控制器之外,Kubernetes 事件驱动架构还允许我们原生插入其他自定义控制器。自定义控制器可以在行为状态变化事件中添加额外的功能,与内部控制器的方式相同。控制器的一个共同特点是,它们是被动的,对系统中的事件做出反应,以执行它们的特定动作。从高层来看,这个调节过程主要包括以下几个步骤:

  • 观察(Observe):当观察到的资源发生变化时,通过观察 Kubernetes 发出的事件来发现实际状态。
  • 分析(Analyze):确定与理想状态的差异。
  • 行动(Act):执行操作,将实际状态驱动到所需状态。

例如,ReplicaSet 控制器观察 ReplicaSet 资源变化,分析需要运行多少 Pod,并通过向API服务器提交 Pod 定义来采取行动。然后,Kubernetes 的后端负责在节点上启动请求的 Pod。

图 22-1 显示了控制器如何将自己注册为事件监听器,以检测管理资源的变化。它观察当前状态,并通过调用 API 服务器来改变当前状态,以便更接近目标状态(如有必要)。
image.png
图 22-1 “观察 - 分析 - 行动” 周期

第 4 章 · Kubernetes API Server

控制器是 Kubernetes 控制平面的一部分,很早以前就清楚,它们还可以通过自定义行为来扩展平台。而且,它们已经变成了扩展平台的标准机制,并实现了复杂的应用生命周期管理。因此,新一代更复杂的控制器诞生了,被称为操作员。从进化和复杂性的角度来看,我们可以将主动调节组件分为两组:

  • 控制器(Controller):一个简单的调节过程,监控并作用于标准 Kubernetes 资源。更多的时候,这些控制器会增强平台行为,并增加新的平台功能。
  • 操作员(Operator):一个复杂的调节过程,它与自定义资源定义(CRD)交互,CRD 是操作员模式的核心。通常,这些 Operator 封装了复杂的应用域逻辑,并管理整个应用生命周期。我们将在第 23 章中深入讨论 Operator。

如前所述,这些分类有助于逐步引入新概念。在这里,我们将重点介绍比较简单的控制器,在下一章中,我们将介绍 CRD,并建立到操作器模式。

:::tips 为了避免有多个控制器同时作用于同一资源,控制器使用第 10 章中解释的 “单例服务” 模式。大多数控制器的部署就像 Deployment 一样,但只有一个副本,因为 Kubernetes 在资源层面使用优化锁来防止改变资源对象时的并发问题。最后,控制器不过是一个在后台永久运行的应用程序。 :::

因为 Kubernetes 本身是用 Go 编写的,而且访问 Kubernetes 的完整客户端库也是用 Go 编写的,所以很多控制器也是用 Go 编写的。但是,你可以通过向 Kubernetes API 服务器发送请求,用任何编程语言编写控制器。我们在后面的例 22-1 中看到一个用纯 Shell 脚本编写的控制器。

最直接的一种控制器扩展了 Kubernetes 管理资源的方式。它们在相同的标准资源上运行,并执行与在标准 Kubernetes 资源上运行的 Kubernetes 内部控制器类似的任务,但集群的用户是看不到的。控制器评估资源定义,并有条件地执行一些操作。尽管它们可以监视和执行资源定义中的任何字段,但元数据和 ConfigMap 最适合于此目的。以下是在选择存储控制器数据的位置时需要考虑的几个因素。

  • 标签(Label):标签作为资源元数据的一部分,可以被任何控制器监视。它们在后端数据库中被编入索引,并可在查询中有效地搜索。当需要类似选择器的功能时,我们应该使用标签(例如,匹配服务或部署的 Pod)。标签的一个限制是,只能使用带有限制的字母名称和值。请参阅 Kubernetes 文档,了解哪些语法和字符集可用于标签。
  • 注释(Annotation):注释是标签的绝佳替代品。如果值不符合标签值的语法限制,就必须用它们来代替标签。注释是没有索引的,所以我们将注解用于控制器查询中不作为键的非识别信息。对于任意元数据,优先选择注释而不是标签,还有一个好处是不会对 Kubernetes 内部性能产生负面影响。
  • ConfigMap:有时控制器需要额外的信息,这些信息不能很好地融入标签或注释中。在这种情况下,可以使用 ConfigMap 来保存目标状态定义。然后,这些 ConfigMap 被控制器监视和读取。CRD 更适合设计自定义的目标状态规范,推荐使用 CRD,而不是普通的 ConfigMap。然而,对于注册 CRD,您需要提升集群级别的权限。如果您没有这些权限,ConfigMap 仍然是 CRD 的最佳选择。我们将在第 23章 “操作员 “中详细解释 CRD。

这里有几个合理简单的控制器示例,你可以作为这个模式的实现样本来研究:

  • jenkins-x/exposecontroller:该控制器观察服务定义,如果它检测到元数据中名为 expose 的注解,控制器就会自动公开一个 Ingress 对象,供外部访问该服务。当有人删除 Service 时,它也会删除 Ingress 对象。
  • fabric8/configmapcontroller:这是一个控制器,用于观察 ConfigMap 对象的变化,并对其相关的部署进行滚动升级。我们可以将此控制器用于无法观察 ConfigMap 并动态更新新配置的应用程序。特别是当 Pod 将 ConfigMap 作为环境变量消耗时,或者当您的应用程序无法在不重启的情况下快速可靠地更新自己时,这一点尤为重要。在示例 22-2 中,我们用一个普通的 Shell 脚本实现了这样一个控制器。
  • Container Linux Update Operator:这是一个控制器,当它检测到 Kubernetes 节点上的特定注解时,会重新启动该节点。

现在我们来看看一个具体的例子:一个由单个 Shell 脚本组成的控制器,它可以观察 Kubernetes API 对 ConfigMap 资源的变化。如果我们用 k8spatterns.io/podDeleteSelector 对这样的 ConfigMap 进行注解,当 ConfigMap 发生变化时,所有被给定注解值选中的 Pod 都会被删除。假设我们用高阶资源(如 Deployment 或 ReplicaSet)来支持这些 Pod,这些 Pod 会被重新启动并拾取更改后的配置。

例如,下面的 ConfigMap 将由我们的控制器监控更改,并将重启所有具有标签 app 和值 webapp 的 Pod。例 22-1 中的 ConfigMap 用于我们的 Web 应用中提供欢迎信息。

  1. # 例 22-1 一个使用 ConfigMap 的 Web 应用的示例
  2. ---
  3. apiVersion: v1
  4. kind: ConfigMap
  5. metadata:
  6. name: webapp-config
  7. annotations:
  8. # 在例 22-2 中作为控制器选择器的注解,用来寻找要重新启动的应用程序 Pod
  9. k8spatterns.io/podDeleteSelector: "app=webapp"
  10. data:
  11. message: "Welcome to Kubernetes Patterns !"

我们的控制器 Shell 脚本现在会评估这个 ConfigMap。你可以在我们的 Git 仓库中找到源码的全部内容。简而言之,控制器启动一个悬挂的 GET HTTP 请求,用于打开一个无尽的 HTTP 响应流,以观察 API Server 推送给我们的生命周期事件。这些事件是以普通 JSON 对象的形式出现的,然后我们对其进行分析,以检测改变后的 ConfigMap 是否携带了我们的注释。当事件到达时,我们会删除所有与注解值的选择器相匹配的 Pod。让我们仔细看看控制器是如何工作的。

这个控制器的主要部分是调和循环,它监听 ConfigMap 生命周期事件,如例 22-2 所示:

  1. # 例 22-2 控制器脚本示例
  2. # 要观察的命名空间(如果没有给定,则为默认)
  3. namespace=${WATCH_NAMESPACE:-default}
  4. # 通过在同一 Pod 中运行的大使代理访问 Kubernetes API
  5. base=http://localhost:8001
  6. ns=namespaces/$namespace
  7. curl -N -s $base/api/v1/${ns}/configmaps?watch=true | \
  8. # 在 ConfigMap 上监视事件的循环
  9. while read -r event
  10. do
  11. # ...
  12. done

环境变量 WATCH_NAMESPACE 指定控制器应在其中监视 ConfigMap 更新的名称空间。我们可以在控制器本身的部署描述符中设置这个变量。在我们的例子中,我们使用第 13 章 “自我意识” 中描述的 Downward API 来监视控制器部署的命名空间,如例 22-3 中配置的控制器 Deployment 的一部分:

  1. # 例 22-3 从当前命名空间中提取的 WATCH_NAMESPACE
  2. ---
  3. env:
  4. - name: WATCH_NAMESPACE
  5. valueFrom:
  6. fieldRef:
  7. fieldPath: metadata.namespace

有了这个命名空间,控制器脚本就会构建到 Kubernetes API 端点的 URL 来观察 ConfigMaps。

:::info 请注意例 22-2 中的 watch=true 查询参数。这个参数向 API Server 表明不要关闭 HTTP 连接,而是在事件发生时立即沿着响应通道发送(挂起 GET 或 Comet 是这种技术的其他名称)。循环会在每个事件到达时将其作为一个单独的项目来处理。 :::

如你所见,我们的控制器通过 localhost 与Kubernetes API Server 进行联系。我们不会直接在 Kubernetes API 主节点上部署这个脚本,但是我们又怎么能在脚本中使用 localhost 呢?你可能已经猜到了,另一个模式在这里启动。我们将这个脚本和一个大使(Ambassador)控制器一起部署在 Pod 中,大使控制器在 localhost 上公开 8001 端口,并将其代理到真正的 Kubernetes 服务上。关于这个模式的更多细节,请参见第 17 章。我们在本章后面会详细看到用这个大使定义实际的 Pod。

当然,用这种方式观察事件并不是很稳健。连接可以随时停止,所以应该有办法重新开始循环。另外,还可能会错过事件,所以生产级控制器不仅要对事件进行观察,还应该不时地查询 API Server 的整个当前状态,并将其作为新的基础。为了演示模式,这样就足够了。

在循环内,执行例 22-4 所示的逻辑。

  1. # 例 22-4 控制器调和回路
  2. curl -N -s $base/api/v1/${ns}/configmaps?watch=true | \
  3. while read -r event
  4. do
  5. # 从事件中提取 ConfigMap 的类型和名称
  6. type=$(echo "$event" | jq -r '.type')
  7. config_map=$(echo "$event" | jq -r '.object.metadata.name')
  8. annotations=$(echo "$event" | jq -r '.object.metadata.annotations')
  9. if [ "$annotations" != "null" ]; then
  10. # 用 k8spatterns.io/podDeleteSelector 提取 ConfigMap 上的所有注释
  11. selector=$(echo $annotations | \
  12. jq -r "\
  13. to_entries |\
  14. .[] |\
  15. select(.key == \"k8spatterns.io/podDeleteSelector\") |\
  16. .value |\
  17. @uri \
  18. ")
  19. fi
  20. # 如果该事件表示 ConfigMap 的更新,并且附加了我们的注解,则找到所有与该标签选择器匹配的 Pod
  21. if [ $type = "MODIFIED" ] && [ -n "$selector" ]; then
  22. pods=$(curl -s $base/api/v1/${ns}/pods?labelSelector=$selector |\
  23. jq -r .items[].metadata.name)
  24. # 删除所有符合选择器的 Pod
  25. for pod in $pods; do
  26. curl -s -X DELETE $base/api/v1/${ns}/pods/$pod
  27. done
  28. fi
  29. done

首先,脚本提取事件类型,指定 ConfigMap 发生了什么动作。提取完 ConfigMap 后,我们用 jq 导出注释。jq 是一个很好的从命令行解析 JSON 文档的工具,脚本假设它在脚本运行的容器中可用。

如果 ConfigMap 有注释,我们通过使用一个更复杂的 jq 查询来检查注释 k8spatterns.io/podDeleteSelector。这个查询的目的是将注解值转换为 Pod 选择器,可以在下一步的 API 查询选项中使用:注解 k8spatterns.io/podDeleteSelector: "app=webapp" 被转换为app%3Dwebapp,作为 Pod 选择器使用。

如果脚本可以提取一个选择器,我们现在就可以直接使用它来选择要删除的 Pod。首先,我们查找所有与选择器相匹配的 Pod ,然后通过直接的 API 调用逐一删除它们。当然,这个基于 Shell 脚本的控制器并不是生产级的(例如,事件循环可以随时停止),但它很好地揭示了基本概念,对我们来说没有太多的模板代码。

剩下的工作是关于创建资源对象和容器镜像。控制器脚本本身存储在 ConfigMap config-watcher-controller 中,如果需要的话,以后可以很方便地进行编辑。

我们使用 Deployment 为我们的控制器创建一个带有两个容器的 Pod:

  • 一个 Kubernetes API 大使容器,在 localhost 上通过 8001 端口暴露 Kubernetes API。镜像 k8spatterns/kubeapi-proxy 是一个安装了本地 kubectl 的 Alpine Linux,并且 kubectl 代理在安装了适当的 CA 和令牌后启动。最初的版本 kubectl-proxy 是由 Marko Lukša 编写的,他在《Kubernetes in Action》中介绍了这个代理。
  • 执行脚本的主容器包含在刚刚创建的 ConfigMap 中。我们这里使用的是安装了 curljq 的 Alpine 基础镜像。

你可以在我们的示例 Git 仓库中找到 k8spatterns/kubeapi-proxyk8spatterns/curl-jq 镜像的 Dockerfile。

现在我们已经有了 Pod 的图像,最后一步是通过使用 Deployment 来部署控制器。我们可以在示例 22-5 中看到部署的主要部分(完整版本可以在我们的示例仓库中找到):

  1. # 例 22-5 控制器的 Deployment
  2. ---
  3. apiVersion: apps/v1
  4. kind: Deployment
  5. # ....
  6. spec:
  7. template:
  8. # ...
  9. spec:
  10. # 具有适当权限的服务账户,可以观看事件和重启 Pod
  11. serviceAccountName: config-watcher-controller
  12. containers:
  13. # 用于将本地主机代理到 Kubeserver API 的大使容器
  14. - name: kubeapi-proxy
  15. image: k8spatterns/kubeapi-proxy
  16. # 盛放所有工具和安装控制器脚本的主容器
  17. - name: config-watcher
  18. image: k8spatterns/curl-jq
  19. # ...
  20. # 调用控制器脚本的启动命令
  21. command:
  22. - "sh"
  23. - "/watcher/config-watcher-controller.sh"
  24. # 映射到持有我们脚本的 ConfigMap 的卷
  25. volumeMounts:
  26. - mountPath: "/watcher"
  27. name: config-watcher-controller
  28. volumes:
  29. # 将 ConfigMap 支持的卷挂载到主 Pod 中
  30. - name: config-watcher-controller
  31. configMap:
  32. name: config-watcher-controller-script

正如您所看到的,我们从之前创建的 ConfigMap 中挂载 config-watcher-controller-script,并直接将其用作主容器的启动命令。为了简单起见,我们省略了任何有效性和就绪性检查以及资源限制声明。此外,我们还需要一个 ServiceAccount 配置 config-watcher-controller 允许监视者控制者监视 ConfigMap。完整的安全设置请参考示例仓库。

现在让我们看看控制器的工作情况。为此,我们使用了一个简单的 Web 服务器,它将一个环境变量的值作为唯一的内容。基础镜像使用普通的 nc(netcat)来服务内容。你可以在我们的示例仓库中找到这个镜像的 Dockerfile。

我们用 ConfigMap 和 Deployment 部署 HTTP 服务器,如例 22-6 中的草图:

  1. # 例 22-6 带有部署和 ConfigMap 的 Web 应用示例
  2. ---
  3. apiVersion: v1
  4. # 用于保存要服务的数据的 ConfigMap
  5. kind: ConfigMap
  6. metadata:
  7. name: webapp-config
  8. annotations:
  9. # 触发网络应用重启 Pod 的注解
  10. k8spatterns.io/podDeleteSelector: "app=webapp"
  11. data:
  12. # 在 HTTP 响应中 Web 应用程序中使用的消息
  13. message: "Welcome to Kubernetes Patterns !"
  14. ---
  15. apiVersion: apps/v1
  16. # 网络应用的部署
  17. kind: Deployment
  18. # ...
  19. spec:
  20. # ...
  21. template:
  22. spec:
  23. containers:
  24. - name: app
  25. # 使用 netcat 进行 HTTP 服务的简单镜像
  26. image: k8spatterns/mini-http-server
  27. ports:
  28. - containerPort: 8080
  29. env:
  30. # 作为 HTTP 响应体使用的环境变量,并从监视的 ConfigMap 中获取
  31. - name: MESSAGE
  32. valueFrom:
  33. configMapKeyRef:
  34. name: webapp-config
  35. key: message

我们的 ConfigMap 控制器在一个普通 Shell 脚本中实现的例子到此结束。虽然这可能是本书中最复杂的例子,但它也表明,编写一个基本控制器并不需要太多。

显然,对于现实世界的场景,你会用真正的编程语言来编写这类控制器,因为它提供了更好的错误处理能力和其他高级功能。

一些讨论

综上所述,Controller 是一个主动调节的过程,它监控感兴趣的对象的世界期望状态和世界的实际状态。然后,它发送指令,试图改变世界的当前状态,使其更像期望状态。Kubernetes 的内部控制器使用了这种机制,你也可以用自定义控制器重用同样的机制。我们演示了编写一个自定义控制器所涉及的内容,以及它是如何运作和扩展 Kubernetes 平台形式的。

控制器之所以能够实现,是因为 Kubernetes 架构的高度模块化和事件驱动的特性。这种架构自然导致了控制器作为扩展点的解耦和异时方式。这里的显著好处是,我们在 Kubernetes 本身和任何扩展点之间有一个精确的技术边界。然而,控制器的异步性质的一个问题是,它们通常很难调试,因为事件的流动并不总是直接的。因此,你不能轻松地在控制器中设置断点来停止一切以检查特定情况。

在第 23 章中,您将了解相关的操作员模式,它建立在这种控制器模式的基础上,并提供了一种更灵活的配置操作的方法。

参考资料