从脚本到 Operator:用 Go 构建可靠的微服务自动扩缩容控制器

从脚本到 Operator:用 Go 构建可靠的微服务自动扩缩容控制器

文章定位:面向具备 Go、Kubernetes 基础的后端与平台工程师,从控制语义、容量模型、控制器实现到部署验收,建立自动扩缩容的工程闭环。

交付边界:本文给出设计、关键代码与实验方法,不附带完整项目仓库。标为"伪代码"的部分需要组装实现;不将未执行的实验、示例参数或虚构业务数据表述为生产验证结论。版本选择以项目锁定的依赖和目标集群为准。本文是独立的自动扩缩容进阶实验,不应被表述为 Aegis W03 的已验收功能。

一、扩容慢,先找到时间花在哪里

设想一个订单入口:流量突然上升,Deployment 的期望副本已经从 3 调整到 12,但用户超时仍在增加。

此时,"控制器扩容慢"只是一个假设。新增容量的生效路径至少包括:
#mermaid-svg-HpKPLNCk68RTxkSM{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-HpKPLNCk68RTxkSM .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-HpKPLNCk68RTxkSM .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-HpKPLNCk68RTxkSM .error-icon{fill:#552222;}#mermaid-svg-HpKPLNCk68RTxkSM .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-HpKPLNCk68RTxkSM .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-HpKPLNCk68RTxkSM .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-HpKPLNCk68RTxkSM .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-HpKPLNCk68RTxkSM .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-HpKPLNCk68RTxkSM .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-HpKPLNCk68RTxkSM .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-HpKPLNCk68RTxkSM .marker{fill:#333333;stroke:#333333;}#mermaid-svg-HpKPLNCk68RTxkSM .marker.cross{stroke:#333333;}#mermaid-svg-HpKPLNCk68RTxkSM svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-HpKPLNCk68RTxkSM p{margin:0;}#mermaid-svg-HpKPLNCk68RTxkSM .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-HpKPLNCk68RTxkSM .cluster-label text{fill:#333;}#mermaid-svg-HpKPLNCk68RTxkSM .cluster-label span{color:#333;}#mermaid-svg-HpKPLNCk68RTxkSM .cluster-label span p{background-color:transparent;}#mermaid-svg-HpKPLNCk68RTxkSM .label text,#mermaid-svg-HpKPLNCk68RTxkSM span{fill:#333;color:#333;}#mermaid-svg-HpKPLNCk68RTxkSM .node rect,#mermaid-svg-HpKPLNCk68RTxkSM .node circle,#mermaid-svg-HpKPLNCk68RTxkSM .node ellipse,#mermaid-svg-HpKPLNCk68RTxkSM .node polygon,#mermaid-svg-HpKPLNCk68RTxkSM .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-HpKPLNCk68RTxkSM .rough-node .label text,#mermaid-svg-HpKPLNCk68RTxkSM .node .label text,#mermaid-svg-HpKPLNCk68RTxkSM .image-shape .label,#mermaid-svg-HpKPLNCk68RTxkSM .icon-shape .label{text-anchor:middle;}#mermaid-svg-HpKPLNCk68RTxkSM .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-HpKPLNCk68RTxkSM .rough-node .label,#mermaid-svg-HpKPLNCk68RTxkSM .node .label,#mermaid-svg-HpKPLNCk68RTxkSM .image-shape .label,#mermaid-svg-HpKPLNCk68RTxkSM .icon-shape .label{text-align:center;}#mermaid-svg-HpKPLNCk68RTxkSM .node.clickable{cursor:pointer;}#mermaid-svg-HpKPLNCk68RTxkSM .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-HpKPLNCk68RTxkSM .arrowheadPath{fill:#333333;}#mermaid-svg-HpKPLNCk68RTxkSM .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-HpKPLNCk68RTxkSM .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-HpKPLNCk68RTxkSM .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-HpKPLNCk68RTxkSM .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-HpKPLNCk68RTxkSM .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-HpKPLNCk68RTxkSM .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-HpKPLNCk68RTxkSM .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-HpKPLNCk68RTxkSM .cluster text{fill:#333;}#mermaid-svg-HpKPLNCk68RTxkSM .cluster span{color:#333;}#mermaid-svg-HpKPLNCk68RTxkSM div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-HpKPLNCk68RTxkSM .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-HpKPLNCk68RTxkSM rect.text{fill:none;stroke-width:0;}#mermaid-svg-HpKPLNCk68RTxkSM .icon-shape,#mermaid-svg-HpKPLNCk68RTxkSM .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-HpKPLNCk68RTxkSM .icon-shape p,#mermaid-svg-HpKPLNCk68RTxkSM .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-HpKPLNCk68RTxkSM .icon-shape .label rect,#mermaid-svg-HpKPLNCk68RTxkSM .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-HpKPLNCk68RTxkSM .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-HpKPLNCk68RTxkSM .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-HpKPLNCk68RTxkSM :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 业务负载上升
指标采集与查询窗口
控制器决策与写入 scale
调度与节点供给
镜像拉取与应用初始化
就绪检查与流量接入

可将端到端扩容时延近似拆成:

T_{capacity}=T_{metric}+T_{decision}+T_{schedule}+T_{startup}+T_{ready}

这是便于定位的分解,不是严格互斥的计时公式:部分过程可能重叠。排查时记录每阶段时间戳,尤其区分:

  • 期望副本数增加:控制器已经提交扩容意图。
  • Pod 被创建:Deployment/ReplicaSet 控制器开始执行。
  • Pod Ready:容器通过就绪检查。
  • 业务容量恢复:实例已接流量,缓存、连接池、JIT 等也进入可服务状态。

如果耗时集中在 Java 服务启动和节点扩容,重写 Operator 并不会自动消除这些时间。预留容量、优化启动、提前扩容可能更有效。

1.1 不要因为"自定义指标"就重写 HPA

HPA 不只支持 CPU、内存;autoscaling/v2 还支持自定义指标和外部指标,并提供多指标决策与扩缩容行为配置。指标采集周期与控制器同步周期是不同环节,也不能把某个 metrics-server 配置当成所有集群的固定值。1

需求 优先方案 自建控制器的额外成本
CPU、内存驱动 HPA 重复实现已有控制逻辑
QPS、业务指标 HPA + 指标适配器 指标接入、容错与兼容维护
队列或外部事件 评估 KEDA 等事件驱动方案 触发器、激活与失败策略
活动预热、审批、预算和维护窗口联合约束 业务 Operator,必要时生成 HPA CRD 生命周期、权限、升级与值班
只需要批量调整一次副本 脚本或运维任务 长期控制器可能得不偿失

自建 Operator 的合理理由是:已有机制不能清晰表达你的持续运维规则,并且团队有能力维护这套规则。

本文采用"直接写 Deployment 的 scale 子资源"的独立实验方案,便于理解执行边界。实际平台也可以让 Operator 只生成、维护 HPA,由 HPA 成为副本数的唯一写入者。

二、先规定谁有权修改副本数

同一个 Deployment 如果同时被 HPA、业务 Operator、GitOps 和人工脚本修改副本数,就会形成互相纠偏的控制环。

Leader Election 只协调使用同一选主域的控制器实例,不能阻止其他控制器或人写入。

2.1 本文的管理契约

  1. 只管理与 AutoScaler 位于同一 namespace 的 apps/v1 Deployment。
  2. 每个 Deployment 最多绑定一个启用中的 AutoScaler。
  3. 接管前检查已有 HPA,以及平台登记的其他副本管理者。
  4. GitOps 配置移交 spec.replicas 的管理权,避免持续覆盖。
  5. 删除 AutoScaler 后停止管理,保留 Deployment 和当前副本数。
  6. 初版 minReplicas >= 1,不实现 scale-to-zero 的流量激活链路。
  7. 暂停和 dry-run 是明确的策略状态,不依赖删除 Pod 实现。

重复绑定检查需要注意并发:两个 CR 可以同时通过"先查再创建"。仅靠列表检查属于检测,不能宣称严格互斥。生产方案应实现一个同 namespace、以目标 Deployment 命名的 ScaleBinding:admission 先拒绝已有 HPA/冲突绑定,再由 Controller 以 resourceVersion Patch 原子争抢 Binding 的 ownerUID。删除 CR 时只解绑 owner UID 匹配的 Binding;目标 Deployment UID 变化时暂停缩容、重新验证管理权并重新积累稳定窗口。该机制仍要求 GitOps 显式移交 spec.replicas,否则 GitOps 会继续覆盖写入。

2.2 不拥有工作负载,就不要设置 ownerReference

这里的 Deployment 通常由业务发布系统创建。AutoScaler 只是引用它,不应为了方便使用 Owns() 就把它变成自己的子对象,否则删除 CR 可能连带影响工作负载的垃圾回收。

Owns(&Deployment{}) 适用于具有匹配 ownerReference 的对象。普通 targetRef 引用需要显式建立映射监听,或使用定时 Reconcile。2

三、Operator 是持续收敛机制,不是事件回调脚本

Reconcile 收到的请求主要是对象的 namespace/name,不是必须按顺序消费的业务事件日志。控制器应重新读取当前状态,再决定下一步。

"幂等"在这里指:在相同输入、观测和策略状态下,重复处理不会重复产生有害副作用。自动扩缩容还依赖时间和外部指标,Reconcile 本身不是纯函数。

适合测试的纯函数是策略层:

text 复制代码
指标样本 + 当前副本 + 策略配置 + 历史状态 + 当前时间 → 决策
组件 提供的能力 仍由开发者负责的部分
Informer/cache list/watch、本地观测 监听范围、内存、陈旧数据处理
WorkQueue 合并同一 key、重试调度 业务限速、外部服务并发预算
Reconciler 执行收敛逻辑 错误分类、幂等、副作用恢复
Manager 生命周期、选主、端点 RBAC、探针含义、退出行为

Manager 的默认客户端通常缓存读、直接写,但不是所有对象读取都必然经过缓存;APIReader、子资源和配置都会改变路径。写入成功后立刻通过缓存读,也未必能看到最新结果。3

四、CRD 首先是一份业务协议

4.1 一个明确指标语义的示例

yaml 复制代码
apiVersion: autoscale.example.com/v1alpha1
kind: AutoScaler
metadata:
  name: order-service
  namespace: production
spec:
  targetRef:
    name: order-service
  minReplicas: 2
  maxReplicas: 20
  pollIntervalSeconds: 15
  paused: false
  dryRun: true
  metrics:
    - name: request-rate
      type: Prometheus
      metricProfileRef: order-service-total-qps
      targetPerReplica: "100"
      maxAgeSeconds: 45
  behavior:
    scaleUpMaxStep: 4
    scaleDownMaxStep: 1
    scaleDownStabilizationSeconds: 300
    scaleDownMinIntervalSeconds: 60

这里明确约定:查询返回整个目标服务的总 QPS ,targetPerReplica 表示压测确认的单个 Ready 副本目标承载量,单位是请求/秒。"100" 是实验值,不是通用容量建议。

Prometheus 地址由管理员统一配置,凭证通过受控 Secret 提供,不允许普通 CR 任意指定外部 URL。普通 CR 不直接携带 PromQL;metricProfileRef 指向管理员维护的 MetricProfile,其中固定查询表达式、允许的标签/namespace、超时、最大时间范围与查询成本预算。这样既防止跨租户查询和高成本查询拖垮共享 Prometheus,也让审计与变更评审有明确对象。

4.2 API 设计需要校验什么

字段 约束或语义
targetRef.name 必填;初版固定同 namespace Deployment
min/maxReplicas 1 <= min <= max,并设置平台允许的上限
pollIntervalSeconds 正整数,并设置允许范围
metrics 至少一项,名称唯一、数量有上限
metricProfileRef 必须存在、在授权范围内;Profile 固定查询及其成本边界
targetPerReplica 可解析、有限、严格大于 0
maxAgeSeconds 正数;与查询和采集链路配套
behavior 步长为正,时间窗口有上限

采用字符串承载阈值时,不能用整数类型的 Minimum marker 假装完成数值校验;需要合适的 Quantity 类型、CEL 或 webhook,并在运行时再次校验解析结果。

跨字段关系可通过 CRD 的 CEL 校验,例如:

go 复制代码
// 放在 Spec 类型上,由 controller-gen 生成相应校验。
// +kubebuilder:validation:XValidation:rule="self.maxReplicas >= self.minReplicas",message="maxReplicas must be >= minReplicas"

依赖工具、controller-gen 和集群支持范围必须在版本基线中确认。targetRef 是否存在仍要在 Reconcile 中检查,准入时存在不代表随后不会被删除。

4.3 status 表示事实和决策,不应混淆

yaml 复制代码
status:
  observedGeneration: 7
  targetUID: "deployment-uid"
  targetReplicas: 6
  readyReplicas: 4
  recommendedReplicas: 10
  desiredReplicas: 10
  appliedReplicas: 6
  lastSuccessfulScaleTime: "2026-10-16T02:00:00Z"
  conditions:
    - type: MetricsAvailable
      status: "True"
      reason: SamplesValid
    - type: ScalingLimited
      status: "True"
      reason: ScaleUpStepLimited

上例中原始建议可大于最终 desired,实际字段值应由本轮决策填入;各阶段不能为了"看起来成功"都写成一个值。Conditions 的完整对象还应包含 message、lastTransitionTime,并记录适用的 observedGeneration。

建议至少区分:TargetAvailable、MetricsAvailable、AbleToScale、ScalingLimited。若提供 Ready,必须说明它表示"控制器可管理",还是"业务副本已就绪"。

status.observedGeneration == metadata.generation 只说明状态评估对应哪个 spec 版本,不代表外部指标没有变化。不能据此跳过后续指标评估。

五、先把容量公式算对

5.1 总量指标与平均指标不能混用

假设服务总 QPS 为 900,单副本目标承载量为 100 QPS:

N_{raw}=\\lceil900/100\\rceil=9

与当前是 3 个还是 6 个副本无关。

如果输入是每个 Pod 的平均值,才可以使用:

N_{raw}=\\left\\lceil N_{current}\\times\\frac{M_{average}}{M_{target}}\\right\\rceil

把 sum(rate(...)) 得到的总 QPS 代入第二个公式,再乘当前副本数,会把容量需求重复放大。

对积压队列,如果每个消费者处理速度为 μ 条/秒,希望在 T 秒内清空现有积压 Q,持续入队速率为 λ,则可从以下容量近似开始:

N_{raw}=\\left\\lceil\\frac{\\lambda+Q/T}{\\mu}\\right\\rceil

这依赖消费能力可扩展、任务分布和下游未饱和等假设。分区数、热点 key、数据库连接数都可能限制可用并行度。只使用 ceil(Q / 每副本允许积压数) 也可以,但它是积压目标策略,不能当作清空时间承诺。

5.2 指标异常不等于负载为零

Prometheus 接入至少检查:HTTP 状态、响应大小、JSON 状态、resultType、序列数量、数值合法性和时效性。

  • 空 vector:无有效观测,不默认当作 0。
  • 多条序列:按契约拒绝,或使用预先定义的聚合,不能随手取第一条。
  • NaN、Inf、负数:对 QPS/队列语义视为非法。
  • 超时、权限错误:记录分类,不进入零负载分支。
  • 陈旧样本:拒绝参与缩容。

即时查询结果时间戳不总能证明底层采集足够新,尤其是 rate、聚合和 recording rule。生产侧应同时检查采集目标健康、规则延迟或业务心跳。maxAgeSeconds 需要配合这些检查,不能只比较查询执行时间。

5.3 多指标部分失败时,扩容与缩容采取不同策略

有效指标的建议数取最大值;若有指标失败:

  • 有效指标已建议扩容:允许按边界扩容。
  • 有效指标建议缩容:保留当前副本,不执行缩容。
  • 全部指标失败:默认保持当前副本并告警。

这是一个保守策略,避免监控故障触发缩容。是否设置紧急保底副本由业务容量契约另行决定;保底也要受资源预算约束。

5.4 可单独测试的总量决策函数

以下代码是独立的策略片段,放入 internal/scaling/decision.go;不依赖 Kubernetes 客户端:

go 复制代码
package scaling

import (
    "fmt"
    "math"
)

type Metric struct {
    Value float64
    PerReplica float64
    Valid bool // 接入层已完成时效与响应语义检查
}

type Decision struct {
    Replicas int32
    Degraded bool
}

func Recommend(current, min, max int32, metrics []Metric) (Decision, error) {
    if min < 1 || max < min || current < 0 || len(metrics) == 0 {
        return Decision{}, fmt.Errorf("invalid policy or input")
    }
    best := min
    valid, failed := 0, false
    for _, m := range metrics {
        if !m.Valid || math.IsNaN(m.Value) || math.IsInf(m.Value, 0) ||
            m.Value < 0 || math.IsNaN(m.PerReplica) ||
            math.IsInf(m.PerReplica, 0) || m.PerReplica <= 0 {
            failed = true
            continue
        }
        valid++
        raw := math.Ceil(m.Value / m.PerReplica)
        candidate := min
        // 先限制范围,再转 int32,避免超大浮点数转换溢出。
        if raw >= float64(max) {
            candidate = max
        } else if raw > float64(min) {
            candidate = int32(raw)
        }
        if candidate > best {
            best = candidate
        }
    }
    // 故障时保持当前值优先于收回副本,即使刚下调了 max。
    // 平台应暴露 OutOfBounds,待指标恢复或人工批准再收敛。
    if valid == 0 || (failed && best < current) {
        return Decision{Replicas: current, Degraded: true}, nil
    }
    return Decision{Replicas: best, Degraded: failed}, nil
}

此函数只生成指标建议,尚未处理稳定窗口、时间限速、暂停和 dry-run。明确分层可以避免所有规则挤进一个 Reconcile。

六、快扩慢缩,必须落实到时间语义

6.1 稳定窗口、步长和冷却是不同规则

机制 要解决的问题 示例
扩容步长 单次变化过大 本轮最多增加 4 个
扩容速率 高频事件不断累计扩容 任意 60 秒最多增加约定数量
缩容稳定窗口 短时低谷造成误缩容 取近 300 秒建议值的最大值
缩容最小间隔 连续缩容过快 两次成功缩容至少间隔 60 秒
容差 阈值附近抖动 可基于业务测量另设带宽

"每轮最多增加 4 个"不等于"每 15 秒最多增加 4 个":watch、重试都可能提前唤醒。如果需要真实时间速率限制,必须依据实际写入记录和时间窗口计算。

不要在所有成功扩容后统一冻结 60 秒,这会使持续上升的负载无法及时补容量。扩容与缩容应分别配置。

6.2 重启后不能丢掉安全记忆

如果缩容稳定窗口只存在内存中,新 leader 没有历史,可能立即缩容。可选择:

  1. 将有界、分桶的建议历史持久化到 status 或专门状态对象。
  2. 不保存全部历史,但在每次启动、目标 UID 改变或策略关键参数变化后,重新等待完整窗口再允许缩容。

第二种更容易做对,但频繁重启会延迟回收资源。第一种需要限制历史长度和写入频率;不能每次事件都向 status 追加记录。

实验初版可以采用保守的第二种方式。生产版本还需要处理"scale 写入成功、status 写入失败"的窗口,不能依赖 status 和 scale 具有事务性。

七、Reconcile:从读取到执行的可靠路径

以下是架构伪代码,辅助说明调用顺序;其中的评估、状态更新和时间窗口方法需要在项目中实现。

go 复制代码
func Reconcile(ctx context.Context, key NamespacedName) (Result, error) {
    cr, err := readAutoScaler(ctx, key)
    if IsNotFound(err) { return Done(), nil }
    if err != nil { return Done(), err }
    if IsDeleting(cr) { return Done(), nil }

    if err := validatePolicy(cr.Spec); err != nil {
        return persistInvalidCondition(ctx, cr, err)
    }
    target, err := readTarget(ctx, cr)
    if err != nil { return classifyTargetFailure(ctx, cr, err) }
    if err := verifyManagementContract(ctx, cr, target); err != nil {
        return persistConflictCondition(ctx, cr, err)
    }
    if cr.Spec.Paused { return persistPaused(ctx, cr, target) }

    samples := fetchMetricsWithDeadline(ctx, cr)
    decision := evaluatePolicy(cr, target, samples, clock.Now())
    if !cr.Spec.DryRun && decision.NeedsWrite {
        if err := applyScaleWithPrecondition(ctx, cr, target, decision); err != nil {
            return Done(), err
        }
    }
    if err := patchStatusIfChanged(ctx, cr, target, decision); err != nil {
        return Done(), err
    }
    return After(nextEvaluationDelay(cr, decision)), nil
}

7.1 错误应该分类处理

情况 处理方式
CR 已删除 正常结束
spec 无效 写 InvalidSpec,等待修复;不高速重试
Deployment 不存在 写 TargetNotFound,保留周期重试或等待映射事件
403 Forbidden 权限告警,按受控周期重试
409 Conflict 重新读取,并重新检查决策前提
Prometheus 失败 按降级策略保持或扩容,下一周期再评估
API 暂时不可用 返回错误,交给队列退避

Kubernetes API 的权限错误会返回 Forbidden;所谓"静默失败"通常是应用吞掉了错误,例如 _ = r.Status().Update(...)。状态写入失败必须被记录和传播。

7.2 通过 scale 子资源缩小修改面

使用 client-go 的 typed client 访问 Deployment 的 /scale,避免更新整个 Deployment:

go 复制代码
// 片段:kube 为 kubernetes.Interface,key 为 types.NamespacedName。
// expectedUID 来自本轮读取的目标 Deployment。
// expectedReplicas 是策略评估时看到的期望副本数。
s, err := kube.AppsV1().Deployments(key.Namespace).
    GetScale(ctx, key.Name, metav1.GetOptions{})
if err != nil {
    return err
}
if s.UID != expectedUID || s.Spec.Replicas != expectedReplicas {
    return fmt.Errorf("target changed; evaluate again")
}
if s.Spec.Replicas != desired {
    s.Spec.Replicas = desired
    _, err = kube.AppsV1().Deployments(key.Namespace).
        UpdateScale(ctx, key.Name, s, metav1.UpdateOptions{})
    if err != nil {
        return err
    }
}

保留 GetScale 返回的 resourceVersion,让 API Server 拒绝冲突写入。冲突后重新进入评估,不能无限重试旧的 desired。

目标 UID 用来区分"同名 Deployment 重建"。看到 UID 变化时,应重置或重新验证绑定和稳定窗口,不能沿用旧工作负载的控制历史。

7.3 状态只在有意义的变化时写入

go 复制代码
before := scaler.DeepCopy()
// 更新字段;用 meta.SetStatusCondition 维护标准 Conditions。
if !apiequality.Semantic.DeepEqual(before.Status, scaler.Status) {
    err := r.Status().Patch(ctx, scaler,
        client.MergeFromWithOptions(before, client.MergeFromWithOptimisticLock{}))
    if err != nil {
        return ctrl.Result{}, err
    }
}

这是嵌入 Reconcile 的片段,所需 import 分别来自 apimachinery 的 api/equality 和 controller-runtime 的 client。

不要每轮修改 LastTransitionTime;它表示条件状态转换时间。也不要把高频采样时间写入 status,导致每次轮询都变成 API 写操作。采样新鲜度优先放到指标系统;若业务必须持久化,应限频。

status 子资源不会自动阻止 status 更新产生 watch 事件。避免风暴需要:差异写入、合理 predicate 和明确的轮询路径共同配合。

7.4 Finalizer 按清理责任决定

本例删除 CR 后只需停止管理,不需要删除外部资源,因此基础方案无需 Finalizer。若引入持久化的目标绑定,则解绑本身就是需要设计的清理责任,才考虑 Finalizer。

清理必须幂等,目标已不存在时也可完成;还要有外部系统长期不可达和控制器永久卸载时的人工处理手册。Finalizer 是删除屏障,不是每个 Operator 的必选模板。4

八、监听和缓存:既要能收到变化,也要控制成本

8.1 引用映射代替误用 Owns

为 AutoScaler 建立字段索引:索引值使用 targetRef.name;namespace 由 List 的 InNamespace 限制。Deployment 变化时查找引用它的 CR,再入队。

go 复制代码
// 示意:索引注册和 map 函数需按项目版本补齐。
ctrl.NewControllerManagedBy(mgr).
    For(&autoscalev1alpha1.AutoScaler{}, builder.WithPredicates(crPredicate)).
    Watches(&appsv1.Deployment{},
        handler.EnqueueRequestsFromMapFunc(mapDeploymentToScalers)).
    Complete(reconciler)

CR 的更新 predicate 可以关注 generation、暂停相关元数据以及删除状态。纯 GenerationChangedPredicate 不覆盖所有 metadata 更新;若使用 Finalizer,需要额外允许 deletionTimestamp 变化。

外部指标不会产生 Kubernetes watch 事件,所以每次有效处理仍应安排 RequeueAfter。定时重入也不能被 observedGeneration 判断提前跳过。

映射函数发生 List 错误时要记录日志和指标;周期重入作为兜底,但不能把映射失败长期隐藏。

8.2 缓存限制必须和可见性契约一致

默认缓存范围可能覆盖整个集群中被监听的资源类型;还可能因为读取新类型而创建 informer。大型集群上线前应显式规划 namespace、对象类型和 selector。3

可选做法:

  • 只 watch AutoScaler 和必要的 Deployment,不为统计副本而缓存所有 Pod。
  • 使用 DefaultNamespaces 限制受管 namespace。
  • 支持时启用 ReaderFailOnMissingInformer,让意外读取尽早失败。
  • 只有少量直读需要时使用 APIReader,同时控制请求频率。
  • 不缓存不必要的 Secret 内容,凭证读取路径需单独评估。

标签选择器存在取舍:如果缓存只包含某标签对象,未打标签的真实对象可能对缓存客户端表现为 NotFound。接管时应有明确标记流程,不能因此误认为目标不存在。

ByObject 中放一个空的 ConfigMap 配置不会自动变成 metadata-only。元数据监听必须使用对应的元数据对象和监听 API;如果随后再以完整类型读取,又可能建立完整对象缓存。

九、选主、权限和健康检查

9.1 两副本运行,仍需要完整的选主权限

除 CR 与 Deployment 的权限外,选主还需要访问控制器 namespace 中的 Lease。漏掉这组权限,Pod 可能运行着,却无法获得领导权。

建议把权限拆为:

范围 资源 典型权限
受管 namespace autoscalers get/list/watch
受管 namespace autoscalers/status get/update/patch
受管 namespace deployments get/list/watch
受管 namespace deployments/scale get/update
控制器 namespace coordination.k8s.io/leases get/list/watch/create/update/patch
写事件的 namespace core/events create/patch

是否需要 CR 主资源 patch、finalizers 权限,取决于是否确实执行相应更新。控制器不创建 CR,就不必授予 CR 的 create/delete。

跨 namespace 管理可以用 ClusterRole 配合各 namespace 的 RoleBinding,也可以部署 namespace 隔离的控制器。限制缓存不是权限隔离,二者需要同时设计。

9.2 探针分别回答什么

  • /healthz:进程是否存活,不应因为 Prometheus 短暂故障而反复重启。
  • /readyz:本实例是否完成需要的初始化;不能直接等价为"已成为 leader"。

healthz.Ping 只返回进程级成功,不能证明缓存同步、选主、指标读取和业务控制链路正常。初始化就绪状态应由对应生命周期信号维护;业务管理能力通过 CR Conditions 和监控观测。

备用实例不承担 Reconcile 也可以 Ready。不要让等待所有副本 Ready 的部署流程,被"只有 leader 才 Ready"的定义卡住。

十、Helm 与 CRD 的升级要分开设计

建议目录如下,各清单必须在项目中实际创建后再运行部署命令:

text 复制代码
api/v1alpha1/                  # API 类型、校验和生成代码
internal/controller/         # 收敛与监听映射
internal/scaling/            # 纯策略及测试
internal/metrics/            # Prometheus 接入
config/crd/bases/            # CRD 源产物
config/samples/              # 最小工作负载与 CR
charts/autoscale-operator/   # Deployment、RBAC、ServiceAccount

新项目可用 Kubebuilder 生成骨架:

bash 复制代码
mkdir autoscale-operator
cd autoscale-operator
kubebuilder init --domain example.com --repo example.com/platform/autoscale-operator
kubebuilder create api --group autoscale --version v1alpha1 --kind AutoScaler
make generate manifests

10.1 把版本组合记录下来

保留 Kubebuilder 版本、Go 版本、controller-runtime/client-go/apimachinery 版本、controller-gen 版本、Kind node 镜像 digest。k8s.io/* 依赖应使用相容版本组合,不要各自追逐 latest。

提交 go.mod、go.sum、工具版本和生成清单。本文不宣称任意版本都能复制编译;Manager Options、typed queue、指标安全端点等 API 应对照锁定版本文档。

10.2 CRD 不会随着 Helm 自动完成迁移

Helm crds/ 中的 CRD 不按普通模板渲染;已有 CRD 的升级、删除不由常规 Helm 生命周期自动处理。5

更可控的流程是:

  1. CI 检查 CRD diff 和现存 CR 的兼容性。
  2. 平台管理员应用向后兼容的 CRD 变化。
  3. 升级控制器并验证新旧 CR。
  4. 确需多版本时,再设计 conversion webhook 与存储版本迁移。

回滚二进制不意味着 CRD 和存储对象自动回滚。删除 CRD 会影响对应自定义资源,不应作为卸载控制器的常规步骤。

Deployment 至少配置非 root、安全上下文、requests/limits、探针、优雅退出和受控指标端点。Helm 中声明的每个 values 必须确实连接到模板,不能保留无效果的 prometheus.enabled 开关。

十一、Kind 验收:让实验能够证伪设计

以下命令为项目实现后的验收模板,默认生成的控制器位于 autoscale-operator-system,Deployment 名为 autoscale-operator-controller-manager;若脚手架实际命名不同,应替换为渲染后的名称。本文没有执行该集群实验。

11.1 先验证依赖与清单

bash 复制代码
go test ./...
make generate manifests
helm lint ./charts/autoscale-operator
helm template autoscale-operator ./charts/autoscale-operator > /tmp/autoscale-rendered.yaml
kubectl apply --dry-run=server -f /tmp/autoscale-rendered.yaml

server dry-run 要求可访问的集群,且应先安装相关 CRD。上述命令是检查步骤,不代表文章已附带 Chart 和全部源文件。

11.2 再构建和部署

bash 复制代码
kind create cluster --name operator-dev
make docker-build IMG=autoscale-operator:v0.2.0
kind load docker-image autoscale-operator:v0.2.0 --name operator-dev
make install
make deploy IMG=autoscale-operator:v0.2.0
kubectl -n autoscale-operator-system rollout status deployment/autoscale-operator-controller-manager

本地镜像使用 IfNotPresent 或 Never,避免仍向远端拉取。选定一个部署路径完成实验,不要同时用 Kustomize 和 Helm 安装两套控制器。

11.3 用可控指标源建立第一条闭环

普通 nginx 不会自动提供前文的 http_requests_total。因此第一轮测试使用显式的测试 Prometheus fixture:提供 /api/v1/query,严格返回一条 vector,可以切换数值、空值、NaN 和失败响应。

json 复制代码
{
  "status": "success",
  "data": {
    "resultType": "vector",
    "result": [{"metric": {}, "value": [1792116000, "900"]}]
  }
}

时间戳必须在运行时生成;上面只示意格式。fixture 仅用于测试,通过测试配置注入,不允许作为生产 CR 的任意 URL。

实验条件:min=2、max=20、每副本目标=100、初始副本=2、dryRun=false。稳定窗口和步长按策略启用。

输入或动作 应验证的事实
总量 900 原始推荐为 9,按扩容步长逐步收敛
总量 10000 原始需求超过上限,最终不超过 20
总量 0 等待缩容窗口,再按步长和间隔回收至 2
空 vector、NaN、超时 不被解释为零负载,不误缩容
一项失败,另一项建议扩容 仍允许受控扩容,并暴露降级状态
一项失败,另一项建议缩容 保持当前副本
dryRun=true 更新建议,不写 scale
paused=true 不执行自动副本调整
同名目标删除重建 检测新 UID,重新验证管理关系

之后再接真实 Prometheus 和压测流量,验证采集语义、单副本容量、就绪耗时和业务 SLO。

11.4 重启实验要区分三层责任

操作 恢复责任方 验收重点
删除业务 Pod Deployment/ReplicaSet 恢复期望数量;不冒充 Operator 能力
删除一个控制器 Pod Deployment + 选主机制 管理恢复、接管耗时、安全状态恢复
指标后端故障 扩缩容策略 冻结缩容、告警、恢复后继续评估
bash 复制代码
kubectl -n autoscale-operator-system get pods
# 从上一步选择一个控制器 Pod,替换占位名称后执行。
kubectl -n autoscale-operator-system delete pod <controller-pod-name>
kubectl -n production get deployment order-service -w

重启前先制造低负载,再检查新 leader 是否遵守缩容保护。只观察新 Pod Running,不能证明控制器状态恢复正确。

11.5 健康端点访问正确的端口

bash 复制代码
kubectl -n autoscale-operator-system port-forward deployment/autoscale-operator-controller-manager 8081:8081

在另一个终端执行:

bash 复制代码
curl --fail http://127.0.0.1:8081/healthz
curl --fail http://127.0.0.1:8081/readyz

不要假设 metrics Service 暴露了健康检查端口;Service 的实际 ports 和 targetPort 才是依据。

十二、测试重点是故障窗口,而不是代码行数

策略单元测试至少覆盖:总量 900/100=9、max 钳制、零值、非法阈值、NaN/Inf、多指标部分失败、当前副本在边界外的故障策略。

时间相关测试注入 Clock,不能依靠真实 sleep。稳定窗口、缩容最小间隔、重启保护和策略修改都应有确定的时间序列。

控制器集成测试建议使用 envtest 验证 status 子资源、resourceVersion 冲突、字段索引和 CRD 校验。fake client 适合窄范围测试,但不能替代 API Server 的完整行为。

尤其验证两种非事务窗口:

  1. scale 更新成功,status 更新失败:下一轮应从事实恢复,不重复放大扩容。
  2. 指标评估成功,写 scale 前目标变化:拒绝旧决策,重新评估。

Kind 用于真实控制平面与 Pod 行为验证;它不能代表生产节点供给、镜像仓库性能和 Java 应用启动时间。

十三、生产监控要看"决策是否生效"

观测项 需要回答的问题
reconcile 耗时、错误率、工作队列深度 控制器是否积压或反复失败
指标查询耗时、错误、采集新鲜度 控制依据是否可信
recommended/desired/applied/ready 卡在建议、写入还是业务就绪阶段
scale 请求冲突与 Forbidden 是否有多写入者或权限缺失
被边界/稳定窗口限制的次数 策略是否过于保守或容量不足
接管耗时、缩容保护状态 重启是否影响安全性
API 请求量、内存、GC 是否出现状态风暴或缓存膨胀

日志记录 namespace/name、CR UID、generation、目标 UID、指标摘要、原始推荐、限制后的 desired、实际写入结果和拒绝原因。不要记录鉴权凭证,也不要把完整查询表达式或错误文本作为高基数指标标签。

容量规划可从以下量级估算开始:

QPS_{queries}\\approx\\frac{受管CR数量\\times每CR指标数量}{轮询周期}

例如 1000 个 CR、每个 2 项指标、15 秒周期,平均约 133 次查询/秒,尚未计入重试和峰值。选主切换后的集中入队可能进一步放大瞬时压力。

使用查询超时、有限并发、查询去重或批量规则、带抖动的初始调度控制压力。MaxConcurrentReconciles 提高前,先计算 API Server 与 Prometheus 的预算。

十四、上线顺序:先证明建议,再交出写权限

建议按以下阶段推进,每阶段都有明确退出条件:

  1. 离线回放:历史负载输入策略,检查推荐数、抖动和异常处理。
  2. 影子评估:dry-run 输出建议,与现有扩缩容和业务延迟对照。
  3. 小范围接管:仅管理明确移交控制权的低风险服务,限制最大副本和变化速率。
  4. 故障演练:指标失联、API 冲突、选主切换和目标重建均符合预期。
  5. 扩大范围:保留暂停入口、审计记录、人工接管和恢复步骤。

人工接管的顺序也要明确:先暂停控制器策略,再调整副本,最后决定何时恢复自动管理。否则人工修改会被下一轮正常 Reconcile 覆盖。

自动扩缩容的工程价值不在于一次把 replicas 从 3 写成 12,而在于负载变化、指标失真、API 冲突和控制器重启时,仍能解释每次决策、限制错误影响并恢复持续管理。做到这些,Operator 才从一段定时脚本成长为可以托付运维规则的系统。


参考资料与核验边界

以下为实现时应对照的官方资料。网站内容会随版本变化,生产项目应同时核对锁定依赖和目标 Kubernetes 版本。

  1. Kubernetes:Horizontal Pod Autoscaling --- 指标类型、算法与行为配置。
  2. Kubebuilder:Watching Resources --- 所有权监听和引用映射。
  3. controller-runtime:cache API 与 Client API --- 缓存范围、缺失 informer、客户端读写和 Patch。
  4. Kubebuilder:Using Finalizers --- 外部清理与幂等删除。
  5. Helm:Custom Resource Definitions --- CRD 安装、升级和删除边界。
  6. Kubernetes:API Concepts --- resourceVersion 和并发控制。
  7. Prometheus:HTTP API --- 查询响应格式和错误语义。
相关推荐
GIS数据转换器3 小时前
地下空间综合管廊运营管理平台
大数据·开发语言·人工智能·经验分享·perl
草莓熊Lotso3 小时前
【C++ 基于微服务的即时通讯系统】etcd + brpc 从安装到服务注册发现全链路实战
网络·数据库·c++·redis·微服务·etcd
Wang's Blog8 小时前
Java 项目实战: 交友项目:登录链路回顾与今日任务地图
java·开发语言·状态模式·交友
砚底藏山河9 小时前
python量化:行情数据回溯
java·开发语言·python·金融·maven
今年下半年9 小时前
AI智能体项目实战记录(02):初始化 Python 项目
开发语言·人工智能·python·fastapi
Williamtym9 小时前
PickGo Lab实践:用浏览器、Python和MCP串起机器人搬运仿真
开发语言·python·机器人
DongQiShanRen9 小时前
六根硬件映射(四):归属澄清、两处不一致与卷十一总表
开发语言·人工智能·算法·rust·哈希算法·集成学习
CS创新实验室9 小时前
Python 对象“死“了之后去了哪?——引用计数与分代垃圾回收
开发语言·jvm·python
quantdash_cc9 小时前
Pandas 处理分钟级股票行情:从原始数据清洗到 K 线聚合
开发语言·python·数据分析·pandas·量化交易·股票数据·quantdash