Skip to content

计算节点在线却被判 HeartbeatExpired(时钟偏移) ​

症状 ​

cubeopscli node ls --json(WebUI /nodes 页面只用橙点标记该节点——不渲染状态文字、也不显示原因)显示某计算节点 Healthy=false、UnhealthyReason=HeartbeatExpired,但该节点其实正常在线——cubelet 在运行、心跳也在上报。本应调度到该节点的沙箱创建失败,报 no ready replica / no more resource,因为调度器会过滤掉 HeartbeatExpired 节点。

关键判据:该节点的 MetaDataUpdateAt 时间戳一直在推进(心跳确实在到达),但节点仍被判过期。

环境 ​

  • CubeSandbox v0.7.x,多节点部署(控制节点与计算节点分属不同主机)
  • 计算节点时钟未同步到统一时间源(NTP/chrony)
  • 相关组件:cubelet(心跳上报)、cube-ops(健康判定)

根因 ​

cubelet 每秒左右向 cube-ops 上报一次心跳(POST /internal/v1/node-agent/nodes/<node-id>/status;node_status_update_frequency 在 Cubelet/config/config.toml 默认 1s;<node-id> 是节点标识——K8s chart 下是节点名,one-click/裸金属路径下通常是配置的 node ID),其中 HeartbeatTime 用计算节点本地 wall clock 打戳。cube-ops 用自己本地 wall clock 作为 now 判定健康:

go
// CubeOps/internal/nodemanagement/service/health.go
func EvaluateHealth(reportedReady bool, heartbeatTime, now time.Time, timeout time.Duration) HealthStatus {
    if heartbeatTime.IsZero() || now.Sub(heartbeatTime) > timeout {
        return HealthStatus{Healthy: false, UnhealthyReason: model.ReasonHeartbeatExpired}
    }
    ...
}

// MetadataTimeout 返回 cubelet 心跳过期窗口(15s)
func MetadataTimeout() time.Duration { return 15 * time.Second }

窗口是 15s。如果计算节点时钟比控制节点慢超过 15s,它打的 HeartbeatTime 在 cube-ops 看来已经超过 15s 前——于是每条刚到的心跳"一到达就过期"。心跳流量本身没问题,只是两端时钟不一致。

这不是代码 bug,而是分布式系统的标准前提:所有节点共享同一时间基准。chrony 未配置 server 的计算节点(Stratum=0、Reference ID=00000000)会自由漂移,VM/PVM 计算节点尤其容易漂。

诊断 ​

  1. 查看节点状态与原因。默认 node ls 表只打印 NODE_ID / NODE_IP / INSTANCE_TYPE / ZONE / CPU_TYPE / HEALTHY / SCHEDULING_DISABLED / HOST_STATUS——不显示 UnhealthyReason 和心跳时间戳,需用 --json(完整字段,两者都能看)或 --score-only(切到含 METADATA_UPDATE 的分数/时间视图,但会去掉 HEALTHY):

    bash
    cubeopscli node ls --json          # 完整字段:Healthy, UnhealthyReason, MetaDataUpdateAt
    # 或,观察心跳时间戳是否在推进(此视图去掉 HEALTHY/UnhealthyReason):
    cubeopscli node ls --score-only    # NODE_ID / SCORE / METRIC_UPDATE / METRIC_LOCAL_UPDATE / METADATA_UPDATE
    # 关注:Healthy=false, UnhealthyReason=HeartbeatExpired,
    # 且 MetaDataUpdateAt 仍在推进(它可能因偏移量而看起来偏旧——
    # 因为它来自计算节点自己的 HeartbeatTime;采样两次确认它在动,
    # 而不是按控制节点的时钟判断"较新")。
    #(--json 是能同时看到原因和时间戳的模式)
  2. 区分"时钟偏移"与"真离线"。若 MetaDataUpdateAt 几天没变,是 cubelet 真的停了——那是真离线,不是本问题。若 MetaDataUpdateAt 一直在更新但节点仍 HeartbeatExpired,就是两端时钟不一致。

    注意这个比较是相对的:EvaluateHealth 用 cube-ops 的 wall clock(控制节点上 now = time.Now())做 now.Sub(heartbeatTime),只有当 heartbeat 时间在控制节点时钟看来是过去——即计算节点时钟落后于控制节点——才触发。任意一端失步都可能造成这个相对条件:计算节点漂慢,或控制节点跳快(控制节点自己的 chrony 未同步、或 VM pause/resume)。症状本身是单向的(计算节点偏快则保持健康),但任一时钟都可能是相对偏移的来源,所以两端都要查——先查计算节点,若正常再查控制节点(各自跑 chronyc tracking)。

  3. 在计算节点上检查 chrony 是否同步:

    bash
    chronyc tracking
    # Stratum: 0 且 Reference ID: 00000000 => 未同步,自由运行
    # 若此处计算节点看着正常但节点仍 HeartbeatExpired,
    # 在控制节点上也跑一次同样的检查——它可能向前跳了。
  4. 测量计算节点与控制节点的时钟差,最简单的办法是直接比对 wall clock:

    bash
    # 在计算节点记下时间,再 SSH 到控制节点比对:
    date +%s          # 计算节点
    ssh <control-node-ip> date +%s   # 控制节点
    # 计算节点必须落后于控制节点:EvaluateHealth 检查的是
    # `now.Sub(heartbeatTime) > timeout`,只有计算节点时钟偏慢才会
    # 复现 HeartbeatExpired。计算节点偏快则保持健康(delta 为负)。
    # 在该方向上差值大于 15s 即可复现误判 HeartbeatExpired。

    若要对指定 NTP 源做一次性偏移测量,在计算节点上跑(打印 System clock ... wrong by <N> seconds,不修改时钟)。目标必须真的提供 NTP 服务——用控制节点所同步的同一个 <your-ntp-server>(见修复步骤 1);若控制节点不是 NTP server,改指向一个真正的 NTP 源:

    bash
    chronyd -Q 'server <your-ntp-server> iburst'   # 一次性探测,打印相对该 server 的偏移

修复 ​

两端都必须同步到同一时间源——这个比较是相对的,若跳快的是控制节点的时钟,只修计算节点无法恢复该节点。先确认两端时钟都已同步(诊断步骤 3 在两个节点上各跑一次),再同步偏移的那一端。

  1. 确认时间源。若控制节点以 chrony 作本地 NTP server(如 local stratum 10 + allow),用它;否则用公网 NTP pool 或企业内网 NTP server。务必选与控制节点一致的时间源,两端时钟才能对齐。若控制节点自身未同步(其 chronyc tracking 显示 Stratum=0),先修控制节点——把计算节点指向一个控制节点没用的时间源,两端时钟仍对不齐。

  2. 在计算节点配置 chrony,配置文件路径和服务名因发行版而异:

    bash
    # ── RPM 系(OpenCloudOS / TencentOS / RHEL / CentOS):/etc/chrony.conf,服务名 chronyd ──
    sudo sed -i '/^server /d; /^pool /d' /etc/chrony.conf
    echo 'pool pool.ntp.org iburst' | sudo tee -a /etc/chrony.conf
    # 或用内网 NTP server:
    # echo 'server <your-ntp-server> iburst' | sudo tee -a /etc/chrony.conf
    sudo systemctl restart chronyd
    
    # ── Debian / Ubuntu:/etc/chrony/chrony.conf,服务名 chrony ──
    #(Ubuntu 默认用 systemd-timesyncd,若未装 chrony 需先安装)
    # sudo apt-get install -y chrony
    sudo sed -i '/^server /d; /^pool /d' /etc/chrony/chrony.conf
    echo 'pool pool.ntp.org iburst' | sudo tee -a /etc/chrony/chrony.conf
    # 或用内网 NTP server:
    # echo 'server <your-ntp-server> iburst' | sudo tee -a /etc/chrony/chrony.conf
    sudo systemctl restart chrony

    解析到多个地址的名字(如 pool.ntp.org)用 pool 指令;server 会在启动时只固定一个解析到的地址。单个内网 NTP 主机用 server。

  3. 重启 chrony 并确认 offset 收敛到接近 0(RPM 系:chronyd;Debian/Ubuntu:chrony)。重启后若偏移很大,chrony 默认用slewing(缓慢纠正,默认最大速率 ~8.3%/s)而非步进——超过 15s 的偏移要远多于一个心跳周期才能补上,几分钟/几小时的偏移可能要几小时。若需快速恢复,立即步进时钟:

    bash
    sudo systemctl restart chronyd    # RPM 系 —— 或:sudo systemctl restart chrony   (Debian/Ubuntu)
    chronyc tracking   # Stratum 应为非 0
    sudo chronyc makestep   # 立即步进时钟,不走 slewing;offset 仍大可重复执行
    chronyc tracking   # offset 此时应接近 0
    #(或在 chrony 配置加 `makestep 1.0 3`,让前 3 次超过 1s 的更新自动步进)
  4. 时钟对齐后,节点在下一次心跳被评估时变为健康,验证:

    bash
    cubeopscli node ls   # 修复的节点 Healthy=True

无需重启 cubelet/cube-ops、无需改配置——两端时钟对齐后,现有心跳流即被判为新鲜。

参考 ​

  • 心跳判定:CubeOps/internal/nodemanagement/service/health.go(EvaluateHealth、MetadataTimeout)
  • 心跳上报:Cubelet/pkg/cubelet/node_status.go(HeartbeatTime: kl.clock.Now())
  • 相关:#455(stale node health 处理——本问题是另一种失败模式:心跳按时到达,但两端时钟不一致)