朕喜技术(ZhenxiJS)
2026.09.26
1 分钟阅读

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 核心认知

  1. 区分「时刻(instant)」和「读数(reading)」。2026-09-25T16:30:05Z 和 2026-09-26T00:30:05+08:00 是同一个时刻, 但读数是 16:30 vs 00:30。任何「按 HH:MM 比对」的逻辑, 动手前先问一句:这个 HH:MM 是谁的读数?
  2. 「解释计划表的口径」必须来自契约,不能来自调用方环境。 凡是「本地时间/本地路径/本地编码」这类相对概念,都必须在部署契约里 显式声明并由代码主动读取。契约文件里写了但代码没读 = 只是注释。
  3. 静默回退(fallback)是时间类 bug 的头号帮凶。if tz: ... else: utc 这种写法把「没配置」悄悄变成了「有个合理默认值」。 正确姿势是缺失就大声失败(SystemExit/非 0 退出码)。
  4. 「跑着」≠「跑到了」。exit 0 / healthcheck healthy / 日志在变长 / JSON 合法, 都只证明进程没崩。能证明业务完成的只有业务计数器(本例是 expected 与 fired 的差集)。监控断言要写「等于期望值」, 不是「不等于空」。
  5. 最有效的定位手法是「对照同一时刻的两种时区」。 同一个 instant 分别喂给 TZ=Asia/Shanghai 与「未设 TZ」,行为不同 → 立刻定性。

6.2 实操避坑清单

  1. TZ 环境变量 ≠ time.localtime() 立即生效(C 侧)。 C 里 os.environ["TZ"]=... 后必须调 time.tzset(),因为 libc 缓存了时区; time.localtime() / strftime 不会自己感知环境变量变化。 父进程改 os.environ 也会被子进程继承,排查时要分清「谁设的 TZ」。
  2. Python 里推荐 datetime.now(timezone.utc).astimezone(ZoneInfo(tz)), 不要用 datetime.now(tz) 或裸 datetime.now()。 前者永远正确(取时刻 + 显式换算);datetime.now() 拿到的是无时区的 「墙上时间」,跨机器/跨容器不可复现。
  3. 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。
  4. COPY 进镜像的文件改完必须 --build。docker compose restart 或直接 exec 还是旧代码。所有 Dockerfile 里的 COPY app/ /srv/app/ 都是这个坑。
  5. /srv/logs 是 volume,旧日志会残留。 重建容器后读到的 [REPORT] 可能是上一轮留下的,差点误判「修复没生效」。 干净复现用 docker compose down -v 或先 rm -f /srv/logs/jobd.log。
  6. 脚本在宿主跑还是在容器跑,要看它内部有没有调 docker compose。 本例 ops/repro.sh 正文是 docker compose exec ...,只能在宿主跑; 在容器内跑会报 docker: command not found。
  7. 冻结时钟来验收,别依赖「真实的现在几点」。 时间类问题如果靠真跑就永远不可复现(今天跑、明天结论不同)。 本例用 _probe.py 把 datetime.now 换成固定时刻,任何时间跑结论一致。
  8. 「修复后 client 仍报 BAD」可能不是 bug,而是业务事实。 让常驻守护真跑到本地 08:09 时,00:30~05:00 确实已经过去, 报 stale 是对的。判定「计划表能不能跑完全天」要用冻结时钟走完业务日 (verify.sh 的 D 组口径)。别把「今天来不及了」误当成「时区没修好」。
  9. docker compose exec 的默认 shell 是 sh(dash), 不支持 <(...) 进程替换,写复杂脚本加 bash -lc '...'。
  10. 兜底/降级逻辑要有「大声失败」分支,且要单独验它。 本例「契约完全没声明时区」这条分支我单独跑了一遍(exit=1), 否则下次重构很容易又被改成静默 UTC。

6.3 同类影响面(同类 bug 的高发场景)

  • cron / anacron / systemd timer 与 CRON_TZ / OnCalendar 的时区归属
  • K8s CronJob 的 spec.timeZone(旧版本默认 kube-controller 的时区)
  • 数据库 TIMESTAMP vs TIMESTAMPTZ、now() vs current_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 经验与避坑

  1. 补索引时不要手打摘要行。 直接从 origin/main 取那一行原样追加, 保证逐字节一致 —— 手打会引入空格/标点差异,日后 diff 两个分支就对不齐。
  2. 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)。
  3. 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; }
    
  4. 改文档也要顺手确认没破坏表格结构。awk 'NR>=52 {n=gsub(/\|/,"|"); if(n!=6) print NR}' 扫一遍, 本次异常行(92/110/117/124/134)与 main 完全相同 —— 它们来自表格单元格内被转义的竖线,是历史既有、不是本次引入; 新增的第 136 行是正常的 6 个竖线。
  5. 「文档缺行」这类收尾项容易被判成「只改文档敷衍」,要给出自证。 本节把 diff 逐字节比对、链接目标存在性、行数增减都写成了可复制命令, reviewer 一条条跑即可确认——改了哪一行、只改了哪一行都要能被证明。238de57 (docs(round2): 补齐第84题分支 experience/README.md 索引行(自包含分支收尾))

版权声明

本文为博主原创文章,遵循 CC BY-NC-SA 4.0 许可协议。 转载请注明出处:http://localhost/blogs/2026-09-26-0800-tzstruct-tm

分享这篇文章