一次Ansible Playbook变量优先级错误导致批量服务器配置漂移的排查实录
问题背景
事情发生在周二凌晨的变更窗口。我们按计划对一套线上业务集群做例行配置加固,涉及32台Web服务器(Nginx + PHP-FPM + Supervisor)。这批机器跑的是相同的CentOS 7.9镜像,由一套统一的Ansible Playbook管理,日常变更一直比较稳定。
当晚的变更内容并不复杂:根据安全组要求,关闭Nginx版本号显示、统一PHP-FPM的慢日志阈值,并调整Supervisor对子进程崩溃后的自动重启策略。由于变更项较少,值班同事直接用了熟悉的命令行方式触发:
1 | ansible-playbook -i inventory/production web.yml \ |
凌晨 01:17 执行完成,playbook 显示所有机器都是 changed=4 ok=28,看起来一切正常。然而早上 08:45 业务方开始在群里反馈:部分接口响应变慢,偶尔出现 502 Bad Gateway,且报错机器不固定。更严重的是,监控显示 PHP-FPM 的 max_children 在几台高并发机器上从 200 变成了 50,连接队列被打满,直接触发告警。
故障现象
故障的典型表现包括:
- Nginx 侧:随机 502,且集中在部分机器上。
error.log中出现大量connect() to unix:/run/php-fpm.sock failed (11: Resource temporarily unavailable)。 - PHP-FPM 侧:
pm.max_children被改为 50,而原生产值为 200。慢日志阈值request_slowlog_timeout变成了 3s,但部分业务池期望的是 5s。 - Supervisor 侧:程序配置中的
autorestart=true被改成了unexpected,导致计划内的退出码也触发了重启,日志里出现大量EXITED后又被拉起的情况。 - 监控告警:Zabbix 连续报
PHP-FPM listen queue len > 100;Prometheus 的phpfpm_active_processes在几台机器上达到 50/50。
我登录到一台问题机器上查看 PHP-FPM 主配置:
1 | $ cat /etc/php-fpm.d/www.conf | grep -E "pm.max_children|request_slowlog_timeout" |
Nginx 配置也被改了:
1 | $ cat /etc/nginx/nginx.conf | grep server_tokens |
server_tokens off 这个变更本身是对的,但问题在于为什么它会把其他配置也一并带偏?Playbook 当晚明明只改了三个变量,为什么 Nginx 的 worker_connections、PHP-FPM 的 pm.max_children、Supervisor 的 autorestart 都出现了非预期变更?
排查过程
第一步:确认变更范围
先通过 Git 查看 Playbook 仓库最近的提交,确认代码层面没有改动。结果显示最近一周只有文档更新,没有角色或变量的变更。那么问题只能出在命令行参数或者环境变量上。
查看值班同事使用的命令历史:
1 | $ history | grep ansible-playbook | tail -5 |
看起来只传了三个变量,但影响面却大得多。我怀疑 -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 | nginx_server_tokens: "on" |
再检查角色变量。比如 roles/nginx/defaults/main.yml:
1 | nginx_server_tokens: "on" |
roles/php-fpm/defaults/main.yml:
1 | php_fpm_slowlog_timeout: 0 |
roles/supervisor/defaults/main.yml:
1 | supervisor_autorestart: true |
注意:当命令行传入 php_fpm_slowlog_timeout=3s 时,它确实只覆盖了 PHP-FPM 的慢日志阈值,但 php_fpm_max_children 为什么也被改了?
第三步:定位变量透传路径
继续看 web.yml 这个 Playbook 的结构:
1 | - name: Configure web servers |
问题立刻暴露: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-check 和 debug 任务:
1 | - debug: |
结果输出为 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_connections 和 php_fpm_max_children 的变更是怎么来的?
继续检查角色模板。在 roles/nginx/templates/nginx.conf.j2 中:
1 | worker_connections {{ nginx_worker_connections | default(1024) }}; |
由于 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 | - debug: |
输出居然是 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 个变更分别是:
nginx.conf被渲染(server_tokens 变化 + worker_connections 回退)www.conf被渲染(slowlog_timeout 变化 + max_children 被 play vars 强制为 50)supervisord.conf被渲染(autorestart 变化)- 重启相关服务
也就是说,Ansible 的 template 模块在每次任务里都会重新渲染整个配置文件,即使只有一处变量变化,也会把模板中所有变量一起带入。如果模板里引用了被 play vars 或 extra vars 覆盖的变量,就会牵一发而动全身。
第六步:确认回滚方案
找到根因后,我需要先止血。由于 group_vars 里的 nginx_worker_connections 已经被误删,且 play vars 中 php_fpm_max_children 被固定为 50,最快的恢复方式是:
- 先手工恢复 group_vars/all.yml 中缺失的
nginx_worker_connections: 4096。 - 临时注释掉
web.yml中 play vars 里的php_fpm_max_children行,让 group_vars 的 200 生效。 - 回滚
supervisor_autorestart到true,php_fpm_slowlog_timeout回滚到 5s,nginx_server_tokens保持off(这是安全要求)。
执行命令:
1 | ansible-playbook -i inventory/production web.yml \ |
同时,临时把 web.yml 中的 play vars 注释:
1 | # vars: |
重新执行后,检查关键配置已恢复正常:
1 | $ cat /etc/php-fpm.d/www.conf | grep pm.max_children |
业务 502 告警在 09:38 开始下降,10:05 完全恢复。
解决方案
这次故障的表面原因是命令行 extra vars 覆盖了不该覆盖的变量,但根因是 Playbook 本身缺乏变量作用域隔离和变更管控。我制定了以下整改方案:
1. 移除 play vars 中的同名变量陷阱
web.yml 中原来的写法:
1 | vars: |
这种写法有严重歧义:它在 play vars 中定义了一个变量,同时试图引用自身求默认值。修改后,把默认值下沉到角色 defaults,并把业务默认值放到 group_vars 中显式管理:
roles/php-fpm/defaults/main.yml:
1 | php_fpm_max_children: 50 |
group_vars/web.yml(新建,专门给 web 主机组):
1 | php_fpm_max_children: 200 |
web.yml:
1 | - name: Configure web servers |
2. 使用变量前缀隔离不同角色的变量
避免不同角色使用完全相同的变量名。例如:
nginx_server_tokens→nginx__server_tokensphp_fpm_max_children→php_fpm__max_childrensupervisor_autorestart→supervisor__autorestart
虽然 Ansible 没有强制命名空间,但为角色变量加双下划线前缀(或统一用角色名前缀)可以显著降低误覆盖风险。不过,更好的做法是使用 rolespec 风格或 include_role 的 vars 参数显式传入。
3. 禁止在命令行直接传复杂变量
以后所有变量变更必须通过 group_vars 或 host_vars 文件提交,走 Git 合并请求。临时命令行变更只允许传明确的开关变量,例如 deploy_only=true。
4. 增加配置漂移检查
在 Playbook 最后增加 ansible-playbook --check --diff 的 dry-run 检查,以及每次变更后自动比对关键配置文件的 checksum:
1 | - name: Verify php-fpm max_children |
5. 把变量定义集中到 inventory 并按环境分组
将生产环境、预发布环境、测试环境的变量分别放到 group_vars/prod_web.yml、group_vars/staging_web.yml 中,避免所有环境共用 group_vars/all.yml 导致一个误删影响全局。
根因分析
根本原因是 Ansible 变量优先级被误用,叠加了三个低级失误:
- Playbook 中 play vars 使用了和 group_vars 同名的变量,且通过
default(50)的方式试图回退,导致 play vars 显式定义了php_fpm_max_children,其优先级高于 group_vars,覆盖了生产值 200。 - group_vars/all.yml 中
nginx_worker_connections被前序提交误删,没有触发任何告警,role defaults 的 1024 在生产环境生效。 - 命令行
-e传入的 extra vars 优先级最高,虽然初衷是只改三个小变量,但因为它可以覆盖 group_vars 和 play vars,业务侧期望的php_fpm_slowlog_timeout=5s和supervisor_autorestart=true被临时命令行值覆盖。 - 变更流程缺失 dry-run 和 diff 检查,值班同事没有执行
--check --diff就跑了生产变更,导致问题未在事前发现。
预防措施
- 变更前必须执行 dry-run:所有 Ansible 生产变更必须先跑
--check --diff,确认 diff 只包含预期项。这条写进变更 SOP。 - 禁用无 MR 的变量变更:group_vars/host_vars 的修改必须走 Git 合并请求,至少经过两人 review。禁止临时用
-e覆盖业务变量。 - 为每个角色维护独立的变量前缀:新项目统一采用
角色名__变量名的命名规范,老项目逐步改造。 - 配置漂移监控:在 Zabbix 和 Ansible 本身增加关键配置项的 checksum 检查,每日比对一次。
- Playbook 代码审查清单:每次变更 Playbook 或变量文件,必须检查:变量名是否重复、是否引用了已删除变量、role defaults 是否可能意外生效、extra vars 是否会覆盖 group_vars。
- 环境变量分层:尽快把
all.yml拆成prod.yml、staging.yml、dev.yml,减少一个文件影响所有环境的风险。
总结
这次事故让我重新认识了 Ansible 的”变量优先级”这个看似简单、实则很容易踩坑的话题。很多时候我们以为 -e 只是临时传一个小参数,但它在 Ansible 里处于最高优先级,稍有不慎就会把生产环境的配置整体带偏。
同时也暴露了我们 Playbook 代码本身的问题:同名变量在多个层级出现、role defaults 没有显式审计、group_vars 被误删无人察觉。这些问题单独看都不是致命伤,但叠加在一起,就能在几分钟内把 32 台服务器的关键配置改乱。
最后送给自己和同事三句话:
- 生产环境的任何变量,都值得被显式管理,而不是靠
default()兜底。 --check --diff不是可选步骤,是变更的底线。- 临时命令行参数
-e能救命,也能挖坑,用之前先想清楚它会影响多少模板。