官方镜像拉下来改两行配置就能跑,这话只在你只写 DAG、只调连接的时候成立。一旦你要动 scheduler 的调度循环、给某个 operator 打补丁、把别人提的 PR 拉到本地验证一遍,官方镜像立刻就不好使了——代码躺在镜像的 site-packages 里,改一行要重新 build 一次,五分钟起步,改三次一上午就没了。我这两年陆陆续续维护过三套内部调度环境,每次遇到这类需求,最后都绕回同一条路:把 Airflow 源码当成一个可编辑安装(editable install)的包,塞进自己构建的镜像,再用 docker compose 把 webserver、scheduler、worker、数据库、消息队列串成一台“伪单机集群”。这篇就把 docker 源码部署 airflow 的全过程摊开讲,从版本怎么对齐、Dockerfile 每一段为什么这么写、compose 里六个容器谁等谁,到首次启动怎么验证、改完源码怎么让改动生效、以及我踩过的那些坑。适合已经能跑起官方 compose 样例、想再往下钻一层的同学;完全没碰过容器编排的,建议先拿官方 quickstart 跑通一遍再回来。
1. 为什么值得为“源码部署”多花这两小时
1.1 官方镜像能覆盖的边界在哪
官方镜像的设计目标很明确:让大多数人以最小的认知成本跑起一个调度环境。所以它把 Airflow 的代码、providers、常用系统库全部预装好,配置项全部走环境变量,你只需要挂载 dags、logs、plugins 三个目录。这个设计对“我是数据开发,只想写 DAG”的人群是完美的,但它有一个隐含前提——你不改 Airflow 本身。
一旦跨过这条线,问题就开始成串出现。想给BaseOperator加一段自定义的生命周期钩子?你得改源码。想验证一个影响 scheduler 心跳逻辑的补丁?你得改源码。想给内部平台加一个只在你们环境存在的 executor 后端?还是得改源码。这时候官方镜像的“预装”反而成了负担:源码在镜像层里,构建缓存一旦失效,改一行注释也要走一遍完整的 pip 依赖解析。
源码部署解决的正是这个问题。把源码目录以挂载的方式送进容器,宿主机的编辑器保存一次,容器里的 Python 进程下一次导入就拿得到新代码。这才是“改代码”该有的手感。
1.2 源码可编辑安装与普通 pip 安装的本质差别
很多人以为pip install -e .就是把源码拷进 site-packages,其实不是。可编辑安装做的是另一件事:它在 site-packages 里放一个“指针”(新版 pip 生成的是__editable__*.pth加一个 finder 模块),记录下源码目录的绝对路径,import 的时候由这个 finder 把请求转发到源码目录去。所以真正被加载的永远是源码目录里的.py文件,site-packages 里没有包体。
理解了这一点,后面所有行为都能推导出来:
- 源码目录被挂载覆盖后,只要挂载点路径和构建时的绝对路径完全一致,import 依然生效——因为指针指向的是路径字符串,不是 inode。
- 反过来,如果你构建时装在
/opt/airflow/airflow-src,运行时却挂到/workspace/airflow,指针就断了,容器里import airflow直接报 ModuleNotFoundError。这是新手最常见的一个“明明装了却找不到”的谜案。 - 也正因为加载的是源码,
.pyc缓存、__pycache__这类东西会跟着源码目录一起被挂到宿主机上,时间长了宿主机目录里全是缓存文件,看着脏但无害,想干净就设PYTHONDONTWRITEBYTECODE=1。
对比一下两条路线的取舍,就很清楚了:
| 维度 | 官方镜像 + 环境变量 | 源码可编辑安装 + 自建镜像 |
|---|---|---|
| 改 DAG 逻辑 | 支持,挂载即可 | 支持 |
| 改 Airflow 核心代码 | 不支持,必须重新构建 | 支持,保存即生效 |
| 依赖版本控制 | 跟随镜像标签 | 自己用 constraints 钉死 |
| 首次构建耗时 | 零(拉取即可) | 5 到 15 分钟,看网络和机器 |
| 镜像体积 | 约 1.2 GB 起 | 通常 1.5 到 2.5 GB |
| 适合场景 | 纯业务开发 | 二次开发、补丁验证、定制后端 |
我的建议是两条路都留着:日常写业务 DAG 用轻量的官方镜像,需要动内核时切到源码镜像,compose 文件拆成两份,用-f叠加覆盖。别为了统一而把所有事情都塞进源码镜像,构建慢会实实在在地拖垮节奏。
2. 动手前的准备:版本对齐、目录规划与硬件底线
2.1 版本对齐表:Python、数据库、队列的匹配关系
源码部署最容易翻车的不是 Dockerfile,而是版本矩阵。Airflow 每个大版本对 Python 的支持范围、对元数据库的最低要求都不一样,装错组合会出现“能起来但 scheduler 一直报 DeprecationError”这种半死不活的状态。下面这张表是我按实际验证过的组合整理的,正式动手前先照着对一遍:
| Airflow 系列 | Python 支持 | 元数据库最低版本 | 队列最低版本 | 备注 |
|---|---|---|---|---|
| 2.7.x | 3.8 – 3.11 | PostgreSQL 12 | Redis 6 | 稳定,资料最多 |
| 2.8.x | 3.8 – 3.11 | PostgreSQL 12 | Redis 6 | datasets 功能趋于完整 |
| 2.9.x | 3.8 – 3.12 | PostgreSQL 12 | Redis 6 | 调度器拆出独立进程 |
| 3.x | 3.9 – 3.12 | PostgreSQL 13 | Redis 7 | 配置项有较大调整 |
注意:上表是经验值,具体到某个小版本可能微调,正式落库前以你所用版本的官方安装文档为准。真要保险,就在 Dockerfile 里把 Python 版本和 Airflow 版本都作为
ARG写死,别用latest。
另外还有三个隐性依赖容易被忽略。第一是psycopg2的编译,需要libpq-dev;第二是celery的 broker 连接,需要libsasl2-dev和libssl-dev;第三是某些 provider 会依赖 Kerberos 头文件,如果没有libkrb5-dev,pip 会在最后一步报一个跟 Airflow 毫无关系的编译错误。这三样在纯官方镜像里都是预装好的,自己 build 就得手动补齐。
2.2 目录结构与挂载点设计
我习惯把整个环境做成一个自包含的目录,进目录就docker compose up -d,不需要记任何额外路径:
airflow-source-stack/ ├── airflow-src/ # Airflow 源码,挂载进容器 ├── dags/ # 业务 DAG ├── logs/ # 任务日志 ├── plugins/ # 自定义插件 ├── config/airflow.cfg # 需要时挂载覆盖默认配置 ├── docker/ │ ├── Dockerfile │ └── entrypoint.sh ├── constraints.txt # 从对应版本下载的约束文件 ├── .env # UID、FERNET_KEY、连接串 ├── .dockerignore └── docker-compose.yaml关于airflow-src的来源,这里有一个很关键的取舍,值得单独说。用 git 克隆整个仓库,好处是能切分支、能看提交历史,坏处是仓库里不含编译好的前端静态资源——Airflow 的 Web UI 是从前端源码构建出来的,克隆下来的airflow/www/static目录可能是空的或者只有源码,起来的 webserver 会给你一个白屏或者一堆静态资源 404。解决办法有两个:一是额外装 node 环境跑一遍前端构建,代价是镜像体积再加几百兆、构建时间翻倍;二是直接下载发行版源码包(sdist 压缩包)解压,它自带预编译的静态资源,三百多兆,解压即用。我自己的选择是后者——我要改的是调度和 operator 逻辑,不是前端,没必要为此付出构建成本。只有确实要动 UI 的时候才切回 git 方式。
.dockerignore必须认真写,这一份文件直接决定了你的构建速度。至少把.git、node_modules、*.pyc、__pycache__、logs、tests、docs排除掉。tests目录在 Airflow 源码里体积不小,而源码部署验证补丁时通常也用不到全套单元测试,排掉能省下几十秒的上传时间。
2.3 硬件与 Docker 环境的底线
先说实话:全部容器加起来,8 GB 内存是起步价。webserver 一个进程大约 400 到 600 MB,scheduler 常驻 300 到 800 MB 并且会随着 DAG 数量增长,Celery worker 每个并发子进程 150 MB 起步,再加上 PostgreSQL 和 Redis,一台 8 GB 的开发机开worker_concurrency=4就已经在边缘试探了。构建阶段更吃内存,pip 编译psycopg2和cryptography的时候瞬时能冲到 1 GB 以上,内存不足会直接 OOM kill 掉构建进程,报错信息只有一个 Killed,很难往内存上想。
Docker 侧需要确认三件事:一是 Compose 用的是 v2 插件(docker compose带空格),不是老的docker-compose脚本,因为新版 compose 对depends_on: condition和 profile 的支持差别很大;二是开启 BuildKit,缓存挂载和并行构建都靠它;三是确认 CPU 架构,Apple 芯片和部分 ARM 服务器上拉postgres、redis官方镜像默认是 arm64 版本,一般没问题,但如果你从别处拷来一个 amd64 的自制镜像,会出现exec format error,看到这个报错先查架构,别急着怀疑代码。
3. Dockerfile 逐段拆解:把源码编译进镜像
3.1 基础镜像与系统依赖:哪些包是真不能省
基础镜像我用python:3.11-slim-bookworm这类“小版本 + 稳定发行版”的组合。用 slim 而不是 alpine,原因很实际:alpine 用 musl libc,psycopg2、cryptography、numpy这些带 C 扩展的包没有现成的预编译 wheel,全部要靠本地编译,构建时间从几分钟变成半小时,还经常因为缺头文件失败。slim 用 glibc,能直接吃 manylinux 的预编译 wheel,这个差别在依赖多的时候非常夸张。
系统依赖只装真正需要的,装完立刻清 apt 缓存,把/var/lib/apt/lists删掉,这是控制镜像体积最廉价的手段:
FROM python:3.11-slim-bookworm AS base ARG AIRFLOW_VERSION=2.9.3 ARG PYTHON_VERSION=3.11 ENV DEBIAN_FRONTEND=noninteractive \ 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 \ libssl-dev libffi-dev \ libpq-dev default-libmysqlclient-dev \ libsasl2-dev libkrb5-dev \ curl git ca-certificates procps \ && rm -rf /var/lib/apt/lists/*procps这个包值得单独提一句。它提供ps命令,Airflow 的部分内部逻辑和一些健康检查脚本会调用它,缺了不会立刻报错,但在容器里排查问题时你连ps aux都没有,会非常难受。
接着创建运行用户。绝不能让容器里跑 root,否则挂载目录里生成的文件在宿主机上全是 root 所有,你在本地编辑器里改不了、删不掉,只能sudo一把把,非常破坏节奏:
RUN useradd -ms /bin/bash -d ${AIRFLOW_HOME} -u 50000 airflow \ && mkdir -p ${AIRFLOW_HOME}/logs ${AIRFLOW_HOME}/dags ${AIRFLOW_HOME}/plugins \ && chown -R airflow:root ${AIRFLOW_HOME} USER airflow WORKDIR ${AIRFLOW_HOME}这里的 UID 50000 是 Airflow 官方约定俗成的值,后面.env里的AIRFLOW_UID要和它保持一致,否则又会出现权限错位。
3.2 用 constraints 把依赖树钉死
这一步是源码部署和“随便 pip install”最大的分水岭。Airflow 的依赖面极广,providers 加起来引用的第三方包有好几百个,如果不加约束直接装,pip 会去解一个巨大的 SAT 问题,运气好解出来一个能用的组合,运气不好解半小时然后告诉你依赖冲突。更糟的是第二次构建解出来的组合可能不一样,同一个 Dockerfile 两天后构建出两个行为不同的镜像,排查问题时完全无从下手。
官方每个 Airflow 版本都会发布对应的约束文件,里面把每个包的版本精确钉死。用法是在 pip 安装时加-c:
COPY --chown=airflow:root airflow-src/ ./airflow-src/ COPY --chown=airflow:root constraints.txt ./constraints.txt RUN pip install --upgrade pip setuptools wheel \ && pip install -e "./airflow-src" -c constraints.txtconstraints.txt的内容取决于你的 Python 版本和 Airflow 版本,命名规则一般是constraints-<airflow版本>/constraints-<python版本>.txt,从对应版本的发布页下载即可。这里有个硬性要求:约束文件的 Python 版本必须和镜像里的 Python 版本严格一致,用 3.11 的镜像配 3.10 的约束文件,pip 会装出一堆不兼容的包,然后在运行期给你莫名其妙的 ImportError。
如果你的环境在内网,pip 拉包很慢甚至拉不到,可以在 Dockerfile 里指定索引地址:
ARG PIP_INDEX_URL=https://your-internal-mirror/simple ENV PIP_INDEX_URL=${PIP_INDEX_URL}把它做成构建参数而不是RUN pip config set,好处是索引地址变了不用改代码,docker compose build --build-arg传进去就行。
3.3 可编辑安装、缓存与 .dockerignore
-e参数是不能省的。省掉它,pip 会把源码“复制”进 site-packages,你后面挂载源码目录就完全不生效了——容器里跑的永远是构建那一刻的代码副本。加上-e,你才有“改文件即生效”的能力。
接下来是最影响日常体验的一件事:构建缓存。pip 下载和编译的产物如果能跨构建复用,第二次改代码重新 build 只需要十几秒。开启 BuildKit 后用缓存挂载:
RUN --mount=type=cache,target=/home/airflow/.cache/pip,sharing=locked \ pip install -e "./airflow-src" -c constraints.txtsharing=locked是为了并发构建时不打架。要注意缓存的 key 和USER有关,上面这行必须放在USER airflow之后、把缓存目录指向 airflow 用户的家目录,否则缓存写到了 root 的目录下,下次构建换了用户就读不到。
还有一个小技巧:如果你只想验证一个很小的改动,完全可以不重新构建镜像,只是把源码挂载进去就够了(后面 5.2 会详细讲)。重建镜像的场景只有三种——改了系统依赖、改了依赖清单、改了 Python 版本。分清这三类,能省掉大量无谓的等待。
4. docker-compose 编排:六个容器怎么串成一台机器
4.1 健康检查决定了启动顺序
初学者最容易遇到的第一个报错是 scheduler 反复重启,日志里全是could not connect to server: Connection refused。原因很简单:容器是并行启动的,scheduler 起跑的时候 PostgreSQL 还在初始化数据目录。depends_on默认只保证“启动顺序”,不保证“就绪”,所以必须配健康检查。
我一般用 YAML 锚点把公共配置抽出来,避免六个服务里复制粘贴同一段环境变量:
x-airflow-common: &airflow-common image: local/airflow-src:2.9.3 build: context: . dockerfile: docker/Dockerfile env_file: .env environment: &airflow-env AIRFLOW__CORE__EXECUTOR: CeleryExecutor AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://airflow:airflow@postgres/airflow AIRFLOW__CELERY__BROKER_URL: redis://redis:6379/0 AIRFLOW__CELERY__RESULT_BACKEND: db+postgresql://airflow:airflow@postgres/airflow AIRFLOW__CORE__FERNET_KEY: ${FERNET_KEY} AIRFLOW__CORE__LOAD_EXAMPLES: "false" AIRFLOW__CORE__DAGS_FOLDER: /opt/airflow/dags volumes: - ./airflow-src:/opt/airflow/airflow-src - ./dags:/opt/airflow/dags - ./logs:/opt/airflow/logs - ./plugins:/opt/airflow/plugins user: "${AIRFLOW_UID:-50000}:0" depends_on: postgres: condition: service_healthy redis: condition: service_healthy数据库和队列的健康检查要选对命令。PostgreSQL 用pg_isready最准,别用curl探测端口了事,因为 TCP 通了不代表数据库完成了初始化:
postgres: image: postgres:13 environment: POSTGRES_USER: airflow POSTGRES_PASSWORD: airflow POSTGRES_DB: airflow volumes: - postgres-db:/var/lib/postgresql/data healthcheck: test: ["CMD", "pg_isready", "-U", "airflow"] interval: 5s retries: 10 start_period: 15s redis: image: redis:7 healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 5s retries: 10start_period这个参数很多人不写,但它在慢磁盘的机器上很关键——它告诉 Docker 在这段时间内的失败不计入重试次数,避免数据库还在初始化就被判定为不健康直接放弃。
4.2 初始化容器与执行器选型
airflow-init是一个只跑一次、跑完就退出的容器,负责建表、建管理员账号、生成基础配置。把它独立出来的好处是其他服务不用再写“如果表不存在就建表”的逻辑,职责清晰:
airflow-init: <<: *airflow-common entrypoint: /bin/bash command: - -c - | airflow db migrate airflow users create \ --username admin --password admin \ --firstname Air --lastname Flow \ --role Admin --email admin@example.com restart: "no" airflow-webserver: <<: *airflow-common command: webserver ports: - "8080:8080" depends_on: airflow-init: condition: service_completed_successfullyservice_completed_successfully这个条件很实用,它保证 webserver 一定等 init 正常退出后才起。老版本 compose 不支持,就得靠脚本轮询,能升级就升级。
执行器怎么选,我给一个朴素的判断标准:单人本地验证补丁,用LocalExecutor,少两个容器、少一层网络、日志只看一个地方,最省心;需要验证过 Celery 路径的改动(比如改过 task 序列化、改过队列路由),必须上CeleryExecutor,因为 LocalExecutor 根本不走 broker,你改的代码在本地环境里压根不会被执行到;KubernetesExecutor除非你要改的就是它,否则本地别碰,光搭一个能用的集群就够写另一篇文章了。
4.3 UID、权限与日志落盘
权限问题在源码部署里出现频率排前三。表现是:任务跑起来了,但日志页打不开,容器里看日志目录权限是drwxr-xr-x root root。原因是容器内进程以 root 或其他 UID 写入了挂载目录,宿主机上的目录所有者被改掉,之后 airflow 用户就写不进去了。
比较稳妥的做法是双管齐下:容器内固定用airflow用户跑,同时 compose 里显式指定user: "${AIRFLOW_UID:-50000}:0",.env里写AIRFLOW_UID=50000和镜像内的 UID 对齐。这样容器内写入的文件在宿主机上归属 50000,虽然你当前登录用户可能还是改不了,但至少容器之间是一致的,不会互相打架。宿主机上的处理办法很简单:sudo chown -R 50000:0 logs,或者干脆给 logs 目录chmod -R 777(本地开发环境,别在共享环境这么干)。
还有一个隐蔽的坑:entrypoint.sh如果是在 Windows 上编辑保存的,会带上 CRLF 换行,容器里执行时报bad interpreter: /bin/bash^M或者exec format error。这个报错看起来特别像架构不匹配,容易带偏思路。解决办法是.gitattributes里加*.sh text eol=lf,或者写完执行一次sed -i 's/\r$//' docker/entrypoint.sh。
5. 首次启动与验证:从 db migrate 到跑通第一个 DAG
5.1 完整启动流程与 env 文件
.env里有三个必须自己生成的值,尤其是 FERNET_KEY,它负责加密连接信息,不能随便写一串字符糊弄:
# 生成 fernet key python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"写进.env:
AIRFLOW_UID=50000 FERNET_KEY=上一步生成的字符串 AIRFLOW__WEBSERVER__SECRET_KEY=随便一串足够长的随机字符然后按顺序来:
docker compose build # 首次构建,5 到 15 分钟 docker compose up airflow-init # 单独跑初始化,看着它退出 docker compose up -d # 起全套 docker compose ps # 确认六个服务的状态都是 healthy 或 runningairflow-init单独跑而不是直接up -d,是因为它的输出里包含了建表和建用户的结果,出问题能第一时间看到。等它正常退出(exit code 0)再起全套,成功率会高很多。
起来之后先做三件事验证:
第一,docker compose exec airflow-scheduler airflow version,确认输出版本和你的源码版本一致。如果不一致,说明镜像里的旧版本没被覆盖,大概率是安装路径对不上。
第二,docker compose exec airflow-scheduler python -c "import airflow; print(airflow.__file__)",输出应该是/opt/airflow/airflow-src/airflow/__init__.py。如果是/usr/local/lib/python3.11/site-packages/...,说明可编辑安装没生效,代码是从 site-packages 加载的,你在宿主机改多少都没用。这一行命令是判断“源码部署成功与否”的唯一可靠标准,建议养成每次改完配置都跑一遍的习惯。
第三,打开localhost:8080,能看到登录页并成功登录,说明 webserver、元数据库、静态资源这条链路全通了。
5.2 改完源码如何让改动生效:挂载还是重建
这里要分情况,不同位置的改动生效方式完全不同:
| 改动位置 | 生效方式 | 是否需要重启 |
|---|---|---|
| DAG 文件 | 挂载自动同步 | 不需要 |
| plugins / 自定义 operator | 挂载自动同步 | 需要重启 scheduler 和 worker |
| Airflow 核心 Python 代码(scheduler、executor) | 挂载自动同步 | 必须重启对应服务 |
| 前端 static 资源 | 挂载同步 | 清浏览器缓存 + 重启 webserver |
| 系统依赖、pip 依赖 | 必须重新构建镜像 | 是 |
核心代码改完,重启命令是docker compose restart airflow-scheduler airflow-worker。注意restart不会重建容器,环境变量改动它是不认的——改.env里的配置项必须docker compose up -d让它检测到配置差异后重建容器。这个差别我踩过不止一次,改了半天环境变量不生效,最后发现是restart用错了。
Celery 环境下还有一个额外的坑:worker 的内存里缓存了旧的模块,restart之后新进程重新导入源码,改动就能生效;但如果你的改动涉及 task 的序列化格式,队列里残留的旧消息会导致反序列化失败,这时候只能docker compose down再up,把 broker 里的残留清掉。
5.3 调试手段:exec、tasks test 与远程断点
容器里的调试体验其实比很多人想的好。最常用的三个动作:
# 进容器看现场 docker compose exec airflow-scheduler bash # 不经过调度器,直接单跑一个 task,带日志 docker compose exec airflow-scheduler airflow tasks test my_dag my_task 2024-01-01 # 看某任务的完整日志 docker compose logs -f airflow-worker --tail=200airflow tasks test是我最推荐的验证方式。它跳过调度、不写入正式的任务实例状态,纯粹把 operator 执行一遍,所有 print 和 logging 直接输出到终端,看堆栈特别方便。改完一个 operator 想验证行为,用这个命令比在 UI 上点“运行”快十倍。
如果逻辑复杂、需要断点,可以在容器里装调试器并监听端口,compose 里加ports: ["5678:5678"],然后在源码里插debugpy.listen(("0.0.0.0", 5678))和debugpy.wait_for_client(),本地 IDE 连上去就能单步。这条路在排查 scheduler 内部循环的时候几乎是唯一选择——你不可能靠 print 看懂调度器为什么没触发某个 DAG。
6. 踩坑实录:构建期与运行期的高频故障
6.1 构建期:依赖编译失败与镜像膨胀
构建期最典型的问题是 pip 装到一半挂了,报包编译失败。绝大多数情况是缺系统头文件,按报错里的包名反查就行:psycopg2缺libpq-dev,cryptography缺libssl-dev和libffi-dev,python-ldap缺libldap2-dev和libsasl2-dev,krbcontext缺libkrb5-dev。一个通用的排查套路是把报错的包单独拎出来pip download看它有没有对应平台的预编译 wheel,有 wheel 却还在编译,说明你的 Python 版本太新,官方还没为它出 wheel。
第二个是镜像体积失控。常见原因是pip install时没加--no-cache-dir(或者用了缓存挂载但没在最后清理)、apt 装完没删/var/lib/apt/lists、把整个.git目录 COPY 进了镜像。前两个好解决,第三个要在.dockerignore里明确写.git。我见过一个 2.8 GB 的镜像,光.git就占了 400 多 MB。
第三个是构建缓存在不该命中时命中了。COPY指令会让后续层全部失效,所以COPY constraints.txt和COPY airflow-src的顺序是有讲究的:依赖清单先 COPY、安装依赖,源码后 COPY。这样只改代码不改依赖时,安装依赖那一层能命中缓存。如果你的 Dockerfile 顺序反了,每次改一行代码都要重装几百个包,体验会是灾难性的。
6.2 运行期:权限、连接、DAG 解析
运行期的坑更分散,我按出现频率排一下。
排第一的是 DAG 解析失败,报ModuleNotFoundError: No module named 'xxx'。这个几乎百分百是“DAG 里 import 的包没装进镜像”。源码部署的人容易有个思维惯性:以为改了源码就万事大吉,忘了 DAG 依赖的第三方包还是要写在依赖清单里。排查方法是进容器pip list | grep xxx确认,没有就补进 Dockerfile 重建。
排第二的是 webserver 起来了但登录报CSRF token错误或者会话一直失效。这是SECRET_KEY没设或者多个 webserver 副本用了不同 key 导致的。单机开发设一个固定值就行,别用默认的随机生成。
排第三是任务卡在 queued 一直不动。先看 worker 活着没有(docker compose ps),再看 worker 有没有注册到 broker 上(docker compose exec airflow-worker airflow celery inspect active),最后看队列名对不对得上——DAG 里queue="xxx"指定了队列,但 worker 只监听default队列,任务就会永远排队。Celery 的队列是个“软约定”,错配不报错,只会安静地卡住。
排第四是时区问题。默认时区是 UTC,UI 上显示的时间和你本地差 8 小时,做日期相关的调度验证时很容易算错。要不要改成东八区是个有争议的话题,我的做法是不改,训练自己看 UTC 的习惯,因为跨时区的任务混跑时,改成本地时区反而会引入更隐蔽的错误。
6.3 疑难问题速查表
| 现象 | 最可能的原因 | 处理动作 |
|---|---|---|
容器起不来,报exec format error | 镜像架构与宿主机不符 | docker image inspect查 Architecture,重建或换镜像 |
报bad interpreter: /bin/bash^M | 脚本带 CRLF 换行 | 转成 LF,或用sed -i 's/\r$//'处理 |
| scheduler 反复重启,日志连不上数据库 | 没有健康检查,启动顺序错乱 | 加pg_isready健康检查 +service_healthy依赖 |
import airflow指向 site-packages | 可编辑安装失效 | 核对挂载路径与构建路径完全一致 |
| 任务日志页 404 | logs 目录权限不对 | 统一 UID,chown挂载目录 |
| 改了核心代码没反应 | 只 restart 没生效,或加载的是 site-packages | 先跑airflow.__file__定位,再重启对应服务 |
| worker 一直空闲但任务排队 | 队列名不一致 | 核对 DAG 的 queue 与 worker 监听队列 |
| UI 白屏 | 源码缺前端静态资源 | 换用 sdist 源码包,或补前端构建步骤 |
| 建表报已经存在 | 元数据库里残留旧数据 | docker compose down -v清卷重来 |
down -v这个动作要谨慎,它会删掉数据库卷,本地开发环境无所谓,但凡有共享的元数据库,一条命令能把所有历史任务状态清空。我自己的习惯是在.env里技术上没法区分,那就靠项目名隔离:不同项目用docker compose -p projectA起,卷名带上项目前缀,误删概率大幅降低。
7. 让这套环境长期好用的几个配置
7.1 参数调优与资源配额
本地环境不需要像生产那样精调,但有几个参数不改会明显影响体验。AIRFLOW__CORE__PARALLELISM控制整个 Airflow 的最大并行任务数,默认值对本地来说偏大,机器一卡就是内存爆掉,改成 8 到 16 比较合适。AIRFLOW__CORE__MAX_ACTIVE_TASKS_PER_DAG也压到 4 以内,防止一个 DAG 把整个 worker 池吃光。
调度器的解析频率也值得调。AIRFLOW__SCHEDULER__MIN_FILE_PROCESS_INTERVAL默认 30 秒,本地只有几十个 DAG 的时候可以压到 10 秒,改完代码很快就能在 UI 上看到变化。反过来,如果你的dags目录挂载在宿主机上、宿主机磁盘很慢(比如某些网络盘),那就得往大调,否则 scheduler 会一直忙于解析,CPU 打满但任务一个都没触发。
Celery worker 的并发数AIRFLOW__CELERY__WORKER_CONCURRENCY建议从 2 或 4 起步。这个值的经验算法是“可用内存减去其他容器占用,再除以每个子进程的峰值内存”,一个跑 pandas 的任务峰值可能 500 MB,那 8 GB 的机器开到 4 就已经很危险了。别贪多,本地并发大了只会让你更慢。
7.2 版本升级与清理
源码部署的好处之一是升级直观:把airflow-src换成新版本、换掉constraints.txt、重建镜像,三步。但要注意元数据库的迁移方向——升级前先备份数据库卷,然后跑airflow db migrate,如果新版本有破坏性变更,migrate 会明确告诉你。我一般会留一个postgres-db-old的卷做快照,出问题三十秒回滚。
日常清理主要盯三样东西。第一是 pip 缓存挂载,BuildKit 的缓存不会自动清理,几个月能攒出好几 G,docker builder prune定期跑一次。第二是logs目录,任务日志会无限增长,本地环境我直接配一个定时脚本,超过七天的一律删。第三是悬空镜像,每次重建都会留下一个<none>标签的旧镜像,docker image prune -f顺手清掉,不然磁盘很快就满了。
最后分享一个我一直在用的小技巧:在项目根目录放一个Makefile,把build、init、up、down、logs、shell、test-task都包成一行命令。源码部署的参数多、路径多、顺序多,靠记忆输入迟早出错,把这些固定动作沉淀成命令,半年后回来接手的人(也包括未来的自己)能少踩很多坑。
这套环境我目前在两台机器上用着,一台笔记本做日常验证,一台工作站跑数据量稍大的回归。最大的感受是,把“改代码—重启—验证”这条链路压到十几秒之后,人才有耐心去真的读源码,而不是遇到问题就绕道。这大概是源码部署相对官方镜像唯一但足够充分的理由。