日志里每条记录都比北京时间晚 8 小时,容器里 date 打出来是 UTC。第一反应是 NTP 没同步,可宿主机上 timedatectl 显示时间准确无误。问题不在时钟,在时区。
数据库里的 created_at 也偏了 8 小时。更麻烦的是它不影响功能测试,只在跨天统计、定时任务、账单日切时冒出来,属于典型的「上线一周后才被发现」的问题。
先确认是不是时区问题#
容器和宿主机共享内核,CLOCK_REALTIME 是同一个值,容器不可能自己走慢。能不一样的只有用户态对时间的解释:/etc/localtime、TZ 环境变量,以及语言运行时启动时缓存下来的那份副本。
在容器里跑这几条命令,基本能定性:
date -u; date; ls -l /etc/localtime; echo "TZ=$TZ"
TZ=Asia/Shanghai date
如果 date -u 和宿主机一致、date 差 8 小时,就是时区配置问题,不用碰 NTP。TZ=Asia/Shanghai date 如果仍然输出 UTC,说明镜像里根本没有 zoneinfo 数据,设变量也没用。
三种修法,各有边界#
| 做法 | 命令 | 适用 | 坑 |
|---|---|---|---|
| 设 TZ 环境变量 | -e TZ=Asia/Shanghai |
镜像含 tzdata | 无数据时静默退回 UTC |
| 挂载宿主时区文件 | -v /etc/localtime:/etc/localtime:ro |
临时调试、单机 | 宿主换时区后容器不跟随 |
| 镜像内装 tzdata | apk add --no-cache tzdata |
生产镜像 | 增加几 MB,要清缓存 |
最稳的是第三种:镜像自带 tzdata,再用 TZ 指定。挂载宿主文件适合临时排查,不适合长期方案,因为镜像从此依赖宿主配置,换台机器就可能不一样。
FROM alpine:3.20
RUN apk add --no-cache tzdata \
&& cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
&& echo "Asia/Shanghai" > /etc/timezone
ENV TZ=Asia/Shanghai
注意 apk add 之后要清 /var/cache/apk,否则镜像白白大几 MB。
还有一处语法陷阱:TZ=UTC+8 不是「东八区」。POSIX 的 TZ 字符串里符号是反的,CST-8 才表示 UTC+8。写错方向会得到差 16 小时的结果,而且程序不报错。
验证时别用 docker exec 进去临时 export TZ。exec 启动的 shell 继承镜像的 ENV,你在会话里 export 只影响这个 shell,服务进程仍然用旧值。要验证就重新 docker run 一个容器,或者直接看进程的环境:tr '\0' '\n' < /proc/<pid>/environ | grep TZ。
挂载单文件为什么有时不生效#
-v /etc/localtime:/etc/localtime:ro 在 Linux 宿主上通常有效,但有两个前提容易忽略。
第一,Docker Desktop(macOS、Windows)跑在一台 Linux 虚拟机里,/etc/localtime 是虚拟机的文件,不是宿主系统的时区,挂进去可能还是 UTC。
第二,镜像里的 /etc/localtime 常常是指向 /usr/share/zoneinfo/Etc/UTC 的软链接。只挂这一个文件不会同步整套 zoneinfo,依赖 /etc/timezone 或按名字查时区的程序仍然拿到旧值,两套配置互相矛盾。
要挂就挂整个目录:-v /usr/share/zoneinfo:/usr/share/zoneinfo:ro,再配合 TZ。这样既不改镜像,也不会破坏软链接结构。
语言运行时各有各的缓存#
改完系统时区,跑着的进程不一定立刻生效,因为很多运行时在启动时就把时区读走了:
- Java:JVM 启动时确定默认时区,之后改
/etc/localtime无效。用-Duser.timezone=Asia/Shanghai,或保证容器启动前TZ已设置。验证命令:java -XshowSettings:properties -version 2>&1 | grep user.timezone。 - Python:
time.tzset()会重新读 TZ;zoneinfo.ZoneInfo("Asia/Shanghai")依赖系统 tzdata,缺失时抛ZoneInfoNotFoundError,可以装tzdata包兜底。 - Go:
time.Local启动时读 TZ,静态编译的二进制在 scratch 镜像里没有 zoneinfo,需要-tags timetzdata把数据编进去,或把/usr/share/zoneinfo拷进镜像。 - Node.js:启动时读 TZ,运行中改
process.env.TZ的行为在不同版本上并不一致,别依赖它。
这些都是「进程启动那一刻」的快照,不是实时查询。所以配置时区的正确时机是容器启动之前,而不是出事之后。
Java 连 MySQL 还有一层:JDBC 的 connectionTimeZone(旧版本叫 serverTimezone)决定驱动怎么解释 TIMESTAMP,和 JVM 时区是两套独立配置。只改了 -Duser.timezone 却没动 JDBC 参数,写进库里的值照样会偏。
时区对了,时间还差几秒#
这类问题的偏差不是 8 小时,而是几百毫秒到几秒,而且只在长时间运行后显现。原因通常是宿主或虚拟机的时钟漂移。
容器共享宿主内核时钟,容器内无权设置时间(需要 CAP_SYS_TIME),只能由宿主同步。在宿主上 chronyc tracking 看当前偏移和漂移率,chronyc sources -v 看时间源是否可达。chrony 多数时候是渐进校正,偏差超过 makestep 阈值时才会跳变,所以你会看到偏移慢慢收敛,而不是「一 synced 就准」。
注意 NTP 校正的是墙钟,CLOCK_MONOTONIC 不受影响。这也是为什么同一个进程里,测耗时用单调时钟更可靠。
时间命名空间管不了时区#
Linux 有 time namespace,可以偏移 CLOCK_MONOTONIC 和 CLOCK_BOOTTIME,但 CLOCK_REALTIME 是全局的,Docker 默认也不给容器独立的 time namespace。换句话说,容器时间不可能靠命名空间和宿主错开,能错的只有解释方式。
顺带一个容易被忽略的点:CLOCK_MONOTONIC 不受 NTP 调整影响,测耗时应该用它,而不是 time.time() 这类墙钟。墙钟会因为闰秒、NTP 回拨跳变,定时器算出负的间隔就是这么来的。
定时任务为什么在凌晨跑#
服务里有个每天 00:10 执行的清理任务,上线后总在北京时间 08:10 才跑。日志上看任务确实执行了,只是晚了 8 小时——因为容器是 UTC,cron 按容器的时区解释那行 10 0 * * *。
修复要同时确认三件事:容器 TZ、cron 守护进程自己的时区、调度系统的解析口径。cronie(RHEL 系)支持在 crontab 顶部写 CRON_TZ=Asia/Shanghai,Debian 自带的 cron 不认这个变量,只能依赖容器 TZ。Kubernetes 的 CronJob 默认按 UTC 解析 schedule,新版本可以用 timeZone 字段显式指定,升级集群时记得复查这个字段。
存储用 UTC,展示再转换#
时区配好只是第一步。数据库里存什么,决定了以后换时区会不会出事。
MySQL 的 TIMESTAMP 会按会话时区转换,DATETIME 不会。用 SELECT @@global.time_zone, @@session.time_zone, NOW(), UTC_TIMESTAMP(); 看清当前会话的偏移。PostgreSQL 的 timestamptz 存 UTC、显示时按 TimeZone 参数转换,SHOW timezone; 可查。
我倾向的约定是:存储层统一 UTC,接口和日志用带偏移的 ISO 8601(2026-11-11T14:00:00+08:00),只在最终展示时转本地时间。这样跨时区部署、跨年查询都不会出现「少了一天」。
改数据库时区参数会改变已有数据的解释,不是纯配置变更。先在测试库验证,再备份后操作。
日志时间戳也建议固定格式,别用本地化字符串。Java 的 logback 用 %d{yyyy-MM-dd'T'HH:mm:ss.SSSXXX},Go 用 time.RFC3339Nano,Python 用 datetime.now(timezone.utc).isoformat()。带偏移的字符串在任何时区的机器上都能还原成同一时刻,排查时不用再问「这条是哪个时区打的」。
结论#
- 容器时间不对,先比
date -u和date,确认是时区解释问题而不是时钟漂移。 - 生产镜像自带 tzdata 并设
TZ,挂载/etc/localtime只适合临时排查。 - 运行时在启动时缓存时区,配置必须在容器启动前完成。
- 存储统一 UTC,展示层再转换;
TIMESTAMP和DATETIME的语义别混用。 - POSIX 的 TZ 字符串符号与直觉相反,不确定就用 IANA 名称。
完