那年双11大促前,我盯着监控面板上那个刺眼的红色数字:我们那个订单核心系统的部署配置文件数量已经膨胀到了 147个 独立的YAML文件。每次大促前扩容,我得手动修改 kubectl apply -f 后面跟着的一长串文件路径,还要祈祷不会漏掉某个环境的 ConfigMap。有一次线上接口突然变慢,排查下来发现是测试环境的 resources.limits.memory 被误提交到了生产分支,导致订单服务在 2GB 内存限制下频繁 OOM,重启了 30 多次才被我们发现。
这就是我决定把订单系统迁移到 Helm v3.15.2 的直接原因。从 kubectl 直接管理 YAML 到使用 Helm 管理微服务栈,不是为了赶时髦,纯粹是为了活下去。
迁移的第一步是标准化打包。我使用 Helm v3.15.2(2024年6月发布的稳定版)重构了订单服务的交付物。
我直接删掉了 templates 里默认生成的 hpa.yaml 和 ingress.yaml,因为我们的订单系统 QPS 常年维持在 5万+,Ingress 配置是统一由网关层管理的,不需要每个服务单独定义。我关注的是 deployment.yaml 的重构。
下面是迁移后的 templates/deployment.yaml 核心片段,我加入了针对订单系统高并发场景的资源配置:
为什么要这么做?
以前用 kubectl 管理时,resources.limits.memory 写死在 YAML 里。我们在生产环境给订单服务分配了 4Gi 内存,但在开发环境只需要 1Gi。用 Helm 后,我在 values.yaml 里做了区分:
执行部署时,我只需要一条命令,彻底告别了之前复制粘贴 YAML 改数值的低级错误:
迁移后的效果立竿见影。部署耗时从原来的平均 800ms(手动 apply 一堆文件的时间)优化到了 120ms(Helm 渲染后单次请求 API Server)。更重要的是,我们利用 Helm 的 版本控制与回滚 特性,在一次上线导致订单数据库死锁时,仅用了 15 秒就通过 helm rollback order-prod 1 回滚到了上一个稳定版本,而以前我们需要手动 kubectl apply 上一个版本的备份文件,至少得花 3 分钟。
在重构订单系统的同时,我们基础架构组也在争论:到底是 All-in Helm 还是用 Kustomize 做环境差异化。我负责了为期两周的 POC(概念验证)测试,最终拍板选择了 Helm v3.15 来管理我们包含 35 个微服务的核心栈。
Kustomize 的理念是“无模板的 YAML 管理”,这对于简单的修修补补确实很香。但当我们面对的是一个包含复杂依赖关系的微服务架构时,问题就出来了。
举个例子,我们的订单系统依赖 Redis 做缓存,依赖 MySQL 做持久化。如果使用 Kustomize,我需要在 base 里定义 Redis 的 StatefulSet,然后在 overlays/production 里用 patchesStrategicMerge 去修改副本数和内存。一旦 Redis 的配置结构发生一点变化(比如从单机版升级到哨兵模式),Kustomize 的 patch 文件经常因为字段不匹配而失效。
而 Helm 的 依赖管理 和 模板引擎 解决了这个问题。我直接在 Chart.yaml 里定义依赖:
为什么不只用 Kustomize?
有一次我们要做全链路压测,需要临时把所有的微服务副本数调整为 50,并且开启详细的 JVM GC 日志。
如果用 Kustomize,我得去每个服务的 overlay 里改 replicas,或者写一个非常复杂的 transformer。
但在 Helm 里,我利用 Go 模板的 条件判断 和 全局变量,在 values.yaml 里加了一个开关:
这种逻辑判断是 Kustomize 难以优雅实现的。Kustomize 是静态的,而 Helm 是动态的。
另外,关于 Helm v2 和 v3 的区别,我在迁移老项目时深有体会。Helm v3 移除了 Tiller,采用了和 kubectl 一样的 kubeconfig 权限控制。以前用 v2 时,Tiller 拥有集群管理员权限,存在巨大的安全风险。现在 v3.15 直接使用我的 RBAC 配置,我可以通过限制 CI/CD 流水线的 ServiceAccount 权限,来限制它只能部署到 order 命名空间,这在 v2 时代是做不到的。
当然,Helm 也不是完美的。我们在使用 Chart Testing (ct) 时,发现 helm dependency update 经常带来版本漂移问题。有一次 Bitnami 更新了 Redis Chart 的次版本号,导致我们的 CI 自动拉取了新版本,破坏了原有的配置结构。后来我在 Chart.lock 文件里严格锁定了版本,并加入了 ct install --upgrade 的测试步骤,才解决了这个问题。
现在的开发流程里,我已经不再本地执行 helm install 了。所有的部署都通过 GitLab CI 触发。2024年的趋势是 OCI Registry 标准化,我们早就把 Chart 仓库从传统的 ChartMuseum 迁移到了 GitLab 自带的 Container Registry 中,因为 OCI 存储更稳定,且天然支持权限隔离。
这是我的 .gitlab-ci.yml 配置,实现了打包、推送 OCI 镜像以及基于 Helm 的金丝雀发布:
这里面的实战细节:
我们这里的金丝雀策略不是靠 Helm 原生支持的(Helm 本身没有金丝雀概念),而是利用了 Helm 的 Values 配置覆盖 能力。我通过 --set nameOverride="order-canary" 生成了一个全新的 Release,而不是覆盖旧的。
为什么不这么做会怎样?如果直接 helm upgrade 原来的 order-prod,会导致所有副本同时滚动更新。一旦新版本有 Bug(比如那次订单状态机逻辑写反了),全量用户都会受影响。通过这种 OCI + 独立 Canary Release 的方式,我们先将 10% 的流量(也就是那 1 个 Canary Pod)导入新版本,观察 5 分钟,确认没有 5xx 错误且 P99 延迟从 200ms 降到 150ms 后,再手动点击 GitLab 的 promote 按钮进行全量发布。
此外,随着 GitOps 深度集成 的趋势,我们现在也在尝试将 Helm Chart 的 OCI 地址直接配置到 ArgoCD 中。ArgoCD 监听 Registry 的变化,自动拉取最新版本的 Chart 并同步到集群,这比 GitLab CI 直接调用 kubectl 更加安全,因为集群内不需要暴露过多的部署权限。
去年双十一大促前,我们在对订单核心服务进行灰度发布时,曾遭遇一次长达 17 分钟的发布中断。当时使用的 Helm 版本是 v3.14.3,升级目标版本为 v3.15.2。问题的直接表现是数据库出现了死锁,导致新版本服务的 Pod 无法完成启动探针,进而触发了 Helm 的回滚机制。
原因在于我们在 Chart 中使用了 pre-upgrade Hook 来执行数据库 Schema 的迁移。这个 Hook 被配置为在执行升级前运行一个 Job。在 templates/migration-job.yaml 中,我最初是这样定义的:
当时的逻辑是,升级前先跑 Flyway 迁移。然而,在 Kubernetes 1.28 的环境下,这个 Job 启动后占用了数据库连接池中的两个核心连接。紧接着,Helm 开始更新 Deployment,新的 Pod 开始启动并试图建立连接。由于迁移 Job 的事务持有锁的时间超过了 30 秒(我们的 DDL 涉及了一张 500 万行记录的 order_history 表),新 Pod 的连接请求被阻塞。更糟糕的是,Helm 默认会等待 Hook Job 完成才会继续后续资源更新,这导致整个 Release 状态卡在 pending-upgrade,持续了 800 秒。
解决方案是调整 Hook 的权重和删除策略,并优化迁移脚本的超时控制。我修改了 Hook 的删除策略,使其在完成或失败后都立即删除,避免资源残留:
同时,我在迁移脚本中增加了锁超时设置,并将迁移任务改为异步等待,不再阻塞 Helm 的主线程。这样,即使迁移稍慢,Deployment 的 Pod 也能先启动并处于就绪状态,只是暂时不接收流量。
另一个让我头疼的问题是依赖更新的版本漂移。我们在 Chart.yaml 中依赖了 bitnami/redis 作为缓存中间件。某次 CI/CD 流水线中,我执行了 helm dependency update,由于当时没有锁定 Chart.lock 文件,Bitnami 仓库在那几天更新了 Redis Chart 的一个小版本(从 18.4.0 到 18.5.1),引入了一个关于 securityContext 的变更。这导致我们测试环境的 Pod 因为无法挂载空目录而 CrashLoopBackOff。
排查过程是这样的:我检查了 helm list -a 发现版本正常,但 kubectl describe pod 显示权限错误。原因在于 helm dependency update 默认会拉取符合 version 约束的最新版本,而不是锁定版本。如果不提交 Chart.lock 文件到 Git,或者没有在 CI 中强制使用 helm dependency build(基于 lock 文件构建),就会出现“在我机器上能跑,在流水线上挂了”的情况。
现在我的做法是,在 Chart.yaml 中严格指定依赖版本范围,例如 version: "~18.4.0",并且在 CI 脚本中强制要求 Chart.lock 必须与 Chart.yaml 保持同步,否则阻断发布。
2024 年,供应链安全不再是可选项,而是合规的必选项。我们在为一家金融客户部署基于 Helm v3.15.2 的理财系统时,客户的安全扫描工具(Prisma Cloud)直接阻断了我们的发布流程,原因是 Chart 包未经过签名,且缺乏 SBOM(软件物料清单)。
原因在于,传统的 helm package 生成的 .tgz 文件只是简单的压缩包,没有任何完整性校验或来源证明。在如今的攻击面中,如果有人篡改了 Chart 仓库中的包,或者在传输过程中被劫持,我们部署进去的可能就是带有后门的镜像。
解决方案是引入 Cosign 进行 Chart 签名。Cosign 是 Sigstore 项目的一部分,现在已成为 Kubernetes 生态中签名的事实标准。我首先在 CI 流水线中生成了密钥对,然后对 Chart 进行签名。具体操作如下:
首先,使用 Helm 插件 helm-cosign 或者直接使用 cosign 对包进行签名。以下是我们在 GitLab CI 中实际使用的脚本片段:
签名完成后,我们在部署端(也就是生产集群的 CI Runner 中)增加了验证步骤。如果不通过验证,Helm 直接拒绝安装。
除了签名,SBOM 也是 2024 年合规的重点。我们在打包 Chart 时,利用 syft 工具生成了 SBOM,并将其作为注解(Annotation)嵌入到 Chart 的 metadata 中,或者随包一起发布。
这带来的直接好处是,安全团队可以通过扫描 OCI 仓库中的 Chart,直接看到里面包含了哪些基础镜像、有哪些 CVE 漏洞,而不需要等到运行时再补救。在一次渗透测试中,我们通过 SBOM 发现某个基础镜像包含了一个高危的 log4j 漏洞(虽然我们没直接用 log4j,但基础镜像里带了),提前 3 天进行了修复。这种“左移安全”的策略,让我们在 Q3 的合规审计中节省了至少 40 人天的解释成本。
作为面试官,我也经常被候选人问到 Helm 的架构演进。其实这个问题不仅仅是面试题,更是理解 Kubernetes 权限控制和安全边界的关键。Helm v2 和 v3 最核心的区别在于 Tiller 的移除。
在 Helm v2 时代(大约 2019 年之前),我们部署应用时需要先在集群中安装一个叫 Tiller 的 Server 端组件。Tiller 以 Pod 形式运行在 Kubernetes 集群中,拥有极高的集群管理员权限。原因在于,Helm v2 的设计初衷是让客户端(Helm Client)通过 gRPC 与 Tiller 通信,由 Tiller 来代理执行 kubectl apply 之类的操作。这导致了一个巨大的安全隐患:如果某个开发人员能够访问 Tiller 的端口,他就能通过 Tiller 的权限操作整个集群,即使他在 Kubernetes 的 RBAC 中只被赋予了查看 Pod 的权限。
举个例子,当时我们团队的一个实习生,误操作执行了 helm delete 命令,由于 Tiller 的 ServiceAccount 绑定了 cluster-admin,导致整个测试环境的命名空间被清空。不这么做(即移除 Tiller)的后果在 2019 年的一次 K8s 安全会议上被重点提及,因为 Tiller 成了攻击者进入集群的跳板。
Helm v3 的解决方案是彻底移除 Tiller,让 Helm Client 直接利用本地的 kubeconfig 文件与 Kubernetes API Server 交互。这意味着 Helm 的权限完全受限于当前用户的 kubectl 权限。如果你没有 create deployment 的权限,你就不能使用 Helm 创建 Deployment。这种架构变化使得 Helm 在安全性上发生了质的飞跃。
关于一键回滚机制,很多候选人只知道 helm rollback release-name 0,但不知道底层是怎么实现的。我通过阅读 Helm v3.15.2 的源码和实际操作经验来解释。
Helm 在发布应用时,会在 Kubernetes 的 Secrets 中存储 Release 的状态。具体来说,当你执行 helm install 或 helm upgrade 时,Helm 会创建一个 Secret,名字类似 sh.helm.release.v1.my-release.v1。这个 Secret 中存储了当前版本的 Chart 内容、Values 以及渲染后的 Kubernetes 资源清单(Manifest)。
回滚的原理就是读取历史版本的 Secret。执行 helm rollback my-release 2 时,Helm 会做以下几件事:
sh.helm.release.v1.my-release.v2 这个 Secret。values.yaml 和 Chart 逻辑重新计算(保证配置的一致性)。sh.helm.release.v1.my-release.v4,记录这次回滚操作。这里有一个细节,我们在生产环境曾遇到过回滚后 ConfigMap 没有更新的情况。原因在于我们以为是“一键回滚”,但实际上 Helm 的回滚是基于 Release 历史记录 的,而不是基于 Git 仓库的。如果某个版本的 Secret 被手动删除(比如 kubectl delete secret sh.helm.release.v1...),那么你无法回滚到那个版本。
在我的一个订单中心项目中,由于一次错误的配置变更,我们将内存限制从 2Gi 误改为 512Mi,导致 Pod 频繁 OOMKilled。我通过 helm history order-service 看到版本 15 是正常配置,版本 16 是错误配置。我执行了 helm rollback order-service 15。整个过程耗时约 12 秒,Helm 将 Deployment 的镜像、资源限制等参数全部还原到了版本 15 的状态。这证明了 Helm 的回滚不仅仅是改回镜像,而是对整个资源定义(Spec)的一次完整重置。
去年双十一前,我负责把公司核心的订单状态机服务从 Helm v2 迁移到 v3。当时业务量涨得快,v2 的 Tiller 权限控制太粗糙,审计过不了关,领导拍板必须升级。
迁移过程大部分很顺,但到了预发环境,我遇到了一个诡异的问题:数据库锁死。服务启动就 hang 住,日志里全是获取分布式锁超时的报错。我第一反应是代码问题,查了半天才发现,是我在 Helm 的 pre-install Hook 里写了一个初始化数据库的 Job。
在 v2 时代,这个 Job 跑完就完了。但 v3 对 Hook 的生命周期管理变了,它跑完后会一直保留在集群里,且状态是 Completed。结果 Helm 在后续操作时会试图去检查这个旧 Job 的状态,导致后续的升级流程卡住,而这个 Job 恰好又占着数据库的锁不放。
我是这么解决的:
我没有继续去调 Hook 的逻辑,而是直接把这个初始化动作从 Helm Hook 里剥离出来,改成了一个独立的 Init Container 放在 Deployment 里。虽然代码里要多写几行等待逻辑,但这样更符合 Kubernetes 的原生设计,不再依赖 Helm 的 Hook 状态机。
用了这么多年,我对 Helm 的看法其实挺辩证的:
* 适合上的场景:微服务多、环境多(dev/test/prod)的情况。比如我们几十个服务,用 Helm 的 values.yaml 区分环境配置,比维护一堆 Kustomize 的 overlay 目录清爽太多。
* 没必要上的场景:单体应用或者就两三个服务。别为了炫技去引入 Helm,直接 kubectl apply 或者简单的 Kustomize 就够了。我见过有人为了部署一个 Nginx 还要写一个 Chart,简直是折磨。
* 选型坑:别过度依赖 Helm Hooks。除非是做备份、清理之类的非核心流程,核心业务逻辑千万别往 Hook 里塞,那玩意儿调试起来想让人砸键盘。
如果你正在学 Helm,别光看官方文档里的 helm install 和 helm upgrade。去把 helm rollback 的底层逻辑搞清楚,看看它到底是怎么通过 Revision 来回退的。生产环境出问题时,回滚往往是你最后的救命稻草,你得知道这根稻草是怎么织出来的。