Skip to main content

3. ControllerRevision 版本管理

为什么需要版本管理​

上一章的 CRD 里有 status.currentRevision 字段——它指向当前版本的 ControllerRevision。这一章讲怎么存历史版本、怎么回滚。

原生 StatefulSet 也用 ControllerRevision,但 NeoSet 的用法不同:

原生 STS 回滚:
找到旧 ControllerRevision → 应用 template → 逐个重建 Pod

NeoSet 回滚:
找到旧 ControllerRevision → 应用 template → 原地 PATCH image
↑ 不重建,秒级回滚

ControllerRevision 是什么​

ControllerRevision 是 K8S 内置资源(apps/v1),专门存控制器的版本快照:

// k8s.io/api/apps/v1/types.go
type ControllerRevision struct {
metav1.TypeMeta
metav1.ObjectMeta

Data runtime.RawExtension `json:"data"` // 版本快照(序列化的 Pod 模板)
Revision int64 `json:"revision"` // 版本号
}

Data 字段装的是序列化后的 Pod 模板。NeoSet 每次发版时,把当前 template 存进一个 ControllerRevision,Revision 递增。

存历史版本​

NeoSet Controller 检测到 template 变化时,创建新 ControllerRevision:

// internal/controller/revision.go
package controller

import (
"context"
"fmt"

appsv1 "k8s.io/api/apps/v1"
"k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/util/json"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/log"

neov1alpha1 "neo.io/api/v1alpha1"
)

// CreateRevision 创建一个新的 ControllerRevision
func (r *NeoSetReconciler) CreateRevision(
ctx context.Context,
neoset *neov1alpha1.NeoSet,
templateHash string,
) (*appsv1.ControllerRevision, error) {
logger := log.FromContext(ctx)

// 1. 序列化当前 Pod 模板
templateBytes, err := json.Marshal(neoset.Spec.Template)
if err != nil {
return nil, fmt.Errorf("序列化 template 失败: %w", err)
}

// 2. 拿当前最大 revision 号
revisions, err := r.ListRevisions(ctx, neoset)
if err != nil {
return nil, err
}
var maxRevision int64 = 0
for _, rev := range revisions {
if rev.Revision > maxRevision {
maxRevision = rev.Revision
}
}

// 3. 检查是否已有相同 hash 的 revision(去重)
for _, rev := range revisions {
if rev.Labels["template-hash"] == templateHash {
logger.Info("模板未变化,复用已有 revision",
"revision", rev.Revision)
return &rev, nil
}
}

// 4. 创建新 ControllerRevision
revision := &appsv1.ControllerRevision{
ObjectMeta: metav1.ObjectMeta{
Name: fmt.Sprintf("%s-%s-%d", neoset.Name, templateHash, maxRevision+1),
Namespace: neoset.Namespace,
Labels: map[string]string{
"app.kubernetes.io/managed-by": "neoset-controller",
"neoset.neo.io/name": neoset.Name,
"template-hash": templateHash,
},
OwnerReferences: []metav1.OwnerReference{
*metav1.NewControllerRef(neoset, neov1alpha1.GroupVersion.WithKind("NeoSet")),
},
},
Revision: maxRevision + 1,
Data: runtime.RawExtension{
Raw: templateBytes,
},
}

if err := r.Create(ctx, revision); err != nil {
return nil, fmt.Errorf("创建 ControllerRevision 失败: %w", err)
}

logger.Info("创建新 ControllerRevision",
"revision", revision.Revision,
"hash", templateHash)

return revision, nil
}

// ListRevisions 列出 NeoSet 的所有历史版本
func (r *NeoSetReconciler) ListRevisions(
ctx context.Context,
neoset *neov1alpha1.NeoSet,
) ([]appsv1.ControllerRevision, error) {
revList := &appsv1.ControllerRevisionList{}
if err := r.List(ctx, revList, client.InNamespace(neoset.Namespace),
client.MatchingLabels{"neoset.neo.io/name": neoset.Name}); err != nil {
return nil, err
}
return revList.Items, nil
}

// CleanupOldRevisions 清理超限的历史版本
func (r *NeoSetReconciler) CleanupOldRevisions(
ctx context.Context,
neoset *neov1alpha1.NeoSet,
) error {
revisions, err := r.ListRevisions(ctx, neoset)
if err != nil {
return err
}

limit := 10
if neoset.Spec.RevisionHistoryLimit != nil {
limit = int(*neoset.Spec.RevisionHistoryLimit)
}

if len(revisions) <= limit {
return nil
}

// 按 revision 号排序,删掉最老的
sort.Slice(revisions, func(i, j int) bool {
return revisions[i].Revision < revisions[j].Revision
})

toDelete := len(revisions) - limit
for i := 0; i < toDelete; i++ {
if revisions[i].Revision == neoset.Status.CurrentRevision {
continue // 不删当前版本
}
if err := r.Delete(ctx, &revisions[i]); err != nil {
return err
}
}
return nil
}

几个关键设计​

去重——如果用户改了 spec 又改回来(template hash 相同),不创建新 revision,复用已有的。避免历史版本里一堆重复。

OwnerReference——ControllerRevision 的 owner 是 NeoSet。NeoSet 被删时,K8S 垃圾回收自动清理关联的 ControllerRevision。

不删当前版本——清理旧版本时跳过 CurrentRevision,防止把正在用的版本删了。

回滚逻辑​

// internal/controller/rollback.go

// RollbackToRevision 回滚到指定版本
func (r *NeoSetReconciler) RollbackToRevision(
ctx context.Context,
neoset *neov1alpha1.NeoSet,
targetRevision int64,
) error {
logger := log.FromContext(ctx)

// 1. 找目标 ControllerRevision
revisions, err := r.ListRevisions(ctx, neoset)
if err != nil {
return err
}

var targetRev *appsv1.ControllerRevision
for i := range revisions {
if revisions[i].Revision == targetRevision {
targetRev = &revisions[i]
break
}
}
if targetRev == nil {
return fmt.Errorf("revision %d 不存在", targetRevision)
}

// 2. 反序列化目标 template
var targetTemplate corev1.PodTemplateSpec
if err := json.Unmarshal(targetRev.Data.Raw, &targetTemplate); err != nil {
return fmt.Errorf("反序列化 template 失败: %w", err)
}

// 3. 把 NeoSet 的 spec.template 改回目标版本
neoset.Spec.Template = targetTemplate

// 4. 重置 partition,让所有 Pod 都回滚
partition := int32(0)
neoset.Spec.UpdateStrategy.Partition = &partition
neoset.Spec.UpdateStrategy.Pause = false

// 5. 更新 status——目标版本变了,需要重新发版
neoset.Status.Phase = neov1alpha1.NeoSetPhaseRollingOut
neoset.Status.TargetImage = targetTemplate.Spec.Containers[0].Image
neoset.Status.UpdatedReplicas = 0
neoset.Status.CurrentUpdatePod = ""

logger.Info("回滚到历史版本",
"targetRevision", targetRevision,
"targetImage", neoset.Status.TargetImage)

return nil
}

回滚的完整流程​

用户: kubectl rollout undo neoset mysql --to-revision=2

Controller:
1. 找到 revision=2 的 ControllerRevision
2. 反序列化它的 Data → 拿到旧 template
3. 更新 spec.template 为旧 template
4. partition 设为 0(全部回滚)
5. status.phase = RollingOut
6. status.targetImage = 旧 image

Reconcile 循环:
7. 发现 status.targetImage != 各 Pod 的 image
8. 逐个 PATCH Pod 改回旧 image(原地升级)
9. 全部 ready 后更新 status.currentImage

原地升级让回滚也变快——不重建 Pod,只 PATCH image。

kubectl rollout 支持​

Kubebuilder 生成的 CRD 默认支持 kubectl rollout。需要确认 CRD 有这些 annotation:

# config/crd/bases/apps.neo.io_neosets.yaml
annotations:
# 告诉 kubectl 这是可 rollout 的资源
kubectl.kubernetes.io/rollback-to: ""

之后这些命令能用:

# 查看历史版本
kubectl rollout history neoset mysql

# 回滚到上一版本
kubectl rollout undo neoset mysql

# 回滚到指定版本
kubectl rollout undo neoset mysql --to-revision=2

# 查看发版状态
kubectl rollout status neoset mysql

跟 status 的协作​

字段协作关系:

spec.template 变了
│
▼
Controller CreateRevision
│
├── status.currentRevision = 新 revision 号
├── status.targetImage = 新 image
└── status.phase = Canary/RollingOut
│
▼
Reconcile 逐个 PATCH Pod
│
▼
全部 ready 后:
├── status.currentImage = targetImage
└── status.phase = Completed

完整 Reconcile 中的版本管理片段​

func (r *NeoSetReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
var neoset neov1alpha1.NeoSet
if err := r.Get(ctx, req.NamespacedName, &neoset); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}

// 1. 检查 template 是否变了
templateHash := computeTemplateHash(neoset.Spec.Template)
currentHash := computeTemplateHashFromRevision(...) // 从 currentRevision 拿

if templateHash != currentHash {
// template 变了,创建新版本
rev, err := r.CreateRevision(ctx, &neoset, templateHash)
if err != nil {
return ctrl.Result{}, err
}
neoset.Status.CurrentRevision = rev.Revision
neoset.Status.TargetImage = neoset.Spec.Template.Spec.Containers[0].Image
neoset.Status.Phase = neov1alpha1.NeoSetPhaseCanary
neoset.Status.UpdatedReplicas = 0
}

// 2. 清理旧版本
if err := r.CleanupOldRevisions(ctx, &neoset); err != nil {
return ctrl.Result{}, err
}

// 3. ... 后续发版逻辑(下一章)
}

前置阅读​

思考题​

问题 1:ControllerRevision 去重时用 template-hash。如果用户改了 labels 但没改 image,hash 会变吗?该不该变?

问题 2:回滚时为什么把 partition 设为 0 而不是保持原值?

问题 3:CleanupOldRevisions 跳过 CurrentRevision。但如果用户回滚到一个很老的 revision,它可能不在"最近 N 个"里——会被误删吗?


解答​

问题 1:hash 应该只基于影响 Pod 行为的字段算——主要是 containers 的 image、command、env、resources。labels 改了不影响容器运行,不应该触发新版本。如果 hash 包含 labels,用户改个 label 就多一个历史版本,很快超限。NeoSet 的 computeTemplateHash 只取 containers 和 initContainers 的关键字段算 hash。

问题 2:回滚是"把所有 Pod 改回旧版本"——partition=0 意味着所有序号 >= 0 的 Pod 都要更新(即全部)。如果保持原值比如 partition=2,只有序号 >= 2 的回滚,序号 0、1 还是新版本——没回滚干净。回滚的语义是"回到目标版本",不是"部分回滚"。

问题 3:不会。CleanupOldRevisions 删的是按 revision 号排序后最老的 N 个,但跳过 CurrentRevision。即使目标 revision 很老,只要它是 CurrentRevision(回滚后 status.currentRevision 会指向它),就不会被删。但如果用户回滚后又不发版好多次,老的 revision 可能被挤出 limit 范围被删——这是预期行为,revisionHistoryLimit 就是为了控制保留数量。

下一章​

下一章我们实现Admission Webhook——配额校验、默认值填充、金丝雀参数校验。