Appearance
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(只判)→ etcd2. 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 Schema | Webhook |
|---|---|---|
| 能力 | 类型、范围、必填、枚举 | 任意逻辑(跨字段、查外部) |
| 性能 | 极快(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 会生成 MutatingWebhookConfiguration 和 ValidatingWebhookConfiguration:
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 的
certmanageroverlay 支持。 - 手动生成:用 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-apiserver 和 etcd 二进制。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/bin3. 启动本地 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.yaml00-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: 637901-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 生成的 Makefile 和 Dockerfile 封装了构建流程:
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-devmake docker-build 内部执行 docker buildx build --load,make 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.commake 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 Bundle4. OLM 发布
OLM(Operator Lifecycle Manager)是 OpenShift 生态的 Operator 包管理器。发布到 OLM 让用户能通过 OperatorHub 一键安装你的 Operator。
发布步骤:
- 生成 bundle:
make bundle。生成bundle/目录,含 manifests、metadata、CSV(ClusterServiceVersion)。 - 构建 bundle 镜像:
make bundle-build BUNDLE_IMG=example.com/redis-operator-bundle:v0.1.0。 - 推送:
docker push example.com/redis-operator-bundle:v0.1.0。 - 安装到 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 的流程:
- 发布新 Operator,CRD 同时支持 v1alpha1 和 v1。
- 写 conversion webhook,把 v1alpha1 转成 v1(字段重命名、默认值补充)。
- 用户逐步迁移 manifest 到 v1。
- 一段时间后,把 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 的输入校验、质量保证和发布交付。要点回顾:
- Admission Webhook 在资源写 etcd 前介入。Mutating 可改(设默认值),Validating 只判(复杂校验)。能用 CRD OpenAPI 就别用 webhook。
- 实现 Webhook:用 Kubebuilder
create webhook生成骨架,实现CustomDefaulter/CustomValidator接口。make manifests生成 WebhookConfiguration,TLS 证书用 cert-manager 管理。 - 单元测试:用
fake.NewClientBuilder构造 mock client,表驱动测试覆盖 Reconcile 各场景。Webhook 是纯函数,最易测。 - envtest 启动真实本地 API Server + etcd,测试 CRD schema、status 子资源、webhook、级联删除等 fake client 模拟不了的行为。
- e2e 测试 在真实集群验证端到端,kuttl 用 YAML 声明步骤和断言,适合发布前回归。
- 发布:
make docker-build构建镜像,make deploy部署到集群,make bundle生成 OLM bundle 发布到 OperatorHub。 - 版本管理:CRD 多版本共存 + conversion webhook 实现平滑升级;Operator 自身靠镜像版本滚动更新,依赖 Reconcile 幂等保证切换安全。
- 发布清单:测试 + 镜像安全 + RBAC + Webhook 证书 + 版本兼容,逐项核对。
至此,Kubernetes Operator 开发系列教程完结。从 CRD 概念到 controller-runtime 架构,从第一个 Operator 到 Reconcile 详解、状态管理、Finalizer、Leader Election、Webhook 与发布,你已具备开发生产级 Operator 的完整知识体系。动手用本系列的 Redis Operator 作模板,把它的能力扩展到真实业务场景——加上备份、监控、主从切换、版本升级——那将是你对 Operator 模式最好的巩固。