前阵子我们把一套跑了大半年的 Airflow 从一个机房搬到另一个机房,原以为只是换台机器的事,结果第二天早上告警响成一片。原来每天凌晨准点跑的数据任务,有的迟了,有的早了几个小时,更麻烦的是那些需要补历史数据的 DAG,全都在错误的时间点上运行,产出的数据对不上账。我们几个排查了半天,最后发现所有异常的根源,居然只是时区没固定住。
一、事故现场
迁移当天晚上,我们按流程把代码、配置、数据库都搬了过去,新环境的机器时区是东八区,旧环境一直用的是 UTC。当时谁也没把“时间”当回事,觉得代码一样、数据库一样,跑起来自然就一样。结果呢?Airflow 的调度时间默认跟着系统走,新机器上的系统时间一变成东八区,原本应该按 UTC 时间凌晨 0 点执行的任务,就变成了东八区的凌晨 0 点执行。而我们历史业务数据都是按东八区的时间口径去算的,这一下子所有调度时间整体偏移了 8 小时。最让人头疼的是那些跑历史补数的任务,因为时间错位,任务以为自己错过了执行窗口,疯狂补跑,结果补出来的数据全都变成了另一天的时间。整个数据仓库的报表像被打乱了一样,业务方连环发问,我们只能一边解释一边回滚。
二、为什么时区会惹出这么大的乱子
说白了,Airflow 本身并不知道你心里想的是哪个时区。你给它一个时间,它得拿到一个参照系才能去安排调度。这个参照系如果没有明确指定,它就会自己去读操作系统的时间设置。新老环境系统时间不一致,就算你的 DAG 代码一个字没改,跑出来的效果也完全是两回事。这就像你约朋友“明天早上 8 点见面”,对方问“你说的 8 点是北京时间还是纽约时间?”你如果不说清楚,第二天就只能在马路上干等。
很多开发者在写 DAG 的时候,习惯直接给一个类似“某年某月某日零点三十分”这样的时间对象,但没带时区信息。这种时间对象在 Python 里叫“不带时区的时间”。Airflow 拿到这种时间,会强行套上运行所在机器的系统时区来理解。机器时区一变,它理解的时间就跟着变,调度自然就乱套了。
还有一个容易忽略的地方:Airflow 的配置项 default_timezone,它控制着调度器解析 cron 表达式时参考哪个时区。很多老项目在初始化的时候根本没管这个配置,走的是默认值。但默认值在不同的安装方式里并不一样,有的场景下会用 UTC,有的场景下会用系统时区。一旦你换了环境,又没有显式地设置这个值,行为就会悄悄改变。总之,时区偏移的根本原因,就是“系统时区”加“隐式依赖”加“没有显式固定”三件事凑到了一起。
三、标准时区:是和稀泥还是立规矩?
怎么解决?有些人说,那就把你的 DAG 都改成系统时区不就行了?但问题是,你这次迁移到东八区,下次迁移到西五区呢?总不能每换一次机器就改一遍代码吧。所以真正需要固定的,不是某一个环境的时间,而是你所有 DAG 共同参照的那把尺子。这把尺子,我们习惯叫它“标准时区”。它不一定非要是 UTC,但必须是一个明确指定的、全公司统一的时区。比如你公司的业务全在中国,那把尺子就可以定成东八区;如果你的业务面向全球,或者你希望彻底避开夏令时这类折腾人的事,直接定成 UTC 是最省心的。
3.1 UTC 的优势
UTC 是世界协调时间,它不受夏令时影响,全球所有国家都认同它。使用 UTC 作为唯一标准,最大的好处是任何时候都不会出现“昨天上午 10 点跑,今天上午 11 点跑”这种因为夏令时来回跳的怪事。Airflow 官方也强烈建议使用 UTC。如果你只有一套集群,并且业务不依赖某个当地时间,那么让所有任务都跑在 UTC 下,是最干净、最不容易出错的做法。
3.2 本地时区的诱惑
本地时区的好处是直观,看日志、看执行列表都符合自己的生活习惯。比如你在上海,看任务显示的是早上 8 点执行,心理上很舒服。但它的代价是,你必须在所有地方都一致地使用这个时区,包括服务器系统、Airflow 配置、DAG 代码里的开始日期、调度表达式等等。任何一个地方漏掉,都会出现前面说的那种错乱。更麻烦的是,如果你的团队分布在多个时区,每个人看到的“本地时间”都不一样,沟通成本会直线上升。
3.3 最终选择
我们这次复盘后,定下的规矩是:不管服务器在哪儿,Airflow 调度统一使用 Asia/Shanghai 作为标准时区。这样做既照顾了业务口径,又避免了系统时区被换成别的地方后再次踩坑。同时,我们在代码里把时区信息写死,这样就算后端机器换了,DAG 里的时间也纹丝不动。
四、DAG 时区固定的正确姿势
4.1 确保调度器时区配置固定
在 Airflow 的配置段 scheduler 下,有一个默认时区配置项,你需要把它显式地设置为选定的标准时区。注意,不要只用环境变量 TZ 去设置,因为 TZ 只影响系统层,Airflow 不一定完全跟着走。你需要在 Airflow 自己的配置里也写上同一句话。我们这次的操作是把默认时区写成了 Asia/Shanghai,并且把之前可能存在的一些旧配置全部清掉,确保每个环境读到的都是唯一确定的时区值。
4.2 在 DAG 里显式声明时区
这里是最关键的一步:每个 DAG 的开始日期必须是一个“带时区的时间对象”,不能是“不带时区的时间对象”。我们来看一个完整的正确写法,技术栈是 Python + Airflow:
# 技术栈:Python + Airflow
from datetime import datetime
from zoneinfo import ZoneInfo # Python 3.9+ 自带时区库
from airflow import DAG
from airflow.operators.python import PythonOperator
# 先定义一个固定时区对象,整个 DAG 都用它
SHANGHAI_TZ = ZoneInfo("Asia/Shanghai")
def say_hello(**context):
# 通过 execution_date 拿到每次调度的基准时间
scheduled_time = context["execution_date"].astimezone(SHANGHAI_TZ)
# 打印一下,方便观察实际执行时间是否被固定在东八区
print(f"本次调度时间(上海时间):{scheduled_time.isoformat()}")
# 注意:start_date 必须带 tzinfo,这是固定的关键
default_args = {
"owner": "data_team",
"start_date": datetime(2024, 1, 1, 0, 30, tzinfo=SHANGHAI_TZ),
"retries": 0,
}
with DAG(
dag_id="fixed_timezone_demo",
default_args=default_args,
# 这里的 cron 表达式“30 0 * * *”表示每天 0:30,
# 因为 start_date 已经带上了上海时区,
# 所以 Airflow 会严格按照上海时间的 0:30 去调度。
schedule_interval="30 0 * * *",
catchup=False,
description="演示如何固定 DAG 时区",
) as dag:
# 最简单的示例任务,只做一件事:打印调度时间
run_me = PythonOperator(
task_id="run_me",
python_callable=say_hello,
)
run_me
这段代码有两个核心要点。第一,我们用了 Python 自带的 zoneinfo 库来指定“Asia/Shanghai”,这样就可以创建一个带时区信息的时间对象;第二,开始日期在创建的时候直接通过 tzinfo 参数,把时区写死了。这样无论 Airflow 跑到哪个机房,只要调度器读到这段代码,它就知道要按上海时间去算 cron 表达式。
4.3 常见错误示例
很多历史 DAG 之所以出问题,就是因为开始日期忘了写时区。我们再看一个反面教材,同样用的是 Python + Airflow:
# 技术栈:Python + Airflow(错误示范)
from datetime import datetime
from airflow import DAG
from airflow.operators.python import PythonOperator
def bad_task():
print("任务开始")
# 这里没有给 datetime 传 tzinfo,创建的是“不带时区的时间”
default_args = {
"owner": "data_team",
"start_date": datetime(2024, 1, 1, 0, 30),
}
with DAG(
dag_id="naive_timezone_demo",
default_args=default_args,
schedule_interval="30 0 * * *",
catchup=False,
) as dag:
bad_run = PythonOperator(
task_id="bad_run",
python_callable=bad_task,
)
bad_run
这段代码在旧环境里如果系统时区恰好是东八区,那运行起来一切正常。但一旦把系统时区改成 UTC,Airflow 就会把“0:30”这个时间当作 UTC 来理解,等于是早上 8 点 30 分才跑。如果你再补跑历史数据,它给你算出来的“昨天”也会整体偏移。所以,拒绝裸奔的时间对象,是所有 DAG 必须养成的习惯。
五、哪些场景最容易踩坑
第一种典型场景就是环境迁移,比如机房搬迁、容器化改造、灾备切换,只要换了机器,系统时区就可能变,历史依赖本地时区的任务就会翻车。第二种是团队协作场景,如果北京、上海、硅谷的同学都在维护同一套 Airflow,每个人看到的“本地时间”都不一样,沟通时容易互相误解。第三种是历史补数场景,catchup 开启后,Airflow 要拿着开始日期去反推过去每一次执行时间,时区一偏,反推结果就全错了。所以,只要你的任务会用到“过去的时间”,时区就必须钉死。第四种是混合云场景,不同云厂商默认的时区不一致,即使你代码没变,跑到另一朵云上也会发生同样的事故。
六、迁移复盘的完整步骤
如果你也碰到类似情况,或者正准备迁移 Airflow,下面这套复盘步骤可以照搬。
6.1 先摸清家底
在动工之前,先列清楚所有 DAG 当前依赖的时区口径。打开每个 DAG 文件,检查开始日期用的是不是带时区的对象;再查一遍旧环境的 Airflow 默认时区配置到底写的什么。最好写个自动化脚本去扫描,但这里重点不在脚本,而是要把每一个可能引用到时间的环节都挑出来。
6.2 统一配置
确定了标准时区后,分成三步执行:第一步,在所有相关机器的系统环境里,把 TZ 变量设置成标准时区,确保基础系统的日志时间也是一致的;第二步,修改 Airflow 的默认时区配置,注意这次要写明确的值,不能依赖默认;第三步,把所有 DAG 里不带时区的开始日期全部替换成带时区的写法,就像前面正确示例那样。
6.3 验证历史任务
改完以后,不要急着直接停掉老任务。先挑几个没有历史补数需求的 DAG,跑一遍看调度时间是否符合预期。然后再找一个有历史补数的 DAG,在测试环境里把某个历史时间段重新跑一次,比对产出结果的时间戳是不是和你期望的时间窗口一致。确认没有问题后,再逐步切换生产任务。
七、技术优缺点与注意事项
7.1 优点
把标准时区固定下来,带来的第一个好处就是“可预期”。今天看到的调度时间,明天、明年、换到任何机器上,都会是同一个时间。第二个好处是排查问题容易,因为每一个时间点都有明确的时区标签,不需要猜。第三个好处是团队协作顺畅,大家谈起任务执行时间,说的都是同一个时区,不会出现“你说的 9 点是哪里的 9 点?”这种尴尬。
7.2 缺点
缺点也跟着来。首先,统一到一个标准时区后,如果业务本身有跨时区的需求,比如有些任务需要在美国时间凌晨跑,那你必须额外做一次时区换算,代码会多一点点复杂度。其次,如果你选的是 UTC,那么看日志的时候会有些不直观,需要心里把 UTC 时间换算成自己的当地时间,尤其在查问题的时候,容易算错。不过这类缺点可以通过在日志打印时转换成业务时区来缓解,比如我们前面示例里就在任务内部打印了上海时间。
7.3 注意事项
还有几个坑要记牢:第一,千万不要在 DAG 已经发布运行之后再悄悄修改开始日期,因为开始日期改变会影响调度的历史累积逻辑,很容易引发重复调度或漏调度;第二,cron 表达式的含义是相对时区的,不是简单的“服务器本地时间”,所以一旦定了标准时区,就不要频繁改动;第三,Airflow 的界面上显示的时区可能和调度时区不是同一个配置,需要把两个配置都设置一致,否则你会看到界面上的时间和你以为的时间不一样;第四,如果你用了外部系统的触发时间,比如 Kafka 或 MySQL 里的时间戳,也要统一转换成标准时区之后再进入 DAG,避免任务内部计算时又出现偏移。
八、总结
这次迁移事故,表面上是“时区偏移”,本质上是对时间没有敬畏心。我们太习惯依赖操作系统给的那个时间,忘记了 Airflow 调度的每一根发条都需要一个确定的参照系。复盘下来最值钱的收获,就是把“标准时区”这个词刻进了团队规范里:所有服务器、所有配置、所有 DAG,都固定使用同一个明确声明的时区。不要相信默认值,不要相信运气,要相信代码里显式写下来的那一行时区信息。只要做到这一点,以后不管搬多少次机房,你的任务都会踏踏实实地在预订的时间点上醒来。
评论
围绕“迁移Airflow至新环境后时区偏移引发历史DAG错乱,围绕标准时区与DAG固定方法展开复盘”参与讨论