计算机 · 2026 年 11 月 11 日

为什么你的程序在 Docker 里时间不对

容器里时间差 8 小时,多半不是时钟漂移,而是时区数据缺失。从 /etc/localtime 到 JVM 的时区缓存,逐层排查并给出可复现的修法。

日志里每条记录都比北京时间晚 8 小时,容器里 date 打出来是 UTC。第一反应是 NTP 没同步,可宿主机上 timedatectl 显示时间准确无误。问题不在时钟,在时区。

数据库里的 created_at 也偏了 8 小时。更麻烦的是它不影响功能测试,只在跨天统计、定时任务、账单日切时冒出来,属于典型的「上线一周后才被发现」的问题。

先确认是不是时区问题#

容器和宿主机共享内核,CLOCK_REALTIME 是同一个值,容器不可能自己走慢。能不一样的只有用户态对时间的解释:/etc/localtime、TZ 环境变量,以及语言运行时启动时缓存下来的那份副本。

在容器里跑这几条命令,基本能定性:

bash
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 指定。挂载宿主文件适合临时排查,不适合长期方案,因为镜像从此依赖宿主配置,换台机器就可能不一样。

dockerfile
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 名称。

完

#Docker#时区#Linux#排障
回到顶部 ↑