从零开发K8s准入控制器:实现自动注入Nginx Sidecar的完整实践

前言

学完前面三章的apiserver源码,我对准入控制机制有了深入理解。但总有种"纸上得来终觉浅"的感觉——知道Webhook是怎么工作的,但自己动手写一个又是另一回事。

于是决定从零开发一个MutatingAdmissionWebhook,实现类似Istio的自动Sidecar注入功能。目标很简单:当在特定namespace创建Pod时,自动注入一个Nginx Sidecar容器。

花了两个周末时间,踩了不少坑(证书问题、TLS配置、JSON Patch格式等),最终成功跑通。今天就把完整过程分享出来。

什么是自动Sidecar注入?

典型应用场景

Service Mesh(如Istio)的核心能力之一就是自动注入Envoy Sidecar:

用户创建Pod:
apiVersion: v1
kind: Pod
metadata:
  name: my-app
spec:
  containers:
  - name: app
    image: my-app:1.0

MutatingWebhook拦截后,自动变成:
apiVersion: v1
kind: Pod
metadata:
  name: my-app
spec:
  containers:
  - name: app
    image: my-app:1.0
  - name: nginx-sidecar  ← 自动注入!
    image: nginx:alpine

优势

  • 应用无感知,不需要修改业务代码
  • 统一的管理和控制
  • 可以添加代理、监控、日志收集等功能

我们的目标

开发一个Webhook,实现:

  1. 监听指定namespace(如sidecar-inject=true标签的namespace)
  2. 在该namespace创建Pod时,自动注入Nginx Sidecar
  3. Nginx作为反向代理,转发流量到主容器

整体架构

┌─────────────────────────────────────────────────────────────────┐
│                    Kubernetes Cluster                          │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  ┌──────────────┐        ┌──────────────────────────────┐     │
│  │   Pod        │        │   MutatingWebhookConfiguration │     │
│  │   my-app     │◄───────│   name: nginx-sidecar-injector │     │
│  └──────┬───────┘        │   namespaceSelector:           │     │
│         │                │     matchLabels:               │     │
│         │                │       sidecar-inject: enabled  │     │
│         │                └──────────────┬─────────────────┘     │
│         │                               │                        │
│         │ 1. 创建Pod                    │ 2. 匹配到Webhook      │
│         │                               │                        │
│  ┌──────▼───────────────────────────────▼─────────────────┐     │
│  │                  kube-apiserver                        │     │
│  │                                                         │     │
│  │  MutatingAdmissionWebhook                              │     │
│  │    │                                                    │     │
│  │    │ 3. HTTP POST AdmissionReview                       │     │
│  │    ▼                                                    │     │
│  │  ┌─────────────────────────────────────────────────────┐│     │
│  │  │      Nginx Sidecar Injector Webhook                  ││     │
│  │  │         (运行在集群内或集群外)                         ││     │
│  │  │                                                     ││     │
│  │  │  - 接收AdmissionReview请求                          ││     │
│  │  │  - 构造JSON Patch添加sidecar容器                     ││     │
│  │  │  - 返回AdmissionResponse                            ││     │
│  │  └─────────────────────────────────────────────────────┘│     │
│  └─────────────────────────────────────────────────────────┘     │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

实施步骤

整个实现分为6个步骤:

  1. 检查集群Webhook支持
  2. 开发Webhook服务(Go实现HTTP server)
  3. 生成TLS证书(Kubernetes需要HTTPS)
  4. 部署Webhook服务
  5. 创建MutatingWebhookConfiguration
  6. 验证注入效果

第一步:检查集群Webhook支持

首先确认集群是否启用了MutatingAdmissionWebhook控制器:

# 检查apiserver启动参数
kubectl get pods -n kube-system -l component=kube-apiserver -o yaml | grep enable-admission-plugins

# 输出应该包含MutatingAdmissionWebhook和ValidatingAdmissionWebhook
# --enable-admission-plugins=...,MutatingAdmissionWebhook,ValidatingAdmissionWebhook

如果没有启用,需要修改apiserver启动参数(Kubernetes 1.9+默认启用)。

第二步:开发Webhook服务

项目结构

sidecar-injector/
├── main.go                 # 主程序
├── go.mod                  # Go模块
├── Dockerfile              # 容器镜像
├── deploy/
│   ├── deployment.yaml     # Webhook部署
│   ├── service.yaml        # Service暴露
│   └── webhook.yaml        # MutatingWebhookConfiguration
└── certs/
    └── generate.sh         # 证书生成脚本

核心代码:main.go

package main

import (
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"

	"k8s.io/api/admission/v1"
	corev1 "k8s.io/api/core/v1"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

const (
	port     = 8443
	certFile = "/etc/webhook/certs/tls.crt"
	keyFile  = "/etc/webhook/certs/tls.key"
)

func main() {
	http.HandleFunc("/mutate", mutateHandler)
	http.HandleFunc("/health", healthHandler)

	fmt.Printf("Starting webhook server on port %d...\n", port)
	if err := http.ListenAndServeTLS(fmt.Sprintf(":%d", port), certFile, keyFile, nil); err != nil {
		fmt.Fprintf(os.Stderr, "Failed to start server: %v\n", err)
		os.Exit(1)
	}
}

// healthHandler 健康检查
func healthHandler(w http.ResponseWriter, r *http.Request) {
	w.WriteHeader(http.StatusOK)
	w.Write([]byte("ok"))
}

// mutateHandler 处理准入控制请求
func mutateHandler(w http.ResponseWriter, r *http.Request) {
	// 读取请求体
	body, err := io.ReadAll(r.Body)
	if err != nil {
		http.Error(w, fmt.Sprintf("could not read request body: %v", err), http.StatusBadRequest)
		return
	}

	// 解析AdmissionReview
	var admissionReview v1.AdmissionReview
	if err := json.Unmarshal(body, &admissionReview); err != nil {
		http.Error(w, fmt.Sprintf("could not parse admission review: %v", err), http.StatusBadRequest)
		return
	}

	// 获取请求信息
	admissionRequest := admissionReview.Request
	if admissionRequest == nil {
		http.Error(w, "admission request is nil", http.StatusBadRequest)
		return
	}

	fmt.Printf("Received admission request: %s/%s %s\n",
		admissionRequest.Namespace,
		admissionRequest.Name,
		admissionRequest.Operation)

	// 解析Pod对象
	var pod corev1.Pod
	if err := json.Unmarshal(admissionRequest.Object.Raw, &pod); err != nil {
		http.Error(w, fmt.Sprintf("could not parse pod: %v", err), http.StatusBadRequest)
		return
	}

	// 构造响应
	admissionResponse := &v1.AdmissionResponse{
		UID:     admissionRequest.UID,
		Allowed: true,
	}

	// 检查是否需要注入sidecar
	if shouldInject(&pod) {
		// 构造JSON Patch
		patch, err := createPatch(&pod)
		if err != nil {
			admissionResponse.Allowed = false
			admissionResponse.Result = &metav1.Status{
				Message: fmt.Sprintf("could not create patch: %v", err),
			}
		} else {
			patchType := v1.PatchTypeJSONPatch
			admissionResponse.PatchType = &patchType
			admissionResponse.Patch = patch
			fmt.Printf("Injected sidecar into pod %s/%s\n", pod.Namespace, pod.Name)
		}
	}

	// 构造返回的AdmissionReview
	responseReview := v1.AdmissionReview{
		TypeMeta: admissionReview.TypeMeta,
		Response: admissionResponse,
	}

	// 序列化响应
	responseBytes, err := json.Marshal(responseReview)
	if err != nil {
		http.Error(w, fmt.Sprintf("could not marshal response: %v", err), http.StatusInternalServerError)
		return
	}

	w.Header().Set("Content-Type", "application/json")
	w.Write(responseBytes)
}

// shouldInject 判断是否需要注入sidecar
func shouldInject(pod *corev1.Pod) bool {
	// 检查是否已经有sidecar
	for _, container := range pod.Spec.Containers {
		if container.Name == "nginx-sidecar" {
			return false
		}
	}

	// 检查annotation
	if pod.Annotations == nil {
		return true
	}

	// 可以通过annotation控制是否注入
	if inject, ok := pod.Annotations["sidecar-injector/inject"]; ok {
		return inject == "true"
	}

	return true
}

// createPatch 创建JSON Patch
func createPatch(pod *corev1.Pod) ([]byte, error) {
	// 获取主容器的端口
	mainPort := int32(8080)
	if len(pod.Spec.Containers) > 0 && len(pod.Spec.Containers[0].Ports) > 0 {
		mainPort = pod.Spec.Containers[0].Ports[0].ContainerPort
	}

	// 构造nginx配置
	nginxConf := fmt.Sprintf(`
server {
    listen 80;
    location / {
        proxy_pass http://localhost:%d;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}`, mainPort)

	// 构造sidecar容器
	sidecar := corev1.Container{
		Name:  "nginx-sidecar",
		Image: "nginx:alpine",
		Ports: []corev1.ContainerPort{
			{
				Name:          "http",
				ContainerPort: 80,
				Protocol:      corev1.ProtocolTCP,
			},
		},
		VolumeMounts: []corev1.VolumeMount{
			{
				Name:      "nginx-config",
				MountPath: "/etc/nginx/conf.d",
			},
		},
	}

	// 构造ConfigMap Volume
	configVolume := corev1.Volume{
		Name: "nginx-config",
		VolumeSource: corev1.VolumeSource{
			ConfigMap: &corev1.ConfigMapVolumeSource{
				LocalObjectReference: corev1.LocalObjectReference{
					Name: "nginx-sidecar-config",
				},
			},
		},
	}

	// 构造JSON Patch
	patch := []map[string]interface{}{}

	// 1. 添加sidecar容器
	patch = append(patch, map[string]interface{}{
		"op":    "add",
		"path":  "/spec/containers/-",
		"value": sidecar,
	})

	// 2. 添加volume(如果不存在)
	if len(pod.Spec.Volumes) == 0 {
		patch = append(patch, map[string]interface{}{
			"op":    "add",
			"path":  "/spec/volumes",
			"value": []corev1.Volume{configVolume},
		})
	} else {
		patch = append(patch, map[string]interface{}{
			"op":    "add",
			"path":  "/spec/volumes/-",
			"value": configVolume,
		})
	}

	// 3. 添加annotation标记已注入
	if pod.Annotations == nil {
		patch = append(patch, map[string]interface{}{
			"op":    "add",
			"path":  "/metadata/annotations",
			"value": map[string]string{
				"sidecar-injector/injected": "true",
			},
		})
	} else {
		patch = append(patch, map[string]interface{}{
			"op":    "add",
			"path":  "/metadata/annotations/sidecar-injector~1injected",
			"value": "true",
		})
	}

	return json.Marshal(patch)
}

go.mod

module sidecar-injector

go 1.21

require (
	k8s.io/api v0.28.0
	k8s.io/apimachinery v0.28.0
)

第三步:生成TLS证书

Webhook必须使用HTTPS,Kubernetes会验证证书。我们有两种方案:

方案1:使用集群CA签名(推荐)

#!/bin/bash
# certs/generate.sh

set -e

SERVICE_NAME="nginx-sidecar-injector"
NAMESPACE="kube-system"

# 生成CA私钥
openssl genrsa -out ca.key 2048

# 生成CA证书
openssl req -new -x509 -key ca.key -out ca.crt -days 365 \
  -subj "/CN=${SERVICE_NAME}-ca"

# 生成Webhook服务器私钥
openssl genrsa -out tls.key 2048

# 创建证书签名请求配置
cat > csr.conf <<EOF
[req]
distinguished_name = req_distinguished_name
req_extensions = v3_req
prompt = no

[req_distinguished_name]
CN = ${SERVICE_NAME}.${NAMESPACE}.svc

[v3_req]
keyUsage = keyEncipherment, dataEncipherment
extendedKeyUsage = serverAuth
subjectAltName = @alt_names

[alt_names]
DNS.1 = ${SERVICE_NAME}
DNS.2 = ${SERVICE_NAME}.${NAMESPACE}
DNS.3 = ${SERVICE_NAME}.${NAMESPACE}.svc
DNS.4 = ${SERVICE_NAME}.${NAMESPACE}.svc.cluster.local
EOF

# 生成证书签名请求
openssl req -new -key tls.key -out tls.csr -config csr.conf

# 使用CA签名
openssl x509 -req -in tls.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
  -out tls.crt -days 365 -extensions v3_req -extfile csr.conf

# 创建Kubernetes Secret
echo "Creating Kubernetes secret..."
kubectl create secret tls ${SERVICE_NAME}-tls \
  --cert=tls.crt \
  --key=tls.key \
  -n ${NAMESPACE} \
  --dry-run=client -o yaml > secret.yaml

# 输出CA Bundle(base64编码,用于MutatingWebhookConfiguration)
echo ""
echo "CA Bundle (for MutatingWebhookConfiguration):"
cat ca.crt | base64 | tr -d '\n'
echo ""

# 清理临时文件
rm -f tls.csr csr.conf ca.srl

echo ""
echo "Done! Files generated:"
echo "  - ca.crt: CA certificate"
echo "  - ca.key: CA private key"
echo "  - tls.crt: Webhook server certificate"
echo "  - tls.key: Webhook server private key"
echo "  - secret.yaml: Kubernetes TLS secret"

运行生成证书:

chmod +x certs/generate.sh
cd certs && ./generate.sh

方案2:使用cert-manager自动管理

如果集群安装了cert-manager,可以自动管理证书:

apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: nginx-sidecar-injector-tls
  namespace: kube-system
spec:
  secretName: nginx-sidecar-injector-tls
  dnsNames:
    - nginx-sidecar-injector
    - nginx-sidecar-injector.kube-system
    - nginx-sidecar-injector.kube-system.svc
    - nginx-sidecar-injector.kube-system.svc.cluster.local
  issuerRef:
    name: kubernetes-ca
    kind: ClusterIssuer

第四步:部署Webhook服务

Dockerfile

FROM golang:1.21-alpine AS builder

WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download

COPY main.go .
RUN CGO_ENABLED=0 GOOS=linux go build -o webhook main.go

FROM alpine:latest
RUN apk --no-cache add ca-certificates

WORKDIR /
COPY --from=builder /app/webhook /webhook

# 创建证书目录
RUN mkdir -p /etc/webhook/certs

EXPOSE 8443

ENTRYPOINT ["/webhook"]

构建镜像:

docker build -t your-registry/nginx-sidecar-injector:v1.0 .
docker push your-registry/nginx-sidecar-injector:v1.0

部署配置

# deploy/namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
  name: kube-system
  labels:
    sidecar-inject: enabled

---
# deploy/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: nginx-sidecar-config
  namespace: kube-system
data:
  default.conf: |
    server {
        listen 80;
        location / {
            proxy_pass http://localhost:8080;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
        }
    }

---
# deploy/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-sidecar-injector
  namespace: kube-system
  labels:
    app: nginx-sidecar-injector
spec:
  replicas: 1
  selector:
    matchLabels:
      app: nginx-sidecar-injector
  template:
    metadata:
      labels:
        app: nginx-sidecar-injector
    spec:
      containers:
        - name: webhook
          image: your-registry/nginx-sidecar-injector:v1.0
          imagePullPolicy: Always
          ports:
            - containerPort: 8443
              name: https
          volumeMounts:
            - name: tls-certs
              mountPath: /etc/webhook/certs
              readOnly: true
          resources:
            limits:
              cpu: 500m
              memory: 256Mi
            requests:
              cpu: 100m
              memory: 128Mi
      volumes:
        - name: tls-certs
          secret:
            secretName: nginx-sidecar-injector-tls

---
# deploy/service.yaml
apiVersion: v1
kind: Service
metadata:
  name: nginx-sidecar-injector
  namespace: kube-system
  labels:
    app: nginx-sidecar-injector
spec:
  ports:
    - port: 443
      targetPort: 8443
      name: https
  selector:
    app: nginx-sidecar-injector

部署:

kubectl apply -f deploy/configmap.yaml
kubectl apply -f deploy/secret.yaml  # 使用生成的secret.yaml
kubectl apply -f deploy/deployment.yaml
kubectl apply -f deploy/service.yaml

第五步:创建MutatingWebhookConfiguration

这是最关键的一步,告诉Kubernetes什么时候调用我们的Webhook。

# deploy/webhook.yaml
apiVersion: admissionregistration.k8s.io/v1
kind: MutatingWebhookConfiguration
metadata:
  name: nginx-sidecar-injector
webhooks:
  - name: sidecar-injector.webhook.example.com
    # 匹配规则
    rules:
      - operations: ["CREATE", "UPDATE"]
        apiGroups: [""]
        apiVersions: ["v1"]
        resources: ["pods"]
        scope: "Namespaced"
    
    # 只拦截特定namespace
    namespaceSelector:
      matchLabels:
        sidecar-inject: enabled
    
    # 或根据对象标签选择
    # objectSelector:
    #   matchLabels:
    #     inject-sidecar: "true"
    
    clientConfig:
      service:
        name: nginx-sidecar-injector
        namespace: kube-system
        path: "/mutate"
        port: 443
      # CA Bundle,替换为ca.crt的base64编码
      caBundle: ${CA_BUNDLE}
    
    admissionReviewVersions: ["v1", "v1beta1"]
    sideEffects: None
    timeoutSeconds: 5
    failurePolicy: Ignore  # 生产环境建议用Fail
    reinvocationPolicy: Never

注入CA Bundle:

# 获取CA Bundle
CA_BUNDLE=$(cat certs/ca.crt | base64 | tr -d '\n')

# 替换并应用
sed "s/\${CA_BUNDLE}/$CA_BUNDLE/g" deploy/webhook.yaml | kubectl apply -f -

第六步:验证注入效果

1. 标记namespace

kubectl label namespace default sidecar-inject=enabled

2. 创建测试Pod

# test-pod.yaml
apiVersion: v1
kind: Pod
metadata:
  name: test-app
  namespace: default
spec:
  containers:
    - name: main-app
      image: hashicorp/http-echo
      args:
        - "-text=Hello from main app"
      ports:
        - containerPort: 5678
kubectl apply -f test-pod.yaml

3. 查看注入结果

# 查看Pod详情
kubectl get pod test-app -o yaml

# 应该看到两个容器
kubectl get pod test-app -o jsonpath='{.spec.containers[*].name}'
# 输出: main-app nginx-sidecar

# 查看sidecar日志
kubectl logs test-app -c nginx-sidecar

# 测试访问(通过sidecar访问主应用)
kubectl port-forward pod/test-app 8080:80
curl http://localhost:8080
# 输出: Hello from main app

4. 查看Webhook日志

kubectl logs -n kube-system -l app=nginx-sidecar-injector

应该看到:

Received admission request: default/test-app CREATE
Injected sidecar into pod default/test-app

踩坑实录

坑1:证书CN和DNS名称不匹配

现象:Kubernetes调用Webhook时报错x509: certificate is valid for ... not for nginx-sidecar-injector.kube-system.svc

解决:确保证书的Subject Alternative Name包含Service的所有可能域名。

坑2:JSON Patch格式错误

现象:Webhook返回成功,但Pod没有变化

解决:JSON Patch路径要使用RFC 6901格式,特别是特殊字符要转义:

// annotation中的/要转义为~1
"/metadata/annotations/sidecar-injector~1injected"

坑3:failurePolicy设置不当

现象:Webhook服务挂了,导致所有Pod创建失败

解决:开发阶段用Ignore,生产环境用Fail但要注意高可用。

坑4:忽略系统namespace

现象:kube-system中的Pod也被注入sidecar,导致集群异常

解决:使用namespaceSelector排除系统namespace:

namespaceSelector:
  matchExpressions:
    - key: kubernetes.io/metadata.name
      operator: NotIn
      values: ["kube-system", "kube-public", "kube-node-lease"]

坑5:Webhook超时

现象:Pod创建慢,或超时失败

解决:优化Webhook处理逻辑,设置合理的timeout:

timeoutSeconds: 5

进阶优化

1. 添加Metrics监控

import "github.com/prometheus/client_golang/prometheus"

var (
	injectionCounter = prometheus.NewCounterVec(
		prometheus.CounterOpts{
			Name: "sidecar_injection_total",
			Help: "Total number of sidecar injections",
		},
		[]string{"namespace", "status"},
	)
)

func init() {
	prometheus.MustRegister(injectionCounter)
}

2. 支持配置化

apiVersion: v1
kind: ConfigMap
metadata:
  name: sidecar-injector-config
data:
  config.yaml: |
    sidecarContainer:
      image: nginx:alpine
      resources:
        limits:
          cpu: 100m
          memory: 128Mi
      port: 80

3. 使用Controller运行时框架

可以用controller-runtime简化Webhook开发:

import (
    "sigs.k8s.io/controller-runtime/pkg/webhook"
    "sigs.k8s.io/controller-runtime/pkg/webhook/admission"
)

type PodMutator struct {
    Client  client.Client
    Decoder *admission.Decoder
}

func (m *PodMutator) Default(ctx context.Context, obj runtime.Object) error {
    pod := obj.(*corev1.Pod)
    // 注入sidecar逻辑
    return nil
}

总结

通过这个项目,我们完整实践了K8s准入控制器的开发:

  1. 理解原理:知道Webhook在API请求链中的位置和作用
  2. 开发实现:用Go实现了MutatingWebhook服务器
  3. 证书管理:生成了自签名证书并配置到Kubernetes
  4. 部署验证:完整部署流程并验证注入效果

实际开发中还有很多细节要考虑(高可用、配置管理、错误处理等),但这个基础版本已经可以工作。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

加倍巴巴

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值