引言:「昨天还好好的,今天证书就无效了」

有一类故障,比任何其他问题都更擅长伪装自己。一个跑了几个月的脚本,一直通过代理访问几十个 API,突然每个请求都开始失败。日志里写着 certificate has expired 或 certificate is not yet valid。工程师在笔记本电脑的浏览器里打开目标网站的证书:有效期正常,信任链全绿。他用笔记本不走代理直接跑脚本——正常。换回代理——又失败。结论似乎不言自明:代理有问题。

但这个结论几乎总是错的。问题不在于代理「替换了证书」或「破坏了 TLS」,而在于脚本运行在另一台机器上——服务器、虚拟机或容器——而这台机器的系统时钟跑偏了。可能偏了几分钟、几小时甚至几天。代理只是整个等式里最显眼的变量,于是背了锅。

我们将全面剖析这个问题。你会了解到现代协议在哪些环节依赖当前时间,以及时钟向不同方向偏移会产生怎样不同的症状。你会看到 curl、Python 和 Node.js 中具体的报错文本,从而在堆栈的的第一行就识别出问题。你会明白为什么时钟偏偏在虚拟机和容器里容易跑偏,以及为什么装了同步守护进程并不意味着它在工作。你将获得一分钟诊断流程、可用的 chrony 和 systemd-timesyncd 配置,以及一份在联系代理服务商之前应该走完的排查清单。

先划清范围。本文不讲证书签发、信任链或根证书存储。我们只讨论一个失败原因——系统时间错误——以及通过代理时由此引发的一切。话题虽窄,却实实在在地烧掉了做集成、监控、公开数据采集以及通过 Proxeon 这类代理基础设施做自动化的团队大量调试时间。

基础:时间为何关键,协议为何要看时钟

先从一个简单的问题开始。网络协议为什么需要知道现在几点?答案只有一个:为了限制信任的有效期。任何密钥——证书、令牌、签名、一次性验证码——迟早都可能泄露。如果它有有效期,泄露造成的损失就是有限的。但有效期限制只有在验证方能知道当前时间的前提下才成立。而验证方获取时间只有一个途径——运行验证的机器上的系统时钟。

由此得出一个根本推论:每一次时间检查都是本地的。服务器不会问客户端现在几点,客户端也不会问服务器。双方各自看自己的时钟然后下判断。如果两边的时钟有偏差,它们看到的就是不同的现实,其中一方就会认为另一方违规。

TLS 中的证书有效期检查

每个 X.509 证书包含两个字段:notBefore(从何时起有效)和 notAfter(到何时失效)。在 TLS 握手时,客户端拿自己时钟的当前时间与该区间做比较。证书的签发时间在这里无关紧要——重要的只是客户端认为现在是什么时间。

偏移方向决定症状。如果客户端时钟偏慢,新签发的证书看起来就像还没签发:报错「certificate is not yet valid」。如果客户端时钟偏快,证书看起来就像已过期:「certificate has expired」。这是第一个值得记住的诊断特征。

为什么现在这个话题变得尖锐了?因为证书寿命在缩短。许多公共证书颁发机构签发 90 天有效期的证书,也出现了 6 天的版本,行业要求还在系统性地压缩最长期限:2026 年 3 月起为 200 天,随后是 100 天,到 2029 年将缩短至 47 天。以前慢了两周的时钟几乎碰不上新证书。现在目标网站可能每隔几天就重新签发证书,一台慢了三天的机器就会把它当作「尚未生效」而拒绝。

同一类问题还涉及相关机制:OCSP 响应和吊销列表有自己的有效窗口,证书透明度(Certificate Transparency)日志包含时间戳,TLS 1.3 的会话票据也有存活期限。所有这些都依据本地时钟来判断。

JWT:exp、nbf 和 iat 字段

JSON Web Token 是 API 中最常见的访问令牌格式。它的负载中有三个以 Unix 时间戳表示的字段:iat(签发时间)、nbf(not before——从何时起有效)和 exp(何时过期)。接收方拿它们和自己的时钟比较,通常会允许一个小的容差(leeway),一般是 30 到 60 秒。

这里出现了一个有意思的不对称。通常令牌由服务器按自己的时钟签发,客户端会缓存它直到 exp 到来——但是按自己的时钟来算。如果客户端慢了五分钟,它会认为令牌还能再用五分钟,而服务器已经开始拒绝了。于是你会在每个令牌生命周期末尾遇到莫名其妙的 401。如果客户端自己签发 JWT——比如用于服务账号认证——而它的时钟偏快,服务器会看到 iat 在未来,从而拒绝这个尚未生效的令牌。

带时间戳的请求签名

很多 API 要求用 HMAC 签名请求,把当前时间包含进待签名字符串。最著名的例子是 AWS Signature Version 4 方案,被兼容 S3 的对象存储广泛使用。服务器只在请求的时间戳与服务器时钟相差不超过 15 分钟时才接受请求,否则返回 RequestTimeTooSkewed 错误。支付网关、交易所和经纪商 API、企业数据总线也采用类似方案,窗口从 30 秒到几分钟不等。企业网络里的 Kerberos 认证默认容忍 5 分钟的偏差。

请注意:签名是你的客户端按自己的时钟生成的。代理在这个过程中根本不参与——它只是转发字节。因此通过代理出现的「签名时间戳错误」永远要查你机器的时钟。

TOTP 一次性验证码

符合 TOTP 标准的双因素认证验证码由共享密钥和 30 秒间隔的编号计算得出。验证方通常允许前后各一个间隔。如果你的时钟偏了一分钟,验证码就会完全不被接受。这同样适用于那些由服务账号以程序方式生成 TOTP 来登录企业门户的集成场景。

不太明显的时间消费点

  • HTTP 缓存。 Date、Expires 和 max-age 头都与本地时钟比较;时钟偏移会导致缓存不生效或返回过期内容。
  • 带 Expires 的 Cookies。 时钟偏慢的客户端会保留更久,偏快的则立即丢弃。
  • HSTS 和 pinning。 max-age 指令也按本地时钟计算。
  • 集群内部的证书轮换。 编排器的组件按计划重新签发证书,节点时钟偏移时它们就不再互相信任。

深入探讨:客户端—代理—服务器链路中谁的时钟重要

现在把这幅图景叠加到真实拓扑上。通过代理工作时,链路中至少有三台机器、三套时钟:你的客户端、代理服务器和目标服务器。诊断的关键问题是:失败的检查发生在哪台机器上?

HTTP CONNECT 和 SOCKS5:代理看不到 TLS

通过 HTTP 代理访问 HTTPS 资源时,客户端发送一条 CONNECT 命令,带上主机名和端口。代理与目标建立 TCP 连接,然后盲目地双向转发字节。TLS 握发生在穿过代理的两个端点之间——你的客户端和目标服务器。代理看不到证书,无法修改它,也不参与验证。SOCKS5 同理。

实际结论:通过透明隧道时,「certificate is not yet valid」或「certificate has expired」错误在物理上不可能由代理引起。来源要么是你机器上的时钟,要么是它上面的受信任根证书存储,要么目标站点的证书真的过期了。前两种都是本地的。而且第一种十秒钟就能排查完。

什么时候会检查更多时钟:到代理本身的 TLS

如果你通过加密通道连接代理(即代理地址用 https://),客户端会验证两个证书:代理服务器的证书和目标网站的证书。两次验证都按客户端时钟执行。症状一样,但在 curl 的详细输出里能看到失败发生在哪个阶段——「Proxy certificate」还是「Server certificate」。

什么取决于代理本身的时钟

代理服务器的时钟只影响有限的一些事情:它自己日志的准确性、它的 TLS 监听器工作(比如获取 OCSP 响应),以及如果服务商使用带有效期的令牌时对授权凭证的检查。Proxeon 基础设施会正常同步其节点的时间,绝大多数故障中代理的时钟都无关紧要:它既不参与目标证书的验证,也不参与你的签名生成或 JWT 验证。这些都是客户端和目标服务器在做。

目标服务器的时钟

偶尔时钟偏差出现在另一端。特征:只有某个主机的请求出错,访问其他主机都正常;该主机响应里的 Date 头与其他服务器明显不同。这你无能为力,只能通知所有者;如果 API 允许,也可以在请求中补偿这个偏差——有些库能从错误响应的 Date 头计算出偏差,并在下一次请求签名时计入。

延迟与「排队中过期」

代理会增加延迟——从几十毫秒到移动网络重连和 IP 轮换时的几秒。对于 15 分钟的窗口来说这微不足道。但重试逻辑里有一个微妙的陷阱。如果你签好请求放进了队列,经过一系列带等待的重试后才发出,签名里的时间戳可能已经过期了。在慢速或拥塞的通道上这种情况更常见。规则是:在发送前一刻签名,每次重试都重新签名,不要复用已签好的对象。

偏移量:大小和方向

把症状与偏移量的对应关系整理成一个表格式列表——读日志时很有用:

  • 客户端时钟偏慢:「certificate is not yet valid」,自签名时 JWT 报 nbf 或 iat 错误,自己缓存的令牌看起来还有效但服务器返回 401「expired」,签名因太旧被拒,TOTP 不被接受。
  • 客户端时钟偏快:「certificate has expired」,自己的 JWT 因 iat 在未来被拒,签名因「来自未来」被拒,令牌提前从缓存中清除,TOTP 不被接受。
  • 偏移小于一秒:上述任何机制都不会察觉。
  • 偏移 30 秒到 5 分钟:TOTP、容差小的 JWT、严格的 HMAC 方案会出问题。
  • 偏移超过 15 分钟:几乎所有请求签名和 Kerberos 都会失效。
  • 偏移以天计:开始无法与使用短寿命证书的网站建立 TLS 连接。

错误在不同客户端中的样子:一眼就能认出的报错文本

能通过报错文本识别问题,比任何方法论都省时间。下面是常见客户端的典型措辞。它们以底层使用 OpenSSL 的库输出的形式呈现;括号里的行号会随版本变化。

curl

时间错误时的基本消息:

curl: (60) SSL certificate problem: certificate is not yet valid
curl: (60) SSL certificate problem: certificate has expired

代码 60 是 CURLE_PEER_FAILED_VERIFICATION,适用于所有证书验证问题。要区分时间原因和根证书问题,只能靠冒号后面的文本。加上 -v 标志会出现一条有用的 OpenSSL 数字验证码行:9 对应「not yet valid」,10 对应「has expired」:

* SSL certificate verify result: certificate is not yet valid (9), continuing anyway.

「continuing anyway」只在带 -k 标志时出现;没有它连接会被断开。在 Windows 上使用 Schannel 后端时文本不同:

curl: (60) schannel: SEC_E_CERT_EXPIRED (0x80090328) - The received certificate has expired.

通过代理做完整检查并显示详细输出,以便同时看到阶段(代理还是目标)和验证结果:

curl -v --proxy http://LOGIN:PASSWORD@PROXY_HOST:PORT https://example.com/ 2>&1 | grep -iE 'verify|expire|valid|date'

Python:ssl、requests、httpx、aiohttp

标准 ssl 模块抛出的异常带有很说明问题的后缀:

ssl.SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: certificate is not yet valid (_ssl.c:1010)

requests 库把它包装成 SSLError,并加上连接池的包装,导致关键词出现在长字符串的最末尾:

requests.exceptions.SSLError: HTTPSConnectionPool(host='example.com', port=443): Max retries exceeded with url: / (Caused by SSLError(SSLCertVerificationError(1, '[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: certificate has expired (_ssl.c:1010)')))

通过代理时的一个重要细节:在 CONNECT 隧道下,目标证书验证失败会表现为 SSLError 而不是 ProxyError。ProxyError 出现在无法建立与代理本身连接时。所以异常类型已经提示了该往哪里查。

httpx 的表述更简短:

httpx.ConnectError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: certificate has expired (_ssl.c:1010)

aiohttp 用自己的类:

aiohttp.client_exceptions.ClientConnectorCertificateError: Cannot connect to host example.com:443 ssl:True [SSLCertVerificationError: (1, '[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: certificate is not yet valid (_ssl.c:1010)')]

PyJWT 在你这边验证令牌时的错误:

jwt.exceptions.ExpiredSignatureError: Signature has expired
jwt.exceptions.ImmatureSignatureError: The token is not yet valid (nbf)

下面是一个通过代理的最小可复现示例,值得作为测试用例常备:

import requests, ssl, sys
PROXY = "http://LOGIN:PASSWORD@PROXY_HOST:PORT"
try:
r = requests.get("https://example.com/", proxies={"https": PROXY}, timeout=15)
print(r.status_code, r.headers.get("Date"))
except requests.exceptions.SSLError as e:
msg = str(e)
if "not yet valid" in msg or "has expired" in msg:
print("看起来是系统时钟偏移,请检查 date -u")
print(msg)

小说明:这个及后面的示例中,换行符以 HTML 实体的形式出现,以便标记保持为一行;复制到编辑器时会变成普通的换行。

Node.js:https、fetch、undici

Node 输出简短的消息,而且有用的是,错误对象带有 code 字段:

Error: certificate is not yet valid
code: 'CERT_NOT_YET_VALID'
Error: certificate has expired
code: 'CERT_HAS_EXPIRED'

内置 fetch 的陷阱:外面只看到 TypeError: fetch failed,真正的原因藏在 cause 属性里。要显式记录它:

try {
 const res = await fetch("https://example.com/");
 console.log(res.status, res.headers.get("date"));
} catch (e) {
 console.error(e.message, e.cause && e.cause.code, e.cause && e.cause.message);
}

在 Node 中让 fetch 走代理要用 undici 包的 ProxyAgent。目标证书错误会以同样的 CERT_HAS_EXPIRED 或 CERT_NOT_YET_VALID 代码出现在 cause 里。经典的 https 模块和 axios 这类库使用 https-proxy-agent 包的 agent——错误代码相同,因为它们都由 Node 内部同一个 OpenSSL 产生。

jsonwebtoken 库验证令牌时区分两种时间情况:

TokenExpiredError: jwt expired
NotBeforeError: jwt not active

签名时服务端的错误

这里文本由 API 定义,但典型措辞很好认。对 S3 兼容存储:

<Code>RequestTimeTooSkewed</Code>
<Message>The difference between the request time and the current time is too large.</Message>

OAuth 服务器会返回带 error="invalid_token" 的 WWW-Authenticate 头,描述类似「The token expired」或「Token used before issued」。金融 API 会报 timestamp outside of recvWindow 或「Timestamp for this request is outside of the allowed window」。只要看到这些说法中的任何一个,首先要查的就是时钟。

时钟为什么会跑偏:虚拟化、容器、没有守护进程、手动修改

主板石英振荡器不是基准。它的典型误差是 10-100 ppm,即不校正的情况下每天漂移最多 1-9 秒。一个月不同步就会累积成几分钟。这是所有机器的背景噪声。但主要故障不是缓慢漂移引起的,而是跳变。我们来分析来源。

虚拟机:暂停、快照、迁移

虚拟机不拥有物理时钟。它通过半虚拟化源(kvm-clock、Hyper-V reference time 及其他 hypervisor 的类似机制)获取时间。当客户机被挂起、保存为快照或在主机间迁移时,它的内部时钟就「冻结」了。恢复后内核要么从 hypervisor 得到校正,要么得不到——取决于客户机代理的设置。经典场景:团队把测试服务器回滚到两周前的快照,它醒来时时钟就慢了 14 天。这两周内签发的所有证书对它来说都「尚未生效」。

另一个麻烦是两个真相来源的冲突。hypervisor 的客户机代理会定期把时间「拉向」主机,而客户机内部又运行着自己的 NTP 守护进程。如果主机自己都不同步,客户机就会收到两个相互矛盾的信号,时钟可能来回跳。虚拟化指南的普遍建议是:要么用 hypervisor 同步,要么用内部 NTP,不要同时用两个。

开发者笔记本:睡眠与桌面虚拟机

Docker Desktop 和 WSL2 运行在一个轻量级虚拟机里。笔记本睡眠或休眠后,这个 VM 的时钟往往会正好落后一个睡眠时长——一小时、一整夜或一个周末。宿主操作系统会把时间拉回来,但虚拟机不一定会。结果:周一早上容器里所有脚本都在 TLS 上失败,而宿主终端里一切正常。正是这种情况最常产生「不用代理从笔记本上能跑,一用代理就失败」的抱怨——而实际上代理在两个场景下可能根本没参与验证。

容器

容器没有自己的实时时钟——它用的是宿主机的时钟。这一点我们会在专门章节详细展开,这里先记住关键:如果宿主机跑偏了,所有容器都跑偏了,而且从容器内部无法修复。

没有同步守护进程或它不工作

精简镜像、「裸装」系统和自编译系统往往完全没有 NTP 客户端。即使有,也可能因为各种原因不工作:防火墙挡住了出站 UDP 123 端口;时间池域名解析不了;服务装了却被 mask 了;同时装了 chrony 和 timesyncd 互相干扰;启动时偏移超过了经典 ntpd 拒绝校正并退出的阈值。特别脆弱的是那些唯一的对外通道是企业网的 HTTP 代理的服务器:NTP 的 UDP 过不了这种代理,时钟就自生自灭了。

没有 RTC 或电池耗尽

Raspberry Pi 这类单板机和很多迷你主机没有带电池的硬件时钟。开机时它们要么从纪元开始,要么从上次保存的日期开始,直到第一次成功同步为止。老服务器的 CMOS 电池会耗尽,每次断电后 BIOS 就把日期重置为出厂值。如果同步不可用,机器就带着十年前的日期运行,任何 TLS 连接都注定失败。

手动修改与时区混淆

执行 date -s 「验证一下假设」然后忘了改回来,是常见剧情。另一个是同一台机器上双系统启动 Windows 和 Linux:Windows 在硬件时钟里存本地时间,Linux 默认期望 UTC,每次切换到另一个系统后时间就会偏一个时区差,莫斯科时间就是三小时。最后是时间和时区的混淆:改 TZ 环境变量或 /etc/localtime 文件只改变显示,不改变系统时间。比较时必须只用 UTC。

一分钟诊断:与外部源对比及可接受偏差

这个过程的目标是在 60 秒内回答一个问题:执行代理请求的那台机器上时钟走得准不准。所有命令都要在出错的那个机器或容器里执行,而不是在你的笔记本上。

第 1 步:查看 UTC 并与任何可信来源对比

date -u

与和运营商网络同步过的手机时钟比较。差几分钟一眼就能看出来。要精确到秒需要做下面的步骤。

第 2 步:通过同一个代理从 HTTP 响应头 Date 获取时间

任何正确的 HTTP 服务器都会按自己的(通常是同步好的)时钟返回 Date 头。技巧是:用普通 HTTP 请求,不要用 HTTPS,因为偏移大时 TLS 根本建立不起来,你连头都拿不到:

date -u; curl -sI --proxy http://LOGIN:PASSWORD@PROXY_HOST:PORT http://example.com/ | grep -i '^date'

两行应该精确到一两秒内吻合(响应头只有秒级粒度,加上通道延迟)。发现差一分钟以上——诊断完成。这个技巧特别有价值,因为它不用 UDP,通过任何代理都能用,且不依赖 TLS。

用 Python 实现同样的技巧——方便嵌入通过 Proxeon 或任何其他代理的服务健康检查里:

import email.utils, time, requests
PROXY = "http://LOGIN:PASSWORD@PROXY_HOST:PORT"
r = requests.head("http://example.com/", proxies={"http": PROXY}, timeout=10)
server_ts = email.utils.parsedate_to_datetime(r.headers["Date"]).timestamp()
skew = time.time() - server_ts
print(f"本地时钟偏移:{skew:+.1f} 秒")
if abs(skew) > 30:
raise SystemExit("时钟已跑偏,必须同步")

第 3 步:问系统它自己认为同步了没有

timedatectl

查找两行:「System clock synchronized: yes」和「NTP service: active」。任何一个显示「no」或「inactive」都是去配置章节的信号。如果装的是 chrony:

chronyc tracking

看「System time」(偏移量和符号)、「Stratum」(不应为 0)和「Leap status」(应为 Normal,而不是「Not synchronised」)。

第 4 步:一次性查询 NTP 服务器而不改时钟

如果对外 UDP 通,可以精确测量偏移而不做任何更改:

chronyd -Q 'pool pool.ntp.org iburst'

或者如果装了 ntpdate:

ntpdate -q pool.ntp.org

输出会以秒为单位显示带符号的 offset。

第 5 步:确认就是时间在破坏 TLS

openssl 工具能通过 HTTP 代理连接,并打印带数字代码的验证结果:

openssl s_client -connect example.com:443 -proxy PROXY_HOST:PORT -servername example.com </dev/null 2>/dev/null | grep 'Verify return code'

在证书本身有效的情况下显示「Verify return code: 9 (certificate is not yet valid)」或「10 (certificate has expired)」,就是确凿证据。注意大多数构建的 s_client 不支持代理的用户名密码认证;这项检查最好用 Proxeon 支持的 IP 白名单授权。要提取证书本身的日期:

openssl s_client -connect example.com:443 -proxy PROXY_HOST:PORT -servername example.com </dev/null 2>/dev/null | openssl x509 -noout -dates

什么偏差算正常

  • 100 毫秒以内——NTP 正常工作的健康机器。什么都不用做。
  • 100 毫秒到 1 秒——对本文讨论的所有协议都可接受,但说明源质量差或轮询太稀疏。值得检查配置。
  • 1 到 30 秒——黄区。TLS 和窗口宽的签名还能用,TOTP 和 leeway 小的 JWT 可能已经开始出错。今天就修。
  • 超过 30 秒——红区。在证明相反之前,把任何认证或 TLS 错误都当作时钟引起的。

如何故意复现问题

想验证你的错误处理能正确识别时间故障,不需要动系统时钟。libfaketime 库可以只为单个进程替换时间:

faketime '2024-01-01 12:00:00' curl -sS https://example.com/ -o /dev/null

对任何证书签发时间晚于指定日期的网站,你都会得到「certificate is not yet valid」。要在无网络时检查证书,OpenSSL 支持 -attime 标志并传入 Unix 时间,便于在 CI 中测试。

同步配置:systemd-timesyncd 与 chrony,以及如何确认它真的在工作

安装软件包不等于同步好了。下面是两套可用方案,更重要的是验证方法。

选择:timesyncd 还是 chrony

systemd-timesyncd——极简的 SNTP 客户端,内置于 systemd。从列表中的一个服务器轮询,支持跳跃式和渐进式校正,不作为服务器工作,不支持硬件源。对工作站和非关键服务器足够用,精度在几十毫秒量级。

chrony——完整的 NTP 实现。组合多个源,长时间停机后快速收敛,在断续网络下表现稳健,支持 NTS(通过 TLS 认证的 NTP),还能在网内分发时间。这是服务器、虚拟机和任何有签名请求和代理工作的节点的默认选择。

重要:同时跑两个守护进程是混乱之源。安装 chrony 时发行版通常会禁用 timesyncd,但要显式确认。

方案 A:systemd-timesyncd

配置文件 /etc/systemd/timesyncd.conf:

[Time]
NTP=ntp.your-provider.internal 0.pool.ntp.org 1.pool.ntp.org
FallbackNTP=2.pool.ntp.org 3.pool.ntp.org
PollIntervalMinSec=32
PollIntervalMaxSec=2048

启用和检查:

sudo timedatectl set-ntp true
sudo systemctl restart systemd-timesyncd
timedatectl timesync-status

timesync-status 输出中 Server 应已填写,Stratum 不为 0,Offset 字段以毫秒计。如果服务器未指定或显示「Packet count: 0」,说明 NTP 应答没到——几乎肯定是 UDP 123 被关了。

方案 B:chrony

文件 /etc/chrony/chrony.conf(某些系统是 /etc/chrony.conf):

server ntp.your-provider.internal iburst prefer
pool pool.ntp.org iburst maxsources 4
driftfile /var/lib/chrony/drift
makestep 1.0 3
rtcsync
logdir /var/log/chrony

我们逐个解析关键指令,因为正是它们决定了故障时的行为:

  • iburst——启动时连续发出一系列请求,几秒钟内同步完成,而不是几分钟。
  • makestep 1.0 3——如果偏移超过 1 秒,前三次更新允许跳变,之后只做渐进校正。没有这条指令,快照或睡眠后 chrony 会花几周「追」时钟。对经常睡眠或迁移的服务器,有些人设置 makestep 1.0 -1,始终允许跳变。
  • rtcsync——定期把时间写入硬件时钟,以便重启后从一个接近的值开始。
  • 服务商内部服务器优先——云平台通常提供内网 NTP 地址;它即使外部 UDP 被关也可达,且延迟最小。从你的服务商文档中获取地址。

启动:

sudo systemctl enable --now chrony
sudo chronyc makestep
chronyc tracking
chronyc sources -v

如何判断同步真的在工作

这是本节最重要的部分。chrony 同步工作的特征:

  1. chronyc tracking 中 Reference ID 不为零,Stratum 对典型服务器在 2-4 范围内。Stratum 为 0 或 10 且 Reference ID 为空意味着「自己在跟自己玩」。
  2. Leap status:Normal。「Not synchronised」的值意味着没有任何人在校正时钟。
  3. System time 显示毫秒或微秒级的偏移,而不是秒级。
  4. chronyc sources 中至少一个源标有星号(当前选中)或加号(参与组合)。所有源都是问号——应答没到,去查防火墙或 DNS。
  5. Last offset 和 RMS offset 随时间减小,而不是增大。

对 timesyncd 来说,等效检查是 timedatectl 中的「System clock synchronized: yes」和 timesync-status 中非零的 Packet count。

当 NTP 被防火墙挡住

如果服务器只允许通过代理走 HTTP(S) 出站,你有三条路。第一,请网络管理员至少对内部 NTP 服务器开放 UDP 123;这是标准且安全的请求。第二,如果是虚拟机或容器,从 hypervisor 或宿主机同步。第三,折中方案是用 htpdate 工具按 HTTP 响应的 Date 头设置时钟;精度在秒级,对 TLS、JWT 和请求签名已经足够,而且它能通过 HTTP 代理工作。不优雅但对隔离网段是可行的。

Windows

Windows 主机上的时间服务这样检查:

w32tm /query /status
w32tm /resync

域环境中源是域控制器,非域环境中是配置的 NTP 服务器。Last Successful Sync Time 字段应该是新的。

容器与时钟:什么从宿主机继承,什么不继承

容器是共享内核之上的一套隔离命名空间。隔离涉及进程、网络、文件系统、挂载点。但实时时钟(CLOCK_REALTIME)不被隔离。Linux 5.6 引入的时间命名空间只允许偏移单调时钟和自启动以来的时间——这是为容器迁移准备的,不影响日历时间。由此得出几个硬性结论。

什么会继承

  • 日历时间。 所有容器看到的和宿主机完全一样的时间。宿主机跑偏了,全跑偏了。
  • 同步。 宿主机上运行的 chrony 会自动「修复」所有容器的时间。
  • 跳变。 如果宿主机跳跃式校正了时钟,容器里的应用会立刻看到这个跳变。

什么不继承

  • 时区。 /etc/localtime 文件和 TZ 变量存在于镜像内部。精简的 Alpine 镜像可能没有 tzdata 包,容器永远显示 UTC。这不是时间错误,而是显示错误,但读日志时经常让人误判。
  • 根证书存储。 不是本文主题,但提醒一下:它也在镜像内部。
  • 改时间的权限。 设置时间的系统调用需要 CAP_SYS_TIME,容器默认没有。在容器里执行 date -s 会以「Operation not permitted」失败。在普通容器里跑 NTP 守护进程没有意义,而给它 CAP_SYS_TIME 是不良实践:它会开始管理整个宿主机的时钟。

实际影响

上一节的诊断可以而且应该也在容器内执行——date -u 和通过代理请求 Date 头会显示真实情况。但修复总是在宿主机上进行。如果容器跑在 Kubernetes 里,需要的宿主是 pod 被调度的那个具体节点;确保 chrony 在节点基础镜像里,并让监控从每个节点采集偏移指标。

笔记本上的 Docker Desktop 和 WSL2 是另一回事,因为那里容器的「宿主」是一个隐藏的虚拟机,睡眠后容易落后。WSL2 的快速修复是从 hypervisor 维护的硬件时钟拉时间:

sudo hwclock -s

对 Docker Desktop 来说,彻底但可靠的方法是重启应用;VM 启动后会与宿主同步。新版本里问题出现得少了,但并未完全消失,所以在开发环境里加个时钟偏移的健康检查不是偏执,而是省下早晨一小时。

示例:compose 文件中的健康检查

思路是:如果宿主机时钟偏移超出允许范围,就不要启动工作容器。检查用同一个技巧——通过代理用普通 HTTP 取 Date 头,从而不依赖 TLS:

healthcheck:
 test: ["CMD", "python", "/app/clock_check.py"]
 interval: 5m
 timeout: 20s
 retries: 1

clock_check.py 脚本就是我们在诊断章节里写的那个,阈值 30 秒。编排器会把容器标记为不健康,你会从状态里看到原因,而不是从应用日志里一大堆 TLS 错误中去找。

常见错误与实战案例

讲不该做什么比讲该做什么更有用。这里收集了故障时刻看似合理、事后代价高昂的做法,以及几个带数字的典型案例。

错误 1:关闭证书验证

-k 标志、verify=False、NODE_TLS_REJECT_UNAUTHORIZED=0。脚本又「跑起来」了,任务关闭。但请求签名和 JWT 还是对不上,TOTP 还是不被接受,而你关掉了唯一保护通道的机制。时钟依然是歪的,一周后还会弄坏别的东西。关闭验证只在诊断时用一条命令可以,永远不要出现在代码里。

错误 2:把 leeway 或签名窗口「留足余量」地调大

把 JWT 验证容差提到 10 分钟,等于把被截获令牌重放攻击的窗口扩大 20 倍。真正的问题(时钟)并没有消失。JWT 的合理容差是 30-60 秒,HMAC 签名用 API 提供方规定的值。

错误 3:手动改时间代替同步

date -s 只能让症状消失几小时。漂移和下一次快照会把一切还原,而且到时候没人记得手动改过时间。

错误 4:同时跑两个时间守护进程

chrony 和 timesyncd,或者 chrony 和启用了同步的 hypervisor 客户机代理,会把时钟往不同方向拉。chrony 日志里表现为持续的大幅校正。只保留一个真相来源。

错误 5:拿本地时间和 UTC 比较

差正好 3、5 或 7 小时——几乎总是时区问题而不是时钟故障。始终用 date -u 与 UTC 源比较,日志里写时间时明确标注时区。

错误 6:相信「installed」状态

Ansible 角色装了 chrony,任务变绿。但 UDP 123 被关,chrony 在「跟自己同步」。基础设施配置里的检查应该看 Leap status 和偏移,而不是包有没有装。

错误 7:重试时复用已签名的请求

尤其是通过有重连的代理时。每次尝试都应该获得新的时间戳和新的签名。

案例 A:测试环境的快照

一个采集公开价格数据的团队把 worker 池放在虚拟机里,每周把环境回滚到「干净」快照。一次回滚后,所有通过代理的 worker 在三分之一的目标网站上开始报「certificate is not yet valid」——正是那些用 90 天证书且在这 12 天里重新签发过的站点。工程师用笔记本不走代理访问这些站点一切正常。一个小时半花在了切换代理池和套餐上,直到有人执行了 date -u,看到了 12 天的滞后。修复:在环境基础镜像里加带 makestep 的 chrony,并在恢复后脚本里加偏移检查。之后再没复现过。

案例 B:隔离网段与迷你主机

一台没有硬件时钟的迷你主机放在只有 HTTP 通过企业代理出网的网段里。计划停电后,它带着镜像构建日期启动——落后 14 个月。没有任何 TLS 连接能建立,看起来就像代理彻底挂了。解决方案:对公司的内部 NTP 服务器开放 UDP 123,chrony 加 makestep 1.0 -1 指令,并在没有 NTP 时启动 htpdate 通过代理取时间。

案例 C:从 Docker Desktop 上传到对象存储

一位开发者在笔记本上从容器通过代理调试往 S3 兼容存储上传文件。午休回来后,所有请求开始返回 RequestTimeTooSkewed。Docker Desktop 虚拟机的时钟偏移——23 分钟,刚好超过 15 分钟的窗口。重启 Docker Desktop 一分钟解决了问题;项目里加了偏移健康检查,能立刻显示原因。

案例 D:50 秒与 TOTP

一个服务用带 TOTP 双因素的服账号自动登录自家企业门户。验证码开始不被接受:服务器说验证码错误。节点时钟偏移 50 秒,因为 timesyncd 装了但迁到 chrony 时被 mask 了,而 chrony 一直没启用。在一个 30 秒间隔的容差下,这就够了。代码零改动,只是启动了 chrony。

工具与「先别怪代理」排查清单

把所有内容整理成可以贴进工单或写进 runbook 的形式。先是工具,然后是流程。

工具

  • date -u、timedatectl、chronyc、hwclock——时钟和同步状态。
  • curl -v --proxy、openssl s_client -proxy——通过代理检查 TLS 并查看验证码。
  • chronyd -Q、ntpdate -q——单次测量偏移而不修改时钟。
  • HTTP 的 Date 头——通用时间来源,通过任何代理都能用。
  • libfaketime、openssl -attime——复现故障并测试错误处理。
  • htpdate——UDP 被封网段里的应急同步。
  • 指标——chrony 或 timesyncd 的导出器把偏移送到监控系统;告警阈值 1 秒,严重阈值 30 秒。

排查清单

  1. 出错的代码到底在哪运行?笔记本、服务器、VM、容器、pod。所有检查都在那里做。
  2. date -u 和手机对得上吗?差几分钟——停,先修时钟。
  3. 通过代理访问 http:// 拿到的 Date 头与本地 UTC 在几秒内吻合吗?不吻合——是时钟问题。
  4. timedatectl 显示 synchronized: yes,chronyc tracking 显示 Leap status Normal 吗?不是——同步就没在工作,不管装了什么。
  5. 报错文本里有 not yet valid、has expired、RequestTimeTooSkewed、jwt expired、jwt not active、timestamp 吗?其中任何一个词都是时间问题。
  6. 从同一台机器不用代理,报错一样吗?一样——跟代理无关。
  7. 从另一台机器通过代理,没有报错吗?是——问题在第一台机器。
  8. openssl s_client 通过代理在证书日期正常的情况下显示 Verify return code 9 或 10 吗?诊断确认。
  9. 只有某一个目标主机出错吗?把它的 Date 和其他比较——可能是它的时钟跑偏了。
  10. 每次重试都重新生成签名吗?不是——修正重试逻辑。
  11. 最近有快照、睡眠、迁移、长时间关机后重启吗?任何一个是「有」——几乎肯定就是原因。
  12. 只有以上全是「没有」之后——才联系代理服务商,并附上 date -u、chronyc tracking 和 curl -v 的输出。这样 Proxeon 或任何其他服务的支持一轮就能解决,而不是五轮。

FAQ:关于时间、代理和故障的常见问题

代理本身会篡改我请求或 TLS 中的时间吗?

通过 CONNECT 或 SOCKS5 隧道时——不会。TLS 握手发生在你的客户端和目标服务器之间,代理只看到加密字节。签名和 JWT 里的时间戳是你的代码在你机器上生成的。代理唯一能加的只有延迟,而且是毫秒级,不是分钟级。

为什么不用代理从笔记本能跑,用代理从服务器就跑不了?

因为这是两台不同的机器、两套不同的时钟。这个比较在结构上就不成立:同时改变了两个变量。要隔离代理的影响,就从服务器不用代理跑一次。要隔离机器的影响,就从笔记本通过代理跑一次。几乎总是会发现错误和机器绑定。

通过代理工作时时间要多准?

对 TLS,如果证书寿命长,小时级精度就够;对短寿命证书,天级精度就够。对请求签名,典型窗口是 5-15 分钟。对 JWT,容差 30-60 秒。对 TOTP,前后 30 秒。正常工作的 NTP 能给出毫秒级精度,所以实际标准是偏移小于 1 秒,超过 30 秒告警。

我能在容器里跑 NTP 客户端吗?

技术上——只有带 CAP_SYS_TIME 才行,那样它会管理整个宿主机的时钟,破坏隔离并与宿主机上的守护进程冲突。正确做法是在宿主机或集群节点上同步。容器里只留偏移检查。

如果 UDP 123 被彻底封了,只有 HTTP 代理出口怎么办?

申请至少对内部 NTP 或云服务商开放 UDP 123——这是标准的基础设施需求。在解决之前,用 hypervisor 同步或通过代理按 Date 头跑 htpdate。注意 NTS 保护的是 TCP 4460 端口上基于 TLS 的 NTP,但时间包本身仍走 UDP,所以 NTS 绕不过 UDP 封锁。

时钟快了几分钟,但 TLS 还能工作。为什么对 API 的请求会失败?

因为 TLS 的窗口是天级,而签名和令牌的窗口是秒级和分钟级。这是正常现象:证书验证通过,但时间戳偏到未来三分钟的 HMAC 签名会被窗口严格的服务器拒绝。要看 API 返回的错误文本,而不只是连接是否成功。

怎么判断是目标服务器的时钟跑偏了,而不是我的?

把出问题主机的 Date 响应头与两三个其他主机以及你的 date -u 比较。如果你的时钟和大多数一致,而只有一台主机偏离——问题在它那边。有些客户端 SDK 能从报时间错误的响应里自动计算服务器偏移并补偿。

通过代理重试时需要重新签名请求吗?

需要。签名包含时间戳,只在窗口内有效。如果第一次尝试和重试之间隔了几分钟等待和重连,旧签名可能已经超窗。在每次尝试发送的那一刻生成签名。

怎么把时钟检查纳入监控?

从 chrony 或 timesyncd 采集偏移到指标系统,设两个阈值:1 秒警告,30 秒严重。另外加一个服务健康检查,通过工作中的代理按 Date 头验证;它能捕获时间守护进程名义上活着但时钟还是跑偏的情况,比如快照恢复后。

2026 年有什么变化吗?

最大的变化是公共证书寿命缩短和自动重签发的普及。这让哪怕几天的小幅时钟滞后也成为 TLS 故障的真实原因,而以前这类事故很罕见。同时 API 中短寿命 JWT 和严格签名窗口的比例在上升。换句话说,对客户端机器时钟精度的要求在提高,时间同步不再是「那种不用管的默认配置」。

结语:把时间作为诊断的第一步

总结一下。证书有效期检查、JWT 的 exp 和 nbf 字段、请求签名的时间戳、一次性验证码——所有这些都是在本地与系统时钟做比较。通过代理工作时链路里多了一台机器,注意力自然会转移到它身上,尽管 TLS 握手、签名和令牌验证都不经过代理隧道。这些场景里代理只是传输通道。而时钟跑偏有着非常接地气的原因:虚拟机的快照和暂停、笔记本上的 Docker Desktop 和 WSL2 睡眠、同步守护进程缺失或不工作、UDP 被封、电池耗尽、手动修改。

实践方案很简单,三步就能概括。第一——养成通过文本识别时间错误的习惯:not yet valid、has expired、RequestTimeTooSkewed、jwt expired、jwt not active。第二——把时钟偏移检查作为任何代理故障排查的第一步:date -u、通过代理取 HTTP 的 Date 头、timedatectl 或 chronyc tracking。这是一分钟,能省下几小时。第三——让问题不再复发:基础镜像里的 chrony 配 iburst 和 makestep,每台机器一个时间源,容器在宿主机上同步,监控里有偏移指标,通过代理的服务里有健康检查。

这些做完之后,联系代理服务商才是有意义的:你带着已排除的假设和现成的命令输出过去,排查就能直击要害。而大多数情况下根本不需要联系——因为时钟会成为你第一个检查的东西,也是最后一个让你意外的东西。