Skip to main content

9. 生产化部署

项目完整结构​

neo-operator/
├── api/v1alpha1/
│ ├── neoset_types.go # CRD 类型定义(第 2 章)
│ ├── neoset_webhook.go # Mutating + Validating Webhook(第 4 章)
│ ├── groupversion_info.go # Scheme 注册
│ └── zz_generated.deepcopy.go # 自动生成
├── internal/controller/
│ ├── neoset_controller.go # Reconcile 主逻辑(第 6 章)
│ ├── inplace.go # 原地升级(第 5 章)
│ ├── revision.go # 版本管理(第 3 章)
│ ├── replicas.go # 扩缩容(第 7 章)
│ ├── readiness.go # 就绪判断(第 8 章)
│ ├── conditions.go # Conditions 更新(第 8 章)
│ ├── metrics.go # Prometheus 指标(第 8 章)
│ └── state_machine.go # 发版状态机(第 6 章)
├── cmd/main/
│ └── main.go # 入口
├── config/
│ ├── crd/ # CRD YAML
│ ├── rbac/ # RBAC 权限
│ ├── manager/ # 部署 YAML
│ └── webhook/ # Webhook Service + Config
├── Dockerfile
├── Makefile
└── go.mod

main.go:完整入口​

// cmd/main/main.go
package main

import (
"os"

"k8s.io/apimachinery/pkg/runtime"
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/healthz"
"sigs.k8s.io/controller-runtime/pkg/metrics/server"
"sigs.k8s.io/controller-runtime/pkg/webhook"

appsv1 "k8s.io/api/apps/v1" // ControllerRevision 需要
neov1alpha1 "neo.io/api/v1alpha1"
"neo.io/internal/controller"
)

var scheme = runtime.NewScheme()

func init() {
utilruntime.Must(clientgoscheme.AddToScheme(scheme))
utilruntime.Must(appsv1.AddToScheme(scheme)) // ControllerRevision
utilruntime.Must(neov1alpha1.AddToScheme(scheme)) // NeoSet
}

func main() {
var metricsAddr string
var probeAddr string
var enableLeaderElection bool
ctrl.SetupSignalHandler()

// 命令行参数
metricsAddr = ":8080"
probeAddr = ":8081"
enableLeaderElection = true

mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
Scheme: scheme,
Metrics: server.Options{
BindAddress: metricsAddr,
},
HealthProbeBindAddress: probeAddr,
LeaderElection: enableLeaderElection,
LeaderElectionID: "neo-controller.neo.io",
WebhookServer: webhook.NewServer(webhook.Options{
Port: 9443,
}),
})
if err != nil {
setupLog.Error(err, "启动 Manager 失败")
os.Exit(1)
}

// 注册 NeoSet CRD
if err := (&neov1alpha1.NeoSet{}).SetupWebhookWithManager(mgr); err != nil {
setupLog.Error(err, "注册 Webhook 失败")
os.Exit(1)
}

// 注册 Controller
if err := (&controller.NeoSetReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
}).SetupWithManager(mgr); err != nil {
setupLog.Error(err, "注册 Controller 失败")
os.Exit(1)
}

// 健康检查
if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
setupLog.Error(err, "健康检查配置失败")
os.Exit(1)
}
if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil {
setupLog.Error(err, "就绪检查配置失败")
os.Exit(1)
}

setupLog.Info("启动 NeoSet Controller")
if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil {
setupLog.Error(err, "Controller 运行失败")
os.Exit(1)
}
}

SetupWithManager:Watch 什么​

// internal/controller/neoset_controller.go

func (r *NeoSetReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&neov1alpha1.NeoSet{}).
Owns(&corev1.Pod{}). // Watch NeoSet 创建的 Pod
Owns(&appsv1.ControllerRevision{}). // Watch 版本快照
WithOptions(controller.Options{
MaxConcurrentReconciles: 3, // 最多 3 个并发 Reconcile
RateLimiter: workqueue.NewMaxOfRateLimiter(
workqueue.NewItemExponentialFailureRateLimiter(1*time.Second, 1000*time.Second),
workqueue.NewBucketRateLimiter(10, 100), // 10 QPS, burst 100
),
}).
Complete(r)
}
  • For(&NeoSet{}):主资源,NeoSet 变了触发 Reconcile
  • Owns(&Pod{}):从属资源,Pod 变了(owner 是 NeoSet)触发 Reconcile
  • Owns(&ControllerRevision{}):版本快照变化触发 Reconcile
  • MaxConcurrentReconciles: 3:最多 3 个 NeoSet 同时 Reconcile(管多个 NeoSet 时提升吞吐)

Dockerfile​

# 多阶段构建
FROM golang:1.22 AS builder
WORKDIR /workspace
COPY go.mod go.sum ./
RUN go mod download
COPY api/ api/
COPY cmd/ cmd/
COPY internal/ internal/
RUN CGO_ENABLED=0 GOOS=linux go build -o manager cmd/main/main.go

# 运行阶段——distroless,无 shell,攻击面小
FROM gcr.io/distroless/static:nonroot
WORKDIR /
COPY --from=builder /workspace/manager .
USER nonroot:nonroot
ENTRYPOINT ["/manager"]

部署 YAML​

# config/manager/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: neo-controller-manager
namespace: neo-system
spec:
replicas: 2 # 双副本高可用
selector:
matchLabels:
control-plane: neo-controller
template:
metadata:
labels:
control-plane: neo-controller
spec:
serviceAccountName: neo-controller
containers:
- name: manager
image: yourrepo/neo-controller:v0.1.0
args:
- --metrics-bind-address=:8080
- --health-probe-bind-address=:8081
- --leader-elect
ports:
- containerPort: 8080
name: metrics
- containerPort: 8081
name: health
- containerPort: 9443
name: webhook
livenessProbe:
httpGet:
path: /healthz
port: 8081
initialDelaySeconds: 15
periodSeconds: 20
readinessProbe:
httpGet:
path: /readyz
port: 8081
initialDelaySeconds: 5
periodSeconds: 10
resources:
limits:
cpu: 500m
memory: 256Mi
requests:
cpu: 100m
memory: 128Mi
volumeMounts:
- name: cert
mountPath: /tmp/k8s-webhook-server/serving-certs
readOnly: true
volumes:
- name: cert
secret:
secretName: neo-webhook-cert
terminationGracePeriodSeconds: 10

RBAC​

# config/rbac/role.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: neo-controller-role
rules:
# NeoSet CRD
- apiGroups: ["apps.neo.io"]
resources: ["neosets"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: ["apps.neo.io"]
resources: ["neosets/status", "neosets/finalizers"]
verbs: ["get", "update", "patch"]
- apiGroups: ["apps.neo.io"]
resources: ["neosets/scale"]
verbs: ["get", "update", "patch"]

# Pod(原地升级 PATCH)
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: [""]
resources: ["pods/status"]
verbs: ["get", "update", "patch"]

# PVC(缩容时删)
- apiGroups: [""]
resources: ["persistentvolumeclaims"]
verbs: ["get", "list", "watch", "create", "delete"]

# Service(Headless Service)
- apiGroups: [""]
resources: ["services"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]

# ControllerRevision(版本管理)
- apiGroups: ["apps"]
resources: ["controllerrevisions"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]

# Events(发版事件)
- apiGroups: [""]
resources: ["events"]
verbs: ["create", "patch"]

e2e 测试​

// test/e2e/neoset_test.go
package e2e

import (
"context"
"time"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/intstr"

neov1alpha1 "neo.io/api/v1alpha1"
)

var _ = Describe("NeoSet", func() {
Context("原地升级", func() {
It("应该不重建 Pod,只改 image", func() {
ctx := context.Background()

// 1. 创建 NeoSet
neoset := &neov1alpha1.NeoSet{
ObjectMeta: metav1.ObjectMeta{
Name: "test-neoset",
Namespace: "default",
},
Spec: neov1alpha1.NeoSetSpec{
Replicas: ptrInt32(3),
Selector: &metav1.LabelSelector{
MatchLabels: map[string]string{"app": "test"},
},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{"app": "test"},
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{{
Name: "nginx",
Image: "nginx:1.25",
Resources: corev1.ResourceRequirements{
Requests: corev1.ResourceList{
corev1.ResourceCPU: resource.MustParse("100m"),
corev1.ResourceMemory: resource.MustParse("128Mi"),
},
},
ReadinessProbe: &corev1.Probe{
ProbeHandler: corev1.ProbeHandler{
HTTPGet: &corev1.HTTPGetAction{
Path: "/",
Port: intstr.FromInt(80),
},
},
},
}},
},
},
UpdateStrategy: neov1alpha1.NeoSetUpdateStrategy{
Type: neov1alpha1.InPlaceUpdateStrategyType,
Partition: ptrInt32(0),
MaxUnavailable: &intstr.IntOrString{Type: intstr.Int, IntVal: 1},
},
},
}
Expect(k8sClient.Create(ctx, neoset)).Should(Succeed())

// 2. 等所有 Pod ready
Eventually(func() int32 {
_ = k8sClient.Get(ctx, types.NamespacedName{Name: "test-neoset", Namespace: "default"}, neoset)
return neoset.Status.ReadyReplicas
}, 60*time.Second, 1*time.Second).Should(Equal(int32(3)))

// 3. 记录 Pod 的 UID(原地升级后 UID 不应变)
pod0 := &corev1.Pod{}
Expect(k8sClient.Get(ctx, types.NamespacedName{Name: "test-neoset-0", Namespace: "default"}, pod0)).Should(Succeed())
oldUID := pod0.UID

// 4. 改 image 触发原地升级
_ = k8sClient.Get(ctx, types.NamespacedName{Name: "test-neoset", Namespace: "default"}, neoset)
neoset.Spec.Template.Spec.Containers[0].Image = "nginx:1.26"
Expect(k8sClient.Update(ctx, neoset)).Should(Succeed())

// 5. 等发版完成
Eventually(func() string {
_ = k8sClient.Get(ctx, types.NamespacedName{Name: "test-neoset", Namespace: "default"}, neoset)
return string(neoset.Status.Phase)
}, 120*time.Second, 1*time.Second).Should(Equal("Completed"))

// 6. 验证 Pod UID 没变(原地升级不重建)
Expect(k8sClient.Get(ctx, types.NamespacedName{Name: "test-neoset-0", Namespace: "default"}, pod0)).Should(Succeed())
Expect(pod0.UID).Should(Equal(oldUID), "Pod UID 不应变——原地升级不重建")

// 7. 验证 image 变了
Expect(pod0.Spec.Containers[0].Image).Should(Equal("nginx:1.26"))
})
})

Context("金丝雀发布", func() {
It("partition=2 时只更新最后 1 个 Pod", func() {
// 类似上面,设 partition=2 (replicas=3)
// 验证只有 mysql-2 被更新,mysql-0/1 保持旧 image
})
})

Context("用旧镜像扩容", func() {
It("发版途中扩容,新 Pod 用 currentImage", func() {
// 1. 发版 partition=2 (只发最后 1 个)
// 2. 等 mysql-2 更新完
// 3. 扩容到 5
// 4. 验证 mysql-3, mysql-4 用旧 image (currentImage)
})
})

Context("回滚", func() {
It("rollout undo 后 Pod 改回旧 image", func() {
// 1. 发版到 v2
// 2. kubectl rollout undo
// 3. 验证所有 Pod image 改回 v1
// 4. 验证 Pod UID 没变(原地回滚)
})
})
})

跑起来​

# 1. 安装 CRD
make install

# 2. 本地跑 Controller(带 Webhook 要跑本地 + port-forward)
make run

# 3. 跑 e2e 测试
make test-e2e

# 4. 构建镜像
make docker-build IMG=yourrepo/neo-controller:v0.1.0

# 5. 部署到集群
make deploy IMG=yourrepo/neo-controller:v0.1.0

# 6. 验证
kubectl get pods -n neo-system
# neo-controller-manager-xxx 1/1 Running

前置阅读​

本章是系列收尾,前面所有章节都是前置:

思考题​

问题 1:MaxConcurrentReconciles: 3 意味着 3 个 NeoSet 可以同时 Reconcile。但同一个 NeoSet 的多个事件会并发处理吗?

问题 2:部署 replicas=2 + Leader Election。Webhook 也在同一个 Pod 里跑。两个 Pod 的 Webhook 都会收到请求吗?

问题 3:e2e 测试验证了 Pod UID 不变。但原地升级后 Pod 的 creationTimestamp 变了吗?


解答​

问题 1:不会。controller-runtime 给同一个资源的事件串行处理——同一个 NeoSet 的事件进同一个 Workqueue key(namespace/name),串行消费。MaxConcurrentReconciles 控制的是"不同资源"的并发度——3 个不同的 NeoSet 可以同时 Reconcile,但同一个 NeoSet 永远串行。

问题 2:Webhook 跟 Controller 不同——Webhook 是无状态的,API Server 通过 Service 负载均衡到任意 Pod。两个 Pod 都会收到 Webhook 请求,哪个被选中看 Service 的负载均衡策略。这没问题——Webhook 逻辑是幂等的(校验 + 填默认值),哪个 Pod 处理都一样。Leader Election 只影响 Controller 的 Reconcile,不影响 Webhook。

问题 3:不变。creationTimestamp 是 Pod 创建时写的,之后不变。原地升级只 PATCH container image,不重建 Pod 对象——creationTimestamp 还是第一次创建时的时间。UID 也不变。变的只有 containerStatuses 里的 imageID(新 image 的 digest)和容器的 lastState(重启记录)。


系列总结​

NeoSet 系列到此结束。回顾走过的路:

章主题产出
1NeoSet 是什么项目背景、能力矩阵
2设计 CRD完整 spec/status Go 类型
3版本管理ControllerRevision 创建/查询/回滚
4Admission Webhook配额校验、默认值填充
5原地升级PATCH image、优雅摘除、资源热更新
6金丝雀与分批Partition 状态机、OnDelete、Hooks
7用旧镜像扩容currentImage、Parallel、PVC 保留策略
8就绪与进度ReadinessGate、minReadySeconds、Conditions
9生产化部署Leader Election + e2e + Dockerfile

从 CRD 设计到 Webhook 到原地升级到分批发布到生产部署——一个完整的生产级 Controller 闭环。每章代码可运行,最终产物是一个能 make install && make run 跑起来的真实 Operator。