跳过正文
FinOps 实践:Kubernetes 成本治理体系建设

FinOps 实践:Kubernetes 成本治理体系建设

·1316 字·7 分钟·
目录

为什么 K8s 集群的账单总比预期高一倍
#

去年接手一个多云 K8s 平台,第一个月账单出来是 $52k,研发团队说"我们就跑了几个微服务"。花了两周把账单拆开看,发现:

  • 节点闲置率 47%:requests 填满了调度,但实际 CPU 使用率平均 18%
  • PVC 孤儿:删了 Pod,没人删 PVC,有 60 多个共计 4TB 的 EBS 卷躺在那里计费
  • Spot 节点使用率接近零:团队配置了 On-Demand 节点组,Spot 节点组"怕不稳定"没敢用
  • 镜像仓库流量:ECR 跨 AZ 拉镜像,一个月 image pull 流量费 $3,200
  • NAT Gateway 费用暗坑:忘了配 VPC Endpoint,所有 S3/ECR 流量都走 NAT

这是一个典型的"云原生陷阱"——容器化之后资源调度变灵活了,但成本可见性反而变差了。FinOps 要解决的正是这个问题。


FinOps 框架:三阶段不能跳级
#

FinOps Foundation 定义了三个成熟度阶段,实践中最常见的错误是跳过 Inform 直接做 Optimize,结果优化了一堆但不知道效果。

Inform 阶段:先看清楚花在哪
#

没有可观测性就没有治理。这一阶段的目标是让每笔云支出都能对应到业务团队、服务、甚至功能。

必须建立的标签体系(Label Schema):

# 强制标签,所有 Deployment/StatefulSet 必须携带
required_labels:
  - app.kubernetes.io/name        # 服务名
  - app.kubernetes.io/component   # 组件类型: api/worker/scheduler
  - team                          # 负责团队
  - env                           # 环境: prod/staging/qa
  - cost-center                   # 成本中心编号

在 OPA/Kyverno 里用 Policy 强制校验,没标签的资源拒绝部署:

# kyverno policy: require-labels.yaml
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-labels
spec:
  validationFailureAction: Enforce
  rules:
    - name: check-required-labels
      match:
        any:
          - resources:
              kinds: [Deployment, StatefulSet, DaemonSet]
      validate:
        message: "必须携带 team 和 cost-center 标签"
        pattern:
          metadata:
            labels:
              team: "?*"
              cost-center: "?*"

Optimize 阶段:找到可以动的钱
#

可见之后才能优化,要按 ROI 排序,先动影响大的。

Operate 阶段:固化流程,防止反弹
#

成本治理不是一次性项目,是持续的 SOP。最终要做到:工程师提 PR 改 requests 时,能看到预测的成本影响;月初自动发报告;超预算自动告警。


OpenCost 部署与 Prometheus 集成
#

OpenCost 是 CNCF 沙箱项目,开源免费,适合自建 K8s。Kubecost 是商业版,有更多功能但核心模型相同。

选型建议
#

维度OpenCostKubecost FreeKubecost Enterprise
费用免费免费$$$
数据保留依赖 Prometheus15 天无限
多集群需自己聚合单集群原生支持
成本分摊基础中等完整 Chargeback
OOTB 告警

中小团队(<10 个集群)用 OpenCost + 自定义 Grafana 面板完全够用。 超过 10 个集群或者需要对业务团队出 Chargeback 报表,考虑 Kubecost Enterprise。

OpenCost 部署
#

# 添加 Helm repo
helm repo add opencost https://opencost.github.io/opencost-helm-chart
helm repo update

# 安装 OpenCost,接入已有 Prometheus
helm install opencost opencost/opencost \
  --namespace opencost \
  --create-namespace \
  --set opencost.prometheus.internal.enabled=false \
  --set opencost.prometheus.external.enabled=true \
  --set opencost.prometheus.external.url=http://kube-prometheus-stack-prometheus.monitoring:9090

AWS 用户需要配置节点价格,OpenCost 默认会查 AWS Price API,但需要给 ServiceAccount 配 IRSA 权限:

{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Action": ["pricing:GetProducts"],
    "Resource": "*"
  }]
}

验证数据是否正常采集:

# 查询过去 24 小时各 namespace 的成本
kubectl port-forward -n opencost svc/opencost 9003:9003 &
curl -s "http://localhost:9003/allocation?window=24h&aggregate=namespace" | jq '.data[0] | to_entries[] | {namespace: .key, cost: .value.totalCost}'

Prometheus 抓取配置
#

OpenCost 暴露了 /metrics 端点,需要在 Prometheus 里配置抓取:

# prometheus-additional-scrape.yaml
- job_name: opencost
  honor_labels: true
  scrape_interval: 1m
  metrics_path: /metrics
  static_configs:
    - targets: ['opencost.opencost:9003']

关键指标:

# 各 namespace 每小时成本(美元)
sum(container_cpu_allocation * on(node) group_left() node_cpu_hourly_cost) by (namespace)
+ sum(container_memory_allocation_bytes * on(node) group_left() node_ram_hourly_cost / 1024 / 1024 / 1024) by (namespace)

# 资源浪费率:请求了但没用的 CPU
1 - (
  sum(rate(container_cpu_usage_seconds_total[1h])) by (namespace)
  /
  sum(kube_pod_container_resource_requests{resource="cpu"}) by (namespace)
)

成本分摊模型:Chargeback vs Showback
#

Showback:给团队看他们用了多少钱,但不实际扣款。适合起步阶段,先建立成本意识。

Chargeback:真的从团队预算里扣。需要更精确的分摊模型,否则引发内部争议。

分摊模型设计
#

K8s 成本主要分三类:

  1. 直接分配成本:Pod 独占的资源(容易,按 label 分)
  2. 共享基础设施成本:kube-system、monitoring、ingress 等(按请求比例分摊)
  3. 闲置成本:节点买了但没用满的部分(这部分最有争议)

推荐做法:闲置成本按各团队的实际使用比例分摊,而不是按请求比例——这样能激励团队把 requests 写准确。

# OpenCost API 查询按 team label 分摊的成本
curl -s "http://localhost:9003/allocation" \
  -d "window=lastmonth" \
  -d "aggregate=label:team" \
  -d "shareIdle=true" \
  -d "shareTenancyCosts=true" | \
  jq '.data[0] | to_entries[] | {team: .key, totalCost: (.value.totalCost | floor)}'

资源浪费识别:VPA 推荐值分析
#

VPA(Vertical Pod Autoscaler)的 Recommender 组件会基于历史用量给出推荐的 requests/limits,即使不启用自动更新模式,单纯用推荐值做分析也非常有价值。

部署 VPA(仅 Recommender 模式)
#

git clone https://github.com/kubernetes/autoscaler
cd autoscaler/vertical-pod-autoscaler

# 只部署 recommender,不部署 updater(避免自动重启 Pod)
helm install vpa fairwinds-stable/vpa \
  --namespace vpa \
  --create-namespace \
  --set updater.enabled=false \
  --set admissionController.enabled=false \
  --set recommender.enabled=true

批量查看推荐值 vs 当前申请值的差距
#

#!/bin/bash
# vpa-waste-report.sh:找出 requests 虚高的 Deployment

kubectl get vpa -A -o json | jq -r '
  .items[] |
  .metadata.namespace as $ns |
  .metadata.name as $name |
  .status.recommendation.containerRecommendations[]? |
  {
    namespace: $ns,
    vpa: $name,
    container: .containerName,
    cpu_request_recommended: .lowerBound.cpu,
    cpu_request_upper: .upperBound.cpu,
    mem_recommended: .lowerBound.memory,
    mem_upper: .upperBound.memory
  }
' | jq -s 'sort_by(.namespace)'

实际经验:超过 60% 的 Deployment,实际 CPU 使用量不到 requests 的 30%。最常见的原因是工程师复制了别人的 YAML 没改 resources,或者"保险起见"申请了很多。

用 Goldilocks 可视化推荐
#

helm install goldilocks fairwinds-stable/goldilocks \
  --namespace goldilocks \
  --create-namespace

# 给要分析的 namespace 打标签
kubectl label namespace production goldilocks.fairwinds.com/enabled=true

# 访问 Dashboard
kubectl port-forward -n goldilocks svc/goldilocks-dashboard 8080:80

Goldilocks 会在 Web UI 里直接展示每个容器的推荐值,以及采纳推荐值能节省多少成本——这个报告直接发给研发团队,让他们自己改。


Karpenter 节点策略:Spot + Consolidation
#

Karpenter 是目前 AWS EKS 上最好的节点自动化管理方案,核心优势是 consolidation(碎片整理)——能主动把负载合并到更少的节点,终止空闲节点。

NodePool 配置:混合 Spot/On-Demand
#

# nodepool-general.yaml
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
  name: general
spec:
  template:
    metadata:
      labels:
        node-type: general
    spec:
      nodeClassRef:
        group: karpenter.k8s.aws
        kind: EC2NodeClass
        name: default
      requirements:
        - key: karpenter.sh/capacity-type
          operator: In
          values: ["spot", "on-demand"]   # 优先 Spot
        - key: kubernetes.io/arch
          operator: In
          values: ["amd64"]
        - key: karpenter.k8s.aws/instance-category
          operator: In
          values: ["c", "m", "r"]
        - key: karpenter.k8s.aws/instance-generation
          operator: Gt
          values: ["3"]
      # Spot 中断时的驱逐策略
      expireAfter: 720h   # 节点最多跑 30 天,定期轮换避免长期运行问题
  disruption:
    consolidationPolicy: WhenUnderutilized  # 利用率低时主动合并
    consolidateAfter: 30s
  limits:
    cpu: "1000"
    memory: 4000Gi

关键:给无状态服务配置 PodDisruptionBudget
#

Consolidation 会驱逐 Pod,没有 PDB 的服务在合并时可能短暂中断:

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: api-server-pdb
spec:
  minAvailable: 1   # 至少保留 1 个副本
  selector:
    matchLabels:
      app: api-server

有状态服务(数据库、消息队列)加到 Karpenter 的 do-not-disrupt 注解,阻止 consolidation 驱逐:

# 给 StatefulSet Pod template 加注解
annotations:
  karpenter.sh/do-not-disrupt: "true"

Spot 中断处理
#

安装 AWS Node Termination Handler,在 Spot 中断前 2 分钟优雅驱逐:

helm install aws-node-termination-handler \
  eks/aws-node-termination-handler \
  --namespace kube-system \
  --set enableSpotInterruptionDraining=true \
  --set enableRebalanceMonitoring=true \
  --set enableScheduledEventDraining=true

僵尸资源自动清理
#

清理孤儿 PVC
#

PVC 在 Pod 删除后仍然存在并计费,需要定期清理:

#!/bin/bash
# find-orphan-pvc.sh

echo "=== 未绑定任何 Pod 的 PVC ==="
kubectl get pvc -A -o json | jq -r '
  .items[] |
  select(.status.phase == "Bound") |
  .metadata.namespace as $ns |
  .metadata.name as $pvc |
  .spec.volumeName as $vol |
  "\($ns)/\($pvc) -> \($vol)"
' | while IFS='/' read -r ns rest; do
  pvc=$(echo "$rest" | cut -d' ' -f1)
  # 检查是否有 Pod 在使用这个 PVC
  used=$(kubectl get pods -n "$ns" -o json | jq --arg pvc "$pvc" '
    [.items[].spec.volumes[]? | select(.persistentVolumeClaim.claimName == $pvc)] | length
  ')
  if [ "$used" -eq 0 ]; then
    size=$(kubectl get pvc -n "$ns" "$pvc" -o jsonpath='{.status.capacity.storage}' 2>/dev/null)
    echo "ORPHAN: $ns/$pvc ($size)"
  fi
done

配合 CronJob 定期跑,输出报告后人工确认删除(别做全自动删除,PVC 删了不可恢复)。

清理未使用的 ConfigMap/Secret
#

# 找出没有被任何 Pod/Deployment 引用的 ConfigMap
kubectl get configmap -n production -o json | jq -r '.items[].metadata.name' | while read cm; do
  refs=$(kubectl get pods,deployments,statefulsets -n production -o json | \
    jq --arg cm "$cm" '[.. | objects | select(.configMap.name? == $cm or .name? == $cm)] | length')
  [ "$refs" -eq 0 ] && echo "UNUSED ConfigMap: $cm"
done

Grafana 成本面板 + 月度超预算告警
#

核心 Grafana 面板配置
#

导入 OpenCost 官方 Dashboard(ID: 15714),再加几个自定义 Panel:

{
  "title": "月度成本趋势 vs 预算",
  "type": "timeseries",
  "targets": [{
    "expr": "sum(increase(opencost_total_cost[1d])) * 30",
    "legendFormat": "预测月度成本"
  }, {
    "expr": "50000",
    "legendFormat": "月预算上限"
  }]
}

AlertManager 告警规则
#

# cost-alerts.yaml
groups:
  - name: finops
    rules:
      - alert: MonthlyCostProjectionExceeded
        expr: |
          (
            sum(increase(opencost_total_cost[24h])) * 30
          ) > 45000
        for: 1h
        labels:
          severity: warning
          team: platform
        annotations:
          summary: "月度成本预测超预算"
          description: "当前月度成本预测 {{ $value | printf \"%.0f\" }} 美元,超过预警线 $45k"

      - alert: NamespaceCostAnomaly
        expr: |
          (
            sum by (namespace) (increase(opencost_total_cost[1h]))
            /
            sum by (namespace) (increase(opencost_total_cost[1h] offset 7d))
          ) > 2
        for: 30m
        labels:
          severity: warning
        annotations:
          summary: "Namespace {{ $labels.namespace }} 成本异常翻倍"
          description: "相比上周同期,成本增加超过 100%"

实战案例:从 $52k 降到 $31k 的完整路径
#

以下是我们实际执行的操作,按 ROI 排序:

第一周:快速止血(节省约 $8k/月)
#

1. 清理孤儿 PVC($1,200/月)

# 跑脚本发现 68 个孤儿 PVC,合计 3.8TB EBS gp3
# 逐一确认后删除,立即生效
kubectl delete pvc -n production $(kubectl get pvc -n production | grep -v Bound | awk '{print $1}')

2. 配置 VPC Endpoint($3,800/月)

# 创建 S3 和 ECR 的 VPC Gateway/Interface Endpoint
# 消除跨 NAT Gateway 的 S3/ECR 流量费
aws ec2 create-vpc-endpoint \
  --vpc-id vpc-xxxxx \
  --service-name com.amazonaws.us-west-2.s3 \
  --route-table-ids rtb-xxxxx

3. 关停开发环境夜间/周末节点($3,000/月)

# 使用 Karpenter NodePool 配置时间窗口,或者简单粗暴用 CronJob scale deployment to 0
kubectl patch deployment -n dev --all -p '{"spec":{"replicas":0}}'

第二周:节点优化(节省约 $7k/月)
#

4. 启用 Karpenter Consolidation

把原有的 Managed Node Group 迁移到 Karpenter 管理,开启 WhenUnderutilized 策略。第一个 72 小时内,节点数从 47 降到 29。

5. 开启 Spot 节点(无状态服务全量迁移)

修改各团队 Deployment 的 nodeSelector,统一切到带 Spot 支持的 NodePool。实际 Spot 中断率不到 2%,配合 PDB 几乎无感知。

第三周:精细化 Requests 调优(节省约 $6k/月)
#

6. 批量按 VPA 推荐值降低 CPU Requests

# 生成变更清单
kubectl get vpa -A -o json | jq -r '
  .items[] | 
  select(.status.recommendation != null) |
  [.metadata.namespace, .metadata.name, 
   (.status.recommendation.containerRecommendations[0].target.cpu // "N/A"),
   (.status.recommendation.containerRecommendations[0].target.memory // "N/A")] |
  @tsv
' | column -t > vpa-recommendations.txt

# 按 namespace 分发给各团队,让他们自己改 PR

集中 Sprint 完成后,集群整体 CPU 请求量从 3200 cores 降到 1800 cores,节点数进一步减少。

结果
#

成本项优化前优化后节省
EC2 节点$32k$19k$13k
EBS 存储$6k$2.8k$3.2k
数据传输$5k$1.2k$3.8k
其他$9k$8k$1k
合计$52k$31k$21k

持续运营:防止反弹的机制
#

成本治理最大的敌人是"优化之后慢慢又涨回去"。防止反弹需要把约束内置到流程里:

  1. CI/CD 集成成本检查:PR 里有 resource requests 变更时,自动跑 Infracost 估算月度影响
  2. 季度 FinOps Review:每季度各团队 Owner 对自己 namespace 的成本趋势负责
  3. Namespace 预算 Quota:用 ResourceQuota 设置 CPU/Memory 上限,超过上限的 Pod 调度失败,倒逼团队做精细化管理
  4. 自动报告:每周一自动跑脚本,把各 namespace 成本发到对应团队的 Slack 频道
# weekly-cost-report.sh(放在 CronJob 里,每周一 9:00 跑)
#!/bin/bash
REPORT=$(curl -s "http://opencost.opencost:9003/allocation?window=lastweek&aggregate=label:team&shareIdle=true" | \
  jq -r '.data[0] | to_entries[] | "\(.key): $\(.value.totalCost | floor)"' | sort -t'$' -k2 -rn | head -10)

curl -X POST "$SLACK_WEBHOOK" \
  -H 'Content-type: application/json' \
  -d "{\"text\": \"*上周各团队 K8s 成本 Top 10*\n\`\`\`${REPORT}\`\`\`\"}"

FinOps 不是一个工具,是一种组织习惯。工具只是让浪费可见,真正的优化靠的是工程团队愿意为资源使用负责。建立这套体系最难的不是技术,是让研发团队相信"改小 requests 不会让服务挂掉"。

Wenzhuo Huang
作者
Wenzhuo Huang
搞运维的工程师,写代码的运维人。专注 Kubernetes、AWS、GitOps 与基础设施可靠性。这个博客既是我的技术笔记本,也是我踩过的坑的受害者档案。

相关文章

Kubernetes 成本优化实战:系统性降本的四条路径

·1066 字·6 分钟
真实的降本案例:从发现成本异常到分析根因,通过 Karpenter 节点弹性伸缩、资源请求规格治理、大机型收敛等手段,系统性降低 AWS EC2 成本。包含具体配置和执行思路。

Karpenter 弹性节点管理实战

·1734 字·9 分钟
Karpenter 替代 Cluster Autoscaler 的完整实践:NodePool 约束配置、EC2NodeClass 实例选型、consolidation 节点整合降本、Spot 实例容错,以及多套集群配置的组织方式。

Karpenter 深度解析:下一代 K8s 节点自动扩缩

·842 字·4 分钟
从 Cluster Autoscaler 迁移到 Karpenter 之后,集群扩容速度和节点利用率都有明显提升。本文详细拆解 Karpenter 的核心机制、关键配置项,以及在多套生产集群运行中踩过的坑。