Ansible 模板与处理程序示例:实现幂等配置
使用 Jinja2 模板生成 nginx 配置,通过处理程序仅在文件实际变更时 reload,并连续运行 playbook 两次验证幂等性。
Ansible 模板和处理程序为第一个 playbook 增加的功能
Ansible 模板和处理程序是将静态 playbook 变成实用配置的两个组成部分。模板根据变量生成配置文件,因此一个文件即可适用于所有主机。处理程序仅在任务确实发生更改时运行,因此服务只会在配置实际变更后重新加载,其他时间不会受到影响。
本指南紧接着您的第一个 VPS Ansible playbook继续。您已经有一个用于安装软件包并启动服务的 play。以下所有操作都在一台机器上运行,因为该 play 通过本地连接定位到 localhost。您不需要第二台服务器即可完成本指南。同一个 play 无需修改任务即可针对实际清单主机运行,最后一节将介绍需要调整的部分。
设置工作目录
sudo apt update
sudo apt install -y ansible nginx
ansible --version
mkdir -p ~/ansible-templates/templates
cd ~/ansible-templates这里使用 nginx,只是因为它是一个具有配置文件和 reload 命令的真实服务,示例所需的条件都具备。ansible --version 会输出 ansible-core 版本及其将使用的 Python 解释器。请记下这两项。下面的 playbook 使用 ansible.builtin.template 等完全限定的模块名称,这要求 Ansible 2.10 或更高版本;目前的任何发行版软件包都已满足这一要求。
创建 inventory.ini:
[local]
localhost ansible_connection=local ansible_python_interpreter="{{ ansible_playbook_python }}"ansible_connection=local 告诉 Ansible 将每个任务作为本地进程运行,而不是通过 SSH 会话连接到自身。第二项设置并非可有可无。将 localhost 写入 inventory 文件后,它会成为普通主机,并失去 Ansible 自动提供给隐式 localhost 的解释器,因此会回退到解释器发现机制,可能选择与运行 play 的 Python 不同的解释器。ansible_playbook_python 是当前运行 ansible-playbook 的解释器,这样可以确保两者一致。
创建 ansible.cfg:
[defaults]
inventory = inventory.ini没有该文件时,您需要在每条命令中传入 -i inventory.ini。完全没有 inventory 时,Ansible 会输出 [WARNING]: provided hosts list is empty, only localhost is available. Note that the implicit localhost does not match 'all',此时包含 hosts: all 的 play 不会匹配任何主机。关于 ansible.cfg 还要注意一点:如果它位于任何用户都可写的目录中,Ansible 会忽略它,因此请将项目放在您的主目录下。inventory 文件不仅包含主机列表,这是能够完成任务的最小配置。
template 与 copy 的区别,以及各自适用的场景
ansible.builtin.copy 按原样传输文件。ansible.builtin.template 会先使用 Jinja2 处理文件,再传输处理结果。模块源码将 template 描述为“完全由 action plugin 实现,并在控制器上运行的虚拟模块”。这会带来一个需要记住的结果:模板渲染发生在您输入 ansible-playbook 的机器上。目标主机看不到您的变量,也不需要安装 Jinja2。
如果每台主机上的文件都完全相同,请使用 copy。只要每台主机有一个值不同,或者需要使用 {% for %} 循环或 {% if %} 块,就应使用 template。copy 确实提供了 content: 参数,其中的变量会像其他任务参数一样替换,但该参数不支持循环和条件判断。因此,任何具有结构的内容都应放入模板中。两个模块接受相同的文件选项,因为它们都引入了相同的文档片段。因此,owner、group、mode、backup 和 validate 在两个模块中的行为相同。
编写模板:一个变量,一个循环
将以下内容保存为 templates/app.conf.j2:
# {{ ansible_managed }}
upstream {{ app_name }}_backend {
{% for backend in app_backends %}
server {{ backend.host }}:{{ backend.port }} weight={{ backend.weight }};
{% endfor %}
}
server {
listen {{ app_listen_port }};
server_name {{ app_server_name }};
location / {
proxy_pass http://{{ app_name }}_backend;
proxy_set_header Host $host;
}
}这里有两种 Jinja2 标签。{{ ... }} 是表达式,会输出其值。{% ... %} 是语句,本身不输出任何内容。app_backends 是一个字典列表,因此 backend.host 会从每个条目中读取一个键,循环会为每个条目写入一行 server,具体行数取决于你定义的条目数量。
关于空白还有一个细节需要注意,因为熟悉其他环境中的 Jinja2 的人可能会对此感到意外。Ansible 默认将 trim_blocks 设置为 yes,而 Jinja2 本身不会这样设置。因此,{% ... %} 标签后紧接的换行符会被删除,循环不会在其后留下空行。Ansible 将 lstrip_blocks 保持为 no,因此你放在 {% 标签前的空格会被保留,并出现在渲染后的文件中。如果输出中出现多余的缩进,请在模板任务中设置 lstrip_blocks: true。
默认情况下,{{ ansible_managed }} 会渲染为字面文本 Ansible managed。保持默认设置。人们经常在 ansible.cfg 中重新定义 ansible_managed,使其包含日期。这样一来,渲染后的文件每次运行都会不同,任务每次运行都会报告发生变更,服务也会每次运行都重新加载。这个设置会破坏本指南其余内容所依赖的属性。.j2 扩展名只是一种约定,Ansible 不会检查它。
执行手册
将以下内容保存为 site.yml:
- name: Render an nginx site from a template
hosts: local
become: true
vars:
app_name: learn
app_listen_port: 8080
app_server_name: learn.example.com
app_backends:
- host: 127.0.0.1
port: 9001
weight: 3
- host: 127.0.0.1
port: 9002
weight: 1
tasks:
- name: Install nginx
ansible.builtin.apt:
name: nginx
state: present
update_cache: true
cache_valid_time: 3600
- name: Render the site configuration
ansible.builtin.template:
src: templates/app.conf.j2
dest: "/etc/nginx/conf.d/{{ app_name }}.conf"
owner: root
group: root
mode: '0644'
backup: true
notify: nginx config changed
- name: Make sure nginx is enabled and running
ansible.builtin.service:
name: nginx
state: started
enabled: true
handlers:
- name: Test the nginx configuration
ansible.builtin.command:
cmd: /usr/sbin/nginx -t
changed_when: false
listen: nginx config changed
- name: Reload nginx
ansible.builtin.service:
name: nginx
state: reloaded
listen: nginx config changed这里特意将 mode: '0644' 放在引号中。文件选项文档说明,应将八进制数“括起来,这样 Ansible 会接收字符串,并自行将字符串转换为数字”。如果不加引号,YAML 解析器会将 0644 读取为普通数字,最终得到的权限可能与预期不符。
notify: nginx config changed 表示主题,而不是处理程序。两个处理程序都包含 listen: nginx config changed,因此一次 notify 会触发这两个处理程序。稍后添加第三个包含相同 listen 行的处理程序时,无需修改模板任务。cache_valid_time: 3600 可防止同一小时内的第二次运行再次访问软件包镜像站。
运行一次,然后读取输出内容
ansible-playbook site.yml如果 sudo 要求输入密码,请添加 -K,Ansible 会提示您输入密码。
先读取每个任务对应的行,然后查看底部的 PLAY RECAP。如果 Ansible 执行了更改,任务会输出 changed:;如果主机已经处于目标状态,任务会输出 ok:。recap 会按主机汇总这些计数器。只有 play 中的每个任务都完成后,才会显示 RUNNING HANDLER [Test the nginx configuration],随后显示 RUNNING HANDLER [Reload nginx]。
现在直接检查计算机本身,不要只相信输出内容:
sudo cat /etc/nginx/conf.d/learn.conf
sudo /usr/sbin/nginx -t
curl -sI http://127.0.0.1:8080/如果组装后的配置可以解析,nginx -t 会输出 nginx: configuration file /etc/nginx/nginx.conf test is successful。curl 会返回 nginx 的状态行。这里正确的结果是 502 Bad Gateway,因为 server block 已生效,并且没有进程监听 9001 或 9002 端口。sudo tail /var/log/nginx/error.log 会用直白的文字说明原因:connect() failed (111: Connection refused) while connecting to upstream。
再次运行以验证幂等性
ansible-playbook site.yml这次运行最重要,因此请逐行将其输出与第一次运行的输出进行比较。模板任务现在应打印 ok:,而第一次运行中打印的是 changed:;此外,输出中不应再出现任何一个处理程序。
其机制很简单,也值得了解,因为调试时需要以此为依据。template 在控制节点上渲染文件,并将结果的校验和与 dest 中现有文件的校验和进行比较。内容、所有者和模式一致时,无需执行任何操作,因此任务报告 ok,notify 不会触发,处理程序也不会运行。处理程序只会在 changed 时触发,不会因其他情况触发。
同时验证相反的情况。将 vars 中的 weight: 3 改为 weight: 1,再次运行 play;模板任务应报告 changed,两个处理程序都会运行,并且 sudo cat /etc/nginx/conf.d/learn.conf 会显示新值。
如果第二次完全相同的运行仍报告发生了更改,说明渲染结果不稳定。首先检查输出中是否包含基于时间的内容,因为这是最常见的原因,而自定义的 ansible_managed 通常是罪魁祸首。然后检查任务中的 mode 和 owner 是否与磁盘上的实际内容一致,因为即使字节完全相同,只要这些属性不匹配,也会被视为发生了更改。
执行变更前查看差异
ansible-playbook site.yml --check --diff--check执行 play,但不会修改主机。--diff输出每个任务可能修改的内容;对于 template,它会逐行显示渲染结果与磁盘上文件之间的差异。两者结合后,可以在不实际执行的情况下回答“本次运行会做什么”。检查模式本身也有局限,尤其是在任务结果依赖前一个任务,而检查模式并未实际执行该任务时。
为什么处理程序要等到 play 结束
handlers 文档对此有明确说明:“默认情况下,handlers 会在特定 play 的所有任务完成后运行。收到通知的 handlers 会在以下各个部分之后自动执行,顺序如下:pre_tasks、roles/tasks 和 post_tasks。”
原因是批处理。一个 play 为同一服务生成 4 个配置文件时,应在最后一次性重启该服务,并确保 4 个文件都已写入。若每生成一个文件就重启一次,服务会被重启 4 次,其中 3 次加载的都是未完成的配置。同一页面也明确说明了这一保证:“无论有多少任务通知同一个 handler,该 handler 都只会执行一次。”
执行顺序同样是固定的:“handlers 按其在 handlers 部分中的定义顺序执行,而不是按 notify 语句中的列出顺序执行。”因此,Test the nginx configuration 在 playbook 中位于 Reload nginx 之上。测试会先运行,因为它的定义顺序更靠前;notify 行中的内容不会改变这一点。
如何提前运行处理程序,以及如何在失败后运行处理程序
有时,同一 play 中的后续任务需要服务已经使用新配置运行。此时可使用 meta 模块刷新已通知的处理程序。文档将其描述为“让 Ansible 运行截至目前已收到通知的所有处理程序任务”。
- name: Run the notified handlers now instead of at the end of the play
ansible.builtin.meta: flush_handlers
- name: Wait for the new listener to accept connections
ansible.builtin.wait_for:
host: 127.0.0.1
port: 8080
timeout: 10删除 meta 行后,wait_for 任务运行时,nginx 仍在提供旧配置。首次运行时,端口 8080 尚未有任何监听程序,因此该任务会等待完整的 ten seconds,然后失败。
第二种情况是任务失败。“如果某个任务通知了处理程序,但 play 中的另一个任务随后失败,默认情况下,该处理程序不会在该主机上运行,这可能导致主机处于意外状态。”因此,先渲染配置、随后因无关任务失败的 play,会将新文件保留在磁盘上,但运行中的服务仍加载旧配置。可在命令行中使用 --force-handlers 覆盖此行为,也可在 play 中使用 force_handlers: true。在 ansible.cfg 的 [defaults] 下,该开关也表示为 force_handlers = True;还可通过环境变量 ANSIBLE_FORCE_HANDLERS 设置。默认值为 False。
处理程序名称冲突时,失败的一方不会提示
文档明确规定:“每个处理程序都应使用全局唯一的名称。如果定义了多个同名处理程序,则只有最后加载到 play 中的处理程序可以被通知和执行。”在角色内部定义的处理程序也不会限定在该角色的作用域内。它们会被插入整个 play 共用的全局处理程序列表。因此,两个角色分别定义 Restart nginx 后,名称最终只会解析到其中一个处理程序。具体解析到哪个处理程序由加载顺序决定,而不是由发出通知的角色决定。
在依赖此行为之前,先验证这条规则。将以下内容保存为 handlers-dup.yml:
- name: Two handlers, one name
hosts: local
gather_facts: false
tasks:
- name: Notify the duplicated name
ansible.builtin.command:
cmd: /bin/true
changed_when: true
notify: Duplicated handler
handlers:
- name: Duplicated handler
ansible.builtin.file:
path: /tmp/dup-first
state: touch
mode: '0644'
- name: Duplicated handler
ansible.builtin.file:
path: /tmp/dup-second
state: touch
mode: '0644'rm -f /tmp/dup-first /tmp/dup-second
ansible-playbook handlers-dup.yml
ls -l /tmp/dup-first /tmp/dup-second该 play 执行成功,RUNNING HANDLER [Duplicated handler] 只出现一次;ls 会分别为 /tmp/dup-first 和 ls: cannot access '/tmp/dup-second': No such file or directory 的另一个处理程序输出一行。实际执行的是先写入的处理程序,而不是最后加载的处理程序。这与该文档句子的描述正好相反。
理解这种差异很重要,因为文档中的规则针对的是处理程序块,而不是文件中的各行。来自不同位置的处理程序(先来自一个角色,再来自另一个角色)属于不同的块,后一个块确实会覆盖前一个块。play 中普通的 handlers: 列表则是一个单独的块,块内的搜索按从上到下的顺序进行,并在找到第一个匹配名称时停止。因此,在同一个文件中,第一个定义会生效,第二个定义无法访问;而在不同角色之间,覆盖行为则符合文档描述。无论哪种情况,都无法访问两个处理程序,也不应依赖任一方向的行为。
有两种直接的解决方法。为每个处理程序名称添加角色专用前缀,或者通知限定形式 role_name : handler_name。文档将其列为“确保通知角色中的处理程序,而不是角色外同名处理程序”的方法。冒号两侧的空格是该语法的一部分。一旦开始引入并非自己编写的角色,这个问题就会立即出现。
同一页面还规定:“避免在处理程序名称中使用变量。由于处理程序名称会较早进行模板化,Ansible 可能无法为这样的处理程序名称提供变量值。”如果处理程序名称为 Restart {{ service_name }},而该变量在名称模板化时尚未定义,整个 play 就会失败。将处理程序名称保持为固定字符串,并使用 listen 对其分组,可以避免这个问题。
验证:拒绝安装损坏的渲染结果
validate 会在 Ansible 将渲染后的文件移动到目标位置前,对该文件执行命令。文档说明:“在将更新后的文件复制到最终目标前运行的验证命令。验证时使用临时文件路径,并通过 %s 传入该路径;如下面的示例所示,命令中必须包含该参数。此外,命令会以安全方式传递,因此扩展和管道等 shell 功能不会生效。”
这段说明直接给出了两条规则。%s 是必需的;不包含它的 validate 字符串会使任务因 validate must contain %s 失败。这里也不会启动 shell,因此管道、重定向、通配符和 && 都不起作用。只能执行一个命令,并传入一个文件参数。
官方模块示例正好展示了这两种可行情况:
- name: Copy a new sudoers file into place, after passing validation with visudo
ansible.builtin.template:
src: /mine/sudoers
dest: /etc/sudoers
validate: /usr/sbin/visudo -cf %s
- name: Update sshd configuration safely, avoid locking yourself out
ansible.builtin.template:
src: etc/ssh/sshd_config.j2
dest: /etc/ssh/sshd_config
owner: root
group: root
mode: '0600'
validate: /usr/sbin/sshd -t -f %s
backup: yes这两个示例都能正常工作,因为每个检查器都接收一个文件,并按照自身规则独立检查该文件。visudo -cf 会读取 sudoers 文件。sshd -t -f 会读取完整的 sshd_config。
为什么 validate 无法检查本指南中的 nginx 文件
在上面的模板任务中添加 validate: /usr/sbin/nginx -t -c %s 后,任务会失败。错误消息指出了原因:
nginx: [emerg] "upstream" directive is not allowed here in <ansible temporary path>:2nginx -t -c 需要一个完整配置。该配置必须从顶层的 events 和 http 块开始。本 play 渲染的文件是一个片段。include /etc/nginx/conf.d/*.conf; 位于 /etc/nginx/nginx.conf 中,并通过 http 块将该片段引入。脱离这个上下文单独检查时,upstream 确实成了位置错误的指令,因此 nginx 会拒绝该文件。但文件在实际位置完全正确。检查器拿到的是一个片段,却将其当作完整配置处理。
可行的做法已经写在 playbook 中。先安装该片段,然后在 reload handler 上方定义的 handler 中检查组装后的配置。handler 按定义顺序运行,因此 nginx -t 看到的是真实的 /etc/nginx/nginx.conf,其中已经包含你的片段。如果检查失败,play 会在调用 systemctl reload 之前失败。需要明确这一做法的代价:检查失败时,损坏的文件已经写入磁盘;nginx 会继续提供上一次加载的配置,直到有人重启它。
这正是 backup: true 存在的原因。它会在覆盖原文件前,将原文件复制到同一目录,并命名为 basename.PID.YYYY-MM-DD@HH:MM:SS~,因此目录中会出现类似 learn.conf.4127.2026-08-20@11:42:09~ 的条目。修改后运行 sudo ls -l /etc/nginx/conf.d/,即可看到其中一个备份文件。
这个命名细节比看起来更重要。在 /etc/nginx/conf.d/ 中,备份文件不会造成问题,因为主配置只包含 conf.d/*.conf,而备份文件名以波浪号结尾。但在使用裸 * 引入的目录中,情况并非如此;在 Debian 和 Ubuntu 上,/etc/nginx/nginx.conf 正是以这种方式包含 /etc/nginx/sites-enabled/*。如果使用 backup: true 将模板写入 sites-enabled,nginx 会将备份文件作为第二个活动 server block 加载。因此,本 play 会改为写入 conf.d。
使用真实 inventory 主机运行同一个 play
将 hosts: local 改为您使用的组名,play 中其他内容无需改动。模板会为每台主机渲染一次,因此 app_listen_port 和 app_backends 可以分别来自 group_vars 和 host_vars,而模板文件本身仍只需一份。将值放入变量而不是直接写入文件,目的就在于此。
有两项内容需要更改。除非每个目标主机都配置了免密码 sudo,否则 become: true 现在需要目标主机上的 sudo 密码,因此请添加 -K。此外,模板中的任何机密信息,例如数据库密码或 API 令牌,都不能以明文形式存放在您提交的文件中的 vars: 内。使用 Ansible Vault 加密这些值,并像现在一样按名称引用它们,因为模板不关心变量的来源。
当 play 扩展到多个服务后,vars:、templates/ 和 handlers: 都已有标准存放位置。将它们移到这些位置,正是拆分 playbook 和 role的意义。
FAQ
我的 Ansible handler 为什么没有运行?
几乎总是因为通知它的任务报告了 ok,而不是 changed。handler 只会在发生更改时触发,其他情况都不会触发。因此,如果模板任务渲染后的内容与磁盘上的现有文件相同,就不会通知任何 handler。接下来检查以下 4 项。notify 中的字符串必须与 handler name 或 listen 主题完全匹配,包括大小写和空格。除非传递 --force-handlers,否则该主机上的后续任务失败会阻止已通知的 handler 运行。在其他 play 中定义的 handler 对当前 play 不可见。被 when 条件跳过的通知任务也完全不会发送通知。
我的 playbook 为什么每次运行都报告 changed?
渲染后的文本在不同运行之间不稳定。最常见的原因是输出中包含时间戳;自定义的 ansible_managed 字符串如果包含日期,也会导致此问题。接下来检查任务中的 mode 和 owner:如果它们与磁盘上的现有文件不匹配,Ansible 就会修正它们并报告更改,即使文件内容相同。运行 ansible-playbook site.yml --check --diff 查看具体是哪一种情况,因为 --diff 会显示任务计划进行的差异。
Ansible 中 template 和 copy 有什么区别?
ansible.builtin.copy 原样发送文件。ansible.builtin.template 会先在 controller 上通过 Jinja2 渲染文件,然后发送渲染结果,因此变量和循环会在文件到达目标主机前解析。文件在所有主机上都必须逐字节一致时,使用 copy。文件内容会因主机而异时,使用 template。两者共享相同的文件选项,因此 mode、owner、backup 和 validate 在两者中的工作方式相同。
如何让 handler 在 play 中间运行?
在需要 handler 运行的位置,将 ansible.builtin.meta: flush_handlers 添加为任务。它会触发截至当前已通知的所有 handler,然后 play 正常继续。后续任务依赖服务已经加载新配置时,可以使用此方式。例如,只有 reload 后端口才存在时,可对该端口执行 wait_for。这是在 play 结束前运行 handler 的受支持方式。
可以对 nginx 配置片段使用 validate 吗?
不能对 nginx -t -c %s 使用。该命令要求完整配置,且配置必须以顶层的 events 和 http 块开头。因此,它会拒绝 conf.d 片段,并显示类似 "upstream" directive is not allowed here 的消息。该片段在 http 块内有效,但单独使用无效。先安装文件,然后在定义于 reload handler 之前的 handler 中,对组装后的配置运行 nginx -t。handler 按定义顺序运行,因此配置错误会在执行 reload 前使 play 失败。在模板任务上设置 backup: true,这样仍可恢复之前的文件。