执行器与资源隔离

本文系统讲解工作流的执行器与资源隔离,回答 Worker 池怎么划分、队列优先级如何设计、GPU 与大内存任务如何调度、多租户如何隔离与配额。覆盖执行器四种形态、资源画像与装箱、容器化执行与镜像优化、Pod 启动延迟、GPU 与显存调度、租户隔离层次、配额与抢占、弹性伸缩与冷启动、僵尸任务回收与容量规划,并给出可落地的配置片段与排查方法。

引言

工作流引擎的调度器决定「什么时候跑」,执行器决定「在哪里跑、用多少资源跑」。前者是逻辑问题,后者是物理问题——而物理问题往往更贵、更难改。

执行器的核心矛盾是异构性。同一个工作流里可能同时有「跑 200 毫秒的参数校验」「跑 40 分钟的 Spark 作业」「需要 8 张 GPU 的模型训练」「占 64 GB 内存的报表计算」。如果它们共用一个 Worker 池,短任务会被长任务堵住,小任务会占着大规格的机器空转,GPU 任务会因为调度器不认识 GPU 而被塞进普通节点。

第二个矛盾是多租户。一个平台通常服务多个业务团队,任何一个团队的异常流量(提交 10 万个任务、某个任务内存泄漏)都不应该影响其他人。这要求隔离不仅是「资源够用」,而是「故障不扩散」。

第三个矛盾是成本与弹性的取舍。弹性伸缩能降低闲置成本,但扩缩容本身有延迟(拉镜像、启动进程、加载模型),而工作流的任务往往很短,扩容的收益可能被冷启动吃掉。

本文按「执行器形态 → 池与队列 → 资源画像 → 容器化与 GPU → 隔离与配额 → 弹性与回收」的顺序展开。调度侧的时间语义参见 Airflow DAG 调度体系 ,容器与集群层面的机制参见 Kubernetes 与云原生专题 。

目录

  1. 执行器要解决什么问题
  2. 执行器的四种形态
  3. Worker 池的划分维度
  4. 队列与优先级设计
  5. 任务的资源画像
  6. 容器化执行
  7. Pod 启动延迟与镜像优化
  8. GPU 任务的调度
  9. 大内存与磁盘密集型任务
  10. 多租户隔离的四个层次
  11. 配额与限流
  12. 抢占与优先级反转
  13. 弹性伸缩
  14. 冷启动与预热
  15. 超时、僵尸任务与回收
  16. 安全隔离与沙箱
  17. 观测与容量规划
  18. 落地路线图
  19. 权衡取舍
  20. 常见坑清单
  21. 小结

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. 常见坑清单

  1. 所有任务共用一个池,长任务阻塞短任务,队列等待时间飙升至小时级。
  2. requests 设成「峰值需求」,节点看起来满了但实际利用率只有 20%。
  3. 内存 limits 设得过紧,偶发峰值触发 OOM Kill,重试后重复被杀。
  4. GPU 任务没有 nodeSelector,被调度到无 GPU 节点后一直 Pending。
  5. 池划分过细(20 个池),每个池利用率不足 30%,整体成本翻倍。
  6. 只有配额没有限流,租户短时间提交上万个小任务压垮 API Server。
  7. 抢占没有配合优雅退出,任务被 SIGKILL 后状态不一致且需从头重跑。
  8. 抢占优先选「占用资源最多」的任务,结果抢占了一个跑了 3 小时的训练任务。
  9. 容器以 root 运行且挂载了 docker.sock,等于把宿主机交给用户脚本。
  10. 临时目录用容器可写层,任务写满后节点磁盘告警且性能极差。
  11. 任务进程已死但引擎状态仍是运行中,配额被永久占用,任务永不重试。
  12. 按 CPU 使用率做弹性伸缩,I/O 密集型任务 CPU 很低导致永远不扩容。
  13. 回收只释放计算资源,没释放分布式锁,任务重试时一直等待。

21. 小结

执行器的设计有一条主线:让资源的「声明」与「实际」尽可能接近。资源画像准确,池划分就能粗(利用率高);画像不准,就只能靠细分的池和保守的配额来兜底(利用率低)。因此「采集资源使用数据并持续校准」是所有优化的起点。

隔离的强度要与信任模型匹配:内部团队之间到命名空间级就够,对外提供代码执行能力则必须上沙箱与严格的准入控制。隔离不足的风险是故障扩散,隔离过度的代价是利用率与灵活性,两者的平衡点应该显式评审而不是默认选择。

弹性伸缩不是万能药。它只对「长任务」或「持续负载」有效,对短任务与突发负载反而会引入抖动。混合架构(常驻 Worker 池跑轻任务 + 容器跑重任务)在多数场景下比「全容器化 + 全弹性」更经济。执行器的观测体系与整体可观测设计参见 工作流可观测与调试 ,基础设施层面的容量规划参见 基础设施与后端架构专题 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「工作流引擎」更多文章

  1. 工作流成本优化
  2. 调度、回填与补数
  3. 工作流数据传递与 Schema