TZ 未设置导致定时任务全部不执行:本地时钟读数没按业务时区换算(struct tm 当业务读数),日志全绿退出码 0
场景:夜间批处理调度器(Python 3.12 / Docker / docker compose) 症状关键词:计划表全部落空、任务一个都没按时跑、
TZ未设置、容器时区 UTC、struct tm、time.localtime、datetime.now().astimezone()、zoneinfo、astimezone、time.tzset、TZ 继承、静默失效、exit code 0、healthcheck healthy、TZ_POLICY=local-clock分支:2026-09-26-0800| 题号:第 84 题
1. 问题背景
公司有一套夜间批处理:数据库备份、出账报表、日志切割、对账单导出、对象存储整理,
最后统一发日报推送。计划表 conf/jobs.def 用业务本地时钟表述:
00:30 backup-db 逻辑备份(重 IO,必须早于 01:00 的开票高峰)
01:00 nightly-report 出账报表(下游 05:00 之前拿不到产物就算事故)
02:00 rotate-logs 日志切割 + 归档
03:00 export-csv 导出对账单
04:00 vacuum-store 对象存储整理
05:00 send-notify 日报推送
现象非常反直觉:
- 白天在容器里手工
python3 /srv/app/jobd.py,任务会触发; - 挂了常驻守护之后,计划表里的 6 个任务一个都没在预定时间跑过;
- 但调度器这边「一切正常」:
[BOOT] jobd start, 6 jobs loaded from /srv/conf/jobs.def
[BOOT] tz_env='(unset)'
[REPORT] {"now_local": "...", "expected": [...6 个任务...], "fired": [], "stale": [...]}
jobd.log 每轮都在变长、healthcheck 一直 healthy、exit code 0、
零 ERROR、fired.json 是合法 JSON。「跑着」掩盖了「没跑到」。
更迷惑人的是:白天人工跑的 shell 自己的 TZ 是设好的(运维机器在东八区),
而常驻容器没有 TZ —— 所以「手动能跑、守护不跑」看起来像权限或路径问题。
2. 复现步骤
环境:scenario/2026-09-26-0800/(Dockerfile + docker-compose.yml 已备好,容器默认不设 TZ)
cd scenario/2026-09-26-0800
docker compose up -d --build
# 看调度器自报的时区线索
docker compose exec -T jobd bash -lc 'grep BOOT /srv/logs/jobd.log; date -Is; echo TZ=[${TZ:-unset}]'
# [BOOT] tz_env='(unset)'
# 2026-09-26T00:08:27+00:00
# TZ=[unset]
# 断言式验收(冻结时钟,任何时间跑结论一致)
docker compose exec -T jobd bash /srv/ops/verify.sh
问题态关键输出:
--- B 组:冻结 2026-09-26T00:30:05+08:00(业务本地 00:30)---
now_local = 2026-09-26T00:30:05+08:00
utcoffset_sec = 28800
fired = ['backup-db'] <-- 带了 TZ 就对了
--- C 组:容器未设置 TZ,同一时刻 2026-09-25T16:30:05Z ---
now_local = 2026-09-25T16:30:05+00:00 <-- 同一个时刻,读数变 UTC
utcoffset_sec = 0
fired = []
stale = ['backup-db','nightly-report','rotate-logs','export-csv','vacuum-store','send-notify']
[FAIL] C1 该瞬间触发的任务: got=[] want=['backup-db']
[FAIL] C2 调度器使用的 UTC 偏移(s): got=0 want=28800
sweep_fired = 0
sweep_stale = 6
PASS=8 FAIL=6
RESULT: BAD
同一份代码、同一个时刻,只差一个 TZ 环境变量,行为就从「全对」变成「全错」。
外部验收脚本的报错信息(这句话可以直接拿去搜索):
BAD: 任务 backup-db(计划 00:30)当天没有触发记录
BAD: 存在 stale(计划时刻已过、当天从未触发): ['backup-db', ...]
BAD: 调度器使用的 UTC 偏移是 0s,部署契约期望 28800s
RESULT: BAD
一键端到端复现(在宿主上跑,脚本内部会调 docker compose):
bash ops/repro.sh # client/check_schedule.py 退出码 1、RESULT: BAD
3. 根本原因
3.1 把「时刻」当成了「本地读数」
2026-09-25T16:30:05Z == 2026-09-26T00:30:05+08:00 (同一个时刻)
前者的本地读数是 16:30,后者是 00:30。计划表里的 00:30 比的是读数。
问题代码:
def local_readings():
tz = os.environ.get("TZ")
now = datetime.now(timezone.utc)
if tz:
now = now.astimezone(ZoneInfo(tz))
else:
now = now.astimezone(timezone.utc) # <-- 无声回退 UTC,就是这里
return {"hour": now.hour, "minute": now.minute, ...}
容器不设 TZ → 系统本地时区就是 UTC → 读数永远是 UTC 的 HH:MM。
计划表要的是 00:30 +08:00,代码等的是 00:30 UTC(=业务本地 08:30),
所以整张计划表一天都不会命中,而且不报任何错。
3.2 真正的根因:解释计划表的「口径」交给了调用方环境
这才是本质问题。conf/jobd.env 里明明写着:
# 业务时区:jobs.def 里的 HH:MM 一律以本时区为口径。
BUSINESS_TZ=Asia/Shanghai
代码从来没有读过这个键。 也就是说:不是「缺个环境变量」,而是
「代码把解释计划表口径这件事,交给了调用方环境,而部署契约早就把口径写好了」。
Dockerfile 里还有 TZ_POLICY=local-clock 这个声明,同样从未被读取 —— 契约文件只是注释。
3.3 三个让 bug 隐形的观测陷阱
| 看起来像证据的东西 | 实际能证明什么 |
|---|---|
进程活着、healthcheck healthy | 只能证明「没崩」 |
jobd.log 每轮变长、exit code 0 | 只能证明「在跑」 |
fired.json 是合法 JSON、零 ERROR | 只能证明「格式没问题」 |
没有一件能证明「任务真的跑了」。 唯一能暴露问题的是
[REPORT] 里 expected 与 fired 的差集(stale 列表)—— 而没人看那一行。
3.4 为什么「手动能跑、守护不跑」
手工进程所在环境是东八区(或运维带了 TZ),astimezone(ZoneInfo(...)) 分支被走到;
常驻容器的 os.environ.get("TZ") 是 None,走 UTC 回退分支。
环境变量差异伪装成了权限/路径问题。
4. 解决办法
只改 app/jobd.py,conf/ 一字未动。
4.1 业务时区从部署契约解析(不依赖调用方 TZ)
def _resolve_business_tz():
"""
业务时区来源(按优先级):
1. conf/jobd.env 的 BUSINESS_TZ(部署契约,最权威)
2. 环境变量 BUSINESS_TZ
3. 环境变量 TZ
三者都拿不到 -> 大声失败,拒绝用系统本地读数静默解释计划表。
"""
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
for cand in (CONF.get("BUSINESS_TZ"),
os.environ.get("BUSINESS_TZ"),
os.environ.get("TZ")):
if not cand:
continue
try:
return ZoneInfo(cand)
except (ZoneInfoNotFoundError, ValueError):
continue
raise SystemExit(
"FATAL: 业务时区未声明(conf/jobd.env 的 BUSINESS_TZ 与环境变量 TZ 均为空),"
"拒绝用系统本地读数解释计划表"
)
BUSINESS_TZ = _resolve_business_tz()
关键设计:契约优先于环境变量 —— 环境变量只能「补位」,不能「抢位」。
4.2 时钟读取:取时刻 → 换算到业务时区
# 修复前
now = datetime.now(timezone.utc)
if tz: now = now.astimezone(ZoneInfo(tz))
else: now = now.astimezone(timezone.utc) # 无声回退
# 修复后(一行)
now = datetime.now(timezone.utc).astimezone(BUSINESS_TZ)
Python 3.9+ 内置 zoneinfo,python:3.12-slim 自带 /usr/share/zoneinfo
(若基础镜像缺 tzdata,需 pip install tzdata 或 apt-get install -y tzdata)。
4.3 补强可观测性
report() / local_readings() 增加 business_tz 字段,[BOOT] 行补打业务时区:
[BOOT] tz_env='(unset)' business_tz=Asia/Shanghai
排查时一眼就能回答「这个调度器认为自己活在哪个时区」。
4.4 明确不改的东西
is_due()判定逻辑一行没动 —— 「读数相等即命中」是对的,错的是喂给它的读数。 改判定会掩盖根因。conf/jobs.def、conf/jobd.env一字未动(验收会做 md5 核对)。docker-compose.yml没有加TZ。加了能让现象消失,但问题依然在 (代码仍然依赖调用方环境)。要加也应作为「部署侧显式声明」的补充, 而不是修复手段。
5. 验证
cd scenario/2026-09-26-0800
docker compose up -d --build
docker compose exec -T jobd bash /srv/ops/verify.sh
修复后:
--- C 组:容器未设置 TZ,同一时刻 2026-09-25T16:30:05Z ---
now_local = 2026-09-26T00:30:05+08:00 <-- 正确的业务读数
tzname = CST
utcoffset_sec = 28800
fired = ['backup-db']
stale = []
--- D 组:未设 TZ 时全天 6 个计划时刻逐点核对 ---
UTC 16:30 local=00:30:30 fired=['backup-db'] stale=[]
UTC 17:00 local=01:00:30 fired=['nightly-report'] stale=[]
UTC 18:00 local=02:00:30 fired=['rotate-logs'] stale=[]
UTC 19:00 local=03:00:30 fired=['export-csv'] stale=[]
UTC 20:00 local=04:00:30 fired=['vacuum-store'] stale=[]
UTC 21:00 local=05:00:30 fired=['send-notify'] stale=[]
sweep_fired = 6
sweep_stale = 0
--- E 组:外部验收 client/check_schedule.py ---
jobd 视角: tz_env='(unset)' tzname='CST' utcoffset=28800s now=2026-09-26T05:05:00+08:00
fired : ['backup-db','nightly-report','rotate-logs','export-csv','vacuum-store','send-notify']
stale : []
RESULT: OK
PASS=14 FAIL=0
RESULT: OK
PASS 8 → 14,FAIL 6 → 0。
三条增量验证:
# (a) 未设 TZ:从契约读到业务时区
docker compose exec -T jobd bash -lc '
W=/tmp/t; rm -rf $W; mkdir -p $W/logs $W/state
env -u TZ LOG_DIR=$W/logs STATE_DIR=$W/state python3 -c "
import sys; sys.path.insert(0,\"/srv/app\"); import jobd
r = jobd.local_readings(); print(\"tz=\",jobd.BUSINESS_TZ,\"off=\",r[\"utcoffset_sec\"])"'
# tz= Asia/Shanghai off= 28800
# (b) 契约优先:环境变量 TZ 不该抢位
docker compose exec -T jobd bash -lc 'env TZ=UTC python3 -c "
import sys; sys.path.insert(0,\"/srv/app\"); import jobd; print(jobd.BUSINESS_TZ)"'
# Asia/Shanghai
# (c) 时区完全没声明时大声失败(不再静默按 UTC 跑)
docker compose exec -T jobd bash -lc '
W=/tmp/f; rm -rf $W; mkdir -p $W/logs $W/state
env -u TZ -u BUSINESS_TZ JOBD_ENV=/nonexistent.env LOG_DIR=$W/logs STATE_DIR=$W/state \
JOBD_MAX_TICKS=1 python3 /srv/app/jobd.py; echo "exit=$?"'
# FATAL: 业务时区未声明...拒绝用系统本地读数解释计划表
# exit=1
反向验证(确认断言有判别力):git checkout -- app/jobd.py + up -d --build
→ verify.sh 重新变红 PASS=8 FAIL=6 / RESULT: BAD。
6. 经验与避坑
6.1 核心认知
- 区分「时刻(instant)」和「读数(reading)」。
2026-09-25T16:30:05Z和2026-09-26T00:30:05+08:00是同一个时刻, 但读数是16:30vs00:30。任何「按 HH:MM 比对」的逻辑, 动手前先问一句:这个 HH:MM 是谁的读数? - 「解释计划表的口径」必须来自契约,不能来自调用方环境。 凡是「本地时间/本地路径/本地编码」这类相对概念,都必须在部署契约里 显式声明并由代码主动读取。契约文件里写了但代码没读 = 只是注释。
- 静默回退(fallback)是时间类 bug 的头号帮凶。
if tz: ... else: utc这种写法把「没配置」悄悄变成了「有个合理默认值」。 正确姿势是缺失就大声失败(SystemExit/非 0 退出码)。 - 「跑着」≠「跑到了」。
exit 0/ healthcheckhealthy/ 日志在变长 / JSON 合法, 都只证明进程没崩。能证明业务完成的只有业务计数器(本例是expected与fired的差集)。监控断言要写「等于期望值」, 不是「不等于空」。 - 最有效的定位手法是「对照同一时刻的两种时区」。
同一个
instant分别喂给TZ=Asia/Shanghai与「未设 TZ」,行为不同 → 立刻定性。
6.2 实操避坑清单
TZ环境变量 ≠time.localtime()立即生效(C 侧)。 C 里os.environ["TZ"]=...后必须调time.tzset(),因为 libc 缓存了时区;time.localtime()/strftime不会自己感知环境变量变化。 父进程改os.environ也会被子进程继承,排查时要分清「谁设的 TZ」。- Python 里推荐
datetime.now(timezone.utc).astimezone(ZoneInfo(tz)), 不要用datetime.now(tz)或裸datetime.now()。 前者永远正确(取时刻 + 显式换算);datetime.now()拿到的是无时区的 「墙上时间」,跨机器/跨容器不可复现。 zoneinfo需要 tzdata。python:3.12-slim自带/usr/share/zoneinfo; 用 scratch/alpine 精简镜像时要apk add tzdata或pip install tzdata, 否则ZoneInfoNotFoundError(顺手pip install tzdata是最省事的兜底)。 自查:docker compose exec app ls /usr/share/zoneinfo/Asia/Shanghai。COPY进镜像的文件改完必须--build。docker compose restart或直接exec还是旧代码。所有 Dockerfile 里的COPY app/ /srv/app/都是这个坑。/srv/logs是 volume,旧日志会残留。 重建容器后读到的[REPORT]可能是上一轮留下的,差点误判「修复没生效」。 干净复现用docker compose down -v或先rm -f /srv/logs/jobd.log。- 脚本在宿主跑还是在容器跑,要看它内部有没有调
docker compose。 本例ops/repro.sh正文是docker compose exec ...,只能在宿主跑; 在容器内跑会报docker: command not found。 - 冻结时钟来验收,别依赖「真实的现在几点」。
时间类问题如果靠真跑就永远不可复现(今天跑、明天结论不同)。
本例用
_probe.py把datetime.now换成固定时刻,任何时间跑结论一致。 - 「修复后 client 仍报 BAD」可能不是 bug,而是业务事实。
让常驻守护真跑到本地
08:09时,00:30~05:00确实已经过去, 报 stale 是对的。判定「计划表能不能跑完全天」要用冻结时钟走完业务日 (verify.sh的 D 组口径)。别把「今天来不及了」误当成「时区没修好」。 docker compose exec的默认 shell 是sh(dash), 不支持<(...)进程替换,写复杂脚本加bash -lc '...'。- 兜底/降级逻辑要有「大声失败」分支,且要单独验它。
本例「契约完全没声明时区」这条分支我单独跑了一遍(
exit=1), 否则下次重构很容易又被改成静默 UTC。
6.3 同类影响面(同类 bug 的高发场景)
cron/anacron/systemd timer与CRON_TZ/OnCalendar的时区归属- K8s CronJob 的
spec.timeZone(旧版本默认 kube-controller 的时区) - 数据库
TIMESTAMPvsTIMESTAMPTZ、now()vscurrent_timestamp - 日志时间戳字段全 UTC,运维按本地时间 grep 找不到
- 报表「当日」边界(
date_trunc('day')按哪个时区切) - 证书/Token 过期时间比较、跨时区对账、夏令时(DST)跳变日的
02:30 - 任何「把
datetime.now()直接当业务读数」的代码
6.4 一条自查命令
# 在任何容器里问一句:这个进程认为自己活在哪个时区?
docker compose exec app sh -c 'echo "TZ=[${TZ:-unset}]"; date -Is; \
python3 -c "import time,datetime;print(time.tzname, datetime.datetime.now().astimezone().isoformat())"'
若业务需要东八区而输出是 UTC / +00:00,且有「按 HH:MM 定时」的逻辑,
本篇文章就是你的问题。
7. 质检复核(第 1 轮)
本篇解答经第 1 轮质检评审,a/b/c/d 四个维度(可复现性、思路与落地、过程与踩坑、SEO/GEO)
均通过;提出的唯一修改点是本分支 experience/README.md 缺第 84 题索引行——
「分支自包含」则「索引也应自包含」。已补入该行(与 origin/main 第 136 行逐字节一致,
相对链接指向本分支自带的经验文档,可解析)。
复核时顺带重跑了一遍修复验证(沙箱无 docker CLI,改用 /srv 等价布局 +
LOG_DIR/STATE_DIR 注入,ops/verify.sh 本身不依赖 docker):
rm -rf /srv && mkdir -p /srv && cp -r app client conf ops /srv/ # 复刻镜像内布局
bash /srv/ops/verify.sh | tail -3
# 修复态:PASS=14 FAIL=0 / RESULT: OK (rc=0)
# 注入 bug(改回 datetime.now(timezone.utc)):PASS=4 FAIL=10 / RESULT: BAD (rc=1)
# 还原后:PASS=14 FAIL=0 / RESULT: OK (rc=0)
三态(修复 / 问题 / 还原)均可复现,断言在问题态确实变红,判别力成立;
conf/ md5 与基线一致(F1 通过)。补索引是纯文档收尾,未触及任何业务代码。
8. 第 2 轮质检收尾修复:自包含分支的索引表一致性
本节补充一条与本题代码修复无关、但同等可复用的工程经验 —— 「独立自包含分支」的文档索引必须跟着分支走,否则分支自己指不到自己的文档。
8.1 现象
本题的解题分支 2026-09-26-0800 是 git checkout --orphan 建出来的
独立自包含分支:分支里带了本期经验文档 experience/2026-09-26-0800-….md、
带了 scenario/2026-09-26-0800/ 全套环境与 SOLUTION.md,
但 experience/README.md 的索引表漏了第 84 题那一行。
质检实测:
grep -c '第84题' experience/README.md # 0
grep -c '2026-09-26-0800' experience/README.md # 0
wc -l experience/README.md # 135,最后一条仍是第 83 题
8.2 根本原因:索引表被写在 main 上,分支没同步
experience/README.md 的索引行是在解题完成后由收尾流程在 main 上补的
(main 的 a824e8c 提交「收录第84题 … + 更新索引」)。
于是出现分支与 main 的文档口径割裂:
| 位置 | 有无第 84 题索引行 |
|---|---|
origin/main:experience/README.md 第 136 行 | 有 |
分支 experience/README.md | 无 |
orphan 分支的价值就是「自包含、单分支可复现」——
索引表缺行意味着 clone 这个分支的人看不到本期经验,自包含性被破坏。
8.3 修复动作(一行命令)
# 仓库根目录,当前在 2026-09-26-0800 分支
git show origin/main:experience/README.md | sed -n '136p' >> experience/README.md
8.4 验证(5 条)
grep -c '第84题' experience/README.md # 1
grep -c '2026-09-26-0800' experience/README.md # 1
ls experience/2026-09-26-0800-时区TZ未设struct-tm当业务读数-夜间批处理计划表全部落空.md
# 存在,15932 字节 → 链接不是死链
git show origin/main:experience/README.md | sed -n '136p' > /tmp/main84.md
tail -1 experience/README.md > /tmp/br84.md
diff /tmp/main84.md /tmp/br84.md # 无输出 = IDENTICAL
head -135 experience/README.md > /tmp/a.md
git show origin/main:experience/README.md | head -135 > /tmp/b.md
diff /tmp/a.md /tmp/b.md # 无输出 = 前 135 行未动
wc -l experience/README.md # 136(135 → 136,只 +1 行)
8.5 经验与避坑
- 补索引时不要手打摘要行。 直接从
origin/main取那一行原样追加, 保证逐字节一致 —— 手打会引入空格/标点差异,日后diff两个分支就对不齐。 grep -c为 0 要分清「行不存在」和「关键词不在行里」。 上一轮质检那条grep -c '2026-09-26-0800' experience/README.md = 0与「行不存在」是同一个结论(行确实不存在);但grep '2026-09-26-0800'匹配的是链接路径./2026-09-26-0800-时区TZ….md, 比grep '第84题'更稳(题号是中文、分支名是纯 ASCII)。orphan自包含分支的收尾清单应该包含experience/README.md。 建议把「分支自包含」写成可断言的自检:# 本期经验文档必须在索引表里被引用,否则分支不自包含 f=$(ls experience/*.md | grep -v README | head -1 | xargs basename) grep -qF -- "$f" experience/README.md || { echo "索引缺行: $f"; exit 1; }- 改文档也要顺手确认没破坏表格结构。
awk 'NR>=52 {n=gsub(/\|/,"|"); if(n!=6) print NR}'扫一遍, 本次异常行(92/110/117/124/134)与main完全相同 —— 它们来自表格单元格内被转义的竖线,是历史既有、不是本次引入; 新增的第 136 行是正常的 6 个竖线。 - 「文档缺行」这类收尾项容易被判成「只改文档敷衍」,要给出自证。
本节把
diff逐字节比对、链接目标存在性、行数增减都写成了可复制命令, reviewer 一条条跑即可确认——改了哪一行、只改了哪一行都要能被证明。238de57 (docs(round2): 补齐第84题分支 experience/README.md 索引行(自包含分支收尾))
