很多人把 Puppet 看成简单的服务器脚本分发器——如果你也这么想,那你绝对被它那张声明式的“伪代码”脸骗了。Puppet 真正的核心,是一套基于资源依赖图的有向无环图(DAG)遍历引擎。说穿了,它做的事就是:把你写的那些看着像 JSON 又不是 JSON 的清单文件(manifest),编译成一个描述系统状态的图,然后像玩多米诺骨牌一样,按拓扑顺序一块块推倒。
编译过程有多折腾人呢?Agent 端把 facts 打包丢给 Master,Master 的 parser 先搞出 AST(抽象语法树),再转成 Puppet 内部的中介表示(相当于你写 Java 代码被 javac 搞出的字节码),最后才生成目录(catalog)——一个序列化的资源图。这中间有至少三次语法树变形。说实话,跟 GCC 的 IR 优化一样魔幻。
我第一次手撕 Puppet 源码(用 Ruby 写的,配合 C++ 扩展的那些部分)的时候,被那段 resource_type 的冲突消解逻辑恶心到吃不下饭。它用了一个三阶段求值模型:定义阶段负责语法糖解糖(比如把“require”虚拟声明转换为实际的元参数),实例化阶段处理类继承和变量作用域嵌套,最后在评估阶段才真正执行 provider 调用。这用学术圈的话叫“惰性具体化”——说白了就是“不见兔子不撒鹰”,不到最后一步绝不碰实际的系统命令。

幂等性不是魔法:收敛引擎里藏着一个“diff”内核

都说什么“幂等性”,听着玄乎。Puppet 实现幂等的机制,本质上就是对每个资源做一次 IS/Should-be 的状态差分。拿最常用的文件资源来说——你想确保/etc/ntp.conf 的内容和模板一样对吧?别以为它傻到直接覆盖。Puppet 的 file provider 会先算 MD5(现在可以选 SHA256,如果你在 puppet.conf 里设了 digest_algorithm),然后把算出来的哈希和 catalog 里记录的 content 属性做比对。只有哈希不一致的时候,才触发写入。这就避免了无意义的磁盘 I/O,也防止每次运行都把 mtime 改得乱七八糟,触发下游监控报警。
但这个 diff 引擎有一个极其隐蔽的性能缺陷:对于大文件,它默认会一次把整个文件内容写入内存计算哈希。我曾经在一个日志归档场景里,单文件 4GB,Agent 直接 OOM。当时我还以为是自己写得不对,查半天文档,才发现 file 资源的 checksum 参数可以设成 mtime,或者干脆用 replace => false 跳过内容管理——但这就违背了配置管理的初衷。后来我的解法是把大文件拆分到多个小资源里用 concat 模块拼起来,只在拼接点做哈希校验。类似地把状态空间降维了。
图算法不是免费的:当资源数破万,你就是 C 的上帝
Puppet 的依赖解析用的是Kahn 算法变种的拓扑排序(其实更像 Tarjan 强连通分量检测的味道,为了找循环依赖)。时间复杂度 O(V+E)。但问题来了:V 和 E 在哪里?V 就是你的资源总数,E 是依赖关系数量。在大型集群里,一个 catalog 轻松破 5000 个资源,E 更是恐怖,因为每个 require、notify 都会加边。我见过一个 catalog 编译消耗 40 秒以上的案例,追踪下来发现85% 的时间耗在 relationship_graph.rb 里的邻接表遍历上。
性能对比触目惊心:我们用同一套 100 节点的基础设施,分别用 Puppet 3.8 和老对手 SaltStack 做比对。在保证同样逻辑(安装 Nginx + 配置虚拟主机 + 部署应用包)的情况下,Puppet 的 catalog 编译平均 2.3 秒,Salt 的 state.highstate 只要 0.8 秒。但 Puppet 胜在收敛过程的原子性——Salt 的并行执行容易产生资源竞态,比如两个状态同时操作同一个 yum 锁,而 Puppet 通过单线程的顺序执行彻底避免了这类加锁开销。工程上这叫“用编译时间换运行确定性”,没什么对错,看你要什么。

实际压测中还有一个反直觉的发现:不要迷信并发。Puppet 从 4.x 开始引入了环境级别的评估并行,在 master 端用 JRuby 多线程处理多个节点的 catalog 编译请求。但我们的压测表明,当并发 Agent 数超过 500 时,master 的 JRuby 实例池的内存碎片导致 GC 停顿长达 15 秒/次。这还不如用 Nginx 负载多个独立 master,每台串行处理 50 个 Agent——反而总吞吐量更高。看图说话吧:
单 Master(800 Agent):吞吐 35 cat/min,平均延迟 22s所以架构选型不是越新越好,得看你们的 Agent 数量和目录复杂度。
双 Master(各 400 Agent):吞吐 68 cat/min,平均延迟 11s
落地磨了三年才摸清的三个血坑

坑一:Hiera 数据绑定在你眼皮底下偷偷搞递归。当你天真地写出 lookup('my_class::param', {'merge' => 'deep'}) 时,Puppet 会把所有层级的数据做深度合并——对哈希没问题,但如果你不小心在数据里塞了个数组,它默认会用“唯一合并”,把多个层级的数组去重拼接。这直接导致我在生产环境里搞丢过防火墙端口号(因为有的层级定义了 [80, 443],有的定义 [443, 8080],合完剩 [80, 443, 8080],看着没错,但某些服务的端口优先级被覆盖了)。解决方案:强制用 merge => 'first' 或者自己写 lookup_options 里的策略函数,不要依赖默认。
坑二:file资源 + notify => 触发服务的连锁雪崩。经典的配置更新触发服务重启:如果 100 个配置文件都是 notify => Service[‘httpd’],那么每次哪怕只有一个文件改了,httpd 都会被刷新一次。看似没啥,但如果 httpd 重启慢(比如加载大内存模型),就可能导致服务端积压重启请求。我们一次事故是改了 SSL 证书,触发全集群滚动重启,结果后几个节点因为 Puppet 运行间隔错开,每隔 30 分钟就重启一遍 Apache,业务抖动了三小时。教训:用 subscribe 替代 notify 有时更精细,或者用资源链语法在逻辑上收束触发点。
坑三:环境隔离下的模块代码缓存污染。Puppet 4 以后支持环境目录里的 lib/ 和 types/ 来写自定义函数和类型。听起来很香对吧?但当一个模块在多个环境下存在不同版本时,Puppetserver 的 JRuby 进程如果没重启,会缓存之前加载的旧代码,导致运行时异常。这个问题被社区讨论了三年之久,最终的稳定方案是:在每台 Agent 的定时任务里加一步清除环境的 r10k 部署,同时 Puppetserver 必须配置 environment-timeout = 0(或者用代码管理器的话,直接 unlimited 但配合垃圾回收)。零超时意味着每次请求都要重新读环境,性能会下降 5-15%,但换来的是绝对的代码一致性,划得来。
技术搞到最后,都是在跟这些不起眼的小概率故障死磕。Puppet 不会过时,因为它解决问题的范式——通过静态声明收敛动态系统——至今仍是分布式运维领域里最优雅的工程抽象之一。至于它的 Ruby 血统、Master/Agent 架构笨重这些,那是另一场关于“演进还是推翻”的讨论了。