Chef

  1. 1. Chef 概述
    1. 1.1. Chef 架构组件
      1. 1.1.1. Chef Infra
      2. 1.1.2. Chef Workstation
      3. 1.1.3. Chef Server
      4. 1.1.4. Chef Client
    2. 1.2. Chef 工作模式对比
  2. 2. Chef 常用资源与模块
    1. 2.1. Cookbook 文件结构
    2. 2.2. Cookbook 核心配置文件
      1. 2.2.1. metadata.rb - Cookbook 元数据
      2. 2.2.2. Berksfile - 依赖管理
      3. 2.2.3. attributes/default.rb - 属性定义
      4. 2.2.4. kitchen.yml - 测试配置
    3. 2.3. 核心资源详解
      1. 2.3.1. 1. Package 资源 - 软件包管理
      2. 2.3.2. 2. Service 资源 - 服务管理
      3. 2.3.3. 3. Systemd_unit 资源 - Systemd 单元管理
      4. 2.3.4. 4. File 资源 - 文件管理
      5. 2.3.5. 5. Template 资源 - 模板文件
      6. 2.3.6. 6. Directory 资源 - 目录管理
      7. 2.3.7. 7. Execute 资源 - 执行命令
      8. 2.3.8. 8. Cookbook File 资源 - 静态文件分发
    4. 2.4. 资源通知机制详解
      1. 2.4.1. notifies - 主动通知
      2. 2.4.2. subscribes - 被动订阅
      3. 2.4.3. lazy 属性 - 延迟求值
      4. 2.4.4. only_if 和 not_if - 条件守卫
  3. 3. 测试代码编写
    1. 3.1. Test Kitchen 配置
    2. 3.2. InSpec 测试示例
    3. 3.3. ChefSpec 单元测试
    4. 3.4. 运行测试
  4. 4. Why-run 和 Runonce 详解
    1. 4.1. Why-run 模式
      1. 4.1.1. Chef 的两阶段执行模型
      2. 4.1.2. Why-run 的工作原理
      3. 4.1.3. 判定 Why-run 执行通过
      4. 4.1.4. Why-run 相关命令
    2. 4.2. Runonce 模式
      1. 4.2.1. Runonce 实现方式
      2. 4.2.2. Runonce 命令示例
      3. 4.2.3. Runonce 最佳实践
  5. 5. 参考资料

Chef is a powerful automation platform that transforms infrastructure into code, enabling automated configuration, deployment, and management of servers at scale.

Chef 概述

Chef 是一个基于 Ruby 的自动化配置管理工具,通过将基础设施定义为代码(Infrastructure as Code),实现服务器配置的自动化管理和部署。

Chef 架构组件

Chef Infra

Chef Infra 是 Chef 的核心组件,负责配置管理的实际执行。它包含:

  • Chef Infra Client: 运行在被管理节点上的代理程序,负责执行配置代码(Recipes 和 Cookbooks)
  • Chef Infra Server: 中央存储和分发配置数据的服务器,存储 Cookbooks、Policies、节点信息等
  • Ohai: 系统信息收集工具,自动检测节点的硬件、网络、操作系统等信息

核心特性:

  • 声明式配置:描述期望状态而非执行步骤
  • 幂等性:多次执行相同配置产生相同结果
  • 跨平台支持:Linux、Windows、macOS 等

Chef Workstation

Chef Workstation 是开发和管理 Chef 代码的本地环境,包含:

  • Chef CLI: 命令行工具集(knifechefcookstyle 等)
  • Test Kitchen: 本地测试框架,用于在虚拟环境中测试 Cookbooks
  • InSpec: 合规性和安全性测试工具
  • Cookstyle: Ruby 代码风格检查工具

主要功能:

  • 编写和测试 Cookbooks
  • 管理 Chef Server 上的对象(节点、角色、环境等)
  • 上传 Cookbooks 到 Chef Server
  • 引导(Bootstrap)新节点

Chef Server

Chef Server 是中央化的配置管理服务器,提供:

  • Cookbook 存储: 存储和版本管理所有 Cookbooks
  • 节点对象: 维护每个节点的状态和属性
  • 搜索索引: 支持通过属性搜索节点
  • 策略管理: 定义不同环境的配置策略
  • 权限控制: 基于角色的访问控制(RBAC)

工作流程:

  1. 开发者在 Workstation 编写 Cookbooks
  2. 使用 knife 上传 Cookbooks 到 Server
  3. Client 从 Server 拉取配置并执行
  4. Client 报告执行结果回 Server

Chef Client

Chef Client 是运行在被管理节点上的代理程序:

  • 定期执行: 默认每 30 分钟运行一次(可配置)
  • 拉取模式: 主动从 Server 拉取最新配置
  • 本地执行: 下载 Cookbooks 到本地缓存后执行
  • 状态报告: 执行完成后向 Server 报告节点状态

执行流程:

  1. 运行 Ohai 收集节点信息
  2. 从 Chef Server 同步 Cookbooks
  3. 加载节点的 run_list
  4. 编译资源集合(Resource Collection)
  5. 按顺序执行资源
  6. 报告执行结果

Chef 工作模式对比

模式 说明 适用场景
Client-Server 中央化管理,Client 从 Server 拉取配置 大规模生产环境
Chef Solo 无需 Server,本地执行 Cookbooks 小规模部署、测试环境
Chef Zero 轻量级内存 Server,用于开发测试 本地开发和 CI/CD

Chef 常用资源与模块

Cookbook 文件结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
mycookbook/
├── attributes/ # 属性定义
│ └── default.rb
├── recipes/ # 配置代码
│ ├── default.rb
│ └── webserver.rb
├── templates/ # 动态配置文件模板
│ └── default/
│ └── nginx.conf.erb
├── files/ # 静态文件
│ └── default/
│ └── index.html
├── libraries/ # 自定义 Ruby 库
│ └── helpers.rb
├── resources/ # 自定义资源
│ └── myapp.rb
├── test/ # 测试代码
│ └── integration/
│ └── default/
│ └── default_test.rb
├── Berksfile # Berkshelf 依赖管理
├── kitchen.yml # Test Kitchen 配置
├── metadata.rb # Cookbook 元数据
└── README.md

Cookbook 核心配置文件

metadata.rb - Cookbook 元数据

文件说明:
metadata.rb 定义 Cookbook 的基本信息、版本、依赖关系和支持的平台。这是每个 Cookbook 必需的文件。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# metadata.rb
name 'mycookbook'
maintainer 'Your Name'
maintainer_email 'you@example.com'
license 'Apache-2.0'
description 'Installs and configures my application'
version '1.2.3'

# Chef 版本要求
chef_version '>= 16.0'

# 依赖其他 Cookbooks
depends 'nginx', '~> 10.0'
depends 'mysql', '>= 8.0'
depends 'apt', '~> 7.4'

# 支持的操作系统
supports 'ubuntu', '>= 18.04'
supports 'centos', '>= 7.0'
supports 'debian', '>= 10'

# 提供的 Recipes
provides 'mycookbook::default'
provides 'mycookbook::webserver'

# 源代码仓库
source_url 'https://github.com/yourorg/mycookbook'
issues_url 'https://github.com/yourorg/mycookbook/issues'

# 隐私声明
privacy true

关键字段说明:

  • name: Cookbook 名称(必需)
  • version: 版本号,遵循语义化版本(必需)
  • depends: 声明依赖的 Cookbook 及版本约束
  • chef_version: 要求的 Chef Infra Client 版本
  • supports: 声明支持的操作系统和版本
  • provides: 显式声明提供的 Recipes

版本约束语法:

1
2
3
4
5
depends 'nginx'              # 任意版本
depends 'nginx', '= 10.0.0' # 精确版本
depends 'nginx', '>= 10.0' # 大于等于
depends 'nginx', '~> 10.0' # 悲观版本锁定(>= 10.0, < 11.0)
depends 'nginx', '>= 10.0', '< 12.0' # 范围

Berksfile - 依赖管理

文件说明:
Berksfile 用于管理 Cookbook 依赖,类似于 Ruby 的 Gemfile 或 Node.js 的 package.json。Berkshelf 是 Chef 的依赖管理工具。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# Berksfile
source 'https://supermarket.chef.io'

# 当前 Cookbook 的元数据
metadata

# 额外的依赖(不在 metadata.rb 中声明)
cookbook 'build-essential', '~> 8.2'

# 从 Git 仓库安装
cookbook 'custom-nginx',
git: 'https://github.com/yourorg/custom-nginx.git',
branch: 'master'

# 从本地路径安装
cookbook 'internal-tools',
path: '../internal-tools'

# 指定特定版本
cookbook 'java', '~> 8.5.0'

# 分组依赖(用于不同环境)
group :integration do
cookbook 'test-helper'
end

常用 Berkshelf 命令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 安装依赖到本地缓存
berks install

# 更新依赖到最新版本
berks update

# 上传 Cookbook 及其依赖到 Chef Server
berks upload

# 查看依赖树
berks list

# 打包 Cookbook(包含依赖)
berks package cookbooks.tar.gz

# 应用 Berksfile.lock
berks apply

Berksfile.lock:

1
2
3
4
5
6
7
8
9
10
11
12
13
# Berksfile.lock(自动生成,不要手动编辑)
DEPENDENCIES
mycookbook
path: .
metadata: true
nginx (~> 10.0)

GRAPH
mycookbook (1.2.3)
nginx (~> 10.0)
nginx (10.1.0)
apt (>= 7.0)
apt (7.4.0)

attributes/default.rb - 属性定义

文件说明:
Attributes 用于定义 Cookbook 的可配置参数。属性可以在不同优先级级别定义,并可以被 Roles、Environments 或其他 Cookbooks 覆盖。

属性优先级(从低到高):

  1. default - 默认值
  2. force_default - 强制默认值
  3. normal - 普通属性(持久化到节点对象)
  4. override - 覆盖属性
  5. force_override - 强制覆盖
  6. automatic - 自动属性(由 Ohai 收集,最高优先级)

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
# attributes/default.rb

# 基本属性定义
default['mycookbook']['version'] = '1.0.0'
default['mycookbook']['user'] = 'appuser'
default['mycookbook']['group'] = 'appgroup'

# 嵌套属性
default['mycookbook']['config']['port'] = 8080
default['mycookbook']['config']['host'] = '0.0.0.0'
default['mycookbook']['config']['workers'] = 4

# 数组属性
default['mycookbook']['packages'] = %w(git curl vim)
default['mycookbook']['allowed_ips'] = ['10.0.0.0/8', '192.168.0.0/16']

# Hash 属性
default['mycookbook']['database'] = {
'host' => 'localhost',
'port' => 3306,
'name' => 'myapp',
'user' => 'dbuser',
'password' => 'secret'
}

# 基于平台的条件属性
case node['platform']
when 'ubuntu', 'debian'
default['mycookbook']['package_name'] = 'myapp'
default['mycookbook']['service_name'] = 'myapp'
when 'centos', 'redhat'
default['mycookbook']['package_name'] = 'myapp-el'
default['mycookbook']['service_name'] = 'myapp-service'
end

# 基于平台版本
if node['platform_version'].to_f >= 20.04
default['mycookbook']['use_systemd'] = true
else
default['mycookbook']['use_systemd'] = false
end

# 计算属性(基于其他属性)
default['mycookbook']['install_dir'] = "/opt/#{node['mycookbook']['version']}"
default['mycookbook']['config_file'] = "#{node['mycookbook']['install_dir']}/config.yml"

# 使用 normal 级别(持久化)
normal['mycookbook']['node_id'] = SecureRandom.uuid

# 使用 override 级别(覆盖默认值)
override['mycookbook']['debug'] = true if node.chef_environment == 'development'

在 Recipe 中使用属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
# recipes/default.rb

# 读取属性
app_user = node['mycookbook']['user']
app_port = node['mycookbook']['config']['port']

# 使用属性
user app_user do
system true
shell '/bin/false'
home '/opt/myapp'
end

directory '/opt/myapp' do
owner app_user
group node['mycookbook']['group']
mode '0755'
action :create
end

# 使用数组属性
node['mycookbook']['packages'].each do |pkg|
package pkg do
action :install
end
end

# 使用 Hash 属性
template '/etc/myapp/database.yml' do
source 'database.yml.erb'
variables(
database: node['mycookbook']['database']
)
mode '0600'
end

# 修改属性(仅在当前运行中有效)
node.default['mycookbook']['runtime_value'] = 'computed'

# 使用计算属性
file node['mycookbook']['config_file'] do
content "port: #{node['mycookbook']['config']['port']}"
action :create
end

属性文件组织:

1
2
3
4
5
6
7
8
9
10
# attributes/default.rb - 基础属性
default['mycookbook']['version'] = '1.0.0'

# attributes/database.rb - 数据库相关属性
default['mycookbook']['database']['host'] = 'localhost'
default['mycookbook']['database']['port'] = 3306

# attributes/web.rb - Web 服务器属性
default['mycookbook']['web']['port'] = 80
default['mycookbook']['web']['ssl_port'] = 443

通过 Roles 覆盖属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# roles/webserver.rb
name 'webserver'
description 'Web server role'

default_attributes(
'mycookbook' => {
'config' => {
'port' => 80
}
}
)

override_attributes(
'mycookbook' => {
'config' => {
'workers' => 8 # 覆盖默认的 4
}
}
)

run_list(
'recipe[mycookbook::default]',
'recipe[nginx::default]'
)

通过 Environments 覆盖属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# environments/production.rb
name 'production'
description 'Production environment'

default_attributes(
'mycookbook' => {
'database' => {
'host' => 'db.prod.example.com'
}
}
)

override_attributes(
'mycookbook' => {
'debug' => false
}
)

在 kitchen.yml 中覆盖属性:

1
2
3
4
5
6
7
suites:
- name: default
attributes:
mycookbook:
config:
port: 9090
debug: true

kitchen.yml - 测试配置

文件说明:
.kitchen.yml 是 Test Kitchen 的配置文件,定义测试环境、平台和测试套件。

完整示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
---
# 驱动配置(虚拟化平台)
driver:
name: vagrant
# name: docker # 也可使用 Docker
provider: virtualbox
customize:
memory: 2048
cpus: 2

# Provisioner 配置(配置管理工具)
provisioner:
name: chef_zero
product_name: chef
product_version: 18
channel: stable
# 日志级别
log_level: info
# Chef 客户端配置
client_rb:
chef_license: accept
# 数据包路径
data_bags_path: test/fixtures/data_bags
roles_path: test/fixtures/roles
environments_path: test/fixtures/environments

# Verifier 配置(测试工具)
verifier:
name: inspec
reporter:
- cli
- json:reports/%{platform}_%{suite}_inspec.json

# 传输配置
transport:
name: ssh
max_ssh_sessions: 2

# 测试平台
platforms:
- name: ubuntu-20.04
driver:
box: bento/ubuntu-20.04
provisioner:
product_version: 18.1.0

- name: ubuntu-22.04
driver:
box: bento/ubuntu-22.04

- name: centos-8
driver:
box: bento/centos-8
provisioner:
# CentOS 特定配置
chef_omnibus_install_options: -d /tmp

- name: debian-11
driver:
box: bento/debian-11

# 测试套件
suites:
# 默认套件
- name: default
run_list:
- recipe[mycookbook::default]
verifier:
inspec_tests:
- test/integration/default
attributes:
mycookbook:
version: '1.0.0'
config:
port: 8080

# Web 服务器套件
- name: webserver
run_list:
- recipe[mycookbook::webserver]
- recipe[nginx::default]
verifier:
inspec_tests:
- test/integration/webserver
attributes:
nginx:
default_site_enabled: true
mycookbook:
web:
port: 80
# 仅在特定平台上测试
includes:
- ubuntu-20.04
- debian-11

# 数据库套件
- name: database
run_list:
- recipe[mycookbook::database]
attributes:
mycookbook:
database:
host: localhost
port: 3306
# 排除特定平台
excludes:
- centos-8

# 生命周期钩子
lifecycle:
pre_create: echo "Before creating instance"
post_create: echo "After creating instance"
pre_converge: echo "Before converging"
post_converge: echo "After converging"

简化配置示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
---
driver:
name: vagrant

provisioner:
name: chef_zero
product_version: 18

verifier:
name: inspec

platforms:
- name: ubuntu-20.04
- name: centos-8

suites:
- name: default
run_list:
- recipe[mycookbook::default]
verifier:
inspec_tests:
- test/integration/default
attributes:
mycookbook:
debug: true

使用 Docker 驱动:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
---
driver:
name: docker
use_sudo: false
privileged: true

platforms:
- name: ubuntu-20.04
driver_config:
image: ubuntu:20.04
platform: ubuntu
- name: centos-8
driver_config:
image: centos:8
platform: centos

suites:
- name: default
run_list:
- recipe[mycookbook::default]

多套件配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
suites:
- name: default
run_list:
- recipe[mycookbook::default]
attributes:
mycookbook:
mode: standard

- name: minimal
run_list:
- recipe[mycookbook::minimal]
attributes:
mycookbook:
mode: minimal
packages: []

- name: full
run_list:
- recipe[mycookbook::default]
- recipe[mycookbook::webserver]
- recipe[mycookbook::database]
attributes:
mycookbook:
mode: full
install_all: true

核心资源详解

1. Package 资源 - 软件包管理

资源说明:
package 资源用于管理系统软件包的安装、升级和卸载。它是一个通用资源,会根据系统平台自动选择合适的包管理器(apt、yum、dnf、zypper 等)。

平台特定的包管理器资源:

资源类型 包管理器 平台 使用场景
package 自动检测 跨平台 通用场景,推荐使用
yum_package YUM RHEL/CentOS 6-7 需要 YUM 特定功能(如 flush_cache)
dnf_package DNF RHEL/CentOS 8+, Fedora 需要 DNF 特定功能
apt_package APT Debian/Ubuntu 需要 APT 特定功能(如 default_release)
rpm_package RPM RHEL 系 安装本地 .rpm 文件
dpkg_package DPKG Debian 系 安装本地 .deb 文件

语法:

1
2
3
4
5
6
7
8
package 'name' do
package_name String, Array # 包名(默认为资源名称)
version String # 版本号
options String, Array # 传递给包管理器的额外选项
source String # 本地包文件路径
timeout Integer # 超时时间(秒)
action Symbol # 默认 :install
end

属性说明:

  • package_name: 要管理的包名,可以是字符串或数组(安装多个包)
  • version: 指定要安装的版本,留空则安装最新版
  • options: 传递给底层包管理器的参数(如 --no-install-recommends
  • source: 本地包文件路径(用于安装 .rpm 或 .deb 文件)
  • timeout: 包安装超时时间,默认 900 秒

Actions:

  • :install - 安装包(默认)
  • :upgrade - 升级到最新或指定版本
  • :remove - 卸载包但保留配置文件
  • :purge - 完全卸载包和配置文件
  • :reconfig - 重新配置已安装的包

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
# 基本安装
package 'nginx'

# 指定版本
package 'nginx' do
version '1.18.0-1ubuntu1'
action :install
end

# 多包安装
package %w(git curl vim wget)

# 使用平台特定包管理器
yum_package 'httpd' do
flush_cache [:before] # YUM 特定功能
action :install
end

# 安装本地 RPM 包
rpm_package 'custom-app' do
source '/tmp/custom-app-1.0.0.rpm'
action :install
end

# DNF 包安装(RHEL 8+)
dnf_package 'postgresql' do
version '12.5'
action :install
end

# APT 包安装
apt_package 'nginx' do
default_release 'buster-backports' # APT 特定功能
action :install
end

# 条件安装
package 'apache2' do
only_if { node['platform_family'] == 'debian' }
end

# 带通知的安装
package 'nginx' do
notifies :restart, 'service[nginx]', :delayed
end

2. Service 资源 - 服务管理

资源说明:
service 资源用于管理系统服务的启动、停止、重启和开机自启动。支持多种服务管理系统(systemd、upstart、sysvinit 等)。

语法:

1
2
3
4
5
6
7
8
9
10
11
12
service 'name' do
service_name String # 服务名称(默认为资源名称)
pattern String # 用于搜索进程的模式
start_command String # 自定义启动命令
stop_command String # 自定义停止命令
restart_command String # 自定义重启命令
reload_command String # 自定义重载命令
status_command String # 自定义状态检查命令
supports Hash # 服务支持的操作
timeout Integer # 超时时间(秒)
action Symbol # 默认 :nothing
end

属性说明:

  • service_name: 实际的服务名称,用于与服务管理系统交互
  • pattern: 用于 ps 命令搜索进程的正则表达式
  • start_command: 覆盖默认的启动命令
  • stop_command: 覆盖默认的停止命令
  • restart_command: 覆盖默认的重启命令
  • reload_command: 覆盖默认的重载命令
  • status_command: 覆盖默认的状态检查命令
  • supports: 声明服务支持的操作,如 { status: true, restart: true, reload: true }
  • timeout: 服务操作的超时时间

Actions:

  • :start - 启动服务
  • :stop - 停止服务
  • :restart - 重启服务
  • :reload - 重新加载配置(不停止服务)
  • :enable - 设置开机自启动
  • :disable - 禁用开机自启动
  • :nothing - 什么都不做(默认)

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# 基本服务管理
service 'nginx' do
action [:enable, :start]
end

# 完整配置
service 'nginx' do
service_name 'nginx'
supports status: true, restart: true, reload: true
action [:enable, :start]
subscribes :reload, 'template[/etc/nginx/nginx.conf]', :delayed
end

# 自定义命令
service 'myapp' do
start_command '/opt/myapp/bin/start.sh'
stop_command '/opt/myapp/bin/stop.sh'
status_command '/opt/myapp/bin/status.sh'
pattern 'myapp'
action [:enable, :start]
end

# 条件重启
service 'apache2' do
action :restart
only_if { ::File.exist?('/etc/apache2/apache2.conf') }
end

3. Systemd_unit 资源 - Systemd 单元管理

资源说明:
systemd_unit 资源用于创建、管理和控制 systemd 单元文件(service、socket、device、mount、automount、swap、target、path、timer、slice、scope)。相比 service 资源,它提供了对 systemd 特定功能的完整控制。

语法:

1
2
3
4
5
6
7
8
systemd_unit 'name' do
content String, Hash # 单元文件内容
unit_name String # 单元名称(默认为资源名称)
user String # 用户级单元(非系统级)
triggers_reload true, false # 是否触发 systemd 重载,默认 true
verify true, false # 是否验证单元文件,默认 true
action Symbol # 默认 :nothing
end

属性说明:

  • content: 单元文件的内容,可以是字符串或 Hash(会自动转换为 INI 格式)
  • unit_name: systemd 单元的名称,包含类型后缀(如 myapp.service
  • user: 指定用户名,创建用户级单元文件(位于 ~/.config/systemd/user/
  • triggers_reload: 单元文件变化时是否自动执行 systemctl daemon-reload
  • verify: 是否使用 systemd-analyze verify 验证单元文件语法

Actions:

  • :create - 创建单元文件
  • :delete - 删除单元文件
  • :preset - 根据预设策略启用/禁用单元
  • :revert - 恢复到包管理器提供的版本
  • :enable - 设置开机启动
  • :disable - 禁用开机启动
  • :mask - 屏蔽单元(完全禁止启动)
  • :unmask - 取消屏蔽
  • :start - 启动单元
  • :stop - 停止单元
  • :restart - 重启单元
  • :reload - 重新加载单元配置
  • :reload_or_restart - 尝试重载,失败则重启
  • :nothing - 什么都不做(默认)

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
# 使用 Hash 创建 service 单元
systemd_unit 'myapp.service' do
content({
Unit: {
Description: 'My Application Service',
After: 'network.target'
},
Service: {
Type: 'simple',
User: 'appuser',
WorkingDirectory: '/opt/myapp',
ExecStart: '/opt/myapp/bin/start.sh',
ExecStop: '/opt/myapp/bin/stop.sh',
Restart: 'on-failure',
RestartSec: '10s'
},
Install: {
WantedBy: 'multi-user.target'
}
})
action [:create, :enable, :start]
end

# 使用字符串创建 service 单元
systemd_unit 'custom-app.service' do
content <<~UNIT
[Unit]
Description=Custom Application
After=network.target

[Service]
Type=forking
ExecStart=/usr/bin/custom-app --daemon
PIDFile=/var/run/custom-app.pid

[Install]
WantedBy=multi-user.target
UNIT
action [:create, :enable, :start]
end

# 创建 timer 单元(定时任务)
systemd_unit 'backup.timer' do
content({
Unit: {
Description: 'Daily Backup Timer'
},
Timer: {
OnCalendar: 'daily',
Persistent: true
},
Install: {
WantedBy: 'timers.target'
}
})
action [:create, :enable, :start]
end

# 对应的 service 单元
systemd_unit 'backup.service' do
content({
Unit: {
Description: 'Backup Service'
},
Service: {
Type: 'oneshot',
ExecStart: '/usr/local/bin/backup.sh'
}
})
action :create
end

# 创建用户级单元
systemd_unit 'user-app.service' do
user 'john'
content({
Unit: {
Description: 'User Application'
},
Service: {
ExecStart: '/home/john/bin/app'
},
Install: {
WantedBy: 'default.target'
}
})
action [:create, :enable, :start]
end

# 屏蔽服务(防止被启动)
systemd_unit 'unwanted.service' do
action :mask
end

# 删除单元文件
systemd_unit 'old-service.service' do
action [:stop, :disable, :delete]
end

4. File 资源 - 文件管理

资源说明:
file 资源用于管理文件的创建、删除和属性修改(权限、所有者等)。可以设置文件内容,但不支持模板变量(使用 template 资源处理模板)。

语法:

1
2
3
4
5
6
7
8
9
10
11
12
13
file 'name' do
path String # 文件路径(默认为资源名称)
content String # 文件内容
owner String, Integer # 文件所有者
group String, Integer # 文件组
mode String, Integer # 文件权限(八进制)
backup Integer, false # 备份版本数,默认 5
checksum String # SHA-256 校验和
force_unlink true, false # 删除前先取消链接
manage_symlink_source true, false # 是否管理符号链接源
verify String, Block # 验证命令或代码块
action Symbol # 默认 :create
end

属性说明:

  • path: 文件的完整路径
  • content: 文件的文本内容,可以使用 lazy 延迟求值
  • owner: 文件所有者(用户名或 UID)
  • group: 文件所属组(组名或 GID)
  • mode: 文件权限,如 '0644'0644
  • backup: 修改文件前保留的备份数量,设为 false 禁用备份
  • checksum: 用于验证文件内容的 SHA-256 校验和
  • verify: 验证文件内容的命令(如 'nginx -t -c %{file}'

Actions:

  • :create - 创建文件(默认)
  • :delete - 删除文件
  • :touch - 更新文件访问和修改时间
  • :create_if_missing - 仅当文件不存在时创建
  • :nothing - 什么都不做

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
# 创建文件
file '/etc/motd' do
content 'Welcome to my server!'
mode '0644'
owner 'root'
group 'root'
action :create
end

# 使用 lazy 动态生成内容
file '/etc/app/config.txt' do
content lazy {
"Hostname: #{node['hostname']}\n" \
"IP: #{node['ipaddress']}\n" \
"Generated at: #{Time.now}"
}
mode '0644'
action :create
end

# 删除文件
file '/tmp/oldfile' do
action :delete
only_if { ::File.exist?('/tmp/oldfile') }
end

# 修改文件权限
file '/var/log/app.log' do
mode '0644'
owner 'appuser'
group 'appgroup'
action :touch
end

# 带验证的文件
file '/etc/nginx/nginx.conf' do
content 'user nginx; worker_processes 4;'
mode '0644'
verify 'nginx -t -c %{file}'
notifies :reload, 'service[nginx]', :delayed
end

# 带备份的文件修改
file '/etc/important.conf' do
content 'new configuration'
backup 10 # 保留 10 个备份版本
action :create
end

5. Template 资源 - 模板文件

资源说明:
template 资源用于从 ERB 模板文件生成配置文件。模板文件位于 templates/ 目录,支持变量替换和 Ruby 代码执行,适合生成动态配置文件。

语法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
template 'name' do
path String # 目标文件路径(默认为资源名称)
source String # 模板文件名(默认为 "name.erb")
variables Hash # 传递给模板的变量
cookbook String # 模板所在的 Cookbook
local true, false # 是否使用本地模板文件
owner String, Integer # 文件所有者
group String, Integer # 文件组
mode String, Integer # 文件权限
backup Integer, false # 备份版本数
helpers Module # 辅助方法模块
verify String, Block # 验证命令
action Symbol # 默认 :create
end

属性说明:

  • path: 生成文件的目标路径
  • source: ERB 模板文件名,从 templates/default/templates/<platform>/ 查找
  • variables: Hash 形式的变量,在模板中通过 @variable_name 访问
  • cookbook: 指定模板来自哪个 Cookbook(默认为当前 Cookbook)
  • local: 设为 true 使用本地文件系统的模板(非 Cookbook)
  • helpers: 包含辅助方法的 Ruby 模块,可在模板中调用
  • verify: 生成文件后执行的验证命令

Actions:

  • :create - 渲染并创建文件(默认)
  • :delete - 删除文件
  • :create_if_missing - 仅当文件不存在时创建
  • :nothing - 什么都不做

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
# 基本模板使用
template '/etc/nginx/nginx.conf' do
source 'nginx.conf.erb'
variables(
worker_processes: node['cpu']['total'],
worker_connections: 1024
)
mode '0644'
owner 'root'
group 'root'
action :create
notifies :reload, 'service[nginx]', :delayed
end

# 使用 lazy 动态变量
template '/etc/myapp/config.yml' do
source 'config.yml.erb'
variables lazy {
{
db_host: node['myapp']['db_host'],
db_port: node['myapp']['db_port'],
timestamp: Time.now.to_i
}
}
action :create
end

# 从其他 Cookbook 使用模板
template '/etc/app/config.conf' do
source 'app_config.erb'
cookbook 'shared-templates'
variables(
app_name: 'myapp',
app_port: 8080
)
end

# 使用平台特定模板
# Chef 会按顺序查找:
# templates/ubuntu-20.04/app.conf.erb
# templates/ubuntu/app.conf.erb
# templates/default/app.conf.erb
template '/etc/app/app.conf' do
source 'app.conf.erb'
action :create
end

# 带验证的模板
template '/etc/apache2/apache2.conf' do
source 'apache2.conf.erb'
verify 'apachectl -t -f %{file}'
notifies :restart, 'service[apache2]', :delayed
end

模板文件示例 (nginx.conf.erb):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
user nginx;
worker_processes <%= @worker_processes %>;
error_log /var/log/nginx/error.log;

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

http {
include /etc/nginx/mime.types;
default_type application/octet-stream;

<% if @enable_gzip %>
gzip on;
gzip_types text/plain text/css application/json;
<% end %>
}

6. Directory 资源 - 目录管理

资源说明:
directory 资源用于创建、删除目录以及管理目录的权限和所有者。支持递归创建多级目录。

语法:

1
2
3
4
5
6
7
8
9
10
directory 'name' do
path String # 目录路径(默认为资源名称)
owner String, Integer # 目录所有者
group String, Integer # 目录组
mode String, Integer # 目录权限(八进制)
recursive true, false # 是否递归创建/删除,默认 false
rights Hash # Windows 权限设置
inherits true, false # Windows 继承权限
action Symbol # 默认 :create
end

属性说明:

  • path: 目录的完整路径
  • owner: 目录所有者(用户名或 UID)
  • group: 目录所属组(组名或 GID)
  • mode: 目录权限,如 '0755'0755
  • recursive: 设为 true 时递归创建父目录或递归删除子目录
  • rights: Windows 系统的权限设置(Hash 格式)
  • inherits: Windows 系统是否继承父目录权限

Actions:

  • :create - 创建目录(默认)
  • :delete - 删除目录
  • :nothing - 什么都不做

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# 创建目录
directory '/var/www/myapp' do
owner 'www-data'
group 'www-data'
mode '0755'
action :create
end

# 递归创建多级目录
directory '/opt/app/data/uploads' do
owner 'appuser'
group 'appuser'
mode '0755'
recursive true
action :create
end

# 删除目录及其内容
directory '/tmp/olddata' do
recursive true
action :delete
only_if { ::Dir.exist?('/tmp/olddata') }
end

# 仅修改目录权限
directory '/var/log/myapp' do
mode '0750'
owner 'appuser'
group 'appgroup'
action :create
end

# Windows 目录权限设置
directory 'C:/app/data' do
rights :read, 'Everyone'
rights :full_control, 'Administrators'
action :create
end

7. Execute 资源 - 执行命令

资源说明:
execute 资源用于执行任意 Shell 命令。应谨慎使用,优先考虑使用专用资源(如 packageservice)。通过 createsnot_ifonly_if 确保幂等性。

语法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
execute 'name' do
command String, Array # 要执行的命令(默认为资源名称)
cwd String # 工作目录
user String # 执行用户
group String # 执行组
environment Hash # 环境变量
creates String # 如果文件存在则跳过
returns Integer, Array # 期望的退出码,默认 0
timeout Integer # 超时时间(秒)
umask String, Integer # 文件创建掩码
live_stream true, false # 实时输出到 STDOUT
input String # 传递给命令的输入
action Symbol # 默认 :run
end

属性说明:

  • command: 要执行的 Shell 命令,可以是字符串或数组
  • cwd: 命令执行的工作目录
  • user: 以指定用户身份执行命令
  • group: 以指定组身份执行命令
  • environment: 设置环境变量的 Hash
  • creates: 如果指定文件已存在,则跳过执行(幂等性保证)
  • returns: 允许的退出码,可以是单个整数或数组
  • timeout: 命令超时时间(秒),默认 3600
  • umask: 文件创建权限掩码(如 '0022'
  • live_stream: 设为 true 时实时输出命令输出
  • input: 传递给命令的标准输入

Actions:

  • :run - 执行命令(默认)
  • :nothing - 什么都不做

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
# 基本命令执行
execute 'update-package-cache' do
command 'apt-get update'
action :run
end

# 使用 creates 保证幂等性
execute 'download-app' do
command 'wget https://example.com/app.tar.gz -O /tmp/app.tar.gz'
creates '/tmp/app.tar.gz'
action :run
end

# 使用 not_if 条件
execute 'download-app-conditional' do
command 'wget https://example.com/app.tar.gz -O /tmp/app.tar.gz'
not_if { ::File.exist?('/tmp/app.tar.gz') }
end

# 设置工作目录、用户和环境变量
execute 'compile-app' do
command './configure && make && make install'
cwd '/tmp/myapp-source'
user 'builder'
group 'builder'
environment(
'PATH' => '/usr/local/bin:/usr/bin:/bin',
'CC' => 'gcc',
'CFLAGS' => '-O2'
)
timeout 3600
only_if { ::File.exist?('/tmp/myapp-source/configure') }
end

# 允许特定退出码
execute 'optional-migration' do
command '/opt/app/migrate.sh'
returns [0, 2] # 0 = 成功,2 = 已迁移
end

# 使用 lazy 动态命令
execute 'set-hostname' do
command lazy { "hostnamectl set-hostname #{node['custom_hostname']}" }
only_if { node['custom_hostname'] }
end

# 实时输出(用于长时间运行的命令)
execute 'long-running-build' do
command 'make all'
cwd '/opt/project'
live_stream true
timeout 7200
end

# 使用数组形式命令(避免 Shell 注入)
execute 'safe-command' do
command ['/usr/bin/myapp', '--config', '/etc/myapp.conf']
action :run
end

# 带输入的命令
execute 'create-user' do
command 'adduser newuser'
input "password\npassword\n"
not_if 'id newuser'
end

8. Cookbook File 资源 - 静态文件分发

资源说明:
cookbook_file 资源用于从 Cookbook 的 files/ 目录分发静态文件到节点。与 template 不同,文件内容不会被处理,直接复制原文件。

语法:

1
2
3
4
5
6
7
8
9
10
11
12
cookbook_file 'name' do
path String # 目标文件路径(默认为资源名称)
source String # 源文件名(默认为资源名称)
cookbook String # 文件所在的 Cookbook
owner String, Integer # 文件所有者
group String, Integer # 文件组
mode String, Integer # 文件权限
backup Integer, false # 备份版本数
checksum String # SHA-256 校验和
verify String, Block # 验证命令
action Symbol # 默认 :create
end

属性说明:

  • path: 目标文件的完整路径
  • source: 源文件名,从 files/default/files/<platform>/ 查找
  • cookbook: 指定文件来自哪个 Cookbook(默认为当前 Cookbook)
  • owner: 文件所有者(用户名或 UID)
  • group: 文件所属组(组名或 GID)
  • mode: 文件权限(如 '0644'
  • backup: 修改文件前保留的备份数量
  • checksum: 用于验证文件的 SHA-256 校验和
  • verify: 文件复制后执行的验证命令

Actions:

  • :create - 创建文件(默认)
  • :create_if_missing - 仅当文件不存在时创建
  • :delete - 删除文件
  • :nothing - 什么都不做

文件查找顺序:

Chef 会按以下顺序查找文件:

  1. files/host-#{node['fqdn']}/filename
  2. files/#{node['platform']}-#{node['platform_version']}/filename
  3. files/#{node['platform']}/filename
  4. files/default/filename

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
# 基本文件分发
cookbook_file '/var/www/html/index.html' do
source 'index.html' # 从 files/default/index.html
mode '0644'
owner 'www-data'
group 'www-data'
action :create
end

# 从子目录分发文件
cookbook_file '/etc/ssl/certs/myapp.crt' do
source 'ssl/myapp.crt' # 从 files/default/ssl/myapp.crt
mode '0644'
action :create
notifies :reload, 'service[nginx]', :delayed
end

# 从其他 Cookbook 分发文件
cookbook_file '/usr/local/bin/helper.sh' do
source 'helper.sh'
cookbook 'shared-scripts'
mode '0755'
owner 'root'
group 'root'
action :create
end

# 使用校验和验证文件
cookbook_file '/opt/app/config.json' do
source 'config.json'
checksum 'a1b2c3d4e5f6...' # SHA-256 校验和
action :create
end

# 平台特定文件
# Chef 会自动选择:
# files/ubuntu-20.04/app.conf
# files/ubuntu/app.conf
# files/default/app.conf
cookbook_file '/etc/app/app.conf' do
source 'app.conf'
action :create
end

# 带验证的文件
cookbook_file '/etc/sudoers.d/myapp' do
source 'sudoers-myapp'
mode '0440'
verify 'visudo -c -f %{file}'
action :create
end

# 备份配置
cookbook_file '/etc/important.conf' do
source 'important.conf'
backup 10 # 保留 10 个备份版本
action :create
end

资源通知机制详解

notifies - 主动通知

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 语法:notifies :action, 'resource[name]', :timing

template '/etc/nginx/nginx.conf' do
source 'nginx.conf.erb'
action :create
# 配置文件变化后重启 nginx(延迟到 run 结束)
notifies :reload, 'service[nginx]', :delayed
# 也可以立即执行
# notifies :reload, 'service[nginx]', :immediately
end

# 多个通知
file '/etc/app/config.conf' do
content 'setting=value'
action :create
notifies :restart, 'service[app]', :delayed
notifies :run, 'execute[clear-cache]', :delayed
end

subscribes - 被动订阅

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 语法:subscribes :action, 'resource[name]', :timing

service 'nginx' do
action [:enable, :start]
# 订阅配置文件变化
subscribes :reload, 'template[/etc/nginx/nginx.conf]', :delayed
end

# 等价于 notifies 的反向写法
template '/etc/nginx/nginx.conf' do
source 'nginx.conf.erb'
action :create
end

service 'nginx' do
action [:enable, :start]
subscribes :reload, 'template[/etc/nginx/nginx.conf]', :delayed
end

时机选择:

  • :delayed - 在 Chef run 结束时执行(推荐,避免重复操作)
  • :immediately - 立即执行(用于关键操作)

lazy 属性 - 延迟求值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# 问题:节点属性在编译时可能未定义
file '/etc/config' do
content node['runtime_value'] # 编译时求值,可能为 nil
end

# 解决:使用 lazy 延迟到运行时求值
file '/etc/config' do
content lazy { node['runtime_value'] } # 运行时求值
end

# 实际应用场景
package 'mysql-server' do
version lazy { node['mysql']['version'] } # 版本可能在其他 recipe 中设置
action :install
end

# 动态生成内容
template '/etc/app/stats.txt' do
source 'stats.txt.erb'
variables lazy {
{
total_memory: `free -m | awk '/^Mem:/{print $2}'`.to_i,
disk_usage: `df -h / | awk 'NR==2{print $5}'`.chomp
}
}
end

only_if 和 not_if - 条件守卫

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# only_if - 仅当条件为真时执行
package 'nginx' do
action :install
only_if { node['platform_family'] == 'debian' }
end

# 使用 Shell 命令
service 'apache2' do
action :restart
only_if 'test -f /etc/apache2/apache2.conf'
end

# not_if - 仅当条件为假时执行
execute 'download-app' do
command 'wget https://example.com/app.tar.gz'
not_if { ::File.exist?('/tmp/app.tar.gz') }
end

# 使用 Shell 命令
package 'httpd' do
action :install
not_if 'rpm -qa | grep -q httpd'
end

# 复杂条件
directory '/opt/app' do
action :create
only_if do
node['environment'] == 'production' &&
!::Dir.exist?('/opt/app')
end
end

测试代码编写

Test Kitchen 配置

.kitchen.yml 配置示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
---
driver:
name: vagrant

provisioner:
name: chef_zero
product_name: chef
product_version: 18

verifier:
name: inspec

platforms:
- name: ubuntu-20.04
- name: centos-8

suites:
- name: default
run_list:
- recipe[mycookbook::default]
verifier:
inspec_tests:
- test/integration/default
attributes:
mycookbook:
setting: value

InSpec 测试示例

test/integration/default/default_test.rb:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
# 测试包是否安装
describe package('nginx') do
it { should be_installed }
end

# 测试服务状态
describe service('nginx') do
it { should be_installed }
it { should be_enabled }
it { should be_running }
end

# 测试文件存在和内容
describe file('/etc/nginx/nginx.conf') do
it { should exist }
it { should be_file }
it { should be_owned_by 'root' }
its('mode') { should cmp '0644' }
its('content') { should match /worker_processes/ }
end

# 测试目录
describe directory('/var/www/myapp') do
it { should exist }
it { should be_directory }
its('owner') { should eq 'www-data' }
its('mode') { should cmp '0755' }
end

# 测试端口监听
describe port(80) do
it { should be_listening }
its('processes') { should include 'nginx' }
end

# 测试 HTTP 请求
describe http('http://localhost') do
its('status') { should cmp 200 }
its('body') { should match /Welcome/ }
end

# 测试命令执行结果
describe command('nginx -v') do
its('exit_status') { should eq 0 }
its('stderr') { should match /nginx version/ }
end

# 测试用户和组
describe user('www-data') do
it { should exist }
its('group') { should eq 'www-data' }
its('shell') { should eq '/usr/sbin/nologin' }
end

ChefSpec 单元测试

spec/unit/recipes/default_spec.rb:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
require 'spec_helper'

describe 'mycookbook::default' do
let(:chef_run) do
ChefSpec::ServerRunner.new(platform: 'ubuntu', version: '20.04') do |node|
node.automatic['memory']['total'] = '2048MB'
end.converge(described_recipe)
end

# 测试包安装
it 'installs nginx package' do
expect(chef_run).to install_package('nginx')
end

# 测试服务配置
it 'enables and starts nginx service' do
expect(chef_run).to enable_service('nginx')
expect(chef_run).to start_service('nginx')
end

# 测试模板渲染
it 'creates nginx configuration' do
expect(chef_run).to create_template('/etc/nginx/nginx.conf').with(
owner: 'root',
group: 'root',
mode: '0644'
)
end

# 测试通知
it 'nginx config notifies nginx restart' do
template = chef_run.template('/etc/nginx/nginx.conf')
expect(template).to notify('service[nginx]').to(:reload).delayed
end

# 测试文件内容
it 'renders config with correct worker processes' do
expect(chef_run).to render_file('/etc/nginx/nginx.conf')
.with_content(/worker_processes 2/)
end

# 测试条件执行
context 'on debian platform' do
let(:chef_run) do
ChefSpec::ServerRunner.new(platform: 'debian', version: '10')
.converge(described_recipe)
end

it 'installs apache2' do
expect(chef_run).to install_package('apache2')
end
end
end

运行测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# 语法检查
cookstyle .

# 单元测试
chef exec rspec

# 集成测试 - 创建测试环境
kitchen create

# 运行 Chef
kitchen converge

# 运行 InSpec 测试
kitchen verify

# 销毁测试环境
kitchen destroy

# 一键测试(创建、配置、测试、销毁)
kitchen test

# 测试特定平台
kitchen test ubuntu-2004

# 登录测试环境调试
kitchen login

Why-run 和 Runonce 详解

Why-run 模式

Why-run(空跑模式)允许在不实际修改系统的情况下预览 Chef 将要执行的操作。

Chef 的两阶段执行模型

要理解 why-run 和 runonce 的行为,首先需要了解 Chef 的两阶段执行模型:

1. 编译阶段(Compile Phase)

在编译阶段,Chef 执行以下操作:

  • 解析 Recipe 代码(Ruby 代码)
  • 评估属性(Attributes)
  • 构建资源集合(Resource Collection)
  • 执行 Ruby 代码块(非 lazy 的代码)
  • 确定资源的依赖关系

在这个阶段,不会实际修改系统,只是构建将要执行的操作清单。

2. 收敛/执行阶段(Converge/Execute Phase)

在收敛阶段,Chef 执行以下操作:

  • 按顺序执行资源集合中的每个资源
  • 检查资源当前状态
  • 如果需要,执行资源的 action
  • 实际修改系统配置
  • 执行 lazy 评估的代码块
  • 处理 notifies 和 subscribes

这个阶段才会真正修改系统状态

两阶段执行流程图:

graph TD
    A[开始 Chef-Client 运行] --> B[编译阶段开始]
    B --> C[加载 Cookbooks]
    C --> D[解析 Recipes Ruby 代码]
    D --> E[评估 Attributes]
    E --> F[构建 Resource Collection]
    F --> G{编译阶段完成}

    G --> H[收敛阶段开始]
    H --> I[遍历 Resource Collection]
    I --> J{检查资源当前状态}

    J -->|状态一致| K[跳过该资源]
    J -->|需要修改| L[执行资源 Action]

    L --> M[实际修改系统]
    M --> N{有 notifies?}
    N -->|是| O[触发通知的资源]
    N -->|否| P{还有资源?}
    O --> P
    K --> P

    P -->|是| I
    P -->|否| Q[收敛阶段完成]
    Q --> R[报告变更结果]
    R --> S[结束]

    style B fill:#e1f5ff
    style H fill:#fff4e1
    style M fill:#ffe1e1

Lazy 评估与两阶段的关系:

1
2
3
4
5
6
7
8
9
# 编译阶段立即评估
file '/tmp/timestamp.txt' do
content Time.now.to_s # 编译时评估,所有资源使用相同时间戳
end

# 延迟到执行阶段评估
file '/tmp/timestamp.txt' do
content lazy { Time.now.to_s } # 执行时评估,获取准确的执行时间
end

为什么这对 Why-run 很重要?

在 why-run 模式下:

  • 编译阶段正常执行 - 构建资源集合
  • 收敛阶段模拟执行 - 不实际修改系统,只预测变更

但问题是:某些资源的状态检查依赖于其他资源的执行结果。如果前置资源没有实际执行,Chef 无法准确预测后续资源的状态。这就是为什么 why-run 有时需要运行多次才能得到准确的预测结果。

Why-run 的工作原理

为什么需要运行 2 次?

1
2
3
4
5
# 第一次 why-run
chef-client --why-run

# 第二次 why-run
chef-client --why-run

原因分析:

  1. 第一次运行 - 发现依赖关系和收集信息

    • Chef 遇到依赖资源时无法确定后续状态
    • 某些资源依赖其他资源的执行结果
    • 例如:模板文件依赖包安装,但包未实际安装
  2. 第二次运行 - 基于第一次的假设验证

    • Chef 假设第一次的操作已执行
    • 重新评估资源状态
    • 提供更准确的变更预览

示例说明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# Recipe 示例
package 'nginx' do
action :install
end

template '/etc/nginx/nginx.conf' do
source 'nginx.conf.erb'
action :create
# 依赖 nginx 包安装
end

service 'nginx' do
action [:enable, :start]
# 依赖配置文件存在
end

第一次 why-run 输出:

1
2
3
4
Would install package[nginx]
Would create template[/etc/nginx/nginx.conf] (assumed)
Would enable service[nginx] (assumed)
Would start service[nginx] (assumed)

第二次 why-run 输出:

1
2
3
4
Would install package[nginx]
Would create template[/etc/nginx/nginx.conf]
Would enable service[nginx]
Would start service[nginx]

注意:第二次运行时 “assumed” 标记消失,表示 Chef 能更准确判断变更。

判定 Why-run 执行通过

成功标准:

  1. 退出码为 0

    1
    2
    chef-client --why-run
    echo $? # 应该返回 0
  2. 无错误输出

    1
    2
    chef-client --why-run 2>&1 | grep -i error
    # 应该没有输出
  3. 资源变更可预测

    • 所有资源显示预期操作
    • 没有 “assumed” 或不确定的状态(第二次运行后)
    • 依赖关系正确解析
  4. 日志检查

    1
    2
    3
    4
    5
    # 在 Recipe 中添加日志
    log 'why-run-check' do
    message 'This would be executed'
    level :info
    end

Why-run 相关命令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 基本 why-run
chef-client --why-run

# 指定 run-list
chef-client --why-run --runlist 'recipe[mycookbook::default]'

# 本地模式 why-run
chef-client --local-mode --why-run --runlist 'recipe[mycookbook]'

# 使用 JSON 属性
chef-client --why-run --json-attributes /tmp/attrs.json

# 详细输出
chef-client --why-run --log_level debug

# 仅格式化输出
chef-client --why-run --format-formatter doc

# Test Kitchen 中使用 why-run
kitchen converge --no-provision

# Knife 测试 why-run
knife ssh 'name:web*' 'sudo chef-client --why-run' -x user

Runonce 模式

Runonce 确保 Chef Client 仅执行一次,常用于初始化场景。

Runonce 实现方式

1. 使用 Guard 文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# Recipe 中实现
execute 'initial-setup' do
command '/opt/app/setup.sh'
creates '/var/lib/app/.setup_complete' # Guard 文件
action :run
end

# 或使用 not_if
execute 'initial-setup' do
command '/opt/app/setup.sh'
not_if { ::File.exist?('/var/lib/app/.setup_complete') }
action :run
end

# 执行后创建 Guard 文件
execute 'initial-setup' do
command '/opt/app/setup.sh && touch /var/lib/app/.setup_complete'
not_if { ::File.exist?('/var/lib/app/.setup_complete') }
action :run
end

2. 使用自定义资源

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# resources/runonce.rb
provides :runonce

property :command, String, required: true
property :marker_file, String, required: true

action :run do
execute new_resource.name do
command new_resource.command
not_if { ::File.exist?(new_resource.marker_file) }
end

file new_resource.marker_file do
content "Executed at: #{Time.now}"
action :create
only_if { shell_out(new_resource.command).exitstatus == 0 }
end
end

# 使用
runonce 'database-migration' do
command '/opt/app/bin/migrate.sh'
marker_file '/var/lib/app/.migrated'
action :run
end

3. 使用节点属性

1
2
3
4
5
6
7
8
9
10
11
# 检查节点属性
unless node['mycookbook']['initialized']
execute 'initialize-app' do
command '/opt/app/init.sh'
action :run
end

# 设置属性(需要 Chef Server)
node.normal['mycookbook']['initialized'] = true
node.save unless Chef::Config[:solo]
end

4. Systemd Oneshot Service

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 创建 oneshot service
template '/etc/systemd/system/app-init.service' do
source 'app-init.service.erb'
action :create
end

# app-init.service.erb
=begin
[Unit]
Description=App Initialization
After=network.target

[Service]
Type=oneshot
ExecStart=/opt/app/init.sh
RemainAfterExit=yes

[Install]
WantedBy=multi-user.target
=end

service 'app-init' do
action [:enable, :start]
end

Runonce 命令示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 手动单次运行
chef-client --once

# 使用间隔(运行一次后退出)
chef-client --interval 0 --splay 0

# 守护进程模式(不推荐用于 runonce)
chef-client --daemonize

# 指定 run-list 并运行一次
chef-client --once --runlist 'recipe[mycookbook::initialize]'

# 结合 cron 实现定时单次运行
cat > /etc/cron.d/chef-runonce << 'EOF'
0 2 * * * root /usr/bin/chef-client --once --runlist 'recipe[backup]'
EOF

# Docker 容器中单次运行
docker run -v /etc/chef:/etc/chef chef/chef:latest \
chef-client --once --local-mode --runlist 'recipe[mycookbook]'

Runonce 最佳实践

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
# 1. 幂等性检查
execute 'app-setup' do
command '/opt/app/setup.sh'
creates '/opt/app/.initialized'
action :run
end

# 2. 版本化标记
execute 'app-migration-v2' do
command '/opt/app/migrate.sh v2'
not_if { ::File.exist?('/var/lib/app/.migrated-v2') }
action :run
end

file '/var/lib/app/.migrated-v2' do
content 'Migration v2 completed'
action :create
only_if { ::File.exist?('/opt/app') }
end

# 3. 带验证的 runonce
ruby_block 'verify-and-mark-setup' do
block do
if shell_out('/opt/app/verify.sh').exitstatus == 0
::File.write('/var/lib/app/.verified', Time.now.to_s)
else
raise 'Setup verification failed'
end
end
not_if { ::File.exist?('/var/lib/app/.verified') }
end

# 4. 可重试的 runonce
max_retries = 3
(1..max_retries).each do |attempt|
execute "setup-attempt-#{attempt}" do
command '/opt/app/setup.sh'
returns [0, 1] # 允许特定退出码
action :run
not_if { ::File.exist?('/var/lib/app/.setup_complete') }
notifies :create, 'file[/var/lib/app/.setup_complete]', :immediately
ignore_failure attempt < max_retries
end
end

参考资料