一、简介
使用 Kubernetes 一段时间后,我们很快就会意识到其中涉及大量样板代码。即使对于简单的服务,我们也需要提供所有必需的详细信息,通常采用相当详细的 YAML 文档的形式。
此外,在处理给定环境中部署的多个服务时,这些 YAML 文档往往包含大量重复元素。例如,我们可能想要向所有部署添加给定的 ConfigMap 或一些 sidecar 容器。
在本文中,我们将探讨如何坚持 DRY 原则并使用 Kubernetes 准入控制器避免所有这些重复代码。
2.什么是准入控制器?
准入控制器是 Kubernetes 使用的一种机制,用于在 API 请求经过身份验证之后、执行之前对其进行预处理。
API 服务器进程 ( kube-apiserver ) 已经附带了多个内置控制器,每个控制器负责 API 处理的特定方面。
AllwaysPullImage 就是一个很好的例子:这个准入控制器会修改 pod 创建请求,因此无论通知值如何,镜像拉取策略都会变为“始终”。 Kubernetes 文档包含标准准入控制器的完整列表。
除了那些实际上作为 kubeapi-server 进程的一部分运行的内置控制器之外,Kubernetes 还支持外部准入控制器。 在这种情况下,准入控制器只是一个处理来自 API 服务器的请求的 HTTP 服务。
此外,这些外部准入控制器可以动态添加和删除,因此称为动态准入控制器。这导致处理管道如下所示:
在这里,我们可以看到传入的 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 中的一个常见问题是管理运行时依赖项,尤其是在使用微服务架构时。例如,如果某个特定的微服务需要访问数据库,那么如果前者处于脱机状态,则启动就没有意义。
为了解决此类问题,我们可以在启动主容器之前将 initContainer 与 pod 一起使用来执行此检查 。一种简单的方法是使用流行的 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 上找到。