Helm 包管理:Chart 开发与仓库治理 > 从"一把梭 kubectl apply"到可复用、可版本化、可治理的部署体系
写在前面
你可能已经用 Helm 安过不少 Chart------helm install nginx-ingress,一条命令,啪,起来了。但当你的集群从 5 个应用膨胀到上百个,问题就来了:每个团队的 Chart 目录结构五花八门,values.yaml 覆盖逻辑像意大利面条,私有仓库里同一个 Chart 有 v1.0、v1.0-hotfix、v1.0.1-rc 三个版本谁也搞不清哪个上了生产。这篇文章不教你"怎么装 Chart",而是教你怎么开发规范 Chart、治理 Helm 仓库、编排多环境部署,让 Helm 从一个安装工具变成你真正的包管理基础设施。
核心问题
怎么管理上百个应用的部署------让 Chart 可复用、values 可分层、版本可追溯、多环境可差异化?
一、原理剖析
1.1 Chart 的本质:一套模板 + 一组默认值
Helm Chart 不是"打包好的镜像",它是一组 Go Template + 一个 values.yaml 的默认值。渲染过程就是:模板引擎把 templates/ 下每个 .yaml 文件里的 {``{ .Values.xxx }} 替换成实际值,最终输出一份完整的 Kubernetes 资源清单。
Chart 目录结构(标准)
mychart/
├── Chart.yaml # 元数据:名称、版本(appVersion vs version)、依赖
├── values.yaml # 默认值------所有可配置项的"基线"
├── templates/
│ ├── _helpers.tpl # 共用模板片段(命名约定、标签集、公共逻辑)
│ ├── deployment.yaml
│ ├── service.yaml
│ ├── ingress.yaml
│ ├── configmap.yaml
│ ├── hpa.yaml
│ ├── NOTES.txt # 安装后提示信息
│ └── tests/
│ └─ test-connection.yaml
├── templates/partials/ # 可选:拆分大模板的子片段
├── .helmignore # 打包排除列表
└── crds/ # CRD 定义(安装前自动加载)
关键区分:
version: Chart 自身的打包版本,遵循 semver,每次改动必须递增。appVersion: 应用(镜像)的版本,是信息性字段,不参与 Helm 的版本计算。
1.2 Values 分层:默认值 → 組覆值 → 命令行覆值
Helm 的值合并遵循一个优先级链,高优先级覆盖低优先级:
优先级(从低到高):
1. Chart 内 values.yaml ← 基线默认值
2. Parent Chart 的 values.yaml ← 如果是子 Chart(subchart)
3. User 的 -f values-prod.yaml ← 環境/自定义覆盖文件
4. User 的 --set key=val ← 命令行覆盖(最高优先级)
5. User 的 --set-string key=val ← 同上,强制字符串类型
渲染时的合并逻辑是深度合并(dict 递归合并,list 整体替换):
yaml
# values.yaml (基线)
replicaCount: 1
resources:
requests:
cpu: 100m
memory: 128Mi
# values-prod.yaml (覆盖)
replicaCount: 3
resources:
requests:
cpu: 500m
# memory 未指定 → 保留基线 128Mi
# 渲染结果
replicaCount: 3
resources:
requests:
cpu: 500m
memory: 128Mi # 深度合并:未覆盖的 key 保留
踩坑预警 : list 类型不会逐项合并,而是整体替换。如果你在基线写了 tolerations: [key1, key2],覆盖文件写了 tolerations: [key3],结果是 [key3] 而不是 [key1, key2, key3]。所以 list 类型的 values 通常在基线设为空 [],让覆盖文件完整提供。
1.3 Helmfile:多 Chart 编排与环境差异化
当你有 10+ 个 Chart 要部署到 3 个环境(dev/staging/prod),纯 Helm 命令行已经不够用了。Helmfile 是一个声明式编排工具,让你用一个 helmfile.yaml 管理所有 Chart 的安装顺序、值覆盖和版本锁定。
Helmfile 值覆盖优先级(在 Helm 之上再加一层):
helmfile.yaml
├── repositories: # 声明 Chart 仓库源
├── releases:
│ ├── name: api-gateway
│ │ chart: stable/nginx-ingress
│ │ version: 4.11.3 # 锁定版本
│ │ values: # 覆盖文件列表(按顺序合并)
│ │ - values/base.yaml
│ │ - values/{{ .Environment.Name }}.yaml # 環境差异化
│ │ - values/api-gateway.yaml # 应用差异化
│ │ set: # 等同 --set
│ │ - clusterName={{ .Environment.Values.clusterName }}
Helmfile 的核心价值:
- 版本锁定 : 每个 release 指定
version,避免"最新版漂移"。 - 環境差异化 : 用
{``{ .Environment.Name }}模板变量自动选择覆盖文件。 - 依赖顺序 :
needs字段控制安装先后,比如先装数据库再装应用。 - 批量操作 :
helmfile sync一条命令同步所有 release 到目标状态。
1.4 Helm vs Kustomize:适用场景对比
┌──────────────────────────────────────────────────────┐
│ 对比维度 │
├──────────┬───────────────────┬───────────────────────┤
│ │ Helm │ Kustomize │
├──────────┼───────────────────┼───────────────────────┤
│ 模板方式 │ Go Template │ 纯 YAML patch/overlay │
│ 分发方式 │ 打包为 Chart │ 不打包,就地叠加 │
│ 版本管理 │ semver 仓库 │ Git commit 即版本 │
│ 值覆盖 │ values 分层 │ overlay 分层 │
│ 适用场景 │ 第三方/公共 Chart │ 内部应用/微调已有资源 │
│ 学习曲线 │ 较高(Go Template) │ 较低(纯 YAML) │
│ 生态 │ Artifact Hub 丰富 │ 原生 kubectl 支持 │
│ 复杂度 │ 适合完整应用包 │ 适合局部定制/补丁 │
└──────────┴───────────────────┴───────────────────────┘
选型建议:
- 安装第三方组件(Nginx Ingress/Prometheus/Redis Operator) → 用 Helm
- 内部微服务部署,只需要差异化覆盖 → 用 Kustomize 或 Helm+Kustomize 插件
- 多团队共享的应用模板 → 用 Helm Chart(标准化强)
- 一个集群里对同一组件多处微调 → 用 Kustomize overlay
- 两者可以共存: Helm 产出原始 YAML,Kustomize 再 patch
二、实战操作
2.1 企业级 Chart 模板开发
先创建一个规范的 Chart:
bash
helm create myapp
# 然后我们对生成的模板做规范化改造
Chart.yaml --- 元数据规范:
yaml
apiVersion: v2
name: myapp
description: 内部业务应用部署模板
type: application
version: 1.0.0 # Chart 打包版本,每次发布必须递增
appVersion: "2.4.1" # 应用镜像版本,信息性字段
kubeVersion: ">=1.28" # 支持的 K8s 版本范围
home: https://wiki.internal/myapp
maintainers:
- name: platform-team
email: platform@company.com
annotations:
category: BusinessApplication
license: Apache-2.0
templates/_helpers.tpl --- 共用模板片段(这是企业级 Chart 的灵魂):
gotemplate
{{/*
标准命名约定:release-name-chart-name
*/}}
{{- define "myapp.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 "myapp.labels" -}}
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{ include "myapp.selectorLabels" . }}
app.kubernetes.io/version: {{ .Values.image.tag | default .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: {{ .Chart.Name }}
{{- end }}
{{/*
选择器标签:Deployment/Service 的 selector 必须用这组
*/}}
{{- define "myapp.selectorLabels" -}}
app.kubernetes.io/name: {{ include "myapp.fullname" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{/*
公共注释块
*/}}
{{- define "myapp.annotations" -}}
{{- if .Values.commonAnnotations }}
{{ toYaml .Values.commonAnnotations }}
{{- end }}
{{- end }}
templates/deployment.yaml --- 使用 helpers 的 Deployment:
yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "myapp.fullname" . }}
labels:
{{- include "myapp.labels" . | nindent 4 }}
{{- with (include "myapp.annotations" .) }}
annotations:
{{- . | nindent 4 }}
{{- end }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
{{- include "myapp.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "myapp.selectorLabels" . | nindent 8 }}
{{- with .Values.podLabels }}
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ include "myapp.fullname" . }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
- name: {{ .Chart.Name }}
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
env:
{{- range $key, $value := .Values.extraEnv }}
- name: {{ $key }}
value: {{ $value | quote }}
{{- end }}
ports:
- name: http
containerPort: {{ .Values.service.port }}
protocol: TCP
livenessProbe:
{{- toYaml .Values.livenessProbe | nindent 12 }}
readinessProbe:
{{- toYaml .Values.readinessProbe | nindent 12 }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.topologySpreadConstraints }}
topologySpreadConstraints:
{{- toYaml . | nindent 8 }}
{{- end }}
tolerations:
{{- toYaml .Values.tolerations | nindent 8 }}
2.2 Values 分层实战
values.yaml --- 基线默认值(保守配置):
yaml
replicaCount: 1
image:
repository: registry.internal/company/myapp
pullPolicy: IfNotPresent
tag: ""
imagePullSecrets: []
nameOverride: ""
fullnameOverride: ""
serviceAccount:
create: true
annotations: {}
name: ""
podSecurityContext:
runAsNonRoot: true
runAsUser: 1000
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
service:
type: ClusterIP
port: 8080
ingress:
enabled: false
className: ""
annotations: {}
hosts: []
tls: []
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
livenessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /ready
port: http
initialDelaySeconds: 5
periodSeconds: 5
autoscaling:
enabled: false
minReplicas: 1
maxReplicas: 10
targetCPUUtilizationPercentage: 80
tolerations: []
affinity: {}
topologySpreadConstraints: []
extraEnv: {}
commonAnnotations: {}
podLabels: {}
values-prod.yaml --- 生产环境覆盖:
yaml
replicaCount: 3
image:
pullPolicy: Always
tag: "2.4.1"
service:
type: ClusterIP
ingress:
enabled: true
className: nginx
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
hosts:
- host: myapp.company.com
paths:
- path: /
pathType: Prefix
tls:
- secretName: myapp-tls
hosts: [myapp.company.com]
resources:
requests:
cpu: 500m
memory: 256Mi
limits:
cpu: "1"
memory: 1Gi
autoscaling:
enabled: true
minReplicas: 3
maxReplicas: 20
targetCPUUtilizationPercentage: 70
tolerations:
- key: dedicated
operator: Equal
value: production
effect: NoSchedule
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchExpressions:
- key: app.kubernetes.io/name
operator: In
values: [myapp]
topologyKey: kubernetes.io/hostname
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app.kubernetes.io/name: myapp
values-staging.yaml --- 预发环境覆盖:
yaml
replicaCount: 2
image:
tag: "2.4.1-rc1"
ingress:
enabled: true
className: nginx
hosts:
- host: myapp-staging.company.com
paths:
- path: /
pathType: Prefix
resources:
requests:
cpu: 200m
memory: 128Mi
limits:
cpu: 500m
memory: 256Mi
安装命令:
bash
# 开发环境(只用默认值)
helm install myapp ./mychart -n dev
# 预发环境(覆盖)
helm install myapp ./mychart -n staging -f values-staging.yaml
# 生产环境(覆盖+命令行微调)
helm install myapp ./mychart -n prod \
-f values-prod.yaml \
--set replicaCount=5 \
--set image.tag=2.4.1-hotfix
2.3 Helmfile 编排多 Chart 多环境
helmfile.yaml:
yaml
repositories:
- name: bitnami
url: https://charts.bitnami.com/bitnami
- name: internal
url: https://harbor.internal/chartrepo/platform
helmDefaults:
timeout: 600
wait: true
createNamespace: true
environments:
dev:
values:
- environments/dev.yaml
staging:
values:
- environments/staging.yaml
prod:
values:
- environments/prod.yaml
releases:
- name: redis
chart: bitnami/redis
version: 18.2.1
namespace: middleware
values:
- values/redis-base.yaml
- values/redis-{{ .Environment.Name }}.yaml
needs:
- middleware/ns # 先确保 namespace 存在
- name: api-gateway
chart: internal/api-gateway
version: 2.1.0
namespace: {{ .Environment.Values.gatewayNamespace }}
values:
- values/api-gateway-base.yaml
- values/api-gateway-{{ .Environment.Name }}.yaml
needs:
- middleware/redis
- name: myapp
chart: internal/myapp
version: 1.0.0
namespace: {{ .Environment.Values.appNamespace }}
values:
- mychart/values.yaml
- mychart/values-{{ .Environment.Name }}.yaml
needs:
- {{ .Environment.Values.gatewayNamespace }}/api-gateway
set:
- name: clusterName
value: {{ .Environment.Values.clusterName }}
environments/prod.yaml:
yaml
clusterName: prod-cluster-01
gatewayNamespace: gateway
appNamespace: production
environments/dev.yaml:
yaml
clusterName: dev-cluster-01
gatewayNamespace: dev-gateway
appNamespace: dev
使用:
bash
# 同步所有 release 到 prod 環境的目标状态
helmfile -e prod sync
# 只更新 myapp
helmfile -e prod -l name=myapp sync
# 查看将要做的变更(dry-run)
helmfile -e prod diff
# 销毁所有 release
helmfile -e prod destroy
2.4 私有仓库:Harbor 作为 Helm Chart 仓库
Harbor 从 2.0 开始原生支持 Helm Chart 仓库(不需要 ChartMuseum)。
bash
# 添加 Harbor 作为 Helm 仓库
helm repo add internal https://harbor.internal/chartrepo/platform \
--username admin --password Harbor12345
# 搜索 Chart
helm search repo internal/
# 推送本地 Chart 到 Harbor
helm package mychart/
helm push myapp-1.0.0.tgz oci://harbor.internal/platform/myapp
# OCI 方式(推荐,K8s 1.30 时代 Helm 3.14 默认支持 OCI)
helm pull oci://harbor.internal/platform/myapp:1.0.0
helm install myapp oci://harbor.internal/platform/myapp:1.0.0 -n prod
2.5 CI/CD 集成:helm diff + helm secrets
helm diff --- 变更预览(上线前必看):
bash
# 安装插件
helm plugin install https://github.com/databus23/helm-diff
# 升级前预览差异
helm diff upgrade myapp ./mychart -n prod -f values-prod.yaml
# 只看变更的资源(不看未变的)
helm diff upgrade myapp ./mychart -n prod -f values-prod.yaml --show-only-changed
helm secrets --- 加密敏感 values:
bash
# 安装插件
helm plugin install https://github.com/jkroepke/helm-secrets
# 加密 values 中的敏感字段
helm secrets enc values-prod.yaml
# 生成 values-prod.yaml.dec(解密文件),values-prod.yaml 中敏感字段被 sops 加密
# 安装时自动解密
helm secrets install myapp ./mychart -n prod -f values-prod.yaml
# helmfile 集成
# helmfile.yaml 中 values 写法不变,helmfile 自动调用 helm-secrets 解密
三、踩坑与排查
踩坑 1:values 覆盖后 selector 标签不一致,Deployment 滚动更新卡死
现象 : 用 -f values-prod.yaml 覆盖了 podLabels,升级后新 Pod 无法 Ready,旧 Pod 一直保留,最终超时。
bash
# 查看事件
kubectl describe deploy myapp -n prod
# 输出:
# Warning ReplicaSetCreateFailure deployment controller can't find matching pods
原因 : Deployment 的 selector.matchLabels 是不可变字段 ,创建后不能改。如果你在 _helpers.tpl 里让 selector 受 podLabels 影响,覆盖后新旧 selector 不一致,Deployment 控制器匹配失败。
解决 : selector 标签永远只用 selectorLabels(不含用户覆盖的 podLabels),podLabels 只加到 template.metadata.labels:
gotemplate
spec:
selector:
matchLabels:
{{- include "myapp.selectorLabels" . | nindent 6 }} # 固定不变
template:
metadata:
labels:
{{- include "myapp.selectorLabels" . | nindent 8 }} # 必须包含 selector 全部标签
{{- with .Values.podLabels }} # 用户额外标签
{{- toYaml . | nindent 8 }}
{{- end }}
踩坑 2:Chart 升级后旧 Release 残留资源未清理
现象 : helm upgrade 后,旧的 ConfigMap/Secret 还在,新 Pod 读到了旧配置,行为异常。
原因 : Helm 只管理 templates/ 里当前版本存在的资源。如果你在 v2.0.0 的 Chart 中删除了某个 ConfigMap 模板,Helm 升级时不会自动删除它------Helm 不会跟踪"已删除的模板"。
解决:
bash
# 方法 1:手动清理
kubectl delete configmap myapp-old-config -n prod
# 方法 2:使用 helm-hooks 在升级前清理
# 在 templates/ 里加一个 pre-upgrade hook:
yaml
apiVersion: v1
kind: Job
metadata:
name: {{ include "myapp.fullname" . }}-cleanup
annotations:
"helm.sh/hook": pre-upgrade
"helm.sh/hook-weight": "-5"
"helm.sh/hook-delete-policy": hook-succeeded
spec:
template:
spec:
containers:
- name: cleanup
image: bitnami/kubectl:1.30
command:
- kubectl
- delete
- configmap
- myapp-old-config
- --ignore-not-found
- -n
- {{ .Release.Namespace }}
restartPolicy: Never
蹈坑 3:helm list 显示 RELEASE NOT FOUND,但资源还在集群里
现象 : helm list -n prod 看不到 release,但 kubectl get all -n prod 资源都在。
原因 : Helm 的 release 信息存储在 Secret 里(helm.v2 或 helm.v3 格式)。如果有人手动删除了这些 Secret, Helm 就"失忆"了。
bash
# 查看 Helm 存储的 release Secret
kubectl get secrets -n prod -l owner=helm
# 找到被删的 Secret 名称
解决:
bash
# 方法 1:用 helm --force 重新安装(会重建 release Secret)
helm install myapp ./mychart -n prod --replace
# 方法 2:从集群资源重建(最安全的做法)
# 先把现有资源导出,再手动构造 release Secret
# 实际生产中建议:永远不要手动删除 Helm 的 release Secret!
蹈坑 4:Helmfile sync 时 needs 依赖顺序不生效
现象 : helmfile sync 先装了 myapp,redis 还没 Ready,myapp 启动失败连不上数据库。
原因 : needs 只保证安装顺序 ,不保证依赖资源已经 Ready。Helmfile 发起 install 后就继续下一个,不等健康检查完成。
解决 : 在 helmDefaults 中设置 wait: true,让每个 release 等到所有资源 Ready 再继续:
yaml
helmDefaults:
wait: true
timeout: 600
或者在 values 里设置 initContainers 等待依赖:
yaml
initContainers:
- name: wait-for-redis
image: busybox:1.36
command: ['sh', '-c', 'until nc -z redis.middleware 6379; do echo waiting; sleep 2; done']
四、最佳实践
Chart 开发规范清单
- 命名规范 : 资源名用
_helpers.tpl的fullname函数,不超过 63 字符,不含大写和特殊字符 - 标签规范 : 所有资源必须携带
myapp.labelshelper,selector 必须用selectorLabels(不可变) - values 规范 : 基线 values.yaml 配置保守(1 replica,小资源),所有可配置项都声明默认值(list 设为
[]) - 模板规范 :
_helpers.tpl必须包含 fullname/labels/selectorLabels,大模板拆到partials/目录 - CRD 规范 : CRD 放在
crds/目录,Helm 安装前自动加载,升级时不更新(CRD 不可变) - Hook 规范 : pre-install/post-install/pre-upgrade/post-upgrade 按需使用,必须设置
hook-delete-policy - 测试规范 :
templates/tests/下放 Pod 测试,helm test自动运行
Helm 仓库治理规范框架
| 治理维度 | 规范 | 工具/机制 |
|---|---|---|
| 版本号 | 严格 semver,禁止 -rc/-hotfix 后缀进仓库 | Chart.yaml version 字段 + CI 校验 |
| 兼容性 | kubeVersion 字段声明最低版本 | helm lint --with-subcharts |
| 安全 | Chart 内容扫描(镜像 CVE/rbac 权限) | trivy scanner / helm charttesting |
| 签名 | OCI Chart 签名(cosign) | helm push --sign |
| 审批 | Chart 上仓库前必须 PR Review + lint pass | GitHub CI + helm lint |
| 保留 | 仓库只保留最近 N 个版本(防止膨胀) | Harbor 保留策略 / 自动清理 |
| 分类 | 按 team/project 划分 project | Harbor project 权限隔离 |
Values 分层最佳实践
- 基线 values.yaml 只放安全默认值,不放环境特定值
- 每个环境一个覆盖文件:
values-{env}.yaml,命名一致 - 命令行
--set只用于临时调试,不上生产 - 敏感值用
helm-secrets加密,不入 Git - 覆盖文件也入 Git,但
.dec解密文件不入库 - list 类型 values 在基线设为空
[],覆盖文件完整提供 - 用
helm template本地渲染验证,不直接helm install
五、小结
Helm 从"安装工具"进化到"包管理基础设施",关键在三件事:规范的 Chart 模板 (helpers/labels/selector 做对)、分层的 values 管理 (基线→覆盖→命令行优先级链搞清)、仓库治理(semver 版本+审批+签名+清理)。再加上 Helmfile 编排多环境、helm diff 做变更预览、helm secrets 加密敏感值,你就有了从 dev 到 prod 的完整部署管线。Helm 和 Kustomize 不是互斥的------第三方 Chart 用 Helm 安,内部应用用 Kustomize 微调,两者共存才是生产常态。
思考题
- 如果你的团队有 50 个内部微服务,每个只需要镜像 tag 和 replica 数不同,你会选 Helm 还是 Kustomize?为什么?
- Helm 的 OCI 仓库模式(推到 Harbor OCI registry)相比传统 ChartMuseum HTTP 仓库,有什么优势和风险?
- 一个 Chart 的
version和appVersion什么时候应该同时递增?什么时候只递增version?