凌晨两点,我被一台改错配置的服务器叫醒了
事情发生在我们给300台Nginx集群更新配置文件。当时的“自动化”是一个Shell脚本,用一个for循环挨个SSH进去scp。脚本跑到第117台卡住了,然后前100台和后200台配置不一致。线上直接出现5xx雪崩。
我爬起来手动把117台这台服务器拉回基线,发现是磁盘满了,scp一半就中断。查了半小时日志,全靠人工盯着for循环的输出。
当晚我就决定:把系统的配置管理彻底重做。候选工具就两个:Ansible 2.16 和 SaltStack 3006。本文用实测数据告诉你,这两个工具在3000台真实服务器上到底谁更能扛事,以及各自有什么坑必须提前避开。
方案对比:连ssh拷文件的时代该结束了
先说结论:Ansible适合临时任务和中型集群,SaltStack适合超大规模或低延迟场景。关键在于架构完全相反。
Ansible:基于SSH的推模式
Ansible没有Agent,靠SSH协议连上去执行命令。好处是零侵入,安全团队能点头同意。坏处是SSH握手需要建立TCP连接、交换密钥,单次连接大概1.5~3秒的固定开销。
如果3000台服务器,每台建立一次SSH连接,光握手时间就是3000 * 2s = 6000秒。这就是并发数(forks)为什么那么重要的原因。
SaltStack:基于ZMQ的常驻Agent
Salt在每台机器上装一个salt-minion守护进程,通过ZMQ长连接和Master通信。ZMQ连接建立一次后就长期复用,消息通过二进制帧交互,没有SSH那种繁琐的TCP握手。
代价是每个minion需要常驻内存20~30MB。3000台服务器就是3000 * 30MB = 90GB的内存开销。预算不够就乖乖用Ansible。
实现一:Ansible从10台扩到3000台的优化之路
先看废弃的Shell脚本,大家引以为戒
#!/bin/bash
# 旧方式:手动scp+ssh重启
for ip in `cat /tmp/server_list.txt`; do
# 千万不能直接跑!根本停不下来
scp /tmp/nginx.conf root@${ip}:/etc/nginx/nginx.conf
ssh root@${ip} "nginx -t && nginx -s reload"
done
这个脚本的问题很明显:串行执行,一台失败后面全部停下。没有重试,没有日志,没有状态追踪。
Ansible调优配置(核心参数)
直接贴我在生产用的 ansible.cfg 文件。注意 forks 和 ssh_args 的大小写,写错不报错但没效果。
# ansible.cfg - 生产环境实测配置
[defaults]
inventory = ./hosts
# 核心参数:并发数。默认5个,我直接提10倍
forks = 100
timeout = 10
# 关闭host key检查,避免交互卡死
host_key_checking = False
# 优化SSH连接复用,这是Ansible提速的关键
ssh_args = -o ControlMaster=auto -o ControlPersist=3600s -o StrictHostKeyChecking=no
# 开启流水线,减少SSH内部往返次数
pipelining = True
# 控制执行顺序
gathering = smart
pipeling 是Ansible性能分水岭。不开启时,每台机器需要多次SSH来回传输临时脚本。开启后,Ansible通过stdin直接传命令,实测能减少35%的总体耗时。
Nginx配置推送Playbook
---
# nginx_playbook.yml
- name: 批量更新 Nginx 配置
hosts: all
become: yes
tasks:
- name: 生成 nginx.conf
template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
# 关键:只有文件变化才通知重载
notify: reload nginx
- name: 检查配置是否生效
command: nginx -t 2>&1
register: nginx_test_result
failed_when: "'successful' not in nginx_test_result.stdout"
changed_when: false
handlers:
- name: reload nginx
systemd:
name: nginx
state: reloaded
这里有个幂等性的隐藏坑。如果直接用 shell: nginx -s reload 写在tasks里,无论配置变没变都会执行reload。用 notify + handlers 可以保证只有任务上报了 changed,才会执行reload。这是Ansible的推荐写法,必须养成习惯。
实现二:SaltStack的零延迟Event驱动架构
Master端核心配置(/etc/salt/master)
# /etc/salt/master - 生产配置
interface: 0.0.0.0
publish_port: 4505
ret_port: 4506
# 事件总线并发,默认为5,我调到20
worker_threads: 20
# 加密盐和哈希因子
hash_type: sha256
# 文件根目录
file_roots:
base:
- /srv/salt
# 让master命令在3秒内返回,太快了反而容易丢
timeout: 5
编写salt state文件
# /srv/salt/nginx/init.sls
nginx-conf:
file.managed:
- name: /etc/nginx/nginx.conf
- source: salt://nginx/files/nginx.conf
- mode: 644
- makedirs: True
# 配置变化后自动检查并重载
cmd.run:
- name: nginx -t && systemctl reload nginx
- onchanges:
- file: nginx-conf
# 如果检查失败,不让service重启
- failhard: True
Salt的 file.managed 默认会对比本地文件的哈希值。如果内容没变,它不会触发后续的 cmd.run。这个相对Ansible的优势在于文件分发是事件驱动,不用整批重新加载。
在3000台服务器上执行
# Ansible执行
time ansible-playbook -i hosts nginx_playbook.yml
# Salt执行
time salt 'web*' state.apply nginx
上面的命令在压测环境执行后,得到一组非常直观的数据。
效果数据:3000台服务器压测对比
压测环境是AWS c5.xlarge (2核4G),3000台EC2,操作系统均为CentOS 7.9。目标:替换 /etc/nginx/nginx.conf 并完成reload。
| 方案 | 总耗时 | 单台平均耗时 | 连接方式 |
|---|---|---|---|
| 原Shell for循环 | 约5小时12分(中断失败) | 6.2秒 | 单线程SSH |
| Ansible (默认5并发) | 58分 | 6.9秒 | SSH |
| Ansible (调优100并发) | 6分11秒 | 6.1秒 | SSH连接复用 |
| SaltStack (ZMQ长连接) | 47秒 | 0.6秒 | ZMQ事件总线 |
数据说明了什么?
- Ansible优化后提速了将近10倍,从58分钟压到6分钟。瓶颈全在SSH连接复用上。
- SaltStack又比优化后的Ansible快了近8倍。ZMQ长连接省去了每次任务握手的开销。
但快不是免费的。下面用Python工具测一下资源占用差异。
#!/usr/bin/env python3
# 对比Ansible和Salt进程资源占用的粗糙代码
import subprocess, os, time
# 测量单台salt-minion内存占用
pid = subprocess.check_output(["pgrep", "salt-minion"]).decode().strip().split("\n")
total_rss = 0
for p in pid:
with open(f"/proc/{p}/status") as f:
for line in f:
if line.startswith("VmRSS"):
total_rss += int(line.split(":")[1].strip().split("kB")[0])
print(f"单台salt-minion平均占用: {total_rss // len(pid)} KB")
# 测量Ansible任务过程的额外内存 (统计当前父进程)
print(f"Ansible父进程内存: {int(open('/proc/self/status').read().split('VmRSS:')[1].split('kB')[0])} KB")
实测单台salt-minion内存占用约25MB,3000台就是75GB。而Ansible主控端在并发100时占内存约2GB,被控服务器则零开销。
选型决策
- 如果你的预算允许装Agent,且服务器规模超过3000台,对操作的实时性要求高(比如要做动态扩容、批量重启),选SaltStack。
- 如果你的服务器只有几十到几百台,或者安全审计不允许装Agent,选Ansible,谁用谁知道。
避坑指南:我踩过的这些坑,你要直接跳过
坑1:Ansible的ControlMaster连接池卡死
之前把 ssh_args 写得太长,没有设 ControlPersist 的超时。结果每次执行完,SSH连接都在本机 /tmp/ansible-ssh-* 下留下socket文件。长时间跑下来,文件描述符被耗尽,连接池直接爆掉。
解决方案:必须设置 ControlPersist=3600s,并且最好在cron里定期清理 find /tmp -name "*ansible*" -exec rm -rf {} \;。我当时排查了2个小时才发现是这个原因。
坑2:Ansible的notify和handlers不触发
在Playbook里用了 notify: reload nginx,结果发现改了配置后rebad没反应。查资料才知道,如果任务里的输出是 ok 而不是 changed,handlers不会执行。
解决:必须确保被引用的文件内容是真正变了。比如Nginx配置里如果有空格或者换行的细微变化,Ansible会默认视为没变化(注意 strip 选项)。实在不行强制加 --force-handlers 参数。
坑3:Salt的ZMQ报错 "Cannot allocate memory"
Salt在跑大规模事件时,ZMQ的内部缓冲区会溢出来报这个错。
解决:
# 在Master上配置系统参数,提升ZMQ吞吐
sysctl -w net.core.rmem_max=67108864
sysctl -w net.core.wmem_max=67108864
echo "net.core.rmem_max=67108864" >> /etc/sysctl.conf
同时把 salt-master 的 worker_threads 从默认5调到20,并发能力翻倍。
坑4:Salt的file_roots有缓存
更新了 /srv/salt/nginx/nginx.conf 后,minion执行状态时一直用旧配置。这是因为Master端的文件服务器模块有缓存。
解决:执行完修改后,重启下salt-master:systemctl restart salt-master,或者在命令行加 --refresh 参数。
坑5:Ansible在批量执行时,用了变量但没定义就静默跳过
Jinja2模板里如果有个变量没定义,Ansible默认会渲染空字符串,而不是报错。这导致机器上出现了空的 user = 这种配置,排查起来非常诡异。
解决:在Playbook开头加 ansible_managed: "{{ ansible_managed }}",并在模板顶部调用 {{ ansible_managed }}。同时养成用 assert 模块校验关键变量的习惯。
写在最后
工具没有绝对的好,只有合不合适。Ansible是轻骑兵,Salt是重装坦克。用本文的压测数据去跟你的领导报告,用我给的代码去搭建你的生产环境,别重复我踩过的坑。