1. 从一次发布事故说起:为什么需要 Hook
很多人第一次接触 Helm 的 Hook,都是在一次“莫名其妙”的发布失败之后。明明helm upgrade返回了成功,可业务就是起不来——数据库迁移没跑、配置没生效、依赖的中间件还没就绪。翻日志才发现,Helm 把资源一股脑全丢给集群就撒手不管了,它默认只关心“资源对象有没有被 API Server 接受”,根本不关心“这些资源是不是真的能干活”。
这就是 Hook 存在的意义。Helm Hook 允许你在 release 生命周期的特定时间点插入额外的操作,比如在安装主资源之前先跑一个数据库迁移 Job,在升级之前先做一次数据备份,在删除 release 之前先清理外部依赖。它本质上是给 Helm 的“声明式部署”补上了一层“命令式编排”的能力。
我见过太多团队把 Hook 当成一个“高级特性”束之高阁,结果每次上线都靠人工手动执行脚本,既容易漏步骤,又没法回滚。其实 Hook 的机制并不复杂,难点在于理解它的执行时机、失败语义和清理策略。这篇文章就把我这些年踩过的坑、总结出来的用法,一次性讲清楚。
适合阅读的人群:已经用过 Helm 做过基本部署、但对 Hook 的边界和坑点还不太有把握的开发和运维同学。如果你还没写过 Chart,建议先补一下模板语法和 values 的基础,再来啃 Hook 会更顺。
2. Hook 的执行时机与生命周期映射
2.1 从 release 生命周期理解 Hook 的挂载点
Helm 把一个 release 的生命周期拆成了几个阶段,Hook 就是挂在这些阶段上的“钩子”。理解 Hook,先要理解这些阶段本身。
安装一个 release 时,Helm 大致经历:渲染模板 → 提交资源到集群 → 等待资源就绪 → 标记 release 为 deployed。升级时类似,但多了“对比新旧版本”的环节。删除时则是反向操作。Hook 可以插入到这些阶段的“之前”或“之后”。
Helm 目前支持的 Hook 类型有这么几种,我按使用频率排一下:
pre-install:安装主资源之前执行,最常用,适合做前置检查、数据库初始化。post-install:主资源全部就绪后执行,适合做冒烟测试、通知。pre-upgrade:升级前执行,适合做数据备份、兼容性检查。post-upgrade:升级后执行,适合做缓存刷新、验证。pre-delete:删除 release 前执行,适合做外部资源清理。post-delete:删除后执行。pre-rollback/post-rollback:回滚前后。test:helm test时执行,适合做集成测试。
这里有个容易混淆的点:pre-install和pre-upgrade是分开的,不是同一个钩子在不同场景下复用。也就是说,如果你希望“安装和升级前都跑一次数据库迁移”,你得写两个 Hook,或者用pre-install,pre-upgrade这种逗号分隔的写法同时挂载。
2.2 权重决定同一时机的执行顺序
同一个 Hook 类型下可以挂多个资源,比如两个 Job 都标了pre-install。这时候谁先跑?靠hook-weight注解控制,值越小越先执行,默认是 0。
我一般会留出间隔,比如前置检查用-10,数据迁移用0,缓存预热用10。这样即使后面加新 Hook,也有插入空间,不用去改已有资源的权重。
apiVersion: batch/v1 kind: Job metadata: name: {{ .Release.Name }}-db-migrate annotations: "helm.sh/hook": pre-upgrade "helm.sh/hook-weight": "0" "helm.sh/hook-delete-policy": before-hook-creation权重相同的情况下,Helm 按资源名称的字典序执行。这个细节很多人不知道,导致偶尔出现“顺序怎么和我想的不一样”的情况。所以要么显式指定权重,要么别依赖默认顺序。
2.3 Hook 资源与普通资源的本质区别
一个被标记为 Hook 的资源,和普通资源最大的区别在于:它不参与 release 的版本管理。也就是说,helm list看不到它,helm rollback不会回滚它,helm get manifest里也不包含它。
这个特性带来两个后果。好处是 Hook 资源不会污染 release 的 diff,升级时不会因为 Hook 资源的变化触发无意义的更新。坏处是 Hook 资源一旦创建,Helm 默认不会主动删除它,除非你配置了删除策略。这就是为什么很多人发现集群里堆了一堆pre-install的 Job,删都删不完。
所以hook-delete-policy这个注解几乎是必配的,后面会专门讲。
3. 删除策略:Hook 资源到底该不该留
3.1 四种删除策略的语义差异
helm.sh/hook-delete-policy支持四种值,可以组合使用:
| 策略值 | 含义 | 适用场景 |
|---|---|---|
hook-succeeded | Hook 执行成功后删除 | 一次性迁移、检查类 Job |
hook-failed | Hook 执行失败后删除 | 调试期保留失败现场,生产慎用 |
before-hook-creation | 创建新 Hook 前删除旧的 | 最常用,避免重名冲突 |
| 不设置 | 永不自动删除 | 需要保留记录的场景 |
我个人的默认选择是before-hook-creation,hook-succeeded。这样每次执行前先清掉上一次的残留,执行成功后再删掉自己,集群里不会堆积垃圾。只有在排查问题时,才会临时改成保留失败现场。
3.2 为什么 before-hook-creation 几乎是必选项
Helm 创建 Hook 资源时,如果同名资源已经存在,会直接报错。而 Job 这种资源的名字通常是固定的(比如{{ .Release.Name }}-db-migrate),第二次升级时就会撞名。
before-hook-creation的作用就是在创建新 Hook 之前,先把同名的旧 Hook 删掉。注意它的删除动作发生在“新 Hook 创建之前”,而不是“旧 Hook 执行之后”。这意味着如果上一次 Hook 还在运行中,这次升级会把它直接删掉,可能造成并发问题。
所以更稳妥的做法是:给 Hook 名字加上版本或时间戳后缀,或者确保 Hook 是短任务、不会长时间运行。对于可能跑很久的迁移 Job,我会在 Job 内部加锁,避免并发执行。
3.3 删除策略与失败重试的配合
这里有个反直觉的点:hook-failed和hook-succeeded是互斥的语义,但可以同时写。如果你写hook-succeeded,hook-failed,效果等同于“无论成功失败都删除”。
但生产环境我强烈建议不要在关键迁移 Hook 上配hook-failed。因为迁移失败时,你需要保留 Job 的 Pod 日志和状态来排查问题。如果失败就删了,你只能看到一句“Hook 执行失败”,什么线索都没有。
正确的做法是:失败时保留现场,人工介入排查,修复后重新执行。等确认没问题了,再手动清理,或者在下一次成功执行时靠before-hook-creation顺带清掉。
4. 实战:用 Hook 编排数据库迁移
4.1 场景拆解与方案选型
假设我们有一个 Web 应用,每次升级都需要先执行数据库 schema 迁移,迁移成功后才能滚动更新应用 Pod。这是一个非常典型的 Hook 使用场景。
方案上有几个选择:一是用pre-upgradeHook 跑一个 Job;二是用 initContainer 在应用 Pod 启动前跑迁移;三是用独立的迁移工具在 CI 里跑。
initContainer 的问题是每个 Pod 都会跑一次迁移,多副本时会并发冲突。CI 里跑的问题是迁移和应用发布脱节,回滚时不好协调。所以pre-upgradeHook 是最合适的:它在应用更新之前执行,只跑一次,失败会阻断整个升级。
4.2 迁移 Job 的完整模板
下面是一个我用了很久的迁移 Job 模板,做了几处关键处理:
apiVersion: batch/v1 kind: Job metadata: name: {{ .Release.Name }}-migrate-{{ .Release.Revision }} annotations: "helm.sh/hook": pre-install,pre-upgrade "helm.sh/hook-weight": "0" "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded spec: backoffLimit: 1 activeDeadlineSeconds: 600 template: spec: restartPolicy: Never containers: - name: migrate image: "{{ .Values.migrate.image }}:{{ .Values.migrate.tag }}" command: ["/bin/sh", "-c"] args: - | set -e echo "running migration for revision {{ .Release.Revision }}" ./migrate up env: - name: DB_HOST valueFrom: secretKeyRef: name: {{ .Release.Name }}-db key: host几个关键点解释一下。名字里带了{{ .Release.Revision }},这样每次升级都是新名字,天然避免撞名,before-hook-creation反而成了兜底。backoffLimit: 1表示失败后最多重试一次,避免无限重试把数据库搞乱。activeDeadlineSeconds: 600是硬超时,防止迁移卡死导致整个发布挂起。
4.3 迁移失败时的阻断与恢复
Hook 失败时,Helm 默认会中止整个 release 操作。安装时失败,release 会处于failed状态;升级时失败,release 会停在pending-upgrade状态。
这里有个大坑:如果升级 Hook 失败,release 会卡在pending-upgrade,此时你既不能再次升级,也不能回滚,必须先手动处理。处理方式是helm rollback到一个正常版本,或者用helm uninstall重来。
所以迁移 Job 一定要做好幂等性。同一个迁移脚本跑两次不能出错,否则一旦失败重试就会雪上加霜。我通常会在迁移工具层面加版本表,记录已执行的迁移,重复执行直接跳过。
提示:调试 Hook 时,可以加
--debug参数看 Helm 的详细日志,但真正的错误信息在 Job 的 Pod 日志里,记得用kubectl logs去看。
5. 那些年踩过的 Hook 坑
5.1 Hook 资源不参与 rollback 带来的状态漂移
这是最隐蔽的坑。假设你有一个post-upgradeHook 负责刷新缓存,升级到 v2 时它跑了,缓存刷新成功。然后你发现 v2 有问题,回滚到 v1。但回滚不会触发post-upgradeHook,缓存里还是 v2 的数据,应用读到的缓存和实际版本不一致。
解决办法是:把缓存刷新逻辑做成幂等的、可重复执行的,并且在应用启动时主动校验缓存版本。或者用post-rollbackHook 专门处理回滚后的清理。但post-rollback也不是万能的,它只在helm rollback时触发,如果是通过其他方式回退的,就管不到了。
我的经验是:Hook 只做“锦上添花”的辅助操作,核心状态一致性要靠应用自身保证。别把关键逻辑完全押在 Hook 上。
5.2 Hook 超时与 Helm 的等待机制
Helm 执行 Hook 时会等待它完成,等待时间由--timeout控制,默认 5 分钟。如果 Hook 是个长任务,比如大数据量的迁移,5 分钟根本不够,就会超时失败。
但这里有个细节:超时后 Helm 会认为 Hook 失败,但不会主动杀掉正在运行的 Job。Job 还在后台跑,Helm 已经报错了。这时候如果你重试,可能两个 Job 同时在跑,数据就乱了。
所以对于长任务,要么调大--timeout,要么在 Job 里设置activeDeadlineSeconds让它自己超时退出。我倾向于后者,因为--timeout是全局的,调大了会影响所有资源的等待时间。
5.3 多个 Hook 并发执行时的资源竞争
前面提到权重相同的 Hook 按名称字典序执行,但这是串行的。Helm 会等一个 Hook 完成再执行下一个。所以不用担心并发,但要担心总耗时。
如果你有 5 个pre-upgradeHook,每个跑 2 分钟,那升级前就要等 10 分钟。这时候可以考虑把能并行的逻辑合并到一个 Job 里,用脚本内部并行,而不是拆成多个 Hook。
另外,Hook 之间如果需要传递数据,只能通过外部存储(比如 ConfigMap、数据库),Helm 本身不提供 Hook 间的数据传递机制。这也是设计时要注意的。
6. 进阶:Hook 与其他机制的边界
6.1 Hook 与 initContainer 的分工
很多人纠结什么时候用 Hook,什么时候用 initContainer。我的判断标准很简单:看这个操作是“每个 Pod 都要做”还是“整个 release 只做一次”。
每个 Pod 都要做的,比如等待依赖服务就绪、生成本地配置,用 initContainer。整个 release 只做一次的,比如数据库迁移、全局锁获取,用 Hook。
还有一个维度是“失败的影响范围”。initContainer 失败只影响单个 Pod,其他 Pod 可能正常。Hook 失败会阻断整个 release。所以关键路径上的操作放 Hook,非关键的放 initContainer。
6.2 Hook 与 Job 的清理责任划分
Hook 资源本质上是普通资源加了注解,所以它的清理责任在 Helm 和用户之间是模糊的。Helm 只在特定策略下清理,其他情况要用户自己管。
我建议在 Chart 的 README 里明确写清楚:这个 Chart 会产生哪些 Hook 资源,它们的删除策略是什么,升级失败后需要手动清理哪些东西。别让接手的人一脸懵。
对于确实需要长期保留的 Hook 资源(比如审计日志),可以设置resource-policy: keep注解,这样helm uninstall时也不会删它。这个注解和hook-delete-policy是两套机制,别搞混了。
6.3 用 test Hook 做发布后验证
test类型的 Hook 比较特殊,它不在 install/upgrade 流程里自动执行,而是通过helm test <release>手动触发。适合做集成测试、连通性检查。
我通常会在 Chart 里放一个testHook,跑一个简单的 curl 检查服务是否可访问。CI 流程里在helm upgrade之后加一步helm test,通过了才算发布成功。这样比单纯看 Pod 是否 Running 靠谱得多。
apiVersion: v1 kind: Pod metadata: name: {{ .Release.Name }}-test annotations: "helm.sh/hook": test spec: restartPolicy: Never containers: - name: test image: curlimages/curl command: ["curl", "-f", "http://{{ .Release.Name }}-svc/healthz"]这个 Pod 跑完就退出,退出码为 0 表示测试通过。简单但有效。
7. 我总结的 Hook 使用清单
用了这么多年 Hook,我整理了一份自己的检查清单,每次写 Hook 前过一遍,能避开大部分坑。
第一,明确 Hook 类型。是 install 前还是 upgrade 前?两者不会自动复用,该写两个就写两个。
第二,设置权重。别依赖默认顺序,显式指定权重,留出间隔。
第三,配置删除策略。默认用before-hook-creation,hook-succeeded,关键迁移失败时保留现场。
第四,保证幂等。Hook 可能因为重试、超时、手动干预而重复执行,脚本必须能安全地跑多次。
第五,设置超时。Job 里加activeDeadlineSeconds,别让 Hook 无限期挂着。
第六,名字带版本。用{{ .Release.Revision }}或时间戳避免撞名。
第七,日志可查。Hook 失败时,错误信息在 Pod 日志里,确保日志能被采集到。
第八,文档写清楚。Chart 里有哪些 Hook,失败后怎么恢复,接手的人需要知道。
这份清单看起来啰嗦,但每一条背后都是一次真实的发布事故。Hook 这个机制本身不复杂,复杂的是它和 release 生命周期、资源清理、失败恢复之间的交互。把这些边界想清楚了,Hook 就能成为发布流程里最可靠的一环,而不是最让人头疼的一环。
最后分享一个我个人的习惯:每次写完 Hook,我都会在测试环境故意让它失败一次,看看 Helm 的报错信息是否清晰、资源是否残留、恢复流程是否顺畅。这个“破坏性测试”花不了几分钟,但能提前暴露很多问题。毕竟,Hook 的价值不在于顺利时它做了什么,而在于出问题时它能不能帮你兜住。