从零编写一个 Helm Chart:把自研应用打包成可复用交付物
自研应用交付时最怕两件事:每个环境手改一遍 YAML,改错一处就是一次故障;配置散落各处,谁也不知道当前线上到底是哪一版。Helm Chart 的作用就是把一组 Kubernetes 资源模板化、参数化、版本化,打包成一个可复用的交付物:部署时只换 values,就能在开发、测试、生产环境复现同一套结构。本文从零写一个完整可用的 Chart,走通结构、模板、验证、发布全流程。
适用环境:Helm 3,Kubernetes 1.24 及以上。示例应用是一个无状态 Web 服务,带 Service 和 Ingress,是最常见的交付形态。
前置条件
- 本机已安装 Helm 3 和 kubectl,能连接任意一个测试集群(没有集群时用
helm template也能完成大部分验证); - 应用镜像已推送到可访问的镜像仓库(文中以 harbor.example.com 占位);
- 清楚应用自身的启动方式:监听端口、健康检查路径、需要的环境变量和配置项。
第一步:Chart 目录结构逐项解释
用 helm create demo-app 生成骨架,再删减到只剩需要的部分。一个 Chart 的标准结构如下,每一项都有明确职责,不要把文件随手乱放:
demo-app/
├── Chart.yaml # Chart 的元信息:名称、版本、应用版本,必填
├── values.yaml # 默认参数值,所有可变项的唯一出处
├── values-prod.yaml # 环境覆盖文件(自备,部署时用 -f 指定,不属于 Chart 规范但很常用)
├── templates/ # 模板目录,渲染后产出最终 YAML
│ ├── _helpers.tpl # 命名、标签等可复用模板片段,不直接产出资源
│ ├── deployment.yaml
│ ├── service.yaml
│ ├── ingress.yaml
│ ├── serviceaccount.yaml
│ └── NOTES.txt # 安装成功后给使用者看的提示信息
└── charts/ # 依赖的子 Chart(有依赖时由 helm dependency 生成,不要手改)
先把 Chart.yaml 写对,两个版本号语义不同,混淆是后面升级混乱的源头:
apiVersion: v2
name: demo-app
description: A demo web application chart
type: application
version: 0.3.0 # Chart 自身版本:模板或默认值有变化就递增,与应用无关
appVersion: "1.4.2" # 应用版本:对应镜像的业务版本,默认作为镜像 tag 的取值来源
第二步:values 分层设计
values 是 Chart 的「参数面板」,设计原则是:结构稳定、命名语义化、默认值能直接跑通最小形态,环境差异全部通过覆盖文件表达,不进模板里写 if 判断环境名。
replicaCount: 1
image:
repository: harbor.example.com/apps/demo-app
tag: "" # 留空时模板回退到 Chart.yaml 的 appVersion,避免两处维护
pullPolicy: IfNotPresent
service:
type: ClusterIP
port: 80
ingress:
enabled: false # 可选资源一律用开关控制,默认关,按需打开
host: app.example.com
tls:
enabled: false
secretName: ""
resources: {} # 资源请求与限制不给假默认值,强制部署方显式填写
config:
logLevel: info
featureX: "off"
生产覆盖文件只写差异项,文件本身纳入代码仓库评审:
# values-prod.yaml
replicaCount: 3
ingress:
enabled: true
host: app.example.com
tls:
enabled: true
secretName: example-com-tls
resources:
requests:
cpu: 200m
memory: 256Mi
limits:
cpu: "1"
memory: 512Mi
config:
logLevel: warn
第三步:三个核心模板的模板化写法
先写 _helpers.tpl:命名只在一处定义
资源名、全名、通用标签会被每个模板引用,必须抽到 helpers 里统一生成,否则改名时要全仓搜索替换,还容易超 63 字符的 DNS 名长度限制:
{{- define "demo-app.name" -}}
{{- .Chart.Name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- define "demo-app.fullname" -}}
{{- printf "%s-%s" .Release.Name .Chart.Name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- define "demo-app.labels" -}}
app.kubernetes.io/name: {{ include "demo-app.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version }}
{{- end }}
Deployment:变量、默认函数与 with 的实用片段
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "demo-app.fullname" . }}
labels:
{{- include "demo-app.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
app.kubernetes.io/name: {{ include "demo-app.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
template:
metadata:
labels:
app.kubernetes.io/name: {{ include "demo-app.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
spec:
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- containerPort: 8080
env:
{{- range $key, $val := .Values.config }}
- name: {{ $key | upper | quote }}
value: {{ $val | quote }}
{{- end }}
{{- with .Values.resources }}
resources:
{{- toYaml . | nindent 12 }}
{{- end }}
这几处是高频实用写法:default 做空值回退;range 把 config 映射展开成环境变量,新增配置项不用改模板;with 在块内切换上下文,配合 toYaml + nindent 整体透传复杂结构(resources、亲和性、容忍度都适合这么处理);字符串一律过 quote,避免 on、1.0 这类值被 YAML 解析成布尔或数字。
Service 与 Ingress:开关控制可选资源
apiVersion: v1
kind: Service
metadata:
name: {{ include "demo-app.fullname" . }}
labels:
{{- include "demo-app.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: 8080
selector:
app.kubernetes.io/name: {{ include "demo-app.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "demo-app.fullname" . }}
labels:
{{- include "demo-app.labels" . | nindent 4 }}
spec:
ingressClassName: nginx
{{- if .Values.ingress.tls.enabled }}
tls:
- hosts:
- {{ .Values.ingress.host }}
secretName: {{ .Values.ingress.tls.secretName }}
{{- end }}
rules:
- host: {{ .Values.ingress.host }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: {{ include "demo-app.fullname" . }}
port:
number: {{ .Values.service.port }}
{{- end }}
注意 Service 的 selector 必须与 Deployment 的 Pod 标签严格一致,且不要把带版本号的标签(如 helm.sh/chart)放进 selector——Chart 一升级 selector 就变,Deployment 的 selector 又是不可变字段,会直接升级失败。selector 只放 name 和 instance 这类稳定标签。
第四步:本地验证与发布
模板写完不要直接往集群上装,先在本地完成三连检:
# 1. 语法与规范检查
helm lint ./demo-app -f values-prod.yaml
# 2. 本地渲染,看最终 YAML 是否符合预期(排版、缩进、值是否生效)
helm template demo ./demo-app -f values-prod.yaml | less
# 3. 服务端干跑,让 API Server 做一次真实校验但不落库
helm install demo ./demo-app -f values-prod.yaml --dry-run=server -n prod
# 4. 打包并推送到 OCI 仓库(Harbor 原生支持),或上传到传统 Chart 仓库
helm package ./demo-app
helm push demo-app-0.3.0.tgz oci://harbor.example.com/charts
发布纪律:Chart 一经发布,同一版本号的内容不要再改——改模板就升 version,换应用就升 appVersion 并同步递增 version。这样任何环境用「Chart 版本 + values 文件」就能唯一确定部署内容,出问题时对照和回退都有据可查。回滚用 helm rollback 回到上一个 Release 修订,它回退的是渲染结果,不要指望它帮你处理数据库之类的有状态变更。
验证
# 确认 Release 状态与实际资源
helm status demo -n prod
helm get values demo -n prod # 核对生效的 values 是否与覆盖文件一致
kubectl get deploy,svc,ingress -n prod -l app.kubernetes.io/instance=demo
# 故意用错一个值再 template 一次,确认渲染会报错而不是静默产出坏 YAML
helm template demo ./demo-app --set replicaCount=abc
常见问题
1. 渲染出来的 YAML 缩进错位、API Server 报字段未知。十有八九是 nindent/indent 的空格数与所在层级对不齐,或 {{- 的左横杠漏写导致多出空行。解决方法不是肉眼数空格,而是养成习惯:每次改模板先 helm template 把产物过一遍,再用 kubectl apply --dry-run=client -f - 校验。
2. values 覆盖不生效。先分清覆盖顺序:-f 文件按出现顺序后者优先,--set 优先级最高;再查键名路径是否写错层级(比如把 image.tag 写成了顶层 tag);最后用 helm get values 看最终生效值定位问题段。还有一个隐蔽点:--set 传入的值默认按字符串/数字解析,版本号 1.10 会被吃成浮点数,该用 --set-string 或写进 values 文件。
3. 升级时报 selector 不可变、Forbidden。多半是有人把带版本的标签放进了 Deployment 的 selector,或改了资源名导致 Helm 认为是新建。selector 一旦创建不可修改,只能删掉 Deployment 重建(Pod 会重建,注意业务窗口)。预防办法就是上文说的:selector 只放稳定标签,命名规则冻结在 helpers 里不轻易动。
4. 模板里引号引发的连环坑。环境变量值、注解、镜像 tag 这几类字符串不加 quote,平时没事,遇到 yes、*、纯数字这类值就会被 YAML 二次解析出怪结果。规则简单粗暴:模板拼出来的字符串值默认全加 quote,除非明确知道它是数字类型。
小结
写 Chart 的核心不是语法,而是三条纪律:可变项全部收敛到 values 且分层清晰;命名与标签只在 helpers 里定义;发布后版本不可变。把这三条守住,Chart 才能从「一次性脚本」变成团队可复用的交付物。入门阶段建议从单应用单 Chart 起步,公共组件(如统一的 Ingress 注解、资源规范)稳定后再抽 Library Chart,不要一上来就做大一统模板。







