引言
工作流引擎的调度器决定「什么时候跑」,执行器决定「在哪里跑、用多少资源跑」。前者是逻辑问题,后者是物理问题——而物理问题往往更贵、更难改。
执行器的核心矛盾是异构性。同一个工作流里可能同时有「跑 200 毫秒的参数校验」「跑 40 分钟的 Spark 作业」「需要 8 张 GPU 的模型训练」「占 64 GB 内存的报表计算」。如果它们共用一个 Worker 池,短任务会被长任务堵住,小任务会占着大规格的机器空转,GPU 任务会因为调度器不认识 GPU 而被塞进普通节点。
第二个矛盾是多租户。一个平台通常服务多个业务团队,任何一个团队的异常流量(提交 10 万个任务、某个任务内存泄漏)都不应该影响其他人。这要求隔离不仅是「资源够用」,而是「故障不扩散」。
第三个矛盾是成本与弹性的取舍。弹性伸缩能降低闲置成本,但扩缩容本身有延迟(拉镜像、启动进程、加载模型),而工作流的任务往往很短,扩容的收益可能被冷启动吃掉。
本文按「执行器形态 → 池与队列 → 资源画像 → 容器化与 GPU → 隔离与配额 → 弹性与回收」的顺序展开。调度侧的时间语义参见 Airflow DAG 调度体系 ,容器与集群层面的机制参见 Kubernetes 与云原生专题 。
目录
- 执行器要解决什么问题
- 执行器的四种形态
- Worker 池的划分维度
- 队列与优先级设计
- 任务的资源画像
- 容器化执行
- Pod 启动延迟与镜像优化
- GPU 任务的调度
- 大内存与磁盘密集型任务
- 多租户隔离的四个层次
- 配额与限流
- 抢占与优先级反转
- 弹性伸缩
- 冷启动与预热
- 超时、僵尸任务与回收
- 安全隔离与沙箱
- 观测与容量规划
- 落地路线图
- 权衡取舍
- 常见坑清单
- 小结
1. 执行器要解决什么问题
执行器的职责可以拆成四件事,每件都有独立的失败模式:
放置(placement):这个任务该在哪台机器上跑?
隔离(isolation):它会不会影响其他任务?
配额(quota):它最多能用多少资源?
回收(reclaim):它跑完或跑挂之后,资源怎么还回来?
放置做错的表现是「任务排队但机器空闲」——因为任务的资源需求(需要 GPU)与空闲节点的资源(只有 CPU)不匹配,而调度器不知道这个约束。隔离做错的表现是「一个任务内存泄漏把整个 Worker 拖垮,同节点上其他任务全部失败」。配额做错的表现是「一个团队提交 10 万个任务,把整个集群占满,其他团队全部排队」。回收做错的表现是「任务被杀了但进程还在,磁盘和内存持续被占用」。
这四件事的复杂度随规模非线性增长:单机执行器只需管好进程与内存,集群执行器要管网络、镜像、存储、GPU、配额、优先级。选择执行器形态时,本质上是在选择「把哪几件事交给现成的基础设施」。
2. 执行器的四种形态
按资源管理的方式,执行器分四类,能力与运维成本递增:
| 形态 | 代表 | 隔离级别 | 扩展方式 | 适用规模 |
|---|---|---|---|---|
| 进程内 | 直接在线程池里跑 | 无 | 垂直 | 开发、演示 |
| 进程级 | Celery Worker、Airflow LocalExecutor | 进程 | 加节点 | 中小规模 |
| 容器级 | KubernetesExecutor、Argo | 容器/Pod | 每任务一 Pod | 中大规模 |
| 平台级 | YARN、Nomad、K8s + 调度器扩展 | 容器 + 配额 | 集群 | 大规模多租户 |
进程级执行器的优点是启动快(毫秒级),缺点是隔离弱(共享 Python 环境、共享内存空间)。它适合「所有任务依赖一致、任务以轻量 Python 为主」的场景。一旦出现「A 任务需要 pandas 1.x、B 任务需要 pandas 2.x」,进程级方案就撑不住了。
容器级执行器解决了依赖与资源隔离,代价是每个任务一个容器的启动开销(通常 3~30 秒,取决于镜像大小与节点状态)。它的关键优化点是复用已拉取的镜像与预热节点,而不是缩短容器本身的启动时间。
平台级(自建 YARN 队列,或 K8s 上叠加 Volcano/Kueue 这类批调度器)解决的是「任务成组调度、队列配额、抢占」的问题,多数团队不需要走到这一步。
3. Worker 池的划分维度
池(pool / queue)是执行器最基本的隔离单位。划分维度有四种,实践中通常组合使用:
按业务域:order-pool / payment-pool / report-pool 好处:故障隔离、成本可归因;代价:利用率下降
按资源需求:cpu-pool / gpu-pool / highmem-pool 好处:避免资源错配;代价:需资源画像
按优先级:critical-pool / normal-pool / batch-pool 好处:关键任务不被挤占;代价:需优先级评审
按租户:team-a-pool / team-b-pool 好处:多租户隔离与配额;代价:池数量爆炸
池的数量必须受控。经验法则是「池的数量不超过 10 个」,超过之后资源碎片化严重,每个池的利用率都不高。控制手段是合并低流量池:如果一个池的日均任务数少于总任务数的 1%,就应该合并进「其他」池,用标签而不是池来做区分。
池的划分与成本归因是同一件事的两面:按业务域划分的池天然支持「这个月订单团队花了多少算力」这类报表。但如果成本归因靠标签就能做,那么按业务域分池的必要性就下降了——优先按资源需求分池,按业务域打标签,是利用率与可归因性的最佳平衡点。
4. 队列与优先级设计
当多个任务同时竞争资源时,队列决定谁先跑。三种排队策略:
FIFO:先到先服务 -> 长任务阻塞后面所有任务(队头阻塞)
优先级队列:按优先级排序 -> 低优先级任务可能永久饿死
加权公平队列(WFQ):按资源权重分配 -> 既保证高优先级优先,又不饿死低优先级
生产系统应该用加权公平队列,而不是简单的优先级队列。WFQ 的核心是「每个队列有一个权重,资源按权重比例分配」,比如 critical:normal:batch = 6:3:1,那么当三者都有任务时,关键任务拿到 60% 的资源,但批处理任务仍能拿到 10% 而不至于饿死。
# Kueue 的队列配额示例(K8s 上的批调度)
apiVersion: kueue.x-k8s.io/v1beta1
kind: ClusterQueue
metadata: { name: workflow-cq }
spec:
resourceGroups:
- coveredResources: ["cpu", "memory", "nvidia.com/gpu"]
flavors:
- name: spot
resources:
- { name: cpu, nominalQuota: 400 }
- { name: memory, nominalQuota: 1600Gi }
- { name: nvidia.com/gpu, nominalQuota: 16 }
「抢占式实例 + 关键任务」的组合是常见的成本优化手段:批处理任务跑在抢占式(Spot)节点上,被回收时自动重试;关键任务跑在按需节点上,保证不被中断。这要求任务能声明「我能不能被中断」,而不是全局统一策略。
5. 任务的资源画像
没有资源画像就无法做放置与装箱。每个任务至少要声明四个数值:
resources:
requests: { cpu: "500m", memory: "1Gi" } # 调度依据:保证能拿到
limits: { cpu: "2", memory: "4Gi" } # 硬上限:超过被杀
gpu: 0 # 需要几张 GPU
ephemeralStorage: 10Gi
三个关键概念必须区分清楚:
requests 与 limits 的差异。requests 是调度依据(决定任务放在哪台机器),limits 是运行时上限(超过就被限流或杀掉)。requests 设得太高会浪费(节点看起来满了实际空闲),设得太低会导致争抢(多个任务的 limits 之和超过节点容量)。经验做法是 requests 设为「P50 实际使用量」,limits 设为「P99 实际使用量 × 1.5」。
CPU 与内存的不可压缩差异。CPU 是可压缩资源(超过 limits 只会被限流,不会被杀),内存是不可压缩资源(超过 limits 会 OOM Kill)。这个差异决定了「内存 limits 不能设得太紧」,否则偶发的峰值会导致任务被杀,而重试又会重复这个峰值。
资源画像从哪里来。三条路径:任务作者手工声明(最准但容易忘)、历史运行数据自动推荐(用 P95 实际使用量作为建议值)、以及「先给保守值,运行后按实际调整」。第二条最实用。
-- 从历史运行记录推荐资源:取 P95 使用量
SELECT task_name,
PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY peak_cpu_milli) AS cpu_p95,
PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY peak_mem_bytes) AS mem_p95,
MAX(peak_mem_bytes) AS mem_max
FROM task_resource_sample WHERE started_at > NOW() - INTERVAL '30 days'
GROUP BY task_name;
6. 容器化执行
容器化是隔离的默认答案,但它的实现细节决定了效率。一个容器化任务的生命周期:
调度 -> 拉镜像 -> 创建容器 -> 启动进程 -> 执行 -> 上报结果 -> 销毁容器
↑ ↑ ↑
秒级 秒~分钟级(取决于镜像大小) 毫秒~秒级(取决于启动脚本)
三个阶段的优化手段不同:调度阶段要减少排队(资源画像准确 + 池划分合理),拉镜像阶段要减少体积(多阶段构建、基础镜像精简、镜像分层复用),启动阶段要减少初始化(不在启动时下载依赖、不每次重跑都初始化连接池)。
# 多阶段构建:最终镜像只含运行时,不含构建工具
FROM python:3.12-slim AS builder
COPY requirements.txt .
RUN pip install --prefix=/install -r requirements.txt
FROM python:3.12-slim
COPY --from=builder /install /usr/local
COPY app/ /app/ # 不要在启动时 pip install
ENTRYPOINT ["python", "-m", "app.worker"]
镜像优化的经验数字:基础镜像从 python:3.12 换成 python:3.12-slim 能省约 700 MB;从 slim 换成 Alpine 能再省约 50 MB,但可能引入 glibc 兼容问题(Alpine 用 musl),对 Python 科学计算栈不友好。「slim + 预装依赖」是多数工作流任务的性价比最优解。
7. Pod 启动延迟与镜像优化
容器化执行器最被低估的成本是启动延迟对短任务的影响。一个跑 5 秒的任务,如果 Pod 启动要 30 秒,总耗时变成 35 秒,其中 86% 是开销。
三个缓解手段:
一是镜像拉取策略。默认 Always 会在每次启动时检查远端镜像,IfNotPresent 直接复用本地缓存。对固定 tag 的镜像应该用 IfNotPresent(注意:用 latest 这类可变 tag 会导致缓存失效)。
spec:
containers:
- name: task
image: registry.internal/workflow-task:v42
imagePullPolicy: IfNotPresent # 固定 tag 用这个
二是节点预热。保持一定数量的空闲节点(或已拉取常用镜像的节点),让新任务能立即被调度。代价是闲置成本,但可以通过「预热节点使用抢占式实例」降低。
三是合并小任务。如果一批任务每个只跑几秒,合并成一个任务(批量处理 N 条记录)比启动 N 个容器更划算。判断标准是「启动开销 / 实际执行时间 > 0.5」。
8. GPU 任务的调度
GPU 调度的复杂来自四个方面:资源不可分割(默认一张 GPU 只能给一个任务)、显存是独立资源(一张 80 GB 的 A100 给一个只用 2 GB 的任务是巨大浪费)、异构(不同型号的 GPU 算力与显存不同)、拓扑(多卡任务需要卡在同一节点且最好在同一 NVLink 域内)。
resources:
limits: { nvidia.com/gpu: 1 } # K8s 原生:整卡分配
nodeSelector:
nvidia.com/gpu.product: NVIDIA-A100-SXM4-80GB # 指定型号
tolerations:
- { key: nvidia.com/gpu, operator: Exists, effect: NoSchedule }
四种提高 GPU 利用率的手段,按收益排序:
一是 MIG(Multi-Instance GPU):A100/H100 支持把一张卡切成最多 7 个独立实例,每个有独立的显存与算力。适合「小模型推理、参数搜索」这类不需要整卡的任务。
二是时间片共享:多任务轮流使用同一张卡,适合「利用率低、延迟不敏感」的推理任务。代价是任务之间会互相影响延迟。
三是显存感知调度:让任务声明需要的显存(比如 nvidia.com/gpu.memory: 8Gi),调度器据此装箱,而不是按整卡分配。
四是队列与配额:GPU 是稀缺资源,必须有队列与配额,否则一个团队的大规模训练会占满整个集群。GPU 配额通常按「卡时」计量(比如每团队每月 1000 卡时),而不是按卡数。
9. 大内存与磁盘密集型任务
内存与磁盘密集任务的隔离难度高于 CPU 任务,因为它们的失败模式更剧烈。
内存密集型的三个要点。一是内存 limits 要留足余量,因为内存超限是 OOM Kill(任务直接死),不像 CPU 超限只是变慢。经验值是 limits 设为 P99 使用量的 1.5 倍。二是避免「大内存任务与小内存任务同节点」,因为大内存任务的内存峰值会触发节点级内存压力,导致内核杀掉节点上其他任务。三是设置合理的 oomScoreAdj,让内核优先杀「本就应该被杀」的任务而不是随机杀。
# 用独立的池隔离大内存任务,避免影响小任务
nodeSelector: { node-pool: highmem } # 64 GB+ 内存的节点池
resources:
requests: { memory: "48Gi" }
limits: { memory: "64Gi" }
磁盘密集型任务(大量临时文件、排序、解压)要注意两点。一是用 ephemeral storage 而不是节点本地盘:ephemeral storage 有配额,超出会被驱逐,避免了「一个任务写满磁盘导致整节点故障」。二是临时目录要显式挂载:容器内的 /tmp 默认在可写层,性能差且计入镜像层大小,应该挂一个 emptyDir(可配 sizeLimit)。
volumeMounts: [{ name: scratch, mountPath: /tmp }]
volumes:
- name: scratch
emptyDir: { sizeLimit: 20Gi } # 防止任务写满节点磁盘
10. 多租户隔离的四个层次
隔离不是一个开关,而是四个层次,成本与强度递增:
层次一:逻辑隔离(标签 + 配额) 共享集群,按标签区分租户 -> 防资源抢占,不防故障扩散
层次二:命名空间隔离(namespace + 配额)资源配额 + 网络策略 + RBAC -> 防抢占与误操作
层次三:节点池隔离(dedicated pool) 不同租户跑在不同节点池 -> 防节点级故障扩散,成本高
层次四:集群隔离(独立集群) 完全隔离,独立升级与故障域 -> 强度最高,成本最高
选择标准是**「租户能否接受被其他租户影响」**。内部团队之间的隔离通常到层次二就够;对外提供服务的平台(SaaS 化的数据平台)需要层次三;强监管场景(金融、医疗的独立合规要求)需要层次四。
层次二的实现要点是**「配额 + 限制范围 + 网络策略」三件套**:
apiVersion: v1
kind: ResourceQuota
metadata: { name: team-a-quota, namespace: team-a }
spec:
hard:
requests.cpu: "200"
requests.memory: 400Gi
limits.cpu: "400"
limits.memory: 800Gi
persistentvolumeclaims: "20"
count/jobs.batch: "500" # 限制同时在跑的 Job 数
count/jobs.batch 这类「对象数量配额」常被忽略,但它是防「任务洪水」的关键:一个租户可以提交任意多个 Job 对象,即使每个都很小,也会把 API Server 与调度器压垮。
11. 配额与限流
配额是「总量限制」,限流是「速率限制」,两者必须同时有。
配额(quota):同时占用不超过 N 个 CPU / M GB 内存
限流(rate limit):每分钟最多提交 K 个任务
只有配额没有限流,租户可以在短时间内提交大量任务(虽然总量受配额限制,但对象数量会压垮控制面)。只有限流没有配额,租户可以提交少量超大的任务把资源占满。
配额的三个设计决策:
一是配额的维度。至少要有 CPU、内存、GPU 三个维度,并加上「对象数量」维度。只限制 CPU 会导致「租户提交一堆不需要 CPU 但占内存的任务」。
二是配额的通知机制。租户在接近配额时应该收到预警(用到 80% 时通知),而不是在提交失败时才发现。
-- 配额使用率报表:按租户统计当前占用与配额的比值
SELECT tenant, SUM(requests_cpu) AS used_cpu, q.cpu_quota,
ROUND(SUM(requests_cpu)::numeric / q.cpu_quota * 100, 1) AS pct
FROM task_allocation a JOIN tenant_quota q USING (tenant)
GROUP BY tenant, q.cpu_quota
HAVING SUM(requests_cpu)::numeric / q.cpu_quota > 0.8;
12. 抢占与优先级反转
抢占(preemption)是「高优先级任务到来时,驱逐低优先级任务腾出资源」。它解决的核心问题是优先级反转:一个低优先级的长任务占着资源,导致高优先级的紧急任务无法执行。
抢占的三个前提,缺一不可:
1. 被抢占的任务可以安全中断(可重试、幂等,或支持检查点)
2. 被抢占的任务能感知中断并优雅退出(收到信号后保存状态)
3. 抢占的收益大于成本(重启开销不能超过它腾出的价值)
第三个前提常被忽略:抢占一个已经跑了 3 小时的训练任务,让它从头开始,成本可能是几小时的 GPU 时间——这比让高优先级任务等 10 分钟更贵。因此抢占策略应该是「优先抢占刚启动的任务」(损失小),而不是「抢占占用资源最多的任务」。
抢占的实现需要「优雅退出」的配合:任务收到 SIGTERM 后应该保存检查点、上报状态、然后退出,而不是直接被 SIGKILL。这要求任务代码实现信号处理,且检查点要能真正恢复:
def on_terminate(signum, frame):
if checkpoint:
save_checkpoint(checkpoint) # 保存进度,供重启后恢复
report_status("PREEMPTED")
sys.exit(0)
signal.signal(signal.SIGTERM, on_terminate)
如果任务无法实现检查点,抢占就应该被禁用,改用「队列优先级 + 预留容量」解决——为关键任务预留一部分容量(比如 20%),保证它们永远不用排队。
13. 弹性伸缩
弹性伸缩的核心矛盾是「伸缩有延迟,而任务可能很短」。扩容的延迟来自三处:指标采集与判定(13 分钟)、节点创建(云厂商 15 分钟)、镜像拉取与启动(秒分钟)。总计通常是 310 分钟。
这意味着弹性伸缩只对「长任务」或「持续负载」有意义。对一个每天只有 10 分钟高峰、任务都是 5 秒的场景,弹性伸缩来不及反应,反而会因为频繁扩缩容导致抖动。
三种伸缩策略的适用场景:
| 策略 | 依据 | 响应速度 | 适用 |
|---|---|---|---|
| 反应式(HPA) | 当前队列长度/资源使用 | 慢(分钟级) | 持续负载 |
| 预测式 | 历史负载规律 | 提前 | 有明显周期性的负载 |
| 定时式 | 固定时间表 | 精确 | 已知的定时高峰 |
最实用的是「定时 + 反应式」组合:按已知的业务周期(比如每天早上 6 点数据管道开始)提前扩容,再用反应式应对突发。纯反应式在周期性负载上总是慢半拍。
# KEDA:按队列长度伸缩,比按 CPU 使用率更贴近工作流场景
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
spec:
scaleTargetRef: { name: workflow-worker }
minReplicaCount: 2
maxReplicaCount: 100
triggers:
- type: kafka
metadata: { topic: workflow-tasks, lagThreshold: "50" } # 单副本积压 50 条即扩容
按队列长度伸缩比按 CPU 伸缩更合适,因为工作流的瓶颈通常是「任务排队」而不是「CPU 打满」。队列长度是直接的积压信号,而 CPU 使用率在任务等待 I/O 时很低,会误导伸缩决策。
14. 冷启动与预热
冷启动的三个来源与对策:
镜像冷启动:节点上没有镜像,需要拉取(几秒~几分钟)
对策:预拉镜像(DaemonSet 预热)、镜像加速(P2P 分发)、精简镜像
进程冷启动:容器启动后要初始化运行时(导入大库、加载模型)
对策:预热进程池、延迟加载、把初始化移出关键路径
数据冷启动:任务首次运行要读远端数据(缓存未命中)
对策:本地缓存、数据预热任务
「进程冷启动」是 Python/Java 工作流最常见的隐性成本。一个导入 pandas + numpy + 内部 SDK 的 Python 进程启动要 2~5 秒,如果任务是「处理一条消息」,这个开销占了大头。对策是保持常驻 Worker 进程(进程级执行器),而不是每个任务一个新进程——这也是为什么很多团队在容器化之后仍然保留「常驻 Worker 池 + 容器只跑重任务」的混合架构。
混合架构的分工建议:轻任务(< 10 秒)走常驻 Worker 池,重任务(> 1 分钟或需要特殊资源)走容器。判断标准是「容器启动开销 / 任务耗时」,超过 0.2 就该考虑常驻池。
15. 超时、僵尸任务与回收
执行器必须假设任务会「不正常结束」,因此需要三层兜底:
任务级超时:任务声明的执行时限,超时被引擎标记失败
进程级超时:容器/进程的硬超时(K8s 的 activeDeadlineSeconds)
兜底清理:定期扫描「状态为运行但实际进程已死」的任务
僵尸任务是执行器最难处理的场景:进程已经死了(被 OOM Kill、被节点驱逐),但引擎状态还是「运行中」,于是资源配额一直被占用,任务永远不会被重试。这需要「心跳 + 租约」机制:任务执行期间定期上报心跳,超过租约时间没有心跳就判定为死亡并回收。
-- 兜底扫描:状态是运行中但心跳超时的任务,标记为失败并释放配额
UPDATE task_run
SET status = 'FAILED', error = 'heartbeat timeout', finished_at = NOW()
WHERE status = 'RUNNING'
AND last_heartbeat_at < NOW() - INTERVAL '10 minutes';
回收必须覆盖三类资源:计算资源(释放配额)、存储资源(清理临时目录与中间结果)、外部资源(关闭连接、释放分布式锁)。第三类最容易被漏掉,表现是「任务重试时因为锁没释放而一直等待」。
16. 安全隔离与沙箱
当工作流允许用户提交任意代码(比如数据平台的用户自定义脚本)时,隔离从「资源隔离」升级为「安全隔离」。四个层次:
层次一:进程隔离 + 资源限制(ulimit / cgroup)
层次二:容器隔离(namespace + seccomp + 只读根文件系统)
层次三:沙箱(gVisor / Kata Containers,内核级隔离)
层次四:独立虚拟机(Firecracker / microVM)
多数内部平台到层次二即可,但要注意几个常见疏漏:容器默认以 root 运行(应该用非 root 用户 + runAsNonRoot)、挂载了 docker.sock(等于给了宿主机 root)、privileged: true(几乎等于没有隔离)。这三项应该在准入控制(admission controller)层面强制禁止,而不是靠开发者自觉。
securityContext:
runAsNonRoot: true
runAsUser: 10001
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities: { drop: ["ALL"] }
代码级的风险(用户脚本读取环境变量里的密钥、访问内网服务)需要额外防护:不要把密钥注入到用户任务的容器里,而是通过临时代理让平台代为访问外部系统。这与 重试幂等与补偿设计 里讲的「凭据不落地」是同一原则。
17. 观测与容量规划
执行器的观测重点与调度器不同:调度器看「是否按时触发」,执行器看「资源是否被有效利用」。
| 指标 | 含义 | 关注点 |
|---|---|---|
| 队列等待时长 P99 | 任务从入队到开始执行 | 超过 SLA 阈值 |
| 资源利用率 | 实际使用 / 已分配 | 低于 30% 说明超配 |
| 装箱率 | 实际使用 / 节点容量 | 低于 50% 说明碎片化 |
| 任务启动开销 | 容器启动到进程就绪 | 超过任务耗时的 20% |
| 抢占次数 | 单位时间被抢占的任务数 | 突增说明容量不足 |
| 僵尸任务数 | 状态运行但心跳超时 | 大于 0 即告警 |
| 配额拒绝次数 | 因配额不足被拒绝的提交 | 需要扩容或调配额 |
「装箱率」是最能反映集群健康度的指标。它低于 50% 通常说明两个问题之一:资源 requests 设得过高(虚占),或者池划分太细(碎片化)。前者用「按历史 P95 推荐 requests」解决,后者用「合并低流量池」解决。
容量规划的公式是「需要的节点数 = 总 requests 之和 / 单节点可分配容量 / 目标装箱率」,其中目标装箱率取 0.7 左右比较稳妥:低于 0.6 浪费严重,高于 0.85 会导致大任务找不到足够大的空闲节点。
18. 落地路线图
- 第 1 周:为所有任务补齐资源声明(requests / limits),没有声明的先给保守默认值。
- 第 2 周:接入资源使用采集,用历史 P95 生成推荐值,逐步替换手工声明。
- 第 3 周:按资源需求划分池(CPU 通用 / GPU / 大内存),把错配的任务迁到正确的池。
- 第 4 周:实现队列优先级(至少区分关键与批处理),配置加权公平队列。
- 第 5 周:接入配额与限流,做一次「模拟租户洪水」演练,验证隔离有效。
- 第 6 周:实现心跳与僵尸任务回收,评估弹性伸缩的收益后按需开启。
顺序上「资源声明」与「资源采集」必须最先做,因为没有数据就无法判断该分几个池、该给多少配额。所有后续优化都建立在这两个基础之上。
19. 权衡取舍
| 选择 | 收益 | 代价 |
|---|---|---|
| 进程级执行器 | 启动快、开销小 | 隔离弱、依赖冲突 |
| 容器级执行器 | 隔离好、依赖独立 | 启动开销秒级 |
| 平台级批调度 | 队列配额与抢占 | 运维复杂度高 |
| 按业务域分池 | 故障隔离、成本可归因 | 利用率下降 |
| 按资源需求分池 | 避免资源错配 | 需要资源画像 |
| 池数量少 | 利用率高 | 隔离弱 |
| 池数量多 | 隔离好 | 碎片化、利用率低 |
| 固定容量 | 稳定、无冷启动 | 峰值需要预留余量 |
| 弹性伸缩 | 成本低 | 冷启动延迟、抖动 |
| 超卖(limits > 容量) | 利用率高 | 需要抢占与优先级 |
| 严格隔离(独立集群) | 故障不扩散 | 运维成本最高 |
20. 常见坑清单
- 所有任务共用一个池,长任务阻塞短任务,队列等待时间飙升至小时级。
- requests 设成「峰值需求」,节点看起来满了但实际利用率只有 20%。
- 内存 limits 设得过紧,偶发峰值触发 OOM Kill,重试后重复被杀。
- GPU 任务没有
nodeSelector,被调度到无 GPU 节点后一直 Pending。 - 池划分过细(20 个池),每个池利用率不足 30%,整体成本翻倍。
- 只有配额没有限流,租户短时间提交上万个小任务压垮 API Server。
- 抢占没有配合优雅退出,任务被 SIGKILL 后状态不一致且需从头重跑。
- 抢占优先选「占用资源最多」的任务,结果抢占了一个跑了 3 小时的训练任务。
- 容器以 root 运行且挂载了 docker.sock,等于把宿主机交给用户脚本。
- 临时目录用容器可写层,任务写满后节点磁盘告警且性能极差。
- 任务进程已死但引擎状态仍是运行中,配额被永久占用,任务永不重试。
- 按 CPU 使用率做弹性伸缩,I/O 密集型任务 CPU 很低导致永远不扩容。
- 回收只释放计算资源,没释放分布式锁,任务重试时一直等待。
21. 小结
执行器的设计有一条主线:让资源的「声明」与「实际」尽可能接近。资源画像准确,池划分就能粗(利用率高);画像不准,就只能靠细分的池和保守的配额来兜底(利用率低)。因此「采集资源使用数据并持续校准」是所有优化的起点。
隔离的强度要与信任模型匹配:内部团队之间到命名空间级就够,对外提供代码执行能力则必须上沙箱与严格的准入控制。隔离不足的风险是故障扩散,隔离过度的代价是利用率与灵活性,两者的平衡点应该显式评审而不是默认选择。
弹性伸缩不是万能药。它只对「长任务」或「持续负载」有效,对短任务与突发负载反而会引入抖动。混合架构(常驻 Worker 池跑轻任务 + 容器跑重任务)在多数场景下比「全容器化 + 全弹性」更经济。执行器的观测体系与整体可观测设计参见 工作流可观测与调试 ,基础设施层面的容量规划参见 基础设施与后端架构专题 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。