1. 先想清楚为什么要用源码方式部署 Airflow
1.1 官方镜像够用,那为什么还要自己构建
docker 源码部署 airflow 这件事,我第一次做的时候纯粹是被逼的。当时团队的调度需求很朴素:每天几十个批处理任务,跑在几台机器上,crontab 已经维护到没人敢动。官方镜像拉下来十分钟就能跑起来,界面能打开,示例 DAG 能跑,看起来很美好。但真正把业务 DAG 放进去的第二天就卡住了:我们需要在任务里读写对象存储、调用内部服务的 SDK、还要用一个特定版本的数据处理库。这些东西官方镜像里没有,pip install装在运行中的容器里,容器一重启全没了。
这就是源码部署最直接的动机——你的依赖不在官方镜像的射程范围内。官方镜像的设计目标是“通用可运行”,它内置的是 Airflow 本体加上一组常见 provider,绝不会为你的业务 SDK 买单。你当然可以在启动脚本里临时pip install,但那样每次重启都要联网重装,构建时间不可控,版本也不可控。把依赖写进 Dockerfile 打成镜像,是最朴素也最可靠的解法。
除了依赖,还有三类常见诉求会把人推向源码构建。第一类是版本锁定,团队的安全基线要求基础镜像统一、系统包统一、Python 补丁版本统一,官方镜像的构建节奏你说了不算。第二类是裁剪体积,官方镜像塞了不少你可能永远用不到的东西,自建可以把镜像压到合理范围(虽然通常也就省个一两百兆,别指望质变)。第三类是可审计,出了问题能顺着 Dockerfile 一行行往回查,而不是去猜镜像里到底装了什么。
注意:源码部署不等于编译 Airflow 的 C 扩展。绝大多数情况下你还是
pip install apache-airflow,只是把安装动作搬到了自己的 Dockerfile 里,可控、可复现、可缓存。别被“源码”两个字吓住。
1.2 三种部署路径的取舍对比
在动手之前,值得把可选路径摆开看一眼。我见过不少团队一上来就上 Kubernetes,结果两周都在和权限、存储类、镜像仓库搏斗,业务 DAG 一行没写。选择编排方式应该由当前规模和团队运维能力决定,而不是由技术热度决定。
| 路径 | 适用场景 | 上手成本 | 运维负担 | 主要痛点 |
|---|---|---|---|---|
| 单机 docker run | 本地调试、概念验证 | 极低 | 低 | 多进程编排靠手写脚本,不可维护 |
| docker compose | 中小规模、单机或单节点 | 低 | 中 | 单点故障,扩容靠换机器 |
| 容器编排平台 | 多团队、任务量大、需要弹性 | 高 | 高 | 前期投入大,概念多 |
我的建议很明确:先用 compose 把整条链路跑通,再考虑要不要往上走。compose 的好处是它把“有哪些进程、各自什么环境变量、谁依赖谁”这件事写得明明白白,这份理解是可迁移的。等你换到更复杂的编排平台时,你会发现要写的还是那些东西,只是表达方式变了。反过来,如果你直接从最复杂的方案起步,一旦跑不起来,你连排查的起点都找不到。
还有一条容易忽略的路径:Airflow 自带的airflow standalone单进程模式。它在本地开发时很好用,一条命令起全套,但它把调度器、Web 服务塞在一个进程里,和生产形态差得太远。我用它只做一件事——验证某个 DAG 的语法和依赖导入是否有问题,验证完立刻切回 compose。
1.3 目录结构与职责划分
动手前先把目录定下来,后面所有操作都围绕它展开。我的习惯是这样:
airflow-deploy/ ├── Dockerfile ├── docker-compose.yaml ├── .env ├── requirements.txt ├── config/ │ └── airflow.cfg # 可选,需要覆盖大量配置时才用 ├── dags/ # DAG 文件,挂载进容器 ├── logs/ # 日志目录,挂载进容器 ├── plugins/ # 自定义插件 └── scripts/ └── entrypoint.sh # 可选,初始化逻辑较重时用这里有个关键决策:dags、logs、plugins 三个目录一定要挂载出来,不要打进镜像。DAG 是业务代码,迭代频率远高于镜像,打进镜像意味着改一行就要重新构建、重新分发、重新滚动,完全是自找麻烦。日志挂载则是为了排查方便,容器删了日志还在,出问题时能翻历史。
config/airflow.cfg我一般先不建。Airflow 的配置体系支持用环境变量覆盖,格式是AIRFLOW__SECTION__KEY,比如AIRFLOW__CORE__PARALLELISM。中小规模部署用环境变量完全够用,而且配置跟着 compose 文件走,一眼能看全。只有当配置项超过三四十个、或者需要写多行配置时,我才会把 cfg 文件拎出来单独管理。
提示:
AIRFLOW_HOME统一设成/opt/airflow,这是官方镜像的约定,保持一致的能省掉很多路径问题。自己定义路径也行,但要在 Dockerfile 里把权限一并处理好。
2. 镜像构建:把依赖稳稳地钉住
2.1 Airflow 各进程的职责边界
在写 Dockerfile 之前,得先弄清楚一个 Airflow 部署到底跑着几个进程,否则你连 compose 里该定义几个服务都不知道。核心角色有这么几个:
scheduler是大脑,负责解析 DAG 文件、判断哪些任务到了该跑的时间、把任务实例推到执行队列里。它内部又有几个子循环:调度循环、任务执行循环、DAG 解析循环。解析 DAG 是最容易出问题的一环,因为它在独立的子进程里跑,你的 DAG 里任何一行顶层代码报错,都会导致这个 DAG 从界面上消失。
webserver是界面,只读元数据库展示状态,同时提供 REST API。它不参与调度决策,所以它挂了不影响任务执行,但你会失去观测能力。
triggerer负责处理延迟(deferrable)算子。很多新版算子的可延迟模式依赖它,比如某些数据库算子、外部任务算子。不启动它,这些算子会一直卡在排队状态。
dag-processor在较新版本里被独立出来,专门负责解析 DAG 文件,把解析结果写进元数据库,scheduler 只管调度。这个拆分很实用——解析慢或解析崩溃的 DAG 不会拖累整个调度循环。
worker只在分布式执行器下存在,负责真正执行任务。用本地执行器时没有这个角色,任务直接在 scheduler 进程内跑。
metadata database是唯一的真相来源,所有状态都存在这里。它挂了,整套系统就停了,重要性排第一。
这六个角色里,前四个在 compose 里各起一个容器,数据库单独起一个,worker 视执行器而定。理清这张图,后面的服务定义就是填空题。
2.2 Dockerfile 逐层拆解
我的 Dockerfile 通常长这样,从上往下拆着说:
FROM python:3.11-slim-bookworm ARG AIRFLOW_VERSION=2.9.3 ARG PYTHON_VERSION=3.11 ARG AIRFLOW_UID=50000 ENV AIRFLOW_HOME=/opt/airflow \ PYTHONUNBUFFERED=1 \ PIP_NO_CACHE_DIR=1 \ PIP_DISABLE_PIP_VERSION_CHECK=1 \ PATH="/home/airflow/.local/bin:${PATH}" RUN apt-get update \ && apt-get install -y --no-install-recommends \ build-essential \ libpq-dev \ curl \ git \ tzdata \ && rm -rf /var/lib/apt/lists/* RUN useradd -m -u ${AIRFLOW_UID} -s /bin/bash airflow RUN pip install "apache-airflow==${AIRFLOW_VERSION}" \ --constraint "https://raw.githubusercontent.com/apache/airflow/constraints-${AIRFLOW_VERSION}/constraints-${PYTHON_VERSION}.txt" COPY requirements.txt /tmp/requirements.txt RUN pip install -r /tmp/requirements.txt \ --constraint "https://raw.githubusercontent.com/apache/airflow/constraints-${AIRFLOW_VERSION}/constraints-${PYTHON_VERSION}.txt" RUN mkdir -p ${AIRFLOW_HOME}/dags ${AIRFLOW_HOME}/logs ${AIRFLOW_HOME}/plugins \ && chown -R airflow:root ${AIRFLOW_HOME} USER airflow WORKDIR ${AIRFLOW_HOME}第一层基础镜像选slim-bookworm而不是alpine。原因很实际:Airflow 依赖链里有一堆需要编译的包,alpine 用的是 musl libc,很多轮子没有预编译版本,pip install会退化到源码编译,构建时间从三分钟变成半小时,还可能因为缺少头文件直接失败。slim 系列基于 glibc,绝大多数包都有现成的 manylinux 轮子,省心太多。代价是镜像大几十兆,我认为完全值得。
系统包这块,build-essential和libpq-dev是常客。前者提供编译工具,后者是 PostgreSQL 客户端库的头文件——连 Postgres 的 Python 驱动通常有轮子,但某些版本或者某些环境下会退到源码编译。curl用来做健康检查,tzdata别忘了,容器默认是 UTC,你的调度时区如果设成东八区而没有时区数据,行为会很诡异。
后面留一句删 apt 缓存,这是常规操作,能省几十兆。
2.3 constraints 文件是这个流程里最关键的一环
很多人第一次自建镜像失败,都栽在依赖冲突上。pip install apache-airflow之后不报错,等到导入某个 provider 或者跑某个 DAG 时,才跳出一堆版本不兼容的报错。根子在于 Airflow 的依赖矩阵非常复杂:本体依赖一批库,每个 provider 又各自依赖一批,这些依赖之间还有版本交叉约束。你自己pip install一个业务库,很容易把某个已经装好的包升级到不兼容的版本。
官方的解法是constraints 文件:一份在特定 Python 版本、特定 Airflow 版本下经过完整测试的版本锁定清单。你用--constraint把它挂上去,pip 在解析依赖时就会优先满足这份清单,不会乱升级。上面 Dockerfile 里那个 URL 是标准拼法,路径包含 Airflow 版本号和 Python 版本号两段,改版本时两处都要跟着改。
顺带说一个坑:requirements.txt里如果写死了某个和 constraints 冲突的版本,pip 会报错退出,构建直接失败。这是好事,早失败早发现。麻烦的是错误信息往往很长,你得往上翻找到ResolutionImpossible那一段,看是哪两个包在打架。我的处理原则是业务库优先,让 constraints 让步——把冲突的包从 requirements 里挑出来单独装,不带 constraint,然后手动验证 Airflow 还能正常起。这种做法有风险,仅限你确认那个包碰不到 Airflow 的核心依赖链。
注意:constraints 文件里的版本会随 Airflow 补丁版本变化。同一套 requirements 在 2.9.2 上能装,换到 2.9.3 可能就冲突了。所以 Dockerfile 里
AIRFLOW_VERSION一定要显式写死,绝不要用latest或者不指定版本,否则构建结果不可复现,今天是好的明天就炸。
2.4 requirements 怎么写才不给自己挖坑
requirements.txt我一般只放三类东西:业务需要的 SDK、provider 包、以及必要的补丁库。写法上有几条经验:
第一,能写死的都写死。requests>=2.28这种写法在本地开发很舒服,在镜像构建里就是隐患。约束文件已经帮你锁了一层,你自己的业务库再锁一层,双重保险。
第二,provider 包单独列出。Airflow 的功能扩展都拆成 provider 了,比如操作某种数据库、对接某个云服务、发消息通知。这些包不装,对应算子导入时直接报ModuleNotFoundError。写的时候注意版本要和 Airflow 本体对齐,一般装和本体版本相近的 provider 版本比较稳。
第三,不要把测试、开发依赖塞进生产镜像。pytest、black、mypy这些东西进了镜像,体积上去了,安全扫描还会给你报一堆 CVE。真要跑单元测试,用多阶段构建或者单独建一个开发镜像。
关于构建缓存,这里有个小技巧:把COPY requirements.txt放在RUN pip install之前,把业务代码和 DAG 放在之后。这样改业务代码不会击穿依赖层的缓存,重建只需几秒。很多人把整个目录COPY .放在最前面,结果每次改个 DAG 都要重装一遍依赖,构建时间从十秒变成十分钟,非常折磨。
3. 编排文件与初始化流程的落地细节
3.1 compose 服务定义的整体思路
compose 文件里最重要的不是服务写得多全,而是依赖关系写得对。Airflow 的启动有严格顺序:数据库先就绪,然后跑数据库迁移,迁移完成后 webserver、scheduler、triggerer 才能起。顺序错了,报错信息往往很含糊,你会花大量时间在错误的方向上排查。
我用一个一次性服务airflow-init来承担初始化职责,其余服务都depends_on它并且要求它成功退出。这是 compose 里表达“必须先跑完”的标准做法。相比在 entrypoint 里写一堆sleep 30之类的等待逻辑,这种写法确定性强得多。
数据库健康检查也要配上。Postgres 官方镜像支持pg_isready,用它做 healthcheck,然后让airflow-init依赖数据库的service_healthy状态。这样能避免“数据库容器起来了但还没接受连接”的经典时序问题。
还有一个细节:所有 Airflow 服务共享同一份环境变量。compose 1.x 时代靠 YAML 锚点,现在直接用x-airflow-common扩展字段加<<:合并就能做到。这样改一处配置,全部服务跟着变,不会出现 scheduler 和 webserver 连了不同数据库这种低级事故。
x-airflow-common: &airflow-common image: airflow-custom:2.9.3 env_file: - .env environment: AIRFLOW__CORE__EXECUTOR: LocalExecutor AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://airflow:airflow@postgres/airflow AIRFLOW__CORE__FERNET_KEY: ${FERNET_KEY} AIRFLOW__WEBSERVER__SECRET_KEY: ${SECRET_KEY} AIRFLOW__CORE__LOAD_EXAMPLES: "false" AIRFLOW__CORE__DAGS_ARE_PAUSED_AT_CREATION: "true" volumes: - ./dags:/opt/airflow/dags - ./logs:/opt/airflow/logs - ./plugins:/opt/airflow/plugins user: "${AIRFLOW_UID}:0" depends_on: postgres: condition: service_healthyuser这一行很关键,下面单独说。LOAD_EXAMPLES关掉,示例 DAG 有一堆,第一次进界面满屏都是,对新人不友好,而且会拖慢解析。DAGS_ARE_PAUSED_AT_CREATION设成 true,新 DAG 默认暂停,防止有人扔个schedule="* * * * *"上来直接把数据库打爆。
3.2 执行器怎么选:先别急着上分布式
LocalExecutor和CeleryExecutor的选择是新手最容易做错的决定。我看到很多人在单机部署上直接选分布式,然后为了跑通还要额外起一个消息队列容器、配一个结果后端,整个链路多出两个故障点,最后卡在某个连接问题上出不来。
我的判断标准很硬:单机运行、任务总量每天几百个以内、单任务时长不超过半小时,就用 LocalExecutor。它的工作方式是 scheduler 进程内起一个进程池,任务直接在本地跑,没有跨节点通信,也没有序列化传输的开销。配置简单到只需要一个数据库连接。并发度由AIRFLOW__CORE__PARALLELISM(全局并行任务数)和AIRFLOW__CORE__MAX_ACTIVE_TASKS_PER_DAG(单 DAG 并行数)控制,按机器核数给个值就行,比如 4 核机器给parallelism=16,留出余量给调度循环。
什么时候该换 Celery?任务量大到单机吃不下、需要横向扩容、或者有长任务会占满进程池导致其他任务饿死。这时候才引入消息队列作为任务分发通道,worker 可以多副本。代价是链路变长、排查变难、还要考虑队列积压和 worker 掉线。
提示:从 LocalExecutor 切到 Celery 的迁移成本很低,本质上就是把
AIRFLOW__CORE__EXECUTOR改掉、加一个消息队列服务、把 worker 服务定义加上。所以先跑 Local,别提前优化。
3.3 环境变量里那几个必须手动生成的项
有两个值绝对不能用默认值,必须自己生成:
FERNET_KEY用于加密连接凭证里存的密码。生成方式:
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"这个值一旦设定就不要再改。改了之后所有已加密的连接密码都解不开,界面上会显示解密失败,你得把所有连接重新录一遍。我第一次踩这个坑是把.env里的值当成临时占位符改了,结果第二天所有数据库连接的密码全废。
SECRET_KEY用于 Web 服务的会话签名。换掉它不会丢数据,但所有已登录会话会失效,用户要重新登录。生产环境必须设成随机值,不设的话每次重启都会随机生成,用户就得反复登录。
另外还要注意AIRFLOW__WEBSERVER__BASE_URL和AIRFLOW__WEBSERVER__ENABLE_PROXY_FIX,如果你前面挂了反向代理,这两个值不配对,界面里的链接会出现跳转到内部地址的情况。
3.4 文件属主:uid 对齐这件事必须一次做对
容器里跑的是airflow用户,uid 50000。宿主机上挂载出来的dags、logs目录如果属于宿主机用户(比如 uid 1000),容器里的 airflow 用户就没法写。表现是 scheduler 日志里刷PermissionError,或者任务日志在界面上点开是空的。
标准解法是在 compose 里显式指定user: "${AIRFLOW_UID}:0",然后在.env里设AIRFLOW_UID=50000,同时把宿主机目录的属主改成 50000:
mkdir -p ./dags ./logs ./plugins sudo chown -R 50000:0 ./dags ./logs ./plugins组 id 用 0(root 组)是个折中做法,因为容器里的 airflow 用户主组就是 root。这样宿主机上你用自己的账号也能读写(如果你的账号在 root 组里),容器里也能写,两边都不别扭。
这个事情的麻烦之处在于它不会立刻暴露。你启动服务,一切正常,界面上能看到 DAG 列表,然后过了两天某个任务的日志点开是空白的,才想起来权限有问题。建议在初始化阶段就把权限写死,别指望后面补。
3.5 初始化服务的完整命令序列
airflow-init这个一次性服务干三件事,顺序不能乱:
airflow-init: <<: *airflow-common entrypoint: /bin/bash command: - -c - | set -e airflow db migrate airflow users create \ --username admin \ --firstname Air \ --lastname Flow \ --role Admin \ --email admin@example.com \ --password admin airflow connections delete 'airflow_db' || true airflow connections add 'airflow_db' \ --conn-type 'postgres' \ --conn-host 'postgres' \ --conn-schema 'airflow' \ --conn-login 'airflow' \ --conn-password 'airflow' \ --conn-port 5432 restart: "no"airflow db migrate是较新版本的命令,老版本叫airflow db init。前者支持增量迁移,后者每次都会检查表结构再决定是否建表,行为略有不同。用新版就选migrate,升级 Airflow 版本时同样用它来应用新的表结构变更。
airflow users create在用户已存在时会返回非零退出码,配合set -e会导致容器失败,进而让整个初始化流程失败。第一次跑没问题,第二次docker compose up就会挂。稳妥的做法是先查再建,或者在命令后面加|| true。我一般写成:
airflow users create ... || echo "user exists, skip"airflow connections add同理,重复添加会报错,先delete一下再add是最省心的写法。这个连接的用途是让某些需要通过 Airflow 连接去访问元数据库的算子能正常工作,比如查调度状态的监控任务。不配也能跑,但配上有备无患。
4. 启动、验证与日常运维的实操记录
4.1 首次启动的正确姿势与验证清单
初始化完之后再起常驻服务。我习惯的启动顺序是:
docker compose up airflow-init docker compose up -d postgres airflow-webserver airflow-scheduler airflow-triggerer docker compose ps docker compose logs -f airflow-scheduler第一次启动别急着-d,把 scheduler 的日志挂在前面看,能提前发现大量问题。正常情况下你会看到解析 DAG 的进度、调度循环的心跳、以及周期性的“没有到期任务”之类的信息。如果日志里反复出现某个 DAG 的解析错误,那就是你的 DAG 文件有顶层代码问题。
验证清单我固定跑这几项:
| 验证项 | 命令 | 期望结果 |
|---|---|---|
| Web 服务存活 | curl -f http://localhost:8080/health | 返回healthy |
| 元数据库连通 | airflow db check | 输出连接正常 |
| 调度器心跳 | 界面右上角调度器状态 | 显示心跳时间且持续更新 |
| 任务可执行 | 跑一个空任务的示例 DAG | 状态从 queued 变 success |
| 日志可写 | 点开任务日志 | 有内容且可下载 |
airflow db check这个命令很好用,它只会尝试连一下数据库并做最简单的查询,不依赖调度器。数据库配错的时候,用它比看一堆日志快得多。
那个空任务 DAG 我建议每套新部署都跑一遍,就用最简单的两三个EmptyOperator,串成一条链。它能验证解析、调度、执行、状态回写、日志写入这整条链路。链路里任何一环挂了,这个 DAG 都跑不完整。
4.2 并发参数怎么估:给一个可套用的算法
并发参数乱给是另一个常见问题。给太小任务排队排到天荒地老,给太大把机器内存吃满。我的估算方法是三步:
第一步,算单任务内存占用。挑几个典型的业务 DAG,在跑的时候docker stats看容器内存峰值,估个平均值。假设平均每个任务占 300MB。
第二步,算机器可用内存。容器内存限制减掉数据库、Web 服务、scheduler 本身的常驻开销。假设宿主 16GB,数据库给 2GB,其他服务 2GB,剩 12GB 给任务,按 300MB 算,大概能同时跑 40 个。
第三步,留出安全余量。实际配置打七折,也就是parallelism=28左右,再按 DAG 的重要程度分配单 DAG 并发。max_active_tasks_per_dag一般给 4 到 8,避免单个大 DAG 把池子占满。
三个参数之间是有约束的:parallelism必须大于等于所有 DAG 的max_active_tasks_per_dag之和的一半以上,否则永远有 DAG 轮不上。如果发现某个 DAG 长期处于排队状态,先看是不是它自己的max_active_tasks_per_dag给太小,再看全局池子是不是满了。
还有一个池子(pool)机制值得用起来。默认的default_pool有 128 个槽位,实际上你可以给不同类型的任务建独立池,比如给重任务一个 8 槽的池,给轻任务一个 32 槽的池。这样重任务不会把轻任务堵死,是很便宜的隔离手段。
4.3 DAG 热更新与日志管理
DAG 目录挂载出来之后,改动是实时的。你把文件扔进./dags,scheduler 会在下一轮解析周期(默认 30 秒左右)扫到并更新。这个体验非常好,但有两个坑。
第一个坑是删除的 DAG 不会立刻消失。它会在元数据库里留下历史记录,界面上仍能看到但显示为已删除状态。要彻底清掉得靠元数据库清理命令,或者等清理策略生效。别指望删文件就干净了。
第二个坑是解析失败会导致 DAG 从列表里消失。界面上突然少了几个 DAG,八成是文件里有语法错误或者导入失败。去看 scheduler 日志,搜 DAG 文件名,能看到具体异常。这种情况最坑的是它不报错到界面上,新人会以为是调度器坏了。
日志管理必须提前规划。容器里的日志目录挂载到宿主机,但 Airflow 自己不会删日志,跑几个月./logs目录能涨到几十 G。两个办法:一是在 compose 里给日志目录所在的卷设配额(如果文件系统支持),二是加一个定期清理的任务。我的做法是每天跑一个清理 DAG,删掉 30 天前的任务日志目录,用一个简单的find命令就行。
日志按dag_id/task_id/执行时间/attempt=1.log这样的层级存放,结构很规整。清理的时候按时间戳筛,别按 dag_id 删,否则会把正在跑的任务日志一起干掉。
提示:如果日志量特别大,考虑把日志写到对象存储,Airflow 支持配置远程日志后端。但这会增加一层依赖,小规模部署没必要,本地磁盘加定期清理完全够用。
5. 常见问题与排查实录
5.1 构建和启动阶段的高频故障速查
这套流程我大概搭过七八次,每次都会遇到几个固定的问题。整理成表,按现象查就行:
| 现象 | 大概率原因 | 排查动作 | 处理方式 |
|---|---|---|---|
构建时ResolutionImpossible | requirements 与 constraints 冲突 | 看报错末尾的冲突包对 | 从 requirements 摘出冲突包单独装,或降级业务库版本 |
| 容器启动后立刻退出 | 初始化命令非零退出 | docker compose logs airflow-init | 常见是用户已存在、连接已存在导致的重复创建 |
| Web 界面打不开 | 未绑定 0.0.0.0 或端口冲突 | 看 webserver 日志有无监听信息 | 检查端口映射和WEB_SERVER_HOST配置 |
| DAG 列表空白 | 解析失败或目录无权限 | scheduler 日志搜 DAG 文件名 | 修正导入错误,或改目录属主 |
| 任务一直排队 | 池子满或并发参数过小 | 界面看池子占用情况 | 调大parallelism或扩容池子 |
| 任务日志空白 | 日志目录权限问题 | 进容器ls -l看属主 | 统一 uid,重建目录权限 |
有一类问题表里没列,但非常典型:镜像构建成功,容器里却导不进刚装的包。原因通常是装到了错误的解释器环境。pip在某些基础镜像里指向的是系统 Python,而airflow命令用的是另一个。解决办法是在 Dockerfile 里用python -m pip install而不是裸pip,保证装的解释器和跑的解释器是同一个。这个坑我踩过一次,排查了两个小时,最后发现which pip和which airflow指向的 Python 路径不一样。
5.2 时间与时区:一个容易被忽视的隐性故障
Airflow 内部所有时间都是 UTC 存储的,这点毋庸置疑。但你的 DAG 里如果写了datetime.now(),拿到的是容器本地时间,容器默认又是 UTC,如果业务方以为那是北京时间,就会出现“任务晚了八小时”的误判。
正确的做法是在 DAG 里统一用pendulum.now("Asia/Shanghai")或者datetime.now(timezone.utc),永远带时区信息,永远不用裸的datetime.now()。Airflow 在很多地方会检查时间对象是否带时区,不带的话会直接报错或者给出警告。
容器时区本身可以通过挂载/etc/localtime或者设TZ环境变量改,但我不建议改。让容器保持 UTC,业务时区在代码里显式处理,这样最不容易出错。你改容器时区之后,某些依赖库的行为会跟着变,反而更难排查。
界面上显示的时区是另一回事,那是给用户看的,在配置里设AIRFLOW__WEBSERVER__DEFAULT_UI_TIMEZONE就行。这只影响展示,不影响调度计算。
5.3 升级路径:别在生产上原地重建
版本升级是最容易出事的时候。我的固定流程是:先在一个副本环境里把新版本的镜像构建出来,用同一份元数据库的备份跑一遍airflow db migrate,确认没有报错、界面能打开、关键 DAG 能正常解析和调度,再动生产。
升级时有几个必查项:constraints 文件换了没有(版本号跟着变)、provider 包版本和本体是否匹配、废弃的配置项有没有被移除。我曾经遇到过一个配置在新版本被重命名,旧配置项被静默忽略,结果调度间隔从每分钟变成了每小时,跑了两天才发现。
数据库备份必须做,而且要在迁移之前做。pg_dump一份完整的,放好。迁移命令虽然大多是可回滚的,但真出问题时,从备份恢复是最快的路。
还有一点:不要用latest标签。镜像 tag 用airflow-custom:2.9.3这种带版本号的形式,回滚时直接切 tag 就行。用latest的话,你连现在跑的是哪个版本都说不清。
6. 我在实际部署中攒下的几条经验
这套部署方式我用了挺久,最大的感受是:所有让人痛苦的问题,几乎都来自“没提前想清楚”。uid 没对齐、constraints 没用上、并发参数拍脑袋、日志没清理策略,这些都是十分钟能想明白、但事后要花几小时补的事。
如果只留三条建议,我会说:一是把AIRFLOW_VERSION和 Python 版本写死,constraints 文件一定要挂,构建结果才可复现;二是初始化逻辑单独抽成一个一次性服务,把数据库迁移、建用户、建连接全塞进去,跑完就退出,别混在常驻服务里;三是所有挂载目录的权限在第一次docker compose up之前就调好,别等出问题再回头补。
还有个小心得:docker compose down的时候,如果你加了-v,数据库卷会被一起删掉。这个参数用顺手了很容易误操作,我在测试环境干过一次,两天的调度记录全没了。生产环境我干脆养成习惯——只敲docker compose down,永远不带-v,要清卷的时候手动docker volume rm,多打几个字买个保险。
最后说一个扩展方向:等这套跑稳了,如果任务量真的上来了,下一步不是急着换编排平台,而是先把元数据库和日志的清理策略做扎实,再把执行器换成分布式的。很多性能问题其实不是调度器扛不住,而是元数据库里堆了几百万条历史记录,查询慢得拖垮了整条链路。清理任务比扩容便宜得多,也见效更快。