一、简介

使用 Kubernetes 一段时间后,我们很快就会意识到其中涉及大量样板代码。即使对于简单的服务,我们也需要提供所有必需的详细信息,通常采用相当详细的 YAML 文档的形式。

此外,在处理给定环境中部署的多个服务时,这些 YAML 文档往往包含大量重复元素。例如,我们可能想要向所有部署添加给定的 ConfigMap 或一些 sidecar 容器。

在本文中,我们将探讨如何坚持 DRY 原则并使用 Kubernetes 准入控制器避免所有这些重复代码。

2.什么是准入控制器?

准入控制器是 Kubernetes 使用的一种机制,用于在 API 请求经过身份验证之后、执行之前对其进行预处理。

API 服务器进程 ( kube-apiserver ) 已经附带了多个内置控制器,每个控制器负责 API 处理的特定方面。

AllwaysPullImage 就是一个很好的例子:这个准入控制器会修改 pod 创建请求,因此无论通知值如何,镜像拉取策略都会变为“始终”。 Kubernetes 文档包含标准准入控制器的完整列表。

除了那些实际上作为 kubeapi-server 进程的一部分运行的内置控制器之外,Kubernetes 还支持外部准入控制器。 在这种情况下,准入控制器只是一个处理来自 API 服务器的请求的 HTTP 服务。

此外,这些外部准入控制器可以动态添加和删除,因此称为动态准入控制器。这导致处理管道如下所示:

k8s 准入控制器

在这里,我们可以看到传入的 API 请求一旦经过身份验证,就会通过每个内置的准入控制器,直到到达持久层。

3. 准入控制器类型

目前,有两种类型的准入控制器:

  • 改变准入控制器
  • 验证准入控制器

正如它们的名字所暗示的,主要区别在于它们对传入请求的处理类型。 变异控制器可以在将请求传递到下游之前修改请求,而验证控制器只能验证它们。

关于这些类型的一个重要点是 API 服务器执行它们的顺序:首先是变异控制器,然后是验证控制器。这是有道理的,因为只有在我们收到最终请求后才会进行验证,并且可能会被任何变异控制器更改。

3.1.入学审查请求

内置准入控制器(变异和验证)使用简单的 HTTP 请求/响应模式与外部准入控制器进行通信:

  • 请求:一个 AdmissionReview JSON对象,在其 请求 属性中包含要处理的API调用
  • 响应:一个在其 响应 属性中包含结果的 AdmissionReview JSON对象

下面是一个请求的示例:

{
  "kind": "AdmissionReview",
  "apiVersion": "admission.k8s.io/v1",
  "request": {
    "uid": "c46a6607-129d-425b-af2f-c6f87a0756da",
    "kind": {
      "group": "apps",
      "version": "v1",
      "kind": "Deployment"
    },
    "resource": {
      "group": "apps",
      "version": "v1",
      "resource": "deployments"
    },
    "requestKind": {
      "group": "apps",
      "version": "v1",
      "kind": "Deployment"
    },
    "requestResource": {
      "group": "apps",
      "version": "v1",
      "resource": "deployments"
    },
    "name": "test-deployment",
    "namespace": "test-namespace",
    "operation": "CREATE",
    "object": {
      "kind": "Deployment",
      ... deployment fields omitted
    },
    "oldObject": null,
    "dryRun": false,
    "options": {
      "kind": "CreateOptions",
      "apiVersion": "meta.k8s.io/v1"
    }
  }
}

在可用的字段中,有一些特别重要:

  • 操作 :告诉这个请求是否会创建、修改或删除资源
  • object: 正在处理的资源的规范详细信息。
  • oldObject: 修改或删除资源时,该字段包含现有资源

预期响应也是一个 AdmissionReview JSON 对象,带有 响应 字段而不是 响应:

{
  "apiVersion": "admission.k8s.io/v1",
  "kind": "AdmissionReview",
  "response": {
    "uid": "c46a6607-129d-425b-af2f-c6f87a0756da",
    "allowed": true,
    "patchType": "JSONPatch",
    "patch": "W3sib3A ... Base64 patch data omitted"
  }
}

让我们剖析 响应 对象的字段:

  • uid :该字段的值必须与传入 请求 字段中存在的相应字段匹配
  • allowed: 审核操作的结果。 true 表示API调用处理可以进行下一步
  • patchType: 仅对变异准入控制器有效。表示 AdmissionReview 请求返回的补丁类型
  • patch :要应用于传入对象的补丁。下一节详细介绍

3.2.补丁数据

变异准入控制器的响应中存在的 补丁 字段告诉 API 服务器在请求继续之前需要更改哪些内容 。它的值是一个 Base64 编码的JSONPatch对象,其中包含 API 服务器用来修改传入 API 调用主体的指令数组:

[
  {
    "op": "add",
    "path": "/spec/template/spec/volumes/-",
    "value":{
      "name": "migration-data",
      "emptyDir": {}
    }
  }
]

在此示例中,我们有一条指令将卷附加到部署规范的 数组中。 处理补丁时的一个常见问题是,除非元素已存在于原始对象中,否则无法将元素添加到现有数组中 。在处理 Kubernetes API 对象时,这尤其令人烦恼,因为最常见的对象(例如部署)包含可选数组。

例如,前一示例仅当传入 部署 已具有至少一个卷时才有效。如果不是这种情况,我们就必须使用稍微不同的指令:

[
  {
    "op": "add",
    "path": "/spec/template/spec/volumes",
    "value": [{
      "name": "migration-data",
      "emptyDir": {}
    }]
  }
]

在这里,我们定义了一个新的 字段,其值是包含卷定义的数组。以前,该值是一个对象,因为这是我们附加到现有数组的内容。

4. 示例用例:等待

现在我们对准入控制器的预期行为有了基本的了解,让我们编写一个简单的示例。 Kubernetes 中的一个常见问题是管理运行时依赖项,尤其是在使用微服务架构时。例如,如果某个特定的微服务需要访问数据库,那么如果前者处于脱机状态,则启动就没有意义。

为了解决此类问题,我们可以在启动主容器之前将 initContainerpod 一起使用来执行此检查 。一种简单的方法是使用流行的 wait-for-it shell 脚本,也可以作为docker 镜像使用。

该脚本采用 主机名端口 参数并尝试连接到它。如果测试成功,容器将退出并显示成功状态代码,并且 Pod 初始化将继续进行。否则,它将失败,并且关联的控制器将根据定义的策略继续重试。外部化此飞行前检查的一个很酷的事情是,任何关联的 Kubernetes 服务都会注意到该故障。因此,不会向其发送任何请求,从而可能提高整体弹性。

4.1.准入控制器案例

这是添加了 wait-forit init 容器的典型部署:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: frontend
  labels:
    app: nginx
spec:
  replicas: 1
  selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        app: nginx
    spec:
      initContainers:
      - name: wait-backend
        image: willwill/wait-for-it
        args:
        -

虽然没有那么复杂(至少在这个简单的示例中),但向每个部署添加相关代码有一些缺点。 特别是,我们给部署作者施加了负担,要求他们准确指定如何进行依赖性检查 。相反,更好的体验只需要定义应该测试的 内容

输入我们的准入控制器。 为了解决这个用例,我们将编写一个变异准入控制器,用于查找资源中是否存在特定注释,并在存在时将 initContainer 添加到其中 。带注释的部署规范如下所示:

apiVersion: apps/v1 
kind: Deployment 
metadata: 
  name: frontend 
  labels: 
    app: nginx 
  annotations:
    com.baeldung/wait-for-it: "www.google.com:80"
spec: 
  replicas: 1 
  selector: 
    matchLabels: 
      app: nginx 
  template: 
    metadata: 
      labels: 
        app: nginx 
    spec: 
      containers:
      - name: nginx
        image: nginx:1.14.2
        ports:
          - containerPort: 80

在这里,我们使用注释 com.baeldung/wait-for-it 来指示我们必须测试的主机和端口。 但重要的是,没有任何东西告诉我们应该 如何 进行测试。 理论上,我们可以以任何方式更改测试,同时保持部署规范不变。

现在,让我们继续实施。

4.2.项目结构

如前所述,外部准入控制器只是一个简单的 HTTP 服务。因此,我们将创建一个 Spring Boot 项目作为我们的基本结构。对于此示例,我们需要的只是Spring Web Reactive启动器,但是对于实际应用程序,添加Actuator和/或某些Cloud Config依赖项等功能也可能很有用。

4.3.处理请求

准入请求的入口点是一个简单的 Spring REST 控制器,它将传入负载的处理委托给服务:

@RestController
@RequiredArgsConstructor
public class AdmissionReviewController {

    private final AdmissionService admissionService;

    @PostMapping(path = "/mutate")
    public Mono<AdmissionReviewResponse> processAdmissionReviewRequest(@RequestBody Mono<ObjectNode> request) {
        return request.map((body) -> admissionService.processAdmission(body));
    }
}

在这里,我们使用 ObjectNode 作为输入参数。这意味着我们将尝试处理 API 服务器发送的任何格式良好的 JSON。 这种宽松方法的原因是,截至撰写本文时,仍然没有为此有效负载发布官方模式 。在这种情况下,使用非结构化类型意味着一些额外的工作,但可以确保我们的实现更好地处理特定 Kubernetes 实现或版本决定扔给我们的任何额外字段。

此外,考虑到请求对象可以是 Kubernetes API 中的任何可用资源,因此在此处添加太多结构并没有多大帮助。

4.4.修改入学申请

处理的核心发生在 AdmissionService 类中。这是一个使用单个公共方法注入到控制器中的 @Component 类: processAdmission。 此方法处理传入的审阅请求并返回适当的响应。

完整的代码可以在线获取,基本上由一长串 JSON 操作组成。其中大多数都是微不足道的,但一些摘录值得一些解释:

if (admissionControllerProperties.isDisabled()) {
    data = createSimpleAllowedReview(body);
} else if (annotations.isMissingNode()) {
    data = createSimpleAllowedReview(body);
} else {
    data = processAnnotations(body, annotations);
}

首先,为什么要添加“disabled”属性? 事实证明,在某些高度受控的环境中,更改现有部署的配置参数可能比删除和/或更新它要容易得多 。由于我们使用 @ConfigurationProperties 机制来填充此属性,因此它的实际值可以来自各种来源。

接下来,我们测试缺少的注释,我们将其视为我们应该保持部署不变的标志。这种方法确保了我们在这种情况下想要的“选择加入”行为。

另一个有趣的片段来自 injectInitContainer() 方法中的JSONPatch生成逻辑:

JsonNode maybeInitContainers = originalSpec.path("initContainers");
ArrayNode initContainers = 
maybeInitContainers.isMissingNode() ?
  om.createArrayNode() : (ArrayNode) maybeInitContainers;
ArrayNode patchArray = om.createArrayNode();
ObjectNode addNode = patchArray.addObject();

addNode.put("op", "add");
addNode.put("path", "/spec/template/spec/initContainers");
ArrayNode values = addNode.putArray("values");
values.addAll(initContainers);

由于无法保证传入规范包含 initContainers 字段,因此我们必须处理两种情况:它们可能丢失或存在。如果缺少,我们使用 ObjectMapper 实例 (上面代码片段中的 om )创建一个新的 ArrayNode 。否则,我们只使用传入的数组。

为此,我们可以使用单个“添加”补丁指令。 尽管有其名称,但其行为是创建该字段或替换具有相同名称的现有字段value 字段始终是一个数组,其中包括(可能为空)原始 initContainers 数组。最后一步添加实际的 等待容器

ObjectNode wfi = values.addObject();
wfi.put("name", "wait-for-it-" + UUID.randomUUID())
// ... additional container fields added (omitted)

由于容器名称在 pod 内必须是唯一的,因此我们只需将随机 UUID 添加到固定前缀即可。这可以避免与现有容器发生任何名称冲突。

4.5.部署

开始使用准入控制器的最后一步是将其部署到目标 Kubernetes 集群。正如预期的那样,这需要编写一些 YAML 或使用Terraform等工具。无论哪种方式,这些都是我们需要创建的资源:

  • 运行我们的准入控制器的 部署 。运行该服务的多个副本是一个好主意,因为故障可能会阻止任何新部署的发生
  • 将来自 API Server 的请求路由到运行准入控制器的可用 Pod 的 服务
  • MutatingWebhookConfiguration 资源,描述哪些 API 调用应路由到我们的 服务

例如,假设我们希望 Kubernetes 在每次创建或更新部署时使用我们的准入控制器。在 MutatingWebhookConfiguration 文档中,我们将看到如下 规则 定义:

apiVersion: admissionregistration.k8s.io/v1
kind: MutatingWebhookConfiguration
metadata:
  name: "wait-for-it.baeldung.com"
webhooks:
- name: "wait-for-it.baeldung.com"
  rules:
  - apiGroups:   ["*"]
    apiVersions: ["*"]
    operations:  ["CREATE","UPDATE"]
    resources:   ["deployments"]
  ... other fields omitted

关于我们的服务器的重要一点:Kubernetes 需要 HTTPS 来与外部准入控制器进行通信 。这意味着我们需要为 SpringBoot 服务器提供正确的证书和私钥。请检查用于部署示例准入控制器的 Terraform 脚本,以了解执行此操作的一种方法。

另外,一个快速提示: 虽然文档中没有提及,但某些 Kubernetes 实现(例如 GCP)需要使用端口 443 ,因此我们需要更改 SpringBoot HTTPS 端口的默认值(8443)。

4.6.测试

一旦我们准备好部署工件,就终于可以在现有集群中测试我们的准入控制器了。在我们的例子中,我们使用 Terraform 来执行部署,因此我们所要做的就是 apply

$ terraform apply -auto-approve

完成后,我们可以使用 kubectl 检查部署和准入控制器状态:

$ kubectl get mutatingwebhookconfigurations
NAME                               WEBHOOKS   AGE
wait-for-it-admission-controller   1          58s
$ kubectl get deployments wait-for-it-admission-controller         
NAME                               READY   UP-TO-DATE   AVAILABLE   AGE
wait-for-it-admission-controller   1/1     1            1           10m

现在,让我们创建一个简单的 nginx 部署,包括我们的注释:

$ kubectl apply -f nginx.yaml
deployment.apps/frontend created

我们可以检查相关日志来查看 wait-for-it init 容器确实被注入了:

 $ kubectl logs --since=1h --all-containers deployment/frontend
wait-for-it.sh: waiting 15 seconds for www.google.com:80
wait-for-it.sh: www.google.com:80 is available after 0 seconds

为了确定起见,我们检查一下部署的 YAML:

$ kubectl get deployment/frontend -o yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  annotations:
    com.baeldung/wait-for-it: www.google.com:80
    deployment.kubernetes.io/revision: "1"
        ... fields omitted
spec:
  ... fields omitted
  template:
      ... metadata omitted
    spec:
      containers:
      - image: nginx:1.14.2
        name: nginx
                ... some fields omitted
      initContainers:
      - args:
        - www.google.com:80
        image: willwill/wait-for-it
        imagePullPolicy: Always
        name: wait-for-it-b86c1ced-71cf-4607-b22b-acb33a548bb2
    ... fields omitted
      ... fields omitted
status:
  ... status fields omitted

此输出显示了我们的准入控制器添加到部署中的 initContainer

5. 结论

在本文中,我们介绍了如何使用 Java 创建 Kubernetes 准入控制器并将其部署到现有集群。

与往常一样,示例的完整源代码可以在 GitHub 上找到。