本文发表于一年多前。旧文章可能包含过时内容。请检查页面中的信息自发布以来是否已变得不正确。

使用准入控制器在运行时检测容器漂移

Introductory illustration

插图作者:Munire Aireti

在 Box,我们使用 Kubernetes (K8s) 来管理数百个微服务,这些微服务使 Box 能够以 PB 级的规模传输数据。在部署过程中,我们运行 kube-applier 作为 GitOps 工作流的一部分,采用声明式配置和自动化部署。开发人员将他们的 K8s 应用清单声明到一个 Git 仓库中,在任何变更被合并并应用到我们的 K8s 集群之前,都需要通过代码审查和自动检查。然而,通过 kubectl exec 和其他类似命令,开发人员能够直接与运行中的容器交互,并改变它们已部署的状态。这种交互可能会规避我们在 CI/CD 管道中强制执行的变更控制和代码审查流程。此外,它还允许这些受影响的容器在生产环境中长期接收流量。

为了解决这个问题,我们开发了自己的 K8s 组件,名为 kube-exec-controller 及其对应的 kubectl 插件。它们协同工作,检测并终止可能发生突变的容器(由交互式 kubectl 命令引起),并将交互事件直接暴露给目标 Pod,以提高可见性。

针对交互式 kubectl 命令的准入控制

一旦请求发送到 K8s,API 服务器需要对其进行身份验证和授权才能继续。此外,K8s 还有一个独立的保护层,称为准入控制器,它可以在对象持久化到 etcd 之前拦截请求。API 服务器二进制文件中编译了各种预定义的准入控制器(例如,ResourceQuota 用于强制执行每个命名空间的硬性资源使用限制)。此外,还有两个动态准入控制器,名为MutatingAdmissionWebhookValidatingAdmissionWebhook,分别用于改变或验证 K8s 请求。我们采用的是后者,用于检测由交互式 kubectl 命令引起的运行时容器漂移。整个过程可以分为三个步骤,详述如下。

1. 准入交互式 kubectl 命令请求

首先,我们需要启用一个验证性 webhook,将符合条件的请求发送到 kube-exec-controller。为了添加专门适用于交互式 kubectl 命令的新验证机制,我们将 webhook 的规则配置为资源为 [pods/exec, pods/attach],操作为 CONNECT。这些规则告诉集群的 API 服务器,所有 execattach 请求都应受到我们的准入控制 webhook 的约束。在我们配置的 ValidatingAdmissionWebhook 中,我们在 clientConfig 节下指定了一个 service 引用(也可以用指定 webhook 位置的 url 替代)和 caBundle,以允许验证其 X.509 证书。

以下是我们的 ValidatingWebhookConfiguration 对象的一个简短示例

apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingWebhookConfiguration
metadata:
  name: example-validating-webhook-config
webhooks:
  - name: validate-pod-interaction.example.com
    sideEffects: None
    rules:
      - apiGroups: ["*"]
        apiVersions: ["*"]
        operations: ["CONNECT"]
        resources: ["pods/exec", "pods/attach"]
    failurePolicy: Fail
    clientConfig:
      service:
        # reference to kube-exec-controller service deployed inside the K8s cluster
        name: example-service
        namespace: kube-exec-controller
        path: "/admit-pod-interaction"
      caBundle: "{{VALUE}}" # PEM encoded CA bundle to validate kube-exec-controller's certificate
    admissionReviewVersions: ["v1", "v1beta1"]

2. 为目标 Pod 标记可能发生突变的容器

一旦 kubectl exec 请求进入,kube-exec-controller 会在内部做一个记录,为相关的 Pod 添加标签。添加的标签意味着我们不仅可以查询所有受影响的 Pod,还能启用安全机制来检索先前识别的 Pod,以防控制器服务本身被重启。

准入控制过程不能在其准入响应中直接修改目标对象。这是因为 pods/exec 请求是针对 Pod API 的一个子资源,而该子资源的 API kind 是 PodExecOptions。因此,在 kube-exec-controller 中有一个独立的进程来异步地修补标签。准入控制总是允许 exec 请求,然后作为 K8s API 的客户端为目标 Pod 添加标签并记录相关事件。开发人员可以使用 kubectl 或类似工具检查他们的 Pod 是否受到影响。例如

$ kubectl get pod --show-labels
NAME      READY  STATUS   RESTARTS  AGE  LABELS
test-pod  1/1    Running  0         2s   box.com/podInitialInteractionTimestamp=1632524400,box.com/podInteractorUsername=username-1,box.com/podTTLDuration=1h0m0s

$ kubectl describe pod test-pod
...
Events:
Type       Reason            Age     From                            Message
----       ------            ----    ----                            -------
Warning    PodInteraction    5s      admission-controller-service    Pod was interacted with 'kubectl exec' command by user 'username-1' initially at time 2021-09-24 16:00:00 -0800 PST
Warning    PodInteraction    5s      admission-controller-service    Pod will be evicted at time 2021-09-24 17:00:00 -0800 PST (in about 1h0m0s).

3. 在预定义的时间段后驱逐目标 Pod

正如你在上面的事件消息中所看到的,受影响的 Pod 不会立即被驱逐。有时,开发人员可能必须进入他们正在运行的容器来调试一些线上问题。因此,我们根据 Pod 运行的集群环境定义了一个受影响 Pod 的生存时间(TTL)。特别地,我们在开发集群中允许更长的时间,因为在积极开发过程中运行 kubectl exec 或其他交互式命令更为常见。

对于我们的生产集群,我们指定一个较低的时间限制,以避免受影响的 Pod 持续提供流量。kube-exec-controller 内部为每个匹配的 Pod 根据关联的 TTL 设置并跟踪一个计时器。一旦计时器到期,控制器会使用 K8s API 驱逐该 Pod。采用驱逐(而不是删除)是为了确保服务可用性,因为集群会遵守任何已配置的PodDisruptionBudget (PDB)。例如,如果用户在其 PDB 中将 x 个 Pod 定义为关键,当目标工作负载运行的 Pod 少于 x 个时,驱逐(由 kube-exec-controller 请求)将不会继续。

下面是上述整个工作流程的序列图

Sequence Diagram

一个新的 kubectl 插件以提供更好的用户体验

我们的准入控制器组件在解决我们平台上的容器漂移问题方面表现出色。它还能够将所有相关事件提交给受影响的目标 Pod。然而,K8s 集群不会长时间保留事件(默认保留期为一小时)。我们需要为开发人员提供其他方式来获取他们的 Pod 交互活动。一个 kubectl 插件 是我们暴露这些信息的完美选择。我们将我们的插件命名为 kubectl pipod-interaction 的缩写),并提供两个子命令:getextend

当调用 get 子命令时,插件会检查我们准入控制器附加的元数据,并将其转换为人类可读的信息。以下是运行 kubectl pi get 的一个示例输出

$ kubectl pi get test-pod
POD-NAME  INTERACTOR  POD-TTL  EXTENSION  EXTENSION-REQUESTER  EVICTION-TIME
test-pod  username-1  1h0m0s   /          /                    2021-09-24 17:00:00 -0800 PST

该插件还可以用于延长已标记为将来驱逐的 Pod 的 TTL。这在开发人员需要额外时间来调试正在发生的问题时非常有用。为此,开发人员使用 kubectl pi extend 子命令,插件会为给定的 Pod 修补相关的*注解*。这些*注解*包括延长的持续时间和请求延长的用户名,以保证透明性(显示在 kubectl pi get 命令返回的表格中)。

相应地,在 kube-exec-controller 中定义了另一个 webhook,它会准入有效的注解更新。一旦被准入,这些更新会根据请求重置目标 Pod 的驱逐计时器。开发人员请求延长的一个示例如下

$ kubectl pi extend test-pod --duration=30m
Successfully extended the termination time of pod/test-pod with a duration=30m
 
$ kubectl pi get test-pod
POD-NAME  INTERACTOR  POD-TTL  EXTENSION  EXTENSION-REQUESTER  EVICTION-TIME
test-pod  username-1  1h0m0s   30m        username-2           2021-09-24 17:30:00 -0800 PST

未来的改进

尽管我们的准入控制器服务在处理对 Pod 的交互式请求方面表现出色,但它也可能在实际命令是空操作(no-op)的情况下驱逐 Pod。例如,开发人员有时运行 kubectl exec 仅仅是为了检查存储在主机上的服务日志。然而,尽管容器的状态完全没有改变,目标 Pod 仍然会被重启。这里的改进之一可以是增加区分传递给交互式请求的命令的能力,这样空操作命令就不应该总是强制驱逐 Pod。然而,当开发人员进入正在运行的容器的 shell 并在 shell 内部执行命令时,这就变得具有挑战性,因为这些命令将不再对我们的准入控制器服务可见。

另一个值得指出的地方是 K8s *标签*和*注解*的选择。在我们的设计中,我们决定将所有不可变的元数据作为*标签*附加,以便在我们的准入控制中更好地强制执行不变性。然而,其中一些元数据可能更适合作为*注解*。例如,我们有一个键为 box.com/podInitialInteractionTimestamp 的标签,用于在 kube-exec-controller 代码中列出所有受影响的 Pod,尽管其值不太可能被查询。作为一个在 K8s 世界中更理想的设计,在我们的案例中,使用单个*标签*进行识别,而将其他元数据作为*注解*应用,可能会更可取。

总结

借助准入控制器的强大功能,我们能够通过在运行时检测可能发生突变的容器来保护我们的 K8s 集群,并在不影响服务可用性的情况下驱逐它们的 Pod。我们还利用 kubectl 插件来提供驱逐时间的灵活性,从而为服务所有者带来更好、更自主的体验。我们自豪地宣布,我们已经将整个项目开源,供社区在他们自己的 K8s 集群中利用。我们非常欢迎并感谢任何贡献。你可以在 GitHub 上找到这个项目:https://github.com/box/kube-exec-controller

特别感谢 Ayush Sobti 和 Ethan Goldblum 在此项目上的技术指导。