用 loop 配合 until 对多个 systemd 服务做逐台启停后状态轮询

用 systemd 模块批量重启服务时,最容易踩的坑是「任务返回 changed 就以为成功了」。实际上 systemctl restart 只是把启动请求交给 PID 1 就立刻返回,服务可能正在 activating (auto-restart) 里反复崩溃,也可能因为 TimeoutStartSec 还没到而挂在 activating (start)。

用 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 顺序碰运气。