大促前手动改资源配额太蠢了,我给 Helm Chart 加了一层环境感知模板让 limits 自动适配

很多团队的 Helm Chart 里,资源配额是写死在 values.yaml 里的。一到 618、双 11 这种大促,就得提前几天手动把 limits 往上调,活动结束再调回来。这件事不仅蠢,而且危险——去年我就见过一个案例,活动结束忘了调回,一个边缘服务挂着 8C16G 的 limit 跑了三个月,账单多出两万多。

手动改配额的真正问题不是「麻烦」,是它把「容量决策」从声明式配置里剥离了。Helm 的核心价值是模板化,但大部分人只模板化了镜像 tag 和端口,资源配额还是硬编码。这篇文章就聊聊我落地的一套环境感知模板方案,让 limits 和 requests 根据环境、时间窗口、负载特征自动适配,不用再碰 values.yaml。

核心思路:把配额决策从「人」挪到「模板表达式」

先说结论:资源配额不应该是一个静态值,而应该是环境上下文和业务策略的函数。模板层要做的是把「大促需要更多资源」这个决策逻辑编码进去,而不是每次都靠人肉改数字。

具体拆开,有三个层次的适配可以叠加:

  • 按环境适配:dev / staging / prod 用不同的基线配额
  • 按场景适配:日常 vs 大促,通过一个开关或时间窗口切换
  • 按负载适配:基于 HPA 的当前副本数或流量指标动态推导单 Pod 配额

三层叠加起来,values.yaml 里只需要维护一套「日常基线」,大促期间的放大系数和切换逻辑全部在模板里完成。

环境基线:用 values 的层级结构代替复制粘贴

最基础的一层,大部分人其实已经在做了,但做得不够彻底。常见的做法是每个环境一个 values 文件,里面重复写一样的资源字段。问题在于,一旦要全局调整某个服务的基线配额,你得改 N 个文件。

更好的做法是让 values 支持按环境覆盖,模板里用一层默认值兜底:

# values.yaml
resources:
  defaults:
    requests:
      cpu: 100m
      memory: 256Mi
    limits:
      cpu: 500m
      memory: 512Mi

# values.prod.yaml
resources:
  defaults:
    requests:
      cpu: 200m
      memory: 512Mi
    limits:
      cpu: 1000m
      memory: 1Gi

模板侧用一个命名模板把默认值和覆盖值合并:

{{- define "app.resources" -}}
{{- $defaults := .Values.resources.defaults -}}
{{- $override := .Values.resources.override | default dict -}}
{{- $merged := mergeOverwrite (deepCopy $defaults) $override -}}
{{- toYaml $merged -}}
{{- end -}}

调用处直接引用:

containers:
- name: app
  resources:
    {{- include "app.resources" . | nindent 4 }}

这样 prod 的 values 文件只需要写差异部分,不需要把整个 resources 块复制一遍。Helm 的 mergeOverwrite 在 3.6+ 版本可用,如果你还在用更老的版本,得用 sprig 的 merge 函数手动处理嵌套 map。

场景切换:用 values 里的「模式开关」控制放大系数

环境基线解决的是「不同环境跑不同体量」的问题,但大促场景要解决的是「同一个环境在不同时间需要不同配额」。这个不能靠改 values 文件,因为大促开始和结束的时间点是确定的,逻辑应该提前编码好。

我的做法是在 values 里加一个模式开关和放大系数:

# values.yaml
scalingMode: normal  # normal | campaign
campaign:
  resourceMultiplier: 2.5
  startTime: "2025-06-01T00:00:00Z"
  endTime: "2025-06-20T00:00:00Z"

模板里根据当前时间或显式开关来决定是否应用放大系数。显式开关优先,时间窗口作为兜底:

{{- define "app.resourceMultiplier" -}}
{{- $mode := .Values.scalingMode -}}
{{- if eq $mode "campaign" -}}
  {{- .Values.campaign.resourceMultiplier -}}
{{- else if eq $mode "auto" -}}
  {{- $now := now | date "2006-01-02T15:04:05Z07:00" -}}
  {{- if and (ge $now .Values.campaign.startTime) (lt $now .Values.campaign.endTime) -}}
    {{- .Values.campaign.resourceMultiplier -}}
  {{- else -}}
    1
  {{- end -}}
{{- else -}}
  1
{{- end -}}
{{- end -}}

然后在校准资源时乘上这个系数。这里有个关键细节:requests 和 limits 的放大不应该用同一个系数。requests 影响调度,放大太多会导致节点资源浪费;limits 影响 QoS 和 cgroup 限制,放大太少会直接 OOMKill。我的经验是 requests 乘以 1.2-1.5,limits 乘以 2-3,具体比例取决于你的流量放大预期。

所以 values 里最好分开定义:

campaign:
  requestsMultiplier: 1.3
  limitsMultiplier: 2.5

动态推导:让 HPA 的指标反过来影响单 Pod 配额

前面两层解决的是「静态场景切换」,但实际生产里更常见的问题是:HPA 已经把副本数从 4 扩到 12 了,单 Pod 的 limit 还是按 4 副本时的流量峰值算的。这会导致两个后果:要么单 Pod 配额过小,HPA 刚加出来的 Pod 立刻被打爆;要么配额过大,12 个副本乘以过大的 limit,集群资源被挤占。

这里有一个反直觉但有效的做法:把资源配额和 HPA 的 minReplicas 关联起来。逻辑是:minReplicas 越小,说明你期望单 Pod 承载的流量占比越高,单 Pod 配额就应该越大;minReplicas 越大,单 Pod 配额可以适当降低,靠横向扩容来分摊。

模板里可以这样取:

{{- $minReplicas := .Values.autoscaling.minReplicas | default 2 -}}
{{- $baseLimit := .Values.resources.defaults.limits.cpu -}}
{{- $adjustedLimit := div (mul $baseLimit 4) $minReplicas -}}

假设基线 limit 是 1000m,minReplicas 从 2 调到 4,单 Pod limit 自动降为 500m。反之,如果一个服务没有开 HPA(minReplicas 不存在),就保持基线不调整。这个逻辑不完美,但比「HPA 扩缩容和资源配额完全脱节」要合理得多。

更进阶的做法是读取 Prometheus 的 P95 流量数据来做推导,但这需要模板里执行 HTTP 查询,Helm 模板引擎本身不支持,得靠外挂脚本或者 Argo CD 的插件机制。我目前不建议在纯 Helm 模板里做这件事,复杂度收益比太低。

把配额差异化暴露到 Grafana 面板,防止「配了没人知道」

模板自动适配之后,一个隐藏的问题是:部署的人不知道当前生效的配额是多少。大促期间 limit 到底放大到多少,不能靠 helm get values 去猜。

我的做法是让模板把最终计算出的配额输出到 ConfigMap 或 annotation 里,方便监控系统抓取:

metadata:
  annotations:
    resource-limits-cpu: {{ .Values.resources.defaults.limits.cpu | toString | quote }}
    resource-limits-memory: {{ .Values.resources.defaults.limits.memory | toString | quote }}
    scaling-mode: {{ .Values.scalingMode | quote }}
    resource-multiplier: {{ include "app.resourceMultiplier" . | toString | quote }}

这些 annotation 可以被 Prometheus 的 kube-state-metrics 或自定义 exporter 抓取,然后在 Grafana 里画出每个服务的 limit 随时间变化的曲线。大促期间如果发现某个服务的 limit 曲线没有出现预期的抬升,说明模板逻辑没有按预期触发,能在流量进来之前发现,而不是等 P99 报警了才排查。

常见问题

这套模板逻辑会让 Chart 变得很难维护吗?

会增加一定的模板复杂度,但可控。核心是把配额计算收敛到一个命名模板里,其他资源定义只是引用它。如果你的 Chart 里 resources 字段散落在十几个 Deployment 里,每个都写不同的计算逻辑,那确实会失控。所以落地时建议先做一层抽象,把所有资源配额的渲染统一到一个 helper 模板,和命名模板 app.resources 类似。

时间窗口自动切换靠谱吗?万一 Helm 渲染的时间和大促实际时间有偏差怎么办?

now 函数取的是 Helm 渲染时的系统时间。如果你用 Argo CD 部署,渲染时间就是 sync 时间。大促切换的精确性要求其实没那么高,提前一小时或推迟一小时切换通常不会造成实质影响。但要注意时区问题,now 返回的是 UTC,你的 startTime 和 endTime 也要用 UTC 写,或者统一用带时区偏移的 RFC3339 格式。

requests 和 limits 分开放大,会不会导致 QoS 降级?

Guaranteed QoS 要求 requests 和 limits 完全相等。如果你用了不同的放大系数,QoS 会降为 Burstable。这在大多数场景下是可接受的,因为 Burstable 的 Pod 在资源紧张时被驱逐的概率介于 Guaranteed 和 BestEffort 之间,对于大促期间的业务优先级来说,用 Burstable 换取更大的 limits 弹性空间是合理的取舍。如果某个服务必须保持 Guaranteed,可以在 values 里加一个开关跳过差异化放大。

模板自动调整后,还需要手动干预吗?

需要,但干预的层级变了。以前是改具体数字,现在是调整策略参数(放大系数、时间窗口、minReplicas 关联逻辑)。策略参数的调整应该是低频的、经过评审的,而不是大促前夜临时改。另外,模板无法预知所有场景,比如某个服务突然接了新的流量入口,或者某个大促活动提前了,这些异常情况仍然需要人工介入。模板解决的是「已知的周期性变化」,不是「所有的容量决策」。