准入控制器概念

k8s apiserver 在处理每一个操作资源对象的请求时,在经过认证(是否为合法用户)/ 鉴权(用户是否拥有权限)后,并不会直接根据端点资源类型和 rest 动作直接执行操作,在这中间,请求会被一系列准入控制器插件(Admission Controller)进行拦截,校验其是否合乎要求,亦或是对特定资源进行配置。

准入机制

  1. 如果所有的 webhooks 批准请求,准入控制链继续流转;
  2. 如果有任意一个 webhooks 阻止请求,那么准入控制请求终止,并返回第一个 webhook 阻止的原因。其中,多个 webhooks 阻止也只会返回第一个 webhook 阻止的原因;
  3. 如果在调用 webhook 过程中发生错误,那么请求会被终止或者忽略 webhook

准入控制器类型

  1. 变更 (Mutating):这种控制器可以解析请求,并在请求向下发送之前对请求进行更改;
  2. 验证 (Validating): 这种控制器可以解析请求并根据特定数据进行验证;
    3.Both:这种控制器可以执行变更和验证两种操作,如默认开启的 LimitRanger 准入控制器在变更阶段使用默认的资源配置来限制容器对资源的使用,并在验证阶段确保容器的资源限制不超过预期的。
    对应的,准入控制分为两个阶段,变更控制器会优先与验证控制器在第一阶段执行,需要注意的是变更控制器会更改请求对象,验证控制器则不行。
    在这里插入图片描述

动态准入控制

除了 k8s 自带的默认控制器,我们还可以以 webhook 的形式去编写自定义的准入控制器,对特定资源的请求进行个性化的修改和验证,典型的场景有通过 mutating 类型 webhook 注入 side-car 到 pod,利用 validating 类型 webhook 实现 crd 资源相关字段的规则验证。

自定义动态准入控制器

这里我们根据官方提供的demo来自定义一个简单的准入控制器,实现以下目标功能:

  1. 验证 pod 名称是否符合规范;
  2. 修改 pod 主容器镜像;
  3. 注入 initContainer 容器。
配置步骤:
1. 通过 cfssl 生成 ca 证书:
root# cat ca-config.json 
{
  "signing": {
    "default": {
      "expiry": "8760h"
    },
    "profiles": {
      "server": {
        "usages": ["signing"],
        "expiry": "8760h"
      }
    }
  }
}
root# cat ca-csr.json 
{
  "CN": "Kubernetes",
  "key": {
    "algo": "rsa",
    "size": 2048
  },
  "names": [
    {
      "C": "zh",
      "L": "zj",
      "O": "hz",
      "OU": "CA"
   }
  ]
}

root# cfssl gencert -initca ca-csr.json | cfssljson -bare ca

root# ls
ca-key.pem
ca.csr
ca.pem
server.pem
2. 配置 k8s secret
root# kubectl create secret tls myhook --cert=server.pem --key=server-key.pem  -n kube-system
 
root# kubectl get secret -n kube-system |grep myhook
myhook        kubernetes.io/tls               2      64m
3. 配置一个 MutatingWebhook 类型准入控制器
root# cat admission.yaml
apiVersion: admissionregistration.k8s.io/v1
kind: MutatingWebhookConfiguration
metadata:
  name: myhook
webhooks:
  - clientConfig:
      caBundle:   LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURZakNDQWtxZ0F3SUJBZ0lVTW9GMWlJN1p5YnR3R21pTmZXZ0Q2OWFjSEJVd0RRWUpLb1pJaHZjTkFRRUwKQlFBd1NURUxNQWtHQTFVRUJoTUNlbWd4Q3pBSkJnTlZCQWNUQW1KcU1Rc3dDUVlEVlFRS0V3SmlhakVMTUFrRwpBMVVFQ3hNQ1EwRXhFekFSQmdOVkJBTVRDa3QxWW1WeWJtVjBaWE13SGhjTk1qRXhNakkxTVRVMU1qQXdXaGNOCk1qWXhNakkwTVRVMU1qQXdXakJKTVFzd0NRWURWUVFHRXdKNmFERUxNQWtHQTFVRUJ4TUNZbW94Q3pBSkJnTlYKQkFvVEFtSnFNUXN3Q1FZRFZRUUxFd0pEUVRFVE1CRUdBMVVFQXhNS1MzVmlaWEp1WlhSbGN6Q0NBU0l3RFFZSgpLb1pJaHZjTkFRRUJCUUFEZ2dFUEFEQ0NBUW9DZ2dFQkFMNEtQbEl5K1lpZ1k0MmM0RXNaOE80RHpFaE5VOXVHCjV1ejJmTmZEUi95MlpkSSttcDJENFJIc3UyTzhiRW1oTHBQZklieWhTL0Vxa2Y4L3hvNHhnVmhvK3Z3eUg5Rm4KNm53OWk5Slp0Qzc5RWY4dlhCbkprbWo4Qi9mTGxZVWluT3RzMVFqZ1FiN0dhWUgxQjY4NHdhK2VBSHZMSHAvYwpRZFkvSG1Wd2VwNG9odnM1allLVGRvT25QMWlhNUJtU2ZaZEY4bFcvZzF6ODRYTDFkdG55dXQyRkdTTFZpNmgyCmtZK1pHNlRLWGxtL1kyUldmYTZzOEZrMDBkcXJybTVVVmVlY0ZFQUQ3VjY0S3lxdFpWcXRIUzJrd1ArbzRWSHcKS0pDNjZEVmdwNTV1NGFlbTQ1bmJQNG5SekQ5VHNoWXl1RE8vdTBkS1dvZlp6a21WZHpReERTc0NBd0VBQWFOQwpNRUF3RGdZRFZSMFBBUUgvQkFRREFnRUdNQThHQTFVZEV3RUIvd1FGTUFNQkFmOHdIUVlEVlIwT0JCWUVGSjJHCjhNYnpiVW83S3pmOGRzdHJoaXc2L1FZR01BMEdDU3FHU0liM0RRRUJDd1VBQTRJQkFRQjZkb3ZXQjB6ajJRUUwKYlZ0akdTQ0JBZHFNeXFvakRqRk9JMmhFWWR3dVIyc2oxN2xSQjU1UGxzeHdkWU04VGp1UEUyUXAvbjVZNFJXRgpkMFVWeDdCSTJGeG84V0ZmaEZnaGo4cm9uMHhOay84UlBvYk5vUVM0NC9kRHZHMWZLbFYyUlJFMXBMdVBNTWRPCjZxcXc3TTBKd2Q1K051OEc0M1VyZU5aTHpKSUlic3UyRmwyZ3JicG56ZGhUUXlNMUdwamMxR3BiMSt4dUZxYkcKd1R5SjNhNG9DaVlZRHhCUmVRWHJWeFcwaHB3Y2EyeW5wenY1YmIyd3FBRXVJemZEK2llYkZTaEFVSTJPQzJ1YwpXMXpYSlkyTnl6eEdCdmVtRWRtQ1VVTWh3S3RPdDhIdTlSRVhJUk5LSnExSzRSTjZNOTVpMmpPZFhGZjRaQ2JtCmZuc0svbHppCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K   # caBundle 为ca客户端证书,由 cat ca.pem |base64 解码而来
      service:
        name: myhook
        namespace: kube-system
        path: /pods # webhook url
    failurePolicy: Fail
    sideEffects: NoneOnDryRun
    name: myhook.wholeeprime.com
    objectSelector: #  资源匹配器
      matchLabels:
        app:  "wholeeprime"
    admissionReviewVersions: ["v1", "v1beta1"]
    namespaceSelector: {}
    rules:
      - apiGroups:   [""] # 列出要匹配的 API 组。"" 是核心 API 组。"*" 匹配所有 API 组。   kubectl api-resources查看
        apiVersions: ["v1"] # 列出要匹配的 API 版本。"*" 匹配所有 API 版本。
        operations:  ["CREATE"] # 列出要匹配的操作。 可以是 CREATE、UPDATE、DELETE、CONNECT 或 * 以匹配所有内容。
        resources:   ["pods"] # 列出了一个或多个要匹配的资源。
        scope: "Namespaced"   # 指定要匹配的范围。有效值为 "Cluster"、"Namespaced" 和 "*"。
4. 编写 webhook
// main.go
package main

import (
	"encoding/json"
	"k8s.io/klog/v2"
	"log"
	"myhook/lib"

	"io/ioutil"
	"k8s.io/api/admission/v1"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"net/http"
)

func main() {
	http.HandleFunc("/pods", func(w http.ResponseWriter, r *http.Request) {
		log.Println(r.RequestURI)
		var body []byte
		if r.Body != nil {
			if data, err := ioutil.ReadAll(r.Body); err == nil {
				body = data
			}
		}
		//第二步
		reqAdmissionReview := v1.AdmissionReview{} //请求
				rspAdmissionReview := v1.AdmissionReview{ //响应 ---只构建了一部分
			TypeMeta: metav1.TypeMeta{
				Kind:       "AdmissionReview",
				APIVersion: "admission.k8s.io/v1",
			},
		}
		//第三步。 把body decode 成对象
		deserializer := lib.Codecs.UniversalDeserializer()
		if _, _, err := deserializer.Decode(body, nil, &reqAdmissionReview); err != nil {
			klog.Error(err)
			rspAdmissionReview.Response = lib.ToV1AdmissionResponse(err)
		} else {
			rspAdmissionReview.Response = lib.AdmitPods(reqAdmissionReview) //我们的业务
		}
		rspAdmissionReview.Response.UID = reqAdmissionReview.Request.UID
		respBytes,_ := json.Marshal(rspAdmissionReview)

		w.Write(respBytes)
	})
		tlsConfig := lib.Config{
		CertFile: "/etc/webhook/certs/tls.crt",
		KeyFile: "/etc/webhook/certs/tls.key",
	}
	server := &http.Server{
		Addr:      ":443",
		TLSConfig: lib.ConfigTLS(tlsConfig),
	}
	klog.Error(server.ListenAndServeTLS("",""))
}
// lib/pod.go
package lib

import (
	"fmt"
	"k8s.io/api/admission/v1"
	corev1 "k8s.io/api/core/v1"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/klog/v2"
)
func pathContainer() []byte{
	str:=`[
   {
		"op" : "replace" ,
		"path" : "/spec/containers/0/image" ,
		"value" : "nginx:1.19-alpine"
	},
   {
        "op": "add",
        "path": "/spec/initContainers",
        "value": [{
           "name": "myinit",
           "image": "busybox:1.28",
           "command": ["sh", "-c", "echo The app is running!"]
          }]
   }
]`
	return []byte(str)
}

func AdmitPods(ar v1.AdmissionReview) *v1.AdmissionResponse {
	podResource := metav1.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"}

	if ar.Request.Resource != podResource {
		err := fmt.Errorf("expect resource to be %s", podResource)
		klog.Error(err)
		return ToV1AdmissionResponse(err)
	}

	raw := ar.Request.Object.Raw
	pod := corev1.Pod{}
	deserializer := Codecs.UniversalDeserializer()
	if _, _, err := deserializer.Decode(raw, nil, &pod); err != nil {
		klog.Error(err)
		return ToV1AdmissionResponse(err)
	}
	reviewResponse := v1.AdmissionResponse{}

	if pod.Name=="dasha"{           // 模拟验证镜像名称是否符合规范
		reviewResponse.Allowed = false
		reviewResponse.Result = &metav1.Status{Code:503,Message: "pod name cannot be dasha"}    
	}else{
		reviewResponse.Allowed = true
		reviewResponse.Patch = pathContainer()
		pt:=v1.PatchTypeJSONPatch // 通过patch补丁方式来实现主容器镜像的修改以及initContainer的注入,补丁策略需要为PatchTypeJSONPatch
		reviewResponse.PatchType=&pt
	}
	return &reviewResponse
}
// lib/scheme.go
var Codecs = serializer.NewCodecFactory(scheme)

func init() {
	addToScheme(scheme)
}

func addToScheme(scheme *runtime.Scheme) {
	utilruntime.Must(corev1.AddToScheme(scheme))
	utilruntime.Must(admissionv1beta1.AddToScheme(scheme))
	utilruntime.Must(admissionregistrationv1beta1.AddToScheme(scheme))
	utilruntime.Must(admissionv1.AddToScheme(scheme))
	utilruntime.Must(admissionregistrationv1.AddToScheme(scheme))
}
// lib/config.go
package lib

import (
	"crypto/tls"
	"k8s.io/klog/v2"
)
// Config contains the server (the webhook) cert and key.
type Config struct {
	CertFile string
	KeyFile  string
}

func ConfigTLS(config Config) *tls.Config {
	sCert, err := tls.LoadX509KeyPair(config.CertFile, config.KeyFile)
	if err != nil {
		klog.Fatal(err)
	}
	return &tls.Config{
		Certificates: []tls.Certificate{sCert},
		// TODO: uses mutual tls after we agree on what cert the apiserver should use.
		// ClientAuth:   tls.RequireAndVerifyClientCert,
	}
}
5. 将 webhook 打包成镜像
root# cat Dockerfile 
FROM  golang:1.16-alpine
MAINTAINER  dasha@test.com

RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \
    && apk add --no-cache openssl-dev  tzdata libffi-dev gcc g++ make


ENV TIME_ZONE=Asia/Shanghai
ENV GOPROXY="https://goproxy.cn,direct"

RUN echo "${TIME_ZONE}" > /etc/timezone \
    && ln -sf /usr/share/zoneinfo/${TIME_ZONE} /etc/localtime

VOLUME ["/tmp"]
WORKDIR /go/src/myhook
ADD . .
RUN go build -i -o "myhook" -gcflags "-N -l" main.go

WORKDIR /usr/local/bin/myhook
RUN mv /go/src/myhook/myhook   .

ENTRYPOINT ["/bin/sh", "-c", "./myhook"]

root# docker build -t harbor.test.com/prod/admission:v1 .
root# docker push harbor.test.com/prod/admission:v1 # 推送到镜像仓库
6. 在 k8s 集群中部署 webhook
root# cat webhook.yaml 
apiVersion: apps/v1
kind: Deployment
metadata:
  name: myhook
  namespace: kube-system
spec:
  replicas: 1
  selector:
    matchLabels:
      app: myhook
  template:
    metadata:
      labels:
        app: myhook
    spec:
      containers:
        - name: myhook
          image: harbor.test.com/prod/admission:v1
          imagePullPolicy: IfNotPresent
          volumeMounts:
            - name: hooktls
              mountPath: /etc/webhook/certs
              readOnly: true
          ports:
            - containerPort: 443
      imagePullSecrets:
        - name: harbor-key
      volumes:
        - name: hooktls
          secret:
            secretName: myhook
---
apiVersion: v1
kind: Service
metadata:
  name: myhook
  namespace: kube-system
  labels:
    app: myhook
spec:
  type: ClusterIP
  ports:
    - port: 443
      targetPort: 443
  selector:
    app: myhook      
#部署准入控制器和webhook
root# kubectl apply  -f webhook.yaml
root# kubectl apply  -f admission.yaml

#查看
root# kubectl get  MutatingWebhookConfiguration  |grep myhook
myhook                                  1          33m

root# kubectl get deploy -n kube-system|grep myhook
myhook               1/1     1            1           35m
6. 创建一个 pod 对我们自定义的准入控制器进行测试
# 验证pod名称是否符合规范
root# cat pod.yaml
apiVersion: v1
kind: Pod
metadata:
  name: daicheng
  namespace: default
  labels:
    app: wholeeprime
spec:
  containers:
    - name: nginx
      image: nginx:1.18-alpine
      imagePullPolicy: IfNotPresent
      ports:
        - containerPort: 80

root# kubectl apply -f pod.yaml 
Error from server: error when creating "pod.yaml": admission webhook "myhook.test.com" denied the request: pod name cannot be dasha

#验证是否能够修改pod主容器镜像版本和注入初始化容器
 root# cat pod.yaml
apiVersion: v1
kind: Pod
metadata:
  name: dasha-test
  namespace: default
  labels:
    app: dasha-test
spec:
  containers:
    - name: nginx
      image: nginx:1.18-alpine
      imagePullPolicy: IfNotPresent
      ports:
        - containerPort: 80
	
root# kubectl apply -f pod.yaml 
pod/dasha-test created

root# kubectl describe pod/dasha-test
...
参考文章以及相关实现代码:

https://kubernetes.io/zh/docs/reference/access-authn-authz/admission-controllers/
https://kubernetes.io/zh/docs/reference/access-authn-authz/extensible-admission-controllers/
https://github.com/kubernetes/kubernetes/tree/588a4569a768306ea91821ff916185d65faa85a8/test/images/agnhost/webhook

Logo

开源、云原生的融合云平台

更多推荐