
做数据管道的人大概都遇到过这种时刻某个 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 \ PYTHONDONTWRITEBYTECODE1 \ PYTHONUNBUFFERED1 \ PIP_NO_CACHE_DIR1 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 结构、任务实例、连接、变量必需redisCelery 的 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 aexample.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只取最新一次提交仓库历史很重全量克隆没什么价值。构建时打个明确的 taggit 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_idsmoke_test, start_datependulum.datetime(2024, 1, 1, tzAsia/Shanghai), schedule*/5 * * * *, catchupFalse, tags[smoke], ) as dag: t1 BashOperator(task_idecho, bash_commandecho hello hostname) t2 BashOperator(task_idenv, bash_commandecho $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”十几分钟没有任何进展。排查动作先CtrlC然后加-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用的是不带时区的datetimeAirflow 按 UTC 解释而我本机在东八区结果所有任务的第一个调度点都落在“未来”调度器当然不派发。这是个纯粹的时间差问题但表现和“调度器坏了”一模一样。解决办法是统一用pendulum.datetime(..., tzAsia/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 进程的基础开销加上你依赖的库一个任务槽位按 300MB500MB 估比较稳。一个 4GB 的 worker 容器给 68 个并发槽位是比较合理的再多就要担心 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 目录换了三茬中间遇到过的问题基本都记在上面了。真正让我觉得这条路值得走的不是“能改源码”这件事本身而是排障时那种“能直接看到运行时到底发生了什么”的确定性——比起对着文档猜我更愿意多花那十几分钟的构建时间。