一次Ansible Playbook变量优先级错误导致批量服务器配置漂移的排查实录

问题背景

事情发生在周二凌晨的变更窗口。我们按计划对一套线上业务集群做例行配置加固,涉及32台Web服务器(Nginx + PHP-FPM + Supervisor)。这批机器跑的是相同的CentOS 7.9镜像,由一套统一的Ansible Playbook管理,日常变更一直比较稳定。

当晚的变更内容并不复杂:根据安全组要求,关闭Nginx版本号显示、统一PHP-FPM的慢日志阈值,并调整Supervisor对子进程崩溃后的自动重启策略。由于变更项较少,值班同事直接用了熟悉的命令行方式触发:

1
2
ansible-playbook -i inventory/production web.yml \
-e "nginx_server_tokens=off php_fpm_slowlog_timeout=3s supervisor_autorestart=unexpected"

凌晨 01:17 执行完成,playbook 显示所有机器都是 changed=4 ok=28,看起来一切正常。然而早上 08:45 业务方开始在群里反馈:部分接口响应变慢,偶尔出现 502 Bad Gateway,且报错机器不固定。更严重的是,监控显示 PHP-FPM 的 max_children 在几台高并发机器上从 200 变成了 50,连接队列被打满,直接触发告警。

故障现象

故障的典型表现包括:

  1. Nginx 侧:随机 502,且集中在部分机器上。error.log 中出现大量 connect() to unix:/run/php-fpm.sock failed (11: Resource temporarily unavailable)
  2. PHP-FPM 侧pm.max_children 被改为 50,而原生产值为 200。慢日志阈值 request_slowlog_timeout 变成了 3s,但部分业务池期望的是 5s。
  3. Supervisor 侧:程序配置中的 autorestart=true 被改成了 unexpected,导致计划内的退出码也触发了重启,日志里出现大量 EXITED 后又被拉起的情况。
  4. 监控告警:Zabbix 连续报 PHP-FPM listen queue len > 100;Prometheus 的 phpfpm_active_processes 在几台机器上达到 50/50。

我登录到一台问题机器上查看 PHP-FPM 主配置:

1
2
3
$ cat /etc/php-fpm.d/www.conf | grep -E "pm.max_children|request_slowlog_timeout"
pm.max_children = 50
request_slowlog_timeout = 3s

Nginx 配置也被改了:

1
2
$ cat /etc/nginx/nginx.conf | grep server_tokens
server_tokens off;

server_tokens off 这个变更本身是对的,但问题在于为什么它会把其他配置也一并带偏?Playbook 当晚明明只改了三个变量,为什么 Nginx 的 worker_connections、PHP-FPM 的 pm.max_children、Supervisor 的 autorestart 都出现了非预期变更?

排查过程

第一步:确认变更范围

先通过 Git 查看 Playbook 仓库最近的提交,确认代码层面没有改动。结果显示最近一周只有文档更新,没有角色或变量的变更。那么问题只能出在命令行参数或者环境变量上。

查看值班同事使用的命令历史:

1
2
3
$ history | grep ansible-playbook | tail -5
ansible-playbook -i inventory/production web.yml \
-e "nginx_server_tokens=off php_fpm_slowlog_timeout=3s supervisor_autorestart=unexpected"

看起来只传了三个变量,但影响面却大得多。我怀疑 -e 传入的 extra variables 在 Playbook 里被某种方式覆盖或透传到了其他角色。

第二步:检查变量优先级

Ansible 的变量优先级(Variable Precedence)是出了名的复杂。从 Ansible 2.9 官方文档看,extra vars(命令行 -e)处于最高优先级,会覆盖几乎所有其他变量,包括 role defaults、inventory vars、play vars、host vars 等。

但问题不在于 extra vars 本身优先级高,而在于这些变量名恰好和角色内部的变量名一致,且被多个角色共用。

我先检查 group_vars/all.yml

1
2
3
4
5
6
7
nginx_server_tokens: "on"
nginx_worker_connections: 4096

php_fpm_slowlog_timeout: 5s
php_fpm_max_children: 200

supervisor_autorestart: true

再检查角色变量。比如 roles/nginx/defaults/main.yml

1
2
nginx_server_tokens: "on"
nginx_worker_connections: 1024

roles/php-fpm/defaults/main.yml

1
2
php_fpm_slowlog_timeout: 0
php_fpm_max_children: 50

roles/supervisor/defaults/main.yml

1
supervisor_autorestart: true

注意:当命令行传入 php_fpm_slowlog_timeout=3s 时,它确实只覆盖了 PHP-FPM 的慢日志阈值,但 php_fpm_max_children 为什么也被改了?

第三步:定位变量透传路径

继续看 web.yml 这个 Playbook 的结构:

1
2
3
4
5
6
7
8
9
- name: Configure web servers
hosts: web
become: yes
vars:
php_fpm_max_children: "{{ php_fpm_max_children | default(50) }}"
roles:
- role: nginx
- role: php-fpm
- role: supervisor

问题立刻暴露:play vars 里使用了和 extra var 同名的 php_fpm_max_children,但它通过 default 过滤器试图回退到 50。extra vars 优先级高于 play vars,但这里有一个坑:当 extra vars 中只显式传了 php_fpm_slowlog_timeout,而 php_fpm_max_children 在 extra vars 中并不存在时,为什么 play vars 里的 default(50) 还会生效?

答案不会是这样。实际上,如果 php_fpm_max_children 在 extra vars 中没有显式传入,那么 play vars 里的 php_fpm_max_children: "{{ php_fpm_max_children | default(50) }}" 会引用自己,导致无限递归?不,Ansible 处理变量时会先找 play vars 的值,模板里再对 php_fpm_max_children 求值,但此时同一作用域里已有定义,所以它会取自身值,结果相当于没有 default 效果,或者报错。

我实际执行了 ansible-playbook --syntax-checkdebug 任务:

1
2
- debug:
var: php_fpm_max_children

结果输出为 50。这说明 play vars 的 default(50) 确实生效了,并且覆盖了 group_vars/all.yml 中定义的 php_fpm_max_children: 200

为什么?因为 Ansible 的变量优先级中,play vars 高于 group_vars 和 host_vars,而 default(50) 只是给模板一个默认值。当 play vars 显式定义了某个变量,无论它是否使用 default,它都会覆盖 group_vars 里的同名词。

第四步:检查其他被影响的变量

回到三个命令行变量:

  • nginx_server_tokens=off:extra var 最高优先级,覆盖了 group_vars/all.yml 和 role defaults。这是预期变更。
  • php_fpm_slowlog_timeout=3s:extra var 覆盖了 group_vars 的 5s。这是预期变更,但业务方期望的 5s 被打破了。
  • supervisor_autorestart=unexpected:extra var 覆盖了 group_vars 的 true,导致计划内退出码也触发重启。

nginx_worker_connectionsphp_fpm_max_children 的变更是怎么来的?

继续检查角色模板。在 roles/nginx/templates/nginx.conf.j2 中:

1
2
worker_connections {{ nginx_worker_connections | default(1024) }};
server_tokens {{ nginx_server_tokens | default('on') }};

由于 nginx_worker_connections 只在 group_vars/all.yml 中定义,没有 extra var 覆盖,优先级低于 play vars。但 web.yml 的 play vars 里没有 nginx_worker_connections,那它为什么变成 1024?

回看 roles/nginx/defaults/main.yml 中定义了 nginx_worker_connections: 1024,而 group_vars/all.yml 定义了 4096。按 Ansible 优先级,group_vars 应该覆盖 role defaults。可是实际结果是 1024。

我再用 debug 打印:

1
2
- debug:
var: nginx_worker_connections

输出居然是 1024!这完全不符合变量优先级表。经过反复核对,我发现 group_vars/all.yml 中这个变量名被同事在月初的某次合并中误删了!Git 记录显示:

1
-nginx_worker_connections: 4096

也就是说,group_vars 里已经不存在这个变量,role defaults 的 1024 自然生效。但更深层的问题是:为什么组里没有人知道这个变量被删了?因为 Playbook 在每次执行时,Ansible 不会主动告诉你某个变量被 role defaults 回退了多少。

第五步:检查审计与变更流程

这引出了另一个关键问题:为什么一个”只改三个变量”的变更,会触发 Nginx、PHP-FPM、Supervisor 三个角色的重新渲染?

查看 Ansible 执行日志,发现 changed=4 中的 4 个变更分别是:

  1. nginx.conf 被渲染(server_tokens 变化 + worker_connections 回退)
  2. www.conf 被渲染(slowlog_timeout 变化 + max_children 被 play vars 强制为 50)
  3. supervisord.conf 被渲染(autorestart 变化)
  4. 重启相关服务

也就是说,Ansible 的 template 模块在每次任务里都会重新渲染整个配置文件,即使只有一处变量变化,也会把模板中所有变量一起带入。如果模板里引用了被 play vars 或 extra vars 覆盖的变量,就会牵一发而动全身。

第六步:确认回滚方案

找到根因后,我需要先止血。由于 group_vars 里的 nginx_worker_connections 已经被误删,且 play vars 中 php_fpm_max_children 被固定为 50,最快的恢复方式是:

  1. 先手工恢复 group_vars/all.yml 中缺失的 nginx_worker_connections: 4096
  2. 临时注释掉 web.yml 中 play vars 里的 php_fpm_max_children 行,让 group_vars 的 200 生效。
  3. 回滚 supervisor_autorestarttruephp_fpm_slowlog_timeout 回滚到 5s,nginx_server_tokens 保持 off(这是安全要求)。

执行命令:

1
2
ansible-playbook -i inventory/production web.yml \
-e "nginx_server_tokens=off php_fpm_slowlog_timeout=5s supervisor_autorestart=true"

同时,临时把 web.yml 中的 play vars 注释:

1
2
# vars:
# php_fpm_max_children: "{{ php_fpm_max_children | default(50) }}"

重新执行后,检查关键配置已恢复正常:

1
2
3
4
5
6
7
8
$ cat /etc/php-fpm.d/www.conf | grep pm.max_children
pm.max_children = 200

$ cat /etc/nginx/nginx.conf | grep worker_connections
worker_connections 4096;

$ cat /etc/supervisord.d/app.ini | grep autorestart
autorestart=true

业务 502 告警在 09:38 开始下降,10:05 完全恢复。

解决方案

这次故障的表面原因是命令行 extra vars 覆盖了不该覆盖的变量,但根因是 Playbook 本身缺乏变量作用域隔离和变更管控。我制定了以下整改方案:

1. 移除 play vars 中的同名变量陷阱

web.yml 中原来的写法:

1
2
vars:
php_fpm_max_children: "{{ php_fpm_max_children | default(50) }}"

这种写法有严重歧义:它在 play vars 中定义了一个变量,同时试图引用自身求默认值。修改后,把默认值下沉到角色 defaults,并把业务默认值放到 group_vars 中显式管理:

roles/php-fpm/defaults/main.yml

1
2
php_fpm_max_children: 50
php_fpm_slowlog_timeout: 0

group_vars/web.yml(新建,专门给 web 主机组):

1
2
php_fpm_max_children: 200
php_fpm_slowlog_timeout: 5s

web.yml

1
2
3
4
5
6
7
- name: Configure web servers
hosts: web
become: yes
roles:
- role: nginx
- role: php-fpm
- role: supervisor

2. 使用变量前缀隔离不同角色的变量

避免不同角色使用完全相同的变量名。例如:

  • nginx_server_tokensnginx__server_tokens
  • php_fpm_max_childrenphp_fpm__max_children
  • supervisor_autorestartsupervisor__autorestart

虽然 Ansible 没有强制命名空间,但为角色变量加双下划线前缀(或统一用角色名前缀)可以显著降低误覆盖风险。不过,更好的做法是使用 rolespec 风格或 include_rolevars 参数显式传入。

3. 禁止在命令行直接传复杂变量

以后所有变量变更必须通过 group_vars 或 host_vars 文件提交,走 Git 合并请求。临时命令行变更只允许传明确的开关变量,例如 deploy_only=true

4. 增加配置漂移检查

在 Playbook 最后增加 ansible-playbook --check --diff 的 dry-run 检查,以及每次变更后自动比对关键配置文件的 checksum:

1
2
3
4
5
6
7
8
9
10
11
12
13
- name: Verify php-fpm max_children
lineinfile:
path: /etc/php-fpm.d/www.conf
regexp: '^pm.max_children\s*='
line: "pm.max_children = {{ php_fpm_max_children }}"
check_mode: yes
register: php_fpm_check
changed_when: false

- name: Fail if php-fpm max_children drifted
fail:
msg: "php-fpm max_children drifted!"
when: php_fpm_check.changed

5. 把变量定义集中到 inventory 并按环境分组

将生产环境、预发布环境、测试环境的变量分别放到 group_vars/prod_web.ymlgroup_vars/staging_web.yml 中,避免所有环境共用 group_vars/all.yml 导致一个误删影响全局。

根因分析

根本原因是 Ansible 变量优先级被误用,叠加了三个低级失误:

  1. Playbook 中 play vars 使用了和 group_vars 同名的变量,且通过 default(50) 的方式试图回退,导致 play vars 显式定义了 php_fpm_max_children,其优先级高于 group_vars,覆盖了生产值 200。
  2. group_vars/all.yml 中 nginx_worker_connections 被前序提交误删,没有触发任何告警,role defaults 的 1024 在生产环境生效。
  3. 命令行 -e 传入的 extra vars 优先级最高,虽然初衷是只改三个小变量,但因为它可以覆盖 group_vars 和 play vars,业务侧期望的 php_fpm_slowlog_timeout=5ssupervisor_autorestart=true 被临时命令行值覆盖。
  4. 变更流程缺失 dry-run 和 diff 检查,值班同事没有执行 --check --diff 就跑了生产变更,导致问题未在事前发现。

预防措施

  1. 变更前必须执行 dry-run:所有 Ansible 生产变更必须先跑 --check --diff,确认 diff 只包含预期项。这条写进变更 SOP。
  2. 禁用无 MR 的变量变更:group_vars/host_vars 的修改必须走 Git 合并请求,至少经过两人 review。禁止临时用 -e 覆盖业务变量。
  3. 为每个角色维护独立的变量前缀:新项目统一采用 角色名__变量名 的命名规范,老项目逐步改造。
  4. 配置漂移监控:在 Zabbix 和 Ansible 本身增加关键配置项的 checksum 检查,每日比对一次。
  5. Playbook 代码审查清单:每次变更 Playbook 或变量文件,必须检查:变量名是否重复、是否引用了已删除变量、role defaults 是否可能意外生效、extra vars 是否会覆盖 group_vars。
  6. 环境变量分层:尽快把 all.yml 拆成 prod.ymlstaging.ymldev.yml,减少一个文件影响所有环境的风险。

总结

这次事故让我重新认识了 Ansible 的”变量优先级”这个看似简单、实则很容易踩坑的话题。很多时候我们以为 -e 只是临时传一个小参数,但它在 Ansible 里处于最高优先级,稍有不慎就会把生产环境的配置整体带偏。

同时也暴露了我们 Playbook 代码本身的问题:同名变量在多个层级出现、role defaults 没有显式审计、group_vars 被误删无人察觉。这些问题单独看都不是致命伤,但叠加在一起,就能在几分钟内把 32 台服务器的关键配置改乱。

最后送给自己和同事三句话:

  • 生产环境的任何变量,都值得被显式管理,而不是靠 default() 兜底。
  • --check --diff 不是可选步骤,是变更的底线。
  • 临时命令行参数 -e 能救命,也能挖坑,用之前先想清楚它会影响多少模板。