前言:那个让我怀疑人生的下午
代码写完了,单测过了,本地起 webhook server 也没问题。本以为部署就是体力活——
把代码打成镜像 → 推到节点 → kubectl apply 一把梭 → 收工下班。
理想很美好。现实是:
- 第一次部署,证书签错了,APIServer 调 webhook 直接
x509: certificate signed by unknown authority - 第二次重新签,CABundle 填的是 base64 解码后的内容,APIServer 还是不认
- 第三次终于过了认证,但 Pod 创建出来 没有 sidecar——日志里没有任何报错
- 第四次发现 namespaceSelector 的 label 写反了
- 第五次……
折腾完一个下午,回头看其实就 6 步。但每一步都有一个坑能让你卡半小时。今天把这条路完整走一遍,让你少踩点坑。
本节重点
- 创建 CA 证书,通过 CSR 让 APIServer 签名
- 获取审批后的证书,用它创建
MutatingWebhookConfiguration - 部署 sidecar-injector 并验证注入效果
一、编译打镜像
1.1 Makefile
把 Go 代码编译成 Linux 平台二进制(重点:必须用 GOOS=linux,否则镜像里跑不起来),再用 Docker 打包。
IMAGE_NAME ?= sidecar-injector
PWD := $(shell pwd)
BASE_DIR := $(shell basename $(PWD))
export GOPATH ?= $(GOPATH_DEFAULT)
IMAGE_TAG ?= $(shell date +v%Y%m%d)-$(shell git describe --match=$(git rev-parse --short=8 HEAD) --tags --always --dirty)
build:
@echo "Building the $(IMAGE_NAME) binary..."
@CGO_ENABLED=0 go build -o $(IMAGE_NAME) ./pkg/
build-linux:
@echo "Building the $(IMAGE_NAME) binary for Docker (linux)..."
@GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o $(IMAGE_NAME) ./pkg/
############################################################
# image section
############################################################
image: build-image
build-image: build-linux
@echo "Building the docker image: $(IMAGE_NAME)..."
@docker build -t $(IMAGE_NAME) -f Dockerfile .
.PHONY: all build image
💡
CGO_ENABLED=0是关键:用alpine作为基础镜像时,glibc 是缺失的。如果你的 Go 程序启用了 CGO(默认开启),编译出来的二进制依赖 glibc,进 alpine 容器后会报not found—— 这个错超级让人迷惑,二进制明明存在,但执行就报 not found。CGO_ENABLED=0强制静态编译,alpine 也能直接跑。
1.2 Dockerfile
FROM alpine:latest
# set environment variables
ENV SIDECAR_INJECTOR=/usr/local/bin/sidecar-injector \
USER_UID=1001 \
USER_NAME=sidecar-injector
COPY sidecar-injector /usr/local/bin/sidecar-injector
# set entrypoint
ENTRYPOINT ["/usr/local/bin/sidecar-injector"]
# switch to non-root user
USER ${USER_UID}
⚠️ 安全提示:
USER 1001这一行别省。webhook 不需要 root 权限,跑成非 root 是 K8s 安全基线(Pod Security Standards)的硬要求。生产集群如果开了restrictedprofile,root 容器直接被 admission 拒绝。
1.3 打镜像 + 分发到节点
# 在 master 节点构建
make build-image
# 导出镜像
docker save sidecar-injector > a.tar
# 拷贝到 worker 节点
scp a.tar k8s-node01:~
# 在 worker 节点导入(containerd 运行时)
ctr --namespace k8s.io images import a.tar
💡 关于
ctr --namespace k8s.io:containerd 默认 namespace 是default,但 kubelet 用的是k8s.io。导入到错的 namespace,kubelet 找不到镜像,Pod 永远 ImagePullBackOff。这个坑我踩过两次。如果你用的是 Docker 运行时(K8s 1.24 之前),用
docker load < a.tar就行。
更省事的做法是搭一个内部 Registry(Harbor / Nexus),make build-image 后 docker push,节点直接拉。这里为了演示简化用 save/load。
二、创建 namespace
# 业务 Pod 部署的 ns(这里的 Pod 会被注入 sidecar)
kubectl create ns nginx-injection
# sidecar-injector 自己部署的 ns
kubectl create ns sidecar-injector
两个 namespace 分开管理:
sidecar-injector:基础设施 ns,只跑 webhook 自己nginx-injection:业务 ns,被 webhook 监听并注入
三、CA 证书:让 APIServer 信任你的 webhook
这是整个部署最容易翻车的一步。MutatingWebhook 必须通过 HTTPS 暴露,APIServer 调用 webhook 时会校验证书。如果证书不被 APIServer 信任,APIServer 调不通 webhook,Pod 创建会卡住或失败。
完整流程:生成密钥 → 创建 CSR → 让 K8s APIServer 签名 → 取签名后的证书 → 创建 Secret → 把 CA 填到 MutatingWebhookConfiguration。
3.1 生成 CSR 配置文件
cat <<EOF > csr.conf
[req]
req_extensions = v3_req
distinguished_name = req_distinguished_name
[req_distinguished_name]
[ v3_req ]
basicConstraints = CA:FALSE
keyUsage = nonRepudiation, digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth
subjectAltName = @alt_names
[alt_names]
DNS.1 = sidecar-injector-webhook-svc
DNS.2 = sidecar-injector-webhook-svc.sidecar-injector
DNS.3 = sidecar-injector-webhook-svc.sidecar-injector.svc
EOF
🚨 最常见的坑:SAN 必须包含 Service 的完整 DNS。
APIServer 调 webhook 用的是 Service DNS(
sidecar-injector-webhook-svc.sidecar-injector.svc),TLS 校验时会比对证书 SAN 字段和这个 DNS。漏写或写错 SAN,会直接报:x509: certificate is valid for xxx, not sidecar-injector-webhook-svc.sidecar-injector.svc上面三个 DNS 名都要写全(短名 / namespace 短名 / 完整 FQDN),缺一不可。
3.2 生成私钥
openssl genrsa -out server-key.pem 2048
3.3 用私钥生成 CSR 请求文件
openssl req -new \
-key server-key.pem \
-subj "/CN=sidecar-injector-webhook-svc.sidecar-injector.svc" \
-out server.csr \
-config csr.conf
CN(Common Name)也写成 Service 的完整 DNS,便于一些老版本客户端做兼容性校验。
3.4 删除可能存在的旧 CSR
kubectl delete csr sidecar-injector-webhook-svc.sidecar-injector
💡 为什么先删? CSR 资源的 name 在集群内全局唯一。如果你之前签过一次(哪怕 Pending 状态),再创建同名的会报
AlreadyExists。重新部署时养成习惯先删旧的。
3.5 向 APIServer 申请 CSR 签名
cat <<EOF | kubectl create -f -
apiVersion: certificates.k8s.io/v1beta1
kind: CertificateSigningRequest
metadata:
name: sidecar-injector-webhook-svc.sidecar-injector
spec:
groups:
- system:authenticated
request: $(< server.csr base64 | tr -d '\n')
usages:
- digital signature
- key encipherment
- server auth
EOF
⚠️ 版本坑:
certificates.k8s.io/v1beta1在 K8s 1.22+ 被移除,必须改成certificates.k8s.io/v1。v1 版本多了一个必填字段signerName:spec: signerName: kubernetes.io/kubelet-serving # 或 kubernetes.io/legacy-unknown request: ...不同 signerName 对应不同的签名器,能否被自动审批也不一样。
kubernetes.io/legacy-unknown不会被自动审批,必须手动 approve(适合 webhook 这种一次性场景)。
3.6 查看 CSR 状态
[root@k8s-master01 ssl]# kubectl get csr
NAME AGE SIGNERNAME REQUESTOR CONDITION
sidecar-injector-webhook-svc.sidecar-injector 54s kubernetes.io/legacy-unknown kubernetes-admin Pending
Pending 状态——等待管理员审批。
3.7 审批 CSR
kubectl certificate approve sidecar-injector-webhook-svc.sidecar-injector
# 输出:
# certificatesigningrequest.certificates.k8s.io/sidecar-injector-webhook-svc.sidecar-injector approved
3.8 提取签名后的证书
serverCert=$(kubectl get csr sidecar-injector-webhook-svc.sidecar-injector -o jsonpath='{.status.certificate}')
echo "${serverCert}" | openssl base64 -d -A -out server-cert.pem
这一步把 APIServer 签好的证书从 CSR 资源里取出来,base64 解码后存到本地 server-cert.pem。
3.9 把证书放进 Secret
kubectl create secret generic sidecar-injector-webhook-certs \
--from-file=key.pem=server-key.pem \
--from-file=cert.pem=server-cert.pem \
--dry-run=client -o yaml |
kubectl -n sidecar-injector apply -f -
💡 这里用
--dry-run=client -o yaml | apply的小技巧:先在客户端生成 YAML,再 apply。好处是幂等——如果 Secret 已存在,会更新而不是报错。kubectl create secret直接执行的话,重复运行会AlreadyExists。
检查 Secret:
kubectl get secret -n sidecar-injector
# 输出:
# NAME TYPE DATA AGE
# default-token-hvgnl kubernetes.io/service-account-token 3 25m
# sidecar-injector-webhook-certs Opaque 2 25m
DATA=2 说明 key.pem 和 cert.pem 都进去了。
四、CABundle:让 MutatingWebhookConfiguration 认证书
MutatingWebhookConfiguration 里有个 caBundle 字段,告诉 APIServer:“调你这个 webhook 时,用这个 CA 来验证它的证书”。我们的证书是 APIServer 自己签的,所以 caBundle 就是 集群 CA 的 base64 编码。
4.1 获取集群 CA
CA_BUNDLE=$(kubectl config view --raw --minify --flatten -o jsonpath='{.clusters[].cluster.certificate-authority-data}')
# 备用方案:从 default sa 的 token secret 里取
if [ -z "${CA_BUNDLE}" ]; then
CA_BUNDLE=$(kubectl get secrets -o jsonpath="{.items[?(@.metadata.annotations['kubernetes\.io/service-account\.name']=='default')].data.ca\.crt}")
fi
🚨 超级容易踩的坑:
certificate-authority-data拿到的已经是 base64 编码的字符串,直接填到 caBundle 里就行,不要base64 -d解码!我第一次部署就栽在这——看到字符串以为是原始数据,手贱解了一次码再填进去,结果 APIServer 把它当 PEM 解析失败,报
tls: failed to verify certificate: x509: certificate signed by unknown authority。caBundle 期望的就是 base64 后的字符串。
4.2 把 CABundle 替换到 webhook 配置里
mutating_webhook.yaml 模板里写了占位符 ${CA_BUNDLE}:
# mutating_webhook.yaml(节选)
clientConfig:
service:
name: sidecar-injector-webhook-svc
namespace: sidecar-injector
path: "/mutate"
caBundle: ${CA_BUNDLE}
用 sed 把占位符替换成真实值:
cat deploy/mutating_webhook.yaml | sed -e "s|\${CA_BUNDLE}|${CA_BUNDLE}|g" > deploy/mutatingwebhook-ca-bundle.yaml
检查替换结果:
cat deploy/mutatingwebhook-ca-bundle.yaml
caBundle 字段应该是一串很长的 base64 字符串(通常 1000+ 字符),不是 ${CA_BUNDLE} 字面量。
4.3 用脚本一键完成
上面的步骤可以全部封装成脚本(这里复用了 Istio 项目的 webhook-create-signed-cert.sh):
chmod +x ./deploy/*.sh
# 一键完成:生成证书 → 申请签名 → 审批 → 创建 Secret
./deploy/webhook-create-signed-cert.sh \
--service sidecar-injector-webhook-svc \
--secret sidecar-injector-webhook-certs \
--namespace sidecar-injector
# 一键完成:CABundle 替换
cat deploy/mutating_webhook.yaml | \
deploy/webhook-patch-ca-bundle.sh > \
deploy/mutatingwebhook-ca-bundle.yaml
生产环境强烈建议用 cert-manager 自动管理证书生命周期,省心很多。
五、部署 sidecar-injector
5.1 先部署 webhook 本体
按顺序部署三个资源:
# 1. 配置文件(sidecar 模板:要注入哪些容器、哪些 volume)
kubectl create -f deploy/inject_configmap.yaml
# 2. Deployment(webhook server)
kubectl create -f deploy/inject_deployment.yaml
# 3. Service(暴露 webhook HTTPS 端口)
kubectl create -f deploy/inject_service.yaml
检查 Pod 和 Service:
[root@k8s-master01]# kubectl get pod -n sidecar-injector
NAME READY STATUS RESTARTS AGE
sidecar-injector-webhook-deployment-5cd7466c9f-xqpq4 1/1 Running 0 98s
[root@k8s-master01]# kubectl get svc -n sidecar-injector
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
sidecar-injector-webhook-svc ClusterIP 10.96.171.114 <none> 443/TCP 111s
⚠️ Pod 起不来怎么办? 最常见 3 个原因:
- 镜像不在节点上 → ImagePullBackOff,检查
ctr -n k8s.io images ls | grep sidecar-injector- Secret 没挂上 → Pod 报
MountVolume.SetUp failed,检查 Deployment 里volumes.secret.secretName和实际 Secret 名一致- 二进制和镜像架构不匹配 → CrashLoopBackOff,日志报
exec format error,确认GOARCH和节点架构一致
5.2 部署 MutatingWebhookConfiguration
这一步把 webhook 注册到 APIServer。注册成功后,所有匹配的 Pod 创建请求都会先经过你的 webhook。
kubectl create -f deploy/mutatingwebhook-ca-bundle.yaml
检查:
[root@k8s-master01]# kubectl get MutatingWebhookConfiguration -A
NAME WEBHOOKS AGE
sidecar-injector-webhook-cfg 1 57s
🚨 这一步部署完之后,整个集群的 Pod 创建都会过这个 webhook。如果 webhook 配置有问题(比如证书错、Service 不通),可能会让 所有 Pod 都无法创建。务必确认
failurePolicy: Ignore已配置,或者你只是在测试集群操作。
5.3 部署 sidecar 容器需要的 ConfigMap
我们要注入的 sidecar 是个 nginx 容器,它需要 nginx 配置文件——这个配置以 ConfigMap 形式挂进 sidecar:
kubectl create -f deploy/nginx_configmap.yaml
5.4 给业务 ns 打标签,启用注入
MutatingWebhookConfiguration 里通常配置了 namespaceSelector,只对指定 label 的 namespace 生效。这样可以按 namespace 粒度控制注入范围,避免影响其他业务。
kubectl create ns nginx-injection
kubectl label namespace nginx-injection sidecar-injection=enabled
sidecar-injection=enabled 这个 label 必须和 webhook 配置里的 namespaceSelector 完全一致:
namespaceSelector:
matchLabels:
nginx-sidecar-injection: enabled
🚨 超容易翻车点:上面这个 YAML 写的是
nginx-sidecar-injection,但我们打的 label 是sidecar-injection——两个 key 不一样!这是原文档的一个不一致,实际部署时必须保证 label key 和 namespaceSelector 完全相同,否则 webhook 永远不会被触发,且没有任何报错。排查这个问题的最快方法:
kubectl get ns nginx-injection --show-labels对照 webhook 配置里的namespaceSelector.matchLabels,字符一个一个比。
确认 label 已打上:
kubectl get ns -L nginx-sidecar-injection
NAME STATUS AGE NGINX-SIDECAR-INJECTION
default Active 156d
kube-system Active 156d
nginx-injection Active 3h57m enabled
sidecar-injector Active 4h38m
最后一列显示 enabled 才算生效。
六、验证:注入一个 Pod 试试
6.1 部署一个需要注入的 Pod
关键是 annotations 里写 need_inject: "true":
apiVersion: v1
kind: Pod
metadata:
namespace: nginx-injection
name: test-alpine-inject-01
labels:
role: myrole
annotations:
sidecar-injector-webhook.nginx.sidecar/need_inject: "true"
spec:
containers:
- image: alpine
command:
- /bin/sh
- "-c"
- "sleep 60m"
imagePullPolicy: IfNotPresent
name: alpine
restartPolicy: Always
部署:
[root@k8s-master01 deploy]# kubectl create -f test_sleep_deployment.yaml
pod/test-alpine-inject-01 created
6.2 验证 sidecar 已注入
注意 READY 列——原本 Pod 只有 1 个容器(alpine),现在变成 2/2:
[root@k8s-master01 deploy]# kubectl get pod -n nginx-injection -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE
test-alpine-inject-01 2/2 Running 0 78s 10.100.85.216 k8s-node01 <none>
curl 一下 Pod IP 的 80 端口,应该能拿到 nginx 的响应(404 是正常的,因为我们没配 root 目录):
[root@k8s-master01 deploy]# curl 10.100.85.216
<html>
<head><title>404 Not Found</title></head>
<body bgcolor="white">
<center><h1>404 Not Found</h1></center>
<hr><center>nginx/1.12.2</center>
</body>
</html>
看到 nginx/1.12.2 说明 sidecar 已经成功注入并对外提供服务。
6.3 看 sidecar-injector 日志确认整条链路
kubectl logs -n sidecar-injector deployment/sidecar-injector-webhook-deployment
应该能看到类似这样的日志(节选关键部分):
I0910 08:35:14.788857 webhook.go:179] serveMutate.receive.request: Body={"kind":"AdmissionReview","apiVersion":"admission.k8s.io/v1beta1","request":{"uid":"17d2ced2-...","kind":{"group":"","version":"v1","kind":"Pod"},"name":"test-alpine-inject-01","namespace":"nginx-injection",...}}
I0910 08:35:14.789561 webhook.go:128] AdmissionReview for Kind=/v1, Kind=Pod, Namespace=nginx-injection Name=test-alpine-inject-01 UID=17d2ced2-... patchOperation=CREATE
I0910 08:35:14.789945 webhook.go:154] AdmissionResponse: patch=[
{"op":"add","path":"/spec/containers/-","value":{"name":"sidecar-nginx","image":"nginx:1.12.2","ports":[{"containerPort":80}],...}},
{"op":"add","path":"/spec/volumes/-","value":{"name":"nginx-conf","configMap":{"name":"nginx-configmap"}}},
{"op":"add","path":"/metadata/annotations","value":{"sidecar-injector-webhook.nginx.sidecar/status":"injected"}}
]
I0910 08:35:14.789987 webhook.go:221] Ready to write reponse ...
三段 patch 一目了然:加 sidecar 容器 → 加 nginx-conf volume → 加 status: injected 标记。
6.4 部署一个不需要注入的 Pod 做对照
把 annotation 改成 need_inject: "false",验证 webhook 能正确跳过:
apiVersion: v1
kind: Pod
metadata:
namespace: nginx-injection
name: test-alpine-inject-02
labels:
role: myrole
annotations:
sidecar-injector-webhook.nginx.sidecar/need_inject: "false"
spec:
containers:
- image: alpine
command:
- /bin/sh
- "-c"
- "sleep 60m"
imagePullPolicy: IfNotPresent
name: alpine
restartPolicy: Always
部署后查看:
kubectl get pod -n nginx-injection -o wide
NAME READY STATUS RESTARTS AGE IP NODE
test-alpine-inject-01 2/2 Running 0 6m8s 10.100.85.216 k8s-node01
test-alpine-inject-02 1/1 Running 0 77s 10.100.85.215 k8s-node01
test-alpine-inject-02 只有 1/1——没被注入,符合预期。
日志里也能看到对应的跳过逻辑:
I0910 08:40:05.466733 webhook.go:106] [skip_mutation][reason=pod_not_need][name:test-alpine-inject-02][ns:nginx-injection]
I0910 08:40:05.466765 webhook.go:133] Skipping mutation for nginx-injection/test-alpine-inject-02 due to policy check
I0910 08:40:05.466815 webhook.go:221] Ready to write reponse ...
reason=pod_not_need 清晰地说明了原因。这种带原因码的日志在生产环境排查问题时巨好用,强烈建议你的 webhook 也这么打。
七、完整流程图
┌──────────────────────────────────────┐
│ Step 1: 编译打镜像 │
│ make build-image → docker save │
│ → scp → ctr import │
└──────────────┬───────────────────────┘
▼
┌──────────────────────────────────────┐
│ Step 2: 创建 namespace │
│ nginx-injection / sidecar-injector │
└──────────────┬───────────────────────┘
▼
┌──────────────────────────────────────┐
│ Step 3: CA 证书 │
│ openssl → CSR → approve │
│ → Secret(key.pem, cert.pem) │
└──────────────┬───────────────────────┘
▼
┌──────────────────────────────────────┐
│ Step 4: CABundle 替换 │
│ kubectl config → caBundle 字段 │
└──────────────┬───────────────────────┘
▼
┌──────────────────────────────────────┐
│ Step 5: 部署 │
│ ConfigMap + Deployment + Service │
│ + MutatingWebhookConfiguration │
│ + ns label │
└──────────────┬───────────────────────┘
▼
┌──────────────────────────────────────┐
│ Step 6: 验证 │
│ apply test pod → 看 2/2 + curl │
└──────────────────────────────────────┘
八、部署排查 Checklist(生产环境保命)
下面这份清单是我踩坑后整理的,强烈建议照着一条条对:
| # | 检查项 | 命令 / 方法 | 出问题的现象 |
|---|---|---|---|
| 1 | 镜像在节点上 | ctr -n k8s.io images ls | grep sidecar | Pod ImagePullBackOff |
| 2 | 镜像架构匹配 | file sidecar-injector(看 ELF 架构) | Pod CrashLoopBackOff, exec format error |
| 3 | webhook Pod Running | kubectl get pod -n sidecar-injector | 注入完全不生效 |
| 4 | Service 可达 | kubectl get svc -n sidecar-injector | APIServer 调不通 webhook |
| 5 | Secret 已挂载 | kubectl describe pod 看 Volumes 段 | webhook 启动失败 |
| 6 | 证书 SAN 含 Service DNS | openssl x509 -in cert.pem -text -noout | grep DNS | x509: certificate is valid for... |
| 7 | CABundle 不要再 base64 解码 | 看 caBundle 字段长度(>1000 字符) | x509: signed by unknown authority |
| 8 | CSR API 版本对 | K8s ≥1.22 用 v1,并写 signerName | CSR 创建失败 |
| 9 | namespaceSelector label 完全匹配 | kubectl get ns --show-labels 对照 webhook 配置 | webhook 永远不被触发,无报错 |
| 10 | failurePolicy: Ignore | 看 MutatingWebhookConfiguration 字段 | webhook 挂了导致整集群 Pod 创建失败 |
| 11 | annotation key 拼写一致 | kubectl get pod -o yaml 比对代码常量 | mutationRequired 永远 return false |
| 12 | webhook 自身不被自己注入 | objectSelector 排除 webhook 自己 | webhook 注入死锁,Pod 起不来 |
九、本节小结
把 sidecar 注入器跑起来,本质就是 6 个 step:
- 打镜像:注意静态编译 + 推到正确的 containerd namespace
- 建 ns:业务 ns 和 webhook ns 分离
- 签证书:注意 SAN、CSR API 版本、签好的证书放进 Secret
- 填 CABundle:直接用 base64 字符串,不要解码
- 部署 webhook + 注册 MutatingWebhookConfiguration:注意
failurePolicy - 打 ns label + 验证:label key 必须和 namespaceSelector 一致
整条链路里最隐蔽的坑是第 9 条——namespaceSelector label 拼错。它不会有任何报错,webhook server 一切正常,Pod 创建也正常,就是 sidecar 注不进去。我第一次部署有一半时间都是在找这个问题。
下一节会进入更进阶的话题:生产环境如何用 cert-manager 自动续期证书、如何用 objectSelector 排除 webhook 自身、以及 webhook 升级时的零停机方案。
十、你踩过这些坑吗?
- 你们生产环境的 webhook 证书是手动签的还是用 cert-manager 自动管理的?过期处理方案是什么?
failurePolicy选Ignore还是Fail?你们是基于什么场景做的决策?是否遇到过 webhook 挂了导致整集群 Pod 创建失败的情况?- webhook 升级时怎么做到对业务无感?滚动更新有没有坑过你?
欢迎在评论区分享你的踩坑实录,我会整理补充到这份 Checklist 里。
十一、延伸思考
- 如果一个 Pod 要被 多个 MutatingWebhook 处理(比如 Istio + 你自己的),patch 的合并顺序是什么?谁的修改会被覆盖?
MutatingWebhookConfiguration里的reinvocationPolicy: IfNeeded是干什么的?什么时候必须开?- 怎么给 webhook 做 健康检查?仅仅
/healthz还不够,怎么保证 TLS 配置也是健康的?
–


1034

被折叠的 条评论
为什么被折叠?



