弹性伸缩、Serverless 与 FinOps:从 KEDA 到成本治理

2022-02-11T10:00:00+08:00 | 22分钟阅读 | 更新于 2022-02-11T10:00:00+08:00

@

本文聚焦「弹性伸缩与 Serverless 混部」(原书第 3 章)与「资源成本与 FinOps」(原书第 9 章)。 写法是「白话建直觉 → 原理拆解 → 可运行配置 → 面试追问」,所有 YAML/bash/JSON 均为可落地模板,代码围栏成对出现。


一、KEDA 基于 Prometheus 指标伸缩

1.1 白话直觉:消息"清空"不等于"活干完了"

KEDA(Kubernetes Event-driven Autoscaler)像一个按外部事件计件的车间主任:它盯着消息队列的 Lag(积压量)决定开几条产线。问题出在"计件逻辑"本身——

类比:快递分拣中心按"待分拣包裹数"决定开几扇窗口。某时刻包裹数突然归零,主任立刻关窗。但真正的情形是:包裹已经被搬到窗口里的台面上正在拆,此时关窗会把正在拆的件直接扔掉。

Kafka Lag 突降为 0,只代表"还没拉取的新消息没了",已经 poll() 进消费者内存、正在被 CPU 处理的那批消息并不计入 Lag。于是 CPU 仍高、Lag 却为 0,KEDA 只会看到"没活了",从而过早缩容。

1.2 防过早缩容:Lag+CPU 组合判定

KEDA 的 ScaledObject 支持多个 trigger,默认用 max 策略合并(取各触发器算出的副本数最大值)。所以把"Kafka Lag"和"CPU 利用率"并列放进同一个 ScaledObject,天然得到:

最终副本数 = max( 按Lag算的副本 , 按CPU算的副本 )

只要 CPU 还高,缩容就不会发生。再配合 cooldownPeriod(缩容冷却)与 minReplicaCount(保底副本),双保险。

# scaledobject-combined.yaml
# 关键点:一个 ScaledObject 里同时挂 kafka 与 cpu 两个 trigger
# multipleScalersCalculation 默认就是 max,无需显式写
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: order-consumer
  namespace: shop
spec:
  scaleTargetRef:
    name: order-consumer          # 目标 Deployment
  minReplicaCount: 2              # 保底 2 个,避免完全归零后冷启动
  maxReplicaCount: 30
  pollingInterval: 15             # 每 15s 采集一次指标
  cooldownPeriod: 120             # 缩容前冷却 120s,给在途消息兜底
  triggers:
    # —— 触发器 1:Kafka Lag(事件驱动)——
    - type: kafka
      metadata:
        bootstrapServers: kafka.svc:9092
        consumerGroup: order-cg
        topic: order-topic
        lagThreshold: "50"        # 每多 50 条积压就多加 1 个副本
        activationLagThreshold: "5"  # Lag>5 才激活(否则停在 minReplicaCount)
    # —— 触发器 2:CPU 利用率(资源驱动)——
    # 即使 Lag=0 但 CPU 仍高,这一项会"顶住"副本数不下降
    - type: cpu
      metricType: Utilization
      metadata:
        value: "60"               # 目标 CPU 利用率 60%
# 部署并观察:人为让 Lag 归零,看副本是否仍被 CPU 顶住
kubectl apply -f scaledobject-combined.yaml

# 实时看 KEDA 推荐的副本数(HPA 对象由 KEDA 自动生成)
kubectl get hpa -n shop keda-hpa-order-consumer -w

# 制造"Lag=0 但 CPU 高"的现场:停止生产消息,同时让消费者跑满 CPU
# 预期:副本数不回落到 minReplicaCount 以下,因为 cpu trigger 生效

面试追问:为什么不直接把 cooldownPeriod 调很大?因为冷却只延缓缩容,不解决"缩容决策本身错误"——CPU 高时本就不该缩。组合 trigger 才是治本。

1.3 KEDA ScaledJob 读 Kafka,控制最大并发度 500

ScaledJobScaledObject 的区别:它跑的是 Job 而不是长期 Deployment,适合"来一批消息就起一批短任务"的场景(如离线对账)。用 maxReplicaCount 卡死并发 Job 数上限。

# scaledjob-kafka.yaml
# 需求:从 Kafka 拉消息,最多同时跑 500 个 Job,每个 Job 处理一批
apiVersion: keda.sh/v1alpha1
kind: ScaledJob
metadata:
  name: kafka-batch-job
  namespace: batch
spec:
  jobTargetRef:
    template:
      spec:
        containers:
          - name: worker
            image: registry.example.com/batch-worker:v1
            args: ["--batch-size", "200"]
            resources:
              requests:
                cpu: "250m"
                memory: "256Mi"
        restartPolicy: Never
  # —— 并发度控制核心 ——
  maxReplicaCount: 500                 # 同时最多 500 个 Job(Pod)
  scalingStrategy:
    strategy: "accurate"               # accurate=尽量贴近目标
    maxConcurrentJobs: 500             # 与 maxReplicaCount 对齐,硬上限
    multipleScalersCalculation: "max"
  # —— 触发源:Kafka Lag ——
  triggers:
    - type: kafka
      metadata:
        bootstrapServers: kafka.svc:9092
        consumerGroup: batch-cg
        topic: batch-topic
        lagThreshold: "200"            # 每 200 条积压起 1 个 Job
# 看正在跑的 Job 数,验证不会超过 500
kubectl get jobs -n batch | wc -l
# 若想临时压低并发做压测,改 maxReplicaCount 即可
kubectl patch scaledjob kafka-batch-job -n batch \
  --type merge -p '{"spec":{"maxReplicaCount":100}}'

1.4 对 KEDA Operator 本身做 HPAScaleToZero

KEDA 控制面(operator + metrics-adapter)平时也要占资源。大集群里它可以缩到 0 省成本,但缩到 0 后谁来把它唤醒?答案是依赖 HPAScaleToZero 特性门控 + 一个常驻的"哨兵"——通常是 metrics-adapter 不能缩(它要对外提供指标),而 operator 可以缩。

# keda-operator-hpa.yaml
# 开启 HPAScaleToZero 后,HPA 允许 minReplicas=0
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: keda-operator
  namespace: keda
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: keda-operator
  minReplicas: 0            # 关键:允许缩到 0(需 API Server 开启 HPAScaleToZero)
  maxReplicas: 2
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 40
  behavior:
    scaleDown:
      stabilizationWindowSeconds: 300   # 安静 5 分钟才缩到 0
# 1) 确认 API Server 已开启特性门控(否则 minReplicas=0 会被拒绝)
kubectl get apiservice v1.autoscaling | grep -i hpascaletozero
# 或检查 kube-apiserver 启动参数含 --feature-gates=HPAScaleToZero=true

# 2) 部署 HPA
kubectl apply -f keda-operator-hpa.yaml

# 3) 验证:长时间无伸缩事件后 operator 副本归零
kubectl get deploy -n keda keda-operator

代价说明:operator 归零期间,ScaledObject 不会再被 reconcile,新消息不会触发扩容。所以只建议在流量极低、且 metrics-adapter 仍常驻的次要集群使用;核心生产环境不建议 operator 缩零。

flowchart TD
    A[Kafka 新消息入队] --> B{Lag > activationLagThreshold?}
    B -- 否 --> C[停在 minReplicaCount]
    B -- 是 --> D[KEDA operator 计算副本]
    D --> E[max by Lag]
    D --> F[max by CPU]
    E --> G[取较大值作为目标副本]
    F --> G
    G --> H[更新 HPA / 拉起 Job]
    H --> I{CPU 仍高?}
    I -- 是 --> G
    I -- 否且 Lag=0 --> J[冷却后缩容]

二、虚拟节点 ECI/Spot 混部成本优化

2.1 白话直觉:Spot 实例是"随时可能被收回的廉价厂房"

Spot/抢占式实例价格可能只有按量付费的 1~2 折,但云厂商有权随时回收。当回收率(单位时间内被收回的比例)>30%,意味着三成机器会"说没就没"。对"订单支付"这种不能失败的关键链路,必须设计优雅迁移窗

类比:租用了一批"随时可能被房东收回"的临时仓库。合同里写明了"收回前 30 秒会给书面通知"。聪明做法是——收到通知立刻把里面值钱货搬去正规仓库,而不是等房东上门才慌。

2.2 Spot 回收率>30% 时设计优雅迁移窗

云厂商在回收前会通过元数据服务/事件发送终止信号(如阿里云 ECI 的 Preempt 事件、AWS Spot 的 instance-action 元数据,通常提前约 30s~2min)。我们要做三件事:

  1. 监听到终止信号 → 立刻 cordon 该节点,新 Pod 不再调度上来;
  2. 给在途 Pod 一个 迁移窗(如 60s)把状态落库/完成支付幂等回执;
  3. 关键 Pod 用 nodeAffinity/toleration 强制只跑按量/包年的真实节点,绝不进 Spot。
# spot-tolerant-pod.yaml
# 允许跑在 Spot 虚拟节点上的"可中断"任务(如日志归档)
apiVersion: v1
kind: Pod
metadata:
  name: log-archive
spec:
  containers:
    - name: archive
      image: registry.example.com/archive:v1
  tolerations:
    - key: "eks.amazonaws.com/spot"     # 容忍 Spot 污点(阿里云为 virtual-kubelet.io/provider)
      operator: "Exists"
      effect: "NoSchedule"
  terminationGracePeriodSeconds: 60      # 收到 SIGTERM 后有 60s 优雅退出窗
# payment-pod-ondemand.yaml
# 支付类关键 Pod:用 nodeAffinity 反亲和 Spot,强制落在真实按量节点
apiVersion: v1
kind: Pod
metadata:
  name: payment-core
spec:
  affinity:
    nodeAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
        nodeSelectorTerms:
          - matchExpressions:
              - key: "eks.amazonaws.com/capacityType"  # 阿里云为 node.kubernetes.io/instance-type 组合判断
                operator: "NotIn"
                values: ["SPOT"]                         # 绝不调度到 Spot
  containers:
    - name: pay
      image: registry.example.com/pay:v1
# 监听 Spot 回收事件并自动 cordon(伪代码式脚本,可放进 DaemonSet)
# 阿里云 ECI:kubectl get events --field-selector reason=Preempt
# AWS:curl http://169.254.169.254/latest/meta-data/spot/instance-action
NODE=$(curl -s http://169.254.169.254/latest/meta-data/instance-id)
if [ -n "$NODE" ] && kubectl get node "$NODE" >/dev/null 2>&1; then
  echo "收到 Spot 回收信号,cordon $NODE"
  kubectl cordon "$NODE"
fi

2.3 基于 VK 将 GPU 工作负载调度到 ECI 的 NodeAffinity

Virtual-Kubelet(VK)把"云上弹性容器实例(如阿里云 ECI)“伪装成一个虚拟节点注册进 K8s。把 GPU 推理任务调度到 ECI 虚拟节点,既能免运维 GPU 机器,又能按需计费。

# gpu-to-eci.yaml
# 通过 nodeAffinity 选中 VK 虚拟节点,并声明 GPU 资源
apiVersion: apps/v1
kind: Deployment
metadata:
  name: gpu-infer
spec:
  replicas: 1
  selector:
    matchLabels: { app: gpu-infer }
  template:
    metadata:
      labels: { app: gpu-infer }
    spec:
      affinity:
        nodeAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
            nodeSelectorTerms:
              - matchExpressions:
                  # VK 虚拟节点通常会被打上 provider 标签
                  - key: "type"
                    operator: "In"
                    values: ["virtual-kubelet"]     # 只去虚拟节点
                  - key: "kubernetes.io/role"
                    operator: "In"
                    values: ["eci"]                 # 且是 ECI
      containers:
        - name: infer
          image: registry.example.com/infer:gpu
          resources:
            limits:
              nvidia.com/gpu: 1          # 向虚拟节点申请 1 张 GPU

2.4 虚拟节点 Pod 设不同 QoS Class 触发不同计费

真实节点上 K8s 按 requests/limits 自动判定 QoS(Guaranteed/Burstable/BestEffort)。ECI 虚拟节点会映射 QoS 到不同计费档位:Guaranteed(requests=limits)通常按"预留规格"计费更贵但稳定;BestEffort(都不设)最便宜但有被驱逐风险。

# qos-guaranteed.yaml —— 稳定型,计费较高
apiVersion: v1
kind: Pod
metadata:
  name: qos-guaranteed
  annotations:
    k8s.aliyun.com/eci-qos-class: "Guaranteed"   # 显式声明(不同厂商注解键不同)
spec:
  containers:
    - name: c
      image: nginx
      resources:
        requests: { cpu: "1", memory: "1Gi" }
        limits:   { cpu: "1", memory: "1Gi" }    # requests==limits → Guaranteed
---
# qos-besteffort.yaml —— 弹性型,计费最低
apiVersion: v1
kind: Pod
metadata:
  name: qos-besteffort
  annotations:
    k8s.aliyun.com/eci-qos-class: "BestEffort"
spec:
  containers:
    - name: c
      image: nginx
      # 不写 requests/limits → BestEffort,单价最低但优先被回收
flowchart LR
    S[Spot/ECI 虚拟节点] -->|接收终止信号| W[优雅迁移窗 60s]
    W -->|落库/回执| O[真实按量节点]
    P[支付关键 Pod] -.->|nodeAffinity 反亲和| S
    P --> O
    G[GPU 任务] -->|nodeAffinity=virtual-kubelet| E[ECI 虚拟节点]
    E -->|按 QoS Class 计费| B[BestEffort/Guaranteed 档位]

三、基于 QPS 的预测式伸缩

3.1 白话直觉:别等水漫金山才掏沙袋

传统 HPA 是事后响应——CPU 已经高了才加机器,中间有一段"水位上涨期"用户已感知到慢。预测式伸缩像看天气预报提前加固堤坝:用历史 QPS 训练模型,提前 5 分钟把副本加上去。难点在于历史数据里的"毛刺”(秒杀、爬虫、故障重试)会污染模型,必须先清洗。

3.2 LSTM 清洗历史 QPS 毛刺

用滚动中位数 + MAD(中位数绝对偏差)识别异常点,再用线性插值替换,避免极端值误导 LSTM。

# clean_qps.py —— 清洗 QPS 毛刺,输出平滑训练集
import pandas as pd
import numpy as np

# 1) 读取历史 QPS(每分钟一个点)
df = pd.read_csv("qps_history.csv", parse_dates=["ts"])
qps = df["qps"].astype(float)

# 2) 滚动中位数 + MAD 检测毛刺
window = 15  # 15 分钟滚动窗口
median = qps.rolling(window, center=True, min_periods=3).median()
mad = (qps - median).abs().rolling(window, center=True, min_periods=3).median()
# 阈值:偏离中位数超过 3 倍 MAD 视为毛刺
mask = (qps - median).abs() > 3 * 1.4826 * mad

# 3) 毛刺点用前后线性插值替换
clean = qps.copy()
clean[mask] = np.nan
clean = clean.interpolate(method="linear")
print("被清洗的毛刺点数:", mask.sum())
clean.to_csv("qps_clean.csv", index=False)

# 4) 之后用 clean 序列训练 LSTM(此处省略模型代码,
#    重点:输入 [t-60..t] 预测 t+5min 的 QPS)

3.3 将预测结果写入 HPA v2 的 CRD

Kubeflow Pipelines 跑完预测后,把"未来 5 分钟建议副本数"写进 HorizontalPodAutoscaler(autoscaling/v2)的 spec.minReplicas,作为实时 HPA 的兜底下限,让实时伸缩只能在这个下限之上、上限之下活动。

# predict_to_hpa.sh
# 假设预测服务返回建议最小副本数
PRED_MIN=$(curl -s http://predictor.svc/predict?metric=qps | jq '.minReplicas')

# 用 kubectl patch 更新 HPA v2 的 minReplicas(CRD 字段)
kubectl patch hpa shop-frontend -n shop --type merge \
  -p "{\"spec\":{\"minReplicas\": $PRED_MIN, \"maxReplicas\": 50}}"

echo "已写入预测下限 minReplicas=$PRED_MIN"
# 对应 HPA v2 模板(autoscaling/v2)
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: shop-frontend
  namespace: shop
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: shop-frontend
  minReplicas: 5          # 由预测脚本动态写入
  maxReplicas: 50         # 由预测脚本动态写入
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 55

3.4 预测伸缩与实时伸缩冲突时设优先级避免震荡

冲突场景:预测说"大促要来了,minReplicas 设 20",但此刻真实 CPU 很低,实时 HPA 想缩到 5。两套逻辑打架会反复横跳(抖动)。解法——预测定下限、实时管区间内、并加冷却

最终副本 = clamp( 实时HPA算出的副本 , 预测minReplicas , maxReplicas )
且:实时缩容必须满足 scaleDown.stabilizationWindowSeconds
# 用 HPA behavior 给实时缩容加稳定窗,预测只碰 minReplicas
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: shop-frontend
  namespace: shop
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: shop-frontend
  minReplicas: 5
  maxReplicas: 50
  metrics:
    - type: Resource
      resource: { name: cpu, target: { type: Utilization, averageUtilization: 55 } }
  behavior:
    scaleDown:
      stabilizationWindowSeconds: 300   # 实时缩容 5 分钟稳定窗,消除抖动
      policies:
        - type: Percent
          value: 10
          periodSeconds: 60             # 每次最多缩 10%
    scaleUp:
      stabilizationWindowSeconds: 0
      policies:
        - type: Percent
          value: 100
          periodSeconds: 30
sequenceDiagram
    participant P as 预测控制器
    participant H as HPA v2
    participant R as 实时指标(CPU)
    P->>H: 写入 minReplicas=20(预测大促)
    R->>H: 当前 CPU 低,建议 5
    H->>H: final = clamp(5, 20, 50) = 20
    Note over H: 实时只能缩到预测下限,不会跌破 20
    R->>H: 大促真开始,CPU 高,建议 40
    H->>H: final = clamp(40, 20, 50) = 40

四、Serverless 工作流

4.1 白话直觉:Serverless 工作流像"乐高式流水线"

阿里云 FunctionFlow(FnF)把多个函数/步骤串成有状态的长流程,自带重试、补偿、状态持久化。它最怕两件事:①重试导致重复扣款(非幂等);②流程状态无限增长把存储和日志成本撑爆

4.2 FunctionFlow 设重试策略避免幂等订单重复扣款

重试必须建立在幂等之上:每次扣款携带 idempotencyKey(订单号),服务端先查"这笔是否已扣",已扣则直接返回成功而不重复扣。FnF 重试配置里要排除"业务已成功但网络超时"这类不该再试的情况,并用指数退避+抖动。

# fnf-retry.yaml(概念配置,对应 FnF 的 Definition)
# 关键:重试仅对 transient 错误,且扣款步骤用 idempotencyKey 保证幂等
flow:
  steps:
    - name: deductPayment
      type: task
      resource: acs:fc:::services/pay/functions/deduct
      input:
        orderId: $.orderId
        idempotencyKey: $.orderId      # 同一订单号 → 幂等,重复调用不重复扣
      retry:
        maxAttempts: 3                 # 最多重试 3 次
        interval: 2                    # 首次间隔 2s
        backoff: 2.0                   # 指数退避:2s,4s,8s
        jitter: 1.0                    # 加入随机抖动避免重试风暴
        errors:                        # 仅对 transient 错误重试
          - "ServiceUnavailable"
          - "Timeout"
        noRetryErrors:                 # 业务明确失败(如余额不足)绝不重试
          - "InsufficientBalance"
          - "DuplicatedOrder"

4.3 Serverless 工作流 + TableStore 实现库存扣减 Saga 事务

Saga 用"正向步骤 + 补偿步骤“替代分布式事务:扣库存失败就反向补偿已创建的订单。下面是基于 FnF 定义语言(JSON)的库存扣减 Saga。

{
  "Name": "InventoryDeductSaga",
  "StartAt": "CreateOrder",
  "States": {
    "CreateOrder": {
      "Type": "Task",
      "Resource": "acs:fc:::services/order/functions/create",
      "Next": "DeductInventory",
      "Catch": [
        { "ErrorEquals": ["*"], "Next": "FailEnd" }
      ]
    },
    "DeductInventory": {
      "Type": "Task",
      "Resource": "acs:tablestore:::ots/Inventory/deduct",
      "Next": "Pay",
      "Catch": [
        { "ErrorEquals": ["InventoryNotEnough"], "Next": "CompensateCreateOrder" }
      ]
    },
    "Pay": {
      "Type": "Task",
      "Resource": "acs:fc:::services/pay/functions/deduct",
      "End": true,
      "Catch": [
        { "ErrorEquals": ["*"], "Next": "CompensateInventory" }
      ]
    },
    "CompensateInventory": {
      "Type": "Task",
      "Resource": "acs:tablestore:::ots/Inventory/rollback",
      "Next": "CompensateCreateOrder"
    },
    "CompensateCreateOrder": {
      "Type": "Task",
      "Resource": "acs:fc:::services/order/functions/cancel",
      "Next": "FailEnd"
    },
    "FailEnd": { "Type": "Fail", "Error": "SagaFailed" }
  }
}

4.4 工作流执行历史 >90 天自动归档 OSS 并清理日志降本

FnF 的执行历史(每一步输入/输出)默认长期保留,90 天后价值低却占存储、还产生日志费用。用生命周期式归档脚本定期搬运到 OSS(冷存储)并清空控制台历史。

# archive_fnf_history.sh
# 把 90 天前的执行实例导出到 OSS,再删除本地历史以降本
OSS_BUCKET="oss://fnf-archive"
CUTOFF=$(date -d '90 days ago' +%Y-%m-%d)

# 1) 列出早于截止日的执行
for exec in $(fnf list-executions --flow InventoryDeductSaga \
  --started-before "$CUTOFF" --query 'executions[].executionName' -o tsv); do
  # 2) 导出执行详情到 OSS
  fnf get-execution-history --execution "$exec" > /tmp/$exec.json
  ossutil cp /tmp/$exec.json "$OSS_BUCKET/$exec.json"
  # 3) 删除控制台历史(释放存储/日志成本)
  fnf stop-execution --execution "$exec" >/dev/null 2>&1
done
echo "归档完成:早于 $CUTOFF 的执行已迁入 OSS"
flowchart TD
    A[开始: CreateOrder] --> B[DeductInventory 扣库存]
    B -->|成功| C[Pay 支付]
    B -->|库存不足| R1[补偿: 取消订单]
    C -->|成功| D[结束]
    C -->|支付失败| R2[补偿: 回滚库存]
    R2 --> R1
    R1 --> E[Fail 终态]

五、冷启动优化与镜像加速

5.1 白话直觉:镜像别"整箱搬”,要"按需取"

传统容器启动要把整个镜像层从仓库拉下来解压,几百 MB 甚至 GB,冷启动自然慢。镜像加速技术做两件事:①懒加载(Nydus)——先挂上"目录",用到哪块数据再拉哪块;②P2P 分发(Dragonfly)——节点间互相传,不让仓库被拉爆。

5.2 Nydus 懒加载:block 大小对 P99 启动延迟的影响

Nydus(RAFS 格式)把镜像切成若干 block,运行时按需从远端拉 block。block 太小 → 拉取请求数爆炸、元数据多;block 太大 → 单次拉取带宽浪费、首屏等待长。需要在 P99 启动延迟上做权衡实验。

# bench_nydus.sh —— 在不同 block 大小下测量 P99 启动延迟
for BS in 4K 64K 512K 1M; do
  # 转换镜像为指定 block 大小的 Nydus 格式
  nydus-image create --bootstrap bootstrap.json \
    --blob blob.bin --block-size "$BS" --fs-version 5
  # 启动容器并记录冷启动耗时(从 kubectl create 到 Ready)
  START=$(date +%s%N)
  kubectl run bench-$BS --image=registry.example.com/app:nydus-$BS
  kubectl wait pod bench-$BS --for=condition=Ready --timeout=120s
  END=$(date +%s%N)
  echo "block=$BS 启动延迟=$(( (END-START)/1000000 ))ms"
done
# 结论:通常 512K~1M 在"拉取请求数"与"首屏带宽"间较平衡,P99 最优

5.3 Dragonfly 做 P2P 镜像分发并限制单节点带宽 50MB/s

Dragonfly 的 dfdaemon 作为镜像拉取代理,节点间 P2P 互传;用 rateLimit 卡住单节点出口带宽,防止拖垮业务网。

# dfdaemon-config.yaml
# Dragonfly dfdaemon 配置:开启 P2P 并限制单节点带宽 50MB/s
apiVersion: v1
kind: ConfigMap
metadata:
  name: dfdaemon-config
  namespace: dragonfly
data:
  dfdaemon.yaml: |
    verbose: false
    p2p:
      enable: true                 # 开启 P2P 分发
      clusterID: 1
    proxy:
      registryMirrors:
        - "https://registry.example.com"   # 回源镜像仓库
      defaultFilter: "overlayfs"           # 仅 P2P 加速镜像层
    # —— 关键:单节点带宽上限 50MB/s ——
    # 单位 MB(不是 Mb),50MB/s ≈ 400Mbps
    scheduler:
      rateLimit: 50               # 单节点 P2P 上行限速 50MB/s
    download:
      perPeerRateLimit: 50M       # 单 peer 下载限速 50MB/s    
---
apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: dfdaemon
  namespace: dragonfly
spec:
  selector:
    matchLabels: { app: dfdaemon }
  template:
    metadata:
      labels: { app: dfdaemon }
    spec:
      containers:
        - name: dfdaemon
          image: dragonflyoss/dfdaemon:v2.0.0
          args: ["--config", "/etc/dragonfly/dfdaemon.yaml"]
          volumeMounts:
            - { name: cfg, mountPath: /etc/dragonfly }
      volumes:
        - name: cfg
          configMap: { name: dfdaemon-config }

5.4 containerd 切到 crun:验证是否真正启用 runc 替代

crun(C 实现)比 runc(Go 实现)更轻更快。切换不是改个名字就完事——必须验证运行时真的走了 crun,否则只是"配置写了但没生效"。

# 1) 修改 containerd 配置,新增 crun runtime
# /etc/containerd/config.toml 增加:
# [plugins."io.containerd.grpc.v1.cri".containerd.runtimes.crun]
#   runtime_type = "io.containerd.runc.v2"
#   [plugins."io.containerd.grpc.v1.cri".containerd.runtimes.crun.options]
#     BinaryName = "/usr/bin/crun"
sudo systemctl restart containerd

# 2) 部署一个测试 Pod,指定 runtimeClass=crun(或集群默认已切)
kubectl run crun-test --image=busybox --command -- sleep 3600

# 3) 验证:找到容器进程,看它用的是 crun 还是 runc
PID=$(kubectl get pod crun-test -o jsonpath='{.status.containerStatuses[0].containerID}' \
  | sed 's#containerd://##' | xargs -I{} ctr -n k8s.io t asks {} 2>/dev/null; \
  ps -eo pid,comm,args | grep -i crun | grep -v grep | head -1 | awk '{print $1}')
# 更直接:看进程命令行
ps -ef | grep -E "crun|runc" | grep -v grep
# 期望只看到 crun,看不到 runc —— 说明真正替代成功

# 4) 确认版本
crun --version
flowchart LR
    P[Pod 创建请求] --> C{containerd 配置}
    C -->|runtime=crun| R1[crun 启动容器]
    C -->|未配置/写错| R2[runc 启动容器]
    R1 --> V[ps 验证见 crun 进程]
    R2 --> X[验证见 runc → 切换失败]

六、实时成本展示与分摊(FinOps)

6.1 白话直觉:看不见的成本,就是失控的成本

FinOps 的核心是"谁花了多少钱,摊到哪个团队"。OpenCost / KubeCost 把 K8s 资源账单按 namespace、label、团队拆开,像给每个部门发"云资源水费单"。

6.2 对 GPU 节点按型号做差异化定价

不同 GPU(A100 贵、T4 便宜)单位成本天差地别。OpenCost 允许通过自定义定价配置覆盖默认价。

# opencost-gpu-pricing.yaml
# 在 OpenCost 的 pricing 配置里按节点标签区分 GPU 型号单价(美元/小时)
apiVersion: v1
kind: ConfigMap
metadata:
  name: opencost-config
  namespace: opencost
data:
  # 自定义定价:以节点 label gpu-model 区分
  "pricing.gpu.A100": "3.67"      # A100 每卡小时价
  "pricing.gpu.T4": "0.95"        # T4 每卡小时价
  # 或在 default.json 中按 instance type 映射
# 给 GPU 节点打型号标签,供 OpenCost 识别
kubectl label nodes gpu-node-01 gpu-model=A100
kubectl label nodes gpu-node-02 gpu-model=T4
# 查询某团队 GPU 成本
kubectl exec -n opencost deploy/opencost -- \
  curl -s "localhost:9003/allocation?window=1d&filter=team=ml" | jq '.'

6.3 基于 KubeCost + Prometheus 的业务团队成本看板

KubeCost 以 Prometheus 为数据源,Grafana 出图。下面给出 dashboard 配置关键片段。

{
  "title": "团队每日云成本",
  "type": "timeseries",
  "datasource": "Prometheus",
  "targets": [
    {
      "expr": "sum by (team) (kubecost_allocation_cost_total{window=\"1d\"})",
      "legendFormat": "{{team}}"
    }
  ]
}

6.4 Spot 实例价格飙升自动飞书告警

当 Spot 单价超过阈值,立刻通知对应团队换规格,避免"省钱变烧钱"。

# spot-price-alert.yaml
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
  name: spot-price-surge
  namespace: monitoring
spec:
  groups:
    - name: cost
      rules:
        - alert: SpotPriceSurge
          expr: aws_spot_price > 0.5       # 单价超 0.5$/h 触发
          for: 5m
          labels: { severity: warning }
          annotations:
            summary: "Spot 价格飙升"
            feishu: "https://open.feishu.cn/open-apis/bot/v2/hook/xxxx"
# 告警路由到飞书(Alertmanager webhook 侧脚本节选)
curl -s -X POST "$FEISHU_WEBHOOK" \
  -H 'Content-Type: application/json' \
  -d "{\"msg_type\":\"text\",\"content\":{\"text\":\"⚠️ Spot 单价超阈值,请评估切换规格\"}}"

七、弹性伸缩成本优化(FinOps)

7.1 Karpenter NodePool 权重优先选择 AMD 实例

同规格下 AMD(如 m6a)常比 Intel 便宜 10%~15%。Karpenter 用 NodePoolweight 让调度器偏好便宜机型。

# karpenter-nodepool.yaml
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
  name: cheap-amd
spec:
  template:
    spec:
      requirements:
        - key: karpenter.k8s.aws/instance-family
          operator: In
          values: ["m6a", "m5a", "c6a"]     # AMD 家族优先
        - key: karpenter.k8s.aws/instance-category
          operator: In
          values: ["c", "m"]
      nodeClassRef:
        name: default
  # 权重:数值越大越优先被选中
  weight: 100
---
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
  name: fallback-intel
spec:
  template:
    spec:
      requirements:
        - key: karpenter.k8s.aws/instance-family
          operator: In
          values: ["m6i", "m5"]
  weight: 10                                # 仅当 AMD 不可用才 fallback

7.2 CAST AI 自动将低利用率 Pod 迁移到更小规格节点

CAST AI 持续分析利用率,把"大马拉小车"的 Pod 迁到更省的小节点。策略片段:

{
  "rightsizing": {
    "enabled": true,
    "cpuHeadroom": "10%",
    "memoryHeadroom": "15%",
    "minSavingPercent": 15,
    "excludeLabels": { "critical": "true" }
  },
  "automation": { "enabled": true, "action": "resize" }
}

7.3 HPA 与 Cluster Autoscaler 竞态:设扩容优先级避免浪费

经典竞态:HPA 先扩容 Pod,但集群没节点 → CA 加节点;CA 加得太慢或加太多 → Pod 又缩。解法:①给节点池设扩容优先级,便宜池先扩;②HPA 的 scaleUp 与 CA 的 scale-down-delay 错峰;③用 Pod priorityClassName 保证关键 Pod 先抢到新节点。

# 关键 Pod 高优先级,确保新节点先给它
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
  name: high-cost-priority
value: 1000
globalDefault: false
description: "关键服务,扩容时优先获得节点"
---
# CA 节点池优先级(示例 annotation)
apiVersion: v1
kind: ConfigMap
metadata:
  name: cluster-autoscaler-priority-expander
  namespace: kube-system
data:
  priorities: |
    10:.*-spot.*$        # Spot 池优先级 10(便宜优先扩)
    50:.*-ondemand.*$    # 按量池优先级 50(兜底)    
flowchart TD
    H[HPA 想扩容 Pod] --> C{有空闲节点?}
    C -- 否 --> A[Cluster Autoscaler 加节点]
    A -->|按优先级扩 Spot 池| S[Spot 节点先上]
    A -->|Spot 不足| O[按量节点兜底]
    S --> P[Pod 调度]
    O --> P
    C -- 是 --> P
    P --> D{CA 检测节点空闲>10min}
    D -- 是 --> R[缩容空闲节点省成本]

八、资源限额与配额治理(FinOps)

8.1 OPA + Kyverno 双重准入防配额冲突

两个策略引擎同时挂为 Mutating/Validating Webhook 时,可能都去设默认值或都拒绝,导致 Pod 创建失败。解法:用 matchConditions 划分职责——OPA 管"安全合规类拒绝",Kyverno 管"默认值注入",互不重叠;failurePolicy: Fail 时尤其要错开。

# kyverno-mutate-quota.yaml —— Kyverno 只负责注入默认 resources
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: default-resources
spec:
  rules:
    - name: add-default
      match:
        any:
          - resources:
              kinds: ["Pod"]
      mutate:
        patchStrategicMerge:
          spec:
            containers:
              - (name): "*"
                resources:
                  requests:
                    cpu: "100m"
                    memory: "128Mi"
# opa-deny-no-team.yaml —— OPA 只负责"无 team 标签就拒绝"
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingWebhookConfiguration
metadata:
  name: opa-validating-webhook
webhooks:
  - name: validation.openpolicyagent.org
    failurePolicy: Fail
    matchPolicy: Equivalent
    # 与 Kyverno 错开:OPA 只校验 label,不注入
    rules:
      - apiGroups: [""]
        apiVersions: ["v1"]
        operations: ["CREATE"]
        resources: ["pods"]

8.2 ResourceQuota + PriorityClass 保证核心服务资源

ResourceQuota 给 namespace 封顶;PriorityClass 让核心服务在资源争抢时优先被调度,不被挤压。

# resourcequota-core.yaml
apiVersion: v1
kind: ResourceQuota
metadata:
  name: core-quota
  namespace: core
spec:
  hard:
    requests.cpu: "40"
    requests.memory: 80Gi
    limits.cpu: "80"
    limits.memory: 160Gi
    pods: "50"
---
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
  name: core-priority
value: 2000                 # 高于其他业务,调度与抢占时优先
globalDefault: false
description: "核心服务,保障资源"

8.3 Namespace 配额耗尽自动扩容并通知财务

配额用尽时,自动化脚本临时提升 Quota 并触发飞书/邮件审批流,避免业务卡住。

# quota_auto_expand.sh
NS="team-a"
USED=$(kubectl get resourcequota -n $NS -o jsonpath='{.items[0].status.used.pods}' 2>/dev/null)
HARD=$(kubectl get resourcequota -n $NS -o jsonpath='{.items[0].spec.hard.pods}' 2>/dev/null)
if [ "$USED" = "$HARD" ]; then
  # 临时 +20% 配额,保证业务不中断
  kubectl patch resourcequota core-quota -n $NS --type merge \
    -p "{\"spec\":{\"hard\":{\"pods\":\"$(($HARD*12/10))\"}}}"
  # 通知财务走正式扩容审批
  curl -s -X POST "$FEISHU_WEBHOOK" \
    -d "{\"msg_type\":\"text\",\"content\":{\"text\":\"team-a 配额耗尽,已临时+20%,请审批\"}}"
fi

九、多云成本对比与竞价实例(FinOps)

9.1 基于 Spot Instance Advisor 预测回收概率

AWS Spot Instance Advisor 提供各机型"中断频率",可预测未来回收概率,挑低中断机型跑批处理。

# spot_advisor.sh
# 查询某机型中断频率(百分比越低越稳)
aws ec2 describe-spot-price-history \
  --instance-types m5.large \
  --product-descriptions "Linux/UNIX" \
  --query 'SpotPriceHistory[0]'

# 中断频率来自 Advisor API(简化):频率 <10% 才用于可中断批任务
curl -s "https://spot-instance-advisor.com/api/v1/frequency" \
  | jq '.[] | select(.instanceType=="m5.large" and .frequency<10)'

9.2 Terraform + Cloud Broker 跨云比价模块

用 Terraform 模块统一查询多云同规格单价,自动选最便宜。

# main.tf —— 跨云比价模块(示意)
module "price_compare" {
  source = "github.com/example/cloud-broker"
  instance_type = "c6.large"
  clouds = ["aws", "aliyun", "azure"]
}

output "cheapest" {
  value = module.price_compare.lowest_price_cloud   # 输出最便宜的云
}

9.3 阿里云抢占式实例释放,自动在 AWS 申请同等规格 Spot 迁移

监听阿里云抢占释放事件,自动跨云申请等价 Spot 并把 Pod 重新调度。

# cross_cloud_migrate.sh
# 阿里云事件触发:抢占式实例即将释放
EVENT=$(aliyun ecs DescribeEvents | jq -r '.Events[].Reason' | grep -i preempt)
if [ -n "$EVENT" ]; then
  SPEC=$(kubectl get node ali-spot-01 -o jsonpath='{.metadata.labels.instance-type}')
  # 在 AWS 申请同等规格 Spot
  aws ec2 request-spot-instances \
    --instance-type "$SPEC" \
    --spot-price "0.05" \
    --target-capacity-specification '{ "TotalTargetCapacity": 1, "SpotTargetCapacity": 1 }'
  # 将 Pod 从阿里云 Spot 节点驱逐,K8s 重新调度到新节点
  kubectl cordon ali-spot-01
  kubectl drain ali-spot-01 --ignore-daemonsets --delete-emptydir-data
fi
flowchart TD
    A[阿里云抢占实例释放事件] --> B[获取规格 SPEC]
    B --> C[在 AWS 申请同等 Spot]
    C --> D[cordon 阿里节点并 drain]
    D --> E[Pod 重调度到 AWS Spot]
    E --> F[业务不中断,成本最低]

十、碳排放与绿色计算(FinOps)

10.1 白话直觉:算力也有"碳账单"

每度电都对应碳排放。FinOps 的尽头是"用更少的能,办同样的事"——Kepler 用 eBPF 测每个 Pod 实际耗能,像给容器装了"电表"。

10.2 Kepler 对 Pod 级能耗实时估算并导出 Prometheus 指标

# kepler-deploy.yaml(节选)
apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: kepler-exporter
  namespace: kepler
spec:
  template:
    spec:
      containers:
        - name: kepler
          image: quay.io/sustainable-computing-io/kepler:latest
          securityContext:
            privileged: true          # 需 eBPF 权限采集能耗
          args: ["--exporter.metric.labels=container,pod,namespace"]
          ports:
            - { containerPort: 9102 }
# 查询某 Pod 能耗(焦耳)与对应碳排
kubectl exec -n kepler ds/kepler-exporter -- \
  curl -s localhost:9102/metrics | grep 'kepler_pod_joules_total'
# 碳排 = 能耗(kWh) × 区域电网碳强度(gCO2/kWh)

10.3 Scheduler Plugin 优先调度到 PUE<1.2 数据中心

PUE(电源使用效率)越低越绿。自定义调度插件给低 PUE 节点更高打分。

// lowpue_plugin.go(调度插件打分函数节选)
func (pl *LowPUE) Score(ctx context.Context, state *framework.CycleState,
    pod *v1.Pod, nodeName string) (int64, *framework.Status) {
    node := getNode(nodeName)
    pue, _ := strconv.ParseFloat(node.Labels["datacenter.pue"], 64)
    // PUE 越低分数越高:1.1 → 100 分,1.5 → 0 分
    score := int64((1.5 - pue) / 0.4 * 100)
    if score < 0 { score = 0 }
    return score, nil
}

10.4 碳排超标自动降低非核心服务副本数并记录审计

# carbon_throttle.sh
CARBON=$(curl -s http://carbon-api/intensity | jq '.gco2_per_kwh')
if awk "BEGIN{exit !($CARBON > 500)}"; then     # 碳强度超 500g 阈值
  # 降非核心服务副本,核心服务不动
  kubectl get deploy -n batch -o name | while read d; do
    kubectl scale "$d" --replicas=1
  done
  echo "$(date) 碳排超标,已降非核心副本" >> /var/log/carbon-audit.log
fi
flowchart TD
    K[Kepler eBPF 采集 Pod 能耗] --> P[Prometheus 指标]
    P --> C[Carbon API 计算碳强度]
    C -->|超阈值| T[降非核心副本+审计]
    C -->|正常| N[维持]
    S[Scheduler 低 PUE 插件] --> D[新 Pod 优先落低 PUE 节点]

自测题与动手练习

概念理解题

  1. KEDA 的 Lag 突降为 0 但 CPU 仍高,为什么不能立刻缩容?用本文的"快递窗口"类比复述一遍。
  2. ScaledObjectScaledJob 分别适合什么场景?最大并发度 500 是通过哪两个字段共同约束的?
  3. Spot 回收率 >30% 时,“优雅迁移窗"要解决的核心风险是什么?支付类 Pod 如何从根本上避开 Spot?

配置实战题 4. 给出一个 ScaledObject YAML,使"Kafka Lag"和"CPU"共同决定副本数(取最大值),并说明 cooldownPeriod 的作用。 5. 写一段 kubectl patch 命令,把名为 api 的 HPA 的 minReplicas 设置为预测脚本算出的 12。 6. 用 nodeAffinity 写一段 Pod 配置,使其只调度到 VK 虚拟节点(label type=virtual-kubelet)且申请 1 张 GPU。

FinOps 综合题 7. OpenCost 如何对 A100/T4 做差异化定价?为什么要按型号而不是统一单价? 8. HPA 与 Cluster Autoscaler 竞态时,文中给出的三条化解手段是什么? 9. 当 Namespace 配额耗尽,自动化脚本做了哪两件事?为什么要"先临时扩容再走审批"而不是"直接拒绝”? 10. Kepler 估算 Pod 能耗依赖什么技术?碳排超阈值时为什么只降"非核心"服务副本?

动手练习

  • 练习 A:在本机 kind 集群装 KEDA,跑 3.3 节的 ScaledObject 组合触发器,用 kubectl get hpa -w 观察 Lag=0 但 CPU 高时副本是否被"顶住"。
  • 练习 B:用文中 Dragonfly 配置起一个 dfdaemon DaemonSet,故意拉一个 1GB 镜像,用 iftop 验证单节点带宽是否真的被限制在 50MB/s。
  • 练习 C:把 10.3 节的 Go 调度插件编译进自定义 scheduler,部署后 kubectl get events 验证新 Pod 是否优先落到 datacenter.pue=1.1 的节点。

本章小结

  • 弹性伸缩(第 3 章):KEDA 用"多触发器取 max"根治 Lag 归零但 CPU 仍高的过早缩容;ScaledJobmaxReplicaCount+maxConcurrentJobs 卡死并发;虚拟节点/Spot 混部靠"终止信号→cordon→迁移窗"保住关键链路,GPU 走 VK、QoS 触发差异化计费;预测式伸缩让"预测定下限、实时管区间内"消除抖动;Serverless 工作流靠幂等键 + Saga 补偿 + 历史归档降本;Nydus/Dragonfly/crun 把冷启动与分发成本压到最低。
  • 成本治理(第 9 章):FinOps 的抓手是"看得见、摊得开、控得住"——OpenCost/KubeCost 做分摊,Karpenter/CAST AI 做弹性降本,ResourceQuota+PriorityClass 做配额护栏,跨云比价与 Spot 迁移做极致压价,Kepler+低 PUE 调度+碳阈节流把可持续性也纳入成本账本。
  • 一条主线:弹性是把"资源供给"贴合"真实负载",FinOps 是把"资源开销"对齐"业务价值"——两者共同目标是用最少的钱,扛最稳的峰
复习提示:
  • KEDA 多触发器取 max:当 Kafka Lag=0 但 CPU 仍高时,仅看 Lag 会导致过早缩容;配置多个触发器取 max 才能同时兼顾消息堆积和计算压力。
  • Spot 混部核心风险:云厂商提前 30 秒通知回收,关键链路必须在这 30 秒内完成 cordon + drain + 迁移到新节点,否则 Pod 直接消失。支付类服务绝对不能用 Spot。
  • FinOps 三件套:OpenCost/KubeCost(看得见)+ Karpenter/CAST AI(弹性降本)+ ResourceQuota(护栏),三步缺一不可。
  • 下一篇讲边缘计算——它把云原生的弹性能力延伸到了离用户最近的设备端。
面试官
KEDA 的 cooldownPeriod 设多大合适?设太小和太大分别有什么后果?
候选人
cooldownPeriod 是缩容后的冷却期,防止 Pod 数在临界值附近频繁震荡。

设太小(如 5 秒):Lag 在阈值附近波动时,HPA 会反复缩扩容,产生"缩 - 扩-缩"抖动,增加调度开销和冷启动延迟。
设太大(如 10 分钟):流量骤降后不敢缩容,浪费资源成本;或者刚扩完的 Pod 还没来得及承载流量就被缩掉。

经验值:消费型场景(Kafka Lag 触发)通常 510 分钟;CPU 型场景 35 分钟。核心原则是 cooldown 时间要大于指标本身的波动周期——如果 Lag 每 2 分钟更新一次,cooldown 至少 5 分钟。面试加分点:提到 KEDA 默认 300 秒(5 分钟),这是经过大量生产实践验证的值。
About Me

没什么想介绍的,一个很大众的码农…

喜欢代码,车,马,真的是 🐎

讨厌别人让我给自己的代码写注释 最厌烦别人的程序没有写注释

目标

学AI,加油!加油!