做数据管道的人大概都遇到过这种时刻:某个 DAG 在线上跑出来的行为和文档描述对不上,翻 release notes 没写,翻 issue 列表只有一句“主干已修复”。这时候官方镜像就成了一个黑盒——它是打包好的发布版,你能读它的源码,但改了不生效,想加一行日志埋点验证猜测都做不到。我第一次动“用 Docker 从源码构建 Airflow 镜像”的念头,就是因为这么一个不上不下的问题:需要在本地的调度器里塞一段临时打印,看看任务实例到底在哪一步被判定成了失败。
这篇文章讲的就是这条路怎么走通:把 Airflow 的仓库源码拉下来,用 Dockerfile 打成一个自己能控制的镜像,再用容器编排把 webserver、scheduler、worker、元数据库、消息队列这一串组件拉起来,最后跑通一个最小 DAG 验证链路。适合已经用过官方镜像、想往底层再探一层的人,也适合需要给调度器打补丁、定制 provider、或者跟随主干分支验证问题的人。全程按我实际操作的顺序写,包括那些文档里不会写、但会实实在在卡住你几个小时的地方。
1. 先把“为什么”讲清楚:源码构建镜像到底解决什么问题
1.1 官方镜像给不了的三样东西
绝大多数场景下,官方镜像完全够用,docker compose up一把梭就能得到一个能跑的生产级调度环境。但有三件事它天然做不了,这跟镜像做得好不好无关,是发布版这个形态决定的。
第一是可修改的运行时。官方镜像里的 Airflow 是pip install进去的编译产物,源码目录在 site-packages 里,你想加一行print、想在某个异常分支上打个断点、想临时改掉一段超时判断,改完还得面对“下次重建镜像就没了”的问题,而且改 site-packages 这件事本身就很不体面。源码构建模式下,Airflow 是以 editable 方式装进去的,源码目录挂在容器外面,改动即时可见——这个差别在排障时的效率差距是数量级的。
第二是非发布状态的代码。有些行为差异只存在于主干和最近发布版之间,你要验证“是不是已经修了”,就必须拿到某次提交之后的代码。发布版镜像做不到这一点,你只能等下一个版本。
第三是依赖的完全掌控。官方镜像是按官方选定的一套依赖组合构建的,你想额外装几个库、想换一个特定版本的 provider、想装一些只有内网才有的包,官方镜像的扩展方式是写自己的 Dockerfile 继承它,一旦你的需求和基础镜像的依赖树打架,排查成本很高。源码构建时,所有依赖从零开始装,冲突在哪一目了然。
1.2 什么情况下这条路是亏的
说清楚收益,也得说清楚成本,否则容易误导人。
源码构建最大的代价是构建时间。Airflow 的依赖树很庞大,从零装一遍,冷缓存下十几分钟很常见,机器慢一点半小时也正常。官方镜像的分层缓存是经过优化的,你自己写的 Dockerfile 很难做到同等水平,除非在层序上花心思(下一节会讲)。
第二个代价是你得自己对版本一致性负责。官方镜像里apache-airflow的版本、provider 的版本、Python 的版本是配套验证过的;源码构建时这些组合都得你自己定,装出来一个能跑但不完全兼容的组合是很正常的事。
第三个代价是运维链路变长。官方镜像升级就是换个 tag,源码构建升级要重新拉代码、重新构建、重新验证。如果没有一套版本标记和回滚机制,出事时很难说清“上一次能跑的是哪个状态”。
所以我的判断标准很简单:如果你只是要跑 DAG,用官方镜像;如果你要改 Airflow 本身或者验证未发布的行为,走源码构建。两者不是替代关系,是不同用途。
2. 目录与镜像分层:动手前先定好骨架
2.1 工作目录的摆法
目录结构这件事,一开始想清楚能省掉后面很多别扭。我是这么摆的:
airflow-src-deploy/ ├── docker/ │ ├── Dockerfile │ └── entrypoint.sh ├── docker-compose.yaml ├── .env ├── constraints.txt # 从官方渠道取得的约束文件 ├── airflow-src/ # Airflow 仓库源码 ├── dags/ # 自己的 DAG 目录 ├── logs/ # 日志落盘目录 ├── plugins/ └── config/ └── airflow.cfgairflow-src放仓库源码,单独的dags、logs、plugins三个目录通过 volume 挂进容器,和源码目录彻底分开。这个分离很关键:源码目录是“我要构建进去的东西”,dags 目录是“我要随时改的东西”,两者混在一起,挂载的时候很容易把构建产物和用户数据搅在一块。
.env里放的是环境变量,主要是数据库连接、broker 地址、密钥这类。注意这个文件不要进版本库,里面的AIRFLOW__CORE__FERNET_KEY和AIRFLOW__WEBSERVER__SECRET_KEY一旦泄露,会话和加密连接都受影响。
还有一个容易忽略的点:.dockerignore必须写。Airflow 仓库源码里有tests/、docs/、chart/这些目录,加起来体积很大,如果不排除,构建上下文会膨胀到几百 MB,每次docker build光是传上下文就要花掉几十秒。我的做法是把这些明显用不到的大目录排掉,但要小心一件事——setup.py在安装时需要读取版本信息,相关的小文件不能一起排掉,否则会直接报错(这一点在第 6 节会详细讲,我在这上面栽过一次)。
2.2 Dockerfile 的层序怎么排才不浪费构建时间
源码安装的 Dockerfile 有个天然的矛盾:pip install -e .这一条命令既读依赖声明,又读全部源码,意味着源码里任何一个文件变动都会让整个依赖安装层失效。只改一行 Python 代码就得重装一遍依赖,这个体验非常糟糕。
我的解法是把依赖安装和源码安装拆成两步。Airflow 的setup.py里依赖清单是写死的,它只需要能读到版本号就能完成依赖解析,不需要全部源码。所以可以这么写:
FROM python:3.11-slim AS base ENV AIRFLOW_HOME=/opt/airflow \ PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ PIP_NO_CACHE_DIR=1 RUN apt-get update \ && apt-get install -y --no-install-recommends \ build-essential libpq-dev curl git \ && rm -rf /var/lib/apt/lists/* WORKDIR /opt/airflow # 第一步:只拷贝能让 setup.py 跑起来的最小文件集 COPY airflow-src/setup.py airflow-src/setup.cfg ./ COPY airflow-src/airflow/__init__.py ./airflow/__init__.py COPY constraints.txt ./ # 第二步:装依赖。这一层只在 setup.py 或 constraints 变化时失效 RUN pip install --no-cache-dir -e ".[celery,postgres,redis]" -c constraints.txt # 第三步:拷贝全量源码,这一步几乎不花时间 COPY airflow-src/ ./这个写法的收益很直接:日常改代码只触发第三步和之后的操作,第二步命中缓存,整个docker build从十几分钟降到几十秒。我第一次用这个写法的时候,感受就是“早该这么干”。
有个细节要注意:airflow/__init__.py里定义版本号的逻辑在不同分支上可能不一样,有些分支会从_version.py之类的地方读,如果构建时报找不到版本文件,就把那个文件也加进第一步的 COPY 里。这个报错信息很明确,一看就知道。
另外.[celery,postgres,redis]这几个 extras 是按你的执行器和后端选的。如果只是单机验证,LocalExecutor + SQLite 也能跑,依赖会少一大截,构建快很多。但只要是要模拟生产,CeleryExecutor + PostgreSQL + Redis 这套组合几乎是标配,不如一开始就按这个装。
2.3 constraints 文件是整套安装的定海神针
这一条我要单独强调,因为它是整个安装过程里唯一能让“装出来的东西可复现”的手段。
Airflow 的setup.py里的依赖声明是宽松的,写的是最低版本加若干上限,本身没问题——它面向的是通用安装场景。但宽松依赖在从零安装时会带来一个副作用:pip 的解析器会在满足范围的前提下尽量装新版本,而这个“新版本”可能是 Airflow 这个分支根本没测过的。装完能启动,跑几天突然在某条路径上炸了,排查起来会很痛苦。
官方提供的 constraints 文件就是干这个的:它把每个包的版本钉死到一组经过验证的组合上。用法就是pip install -e . -c constraints.txt,-c是 constraint 而不是 requirement,意思是“装什么由我自己定,但版本不能超过这个文件规定的”。
拿 constraints 文件有两个要点。第一,版本必须严格对应:文件是按 Airflow 版本和 Python 小版本区分的,拿 3.10 的文件去装 3.11 的解释器,会在某些带 C 扩展的包上直接失败。第二,要注意时效:如果你构建的是主干分支的代码,配套的 constraints 可能是按最新提交生成的,和你的源码提交点差了几十个 commit 也可能出现不匹配,这时候要么换到对应时间的源码点,要么接受少量偏差。
我把constraints.txt也放在构建上下文里单独 COPY,就是为了让它成为缓存失效判定的依据之一——约束变了,依赖层重建,这是符合预期的。
3. 组件编排:把 Airflow 拆成几个容器
3.1 服务拆分与各自的职责边界
Airflow 的架构决定了它不是一个能单容器跑好的东西(单机 LocalExecutor 除外)。标准拆分是这样的:
| 服务 | 职责 | 是否必需 |
|---|---|---|
| postgres | 存元数据:DAG 结构、任务实例、连接、变量 | 必需 |
| redis | Celery 的 broker,任务队列 | 用 CeleryExecutor 时必需 |
| airflow-init | 一次性任务:数据库迁移、建管理员 | 必需 |
| airflow-webserver | 提供 Web UI 和 REST API | 必需 |
| airflow-scheduler | 解析 DAG、生成任务实例、派发 | 必需 |
| airflow-worker | 真正执行任务 | 用 CeleryExecutor 时必需 |
| airflow-triggerer | 处理 deferrable operator 的触发器 | 可选,但强烈建议 |
拆成多个容器的意义不只是“看起来专业”。每个组件的负载特征完全不同:scheduler 是持续解析,吃 CPU 单核;worker 是执行任务的,吃内存和并发;webserver 是突发请求。拆开之后,各自的资源限制、重启策略、日志采集都能独立配置。混在一个容器里,任何一个组件出问题都会拖着其他组件一起重启。
用的是同一个镜像,靠command参数区分角色,这样构建一次就能跑起全套。这是 Airflow 官方 compose 的思路,也确实是省事的做法。
3.2 初始化容器的存在意义
airflow-init这个一次性容器是很多人第一次看 compose 文件时最容易忽视的部分,但它解决的问题很实际。
元数据库在第一次启动时是空的,所有表都不存在。如果让 webserver 或 scheduler 直接起来,它们会立刻连数据库然后报一堆“关系不存在”的错误,接着退出、重启,进入循环。手动进去执行迁移命令虽然可行,但每次清库重来都得重复一遍,很不优雅。
初始化容器做的就是把这件事变成编排的一部分:它跑完airflow db migrate建表、跑airflow users create建管理员账号,然后退出。其他服务用depends_on加condition: service_completed_successfully挂在它后面,保证“迁移完成才启动”。
airflow-init: image: airflow-src:local command: > bash -c "airflow db migrate && airflow users create --username admin --password admin --firstname A --lastname B --role Admin --email a@example.com" depends_on: postgres: condition: service_healthy redis: condition: service_healthy这里有个版本差异要留心:老一些的版本用的是airflow db init,新版本改成了airflow db migrate。如果你拿的源码分支比较新而参考的是老教程,这条命令会直接报“未知子命令”。我建议直接翻源码里airflow/cli/commands/下对应的实现确认,比搜教程靠谱。
users create那条命令在重复启动时会因为用户名已存在而失败,导致整个 init 容器非零退出。如果希望它幂等,可以加个|| true兜底,或者在脚本里先判断用户是否存在。这个坑很浅但很烦人,尤其在你反复up/down的时候。
3.3 健康检查与启动顺序的硬约束
编排最容易出问题的地方就是依赖顺序。Airflow 这套组件之间的依赖不是“启动即可”,而是“就绪才行”。
postgres 和 redis 必须配健康检查,pg_isready和redis-cli ping各一条,配了之后depends_on的service_healthy条件才有意义。不配的话,容器进程起来了但数据库还在初始化,会随机失败,而这种随机失败最难查——有时候能起来,有时候起不来,重试一下又好了。
Airflow 自己的服务也需要健康检查。webserver 有/health端点,可以直接 curl。scheduler 的健康检查稍微麻烦一点,新版本提供了airflow jobs check命令,检查 scheduler 的心跳是否在有效期内:
healthcheck: test: ["CMD", "airflow", "jobs", "check", "--job-type", "SchedulerJob", "--hostname", "$${HOSTNAME}"] interval: 30s timeout: 10s retries: 5这个检查比ps aux | grep airflow之类的进程检查靠谱得多。进程活着不代表调度器在工作,心跳停了才是真正的故障信号。
还有一个隐含约束:所有 Airflow 容器必须共享同一份airflow.cfg(或同一组环境变量)。因为 webserver 和 scheduler 是分进程的,如果两边读到的secret_key或sql_alchemy_conn不一致,表现就是“页面能打开但一切操作都失败”,非常难查。我用的是统一从.env注入环境变量的方式,配置文件只作为兜底。
4. 首次构建到第一个 DAG 跑通的完整链路
4.1 构建阶段实际操作
拉源码的时候建议带--depth 1只取最新一次提交,仓库历史很重,全量克隆没什么价值。构建时打个明确的 tag:
git clone --depth 1 <源码地址> airflow-src cd airflow-src && git rev-parse --short HEAD # 记下这个 commit cd .. docker build -f docker/Dockerfile -t airflow-src:$(date +%Y%m%d)-<短哈希> .用“日期 + commit 短哈希”做 tag 不是洁癖,是必须。源码构建最怕的就是“这个镜像到底是什么版本”,等你手上有五六个 tag 都是local或latest的时候,回滚和排障会非常痛苦。我后来还加了个小动作:在构建时把 commit hash 写进一个文件塞到镜像里,容器起来后cat一下就知道了。
构建过程中最值得盯的是依赖安装那一步的输出。pip 解析器在做回溯的时候会打印大量 “downloading” 和 “collecting” 信息,如果发现它在同一个包的不同版本之间反复横跳,说明依赖冲突了,这时候别等它跑完,直接停下来看是哪个包。第 6 节会讲我遇到的具体情况。
构建完成后先别急着up,跑一下:
docker run --rm airflow-src:<tag> airflow version docker run --rm airflow-src:<tag> python -c "import airflow; print(airflow.__file__)"第二条命令的输出应该指向/opt/airflow/airflow/__init__.py而不是 site-packages 里的路径,这才说明是 editable 安装、源码生效了。这个验证很关键,如果装成了普通安装,后面所有“改源码即时生效”的期待都会落空。
4.2 数据库迁移与管理员账号创建
docker compose up之后,init 容器会先跑。这里我建议第一次用docker compose up airflow-init单独跑,盯着它的日志,确认迁移的每一步都正常。迁移过程中会打印大量 “Running upgrade xxx -> yyy”,这是 Alembic 在逐版本升级表结构,正常现象。如果中途报错退出,元数据库里会留下一个半迁移的状态,下次启动会卡住,最简单粗暴的解法是把 postgres 的数据卷删掉重来(前提是你没有已经积累的生产数据)。
管理员账号的密码,测试环境用admin无所谓,但只要这个环境会暴露在任何非本机的地方,就必须改掉。我见过不止一次因为测试环境的弱密码被扫到,然后是漫长的清理。这件事没有“以后再说”的余地。
4.3 用最小 DAG 验证调度链路
环境起来之后不要急着搬自己的 DAG 进来,先放一个最小的验证。
import pendulum from airflow import DAG from airflow.operators.bash import BashOperator with DAG( dag_id="smoke_test", start_date=pendulum.datetime(2024, 1, 1, tz="Asia/Shanghai"), schedule="*/5 * * * *", catchup=False, tags=["smoke"], ) as dag: t1 = BashOperator(task_id="echo", bash_command="echo hello && hostname") t2 = BashOperator(task_id="env", bash_command="echo $AIRFLOW_HOME") t1 >> t2这个 DAG 的价值在于它一次验证了四件事:调度器能不能解析 DAG 文件、任务能不能被派发到 worker、worker 能不能执行 Shell、worker 容器内的环境变量是否按预期注入。
看的地方有三个:UI 上 DAG 是否出现且没有解析错误;手动 trigger 一次看两个任务是否都变绿;点开任务的日志,确认hostname输出的是 worker 容器而不是 webserver 容器。第三条经常被忽略——如果执行器配置成了 LocalExecutor 而你以为是 CeleryExecutor,任务会在 scheduler 里跑,日志里看到的主机名会暴露这一点。
如果 DAG 一直不出现,先看 scheduler 日志里有没有 “DagFileProcessor” 相关的报错;如果出现了但任务一直排队,看 worker 日志有没有连上 broker。这两个方向基本能覆盖 90% 的首次启动问题。
5. 改了源码怎么让它生效:三条路径的实测取舍
5.1 挂载覆盖与 editable 安装
这是源码部署最大的价值所在,也是最多人踩坑的地方。
原理上很直接:镜像里是 editable 安装(pip install -e .),本质是在 site-packages 里放一个指向源码目录的链接文件。所以只要把宿主机的源码目录覆盖到容器内的同一个路径,容器加载的模块就来自宿主机:
volumes: - ./airflow-src/airflow:/opt/airflow/airflow - ./dags:/opt/airflow/dags - ./logs:/opt/airflow/logs改完源码,重启对应容器就生效。但“重启”这件事有讲究:Python 进程启动时会把模块加载进内存,改文件不会自动重新加载。所以改完必须docker compose restart airflow-scheduler,光刷新页面没用。
还有一个更隐蔽的限制:只有 Python 文件的改动能这样生效。如果改的是setup.py(加了新依赖)、或者源码里的.pyx、.so这类编译产物、或者airflow/config_templates/下的配置模板,挂载是救不了的,必须重建镜像。这一点我在第一次改配置模板时完全没意识到,改了半小时一直不生效,最后才反应过来模板在构建时已经被复制到别处了。
5.2 重建镜像的时机判断
什么时候必须重建,我的判断清单是这样的:
| 改动类型 | 是否需要重建 | 原因 |
|---|---|---|
普通.py业务逻辑 | 否 | editable 安装,挂载即生效 |
| 新增/修改依赖 | 是 | 依赖层在镜像里 |
| 配置模板文件 | 是 | 构建时被复制 |
| 入口脚本 entrypoint.sh | 是 | 在镜像层里 |
| 前端资源(改动 UI 时) | 是 | 需要重新编译静态文件 |
| DAG 文件 | 否 | 本来就是挂载的 |
前端资源的处理值得单独提一句。如果你改的东西涉及 Web UI,源码里的前端资源需要先构建成静态文件再打包,这个过程需要 Node 环境,是整个构建里最慢的一环之一。如果只是后端逻辑改动,可以在构建时跳过前端编译——但具体怎么跳,不同分支的处理方式不一样,得看构建脚本,不要想当然地删命令,很容易导致镜像起来后 UI 一片空白。
5.3 我实际用的组合策略
折腾了一轮之后,我固定的做法是:
开发阶段:挂载源码目录,改逻辑就restart一下 scheduler,几十秒的反馈周期,够用。同时保留一个“全量构建”的 tag,作为可回退的基线。
验证阶段:改动稳定之后,重建一次镜像,但去掉源码挂载,纯粹用镜像内的代码跑一遍。这一步是为了排除“挂载了宿主机文件才正常”的假象——我遇到过一次,宿主机上有个残留的.pyc或者一个未提交的临时文件,掩盖了真正的问题,去掉挂载之后就复现了。
出问题回退:因为每次构建都打了 tag,回退就是改 compose 里的镜像版本然后up -d,两三分钟的事。这个能力在排查“是不是我改的东西导致的”时非常好用,二分定位。
这套策略的核心思想是:开发和验证用不同的运行形态,别用一套对付所有场景。
6. 这一路上踩的坑和当时的排查动作
6.1 依赖解析阶段直接卡死
第一次构建,卡在pip install那一步,日志在几个包之间反复 “collecting”,十几分钟没有任何进展。
排查动作:先Ctrl+C,然后加-v重新跑,把解析日志打全。看到的是两个包互相要求对方的不同版本区间,形成了一个无解的矛盾。原因是我拿的 constraints 文件和源码提交点不匹配——源码里有新的依赖声明,但 constraints 还是老的,钉死的版本不满足新声明。
处理方式有两条:一是把源码切到与 constraints 时间接近的提交点,二是重新取一份对应分支的 constraints。我选了后者,因为要验证的问题在新提交里。这次之后我养成了一个习惯:拿到源码先看setup.py里的依赖声明有没有近期变动,再决定 constraints 取哪一份,这样能提前发现不匹配,不用等到构建时才发现。
顺带说一句,如果你的环境在依赖源访问上有额外配置,记得把相关配置也带进构建过程,否则会表现为“下载超时”或者“找不到包”,这类报错信息很容易被误判成网络问题去反复重试。
6.2 页面能开但接口 500
容器全部起来了,UI 首页能打开,但一刷新就报 500,日志里是一串堆栈。
排查链路是这么走的:先看 webserver 日志,堆栈指向某个表不存在。于是怀疑迁移没跑完,回去看 init 容器日志——果然,它在迁移的某一步失败了,但因为它是个一次性容器,退出之后并没有影响其他服务的启动(我当时没配service_completed_successfully,配的是简单的depends_on)。
这里暴露两个问题:一是 init 失败没有阻断后续启动,二是失败信息被淹没在大量日志里。修复方式是加上严格的完成条件,并且把 init 的日志单独重定向到文件方便回看。改完之后,同样的问题会表现为“其他服务压根不启动”,一眼就能看出根因在 init。
另一个会导致 500 的原因是secret_key不一致。webserver 和 scheduler 从不同来源读到了不同的密钥,表现是会话异常、部分接口报错。这个问题的排查方式是把两个容器里的环境变量打出来对比,docker compose exec airflow-webserver env | grep SECRET,很容易发现差异。统一从.env注入之后就再没出现过。
6.3 调度器“活着”却不接任务
现象是:scheduler 容器状态 healthy,日志里也有心跳,但 DAG 里新加的任务一直不进入调度,已有的任务也停了。
排查动作分三步。第一步确认 DAG 文件被解析了:看 scheduler 日志里有没有 “Processing ... /dags/xxx.py” 的记录。有,说明文件读到了。第二步确认没有解析错误:UI 上的 DAG 状态如果是 import error,点进去能直接看到堆栈。第三步是关键——看任务实例的start_date和 DAG 的schedule是否匹配。
我那次的原因就是时区。DAG 里写的start_date用的是不带时区的datetime,Airflow 按 UTC 解释,而我本机在东八区,结果所有任务的第一个调度点都落在“未来”,调度器当然不派发。这是个纯粹的时间差问题,但表现和“调度器坏了”一模一样。
解决办法是统一用pendulum.datetime(..., tz="Asia/Shanghai")显式带时区,并且把AIRFLOW__CORE__DEFAULT_TIMEZONE也设成同一个时区。顺便一提,catchup参数在这种场景下也要留意,默认值是True的话,一个早期start_date会让调度器尝试补跑大量历史实例,看起来就像“卡住了”。
6.4 挂载目录权限不对导致的静默失败
这个坑最阴,因为它不报错,只是行为不对。
容器里的进程以某个非 root 用户(常见是 uid 1000)运行,宿主机的logs目录如果是 root 创建的,容器写日志就会失败。表现是任务跑完了,状态是成功,但日志页面是空的——因为日志根本没写进去。
排查的时候我一开始怀疑是日志配置的问题,翻了半天airflow.cfg。后来才想起来看容器内的用户和目录属主:
docker compose exec airflow-worker id docker compose exec airflow-worker ls -ld /opt/airflow/logs一眼看到 uid 对不上。修复就是在宿主机上把目录属主改成对应 uid,或者在entrypoint.sh里加一句对目录的属主修正。
这件事给我的教训是:挂载目录的权限问题永远优先排查,别在配置里绕圈子。因为它是“静默失败”,没有任何错误日志指路,靠读配置猜是最低效的方式。现在我的entrypoint.sh里第一步就是检查并修正几个关键目录的属主,宁可多花一秒,也不要在后面浪费两小时。
7. 从能跑到敢用:上线前必须动的那几个开关
7.1 默认配置里不能带上去的项
源码构建的镜像跑起来之后,一堆默认配置是面向“本地验证”的,任何对外暴露的环境都得先改掉。
AIRFLOW__WEBSERVER__SECRET_KEY必须显式设置,默认不管的话,容器每次重建都会变,所有会话失效、部分加密数据读不出来。AIRFLOW__CORE__FERNET_KEY更是必须固定,它用于加密数据库里的连接信息,一旦变了,已存的连接全部解不开,表现是“所有连接都显示为无效”。
AIRFLOW__CORE__EXECUTOR要明确写出来,不要靠默认值。默认是 SequentialExecutor,你在 compose 里配了 Celery 的 broker,结果执行器还是单线程,所有任务排队慢慢跑,很容易被误判成性能问题。
AIRFLOW__CORE__LOAD_EXAMPLES一定要关掉。示例 DAG 有几十个,启动时会全部解析一遍,既占时间又污染界面,而且在自动创建的连接上还可能有默认凭据。测试阶段开着无所谓,上线前必须false。
还有AIRFLOW__CORE__DAGS_ARE_PAUSED_AT_CREATION,默认是True,新 DAG 进来是暂停状态。这个默认值其实是好的(避免误触发),但如果你是“DAG 上线即自动跑”的模式,得知道要手动启用,而不是等它自己动。
7.2 并发与资源参数怎么估
这几个参数是“不配会出事,配错了更会出事”的典型。
AIRFLOW__CORE__PARALLELISM控制整个 Airflow 同时跑的任务上限,worker_concurrency控制单个 worker 上的并发。两个数字的关系是:总并发上限 ≈ worker 数量 × worker_concurrency,但要小于等于parallelism,否则后者会成为瓶颈。
估算的方式我一般这么走:先算单个 worker 容器的内存,Airflow 的任务本身不一定吃内存,但 Python 进程的基础开销加上你依赖的库,一个任务槽位按 300MB~500MB 估比较稳。一个 4GB 的 worker 容器,给 6~8 个并发槽位是比较合理的,再多就要担心 OOM。
AIRFLOW__SCHEDULER__MIN_FILE_PROCESS_INTERVAL影响解析频率,默认值偏保守。DAG 少的时候调小能让新 DAG 出现得更快,但 DAG 上千的时候就别动它,解析压力会很明显。AIRFLOW__SCHEDULER__PARSING_PROCESSES可以考虑按 CPU 核数调整。
这些参数没有一套通用的最优值,唯一的办法是先按上面的思路估一个初始值,然后观察 scheduler 的解析耗时和 worker 的内存水位,再调。上线就写一堆“抄来的推荐值”而不看自己环境的实际负载,是最容易翻车的做法。
7.3 版本可追溯与日志落盘
源码构建的镜像有一个天然缺陷:它不像官方镜像有个公开的 tag 能对应到明确的发布版本。所以版本可追溯这件事必须自己补上,而且要做实。
我的做法三件套:镜像 tag 用“日期 + commit 短哈希”;构建时把这个 commit 写进镜像里的一个文本文件;容器启动时打印一次。这样任何时候docker inspect或者看启动日志,都能立刻知道这个容器是什么时候、什么代码构建出来的。别嫌麻烦,等你在半夜排查“昨天还好好的今天不行了”而完全想不起来昨天改了什么的时候,这三件套会救你。
日志这边,Docker 的默认日志驱动会把容器标准输出存成 json 文件,不轮转,跑久了能把磁盘撑满。要么在 compose 里配logging.options加max-size和max-file,要么把 Airflow 自己的任务日志落到挂载出来的目录里并配合宿主机上的轮转。我两个都配了——容器日志和应用日志是两回事,前者是进程级的,后者是任务级的,缺一个都会在排查时少一条线索。
最后是一个我踩过的细节:logs目录挂载出来之后,Airflow 会按 DAG 和运行日期建多层子目录,文件数量增长很快。如果挂载点在某种容量受限的存储上,或者做了目录级别的配额,很容易在某一天突然写不进去,然后就是前面说的那种“任务成功但日志空白”。定期看一眼这个目录的体积,比出事之后再查划算得多。
这套东西我从第一次构建到现在跑了大半年,镜像重建了几十次,DAG 目录换了三茬,中间遇到过的问题基本都记在上面了。真正让我觉得这条路值得走的,不是“能改源码”这件事本身,而是排障时那种“能直接看到运行时到底发生了什么”的确定性——比起对着文档猜,我更愿意多花那十几分钟的构建时间。