官方文档

概述

在 Kubernetes 中,准入控制是保障 API 请求安全和合规性的重要机制。它在 API 请求流程中扮演着关键角色,拦截即将发送到 Kubernetes APIServer的请求,在持久化之前,但在身份验证和授权之后。其位置如下图所示:

图片来源https://sysdig.com/blog/kubernetes-admission-controllers/

图片来源https://sysdig.com/blog/kubernetes-admission-controllers/

准入控制器适用于创建、删除或修改对象的请求,同时也可以阻止自定义动作。读操作会绕过准入控制层,不会受到准入控制器的影响。当有多个准入控制器存在时,它们会依次被调用,只要其中一个准入控制器拒绝请求,整个请求就会被立即驳回。

准入控制常常被用来自动化设置默认资源请求和限制、应用标签和注释以及强制命名约定等管理任务。

准入控制分两个阶段:

  1. 变更准入控制器,用于在请求处理过程中对对象进行修改。
  2. 验证准入控制器,负责验证请求是否符合特定规则。

需要注意的是,部分控制器兼具变更准入和验证准入的功能。

启用准入

1
kube-apiserver --enable-admission-plugins=NamespaceLifecycle,LimitRanger ...

关闭准入

1
kube-apiserver --disable-admission-plugins=PodNodeSelector,AlwaysDeny ...

准入的两种类型:

  • 静态准入:由 Kubernetes 内置提供,无需额外配置。
  • 动态准入:允许用户根据自身需求进行扩展。

动态准入控制器:

  • MutatingAdmissionWebhook

    此准入控制器调用任何与请求匹配的变更(Mutating) Webhook。匹配的 Webhook 将被顺序调用

  • ValidatingAdmissionWebhook

    此准入控制器调用与请求匹配的所有验证性 Webhook。 匹配的 Webhook 将被并行调用。如果其中任何一个拒绝请求,则整个请求将失败。 该准入控制器仅在验证(Validating)阶段运行

  • ValidatingAdmissionPolicy

    验证准入策略使用通用表达语言 (Common Expression Language,CEL) 来声明策略的验证规则,是一种声明式的、进程内的验证准入 Webhook 方案

  • MutatingAdmissionPolicy

    提供了一种声明式的、进程内的方案, 可以用来替代变更性准入 Webhook

实现

步骤概览

实现一个 MutatingAdmissionWebhook 主要包括以下几个步骤:

  1. 生成证书,Kubernetes 和 Webhook 服务使用 TLS 加密通信
    1. APIServer对Webhook的认证
    2. Webhook对APIServer的认证(可选)
  2. 实现一个接口,接收AdmissionReview请求,解析并填充response字段,响应相同版本的AdmissionReview
  3. 部署上面接口,集群内外都可以
  4. 注册准入控制器
  5. 监控 Admission Webhook

以下面需求为例:

  1. 实现一个准入控制器,对指定名称空间下的pod对象,检查镜像地址是否是配置的可靠来源,不是则修改其镜像地址
  2. 实现一个准入控制器,指定名称空间下的pod对象,如果没有prometheus相关注解则自动添加

1. 生成证书

X.509 证书标准里,CN(Common Name,通用名称)字段只能指定一个值,SAN(Subject Alternative Name)扩展允许在一个证书中包含多个域名,包括子域名和多级子域名。或许我们需要签发多个证书用于不同的地方,则可以通过一个根证书随时签发。下面是使用SAN扩展生成包含多个通配域名证书的命令

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# 根证书CA私钥
openssl genrsa -out rootCA.key 4096
# 根证书
openssl req -x509 -new -nodes -key rootCA.key -sha256 -days 3650 -out rootCA.crt -subj "/CN=BaiqiKubernetes"
# SAN配置文件
echo '[req]
      default_bits = 2048
      prompt = no
      default_md = sha256
      distinguished_name = dn
      req_extensions = req_ext

      [dn]
      CN = baiqi.io

      [req_ext]
      subjectAltName = @alt_names

      [alt_names]
      DNS.1 = *.default.svc
      DNS.2 = *.kube-system.svc
      DNS.3 = *.ops.svc
      DNS.4 = *.baiqi.io
      DNS.5 = *.ops.baiqi.io' > server.cnf

# 证书私钥
openssl genrsa -out server.key 2048
# 根据配置和私钥生成证书签名请求文件CSR
openssl req -new -key server.key -out server.csr -config server.cnf
# 使用根证书签发证书
openssl x509 -req -in server.csr -CA rootCA.crt -CAkey rootCA.key -CAcreateserial -out server.crt -days 1095 -extensions req_ext -extfile server.cnf

生成的相关文件如下:

1
2
3
4
5
6
7
-rw-rw-r-- rootCA.crt
-rw------- rootCA.key
-rw-rw-r-- rootCA.srl
-rw-rw-r-- server.cnf
-rw-rw-r-- server.crt
-rw-rw-r-- server.csr
-rw------- server.key

2. webhook接口

请求:AdmissionReview对象

响应:

  • HTTP 200 状态码
  • 响应头:Content-Type: application/json
  • AdmissionReview 对象的 JSON 序列化格式,AdmissionReview 对象与发送的版本相同,且其中包含的 response 字段已被有效填充。当允许请求时,mutating准入通过在响应中使用 patchpatchType 字段来修改对象,patchTypeJSON patch

代码实现:

  1. 解析准入请求AdmissionReview对象
  2. 检查传入的对象是否允许准入,是否需要修改对象
  3. 如果允许准入,根据需要生成jsonpatch
  4. 按要求响应即可

解释直接放在代码里了:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
package main

import (
    "crypto/tls"
    "encoding/json"
    "flag"
    "fmt"
    "io"
    "log"
    "log/slog"
    "net/http"
    "os"
    "strconv"
    "strings"

    // api group/version类型定义
    admissionV1 "k8s.io/api/admission/v1"
    v1 "k8s.io/api/core/v1"
    // Kubernetes 对象的类型定义、注册和管理,以及提供通用的对象处理功能
    "k8s.io/apimachinery/pkg/runtime"
    // 对象的序列化和反序列化操作,提供了多种序列化器的实现和操作方法
    "k8s.io/apimachinery/pkg/runtime/serializer"
)

var (
    port        int
    tlsKey      string
    tlsCert     string
    imageSource string // 以逗号分割的仓库地址,默认改为第一个仓库地址
)

var logger *slog.Logger

// patch 操作定义
type PatchOperation struct {
    Op    string      `json:"op"`
    Path  string      `json:"path"`
    Value interface{} `json:"value,omitempty"`
}

func init() {
    flag.IntVar(&port, "port", 9099, "Admisson controller port")
    flag.StringVar(&tlsKey, "tls-key", "/app/certs/tls.key", "Private key for TLS")
    flag.StringVar(&tlsCert, "tls-crt", "/app/certs/tls.crt", "TLS certificate")
    flag.StringVar(&imageSource, "image-url", "", "image url host")
}

func main() {
    flag.Parse()

    logHandler := slog.NewJSONHandler(
        os.Stdout, &slog.HandlerOptions{
            Level: slog.LevelInfo,
        },
    )
    logger = slog.New(logHandler)
    logger.With("mutateAdmissionName", "image-admission")

    // 加载证书和私钥
    certs, err := tls.LoadX509KeyPair(tlsCert, tlsKey)
    if err != nil {
        panic(err)
    }

    // webhook 路由端点
    http.HandleFunc("/pod-mutate", serverPod)

    logger.Info("Starting server...", "port", port)
    server := http.Server{
        Addr: fmt.Sprintf(":%d", port),
        TLSConfig: &tls.Config{
            Certificates: []tls.Certificate{certs},
        },
    }

    // 启动TLS监听
    if err := server.ListenAndServeTLS("", ""); err != nil {
        log.Panic(err)
    }
}

// imagePatches 按要求生成patch的操作列表,即前面说的需求1
func imagePatches(pod *v1.Pod) []PatchOperation {
    var patches []PatchOperation
    var image string
    patch := PatchOperation{
        Op: "replace",
    }

    imageSourceList := strings.Split(imageSource, ",")
    for i, container := range pod.Spec.Containers {
        imagePair := strings.Split(container.Image, ":")
        if strings.Contains(imagePair[0], ".") {
            image = strings.Replace(container.Image, strings.Split(imagePair[0], "/")[0], imageSourceList[0], -1)
            patch.Path = "/spec/containers/" + strconv.Itoa(i) + "/image"
            patch.Value = image
            patches = append(patches, patch)
            continue
        }
        image = imageSourceList[0] + "/" + container.Image
        patch.Path = "/spec/containers/" + strconv.Itoa(i) + "/image"
        patch.Value = image
        patches = append(patches, patch)
    }
    return patches
}

// parseAdmissionReview 解析准入请求
func parseAdmissionReview(r *http.Request, decoder runtime.Decoder) (*admissionV1.AdmissionReview, error) {
    var admissionReviewRequest = &admissionV1.AdmissionReview{}
    body, err := io.ReadAll(r.Body)
    defer r.Body.Close()
    if err != nil {
        return nil, err
    }
    // err = json.Unmarshal(body, &admissionReviewRequest)
    _, _, err = decoder.Decode(body, nil, admissionReviewRequest)
    if err != nil {
        return nil, err
    }
    return admissionReviewRequest, nil
}

// http handler,处理请求
func serverPod(w http.ResponseWriter, r *http.Request) {

    // 创建反序列化器
    scheme := runtime.NewScheme()
    codecFactory := serializer.NewCodecFactory(scheme)
    deserializer := codecFactory.UniversalDeserializer()

    admissionReviewRequest, err := parseAdmissionReview(r, deserializer)
    if err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        logger.Error("unable to parse AdmissionReview,", "err", err)
        return
    }
    // 只针对pods资源
    if admissionReviewRequest.Request.Kind.Kind != "Pod" {
        http.Error(w, "admissionReviewRequest.Request.Kind.Kind != Pod", http.StatusForbidden)
        logger.Error("admission review requires Kind Pod, Please check register config", "err", err)
        return
    }
    logger.Debug("admission request", admissionReviewRequest)

    // 从请求中解析出pod声明
    pod := &v1.Pod{}
    _, _, err = deserializer.Decode(admissionReviewRequest.Request.Object.Raw, nil, pod)
    if err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        logger.Error("unable to decode AdmissionReview object to Pod", "err", err)
        return
    }
    logger.Debug("requested object", pod.Spec.Containers)

    // 生成json patch列表
    patches := imagePatches(pod)

    logger.Debug("patches", patches)

    patchesBytes, err := json.Marshal(patches)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        logger.Error("unable to encode patch", "err", err)
        return
    }

    // 组装响应
    admissionResponse := &admissionV1.AdmissionResponse{}
    // 响应中的UID必须和请求中的一致
    admissionResponse.UID = admissionReviewRequest.Request.UID
    // 允许准入
    admissionResponse.Allowed = true
    admissionResponse.PatchType = func() *admissionV1.PatchType { pt := admissionV1.PatchTypeJSONPatch; return &pt }()
    admissionResponse.Patch = patchesBytes
    admissionResponse.Warnings = nil

    var admissionReviewResponse admissionV1.AdmissionReview
    admissionReviewResponse.Response = admissionResponse
    admissionReviewResponse.SetGroupVersionKind(admissionReviewRequest.GroupVersionKind())

    responseBytes, err := json.Marshal(admissionReviewResponse)
    if err != nil {
        logger.Error("unable to encode AdmissionReview response", "err", err)
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    // 准入要求的头和状态码
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusOK)
    _, _ = w.Write(responseBytes)
}

3. 部署

可以将其部署在集群内或集群外,只是注册方式稍有不同,这里放本集群内

  1. 构建镜像
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
FROM golang:1.24 AS builder

ENV GO111MODULE=on
ENV GOPROXY=https://goproxy.cn,direct
WORKDIR /opt/
COPY . .
RUN go mod download && go mod verify
RUN GOOS=linux CGO_ENABLED=0 GOARCH=amd64 go build -v -o image-admission

FROM alpine:3.18 AS prod
WORKDIR /opt/
COPY --from=builder /opt/image-admission ./image-admission
ENTRYPOINT ["/opt/image-admission"]
  1. 创建k8s资源对象

    • deployment方式运行webhook服务,挂载tls证书secret到指定目录,指定容器启动的args(信任的镜像源)

    • tls公私钥secret

    • 给Apiserver访问的service

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: image-admission
  namespace: kube-system
  labels:
    app: image-admission
spec:
  replicas: 1
  selector:
    matchLabels:
      app: image-admission
  template:
    metadata:
      name: image-admission
      labels:
        app: image-admission
    spec:
      containers:
        - name: image-admission
          command:
            - /opt/image-admission
            - "-image-url"
            - "a.b.c,x.y.z"
            - "-port"
            - "9099"
          image: image-admission:v0.0.1
          imagePullPolicy: Always
          ports:
            - containerPort: 9099
              protocol: TCP
          volumeMounts:
            - mountPath: "/app/certs"
              name: tls
      restartPolicy: Always
      imagePullSecrets:
        - name: hub-in
      volumes:
        - name: tls
          secret:
            secretName: image-admission-tls

---
apiVersion: v1
kind: Secret
metadata:
  name: image-admission-tls
  namespace: kube-system
  labels:
    app: image-admission
type: kubernetes.io/tls
data:
  tls.crt: xx
  tls.key: yy

---
apiVersion: v1
kind: Service
metadata:
  labels:
    app: image-admission
  name: image-admission
  namespace: kube-system
spec:
  internalTrafficPolicy: Cluster
  ipFamilies:
    - IPv4
  ipFamilyPolicy: SingleStack
  ports:
    - name: https
      port: 443
      protocol: TCP
      targetPort: 9099
  selector:
    app: image-admission
  sessionAffinity: None
  type: ClusterIP

4. 注册

哈拉少,跑起来后注册准入,创建准入配置 MutatingWebhookConfiguration,指定名称空间default和x下对pods资源的创建和修改需要经过该准入webhook处理。caBundle就是前面生成证书时用的根证书。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
---
apiVersion: admissionregistration.k8s.io/v1
kind: MutatingWebhookConfiguration
metadata:
  labels:
    app: image-admission
  name: image-admission
  namespace: kube-system
webhooks:
  - name: image-admission.x.z
    timeoutSeconds: 3
    failurePolicy: Fail
    clientConfig:
      caBundle: cacaca=
      service:
        namespace: kube-system
        name: image-admission
        path: /pod-mutate
        port: 443
    namespaceSelector:
      matchExpressions:
        - key: kubernetes.io/metadata.name
          operator: In
          values: ["default", "x"]
    rules:
      - operations: ["CREATE", "UPDATE"]
        # "" 是core组
        apiGroups: [""]
        apiVersions: ["v1"]
        resources: ["pods"]
        scope: "Namespaced"
    admissionReviewVersions:
      - v1
    sideEffects: None

5. 验证

创建如下pod,观察准入服务和APIServer的日志,如果开启审计可以查看审计日志

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
apiVersion: v1
kind: Pod
metadata:
  name: nginx
spec:
  containers:
  - name: nginx
    image: nginx:1.14.2
    ports:
    - containerPort: 80

如果没有问题,镜像已被修改

1
2
$ kubectl get pod nginx -o jsonpath='{.spec.containers[0].image}'
a.b.c/nginx:1.14.2

开启审计

Kubernetes 的集群审计功能能够记录 API 服务器接收到的请求信息,这对于安全分析、合规性检查以及故障排查都非常有帮助,但是对集群性能有一定影响,自行评估,参考官方文档

  1. 创建审计策略文件,定义哪些请求需要被审计以及审计的详细程度
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
apiVersion: audit.k8s.io/v1
kind: Policy
# 忽略健康检查请求
omitStages:
  - "RequestReceived"
rules:
  # 记录pod变化
  - level: RequestResponse
    resources:
      - group: ""
        resources: ["pods"]
  # 对所有其他请求进行元数据级别的审计
  - level: Metadata
    resources:
      - group: ""
  1. 添加 APIServer参数,使其加载审计策略文件并指定审计日志的输出位置。以下是一个配置示例:
1
2
3
4
5
6
kube-apiserver \
  --audit-policy-file=/path/to/audit-policy.yaml \
  --audit-log-path=/var/log/kubernetes/audit.log \
  --audit-log-maxsize=100 \
  --audit-log-maxbackup=10 \
  --audit-log-maxage=30