site logo

Marico's space

自动更新 Kubernetes 工作负载:注解驱动的滚动部署与熔断机制

服务器技术 2026-04-27 21:00:46 13

作者做 AI agent 运行时(agent runtime)相关工作,集群上跑着若干 agent pod,每周定时有新版本发布,手动更新十个 pod 既累又容易出错。这篇文章介绍了 k8s4claw 项目中 auto-update controller 的设计思路:用一个注解(annotation)驱动整个滚动部署流程,配合健康检查和熔断器,实现"自动化但可控"的镜像更新。

说实话,这种"把状态写在资源上、controller 无需记忆"的设计思路值得玩味——很多 K8s operator 写复杂了,往往就是因为没想清楚这一点。

问题的形态

开启自动更新后,Claw 资源大概长这样:

spec:
  runtime: openclaw
  autoUpdate:
    enabled: true
    schedule: "0 3 * * *"           # 每天凌晨 3 点
    versionConstraint: ">=1.0.0,<2"
    healthTimeout: "10m"
    maxRollbacks: 3

五个字段,controller 需要:

  1. 按 cron 表达式定时唤醒(不是"每 N 秒"那种轮询)
  2. 查询 registry 中镜像存在哪些 tags
  3. 按语义化版本(semver)过滤符合约束的 tags
  4. 选出严格大于当前运行版本的最高版本,跳过已经尝试过且回滚过的版本
  5. 应用它——但不直接 patch StatefulSet
  6. healthTimeout(默认 10 分钟)内观察就绪状态
  7. 如果 sts.Status.UpdatedReplicassts.Status.ReadyReplicas 都达到期望数量:记录成功,重置回滚计数器
  8. 如果超时:清除 target-image 注解,让主 reconciler 回退到 runtime adapter 的默认镜像,标记该版本失败,递增回滚计数器
  9. 连续失败 maxRollbacks 次后:打开熔断器并停止尝试。后续版本检查会发出"有版本可用,但熔断器已打开"的事件,而不是应用新镜像

不太明显的地方在于状态存放在哪里以及灰度发布实际上是怎么发生的。两者用到了同一个技巧。

机制 1 — 注解驱动正在进行的更新

auto-update controller 从不在 reconcile 之间保持内存状态。状态存在于 Claw 资源上的两个地方:

  • Annotations 驱动正在进行的更新——我们想要什么镜像、处于什么阶段、何时开始
  • status.autoUpdate 保存持久化记账信息——当前版本、可用版本、回滚次数、熔断器标志、失败版本列表、版本历史

三个注解:

const (
    annotationTargetImage = "claw.prismer.ai/target-image"
    annotationUpdatePhase = "claw.prismer.ai/update-phase"
    annotationUpdateStart = "claw.prismer.ai/update-started"
)
  • target-image — 我们想要运行的全镜像引用(如 ghcr.io/.../openclaw:1.2.0)。成功更新后保持设置
  • update-phase — 目前只有 HealthCheck 或空。空 = 空闲
  • update-started — 设置阶段注解时的 RFC3339 时间戳,供健康检查计时器使用

Reconcile 在阶段上是一个二路分支:

phase := claw.Annotations[annotationUpdatePhase]
if phase == "HealthCheck" {
    return r.reconcileHealthCheck(ctx, &claw)
}
// otherwise: idle — check if a version poll is due

这意味着 controller 是无状态且幂等的。如果 operator pod 在更新过程中重启,下次 reconcile 从 etcd 读回注解,正好从上次中断的地方继续。没有什么 map 需要重建,也不需要 leader election 来处理正在进行的操作。Kubernetes 就是数据库,controller 就是当前状态的一个函数。

这样做还有一个好处:kubectl describe claw foo 会原样显示正在进行的更新,不需要 tracing,不需要 grep 日志。状态就在资源上。

机制 2 — 更新就是一个注解

auto-update 逻辑不 patch StatefulSet,不触碰 pods。它做的是:

targetImage := baseImage + ":" + newVersion
claw.Annotations[annotationTargetImage] = targetImage
claw.Annotations[annotationUpdatePhase] = "HealthCheck"
claw.Annotations[annotationUpdateStart] = now.Format(time.RFC3339)
r.Update(ctx, &claw)

就这样。这就是完整的"应用新版本"代码路径。

更新之所以真正发生,是因为 ClawReconciler 也在监听同一个 Claw 资源,并在每次 reconcile 时重建 pod template。它检查注解:

podTemplate := adapter.PodTemplate(claw)

// Auto-update: override runtime image if target-image annotation is set.
if targetImage := claw.Annotations["claw.prismer.ai/target-image"]; targetImage != "" {
    for i := range podTemplate.Spec.Containers {
        if podTemplate.Spec.Containers[i].Name == "runtime" {
            podTemplate.Spec.Containers[i].Image = targetImage
            break
        }
    }
}

所以 auto-update controller 纯粹是一个信号源——它说"我想要这个镜像运行",具体怎么执行由主 reconciler 负责。

这种分离很重要:

  1. 回滚基本上就是删除注解。 回滚时删除 target-image 注解,主 reconciler 在下次遍历时自动回退到 adapter 默认镜像。StatefulSet 逻辑中不需要特殊的"回滚路径"。
  2. 手动镜像覆盖继续有效。 如果有人手动设置了 target-image 用于热修复,主 reconciler 会遵循它。auto-update controller 比较的是 status.CurrentVersion(而不是注解),所以不会意外改变对"当前版本"的理解。
  3. 可以完全移除 auto-update controller 而不会破坏任何东西。

机制 3 — 语义化版本解析

版本选择逻辑:

func ResolveBestVersion(tags []string, constraint, current string, failedVersions []string) (string, bool) {
    c, err := semver.NewConstraint(constraint)
    if err != nil {
        return "", false
    }

    var currentVer *semver.Version
    if current != "" {
        currentVer, _ = semver.NewVersion(current)
    }

    failedSet := make(map[string]bool, len(failedVersions))
    for _, f := range failedVersions {
        failedSet[f] = true
    }

    var best *semver.Version
    for _, tag := range tags {
        v, err := semver.NewVersion(tag)
        if err != nil {
            continue // skip non-semver tags like "latest", "sha-abc"
        }
        if !c.Check(v) {
            continue
        }
        if failedSet[v.Original()] {
            continue
        }
        if currentVer != nil && !v.GreaterThan(currentVer) {
            continue
        }
        if best == nil || v.GreaterThan(best) {
            best = v
        }
    }

    if best == nil {
        return "", false
    }
    return best.Original(), true
}

三个值得注意的细节:

  • 非 semver 标签会被静默丢弃。 latestsha-abc1234nightly 等无法与版本约束比较的标签,不会被自动滚动进来。这是正确的默认行为。
  • failedVersions 在约束检查之后按精确原始标签字符串检查。 被回滚过的版本会从未来的自动选择中排除。注意约束检查是 semver 感知的,但失败版本过滤是按字符串精确匹配的——"1.2.0""v1.2.0" 被当作不同字符串。这是保守的设计:假设 v1.2.0 卡过你的 pods,下一次凌晨 3 点的 cron 运行也不会自动修复它。
  • !v.GreaterThan(currentVer) 排除了相等情况。 每次 cron 触发都重新安装相同版本会是一个无意义的操作。

auto-update controller 还有一个针对 digest 固定镜像的提前退出检查:

currentImage := claw.Annotations[annotationTargetImage]
if currentImage != "" && registry.IsDigestPinned(currentImage) {
    logger.Info("skipping auto-update: image is digest-pinned", "image", currentImage)
    return r.requeueAtNextCron(spec), nil
}

它检查的是 target-image 注解,而不是实际运行的镜像。IsDigestPinned 就是 strings.Contains(image, "@sha256:")。如果把 target-image 设置为 digest 固定的引用,controller 就会停止触碰那个 Claw。

机制 4 — 健康验证

注解设置好、主 reconciler 滚动 StatefulSet 之后,auto-update controller 每 15 秒重新入队并观察就绪状态:

desiredReplicas := int32(1)
if sts.Spec.Replicas != nil {
    desiredReplicas = *sts.Spec.Replicas
}
if sts.Status.UpdatedReplicas >= desiredReplicas &&
   sts.Status.ReadyReplicas >= desiredReplicas {
    // Health check passed.
}

两个条件,两者都必须满足:

  • UpdatedReplicas — 运行 template 的 pods,不是旧的。没有这个检查,你会在旧 pods 仍然 ready 的情况下就"成功"了——那时滚动甚至还没开始
  • ReadyReplicas — 通过就绪探针的 pods

如果两者在 healthTimeout(默认 10 分钟)内都通过,记录成功:重置回滚计数器,重置熔断器,向版本历史追加记录,并清除 update-phaseupdate-started 注解。注意有意保留 target-image——它是主 reconciler 用来覆盖 runtime 容器镜像的信号。

如果计时器先到期,则回滚。15 秒是轮询间隔,不是截止时间。真正的截止时间是 healthTimeout。如果正在升级一个需要 8 分钟预热的重量级 runtime,设置 healthTimeout: 15m 即可。

机制 5 — 熔断器

回滚一次是意外,连续回滚三次是系统在告诉你停下来:

maxRollbacks := defaultMaxRollbacks  // 3
if spec.MaxRollbacks > 0 {
    maxRollbacks = spec.MaxRollbacks
}
if status.RollbackCount >= maxRollbacks {
    status.CircuitOpen = true
    SetAutoUpdateCircuit(claw.Namespace, claw.Name, true)
    r.Recorder.Event(claw, corev1.EventTypeWarning, EventAutoUpdateCircuitOpen,
        fmt.Sprintf("Circuit breaker opened after %d rollbacks", status.RollbackCount))
}

熔断器打开后,主 Reconcile 路径检测到新版本并发出事件:"版本 X 可用,但我们不会应用它"。用户可以在 kubectl describe claw foo 中看到这些。

controller 不会自动恢复熔断器——没有"等待 24 小时再试"计时器,没有指数退避。恢复路径有两种:

  1. 人工将 status.autoUpdate.circuitOpen patch 为 false(通常还会把 rollbackCount patch 为 0)。下次 cron 触发恢复正常版本轮询。
  2. 人工强制走更新路径——手动设置全部三个注解(target-image 设为已知可用的镜像、update-phase 设为 HealthCheckupdate-started 设为新的 RFC3339 时间戳)。成功灰度后会重置 RollbackCountCircuitOpen

理由:连续三个坏版本可能意味着 controller 视野之外出了问题(上游镜像坏了、探针坏了、集群网络坏了)。自动恢复只会重新发现坏状态,我们宁可去通知人。

机制 6 — 版本历史(带上限)

每次成功的更新和每次回滚都会向 Status.AutoUpdate.VersionHistory 追加一条记录:

status.VersionHistory = append(status.VersionHistory, clawv1alpha1.VersionHistoryEntry{
    Version:   version,
    AppliedAt: metav1.Now(),
    Status:    clawv1alpha1.VersionHistoryHealthy,  // or VersionHistoryRolledBack
})
trimVersionHistory(status)

trimVersionHistory 存在是因为 etcd 对象有大小限制,否则一个每天更新、持续两年的 Claw 会积累大量历史记录:

const maxVersionHistory = 50

func trimVersionHistory(status *clawv1alpha1.AutoUpdateStatus) {
    if len(status.VersionHistory) > maxVersionHistory {
        status.VersionHistory = status.VersionHistory[len(status.VersionHistory)-maxVersionHistory:]
    }
}

50 条记录足以调试过去几个月的活动。需要长期审计的话,把 controller 的事件抓取到可观测性栈中——Status 字段不是审计日志。

Update vs Status().Update 的配合

注解在资源上(metadata 下),Status 字段在 .status 下。在 Kubernetes 中,这些通过不同的子资源写入:

  • r.Update(ctx, claw) — 写入 metadataspec,递增 resourceVersion
  • r.Status().Update(ctx, claw) — 写入 .status,也递增 resourceVersion

当单次 reconcile 需要写入两者时,内存中的 claw 对象在两次调用之间会变旧。controller 在中间做了显式重新获取:

// Update annotations first, then re-fetch and merge status.
if err := r.Update(ctx, &claw); err != nil {
    return ctrl.Result{}, fmt.Errorf("failed to set target-image annotation: %w", err)
}
// Re-fetch to get updated resourceVersion before status update.
if err := r.Get(ctx, req.NamespacedName, &claw); err != nil {
    return ctrl.Result{}, fmt.Errorf("failed to re-fetch after annotation update: %w", err)
}
mergeAutoUpdateStatus(&claw, status)
// ... write status ...
if err := r.Status().Update(ctx, &claw); err != nil {
    return ctrl.Result{}, fmt.Errorf("failed to update status: %w", err)
}

重新获取会拿到新的 resourceVersion,这样 Status().Update 就不会与刚做的写入冲突。没有它,你会在任何非平凡的 reconcile 频率下看到 409 错误。

mergeAutoUpdateStatus 逐字段复制本地跟踪的 status 字段到刚重新获取的对象中,而不是 wholesale 替换指针。这是保守的做法:如果将来 AutoUpdateStatus 添加了新字段而忘了在本地跟踪, wholesale 替换会悄然将其清零。

可测试性:Clock 和 TagLister

两个接口,都是为了测试:

type TagLister interface {
    ListTags(ctx context.Context, image string) ([]string, error)
}

type Clock interface {
    Now() time.Time
    Since(t time.Time) time.Duration
}

TagLister 让单元测试可以注入模拟 tag 列表而不是真正访问 registry。Clock 让测试可以在没有 time.Sleep 的情况下推进时间。

auto-update 单元测试使用 controller-runtime/pkg/client/fake——不需要真实 API server,不需要 kube-apiserver 进程,只是一个内存客户端-backed by typed scheme。它们创建一个 Claw,用受控时钟运行单次 Reconcile 遍历,然后断言注解和 Status.AutoUpdate。没有真正的 registry 调用,没有真正的计时器,没有 flaky 测试。每条测试总运行时间在亚秒级。

如果你发现自己在 reconciler 内部直接使用 time.Now() 或直接调用外部 API,先停下来定义接口。未来写测试的你会感谢现在的你。

有意没做的事

  • 预检镜像探针。 不在翻转 StatefulSet 之前拉取新镜像并尝试运行它。StatefulSet 灰度发布本身就是一种探针——就绪检查在生产环境中运行。
  • 金丝雀部署。 先灰度一个 pod,观察,然后灰度其余的。对于 replicas 是 1 的 workloads,没有必要;对于更高 replicas 的部署,可以在 idle 和 HealthCheck 之间增加一个 Canary 阶段。
  • Webhook 驱动的更新。 从 registry 推送而不是轮询。操作上更简单,但会在 registry 到集群之间创建一个入站依赖,cron 轮询在操作简单性上胜出。
  • 跨 namespace 协调。 如果有多个 Claw 使用同一镜像,而一个坏版本发布了,它们会独立回滚。熔断器 + 失败版本列表已经足够,每个 Claw 从自己的痛苦中学习。
  • 镜像签名验证。 Sigstore / cosign 集成可以插入到 IsDigestPinned 的层级,但对于大多数项目来说还没到那一步。

总结

一个约 470 行的 controller,为一个 CRD 做 cron 驱动、semver 过滤、健康验证、自动回滚的镜像更新,带熔断器和版本历史。所有正在进行的的状态都在 Claw 资源上(注解用于阶段,.status 用于持久化记账),所以 controller 跨重启没有内存状态可丢失。

如果要我指给一个初级 K8s controller 作者看这段代码的亮点,那就是注解驱动的分离:controller 不做灰度发布,它请求灰度发布。一旦你内化了这个,很多 K8s controller 都会变小。

原文:https://dev.to/willamhou/auto-updating-kubernetes-workloads-an-annotation-driven-rollout-with-circuit-breaker-280o