本地测试一切正常,把脚本部署到服务器后,任务却在完全不同的时间点跑了起来——本来想每天上午 9 点发报表,结果邮件在下午 5 点才发出去,正好差了 8 小时。这类问题几乎只有一个原因:cron 按服务器的时区执行,而服务器的时区和你以为的不一样。
cron 用的是服务器本地时间
cron 表达式的每一个字段,都按运行 cron 的那台机器的本地时区解释。表达式里写 0 9 * ,意思是「服务器本地时间 9 点整」。
这就意味着表达式本身不携带时区信息。同一份 crontab 部署到 UTC 的机器和 UTC+8 的机器上,实际执行时刻相差 8 小时。
北京时间是 UTC+8,所以如果服务器是 UTC,表达式里的小时需要减 8 才能对应到北京时间:想在北京时间 9 点执行,UTC 服务器上要写 0 1 * 。
容器场景是重灾区
现在大多数服务跑在容器里,而容器的基础镜像默认时区通常是 UTC。这就解释了为什么「本机测试正常、容器里差 8 小时」成了最常见的抱怨。
排查顺序很清晰:先在容器里执行 date,看输出的时区和时间;再和宿主机对比。如果容器显示 UTC 而宿主机是 CST,问题就确认了。
修复方式有三种,按推荐程度排列:
第一种是设置环境变量 TZ=Asia/Shanghai。多数基础镜像在有 tzdata 的情况下会识别这个变量,改起来最轻量。
第二种是把宿主机的时区文件挂载进容器,例如把 /etc/localtime 挂到容器的同名路径。这种方式不依赖镜像里是否装了时区数据库。
第三种是在构建镜像时安装 tzdata 并设置好时区。这种方式最彻底,但需要改 Dockerfile 并重建镜像。
不要靠改表达式来绕过时区
有一种常见的临时做法:不修时区,直接把表达式里的小时数加减 8 来凑。这在没有夏令时的地区看起来能工作,但会带来两个隐患。
一是可读性变差。半年后维护的人看到一个 0 1 * ,很难立刻意识到它想表达的是北京时间上午 9 点。
二是遇到夏令时就失效。有夏令时的地区每年会调整一次时钟,加减固定小时数的写法会在切换当天错一小时。而如果服务器时区设置正确,cron 会跟着系统时间自动调整。
夏令时本身也是一个坑
即使时区设置正确,在有夏令时的地区仍会遇到两个特殊现象。
一是「跳过的时刻」。春季时钟向前拨一小时,某些本地时间点根本不存在,落在这些时刻的任务当天不会执行。
二是「重复的时刻」。秋季时钟回拨一小时,某些本地时间点会出现两次,落在这些时刻的任务可能执行两次。对幂等性要求高的任务,需要自己在脚本里加锁或做去重。
如果业务对执行时刻的精确性要求很高,建议把调度统一到 UTC,并在文档里写明所有时间都是 UTC,避免本地时间带来的歧义。
检查清单
- 记住 cron 表达式按服务器本地时区解释,表达式本身不带时区
- 排查第一步:在运行 cron 的环境里执行
date,确认时区 - 容器里默认多为 UTC,用
TZ环境变量或挂载/etc/localtime修正 - 不要靠加减小时数绕过时区问题,可读性和夏令时都会出问题
- 有夏令时的地区注意「跳过」与「重复」两种执行异常
- 对时刻精度要求高的场景统一使用 UTC,并在文档里明确声明