文章总结: 本文基于2017-2024年实战经验,详细介绍了SaltStack在企业级批量管控中的配置部署实践。核心内容包括SaltStack的适用场景(大规模同质机配置、配置漂移治理、批量运维)、架构组件(master-minion模型、ZeroMQ传输)、性能优化建议(调整worker_threads、配置returner)、以及高可用拓扑设计。文档提供了具体的安装配置指南和故障排查经验,特别强调了与Ansible等工具的对比差异和实际生产环境中的踩坑总结。 综合评分: 85 文章分类: 安全工具,技术标准,解决方案,安全运营,安全开发
企业级批量管控:SaltStack 配置部署落地与踩坑总结
点击关注 👉 点击关注 👉
马哥Linux运维
2026年6月8日 10:00 河南
在小说阅读器读本章
去阅读
一、背景与定位
我们最早接触 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_id>,状态是 unaccepted。 运维在 master 上执行 salt-key -A 接受(或者 -a <minion_id> 接受单个), master 重启自己的 minion 缓存,minion 下一次心跳会拉到 master 的公钥, 双方建立信任。
接受之后,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 <id>:接受指定 id。-D:删除所有 accepted(生产里不要用)。-d <id>:删除指定 id。-R:拒绝所有 unaccepted(标记为 Rejected)。-r <id>:拒绝指定 id。-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 [email protected] /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 'nginx:port:80' 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:!aNULL:!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]*PermitRootLogin\s+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 在改配置前会自动备份原文件到 <file>.rpmsave。 如果是新机器,没有原文件,则必须保留一份手动的应急 SSH 配置。
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 10:23:45] intern1 GET / - tgt='prod-db-*' fun=cmd.run arg=['ls /data']
# [2024-09-12 10:24:01] 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 <id> | 删单个 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 自动备份
<file>.rpmsave,确认存在。 - 保留旧 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,多下线。
文末福利
今天给大家分享一份超级牛掰的Linux学习笔记,足足有1456页!是一位Linux运维大佬整理分享的,分享是获得大佬同意的,大家有需要的尽管收藏起来!
笔记介绍
这份笔记非常全面且详细,从Linux基础到shell脚本,再到防火墙、数据库、日志服务管理、Nginx、高可用集群、Redis、虚拟化、Docker等等,与其说Linux学习笔记,不如说是涵盖了运维各个核心知识。
并且图文并茂,代码清晰,每一章下面都有更具体详细的内容,十分适合Linux运维学习参考!
笔记展示
笔记下载
扫描下方二维码,回复暗号“1456页Linux笔记“,即可100%免费领取成功
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:马哥Linux运维 点击关注 👉 点击关注 👉《企业级批量管控:SaltStack 配置部署落地与踩坑总结》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。



![[ACTF2020新生赛]Include](/images/random/titlepic/4.jpg)
![[ACTF2020新生赛]Include](/images/random/titlepic/8.jpg)
![[ACTF2020新生赛]Include](/images/random/titlepic/15.jpg)



评论