SaltStack 配置管理

本文合并自:03-SaltStack配置管理.md、3-5-SaltStack自动化运维.md

官方中文文档:http://docs.saltstack.cn/


一、配置管理的价值与基本思想

1.1 为什么需要配置管理工具

当服务器从几台增长到几十上百台时,纯手工登录逐台操作会带来三个不可逆的问题:

  1. 效率低:同一件事(装包、改配置、重启服务)要重复 N 次;
  2. 不一致(配置漂移):人手操作难免遗漏,机器之间配置逐渐产生差异,故障排查成本剧增;
  3. 不可追溯:没有人知道某台机器的某个参数是谁在什么时候改的、为什么改。

配置管理工具(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,这带来两个好处:

  1. minion 在 NAT 后面、没有公网 IP 也能被管理;
  2. 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 端口。

由此带来的优势:

  1. 高并发:一条 publish 消息广播给数千个 minion,master 的下发开销几乎与 minion 数量无关;
  2. 低延迟:命令是实时推送的,秒级完成上千台的批量操作,不需要等轮询周期;
  3. 异步非阻塞:ZeroMQ 是异步消息队列,master 端由 worker_threads 个工作进程并行处理返回结果;
  4. 天然支持事件驱动:消息总线本身就是一条 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 的完整流程:

  1. 命令行工具把任务(含 target、function、参数、jid)通过本地 socket 交给 master;
  2. master 在 4505 上 publish 一条加密消息,附带目标匹配表达式;
  3. 所有 minion 都收到这条消息,各自判断自己是否匹配 target(匹配判断在 minion 端做);
  4. 匹配的 minion 在本地执行 test.ping;
  5. minion 把结果通过 4506 返回给 master;
  6. master 收集结果(受 timeout 限制),格式化输出到命令行;同时按需写入 job cache 和 returner。

记住这条排错口诀:Minion 没反应,先查 4505 通不通(收不到命令);能收到命令但拿不到结果或拉不到文件,查 4506。

3.4 AES 加密与密钥认证机制

  • 通信内容使用 AES 对称加密,AES key 由 master 生成并分发给已认证的 minion;
  • 身份认证使用 RSA 非对称密钥对:
    1. minion 首次启动时生成自己的密钥对,把公钥发送给 master;
    2. 该公钥在 master 上处于 Unaccepted(待接受)状态,存放于 /etc/salt/pki/master/minions_pre/;
    3. 管理员用 salt-key -a <minion_id> 接受,公钥移动到 /etc/salt/pki/master/minions/;
    4. 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 官方源。

准备工作清单(三台都要做):

  1. 主机名及主机名绑定(必须能互相解析)
  2. 静态 IP
  3. 时间同步
  4. 关闭防火墙、SELinux(或按 5.4 放行端口)
  5. 配置 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 的三点提醒:

  1. 不设置 id 时默认取主机名,所以主机名必须唯一且规范;
  2. minion 首次启动后会把 id 缓存到 /etc/salt/minion_id,改了配置里的 id 还要删掉这个文件才生效;
  3. 改 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(甚至猜中你的生产机器名)就能:
    1. 拉取该 id 对应的 Pillar 数据(里面往往有数据库密码、API key);
    2. 接收 master 广播的命令,探知你的运维动作;
    3. 制造 id 冲突让真实 minion 失联。

生产环境的正确做法:

  1. auto_accept: False,人工 salt-key -a;或
  2. 用 salt-key --gen-keys 预生成密钥并在装机时预置到 minion 的 /etc/salt/pki/minion/,同时把公钥直接放进 master 的 minions/ 目录,实现"零人工且安全";或
  3. 若确需 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   # 只要值,方便脚本处理

十、常用执行模块详解

模块参考文档:http://docs.saltstack.cn/ref/index.html

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 的四个致命问题:

  1. 不幂等:cmd.run 'echo x >> /etc/hosts' 执行 10 次就追加 10 行;
  2. 无状态感知:Salt 不知道命令是否真的改变了系统,返回结果永远是 Changed,无法与 watch/onchanges 正确联动;
  3. 不跨平台:写死 yum install 就无法在 Debian 上跑;写死 service httpd start 在 CentOS 7 上也能跑但语义不清;
  4. 无法 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 更详细

为什么它最好用:

  1. 输出完整的报错堆栈,而 master 端往往只显示一句概要(甚至只显示 Minion did not return);
  2. 能看到 SLS 渲染前后的内容,Jinja 变量到底取到了什么值一目了然;
  3. 能看到 minion 向 master 请求文件/pillar 的完整过程和结果(salt:// 路径对不对、pillar 有没有拿到);
  4. 能看到 AES 认证与连接 master 的细节,认证问题直接暴露;
  5. 不依赖 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);
  • 服务名在两版可能不同(mysqld vs mariadb、iptables vs firewalld),需要用 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 是存放以下数据的正确位置:

  1. 敏感数据:数据库密码、Redis 密码、API Key、证书私钥、用户密码哈希;
  2. 差异化数据:每台机器不同的端口、域名、后端 IP 列表、worker 数、环境标识;
  3. 业务参数:版本号、部署路径、监控 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 并完成密钥认证

要求

  1. 按本文环境规划搭建 master(10.1.1.2) + minion1(10.1.1.3) + minion2(10.1.1.4),master 自身也纳管;
  2. 完成主机名、hosts 解析、时间同步、防火墙放行 4505/4506、SELinux 处理;
  3. 完成密钥认证,salt '*' test.ping 三台全部返回 True;
  4. 说明 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 方式,分别完成:

  1. 匹配所有机器;
  2. 匹配 minion1 与 minion2(用通配、列表、正则三种方式各写一遍);
  3. 匹配所有 CentOS 7 机器(grains);
  4. 匹配 10.1.1.0/24 网段;
  5. 用复合匹配:CentOS 系统且是 webserver 角色,但排除 master;
  6. 定义并使用一个名为 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 上:

  1. 装 httpd 与 httpd-devel;
  2. 把 master 上的 httpd.conf 下发到 /etc/httpd/conf/httpd.conf;
  3. 把监听端口改成 8080;
  4. 启动并设置开机自启;
  5. 验证;
  6. 说明为什么生产中不推荐这种命令式做法。

参考答案

# ① 装包(分批,防止 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'

为什么生产不推荐命令式做法

  1. 不幂等:再执行一次 file.replace 或 cmd.run 结果不可预期;echo >> 类操作会重复追加;
  2. 无状态描述:这些操作只存在于当时的命令历史里,没有一份可读的"机器应该长什么样"的文档;新机器上线要靠人回忆敲哪些命令;
  3. 不可版本化、不可 review:出了事故无法追溯是哪次改动引入的;
  4. 无法空跑演练:state.sls test=True 能预演,命令式做不到;
  5. 无联动:配置变了不会自动触发服务 reload,需要人记着重启;
  6. 中途失败状态不确定:装了包但配置没下发完,机器处于半成品状态。

正确做法是把上述全部写成 SLS(见练习 5),命令式只用于临时排障与查询。

练习 4:自定义 grains 并按 grains 分组下发不同配置

要求

  1. 给 minion1 打标签 role: webserver、idc: shanghai;给 minion2 打 role: dbserver、idc: beijing;
  2. 用三种方式各实现一次自定义 grains(minion 配置文件、/etc/salt/grains、_grains Python 脚本);
  3. 按 role 分组,给 web 机器装 nginx,给 db 机器装 mariadb;
  4. 写一个 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 状态树

要求

  1. 建立模块化目录 /srv/salt/httpd/{init,install,config,service}.sls 与 files/;
  2. 配置文件用 Jinja 模板,端口从 pillar 取,ServerName 用 grains 的 fqdn;
  3. 实现"改配置自动 reload 服务";
  4. 加语法检查,检查不通过就不要重启服务;
  5. 执行两次,第二次必须显示 Changes: {}(证明幂等);
  6. 改一下 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 实现不同主机不同密码

要求

  1. 在两台 minion 上创建同一个用户 appuser,但两台机器的密码不同;
  2. 密码用 pillar 下发,SLS 文件只写一份;
  3. 验证两台机器的密码哈希确实不同;
  4. 说明为什么密码要放 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 服务,处理好:

  1. 包名差异(RedHat 系 httpd / Debian 系 apache2);
  2. 服务管理差异(service vs systemctl —— 说明为什么状态模块不需要分支);
  3. 防火墙差异(iptables vs firewalld);
  4. 配置文件语法差异(Order allow,deny vs Require all granted);
  5. 数据库服务名差异(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)

关键设计点

  1. init.yum 必须 order: 1——yum 源没配好,后面所有 pkg.installed 都会失败;
  2. init.ssh 放最后——sshd 配错会把自己锁在外面,所以它有 sshd -t 语法检查 + reload 而非 restart + backup: minion;
  3. 防火墙放行必须在改 sshd 端口之前(用 order: 1);
  4. init-done-flag 用 order: last,成为"初始化成功"的标记;
  5. 所有 cmd.run 都带 unless / onlyif / creates 守卫,保证反复执行安全;
  6. 收敛验证(第 ⑧ 步)是判断状态树写得好不好的唯一标准:第二次空跑必须零变更。

练习 9:故意写错 YAML 与 salt:// 路径后用 debug 定位

要求

依次制造以下四种错误,用调试命令定位并修复,写出报错信息、定位命令与修复方法:

  1. 用 Tab 缩进;
  2. 冒号后不加空格;
  3. salt:// 路径写错;
  4. 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 模式。它的核心竞争力有三点:

  1. 速度——ZeroMQ 消息总线 + 长连接 + 并行发布,数千台机器秒级返回,这是它与 Puppet(Pull + HTTPS 轮询)、Ansible(SSH 串行连接)最本质的区别;
  2. 一套工具两种能力——既能做临时的远程执行(salt '*' cmd.run),又能做长期的状态管理(SLS 声明式配置),Ansible 与 Puppet 各偏一头;
  3. 数据与逻辑分离——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(幂等收敛)。
最后修改:2026 年 09 月 20 日
如果觉得我的文章对你有用,请随意赞赏