Skip to main content

4. Admission Webhook:配额校验

为什么需要 Webhook​

CRD 的 schema 只能校验"字段类型对不对"——replicas 是整数、partition 大于等于 0。但业务规则 schema 写不了:

  • replicas 不能超过 100(配额限制)
  • maxUnavailable 不能大于 replicas(否则全挂)
  • 金丝雀发布时 partition 必须小于 replicas(否则啥也不发)
  • 资源 requests 必须填(防止不限制资源的 Pod 进集群)

这些用 Validating Webhook 在提交时拦住。同时,默认值填充用 Mutating Webhook——不填 maxUnavailable 就给默认 1,不填 type 就给 InPlace。

生成 Webhook 骨架​

kubebuilder create webhook \
--group apps \
--version v1alpha1 \
--kind NeoSet \
--defaulting \
--validation

生成 api/v1alpha1/neoset_webhook.go。

Mutating Webhook:默认值填充​

// api/v1alpha1/neoset_webhook.go
package v1alpha1

import (
"k8s.io/apimachinery/pkg/runtime"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/webhook"
"k8s.io/apimachinery/pkg/util/intstr"
)

// +kubebuilder:webhook:path=/mutate-apps-neo-io-v1alpha1-neoset,mutating=true,failurePolicy=fail,sideEffects=None,groups=apps.neo.io,resources=neosets,verbs=create;update,versions=v1alpha1,name=mneoset.kb.io,admissionReviewVersions=v1

var _ webhook.CustomDefaulter = &NeoSet{}

// Default 实现 Mutating Webhook
func (r *NeoSet) Default() {
// 1. replicas 默认 1
if r.Spec.Replicas == nil {
r.Spec.Replicas = ptrInt32(1)
}

// 2. 更新类型默认 InPlace
if r.Spec.UpdateStrategy.Type == "" {
r.Spec.UpdateStrategy.Type = InPlaceUpdateStrategyType
}

// 3. maxUnavailable 默认 1
if r.Spec.UpdateStrategy.MaxUnavailable == nil {
r.Spec.UpdateStrategy.MaxUnavailable = &intstr.IntOrString{
Type: intstr.Int,
IntVal: 1,
}
}

// 4. partition 默认 0(全部更新)
if r.Spec.UpdateStrategy.Partition == nil {
r.Spec.UpdateStrategy.Partition = ptrInt32(0)
}

// 5. 就绪策略默认 Probe
if r.Spec.UpdateStrategy.InPlaceUpdateStrategy == nil {
r.Spec.UpdateStrategy.InPlaceUpdateStrategy = &InPlaceUpdateStrategy{
Readiness: ReadinessTypeProbe,
}
}
if r.Spec.UpdateStrategy.InPlaceUpdateStrategy.ReadinessTimeoutSeconds == nil {
r.Spec.UpdateStrategy.InPlaceUpdateStrategy.ReadinessTimeoutSeconds = ptrInt64(300)
}

// 6. revisionHistoryLimit 默认 10
if r.Spec.RevisionHistoryLimit == nil {
r.Spec.RevisionHistoryLimit = ptrInt32(10)
}
}

func ptrInt32(v int32) *int32 { return &v }
func ptrInt64(v int64) *int64 { return &v }

Validating Webhook:配额校验​

// +kubebuilder:webhook:path=/validate-apps-neo-io-v1alpha1-neoset,mutating=false,failurePolicy=fail,sideEffects=None,groups=apps.neo.io,resources=neosets,verbs=create;update,versions=v1alpha1,name=vneoset.kb.io,admissionReviewVersions=v1

var _ webhook.Validator = &NeoSet{}

const (
MaxReplicas int32 = 100 // 副本数上限
MaxStoragePerPod int64 = 500 // 单 Pod 存储上限 Gi
MaxCPUPerPod string = "8" // 单 Pod CPU 上限
MaxMemoryPerPod string = "16Gi"
)

// ValidateCreate 创建时校验
func (r *NeoSet) ValidateCreate() error {
return r.validateNeoSet()
}

// ValidateUpdate 更新时校验
func (r *NeoSet) ValidateUpdate(old runtime.Object) error {
oldNeo, ok := old.(*NeoSet)
if !ok {
return fmt.Errorf("旧对象类型错误")
}

// 1. 不允许改 selector(会导致 Pod 归属混乱)
if !equality.Semantic.DeepEqual(r.Spec.Selector, oldNeo.Spec.Selector) {
return fmt.Errorf("selector 不允许修改")
}

// 2. 不允许改 volumeClaimTemplates(PVC 已创建,改了不一致)
if !equality.Semantic.DeepEqual(r.Spec.VolumeClaimTemplates, oldNeo.Spec.VolumeClaimTemplates) {
return fmt.Errorf("volumeClaimTemplates 不允许修改")
}

// 3. 缩容时不能低于当前 updatedReplicas(防止删掉已更新的 Pod)
if r.Spec.Replicas != nil && oldNeo.Status.UpdatedReplicas > 0 {
if *r.Spec.Replicas < oldNeo.Status.UpdatedReplicas {
return fmt.Errorf("缩容不能低于已更新副本数 %d", oldNeo.Status.UpdatedReplicas)
}
}

return r.validateNeoSet()
}

// ValidateDelete 删除时校验(NeoSet 不需要)
func (r *NeoSet) ValidateDelete() error {
return nil
}

// validateNeoSet 通用校验
func (r *NeoSet) validateNeoSet() error {
var allErrs field.ErrorList

// 1. 副本数校验
if r.Spec.Replicas != nil {
if *r.Spec.Replicas < 0 {
allErrs = append(allErrs, field.Invalid(
field.NewPath("spec", "replicas"),
*r.Spec.Replicas,
"不能为负数",
))
}
if *r.Spec.Replicas > MaxReplicas {
allErrs = append(allErrs, field.Invalid(
field.NewPath("spec", "replicas"),
*r.Spec.Replicas,
fmt.Sprintf("超过上限 %d", MaxReplicas),
))
}
}

// 2. partition 校验
if r.Spec.UpdateStrategy.Partition != nil && r.Spec.Replicas != nil {
if *r.Spec.UpdateStrategy.Partition > *r.Spec.Replicas {
allErrs = append(allErrs, field.Invalid(
field.NewPath("spec", "updateStrategy", "partition"),
*r.Spec.UpdateStrategy.Partition,
"不能大于 replicas",
))
}
}

// 3. maxUnavailable 校验
if r.Spec.UpdateStrategy.MaxUnavailable != nil && r.Spec.Replicas != nil {
maxUnavail, err := intstr.GetScaledValueFromIntOrPercent(
r.Spec.UpdateStrategy.MaxUnavailable, int(*r.Spec.Replicas), true)
if err != nil {
allErrs = append(allErrs, field.Invalid(
field.NewPath("spec", "updateStrategy", "maxUnavailable"),
r.Spec.UpdateStrategy.MaxUnavailable,
"格式错误",
))
} else if maxUnavail > int(*r.Spec.Replicas) {
allErrs = append(allErrs, field.Invalid(
field.NewPath("spec", "updateStrategy", "maxUnavailable"),
r.Spec.UpdateStrategy.MaxUnavailable,
"不能大于 replicas",
))
}
}

// 4. Pod 资源配额校验
for i, container := range r.Spec.Template.Spec.Containers {
if container.Resources.Requests.Cpu().IsZero() {
allErrs = append(allErrs, field.Required(
field.NewPath("spec", "template", "spec", "containers").Index(i).Child("resources", "requests", "cpu"),
"CPU request 必填",
))
}
if container.Resources.Requests.Memory().IsZero() {
allErrs = append(allErrs, field.Required(
field.NewPath("spec", "template", "spec", "containers").Index(i).Child("resources", "requests", "memory"),
"Memory request 必填",
))
}

// 超额校验
cpuQty := container.Resources.Requests.Cpu()
if cpuQty.Cmp(MaxCPUPerPod) > 0 {
allErrs = append(allErrs, field.Invalid(
field.NewPath("spec", "template", "spec", "containers").Index(i).Child("resources", "requests", "cpu"),
cpuQty.String(),
fmt.Sprintf("超过单 Pod 上限 %s 核", MaxCPUPerPod),
))
}
}

// 5. PVC 存储校验
for i, pvc := range r.Spec.VolumeClaimTemplates {
storage := pvc.Spec.Resources.Requests.Storage()
if storage == nil || storage.IsZero() {
allErrs = append(allErrs, field.Required(
field.NewPath("spec", "volumeClaimTemplates").Index(i).Child("spec", "resources", "requests", "storage"),
"存储大小必填",
))
} else if storage.Value() > MaxStoragePerPod*1024*1024*1024 {
allErrs = append(allErrs, field.Invalid(
field.NewPath("spec", "volumeClaimTemplates").Index(i).Child("spec", "resources", "requests", "storage"),
storage.String(),
fmt.Sprintf("超过单 PVC 上限 %dGi", MaxStoragePerPod),
))
}
}

// 6. InPlace 模式必须有 readinessProbe(原地升级后靠它判断就绪)
if r.Spec.UpdateStrategy.Type == InPlaceUpdateStrategyType {
if r.Spec.UpdateStrategy.InPlaceUpdateStrategy != nil &&
r.Spec.UpdateStrategy.InPlaceUpdateStrategy.Readiness == ReadinessTypeProbe {
for i, container := range r.Spec.Template.Spec.Containers {
if container.ReadinessProbe == nil {
allErrs = append(allErrs, field.Required(
field.NewPath("spec", "template", "spec", "containers").Index(i).Child("readinessProbe"),
"InPlace + Probe 模式必须配置 readinessProbe",
))
}
}
}
}

if len(allErrs) > 0 {
return apierrors.NewInvalid(
GroupKind, r.Name, allErrs)
}
return nil
}

校验效果​

用户提交一个配额超标的 NeoSet:

spec:
replicas: 200 # 超过 100 上限
updateStrategy:
partition: 150 # 超过 replicas... 等等 replicas 也超了
maxUnavailable: "80%" # 80% of 200 = 160,但 replicas 超限了
$ kubectl apply -f mysql.yaml
Error from server: NeoSet.apps.neo.io "mysql" is invalid:
- spec.replicas: Invalid value: 200: 超过上限 100
- spec.updateStrategy.partition: Invalid value: 150: 不能大于 replicas
- spec.template.spec.containers[0].resources.requests.cpu: Required value: CPU request 必填

直接被拦在 API Server,到不了 Controller。

注册 Webhook​

main.go 里注册:

func main() {
// ...
if err := (&neov1alpha1.NeoSet{}).SetupWebhookWithManager(mgr); err != nil {
setupLog.Error(err, "注册 webhook 失败")
os.Exit(1)
}
}
// api/v1alpha1/neoset_webhook.go
func (r *NeoSet) SetupWebhookWithManager(mgr ctrl.Manager) error {
return ctrl.NewWebhookManagedBy(mgr).
For(r).
Complete()
}

Webhook 的 TLS 证书​

Webhook 需要 TLS 证书——API Server 调 Webhook 时走 HTTPS。Kubebuilder 的 cert-manager 集成自动处理,但没装 cert-manager 时要手动生成:

# 生成自签证书
./scripts/cert-generation.sh

# 证书会写到
# config/certmanager/
# - server-cert.pem
# - server-key.pem
# - ca.pem

本地开发用 make run 时,Webhook 跑在本地,K8s 集群需要能访问到。用 kubectl port-forward 或配 webhook service 指向本地。

配额校验的三个层次​

第 1 层: CRD Schema (OpenAPI)
- 字段类型、必填、范围
- replicas 是整数、partition >= 0

第 2 层: Mutating Webhook (本章)
- 默认值填充
- maxUnavailable 不填就给 1

第 3 层: Validating Webhook (本章)
- 业务规则校验
- 配额限制、参数合法性、跨字段约束

这三层在 API Server 准入阶段顺序执行:

用户提交 YAML
│
▼
Schema 校验 (类型/必填)
│
▼
Mutating Webhook (填默认值)
│
▼
Validating Webhook (业务规则)
│
▼
写入 etcd

前置阅读​

  • 设计CRD — 本章前置,校验的是 CRD 定义的字段
  • 使用Kubebuilder — Controller 系列第 9 章,Webhook 概念
  • 安全 — Starter 第 8 章,准入控制基础

思考题​

问题 1:为什么 selector 和 volumeClaimTemplates 不允许修改?

问题 2:maxUnavailable 是百分比 "30%",replicas 是 5。最终值是多少?怎么算的?

问题 3:InPlace + Probe 模式必须配 readinessProbe。如果用户故意用 Ready 模式绕过这个校验,会发生什么?


解答​

问题 1:selector 改了会导致 Pod 归属混乱——旧 selector 选中的 Pod 可能不被新 selector 选中,Controller 以为它们"不属于自己"了,可能误删。volumeClaimTemplates 改了会导致新 Pod 用的 PVC 跟旧 Pod 不一致——已有 PVC 是 10Gi,改成 20Gi 后新 Pod 申请新的 20Gi PVC,但旧 Pod 还是 10Gi,集群里两种规格混着。要改这两者,只能删了重建。

问题 2:intstr.GetScaledValueFromIntOrPercent 算百分比时会向上取整。30% of 5 = 1.5,向上取整 = 2。所以 maxUnavailable=2,意思是滚动过程中最多 2 个 Pod 不可用。但要注意——replicas=5 时 maxUnavailable=2 意味着至少 3 个可用,如果 2 个在更新中挂了,剩下 3 个撑着。

问题 3:Ready 模式等 Pod 的 Ready 条件变 true。但原地升级时,Pod 的 image 被 PATCH 后容器重启,Ready 条件可能短暂为 false 再变 true。如果新版本代码有 bug 导致 CrashLoopBackOff,Ready 条件一直是 false——Controller 会等到 readinessTimeoutSeconds(默认 300s)超时,然后标记 phase=Failed。跟 Probe 模式的区别是:Probe 模式能更细粒度地判断(比如 TCP 探针检查端口是否通),Ready 模式只看 Pod 整体状态。绕过校验不会导致 Controller 崩溃,但可能误判"就绪了"或"超时了"。

下一章​

下一章我们实现核心能力——原地升级——不删 Pod,PATCH 改 container image。