为什么需要 Helm

裸用 kubectl apply -f 管理 K8s 应用,在规模小时够用,但随着环境增多(dev/staging/prod)和服务增长,问题立刻暴露:

  • 配置硬编码:每个环境一份 YAML,镜像 tag、副本数、资源限制全写死,改一个值要改十个文件
  • 无版本管理:升级回滚靠手动记录,不知道上次部署了什么版本
  • 无法复用:部署 Redis 和部署 MySQL 写两套完全不同的 YAML,无法模板化

Helm 是 K8s 的包管理器,把一组 K8s 资源打包成 Chart,通过 values.yaml 参数化配置,实现一份模板、多环境部署、版本化升级和一键回滚。

本文参考 Helm 官方文档

Helm Chart 目录结构

my-web-app/
├── Chart.yaml              # Chart 元信息(名称、版本、描述)
├── values.yaml             # 默认配置值
├── values-prod.yaml        # 生产环境覆盖配置
├── charts/                 # 依赖的子 Chart
├── templates/              # K8s 资源模板
│   ├── _helpers.tpl        # 命名模板(可复用的模板片段)
│   ├── deployment.yaml     # Deployment
│   ├── service.yaml        # Service
│   ├── ingress.yaml        # Ingress
│   ├── configmap.yaml      # ConfigMap
│   ├── secret.yaml         # Secret
│   ├── hpa.yaml            # HorizontalPodAutoscaler
│   ├── serviceaccount.yaml # ServiceAccount
│   └── NOTES.txt           # 安装后提示信息
└── .helmignore             # Helm 打包忽略文件

Chart.yaml 详解

apiVersion: v2                    # Helm 3 固定用 v2
name: my-web-app                  # Chart 名称
description: A production-grade web application Helm chart
type: application                 # application | library
version: 1.2.0                    # Chart 版本(SemVer)
appVersion: "2.4.1"               # 应用版本(不强制 SemVer)
keywords:
  - web
  - api
  - microservice
maintainers:
  - name: 徐保金
    email: xubaojin@example.com
dependencies:
  - name: redis
    version: 18.x.x
    repository: https://charts.bitnami.com/bitnami
    condition: redis.enabled      # 只有 values 中 redis.enabled=true 时才安装

关键字段:

  • version 是 Chart 自身的版本,每次修改模板或 values 时递增
  • appVersion 是打包应用的版本,与 Chart version 相互独立
  • dependencies 声明依赖的子 Chart,condition 控制是否启用

模板语法

基础表达式

# {{ .Values.xxx }} — 引用 values.yaml 中的值
# {{ .Release.xxx }} — 引用 Helm Release 信息
# {{ .Chart.xxx }} — 引用 Chart.yaml 中的值

apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ .Release.Name }}-{{ .Chart.Name }}
  labels:
    app.kubernetes.io/name: {{ .Chart.Name }}
    app.kubernetes.io/instance: {{ .Release.Name }}
    app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}

常用内置对象:

对象说明示例
.Valuesvalues.yaml 中的值.Values.image.repository
.Release.NameRelease 名称my-release
.Release.Namespace目标命名空间production
.Release.IsInstall是否首次安装true / false
.Release.IsUpgrade是否升级操作true / false
.Chart.NameChart 名称my-web-app
.Chart.AppVersion应用版本2.4.1
.FilesChart 内文件访问.Files.Get "config/nginx.conf"

管道与函数

Helm 模板支持管道(pipe)和 Sprig 函数库:

# 管道:将前一个函数的输出作为后一个的输入
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"

# 常用函数
{{ .Values.replicaCount | int }}        # 转整数
{{ .Values.image.tag | quote }}          # 加引号
{{ .Values.hostname | upper }}           # 转大写
{{ .Values.host | replace "." "-" }}     # 替换字符串
{{ include "my-app.fullname" . }}        # 调用命名模板
{{ .Values.port | toString }}            # 转字符串

# default 函数:值为空时使用默认值
imagePullPolicy: {{ .Values.image.pullPolicy | default "IfNotPresent" }}

# nindent 函数:缩进 N 个空格(注意是 nindent 不是 indent,前者有前导换行)
{{- include "my-app.labels" . | nindent 4 }}

range 循环

# values.yaml
service:
  ports:
    - name: http
      port: 80
      targetPort: 8080
    - name: metrics
      port: 9090
      targetPort: 9090

# templates/service.yaml
spec:
  type: {{ .Values.service.type }}
  ports:
    {{- range .Values.service.ports }}
    - name: {{ .name }}
      port: {{ .port }}
      targetPort: {{ .targetPort }}
      protocol: TCP
    {{- end }}

if 条件

# values.yaml
ingress:
  enabled: true
  className: nginx
  hosts:
    - host: api.example.com
      paths:
        - path: /
          pathType: Prefix

# templates/ingress.yaml
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: {{ include "my-app.fullname" . }}
spec:
  ingressClassName: {{ .Values.ingress.className }}
  rules:
    {{- range .Values.ingress.hosts }}
    - host: {{ .host | quote }}
      http:
        paths:
          {{- range .paths }}
          - path: {{ .path }}
            pathType: {{ .pathType }}
            backend:
              service:
                name: {{ include "my-app.fullname" $ }}
                port:
                  number: 80
          {{- end }}
    {{- end }}
{{- end }}

注意模板中的 {{- }} 语法——横杠表示去除前导/尾部空白,避免生成 YAML 中出现多余空行导致格式错误。

with 作用域

# values.yaml
livenessProbe:
  httpGet:
    path: /healthz
    port: http
  initialDelaySeconds: 10
  periodSeconds: 10

# templates/deployment.yaml
# with 改变 . 的作用域,避免重复写长路径
{{- with .Values.livenessProbe }}
livenessProbe:
  {{- toYaml . | nindent 2 }}
{{- end }}

with 块内,. 指向 .Values.livenessProbe。如果需要在 with 块内访问根上下文,使用 $(根上下文的别名)。

values.yaml 层级覆盖机制

Helm 的配置覆盖有一套清晰的优先级,从高到低:

--set 参数(最高优先级)
    ↓ 覆盖
-f / --values 指定的文件
    ↓ 覆盖
父 Chart 的 values.yaml(最低)
    ↓ 被覆盖
子 Chart 的 values.yaml(最低中的最低)

–set 传参

# 设置单个值
helm install my-app ./my-web-app \
  --set image.tag=v2.4.1

# 设置嵌套值
helm install my-app ./my-web-app \
  --set image.tag=v2.4.1 \
  --set replicaCount=3

# 设置数组元素
helm install my-app ./my-web-app \
  --set ingress.hosts[0].host=api.example.com

# 设置布尔值
helm install my-app ./my-web-app \
  --set ingress.enabled=true

-f 多环境配置文件

# values.yaml(默认值)
replicaCount: 2
image:
  repository: my-web-app
  tag: latest
resources:
  requests:
    cpu: 100m
    memory: 128Mi
ingress:
  enabled: false

# values-prod.yaml(生产覆盖)
replicaCount: 5
image:
  tag: v2.4.1
resources:
  requests:
    cpu: 500m
    memory: 512Mi
ingress:
  enabled: true
  className: nginx
  hosts:
    - host: api.example.com
      paths:
        - path: /
          pathType: Prefix
# 生产部署:默认值被 values-prod.yaml 覆盖
helm upgrade --install my-app ./my-web-app \
  -f values.yaml \
  -f values-prod.yaml \
  -n production \
  --create-namespace

父子 Chart 覆盖

# 项目结构
parent-chart/
├── Chart.yaml
├── values.yaml          # 父 Chart 的值可以覆盖子 Chart
├── charts/
│   └── child-chart/
│       ├── Chart.yaml
│       └── values.yaml  # 子 Chart 自身默认值
└── templates/
# parent/values.yaml
# 以子 Chart 名称为顶级 key,覆盖子 Chart 的值
child-chart:
  replicaCount: 3
  image:
    tag: v2.4.1

父 Chart 的 values.yaml 中以子 Chart 名称为 key 的部分,会覆盖子 Chart 的 values.yaml。但子 Chart 模板中只能访问自己的 .Values,看不到父 Chart 的全局值。

命名模板与钩子

_helpers.tpl

命名模板(named template)是可复用的模板片段,定义在 _helpers.tpl 中。以 _ 开头的文件不会被渲染为 K8s 资源,只供其他模板引用。

{{/* templates/_helpers.tpl */}}

{{/*
生成完整的资源名称:release-name-chart-name
*/}}
{{- define "my-app.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}

{{/*
标准标签:所有资源统一使用
*/}}
{{- define "my-app.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/version: {{ .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}

{{/*
资源选择器标签:Deployment/Service 的 selector 必须一致
*/}}
{{- define "my-app.selectorLabels" -}}
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}

在模板中使用:

# templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "my-app.fullname" . }}
  labels:
    {{- include "my-app.labels" . | nindent 4 }}
spec:
  selector:
    matchLabels:
      {{- include "my-app.selectorLabels" . | nindent 6 }}
  template:
    metadata:
      labels:
        {{- include "my-app.selectorLabels" . | nindent 8 }}

Helm Hooks

Hooks 允许在 Release 生命周期的特定时机执行操作,如安装前初始化数据库、升级后运行迁移脚本。

# templates/post-install-job.yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: {{ include "my-app.fullname" . }}-migrate
  labels:
    {{- include "my-app.labels" . | nindent 4 }}
  annotations:
    "helm.sh/hook": post-install,post-upgrade      # 时机
    "helm.sh/hook-weight": "-5"                    # 权重(数字越小越先执行)
    "helm.sh/hook-delete-policy": hook-succeeded   # 成功后删除 Job
spec:
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: migrate
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          command: ["./app", "migrate"]

常用 Hook 时机:

Hook触发时机典型用途
pre-install模板渲染后、资源创建前创建外部依赖(如数据库)
post-install所有资源创建后数据库迁移、初始化数据
pre-upgrade升级渲染后、替换前备份数据
post-upgrade升级完成后缓存预热、健康检查
pre-delete删除前清理外部资源
post-delete删除后清理确认

私有仓库管理:Harbor

Harbor 部署

Harbor 是 CNCF 毕业项目,同时支持容器镜像仓库和 Helm Chart 仓库(2.x 版本后 Chartmuseum 已内置集成)。

# 下载 Harbor 安装包
wget https://github.com/goharbor/harbor/releases/download/v2.9.0/harbor-offline-installer-v2.9.0.tgz
tar xzf harbor-offline-installer-v2.9.0.tgz
cd harbor

# 生成自签名证书(生产环境用正式证书)
mkdir -p /data/cert
openssl genrsa -out /data/cert/ca.key 4096
openssl req -x509 -new -nodes -sha512 -days 3650 \
  -subj "/CN=harbor.example.com" \
  -key /data/cert/ca.key \
  -out /data/cert/ca.crt

# 配置 harbor.yml
cp harbor.yml.tmpl harbor.yml
# harbor.yml 关键配置
hostname: harbor.example.com

http:
  port: 80

https:
  port: 443
  certificate: /data/cert/ca.crt
  private_key: /data/cert/ca.key

harbor_admin_password: YourStrongPassword

data_volume: /data/harbor

# 启用 Chart 仓库服务
chart:
  absolute_url: false
# 安装(含 Chart 服务)
./install.sh --with-chartmuseum

# 验证
docker ps | grep harbor
# harbor-core, harbor-db, harbor-jobservice, chartmuseum, registry, ...

Chart 推送到 Harbor

# 1. 添加 Harbor Helm 仓库(需认证)
helm repo add my-harbor https://harbor.example.com/chartrepo/library \
  --username admin --password YourStrongPassword

# 2. 打包 Chart
helm package ./my-web-app --version 1.2.0
# Successfully packaged chart and saved it to: my-web-app-1.2.0.tgz

# 3. 推送到 Harbor
helm push my-web-app-1.2.0.tgz my-harbor

# 4. 搜索验证
helm search repo my-harbor/my-web-app
# NAME                    CHART VERSION  APP VERSION
# my-harbor/my-web-app    1.2.0          2.4.1
# 从 Harbor 安装
helm install my-app my-harbor/my-web-app \
  --version 1.2.0 \
  -f values-prod.yaml \
  -n production

推送 Helm 插件安装

Helm 3.8+ 内置了 OCI 支持,可以直接用 helm push 推送 Chart 到 OCI 兼容仓库(包括 Harbor)。

# Helm 3.8+ OCI 方式推送
helm registry login harbor.example.com \
  --username admin --password YourStrongPassword

helm package ./my-web-app --version 1.2.0
helm push my-web-app-1.2.0.tgz oci://harbor.example.com/charts

# 拉取安装
helm install my-app oci://harbor.example.com/charts/my-web-app \
  --version 1.2.0 \
  -n production

CI/CD 集成:GitHub Actions

# .github/workflows/helm-release.yml
name: Helm Chart Release

on:
  push:
    tags:
      - 'chart-v*'   # tag 格式: chart-v1.2.0

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Install Helm
        uses: azure/setup-helm@v4
        with:
          version: v3.14.0

      - name: Extract version
        id: version
        run: echo "VERSION=${GITHUB_REF#refs/tags/chart-v}" >> $GITHUB_OUTPUT

      - name: Lint Chart
        run: |
          helm lint ./charts/my-web-app          

      - name: Update Chart.yaml version
        run: |
          sed -i "s/^version:.*/version: ${{ steps.version.outputs.VERSION }}/" \
            ./charts/my-web-app/Chart.yaml          

      - name: Package Chart
        run: |
          mkdir -p packaged
          helm package ./charts/my-web-app \
            --version ${{ steps.version.outputs.VERSION }} \
            --destination packaged          

      - name: Login to Harbor
        run: |
          echo "${{ secrets.HARBOR_PASSWORD }}" | \
          helm registry login harbor.example.com \
            --username ${{ secrets.HARBOR_USERNAME }} --password-stdin          

      - name: Push Chart to Harbor
        run: |
          helm push packaged/my-web-app-${{ steps.version.outputs.VERSION }}.tgz \
            oci://harbor.example.com/charts          

      - name: Generate Release Notes
        run: |
          echo "## Helm Chart v${{ steps.version.outputs.VERSION }}" > release-notes.md
          echo "" >> release-notes.md
          echo "### Install" >> release-notes.md
          echo '```bash' >> release-notes.md
          echo "helm install my-app \\" >> release-notes.md
          echo "  oci://harbor.example.com/charts/my-web-app \\" >> release-notes.md
          echo "  --version ${{ steps.version.outputs.VERSION }} \\" >> release-notes.md
          echo "  -n production --create-namespace" >> release-notes.md
          echo '```' >> release-notes.md          

      - name: Create GitHub Release
        uses: softprops/action-gh-release@v2
        with:
          files: packaged/*.tgz
          body_path: release-notes.md
          token: ${{ secrets.GITHUB_TOKEN }}

实战:完整的 Web 应用 Helm Chart

values.yaml

# values.yaml — 默认配置
replicaCount: 2

image:
  repository: my-web-app
  tag: ""
  pullPolicy: IfNotPresent
  pullSecrets: []

nameOverride: ""
fullnameOverride: ""

serviceAccount:
  create: true
  annotations: {}
  name: ""

podAnnotations: {}
podSecurityContext:
  fsGroup: 1000

securityContext:
  runAsNonRoot: true
  runAsUser: 1000
  readOnlyRootFilesystem: true
  allowPrivilegeEscalation: false

service:
  type: ClusterIP
  port: 80
  targetPort: 8080

ingress:
  enabled: false
  className: ""
  annotations: {}
  hosts: []
  tls: []

resources:
  requests:
    cpu: 100m
    memory: 128Mi
  limits:
    cpu: 500m
    memory: 512Mi

autoscaling:
  enabled: false
  minReplicas: 2
  maxReplicas: 10
  targetCPUUtilizationPercentage: 80

livenessProbe:
  httpGet:
    path: /healthz
    port: http
  initialDelaySeconds: 15
  periodSeconds: 10
  failureThreshold: 3

readinessProbe:
  httpGet:
    path: /ready
    port: http
  initialDelaySeconds: 5
  periodSeconds: 5
  failureThreshold: 3

config:
  LOG_LEVEL: info
  APP_ENV: production

nodeSelector: {}
tolerations: []
affinity: {}

templates/deployment.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "my-app.fullname" . }}
  labels:
    {{- include "my-app.labels" . | nindent 4 }}
spec:
  {{- if not .Values.autoscaling.enabled }}
  replicas: {{ .Values.replicaCount }}
  {{- end }}
  selector:
    matchLabels:
      {{- include "my-app.selectorLabels" . | nindent 6 }}
  template:
    metadata:
      annotations:
        checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
        {{- with .Values.podAnnotations }}
        {{- toYaml . | nindent 8 }}
        {{- end }}
      labels:
        {{- include "my-app.selectorLabels" . | nindent 8 }}
    spec:
      {{- with .Values.image.pullSecrets }}
      imagePullSecrets:
        {{- toYaml . | nindent 8 }}
      {{- end }}
      serviceAccountName: {{ include "my-app.serviceAccountName" . }}
      securityContext:
        {{- toYaml .Values.podSecurityContext | nindent 8 }}
      containers:
        - name: {{ .Chart.Name }}
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
          imagePullPolicy: {{ .Values.image.pullPolicy }}
          ports:
            - name: http
              containerPort: {{ .Values.service.targetPort }}
              protocol: TCP
          envFrom:
            - configMapRef:
                name: {{ include "my-app.fullname" . }}-config
          {{- with .Values.livenessProbe }}
          livenessProbe:
            {{- toYaml . | nindent 12 }}
          {{- end }}
          {{- with .Values.readinessProbe }}
          readinessProbe:
            {{- toYaml . | nindent 12 }}
          {{- end }}
          resources:
            {{- toYaml .Values.resources | nindent 12 }}
          securityContext:
            {{- toYaml .Values.securityContext | nindent 12 }}
      {{- with .Values.nodeSelector }}
      nodeSelector:
        {{- toYaml . | nindent 8 }}
      {{- end }}
      {{- with .Values.affinity }}
      affinity:
        {{- toYaml . | nindent 8 }}
      {{- end }}
      {{- with .Values.tolerations }}
      tolerations:
        {{- toYaml . | nindent 8 }}
      {{- end }}

templates/configmap.yaml

apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ include "my-app.fullname" . }}-config
  labels:
    {{- include "my-app.labels" . | nindent 4 }}
data:
  {{- range $key, $val := .Values.config }}
  {{ $key }}: {{ $val | quote }}
  {{- end }}

templates/hpa.yaml

{{- if .Values.autoscaling.enabled }}
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: {{ include "my-app.fullname" . }}
  labels:
    {{- include "my-app.labels" . | nindent 4 }}
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: {{ include "my-app.fullname" . }}
  minReplicas: {{ .Values.autoscaling.minReplicas }}
  maxReplicas: {{ .Values.autoscaling.maxReplicas }}
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
{{- end }}

部署验证

# 渲染模板预览(不实际部署)
helm template my-app ./my-web-app -f values-prod.yaml

# Lint 检查
helm lint ./my-web-app -f values-prod.yaml
# ==> Linting ./my-web-app
# [INFO] Chart.yaml: icon is recommended
# 1 chart(s) linted, 0 chart(s) failed

# Dry-run 部署
helm install my-app ./my-web-app \
  -f values-prod.yaml \
  -n production \
  --create-namespace \
  --dry-run

# 正式部署
helm upgrade --install my-app ./my-web-app \
  -f values-prod.yaml \
  -n production \
  --create-namespace

# 查看部署状态
helm list -n production
helm status my-app -n production

# 查看历史版本
helm history my-app -n production

# 回滚到上一版本
helm rollback my-app 1 -n production

总结

Helm Chart 是 K8s 应用管理的标准方式。核心要点:

  1. 目录结构清晰——Chart.yaml 管元信息、values.yaml 管配置、templates/ 管模板,各司其职
  2. 模板语法强大——{{ }} 表达式 + Sprig 函数 + range/if/with 控制结构,满足 99% 的配置需求
  3. values 分层覆盖——--set > -f > 默认 values.yaml,多环境配置通过文件分离,不修改模板
  4. 命名模板复用——_helpers.tpl 中定义标签、名称等公共片段,所有模板统一引用,保持一致性
  5. Hooks 灵活扩展——pre-install/post-upgrade 等钩子支持在生命周期关键节点执行初始化或迁移
  6. 私有仓库规范管理——Harbor 统一托管镜像和 Chart,OCI 协议让 helm push 更简单
  7. CI/CD 自动化——GitHub Actions 中 helm lint → package → push 全自动,打 tag 即发版

一份好的 Helm Chart,应该做到:开发者改 values.yaml 就能适配新环境,不需要碰模板文件;模板经过 helm linthelm template 验证,不会部署出格式错误的 YAML。这才是 GitOps 的基础。

参考资料与致谢

本文在撰写过程中参考了以下资料,感谢原作者的贡献:

  1. Helm 官方文档 — Helm 项目,参考了Helm 官方文档相关内容