集群里最容易被忽略、又最容易引发”玄学故障”的基础项,就是时间。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 采用三阶段模型:
- 探测(gather):读取现有
/etc/chrony.conf中的 server 行、timedatectl输出的时区、以及chronyc tracking的偏移量。全部用changed_when: false包成只读任务。 - 决策(converge):仅当配置与期望不一致时才写文件并通知 handler 重启,且用
serial分批,避免全量同时重启。 - 巡检(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 次串行,是合理的折中。