正如我们在前两章中提到的,准入控制是 API 请求入驻的第三阶段。当我们到达 API 请求生命周期的这个阶段时,我们已经确定该请求来自一个真实的、经过认证的用户,并且该用户被授权执行该请求。现在我们关心的是这个请求是否符合我们认为的有效请求的标准,如果不符合,我们应该采取什么行动。我们应该完全拒绝这个请求,还是应该改变它以满足我们的业务标准?对于熟悉 API 中间件概念的人来说,准入控制器的功能非常相似。

虽然身份验证和准入控制都是成功部署的关键,但作为管理员,你可以真正开始处理用户的工作负载。在这里,你能够限制资源,执行策略,并启用高级功能。这有助于提高利用率,为不同的工作负载增加一些理性,并无缝集成新技术。

幸运的是,就像其他两个阶段一样,Kubernetes 在开箱即提供了广泛的接纳功能。虽然认证和授权在不同版本之间变化不大,但准入控制却恰恰相反。在用户如何管理他们的集群时,有一个似乎永无止境的功能列表。而且,因为准入控制是大部分神奇功能发生的地方,所以这个组件不断发展也就不足为奇了。

我们可以写书来介绍 Kubernetes 的原生准入控制功能。然而,因为这并不真正实用,所以在这里,我们专注于一些比较流行的控制器,以及演示如何实现你自己的控制器。

启用配置

启用准入控制非常简单。由于这是一个 API 函数,我们在 kube-apiserver 运行时参数中添加 --enable-admission-plugins 标志。这和其他配置项一样,是一个以逗号分隔的列表,列出了我们要启用的接纳控制器。

:::info 在 Kubernetes 1.10 之前,指定接纳控制器的顺序很重要。随着 --enable-admission-plugins 命令行参数的引入,这种情况不再发生。对于 1.9 及更早的版本,你应该使用依赖于顺序的 --admission-control 参数。 :::

通用控制器

在 Kubernetes 中,用户认为理所当然的很多功能其实都是通过准入控制器的方式来实现的。例如,ServiceAccount 准入控制器会自动为 ServiceAccount 分配 Pod。同样,如果你曾试图向一个当前处于终止状态的 Namespace 添加新资源,你的请求很可能被 NamespaceLifecycle 控制器拒绝。

Kubernetes 开箱即用的准入控制器有两个主要目标:确保在没有用户指定值的情况下利用合理的默认值,以及确保用户拥有的能力不会超过他们需要的能力。用户被授权执行的许多操作都是由 RBAC 控制的,但准入控制器允许管理员定义额外的细粒度策略,这些策略超越了授权提供的简单的资源、动作和主题策略。

PodSecurityPolicy

PodSecurityPolicy 控制器是使用最广泛的准入控制器之一。通过这个控制器,管理员可以指定 Kubernetes 控制下的进程的约束。通过 PodSecurityPolicy,管理员可以强制要求 Pod 不能在特权上下文中运行,不能绑定到 hostNetwork 上,必须以特定用户的身份运行,并通过其他各种以安全为核心的属性进行约束。

当 PodSecurityPolicy 被启用时,除非有授权的政策,否则用户无法登录新的 Pod。政策可以是允许的或限制的,因为你的组织的安全态势要求。在生产的多用户环境中,管理员应该使用 PodSecurityPolicy 提供的大多数策略,因为这些策略可以显著提高整体集群的安全性。

让我们考虑一个简单而典型的案例,我们希望确保 Pod 不能在特权环境下运行。像往常一样,通过 Kubernetes API 来定义策略:

  1. apiVersion: policy/v1beta1
  2. kind: PodSecurityPolicy
  3. metadata:
  4. name: non-privileged
  5. spec:
  6. privileged: false

如果你要创建这个策略,将它应用到 API Server,然后尝试创建一个符合要求的 Pod,请求会被拒绝,因为你的用户和/或服务账户没有权限使用这个策略。要纠正这种情况,只需创建一个 RBAC Role 和 RoleBinding,允许这些主体类型之一使用这个 PodSecurityPolicy:

---
kind: Role
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  name: non-privileged-user
  namespace: user-namespace
rules:
- apiGroups: ['policy']
  resources: ['podsecuritypolicies']
  verbs:     ['use']
  resources:
  - non-privileged

---
kind: RoleBinding
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  name: non-privileged-user
  namespace: user-namespace
roleRef:
  kind: Role
  name: non-privileged-user
  apiGroup: rbac.authorization.k8s.io
subjects:
- kind: ServiceAccount
  name: some-service-account
  namespace: user-namespace

在用户被授权使用 PodSecurityPolicy 后,只要符合定义的策略,就可以声明 Pod。

:::info 由于 PodSecurityPolicy 是作为一个准入控制器来实现的(在 API 请求流中执行政策),在 PodSecurityPolicy 被启用之前已经被安排的 Pod 可能不再符合。请记住这一点,因为这些 Pod 的重启可能会导致它们无法被安排。理想情况下,PodSecurityPolicy 准入控制器在安装时启用。 :::

ResourceQutoa

一般来说,在集群上执行配额是个好做法。配额确保没有一个用户能够使用超过她所分配的资源,是推动集群整体利用率的一个关键组成部分。如果您打算执行用户配额,您还应该启用 ResourceQuota 控制器。

该控制器确保任何新声明的 Pod 首先根据给定命名空间的当前配额利用率进行评估。通过在工作负载入职期间执行此检查,我们会立即通知用户他的 Pod 将或不适合配额。还请注意,当为一个命名空间定义配额时,所有的 Pod 定义(即使源自其他资源,如部署或 ReplicaSet)都需要指定资源请求和限制。

配额可以为一个不断扩大的资源列表实现,但一些最常见的资源包括 CPU、内存和卷。也可以对 Namespace 中不同的 Kubernetes 资源(如 Pod、Deployments、Job 等)的数量设置配额。

配置配额是很直接的:

apiVersion: v1
kind: ResourceQuota
metadata:
  name: memoryquota
  namespace: memoryexample
spec:
  hard:
    requests.memory: 256Mi
    limits.memory: 512Mi

现在,如果我们试图超过限制,即使是单个 Pod,我们的声明也会立即被 ResourceQuota 准入控制器拒绝:

$ cat pod.yml
apiVersion: v1
kind: Pod
metadata:
  name: nginx
  namespace: memoryexample
  labels:
    app: nginx
spec:
  containers:
  - name: nginx
    image: nginx
    ports:
    - containerPort: 80
    resources:
      limits:
        memory: 1Gi
      requests:
        memory: 512Mi

$ kubectl apply -f pod.yml
Error from server (Forbidden): error when creating "pod.yml": pods "nginx" is
forbidden: exceeded quota: memoryquota, requested:
limits.memory=1Gi,requests.memory=512Mi, used: limits.memory=0,requests.memory=0,
limited: limits.memory=512Mi,requests.memory=256Mi

虽然不那么明显,但对于通过 Deployment 等高阶资源创建的 Pod 也是如此:

$ cat deployment.yml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-deployment
  namespace: memoryexample
  labels:
    app: nginx
spec:
  replicas: 3
  selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
      - name: nginx
        image: nginx
        ports:
        - containerPort: 80
        resources:
          limits:
            memory: 256Mi
          requests:
            memory: 128Mi

$ kubectl apply -f deployment.yml
deployment.apps "nginx-deployment" configured

$ kubectl get po -n memoryexample
NAME                                READY     STATUS    RESTARTS   AGE
nginx-deployment-55dd98c6c8-9xmjn   1/1       Running   0          25s
nginx-deployment-55dd98c6c8-hc2pf   1/1       Running   0          24s

尽管我们指定了三个副本,但根据配额,我们只能满足两个。如果我们描述一下结果的 ReplicaSet,我们就会看到失败:

Warning  FailedCreate      3s (x4 over 3s)  replicaset-controller
(combined from similar events): Error creating: pods
"nginx-deployment-55dd98c6c8-tkrtz" is forbidden: exceeded quota:
memoryquota, requested: limits.memory=256Mi,requests.memory=128Mi,
used: limits.memory=512Mi,requests.memory=256Mi, limited:
limits.memory=512Mi,requests.memory=256Mi

同样,这个错误源于 ResourceQuota 准入控制器,但这一次,错误在某种程度上是隐藏的,因为它被返回到 Deployment 的 ReplicaSet(它是 Pod 的创建者)。

到现在,可能已经很清楚,配额可以帮助你有效地管理你的资源。

LimitRange

作为 ResourceQuota 的补充,如果您对 Namespace 定义了任何 LimitRange 策略,LimitRange 准入控制器是必要的。简单地说,LimitRange 允许您为声明为特定 Namespace 成员的 Pod设 置默认资源限制。

apiVersion: v1
kind: LimitRange
metadata:
  name: default-mem
spec:
  limits:
  - default:
      memory: 1024Mi
    defaultRequest:
      memory: 512Mi
    type: Container

该功能在已定义配额的情况下非常重要。当启用配额时,一个没有定义资源限制的用户的 Pod 请求会被拒绝。有了 LimitRange 准入控制器,一个没有定义资源限制的 Pod 将被赋予默认值(由管理员定义),Pod 将被接受。

动态准入控制器

到目前为止,我们已经关注了 Kubernetes 本身提供的准入控制器。然而,有的时候,可能原生功能还不能满足需要。在这样的场景下,我们需要开发额外的功能,帮助我们实现业务目标。幸运的是,Kubernetes 支持广泛的可扩展点,对于准入控制器也是如此。

动态准入控制是我们向准入控制管道注入自定义业务逻辑的机制。动态准入控制有两种类型:验证型和转换型。

对于验证型准入控制,我们的业务逻辑只是根据我们的要求,接受或拒绝用户的请求。在失败的情况下,会给用户返回一个合适的 HTTP 状态码和失败原因。我们把声明符合资源规范的责任放在最终用户身上,希望这样做不会引起不安。

在转换准入控制器的情况下,我们再次针对 API Server 评估请求,但在这种情况下,我们有选择地改变声明以满足我们的目标。在简单的情况下,这可能是一些简单的事情,比如将一系列众所周知的标签应用到资源中。在更复杂的情况下,我们可能会走得更远,以至于透明地注入一个 Sidecar 容器。虽然在这种情况下,我们为最终用户承担了很多负担,但当用户发现一些额外的魔法正在幕后发生时,有时会让他感到有点困惑。也就是说,这种功能如果得到很好的记录,对于实现高级架构来说是至关重要的。

在这两种情况下,这种功能都是通过用户定义的 Webhook 来实现的。这些下游的 Webhook 在看到一个合格的请求被发出时,会被 API Server 调用。正如我们将在下面的例子中看到的,用户能够以类似于定义 RBAC 策略的方式来限定请求)。API Server 向这些 Webhook POST 一个AdmissionReview 对象。这个请求的主体包括原始请求、对象的状态和关于请求用户的元数据。

而 Webhook 则提供一个简单的 AdmissionResponse 对象。这个对象包括这个请求是否被允许的字段,失败的原因和代码,甚至包括一个转换补丁的样子的字段。

为了利用动态准入控制器,你必须首先通过更改 --enable-admission-plugins 参数来配置 API Server:

--enable-admission-plugins=...,MutatingAdmissionWebhook,ValidatingAdmissionWebhook

验证准入控制器

让我们来看看如何实现我们自己的验证准入控制器,并重用前面的例子。我们的控制器将检查所有的 Pod CREATE 请求,以确保每个 Pod 都有一个环境标签,并且该标签的值为 devprod

为了证明你可以用你选择的语言编写动态准入控制器,我们在这个例子中使用了一个 Python Flask 应用程序:

import json
import os

from flask import jsonify, Flask, request

app = Flask(__name__)

@app.route('/', methods=['POST'])
def validation():
    review = request.get_json()
    app.logger.info('Validating AdmissionReview request: %s',
                    json.dumps(review, indent=4))

    labels = review['request']['object']['metadata']['labels']
    response = {}
    msg = None
    if 'environment' not in list(labels):
        msg = "Every Pod requires an 'environment' label."
        response['allowed'] = False
    elif labels['environment'] not in ('dev', 'prod',):
        msg = "'environment' label must be one of 'dev' or 'prod'"
        response['allowed'] = False
    else:
        response['allowed'] = True

    status = {
        'metadata': {},
        'message': msg
    }
    response['status'] = status

    review['response'] = response
    return jsonify(review), 200

context = (
    os.environ.get('WEBHOOK_CERT', '/tls/webhook.crt'),
    os.environ.get('WEBHOOK_KEY', '/tls/webhook.key'),
)
app.run(host='0.0.0.0', port='443', debug=True, ssl_context=context)

我们将这个应用容器化,并通过 ClusterIP 服务使其在内部可用:

---
apiVersion: v1
kind: Pod
metadata:
  name: label-validation
  namespace: infrastructure
  labels:
    controller: label-validator
spec:
  containers:
  - name: label-validator
    image: label-validator:latest
    volumeMounts:
    - mountPath: /tls
      name: tls
  volumes:
  - name: tls
    secret:
      secretName: admission-tls
---
kind: Service
apiVersion: v1
metadata:
  name: label-validation
  namespace: infrastructure
spec:
  selector:
    controller: label-validator
  ports:
  - protocol: TCP
    port: 443

在这种情况下,Webhook 托管在集群上。为了简单起见,我们使用了一个独立的 Pod,但没有理由不可以用更强大的东西来部署,比如 Deployment。而且,就像任何 Web 服务一样,我们用 TLS 来保护它。

在这个 Service 可用之后,我们需要引导 API Server 调用我们的 Webhook。我们指明我们关心哪些资源和操作,API Server 只有在观察到符合这个条件的请求时才会调用这个 Webhook。

apiVersion: admissionregistration.k8s.io/v1beta1
kind: ValidatingWebhookConfiguration
metadata:
  name: label-validation
webhooks:
- name: admission.example.com
  rules:
  - apiGroups:
    - ""
    apiVersions:
    - v1
    operations:
    - CREATE
    resources:
    - pods
  clientConfig:
    service:
      namespace: infrastructure
      name: label-validation
    caBundle: <base64 encoded bundle>

有了 ValidatingWebhookConfiguration,我们现在可以验证我们的策略是否按照预期工作。试图应用一个没有环境标签的 Pod,结果是:

# kubectl apply -f pod.yaml
Error from server: error when creating "pod.yaml": admission webhook
"admission.example.com" denied the request: Every Pod requires an 'environment'
label.

同理,用 environment=staging 标签:

# kubectl apply -f pod.yaml
Error from server: error when creating "pod.yaml": admission webhook
"admission.example.com" denied the request: 'environment' label must be one of
'dev' or 'prod'

只有当我们按照规范添加环境标签时,才能够成功创建一个新的 Pod。

:::info 请注意,我们的应用程序是通过 TLS 服务的。由于 API 请求可能包含敏感信息,所有的流量都应该被加密。 :::

转换准入控制器

如果我们修改我们的例子,我们可以很容易地开发一个转换的 Webhook。同样,通过一个转换的 Webhook,我们试图为用户透明地改变资源定义。

在这个例子中,我们注入了一个代理 Sidecar 容器。虽然这个 Sidecar 只是一个辅助性的 nginx 进程,但我们可以修改资源的任何方面。

:::warning 在运行时修改资源时要小心谨慎,因为可能存在依赖于明确定义和/或先前定义的值的现有逻辑。一般的经验法则是只设置先前未设置的字段。始终避免改变任何命名空间的值(例如,资源注释)。 :::

我们的新 Webhook 是这样的:

import base64
import json
import os

from flask import jsonify, Flask, request

app = Flask(__name__)

@app.route("/", methods=["POST"])
def mutation():
    review = request.get_json()
    app.logger.info("Mutating AdmissionReview request: %s",
                    json.dumps(review, indent=4))

    response = {}
    patch = [{
        'op': 'add',
        'path': '/spec/containers/0',
        'value': {
            'image': 'nginx',
            'name': 'proxy-sidecar',
        }
    }]
    response['allowed'] = True
    response['patch'] = base64.b64encode(json.dumps(patch))
    response['patchType'] = 'application/json-patch+json'

    review['response'] = response
    return jsonify(review), 200

context = (
    os.environ.get("WEBHOOK_CERT", "/tls/webhook.crt"),
    os.environ.get("WEBHOOK_KEY", "/tls/webhook.key"),
)
app.run(host='0.0.0.0', port='443', debug=True, ssl_context=context)

在这里,我们使用 JSON Patch 语法将 proxy-sidecar 添加到 Pod 中。

就像验证 Webhook 一样,我们将应用容器化,然后动态配置 API Server,将请求转发到 Webhook。唯一不同的是,我们将使用一个 MutatingWebhookConfiguration,并且自然地指向内部的 ClusterIP 服务:

apiVersion: admissionregistration.k8s.io/v1beta1
kind: MutatingWebhookConfiguration
metadata:
  name: pod-mutation
webhooks:
- name: admission.example.com
  rules:
  - apiGroups:
    - ""
    apiVersions:
    - v1
    operations:
    - CREATE
    resources:
    - pods
  clientConfig:
    service:
      namespace: infrastructure
      name: pod-mutator
    caBundle: <base64 encoded bundle>

现在,当我们应用一个非常简单的、单一容器的 Pod 时,我们会得到更多的东西:

apiVersion: v1
kind: Pod
metadata:
  name: testpod
  labels:
    app: testpod
    environment: prod
#staging
spec:
  containers:
  - name: busybox
    image: busybox
    command: ['/bin/sleep', '3600']

尽管我们的 Pod 只声明了 busybox 容器,但我们现在在运行时有两个容器:

# kubectl get pod testpod
NAME      READY     STATUS    RESTARTS   AGE
testpod   2/2       Running   0          1m

而更深层次的检查发现,我们的 Sidecar 是正确注入的:

...
spec:
  containers:
  - image: nginx
    imagePullPolicy: Always
    name: proxy-sidecar
    resources: {}
    terminationMessagePath: /dev/termination-log
    terminationMessagePolicy: File
  - command:
    - /bin/sleep
    - "3600"
    image: busybox
...

有了突变的 Webhook,我们就有了一个非常强大的工具来规范我们用户的声明。谨慎使用这种力量。

总结

准入控制是另一个用于净化你的集群状态的工具。由于这个功能是不断发展的,所以一定要在每个 Kubernetes 版本中检查新功能,并实现那些有助于保护环境安全和提高利用率的控制器。而且,在适当的情况下,不要害怕卷起袖子,实现对你的特定用例最有意义的逻辑。