Shared、Idle 与 Unallocated:让成本分摊守恒
平台月报显示集群成本为 100 个记账单位,按 namespace 导出后只剩 82;团队把剩余 18 平均摊给业务,又发现总额变成 106。第一轮差异来自没有保留 idle 和缺少 owner 的记录,第二轮差异来自平台 namespace 已经在 direct cost 中,却又被放进 shared pool 重复分摊。总额没有复杂到无法解释,真正的问题是不同语义的成本桶被混成了一个“其他”。
三个名字对应三种不同故障
Idle 是资产已经付费、却没有被工作负载分配或消费的差额。以节点为例,它通常来自节点成本减去已分配 CPU、内存等工作负载成本;它回答“买来的容量为什么没有进入工作负载账”。OpenCost 的 Allocation API 可以用 includeIdle=true 保留 __idle__,用 idleByNode=true 把差额保留在节点粒度,而不是先在集群级抹平。
Unallocated 是当前聚合维度缺失。按 label:cost-center 聚合时,没有这个 label 的工作负载会进入 __unallocated__;它仍然可能有明确的 Pod、namespace 和 controller,只是缺少当前报表需要的归属键。它回答“这笔消费为什么找不到责任维度”,不等于节点空闲,也不能通过 shareIdle 修复。OpenCost 的 kubectl cost 说明明确把按标签聚合时的 Unallocated 定义为缺少该标签的资源。
Shared 是组织主动指定的分摊政策。平台控制面、入口网关、观测、CI runner 或安全代理确实服务多个团队,但“共享”不是资源的天然属性,而是由共享池成员、受益者、权重和生效版本共同定义。平台 namespace 若先作为 direct cost 进入总额,转入 shared pool 时必须从原位置移出;否则就是重复计费。
还有一类不能塞进这三个桶:账单记录无法映射到任何已知资产或 Kubernetes 对象。它是 reconciliation gap,应单列为 unmapped_billing,沿 provider resource ID、时间窗和资产生命周期排查。把它叫 Unallocated 会把数据接入故障伪装成标签治理问题。
先保存原始桶,再生成管理视图
成本流水至少保留这些字段:
window_start, window_end, source_system, source_record_id
cluster_id, asset_id, workload_id, allocation_dimension, allocation_value
cost_type, original_cost, currency, pricing_basis
policy_id, policy_revision, allocated_cost, evidence_statuscost_type 使用受控枚举,例如 direct、idle、unallocated、shared_source、unmapped_billing。original_cost 永远保存来源金额;allocated_cost 是政策运算结果。修改 owner 或权重时新建 policy revision,不回写历史来源记录。这样财务可以复算旧关账,工程团队也能判断差异来自资源变化还是规则变化。
在一个固定窗口和币种中,原始守恒关系是:
SourceTotal = Direct + Idle + SharedSource + Unallocated + UnmappedBilling分摊后的管理视图可以写成:
Recipient(i) = Direct(i) + SharedSource * sharedWeight(i) + Idle * idleWeight(i)
sum(sharedWeight) = 1
sum(idleWeight) = 1
sum(Recipient) + Unallocated + UnmappedBilling = SourceTotal是否把 Idle 分给业务是治理选择,不是技术真理。按 direct cost 比例分摊容易复算,但会让当前花费高的团队承担更多闲置;按 requests 分摊体现容量声明责任,却可能奖励低报 request;按固定配额分摊适合平台预留;按业务指标分摊更接近受益关系,但要求指标稳定、可审计。选择哪一种,都要保存分母缺失、零权重和四舍五入余数的处理方式。
分摊引擎实际上是一张有向成本图
共享成本不是把一个数字乘上百分比那么简单。生产系统通常存在多级关系:云账号费用先进入集群,集群平台费用再进入 namespace,namespace 中的网关或观测费用再分给应用,最后才汇总到成本中心。可以把每个原始成本桶看成图中的源节点,把受益对象看成目标节点,把规则权重看成有向边。引擎必须按拓扑顺序执行,并禁止共享池经过若干层后重新指向自己,否则循环分摊会让金额反复膨胀。
每一轮运算都应产生不可变的 allocation edge:from_id、to_id、policy_revision、weight_basis、numerator、denominator、amount_before_rounding 和 amount_after_rounding。查询某个团队成本时,不只返回最终金额,还能沿边反查它承担了哪些平台资产、使用了什么分母。争议因此从“这个数不对”收敛为“哪条边、哪个分母或哪版规则不对”。
舍入也必须是确定性算法。若按货币最小单位逐条四舍五入,受益者数量越多,累计偏差越大。常用做法是先保留高精度金额,再采用最大余数法:先向下取整到最小货币单位,把剩余单位按小数余数从大到小分配;余数相同时用稳定的 recipient ID 排序。这样每次重算顺序一致,且分配后严格等于共享池金额。负成本、credit 和退款应使用同一符号规则参与计算,不能先取绝对值再分摊。
零分母是工程分支,不是除零异常。某窗口内业务 direct cost 为零,却仍有平台基础费用时,可选择保留为 unallocated_shared、按预先批准的固定配额分配,或延用上一个有效周期的权重。延用历史权重会让新旧组织关系错位,只适合短窗口并必须带过期时间;临时平均分配虽然简单,却常把已下线团队重新带回账单。未明确政策时,宁可保留未分配,也不要让程序偷偷兜底。
策略选型先看因果关系,再看报表是否好看
按 direct cost 比例适合共享服务消耗与总体资源规模近似线性的场景,例如通用监控与基础节点管理;优点是数据现成、规则稳定,缺点是成本高的团队会继续承担更高的平台税。按 requests、CPU-hours 或内存时长适合容量驱动型服务,但必须处理过度申请、低估申请和不同资源不可直接相加的问题。按调用量、日志量、构建分钟数等驱动指标最接近因果关系,代价是引入新的计量链、数据延迟和指标被操纵风险。
固定配额适合合同席位、独享保底或组织预算;它可预测,却不能反映短期使用变化。平均分配只适用于受益关系确实均等、团队集合稳定的小规模公共服务,不应作为缺少数据时的默认算法。对外 chargeback 还要考虑规则可解释性和关账稳定性,内部 showback 可以先保留多个模拟视图,用历史数据比较团队波动、零分母次数和争议量后再决定。
工具选型也由这组约束决定。成本工具内置的 shareIdle 或共享筛选适合交互分析和单级规则;当组织需要多级共享池、审批、历史重算与财务入账时,应把原始分配结果送入独立规则引擎或分析仓库。纯 SQL 模型便于审计和回放,适合批量关账;流式引擎能降低新鲜度,但事件乱序、账单修订与版本回滚更复杂。不要因为 dashboard 已经提供一个“分摊”开关,就把它当成组织唯一账本。
从 API 取回未加工证据
已有 OpenCost 时,可先把 API 留在本机回环地址。生产环境不要为下载报表临时开放公网入口:
kubectl -n opencost port-forward service/opencost 9003:9003
curl -fsS --get 'http://127.0.0.1:9003/allocation' \
--data-urlencode 'window=1d' \
--data-urlencode 'aggregate=namespace' \
--data-urlencode 'includeIdle=true' \
--data-urlencode 'shareIdle=false' \
--data-urlencode 'idleByNode=true' \
-o allocation-raw.json预期响应保留 namespace 分配项和 __idle__,而不是直接给出一张已经“看起来都归属了”的报表。window、aggregate、includeIdle、shareIdle 和 idleByNode 共同改变查询语义;两张报表若这些参数不同,即使标题相同也不能直接比较。接口行为与参数应从 OpenCost Allocation API核对。
再按责任标签查询一次,不要覆盖第一份原始响应:
curl -fsS --get 'http://127.0.0.1:9003/allocation' \
--data-urlencode 'window=1d' \
--data-urlencode 'aggregate=label:cost-center' \
--data-urlencode 'includeIdle=true' \
--data-urlencode 'shareIdle=false' \
-o allocation-by-cost-center.json如果第二份出现 __unallocated__,先找缺失 label 的 workload,并回到 controller template 修复;只给现存 Pod 临时打标签会在下一次发布后再次漂移。若 Prometheus relabel 或成本采集链丢掉标签,即使 Kubernetes 对象上存在,也会表现为 Unallocated,因此要同时检查源对象和采集后的标签集。
正向实验:用最小账本证明再分摊守恒
下面的本地脚本不需要云账号,只验证规则。把它保存为 allocation-lab.mjs,使用 Node.js 运行:
const rows = [
{ bucket: "direct", owner: "team-a", cost: 40 },
{ bucket: "direct", owner: "team-b", cost: 30 },
{ bucket: "shared_source", owner: "platform", cost: 12 },
{ bucket: "idle", owner: null, cost: 10 },
{ bucket: "unallocated", owner: null, cost: 5 },
{ bucket: "unmapped_billing", owner: null, cost: 3 },
];
const recipients = ["team-a", "team-b"];
const direct = Object.fromEntries(recipients.map((owner) => [
owner,
rows.filter((r) => r.bucket === "direct" && r.owner === owner)
.reduce((sum, r) => sum + r.cost, 0),
]));
const directTotal = Object.values(direct).reduce((a, b) => a + b, 0);
const shared = rows.find((r) => r.bucket === "shared_source").cost;
const idle = rows.find((r) => r.bucket === "idle").cost;
const excluded = rows.filter((r) => ["unallocated", "unmapped_billing"].includes(r.bucket))
.reduce((sum, r) => sum + r.cost, 0);
const result = Object.fromEntries(recipients.map((owner) => {
const weight = direct[owner] / directTotal;
return [owner, direct[owner] + shared * weight + idle * weight];
}));
const sourceTotal = rows.reduce((sum, r) => sum + r.cost, 0);
const finalTotal = Object.values(result).reduce((a, b) => a + b, 0) + excluded;
console.table(result);
console.log({ sourceTotal, finalTotal, delta: finalTotal - sourceTotal });
if (Math.abs(finalTotal - sourceTotal) > 1e-9) process.exit(1);node allocation-lab.mjs预期 team-a 与 team-b 获得按 direct cost 比例计算的 shared 和 idle,最后输出 sourceTotal: 100、finalTotal: 100、delta: 0。unallocated 与 unmapped_billing 仍被显式保留,说明守恒并不要求强行把每笔钱塞给业务团队。
反向实验:制造重复分摊与静默丢弃
把脚本中的 finalTotal 临时改成下面的错误算法:
const finalTotal = Object.values(result).reduce((a, b) => a + b, 0)
+ shared
+ rows.filter((r) => r.bucket === "unallocated").reduce((s, r) => s + r.cost, 0);再次运行会得到非零 delta 并以状态码 1 退出。这里同时重算了 shared source,又丢弃了 unmapped_billing;总额偏差正是算法错误的证据。更隐蔽的反例是把 Unallocated 分到团队后从质量报表中删除:总额可能仍守恒,但缺失 owner 的治理缺口被掩盖。质量门禁因此要同时检查金额守恒和桶覆盖率。
建议在数据流水线中至少保留四个断言:同一 source_record_id 只能进入一个原始桶;每组权重和为 1;分摊前后按窗口与币种的 delta 在允许的舍入余数内;Unallocated 与 unmapped 的金额和记录数不得因发布新规则而无解释骤降。示例数字用于验证算法,生产容差由币种精度、账单修订和关账政策决定。
修复错误算法后,不要只看退出码恢复为零。先恢复 finalTotal 的正确表达式,再把脚本输出保存为 fixed-run.json,与错误运行比较 sourceTotal、各桶金额、recipient 金额和 delta;预期 shared source 只消费一次,unmapped_billing 重新出现,delta 回到允许范围。随后交换两条输入记录的顺序再次运行,结果应保持一致;再加入一个 direct cost 为零的团队,验证它不会因除零得到 NaN 或意外份额。三项证据分别证明金额守恒、算法确定性和边界分支已修复。
报表接入要把规则当成代码
团队项目不要只消费最终 CSV。仓库中应保存不含真实金额的规则文件、数据契约和测试夹具,例如:
policyId: platform-shared-cost
revision: r3
sourceSelector:
namespaces: [platform-system, observability]
recipients:
dimension: label:cost-center
sharedStrategy: proportional-to-direct-cost
idleStrategy: preserve
zeroDenominator: keep-unallocated
rounding: largest-remainder
owner: finops-platform流水线先物化原始桶,再用 policy revision 生成 showback 视图;变更经代码评审后在历史副本上 dry-run,比较 owner 迁移、最大单团队变化、Unallocated 覆盖率和总额 delta。关账后若修正规则,生成 adjustment 或重算版本,不删除旧结果。财务系统、BI 和成本 API 都引用同一个 revision,避免三处各写一套分摊逻辑。
故障要从哪个桶异常开始追
Idle 突升时,先看节点、PV、LB 等资产总额是否变化,再看 workload allocation 是否因指标缺失下降;随后检查 requests、调度碎片、扩缩容和采样窗口。Idle 是结果,不自动证明某个团队浪费。
Unallocated 突升时,按缺失维度反查具体 workload,检查 controller template、admission default、GitOps 覆盖和采集 relabel。只有新部署进入 Unallocated,多半是模板或准入问题;所有历史对象同时进入,优先检查标签采集与查询参数。
Shared 突升时,比较共享池成员和规则 revision。平台 namespace 被重命名、selector 扩大或外部成本重复导入,都可能改变池大小。管理视图变化但原始桶不变,优先检查规则;原始桶本身变化,再检查资源和账单。
总额不守恒时,按 source_record_id 做重复与缺口检查,再核对窗口、币种、税费、credit、账单修订和舍入。不要先调权重把 delta “抹平”,那只会破坏审计证据。
可以把失败进一步分成四层。来源层失败表现为账单总额或资产集合突变,应检查导出分区、重放、币种与修订批次;身份层失败表现为 Unallocated 或 unmapped 集中增长,应检查标签历史、provider ID 和组织映射;规则层失败表现为原始桶稳定但团队金额迁移,应比较 policy revision、分母与共享池成员;数值层失败表现为小额且随 recipient 数量增长的 delta,应检查精度、舍入和负数。修复后沿相反方向复核:来源总额恢复、身份覆盖率回升、规则迁移符合批准清单、数值 delta 不随规模扩张。
当账本达到亿级边数时,不能每次查询现场递归整张图。原始事实按窗口和来源分区,规则边按 revision 物化,团队日/月汇总作为可重建缓存;重算队列按受影响的共享池和时间范围裁剪,而不是全历史刷新。容量预算同时估算原始记录、分摊边、规则版本、重算副本和审计日志。边数近似为来源记录数乘平均受益者数,平均分配给几百个团队的规则会产生明显写放大,应先在上层聚合或使用稀疏权重。
权限、敏感数据与长期责任
成本记录会暴露账号结构、租户规模、内部服务名、折扣和预算。采集身份只读指定成本数据集与 Kubernetes 元数据;规则维护者可以提交 policy,但不能改原始账单;关账审批者不能静默修改 owner 映射。导出给业务团队的视图移除云账号、资源全局 ID、合同单价和其他租户明细,只保留其可见范围与可复算摘要。
规则 owner 负责权重、零分母、舍入和争议流程;平台 owner 负责共享资源成员与容量事实;应用 owner 负责标签模板和缺失映射;财务 owner 负责币种、账期、修订与关账。每次升级成本工具或更换数据源,都用固定夹具和一个重叠窗口比较 direct、idle、unallocated、shared source 与总额,不只比较首页总数。
规则发布采用双版本而不是原地覆盖。新 revision 先在只读历史窗口生成影子结果,门禁检查守恒、最大团队涨跌、零分母、未分配率和图循环;通过审批后只切换管理视图的版本指针。异常时把指针切回旧 revision,并保留失败结果、输入摘要和差异报告用于复盘。若新规则已经进入财务关账,则通过 adjustment 冲正,不修改已发布账期。长期责任还包括季度清理失效团队、共享池成员和过期例外,年度复核分摊因果关系,避免一条“临时规则”永久改变组织成本。
退出某套成本工具时,先导出原始桶、规则 revision、owner 映射、查询参数和对账结果,再并行生成替代视图。确认替代链在相同窗口内守恒、Unallocated 可追溯、争议可复算后,撤销 API token、端口转发入口和账单读取身份;最后按保留政策清理临时 JSON 与含成本明细的本地文件。
