GitOps 同步顺序、Hooks 与 Jobs:把发布副作用变成可证明的事务
凌晨的版本发布停在数据库迁移步骤。Job 第一次执行时已经新增索引,却在写入“迁移完成”记录之前因超时退出;控制器重建 Job 后,第二次执行再次提交同一条 DDL,等待元数据锁的连接迅速堆积。应用 Deployment 尚未更新,数据库连接池却先被耗尽。值班人员看到的是一个失败 Job,真正需要回答的却是:副作用究竟执行到哪一步、下一次重试是否安全、旧版本还能否继续读写,以及谁有权决定继续还是停止。
另一个团队把 CRD、Controller、业务自定义资源和初始化 Job 放在同一目录,并按文件名加上 00-、01-、02-。本地渲染顺序看起来整齐,GitOps 控制器却不会把文件名当完成门。CR 在 CRD 尚未可用时被 API Server 拒绝,初始化 Job 又在 Controller 尚未 Ready 时启动。重复点击同步偶尔成功,只是把竞态变成了概率问题。可靠发布必须显式表达“谁依赖谁”“什么状态才算完成”和“失败后哪里停止”,不能把目录顺序当作状态机。
先把文件顺序改写成依赖图
同步顺序解决的不是“对象先提交还是后提交”,而是“后继动作何时可以相信前驱已经完成”。提交 CRD 只说明 API Server 接受了定义,不代表负责该 CR 的 Controller 已经 Ready;创建 Service 只说明对象存在,不代表 Endpoint 已有可用后端;Job condition 为 Complete 也只说明容器以成功路径结束,不代表数据库不变量、外部系统结果或真实请求已经满足发布要求。
一条可排查的发布链至少区分六类状态:源 revision 已固定,清单已渲染,API 对象已接受,依赖资源已达到健康条件,副作用已经留下可复核完成事实,业务运行结果符合预期。顺序机制只能连接这些状态,不能凭空创造完成证据。把所有对象塞进一个同步单元,会让“部分成功”难以定位;拆得过细又会增加仓库对象、控制器队列和权限关系。常用拆法是按失败域划分:基础 API、控制器、数据变更、应用工作负载、运行探测各自成为可观察节点。
可以先用下面的依赖图评审一次发布。每条箭头旁都要能回答完成判据和失败后的停止动作:
CRD accepted
-> controller Available + API health
-> schema migration committed + invariant holds
-> application observedGeneration caught up
-> smoke request returns expected build/data version
-> cleanup old compatibility objects如果“数据库迁移完成”只能通过某个 Pod 名称或一行日志判断,依赖图仍是不完整的。Pod 会重建,日志会过期,控制器也可能重复执行同一 revision。更稳定的证据是迁移账本中的唯一版本键、Job UID 与源 revision 的关联、数据库 schema 版本、开始结束时间、终态 condition,以及针对业务数据不变量的查询结果。
Argo CD 用 phase、wave 与 health 建立同步门
Argo CD 的 phase 先决定动作属于 PreSync、Sync、PostSync 还是失败后的 SyncFail;wave 再在同一 phase 内排序。PreSync 失败会阻止主同步,PostSync 只有主资源同步成功且达到 Healthy 后才运行。wave 不是全局时间线:一个 PreSync 的 wave 值不能与 Sync phase 的 wave 值直接比较。删除时 wave 反向执行,高 wave 对象先被 prune,这一点会直接影响依赖资源的退出顺序。具体语义可对照 Argo CD 的 Sync Phases and Waves。
下面的迁移 Job 位于 PreSync,固定名称配合 BeforeHookCreation 让下一轮创建前先清理旧 hook;成功和失败记录则按治理需要决定是否自动删除。示例保留成功 Job 一段时间供取证,并把执行上限、容器重试、并发和资源预算写进对象。镜像应替换成经过批准的不可变 digest,迁移程序还必须自行实现幂等,不能依赖这些 Kubernetes 字段替代事务设计。
apiVersion: batch/v1
kind: Job
metadata:
name: checkout-schema-migrate
namespace: checkout
annotations:
argocd.argoproj.io/hook: PreSync
argocd.argoproj.io/sync-wave: "-1"
argocd.argoproj.io/hook-delete-policy: BeforeHookCreation
spec:
completions: 1
parallelism: 1
backoffLimit: 2
activeDeadlineSeconds: 300
ttlSecondsAfterFinished: 86400
template:
spec:
serviceAccountName: checkout-migrator
automountServiceAccountToken: false
restartPolicy: Never
containers:
- name: migrate
image: registry.example.com/checkout-migrator@sha256:<approved-digest>
args: ["apply", "--release", "<source-revision>"]
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
runAsNonRoot: true
resources:
requests: { cpu: 100m, memory: 128Mi }
limits: { cpu: 500m, memory: 512Mi }
envFrom:
- secretRef:
name: checkout-migration-credential应用 Deployment 可以保持普通 Sync phase 和 wave 0,运行探测 Job 使用 PostSync。这样迁移失败时工作负载不会推进,主资源未健康时探测也不会提前运行。不要把选择性同步当作生产抢修捷径:Argo CD 的 selective sync 不运行 hooks,单独选择 Deployment 可能绕过迁移和安全检查。需要紧急发布时,应准备不依赖绕过 hook 的受审计路径,并明确数据库兼容窗口。
phase、wave 和 SyncFail 仍不是数据库事务。SyncFail 只能在同步失败后执行另一个受控动作,不能证明前面的 DDL、消息或外部 API 已被撤销;它自己失败时也需要独立告警和人工接管。wave 只在当前 phase 内排序,并依赖 Argo CD 的健康判断放行后续 wave;自定义资源若没有可靠 health 语义,应先补健康检查或拆成有明确完成事实的同步单元,不能只把 wave 数字调大。
PreDelete 只在删除整个 Application 时运行,普通 prune 不会调用它。因此“清单里删掉一个资源之前自动备份”不能由 PreDelete 承诺。备份、外部资产销毁和数据库退役应拥有独立审批、完成事实和恢复验证,不能藏在一个可能根本不会触发的 hook 中。
Flux 用多个 Kustomization 表达真正的依赖
Flux 不把一个目录中的 YAML 次序当发布门。Kustomization.spec.dependsOn 会等待依赖 Kustomization Ready;wait: true 对该单元调谐出的资源等待健康,healthChecks 可以只指定关键对象,healthCheckExprs 则可为自定义资源定义条件。CRD/Controller 与 CR、迁移 Job 与应用、应用与发布后探测都适合拆成独立 Kustomization,再用依赖连接。字段语义见 Flux Kustomization 和官方的 Running jobs with Flux。
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: checkout-pre
namespace: flux-system
spec:
interval: 10m
path: ./clusters/production/checkout/pre
prune: true
wait: true
force: true
timeout: 6m
sourceRef:
kind: GitRepository
name: platform-config
serviceAccountName: checkout-reconciler
---
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: checkout-app
namespace: flux-system
spec:
dependsOn:
- name: checkout-pre
interval: 10m
path: ./clusters/production/checkout/app
prune: true
wait: true
timeout: 8m
sourceRef:
kind: GitRepository
name: platform-config
serviceAccountName: checkout-reconciler
---
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: checkout-post
namespace: flux-system
spec:
dependsOn:
- name: checkout-app
interval: 10m
path: ./clusters/production/checkout/post
prune: true
wait: true
force: true
timeout: 3m
sourceRef:
kind: GitRepository
name: platform-config
serviceAccountName: checkout-reconciler正向路径应在 checkout-pre 的 Job Complete 后看到该 Kustomization Ready,随后 checkout-app 才更新 lastAppliedRevision,最后才出现 post Job。反向路径可让 pre Job 使用一个必然失败的检查参数;预期证据是 pre condition 进入失败或超时,app 与 post 的 observed revision 保持上一轮,业务仍运行旧版本。若后继对象仍推进,说明依赖引用、namespace 或健康条件没有按设计生效。
这里还要解决“下一轮怎样得到一个新 Job”。固定名称 Job 的 Pod template 大多是不可变字段,仅修改镜像或参数并不会天然产生第二次执行。Flux 官方示例在只管理 Job 的 pre/post Kustomization 上启用 force: true,当镜像或参数等不可变字段变化时删除并重建 Job;上面的配置也把 force 限定在这两个同步单元,没有施加到应用 Kustomization。另一种做法是由晋级工具为每个 release 生成带稳定版本后缀的新 Job 名称,再让 inventory、prune 和证据保留策略清理旧对象。无论采用哪种路径,执行实例身份只能帮助追踪,副作用系统里的稳定业务键仍负责阻止重复提交。
Flux 的 force 或对象注解 kustomize.toolkit.fluxcd.io/force: Enabled 会在不可变字段变化时重建资源,可让固定名称 Job 随镜像或参数变化再次创建。它不是通用修复开关:应用到 StatefulSet 等有状态对象可能导致数据损失。依赖环也不会通过重试自行消失;A 依赖 B、B 又依赖 A 时,两者会一直阻塞。自定义 readyExpr 同样要准备字段缺失和表达式错误的负例,否则一次 CRD 变化就可能把整条链卡到 timeout。
Fleet 要分清 Bundle 依赖和集群批次
Fleet 的 dependsOn 表达 Bundle 之间的依赖,只有依赖 Bundle 达到 accepted state 后,当前 Bundle 才会部署;默认 accepted state 是 Ready。所有 Bundle 最终以 Helm release 方式落到下游,即使源内容是 raw YAML 或 Kustomize。helm.waitForJobs 只有与 timeoutSeconds 一起配置时,才会等待 Job 完成。字段组合可查阅 fleet.yaml reference。
Bundle 依赖解决的是单个应用内部的基础设施、迁移和工作负载顺序;rollout partition 解决的是同一个 Bundle 先进入哪些集群、允许多少不可用集群。二者不能互换。若迁移 Bundle 在第一个集群成功,不代表后续集群共享的外部数据库还可以安全重复执行;这时幂等键必须包含业务变更身份,而不是简单包含集群名。相反,如果每个集群拥有独立数据库,完成事实又必须带上目标集群身份,避免一个集群的成功记录误放行另一个集群。
atomic、force 和 takeOwnership 会改变失败、重建和所有权语义。force 可能重建对象,takeOwnership 会跳过 Helm ownership annotation 检查,都不应成为“发布卡住就打开”的默认动作。先区分 Job 尚未完成、Helm release 冲突、Bundle 未 Ready、下游 agent 失联还是目标 API 拒绝,再决定是否重试。所有权冲突需要规划移交,而不是通过强制接管掩盖旧控制器仍在写入的事实。
把 Job 设计成可重复提交的发布事务
Kubernetes Job 只负责让 Pod 运行到完成条件,它不知道外部副作用是否已经提交。控制器重试、Pod 重建、hook 重新创建、GitOps Controller 重启和人工操作都可能让同一动作再次开始。幂等性必须落在副作用系统内,并使用稳定的业务键,例如 service + migration-version + artifact-digest;Job UID 适合审计一次执行,不适合作为幂等键,因为每次重建都会变化。
数据库迁移可以先在同一事务中争抢唯一版本记录,再执行可重复判断的变更;无法放进单事务的在线 DDL,则采用“计划、执行、确认”状态机,并把每一步写入迁移账本。仅有唯一键还不够,执行者必须知道自己是否真正取得执行权。下面的 PostgreSQL 风格伪 SQL 用 RETURNING 区分本次抢占成功与既有记录,字段名、租约和锁策略应按实际数据库调整:
BEGIN;
INSERT INTO schema_change_ledger(
change_key, source_revision, execution_token, state, lease_until, started_at
)
VALUES (
'checkout:add-order-index:v3',
'<source-revision>',
'<random-execution-token>',
'RUNNING',
CURRENT_TIMESTAMP + INTERVAL '10 minutes',
CURRENT_TIMESTAMP
)
ON CONFLICT (change_key) DO NOTHING
RETURNING change_key, execution_token, state, lease_until;
-- 仅返回一行时,本执行者取得执行权;未返回行就停止 DDL 并读取既有状态:
-- SUCCEEDED -> 返回成功且不重复执行;
-- RUNNING -> 检查租约、execution_token 与实际 schema 后停止;
-- FAILED -> 仅由恢复流程签发新的 attempt,不能直接覆盖状态。
COMMIT;迁移程序随后读取实际 schema,只有“不存在目标索引、本次取得执行权且租约仍有效”时才提交 DDL;完成更新必须带上 change_key + execution_token + RUNNING 条件,避免过期执行者覆盖新 attempt,并保存 schema 版本和结果摘要。租约过期只表示需要调查,不表示副作用未发生;接管者必须先核对实际 schema、数据库活动会话和账本,再决定续作或补偿。外部 API 调用同理:把幂等键传给支持该能力的服务,或在本地 outbox/账本中记录请求与结果。发送消息、扣减额度、创建云资源这类动作不能靠“失败就再跑一次”处理。
完成判据至少包含 Job UID、源 revision、输入 schema/version、开始结束时间、Job condition、业务不变量和补偿状态。日志可以帮助解释过程,但不是唯一事实。对于数据库变更,正向不变量可以是目标索引存在、旧应用查询仍成功、新应用读写符合约束;反向不变量可以是重复执行不新增第二条账本、不重复发消息、不扩大权限,也不把失败状态误写为成功。
分开计算容器重试、控制器重试与人工重试
backoffLimit 控制 Job 层面对失败 Pod 的容忍,activeDeadlineSeconds 给整次 Job 设置时间上限,parallelism 与 completions 决定并发和完成数,ttlSecondsAfterFinished 管理完成对象的清理。官方 Kubernetes Jobs 特别指出,长期保留大量 finished Job 会给 API Server 带来压力。资源 requests/limits 还要避免迁移与应用发布争抢节点,数据库连接池也应有独立上限。
这些字段只覆盖 Job 自己。Argo CD 可能重建 hook,Flux 可能因不可变字段与 force 重建 Job,Fleet/Helm 可能在超时后留下需要调查的 release,操作人员也可能再次提交 revision。总重试预算因此不是某一个 backoffLimit;当各层都能独立跑满次数时,最坏尝试上限会呈乘法放大,还要加上超时后仍在外部系统执行的“未知结果”动作。生产策略应指定唯一重试 owner:Job 内部处理短暂连接错误,GitOps 层只在完成事实证明可重复时重新创建,人工重试则要求关联事故或变更记录。
遇到失败时先冻结证据,再决定动作:
kubectl -n checkout get job checkout-schema-migrate -o yaml
kubectl -n checkout get pods -l job-name=checkout-schema-migrate -o wide
kubectl -n checkout describe job checkout-schema-migrate
kubectl -n checkout logs job/checkout-schema-migrate --all-containers=true
kubectl -n checkout get events --sort-by=.lastTimestamp预期证据包括 Job UID、失败 condition、已尝试 Pod、退出码、deadline 或调度事件。随后从迁移账本和目标系统读取副作用状态。如果账本为 SUCCEEDED 且业务不变量成立,修复的是控制面完成信号;如果状态为 RUNNING 且租约仍有效,应停止重建;如果外部动作部分提交且无法自动判定,就进入人工恢复,而不是提高重试次数。日志中可能含连接串、SQL 参数和业务数据,导出前必须脱敏并限制保留期。
用正反实验证明顺序与幂等真的存在
先在隔离 namespace 使用无破坏的 ConfigMap 或测试表走通正向链。固定一个源 revision,提交迁移 Job、应用 Deployment 和探测 Job;观察 Argo CD operation、Flux Kustomization conditions 或 Fleet Bundle 状态,同时保存 Job UID、工作负载 generation 和真实请求结果。成功信号不是控制器整体变绿,而是迁移完成事实、后继资源才开始、应用达到预期 generation、探测命中正确构建身份并满足数据不变量。
第二步用同一个幂等键重新创建 Job。预期结果是迁移程序识别 SUCCEEDED,不再次提交 DDL 或外部动作,新的 Job 可以成功结束,并留下“已完成,未重复执行”的结构化结果。如果再次出现锁、第二条消息或重复云资源,幂等性失败,必须在副作用系统修复,不能靠 hook 名称和 TTL 遮住。
第三步构造可控失败:让程序在写入 RUNNING 后、提交最终完成状态前退出,或使用错误的只读数据库身份。预期结果是后继同步单元不推进,失败证据能区分权限拒绝、deadline、业务检查失败和外部系统不可达。恢复前先验证实际 schema 与账本,再按状态机续作。禁止在共享生产库故意执行破坏性 DDL、制造锁等待或撤销真实凭据。
最后验证绕过路径。对依赖 hook 的 Argo CD Application 尝试 selective sync 时,应在变更审批或策略层被拒绝;为 Flux 制造依赖环或错误 readyExpr 时,后继保持阻塞且告警能指出依赖;Fleet 中省略 timeoutSeconds 时,不应把 waitForJobs 想象成可靠完成门。每个反例都要定义停止时间和清理动作,避免测试本身成为长期失败源。
权限、凭证与敏感数据决定事故半径
迁移 Job 不应复用 GitOps Controller 的高权限 ServiceAccount。控制器只负责创建受限 namespace 内的 Job;Job 的 ServiceAccount 只读取必要 ConfigMap/Secret,并访问目标数据库或外部 API。若程序不需要调用 Kubernetes API,可禁用不必要的 ServiceAccount token 挂载;若确实需要读取对象,则用精确的 namespace、resource 和 verb 授权,不授予 Secret 列表、Pod exec、RBAC 修改或集群管理能力。
仓库身份、集群写身份和副作用凭证要分离。仓库只读 Token 泄漏不应自动获得数据库 DDL 权限,应用团队能同步 Deployment 也不应能替换迁移凭证。数据库账户按 schema 与动作收敛,凭证使用外部 Secret 注入或短期身份,轮换后既验证新身份成功,也验证旧身份被拒绝。不要把密码、私钥、完整连接串写进 Job 参数、annotation、termination message、日志或 Git diff。
对迁移镜像同样建立供应链约束:固定 digest,记录来源和扫描结果,限制入口脚本与可执行工具,禁止运行时从互联网下载未固定脚本。Job 能读取高价值数据时还要限制网络出口,只允许数据库、Kubernetes API 和必要观测端点;否则一次镜像失陷就能借发布窗口外传数据。支持包、失败日志和 SQL 结果按敏感资产处理,审计记录保存身份、动作和摘要,不保存秘密值。
多租户环境还需防止应用仓库自选高权限 serviceAccountName。Argo CD 用 AppProject 与目标 Kubernetes RBAC共同限制可创建资源,Flux 可让 Kustomization 以受限 ServiceAccount 调谐,Fleet 则应让 Bundle 目标与 Helm 所有权保持唯一。任何允许租户创建 Job 的平台,都要通过策略限制 privileged、hostPath、hostNetwork、节点挂载、任意 Secret 引用和越界 ServiceAccount,否则“发布 hook”会变成通用代码执行入口。
删除和回滚必须分别处理对象与副作用
删除 Job 只会删除 Kubernetes 对象及其 Pod,不会撤销已经提交的 DDL、消息、云资源或外部 API 请求。回退 Git revision 也只改变期望状态,数据库 schema 和数据不会自动倒流。数据发布优先采用 expand/contract:先增加向后兼容字段或索引,让旧、新应用都能运行;确认新版本稳定后,再在独立变更中清理旧结构。不可逆动作发生后,安全路径通常是向前修复,而不是强行回放旧清单。
Argo CD 的 HookSucceeded、HookFailed 与 BeforeHookCreation 决定 hook 历史何时删除;Kubernetes TTL 再控制 finished Job 的保留。保留太短会丢失首个失败现场,保留太长会增加 API Server、etcd、日志平台和对象检索成本。较稳妥的做法是先把 Job UID、revision、终态、结果摘要和审计关联输出到受控证据库,再由 TTL 清理集群对象。普通 prune 不触发 PreDelete,所以资源退役不能依赖它执行数据备份。
Flux 中删除 Kustomization、平时 revision prune 和对单个对象禁用 prune 是不同入口;force 重建 Job也不是回滚。Fleet 的 Helm release、Bundle 删除和 keepResources 等配置又有自己的所有权语义。退出前先暂停自动调谐,列出将删除的 Job、Secret、ServiceAccount、RoleBinding、外部身份和日志索引;确认副作用系统已经移交或关闭,再撤销凭证,最后删除控制对象。先删 Secret 或 ServiceAccount 可能让 finalizer、清理 Job和证据导出失去能力。
一次可恢复的回退决策至少回答四个问题:旧应用能否读取新 schema;迁移是否有反向脚本且经过数据量评估;外部副作用能否补偿;当前 Job 是否仍在运行或可能被控制器重建。任何答案不确定,都先停止后继同步和自动重试,保存现场并选择前向修复。把 git revert 当作数据库回滚按钮,是同步控制面与业务事实最危险的混淆之一。
用容量、成本和职责把机制长期运转起来
Job 数量会同时消耗 Pod 调度、镜像拉取、API 对象、Event、日志、数据库连接和控制器等待槽位。一次大规模多集群发布若为每个应用、每个阶段创建多个 Job,失败重试会把对象数和外部调用量成倍放大。容量模型至少统计单位发布 Job 数、平均与最长执行时间、失败率、重试次数、并发数据库连接、日志字节、finished Job 保留量、控制器队列年龄和目标 API 限流。
成本控制不能只缩短 TTL。迁移程序启动慢时,可优化镜像体积和依赖初始化;大量应用重复相同无副作用检查时,可前移到渲染或策略门;共享数据库上的迁移要按数据库串行,而不是按集群盲目并行;长时间数据回填应从发布 hook 拆成有独立进度、限流、暂停和恢复能力的任务。一个需要运行数小时、可以跨发布窗口持续、还会处理海量数据的动作,不应绑住整个 GitOps 同步 operation。
职责也要写进运行制度。应用团队拥有迁移代码、幂等键和业务不变量;数据库 owner 审查锁、容量、备份与兼容窗口;平台团队维护 phase/wave/dependsOn 模板、受限身份、策略与控制器告警;发布负责人决定停止、续作和环境晋级;安全团队治理凭证、镜像来源、日志脱敏和临时提权。没有 owner 的 hook 不应继续自动运行生产变更。
每次发布完成后复核五类证据:依赖链按预期顺序推进;重复执行没有重复副作用;失败能在预算内停止;回退决策与数据兼容事实一致;历史对象、凭证和日志按策略清理。做到这些,waves、hooks 和 Jobs 才不只是把命令塞进集群,而是把一次高风险变更变成可观察、可拒绝、可恢复和可长期治理的发布事务。
