WEBKT

用强化学习算法 TD3 优化 K8s 动态调度:高并发场景下的落地实践

83 0 0 0

在混合部署、大模型微调以及高并发微服务等复杂业务场景下,Kubernetes 默认的 kube-scheduler 往往会显得力不从心。默认调度器主要依赖静态的 RequestLimit 进行资源预估,并采用固定的过滤(Filter)和打分(Score)算法。

但在真实的生产环境中,物理节点的 CPU、内存、I/O 以及网络带宽是动态波动的。静态调度容易导致“热点节点”与“空闲节点”并存,甚至引发级联雪崩。

为了解决这个问题,我们团队尝试将强化学习(RL)中的**双延迟深度确定性策略梯度(Twin Delayed Deep Deterministic Policy Gradient, TD3)**算法引入 K8s 调度系统。本文将分享这一方案的设计架构、核心算法映射以及生产落地时踩过的坑。


为什么选择 TD3 算法?

在连续动作空间的强化学习中,DDPG(Deep Deterministic Policy Gradient)是一个经典算法。但在实际的 K8s 资源调度中,DDPG 存在严重的过估偏差(Overestimation Bias),这会导致调度器做出过于激进的决定(例如误认为某个处于 I/O 临界值的节点依然“非常健康”),从而导致服务抖动。

TD3 作为 DDPG 的升级版,通过以下三大机制完美契合了复杂的调度场景:

  1. 截断的双 Q 网络(Clipped Double Q-learning):引入两个 Critic 网络,取其最小的 Q 值来计算 target 值。这极大缓解了对节点承载能力的过估,让调度决策更保守、更安全。
  2. 延迟策略更新(Delayed Policy Updates):Actor 网络(策略网络)的更新频率低于 Critic 网络。在频繁变动的集群指标中,这能防止调度策略因瞬时流量抖动而频繁振荡。
  3. 目标策略平滑正则化(Target Policy Smoothing):在目标动作中加入少量随机噪声,使策略对网络波动的鲁棒性更强。

核心挑战:连续动作空间 vs 离散节点选择

TD3 本质上是一个适用于连续动作空间(Continuous Action Space)的算法,而 K8s 的调度目标是明确的离散节点(Discrete Nodes)

如果直接让 TD3 输出节点 ID,会面临两个致命问题:

  • 集群节点数量是动态变化的(弹性伸缩),神经网络的输出维度无法固定。
  • 离散的节点 ID 无法求导,策略梯度无法反向传播。

我们的解决方案:动态权重打分(Dynamic Weight Scoring)

我们没有让 TD3 直接选择具体的节点,而是让其输出一个连续的资源权重向量 $\boldsymbol{a} = [w_{cpu}, w_{mem}, w_{io}, w_{net}]$

K8s 调度器的 Score 阶段会调用这个权重向量,对每个候选节点进行加权计算。

$$\text{Score}(Node_i) = w_{cpu} \cdot C_i + w_{mem} \cdot M_i + w_{io} \cdot I_i + w_{net} \cdot N_i$$

其中 $C_i, M_i, I_i, N_i$ 是通过 Prometheus 实时监控获取并归一化后的物理指标。这样既保留了 TD3 在连续空间中高效搜索最优解的能力,又完美兼容了 K8s 调度框架的插件机制。


系统整体架构设计

整个智能调度系统分为两部分:K8s 调度器插件(Go)TD3 决策引擎(Python)。两者通过高并发的 gRPC 进行通信。

+-------------------------------------------------------------+
|                     Kubernetes Cluster                      |
|                                                             |
|   +------------------+             +--------------------+   |
|   |   Pending Pod    |             |  Prometheus / APM  |   |
|   +--------+---------+             +---------+----------+   |
|            |                                 |              |
|            v (Scheduling Queue)              | (Metrics)    |
|   +--------+---------------------------------+----------+   |
|   |  K8s Custom Scheduler (Go)                          |   |
|   |                                                     |   |
|   |  1. Filter Phase (Hard Constraints)                 |   |
|   |  2. Score Phase (gRPC request to TD3 Engine)        |   |
|   +--------+---------------------------------+----------+   |
|            |                                 |              |
+------------|---------------------------------|--------------+
             | gRPC (Get Weights)              | Push State (State, Reward)
             v                                 v
+------------+---------------------------------+--------------+
|            |      TD3 Decision Engine (Python)              |
|            +-------> Inference (Actor Network)              |
|                      Training (Replay Buffer)               |
+-------------------------------------------------------------+

1. K8s 调度框架插件实现

在 Go 编写的调度器中,我们注册了一个自定义的 Score 插件。每次调度 Pod 时,插件向 TD3 引擎发送当前集群的状态(State),获取当前最优的权重分配,再完成打分。

2. TD3 强化学习要素定义

  • 状态空间 (State, $s_t$)
    • 当前待调度 Pod 的资源申请值(CPU, Memory)。
    • 集群中各节点的实时资源空闲率(CPU 实际空闲、内存实际空闲、磁盘 I/O 负载、网络带宽占用)。
    • 过去 5 分钟内节点的 OOM 发生次数。
  • 动作空间 (Action, $a_t$)
    • 四维实数向量 $[w_{cpu}, w_{mem}, w_{io}, w_{net}] \in [0, 1]^4$,且其和为 1。
  • 回报函数 (Reward, $r_t$)
    • 我们设计了一个多目标的混合回报函数:
      $$Reward = -\alpha \cdot \text{Fragmentation} - \beta \cdot \text{OverloadPenalty} - \gamma \cdot \text{SLA_Violation}$$
      • 碎片率惩罚(Fragmentation):鼓励 CPU 和内存尽量等比例消耗,减少无法被利用的系统碎片。
      • 超载惩罚(OverloadPenalty):若调度后某个物理节点的实际负载超过 85%,给予极大的负回报。
      • SLA 违约(SLA_Violation):监控 Pod 调度后的启动延迟和运行期抖动率。

核心代码实现

Go 端:K8s 调度器 Score 插件片段

package td3_scheduler

import (
    "context"
    "time"

    "google.golang.org/grpc"
    v1 "k8s.io/api/core/v1"
    "k8s.io/kubernetes/pkg/scheduler/framework"
)

type TD3ScorePlugin struct {
    grpcClient  SchedulerDecisionClient // gRPC 客户端
    metricsConn *grpc.ClientConn
}

func (pl *TD3ScorePlugin) Name() string {
    return "TD3ScorePlugin"
}

func (pl *TD3ScorePlugin) Score(ctx context.Context, state *framework.CycleState, pod *v1.Pod, nodeName string) (int64, *framework.Status) {
    // 1. 获取当前节点的物理监控指标
    nodeMetrics := getRealtimeMetrics(nodeName)
    
    // 2. 调用 Python 端的 TD3 模型获取动态权重
    ctx, cancel := context.WithTimeout(ctx, 15*time.Millisecond) // 严格控制超时
    defer cancel()
    
    response, err := pl.grpcClient.GetSchedulingWeights(ctx, &DecisionRequest{
        PodCpuRequest:    pod.Spec.Containers[0].Resources.Requests.Cpu().MilliValue(),
        PodMemRequest:    pod.Spec.Containers[0].Resources.Requests.Memory().Value(),
        NodeCpuIdle:      nodeMetrics.CpuIdle,
        NodeMemIdle:      nodeMetrics.MemIdle,
        NodeIoLoad:       nodeMetrics.IoLoad,
        NodeNetworkLoad:  nodeMetrics.NetworkLoad,
    })
    
    if err != nil {
        // 降级策略:如果 gRPC 调用超时或失败,退回到默认的静态打分
        return defaultStaticScore(nodeMetrics), framework.NewStatus(framework.Success, "fallback")
    }

    // 3. 计算加权最终得分
    score := float64(nodeMetrics.CpuIdle) * response.WCpu +
        float64(nodeMetrics.MemIdle) * response.WMem -
        float64(nodeMetrics.IoLoad) * response.WIo -
        float64(nodeMetrics.NetworkLoad) * response.WNet

    return int64(score * 100), framework.NewStatus(framework.Success, "")
}

Python 端:TD3 决策网络模型实现

import torch
import torch.nn as nn
import torch.nn.functional as F

class Actor(nn.Module):
    def __init__(self, state_dim, action_dim=4):
        super(Actor, self).__init__()
        # 定义网络结构,输入为 K8s 集群状态,输出为四维动作权重
        self.l1 = nn.Linear(state_dim, 256)
        self.l2 = nn.Linear(256, 256)
        self.l3 = nn.Linear(256, action_dim)
        
    def forward(self, state):
        x = F.relu(self.l1(state))
        x = F.relu(self.l2(x))
        # 使用 Softmax 确保输出的权重相加为 1 且均为正数
        return F.softmax(self.l3(x), dim=-1)

class Critic(nn.Module):
    def __init__(self, state_dim, action_dim=4):
        super(Critic, self).__init__()
        # Q1 网络
        self.l1 = nn.Linear(state_dim + action_dim, 256)
        self.l2 = nn.Linear(256, 256)
        self.l3 = nn.Linear(256, 1)

        # Q2 网络 (TD3 核心:双网络降低估算偏差)
        self.l4 = nn.Linear(state_dim + action_dim, 256)
        self.l5 = nn.Linear(256, 256)
        self.l6 = nn.Linear(256, 1)

    def forward(self, state, action):
        sa = torch.cat([state, action], 1)

        q1 = F.relu(self.l1(sa))
        q1 = F.relu(self.l2(q1))
        q1 = self.l3(q1)

        q2 = F.relu(self.l4(sa))
        q2 = F.relu(self.l5(q2))
        q2 = self.l6(q2)
        return q1, q2

落地生产面临的“血泪史”与解决方案

将这套方案推向生产环境时,我们经历了数次惨痛的事故。以下是最终总结出的避坑指南:

1. 毫秒级调度延迟挑战

  • 痛点:K8s 的调度吞吐率要求极高。如果每次打分都要通过 gRPC 访问 PyTorch 进行推理,会导致调度延迟从 5ms 暴涨到 80ms,引发 Pod 积压。
  • 解法
    • 本地缓存与批量推断:在 Python 侧使用 LibTorch (C++) 或者将模型导出为 ONNX 格式,使用 TensorRT 引擎进行加速。
    • 异步状态同步:集群状态不采用实时查询,而是通过 Informer 机制在本地内存中维护一个“影子状态集”。最终将单次 gRPC 交互耗时压缩到了 3.2ms 左右。

2. 冷启动与探索期的系统崩溃

  • 痛点:强化学习在训练初期(Exploration 阶段)是“盲目”尝试的。如果直接在生产环境部署,TD3 可能会给出极其离谱的权重组合,导致所有大流量 Pod 被调度到同一个高负载节点,瞬间压垮节点。
  • 解法
    • 行为约束(Safe RL):我们在 Go 语言端加了一层“硬限”。即使 TD3 输出的资源权重再极端,只要候选节点满足 $CPU_{usage} > 90%$,Filter 阶段也会直接将其剔除,不参与打分。
    • 离线模仿学习(Imitation Learning):采集了集群过去 3 个月的历史调度日志,用行为克隆(Behavior Cloning)对 TD3 模型进行离线预训练,使其初始行为表现不亚于默认调度器,再开启在线实时探索。

3. 环境非平稳性(Non-Stationary Environment)

  • 痛点:由于业务版本的不断发布迭代,集群的资源消耗特征随时在变,半年前训练出的模型可能会因为业务特征改变而逐渐失效。
  • 解法
    • 引入滑窗式在线增量训练(Continuous Training)。我们专门拉起了一个旁路任务,收集每日的调度日志及随后的 Reward 反馈,并采用较小的学习率对 Critic 和 Actor 进行微调(Fine-tuning),保持模型对当前业务负载的敏感度。

落地效果对比

在公司内部的核心计算集群上线 3 个月后,这套基于 TD3 的智能调度器取得了显著成效:

指标维度 默认 kube-scheduler TD3 智能调度器 提升效果
集群 CPU 平均利用率 42.1% 51.3% 绝对值提升 9.2%
节点资源规格碎片率 18.4% 8.2% 碎片浪费减少 10.2%
业务微服务 P99 延迟 124ms 108ms 响应延迟降低 12.9%
突发流量下 OOM 发生次数 14次/周 2次/周 OOM 故障率大幅下降

智能调度并非一蹴而就。通过将强化学习算法与传统的 K8s 声明式架构相结合,我们不光收获了更低的硬件采购账单,更重要的是,探索出了一条将 AI 算法真正落地底层架构的可行路径。

架构师老安 Kubernetes强化学习TD3算法

评论点评