一、背景与定位
我们最早接触 SaltStack 是 2017 年,当时手上有一批新上的 IDC 机器,2000 多台同质的 CentOS 7, 要做初始化、SSH 加固、账号清理、Nginx 统一上线、MySQL 主从配置管理。 在那之前,团队一直用 expect 写半自动脚本,但脚本没人维护、参数到处飞、出了问题回滚基本靠人肉。 我们试过 Puppet(太重、Ruby 改起来累)、Chef(也重,knife 操作反人类)、 Ansible(2017 年那会儿还叫 1.x,连个像样的 inventory 缓存都没有), 最终选 SaltStack 的原因很朴素:默认有 agent,性能能打,SLS 写起来像写代码。
到了 2024 年,Salt 已经发布到 3006,传输层依然默认 ZeroMQ pub/sub, 架构、命令、配置几乎没有本质变化,新版本主要在 Python 3 兼容、加密传输、returner 性能上做文章。 所以这一篇里讲的东西,对 Salt 2018、2019、3000、3004、3006 都基本适用, 个别参数和路径以官方 docs.saltproject.io 为准。
1.1 适用场景
我们把 SaltStack 用在下面这几类场景上,每一类都跑过几千台规模。
大规模同质机配置中心化。 IDC 自购服务器,云上 ECS,操作系统 80% 是 CentOS 7,少量 Rocky 8、Ubuntu。 机器之间差异主要在 IP、hostname、机房 / 可用区,业务镜像基本一致。 这种情况,SLS 里写一次,所有机器收敛。
配置漂移治理。 团队里有人手改 /etc/ssh/sshd_config,有人改了没记下来,结果一收 salt '*' state.sls ssh.hardening, diff 一看一堆变更。state.sls 自带幂等,没改就跳过,改了才下发,正好治这个毛病。
跨 IDC 批量执行运维动作。 重启、清理日志、改 crontab、抓日志、临时拉黑 IP。 这种短任务用 salt-run 或者 salt -G 比写 SSH 脚本快太多。
初始化基线落地。 新机器从装机开始就接 minion,跑一次 state.highstate 把 sysctl、limits、user、repo、ntp 全部装好。 这块我们和 PXE + Cobbler 联动,10 分钟一台机器从裸机到上线。
1.2 不适用场景
不是所有场景都适合用 SaltStack,我们踩过的坑也写下来。
纯云上短期项目。上 20 台机器用 3 个月就拆,Ansible 或者 Terraform 更轻。
容器化为主的集群。K8s 自己一套声明式,Salt 去管容器化节点属于重复造轮子。
变更极少、维护人员少的团队。SaltStack 的回报要靠"统一收敛"来体现, 一年改不了几次的东西,不如写个文档让人手改。
1.3 选型对比的真实体感
我们和 Ansible / Puppet / Chef 都打过几年交道,下面这张表不是理论比较,是 "2018–2024 年我们用它们踩过什么坑"的总结。
| 工具 | 传输方式 | 性能(千台并发) | 学习曲线 | 排错难度 | 团队接受度 |
|---|---|---|---|---|---|
| SaltStack | ZeroMQ pub/sub 长连接 | 强,master 多 worker 撑得住 | 中,SLS 关键字要记 | 中,returner 写不全会丢日志 | 高,命令风格像 ssh |
| Ansible | SSH 短连接 | 弱,串行默认 5 fork,开了也吃力 | 低,YAML 直接写 | 难,错误信息散在 stdout | 高,但 500 台开始卡 |
| Puppet | 拉模式,agent 周期同步 | 中等 | 高,DSL 难上手 | 难,catalog 排错痛苦 | 中,老牌但新人不愿意学 |
| Chef | 拉模式 + Ruby DSL | 中等 | 高,Ruby 门槛 | 难,knife 操作复杂 | 低,新人基本不来 |
注意:上面这些是"我们的体感",不是 benchmark。新版本 Ansible 加了 async、 Puppet 加了 Bolt、Salt 加了 RAET 和 TCP transport,性能差距没有表格里那么夸张。 但在我们这种"几千台同质机、配置反复改"的场景里,Salt 的长连接模型确实省事。
1.4 我们踩过的最痛的一个坑
2018 年第一次上生产的时候,我们以为"装好 master、装好 minion、salt-key -A 一下就完事了"。 结果当 minion 数量上到 2000 的时候,master 莫名其妙 OOM 被 kill。 后来查到三个原因叠在一起:
默认 worker_threads 只开 5 个,几千并发直接打满。
returner 没配,作业结果全堆在 master 内存。
event bus 接收的认证事件没归档,越积越大。
那次故障之后我们做了三件事:给 master 加 worker、调 returner 落库、 按业务拆分多 master。这一篇后面"高可用"那一节会详细讲怎么调。
二、架构与组件
SaltStack 的架构在文档里写得很抽象,第一次看很容易蒙。 我们按"角色 + 数据流 + 端口"三件事讲一次,后面所有命令都能对得上。
2.1 角色
SaltStack 一共五种角色,生产里最常用前三种。
salt-master。 中心节点,对外接收 minion 的认证请求,对内负责编译 SLS、维护 Pillar、调度作业。 默认监听两个端口:publish_port = 4505(pub/sub)、ret_port = 4506(return)。
salt-minion。 装在被管机器上的 agent。 启动时按 minion 配置里的 master 地址去连,连上后做密钥交换,然后保持长连接。 收到作业就执行,执行完把 return 发回 master。
salt-syndic。 中转节点,自己既是 master 又是 minion。 适合"总控 + 分区"的多层级管理,比如集团下面有多个 BU,每个 BU 自己的 master, BU 上面再架 syndic 连集团 master。 我们没用过 syndic,10K 规模用不到,syndic 的问题在于它要做协议转换,调试链路长。
salt-api。 独立服务,提供 RESTful 接口,底层走 CherryPy。 装好后 master 上的 wheel / runner / local 模块都能通过 HTTP 调用。 这是后面接 CI / 平台的关键组件。
salt-ssh。 纯 SSH 模式,没有 minion 也能跑 Salt 命令。 适合网络隔离、不能装 agent 的场景,比如一些银行的 DMZ 区。 性能比 ZeroMQ 模式差很多,不建议大规模用。
2.2 传输层
Salt 的传输层在历史上换过几次,3000 之前默认 ZeroMQ,3000+ 还能选 RAET(基本不用了) 和 TCP(实验性)。
ZeroMQ 模式下数据流是这样的:
minion --pub--> master:4505 (认证、心跳、作业下发) minion <--ret-- master:4506 (return 结果,minion 主动连回)
注意方向是反直觉的:
4505(publish_port)由 master 监听,minion 主动连进来。 minion 一连上就订阅一个 topic,master 推送作业时是广播到所有订阅者。 所以这个端口是"minion 主动连 master"。
4506(ret_port)也是 master 监听,minion 主动连进来。 但方向是 minion 把 return 发回 master。 也就是说 两个端口都是 minion 主动连 master,master 不主动连 minion。
这个细节对排错和防火墙规则都很重要,后面的故障案例会用到。
2.3 认证
minion 第一次启动时,会生成自己的密钥对,公钥发给 master。 master 把这个公钥放在 /etc/salt/pki/master/minions/
接受之后,minion 就可以发作业请求了。master 会校验 minion_id 和 minion 公钥是否匹配。
常见误区:
直接 salt-key -A 接受所有 unaccepted 主机,这在生产里是大忌。 我们公司要求:"salt-key 必须按主机名白名单接受",由堡垒机工单系统推过来。
改完 hostname 之后 minion 重新生成密钥对,老的 accepted 列表会失效, 表现为 Minion did not return. [No response]。需要先 salt-key -d old_hostname 删旧的,再 -A 收新的。
2.4 拓扑
我们生产用的是双 master(failover)拓扑,结构如下:
+------------------+ | salt-master-A | <--- ops / ci 调用 +------------------+ ↑ ↑ ↑ master_type: failover ↑ ↑ ↑ +-------------+ +------+-------+ +------+-------+ | minion-1 |--->| | | | +-------------+ | salt-master-B|<-->| ops host | +-------------+ | | | (admin) | | minion-2 |--->| | +--------------+ +-------------+ +--------------+ (实际环境两个 master 都做 master,互不依赖。 minion 配置 master: [A, B],master_type: failover,按列表顺序尝试。 我们不用双主热切换,因为状态文件用 Git 分发,比 keepalived 简单可靠。)
单机小规模:单 master 就够。 中大规模(500–5000):多 master failover。 超大规模(5000+):拆 BU + syndic 或者直接上多个独立 master 集群。
2.5 与 Ansible 的本质差异
简单说三件事:
长连接 vs 短连接。 Salt 是 minion 启动就常驻,连一次保活,作业直接通过 ZeroMQ 推下去,毫秒级。 Ansible 默认是 SSH 短连接,每次执行都要建链。 这点在小规模上没差别,几千台就有差别。
有 agent vs 无 agent。 Salt 必须装 minion。 Ansible 不用,但代价是每台机器都得有 Python 和 SSH 服务。 装机量大的团队,"装 minion" 反而是优势,因为装机过程就会把 minion 一起装上。
状态 vs 任务。 Salt 鼓励你写 SLS(声明式状态),系统自己收敛到期望状态。 Ansible 偏任务式,playbook 是步骤清单。 这个差异在配置管理场景里影响很大。
2.6 目录结构
master 上几个关键目录,生产环境建议按这个走:
/etc/salt/ master # master 主配置 minion # master 自己也可以跑 minion pki/ master/ master.pub # master 公钥 master.pem # master 私钥 minions/ # 已接受的 minion 公钥 minions_pre/ # 待接受 minions_rejected/ # 已拒绝 /srv/salt/ # state 文件根(file_roots: base) top.sls nginx/ ssh/ ... /srv/pillar/ # pillar 文件根(pillar_roots: base) top.sls nginx/ ... /var/log/salt/ # master / minion 日志 master minion /var/cache/salt/ # job cache、minion cache /etc/salt/minion # minion 配置(被管机器上)
/srv/salt 和 /srv/pillar 在 master 和 minion 上的角色不一样: master 上是源数据,minion 上是 master 推过来的渲染结果,路径不一定在 /srv, 可以通过 file_client: local 或者 roots 配置改。
三、环境准备与安装
3.1 操作系统与 Python 版本矩阵
生产里我们见过最稳的组合:
| 操作系统 | Python 默认版本 | Salt 推荐版本 | 备注 |
|---|---|---|---|
| CentOS 7.9 | 2.7(系统) | Salt 3006.6+(用 SCL 装 Py3) | 装 python36 SCL,让 minion 走 Py3 |
| Rocky Linux 8.6 | 3.6(系统) | Salt 3006.x | 直接 pip 或官方 repo |
| Rocky Linux 9.x | 3.9 / 3.11 | Salt 3006.x | 默认 Py3 兼容好 |
| Ubuntu 20.04 | 3.8 | Salt 3006.x | apt 源直接装 |
| Ubuntu 22.04 | 3.10 | Salt 3006.x | apt 源直接装 |
不要在生产里用 Salt 2018 / 2019 这种老版本跑 Py2.7 master。 Py2.7 已经 EOL,各种库不再维护,安全漏洞没人修。 如果机器上还有 Py2 的脚本共存,让 minion 跑在 SCL / venv 的 Py3 环境里, 不要去改系统默认 Python。
3.2 仓库配置
CentOS 7 配官方源(盐项目官方源在国内访问不太稳,建议走阿里云镜像):
# /etc/yum.repos.d/salt.repo [salt] name=SaltStack repo for RHEL/CentOS $releasever baseurl=https://mirrors.aliyun.com/salt/rpm/latest/$releasever/ enabled=1 gpgcheck=1 gpgkey=https://mirrors.aliyun.com/salt/rpm/latest/$releasever/SALTSTACK-GPG-KEY.pub
Rocky 8 / RHEL 8:
dnf install -y epel-release dnf config-manager --add-repo https://mirrors.aliyun.com/salt/rpm/latest/8/
Ubuntu 20.04:
curl -fsSL -o /usr/share/keyrings/salt-archive-keyring.gpg https://mirrors.aliyun.com/salt/deb/ubuntu/20.04/amd64/latest/salt-archive-keyring.gpg echo "deb [signed-by=/usr/share/keyrings/salt-archive-keyring.gpg] https://mirrors.aliyun.com/salt/deb/ubuntu/20.04/amd64/latest focal main" > /etc/apt/sources.list.d/salt.list apt-get update
这里有个坑:很多教程让你装 salt-master 包名,但 Salt 3000+ 的包名变了。 请按下面这套来:
| 角色 | 包名(CentOS) | 包名(Ubuntu) |
|---|---|---|
| master | salt-master | salt-master |
| minion | salt-minion | salt-minion |
| syndic | salt-syndic | salt-syndic |
| api | salt-api | salt-api |
| 通用依赖 | salt-common | salt-common |
salt-common 包含所有 Python 库依赖,单独装一份常用来排错(rpm -ql salt-common 看一下)。
3.3 安装
3.3.1 YUM / APT 安装(生产推荐)
master:
yum install -y salt-master salt-api salt-cloud salt-ssh systemctl enable salt-master systemctl start salt-master
minion:
yum install -y salt-minion systemctl enable salt-minion systemctl start salt-minion
3.3.2 pip 安装(容器化场景)
容器里我们更倾向 pip,因为基础镜像通常不带 systemd:
pip3 install salt==3006.6 salt-api==3006.6
容器化 Salt 的 master 进程启动方式比较 hack,需要自己写个 entrypoint 起 salt-master:
#!/bin/bash # /entrypoint.sh set -e if [ "$1" = "master" ]; then exec salt-master -l warning elif [ "$1" = "minion" ]; then exec salt-minion -l warning elif [ "$1" = "api" ]; then exec salt-api -l warning else exec "$@" fi
注意:容器化 master 不适合做大规模(性能不如物理机),适合做 dev / test 沙箱。 生产 master 我们坚持用物理机或稳定 VM。
3.4 关键依赖
rpm -qR salt-master 能看到所有依赖,下面这几个出问题最多:
python3-pyzmp:ZeroMQ 的 Python 绑定。版本不匹配会报zmq.error.ZMQError: Protocol not supported,重装就能好。
python3-crypto / pycryptodome:AES 加密、pillar gpg 都靠它。
python3-msgpack:作业 / pillar 序列化。
python3-jinja2:SLS 模板渲染。
python3-looseversion:版本比较。
python3-yaml:YAML 解析。
YUM 装的版本是 Salt 官方测过的,最稳。不要自己 pip install 升级这些库,会和包管理器的版本冲突。
3.5 启动参数
master 启动参数:
# 守护进程 salt-master -d # debug 模式(前台、详细日志、排错用) salt-master -l debug # 改日志输出位置 salt-master --log-file=/var/log/salt/master.log # 改配置文件路径 salt-master -c /etc/salt # 改 user salt-master --user salt
minion 启动参数和 master 类似,常见排错组合:
salt-minion -l debug
把 minion 跑在前台 + debug 日志,是定位"为什么连不上 master"最快的办法。 看 /var/log/salt/minion 里的关键报错,比如 Failed to authenticate、No master found、Key exchange failed,90% 的断连问题能从日志里直接看出原因。
3.6 minion 注册流程
这是新机器上线的标准动作:
# 1. minion 端配置 /etc/salt/minion master: 10.20.0.10 id: web-prod-01 # 2. 启动 minion systemctl start salt-minion # 3. minion 端查看自己的公钥 cat /etc/salt/pki/minion/minion.pub # 4. master 端查看待接受列表 salt-key -L # 输出: # Unaccepted Keys: # web-prod-01 # Accepted Keys: # ... # 5. 接受单个 salt-key -a web-prod-01 # 6. 接受所有(仅测试环境) salt-key -A # 7. 拒绝单个 salt-key -D # 8. 删除一个已接受的 salt-key -d web-prod-01
salt-key 的参数:
-L:列出所有(分 Unaccepted / Accepted / Rejected 三组)。
-A:接受所有 unaccepted。
-a
-D:删除所有 accepted(生产里不要用)。
-d
-R:拒绝所有 unaccepted(标记为 Rejected)。
-r
-F:打印 fingerprint,配合 -l 可以看哪个 key 对应哪个主机。
风险提醒:salt-key -D 会把所有已接受 minion 全部踢下线。 生产里执行前一定要先 salt-key -L | tee /tmp/before-rm.txt 留个底。
3.7 防火墙与端口
master 端要开放 4505、4506 给 minion 段。 不要把这两个端口暴露到公网,只在 IDC 内部网或 VPN 通道开放。
# master 端防火墙 firewall-cmd --permanent --add-port=4505/tcp firewall-cmd --permanent --add-port=4506/tcp firewall-cmd --reload # 或者 iptables iptables -A INPUT -p tcp -s 10.20.0.0/16 --dport 4505 -j ACCEPT iptables -A INPUT -p tcp -s 10.20.0.0/16 --dport 4506 -j ACCEPT
minion 端不需要开放端口(minion 是主动连 master 的)。
3.8 安装后的快速验证
# 看 master 上接受的 minion 数量 salt-key -L | grep -c "^[a-zA-Z]" # 给所有 minion 发个 ping salt '*' test.ping # 输出形如: # web-prod-01: # True # web-prod-02: # True # 看版本 salt '*' test.version # 输出形如: # web-prod-01: # 3006.6 # 看 grains(基础信息) salt '*' grains.items # 看网络信息 salt '*' network.ip_addrs salt '*' network.interfaces # 看 disk / cpu / mem salt '*' disk.usage salt '*' status.diskstats salt '*' status.meminfo salt '*' status.cpustats salt '*' status.loadavg salt '*' status.uptime salt '*' status.all_status salt '*' status.w salt '*' status.version salt '*' status.pid
这几条命令是后面所有排错的基础,先确保它们在 test=True(不实际改动)下都跑通。
四、核心概念深入
SaltStack 的"概念"在文档里写得零零散散,我们按工程上理解的先后顺序重新组织一遍。
4.1 Grains
Grains 是 minion 启动时收集的静态信息,保存在 minion 端。 常见 grains 字段:
os: CentOS os_family:RedHat osarch:x86_64 kernel:Linux cpuarch:x86_64 num_cpus:16 mem_total:64258 ipv4:[10.20.0.11,10.20.0.12] fqdn:web-prod-01.example.com id:web-prod-01
自定义 grains,写在 minion 的 /etc/salt/grains(YAML 格式):
# /etc/salt/grains role: nginx env: prod zone: cn-bj-1
或者在 minion 配置里指定:
# /etc/salt/minion grains: role: nginx env: prod zone: cn-bj-1
自定义 grains 用法:
# 按 role 过滤 salt -G 'role:nginx' test.ping # 按 env 过滤 salt -G 'env:canary' test.ping # 多条件 salt -G 'role:nginx and env:prod' test.ping salt -G 'role:mysql or role:redis' test.ping
Grains 适合做"机器特征",比如机房、角色、OS 版本。不要把变化频繁的数据写到 grains,比如"今天是不是在灰度名单里"。
4.2 Pillar
Pillar 是 master 端维护的、给特定 minion 用的数据。和 grains 的区别:
| 维度 | Grains | Pillar |
|---|---|---|
| 存在端 | minion | master |
| 谁维护 | minion 启动时自己 | 运维在 master 上写 |
| 适用 | 静态、机器特征 | 动态、配置、敏感数据 |
| 同步 | 主动 push | minion 拉 |
| 加密 | 不支持 | 支持 gpg |
Pillar 的目录结构:
/srv/pillar/ top.sls base/ init.sls nginx/ init.sls mysql/ init.sls
/srv/pillar/top.sls:
base: '*': -base.init 'role:nginx': -match:grain -nginx.init 'role:mysql and env:prod': -match:grain -mysql.init -mysql.prod
/srv/pillar/base/init.sls:
timezone: Asia/Shanghai ntp_servers: - ntp1.example.com - ntp2.example.com
/srv/pillar/nginx/init.sls:
nginx:
port: 80
workers: "{{ grains['num_cpus'] }}"
domains:
- example.com
- example.org
/srv/pillar/mysql/init.sls:
mysql:
server_id: "10{{ grains['id'][-3:]|int }}"
binlog_format: ROW
innodb_buffer_pool_size: "{{ (grains['mem_total'] * 0.6) | int }}M"
注意:server_id 这种数字如果直接用 jinja 拼接,minion_id 又不是数字前缀, 会拼出错。我们用 [-3:]|int 这种方式做尾巴截取 + 转 int, 对 ID 是 web-prod-001 的机器就能正常生成 server_id = 101。
Pillar 调试命令:
# 看某台机器的 pillar 全量 salt 'web-prod-01' pillar.items # 拿一个 key salt 'web-prod-01' pillar.get nginx:port # 强制刷新(minion 端拉一次) salt 'web-prod-01' saltutil.refresh_pillar # 看哪些 SLS 命中了 salt 'web-prod-01' pillar.show_top
敏感数据加密:用 gpg-render 把 Pillar 文件用 gpg key 加密, master 配置:
# /etc/salt/master pillar: gpg_render: True gpg_keydir: /etc/salt/gpgkeys
加密命令:
gpg --gen-key # 导出公钥到 master gpg --export --armor > /etc/salt/gpgkeys/pub.key # 用私钥加密 SLS gpg --encrypt --sign --armor -r user@example.com /srv/pillar/secret.sls
加密后的 pillar 文件是 ASCII armor 格式,可以放心进 Git。 不过实际项目里我们更倾向用 vault 拉取 token 注入,gpg 维护起来很重。
4.3 State(SLS)
SLS 是 Salt 的核心,结构是 YAML 渲染 + Jinja2 模板。
4.3.1 基础结构
# /srv/salt/nginx/init.sls nginx: pkg.installed: -name:nginx service.running: -name:nginx -enable:True -watch: -file:/etc/nginx/nginx.conf /etc/nginx/nginx.conf: file.managed: -source:salt://nginx/files/nginx.conf -template:jinja -user:root -group:root -mode:644 -require: -pkg:nginx
几个关键字段解释:
ID:state 文件里的资源 ID,state 调用时按 ID 寻址。
state.module:状态模块,常见有 pkg.installed、file.managed、service.running。
name:模块实际操作的资源名。省略时用 ID。
require:依赖关系,声明 A 必须在 B 之后执行。
watch:和 require 类似,但当被 watch 的资源变更时,触发 service reload。
unless:条件守卫,条件为真则跳过。
onlyif:条件守卫,条件为真才执行。
extend:在其他 state 文件中追加属性。
4.3.2 优先级
SLS 优先级(requisite 链)的执行顺序:
require / require_in:强依赖。
watch / watch_in:变更触发。
onfail / onfail_in:失败触发。
onchanges / onchanges_in:变化触发。
prereq / prereq_in:被依赖方先执行。
最常用的就是 require 和 watch,其他的进阶场景才用。
4.3.3 顺序控制
# A 必须在 B 之前 A: ... -require: -pkg:B # B 必须在 A 之前 A: ... -require_in: -pkg:B
require_in 写在 A 里,但语义是"为了让 B 成功,A 必须先成功"。 两个方向都对,区别在于"以谁为锚点写状态"。
4.3.4 条件判断
/etc/nginx/conf.d/default.conf: file.absent: - name: /etc/nginx/conf.d/default.conf - unless: - ls /etc/nginx/conf.d/default.conf
nginx: pkg.installed: - name: nginx - onlyif: - test "$(rpm -q nginx | wc -l)" == "0"
onlyif 和 unless 后面跟的是 shell 命令列表。注意:unless/onlyif 在 minion 端执行,如果 minion 是 root 跑命令,就别在命令里写需要登录 shell 的语法。
4.3.5 引用和继承
# /srv/salt/top.sls base: '*': -common -ssh.hardening 'role:nginx': -match:grain -nginx 'role:mysql': -match:grain -mysql
include 在 state 文件内引用其他 SLS:
# /srv/salt/web/init.sls include: - common - nginx - ssh.hardening
extend 在不修改原 SLS 的情况下追加:
# /srv/salt/nginx/ext.sls include: - nginx extend: /etc/nginx/nginx.conf: file.managed: - mode: 600
这在"上游有公共 base,子公司要本地化微调"的场景里非常有用。
4.4 Render Pipeline
state 文件从写出来到执行,要经过四步:
YAML 解析:把 SLS 文件解析成 Python 字典。
Jinja 渲染:把 {{ grains['os'] }} 这种变量替换成实际值。
PyObjects 转换:把字典转成 state 内部对象(PyObjects)。
HighState 排序:按 require 链把执行顺序排出来,输出执行计划。
调试时如果渲染出问题,可以用 state.show_sls 单独看渲染结果:
salt 'web-prod-01' state.show_sls nginx salt 'web-prod-01' state.show_lowstate salt 'web-prod-01' state.show_highstate
show_highstate 能看到所有 minion 应当执行的状态、当前实际状态、以及 diff。这是排查"为什么这个 state 改不下去"的第一步。
4.5 Orchestrate
Orchestrate 是 runner 模块里的 state 子模块,作用是跨 minion 编排执行顺序。 比如:"先在 A 上跑 SQL 升级,成功后再在 B 上跑"。
salt-run state.orchestrate orchestrate.web_upgrade
对应的 SLS 在 /srv/salt/orchestrate/web_upgrade.sls(或 /srv/salt/orchestrate/web_upgrade/init.sls):
# /srv/salt/orchestrate/web_upgrade.sls upgrade_db_master: salt.state: -tgt:'role:db and role_master:True' -sls: -mysql.upgrade -failhard:True upgrade_db_slave: salt.state: -tgt:'role:db and role_master:False' -sls: -mysql.upgrade -require: -salt:upgrade_db_master -failhard:True upgrade_app: salt.state: -tgt:'role:web' -sls: -web.reload -require: -salt:upgrade_db_slave -failhard:True
failhard: True 是关键——一个环节失败立刻终止整个编排, 避免雪崩式故障。
4.6 Returner
默认情况下,作业 return 存在 master 内存里。 但 master 重启就没了,对审计和排错不友好。 returner 把 return 落到外部存储。
常见 returner:
| Returner | 配置 | 适用 |
|---|---|---|
| local | /var/cache/salt/job | 单机调试 |
| mysql | 写到 MySQL jids / salt_returns 表 | 长期审计 |
| redis | 写到 Redis list | 高吞吐场景 |
| kafka | 写 topic | 接 ELK 做日志分析 |
| sentry | 失败时上报 | 异常监控 |
| slack | 失败时发通知 | 简单告警 |
MySQL returner 配置示例:
# /etc/salt/master master_job_cache: mysql mysql.host: '127.0.0.1' mysql.user: 'salt' mysql.pass: 'salt_pwd' mysql.db: 'salt' mysql.port: 3306
建表 SQL(官方自带):
CREATE DATABASEsaltCHARACTERSET utf8mb4; USEsalt; -- jids 表 CREATETABLE jids ( jid varchar(20) NOTNULL PRIMARY KEY, load mediumtext NOTNULL ) ENGINE=InnoDB; -- salt_returns 表 CREATETABLE salt_returns ( fun varchar(50) NOTNULL, jid varchar(20) NOTNULL, return mediumtext NOTNULL, idvarchar(255) NOTNULL, successvarchar(20) NOTNULL, full_ret mediumtext NOTNULL, alter_time TIMESTAMPNOTNULLDEFAULTCURRENT_TIMESTAMP, KEYid (id), KEY jid (jid), KEY fun (fun) ) ENGINE=InnoDB; -- salt_events 表(事件流) CREATETABLE salt_events ( idBIGINTNOTNULL AUTO_INCREMENT PRIMARY KEY, tag varchar(255) NOTNULL, data mediumtext NOTNULL, alter_time TIMESTAMPNOTNULLDEFAULTCURRENT_TIMESTAMP, KEY tag (tag) ) ENGINE=InnoDB;
风险提醒:MySQL returner 的写频率是按作业来算的,5000 台并发时 SQL 写入很快。 要给 salt 用户的 salt_returns 表加合适索引,并把 master 的event_match_type 调成 startswith 或 pcre,否则 master 端 CPU 会打满。
4.7 Reactor
Reactor 在 master 监听 event bus,事件触发执行 state / runner。 比如"minion 启动事件触发注册动作"。
# /etc/salt/master reactor: - 'salt/minion/*/start': - /srv/salt/reactor/minion_start.sls - 'salt/job/*/ret/*': - /srv/salt/reactor/job_ret.sls
reactor SLS:
# /srv/salt/reactor/minion_start.sls
log_start:
local.cmd.run:
- tgt: '*'
- expr_form: glob
- arg:
- echo "{{ data['id'] }} started at {{ data['stamp'] }}"
注意:reactor 写起来方便但滥用会让 master 事件流死循环,不要在 reactor 里再触发会写 event 的 action。
4.8 Scheduler 和 Beacon
Scheduler:minion 端定时任务,类似 cron。 Beacon:minion 端系统事件采集。
# /etc/salt/minion
schedule:
highstate:
function:state.highstate
minutes:30
beacons:
load:
-averages:
1m:
-0.0
-10.0
5m:
-0.0
-5.0
-emit_at_rest:True
inotify:
-/etc/nginx:{}
beacon 把系统状态变化转成 event,master 端用 reactor 接住就能联动。 比如发现某台机器 load 飙高,自动发 salt job 抓现场。
五、常用命令与排查路径
5.1 目标匹配
Salt 的目标匹配(targeting)非常灵活,是日常 80% 的命令入口。
# 通配 salt 'web*' test.ping # 正则 salt -E 'web-(prod|canary)-.*' test.ping # 列表 salt -L 'web-01,web-02,web-03' test.ping # Grains salt -G 'role:nginx and env:prod' test.ping salt -G 'os:CentOS and mem_total:>30000' test.ping # Pillar salt -P 'nginx80' test.ping # CIDR salt -S '10.20.0.0/24' test.ping # Nodegroup(在 master 配置里预定义) salt -N web-cluster test.ping
master 端 nodegroups 配置:
# /etc/salt/master nodegroups: web-cluster: 'G@role:web and G@env:prod' db-cluster: 'G@role:mysql and G@env:prod' canary: 'G@env:canary'
风险提醒:-L 后面跟一千个 ID 是合法的,但容易拼错。 我们要求生产环境的批量执行强制走 nodegroup 或者 -G,避免误操作。
5.2 常用模块
5.2.1 cmd
# 单条命令 salt '*' cmd.run 'uptime' # 带超时 salt '*' cmd.run 'long_task.sh' timeout=60 # stdin 传参 salt '*' cmd.run_stdin 'echo $0' # 看完整 shell 环境 salt '*' cmd.run 'env' # 拿 exit code salt '*' cmd.retcode 'nginx -t' # 跑脚本(注意 shell 转义) salt '*' cmd.script 'salt://scripts/check.sh' salt '*' cmd.script 'https://example.com/check.sh' # 看进程树 salt '*' cmd.run 'ps -ef | head'
风险提醒:cmd.run 是"任意命令执行"。 生产里 cmd.run 必须走 salt-api,不允许直接 SSH 到 master 跑。 salt-api 后台要开 audit returner 留痕。
5.2.2 service
# 看运行状态 salt '*' service.status nginx salt '*' service.available nginx salt '*' service.enabled nginx # 启停 salt '*' service.start nginx salt '*' service.stop nginx salt '*' service.restart nginx salt '*' service.reload nginx # enable / disable salt '*' service.enable nginx salt '*' service.disable nginx
5.2.3 pkg
# 装包 salt '*' pkg.install nginx salt '*' pkg.install pkgs='["nginx","php-fpm"]' # 卸包 salt '*' pkg.remove nginx # 升级 salt '*' pkg.upgrade salt '*' pkg.upgrade available=True # 看列表 salt '*' pkg.list_installed salt '*' pkg.list_upgrades salt '*' pkg.version nginx # 加源(CentOS) salt '*' pkg.installed https://example.com/repo.rpm # 加源(Ubuntu) salt '*' pkgrepo.managed name='example' uri='https://example.com/repo'
5.2.4 file
# 看文件信息 salt '*' file.stats /etc/nginx/nginx.conf # 看内容 salt '*' file.read /etc/nginx/nginx.conf # 查文件是否存在 salt '*' file.file_exists /etc/nginx/nginx.conf # 替换文本 salt '*' file.replace /etc/nginx/nginx.conf pattern='^worker_processes.*' repl='worker_processes auto;' # 加一行 salt '*' file.append /etc/hosts text='10.20.0.100 db.example.com' # 删文件 salt '*' file.remove /tmp/old.log # 改权限 salt '*' file.set_mode /etc/redis.conf mode='0640' salt '*' file.set_user /etc/redis.conf user=redis
风险提醒:file.remove 会真删文件,必须配合 unless。file.replace 用错了正则可能把配置改坏,先 dry-run 看一下。
5.2.5 user / group
salt '*' user.add deployer uid=2001 shell=/bin/bash salt '*' user.present name=deployer shell=/bin/bash groups=['sudo','docker'] salt '*' user.delete deployer remove=True salt '*' group.present name=deploy salt '*' group.adduser deploy deployer
5.2.6 cron
salt '*' cron.present name='logrotate' user=root minute=0 hour=2 command='/usr/sbin/logrotate /etc/logrotate.conf' salt '*' cron.absent name='logrotate' user=root salt '*' cron.list_tab root
5.2.7 mount
salt '*' mount.mounted /data fstype=nfs device='nfs.example.com:/data' opts='defaults,_netdev' dump=0 pass_num=0 salt '*' mount.swapon /swapfile salt '*' mount.fstab salt '*' mount.umount /data
风险提醒:mount.mounted 默认会写 /etc/fstab,挂错的 NFS 会让机器重启卡住。 生产里要 test=True 先验证。
5.2.8 network
salt '*' network.ip_addrs salt '*' network.interfaces salt '*' network.routes salt '*' network.active_tcp salt '*' network.dig www.example.com salt '*' network.ping host=8.8.8.8 salt '*' network.traceroute host=8.8.8.8 salt '*' network.netstat salt '*' network.ss
5.3 状态执行与 dry-run
# 单 SLS salt 'web*' state.sls nginx # 加 test=True 不实际改动 salt 'web*' state.sls nginx test=True # 显式指定 env salt 'web*' state.sls nginx saltenv=base # 全量 salt '*' state.highstate salt '*' state.highstate test=True # 应用(推 top.sls 命中之外的所有 state) salt '*' state.apply # 单独跑一个 state(带参数) salt '*' state.single pkg.installed name=nginx # 高 detail 输出 salt '*' state.sls nginx -l debug --state-output=mixed # 详细显示 change / diff salt '*' state.sls nginx --state-output=changes
--state-output 的几个值:
full:默认,显示所有字段。
terse:精简。
mixed:change 块。
changes:只显示 diff。
no:不显示。
排错时 mixed 最好用,能看到本次到底改了什么。
5.4 异步执行
# 同步等所有 minion 跑完 salt '*' cmd.run 'sleep 5' --timeout=10 # 异步立即返回 jid salt '*' cmd.run 'sleep 5' --async # 输出形如: # Executed command with job ID: 20250506102030123456 # 之后查结果 salt-run jobs.lookup_jid 20250506102030123456 # 查正在跑的 salt-run jobs.active # 查最近一次 salt 'web*' jobs.last # 杀作业 salt 'web*' jobs.kill 20250506102030123456 salt-run jobs.kill_job 20250506102030123456
风险提醒:jobs.kill 只能杀 minion 上的 salt-minion 进程,不能杀底层命令。 如果要彻底杀掉进程,用 cmd.run 'pkill -f xxx',但更彻底的做法是写 SLS。
5.5 超时控制
# 单条命令超时 salt '*' cmd.run 'long_task' timeout=300 # 全局超时 salt '*' cmd.run 'long_task' --timeout=300 # state 执行超时 salt '*' state.sls nginx --timeout=600
注意 timeout 单位是秒,Salt 默认 5 秒,远低于很多实际场景。 我们生产里把 global timeout 调到 60,单独任务可以 --timeout 覆盖。
5.6 排查命令
# master 端看 minion 在线 salt-run manage.status salt-run manage.up salt-run manage.down # 看 minion 端到 master 的连接状态 salt '*' test.ping # 强制 minion 重新连 master salt 'web*' service.restart salt-minion # 强制 minion 重新读 pillar salt 'web*' saltutil.refresh_pillar # 强制 minion 重新读 grains salt 'web*' saltutil.sync_grains # 强制 minion 重新同步 module salt 'web*' saltutil.sync_modules salt 'web*' saltutil.sync_all # 看 master 上的 jobs salt-run jobs.list_jobs salt-run jobs.list_jobs search_function='cmd.run' # 跟踪一个 jid salt-run jobs.lookup_jid 20250506102030123456 # 看 master 上的 event salt-run state.event pretty=True # 看 master 内部状态 salt-run status salt-run config.get # 看 pillar / grains salt '*' pillar.items salt '*' grains.items # 本地执行(minion 不需要 master) salt-call --local test.ping salt-call --local state.highstate test=True # 同步 state 文件到 minion salt '*' saltutil.sync_states # 看某个 SLS 编译后的结果 salt '*' state.show_sls nginx # 详细 diff salt '*' state.sls nginx test=True -l debug --state-output=changes
test=True + -l debug + --state-output=changes 是排 state 问题的"三件套"。
六、实战配置示例
下面这些 SLS 是我们生产里跑过的真实配置(去敏感化),每一个都带:
业务背景
pillar / state 文件
灰度步骤
回滚方案
风险提醒
6.1 Nginx 统一部署
6.1.1 背景
公司 600 多台 Web 机器,跑同一个编译版的 Nginx。 每个机房一份 vhost 列表,PHP-FPM 监听端口不一致。 统一用 SLS 收敛以后,新机器装机 10 分钟内就有完整的 Nginx。
6.1.2 Pillar
# /srv/pillar/nginx/init.sls
nginx:
port:80
workers:"{{ grains['num_cpus'] }}"
user:nginx
group:nginx
vhosts:
-name:example.com
root:/var/www/example.com
php:True
-name:static.example.com
root:/var/www/static
php:False
ssl:
enabled:True
cert:/etc/pki/tls/certs/example.com.crt
key:/etc/pki/tls/private/example.com.key
6.1.3 State
# /srv/salt/nginx/init.sls
nginx-repo:
pkgrepo.managed:
-name:nginx-stable
-humanname:NginxStableRepo
-baseurl:https://nginx.org/packages/centos/$releasever/$basearch/
-gpgcheck:1
-gpgkey:https://nginx.org/keys/nginx_signing.key
nginx:
pkg.installed:
-name:nginx
-require:
-pkgrepo:nginx-repo
service.running:
-name:nginx
-enable:True
-watch:
-file:/etc/nginx/nginx.conf
-file:/etc/nginx/conf.d
nginx-user:
user.present:
-name:nginx
-uid:998
-gid:998
-shell:/sbin/nologin
/etc/nginx/nginx.conf:
file.managed:
-source:salt://nginx/files/nginx.conf
-template:jinja
-user:root
-group:root
-mode:'0644'
-require:
-pkg:nginx
/etc/nginx/conf.d:
file.directory:
-name:/etc/nginx/conf.d
-user:root
-group:root
-mode:'0755'
-makedirs:True
{%forvhostinpillar.get('nginx:vhosts',[])%}
/etc/nginx/conf.d/{{vhost.name}}.conf:
file.managed:
-source:salt://nginx/files/vhost.conf
-template:jinja
-user:root
-group:root
-mode:'0644'
-defaults:
vhost:{{vhost}}
ssl:{{pillar['nginx']['ssl']}}
-require:
-file:/etc/nginx/conf.d
-watch_in:
-service:nginx
{%endfor%}
/srv/salt/nginx/files/nginx.conf(模板片段):
user {{ pillar['nginx']['user'] }};
worker_processes {{ pillar['nginx']['workers'] }};
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 10240;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
server_tokens off;
{% if pillar['nginx']['ssl']['enabled'] %}
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH!MD5;
{% endif %}
include /etc/nginx/conf.d/*.conf;
}
6.1.4 灰度
# 1. canary 机器 salt -G 'env:canary and role:web' state.sls nginx test=True salt -G 'env:canary and role:web' state.sls nginx # 2. 10% 抽样 salt -G 'env:prod and role:web' state.sls nginx test=True --batch-size=10% # 3. 全量 salt -G 'role:web' state.sls nginx
--batch-size=10% 是 Salt 3000+ 的批量执行参数,每批之间有间隔, 出问题能及时停。
6.1.5 回滚
# 回退前先备份老配置 salt -G 'role:web' cmd.run 'cp /etc/nginx/nginx.conf /etc/nginx/nginx.conf.bak.$(date +%s)' # 如果新版有问题,强制恢复 salt -G 'role:web' cmd.run 'cp /etc/nginx/nginx.conf.bak.* /etc/nginx/nginx.conf && nginx -t && nginx -s reload'
6.1.6 风险提醒
模板渲染失败会导致 nginx -t 不过,从而 service.running 失败。 一定要 test=True 先看 diff。
启停脚本里默认 nginx -s reload 在 worker 错配时不会成功, 加上 test=True 的 service.status 二次确认。
vhost 列表改了以后,老的 vhost.conf 不会被自动删除, 要么手写 file.absent,要么先在 file.directory 上加 clean: True(Salt 3006+)。
6.2 系统初始化基线
6.2.1 背景
新机器从装机到上线,OS、kernel、ssh、limits、ntp、timezone、hosts 都要按基线打。 我们用 orchestrate 编排整个流程。
6.2.2 Pillar
# /srv/pillar/common/init.sls timezone:Asia/Shanghai ntp_servers: -ntp1.example.com -ntp2.example.com -ntp3.example.com sysctl: net.core.somaxconn:65535 net.ipv4.tcp_max_syn_backlog:65535 vm.swappiness:10 limits: -domain:'*' type:soft item:nofile value:65535 -domain:'*' type:hard item:nofile value:65535
6.2.3 State
# /srv/salt/common/init.sls
timezone:
file.symlink:
-name:/etc/localtime
-target:/usr/share/zoneinfo/{{pillar['timezone']}}
-force:True
ntp:
pkg.installed:
-name:chrony
service.running:
-name:chronyd
-enable:True
-watch:
-file:/etc/chrony.conf
/etc/chrony.conf:
file.managed:
-source:salt://common/files/chrony.conf
-template:jinja
-mode:'0644'
{%fork,vinpillar.get('sysctl',{}).items()%}
sysctl-{{k}}:
sysctl.present:
-name:{{k}}
-value:{{v}}
{%endfor%}
{%forlimitinpillar.get('limits',[])%}
limits-{{limit.item}}-{{limit.type}}:
pam_limits.present:
-name:{{limit.item}}
-type:{{limit.type}}
-value:{{limit.value}}
{%endfor%}
hostname:
network.system:
-hostname:{{grains['id']}}
-retain_hosts:True
/etc/hosts:
file.managed:
-source:salt://common/files/hosts
-template:jinja
-mode:'0644'
6.2.4 灰度
# 先在 1 台机器上 salt -L 'web-canary-01' state.sls common test=True # 没问题再批量 salt -G 'env:canary' state.sls common salt -G 'env:prod' state.sls common --batch-size=10%
6.2.5 回滚
sysctl 和 limits 都是回写到文件,机器重启前不会生效。 出问题回滚只要恢复 /etc/sysctl.conf 和 /etc/security/limits.conf 即可。 hosts 改坏了会导致服务连不上数据库,**先 test=True**,再加 --batch-size=10%。
6.2.6 风险提醒
改 hostname 必须同步改 hosts,否则 minion 自身重启后无法连回 master。network.system 模块会自动写 /etc/hostname 和 /etc/sysconfig/network(CentOS), 如果 minion 跑在容器里要小心,hostname 改了容器 ID 变了。
sysctl 改完不会立即生效,需要 sysctl -p 或重启网络服务。 Salt 的 sysctl.present 模块会执行 sysctl -w,但持久化在 /etc/sysctl.conf。
6.3 SSH 安全加固
6.3.1 背景
每次出新机器,SSH 端口是 22、root 能登录、密码认证开着。 我们用 SLS 把这些都收敛到"加固"基线。
6.3.2 State
# /srv/salt/ssh/hardening.sls sshd: pkg.installed: -name:openssh-server sshd_config: file.managed: -name:/etc/ssh/sshd_config -source:salt://ssh/files/sshd_config -template:jinja -user:root -group:root -mode:'0600' ssh-banner: file.managed: -name:/etc/ssh/banner -source:salt://ssh/files/banner -mode:'0644' sshd-service: service.running: -name:sshd -enable:True -watch: -file:sshd_config # 防止把自己锁在外面:检查加固完成后是否还允许 root + 密码登录 ssh-hardening-guard: cmd.run: -name:echo"sshd_hardened_ok" -unless: -grep-E'^[#s]*PermitRootLogins+yes'/etc/ssh/sshd_config
/srv/salt/ssh/files/sshd_config 关键配置:
Port 2222 PermitRootLogin no PasswordAuthentication no ChallengeResponseAuthentication no UsePAM yes X11Forwarding no PrintMotd no AcceptEnv LANG LC_* Subsystem sftp /usr/lib/openssh/sftp-server Banner /etc/ssh/banner
6.3.3 灰度
SSH 加固有一个最大的坑:如果你把当前 SSH 连接的端口 22 关了、密码禁了, 会直接把自己锁在机器外面。 所以正确做法:
灰度批次机器(不要是当前会话所在机器)。
新配置里先保留 Port 22、保留 PasswordAuthentication yes, 把 PermitRootLogin no 加上试一次。
验证无误后,分两步发:
第一步:关密码、保留端口 22、保留 2222。
第二步:把 22 改 2222。
中间任何一步都保留应急的 console / 带外管理通道。
我们生产里禁止直接全量执行 SSH 加固 SLS,必须走 canary 批次。
6.3.4 回滚
# 在被改的机器上,把 sshd_config 还原 cp /etc/ssh/sshd_config.rpmsave /etc/ssh/sshd_config systemctl reload sshd
Salt 在改配置前会自动备份原文件到
6.3.5 风险提醒
任何时候都不要在 SLS 里写 PermitRootLogin yes + PasswordAuthentication yes。
多端口(Port 22 + Port 2222)下,新配置要把老端口显式删掉,否则 salt-minion 重启 sshd 不会失败,但实际监听会多。
用堡垒机的环境,加固时把堡垒机 IP 加入 AllowUsers,否则堡垒机自己也连不上。
6.4 防火墙规则批量下发
6.4.1 背景
公司 8 个机房,每个机房有自己的网络策略。 手动写 iptables 容易写错规则导致 SSH 不通,严重的要进机房连显示器。
6.4.2 Pillar
# /srv/pillar/firewall/init.sls firewall: default_policy:drop allow_ssh_from: -10.20.0.0/16 -10.30.0.0/16 allow_db_from: -10.20.10.0/24 custom_rules: -comment:"Allow DNS" dport:53 proto:udp -comment:"Allow NTP" dport:123 proto:udp
6.4.3 State(iptables)
# /srv/salt/firewall/iptables.sls
{%ifgrains['os_family']=='RedHat'%}
iptables_installed:
pkg.installed:
-name:iptables-services
service.dead:
-name:firewalld
-enable:False
service.running:
-name:iptables
-enable:True
/etc/sysconfig/iptables:
file.managed:
-source:salt://firewall/files/iptables.rules
-template:jinja
-user:root
-group:root
-mode:'0600'
-watch_in:
-service:iptables
{%elifgrains['os_family']=='Debian'%}
iptables_installed:
pkg.installed:
-name:iptables-persistent
service.running:
-name:netfilter-persistent
-enable:True
/etc/iptables/rules.v4:
file.managed:
-source:salt://firewall/files/iptables.rules
-template:jinja
-mode:'0640'
-watch_in:
-service:netfilter-persistent
{%endif%}
/srv/salt/firewall/files/iptables.rules 模板片段:
*filter
:INPUT {{ pillar['firewall']['default_policy'] | upper }} [0:0]
:FORWARD {{ pillar['firewall']['default_policy'] | upper }} [0:0]
:OUTPUT ACCEPT [0:0]
-A INPUT -i lo -j ACCEPT
-A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
-A INPUT -p icmp -j ACCEPT
{% for src in pillar.get('firewall:allow_ssh_from', []) %}
-A INPUT -s {{ src }} -p tcp --dport 22 -j ACCEPT
{% endfor %}
{% for rule in pillar.get('firewall:custom_rules', []) %}
-A INPUT -p {{ rule.proto }} --dport {{ rule.dport }} -j ACCEPT -m comment --comment "{{ rule.comment }}"
{% endfor %}
COMMIT
6.4.4 灰度
防火墙改完绝对不能覆盖。我们生产做法:
第一次下发:往 chain 末尾追加新规则,保留原规则。
test=True 验证配置文件合法。
真实应用,验证从堡垒机能连 SSH、ping 通。
第二天再看没报警,再让 playbook 跑全量。
6.4.5 回滚
# /etc/sysconfig/iptables.rpmsave 是改之前备份的 iptables-restore < /etc/sysconfig/iptables.rpmsave systemctl restart iptables
或者手动用 iptables -F 临时清空(生产环境慎用)。
6.4.6 风险提醒
改防火墙前必须确认从堡垒机或带外管理能连到机器。 一次我们改了 SSH 端口 + 防火墙,碰巧那天堡垒机 IP 段调整,运维全部被锁在外面。
默认 policy 一定是 ACCEPT 或 DROP,不要 DROP 后没加 ESTABLISHED,RELATED, 否则 sshd 已经建立的连接也会被丢。
用 firewalld 还是 iptables 取决于 OS,不要在 Rocky 8 上写 iptables-only SLS, firewalld 没 disable 的话会冲突。
6.5 MySQL 主从配置管理
6.5.1 背景
我们有 50 多套 MySQL 主从,全部 5.7 / 8.0 混跑。 手写 my.cnf 容易写错 server_id 重号。 用 SLS 把 my.cnf 模板化,server_id 从 minion_id 派生。
6.5.2 Pillar
# /srv/pillar/mysql/init.sls
mysql:
version:8.0.36
port:3306
data_dir:/data/mysql
log_dir:/var/log/mysql
binlog_format:ROW
innodb_buffer_pool_size:"{{ (grains['mem_total'] * 0.6) | int }}M"
max_connections:1000
replication_user:repl
# /srv/pillar/mysql/master.sls
mysql:
role:master
server_id:"10{{ grains['id'][-3:]|int }}"
read_only:0
log_bin:mysql-bin
log_slave_updates:1
gtid_mode:1
enforce_gtid_consistency:1
# /srv/pillar/mysql/slave.sls
mysql:
role:slave
server_id:"10{{ grains['id'][-3:]|int }}"
read_only:1
log_bin:mysql-bin
log_slave_updates:0
gtid_mode:1
enforce_gtid_consistency:1
6.5.3 State
# /srv/salt/mysql/init.sls
mysql-pkg:
pkg.installed:
-name:mysql-community-server
-version:{{pillar['mysql']['version']}}
mysql-conf-dir:
file.directory:
-name:/etc/my.cnf.d
-makedirs:True
mysql-config:
file.managed:
-name:/etc/my.cnf
-source:salt://mysql/files/my.cnf
-template:jinja
-user:root
-group:root
-mode:'0644'
-require:
-pkg:mysql-pkg
-watch_in:
-service:mysql
mysql-data-dir:
file.directory:
-name:{{pillar['mysql']['data_dir']}}
-user:mysql
-group:mysql
-mode:'0750'
-makedirs:True
mysql-service:
service.running:
-name:mysqld
-enable:True
-watch:
-file:mysql-config
/srv/salt/mysql/files/my.cnf:
[mysqld]
user = mysql
port = {{ pillar['mysql']['port'] }}
datadir = {{ pillar['mysql']['data_dir'] }}
socket = /var/lib/mysql/mysql.sock
log-error = {{ pillar['mysql']['log_dir'] }}/mysqld.log
pid-file = /var/run/mysqld/mysqld.pid
server_id = {{ pillar['mysql']['server_id'] }}
log_bin = {{ pillar['mysql']['log_bin'] }}
binlog_format = {{ pillar['mysql']['binlog_format'] }}
log_slave_updates = {{ pillar['mysql']['log_slave_updates'] }}
read_only = {{ pillar['mysql']['read_only'] }}
gtid_mode = {{ pillar['mysql']['gtid_mode'] }}
enforce_gtid_consistency = {{ pillar['mysql']['enforce_gtid_consistency'] }}
innodb_buffer_pool_size = {{ pillar['mysql']['innodb_buffer_pool_size'] }}
max_connections = {{ pillar['mysql']['max_connections'] }}
6.5.4 灰度
# 配置更新是平滑的(reload),但改 server_id 需要重启 salt -G 'role:mysql' state.sls mysql test=True salt -G 'env:canary and role:mysql' state.sls mysql salt -G 'env:prod and role:mysql' state.sls mysql --batch-size=20%
注意:server_id 改动必须重启 MySQL。 线上不能全量同时重启,要用 orchestrate 编排滚动重启。 这部分逻辑一般写在 orchestrate/mysql_rolling_restart.sls 里。
6.5.5 回滚
# 还原 my.cnf cp /etc/my.cnf.rpmsave /etc/my.cnf systemctl reload mysqld # 如果 server_id 变了导致主从关系混乱,需要重新 change master
6.5.6 风险提醒
改 read_only 在 slave 上误改成 0,会导致从库接收写请求, 复制中断。需要用 unless 守卫。
改 innodb_buffer_pool_size 不重启不会生效。
改 binlog_format 在线不生效,要等新连接才生效。
永远不要在 SLS 里写 MySQL 不支持的参数,比如 CREATE INDEX ... INCLUDE (...)、 8.0 不支持 query_cache_type(5.7 有,8.0 已删除)。
6.6 批量用户与 sudo 管理
6.6.1 背景
运维、研发、dba 各自有自己的系统账号,权限和 sudo 配置要统一管控。
6.6.2 State
# /srv/salt/users/init.sls
{%foruserinpillar.get('users',[])%}
{{user.name}}:
user.present:
-name:{{user.name}}
-uid:{{user.uid}}
-gid:2000
-home:/home/{{user.name}}
-shell:/bin/bash
-groups:
-{{user.name}}
{%ifuser.sudo|default(False)%}
-wheel
{%endif%}
-require:
-group:{{user.name}}
group-{{user.name}}:
group.present:
-name:{{user.name}}
-gid:2000
{%ifuser.sudo|default(False)%}
/etc/sudoers.d/{{user.name}}:
file.managed:
-name:/etc/sudoers.d/{{user.name}}
-source:salt://users/files/sudoers.tmpl
-template:jinja
-user:root
-group:root
-mode:'0440'
-defaults:
user:{{user}}
{%endif%}
{%endfor%}
6.6.3 灰度
新增用户不涉及存量用户,可以一次性下发。 删除用户必须先确认 user.present 转 user.absent,并保留一段时间的"禁用"状态。
6.6.4 回滚
# 删除用户 salt '*' user.absent name=opsuser1 remove=True force=True # 还原 sudoers salt '*' file.managed /etc/sudoers.d/opsuser1 source=salt://users/files/opsuser1.sudoers
6.6.5 风险提醒
user.absent 加 remove=True 会删 home 目录,生产里不要用。 改成 user.absent name=opsuser1,保留 home 目录作为历史。
改 /etc/sudoers 写错语法会导致所有用户无法 sudo。 Salt 提供了 visudo 验证钩子,写在 file.managed 之前:
/etc/sudoers.d/opsuser1: file.managed: - ... - check_cmd: /usr/sbin/visudo -c -f
永远不要把用户密码写在 pillar 里。用 user.present 配合hash_password: True + password: '...' 但 hash 由 ssh key 体系替代。
6.7 定时任务分发
6.7.1 背景
每个业务都有自己的清理脚本、监控脚本,定期跑。 这些 cron 任务集中管理,不再允许开发直接登机器写 crontab。
6.7.2 State
# /srv/salt/crons/init.sls
{%forjobinpillar.get('crons',[])%}
cron-{{job.name}}:
cron.present:
-name:{{job.name}}
-user:{{job.user|default('root')}}
-minute:{{job.minute|default('*')}}
-hour:{{job.hour|default('*')}}
-daymonth:{{job.daymonth|default('*')}}
-dayweek:{{job.dayweek|default('*')}}
-month:{{job.month|default('*')}}
-command:{{job.command}}
{%endfor%}
pillar 示例:
crons: -name:clean-old-logs user:root minute:0 hour:3 command:/usr/local/bin/clean_logs.sh>/var/log/clean.log2>&1 -name:report-disk user:deployer minute:'*/30' command:/home/deployer/bin/disk_report.sh
6.7.3 灰度
cron 任务不会立即执行(等下一个时间点),但配置错会持续失败。 我们要求 test=True 看 diff,全量发。
6.7.4 回滚
salt '*' cron.absent name=clean-old-logs user=root
6.7.5 风险提醒
cron 任务如果执行时间长且频率高,可能出现"上次还没跑完下次又启动"的并发问题。 脚本里加 flock:
/usr/local/bin/clean_logs.sh: * * * * * /usr/bin/flock -n /tmp/clean.lock /usr/local/bin/clean_logs.sh
cron.present 默认会用 MAILTO=root,要发不了邮件会撑爆 spool。 配 email: false 关闭。
6.8 日志切割与归档
6.8.1 背景
业务日志散在 /var/log/,磁盘总被撑爆,logrotate 配置文件散落各处。 统一用 SLS 收敛。
6.8.2 State
# /srv/salt/logrotate/init.sls
logrotate:
pkg.installed:
-name:logrotate
{%forappinpillar.get('logrotate_apps',[])%}
/etc/logrotate.d/{{app.name}}:
file.managed:
-name:/etc/logrotate.d/{{app.name}}
-source:salt://logrotate/files/app.tmpl
-template:jinja
-user:root
-group:root
-mode:'0644'
-defaults:
app:{{app}}
{%endfor%}
pillar 示例:
logrotate_apps: -name:nginx paths: -/var/log/nginx/*.log rotate:14 size:100M compress:True create:'0640 nginx nginx' postrotate:'nginx -s reload > /dev/null 2>&1 || true' -name:my-app paths: -/var/log/my-app/*.log rotate:7 daily:True missingok:True sharedscripts:True postrotate:'systemctl reload my-app'
6.8.3 灰度
test=True 验证模板渲染没问题即可全量发。 logrotate 自己的执行是 logrotate -d 干跑,不会真的转。
6.8.4 回滚
file.absent 删除不需要的配置,重新跑 SLS 恢复。
6.8.5 风险提醒
nginx 的 postrotate 必须用 nginx -s reload,不要 kill -HUP(PID 文件可能错)。
多个 file 共享 postrotate 要 sharedscripts: True,否则每个 file 都跑一次 reload。
size 和 daily 同时存在时,logrotate 会按"哪个先到"判断。
6.9 监控客户端批量接入
6.9.1 背景
Prometheus 体系下,node_exporter 装在每台机器上,端口、用户、版本必须统一。
6.9.2 Pillar
# /srv/pillar/node_exporter/init.sls node_exporter: version:1.7.0 port:9100 user:node_exporter textfile_dir:/var/lib/node_exporter enabled_collectors: -cpu -meminfo -diskstats -netdev -filesystem -loadavg -systemd -process -ntp bind:'0.0.0.0' allow_cidrs: -10.20.0.0/16
6.9.3 State
# /srv/salt/node_exporter/init.sls
node-exporter-user:
user.present:
-name:{{pillar['node_exporter']['user']}}
-shell:/sbin/nologin
-system:True
node-exporter-dirs:
file.directory:
-name:/opt/node_exporter
-user:{{pillar['node_exporter']['user']}}
-group:{{pillar['node_exporter']['user']}}
-mode:'0755'
file.directory:
-name:/var/lib/node_exporter
-user:{{pillar['node_exporter']['user']}}
-group:{{pillar['node_exporter']['user']}}
-mode:'0755'
-makedirs:True
node-exporter-bin:
file.managed:
-name:/opt/node_exporter/node_exporter
-source:https://github.com/prometheus/node_exporter/releases/download/v{{pillar['node_exporter']['version']}}/node_exporter-{{pillar['node_exporter']['version']}}.linux-amd64.tar.gz
-source_hash:https://github.com/prometheus/node_exporter/releases/download/v{{pillar['node_exporter']['version']}}/sha256sums.txt
-skip_verify:True
-mode:'0755'
-user:root
-group:root
# 注意:上面这种方式在生产里并不好——Salt 下载外网受网络限制
# 实际生产里我们会把 node_exporter 二进制放到内部 mirror(比如自建 minio)
# 然后用 salt://node_exporter/files/node_exporter 走 file.managed
node-exporter-systemd:
file.managed:
-name:/etc/systemd/system/node_exporter.service
-source:salt://node_exporter/files/node_exporter.service
-template:jinja
-user:root
-group:root
-mode:'0644'
-watch_in:
-service:node_exporter
node-exporter-firewall:
iptables.insert:
-position:1
-table:filter
-chain:INPUT
-jump:ACCEPT
-source:{{pillar['node_exporter']['allow_cidrs'][0]}}
-dport:{{pillar['node_exporter']['port']}}
-proto:tcp
-save:True
service.running:
-name:node_exporter
-enable:True
-watch:
-file:node-exporter-systemd
/srv/salt/node_exporter/files/node_exporter.service:
[Unit]
Description=Node Exporter
After=network.target
[Service]
User={{ pillar['node_exporter']['user'] }}
ExecStart=/opt/node_exporter/node_exporter
--web.listen-address={{ pillar['node_exporter']['bind'] }}:{{ pillar['node_exporter']['port'] }}
--collector.textfile.directory={{ pillar['node_exporter']['textfile_dir'] }}
--collectors.enabled={{ pillar['node_exporter']['enabled_collectors'] | join(',') }}
Restart=on-failure
RestartSec=5s
[Install]
WantedBy=multi-user.target
6.9.4 灰度
salt -G 'env:canary' state.sls node_exporter salt -G 'env:prod' state.sls node_exporter --batch-size=10%
6.9.5 回滚
salt -G 'role:web' state.single service.dead name=node_exporter salt -G 'role:web' state.single file.absent name=/etc/systemd/system/node_exporter.service
6.9.6 风险提醒
node_exporter 默认监听 9100,只对内网开放。 错误地监听 0.0.0.0 + 没防火墙,可能被外网收集所有 metrics。
启用的 collector 不要全开。systemd、process 之类的开销大, 按需开启。
version 不要用 latest,写死版本号,文件下载走内部 mirror。
6.10 salt-api 与 Web 平台对接
6.10.1 背景
SLS 文件用 Git 管理,CI 平台推完代码后要触发 Salt 执行; 同时运营 / DBA 同学偶尔需要重启某个服务,希望走 Web 平台而不是 SSH。
6.10.2 安装
yum install -y salt-api
6.10.3 配置
/etc/salt/master 加段:
external_auth: pam: opsuser1: -.* -'@runner' -'@wheel' -'@jobs' dbauser1: -'web*': -state.sls -state.highstate -cmd.run -service.* rest_cherrypy: port:8000 host:0.0.0.0 ssl_crt:/etc/pki/tls/certs/salt-api.crt ssl_key:/etc/pki/tls/private/salt-api.key disable_ssl:False
@jobs 允许列历史作业,@runner 允许 salt-run,@wheel 允许 master 端 key 管理。
6.10.4 启动
systemctl enable salt-api systemctl start salt-api
6.10.5 调用示例
登录拿 token:
curl -k https://salt-api.example.com:8000/login -H "Accept: application/json" -d username=opsuser1 -d password=opsuser1_pwd -d eauth=pam
返回:
{
"return": [
{
"token": "a1b2c3d4...",
"start": 1640995200.0,
"expire": 1641038400.0,
"user": "opsuser1",
"perms": [".*", "@runner", "@wheel", "@jobs"],
"eauth": "pam"
}
]
}
带 token 跑命令:
curl -k https://salt-api.example.com:8000
-H "Accept: application/json"
-H "X-Auth-Token: a1b2c3d4..."
-d client=local
-d tgt='role:nginx and env:prod'
-d fun=state.sls
-d arg='nginx'
-d kwarg='{"test": true}'
6.10.6 灰度
API 调用本身就是异步的。 CI 平台推 SLS 时,先发 canary 组,再发 prod 组,通过编排:
# /srv/salt/orchestrate/release_v1.sls canary: salt.state: -tgt:'G@env:canary and G@role:web' -sls: -web.deploy -failhard:True -batch:'10%' prod: salt.state: -tgt:'G@env:prod and G@role:web' -sls: -web.deploy -require: -salt:canary -failhard:True -batch:'20%'
6.10.7 风险提醒
external_auth 一定要按"用户 + 目标 + 模块"最小化授权。 一次我们给新来的实习生配 .* + *,导致能跑任意 SLS,把生产 nginx 配置全删了。
rest_cherrypy 一定要开 SSL,否则 token 会以明文走。
API 操作的 audit 要开 audit returner,所有调用的 fun、tgt、arg 入库。
密码不要写死在调用里,CI 用 vault 注入。
七、高可用与扩展
SaltStack 的 HA 是个大坑。社区对"多 master 怎么做"有过很多讨论,但生产里要看你 到底要解决什么问题。我们这里讲两种我们实际跑过的方案。
7.1 单 master 的单点风险
默认部署下,master 是一个进程:salt-master。 它的关键工作:
监听 4505 / 4506。
维护 Pillar / file_roots。
维护 minion 密钥列表。
维护 jobs cache。
维护 event bus。
任一项挂掉,集群就开始"看起来还活着但啥都干不了"。 常见的故障:
master OOM。 minion 数量超过 1 万后,几千并发作业时 ZeroMQ 消息全堆在内存。
master 磁盘满。 returner 写 MySQL 失败,event 继续堆在本地。
master 主机故障。 物理机宕机、虚拟化热迁移失败。
网络分区。 机房之间断网,syndic 链路失效。
7.2 多 master failover 方案
我们生产用的是双 master 方案,minion 端配置:
# /etc/salt/minion master: -10.20.0.10 -10.20.0.11 master_type:failover master_shuffle:True master_alive_interval:30 master_tries:3
master 写成列表,minion 按顺序尝试。
master_type: failover 表示主备,主挂了就切备。
master_shuffle: True 启动时随机选一个 master,可以分散 master 压力。
master_alive_interval: 30 每 30 秒检测一次主 master 是否活着。
master_tries: 3 失败重试次数。
关键:两个 master 的 file_roots / pillar_roots / ext_pillar 必须保持一致。 我们用 Git 仓库 + rsync:
# /usr/local/bin/salt-sync.sh #!/bin/bash # master-A 上 rsync -az --delete /srv/salt/ master-B:/srv/salt/ rsync -az --delete /srv/pillar/ master-B:/srv/pillar/ rsync -az /etc/salt/master master-B:/etc/salt/master ssh master-B 'systemctl reload salt-master'
每 5 分钟同步一次。生产里可以走 keepalived + DRBD,但更复杂的方案稳定性也未必好。
7.3 master 端调优
master 端几个关键参数。生产里 5000 minion 规模我们调过一轮:
# /etc/salt/master worker_threads:20 pub_hwm:1000 zmq_backlog:10000 tcp_keepalive:True tcp_keepalive_idle:300 tcp_keepalive_cnt:3 tcp_keepalive_intvl:60 event_match_type:startswith
worker_threads:处理作业的工作线程。默认 5,几千并发要调到 20–30。 上限取决于 CPU。
pub_hwm:ZeroMQ 发送队列高水位线。超过会丢弃。 调大让突发流量有缓冲。
zmq_backlog:监听 socket 的 backlog,调大避免握手 RST。
tcp_keepalive:开 TCP keepalive,minion 死链能被快速发现。
event_match_type:事件匹配模式。startswith 比 pcre 快很多。 reactor 里如果用 pcre,master 端 CPU 会涨。
7.4 jobs 持久化
默认 job cache 在 master 的 /var/cache/salt/master/jobs 目录的 local 库里。 master 重启后,老的 jid 还能查到,但新的作业可能因为 return 没到就丢。
我们用 MySQL returner 持久化:
master_job_cache: mysql
mysql.host: 'salt-meta.example.com'
mysql.user: 'salt'
mysql.pass: '{{ salt_pillar['mysql_pass'] }}'
mysql.db: 'salt'
jids 表存 jid + 完整请求载荷,salt_returns 表存每次执行结果。 作业完成后,事件流也写到 salt_events 表。
通过 salt-run jobs.list_jobs 查历史:
salt-run jobs.list_jobs salt-run jobs.list_jobs search_function='state.sls' salt-run jobs.list_jobs search_target='role:nginx'
风险提醒:MySQL returner 写放大明显。 5000 minion 一次并发作业,salt_returns 表可能一次写 5000 行。 要给表加合适的索引,定期归档(按月)。
7.5 状态爆炸
master 跑一年,/srv/salt 下的 SLS 越来越多,几十 MB 是常事。 编译一次 highstate 越来越慢,minion 端要 5–10 秒才返回。
解决办法:
按业务拆分 pillar。不要把所有数据写在一个 init.sls。
用 ext_pillar 从 CMDB 取数据。ext_pillar: [my_cmbd],从内部配置中心拉。
用 master_tops 替代 top.sls。基于业务标签动态生成匹配。
拆分 master。按业务线分多套 master,独立 SLS 仓库。
我们最终选的是 1 + 2:pillar 拆细,用 ext_pillar 从 CMDB 拉机器标签。 SLS 文件不再做"角色 × 环境"的全量组合,而是按角色写一套,环境变量从 pillar 注入。
7.6 大规模场景的拆分策略
5000 minion 以下,单 master 双机 failover 撑得住。 5000–20000 建议拆业务线:
master-A:基础平台(DB、MQ、缓存)。
master-B:业务应用(Web、API、Worker)。
master-C:大数据(Hadoop、Flink、ES)。
minion 端 master 字段配对应的 master IP,或者用 DNS round-robin。
20000+ 建议引入 syndic:
顶级 master ├── syndic-A(区域 1) │ ├── minion × 5000 └── syndic-B(区域 2) ├── minion × 5000
syndic 自己既是 master 又是 minion,会做协议转换。 调试难度大,我们在 8000 minion 时没用 syndic,靠多 master failover 顶住了。
八、与 CI/CD、配置中心、监控联动
SaltStack 单独用只能解决"配置管理",要真正"自动化运维",必须接 CI / 监控 / 审计。
8.1 SLS 文件进 Git
我们用 GitLab 管理所有 SLS、Pillar、master 配置:
salt-config/ master/ minion/ salt/ common/ nginx/ mysql/ redis/ k8s/ ... pillar/ base/ nginx/ mysql/ ... top.sls README.md
master 的 /etc/salt/master 不直接进仓库(包含敏感信息), 但所有 master 配置相关的"软链接"指向仓库:
ln -sf /opt/salt-config/master/master.conf /etc/salt/master ln -sf /opt/salt-config/master/file_roots /srv/salt ln -sf /opt/salt-config/master/pillar_roots /srv/pillar
这样 master 重启后所有配置从仓库恢复。
8.2 CI/CD 触发
GitLab CI 流水线:
# .gitlab-ci.yml
stages:
-test
-deploy
lint:
stage:test
image:salt-lint:latest
script:
-findsaltpillar-name'*.sls'-execsalt-lint{};
-yamllint-d'{extends: default, rules: {line-length: disable}}'salt/pillar/
dry-run:
stage:test
script:
-salt-call--localstate.slsnginxtest=True
only:
-merge_requests
deploy-canary:
stage:deploy
script:
-salt-api-callweb-clusterstate.slsnginx
environment:
name:canary
only:
-main
when:manual
deploy-canary 阶段是手动确认,运营同学在 GitLab UI 点头才执行。 执行走 salt-api 的 RESTful 接口,不直接 SSH 到 master。
8.3 接入 ELK
salt returner 接外部系统的标准做法在 Salt 3000+ 以后变窄了: 官方维护的常用 returner 是 mysql、postgres、local、redis、sentry、slack、mattermost,kafka returner 在 Salt 3000+ 不再随主仓库发布。 我们生产里的做法是:先用 mysql returner 落库, 再通过 Canal / Debezium 把 MySQL 的 salt_returns 表 binlog 订阅到 Kafka, 最终走 ELK 链路。
Kibana 上查"过去 24 小时所有失败的 state":
tag: "salt/job/*/ret/*" AND data.success: false
8.4 监控 master 自身
Prometheus 抓取 master 自身指标有几种方式:
salt-master 自带 salt-master 的内嵌 prometheus exporter(3004+ 部分支持)。
写自定义 salt-run 脚本,输出 Prometheus 文本格式,挂 sidecar。
直接用 node_exporter 抓 salt-master 进程的 CPU / 内存 / FD。
我们生产用的是方案 3 + 方案 2 结合:
#!/usr/bin/env python3
# /usr/local/bin/salt_exporter.py
from prometheus_client import start_http_server, Gauge
import subprocess, time, json
MINION_UP = Gauge('salt_minion_up', '1 if minion is up', ['minion_id'])
JOBS_ACTIVE = Gauge('salt_jobs_active', 'Active jobs count')
JOBS_FAILED = Gauge('salt_jobs_failed', 'Failed jobs in last 1h')
def collect():
out = subprocess.run(['salt-run', 'manage.status', '--out=json'],
capture_output=True, text=True)
status = json.loads(out.stdout)
up = status.get('return', {}).get('up', [])
down = status.get('return', {}).get('down', [])
for m in up:
MINION_UP.labels(minion_id=m).set(1)
for m in down:
MINION_UP.labels(minion_id=m).set(0)
if __name__ == '__main__':
start_http_server(9101)
whileTrue:
collect()
time.sleep(30)
Grafana 告警规则:
groups:
-name:salt
rules:
-alert:SaltMinionDown
expr:salt_minion_up==0
for:5m
labels:
severity:warning
annotations:
summary:"Minion {{ $labels.minion_id }} down"
-alert:SaltMinionDown
expr:salt_minion_up==0
for:30m
labels:
severity:critical
8.5 审计
所有执行走 salt-api 留痕,MySQL audit returner 记录:
# /etc/salt/master # audit 通过 audit returner 实现,下面是一种典型实现 # 推荐做法:自定义 returner 写到 ES / MySQL,把每次 fun、tgt、arg、metadata 入库
8.5.1 自定义 audit returner 思路
在 master 端的 /srv/salt/_returners/ 目录放一个自定义 returner。 Salt 会在 salt-master 启动时自动 sync 这些 returner。 最简示例,把每次调用写到 MySQL audit_log 表:
# /srv/salt/_returners/audit_mysql.py
import json
import time
import logging
import salt.returners
log = logging.getLogger(__name__)
def __virtual__():
return'audit_mysql'
def returner(ret):
'''
ret 是 dict,包含 fun、jid、id、return、success、fun_args 等字段
'''
try:
import MySQLdb
conn = MySQLdb.connect(
host='salt-meta.example.com',
user='salt',
passwd='{{ pillar['mysql_pass'] }}',
db='salt',
port=3306
)
cur = conn.cursor()
cur.execute("""INSERT INTO audit_log
(fun, jid, minion_id, success, fun_args, return_data, alter_time)
VALUES (%s, %s, %s, %s, %s, %s, NOW())""",
(
ret.get('fun'),
ret.get('jid'),
ret.get('id'),
ret.get('success'),
json.dumps(ret.get('fun_args', [])),
json.dumps(ret.get('return', ''))
))
conn.commit()
cur.close()
conn.close()
except Exception as e:
log.error('audit_mysql returner failed: %s', e)
注意几点:
__virtual__ 返回名字是 returner 的标识符,调用时用 --return audit_mysql。
不要把 MySQL 密码硬编码在 returner 里,从 __opts__['pillar'] 拿。
真正生产里这个 returner 还应该带 fun_args、user、external_auth 来源信息, 方便事后追溯。
更进一步可以加一个异步队列(Redis List),让 returner 立刻 enqueue 之后返回, 避免主作业流程被审计写阻塞。
CREATE TABLE audit_log ( idBIGINTNOTNULL AUTO_INCREMENT PRIMARY KEY, fun VARCHAR(64) NOTNULL, jid VARCHAR(20) NOTNULL, minion_id VARCHAR(255) NOTNULL, successVARCHAR(20) NOTNULL, fun_args MEDIUMTEXT, return_data LONGTEXT, alter_time TIMESTAMPNOTNULLDEFAULTCURRENT_TIMESTAMP, KEY fun (fun), KEY jid (jid), KEY minion_id (minion_id) ) ENGINE=InnoDB;
使用时:
salt -G 'role:nginx' cmd.run 'uptime' --return audit_mysql
salt-api 的请求会带上 metadata,returner 把 metadata 也写到 audit_log, 事后追溯非常方便。
8.5.2 二次审批
仅审计还不够,关键操作要二次审批。 CI 平台调 salt-api 跑生产 state 之前,要走工单系统确认。 审核人通过后 CI 才会带上"已审批"的 metadata 调 salt-api。 未带 metadata 或 metadata 不全的请求,salt-api 端拦下不执行。
curl -k https://salt-api:8000
-H "X-Auth-Token: $TOKEN"
-d client=local
-d tgt='role:nginx'
-d fun=state.sls
-d arg='nginx'
-d metadata='{"ticket": "INC-20260506-001", "operator": "opsuser1"}'
这些 metadata 会被 returner 一起落到审计库,事后追溯非常方便。
九、故障复盘案例
下面四个案例都是我们生产里真实遇到过的(去敏感化),按"现象→判断→检查→定位→修复→验证→复盘"展开。
9.1 案例一:minion 集体断连
9.1.1 现象
监控告警:salt_minion_up == 0 的机器数从 5 台突然涨到 80 台。 告警系统本身用 salt '*' test.ping 做心跳,发现大量 false。 Prometheus 平台 UI 上看 30 分钟内 minion 离线率从 0.5% 涨到 13%。
9.1.2 初步判断
不是单台机器问题,是 master 端的问题。 可能性:master 进程 OOM、被 K8s 调度、磁盘满、网络抖动。
9.1.3 命令检查
# master 端 ps aux | grep salt-master free -h df -h /var/log /var/cache # 看 master 日志 tail -f /var/log/salt/master # 看连接 ss -tan | grep -E '4505|4506' | wc -l # 看 minion 在线 salt-run manage.status salt-run manage.up salt-run manage.down
我们这次看到的:
salt-master 进程还在。
free -h 显示 available 内存 0。
/var/log/salt/master 最后几行是 Out of memory: Killed process 12345 (salt-master)。
salt-run manage.status 输出 minion 都 down。
9.1.4 关键指标
master 内存:32GB,常驻 28GB。
minion 数量:~4000。
作业并发:最近 1 小时有一个"全网 highstate"的 batch,触发了几千并发作业。
returner:MySQL 在另一个机房,跨机房延迟 30ms,每次写放大。
9.1.5 根因定位
worker_threads=5 配的太低,几千并发作业排队; returner 跨机房写 MySQL,每次作业要等 30ms 往返; master 端作业结果在内存里堆积,直到 OOM 被 kill。 minion 端的作业已经下发,但 return 回来时 master 进程没了, 所以 minion 端表现是"作业没回执"。
9.1.6 修复方案
短期:
临时把 master 切到备机 B(failover 自动完成)。
备机 B 的 worker_threads 调到 20。
临时关闭高并发批量任务。
长期:
worker_threads: 20。
returner 改成本机 SQLite,等异步转发到 MySQL(写个 reactor 把结果转发)。
pub_hwm: 1000、zmq_backlog: 10000。
加 master 自身监控:内存使用率 > 80% 告警。
9.1.7 验证方式
# 模拟小批量 salt -G 'env:canary' state.highstate # 看 master 内存 free -h # 看连接 ss -tan | grep -E '4505|4506' | wc -l
观测 30 分钟,内存稳定在 16GB 左右,连接数稳定。
9.1.8 复盘总结
单 master 撑大集群必须配 worker_threads 和 returner,不能让作业堆在内存。
master 自身监控不能只监控进程存活,要看内存、连接数、worker queue。
failhard 加上能让大批量任务在出错时快速失败而不是把资源耗光。
9.2 案例二:state 执行半成功
9.2.1 现象
salt '*' state.highstate
输出形如:
Summary for web-prod-01 ------------ Succeeded: 14 Failed: 2 ------------ Total states run: 16 Total run time: 23.421 s
每次都恰好 2 个 state 失败,同一台机器上固定是 pkg.installed 的 nginx 和 php-fpm。 Succeeded 数量在不同机器上有差异,但失败的 ID 一致。
9.2.2 初步判断
不是环境差异(不是 OS、不是机器型号),是 pkg.installed 找不到包。 可能:包被删了、repo 错了、pkg cache 过期。
9.2.3 命令检查
# 单台机器 debug salt 'web-prod-01' state.sls nginx -l debug --state-output=changes
输出关键行:
[ERROR ] Failed to install package nginx. Error: Error downloading packages: Cannot find a valid baseurl for repo: extra [DEBUG ] Repository 'extra' is missing name in configuration
/etc/yum.repos.d/extra.repo 不存在,但 state 里写了要装这个 repo, 显然是某次手改 yum.repos.d 后这个文件被删了。
9.2.4 关键指标
看 state 文件 nginx/init.sls:
nginx-repo: pkgrepo.managed: - name: extra - baseurl: file:///opt/repo/extra - enabled: True - gpgcheck: False
看 minion 端 /etc/yum.repos.d/:
ls -la /etc/yum.repos.d/
实际只有 CentOS-Base.repo 和 epel.repo,没有 extra.repo。 state 里写了 pkgrepo.managed 想创建 extra.repo,但 baseurl 是 file:///opt/repo/extra, 本地根本没有这个目录(/opt/repo/extra 不存在),于是 pkgrepo.managed 失败, nginx 包就装不了。
9.2.5 根因定位
之前有人改了 nginx/init.sls,加了 pkgrepo.managed 块。
file:///opt/repo/extra 这个本地仓库路径在某些机器上有、在其他机器上没有。
state 加了 unless 守卫,但守卫写错(用 test -f /etc/yum.repos.d/extra.repo, 但 pkgrepo 创建前文件本来就不在,守卫永远 true,导致 pkgrepo 实际不被管理)。
9.2.6 修复方案
改 state 文件,正确使用 unless / onlyif。
在 pkgrepo.managed 块加 require 提前建目录。
灰度:
# 先在 1 台 salt -L 'web-canary-01' state.sls nginx test=True # 再在 canary 组 salt -G 'env:canary' state.sls nginx # 全量 salt -G 'role:nginx' state.sls nginx --batch-size=20%
9.2.7 验证方式
salt -G 'role:nginx' state.sls nginx test=True # 看到所有 "Succeeded",没有 Failed
9.2.8 复盘总结
unless / onlyif 写错了不会报错,是 salt 执行成功的,但实际逻辑反了。
pkgrepo.managed 的 baseurl 必须是有效的,能用 curl/wget 验证。
SLS 改完先 lint(salt-lint),再 dry-run,再灰度,最后全量。
9.3 案例三:top.sls 拼写错误导致 prod 配置部分丢失
9.3.1 现象
某业务上新机器一批 50 台,Nginx 装上后能起,systemctl status nginx 显示 active (running)。 从浏览器访问业务域名,返回 502。 部分页面(首页、登录页)能访问,但业务页面统一 502。 已经跑过 state.highstate 一次,看起来是绿的。
9.3.2 初步判断
Nginx 进程在跑、配置语法过、监听正常,但 server_name 匹配不上或者 root 路径错。 看具体 nginx.conf 内容。
9.3.3 命令检查
salt 'web-prod-NN' cmd.run 'nginx -t'
# 输出:
# nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
# nginx: configuration file /etc/nginx/nginx.conf test is successful
# (但访问业务域名 502)
salt 'web-prod-NN' cmd.run 'cat /etc/nginx/conf.d/example.com.conf'
# 输出:server { listen 80; server_name example.com; root ???; }
# root 字段渲染成空字符串
9.3.4 关键指标
模板文件用 jinja 渲染,jinja 拿 pillar nginx:port 替换 listen 端口。
看 pillar:
salt 'web-prod-NN' pillar.items nginx # 输出(实际生产场景下): # ---------- # nginx: # ---------- # port: # 80 # workers: # auto # vhosts: # None
nginx:port 和 nginx:workers 拿到了,vhosts 却是 None。 说明 base 命中的 nginx/init.sls 渲染成功,但 prod 环境预期合并的 vhosts 字段没进来。
9.3.5 根因定位
看 /srv/pillar/top.sls:
base: '*': -base 'role:nginx': -match:grain -nginx 'role:nginx and env:prod': -match:grain -nginx -nginx-prod
真正的根因:top.sls 把 prod 环境的 SLS 引用写成 nginx-prod(带连字符), 但仓库里实际文件叫 nginx.prod.sls(带点号)。 Salt 在解析 top.sls 时,找不到 nginx-prod 这个 SLS,只 log warning 不报错, prod 环境原本应该合并的 vhosts 字段就丢了。 结果是 nginx.init.sls 里 base 的 port/workers 仍然生效,但 vhosts 是 None。
top.sls 拼写错误是 Salt pillar 调试里最痛的问题: 解析不报错,运行时只丢一部分配置。
/srv/pillar/nginx/init.sls:
nginx: port:80 workers:auto vhosts: -name:example.com root:/var/www/example.com php:True -name:static.example.com root:/var/www/static php:False
/srv/pillar/nginx.prod.sls:
nginx: vhosts: -name:example.com root:/var/www/example.com-prod php:True -name:static.example.com root:/var/www/static-prod php:False
prod 文件意图是覆盖 vhosts 字段(指到 prod 路径)。 但 prod 文件没有重写 port/workers,所以 Salt 的 smart merge 仍然拿到 base 的值。 这个合并行为本身是对的。
问题在于 top.sls 的 - nginx-prod 拼写错误,导致 prod 整份 SLS 没被加载。
9.3.6 修复方案
改 top.sls,把 nginx-prod 改回 nginx.prod。
用 pillar.show_top 验证匹配的 SLS 列表:
salt 'web-prod-NN' pillar.show_top # 输出应包含 nginx.prod
改完后用 pillar.items 完整对账:
salt 'web-prod-NN' pillar.get nginx:vhosts # 应该返回 prod 的 vhosts 列表
模板用 pillar.get 给默认值,避免 None 被 jinja 渲染成空串:
root {{ vhost.root|default('/var/www/' ~ vhost.name) }};
灰度重启 nginx:
salt -G 'env:canary' cmd.run 'systemctl reload nginx'
上线后加一个 CI 校验:解析 top.sls 后对每台 minion dump 应命中的 SLS 列表, prod 环境的 web 机器 pillar 里 nginx.vhosts 必须非空。
9.3.7 验证方式
# 1. 验证 top.sls 命中 salt 'web-prod-NN' pillar.show_top # 输出应该包含 nginx.prod # 2. 验证 pillar 数据 salt 'web-prod-NN' pillar.get nginx:port salt 'web-prod-NN' pillar.get nginx:workers salt 'web-prod-NN' pillar.get nginx:vhosts # 全部非空 # 3. 验证渲染后配置 salt 'web-prod-NN' cmd.run 'cat /etc/nginx/conf.d/example.com.conf' # root 字段有值,且是 prod 路径 # 4. 业务验证 curl -H "Host: example.com" http://web-prod-NN/ # 应该返回业务页面,不再是 502
9.3.8 复盘总结
top.sls 的 SLS 引用写错是 Salt pillar 调试最痛的坑。 解析不报错,只 log warning,运行时部分配置悄悄丢失。
pillar.items 输出"key 在但 value 是 None"是 Salt 的一个特点, 用 pillar.get 看具体值更准;pillar.show_top 看实际命中的 SLS。
state 模板里要 pillar.get('key', default),不要直接 pillar['key'], 避免 KeyError 之外还有"空字符串被填进去"的问题。
top.sls / pillar 改动必须走 PR review,不能直接 master 改。
加 CI 校验:所有 prod 环境的 pillar dump 后做 schema 校验,缺字段直接拦下。
pillar 数据"被合并"的逻辑不直观,最好用 pillar.show_top 验证。
9.4 案例四:salt-api 接口被误用为远程命令执行
9.4.1 现象
某天审计发现:salt-api 的 audit log 里有大量 cmd.run 调用, 执行用户是 intern1(实习生),执行机器是 prod-db-*。
数据库上有些 SHOW MASTER STATUS 之类的查询被跑过, 幸运的是没有改数据,但这是重大安全隐患。
9.4.2 初步判断
实习生账号权限过大。salt-api 的 external_auth 配置有问题。
9.4.3 命令检查
# 看 access log grep 'intern1' /var/log/salt/api.log | tail -50 # 输出形如: # [2024-09-12 1045] intern1 GET / - tgt='prod-db-*' fun=cmd.run arg=['ls /data'] # [2024-09-12 1001] intern1 GET / - tgt='prod-db-*' fun=cmd.run arg=['mysql -e "show processlist"'] # 看 external_auth 配置 cat /etc/salt/master | grep -A 30 'external_auth'
external_auth 配置(问题版本):
external_auth: pam: intern1: - .* - '@runner' - '@wheel' - '@jobs'
.* 匹配所有模块,@runner 允许跑 salt-run(可以删 minion key),@wheel 允许管理 master 配置。
9.4.4 关键指标
实习生账号创建是为了"查询监控数据"。
创建时运维同学图省事,给了 .*。
没有 target 限制,意味着 intern1 可以对所有 minion 跑任意命令。
9.4.5 根因定位
external_auth 写错了,不是"按用户最小化授权",而是"一把钥匙开所有门"。
实习生拿到 token 后能做的事超过范围。
审计发现得早,没造成实际数据损失。
9.4.6 修复方案
收回 intern1 的 token,重新签发。
改 external_auth:
external_auth: pam: intern1: -'role:web and env:canary': -cmd.run -test.ping -test.version -grains.items dbauser1: -'role:mysql': -state.sls -state.highstate -cmd.run -service.* opsuser1: -'*': -.* -'@runner' -'@wheel' -'@jobs'
强制 salt-api 走 SSL:
rest_cherrypy: port: 8000 disable_ssl: False ssl_crt: /etc/pki/tls/certs/salt-api.crt ssl_key: /etc/pki/tls/private/salt-api.key
启用 audit returner,把所有调用入库:
# /etc/salt/master audit_log_file: /var/log/salt/audit.log
加审计告警:调用 cmd.run、@wheel 立即通知 ops 群。
9.4.7 验证方式
# 实习生用 intern1 token 跑生产 DB 机器 curl -k https://salt-api:8000 -H "X-Auth-Token: $INTERN_TOKEN" -d client=local -d tgt='role:mysql and env:prod' -d fun=cmd.run -d arg='whoami' # 应该返回 401
9.4.8 复盘总结
salt-api 的 external_auth 是生产里最敏感的配置。 一定按"用户 × target × module"三维最小化授权。
实习生、临时外包账号默认 test.ping + test.version + grains.items。
强审计:所有 cmd.run、@wheel 操作的 metadata 一定要有 operator、reason。
关闭非 SSL 访问,强制走 HTTPS。
十、风险提醒与回滚清单
下面这张表是我们把 SaltStack 用在生产三年沉淀的"事故预防清单"。
10.1 高危命令清单
| 命令 | 风险 | 防控 |
|---|---|---|
| salt-key -D | 删所有 minion 接受记录 | 加 audit,禁止无审计执行 |
|
salt-key -d |
删单个 minion | 强提示 + 二次确认 |
| salt '*' cmd.run 'rm -rf ...' | 误删文件 | 必须 pillar 化、dry-run 验证 |
| salt '*' file.remove /etc/... | 改 / 删关键文件 | 走 state + unless 守卫 |
| salt '*' iptables.flush | 清空防火墙 | 配 iptables.services + dry-run |
| salt '*' system.reboot | 强制重启 | 加 test=True、at 调度 |
| salt '*' mysql.query 'DROP DATABASE ...' | 删库 | 完全禁止;备份恢复走专用工具 |
| salt '*' pkg.remove 'kernel*' | 卸载 kernel | 加 -match 限定包名 |
10.2 灰度标准步骤
我们生产里所有 SLS 变更走统一灰度:
test=True 看 diff。
canary 1–2 台机器,5 分钟观察。
canary 完整组(约 5%),5 分钟观察。
10% 抽样(--batch-size=10%)。
30% 抽样。
50% 抽样。
100%。
每一步间隔 10 分钟,监控告警和业务指标正常才能继续。 出问题立刻停止,回到上一步。
10.3 回滚三板斧
备份原文件:Salt 自动备份
保留旧 SLS:Git 仓库保留,必要时 git revert。
编排 onfail:
deploy_app: salt.state: -tgt:'role:web' -sls: -web.deploy -onfail: -salt:deploy_db rollback_app: salt.state: -tgt:'role:web' -sls: -web.rollback -require: -salt:deploy_app
10.4 变更窗口
工作日上午 10:00–12:00、下午 14:00–17:00。
业务高峰避开:电商类业务避开晚 8 点到 10 点。
大版本 Salt 升级、master HA 切换放在 0:00–5:00。
10.5 操作审计
所有通过 salt-api 的操作必须带 metadata:
curl -k https://salt-api:8000
-H "X-Auth-Token: $TOKEN"
-d client=local
-d tgt='role:nginx'
-d fun=state.sls
-d arg='nginx'
-d metadata='{"ticket": "INC-20260506-001", "operator": "opsuser1", "reason": "fix vhost port"}'
数据库 audit log 至少保留 90 天,半年导出冷归档。
10.6 灾备
master 配置、pillar、state 全部进 Git。
每台 master 上跑 salt-master 之前先 git pull 拉最新配置。
灾备演练:把 master A 关掉,确认 master B 接住,minion 端无感切换。
十一、总结
11.1 一句话
SaltStack 不是银弹,但在大规模同质机 + 配置中心化场景下, 仍然是"最稳的方案之一",比 Puppet 易学,比 Ansible 撑得住规模,比 Chef 接地气。 用好 SaltStack 的关键就三件事:
把 SLS 写得像代码——版本控制、PR review、CI 校验。
把 pillar 写得像数据——拆细、加密、按业务隔离。
把执行写得像发布——灰度、灰度、灰度。
11.2 演进方向
我们接下来想做的事:
Salt 3006+ 的 RAET 替代 ZeroMQ。 性能更好,但生产案例少,先观察。
salt-master 跑在 K8s 上。 master 是有状态服务,StatefulSet + PVC 跑起来,灾备变简单。
自研 Salt 调度平台。 现在的编排走 orchestrate + salt-api, 想做一个前端,把"提交 SLS → canary → 灰度 → 监控 → 回滚"全流程包起来。
接 GitOps。 SLS 仓库 push 触发 reconcile,ArgoCD / Flux 风格的声明式执行。
11.3 写给读者
如果你是第一次接触 SaltStack,建议按这个顺序读:
salt '*' test.ping 跑通,理解 minion-master 通信。
写一个最简单的 SLS:pkg.installed nginx。
加 file.managed 把 nginx.conf 模板化。
加 pillar,理解数据怎么从 master 推到 minion。
加 Grains,理解怎么按机器特征分类。
写 orchestrate,理解跨机器编排。
接 salt-api,理解怎么被外部系统调用。
接 MySQL returner,理解作业结果怎么持久化。
接 reactor,理解事件怎么触发自动化。
最后才是 HA、监控、灾备。
SaltStack 的概念多,但都是工程上绕不开的。 理解透了之后你再回头看 Ansible / Puppet / Chef,会发现他们做的事情本质上一样, 只是工程上的取舍不同。
祝你们的集群稳定,少 OOM,多下线。
全部0条评论
快来发表一下你的评论吧 !