Helm Chart模板开发与Release管理
引言
先说一个我亲身经历过的生产事故。
2023年,我负责的一个微服务集群要从中东机房扩展到欧洲机房。运维同学兴高采烈地跑来说:“Chart 都写好了,改个 values.yaml 就能上。” 结果上线当天,Pod 全部 CrashLoopBackOff。排查了三个小时才发现——中东环境的 values-prod.yaml 里硬编码了一个 imagePullSecrets 名字,而欧洲集群里这个 Secret 根本不存在;同时某个模板文件里用了 {{ .Values.replicaCount | default 3 }},但另一个模板文件里直接写了 {{ .Values.replicaCount }},导致某几个 Deployment 渲染出了空字符串,API Server 直接拒绝。
这就是 Helm 用起来“爽”,但用不好就“炸”的典型场景。很多人把 Helm 当成“带变量的 kubectl apply”,写 Chart 的时候像写 Shell 脚本一样随意,最后 Chart 变成了一个没人敢碰的黑盒。
这篇文章我们不谈“Helm 是什么”,而是从模板引擎的实现原理、Release 的状态机管理、到生产级 Chart 的组织方式,一层层拆开看。读完之后,你应该能写出那种“换一个集群、换一个环境,闭着眼睛也能上”的 Chart。
核心概念:Helm 到底在做什么
一个生活化的类比:装修公司的“施工图纸 + 现场验收单”
想象你要装修三套房子(三个 K8s 集群):北京的房子、上海的房子、深圳的房子。这三套房子的户型不同(集群配置不同),但你想装的风格是一样的(同一套微服务)。
- Chart = 装修图纸 + 材料清单。它描述了“这套房子该怎么装”,但不包含“这次到底用多少块瓷砖”。
- values.yaml = 这次的施工参数。比如“北京用大理石地砖,上海用木地板”。
- Template = 图纸上的可替换区域。图纸上画的是“{{ 地砖材质 }}”,渲染的时候才填入具体材料。
- Release = 一次具体的施工记录。北京那套房子用的是 Release
my-app-bj-v1,上海那套是my-app-sh-v1。每个 Release 都有独立的状态、历史版本、回滚记录。
关键点在于:Helm 不是“模板 + kubectl apply”,而是“模板渲染 + 资源差异计算 + 状态持久化”的三位一体。很多人只看到了第一层。
技术定义
Helm 3 的架构里,核心组件有三个:
- Chart:一个目录结构,包含
Chart.yaml(元数据)、values.yaml(默认值)、templates/(Go Template 文件)、charts/(子 Chart 依赖)。 - Release:一次安装的实例。存储在 K8s 的 Secret 里(Helm 3 默认用 Secret,不再是 Helm 2 的 ConfigMap)。
- Repository:Chart 的存储仓库,可以是 HTTP 服务器、OCI Registry(Helm 3.8+ 支持)。
这张图里最关键的是 F 和 G 两步。Helm 3 不像 Helm 2 那样用 Tiller 做“全量 apply”,而是做了 三路合并(Three-way Strategic Merge Patch)。这也是很多“升级后资源被意外覆盖”问题的根源,后面会详细讲。
源码/原理深度分析
1. Go Template 的渲染机制:为什么 {{ .Values.x }} 会渲染成空字符串
Helm 的模板引擎基于 Go 标准库的 text/template,加上 Sprig 函数库。当你写 {{ .Values.replicaCount }} 时,如果 values.yaml 里没有这个字段,Go Template 不会报错,而是渲染成 或者空字符串(取决于上下文)。
源码层面,Helm 在 pkg/engine/engine.go 里做了几件事:
// 简化后的核心逻辑
func (e Engine) Render(chrt *chart.Chart, values chartutil.Values) (map[string]string, error) {
// 1. 构建模板上下文
tmap := make(map[string]renderable)
// ... 把 Chart 的 templates/ 目录下所有文件读进来
// 2. 合并 values(默认值 + 用户覆盖值)
vals, err := chartutil.CoalesceValues(chrt, values)
// 3. 渲染每个模板文件
for _, filename := range tmap {
// 关键:这里用的是 text/template 的 Execute
if err := t.Execute(&buf, vals); err != nil {
return nil, err
}
}
}CoalesceValues 是重点。它做的是深度合并:用户传入的 values 会覆盖 chart 默认的 values,但如果用户没传某个字段,就用默认值。问题在于——如果默认值里也没有这个字段,那就是真的没有。
Go Template 的行为是:访问一个不存在的 map key,返回零值。对于 string 类型,零值是 "";对于 int 类型,零值是 0。所以 {{ .Values.replicaCount }} 会渲染成空字符串,最终生成的 YAML 里 replicas: 后面什么都没有,K8s 会报错。
解决方案:永远用 default 函数兜底,或者用 required 强制报错。
# 好的写法
replicas: {{ .Values.replicaCount | default 3 }}
# 更好的写法:如果这个值必须由用户提供,用 required
replicas: {{ required "replicaCount is required!" .Values.replicaCount }}2. Release 状态机:Helm 3 的 Secret 存储与三路合并
Helm 3 把 Release 信息存在 sh.helm.release.v1. 这个 Secret 里。这个 Secret 的内容是 gzip + base64 编码的 protobuf。你可以用下面的命令看:
kubectl get secret sh.helm.release.v1.my-app.v1 -o jsonpath='{.data.release}' | base64 -d | base64 -d | gzip -d | protoc --decode_raw每次 helm upgrade,Helm 会:
- 从 Secret 里读出上一个版本的 Release 信息,包括上一次渲染出的 Manifest。
- 用新的 values 渲染出新的 Manifest。
- 从 K8s API Server 拉取当前集群里实际存在的资源状态。
- 做三路合并:
old manifest(上次 Helm 渲染的)
new manifest(这次 Helm 渲染的)
live state(集群里实际跑的)
三路合并的规则是:如果某个字段在 old manifest 里存在、在 live state 里被修改过、在 new manifest 里不存在,那么保留 live state 的值。这就是为什么你手动 kubectl edit 改了一个字段,下次 helm upgrade 不会把它覆盖掉——Helm 认为这是“集群侧的合法修改”。
但反过来,如果你在 values.yaml 里删掉了一个字段,而 old manifest 里有、live state 里没改过,那么 Helm 会把这个字段删掉。这就是“为什么升级后某些配置消失了”的根源。
3. Template 的作用域与命名模板
Helm 的模板里,. 代表当前作用域。在 range 循环里,. 会变成当前元素。很多人写嵌套循环时被这个坑过:
# 错误写法:内层 . 变成了 env 元素,访问不到 .Values
{{- range .Values.env }}
- name: {{ .name }}
value: {{ .Values.global.someValue }} # 这里 . 已经不是根作用域了!
{{- end }}
# 正确写法:用 $ 引用根作用域
{{- range .Values.env }}
- name: {{ .name }}
value: {{ $.Values.global.someValue }}
{{- end }}$ 是 Helm 在渲染时注入的根上下文变量。这个设计来自 Go Template 本身,但在 Helm 里特别容易踩坑。
另外,define 和 template 是组织复用代码的关键:
# templates/_helpers.tpl
{{- define "my-app.fullname" -}}
{{- printf "%s-%s" .Release.Name .Chart.Name | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- define "my-app.labels" -}}
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end -}}注意 _helpers.tpl 以下划线开头,Helm 不会把它当成 K8s 资源渲染,只用来定义命名模板。
实战代码
示例 1:一个生产级的 Deployment 模板
这个模板涵盖了镜像、资源限制、健康检查、环境变量、配置挂载等常见需求,并且所有字段都有兜底。
# templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "my-app.fullname" . }}
labels:
{{- include "my-app.labels" . | nindent 4 }}
spec:
{{- /* 副本数:优先用 values,否则默认 3,且禁止为 0(除非显式允许) */}}
replicas: {{ .Values.replicaCount | default 3 }}
selector:
matchLabels:
{{- include "my-app.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "my-app.selectorLabels" . | nindent 8 }}
{{- /* 只有当 podAnnotations 非空时才渲染,避免生成空 map */}}
{{- with .Values.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
{{- /* 镜像拉取密钥:如果没配置就完全不渲染这个字段,避免引用不存在的 Secret */}}
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy | default "IfNotPresent" }}
ports:
- name: http
containerPort: {{ .Values.service.targetPort | default 8080 }}
protocol: TCP
{{- /* 环境变量:支持直接值和 ConfigMap/Secret 引用两种形式 */}}
{{- with .Values.env }}
env:
{{- range . }}
- name: {{ .name }}
{{- if .value }}
value: {{ .value | quote }}
{{- else if .valueFrom }}
valueFrom:
{{- toYaml .valueFrom | nindent 16 }}
{{- end }}
{{- end }}
{{- end }}
{{- /* 资源限制:如果没有配置,给一个保守的默认值 */}}
resources:
{{- toYaml (.Values.resources | default (dict
"limits" (dict "cpu" "500m" "memory" "512Mi")
"requests" (dict "cpu" "100m" "memory" "128Mi")
)) | nindent 12 }}
{{- /* 健康检查:liveness 和 readiness 分开配置 */}}
{{- with .Values.livenessProbe }}
livenessProbe:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.readinessProbe }}
readinessProbe:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- /* 挂载配置文件 */}}
{{- with .Values.volumeMounts }}
volumeMounts:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.volumes }}
volumes:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}关键设计点:
- 所有可选字段都用
{{- with ... }}包裹。如果值为空,整个字段不渲染,而不是渲染成field: null。
include配合nindent是 Helm 的标准缩进模式。nindent会在前面加一个换行,并给每一行加指定数量的空格。
resources用了dict构造默认值,避免用户不配置时 Pod 没有资源限制(这在生产环境是灾难)。
示例 2:多环境 Values 管理与模板校验
生产环境通常需要 dev/staging/prod 三套 values。推荐的组织方式:
my-app/
├── Chart.yaml
├── values.yaml # 基础默认值
├── values-dev.yaml # dev 覆盖
├── values-staging.yaml # staging 覆盖
├── values-prod.yaml # prod 覆盖
└── templates/
├── _helpers.tpl
├── deployment.yaml
├── service.yaml
└── configmap.yamlvalues.yaml 里定义所有字段的默认值和结构:
# values.yaml
replicaCount: 3
image:
repository: my-registry/my-app
tag: "" # 空字符串,实际用 Chart.AppVersion
pullPolicy: IfNotPresent
imagePullSecrets: []
service:
type: ClusterIP
port: 80
targetPort: 8080
resources:
limits:
cpu: 500m
memory: 512Mi
requests:
cpu: 100m
memory: 128Mi
livenessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 10
periodSeconds: 10
readinessProbe:
httpGet:
path: /ready
port: http
initialDelaySeconds: 5
periodSeconds: 5
env: []
# 示例:
# env:
# - name: LOG_LEVEL
# value: info
# - name: DB_PASSWORD
# valueFrom:
# secretKeyRef:
# name: db-secret
# key: passwordvalues-prod.yaml 只覆盖需要变的字段:
# values-prod.yaml
replicaCount: 10
image:
tag: "v2.3.1"
imagePullSecrets:
- name: prod-registry-secret
resources:
limits:
cpu: 2000m
memory: 2Gi
requests:
cpu: 500m
memory: 512Mi
env:
- name: LOG_LEVEL
value: warn
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: prod-db-secret
key: password部署命令:
helm upgrade --install my-app-prod ./my-app \
-f values-prod.yaml \
--namespace prod \
--create-namespace \
--atomic \
--timeout 10m注意 --atomic:它会在升级失败时自动回滚。生产环境强烈建议加上。
示例 3:用 Helm Hook 做数据库迁移
Helm Hook 是 Release 生命周期中的“钩子”,可以在资源安装前/后、升级前/后执行。最典型的用法是数据库迁移。
# templates/migration-job.yaml
apiVersion: batch/v1
kind: Job
metadata:
name: {{ include "my-app.fullname" . }}-migration
labels:
{{- include "my-app.labels" . | nindent 4 }}
annotations:
# 关键:定义 Hook 类型和删除策略
"helm.sh/hook": pre-upgrade
"helm.sh/hook-weight": "-5" # 权重,越小越先执行
"helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
spec:
backoffLimit: 3
template:
metadata:
labels:
{{- include "my-app.selectorLabels" . | nindent 8 }}
spec:
restartPolicy: Never
containers:
- name: migration
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
command: ["/bin/sh", "-c"]
args:
- |
echo "Running database migration..."
./migrate -path /migrations -database "$DATABASE_URL" up
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: {{ .Values.database.secretName }}
key: urlHook 的生命周期:
Hook 的坑:
- Hook 资源不会被
helm uninstall自动删除,除非你配置了hook-delete-policy。
- Hook 的权重(weight)决定执行顺序,但同权重的 Hook 是并行执行的,不要依赖顺序。
pre-upgradeHook 失败会导致整个升级失败并回滚,但post-upgradeHook 失败不会回滚(因为资源已经应用了)。
方案对比:Helm vs Kustomize vs Operator
| 维度 | Helm | Kustomize | Operator |
|---|---|---|---|
| 核心思想 | 模板 + 值注入 | 叠加 + 补丁 | 自定义控制器 |
| 学习曲线 | 中(Go Template 有坑) | 低(纯 YAML) | 高(要写 Go) |
| 状态管理 | 有 Release 概念,支持回滚 | 无状态,靠 Git | 有 CRD 状态,支持 reconcile |
| 多环境支持 | values 文件覆盖 | overlay 目录 | CR 实例 |
| 复杂逻辑 | 支持(但容易写乱) | 不支持 | 完全支持 |
| 适用场景 | 通用应用分发 | K8s 原生配置管理 | 有状态服务、复杂运维逻辑 |
我的建议:
- 如果你的应用是无状态微服务,需要多环境部署 + 版本管理 + 回滚,选 Helm。
- 如果你只是想把同一套 YAML 做少量差异化,且团队不想学模板语法,选 Kustomize。
- 如果你要管理数据库、消息队列这类有状态服务,且需要自动化运维逻辑(如自动扩缩容、故障自愈),选 Operator。
混合方案:Helm 渲染出基础 Manifest,再用 Kustomize 做最后的集群特定补丁。这是很多大厂的做法,但会增加 CI/CD 的复杂度,小团队慎用。
最佳实践与避坑指南
1. 永远不要在模板里硬编码环境相关的东西
错误做法:
image: my-registry.com/my-app:v1.2.3 # 硬编码正确做法:
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"2. 用 helm template 做本地校验
# 渲染出最终 YAML,肉眼检查
helm template my-app ./my-app -f values-prod.yaml > rendered.yaml
# 用 kubeconform 做 schema 校验
helm template my-app ./my-app -f values-prod.yaml | kubeconform -strict -summary3. 用 helm lint 和 helm unittest 做 CI 检查
helm lint ./my-app
# 配合 helm-unittest 插件做单元测试
helm unittest ./my-app4. 避免在模板里做复杂逻辑
Go Template 不是编程语言,写复杂逻辑会变成“模板里的意大利面条”。如果逻辑太复杂,考虑:
- 拆成多个子 Chart。
- 用
tpl函数在 values 里做简单模板。
- 实在不行,用 Operator 替代。
5. 注意 helm upgrade 的 --reuse-values 陷阱
--reuse-values 会复用上一次的 values,而不是重新合并 values.yaml。这会导致你改了 values.yaml 但升级后没生效。建议永远不要用这个参数,而是显式传 -f。
6. Release 名称不要用随机字符串
Release 名称会成为资源名称的一部分。如果你的 Release 叫 my-app-20240101-abc123,那所有资源名都会带这个后缀,非常难管理。用固定的、有意义的名字。
7. 处理 CRD 的升级
Helm 对 CRD 的处理很特殊:crds/ 目录下的 CRD 只在 helm install 时安装,helm upgrade 时不会更新。如果需要更新 CRD,要么手动 kubectl apply,要么把 CRD 放在 templates/ 里(但这样 helm uninstall 会删掉 CRD,可能导致数据丢失)。
8. 监控 Release 状态
# 查看 Release 历史
helm history my-app -n prod
# 查看某个 Release 的 Manifest
helm get manifest my-app -n prod
# 查看某个 Release 的 values
helm get values my-app -n prod
# 回滚到指定版本
helm rollback my-app 3 -n prod总结
Helm 的本质是模板引擎 + Release 状态机 + 三路合并。很多人只用了第一层,所以觉得“Helm 不就是带变量的 kubectl 吗”。但真正让 Helm 在生产环境可靠运行的,是后两层。
回顾几个要点:
- 模板渲染:Go Template 的零值行为是最大的坑,永远用
default或required兜底。 - Release 管理:Helm 3 把状态存在 Secret 里,支持回滚,但三路合并的逻辑需要理解清楚,否则会遇到“配置莫名消失”的问题。
- 多环境管理:用基础
values.yaml+ 环境覆盖文件,配合--atomic保证升级安全。 - Hook 机制:适合做数据库迁移、缓存预热这类生命周期任务,但要注意删除策略和权重。
延伸思考:Helm 正在向 OCI Registry 迁移,未来 Chart 会像 Docker 镜像一样用 oci:// 协议分发。同时,Helm 和 Operator 的边界也在模糊——有些团队开始用 Helm 部署 Operator,再用 Operator 管理有状态服务。理解这两者的分工,是云原生架构师进阶的必修课。
最后送一句话:写 Chart 的时候,假设下一个维护它的人是一个刚入职的实习生,而且他只能看你的 values.yaml,不能看你的模板。 如果他能猜出所有配置项的含义,你的 Chart 就合格了。