NTP 时间不同步导致 Pod 反复重启排查

起因是收到 k8s 集群多个节点上的 node-feature-discovery-worker Pod(DaemonSet)重启告警,但集群当时负载并不高,不应该是负载过高导致的

排查过程

1. 看容器上一次退出的日志

node-feature-discovery-worker 容器:

E0808 03:45:10.665134       1 main.go:83] "error while running" err="failed to advertise features (via CRD API): failed to get NodeFeature object: Unauthorized"

报错本质是:这个组件调用一次 apiserver 失败就直接 os.Exit,没有内部重试机制,于是被 kubelet 判定为容器异常退出,反复重启。问题变成了:为什么会间歇性认证不了 apiserver?

2. 查 apiserver 日志,定位到鉴权失败

$ kubectl logs -n kube-system kube-apiserver-master-01 --since=48h | grep -c "not valid yet"
501
$ kubectl logs -n kube-system kube-apiserver-master-03 --since=48h | grep -c "not valid yet"
63
$ kubectl logs -n kube-system kube-apiserver-master-02 --since=48h | grep -c "not valid yet"
0

具体报错:

E0808 07:36:19.677228 authentication.go:75] "Unable to authenticate the request" err="[invalid bearer token, service account token is not valid yet]"

为什么 "not valid yet" 直接指向时间问题

这句话是整个排查的转折点。要理解它为什么几乎只可能是时钟问题,需要知道 k8s ServiceAccount Token 的校验机制:

  • kubelet 在节点上通过 TokenRequest API 向 apiserver 申请一个短期 Token,这个 Token 是一个 JWT,里面带 iat(issued at,签发时间)和 exp(过期时间)。
  • apiserver 在校验 Token 时,会拿自己当前的系统时间和 Token 里的 iat / exp 比较。

设想一下:kubelet 刚刚(这一秒)才向 apiserver 申请到这个 Token,Token 里的 iat 就是当前时刻。然后 nfd-worker 拿着这个刚出炉的 Token 去访问 apiserver,apiserver 却说"这个 Token 还没到生效时间"。

一个"现在才签发"的 Token 怎么会"还没生效"?唯一的解释是:

apiserver 校验时用的"当前时间"比 Token 签发时用的"当前时间"要慢。

也就是说——签发 Token 的那台 apiserver(或 kubelet 所在节点)时钟偏快,校验 Token 的这台 apiserver 时钟偏慢。两边对"现在几点"的认知不一致,才会出现"刚签发的 Token 在另一边看来是未来时间签的"。普通的 RBAC 配置错误、Token 过期、网络问题都不会产生"not valid yet"这种措辞——这个词组本身就是时间问题的指纹。

三台 master 的报错次数差异巨大,且 master-02 完全没有——这个分布进一步印证了上面的推断:如果只是单纯的 RBAC/配置问题,三台 apiserver 的报错应该差不多;但这里三台差异巨大,且有一台完全为 0,这种"和具体 apiserver 实例强相关"的分布,几乎只可能是每台 apiserver 自己的本地状态(时钟)不同导致的。配置是共享的、Token 是同一个,唯一不同的就是各台机器的墙上时钟。

3. 对比三台 master 的真实时间

在同一秒内连续 SSH 到三台机器执行 date -u:

参考时间(本地)           2026-08-08 07:40:49.92
master-01                  2026-08-08 07:39:20.46   慢 ~89 秒
master-02                  2026-08-08 07:40:46.24   慢 ~4 秒
master-03                  2026-08-08 07:39:37.07   慢 ~72 秒

三台机器的 timedatectl status 全部显示:

System clock synchronized: no

偏差量和 apiserver 报错次数完全对应:偏差越大,"not valid yet" 报错越多。

4. 顺藤摸瓜查 NTP 配置

集群的时间服务器均指向内网的一个时间服务器

内网时间服务器是使用chrony,排查发现chrony服务出现问题,所以集群节点时间同步失败

5. 为什么集群时间同步告警没有触发

集群里配置了“如果集群节点时间偏差10s触发告警”这条告警规则,但是实际上并没有触发:

- alert: NodeClockSkewDetected
  expr: |
    (
      node_timex_offset_seconds > 10
      and
      deriv(node_timex_offset_seconds[5m]) >= 0
    )
    or
    (
      node_timex_offset_seconds < -10
      and
      deriv(node_timex_offset_seconds[5m]) <= 0
    )
  for: 10m

直接在 Prometheus 里查故障期间 node_timex_offset_seconds 的真实值:

$ curl -s 'http://localhost:9090/api/v1/query?query=max_over_time(node_timex_offset_seconds{nodename="master-01"}[2d])'
{"value":[..., "0.001798053"]}
$ curl -s 'http://localhost:9090/api/v1/query?query=min_over_time(node_timex_offset_seconds{nodename="master-01"}[2d])'
{"value":[..., "-0.047216027"]}

全程都在 ±0.05 秒以内,从未超过 10 秒的阈值——即便同一时段内 master-01 的真实墙上时钟已经偏差到 89 秒。

再查 node_timex_sync_status(1=已同步,0=未同步)在同一时间段的 range 数据:

[[1786109306,"0"],[1786111106,"0"], ... ,[1786174106,"0"],
 [1786175906,"1"],[1786177706,"1"], ...]

在我们修复 NTP 之前的 18 小时窗口里全部是 0,修复后立刻变成 1。

再查 node_timex_maxerror_seconds(内核估计的最大误差上限):

故障期间:稳定钉死在 16(Linux 内核对"未同步"状态设的误差上限硬顶值)
修复后:0.003 ~ 0.9 之间波动(正常范围)

根因:node_timex_offset_seconds 反映的不是"墙上时钟和真实时间差多少",而是"上一次 NTP 客户端成功从服务器拿到应答、并通过 adjtimex() 写入内核的修正量"。由于 systemd-timesyncd 从来没有成功连上过服务器(一直超时),它从来没有调用过 adjtimex() 写入任何真实偏移,这个指标就一直停留在陈旧的接近 0 的值上,跟真实偏差完全脱节。告警规则选错了指标,覆盖不到"NTP 彻底失联"这种最坏场景,只能覆盖"NTP 在正常工作、只是缓慢漂移"的场景。

修复

1. 修复 NTP 服务器

恢复内网时间服务器上的 chrony

2. 全量节点重新同步

对集群节点执行 systemctl restart systemd-timesyncd,让它们重新握手(此前一直是失败状态,需要主动触发重试,不会自愈得很快)

3. 补齐告警盲区

原有规则加上 node_timex_sync_status == 1 前提,只在"已同步但仍缓慢漂移"场景下工作;再新增一条不依赖节点自证的规则,直接拿 Prometheus 采集样本时刻的时间戳跟节点上报的系统时间做差:

    - alert: NodeClockNotSynced
      expr: node_timex_sync_status == 0
      for: 15m
      labels:
        owner: system
        severity: warning
      annotations:
        summary: "节点 {{ $labels.nodename }} 时钟未同步"
        description: "节点 {{ $labels.nodename }} 的 NTP/chrony 已经超过15分钟处于未同步状态(node_timex_sync_status=0),请检查时间服务是否可达。"

    # 不依赖节点自身 NTP 子系统的"自证":直接用 Prometheus 采集样本时刻的时间戳
    # 与节点上报的系统时间做差,哪怕本机 NTP/chrony 状态汇报有误(比如同步到了
    # 错误的服务器,或 sync_status 卡在错误的值),也能独立测出真实的时钟偏差。
    - alert: NodeClockSkewVsPrometheus
      expr: |
        abs(
          node_time_seconds{job="node-exporter"}
          -
          timestamp(node_time_seconds{job="node-exporter"})
        ) > 10
      for: 10m
      labels:
        severity: warning
        owner: system
      annotations:
        summary: "节点 {{ $labels.nodename }} 时钟与监控系统时间不一致"
        description: "节点 {{ $labels.nodename }} 的系统时间和 Prometheus 采集时刻的时间相差 {{ printf \"%.1f\" $value }} 秒,与本机 NTP/chrony 是否报告“已同步”无关,说明真实时钟已经偏移,请检查 NTP/chrony 配置。"

NodeClockSkewVsPrometheus 的关键价值:哪怕节点自己的 NTP 子系统汇报的状态是错的(比如同步到了错误的服务器、或者 sync_status 卡死),只要节点真实系统时间和 Prometheus 服务器的时间对不上,就一定能测出来。