配置管理工具用久了,你会发现一个说不上来、但确实存在的“痒点”:内置资源很好用,可一旦遇到组合拳式的重复劳动,你就开始怀疑人生。比如,每次部署一个静态站点,都要三步走:建目录、写首页、重载Nginx。这三步拆开来看都很简单,可你要是部署十几个站点,手写十几个重复块,不仅烦,还容易出错。Chef的自定义资源,就是来治这个痛点的。它让你把“建目录、写首页、重载服务”装进一个自己命名的小盒子里,以后每次只喊一声盒子名,剩下的流程自动跑。
一、先说说Chef里“资源”是个啥
Chef里的“资源”其实是一段声明式配置,它描述“我们希望服务器达到什么状态”,而不是“怎么一步步达到”。比如,我们希望服务器上有一个文件,那就写一个file资源;希望有一个目录,就写directory资源;希望某个服务运行着,就用service资源。每一个资源都有自己的类型、名称和属性,Chef的引擎会按照声明去检查服务器现状,如果不符合,就动手修正到目标状态。
这种思路跟我们平时写脚本很不一样,脚本强调的是“执行步骤”,而声明式强调“目标状态”。好处是,同一套代码在全新服务器和已经半配置的服务器上都能得到相同结果。坏处是,一但你发现某个“目标状态”需要用七八个内置资源搭配才能实现,那recipe里的代码就会又臭又长。
1.1 资源的基本长相
来看一个最简单的file资源。我们想确保服务器上有一个路径为/tmp/hello.txt的文件,内容固定为Hello:
# 技术栈:Ruby(Chef 专属 DSL)
# 声明一个文件资源
file '/tmp/hello.txt' do
content 'Hello' # 文件内容
mode '0644' # 权限是 644
end
这段代码放在recipe里,Chef执行时会检查/tmp/hello.txt是否存在、内容是否一致。如果一切正常,它什么都不做;如果缺少或内容不对,它会创建或覆盖。这种“不折腾”的幂等性,正是配置管理工具的魅力。
但问题来了:如果每部署一个站点,你都要先写一个directory,再写一个file,再写一个service,那recipe里全是这些底层小块。而且稍微改一个参数,可能要动三四处地方。于是,自定义资源登场了。
二、为什么要自己造“资源”
想象你在厨房做饭。内置资源就像是生食材:西红柿、鸡蛋、盐、油。你当然可以每次自己切、自己炒,但切西红柿的姿势、打蛋的手法每次都不一样,味道全靠手感。自定义资源就好像一道菜谱,它把“西红柿炒蛋”这个目标封装起来,你只需要说一句“来一份西红柿炒蛋”,后面切、炒、放盐全都按固定流程走。你甚至可以给菜谱加参数,比如“多放糖”或“不放葱”。
在团队协作中更是如此。运维A写了一个复杂的“部署Java应用”的资源,运维B只需要引用这个资源名,并给几个参数,就能复用整套部署逻辑,不必关心内部是用了tomcat还是jar包,更不需要去copy几十行代码。
2.1 适用场景先瞥一眼
自定义资源并不是用来炫技的,适合它的场景大概有三类。第一类:同一个操作在多处出现,且步骤有好几步,比如创建虚拟主机、初始化环境。第二类:这个操作包含很多潜在细节,你希望对外隐藏复杂度,比如“部署一套带监控、日志、备份的服务”。第三类:你想给团队提供一套统一的内部“方言”,大家按照约定好的名称和属性来声明基础设施。
三、从零写第一个自定义资源
我们拿“部署一个静态网站”来练手。假设目标是用Nginx托管一个静态页面,网站目录是/var/www/blog,首页是index.html,部署完成之后还要帮我们重载Nginx,让配置立即生效。这个过程写起来很杂,所以我们把它包成一个叫“static_site”的自定义资源。
3.1 文件放哪儿
自定义资源不是写在recipe里的,而是放在cookbook的resources目录下。比如你有一个叫web_site的cookbook,那么就在web_site/resources/static_site.rb里写这个资源。Chef在加载时会自动找到它,然后recipe里就可以直接使用“static_site”这个关键字了,就像使用内置file、service一样。
3.2 自定义资源代码
我们打开static_site.rb,从定义属性开始。资源名就叫static_site,属性有站点名、首页文件名、根目录、属主、属组。然后定义两个动作:create用来创建站点,delete用来删除站点。
# 技术栈:Ruby(Chef 专属 DSL)
# 文件路径:cookbooks/web_site/resources/static_site.rb
# 站点名取资源名,比如 static_site 'blog' 里的 blog
property :site_name, String, name_property: true
# 首页文件名,默认是 index.html
property :homepage, String, default: 'index.html'
# 站点根目录,不设置的话我帮你算出来
property :root_path, String
# 文件属主
property :owner, String, default: 'www-data'
# 文件属组
property :group, String, default: 'www-data'
# 创建站点的动作
action :create do
# 先声明好 Nginx 服务,后面文件通知它做 reload
service 'nginx' do
action :nothing
end
# 如果没有给 root_path,就默认使用 /var/www/<站点名>
actual_root = root_path || "/var/www/#{site_name}"
# 创建根目录,recursive 表示父目录不存在时一并创建
directory "创建站点根目录 #{site_name}" do
path actual_root
owner owner
group group
mode '0755'
recursive true
end
# 生成首页文件
file "生成首页 #{site_name}" do
path File.join(actual_root, homepage)
content "<html><body><h1>Welcome to #{site_name}!</h1></body></html>"
owner owner
group group
mode '0644'
# 文件发生变化时,立刻通知 Nginx 重载
notifies :reload, 'service[nginx]', :immediately
end
end
# 删除站点的动作
action :delete do
service 'nginx' do
action :nothing
end
actual_root = root_path || "/var/www/#{site_name}"
# 先删首页,再删目录
file "删除首页 #{site_name}" do
path File.join(actual_root, homepage)
action :delete
notifies :reload, 'service[nginx]', :delayed
end
directory "删除站点根目录 #{site_name}" do
path actual_root
recursive true
action :delete
end
end
这段代码里,你可能注意到两个细节。一是我们用service 'nginx'声明了一个什么都不做的服务资源,但后面的file资源用通知去触发它。这个“通知”是Chef的核心编排方式,意思是:当file真的修改了内容,才去执行service的reload;如果文件没变,就不需要reload。二是我们在自定义资源内部可以使用Ruby的普通代码,比如给root_path设置默认值,这让资源变得很有弹性。
3.3 在recipe里使用它
resource文件写完,recipe里用它就非常简单。比如我们要创建两个站点,再删掉一个已经不用的旧站点:
# 技术栈:Ruby(Chef 专属 DSL)
# 文件路径:cookbooks/web_site/recipes/default.rb
# 创建博客站点
static_site 'blog' do
root_path '/var/www/blog'
end
# 创建文档站点
static_site 'docs' do
root_path '/var/www/docs'
end
# 旧的 legacy 站点不再需要,删掉
static_site 'legacy' do
action :delete
end
你看,声明式的味道出来了。每个站点只需要一行资源声明,再加上必要的参数,背后的目录、文件、通知全部被封装。这就是自定义资源的直接收益:你的recipe从“步骤说明书”变成了“愿望清单”。
四、复杂配置场景实战:应用配置合并与生成
第一个例子偏“流程化”,下面我们来看一个更复杂的场景:多环境的应用配置管理。假设我们维护一个Java应用,叫作myapp。它依赖数据库、Redis,还有一个自身调优用的app.yml。生产环境和预发布环境的数据库地址、连接池大小、日志级别都不一样。传统做法是在recipe里写一堆file资源,然后复制粘贴,区别只是路径和内容,非常痛苦。
自定义资源能够优雅地解决这个问题。我们做一个名叫app_config的资源,它接收一个Hash,里面记录“文件名”和“配置内容”的对应关系,它会自动为每个文件创建目录、写入内容,并在内容变更时重启应用服务。
4.1 先定义自定义资源
在cookbook的resources目录下新建app_config.rb。我们要用到JSON库,所以开头require一下。
# 技术栈:Ruby(Chef 专属 DSL)
# 文件路径:cookbooks/app_setup/resources/app_config.rb
require 'json' # 用 JSON 来生成可读的配置文件
# 应用名,同时作为服务名
property :app_name, String, name_property: true
# 环境名,比如 production / staging
property :environment, String, required: true
# 配置根目录,默认在 /etc/app_configs
property :config_dir, String, default: '/etc/app_configs'
# 文件属主和属组
property :user, String, default: 'appuser'
property :group, String, default: 'appuser'
# 文件权限,默认0640
property :mode, String, default: '0640'
# config_data 是一个 Hash,格式如下:
# {
# 'database.yml' => { 'host' => 'xxx', 'port' => 5432 },
# 'redis.yml' => { 'host' => 'xxx', 'port' => 6379 }
# }
property :config_data, Hash, required: true
action :create do
# 预先把服务声明出来,等通知
service app_name do
action :nothing
end
# 遍历每一个文件名和对应的配置内容
config_data.each do |filename, settings|
# 配置文件放在环境子目录下,避免不同环境互相覆盖
full_path = File.join(config_dir, environment, filename)
# 确保这个文件所在的目录存在
directory "创建配置目录 #{app_name} - #{filename}" do
path File.dirname(full_path)
owner user
group group
mode '0755'
recursive true
end
# 把配置对象写成漂亮的JSON字符串
file "写入配置文件 #{app_name} - #{filename}" do
path full_path
content JSON.pretty_generate(settings)
owner user
group group
mode mode
# 内容有变化时,延迟重启应用,把所有配置文件都更新完再启动
notifies :restart, "service[#{app_name}]", :delayed
end
end
end
这里有几个常见的Chef细节需要解释。service后面的app_name是Ruby变量,会替换成实际的资源名。通知使用延迟模式,意思是如果多个配置文件都发生了变更,会在整个Chef运行的最后统一重启一次服务,而不是改一个重启一次,这样能避免应用起起伏伏。
4.2 在recipe里合并不同环境的配置
资源写好了,调用之前,我们得把不同环境的配置整理好。先定义一份公共默认配置,再定义每个环境的覆盖项,最后做一次“深度合并”。如果只是简单地用merge,嵌套的Hash会被整体替换,所以需要一个能递归合并的方法。
# 技术栈:Ruby(Chef 专属 DSL)
# 文件路径:cookbooks/app_setup/recipes/deploy.rb
# 公共默认配置
defaults = {
'database.yml' => { 'host' => 'localhost', 'port' => 5432, 'pool' => 5 },
'redis.yml' => { 'host' => 'localhost', 'port' => 6379 },
'app.yml' => { 'log_level' => 'info', 'features' => { 'beta' => false } }
}
# 根据当前环境得到覆盖项
case node['app_env']
when 'production'
env_override = {
'database.yml' => { 'host' => 'db-prod.internal', 'port' => 5432, 'pool' => 20 },
'redis.yml' => { 'host' => 'redis-prod.internal', 'port' => 6379 },
'app.yml' => { 'log_level' => 'warn', 'features' => { 'beta' => false } }
}
when 'staging'
env_override = {
'database.yml' => { 'host' => 'db-staging.internal', 'port' => 5432, 'pool' => 10 },
'app.yml' => { 'log_level' => 'debug', 'features' => { 'beta' => true } }
}
else
# 本地开发环境,直接用默认配置
env_override = {}
end
# 递归合并函数:两个值都是Hash时,合并它们;否则用新的覆盖旧的
deep_merge = lambda do |default_val, override_val|
if default_val.is_a?(Hash) && override_val.is_a?(Hash)
default_val.merge(override_val) { |_key, d, o| deep_merge.call(d, o) }
else
override_val
end
end
# 生成最终的配置:默认配置和环境覆盖合并
final_config = defaults.merge(env_override) { |_key, d, o| deep_merge.call(d, o) }
# 调用自定义资源
app_config 'myapp' do
environment node['app_env'] || 'development'
config_data final_config
end
这里的node属性中的app_env字段就是Chef的节点属性,它可以通过多种途径配置,比如角色文件、环境文件,或者直接在节点上设置。复杂环境中,你只要在不同节点上把app_env设成不同值,同一份recipe就能自动适配。
这个示例展示了自定义资源和Ruby语言能力结合时的强大。你可以写循环、条件判断、递归函数,把配置逻辑整理得井井有条,而不需要去堆砌几十个file资源。
4.3 补充:配置内容的另一种选择
上面的示例用的是JSON格式,实际场景中很多应用更喜欢YAML。如果你需要生成YAML配置,修改起来也很简单:在资源文件开头引入YAML库,把写文件内容那行替换成YAML序列化方法,这样落盘的就是YAML格式。Chef并不会强制限制输出格式,只要最终内容符合应用要求就行。这种自由组合的能力,正是自定义资源让人上瘾的地方。
五、自定义资源里的一些小机关
写自定义资源的时候,有几个细节值得留意。这些技巧能让你写出的资源更健壮,也更像“正经货”。
5.1 属性的校验与别名
除了必填和默认值,Chef还允许你给属性设置允许的值列表。比如,某个属性只能接收“fast”和“slow”两个值,可以用等值校验:
# 技术栈:Ruby(Chef 专属 DSL)
property :speed, String, equal_to: ['fast', 'slow'], default: 'fast'
还可以用状态开关来声明某个属性并不参与收敛判断。什么意思呢?比如某个仅用于计算的值,它不会影响资源是否发生变化。这对于那些“改一下不影响服务重启”的属性很有用。
5.2 幂等性与条件执行
幂等性几乎是配置管理的生命线。如果你写了一个自定义资源,每次运行都会触发通知或者执行一些不确定的动作,那用户很快就会恨你。要保证幂等,一方面尽量使用内置资源,让内置资源的收敛逻辑来帮忙;另一方面,对于无法用内置资源表达的临界情况,可以加上条件执行。
比如,某个配置文件只有在新版本安装之后才需要生成,那就可以这样写:
# 技术栈:Ruby(Chef 专属 DSL)
file '/opt/myapp/config.yml' do
content 'new config'
only_if { ::File.exist?('/opt/myapp/version.txt') }
end
这个条件判断会在执行之前先检查,条件不成立就直接跳过这个文件操作。这样一来,无论Chef运行多少次,都不会产生“多余的动作”。
5.3 让动作更灵活:分支与动态资源
有时我们想在一个自定义资源内部,根据属性动态创建不同类型的资源。比如,根据安装方式属性来决定调用包管理器还是下载二进制包。这个技巧在安装类资源里非常常见,可以让一个资源同时支持多种安装路径。
# 技术栈:Ruby(Chef 专属 DSL)
property :install_method, String, default: 'package'
action :install do
if install_method == 'package'
package 'myapp' do
version '1.2.3'
end
elsif install_method == 'binary'
remote_file '/opt/myapp.tar.gz' do
source 'https://example.com/myapp.tar.gz'
end
end
end
这里用分支就足够灵活。真正的动态声明资源适合更复杂的场景,比如资源类型也要作为参数。不过,日常需求大多数时候用条件判断就能解决,所以不用急着学。
六、什么时候该用自定义资源
不是所有场景都值得封装。我建议你从这三个信号来观察:
第一,重复。你已经第三次在三个不同recipe里写同样的一组资源,而且每次只是改了一两个参数。第二,复杂。某个部署流程需要十步以上,中间还夹杂着不少条件判断、通知和文件生成,把这些都摊在recipe里会让阅读的人头皮发麻。第三,需要统一规范。团队里好几个人都要做类似的事,但各自写法不一样,运维规范难以落地。这时候用一个自定义资源把“正确做法”锁死,其他人只能按这个口子来,质量自然就上去了。
反过来,如果只是很简单的单资源操作,或者这个操作只在一个地方出现,那强行封装反而会增加理解成本,得不偿失。
七、优缺点都得说清楚
7.1 优点
自定义资源最大的优点是把“过程”藏起来,只留“声明”。你只需要关心想要什么,而不是怎么做。这让recipe代码变得极短,短到扫描一眼就能抓住重点。第二个优点是复用性,同一个自定义资源可以被多个cookbook共享,甚至通过社区分发。第三个优点是易测性,Chef官方推荐配合ChefSpec来做单元测试,你可以直接验证自定义资源在不同属性下是不是会执行预期的子资源。第四个优点是它天然比用普通函数更贴合Chef的声明式理念,因为资源有动作、属性、通知、幂等,用起来更自然。
7.2 缺点
缺点也很明显。第一,学习曲线陡。你得懂Ruby,又得懂Chef的资源收敛机制,否则写出来的资源可能在幂等的边缘疯狂试探。第二,调试困难。自定义资源里的错误信息有时候不如内置资源友好,尤其是当多个子资源相互通知时,要理清执行顺序得花不少功夫。第三,滥用会带来维护负担。一个团队里如果每个人都写一套自定义资源,反而会变成大杂烩。所以自定义资源也需要像代码一样做评审、定规范、保持精简。
八、最后聊两句
Chef的自定义资源,说到底是在“DSL”这套语法上开了一道自定义出口。它允许你把你对基础设施的理解,封装成让所有人都能轻松使用的高层动词。这和写函数、写类是一个道理:先抽象,再复用,只是它服务的对象是服务器上的配置。
鼓励你在自己的Chef项目里试一试。先从最小的重复开始,写一个只包含两个步骤的自定义资源,然后在下一个cookbook里重新用一下。你会逐渐找到那种“原来还能这么玩”的成就感。等到你真正熟练Ruby DSL之后,复杂的多环境配置、服务编排、应用部署,都会变得像写购物清单一样轻松。
评论
围绕“Chef基于Ruby DSL的自定义资源编写及复杂配置场景的实现方法”参与讨论