Skip to main content

1. NeoSet 是什么

为什么要造这个轮子​

原生 StatefulSet 有四个生产痛点:

痛点 1:发版必须重建 Pod

原生 STS 滚动更新时,每个 Pod 被删除重建——IP 变、Volume 重新挂载、启动慢。MySQL 主从集群每次发版主库都要重启切主,业务感知明显。

痛点 2:没有金丝雀和分批

原生 STS 的 RollingUpdate 从最大序号逐个滚动。不能"先发 1 个观察 10 分钟,没问题再发 30%,最后全量"。要做金丝雀得自己造轮子。

痛点 3:发版途中扩容会混版本

发版到一半(5 个 Pod 里前 2 个已是新版本),业务量突增要扩容到 8 个。原生 STS 新建的 3 个 Pod 用新版本 image——但新版本还没验证完,这 3 个新 Pod 跟那 3 个旧版本 Pod 混在一起,行为不可预测。

痛点 4:回滚不方便

原生 STS 的回滚靠 partition——把 partition 设回 0 让旧版本 Pod "更新"回旧版本。但这是反向滚动,又要逐个重建。没有"快速回到某个历史版本"的能力。

NeoSet 的六个核心能力​

能力做什么解决什么痛点
原地升级不删 Pod,PATCH 改 container image痛点 1:IP 不变、Volume 不动
金丝雀发布partition: replicas-1 只发最后 1 个痛点 2:先小范围验证
分批发布逐步调 partition + MaxUnavailable 控制痛点 2:控制爆炸半径
暂停/恢复pause: true 任意时刻停住痛点 2:人工介入窗口
用旧镜像扩容扩容时用"当前稳定版本"image痛点 3:不混版本
历史版本回滚ControllerRevision 存历史,一键回滚痛点 4:快速回退

跟原生 StatefulSet 的对比​

原生 STS 滚动更新(5 副本):
mysql-4 删除 → 重建(新IP, 重挂Volume)→ ready
mysql-3 删除 → 重建 → ready
...逐个进行

NeoSet 原地升级 + 金丝雀:
阶段1 (金丝雀): partition=4
→ 只 PATCH mysql-4 的 image,等 ready
→ pause=true,停住观察

阶段2 (分批): pause=false, partition=2
→ PATCH mysql-3, mysql-2 的 image
→ 等两个都 ready

阶段3 (全量): partition=0
→ PATCH mysql-1, mysql-0 的 image
→ 完成

全程:IP 不变、Volume 不动、秒级完成

"用旧镜像扩容"是什么意思​

这是生产环境高频场景:

当前状态: replicas=5, 正在发版 v1→v2
mysql-0~1: v2(已更新)
mysql-2~4: v1(待更新)

业务突增,扩容到 8:
❌ 原生 STS: mysql-5~7 用 v2(新版本没验证完就扩散了)
✅ NeoSet: mysql-5~7 用 v1(当前稳定版本)

发版继续:
mysql-2~7 逐批原地升级到 v2
全程不混版本

NeoSet 怎么做到的?在 status 里记一个 currentImage(当前稳定版本镜像)。扩容时新 Pod 用 currentImage,不用 spec.template 里的新 image。等发版完成后,currentImage 更新为新版本。

"历史版本回滚"怎么实现​

NeoSet 用 ControllerRevision 存历史版本(跟 StatefulSet 一样,但机制不同):

NeoSet mysql (当前 template: v2)
│
├── ControllerRevision mysql-v1 (template v1 快照)
└── ControllerRevision mysql-v2 (template v2 快照) ← 当前

回滚到 v1:
kubectl rollout undo neoset mysql --to-revision=1
→ Controller 把 v1 的 template 应用回 NeoSet
→ 原地升级把所有 Pod 改回 v1 image
→ 不重建,秒级回滚

原地升级让回滚也变快——原生 STS 回滚是反向重建,NeoSet 回滚是反向 PATCH image,速度快一个数量级。

整体架构​

用户提交 NeoSet CR
│
▼
┌─────────────────────────────────────────┐
│ Admission Webhook │
│ - 配额校验(副本数、资源上限) │
│ - 默认值填充(maxUnavailable 默认 1) │
│ - 金丝雀参数校验 │
└──────────────┬──────────────────────────┘
│ 通过
▼
┌─────────────────────────────────────────┐
│ NeoSet Controller │
│ │
│ Reconcile: │
│ 1. 读 spec(期望状态 + 更新策略) │
│ 2. 读 status(当前进度 + currentImage) │
│ 3. 判断该干什么: │
│ ├─ 扩容?→ 用 currentImage 建新 Pod │
│ ├─ 发版?→ 算下一批,PATCH image │
│ ├─ 暂停?→ 停住不动 │
│ └─ 回滚?→ 找历史版本,PATCH 回去 │
│ 4. 等就绪 │
│ 5. 更新 status(进度 + currentImage) │
│ 6. 存 ControllerRevision(版本变了时) │
└───────────────────────────────────────────┘

系列规划​

章主题核心代码
1NeoSet 是什么项目背景(本章)
2设计 CRD完整 spec/status Go 类型
3ControllerRevision 版本管理历史版本存储、回滚逻辑
4Admission Webhook配额校验、默认值填充
5实现原地升级PATCH image、优雅摘除、资源热更新
6金丝雀与分批发布Partition 状态机、OnDelete、更新 Hooks
7用旧镜像扩容currentImage 追踪、Parallel/PVC 保留策略
8就绪判断与进度追踪ReadinessGate、minReadySeconds、Conditions
9生产化部署Leader Election、指标、e2e 测试

每章都有可运行的 Go 代码,不是伪代码。到最后能 make install && make run 跑起来一个真实的 NeoSet Controller。

前置阅读​

思考题​

问题 1:原地升级时 Pod IP 不变。如果新版本代码有 bug 导致容器 CrashLoopBackOff,下游还连着这个 IP,会发生什么?

问题 2:用旧镜像扩容时,新 Pod 用 v1 镜像。但 spec.template 已经是 v2。如果此时 Controller 重启了,它怎么知道该用 v1 还是 v2 扩容?

问题 3:原地升级回滚比重建回滚快。但有什么场景下原地回滚反而不如重建回滚?


解答​

问题 1:CrashLoopBackOff 的 Pod 会被 EndpointSlice Controller 从端点列表摘掉(ready=false)。下游通过 Service 访问不会路由到它。但如果是 Headless Service + 客户端直连 IP,客户端会连上一个不断重启的容器——这就是原地升级的风险:IP 不变是优势也是风险。解决方案:原地升级后必须配 readinessProbe,让 EndpointSlice 及时摘除不健康的 Pod。

问题 2:靠 status.currentImage。Controller 每次发版时,先更新 spec.template(新 image),但不立即更新 status.currentImage——等所有 Pod 都更新完才更新 currentImage。扩容时读 status.currentImage 而不是 spec.template.spec.containers[0].image。Controller 重启后从 etcd 读 status,currentImage 还在,逻辑一致。

问题 3:当新版本修改了 Volume 挂载路径或数据格式时。原地回滚只改 image,Volume 里的数据还是新版本写的——旧版本代码读不懂新格式的数据,会出错。这种场景下重建回滚更安全:新 Pod 用旧 image + 全新 Volume(从快照恢复)。原地升级的前提是"新旧版本的数据格式兼容"。

下一章​

下一章我们设计 NeoSet 的 CRD——完整的 spec/status Go 类型定义,包含更新策略、版本管理、进度追踪字段。