从刀耕火种到 Zarf:air-gap Kubernetes 软件交付踩坑实录
我第一次出差给用户交付 HAMi 企业版,Helm 安装到一半就撞上了五六个漏打包的镜像,只能 Ctrl+C 停下来排障。
这在普通环境里也许只是补一次下载,在有些传统 HPC 或 IDC 环境里却完全不是一回事:文件要先刻录到光盘,经过可信设备,再从内网传到集群主节点。偏偏那天 MacBook Pro 还认不出 USB 光驱,一下午就这么没了。
当时我们交付的是一份 10 GB 左右的压缩包和一本安装手册。后来我才意识到:我交付的不是代码,也不是 Helm Chart,而是一整套要在没有公网的集群里跑起来、升级和排障的环境。
那也是我第一次负责 air-gap 环境交付。这篇文章最初写于 Zarf v0.76.0。2026 年 8 月,项目升级到了 v0.82.0,旧文里一些绝对判断也被实践推翻了。这一版保留当时的事故和选择过程,再补上后来踩到的 values、CRD、镜像架构和制品校验问题。
刀耕火种时期
HAMi 企业版最早的离线交付物很朴素:一个压缩包,加一本安装手册。包里放工具、镜像、Chart、values 和脚本,现场按文档依次执行:
tar -xzf offline-installer.tar.gz
cd offline-installer
bash tools/install-helm.sh
bash tools/install-nerdctl.sh
bash load-all-images.sh
helm install prometheus ...
helm install gpu-operator ...
helm install hami ...
这个办法当然能工作,事实上也在几家客户那里完成过交付。它的问题不是「装不上」,而是把构建、分发和部署的复杂度原样传给了现场工程师,以及拿到压缩包的客户。
构建时:以为找全了镜像
为了找出所有 Helm Chart 依赖的镜像,前人用 AI 搓了一个脚本:先 render Chart,再搜索 image: 字段。然后就发生了开头那次事故。
漏五六个镜像已经够麻烦,更麻烦的是不知道还会不会继续漏。离线交付最怕的不是镜像多,而是构建成功让人误以为镜像已经齐了,直到现场的控制器开始创建工作负载,才发现包里少了最后一个。
分发时:研发方便,客户遭罪
公司当时有 GCP startup credit,离线包在 GCP VM 上构建,再走内网上传到对象存储。研发侧很方便,身处中国大陆的客户下载十几 GiB 文件却很痛苦:速度慢、连接容易断,当时甚至连 checksum 文件都没有。
文件越来越大以后,发一个小补丁也要重新上传整套软件栈。旧版完整交付物一度达到 17 GiB;我后来拆出完整包、Slim 包和独立示例,又写了 Makefile 和带重试的上传脚本。做到这一步,自动化已经不是为了优雅,只是因为我的脑子确实记不住那么多构建和上传命令。
构建完成后,upload-to-oss.sh 和 upload-to-r2.sh 会检查预期制品是否存在,跳过远端同名对象,再调用 ossutil 或 AWS CLI 上传。断点续传和底层重试交给客户端,至少能让我睡觉时继续跑;正式发布仍要另外核对本地与远端 SHA-256。presign-oss.sh 再生成一周有效的下载地址。当时阿里云 OSS Web 控制台只能生成最长 9 小时的地址,命令行工具却能签一周——这是什么用意呢?
安装时:顺序和耦合全写在文档里
离线包经历「七七四十九天」抵达客户的集群主节点后,九九八十一难才走过一半。
HAMi AI 平台不是一个 Chart,而是一组有前后关系的组件:
- HAMi scheduler 和 device plugin;
- NVIDIA GPU Operator 或 Ascend device plugin;
- Prometheus Operator CRD 与 kube-prometheus-stack;
- Gateway API、Envoy Gateway CRD 与 Envoy Gateway;
- HAMi AI 平台的 API、controller、UI 和数据库迁移。
依赖关系不只发生在 Pod 之间。prometheus 需要先有 Prometheus Operator CRD,envoy-gateway 需要先有 Gateway API 和 Envoy Gateway CRD,业务 Chart 还依赖固定的 release name、Namespace 和 values。早期版本里,release name 或 Namespace 只要和文档有一点出入,前端就可能看不到 GPU 指标。
在当时的流程里,只要集群不止一个节点,镜像还得先推到客户的 Registry,再逐个修改所有 Chart 的 values,把公网镜像地址换成内网地址。镜像漏一次,不只是补一个文件,还要重新核对整套镜像映射。
客户自己操作时,错误往往会连在一起:解压目录混入 macOS 特征文件、漏导入镜像、不理解多份 values 的合并结果、改坏默认 values、颠倒 CRD 和主 Chart 的安装顺序,或者把同一批资源交给两套 Helm 流程管理。
那时我真正要解决的已经不是「怎样把镜像带进内网」,而是「怎样把镜像、Chart、配置、顺序和检查绑定到同一个版本化制品」。文档可以解释复杂度,但不能约束执行过程。想让现场少出意外,只能把更多问题留在构建机和测试集群。
差点自研一套安装系统
接触 Zarf 前,我已经准备好做一套自定义 air-gap bundle:
- 外层使用
tar.gz; - 镜像保存为 OCI image layout;
- 用
manifest.yaml记录 Chart、镜像和 digest; - 包内携带
oras、helm、zot和yq; - 用
00-preflight.sh、10-install-prereqs.sh、20-install-engine.sh、90-upgrade.sh这类脚本编排顺序。
方案已经细到包名、双层 checksum、Registry 引导、备份恢复和升级验证。它并不差,问题是最后执行和维护这套系统的人也是我。
头两天我同时推进两条线:一条照着自研方案写脚本,一条验证 Zarf。差距很快就出来了。Zarf 线已经能创建 package 并跑通基本部署,手搓脚本还困在失败恢复和边界条件里。
我还发推问了圈子里的朋友。样本当然不大,但结果很有意思:真正做过离线交付的人大多在忙着手搓脚本,没做过的人反而觉得 Zarf 很新奇,想尝鲜。
我之前用 Helmfile 做过多集群 Chart 交付,它很适合开发环境和频繁迭代,但这次还要处理离线镜像收集、内网 Registry 引导和制品检查。为了让 Helmfile 也做这些事,我还是得继续写代码。
我给替代方案定的门槛很具体:两三条命令完成整套安装;能表达 Chart 和 CRD 的先后关系;原生支持部署前、部署后和失败后的 actions;镜像分发也不能再靠我维护另一套脚本系统。
代码就是负债,软件的维护成本通常比第一版开发成本更高。Zarf 已经把 package、镜像分发、Chart 生命周期和 hooks 放在同一个模型里,自研方案就没有继续做下去。
Zarf 接住了什么
Zarf 使用 zarf.yaml 描述 package。一个 package 可以包含镜像、Helm Chart、manifest、文件和 actions,创建后得到 .tar.zst 制品。实施人员不需要解压它,部署入口也从十几段命令变成 zarf init 和 zarf package deploy。
先解决 Registry 的鸡生蛋问题
完全离线的集群里有一个很别扭的自举问题:要在集群里运行 Registry,先得拿到 Registry 镜像;要把镜像送进集群,通常又需要一个 Registry。
Zarf init package 的 NodePort 模式会把 zarf-injector 和 registry:3 镜像分块放进 ConfigMap,再找一个集群中已经存在的镜像启动临时 Pod。体积很小的 Rust injector 把镜像重新拼起来,先提供临时 seed Registry;长期运行的 Zarf Registry 从这里拉起,最后再清理临时 Pod、Service 和 ConfigMap。
这个过程最吸引我的地方,是它把「目标环境没有任何 Registry」也纳入了交付模型,而不是留成安装手册开头的一句「请先准备私有镜像仓库」。
初始化命令很短:
./zarf-linux-amd64 init \
./zarf-init-amd64-v0.82.0.tar.zst \
--agent-mutation-policy=labeled \
--confirm
Agent 不只是替换 Registry host
application package 把镜像导入 Zarf Registry 后,Agent 会在 admission 阶段改写 PodSpec。对没有用 digest 锁定的镜像,它还会给 tag 追加由原始镜像名计算出的 CRC32:
ghcr.io/stefanprodan/podinfo:6.4.0
→ <zarf-registry>/podinfo:6.4.0-zarf-298505108
这样可以避免来自不同 Registry、路径和 tag 又相同的镜像在内网 Registry 里互相覆盖。原始地址会保存在 zarf.dev/original-image-<container-name> annotation 中。
v0.82.0 的 labeled policy 只改写显式标记 zarf.dev/agent: mutate 的 Namespace 或资源。当前 package 会在创建工作负载前设置所需标签;已有 Pod 不会因为后来补了标签而改变,需要重新创建才会再次经过 admission webhook。zarf.dev/agent: ignore 仍可排除单个资源或 Namespace。
我后来才知道怎样通过 EnvoyProxy.spec.provider.kubernetes.envoyDeployment.container.image 显式替换 Envoy 数据面镜像。在此之前,确实是 Zarf 稳稳地接住了我 🤡。
component 和 actions 把顺序放回 package
当前 package 把 HAMi、Prometheus、GPU Operator、Envoy Gateway 和 HAMi AI 平台拆成 component。--components 负责选择,部署顺序仍以 zarf.yaml 中的定义顺序为准。它不是一个可以随意重排依赖的参数。
component 里的 onDeploy.before、onDeploy.after 和 onDeploy.onFailure 用于 Namespace 标记、预检、部署后等待和失败诊断。这里也踩过一个反直觉的生命周期边界:同一 component 的 onDeploy.before 早于该 component 的文件释放,前置 action 不能调用同一 component 中尚未复制出来的脚本。现在我用独立的 hami-deploy-scripts component 先释放文件,再让后续 component 复用。
actions 减少了漏执行步骤,但还不是可靠门禁。当前预检脚本打印 WARN 或 FAIL 后仍返回 0,诊断脚本也依赖目标机工具。日志和验收结果仍要有人看。
最终交付物还是一个外层归档
以 v0.82.0、package v0.0.3 的 AI 平台完整版为例,外层目录大致如下:
hami-ai-platform-v0.0.3-airgap-amd64/
├── zarf-linux-amd64
├── zarf-init-amd64-v0.82.0.tar.zst
├── hami-ai-platform-v0.0.3-airgap-amd64.tar.zst
├── zarf-package-hami-example-vllm-qwen-amd64-v0.0.4.tar.zst
├── zarf-package-hami-example-gpu-burn-amd64-v0.0.2.tar.zst
├── collect-cluster-info.sh
├── collect-hami-license-info.sh
├── COLLECT-CLUSTER-INFO.md
├── hami/
└── kantaloupe/
collect-cluster-info.sh 和 collect-hami-license-info.sh 用于安装前后的环境与授权信息收集,hami/ 和 kantaloupe/ 则放需要随交付物一起提供的配置示例。它们留在目录树里,是因为离线交付物不只有 Zarf package,还要包含现场拿到包以后完成检查和配置所需的材料。
核心 .tar.zst 和外层 .tar.gz 都会生成 SHA-256,不能只校验其中一层。Zarf 创建 package 时默认生成 SBOM,构建后还可以检查 definition、镜像清单、OCI manifest digest 和 SBOM。以前 SBOM 和漏洞报告像是交付后的另一项工作;现在 SBOM 会随 package 生成,scan-vulnerabilities.sh 再提取 SBOM 并调用 Grype 生成报告。不过这份报告仍需人工复核,当前脚本的退出码不能当成发布门禁。
部署时选择与硬件、现有共享服务和产品范围匹配的 component:
PACKAGE=./hami-ai-platform-v0.0.3-airgap-amd64.tar.zst
COMPONENTS=hami-deploy-scripts,hami,prometheus-crds,prometheus,gpu-operator,envoy-gateway-crds,envoy-gateway,hami-ai-platform
VALUES_FILE=./package-values.yaml
./zarf-linux-amd64 package deploy "${PACKAGE}" \
--components="${COMPONENTS}" \
--values="${VALUES_FILE}" \
--confirm
同一个入口也用于失败后的重跑和新 package 升级;能不能安全升级,仍取决于 Chart、数据库、PVC 和 CRD 的迁移设计。
我不再让现场手工安装十几个 Chart,但这不意味着一条命令跑完就能交差。执行前要确认 kubecontext、Kubernetes 版本、StorageClass、硬件驱动、现有 Helm release、CRD ownership、component 集合和 values。执行后再验证设备分配、Prometheus targets、Gateway 请求、数据库迁移和业务 API。
升级到 v0.82.0 后,坑换了位置
Zarf 把现场步骤收进了 package,复杂度并没有消失。升级到 v0.82.0 后,我反而更清楚哪些问题属于工具,哪些必须由交付流程自己负责。
构建 CLI 也得固定版本
早期 Makefile 下载固定版本的 CLI 和 init package 放进交付物,创建 package 时却使用 PATH 中的任意 zarf。这会出现一种很荒唐的结果:交付物写着 v0.76.0,核心 package 可能由另一个版本创建。
现在 ZARF_VERSION 同时约束构建 CLI、交付 CLI 和 init package,make check-zarf-version 会比较实际 zarf version。所有 zarf.yaml 也固定到 v0.82.0 schema,尽量把版本不一致挡在编辑和 CI 阶段。
构建缓存统一放在仓库内的 build/zarf-cache/。同版本 OCI Chart 还要用 digest 固定内容,否则远端覆盖 tag 后,本地可能继续命中旧缓存。这种问题如果留到客户现场,排起来会非常难看。
--values 能直接用了,但不能随便传
v0.82.0 可以直接使用 --values 和 --set-values,不再需要旧命令里的 --features="values=true"。不过 --values 仍是 package 级入口,不支持 componentA=valuesA.yaml 这种按 component 传文件的写法。
package 中的 charts[].values 映射也不能删。它决定 package values 怎样传给每个 Chart;升级只是移除了 feature gate,没有自动推断不同 Chart 的键路径。--values 在 CLI help 中仍标记为 Alpha,发布前需要保留 manifest render 和 package inspect。
当前 package 的多个 Chart 都把 package values 的根节点映射到 Chart values 的根节点。如果两边都有 foo.bar 或 global.* 这类同名配置,一份 package values 就可能同时影响多个 Chart;CLI 又不能把某个 values 文件只交给指定 component。正因为如此,package values 的键名和 charts[].values 映射必须按整套软件设计,不能把现场已有的 Chart values 不加区分地塞进去。
这也影响了我维护业务 Chart 的方式:尽可能不让实施人员配置;确实要配时,多做选择题,少做填空题。比如暴露 AI 平台服务,可以在 Envoy Gateway NodePort、LoadBalancer 和只保留 ClusterIP 之间选择,而不是让现场临时拼一套网关 values。
Server-Side Apply 治不好 Helm Secret 超限
v0.82.0 内置 Helm v4.2.0,Chart 配置支持 serverSideApply。prometheus-operator-crds 可以直接启用它,避免 client-side apply 把大型 CRD 全量写进 kubectl.kubernetes.io/last-applied-configuration annotation。
我一开始也想用同样的方法安装 Envoy Gateway CRD,错误却没有消失:
Secret "sh.helm.release.v1.eg-crds.v1" is invalid: data too long: must have at most 1048576 bytes
原因是当前配置下的 Envoy Gateway v1.6.2 CRD Chart 会渲染出 20 个 CRD,Helm 保存的 release 数据超过 Kubernetes Secret 的 1 MiB 上限。Server-Side Apply 改变的是资源提交方式,不会缩小 release Secret;Zarf 的 manifests 也会包装成 Helm release,照样绕不过去。
最后我不再让这批 CRD 进入 Helm release。维护阶段按固定上游版本预先渲染 12 个 Gateway API Experimental CRD 和 8 个 Envoy Gateway CRD,连同 SHA-256 一起提交。构建时校验文件哈希和 CRD 数量,再打进 envoy-gateway-crds component。部署时执行 kubectl apply --server-side 并等待 Established。Envoy Gateway 主 Chart 使用同版本本地副本,只移除 crds/;package remove 也不删除这些 CRD。
Server-Side Apply 解决的是字段提交,Helm release Secret 属于另一条生命周期。把两者都归结成「CRD 太大」,很容易开错药。
构建时:镜像还是不全
如果 Zarf 自己就能找全所有镜像,也不需要我继续写这些补丁了。
最典型的是 GPU Operator。一开始我以为把 Chart 完整 render 一遍,再抓取 image: 就够了。后来才发现,问题不只是镜像「藏得深」,而是有些镜像从机制上就不会出现在一次静态渲染里:GPU Operator 会根据 values、Chart 版本和目标操作系统拼出镜像,Envoy Gateway 等控制器还会在运行时创建工作负载。这正是早期脚本反复漏镜像的原因。
我后来把 discover-images.sh 做成了四层保险,其中前三层负责自动发现:
- Pass 1:
zarf dev inspect manifests。先把zarf.yaml里的 Chart 完整 render 成最终 Kubernetes manifest,再提取已经落成资源对象的显式镜像。 - Pass 2:
zarf dev find-images。从 Chart 模板继续找没有进入最终 manifest 的镜像引用;必要时可以用--why追查来源,再解析 YAML 输出。 - Pass 3:
update-gpu-operator-images.py。GPU Operator 的 driver、GDS 和 GDRCopy 镜像带有操作系统后缀,需要查询 Registry tags API;部分组件版本继承Chart.appVersion,启用状态还取决于合并后的 values,内置 NFD 子 Chart 也要单独处理。实现思路参考了 NVIDIA 的 gpu-operator#2367。当时项目固定的旧 Chart release 没有可直接使用的完整镜像清单,因此我把相关逻辑移植到仓库里。 - 第四层:
known-images.txt。Envoy 数据面、HAMimock-device-plugin等镜像由控制器在运行时创建,不会出现在 Helm 模板里。对这批数量有限、来源明确的镜像,我没有再叠一层脆弱的自动推导,而是保留显式名单。这里「能审查」比「看起来全自动」更重要。
最后用一条命令交叉比较自动发现结果、zarf.yaml 和手工清单:
./scripts/discover-images.sh --check .
报告里的三个状态分别表示:
MISSING:自动发现了,但zarf.yaml没有声明;MANUAL:zarf.yaml已经声明,但自动发现没有覆盖;KNOWN-MISSING:known-images.txt已经记录,但zarf.yaml仍然遗漏。
发布时不能只看退出码。三个自动 pass 都要成功,MISSING 和 KNOWN-MISSING 必须为空,MANUAL 要逐项确认来源。因为离线交付里最吓人的从来不是镜像多,而是以为已经找全,到了现场才发现包里少了一个。
架构是另一层问题。zarf package create --architecture arm64 只告诉 Zarf 选择哪个平台,不能保证输入一定是 ARM64。单架构 AMD64 manifest 或错误架构的本地 image archive 仍可能进入名为 arm64 的 package。构建前要检查输入,构建后还要检查 package 的镜像清单和内部索引。
CLI 里有的能力,不等于发布流程已经采用
v0.82.0 可以创建差异包和分卷包,也可以把 package 直接输出到 OCI Registry,或者从 oci:// 检查和部署。差异包很适合「十几 GiB 制品只改了一个 Chart」的情况,但它同时引入了基线 package 依赖;分卷会打破当前外层归档脚本对单个 .tar.zst 的假设。现阶段我仍然发布完整 package。
package 签名也是同样的道理。v0.82.0 支持 --verify、--trusted-root 和 RFC 3161 signed timestamp,当前项目却还没有建立密钥轮换、离线信任根分发和恢复流程,自产的 HAMi application package 仍未签名。SHA-256 和 package digest 能证明内容是否变化,不能证明发布者身份。
Zarf 没替我解决什么
最先踩到的是资源所有权。我试过同时用 Zarf 和 Helm 管同一批资源,后来专门建了 Helmfile repo 处理开发环境。不能想着装完以后就把 Zarf 一脚踢开,再用 Helm 管回同一批资源;两个管理者迟早会在 ownership、升级或状态判断上打架。现在迁移已有资源时才考虑 --take-ownership;--force-conflicts 处理的是 Server-Side Apply 字段所有权冲突,不是一个更强的「覆盖安装」开关。
数据库、PVC 和 CRD 也不会因为进入 Zarf package 就获得事务回滚。fresh install、N-1 upgrade、真实设备分配、数据库迁移和 Gateway 请求仍要在测试集群验收,Pod 全部 Running 只能说明检查刚刚开始。
Zarf Agent 的边界也需要明确。采用 labeled policy 后,同一个 Namespace 并非绝对不能使用其他 Registry;实际行为由资源标签、Namespace 标签和网络条件共同决定。旧文里「同一 Namespace 只能使用 Zarf Registry」以及「开发环境绝对不能用 Zarf」都说得太满,这一版不再保留。
Zarf 没有消灭复杂度,只是把复杂度从客户现场搬回了构建机和测试集群。镜像仍要找全,CRD 生命周期仍要设计,数据库和 PVC 仍要准备回退;但这些问题至少不再等到出差现场才第一次暴露。
说到底,我交付的从来不是代码,也不是 Helm Chart,而是一整套能落地的环境。Zarf 没那么神,但它帮我把离线交付从「刀耕火种式的现场施工」变成了「可复制的制品」——对 air-gap 交付来说,这就够了。
