Puppet 配置管理

本文合并自:02-Puppet配置管理.md、3-4-Puppet自动化运维.md

本文介绍 Puppet 自动化配置管理工具,覆盖配置管理理念与幂等性、C/S 架构原理、安装部署、Master/Agent 证书认证、manifest 与 site.pp、核心资源类型(file/user/group/cron/service/exec/package/mount/host/yumrepo)、资源依赖与执行顺序、变量与条件、类与模块、ERB 模板、节点定义、Facter 差异化管理,以及推送 sudoers、部署 httpd/vsftpd 等完整实战,最后补充 Hiera、PuppetDB、导出资源、puppet-dashboard 报告平台与排错清单。

适用环境:CentOS 6.x / CentOS 7.3,Puppet 3.x(旧目录结构)与 Puppet 4.x/5.x/6.x(AIO 新目录结构)并列说明。

架构:Master/Agent(C/S),Master 监听 8140 端口。


一、配置管理概述

1.1 手工运维的困境

假设要管理 10000 台服务器,需要在所有机器上执行同一件事情(创建文件、修改文件、安装软件、启动服务、创建用户等),常见做法有:

  1. 逐个 ssh 连上去手动操作;
  2. 编写 shell 脚本批量执行。

但这两种方法都存在两个主要问题:

  • 密码问题:可用 ssh-keygen 生成空密钥(免密登录),或用 expect 自动应答密码解决;
  • 循环效率问题:10000 台机器,每台操作 10 秒,串行就需要 100000 秒(约 27.8 小时)。可通过 shell 并发执行解决。

shell 并发示例:

#!/bin/bash

for i in `seq 10`
do
    echo $i
    sleep 1
done &          # 加后台符号与不加效果不一样,可自行测试

for j in `seq 11 20`
do
    echo $j
    sleep 1
done

即使解决了密码和并发,手工/脚本运维仍然有更深层的问题:

  • 一致性无法保证:脚本执行到一半失败,机器处于"半配置"状态,同一批机器最终状态不一致(配置漂移 configuration drift);
  • 不可重复:脚本跑第二遍往往会出错(重复添加同一行、重复创建用户报错),无法安全地反复执行;
  • 不可审计:谁在什么时候改了哪台机器的什么配置,没有记录;
  • 系统差异难处理:CentOS 6 用 service/chkconfig,CentOS 7 用 systemctl;RHEL 用 yum,SUSE 用 zypper,脚本里全靠 if/else 堆叠。

差异化管理的典型 shell 逻辑:

if centos; then
    xxxx
elif rhel; then
    xxxx
elif suse; then
    xxxx
else
    xxxx
fi

1.2 配置管理工具的价值

集中化配置管理工具(Puppet、Ansible、SaltStack、Chef)带来的核心价值:

价值 含义
一致性(Consistency) 所有节点向同一份"期望状态"收敛,消除配置漂移
可重复(Repeatable) 同一份代码可反复执行,结果相同;新机器上线只需跑一遍代码
可审计(Auditable) 配置即代码(Infrastructure as Code),进 Git 仓库,有提交记录、有 code review、可回滚
幂等(Idempotent) 执行 1 次和执行 N 次的结果完全一样,不会重复叠加副作用
自文档化 manifest 本身就是环境的文档,读代码即知系统配置

使用这类工具时,通常需要关注以下几点:

关注点 解决思路
大并发 使用 MQ 消息队列(如 ActiveMQ/RabbitMQ + MCollective)
操作系统/版本差异 通过 facter 等工具获取客户端信息,按系统分支处理
异常处理 日志、报告(report)
分组机制 按角色、正则、节点分组(node 定义)
Web 管理界面 dashboard(puppet-dashboard、Foreman)
操作语言 自有的 DSL 或声明式语言

1.3 主流配置管理工具对比

常见自动化运维工具:Cobbler、Shell 脚本、Puppet、Ansible、SaltStack、Chef、Python 自研。

四大主流配置管理工具对比:

对比项 Puppet Ansible SaltStack Chef
开发语言 Ruby(+ Clojure 写的 Puppet Server) Python Python Ruby + Erlang
是否需要 agent 需要(也支持无 agent 的 puppet apply 单机模式) 不需要(agentless) 需要 minion(也有 salt-ssh 无 agent 模式) 需要 chef-client
通信方式 HTTPS + SSL 双向证书,Master 监听 8140 SSH(默认 22 端口) ZeroMQ 消息队列,master 监听 4505/4506 HTTPS REST API
配置语言 自有声明式 DSL(.pp manifest) YAML(playbook) YAML(state,SLS 文件) Ruby DSL(recipe)
学习曲线 较陡(需学 DSL、资源关系、Hiera) 平缓(YAML + SSH,最易上手) 中等 陡(需懂 Ruby)
执行模型 Pull(Agent 定期拉取,默认 30 分钟) Push(控制机主动推) Push 为主,也支持 Pull Pull
适用规模 大规模(数千至上万节点),强于"状态维持" 中小规模及临时批量任务,强于"编排部署" 大规模、要求实时性高(消息队列速度快) 大规模,云环境较多
社区现状 成熟稳定,企业存量多,新项目增速放缓 最活跃,事实上的主流 活跃度中等 逐步收缩
典型场景 长期状态合规、基线管理 应用发布、临时运维任务 大规模实时命令下发 + 状态管理 云平台自动化

详见 14-02-Ansible自动化部署.md

详见 14-04-SaltStack配置管理.md

选型经验:

  • 只是想"批量执行命令、快速上线应用" → Ansible;
  • 需要"长期保证 1000+ 台机器的配置基线不漂移" → Puppet;
  • 需要"秒级向上万台机器下发命令 + 状态管理" → SaltStack;
  • 团队是 Ruby 技术栈、重云环境 → Chef。

1.4 声明式与命令式的区别(理解 Puppet 的关键)

这是初学 Puppet 最容易卡住的地方。Shell 脚本是命令式的,Puppet 是声明式的。

命令式(Imperative):描述"怎么做",一步一步下指令。

# 命令式:告诉机器每一步怎么做
useradd user3
echo "hello" > /tmp/test.txt
yum install -y httpd
systemctl start httpd

问题:第二次执行时 useradd user3 会报 "user already exists",脚本可能中断;echo > 会覆盖别人的修改也无从判断。

声明式(Declarative):描述"要什么结果"(期望状态 desired state),由工具自己判断当前状态、计算差异、只做必要的变更。

# 声明式:只描述我要的最终状态
user { 'user3':
  ensure => present,
}

file { '/tmp/test.txt':
  content => "hello\n",
}

package { 'httpd':
  ensure => installed,
}

service { 'httpd':
  ensure => running,
  enable => true,
}

Puppet 每次运行时的逻辑是:

  1. 检查当前状态(用户 user3 存在吗?文件内容是这个吗?包装了吗?服务在跑吗?);
  2. 与声明的期望状态对比;
  3. 只对不一致的部分做修改,一致的部分什么都不做(输出中不会出现该资源的变更信息)。

1.5 幂等性(Idempotence)

幂等:同一操作执行一次与执行任意多次,系统最终状态相同,且不产生额外副作用。

  • mkdir /test → 不幂等(第二次报 File exists)
  • mkdir -p /test → 幂等
  • echo "x" >> /etc/hosts → 不幂等(每次追加一行,跑 10 次多 10 行)
  • file { '/etc/hosts': content => ... } → 幂等(内容一致就不动)

Puppet 的绝大多数资源类型(file、package、service、user、group、cron、host、mount)天然幂等。

唯一容易破坏幂等性的是 exec 资源,因为它执行的是裸命令,Puppet 不知道这条命令做了什么。因此 exec 必须配合 onlyif / unless / creates / refreshonly 等"幂等守卫",否则每次 Puppet 运行(默认每 30 分钟一次)都会重复执行,造成灾难(比如反复重启服务、反复追加配置行)。

结论:能用原生资源类型表达的,绝不要用 exec。


二、Puppet 架构与运行流程

2.1 C/S 架构

Puppet 是典型的 C/S(Master/Agent)架构配置管理工具:

  • master:管理端,集中编写配置清单(manifest),监听 8140 端口;
  • agent:被管理端,周期性(默认 30 分钟,由 runinterval 控制)从 master 拉取配置并在本机应用。
                 master
                   |
        +----------+----------+
        |          |          |
     server1    server2    server3  ......  (agent)
        Puppet Master
              |
    ---------------------
    |         |         |
  Agent1   Agent2    Agent3

管理员在 master 上执行诸如 touch /tmp/123、rpm -ivh xxx、yum install xxx 等操作的"声明",由 agent 负责真正落地。

当被管理机器的操作系统或版本不同时,Puppet 提供一个叫 facter 的包,可以采集被管机器的系统、版本等信息,管理员据此写出差异化配置。

2.2 两种运行模式

(1)Master/Agent 模式(C/S,生产环境标准做法)

代码集中放在 Master 上,Agent 定期拉取。优点是集中管理、有报告、有证书鉴权。

(2)单机 apply 模式(Masterless)

不需要 Master,直接在本机执行 manifest:

puppet apply /etc/puppet/manifests/site.pp
puppet apply -e 'file { "/tmp/a.txt": content => "hi\n" }'    # 直接执行一段代码

适用场景:学习测试、镜像打包(Packer/Docker 构建)、少量机器、用 Git + cron 自行分发代码的 masterless 架构。

2.3 核心组件

组件 作用 说明
Puppet Master 编译并下发 catalog,充当 CA 3.x 默认用 Ruby 的 WEBrick(单线程,只能撑约 10 台 agent,仅供测试);生产需换 Passenger + Apache。4.x+ 改为 Puppet Server(Clojure/JRuby 编写,跑在 JVM 上,多线程,性能高得多,默认即生产可用)
Puppet Agent 收集 facts、请求并应用 catalog、回报 report 守护进程或 puppet agent --test 手工运行
Facter 采集节点系统信息(fact),如 OS 类型、版本、IP、内存、CPU 结果作为顶层变量供 manifest 使用
PuppetDB 存储节点 facts、catalog、report 与导出资源 支持导出资源(exported resources)、查询 API;底层用 PostgreSQL
Hiera 数据与代码分离的层级键值查找系统 把环境差异、敏感数据从 manifest 中抽出,放进 YAML
Puppet CA 内置证书颁发机构,签发 Agent 证书 默认由 Master 自身承担
Dashboard / Foreman Web 报告与管理界面 见后文扩展章节
MCollective 基于消息队列的实时命令下发框架 配合 ActiveMQ/RabbitMQ 使用

2.4 Puppet 的运行流程(五步)

这是必须记住的核心流程:

  Agent                                       Master
    |                                           |
 1. 收集 Facts (facter)                          |
    |------- 2. 发送 Facts + 证书鉴权 ----------->|
    |                                       3. 编译 Catalog
    |                                          (读 site.pp / node / class /
    |                                           模块 / Hiera 数据,
    |                                           把 manifest 编译成
    |                                           针对该节点的资源清单)
    |<------ 4. 下发 Catalog (纯资源列表) --------|
 5. 应用 Catalog,比对当前状态并修改
    |------- 6. 回报 Report -------------------->|
    |                                       (存入 PuppetDB / Dashboard)
  1. Agent 收集 Facts:Agent 启动后先运行 facter,采集本机 OS、版本、主机名、IP、内存等信息;
  2. 发送给 Master:通过 HTTPS(8140)把 facts 发给 Master,同时完成 SSL 双向认证;
  3. Master 编译 Catalog:Master 根据 facts 与节点名匹配 node 定义,读取对应的 class/模块/Hiera 数据,把声明式代码编译成一份只属于该节点的、已经解析完所有变量和条件判断的资源清单(Catalog)。注意:编译发生在 Master 端,Agent 拿到的是结果而不是代码;
  4. 下发 Catalog:Master 把 catalog 返回给 Agent;
  5. Agent 应用并回报:Agent 逐个资源比对当前状态与期望状态,只修改不一致的部分,然后生成 report 回报给 Master。

关键推论:修改 site.pp 或模块代码后不需要重启 Master,因为 catalog 是每次请求时现场编译的。但修改 puppet.conf 必须重启服务才生效。

2.5 证书与 SSL 认证机制

为什么需要证书?

Master 会把整个环境的配置(可能包含密码、密钥、内部拓扑)下发给 Agent。如果任何机器都能冒充某个节点来请求 catalog,就能窃取配置;反之如果 Agent 不验证 Master,也可能被中间人下发恶意配置。因此 Puppet 使用 SSL 双向认证(mutual TLS):

  • Agent 要验证 Master 的证书(防止连到假 Master);
  • Master 要验证 Agent 的证书(确认它就是它声称的那个节点,节点名即证书 CN)。

CA 的角色

Puppet Master 默认自带一个 CA(证书颁发机构)。CA 的私钥/证书保存在 Master 的 ssldir/ca/ 下。所有 Agent 的证书都由这个 CA 签发,因此双方可以通过"都信任同一个 CA"来互相验证。

首次连接的证书签发流程

1. Master 首次启动 → 自动生成 CA 密钥对 + CA 证书 + Master 自身的服务器证书
2. Agent 首次运行 puppet agent --test
     ├─ 本地生成私钥
     ├─ 生成 CSR(证书签名请求,CN = certname,默认是 FQDN)
     ├─ 把 CSR 发送给 Master
     └─ 下载 CA 证书(ca.pem),然后等待(阻塞或退出)
3. Master 端:puppet cert list          → 看到待签名的请求
              puppet cert sign <name>   → 用 CA 私钥签发证书
4. Agent 再次运行 puppet agent --test
     ├─ 取回自己已签发的证书(<certname>.pem)
     └─ 建立双向 SSL 连接,正式获取 catalog

证书与主机名、时间强绑定(后文排错章节详述):

  • 证书的 CN 是节点名,主机名改了证书就失效;
  • 证书有 notBefore / notAfter 有效期,两端时间不同步会导致证书"尚未生效"或"已过期"。

2.6 版本演进与差异

Puppet 的版本跨度大,目录结构和命令差异明显,这是照抄旧教程最容易踩的坑。

(1)目录结构对照

项目 Puppet 3.x(CentOS 6/7 早期,OSS 包) Puppet 4.x / 5.x / 6.x(AIO 全包)
配置目录 confdir /etc/puppet/ /etc/puppetlabs/puppet/
主配置文件 /etc/puppet/puppet.conf /etc/puppetlabs/puppet/puppet.conf
manifest 入口 /etc/puppet/manifests/site.pp /etc/puppetlabs/code/environments/production/manifests/site.pp
模块目录 /etc/puppet/modules/ /etc/puppetlabs/code/environments/production/modules/(另有 /etc/puppetlabs/code/modules/)
代码根目录 codedir 无(混在 confdir) /etc/puppetlabs/code/
数据目录 vardir /var/lib/puppet/ /opt/puppetlabs/puppet/cache/
SSL 目录 ssldir /var/lib/puppet/ssl/ /etc/puppetlabs/puppet/ssl/
日志目录 logdir /var/log/puppet/ /var/log/puppetlabs/puppet/(Server 日志在 /var/log/puppetlabs/puppetserver/)
运行目录 rundir /var/run/puppet/ /var/run/puppetlabs/
Hiera 配置 /etc/puppet/hiera.yaml /etc/puppetlabs/puppet/hiera.yaml(全局)+ 环境层 hiera.yaml(Hiera 5)
可执行文件 /usr/bin/puppet(系统 Ruby) /opt/puppetlabs/bin/puppet(自带 Ruby,需加 PATH 或做软链接)

(2)包名与服务名对照

项目 Puppet 3.x Puppet 4.x+ (AIO)
Master 包名 puppet-server puppetserver
Agent 包名 puppet puppet-agent
Master 服务名 puppetmaster puppetserver
Agent 服务名 puppet(部分版本 puppetagent) puppet
Web 服务器 WEBrick(测试)/ Passenger(生产) 内置 Jetty(JVM),生产可用
Ruby 依赖系统 Ruby 1.8/1.9 AIO 自带 Ruby,不污染系统

(3)命令对照

功能 Puppet 3.x Puppet 6.x+
查看待签证书 puppet cert list puppetserver ca list
查看全部证书 puppet cert list --all puppetserver ca list --all
签发证书 puppet cert sign <name> puppetserver ca sign --certname <name>
批量签发 puppet cert --sign --all puppetserver ca sign --all
吊销/清除证书 puppet cert clean <name> puppetserver ca clean --certname <name>
生成证书 puppet cert generate <name> puppetserver ca generate --certname <name>

说明:puppet cert 在 5.x 中被标记为废弃(deprecated),在 6.0 中被移除,改用 puppetserver ca。Puppet 4.x/5.x 两者都可用。

(4)AIO(All-In-One)全包安装

从 4.x 开始,Puppet 官方改用 AIO 打包方式:puppet-agent 包里自带专属的 Ruby、Facter、Hiera、gem 依赖,全部装在 /opt/puppetlabs/ 下,与系统 Ruby 完全隔离。好处是不再有"系统 Ruby 版本冲突"问题,代价是路径变了、命令不在默认 PATH 里:

# AIO 安装后把 puppet 命令加入 PATH
echo 'export PATH=/opt/puppetlabs/bin:$PATH' >> /etc/profile.d/puppet.sh
source /etc/profile.d/puppet.sh
# 或建软链接
ln -s /opt/puppetlabs/bin/puppet /usr/bin/puppet

(5)语言层面的差异(3.x → 4.x)

  • 4.x 引入数据类型系统(String、Integer、Array[String]、Enum、Optional 等),参数化类可做类型校验;
  • 4.x 移除了 import 关键字(3.x 中 import 'nodes.pp' 的写法在 4.x 失效,改为把所有 .pp 放在环境的 manifests/ 目录下自动加载);
  • 4.x 中裸词(bareword)不再自动当字符串,属性值建议一律加引号;mode 必须写成字符串 '0644';
  • 4.x 中 $::osfamily 等 fact 建议用 $facts['osfamily'] 访问(结构化 facts);
  • 4.x 默认启用 directory environments(目录环境),/etc/puppetlabs/code/environments/<env>/ 即一个环境。

三、安装部署与证书管理

3.1 环境准备

实验环境规划(一个 master,两个 agent,全部为 CentOS 7.3):

角色 主机名 IP 说明
Master master.cluster.com 10.1.1.2 管理端,监听 8140,兼 CA
Agent1 agent1.cluster.com 10.1.1.3 被管理端
Agent2 agent2.cluster.com 10.1.1.4 被管理端
(可选)yum 源 server.cluster.com 10.1.1.1 教学环境本地 yum 仓库

准备要求汇总:

  1. 主机名及互相绑定 /etc/hosts
  2. 时间同步
  3. 静态 IP
  4. 关闭 firewalld、SELinux(或按下文放行 8140 端口)
  5. 配置 yum 源(本地 ISO、163、EPEL、Puppet 官方源)

3.1.1 主机名与 /etc/hosts(最常见故障源,务必正确)

这是 Puppet 部署中排第一位的故障原因,因为 Puppet 的证书 CN 就是主机名,Agent 必须能用名字(而不是 IP)连上 Master,Master 也要能反查 Agent 的名字。

CentOS 7 设置主机名:

hostnamectl set-hostname --static master.cluster.com
hostnamectl set-hostname --static agent1.cluster.com
hostnamectl set-hostname --static agent2.cluster.com
hostname -f          # 验证 FQDN 是否正确

CentOS 6 设置主机名:

# 临时生效
hostname master.cluster.com
# 永久生效,编辑 /etc/sysconfig/network
# HOSTNAME=master.cluster.com
vi /etc/sysconfig/network

三台机器的 /etc/hosts 都要写全(Master 和所有 Agent 都要能互相解析):

# /etc/hosts
127.0.0.1   localhost localhost.localdomain
10.1.1.2    master.cluster.com  master
10.1.1.3    agent1.cluster.com  agent1
10.1.1.4    agent2.cluster.com  agent2

要点强调:

  • Agent 的 server = 配置项必须写 Master 的主机名,不能写 IP,也不建议写别名(别名可能导致证书 CN 不匹配报错),且该名字必须能通过 hosts 或 DNS 解析;
  • Agent 自己的 FQDN 必须唯一且稳定,一旦签发证书后再改主机名,必须重新签发证书;
  • 用 ping master.cluster.com、getent hosts master.cluster.com 验证解析;
  • 克隆虚拟机做实验时,最容易出现两台 Agent 主机名相同 → 证书冲突。

3.1.2 时间同步(对证书有效性至关重要)

SSL 证书带有生效时间(notBefore)和过期时间(notAfter)。如果:

  • Agent 时间比 Master 慢很多 → 证书看起来"尚未生效"(certificate is not yet valid);
  • Agent 时间比 Master 快很多 → 证书看起来"已过期"(certificate has expired);

两种情况都会导致 SSL 握手失败,且报错信息很容易被误判为"证书没签"。

# CentOS 7:chrony(推荐)
yum install -y chrony
systemctl enable chronyd
systemctl start chronyd
chronyc sources -v
timedatectl set-timezone Asia/Shanghai

# CentOS 7/6:临时手工同步
ntpdate ntp1.aliyun.com

# CentOS 6:ntpd
yum install -y ntp
service ntpd start
chkconfig ntpd on

# 验证两端时间一致
date

详见 08-03-时间同步与Xinetd.md

3.1.3 防火墙放行 8140

Master 需要放行 TCP 8140(若搭 dashboard 还需 8141,PuppetDB 需 8081)。

# CentOS 7 firewalld
firewall-cmd --permanent --add-port=8140/tcp
firewall-cmd --permanent --add-port=8141/tcp    # 可选:dashboard
firewall-cmd --reload
firewall-cmd --list-all

# 教学实验环境可直接关闭
systemctl stop firewalld
systemctl disable firewalld

# CentOS 6 iptables
iptables -I INPUT -p tcp --dport 8140 -j ACCEPT
service iptables save
service iptables restart
# 或关闭
service iptables stop
chkconfig iptables off

详见 07-03-防火墙与SELinux.md

3.1.4 SELinux 处理

SELinux 开启时,Puppet 用 file 资源下发的文件可能因缺少正确的安全上下文(context)而无法被服务读取(典型:下发 /var/www/html/index.html 后 httpd 403)。

# 查看状态
getenforce
sestatus

# 临时关闭(立即生效,重启失效)
setenforce 0

# 永久关闭:编辑 /etc/selinux/config
# SELINUX=disabled
sed -i 's/^SELINUX=enforcing/SELINUX=disabled/' /etc/selinux/config

# 若必须保持 enforcing,则在 file 资源中显式声明上下文
file { '/var/www/html/index.html':
  ensure  => file,
  content => "hello\n",
  seltype => 'httpd_sys_content_t',   # 显式指定 SELinux 类型
  selrole => 'object_r',
}

详见 07-03-防火墙与SELinux.md

3.1.5 静态 IP 与 NetworkManager

Puppet 节点必须是静态 IP,IP 漂移会导致 hosts 解析失效。CentOS 7 上 NetworkManager 可能覆盖 /etc/resolv.conf 与 hosts 相关行为,教学环境通常关闭它:

# CentOS 7:关闭 NetworkManager,使用 network 服务
systemctl stop NetworkManager
systemctl disable NetworkManager
systemctl restart network
systemctl enable network

# 或者保留 NetworkManager,但让它不改 DNS:
# 在 /etc/NetworkManager/NetworkManager.conf 的 [main] 段加
# dns=none

静态 IP 配置(CentOS 7,/etc/sysconfig/network-scripts/ifcfg-eth0):

TYPE=Ethernet
BOOTPROTO=static
NAME=eth0
DEVICE=eth0
ONBOOT=yes
IPADDR=10.1.1.2
NETMASK=255.255.255.0
GATEWAY=10.1.1.254
DNS1=114.114.114.114
NM_CONTROLLED=no

3.2 配置 yum 源

Puppet 官方源(Puppet 3.x,EL7):

# /etc/yum.repos.d/puppet.repo
[puppet]
name=puppet
baseurl=http://yum.puppetlabs.com/el/7Server/products/x86_64/
enabled=1
gpgcheck=0

[puppet-dependencies]
name=puppet-dependencies
baseurl=http://yum.puppetlabs.com/el/7Server/dependencies/x86_64/
enabled=1
gpgcheck=0

教学环境本地源(已将两个仓库合并):

# /etc/yum.repos.d/puppet.repo
[puppet]
name=puppet
baseurl=http://10.1.1.1/puppet
enabled=1
gpgcheck=0

Puppet 4.x+ 官方 release 包方式(PC1 / puppet5 / puppet6 仓库):

# Puppet 4.x(PC1 仓库)
rpm -Uvh https://yum.puppetlabs.com/puppetlabs-release-pc1-el-7.noarch.rpm

# Puppet 5.x
rpm -Uvh https://yum.puppetlabs.com/puppet5-release-el-7.noarch.rpm

# Puppet 6.x
rpm -Uvh https://yum.puppetlabs.com/puppet6-release-el-7.noarch.rpm

# CentOS 6 换成 el-6
rpm -Uvh https://yum.puppetlabs.com/puppetlabs-release-pc1-el-6.noarch.rpm

国内源加速(清华/阿里镜像,可选):

# /etc/yum.repos.d/puppet6.repo
[puppet6]
name=Puppet 6 Repository el 7 - x86_64
baseurl=https://mirrors.tuna.tsinghua.edu.cn/puppet/yum/puppet6/el/7/x86_64
enabled=1
gpgcheck=0

同时建议配置系统基础源与 EPEL:

# 阿里云 base 源
curl -o /etc/yum.repos.d/CentOS-Base.repo http://mirrors.aliyun.com/repo/Centos-7.repo
# EPEL
yum install -y epel-release
yum clean all && yum makecache

3.3 安装 Master

Puppet 3.x:

yum install puppet-server -y

# CentOS 7
systemctl start puppetmaster
systemctl enable puppetmaster
systemctl status puppetmaster

# CentOS 6
service puppetmaster start
chkconfig puppetmaster on
service puppetmaster status

# 验证端口
lsof -i:8140
netstat -tunlp | grep 8140
ss -tunlp | grep 8140

Puppet 4.x/5.x/6.x(puppetserver):

yum install puppetserver -y

# puppetserver 跑在 JVM 上,默认申请 2GB 内存,实验机内存小需调小
vi /etc/sysconfig/puppetserver
# JAVA_ARGS="-Xms512m -Xmx512m -Djruby.logger.class=com.puppetlabs.jruby_utils.jruby.Slf4jLogger"

systemctl start puppetserver
systemctl enable puppetserver
systemctl status puppetserver

# 首次启动较慢(JVM 预热 + 生成 CA),耐心等待
tail -f /var/log/puppetlabs/puppetserver/puppetserver.log

3.4 安装 Agent

在所有 agent 上执行。

Puppet 3.x:

yum install puppet -y
systemctl start puppet
systemctl enable puppet
systemctl status puppet

部分版本服务名为 puppetagent:

systemctl start puppetagent
systemctl enable puppetagent
systemctl status puppetagent

CentOS 6:

service puppet start
chkconfig puppet on

Puppet 4.x+:

yum install puppet-agent -y
export PATH=/opt/puppetlabs/bin:$PATH
puppet --version

systemctl start puppet
systemctl enable puppet

3.5 版本一致性要求

  • Agent 的版本不应高于 Master 的版本。Master 负责编译 catalog,若 Agent 比 Master 新,Agent 可能请求 Master 不支持的 API 版本或语言特性,导致编译/应用失败;反之(Agent 略旧于 Master)通常是安全兼容的;
  • 同一环境内的 Agent 尽量保持版本统一,避免同一份 manifest 在不同 Agent 上行为不一致(例如 3.x 与 4.x 对裸词、mode 类型的处理不同);
  • 3.x 的 Agent 不能连 4.x 的 Master(协议与 catalog 格式差异过大),跨大版本升级必须先升 Master 再升 Agent;
  • 查看版本:
puppet --version
facter --version
ruby --version                     # 3.x 依赖系统 Ruby
/opt/puppetlabs/puppet/bin/ruby -v # 4.x+ AIO 自带 Ruby
rpm -qa | grep -E 'puppet|facter|ruby'

依赖说明:

  • Puppet 3.x 依赖系统 Ruby 1.8.7/1.9.3+、ruby-libs、ruby-shadow(管理用户密码需要)、facter、hiera;
  • Puppet 4.x+ 的 AIO 包自带 Ruby 2.x、Facter 3(用 C++ 重写,速度快很多)、Hiera,不依赖系统 Ruby;
  • facter 是必装组件,Puppet 靠它采集 fact。

3.6 主配置文件 puppet.conf 逐项详解

路径对照:

版本 主配置文件路径
Puppet 3.x /etc/puppet/puppet.conf
Puppet 4.x+ /etc/puppetlabs/puppet/puppet.conf

配置文件是 INI 格式,分 [main]、[master](4.x+ 也叫 [server])、[agent] 三个段。[main] 是全局默认,其他段可覆盖它。

完整示例(Puppet 3.x,Master 端):

[main]
    # 配置目录,所有相对路径的基准
    confdir  = /etc/puppet
    # 变量数据目录:ssl、state、报告、缓存、客户端 catalog 都在这里
    vardir   = /var/lib/puppet
    # 日志目录
    logdir   = /var/log/puppet
    # PID 文件目录
    rundir   = /var/run/puppet
    # SSL 证书目录(CA、私钥、证书、CRL)
    ssldir   = $vardir/ssl
    # 模块搜索路径,多个路径用冒号分隔
    modulepath = /etc/puppet/modules:/usr/share/puppet/modules
    # Agent 端需要:指定 Master 主机名
    server   = master.cluster.com

[master]
    # Master 自身的证书名(证书 CN),默认取 FQDN
    certname     = master.cluster.com
    # 证书的备用名,让 Agent 可以用别名/IP 连接而不报 CN 不匹配
    # 注意:修改此项后必须重新生成 Master 证书才生效
    dns_alt_names = master.cluster.com,master,puppet,10.1.1.2
    # 自动签名:true 表示无条件签发所有 CSR(危险);
    # 也可指向 autosign.conf 文件按域名白名单自动签
    autosign     = /etc/puppet/autosign.conf
    # 报告处理器:store 存本地 yaml,http 发给 dashboard,log 写日志
    reports      = store,http
    reporturl    = http://10.1.1.2:8141/reports
    # manifest 入口文件(3.x)
    manifest     = $confdir/manifests/site.pp
    # 环境目录(4.x+ 用 environmentpath)
    environmentpath = $confdir/environments

[agent]
    # Master 地址(必须是主机名,且可解析)
    server       = master.cluster.com
    # 本机证书名,默认 FQDN;多网卡/多域名环境可显式指定
    certname     = agent1.cluster.com
    # 守护模式下与 Master 同步的间隔,单位秒,默认 1800(30 分钟)
    # 可写 30m / 1h / 3(秒)
    runinterval  = 1800
    # 使用的环境
    environment  = production
    # 是否把运行结果回报给 Master
    report       = true
    # 随机延迟启动,避免上千 Agent 同时打爆 Master
    splay        = true
    splaylimit   = 600
    # 是否以守护进程方式运行
    daemonize    = true
    # 日志级别
    log_level    = notice
    # 遇到 pluginsync(同步自定义 fact/函数)
    pluginsync   = true

Puppet 4.x+ 示例:

[main]
    confdir  = /etc/puppetlabs/puppet
    codedir  = /etc/puppetlabs/code
    vardir   = /opt/puppetlabs/puppet/cache
    logdir   = /var/log/puppetlabs/puppet
    rundir   = /var/run/puppetlabs
    ssldir   = /etc/puppetlabs/puppet/ssl
    server   = master.cluster.com

[server]
    certname        = master.cluster.com
    dns_alt_names   = master.cluster.com,puppet
    autosign        = /etc/puppetlabs/puppet/autosign.conf
    reports         = store,puppetdb
    environmentpath = $codedir/environments
    strict_variables = true

[agent]
    server      = master.cluster.com
    certname    = agent1.cluster.com
    runinterval = 30m
    environment = production
    report      = true
    splay       = true

关键参数逐项说明:

参数 所属段 说明
confdir main 配置根目录,$confdir 可在其他参数中引用
vardir main 可变数据目录(ssl、state、lock、报告、缓存)
logdir main 日志目录
rundir main PID / lock 文件目录
ssldir main 证书目录;重置证书就是删这个目录
modulepath main/master 模块搜索路径(4.x 推荐用环境目录中的 modules)
certname master/agent 证书 CN,唯一标识节点,也是 node 匹配依据
dns_alt_names master Master 证书的备用名,允许 Agent 用别名连接
autosign master 自动签名开关或白名单文件
reports master 报告处理方式:none/store/log/http/puppetdb
manifest master 3.x 的入口 manifest 文件
environmentpath master 目录环境根路径
server agent Master 主机名(必填)
runinterval agent 拉取间隔,默认 1800 秒
environment agent 使用哪个环境(production/development)
report agent 是否回报 report
splay / splaylimit agent 随机化启动时间,削峰
noop agent 全局空跑模式,只报告不修改
pluginsync agent 同步 Master 上模块的自定义 fact/函数/类型

重要:puppet.conf 修改后必须重启对应服务才生效(Master 重启 puppetmaster/puppetserver,Agent 重启 puppet)。而 manifest/模块代码修改后不需要重启。

查看某参数当前实际生效的值:

puppet config print all
puppet config print modulepath
puppet config print ssldir
puppet config print confdir vardir logdir
puppet config print server --section agent

3.7 证书签发的完整流程

3.7.1 第一步:Master 启动并生成 CA

首次启动 Master 时会自动生成 CA:

# Puppet 3.x
systemctl start puppetmaster
ls /var/lib/puppet/ssl/
# ca  certs  crl.pem  private  private_keys  public_keys

ls /var/lib/puppet/ssl/ca/
# ca_crl.pem  ca_crt.pem  ca_key.pem  ca_pub.pem  inventory.txt
# requests  serial  signed

# Puppet 4.x+
systemctl start puppetserver
ls /etc/puppetlabs/puppet/ssl/ca/

验证 Master 端口已监听:

lsof -i:8140
ss -tunlp | grep 8140

3.7.2 第二步:Agent 配置并发起 CSR

在所有 agent 上编辑 /etc/puppet/puppet.conf(4.x 为 /etc/puppetlabs/puppet/puppet.conf),在 [main] 段(或 [agent] 段)加上:

server = master.cluster.com

这里必须写 puppet 服务器的主机名,别名可能有问题,并且必须能通过 hosts/DNS 解析。

发送验证请求(发起 CSR):

puppet agent --server=master.cluster.com --test

该命令会产生创建 SSL 证书的信息,典型输出:

Info: Creating a new SSL key for agent1.cluster.com
Info: Caching certificate for ca
Info: csr_attributes file loading from /etc/puppet/csr_attributes.yaml
Info: Creating a new SSL certificate request for agent1.cluster.com
Info: Certificate Request fingerprint (SHA256): 0D:FE:23:A0:...
Exiting; no certificate found and waitforcert is disabled

查看 agent 端证书目录,此时只有 CA 证书,还没有自己的证书:

ls /var/lib/puppet/ssl/certs/
# ca.pem

3.7.3 第三步:Master 签名

在 master 上列出待签请求:

puppet cert list
  "agent1.cluster.com" (SHA256) 0D:FE:23:A0:BA:DC:59:74:95:A3:DD:0D:15:C3:68:1D:EC:9C:94:C5:49:9F:65:65:9A:1A:DB:EB:B4:C3:05:DD
  "agent2.cluster.com" (SHA256) 36:A4:39:46:9D:09:BF:E7:96:D4:AE:A3:51:3B:C0:07:1A:E9:A1:A1:58:FB:DE:2F:28:09:1A:CD:9E:96:3B:1C

签发单个证书:

puppet cert sign agent1.cluster.com

对所有请求批量签名:

puppet cert --sign --all
# 等价写法
puppet cert sign --all

查看已签发的全部证书(+ 号表示已签发,无前缀表示待签):

puppet cert list --all
+ "master.cluster.com" (SHA256) AA:BB:... (alt names: "DNS:master.cluster.com", "DNS:puppet")
+ "agent1.cluster.com" (SHA256) 0D:FE:...
+ "agent2.cluster.com" (SHA256) 36:A4:...

吊销/清除某个 Agent 的证书:

puppet cert clean agent1.cluster.com

Puppet 4.x/5.x/6.x 的 puppetserver ca 对照命令:

puppetserver ca list                                # 待签
puppetserver ca list --all                          # 全部
puppetserver ca sign --certname agent1.cluster.com  # 签发单个
puppetserver ca sign --all                          # 批量签发
puppetserver ca clean --certname agent1.cluster.com # 吊销清除
puppetserver ca revoke --certname agent1.cluster.com # 只吊销不删文件
puppetserver ca generate --certname agent3.cluster.com # 在 Master 端直接生成

3.7.4 第四步:Agent 再次拉取取回证书

回到所有 agent 再次执行:

puppet agent --server=master.cluster.com --test

再次查看 /var/lib/puppet/ssl/certs/,会多出一个以主机名命名的证书文件,例如 agent1 上为 agent1.cluster.com.pem:

ls /var/lib/puppet/ssl/certs/
# agent1.cluster.com.pem  ca.pem

此时 SSL 双向认证建立完成,Agent 可以正常获取 catalog。

3.7.5 自动签名 autosign.conf

节点数量多(或云环境频繁扩缩容)时,手工签名不现实,可配置自动签名。

方式一:无条件自动签名(极不安全,仅限隔离的实验环境)

# puppet.conf 的 [master] 段
[master]
    autosign = true

方式二:白名单文件(推荐)

# puppet.conf 的 [master] 段
[master]
    autosign = /etc/puppet/autosign.conf
# /etc/puppet/autosign.conf
# 支持完整主机名
agent1.cluster.com
# 支持域名通配(*.开头)
*.cluster.com
*.test.example.com
# 4.x+ 路径
vi /etc/puppetlabs/puppet/autosign.conf

方式三:基于策略的自动签名(policy-based,最安全)

autosign 指向一个可执行脚本,Puppet 把 certname 作为参数、CSR 内容从标准输入传给它,脚本返回 0 则签发。可结合 csr_attributes.yaml 中的预共享密钥校验:

[master]
    autosign = /usr/local/bin/check_csr.sh
#!/bin/bash
# /usr/local/bin/check_csr.sh
CERTNAME=$1
CSR=$(cat)
# 从 CSR 扩展属性中取出预共享 token 并校验
echo "$CSR" | openssl req -noout -text | grep -q "MySecretToken12345"
exit $?

自动签名的安全风险(必须知晓):

  • 开启 autosign = true 后,任何能连上 8140 的机器都能声称自己是任意节点名(例如冒充 db-master.example.com)并拿到该节点的完整 catalog;
  • catalog 里往往包含数据库密码、SSH 密钥、API token 等敏感数据 → 直接导致配置泄露;
  • 攻击者还可以抢先注册一个合法节点即将使用的名字,造成正常节点无法上线(拒绝服务);
  • 因此:生产环境禁止 autosign = true,至少使用域名白名单,最好用带预共享密钥的策略脚本,并把 8140 端口限制在内网/安全组内。

3.7.6 证书问题的重置方法(重建连接)

如果建立连接有问题(证书冲突、主机名改过、克隆机重复、报 SSL 错误),需要两端都清理后重新签发。

# ===== 第一步:在 Master 上清除该 Agent 的证书 =====
puppet cert clean agent1.cluster.com
puppet cert clean agent2.cluster.com
# 4.x+
puppetserver ca clean --certname agent1.cluster.com

# 确认已清除
puppet cert list --all

# ===== 第二步:在 Agent 上删除本地 ssl 目录 =====
systemctl stop puppet            # 先停掉守护进程,否则会占用
rm -rf /var/lib/puppet/ssl/*     # Puppet 3.x
# rm -rf /etc/puppetlabs/puppet/ssl/*    # Puppet 4.x+
# 原文档写法(只删 certs 子目录,多数情况够用,彻底重置建议删整个 ssl 目录)
rm /var/lib/puppet/ssl/certs/* -rf

# ===== 第三步:Agent 重新发起请求 =====
puppet agent --server=master.cluster.com --test

# ===== 第四步:Master 重新签名 =====
puppet cert list
puppet cert --sign --all

# ===== 第五步:Agent 再次运行取回证书 =====
puppet agent --server=master.cluster.com --test

# ===== 第六步:恢复守护进程 =====
systemctl start puppet

排错后重新执行上述 3.7.2 ~ 3.7.4 的步骤即可。

如果是 Master 自身的证书要重置(例如改了 dns_alt_names 或改了 Master 主机名),影响面更大,所有 Agent 都要重签:

# Master 上
systemctl stop puppetmaster           # 或 puppetserver
rm -rf /var/lib/puppet/ssl/*          # 3.x;4.x 为 /etc/puppetlabs/puppet/ssl/*
systemctl start puppetmaster          # 重新生成 CA 与 Master 证书

# 所有 Agent 上都要清理并重新申请
systemctl stop puppet
rm -rf /var/lib/puppet/ssl/*
puppet agent --test --server=master.cluster.com

3.8 验证与 Agent 运行方式

服务启动与开机自启:

# Master(3.x / 4.x+)
systemctl start puppetmaster   ; systemctl enable puppetmaster
systemctl start puppetserver   ; systemctl enable puppetserver

# Agent
systemctl start puppet         ; systemctl enable puppet
systemctl start puppetagent    ; systemctl enable puppetagent   # 部分版本服务名

# CentOS 6
service puppetmaster start ; chkconfig puppetmaster on
service puppet start       ; chkconfig puppet on

Agent 手工运行的常用参数:

# 最常用:前台运行一次,输出详细过程(--test = --onetime --verbose --no-daemonize --show_diff --detailed-exitcodes 的组合)
puppet agent --test

# 指定 Master
puppet agent --test --server master.cluster.com

# 空跑演练:只报告"将会做什么",不真正修改系统(上线前必做)
puppet agent --test --noop

# 显示更详细的调试信息(排错必备,会打印 HTTP 请求、fact 加载、资源比对细节)
puppet agent --test --debug

# 只要详细输出,不要 debug 那么啰嗦
puppet agent --test --verbose

# 显示文件内容差异
puppet agent --test --show_diff

# 不以守护进程方式运行(原文档写法)
puppet agent --test --server=master.cluster.com --no-daemonize --verbose

# 指定环境
puppet agent --test --environment development

# 一次性运行后退出,用退出码判断结果(0=无变更, 2=有变更, 4=有失败, 6=有变更且有失败)
puppet agent --onetime --no-daemonize --detailed-exitcodes

守护模式与 runinterval:

Puppet agent 默认每 30 分钟与 master 同步一次。为快速验证,可在客户端 puppet.conf 的 [agent] 段加入:

runinterval = 3

表示 3 秒同步一次(仅用于实验,生产环境绝不可这样设,会打爆 Master)。改完重启服务:

systemctl restart puppet
systemctl restart puppetagent

日志位置:

内容 Puppet 3.x Puppet 4.x+
Master 日志 /var/log/puppet/masterhttp.log、/var/log/messages /var/log/puppetlabs/puppetserver/puppetserver.log
Agent 日志 /var/log/messages(syslog)、/var/log/puppet/ /var/log/messages、journalctl -u puppet
报告 /var/lib/puppet/reports/<certname>/*.yaml /opt/puppetlabs/puppet/cache/reports/
tail -f /var/log/messages | grep -i puppet
journalctl -u puppet -f
journalctl -u puppetserver -f

四、Puppet DSL 与 Manifest 基础

4.1 manifest 文件

  • Puppet 的配置清单文件称为 manifest,扩展名为 .pp;
  • 入口文件是 site.pp:
    • Puppet 3.x:/etc/puppet/manifests/site.pp
    • Puppet 4.x+:/etc/puppetlabs/code/environments/production/manifests/site.pp
  • site.pp 修改后立即生效,无需重启 master(因为每次 Agent 请求时都会重新编译 catalog)。

语法检查(写完必做,避免 Agent 拉到坏代码):

# 检查单个文件语法
puppet parser validate /etc/puppet/manifests/site.pp
puppet parser validate /etc/puppet/modules/httpd/manifests/init.pp

# 批量检查所有 pp 文件
find /etc/puppet -name '*.pp' -exec puppet parser validate {} \;

# 检查 ERB 模板语法
erb -P -x -T '-' /etc/puppet/modules/httpd/templates/httpd.conf.erb | ruby -c

# 代码风格检查(需安装 puppet-lint gem)
gem install puppet-lint
puppet-lint /etc/puppet/modules/httpd/manifests/init.pp

# 本机试跑一段代码
puppet apply --noop /etc/puppet/manifests/site.pp

代码风格约定(Puppet Style Guide):

  • 缩进使用 2 个空格,不用 Tab;
  • 属性的 => 尽量对齐;
  • 资源类型和属性名全部小写;
  • 资源标题(title)用单引号;除需要变量插值时用双引号;
  • 每个资源块的最后一个属性后也加逗号(trailing comma),便于后续增删;
  • ensure 永远写在属性列表的第一行;
  • 注释使用 #;
  • 模块名、类名只用小写字母、数字和下划线,不能有横线。
# 这是单行注释

/*
  Puppet 也支持 C 风格多行注释(不推荐,官方建议用 #)
*/

# 推荐风格
file { '/etc/motd':
  ensure  => file,
  owner   => 'root',
  group   => 'root',
  mode    => '0644',
  content => "Welcome to ${::fqdn}\n",
}

4.2 第一个 manifest 测试

服务端编写 site.pp(/etc/puppet/manifests/site.pp):

node default {                  # default 节点代表所有节点
  file { "/tmp/test.txt":       # 资源 title,未使用 path 参数时即文件路径
          content => "hello,world\n",   # 文件内容,文件不存在会自动创建
  }
}

或写作(更规范的风格):

node default {
  file { '/tmp/test.txt':
    ensure  => file,
    content => "hello, world\n",
  }
}

客户端验证:

puppet agent --test --server master.cluster.com
cat /tmp/test.txt
# hello,world

手动同步与排错:

同步异常时,在 agent 端手动执行查看报错:

puppet agent --test --server master.cluster.com

若提示:

Notice: Run of Puppet configuration client already in progress; skipping  (/var/lib/puppet/state/agent_catalog_run.lock exists)

说明有另一个 Puppet 运行实例(通常是守护进程)正在跑,处理方式:

rm -rf /var/lib/puppet/state/agent_catalog_run.lock
systemctl stop puppet
systemctl stop puppetagent
puppet agent --test --server master.cluster.com

根据红色错误提示排错,完成后再启动服务:

systemctl start puppet
systemctl start puppetagent

更多资源语法参考:http://docs.puppetlabs.com/references/latest/type.html

4.3 资源声明的通用语法

资源类型 { '资源标题(title)':
  属性1 => 值1,
  属性2 => 值2,
}
  • 资源类型(type):file、package、service、exec 等,小写;
  • 标题(title / namevar):资源的唯一标识。同一类型下 title 不能重复,否则报 Duplicate declaration。多数资源类型的 title 默认就是其 name/path 属性的值;
  • 属性(attribute):=> 左边属性名,右边值;
  • 引用资源:类型首字母大写 + 方括号,如 Package['httpd']、File['/etc/httpd/conf/httpd.conf']、Service['httpd']。

4.4 元参数(Metaparameters,所有资源类型通用)

元参数 作用
ensure 期望状态(严格说是多数类型的普通属性,但用法通用)
require 本资源依赖某资源,被依赖者先执行
before 本资源必须在某资源之前执行
notify 本资源变更后,通知某资源刷新(refresh)
subscribe 本资源订阅某资源,被订阅者变更时自己刷新
tag 给资源打标签,可用 puppet agent --tags xxx 只应用部分资源
alias 给资源起别名,便于引用
schedule 限制资源只在某时间窗口内应用
noop 单个资源级别的空跑
audit 审计某属性的变化而不修改它
loglevel 该资源产生日志的级别
stage 指定运行阶段(run stage)
# tag 与 audit 示例
file { '/etc/sudoers':
  ensure => file,
  audit  => [owner, group, mode],   # 只审计不修改
  tag    => ['security', 'baseline'],
}
# 只应用带 security 标签的资源
puppet agent --test --tags security

五、核心资源类型详解

5.1 file 资源

常用属性:

属性 说明
path 文件路径(不写则用 title)
ensure present(存在即可)/ absent(删除)/ file(普通文件)/ directory(目录)/ link(软链接)
content 文件内容(字符串,支持 \n、变量插值、template())
source 源文件路径或 puppet:/// URL
target 软链接指向的目标(ensure => link 时必填)
mode 权限,如 '0755'、'4755'(4.x 必须写成字符串)
owner / group 属主 / 属组
recurse true/false/remote/inf,递归处理目录
purge 配合 recurse,删除目标目录中不由 Puppet 管理的多余文件
force 删除目录、替换目录为文件等强制操作时需设为 true/yes
backup 备份策略:false 不备份,或写 filebucket 名字
replace false 时只在文件不存在时创建,已存在则不覆盖内容
links follow/manage,遇到符号链接的处理方式
checksum 比对方式:md5(默认)/mtime/ctime
show_diff 是否在日志中显示内容差异
seltype/selrole/seluser/selrange SELinux 上下文
validate_cmd 应用前用命令校验文件(如 visudo -c -f %),校验失败则不写入

5.1.1 创建目录、文件并指定权限

node /\.cluster\.com$/ {
        file { "/test/":
                ensure => directory,
        }
        file { "/test/abc":
                content => "haha\n",
                mode => 4755,
                owner => bin,
                group => daemon,
        }
}

4.x 规范写法(属性值加引号):

node /\.cluster\.com$/ {
  file { '/test/':
    ensure => directory,
    mode   => '0755',
  }
  file { '/test/abc':
    ensure  => file,
    content => "haha\n",
    mode    => '4755',
    owner   => 'bin',
    group   => 'daemon',
  }
}

5.1.2 用 source 推送文件内容

把客户端 /etc/fstab 的内容复制为 /test/abc(相当于 cp /etc/fstab /test/abc,会覆盖原内容):

node /^agent\d+\.cluster\.com/ {
        file { "/test/":
                ensure => directory,
        }
        file { "/test/abc":
                source => "/etc/fstab",
                mode => 4755,
                owner => bin,
                group => daemon,
        }
}

也可简写为:

file { "/test/abc":
  source => "/etc/fstab",
  mode => "4755",
  owner => "bin",
  group => "daemon",
}

注意:source => "/etc/fstab" 这种本地绝对路径是指 Agent 本机的路径。要从 Master 分发文件必须用 puppet:/// 协议,见后文"文件分发"章节。

5.1.3 创建软链接

# 匹配 agentX.cluster.com,X 为任意数字
node /^agent\d+\.cluster\.com/ {
        file { "/tmp/fstab":
                ensure => link,          # 创建软链接
                target => "/etc/fstab",  # 源文件
        }
}

5.1.4 删除文件与目录

node 'agent1.cluster.com' {
        file { "/tmp/test.txt":
                ensure => absent,   # 保证文件不存在,存在则删除
        }
}
node 'agent2.cluster.com' {
        file { "/tmp/test.txt":
                ensure => absent,
        }
}

删除目录需要加 force => yes(否则 Puppet 拒绝删除非空目录)。

file { '/tmp/olddir':
  ensure => absent,
  force  => true,       # 递归删除目录及其内容
  purge  => true,
}

5.1.5 递归分发目录、purge 清理与备份

# 把 Master 上的整个目录递归同步到 Agent,并清理 Agent 上多余的文件
file { '/etc/nginx/conf.d':
  ensure  => directory,
  owner   => 'root',
  group   => 'root',
  mode    => '0644',
  recurse => true,        # 递归
  purge   => true,        # 删除目录中不由 Puppet 管理的多余文件
  force   => true,        # purge 遇到子目录时需要
  source  => 'puppet:///modules/nginx/conf.d',
  backup  => false,       # 不备份被覆盖的旧文件
}

# 只递归修改权限,不下发内容
file { '/opt/app':
  ensure  => directory,
  owner   => 'appuser',
  group   => 'appuser',
  recurse => true,
}

# 只在文件不存在时创建,已存在不覆盖(适合初始化模板)
file { '/etc/app/local.conf':
  ensure  => file,
  content => "# 本地覆盖配置,Puppet 只创建一次\n",
  replace => false,
}

# 写入前校验语法(避免下发坏的 sudoers 把系统弄坏)
file { '/etc/sudoers':
  ensure       => file,
  owner        => 'root',
  group        => 'root',
  mode         => '0440',
  source       => 'puppet:///modules/sudo/etc/sudoers',
  validate_cmd => '/usr/sbin/visudo -c -f %',
}

5.2 package 资源

常用属性:

属性 说明
name 包名(不写则用 title)
ensure installed / present(已装即可)、latest(每次运行都升级到最新)、absent(卸载)、purged(卸载并删配置)、具体版本号如 '2.4.6-45.el7'、held(锁定版本)
provider 包管理器:yum、rpm、apt、dpkg、gem、pip、npm 等
source 本地或远程包文件路径(provider => rpm 时必填)
install_options 传给包管理器的额外参数,如 ['--enablerepo=epel']
responsefile 应答文件(Debian 系)

推送 yum 配置并安装 vsftpd:

node /^agent\d+\.cluster\.com/ {
        file { "/etc/yum.repos.d/rhel-source.repo":
                content => "[server]\nname=server\nbaseurl=file:///yum\nenabled=1\ngpgcheck=0\n",
                mode => 644,
                owner => root,
                group => root,
        }
        package { 'install vsftpd':
                provider => yum,
                name => vsftpd,
                ensure => installed,
        }
}

注意上例中资源 title 是 'install vsftpd'(一个描述性名字),真正的包名由 name => vsftpd 指定。若省略 name,则 title 必须就是包名。

更多用法:

# 最简写法:title 即包名
package { 'httpd':
  ensure => installed,
}

# 一次装多个包(数组作为 title)
package { ['httpd', 'httpd-devel', 'mod_ssl', 'php']:
  ensure => installed,
}

# 每次运行都保证是最新版(慎用,可能造成意外升级)
package { 'nginx':
  ensure => latest,
}

# 锁定精确版本(生产推荐)
package { 'httpd':
  ensure => '2.4.6-45.el7.centos',
}

# 卸载
package { 'telnet':
  ensure => absent,
}

# 从 EPEL 仓库安装
package { 'htop':
  ensure          => installed,
  install_options => ['--enablerepo=epel'],
}

# 用 rpm provider 装本地包
package { 'custom-agent':
  ensure   => installed,
  provider => rpm,
  source   => '/tmp/custom-agent-1.0-1.el7.x86_64.rpm',
}

# 装 gem
package { 'bundler':
  ensure   => installed,
  provider => gem,
}

5.3 service 资源

常用属性:

属性 说明
name 服务名(不写则用 title)
ensure running / true(确保运行)、stopped / false(确保停止)
enable true 开机自启(相当于 systemctl enable / chkconfig on)、false 禁止自启
hasrestart 该服务的脚本是否支持 restart 参数。true 时 Puppet 用 service X restart;false 时用 stop + start
hasstatus 该服务脚本是否支持 status 参数。true(默认)时用 service X status 判断状态;false 时 Puppet 用 ps 查进程名
status 自定义状态检查命令(脚本不标准时用)
start / stop / restart 自定义启动/停止/重启命令
pattern hasstatus => false 时用于 ps 匹配的进程名模式
provider systemd、redhat(SysV init)、init、upstart 等
path init 脚本搜索路径

控制客户端服务状态:

node /^agent\d+\.cluster\.com/ {
        service { sshd:
                ensure => "stopped",   # 确保停止,启动了会被关闭
                enable => false,       # 相当于 systemctl disable sshd
        }
        service { httpd:
                ensure => "running",   # 确保运行,关闭了会被启动
                enable => true,        # 相当于 systemctl enable httpd
        }
}

规范写法:

node /^agent\d+\.cluster\.com/ {
  service { 'sshd':
    ensure => 'stopped',
    enable => false,
  }
  service { 'httpd':
    ensure => 'running',
    enable => true,
  }
}

自定义启停命令与非标准脚本的处理:

# 服务脚本不支持 restart,让 Puppet 用 stop+start
service { 'myapp':
  ensure     => running,
  enable     => true,
  hasrestart => false,
  hasstatus  => false,
  pattern    => 'myapp_daemon',     # 用 ps 匹配进程判断状态
}

# 完全自定义命令
service { 'legacy-app':
  ensure  => running,
  start   => '/opt/legacy/bin/start.sh',
  stop    => '/opt/legacy/bin/stop.sh',
  restart => '/opt/legacy/bin/restart.sh',
  status  => '/opt/legacy/bin/status.sh',
}

# 强制指定 provider(CentOS 6 用 redhat,CentOS 7 用 systemd)
service { 'httpd':
  ensure   => running,
  enable   => true,
  provider => $::operatingsystemmajrelease ? {
    '6'     => 'redhat',
    default => 'systemd',
  },
}

5.4 exec 资源

exec 是唯一命令式的资源类型,也是最容易破坏幂等性的资源,应尽量少用。

常用属性:

属性 说明
command 要执行的命令(不写则用 title)
path 命令搜索路径。若命令未写绝对路径则必须设置 path,否则报 "Could not find command"
cwd 命令执行的工作目录
user / group 以哪个用户/组身份执行
onlyif 幂等守卫1:只有当该命令退出码为 0 时才执行 command
unless 幂等守卫2:只有当该命令退出码非 0 时才执行 command
creates 幂等守卫3:指定一个文件路径,该文件不存在时才执行 command(最常用、最直观)
refreshonly 幂等守卫4:true 时只有被其他资源 notify 时才执行(平时不执行)
timeout 超时秒数,默认 300 秒;0 表示不限制
tries / try_sleep 失败重试次数与间隔
returns 认为是"成功"的退出码,默认 0,可写数组
logoutput 是否记录命令输出:true/false/on_failure(默认)
environment 设置环境变量,如 ['HOME=/root', 'LANG=C']
provider posix(默认)或 shell(支持管道、重定向等 shell 语法)

创建脚本并用 exec 执行:

node /^agent\d+\.cluster\.com/ {
        file { "/shell/":
                ensure => directory,
        }
        file { "/shell/1.sh":
                content => "mkdir -p /test\ntouch /test/{1..100}\n",
                mode => 755,
                owner => root,
                group => root,
        }
        exec { "exec-shell":
                cwd => "/shell",
                command => "sh 1.sh",
                user => "root",
                path => "/bin:/sbin:/usr/local/bin:/usr/local/sbin:/usr/sbin:/usr/bin",
        }
}

简化写法:

node /^agent\d+\.cluster\.com/ {
  file { "/shell/":
    ensure => directory,
  }
  file { "/shell/1.sh":
    content => "mkdir -p /test\ntouch /test/{1..100}\n",
    mode => "755",
  }
  exec { "exec-shell":
    cwd => "/shell",
    command => "sh 1.sh",
    user => "root",
    path => "/bin:/sbin:/usr/local/bin:/usr/local/sbin:/usr/sbin:/usr/bin",
  }
}

上面这个 exec 是不幂等的:每次 Puppet 运行(默认每 30 分钟)都会重新执行 sh 1.sh。下面给出三种幂等守卫的正确写法。

三种幂等守卫的对比与用法:

# ① creates —— 最直观:产物文件存在就不再执行(适合解压、编译、初始化)
exec { 'extract-tomcat':
  command => 'tar zxf /tmp/apache-tomcat-9.tar.gz -C /opt/',
  path    => '/bin:/usr/bin',
  creates => '/opt/apache-tomcat-9',     # 该目录已存在则跳过
}

# ② unless —— 检查命令返回非 0(即"条件不满足")时才执行
exec { 'add-iptables-rule':
  command => 'iptables -I INPUT -p tcp --dport 8080 -j ACCEPT',
  path    => '/sbin:/usr/sbin:/bin',
  unless  => 'iptables -C INPUT -p tcp --dport 8080 -j ACCEPT',  # 规则已存在则不加
}

exec { 'set-mysql-root-password':
  command => "mysqladmin -u root password 'Secret123'",
  path    => '/bin:/usr/bin',
  unless  => "mysql -u root -pSecret123 -e 'select 1'",   # 已能用新密码登录则跳过
}

# ③ onlyif —— 检查命令返回 0(即"条件满足")时才执行
exec { 'restart-if-degraded':
  command => 'systemctl restart myapp',
  path    => '/bin:/usr/bin',
  onlyif  => '/usr/local/bin/check_degraded.sh',   # 脚本返回 0 表示确实降级了
}

# ④ refreshonly —— 平时不跑,只在被 notify 时跑(配合配置文件变更)
exec { 'reload-sysctl':
  command     => 'sysctl -p /etc/sysctl.conf',
  path        => '/sbin:/usr/sbin',
  refreshonly => true,
  subscribe   => File['/etc/sysctl.conf'],
}

# 需要管道/重定向时用 shell provider
exec { 'count-users':
  command  => 'wc -l /etc/passwd > /tmp/usercount',
  provider => shell,
  creates  => '/tmp/usercount',
}

# 超时与重试
exec { 'long-build':
  command   => '/opt/build.sh',
  timeout   => 1800,        # 30 分钟
  tries     => 3,
  try_sleep => 60,
  logoutput => true,
}

为什么 exec 应尽量少用?

  1. 破坏幂等性:稍不注意就每次运行都执行,反复追加配置行、反复重启服务;
  2. 不可预知:Puppet 不知道命令做了什么,--noop 空跑时无法准确预告变更;
  3. 报告无意义:报告里只显示"执行了命令",不体现真正的系统状态变化;
  4. 跨平台差异:命令路径、参数在不同发行版上不同,失去了 Puppet 抽象层的价值;
  5. 调试困难:失败原因藏在命令输出里;
  6. 依赖顺序难表达:无法像原生资源那样自动推导依赖。

能用原生资源替代的场景(务必替代):

不要这样写 应该这样写
exec { 'useradd u1': } user { 'u1': ensure => present }
exec { 'yum install -y httpd': } package { 'httpd': ensure => installed }
exec { 'systemctl start httpd': } service { 'httpd': ensure => running }
exec { 'sed -i ... /etc/xxx.conf': } file { '/etc/xxx.conf': content => template(...) } 或 file_line(stdlib 的 augeas/社区 file_line)
exec { 'echo "1.1.1.1 h1" >> /etc/hosts': } host { 'h1': ip => '1.1.1.1' }
exec { 'mount /dev/sdb1 /data': } mount { '/data': ensure => mounted, device => ... }
exec { 'crontab -l ...': } cron { 'job1': ... }

5.5 user 与 group 资源

user 常用属性:

属性 说明
name 用户名(不写则用 title)
ensure present / absent / role
uid 用户 UID
gid 主组(组名或 GID,该组必须已存在)
groups 附加组(数组)
shell 登录 shell,如 /bin/bash、/sbin/nologin
home 家目录路径
managehome true 时创建家目录(等价 useradd -m);删用户时同时删家目录
password 加密后的密码串(不能写明文,需用 openssl passwd -1 或 grub-crypt 生成)。CentOS 6 上需装 ruby-shadow
comment 备注(GECOS 字段)
expiry 账号过期日期,YYYY-MM-DD 或 absent
password_max_age / password_min_age 密码最长/最短使用天数
system true 创建系统账号(UID < 1000)
membership minimum(只保证包含指定组,默认)/ inclusive(精确等于指定组列表,多余的组会被移除)
forcelocal 强制使用本地 provider(有 LDAP 时需要)
purge_ssh_keys true 时清理 authorized_keys 中不由 Puppet 管理的密钥

group 常用属性:

属性 说明
name 组名
ensure present / absent
gid 组 GID
members 组成员(数组,部分 provider 支持)
system 系统组
auth_membership true 时精确控制成员列表

创建 user2(相当于在 agent 上 useradd user2):

node /^agent\d+\.cluster\.com/ {
        user { "user2":
                ensure => present,
                managehome => true,   # 创建家目录
        }
}

删除 user2、新增 user3 和组 group1:

node /^agent\d+\.cluster\.com/ {
        user { "user2":
                ensure => absent,
                managehome => true,   # 删用户时连同家目录一起删除
        }
        user { "user3":
                ensure => present,
                uid => 505,
                shell => "/sbin/nologin",
        }
        group { "group1":
                ensure => present,
                gid => 520,
        }
}

指定 uid 时不要随意写 gid,若该 gid 的组不存在会出错。 正确做法是先声明 group 资源,并用 require 建立依赖。

完整、规范的用户管理示例:

group { 'developers':
  ensure => present,
  gid    => 3000,
}

user { 'zhangsan':
  ensure           => present,
  uid              => 3001,
  gid              => 'developers',
  groups           => ['wheel', 'docker'],
  shell            => '/bin/bash',
  home             => '/home/zhangsan',
  managehome       => true,
  comment          => 'Zhang San - Dev Team',
  # openssl passwd -1 'MyPassword'  生成
  password         => '$1$xyz$abcdefghijklmnopqrstuv',
  password_max_age => 90,
  password_min_age => 1,
  require          => Group['developers'],   # 关键:先建组
}

# 批量创建用户(数组 title)
user { ['op1', 'op2', 'op3']:
  ensure     => present,
  gid        => 'developers',
  shell      => '/bin/bash',
  managehome => true,
  require    => Group['developers'],
}

# 精确控制附加组(多余的组会被移除)
user { 'appuser':
  ensure     => present,
  groups     => ['appgroup'],
  membership => 'inclusive',
}

# 下发 SSH 公钥(ssh_authorized_key 资源)
ssh_authorized_key { 'zhangsan@laptop':
  ensure => present,
  user   => 'zhangsan',
  type   => 'ssh-rsa',
  key    => 'AAAAB3NzaC1yc2EAAAADAQABAAABAQ...',
  require => User['zhangsan'],
}

5.6 cron 资源

常用属性:

属性 说明
name 任务标识(会作为注释写入 crontab)
ensure present / absent
command 要执行的命令
user 属于哪个用户的 crontab,默认 root
minute 分,0-59,可写数组或 '*/5'
hour 时,0-23
monthday 日,1-31
month 月,1-12
weekday 周,0-7 或 'Mon'
special reboot/hourly/daily/weekly/monthly/yearly
environment crontab 中的环境变量,如 ['PATH=/bin:/usr/bin', 'MAILTO=""']
target 目标 crontab 文件

在客户端创建定时任务(用对应用户 crontab -l 验证):

node /^agent\d+\.cluster\.com/ {
        cron { "cron1":                  # 注释名称
                command => "/sbin/init 0",
                user => "root",
                minute => "01",
                hour => "21",            # 21:01 执行;未写字段默认为 *
        }
}

更多写法:

# 每 5 分钟同步一次时间
cron { 'ntp-sync':
  ensure      => present,
  command     => '/usr/sbin/ntpdate ntp1.aliyun.com > /dev/null 2>&1',
  user        => 'root',
  minute      => '*/5',
  environment => ['PATH=/sbin:/bin:/usr/sbin:/usr/bin', 'MAILTO=""'],
}

# 每天凌晨 2:30 备份
cron { 'daily-backup':
  ensure   => present,
  command  => '/usr/local/bin/backup.sh',
  user     => 'root',
  hour     => 2,
  minute   => 30,
}

# 每周一、三、五的 3 点清理日志
cron { 'clean-logs':
  ensure  => present,
  command => '/usr/local/bin/cleanlog.sh',
  user    => 'root',
  hour    => 3,
  minute  => 0,
  weekday => [1, 3, 5],
}

# 开机时执行
cron { 'on-reboot':
  ensure  => present,
  command => '/usr/local/bin/init_env.sh',
  user    => 'root',
  special => 'reboot',
}

# 删除某个定时任务
cron { 'old-job':
  ensure => absent,
}

5.7 host 资源

修改 /etc/hosts 中的记录(主机名不变,修改 IP 或别名):

node default {
        host { "agent1.cluster.com":
                ip => "10.1.1.4",
                host_aliases => "agent2haha",
        }
}

常用属性: name(主机名)、ensure(present/absent)、ip、host_aliases(别名,字符串或数组)、comment、target(默认 /etc/hosts)。

host { 'master.cluster.com':
  ensure       => present,
  ip           => '10.1.1.2',
  host_aliases => ['master', 'puppet'],
  comment      => 'managed by puppet',
}

host { 'obsolete.example.com':
  ensure => absent,
}

5.8 mount 资源

将块设备挂载到目录:

node default {
        mount { "/mnt":                        # title 即挂载点
                ensure => "mounted",           # 写入 /etc/fstab 并立即挂载
                device => "UUID=3f1de712-7864-47ea-b0b4-9fa5781b2e88",
        }
}

ensure 支持的取值:

值 含义
defined / present 只写入 /etc/fstab,不挂载
mounted 写入 /etc/fstab 并立即挂载
unmounted 保留 /etc/fstab 条目,但确保未挂载
absent 卸载并从 /etc/fstab 中删除

常用属性: name(挂载点)、device、fstype、options、dump、pass、atboot、remounts。

# 挂载本地分区
mount { '/data':
  ensure  => mounted,
  device  => 'UUID=3f1de712-7864-47ea-b0b4-9fa5781b2e88',
  fstype  => 'xfs',
  options => 'defaults,noatime',
  dump    => '0',
  pass    => '2',
  atboot  => true,
  require => File['/data'],
}

file { '/data':
  ensure => directory,
}

# 挂载 NFS
mount { '/nfsdata':
  ensure  => mounted,
  device  => '10.1.1.1:/export/data',
  fstype  => 'nfs',
  options => 'defaults,_netdev,vers=3',
}

# 卸载并移除条目
mount { '/olddata':
  ensure => absent,
}

5.9 yumrepo 资源

用原生资源管理 yum 源,比 file 下发 .repo 文件更规范(幂等且能逐项修改)。

常用属性: name(repo id)、ensure、descr(对应 name=)、baseurl、mirrorlist、enabled、gpgcheck、gpgkey、priority、exclude、includepkgs、proxy、cost、sslverify、target。

# Puppet 官方源
yumrepo { 'puppet':
  ensure   => present,
  descr    => 'Puppet Repository',
  baseurl  => 'http://yum.puppetlabs.com/el/7Server/products/x86_64/',
  enabled  => 1,
  gpgcheck => 0,
}

yumrepo { 'puppet-dependencies':
  ensure   => present,
  descr    => 'Puppet Dependencies',
  baseurl  => 'http://yum.puppetlabs.com/el/7Server/dependencies/x86_64/',
  enabled  => 1,
  gpgcheck => 0,
}

# 本地教学源
yumrepo { 'server':
  ensure   => present,
  descr    => 'local server repo',
  baseurl  => 'file:///yum',
  enabled  => 1,
  gpgcheck => 0,
}

# EPEL(带 GPG 校验)
yumrepo { 'epel':
  ensure   => present,
  descr    => 'Extra Packages for Enterprise Linux 7 - $basearch',
  baseurl  => 'http://mirrors.aliyun.com/epel/7/$basearch',
  enabled  => 1,
  gpgcheck => 1,
  gpgkey   => 'http://mirrors.aliyun.com/epel/RPM-GPG-KEY-EPEL-7',
  priority => 10,
}

# 禁用某个源
yumrepo { 'extras':
  enabled => 0,
}

# 删除某个源
yumrepo { 'obsolete-repo':
  ensure => absent,
}

5.10 其他常用资源类型速览

# augeas —— 精确编辑结构化配置文件(sshd_config、fstab、sysctl 等)
augeas { 'sshd-no-root-login':
  context => '/files/etc/ssh/sshd_config',
  changes => [
    'set PermitRootLogin no',
    'set MaxAuthTries 3',
  ],
  notify  => Service['sshd'],
}

# selboolean —— SELinux 布尔值
selboolean { 'httpd_can_network_connect':
  value      => on,
  persistent => true,
}

# notify —— 打印一条消息(调试用)
notify { 'debug-osfamily':
  message => "This node osfamily is ${::osfamily}, release ${::operatingsystemmajrelease}",
}

# tidy —— 清理旧文件(按年龄/大小)
tidy { '/var/log/myapp':
  age     => '4w',
  recurse => true,
  matches => ['*.log'],
}

# filebucket —— 备份被覆盖的文件到服务端
filebucket { 'main':
  server => 'master.cluster.com',
  path   => false,
}

# schedule —— 时间窗口
schedule { 'maintenance-window':
  range  => '2 - 4',
  period => daily,
  repeat => 1,
}

六、资源依赖与执行顺序

6.1 关键点:Puppet 默认不保证执行顺序

这是初学者最容易犯的错误。 manifest 中资源的书写顺序 不等于 执行顺序。Puppet 3.x 默认按内部哈希顺序(近似随机)应用资源;即使 4.x 默认改为 ordering = manifest(按 manifest 书写顺序),跨 class、跨模块之间的顺序依然不保证,并且依赖显式声明才可靠。

因此下面这段代码是错误的(可能先起服务再装包,导致失败):

# 错误示范:没有声明依赖
package { 'httpd': ensure => installed }
file { '/etc/httpd/conf/httpd.conf': source => 'puppet:///modules/httpd/httpd.conf' }
service { 'httpd': ensure => running }

正确做法:显式声明依赖关系,形成 包 → 配置 → 服务(Package → File → Service) 这一经典模式。

6.2 四种元参数的对应关系

元参数 写在哪个资源上 含义 等价箭头写法
require => X 后执行的资源 我依赖 X,X 先执行 X -> Me
before => X 先执行的资源 我必须在 X 之前执行 Me -> X
notify => X 先执行的资源 我变了就通知 X 刷新 Me ~> X
subscribe => X 后执行的资源 我订阅 X,X 变了我刷新 X ~> Me

互为反向的两对:require ↔ before,notify ↔ subscribe。表达同一关系用哪个都行,取决于你想把关系写在哪个资源上(写在"当事人"上更内聚)。

# 写法一:用 require(关系写在后执行的资源上)
package { 'httpd':
  ensure => installed,
}

file { '/etc/httpd/conf/httpd.conf':
  ensure  => file,
  owner   => 'root',
  group   => 'root',
  mode    => '0644',
  source  => 'puppet:///modules/httpd/httpd.conf',
  require => Package['httpd'],                 # 先装包
  notify  => Service['httpd'],                 # 配置变了就重启服务
}

service { 'httpd':
  ensure  => running,
  enable  => true,
  require => File['/etc/httpd/conf/httpd.conf'],
}
# 写法二:用 before / subscribe(等价效果)
package { 'httpd':
  ensure => installed,
  before => File['/etc/httpd/conf/httpd.conf'],
}

file { '/etc/httpd/conf/httpd.conf':
  ensure => file,
  source => 'puppet:///modules/httpd/httpd.conf',
}

service { 'httpd':
  ensure    => running,
  enable    => true,
  subscribe => File['/etc/httpd/conf/httpd.conf'],   # 订阅配置文件变化
}

6.3 箭头符号(链式关系)

符号 名称 含义
-> 顺序箭头(ordering arrow) 左边先执行,右边后执行
~> 通知箭头(notification arrow) 左边先执行,且左边有变更时通知右边刷新
<- 反向顺序箭头 右边先执行(等价于把 -> 反过来写,不推荐)
<~ 反向通知箭头 右边先执行并通知左边(不推荐)
# 最简洁的链式声明:装包 -> 配置 ~> 重启服务
Package['httpd'] -> File['/etc/httpd/conf/httpd.conf'] ~> Service['httpd']

# 也可以对整个类做链式
Class['yumrepo_setup'] -> Class['httpd'] -> Class['monitoring']

# 也可以直接把资源声明写进链
package { 'nginx': ensure => installed }
-> file { '/etc/nginx/nginx.conf': content => template('nginx/nginx.conf.erb') }
~> service { 'nginx': ensure => running, enable => true }

# 反向箭头(可读性差,官方不推荐)
Service['httpd'] <- File['/etc/httpd/conf/httpd.conf'] <- Package['httpd']

6.4 notify / subscribe 与 refresh 机制

notify / subscribe 除了表达"顺序",还额外表达"刷新(refresh)":当被通知方所监听的资源实际发生了变更(而不是每次运行)时,被通知方会执行它的 refresh 动作。

不同资源类型的 refresh 动作:

资源类型 refresh 动作
service 重启服务(hasrestart => true 用 restart,否则 stop+start)
exec 执行 command(配合 refreshonly => true 时只在被通知时执行)
mount 重新挂载(remounts => true 用 remount)

经典模式:改配置文件自动重启服务

class nginx {
  package { 'nginx':
    ensure => installed,
  }

  file { '/etc/nginx/nginx.conf':
    ensure  => file,
    content => template('nginx/nginx.conf.erb'),
    require => Package['nginx'],
    notify  => Service['nginx'],      # 内容变了才重启,没变就什么都不做
  }

  service { 'nginx':
    ensure => running,
    enable => true,
  }
}

运行效果:

  • 第 1 次运行:装包 → 下发配置 → 启动服务(并因为配置变更触发一次 restart);
  • 之后每次运行(配置未改):什么都不做,输出中没有任何变更;
  • 你在 Master 上改了模板 → Agent 下次运行时检测到配置内容不同 → 更新文件 → 自动 restart nginx。

多个资源通知同一个服务(数组形式):

file { '/etc/nginx/nginx.conf':
  content => template('nginx/nginx.conf.erb'),
  notify  => Service['nginx'],
}

file { '/etc/nginx/conf.d/vhost.conf':
  content => template('nginx/vhost.conf.erb'),
  notify  => Service['nginx'],
}

service { 'nginx':
  ensure    => running,
  enable    => true,
  subscribe => [ File['/etc/nginx/nginx.conf'], File['/etc/nginx/conf.d/vhost.conf'] ],
}

即使多个资源同时变更,Puppet 在一次运行中只会 refresh 一次(不会重启 N 次)。

用 reload 代替 restart(避免中断连接):

# 定义一个 refreshonly 的 exec 来做 reload
exec { 'nginx-reload':
  command     => '/usr/sbin/nginx -t && /bin/systemctl reload nginx',
  provider    => shell,
  refreshonly => true,
  subscribe   => File['/etc/nginx/nginx.conf'],
  require     => Service['nginx'],
}

6.5 依赖环(dependency cycle)

如果依赖关系形成闭环,Puppet 无法确定顺序,直接编译失败:

# 错误:A 依赖 B,B 又依赖 A
file { '/tmp/a':
  content => "a\n",
  require => File['/tmp/b'],
}

file { '/tmp/b':
  content => "b\n",
  require => File['/tmp/a'],
}

报错信息:

Error: Could not apply complete catalog: Found 1 dependency cycle:
(File[/tmp/a] => File[/tmp/b] => File[/tmp/a])
Try the '--graph' option and opening the resulting '.dot' file in OmniGraffle or GraphViz

定位方法:

# 生成依赖关系图
puppet agent --test --graph
ls /var/lib/puppet/state/graphs/
# cycles.dot  expanded_relationships.dot  relationships.dot  resources.dot

# 转成图片查看(需安装 graphviz)
yum install -y graphviz
dot -Tpng /var/lib/puppet/state/graphs/cycles.dot -o /tmp/cycles.png

# 4.x+ 路径
ls /opt/puppetlabs/puppet/cache/state/graphs/

常见环的来源:

  • 同时写了 require 和 before 指向对方;
  • Class 之间用 Class['a'] -> Class['b'] 而 b 内部又 require => Class['a'] 之外还反向依赖;
  • 用 anchor 模式不当(老版本 contain/anchor 问题)。

七、变量、数据类型与条件判断

7.1 变量定义与作用域

# 变量以 $ 开头,赋值用 =,一旦赋值不可修改(immutable)
$webroot   = '/var/www/html'
$http_port = 80
$enabled   = true
$packages  = ['httpd', 'httpd-devel', 'mod_ssl']
$settings  = { 'port' => 80, 'root' => '/web' }

# 引用变量
file { "${webroot}/index.html":
  content => "port is ${http_port}\n",
}

作用域(scope):

作用域 说明
顶层作用域(top scope) 在 site.pp 中直接定义的变量、以及所有 facts。用 $::varname 显式引用
节点作用域(node scope) node {} 块内定义的变量
类作用域(class scope) class {} 内定义的变量,外部用 $classname::varname 引用
# site.pp(顶层)
$puppetserver = 'master.cluster.com'
$company      = 'MyCorp'

class apache {
  $docroot = '/var/www/html'          # 类作用域

  notify { 'show':
    # $::company 显式引用顶层作用域,避免被同名局部变量遮蔽
    message => "company=${::company}, server=${::puppetserver}, docroot=${docroot}",
  }
}

class other {
  notify { 'cross-class':
    # 跨类引用:$类名::变量名
    message => "apache docroot is ${apache::docroot}",
  }
}

强烈建议:引用 fact 和顶层变量时一律加 $:: 前缀(如 $::osfamily、$::fqdn),可以避免被局部同名变量意外遮蔽,这是 Puppet 代码规范的重要一条。

Puppet 4.x+ 推荐用结构化 facts:$facts['os']['family']、$facts['networking']['ip'],并开启 strict_variables = true 让引用未定义变量直接报错。

7.2 字符串插值与引号区别

引号 是否插值 是否解释转义符 用途
'单引号' 否 只解释 \\ 和 \' 普通字面量、资源 title(推荐默认使用)
"双引号" 是(${} 或 $var) 解释 \n、\t 等 需要变量插值或换行符时
$name = 'world'

notify { 'a': message => 'hello $name' }      # 输出:hello $name(不插值)
notify { 'b': message => "hello $name" }      # 输出:hello world
notify { 'c': message => "hello ${name}" }    # 输出:hello world(推荐写法,边界清晰)
notify { 'd': message => "line1\nline2" }     # 输出两行

# 复杂表达式必须用 ${}
$ip_last = '5'
file { "/web/index.html":
  content => "web${ip_last}\n",
}

# 引用数组/哈希元素、方法调用
$arr = ['a', 'b', 'c']
$h   = { 'k1' => 'v1' }
notify { 'e': message => "first=${arr[0]}, k1=${h['k1']}, upper=${name.upcase}" }

规范建议:没有变量插值需求就用单引号;${} 优先于裸 $var。

7.3 数据类型:数组与哈希

# 数组
$packages = ['httpd', 'httpd-devel', 'mod_ssl']
$ports    = [80, 443, 8080]

# 数组可直接作为资源 title,一次声明多个资源
package { $packages:
  ensure => installed,
}

# 访问元素(下标从 0 开始,支持负数)
notify { 'arr': message => "first=${packages[0]}, last=${packages[-1]}" }

# 哈希(键值对)
$vhosts = {
  'www.a.com' => { 'port' => 80,  'root' => '/web/a' },
  'www.b.com' => { 'port' => 443, 'root' => '/web/b' },
}

notify { 'hash': message => "a.com root = ${vhosts['www.a.com']['root']}" }

# 常用内置函数(部分需 stdlib 模块)
notify { 'funcs':
  message => "size=${size($packages)} join=${join($packages, ',')} upcase=${upcase('abc')}",
}

# 4.x 迭代(each / map / filter / reduce)
$vhosts.each |$name, $cfg| {
  file { "/etc/httpd/conf.d/${name}.conf":
    ensure  => file,
    content => "ServerName ${name}\nDocumentRoot ${cfg['root']}\nListen ${cfg['port']}\n",
  }
}

$enabled_pkgs = $packages.filter |$p| { $p =~ /^httpd/ }
$upper_pkgs   = $packages.map |$p| { upcase($p) }

# 4.x 类型系统(参数化类中做校验)
# String, Integer, Boolean, Array[String], Hash[String, Any],
# Enum['present','absent'], Optional[String], Variant[String, Integer], Pattern[/^\d+$/]

7.4 Facts 变量与 facter

Facts 是 Facter 在 Agent 端采集的系统信息,编译 catalog 时作为顶层变量可直接使用。

查看所有 fact:

# 列出全部 fact(3.x 是扁平的,4.x+ 是结构化 JSON/YAML)
facter

# 查看单个 fact
facter osfamily
facter operatingsystem
facter operatingsystemrelease
facter operatingsystemmajrelease
facter ipaddress
facter fqdn
facter memorysize
facter processorcount

# 结构化输出
facter -p            # 包含 puppet 自定义 fact(pluginsync 同步来的)
facter -j            # JSON 格式
facter -y            # YAML 格式
facter os            # 4.x:整个 os 结构体
facter os.family
facter networking.ip

# 通过 puppet 命令查看(含自定义 fact)
puppet facts
puppet facts find $(hostname -f)

常用 fact 一览:

Fact 名 示例值 说明
$::osfamily RedHat / Debian / Suse 操作系统家族(做分支的首选)
$::operatingsystem CentOS / RedHat / Fedora / Ubuntu 具体发行版
$::operatingsystemrelease 7.3.1611 完整版本号
$::operatingsystemmajrelease 7 主版本号(区分 CentOS 6/7 的关键)
$::hostname agent1 短主机名
$::fqdn agent1.cluster.com 完整域名
$::domain cluster.com 域名
$::ipaddress 10.1.1.3 主 IP
$::ipaddress_eth0 10.1.1.3 指定网卡 IP
$::netmask / $::network_eth0 掩码/网段
$::macaddress MAC 地址
$::architecture x86_64 架构
$::kernel Linux 内核类型
$::kernelrelease 3.10.0-514.el7.x86_64 内核版本
$::processorcount 4 CPU 核数
$::memorysize / $::memorytotal 3.86 GB 内存
$::memorysize_mb 3952.00 内存(MB,数值可比较)
$::virtual vmware / kvm / physical / docker 虚拟化类型
$::is_virtual true / false 是否虚拟机
$::selinux true / false SELinux 是否启用
$::uptime_days 12 运行天数
$::timezone CST 时区
$::path Agent 的 PATH
$::puppetversion 3.6.2 Puppet 版本
$::clientcert agent1.cluster.com Agent 的证书名(= node 名)
$::environment production 当前环境
$::blockdevices / $::partitions 块设备/分区信息

内置变量(非 fact,由 Puppet 提供):

变量 说明
$::clientcert Agent 证书名
$::clientversion Agent 的 Puppet 版本
$::environment 当前环境名
$::servername / $::serverip / $::serverversion Master 的主机名/IP/版本
$module_name 当前所在模块名
$caller_module_name 调用方模块名
$title / $name 在 define 定义类型中,代表实例的标题

自定义 fact:

方式一:模块中的 Ruby 自定义 fact(放在 模块/lib/facter/xxx.rb,靠 pluginsync 同步到 Agent):

# /etc/puppet/modules/myapp/lib/facter/app_version.rb
Facter.add(:app_version) do
  setcode do
    if File.exist?('/opt/myapp/VERSION')
      File.read('/opt/myapp/VERSION').strip
    else
      'none'
    end
  end
end

# 带条件限制的 fact
Facter.add(:server_role) do
  confine :osfamily => 'RedHat'
  setcode do
    Facter::Core::Execution.exec('cat /etc/server_role 2>/dev/null') || 'unknown'
  end
end
# manifest 中直接使用
notify { 'ver': message => "app version = ${::app_version}" }

方式二:外部 fact(更简单,无需写 Ruby),放在 Agent 的 /etc/facter/facts.d/ 或 /opt/puppetlabs/facter/facts.d/:

# /etc/facter/facts.d/custom.txt
server_role=web
datacenter=bj-idc1
app_env=production
# /etc/facter/facts.d/custom.yaml
---
server_role: web
datacenter: bj-idc1
tags:
  - web
  - frontend
#!/bin/bash
# /etc/facter/facts.d/disk_info.sh  (必须可执行,输出 key=value)
echo "root_disk_free=$(df -h / | tail -1 | awk '{print $4}')"

方式三:环境变量(临时):

FACTER_server_role=web puppet agent --test

7.5 条件判断

7.5.1 if / elsif / else

if $::osfamily == 'RedHat' {
  $apache_pkg = 'httpd'
  $apache_svc = 'httpd'
  $apache_dir = '/etc/httpd/conf'
} elsif $::osfamily == 'Debian' {
  $apache_pkg = 'apache2'
  $apache_svc = 'apache2'
  $apache_dir = '/etc/apache2'
} else {
  fail("Unsupported osfamily: ${::osfamily}")
}

# 数值比较
if $::memorysize_mb > 4096 {
  $worker_processes = 4
} else {
  $worker_processes = 2
}

# in 操作符(判断是否在数组/字符串/哈希键中)
if $::operatingsystem in ['RedHat', 'CentOS', 'Fedora'] {
  notify { 'redhat-like': message => 'This is a RedHat-like system' }
}

# 布尔与逻辑运算
if ($::osfamily == 'RedHat') and ($::operatingsystemmajrelease == '7') {
  notify { 'el7': message => 'CentOS/RHEL 7' }
}

if !$::is_virtual {
  notify { 'phys': message => 'Physical machine' }
}

7.5.2 unless(相当于 if not)

unless $::osfamily == 'RedHat' {
  fail("This module only supports RedHat family, got ${::osfamily}")
}

unless defined(Package['httpd']) {
  package { 'httpd': ensure => installed }
}

7.5.3 case

case $::osfamily {
  'RedHat': {
    $pkg     = 'httpd'
    $conf    = '/etc/httpd/conf/httpd.conf'
    $confdir = '/etc/httpd/conf.d'
  }
  'Debian': {
    $pkg     = 'apache2'
    $conf    = '/etc/apache2/apache2.conf'
    $confdir = '/etc/apache2/conf-enabled'
  }
  /^(Suse|SLES)$/: {         # case 支持正则
    $pkg     = 'apache2'
    $conf    = '/etc/apache2/httpd.conf'
    $confdir = '/etc/apache2/conf.d'
  }
  default: {
    fail("Unsupported osfamily: ${::osfamily}")
  }
}

# 多值匹配(逗号分隔)
case $::operatingsystem {
  'RedHat', 'CentOS', 'OracleLinux', 'Scientific': {
    $repo_type = 'yum'
  }
  'Ubuntu', 'Debian': {
    $repo_type = 'apt'
  }
  default: { $repo_type = 'unknown' }
}

7.5.4 selector 选择器(? {})

selector 是一个表达式,用来给变量赋值或直接作为属性值,不能包含资源声明。

# 给变量赋值
$apache_pkg = $::osfamily ? {
  'RedHat' => 'httpd',
  'Debian' => 'apache2',
  default  => 'httpd',
}

# 直接作为属性值
service { 'apache':
  name   => $::osfamily ? {
    'RedHat' => 'httpd',
    'Debian' => 'apache2',
  },
  ensure => running,
  enable => true,
}

# 正则匹配
$env_short = $::fqdn ? {
  /^prod/  => 'production',
  /^test/  => 'testing',
  /^dev/   => 'development',
  default  => 'unknown',
}

7.5.5 比较与正则匹配运算符

运算符 说明
== / != 相等 / 不等(字符串比较不区分大小写)
< > <= >= 数值/版本比较
=~ 匹配正则(成功返回 true,并把捕获组放入 $1、$2)
!~ 不匹配正则
in 包含(数组元素 / 字符串子串 / 哈希键)
and / or / ! 逻辑与 / 或 / 非
+ - * / % 算术运算
<< / >> 位移
if $::fqdn =~ /^web(\d+)\.cluster\.com$/ {
  $web_index = $1                      # 捕获组
  notify { 'idx': message => "This is web node number ${web_index}" }
}

if $::kernelrelease !~ /^3\.10/ {
  notify { 'kernel-warn': message => 'Unexpected kernel version' }
}

if 'httpd' in $packages { notify { 'has-httpd': message => 'httpd in list' } }

7.5.6 按 osfamily / operatingsystemmajrelease 做版本分支(实用写法)

这是处理 CentOS 6 与 CentOS 7 差异的标准做法:

class baseline {
  # 服务管理方式差异
  case $::operatingsystemmajrelease {
    '6': {
      # CentOS 6:SysV init + iptables + ntpd
      $service_provider = 'redhat'
      $firewall_pkg     = 'iptables'
      $firewall_svc     = 'iptables'
      $time_pkg         = 'ntp'
      $time_svc         = 'ntpd'
      $network_svc      = 'network'
      $syslog_conf      = '/etc/rsyslog.conf'
    }
    '7', '8': {
      # CentOS 7/8:systemd + firewalld + chrony
      $service_provider = 'systemd'
      $firewall_pkg     = 'firewalld'
      $firewall_svc     = 'firewalld'
      $time_pkg         = 'chrony'
      $time_svc         = 'chronyd'
      $network_svc      = 'network'
      $syslog_conf      = '/etc/rsyslog.conf'
    }
    default: {
      fail("Unsupported EL release: ${::operatingsystemmajrelease}")
    }
  }

  package { $time_pkg:
    ensure => installed,
  }

  service { $time_svc:
    ensure   => running,
    enable   => true,
    provider => $service_provider,
    require  => Package[$time_pkg],
  }
}

八、Class 类与模块

8.1 class 的定义与声明

定义 class(只是定义,不会执行):

class sudo {
  package { 'sudo':
    ensure => present,
  }
  file { '/etc/sudoers':
    ensure => file,
    mode   => '0440',
    owner  => 'root',
    group  => 'root',
    source => 'puppet:///modules/sudo/etc/sudoers',
    require => Package['sudo'],
  }
}

声明(调用)class 的两种方式:

# 方式一:include(幂等,可重复 include 同一个类,只生效一次)
node 'agent1.cluster.com' {
  include sudo
  include httpd
  include ntp
}

# include 也可一次包含多个
include sudo, httpd, ntp
include ['sudo', 'httpd', 'ntp']

# 方式二:resource-like 声明(可传参数,但同一个类只能声明一次,重复会报 Duplicate declaration)
node 'agent1.cluster.com' {
  class { 'httpd':
    port    => 8080,
    docroot => '/web',
  }
}

# require 声明(include + 建立依赖)
node 'agent1.cluster.com' {
  require yumrepo_setup      # 保证 yumrepo_setup 先执行
  include httpd
}

# contain(把类包含进当前类的容器,保证依赖关系正确传递)
class webstack {
  contain nginx
  contain php
  Class['php'] -> Class['nginx']
}
声明方式 能否传参 能否重复 说明
include x 否(参数从 Hiera/默认值取) 能(幂等) 推荐,尤其配合 Hiera 自动参数查找
class { 'x': p => v } 能 不能 只能声明一次,多处引用易冲突
require x 否 能 include + 依赖
contain x 否 能 保证类的资源被"包含"在父类的依赖边界内

8.2 参数化类与默认值

# 定义带参数的类(参数有默认值,可被覆盖)
class httpd (
  $package_name = 'httpd',
  $service_name = 'httpd',
  $port         = 80,
  $docroot      = '/var/www/html',
  $server_admin = 'root@localhost',
  $ensure       = 'running',
  $enable       = true,
) {
  package { $package_name:
    ensure => installed,
  }

  file { '/etc/httpd/conf/httpd.conf':
    ensure  => file,
    owner   => 'root',
    group   => 'root',
    mode    => '0644',
    content => template('httpd/httpd.conf.erb'),   # 模板中可用上面的参数
    require => Package[$package_name],
    notify  => Service[$service_name],
  }

  file { $docroot:
    ensure => directory,
    owner  => 'apache',
    group  => 'apache',
  }

  service { $service_name:
    ensure => $ensure,
    enable => $enable,
  }
}

Puppet 4.x+ 带数据类型校验的参数化类(推荐,能提前发现错误):

class httpd (
  String                    $package_name = 'httpd',
  String                    $service_name = 'httpd',
  Integer[1, 65535]         $port         = 80,
  Stdlib::Absolutepath      $docroot      = '/var/www/html',
  Enum['running','stopped'] $ensure       = 'running',
  Boolean                   $enable       = true,
  Optional[String]          $server_admin = undef,
  Array[String]             $extra_pkgs   = [],
  Hash                      $vhosts       = {},
) {
  # ...
}

调用并传参:

# 用 resource-like 声明传参
class { 'httpd':
  port    => 8080,
  docroot => '/web',
}

# 或者用 include + Hiera 自动参数查找(推荐)
# 在 Hiera 数据文件中写:
#   httpd::port: 8080
#   httpd::docroot: /web
include httpd

# 也可用 class 默认参数(resource default)
Class['httpd'] { port => 8080 }     # 不推荐

8.3 类继承(inherits)与为什么不推荐

class base {
  package { 'wget': ensure => installed }
  file { '/etc/motd':
    ensure  => file,
    content => "Base MOTD\n",
  }
}

# 子类继承父类,可用 +> 追加、用 资源引用 {} 覆盖父类属性
class base::web inherits base {
  # 覆盖父类的 File['/etc/motd'] 的 content
  File['/etc/motd'] {
    content => "Web Server MOTD\n",
  }
  package { 'httpd': ensure => installed }
}

为什么不推荐使用 inherits:

  1. 只有继承能覆盖属性这个特性容易被滥用,导致资源的最终属性值散落在多个类中,难以追踪;
  2. 继承链一长,谁覆盖了谁完全看不清,出问题极难排查;
  3. 继承会自动 include 父类,隐式引入大量资源,容易和别处的声明冲突(Duplicate declaration);
  4. Puppet 官方风格指南明确建议:用参数化类 + Hiera 数据分离来代替继承;
  5. 唯一被官方认可的用途是"xxx::params 模式"(把平台相关的变量集中在一个 params 类里,然后 inherits xxx::params),但在 Puppet 4.x+ 中这也已被 Hiera 数据 in module 取代。

替代方案(推荐):

# 推荐:参数化 + 组合,不用继承
class motd (
  String $content = "Default MOTD\n",
) {
  file { '/etc/motd':
    ensure  => file,
    content => $content,
  }
}

class profile::webserver {
  class { 'motd':
    content => "Web Server MOTD\n",
  }
  include httpd
}

8.4 模块的标准目录结构

模块是 Puppet 组织可复用配置的方式。一个模块典型目录结构如下:

/etc/puppet/modules/<module_name>/       # Puppet 3.x
/etc/puppetlabs/code/environments/production/modules/<module_name>/   # Puppet 4.x+
├── manifests/                # 【必需】所有 .pp 清单文件
│   ├── init.pp               #   模块入口,定义与模块同名的 class
│   ├── install.pp            #   子类 <module>::install
│   ├── config.pp             #   子类 <module>::config
│   ├── service.pp            #   子类 <module>::service
│   ├── params.pp             #   子类 <module>::params(平台变量)
│   └── vhost.pp              #   定义类型 <module>::vhost
├── files/                    # 静态文件,通过 puppet:///modules/<module>/<file> 分发
│   └── etc/
│       └── sudoers
├── templates/                # ERB / EPP 模板,通过 template('<module>/x.erb') 引用
│   └── httpd.conf.erb
├── lib/                      # Ruby 扩展(靠 pluginsync 同步到 Agent)
│   ├── facter/               #   自定义 fact
│   │   └── app_version.rb
│   └── puppet/
│       ├── parser/functions/ #   自定义函数(3.x API)
│       ├── functions/        #   自定义函数(4.x API)
│       ├── type/             #   自定义资源类型
│       └── provider/         #   自定义 provider
├── examples/                 # 使用示例,可用 puppet apply 直接测试
│   └── init.pp
├── tests/                    # 旧版的示例目录(等同 examples)
├── spec/                     # rspec-puppet 单元测试
├── facts.d/                  # 外部 fact(静态)
├── data/                     # 模块内 Hiera 数据(Hiera 5)
│   └── common.yaml
├── hiera.yaml                # 模块内 Hiera 层级配置(Hiera 5)
├── metadata.json             # 【重要】模块元数据:名称、版本、依赖、支持的 OS
├── README.md                 # 文档
├── CHANGELOG.md
└── LICENSE

简化版(教学环境最常用的三个目录):

/etc/puppet/modules/sudo/
├── files/
├── templates/
└── manifests/
    └── init.pp

各目录/文件的作用:

路径 作用
manifests/ 存放 .pp 清单,模块的逻辑代码都在这里
manifests/init.pp 模块入口,必须定义与模块同名的 class
files/ 静态文件仓库,用 source => 'puppet:///modules/<模块名>/<相对路径>' 分发
templates/ ERB/EPP 模板,用 template('<模块名>/文件名.erb') 或 epp() 渲染
lib/ Ruby 代码:自定义 fact、函数、资源类型、provider;Agent 端靠 pluginsync = true 自动同步
examples/ 示例 manifest,用于 puppet apply --noop examples/init.pp 快速测试
metadata.json 模块元数据,puppet module 命令与 Forge 依赖此文件
data/ + hiera.yaml 模块内置默认数据(Hiera 5 的 "data in modules"),替代旧的 params.pp
spec/ rspec-puppet 单元测试

metadata.json 示例:

{
  "name": "mycorp-httpd",
  "version": "1.2.0",
  "author": "MyCorp Ops",
  "summary": "Manage Apache httpd",
  "license": "Apache-2.0",
  "source": "https://git.mycorp.com/puppet/httpd",
  "project_page": "https://git.mycorp.com/puppet/httpd",
  "issues_url": "https://git.mycorp.com/puppet/httpd/issues",
  "dependencies": [
    { "name": "puppetlabs/stdlib", "version_requirement": ">= 4.13.1 < 9.0.0" },
    { "name": "puppetlabs/concat", "version_requirement": ">= 2.0.0 < 8.0.0" }
  ],
  "operatingsystem_support": [
    { "operatingsystem": "CentOS", "operatingsystemrelease": ["6", "7", "8"] },
    { "operatingsystem": "RedHat", "operatingsystemrelease": ["6", "7", "8"] }
  ],
  "requirements": [
    { "name": "puppet", "version_requirement": ">= 4.10.0 < 8.0.0" }
  ]
}

8.5 模块的自动加载规则与命名约定

Puppet 会根据类名自动推导它所在的文件路径,因此命名必须严格遵守约定,否则会报 Could not find class。

类名 对应文件路径(相对模块根)
httpd httpd/manifests/init.pp
httpd::install httpd/manifests/install.pp
httpd::config httpd/manifests/config.pp
httpd::params httpd/manifests/params.pp
httpd::vhost::ssl httpd/manifests/vhost/ssl.pp
httpd::vhost(定义类型) httpd/manifests/vhost.pp

规则总结:

  • 类名用 :: 分隔命名空间,第一段必须等于模块名;
  • :: 对应目录层级,最后一段对应文件名(去掉 .pp);
  • 与模块同名的类放在 init.pp 中;
  • 模块名和类名只能包含小写字母、数字、下划线,不能有横线(my-module 非法,应写 my_module);
  • 一个 .pp 文件中应只定义一个类(或一个定义类型);
  • 模块目录必须位于 modulepath 之内,否则找不到。
# 查看当前生效的模块路径
puppet config print modulepath
# 列出已安装模块及其依赖状态
puppet module list
puppet module list --tree

推荐的模块内部分层写法(install / config / service 三段式):

# httpd/manifests/init.pp
class httpd (
  $port    = 80,
  $docroot = '/var/www/html',
) {
  # 用 contain 保证依赖边界正确
  contain httpd::install
  contain httpd::config
  contain httpd::service

  Class['httpd::install']
  -> Class['httpd::config']
  ~> Class['httpd::service']
}
# httpd/manifests/install.pp
class httpd::install {
  package { ['httpd', 'httpd-devel']:
    ensure => installed,
  }
}
# httpd/manifests/config.pp
class httpd::config {
  file { '/etc/httpd/conf/httpd.conf':
    ensure  => file,
    owner   => 'root',
    group   => 'root',
    mode    => '0644',
    content => template('httpd/httpd.conf.erb'),
  }
}
# httpd/manifests/service.pp
class httpd::service {
  service { 'httpd':
    ensure => running,
    enable => true,
  }
}

8.6 定义类型 define 与 class 的区别

class 在一个节点上只能存在一份(单例,singleton);define 定义的类型可以在同一节点上实例化多次(像自定义资源类型一样使用)。

# 定义类型:httpd/manifests/vhost.pp  →  类型名 httpd::vhost
define httpd::vhost (
  $port       = 80,
  $docroot    = "/var/www/${name}",     # $name 就是实例的 title
  $server_name = $title,                # $title 同 $name
  $ssl        = false,
) {
  file { $docroot:
    ensure => directory,
    owner  => 'apache',
    group  => 'apache',
  }

  file { "/etc/httpd/conf.d/${name}.conf":
    ensure  => file,
    content => template('httpd/vhost.conf.erb'),
    notify  => Service['httpd'],
  }

  file { "${docroot}/index.html":
    ensure  => file,
    content => "<h1>${server_name}</h1>\n",
    require => File[$docroot],
  }
}
# 使用:可以实例化任意多次
node 'web1.cluster.com' {
  include httpd

  httpd::vhost { 'www.a.com':
    port    => 80,
    docroot => '/web/a',
  }

  httpd::vhost { 'www.b.com':
    port    => 8080,
    docroot => '/web/b',
  }

  httpd::vhost { 'www.c.com':
    port    => 443,
    ssl     => true,
  }
}

对比表:

项目 class define(定义类型)
关键字 class define
一个节点上可存在几份 1 份(单例) 任意多份
声明方式 include x / class { 'x': } x { 'title': }(像资源一样)
内置变量 无 $title/$name 有 $title 和 $name(= 实例标题)
可否被 include 可 不可
可否用 Hiera 自动参数查找 可 不可(需显式传参或在类中查)
适用场景 一台机器上"唯一"的东西:安装某软件、全局配置、启动服务 一台机器上"多份"的东西:多个虚拟主机、多个用户、多个 Nginx server 块、多个日志轮转规则

何时用哪个(判断标准):

  • 问自己:"这台机器上会有几个这样的东西?"
    • 只会有一个(httpd 装一次、sshd 配一份)→ class;
    • 会有多个(3 个 vhost、5 个数据库、10 个用户)→ define。
  • 定义类型内部的资源标题必须包含 $name 或 $title,否则实例化第二次时资源标题重复,报 Duplicate declaration。
# 错误:资源标题不含 $name,实例化两次就冲突
define bad_example ($content = 'x') {
  file { '/tmp/fixed.txt':          # ← 固定标题,第二个实例必定冲突
    content => $content,
  }
}

# 正确
define good_example ($content = 'x') {
  file { "/tmp/${name}.txt":        # ← 标题含 $name
    content => $content,
  }
}

good_example { 'a': content => "A\n" }
good_example { 'b': content => "B\n" }

8.7 模块复用与 Puppet Forge

Puppet Forge(https://forge.puppet.com)是官方模块仓库,大量常用软件已有成熟模块,不必自己重复写。

# 搜索模块
puppet module search apache
puppet module search ntp

# 安装模块(默认装到 modulepath 的第一个目录)
puppet module install puppetlabs-stdlib
puppet module install puppetlabs-apache
puppet module install puppetlabs-ntp --version 8.5.0
puppet module install puppetlabs-mysql

# 指定安装目录 / 指定环境
puppet module install puppetlabs-apache --modulepath /etc/puppet/modules
puppet module install puppetlabs-apache --environment production

# 离线安装(内网环境:先下载 tar.gz,再本地安装)
puppet module install /tmp/puppetlabs-stdlib-9.0.0.tar.gz --ignore-dependencies

# 列出、升级、卸载
puppet module list
puppet module list --tree
puppet module upgrade puppetlabs-apache
puppet module uninstall puppetlabs-apache

# 生成一个新模块的骨架(自动创建标准目录结构与 metadata.json)
puppet module generate mycorp-myapp

必装基础模块:puppetlabs-stdlib(Puppet 标准库),提供大量实用函数,几乎所有 Forge 模块都依赖它:

常用 stdlib 函数 作用
validate_string / validate_bool / validate_array / validate_hash 参数类型校验(3.x 时代)
validate_absolute_path 校验是否绝对路径
str2bool / bool2str / str2saltedsha512 类型转换
join / split / flatten / unique / sort 数组操作
merge / keys / values / has_key 哈希操作
file_line 修改文件中的单行(避免用 exec + sed)
pick / pick_default 取第一个非空值
dirname / basename 路径处理
getvar 动态取变量
ensure_packages / ensure_resource 幂等地确保资源存在(避免 Duplicate declaration)
# file_line:只改一行,不覆盖整个文件(非常实用)
include stdlib

file_line { 'sshd-disable-root':
  path   => '/etc/ssh/sshd_config',
  line   => 'PermitRootLogin no',
  match  => '^#?PermitRootLogin',
  notify => Service['sshd'],
}

file_line { 'set-max-open-files':
  path => '/etc/security/limits.conf',
  line => '* - nofile 65535',
}

# ensure_resource:多个模块可能都想声明同一资源时,避免重复声明报错
ensure_resource('package', 'wget', { 'ensure' => 'installed' })
ensure_packages(['vim', 'wget', 'curl'], { 'ensure' => 'installed' })

8.8 site.pp 中的 node 定义与节点匹配规则

Puppet 通过 node 关键字为不同节点定义不同配置,支持精确节点名、default 以及正则表达式(*当前版本不支持通配符 ``**)。

节点名的来源:Agent 的 certname(默认是 FQDN),可用 facter fqdn 或 puppet config print certname 查看。

8.8.1 精确匹配

为不同节点写不同内容:

node 'agent1.cluster.com' {     # 只针对 agent1,节点名用单引号
        file { "/tmp/test.txt":
                content => "hahahaha\n",
        }
}
node 'agent2.cluster.com' {     # 只针对 agent2
        file { "/tmp/test.txt":
                content => "hehehehe\n",
        }
}

为不同节点删除文件:

node 'agent1.cluster.com' {
        file { "/tmp/test.txt":
                ensure => absent,   # 保证文件不存在,存在则删除;删目录需加 force => yes
        }
}
node 'agent2.cluster.com' {
        file { "/tmp/test.txt":
                ensure => absent,
        }
}

一个 node 块可匹配多个节点名(逗号分隔):

node 'web1.cluster.com', 'web2.cluster.com', 'web3.cluster.com' {
  include httpd
}

8.8.2 正则匹配一批节点

# 匹配 agentX.cluster.com,X 为任意数字
# 也可写为 /^agent/ 或 /cluster\.com$/
node /^agent\d+\.cluster\.com/ {
        file { "/tmp/fstab":
                ensure => link,          # 创建软链接
                target => "/etc/fstab",  # 源文件
        }
}

# 匹配所有 .cluster.com 结尾的节点
node /\.cluster\.com$/ {
  include sudo
}

# 匹配 web 开头的节点
node /^web/ {
  include httpd
}

# 匹配 db 开头且在 bj 机房
node /^db\d+\.bj\.cluster\.com$/ {
  include mysql
}

8.8.3 default 节点

node default {                  # default 节点代表所有"未被其他 node 块匹配到"的节点
  file { '/etc/motd':
    ensure  => file,
    content => "Managed by Puppet\n",
  }
}

8.8.4 节点匹配规则(重要)

Puppet 的节点匹配是排他的:只有一个 node 块会生效,按以下优先级选择:

  1. 精确节点名匹配(最高优先级);
  2. 若无精确匹配,尝试正则匹配(多个正则都匹配时,行为不确定,应避免重叠);
  3. 若都不匹配,使用 default 节点;
  4. 若连 default 都没有 → 报错 Could not find default node or by name with 'xxx'。

关键陷阱:如果某节点同时符合 'agent1.cluster.com' 和 /\.cluster\.com$/,只会执行精确匹配的那个块,正则块里的 include sudo 不会生效。这是"Agent 运行没有任何变更"的常见原因。

解决方案:用节点继承(不推荐)或把公共配置抽成基础类:

# 推荐:把公共配置抽成 base 类,每个 node 块都 include 它
class base {
  include sudo
  include ntp
  include motd
}

node 'agent1.cluster.com' {
  include base
  include httpd
}

node 'agent2.cluster.com' {
  include base
  include mysql
}

node default {
  include base
}
# 或用 node 继承(Puppet 4 中已废弃,不推荐)
node basenode {
  include sudo
  include ntp
}

node 'agent1.cluster.com' inherits basenode {
  include httpd
}

8.8.5 3.x 的 import 与 4.x 的自动加载

Puppet 3.x:可以把节点定义拆到单独文件,用 import 引入。

# /etc/puppet/manifests/site.pp
import 'nodes.pp'
import 'nodes/*.pp'

$puppetserver = "master.cluster.com"
# /etc/puppet/manifests/nodes.pp
node /\.cluster\.com$/ {
  include sudo
}

注意:import 在 Puppet 3.x 中已被标记废弃,在 Puppet 4.x 中被彻底移除。

Puppet 4.x+:把所有 .pp 文件直接放在环境的 manifests/ 目录下即可,Puppet 会按字母顺序自动加载全部文件,无需 import。

/etc/puppetlabs/code/environments/production/manifests/
├── site.pp          # 通常放全局配置和 default 节点
├── nodes_web.pp     # web 节点定义
├── nodes_db.pp      # db 节点定义
└── nodes_misc.pp

8.8.6 ENC(External Node Classifier,外部节点分类器)

节点数量大或分组逻辑复杂(来自 CMDB、Foreman、数据库)时,用 node {} 硬编码不现实。ENC 是一个由 Master 调用的外部脚本,Puppet 把节点名作为参数传给它,脚本返回 YAML 描述该节点应该应用哪些类、参数与环境。

# puppet.conf 的 [master] / [server] 段
[master]
    node_terminus  = exec
    external_nodes = /usr/local/bin/enc.rb
#!/bin/bash
# /usr/local/bin/enc.sh  —— 最简 ENC 示例,接收节点名,输出 YAML
CERTNAME=$1

case "$CERTNAME" in
  web*)
    cat <<EOF
---
classes:
  base:
  httpd:
    port: 80
    docroot: /web
parameters:
  role: webserver
  datacenter: bj-idc1
environment: production
EOF
    ;;
  db*)
    cat <<EOF
---
classes:
  base:
  mysql:
parameters:
  role: database
environment: production
EOF
    ;;
  *)
    cat <<EOF
---
classes:
  base:
environment: production
EOF
    ;;
esac
exit 0

要点:

  • ENC 脚本必须可执行、以 puppet 用户能运行、退出码 0;
  • 输出的 YAML 中 classes 下的类会被自动 include,parameters 会成为该节点的顶层变量;
  • ENC 与 node {} 定义可以共存(默认 ENC 优先,node 块中额外的类会合并,具体行为取决于版本);
  • 常见的 ENC 实现:Foreman、Puppet Enterprise Console、自研脚本查 CMDB/数据库。

九、ERB 模板

9.1 模板的用途

content => "..." 只能写死内容,source => 只能分发完全一样的文件。但现实中每台机器的配置文件往往大部分相同、少数几行不同(监听 IP、worker 数、主机名、内存相关参数)。模板就是解决这个问题的:一份带占位符的文件,渲染时把变量替换成该节点的实际值。

  • ERB(Embedded Ruby):Puppet 传统模板语言,扩展名 .erb,在 Master 端渲染;
  • EPP(Embedded Puppet):Puppet 4.x+ 引入的新模板语言,扩展名 .epp,用 Puppet 语法而非 Ruby。

模板文件放在模块的 templates/ 目录下,用 template() / epp() 函数引用。

9.2 ERB 标签语法

标签 名称 作用
<%= 表达式 %> 输出标签 求值并输出结果到文件
<% 语句 %> 执行标签 只执行 Ruby 代码,不输出(用于 if / each / 赋值)
<%# 注释 %> 注释标签 模板注释,不输出到生成的文件
<%- 语句 %> 去左空白 执行代码并去掉标签前的空白
<% 语句 -%> 去右换行 执行代码并去掉标签后的换行(避免生成空行)
<%% / %%> 转义 输出字面的 <% / %>

9.3 在模板中引用变量与 Facts

<%# 三种引用方式,本质是 Puppet 把变量注入为 Ruby 实例变量 %>

<%# 方式一:@变量名 —— 最常用、最推荐 %>
ServerName <%= @fqdn %>
Listen <%= @port %>
DocumentRoot <%= @docroot %>
ServerAdmin <%= @server_admin %>

<%# 方式二:scope 方法 —— 可访问其他作用域的变量,或变量名带命名空间时 %>
Server IP: <%= scope['::ipaddress'] %>
Top var:   <%= scope['::puppetserver'] %>
Class var: <%= scope['httpd::docroot'] %>
<%# 旧写法(3.x) %>
Old style: <%= scope.lookupvar('::ipaddress') %>

<%# 方式三:判断变量是否定义 %>
<% if scope['::app_version'] %>
AppVersion <%= scope['::app_version'] %>
<% end %>

要点:

  • @变量名 只能访问当前作用域(当前类的参数与局部变量)和顶层 facts;
  • 变量名带 :: 或需要跨作用域取值时,必须用 scope['...'];
  • @variable 未定义时值为 nil(不会报错),可用 if 判断。

9.4 模板中的条件与循环

<%# ===== 条件判断 ===== %>
<% if @osfamily == 'RedHat' -%>
User apache
Group apache
<% else -%>
User www-data
Group www-data
<% end -%>

<%# 多分支 %>
<% case @operatingsystemmajrelease
   when '6' -%>
# CentOS 6 configuration
PidFile /var/run/httpd/httpd.pid
<% when '7' -%>
# CentOS 7 configuration
PidFile /run/httpd/httpd.pid
<% else -%>
# Unknown release
<% end -%>

<%# ===== 数值计算 ===== %>
<%# 按 CPU 核数设置 worker 进程 %>
worker_processes <%= @processorcount %>;
<%# 内存的一半作为缓存(Ruby 表达式) %>
cache_size <%= (@memorysize_mb.to_f / 2).round %>M;
max_clients <%= @processorcount.to_i * 256 %>

<%# ===== 遍历数组 ===== %>
<% @allowed_ips.each do |ip| -%>
    allow <%= ip %>;
<% end -%>

<%# 带索引 %>
<% @upstreams.each_with_index do |server, i| -%>
    server <%= server %> weight=<%= 10 - i %>;
<% end -%>

<%# ===== 遍历哈希 ===== %>
<% @vhosts.each do |name, cfg| -%>
<VirtualHost *:<%= cfg['port'] %>>
    ServerName <%= name %>
    DocumentRoot <%= cfg['root'] %>
</VirtualHost>
<% end -%>

<%# 排序遍历(保证输出稳定,避免每次运行文件内容顺序不同导致误判变更) %>
<% @settings.sort.each do |k, v| -%>
<%= k %> = <%= v %>
<% end -%>

<%# ===== 数组 join ===== %>
Listen <%= @ports.join(' ') %>

重要细节:模板中遍历哈希时一定要排序(.sort.each),否则不同次渲染出的行顺序可能不同,Puppet 会认为文件"变了",每次运行都重写文件并触发服务重启,破坏幂等性。

9.5 template 函数

# 单个模板
file { '/etc/httpd/conf/httpd.conf':
  ensure  => file,
  owner   => 'root',
  group   => 'root',
  mode    => '0644',
  content => template('httpd/httpd.conf.erb'),   # 对应 httpd/templates/httpd.conf.erb
  notify  => Service['httpd'],
}

# 多个模板拼接(按顺序连接成一个文件)
file { '/etc/nginx/nginx.conf':
  content => template('nginx/header.erb', 'nginx/http.erb', 'nginx/footer.erb'),
}

# inline_template:直接在 manifest 中写一小段 ERB
$motd_text = inline_template("Host: <%= @fqdn %> IP: <%= @ipaddress %>\n")
file { '/etc/motd':
  content => $motd_text,
}

template() 的路径规则:template('<模块名>/<templates 目录下的相对路径>')

写法 实际文件(Puppet 3.x)
template('httpd/httpd.conf.erb') /etc/puppet/modules/httpd/templates/httpd.conf.erb
template('httpd/vhost/ssl.conf.erb') /etc/puppet/modules/httpd/templates/vhost/ssl.conf.erb

9.6 EPP 模板(Puppet 4.x+)与 ERB 的差异

EPP 用 Puppet 语言而不是 Ruby,语法更一致,还支持显式声明参数。

<%- | String $servername,
      Integer $port = 80,
      Array[String] $allowed_ips = [],
| -%>
<%# 上面是 EPP 的参数声明块,可做类型校验 %>
ServerName <%= $servername %>
Listen <%= $port %>

<% $allowed_ips.each |$ip| { -%>
    Allow from <%= $ip %>
<% } -%>

<% if $facts['os']['family'] == 'RedHat' { -%>
User apache
<% } -%>
file { '/etc/httpd/conf.d/vhost.conf':
  content => epp('httpd/vhost.conf.epp', {
    'servername'  => 'www.example.com',
    'port'        => 8080,
    'allowed_ips' => ['10.1.1.0/24', '192.168.0.0/16'],
  }),
}

# inline_epp
$text = inline_epp('Host is <%= $facts["fqdn"] %>')

ERB 与 EPP 对比:

项目 ERB EPP
语言 Ruby Puppet
扩展名 .erb .epp
变量引用 <%= @var %> / scope['x'] <%= $var %> / $facts['x']
循环 <% arr.each do \|x\| %> ... <% end %> <% $arr.each \|$x\| { %> ... <% } %>
显式参数 不支持 支持(带类型校验)
可用能力 完整 Ruby(强大但危险) 限制在 Puppet 语言内(更安全)
兼容性 所有版本 Puppet 4.0+
调用函数 template() / inline_template() epp() / inline_epp()
建议 存量代码维护 新代码首选

9.7 完整示例:用模板生成 httpd 配置

模块结构:

/etc/puppet/modules/httpd/
├── manifests/
│   └── init.pp
└── templates/
    └── httpd.conf.erb

httpd/templates/httpd.conf.erb:

<%# 由 Puppet 管理,请勿手工修改 —— 本行为 ERB 注释,不会出现在生成的文件中 %>
#
# WARNING: This file is managed by Puppet.
# Any manual changes will be overwritten on the next Puppet run.
# Generated for: <%= @fqdn %> (<%= @ipaddress %>) at template render time
#

ServerRoot "/etc/httpd"
Listen <%= @port %>
<% if @ssl_enabled -%>
Listen <%= @ssl_port %>
<% end -%>

<%# CentOS 6 与 7 的 PidFile 路径不同 %>
<% if @operatingsystemmajrelease == '6' -%>
PidFile /var/run/httpd/httpd.pid
<% else -%>
PidFile /run/httpd/httpd.pid
<% end -%>

User <%= @httpd_user %>
Group <%= @httpd_group %>

ServerAdmin <%= @server_admin %>
ServerName <%= @fqdn %>:<%= @port %>

<%# 按 CPU 核数与内存动态计算性能参数 %>
StartServers        <%= @processorcount %>
MinSpareServers     <%= @processorcount %>
MaxSpareServers     <%= @processorcount.to_i * 2 %>
MaxRequestWorkers   <%= @processorcount.to_i * 64 %>
MaxConnectionsPerChild 10000

DocumentRoot "<%= @docroot %>"

<Directory "<%= @docroot %>">
    AllowOverride None
    Options <%= @dir_options %>
<% if @operatingsystemmajrelease == '6' -%>
    Order allow,deny
    Allow from all
<% else -%>
    Require all granted
<% end -%>
</Directory>

DirectoryIndex <%= @dir_index.join(' ') %>

<%# 遍历数组生成多个 Listen/Alias %>
<% @aliases.sort.each do |url, path| -%>
Alias <%= url %> "<%= path %>"
<% end -%>

<%# 遍历数组生成允许访问的网段 %>
<Location /server-status>
    SetHandler server-status
<% @admin_networks.each do |net| -%>
    Require ip <%= net %>
<% end -%>
</Location>

ErrorLog "logs/error_log"
LogLevel <%= @log_level %>
CustomLog "logs/access_log" combined

IncludeOptional conf.d/*.conf

httpd/manifests/init.pp:

class httpd (
  $port           = 80,
  $ssl_enabled    = false,
  $ssl_port       = 443,
  $docroot        = '/var/www/html',
  $server_admin   = 'root@localhost',
  $log_level      = 'warn',
  $dir_options    = 'Indexes FollowSymLinks',
  $dir_index      = ['index.html', 'index.php'],
  $aliases        = {},
  $admin_networks = ['127.0.0.1'],
) {
  # 按发行版决定运行用户
  case $::osfamily {
    'RedHat': {
      $httpd_pkg   = 'httpd'
      $httpd_svc   = 'httpd'
      $httpd_conf  = '/etc/httpd/conf/httpd.conf'
      $httpd_user  = 'apache'
      $httpd_group = 'apache'
    }
    'Debian': {
      $httpd_pkg   = 'apache2'
      $httpd_svc   = 'apache2'
      $httpd_conf  = '/etc/apache2/apache2.conf'
      $httpd_user  = 'www-data'
      $httpd_group = 'www-data'
    }
    default: { fail("Unsupported osfamily: ${::osfamily}") }
  }

  package { $httpd_pkg:
    ensure => installed,
  }

  file { $docroot:
    ensure  => directory,
    owner   => $httpd_user,
    group   => $httpd_group,
    mode    => '0755',
    require => Package[$httpd_pkg],
  }

  file { $httpd_conf:
    ensure       => file,
    owner        => 'root',
    group        => 'root',
    mode         => '0644',
    content      => template('httpd/httpd.conf.erb'),
    require      => Package[$httpd_pkg],
    notify       => Service[$httpd_svc],
    validate_cmd => '/usr/sbin/httpd -t -f %',   # 校验配置语法后才写入
  }

  service { $httpd_svc:
    ensure => running,
    enable => true,
  }
}

在 site.pp 中使用:

node /^web\d+\.cluster\.com$/ {
  class { 'httpd':
    port           => 80,
    docroot        => '/web',
    server_admin   => 'ops@cluster.com',
    log_level      => 'warn',
    dir_index      => ['index.html', 'index.htm'],
    aliases        => { '/static' => '/data/static', '/img' => '/data/img' },
    admin_networks => ['10.1.1.0/24'],
  }
}

9.8 完整示例:用模板生成 Nginx 配置

nginx/templates/nginx.conf.erb:

# Managed by Puppet. DO NOT EDIT.
user  <%= @nginx_user %>;
worker_processes  <%= @worker_processes %>;
worker_rlimit_nofile <%= @worker_rlimit_nofile %>;

error_log  /var/log/nginx/error.log <%= @log_level %>;
pid        /var/run/nginx.pid;

events {
    use epoll;
    worker_connections  <%= @worker_connections %>;
}

http {
    include       /etc/nginx/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"';

    access_log  /var/log/nginx/access.log  main;

    sendfile        on;
    tcp_nopush      on;
    keepalive_timeout  <%= @keepalive_timeout %>;
    client_max_body_size <%= @client_max_body_size %>;
<% if @gzip_enabled -%>
    gzip  on;
    gzip_min_length 1k;
    gzip_comp_level 4;
    gzip_types text/plain text/css application/json application/javascript;
<% end -%>

<%# 生成 upstream 后端池 %>
<% @upstreams.sort.each do |pool, servers| -%>
    upstream <%= pool %> {
<% servers.each do |s| -%>
        server <%= s %> max_fails=3 fail_timeout=30s;
<% end -%>
        keepalive 32;
    }
<% end -%>

    server {
        listen       <%= @listen_port %>;
        server_name  <%= @server_name %>;
        root         <%= @docroot %>;
        index        index.html index.htm;

<% @locations.sort.each do |path, backend| -%>
        location <%= path %> {
            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;
        }
<% end -%>

        location /nginx_status {
            stub_status on;
            access_log off;
<% @admin_networks.each do |net| -%>
            allow <%= net %>;
<% end -%>
            deny all;
        }
    }

    include /etc/nginx/conf.d/*.conf;
}

nginx/manifests/init.pp:

class nginx (
  $nginx_user            = 'nginx',
  $listen_port           = 80,
  $server_name           = $::fqdn,
  $docroot               = '/usr/share/nginx/html',
  $log_level             = 'warn',
  $keepalive_timeout     = 65,
  $client_max_body_size  = '50m',
  $gzip_enabled          = true,
  $worker_connections    = 10240,
  $worker_rlimit_nofile  = 65535,
  $upstreams             = {},
  $locations             = {},
  $admin_networks        = ['127.0.0.1'],
) {
  # worker_processes 按 CPU 核数自动设定
  $worker_processes = $::processorcount

  package { 'nginx':
    ensure => installed,
  }

  file { '/etc/nginx/nginx.conf':
    ensure       => file,
    owner        => 'root',
    group        => 'root',
    mode         => '0644',
    content      => template('nginx/nginx.conf.erb'),
    require      => Package['nginx'],
    notify       => Service['nginx'],
    validate_cmd => '/usr/sbin/nginx -t -c %',
  }

  service { 'nginx':
    ensure => running,
    enable => true,
  }
}
# site.pp 中调用
node 'lb1.cluster.com' {
  class { 'nginx':
    listen_port    => 80,
    server_name    => 'www.cluster.com',
    upstreams      => {
      'app_pool' => ['10.1.1.11:8080', '10.1.1.12:8080'],
      'api_pool' => ['10.1.1.21:9000'],
    },
    locations      => {
      '/'    => 'app_pool',
      '/api' => 'api_pool',
    },
    admin_networks => ['10.1.1.0/24'],
  }
}

十、文件分发与 puppet:/// 协议

10.1 files 目录与 puppet:/// 三段式路径规则

file 资源的 source 属性若指向 Master 上的文件,必须用 puppet:/// URL。

标准三段式(Puppet 3.x 后推荐,模块方式):

puppet:///modules/<模块名>/<文件在模块 files 目录下的相对路径>
   ↑         ↑          ↑                    ↑
 协议    固定关键字   模块名           相对 files/ 的路径(不含 "files/" 这一层)
URL 实际文件位置(Puppet 3.x)
puppet:///modules/sudo/etc/sudoers /etc/puppet/modules/sudo/files/etc/sudoers
puppet:///modules/httpd/httpd.conf /etc/puppet/modules/httpd/files/httpd.conf
puppet:///modules/nginx/conf.d/vhost.conf /etc/puppet/modules/nginx/files/conf.d/vhost.conf

注意:URL 中不写 files/,Puppet 自动在模块的 files/ 目录下查找。这是最容易写错的地方。

四段式(旧式 fileserver mount 方式,Puppet 3.x 及更早):

puppet://<master 主机名>/<fileserver.conf 中定义的挂载点名>/<挂载点下的相对路径>

需要在 /etc/puppet/fileserver.conf 中定义挂载点:

# /etc/puppet/fileserver.conf
[sudo]
path /etc/puppet/modules/sudo
allow *

[httpd]
path /etc/puppet/modules/httpd
allow *

[extra_files]
path /data/puppet_files
allow 10.1.1.0/24
deny  10.1.1.99

对应的 URL:

$puppetserver = 'master.cluster.com'

file { '/etc/sudoers':
  # 四段式:协议 + 服务器名 + 挂载点名 + 相对路径
  source => "puppet://$puppetserver/sudo/files/etc/sudoers",
}

两种写法对比:

方式 URL 形式 是否需要 fileserver.conf 推荐度
模块方式(三段式) puppet:///modules/<模块>/<路径> 不需要(自动可用) 推荐
挂载点方式(四段式) puppet://<server>/<mount>/<路径> 需要 兼容老代码
省略主机名 puppet:///...(三个斜杠) - Agent 自动用 server 配置的地址

使用三个斜杠 puppet:/// 时,Agent 会自动使用 puppet.conf 中 server = 配置的 Master;写成 puppet://主机名/... 则是硬编码 Master 地址(不利于多环境/多 Master)。推荐用三斜杠。

文件权限要求:模块目录及文件需要 puppet 用户可读:

chown -R puppet /etc/puppet/modules/
find /etc/puppet/modules -type d -exec chmod 755 {} \;
find /etc/puppet/modules -type f -exec chmod 644 {} \;

10.2 source 的多路径回退写法

source 可以是数组,Puppet 按顺序尝试,用第一个存在的文件。这是实现"按节点/按环境定制、否则用默认"的经典技巧:

file { '/etc/motd':
  ensure => file,
  source => [
    "puppet:///modules/motd/${::fqdn}/motd",         # ① 该主机专属版本
    "puppet:///modules/motd/${::domain}/motd",       # ② 该域专属版本
    "puppet:///modules/motd/${::osfamily}/motd",     # ③ 该 OS 家族版本
    'puppet:///modules/motd/motd',                    # ④ 默认版本(兜底)
  ],
}

对应的目录结构:

/etc/puppet/modules/motd/files/
├── agent1.cluster.com/
│   └── motd                 # agent1 专属
├── cluster.com/
│   └── motd                 # cluster.com 域通用
├── RedHat/
│   └── motd                 # RedHat 系通用
└── motd                     # 全局默认
# 另一个常见用法:按环境回退
file { '/etc/app/app.conf':
  ensure => file,
  source => [
    "puppet:///modules/app/conf/${::environment}/app.conf",
    'puppet:///modules/app/conf/default/app.conf',
  ],
  notify => Service['app'],
}

# 也支持 http/https/file 源(4.x+)
file { '/tmp/tool.tar.gz':
  ensure => file,
  source => 'https://mirror.internal.com/tools/tool.tar.gz',
}

若数组中所有路径都不存在,Puppet 报错 Could not retrieve information from environment production source(s)。

10.3 递归目录分发与 purge 清理

# 完整分发一个目录树,并保持 Agent 端与 Master 端完全一致
file { '/etc/nginx/conf.d':
  ensure  => directory,
  owner   => 'root',
  group   => 'root',
  mode    => '0644',
  recurse => true,               # 递归分发子目录与文件
  purge   => true,               # 删除 Agent 上不由 Puppet 管理的多余文件
  force   => true,               # purge 遇到子目录时必须加
  source  => 'puppet:///modules/nginx/conf.d',
  backup  => false,              # 不备份被删的文件
  notify  => Service['nginx'],
}

recurse 的取值:

值 说明
false(默认) 不递归,只处理目录本身(如属主属组)
true / inf 无限深度递归
remote 只递归远端 source的内容,不管理本地多出来的文件(purge 无效)
recurselimit => N 限制递归层数
# 只递归 2 层
file { '/opt/app':
  ensure       => directory,
  recurse      => true,
  recurselimit => 2,
  owner        => 'appuser',
}

# 清空一个目录(只保留目录本身)
file { '/var/cache/myapp':
  ensure  => directory,
  recurse => true,
  purge   => true,
  force   => true,
}

# 清理 authorized_keys 中不由 Puppet 管理的密钥
user { 'deploy':
  ensure         => present,
  managehome     => true,
  purge_ssh_keys => true,
}

purge 的注意事项(危险操作):

  • purge => true 会删除目标目录中 Puppet 不认识的所有文件,用错会造成数据丢失;
  • 千万不要对 /etc、/var、/home 这类目录开 purge;
  • 上线前必须先用 --noop 演练确认要删的文件清单:
puppet agent --test --noop
# 输出中会显示 File[/etc/nginx/conf.d/old.conf]/ensure: current_value file, should be absent (noop)

十一、完整实战案例

11.1 案例一:用模块统一推送 /etc/sudoers

需求:所有 .cluster.com 节点安装 sudo 包,并统一使用 Master 上维护的 sudoers 文件。

第一步:在 master 上建立模块目录结构

mkdir -p /etc/puppet/modules/sudo/{files,templates,manifests}

第二步:在 master 上指定模块路径

编辑 /etc/puppet/puppet.conf,在 [main] 段增加:

modulepath = /etc/puppet/modules

第三步:配置 fileserver.conf,允许客户端访问模块目录

# /etc/puppet/fileserver.conf
[sudo]
path /etc/puppet/modules/sudo
allow *

第四步:配置 site.pp

/etc/puppet/manifests/site.pp:

import 'nodes.pp'

$puppetserver = "master.cluster.com"

第五步:配置 nodes.pp

/etc/puppet/manifests/nodes.pp,大量节点时可用正则集中配置:

node /\.cluster\.com$/ {
        include sudo        # 所有 .cluster.com 结尾的节点调用 sudo 类
}

第六步:编写模块核心 init.pp

/etc/puppet/modules/sudo/manifests/init.pp。其中 $operatingsystem 的值由 facter 返回:

class sudo {
    package { sudo:
        provider => yum,
        name => sudo,
        ensure => present,
    }

    if $operatingsystem in [ "RedHat", "CentOS", "Fedora" ] {
        file { "/etc/sudoers":
            owner => "root",
            group => "root",
            mode => 0440,
            source => "puppet://$puppetserver/sudo/files/etc/sudoers",
            require => Package["sudo"],
        }
    } else {
        fail("Doesn't support this OS: $operatingsystem")
    }
}

第七步:准备要下发的真实文件

mkdir -p /etc/puppet/modules/sudo/files/etc
cp /etc/sudoers /etc/puppet/modules/sudo/files/etc/sudoers
# 按需修改拷贝后的 sudoers,修改内容即要推送到 agent 的内容
vi /etc/puppet/modules/sudo/files/etc/sudoers
chown -R puppet /etc/puppet/modules/

要下发的 sudoers 内容示例(在文件末尾追加):

## 由 Puppet 统一管理,请勿手工修改本机文件
%wheel      ALL=(ALL)       ALL
ops         ALL=(ALL)       NOPASSWD: ALL
dev         ALL=(ALL)       NOPASSWD: /bin/systemctl restart myapp, /usr/bin/tail
Defaults    logfile=/var/log/sudo.log

第八步:客户端验证

puppet agent --test --server master.cluster.com --no-daemonize --verbose

或直接在 agent 上:

cat /etc/sudoers
ls -l /etc/sudoers        # 验证权限为 0440,属主 root:root
visudo -c                 # 验证语法正确
sudo -l -U ops            # 验证权限生效

改进版(Puppet 4.x 风格,用三段式 URL + validate_cmd + 参数化 + sudoers.d 分片):

# modules/sudo/manifests/init.pp
class sudo (
  String  $sudoers_source = 'puppet:///modules/sudo/etc/sudoers',
  Boolean $manage_dropin  = true,
) {
  unless $::osfamily == 'RedHat' {
    fail("sudo module doesn't support this OS: ${::operatingsystem}")
  }

  package { 'sudo':
    ensure => present,
  }

  file { '/etc/sudoers':
    ensure       => file,
    owner        => 'root',
    group        => 'root',
    mode         => '0440',
    source       => $sudoers_source,
    require      => Package['sudo'],
    validate_cmd => '/usr/sbin/visudo -c -f %',   # 语法错误则不写入,防止把系统弄坏
  }

  # 用 sudoers.d 分片管理更安全(不动主文件)
  if $manage_dropin {
    file { '/etc/sudoers.d':
      ensure  => directory,
      owner   => 'root',
      group   => 'root',
      mode    => '0750',
      recurse => true,
      purge   => true,
      source  => 'puppet:///modules/sudo/sudoers.d',
      require => Package['sudo'],
    }
  }
}

11.2 案例二:统一管理 /etc/hosts

需求:所有节点的 /etc/hosts 保持一致,包含集群内所有主机的解析记录。

模块目录结构:

/etc/puppet/modules/hosts/
├── manifests/
│   └── init.pp
└── templates/
    └── hosts.erb

方式一:用 host 资源逐条声明(推荐,最幂等,不影响文件中其他手工内容)

# modules/hosts/manifests/init.pp
class hosts (
  Hash $entries = {},
) {
  # 集群固定成员
  host { 'master.cluster.com':
    ensure       => present,
    ip           => '10.1.1.2',
    host_aliases => ['master', 'puppet'],
    comment      => 'managed by puppet',
  }

  host { 'agent1.cluster.com':
    ensure       => present,
    ip           => '10.1.1.3',
    host_aliases => ['agent1'],
  }

  host { 'agent2.cluster.com':
    ensure       => present,
    ip           => '10.1.1.4',
    host_aliases => ['agent2'],
  }

  host { 'server.cluster.com':
    ensure       => present,
    ip           => '10.1.1.1',
    host_aliases => ['server', 'yumsrv'],
  }

  # 通过参数(可由 Hiera 提供)批量追加
  $entries.each |String $name, Hash $cfg| {
    host { $name:
      ensure       => 'present',
      ip           => $cfg['ip'],
      host_aliases => $cfg['aliases'],
    }
  }

  # 清理已下线的主机记录
  host { 'oldnode.cluster.com':
    ensure => absent,
  }
}

方式二:用模板整体覆盖 /etc/hosts(强一致,但会抹掉本地手工修改)

<%# modules/hosts/templates/hosts.erb %>
# This file is managed by Puppet. DO NOT EDIT MANUALLY.
# Generated for <%= @fqdn %>

127.0.0.1   localhost localhost.localdomain localhost4 localhost4.localdomain4
::1         localhost localhost.localdomain localhost6 localhost6.localdomain6

<%# 排序遍历,保证输出稳定,避免每次运行都认为文件变了 %>
<% @cluster_hosts.sort.each do |name, cfg| -%>
<%= cfg['ip'] %>    <%= name %>    <%= (cfg['aliases'] || []).join(' ') %>
<% end -%>
# modules/hosts/manifests/init.pp(模板方式)
class hosts (
  Hash $cluster_hosts = {
    'master.cluster.com' => { 'ip' => '10.1.1.2', 'aliases' => ['master', 'puppet'] },
    'agent1.cluster.com' => { 'ip' => '10.1.1.3', 'aliases' => ['agent1'] },
    'agent2.cluster.com' => { 'ip' => '10.1.1.4', 'aliases' => ['agent2'] },
    'server.cluster.com' => { 'ip' => '10.1.1.1', 'aliases' => ['server'] },
  },
) {
  file { '/etc/hosts':
    ensure  => file,
    owner   => 'root',
    group   => 'root',
    mode    => '0644',
    content => template('hosts/hosts.erb'),
    backup  => 'main',                       # 覆盖前备份到 filebucket
  }
}

在 site.pp 中引用:

node /\.cluster\.com$/ {
  include hosts
}

验证:

puppet agent --test --noop     # 先看差异
puppet agent --test
cat /etc/hosts
ping -c1 agent2.cluster.com
getent hosts master.cluster.com

11.3 案例三:批量创建用户与用户组

需求:为运维组、开发组统一创建用户和组,分发 SSH 公钥,并清理离职人员账号。

模块目录结构:

/etc/puppet/modules/accounts/
├── manifests/
│   ├── init.pp
│   └── user.pp          # define accounts::user
└── files/
    └── ssh_keys/
        ├── zhangsan.pub
        └── lisi.pub

modules/accounts/manifests/user.pp(用 define 支持多实例):

define accounts::user (
  String           $group,
  Integer          $uid,
  Enum['present','absent'] $ensure = 'present',
  Array[String]    $groups   = [],
  String           $shell    = '/bin/bash',
  String           $comment  = '',
  Optional[String] $password = undef,
  Optional[String] $ssh_key  = undef,
) {
  user { $title:
    ensure     => $ensure,
    uid        => $uid,
    gid        => $group,
    groups     => $groups,
    shell      => $shell,
    home       => "/home/${title}",
    managehome => true,
    comment    => $comment,
    password   => $password,
    require    => Group[$group],
  }

  if $ensure == 'present' {
    file { "/home/${title}/.ssh":
      ensure  => directory,
      owner   => $title,
      group   => $group,
      mode    => '0700',
      require => User[$title],
    }

    if $ssh_key {
      file { "/home/${title}/.ssh/authorized_keys":
        ensure  => file,
        owner   => $title,
        group   => $group,
        mode    => '0600',
        content => "${ssh_key}\n",
        require => File["/home/${title}/.ssh"],
      }
    }
  }
}

modules/accounts/manifests/init.pp:

class accounts (
  Hash $users  = {},
  Hash $groups = {},
) {
  # ===== 用户组 =====
  group { 'ops':
    ensure => present,
    gid    => 3000,
  }

  group { 'dev':
    ensure => present,
    gid    => 3001,
  }

  group { 'dba':
    ensure => present,
    gid    => 3002,
  }

  # ===== 运维组用户 =====
  accounts::user { 'zhangsan':
    uid      => 3101,
    group    => 'ops',
    groups   => ['wheel'],
    comment  => 'Zhang San - Ops',
    # openssl passwd -1 'Passw0rd!'  生成
    password => '$1$Vs3xQ2mZ$aBcDeFgHiJkLmNoPqRsTu0',
    ssh_key  => 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCzhang... zhangsan@laptop',
  }

  accounts::user { 'lisi':
    uid     => 3102,
    group   => 'ops',
    groups  => ['wheel'],
    comment => 'Li Si - Ops',
    ssh_key => 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQClisi... lisi@laptop',
  }

  # ===== 开发组用户(不给 wheel,不允许登录部分机器) =====
  accounts::user { 'wangwu':
    uid     => 3201,
    group   => 'dev',
    comment => 'Wang Wu - Dev',
  }

  # ===== 应用运行账号(系统账号,禁止登录) =====
  user { 'appuser':
    ensure     => present,
    uid        => 3900,
    gid        => 'dev',
    shell      => '/sbin/nologin',
    home       => '/opt/app',
    managehome => false,
    system     => false,
    comment    => 'application runtime account',
    require    => Group['dev'],
  }

  # ===== 清理离职人员 =====
  user { ['zhaoliu', 'sunqi']:
    ensure     => absent,
    managehome => true,      # 连家目录一起删
  }

  # ===== 由 Hiera 提供的额外用户 =====
  $users.each |String $name, Hash $cfg| {
    accounts::user { $name: * => $cfg }
  }
}

site.pp 引用:

node /\.cluster\.com$/ {
  include accounts
  include sudo               # 配合 sudo 模块给 wheel 组授权
}

验证:

puppet agent --test
id zhangsan
getent group ops dev dba
ls -ld /home/zhangsan /home/zhangsan/.ssh
cat /home/zhangsan/.ssh/authorized_keys
id zhaoliu       # 应该报 no such user

11.4 案例四:完整部署 vsftpd(site.pp 直写版)

配置 yum、安装包、修改配置(拒绝匿名登录、所有普通用户支持 chroot)、启动服务并开机自启:

node /^agent\d+\.cluster\.com/ {
        file { "/etc/yum.repos.d/abc.repo":
                content => "[server]\nname=server\nbaseurl=file:///yum\nenabled=1\ngpgcheck=0\n",
                mode => 644,
                owner => root,
                group => root,
        }
        package { 'install vsftpd':
                provider => yum,
                name => vsftpd,
                ensure => installed,
        }
        file { "/tmp/1.sh":
                content => "sed -i '/^anonymous_enable/s/YES/NO/' /etc/vsftpd/vsftpd.conf\nsed -i '/chroot_local_user=YES/s/#//' /etc/vsftpd/vsftpd.conf\n",
        }
        exec { "exec-shell":
                cwd => "/tmp",
                command => "sh 1.sh; rm -rf 1.sh",
                user => "root",
                path => "/bin:/sbin:/usr/local/bin:/usr/local/sbin:/usr/sbin:/usr/bin",
        }
        service { vsftpd:
                ensure => true,
                enable => true,
        }
}

改进版(消除 exec,改用配置文件模板,幂等且可维护):

/etc/puppet/modules/vsftpd/
├── manifests/
│   └── init.pp
└── templates/
    └── vsftpd.conf.erb
<%# modules/vsftpd/templates/vsftpd.conf.erb %>
# Managed by Puppet. DO NOT EDIT.
anonymous_enable=<%= @anonymous_enable ? 'YES' : 'NO' %>
local_enable=YES
write_enable=YES
local_umask=022
dirmessage_enable=YES
xferlog_enable=YES
connect_from_port_20=YES
xferlog_std_format=YES
listen=<%= @listen ? 'YES' : 'NO' %>
listen_ipv6=NO
pam_service_name=vsftpd
userlist_enable=YES
tcp_wrappers=YES

<%# 所有普通用户 chroot 到家目录 %>
<% if @chroot_local_user -%>
chroot_local_user=YES
allow_writeable_chroot=YES
<% end -%>

max_clients=<%= @max_clients %>
max_per_ip=<%= @max_per_ip %>
idle_session_timeout=<%= @idle_timeout %>
# modules/vsftpd/manifests/init.pp
class vsftpd (
  Boolean $anonymous_enable  = false,
  Boolean $chroot_local_user = true,
  Boolean $listen            = true,
  Integer $max_clients       = 200,
  Integer $max_per_ip        = 10,
  Integer $idle_timeout      = 600,
) {
  # 用 yumrepo 资源替代 file 下发 repo 文件
  yumrepo { 'server':
    ensure   => present,
    descr    => 'local server repo',
    baseurl  => 'file:///yum',
    enabled  => 1,
    gpgcheck => 0,
  }

  package { 'vsftpd':
    ensure  => installed,
    require => Yumrepo['server'],
  }

  file { '/etc/vsftpd/vsftpd.conf':
    ensure  => file,
    owner   => 'root',
    group   => 'root',
    mode    => '0600',
    content => template('vsftpd/vsftpd.conf.erb'),
    require => Package['vsftpd'],
    notify  => Service['vsftpd'],           # 配置变了自动重启
  }

  service { 'vsftpd':
    ensure => running,
    enable => true,
  }
}
# site.pp
node /^agent\d+\.cluster\.com/ {
  class { 'vsftpd':
    anonymous_enable  => false,
    chroot_local_user => true,
    max_clients       => 100,
  }
}

验证:

puppet agent --test
rpm -q vsftpd
systemctl is-active vsftpd ; systemctl is-enabled vsftpd
grep -E 'anonymous_enable|chroot_local_user' /etc/vsftpd/vsftpd.conf
ftp localhost      # 匿名登录应被拒绝

11.5 案例五:用模块部署 httpd(原练习完整保留 + 改进版)

需求:

  1. 配置 yum;
  2. 安装 httpd、httpd-devel;
  3. 修改 httpd 家目录为 /web,并在每个家目录生成主页 index.html,内容为 web$IP 主页($IP 为 agent 节点 IP 的最后一个数字);
  4. httpd 配置文件跟随 puppet 服务器变更;
  5. 服务启动并开机自启。

11.5.1 原始参考答案(Puppet 3.x 风格)

1. 建立模块目录

mkdir -p /etc/puppet/modules/httpd/{files,templates,manifests}

2. site.pp

import 'nodes.pp'
$puppetserver = "puppetmaster.cluster.com"

3. nodes.pp

node /\.cluster\.com$/ {
        include sudo
        include httpd
}

4. init.pp

/etc/puppet/modules/httpd/manifests/init.pp:

class httpd {
        file { "/etc/yum.repos.d/rhel-source.repo":
                content => "[server]\nname=server\nbaseurl=file:///yum\nenabled=1\ngpgcheck=0\n",
                mode => 644,
                owner => root,
                group => root,
        }
        package { 'install httpd':
                provider => yum,
                name => httpd,
                ensure => installed,
        }
        package { 'install httpd-devel':
                provider => yum,
                name => httpd-devel,
                ensure => installed,
        }
        file { "/web":
                ensure => directory,
        }
        file { "/tmp/1.sh":
                content => "#!/bin/bash\nip=`ifconfig eth0 | grep broadcast |awk '{print \$2}' |awk -F. '{print \$4}'`\necho web\$ip > /web/index.html\nsed -i '119,131s/\/var\/www\/html/\/web/' /etc/httpd/conf/httpd.conf\n",
                mode => 755,
                owner => root,
                group => root,
        }
        exec { "exec-shell":
                cwd => "/tmp",
                command => "sh 1.sh",
                user => "root",
                path => "/bin:/sbin:/usr/local/bin:/usr/local/sbin:/usr/sbin:/usr/bin",
        }
        if $operatingsystem in [ "RedHat", "CentOS", "Fedora" ] {
                file { "/etc/httpd/conf/httpd.conf":
                        owner => "root",
                        group => "root",
                        mode => 0440,
                        source => "puppet://$puppetserver/httpd/files/etc/httpd.conf",
                        require => Package["httpd"],
                }
        } else {
                fail("Doesn't support this OS: $operatingsystem")
        }
        service { httpd:
                ensure => "running",
                enable => true,
        }
}

5. puppet.conf 指定 modulepath

modulepath = /etc/puppet/modules

6. fileserver.conf 增加 httpd 段

[sudo]
path /etc/puppet/modules/sudo
allow *
[httpd]
path /etc/puppet/modules/httpd
allow *

7. 准备配置文件并重启 master

mkdir -p /etc/puppet/modules/httpd/files/etc
cp /etc/httpd/conf/httpd.conf /etc/puppet/modules/httpd/files/etc/httpd.conf
# 拷过来后将家目录改为 /web
vi /etc/puppet/modules/httpd/files/etc/httpd.conf
chown -R puppet /etc/puppet/modules/
systemctl restart puppetmaster

8. 客户端验证

puppet agent --test --server master.cluster.com
rpm -qa | grep httpd
ls -ld /web
cat /web/index.html          # 应显示 web3 / web4(IP 最后一段)
grep -n 'DocumentRoot' /etc/httpd/conf/httpd.conf
systemctl is-active httpd
curl http://localhost/

11.5.2 改进版:完整可跑的 httpd 模块(消除 exec,用模板 + notify 联动)

上面的原始答案用 exec + sed 修改配置文件,不幂等(每次运行都会跑 1.sh),并且 sed '119,131s/...' 依赖行号,配置文件版本一变就失效。下面是生产可用的写法。

模块目录结构:

/etc/puppet/modules/httpd/
├── manifests/
│   ├── init.pp              # class httpd(入口,串联三段)
│   ├── params.pp            # class httpd::params(平台差异变量)
│   ├── install.pp           # class httpd::install
│   ├── config.pp            # class httpd::config
│   ├── service.pp           # class httpd::service
│   └── vhost.pp             # define httpd::vhost(多虚拟主机)
├── templates/
│   ├── httpd.conf.erb
│   ├── index.html.erb
│   └── vhost.conf.erb
├── files/
│   └── etc/
│       └── httpd.conf       # 可选:静态整体覆盖用
├── examples/
│   └── init.pp
└── metadata.json

manifests/params.pp:

class httpd::params {
  case $::osfamily {
    'RedHat': {
      $package_names = ['httpd', 'httpd-devel']
      $service_name  = 'httpd'
      $conf_file     = '/etc/httpd/conf/httpd.conf'
      $conf_dir      = '/etc/httpd/conf.d'
      $run_user      = 'apache'
      $run_group     = 'apache'
      # CentOS 6 与 7 的 PID 文件路径不同
      $pid_file      = $::operatingsystemmajrelease ? {
        '6'     => '/var/run/httpd/httpd.pid',
        default => '/run/httpd/httpd.pid',
      }
      $svc_provider  = $::operatingsystemmajrelease ? {
        '6'     => 'redhat',
        default => 'systemd',
      }
    }
    'Debian': {
      $package_names = ['apache2', 'apache2-dev']
      $service_name  = 'apache2'
      $conf_file     = '/etc/apache2/apache2.conf'
      $conf_dir      = '/etc/apache2/conf-enabled'
      $run_user      = 'www-data'
      $run_group     = 'www-data'
      $pid_file      = '/var/run/apache2/apache2.pid'
      $svc_provider  = 'systemd'
    }
    default: {
      fail("Doesn't support this OS: ${::operatingsystem} (${::osfamily})")
    }
  }
}

manifests/install.pp:

class httpd::install inherits httpd::params {
  # 用 yumrepo 资源管理 yum 源(替代下发 .repo 文件)
  if $::osfamily == 'RedHat' {
    yumrepo { 'server':
      ensure   => present,
      descr    => 'local server repo',
      baseurl  => 'file:///yum',
      enabled  => 1,
      gpgcheck => 0,
    }

    package { $httpd::params::package_names:
      ensure  => installed,
      require => Yumrepo['server'],
    }
  } else {
    package { $httpd::params::package_names:
      ensure => installed,
    }
  }
}

manifests/config.pp:

class httpd::config inherits httpd::params {
  # 家目录(DocumentRoot)
  file { $httpd::docroot:
    ensure => directory,
    owner  => $httpd::params::run_user,
    group  => $httpd::params::run_group,
    mode   => '0755',
  }

  # 主页:内容为 web + IP 最后一段(用 Puppet 内置函数取,不用 exec/awk)
  file { "${httpd::docroot}/index.html":
    ensure  => file,
    owner   => $httpd::params::run_user,
    group   => $httpd::params::run_group,
    mode    => '0644',
    content => template('httpd/index.html.erb'),
    require => File[$httpd::docroot],
  }

  # 主配置文件:用模板生成,DocumentRoot 由变量注入,不再依赖 sed 行号
  file { $httpd::params::conf_file:
    ensure       => file,
    owner        => 'root',
    group        => 'root',
    mode         => '0644',       # 注意:原答案写 0440,httpd 以 root 读取尚可,但 0644 更常规
    content      => template('httpd/httpd.conf.erb'),
    require      => Package[$httpd::params::package_names],
    validate_cmd => $::osfamily ? {
      'RedHat' => '/usr/sbin/httpd -t -f %',
      default  => undef,
    },
  }

  # 虚拟主机 drop-in 目录,purge 掉手工添加的多余配置
  file { $httpd::params::conf_dir:
    ensure  => directory,
    owner   => 'root',
    group   => 'root',
    mode    => '0755',
    recurse => true,
    purge   => false,       # 生产上确认无手工文件后再开 true
  }
}

templates/index.html.erb:

<%# 取 IP 最后一段:10.1.1.3 -> 3 %>
<% ip_last = @ipaddress.split('.').last -%>
web<%= ip_last %>

生成的 /web/index.html 内容即 web3(agent1)、web4(agent2),满足需求 3。

若想输出更完整的 HTML 页面:

<% ip_last = @ipaddress.split('.').last -%>
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>web<%= ip_last %></title></head>
<body>
  <h1>web<%= ip_last %> 主页</h1>
  <p>Hostname: <%= @fqdn %></p>
  <p>IP: <%= @ipaddress %></p>
  <p>OS: <%= @operatingsystem %> <%= @operatingsystemrelease %></p>
  <p>Managed by Puppet (<%= @puppetversion %>)</p>
</body>
</html>

templates/httpd.conf.erb(关键片段,完整版见第九章):

# Managed by Puppet. DO NOT EDIT MANUALLY.
ServerRoot "/etc/httpd"
Listen <%= @port %>
PidFile <%= scope['httpd::params::pid_file'] %>
User <%= scope['httpd::params::run_user'] %>
Group <%= scope['httpd::params::run_group'] %>
ServerAdmin <%= @server_admin %>
ServerName <%= @fqdn %>:<%= @port %>

<%# 需求 3 的核心:DocumentRoot 改为 /web %>
DocumentRoot "<%= @docroot %>"

<Directory />
    AllowOverride none
    Require all denied
</Directory>

<Directory "<%= @docroot %>">
    Options Indexes FollowSymLinks
    AllowOverride None
<% if @operatingsystemmajrelease == '6' -%>
    Order allow,deny
    Allow from all
<% else -%>
    Require all granted
<% end -%>
</Directory>

<IfModule dir_module>
    DirectoryIndex index.html index.htm
</IfModule>

<Files ".ht*">
    Require all denied
</Files>

ErrorLog "logs/error_log"
LogLevel <%= @log_level %>

<IfModule log_config_module>
    LogFormat "%h %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-Agent}i\"" combined
    CustomLog "logs/access_log" combined
</IfModule>

<IfModule mime_module>
    TypesConfig /etc/mime.types
    AddType application/x-compress .Z
    AddType application/x-gzip .gz .tgz
</IfModule>

AddDefaultCharset UTF-8
EnableSendfile on

IncludeOptional conf.d/*.conf

manifests/service.pp:

class httpd::service inherits httpd::params {
  service { $httpd::params::service_name:
    ensure     => $httpd::ensure,
    enable     => $httpd::enable,
    provider   => $httpd::params::svc_provider,
    hasrestart => true,
    hasstatus  => true,
  }
}

manifests/init.pp(入口,串联三段并建立 notify 联动):

# 部署并管理 httpd:安装 -> 配置 -> 服务,配置变更自动重启
class httpd (
  Integer[1,65535]          $port         = 80,
  String                    $docroot      = '/web',
  String                    $server_admin = 'root@localhost',
  String                    $log_level    = 'warn',
  Enum['running','stopped'] $ensure       = 'running',
  Boolean                   $enable       = true,
) inherits httpd::params {

  contain httpd::install
  contain httpd::config
  contain httpd::service

  # 核心:安装 -> 配置 ~> 服务(~> 表示配置变更时刷新/重启服务)
  Class['httpd::install']
  -> Class['httpd::config']
  ~> Class['httpd::service']
}

manifests/vhost.pp(可选:支持多虚拟主机):

define httpd::vhost (
  Integer $port        = 80,
  String  $docroot     = "/web/${name}",
  String  $server_name = $title,
) {
  include httpd

  file { $docroot:
    ensure => directory,
    owner  => $httpd::params::run_user,
    group  => $httpd::params::run_group,
    mode   => '0755',
  }

  file { "${docroot}/index.html":
    ensure  => file,
    content => "<h1>${server_name}</h1>\n",
    require => File[$docroot],
  }

  file { "${httpd::params::conf_dir}/${name}.conf":
    ensure  => file,
    owner   => 'root',
    group   => 'root',
    mode    => '0644',
    content => template('httpd/vhost.conf.erb'),
    notify  => Service[$httpd::params::service_name],
  }
}

templates/vhost.conf.erb:

# Managed by Puppet - vhost <%= @name %>
Listen <%= @port %>
<VirtualHost *:<%= @port %>>
    ServerName <%= @server_name %>
    DocumentRoot "<%= @docroot %>"
    ErrorLog  logs/<%= @name %>-error_log
    CustomLog logs/<%= @name %>-access_log combined
    <Directory "<%= @docroot %>">
        Options Indexes FollowSymLinks
        AllowOverride None
<% if @operatingsystemmajrelease == '6' -%>
        Order allow,deny
        Allow from all
<% else -%>
        Require all granted
<% end -%>
    </Directory>
</VirtualHost>

examples/init.pp(本机快速测试用):

class { 'httpd':
  port    => 80,
  docroot => '/web',
}
# 在 Master 上先本机试跑,不需要 Agent
cd /etc/puppet/modules/httpd
puppet apply --modulepath /etc/puppet/modules --noop examples/init.pp
puppet apply --modulepath /etc/puppet/modules examples/init.pp

在 site.pp 中的引用方式:

# Puppet 3.x:/etc/puppet/manifests/site.pp
import 'nodes.pp'
$puppetserver = "master.cluster.com"
# Puppet 3.x:/etc/puppet/manifests/nodes.pp
node /\.cluster\.com$/ {
  include sudo
  include hosts
  include accounts

  class { 'httpd':
    port         => 80,
    docroot      => '/web',
    server_admin => 'ops@cluster.com',
  }
}

# 只在 web 节点上开多虚拟主机
node /^web\d+\.cluster\.com$/ {
  include sudo

  httpd::vhost { 'www.a.com': port => 8081, docroot => '/web/a' }
  httpd::vhost { 'www.b.com': port => 8082, docroot => '/web/b' }
}
# Puppet 4.x+:/etc/puppetlabs/code/environments/production/manifests/site.pp
# 无需 import,同目录的 .pp 自动加载
node default {
  include base
}

node /\.cluster\.com$/ {
  include sudo
  class { 'httpd':
    port    => 80,
    docroot => '/web',
  }
}

验证方法:

# ① Master 端先做语法检查
puppet parser validate /etc/puppet/manifests/site.pp
puppet parser validate /etc/puppet/modules/httpd/manifests/*.pp
erb -P -x -T '-' /etc/puppet/modules/httpd/templates/httpd.conf.erb | ruby -c

# ② Agent 端空跑演练,确认将要发生的变更
puppet agent --test --noop --server master.cluster.com

# ③ 正式执行
puppet agent --test --server master.cluster.com

# ④ 检查结果
rpm -q httpd httpd-devel
ls -ld /web
cat /web/index.html                              # 应为 web3 / web4
grep -n 'DocumentRoot' /etc/httpd/conf/httpd.conf # 应为 /web
httpd -t                                         # 配置语法检查
systemctl is-active httpd ; systemctl is-enabled httpd
curl http://localhost/
curl http://10.1.1.3/                            # 从 Master 上访问 agent1

# ⑤ 验证幂等性:再跑一次,应该没有任何变更输出
puppet agent --test --server master.cluster.com
# 期望输出:Notice: Applied catalog in x.xx seconds(没有 Notice: /Stage[main]/... changed 行)

# ⑥ 验证 notify 联动:在 Master 上改模板中的 log_level,再跑 Agent
#    应看到 httpd.conf 内容更新 + Service[httpd] 被 refresh(重启)

11.6 案例六:部署 NTP 客户端配置

需求:所有节点统一使用内网 NTP 服务器,CentOS 6 用 ntpd,CentOS 7 用 chronyd。

详见 08-03-时间同步与Xinetd.md

模块目录结构:

/etc/puppet/modules/ntp/
├── manifests/
│   └── init.pp
└── templates/
    ├── ntp.conf.erb          # CentOS 6
    └── chrony.conf.erb       # CentOS 7

templates/ntp.conf.erb(CentOS 6 / ntpd):

# Managed by Puppet. DO NOT EDIT.
driftfile /var/lib/ntp/drift

restrict default kod nomodify notrap nopeer noquery
restrict -6 default kod nomodify notrap nopeer noquery
restrict 127.0.0.1
restrict -6 ::1
<% @allowed_networks.each do |net| -%>
restrict <%= net %> mask 255.255.255.0 nomodify notrap
<% end -%>

<% @servers.each do |srv| -%>
server <%= srv %> iburst
<% end -%>

includefile /etc/ntp/crypto/pw
keys /etc/ntp/keys

templates/chrony.conf.erb(CentOS 7 / chronyd):

# Managed by Puppet. DO NOT EDIT.
<% @servers.each do |srv| -%>
server <%= srv %> iburst
<% end -%>

driftfile /var/lib/chrony/drift
makestep 1.0 3
rtcsync
<% @allowed_networks.each do |net| -%>
allow <%= net %>
<% end -%>
logdir /var/log/chrony

manifests/init.pp:

class ntp (
  Array[String] $servers          = ['10.1.1.1', 'ntp1.aliyun.com', 'ntp2.aliyun.com'],
  Array[String] $allowed_networks = ['10.1.1.0/24'],
  String        $timezone         = 'Asia/Shanghai',
) {
  # 按发行版选择实现
  case $::operatingsystemmajrelease {
    '6': {
      $pkg      = 'ntp'
      $svc      = 'ntpd'
      $conf     = '/etc/ntp.conf'
      $template = 'ntp/ntp.conf.erb'
      $provider = 'redhat'
    }
    '7', '8': {
      $pkg      = 'chrony'
      $svc      = 'chronyd'
      $conf     = '/etc/chrony.conf'
      $template = 'ntp/chrony.conf.erb'
      $provider = 'systemd'
    }
    default: {
      fail("Unsupported EL release: ${::operatingsystemmajrelease}")
    }
  }

  package { $pkg:
    ensure => installed,
  }

  file { $conf:
    ensure  => file,
    owner   => 'root',
    group   => 'root',
    mode    => '0644',
    content => template($template),
    require => Package[$pkg],
    notify  => Service[$svc],
  }

  service { $svc:
    ensure   => running,
    enable   => true,
    provider => $provider,
  }

  # 统一时区(Puppet 时间同步的前提,也影响证书有效性判断)
  file { '/etc/localtime':
    ensure => link,
    target => "/usr/share/zoneinfo/${timezone}",
    force  => true,
  }

  # CentOS 6 的时区配置文件
  if $::operatingsystemmajrelease == '6' {
    file { '/etc/sysconfig/clock':
      ensure  => file,
      content => "ZONE=\"${timezone}\"\nUTC=false\nARC=false\n",
    }
  }

  # 兜底:每小时强制校时一次(应对长期漂移)
  cron { 'force-time-sync':
    ensure      => present,
    command     => "/usr/sbin/ntpdate ${servers[0]} > /dev/null 2>&1",
    user        => 'root',
    minute      => 15,
    environment => ['PATH=/sbin:/bin:/usr/sbin:/usr/bin', 'MAILTO=""'],
  }
}

site.pp 引用与验证:

node /\.cluster\.com$/ {
  class { 'ntp':
    servers          => ['10.1.1.1'],
    allowed_networks => ['10.1.1.0/24'],
    timezone         => 'Asia/Shanghai',
  }
}
puppet agent --test
# CentOS 7
systemctl is-active chronyd ; chronyc sources -v ; timedatectl
# CentOS 6
service ntpd status ; ntpq -p ; date

11.7 案例七:SSH 加固配置

需求:禁止 root 直接登录、禁用密码登录(只允许密钥)、限制来源网段、缩短空闲超时。

详见 07-03-防火墙与SELinux.md

模块目录结构:

/etc/puppet/modules/ssh/
├── manifests/
│   └── init.pp
└── templates/
    └── sshd_config.erb

templates/sshd_config.erb:

# Managed by Puppet. DO NOT EDIT MANUALLY.
# Generated for <%= @fqdn %>

Port <%= @port %>
<% @listen_addresses.each do |addr| -%>
ListenAddress <%= addr %>
<% end -%>

Protocol 2
HostKey /etc/ssh/ssh_host_rsa_key
HostKey /etc/ssh/ssh_host_ecdsa_key
HostKey /etc/ssh/ssh_host_ed25519_key

SyslogFacility AUTHPRIV
LogLevel <%= @log_level %>

# ===== 认证策略 =====
PermitRootLogin <%= @permit_root_login %>
PubkeyAuthentication yes
AuthorizedKeysFile .ssh/authorized_keys
PasswordAuthentication <%= @password_authentication ? 'yes' : 'no' %>
PermitEmptyPasswords no
ChallengeResponseAuthentication no
KerberosAuthentication no
GSSAPIAuthentication no
UsePAM yes

MaxAuthTries <%= @max_auth_tries %>
MaxSessions <%= @max_sessions %>
LoginGraceTime <%= @login_grace_time %>

# ===== 会话超时 =====
ClientAliveInterval <%= @client_alive_interval %>
ClientAliveCountMax <%= @client_alive_count_max %>
TCPKeepAlive yes

# ===== 访问控制 =====
<% unless @allow_users.empty? -%>
AllowUsers <%= @allow_users.join(' ') %>
<% end -%>
<% unless @allow_groups.empty? -%>
AllowGroups <%= @allow_groups.join(' ') %>
<% end -%>
<% unless @deny_users.empty? -%>
DenyUsers <%= @deny_users.join(' ') %>
<% end -%>

# ===== 其他加固项 =====
X11Forwarding <%= @x11_forwarding ? 'yes' : 'no' %>
AllowTcpForwarding <%= @allow_tcp_forwarding ? 'yes' : 'no' %>
PermitUserEnvironment no
IgnoreRhosts yes
HostbasedAuthentication no
StrictModes yes
Compression delayed
UseDNS no
PrintMotd yes
PrintLastLog yes
Banner /etc/ssh/banner

# ===== 加密算法白名单(禁用弱算法) =====
<% if @operatingsystemmajrelease.to_i >= 7 -%>
Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes256-ctr,aes192-ctr,aes128-ctr
MACs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com,hmac-sha2-512,hmac-sha2-256
KexAlgorithms curve25519-sha256@libssh.org,diffie-hellman-group-exchange-sha256
<% end -%>

Subsystem sftp /usr/libexec/openssh/sftp-server

manifests/init.pp:

class ssh (
  Integer[1,65535] $port                    = 22,
  Array[String]    $listen_addresses        = ['0.0.0.0'],
  Enum['yes','no','without-password','prohibit-password','forced-commands-only']
                   $permit_root_login       = 'no',
  Boolean          $password_authentication = false,
  Integer          $max_auth_tries          = 3,
  Integer          $max_sessions            = 10,
  Integer          $login_grace_time        = 60,
  Integer          $client_alive_interval   = 300,
  Integer          $client_alive_count_max  = 2,
  Array[String]    $allow_users             = [],
  Array[String]    $allow_groups            = ['ops', 'wheel'],
  Array[String]    $deny_users              = [],
  Boolean          $x11_forwarding          = false,
  Boolean          $allow_tcp_forwarding    = true,
  String           $log_level               = 'INFO',
  String           $banner_text             = "Authorized access only. All activity is monitored.\n",
) {
  package { 'openssh-server':
    ensure => installed,
  }

  file { '/etc/ssh/banner':
    ensure  => file,
    owner   => 'root',
    group   => 'root',
    mode    => '0644',
    content => $banner_text,
  }

  file { '/etc/ssh/sshd_config':
    ensure       => file,
    owner        => 'root',
    group        => 'root',
    mode         => '0600',
    content      => template('ssh/sshd_config.erb'),
    require      => [ Package['openssh-server'], File['/etc/ssh/banner'] ],
    notify       => Service['sshd'],
    # 关键:配置语法错误时不写入,避免把自己锁在门外
    validate_cmd => '/usr/sbin/sshd -t -f %',
  }

  service { 'sshd':
    ensure     => running,
    enable     => true,
    hasrestart => true,
    provider   => $::operatingsystemmajrelease ? {
      '6'     => 'redhat',
      default => 'systemd',
    },
  }
}

site.pp 引用:

node /\.cluster\.com$/ {
  class { 'ssh':
    permit_root_login       => 'no',
    password_authentication => false,     # 只允许密钥登录
    allow_groups            => ['ops', 'wheel'],
    client_alive_interval   => 300,
    x11_forwarding          => false,
  }
  include accounts       # 确保 ops 组和用户密钥已下发
  Class['accounts'] -> Class['ssh']       # 先建用户再改 sshd,避免锁死
}

验证(务必先在测试机验证,并保留一个已登录的会话):

puppet agent --test --noop        # 先空跑看差异!
puppet agent --test
sshd -t                           # 语法检查
grep -E '^(PermitRootLogin|PasswordAuthentication|AllowGroups)' /etc/ssh/sshd_config
systemctl is-active sshd
# 在【另一个终端】测试新连接能否登录,确认没被锁死
ssh -o PreferredAuthentications=password root@10.1.1.3   # 应被拒绝
ssh zhangsan@10.1.1.3                                    # 应用密钥登录成功

安全警告:修改 sshd 配置有把自己锁在门外的风险。务必:① 先 --noop;② 保留一个已建立的 SSH 会话不要断开;③ validate_cmd 必须配置;④ 先在一台测试机验证再全量推送。


十二、进阶特性

12.1 环境(Environment)

环境让同一个 Master 同时提供多套代码,用于隔离开发、测试、生产,实现代码上线流程。

Puppet 3.x 的目录环境(directory environments):

# /etc/puppet/puppet.conf
[main]
    environmentpath = $confdir/environments
    # 旧式配置(config-file environments,已废弃)
    # [production]
    # modulepath = /etc/puppet/environments/production/modules
    # manifest   = /etc/puppet/environments/production/manifests/site.pp

Puppet 4.x+ 的标准目录布局:

/etc/puppetlabs/code/environments/
├── production/                      # 生产环境(默认)
│   ├── environment.conf             # 该环境的配置(可覆盖 modulepath 等)
│   ├── hiera.yaml                   # 该环境的 Hiera 层级(Hiera 5)
│   ├── manifests/
│   │   ├── site.pp
│   │   └── nodes.pp
│   ├── modules/                     # 该环境私有的模块
│   │   ├── httpd/
│   │   ├── sudo/
│   │   └── ntp/
│   └── data/                        # Hiera 数据
│       ├── common.yaml
│       ├── nodes/
│       │   └── agent1.cluster.com.yaml
│       └── os/
│           └── RedHat.yaml
├── development/                     # 开发环境
│   ├── environment.conf
│   ├── manifests/
│   │   └── site.pp
│   └── modules/
└── testing/                         # 测试环境
    ├── manifests/
    └── modules/

environment.conf 示例:

# /etc/puppetlabs/code/environments/production/environment.conf
# 模块搜索路径($basemodulepath 是全局公共模块目录)
modulepath = ./modules:./site:$basemodulepath
# manifest 入口(默认就是 ./manifests)
manifest = ./manifests
# 环境代码缓存时间,0 表示每次都重新加载(开发环境用 0,生产用 30~180 秒)
environment_timeout = 0
# 该环境使用的 ENC
# config_version = ./scripts/config_version.sh

指定环境的方式:

# ① Agent 命令行临时指定
puppet agent --test --environment development

# ② Agent 配置文件固定指定
# puppet.conf 的 [agent] 段
#   environment = development

# ③ puppet apply 指定
puppet apply --environment development /etc/puppetlabs/code/environments/development/manifests/site.pp

# ④ ENC 返回的 environment 字段(优先级最高)

# 查看当前环境
puppet config print environment
facter environment

在 manifest 中使用环境变量:

# 按环境区分配置
class app {
  case $::environment {
    'production': {
      $db_host   = 'db-prod.cluster.com'
      $log_level = 'warn'
      $replicas  = 6
    }
    'testing': {
      $db_host   = 'db-test.cluster.com'
      $log_level = 'info'
      $replicas  = 2
    }
    'development': {
      $db_host   = 'localhost'
      $log_level = 'debug'
      $replicas  = 1
    }
    default: { fail("Unknown environment: ${::environment}") }
  }

  file { '/etc/app/app.conf':
    content => template('app/app.conf.erb'),
  }
}

注意:环境名只能包含小写字母、数字和下划线;环境目录必须在 environmentpath 下;新增环境目录后无需重启(受 environment_timeout 影响)。

12.2 Hiera:数据与代码分离

为什么需要 Hiera?

不用 Hiera 时,环境差异和敏感数据只能硬编码在 manifest 里:

# 反面示例:数据写死在代码里
class mysql {
  $root_password = 'Secret123!'                 # 密码进了 Git,泄露风险
  $bind_address  = '10.1.1.10'                  # 换机房就要改代码
  $buffer_pool   = '8G'                          # 不同机型不一样
}

问题:

  • 敏感数据进代码库(密码、密钥、token 提交到 Git 后不可撤销);
  • 环境差异污染代码(每换一个机房/环境就要改 if/case);
  • 无法复用模块(模块里塞满了本公司专有的值)。

Hiera 把"做什么(代码)"和"用什么值(数据)"分开:模块代码保持通用,数据放在按层级组织的 YAML 里,Puppet 按层级顺序查找,越具体的层覆盖越通用的层。

Hiera 3(配合 Puppet 3.x/4.x)全局配置 /etc/puppet/hiera.yaml:

---
:backends:
  - yaml
  - eyaml            # 加密数据后端(hiera-eyaml)

:yaml:
  :datadir: /etc/puppet/hieradata

:eyaml:
  :datadir: /etc/puppet/hieradata
  :pkcs7_private_key: /etc/puppet/keys/private_key.pkcs7.pem
  :pkcs7_public_key:  /etc/puppet/keys/public_key.pkcs7.pem

# 层级:从上到下依次查找,上面的优先级高
:hierarchy:
  - "nodes/%{::clientcert}"              # ① 单节点专属
  - "roles/%{::role}"                    # ② 按角色(自定义 fact)
  - "env/%{::environment}"               # ③ 按环境
  - "os/%{::osfamily}-%{::operatingsystemmajrelease}"  # ④ 按 OS 版本
  - "os/%{::osfamily}"                   # ⑤ 按 OS 家族
  - "datacenter/%{::datacenter}"         # ⑥ 按机房
  - common                               # ⑦ 全局默认(兜底)

:logger: console
:merge_behavior: deeper                  # 哈希深合并(需 deep_merge gem)

Hiera 5(Puppet 4.9+ 推荐)环境层配置 environments/production/hiera.yaml:

---
version: 5

defaults:
  datadir: data
  data_hash: yaml_data

hierarchy:
  - name: "Per-node data"
    path: "nodes/%{trusted.certname}.yaml"

  - name: "Per-role data"
    path: "roles/%{facts.role}.yaml"

  - name: "Per-environment data"
    path: "env/%{server_facts.environment}.yaml"

  - name: "Per-OS major release"
    path: "os/%{facts.os.family}-%{facts.os.release.major}.yaml"

  - name: "Per-OS family"
    path: "os/%{facts.os.family}.yaml"

  - name: "Per-datacenter"
    path: "datacenter/%{facts.datacenter}.yaml"

  - name: "Encrypted secrets"
    lookup_key: eyaml_lookup_key
    path: "secrets/%{server_facts.environment}.eyaml"
    options:
      pkcs7_private_key: /etc/puppetlabs/puppet/eyaml/private_key.pkcs7.pem
      pkcs7_public_key:  /etc/puppetlabs/puppet/eyaml/public_key.pkcs7.pem

  - name: "Common data"
    path: "common.yaml"

数据文件示例:

# hieradata/common.yaml  —— 全局默认
---
# 类的自动参数查找:<类名>::<参数名>
httpd::port: 80
httpd::docroot: /var/www/html
httpd::log_level: warn
httpd::server_admin: ops@cluster.com

ssh::permit_root_login: 'no'
ssh::password_authentication: false
ssh::allow_groups:
  - ops
  - wheel

ntp::servers:
  - 10.1.1.1
  - ntp1.aliyun.com
ntp::timezone: Asia/Shanghai

# 普通键值(用 hiera()/lookup() 取)
dns_servers:
  - 114.114.114.114
  - 8.8.8.8

base_packages:
  - vim-enhanced
  - wget
  - curl
  - net-tools
  - lsof
  - tree

# 该节点应包含哪些类(配合 hiera_include)
classes:
  - base
  - sudo
  - ntp
  - ssh
# hieradata/os/RedHat-6.yaml  —— CentOS 6 专属
---
httpd::pid_file: /var/run/httpd/httpd.pid
ntp::package_name: ntp
ntp::service_name: ntpd
service_provider: redhat
base_packages:
  - vim-enhanced
  - wget
  - sysstat
# hieradata/os/RedHat-7.yaml  —— CentOS 7 专属
---
httpd::pid_file: /run/httpd/httpd.pid
ntp::package_name: chrony
ntp::service_name: chronyd
service_provider: systemd
# hieradata/env/production.yaml  —— 生产环境
---
httpd::log_level: warn
app::db_host: db-prod.cluster.com
app::replicas: 6
app::debug: false
# hieradata/env/development.yaml  —— 开发环境
---
httpd::log_level: debug
app::db_host: localhost
app::replicas: 1
app::debug: true
# hieradata/nodes/agent1.cluster.com.yaml  —— 单节点覆盖(优先级最高)
---
httpd::port: 8080
httpd::docroot: /web
classes:
  - base
  - sudo
  - ntp
  - ssh
  - httpd
# hieradata/roles/webserver.yaml  —— 按角色
---
classes:
  - base
  - httpd
  - monitoring::web
httpd::docroot: /web

在代码中查找数据:

# ===== 方式一:自动参数查找(Automatic Parameter Lookup,最推荐)=====
# 只要 include 一个参数化类,Puppet 自动去 Hiera 找 <类名>::<参数名>
class httpd (
  $port    = 80,               # 若 Hiera 中有 httpd::port,则用 Hiera 的值
  $docroot = '/var/www/html',
) { ... }

include httpd    # 参数自动从 Hiera 注入,无需显式传参

# ===== 方式二:hiera 函数系列(Hiera 3 时代)=====
$dns    = hiera('dns_servers')                        # 优先级查找,返回第一个命中
$dns    = hiera('dns_servers', ['8.8.8.8'])           # 带默认值
$all    = hiera_array('base_packages')                # 合并所有层的数组
$merged = hiera_hash('app_settings')                  # 合并所有层的哈希
hiera_include('classes')                              # 把 classes 键中列的类全部 include

# ===== 方式三:lookup 函数(Puppet 4.x+ 统一入口,推荐)=====
$dns = lookup('dns_servers')
$dns = lookup('dns_servers', Array[String], 'unique', ['8.8.8.8'])
#                ↑键          ↑类型断言     ↑合并策略   ↑默认值

# 合并策略:
#   'first'(默认,取最高优先级层的值)
#   'unique'(数组合并去重,等价 hiera_array)
#   'hash'(哈希浅合并,等价 hiera_hash)
#   'deep'(哈希深合并)

$settings = lookup('app_settings', Hash, 'deep', {})

# 带完整选项的写法
$pkgs = lookup({
  'name'          => 'base_packages',
  'value_type'    => Array[String],
  'merge'         => 'unique',
  'default_value' => [],
})

# 用 lookup_options 在 Hiera 数据中声明每个键的合并策略
# hieradata/common.yaml 中声明合并策略
---
lookup_options:
  base_packages:
    merge: unique
  app_settings:
    merge: deep
  classes:
    merge: unique

用 hiera_include 实现"数据驱动的节点分类"(可完全取代 node 块):

# site.pp 只需一行
node default {
  hiera_include('classes')     # 每个节点应用哪些类完全由 Hiera 数据决定
}

# Puppet 4.x+ 写法
node default {
  lookup('classes', Array[String], 'unique', []).include
}

命令行调试 Hiera:

# Hiera 3:模拟某节点的查找(需提供 fact)
hiera httpd::port ::clientcert=agent1.cluster.com ::osfamily=RedHat ::environment=production
hiera -a base_packages ::osfamily=RedHat        # 数组合并
hiera -h app_settings ::osfamily=RedHat         # 哈希合并
hiera -d httpd::port ::clientcert=agent1.cluster.com   # debug 模式,显示查找了哪些文件

# Hiera 5 / Puppet 4.x+:用 puppet lookup(更准确,会加载真实 facts)
puppet lookup httpd::port --node agent1.cluster.com
puppet lookup httpd::port --node agent1.cluster.com --explain      # 显示完整查找过程
puppet lookup classes --node agent1.cluster.com --merge unique
puppet lookup --environment development app::db_host --node agent1.cluster.com

用 eyaml 加密敏感数据:

# 安装
gem install hiera-eyaml
# 4.x+ 用自带 gem
/opt/puppetlabs/puppet/bin/gem install hiera-eyaml

# 生成密钥对
eyaml createkeys
mkdir -p /etc/puppetlabs/puppet/eyaml
mv keys/*.pem /etc/puppetlabs/puppet/eyaml/
chown -R puppet:puppet /etc/puppetlabs/puppet/eyaml
chmod 500 /etc/puppetlabs/puppet/eyaml
chmod 400 /etc/puppetlabs/puppet/eyaml/*.pem

# 加密一个值
eyaml encrypt -s 'MySecretPassword' \
  --pkcs7-public-key=/etc/puppetlabs/puppet/eyaml/public_key.pkcs7.pem

# 编辑加密文件(自动解密-编辑-重新加密)
eyaml edit hieradata/secrets/production.eyaml
# hieradata/secrets/production.eyaml
---
mysql::root_password: >
    ENC[PKCS7,MIIBiQYJKoZIhvcNAQcDoIIBejCCAXYCAQAxggEhMIIBHQIBADAFMAACAQEw
    DQYJKoZIhvcNAQEBBQAEggEAflcCq7cSDaPpXPP5MDLQmYnVE0ZKC3fL...]
app::api_token: >
    ENC[PKCS7,MIIBiQYJKoZIhvcNAQcDoIIBejCCAXYCAQAxggEhMIIBHQIBADAFMAAC...]
# 代码中透明使用,无需关心加密
class mysql (
  Sensitive[String] $root_password,     # Sensitive 类型可避免密码出现在日志中
) {
  file { '/root/.my.cnf':
    ensure    => file,
    mode      => '0600',
    content   => Sensitive("[client]\npassword=${root_password.unwrap}\n"),
    show_diff => false,                 # 日志中不显示差异内容
  }
}

12.3 PuppetDB 与导出资源(Exported Resources)

PuppetDB 是 Puppet 的数据存储服务,保存所有节点的 facts、catalog、report 和导出资源,底层用 PostgreSQL,提供查询 API。

安装 PuppetDB:

# Master 端
yum install -y puppetdb puppetdb-termini
# PostgreSQL
yum install -y postgresql-server
postgresql-setup initdb
systemctl enable --now postgresql

su - postgres -c "createuser -DRSP puppetdb"
su - postgres -c "createdb -E UTF8 -O puppetdb puppetdb"

# 配置数据库连接
vi /etc/puppetlabs/puppetdb/conf.d/database.ini
# /etc/puppetlabs/puppetdb/conf.d/database.ini
[database]
subname = //localhost:5432/puppetdb
username = puppetdb
password = puppetdb_password
# 报告保留期限
report-ttl = 14d
node-ttl = 7d
node-purge-ttl = 14d
# /etc/puppetlabs/puppetdb/conf.d/jetty.ini
[jetty]
host = 0.0.0.0
port = 8080
ssl-host = 0.0.0.0
ssl-port = 8081
ssl-key = /etc/puppetlabs/puppetdb/ssl/private.pem
ssl-cert = /etc/puppetlabs/puppetdb/ssl/public.pem
ssl-ca-cert = /etc/puppetlabs/puppetdb/ssl/ca.pem
puppetdb ssl-setup
systemctl enable --now puppetdb

让 Master 使用 PuppetDB:

# /etc/puppetlabs/puppet/puppet.conf
[master]
    storeconfigs = true                    # 【关键】开启导出资源存储
    storeconfigs_backend = puppetdb
    reports = store,puppetdb
# /etc/puppetlabs/puppet/routes.yaml
---
master:
  facts:
    terminus: puppetdb
    cache: yaml
# /etc/puppetlabs/puppet/puppetdb.conf
[main]
server_urls = https://master.cluster.com:8081
systemctl restart puppetserver

导出资源的语法:

语法 名称 含义
@@resource { ... } 导出资源(exported resource) 把资源声明导出到 PuppetDB,不在本节点应用
Resource <<| 查询条件 |>> 收集器(collector) 从 PuppetDB 收集符合条件的导出资源并在本节点应用
@resource { ... } 虚拟资源(virtual resource) 只在本节点内声明但不应用,需 Resource <| |> 实现
Resource <| 条件 |> 虚拟资源收集器 实现(realize)本节点内的虚拟资源
realize(Resource['x']) realize 函数 实现虚拟资源的另一种写法

12.3.1 典型场景一:自动生成 /etc/hosts

每个节点导出自己的 host 记录,所有节点收集全部记录 → 自动维护完整的 hosts 文件,新增节点无需改代码。

# modules/hosts/manifests/init.pp
class hosts {
  # ① 每个节点导出自己的 host 记录(@@ 表示导出)
  @@host { $::fqdn:
    ensure       => present,
    ip           => $::ipaddress,
    host_aliases => [$::hostname],
    tag          => 'cluster_hosts',       # 打标签便于收集
  }

  # ② 收集所有节点导出的 host 记录并在本机应用
  Host <<| tag == 'cluster_hosts' |>>

  # 也可以按条件筛选,例如只收集同机房的
  # Host <<| tag == 'cluster_hosts' and datacenter == $::datacenter |>>
}
node /\.cluster\.com$/ {
  include hosts
}

运行效果:第一轮所有 Agent 跑完后,PuppetDB 里就有了全部节点的 host 记录;第二轮每个节点都会得到完整的 /etc/hosts。

12.3.2 典型场景二:自动生成 Nagios/Zabbix 监控配置

被监控节点导出自己的监控定义,监控服务器收集全部定义 → 新机器上线自动纳入监控。

# modules/monitoring/manifests/client.pp —— 所有被监控节点
class monitoring::client {
  package { 'nrpe': ensure => installed }

  # 导出该节点的 Nagios 主机定义
  @@nagios_host { $::fqdn:
    ensure  => present,
    alias   => $::hostname,
    address => $::ipaddress,
    use     => 'linux-server',
    target  => '/etc/nagios/conf.d/hosts.cfg',
    tag     => 'nagios_managed',
  }

  # 导出该节点的服务检查定义
  @@nagios_service { "check_ping_${::fqdn}":
    ensure              => present,
    host_name           => $::fqdn,
    check_command       => 'check_ping!100.0,20%!500.0,60%',
    service_description => 'PING',
    use                 => 'generic-service',
    target              => '/etc/nagios/conf.d/services.cfg',
    tag                 => 'nagios_managed',
  }

  @@nagios_service { "check_disk_${::fqdn}":
    ensure              => present,
    host_name           => $::fqdn,
    check_command       => 'check_nrpe!check_disk',
    service_description => 'Disk Usage',
    use                 => 'generic-service',
    target              => '/etc/nagios/conf.d/services.cfg',
    tag                 => 'nagios_managed',
  }
}
# modules/monitoring/manifests/server.pp —— 监控服务器
class monitoring::server {
  package { ['nagios', 'nagios-plugins-all', 'nrpe']:
    ensure => installed,
  }

  file { '/etc/nagios/conf.d':
    ensure => directory,
    owner  => 'nagios',
    group  => 'nagios',
    mode   => '0755',
  }

  # 收集所有被监控节点导出的定义
  Nagios_host <<| tag == 'nagios_managed' |>> {
    notify => Service['nagios'],
  }

  Nagios_service <<| tag == 'nagios_managed' |>> {
    notify => Service['nagios'],
  }

  service { 'nagios':
    ensure  => running,
    enable  => true,
    require => Package['nagios'],
  }
}

12.3.3 典型场景三:自动生成负载均衡后端池、SSH known_hosts

# 后端应用节点导出自己
class app::backend {
  @@concat::fragment { "upstream_${::fqdn}":
    target  => '/etc/nginx/conf.d/upstream.conf',
    content => "    server ${::ipaddress}:8080 max_fails=3 fail_timeout=30s;\n",
    order   => '10',
    tag     => 'app_upstream',
  }
}

# 负载均衡节点收集
class app::loadbalancer {
  concat { '/etc/nginx/conf.d/upstream.conf':
    ensure => present,
    notify => Service['nginx'],
  }

  concat::fragment { 'upstream_header':
    target  => '/etc/nginx/conf.d/upstream.conf',
    content => "upstream app_pool {\n",
    order   => '01',
  }

  Concat::Fragment <<| tag == 'app_upstream' |>>

  concat::fragment { 'upstream_footer':
    target  => '/etc/nginx/conf.d/upstream.conf',
    content => "    keepalive 32;\n}\n",
    order   => '99',
  }
}
# 自动互信:每个节点导出自己的 SSH host key,所有节点收集
class ssh::knownhosts {
  @@sshkey { $::fqdn:
    ensure       => present,
    type         => 'ssh-rsa',
    key          => $::sshrsakey,
    host_aliases => [$::hostname, $::ipaddress],
    tag          => 'cluster_sshkeys',
  }

  Sshkey <<| tag == 'cluster_sshkeys' |>>
}

查询 PuppetDB:

# 查所有节点
curl -k https://master.cluster.com:8081/pdb/query/v4/nodes | python -m json.tool

# 查某节点的 facts
curl -k 'https://master.cluster.com:8081/pdb/query/v4/facts?query=["=","certname","agent1.cluster.com"]'

# 查所有 CentOS 7 节点
curl -k -G https://master.cluster.com:8081/pdb/query/v4/facts \
  --data-urlencode 'query=["and",["=","name","operatingsystemmajrelease"],["=","value","7"]]'

# 查所有导出资源
curl -k -G https://master.cluster.com:8081/pdb/query/v4/resources \
  --data-urlencode 'query=["and",["=","exported",true],["=","type","Host"]]'

# 查最近的报告
curl -k https://master.cluster.com:8081/pdb/query/v4/reports | python -m json.tool

# 用 puppet query 命令(需 puppetdb-cli)
puppet query 'nodes {}'
puppet query 'inventory { facts.os.family = "RedHat" }'

导出资源的注意事项:

  • 必须先开启 storeconfigs = true 且配好 PuppetDB,否则 @@ 静默失效或报错;
  • 需要两轮 Puppet 运行才能生效(第一轮导出,第二轮才能被收集到);
  • 导出资源的标题必须全局唯一(含 $::fqdn 或 $::hostname),否则不同节点导出同名资源会冲突;
  • 节点下线后要用 puppet node deactivate <certname> 从 PuppetDB 中清理,否则残留的导出资源会一直被收集;
  • 大规模环境下收集器会显著增加 catalog 编译时间,需注意性能。
# 清理已下线节点
puppet node deactivate agent2.cluster.com
puppet node clean agent2.cluster.com       # 同时清证书

12.4 report 与 Dashboard

report 处理器(reports 参数):

值 作用
none 不处理报告
store 存为 YAML 文件($vardir/reports/<certname>/*.yaml)
log 写入 Master 的日志
http POST 到指定的 reporturl(puppet-dashboard 用这个)
puppetdb 存入 PuppetDB
tagmail 按 tag 发邮件
# Master 端 puppet.conf
[master]
    reports   = store,http,puppetdb
    reporturl = http://10.1.1.2:8141/reports

# Agent 端
[agent]
    report = true

报告中包含的信息:本次运行的耗时、每个资源的状态(unchanged / changed / failed / skipped / out_of_sync)、变更前后的值、失败原因、节点的 facts、catalog 版本。

Web 界面选择:

工具 说明
puppet-dashboard 老牌轻量报告平台(Rails 应用 + MySQL),见下文完整搭建步骤
Foreman 功能最全:报告 + ENC + 主机生命周期管理 + PXE 装机,可完全替代 dashboard
PuppetBoard 基于 PuppetDB API 的轻量 Web 界面(Python/Flask),只读,部署最简单
Puppet Enterprise Console 商业版自带的控制台

12.5 puppet apply 单机模式

# 应用一个 manifest 文件
puppet apply /etc/puppet/manifests/site.pp

# 直接执行一段代码
puppet apply -e 'file { "/tmp/a.txt": ensure => file, content => "hi\n" }'
puppet apply -e 'package { "vim-enhanced": ensure => installed }'
puppet apply -e 'include httpd'

# 空跑演练
puppet apply --noop /etc/puppet/manifests/site.pp

# 指定模块路径与 Hiera 配置
puppet apply --modulepath /etc/puppet/modules \
             --hiera_config /etc/puppet/hiera.yaml \
             /etc/puppet/manifests/site.pp

# 详细输出 / 调试
puppet apply --verbose --debug --show_diff site.pp

# 用退出码判断结果(CI 中很有用)
puppet apply --detailed-exitcodes site.pp
# 0 = 无变更无失败;2 = 有变更;4 = 有失败;6 = 有变更且有失败

# 测试单个模块(模块自带 examples)
cd /etc/puppet/modules/httpd
puppet apply --modulepath /etc/puppet/modules examples/init.pp

适用场景:

  • 学习与调试:不用搭 Master 即可验证 manifest 语法与逻辑;
  • 镜像打包:Packer 构建 AMI/OVA、Docker 镜像构建时执行一次 Puppet 代码固化基线;
  • Masterless 架构:不部署 Master,把代码用 Git 拉到每台机器,用 cron 定期 puppet apply(适合节点少、不想维护 Master 的场景);
  • Bootstrap:新机器初装时先跑一次 apply 完成 Puppet Agent 自身的配置,再交给 Master 管理;
  • 紧急修复:Master 故障时在单机上应急执行。

Masterless 架构示例:

#!/bin/bash
# /usr/local/bin/puppet-masterless.sh —— 由 cron 每 30 分钟调用
set -e
CODE_DIR=/opt/puppet-code

cd "$CODE_DIR"
git fetch --all --quiet
git reset --hard origin/master --quiet

/opt/puppetlabs/bin/puppet apply \
  --modulepath "${CODE_DIR}/modules:${CODE_DIR}/site" \
  --hiera_config "${CODE_DIR}/hiera.yaml" \
  --detailed-exitcodes \
  "${CODE_DIR}/manifests/site.pp" \
  --logdest /var/log/puppet-apply.log
# 用 Puppet 管理这个 cron(自举)
cron { 'puppet-masterless':
  ensure      => present,
  command     => '/usr/local/bin/puppet-masterless.sh',
  user        => 'root',
  minute      => ['3', '33'],
  environment => ['PATH=/opt/puppetlabs/bin:/sbin:/bin:/usr/sbin:/usr/bin', 'MAILTO=ops@cluster.com'],
}

12.6 与 Git 结合做代码版本管理 + r10k

为什么必须把 Puppet 代码放进 Git?

Puppet 代码就是基础设施的定义(Infrastructure as Code),必须享有和应用代码同等的工程实践:版本历史、分支、Code Review、回滚、CI 检查。

详见 14-01-Git版本控制.md

基本工作流:

# 1. 把 Puppet 代码目录初始化为 Git 仓库
cd /etc/puppetlabs/code/environments/production
git init
cat > .gitignore <<'EOF'
*.swp
*.bak
.vagrant/
spec/fixtures/modules/
secrets/*.plain.yaml
EOF
git add .
git commit -m "Initial Puppet code"
git remote add origin git@git.cluster.com:ops/puppet-control.git
git push -u origin master

# 2. 开发新功能用分支
git checkout -b feature/add-redis-module
# ... 编写模块 ...
puppet parser validate manifests/*.pp modules/redis/manifests/*.pp
puppet-lint modules/redis/manifests/
git add . && git commit -m "Add redis module"
git push origin feature/add-redis-module
# 3. 提 Merge Request → Code Review → 合并到 master

Git 分支与 Puppet 环境的映射(关键理念):

Git 分支              →   Puppet 环境
──────────────────────────────────────────────
master / production   →   production 环境
testing               →   testing 环境
development           →   development 环境
feature/add-redis     →   feature_add_redis 环境(临时测试)

这样开发者可以在自己的 feature 分支上测试(puppet agent --test --environment feature_add_redis),验证通过后合并到 production 分支,代码自动进入生产环境。

post-receive 钩子实现自动部署(简易方案):

#!/bin/bash
# Git 服务器上的 hooks/post-receive
while read oldrev newrev refname; do
  BRANCH=$(echo "$refname" | sed 's|refs/heads/||')
  TARGET="/etc/puppetlabs/code/environments/${BRANCH}"

  if [ ! -d "$TARGET" ]; then
    mkdir -p "$TARGET"
    git clone /srv/git/puppet-control.git -b "$BRANCH" "$TARGET"
  fi

  cd "$TARGET"
  git fetch --all
  git reset --hard "$newrev"
  chown -R puppet:puppet "$TARGET"
  echo "Deployed branch $BRANCH to $TARGET"
done

r10k 简述

r10k 是 Puppet 官方推荐的环境与模块部署工具,核心能力是"Git 分支 = Puppet 环境"的自动化。

# 安装
gem install r10k
/opt/puppetlabs/puppet/bin/gem install r10k
# /etc/puppetlabs/r10k/r10k.yaml
---
# 缓存目录
cachedir: '/var/cache/r10k'

# control repo(存放 Puppetfile 与 manifests 的仓库)
sources:
  main:
    remote: 'git@git.cluster.com:ops/puppet-control.git'
    basedir: '/etc/puppetlabs/code/environments'

# Forge 代理(内网环境)
forge:
  proxy: 'http://proxy.cluster.com:3128'
  baseurl: 'https://forgeapi.puppet.com'
# control repo 中的 Puppetfile —— 声明依赖哪些外部模块及版本
forge 'https://forgeapi.puppet.com'

# 从 Forge 安装,锁定版本
mod 'puppetlabs/stdlib',  '9.0.0'
mod 'puppetlabs/concat',  '7.4.0'
mod 'puppetlabs/apache',  '10.1.1'
mod 'puppetlabs/mysql',   '14.0.0'
mod 'puppetlabs/ntp',     '10.1.0'

# 从内部 Git 仓库安装(指定分支/tag/commit)
mod 'mycorp_base',
  :git    => 'git@git.cluster.com:puppet/mycorp_base.git',
  :branch => 'master'

mod 'mycorp_app',
  :git => 'git@git.cluster.com:puppet/mycorp_app.git',
  :tag => 'v2.3.1'

mod 'mycorp_monitoring',
  :git => 'git@git.cluster.com:puppet/mycorp_monitoring.git',
  :ref => 'a1b2c3d4'
# 部署所有环境(每个 Git 分支变成一个环境目录)
r10k deploy environment -p
#   -p / --puppetfile 表示同时按 Puppetfile 安装模块

# 只部署某个环境
r10k deploy environment production -p
r10k deploy environment development -p

# 只更新模块
r10k deploy module -e production stdlib apache

# 查看将要部署的环境
r10k deploy display -p

# 常见配合:Git post-receive 钩子中调用 r10k

完整的 CI/CD 流水线(GitLab CI 示例):

# .gitlab-ci.yml
stages:
  - lint
  - unit
  - deploy

syntax:
  stage: lint
  script:
    - find . -name '*.pp' -exec puppet parser validate {} \;
    - find . -name '*.epp' -exec puppet epp validate {} \;
    - puppet-lint --relative manifests/ site/
    - find . -name '*.erb' -exec sh -c 'erb -P -x -T "-" {} | ruby -c' \;
    - find . -name '*.yaml' -exec ruby -e 'require "yaml"; YAML.load_file(ARGV[0])' {} \;

rspec:
  stage: unit
  script:
    - bundle install
    - bundle exec rake spec

deploy_production:
  stage: deploy
  only:
    - master
  script:
    - ssh puppet@master.cluster.com 'r10k deploy environment production -p'

十三、扩展:puppet-dashboard 报告平台

Puppet 还有一些生态插件可课后自行研究:

  1. Web 报告查看;
  2. ActiveMQ/RabbitMQ + mcollective 实现队列高效推送;
  3. Web 管理工具 puppet-dashboard 和 foreman。

参考网站:http://www.kisspuppet.com/

下面是 puppet-dashboard 的简易配置(在 RHEL 6.5 上测试成功,未在 CentOS 7.3 上测试)。

说明:puppet-dashboard 依赖 Ruby 1.8 + Rails 2.x,属于 Puppet 3.x 时代的产物,在 CentOS 7 / Puppet 4.x+ 上已不适用。新环境请改用 Foreman 或 PuppetBoard。

13.1 安装软件包

在 puppetmaster 上:

yum install puppet-dashboard ruby-mysql mysql-server

13.2 启动 MySQL 并建库授权

/etc/init.d/mysqld restart
mysql
mysql> create database dashboard character set utf8;
mysql> grant all on dashboard.* to 'dashboard'@'localhost' identified by '123';
mysql> flush privileges;
mysql> quit

13.3 配置 database.yml

/usr/share/puppet-dashboard/config/database.yml:

production:
  database: dashboard
  username: dashboard
  password: 123
  encoding: utf8
  adapter: mysql

13.4 导入数据

cd /usr/share/puppet-dashboard/
rake gems:refresh_specs
rake RAILS_ENV=production db:migrate

验证:

mysql> use dashboard;
mysql> show tables;    -- 验证导入了 18 张表

13.5 使用 Passenger 运行 dashboard

Passenger 方式效率较高,但安装麻烦,需要用 gem 安装 ruby 相关包。

yum install ruby-devel ruby-libs rubygems libcurl-devel httpd* mod_ssl -y

gem 包位于笔记目录的 arch/puppet_gem_soft 下:

daemon_controller-1.1.7.gem
passenger-4.0.30.gem
rack-1.5.2.gem
rake-10.0.1.gem

将这四个包拷到 puppetmaster,进入拷贝目录执行:

gem list --local        # 查看已安装 gem
*** LOCAL GEMS ***

json (1.5.5)
gem install --local passenger-4.0.30.gem   # 自动解决依赖

gem list --local
*** LOCAL GEMS ***

daemon_controller (1.1.7)
json (1.5.5)
passenger (4.0.30)
rack (1.5.2)
rake (10.0.1)
passenger-install-apache2-module

确认模块生成:

ls /usr/lib/ruby/gems/1.8/gems/passenger-4.0.30/buildout/apache2/mod_passenger.so

13.6 配置虚拟主机

新建 /etc/httpd/conf.d/passenger.conf:

LoadModule passenger_module /usr/lib/ruby/gems/1.8/gems/passenger-4.0.30/buildout/apache2/mod_passenger.so
<IfModule mod_passenger.c>
   PassengerRoot /usr/lib/ruby/gems/1.8/gems/passenger-4.0.30
   PassengerRuby /usr/bin/ruby
   PassengerHighPerformance on
   PassengerMaxPoolSize 12
   PassengerPoolIdleTime 1500
   PassengerStatThrottleRate 120
 # RailsAutoDetect On
</IfModule>
Listen 8141
<VirtualHost *:8141>
        DocumentRoot "/usr/share/puppet-dashboard/public/"
        <Directory "/usr/share/puppet-dashboard/public/">
                Options None
                AllowOverride AuthConfig
                Order allow,deny
                allow from all
        </Directory>
        ErrorLog /var/log/httpd/dashboard.error.log
        LogLevel warn
        CustomLog /var/log/httpd/dashboard.access.log combined
</VirtualHost>

13.7 启动服务

/etc/rc.d/init.d/puppetmaster restart
/etc/rc.d/init.d/httpd restart

通过 firefox 访问 http://172.16.21.12:8141/,此时还没有信息。

13.8 配置 agent 自动上报

在 puppetmaster 的 /etc/puppet/puppet.conf 的 [main] 段加:

reports = http
reporturl = http://172.16.21.12:8141/reports

13.9 开启后台报告处理进程

cd /usr/share/puppet-dashboard/
rake RAILS_ENV=production jobs:work &

再访问 http://172.16.21.12:8141/ 即可看到报告,但时间默认为 UTC,需改为 CST。

13.10 修改时区

/usr/share/puppet-dashboard/config/settings.yml:

time_zone: 'Asia/Shanghai'
/etc/init.d/httpd restart

再次访问,时间显示正常。


十四、排错清单

14.1 Agent 连不上 Master

典型报错:

Error: Could not request certificate: Connection refused - connect(2) for "master.cluster.com" port 8140
Error: Could not retrieve catalog from remote server: Connection refused
Error: getaddrinfo: Name or service not known

排查流程(按顺序):

# ① 名字能否解析(最常见)
ping -c1 master.cluster.com
getent hosts master.cluster.com
cat /etc/hosts                      # 检查是否有 Master 的记录
# 修复:在 /etc/hosts 中补上 10.1.1.2  master.cluster.com

# ② 端口能否连通
telnet master.cluster.com 8140
nc -zv master.cluster.com 8140
curl -k https://master.cluster.com:8140       # 应返回 400/404 而非 refused

# ③ Master 端服务是否在跑、端口是否监听
systemctl status puppetmaster        # 或 puppetserver
lsof -i:8140
ss -tunlp | grep 8140
# 若没监听:查看日志
tail -50 /var/log/messages | grep -i puppet
tail -50 /var/log/puppetlabs/puppetserver/puppetserver.log

# ④ 防火墙是否放行
# Master 端
firewall-cmd --list-all | grep 8140
iptables -L -n | grep 8140
# 临时验证:systemctl stop firewalld

# ⑤ SELinux 是否拦截
getenforce
ausearch -m avc -ts recent | grep -i puppet
setenforce 0                          # 临时验证

# ⑥ Agent 的 server 配置是否正确
grep -n 'server' /etc/puppet/puppet.conf
puppet config print server --section agent
# 必须是主机名,不能是 IP,且必须能解析

# ⑦ 是否有代理/路由问题
env | grep -i proxy
ip route get 10.1.1.2
traceroute master.cluster.com

14.2 证书报错

14.2.1 SSL_connect / certificate verify failed

Error: Could not retrieve catalog from remote server: SSL_connect returned=1 errno=0
  state=error: certificate verify failed: [self signed certificate in certificate chain]
Warning: Unable to fetch my node definition, but the agent run will continue

原因:Agent 本地缓存的 CA 证书与 Master 当前的 CA 不匹配(Master 重装过、Master 的 ssl 目录被重建过)。

解决步骤:

# ① Master 上清除该节点旧证书
puppet cert clean agent1.cluster.com
# 4.x+
puppetserver ca clean --certname agent1.cluster.com

# ② Agent 上彻底删除本地 ssl 目录
systemctl stop puppet
rm -rf /var/lib/puppet/ssl/*                  # 3.x
rm -rf /etc/puppetlabs/puppet/ssl/*           # 4.x+

# ③ Agent 重新请求
puppet agent --test --server master.cluster.com

# ④ Master 签名
puppet cert sign agent1.cluster.com

# ⑤ Agent 再次运行
puppet agent --test --server master.cluster.com
systemctl start puppet

14.2.2 hostname 不匹配(CN 与请求名不一致)

Error: Could not retrieve catalog from remote server: hostname "master" does not match
  the server certificate (master.cluster.com)
Warning: Server hostname 'puppet' did not match server certificate; expected master.cluster.com

原因:Agent 用别名(如 puppet、master、IP)连接,但 Master 证书的 CN / SAN 中没有这个名字。

解决方案 A(推荐):Agent 改用 Master 证书的 CN 连接

# 改 puppet.conf 中的 server 为完整的 CN
sed -i 's/^\s*server\s*=.*/    server = master.cluster.com/' /etc/puppet/puppet.conf
puppet agent --test

解决方案 B:给 Master 证书加备用名(dns_alt_names),需重新生成 Master 证书

# Master 上
vi /etc/puppet/puppet.conf
# [master]
#     dns_alt_names = master.cluster.com,master,puppet,10.1.1.2

systemctl stop puppetmaster

# 只删 Master 自己的证书,保留 CA(否则所有 Agent 都要重签)
rm -f /var/lib/puppet/ssl/certs/master.cluster.com.pem
rm -f /var/lib/puppet/ssl/private_keys/master.cluster.com.pem
rm -f /var/lib/puppet/ssl/public_keys/master.cluster.com.pem
rm -f /var/lib/puppet/ssl/ca/signed/master.cluster.com.pem

# 重新生成带 alt names 的 Master 证书
puppet cert generate master.cluster.com --dns_alt_names=master.cluster.com,master,puppet,10.1.1.2
# 4.x+
puppetserver ca generate --certname master.cluster.com --subject-alt-names master,puppet,10.1.1.2

systemctl start puppetmaster

# 验证证书中的 SAN
openssl x509 -in /var/lib/puppet/ssl/certs/master.cluster.com.pem -noout -text | grep -A1 'Alternative'

14.2.3 Agent 主机名改过导致证书失效

Error: Could not request certificate: The certificate retrieved from the master does not
  match the agent's private key.

按 14.2.1 的完整重置流程处理(两端都清)。

14.2.4 证书未生效 / 已过期(时间不同步)

Error: SSL_connect returned=1 errno=0 state=error: certificate verify failed:
  [certificate is not yet valid for /CN=agent1.cluster.com]
Error: ... [certificate has expired for /CN=master.cluster.com]

排查与解决:

# ① 对比两端时间(这是根因)
date                                  # Master 和 Agent 上都执行
timedatectl                           # CentOS 7

# ② 查看证书的有效期
openssl x509 -in /var/lib/puppet/ssl/certs/agent1.cluster.com.pem -noout -dates
# notBefore=Aug 22 03:00:00 2026 GMT
# notAfter=Aug 21 03:00:00 2031 GMT

# ③ 强制同步时间
systemctl stop puppet
ntpdate ntp1.aliyun.com               # 或 chronyc makestep
timedatectl set-timezone Asia/Shanghai
hwclock -w                            # 写入硬件时钟

# ④ 启用持续同步
systemctl enable --now chronyd        # CentOS 7
chkconfig ntpd on; service ntpd start # CentOS 6

# ⑤ 若证书确实已过期(默认 5 年),需重新签发
puppet cert clean agent1.cluster.com          # Master
rm -rf /var/lib/puppet/ssl/*                  # Agent
puppet agent --test                           # Agent 重新申请
puppet cert sign agent1.cluster.com           # Master 签名

# ⑥ 检查所有证书的过期时间(提前预警)
for f in /var/lib/puppet/ssl/ca/signed/*.pem; do
  echo -n "$(basename $f .pem): "
  openssl x509 -in "$f" -noout -enddate
done

详见 08-03-时间同步与Xinetd.md

14.2.5 CSR 已存在冲突

Error: Could not request certificate: Error 400 on SERVER:
  CSR for host "agent1.cluster.com" has already been signed / already exists
# Master 上清掉旧的 CSR 与证书
puppet cert clean agent1.cluster.com
ls /var/lib/puppet/ssl/ca/requests/
rm -f /var/lib/puppet/ssl/ca/requests/agent1.cluster.com.pem
# Agent 上清本地
rm -rf /var/lib/puppet/ssl/*
puppet agent --test

14.3 Could not find class(模块路径与命名问题)

Error: Could not find class httpd for agent1.cluster.com on node agent1.cluster.com
Error: Evaluation Error: Error while evaluating a Function Call, Could not find class ::httpd

排查清单:

# ① modulepath 配置是否正确、模块是否在该路径下
puppet config print modulepath
puppet config print modulepath --section master
ls /etc/puppet/modules/

# ② 类名与文件路径是否符合自动加载规则
#    class httpd          →  httpd/manifests/init.pp
#    class httpd::config  →  httpd/manifests/config.pp
ls -l /etc/puppet/modules/httpd/manifests/init.pp
grep -n '^class' /etc/puppet/modules/httpd/manifests/init.pp
# init.pp 中的类名必须严格等于模块名

# ③ 模块名/类名是否含非法字符(横线、大写字母)
#    错误:my-module、MyModule    正确:my_module
ls /etc/puppet/modules/ | grep -- '-'

# ④ 目录/文件权限,puppet 用户能否读
ls -ld /etc/puppet/modules /etc/puppet/modules/httpd /etc/puppet/modules/httpd/manifests
chown -R puppet /etc/puppet/modules/
find /etc/puppet/modules -type d -exec chmod 755 {} \;
find /etc/puppet/modules -type f -exec chmod 644 {} \;

# ⑤ 语法错误导致类未被解析
puppet parser validate /etc/puppet/modules/httpd/manifests/init.pp

# ⑥ 环境不对:Agent 用的环境里没有这个模块
puppet config print environment --section agent
ls /etc/puppetlabs/code/environments/production/modules/

# ⑦ 4.x:environment.conf 中 modulepath 是否包含模块所在目录
cat /etc/puppetlabs/code/environments/production/environment.conf

# ⑧ 列出 Puppet 实际能看到的模块
puppet module list
puppet module list --environment production

常见根因总结:

现象 根因
类名与模块名不一致 init.pp 里写 class apache 但目录叫 httpd
文件放错位置 把 config.pp 放在 manifests/ 之外
模块名带横线 my-app 应改为 my_app
modulepath 没配 puppet.conf 的 [main] 缺 modulepath
权限问题 模块目录属主不是 puppet
环境放错 模块放在 development,Agent 用 production

14.4 Duplicate declaration(重复声明资源)

Error: Duplicate declaration: File[/etc/httpd/conf/httpd.conf] is already declared
  in file /etc/puppet/modules/httpd/manifests/init.pp:15;
  cannot redeclare at /etc/puppet/modules/webserver/manifests/init.pp:8
Error: Duplicate declaration: Package[httpd] is already declared ...

原因:同一个资源(类型 + 标题)在同一个 catalog 中被声明了两次。Puppet 要求每个资源全局唯一。

常见场景与解决:

场景 解决方案
两个模块都声明了 package { 'httpd': } 把公共资源抽到一个独立类(如 base::packages),两边都 include 它
两个模块都声明了 package { 'wget': } 用 ensure_packages(['wget'])(stdlib)或 ensure_resource('package', 'wget', {...})
define 中的资源标题没含 $name 资源标题必须写成 "/tmp/${name}.txt"
同一个类被 class {} 声明了两次 改用 include(幂等),或只在一处声明
node 块和 class 里都声明了同一资源 统一放到 class 里
用 file 和 file_line 同时管一个文件 只用一种方式
# 解决方案①:抽公共类
class base::packages {
  package { ['httpd', 'wget', 'vim-enhanced']:
    ensure => installed,
  }
}

class httpd {
  include base::packages           # 不再自己声明 package
  file { '/etc/httpd/conf/httpd.conf': ... }
}

class webserver {
  include base::packages
  # ...
}
# 解决方案②:用 ensure_resource / ensure_packages(stdlib)
class module_a {
  ensure_packages(['wget', 'curl'], { 'ensure' => 'installed' })
}

class module_b {
  ensure_packages(['wget', 'unzip'], { 'ensure' => 'installed' })  # wget 不会冲突
}

# ensure_resource 也可用于 file/user 等
ensure_resource('file', '/opt/data', { 'ensure' => 'directory' })
# 解决方案③:用虚拟资源(virtual resource),声明多次实现一次
class allusers {
  @user { 'zhangsan': ensure => present, uid => 3101 }
  @user { 'lisi':     ensure => present, uid => 3102 }
  @user { 'wangwu':   ensure => present, uid => 3201 }
}

class webserver {
  include allusers
  realize(User['zhangsan'], User['lisi'])       # 只实现需要的
}

class dbserver {
  include allusers
  User <| uid > 3200 |>                          # 用收集器按条件实现
}

定位重复声明的位置:

# 报错信息本身会给出两个文件和行号,直接去看
# 也可以搜索
grep -rn "package { 'httpd'" /etc/puppet/modules/
grep -rn 'httpd.conf' /etc/puppet/modules/ /etc/puppet/manifests/

# 在 Master 上编译某节点的 catalog 查看完整报错
puppet catalog find agent1.cluster.com --terminus compiler

14.5 依赖环(dependency cycle)

Error: Could not apply complete catalog: Found 1 dependency cycle:
(File[/etc/app.conf] => Service[app] => File[/etc/app.conf])
Try the '--graph' option and opening the resulting '.dot' file in OmniGraffle or GraphViz

定位方法:

# ① 直接看报错括号里的环,报错已给出完整链路

# ② 生成关系图
puppet agent --test --graph
ls -l /var/lib/puppet/state/graphs/
# cycles.dot  expanded_relationships.dot  relationships.dot  resources.dot

# 4.x+ 路径
ls -l /opt/puppetlabs/puppet/cache/state/graphs/

# ③ 转成图片
yum install -y graphviz
dot -Tpng /var/lib/puppet/state/graphs/cycles.dot -o /tmp/cycles.png
dot -Tsvg /var/lib/puppet/state/graphs/relationships.dot -o /tmp/rel.svg

# ④ 直接看 dot 文件内容(小规模够用)
cat /var/lib/puppet/state/graphs/cycles.dot

常见根因:

# ① 同时写了 require 和 before 指向对方
file { '/etc/app.conf':
  require => Service['app'],       # 错:配置依赖服务
  notify  => Service['app'],       # 又通知服务 → 成环
}
# 修复:删掉 require,只保留 notify

# ② 类之间双向依赖
class a { include b; Class['b'] -> Class['a'] }   # 错
class b { include a }

# ③ exec 的 require 指向了会 notify 自己的资源
exec { 'reload':
  refreshonly => true,
  subscribe   => File['/etc/app.conf'],
  require     => File['/etc/app.conf'],   # 冗余但不成环
  before      => File['/etc/app.conf'],   # 错:成环
}

# ④ 定义类型内部引用了外部会依赖自己的资源

14.6 exec 不幂等(每次运行都执行)

现象:每次 puppet agent --test 都看到同样的 exec 被执行,服务被反复重启,配置行被反复追加。

Notice: /Stage[main]/Httpd/Exec[exec-shell]/returns: executed successfully

每一次运行都出现上面这行,就说明不幂等。

解决:给 exec 加幂等守卫

# ❌ 不幂等
exec { 'exec-shell':
  cwd     => '/tmp',
  command => 'sh 1.sh',
  path    => '/bin:/sbin:/usr/bin:/usr/sbin',
}

# ✅ 用 creates:产物存在就跳过
exec { 'exec-shell':
  cwd     => '/tmp',
  command => 'sh 1.sh',
  path    => '/bin:/sbin:/usr/bin:/usr/sbin',
  creates => '/web/index.html',
}

# ✅ 用 unless:条件检查
exec { 'add-config-line':
  command => 'echo "max_conn=100" >> /etc/app.conf',
  provider => shell,
  unless  => 'grep -q "^max_conn=" /etc/app.conf',
}

# ✅ 用 onlyif
exec { 'fix-perm':
  command => 'chmod 600 /etc/app/secret',
  path    => '/bin:/usr/bin',
  onlyif  => 'test $(stat -c %a /etc/app/secret) != "600"',
}

# ✅ 用 refreshonly:只在被通知时执行
exec { 'reload-config':
  command     => 'systemctl reload app',
  path        => '/bin:/usr/bin',
  refreshonly => true,
  subscribe   => File['/etc/app.conf'],
}

# ✅ 最佳方案:根本不用 exec,改用原生资源
file { '/web/index.html':
  ensure  => file,
  content => template('httpd/index.html.erb'),
}

验证幂等性的方法:连续跑两次 puppet agent --test,第二次的输出中不应有任何 changed / executed 行。

puppet agent --test
puppet agent --test | grep -E 'changed|executed|Notice: /Stage'
# 第二次应该没有任何输出(除了 "Applied catalog in x.xx seconds")

14.7 文件权限与 SELinux 导致 file 资源失败

Error: /Stage[main]/Httpd/File[/etc/httpd/conf/httpd.conf]: Could not evaluate:
  Could not retrieve information from environment production source(s)
  puppet:///modules/httpd/httpd.conf
Error: Failed to generate additional resources using 'eval_generate': Permission denied
Notice: /File[/var/www/html/index.html]/seluser: seluser changed 'unconfined_u' to 'system_u'

排查:

# ① Master 端:模块文件是否存在、路径是否正确
# puppet:///modules/httpd/httpd.conf → /etc/puppet/modules/httpd/files/httpd.conf
ls -l /etc/puppet/modules/httpd/files/httpd.conf
# 注意 URL 中不写 files/ 这一层,这是最常见的写错点

# ② Master 端:puppet 用户能否读
sudo -u puppet cat /etc/puppet/modules/httpd/files/httpd.conf
chown -R puppet /etc/puppet/modules/
find /etc/puppet/modules -type d -exec chmod 755 {} \;
find /etc/puppet/modules -type f -exec chmod 644 {} \;

# ③ Agent 端:目标目录是否存在、是否可写
ls -ld /etc/httpd/conf
# 若父目录不存在,需先声明 file { '/etc/httpd/conf': ensure => directory }

# ④ SELinux 拦截
getenforce
ls -Z /var/www/html/index.html
ausearch -m avc -ts recent
grep -i denied /var/log/audit/audit.log | tail -20

# 临时验证
setenforce 0
puppet agent --test
setenforce 1

# 正确修复:恢复默认上下文
restorecon -Rv /var/www/html
# 或在 file 资源中显式指定
file { '/var/www/html/index.html':
  ensure  => file,
  content => "hello\n",
  owner   => 'apache',
  group   => 'apache',
  mode    => '0644',
  seltype => 'httpd_sys_content_t',
  seluser => 'system_u',
  selrole => 'object_r',
}

# 非标准目录做 DocumentRoot 时,还要放开 SELinux
selboolean { 'httpd_read_user_content':
  value      => on,
  persistent => true,
}

exec { 'semanage-web':
  command => 'semanage fcontext -a -t httpd_sys_content_t "/web(/.*)?" && restorecon -Rv /web',
  path    => '/usr/sbin:/usr/bin:/sbin:/bin',
  provider => shell,
  unless  => 'semanage fcontext -l | grep -q "^/web"',
  require => File['/web'],
}

详见 07-03-防火墙与SELinux.md

14.8 --noop 与实际执行结果不一致

现象:--noop 说会做 3 处变更,实际执行时做了 5 处,或者反过来。

原因分析:

原因 说明
exec 的不可预测性 --noop 不会真的执行 exec 的 command,因此后续依赖 exec 产物的资源状态无法预测
级联效应 例如 package 在 noop 下没真装,安装后才会出现的配置文件目录在 noop 时不存在,相关 file 资源的判断就不准
notify/refresh 链 noop 下服务不会真的重启,refresh 的连带效果看不到
动态生成的资源 eval_generate(如 recurse => true 的目录)在 noop 下枚举结果可能不同
外部状态变化 两次运行之间系统被别人改了
noop => false 覆盖 某个资源显式写了 noop => false,在全局 noop 时仍会真执行

正确的使用方式:

# ① noop 只用于"评估影响范围",不要指望 100% 精确
puppet agent --test --noop --show_diff

# ② 分阶段验证:先 noop,再在一台机器上真跑,确认无误后全量
puppet agent --test --tags httpd --noop     # 只看 httpd 相关
puppet agent --test --tags httpd            # 只执行 httpd 相关

# ③ 用退出码判断
puppet agent --test --noop --detailed-exitcodes
echo $?    # 2 = 有待变更;0 = 无变更

# ④ 对 exec 尽量写清 unless/onlyif/creates,让 noop 能正确判断

# ⑤ 单个资源强制真执行(谨慎)
exec { 'critical-fix':
  command => '/usr/local/bin/fix.sh',
  noop    => false,        # 即使全局 --noop 也会真执行
  creates => '/var/lib/fixed',
}

14.9 Agent 运行成功但没有任何变更(节点没匹配到 class)

现象:puppet agent --test 输出正常、无报错,但什么都没做:

Info: Retrieving pluginfacts
Info: Retrieving plugin
Info: Caching catalog for agent1.cluster.com
Info: Applying configuration version '1755840000'
Notice: Applied catalog in 0.03 seconds

排查清单(这是最容易困惑的问题):

# ① 【最常见】节点匹配规则的排他性
#    node 匹配只生效一个块:精确名 > 正则 > default
#    若同时存在 node 'agent1.cluster.com' {} 和 node /\.cluster\.com$/ {}
#    则 agent1 只执行精确匹配的那个块,正则块里的 include 完全不生效
grep -n '^node' /etc/puppet/manifests/site.pp /etc/puppet/manifests/nodes.pp
# 修复:把公共配置抽成 base 类,每个 node 块都 include base

# ② 节点名(certname)与 node 块里写的名字是否一致
puppet config print certname                # Agent 上
facter fqdn
puppet cert list --all                      # Master 上看实际签发的名字
# 若 certname 是 agent1(短名)而 node 写的是 agent1.cluster.com,就匹配不到

# ③ site.pp 是否被 Master 真正读取(路径是否对)
puppet config print manifest --section master
ls -l /etc/puppet/manifests/site.pp
# 4.x+:确认在正确的环境目录下
ls -l /etc/puppetlabs/code/environments/production/manifests/

# ④ Puppet 3.x:import 'nodes.pp' 是否写了、文件是否存在
grep -n 'import' /etc/puppet/manifests/site.pp
ls -l /etc/puppet/manifests/nodes.pp

# ⑤ 4.x:是否还在用已被移除的 import(会静默不生效或报错)
# 修复:删掉 import,把 nodes.pp 直接放在 manifests/ 目录下自动加载

# ⑥ Agent 用的环境里有没有代码
puppet config print environment --section agent
ls /etc/puppetlabs/code/environments/$(puppet config print environment)/manifests/

# ⑦ 在 Master 上直接编译该节点的 catalog,看里面到底有什么资源
puppet catalog find agent1.cluster.com --terminus compiler | python -m json.tool | grep '"title"'
# 若 catalog 里只有 Stage/Class[main]/Class[Settings],说明确实没匹配到任何 class

# ⑧ 用 --debug 看节点匹配过程
puppet agent --test --debug 2>&1 | grep -iE 'node|class|catalog'

# ⑨ ENC 返回空
/usr/local/bin/enc.sh agent1.cluster.com      # 手工执行 ENC 脚本看输出

# ⑩ 是否被 --tags 限制了
puppet agent --test                            # 不加 --tags 再试一次

典型修复示例:

# ❌ 错误:agent1 只会执行第一个块,include sudo 不生效
node 'agent1.cluster.com' {
  file { '/tmp/a': content => "a\n" }
}
node /\.cluster\.com$/ {
  include sudo
  include httpd
}

# ✅ 正确:把公共配置抽成类
class base {
  include sudo
  include ntp
  include hosts
}

node 'agent1.cluster.com' {
  include base
  include httpd
  file { '/tmp/a': content => "a\n" }
}

node 'agent2.cluster.com' {
  include base
  include mysql
}

node default {
  include base
}

14.10 Run of Puppet configuration client already in progress

Notice: Run of Puppet configuration client already in progress; skipping
  (/var/lib/puppet/state/agent_catalog_run.lock exists)

原因:Agent 守护进程正在运行(或上次运行异常退出留下了 lock 文件),同一时刻只允许一个 Puppet 运行实例。

处理方式:

# ① 确认是否真有 puppet 进程在跑
ps -ef | grep -v grep | grep puppet

# ② 若确实没有进程,删除残留的 lock 文件
rm -rf /var/lib/puppet/state/agent_catalog_run.lock        # 3.x
rm -rf /opt/puppetlabs/puppet/cache/state/agent_catalog_run.lock   # 4.x+

# ③ 停掉守护进程再手工运行
systemctl stop puppet
systemctl stop puppetagent
puppet agent --test --server master.cluster.com

# ④ 根据红色错误提示排错,完成后再启动服务
systemctl start puppet
systemctl start puppetagent

# ⑤ 若有 disabled 锁(被 puppet agent --disable 禁用过)
cat /var/lib/puppet/state/agent_disabled.lock
puppet agent --enable         # 解除禁用
puppet agent --disable "maintenance window"   # 主动禁用(维护窗口用)

14.11 puppet.conf 改了不生效

现象:改了 puppet.conf 中的 runinterval、server、modulepath、autosign,但行为没变。

原因与解决:

# ① 【核心】puppet.conf 修改后必须重启对应服务
systemctl restart puppetmaster       # Master(3.x)
systemctl restart puppetserver       # Master(4.x+),JVM 重启较慢
systemctl restart puppet             # Agent

# 对比:manifest / 模块代码修改【不需要】重启,因为 catalog 是实时编译的

# ② 改错了段(section)
#    server 要写在 [main] 或 [agent],写在 [master] 无效
#    autosign / reports 要写在 [master],写在 [agent] 无效
#    manifest / modulepath 要写在 [main] 或 [master]

# ③ 改错了文件(3.x 与 4.x 路径不同)
puppet config print config           # 查看实际使用的配置文件路径
# 3.x: /etc/puppet/puppet.conf
# 4.x: /etc/puppetlabs/puppet/puppet.conf

# ④ 用 puppet config print 验证参数是否真的生效
puppet config print runinterval --section agent
puppet config print server --section agent
puppet config print modulepath --section master
puppet config print autosign --section master
puppet config print all | grep -i interval

# ⑤ 用 puppet config set 修改(自动写对位置)
puppet config set server master.cluster.com --section agent
puppet config set runinterval 1800 --section agent
puppet config set autosign /etc/puppet/autosign.conf --section master

# ⑥ 语法错误(INI 格式,段名要有方括号,等号两边空格无所谓)
#    检查是否有 Tab 混用、中文全角符号、多余引号

# ⑦ environment_timeout 缓存导致代码变更不生效(4.x+)
# environment.conf 中设 environment_timeout = 0(开发)
# 或手工清缓存
curl -k -X DELETE --cert /etc/puppetlabs/puppet/ssl/certs/master.cluster.com.pem \
  --key /etc/puppetlabs/puppet/ssl/private_keys/master.cluster.com.pem \
  --cacert /etc/puppetlabs/puppet/ssl/certs/ca.pem \
  https://master.cluster.com:8140/puppet-admin-api/v1/environment-cache

14.12 日志在哪看与 --debug 的使用

日志位置汇总:

内容 Puppet 3.x Puppet 4.x+
Master 应用日志 /var/log/puppet/masterhttp.log /var/log/puppetlabs/puppetserver/puppetserver.log
Master 访问日志 - /var/log/puppetlabs/puppetserver/puppetserver-access.log
Master GC/JVM - /var/log/puppetlabs/puppetserver/puppetserver_gc.log
Agent 日志 /var/log/messages(syslog) /var/log/messages、journalctl -u puppet
报告 YAML /var/lib/puppet/reports/<certname>/ /opt/puppetlabs/puppet/cache/reports/<certname>/
关系图 /var/lib/puppet/state/graphs/ /opt/puppetlabs/puppet/cache/state/graphs/
最后一次运行摘要 /var/lib/puppet/state/last_run_summary.yaml /opt/puppetlabs/puppet/cache/state/last_run_summary.yaml
最后一次运行报告 /var/lib/puppet/state/last_run_report.yaml /opt/puppetlabs/puppet/cache/state/last_run_report.yaml
PuppetDB - /var/log/puppetlabs/puppetdb/puppetdb.log

常用查看命令:

# Agent 日志(syslog)
tail -f /var/log/messages | grep -i puppet
journalctl -u puppet -f
journalctl -u puppet --since '10 min ago'

# Master 日志
tail -f /var/log/puppetlabs/puppetserver/puppetserver.log
journalctl -u puppetserver -f
tail -f /var/log/messages | grep -i puppet-master

# 最后一次运行摘要(快速判断有多少变更/失败)
cat /var/lib/puppet/state/last_run_summary.yaml

--debug 的使用(排错最强武器):

# 完整调试输出:显示 HTTP 请求、fact 加载、每个资源的比对细节、provider 选择
puppet agent --test --debug

# 输出量很大,建议重定向后过滤
puppet agent --test --debug > /tmp/puppet-debug.log 2>&1
grep -iE 'error|warn|fail' /tmp/puppet-debug.log
grep -i 'Executing' /tmp/puppet-debug.log        # 看实际执行的命令
grep -i 'Facter' /tmp/puppet-debug.log           # 看 fact 加载
grep -i 'https://' /tmp/puppet-debug.log         # 看请求了哪些 URL
grep -i 'file_metadata' /tmp/puppet-debug.log    # 看文件分发查找过程

# 组合参数
puppet agent --test --debug --show_diff --trace --evaltrace
#   --trace     出错时打印 Ruby 调用栈
#   --evaltrace 显示每个资源的求值耗时(找慢资源)

# 只调试特定标签的资源
puppet agent --test --debug --tags httpd

# Master 端调试:直接编译某节点的 catalog
puppet master --debug --no-daemonize          # 3.x,前台跑 master
puppet catalog find agent1.cluster.com --terminus compiler --debug

# 单独测试一个资源
puppet resource file /etc/httpd/conf/httpd.conf
puppet resource service httpd
puppet resource package httpd
puppet resource user zhangsan
# 甚至可以直接修改
puppet resource service httpd ensure=running enable=true

# 查看某资源类型支持的所有属性
puppet describe file
puppet describe exec
puppet describe service --list      # 列出所有资源类型

14.13 其他常见问题

报错 / 现象 原因与解决
Error 400 on SERVER: Not authorized to call find on /file_metadata/... fileserver.conf 或 auth.conf 未放行;改用 puppet:///modules/ 三段式路径
Could not find data item xxx in any Hiera data file Hiera 键不存在且没给默认值;用 puppet lookup xxx --explain 排查层级
Failed to apply catalog: Parameter mode failed: mode must be a valid octal number Puppet 4.x 中 mode 必须写成字符串 '0644',不能写 0644 数字
Error: Could not find command 'xxx' exec 没设 path,或命令未安装;写绝对路径或补 path
Error: Systemd start for xxx failed! 服务本身启动失败;到 Agent 上 systemctl status xxx -l 和 journalctl -xe 看真实原因
Package[xxx]: Could not evaluate: Execution of '/usr/bin/yum -d 0 -e 0 -y install xxx' returned 1 yum 源问题;先在 Agent 上手工 yum install xxx 验证
Master 内存耗尽 / OOM(4.x+) puppetserver 是 JVM 应用,调 /etc/sysconfig/puppetserver 的 JAVA_ARGS
Agent 运行很慢(几十秒到几分钟) 用 --evaltrace 找慢资源;常见是 recurse => true 的大目录、ensure => latest 的包、exec 超时
WEBrick 撑不住(3.x) WEBrick 单线程只能撑约 10 个 agent,生产必须换 Passenger + Apache 或升级到 puppetserver
多个 Agent 同时打爆 Master 开启 splay = true 与 splaylimit,或错开 runinterval
Warning: Local environment: "production" doesn't match server specified node environment "development" Agent 与 ENC/Master 指定的环境不一致;统一 environment 配置
改了 fact 但 manifest 里取到旧值 Agent 端 fact 缓存或 pluginsync 未同步;rm -rf $vardir/facts.d 后重跑

练习

一、基础概念题

1. 为什么需要配置管理工具?手工 SSH 或 shell 脚本批量运维存在哪两个主要问题,如何解决?

答:手工/脚本运维的两个主要问题是:

  • 密码问题:需要在每台机器上输入密码。解决办法:用 ssh-keygen 生成空密钥做免密登录,或用 expect 自动应答密码。
  • 循环效率问题:10000 台机器每台操作 10 秒,串行需要 100000 秒。解决办法:shell 并发执行(把循环体放后台)。

除此之外,脚本方式还有一致性无法保证、不可重复执行、不可审计、系统差异难处理等更深层问题,这才是配置管理工具的真正价值所在。

2. 写一段 shell 并发执行的示例代码。

答:

#!/bin/bash

for i in `seq 10`
do
    echo $i
    sleep 1
done &          # 加后台符号与不加效果不一样,可自行测试

for j in `seq 11 20`
do
    echo $j
    sleep 1
done

3. 使用集中化配置管理工具通常需要关注哪些点?分别如何解决?

答:

关注点 解决思路
大并发 使用 MQ 消息队列
操作系统/版本差异 通过 facter 等工具获取客户端信息,按系统分支处理
异常处理 日志、报告
分组机制 按角色、正则、节点分组
Web 管理界面 dashboard
操作语言 自有的 DSL 或声明式语言

4. 列举常见的自动化运维工具。

答:Cobbler、Shell 脚本、Puppet、Ansible、SaltStack、Chef、Python 自研。

5. 什么是声明式?什么是幂等性?为什么说 exec 资源是 Puppet 中破坏幂等性的主要来源?

答:

  • 声明式:只描述"期望的最终状态"(desired state),由工具自己检查当前状态、计算差异、只做必要的修改。与之相对的命令式(如 shell 脚本)描述"一步一步怎么做"。
  • 幂等性:同一操作执行 1 次和执行 N 次的系统最终状态相同,且不产生额外副作用。例如 mkdir /test 不幂等(第二次报错),mkdir -p /test 幂等。
  • exec 破坏幂等性的原因:exec 执行的是裸命令,Puppet 无法知道这条命令做了什么、当前状态是否已经满足,因此默认每次运行(默认每 30 分钟一次)都会重新执行。必须用 onlyif、unless、creates、refreshonly 四种幂等守卫之一来约束。

6. Puppet 是什么架构?master 和 agent 各自的作用是什么?默认端口和同步间隔是多少?

答:Puppet 是 C/S(Master/Agent)架构。

  • master:管理端,集中编写配置清单(manifest),监听 8140 端口,兼任 CA;
  • agent:被管理端,周期性(默认 30 分钟,由 runinterval 控制)从 master 拉取配置并在本机应用。

管理员在 master 上写声明,由 agent 负责真正落地。

7. 简述 Puppet 的运行流程五步。

答:

  1. Agent 收集 Facts:运行 facter 采集本机 OS、版本、主机名、IP、内存等信息;
  2. 发送给 Master:通过 HTTPS(8140)把 facts 发给 Master,同时完成 SSL 双向认证;
  3. Master 编译 Catalog:根据 facts 匹配 node 定义,读取对应的 class/模块/Hiera 数据,把声明式代码编译成只属于该节点的资源清单(Catalog);
  4. 下发 Catalog:Master 把 catalog 返回给 Agent;
  5. Agent 应用并回报 Report:Agent 逐个资源比对当前状态与期望状态,只修改不一致的部分,然后生成 report 回报给 Master。

8. 为什么 Puppet 需要 SSL 证书?CA 的角色是什么?首次连接的证书签发流程是怎样的?

答:

  • 为什么需要:Master 会把整个环境的配置(可能含密码、密钥)下发给 Agent。若任何机器都能冒充某节点请求 catalog,就能窃取配置;若 Agent 不验证 Master,也可能被中间人下发恶意配置。因此需要 SSL 双向认证(mutual TLS)。
  • CA 的角色:Puppet Master 默认自带一个 CA,所有 Agent 的证书都由它签发,双方通过"都信任同一个 CA"实现互相验证。
  • 签发流程:① Master 首次启动生成 CA 与自身服务器证书;② Agent 首次运行 puppet agent --test,生成私钥和 CSR 发给 Master,同时下载 CA 证书;③ Master 上 puppet cert list 查看待签,puppet cert sign <name> 签发;④ Agent 再次运行,取回自己的证书,建立双向 SSL 连接。

9. facter 的作用是什么?

答:facter 是 Puppet 提供的一个包/命令,用于采集被管理机器的操作系统、版本、IP、内存、CPU 等信息(称为 fact)。这些 fact 作为顶层变量供 manifest 使用,管理员据此写出差异化配置(如按 $::osfamily 区分 RedHat 与 Debian,按 $::operatingsystemmajrelease 区分 CentOS 6 与 7)。

10. Puppet 3.x 与 4.x+ 的目录结构、包名、命令有哪些差异?

答:

项目 Puppet 3.x Puppet 4.x+(AIO)
配置目录 /etc/puppet/ /etc/puppetlabs/puppet/
manifest 入口 /etc/puppet/manifests/site.pp /etc/puppetlabs/code/environments/production/manifests/site.pp
模块目录 /etc/puppet/modules/ .../environments/production/modules/
SSL 目录 /var/lib/puppet/ssl/ /etc/puppetlabs/puppet/ssl/
数据目录 /var/lib/puppet/ /opt/puppetlabs/puppet/cache/
可执行文件 /usr/bin/puppet /opt/puppetlabs/bin/puppet
Master 包名/服务名 puppet-server / puppetmaster puppetserver / puppetserver
Agent 包名/服务名 puppet / puppet(或 puppetagent) puppet-agent / puppet
Web 服务器 WEBrick(测试)/ Passenger(生产) 内置 Jetty(JVM,生产可用)
证书命令 puppet cert list/sign/clean puppetserver ca list/sign/clean
import 关键字 支持(已废弃) 已移除

二、环境与安装题

11. 部署 Puppet 实验环境需要做哪些准备工作?画出实验环境规划表。

答:

角色 主机名 IP
Master master.cluster.com 10.1.1.2
Agent1 agent1.cluster.com 10.1.1.3
Agent2 agent2.cluster.com 10.1.1.4

准备要求:

  1. 主机名及互相绑定 /etc/hosts
  2. 时间同步
  3. 静态 IP
  4. 关闭 firewalld、SELinux
  5. 配置 yum 源(本地 ISO、163、EPEL、Puppet 官方源)

12. 为什么主机名和 /etc/hosts 是最常见的故障源?

答:因为 Puppet 的证书 CN 就是主机名(certname,默认 FQDN),Agent 必须用名字(不能用 IP,不建议用别名)连接 Master,Master 也要能反查 Agent 的名字。若解析不正确会导致:连接失败(getaddrinfo: Name or service not known)、证书 CN 不匹配(hostname "master" does not match the server certificate)、克隆虚拟机主机名重复导致证书冲突。所有节点的 /etc/hosts 都要写全集群的解析记录。

13. 时间同步为什么对 Puppet 至关重要?

答:SSL 证书带有生效时间(notBefore)和过期时间(notAfter):

  • Agent 时间比 Master 慢很多 → 证书"尚未生效"(certificate is not yet valid);
  • Agent 时间比 Master 快很多 → 证书"已过期"(certificate has expired)。

两种情况都导致 SSL 握手失败,且报错很容易被误判为"证书没签"。因此必须用 chrony(CentOS 7)或 ntpd(CentOS 6)保持两端时间同步,并统一时区。

14. 写出 Puppet 官方 yum 源的配置。

答:

[puppet]
name=puppet
baseurl=http://yum.puppetlabs.com/el/7Server/products/x86_64/
enabled=1
gpgcheck=0

[puppet-dependencies]
name=puppet-dependencies
baseurl=http://yum.puppetlabs.com/el/7Server/dependencies/x86_64/
enabled=1
gpgcheck=0

教学环境本地源(已将两个仓库合并):

[puppet]
name=puppet
baseurl=http://10.1.1.1/puppet
enabled=1
gpgcheck=0

15. 写出 Master 端和 Agent 端的安装与启动命令,以及如何验证端口。

答:

# Master 端
yum install puppet-server -y
systemctl start puppetmaster
systemctl enable puppetmaster
systemctl status puppetmaster
lsof -i:8140

# Agent 端
yum install puppet -y
systemctl start puppet
systemctl enable puppet
systemctl status puppet

# 部分版本 Agent 服务名为 puppetagent
systemctl start puppetagent
systemctl enable puppetagent
systemctl status puppetagent

16. Agent 与 Master 的版本有什么一致性要求?

答:Agent 的版本不应高于 Master 的版本。Master 负责编译 catalog,若 Agent 比 Master 新,可能请求 Master 不支持的 API 版本或语言特性导致失败;反之(Agent 略旧)通常兼容。同一环境内 Agent 版本应尽量统一。3.x 的 Agent 不能连 4.x 的 Master,跨大版本升级必须先升 Master 再升 Agent。

三、证书管理题

17. 完整写出 Master 与 Agent 建立连接的四步操作。

答:

# 第一步:Agent 上配置 /etc/puppet/puppet.conf 的 [main] 段
#   server = master.cluster.com

# 第二步:Agent 发起证书请求
puppet agent --server=master.cluster.com --test
# 此时 /var/lib/puppet/ssl/certs/ 下只有 ca.pem

# 第三步:Master 上查看并签名
puppet cert list
puppet cert --sign --all
# 或签单个:puppet cert sign agent1.cluster.com

# 第四步:Agent 再次运行,取回自己的证书
puppet agent --server=master.cluster.com --test
# 此时 /var/lib/puppet/ssl/certs/ 下多出 agent1.cluster.com.pem

18. 如果证书出问题需要重建连接,完整步骤是什么?

答:必须两端都清理后重新签发:

# ① Master 上清除该 Agent 的证书
puppet cert clean agent1.cluster.com
puppet cert clean agent2.cluster.com

# ② Agent 上删除本地证书
systemctl stop puppet
rm /var/lib/puppet/ssl/certs/* -rf
# 彻底重置建议删整个 ssl 目录:rm -rf /var/lib/puppet/ssl/*

# ③ Agent 重新发起请求
puppet agent --server=master.cluster.com --test

# ④ Master 重新签名
puppet cert list
puppet cert --sign --all

# ⑤ Agent 再次运行取回证书
puppet agent --server=master.cluster.com --test

# ⑥ 恢复守护进程
systemctl start puppet

19. 写出 Puppet 3.x 与 6.x 的证书命令对照表。

答:

功能 Puppet 3.x Puppet 6.x+
查看待签证书 puppet cert list puppetserver ca list
查看全部证书 puppet cert list --all puppetserver ca list --all
签发单个 puppet cert sign <name> puppetserver ca sign --certname <name>
批量签发 puppet cert --sign --all puppetserver ca sign --all
吊销清除 puppet cert clean <name> puppetserver ca clean --certname <name>

20. autosign 自动签名怎么配置?有什么安全风险?

答:

配置方式:

# 无条件自动签名(极不安全,仅限隔离实验环境)
[master]
    autosign = true

# 白名单文件(推荐)
[master]
    autosign = /etc/puppet/autosign.conf
# /etc/puppet/autosign.conf
agent1.cluster.com
*.cluster.com

安全风险:

  1. 开启 autosign = true 后,任何能连上 8140 的机器都能声称自己是任意节点名(如冒充 db-master)并拿到该节点的完整 catalog;
  2. catalog 里往往包含数据库密码、SSH 密钥、API token → 直接导致配置泄露;
  3. 攻击者可抢先注册合法节点即将使用的名字,造成正常节点无法上线(拒绝服务)。

结论:生产环境禁止 autosign = true,至少用域名白名单,最好用带预共享密钥的策略脚本,并把 8140 限制在内网。

四、Manifest 与资源题

21. 写一个 manifest,让所有节点在 /tmp/test.txt 中生成内容 hello,world。site.pp 修改后需要重启 master 吗?

答:

# /etc/puppet/manifests/site.pp
node default {                  # default 节点代表所有节点
  file { "/tmp/test.txt":       # 资源 title,未使用 path 参数时即文件路径
          content => "hello,world\n",   # 文件内容,文件不存在会自动创建
  }
}

site.pp 修改后立即生效,无需重启 master(catalog 是每次请求时现场编译的)。客户端验证:

puppet agent --test --server master.cluster.com
cat /tmp/test.txt
# hello,world

22. Agent 默认 30 分钟同步一次,如何改成 3 秒?

答:在客户端 /etc/puppet/puppet.conf 的 [agent] 段加入:

runinterval = 3

然后重启服务:

systemctl restart puppet
systemctl restart puppetagent

注意:这仅用于实验,生产环境绝不可这样设,会打爆 Master。

23. 分别为 agent1 和 agent2 在 /tmp/test.txt 中写入不同内容。

答:

node 'agent1.cluster.com' {     # 只针对 agent1,节点名用单引号
        file { "/tmp/test.txt":
                content => "hahahaha\n",
        }
}
node 'agent2.cluster.com' {     # 只针对 agent2
        file { "/tmp/test.txt":
                content => "hehehehe\n",
        }
}

24. 删除 agent1 和 agent2 上的 /tmp/test.txt。删除目录需要注意什么?

答:

node 'agent1.cluster.com' {
        file { "/tmp/test.txt":
                ensure => absent,   # 保证文件不存在,存在则删除
        }
}
node 'agent2.cluster.com' {
        file { "/tmp/test.txt":
                ensure => absent,
        }
}

删除目录需要加 force => yes,否则 Puppet 拒绝删除非空目录。

25. 用正则匹配一批节点,在它们上创建一个指向 /etc/fstab 的软链接 /tmp/fstab。

答:

# 匹配 agentX.cluster.com,X 为任意数字
# 也可写为 /^agent/ 或 /cluster\.com$/
node /^agent\d+\.cluster\.com/ {
        file { "/tmp/fstab":
                ensure => link,          # 创建软链接
                target => "/etc/fstab",  # 源文件
        }
}

26. 在所有 .cluster.com 节点上创建目录 /test/ 和文件 /test/abc(内容 haha,权限 4755,属主 bin,属组 daemon)。

答:

node /\.cluster\.com$/ {
        file { "/test/":
                ensure => directory,
        }
        file { "/test/abc":
                content => "haha\n",
                mode => 4755,
                owner => bin,
                group => daemon,
        }
}

27. 把客户端本机的 /etc/fstab 复制为 /test/abc。

答:

node /^agent\d+\.cluster\.com/ {
        file { "/test/":
                ensure => directory,
        }
        file { "/test/abc":
                source => "/etc/fstab",
                mode => 4755,
                owner => bin,
                group => daemon,
        }
}

注意:source 写本地绝对路径指的是 Agent 本机的路径。要从 Master 分发文件必须用 puppet:/// 协议。

28. 列出 file 资源的常用属性及说明。

答:

属性 说明
ensure present/absent/file/directory/link
content 文件内容(字符串)
source 源文件或 puppet:/// URL
target 软链接目标
mode 权限,如 0755、4755
owner/group 属主/属组
force 删除目录等场景需设为 yes
recurse 递归处理目录
purge 配合 recurse,删除不由 Puppet 管理的多余文件
backup 备份策略
replace false 时只在文件不存在时创建
validate_cmd 写入前用命令校验

29. 在 agent 上创建用户 user2 并生成家目录。

答:

node /^agent\d+\.cluster\.com/ {
        user { "user2":
                ensure => present,
                managehome => true,   # 创建家目录
        }
}

30. 删除 user2(连家目录)、新增 user3(uid 505,shell 为 nologin)和组 group1(gid 520)。指定 uid 时要注意什么?

答:

node /^agent\d+\.cluster\.com/ {
        user { "user2":
                ensure => absent,
                managehome => true,   # 删用户时连同家目录一起删除
        }
        user { "user3":
                ensure => present,
                uid => 505,
                shell => "/sbin/nologin",
        }
        group { "group1":
                ensure => present,
                gid => 520,
        }
}

指定 uid 时不要随意写 gid,若该 gid 的组不存在会出错。 正确做法是先声明 group 资源,并用 require => Group['xxx'] 建立依赖。

31. 在客户端创建一个 21:01 执行 /sbin/init 0 的定时任务。

答:

node /^agent\d+\.cluster\.com/ {
        cron { "cron1":                  # 注释名称
                command => "/sbin/init 0",
                user => "root",
                minute => "01",
                hour => "21",            # 21:01 执行;未写字段默认为 *
        }
}

32. 让客户端的 sshd 停止且禁止自启,httpd 运行且开机自启。

答:

node /^agent\d+\.cluster\.com/ {
        service { sshd:
                ensure => "stopped",   # 确保停止,启动了会被关闭
                enable => false,       # 相当于 systemctl disable sshd
        }
        service { httpd:
                ensure => "running",   # 确保运行,关闭了会被启动
                enable => true,        # 相当于 systemctl enable httpd
        }
}

33. 在客户端创建 /shell/1.sh 脚本(内容为创建 /test 并生成 100 个文件)并用 exec 执行。这段代码幂等吗?如何改进?

答:

node /^agent\d+\.cluster\.com/ {
        file { "/shell/":
                ensure => directory,
        }
        file { "/shell/1.sh":
                content => "mkdir -p /test\ntouch /test/{1..100}\n",
                mode => 755,
                owner => root,
                group => root,
        }
        exec { "exec-shell":
                cwd => "/shell",
                command => "sh 1.sh",
                user => "root",
                path => "/bin:/sbin:/usr/local/bin:/usr/local/sbin:/usr/sbin:/usr/bin",
        }
}

这段代码不幂等:每次 Puppet 运行都会重新执行 sh 1.sh。改进方式是加幂等守卫:

exec { "exec-shell":
  cwd     => "/shell",
  command => "sh 1.sh",
  user    => "root",
  path    => "/bin:/sbin:/usr/local/bin:/usr/local/sbin:/usr/sbin:/usr/bin",
  creates => "/test/100",         # 产物存在就不再执行
  # 或 unless => 'test -f /test/100'
}

34. 推送 yum 配置并安装 vsftpd。

答:

node /^agent\d+\.cluster\.com/ {
        file { "/etc/yum.repos.d/rhel-source.repo":
                content => "[server]\nname=server\nbaseurl=file:///yum\nenabled=1\ngpgcheck=0\n",
                mode => 644,
                owner => root,
                group => root,
        }
        package { 'install vsftpd':
                provider => yum,
                name => vsftpd,
                ensure => installed,
        }
}

更规范的写法是用 yumrepo 资源替代 file 下发 repo 文件。

35. 用 mount 资源把一个 UUID 设备挂载到 /mnt。ensure 支持哪些值?

答:

node default {
        mount { "/mnt":                        # title 即挂载点
                ensure => "mounted",           # 写入 /etc/fstab 并立即挂载
                device => "UUID=3f1de712-7864-47ea-b0b4-9fa5781b2e88",
        }
}

ensure 支持:

值 含义
defined / present 只写入 /etc/fstab,不挂载
mounted 写入 /etc/fstab 并立即挂载
unmounted 保留 fstab 条目但确保未挂载
absent 卸载并从 fstab 删除

36. 用 host 资源修改 /etc/hosts 中 agent1 的 IP 和别名。

答:

node default {
        host { "agent1.cluster.com":
                ip => "10.1.1.4",
                host_aliases => "agent2haha",
        }
}

37. Puppet 默认保证资源执行顺序吗?如何显式声明依赖?四种元参数的对应关系是什么?

答:Puppet 默认不保证执行顺序,manifest 中的书写顺序不等于执行顺序。必须显式声明依赖。

元参数 写在哪个资源上 含义 等价箭头
require => X 后执行的资源 我依赖 X,X 先执行 X -> Me
before => X 先执行的资源 我必须在 X 之前 Me -> X
notify => X 先执行的资源 我变了就通知 X 刷新 Me ~> X
subscribe => X 后执行的资源 我订阅 X,X 变了我刷新 X ~> Me

require ↔ before 互为反向,notify ↔ subscribe 互为反向。箭头符号:->(顺序)、~>(顺序 + 刷新)、<-、<~(反向,不推荐)。

38. 写出"改配置文件自动重启服务"的经典模式代码。

答:

class nginx {
  package { 'nginx':
    ensure => installed,
  }

  file { '/etc/nginx/nginx.conf':
    ensure  => file,
    content => template('nginx/nginx.conf.erb'),
    require => Package['nginx'],
    notify  => Service['nginx'],      # 内容变了才重启,没变什么都不做
  }

  service { 'nginx':
    ensure => running,
    enable => true,
  }
}

# 或用链式箭头
Package['nginx'] -> File['/etc/nginx/nginx.conf'] ~> Service['nginx']

39. 用 case 和 selector 两种方式,按 osfamily 决定 apache 的包名。

答:

# case
case $::osfamily {
  'RedHat': { $apache_pkg = 'httpd' }
  'Debian': { $apache_pkg = 'apache2' }
  default:  { fail("Unsupported osfamily: ${::osfamily}") }
}

# selector
$apache_pkg = $::osfamily ? {
  'RedHat' => 'httpd',
  'Debian' => 'apache2',
  default  => 'httpd',
}

40. 写出区分 CentOS 6 与 CentOS 7 的实用分支代码(服务管理方式、防火墙、时间同步)。

答:

case $::operatingsystemmajrelease {
  '6': {
    # CentOS 6:SysV init + iptables + ntpd
    $service_provider = 'redhat'
    $firewall_svc     = 'iptables'
    $time_pkg         = 'ntp'
    $time_svc         = 'ntpd'
  }
  '7', '8': {
    # CentOS 7/8:systemd + firewalld + chrony
    $service_provider = 'systemd'
    $firewall_svc     = 'firewalld'
    $time_pkg         = 'chrony'
    $time_svc         = 'chronyd'
  }
  default: {
    fail("Unsupported EL release: ${::operatingsystemmajrelease}")
  }
}

package { $time_pkg: ensure => installed }

service { $time_svc:
  ensure   => running,
  enable   => true,
  provider => $service_provider,
  require  => Package[$time_pkg],
}

五、类、模块与模板题

41. 什么是类?什么是模块?为什么要用模块?

答:

  • 类(class):一组资源的命名集合,可复用、可参数化。类只是"定义",必须被 include 或 class {} 声明后才会进入 catalog。
  • 模块(module):按约定目录结构组织的一个功能单元,包含 manifests(代码)、files(静态文件)、templates(模板)、lib(自定义 fact/函数)等。
  • 为什么用模块:把 site.pp 里堆积的资源按功能拆分,实现复用、独立版本管理、可从 Forge 分享下载;Puppet 的自动加载机制也依赖模块目录结构。

42. 写出一个标准模块的目录结构,并说明各目录用途和自动加载命名规则。

答:

/etc/puppet/modules/mymodule/          # 3.x
/etc/puppetlabs/code/environments/production/modules/mymodule/   # 4.x+
├── manifests/
│   ├── init.pp          # class mymodule            (模块同名类,必须在 init.pp)
│   ├── install.pp       # class mymodule::install
│   ├── config.pp        # class mymodule::config
│   ├── service.pp       # class mymodule::service
│   ├── params.pp        # class mymodule::params
│   └── vhost.pp         # define mymodule::vhost
├── files/               # 静态文件,用 puppet:///modules/mymodule/xxx 引用
├── templates/           # ERB/EPP 模板,用 template('mymodule/xxx.erb') 引用
├── lib/
│   ├── facter/          # 自定义 fact(Ruby)
│   └── puppet/          # 自定义函数/资源类型
├── examples/            # 或 tests/,使用示例
├── spec/                # rspec-puppet 单元测试
├── data/                # 模块内 Hiera 数据(Hiera 5)
├── hiera.yaml           # 模块内 Hiera 配置(Hiera 5)
├── metadata.json        # 模块元数据:名称、版本、依赖、支持的 OS
└── README.md

自动加载规则:类名 a::b::c → 文件 a/manifests/b/c.pp;类名 a(与模块同名)→ 文件 a/manifests/init.pp。文件名与类名必须严格对应,否则报 Could not find class。

43. 完成实验:编写 sudo 模块,把 master 上的 sudoers 推送到所有客户端。写出全部 8 个步骤。

答:

1)创建模块目录:

mkdir -p /etc/puppet/modules/sudo/{manifests,files/etc,templates,lib,tests,spec}

2)在 /etc/puppet/puppet.conf 的 [main] 段指定模块路径:

modulepath = /etc/puppet/modules

3)配置 /etc/puppet/fileserver.conf,允许客户端拉取文件:

[modules]
  allow *

4)编写 /etc/puppet/manifests/site.pp:

import 'nodes.pp'                          # 导入节点定义文件(3.x 语法)
$puppetserver = 'master.cluster.com'       # 定义全局变量,供模块引用

5)编写 /etc/puppet/manifests/nodes.pp:

node /^agent\d+\.cluster\.com/ {
        include sudo               # 声明 sudo 类
}

6)编写 /etc/puppet/modules/sudo/manifests/init.pp:

class sudo {
        package { sudo:
                ensure => present,
        }
        if $operatingsystem in [ "RedHat", "CentOS", "Fedora" ] {
                file { "/etc/sudoers":
                        owner => "root",
                        group => "root",
                        mode => 0440,
                        source => "puppet://$puppetserver/sudo/files/etc/sudoers",
                        require => Package["sudo"],
                }
        } else {
                fail("this module is not support $operatingsystem")
        }
}

7)把要分发的 sudoers 放进模块并授权:

cp /etc/sudoers /etc/puppet/modules/sudo/files/etc/
chown -R puppet /etc/puppet/modules/sudo/
systemctl restart puppetmaster

8)客户端验证:

puppet agent --test --server master.cluster.com

44. puppet:///modules/xxx 这种三段式路径为什么不写 files?四段式又是什么?

答:三段式 puppet:///modules/<模块名>/<相对路径> 是模块文件挂载点的简写,Puppet 会自动在模块的 files/ 目录下找,所以路径里不写 files:

source => "puppet:///modules/sudo/etc/sudoers"
# 实际文件:/etc/puppet/modules/sudo/files/etc/sudoers

四段式 puppet://<服务器>/<挂载点>/<路径> 显式指定服务器和 fileserver.conf 中定义的挂载点,早期文档中常见(如 puppet://$puppetserver/sudo/files/etc/sudoers 就是把 sudo 当挂载点用)。推荐统一用三段式,服务器由 Agent 的 server 参数决定,迁移时不用改代码。

45. source 可以写多个吗?有什么用?

答:可以写数组,Puppet 按顺序回退到第一个存在的文件,非常适合"节点专属配置优先,找不到就用通用配置":

file { '/etc/myapp.conf':
  source => [
    "puppet:///modules/myapp/conf/${::fqdn}.conf",      # 本机专属
    "puppet:///modules/myapp/conf/${::osfamily}.conf",  # 按系统
    'puppet:///modules/myapp/conf/default.conf',        # 兜底
  ],
}

46. 完成实验:编写 httpd 模块,安装 httpd、修改 DocumentRoot 为 /web、生成首页并启动服务。写出完整步骤。

答(原始实验版,7 步):

1)创建目录:

mkdir -p /etc/puppet/modules/httpd/{manifests,files/etc/httpd/conf,templates,lib,tests,spec}

2)modulepath、fileserver.conf、site.pp 同 sudo 模块实验。

3)/etc/puppet/manifests/nodes.pp:

node /^agent\d+\.cluster\.com/ {
        include httpd
}

4)/etc/puppet/modules/httpd/manifests/init.pp:

class httpd {
        package { httpd:
                ensure => present,
        }
        file { "/etc/httpd/conf/httpd.conf":
                owner => "root",
                group => "root",
                mode => 0644,
                source => "puppet://$puppetserver/httpd/files/etc/httpd/conf/httpd.conf",
                require => Package["httpd"],
        }
        file { "/web":
                ensure => directory,
        }
        file { "/shell/":
                ensure => directory,
        }
        file { "/shell/1.sh":
                content => "ip=`ifconfig eth0 | grep 'inet ' | awk '{print \$2}' | awk -F. '{print \$4}'`\necho \"web\$ip\" > /web/index.html\n",
                mode => 755,
        }
        exec { "make-index":
                cwd => "/shell",
                command => "sh 1.sh",
                path => "/bin:/sbin:/usr/local/bin:/usr/local/sbin:/usr/sbin:/usr/bin",
                require => File["/web"],
        }
        service { httpd:
                ensure => running,
                enable => true,
                require => File["/etc/httpd/conf/httpd.conf"],
        }
}

5)准备配置文件,把 DocumentRoot 与目录段改为 /web:

cp /etc/httpd/conf/httpd.conf /etc/puppet/modules/httpd/files/etc/httpd/conf/
sed -i '119,131s/\/var\/www\/html/\/web/' /etc/puppet/modules/httpd/files/etc/httpd/conf/httpd.conf

6)授权并重启:

chown -R puppet /etc/puppet/modules/httpd/
systemctl restart puppetmaster

7)客户端验证:

puppet agent --test --server master.cluster.com
curl http://10.1.1.3      # web3
curl http://10.1.1.4      # web4

生产改进版(用 ERB 模板替代 exec + awk、用 contain 拆分三段式、加 validate_cmd)见正文 11.5.2 小节。

47. ERB 模板中三种标签的区别?如何引用变量和 Facts?

答:

标签 作用
<%= expr %> 求值并输出结果
<% code %> 执行 Ruby 代码但不输出(用于 if/each)
<%# 注释 %> 模板注释,不出现在结果中
<%- -%> 去掉标签前后的空白/换行

引用变量:

<%= @port %>                        <%# 当前作用域变量、类参数 %>
<%= @fqdn %>  <%= @ipaddress %>     <%# 顶层 Facts 直接用 @ %>
<%= scope['::nginx::params::user'] %>   <%# 跨作用域取值 %>
<%= scope.lookupvar('::osfamily') %>    <%# 老写法 %>

48. 用 ERB 写一个模板,为不同节点生成含 worker 数、监听端口和 upstream 列表的 Nginx 配置。

答:

# 由 Puppet 管理,请勿手工修改
user  <%= @nginx_user %>;
worker_processes  <%= @processorcount %>;   <%# 直接用 CPU 核数 Fact %>

events {
    worker_connections  <%= @worker_connections %>;
}

http {
    include       mime.types;
    default_type  application/octet-stream;
    sendfile        on;
    keepalive_timeout  65;

<% @upstreams.each_pair do |name, members| -%>
    upstream <%= name %> {
<% members.each do |m| -%>
        server <%= m %>;
<% end -%>
    }
<% end -%>

    server {
        listen       <%= @port %>;
        server_name  <%= @fqdn %>;

<% if @enable_ssl -%>
        listen       443 ssl;
        ssl_certificate      <%= @ssl_cert %>;
        ssl_certificate_key  <%= @ssl_key %>;
<% end -%>

        location / {
            root   <%= @docroot %>;
            index  index.html index.htm;
        }
    }
}

对应的类:

class nginx (
  Integer $port               = 80,
  Integer $worker_connections = 1024,
  String  $nginx_user         = 'nginx',
  String  $docroot            = '/web',
  Boolean $enable_ssl         = false,
  Optional[String] $ssl_cert  = undef,
  Optional[String] $ssl_key   = undef,
  Hash    $upstreams          = {},
) {
  file { '/etc/nginx/nginx.conf':
    ensure       => file,
    content      => template('nginx/nginx.conf.erb'),
    validate_cmd => '/usr/sbin/nginx -t -c %',
    notify       => Service['nginx'],
  }
}

49. define 与 class 有什么区别?什么时候用 define?

答:

class define
实例个数 每节点只能声明一次 可用不同 title 声明多次
声明方式 include x / class { 'x': } x { 'title': }(像资源)
内置变量 无 $title、$name
典型用途 一个服务/组件的整体配置 需要重复创建的东西

用 define 的场景:多个 Nginx/Apache 虚拟主机、批量用户、批量 sudo 规则、批量 cron 任务。例如:

define httpd::vhost (
  String  $docroot,
  Integer $port       = 80,
  String  $servername = $title,
) {
  file { "/etc/httpd/conf.d/${title}.conf":
    ensure  => file,
    content => template('httpd/vhost.conf.erb'),
    notify  => Service['httpd'],
  }
  file { $docroot: ensure => directory }
}

# 声明多次
httpd::vhost { 'www.example.com': docroot => '/web/www', port => 80 }
httpd::vhost { 'api.example.com': docroot => '/web/api', port => 8080 }

50. 节点匹配的优先级规则是什么?如果一个节点同时能匹配精确名、正则和 default 会怎样?

答:Puppet 的节点匹配是排他的,只会命中一个 node 块,优先级:

  1. 精确的节点名(FQDN 完全相同)
  2. 正则表达式节点(多个正则都匹配时行为不确定,应避免重叠)
  3. node default

也就是说:只要有精确匹配,正则和 default 都不生效;只要有正则匹配,default 不生效。这也是"Agent 运行成功但没有任何变更"的常见原因——节点被一个空的 node 块或错误的 default 抢走了。

生产环境推荐用 Hiera + roles/profiles 或 ENC 代替大量 node 块。

六、进阶与排错题

51. Puppet 的 environment 是什么?production 和 development 目录怎么布局?

答:environment 是一套独立的代码与数据(modules + manifests + hiera),Agent 可以指定运行在哪个环境里,实现"测试环境验证后再上生产"。

/etc/puppetlabs/code/environments/
├── production/
│   ├── environment.conf
│   ├── manifests/site.pp
│   ├── modules/
│   ├── hiera.yaml
│   └── data/
└── development/
    ├── environment.conf
    ├── manifests/site.pp
    └── modules/

指定环境运行:

puppet agent --test --environment development
# 或在 puppet.conf 的 [agent] 段写 environment = development

52. Hiera 解决什么问题?写出 Hiera 5 的分层配置示例。

答:Hiera 把数据从代码中分离出来,让同一份模块代码在不同节点/环境使用不同参数,避免为每个节点复制一份 manifest。

# /etc/puppetlabs/code/environments/production/hiera.yaml
---
version: 5
defaults:
  datadir: data
  data_hash: yaml_data
hierarchy:
  - name: "节点专属数据"
    path: "nodes/%{trusted.certname}.yaml"
  - name: "按角色"
    path: "roles/%{facts.role}.yaml"
  - name: "按操作系统"
    path: "os/%{facts.os.family}-%{facts.os.release.major}.yaml"
  - name: "公共默认值"
    path: "common.yaml"
# data/common.yaml
nginx::port: 80
nginx::worker_connections: 1024

# data/nodes/agent1.cluster.com.yaml
nginx::port: 8080

Puppet 4+ 的自动参数查找(APL):类 nginx 的参数 $port 会自动去 Hiera 找 key nginx::port,无需显式调用。手工查找用 lookup('nginx::port'),调试用 puppet lookup nginx::port --explain --node agent1.cluster.com。

53. 什么是导出资源?举一个典型使用场景并写出代码。

答:导出资源(exported resources)借助 PuppetDB,让一个节点声明的资源被其他节点收集,实现节点间自动发现。需要在 Master 上开启 storeconfigs = true 和 PuppetDB。

  • 声明:@@ 前缀
  • 收集:<<| 条件 |>>

典型场景:所有 Web 节点把自己的 host 记录导出,每个节点收集全部记录,自动维护 /etc/hosts:

class base::hosts {
  # 每个节点导出自己的 host 条目
  @@host { $::fqdn:
    ip           => $::ipaddress,
    host_aliases => [$::hostname],
  }

  # 收集所有节点导出的 host 条目
  Host <<| |>>
}

其他场景:Nginx 负载均衡自动收集后端节点、Nagios 自动注册被监控主机、SSH 主机密钥自动互信(@@sshkey)、备份客户端自动注册到备份服务器。

54. Agent 报 Error: Could not request certificate: ... SSL_connect ... certificate verify failed,排查思路?

答:

  1. 时间:date 对比两端,时间偏差过大会导致证书"尚未生效"或"已过期"。用 ntpdate/chronyd 同步后重试。
  2. 主机名:Agent 连接用的名字必须在 Master 证书的 CN 或 dns_alt_names 里。若用 IP 或别名连接,需在 Master 的 puppet.conf 设 dns_alt_names = puppet,master,master.cluster.com,然后重新生成 Master 证书。
  3. 残留证书:改过主机名或重装过,两端 ssl 目录有旧证书。彻底重置:
# Master
puppet cert clean agent1.cluster.com
# Agent
rm -rf /var/lib/puppet/ssl/*        # 3.x
rm -rf /etc/puppetlabs/puppet/ssl/* # 4.x+
puppet agent --test --server master.cluster.com
# Master 再次签发
puppet cert sign agent1.cluster.com
  1. CA 不一致:Agent 的 certs/ca.pem 与 Master CA 不匹配,同样按第 3 步清理。

55. Error: Could not find class xxx 有哪些常见原因?

答:

  1. modulepath 没包含模块所在目录(puppet config print modulepath 确认)。
  2. 类名与文件路径不匹配(class a::b 必须在 a/manifests/b.pp;class a 必须在 a/manifests/init.pp)。
  3. 模块目录名带了大写、横线或版本号后缀(如 nginx-1.0),模块名只能是小写字母、数字、下划线。
  4. 文件权限问题,puppet 用户读不到(chown -R puppet /etc/puppet/modules)。
  5. 环境放错了(代码放在 production 但 Agent 跑在 development)。
  6. 3.x 里用了 import 但没导入对应文件。

56. 报 Duplicate declaration: File[/etc/xxx] is already declared 怎么处理?

答:同一资源(相同类型 + 相同 title/path)在 catalog 中只允许出现一次。处理办法:

  1. 找到重复声明的位置(报错信息会给出两处文件行号)。
  2. 把公共资源抽到一个基础类里,其他类通过 include/require 复用。
  3. 用 ensure_resource('file', '/etc/xxx', {...})(stdlib)做幂等声明。
  4. 只需要改文件里的一行时,用 file_line(stdlib)而不是整体管理该文件。
  5. 需要"允许重复但只生效一次"的语义时用虚拟资源:@user { 'tom': ... } + realize(User['tom'])。

57. 报依赖环 Found 1 dependency cycle 如何定位?

答:

puppet agent --test --graph
# 生成 /var/lib/puppet/state/graphs/*.dot
dot -Tpng /var/lib/puppet/state/graphs/cycles.dot -o cycles.png

看图找出互相依赖的资源,去掉其中一个方向的 require/notify。常见错误是同时写了 A require B 和 B notify A。

58. --noop 显示会变更,但实际运行后没变化(或反之),可能是什么原因?

答:

  • --noop 只做模拟,exec 资源不会真的执行,所以依赖 exec 产物的后续资源判断可能失真。
  • 资源被 noop => true 元参数固定为模拟模式。
  • 权限或 SELinux 导致实际写入失败(--debug 看详细报错,getenforce/ausearch -m avc 排查)。
  • 文件被 replace => false 保护,只在不存在时创建。
  • 目标节点匹配到了别的 node 块(见第 50 题)。

59. 改了 puppet.conf 但不生效,怎么办?

答:

  1. 确认改的是正确的文件:3.x 是 /etc/puppet/puppet.conf,4.x+ 是 /etc/puppetlabs/puppet/puppet.conf。用 puppet config print config 确认实际加载路径。
  2. 确认改在正确的段里:[agent] 的参数写到 [master] 里不会生效。
  3. 用 puppet config print <参数名> --section agent 验证实际取值。
  4. Master 端的多数参数需要重启服务:systemctl restart puppetmaster / systemctl restart puppetserver。
  5. 注意命令行参数优先级最高,会覆盖配置文件。

60. Agent 运行提示 Could not run: Could not create PID file 或有 lock 文件,如何处理?

答:报错通常形如:

notice: Run of Puppet configuration client already in progress;
skipping (/var/lib/puppet/state/agent_catalog_run.lock exists)

原因是已有一个 Agent 进程在运行(守护模式与手工 --test 冲突),或上次运行被强杀留下了锁文件。处理:

ps -ef | grep puppet          # 确认没有正在运行的进程
systemctl stop puppet         # 先停守护进程再手工测试
rm -f /var/lib/puppet/state/agent_catalog_run.lock
puppet agent --test --server master.cluster.com

七、动手练习

练习 A:搭建完整的 Master/Agent 环境

要求:1 台 Master(master.cluster.com,10.1.1.2)、2 台 Agent(agent1/agent2,10.1.1.3/10.1.1.4)。完成主机名、/etc/hosts、时间同步、防火墙放行 8140、安装、证书签发全流程,最后用 puppet cert list --all 证明两个 Agent 都已签发。

练习 B:编写 base 基础模块

要求实现一个 base 模块,对所有节点统一做以下事情,并能在 CentOS 6/7 上通用:

  1. 用 host 资源统一 /etc/hosts(含 master 与所有 agent);
  2. 安装并启动时间同步服务(6 用 ntpd,7 用 chronyd),配置文件用 ERB 模板生成,指向 master.cluster.com;
  3. 创建运维组 ops(gid 2000)与用户 deploy(uid 2000,属组 ops,带家目录);
  4. 下发 /etc/sudoers.d/ops,允许 ops 组免密 sudo,必须带 validate_cmd 校验;
  5. 下发 /etc/motd,内容包含主机名、IP、CPU 核数(用 Facts);
  6. 所有文件资源必须正确声明依赖,配置变更能自动重启对应服务。

练习 C:把 httpd 模块改造为生产级

以正文 11.5.1 的原始实验为起点,完成:

  1. 拆分为 httpd::params、httpd::install、httpd::config、httpd::service 四个类,用 contain + 链式箭头组织;
  2. 用 ERB 模板替代 exec + awk 生成首页(首页内容 web<IP最后一段>);
  3. 主配置文件用模板生成,DocumentRoot 可通过类参数配置,默认 /web;
  4. 定义 httpd::vhost 自定义资源类型,支持创建多个虚拟主机;
  5. 端口、DocumentRoot、虚拟主机列表全部通过 Hiera 提供,节点专属数据覆盖公共默认值;
  6. 用 puppet parser validate 和 puppet agent --test --noop 验证,确认第二次运行为零变更(幂等)。

练习 D:自定义 fact 与条件逻辑

编写一个自定义 fact app_role,规则为:主机名以 web 开头返回 web,以 db 开头返回 db,否则返回 other。然后在 manifest 中根据 $::app_role 分别安装 nginx / mariadb / 仅安装基础包。要求同时给出 Ruby 版(lib/facter/app_role.rb)和外部 fact 版(/etc/facter/facts.d/app_role.txt)两种实现。

练习 E:幂等性改造

下面这段代码存在三个问题,请指出并改写为幂等、可维护的版本:

node default {
  exec { 'add-user':
    command => 'useradd tom',
    path    => '/usr/sbin:/usr/bin',
  }
  exec { 'install-nginx':
    command => 'yum -y install nginx',
    path    => '/usr/bin',
  }
  exec { 'start-nginx':
    command => 'service nginx start',
    path    => '/sbin:/usr/sbin',
  }
}

参考答案要点:三处都该用原生资源(user/package/service)而不是 exec;exec 无守卫条件(unless/creates/onlyif)每次都会执行且第二次直接报错;资源间没有依赖声明,执行顺序不确定。改写:

node default {
  user    { 'tom':   ensure => present, managehome => true }
  package { 'nginx': ensure => installed }
  service { 'nginx':
    ensure  => running,
    enable  => true,
    require => Package['nginx'],
  }
}

练习 F:Git 管理 Puppet 代码

把 /etc/puppet/(或 /etc/puppetlabs/code/)纳入 Git 管理,创建 production 与 development 两个分支,编写 Puppetfile,用 r10k 把两个分支自动同步为两个 Puppet 环境,并验证 puppet agent --test --environment development 拉取的是 development 分支的代码。

详见 14-01-Git版本控制.md


本章小结

一、核心概念回顾

  1. 配置管理的价值:一致性、可重复、可审计、幂等。把基础设施变成代码(IaC),用版本控制管理,消除配置漂移。
  2. 声明式 vs 命令式:Puppet 是声明式的——描述"最终状态"而不是"执行步骤",由 Puppet 自己判断当前状态并决定是否需要动作。这是幂等性的基础。
  3. 幂等性:同一份代码执行 N 次结果一致,第二次运行应为零变更。exec 是幂等性最大的破坏者,必须加 unless/onlyif/creates/refreshonly 守卫,能用原生资源就不要用 exec。
  4. 架构:Master/Agent 的 C/S 模式,走 8140 端口的双向 SSL;Agent 默认每 30 分钟(runinterval = 1800)拉取一次。单机可用 puppet apply 无 Master 运行。
  5. 运行流程五步:Facter 采集 Facts → 发送给 Master → Master 编译 Catalog → 下发 Catalog → Agent 应用并回报 Report。
  6. 证书是第一道门槛:主机名解析 + 时间同步 + 证书签发,三者任一出错都连不上 Master。

二、常用命令速查表

Agent 端

命令 说明
puppet agent --test 前台运行一次,输出详细日志(最常用)
puppet agent --test --server master.cluster.com 指定 Master 运行一次
puppet agent --test --noop 模拟运行,只显示会做什么,不真的改
puppet agent --test --debug 输出调试级日志,排错必备
puppet agent --test --verbose 输出较详细信息
puppet agent --test --show_diff 显示文件变更的 diff
puppet agent --test --evaltrace 显示每个资源的求值过程与耗时
puppet agent --test --trace 出错时打印 Ruby 堆栈
puppet agent --test --tags nginx 只应用带指定 tag 的资源
puppet agent --test --environment development 指定环境运行
puppet agent --test --detailed-exitcodes 返回细分退出码(2=有变更,4=有失败,6=两者都有)
puppet agent --test --graph 生成依赖关系图(排查依赖环)
puppet agent --enable / --disable "原因" 启用/禁用 Agent 运行
puppet apply site.pp 单机模式直接应用 manifest
puppet apply -e 'notice("hi")' 直接执行一段代码

Master 端

命令 说明
puppet cert list 查看待签发的证书请求(3.x)
puppet cert list --all 查看所有证书,+ 表示已签发
puppet cert sign agent1.cluster.com 签发指定证书
puppet cert --sign --all / puppet cert sign --all 签发全部待签请求
puppet cert clean agent1.cluster.com 吊销并删除指定节点证书
puppetserver ca list --all 4.x+/6.x 查看所有证书
puppetserver ca sign --certname agent1 4.x+/6.x 签发
puppetserver ca sign --all 4.x+/6.x 全部签发
puppetserver ca clean --certname agent1 4.x+/6.x 清理证书
systemctl restart puppetmaster 重启 Master(3.x)
systemctl restart puppetserver 重启 Master(4.x+)
lsof -i:8140 / netstat -tnlp \| grep 8140 确认 Master 已监听

通用/调试

命令 说明
puppet parser validate site.pp 语法检查(改完必做)
puppet-lint site.pp 代码风格检查
puppet config print 打印全部配置项
puppet config print modulepath 打印模块路径
puppet config print config --section agent 打印实际加载的配置文件路径
puppet describe file 查看资源类型的全部属性与说明
puppet describe --list 列出所有可用资源类型
puppet resource user root 把当前系统状态反查为 Puppet 代码
puppet resource service httpd ensure=running 直接用命令行改状态
puppet module install puppetlabs-stdlib 从 Forge 安装模块
puppet module list 列出已安装模块
puppet module search nginx 搜索模块
puppet lookup nginx::port --explain 调试 Hiera 查找过程
facter / facter -p 查看全部 Facts(-p 含自定义 fact)
facter osfamily ipaddress 查看指定 Fact
puppet catalog find agent1.cluster.com 查看为某节点编译的 catalog

三、核心资源类型与属性速查表

资源 作用 关键属性
file 文件/目录/软链接 ensure(present/absent/file/directory/link)、content、source、target、mode、owner、group、recurse、purge、force、replace、backup、validate_cmd、show_diff
package 软件包 ensure(installed/present/latest/absent/purged/版本号)、name、provider(yum/rpm/gem/pip)、source、install_options
service 服务 ensure(running/stopped)、enable(true/false)、name、provider(redhat/systemd)、hasstatus、hasrestart、restart、status、pattern
exec 执行命令(慎用) command、path、cwd、user、unless、onlyif、creates、refreshonly、timeout、tries、environment、logoutput
user 用户 ensure、uid、gid、groups、shell、home、managehome、password、comment、expiry
group 用户组 ensure、gid、members
cron 定时任务 command、user、minute、hour、monthday、month、weekday、ensure、environment
host /etc/hosts 条目 ip、host_aliases、ensure、comment、target
mount 挂载点 ensure(mounted/unmounted/present/absent)、device、fstype、options、dump、pass、atboot
yumrepo yum 仓库 baseurl、mirrorlist、enabled、gpgcheck、gpgkey、descr、priority
notify 输出日志消息 message、withpath
tidy 清理旧文件 path、age、size、recurse、matches、rmdirs
ssh_authorized_key 公钥免密 user、type、key、ensure
sshkey known_hosts type、key、host_aliases
augeas 结构化改配置 context、changes、onlyif、lens
selboolean SELinux 布尔值 value、persistent
file_line(stdlib) 只改文件中的一行 path、line、match、ensure

元参数(所有资源可用)

元参数 作用
require 依赖:目标资源先执行
before 我在目标资源之前执行
notify 我变更后通知目标资源刷新
subscribe 目标资源变更后我刷新
tag 打标签,配合 --tags 选择性运行
alias 资源别名
schedule 只在指定时间窗口内执行
noop 该资源只模拟不执行
audit 审计属性变化但不修改
loglevel 该资源日志级别
stage 指定运行阶段(配合 stage 资源)

四、版本差异要点

项目 Puppet 3.x Puppet 4.x/5.x/6.x(AIO)
配置目录 /etc/puppet/ /etc/puppetlabs/puppet/
代码目录 /etc/puppet/manifests、/etc/puppet/modules /etc/puppetlabs/code/environments/production/
数据目录 /var/lib/puppet/ /opt/puppetlabs/puppet/cache/
SSL 目录 /var/lib/puppet/ssl/ /etc/puppetlabs/puppet/ssl/
日志 /var/log/puppet/ /var/log/puppetlabs/
二进制 系统 Ruby + /usr/bin/puppet /opt/puppetlabs/bin/puppet(自带 Ruby)
Master 包名 puppet-server puppetserver
Agent 包名 puppet puppet-agent
Master 服务 puppetmaster(WEBrick,需 Passenger 上生产) puppetserver(Jetty + JRuby,生产可用)
Agent 服务 puppet / puppetagent puppet
证书命令 puppet cert list/sign/clean puppetserver ca list/sign/clean
import 支持 已移除,改用目录环境自动加载
数据类型 无强类型 支持 String/Integer/Enum/Optional/Sensitive 等
Hiera Hiera 3(:backends/:hierarchy) Hiera 5(version: 5/hierarchy),支持模块层
模板 ERB ERB + EPP
项目 CentOS 6 CentOS 7
初始化系统 SysV init(service/chkconfig) systemd(systemctl)
Puppet provider redhat systemd
主机名 /etc/sysconfig/network + hostname hostnamectl set-hostname --static
防火墙 iptables(-A INPUT -p tcp --dport 8140 -j ACCEPT) firewalld(firewall-cmd --add-port=8140/tcp --permanent)
时间同步 ntp / ntpd / ntpdate chrony / chronyd / chronyc sources
网络服务 network NetworkManager + network

五、排错口诀

"名、时、证、火、类、序、幂"七字诀

  1. 名——主机名与解析先过关。Master/Agent 的 hostname -f 必须是规划好的 FQDN,/etc/hosts 双向可解析,Agent 必须能 ping 通 Master 的名字(不是只有 IP)。这是最高频的故障源。
  2. 时——时间必须同步。证书的有效期校验对时间极其敏感,偏差过大直接 certificate verify failed。
  3. 证——证书要签发且要干净。puppet cert list --all 看 +;改过主机名/重装过就两端清 ssl 目录 + puppet cert clean 后重来。
  4. 火——防火墙与 SELinux。Master 放行 8140,必要时先 setenforce 0 定位是否 SELinux 拦截,确认后用 semanage/restorecon 正规解决而不是长期关闭。
  5. 类——找不到类看路径。Could not find class 先查 modulepath、类名与文件名的对应关系、模块目录名是否合法、puppet 用户能否读取。
  6. 序——顺序靠声明不靠书写。默认无序,必须用 require/before/notify/subscribe 或 ->/~>;出现依赖环用 --graph + graphviz 出图定位。
  7. 幂——第二次运行必须零变更。跑两遍 puppet agent --test,第二遍还有 notice: ... changed 就说明有资源不幂等,重点查 exec。

通用排错三板斧

# 1. 语法先过
puppet parser validate /etc/puppet/manifests/site.pp

# 2. 模拟看差异
puppet agent --test --noop --show_diff

# 3. 调试看细节
puppet agent --test --debug --evaltrace --trace

日志位置

内容 3.x 路径 4.x+ 路径
Master 日志 /var/log/puppet/masterhttp.log、/var/log/messages /var/log/puppetlabs/puppetserver/puppetserver.log
Agent 日志 /var/log/messages(syslog) /var/log/messages、journalctl -u puppet
运行锁文件 /var/lib/puppet/state/agent_catalog_run.lock /opt/puppetlabs/puppet/cache/state/agent_catalog_run.lock
上次运行摘要 /var/lib/puppet/state/last_run_summary.yaml /opt/puppetlabs/puppet/cache/state/last_run_summary.yaml

六、生产实践建议

  1. 代码进 Git,用分支对应环境,配合 r10k 自动部署,任何变更走 MR/PR 评审。
  2. 数据与代码分离,参数一律走 Hiera,敏感数据用 hiera-eyaml 加密。
  3. 禁用 autosign = true,用域名白名单或带预共享密钥的策略脚本。
  4. 少用 exec,优先原生资源和 augeas/file_line;必须用时一定加守卫条件。
  5. 上线前必跑 --noop,并在测试环境先验证;关键变更用 --tags 缩小影响范围。
  6. 模块化 + roles/profiles 分层,避免在 site.pp 里堆节点定义。
  7. 配置文件模板化,配合 validate_cmd 校验语法,防止推错配置导致服务起不来。
  8. 接入报告系统(PuppetDB + Foreman/PuppetBoard),做到变更可审计、节点状态可视化。
  9. runinterval 保持默认或适当拉长,大规模环境务必开启 splay 打散请求,避免瞬时压垮 Master。
  10. 写 rspec-puppet 测试 + puppet-lint 检查,纳入 CI 流水线。

详见 14-01-Git版本控制.md 详见 14-02-Ansible自动化部署.md 详见 14-04-SaltStack配置管理.md

最后修改:2026 年 09 月 20 日
如果觉得我的文章对你有用,请随意赞赏