成本平台容量、高可用、升级与退出
月度成本评审前,仪表盘仍能打开,数字却停在旧水位。采集任务因为云账单 schema 增加一列而连续失败,重试队列堆积;值班人员扩了 API 副本,却没有增加仓库写入吞吐。随后团队回滚应用版本,发现新版本已经写入不可逆的聚合表,旧版本读不懂 checkpoint。页面恢复为绿色,最近费用窗口仍缺失,部分迟到费用又被重复记账。
成本平台的高可用不是“网页能访问”。它要在供应商迟到、文件重投、队列积压、仓库故障、schema 演进和平台迁移时,仍能说明最后完整水位在哪里、哪些窗口是 provisional、重放会不会重复计费、历史能否查询、旧系统是否还在产生费用。
把平台拆成六个可独立恢复的阶段
可靠成本平台至少包含:供应商导出接收、不可变原始区、规范化转换、身份与分摊、查询存储、API/报表。每层的成功证据不同:
provider delivery
-> raw object accepted + checksum
-> schema validated + normalized facts
-> identity and allocation applied
-> reconciliation and close state
-> query projection published
-> consumer acknowledgementAPI 200 只能证明最后一层能响应,不能证明上游数据完整。每个阶段都保存输入版本、输出摘要、处理水位、失败原因和重放键。原始文件保持不可变,修复通过新转换版本和新批次完成;直接覆盖原始账单会让后续对账失去基准。
一个最小批次清单可以这样表达:
batchId: provider-a:<delivery-id>
source:
provider: provider-a
object: raw/<billing-scope>/<delivery-id>.parquet
checksum: sha256:<digest>
schemaVersion: provider-schema-v7
window:
chargePeriodStart: <period-start>
chargePeriodEnd: <period-end>
pipeline:
normalizerVersion: focus-mapping-v4
allocationPolicy: allocation-v6
checkpoint:
acceptedRows: 0
rejectedRows: 0
maxSourceWatermark: <watermark>
state: acceptedbatchId + source object version + row identity 构成幂等边界。单纯用文件名去重不够:供应商可能用同名对象发布修订;单纯用账期去重也不够:同一账期会持续收到迟到和 correction 行。
先把恢复目标写成数据目标
成本平台的 RTO 与 RPO 不能照抄交易系统。供应商本身可能延迟交付或修订账单,平台无法承诺比源数据更“实时”。应分别定义:
接收 RPO:已被供应商交付且确认的对象,平台最多允许丢失多少。处理 RPO:已接收对象到规范化、分摊和发布之间允许落后多少水位。查询 RTO:故障后多久恢复已关闭窗口和最近完整窗口查询。
重放 RTO:从不可变原始区重建指定窗口需要多久。关账恢复:审批、争议、调整和财务回执能否恢复到一致状态。
不要用“最近一天数据”这种日历承诺。验收使用相对水位:source delivered watermark - published complete watermark。最近窗口可以是 provisional,但必须带完整性状态;已关闭窗口若发生供应商修订,应追加 correction,而不是静默改写。
用容器跑通幂等批次账本
本地实验使用 PostgreSQL 保存批次和费用事实。启动临时实例:
docker run --name finops-ha-lab --rm -d \
-e POSTGRES_PASSWORD='<lab-password>' \
-p 127.0.0.1:55433:5432 postgres:17-alpine
docker exec -i finops-ha-lab psql -U postgres <<'SQL'
CREATE DATABASE finops_ha_lab;
SQL建立批次账本和费用表:
CREATE TABLE ingestion_batch (
batch_id text PRIMARY KEY,
source_checksum text NOT NULL,
schema_version text NOT NULL,
source_watermark text NOT NULL,
state text NOT NULL CHECK (state IN ('accepted','processing','published','failed')),
accepted_rows bigint NOT NULL DEFAULT 0,
rejected_rows bigint NOT NULL DEFAULT 0
);
CREATE TABLE normalized_charge (
source_provider text NOT NULL,
source_row_id text NOT NULL,
source_revision text NOT NULL,
batch_id text NOT NULL REFERENCES ingestion_batch(batch_id),
owner_id text,
effective_cost numeric(18,6) NOT NULL,
currency char(3) NOT NULL,
PRIMARY KEY (source_provider, source_row_id, source_revision)
);
CREATE TABLE publish_checkpoint (
dataset_name text PRIMARY KEY,
complete_watermark text NOT NULL,
batch_digest text NOT NULL,
schema_version text NOT NULL
);生产系统还需要 raw object inventory、错误隔离表、allocation rule version、reconciliation 状态和下游确认。这里保留决定重放与去重的核心对象。
正向实验:同一批次重放不重复计费
先接收一个批次并写入两条费用:
BEGIN;
INSERT INTO ingestion_batch
(batch_id, source_checksum, schema_version, source_watermark, state)
VALUES
('delivery-a:rev-1', 'sha256:demo-a', 'provider-v7', 'T+1', 'processing');
INSERT INTO normalized_charge VALUES
('provider-a', 'row-1', 'rev-1', 'delivery-a:rev-1', 'team-a', 12.50, 'USD'),
('provider-a', 'row-2', 'rev-1', 'delivery-a:rev-1', 'team-b', 8.25, 'USD');
UPDATE ingestion_batch
SET state = 'published', accepted_rows = 2
WHERE batch_id = 'delivery-a:rev-1';
INSERT INTO publish_checkpoint VALUES
('allocated_cost', 'T+1', 'sha256:batch-demo', 'cost-schema-v3');
COMMIT;预期批次状态为 published,费用总额为 20.75,checkpoint 指向 T+1。模拟消费者超时后重新提交完全相同的批次:
INSERT INTO ingestion_batch
(batch_id, source_checksum, schema_version, source_watermark, state)
VALUES
('delivery-a:rev-1', 'sha256:demo-a', 'provider-v7', 'T+1', 'processing')
ON CONFLICT (batch_id) DO NOTHING;
INSERT INTO normalized_charge VALUES
('provider-a', 'row-1', 'rev-1', 'delivery-a:rev-1', 'team-a', 12.50, 'USD')
ON CONFLICT (source_provider, source_row_id, source_revision) DO NOTHING;
SELECT COUNT(*) AS rows, SUM(effective_cost) AS amount
FROM normalized_charge;预期仍是 2 行、20.75。重放成功的证据不是任务显示 SUCCESS,而是输入摘要相同、唯一键拒绝重复、金额和行数不变、checkpoint 没有越过未完成批次。
若同一 batch_id 携带不同 checksum,不能 DO NOTHING。这表示供应商修订、对象被替换或上游损坏,应把新对象登记为新 revision,保留旧新差异并按 correction 语义处理。
反向实验:错误 checkpoint 制造“绿色缺数”
模拟转换只写入一行,却提前推进完整水位:
BEGIN;
INSERT INTO ingestion_batch
(batch_id, source_checksum, schema_version, source_watermark, state, accepted_rows, rejected_rows)
VALUES
('delivery-b:rev-1', 'sha256:demo-b', 'provider-v7', 'T+2', 'published', 1, 1);
UPDATE publish_checkpoint
SET complete_watermark = 'T+2', batch_digest = 'sha256:incomplete-demo'
WHERE dataset_name = 'allocated_cost';
COMMIT;
SELECT batch_id, state, accepted_rows, rejected_rows
FROM ingestion_batch
WHERE source_watermark = 'T+2';批次显示 published,但 rejected_rows = 1。如果查询层只看 checkpoint,就会把不完整窗口标为绿色。修复原则是 checkpoint 只能由满足完整性、schema、守恒和对账门禁的事务推进:
UPDATE publish_checkpoint p
SET complete_watermark = b.source_watermark,
batch_digest = '<validated-digest>',
schema_version = 'cost-schema-v3'
FROM ingestion_batch b
WHERE p.dataset_name = 'allocated_cost'
AND b.batch_id = 'delivery-b:rev-1'
AND b.state = 'published'
AND b.rejected_rows = 0;预期更新 0 行。随后把 T+2 标记为 provisional 或 failed,修复被拒记录,生成新 batch/revision,再重新计算守恒并推进水位。绝不能直接把 rejected_rows 改成 0 而不保留修复证据。
容量模型从行数、基数和重放窗口开始
成本平台负载由多个乘数共同决定:
storage = raw bytes × object versions × retention
+ normalized rows × row width × replicas
+ allocation fan-out × policy traces
+ exports/cache/audit/backup
rebuild time = retained raw bytes / sustainable transform throughput
+ index/compaction time
+ reconciliation time共享费用按 N 个受益者展开时,分摊行数可能远大于源账单。高基数标签会增大 join、group by、索引和报表缓存;多云规范化还要保留供应商扩展列和 schema 版本。容量测试必须使用代表性的行宽、标签基数、共享分摊扇出、迟到修订和历史重算窗口,不能只导入一份小 CSV 测页面响应。
持续观察:
delivery_lag:供应商已交付水位与 raw 接收水位差。normalization_lag:raw 水位与规范化水位差。allocation_lag:规范化水位与发布水位差。
queue_oldest_age 与 retry rate:积压是否持续扩大。rejected rows、duplicate rows、schema drift 与 per-batch reconciliation delta。仓库写入吞吐、查询 p95、compaction、对象存储增长和重放 ETA。
API 并发、导出字节、缓存命中、审计吞吐和下游消费延迟。
扩容先找瓶颈阶段。API CPU 高可增加无状态副本;转换积压要增加分区与 worker,但必须保证同一业务键的幂等和顺序;仓库写入受限时盲目增加 worker 只会放大锁、small files 或 compaction;对象存储增长则要检查修订版本、导出和生命周期,而不是缩短财务要求的原始数据保留。
高可用必须覆盖控制面和数据面
采集 scheduler、队列、转换 worker、仓库、查询 API 和策略服务需要分别设计故障域。无状态服务可以多副本,checkpoint 与 leader election 必须有一致性存储;有状态仓库要使用其支持的复制、备份和恢复机制,不能把共享文件系统挂到多个不支持并发写的实例上冒充 HA。
跨可用区部署要评估数据传输、对象存储请求、复制和查询成本。将所有组件机械铺到三处会提高账单,却不一定改善恢复;真正的判断是单一故障域丢失后,哪一份状态仍然权威、谁接管、如何防止双写、积压恢复是否压垮下游。
一次故障演练按顺序验证:
暂停一个采集 leader,确认新 leader 从已提交 checkpoint 继续,不跳过也不重复对象。终止一组转换 worker,确认队列年龄上升后可收敛,唯一键与金额不变。阻断仓库写入,确认 checkpoint 不前进,API 明确显示 stale/provisional。
恢复仓库,限制补偿并发,观察队列、写入、compaction 和 API 长尾。从备份恢复隔离实例,用 raw inventory、批次 digest、关账和财务回执验证一致性。
恢复后“总额差不多”不合格。应按 source row、batch、owner、费用窗口和 close batch 检查完整性,并保留丢失、重复、修订与未分配差异。
Schema 演进要兼容生产者、存储与消费者
供应商导出、FOCUS 版本、OpenCost/Kubecost API、内部规范表和报表契约有不同版本节奏。FOCUS 最新规范版本不代表云厂商已经交付同一版本,供应商扩展列也不能被规范化层静默删除。
schema registry 或数据契约至少记录字段名、类型、是否可空、语义、单位、币种、版本和生产者。演进分为:新增可空列、扩大枚举、类型变化、主键变化、语义变化和删除列。新增列通常可向后兼容;主键或金额语义变化必须新建版本并双写,不能原地改列名。
升级前运行 contract test:
old producer -> new normalizer
new producer -> old normalizer (若承诺兼容)
new normalizer -> old consumer
new normalizer -> new consumer
historical raw -> new normalizer replay未知列应进入扩展区或原始保留,未知枚举进入隔离队列并告警;把未知值强转为 other 会让金额看似完整却丢失语义。
双轨升级不是同时开两个页面
成本平台升级需要锁定应用、chart/镜像、schema、配置、策略和数据 revision。数据库迁移优先采用 expand/contract:先增加新结构并让旧版本仍可读,再双写或回填,切换消费者,最后在回滚窗口结束后删除旧结构。
双轨期间,新旧平台消费同一不可变 raw 输入,不能各自从不同日期或不同账单 scope 抓取。每个窗口比较:
source coverage
row identity coverage
total and per-service amount
owner allocation
idle/shared/unallocated
currency and amortization
late correction handling
query and export contract差异需要分类为 schema、价格、窗口、身份、政策、迟到数据或软件缺陷。一个全局百分比不能掩盖某个大客户或成本中心完全错配。切流门禁还包括新平台 ingestion lag 可收敛、历史窗口可查、权限与审计通过、备份恢复完成、回滚时旧平台仍能消费兼容数据。
若新版本已执行破坏性 schema 迁移,应用镜像回滚不等于系统回滚。必须提前准备数据库快照、向下兼容视图或反向迁移,并说明恢复点之后产生的新数据怎样合并。
故障排查从水位而不是页面开始
| 现象 | 第一条证据 | 常见原因 | 恢复动作 |
|---|---|---|---|
| 仪表盘有数据但长期不变 | 各阶段 watermark | 上游停投、采集失败、checkpoint 卡住 | 找到首个落后阶段,修复后限速重放 |
| 金额突然翻倍 | source row/revision 唯一键 | 对象重投、消费者重试非幂等、双轨双写同表 | 隔离重复批次,按原始 ID 重建 |
| 升级后历史为空 | schema/partition/retention | 新版本读错表、集群 ID 或存储路径 | 恢复兼容配置,不先删除旧卷 |
| 队列恢复后 API 雪崩 | oldest age、warehouse writes、query p95 | 补偿并发挤占在线查询 | 设置重放配额与资源池,分批追水位 |
| 总额对上但 owner 大幅漂移 | per-owner diff 与 policy version | 身份快照或分摊规则不同 | 冻结输入,逐规则解释并重算 |
| 卸载后仍产生费用 | 云资产清单与账单行 | PVC、LB、对象版本、跨区流量、授权仍存 | 逐项核销并跟踪迟到账单 |
排障动作必须记录开始水位、受影响窗口、修复版本、重放批次、差异结果和关闭 owner。直接清队列或删除失败批次会让平台恢复表面吞吐,却永久失去一段费用。
备份恢复要保留可重建材料
备份不只包含查询数据库。至少保留 raw object inventory、对象版本与 checksum、批次账本、schema、身份历史、分摊政策、价格配置、关账状态、争议、财务回执、访问策略和密钥恢复流程。仪表盘可以重建,已批准的责任与财务证据不能凭当前配置推导。
恢复演练使用隔离账号或 namespace,按“身份与密钥 -> 原始区 -> 元数据与批次 -> 转换 -> 分摊 -> 查询 -> 财务证据”的顺序执行。恢复环境禁止向正式财务接口和通知渠道写入。验收比较对象数量、checksum、批次状态、关闭窗口金额、owner 分配和审计连续性;最后删除演练资源并核对存储、快照和出口费用。
退出平台要证明旧链路停止产生事实和费用
退出从冻结变更开始。先清点数据源、调度器、队列、数据库、对象存储、缓存、报表、API、身份、KMS、DNS、负载均衡、PVC、备份、许可证和外部集成。然后导出原始清单、规范化 schema、映射与政策、历史结果、争议、审批、审计和数据字典。
替代平台并行消费相同 raw 输入,完成金额、owner、窗口、修订和权限对比。切换消费者后,旧平台进入只读观察期:停止采集与写入,保留必要查询,监控是否仍出现新 batch、新对象、新 token 使用和新账单行。
清理顺序建议为:
停止下游写接口、调度与新导出,确认队列清空或有承接记录。撤销云账单、仓库、对象存储、BI 和财务系统凭证。移除 Ingress、DNS、LoadBalancer、API token 与 webhook。
按保留政策归档或删除数据库、PVC、缓存、临时导出和对象版本。删除 chart/release、namespace、服务账号、角色和 KMS grants。连续检查云资产清单与迟到账单,给每个残余成本指定 owner。
docker stop finops-ha-lab本地实验容器使用 --rm,停止后会自动删除。生产环境不能套用“一条卸载命令”:先删平台再导出策略和历史,会失去迁移证据;先删身份再完成只读核对,会让团队无法解释最后差异。
架构决策检查
每个处理阶段是否有输入版本、状态、水位、摘要、失败原因和重放键。原始账单是否不可变且可枚举,规范化与分摊是否能从原始区重建。checkpoint 是否只在完整性、schema、守恒与对账通过后推进。
RPO/RTO 是否分别覆盖接收、处理、查询、重放、关账和财务证据。容量预算是否包含分摊扇出、修订、重算、审计、导出、备份和 compaction。故障恢复是否控制补偿并发,避免积压追赶压垮仓库与在线查询。
schema 升级是否采用兼容扩展、双读双写和历史回放,而非一次性破坏迁移。双轨是否消费相同输入并按局部金额、owner、窗口和修订比较。退出是否完成数据导出、凭证撤销、资产删除和残余成本跟踪。
当成本平台能在故障和升级中保持“原始输入可找、批次可重放、金额不重复、水位不越界、历史可解释”,并在退出后证明旧系统不再产出数据、不再持有凭证、不再产生无主费用,它才具备长期工程价值。
