时间守护进程只有在实际状态满足应用要求时才真正有用。进程正在运行,仍可能没有可用来源;已经选择某个来源,也可能尚未充分测量,或者来源本身就不正确。时间服务健康应当是一条可观察的链路:来源配置、成功采样、选择状态、不确定性以及实际允许的时钟控制。进程显示绿色,只能覆盖链路最前面的一部分。

读取守护进程实际报告的内容

Chrony 的 tracking 输出分别给出参考来源、层级、最近更新时间、尚未消除的系统时间修正量、估计频率、频率偏斜不确定性、根延迟和根离散度。它们回答不同问题:offset 关注时间差,frequency 关注时钟走速,skew 反映频率估计的不确定性。根延迟与离散度则参与描述参考路径带来的时间不确定性。只挑一个很小的偏移数值,不能概括整个时钟质量。

sources 输出用于观察候选来源及实际选择。常见显示中的 ^* 表示选中的 NTP 服务器。Reach 是八进制历史位图,377 表示最近八次轮询机会都有成功响应,并不是百分比,也不保证下一次仍然可达。LastRx 有助于查看最近收到的样本已经过去多久。应把来源状态与 tracking 一起读,而不是把进程持续运行时长误认为同步证据。

上游短暂中断后,守护进程可能继续依据已有模型估计时间,但不确定性会随条件变化而扩大。需要根据应用容忍度监控更新时间和误差估计,区分短期维持与无限期可信。多个服务器名称也不必然意味着独立参考,如果它们共享上游或基础设施,仍然可能一起出错。来源选择降低部分故障风险,却不能证明所有上游声明都正确。

今天能够使用的来源,不能证明长时间断联之后启动服务仍然安全。应事先决定业务能否在维持估计期间启动,以及哪项实际测量超过边界就必须阻止启动。还要把这道检查与耗时测量分开:民用时间可能被校正,而超时逻辑需要合适的单调时钟,不能只因同步状态正常就混用两者。

坏掉的样子

容器通常共享宿主的内核时钟,在容器内运行时间服务并不会自然得到一块独立可调的民用时钟。Chronyd 的 -x 模式明确关闭系统时钟控制,但仍保留估计和提供时间服务的能力。因此,客户端可以报告已经同步,系统时钟却并没有被这个进程修正。排查时必须确认实际运行模式,以及哪一层负责宿主时间。

local 指令也容易制造错误信心。它允许 chronyd 在没有外部参考的情况下表现为已同步,可以用于有明确设计的隔离网络安排。但如果只是为了消除告警而启用,就会把缺失参考这一问题隐藏起来。解释 Normal 之前,要核对参考标识以及预期来源政策,不能把某种协议状态直接解释成 UTC 已经准确。

运行检查还需要有意义的限制。Chronyc 的 waitsync 可以等待同步,并可选地限制剩余时间修正量和频率估计偏斜;相应限制为零时,额外约束会被关闭。两个限制都关闭的成功结果,比执行应用专属容忍边界的检查弱得多。即使配置了边界,它也不能替任意上游时钟证明真实性,来源信任仍是另一项要求。

快速验证

使用新的临时 Debian 12 Docker 容器,运行 apt-get update 和 apt-get install -y python3 chrony 安装依赖,将下面代码保存为 chrony-status.py,以 root 执行 python3 chrony-status.py。创建容器时保持独立网络,不使用宿主网络,并移除 SYS_TIME 能力。实测版本为 chrony 4.3 与 Python 3.11.2,代码只在回环地址通信。

from pathlib import Path
from tempfile import TemporaryDirectory
import json
import os
import socket
import subprocess
import time

assert os.geteuid() == 0 and Path('/.dockerenv').exists(), 'use a disposable root Linux container'
with TemporaryDirectory(prefix='chrony-status-') as directory:
    root = Path(directory)
    processes = []
    with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as reservation:
        reservation.bind(('127.0.0.1', 0))
        port = reservation.getsockname()[1]

    def start(name, extra):
        config = root / (name + '.conf')
        config.write_text(f'pidfile {root}/{name}.pid\ncmdport 0\nbindcmdaddress {root}/{name}.sock\n' + extra)
        process = subprocess.Popen(['chronyd', '-x', '-n', '-u', 'root', '-f', str(config)],
                                   stdout=subprocess.PIPE, stderr=subprocess.PIPE)
        processes.append(process)
        deadline = time.monotonic() + 5
        while not (root/(name+'.sock')).exists():
            assert process.poll() is None, 'chronyd exited before command socket was ready'
            if time.monotonic() > deadline:
                raise AssertionError('command socket not ready')
            time.sleep(0.05)
        return process

    def command(name, *args):
        return subprocess.run(['chronyc', '-n', '-h', str(root/(name+'.sock')), *args],
                              capture_output=True, text=True, timeout=10)

    def tracking(name):
        result = command(name, 'tracking')
        assert result.returncode == 0, result.stderr
        return {line.split(':', 1)[0].strip(): line.split(':', 1)[1].strip()
                for line in result.stdout.splitlines() if ':' in line}

    try:
        client = start('client', f'port 0\nserver 127.0.0.1 port {port} iburst minpoll 0 maxpoll 0\n')
        before = tracking('client')
        before_gate = command('client', 'waitsync', '1', '0', '0', '0.1')
        assert client.poll() is None and before['Leap status'] == 'Not synchronised'
        assert before_gate.returncode != 0
        start('source', f'port {port}\nbindaddress 127.0.0.1\nlocal stratum 8\nallow 127.0.0.1\n')
        assert command('client', 'burst', '4/4').returncode == 0
        deadline = time.monotonic() + 25
        while time.monotonic() < deadline:
            sources = command('client', 'sources').stdout
            after = tracking('client')
            if '^* 127.0.0.1' in sources and after['Leap status'] == 'Normal':
                break
            time.sleep(0.2)
        else:
            raise AssertionError('local source was not selected')
        assert after['Stratum'] == '9'
        after_gate = command('client', 'waitsync', '1', '0', '0', '0.1')
        assert after_gate.returncode == 0
        source_tracking = tracking('source')
        assert source_tracking['Reference ID'].startswith('7F7F0101')
        print(json.dumps({'daemon_alive_while_unsynchronised': True,
                          'before': before, 'after': after, 'selected_sources': sources,
                          'waitsync_returncodes': [before_gate.returncode, after_gate.returncode],
                          'source_reference': source_tracking['Reference ID'],
                          'system_clock_adjustment_disabled': True,
                          'external_UTC_accuracy_tested': False}))
    finally:
        for process in reversed(processes):
            process.terminate()
            process.communicate(timeout=5)

程序首先启动一个指向尚无人监听端口的客户端。进程仍然存活,但 tracking 显示 Not synchronised,只尝试一次的 waitsync 返回失败。随后启动另一进程,以 local stratum 8 提供本地来源,要求客户端突发采样,并等待实际选中它。客户端最终显示 ^* 127.0.0.1、层级九和 Normal;同一个等待检查此时返回成功。

两个进程全程都禁用时钟调整,结束后都会停止。本次记录的初始选择阶段,频率估计的 skew 仍然很大,即使同步等待检查已经通过。来源参考标识为 7F7F0101,说明它工作在本地参考模式,而不是已经连接外部 UTC 来源。程序保留完整输出,避免只截取绿色状态后把这些限制丢掉。

原先记录报告全部断言执行成功,本轮审阅没有重新运行这项使用套接字的实验。没有测试外部 UTC 精度、长时间收敛、丢失来源后的恢复或真实时钟修正。这个反例用于说明监控必须分开解释进程可用性、同步状态、质量边界和时间权威。真正上线时应针对业务要求验证这些层次,不能把一个简单成功退出码写成全部时间保障都已经成立。

延伸阅读

来源