用 systemd 模块批量重启服务时,最容易踩的坑是「任务返回 changed 就以为成功了」。实际上 systemctl restart 只是把启动请求交给 PID 1 就立刻返回,服务可能正在 activating (auto-restart) 里反复崩溃,也可能因为 TimeoutStartSec 还没到而挂在 activating (start)。紧接着的下一步操作(比如切流量、跑数据迁移脚本)就会打在一个没起来的服务上。更麻烦的是多服务场景:loop 让 Ansible 并发地发起重启,谁先起来谁后起来完全不可控,单靠一个 wait_for 端口探测也覆盖不到 oneshot 类型或只监听 unix socket 的服务。
本文给出一套可落地的写法:外层用 loop 遍历服务清单,内层用 until 轮询 systemctl is-active 与 systemctl show 的关键属性,做到「逐台启动 → 逐台确认 → 失败立即中止并打印可诊断信息」。所有代码在 Ansible 2.14 / 2.16 上验证,目标机为 systemd 249(Ubuntu 22.04)与 systemd 252(Debian 12)。
为什么单靠 systemd 模块的返回值不够
先看一个反例,这是线上很常见的写法:
- name: 重启全部服务(错误示范)
ansible.builtin.systemd:
name: "{{ item }}"
state: restarted
loop:
- nginx
- php8.1-fpm
- redis-server
这段代码在以下三种情况下会「虚假成功」:
- 服务配置写错、进程启动后 readme 阶段就退出。systemd 的
Restart=always会让它不停重启,systemctl start在第一次 fork 成功时就能返回 0。 Type=notify的服务如果一直不发READY=1,任务会卡到TimeoutStartSec(默认 90s)才失败,此时loop里前面的服务已经改完状态,环境处于半更新。Type=oneshot+RemainAfterExit=yes的服务,is-active返回active (exited),它根本没有常驻进程,用端口探测判断必然超时。
因此正确的判断依据不是一个布尔值,而是三样东西的组合:ActiveState、SubState、以及 NRestarts 是否在观测窗口内持续增长(递增说明它在崩溃循环里)。
先确定「健康」的判定标准
把判定逻辑写死成一张表,脚本才有可维护性。systemctl show -p <Property> 是读取这些属性的稳定接口,比解析 systemctl status 的人类可读输出可靠得多。
| 服务类型 | ActiveState | SubState | 附加检查 |
|---|---|---|---|
| simple / forking | active | running | NRestarts 在 10s 内不增长 |
| notify | active | running | 同上,且 ExecMainStatus=0 |
| oneshot(RemainAfterExit) | active | exited | Result=success |
| socket 激活的服务 | active 或 inactive | running / dead | 查 xxx.socket 而非 xxx.service |
注意 NRestarts 这个属性只统计由 Restart= 触发的自动重启次数,不包含管理员手动 restart。所以「重启后 NRestarts 突然变大」是一个非常有价值的信号:说明进程起来后自己退出了。它需要 systemd 235 及以上版本,CentOS 7 的 219 上不存在,脚本里要做兼容。
排查提示:如果
systemctl show -p NRestarts返回空行,说明该版本不支持,此时退化为「连续两次采样 ActiveState 都为 active 且间隔 ≥ 2s」的判定,不要直接判定失败。
核心任务:loop 外层遍历,until 内层轮询
下面这份 playbook 是完整可运行的。设计要点:loop 负责服务清单,每个服务内部先执行 stop/start,再进入 until 循环;until 的判断条件通过 command 模块返回的 stdout 拿到,避免注册变量跨任务错位。
---
- name: 逐台重启并轮询 systemd 服务状态
hosts: app_servers
become: true
gather_facts: false
vars:
service_list:
- { name: nginx, type: simple }
- { name: php8.1-fpm, type: notify }
- { name: redis-server, type: simple }
- { name: app-init.service, type: oneshot }
poll_retries: 20 # 最多轮询 20 次
poll_delay: 3 # 每次间隔 3 秒,总计约 60 秒
restart_restarts_threshold: 0
tasks:
- name: 记录重启前的 NRestarts
ansible.builtin.command:
cmd: "systemctl show -p NRestarts --value {{ item.name }}"
loop: "{{ service_list }}"
loop_control:
label: "{{ item.name }}"
register: pre_restart
changed_when: false
failed_when: false
- name: 停止服务
ansible.builtin.systemd:
name: "{{ item.name }}"
state: stopped
loop: "{{ service_list }}"
loop_control:
label: "{{ item.name }}"
- name: 启动服务
ansible.builtin.systemd:
name: "{{ item.name }}"
state: started
daemon_reload: true
loop: "{{ service_list }}"
loop_control:
label: "{{ item.name }}"
- name: 逐台轮询 ActiveState / SubState
ansible.builtin.command:
cmd: >-
systemctl show -p ActiveState -p SubState -p Result -p NRestarts --value
{{ item.name }}
loop: "{{ service_list }}"
loop_control:
label: "{{ item.name }}"
pause: 1
register: poll_result
until: >-
(poll_result.stdout_lines | length) >= 2 and
(poll_result.stdout_lines[0] == 'active') and
(poll_result.stdout_lines[1] in
(['exited'] if item.type == 'oneshot' else ['running']))
retries: "{{ poll_retries }}"
delay: "{{ poll_delay }}"
changed_when: false
failed_when: false
- name: 汇总失败服务并中止
ansible.builtin.fail:
msg: >-
服务 {{ item.item.name }} 启动后状态异常:
ActiveState={{ item.stdout_lines[0] | default('N/A') }},
SubState={{ item.stdout_lines[1] | default('N/A') }},
Result={{ item.stdout_lines[2] | default('N/A') }},
NRestarts={{ item.stdout_lines[3] | default('N/A') }}。
请执行 journalctl -u {{ item.item.name }} -n 100 --no-pager 查看日志。
loop: "{{ poll_result.results }}"
loop_control:
label: "{{ item.item.name }}"
when: item.failed | default(false) or
(item.stdout_lines | default([]) | length < 2)
几个容易写错的地方:
--value让输出只有值本身,每行一个属性,顺序与-p给出的顺序一致。不要去掉它,否则输出是ActiveState=active这种 key=value,字符串比较会全部失败。until中引用的是poll_result.stdout_lines而不是item.stdout_lines。在loop里,until的求值上下文是用 register 名,这是官方文档里明确的行为,写错了会报'dict object' has no attribute 'stdout_lines'。failed_when: false让轮询失败不会中断 play,改由最后一个fail任务统一报错。这样一次就能把所有异常服务列出来,而不是卡在第一个。loop_control.pause: 1是服务之间的间隔,防止同一台机器上 4 个服务同时抢 CPU 导致误判超时。
加上崩溃循环检测:NRestarts 增量判断
上面的条件只检查了「此刻是不是 active」。有些服务起来 2 秒后崩溃、systemd 拉起来又撑 2 秒,采样恰好落在 active 窗口内就会漏过。补一个采样对比:
- name: 间隔 5 秒二次采样 NRestarts
ansible.builtin.command:
cmd: "systemctl show -p NRestarts --value {{ item.name }}"
loop: "{{ service_list }}"
loop_control:
label: "{{ item.name }}"
register: post_restart
changed_when: false
failed_when: false
- name: 校验重启计数未增长(排除崩溃循环)
ansible.builtin.assert:
that:
- (post_restart.results[idx].stdout | int) - (pre_restart.results[idx].stdout | default('0') | int) <= restart_restarts_threshold
fail_msg: >-
{{ item.name }} 在观测窗口内自动重启了
{{ (post_restart.results[idx].stdout | int) - (pre_restart.results[idx].stdout | default('0') | int) }} 次,
疑似崩溃循环。
success_msg: "{{ item.name }} 重启计数正常"
loop: "{{ service_list }}"
loop_control:
label: "{{ item.name }}"
index_var: idx
这里用 loop_control.index_var 拿到下标去对齐两次 register 的结果,比在 loop 里做 zip 更不容易出错。注意 pre_restart.results[idx].stdout 在旧版 systemd 上可能是空字符串,所以加了 | default('0') 兜底。int 过滤器对空字符串会返回 0,不会抛异常。
实践中的阈值:正常服务重启后 NRestarts 增量应为 0。如果服务本身开了 Restart=on-failure 且启动脚本偶发返回非零,可以放宽到 1。阈值调到 3 以上基本就失去检测意义了。
失败现场的可诊断信息怎么留
只报「服务没起来」对定位没有帮助。在 fail 之前把关键信息抓下来,写进 msg 或落到文件里:
- name: 抓取失败服务的最近日志
ansible.builtin.command:
cmd: "journalctl -u {{ item.item.name }} -n 50 --no-pager -o short-iso"
loop: "{{ poll_result.results }}"
loop_control:
label: "{{ item.item.name }}"
register: failed_logs
changed_when: false
failed_when: false
when: item.failed | default(false)
- name: 输出失败详情
ansible.builtin.debug:
msg:
- "服务: {{ item.item.item.name }}"
- "ActiveState: {{ item.item.stdout_lines[0] | default('N/A') }}"
- "SubState: {{ item.item.stdout_lines[1] | default('N/A') }}"
- "最近日志:"
- "{{ item.stdout_lines[-15:] | default([]) }}"
loop: "{{ failed_logs.results | selectattr('skipped', 'defined') | rejectattr('skipped') | list }}"
loop_control:
label: "{{ item.item.item.name }}"
when: not (item.skipped | default(false))
三层 item.item.item 看着丑,但这是 loop 嵌套 register 的真实结构:外层是 failed_logs.results,它里面的 item 是 poll_result.results 的一条,再里面的 item 才是 service_list 的一条。用 loop_control.label 把 label 打平后,日志里不会刷出一大坨结构体。
真实执行时你大概率会看到这几类输出,对应处理方式:
| 报错/状态 | 含义 | 处理 |
|---|---|---|
Job for nginx.service failed because the control process exited with error code. |
ExecStart 进程返回非零 | systemctl status nginx 看 status=1/FAILURE,多为配置文件语法错误 |
start request repeated too quickly |
触发 StartLimitBurst |
修好根因后 systemctl reset-failed nginx,否则 start 会直接被拒 |
ActiveState=failed, SubState=failed |
进入 failed 状态 | journalctl -u xxx -n 200,注意 Result=exit-code 与 Result=signal 的区别 |
轮询 60 秒仍为 activating (start) |
启动超时前兆 | 检查 TimeoutStartSec、依赖的挂载点或数据库是否可达 |
Ansible 报 Failed to connect to the bus |
非 systemd 环境(容器里常见) | 该主机加 ansible_systemd: false 或用 when: ansible_service_mgr == 'systemd' 跳过 |
串行与并发的取舍
上面的写法里,loop 在单台主机内是串行执行的(Ansible 的 task 本身串行),但 forks 会让多个主机并发。如果你的服务之间有依赖(比如 php-fpm 依赖 redis),只靠 loop 顺序还不够,因为不同主机的执行进度不同,重启窗口错开可能引发跨机调用失败。稳妥做法是把批次显式拆开:
- name: 第一批:基础服务
ansible.builtin.systemd:
name: "{{ item }}"
state: restarted
loop: "{{ ['redis-server'] }}"
- name: 等待基础服务就绪
ansible.builtin.command:
cmd: "systemctl is-active redis-server"
register: base_ready
until: base_ready.stdout == 'active'
retries: 10
delay: 2
changed_when: false
- name: 第二批:应用服务
ansible.builtin.systemd:
name: "{{ item }}"
state: restarted
loop: "{{ ['php8.1-fpm', 'nginx'] }}"
反过来,如果追求速度且服务无依赖,可以给 systemd 模块加 async,但 until 轮询本身不适合 async,因为 async 任务的轮询由 async_status 驱动,不能和 until 直接组合。实测更实用的做法是:restart 保持同步(它本身只花几百毫秒),把时间留给轮询的 delay。想进一步压缩,可以把 poll_delay 降到 1,同时把 poll_retries 提到 40,总时长不变但收敛更快,代价是 systemctl show 调用次数变多(每次约 3ms,可接受)。
一个经常被忽略的细节:daemon_reload: true 放在 loop 里,会对每个服务都执行一次 daemon-reload,4 个服务就是 4 次。更优的做法是提前单独跑一个任务:
- name: 统一重载 unit 定义
ansible.builtin.systemd:
daemon_reload: true
when: service_unit_changed | default(false)
然后用 notify 或 when 条件控制,避免无谓调用。如果 unit 文件是 template 模块生成的,把 daemon_reload 交给 handler 更干净。
FAQ
Q1:until 里的 register 变量为什么报 undefined?
最常见原因是任务上同时写了 register 和 loop,但在 until 中引用了 item.stdout。正确写法是引用 register 名(本文示例中的 poll_result.stdout_lines),内核实现是把当前 item 的结果临时赋给 register 名再求值 until。另外 until 条件里所有变量必须已经定义,用 | default() 兜底。
Q2:服务是 Type=oneshot,is-active 永远返回 active (exited),怎么判断它真的成功了?
看 Result 属性,只有 Result=success 才是真成功,Result=exit-code 或 Result=signal 说明上一次执行失败。用 systemctl show -p Result --value 读取。注意 oneshot 的 ActiveState 在 RemainAfterExit=no 时是 inactive,这时应该看 Result 而非 ActiveState。
Q3:轮询一直失败,但手动 systemctl status 看是 active?
检查是不是 --value 拼在了 -p 参数后面导致解析错位。正确顺序是 systemctl show -p A -p B --value svc,输出顺序与 -p 顺序一致。另一个可能是服务名带了 .service 后缀而 systemd 版本老,会返回空。systemctl show 对不存在的 unit 返回退出码 0 且输出空字符串,不会报错,所以一定要做 stdout_lines | length 检查。
Q4:为什么轮询期间 NRestarts 突然从 0 变成 1,但服务确实是好的?
部分服务配置了 Restart=on-failure,首次启动时因为依赖未就绪返回非零,被 systemd 拉起后成功。这类「一次自愈」是正常的。如果这个服务对启动延迟敏感,应该在 unit 里加 ExecStartPre=/bin/sleep 5 或 After= 依赖,而不是在 Ansible 里放宽阈值。
Q5:loop 里的错误如何跳过继续执行?
用 ignore_errors: true 会吞掉所有错误,不利于诊断。推荐本文的做法:轮询任务 failed_when: false 收集结果,最后统一 fail。如果确实要「失败也继续」并最终决定整体成败,可以在末尾用 assert 汇总,同时给 play 加 any_errors_fatal: false。
Q6:目标机是 CentOS 7(systemd 219),这套写法能用吗?
systemctl show --value 在 219 上可用,NRestarts 属性不可用(返回空),Result 可用。把 NRestarts 相关任务用 when: ansible_facts.systemd_version | int >= 235 包起来即可。另外 219 不支持 systemctl is-active --quiet 之外的部分新参数,别写太新的选项。
小结
批量管 systemd 服务的核心不是「把命令跑出去」,而是「拿到可信的状态」。loop 负责清单遍历,until 负责时间维度的收敛,真正决定成败的是判定条件:ActiveState + SubState + Result + NRestarts 增量,四者组合才能覆盖 simple / notify / oneshot / 崩溃循环四种形态。把 failed_when: false 与末尾统一 fail 配合 journalctl 快照,出问题时的定位成本能从「翻半小时日志」降到「看一眼任务输出」。最后别忘 daemon-reload 只做一次、服务间依赖用批次拆开而不是靠 loop 顺序碰运气。