Kubernetes 资源拓扑调度:从亲和性到拓扑扩展的调度策略
Kubernetes 资源拓扑调度:从亲和性到拓扑扩展的调度策略
一、K8s 调度的"盲区":跨可用区部署的隐性成本
Kubernetes 默认调度器在分配 Pod 时考虑资源请求、亲和性和污点容忍,但对网络拓扑的感知有限。某在线教育平台将 100 个 Pod 调度到 3 个可用区,默认调度器随机分配,导致同一服务的多个副本集中在同一可用区。当该可用区故障时,服务可用性从 99.99% 骤降至 66%。更隐蔽的问题是跨可用区的网络延迟:同一可用区内延迟 < 0.5ms,跨可用区延迟 2-5ms,数据库访问跨可用区后 P99 延迟增加 300%。
拓扑感知调度要求调度器理解节点间的拓扑关系(可用区、机架、NUMA 节点),并根据业务需求做出合理的分布决策。
二、K8s 拓扑调度的层级与策略
flowchart TB
subgraph 拓扑层级["拓扑层级"]
direction TB
L1[区域 Region<br/>跨地域容灾]
L2[可用区 Zone<br/>电力/网络隔离]
L3[机架 Rack<br/>交换机隔离]
L4[NUMA 节点<br/>内存访问延迟]
end
subgraph 调度策略["调度策略"]
direction LR
S1[Pod 拓扑分布约束<br/>topologySpreadConstraints<br/>均匀分布]
S2[节点亲和性<br/>nodeAffinity<br/>指定拓扑域]
S3[服务亲和性<br/>serviceAffinity<br/>同拓扑域优先]
end
subgraph 扩展机制["扩展机制"]
direction LR
E1[调度框架<br/>Scheduler Framework<br/>Plugin 扩展]
E2[调度器配置<br/>Profile + Plugin<br/>多调度器]
E3[Descheduler<br/>事后重平衡<br/>违反约束时迁移]
end
L1 --> S1 & S2
L2 --> S1 & S3
L3 --> S3
L4 --> E1
S1 --> E1
S2 --> E2
S3 --> E3
style 拓扑层级 fill:#eef,stroke:#333
style 调度策略 fill:#fee,stroke:#333
style 扩展机制 fill:#efe,stroke:#333
三、K8s 拓扑调度的代码实现
from dataclasses import dataclass, field
from typing import List, Dict, Optional, Tuple
from enum import Enum
from collections import defaultdict
import math
class TopologyLevel(Enum):
REGION = "region"
ZONE = "zone"
RACK = "rack"
NODE = "node"
@dataclass
class NodeInfo:
"""节点信息"""
name: str
zone: str
region: str
rack: str
cpu_capacity: int # CPU 核数
cpu_allocatable: int
memory_capacity: int # MB
memory_allocatable: int
labels: Dict[str, str] = field(default_factory=dict)
@dataclass
class PodInfo:
"""Pod 信息"""
name: str
namespace: str
app_label: str
cpu_request: int # millicores
memory_request: int # MB
preferred_zone: Optional[str] = None
current_node: Optional[str] = None
@dataclass
class TopologySpreadConstraint:
"""拓扑分布约束"""
topology_key: str # topology.kubernetes.io/zone
max_skew: int # 最大偏差
when_unsatisfiable: str # DoNotSchedule / ScheduleAnyway
label_selector: Dict # 匹配的 Pod 标签
class TopologyAwareScheduler:
"""
拓扑感知调度器:实现 Pod 拓扑分布约束
"""
def __init__(self):
self._nodes: Dict[str, NodeInfo] = {}
self._pods: List[PodInfo] = []
def add_node(self, node: NodeInfo):
self._nodes[node.name] = node
def add_pod(self, pod: PodInfo):
self._pods.append(pod)
# ============ 拓扑分布计算 ============
def get_topology_distribution(self, app_label: str,
topology_key: str) -> Dict[str, int]:
"""获取指定应用在指定拓扑域的分布"""
distribution = defaultdict(int)
for pod in self._pods:
if pod.app_label != app_label or not pod.current_node:
continue
node = self._nodes.get(pod.current_node)
if not node:
continue
if topology_key == "topology.kubernetes.io/zone":
domain = node.zone
elif topology_key == "topology.kubernetes.io/region":
domain = node.region
elif topology_key == "rack":
domain = node.rack
else:
domain = node.labels.get(topology_key, "unknown")
distribution[domain] += 1
return dict(distribution)
def calculate_skew(self, distribution: Dict[str, int]) -> int:
"""计算最大偏差"""
if not distribution:
return 0
return max(distribution.values()) - min(distribution.values())
# ============ 调度决策 ============
def schedule(self, pod: PodInfo,
constraint: TopologySpreadConstraint) -> Optional[str]:
"""
为 Pod 选择最优节点
核心逻辑:选择使拓扑偏差最小的域中的可用节点
"""
# Step 1: 获取当前分布
distribution = self.get_topology_distribution(
pod.app_label, constraint.topology_key
)
# Step 2: 获取所有拓扑域
all_domains = self._get_all_domains(constraint.topology_key)
# 补全分布(无 Pod 的域也要考虑)
for domain in all_domains:
if domain not in distribution:
distribution[domain] = 0
# Step 3: 选择 Pod 数最少的域
min_count = min(distribution.values())
candidate_domains = [
d for d, c in distribution.items() if c == min_count
]
# Step 4: 检查偏差约束
if constraint.when_unsatisfiable == "DoNotSchedule":
# 严格模式:如果调度后偏差超过 max_skew,拒绝调度
for domain in candidate_domains:
new_distribution = dict(distribution)
new_distribution[domain] += 1
new_skew = self.calculate_skew(new_distribution)
if new_skew <= constraint.max_skew:
# 在该域中选择资源最充足的节点
node = self._select_node_in_domain(
domain, pod, constraint.topology_key
)
if node:
return node
return None # 无法满足约束
else:
# 宽松模式:优先选择偏差最小的域,但不拒绝
# 按域的 Pod 数升序排列
sorted_domains = sorted(
distribution.items(), key=lambda x: x[1]
)
for domain, _ in sorted_domains:
node = self._select_node_in_domain(
domain, pod, constraint.topology_key
)
if node:
return node
return None
def _get_all_domains(self, topology_key: str) -> List[str]:
"""获取所有拓扑域"""
domains = set()
for node in self._nodes.values():
if topology_key == "topology.kubernetes.io/zone":
domains.add(node.zone)
elif topology_key == "topology.kubernetes.io/region":
domains.add(node.region)
elif topology_key == "rack":
domains.add(node.rack)
else:
domains.add(node.labels.get(topology_key, "unknown"))
return list(domains)
def _select_node_in_domain(self, domain: str, pod: PodInfo,
topology_key: str) -> Optional[str]:
"""在指定拓扑域中选择资源最充足的节点"""
candidates = []
for node in self._nodes.values():
# 检查节点是否属于目标域
if topology_key == "topology.kubernetes.io/zone":
if node.zone != domain:
continue
elif topology_key == "topology.kubernetes.io/region":
if node.region != domain:
continue
elif topology_key == "rack":
if node.rack != domain:
continue
# 检查资源是否充足
if (node.cpu_allocatable >= pod.cpu_request and
node.memory_allocatable >= pod.memory_request):
# 计算可用资源分数
score = (
node.cpu_allocatable * 10 + node.memory_allocatable / 1024
)
candidates.append((node.name, score))
if not candidates:
return None
# 选择分数最高的节点
candidates.sort(key=lambda x: x[1], reverse=True)
return candidates[0][0]
# ============ K8s Manifest 生成 ============
class TopologyManifestGenerator:
"""生成 K8s 拓扑调度相关的 Manifest"""
@staticmethod
def generate_deployment_with_spread(
app_name: str,
replicas: int,
image: str,
zones: List[str],
max_skew: int = 1,
) -> Dict:
"""生成带拓扑分布约束的 Deployment"""
return {
"apiVersion": "apps/v1",
"kind": "Deployment",
"metadata": {"name": app_name},
"spec": {
"replicas": replicas,
"selector": {
"matchLabels": {"app": app_name}
},
"template": {
"metadata": {
"labels": {"app": app_name}
},
"spec": {
"topologySpreadConstraints": [{
"maxSkew": max_skew,
"topologyKey": "topology.kubernetes.io/zone",
"whenUnsatisfiable": "DoNotSchedule",
"labelSelector": {
"matchLabels": {"app": app_name}
},
}],
"affinity": {
"podAntiAffinity": {
"preferredDuringSchedulingIgnoredDuringExecution": [{
"weight": 100,
"podAffinityTerm": {
"labelSelector": {
"matchLabels": {"app": app_name}
},
"topologyKey": "kubernetes.io/hostname",
},
}],
},
},
"containers": [{
"name": app_name,
"image": image,
"resources": {
"requests": {
"cpu": "100m",
"memory": "128Mi",
},
},
}],
},
},
},
}
@staticmethod
def generate_descheduler_policy() -> Dict:
"""生成 Descheduler 策略:定期重平衡"""
return {
"apiVersion": "descheduler/v1alpha1",
"kind": "DeschedulerPolicy",
"strategies": {
"RemoveDuplicates": {
"enabled": True,
},
"LowNodeUtilization": {
"enabled": True,
"params": {
"nodeResourceUtilizationThresholds": {
"thresholds": {
"cpu": 40,
"memory": 40,
},
"targetThresholds": {
"cpu": 70,
"memory": 70,
},
},
},
},
"PodLifeTime": {
"enabled": True,
"params": {
"maxPodLifeTimeSeconds": 86400,
},
},
},
}
# ============ 模拟与验证 ============
class TopologySimulator:
"""拓扑调度模拟器:验证分布效果"""
def __init__(self):
self._scheduler = TopologyAwareScheduler()
def simulate(self, nodes: List[NodeInfo], pods: List[PodInfo],
constraint: TopologySpreadConstraint) -> Dict:
"""模拟调度并输出分布结果"""
for node in nodes:
self._scheduler.add_node(node)
results = {"scheduled": [], "failed": [], "distribution": {}}
for pod in pods:
node_name = self._scheduler.schedule(pod, constraint)
if node_name:
pod.current_node = node_name
self._scheduler.add_pod(pod)
results["scheduled"].append({
"pod": pod.name,
"node": node_name,
})
else:
results["failed"].append(pod.name)
# 最终分布
results["distribution"] = self._scheduler.get_topology_distribution(
pods[0].app_label if pods else "",
constraint.topology_key,
)
# 计算偏差
results["skew"] = self._scheduler.calculate_skew(
results["distribution"]
)
return results
四、K8s 拓扑调度的 Trade-offs
均匀分布与资源利用率的矛盾。严格的拓扑分布约束(maxSkew=1)确保均匀分布,但可能导致资源碎片化——某可用区资源充足但 Pod 数已达上限,新 Pod 被迫调度到资源紧张的可用区。建议对核心服务使用严格约束,对非核心服务使用宽松约束(ScheduleAnyway)。
Pod 反亲和性的爆炸效应。podAntiAffinity 要求同一服务的 Pod 不在同一节点上,当副本数超过节点数时,调度会失败。在大规模集群中,反亲和性的计算复杂度随 Pod 数量二次增长,调度延迟显著增加。
Descheduler 的迁移成本。Descheduler 通过驱逐 Pod 来重平衡分布,但每次驱逐都会触发 Pod 重建,增加服务中断风险。建议仅在偏差严重时触发(如 skew > 3),并配置 PDB(PodDisruptionBudget)限制并发驱逐数。
多约束冲突。同时设置拓扑分布约束、节点亲和性和 Pod 反亲和性时,约束之间可能冲突。例如节点亲和性要求调度到 zone-a,但拓扑分布约束要求均匀分布到所有可用区。K8s 调度器按优先级处理,但调试约束冲突是运维中的常见痛点。
五、总结
K8s 拓扑感知调度通过 topologySpreadConstraints 实现跨可用区的均匀分布,通过节点亲和性指定拓扑域偏好,通过 Descheduler 事后重平衡违反约束的分布。调度决策的核心逻辑是选择使拓扑偏差最小的域中的可用节点。关键权衡在于均匀分布与资源利用率、Pod 反亲和性的爆炸效应、Descheduler 的迁移成本,以及多约束冲突。拓扑调度的目标是让服务在拓扑层级上具备容灾能力,同时避免过度约束导致的调度失败。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)