从零编写一个 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,不要一上来就做大一统模板。

发表回复

后才能评论