Skip to content

Webhook、测试与发布

本篇是 Operator 系列的收官篇,覆盖三块进阶内容:准入 Webhook(校验和修改 CR)、测试体系(单元测试、envtest 集成测试、e2e 测试)、以及 Operator 的镜像构建与发布。前面七章我们写出了一功能完整的 Operator,但生产级软件还需要「输入校验」和「质量保证」。本篇把这些补齐,让你具备交付一个可运维、可测试、可升级的 Operator 的完整能力。

一、Admission Webhook

1. 什么是 Admission Webhook

Admission Webhook 是 K8s API Server 在资源被持久化到 etcd 前调用的 HTTP 回调。它让你能在「写」操作(Create/Update/Delete)发生时插入自定义逻辑。API Server 把待操作的对象包装成 AdmissionReview 请求 POST 给你的 webhook 服务,webhook 返回「允许/拒绝」或「修改后的对象」。

Webhook 分两类:

  • Mutating Webhook(变更):可以修改对象内容(设置默认值、注入字段)。在 Validating 之前执行。
  • Validating Webhook(校验):只能允许或拒绝,不能修改。在 Mutating 之后执行。

执行顺序:

kubectl apply → 认证授权 → Mutating Webhook(可改)→ Validating Webhook(只判)→ etcd

2. Mutating Webhook:修改资源

典型用途:

  • 给 CR 设置默认值(spec.port 没填就填 6379)。
  • 注入 sidecar(Istio 就是这么做的)。
  • 强制添加标签或 annotation。

3. Validating Webhook:验证资源

典型用途:

  • 校验 spec.size 在合法范围内(虽然 CRD 的 OpenAPI 也能做,但复杂规则要 webhook)。
  • 禁止删除受保护资源。
  • 校验字段间的依赖(如设了 A 就必须设 B)。

4. CRD 校验:OpenAPI vs Webhook

维度CRD OpenAPI SchemaWebhook
能力类型、范围、必填、枚举任意逻辑(跨字段、查外部)
性能极快(API Server 本地)一次 HTTP 调用
可用性影响webhook 挂了写操作可能受影响
适用简单静态校验复杂动态校验、默认值注入

原则:能用 OpenAPI 就别用 Webhook。Webhook 是 OpenAPI 不够用时的补充。

二、实现 Webhook

1. Kubebuilder 生成 Webhook 脚手架

kubebuilder create webhook 生成:

bash
# 生成 Mutating + Validating webhook
kubebuilder create webhook \
    --group cache \
    --version v1 \
    --kind RedisCluster \
    --defaulting \
    --validation

# 只生成 Validating
# kubebuilder create webhook --group cache --version v1 --kind RedisCluster --validation

生成的文件在 api/v1/rediscluster_webhook.go,同时 cmd/main.go 会增加注册 webhook 的代码。

2. 自定义 Defaulter(Mutating)

实现 CustomDefaulter 接口,在 Default() 方法里设置默认值:

go
package v1

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

// +kubebuilder:webhook:path=/mutate-cache-example-com-v1-rediscluster,
//   mutating=true,failurePolicy=fail,sideEffects=None,groups=cache.example.com,
//   resources=redisclusters,verbs=create;update,versions=v1,name=mrediscluster.kb.io,
//   admissionReviewVersions=v1

// RedisClusterCustomDefaulter 给 RedisCluster 设置默认值
type RedisClusterCustomDefaulter struct{}

var _ webhook.CustomDefaulter = &RedisClusterCustomDefaulter{}

// Default 实现 CustomDefaulter 接口,被 Mutating Webhook 调用
func (d *RedisClusterCustomDefaulter) Default(ctx context.Context, obj runtime.Object) error {
    rc, ok := obj.(*RedisCluster)
    if !ok {
        return fmt.Errorf("expected RedisCluster, got %T", obj)
    }

    // 设置端口默认值
    if rc.Spec.Port == 0 {
        rc.Spec.Port = 6379
    }
    // 设置 size 默认值
    if rc.Spec.Size == 0 {
        rc.Spec.Size = 1
    }
    // 如果没填 image,用默认镜像
    if rc.Spec.Image == "" {
        rc.Spec.Image = "redis:7.0"
    }
    return nil
}

+kubebuilder:webhook 注释会被 controller-gen 解析成 MutatingWebhookConfiguration。注意 path(/mutate-cache-example-com-v1-rediscluster)必须和注册时一致。

3. 自定义 Validator(Validating)

实现 CustomValidator 接口,ValidateCreate/ValidateUpdate/ValidateDelete 三个方法:

go
package v1

import (
    "context"
    "fmt"

    "k8s.io/apimachinery/pkg/runtime"
    ctrl "sigs.k8s.io/controller-runtime"
    "sigs.k8s.io/controller-runtime/pkg/webhook/admission"
)

// +kubebuilder:webhook:path=/validate-cache-example-com-v1-rediscluster,
//   mutating=false,failurePolicy=fail,sideEffects=None,groups=cache.example.com,
//   resources=redisclusters,verbs=create;update;delete,versions=v1,name=vrediscluster.kb.io,
//   admissionReviewVersions=v1

// RedisClusterCustomValidator 校验 RedisCluster
type RedisClusterCustomValidator struct{}

var _ webhook.CustomValidator = &RedisClusterCustomValidator{}

// ValidateCreate 校验创建请求
func (v *RedisClusterCustomValidator) ValidateCreate(ctx context.Context, obj runtime.Object) (admission.Warnings, error) {
    rc, ok := obj.(*RedisCluster)
    if !ok {
        return nil, fmt.Errorf("expected RedisCluster, got %T", obj)
    }
    return nil, v.validateRedisCluster(rc)
}

// ValidateUpdate 校验更新请求
func (v *RedisClusterCustomValidator) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.Object) (admission.Warnings, error) {
    oldRC, ok := oldObj.(*RedisCluster)
    if !ok {
        return nil, fmt.Errorf("expected RedisCluster, got %T", oldObj)
    }
    newRC, ok := newObj.(*RedisCluster)
    if !ok {
        return nil, fmt.Errorf("expected RedisCluster, got %T", newObj)
    }
    // 禁止缩小 size 到 0
    if newRC.Spec.Size == 0 && oldRC.Spec.Size > 0 {
        return nil, fmt.Errorf("size 不能为 0,最小为 1")
    }
    return nil, v.validateRedisCluster(newRC)
}

// ValidateDelete 校验删除请求
func (v *RedisClusterCustomValidator) ValidateDelete(ctx context.Context, obj runtime.Object) (admission.Warnings, error) {
    // 一般删除不校验,留空即可
    return nil, nil
}

// validateRedisCluster 通用校验逻辑
func (v *RedisClusterCustomValidator) validateRedisCluster(rc *RedisCluster) error {
    if rc.Spec.Size < 1 || rc.Spec.Size > 10 {
        return fmt.Errorf("size 必须在 1-10 之间,当前 %d", rc.Spec.Size)
    }
    if rc.Spec.Image == "" {
        return fmt.Errorf("image 不能为空")
    }
    if rc.Spec.Port < 1 || rc.Spec.Port > 65535 {
        return fmt.Errorf("port 必须在 1-65535 之间,当前 %d", rc.Spec.Port)
    }
    // 跨字段校验:如果密码不为空,长度至少 8
    if rc.Spec.Password != "" && len(rc.Spec.Password) < 8 {
        return fmt.Errorf("password 长度至少 8 位")
    }
    return nil
}

admission.Warnings 是 K8s 1.19+ 引入的「警告」机制——返回非 error 的警告字符串,kubectl 会打印但允许操作。适合「不推荐但不禁止」的提示,比如「这个镜像版本已过时」。

4. 在 main.go 注册 Webhook

go
func main() {
    // ... 创建 Manager ...

    // 注册 Webhook
    if err := (&cachev1.RedisClusterCustomDefaulter{}).SetupWebhookWithManager(mgr); err != nil {
        setupLog.Error(err, "unable to create webhook", "webhook", "RedisCluster")
        os.Exit(1)
    }
    if err := (&cachev1.RedisClusterCustomValidator{}).SetupWebhookWithManager(mgr); err != nil {
        setupLog.Error(err, "unable to create webhook", "webhook", "RedisCluster")
        os.Exit(1)
    }

    // ... mgr.Start ...
}

SetupWebhookWithManager 由 Kubebuilder 生成,负责把 webhook 注册到 Manager 的 webhook server:

go
func (d *RedisClusterCustomDefaulter) SetupWebhookWithManager(mgr ctrl.Manager) error {
    return ctrl.NewWebhookManagedBy(mgr).
        For(&RedisCluster{}).
        WithDefaulter(d).
        Complete()
}

func (v *RedisClusterCustomValidator) SetupWebhookWithManager(mgr ctrl.Manager) error {
    return ctrl.NewWebhookManagedBy(mgr).
        For(&RedisCluster{}).
        WithValidator(v).
        Complete()
}

5. Webhook 配置

make manifests 会生成 MutatingWebhookConfigurationValidatingWebhookConfiguration

yaml
apiVersion: admissionregistration.k8s.io/v1
kind: MutatingWebhookConfiguration
metadata:
  name: redis-operator-mutating-webhook-configuration
webhooks:
  - name: mrediscluster.kb.io
    admissionReviewVersions: ["v1"]
    sideEffects: None
    failurePolicy: Fail
    clientConfig:
      service:
        name: redis-operator-webhook-service
        namespace: operator-system
        path: /mutate-cache-example-com-v1-rediscluster
    rules:
      - operations: ["CREATE", "UPDATE"]
        apiGroups: ["cache.example.com"]
        apiVersions: ["v1"]
        resources: ["redisclusters"]

failurePolicy 有两个值:

  • Fail:webhook 调用失败(服务不可用)时拒绝请求。安全但可能因 webhook 故障导致无法操作。
  • Ignore:webhook 调用失败时放行。可用性优先但可能漏校验。

生产环境通常 Validating 用 Fail(严格),Mutating 视情况——如果默认值不是关键,可用 Ignore。

6. Webhook 的 TLS 证书

API Server 调用 webhook 走 HTTPS,webhook server 必须有 TLS 证书。证书管理有几种方式:

  • cert-manager:自动签发和轮换证书,生产推荐。Kubebuilder 的 certmanager overlay 支持。
  • 手动生成:用 openssl 生成自签证书,塞进 Secret。
  • in-cluster bootstrap:Operator 启动时自签证书写进 Secret(适合开发)。

使用 cert-manager 的 kustomize 配置:

bash
# Kubebuilder 默认提供 certmanager overlay
cd config/default
kustomize build . | kubectl apply -f -

需要先安装 cert-manager:

bash
kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.14.0/cert-manager.yaml

三、单元测试

1. 测试 Reconcile 逻辑

Operator 的核心是 Reconcile,单元测试重点是验证 Reconcile 在各种输入下产生正确的输出。controller-runtime 提供 fake.NewClientBuilder 构造 mock client,无需真实集群。

go
package controller

import (
    "context"
    "testing"

    appsv1 "k8s.io/api/apps/v1"
    corev1 "k8s.io/api/core/v1"
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    "k8s.io/apimachinery/pkg/runtime"
    "k8s.io/apimachinery/pkg/types"
    clientgoscheme "k8s.io/client-go/kubernetes/scheme"
    "sigs.k8s.io/controller-runtime/pkg/client/fake"
    "sigs.k8s.io/controller-runtime/pkg/reconcile"

    cachev1 "example.com/redis-operator/api/v1"
)

func TestRedisClusterReconcile_CreatesDeployment(t *testing.T) {
    scheme := runtime.NewScheme()
    _ = clientgoscheme.AddToScheme(scheme)
    _ = cachev1.AddToScheme(scheme)

    // 准备一个已存在的 RedisCluster
    rc := &cachev1.RedisCluster{
        ObjectMeta: metav1.ObjectMeta{Name: "my-redis", Namespace: "default"},
        Spec: cachev1.RedisClusterSpec{
            Size:   3,
            Image:  "redis:7.0",
            Port:   6379,
        },
    }

    // 构造 fake client,预置 CR
    cl := fake.NewClientBuilder().
        WithScheme(scheme).
        WithObjects(rc).
        WithStatusSubresource(&cachev1.RedisCluster{}).  // 必须声明 status 子资源
        Build()

    r := &RedisClusterReconciler{
        Client: cl,
        Scheme: scheme,
    }

    // 调用 Reconcile
    _, err := r.Reconcile(context.Background(), reconcile.Request{
        NamespacedName: types.NamespacedName{Name: "my-redis", Namespace: "default"},
    })
    if err != nil {
        t.Fatalf("Reconcile failed: %v", err)
    }

    // 验证 Deployment 被创建
    var dep appsv1.Deployment
    if err := cl.Get(context.Background(),
        types.NamespacedName{Name: "my-redis", Namespace: "default"}, &dep); err != nil {
        t.Fatalf("expected Deployment to be created: %v", err)
    }
    if *dep.Spec.Replicas != 3 {
        t.Errorf("expected replicas=3, got %d", *dep.Spec.Replicas)
    }
    if dep.Spec.Template.Spec.Containers[0].Image != "redis:7.0" {
        t.Errorf("expected image redis:7.0, got %s", dep.Spec.Template.Spec.Containers[0].Image)
    }

    // 验证 ownerReference 设置正确(级联删除)
    if len(dep.OwnerReferences) != 1 || dep.OwnerReferences[0].Name != "my-redis" {
        t.Errorf("expected ownerReference to my-redis, got %v", dep.OwnerReferences)
    }

    // 验证 Service 被创建
    var svc corev1.Service
    if err := cl.Get(context.Background(),
        types.NamespacedName{Name: "my-redis", Namespace: "default"}, &svc); err != nil {
        t.Fatalf("expected Service to be created: %v", err)
    }
}

2. Mock Client:fake.NewClientBuilder

fake.Client 实现了 client.Client 接口,所有操作都在内存里,速度极快。关键方法:

  • WithScheme(scheme):注册资源类型。
  • WithObjects(objs...):预置对象。
  • WithStatusSubresource(&CR{}):声明哪些资源有 status 子资源(否则 Status().Update 不生效)。
  • WithInterceptorFuncs(...):拦截请求注入错误,测试错误分支。
go
import "sigs.k8s.io/controller-runtime/pkg/interceptor"

// 模拟 Update 冲突
cl := fake.NewClientBuilder().
    WithScheme(scheme).
    WithObjects(rc).
    WithInterceptorFuncs(interceptor.Funcs{
        func(ctx context.Context, client client.Client, obj client.Object, opts ...client.UpdateOption) error {
            return errors.NewConflict(...)
        },
    }).
    Build()

3. 表驱动测试

针对多种场景用一个表驱动测试覆盖:

go
func TestReconcile_Scenarios(t *testing.T) {
    scheme := runtime.NewScheme()
    _ = clientgoscheme.AddToScheme(scheme)
    _ = cachev1.AddToScheme(scheme)

    tests := []struct {
        name        string
        initialObj  client.Object
        req         reconcile.Request
        wantErr     bool
        wantDepReplicas int32
        wantPhase   string
    }{
        {
            name: "正常创建",
            initialObj: &cachev1.RedisCluster{
                ObjectMeta: metav1.ObjectMeta{Name: "rc1", Namespace: "default"},
                Spec: cachev1.RedisClusterSpec{Size: 2, Image: "redis:7.0", Port: 6379},
            },
            req: reconcile.Request{NamespacedName: types.NamespacedName{Name: "rc1", Namespace: "default"}},
            wantDepReplicas: 2,
        },
        {
            name: "CR 不存在",
            initialObj: &cachev1.RedisCluster{},
            req: reconcile.Request{NamespacedName: types.NamespacedName{Name: "not-exist", Namespace: "default"}},
            wantErr: false,  // NotFound 不算错误
        },
        {
            name: "扩容到 5",
            initialObj: &cachev1.RedisCluster{
                ObjectMeta: metav1.ObjectMeta{Name: "rc2", Namespace: "default"},
                Spec: cachev1.RedisClusterSpec{Size: 5, Image: "redis:7.0", Port: 6379},
            },
            req: reconcile.Request{NamespacedName: types.NamespacedName{Name: "rc2", Namespace: "default"}},
            wantDepReplicas: 5,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            objs := []client.Object{}
            if tt.initialObj != nil && tt.initialObj.GetName() != "" {
                objs = append(objs, tt.initialObj)
            }
            cl := fake.NewClientBuilder().
                WithScheme(scheme).
                WithObjects(objs...).
                WithStatusSubresource(&cachev1.RedisCluster{}).
                Build()

            r := &RedisClusterReconciler{Client: cl, Scheme: scheme}
            _, err := r.Reconcile(context.Background(), tt.req)
            if (err != nil) != tt.wantErr {
                t.Errorf("Reconcile() error = %v, wantErr %v", err, tt.wantErr)
            }
            if tt.wantDepReplicas > 0 {
                var dep appsv1.Deployment
                _ = cl.Get(context.Background(), tt.req.NamespacedName, &dep)
                if *dep.Spec.Replicas != tt.wantDepReplicas {
                    t.Errorf("replicas = %d, want %d", *dep.Spec.Replicas, tt.wantDepReplicas)
                }
            }
        })
    }
}

4. 测试 Webhook

Webhook 的 Defaulter 和 Validator 是纯函数,最容易测:

go
func TestRedisClusterValidator(t *testing.T) {
    v := &cachev1.RedisClusterCustomValidator{}

    tests := []struct {
        name    string
        rc      *cachev1.RedisCluster
        wantErr bool
    }{
        {"合法", &cachev1.RedisCluster{Spec: cachev1.RedisClusterSpec{Size: 3, Image: "redis:7.0", Port: 6379}}, false},
        {"size 太大", &cachev1.RedisCluster{Spec: cachev1.RedisClusterSpec{Size: 20, Image: "redis:7.0", Port: 6379}}, true},
        {"image 空", &cachev1.RedisCluster{Spec: cachev1.RedisClusterSpec{Size: 3, Image: "", Port: 6379}}, true},
        {"密码太短", &cachev1.RedisCluster{Spec: cachev1.RedisClusterSpec{Size: 3, Image: "redis:7.0", Port: 6379, Password: "abc"}}, true},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            _, err := v.ValidateCreate(context.Background(), tt.rc)
            if (err != nil) != tt.wantErr {
                t.Errorf("ValidateCreate() error = %v, wantErr %v", err, tt.wantErr)
            }
        })
    }
}

func TestRedisClusterDefaulter(t *testing.T) {
    d := &cachev1.RedisClusterCustomDefaulter{}
    rc := &cachev1.RedisCluster{Spec: cachev1.RedisClusterSpec{Size: 0, Image: "", Port: 0}}
    if err := d.Default(context.Background(), rc); err != nil {
        t.Fatalf("Default failed: %v", err)
    }
    if rc.Spec.Size != 1 {
        t.Errorf("expected default size 1, got %d", rc.Spec.Size)
    }
    if rc.Spec.Port != 6379 {
        t.Errorf("expected default port 6379, got %d", rc.Spec.Port)
    }
    if rc.Spec.Image != "redis:7.0" {
        t.Errorf("expected default image redis:7.0, got %s", rc.Spec.Image)
    }
}

四、集成测试:envtest

1. 什么是 envtest

单元测试用 fake client,但 fake 不完全等价真实 API Server(比如它不跑 admission webhook、不维护 ownerReference GC、不校验 schema)。envtest 启动一个真实的本地 API Server 和 etcd(不含 kubelet/controller-manager),让你在接近真实的环境测试 Operator。

envtest 的适用场景:

  • 测试 CRD 安装、schema 校验。
  • 测试 webhook(fake client 跑不了 webhook)。
  • 测试 ownerReference 级联删除(依赖 GC)。
  • 测试 status 子资源的真实行为。

2. 安装 envtest 依赖

envtest 需要本地有 kube-apiserveretcd 二进制。Kubebuilder Makefile 里有 envtest target:

bash
# 下载 apiserver 和 etcd 二进制(首次执行)
make envtest

# 或手动
curl -sSLo envtest-bins.tar.gz https://storage.googleapis.com/kubebuilder-tools/kubebuilder-tools-1.30.0-linux-amd64.tar.gz
tar -xzf envtest-bins.tar.gz

设置环境变量告诉 envtest 二进制位置:

bash
export KUBEBUILDER_ASSETS=/path/to/kubebuilder/bin

3. 启动本地 API Server

Kubebuilder 生成的 internal/controller/suite_test.go 已经搭好框架:

go
package controller

import (
    "context"
    "path/filepath"
    "testing"

    "k8s.io/apimachinery/pkg/runtime"
    "k8s.io/client-go/kubernetes/scheme"
    "k8s.io/client-go/rest"
    ctrl "sigs.k8s.io/controller-runtime"
    "sigs.k8s.io/controller-runtime/pkg/envtest"
    logf "sigs.k8s.io/controller-runtime/pkg/log"
    "sigs.k8s.io/controller-runtime/pkg/log/zap"
    metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"

    cachev1 "example.com/redis-operator/api/v1"
    // +kubebuilder:scaffold:imports
)

var cfg *rest.Config
var k8sClient client.Client
var testEnv *envtest.Environment
var ctx context.Context
var cancel context.CancelFunc

func TestMain(m *testing.M) {
    logf.SetLogger(zap.New(zap.UseDevMode(true)))

    ctx, cancel = context.WithCancel(context.Background())

    testEnv = &envtest.Environment{
        CRDDirectoryPaths:     []string{filepath.Join("..", "..", "config", "crd", "bases")},
        ErrorIfCRDPathMissing: true,
    }

    var err error
    cfg, err = testEnv.Start()
    if err != nil {
        panic(err)
    }

    scheme := runtime.NewScheme()
    _ = scheme.AddToScheme(scheme)
    _ = cachev1.AddToScheme(scheme)

    k8sClient, err = client.New(cfg, client.Options{Scheme: scheme})
    if err != nil {
        panic(err)
    }

    // 启动 Manager 和 Controller(真实环境)
    mgr, err := ctrl.NewManager(cfg, ctrl.Options{
        Scheme:  scheme,
        Metrics: metricsserver.Options{BindAddress: "0"},
    })
    if err != nil {
        panic(err)
    }
    if err := (&RedisClusterReconciler{
        Client: mgr.GetClient(),
        Scheme: mgr.GetScheme(),
    }).SetupWithManager(mgr); err != nil {
        panic(err)
    }

    go func() {
        _ = mgr.Start(ctx)
    }()

    code := m.Run()

    cancel()
    _ = testEnv.Stop()
    os.Exit(code)
}

4. envtest 测试示例

go
func TestRedisClusterReconcile_Integration(t *testing.T) {
    // 创建 CR
    rc := &cachev1.RedisCluster{
        ObjectMeta: metav1.ObjectMeta{Name: "it-redis", Namespace: "default"},
        Spec: cachev1.RedisClusterSpec{Size: 2, Image: "redis:7.0", Port: 6379},
    }
    if err := k8sClient.Create(ctx, rc); err != nil {
        t.Fatalf("create CR: %v", err)
    }

    // 等待 Deployment 出现(轮询)
    eventually(t, func() bool {
        var dep appsv1.Deployment
        err := k8sClient.Get(ctx, types.NamespacedName{Name: "it-redis", Namespace: "default"}, &dep)
        return err == nil && *dep.Spec.Replicas == 2
    }, 5*time.Second, 100*time.Millisecond, "等待 Deployment 创建")

    // 测试 status 子资源:envtest 真实跑了 status 子资源
    eventually(t, func() bool {
        var got cachev1.RedisCluster
        _ = k8sClient.Get(ctx, types.NamespacedName{Name: "it-redis", Namespace: "default"}, &got)
        return got.Status.Phase == "Pending" || got.Status.Phase == "Running"
    }, 5*time.Second, 100*time.Millisecond, "等待 status 更新")

    // 测试 webhook(如果注册了)
    // envtest 默认不启 webhook server,需额外配置
}

// eventually 是个轮询断言工具
func eventually(t *testing.T, condition func() bool, timeout, interval time.Duration, msg string) {
    t.Helper()
    deadline := time.Now().Add(timeout)
    for time.Now().Before(deadline) {
        if condition() {
            return
        }
        time.Sleep(interval)
    }
    t.Fatalf("condition not met: %s", msg)
}

envtest 比 fake client 慢(启动 API Server 要几秒),但更真实。推荐:核心逻辑用 fake client 跑大量表驱动测试,envtest 跑少量端到端关键路径。

5. 测试完整流程

envtest 真实跑 Manager + Controller,适合测试「CR 创建 → Controller 调谐 → 资源出现 → status 更新 → CR 删除 → 资源消失」整个闭环。注意 envtest 没有 kubelet,Pod 不会被真正调度,所以 Pod 的 readyReplicas 永远是 0——测试时要 mock 或跳过对就绪状态的断言。

五、e2e 测试

1. e2e 与集成测试的区别

  • 集成测试(envtest):本地 API Server,无真实节点,无 Pod 运行。快但失真。
  • e2e 测试:真实集群(kind/minikube/云集群),Pod 真正运行,能验证端到端业务行为。慢但最真实。

e2e 测试通常用于发布前回归,不在每次 go test 跑。

2. 用 kuttl 做 e2e

kuttl 是 K8s 原生的测试框架,用 YAML 声明测试步骤和断言,适合 Operator e2e。

目录结构:

test/e2e/
├── redis-cluster-test/
│   ├── 00-assert.yaml     # 期望状态
│   ├── 00-install.yaml    # 创建 Operator
│   ├── 01-assert.yaml
│   ├── 01-create-cr.yaml  # 创建 CR
│   └── 02-assert.yaml
└── kuttl-test.yaml

00-install.yaml

yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: redis-operator
  namespace: operator-system
spec:
  replicas: 1
  selector:
    matchLabels:
      control-plane: controller-manager
  template:
    metadata:
      labels:
        control-plane: controller-manager
    spec:
      containers:
        - name: manager
          image: example.com/redis-operator:v0.1.0
          args: ["--leader-elect"]

01-create-cr.yaml

yaml
apiVersion: cache.example.com/v1
kind: RedisCluster
metadata:
  name: e2e-redis
  namespace: default
spec:
  size: 3
  image: redis:7.0
  port: 6379

01-assert.yaml(断言:Deployment 出现且副本数=3):

yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: e2e-redis
  namespace: default
spec:
  replicas: 3
status:
  readyReplicas: 3

运行:

bash
kubectl kuttl test ./test/e2e/

kuttl 会按序执行步骤,每步 apply YAML 后轮询 assert,超时未满足则失败。

六、Operator 发布

1. 构建镜像

Kubebuilder 生成的 MakefileDockerfile 封装了构建流程:

dockerfile
# Kubebuilder 生成的多阶段 Dockerfile
FROM golang:1.22 AS builder
ARG TARGETOS
ARG TARGETARCH
WORKDIR /workspace
COPY go.mod go.sum ./
RUN go mod download
COPY cmd/ cmd/
COPY api/ api/
COPY internal/ internal/
RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} \
    go build -a -o manager cmd/main.go

FROM gcr.io/distroless/static-debian11:nonroot
WORKDIR /
COPY --from=builder /workspace/manager .
USER 65532:65532
ENTRYPOINT ["/manager"]

构建并推送:

bash
# 设置镜像仓库地址
export IMG=example.com/redis-operator:v0.1.0

# 构建并推送
make docker-build docker-push

# 或本地 kind 加载(不推远端)
kind load docker-image ${IMG} --name operator-dev

make docker-build 内部执行 docker buildx build --loadmake docker-push 执行 docker push

2. 部署到集群:make deploy

bash
# 设置镜像
export IMG=example.com/redis-operator:v0.1.0

# 部署 CRD + RBAC + Operator Deployment
make deploy

# 验证
kubectl get pods -n operator-system
kubectl get crd redisclusters.cache.example.com

make deploy 内部用 kustomize 把 config/default 组装成完整部署清单并 apply。卸载用 make undeploy

3. 生成的部署清单

config/default/kustomization.yaml 是部署入口:

yaml
resources:
  - ../crd                # CRD
  - ../rbac               # RBAC
  - ../manager            # Operator Deployment
  - ../webhook            # Webhook 配置(如有)
  - ../certmanager        # 证书(如有)

patches:
  - path: manager_auth_proxy_patch.yaml   # 注入 metrics sidecar
  - path: webhookcainjection_patch.yaml    # 注入 CA Bundle

4. OLM 发布

OLM(Operator Lifecycle Manager)是 OpenShift 生态的 Operator 包管理器。发布到 OLM 让用户能通过 OperatorHub 一键安装你的 Operator。

发布步骤:

  1. 生成 bundle:make bundle。生成 bundle/ 目录,含 manifests、metadata、CSV(ClusterServiceVersion)。
  2. 构建 bundle 镜像:make bundle-build BUNDLE_IMG=example.com/redis-operator-bundle:v0.1.0
  3. 推送:docker push example.com/redis-operator-bundle:v0.1.0
  4. 安装到 OLM:operator-sdk run bundle example.com/redis-operator-bundle:v0.1.0

CSV(ClusterServiceVersion)是 OLM 的核心元数据,描述 Operator 的版本、CRD、权限、安装策略。make bundle 会从 PROJECT 文件和 +kubebuilder:rbac 注释自动生成大部分内容。

5. 版本管理与升级策略

Operator 的版本管理有两层:

  • Operator 自身版本:Operator 二进制/镜像的版本(v0.1.0 → v0.2.0)。
  • CRD API 版本:CRD 的 apiVersion(v1alpha1 → v1beta1 → v1)。

CRD API 版本升级

K8s 支持 CRD 多版本共存,通过 version + served + storage 控制:

yaml
versions:
  - name: v1alpha1
    served: true
    storage: false      # 不再是存储版本(旧资源会被自动转换到 storage 版本)
  - name: v1
    served: true
    storage: true        # 存储版本,新资源用这个

从 v1alpha1 升级到 v1 的流程:

  1. 发布新 Operator,CRD 同时支持 v1alpha1 和 v1。
  2. 写 conversion webhook,把 v1alpha1 转成 v1(字段重命名、默认值补充)。
  3. 用户逐步迁移 manifest 到 v1。
  4. 一段时间后,把 v1alpha1 的 served 设为 false(废弃但保留),再后续版本彻底移除。

Operator 自身升级

滚动更新 Operator Deployment 即可(kubectl set image deployment/redis-operator manager=...)。因为 Reconcile 是幂等的,新老版本切换不会破坏状态。注意:

  • 数据库迁移:如果新版本改了 status schema,要在 Reconcile 里兼容老 status(看到老格式自动迁移)。
  • Finalizer 兼容:不要随意改 finalizer 名字(第六章讲过)。
  • 灰度发布:可用 OLM 的 olm.maxOpenShiftVersion 或手动分批升级。

七、测试与发布清单

发布前过一遍这个清单:

测试

  • [ ] 单元测试覆盖 Reconcile 主路径和错误分支(go test ./...)。
  • [ ] envtest 集成测试覆盖 CR 创建/更新/删除闭环。
  • [ ] Webhook 的 Defaulter/Validator 单元测试。
  • [ ] e2e 测试在 kind 集群跑通。
  • [ ] Leader Election 切换测试(删 Leader Pod 验证接管)。

镜像与部署

  • [ ] 镜像用 distroless 基础镜像,非 root 运行。
  • [ ] Deployment 配置 resources limit/requests。
  • [ ] 健康检查(liveness/readiness)配置。
  • [ ] terminationGracePeriodSeconds 给 Leader Election 优雅退出留够时间。
  • [ ] RBAC 最小权限(只授权 +kubebuilder:rbac 声明的资源)。

Webhook

  • [ ] TLS 证书用 cert-manager 自动管理。
  • [ ] failurePolicy 按风险设置(Validating 用 Fail)。
  • [ ] webhook 的 sideEffects: None(声明无副作用,便于审计)。

版本管理

  • [ ] CRD 标注 storage 版本。
  • [ ] 升级路径文档化(用户从老版本怎么升)。
  • [ ] 向后兼容(老 CR 实例新 Operator 能处理)。

八、小结

本篇覆盖了 Operator 的输入校验、质量保证和发布交付。要点回顾:

  1. Admission Webhook 在资源写 etcd 前介入。Mutating 可改(设默认值),Validating 只判(复杂校验)。能用 CRD OpenAPI 就别用 webhook。
  2. 实现 Webhook:用 Kubebuilder create webhook 生成骨架,实现 CustomDefaulter/CustomValidator 接口。make manifests 生成 WebhookConfiguration,TLS 证书用 cert-manager 管理。
  3. 单元测试:用 fake.NewClientBuilder 构造 mock client,表驱动测试覆盖 Reconcile 各场景。Webhook 是纯函数,最易测。
  4. envtest 启动真实本地 API Server + etcd,测试 CRD schema、status 子资源、webhook、级联删除等 fake client 模拟不了的行为。
  5. e2e 测试 在真实集群验证端到端,kuttl 用 YAML 声明步骤和断言,适合发布前回归。
  6. 发布make docker-build 构建镜像,make deploy 部署到集群,make bundle 生成 OLM bundle 发布到 OperatorHub。
  7. 版本管理:CRD 多版本共存 + conversion webhook 实现平滑升级;Operator 自身靠镜像版本滚动更新,依赖 Reconcile 幂等保证切换安全。
  8. 发布清单:测试 + 镜像安全 + RBAC + Webhook 证书 + 版本兼容,逐项核对。

至此,Kubernetes Operator 开发系列教程完结。从 CRD 概念到 controller-runtime 架构,从第一个 Operator 到 Reconcile 详解、状态管理、Finalizer、Leader Election、Webhook 与发布,你已具备开发生产级 Operator 的完整知识体系。动手用本系列的 Redis Operator 作模板,把它的能力扩展到真实业务场景——加上备份、监控、主从切换、版本升级——那将是你对 Operator 模式最好的巩固。