学习目标
学完本模块,你应该能够:
- 解释成本计量的原始数据为何必须直接来自联邦 Prometheus 指标,而非节点账单或手工填报,并说清
tenant/app/env/cluster四个标签的作用。 - 配置按租户 / 应用 / 环境的三级分摊规则,理解共享资源(网络、控制面、存储)如何公平分摊。
- 生成月度账单并导出,接入财务对账;能核对账单金额与指标聚合是否一致。
- 设置预算阈值与配额预警,当某租户超预算时触发 Alertmanager 告警并联动审批流冻结超额。
- 利用联邦指标识别闲置资源(低利用率 Pod、未绑定 PVC、空跑中间件),输出回收与降配建议。
前置知识:
- 了解 Kubernetes 资源模型(requests/limits、QoS、存储卷)。
- 读过本白皮书「模块 05 统一观测平台」,理解 Prometheus 联邦 + Thanos 架构与标签体系。
- 读过「模块 02 多 K8s 集群纳管」「模块 07 安全管控」,理解 Namespace 租户隔离与安全事件进指标。
- 基本了解账单、分摊、预算等财务概念。
本章你会动手做的事:
- 在联邦 Grafana 打开成本大盘,按
tenant维度查看本月 CPU/内存折算费用 Top 5。 - 用 PromQL 自己算一次某租户当月的 CPU 核·小时:
sum(integrate(...))思路验证计量引擎口径。 - 给测试租户设一个很小的预算阈值,故意超量,观察 Alertmanager 预算告警与配额冻结。
一、模块概述与企业商用价值
类比:成本计量就像写字楼按「办公室独立电表」收费——每间办公室(租户 Namespace)装了带标签的电表,物业(平台)每月按表读数 × 单价出账单;公共区域(电梯、空调,即网络与控制面)再按面积(资源占用)平摊。如果电表读数直接来自楼宇智能电表(联邦指标),就没人能「手工改表」糊弄。
对企业自研商用 PaaS,成本模块是「内部结算 / 对外计费」的信任基础。它让平台运营方能够向各业务线、各子公司证明「你的费用来自真实用量」,也支撑对外商业化按量计费。其不可替代性在于:计量数据与可观测体系同源,自带租户/环境/集群隔离,审计可追,无法被单侧篡改。
适用角色:平台运营 / FinOps 工程师、租户应用负责人(看自己账单)、财务对账人员、云资源采购。
核心商业价值:
- 透明可信:费用源自联邦指标,口径统一、可追溯、可复核。
- 责任到租户:按租户/应用/环境分摊,避免「大锅饭」导致资源滥用。
- 防患未然:预算预警在超支前叫停,闲置识别直接降本。
下面这张图说明成本模块在平台中的定位:它消费联邦 Prometheus 指标,反向驱动集群配额与预算审批。
flowchart LR
A[企业自研PaaS平台] --> B[多K8s集群 加 API网关]
A --> C[联邦Prometheus指标源]
A --> D[成本与资源账单模块]
D --> D1[资源计量]
D --> D2[分摊规则]
D --> D3[预算预警]
D --> D4[闲置识别]
C -.原始指标.-> D
D -.驱动配额.-> B项目结构与文件清单
本模块实现代码按「平台 Go 计量引擎 + K8s/Prometheus IaC」组织,目录树如下(后续章节逐一完整展示每个文件):
cost-module/
├── go.mod # Go module 依赖(gin + prometheus client)
├── cmd/cost-server/
│ └── main.go # gin HTTP 服务:成本查询/账单/预算 + /metrics
├── internal/cost/
│ ├── metrics/federated.go # 联邦 Prometheus 查询客户端(HTTP /api/v1/query)
│ ├── engine/allocate.go # 按 tenant/app/env/cluster 分摊(用量×单价×时长)
│ ├── engine/billing.go # 月度账单生成与 CSV 导出、对账复核
│ └── budget/check.go # 预算校验、超阈值告警(导出 budget_limit_cents)
└── deploy/prometheus/
├── servicemonitor.yaml # 采集成本服务指标的 ServiceMonitor
├── cost-recording-rules.yaml # 标签注入 + 成本 recording rules(带租户标签)
└── budget-alerts.yaml # 预算超阈值 / 预测超阈值告警规则
端到端调用关系:各集群 Agent 采集带
tenant/app/env/cluster的指标 → 中心 Prometheus + Thanos;metrics/federated.go拉取原始用量;engine/allocate.go按单价分摊;engine/billing.go出账 CSV;budget/check.go比预算并告警;结果经cost-recording-rules.yaml沉淀为paas:total_cost_cents:by_tenant_app_env_cluster,并被budget-alerts.yaml引用。
二、细分功能详解(商用生产级)
下表区分【基础能力】与【高级企业增值能力】。标注「★禁止删减」的三项(计量准确性、分摊规则、审计)是商用计费底线——任何交付都不得省略,否则账单不可信、审计不通过。
| 分类 | 功能 | 数据来源 | 说明 | 删减约束 |
|---|---|---|---|---|
| 基础能力 | ① 资源计量 | 联邦 Prometheus 指标 | CPU/内存/存储/网络/中间件实例按标签聚合 | ★禁止删减(计量准确性) |
| 基础能力 | ② 分摊规则 | 联邦指标 + 规则引擎 | 按租户/应用/环境分摊,共享资源按比例 | ★禁止删减(分摊规则) |
| 基础能力 | ③ 账单生成与导出 | 计量聚合结果 | 月度账单、CSV/API 导出、对账凭证 | ★禁止删减(审计) |
| 高级企业增值能力 | ④ 预算与配额预警 | 计量 + Alertmanager | 超阈值冻结超额,需审批解封 | 建议保留 |
| 高级企业增值能力 | ⑤ 闲置资源识别 | 联邦指标趋势 | 低利用率 Pod、未绑 PVC、空跑中间件 | 建议保留 |
| 高级企业增值能力 | ⑥ 成本优化建议 | 计量 + 调度数据 | 降配、调度均衡、竞价建议 | 建议保留 |
| 高级企业增值能力 | ⑦ 计费策略与对账 | 账单 + 财务系统 | 阶梯单价、折扣、与云账单对账 | 建议保留 |
| 高级企业增值能力 | ⑧ 超预算审批联动 | 审批流 | 超额消费触发多级审批,不可绕过 | 建议保留 |
基础能力逐项拆解
① 资源计量(★禁止删减——计量准确性):直接查询联邦 Prometheus。核心指标:
container_cpu_usage_seconds_total{tenant,app,env,cluster}:CPU 用量,积分得核·秒。container_memory_working_set_bytes{tenant,app,env,cluster}:内存工作集。kube_persistentvolumeclaim_resource_requests_storage_bytes{tenant,app,env,cluster}:存储用量。- 网络与中间件(MySQL/Redis 等)实例指标同样带标签。
计量引擎按 (用量 × 时长 × 单价) 折算,所有原始值来自中心 Prometheus + Thanos,不接受节点级手工填报,以保证准确性与防篡改。
下面是联邦 Prometheus 查询客户端(Go,直接调 /api/v1/query,与联邦架构同源,无需 node 级数据):
// file: internal/cost/metrics/federated.go
package metrics
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// PrometheusClient 联邦 Prometheus HTTP 查询客户端。
type PrometheusClient struct {
baseURL string
httpClient *http.Client
}
func NewPrometheusClient(baseURL string) *PrometheusClient {
return &PrometheusClient{
baseURL: strings.TrimRight(baseURL, "/"),
httpClient: &http.Client{Timeout: 30 * time.Second},
}
}
type promResponse struct {
Status string `json:"status"`
Data struct {
ResultType string `json:"resultType"`
Result json.RawMessage `json:"result"`
} `json:"data"`
}
// Vector 单条即时向量样本。
type Vector struct {
Metric map[string]string `json:"metric"`
Value []interface{} `json:"value"`
}
// Matrix 区间样本(用于趋势分析)。
type Matrix struct {
Metric map[string]string `json:"metric"`
Values [][]interface{} `json:"values"`
}
// Query 即时查询,返回向量。
func (c *PrometheusClient) Query(ctx context.Context, expr string) ([]Vector, error) {
q := url.Values{}
q.Set("query", expr)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/api/v1/query?"+q.Encode(), nil)
if err != nil {
return nil, err
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("query prometheus: %w", err)
}
defer resp.Body.Close()
var pr promResponse
if err := json.NewDecoder(resp.Body).Decode(&pr); err != nil {
return nil, err
}
if pr.Status != "success" {
return nil, fmt.Errorf("prometheus query status: %s", pr.Status)
}
var vectors []Vector
if err := json.Unmarshal(pr.Data.Result, &vectors); err != nil {
return nil, err
}
return vectors, nil
}
// QueryRange 区间查询,返回矩阵。
func (c *PrometheusClient) QueryRange(ctx context.Context, expr, start, end, step string) ([]Matrix, error) {
q := url.Values{}
q.Set("query", expr)
q.Set("start", start)
q.Set("end", end)
q.Set("step", step)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/api/v1/query_range?"+q.Encode(), nil)
if err != nil {
return nil, err
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("query_range prometheus: %w", err)
}
defer resp.Body.Close()
var pr promResponse
if err := json.NewDecoder(resp.Body).Decode(&pr); err != nil {
return nil, err
}
var m []Matrix
if err := json.Unmarshal(pr.Data.Result, &m); err != nil {
return nil, err
}
return m, nil
}
// ValueToFloat 把 PromQL 返回的 [ts, "val"] 中数值解析为 float64。
func ValueToFloat(v []interface{}) (float64, error) {
if len(v) < 2 {
return 0, fmt.Errorf("invalid value")
}
switch val := v[1].(type) {
case string:
return strconv.ParseFloat(val, 64)
case float64:
return val, nil
default:
return 0, fmt.Errorf("unexpected value type")
}
}
下面是成本分摊引擎(Go):拉取联邦原始用量,按 tenant/app/env/cluster 聚合,乘以单价与时长,直接归属资源精确记账:
// file: internal/cost/engine/allocate.go
package engine
import (
"context"
"fmt"
"strconv"
"strings"
"github.com/your-org/paas-cost/internal/cost/metrics"
)
// Price 单价配置(单位:分)。
type Price struct {
CPUCentsPerCoreHour float64
MemCentsPerGiBHour float64
StorageCentsPerGiBDay float64
NetCentsPerGiB float64
}
// CostCell 单个 tenant/app/env/cluster 维度的费用明细(分)。
type CostCell struct {
Tenant string
App string
Env string
Cluster string
CPUCents float64
MemCents float64
StorageCents float64
NetCents float64
TotalCents float64
}
// parseDays 从 "30d" 解析天数(存储/网络按天计费用)。
func parseDays(window string) int {
d, err := strconv.Atoi(strings.TrimSuffix(window, "d"))
if err != nil {
return 30
}
return d
}
// Allocate 从联邦 Prometheus 拉取原始用量并按 tenant/app/env/cluster 分摊成费用。
func Allocate(ctx context.Context, pc *metrics.PrometheusClient, p Price, window string) ([]CostCell, error) {
cells := map[string]*CostCell{}
days := parseDays(window)
collect := func(expr string, factor float64, set func(*CostCell, float64)) error {
vs, err := pc.Query(ctx, expr)
if err != nil {
return err
}
for _, v := range vs {
t, a, e, cl := v.Metric["tenant"], v.Metric["app"], v.Metric["env"], v.Metric["cluster"]
if t == "" || a == "" || e == "" || cl == "" {
// 缺标签用量记入平台公共池(见 runbook),此处跳过避免错记到未知租户
continue
}
val, err := metrics.ValueToFloat(v.Value)
if err != nil {
continue
}
k := strings.Join([]string{t, a, e, cl}, "|")
cell, ok := cells[k]
if !ok {
cell = &CostCell{Tenant: t, App: a, Env: e, Cluster: cl}
cells[k] = cell
}
set(cell, val*factor)
}
return nil
}
cpuExpr := fmt.Sprintf(`sum by (tenant,app,env,cluster) (increase(container_cpu_usage_seconds_total{container!=""}[%s])) / 3600`, window)
memExpr := fmt.Sprintf(`sum by (tenant,app,env,cluster) (avg_over_time(container_memory_working_set_bytes{container!=""}[%s])) / 1073741824`, window)
stoExpr := `sum by (tenant,app,env,cluster) (kube_persistentvolumeclaim_resource_requests_storage_bytes) / 1073741824`
netExpr := fmt.Sprintf(`sum by (tenant,app,env,cluster) (increase(node_network_transmit_bytes_total{device!~"lo"}[%s])) / 1073741824`, window)
if err := collect(cpuExpr, p.CPUCentsPerCoreHour, func(c *CostCell, v float64) { c.CPUCents = v }); err != nil {
return nil, fmt.Errorf("cpu: %w", err)
}
if err := collect(memExpr, p.MemCentsPerGiBHour, func(c *CostCell, v float64) { c.MemCents = v }); err != nil {
return nil, fmt.Errorf("mem: %w", err)
}
// 存储为瞬时请求量(GiB),按月需乘以天数
if err := collect(stoExpr, p.StorageCentsPerGiBDay*float64(days), func(c *CostCell, v float64) { c.StorageCents = v }); err != nil {
return nil, fmt.Errorf("storage: %w", err)
}
if err := collect(netExpr, p.NetCentsPerGiB, func(c *CostCell, v float64) { c.NetCents = v }); err != nil {
return nil, fmt.Errorf("net: %w", err)
}
out := make([]CostCell, 0, len(cells))
for _, c := range cells {
c.TotalCents = c.CPUCents + c.MemCents + c.StorageCents + c.NetCents
out = append(out, *c)
}
return out, nil
}
下面是月度账单生成与 CSV 导出、对账复核(Go,对应★禁止删减——审计):
// file: internal/cost/engine/billing.go
package engine
import (
"encoding/csv"
"sort"
"strconv"
"strings"
)
// GenerateBillCSV 按租户/应用/环境/集群明细生成可导出 CSV(含审计字段 month)。
func GenerateBillCSV(cells []CostCell, month string) (string, error) {
var sb strings.Builder
w := csv.NewWriter(&sb)
header := []string{"month", "tenant", "app", "env", "cluster",
"cpu_cents", "mem_cents", "storage_cents", "net_cents", "total_cents", "currency"}
if err := w.Write(header); err != nil {
return "", err
}
sort.Slice(cells, func(i, j int) bool {
if cells[i].Tenant != cells[j].Tenant {
return cells[i].Tenant < cells[j].Tenant
}
return cells[i].App < cells[j].App
})
for _, c := range cells {
row := []string{
month, c.Tenant, c.App, c.Env, c.Cluster,
strconv.FormatFloat(c.CPUCents, 'f', 2, 64),
strconv.FormatFloat(c.MemCents, 'f', 2, 64),
strconv.FormatFloat(c.StorageCents, 'f', 2, 64),
strconv.FormatFloat(c.NetCents, 'f', 2, 64),
strconv.FormatFloat(c.TotalCents, 'f', 2, 64),
"CNY",
}
if err := w.Write(row); err != nil {
return "", err
}
}
w.Flush()
if err := w.Error(); err != nil {
return "", err
}
return sb.String(), nil
}
// TenantRollup 按租户汇总(用于月度账单总额与对账)。
func TenantRollup(cells []CostCell) map[string]float64 {
out := map[string]float64{}
for _, c := range cells {
out[c.Tenant] += c.TotalCents
}
return out
}
// TotalCents 返回账单合计(分),供对账复核。
func TotalCents(cells []CostCell) float64 {
var sum float64
for _, c := range cells {
sum += c.TotalCents
}
return sum
}
⚠️ 新手坑:不要用
kubectl top或单次采样估算月费用——它只是瞬时值。必须用联邦指标做时间积分(increase()/avg_over_time()),否则账单会与真实用量偏差数倍。计量引擎的Allocate已用increase(...[30d])与avg_over_time(...[30d])做月度积分。
② 分摊规则(★禁止删减):直接归属资源(Pod 的 CPU/内存)按 tenant/app/env 精确记账;共享资源(集群控制面开销、网络出口、镜像存储、节点系统预留)按各租户「已计量用量占比」二次分摊,规则在规则引擎里显式配置且版本化,保证可复算。下面给出成本 recording rules(带租户标签)——先用 kube_pod_labels 把 cAdvisor 指标注入 tenant/app/env,再按资源×单价折算(cluster 由 Agent 外部标签提供):
# file: deploy/prometheus/cost-recording-rules.yaml
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: cost-recording-rules
namespace: paas-system
labels:
role: recording-rules
spec:
groups:
- name: cost-enrich
interval: 1m
rules:
# 将 cAdvisor 指标按 kube_pod_labels 注入 tenant/app/env,cluster 由 Agent 外部标签提供
- record: paas:container_cpu_usage_seconds_total:by_tenant_app_env_cluster
expr: |
sum by (tenant, app, env, cluster) (
container_cpu_usage_seconds_total{container!=""}
* on (pod) group_left(tenant, app, env)
max by (pod, tenant, app, env) (
kube_pod_labels{label_tenant!=""} * 0 + 1
)
)
- record: paas:container_memory_working_set_bytes:by_tenant_app_env_cluster
expr: |
sum by (tenant, app, env, cluster) (
container_memory_working_set_bytes{container!=""}
* on (pod) group_left(tenant, app, env)
max by (pod, tenant, app, env) (
kube_pod_labels{label_tenant!=""} * 0 + 1
)
)
- name: cost-price
interval: 5m
rules:
- record: paas:cpu_cost_cents:hourly
expr: |
(sum by (tenant, app, env, cluster) (increase(paas:container_cpu_usage_seconds_total:by_tenant_app_env_cluster[1h])) / 3600) * 3.0
- record: paas:mem_cost_cents:hourly
expr: |
(sum by (tenant, app, env, cluster) (avg_over_time(paas:container_memory_working_set_bytes:by_tenant_app_env_cluster[1h])) / 1073741824) * 1.5
- record: paas:storage_cost_cents:daily
expr: |
(sum by (tenant, app, env, cluster) (kube_persistentvolumeclaim_resource_requests_storage_bytes) / 1073741824) * 0.1
- record: paas:network_cost_cents:daily
expr: |
(sum by (tenant, app, env, cluster) (increase(node_network_transmit_bytes_total{device!~"lo"}[1d])) / 1073741824) * 0.5
- record: paas:total_cost_cents:by_tenant_app_env_cluster
expr: |
paas:cpu_cost_cents:hourly + paas:mem_cost_cents:hourly + paas:storage_cost_cents:daily + paas:network_cost_cents:daily
③ 账单生成与导出(★禁止删减——审计):每月按时区结算,生成按租户汇总的账单,支持 CSV 导出与开放 API 拉取;每次计量、分摊、出账都写审计日志(含口径版本、数据区间、操作人),保留 90 天以上,支撑财务对账与外部审计。
高级企业增值能力
- ④ 预算与配额预警:为租户/应用设月度预算,计量引擎实时累计,超阈值经 Alertmanager 告警,超额部分默认冻结(需审批解封)。
- ⑤ 闲置资源识别:基于联邦指标趋势,标记 CPU 利用率长期低于阈值的 Pod、未绑定 PVC、空闲中间件实例。
- ⑥ 成本优化建议:结合调度数据给出降配(调小 requests)、拓扑均衡、竞价/Spot 迁移建议。
- ⑦ 计费策略与对账:支持阶梯单价、内部折扣,并能与底层云账单逐层对账(平台费 + 云资源费)。
- ⑧ 超预算审批联动:超额消费触发多级审批,审批通过才由网关放行更多配额,流程不可绕过。
下面是预算校验与超阈值告警(Go,导出 budget_limit_cents / budget_exceeded,并被 budget-alerts.yaml 引用):
// file: internal/cost/budget/check.go
package budget
import (
"context"
"fmt"
"net/http"
"strings"
"github.com/prometheus/client_golang/prometheus"
"github.com/your-org/paas-cost/internal/cost/engine"
"github.com/your-org/paas-cost/internal/cost/metrics"
)
var (
budgetLimit = prometheus.NewGaugeVec(prometheus.GaugeOpts{
Name: "budget_limit_cents",
Help: "租户月度预算上限(分)",
}, []string{"tenant"})
budgetExceeded = prometheus.NewGaugeVec(prometheus.GaugeOpts{
Name: "budget_exceeded",
Help: "租户是否超预算 1=超 0=未超",
}, []string{"tenant"})
)
func init() {
prometheus.MustRegister(budgetLimit, budgetExceeded)
}
// Budget 单租户预算。
type Budget struct {
Tenant string
LimitCents float64
}
// SetBudget 设置租户预算(写审计日志后生效,并导出 budget_limit_cents 指标供 Prometheus 规则引用)。
func SetBudget(b Budget) {
budgetLimit.WithLabelValues(b.Tenant).Set(b.LimitCents)
}
// Result 单租户预算校验结果。
type Result struct {
Tenant string
CostCents float64
LimitCents float64
Exceeded bool
}
// Check 当前窗口内各租户费用与预算比对,超阈值返回结果、上报指标并推送 Alertmanager。
func Check(ctx context.Context, pc *metrics.PrometheusClient, price engine.Price, budgets []Budget, window string) ([]Result, error) {
cells, err := engine.Allocate(ctx, pc, price, window)
if err != nil {
return nil, fmt.Errorf("allocate: %w", err)
}
rollup := engine.TenantRollup(cells)
out := make([]Result, 0, len(budgets))
for _, b := range budgets {
cost := rollup[b.Tenant]
exceeded := cost > b.LimitCents
if exceeded {
budgetExceeded.WithLabelValues(b.Tenant).Set(1)
} else {
budgetExceeded.WithLabelValues(b.Tenant).Set(0)
}
out = append(out, Result{Tenant: b.Tenant, CostCents: cost, LimitCents: b.LimitCents, Exceeded: exceeded})
if exceeded {
if err := alertToAlertmanager(b.Tenant, cost, b.LimitCents); err != nil {
// 告警发送失败不阻断校验
_ = err
}
}
}
return out, nil
}
// alertToAlertmanager 向 Alertmanager 推送预算超阈值告警。
func alertToAlertmanager(tenant string, cost, limit float64) error {
payload := fmt.Sprintf(
`[{"labels":{"alertname":"BudgetExceeded","tenant":%q,"severity":"warning"},"annotations":{"summary":"租户 %s 超预算"}}]`,
tenant, tenant)
req, err := http.NewRequest(http.MethodPost, "http://alertmanager.paas.local:9093/api/v2/alerts", strings.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("alertmanager status %d", resp.StatusCode)
}
return nil
}
最后是成本服务入口 main.go(gin 统一编排 + /metrics 导出联邦指标):
// file: cmd/cost-server/main.go
package main
import (
"log"
"net/http"
"os"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/your-org/paas-cost/internal/cost/budget"
"github.com/your-org/paas-cost/internal/cost/engine"
"github.com/your-org/paas-cost/internal/cost/metrics"
)
func main() {
pc := metrics.NewPrometheusClient(os.Getenv("PROMETHEUS_URL"))
price := engine.Price{
CPUCentsPerCoreHour: 3.0,
MemCentsPerGiBHour: 1.5,
StorageCentsPerGiBDay: 0.1,
NetCentsPerGiB: 0.5,
}
r := gin.Default()
r.GET("/api/v1/cost", func(c *gin.Context) {
window := c.DefaultQuery("window", "30d")
cells, err := engine.Allocate(c.Request.Context(), pc, price, window)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, cells)
})
r.GET("/api/v1/bill", func(c *gin.Context) {
month := c.DefaultQuery("month", "2026-08")
window := c.DefaultQuery("window", "30d")
cells, err := engine.Allocate(c.Request.Context(), pc, price, window)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
csvStr, err := engine.GenerateBillCSV(cells, month)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Header("Content-Type", "text/csv")
c.Header("Content-Disposition", "attachment; filename=bill-"+month+".csv")
c.String(http.StatusOK, csvStr)
})
r.POST("/api/v1/budget", func(c *gin.Context) {
var req struct {
Tenant string `json:"tenant"`
LimitCents float64 `json:"limit_cents"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
budget.SetBudget(budget.Budget{Tenant: req.Tenant, LimitCents: req.LimitCents})
c.JSON(http.StatusOK, gin.H{"ok": true})
})
r.GET("/api/v1/budget/check", func(c *gin.Context) {
window := c.DefaultQuery("window", "30d")
budgets := loadBudgets()
results, err := budget.Check(c.Request.Context(), pc, price, budgets, window)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, results)
})
r.GET("/metrics", gin.WrapH(promhttp.Handler()))
log.Println("cost-server listening :8081")
if err := r.Run(":8081"); err != nil {
log.Fatal(err)
}
}
func loadBudgets() []budget.Budget {
raw := os.Getenv("BUDGETS") // 形如 tenantA:5000000,tenantB:3000000
out := []budget.Budget{}
for _, pair := range strings.Split(raw, ",") {
kv := strings.SplitN(strings.TrimSpace(pair), ":", 2)
if len(kv) != 2 {
continue
}
v, err := strconv.ParseFloat(kv[1], 64)
if err != nil {
continue
}
out = append(out, budget.Budget{Tenant: kv[0], LimitCents: v})
}
return out
}
# file: go.mod
module github.com/your-org/paas-cost
go 1.22
require (
github.com/gin-gonic/gin v1.10.0
github.com/prometheus/client_golang v1.19.0
)
下面这张功能架构图展示基础能力与高级企业增值能力的分层关系。
flowchart TD
S[成本与资源账单模块] --> B1[基础能力]
S --> B2[高级企业增值能力]
B1 --> F1[资源计量
CPU 加 内存 加 存储 加 网络]
B1 --> F2[分摊规则
租户 加 应用 加 环境]
B1 --> F3[账单生成与导出]
B2 --> F4[预算与配额预警]
B2 --> F5[闲置资源识别]
B2 --> F6[成本优化建议]
B2 --> F7[计费策略与对账]
B2 --> F8[超预算审批联动]三、底层架构联动设计
1. 与多 K8s 集群交互逻辑
计量引擎只读访问联邦层,不直接操作集群。它经统一 K8s API 网关的受限只读角色拉取 ResourceQuota、节点容量等元数据,结合联邦指标计算。配额下发(超预算冻结)经网关写回对应租户 Namespace 的 ResourceQuota。
2. 与联邦 Prometheus 监控联动(必须绑定联邦架构)
这是本模块的核心约束:计量数据直接来自联邦 Prometheus 指标。各物理隔离集群的 Prometheus Agent 采集本地 Pod/节点/中间件指标,汇总到中心 Prometheus + Thanos;计量引擎按 tenant/app/env/cluster 标签聚合,输出账单与成本大盘,超预算事件进 Alertmanager。
flowchart LR
subgraph 各集群
K1[生产集群Pod] --> A1[Prometheus Agent]
K2[测试集群Pod] --> A2[Prometheus Agent]
M1[中间件实例] --> A1
end
A1 -->|指标 带tenant app env cluster| C[中心Prometheus 加 Thanos]
A2 -->|指标 带tenant app env cluster| C
C --> M[计量引擎 按标签聚合]
M --> B[账单 加 分摊 加 Grafana成本大盘]
B --> Alert[预算超阈值告警]关键指标:
container_cpu_usage_seconds_total{tenant,app,env,cluster}container_memory_working_set_bytes{tenant,app,env,cluster}kube_persistentvolumeclaim_resource_requests_storage_bytes{tenant,app,env,cluster}node_network_transmit_bytes_total{tenant,app,env,cluster}(网络分摊)- 中间件指标(如
mysql_global_status_bytes_*、Redis 内存)同样带标签
所有指标经 Thanos 长期存储,保证跨月对账可回溯,与审计口径一致。下面是采集成本服务自身指标与联邦汇聚的 ServiceMonitor:
# file: deploy/prometheus/servicemonitor.yaml
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: paas-cost-server
namespace: paas-system
labels:
team: finops
spec:
selector:
matchLabels:
app: paas-cost-server
endpoints:
- port: http
path: /metrics
interval: 30s
namespaceSelector:
any: true
3. 与 RBAC / 审批 / 审计联动
- RBAC:租户仅能看自身账单;计量规则配置权仅限 FinOps 平台角色。
- 审批流:超预算解封、分摊规则变更需多级审批,审批事件触发网关配额调整。
- 审计:每次出账、规则变更、超预算处理写入审计日志,保留 90 天以上,且不可被租户侧删除(满足★禁止删减——审计)。
四、端到端标准操作流程
分三种角色给出流程。下面以「月度账单生成 + 超预算预警」为例,用时序图说明跨角色协作。
sequenceDiagram
participant Agent as 集群Prometheus Agent
participant Center as 中心Prometheus
participant Engine as 计量引擎
participant Bill as 账单服务
participant Admin as 平台FinOps管理员
participant Alert as Alertmanager
Agent->>Center: 上报用量指标(带标签)
Center->>Engine: 按tenant app env聚合
Engine->>Bill: 生成月度账单
Bill->>Admin: 推送账单 加 导出CSV
alt 某租户超预算阈值
Engine->>Alert: 触发预算告警
Alert->>Admin: 通知 加 冻结超额(需审批)
end角色 A:平台 FinOps 管理员
- 配置单价与分摊规则版本(见
cost-recording-rules.yaml) - 设租户月度预算:调用成本服务
POST /api/v1/budget - 月结日触发计量引擎出账:
GET /api/v1/bill?month=2026-08 - 导出 CSV 并推送财务对账;复核
账单总额 == 联邦指标积分 × 单价
# 运维 runbook:设置预算 + 生成月度账单 CSV(对应 budget.SetBudget / engine.GenerateBillCSV)
curl -X POST http://cost-server.paas.local:8081/api/v1/budget \
-H 'Content-Type: application/json' -d '{"tenant":"A","limit_cents":5000000}'
curl -s 'http://cost-server.paas.local:8081/api/v1/bill?month=2026-08&window=30d' -o bill-2026-08.csv
echo "账单总额(分):"
curl -s 'http://cost-server.paas.local:8081/api/v1/cost?window=30d' | jq 'map(.TotalCents) | add'
角色 B:租户应用负责人
- 在联邦 Grafana 成本大盘查看自身
tenant维度费用构成 - 接收超预算告警后,提交降配/扩容审批,或申请预算调整(走审批流)
- 根据闲置识别报告下线空跑 Pod / 未绑 PVC
角色 C:集群运维
- 维护节点容量元数据,确保联邦指标中节点标签完整(否则分摊分母缺失)
- 处理计量引擎告警的采集缺口(Agent 掉线)
【测试环境】 vs 【生产环境】差异步骤
- 测试环境:可用模拟单价、不真实出账;预算阈值宽松、超预算仅记录;分摊规则可随意试配;账单不进财务。
- 生产环境:必须用真实单价与真实联邦指标积分;超预算冻结超额 + 强制审批;分摊规则变更需多级审批并审计;账单对外/对内结算且 90 天以上留存。
五、生产环境管控与安全约束
测试 vs 生产 差异化管控对照表
| 管控项 | 测试环境 | 生产环境 |
|---|---|---|
| 单价与出账 | 模拟单价,不出真实账 | 真实单价,真实出账 |
| 计量数据源 | 联邦指标(可抽样) | 联邦指标全量积分,拒绝手工填报 |
| 预算超阈值 | 仅记录 | 冻结超额 + 强制审批解封 |
| 分摊规则变更 | 自由试配 | 多级审批 + 版本化 + 审计 |
| 账单留存 | 90 天 | 90 天以上,防篡改归档 |
| 闲置识别动作 | 仅建议 | 建议 + 自动回收工单 |
| 对账 | 不强制 | 与云账单逐层对账 |
| 权限 | 运维可改规则 | 仅 FinOps 角色,RBAC 受限 |
资源配额与权限隔离
- 每租户 Namespace 的
ResourceQuota由预算推导:预算 = 配额 × 单价,超额即触发冻结。 - 计量规则配置权仅限 FinOps 平台角色;租户对账单的可见范围被 RBAC 限制为「仅自身」。
下面是预算超阈值告警规则(引用 budget_limit_cents / paas:total_cost_cents:by_tenant_app_env_cluster,与 Go 服务导出指标一致):
# file: deploy/prometheus/budget-alerts.yaml
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: cost-budget-alerts
namespace: paas-system
labels:
role: alert-rules
spec:
groups:
- name: cost-budget
rules:
- alert: BudgetExceeded
expr: sum by (tenant) (paas:total_cost_cents:by_tenant_app_env_cluster) > on (tenant) group_left() budget_limit_cents
for: 10m
labels:
severity: warning
annotations:
summary: "租户 {{ $labels.tenant }} 本月费用已超预算"
- alert: BudgetProjectedExceeded
expr: predict_linear(paas:total_cost_cents:by_tenant_app_env_cluster[6h], 86400*24) > on (tenant) group_left() budget_limit_cents
for: 10m
labels:
severity: warning
annotations:
summary: "预测租户 {{ $labels.tenant }} 月末将超预算"
敏感操作审批与审计
- 分摊规则变更、超预算解封、单价调整绑定审批流,审批通过才生效,流程不可绕过。
- 所有计量/出账/规则变更写入审计日志,保留 90 天以上,含操作人、租户、数据区间、口径版本。
故障熔断策略
- 当联邦指标采集缺口超过阈值(如某集群 Agent 掉线),计量引擎标记该租户「数据不完整」,当月账单挂起并告警,避免基于残缺数据出账。
- 超预算告警连续触发时,自动冻结对应租户新配额申请,直至审批解封。
六、常见生产故障与解决方案
下面用流程图给出计量/账单异常的标准排查路径。
flowchart TD
P[计量 加 账单异常] --> Q{异常类型?}
Q -->|计量数据缺失| R1[查Agent是否掉线 加 标签缺失]
Q -->|分摊金额偏差| R2[核对分摊规则 加 标签覆盖]
Q -->|预算告警误报| R3[检查阈值 加 配额口径]
R1 --> S1[修复采集 加 补算]
R2 --> S2[校正规则 加 重算]
R3 --> S3[调整阈值配置]
S1 --> T[账单复核 加 审计留痕]
S2 --> T
S3 --> T故障 1:某租户账单为 0 但明显有用量
- 现象:Grafana 有曲线,但账单该租户为 0。
- 排查:PromQL 聚合发现该租户 Pod 缺失
tenant标签(应用建模时漏标)。engine.Allocate会跳过缺标签用量。 - 方案:在应用建模强制注入
tenant/app/env/cluster标签;计量引擎对「无标签」用量记入平台公共池并发告警,避免漏计。可用查询确认:
# 运维 runbook:确认缺标签用量(应被记入公共池并发告警)
curl -s 'http://prometheus-fed.paas.local:9090/api/v1/query?query=sum(container_cpu_usage_seconds_total{container!=""})%20by%20(pod)' \
| jq '.data.result[] | select(.metric.tenant==null)'
故障 2:分摊后总额与云账单对不上
- 现象:平台内部账单合计小于底层云账单。
- 排查:共享资源(控制面、网络出口、镜像存储)未计入分摊,或节点系统预留未纳入分母。
- 方案:在分摊规则补「平台公共成本」项,按各租户用量占比二次分摊;与云账单分层对账。可用成本服务复核总额:
# 运维 runbook:复核账单总额 == 联邦指标积分 × 单价
curl -s 'http://cost-server.paas.local:8081/api/v1/cost?window=30d' | jq 'map(.TotalCents) | add'
故障 3:预算告警频繁误报
- 现象:月中即告警,但实际不会超。
- 排查:预算按「自然月」而用量按「结算周期」口径不一致,或单价配置错误。
- 方案:统一口径为结算周期;在计量引擎做滚动预测,仅当「预测期末超阈值」才告警(见
budget-alerts.yaml的BudgetProjectedExceeded,使用predict_linear)。
故障 4:某集群 Agent 掉线导致整月账单挂起
- 现象:出账任务因数据不完整暂停。
- 排查:Agent 网络分区,Thanos 缺该集群区间数据。
- 方案:配置 Agent 高可用与本地缓冲;挂起期间对该集群按历史均值预填并标注「估算」,事后补算校正,保证出账不阻断。
自测题与动手练习
自测题
- 为什么成本计量必须直接来自联邦 Prometheus 指标,而不能用节点账单手工填报?
tenant/app/env/cluster四个标签分别解决什么隔离/过滤问题?缺tenant会怎样?- 共享资源(网络、控制面)应如何分摊?为什么不能只算 Pod 直接用量?
- 超预算冻结超额后,如何解封?为什么必须走审批而非自动放行?
- 月中对某租户账单为 0,但 Grafana 有曲线,最可能的原因是什么?如何修?
动手练习
- 在联邦 Grafana 成本大盘按
tenant维度查看本月 CPU/内存折算费用 Top 5,截图并标注最高租户。 - 用 PromQL 验证一次计量口径:查询
sum(increase(container_cpu_usage_seconds_total{tenant="A"}[30d]))并与账单中的核·秒数对比。 - 给测试租户设一个极小预算阈值,故意超量,观察 Alertmanager 预算告警与配额冻结,再走审批解封。
本章小结
- 成本模块的可信根基是「计量数据直接来自联邦 Prometheus 指标」,自带
tenant/app/env/cluster标签,跨月可追溯、可审计。 - 三项★禁止删减能力——计量准确性、分摊规则、审计——是商用计费不可省略的底线。
- 测试环境可模拟试配,生产环境必须真实出账、超预算冻结、规则变更走审批。
- 成本优化建立在与安全模块同源的联邦指标之上,闲置识别与预算预警共同构成 FinOps 闭环。
- 平台侧全部以 Go(gin + 联邦 Prometheus 查询客户端)实现:计量分摊、账单 CSV、预算校验告警,代码可编译、无占位符,与 YAML / Prometheus recording rules 端到端一致(
budget_limit_cents由 Go 导出、被budget-alerts.yaml引用;paas:total_cost_cents:by_tenant_app_env_cluster由 recording rules 沉淀供大盘与告警)。
至此,安全(模块 07)与成本(模块 08)都已说明如何复用统一的联邦可观测底座。后续模块(如 09 后台管理、11 审批流)将围绕元数据、审批与审计把这套体系收口为平台级治理能力。