SaltStack 配置管理
本文合并自:03-SaltStack配置管理.md、3-5-SaltStack自动化运维.md
官方中文文档:http://docs.saltstack.cn/
一、配置管理的价值与基本思想
1.1 为什么需要配置管理工具
当服务器从几台增长到几十上百台时,纯手工登录逐台操作会带来三个不可逆的问题:
- 效率低:同一件事(装包、改配置、重启服务)要重复 N 次;
- 不一致(配置漂移):人手操作难免遗漏,机器之间配置逐渐产生差异,故障排查成本剧增;
- 不可追溯:没有人知道某台机器的某个参数是谁在什么时候改的、为什么改。
配置管理工具(SaltStack / Ansible / Puppet / Chef)解决的核心问题是:把"服务器应该长什么样"变成一份可版本化、可复用、可重复执行的代码。这就是"基础设施即代码"(Infrastructure as Code)。
1.2 幂等性(Idempotence)
幂等性是配置管理工具最重要的性质:同一份配置执行一次和执行 N 次,结果完全相同。
- 第一次执行:机器未达到目标状态 → 工具执行变更 → 达到目标状态;
- 第二次执行:机器已经处于目标状态 → 工具什么都不做,只报告"已是期望状态"。
对比理解:
# 非幂等:每执行一次就多追加一行
echo "hello" >> /tmp/a.txt
# 幂等:无论执行多少次,文件里始终只有一行 hello
# SaltStack 的写法(状态模块)
/tmp/a.txt:
file.managed:
- contents: hello
注意:
cmd.run这类"直接执行命令"的模块天生不幂等,必须靠onlyif/unless/creates加幂等守卫,后文会详述。
1.3 声明式 vs 命令式
| 维度 | 命令式(Imperative) | 声明式(Declarative) |
|---|---|---|
| 描述内容 | 怎么做(步骤序列) | 要什么(目标状态) |
| 典型代表 | Shell 脚本、salt '*' cmd.run |
SLS 状态文件、pkg.installed |
| 幂等性 | 需要自己写判断逻辑 | 工具内置保证 |
| 可读性 | 步骤越多越难读 | 一眼看出机器最终形态 |
| 出错影响 | 中途失败后状态不确定 | 可反复执行直到收敛 |
同一件事的两种写法:
# 命令式:我告诉机器每一步怎么做
salt '*' cmd.run 'rpm -q httpd || yum install -y httpd'
salt '*' cmd.run 'systemctl start httpd'
salt '*' cmd.run 'systemctl enable httpd'
# 声明式:我只描述机器应该处于的状态
httpd:
pkg.installed: []
service.running:
- enable: True
- require:
- pkg: httpd
*结论:SaltStack 的远程执行(`salt '' module.function`)是命令式的,状态管理(SLS)是声明式的。生产中「临时查询与应急操作」用远程执行,「长期要保持的配置」一律写成 SLS。**
二、主流配置管理工具对比
| 对比项 | SaltStack | Ansible | Puppet | Chef |
|---|---|---|---|---|
| 开发语言 | Python | Python | Ruby | Ruby |
| 是否需 agent | 需要(salt-minion);也支持 salt-ssh 无 agent | 不需要(走 SSH) | 需要(puppet agent) | 需要(chef-client) |
| 通信机制 | ZeroMQ 消息总线(4505/4506),也可用 RAET/TCP | SSH(OpenSSH / paramiko) | HTTPS + SSL 证书(8140) | HTTPS REST API |
| 配置语言 | YAML(SLS)+ Jinja2 | YAML(Playbook)+ Jinja2 | Puppet DSL(自有声明式语言) | Ruby DSL(Recipe) |
| 执行速度 | 极快(长连接推送,秒级到千台) | 较慢(每台建 SSH 连接,串行/分叉) | 中等(默认 30 分钟拉取周期) | 中等(周期性拉取) |
| 工作模式 | Push(推)为主,也支持 Pull/Masterless | Push | Pull(agent 定期向 master 拉) | Pull |
| 学习曲线 | 中(概念多:grains/pillar/state/runner) | 低(无 agent、YAML 直观) | 高(要学 DSL 和资源模型) | 高(要会 Ruby) |
| 适用规模 | 大规模(数千台以上),实时性要求高 | 中小规模(几十到数百台),临时任务 | 大规模、强合规审计场景 | 大规模、开发能力强的团队 |
| 二次开发 | Salt API、自定义模块方便 | 自定义模块方便 | 需懂 Ruby | 需懂 Ruby |
选型口诀:
- 要快、要管几千台、要事件驱动 → SaltStack;
- 不想装 agent、临时批量任务多、上手快 → Ansible;
- 强调状态强收敛与合规报表、团队会 Ruby → Puppet / Chef。
详见 14-02-Ansible自动化部署.md
详见 14-03-Puppet配置管理.md
三、SaltStack 架构详解
3.1 Master/Minion 的 C/S 模式
SaltStack 是一个分布式配置管理与远程执行系统,采用 master/minion(C/S)架构:
master
10.1.1.2
minion1 minion2
10.1.1.3 10.1.1.4
- salt-master:管理端。下发指令,存放 state(SLS)、pillar 配置,接收并汇总 minion 返回结果;
- salt-minion:被管理端。启动后主动连接 master(所以 master 不需要能 SSH 到 minion,只要 minion 能连到 master 的 4505/4506),等待指令并在本机执行。
注意方向性:是 minion 主动连 master,这带来两个好处:
- minion 在 NAT 后面、没有公网 IP 也能被管理;
- master 不需要维护到每台机器的 SSH 认证。
3.2 基于 ZeroMQ 的消息总线(Salt 相比 Puppet 的核心差异)
这是 SaltStack 最关键的架构特点,也是它比 Puppet/Chef 快一个数量级的根本原因。
- Puppet/Chef 是 Pull 模型 + HTTP 请求:每个 agent 定期(默认 30 分钟)向 master 发一次 HTTPS 请求拉取 catalog。master 要为每个连接做一次 SSL 握手和编译,并发能力受 HTTP 服务器限制,且指令下发有延迟(最坏要等一个周期)。
- SaltStack 是 Push 模型 + ZeroMQ 消息总线:所有 minion 启动后与 master 的 4505 端口建立持久的 TCP 长连接并订阅消息。master 下发指令时,只是往消息总线上 publish 一条消息,所有订阅者(minion)几乎同时收到,然后各自执行、各自把结果推回 4506 端口。
由此带来的优势:
- 高并发:一条 publish 消息广播给数千个 minion,master 的下发开销几乎与 minion 数量无关;
- 低延迟:命令是实时推送的,秒级完成上千台的批量操作,不需要等轮询周期;
- 异步非阻塞:ZeroMQ 是异步消息队列,master 端由
worker_threads个工作进程并行处理返回结果; - 天然支持事件驱动:消息总线本身就是一条 event bus,Reactor/Beacon 机制就建立在它之上。
3.3 发布订阅模型:4505 与 4506 两个端口
| 端口 | 名称(配置项) | 方向 | 职责 |
|---|---|---|---|
| 4505 | publish_port |
minion → master(长连接,订阅) | 消息发布端口(PUB)。master 通过它把命令广播给所有 minion;minion 启动即连上并持续监听,所有 salt 事件也走这条总线 |
| 4506 | ret_port |
minion → master(短连接,请求应答) | 结果返回端口(REP/REQ)。minion 通过它把执行结果回传给 master;同时用于 minion 拉取文件(salt://)、拉取 pillar 数据、密钥认证 |
一次 salt '*' test.ping 的完整流程:
- 命令行工具把任务(含 target、function、参数、jid)通过本地 socket 交给 master;
- master 在 4505 上 publish 一条加密消息,附带目标匹配表达式;
- 所有 minion 都收到这条消息,各自判断自己是否匹配 target(匹配判断在 minion 端做);
- 匹配的 minion 在本地执行
test.ping; - minion 把结果通过 4506 返回给 master;
- master 收集结果(受
timeout限制),格式化输出到命令行;同时按需写入 job cache 和 returner。
记住这条排错口诀:Minion 没反应,先查 4505 通不通(收不到命令);能收到命令但拿不到结果或拉不到文件,查 4506。
3.4 AES 加密与密钥认证机制
- 通信内容使用 AES 对称加密,AES key 由 master 生成并分发给已认证的 minion;
- 身份认证使用 RSA 非对称密钥对:
- minion 首次启动时生成自己的密钥对,把公钥发送给 master;
- 该公钥在 master 上处于
Unaccepted(待接受)状态,存放于/etc/salt/pki/master/minions_pre/; - 管理员用
salt-key -a <minion_id>接受,公钥移动到/etc/salt/pki/master/minions/; - master 把 AES key 用 minion 公钥加密后发回,此后双方用 AES 通信。
相关目录:
| 路径 | 说明 |
|---|---|
/etc/salt/pki/master/master.pem / .pub |
master 自身私钥/公钥 |
/etc/salt/pki/master/minions/ |
已接受(accepted)的 minion 公钥 |
/etc/salt/pki/master/minions_pre/ |
待接受(unaccepted)的 minion 公钥 |
/etc/salt/pki/master/minions_rejected/ |
已拒绝(rejected)的 minion 公钥 |
/etc/salt/pki/master/minions_denied/ |
被拒绝(denied,id 冲突)的公钥 |
/etc/salt/pki/minion/minion.pem / .pub |
minion 自身私钥/公钥 |
/etc/salt/pki/minion/minion_master.pub |
minion 缓存的 master 公钥(重装 master 后要删掉它) |
3.5 Syndic:多层级架构
当单台 master 管不动(或需要跨机房、跨网络区域)时,用 Syndic 做多层级:
Master of Masters(顶层 master)
|
+---------+---------+
| |
Syndic A Syndic B (既是上层的 minion,又是下层的 master)
| |
minion x N minion x N
- Syndic 节点上同时运行
salt-master和salt-syndic; - 顶层 master 下发的命令由 Syndic 转发给自己管理的 minion,结果再逐层汇总回顶层;
- 顶层 master 无需知道底层 minion 的存在,适合跨地域、多机房、超大规模(万台以上)场景;
- 配置要点:Syndic 的
/etc/salt/master中设置syndic_master: <顶层master地址>,顶层 master 设置order_masters: True。
# Syndic 节点 /etc/salt/master
syndic_master: 10.1.1.1
# 顶层 Master of Masters /etc/salt/master
order_masters: True
3.6 Salt SSH:无 agent 模式
salt-ssh 让 SaltStack 在不安装 minion 的情况下通过 SSH 管理机器(用法与 Ansible 类似)。
定位与适用场景:
- 适用:机器数量少;不允许装 agent(例如网络设备、安全合规要求严格的机器);用于给新机器做「装 minion 之前」的初始化引导(这是最经典的用法:先用 salt-ssh 把 minion 装上,之后走正常模式);临时接管一批陌生机器。
- 不适用:大规模并发。因为它退化成逐台建立 SSH 连接,失去了 ZeroMQ 消息总线的高并发优势,速度慢很多,也没有长连接和事件驱动。
3.7 Masterless 模式
不需要 master,直接在单机上用 salt-call --local 执行本地的 SLS:
- 状态文件放在本机的
file_roots(默认/srv/salt); - 适用场景:单机标准化、镜像制作(打 AMI/模板机时跑一遍初始化状态)、容器构建、master 故障时的应急自救、测试 SLS 语法。
# Masterless:只在本机执行本地状态
salt-call --local state.apply
salt-call --local state.sls init
salt-call --local -l debug grains.items
四、核心概念一览
| 概念 | 一句话说明 | 存放位置 |
|---|---|---|
| Grains | minion 的静态资源信息(OS、CPU、内存、IP…),minion 启动时采集 | minion 端 |
| Pillar | master 端定义、按 minion 定向分发的数据,适合放敏感/差异化信息 | master 端 |
| States | 状态,即 SLS 文件,声明式描述 minion 应有的最终状态 | master 的 file_roots |
| Modules | 执行模块,命令式的远程执行函数(cmd.run、pkg.install…) |
minion 端动态同步 |
| Returners | 结果返回器,把执行结果写到 MySQL/Redis/ES 等外部存储而非仅打印 | master/minion |
| Runners | 运行在 master 本机的模块,用 salt-run 调用(如 manage.up) |
master 端 |
| Reactor | 事件反应器,监听 event bus,某事件发生时自动触发某状态/命令 | master 端 |
| Beacon | 在 minion 端监控本地资源(磁盘、进程、文件变化)并往总线发事件 | minion 端 |
| Top File | top.sls,把"哪些 minion 应用哪些 SLS"关联起来的入口文件 |
file_roots / pillar_roots |
| Environment | 环境划分(base/dev/prod),每个环境对应不同的 file_roots 目录 |
master 配置 |
| Jinja | 模板引擎,让 SLS 和配置文件能带变量、条件、循环 | 渲染时生效 |
4.1 Grains 与 Pillar 的区别(重点)
这是初学者最容易混淆、面试也最常问的一点。
| 对比项 | Grains | Pillar |
|---|---|---|
| 数据来源 | minion 端采集/定义 | master 端定义 |
| 数据性质 | 静态的、描述"机器本身是什么" | 动态的、描述"要给这台机器什么配置" |
| 更新时机 | minion 启动时采集(改了要重启或 saltutil.sync_grains) |
每次执行状态时从 master 拉取(saltutil.refresh_pillar) |
| 可见性 | 所有 minion 的 grains 都能被查询到 | 只有被 top.sls 指定的 minion 才能看到自己那份,天然隔离 |
| 安全性 | 不适合放敏感信息(minion 上的普通用户可读,且全局可见) | 适合放密码、密钥等敏感信息(可配 GPG 加密) |
| 典型内容 | os、osrelease、num_cpus、mem_total、ip4_interfaces、自定义 role |
数据库密码、监控 server 地址、每台机器不同的端口/域名 |
| 匹配参数 | -G 'os:CentOS' |
-I 'role:web' |
| 典型用途 | 给机器打标签、分组、按硬件/系统差异分支 | 给同一份模板填不同的值、下发机密 |
一句话记忆:
Grains 回答"你是谁"(机器属性),Pillar 回答"你该配什么"(下发数据)。分组用 Grains,机密和差异化数据用 Pillar。
实战组合用法:用 grains 里的 role: webserver 把机器分组,用 pillar 给不同 role 的机器下发不同的 nginx 端口和数据库密码。
五、环境准备与安装
5.1 实验环境规划(本文主线)
两份源文档的环境规划一致,本文统一采用如下规划(CentOS 7.3):
| 角色 | 主机名 | IP | 安装的组件 |
|---|---|---|---|
| Master | master | 10.1.1.2 | salt-master + salt-minion(自己也纳管) |
| Minion1 | minion1 | 10.1.1.3 | salt-minion |
| Minion2 | minion2 | 10.1.1.4 | salt-minion |
旁注:另有一台 10.1.1.1 作为内网 yum 源服务器(提供 epel、centos163 仓库),若你的环境能直连公网,可跳过内网源,直接用公网 EPEL 或 SaltStack 官方源。
准备工作清单(三台都要做):
- 主机名及主机名绑定(必须能互相解析)
- 静态 IP
- 时间同步
- 关闭防火墙、SELinux(或按 5.4 放行端口)
- 配置 yum 源(本地 ISO、CentOS 163、epel)
5.2 主机名与 /etc/hosts 解析
Salt 用 minion id(默认取主机名)作为标识,master 与 minion 之间、minion 相互之间都必须能解析主机名,否则会出现认证失败、返回结果慢、file.managed 拉取超时等诡异问题。
# CentOS 7 设置主机名(永久生效)
hostnamectl set-hostname master # 在 master 上
hostnamectl set-hostname minion1 # 在 minion1 上
hostnamectl set-hostname minion2 # 在 minion2 上
# CentOS 6 设置主机名
# 编辑 /etc/sysconfig/network → HOSTNAME=master
# 临时生效:hostname master
三台机器的 /etc/hosts 都追加:
10.1.1.2 master
10.1.1.3 minion1
10.1.1.4 minion2
验证:
ping -c1 master; ping -c1 minion1; ping -c1 minion2
5.3 时间同步
Salt 的密钥认证与 job 的 jid(以时间戳命名)都依赖时间,master 与 minion 时间偏差过大会导致认证失败或 job 结果混乱,必须做时间同步。
# CentOS 7:chrony(推荐)
yum install -y chrony
systemctl start chronyd
systemctl enable chronyd
chronyc sources -v
timedatectl set-timezone Asia/Shanghai
# CentOS 6:ntp
yum install -y ntp
service ntpd start
chkconfig ntpd on
# 通用:手工立即同步一次
ntpdate ntp.aliyun.com
详见 08-03-时间同步与Xinetd.md
5.4 防火墙放行 4505/4506 与 SELinux 处理
master 端必须放行 4505 与 4506(TCP);minion 端是主动外连,通常无需放行入站端口。
# ---------- CentOS 7:firewalld ----------
firewall-cmd --permanent --add-port=4505-4506/tcp
firewall-cmd --reload
firewall-cmd --list-ports
# 或者干脆关闭(实验环境)
systemctl stop firewalld
systemctl disable firewalld
# ---------- CentOS 6:iptables ----------
iptables -I INPUT -p tcp --dport 4505 -j ACCEPT
iptables -I INPUT -p tcp --dport 4506 -j ACCEPT
service iptables save
service iptables restart
# 或者关闭(实验环境)
service iptables stop
chkconfig iptables off
SELinux 处理(CentOS 6/7 通用):
# 临时关闭
setenforce 0
getenforce
# 永久关闭:编辑 /etc/selinux/config
# SELINUX=enforcing → SELINUX=disabled
sed -i 's/^SELINUX=enforcing/SELINUX=disabled/' /etc/selinux/config
生产环境如果必须保留 SELinux,需要为 salt 相关端口和文件上下文放行;实验环境直接 disabled 最省事。
详见 07-03-防火墙与SELinux.md
5.5 配置 yum 源
方式一:内网源 / 本地源(源文档实验环境用法)
/etc/yum.repos.d/ 下相关源文件:
[local]
name=local
baseurl=file:///yum/
enabled=1
gpgcheck=0
[epel]
name=epel
baseurl=http://10.1.1.1/epel
enabled=1
gpgcheck=0
[centos163]
name=centos163
baseurl=http://10.1.1.1/centos163
enabled=1
gpgcheck=0
方式二:公网 EPEL(salt 包在 EPEL 里)
# CentOS 7
yum install -y epel-release
# 换成国内阿里云镜像(可选,快很多)
sed -i 's|^#baseurl=http://download.fedoraproject.org/pub|baseurl=https://mirrors.aliyun.com|' /etc/yum.repos.d/epel.repo
sed -i 's|^metalink|#metalink|' /etc/yum.repos.d/epel.repo
yum clean all && yum makecache
方式三:SaltStack 官方源(可装到指定新版本)
# /etc/yum.repos.d/salt.repo (CentOS 7 示例)
[saltstack-repo]
name=SaltStack repo for RHEL/CentOS 7
baseurl=https://repo.saltstack.com/py3/redhat/7/x86_64/latest
enabled=1
gpgcheck=1
gpgkey=https://repo.saltstack.com/py3/redhat/7/x86_64/latest/SALTSTACK-GPG-KEY.pub
国内镜像(清华/阿里,把 baseurl 换掉即可):
baseurl=https://mirrors.tuna.tsinghua.edu.cn/saltstack/py3/redhat/7/x86_64/latest
yum clean all
yum makecache
yum list salt-master salt-minion
5.6 安装软件包
# master 上同时安装 master 和 minion(这样 master 自己也能被自己管理)
[root@master ~]# yum install salt-master salt-minion -y
# 两台 minion 上只装 minion
[root@minion1 ~]# yum install salt-minion -y
[root@minion2 ~]# yum install salt-minion -y
查看版本:
salt --versions-report
salt-minion --version
5.7 版本一致性要求(重要)
master 与所有 minion 的 salt 版本应保持一致,至少大版本一致。
- Salt 的执行模块是由 master 同步给 minion 的,minion 版本低于 master 时,新模块/新参数在 minion 上不存在,会报
'xxx.yyy' is not available; - 反之 minion 版本过高,也可能出现返回数据结构不兼容、渲染差异;
- 官方兼容规则:master 版本 ≥ minion 版本是相对安全的方向,但仍强烈建议统一版本;
- 升级顺序:先升 master,再升 minion。
# 批量核对所有 minion 版本,与 master 对比
salt '*' test.version
salt-run manage.versions
5.8 配置 Minion 指向 Master
三台机器(含 master 自身)都编辑 /etc/salt/minion,找到第 16 行附近,取消注释并配置:
master: 10.1.1.2
5.9 启动服务
# ---------- CentOS 7:systemctl ----------
# master
[root@master ~]# systemctl start salt-master
[root@master ~]# systemctl enable salt-master
[root@master ~]# systemctl start salt-minion
[root@master ~]# systemctl enable salt-minion
# minion1
[root@minion1 ~]# systemctl start salt-minion
[root@minion1 ~]# systemctl enable salt-minion
# minion2
[root@minion2 ~]# systemctl start salt-minion
[root@minion2 ~]# systemctl enable salt-minion
# ---------- CentOS 6:service + chkconfig ----------
# master
[root@master ~]# service salt-master start
[root@master ~]# chkconfig salt-master on
[root@master ~]# service salt-minion start
[root@master ~]# chkconfig salt-minion on
# minion
[root@minion1 ~]# service salt-minion start
[root@minion1 ~]# chkconfig salt-minion on
检查端口是否监听(master 端):
netstat -tunlp | grep -E '4505|4506'
# 或
ss -tunlp | grep -E '4505|4506'
六、配置文件详解
6.1 /etc/salt/master 常用配置项
# ===== 网络与端口 =====
# master 监听的地址,0.0.0.0 表示监听所有网卡;多网卡环境建议指定内网 IP
interface: 0.0.0.0
# 消息发布端口(PUB),master 向 minion 广播命令
publish_port: 4505
# 结果返回端口(REP),minion 回传结果、拉取文件与 pillar
ret_port: 4506
# ===== 状态文件根目录(SLS 存放位置)=====
file_roots:
base:
- /srv/salt/
dev:
- /srv/salt/dev/
prod:
- /srv/salt/prod/
# ===== Pillar 根目录 =====
pillar_roots:
base:
- /srv/pillar
# ===== 自动接受 minion 密钥(危险!)=====
# True 表示 minion 一连上来就自动认证通过,不需要 salt-key -a
auto_accept: False
# ===== 并发与超时 =====
# master 处理 minion 返回的工作进程数,默认 5
# 经验值:不超过 CPU 核数 * 1.5 ~ 2;minion 多、返回慢时适当加大
worker_threads: 5
# 等待 minion 返回结果的超时时间(秒),默认 5
# 网络差或执行耗时长的任务需要加大,或命令行用 -t 临时指定
timeout: 5
# ===== 日志 =====
log_file: /var/log/salt/master
# 级别:all / garbage / trace / debug / info / warning / error / critical
log_level: warning
log_level_logfile: warning
# ===== 包含额外配置 =====
# 默认加载 /etc/salt/master.d/*.conf,便于把自定义配置拆成小文件
default_include: master.d/*.conf
# ===== 节点组 =====
nodegroups:
web: 'L@minion1,minion2'
db: 'G@role:dbserver'
各项要点说明:
| 配置项 | 作用与注意事项 |
|---|---|
interface |
master 绑定的 IP。多网卡/多 IP 时必须指定,否则 minion 可能连到错误网段 |
publish_port |
默认 4505,修改后 minion 端必须同步改 publish_port |
ret_port |
默认 4506,修改后 minion 端必须同步改 master_port |
file_roots |
SLS 与文件分发的根目录,salt:// 协议就是相对这个目录解析的;支持多环境、每环境多目录(按顺序查找,先找到先用) |
pillar_roots |
pillar 数据根目录,结构与 file_roots 相同 |
auto_accept |
安全风险大:任何能连到 4506 的机器都会被自动纳管,攻击者只要伪造一个 minion id 即可获得 pillar 数据(可能含明文密码)并接收命令。生产环境必须为 False,改用手工 salt-key -a 或结合 master_finger + 预置密钥自动化 |
worker_threads |
太小会导致大批量返回时结果丢失/超时;太大会耗尽 CPU 与内存 |
timeout |
全局默认超时,命令行 -t 可覆盖单次执行 |
log_level |
排错时临时改成 debug,问题解决后一定改回 warning,否则日志暴涨 |
default_include |
推荐把自定义配置写进 /etc/salt/master.d/xxx.conf,升级时主配置文件被覆盖也不丢配置 |
6.2 /etc/salt/minion 常用配置项
# ===== 指向 master =====
# 单 master
master: 10.1.1.2
# 多 master(高可用):写成列表,minion 会同时连接(需配 master_type)
#master:
# - 10.1.1.2
# - 10.1.1.5
#master_type: failover # failover=主备切换;str(默认)=全部连接
#master_alive_interval: 30 # 探测 master 存活的间隔
# master 的返回端口,要与 master 端 ret_port 一致
master_port: 4506
# ===== minion 唯一标识 =====
# 不设置时,默认取本机主机名(FQDN/hostname)
# 强烈建议显式设置,避免主机名变更后被当成一台新机器
id: minion1
# ===== master 公钥指纹校验(防中间人)=====
# 在 master 上执行 salt-key -F master 取 master.pub 的指纹填在这里
# 填了之后 minion 只信任指纹匹配的 master,可安全地配合 auto_accept 使用
master_finger: 'ba:30:65:2a:d6:9e:20:4f:d8:b2:f3:a7:2e:4b:9c:1d'
# ===== 自定义 grains =====
grains:
role:
- webserver
- dbserver
env: prod
# ===== 日志 =====
log_file: /var/log/salt/minion
log_level: warning
关于 id 的三点提醒:
- 不设置
id时默认取主机名,所以主机名必须唯一且规范; - minion 首次启动后会把 id 缓存到
/etc/salt/minion_id,改了配置里的 id 还要删掉这个文件才生效; - 改 id 相当于换了一台新 minion,master 端需要重新接受新 key、删除旧 key。
6.3 配置改动后必须重启服务
# 改了 /etc/salt/master
systemctl restart salt-master # CentOS 7
service salt-master restart # CentOS 6
# 改了 /etc/salt/minion(包括自定义 grains)
systemctl restart salt-minion # CentOS 7
service salt-minion restart # CentOS 6
易错点:改了
file_roots/pillar_roots/nodegroups不重启 master 是不生效的;改了grains:不重启 minion 也不生效(或用salt '*' saltutil.sync_grains+saltutil.refresh_grains)。
七、密钥认证流程(salt-key)
7.1 认证流程回顾
minion 启动后会主动向 master 发起公钥认证,master 必须接受后才能管理它。
7.2 查看 key 状态
[root@master ~]# salt-key
Accepted Keys:
Denied Keys:
Unaccepted Keys:
master
minion1
minion2
Rejected Keys:
salt-key -L 与不带参数的 salt-key 等价,都是列出全部 key。四种状态含义:
| 状态 | 含义 | 对应目录 |
|---|---|---|
| Accepted | 已接受,可以正常管理 | minions/ |
| Unaccepted | 已提交公钥、等待接受 | minions_pre/ |
| Rejected | 被管理员明确拒绝(-r),master 会忽略它 |
minions_rejected/ |
| Denied | 被 master 自动拒绝,通常是同一个 minion id 提交了不同的公钥(重装 minion、id 冲突) | minions_denied/ |
7.3 接受 key
[root@master ~]# salt-key -a minion1
[root@master ~]# salt-key -a minion2
[root@master ~]# salt-key -a master # master 自身也是 minion,也需要接受
-a 参数会把 Unaccepted Keys 中的 key 变为 accepted 状态。再次查看:
Accepted Keys:
master
minion1
minion2
Denied Keys:
Unaccepted Keys:
Rejected Keys:
一次性接受所有待签 minion(免交互加 -y):
[root@master ~]# salt-key -A
[root@master ~]# salt-key -A -y
7.4 salt-key 常用参数速查
| 参数 | 作用 |
|---|---|
-L / 不带参数 |
列出所有 key 及其状态 |
-l <状态> |
只列出某状态,如 salt-key -l acc(accepted)、-l un、-l rej、-l den |
-a <minion> |
接受指定 minion(accept) |
-A |
接受所有待签 minion |
-r <minion> |
拒绝指定 minion(reject),key 移到 rejected |
-R |
拒绝所有 |
-d <minion> |
删除指定 minion 的 key |
-D |
删除所有 key(慎用,会让所有 minion 失联需重新认证) |
-f <minion> |
查看指定 minion key 的指纹(finger) |
-F |
查看 master 及所有 minion 的指纹(salt-key -F master 取 master 指纹填 master_finger) |
-y |
免交互确认 |
--gen-keys=<名字> |
预生成一对密钥(用于自动化预置密钥,配合 auto_accept: False 也能免人工) |
# 查看指纹(用于人工核对,防中间人)
[root@master ~]# salt-key -f minion1
Unaccepted Keys:
minion1: 3d:5f:...:9a
[root@master ~]# salt-key -F master
Local Keys:
master.pem: ...
master.pub: ba:30:65:2a:...
7.5 auto_accept 的便利与安全代价
# /etc/salt/master
auto_accept: True
- 便利:批量上线 100 台机器时不用逐个
salt-key -a,装完 minion 自动纳管,非常适合自动扩容场景; - 代价(必须点明):任何能访问 master 4506 端口的机器都会被自动接受。攻击者只要能连上 master,伪造一个 minion id(甚至猜中你的生产机器名)就能:
- 拉取该 id 对应的 Pillar 数据(里面往往有数据库密码、API key);
- 接收 master 广播的命令,探知你的运维动作;
- 制造 id 冲突让真实 minion 失联。
生产环境的正确做法:
auto_accept: False,人工salt-key -a;或- 用
salt-key --gen-keys预生成密钥并在装机时预置到 minion 的/etc/salt/pki/minion/,同时把公钥直接放进 master 的minions/目录,实现"零人工且安全";或 - 若确需
auto_accept: True,必须把 master 严格限制在受信网络内(防火墙只放行内网网段),并在 minion 端配置master_finger做双向校验。
7.6 重装 Minion 后密钥不匹配的处理
重装系统、重建虚拟机、删了 /etc/salt/pki/minion 之后,minion 会生成新的密钥对,但 master 上还缓存着旧公钥,于是报错:
The Salt Master has cached the public key for this node, this salt minion will wait for 10 seconds before attempting to re-authenticate
或
The Salt Master has rejected this minion's public key!
To repair this issue, delete the public key for this minion on the Salt Master.
完整处理步骤:
# ① master 端:删除该 minion 的旧 key
[root@master ~]# salt-key -d minion1 -y
[root@master ~]# salt-key -L # 确认已删除
# ② minion 端:停服务,删除本地密钥与 master 公钥缓存
[root@minion1 ~]# systemctl stop salt-minion # CentOS 7
#[root@minion1 ~]# service salt-minion stop # CentOS 6
[root@minion1 ~]# rm -rf /etc/salt/pki/minion/*
# 如果同时改了 id,还要删缓存的 id
[root@minion1 ~]# rm -f /etc/salt/minion_id
# ③ minion 端:重启服务,重新提交公钥
[root@minion1 ~]# systemctl start salt-minion
# ④ master 端:重新接受
[root@master ~]# salt-key -L # 应看到 minion1 在 Unaccepted
[root@master ~]# salt-key -a minion1 -y
# ⑤ 验证
[root@master ~]# salt 'minion1' test.ping
反向情况:重装了 master,则每台 minion 都要删掉
/etc/salt/pki/minion/minion_master.pub(缓存的旧 master 公钥)后重启 salt-minion,否则 minion 会认为 master 是冒充的。
7.7 连通性验证
[root@master ~]# salt '*' test.ping
minion1:
True
minion2:
True
master:
True
[root@master ~]# salt-run manage.up # 查看处于 up 状态的被管机器
- master
- minion1
- minion2
八、目标匹配(Targeting)
8.1 匹配方式逐项详解
① 默认:glob 通配(无需选项)
按 minion id 做 shell 风格通配,是最常用的方式。
salt '*' test.ping # 所有已接受的 minion
salt 'minion1' cmd.run 'hostname' # 精确单台
salt 'minion*' cmd.run 'hostname' # 以 minion 开头
salt 'minion?' test.ping # ? 匹配单个字符
salt 'minion[12]' test.ping # 字符集:minion1 与 minion2
salt 'minion[1-3]' test.ping # 字符范围
salt '*.example.com' test.ping # 按域名后缀
目标一定要用单引号包裹,否则
*会先被 shell 展开成当前目录的文件名。
② -E / --pcre:正则匹配
salt -E 'minion(1|2)' cmd.run 'uptime'
salt -E '^minion[0-9]{1,2}$' test.ping
salt -E 'web-(sh|bj)-\d+' cmd.run 'hostname'
③ -L / --list:列表匹配
明确枚举 minion id,用逗号分隔(逗号后不要加空格)。
salt -L 'minion1,minion2' cmd.run 'df -h'
salt -L 'minion1,minion2,master' test.ping
④ -G / --grain:按 Grains 匹配
语法 -G '<grains键>:<值>',值支持通配。嵌套 grains 用 : 分隔层级。
salt -G 'os:CentOS' cmd.run 'hostname'
salt -G 'os:CentOS' test.ping
salt -G 'osrelease:7*' cmd.run 'uname -r' # 所有 CentOS 7.x
salt -G 'os_family:RedHat' pkg.version httpd
salt -G 'role:webserver' cmd.run 'hostname' # 自定义 grains
salt -G 'num_cpus:4' test.ping
salt -G 'ip4_interfaces:eth0:10.1.1.3' test.ping # 嵌套用 : 逐层
# 正则形式的 grains 匹配
salt --grain-pcre 'osrelease:7\.[0-9]' test.ping
⑤ -I / --pillar:按 Pillar 匹配
salt -I 'name:test_service' test.ping
salt -I 'role:web' state.sls nginx
salt --pillar-pcre 'app_env:(dev|test)' test.ping
注意:pillar 匹配要求 minion 已经拿到 pillar 数据,改了 pillar 后先
saltutil.refresh_pillar。
⑥ -S / --ipcidr:按子网/IP 匹配
salt -S '10.1.1.3' test.ping # 单个 IP
salt -S '10.1.1.0/24' test.ping # 整个网段
salt -S '10.1.0.0/16' cmd.run 'hostname'
⑦ -C / --compound:复合匹配(and / or / not)
最强大的匹配方式,用前缀字母指定每个子条件的匹配类型:
| 前缀 | 含义 |
|---|---|
G@ |
grains |
P@ |
grains 正则 |
I@ |
pillar |
J@ |
pillar 正则 |
L@ |
列表 |
S@ |
子网/IP |
E@ |
minion id 正则 |
N@ |
节点组 |
| 无前缀 | glob 通配(默认) |
# CentOS 系统 且 是 webserver 角色
salt -C 'G@os:CentOS and G@role:webserver' test.ping
# webserver 或 dbserver
salt -C 'G@role:webserver or G@role:dbserver' cmd.run 'hostname'
# 所有 minion 开头的机器,但排除 minion2
salt -C 'minion* and not minion2' test.ping
# 10.1.1.0/24 网段里的 CentOS 7,且不是 master
salt -C 'S@10.1.1.0/24 and G@osrelease:7* and not master' test.ping
# 混合列表与 grains
salt -C 'L@minion1,minion2 and G@os_family:RedHat' pkg.install httpd
# 括号分组
salt -C '( G@role:web or G@role:cache ) and G@os:CentOS' test.ping
⑧ -N / --nodegroup:节点组
把常用的复合表达式在 master 上起个名字,避免每次敲长命令。
# /etc/salt/master (改完必须重启 salt-master)
nodegroups:
web: 'L@minion1,minion2'
db: 'G@role:dbserver'
centos7: 'G@osrelease:7*'
# 引用其他节点组用 N@
prod_web: 'N@web and G@env:prod'
# 包含特殊字符时用单引号,并注意转义
test_group: 'E@^minion[0-9]+$ and not L@minion9'
systemctl restart salt-master
salt -N web test.ping
salt -N web state.sls nginx
salt -N db cmd.run 'mysql -V'
# 节点组也可以在复合匹配里引用
salt -C 'N@web and G@os:CentOS' test.ping
8.2 匹配语法对照表
| 方式 | 选项 | 示例 | 说明 |
|---|---|---|---|
| glob 通配 | 无 | salt 'minion*' test.ping |
默认,按 minion id |
| 列表通配 | 无 | salt 'minion[12]' test.ping |
字符集 |
| 正则 | -E |
salt -E 'minion(1\|2)' cmd.run uptime |
PCRE |
| 列表 | -L |
salt -L 'minion1,minion2' cmd.run 'df -h' |
精确枚举 |
| Grains | -G |
salt -G 'os:CentOS' cmd.run hostname |
按机器属性 |
| Grains 正则 | -P |
salt -P 'osrelease:7\..*' test.ping |
|
| Pillar | -I |
salt -I 'role:web' test.ping |
按下发数据 |
| Pillar 正则 | -J |
salt -J 'env:(dev\|test)' test.ping |
|
| 子网/IP | -S |
salt -S '10.1.1.0/24' test.ping |
|
| 复合 | -C |
salt -C 'G@os:CentOS and not minion2' test.ping |
and/or/not |
| 节点组 | -N |
salt -N web test.ping |
master 预定义 |
九、salt 命令行结构与常用选项
9.1 命令结构
salt [选项] '<target>' <module.function> [参数...]
<target>:目标匹配表达式(见上一节);<module.function>:执行模块.函数,如test.ping、cmd.run、pkg.install;[参数]:位置参数直接写,关键字参数写成key=value。
salt 'minion1' cmd.run 'ls -l /tmp' # 位置参数
salt 'minion1' cmd.run 'ls -l' cwd=/etc # 关键字参数
salt 'minion1' pkg.install httpd # 位置参数
salt 'minion1' file.chown /tmp/a.txt root root # 多个位置参数
salt 'minion1' state.sls nginx test=True # 关键字参数
9.2 常用选项
| 选项 | 作用 | 示例 |
|---|---|---|
-t <秒> |
单次执行的超时时间,覆盖 master 的 timeout |
salt -t 60 '*' cmd.run 'yum -y update' |
--out=<格式> |
输出格式:nested(默认)、json、yaml、txt、highstate、table、raw、quiet、key、newline_values_only |
salt '*' grains.items --out=yaml |
--out-indent=<n> |
输出缩进空格数,-1 表示不缩进(便于机器解析) |
salt '*' grains.items --out=json --out-indent=-1 |
--no-color |
关闭彩色输出(写日志/管道时用) | |
-v / --verbose |
详细输出,会打印本次任务的 jid | salt -v '*' test.ping |
--async |
异步执行:立即返回 jid 不等结果,适合超长任务 | salt --async '*' state.highstate |
-b <n> / --batch-size=<n> |
批量执行,控制并发。可写数字或百分比 | salt -b 5 '*' state.sls nginx |
--batch-wait=<秒> |
每批之间等待秒数 | salt -b 10% --batch-wait=3 '*' service.restart nginx |
-s / --static |
等所有结果收齐后一次性输出(默认边收边输出) | |
--summary |
结束时打印成功/失败台数汇总 | salt --summary '*' test.ping |
-l <级别> |
命令行日志级别(debug 最常用于排错) |
salt -l debug 'minion1' test.ping |
--return=<returner> |
结果同时写入指定 returner | salt '*' test.ping --return mysql |
-C/-E/-L/-G/-I/-S/-N/-P/-J |
目标匹配方式(见上节) |
关于 -b 批量执行(生产必备):
滚动升级、重启服务时,如果一次性对所有机器操作会导致服务全部中断。-b 让 salt 分批执行,一批完成后才进行下一批:
# 每次只操作 2 台,2 台成功后再操作下 2 台
salt -b 2 '*' state.sls nginx
# 每次操作 25% 的机器
salt -b 25% '*' service.restart nginx
# 每批之间等 5 秒,给服务预热时间
salt -b 3 --batch-wait=5 -G 'role:webserver' state.sls nginx.deploy
口诀:改配置重启服务,一律加
-b;批量装包做更新,一律加-t。
输出格式示例:
salt 'minion1' grains.item os osrelease --out=json
salt 'minion1' grains.item os --out=yaml
salt '*' test.ping --out=txt
salt '*' state.sls nginx --out=highstate # 状态执行结果的友好格式
salt '*' cmd.run 'hostname' --out=newline_values_only # 只要值,方便脚本处理
十、常用执行模块详解
10.1 test:连通性与自检
salt '*' test.ping # 连通性测试,返回 True
salt '*' test.version # 查看各 minion 的 salt 版本(核对版本一致性)
salt '*' test.versions_report # 完整依赖版本报告
salt '*' test.echo 'hello salt' # 回显字符串,测试参数传递
salt '*' test.fib 10 # 计算斐波那契,测试性能
salt '*' test.sleep 10 # 睡眠,测试 timeout
salt '*' test.collatz 30
salt '*' test.provider service # 查看某模块在该 minion 上的实际提供者
10.2 cmd:执行任意命令
# 最常用:执行命令并返回标准输出
[root@master ~]# salt "*" cmd.run "uname -r"
master:
3.10.0-514.el7.x86_64
minion2:
3.10.0-514.el7.x86_64
minion1:
3.10.0-514.el7.x86_64
salt "minion*" cmd.run "hostname"
salt -E "minion(1|2)" cmd.run "uptime"
salt -L "minion1,minion2" cmd.run "df -h"
# 指定工作目录
salt '*' cmd.run 'ls -l' cwd=/etc
# 指定 shell
salt '*' cmd.run 'echo $0' shell=/bin/bash
# 以指定用户身份执行
salt '*' cmd.run 'whoami' runas=nginx
# 传递环境变量
salt '*' cmd.run 'echo $MYVAR' env='{"MYVAR": "hello"}'
# cmd.run_all:返回 stdout / stderr / retcode / pid 的完整字典(脚本判断成败必用)
salt '*' cmd.run_all 'ls /notexist'
# minion1:
# ----------
# pid: 12345
# retcode: 2
# stderr: ls: cannot access /notexist: No such file or directory
# stdout:
# 只要标准输出 / 只要 retcode
salt '*' cmd.run_stdout 'hostname'
salt '*' cmd.run_stderr 'ls /notexist'
salt '*' cmd.retcode 'test -f /etc/passwd'
# cmd.script:把 master 上的脚本下发到 minion 执行(命令在 master 发起,脚本在被管机执行)
[root@master ~]# salt "minion*" cmd.script salt://test_dir/1.sh
salt '*' cmd.script salt://scripts/init.sh 'arg1 arg2'
salt '*' cmd.script salt://scripts/init.sh runas=root cwd=/tmp
# 执行时不记录到日志(含密码的命令用)
salt '*' cmd.run 'mysql -uroot -pXXXX -e "show databases"' output_loglevel=quiet
cmd 常用参数汇总:
| 参数 | 作用 |
|---|---|
cwd |
命令的工作目录 |
shell |
使用的 shell,默认 /bin/sh |
runas |
以哪个用户身份执行 |
env |
传入环境变量(字典) |
timeout |
命令自身的执行超时 |
python_shell |
是否允许使用 shell 特性(管道、重定向),高版本默认 False,需要管道时设 True |
output_loglevel |
输出的日志级别,敏感命令设 quiet |
10.3 cmd.run 的滥用问题(重要)
初学者最容易走的弯路:什么都用 cmd.run,把 SaltStack 当成"批量 SSH 工具"。
cmd.run 的四个致命问题:
- 不幂等:
cmd.run 'echo x >> /etc/hosts'执行 10 次就追加 10 行; - 无状态感知:Salt 不知道命令是否真的改变了系统,返回结果永远是
Changed,无法与watch/onchanges正确联动; - 不跨平台:写死
yum install就无法在 Debian 上跑;写死service httpd start在 CentOS 7 上也能跑但语义不清; - 无法 test=True 空跑:
state.sls xxx test=True时cmd.run只会说"将会执行某命令",无法预知实际变更。
对照表——优先用专用模块:
| 不要这样写(cmd.run) | 应该这样写(专用模块) |
|---|---|
cmd.run 'yum install -y httpd' |
pkg.install httpd / pkg.installed |
cmd.run 'systemctl restart httpd' |
service.restart httpd / service.running |
cmd.run 'useradd tom' |
user.add tom / user.present |
cmd.run 'mkdir -p /data/log' |
file.mkdir /data/log / file.directory |
cmd.run 'cp /tmp/a /tmp/b' |
file.copy / file.managed |
cmd.run 'sed -i s/a/b/ /etc/x.conf' |
file.replace / file.line / file.managed + 模板 |
cmd.run 'echo x >> /etc/hosts' |
file.append / file.managed |
cmd.run 'chown nginx /data' |
file.chown / file.managed 的 user/group |
cmd.run 'crontab -l' 手工编辑 |
cron.present |
cmd.run 'mount /dev/sdb1 /data' |
mount.mounted |
cmd.run 'tar zxf x.tar.gz -C /opt' |
archive.extracted |
cmd.run 'git pull' |
git.latest |
什么时候可以用 cmd.run?
- 临时查询、应急排障(
cmd.run 'netstat -tunlp'); - 确实没有对应专用模块的一次性操作(如厂商专有工具、编译安装的
make install); - 写在 SLS 里时必须加
onlyif/unless/creates幂等守卫。
10.4 state:状态相关
# 执行指定 SLS
salt '*' state.sls nginx
salt 'minion*' state.sls file
salt '*' state.sls nginx,php,mysql # 一次执行多个
salt '*' state.sls nginx saltenv=prod # 指定环境
# 执行 top.sls 中该 minion 匹配到的全部状态(高级状态)
salt '*' state.highstate
salt '*' state.apply # 等价于 highstate(不带参数)
salt '*' state.apply nginx # 等价于 state.sls nginx(带参数)
# ★ test=True 空跑演练(dry-run):只报告"会做什么",不真正修改
salt '*' state.sls nginx test=True
salt '*' state.highstate test=True
salt '*' state.apply test=True --out=highstate
# 查看 SLS 渲染后的最终结果(排查 Jinja/YAML 问题的利器)
salt '*' state.show_sls nginx
salt '*' state.show_sls nginx --out=yaml
salt '*' state.show_highstate --out=yaml # 查看该 minion 的完整高级状态
salt '*' state.show_top # 查看 top.sls 匹配结果
salt '*' state.show_low_sls nginx # 展开成底层数据结构
# 单条状态直接执行,不写 SLS 文件
salt '*' state.single pkg.installed name=httpd
salt '*' state.single file.directory name=/data/log mode=755 makedirs=True
# 传入临时 pillar
salt '*' state.sls nginx pillar='{"nginx_port": 8080}'
上线口诀:先
test=True空跑看变更,再去掉 test 正式执行;批量时加-b。
10.5 pkg:软件包管理
salt '*' pkg.install httpd # 安装
salt '*' pkg.install httpd,httpd-devel # 安装多个
salt '*' pkg.install httpd version=2.4.6 # 安装指定版本
salt '*' pkg.install pkgs='["httpd","php","mariadb"]'
salt '*' pkg.remove httpd # 卸载
salt '*' pkg.purge httpd # 卸载并删除配置
salt '*' pkg.upgrade # 升级所有包(相当于 yum update)
salt '*' pkg.upgrade httpd # 升级单个包
salt '*' pkg.version httpd # 查询已安装版本
salt '*' pkg.latest_version httpd # 查询仓库中最新可用版本
salt '*' pkg.available_version httpd
salt '*' pkg.list_pkgs # 列出所有已安装包及版本
salt '*' pkg.list_upgrades # 列出可升级的包
salt '*' pkg.refresh_db # 刷新 yum 缓存
salt '*' pkg.file_list httpd # 该包包含哪些文件
salt '*' pkg.owner /usr/sbin/httpd # 某文件属于哪个包
salt '*' pkg.group_install "Development Tools" # 安装包组
10.6 service:服务管理
salt '*' service.start httpd
salt '*' service.stop httpd
salt '*' service.restart httpd
salt '*' service.reload httpd # 重载配置(不中断)
salt '*' service.status httpd # 是否正在运行(True/False)
salt '*' service.enable httpd # 开机自启
salt '*' service.disable httpd # 取消开机自启
salt '*' service.enabled httpd # 查询是否已设自启
salt '*' service.disabled httpd
salt '*' service.available httpd # 该服务在系统中是否存在
salt '*' service.missing httpd
salt '*' service.get_all # 列出所有服务
salt '*' service.get_running
salt '*' service.get_enabled
Salt 的
service模块会自动适配底层实现:CentOS 7 走 systemd(systemctl),CentOS 6 走 SysV init(service+chkconfig)。这正是"用专用模块而不用 cmd.run"的最大好处——同一条命令跨版本通用。
10.7 file:文件操作
salt '*' file.copy /tmp/a.txt /tmp/b.txt # minion 本机复制
salt '*' file.copy /tmp/dir1 /tmp/dir2 recurse=True
salt '*' file.managed /tmp/x.conf source=salt://conf/x.conf mode=644
salt '*' file.mkdir /data/logs # 创建目录(含父目录)
salt '*' file.makedirs /data/a/b/c/ # 创建父目录
salt '*' file.remove /tmp/a.txt # 删除文件或目录
salt '*' file.touch /tmp/a.txt
salt '*' file.find /var/log type=f name='*.log' mtime=+7 # 查找 7 天前的 log 文件
salt '*' file.find /tmp maxdepth=1 type=d
salt '*' file.replace /etc/ssh/sshd_config pattern='^#?Port .*' repl='Port 2222'
salt '*' file.replace /etc/hosts pattern='old.host' repl='new.host' backup='.bak'
salt '*' file.chown /tmp/a.txt nginx nginx # 改属主属组
salt '*' file.chgrp /tmp/a.txt nginx
salt '*' file.set_mode /tmp/a.txt 0644
salt '*' file.stats /etc/passwd # 查看文件详细属性
salt '*' file.file_exists /etc/passwd
salt '*' file.directory_exists /etc
salt '*' file.contains /etc/hosts 'minion1'
salt '*' file.append /etc/hosts '10.1.1.5 minion3'
salt '*' file.grep /etc/passwd root
salt '*' file.get_sum /etc/passwd md5
salt '*' file.symlink /usr/local/nginx /opt/nginx
salt '*' file.readlink /opt/nginx
salt '*' file.rename /tmp/a.txt /tmp/b.txt
salt '*' file.write /tmp/a.txt 'hello'
salt '*' file.read /etc/hosts
10.8 user 与 group
salt '*' user.add tom # 创建用户
salt '*' user.add tom uid=5001 gid=5001 home=/home/tom shell=/bin/bash
salt '*' user.delete tom # 删除用户(保留家目录)
salt '*' user.delete tom remove=True force=True # 连家目录一起删
salt '*' user.info tom # 查看用户信息
salt '*' user.list_users # 列出所有用户
salt '*' user.list_groups tom # 用户所属组
salt '*' user.chuid tom 6001
salt '*' user.chshell tom /sbin/nologin
salt '*' user.chhome tom /data/tom
salt '*' shadow.set_password tom '$6$xxxxx' # 设置密码(加密串)
salt '*' shadow.gen_password 'MyPassw0rd' # 生成加密串
salt '*' group.add devops # 创建组
salt '*' group.add devops gid=6002 system=True
salt '*' group.delete devops
salt '*' group.info devops
salt '*' group.getent # 列出所有组
salt '*' group.adduser devops tom # 把用户加入组
salt '*' group.deluser devops tom
salt '*' group.members devops 'tom,jerry' # 覆盖设置组成员
10.9 cp:文件分发与 salt:// 协议
不必写 state,也可以直接用执行模块从 master 下发文件:
# 拷贝单个文件
[root@master ~]# salt "minion*" cp.get_file salt://test_dir/1.sh /tmp/2.sh
minion1:
/tmp/2.sh
minion2:
/tmp/2.sh
# 递归拷贝目录
[root@master ~]# salt "minion*" cp.get_dir salt://test_dir/ /tmp/
minion2:
- /tmp//test_dir/1.sh
- /tmp//test_dir/1.txt
minion1:
- /tmp//test_dir/1.sh
- /tmp//test_dir/1.txt
其他常用用法:
salt '*' cp.get_file salt://conf/nginx.conf /etc/nginx/nginx.conf
salt '*' cp.get_file salt://conf/nginx.conf /etc/nginx/nginx.conf makedirs=True
salt '*' cp.get_file salt://conf/nginx.conf.jinja /etc/nginx/nginx.conf template=jinja
salt '*' cp.get_file salt://conf/x.conf /tmp/x.conf saltenv=prod
salt '*' cp.get_dir salt://scripts/ /opt/
salt '*' cp.get_url http://mirrors.aliyun.com/x.rpm /tmp/x.rpm # 从 URL 下载
salt '*' cp.list_master # 列出 file_roots 中所有文件
salt '*' cp.list_master_dirs
salt '*' cp.hash_file salt://conf/nginx.conf
salt '*' cp.cache_file salt://conf/nginx.conf # 缓存到 minion 本地
salt:// 协议的路径解析规则(关键):
salt://就是 master 上file_roots中当前环境的根目录;- 默认环境是
base,若file_roots配置为base: [/srv/salt/],则:
salt:// 路径 |
实际对应 master 上的文件 |
|---|---|
salt://top.sls |
/srv/salt/top.sls |
salt://test_dir/1.sh |
/srv/salt/test_dir/1.sh |
salt://lnmp/conf/nginx.conf |
/srv/salt/lnmp/conf/nginx.conf |
salt://nginx/files/nginx.conf(saltenv=prod) |
/srv/salt/prod/nginx/files/nginx.conf |
salt://里绝不能写 master 的绝对路径(salt:///srv/salt/x.conf是错的);- 一个环境配了多个目录时,Salt 按配置顺序查找,先找到的先用;
- 文件是通过 4506 端口传输的,所以 4506 不通会导致
file.managed报找不到 source。
10.10 network 与系统状态
salt '*' network.interfaces # 所有网卡详情
salt '*' network.ip_addrs # 所有 IP
salt '*' network.ip_addrs eth0
salt '*' network.hw_addr eth0 # MAC
salt '*' network.ping www.baidu.com # 从 minion ping 目标
salt '*' network.netstat
salt '*' network.active_tcp
salt '*' network.default_route
salt '*' network.get_hostname
salt '*' network.connect www.baidu.com 80 # 测试到某端口的连通性
salt '*' network.traceroute www.baidu.com
salt '*' disk.usage # 磁盘使用率(df)
salt '*' disk.inodeusage # inode 使用率
salt '*' disk.percent /
salt '*' disk.blkid
salt '*' status.diskusage # 磁盘用量(按文件系统)
salt '*' status.loadavg # 1/5/15 分钟负载
salt '*' status.meminfo # 内存详情
salt '*' status.cpuinfo
salt '*' status.cpustats
salt '*' status.uptime # 运行时长
salt '*' status.netdev
salt '*' status.all_status # 全部状态汇总
salt '*' status.version
10.11 grains 相关执行函数
# 列出 minion1 所有 grains 项的名称
[root@master ~]# salt "minion1" grains.ls
# 列出 minion1 所有 grains 项及值
[root@master ~]# salt "minion1" grains.items
salt '*' grains.item os osrelease num_cpus # 只看指定几项
salt '*' grains.get os # 取单项(支持嵌套 a:b:c)
salt '*' grains.get ip4_interfaces:eth0
salt '*' grains.setval role webserver # 设置自定义 grains(写入 /etc/salt/grains)
salt '*' grains.setval roles "['web','db']"
salt '*' grains.append roles cache # 往列表型 grains 追加
salt '*' grains.delval role # 删除某项(值置为 None)
salt '*' grains.delval role destructive=True # 彻底删除该键
salt '*' grains.has_value role
salt '*' saltutil.sync_grains # 同步自定义 grains 模块
salt '*' saltutil.refresh_grains # 重新采集 grains(不重启服务)
10.12 sys:自查文档(强烈建议掌握)
*不要死记模块名,学会用 `sys.` 自己查文档,这是从"照抄命令"到"独立解决问题"的分界线。**
# 列出该 minion 上所有可用的执行模块
salt 'minion1' sys.list_modules
# 列出某模块的所有函数
salt 'minion1' sys.list_functions pkg
salt 'minion1' sys.list_functions file
# 查看模块/函数的完整帮助文档(含参数与示例)
salt 'minion1' sys.doc pkg.install
salt 'minion1' sys.doc file.managed
salt 'minion1' sys.doc cmd # 整个 cmd 模块的所有函数文档
# 状态模块(写 SLS 用的)对应的查询命令
salt 'minion1' sys.list_state_modules
salt 'minion1' sys.list_state_functions file
salt 'minion1' sys.state_doc file.managed
salt 'minion1' sys.state_doc service.running
# 查看 runner / returner
salt-run doc.runner
salt 'minion1' sys.list_returners
salt 'minion1' sys.returner_doc mysql
# 查看某函数的参数签名
salt 'minion1' sys.argspec pkg.install
配合
grep快速定位:salt 'minion1' sys.list_functions file | grep line
十一、Grains 详解
Grains 是 minion 启动时采集的静态系统信息(操作系统、内核、网卡、IP、CPU、内存等),数据存在 minion 端。
11.1 常用内置 grains
| grains 项 | 说明 | 示例值 |
|---|---|---|
os |
操作系统名称 | CentOS |
osrelease |
系统版本号 | 7.3.1611 |
osmajorrelease |
主版本号 | 7 |
osfinger |
系统名+主版本(做分支判断最好用) | CentOS Linux-7 |
os_family |
系统家族(RedHat / Debian / Suse),跨发行版兼容的关键 | RedHat |
oscodename |
代号 | Core |
kernel |
内核类型 | Linux |
kernelrelease |
内核版本 | 3.10.0-514.el7.x86_64 |
host |
主机名(短) | minion1 |
fqdn |
完全限定域名 | minion1.example.com |
fqdn_ip4 |
FQDN 解析出的 IPv4 | ['10.1.1.3'] |
domain |
域名部分 | example.com |
id |
minion id | minion1 |
ipv4 / ipv6 |
所有 IP 列表 | ['10.1.1.3', '127.0.0.1'] |
ip4_interfaces |
每块网卡的 IPv4(字典) | {'eth0': ['10.1.1.3'], 'lo': ['127.0.0.1']} |
ip_interfaces |
每块网卡的所有 IP | |
hwaddr_interfaces |
每块网卡 MAC | |
num_cpus |
CPU 核数(调 worker/进程数的模板变量) | 4 |
cpu_model |
CPU 型号 | Intel(R) Xeon(R) ... |
cpuarch |
CPU 架构 | x86_64 |
mem_total |
内存总量(MB) | 3789 |
swap_total |
swap 总量 | |
virtual |
虚拟化类型(physical / VMware / kvm / VirtualBox / container) | VMware |
selinux |
SELinux 状态(字典) | {'enabled': False, 'enforced': 'Disabled'} |
saltversion |
minion 上的 salt 版本 | 2019.2.0 |
pythonversion |
Python 版本 | |
shell |
默认 shell | /bin/sh |
path |
PATH 环境变量 | |
localhost |
本机名 | |
nodename |
uname -n | |
productname / manufacturer |
硬件型号/厂商(物理机盘点用) | |
biosversion / serialnumber |
BIOS 版本/序列号 | |
disks / ssds |
磁盘设备列表 | |
gpus |
显卡信息 |
salt '*' grains.item os osrelease osfinger os_family num_cpus mem_total virtual kernel
11.2 按 grains 定位机器
[root@master ~]# salt -G os:CentOS cmd.run 'hostname'
master:
master
minion1:
minion1
minion2:
minion2
11.3 自定义 grains 的三种方式
方式一:在 minion 配置文件 /etc/salt/minion 中定义
在每台 minion 的 /etc/salt/minion 末尾添加(注意键名是 role,不是 roles):
grains:
role:
- webserver
- dbserver
表示该 minion 同时属于 webserver 和 dbserver 两种角色。minion1、minion2 都配置相同内容后重启服务:
[root@minion1 ~]# systemctl restart salt-minion # CentOS 7
[root@minion1 ~]# service salt-minion restart # CentOS 6
在 master 上按角色定位:
[root@master ~]# salt -G role:webserver cmd.run 'hostname'
minion1:
minion1
minion2:
minion2
[root@master ~]# salt -G role:dbserver cmd.run 'hostname'
minion1:
minion1
minion2:
minion2
方式二:独立的 /etc/salt/grains 文件(推荐)
把自定义 grains 与 minion 主配置分离,便于自动化管理。注意这个文件里不需要写最外层的 grains: 键:
# /etc/salt/grains
role:
- webserver
- dbserver
env: prod
idc: shanghai
app_name: mall-web
nginx_worker: 4
# 生效方式(二选一)
systemctl restart salt-minion
# 或者不重启,直接刷新
salt 'minion1' saltutil.refresh_grains
也可以从 master 端远程写入:
salt 'minion1' grains.setval env prod
salt 'minion1' grains.setval role "['webserver','dbserver']"
# 这些值都会被写进 /etc/salt/grains
方式三:_grains 目录下的 Python 脚本(动态 grains)
在 master 的 file_roots 下创建 _grains 目录,放自定义 Python 模块,可以采集任意信息(如从 CMDB 拉标签、读某个文件、执行命令判断)。
mkdir -p /srv/salt/_grains
# /srv/salt/_grains/mygrains.py
# -*- coding: utf-8 -*-
import os
import subprocess
def my_custom_grains():
grains = {}
# ① 从本地文件读业务标签
tag_file = '/etc/app_role'
if os.path.exists(tag_file):
with open(tag_file) as f:
grains['app_role'] = f.read().strip()
else:
grains['app_role'] = 'unknown'
# ② 根据 CPU 核数算 nginx worker 数
try:
cpus = int(subprocess.check_output(['nproc']).strip())
except Exception:
cpus = 1
grains['nginx_worker_processes'] = cpus
# ③ 判断是否是数据库机(看有没有 mysqld)
grains['has_mysql'] = os.path.exists('/usr/sbin/mysqld') or \
os.path.exists('/usr/libexec/mysqld')
# 返回的字典就是 grains(函数名不重要,返回值才是)
return grains
同步到 minion 并生效:
# 把 _grains 目录下的模块推送到所有 minion
salt '*' saltutil.sync_grains
# 或同步所有自定义模块(_grains/_modules/_states/_returners...)
salt '*' saltutil.sync_all
# 重新采集
salt '*' saltutil.refresh_grains
# 验证
salt '*' grains.item app_role nginx_worker_processes has_mysql
三种方式对比:
| 方式 | 位置 | 生效方式 | 适用场景 |
|---|---|---|---|
| minion 配置文件 | /etc/salt/minion 的 grains: 段 |
重启 salt-minion | 少量固定标签,装机时一次写好 |
| grains 文件 | /etc/salt/grains |
重启或 refresh_grains;可用 grains.setval 远程改 |
推荐,标签多、需要动态调整 |
_grains Python 脚本 |
master 的 file_roots/_grains/ |
saltutil.sync_grains + refresh_grains |
需要计算/探测才能得到的值 |
11.4 引用 grains 的三种场景
① 命令行中引用(做匹配)
salt -G 'os_family:RedHat' pkg.install httpd
salt -C 'G@role:webserver and G@osmajorrelease:7' state.sls nginx
② SLS 文件中引用(Jinja 语法)
# 按 os_family 选择不同包名
web-server:
pkg.installed:
- name: {% if grains['os_family'] == 'RedHat' %}httpd{% else %}apache2{% endif %}
# 直接取值
/tmp/info.txt:
file.managed:
- contents: |
主机名: {{ grains['id'] }}
系统: {{ grains['os'] }} {{ grains['osrelease'] }}
CPU: {{ grains['num_cpus'] }} 核
内存: {{ grains['mem_total'] }} MB
③ 配置文件模板中引用(template: jinja)
# nginx.conf 模板片段
worker_processes {{ grains['num_cpus'] }};
server_name {{ grains['fqdn'] }};
推荐用 salt['grains.get']() 取值,可以带默认值、不会因 key 不存在而渲染失败:
{{ salt['grains.get']('role', 'default') }}
{{ salt['grains.get']('ip4_interfaces:eth0:0', '127.0.0.1') }}
{{ grains.get('num_cpus', 1) }}
11.5 grains 用于分组与差异化配置的实战
# 场景:给上海机房的 web 机器下发一套配置,北京机房另一套
# 前提:/etc/salt/grains 里有 idc: shanghai / beijing 与 role: webserver
salt -C 'G@role:webserver and G@idc:shanghai' state.sls nginx.sh
salt -C 'G@role:webserver and G@idc:beijing' state.sls nginx.bj
# 或者在一个 top.sls 里按 grains 分发(更优雅)
# /srv/salt/top.sls
base:
'G@role:webserver':
- match: compound
- nginx
'G@role:dbserver':
- match: compound
- mysql
'G@osmajorrelease:6':
- match: compound
- compat.centos6
'G@osmajorrelease:7':
- match: compound
- compat.centos7
十二、salt-call:在 Minion 本地执行与调试
salt-call 运行在 minion 本机,直接调用本地模块,不经过 master 下发(但默认仍会向 master 取 pillar 和文件)。
# 在 minion 上执行(等价于 master 端 salt 'this_minion' xxx)
[root@minion1 ~]# salt-call test.ping
[root@minion1 ~]# salt-call grains.items
[root@minion1 ~]# salt-call pillar.items
[root@minion1 ~]# salt-call state.highstate
[root@minion1 ~]# salt-call state.sls nginx
[root@minion1 ~]# salt-call cmd.run 'hostname'
12.1 --local:masterless 模式
完全不连 master,只用本机 /srv/salt 下的状态文件:
[root@minion1 ~]# salt-call --local test.ping
[root@minion1 ~]# salt-call --local state.apply
[root@minion1 ~]# salt-call --local state.sls init
[root@minion1 ~]# salt-call --local grains.items
[root@minion1 ~]# salt-call --local --file-root=/opt/mysalt state.sls init
[root@minion1 ~]# salt-call --local --pillar-root=/opt/mypillar state.apply
12.2 -l debug:排错第一命令(重点)
当 master 端执行报错但看不出原因时,登录到那台 minion 上执行 salt-call -l debug,几乎能定位 90% 的问题。
[root@minion1 ~]# salt-call -l debug state.sls nginx
[root@minion1 ~]# salt-call -l debug state.highstate
[root@minion1 ~]# salt-call -l debug pillar.items
[root@minion1 ~]# salt-call -l debug test.ping
[root@minion1 ~]# salt-call -l trace state.sls nginx # 比 debug 更详细
为什么它最好用:
- 输出完整的报错堆栈,而 master 端往往只显示一句概要(甚至只显示
Minion did not return); - 能看到 SLS 渲染前后的内容,Jinja 变量到底取到了什么值一目了然;
- 能看到 minion 向 master 请求文件/pillar 的完整过程和结果(
salt://路径对不对、pillar 有没有拿到); - 能看到 AES 认证与连接 master 的细节,认证问题直接暴露;
- 不依赖 master 的 timeout,长任务不会被截断。
# 组合技:定位某台机器 highstate 失败
[root@minion1 ~]# salt-call -l debug state.highstate test=True 2>&1 | tee /tmp/salt-debug.log
[root@minion1 ~]# grep -i -E 'error|traceback|failed' /tmp/salt-debug.log
十三、salt-run:Master 端 Runner
salt-run 执行运行在 master 本机的模块,不下发到 minion。
# ---------- manage:minion 状态管理 ----------
salt-run manage.status # 同时列出 up 和 down 的 minion
salt-run manage.up # 列出在线(能响应)的 minion
salt-run manage.down # 列出失联的 minion
salt-run manage.down removekeys=True # 列出失联并顺手删除它们的 key
salt-run manage.alived
salt-run manage.not_alived
salt-run manage.versions # 对比 master 与各 minion 版本(排查版本不一致)
salt-run manage.list_state # 列出已连接的 minion
salt-run manage.present
# ---------- jobs:任务管理 ----------
salt-run jobs.list_jobs # 列出 job cache 中的历史任务
salt-run jobs.list_jobs search_function='state.sls'
salt-run jobs.active # 当前正在运行的任务
salt-run jobs.lookup_jid 20240815103012345678 # 按 jid 查历史结果
salt-run jobs.print_job 20240815103012345678
salt-run jobs.exit_success 20240815103012345678
# ---------- 其他常用 runner ----------
salt-run state.event pretty=True # 实时监听 event bus(学 Reactor 必用)
salt-run fileserver.file_list # 列出 file_roots 中所有文件
salt-run fileserver.dir_list
salt-run fileserver.update
salt-run pillar.show_pillar minion1 # 在 master 端查看某 minion 的 pillar
salt-run cache.grains
salt-run cache.pillar
salt-run doc.runner # 列出所有 runner 及文档
salt-run survey.hash '*' cmd.run 'md5sum /etc/nginx/nginx.conf' # 按结果分组,找配置不一致的机器
salt-run network.wol 00:11:22:33:44:55
十四、salt-cp:分发文件
salt-cp 把 master 本地任意路径的文件推送到 minion(不要求文件在 file_roots 里,这是它与 cp.get_file 的区别)。
# 推送单个文件
salt-cp '*' /etc/hosts /etc/hosts
salt-cp 'minion*' /root/id_rsa.pub /root/.ssh/authorized_keys
# 推送多个文件到同一目录
salt-cp '*' /tmp/a.conf /tmp/b.conf /tmp/
# 递归推送目录(较新版本支持)
salt-cp -C 'G@role:webserver' /opt/scripts /opt/
# 配合 targeting
salt-cp -L 'minion1,minion2' /etc/resolv.conf /etc/resolv.conf
salt-cp -G 'os:CentOS' /tmp/x.sh /tmp/x.sh
salt-cp vs cp.get_file vs file.managed:
| 手段 | 文件来源 | 幂等 | 能否用模板 | 适用场景 |
|---|---|---|---|---|
salt-cp |
master 任意本地路径 | ❌ | ❌ | 临时应急分发 |
cp.get_file |
必须在 file_roots(salt://) |
❌ | ✅(template=jinja) |
命令式一次性下发 |
file.managed(状态) |
salt://、http、绝对路径 |
✅ | ✅ | 生产标准做法 |
十五、状态管理:SLS 文件基础
States 用声明式的 YAML 文件(SLS)描述 minion 应该处于的状态,由 master 统一下发。
states 教程:http://docs.saltstack.cn/topics/tutorials/starting_states.html
15.1 配置 file_roots
编辑 /etc/salt/master,打开注释:
file_roots:
base: # base 为测试环境,prod 为生产环境
- /srv/salt/
[root@master ~]# systemctl restart salt-master # CentOS 7
#[root@master ~]# service salt-master restart # CentOS 6
[root@master ~]# mkdir /srv/salt
15.2 YAML 语法要点(高频坑)
| 要点 | 说明 |
|---|---|
| 缩进必须用空格,绝对不能用 Tab | SLS 文件使用 YAML 语法,禁止使用 Tab,必须用空格缩进,否则会报 Illegal tab character / found character '\t' that cannot start any token |
| 缩进层级用 2 个空格 | 同一层级缩进必须完全一致;Salt 对具体空格数不挑,但同级必须对齐 |
| 冒号后必须有空格 | key: value ✅ ;key:value ❌(会被当成整个字符串) |
列表用 - |
短横线后必须跟一个空格 |
| 字典嵌套 | 用缩进表示层级 |
| 字符串一般不用引号 | 但含 : # { } * % 等特殊字符时必须加引号 |
| 数字型字符串要加引号 | minute: '*'、version: '1.2'、mode: '0644'(八进制前导 0 尤其要注意) |
| 布尔值 | True / False(也接受 true/yes/on,但统一写 True 更清晰) |
| 注释 | # 开头 |
| 多行字符串 | | 保留换行;> 折叠为一行 |
# 正确示例
/tmp/demo.txt:
file.managed:
- user: root
- group: root
- mode: '0644'
- contents: |
第一行
第二行
15.3 SLS 的三段式结构
每个状态声明由三部分组成:
<ID 声明>: # ① ID:全局唯一的名字;若不写 name,则 ID 就是 name
<状态模块>.<函数>: # ② 状态模块与函数,如 file.managed、pkg.installed
- <属性>: <值> # ③ 属性(参数)列表
- <属性>: <值>
例如:
httpd-config: # ① ID 声明(任意唯一名字)
file.managed: # ② 状态模块.函数
- name: /etc/httpd/conf/httpd.conf # ③ 属性
- source: salt://httpd/files/httpd.conf
- user: root
- group: root
- mode: '0644'
两种等价写法(ID 直接当 name):
# 写法 A:ID 就是文件路径,不需要 name
/etc/httpd/conf/httpd.conf:
file.managed:
- source: salt://httpd/files/httpd.conf
# 写法 B:ID 是描述性名字,用 name 指定真实路径(推荐,可读性好、便于被 require 引用)
httpd-config:
file.managed:
- name: /etc/httpd/conf/httpd.conf
- source: salt://httpd/files/httpd.conf
一个 ID 下可以挂多个状态模块(短式声明):
httpd:
pkg.installed: []
service.running:
- enable: True
- require:
- pkg: httpd
ID 必须全局唯一(同一次 highstate 内),重复会报
Detected conflicting IDs。
15.4 file_roots 与 base 环境的目录布局
推荐按功能模块建目录,每个模块一个子目录的组织方式:
/srv/salt/ ← file_roots base,即 salt:// 的根
├── top.sls ← 入口文件
├── _grains/ ← 自定义 grains 模块
├── _modules/ ← 自定义执行模块
├── _states/ ← 自定义状态模块
├── init/ ← 系统初始化模块
│ ├── init.sls ← 汇总入口(include 其他)
│ ├── dns.sls
│ ├── hosts.sls
│ ├── selinux.sls
│ ├── sysctl.sls
│ ├── limits.sls
│ ├── yum.sls
│ ├── ssh.sls
│ └── files/
│ ├── hosts
│ ├── resolv.conf
│ └── sshd_config
├── httpd/
│ ├── init.sls
│ ├── install.sls
│ ├── config.sls
│ └── files/
│ └── httpd.conf
├── nginx/
│ ├── init.sls
│ └── files/
│ └── nginx.conf.jinja
└── lnmp/
├── init.sls
└── conf/
├── local.repo
├── www.conf
├── nginx.conf
└── test.php
对应关系:
| SLS 引用写法 | 实际文件 |
|---|---|
- httpd |
/srv/salt/httpd.sls 或 /srv/salt/httpd/init.sls |
- httpd.install |
/srv/salt/httpd/install.sls |
- init.ssh |
/srv/salt/init/ssh.sls |
salt://httpd/files/httpd.conf |
/srv/salt/httpd/files/httpd.conf |
15.5 SLS 命名与 init.sls 自动查找规则
- SLS 文件引用时不写
.sls后缀; - 目录层级用
.分隔(init.ssh表示init/ssh.sls); - 引用一个目录名时,Salt 自动查找该目录下的
init.sls:- nginx会依次尝试/srv/salt/nginx.sls,找不到再找/srv/salt/nginx/init.sls; - 因此模块化组织时,把入口写成
模块名/init.sls,引用时只写模块名即可,非常整洁; - 文件名不要用
-(会与 Jinja/Python 变量名冲突的场景),推荐用_。
15.6 top.sls 的编写(三层结构)
top.sls 是高级状态(highstate)的入口,把"哪些 minion 应用哪些 SLS"关联起来。三层结构:
<环境>: # ① 第一层:环境(base/dev/prod)
'<目标匹配>': # ② 第二层:目标 minion
- <状态1> # ③ 第三层:要应用的状态列表
- <状态2>
源文档中逐步累积的 top.sls(每加一个练习就加一行):
# /srv/salt/top.sls
base:
"minion*":
- file
- dir
- user
- group
- cron
- httpd
- cmd
- script
规范写法(推荐 2 空格缩进):
# /srv/salt/top.sls
base:
'*': # 所有机器都做系统初始化
- init
'minion*': # glob 匹配
- file
- dir
'G@role:webserver': # 按 grains 匹配,需声明 match
- match: grain
- nginx
- php
'G@role:dbserver and G@idc:shanghai':
- match: compound
- mysql
'minion1,minion2':
- match: list
- monitor.zabbix_agent
'E@^web-\d+$':
- match: pcre
- nginx
web: # 节点组
- match: nodegroup
- nginx
prod: # 另一个环境
'*':
- init
match 可选值:glob(默认)、pcre、grain、grain_pcre、pillar、pillar_pcre、list、ipcidr、compound、nodegroup。
15.7 state.highstate 的执行流程
① master 端执行 salt '*' state.highstate
↓
② master 读取 file_roots 中当前环境的 top.sls
↓
③ 对每个目标 minion,计算它匹配到哪些状态列表(可用 state.show_top 查看)
↓
④ 把这些 SLS 用 Jinja 渲染(此时 grains/pillar 已可用)→ 再用 YAML 解析
↓
⑤ 解析出 low state(底层状态数据结构,可用 state.show_lowstate 查看)
↓
⑥ 依据 require/watch/order 等做拓扑排序,确定执行顺序
↓
⑦ 下发给 minion,minion 逐条执行:先判断当前是否已满足 → 不满足才变更(幂等)
↓
⑧ 结果(Result / Comment / Changes / Duration)回传 master,汇总输出
对应的调试命令:
salt 'minion1' state.show_top # 看第 ③ 步匹配结果
salt 'minion1' state.show_sls nginx --out=yaml # 看第 ④ 步渲染结果
salt 'minion1' state.show_highstate --out=yaml # 看完整渲染结果
salt 'minion1' state.show_lowstate --out=yaml # 看第 ⑤ 步
salt 'minion1' state.highstate test=True # 空跑第 ⑦ 步
salt 'minion1' state.highstate # 正式执行
15.8 state.sls 指定单个状态
调试阶段不要动辄 highstate,用 state.sls 只跑正在改的那个模块:
salt 'minion*' state.sls file # 只执行 file.sls
salt 'minion*' state.sls httpd.config # 只执行 httpd/config.sls
salt 'minion*' state.sls nginx,php # 执行多个
salt 'minion*' state.sls nginx saltenv=prod # 指定环境
salt 'minion*' state.sls nginx test=True # 空跑
state.sls不看 top.sls,直接执行你指定的 SLS;state.highstate才依赖 top.sls。所以"写好了 SLS 但 highstate 没执行",八成是忘了往 top.sls 里加。
十六、常用状态模块详解
16.1 pkg:软件包状态
参考:http://docs.saltstack.cn/ref/states/all/salt.states.pkg.html
# ① 安装单个包
httpd:
pkg.installed: []
# ② 安装多个包(names,源文档写法)
httpd-install:
pkg.installed:
- names:
- httpd
- httpd-devel
# ③ 安装多个包(pkgs 列表,可为每个包单独指定版本,推荐)
web-packages:
pkg.installed:
- pkgs:
- httpd
- httpd-devel
- php
- php-mysql
version-pinned-packages:
pkg.installed:
- pkgs:
- httpd: 2.4.6-97.el7.centos
- php: '5.4.16'
# ④ 安装指定版本
nginx-fixed:
pkg.installed:
- name: nginx
- version: 1.16.1
# ⑤ 始终保持最新版本(每次 highstate 都会检查升级,慎用于生产)
nginx-latest:
pkg.latest:
- name: nginx
- refresh: True # 先刷新 yum 缓存
# ⑥ 确保包被卸载
telnet-server-removed:
pkg.removed:
- name: telnet-server
old-packages-removed:
pkg.removed:
- pkgs:
- telnet-server
- rsh-server
- ypserv
# ⑦ 卸载并清除配置
sendmail-purged:
pkg.purged:
- name: sendmail
# ⑧ 保持 yum 缓存最新
refresh-repo:
pkg.uptodate:
- refresh: True
# ⑨ 安装包组
devtools:
pkg.group_installed:
- name: 'Development Tools'
| 函数 | 作用 |
|---|---|
pkg.installed |
确保已安装(最常用,已装则不动,幂等) |
pkg.latest |
确保是仓库最新版(会主动升级) |
pkg.removed |
确保已卸载 |
pkg.purged |
卸载并删除配置文件 |
pkg.uptodate |
全系统包更新 |
pkg.group_installed |
安装包组 |
16.2 file:文件与目录状态
参考:http://docs.saltstack.cn/ref/states/all/salt.states.file.html
file.managed:管理单个文件(最重要的状态之一)
# 例 1:创建文件并写入内容(源文档写法一)
/tmp/itjiangshi_1:
file.managed:
- contents:
haha
# 例 1 变体(源文档写法二,contents 为列表,每项一行)
/tmp/itjiangshi_1:
file.managed:
- contents:
- haha
# 例 3:下发 master 上的文件并指定权限(source 用 master 绝对路径)
/tmp/itjiangshi_2:
file.managed:
- source: /etc/fstab
- user: bin
- group: daemon
- mode: 0600
完整参数示范:
nginx-config:
file.managed:
- name: /etc/nginx/nginx.conf # 目标路径(不写则用 ID)
- source: salt://nginx/files/nginx.conf # 源文件(salt:// / http:// / ftp:// / master 绝对路径)
- template: jinja # ★ 启用 Jinja 渲染,才能用变量
- user: root # 属主
- group: root # 属组
- mode: '0644' # 权限(建议加引号,避免八进制被解析成十进制)
- backup: minion # ★ 修改前在 minion 上备份旧文件
- makedirs: True # 父目录不存在则创建
- show_changes: True # 输出 diff
- source_hash: md5=5f2f... # 校验源文件(从 http 拉取时用)
- defaults: # 模板默认变量
worker: 4
port: 80
- context: # 模板变量(优先级高于 defaults)
server_name: www.example.com
- contents_pillar: nginx:conf_content # 直接用 pillar 里的内容作为文件内容
- replace: True # False 表示只在文件不存在时创建,已存在不覆盖
- create: True # False 表示文件不存在时不创建(只管权限)
关于 backup: minion:修改前把原文件备份到 minion 的 /var/cache/salt/minion/file_backup/,可用以下命令查看和恢复:
salt 'minion1' file.list_backups /etc/nginx/nginx.conf
salt 'minion1' file.restore_backup /etc/nginx/nginx.conf 0
file.absent:确保文件/目录不存在
# 源文档写法(ID 随意,用 name 指定目标)
sdfdsafsa:
file.absent:
- name: /tmp/itjiangshi_1
# 简写
/tmp/itjiangshi_1:
file.absent
# 规范写法
remove-old-file:
file.absent:
- name: /tmp/itjiangshi_1
file.directory:管理目录
# 源文档例 4:创建目录
/tmp/dir1:
file.directory:
- user: daemon
- group: daemon
- dir_mode: 0700
完整参数:
/data/logs:
file.directory:
- user: nginx
- group: nginx
- dir_mode: '0755' # 目录权限
- file_mode: '0644' # 目录内文件权限(配合 recurse)
- makedirs: True # ★ 递归创建父目录(/data 不存在也能建)
- recurse: # ★ 递归应用属性到子目录/子文件
- user
- group
- mode
- clean: False # ★ True 表示删除目录中不被 salt 管理的文件(危险,慎用)
- exclude_pat: '*.log' # clean 时排除的模式
file.recurse:递归下发整个目录
# 源文档例 5(ID 是 /tmp/dir1,但用 name 覆盖为 /tmp/haha_dir)
/tmp/dir1:
file.recurse:
- name: /tmp/haha_dir
- source: salt://test_dir
- user: root
- dir_mode: 0700
- file_mode: 0600
- mkdir: True
- clean: True
# 另一份源文档的等价写法(ID 直接是目标路径,更清晰)
/tmp/haha_dir:
file.recurse:
- source: salt://test_dir
- user: root
- dir_mode: 0700
- file_mode: 0600
- mkdir: True
- clean: True
准备源目录:
[root@master ~]# mkdir /srv/salt/test_dir
[root@master ~]# echo haha > /srv/salt/test_dir/1.txt
# 将 /srv/salt/test_dir 同步到 minion 的 /tmp/haha_dir 并修改权限
[root@master ~]# salt "minion*" state.sls dir
file.recurse 还支持模板渲染整个目录:
/opt/scripts:
file.recurse:
- source: salt://scripts/files
- template: jinja
- include_empty: True
- file_mode: '0755'
- exclude_pat: '*.md'
file.symlink:软链接
/usr/local/nginx:
file.symlink:
- target: /usr/local/nginx-1.20.1
- force: True # 目标存在时强制替换
- makedirs: True
file.append:追加内容(源文档 LNMP 中使用)
/etc/security/limits.conf:
file.append:
- text:
- nginx soft nofile 65536
- nginx hard nofile 65536
file.append是幂等的:内容已存在就不再追加。
file.line:精确操作单行(推荐用于改配置)
sshd-port:
file.line:
- name: /etc/ssh/sshd_config
- content: 'Port 2222'
- match: '^#?Port '
- mode: replace # ensure/replace/delete/insert
- backup: '.bak'
add-line-after:
file.line:
- name: /etc/hosts
- content: '10.1.1.5 minion3'
- mode: ensure # 不存在则加,存在则不动(最幂等)
- after: '10.1.1.4 minion2'
mode 可选值:
| mode | 含义 |
|---|---|
ensure |
确保这行存在(配合 before/after 定位插入位置) |
replace |
匹配到的行替换成 content |
delete |
删除匹配的行 |
insert |
在指定位置插入(需 location: start/end 或 before/after) |
file.replace:正则替换
disable-root-login:
file.replace:
- name: /etc/ssh/sshd_config
- pattern: '^#?PermitRootLogin\s+.*$'
- repl: 'PermitRootLogin no'
- append_if_not_found: True # 没匹配到就追加到文件末尾
- backup: '.bak'
- show_changes: True
- count: 0 # 0 表示替换所有匹配
file.blockreplace:替换标记之间的整块内容(管理片段配置的最佳实践)
manage-hosts-block:
file.blockreplace:
- name: /etc/hosts
- marker_start: '# SALT MANAGED BLOCK START'
- marker_end: '# SALT MANAGED BLOCK END'
- source: salt://init/files/hosts_block
- template: jinja
- append_if_not_found: True # 标记不存在则追加标记块
- backup: '.bak'
- show_changes: True
生成的 /etc/hosts 效果(标记外的手工内容不会被破坏):
127.0.0.1 localhost
# 这是运维手工加的,salt 不会动
# SALT MANAGED BLOCK START
10.1.1.2 master
10.1.1.3 minion1
10.1.1.4 minion2
# SALT MANAGED BLOCK END
file 常用函数速查
| 函数 | 作用 |
|---|---|
file.managed |
管理单个文件(内容/权限/属主),支持模板 |
file.directory |
管理目录 |
file.recurse |
递归下发整个目录 |
file.symlink |
软链接 |
file.absent |
确保不存在 |
file.append |
追加行(幂等) |
file.prepend |
在文件开头插入行 |
file.line |
精确操作单行 |
file.replace |
正则替换 |
file.blockreplace |
替换标记块 |
file.copy |
minion 本机复制 |
file.rename |
重命名/移动 |
file.touch |
创建空文件 |
file.uncomment / file.comment |
取消注释 / 注释掉某行 |
file.serialize |
用数据结构生成 json/yaml 配置文件 |
16.3 service:服务状态
参考:http://docs.saltstack.cn/ref/states/all/salt.states.service.html
# 源文档写法:确保 httpd 运行且开机自启
httpd-run:
service.running:
- name: httpd
- enable: True
完整参数与 watch 联动:
nginx-service:
service.running:
- name: nginx
- enable: True # 开机自启(False 表示禁止自启)
- reload: True # ★ watch 触发时用 reload 而不是 restart(不中断连接)
- sig: nginx # 用进程名判断是否运行(针对非标准服务)
- init_delay: 3 # 启动后等待 3 秒再判定
- watch: # ★ 被监视对象发生变化时自动重启/重载
- file: nginx-config
- pkg: nginx-install
# 确保服务停止且禁止自启
firewalld-dead:
service.dead:
- name: firewalld
- enable: False
# 只管自启,不管当前运行状态
crond-enabled:
service.enabled:
- name: crond
postfix-disabled:
service.disabled:
- name: postfix
| 函数 | 作用 |
|---|---|
service.running |
确保服务正在运行(最常用,配 enable 与 watch) |
service.dead |
确保服务已停止 |
service.enabled |
确保开机自启(不改变当前运行状态) |
service.disabled |
确保禁止自启 |
service.masked |
systemd 的 mask(CentOS 7+) |
CentOS 6 与 7 的差异:状态模块 service.running 两版通用,Salt 内部会自动选择 systemd(CentOS 7)或 SysV init(CentOS 6)。但要注意:
- CentOS 6 上服务名以
/etc/init.d/下的脚本名为准(如mysqld); - CentOS 7 上以 unit 名为准(如
mariadb、nginx); - 服务名在两版可能不同(
mysqldvsmariadb、iptablesvsfirewalld),需要用 Jinja 按osmajorrelease分支,见第十八章示例。
16.4 cmd:命令状态与三种幂等守卫
参考:http://docs.saltstack.cn/ref/states/all/salt.states.cmd.html
源文档示例(/srv/salt/cmd.sls):
cmd-test1:
cmd.run:
- onlyif: test -f /tmp/haha
- name:
echo itjiangshi.com >> /tmp/haha
- user: root
cmd-test2:
cmd.run:
- unless: test -e /tmp/hehe
- name:
echo itjiangshi.com > /tmp/hehe
- user: root
onlyif:命令返回真(文件存在)才执行;unless:命令返回假(文件不存在)才执行。
[root@master ~]# salt "minion*" state.sls cmd
三种幂等守卫对比(cmd.run 必须加,否则每次 highstate 都重复执行):
| 守卫 | 语义 | 典型用法 |
|---|---|---|
onlyif |
守卫命令成功(退出码 0)时,才执行本状态 | 前置条件满足才做:onlyif: test -f /etc/xx.conf |
unless |
守卫命令失败(退出码非 0)时,才执行本状态 | 已完成就跳过:unless: rpm -q nginx |
creates |
指定的文件/目录不存在时才执行;已存在则跳过 | 编译安装、解压:creates: /usr/local/nginx/sbin/nginx |
# ① unless:包已装就不再编译
compile-nginx:
cmd.run:
- name: |
cd /usr/local/src/nginx-1.20.1 &&
./configure --prefix=/usr/local/nginx &&
make && make install
- unless: test -x /usr/local/nginx/sbin/nginx
- cwd: /usr/local/src
- user: root
- shell: /bin/bash
# ② creates:更直观的写法,效果同上
compile-nginx-2:
cmd.run:
- name: 'cd /usr/local/src/nginx-1.20.1 && ./configure --prefix=/usr/local/nginx && make && make install'
- creates: /usr/local/nginx/sbin/nginx
# creates 支持列表(全部存在才跳过)
init-db:
cmd.run:
- name: /usr/bin/mysql_install_db --user=mysql
- creates:
- /var/lib/mysql/mysql
- /var/lib/mysql/ibdata1
# ③ onlyif:只在是 web 角色的机器上执行
warmup-cache:
cmd.run:
- name: /opt/scripts/warmup.sh
- onlyif: test -f /opt/scripts/warmup.sh
# ④ cmd.wait:★ 平时什么都不做,只在被 watch 的对象变化时才执行
reload-app:
cmd.wait:
- name: /opt/app/bin/reload.sh
- watch:
- file: /opt/app/conf/app.yml
# ⑤ cmd.script:下发并执行 master 上的脚本
shell_test:
cmd.script:
- source: salt://test_dir/1.sh # 对应 master 上准备好的脚本
- user: root
deploy-script:
cmd.script:
- source: salt://scripts/deploy.sh
- template: jinja # 脚本里也能用 grains/pillar 变量
- args: 'v1.2.3 prod'
- cwd: /opt
- user: root
- creates: /opt/app/v1.2.3 # 幂等守卫
master 上准备的脚本 /srv/salt/test_dir/1.sh:
#!/bin/bash
mkdir /test -p
touch /test/{1..10}
执行:
[root@master ~]# salt "minion*" state.sls script
扩展:不写 state,直接命令执行 master 上的脚本(命令在 master 上发起,脚本在被管机器上执行):
[root@master ~]# salt "minion*" cmd.script salt://test_dir/1.sh
| 函数 | 作用 |
|---|---|
cmd.run |
执行命令(必须加幂等守卫) |
cmd.wait |
平时不执行,只被 watch 触发时执行 |
cmd.script |
下发并执行脚本 |
cmd.wait_script |
被 watch 触发时才执行脚本 |
cmd.call |
调用一个执行模块函数作为状态 |
16.5 user 与 group 状态
参考:
源文档 /srv/salt/user.sls:
user2:
user.present:
- fullname: itjiangshi_daniel
- shell: /bin/bash
- home: /home/user2
- uid: 5000
- gid: 5000 # 如果不存在 gid=5000 的组,请去掉这句
- groups:
- root
- bin
- daemon
user1:
user.absent:
- purge: true # 删除用户家目录、邮件等数据
另一份源文档的简化版(去掉了 gid,避免组不存在报错):
user2:
user.present:
- fullname: itjiangshi_daniel
- shell: /bin/bash
- home: /home/user2
- uid: 5000
- groups:
- root
- bin
user1:
user.absent:
- purge: true
[root@master ~]# salt "minion*" state.sls user
完整参数:
deploy-user:
user.present:
- name: deploy
- fullname: Deploy User
- uid: 6001
- gid: 6001
- gid_from_name: True # 自动创建同名主组
- home: /home/deploy
- createhome: True
- shell: /bin/bash
- password: '$6$rounds=656000$xxxxxxxx' # 加密后的密码串(用 pillar 存)
- password_from_pillar: True
- enforce_password: True # 每次都强制同步密码
- groups: # 附加组(会覆盖为这个列表)
- wheel
- docker
- optional_groups: # 附加组(组不存在时不报错)
- nginx
- remove_groups: False # False 表示不移除列表外的已有附加组
- expire: 20000 # 账号过期日(自 1970 的天数)
- require:
- group: deploy-group
源文档 /srv/salt/group.sls:
group1:
group.present:
- gid: 6002
- system: True
- addusers:
- bin
- daemon
group2:
group.present:
- gid: 7000
- members:
- root
- lp
[root@master ~]# salt "minion*" state.sls group
addusers:追加这些用户到组(不影响其他成员);members:覆盖设置组成员(列表外的成员会被移除);delusers:从组中移除指定用户;system: True:创建系统组(gid 取系统区间)。
16.6 cron:定时任务状态
参考:http://docs.saltstack.cn/ref/states/all/salt.states.cron.html
源文档 /srv/salt/cron.sls:
cron_jobs:
cron.present:
- name: date +'%F %T' >> /tmp/haha
- user: root
- minute: '*'
- hour: 11
[root@master ~]# salt "minion*" state.sls cron
完整参数:
backup-database:
cron.present:
- name: /opt/scripts/backup.sh > /var/log/backup.log 2>&1
- user: root
- minute: '30'
- hour: '2'
- daymonth: '*'
- month: '*'
- dayweek: '*'
- identifier: db-backup-job # ★ 唯一标识,改命令内容时靠它定位旧任务
- comment: '每天凌晨 2:30 备份数据库'
# 删除定时任务
remove-old-cron:
cron.absent:
- name: /opt/scripts/old.sh
- user: root
- identifier: old-job
# 管理环境变量
cron-mailto:
cron.env_present:
- name: MAILTO
- value: ops@example.com
- user: root
重要:不加
identifier时,Salt 用命令内容本身做标识,改了命令就会新增一条任务而不是修改原有的,导致重复。生产中一律加identifier。
16.7 mount:挂载状态
mount-data-disk:
mount.mounted:
- name: /data # 挂载点
- device: /dev/sdb1 # 设备(也可用 UUID=xxx 或 LABEL=xxx)
- fstype: xfs
- mkmnt: True # 挂载点不存在则创建
- opts:
- defaults
- noatime
- dump: 0
- pass_num: 2
- persist: True # ★ 同时写入 /etc/fstab(默认 True)
mount-nfs:
mount.mounted:
- name: /mnt/nfs
- device: 10.1.1.1:/data/share
- fstype: nfs
- mkmnt: True
- opts: 'rw,soft,intr,rsize=8192,wsize=8192'
# 卸载并从 fstab 移除
umount-old:
mount.unmounted:
- name: /mnt/old
- persist: True
16.8 archive:解压归档
extract-tomcat:
archive.extracted:
- name: /usr/local/ # 解压目标目录
- source: salt://pkg/apache-tomcat-9.0.65.tar.gz # 也可用 http:// URL
- source_hash: md5=8f2c3b1a... # 从 URL 拉取时建议校验
- archive_format: tar
- options: z # tar 的压缩选项
- enforce_toplevel: False
- keep_source: True
- user: tomcat
- group: tomcat
- if_missing: /usr/local/apache-tomcat-9.0.65 # ★ 幂等守卫:该路径存在就不解压
extract-from-url:
archive.extracted:
- name: /opt/app
- source: https://example.com/app-1.2.3.tar.gz
- source_hash: https://example.com/app-1.2.3.tar.gz.sha256
- if_missing: /opt/app/app-1.2.3
16.9 git:代码部署
deploy-app-code:
git.latest:
- name: git@gitlab.example.com:web/app.git # 仓库地址
- target: /data/www/app # 本地目录
- rev: master # 分支/tag/commit
- branch: master
- user: deploy # 以哪个用户执行 git
- identity: /home/deploy/.ssh/id_rsa # SSH 私钥
- force_reset: True # 本地有改动时强制 reset
- force_checkout: True
- submodules: True
- require:
- pkg: git
git-installed:
pkg.installed:
- name: git
# 只 clone 一次,之后不更新
clone-once:
git.cloned:
- name: https://github.com/xxx/yyy.git
- target: /opt/yyy
- branch: main
详见 14-01-Git版本控制.md
十七、状态间的依赖关系
Salt 默认按 SLS 文件中的书写顺序执行状态,但一旦跨文件、跨 include,顺序就不可靠了。生产中必须显式声明依赖。
17.1 require / require_in:依赖
require 表示"我依赖它,它必须先成功执行"。被依赖状态失败时,本状态不执行(结果为 False,Comment 显示 One or more requisite failed)。
# 标准写法:pkg → file → service 三段式
httpd-install:
pkg.installed:
- name: httpd
httpd-config:
file.managed:
- name: /etc/httpd/conf/httpd.conf
- source: salt://httpd/files/httpd.conf
- require:
- pkg: httpd-install # 格式:- <状态模块>: <被依赖状态的 ID>
httpd-service:
service.running:
- name: httpd
- enable: True
- require:
- pkg: httpd-install
- file: httpd-config
require_in 是反向声明(我被谁依赖),适合"不想改别人的文件,但要插到它前面"的场景:
# 等价于在 httpd-config 里写 require: - pkg: httpd-install
httpd-install:
pkg.installed:
- name: httpd
- require_in:
- file: httpd-config
- service: httpd-service
跨 SLS 引用:ID 是全局唯一的,直接写 ID 即可(不需要写 SLS 名)。
17.2 watch / watch_in:监视并触发(改配置自动重启服务的经典模式)
watch = require + "被监视对象发生变化时,额外触发一个动作"。
- 对
service.running:被监视文件变化 → 自动 restart(若设reload: True则 reload); - 对
cmd.wait:被监视对象变化 → 才执行命令,否则完全不执行。
# ★ 最经典的模式:配置文件一变,服务自动重启
nginx-install:
pkg.installed:
- name: nginx
nginx-config:
file.managed:
- name: /etc/nginx/nginx.conf
- source: salt://nginx/files/nginx.conf.jinja
- template: jinja
- backup: minion
- require:
- pkg: nginx-install
nginx-vhost:
file.recurse:
- name: /etc/nginx/conf.d
- source: salt://nginx/files/conf.d
- template: jinja
- require:
- pkg: nginx-install
nginx-service:
service.running:
- name: nginx
- enable: True
- reload: True # ★ 用 reload 而不是 restart,业务不中断
- watch: # ★ 任一变化就触发 reload
- file: nginx-config
- file: nginx-vhost
执行效果:
- 第一次执行:装包 → 下发配置 → 启动服务;
- 第二次执行(配置未变):三个状态都
Result: True,Changes: {},服务不重启; - 改了 master 上的
nginx.conf.jinja再执行:nginx-config有 Changes →nginx-service被触发 reload。
watch_in 反向写法:
nginx-config:
file.managed:
- name: /etc/nginx/nginx.conf
- source: salt://nginx/files/nginx.conf
- watch_in:
- service: nginx-service # 我变了就通知 nginx-service
nginx-service:
service.running:
- name: nginx
- enable: True
watch_in的巨大好处:在一个通用模块里定义好 service,各业务模块自己watch_in进来,不需要反复修改 service 定义。
17.3 onchanges / onchanges_in
只在被监视状态确实发生变化时才执行;被监视状态无变化则本状态跳过(Result: True, 但标记为 skipped)。
与 watch 的区别:watch 隐含 require(被监视状态必须先执行成功),而 onchanges 不隐含依赖,且它作用于任意状态(不只是 service/cmd.wait)。
app-code:
git.latest:
- name: git@git.example.com:web/app.git
- target: /data/www/app
# 只有代码真的更新了,才编译前端资源
build-frontend:
cmd.run:
- name: cd /data/www/app && npm run build
- onchanges:
- git: app-code
# 只有代码真的更新了,才清理缓存
clear-cache:
cmd.run:
- name: rm -rf /data/www/app/cache/*
- onchanges:
- git: app-code
17.4 onfail / onfail_in
被监视状态失败时才执行——用来做失败告警和自动回滚。
deploy-app:
cmd.run:
- name: /opt/scripts/deploy.sh v1.2.3
- creates: /opt/app/v1.2.3
rollback-on-failure:
cmd.run:
- name: /opt/scripts/rollback.sh
- onfail:
- cmd: deploy-app
alert-on-failure:
cmd.run:
- name: 'curl -X POST https://alert.example.com/hook -d "deploy failed on {{ grains["id"] }}"'
- onfail:
- cmd: deploy-app
17.5 prereq / prereq_in
prereq 用于"只有当后面某个状态将会发生变化时,我才提前执行",典型场景是发布前先把机器从负载均衡摘掉。
Salt 的处理流程:先对 prereq 指向的状态做一次 test=True 空跑,如果空跑显示它会有变化,才执行本状态。
# 如果代码将要更新,就先把本机从 LB 摘掉
remove-from-lb:
cmd.run:
- name: /opt/scripts/lb_offline.sh
- prereq:
- git: app-code
app-code:
git.latest:
- name: git@git.example.com:web/app.git
- target: /data/www/app
# 更新完再加回去
add-to-lb:
cmd.run:
- name: /opt/scripts/lb_online.sh
- onchanges:
- git: app-code
17.6 use / use_in:复用属性
把另一个状态的属性"继承"过来,避免重复书写(不含 require/watch 等依赖属性)。
base-file-attrs:
file.managed:
- user: nginx
- group: nginx
- mode: '0644'
- template: jinja
/etc/nginx/conf.d/a.conf:
file.managed:
- source: salt://nginx/files/a.conf
- use:
- file: base-file-attrs # 继承 user/group/mode/template
/etc/nginx/conf.d/b.conf:
file.managed:
- source: salt://nginx/files/b.conf
- use:
- file: base-file-attrs
17.7 order:强制执行顺序
# order 数字越小越先执行;order: 1 表示最先
setup-yum-repo:
file.managed:
- name: /etc/yum.repos.d/local.repo
- source: salt://init/files/local.repo
- order: 1 # ★ 必须最先执行,否则后面装包全失败
install-base-tools:
pkg.installed:
- pkgs: [vim, wget, net-tools]
- order: 2
# order: last 表示最后执行
final-report:
cmd.run:
- name: 'echo "init done at $(date)" >> /var/log/salt-init.log'
- order: last
order只是"排序提示",不能替代require。require表达的是逻辑依赖(失败会阻断),order只影响先后。生产中优先用require,order只用于"必须最先做的全局前置"(如配 yum 源)。
17.8 include:引入其他 SLS
# /srv/salt/lnmp/init.sls
include:
- init.yum # 引入 /srv/salt/init/yum.sls
- nginx # 引入 /srv/salt/nginx/init.sls
- php.fpm # 引入 /srv/salt/php/fpm.sls
- mysql
# include 进来的状态与本文件的状态一起执行,ID 处于同一命名空间
lnmp-check:
cmd.run:
- name: 'curl -s http://127.0.0.1/test.php'
- require:
- service: nginx-service # 可以直接 require include 进来的 ID
- service: php-fpm-service
相对路径引入(. 表示当前目录):
# /srv/salt/nginx/init.sls
include:
- .install # = nginx.install
- .config # = nginx.config
- .service # = nginx.service
17.9 extend:覆盖被引入状态的属性
在不修改原 SLS 的前提下,扩展/覆盖它的属性。
# /srv/salt/nginx/init.sls (通用模块,不要动它)
nginx-config:
file.managed:
- name: /etc/nginx/nginx.conf
- source: salt://nginx/files/nginx.conf
- mode: '0644'
nginx-service:
service.running:
- name: nginx
- enable: True
# /srv/salt/web/prod.sls ← 生产环境定制
include:
- nginx
extend:
# 覆盖 source,换成生产专用配置
nginx-config:
file:
- source: salt://web/files/nginx-prod.conf
- mode: '0640'
# 给 service 补上 watch 与 reload
nginx-service:
service:
- reload: True
- watch:
- file: nginx-config
extend 的语义规则:
| 属性类型 | extend 的行为 |
|---|---|
标量(如 source、mode) |
覆盖 |
列表(如 require、watch、pkgs) |
追加(不是覆盖) |
注意:
extend必须配合include使用,且只能扩展 include 进来的 SLS 中已存在的 ID,否则报Cannot extend ID ... it is not part of the high state。
17.10 依赖写法对照表
| 关键字 | 语义 | 触发条件 | 隐含 require | 常用场合 |
|---|---|---|---|---|
require |
我依赖它先成功 | 它成功 → 我执行;它失败 → 我不执行 | — | pkg→file→service |
require_in |
它依赖我(反向) | 同上 | — | 不改对方文件时用 |
watch |
依赖 + 监视变化 | 它变化 → 我额外做动作(service 重启) | ✅ | 改配置自动重启服务 |
watch_in |
反向 watch | 同上 | ✅ | 通用 service + 各业务 watch_in |
onchanges |
它变化我才执行 | 它有 Changes → 我执行;无变化 → 我跳过 | ❌ | 代码更新后才编译/清缓存 |
onchanges_in |
反向 onchanges | 同上 | ❌ | |
onfail |
它失败我才执行 | 它 Result: False → 我执行 | ❌ | 回滚、告警 |
onfail_in |
反向 onfail | 同上 | ❌ | |
prereq |
它"将要"变化我才提前执行 | 空跑显示它有变化 → 我先执行 | ❌ | 发布前摘 LB |
prereq_in |
反向 prereq | 同上 | ❌ | |
use |
复用它的属性 | — | ❌ | 减少重复参数 |
use_in |
反向 use | — | ❌ | |
order |
排序提示 | — | ❌ | 全局最先/最后执行 |
unless / onlyif / creates |
幂等守卫 | 见 16.4 | ❌ | cmd.run 必备 |
引用格式统一为:
- require:
- <状态模块名>: <被依赖状态的 ID>
# 例如: - pkg: nginx-install
# - file: /etc/nginx/nginx.conf ← ID 是路径时直接写路径
# - sls: init.yum ← 依赖整个 SLS 全部完成
依赖整个 SLS:
nginx-service:
service.running:
- name: nginx
- require:
- sls: init.yum # init/yum.sls 中所有状态都成功后才执行
17.11 依赖环的报错定位
写成互相依赖就会形成环,Salt 报错:
Recursive requisite found
# 或
Recursive ordering detected in the state: xxx
# 或
Data failed to compile:
----------
Recursive requisite found
典型成因与排查方法:
# ❌ 环:A require B,B 又 require A
state-a:
cmd.run:
- name: echo a
- require:
- cmd: state-b
state-b:
cmd.run:
- name: echo b
- require:
- cmd: state-a
排查步骤:
# ① 用 show_lowstate 看完整依赖数据,人工检查环
salt 'minion1' state.show_lowstate --out=yaml | grep -A3 -E 'require|watch'
# ② 用 show_sls 单个文件排查,缩小范围
salt 'minion1' state.show_sls nginx --out=yaml
# ③ 在 minion 上 debug,报错信息会指出涉及的 ID
salt-call -l debug state.highstate
# ④ 逐个注释掉 require,二分定位
常见隐蔽环:
- 一个 SLS
include了 B,B 又include了这个 SLS(include 循环); - 用
require_in与require混写,同一对状态被声明了两个方向的依赖; - sls: xxx依赖了包含自己的 SLS。
十八、Pillar 详解
Pillar 是 master 端定义、下发给指定 minion 的变量数据,常用于存放敏感信息(密码、端口、配置路径等)。
18.1 与 Grains 的本质区别
- Grains 在 minion 端采集,描述"机器是什么",所有 minion 的 grains 都是公开可查的;
- Pillar 在 master 端定义,通过
top.sls按 minion 定向分配,每个 minion 只能看到分配给自己的那一份。
正因为这种"定向 + 隔离"的特性,Pillar 是存放以下数据的正确位置:
- 敏感数据:数据库密码、Redis 密码、API Key、证书私钥、用户密码哈希;
- 差异化数据:每台机器不同的端口、域名、后端 IP 列表、worker 数、环境标识;
- 业务参数:版本号、部署路径、监控 server 地址。
反例:把数据库密码写进 grains 或直接硬编码在 SLS 里 —— 前者所有 minion 都能查到,后者提交到 Git 就泄露了。
18.2 初始查看(默认为空)
[root@master ~]# salt "minion*" pillar.items
minion1:
----------
minion2:
----------
18.3 配置 pillar_roots
编辑 /etc/salt/master,打开以下注释:
pillar_roots:
base:
- /srv/pillar
创建目录:
[root@master ~]# mkdir /srv/pillar
[root@master ~]# systemctl restart salt-master
18.4 编写 pillar 的 top.sls
/srv/pillar/top.sls(源文档写法):
base:
"minion*":
- itjiangshi
规范写法(与 file_roots 的 top.sls 结构完全一样,也支持 match):
# /srv/pillar/top.sls
base:
'*': # 所有机器都能拿到的公共数据
- common
'minion*':
- itjiangshi
'G@role:webserver':
- match: grain
- web
- secrets.web # /srv/pillar/secrets/web.sls
'G@role:dbserver':
- match: grain
- db
- secrets.db
'minion1':
- hosts.minion1 # 单机专属数据
18.5 编写 pillar 数据
/srv/pillar/itjiangshi.sls(源文档):
name: test_service
conf: /etc/itjiangshi.conf
嵌套结构示例(生产常用):
# /srv/pillar/web.sls
nginx:
version: 1.20.1
port: 80
worker_processes: auto
worker_connections: 10240
server_name: www.example.com
gzip: True
upstreams:
- 10.1.1.10:8080
- 10.1.1.11:8080
log:
access: /var/log/nginx/access.log
error: /var/log/nginx/error.log
level: warn
php:
version: '7.4'
fpm:
max_children: 50
max_requests: 500
users:
- name: deploy
uid: 6001
shell: /bin/bash
- name: monitor
uid: 6002
shell: /sbin/nologin
# /srv/pillar/secrets/db.sls ← 敏感数据单独放
mysql:
root_password: 'Str0ngR00tPwd!'
repl_user: repl
repl_password: 'Repl@Pwd123'
backup_user: backup
backup_password: 'Backup@Pwd456'
# 用户密码哈希(用 salt '*' shadow.gen_password 'xxx' 生成)
user_passwords:
deploy: '$6$rounds=656000$abcdefgh$xxxxxxxxxxxxxxxxxxxxxxxxxx'
monitor: '$6$rounds=656000$ijklmnop$yyyyyyyyyyyyyyyyyyyyyyyyyy'
Pillar 文件里也能用 Jinja(可以引用 grains,实现"根据机器属性算出下发值"):
# /srv/pillar/web.sls
{% if grains['num_cpus'] >= 8 %}
nginx_worker: 8
{% else %}
nginx_worker: {{ grains['num_cpus'] }}
{% endif %}
idc: {{ grains.get('idc', 'unknown') }}
monitor_server: {% if grains.get('idc') == 'shanghai' %}10.1.1.100{% else %}10.2.1.100{% endif %}
18.6 刷新并验证
[root@master ~]# salt "minion*" saltutil.refresh_pillar
minion1:
True
minion2:
True
[root@master ~]# salt "minion*" pillar.items
minion1:
----------
conf:
/etc/itjiangshi.conf
name:
test_service
minion2:
----------
conf:
/etc/itjiangshi.conf
name:
test_service
只查看某个 key:
[root@master ~]# salt "minion*" pillar.items conf
minion1:
----------
conf:
/etc/itjiangshi.conf
minion2:
----------
conf:
/etc/itjiangshi.conf
其他常用查看命令:
salt '*' saltutil.refresh_pillar # ★ 改了 pillar 必须刷新(否则 minion 用旧缓存)
salt '*' saltutil.refresh_pillar wait=True # 等待刷新完成
salt '*' pillar.items # 全部 pillar
salt '*' pillar.item nginx # 指定一项
salt '*' pillar.get nginx:port # ★ 取嵌套值(用 : 分隔层级)
salt '*' pillar.get nginx:log:level
salt '*' pillar.get 'nginx:port' 8080 # 带默认值
salt '*' pillar.raw # 不经过 renderer 的原始数据
salt '*' pillar.keys nginx
salt '*' pillar.ls
salt-run pillar.show_pillar minion1 # 在 master 端直接算出某 minion 的 pillar(不下发)
salt-run pillar.show_pillar minion1 --out=yaml
18.7 在 SLS 与模板中取值的两种方式
方式一:pillar['key'](字典下标)
nginx-config:
file.managed:
- name: /etc/nginx/nginx.conf
- source: salt://nginx/files/nginx.conf.jinja
- template: jinja
- mode: {{ pillar['nginx']['mode'] }}
方式二:salt['pillar.get']('key', '默认值')(推荐)
nginx-config:
file.managed:
- name: /etc/nginx/nginx.conf
- source: salt://nginx/files/nginx.conf.jinja
- template: jinja
- mode: {{ salt['pillar.get']('nginx:mode', '0644') }}
- user: {{ salt['pillar.get']('nginx:user', 'root') }}
为什么 pillar.get 更安全(重点):
| 对比项 | pillar['nginx']['port'] |
salt['pillar.get']('nginx:port', 80) |
|---|---|---|
| key 不存在时 | 直接渲染失败,报 Jinja variable 'dict object' has no attribute 'nginx',整个 SLS 编译不了 |
返回默认值,正常渲染 |
| 嵌套写法 | 层层下标,['a']['b']['c'] 冗长 |
'a:b:c' 一行搞定 |
| 默认值 | 需自己写 {% if %} 判断 |
第二个参数就是默认值 |
| 部分机器没配该 pillar | highstate 直接崩,影响所有机器 | 各机器各取所需,互不影响 |
结论:SLS 与模板中一律用
salt['pillar.get']('a:b', 默认值)。只有在"这个值必须存在,缺了就应该报错"时才用pillar['key']让它显式失败。
Jinja 中也可以用 pillar.get() 的简写形式(不推荐混用,容易记乱):
{{ pillar.get('nginx', {}).get('port', 80) }}
{{ salt['pillar.get']('nginx:port', 80) }} ← 推荐这个
18.8 用 Pillar 实现"不同主机不同密码"
# /srv/pillar/top.sls
base:
'minion1':
- secrets.minion1
'minion2':
- secrets.minion2
# /srv/pillar/secrets/minion1.sls
app_user_password: '$6$rounds=656000$aaaa$1111111111111111111111'
mysql_root_password: 'Minion1@Pwd'
# /srv/pillar/secrets/minion2.sls
app_user_password: '$6$rounds=656000$bbbb$2222222222222222222222'
mysql_root_password: 'Minion2@Pwd'
# /srv/salt/app/user.sls ← 同一份 SLS,不同机器得到不同密码
app-user:
user.present:
- name: appuser
- uid: 7001
- shell: /bin/bash
- home: /home/appuser
- password: {{ salt['pillar.get']('app_user_password') }}
salt '*' saltutil.refresh_pillar
salt '*' state.sls app.user
# 验证:两台机器的 /etc/shadow 中 appuser 的哈希不同
salt '*' cmd.run "grep appuser /etc/shadow"
18.9 Pillar 加密:GPG Renderer 简述
Pillar 文件本身是明文的,提交到 Git 仓库仍有泄露风险。Salt 提供 GPG renderer 让 pillar 中的敏感字段以密文存储,只有 master 上的私钥能解密。
流程概要:
# ① 在 master 上生成 GPG 密钥对(存放在 /etc/salt/gpgkeys)
mkdir -p /etc/salt/gpgkeys
chmod 700 /etc/salt/gpgkeys
gpg --gen-key --homedir /etc/salt/gpgkeys
# ② 导出公钥,供开发/运维在本地加密
gpg --homedir /etc/salt/gpgkeys --armor --export salt@example.com > /tmp/salt-pub.asc
# ③ 加密一个密码
echo -n 'Str0ngR00tPwd!' | gpg --homedir /etc/salt/gpgkeys --armor --encrypt -r salt@example.com
# ④ 在 pillar 文件顶部声明 renderer 链:先 jinja,再 yaml,再 gpg
#!yaml|gpg
# /srv/pillar/secrets/db.sls
mysql:
root_password: |
-----BEGIN PGP MESSAGE-----
hQEMAw2B674HRhwSAQf/RmVLD5FCsBW6R2C1234567890abcdefghijklmnopqrst
uvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/AAAAAAAAAAAAAAAAAAAA
=abcd
-----END PGP MESSAGE-----
# ⑤ /etc/salt/master 中启用(较新版本默认已支持,可显式指定 renderer 链)
renderer: jinja | yaml | gpg
gpg_keydir: /etc/salt/gpgkeys
# ⑥ 验证:master 端解密后下发,minion 拿到的是明文
salt 'minion1' pillar.get mysql:root_password
这样即使 pillar 仓库泄露,攻击者拿到的也只是密文。除 GPG 外,也可用 Vault / SDB(Salt DataBase) 对接外部密钥管理系统:
# sdb 方式(配置在 master.d 下)
mysql_password: sdb://myvault/secret/mysql/root
十九、Jinja 模板
Salt 默认的 renderer 链是 jinja | yaml:先用 Jinja 渲染,再用 YAML 解析。所以 SLS 文件天然支持 Jinja;配置文件模板需要在 file.managed 中显式声明 template: jinja。
19.1 三种语法定界符
| 语法 | 用途 | 示例 |
|---|---|---|
{{ ... }} |
变量替换/表达式求值,输出结果 | {{ grains['os'] }} |
{% ... %} |
逻辑控制语句,不输出 | {% if x %} {% for %} {% set %} |
{# ... #} |
注释,不输出到最终文件 | {# 这行不会出现在生成的配置里 #} |
{# 这是 Jinja 注释,渲染后消失 #}
# 这是配置文件注释,渲染后保留
worker_processes {{ grains['num_cpus'] }};
{% if salt['pillar.get']('nginx:gzip', False) %}
gzip on;
{% endif %}
19.2 在模板中引用 grains / pillar / 执行模块
{# ① grains #}
{{ grains['id'] }}
{{ grains['os'] }} {{ grains['osrelease'] }}
{{ grains['num_cpus'] }}
{{ grains['ip4_interfaces']['eth0'][0] }}
{{ salt['grains.get']('role', 'default') }} {# 推荐:带默认值 #}
{{ salt['grains.get']('ip4_interfaces:eth0:0', '127.0.0.1') }}
{# ② pillar #}
{{ pillar['nginx']['port'] }}
{{ salt['pillar.get']('nginx:port', 80) }} {# 推荐 #}
{# ③ ★ 直接调用任意 salt 执行模块(非常强大) #}
{{ salt['cmd.run']('hostname') }}
{{ salt['cmd.run']('nproc') }}
{{ salt['cmd.run']("awk '/MemTotal/{print int($2/1024)}' /proc/meminfo") }}
{{ salt['network.ip_addrs']()[0] }}
{{ salt['network.interfaces']()['eth0']['inet'][0]['address'] }}
{{ salt['pkg.version']('nginx') }}
{{ salt['file.file_exists']('/etc/nginx/nginx.conf') }}
{{ salt['user.info']('nginx')['uid'] }}
{{ salt['disk.usage']()['/']['available'] }}
{{ salt['status.loadavg']()['1-min'] }}
{{ salt['mine.get']('role:db', 'network.ip_addrs', 'grain') }} {# 跨机器取数据 #}
{{ salt['xxx.yyy']() }} 的强大之处:模板渲染时可以在 minion 上实时执行任何命令/模块拿到当前真实值,不需要事先写进 grains 或 pillar。
{# 实战:算出 nginx worker_connections(内存的一个函数) #}
{% set mem_mb = grains['mem_total'] %}
{% set conns = (mem_mb * 8) | int %}
worker_connections {{ [conns, 65535] | min }};
{# 实战:取本机第一块业务网卡 IP 作为 bind 地址 #}
{% set bind_ip = salt['network.ip_addrs'](interface='eth0')[0] %}
bind-address = {{ bind_ip }}
注意:
salt['cmd.run']()在渲染阶段执行,每次 highstate 都会跑一次,命令要足够轻量且无副作用,绝不要在模板里跑yum install。
19.3 变量定义与运算
{% set app_port = salt['pillar.get']('app:port', 8080) %}
{% set app_root = '/data/www/' ~ grains['id'] %} {# ~ 是字符串拼接 #}
{% set worker = grains['num_cpus'] * 2 %}
{% set is_prod = salt['grains.get']('env') == 'prod' %}
{% set servers = ['10.1.1.10', '10.1.1.11', '10.1.1.12'] %}
{% set conf = {'port': 80, 'host': 'localhost'} %}
listen {{ app_port }};
root {{ app_root }};
worker_processes {{ worker }};
19.4 条件判断 if / elif / else
{% if grains['os_family'] == 'RedHat' %}
包管理器: yum
{% elif grains['os_family'] == 'Debian' %}
包管理器: apt
{% else %}
包管理器: unknown
{% endif %}
{# 复合条件 #}
{% if grains['num_cpus'] >= 8 and grains['mem_total'] > 8000 %}
worker_processes 8;
{% elif grains['num_cpus'] >= 4 %}
worker_processes 4;
{% else %}
worker_processes 1;
{% endif %}
{# in 测试与 not #}
{% if grains['id'] in ['minion1', 'minion2'] %}
role = web
{% endif %}
{% if not salt['file.file_exists']('/etc/app.conf') %}
# 首次部署
{% endif %}
{# is defined / is none 测试 #}
{% if salt['pillar.get']('nginx:ssl_cert') is defined %}
ssl_certificate {{ salt['pillar.get']('nginx:ssl_cert') }};
{% endif %}
{# 行内三元表达式 #}
access_log {{ '/var/log/nginx/access.log' if is_prod else 'off' }};
19.5 循环 for
{# ① 遍历列表 #}
upstream backend {
{% for server in salt['pillar.get']('nginx:upstreams', []) %}
server {{ server }} weight=1 max_fails=3 fail_timeout=30s;
{% endfor %}
}
{# ② 遍历字典 #}
{% for key, value in salt['pillar.get']('sysctl', {}).items() %}
{{ key }} = {{ value }}
{% endfor %}
{# ③ 遍历字典列表 #}
{% for user in salt['pillar.get']('users', []) %}
# 用户 {{ loop.index }}: {{ user['name'] }} (uid={{ user['uid'] }})
{% endfor %}
{# ④ 循环变量与条件过滤 #}
{% for i in range(1, 5) %}
worker_{{ i }} enabled
{% endfor %}
{% for s in servers if not s.startswith('127.') %}
server {{ s }};
{% endfor %}
{# ⑤ loop 对象 #}
{% for s in servers %}
{{ loop.index }}/{{ loop.length }}: {{ s }}{% if not loop.last %},{% endif %}
{% endfor %}
{# ⑥ 空列表兜底 #}
{% for s in servers %}
server {{ s }};
{% else %}
server 127.0.0.1:8080; {# 列表为空时执行 #}
{% endfor %}
loop 对象常用属性:loop.index(从 1)、loop.index0(从 0)、loop.first、loop.last、loop.length、loop.revindex。
在 SLS 中用循环批量生成状态(非常实用):
# 用 pillar 里的用户列表批量创建用户
{% for user in salt['pillar.get']('users', []) %}
create-user-{{ user['name'] }}:
user.present:
- name: {{ user['name'] }}
- uid: {{ user['uid'] }}
- shell: {{ user.get('shell', '/bin/bash') }}
- home: /home/{{ user['name'] }}
{% endfor %}
# 批量下发多个虚拟主机配置
{% for site in salt['pillar.get']('nginx:sites', []) %}
vhost-{{ site }}:
file.managed:
- name: /etc/nginx/conf.d/{{ site }}.conf
- source: salt://nginx/files/vhost.conf.jinja
- template: jinja
- context:
site_name: {{ site }}
- watch_in:
- service: nginx-service
{% endfor %}
19.6 常用过滤器
| 过滤器 | 作用 | 示例 |
|---|---|---|
default(值) |
变量未定义/为空时用默认值 | {{ port | default(80) }} |
default(值, True) |
连空字符串也算未定义 | {{ name | default('web', True) }} |
upper / lower |
转大写/小写 | {{ grains['os'] | upper }} → CENTOS |
title / capitalize |
首字母大写 | {{ 'centos' | capitalize }} → Centos |
join('分隔符') |
列表拼成字符串 | {{ ['a','b','c'] | join(',') }} → a,b,c |
int |
转整数 | {{ '8' | int }}、{{ 3.9 | int }} → 3 |
float |
转浮点 | {{ '1.5' | float }} |
string |
转字符串 | {{ 80 | string }} |
length / count |
长度 | {{ servers | length }} |
first / last |
首/末元素 | {{ servers | first }} |
min / max |
最小/最大 | {{ [100, 65535] | min }} |
sum |
求和 | {{ [1,2,3] | sum }} |
sort |
排序 | {{ servers | sort | join(' ') }} |
unique |
去重 | {{ list | unique | list }} |
reverse |
反转 | |
replace('a','b') |
替换 | {{ name | replace('-','_') }} |
trim |
去首尾空白 | {{ salt['cmd.run']('hostname') | trim }} |
split('分隔符') |
切分(Salt 扩展) | {{ '1,2,3' | split(',') }} |
regex_replace(a,b) |
正则替换(Salt 扩展) | {{ s | regex_replace('^v','') }} |
json / yaml |
序列化(Salt 扩展) | {{ mydict | json }} |
indent(n) |
缩进 | {{ block | indent(4) }} |
to_bool |
转布尔(Salt 扩展) | {{ 'yes' | to_bool }} |
random_hash |
随机哈希(Salt 扩展) | |
dict_to_sls_yaml_params |
字典转 SLS 参数(Salt 扩展) |
{# 组合使用 #}
worker_processes {{ salt['pillar.get']('nginx:worker', grains['num_cpus']) | int }};
allow_hosts {{ salt['pillar.get']('nginx:allow', ['127.0.0.1']) | join(' ') }};
server_name {{ salt['pillar.get']('nginx:server_name', grains['fqdn']) | lower }};
{{ salt['cmd.run']('nproc') | trim | int }}
19.7 {%- -%} 控制空白
Jinja 的 {% %} 语句块本身会在输出中留下空行,在生成 YAML(SLS)时空行无害,但在生成配置文件时会产生大量难看的空行,在 YAML 里错位的缩进甚至会导致解析失败。
{%-:删除标签前面的空白(包括换行);-%}:删除标签后面的空白(包括换行)。
{# ❌ 不控制空白:生成的文件里每个 for/if 都留一个空行 #}
upstream backend {
{% for s in servers %}
server {{ s }};
{% endfor %}
}
{# 渲染结果:
upstream backend {
server 10.1.1.10;
server 10.1.1.11;
}
#}
{# ✅ 控制空白 #}
upstream backend {
{%- for s in servers %}
server {{ s }};
{%- endfor %}
}
{# 渲染结果:
upstream backend {
server 10.1.1.10;
server 10.1.1.11;
}
#}
在 SLS 中生成状态时尤其重要:
{%- set pkgs = salt['pillar.get']('base_packages', ['vim', 'wget']) %}
base-packages:
pkg.installed:
- pkgs:
{%- for p in pkgs %}
- {{ p }}
{%- endfor %}
也可以在 master 配置中全局开启
jinja_trim_blocks: True和jinja_lstrip_blocks: True,但显式写{%- -%}更可控、可读。
19.8 实战:按 os_family / 版本写兼容 CentOS 6/7 的配置
做法一:在 SLS 里用 Jinja 分支
# /srv/salt/httpd/init.sls ← 同时兼容 CentOS 6 与 7、以及 Debian
{%- if grains['os_family'] == 'RedHat' %}
{%- set pkg_name = 'httpd' %}
{%- set svc_name = 'httpd' %}
{%- set conf_path = '/etc/httpd/conf/httpd.conf' %}
{%- set conf_user = 'apache' %}
{%- elif grains['os_family'] == 'Debian' %}
{%- set pkg_name = 'apache2' %}
{%- set svc_name = 'apache2' %}
{%- set conf_path = '/etc/apache2/apache2.conf' %}
{%- set conf_user = 'www-data' %}
{%- endif %}
httpd-install:
pkg.installed:
- name: {{ pkg_name }}
httpd-config:
file.managed:
- name: {{ conf_path }}
- source: salt://httpd/files/httpd.conf.jinja
- template: jinja
- user: root
- group: root
- mode: '0644'
- backup: minion
- context:
run_user: {{ conf_user }}
- require:
- pkg: httpd-install
httpd-service:
service.running:
- name: {{ svc_name }}
- enable: True
- watch:
- file: httpd-config
做法二:用 map.jinja 集中管理平台差异(生产最佳实践)
{# /srv/salt/httpd/map.jinja #}
{% set httpd = salt['grains.filter_by']({
'RedHat': {
'pkg': 'httpd',
'service': 'httpd',
'conf_dir': '/etc/httpd/conf',
'conf': '/etc/httpd/conf/httpd.conf',
'vhost_dir': '/etc/httpd/conf.d',
'user': 'apache',
'group': 'apache',
'log_dir': '/var/log/httpd',
'doc_root': '/var/www/html',
},
'Debian': {
'pkg': 'apache2',
'service': 'apache2',
'conf_dir': '/etc/apache2',
'conf': '/etc/apache2/apache2.conf',
'vhost_dir': '/etc/apache2/sites-enabled',
'user': 'www-data',
'group': 'www-data',
'log_dir': '/var/log/apache2',
'doc_root': '/var/www/html',
},
}, merge=salt['pillar.get']('httpd:lookup')) %}
# /srv/salt/httpd/init.sls
{% from "httpd/map.jinja" import httpd with context %}
httpd-install:
pkg.installed:
- name: {{ httpd.pkg }}
httpd-config:
file.managed:
- name: {{ httpd.conf }}
- source: salt://httpd/files/httpd.conf.jinja
- template: jinja
- require:
- pkg: httpd-install
httpd-service:
service.running:
- name: {{ httpd.service }}
- enable: True
- watch:
- file: httpd-config
做法三:CentOS 6/7 服务与防火墙差异的处理
# /srv/salt/init/firewall.sls
{%- set major = grains['osmajorrelease'] | int %}
{%- if major == 6 %}
# ---------- CentOS 6:iptables ----------
iptables-config:
file.managed:
- name: /etc/sysconfig/iptables
- source: salt://init/files/iptables.centos6
- mode: '0600'
iptables-service:
service.running:
- name: iptables
- enable: True
- watch:
- file: iptables-config
# CentOS 6 上 firewalld 不存在,无需处理
{%- else %}
# ---------- CentOS 7:firewalld ----------
firewalld-installed:
pkg.installed:
- name: firewalld
open-salt-ports:
cmd.run:
- name: 'firewall-cmd --permanent --add-port=4505-4506/tcp && firewall-cmd --reload'
- unless: 'firewall-cmd --list-ports | grep -q 4505'
- require:
- pkg: firewalld-installed
firewalld-service:
service.running:
- name: firewalld
- enable: True
- require:
- pkg: firewalld-installed
# CentOS 7 上禁用 iptables-services 避免冲突
iptables-services-disabled:
service.dead:
- name: iptables
- enable: False
- onlyif: 'systemctl list-unit-files | grep -q "^iptables.service"'
{%- endif %}
# 服务名差异(MySQL 在 6 上叫 mysqld,7 上是 mariadb)
{%- set db_svc = 'mysqld' if grains['osmajorrelease'] | int == 6 else 'mariadb' %}
{%- set db_pkgs = ['mysql-server'] if grains['osmajorrelease'] | int == 6 else ['mariadb', 'mariadb-server'] %}
db-install:
pkg.installed:
- pkgs: {{ db_pkgs | json }}
db-service:
service.running:
- name: {{ db_svc }}
- enable: True
- require:
- pkg: db-install
19.9 完整示例:用模板生成 Nginx 配置
目录结构
/srv/salt/nginx/
├── init.sls
└── files/
└── nginx.conf.jinja
/srv/pillar/
├── top.sls
└── nginx.sls
Pillar 数据 /srv/pillar/nginx.sls
nginx:
port: 80
worker_connections: 10240
keepalive_timeout: 65
client_max_body_size: 50m
gzip: True
server_name: www.example.com
doc_root: /data/www/html
upstreams:
- 10.1.1.10:8080
- 10.1.1.11:8080
allow_status:
- 10.1.1.0/24
- 127.0.0.1
# /srv/pillar/top.sls
base:
'minion*':
- nginx
模板 /srv/salt/nginx/files/nginx.conf.jinja
{#- 由 SaltStack 自动生成,请勿手工修改 -#}
# ============================================
# Generated by SaltStack on {{ salt['cmd.run']('date +"%F %T"') }}
# Minion : {{ grains['id'] }} ({{ grains['fqdn'] }})
# System : {{ grains['os'] }} {{ grains['osrelease'] }} ({{ grains['os_family'] }})
# CPU : {{ grains['num_cpus'] }} cores MEM: {{ grains['mem_total'] }} MB
# ============================================
user {{ salt['pillar.get']('nginx:user', 'nginx') }};
{#- 按 CPU 核数自动设置 worker 数 -#}
worker_processes {{ salt['pillar.get']('nginx:worker_processes', grains['num_cpus']) }};
worker_rlimit_nofile 65535;
error_log /var/log/nginx/error.log {{ salt['pillar.get']('nginx:log_level', 'warn') }};
pid /var/run/nginx.pid;
events {
use epoll;
{#- 按内存大小估算连接数,上限 65535 -#}
{%- set conns = salt['pillar.get']('nginx:worker_connections', (grains['mem_total'] * 8) | int) %}
worker_connections {{ [conns | int, 65535] | min }};
multi_accept on;
}
http {
include mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for" $request_time';
access_log /var/log/nginx/access.log main;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout {{ salt['pillar.get']('nginx:keepalive_timeout', 65) }};
client_max_body_size {{ salt['pillar.get']('nginx:client_max_body_size', '10m') }};
server_tokens off;
{%- if salt['pillar.get']('nginx:gzip', False) %}
gzip on;
gzip_min_length 1k;
gzip_comp_level 4;
gzip_types text/plain text/css application/json application/javascript text/xml;
gzip_vary on;
{%- endif %}
{#- 后端 upstream,从 pillar 循环生成 -#}
{%- set upstreams = salt['pillar.get']('nginx:upstreams', []) %}
{%- if upstreams %}
upstream backend {
least_conn;
{%- for srv in upstreams %}
server {{ srv }} weight=1 max_fails=3 fail_timeout=30s;
{%- endfor %}
keepalive 32;
}
{%- endif %}
server {
listen {{ salt['pillar.get']('nginx:port', 80) }};
server_name {{ salt['pillar.get']('nginx:server_name', grains['fqdn']) }};
root {{ salt['pillar.get']('nginx:doc_root', '/usr/share/nginx/html') }};
index index.html index.htm index.php;
location / {
{%- if upstreams %}
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
{%- else %}
try_files $uri $uri/ =404;
{%- endif %}
}
location ~ \.php$ {
fastcgi_pass 127.0.0.1:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
location = /nginx_status {
stub_status on;
access_log off;
{%- for net in salt['pillar.get']('nginx:allow_status', ['127.0.0.1']) %}
allow {{ net }};
{%- endfor %}
deny all;
}
}
include /etc/nginx/conf.d/*.conf;
}
状态文件 /srv/salt/nginx/init.sls
{%- set doc_root = salt['pillar.get']('nginx:doc_root', '/usr/share/nginx/html') %}
nginx-install:
pkg.installed:
- name: nginx
nginx-docroot:
file.directory:
- name: {{ doc_root }}
- user: nginx
- group: nginx
- dir_mode: '0755'
- makedirs: True
- require:
- pkg: nginx-install
nginx-config:
file.managed:
- name: /etc/nginx/nginx.conf
- source: salt://nginx/files/nginx.conf.jinja
- template: jinja
- user: root
- group: root
- mode: '0644'
- backup: minion
- show_changes: True
- require:
- pkg: nginx-install
nginx-config-check:
cmd.run:
- name: /usr/sbin/nginx -t
- onchanges:
- file: nginx-config
nginx-service:
service.running:
- name: nginx
- enable: True
- reload: True
- watch:
- file: nginx-config
- require:
- cmd: nginx-config-check
执行与验证
# ① 刷新 pillar
salt 'minion*' saltutil.refresh_pillar
# ② 先看渲染结果对不对(不落盘)
salt 'minion1' state.show_sls nginx --out=yaml
salt 'minion1' cp.get_template salt://nginx/files/nginx.conf.jinja /tmp/nginx.conf.preview
salt 'minion1' cmd.run 'cat /tmp/nginx.conf.preview'
# ③ 空跑
salt 'minion*' state.sls nginx test=True
# ④ 正式执行(分批)
salt -b 1 'minion*' state.sls nginx
# ⑤ 验证
salt 'minion*' cmd.run 'nginx -t'
salt 'minion*' cmd.run 'grep worker_processes /etc/nginx/nginx.conf'
salt 'minion*' service.status nginx
salt 'minion*' cmd.run 'curl -sI http://127.0.0.1 | head -1'
# ⑥ 验证 watch 联动:改模板后重新执行,服务应自动 reload
# 再执行一次不改动的话,应显示 Changes: {} 且服务不重启(幂等)
salt 'minion*' state.sls nginx
同理生成 httpd 配置(片段)
{#- /srv/salt/httpd/files/httpd.conf.jinja 关键片段 -#}
ServerRoot "/etc/httpd"
Listen {{ salt['pillar.get']('httpd:port', 80) }}
ServerName {{ grains['fqdn'] }}:{{ salt['pillar.get']('httpd:port', 80) }}
User {{ salt['pillar.get']('httpd:user', 'apache') }}
Group {{ salt['pillar.get']('httpd:group', 'apache') }}
{#- 按 CPU 核数与内存调 prefork/worker 参数 -#}
<IfModule prefork.c>
StartServers {{ grains['num_cpus'] }}
MinSpareServers {{ grains['num_cpus'] * 2 }}
MaxSpareServers {{ grains['num_cpus'] * 4 }}
ServerLimit {{ (grains['mem_total'] / 20) | int }}
MaxClients {{ (grains['mem_total'] / 20) | int }}
MaxRequestsPerChild 4000
</IfModule>
DocumentRoot "{{ salt['pillar.get']('httpd:doc_root', '/var/www/html') }}"
KeepAlive {{ 'On' if salt['pillar.get']('httpd:keepalive', True) else 'Off' }}
Timeout {{ salt['pillar.get']('httpd:timeout', 60) }}
二十、实战案例
20.1 源文档基础练习完整复现(file / dir / user / group / cron / pkg+service / cmd / script)
这一组是两份源文档的教学主线,逐步往 top.sls 里累加状态。最终的目录与文件:
/srv/salt/
├── top.sls
├── file.sls
├── dir.sls
├── user.sls
├── group.sls
├── cron.sls
├── httpd.sls
├── cmd.sls
├── script.sls
└── test_dir/
├── 1.txt
├── 1.sh
└── httpd.conf
最终 /srv/salt/top.sls(源文档累加后的完整形态)
base:
"minion*":
- file
- dir
- user
- group
- cron
- httpd
- cmd
- script
准备源目录与脚本
[root@master ~]# mkdir /srv/salt/test_dir
[root@master ~]# echo haha > /srv/salt/test_dir/1.txt
[root@master ~]# cp /etc/httpd/conf/httpd.conf /srv/salt/test_dir/ # 供 httpd.sls 下发
[root@master ~]# cat > /srv/salt/test_dir/1.sh <<'EOF'
#!/bin/bash
mkdir /test -p
touch /test/{1..10}
EOF
① /srv/salt/file.sls —— 三个例子(按需切换其中一个执行)
# 例 1:创建文件并写入内容
/tmp/itjiangshi_1:
file.managed:
- contents:
haha
# 例 2:删除文件
sdfdsafsa:
file.absent:
- name: /tmp/itjiangshi_1
# 例 3:下发 master 上的文件并指定权限
/tmp/itjiangshi_2:
file.managed:
- source: /etc/fstab
- user: bin
- group: daemon
- mode: 0600
[root@master ~]# salt "minion*" state.sls file
② /srv/salt/dir.sls —— 创建目录 / 递归下发目录
# 例 4:创建目录
/tmp/dir1:
file.directory:
- user: daemon
- group: daemon
- dir_mode: 0700
# 例 5:递归下发整个目录(把 /srv/salt/test_dir 同步到 minion 的 /tmp/haha_dir)
/tmp/dir1:
file.recurse:
- name: /tmp/haha_dir
- source: salt://test_dir
- user: root
- dir_mode: 0700
- file_mode: 0600
- mkdir: True
- clean: True
[root@master ~]# salt "minion*" state.sls dir
③ /srv/salt/user.sls
user2:
user.present:
- fullname: itjiangshi_daniel
- shell: /bin/bash
- home: /home/user2
- uid: 5000
- gid: 5000 # 如果不存在 gid=5000 的组,请去掉这句
- groups:
- root
- bin
- daemon
user1:
user.absent:
- purge: true # 删除用户家目录、邮件等数据
[root@master ~]# salt "minion*" state.sls user
④ /srv/salt/group.sls
group1:
group.present:
- gid: 6002
- system: True
- addusers:
- bin
- daemon
group2:
group.present:
- gid: 7000
- members:
- root
- lp
[root@master ~]# salt "minion*" state.sls group
⑤ /srv/salt/cron.sls
cron_jobs:
cron.present:
- name: date +'%F %T' >> /tmp/haha
- user: root
- minute: '*'
- hour: 11
[root@master ~]# salt "minion*" state.sls cron
⑥ /srv/salt/httpd.sls —— pkg + file + service
httpd-install:
pkg.installed:
- names:
- httpd
- httpd-devel
httpd-config:
file.managed:
- name: /etc/httpd/conf/httpd.conf
- source: salt://test_dir/httpd.conf
httpd-run:
service.running:
- name: httpd
- enable: True
[root@master ~]# salt "minion*" state.sls httpd
⑦ /srv/salt/cmd.sls —— onlyif / unless
cmd-test1:
cmd.run:
- onlyif: test -f /tmp/haha
- name:
echo itjiangshi.com >> /tmp/haha
- user: root
cmd-test2:
cmd.run:
- unless: test -e /tmp/hehe
- name:
echo itjiangshi.com > /tmp/hehe
- user: root
[root@master ~]# salt "minion*" state.sls cmd
⑧ /srv/salt/script.sls —— 远程执行脚本
shell_test:
cmd.script:
- source: salt://test_dir/1.sh # 对应 master 上准备好的脚本
- user: root
[root@master ~]# salt "minion*" state.sls script
# 扩展:不写 state,直接命令执行 master 上的脚本
[root@master ~]# salt "minion*" cmd.script salt://test_dir/1.sh
⑨ 一次性执行全部(走 top.sls)
[root@master ~]# salt "minion*" state.highstate test=True # 先空跑
[root@master ~]# salt "minion*" state.highstate
⑩ 不写 state,直接用 cp 模块下发文件/目录
[root@master ~]# salt "minion*" cp.get_file salt://test_dir/1.sh /tmp/2.sh
minion1:
/tmp/2.sh
minion2:
/tmp/2.sh
[root@master ~]# salt "minion*" cp.get_dir salt://test_dir/ /tmp/
minion2:
- /tmp//test_dir/1.sh
- /tmp//test_dir/1.txt
minion1:
- /tmp//test_dir/1.sh
- /tmp//test_dir/1.txt
20.2 实战:统一分发 /etc/hosts
目录结构
/srv/salt/init/
├── hosts.sls
└── files/
└── hosts.jinja
/srv/pillar/
├── top.sls
└── hosts.sls
/srv/pillar/hosts.sls
cluster_hosts:
master: 10.1.1.2
minion1: 10.1.1.3
minion2: 10.1.1.4
yumrepo: 10.1.1.1
/srv/salt/init/files/hosts.jinja
# 由 SaltStack 统一管理,请勿手工修改
127.0.0.1 localhost localhost.localdomain localhost4 localhost4.localdomain4
::1 localhost localhost.localdomain localhost6 localhost6.localdomain6
# ---- cluster hosts ----
{%- for name, ip in salt['pillar.get']('cluster_hosts', {}).items() | sort %}
{{ ip }} {{ name }} {{ name }}.example.com
{%- endfor %}
/srv/salt/init/hosts.sls(方式一:整个文件托管)
manage-hosts:
file.managed:
- name: /etc/hosts
- source: salt://init/files/hosts.jinja
- template: jinja
- user: root
- group: root
- mode: '0644'
- backup: minion
- show_changes: True
/srv/salt/init/hosts.sls(方式二:只管理标记块,保留人工内容,更安全)
manage-hosts-block:
file.blockreplace:
- name: /etc/hosts
- marker_start: '# ---- SALT MANAGED BLOCK START ----'
- marker_end: '# ---- SALT MANAGED BLOCK END ----'
- template: jinja
- content: |
{%- for name, ip in salt['pillar.get']('cluster_hosts', {}).items() | sort %}
{{ ip }} {{ name }} {{ name }}.example.com
{%- endfor %}
- append_if_not_found: True
- backup: '.bak'
- show_changes: True
top.sls 与执行
# /srv/salt/top.sls
base:
'*':
- init.hosts
# /srv/pillar/top.sls
base:
'*':
- hosts
salt '*' saltutil.refresh_pillar
salt '*' state.sls init.hosts test=True
salt '*' state.sls init.hosts
# 验证
salt '*' cmd.run 'cat /etc/hosts'
salt '*' cmd.run 'ping -c1 minion2'
salt '*' file.contains /etc/hosts 'minion2'
20.3 实战:批量创建用户与分发 SSH 公钥
目录结构
/srv/salt/users/
├── init.sls
└── files/
└── deploy_id_rsa.pub
/srv/pillar/
├── users.sls
└── secrets/
└── user_passwords.sls
/srv/pillar/users.sls
system_users:
- name: deploy
uid: 6001
gid: 6001
shell: /bin/bash
home: /home/deploy
groups:
- wheel
sudo: True
ssh_keys:
- 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDxxxxx deploy@ops'
- name: monitor
uid: 6002
gid: 6002
shell: /bin/bash
home: /home/monitor
groups: []
sudo: False
ssh_keys:
- 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDyyyyy monitor@zabbix'
- name: dev01
uid: 6003
gid: 6003
shell: /bin/bash
home: /home/dev01
groups: []
sudo: False
ssh_keys: []
removed_users:
- testuser
- olddev
/srv/pillar/secrets/user_passwords.sls
# 用 salt '*' shadow.gen_password 'MyPassw0rd' 生成
user_passwords:
deploy: '$6$rounds=656000$aaaaaaaa$1111111111111111111111111111111111111111'
monitor: '$6$rounds=656000$bbbbbbbb$2222222222222222222222222222222222222222'
dev01: '$6$rounds=656000$cccccccc$3333333333333333333333333333333333333333'
/srv/salt/users/init.sls
{%- set users = salt['pillar.get']('system_users', []) %}
{%- set pwds = salt['pillar.get']('user_passwords', {}) %}
{%- for u in users %}
# ---------- {{ u.name }} ----------
group-{{ u.name }}:
group.present:
- name: {{ u.name }}
- gid: {{ u.gid }}
user-{{ u.name }}:
user.present:
- name: {{ u.name }}
- uid: {{ u.uid }}
- gid: {{ u.gid }}
- home: {{ u.home }}
- shell: {{ u.shell }}
- createhome: True
{%- if u.groups %}
- optional_groups:
{%- for g in u.groups %}
- {{ g }}
{%- endfor %}
{%- endif %}
{%- if pwds.get(u.name) %}
- password: '{{ pwds.get(u.name) }}'
{%- endif %}
- require:
- group: group-{{ u.name }}
sshdir-{{ u.name }}:
file.directory:
- name: {{ u.home }}/.ssh
- user: {{ u.name }}
- group: {{ u.name }}
- mode: '0700'
- makedirs: True
- require:
- user: user-{{ u.name }}
{%- if u.ssh_keys %}
authkeys-{{ u.name }}:
file.managed:
- name: {{ u.home }}/.ssh/authorized_keys
- user: {{ u.name }}
- group: {{ u.name }}
- mode: '0600'
- contents: |
{%- for key in u.ssh_keys %}
{{ key }}
{%- endfor %}
- require:
- file: sshdir-{{ u.name }}
{#- 也可以用专用的 ssh_auth 状态模块,更幂等(不会覆盖已有其他 key) -#}
sshauth-{{ u.name }}:
ssh_auth.present:
- user: {{ u.name }}
- names:
{%- for key in u.ssh_keys %}
- {{ key }}
{%- endfor %}
- require:
- file: sshdir-{{ u.name }}
{%- endif %}
{%- if u.sudo %}
sudoers-{{ u.name }}:
file.managed:
- name: /etc/sudoers.d/{{ u.name }}
- mode: '0440'
- user: root
- group: root
- contents: '{{ u.name }} ALL=(ALL) NOPASSWD: ALL'
- require:
- user: user-{{ u.name }}
{%- endif %}
{%- endfor %}
# ---------- 清理已离职用户 ----------
{%- for name in salt['pillar.get']('removed_users', []) %}
remove-user-{{ name }}:
user.absent:
- name: {{ name }}
- purge: True
- force: True
{%- endfor %}
执行与验证
salt '*' saltutil.refresh_pillar
salt '*' state.sls users test=True
salt -b 5 '*' state.sls users
# 验证
salt '*' user.list_users | grep -E 'deploy|monitor|dev01'
salt '*' user.info deploy
salt '*' cmd.run 'ls -la /home/deploy/.ssh/'
salt '*' cmd.run 'cat /home/deploy/.ssh/authorized_keys'
salt '*' cmd.run 'ls -l /etc/sudoers.d/'
# 从跳板机测试免密登录
ssh -i ~/.ssh/id_rsa deploy@10.1.1.3 'hostname'
20.4 实战:SSH 安全加固
目录结构
/srv/salt/init/
├── ssh.sls
└── files/
└── sshd_config.jinja
/srv/pillar/
└── ssh.sls
/srv/pillar/ssh.sls
ssh:
port: 2222
permit_root_login: 'no'
password_authentication: 'no'
pubkey_authentication: 'yes'
max_auth_tries: 3
client_alive_interval: 300
client_alive_count_max: 2
use_dns: 'no'
allow_users:
- deploy
- monitor
allow_groups:
- wheel
/srv/salt/init/files/sshd_config.jinja
# 由 SaltStack 管理,请勿手工修改 ({{ grains['id'] }})
Port {{ salt['pillar.get']('ssh:port', 22) }}
Protocol 2
AddressFamily inet
ListenAddress 0.0.0.0
HostKey /etc/ssh/ssh_host_rsa_key
{%- if grains['osmajorrelease'] | int >= 7 %}
HostKey /etc/ssh/ssh_host_ecdsa_key
HostKey /etc/ssh/ssh_host_ed25519_key
{%- else %}
HostKey /etc/ssh/ssh_host_dsa_key
{%- endif %}
SyslogFacility AUTHPRIV
LogLevel INFO
# ---------- 认证安全 ----------
PermitRootLogin {{ salt['pillar.get']('ssh:permit_root_login', 'no') }}
PubkeyAuthentication {{ salt['pillar.get']('ssh:pubkey_authentication', 'yes') }}
AuthorizedKeysFile .ssh/authorized_keys
PasswordAuthentication {{ salt['pillar.get']('ssh:password_authentication', 'no') }}
PermitEmptyPasswords no
ChallengeResponseAuthentication no
MaxAuthTries {{ salt['pillar.get']('ssh:max_auth_tries', 3) }}
MaxSessions 10
LoginGraceTime 60
StrictModes yes
# ---------- 关闭不必要的功能 ----------
X11Forwarding no
AllowAgentForwarding no
AllowTcpForwarding yes
PermitTunnel no
GatewayPorts no
# ---------- 性能与连接 ----------
UseDNS {{ salt['pillar.get']('ssh:use_dns', 'no') }}
ClientAliveInterval {{ salt['pillar.get']('ssh:client_alive_interval', 300) }}
ClientAliveCountMax {{ salt['pillar.get']('ssh:client_alive_count_max', 2) }}
TCPKeepAlive yes
Compression delayed
GSSAPIAuthentication no
UsePAM yes
{%- if grains['osmajorrelease'] | int == 6 %}
UsePrivilegeSeparation sandbox
{%- endif %}
AcceptEnv LANG LC_*
Subsystem sftp /usr/libexec/openssh/sftp-server
Banner /etc/ssh/banner
# ---------- 白名单 ----------
{%- set allow_users = salt['pillar.get']('ssh:allow_users', []) %}
{%- if allow_users %}
AllowUsers {{ allow_users | join(' ') }}
{%- endif %}
{%- set allow_groups = salt['pillar.get']('ssh:allow_groups', []) %}
{%- if allow_groups %}
AllowGroups {{ allow_groups | join(' ') }}
{%- endif %}
/srv/salt/init/ssh.sls
{%- set ssh_port = salt['pillar.get']('ssh:port', 22) %}
openssh-installed:
pkg.installed:
- pkgs:
- openssh-server
- openssh-clients
ssh-banner:
file.managed:
- name: /etc/ssh/banner
- mode: '0644'
- contents: |
***************************************************************
* 本系统仅供授权人员使用,所有操作均被记录并审计。 *
* Authorized access only. All activity is monitored. *
***************************************************************
# SELinux 需要为非标准 ssh 端口放行(CentOS 7)
{%- if ssh_port != 22 and grains['osmajorrelease'] | int >= 7 %}
selinux-ssh-port:
cmd.run:
- name: 'semanage port -a -t ssh_port_t -p tcp {{ ssh_port }} || semanage port -m -t ssh_port_t -p tcp {{ ssh_port }}'
- unless: 'semanage port -l | grep -E "^ssh_port_t.*{{ ssh_port }}"'
- onlyif: 'test -x /usr/sbin/semanage && test "$(getenforce)" != "Disabled"'
{%- endif %}
# 防火墙放行新端口(★ 必须在改配置之前,否则改完重启就锁死自己)
{%- if grains['osmajorrelease'] | int >= 7 %}
firewall-ssh-port:
cmd.run:
- name: 'firewall-cmd --permanent --add-port={{ ssh_port }}/tcp && firewall-cmd --reload'
- unless: 'firewall-cmd --list-ports | grep -q "{{ ssh_port }}/tcp"'
- onlyif: 'systemctl is-active firewalld'
- order: 1
{%- else %}
iptables-ssh-port:
cmd.run:
- name: 'iptables -I INPUT -p tcp --dport {{ ssh_port }} -j ACCEPT && service iptables save'
- unless: 'iptables -nL INPUT | grep -q "dpt:{{ ssh_port }}"'
- order: 1
{%- endif %}
sshd-config:
file.managed:
- name: /etc/ssh/sshd_config
- source: salt://init/files/sshd_config.jinja
- template: jinja
- user: root
- group: root
- mode: '0600'
- backup: minion # ★ 一定要备份,配错了能回滚
- show_changes: True
- require:
- pkg: openssh-installed
- file: ssh-banner
# ★ 改完先做语法检查,检查不通过就不要 reload(避免把自己锁在外面)
sshd-config-check:
cmd.run:
- name: '/usr/sbin/sshd -t'
- onchanges:
- file: sshd-config
sshd-service:
service.running:
- name: sshd
- enable: True
- reload: True # ★ reload 不中断已有连接,比 restart 安全
- watch:
- file: sshd-config
- require:
- cmd: sshd-config-check
执行与验证(务必谨慎,先在一台上试)
salt '*' saltutil.refresh_pillar
# ① 先只在 minion1 上空跑
salt 'minion1' state.sls init.ssh test=True
# ② 只在 minion1 上执行,并保持当前 SSH 会话不要断开
salt 'minion1' state.sls init.ssh
# ③ 用新端口另开一个终端验证能登进去
ssh -p 2222 deploy@10.1.1.3 'hostname'
# ④ 确认无误后再批量(一台一台来)
salt -b 1 'minion*' state.sls init.ssh
# 验证
salt '*' cmd.run 'sshd -T | grep -E "^port|permitrootlogin|passwordauthentication"'
salt '*' cmd.run 'netstat -tunlp | grep sshd'
salt '*' cmd.run 'firewall-cmd --list-ports'
salt 'minion1' file.list_backups /etc/ssh/sshd_config # 出问题时看备份
详见 07-03-防火墙与SELinux.md
20.5 实战:部署 httpd 完整状态树(含 watch 联动)
目录结构
/srv/salt/httpd/
├── init.sls ← 汇总入口
├── install.sls
├── config.sls
├── service.sls
└── files/
├── httpd.conf.jinja
├── vhost.conf.jinja
└── index.html.jinja
/srv/pillar/
└── httpd.sls
/srv/pillar/httpd.sls
httpd:
port: 80
user: apache
group: apache
doc_root: /var/www/html
timeout: 60
keepalive: True
server_admin: ops@example.com
vhosts:
- name: www.example.com
port: 80
root: /data/www/www
- name: blog.example.com
port: 80
root: /data/www/blog
/srv/salt/httpd/install.sls
httpd-install:
pkg.installed:
- pkgs:
- httpd
- httpd-devel
- httpd-tools
- mod_ssl
/srv/salt/httpd/config.sls
include:
- httpd.install
{%- set doc_root = salt['pillar.get']('httpd:doc_root', '/var/www/html') %}
httpd-main-config:
file.managed:
- name: /etc/httpd/conf/httpd.conf
- source: salt://httpd/files/httpd.conf.jinja
- template: jinja
- user: root
- group: root
- mode: '0644'
- backup: minion
- show_changes: True
- require:
- pkg: httpd-install
httpd-docroot:
file.directory:
- name: {{ doc_root }}
- user: {{ salt['pillar.get']('httpd:user', 'apache') }}
- group: {{ salt['pillar.get']('httpd:group', 'apache') }}
- dir_mode: '0755'
- makedirs: True
- require:
- pkg: httpd-install
httpd-index:
file.managed:
- name: {{ doc_root }}/index.html
- source: salt://httpd/files/index.html.jinja
- template: jinja
- user: {{ salt['pillar.get']('httpd:user', 'apache') }}
- mode: '0644'
- require:
- file: httpd-docroot
{#- 循环生成每个虚拟主机的配置与目录 -#}
{%- for vh in salt['pillar.get']('httpd:vhosts', []) %}
vhost-root-{{ vh.name }}:
file.directory:
- name: {{ vh.root }}
- user: {{ salt['pillar.get']('httpd:user', 'apache') }}
- group: {{ salt['pillar.get']('httpd:group', 'apache') }}
- dir_mode: '0755'
- makedirs: True
vhost-conf-{{ vh.name }}:
file.managed:
- name: /etc/httpd/conf.d/{{ vh.name }}.conf
- source: salt://httpd/files/vhost.conf.jinja
- template: jinja
- mode: '0644'
- context:
vhost_name: {{ vh.name }}
vhost_port: {{ vh.port }}
vhost_root: {{ vh.root }}
- require:
- pkg: httpd-install
- file: vhost-root-{{ vh.name }}
- watch_in: # ★ 任一 vhost 变化就通知服务
- service: httpd-service
{%- endfor %}
# 语法检查:配置有变化时才跑,不通过则 service 不会被重启
httpd-config-check:
cmd.run:
- name: '/usr/sbin/httpd -t'
- onchanges:
- file: httpd-main-config
/srv/salt/httpd/service.sls
include:
- httpd.install
- httpd.config
httpd-service:
service.running:
- name: httpd
- enable: True
- reload: True # ★ 配置变化时用 graceful reload
- watch: # ★ watch 联动的核心
- pkg: httpd-install
- file: httpd-main-config
- require:
- cmd: httpd-config-check
{%- if grains['osmajorrelease'] | int >= 7 %}
httpd-firewall:
cmd.run:
- name: 'firewall-cmd --permanent --add-service=http --add-service=https && firewall-cmd --reload'
- unless: 'firewall-cmd --list-services | grep -q http'
- onlyif: 'systemctl is-active firewalld'
{%- else %}
httpd-iptables:
cmd.run:
- name: 'iptables -I INPUT -p tcp --dport 80 -j ACCEPT && service iptables save'
- unless: 'iptables -nL INPUT | grep -q "dpt:http"'
{%- endif %}
/srv/salt/httpd/init.sls
include:
- httpd.install
- httpd.config
- httpd.service
/srv/salt/httpd/files/vhost.conf.jinja
# vhost for {{ vhost_name }} (managed by SaltStack)
<VirtualHost *:{{ vhost_port }}>
ServerName {{ vhost_name }}
ServerAdmin {{ salt['pillar.get']('httpd:server_admin', 'root@localhost') }}
DocumentRoot "{{ vhost_root }}"
<Directory "{{ vhost_root }}">
Options -Indexes +FollowSymLinks
AllowOverride All
{%- if grains['osmajorrelease'] | int >= 7 %}
Require all granted
{%- else %}
Order allow,deny
Allow from all
{%- endif %}
</Directory>
ErrorLog logs/{{ vhost_name }}-error_log
CustomLog logs/{{ vhost_name }}-access_log combined
</VirtualHost>
/srv/salt/httpd/files/index.html.jinja
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>{{ grains['id'] }}</title></head>
<body>
<h1>Deployed by SaltStack</h1>
<ul>
<li>Minion ID: {{ grains['id'] }}</li>
<li>FQDN: {{ grains['fqdn'] }}</li>
<li>IP: {{ salt['grains.get']('ip4_interfaces:eth0:0', 'unknown') }}</li>
<li>OS: {{ grains['os'] }} {{ grains['osrelease'] }}</li>
<li>CPU: {{ grains['num_cpus'] }} cores / MEM: {{ grains['mem_total'] }} MB</li>
<li>Deploy time: {{ salt['cmd.run']('date +"%F %T"') }}</li>
</ul>
</body>
</html>
top.sls 与执行验证
# /srv/salt/top.sls
base:
'G@role:webserver':
- match: grain
- httpd
salt '*' saltutil.refresh_pillar
salt -G 'role:webserver' state.sls httpd test=True
salt -b 1 -G 'role:webserver' state.sls httpd
# 验证
salt -G 'role:webserver' cmd.run 'httpd -t'
salt -G 'role:webserver' service.status httpd
salt -G 'role:webserver' cmd.run 'curl -s http://127.0.0.1 | head -5'
salt -G 'role:webserver' cmd.run 'curl -s -H "Host: www.example.com" http://127.0.0.1'
salt -G 'role:webserver' cmd.run 'ls /etc/httpd/conf.d/'
# ★ 验证 watch 联动
# 1) 改 pillar 里的 port,refresh_pillar 后重新执行 → 服务应被 reload
# 2) 什么都不改再执行一次 → 应显示 Changes: {},服务不动(幂等)
salt -G 'role:webserver' state.sls httpd
20.6 实战:部署 Zabbix Agent
目录结构
/srv/salt/monitor/zabbix_agent/
├── init.sls
└── files/
├── zabbix_agentd.conf.jinja
└── userparameter_custom.conf
/srv/pillar/
└── zabbix.sls
/srv/pillar/zabbix.sls
zabbix:
version: '4.0'
server: 10.1.1.100
server_active: 10.1.1.100
listen_port: 10050
timeout: 15
enable_remote_commands: 0
log_file: /var/log/zabbix/zabbix_agentd.log
log_file_size: 10
/srv/salt/monitor/zabbix_agent/files/zabbix_agentd.conf.jinja
# 由 SaltStack 管理,请勿手工修改
PidFile=/var/run/zabbix/zabbix_agentd.pid
LogFile={{ salt['pillar.get']('zabbix:log_file', '/var/log/zabbix/zabbix_agentd.log') }}
LogFileSize={{ salt['pillar.get']('zabbix:log_file_size', 10) }}
# ---------- Server 端 ----------
Server={{ salt['pillar.get']('zabbix:server', '127.0.0.1') }}
ServerActive={{ salt['pillar.get']('zabbix:server_active', '127.0.0.1') }}
ListenPort={{ salt['pillar.get']('zabbix:listen_port', 10050) }}
# ---------- 主机标识(用 grains 自动填,无需逐台手工改)----------
Hostname={{ grains['id'] }}
HostMetadata={{ salt['grains.get']('role', 'default') }}
# ---------- 其他 ----------
Timeout={{ salt['pillar.get']('zabbix:timeout', 15) }}
EnableRemoteCommands={{ salt['pillar.get']('zabbix:enable_remote_commands', 0) }}
LogRemoteCommands=0
StartAgents=3
Include=/etc/zabbix/zabbix_agentd.d/*.conf
/srv/salt/monitor/zabbix_agent/files/userparameter_custom.conf
# 自定义监控项
UserParameter=custom.tcp.estab,netstat -an | grep -c ESTABLISHED
UserParameter=custom.disk.inode[*],df -i $1 | awk 'NR==2{print $5}' | tr -d '%'
UserParameter=custom.process.num[*],ps -ef | grep -c "[$(echo $1 | cut -c1)]$(echo $1 | cut -c2-)"
UserParameter=custom.salt.minion.alive,systemctl is-active salt-minion >/dev/null 2>&1 && echo 1 || echo 0
/srv/salt/monitor/zabbix_agent/init.sls
{%- set zbx_ver = salt['pillar.get']('zabbix:version', '4.0') %}
{%- set major = grains['osmajorrelease'] | int %}
zabbix-repo:
pkg.installed:
- sources:
- zabbix-release: https://repo.zabbix.com/zabbix/{{ zbx_ver }}/rhel/{{ major }}/x86_64/zabbix-release-{{ zbx_ver }}-1.el{{ major }}.noarch.rpm
- unless: 'rpm -q zabbix-release'
- order: 1
zabbix-agent-install:
pkg.installed:
- name: zabbix-agent
- require:
- pkg: zabbix-repo
zabbix-log-dir:
file.directory:
- name: /var/log/zabbix
- user: zabbix
- group: zabbix
- dir_mode: '0755'
- makedirs: True
- require:
- pkg: zabbix-agent-install
zabbix-agent-config:
file.managed:
- name: /etc/zabbix/zabbix_agentd.conf
- source: salt://monitor/zabbix_agent/files/zabbix_agentd.conf.jinja
- template: jinja
- user: root
- group: root
- mode: '0644'
- backup: minion
- require:
- pkg: zabbix-agent-install
zabbix-userparam:
file.managed:
- name: /etc/zabbix/zabbix_agentd.d/userparameter_custom.conf
- source: salt://monitor/zabbix_agent/files/userparameter_custom.conf
- mode: '0644'
- makedirs: True
- require:
- pkg: zabbix-agent-install
zabbix-agent-service:
service.running:
- name: zabbix-agent
- enable: True
- watch:
- file: zabbix-agent-config
- file: zabbix-userparam
- require:
- file: zabbix-log-dir
{%- if major >= 7 %}
zabbix-firewall:
cmd.run:
- name: 'firewall-cmd --permanent --add-port={{ salt["pillar.get"]("zabbix:listen_port", 10050) }}/tcp && firewall-cmd --reload'
- unless: 'firewall-cmd --list-ports | grep -q 10050'
- onlyif: 'systemctl is-active firewalld'
{%- else %}
zabbix-iptables:
cmd.run:
- name: 'iptables -I INPUT -p tcp --dport 10050 -j ACCEPT && service iptables save'
- unless: 'iptables -nL INPUT | grep -q "dpt:10050"'
{%- endif %}
执行与验证
salt '*' saltutil.refresh_pillar
salt '*' state.sls monitor.zabbix_agent test=True
salt -b 10 '*' state.sls monitor.zabbix_agent
# 验证
salt '*' service.status zabbix-agent
salt '*' cmd.run 'grep -E "^Server|^Hostname" /etc/zabbix/zabbix_agentd.conf'
salt '*' cmd.run 'netstat -tunlp | grep 10050'
# 在 zabbix server 端测试取值
zabbix_get -s 10.1.1.3 -k agent.ping
zabbix_get -s 10.1.1.3 -k custom.tcp.estab
20.7 实战:部署 NTP / Chrony 客户端
/srv/pillar/ntp.sls
ntp:
servers:
- ntp1.aliyun.com
- ntp2.aliyun.com
- 10.1.1.1
timezone: Asia/Shanghai
allow_network: 10.1.1.0/24
/srv/salt/init/ntp.sls
{%- set major = grains['osmajorrelease'] | int %}
{%- set servers = salt['pillar.get']('ntp:servers', ['ntp.aliyun.com']) %}
{%- set tz = salt['pillar.get']('ntp:timezone', 'Asia/Shanghai') %}
# ---------- 时区(6/7 通用)----------
set-timezone:
file.symlink:
- name: /etc/localtime
- target: /usr/share/zoneinfo/{{ tz }}
- force: True
{%- if major >= 7 %}
# ================= CentOS 7:chrony =================
chrony-install:
pkg.installed:
- name: chrony
chrony-config:
file.managed:
- name: /etc/chrony.conf
- user: root
- group: root
- mode: '0644'
- backup: minion
- contents: |
# 由 SaltStack 管理
{%- for s in servers %}
server {{ s }} iburst
{%- endfor %}
driftfile /var/lib/chrony/drift
makestep 1.0 3
rtcsync
logdir /var/log/chrony
{%- if salt['pillar.get']('ntp:allow_network') %}
allow {{ salt['pillar.get']('ntp:allow_network') }}
{%- endif %}
- require:
- pkg: chrony-install
chrony-service:
service.running:
- name: chronyd
- enable: True
- watch:
- file: chrony-config
# CentOS 7 上关掉 ntpd 避免与 chronyd 抢 123 端口
ntpd-dead:
service.dead:
- name: ntpd
- enable: False
- onlyif: 'systemctl list-unit-files | grep -q "^ntpd.service"'
{%- else %}
# ================= CentOS 6:ntpd =================
ntp-install:
pkg.installed:
- pkgs:
- ntp
- ntpdate
ntp-config:
file.managed:
- name: /etc/ntp.conf
- user: root
- group: root
- mode: '0644'
- backup: minion
- contents: |
# 由 SaltStack 管理
driftfile /var/lib/ntp/drift
restrict default kod nomodify notrap nopeer noquery
restrict 127.0.0.1
restrict -6 ::1
{%- for s in servers %}
server {{ s }} iburst
{%- endfor %}
server 127.127.1.0
fudge 127.127.1.0 stratum 10
includefile /etc/ntp/crypto/pw
keys /etc/ntp/keys
- require:
- pkg: ntp-install
# 首次启动前先强制同步一次(时间偏差过大时 ntpd 会拒绝自动纠正)
ntpdate-once:
cmd.run:
- name: 'ntpdate -u {{ servers[0] }}'
- unless: 'test -f /var/lib/salt-ntpdate-done'
- require:
- pkg: ntp-install
ntpdate-flag:
file.touch:
- name: /var/lib/salt-ntpdate-done
- require:
- cmd: ntpdate-once
ntp-service:
service.running:
- name: ntpd
- enable: True
- watch:
- file: ntp-config
- require:
- cmd: ntpdate-once
# 兜底:加一条 cron 每天校时
ntpdate-cron:
cron.present:
- name: '/usr/sbin/ntpdate -u {{ servers[0] }} > /dev/null 2>&1'
- user: root
- minute: '10'
- hour: '4'
- identifier: salt-ntpdate-daily
{%- endif %}
执行与验证
salt '*' saltutil.refresh_pillar
salt '*' state.sls init.ntp test=True
salt '*' state.sls init.ntp
# 验证
salt '*' cmd.run 'date'
salt '*' cmd.run 'timedatectl' # CentOS 7
salt -G 'osmajorrelease:7' cmd.run 'chronyc sources -v'
salt -G 'osmajorrelease:6' cmd.run 'ntpq -p'
# 检查所有机器时间是否一致(差异应在秒级)
salt '*' cmd.run 'date +%s' --out=txt
详见 08-03-时间同步与Xinetd.md
20.8 实战:内核参数(sysctl)与 limits 优化下发
/srv/pillar/sysctl.sls
sysctl_params:
# ---- 网络 ----
net.ipv4.tcp_syncookies: 1
net.ipv4.tcp_tw_reuse: 1
net.ipv4.tcp_fin_timeout: 30
net.ipv4.tcp_keepalive_time: 600
net.ipv4.tcp_max_syn_backlog: 65536
net.ipv4.tcp_max_tw_buckets: 20000
net.ipv4.ip_local_port_range: '1024 65000'
net.core.somaxconn: 65535
net.core.netdev_max_backlog: 262144
net.core.rmem_max: 16777216
net.core.wmem_max: 16777216
net.ipv4.ip_forward: 0
net.ipv4.conf.all.rp_filter: 1
net.ipv4.icmp_echo_ignore_broadcasts: 1
# ---- 内存与文件 ----
vm.swappiness: 10
vm.overcommit_memory: 1
vm.max_map_count: 262144
fs.file-max: 1000000
fs.inotify.max_user_watches: 524288
# ---- 内核 ----
kernel.sysrq: 0
kernel.pid_max: 65536
limits_conf:
- domain: '*'
type: soft
item: nofile
value: 65535
- domain: '*'
type: hard
item: nofile
value: 65535
- domain: '*'
type: soft
item: nproc
value: 65535
- domain: '*'
type: hard
item: nproc
value: 65535
- domain: nginx
type: soft
item: nofile
value: 100000
- domain: nginx
type: hard
item: nofile
value: 100000
/srv/salt/init/sysctl.sls
{#- 方式一:用 sysctl.present 状态模块,逐项管理(推荐,最幂等)-#}
{%- for key, value in salt['pillar.get']('sysctl_params', {}).items() %}
sysctl-{{ key }}:
sysctl.present:
- name: {{ key }}
- value: '{{ value }}'
- config: /etc/sysctl.d/99-salt-tuning.conf
{%- endfor %}
{#- 方式二:整个文件托管 + sysctl -p,一次生效 -#}
sysctl-conf-file:
file.managed:
- name: /etc/sysctl.d/99-salt-tuning.conf
- user: root
- group: root
- mode: '0644'
- backup: minion
- contents: |
# 由 SaltStack 统一下发,请勿手工修改
{%- for key, value in salt['pillar.get']('sysctl_params', {}).items() | sort %}
{{ key }} = {{ value }}
{%- endfor %}
sysctl-reload:
cmd.run:
{%- if grains['osmajorrelease'] | int >= 7 %}
- name: 'sysctl --system'
{%- else %}
- name: 'sysctl -p /etc/sysctl.d/99-salt-tuning.conf'
{%- endif %}
- onchanges: # ★ 只在文件变化时才 reload
- file: sysctl-conf-file
/srv/salt/init/limits.sls
{#- 方式一:用 file.managed 生成独立的 limits.d 文件(推荐,不动系统主文件)-#}
limits-conf:
file.managed:
- name: /etc/security/limits.d/99-salt-limits.conf
- user: root
- group: root
- mode: '0644'
- makedirs: True
- backup: minion
- contents: |
# 由 SaltStack 统一下发(重新登录后生效)
# <domain> <type> <item> <value>
{%- for l in salt['pillar.get']('limits_conf', []) %}
{{ '%-12s' | format(l.domain) }}{{ '%-8s' | format(l.type) }}{{ '%-10s' | format(l.item) }}{{ l.value }}
{%- endfor %}
{#- CentOS 6 的 nproc 默认被 90-nproc.conf 限制为 1024,必须一起改 -#}
{%- if grains['osmajorrelease'] | int == 6 %}
fix-90-nproc:
file.replace:
- name: /etc/security/limits.d/90-nproc.conf
- pattern: '^\*\s+soft\s+nproc\s+\d+'
- repl: '* soft nproc 65535'
- append_if_not_found: True
- onlyif: 'test -f /etc/security/limits.d/90-nproc.conf'
{%- else %}
fix-20-nproc:
file.replace:
- name: /etc/security/limits.d/20-nproc.conf
- pattern: '^\*\s+soft\s+nproc\s+\d+'
- repl: '* soft nproc 65535'
- append_if_not_found: True
- onlyif: 'test -f /etc/security/limits.d/20-nproc.conf'
{%- endif %}
# systemd 管理的服务不读 limits.conf,需要单独给 unit 设 LimitNOFILE(CentOS 7)
{%- if grains['osmajorrelease'] | int >= 7 %}
systemd-global-limits:
file.append:
- name: /etc/systemd/system.conf
- text:
- 'DefaultLimitNOFILE=65535'
- 'DefaultLimitNPROC=65535'
systemd-daemon-reexec:
cmd.run:
- name: 'systemctl daemon-reexec'
- onchanges:
- file: systemd-global-limits
{%- endif %}
源文档 LNMP 案例中的等价写法(用 file.append 直接追加到主文件):
cmd-ulimit:
cmd.run:
- name:
ulimit -n 65536
- user: root
/etc/security/limits.conf:
file.append:
- text:
- nginx soft nofile 65536
- nginx hard nofile 65536
说明:
cmd.run: ulimit -n 65536只对该次命令的子 shell 生效,退出即失效,属于源文档中的教学示例。生产中应通过limits.conf/limits.d// systemd 的LimitNOFILE实现持久化。
执行与验证
salt '*' saltutil.refresh_pillar
salt '*' state.sls init.sysctl test=True
salt '*' state.sls init.sysctl
salt '*' state.sls init.limits
# 验证
salt '*' cmd.run 'sysctl -a 2>/dev/null | grep -E "somaxconn|swappiness|file-max"'
salt '*' cmd.run 'cat /etc/sysctl.d/99-salt-tuning.conf'
salt '*' cmd.run 'cat /etc/security/limits.d/99-salt-limits.conf'
salt '*' cmd.run 'ulimit -n' # 注意:需重新登录才生效
salt '*' cmd.run 'su - deploy -c "ulimit -n"'
salt '*' cmd.run 'cat /proc/$(pgrep -f nginx | head -1)/limits' # 看进程实际限制
20.9 实战:系统初始化标准化(新机一键 init)
这是 SaltStack 在生产中最有价值的场景:新机器上线后一条命令完成全部标准化。
目录结构
/srv/salt/init/
├── init.sls ← 汇总入口,include 全部子状态
├── yum.sls ← 配置 yum 源
├── selinux.sls ← 关闭 SELinux
├── firewall.sls ← 防火墙(6/7 分支)
├── pkgs.sls ← 安装常用工具
├── hosts.sls ← 分发 /etc/hosts
├── ntp.sls ← 时间同步
├── ssh.sls ← sshd 加固
├── sysctl.sls ← 内核参数
├── limits.sls ← 资源限制
├── services.sls ← 关闭无用服务
├── history.sls ← 命令历史审计
├── motd.sls ← 登录提示
└── files/
├── local.repo
├── epel.repo
├── CentOS-Base.repo
├── hosts.jinja
├── sshd_config.jinja
└── profile_history.sh
/srv/salt/init/init.sls —— 汇总入口
include:
- init.yum # order:1,必须最先
- init.selinux
- init.pkgs
- init.hosts
- init.ntp
- init.sysctl
- init.limits
- init.services
- init.history
- init.motd
- init.firewall
- init.ssh # 放最后,改 sshd 风险最高
init-done-flag:
file.managed:
- name: /etc/salt-init-done
- contents: |
SaltStack 系统初始化完成
minion : {{ grains['id'] }}
os : {{ grains['os'] }} {{ grains['osrelease'] }}
init time : {{ salt['cmd.run']('date +"%F %T"') }}
- order: last
/srv/salt/init/yum.sls —— 配置 yum 源(必须最先执行)
{%- set major = grains['osmajorrelease'] | int %}
# 备份原有源(只做一次)
backup-old-repos:
cmd.run:
- name: 'mkdir -p /etc/yum.repos.d/bak && mv -f /etc/yum.repos.d/*.repo /etc/yum.repos.d/bak/ 2>/dev/null; true'
- creates: /etc/yum.repos.d/bak
- order: 1
yum-base-repo:
file.managed:
- name: /etc/yum.repos.d/CentOS-Base.repo
- source: salt://init/files/CentOS-Base.repo
- mode: '0644'
- order: 2
- require:
- cmd: backup-old-repos
yum-epel-repo:
file.managed:
- name: /etc/yum.repos.d/epel.repo
- source: salt://init/files/epel.repo
- mode: '0644'
- order: 2
- require:
- cmd: backup-old-repos
yum-local-repo:
file.managed:
- name: /etc/yum.repos.d/local.repo
- source: salt://init/files/local.repo
- mode: '0644'
- order: 2
- require:
- cmd: backup-old-repos
yum-clean-makecache:
cmd.run:
- name: 'yum clean all && yum makecache'
- order: 3
- onchanges:
- file: yum-base-repo
- file: yum-epel-repo
- file: yum-local-repo
/srv/salt/init/files/local.repo(源文档的实验环境源,完整保留)
[local]
name=local
baseurl=file:///yum/
enabled=1
gpgcheck=0
[epel]
name=epel
baseurl=http://10.1.1.1/epel
enabled=1
gpgcheck=0
[centos163]
name=centos163
baseurl=http://10.1.1.1/centos163
enabled=1
gpgcheck=0
/srv/salt/init/selinux.sls —— 关闭 SELinux
selinux-config:
file.replace:
- name: /etc/selinux/config
- pattern: '^SELINUX=.*'
- repl: 'SELINUX=disabled'
- append_if_not_found: True
- backup: '.bak'
- onlyif: 'test -f /etc/selinux/config'
# 立即临时生效(重启后靠配置文件生效)
selinux-setenforce-now:
cmd.run:
- name: 'setenforce 0'
- onlyif: 'test -x /usr/sbin/getenforce && test "$(getenforce)" = "Enforcing"'
{#- 也可以用专用状态模块(需要 policycoreutils-python) -#}
#selinux-permissive:
# selinux.mode:
# - name: permissive
/srv/salt/init/pkgs.sls —— 安装常用工具
include:
- init.yum
base-tools:
pkg.installed:
- pkgs:
- vim-enhanced
- wget
- curl
- net-tools
- lsof
- unzip
- zip
- tree
- telnet
- bash-completion
- lrzsz
- sysstat
- iotop
- dstat
- tcpdump
- nmap-ncat
- rsync
- screen
- psmisc
- man-pages
- gcc
- make
- git
{%- if grains['osmajorrelease'] | int >= 7 %}
- htop
- iproute
- policycoreutils-python
- yum-utils
{%- else %}
- iproute
- policycoreutils-python
{%- endif %}
- require:
- cmd: yum-clean-makecache
remove-insecure-pkgs:
pkg.removed:
- pkgs:
- telnet-server
- rsh-server
- ypserv
- tftp-server
/srv/salt/init/services.sls —— 关闭无用服务
{%- set major = grains['osmajorrelease'] | int %}
{%- if major >= 7 %}
{%- set useless = ['postfix', 'cups', 'avahi-daemon', 'bluetooth', 'atd'] %}
{%- else %}
{%- set useless = ['postfix', 'cups', 'avahi-daemon', 'bluetooth', 'atd', 'iscsi', 'rpcbind', 'nfslock'] %}
{%- endif %}
{%- for svc in useless %}
disable-{{ svc }}:
service.dead:
- name: {{ svc }}
- enable: False
{%- if major >= 7 %}
- onlyif: 'systemctl list-unit-files | grep -q "^{{ svc }}.service"'
{%- else %}
- onlyif: 'chkconfig --list 2>/dev/null | grep -q "^{{ svc }}"'
{%- endif %}
{%- endfor %}
# 必须开启的服务
{%- for svc in ['crond', 'rsyslog', 'sshd'] %}
enable-{{ svc }}:
service.running:
- name: {{ svc }}
- enable: True
{%- endfor %}
/srv/salt/init/history.sls —— 命令历史审计
history-audit:
file.managed:
- name: /etc/profile.d/salt_history.sh
- user: root
- group: root
- mode: '0644'
- contents: |
# 由 SaltStack 下发:命令历史审计
export HISTSIZE=10000
export HISTFILESIZE=10000
export HISTTIMEFORMAT="%F %T `whoami` "
export PROMPT_COMMAND='{ msg=$(history 1 | { read x y; echo $y; }); logger -p local6.notice "[euid=$(whoami)]":$(who am i)":$msg"; }'
readonly HISTSIZE
readonly HISTFILESIZE
readonly HISTTIMEFORMAT
rsyslog-history-conf:
file.managed:
- name: /etc/rsyslog.d/history.conf
- mode: '0644'
- contents: |
local6.notice /var/log/cmd_history.log
- makedirs: True
rsyslog-service:
service.running:
- name: rsyslog
- enable: True
- watch:
- file: rsyslog-history-conf
/srv/salt/init/motd.sls
motd-banner:
file.managed:
- name: /etc/motd
- mode: '0644'
- contents: |
================================================================
Hostname : {{ grains['id'] }} ({{ grains['fqdn'] }})
System : {{ grains['os'] }} {{ grains['osrelease'] }} {{ grains['cpuarch'] }}
Kernel : {{ grains['kernelrelease'] }}
CPU/MEM : {{ grains['num_cpus'] }} cores / {{ grains['mem_total'] }} MB
Role : {{ salt['grains.get']('role', 'undefined') }}
Managed by SaltStack —— 请勿手工修改被 Salt 管理的配置文件
================================================================
top.sls 与新机一键执行
# /srv/salt/top.sls
base:
'*':
- init # 所有机器都做标准初始化
'G@role:webserver':
- match: grain
- httpd
- users
- monitor.zabbix_agent
'G@role:dbserver':
- match: grain
- mysql
- users
- monitor.zabbix_agent
# ---------- 新机上线流程 ----------
# ① 装 minion 并指向 master(也可用 salt-ssh 完成这一步)
ssh root@10.1.1.5 'yum install -y salt-minion && \
sed -i "s/^#master: salt/master: 10.1.1.2/" /etc/salt/minion && \
echo "minion3" > /etc/salt/minion_id && \
systemctl enable --now salt-minion'
# ② master 端接受 key
salt-key -L
salt-key -a minion3 -y
# ③ 打角色标签
salt 'minion3' grains.setval role webserver
salt 'minion3' grains.setval idc shanghai
salt 'minion3' saltutil.refresh_grains
# ④ 刷新 pillar,空跑检查
salt 'minion3' saltutil.refresh_pillar
salt 'minion3' state.highstate test=True --out=highstate
# ⑤ 一键初始化(★ 就这一条命令)
salt 'minion3' state.highstate
# ⑥ 验证
salt 'minion3' cmd.run 'cat /etc/salt-init-done'
salt 'minion3' cmd.run 'getenforce; ulimit -n; date; sysctl net.core.somaxconn'
salt 'minion3' cmd.run 'rpm -qa | wc -l'
salt 'minion3' service.status sshd
salt 'minion3' state.highstate test=True # 再空跑一次,应全部 Changes: {} → 已收敛
20.10 实战:LNMP 环境编排(源文档完整案例)
在 master 上准备目录及配置文件:
[root@master ~]# mkdir /srv/salt/lnmp/conf -p
# 然后在该目录中准备 lnmp 所需配置文件(local.repo、www.conf、nginx.conf、test.php 等,过程省略)
目录结构
/srv/salt/
├── lnmp.sls ← 源文档写法(单文件)
└── lnmp/
└── conf/
├── local.repo
├── www.conf
├── nginx.conf
└── test.php
/srv/salt/lnmp.sls(源文档完整内容,一字不改保留)
yum-config:
file.managed:
- name: /etc/yum.repos.d/local.repo
- source: salt://lnmp/conf/local.repo
lnmp-install:
pkg.installed:
- names:
- mariadb
- mariadb-server
- php
- php-mysql
- php-gd
- libjpeg-turbo-devel
- php-ldap
- php-odbc
- php-pear
- php-xml
- php-xmlrpc
- php-mbstring
- php-bcmath
- php-common
- php-fpm
- php-pecl-zendopcache
- nginx
- memcached
- php-pecl-memcache
php-fpm-config:
file.managed:
- name: /etc/php-fpm.d/www.conf
- source: salt://lnmp/conf/www.conf
nginx-config:
file.managed:
- name: /etc/nginx/nginx.conf
- source: salt://lnmp/conf/nginx.conf
test.php:
file.managed:
- name: /usr/share/nginx/html/test.php
- source: salt://lnmp/conf/test.php
/var/run/php-fpm/:
file.directory:
- user: nginx
- group: nginx
cmd-ulimit:
cmd.run:
- name:
ulimit -n 65536
- user: root
/etc/security/limits.conf:
file.append:
- text:
- nginx soft nofile 65536
- nginx hard nofile 65536
mariadb-run:
service.running:
- name: mariadb
- enable: True
php-fpm-run:
service.running:
- name: php-fpm
- enable: True
nginx-run:
service.running:
- name: nginx
- enable: True
memcached-run:
service.running:
- name: memcached
- enable: True
执行部署:
[root@master ~]# salt "minion*" state.sls lnmp
改进版:加上依赖、watch 联动与模板(生产推荐写法)
# /srv/salt/lnmp/init.sls
include:
- init.yum
- init.limits
lnmp-install:
pkg.installed:
- pkgs:
- mariadb
- mariadb-server
- php
- php-mysql
- php-gd
- libjpeg-turbo-devel
- php-ldap
- php-odbc
- php-pear
- php-xml
- php-xmlrpc
- php-mbstring
- php-bcmath
- php-common
- php-fpm
- php-pecl-zendopcache
- nginx
- memcached
- php-pecl-memcache
- require:
- sls: init.yum
php-fpm-config:
file.managed:
- name: /etc/php-fpm.d/www.conf
- source: salt://lnmp/conf/www.conf.jinja
- template: jinja
- backup: minion
- require:
- pkg: lnmp-install
nginx-config:
file.managed:
- name: /etc/nginx/nginx.conf
- source: salt://lnmp/conf/nginx.conf.jinja
- template: jinja
- backup: minion
- require:
- pkg: lnmp-install
test-php-page:
file.managed:
- name: /usr/share/nginx/html/test.php
- source: salt://lnmp/conf/test.php
- user: nginx
- group: nginx
- mode: '0644'
- require:
- pkg: lnmp-install
php-fpm-run-dir:
file.directory:
- name: /var/run/php-fpm/
- user: nginx
- group: nginx
- makedirs: True
- require:
- pkg: lnmp-install
nginx-limits:
file.append:
- name: /etc/security/limits.conf
- text:
- 'nginx soft nofile 65536'
- 'nginx hard nofile 65536'
mariadb-run:
service.running:
- name: mariadb
- enable: True
- require:
- pkg: lnmp-install
php-fpm-run:
service.running:
- name: php-fpm
- enable: True
- watch: # ★ 配置变了自动重启
- file: php-fpm-config
- require:
- file: php-fpm-run-dir
nginx-run:
service.running:
- name: nginx
- enable: True
- reload: True
- watch: # ★ 配置变了自动 reload
- file: nginx-config
- require:
- service: php-fpm-run
memcached-run:
service.running:
- name: memcached
- enable: True
- require:
- pkg: lnmp-install
lnmp-verify:
cmd.run:
- name: 'curl -s http://127.0.0.1/test.php | grep -q "phpinfo\|PHP Version"'
- require:
- service: nginx-run
- service: php-fpm-run
执行与验证
salt "minion*" state.sls lnmp test=True
salt -b 1 "minion*" state.sls lnmp
# 验证
salt "minion*" service.status nginx
salt "minion*" service.status php-fpm
salt "minion*" service.status mariadb
salt "minion*" service.status memcached
salt "minion*" cmd.run 'netstat -tunlp | grep -E "80|3306|9000|11211"'
salt "minion*" cmd.run 'curl -s http://127.0.0.1/test.php | head -20'
salt "minion*" cmd.run 'php -v; nginx -v; mysql -V'
20.11 典型 LNMP 架构示意
基于 SaltStack 可批量构建如下 LNMP 高可用/分布式架构:
nginx1 nginx2
php-fpm1 php-fpm2 lnmp1 lnmp2
mycat mycat备 lnmp
memcached memcached
mysql1 mysql2 <---- zabbix
<---- saltstack
- nginx 层做负载均衡/反向代理;
- php-fpm 层处理动态请求;
- mycat 做 MySQL 中间件,配合主从;
- memcached 做缓存;
- zabbix 做监控;
- saltstack 做统一配置管理与批量部署。
详见 10-05-LAMP与LNMP架构搭建.md
二十一、进阶
21.1 多环境(base / dev / prod)
# /etc/salt/master
file_roots:
base:
- /srv/salt/base
dev:
- /srv/salt/dev
- /srv/salt/base # ★ 找不到时回退到 base,避免重复维护
prod:
- /srv/salt/prod
- /srv/salt/base
pillar_roots:
base:
- /srv/pillar/base
dev:
- /srv/pillar/dev
prod:
- /srv/pillar/prod
# 默认环境
environment: base
# 只允许 top.sls 在 base 环境中定义(推荐,避免多环境 top 互相覆盖)
top_file_merging_strategy: same
state_top_saltenv: base
systemctl restart salt-master
mkdir -p /srv/salt/{base,dev,prod} /srv/pillar/{base,dev,prod}
环境切换的三种方式:
# ① 命令行指定环境
salt '*' state.sls nginx saltenv=prod
salt '*' state.highstate saltenv=prod
salt '*' state.apply nginx saltenv=dev
salt '*' cp.get_file salt://conf/x.conf /tmp/x.conf saltenv=prod
# ② 在 top.sls 中区分(每个环境一个 top.sls)
# ③ 在 minion 配置中固定该机器的环境(推荐,防误操作)
# /etc/salt/minion ← 固定这台机器只能用 prod 环境
environment: prod
# /srv/salt/base/top.sls ← 用 grains 里的 env 自动路由到对应环境
base:
'G@env:dev':
- match: grain
- init
prod:
'G@env:prod':
- match: grain
- init
- nginx
dev:
'G@env:dev':
- match: grain
- nginx
常见坑:
salt://的解析结果依赖当前saltenv。在 prod 环境下salt://nginx/files/x.conf找的是/srv/salt/prod/nginx/files/x.conf,若只在 base 里放了文件而 prod 没配回退目录,就会报找不到 source。
21.2 salt-ssh:无 agent 模式完整用法
yum install -y salt-ssh
roster 文件 /etc/salt/roster
# 最简写法:只写 host(用默认 root + 已有 SSH 密钥)
minion1:
host: 10.1.1.3
# 完整写法
minion2:
host: 10.1.1.4
user: root
passwd: 'RootPassw0rd' # 密码认证(首次没有密钥时用)
port: 22
sudo: False
timeout: 60
thin_dir: /tmp/.salt-thin # 上传的 salt 精简包存放目录
web3:
host: 10.1.1.5
user: deploy
priv: /root/.ssh/id_rsa # 私钥认证(推荐)
sudo: True # 用 sudo 提权
sudo_user: root
port: 2222
# 也可以按组织成组(用 nodegroups 或 glob 匹配 roster 里的 id)
db1:
host: 10.1.1.10
user: root
priv: /root/.ssh/id_rsa
minion_opts: # 为这台机器单独指定 minion 选项
grains:
role: dbserver
常用命令
# 基本用法:与 salt 几乎一致,只是把 salt 换成 salt-ssh
salt-ssh '*' test.ping
salt-ssh 'minion1' cmd.run 'hostname'
salt-ssh '*' grains.items
salt-ssh '*' pkg.install salt-minion
# 首次连接需要确认 host key,用 -i 自动接受
salt-ssh -i '*' test.ping
# 指定 roster 文件位置
salt-ssh --roster-file=/opt/myroster '*' test.ping
# 用 flat roster 之外的方式(cloud/ansible inventory 等)
salt-ssh --roster=flat '*' test.ping
# 控制并发(默认 25)
salt-ssh --max-procs=50 '*' cmd.run 'uptime'
# 执行状态(SLS 依然放在 master 的 file_roots)
salt-ssh '*' state.sls init
salt-ssh '*' state.highstate
salt-ssh '*' state.apply init test=True
# 排错
salt-ssh -l debug 'minion1' test.ping
salt-ssh --raw-shell 'minion1' 'uptime' # 直接跑原始 shell 命令
salt-ssh -W '*' test.ping # 不部署 thin,纯 shell 模式
★ 最经典用法:用 salt-ssh 给新机器装 minion(引导)
# /srv/salt/bootstrap/minion.sls
salt-minion-installed:
pkg.installed:
- name: salt-minion
salt-minion-config:
file.managed:
- name: /etc/salt/minion
- contents: |
master: 10.1.1.2
id: {{ grains['host'] }}
log_level: warning
- require:
- pkg: salt-minion-installed
salt-minion-service:
service.running:
- name: salt-minion
- enable: True
- watch:
- file: salt-minion-config
# ① 用 salt-ssh 把 minion 装上
salt-ssh -i '*' state.sls bootstrap.minion
# ② master 端接受 key,之后就走正常的高并发模式了
salt-key -L
salt-key -A -y
salt '*' test.ping
salt-ssh 的限制:
- 速度慢(逐台 SSH,即使并发也远不如 ZeroMQ);
- 不支持 Reactor/Beacon/mine 等依赖事件总线的特性;
- 需要目标机有 Python(或用
-W纯 shell 模式); - 每次执行都要上传/校验 thin 包,首次尤其慢。
21.3 Masterless 模式
# /etc/salt/minion (在这台机器本地)
file_client: local # ★ 关键:不向 master 请求,用本地文件
file_roots:
base:
- /srv/salt
pillar_roots:
base:
- /srv/pillar
# 把状态代码放到本机(scp / git clone / 打进镜像)
git clone git@git.example.com:ops/salt-states.git /srv/salt
git clone git@git.example.com:ops/salt-pillar.git /srv/pillar
# 执行(--local 等价于临时设 file_client: local)
salt-call --local state.apply
salt-call --local state.sls init
salt-call --local state.highstate
salt-call --local -l debug state.sls nginx
salt-call --local --file-root=/opt/mysalt --pillar-root=/opt/mypillar state.apply
salt-call --local grains.items
salt-call --local pillar.items
适用场景:
| 场景 | 说明 |
|---|---|
| 镜像/模板机制作 | 打 AMI、VMware 模板、Docker 镜像时跑一遍初始化,之后镜像即标准化 |
| 单机标准化 | 只有几台机器,不值得搭 master |
| master 故障应急 | master 挂了但必须改配置,用本地缓存的状态自救 |
| CI 中测试 SLS | 在容器里 salt-call --local state.sls xxx test=True 做语法与逻辑验证 |
| 网络隔离环境 | 目标机无法连接 master(严格隔离区),把状态包拷进去本地跑 |
| 容器构建 | Dockerfile 里 RUN salt-call --local state.apply |
21.4 Job 管理
# 提交异步任务,立即返回 jid
[root@master ~]# salt --async '*' state.highstate
Executed command with job ID: 20240822103015123456
# 查看当前正在运行的任务
salt-run jobs.active
salt '*' saltutil.running # 在 minion 端看正在跑什么
# 按 jid 查历史结果(job cache 里)
salt-run jobs.lookup_jid 20240822103015123456
salt-run jobs.lookup_jid 20240822103015123456 --out=highstate
salt '*' saltutil.find_job 20240822103015123456
# 列出历史任务
salt-run jobs.list_jobs
salt-run jobs.list_jobs search_function='state.*'
salt-run jobs.list_jobs search_target='minion1'
salt-run jobs.list_jobs start_time='2024-08-22T00:00:00' end_time='2024-08-23T00:00:00'
salt-run jobs.print_job 20240822103015123456
# ★ 杀掉卡住的任务
salt '*' saltutil.kill_job 20240822103015123456
salt '*' saltutil.term_job 20240822103015123456 # 先发 SIGTERM(较温和)
salt '*' saltutil.signal_job 20240822103015123456 15
salt 'minion1' saltutil.kill_all_jobs # 杀掉该 minion 上所有 salt 任务
job cache 配置
# /etc/salt/master
job_cache: True # 默认开启,结果存 /var/cache/salt/master/jobs
keep_jobs: 24 # 保留小时数(默认 24),机器多时不宜太大
gather_job_timeout: 10
# 大规模环境建议把 job cache 换成外部存储(避免 master 磁盘 inode 耗尽)
#master_job_cache: redis
#master_job_cache: mysql
# 清理过期 job cache(避免小文件把 inode 撑满,这是大规模部署的常见故障)
salt-run cache.clear_all
find /var/cache/salt/master/jobs -mtime +2 -type d | xargs rm -rf
df -i /var/cache
21.5 Returners:把结果写入 MySQL / Redis
默认执行结果只打印在终端,Returner 让结果持久化到外部存储,便于审计、报表、可视化。
思路与配置(MySQL)
# /etc/salt/master (或 /etc/salt/minion,取决于由谁写库)
mysql.host: '10.1.1.20'
mysql.user: 'salt'
mysql.pass: 'SaltR3turner'
mysql.db: 'salt'
mysql.port: 3306
# ★ 让所有任务自动写入(无需每次加 --return)
return: mysql
# 或者用 master_job_cache,由 master 统一写库(推荐:minion 不需要连数据库)
master_job_cache: mysql
event_return: mysql # 事件也写库
-- 建库建表(Salt 官方文档提供完整 schema,核心两张表)
CREATE DATABASE salt DEFAULT CHARACTER SET utf8;
USE salt;
CREATE TABLE `jids` (
`jid` varchar(255) NOT NULL,
`load` mediumtext NOT NULL,
UNIQUE KEY `jid` (`jid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
CREATE TABLE `salt_returns` (
`fun` varchar(50) NOT NULL,
`jid` varchar(255) NOT NULL,
`return` mediumtext NOT NULL,
`id` varchar(255) NOT NULL,
`success` varchar(10) NOT NULL,
`full_ret` mediumtext NOT NULL,
`alter_time` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
KEY `id` (`id`), KEY `jid` (`jid`), KEY `fun` (`fun`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
CREATE TABLE `salt_events` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`tag` varchar(255) NOT NULL,
`data` mediumtext NOT NULL,
`alter_time` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`master_id` varchar(255) NOT NULL,
PRIMARY KEY (`id`), KEY `tag` (`tag`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
yum install -y MySQL-python # CentOS 6/7 py2
# 或 pip install PyMySQL
# 单次指定 returner
salt '*' test.ping --return mysql
salt '*' state.highstate --return mysql
# 从 returner 里查历史
salt-run jobs.lookup_jid 20240822103015123456
-- 用 SQL 做运维报表
-- 最近失败的状态执行
SELECT id, fun, alter_time FROM salt_returns
WHERE success='False' AND alter_time > NOW() - INTERVAL 1 DAY;
-- 每台机器最后一次 highstate 时间(找出长期未收敛的机器)
SELECT id, MAX(alter_time) FROM salt_returns WHERE fun='state.highstate' GROUP BY id;
Redis returner
# /etc/salt/master
redis.host: '10.1.1.21'
redis.port: 6379
redis.db: '0'
redis.password: 'RedisPwd'
return: redis
yum install -y python-redis
salt '*' test.ping --return redis
redis-cli -h 10.1.1.21 keys 'minion*'
其他常用 returner:elasticsearch(配 Kibana 做可视化)、slack / smtp(失败告警)、local_cache(默认)、carbon(Graphite)、influxdb、postgres、mongo。
salt 'minion1' sys.list_returners # 查看可用 returner
salt 'minion1' sys.returner_doc mysql
21.6 Reactor 与 Beacon:事件驱动自动化
核心概念:
- Event Bus(事件总线):Salt 内部所有动作(认证、job 开始/结束、状态变化、minion 上下线)都会向 4505 消息总线发一条 event,每条 event 有一个
tag和一份data; - Beacon:跑在 minion 端的监控器,周期性检查本地资源(磁盘、进程、文件变化、日志、服务状态、inotify),发现符合条件就往总线发事件;
- Reactor:跑在 master 端,监听总线上的 event,tag 匹配某个模式时自动执行预定的状态/命令 —— 这就是"自愈"。
Beacon(minion 检测磁盘 > 85%)
↓ 发 event 到总线(tag: salt/beacon/minion1/diskusage/)
Event Bus
↓ Reactor 匹配 tag
Reactor(master)→ 自动执行 state.sls cleanup.disk → 清理日志
观察 event bus(学习 Reactor 的第一步)
# 在 master 上实时打印所有事件
salt-run state.event pretty=True
# 只看某类事件
salt-run state.event tagmatch='salt/beacon/*' pretty=True
salt-run state.event tagmatch='salt/job/*/ret/*' pretty=True
# 另开终端触发一些动作,观察输出
salt 'minion1' test.ping
systemctl restart salt-minion # 在 minion 上,观察认证事件
完整示例:磁盘超阈值自动清理
① minion 端配置 Beacon:
# /etc/salt/minion.d/beacons.conf
beacons:
diskusage:
- interval: 60 # 每 60 秒检查一次
- /: 85% # 根分区超 85% 就发事件
- /data: 90%
- /var/log: 80%
# 监控服务是否挂掉
service:
- interval: 30
- services:
nginx:
onchangeonly: True
salt-minion:
onchangeonly: True
# 监控关键文件被改动(依赖 pyinotify)
inotify:
- interval: 5
- files:
/etc/passwd:
mask:
- modify
/etc/ssh/sshd_config:
mask:
- modify
# 监控负载
load:
- interval: 60
- averages:
1m:
- 0.0
- 8.0
5m:
- 0.0
- 6.0
systemctl restart salt-minion
# 验证 beacon 已加载
salt 'minion1' beacons.list
salt 'minion1' beacons.list_available
# 也可以用命令行动态添加 beacon
salt 'minion1' beacons.add diskusage "[{'/': '85%'}, {'interval': 60}]"
salt 'minion1' beacons.disable_beacon diskusage
② master 端配置 Reactor:
# /etc/salt/master.d/reactor.conf
reactor:
- 'salt/beacon/*/diskusage/*': # tag 通配
- /srv/reactor/disk_cleanup.sls
- 'salt/beacon/*/service/*':
- /srv/reactor/service_restart.sls
- 'salt/minion/*/start': # minion 上线自动初始化
- /srv/reactor/minion_start.sls
- 'salt/auth': # 自动接受符合命名规范的 key
- /srv/reactor/auth_accept.sls
③ Reactor SLS(注意:这里用的是 reactor 专用语法,不是普通状态):
# /srv/reactor/disk_cleanup.sls
clean_disk:
local.state.sls: # local = 在 minion 上执行执行模块/状态
- tgt: {{ data['id'] }} # 触发事件的那台 minion
- arg:
- cleanup.disk
notify_disk_alert:
local.cmd.run:
- tgt: {{ data['id'] }}
- arg:
- 'curl -s -X POST https://alert.example.com/hook -d "host={{ data["id"] }}&msg=disk_full"'
# /srv/reactor/service_restart.sls
{% if data['data'].get('nginx') == False %}
restart_nginx:
local.service.restart:
- tgt: {{ data['id'] }}
- arg:
- nginx
{% endif %}
# /srv/reactor/minion_start.sls
# minion 一上线自动跑初始化
init_new_minion:
local.state.apply:
- tgt: {{ data['id'] }}
- arg:
- init
# /srv/reactor/auth_accept.sls
# 只自动接受符合命名规范的 minion(比 auto_accept: True 安全)
{% if 'act' in data and data['act'] == 'pend' and data['id'].startswith('web-') %}
accept_key:
wheel.key.accept:
- match: {{ data['id'] }}
{% endif %}
④ 清理状态:
# /srv/salt/cleanup/disk.sls
clean-old-logs:
cmd.run:
- name: |
find /var/log -name "*.log" -mtime +7 -size +100M -exec truncate -s 0 {} \;
find /var/log -name "*.gz" -mtime +30 -delete
find /tmp -type f -mtime +7 -delete
journalctl --vacuum-size=200M 2>/dev/null || true
yum clean all
report-disk-after-clean:
cmd.run:
- name: 'df -h / /data >> /var/log/salt-disk-cleanup.log'
- require:
- cmd: clean-old-logs
systemctl restart salt-master
# 验证:手工造一个大文件把磁盘打满,观察是否自动清理
salt 'minion1' cmd.run 'fallocate -l 5G /var/log/bigfile'
salt-run state.event pretty=True | grep -A5 diskusage
tail -f /var/log/salt/master
Reactor 可用的低层执行方式:
| 前缀 | 含义 |
|---|---|
local.<模块.函数> |
在指定 minion 上执行执行模块/状态(等价于 salt 命令) |
runner.<runner> |
在 master 上执行 runner |
wheel.<模块> |
操作 master 自身(如 wheel.key.accept 管理密钥) |
caller.<模块.函数> |
在触发事件的 minion 本地执行(用于 minion 端 reactor) |
21.7 Salt API 与二次开发
salt-api 提供 RESTful 接口,是自建运维平台/CMDB 对接 Salt 的标准方式。
yum install -y salt-api
# 创建认证用户(使用 PAM 认证)
useradd -M -s /sbin/nologin saltapi
echo 'SaltApiPwd' | passwd --stdin saltapi
# 生成自签证书
salt-call --local tls.create_self_signed_cert
# 证书默认在 /etc/pki/tls/certs/localhost.crt 与 /etc/pki/tls/certs/localhost.key
# /etc/salt/master.d/api.conf
rest_cherrypy:
port: 8000
host: 0.0.0.0
ssl_crt: /etc/pki/tls/certs/localhost.crt
ssl_key: /etc/pki/tls/certs/localhost.key
debug: False
disable_ssl: False
# /etc/salt/master.d/eauth.conf ← 外部认证与权限控制(重要)
external_auth:
pam:
saltapi:
- .* # 允许所有模块(生产应精细化)
- '@runner'
- '@wheel'
- '@jobs'
readonly_user:
- 'minion*':
- test.*
- grains.*
- status.*
systemctl restart salt-master
systemctl enable --now salt-api
netstat -tunlp | grep 8000
调用示例
# ① 登录获取 token
curl -sSk https://10.1.1.2:8000/login \
-H 'Accept: application/x-yaml' \
-d username=saltapi -d password=SaltApiPwd -d eauth=pam
# 返回中的 token 用于后续请求
TOKEN=xxxxxxxxxxxxxxxxxxxxxxxx
# ② 执行命令
curl -sSk https://10.1.1.2:8000/ \
-H "X-Auth-Token: $TOKEN" \
-H 'Accept: application/json' \
-d client=local -d tgt='*' -d fun=test.ping
# ③ 执行状态
curl -sSk https://10.1.1.2:8000/ \
-H "X-Auth-Token: $TOKEN" \
-d client=local -d tgt='minion1' -d fun=state.sls -d arg=nginx
# ④ 异步执行
curl -sSk https://10.1.1.2:8000/ \
-H "X-Auth-Token: $TOKEN" \
-d client=local_async -d tgt='*' -d fun=state.highstate
# ⑤ 调用 runner
curl -sSk https://10.1.1.2:8000/ \
-H "X-Auth-Token: $TOKEN" \
-d client=runner -d fun=manage.up
# ⑥ 一次性认证并执行(run 端点,不用先取 token)
curl -sSk https://10.1.1.2:8000/run \
-d username=saltapi -d password=SaltApiPwd -d eauth=pam \
-d client=local -d tgt='*' -d fun=test.ping
# Python 封装示例
import requests
import urllib3
urllib3.disable_warnings()
class SaltAPI(object):
def __init__(self, url, user, passwd):
self.url = url.rstrip('/')
self.session = requests.Session()
self.session.verify = False
r = self.session.post(self.url + '/login', data={
'username': user, 'password': passwd, 'eauth': 'pam'
}, headers={'Accept': 'application/json'})
self.token = r.json()['return'][0]['token']
def cmd(self, tgt, fun, arg=None, client='local', expr_form='glob'):
data = {'client': client, 'tgt': tgt, 'fun': fun, 'expr_form': expr_form}
if arg:
data['arg'] = arg
r = self.session.post(self.url, data=data, headers={
'X-Auth-Token': self.token, 'Accept': 'application/json'})
return r.json()['return'][0]
if __name__ == '__main__':
api = SaltAPI('https://10.1.1.2:8000', 'saltapi', 'SaltApiPwd')
print(api.cmd('*', 'test.ping'))
print(api.cmd('minion1', 'state.sls', 'nginx'))
print(api.cmd('minion1', 'cmd.run', 'uptime'))
自定义模块二次开发:在 file_roots 下建目录,写 Python 后 saltutil.sync_all 同步。
| 目录 | 用途 |
|---|---|
_modules/ |
自定义执行模块(salt '*' mymod.myfunc) |
_states/ |
自定义状态模块(SLS 里 mymod.myfunc:) |
_grains/ |
自定义 grains |
_returners/ |
自定义 returner |
_runners/ |
自定义 runner |
_pillar/ |
外部 pillar(对接 CMDB / 数据库) |
_beacons/ |
自定义 beacon |
_utils/ |
公共工具函数 |
# /srv/salt/_modules/myapp.py
def version():
'''
获取本机部署的应用版本
CLI Example::
salt '*' myapp.version
'''
try:
with open('/opt/app/VERSION') as f:
return f.read().strip()
except IOError:
return 'not deployed'
def deploy(version):
'''
salt '*' myapp.deploy 1.2.3
'''
return __salt__['cmd.run']('/opt/scripts/deploy.sh {0}'.format(version))
salt '*' saltutil.sync_modules
salt '*' saltutil.sync_all
salt '*' myapp.version
salt '*' myapp.deploy 1.2.3
21.8 与 Git 结合管理状态代码
# ① 把 file_roots 与 pillar_roots 纳入版本控制
cd /srv/salt && git init && git remote add origin git@git.example.com:ops/salt-states.git
cd /srv/pillar && git init && git remote add origin git@git.example.com:ops/salt-pillar.git
# ② .gitignore(不要提交明文密码;pillar 里的敏感数据用 GPG 加密后再提交)
cat > /srv/pillar/.gitignore <<'EOF'
*.key
*.pem
secrets_plain/
EOF
# ③ 上线流程:改代码 → PR → review → merge → master 上 git pull → test=True → 正式执行
cd /srv/salt && git pull origin master
salt '*' state.highstate test=True
salt -b 10 '*' state.highstate
gitfs:直接把 Git 仓库当作 file_roots(无需在 master 上手工 pull)
# /etc/salt/master
fileserver_backend:
- gitfs
- roots # 保留本地 roots 作为兜底
gitfs_provider: pygit2 # 或 gitpython
gitfs_remotes:
- git@git.example.com:ops/salt-states.git:
- root: salt # 仓库中的子目录作为根
- base: master # 把 git 的 master 分支映射为 salt 的 base 环境
- pubkey: /root/.ssh/id_rsa.pub
- privkey: /root/.ssh/id_rsa
- https://git.example.com/ops/salt-public.git
# ★ Git 分支/tag 自动映射为 salt 环境:master→base,其他分支→同名环境
gitfs_base: master
gitfs_root: salt
gitfs_update_interval: 60
# pillar 也可以走 git
ext_pillar:
- git:
- master git@git.example.com:ops/salt-pillar.git
yum install -y python-pygit2 # 或 GitPython
systemctl restart salt-master
# 手工触发一次更新
salt-run fileserver.update
salt-run fileserver.file_list
# 现在 git 的 dev 分支就是 salt 的 dev 环境
salt '*' state.sls nginx saltenv=dev
详见 14-01-Git版本控制.md
21.9 性能调优
① worker_threads
# /etc/salt/master
worker_threads: 10 # 默认 5
- 作用:处理 minion 返回、pillar 请求、文件请求的工作进程数;
- 经验值:CPU 核数的 1.5 ~ 2 倍,不要盲目调大(每个进程都占内存,且过多进程反而抢 CPU);
- 判断是否不足:master 日志出现大量
Timeout、Salt request timed out;minion 频繁重连;salt '*' test.ping返回台数忽多忽少。
② batch 执行
salt -b 20 '*' state.highstate # 每批 20 台
salt -b 10% '*' state.highstate # 每批 10%
salt -b 5 --batch-wait=3 '*' service.restart nginx
- 降低 master 瞬时压力(一批一批返回,worker 不被打满);
- 保护业务(滚动重启,不会全站同时中断);
- 千台以上环境必须用 batch,否则 master 会被返回风暴打垮。
③ timeout 与 gather_job_timeout
# /etc/salt/master
timeout: 15 # 等待返回的秒数(默认 5,网络差或任务重就调大)
gather_job_timeout: 15 # 收集 job 结果的超时
salt -t 300 '*' state.highstate # 单次任务临时调大
salt -t 600 '*' pkg.upgrade
salt --async '*' pkg.upgrade # 超长任务直接改用异步 + jid 查询
④ 其他调优点
# /etc/salt/master
# 消息队列高水位(大规模时调大,避免消息被丢)
pub_hwm: 10000
# 关闭不需要的 fileserver backend,减少启动与查找开销
fileserver_backend:
- roots
# 缩短 job cache 保留时间,防止小文件耗尽 inode
keep_jobs: 12
# 大规模环境把 job cache 迁到外部存储
#master_job_cache: redis
# 关闭 minion 的 mine(如果不用)
#mine_enabled: False
# /etc/salt/minion
# 随机化连接 master 的时间,避免 minion 同时重连打垮 master(大规模必配)
random_reauth_delay: 60
recon_default: 1000
recon_max: 60000
recon_randomize: True
# 减少不必要的 grains 采集
#disable_grains:
# - esxi
# minion 端调度任务错峰
splay: 300
# 控制 minion 缓存
grains_cache: True
grains_cache_expiration: 300
⑤ 架构层面
- Syndic 分层:单 master 建议不超过 2000~5000 个 minion,超过就上 Syndic;
- 多 master 高可用:minion 配多个 master +
master_type: failover; - file server 分流:大文件分发不要走
salt://(走 4506 会占用 worker),改用 nginx/HTTP +file.managed的source: http://...+source_hash; - 调度错峰:用
schedule让 minion 定期自己跑 highstate 时加splay打散。
# /etc/salt/minion ← minion 端定时自我收敛(Pull 模式,减轻 master 推送压力)
schedule:
highstate_run:
function: state.apply
minutes: 60
splay: 600 # 在 0~600 秒内随机错峰
return_job: False
二十二、排错清单
22.0 排错第一命令:salt-call -l debug(务必记住)
遇到任何 SLS / pillar / 模板 / 认证问题,第一反应是登录到出问题的那台 minion,执行:
[root@minion1 ~]# salt-call -l debug state.sls <出问题的sls>
[root@minion1 ~]# salt-call -l debug state.highstate
[root@minion1 ~]# salt-call -l debug pillar.items
[root@minion1 ~]# salt-call -l debug test.ping
它会打印:完整 Python 异常堆栈、SLS 渲染前后内容、向 master 请求文件/pillar 的每一步与结果、AES 认证细节。master 端只给一句概要,minion 端 debug 给你全部真相。
日志位置:
| 文件 | 说明 |
|---|---|
/var/log/salt/master |
master 日志(认证、publish、worker 异常、fileserver) |
/var/log/salt/minion |
minion 日志(认证、执行、模块加载) |
/var/log/salt/key |
密钥操作日志 |
/var/log/salt/api |
salt-api 日志 |
/var/cache/salt/master/jobs/ |
job cache(历史执行结果) |
/var/cache/salt/minion/files/base/ |
minion 缓存的 salt:// 文件(可对比判断是否拉到最新) |
# 临时开 debug 日志(改完记得改回 warning)
sed -i 's/^#log_level: warning/log_level: debug/' /etc/salt/master
systemctl restart salt-master
tail -f /var/log/salt/master
# 前台启动服务看实时输出(最直接)
systemctl stop salt-master && salt-master -l debug
systemctl stop salt-minion && salt-minion -l debug
22.1 Minion 显示 Not connected / No response(分层排查)
报错形态:
Minion did not return. [No response]
Minion did not return. [Not connected]
minion1:
Minion did not return
按这个顺序逐层排查(从下往上):
① 服务状态
# minion 端
systemctl status salt-minion # CentOS 7
service salt-minion status # CentOS 6
ps -ef | grep salt-minion
tail -50 /var/log/salt/minion
# master 端
systemctl status salt-master
ps -ef | grep salt-master
② 端口连通性(4505 / 4506)
# master 端:确认在监听
netstat -tunlp | grep -E '4505|4506'
ss -tunlp | grep -E '4505|4506'
# minion 端:确认能连到 master
telnet 10.1.1.2 4505
telnet 10.1.1.2 4506
# 或
nc -zv 10.1.1.2 4505
nc -zv 10.1.1.2 4506
# 看已建立的长连接(正常应能看到到 4505 的 ESTABLISHED)
netstat -an | grep 4505
# 防火墙检查
# CentOS 7
firewall-cmd --list-ports
systemctl status firewalld
# CentOS 6
iptables -nL INPUT
service iptables status
# SELinux
getenforce
③ hosts / DNS 解析
# minion 端能否解析 master 配置里写的地址
ping -c1 10.1.1.2
cat /etc/salt/minion | grep -E '^master'
cat /etc/hosts
# master 端能否解析 minion id
ping -c1 minion1
salt '*' grains.item fqdn id
④ 密钥状态
salt-key -L
# minion1 应在 Accepted Keys 里;在 Unaccepted 说明没接受;
# 在 Denied/Rejected 说明有冲突或被拒
⑤ 时间偏差
salt '*' cmd.run 'date +%s' --out=txt
date +%s
# 偏差超过几十秒会导致认证/job 异常
⑥ 版本不一致
salt '*' test.version
salt --versions-report
salt-run manage.versions
⑦ master 端 worker 打满 / timeout 太小
grep -iE 'timeout|worker' /var/log/salt/master | tail -20
salt -t 60 '*' test.ping # 加大超时再试
⑧ 汇总排查命令
salt-run manage.status # 一次看清 up / down
salt-run manage.down # 只看失联的
salt '*' test.ping --out=txt | grep -v True
22.2 密钥不匹配:The Salt Master has cached the public key for this node
报错:
The Salt Master has cached the public key for this node, this salt minion
will wait for 10 seconds before attempting to re-authenticate
或
The Salt Master has rejected this minion's public key!
To repair this issue, delete the public key for this minion on the Salt Master.
The Salt Minion will attempt to re-authenticate.
完整处理步骤(同 7.6 节):
# ① master:删旧 key
salt-key -d minion1 -y
salt-key -L
# ② minion:停服务 + 清本地 pki
systemctl stop salt-minion
rm -rf /etc/salt/pki/minion/*
rm -f /etc/salt/minion_id # 如果改了 id
# ③ minion:启动,重新提交公钥
systemctl start salt-minion
# ④ master:接受
salt-key -L
salt-key -a minion1 -y
# ⑤ 验证
salt 'minion1' test.ping
反向情况:重装了 master
# 每台 minion 上删掉缓存的旧 master 公钥
salt-ssh '*' cmd.run 'rm -f /etc/salt/pki/minion/minion_master.pub && systemctl restart salt-minion'
# 或逐台
ssh minion1 'rm -f /etc/salt/pki/minion/minion_master.pub; systemctl restart salt-minion'
Denied Keys 的成因:两台机器用了相同的 minion id(克隆虚拟机忘改主机名是最常见原因)。
salt-key -l den # 查看 denied
salt-key -d <id> -y # 删掉冲突 key
# 到那台机器上改 id 后重来
echo 'minion3' > /etc/salt/minion_id
rm -rf /etc/salt/pki/minion/*
systemctl restart salt-minion
22.3 salt-key -L 里 Minion 一直在 Unaccepted
这说明通信没问题(公钥都送到 master 了),只是你没接受:
salt-key -a minion1 -y
salt-key -A -y # 全部接受
salt-key -L # 确认已进 Accepted
salt 'minion1' test.ping
如果接受后马上又回到 Unaccepted / 出现新的待签项:
- 检查是不是两台机器同 id在轮流提交(
salt-key -f <id>看指纹变不变); - 检查 minion 是不是在反复重启(
systemctl status salt-minion看重启次数、看日志); - 检查 master 的
/etc/salt/pki/master/minions/目录权限(应属 root,salt 用户可读)。
如果连 Unaccepted 里都没有:说明公钥没送到,回到 22.1 查端口与服务。
22.4 SLS 报 YAML 语法错误
典型报错:
Rendering SLS 'base:nginx' failed:
while scanning for the next token found character '\t' that cannot start any token
Rendering SLS 'base:nginx' failed:
mapping values are not allowed in this context; line 5
Data failed to compile:
----------
Rendering SLS 'base:nginx' failed: could not find expected ':'
定位方法:
① 检查 Tab(最高频)
# 找出含 Tab 的行(会高亮显示 ^I)
cat -A /srv/salt/nginx/init.sls | grep -n '\^I'
grep -Pn '\t' /srv/salt/nginx/init.sls
# 批量把 Tab 换成 2 个空格
sed -i 's/\t/ /g' /srv/salt/nginx/init.sls
# vim 里预防:设置粘贴时自动转空格
cat >> ~/.vimrc <<'EOF'
set expandtab
set tabstop=2
set shiftwidth=2
set list listchars=tab:>-,trail:.
autocmd FileType yaml,sls setlocal ts=2 sts=2 sw=2 expandtab
EOF
② 检查冒号后空格
# ❌ 错误
nginx:
pkg.installed:
- name:nginx ← 冒号后没空格,整体被当字符串
# ✅ 正确
- name: nginx
③ 检查缩进层级是否对齐
# 用 cat -A 看每行真实的空白
cat -A /srv/salt/nginx/init.sls | head -30
④ 用 Python / 在线工具直接验 YAML
python -c "import yaml,sys; yaml.safe_load(open('/srv/salt/nginx/init.sls'))"
# 报错会指出行号
# 若文件里有 Jinja,先渲染再验(Jinja 未渲染时 YAML 校验会误报)
salt 'minion1' state.show_sls nginx --out=yaml
⑤ 用 state.show_sls 与 --out=yaml 排查渲染结果(核心手段)
# 看渲染后的最终 YAML 结构(Jinja 已展开)
salt 'minion1' state.show_sls nginx --out=yaml
# 看 top.sls 匹配结果
salt 'minion1' state.show_top
# 看完整 highstate
salt 'minion1' state.show_highstate --out=yaml
# 看底层数据结构(依赖关系都在这里)
salt 'minion1' state.show_lowstate --out=yaml
# 只渲染模板不落盘,直接看生成的配置文件内容
salt 'minion1' cp.get_template salt://nginx/files/nginx.conf.jinja /tmp/preview.conf
salt 'minion1' cmd.run 'cat /tmp/preview.conf'
⑥ 数字与特殊字符要加引号
# ❌ 容易出错
- minute: * # * 在 YAML 里是别名符号
- mode: 0644 # 可能被当八进制/十进制处理不一致
- version: 1.20 # 会变成浮点数 1.2
# ✅ 正确
- minute: '*'
- mode: '0644'
- version: '1.20'
22.5 Rendering SLS failed 与 Jinja 变量未定义
典型报错:
Rendering SLS 'base:nginx' failed: Jinja variable 'dict object' has no attribute 'nginx'
Rendering SLS 'base:nginx' failed: Jinja variable 'salt.utils.templates...' is undefined
Rendering SLS 'base:nginx' failed: Jinja error: 'None' has no attribute 'items'
Rendering SLS 'base:nginx' failed: Unable to manage file: Jinja syntax error: expected token 'end of statement block'
排查步骤:
# ① minion 端 debug(会打印渲染失败的具体行与上下文)
salt-call -l debug state.sls nginx
# ② 确认 pillar 是否真的拿到了
salt 'minion1' saltutil.refresh_pillar
salt 'minion1' pillar.items
salt 'minion1' pillar.get nginx:port
salt-run pillar.show_pillar minion1 --out=yaml # master 端算出来的 pillar
# ③ 确认 grains 是否存在该键
salt 'minion1' grains.get role
salt 'minion1' grains.items | grep -i role
常见成因与修法:
| 成因 | 修法 |
|---|---|
pillar['nginx']['port'] 但该 minion 没分配这份 pillar |
改用 salt['pillar.get']('nginx:port', 80) 给默认值;或修正 pillar 的 top.sls 匹配 |
| pillar 改了没刷新 | salt '*' saltutil.refresh_pillar |
grains['role'] 但没定义自定义 grains |
改用 salt['grains.get']('role', 'default') |
{% for %} 遍历一个 None(pillar 缺 key) |
{% for x in salt['pillar.get']('list_key', []) %} 给空列表默认值 |
{% if %} / {% for %} 忘了 {% endif %} / {% endfor %} |
补上闭合标签 |
{{ }} 里写了 {% %} 的语句 |
分开写 |
Jinja 与 YAML 混淆:{%- %} 导致缩进错位 |
用 state.show_sls --out=yaml 看渲染结果 |
file.managed 忘了 template: jinja,模板里的 {{ }} 原样写进文件 |
加上 - template: jinja |
| 自定义模块未同步 | salt '*' saltutil.sync_all |
# ★ 防御性写法总结
{%- set port = salt['pillar.get']('nginx:port', 80) %}
{%- set upstreams = salt['pillar.get']('nginx:upstreams', []) %}
{%- set role = salt['grains.get']('role', 'default') %}
{%- if upstreams %}
{# 只有非空才循环 #}
{%- endif %}
22.6 file.managed 找不到 source
报错:
Source file salt://nginx/files/nginx.conf not found in saltenv 'base'
Unable to manage file: none of the specified sources were found
Specified file salt://... is not available
排查步骤:
# ① 列出 master fileserver 里实际有哪些文件(最直接)
salt 'minion1' cp.list_master
salt 'minion1' cp.list_master | grep nginx
salt-run fileserver.file_list
salt-run fileserver.file_list saltenv=prod
# ② 核对 file_roots 配置与实际路径
grep -A5 '^file_roots' /etc/salt/master
ls -l /srv/salt/nginx/files/
# ③ 确认 saltenv 是否匹配
salt 'minion1' state.sls nginx saltenv=base
salt-run fileserver.dir_list saltenv=prod
# ④ 手工拉一次文件测试
salt 'minion1' cp.get_file salt://nginx/files/nginx.conf /tmp/t.conf
常见成因:
| 成因 | 说明与修法 |
|---|---|
salt:// 路径与 file_roots 不匹配 |
salt://a/b.conf 必须存在 <file_roots>/a/b.conf。核对是否多写/少写一层目录 |
| 写了 master 的绝对路径 | salt:///srv/salt/x.conf ❌ → salt://x.conf ✅ |
| 环境不对 | 在 saltenv=prod 下找的是 prod 目录;把文件放对环境,或给 prod 加 base 回退目录 |
改了 file_roots 没重启 master |
systemctl restart salt-master |
| 文件权限不对,salt 进程读不到 | chmod -R 644 /srv/salt/**/*; chmod -R 755 目录 |
| 用了 gitfs 但没更新 | salt-run fileserver.update |
| 4506 端口不通 | 文件是通过 4506 传的,端口不通就"找不到" → 查防火墙 |
| minion 缓存了旧的失败结果 | rm -rf /var/cache/salt/minion/files/base/* 后重试 |
| 文件名大小写 / 拼写错误 | Linux 区分大小写,仔细核对 |
22.7 Pillar 取不到值
# ① 刷新(改了 pillar 必做)
salt '*' saltutil.refresh_pillar
salt '*' saltutil.refresh_pillar wait=True
# ② 在 master 端直接算这台 minion 的 pillar(绕过 minion 缓存,判断是 master 侧还是 minion 侧问题)
salt-run pillar.show_pillar minion1 --out=yaml
# ③ 在 minion 端看实际拿到的
salt 'minion1' pillar.items
salt 'minion1' pillar.get nginx:port
salt-call -l debug pillar.items # 在 minion 上看请求过程
常见成因:
| 成因 | 修法 |
|---|---|
| pillar 的 top.sls 目标写错(最常见) | 核对 /srv/pillar/top.sls 里的匹配表达式,用 grains 匹配时别忘 - match: grain |
| 忘了 refresh_pillar | salt '*' saltutil.refresh_pillar |
pillar_roots 没配 / 配了没重启 master |
检查 /etc/salt/master 的 pillar_roots,改完重启 |
/srv/pillar 目录不存在 |
mkdir /srv/pillar |
| 文件里 YAML 缩进错,整份 pillar 编译失败(会导致该 minion 所有 pillar 都为空) | salt-run pillar.show_pillar minion1 看报错;逐个文件排查 |
| 嵌套 key 取值写法错 | pillar.get('a:b:c') 用冒号,不是点 |
| 两份 pillar 定义了同名 key 互相覆盖 | 检查 pillar_source_merging_strategy;避免重名 |
| pillar 里用了 Jinja 引用不存在的 grains | 用 salt['grains.get']('x','默认') |
# ★ pillar top.sls 常见写错示例
base:
'role:webserver': # ❌ 缺少 match,被当成 glob 匹配 minion id
- web
base:
'G@role:webserver': # ✅
- match: grain
- web
22.8 状态执行 Result: False 的读法
输出结构:
----------
ID: nginx-config ← 出问题的状态 ID
Function: file.managed ← 状态模块.函数
Name: /etc/nginx/nginx.conf ← 操作的目标
Result: False ← ★ True/False/None(test模式)
Comment: Source file salt://nginx/files/nginx.conf not found ← ★ 失败原因在这里
Started: 10:30:15.123456
Duration: 15.234 ms ← ★ 耗时(找慢状态用)
Changes: ← ★ 实际做了什么改动
----------
Summary for minion1
--------------
Succeeded: 8 (changed=3)
Failed: 1
--------------
Total states run: 9
Total run time: 2.345 s
读法要点:
| 字段 | 怎么看 |
|---|---|
Result: True |
成功(已达到期望状态) |
Result: False |
失败,看 Comment 找原因 |
Result: None |
test=True 空跑模式下表示"会发生改变";或状态被跳过 |
Comment |
最重要的字段,失败原因、跳过原因、变更说明都在这 |
Changes: {} |
没有变更 → 说明幂等生效,本次什么都没做(这是好事) |
Changes: {...} |
有变更,会显示 diff / 新旧值 |
Duration |
耗时,用来找拖慢 highstate 的状态 |
Summary 的 changed=N |
有多少个状态发生了实际改动 |
常见 Comment 与含义:
| Comment | 含义与处理 |
|---|---|
One or more requisite failed: xxx |
依赖的状态失败了 → 先修被依赖的那个 |
Source file salt://... not found |
source 路径问题 → 见 22.6 |
State 'xxx' was not found in SLS |
状态模块名拼错,或模块不可用 → sys.list_state_functions 核对 |
The following packages failed to install: xxx |
yum 装包失败 → 到 minion 上手工 yum install 看真实报错 |
Service xxx is already dead / not available |
服务名错,或该系统上服务名不同(6/7 差异) |
unless/onlyif condition is true, state not run |
幂等守卫生效跳过了,这是正常的 |
Command "xxx" run + retcode: 1 |
命令执行了但返回非 0 |
Recursive requisite found |
依赖环 → 见 17.11 |
Detected conflicting IDs |
两个状态用了相同 ID → 改名 |
Specified state ... is unavailable |
缺依赖(如 git.latest 需要装 git;archive.extracted 需要 tar) |
User xxx is not available |
file 的 user: 指定的用户不存在 → 加 require: user: xxx |
# 只看失败的状态(大量输出时很有用)
salt '*' state.highstate --state-output=mixed
salt '*' state.highstate --state-output=changes
salt '*' state.highstate --state-output=terse
salt '*' state.highstate --out=highstate | grep -B2 -A6 'Result: False'
salt '*' state.highstate --state-verbose=False # 只显示有变化/失败的
22.9 执行超时
Minion did not return. [No response]
Salt request timed out. The master is not responding.
# ① 命令行临时加大超时
salt -t 60 '*' state.highstate
salt -t 300 '*' pkg.upgrade
salt -t 600 '*' cmd.run 'yum -y update'
# ② 全局加大(/etc/salt/master)
# timeout: 15
# gather_job_timeout: 15
systemctl restart salt-master
# ③ 超长任务改用异步 + 后查结果(最佳做法)
salt --async '*' pkg.upgrade
# Executed command with job ID: 20240822...
salt-run jobs.lookup_jid 20240822...
salt-run jobs.active
# ④ 分批降低压力
salt -b 10 '*' state.highstate
# ⑤ 加大 worker_threads(worker 打满也表现为超时)
22.10 cmd.run 每次都执行 —— 缺幂等守卫
现象:每次 state.highstate 都显示 cmd.run 有 Changes,服务被反复重启,文件被反复追加。
# ❌ 每次都跑
init-db:
cmd.run:
- name: /usr/bin/mysql_install_db --user=mysql
# ✅ 加 creates
init-db:
cmd.run:
- name: /usr/bin/mysql_install_db --user=mysql
- creates: /var/lib/mysql/ibdata1
# ✅ 加 unless
install-agent:
cmd.run:
- name: 'rpm -ivh /tmp/agent.rpm'
- unless: 'rpm -q agent'
# ✅ 加 onlyif
run-if-config-exists:
cmd.run:
- name: /opt/scripts/reload.sh
- onlyif: 'test -f /opt/app/app.conf'
# ✅ 改用 cmd.wait,只在被 watch 对象变化时执行
reload-app:
cmd.wait:
- name: /opt/app/bin/reload.sh
- watch:
- file: /opt/app/conf/app.yml
# ✅✅ 最佳:换成专用状态模块(见 10.3 对照表)
22.11 Minion 版本与 Master 不一致导致模块异常
报错形态:
'pkg.installed' is not available.
The function "state.sls" is running as PID ...
Module function xxx.yyy threw an exception
TypeError: xxx() got an unexpected keyword argument 'zzz'
# ① 核对版本
salt '*' test.version
salt --versions-report
salt-run manage.versions
# ② 找出版本不一致的机器
salt '*' grains.item saltversion --out=txt | sort -k2
# ③ 升级(★ 先升 master,再升 minion)
# master
yum clean all && yum update salt-master salt-minion
systemctl restart salt-master salt-minion
# minion(分批)
salt -b 10 '*' pkg.install salt-minion
salt -b 10 '*' service.restart salt-minion
# 注意:用 salt 自己升级 salt-minion 时,重启会导致返回丢失,属正常
salt --async '*' cmd.run 'yum -y update salt-minion && systemctl restart salt-minion'
# ④ 同步模块(升级后必做)
salt '*' saltutil.sync_all
salt '*' saltutil.refresh_modules
salt '*' saltutil.refresh_grains
salt '*' saltutil.refresh_pillar
22.12 其他常见问题速查
| 现象 | 原因 | 处理 |
|---|---|---|
state.highstate 什么都没执行,No Top file or ...matches found |
top.sls 不存在,或匹配表达式没命中该 minion | salt 'x' state.show_top 看匹配结果;检查 /srv/salt/top.sls |
写好的 SLS highstate 不跑,state.sls 却能跑 |
忘了往 top.sls 里加 |
加进 top.sls |
Detected conflicting IDs |
两个状态 ID 重名(常见于 include 后) | 改 ID;用 模块-用途 命名规范 |
minion 频繁重连、日志刷 Authentication wait |
master worker 不够、时间偏差、网络抖动 | 调 worker_threads;校时;配 recon_randomize |
/var/cache 撑满,master 变慢 |
job cache 小文件太多 | keep_jobs 调小;salt-run cache.clear_all;换外部 job cache |
Passed invalid arguments |
模块参数写错 | salt 'x' sys.doc <模块.函数> 查文档 |
| 自定义 grains / 模块不生效 | 没同步 | salt '*' saltutil.sync_all + refresh_grains |
CentOS 6 上 service.running 报服务不存在 |
服务名与 7 不同(mysqld vs mariadb) |
用 Jinja 按 osmajorrelease 分支 |
| CentOS 7 上防火墙状态没生效 | 用了 iptables 的写法 | 用 firewall-cmd,见 19.8 |
file.managed 反复报有变更但内容看起来一样 |
行尾符(CRLF vs LF)或末尾换行差异 | 用 dos2unix 处理源文件;file.managed 加 - show_changes: True 看 diff |
| 大文件分发很慢并拖垮 master | 走了 salt://(占用 4506 worker) |
改用 HTTP 源 + source_hash |
练习
说明:两份源文档均未提供独立的练习题与答案,以下练习围绕本章内容新编,涵盖从环境搭建到排错的完整链路。
练习 1:搭建 Master 与两台 Minion 并完成密钥认证
要求
- 按本文环境规划搭建 master(10.1.1.2) + minion1(10.1.1.3) + minion2(10.1.1.4),master 自身也纳管;
- 完成主机名、hosts 解析、时间同步、防火墙放行 4505/4506、SELinux 处理;
- 完成密钥认证,
salt '*' test.ping三台全部返回 True; - 说明 4505 与 4506 各自的职责。
参考答案
# ---------- 三台都做:主机名与解析 ----------
hostnamectl set-hostname master # 分别在三台上设成 master/minion1/minion2
cat >> /etc/hosts <<'EOF'
10.1.1.2 master
10.1.1.3 minion1
10.1.1.4 minion2
EOF
# ---------- 三台都做:时间同步 ----------
yum install -y chrony
systemctl enable --now chronyd
timedatectl set-timezone Asia/Shanghai
chronyc sources -v
# ---------- 三台都做:SELinux ----------
setenforce 0
sed -i 's/^SELINUX=enforcing/SELINUX=disabled/' /etc/selinux/config
# ---------- master:防火墙放行 ----------
firewall-cmd --permanent --add-port=4505-4506/tcp
firewall-cmd --reload
# CentOS 6 写法:
# iptables -I INPUT -p tcp --dport 4505 -j ACCEPT
# iptables -I INPUT -p tcp --dport 4506 -j ACCEPT
# service iptables save
# ---------- 安装 ----------
# master
yum install -y salt-master salt-minion
# minion1 / minion2
yum install -y salt-minion
# ---------- 三台都改 /etc/salt/minion ----------
sed -i 's/^#master: salt/master: 10.1.1.2/' /etc/salt/minion
# 显式指定 id(推荐)
echo 'minion1' > /etc/salt/minion_id # 各机器写自己的 id
# ---------- 启动 ----------
# master(CentOS 7)
systemctl enable --now salt-master salt-minion
# minion
systemctl enable --now salt-minion
# CentOS 6:
# service salt-master start; chkconfig salt-master on
# service salt-minion start; chkconfig salt-minion on
# ---------- 密钥认证 ----------
[root@master ~]# salt-key -L
Accepted Keys:
Denied Keys:
Unaccepted Keys:
master
minion1
minion2
Rejected Keys:
[root@master ~]# salt-key -A -y # 或逐个 salt-key -a minion1
[root@master ~]# salt-key -L # 三个都应在 Accepted
# ---------- 验证 ----------
[root@master ~]# salt '*' test.ping
minion1:
True
minion2:
True
master:
True
[root@master ~]# salt-run manage.up
[root@master ~]# netstat -tunlp | grep -E '4505|4506'
端口职责回答
- 4505(
publish_port,PUB):master 的消息发布端口。所有 minion 启动后与它建立持久长连接并订阅,master 下发命令时向总线 publish 一条加密消息广播给所有订阅者;Salt 的 event bus 也走这条链路。 - 4506(
ret_port,REP/REQ):结果返回端口。minion 用它把执行结果回传,同时用于拉取salt://文件、拉取 pillar 数据、密钥认证。 - 排错口诀:收不到命令查 4505,拿不到结果/拉不到文件查 4506。
练习 2:用各种 targeting 方式精确匹配
要求
用至少 7 种不同的 targeting 方式,分别完成:
- 匹配所有机器;
- 匹配 minion1 与 minion2(用通配、列表、正则三种方式各写一遍);
- 匹配所有 CentOS 7 机器(grains);
- 匹配 10.1.1.0/24 网段;
- 用复合匹配:CentOS 系统且是 webserver 角色,但排除 master;
- 定义并使用一个名为
web的节点组。
参考答案
# ① 所有机器(glob)
salt '*' test.ping
# ② minion1 与 minion2 —— 三种写法
salt 'minion*' cmd.run 'hostname' # glob 通配
salt 'minion[12]' cmd.run 'hostname' # glob 字符集
salt -L 'minion1,minion2' cmd.run 'hostname' # 列表
salt -E 'minion(1|2)' cmd.run 'hostname' # 正则
salt -E '^minion[0-9]+$' test.ping
# ③ CentOS 7(grains)
salt -G 'os:CentOS' cmd.run 'hostname'
salt -G 'osrelease:7*' cmd.run 'uname -r'
salt -G 'osmajorrelease:7' test.ping
salt -G 'os_family:RedHat' test.ping
salt -P 'osrelease:7\.[0-9].*' test.ping # grains 正则
# ④ 子网
salt -S '10.1.1.0/24' test.ping
salt -S '10.1.1.3' test.ping
# ⑤ 复合匹配
salt -C 'G@os:CentOS and G@role:webserver and not master' cmd.run 'hostname'
salt -C '( G@role:webserver or G@role:dbserver ) and G@osmajorrelease:7' test.ping
salt -C 'L@minion1,minion2 and G@os_family:RedHat' test.ping
salt -C 'S@10.1.1.0/24 and not E@^master$' test.ping
# ⑥ 节点组
# /etc/salt/master 中添加:
# nodegroups:
# web: 'L@minion1,minion2'
# db: 'G@role:dbserver'
# centos7: 'G@osmajorrelease:7'
# prod_web: 'N@web and G@env:prod'
systemctl restart salt-master
salt -N web test.ping
salt -N web cmd.run 'hostname'
salt -C 'N@web and G@os:CentOS' test.ping
# ⑦ pillar 匹配
salt -I 'name:test_service' test.ping
salt -J 'name:test_.*' test.ping
练习 3:用执行模块完成批量装包、改配置、重启服务
要求
不写任何 SLS,只用执行模块在两台 minion 上:
- 装 httpd 与 httpd-devel;
- 把 master 上的 httpd.conf 下发到
/etc/httpd/conf/httpd.conf; - 把监听端口改成 8080;
- 启动并设置开机自启;
- 验证;
- 说明为什么生产中不推荐这种命令式做法。
参考答案
# ① 装包(分批,防止 yum 并发把源打垮)
salt -b 1 -t 300 'minion*' pkg.install pkgs='["httpd","httpd-devel"]'
salt 'minion*' pkg.version httpd
# ② 下发配置(先把文件放进 file_roots)
cp /etc/httpd/conf/httpd.conf /srv/salt/test_dir/
salt 'minion*' cp.get_file salt://test_dir/httpd.conf /etc/httpd/conf/httpd.conf
# 或用 salt-cp(源文件不必在 file_roots)
salt-cp 'minion*' /root/httpd.conf /etc/httpd/conf/httpd.conf
# ③ 改端口
salt 'minion*' file.replace /etc/httpd/conf/httpd.conf \
pattern='^Listen\s+\d+' repl='Listen 8080' backup='.bak'
salt 'minion*' cmd.run 'grep ^Listen /etc/httpd/conf/httpd.conf'
# SELinux 若开启,需放行 8080
salt 'minion*' cmd.run 'semanage port -a -t http_port_t -p tcp 8080' \
unless='semanage port -l | grep -q "http_port_t.*8080"'
# ④ 启动并自启
salt 'minion*' cmd.run 'httpd -t' # 先查语法
salt -b 1 'minion*' service.start httpd
salt 'minion*' service.enable httpd
# ⑤ 验证
salt 'minion*' service.status httpd
salt 'minion*' cmd.run 'netstat -tunlp | grep 8080'
salt 'minion*' cmd.run 'curl -sI http://127.0.0.1:8080 | head -1'
salt 'minion*' cmd.run 'firewall-cmd --permanent --add-port=8080/tcp && firewall-cmd --reload'
为什么生产不推荐命令式做法
- 不幂等:再执行一次
file.replace或cmd.run结果不可预期;echo >>类操作会重复追加; - 无状态描述:这些操作只存在于当时的命令历史里,没有一份可读的"机器应该长什么样"的文档;新机器上线要靠人回忆敲哪些命令;
- 不可版本化、不可 review:出了事故无法追溯是哪次改动引入的;
- 无法空跑演练:
state.sls test=True能预演,命令式做不到; - 无联动:配置变了不会自动触发服务 reload,需要人记着重启;
- 中途失败状态不确定:装了包但配置没下发完,机器处于半成品状态。
正确做法是把上述全部写成 SLS(见练习 5),命令式只用于临时排障与查询。
练习 4:自定义 grains 并按 grains 分组下发不同配置
要求
- 给 minion1 打标签
role: webserver、idc: shanghai;给 minion2 打role: dbserver、idc: beijing; - 用三种方式各实现一次自定义 grains(minion 配置文件、
/etc/salt/grains、_grainsPython 脚本); - 按 role 分组,给 web 机器装 nginx,给 db 机器装 mariadb;
- 写一个 top.sls 让 highstate 自动按 role 路由。
参考答案
方式一:minion 配置文件
# minion1 的 /etc/salt/minion 末尾
grains:
role:
- webserver
idc: shanghai
# minion2 的 /etc/salt/minion 末尾
grains:
role:
- dbserver
idc: beijing
systemctl restart salt-minion # 两台都重启
salt '*' grains.item role idc
方式二:/etc/salt/grains 文件(注意不写最外层 grains:)
# minion1 的 /etc/salt/grains
role:
- webserver
idc: shanghai
env: prod
# 不重启也能生效
salt 'minion1' saltutil.refresh_grains
salt 'minion1' grains.item role idc env
# 也可以从 master 远程写入(会写进 /etc/salt/grains)
salt 'minion2' grains.setval role "['dbserver']"
salt 'minion2' grains.setval idc beijing
salt 'minion2' saltutil.refresh_grains
方式三:_grains Python 脚本
# /srv/salt/_grains/roleinfo.py
import os
def roleinfo():
grains = {}
# 从本地标记文件读角色(装机脚本写入)
f = '/etc/server_role'
if os.path.exists(f):
with open(f) as fh:
grains['auto_role'] = fh.read().strip()
else:
grains['auto_role'] = 'unknown'
# 根据是否存在特征文件自动判断
grains['is_web'] = os.path.exists('/usr/sbin/nginx') or os.path.exists('/usr/sbin/httpd')
grains['is_db'] = os.path.exists('/usr/libexec/mysqld') or os.path.exists('/usr/sbin/mysqld')
return grains
salt '*' cmd.run 'echo webserver > /etc/server_role' # 仅示例
salt '*' saltutil.sync_grains
salt '*' saltutil.refresh_grains
salt '*' grains.item auto_role is_web is_db
按 role 下发不同配置
# /srv/salt/nginx/init.sls
nginx-install:
pkg.installed:
- name: nginx
nginx-service:
service.running:
- name: nginx
- enable: True
- require:
- pkg: nginx-install
# /srv/salt/mysql/init.sls
{%- set db_pkgs = ['mysql-server'] if grains['osmajorrelease'] | int == 6 else ['mariadb','mariadb-server'] %}
{%- set db_svc = 'mysqld' if grains['osmajorrelease'] | int == 6 else 'mariadb' %}
mysql-install:
pkg.installed:
- pkgs: {{ db_pkgs | json }}
mysql-service:
service.running:
- name: {{ db_svc }}
- enable: True
- require:
- pkg: mysql-install
# /srv/salt/top.sls
base:
'*':
- init
'G@role:webserver':
- match: grain
- nginx
'G@role:dbserver':
- match: grain
- mysql
'G@idc:shanghai':
- match: grain
- dns.shanghai
'G@idc:beijing':
- match: grain
- dns.beijing
# 命令行按 grains 下发
salt -G 'role:webserver' state.sls nginx
salt -G 'role:dbserver' state.sls mysql
salt -C 'G@role:webserver and G@idc:shanghai' state.sls nginx
# highstate 自动路由(每台机器只跑自己该跑的)
salt '*' state.show_top # 先确认匹配结果
salt '*' state.highstate test=True
salt '*' state.highstate
# 验证
salt 'minion1' cmd.run 'rpm -q nginx'
salt 'minion2' cmd.run 'rpm -q mariadb-server'
练习 5:编写带 watch 联动的 httpd 状态树
要求
- 建立模块化目录
/srv/salt/httpd/{init,install,config,service}.sls与files/; - 配置文件用 Jinja 模板,端口从 pillar 取,
ServerName用 grains 的 fqdn; - 实现"改配置自动 reload 服务";
- 加语法检查,检查不通过就不要重启服务;
- 执行两次,第二次必须显示
Changes: {}(证明幂等); - 改一下 pillar 的端口,再执行,验证服务被自动 reload。
参考答案
/srv/salt/httpd/
├── init.sls
├── install.sls
├── config.sls
├── service.sls
└── files/
└── httpd.conf.jinja
# /srv/pillar/httpd.sls
httpd:
port: 8080
user: apache
group: apache
doc_root: /var/www/html
timeout: 60
# /srv/pillar/top.sls
base:
'minion*':
- httpd
# /srv/salt/httpd/install.sls
httpd-install:
pkg.installed:
- pkgs:
- httpd
- httpd-devel
{#- /srv/salt/httpd/files/httpd.conf.jinja (关键片段)-#}
# 由 SaltStack 管理,请勿手工修改
ServerRoot "/etc/httpd"
Listen {{ salt['pillar.get']('httpd:port', 80) }}
ServerName {{ grains['fqdn'] }}:{{ salt['pillar.get']('httpd:port', 80) }}
User {{ salt['pillar.get']('httpd:user', 'apache') }}
Group {{ salt['pillar.get']('httpd:group', 'apache') }}
Timeout {{ salt['pillar.get']('httpd:timeout', 60) }}
DocumentRoot "{{ salt['pillar.get']('httpd:doc_root', '/var/www/html') }}"
Include conf.modules.d/*.conf
IncludeOptional conf.d/*.conf
<Directory "{{ salt['pillar.get']('httpd:doc_root', '/var/www/html') }}">
Options -Indexes +FollowSymLinks
AllowOverride None
{%- if grains['osmajorrelease'] | int >= 7 %}
Require all granted
{%- else %}
Order allow,deny
Allow from all
{%- endif %}
</Directory>
ErrorLog "logs/error_log"
CustomLog "logs/access_log" combined
DirectoryIndex index.html index.php
# /srv/salt/httpd/config.sls
include:
- httpd.install
httpd-config:
file.managed:
- name: /etc/httpd/conf/httpd.conf
- source: salt://httpd/files/httpd.conf.jinja
- template: jinja
- user: root
- group: root
- mode: '0644'
- backup: minion
- show_changes: True
- require:
- pkg: httpd-install
# ★ 只在配置有变化时做语法检查
httpd-config-check:
cmd.run:
- name: '/usr/sbin/httpd -t'
- onchanges:
- file: httpd-config
# /srv/salt/httpd/service.sls
include:
- httpd.install
- httpd.config
httpd-service:
service.running:
- name: httpd
- enable: True
- reload: True # ★ 用 graceful reload,不中断连接
- watch: # ★ 配置变了自动触发
- file: httpd-config
- require: # ★ 语法检查不通过就不动服务
- cmd: httpd-config-check
# /srv/salt/httpd/init.sls
include:
- httpd.install
- httpd.config
- httpd.service
# /srv/salt/top.sls
base:
'minion*':
- httpd
执行与验证
salt 'minion*' saltutil.refresh_pillar
# ① 第一次执行(会有变更)
salt 'minion*' state.sls httpd test=True
salt -b 1 'minion*' state.sls httpd
# Summary: Succeeded: 5 (changed=4)
# ② 第二次执行(幂等验证:应全部 Changes: {} )
salt 'minion*' state.sls httpd
# Summary: Succeeded: 5 (changed=0) ← ★ 这就是幂等
# ③ 改 pillar 端口验证 watch 联动
sed -i 's/port: 8080/port: 8888/' /srv/pillar/httpd.sls
salt 'minion*' saltutil.refresh_pillar
salt 'minion*' state.sls httpd
# 输出中应看到:
# ID: httpd-config Result: True Changes: (diff 显示 Listen 8080 → 8888)
# ID: httpd-config-check Result: True Changes: (httpd -t 执行了)
# ID: httpd-service Result: True Comment: Service reloaded
# ④ 验证端口真的变了
salt 'minion*' cmd.run 'netstat -tunlp | grep httpd'
salt 'minion*' cmd.run 'grep ^Listen /etc/httpd/conf/httpd.conf'
salt 'minion*' cmd.run 'curl -sI http://127.0.0.1:8888 | head -1'
# ⑤ 验证语法检查保护:故意写坏模板
echo 'ThisIsBadDirective xxx' >> /srv/salt/httpd/files/httpd.conf.jinja
salt 'minion1' state.sls httpd
# httpd-config 成功(文件已下发),但 httpd-config-check 失败 → httpd-service 因 require 失败而不执行
# → 服务保持旧配置继续运行,避免了业务中断
salt 'minion1' file.list_backups /etc/httpd/conf/httpd.conf # 从备份恢复
练习 6:用 Pillar 实现不同主机不同密码
要求
- 在两台 minion 上创建同一个用户
appuser,但两台机器的密码不同; - 密码用 pillar 下发,SLS 文件只写一份;
- 验证两台机器的密码哈希确实不同;
- 说明为什么密码要放 pillar 而不是 grains。
参考答案
# ① 生成两个不同的密码哈希
salt 'minion1' shadow.gen_password 'Minion1@Pwd123'
salt 'minion2' shadow.gen_password 'Minion2@Pwd456'
# 输出形如 $6$rounds=656000$xxxx$yyyy...
# ② /srv/pillar/top.sls ← 按 minion 定向分配
base:
'*':
- common
'minion1':
- secrets.minion1
'minion2':
- secrets.minion2
# /srv/pillar/common.sls
app:
user: appuser
uid: 7001
shell: /bin/bash
home: /home/appuser
# /srv/pillar/secrets/minion1.sls
app_user_password: '$6$rounds=656000$aaaaaaaaaaaa$1111111111111111111111111111111111'
mysql_root_password: 'Minion1@MySQL'
redis_password: 'Minion1@Redis'
# /srv/pillar/secrets/minion2.sls
app_user_password: '$6$rounds=656000$bbbbbbbbbbbb$2222222222222222222222222222222222'
mysql_root_password: 'Minion2@MySQL'
redis_password: 'Minion2@Redis'
# ③ /srv/salt/app/user.sls ← 只写一份 SLS
{%- set app = salt['pillar.get']('app', {}) %}
app-group:
group.present:
- name: {{ app.get('user', 'appuser') }}
- gid: {{ app.get('uid', 7001) }}
app-user:
user.present:
- name: {{ app.get('user', 'appuser') }}
- uid: {{ app.get('uid', 7001) }}
- gid: {{ app.get('uid', 7001) }}
- home: {{ app.get('home', '/home/appuser') }}
- shell: {{ app.get('shell', '/bin/bash') }}
- createhome: True
- password: '{{ salt['pillar.get']('app_user_password') }}'
- enforce_password: True
- require:
- group: app-group
# 顺带下发一个带密码的应用配置(演示敏感数据用法)
app-config:
file.managed:
- name: /etc/appuser.conf
- user: {{ app.get('user', 'appuser') }}
- mode: '0600' # ★ 含密码的文件权限必须收紧
- contents: |
[database]
host = 10.1.1.20
user = root
password = {{ salt['pillar.get']('mysql_root_password', '') }}
[redis]
password = {{ salt['pillar.get']('redis_password', '') }}
- require:
- user: app-user
# ④ 执行与验证
salt '*' saltutil.refresh_pillar
# 先确认每台机器拿到的 pillar 不同
salt 'minion1' pillar.get app_user_password
salt 'minion2' pillar.get app_user_password
salt-run pillar.show_pillar minion1 --out=yaml
salt-run pillar.show_pillar minion2 --out=yaml
salt 'minion*' state.sls app.user test=True
salt 'minion*' state.sls app.user
# ⑤ 验证密码哈希不同
salt 'minion*' cmd.run "grep appuser /etc/shadow"
salt 'minion*' cmd.run "cat /etc/appuser.conf"
salt 'minion*' cmd.run "ls -l /etc/appuser.conf" # 应为 0600
# 实际登录验证
ssh appuser@10.1.1.3 # 用 Minion1@Pwd123
ssh appuser@10.1.1.4 # 用 Minion2@Pwd456
为什么密码要放 Pillar 而不是 Grains
| 理由 | 说明 |
|---|---|
| 可见范围 | Grains 是全局可查的:salt '*' grains.items 任何有 master 权限的人、甚至任意 minion(通过 mine)都能看到所有机器的 grains。Pillar 由 master 按 top.sls 定向分发,minion 只能看到属于自己的那一份 |
| 存储位置 | Grains 存在 minion 本地(/etc/salt/grains),被入侵的机器上密码直接暴露,且普通用户可能可读。Pillar 存在 master 上,minion 只在内存中持有自己那份 |
| 可加密 | Pillar 支持 GPG renderer / SDB / Vault 加密存储;Grains 没有这种机制 |
| 变更方式 | 改 Pillar 只需在 master 上改一处 + refresh_pillar;改 Grains 要动每一台 minion |
| 语义 | Grains 语义是"这台机器是什么"(客观属性);Pillar 语义是"要给这台机器什么数据"(下发配置)。密码显然属于后者 |
练习 7:用 Jinja 模板按 os_family 生成兼容 CentOS 6/7 的配置
要求
写一个状态,在 CentOS 6 与 CentOS 7 上都能正确部署 Web 服务,处理好:
- 包名差异(RedHat 系 httpd / Debian 系 apache2);
- 服务管理差异(service vs systemctl —— 说明为什么状态模块不需要分支);
- 防火墙差异(iptables vs firewalld);
- 配置文件语法差异(
Order allow,denyvsRequire all granted); - 数据库服务名差异(mysqld vs mariadb)。
参考答案
{#- /srv/salt/web/map.jinja ← 集中管理平台差异 -#}
{% set web = salt['grains.filter_by']({
'RedHat': {
'pkg': 'httpd',
'service': 'httpd',
'conf': '/etc/httpd/conf/httpd.conf',
'vhost_dir': '/etc/httpd/conf.d',
'user': 'apache',
'group': 'apache',
'doc_root': '/var/www/html',
'log_dir': '/var/log/httpd',
},
'Debian': {
'pkg': 'apache2',
'service': 'apache2',
'conf': '/etc/apache2/apache2.conf',
'vhost_dir': '/etc/apache2/sites-enabled',
'user': 'www-data',
'group': 'www-data',
'doc_root': '/var/www/html',
'log_dir': '/var/log/apache2',
},
}, merge=salt['pillar.get']('web:lookup')) %}
{#- 数据库的 6/7 差异 -#}
{% if grains['osmajorrelease'] | int == 6 %}
{% set db = {'pkgs': ['mysql-server', 'mysql'], 'service': 'mysqld', 'conf': '/etc/my.cnf'} %}
{% else %}
{% set db = {'pkgs': ['mariadb', 'mariadb-server'], 'service': 'mariadb', 'conf': '/etc/my.cnf'} %}
{% endif %}
# /srv/salt/web/init.sls
{% from "web/map.jinja" import web, db with context %}
{%- set major = grains['osmajorrelease'] | int %}
{%- set port = salt['pillar.get']('web:port', 80) %}
# ---------- ① 包名差异 ----------
web-install:
pkg.installed:
- name: {{ web.pkg }}
db-install:
pkg.installed:
- pkgs: {{ db.pkgs | json }}
# ---------- ② 配置文件(语法差异在模板里处理)----------
web-config:
file.managed:
- name: {{ web.conf }}
- source: salt://web/files/httpd.conf.jinja
- template: jinja
- mode: '0644'
- backup: minion
- context:
run_user: {{ web.user }}
run_group: {{ web.group }}
doc_root: {{ web.doc_root }}
listen_port: {{ port }}
- require:
- pkg: web-install
web-config-check:
cmd.run:
{%- if grains['os_family'] == 'RedHat' %}
- name: '/usr/sbin/httpd -t'
{%- else %}
- name: '/usr/sbin/apache2ctl configtest'
{%- endif %}
- onchanges:
- file: web-config
# ---------- ③ 服务管理:状态模块无需分支!----------
# service.running 会自动选择 systemd(CentOS 7) 或 SysV init(CentOS 6)
web-service:
service.running:
- name: {{ web.service }}
- enable: True
- reload: True
- watch:
- file: web-config
- require:
- cmd: web-config-check
db-service:
service.running:
- name: {{ db.service }} # ← CentOS 6: mysqld / CentOS 7: mariadb
- enable: True
- require:
- pkg: db-install
# ---------- ④ 防火墙差异 ----------
{%- if major >= 7 %}
# ===== CentOS 7:firewalld =====
firewalld-installed:
pkg.installed:
- name: firewalld
firewalld-service:
service.running:
- name: firewalld
- enable: True
- require:
- pkg: firewalld-installed
open-web-port-firewalld:
cmd.run:
- name: 'firewall-cmd --permanent --add-port={{ port }}/tcp && firewall-cmd --reload'
- unless: 'firewall-cmd --list-ports | grep -q "{{ port }}/tcp"'
- require:
- service: firewalld-service
# CentOS 7 上禁用 iptables-services,防止与 firewalld 冲突
iptables-service-dead:
service.dead:
- name: iptables
- enable: False
- onlyif: 'systemctl list-unit-files | grep -q "^iptables.service"'
{%- else %}
# ===== CentOS 6:iptables =====
iptables-installed:
pkg.installed:
- name: iptables
open-web-port-iptables:
cmd.run:
- name: 'iptables -I INPUT -p tcp --dport {{ port }} -j ACCEPT && /etc/init.d/iptables save'
- unless: 'iptables -nL INPUT | grep -q "dpt:{{ port }}"'
- require:
- pkg: iptables-installed
iptables-service:
service.running:
- name: iptables
- enable: True
- require:
- pkg: iptables-installed
{%- endif %}
{#- /srv/salt/web/files/httpd.conf.jinja ← ⑤ 配置语法差异 -#}
# 由 SaltStack 生成 —— {{ grains['os'] }} {{ grains['osrelease'] }}
ServerRoot "/etc/httpd"
Listen {{ listen_port }}
ServerName {{ grains['fqdn'] }}:{{ listen_port }}
User {{ run_user }}
Group {{ run_group }}
DocumentRoot "{{ doc_root }}"
{%- if grains['osmajorrelease'] | int >= 7 %}
{#- CentOS 7 / Apache 2.4 语法 -#}
Include conf.modules.d/*.conf
IncludeOptional conf.d/*.conf
<Directory "{{ doc_root }}">
Options -Indexes +FollowSymLinks
AllowOverride None
Require all granted
</Directory>
<Directory />
AllowOverride none
Require all denied
</Directory>
{%- else %}
{#- CentOS 6 / Apache 2.2 语法 -#}
Include conf.d/*.conf
LoadModule auth_basic_module modules/mod_auth_basic.so
<Directory "{{ doc_root }}">
Options -Indexes FollowSymLinks
AllowOverride None
Order allow,deny
Allow from all
</Directory>
<Directory />
Options FollowSymLinks
AllowOverride None
Order deny,allow
Deny from all
</Directory>
{%- endif %}
{#- prefork 参数按 CPU/内存自动计算 -#}
<IfModule prefork.c>
StartServers {{ grains['num_cpus'] }}
MinSpareServers {{ grains['num_cpus'] * 2 }}
MaxSpareServers {{ grains['num_cpus'] * 4 }}
MaxClients {{ (grains['mem_total'] / 20) | int }}
MaxRequestsPerChild 4000
</IfModule>
ErrorLog "logs/error_log"
CustomLog "logs/access_log" combined
DirectoryIndex index.html index.php
关于②的回答(为什么状态模块不需要分支)
Salt 的 service 状态/执行模块是虚拟模块(virtual module):它在 minion 上加载时会探测系统的 init 系统,自动选择底层实现——
- CentOS 7 →
systemd模块(底层调systemctl); - CentOS 6 →
rh_service模块(底层调service+chkconfig)。
所以 service.running: - name: httpd - enable: True 在 6 和 7 上都能正确工作,这正是"用专用模块而不是 cmd.run 'systemctl start httpd'"的最大价值。同理 pkg 模块在 RedHat 系走 yum、Debian 系走 apt。
# 验证 Salt 实际选用了哪个底层实现
salt 'minion1' sys.provider service
salt 'minion1' grains.item os_family osmajorrelease
salt -G 'osmajorrelease:7' cmd.run 'systemctl is-enabled httpd'
salt -G 'osmajorrelease:6' cmd.run 'chkconfig --list httpd'
需要分支的部分:服务名(mysqld/mariadb)、包名(mysql-server/mariadb-server)、防火墙工具(iptables/firewall-cmd)、配置文件语法(Apache 2.2/2.4)。
# 执行验证
salt '*' state.sls web test=True
salt -b 1 '*' state.sls web
salt -G 'osmajorrelease:7' cmd.run 'systemctl status httpd; firewall-cmd --list-ports'
salt -G 'osmajorrelease:6' cmd.run 'service httpd status; iptables -nL INPUT'
salt '*' cmd.run 'curl -sI http://127.0.0.1 | head -1'
练习 8:写一套系统初始化状态并在新机上一键执行
要求
编写 /srv/salt/init/ 状态树,包含:关闭 SELinux、配 yum 源、装常用工具、优化 sshd、时间同步、内核参数、资源限制、关闭无用服务;然后在一台新机器上一键执行并验证收敛。
参考答案
完整代码见 20.9 节(init/init.sls 及各子状态),此处给出流程与验证要点。
# /srv/salt/init/init.sls
include:
- init.yum # order:1,必须最先(否则后面装包全失败)
- init.selinux
- init.pkgs
- init.hosts
- init.ntp
- init.sysctl
- init.limits
- init.services
- init.history
- init.motd
- init.firewall
- init.ssh # 放最后,风险最高
init-done-flag:
file.managed:
- name: /etc/salt-init-done
- contents: |
SaltStack 初始化完成
minion : {{ grains['id'] }}
os : {{ grains['os'] }} {{ grains['osrelease'] }}
init time : {{ salt['cmd.run']('date +"%F %T"') }}
- order: last
# /srv/salt/top.sls
base:
'*':
- init
# ---------- 新机 minion3 (10.1.1.5) 上线全流程 ----------
# ① 用 salt-ssh 引导安装 minion(无需人工登录)
cat >> /etc/salt/roster <<'EOF'
minion3:
host: 10.1.1.5
user: root
passwd: 'RootPwd'
EOF
salt-ssh -i 'minion3' state.sls bootstrap.minion
# ② 接受 key
salt-key -L
salt-key -a minion3 -y
# ③ 打角色标签
salt 'minion3' grains.setval role webserver
salt 'minion3' grains.setval idc shanghai
salt 'minion3' grains.setval env prod
salt 'minion3' saltutil.refresh_grains
salt 'minion3' saltutil.sync_all
# ④ 刷新 pillar 并确认匹配
salt 'minion3' saltutil.refresh_pillar
salt 'minion3' state.show_top
salt 'minion3' pillar.items
# ⑤ 空跑演练(★ 上线前必做)
salt 'minion3' state.highstate test=True --out=highstate | tee /tmp/init-dryrun.log
grep -c 'Result: None' /tmp/init-dryrun.log # 有多少项会被改动
# ⑥ 一键初始化
salt 'minion3' state.highstate
# ⑦ 验证各项
salt 'minion3' cmd.run 'cat /etc/salt-init-done'
salt 'minion3' cmd.run 'getenforce' # Disabled/Permissive
salt 'minion3' cmd.run 'ls /etc/yum.repos.d/'
salt 'minion3' cmd.run 'rpm -q vim-enhanced wget lsof tree net-tools'
salt 'minion3' cmd.run 'sshd -T | grep -E "^port|permitrootlogin|passwordauthentication"'
salt 'minion3' cmd.run 'date; chronyc sources -v 2>/dev/null || ntpq -p'
salt 'minion3' cmd.run 'sysctl net.core.somaxconn vm.swappiness fs.file-max'
salt 'minion3' cmd.run 'cat /etc/security/limits.d/99-salt-limits.conf'
salt 'minion3' cmd.run 'cat /etc/hosts'
salt 'minion3' cmd.run 'systemctl is-enabled postfix cups 2>&1'
salt 'minion3' cmd.run 'cat /etc/motd'
# ⑧ ★ 收敛验证:再空跑一次,应该全部无变更
salt 'minion3' state.highstate test=True
# Summary: Succeeded: 42 (unchanged=42) ← 说明系统已完全收敛到期望状态
# ⑨ 幂等验证:正式再跑一次,changed 应为 0
salt 'minion3' state.highstate
# Summary: Succeeded: 42 (changed=0)
关键设计点
init.yum必须order: 1——yum 源没配好,后面所有pkg.installed都会失败;init.ssh放最后——sshd 配错会把自己锁在外面,所以它有sshd -t语法检查 +reload而非 restart +backup: minion;- 防火墙放行必须在改 sshd 端口之前(用
order: 1); init-done-flag用order: last,成为"初始化成功"的标记;- 所有
cmd.run都带unless/onlyif/creates守卫,保证反复执行安全; - 收敛验证(第 ⑧ 步)是判断状态树写得好不好的唯一标准:第二次空跑必须零变更。
练习 9:故意写错 YAML 与 salt:// 路径后用 debug 定位
要求
依次制造以下四种错误,用调试命令定位并修复,写出报错信息、定位命令与修复方法:
- 用 Tab 缩进;
- 冒号后不加空格;
salt://路径写错;- Jinja 引用不存在的 pillar 键。
参考答案
错误 1:用 Tab 缩进
# 制造错误
printf '/tmp/tabtest:\n\tfile.managed:\n\t\t- contents: hello\n' > /srv/salt/bad1.sls
salt 'minion1' state.sls bad1
报错:
minion1:
Data failed to compile:
----------
Rendering SLS 'base:bad1' failed: found character '\t' that cannot start any token
定位:
# ① minion 端 debug(会指出行号与上下文)
salt-call -l debug state.sls bad1
# ② 直接找 Tab(^I 就是 Tab)
cat -A /srv/salt/bad1.sls
# /tmp/tabtest:$
# ^Ifile.managed:$ ← ★ 这里有 Tab
# ^I^I- contents: hello$
grep -Pn '\t' /srv/salt/bad1.sls
修复:
sed -i 's/\t/ /g' /srv/salt/bad1.sls
salt 'minion1' state.sls bad1
# 预防:vim 里 set expandtab / set list listchars=tab:>-
# 修复后
/tmp/tabtest:
file.managed:
- contents: hello
错误 2:冒号后不加空格
# /srv/salt/bad2.sls
nginx-install:
pkg.installed:
- name:nginx ← ❌ 冒号后无空格
- version:1.20.1 ← ❌
salt 'minion1' state.sls bad2
报错(两种可能):
Rendering SLS 'base:bad2' failed: could not find expected ':'
或不报语法错但行为异常:
ID: nginx-install
Function: pkg.installed
Result: False
Comment: Invalid function passed / Passed invalid arguments: ...
The following arguments are missing: name
定位:
# ① 看渲染后的实际数据结构(能直接看出 "name:nginx" 被当成一个字符串)
salt 'minion1' state.show_sls bad2 --out=yaml
# 输出会显示:
# nginx-install:
# pkg:
# - name:nginx ← ★ 是字符串而不是 {name: nginx} 字典
# ② 用 Python 验 YAML
python -c "import yaml; print(yaml.safe_load(open('/srv/salt/bad2.sls')))"
# ③ debug
salt-call -l debug state.sls bad2
修复:
nginx-install:
pkg.installed:
- name: nginx
- version: '1.20.1' # 版本号也加引号,防止被当浮点数
错误 3:salt:// 路径写错
# /srv/salt/bad3.sls
bad-source:
file.managed:
- name: /tmp/x.conf
- source: salt://nginx/file/nginx.conf ← ❌ 目录是 files 不是 file
salt 'minion1' state.sls bad3
报错:
----------
ID: bad-source
Function: file.managed
Name: /tmp/x.conf
Result: False
Comment: Source file salt://nginx/file/nginx.conf not found in saltenv 'base'
Changes:
定位:
# ① ★ 列出 master fileserver 里实际有什么(最直接)
salt 'minion1' cp.list_master | grep nginx
# nginx/files/nginx.conf ← ★ 真实路径是 files
salt-run fileserver.file_list
# ② 核对 file_roots 与磁盘路径
grep -A5 '^file_roots' /etc/salt/master
ls -l /srv/salt/nginx/files/
# ③ 手工拉一次验证
salt 'minion1' cp.get_file salt://nginx/file/nginx.conf /tmp/t.conf
# 返回 False → 路径确实不存在
salt 'minion1' cp.get_file salt://nginx/files/nginx.conf /tmp/t.conf
# 返回 /tmp/t.conf → 正确路径
# ④ debug 看请求过程
salt-call -l debug state.sls bad3 2>&1 | grep -i 'fileserver\|not found\|cp.hash'
修复:
bad-source:
file.managed:
- name: /tmp/x.conf
- source: salt://nginx/files/nginx.conf
其他易犯的 salt:// 错误:
- source: salt:///srv/salt/nginx/files/nginx.conf # ❌ 不能写 master 绝对路径
- source: salt://nginx/files/nginx.conf # ✅
# 环境不对:文件只在 base,却用 saltenv=prod 执行 → 给 prod 加 base 回退目录
# 改了 file_roots 忘重启 master → systemctl restart salt-master
# 4506 不通也表现为"找不到 source" → 查防火墙
错误 4:Jinja 引用不存在的 pillar 键
# /srv/salt/bad4.sls
bad-jinja:
file.managed:
- name: /tmp/y.conf
- contents: |
port = {{ pillar['myapp']['port'] }} ← ❌ myapp 这个 pillar 不存在
salt 'minion1' state.sls bad4
报错:
minion1:
Data failed to compile:
----------
Rendering SLS 'base:bad4' failed: Jinja variable 'dict object' has no attribute 'myapp'
定位:
# ① ★ minion 端 debug(会打印失败的具体行与渲染上下文)
salt-call -l debug state.sls bad4
# ...Jinja variable 'dict object' has no attribute 'myapp'
# 并会显示模板内容与行号
# ② 确认 minion 实际拿到了哪些 pillar
salt 'minion1' saltutil.refresh_pillar
salt 'minion1' pillar.items
salt 'minion1' pillar.get myapp
# 返回空 → 确认这份 pillar 没分配给 minion1
# ③ 在 master 端算一遍(判断是 master 侧没定义,还是 minion 侧没刷新)
salt-run pillar.show_pillar minion1 --out=yaml
# ④ 检查 pillar 的 top.sls 目标是否写对
cat /srv/pillar/top.sls
修复(三种方案,推荐第一种):
# ✅ 方案一:改用 pillar.get 并给默认值(最健壮)
bad-jinja:
file.managed:
- name: /tmp/y.conf
- contents: |
port = {{ salt['pillar.get']('myapp:port', 8080) }}
# ✅ 方案二:补齐 pillar 定义
# /srv/pillar/myapp.sls
myapp:
port: 8080
# /srv/pillar/top.sls
base:
'minion*':
- myapp
salt 'minion1' saltutil.refresh_pillar
salt 'minion1' state.sls bad4
# ✅ 方案三:Jinja 里显式判断
{%- set myapp = salt['pillar.get']('myapp', {}) %}
{%- if myapp %}
bad-jinja:
file.managed:
- name: /tmp/y.conf
- contents: 'port = {{ myapp.get("port", 8080) }}'
{%- endif %}
本练习的排错方法论总结
| 错误类型 | 第一定位命令 | 辅助命令 |
|---|---|---|
| YAML 语法(Tab/冒号/缩进) | cat -A 文件 |
python -c "import yaml;yaml.safe_load(...)"、state.show_sls --out=yaml |
salt:// 路径 |
salt 'x' cp.list_master \| grep xxx |
salt-run fileserver.file_list、cp.get_file 手工试 |
| Jinja / pillar / grains | salt-call -l debug state.sls xxx |
pillar.items、salt-run pillar.show_pillar、grains.items |
| 渲染结果不符合预期 | state.show_sls xxx --out=yaml |
state.show_highstate、cp.get_template 预览 |
| 依赖/顺序问题 | state.show_lowstate --out=yaml |
逐个注释 require 二分定位 |
| 状态失败但语法正常 | 看输出的 Comment 字段 | --out=highstate、--state-verbose=False |
贯穿始终的一句话:任何搞不清的问题,先到那台 minion 上跑 salt-call -l debug。
本章小结
SaltStack 是基于 Python + ZeroMQ 的配置管理与远程执行框架,采用 Master/Minion(C/S)Push 模式。它的核心竞争力有三点:
- 速度——ZeroMQ 消息总线 + 长连接 + 并行发布,数千台机器秒级返回,这是它与 Puppet(Pull + HTTPS 轮询)、Ansible(SSH 串行连接)最本质的区别;
- 一套工具两种能力——既能做临时的远程执行(
salt '*' cmd.run),又能做长期的状态管理(SLS 声明式配置),Ansible 与 Puppet 各偏一头; - 数据与逻辑分离——Grains 描述"机器是什么"(客观静态属性,存 minion 端),Pillar 描述"给机器什么数据"(敏感/差异化数据,存 master 端定向下发),加上 Jinja 模板,一份 SLS 就能适配全部机型与环境。
学习路径建议:装环境 → 认 key → 玩 targeting 与执行模块 → 写第一个 SLS → 用 Grains 分组 → 用 Pillar 抽敏感数据 → 用 Jinja 消除重复 → 用 watch 做联动 → 组织成状态树 → 系统初始化一键化 → 进阶 Reactor/API。
常用命令速查表
| 分类 | 命令 | 说明 |
|---|---|---|
| 服务 | systemctl start/enable salt-master |
CentOS 7 启动 master |
/etc/init.d/salt-master start + chkconfig salt-master on |
CentOS 6 启动 master | |
systemctl restart salt-minion |
改完 minion 配置必须重启 | |
| 密钥 | salt-key -L |
列出全部 key 及状态(★ 最常用) |
salt-key -a <id> / -A |
接受指定 / 全部未接受 key | |
salt-key -d <id> / -D |
删除指定 / 全部 key | |
salt-key -r <id> / -R |
拒绝指定 / 全部 key | |
salt-key -f <id> / -F |
查看指纹 / master 与全部 key 指纹 | |
salt-key -y |
免交互确认(配合 -a/-A/-d/-D) | |
| 连通 | salt '*' test.ping |
测试 minion 存活(★ 第一条验证命令) |
salt '*' test.version |
查看 minion 版本(排查版本不一致) | |
salt-run manage.status |
分组显示 up/down | |
salt-run manage.up / manage.down |
只看在线 / 只看离线 | |
| 执行 | salt '<target>' <mod.fun> [参数] |
远程执行通用结构 |
salt-call <mod.fun> |
在 minion 本地执行(走 master) | |
salt-call --local <mod.fun> |
Masterless 本地执行(不需要 master) | |
salt-call -l debug state.sls x |
★★★ 排错第一命令 | |
salt-ssh '*' <mod.fun> |
无 agent 执行(走 SSH + roster) | |
salt-cp '*' 本地文件 远端路径 |
分发文件(不走 file_roots) | |
| 状态 | salt '*' state.highstate |
按 top.sls 应用全部状态 |
salt '*' state.highstate test=True |
★ 空跑演练,上线前必做 | |
salt '*' state.sls httpd |
只应用指定 SLS | |
salt '*' state.sls httpd test=True |
空跑指定 SLS | |
salt '*' state.apply / state.apply httpd |
新写法(等价 highstate / sls) | |
salt '*' state.show_sls httpd --out=yaml |
看渲染后的数据结构(★ 查语法) | |
salt '*' state.show_top |
看该 minion 匹配到哪些状态 | |
salt '*' state.show_highstate |
看完整 highstate 渲染结果 | |
salt '*' state.show_lowstate --out=yaml |
看最终执行顺序(★ 查依赖) | |
salt '*' state.single pkg.installed name=vim |
不写 SLS 直接跑单个状态 | |
| Grains | salt '*' grains.items |
全部 grains |
salt '*' grains.item os osrelease |
指定几项 | |
salt '*' grains.get ip4_interfaces:eth0 |
取嵌套值 | |
salt '*' grains.setval role webserver |
写入 /etc/salt/grains |
|
salt '*' grains.delval role destructive=True |
删除自定义 grain | |
salt '*' saltutil.sync_grains |
同步 _grains/ 自定义脚本 |
|
salt '*' saltutil.refresh_grains |
刷新 grains 缓存 | |
| Pillar | salt '*' pillar.items |
该 minion 拿到的全部 pillar |
salt '*' pillar.get a:b |
取嵌套值(推荐) | |
salt '*' saltutil.refresh_pillar |
★ 改完 pillar 必须刷新 | |
salt-run pillar.show_pillar minion1 |
在 master 端算某 minion 的 pillar | |
| 文件 | salt '*' cp.get_file salt://a/b /tmp/b |
拉单个文件 |
salt '*' cp.get_dir salt://a /tmp |
拉整个目录 | |
salt '*' cp.list_master |
★ 列出 fileserver 里所有文件(查 salt:// 路径) | |
salt-run fileserver.file_list |
master 端列出 fileserver 内容 | |
| 任务 | salt '*' <mod.fun> --async |
异步执行,立即返回 jid |
salt-run jobs.active |
正在执行的任务 | |
salt-run jobs.list_jobs |
历史任务列表 | |
salt-run jobs.lookup_jid <jid> |
查某个任务的结果 | |
salt '*' saltutil.running |
minion 上正在跑的任务 | |
salt '*' saltutil.kill_job <jid> |
杀掉卡住的任务 | |
| 同步 | salt '*' saltutil.sync_all |
同步全部自定义模块(★ 万能刷新) |
| 文档 | salt '*' sys.doc |
全部模块文档 |
salt '*' sys.doc pkg.installed |
★ 查具体函数用法,不用翻官网 | |
salt '*' sys.list_modules |
可用模块列表 | |
salt '*' sys.list_functions pkg |
某模块的全部函数 | |
salt '*' sys.argspec pkg.install |
函数参数签名 | |
| 选项 | -t 60 |
超时时间(默认 5s,慢操作要加大) |
-b 10 / --batch-size 20% |
★ 分批执行,生产必用 | |
--out=json/yaml/txt/highstate |
输出格式 | |
--out-indent=2 |
缩进 | |
-v / --show-jid |
显示 jid | |
--state-output=terse/changes/mixed |
状态输出精简程度 |
执行模块与状态模块对照速查表
核心区别:执行模块是"动词"(立即做一次),状态模块是"形容词"(描述期望的样子,幂等收敛)。
| 目的 | 执行模块(命令行,一次性) | 状态模块(SLS,幂等) |
|---|---|---|
| 装包 | pkg.install nginx |
pkg.installed: - name: nginx |
| 装多个包 | pkg.install pkgs='[a,b]' |
pkg.installed: - pkgs: [a, b] |
| 装最新版 | pkg.upgrade nginx |
pkg.latest: - name: nginx |
| 卸包 | pkg.remove nginx |
pkg.removed: - name: nginx |
| 查包版本 | pkg.version nginx |
(无,用 - version: 1.20.1) |
| 启服务 | service.start httpd |
service.running: - name: httpd |
| 停服务 | service.stop httpd |
service.dead: - name: httpd |
| 重启服务 | service.restart httpd |
(用 watch 自动触发) |
| 开机自启 | service.enable httpd |
service.running: - enable: True |
| 查服务状态 | service.status httpd |
(无) |
| 下发文件 | cp.get_file salt://a/b /tmp/b |
file.managed: - source: salt://a/b |
| 下发目录 | cp.get_dir salt://a /tmp |
file.recurse: - source: salt://a |
| 建目录 | file.mkdir /data/logs |
file.directory: - makedirs: True |
| 删文件/目录 | file.remove /tmp/x |
file.absent: - name: /tmp/x |
| 改文件内容 | file.replace /etc/x pattern repl |
file.replace: - pattern: - repl: |
| 加一行 | file.append /etc/x '内容' |
file.line: - mode: ensure |
| 建软链 | file.symlink 源 目标 |
file.symlink: - target: 源 |
| 改属主 | file.chown /tmp/x root root |
file.managed: - user: - group: |
| 改权限 | file.set_mode /tmp/x 644 |
file.managed: - mode: '0644' |
| 建用户 | user.add appuser uid=7001 |
user.present: - uid: 7001 |
| 删用户 | user.delete appuser |
user.absent: - name: appuser |
| 建组 | group.add dev 6000 |
group.present: - gid: 6000 |
| 加定时任务 | cron.set_job root '*' ... |
cron.present: - user: root |
| 删定时任务 | cron.rm_job root '命令' |
cron.absent: - name: '命令' |
| 挂载 | mount.mount /data /dev/sdb1 |
mount.mounted: - persist: True |
| 解压 | archive.unzip a.zip /tmp |
archive.extracted: - source: |
| 拉 Git 代码 | git.clone /data url |
git.latest: - rev: master |
| 执行命令 | cmd.run 'uptime' |
cmd.run: - unless: ...(★ 必须带守卫) |
| 执行脚本 | cmd.script salt://a/x.sh |
cmd.script: - source: salt://a/x.sh |
| 触发式执行 | (无) | cmd.wait: - watch: - file: xxx |
| 内核参数 | sysctl.persist net.x 1 |
sysctl.present: - value: 1 |
| SELinux | selinux.setenforce 0 |
selinux.mode: - name: permissive |
| 主机名 | network.mod_hostname n3 |
network.system: - hostname: n3 |
| 应用状态 | state.sls httpd |
(state 模块本身就是入口) |
常用执行模块补充速查
| 模块函数 | 用途 |
|---|---|
test.ping / test.version / test.echo '文本' / test.sleep 5 |
连通与自检 |
cmd.run / cmd.run_all / cmd.retcode / cmd.script |
执行命令,run_all 返回 stdout/stderr/retcode |
cmd.run cwd=/tmp runas=nginx shell=/bin/bash |
指定工作目录/用户/解释器 |
network.interfaces / network.ip_addrs / network.ping |
网络信息 |
disk.usage / disk.inodeusage / disk.blkid |
磁盘 |
status.diskusage / status.loadavg / status.meminfo / status.uptime / status.cpuinfo |
系统状态 |
ps.pgrep nginx / ps.top / ps.total_physical_memory |
进程 |
user.list_users / user.info root / user.chshell |
用户查询 |
shadow.gen_password 'pwd' |
生成密码哈希(★ 配 user.present) |
iptables.append / firewalld.add_port |
防火墙 |
sysctl.show / sysctl.get |
查内核参数 |
mount.active / mount.fstab |
挂载信息 |
timezone.get_zone / timezone.set_zone |
时区 |
rpm.list_pkgs / pkg.list_upgrades |
包信息 |
system.reboot / system.shutdown / system.halt |
重启关机(慎用) |
hosts.add_host / hosts.list_hosts |
/etc/hosts |
mine.get '*' network.interfaces |
从 mine 取其他 minion 的数据 |
event.send '标签' '{数据}' |
发送自定义事件(配 Reactor) |
Targeting 语法速查表
| 方式 | 选项 | 示例 | 说明 |
|---|---|---|---|
| 通配符 | 无(默认) | salt '*' test.ping |
全部 |
salt 'minion*' test.ping |
前缀匹配 | ||
salt 'minion?' test.ping |
单字符 | ||
salt 'minion[1-3]' test.ping |
字符范围 | ||
salt '*.example.com' test.ping |
域名后缀 | ||
| 正则 | -E / --pcre |
salt -E 'minion(1\|2)' test.ping |
PCRE 正则 |
salt -E '^web\d+$' test.ping |
锚定匹配 | ||
| 列表 | -L / --list |
salt -L 'minion1,minion2' test.ping |
逗号分隔,不能有空格 |
| Grains | -G / --grain |
salt -G 'os:CentOS' test.ping |
键:值 |
salt -G 'osmajorrelease:7' test.ping |
按大版本 | ||
salt -G 'os_family:RedHat' test.ping |
按系统家族 | ||
salt -G 'ip4_interfaces:eth0:10.1.1.3' test.ping |
嵌套用冒号 | ||
salt -G 'role:webserver' test.ping |
自定义 grain(★ 分组常用) | ||
| Grains 正则 | -P / --grain-pcre |
salt -P 'os:Cent.*' test.ping |
grains 值用正则 |
salt -P 'osrelease:7\..*' test.ping |
7.x 全部 | ||
| Pillar | -I / --pillar |
salt -I 'app:nginx' test.ping |
按 pillar 键值 |
| Pillar 正则 | -J / --pillar-pcre |
salt -J 'env:prod.*' test.ping |
pillar 值正则 |
| 子网/IP | -S / --ipcidr |
salt -S '10.1.1.0/24' test.ping |
CIDR 网段 |
salt -S '10.1.1.3' test.ping |
单个 IP | ||
| 节点组 | -N / --nodegroup |
salt -N web test.ping |
需先在 master 配 nodegroups |
| 复合 | -C / --compound |
salt -C 'G@os:CentOS and minion*' |
and / or / not |
salt -C '* and not minion2' |
排除 | ||
salt -C 'G@role:web or G@role:db' |
或 | ||
salt -C 'G@os:CentOS and G@osmajorrelease:7 and not L@minion3' |
多条件 |
复合匹配前缀表(-C 专用)
| 前缀 | 含义 | 等价单独选项 |
|---|---|---|
| (无前缀) | 通配符 | 默认 |
G@ |
Grains | -G |
P@ |
Grains 正则 | -P |
I@ |
Pillar | -I |
J@ |
Pillar 正则 | -J |
L@ |
列表 | -L |
S@ |
子网/IP | -S |
E@ |
Minion ID 正则 | -E |
N@ |
节点组 | -N |
R@ |
范围(需 range 支持) | — |
# /etc/salt/master 中定义节点组
nodegroups:
web: 'L@minion1,minion2'
db: 'G@role:dbserver'
prod: 'G@env:prod and not L@minion3'
centos7: 'G@osmajorrelease:7'
top.sls 中的 match 写法对照
base:
'*': # 通配符(默认,无需 match)
- init
'os:CentOS':
- match: grain # grains 匹配
- centos
'osrelease:7*':
- match: grain_pcre # grains 正则
- centos7
'app:nginx':
- match: pillar # pillar 匹配
- nginx
'minion1,minion2':
- match: list # 列表
- web
'10.1.1.0/24':
- match: ipcidr # 子网
- lan
'web':
- match: nodegroup # 节点组
- webserver
'G@role:web and G@env:prod':
- match: compound # 复合
- prod_web
'minion(1|2)':
- match: pcre # minion id 正则
- web
Grains 与 Pillar 差异对照表
这是 SaltStack 最核心的一组概念,务必分清。
| 对比项 | Grains | Pillar |
|---|---|---|
| 一句话定义 | 这台机器是什么(客观属性) | 要给这台机器什么数据(下发配置) |
| 数据方向 | minion → master(上报) | master → minion(下发) |
| 存储位置 | minion 本地(/etc/salt/grains、minion 配置文件、_grains/ 脚本) |
master 本地(/srv/pillar/,由 pillar_roots 定义) |
| 采集/生成时机 | minion 启动时采集并缓存 | minion 每次请求时由 master 实时渲染 |
| 静态/动态 | 相对静态(重启或 refresh 才变) | 动态,改完刷新立即生效 |
| 可见范围 | 全局可见:salt '*' grains.items 谁都能看全部机器 |
定向可见:minion 只能看到 top.sls 分配给自己的那份 |
| 是否适合放敏感数据 | ❌ 绝对不要(存在 minion 磁盘上,全局可查) | ✅ 专门用于此(支持 GPG/Vault/SDB 加密) |
| 典型内容 | os、osrelease、osfinger、os_family、fqdn、ip4_interfaces、num_cpus、mem_total、kernel、virtual、role、idc | 密码、密钥、License、DB 连接串、端口、域名、版本号、按环境差异化的参数 |
| 主要用途 | 分组、判断分支(选包名/服务名/配置语法) | 参数化、差异化(同一份 SLS 适配不同主机) |
| 命令行匹配 | -G / -P / G@ / P@ |
-I / -J / I@ / J@ |
| top.sls match | match: grain / grain_pcre |
match: pillar |
| 查看命令 | grains.items / grains.item x / grains.get a:b |
pillar.items / pillar.get a:b |
| 刷新命令 | saltutil.refresh_grains、saltutil.sync_grains |
saltutil.refresh_pillar |
| SLS/模板中引用 | {{ grains['os'] }}、{{ salt['grains.get']('role') }} |
{{ pillar['k'] }}、{{ salt['pillar.get']('a:b','默认值') }} |
| 推荐引用写法 | salt['grains.get']('a:b', '默认值') |
salt['pillar.get']('a:b', '默认值')(★ 键不存在不报错) |
| 自定义方式 | ① minion 配置文件 grains: ② /etc/salt/grains ③ _grains/*.py ④ grains.setval |
直接在 pillar_roots 下写 SLS + top.sls 分配 |
| 改完是否要重启 | 方式①改 minion 配置要重启 salt-minion;②③④ 只需 refresh | 不需要重启,refresh_pillar 即可 |
| master 端查询 | salt 'x' grains.items |
salt-run pillar.show_pillar x |
一句口诀:Grains 描述机器,Pillar 分发数据;机器属性用 Grains 分组,敏感差异用 Pillar 赋值。
# 二者配合的经典范式:Grains 决定"走哪个分支",Pillar 决定"填什么值"
{%- if grains['osmajorrelease'] | int >= 7 %}
{%- set svc = 'mariadb' %}
{%- else %}
{%- set svc = 'mysqld' %}
{%- endif %}
mysql-service:
service.running:
- name: {{ svc }} # ← Grains 决定
- enable: True
mysql-root-pwd:
cmd.run:
- name: >
mysqladmin -uroot password
'{{ salt['pillar.get']('mysql_root_password') }}' # ← Pillar 决定
- unless: mysql -uroot -p'{{ salt['pillar.get']('mysql_root_password') }}' -e 'select 1'
依赖关系关键字速查表
| 关键字 | 方向 | 触发条件 | 说明 |
|---|---|---|---|
require |
我依赖别人 | 对方 成功 | 最常用;对方失败则我不执行(Result: False, 显示 "One or more requisite failed") |
require_in |
别人依赖我 | 我成功 | require 反向写法,用于不方便改对方时 |
watch |
我监视别人 | 对方有 changes | 隐含 require;对方无变化只做 require 判断 |
watch_in |
别人监视我 | 我有 changes | watch 反向写法 |
onchanges |
仅在变化时执行 | 对方有 changes | 不隐含 require;对方无变化则我状态为 Result: True + 跳过 |
onchanges_in |
反向 | 同上 | — |
onfail |
失败时补救 | 对方失败 | 用于回滚、告警 |
onfail_in |
反向 | 同上 | — |
prereq |
预处理 | 对方将会有变化(test 预判) | 先空跑对方,若判定会改动则我先执行(如"先停服务再升级") |
prereq_in |
反向 | 同上 | — |
use |
复制对方参数 | — | 继承对方的属性,避免重复写 |
use_in |
反向 | — | — |
order |
强制顺序 | — | order: 1 最先、order: last 最后;只影响未被依赖约束的部分 |
include |
引入其他 SLS | — | 把别的 SLS 内容合并进来,是跨文件 require 的前提 |
extend |
修改被 include 的状态 | — | 追加/覆盖已 include 状态的属性 |
failhard |
失败即中止 | — | failhard: True 该状态失败则整个 highstate 立即停止 |
unless |
幂等守卫 | 命令返回非 0 才执行 | 常用于 cmd.run |
onlyif |
幂等守卫 | 命令返回 0 才执行 | 与 unless 相反 |
creates |
幂等守卫 | 指定文件不存在才执行 | 最直观的一次性守卫 |
watch vs onchanges 的关键区别
watch |
onchanges |
|
|---|---|---|
| 隐含 require | ✅ 是(对方失败我就不跑) | ❌ 否 |
| 对方无变化时 | 状态正常执行(如 service.running 保证服务在跑) | 完全跳过 |
| 对方有变化时 | 触发 mod_watch(service 会 restart/reload) |
正常执行我 |
| 典型用途 | service.running 监视配置文件 → 改配置自动重启 |
配置变了才跑一次命令(如语法检查、清缓存) |
| 支持的模块 | 只有实现了 mod_watch 的模块才有特殊行为(service、cmd 等) |
任意模块 |
# ★ 生产最经典的三段式联动:装包 → 配文件 → 检查 → 服务
nginx-pkg:
pkg.installed:
- name: nginx
nginx-conf:
file.managed:
- name: /etc/nginx/nginx.conf
- source: salt://nginx/files/nginx.conf.jinja
- template: jinja
- backup: minion
- require:
- pkg: nginx-pkg # 装完包才有配置目录
nginx-conf-check:
cmd.run:
- name: 'nginx -t'
- onchanges: # ★ 只在配置真的变了才检查
- file: nginx-conf
nginx-service:
service.running:
- name: nginx
- enable: True
- reload: True
- watch: # ★ 配置变了自动 reload
- file: nginx-conf
- require:
- cmd: nginx-conf-check # ★ 语法不过就不动服务
依赖环(Recursive requisite found)排查
# 报错示例
# Recursive requisite found: A -> B -> C -> A
salt 'minion1' state.show_lowstate --out=yaml | grep -A5 'require'
# 逐个注释 require 二分定位;原则:依赖关系应是有向无环图(DAG)
# 常见误用:A require B 同时 B require_in A(重复声明成环)
端口清单
| 端口 | 配置项 | 方向 | 协议/模式 | 用途 |
|---|---|---|---|---|
| 4505 | publish_port |
minion → master 连入 | ZeroMQ PUB/SUB | Master 的发布端口。所有 minion 启动后主动连上来并长连接订阅,master 把命令广播到这条总线上,minion 自行判断是否匹配自己。这是 Salt 快的根本原因(无需逐台建连) |
| 4506 | ret_port |
minion → master 连入 | ZeroMQ REQ/REP | Master 的返回端口。minion 上报执行结果、拉取文件(salt://)、请求 Pillar、密钥认证都走这里。文件下发失败、pillar 取不到,先查 4506 |
| 22 | — | master → minion | SSH | 仅 salt-ssh 模式使用(无 agent),普通 Master/Minion 模式不需要 |
| 8000 | rest_cherrypy: port |
客户端 → master | HTTPS | Salt API(rest_cherrypy)默认端口,供外部系统/自研平台调用 |
| 4505/4506 | — | master → syndic_master | ZeroMQ | 多级架构中 Syndic 作为"minion"连上级 Master 的同样两个端口 |
# ★ 端口必查三连
netstat -tunlp | grep -E '4505|4506' # master 端应 LISTEN
ss -tan | grep -E '4505|4506'
salt 'minion1' cmd.run 'ss -tan | grep 4505' # minion 端应 ESTAB
# 放行(CentOS 7 firewalld)
firewall-cmd --permanent --add-port=4505-4506/tcp
firewall-cmd --reload
firewall-cmd --list-ports
# 放行(CentOS 6 iptables)
iptables -I INPUT -p tcp -m multiport --dports 4505,4506 -j ACCEPT
/etc/init.d/iptables save
service iptables restart
# minion 端连通性测试(不通就别再看 salt 了,先修网络)
telnet 10.1.1.2 4505
telnet 10.1.1.2 4506
关键记忆点:
- 只需在 Master 上放行 4505/4506 入站,minion 端不需要开放任何端口(都是 minion 主动连出);
- 所以 SaltStack 能穿越单向 NAT:minion 在内网、master 有公网 IP 即可;
- 4505 通、4506 不通的典型症状:
test.ping有响应但state.sls报"Source file not found"、pillar 为空; - 两个端口都可改(
publish_port/ret_port),改了要同时改 master 和 minion 配置并重启双端。
排错口诀
一句总纲:先服务、再端口、再解析、再密钥、再语法、再 Pillar,搞不清就 salt-call -l debug。
六步排错口诀
────────────────────────────────────────
一查服务 systemctl status salt-master / salt-minion ——进程死了一切白搭
二查端口 netstat -tunlp | grep 450[56] + telnet ——防火墙/SELinux 是头号杀手
三查解析 ping 主机名、cat /etc/hosts ——双向解析都要通
四查密钥 salt-key -L 看状态是否 Accepted ——unaccepted 就 -a
五查语法 cat -A 找 Tab、state.show_sls --out=yaml ——空格不是 Tab、冒号后要空格
六查数据 pillar.items、grains.items、refresh_pillar ——改完 pillar 必须刷新
搞不清 salt-call -l debug state.sls xxx ——★ 万能第一命令
────────────────────────────────────────
常见报错 → 一句话对策
| 报错 / 现象 | 对策 |
|---|---|
Minion did not return. [Not connected] |
minion 服务没起 / 4505 不通 / key 没接受 |
Minion did not return. [No response] |
命令执行超时 → 加 -t 60;或 4506 不通 |
The Salt Master has cached the public key for this node |
minion 重装过 → master 上 salt-key -d <id>,minion 上 rm -rf /etc/salt/pki/minion,重启 minion,再 salt-key -a |
key 一直停在 Unaccepted Keys |
忘了 salt-key -a <id> -y |
salt-key -L 里根本没有该 minion |
minion 的 master: 配错 / 4506 不通 / 服务没起 |
found character '\t' that cannot start any token |
YAML 里有 Tab → cat -A 找出来换成空格 |
could not find expected ':' |
冒号后没加空格,或缩进不一致 |
Rendering SLS 'base:xxx' failed |
Jinja/YAML 渲染错 → salt-call -l debug state.sls xxx |
Jinja variable 'dict object' has no attribute 'x' |
pillar/grains 键不存在 → 改用 salt['pillar.get']('x','默认值') |
Source file salt://x/y not found in saltenv 'base' |
路径写错或环境不对 → cp.list_master \| grep y,核对 file_roots |
Pillar 取不到值 / pillar.items 为空 |
saltutil.refresh_pillar;查 /srv/pillar/top.sls 目标写没写对;查 4506 |
State 'xxx' was not found in SLS |
状态模块名写错 → sys.list_functions file 核对 |
Recursive requisite found |
依赖成环 → state.show_lowstate + 二分注释 require |
Result: False 但看不出原因 |
读 Comment: 字段,它写明了真正的失败理由 |
cmd.run 每次都执行 |
缺幂等守卫 → 加 unless / onlyif / creates |
| 状态执行顺序不对 | state.show_lowstate --out=yaml 看真实顺序;用 require / order 约束 |
| minion 与 master 版本不一致导致怪问题 | salt '*' test.version 对比,统一版本(yum install salt-minion-3004.2) |
| 改了 master 配置没生效 | systemctl restart salt-master(file_roots、pillar_roots、nodegroups 都要重启) |
| 自定义模块/grains 不生效 | salt '*' saltutil.sync_all + saltutil.refresh_grains |
| 任务卡死 | salt-run jobs.active → salt '*' saltutil.kill_job <jid> |
日志位置(★ 报错信息的最终来源)
tail -f /var/log/salt/master # Master 日志
tail -f /var/log/salt/minion # Minion 日志
tail -f /var/log/salt/key # 密钥操作日志
tail -f /var/log/salt/api # Salt API 日志
# 临时调高日志级别(不改配置,前台跑)
salt-master -l debug # 先 systemctl stop salt-master
salt-minion -l debug # 先 systemctl stop salt-minion
# 永久调整
# /etc/salt/master 或 /etc/salt/minion
# log_level: debug (garbage/trace/debug/info/warning/error/critical)
# log_level_logfile: debug
上线纪律(比排错更重要)
1. 任何状态先 test=True 空跑,看清 Result: None 的条目再执行;
2. 大规模执行必须 -b(分批),例如 salt -b 10 '*' state.highstate;
3. 改配置类状态一律加 backup: minion,出事能用 file.list_backups 找回;
4. 重启类操作优先 reload 而非 restart,并用 watch 而非手工重启;
5. 高危配置(sshd、iptables、内核)必须先做语法/连通检查,再用 require 卡住服务动作;
6. 状态树写好的唯一验收标准:第二次执行 changed=0(幂等收敛)。
此处评论已关闭