Skip to main content

2. 设计 CRD

设计原则​

NeoSet 的 CRD 设计遵循三条原则:

  1. 兼容 STS 习惯——replicas、selector、template、volumeClaimTemplates 跟 StatefulSet 一样,迁移成本低
  2. 更新策略集中——所有跟发版相关的参数放 spec.updateStrategy 下,一目了然
  3. 版本可追踪——status 里记 currentImage(稳定版本)和 targetImage(目标版本),支撑"用旧镜像扩容"

完整 Go 类型定义​

Spec​

// api/v1alpha1/neoset_types.go
package v1alpha1

import (
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/intstr"
)

// NeoSetSpec 定义期望状态
type NeoSetSpec struct {
// 副本数,默认 1
Replicas *int32 `json:"replicas,omitempty"`

// 标签选择器,跟 STS 一样
Selector *metav1.LabelSelector `json:"selector"`

// Pod 模板,跟 STS 一样
Template corev1.PodTemplateSpec `json:"template"`

// PVC 模板,跟 STS 一样
VolumeClaimTemplates []corev1.PersistentVolumeClaim `json:"volumeClaimTemplates,omitempty"`

// Headless Service 名,跟 STS 一样
ServiceName string `json:"serviceName,omitempty"`

// 更新策略——NeoSet 的灵魂
UpdateStrategy NeoSetUpdateStrategy `json:"updateStrategy,omitempty"`

// 历史版本保留数量,默认 10
RevisionHistoryLimit *int32 `json:"revisionHistoryLimit,omitempty"`
}

// NeoSetUpdateStrategy 更新策略
type NeoSetUpdateStrategy struct {
// 更新类型
// InPlace — 原地升级(默认,NeoSet 核心能力)
// Recreate — 重建更新(跟原生 STS 一样,兜底用)
Type UpdateStrategyType `json:"type,omitempty"`

// Partition:只更新序号 >= partition 的 Pod
// replicas=5, partition=3 → 只更新 mysql-3, mysql-4
// 金丝雀:partition=replicas-1 → 只发最后 1 个
// 全量:partition=0 → 全部更新
Partition *int32 `json:"partition,omitempty"`

// MaxUnavailable:滚动过程中最多允许多少 Pod 不可用
// 支持 int(绝对值)或 string(百分比 "30%")
// 默认 1
MaxUnavailable *intstr.IntOrString `json:"maxUnavailable,omitempty"`

// 原地升级的就绪策略
InPlaceUpdateStrategy *InPlaceUpdateStrategy `json:"inPlaceUpdateStrategy,omitempty"`

// 暂停/恢复开关
// true — 发版暂停在当前进度
// false — 继续发版
Pause bool `json:"pause,omitempty"`
}

type UpdateStrategyType string

const (
InPlaceUpdateStrategyType UpdateStrategyType = "InPlace"
RecreateUpdateStrategyType UpdateStrategyType = "Recreate"
)

type InPlaceUpdateStrategy struct {
// 就绪判断方式
// Probe — 等 readinessProbe 通过(默认)
// Ready — 等 Pod Ready 条件为 true
Readiness ReadinessType `json:"readiness,omitempty"`

// 原地更新后等待就绪的超时时间
// 超时后标记为失败,触发重试
// 默认 300s
ReadinessTimeoutSeconds *int64 `json:"readinessTimeoutSeconds,omitempty"`
}

type ReadinessType string

const (
ReadinessTypeProbe ReadinessType = "Probe"
ReadinessTypeReady ReadinessType = "Ready"
)

Status​

// NeoSetStatus 定义实际状态
type NeoSetStatus struct {
// === 跟 STS 一样的字段 ===
Replicas int32 `json:"replicas"`
ReadyReplicas int32 `json:"readyReplicas"`
AvailableReplicas int32 `json:"availableReplicas"`
UpdatedReplicas int32 `json:"updatedReplicas"` // 已更新到 targetImage 的数量

// === NeoSet 独有 ===

// CurrentImage:当前稳定版本镜像
// 扩容时新 Pod 用这个 image,不用 spec.template 里的
// 发版完成后更新为 targetImage
CurrentImage string `json:"currentImage,omitempty"`

// TargetImage:目标版本镜像
// 跟 spec.template 里的 image 一致
// 发版完成后 == CurrentImage
TargetImage string `json:"targetImage,omitempty"`

// 当前发版阶段
// Idle — 空闲,没有发版
// Canary — 金丝雀阶段(只发了 1 个)
// RollingOut — 分批发版中
// Paused — 暂停
// Completed — 发版完成
// Failed — 发版失败(就绪超时等)
Phase NeoSetPhase `json:"phase,omitempty"`

// 当前 partition 值(运行时计算,不一定等于 spec 里的)
// 发版过程中 Controller 可能自动调整 partition
CurrentPartition int32 `json:"currentPartition,omitempty"`

// 当前 revision 号
CurrentRevision int64 `json:"currentRevision,omitempty"`

// 当前正在更新的 Pod 名(用于串行控制)
// NeoSet 默认一次只原地更新一个 Pod,等它 ready 再更新下一个
// 这个字段记录"当前在等谁 ready"
CurrentUpdatePod string `json:"currentUpdatePod,omitempty"`

// 上次更新时间
LastUpdateTime metav1.Time `json:"lastUpdateTime,omitempty"`
}

type NeoSetPhase string

const (
NeoSetPhaseIdle NeoSetPhase = "Idle"
NeoSetPhaseCanary NeoSetPhase = "Canary"
NeoSetPhaseRollingOut NeoSetPhase = "RollingOut"
NeoSetPhasePaused NeoSetPhase = "Paused"
NeoSetPhaseCompleted NeoSetPhase = "Completed"
NeoSetPhaseFailed NeoSetPhase = "Failed"
)

顶层类型​

// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:subresource:scale:specpath=.spec.replicas,statuspath=.status.replicas
// +kubebuilder:resource:shortName=neo
// +kubebuilder:printcolumn:name="Replicas",type="integer",JSONPath=".status.replicas"
// +kubebuilder:printcolumn:name="Ready",type="integer",JSONPath=".status.readyReplicas"
// +kubebuilder:printcolumn:name="Updated",type="integer",JSONPath=".status.updatedReplicas"
// +kubebuilder:printcolumn:name="Phase",type="string",JSONPath=".status.phase"
// +kubebuilder:printcolumn:name="Current",type="string",JSONPath=".status.currentImage"
// +kubebuilder:printcolumn:name="Target",type="string",JSONPath=".status.targetImage"
// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp"
type NeoSet struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`

Spec NeoSetSpec `json:"spec,omitempty"`
Status NeoSetStatus `json:"status,omitempty"`
}

// +kubebuilder:object:root=true
type NeoSetList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []NeoSet `json:"items"`
}

kubebuilder 注释说明​

注释作用
subresource:status启用 /status 子资源,Controller 写 status 不影响 spec
subresource:scale启用 /scale 子资源,kubectl scale neo mysql --replicas=5 能用
resource:shortName=neokubectl get neo 简写
printcolumnkubectl get neo 显示这些列

跑 make manifests 后,生成的 CRD YAML 会在 kubectl get neo 时显示:

$ kubectl get neo
NAME REPLICAS READY UPDATED PHASE CURRENT TARGET AGE
mysql 5 5 2 RollingOut mysql:8.0 mysql:8.1 3h

完整的 YAML 示例​

apiVersion: apps.neo.io/v1alpha1
kind: NeoSet
metadata:
name: mysql
spec:
replicas: 5
serviceName: mysql-headless
selector:
matchLabels:
app: mysql
template:
metadata:
labels:
app: mysql
spec:
containers:
- name: mysql
image: mysql:8.1 # ← 目标版本
ports:
- containerPort: 3306
readinessProbe:
tcpSocket:
port: 3306
initialDelaySeconds: 10
periodSeconds: 5
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 50Gi
updateStrategy:
type: InPlace
partition: 4 # ← 只更新 mysql-4(金丝雀)
maxUnavailable: 1
inPlaceUpdateStrategy:
readiness: Probe
readinessTimeoutSeconds: 300
pause: false # ← 不暂停,继续发
revisionHistoryLimit: 10

Spec/Status 的协作关系​

发版流程中各字段的变化:

初始状态 (v1→v2 发版前):
spec.template.image = mysql:8.1 (v2, 用户改了)
status.currentImage = mysql:8.0 (v1, 还没变)
status.targetImage = "" (空)
status.phase = Idle

Controller 发现 template 变了:
status.targetImage = mysql:8.1 (v2, 记下目标)
status.phase = Canary
status.currentPartition = 4 (只发 mysql-4)

金丝雀完成 (mysql-4 ready):
status.updatedReplicas = 1
status.phase = RollingOut
status.currentPartition = 2 (发 mysql-3, mysql-2)

分批完成:
status.updatedReplicas = 3
status.currentPartition = 0 (发 mysql-1, mysql-0)

全量完成:
status.updatedReplicas = 5
status.currentImage = mysql:8.1 (v2, 更新稳定版本)
status.targetImage = mysql:8.1
status.phase = Completed

扩容 (发版途中 replicas 5→8):
新 Pod 用 status.currentImage (还是 v1) 而不是 spec.template (v2)
避免新 Pod 混入未验证的新版本

前置阅读​

思考题​

问题 1:status.currentImage 和 status.targetImage 什么时候相等,什么时候不等?

问题 2:partition: 4, replicas: 5 时只更新 mysql-4。如果此时 pause: true,然后用户把 partition 改成 2 同时 pause: false,会发生什么?

问题 3:为什么 CurrentUpdatePod 要记在 status 里?Controller 重启后为什么需要它?


解答​

问题 1:没有发版时两者相等(都等于当前运行的 image)。发版过程中不等——targetImage 是 spec.template 里的新 image,currentImage 是上一个稳定版本的 image。发版完成后 Controller 把 currentImage 更新为 targetImage,两者再次相等。这个设计是为了支持"用旧镜像扩容"——发版途中扩容时,新 Pod 用 currentImage 而不是 targetImage。

问题 2:Controller 下一轮 Reconcile 时,发现 pause: false,读 partition: 2,发现 mysql-3 和 mysql-2 还没更新(image 还是 v1),就 PATCH 它们的 image。不会等用户确认——pause 从 true 变 false 就是"放行"信号。用户可以通过改 partition 控制发版范围,改 pause 控制是否继续。

问题 3:NeoSet 默认一次只原地更新一个 Pod,等它 ready 再更新下一个。CurrentUpdatePod 记录"当前在等谁 ready"。Controller 重启后,如果不看这个字段,它不知道 mysql-3 正在被更新中,可能重复 PATCH 或跳到下一个。有了这个字段,Controller 重启后知道"mysql-3 正在更新,先等它 ready"。

下一章​

下一章我们实现ControllerRevision 版本管理——历史版本存储、回滚逻辑、跟 status.currentRevision 的协作。