从脚本到 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 本文的管理契约
- 只管理与 AutoScaler 位于同一 namespace 的
apps/v1 Deployment。 - 每个 Deployment 最多绑定一个启用中的 AutoScaler。
- 接管前检查已有 HPA,以及平台登记的其他副本管理者。
- GitOps 配置移交
spec.replicas的管理权,避免持续覆盖。 - 删除 AutoScaler 后停止管理,保留 Deployment 和当前副本数。
- 初版
minReplicas >= 1,不实现 scale-to-zero 的流量激活链路。 - 暂停和 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 没有历史,可能立即缩容。可选择:
- 将有界、分桶的建议历史持久化到 status 或专门状态对象。
- 不保存全部历史,但在每次启动、目标 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
更可控的流程是:
- CI 检查 CRD diff 和现存 CR 的兼容性。
- 平台管理员应用向后兼容的 CRD 变化。
- 升级控制器并验证新旧 CR。
- 确需多版本时,再设计 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 的完整行为。
尤其验证两种非事务窗口:
- scale 更新成功,status 更新失败:下一轮应从事实恢复,不重复放大扩容。
- 指标评估成功,写 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 的预算。
十四、上线顺序:先证明建议,再交出写权限
建议按以下阶段推进,每阶段都有明确退出条件:
- 离线回放:历史负载输入策略,检查推荐数、抖动和异常处理。
- 影子评估:dry-run 输出建议,与现有扩缩容和业务延迟对照。
- 小范围接管:仅管理明确移交控制权的低风险服务,限制最大副本和变化速率。
- 故障演练:指标失联、API 冲突、选主切换和目标重建均符合预期。
- 扩大范围:保留暂停入口、审计记录、人工接管和恢复步骤。
人工接管的顺序也要明确:先暂停控制器策略,再调整副本,最后决定何时恢复自动管理。否则人工修改会被下一轮正常 Reconcile 覆盖。
自动扩缩容的工程价值不在于一次把 replicas 从 3 写成 12,而在于负载变化、指标失真、API 冲突和控制器重启时,仍能解释每次决策、限制错误影响并恢复持续管理。做到这些,Operator 才从一段定时脚本成长为可以托付运维规则的系统。
参考资料与核验边界
以下为实现时应对照的官方资料。网站内容会随版本变化,生产项目应同时核对锁定依赖和目标 Kubernetes 版本。
- Kubernetes:Horizontal Pod Autoscaling --- 指标类型、算法与行为配置。
- Kubebuilder:Watching Resources --- 所有权监听和引用映射。
- controller-runtime:cache API 与 Client API --- 缓存范围、缺失 informer、客户端读写和 Patch。
- Kubebuilder:Using Finalizers --- 外部清理与幂等删除。
- Helm:Custom Resource Definitions --- CRD 安装、升级和删除边界。
- Kubernetes:API Concepts --- resourceVersion 和并发控制。
- Prometheus:HTTP API --- 查询响应格式和错误语义。