用强化学习算法 TD3 优化 K8s 动态调度:高并发场景下的落地实践
在混合部署、大模型微调以及高并发微服务等复杂业务场景下,Kubernetes 默认的 kube-scheduler 往往会显得力不从心。默认调度器主要依赖静态的 Request 和 Limit 进行资源预估,并采用固定的过滤(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 的升级版,通过以下三大机制完美契合了复杂的调度场景:
- 截断的双 Q 网络(Clipped Double Q-learning):引入两个 Critic 网络,取其最小的 Q 值来计算 target 值。这极大缓解了对节点承载能力的过估,让调度决策更保守、更安全。
- 延迟策略更新(Delayed Policy Updates):Actor 网络(策略网络)的更新频率低于 Critic 网络。在频繁变动的集群指标中,这能防止调度策略因瞬时流量抖动而频繁振荡。
- 目标策略平滑正则化(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 模型进行离线预训练,使其初始行为表现不亚于默认调度器,再开启在线实时探索。
- 行为约束(Safe RL):我们在 Go 语言端加了一层“硬限”。即使 TD3 输出的资源权重再极端,只要候选节点满足 $CPU_{usage} > 90%$,
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 算法真正落地底层架构的可行路径。