容器时区卡在 UTC:一次对账任务错位 8 小时的复盘
早上九点,值班同事在群里问:昨天的日终对账报表怎么还没出?按计划它是每天 00:30 跑的,上游的明细文件 02:00 就落好了,正常情况六点前就能看到结果。
登录机器,宿主机 date 显示的是北京时间,没有问题。进容器一看:
$ docker exec -it recon date -Is
2026-10-29T16:31:07+00:00
任务确实跑了,只不过它理解的「00:30」是 UTC 00:30,也就是北京时间早上 08:30。更要命的是它算「昨天」时用的是 UTC 日期,在跨零点的边界上又错了一天,把前一天的明细当成了当天的。整条链路没有一行报错,日志里的时间戳前后一致(都是 UTC),所以既没触发告警,也没人第一时间怀疑时间。
一、先把「时钟」和「时区」拆开
这类事故拖长的原因,往往是一开始就把两件事混在一起查。
容器没有自己的时钟。所有容器和宿主机共享同一个内核时钟源,docker exec 里看到的 date 与宿主机的 epoch 秒数完全相同。所以「容器时间不对」这个说法必须拆成两个独立问题:
| 问题 | 表现 | 修复位置 |
|---|---|---|
| 时钟不准 | 与标准时间差几秒到几分钟,偏移是零散的 | 宿主机 chrony / NTP |
| 时区错误 | 偏移正好是整小时(常见 8 小时),epoch 完全一致 | 镜像 tzdata、TZ、/etc/localtime、应用配置 |
判断方法很直接:比 epoch,不比格式化字符串。epoch 相同而显示不同,就一定是时区;epoch 有偏差,才是时钟。
# 宿主机:时钟源与时区是两件独立的事,一起看
timedatectl
chronyc tracking | sed -n '1,6p'
date -Is
# 容器:只需要确认三样东西
docker exec -it recon date -Is
docker exec -it recon sh -c 'ls -l /etc/localtime; cat /etc/timezone 2>/dev/null; echo "TZ=$TZ"'
docker exec -it recon sh -c 'ls -l /usr/share/zoneinfo/Asia/Shanghai 2>&1'
第三步是最容易被跳过的一步。如果 /usr/share/zoneinfo/Asia/Shanghai 不存在,那么 TZ=Asia/Shanghai 根本不会被解析成功——glibc 和 musl 在找不到对应时区文件时都会静默回退到 UTC,不报错、不告警。这就是很多「我明明设了 TZ 却没用」的真相。
二、四个成因,按踩到的频率排
成因一:镜像里没有时区数据
debian:slim、ubuntu 这类基础镜像通常带 tzdata,所以本地测试一切正常;换到 alpine、distroless、scratch 上就集体失守——这些镜像为了体积把 tzdata 删掉了,容器内只有 UTC 可用。
Alpine 的修法是在构建期就装好,并且把时区文件落到 /etc/localtime,而不是只依赖环境变量:
FROM alpine:3.20
# 时区必须在构建期固化:只设 TZ 而不装 tzdata 会静默回退 UTC
RUN apk add --no-cache tzdata \
&& cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
&& echo "Asia/Shanghai" > /etc/timezone \
&& rm -rf /var/cache/apk/*
ENV TZ=Asia/Shanghai
distroless 没有包管理器,只能从构建阶段拷文件:
FROM alpine:3.20 AS tz
RUN apk add --no-cache tzdata
FROM gcr.io/distroless/static-debian12
COPY --from=tz /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
ENV TZ=Asia/Shanghai
注意这里拷的是具体时区文件而不是整个 zoneinfo 目录。目标容器里如果需要程序自己按名字解析时区(比如 Python 的 zoneinfo.ZoneInfo("Asia/Shanghai")),就必须把 /usr/share/zoneinfo 整个目录带上,只拷一个文件是不够的。
成因二:只设了 TZ,但没搞清谁在读它
TZ 不是万能开关,它的生效范围取决于运行时:
- Go:静态编译的程序在没有 zoneinfo 文件时只能拿到 UTC,除非在代码里
import _ "time/tzdata"把时区库编进二进制。 - JVM:默认通过
/etc/localtime判定系统时区,对TZ环境变量的支持随版本而异,最可靠的做法是显式给-Duser.timezone。 - Node.js:读
TZ,Intl.DateTimeFormat().resolvedOptions().timeZone能直接反映结果。 - Python:
datetime.now()走 libc,依赖TZ与/etc/localtime;用zoneinfo时还需要系统时区文件。
一条命令就能验证运行时到底认了什么:
# Java:看 user.timezone 的最终取值
docker exec -it app java -XshowSettings:properties -version 2>&1 | grep -i 'user.timezone'
# Node:看进程实际生效的时区
docker exec -it app node -e 'console.log(process.env.TZ, Intl.DateTimeFormat().resolvedOptions().timeZone)'
# Python:同时看环境变量与 libc 的判定
docker exec -it app python3 -c 'import os,time,datetime;print(os.environ.get("TZ"), time.tzname, datetime.datetime.now().astimezone().tzinfo)'
三者输出不一致,就说明应用层还有一层覆盖在生效,光改容器没用。
成因三:挂载 /etc/localtime 的隐性代价
这是很常见的写法:
services:
recon:
image: registry.example.com/recon:1.4.2
environment:
TZ: Asia/Shanghai
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
它能让容器跟随宿主机时区,但有两个后果必须知道。
第一,宿主机改时区后,容器不会自动跟着变。/etc/localtime 在 Ubuntu 上是一个指向 /usr/share/zoneinfo/Asia/Shanghai 的符号链接,而 bind mount 在创建时就解析到了链接指向的那个具体文件。之后宿主机把时区换成别的,/etc/localtime 链接指向变了,但容器里挂着的还是旧文件,必须 docker compose up -d --force-recreate 重建才生效。
第二,ro 挂载意味着容器内不能再修改时区,任何试图写 /etc/localtime 的操作都会直接失败。
成因四:应用层又覆盖了一次
最迷惑人的一种:容器 date 已经是对的,日志里却还是 UTC。原因是框架或启动参数里写死了时区,比如 JVM 的 -Duser.timezone=UTC、Spring 的 spring.jackson.time-zone: UTC、数据库连接串里的 serverTimezone=UTC,又或者代码里主动调了 utcnow()。
排查顺序应该是:容器 date → 运行时时区 → 应用配置 → 代码里显式取 UTC 的地方,一层层往下对,不要跳层。
三、修复顺序:顺序错了会反复返工
每一层都在读下一层的值,所以必须从下往上改,改一层验一层:
- 时钟源:
systemctl status chrony,chronyc tracking里的System time偏移应在毫秒级。 - 宿主机时区:
sudo timedatectl set-timezone Asia/Shanghai,用date -Is确认带+08:00。 - 镜像:补齐 tzdata,
/usr/share/zoneinfo/Asia/Shanghai必须存在。 - 容器:
TZ与/etc/localtime同时到位,两者不要只留一个。 - 应用:显式声明时区,并确认没有被启动参数反向覆盖。
- 调度:把定时表达式改成带时区的写法,不依赖运行环境的默认值。
一条命令横向比对宿主和所有容器,第 4 步改完立刻能验证:
# 宿主与所有容器的时间对照;epoch 相同而显示不同 = 时区问题
printf '%-24s %s\n' host "$(date -Is)"
docker ps --format '{{.Names}}' | while read -r n; do
printf '%-24s %s\n' "$n" "$(docker exec "$n" date -Is 2>/dev/null || echo 'exec 失败')"
done
第 6 步是最容易被忽略的一环。定时任务如果跑在宿主机 crontab 里,容器只是被 docker exec 拉起执行,那么判断基准在宿主机,容器时区再对也没意义;如果调度在容器内部(应用自带调度器、容器内 crond),就必须让容器时区正确,或者干脆把调度表达式写成带时区的形式。
# vixie-cron / cronie 支持 CRON_TZ,busybox crond 和部分精简镜像不支持
CRON_TZ=Asia/Shanghai
30 0 * * * /opt/recon/run.sh >> /var/log/recon.log 2>&1
# systemd timer(22.04 的 systemd 249 支持 OnCalendar 带时区)
# /etc/systemd/system/recon.timer
# [Timer]
# OnCalendar=*-*-* 00:30:00 Asia/Shanghai
# Persistent=true
Persistent=true 值得加上:机器在计划时间关机或重启,开机后会补跑一次,避免静默漏跑。
四、报错对照表
| 现象 | 原因 | 处理 |
|---|---|---|
容器 date 比宿主慢 8 小时,epoch 一致 |
镜像 /etc/localtime 指向 Etc/UTC,且未设 TZ |
设 TZ + 重建容器;镜像层补 tzdata |
设了 TZ=Asia/Shanghai 仍显示 UTC,无任何报错 |
镜像内缺 /usr/share/zoneinfo,时区名解析失败后静默回退 UTC |
apk add tzdata 或从构建阶段 COPY 时区文件 |
cp: cannot create regular file '/etc/localtime': Read-only file system |
/etc/localtime 以 :ro 挂载,容器内不可写 |
时区改在构建期固化;确需运行期改则去掉 ro,但不建议 |
容器 date 正确,Java 日志仍是 UTC |
JVM 启动参数或框架配置覆盖了系统时区 | 查 -XshowSettings:properties 的 user.timezone,统一改 JAVA_TOOL_OPTIONS |
| 宿主机改了时区,容器内没变 | bind mount 在创建时已解析到具体 zoneinfo 文件 | docker compose up -d --force-recreate 重建 |
| 定时任务在错误时刻触发,日志时间却「看着对」 | 调度按容器本地时间(UTC)算,日志也是 UTC,两者自洽 | 调度表达式显式带时区,或统一改「存储 UTC + 展示转换」 |
| 时区对了,数据库里存的时间仍差 8 小时 | 应用写入时用的是本地时间而非 UTC | 存储层统一 UTC(timestamptz / DATETIME + 明确约定),只在展示层转 |
最后一行其实是这类问题的根治办法:存储与传输统一 UTC,只在展示层转本地时区。时区变成纯显示属性之后,容器时区错了最多让日志难读,不会再把业务日期算错。
五、收口:把判断标准写成脚本
事故之后我们加了一条巡检,不依赖任何人记得检查时区:
#!/usr/bin/env bash
# /usr/local/bin/tz-audit.sh —— 比对宿主与容器 epoch,偏移超 5 秒就报
set -uo pipefail
host=$(date +%s); fail=0
while read -r n; do
[ -z "$n" ] && continue
c=$(docker exec "$n" date +%s 2>/dev/null) || { echo "SKIP $n (exec 失败)"; continue; }
off=$(( c - host ))
if [ "${off#-}" -gt 5 ]; then echo "WARN $n epoch 偏移 ${off}s"; fail=1; fi
done < <(docker ps --format '{{.Names}}')
exit "$fail"
这个脚本只能抓时钟问题——因为 epoch 相同恰恰证明时区问题是抓不到的。所以巡检之外还得有一条静态约定:镜像必须自带 tzdata,TZ 必须显式声明,新增服务上线前跑一次 docker exec <name> date -Is 并肉眼确认偏移。三条都做到,时区就不会再变成一次事故。
文中的命令在 Ubuntu 22.04(glibc 2.35)、Docker 29.x 上验证过;Alpine(musl)与 distroless 的时区文件路径相同,但缺 tzdata 时的回退行为更安静,需要单独确认。其他发行版与更老的 Docker 版本建议先跑一遍第一节的三条检查命令,再决定改哪一层。
- 点赞
- 收藏
- 关注作者
评论(0)