1. 混布任务的失控现场:做资源隔离之前的真实困境
先说一个我接手过的典型场景。某公司的数据分析平台,一开始就一台 Ubuntu 服务器,装了个 Airflow 做调度,Python 任务直接跑在调度机上。刚开始数据量小,任务也不多,大家相安无事。半年后情况就不对了:有人跑关系计算,有人跑同步脚本,还有人直接把训练脚本挂到 DAG 里,所有任务挤在同一个 Python 进程组里,谁都能把谁带崩。
那个阶段我最常见的告警就是两类:一类是宿主机内存报警,free -h一看,几乎吃满,swap 被疯狂换页,整台机器卡到 SSH 都敲不动;另一类是某个任务莫名其妙失败,点开日志却发现运行到一半就断了,连异常堆栈都没有。你根本说不清是代码的问题还是环境的问题,因为同一个脚本,今天能跑,明天跑一半就死。说白了,就是任务与任务之间没有任何边界。
当时我们试过的思路有:给 Airflow 换 CeleryExecutor,让任务分散到多个 worker 上。但这只是把问题从一台机器扩散到多台机器,worker 上仍然是混跑的,依赖冲突、资源争抢一样存在。后来又试过把 Python 环境隔离开,用 venv、用 conda,但环境隔离只能解决依赖打架,解决不了 CPU 和内存互相抢占的问题。
真正让我破局的,是转变视角:把任务本身当作一个可隔离的"盒子里执行"的单元。这就是 Airflow + Docker 组合的核心价值所在——用容器做任务级隔离,用 Airflow 做调度编排,在 Ubuntu 宿主机上通过内核机制彻底切断任务之间的干扰。
我总结下来,资源隔离要解决的问题有四个维度,缺一个都不算完成。
1.1 五种常见的"任务互相伤害"事故
我把日常工作中能遇到的任务干扰归了五类,你可以对照自己的环境看看中了几条。
第一类是CPU 争抢。一个跑数据校验的任务是纯 Python 循环,单线程只能吃满 100% CPU,但架不住一堆类似任务叠加,加之有的大任务用了多进程池,直接把宿主机 CPU 打满,其他任务的响应时间翻了好几倍。
第二类是内存抢占。Python 的 pandas 读大文件,内存说涨就涨。一个任务吃 8GB 内存,另一个任务申请内存就失败,连 Airflow 调度进程本身都可能被 OOM Killer 盯上。
第三类是依赖污染。A 任务要numpy==1.21,B 任务升级成了numpy==1.24,两个任务在同一个 Python 环境里跑,必有一个在导入时报错。这是混布架构里最让人头疼的,因为它的报错时间特别滞后,经常是某一次升级后,无关任务开始诡异失败。
第四类是文件系统冲突。多个任务共用一个临时目录时,生成的文件名如果恰好相同,轻则其中一个任务读错数据,重则有人删临时目录把别人的中间结果也删了。
第五类是网络端口和系统资源冲突。有的脚本会自己起端口监听,或者往系统目录写日志,多个任务互相影响。
这些事故在单机上混跑时几乎是概率问题——跑得少还好,跑得多必出事。而容器化隔离,恰好把这五类问题全都拦在了边界内外。
1.2 资源隔离的四个维度:CPU、内存、依赖与权限
我先帮大家把隔离的维度捋清楚,后面讲方案才不容易乱。
第一个维度是计算资源隔离,核心是 CPU 和内存。CPU 隔离可以通过 cgroups 限定占用上限,内存隔离则是限定容器可用的物理内存上限,超过就被内核回收甚至杀掉进程。
第二个维度是依赖环境隔离,核心是让每个任务拥有自己独立的目录和运行环境。Docker 镜像天然就是干这个的——把 Python 版本、依赖包、系统库全部固化在镜像里,不同任务用不同镜像,互不干扰。
第三个维度是文件系统隔离,核心是让每个任务只看到自己的临时目录和工作空间。容器有自己的文件系统层,挂载只读的代码卷,写入容器内临时目录,出了容器就清理,自然不存在跨任务文件冲突的问题。
第四个维度是权限隔离,这个很容易被忽略。任务容器只拥有它需要的 Linux capabilities,不拥有宿主机的 root full privileges。这样即使某个任务被入侵或误操作,爆炸半径也局限在容器内部。
你要做的,是把这四个维度落实到具体的调度和容器配置上。Airflow 负责编排,Docker 负责隔离边界,Ubuntu 内核负责强制配额。三者各司其职,这就是"任务资源隔离技术"的全部逻辑。
2. 为什么选 Airflow + Docker 这个组合:只该锁的锁
很多人一上来就问,Airflow 和 Docker 该怎么样组合?是要用 DockerExecutor,还是只在 DAG 里搞 DockerOperator?甚至有人觉得换个 Kubernetes 环境才叫"正统隔离"。
我先给结论:在中小规模任务平台、单机或少量节点的情况下,Airflow 调度 + DockerOperator 跑容器任务 + Ubuntu 侧 cgroups 硬配额,是最实用的组合。这个方案不需要引入复杂的编排集群,维护成本低,隔离效果却一点不少。
2.1 Executor选择背后的权衡:DockerOperator不是唯一答案
Airflow 的 Executor 选型,决定了任务到底在哪里执行。
默认的 SequentialExecutor 不提了,单任务串行,根本不适合生产用。LocalExecutor 是在调度机本地起多进程跑任务,虽然能用多 worker,但两个任务仍然在同一台机器上直接跑,进程级的隔离依然是裸的,依赖环境、文件、资源全面共享。CeleryExecutor 可以把任务分发给局域网里的多台 worker 节点,但每个 worker 节点内部仍然是混跑的,只是把隔离粒度从"一机"放大到"多机",并没有质的改变。
DockerExecutor 是 Airflow 原生支持的一种执行器,它会把每个 task instance 放到独立的容器里运行,镜像由 DAG 指定。听起来很理想,但实际用起来我觉得限制比较多:DockerExecutor 的容器镜像需要在每个调度节点上存在,容器编排参数非常简化,对资源限制的精细控制不太友好,任务出问题排查也相对麻烦。
我实际生产中更推荐的是LocalExecutor + DockerOperator这套。Airflow 本身不负责启动容器,而是在每个调度节点用 DockerOperator 去调用 Docker API,把一个 task 启动成一个独立容器。这样调度层和执行层是解耦的:调度层管流程、重试、依赖,执行层完全由 Docker 接管——镜像、GPU支持、资源限额都在容器启动参数里定义。
这套组合的好处是:
- 不需要额外的编排集群,一个 Ubuntu 节点就能跑完整套
- 每个任务完全独立,失败和重试都只是"再拉起一个新容器"
- 资源配额写在 DAG 里,调度员看得见、改得动,不用去碰宿主机的配置
2.2 Ubuntu宿主机上隔离能力的落脚点
Ubuntu 在服务器领域用得广,且对容器方案的支持稳定成熟,主要体现在 Docker 引擎依赖的内核特性上:cgroups(控制组)、namespaces(名称空间)、overlayfs(存储驱动)、iptables(网络隔离),这些都是开箱即用。
其中最重要的就是cgroups。Docker 的--cpus、--memory参数,本质上都是写 cgroups 的控制文件,由内核强制执行。也就是说,哪怕容器里面的进程是个死循环,把 CPU 冲到 200%,内核也只允许它占用你配置的配额,其余 CPU 时间会均匀分配给同机的其他容器和进程。
这个强制力是关键。它跟"应用层自己限速"完全不是一个量级——应用层是可以被绕过或被 bug 改写的,而 cgroups 是内核层面的保险,进程逃不掉。
不过要注意一点:不同 Ubuntu 版本用的 cgroups 版本不同。Ubuntu 20.04 及以前用的是 cgroups v1,Ubuntu 22.04 开始默认用 cgroups v2。两者在配置路径和查询方式上差异不小,后面排查那块我会细讲。我的经验是:生产环境用 Ubuntu 22.04 LTS,cgroups v2 语义更清晰,Docker 的适配也更顺畅。
3. Ubuntu 侧的容器资源限制工程:先把牢笼焊结实
在 Airflow 里写资源限制之前,先把 Docker 本身的资源限制能力吃透。因为 Airflow 的 DockerOperator 最终也是把这些参数透传给 Docker API,如果你不理解底层,出了问题都不知道该调哪个参数。
3.1 先弄明白 cgroups 配额的计算逻辑
CPU 限制的底层逻辑是 CPU 时间片的分配。一个 CPU 核心每秒有 100000 微秒的 CPU 配额时间,cgroups 里通过cpu.cfs_quota_us和cpu.cfs_period_us两个参数控制。Docker 的--cpus=1.5参数,翻译过来就是quota = 150000,period = 100000,即每 100ms 周期内最多用 150ms 的 CPU 时间(相当于1.5个核)。
很多人会问,为什么不用--cpu-quota手写?因为--cpus已经把计算封装好了,直接写小数就行。比如一个计算密集任务需要 4 核左右,我会给 4.5 或 5,稍微留点余量,避免任务在峰值时被打压。在 DAG 里不要给太满,否则多个任务同时冲高峰,宿主机还是会顶不住。
内存限制的逻辑就直观很多。--memory限制的是容器物理内存上限,--memory-swap限制的是"内存+swap"的总量。平时大家容易忽略 swap 的配置。如果只设置了--memory,Docker 默认会把 swap 上限设成和 memory 一样大,意味着容器物理内存用满后就会触发 OOM,而不是去 swap 区借。
这在数据任务里是个大问题——有的脚本写入量大,依赖 swap 做缓冲,你直接让它 OOM,任务还没跑到一半就没了。所以我会写成--memory=4g --memory-swap=6g,物理内存 4GB,允许拿出 2GB swap 缓冲,超出 6GB 才杀。
3.2 有限配额组合:CPU、内存、IO、进程数
用docker run手动起一个容器时,我常用的完整限制参数是这样的:
docker run -d \ --name my_task \ --cpus=2.5 \ --memory=4g \ --memory-swap=6g \ --pids-limit=512 \ --blkio-weight=500 \ --cap-drop ALL \ --security-opt no-new-privileges \ my_task_image:latest逐个参数解释一下:
--cpus=2.5:任务最多占用 2.5 个 CPU 核心,多给 0.5 是余量。--memory=4g:物理内存上限 4GB。--memory-swap=6g:物理内存 + swap 一共 6GB,超出才触发 OOM。--pids-limit=512:限制容器内进程数量,防止某个任务 fork 炸弹把节点拖垮。--blkio-weight=500:IO 权重。500 是一般值,比 default 的 500 相同。如果有高 IO 需求的任务,可以调成 800,磁盘读写时能抢到更大的时间片。--cap-drop ALL:去掉容器内所有默认 capabilities,最小权限运行。--security-opt no-new-privileges:禁止进程提升权限。
这些参数组合在一起,才是完整的资源隔离。很多人只限 CPU 和内存,忽略 PID 和 IO,短期看没事,长期跑大数据吞吐的任务会发现节点莫名其妙卡死,一排查是某个任务疯狂创建线程。
注意,blkio-weight只有在多个容器争抢磁盘 IO 时才生效。如果宿主机没有其他 IO 负载,这个参数对吞吐的影响不大,可以不用过度纠结。
3.3 从宿主机看容器:定位资源抢占者的工具链
配置做完了,怎么确认真的生效?我习惯性的验证路子分三步。
先看容器实际占用:docker stats。它会实时显示每个容器的 CPU%、内存、网络 IO 和磁盘 IO,一眼就能看出哪个容器异常。
再看内核的实际 quota 配置:
cat /sys/fs/cgroup/system.slice/docker-<容器ID>.scope/cpu.max # 输出示例:250000 100000 cat /sys/fs/cgroup/system.slice/docker-<容器ID>.scope/memory.max # 输出示例:4294967296在 cgroups v2 里,cpu.max的值如果是250000 100000,说明每 100ms 周期最多 250ms CPU 时间,对应 2.5 核。memory.max的单位是字节,4294967296 就是 4GB。检查时拿配置对比一下就清楚,根本不用猜。
最后看宿主机整体的压力:
top -p <容器主进程PID> iostat -x 1top可以直接观察进入容器的那个主进程的实时 CPU 使用率。iostat则能告诉你磁盘是否有排队延迟。不过进入容器内部的进程是经过容器 PID namespace 映射的,宿主机的 top 显示的 PID 和容器内 PID 不一样,你需要先docker top 容器名拿到宿主视角的 PID 再看。
这套工具链不需要额外安装任何东西,都是系统自带的,排查时足够用。
4. Airflow 编排层的资源池设计与优先级策略
隔离的底层能力准备完毕后,再回到 Airflow 这一层。这一层真正要解决的是:如何让任务按大小规格分门别类地跑,且优先级明确。
4.1 用 Pool 把不同规格任务分流
Airflow 的 Pool 机制很多人当并发限制来用的——设置一个slots数量,任务跑满 slots 就排队等待。这个是基础功能,但结合资源隔离后,Pool 的意义就变了:它变成了"某种规格资源"的入口。
我在配置里会按任务规格分池。比如:
| Pool 名称 | slots | 适用任务 |
|---|---|---|
small_pool | 8 | 轻量同步、API 调用、小文件校验 |
medium_pool | 4 | 中型 ETL、报表计算、特征工程 |
large_pool | 2 | 重型训练、全量计算、大数据量处理 |
io_pool | 3 | 大数据 IO 读写任务 |
这样设计的意思是,每个池里的任务,DAG 里写 DockerOperator 时会给固定的资源配额。small_pool任务给 1 核 2G,large_pool任务给 6 核 16G。不同池之间互不干扰,同池内 slots 限制了并发数,确保同规格任务不会超过预期叠加。
比如large_pool只有 2 个 slots,即使同时有 5 个重型任务被调度,也只有 2 个能同时跑,其余排队。这样宿主机上最多只有 2 个 16G 容器在跑重型任务,加上常规轻重任务,总内存压力是可预估的。
4.2 DockerOperator 参数与资源限制的对接
Airflow 的 DockerOperator 有个参数叫docker_conn_id,但资源限制参数可不是从 Conn 里读的,而是直接写在 DockerOperator 的构造参数里。
一个标准的小型任务示例:
from airflow.providers.docker.operators.docker import DockerOperator small_task = DockerOperator( task_id="run_small_etl", image="my-registry/small-task-image:latest", container_name="small-etl", docker_conn_id="docker_local", pool="small_pool", api_version="auto", auto_remove="force", network_mode="bridge", cpus=1.0, mem_limit="2g", memswap_limit="3g", pids_limit=256, docker_url="unix://var/run/docker.sock", command="python /app/main.py --env=prod", mount_tmp_dir=False, user="1000:1000", )逐项解释一下我在生产里为什么这么设:
cpus=1.0限制单任务 1 核,符合 small_pool 的规格。mem_limit="2g"物理内存 2GB;memswap_limit="3g"允许 1GB 的 swap 缓冲。pids_limit=256防止这个容器内进程数量失控。user="1000:1000"以非 root 用户运行,权限隔离在编排层再上一道锁。mount_tmp_dir=False很关键。Airflow 默认会把调度机的/tmp/airflow_tmp...挂载进容器,如果两个任务同时挂了同一个目录,就埋下了文件冲突隐患。直接禁用,让容器只用自己的可写层。auto_remove="force"保证任务结束容器自动删除,不会堆积。
DockerOperator 在旧版本里,资源参数的写法可能不一样。我现在用的参数是基于较新版本的 provider 包,如果你的版本偏老,建议先查一下官方文档的 operator 参数列表,别照抄。核心思路是:容器资源限制参数要从 DAG 里透传,而不是改宿主机全局配置,否则每加一个任务都要动一次系统,太乱。
4.3 任务级 QoS:重任务让路、轻任务不饿死
Pool 解决了并发分流的"容量"问题,优先级解决了"谁先跑"的问题。
Airflow 里优先级是priority_weight和weight_rule联合控制的。priority_weight是一个整数,数值越高任务越可能先被调度和执行。weight_rule可以选upstream、downstream、absolute,默认是upstream,即任务父链路的权重决定它本身的优先级。
我的实践是:
- 重任务用
priority_weight=80,因为重任务一旦排队等资源,浪费成本最高。 - 中任务
priority_weight=50。 - 日志告警、状态同步等轻任务给
priority_weight=20,它们需要快速穿插完成,但不该抢重任务的资源。
更细一点的操作是配合pool的队列机制,在同一 Pool 内,权重高的任务会插队到权重低的任务前面执行。而跨 Pool 之间则互不干扰,重任务跑large_pool,不会挤压small_pool里轻任务的 slot。
我见过不少人忽略优先级,只管调大 slots,结果就是重任务把轻任务全部阻塞,页面看板迟迟出不来。加了 QoS 之后,用户体验才能稳定。
5. 一个生产级示例:DAG 内跑三种规格的隔离任务
纸上谈兵没意思,我给你看一个我实际搭建过的配置骨架,可以直接参考着去改。
5.1 构建自定义任务镜像:把资源探针装进容器
为了能够证明"资源限制确实在容器内生效",我习惯在镜像里放一个小探针脚本。这个探针在任务启动时输出当前 cgroup 限制值,这样日志里就能直接看到资源配额的执行情况,而不是黑盒运行。
Dockerfile 大致长这样:
FROM ubuntu:22.04 ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update && apt-get install -y \ python3 python3-pip curl \ procps ca-certificates \ && apt-get clean RUN pip3 install --no-cache-dir \ pandas numpy requests # 探针脚本,输出 cgroup 限制信息 COPY resource_probe.sh /usr/local/bin/resource_probe.sh RUN chmod +x /usr/local/bin/resource_probe.sh WORKDIR /app COPY task_script.py /app/task_script.py USER 1000:1000 ENTRYPOINT ["/usr/local/bin/resource_probe.sh"]resource_probe.sh的内容是:
#!/bin/bash echo "=== cgroup resource limits ===" cat /sys/fs/cgroup/cpu.max 2>/dev/null || echo "cgroup v1 mode" cat /sys/fs/cgroup/memory.max 2>/dev/null || echo "cgroup v1 mode" echo "=== start task ===" python3 /app/task_script.py注意容器内的 cgroup 路径与宿主机不同,但内容是一致的,可以直接读。这个探针在容器以非 root 用户执行时,也能读取到自己的限制值,用途是留证。
这里补充一个关键点:如果当前系统是 cgroups v1,路径会变成/sys/fs/cgroup/cpu/cpu.cfs_quota_us这类,与 v2 完全不同。我的环境统一 Ubuntu 22.04,所以直接写 v2 路径。你要是还在用 20.04,探针脚本要按 v1 路径改。
5.2 DAG 代码与容器配额对应关系
接下来是一个 DAG 文件,定义了三个任务,分别代表轻、中、重三种规格:
from datetime import datetime, timedelta from airflow import DAG from airflow.providers.docker.operators.docker import DockerOperator default_args = { "owner": "data-platform", "depends_on_past": False, "retries": 2, "retry_delay": timedelta(minutes=3), } with DAG( dag_id="resource_isolation_demo", default_args=default_args, schedule_interval="0 2 * * *", start_date=datetime(2024, 6, 1), catchup=False, tags=["isolation", "docker"], ) as dag: light_etl = DockerOperator( task_id="light_etl", image="my-registry/light-task:1.0.2", command="python /app/light.py", docker_conn_id="docker_local", pool="small_pool", priority_weight=20, cpus=1.0, mem_limit="2g", memswap_limit="3g", pids_limit=128, auto_remove="force", ) medium_etl = DockerOperator( task_id="medium_etl", image="my-registry/medium-task:1.0.2", command="python /app/medium.py", docker_conn_id="docker_local", pool="medium_pool", priority_weight=50, cpus=3.0, mem_limit="8g", memswap_limit="10g", pids_limit=256, auto_remove="force", ) heavy_compute = DockerOperator( task_id="heavy_compute", image="my-registry/heavy-task:1.0.2", command="python /app/heavy.py", docker_conn_id="docker_local", pool="large_pool", priority_weight=80, cpus=6.0, mem_limit="16g", memswap_limit="20g", pids_limit=512, auto_remove="force", ) light_etl >> medium_etl >> heavy_compute这个 DAG 的核心思想是:任务之间用上游依赖保证顺序,同时各自在不同的池中申请资源,CPU 和内存上限都在任务定义里写死。
有人可能会问,如果同一个 DAG 里后面的重型任务强依赖前面轻任务的产物,那隔离还有什么意义?答案是隔离主要针对不同 DAG 之间的任务并行时互相抢占资源的情况。同一个 DAG 内部通常是有序依赖的,但不同 DAG 是并行调度的,隔离的价值主要体现跨 DAG 的并行场景。
5.3 配额分配表与边界条件说明
我把这个 DAG 所对应的宿主机资源预算大概算一下:
| 规格 | CPU | 内存 | swap | 同时最大运行数 | 单池 CPU 上限 | 单池内存上限 |
|---|---|---|---|---|---|---|
| 轻任务 | 1.0 | 2G | 1G | 8(slots) | 8 核 | 16G |
| 中任务 | 3.0 | 8G | 2G | 4 | 12 核 | 32G |
| 重任务 | 6.0 | 16G | 4G | 2 | 12 核 | 32G |
如果你宿主机是 32 核 64G 内存,最极端情况下,三池全开占用 8+12+12=32 核,内存 16+32+32=80G。看起来已经超了,但实际上三池同一秒写满的情况几乎不会出现,因为不同任务有依赖关系、调度有时间差、任务运行时长各不相同。
不过,我实际配置时会基于预算做一个"惩罚式设计":故意把某个池的 slots 缩到小于计算值。比如 32 核机器,我只留 26 核的池容量,留出 6 核给 Airflow 调度器、监控、宿主机系统进程。这个余量看着浪费,但高并发场景下它就是保命空间。
6. 踩坑实录:一次内存 OOM 的完整排查链路
配置写得再漂亮,上线第一天也会出问题。我讲一个真实的排查经历,是最有教育意义的一个案例。
那天晚上报警:heavy_compute任务频繁失败。点开 Airflow 任务的日志,最后一句话是Process terminated,没有任何 Python traceback。我第一反应是容器被杀了,但不确定是谁杀的、为什么杀。
6.1 日志里没有线索,先在容器外找证据
容器已经被auto_remove="force"自动删了,Airflow 日志里看不到内核层面的原因。我立刻去宿主机上看有没有当时的记录,先翻内核日志:
journalctl -k --since "10 minutes ago" | grep -i "oom\|kill"很快就找到了关键信息:Memory cgroup out of memory: Killed process 176452 (python3) total-vm:...。这下确认了容器是被 cgroup 内存限制杀掉,不是宿主机内存不够。
很多人遇到这种问题就直接去调内存限制,把 16G 改成 24G。但如果不知道为什么不够,调大了也只是侥幸掩盖,下次换数据量更大照样崩。所以必须把任务的真实内存曲线拉出来看。
好在任务镜像里有探针,但探针不会记录内存曲线。我给镜像里的脚本加了一段:用 Python 的tracemalloc或直接轮询psutil,每 10 秒记录一次 RSS 内存,写进容器日志。重新跑一遍任务就能采集到数据。
6.2 cgroup 事件与宿主机内核日志的交叉定位
重跑后,我一边看 Airflow 日志,一边在宿主机实时监控:
docker stats --format "table {{.Name}}\t{{.MemUsage}}"观察到的现象是:任务运行到第 40 分钟时,容器内 RSS 突然从 12G 跳到 17G,紧接着容器秒挂。同时内核日志再次记录了 cgroup OOM kill。
这一步基本定位了问题:任务脚本在特定阶段会加载一个全量的大特征文件到内存,这个阶段内存峰值超出 cgroup 限制,进程直接被内核杀掉。为什么本地跑没问题?因为本地机器有 32G 内存,任务放开了用,没有人给它套 16G 的限制;到了容器里,cgroup 强制 16G,就立刻露馅了。
修复的办法不是单纯加内存限制到 24G,而是改任务本身:把大文件加载改成分批迭代模式,控制内存峰值在 12G 以内。改完后 16G 限制依然保持不变,任务稳定跑完。这说明容器资源限制反而倒逼我们发现了代码里的内存泄漏式写法。
6.3 修复验证与同类问题预防
修复后我做了三次验证:一次正常跑完,日志里 RSS 峰值 11.8G,没有触发 OOM;一次用stress-ng --vm人为加压,确认 cgroup 限制依然有效;一次把数据量放大到原来的 1.5 倍,确认还有余量。
这次排查给我沉淀了一套通用检查方法,后来遇到任何容器异常退出,我都按这个顺序查:
- 查 Airflow 日志:看有没有 Python 异常。如果有,说明是任务代码问题;如果没有,大概率是外部强制杀进程。
- 查内核日志:
journalctl -k | grep -i "oom\|kill",如果没有大内存压力,还可以查dmesg -T。 - 查看容器退出码:DockerOperator 失败时,退出码是 137 表示 SIGKILL,也就是被强杀;退出码 1 表示 Python 主动报错。OOM 被杀常见退出码就是 137。
- 查 cgroup 事件:在 cgroups v2 下,看
/sys/fs/cgroup/.../memory.events,它会记录oom_kill的触发次数。
cat /sys/fs/cgroup/system.slice/docker-<容器ID>.scope/memory.events # 输出中如果 oom_kill 不为 0,说明 OOM 杀手确实触发过这套链路走一遍,十有八九能定位问题。后来我又遇到一次 CPU 配额设置过小导致任务超时的案例,排查套路完全一样:先在宿主机的 cgroup 文件里核对配额,再用探针观察容器内实际可用配额,最后调整任务代码里面的多进程数量,而不是简单调大cpus参数。
7. 上线后的量化对比与调优建议
改造完成后,我把同一套任务在隔离前后各跑了两周,记录了关键指标。数据不能说多严谨,但趋势是很明显的。
7.1 隔离前后的关键指标对比
| 指标 | 隔离前 | 隔离后 |
|---|---|---|
| 任务失败率(周均值) | 4.7% | 0.6% |
| 因资源问题导致的失败数 | 12次/周 | 1次/周 |
| 宿主机 CPU 峰值 | 95% 以上频率很高 | 75% 以下为主,偶发 85% |
| 宿主机内存可用率 | 经常低于 10% | 稳定在 25% 以上 |
| 同类任务平均执行时长 | 47 分钟 | 38 分钟 |
| 依赖冲突告警数 | 每周都有 | 归零 |
隔离前失败率高的关键其实不是机器性能不够,而是任务之间的互相干扰。有一个重量级任务跑全量计算时,会把它所在宿主机 CPU 冲到 100%,其他任务全部变慢甚至超时。隔离后,这个重任务被关在 6 核的容器里,宿主机和轻任务完全不受影响。
执行时长反而缩短也符合预期:以前任务慢主要是在等待资源、被其他任务影响;现在它的资源是专属的,运行更稳定,整体耗时反而降了。
7.2 可以继续做的三层优化
这套方案跑顺之后,我建议你还可以往三个方向延伸。
第一层是动态资源池。目前 Pool 的 slots 是静态配置。如果任务峰谷差异特别明显,可以用 Airflow 的 API 在低峰期动态调大 slots,高峰期调小。我实测过,改造起来并不难,核心是新建一个简单的控制 DAG,每小时检查宿主机负载,然后动态更新 pool 配置。
第二层是镜像分层和缓存优化。容器任务多了以后,镜像拉取会成为瓶颈。我建议把项目代码和依赖库分成不同镜像层,代码频繁改,依赖很少动,这样每次发布只更新应用层,拉取速度大幅提升。另外在本地 Ubuntu 节点配一个镜像仓库做缓存,也能省下大量公网带宽。
第三层是引入宿主机指标的自动伸缩。当 cgroup 配额仍然满足不了部分极端任务时,可以基于docker stats的指标做一个简单的扩缩容决策:如果同规格任务单次运行超过阈值,就自动调高它的配额并通知管理员;如果长期使用率很低,就自动缩容。这方案我还没完全落地,但思路已经验证过,对任务类型波动大的公司尤其有用。
最后再分享一个小技巧:容器内要监控内存,别凭感觉,直接给每个任务容器都挂一个/metrics目录,定期写 RSS 到文件里,配合宿主机docker stats,任务失败时 5 分钟就能定位到是不是资源问题。这套组合架起来以后,Airflow 的每个任务都像流水线上的独立工作间,你盯着看板就能知道哪个工作间在偷懒、哪个在超支,再也不是以前那种一人出事全网陪跑的局面了。