多主机时间同步与漂移巡检:一个 Ansible Playbook 全覆盖

集群里最容易被忽略、又最容易引发"玄学故障"的基础项,就是时间。NTP 漂移几百毫秒,可能让 JWT 校验随机失败、让 Kafka 消费者位点错乱、让分布式锁提前释放、让 ELK 里的日志时间线对不上。而这类问题往往不具备可复现性,排查成本极高。 手工逐台 ssh 上去看 chronyc tracking 的运维方式,在几十台机器规模下就完全不可接受了。本文

集群里最容易被忽略、又最容易引发”玄学故障”的基础项,就是时间。NTP 漂移几百毫秒,可能让 JWT 校验随机失败、让 Kafka 消费者位点错乱、让分布式锁提前释放、让 ELK 里的日志时间线对不上。而这类问题往往不具备可复现性,排查成本极高。

手工逐台 ssh 上去看 chronyc tracking 的运维方式,在几十台机器规模下就完全不可接受了。本文给出一个可直接落地的 Ansible Playbook:一次执行,同时完成时区与 NTP 服务状态的核对、chrony 配置的统一、偏差量的采集、以及超出阈值主机的告警输出。不需要额外安装 Agent,不需要集中式监控,纯 Ansible + chrony 自带命令。

前置约定:环境与假设

Playbook 面向 CentOS 7/8、Rocky Linux 8/9、Ubuntu 20.04/22.04 这几类主流发行版。CentOS 7 默认使用 chronyd,Ubuntu 从 18.04 起也默认是 chrony,因此主线路走 chrony;ntpd 的环境在文末 FAQ 里给出替代做法。

清单文件按角色分组,示例:

# inventory/hosts.ini
[all:vars]
ansible_user=ops
ansible_ssh_private_key_file=~/.ssh/id_rsa
ansible_python_interpreter=/usr/bin/python3

[ntp_servers]
ntp1.example.com
ntp2.example.com

[app_cluster]
app[01:20].example.com

[db_cluster]
db[01:06].example.com

[all_children]
ntp_servers
app_cluster
db_cluster

控制机需要 ansible-core >= 2.12。用 ansible --version 确认,低于该版本时 ansible.builtin.chrony 之外的若干模块参数会有差异,建议先升级:

python3 -m pip install --user "ansible-core>=2.14,<2.17"
ansible-galaxy collection install ansible.posix community.general

目标机上必须具备 chronyc。若某台机器是 ntpd 或 systemd-timesyncd,Playbook 会在预检阶段直接跳过并给出提示,而不是报错中断整批任务。

核心思路:先探测,再决策,最后巡检

很多"一键时间同步"脚本的通病是硬编码 ntp.aliyun.com,然后无脑重启服务。生产环境里这会带来两个隐患:一是内网机器无法访问公网 NTP;二是重启 chronyd 会短暂丢失同步状态,对时间敏感的业务是需要评估的。

本 Playbook 采用三阶段模型:

  1. 探测(gather):读取现有 /etc/chrony.conf 中的 server 行、timedatectl 输出的时区、以及 chronyc tracking 的偏移量。全部用 changed_when: false 包成只读任务。
  2. 决策(converge):仅当配置与期望不一致时才写文件并通知 handler 重启,且用 serial 分批,避免全量同时重启。
  3. 巡检(audit):采集 System time 偏移、Leap status、Stratum、Reference ID,按阈值判定,生成结构化报告并在本地落一份 CSV。

关键点在于"偏移量"取哪个字段。chronyc tracking 输出里的 System time 是内核时钟相对 NTP 参考源的即时偏差,单位秒,带正负号,这才是判断漂移的正确指标;而 chronyc sources 里的字段是各上游源的延迟/偏移,反映链路质量,不能直接当作本机漂移。

完整 Playbook

下面这份文件可直接保存为 ntp-sync-audit.yml。所有阈值走变量,便于按机房策略调整。

---
- name: NTP 时间同步与漂移巡检
  hosts: all
  gather_facts: true
  serial: "30%"
  max_fail_percentage: 20

  vars:
    ntp_upstream_servers:
      - 0.cn.pool.ntp.org
      - 1.cn.pool.ntp.org
      - ntp1.example.com
    ntp_allowed_networks:
      - "10.0.0.0/8"
      - "172.16.0.0/12"
      - "192.168.0.0/16"
    expected_timezone: "Asia/Shanghai"
    drift_warn_seconds: 0.5
    drift_fail_seconds: 5.0
    chrony_config_path: /etc/chrony.conf
    report_local_dir: "./ntp_report"
    is_ntp_server_group: "{{ inventory_hostname in groups['ntp_servers'] | default([]) }}"

  tasks:
    - name: 预检 - 确认为 chrony 环境
      ansible.builtin.stat:
        path: /usr/bin/chronyc
      register: chronyc_bin

    - name: 预检 - 跳过非 chrony 主机
      ansible.builtin.meta: end_host
      when: not chronyc_bin.stat.exists

    - name: 采集 - 当前时区
      ansible.builtin.command: timedatectl show --property=Timezone --value
      register: tz_now
      changed_when: false
      failed_when: false

    - name: 采集 - 当前 chrony 配置中的 server 行
      ansible.builtin.command: grep -E "^\s*(server|pool)\s" {{ chrony_config_path }}
      register: srv_now
      changed_when: false
      failed_when: false

    - name: 采集 - 同步状态与偏移量
      ansible.builtin.command: chronyc tracking
      register: tracking_now
      changed_when: false
      failed_when: false

    - name: 解析 - 抽取 System time / Stratum / Leap status
      ansible.builtin.set_fact:
        ntp_offset: "{{ tracking_now.stdout | regex_search('System time\\s*:\\s*([-0-9.eE+]+)', '\\1') | default(['unknown'], true) | first }}"
        ntp_stratum: "{{ tracking_now.stdout | regex_search('Stratum\\s*:\\s*(\\d+)', '\\1') | default(['0'], true) | first }}"
        ntp_leap: "{{ tracking_now.stdout | regex_search('Leap status\\s*:\\s*(.+)', '\\1') | default(['UNKNOWN'], true) | first | trim }}"
        ntp_refid: "{{ tracking_now.stdout | regex_search('Reference ID\\s*:\\s*([A-F0-9.]+)', '\\1') | default(['unknown'], true) | first }}"
      when: tracking_now.rc == 0

    - name: 决策 - 生成期望的 chrony 配置
      ansible.builtin.set_fact:
        chrony_desired: |
          # Managed by Ansible - do not edit manually
          {% if is_ntp_server_group %}
          # 本机为内部 NTP 服务端,上游指向公网/权威源
          {% for s in ntp_upstream_servers %}
          server {{ s }} iburst
          {% endfor %}
          local stratum 5
          allow {{ ntp_allowed_networks | join(' ') }}
          {% else %}
          # 本机为客户端,优先向内部 NTP 服务端同步
          {% for h in groups['ntp_servers'] | default([]) %}
          server {{ hostvars[h].ansible_facts.default_ipv4.address | default(h) }} iburst
          {% endfor %}
          {% endif %}
          driftfile /var/lib/chrony/drift
          makestep 1.0 3
          rtcsync
          logdir /var/log/chrony

    - name: 决策 - 写入 chrony.conf(仅在不一致时)
      ansible.builtin.copy:
        content: "{{ chrony_desired }}"
        dest: "{{ chrony_config_path }}"
        owner: root
        group: root
        mode: "0644"
        backup: true
        validate: "chronyd -Q -f %s"
      notify: restart chronyd

    - name: 决策 - 设置时区
      ansible.builtin.command: timedatectl set-timezone {{ expected_timezone }}
      when: tz_now.stdout | trim != expected_timezone
      changed_when: true

    - name: 确保 chronyd 处于启用并运行状态
      ansible.builtin.systemd:
        name: chronyd
        state: started
        enabled: true

    - name: 巡检 - 强制立即校时(仅当偏移超过 fail 阈值)
      ansible.builtin.command: chronyc makestep
      when:
        - ntp_offset is defined
        - ntp_offset not in ['unknown', '']
        - (ntp_offset | float | abs) > drift_fail_seconds
      changed_when: true

    - name: 巡检 - 汇总判定
      ansible.builtin.set_fact:
        ntp_verdict: >-
          {%- if ntp_offset in ['unknown', ''] -%}UNKNOWN
          {%- elif (ntp_offset | float | abs) > drift_fail_seconds -%}FAIL
          {%- elif (ntp_offset | float | abs) > drift_warn_seconds -%}WARN
          {%- elif ntp_leap != 'Normal' -%}WARN
          {%- else -%}OK
          {%- endif -%}

    - name: 巡检 - 输出单机结果
      ansible.builtin.debug:
        msg: >-
          [{{ ntp_verdict }}] {{ inventory_hostname }}
          tz={{ tz_now.stdout | trim }}
          offset={{ ntp_offset }}s
          stratum={{ ntp_stratum }}
          leap={{ ntp_leap }}
          refid={{ ntp_refid }}
          changed={{ srv_now.rc != 0 or (chrony_desired not in (srv_now.stdout | default(''))) }}

  handlers:
    - name: restart chronyd
      ansible.builtin.systemd:
        name: chronyd
        state: restarted

- name: 汇总生成巡检报告
  hosts: localhost
  gather_facts: false
  vars:
    report_local_dir: "./ntp_report"
  tasks:
    - name: 创建本地报告目录
      ansible.builtin.file:
        path: "{{ report_local_dir }}"
        state: directory
        mode: "0755"

    - name: 生成 CSV 报告
      ansible.builtin.copy:
        dest: "{{ report_local_dir }}/ntp_report_{{ ansible_date_time.iso8601_basic_short }}.csv"
        mode: "0644"
        content: |
          host,verdict,timezone,offset_seconds,stratum,leap_status,reference_id
          {% for h in groups['all'] %}
          {% if hostvars[h].ntp_verdict is defined %}
          {{ h }},{{ hostvars[h].ntp_verdict }},{{ hostvars[h].tz_now.stdout | default('') | trim }},{{ hostvars[h].ntp_offset | default('') }},{{ hostvars[h].ntp_stratum | default('') }},{{ hostvars[h].ntp_leap | default('') }},{{ hostvars[h].ntp_refid | default('') }}
          {% endif %}
          {% endfor %}
      run_once: true
      delegate_to: localhost

    - name: 打印异常主机清单
      ansible.builtin.debug:
        msg: >-
          {{ groups['all']
             | select('in', hostvars)
             | selectattr('ntp_verdict', 'defined')
             | rejectattr('ntp_verdict', 'equalto', 'OK')
             | list }}

关键参数逐条解释

参数 作用 推荐值来源
iburst 启动后快速发送 8 个包完成首次收敛,从分钟级降到秒级 所有 server/pool 行都应加
makestep 1.0 3 前 3 次更新内,偏移超过 1 秒就直接跳跃校时而非缓慢 slew 虚拟机、容器宿主必配
local stratum 5 当内部 NTP 服务端与上游失联时,仍以 5 层对外提供服务 仅内部服务端启用,客户端不要加
rtcsync 每 11 分钟把系统时间同步到硬件时钟 RTC 物理机必备
driftfile 记录晶振频率偏差,重启后无需二次收敛 固定为 /var/lib/chrony/drift
serial: "30%" 按比例分批执行,控制重启风暴 节点数 > 10 时建议 20%~30%

运行方式与真实输出

先做 dry-run 看哪些主机配置会变:

ansible-playbook -i inventory/hosts.ini ntp-sync-audit.yml --check --diff -l app_cluster

正式执行:

ansible-playbook -i inventory/hosts.ini ntp-sync-audit.yml -e "drift_warn_seconds=0.3"

实际执行时典型的 debug 输出长这样:

TASK [巡检 - 输出单机结果] *****************************************************
ok: [app03.example.com] => {
    "msg": "[OK] app03.example.com tz=Asia/Shanghai offset=0.000124s stratum=3 leap=Normal refid=C0A8010A changed=False"
}
ok: [app07.example.com] => {
    "msg": "[WARN] app07.example.com tz=Asia/Shanghai offset=0.732841s stratum=2 leap=Normal refid=A1B2C3D4 changed=False"
}
ok: [db02.example.com] => {
    "msg": "[FAIL] db02.example.com tz=UTC offset=12.908411s stratum=0 leap=Not synchronised refid=00000000 changed=True"
}

注意 db02 这一行:stratum=0 且 Leap status: Not synchronised,说明它根本没同步上任何源。Reference ID 00000000 是"无参考源"的确定标志,这比偏移量本身更能说明问题——这类机器通常是 chrony 未运行、或者配置的 server 全部不可达、或者上层防火墙拦截了 123/udp。

报告文件落在 ./ntp_report/ntp_report_20240115T031422.csv,可直接 import 到 Excel 或交给审计系统归档。

常见报错与处理

1. 配置校验失败:chronyd -Q -f 返回非零

fatal: [app05.example.com]: FAILED! => {"changed": false,
 "msg": "failed to validate: rc:1, stdout:, stderr:chronyd: Unrecognized option ..."}

原因是老版本 chrony(CentOS 7 自带 3.2 之前)不支持 local stratum 之外的部分写法,或者 pool 与 server 混用顺序有误。排查办法:把生成的配置写到临时目录,在目标机上单跑 chronyd -Q -f /tmp/x.conf 看具体行号提示。若确实是版本问题,把 pool 全部降级为 server 行即可。

2. chronyc tracking 报 506 Cannot talk to daemon

fatal: [app09.example.com]: FAILED! => {"changed": true, "rc": 1,
 "msg": "non-zero return code", "stderr": "506 Cannot talk to daemon"}

守护进程没起,或 chronyd 启动即退出。先看 systemctl status chronyd 与 journalctl -u chronyd -n 50。常见诱因:容器内缺少 CAP_SYS_TIME、seccomp 拦截了 adjtimex 系统调用、或者 /var/lib/chrony 目录权限错乱。Playbook 里对这条命令用了 failed_when: false,所以不会中断,但会把该机判为 UNKNOWN,需要人工介入。

3. 目标机是 systemd-timesyncd,被自动跳过

这是预期行为。Debian 系部分镜像默认启用 timesyncd,它和 chrony 会抢 123/udp 与内核时钟。如果确实要切到 chrony:

timedatectl set-ntp false
systemctl disable --now systemd-timesyncd
systemctl enable --now chronyd

切换后重新跑本 Playbook,预检就能通过。

4. 无法访问密码学签名的上游源

2024-01-15T03:14:22Z chronyd[812]: NTS-KE session with ntp.example.com (443) timed out

配置了 nts 开关但端口 443 被内网策略阻断。内网环境建议去掉 NTS,改用 server x iburst 配合 allow 白名单即可,安全边界由网络层负责。

5. 大批主机同时被判 FAIL,偏移高达上千秒

这通常不是 NTP 的问题,而是虚拟化平台做了快照恢复或挂起-恢复,导致客户机时钟被"冻结"。此时 makestep 的触发前提是 chronyd 已经完成初次同步,若偏移超过 makestep 的阈值它会直接跳跃校正;但如果偏移量巨大(> 1000s),建议先在平台上确认宿主时间是否正确,再逐台 chronyc makestep,不要一次性全推。

小结

这个 Playbook 把"时间"从经验性手工操作变成了可审计的声明式配置:iburst + makestep 解决收敛速度,serial + handler 控制变更风险,chronyc tracking 的分字段解析给出可量化的漂移指标,CSV 报告让跨批次的对比成为可能。建议把它挂进每日 CI 或定时任务,用 --check 模式跑一遍,只做巡检不落盘,漂移趋势会自己浮出来。

FAQ

Q1:为什么不用 Ansible 的 ansible.builtin.chrony 模块?

该模块主要管理 chrony.conf 中的特定条目,对 server 列表的整体重写和 validate 钩子支持有限,且不同版本参数名不一致。用 template/copy + 显式 validate 更可控,也便于在配置里加注释。

Q2:偏移量用 System time 还是 Last offset?

巡检用 System time,它表示此刻内核时钟相对参考源的偏差,反映"现状"。Last offset 是上一次校正时的偏差,反映"历史"。压测场景下两者差异会很明显,报告里只需要前者。

Q3:内部 NTP 服务端该不该开 allow?

必须开,否则所有客户端请求都会被丢弃。allow 的值要收敛到业务网段,不要写 allow all;0.0.0.0/0 会把你变成公开的 NTP 放大器,存在被反射攻击利用的风险。

Q4:容器里的 Pod 也需要同步吗?

Linux 容器共享宿主内核时钟,Pod 内无法独立设置时间,所以容器本身只做巡检不做变更。对本 Playbook 而言,容器化主机应被排除在 ntp_servers 分组之外,只保留巡检判定。若需要在容器内看时间状态,可将 chronyc 与宿主 socket 挂载进去,但更简单的做法还是在宿主层面治理。

Q5:serial 和 forks 冲突吗?

不冲突,但要注意 serial 作用于 Play 级批次,forks 作用于批次内的并发数。设 serial: "30%" 且 forks: 10,100 台机器时每批 30 台、批内并发 10,总耗时约为 10 个波次而非 100 次串行,是合理的折中。