Kubernetes Helm 配置:用 values 管理多环境发布
用 Helm 管理多环境发布:模板化消除重复,values 承载差异,保持 Chart 直白可维护。
手写多份几乎一样的 Kubernetes 清单是运维里最典型的重复劳动:dev、staging、prod 的 Deployment 有九成字段相同,只有副本数、镜像 tag、资源配额、域名不同,任何改动都要同步多处,漏改一处环境之间就开始漂移。这篇文章解决的是这个问题,而它背后最底层的机制只有一句话:Helm 是模板引擎,它把 Chart 里的 Go 模板文件与 values 数据在本地合并,渲染出最终的 manifest 再交给集群——环境之间的差异用数据表达,结构之间的差异用模板表达,于是多环境发布就归结为"共享一份 Chart + 分层维护 values"。
下文按因果链展开:先讲清渲染管线与 Chart 各文件的职责,再给一套完整可复现的三环境操作流程,然后是验证方法、边界与坑。原稿里"发布前必须 helm template"、values 明文凭据按事故处理、"消除重复不消除差异"等经验逐条保留并给出机制解释。
#原理层:模板与数据如何合成 manifest
#Helm 的本质:一个本地渲染管线
helm install 执行时,发生的事情可以拆成五步:
- 读取 Chart 目录,解析 Chart.yaml,拿到名称、版本、依赖关系;
- 把 values 数据装入内存中的 .Values 对象(数据来源与优先级下文细说);
- 用 Go 模板引擎逐个渲染 templates/ 下的文件,模板里的 {{ .Values.xxx }} 等指令被替换成具体值;
- 把渲染结果解析成 Kubernetes 资源清单并做校验,然后通过与 kubectl 同一套 API 提交给集群的 apiserver;
- 把"用的什么 Chart、什么 values、渲染出了什么"打包成一条 release 记录写回集群(Helm 3 默认以 Secret 形式存储)。
第 3 步纯本地、纯文本,不需要集群存在,也不需要任何权限——这正是可以无限次预览、helm template 验证手段的基础。管线里模板是"结构"、values 是"数据",两者分离,是全部多环境技巧的出发点。
#Chart 目录结构:每个文件的作用
最小可用的 Chart 结构与原稿一致:
charts/demo-api/
├── Chart.yaml # 名称、版本、依赖
├── values.yaml # 默认值
└── templates/
├── deployment.yaml
├── service.yaml
└── _helpers.tpl # 公共模板片段各文件的职责:
- Chart.yaml 声明 Chart 的身份。name 和 version 是必填项,version 是语义化版本,同一版本下内容不应变化;
- values.yaml 是模板数据的默认来源,模板里每个 {{ .Values.xxx }} 都应在这里有兜底定义,渲染时它是优先级最低的一层;
- templates/ 是渲染源。目录下每个不以 _ 开头、扩展名不是 .tpl 的文件都会被渲染成资源并提交给集群;_helpers.tpl 以下划线开头,是"只提供函数、不直接产出资源"的公共片段文件,helm lint 和 helm template 会把它的内容合并进每个模板的渲染上下文;
- .helmignore 在打包与渲染时排除文件(类似 .gitignore)。
用 helm create 生成的 Chart 是脚手架全家桶,比最小结构多出不少文件:
charts/demo-api/
├── Chart.yaml
├── charts/ # 子依赖 Chart(默认空)
├── .helmignore
├── templates/
│ ├── NOTES.txt # 安装成功后的提示文案
│ ├── _helpers.tpl
│ ├── deployment.yaml
│ ├── hpa.yaml
│ ├── ingress.yaml
│ ├── service.yaml
│ ├── serviceaccount.yaml
│ └── tests/
│ └── test-connection.yaml
└── values.yaml脚手架的意义是给出能跑的最小起点,不是让人照单全收:实战中把暂时不用的模板移出 templates/,只保留部署必需的部分。原稿就这么做:最小 Chart 只保留 Chart.yaml、values.yaml 与 deployment.yaml、service.yaml、_helpers.tpl。
#模板语法:从 values 到 YAML 的最小闭环
模板文件是 YAML 与 Go 模板指令的混合体,最小的闭环是取值:
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "demo-api.fullname" . }}
spec:
replicas: {{ .Values.replicaCount }}
template:
spec:
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
resources:
{{- toYaml .Values.resources | nindent 12 }}要读懂它需要弄清一个对象和三个写法:
- 模板顶层把当前作用域 . 传给引擎,携带内置对象:.Values(合并后的 values 数据)、.Chart(Chart.yaml 内容)、.Release(发布信息,含 Name、Namespace、Service)、.Template,以及 include、toYaml、nindent 等函数;
- {{ .Values.xxx }} 是取值:values 里的键通过点号路径访问。键一旦拼错,取到的是零值(数字 0、空字符串、nil),Go 模板不会报错——这是原稿强调"发布前必须 helm template"的直接原因;
- include "demo-api.fullname" . 调用 _helpers.tpl 里 define 的命名函数生成统一的资源名。命名逻辑只写一份,所有模板引用同一来源,避免每个模板各写各的名字导致漂移;
- toYaml .Values.resources | nindent 12 是管道写法:toYaml 把嵌套 map 转成多行 YAML,nindent 12 补 12 个空格缩进。这是渲染 resources、affinity、nodeSelector 这类嵌套结构的标准写法——直接输出 {{ .Values.resources }} 打出来的是 Go 表示,不是 YAML。
条件与循环是模板里仅有的两种控制结构:
{{- if .Values.autoscaling.enabled }}
...
{{- end }}
env:
{{- range .Values.extraEnv }}
- name: {{ .name }}
value: {{ .value | quote }}
{{- end }}{{- 与 -}} 是空白控制:{{- 吃掉前面的换行和缩进,-}} 吃掉后面的。结构差异(有没有某个资源)用 if 判断,重复项(环境变量、端口、挂载)用 range 遍历——多环境模板大概只需要这两种控制,再多就该警惕了。
#values 合并顺序:从低到高的覆盖规则
渲染时,.Values 不是只读 values.yaml 一份文件,而是多来源按优先级合并的结果。从低到高:
- Chart 内置的 values.yaml(默认值);
- 命令行 -f / --values 指定的文件,多个文件按出现顺序叠加,后写的覆盖先写的;
- --set 指定的键值对;
- --set-string(强制按字符串处理);
- --set-file(把文件内容整体作为值)。
换句话说,决定最终值的不是"文件放在哪",而是"优先级加上顺序":两个 -f 文件里都写了 replicaCount 时,后面的赢。
合并还有一个容易踩的语义:合并是深合并(deep merge)。对 map,逐键递归合并,只覆盖出现的键,没出现的键保留低优先级文件的值;对 list,不逐项合并,整个列表被后者整体替换。用原稿的例子,values.yaml(默认,开发):
replicaCount: 1
image:
repository: ghcr.io/acme/demo-api
tag: dev
resources:
requests: {cpu: 50m, memory: 64Mi}
limits: {cpu: 200m, memory: 256Mi}values-prod.yaml(覆盖生产):
replicaCount: 3
image:
tag: "1.2.0"
resources:
requests: {cpu: 200m, memory: 256Mi}
limits: {cpu: 1, memory: 1Gi}resources 是嵌套 map,prod 只覆盖写到的键,没写的沿用默认值,于是 prod 的 resources 就是上面这份完整值。反过来,键是 list(如 extraEnv、volumeMounts、affinity 黑白名单)时必须把整个列表写全:列表不合并,任何覆盖都是整体替换。
#为什么多环境共享模板、差异进 values
连起上面的机制,多环境的答案自然浮现:
- 结构相同、数值不同,进 values。副本数、镜像 tag、资源配额、域名、日志级别、数据库地址,都是数据;
- 结构不同,用 if 条件控制。有没有 HPA、有没有 ingress、用不用 PVC,是结构问题,由模板里的 if 分支表达,开关布尔值放 values;
- 不要在模板里写死某个环境的数值,也不要在 values 里复制整份 Deployment。
收益是:环境差异集中在少数几个 values 文件里,review 只看 diff 就知道这个环境改了什么;模板只有一份,结构升级(如加一个探针)只改一处,三个环境同时生效,不再有"prod 改了、dev 漏了"的漂移。原稿把这句话概括成"用模板消除重复,不消除差异"——差异是被显式管理的数据,而不是被复制粘贴的代码。
#操作层:从零搭建三环境 Chart(可复现)
#前置条件与版本
- Helm v3。本文命令以 v3.14 为例,Helm 2 已废弃且架构不同(依赖集群内 Tiller),不适用本文;用 helm version --short 确认;
- 一个可达的 Kubernetes 集群,kubectl 可连;本地实验可用 kind create cluster --name helm-demo 或 minikube start;
- 网络能拉取示例镜像 ghcr.io/acme/demo-api。
helm version --short
kubectl version --client#生成并精简 Chart
在仓库根目录执行:
helm create charts/demo-api
cd charts/demo-api
rm -f templates/hpa.yaml templates/ingress.yaml templates/serviceaccount.yaml templates/NOTES.txt
rm -rf templates/tests
cd ../..本示例只保留 deployment.yaml、service.yaml、_helpers.tpl 三个模板文件,values.yaml 替换为下文内容。
#编写模板
templates/_helpers.tpl 提供统一的命名与标签:
{{- define "demo-api.fullname" -}}
{{- printf "%s-%s" .Release.Name .Chart.Name | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- define "demo-api.labels" -}}
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version }}
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end -}}templates/deployment.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "demo-api.fullname" . }}
labels:
{{- include "demo-api.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
template:
metadata:
labels:
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
spec:
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: {{ .Values.service.port }}
protocol: TCP
env:
{{- range .Values.env }}
- name: {{ .name }}
value: {{ .value | quote }}
{{- end }}
resources:
{{- toYaml .Values.resources | nindent 12 }}templates/service.yaml:
apiVersion: v1
kind: Service
metadata:
name: {{ include "demo-api.fullname" . }}
labels:
{{- include "demo-api.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: http
protocol: TCP
name: http
selector:
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}#三套 values 文件
values.yaml 是 Chart 默认值,同时充当 dev 环境的基础,数值保留原稿事实:
replicaCount: 1
image:
repository: ghcr.io/acme/demo-api
tag: dev
pullPolicy: IfNotPresent
service:
type: ClusterIP
port: 8080
env: []
resources:
requests: {cpu: 50m, memory: 64Mi}
limits: {cpu: 200m, memory: 256Mi}values-dev.yaml 显式表达 dev 环境的差异(示例:开发库地址与调试日志):
image:
tag: dev
env:
- name: LOG_LEVEL
value: debug
- name: DATASOURCE_URL
value: jdbc:postgresql://dev-db:5432/acmevalues-staging.yaml(示例:验证环境,副本与资源居中,镜像用预发布 tag):
replicaCount: 2
image:
tag: staging
env:
- name: LOG_LEVEL
value: info
- name: DATASOURCE_URL
value: jdbc:postgresql://staging-db:5432/acme
resources:
requests: {cpu: 100m, memory: 128Mi}
limits: {cpu: 500m, memory: 512Mi}values-prod.yaml:保留原稿生产事实(3 副本、版本化 tag、更大资源):
replicaCount: 3
image:
tag: "1.2.0"
env:
- name: LOG_LEVEL
value: warn
- name: DATASOURCE_URL
value: jdbc:postgresql://prod-db:5432/acme
resources:
requests: {cpu: 200m, memory: 256Mi}
limits: {cpu: 1, memory: 1Gi}注意 prod 里 tag 写了带引号的 "1.2.0":1.2.0 本身因为有两个小数点不会被 YAML 当数字,但 1.0、1.2 这类 tag 会被解析成浮点数,渲染时变成 1、1.2,镜像 tag 就悄悄错了。所有 tag 统一加引号是零成本的防御性写法,--set 场景则用 --set-string。
#发布三套环境
每套环境都走同一流程:lint 校验、template 预览、upgrade --install 提交。
helm lint ./charts/demo-api
helm template demo-api ./charts/demo-api -n dev -f values-dev.yaml
helm upgrade --install demo-api ./charts/demo-api -n dev --create-namespace -f values-dev.yaml
helm template demo-api ./charts/demo-api -n staging -f values-staging.yaml
helm upgrade --install demo-api ./charts/demo-api -n staging --create-namespace -f values-staging.yaml
helm template demo-api ./charts/demo-api -n production -f values-prod.yaml
helm upgrade --install demo-api ./charts/demo-api -n production --create-namespace -f values-prod.yamlhelm upgrade --install 是幂等写法:release 不存在就安装,存在就升级。三个环境用同一个 release 名、不同 namespace,互不干扰;--create-namespace 自动建 namespace。首装也可用 helm install,效果一致。
发布前必须 helm template 看一眼渲染结果,这是原稿最明确的一条经验:values 里某个键拼错时 Helm 不报错,渲染出的 YAML 会静默缺字段,直到 kubectl 侧才暴露。template 一眼能看出 replicas 是不是 3、镜像 tag 对不对。
#常用管理命令
helm list -A # 列出所有 namespace 的 release
helm status demo-api -n production # 单个 release 状态与资源汇总
helm get values demo-api -n production # 该 release 当前生效的用户 values
helm get manifest demo-api -n production # 该 release 渲染出的全部 manifest
helm uninstall demo-api -n dev # 删除 release 与对应资源#验证层:怎么确认我做对了
#验证渲染结果
用 template 输出做静态核对:
helm template demo-api ./charts/demo-api -n production -f values-prod.yaml > /tmp/prod.yaml
grep -n -A2 'replicas:' /tmp/prod.yaml
grep -n 'image:' /tmp/prod.yaml预期输出(示例,行号因版本而异):
34- replicas: 3
40- image: "ghcr.io/acme/demo-api:1.2.0"判定标准:replicas、image.tag、resources 与 values-prod.yaml 完全一致;env 列表完整;没有值渲染成空字符串或 0——那是键拼错或缺失的典型症状。
#验证集群内事实
kubectl get deploy,svc -n production -l app.kubernetes.io/instance=demo-api
kubectl get deploy demo-api -n production -o yaml | grep -E 'replicas|image:'
kubectl get endpoints demo-api -n production判定标准:Deployment 就绪副本数等于目标 replicas;镜像 tag 是 1.2.0;Service selector 与 Pod label 匹配、Endpoints 非空——selector 不匹配时 Service 存在但流量不通,是最常见的"部署成功但访问失败"。
#验证发布记录与回滚
Helm 每次 upgrade 都记录一个 release 版本(发布快照)。模拟坏发布再回滚:
helm upgrade demo-api ./charts/demo-api -n production -f values-prod.yaml --set image.tag=broken-tag
helm history demo-api -n productionhistory 输出(示例,实际会多一列 UPDATED 时间戳,不影响判定):
REVISION STATUS CHART APP VERSION DESCRIPTION
1 superseded demo-api-0.1.0 1.16.0 Install complete
2 failed demo-api-0.1.0 1.16.0 Upgrade complete回滚到第 1 版:
helm rollback demo-api 1 -n production
helm history demo-api -n production # 会出现第 3 条记录:rolled back to 1
helm get values demo-api -n production # 确认生效的 values 已回到第 1 版
kubectl get deploy demo-api -n production -o yaml | grep 'image:'rollback 不是时光倒流,而是一次反向 upgrade:用第 1 版的 manifest 和 values 生成新 revision(第 3 版)并应用,所以 history 会多一条记录而非删掉第 2 条。发布前把 Chart 版本、values 文件、镜像版本一起记录(如写进 release notes),回滚时才能回答"上一版到底是什么配置"——否则 revision 只是数字,你不知道它对应哪份代码。
#release 记录存在哪里
revision 记录不是本地文件,而是作为 Kubernetes Secret 存在集群里:
kubectl get secret -n production -l owner=helm
kubectl get secret sh.helm.release.v1.demo-api.v1 -n production -o jsonpath='{.data.release}' | base64 -d | gzip -d | strings第一条列出的是 sh.helm.release.v1.<release名>.
#把校验装进 CI
下面几行可原样进 CI,在合并前挡住低级错误:
helm lint ./charts/demo-api
helm template review ./charts/demo-api -f values-dev.yaml > /tmp/render-dev.yaml
helm template review ./charts/demo-api -f values-prod.yaml > /tmp/render-prod.yaml
git diff --no-index /tmp/render-dev.yaml /tmp/render-prod.yaml团队还可以在 upgrade 前用 helm-diff 插件查看"集群将被改成什么样":
helm plugin install https://github.com/databus23/helm-diff
helm diff upgrade demo-api ./charts/demo-api -n production -f values-prod.yaml#边界与坑
#键拼错:Helm 不报错,YAML 静默缺字段
模板里 .Values.resouces 与 .Values.resources 的差别,Go 模板完全无感——前者取到 nil,resources 下渲染成空,集群里的资源限制悄悄变成默认值。这是 Helm 最阴性的失败模式,原稿专门强调:发布前必须 helm template 并结合人工或 diff 检查;CI 里加 lint 加 diff。任何生产变更先看渲染 diff 再谈发布。
#合并是"深"的,但 list 例外
map 深合并、list 整体替换,是 values 合并的两条铁律。坑大多出在 list:values.yaml 里定义了 extraEnv 两个变量,prod 文件想加第三个时如果手滑把整个 extraEnv 抄了一遍,生产环境的 env 就只剩抄下来的那几项。多环境文件里凡是 list 字段,写的时候默认是"整体重写",不是"追加";需要追加语义时,在模板里用 range 循环拼"取值 + 固定项"两个列表,不要依赖 values 自动合并。
#Secret 明文进 values 等于事故
values 文件通常跟着 Chart 进 Git,所以里面不能放真实密码。原稿记录过一个真实事故:values-prod.yaml 里躺着数据库明文口令,理由是"先跑起来再说",等它被同步到公共仓库就成了安全事件。正确做法只有两条路:
- 模板里用 valueFrom.secretKeyRef 引用集群里已经存在的 Secret,values 里只放 Secret 的名字;
- 用 External Secrets Operator / Vault 把外部密钥同步成集群 Secret,Helm 只管引用,永远不接触明文。
这句话可以直接写进团队规范:values 里出现任何明文凭据,都按事故处理。
#release 记录是集群对象,不是本地文件
release 历史以 Secret 存在集群:换集群就没有历史;namespace 被删除,记录一并消失;helm uninstall 默认删除记录。不要手动 kubectl delete 那些 sh.helm.release.v1.* 的 Secret——那会破坏 helm history 与 rollback 的一致性,让 Helm 认为 release 损坏,后续 upgrade 都会失败。revision 设上限(--history-max)既是为 etcd 容量,也是为让人读得懂 history。
#模板别写成天书
Helm 模板函数很强大,但越强大越要克制。原稿的原则逐条保留:
- 模板里只做"取值 + 简单的条件/循环":{{- if .Values.autoscaling.enabled }}、{{- range .Values.extraEnv }};
- 业务逻辑放应用代码,不放模板。有人用 Helm 模板实现复杂的配置计算,Chart 变成没人敢动的黑盒;
- 常用片段抽进 _helpers.tpl(如 fullname、labels),保持各模板一致;
- helm lint 和 helm template 进 CI,合并前就挡住低级错误。
判断标准也是原稿给的:如果 Chart 里某个表达式要读三遍才懂,那就是过度设计了。
#--set 的转义与优先级陷阱
--set 优先级高于所有 values 文件,但也最容易写错:值里的逗号、花括号、等号要用反斜杠转义,列表用 {a,b,c} 语法,数字会被当数字处理。最典型的是镜像 tag:
helm upgrade demo-api ./charts/demo-api -n production -f values-prod.yaml --set image.tag=1.0
helm upgrade demo-api ./charts/demo-api -n production -f values-prod.yaml --set-string image.tag=1.0第一条 --set 得到的是浮点数 1,渲染后 image 变成 ghcr.io/acme/demo-api:1,tag 语义被破坏;第二条 --set-string 才得到字符串 1.0。简单场景用 --set 做临时覆盖没问题,一旦超过两三个键或值本身是嵌套结构,写进 values 文件更可读、可 review、可回放。--set 适合临时调试,不适合作为环境的真相来源。
#升级失败的三层恢复
坏发布被 apply 之后有三层恢复手段,由轻到重:
- 回滚:helm rollback demo-api
-n production,用历史 manifest 反向 upgrade,适合 values 或镜像 tag 出错; - 原子安装:升级时加 --atomic --timeout 5m,失败自动回滚到上一版,适合无人值守的 CI 发布;
- 重建:如果 Chart 模板本身写坏了(语法、结构错误),rollback 一个坏快照没有意义,修好模板后再 upgrade。
#什么时候不该用 Helm
Helm 的价值随模板复用次数上升。只有一两个 Deployment、没有环境分层的项目,直接写 YAML 或用 Kustomize 更短;没有多环境和回滚诉求时,模板、release、Secret 存储都是纯成本。Helm 适合:同一套结构要反复部署到多个环境或多个集群,且发布需要版本历史。
#收尾
从机制回顾全文:Helm 是模板引擎,Chart 是模板与默认值的集合,values 是环境数据,release 是发布快照。多环境用法的全部规则——共享模板、差异进 values、后写覆盖先写、map 深合并而 list 整体替换、回滚即反向 upgrade——都能从这条渲染管线推导出来,不需要死记命令。原稿给出的实践底线依然适用:发布前看渲染结果,values 不进明文凭据,模板保持直白。下一步建议:把 lint 加 template 加 diff 固化进 CI;每个环境一个 values 文件,review 只看 diff;Secret 全部迁到 secretKeyRef 或 External Secrets Operator。做到这三件事,Chart 会一直保持可维护,而不是变成只有作者敢碰的天书。
相关阅读
Related留言讨论
0 条还没有留言,来说点什么吧。