SSD Nodes Learn 🎉 VPS $5.50/月起
指南 Matt Connor作者: Matt Connor · 更新于 2026-08-07

Ansible playbook 和 role 有什么区别,怎么选择

了解何时使用扁平 playbook,何时拆分为 role,涵盖 role 目录结构、ansible-galaxy init、角色调用方式与变量优先级,并说明约 100 行时的拆分信号。

Verified Every command ran end-to-end on a fresh Ubuntu 24.04 server, July 30, 2026.

Ansible playbook 与 role:有什么区别

Ansible playbook 是使用 ansible-playbook 运行的文件。它将一组主机映射到这些主机需要执行的工作。Ansible role 是一个采用固定目录结构的目录,其中包含任务、模板、处理程序和默认变量,playbook 按名称调用它。两者内部的任务语法完全相同,因此区别不在于能够表达什么,而在于复用方式。

从扁平 playbook 开始。一个 site.yml,其中包含一个 tasks: 列表,是编写第一个自动化任务的正确结构,而且这种结构适用的时间通常比大多数人预期的更长。当同一组任务需要在第二组主机上运行,或者文件增长到大约 100 行、无法再通过滚动快速找到任务时,再将其转换为 role。

如果您还没有编写过 playbook,请先针对单个 VPS 编写第一个 playbook,等它开始变得复杂后再回来。

平面 playbook 适用的场景

当任务只执行一次、只针对一台主机,或不会有其他人阅读时,使用平面 playbook 是正确的选择。配置单台应用服务器,或在维护窗口前为一台主机安装补丁:这些场景都不需要创建目录树。一个 role 会增加 7 个目录和一层间接调用。如果唯一的调用方就是旁边的 playbook,那么这层间接调用没有带来任何好处;每次想查看实际执行的内容时,反而都要多跳转一次。

平面 playbook 在某个明确的时刻不再适用,而且很容易发现这个时刻。你把一组任务复制到第二个 playbook 中。这次复制就是信号。从此以后,每个修复都必须执行两次;而总有一天,你只会修复其中一处。

角色目录实际包含的内容

roles/common/
  defaults/main.yml
  vars/main.yml
  tasks/main.yml
  handlers/main.yml
  templates/99-hardening.conf.j2
  files/
  meta/main.yml
  • tasks/main.yml 是入口文件。调用角色时,Ansible 会运行此文件,其他目录均为可选。
  • defaults/main.yml 存放调用方通常需要覆盖的变量。它在 Ansible 中的优先级最低,因此几乎所有其他来源的变量都会覆盖它。
  • vars/main.yml 存放调用方通常不应覆盖的变量。它的优先级高于 inventory,这是一个需要谨慎做出的强约束。应少用此目录。
  • handlers/main.yml 存放由 notify 触发的任务。handler 会在 play 结束时运行一次,无论有多少任务通知了它。
  • files/ 存放由 copy 模块原样复制的文件,templates/ 存放由 template 模块渲染的 Jinja2 模板。在角色中引用这两类文件时,只需使用不带路径的文件名,因为 Ansible 会优先搜索角色自身的目录。
  • meta/main.yml 声明角色依赖项,并包含 Ansible Galaxy 读取的元数据。

此目录结构不是风格偏好。Ansible 会在这些固定路径中查找,因此放在 roles/common/template/(单数)中的模板根本不会被找到。

使用 ansible-galaxy init 构建 common 角色

mkdir -p ~/infra/roles
cd ~/infra
ansible-galaxy init --init-path roles common

该命令会在 roles/common 下生成完整的目录结构,其中包括不会使用的目录,以及只包含 ---main.yml 占位文件。删除不需要且保持为空的目录。空的 vars/main.yml 对 Ansible 没有影响,但会掩盖角色中实际起作用的文件。

现在填写执行实际工作的文件。先处理默认值,因为它们是角色的公共接口。

# roles/common/defaults/main.yml
---
common_packages:
  - ufw
  - fail2ban
  - unattended-upgrades
common_admin_group: admins
common_permit_root_login: "no"
common_password_authentication: "no"

"no""yes" 加引号。Ansible 使用 PyYAML 解析 YAML。PyYAML 会将不带引号的 no 读取为布尔值 false,因此生成的配置行会变成 PermitRootLogin False,sshd 会拒绝该配置。加引号可以确保该值按字符串处理。

# roles/common/tasks/main.yml
---
- name: Install the base packages
  ansible.builtin.apt:
    name: "{{ common_packages }}"
    state: present
    update_cache: true
    cache_valid_time: 3600

- name: Create the admin group
  ansible.builtin.group:
    name: "{{ common_admin_group }}"
    state: present

- name: Install the sshd hardening drop-in
  ansible.builtin.template:
    src: 99-hardening.conf.j2
    dest: /etc/ssh/sshd_config.d/99-hardening.conf
    owner: root
    group: root
    mode: "0644"
    validate: /usr/sbin/sshd -t -f %s
  notify: Restart sshd
# roles/common/handlers/main.yml
---
- name: Restart sshd
  ansible.builtin.service:
    name: ssh
    state: restarted
# roles/common/templates/99-hardening.conf.j2
# Managed by Ansible. Local edits are overwritten on the next run.
PermitRootLogin {{ common_permit_root_login }}
PasswordAuthentication {{ common_password_authentication }}

在 Debian 和 Ubuntu 上,systemd 单元名称为 ssh;在 RHEL 系列系统上,名称为 sshd。如果处理程序使用了错误的单元名称,只有在某个操作实际修改模板时才会失败,因此通常要到几周后才会暴露。

任务中的 validate 行最有用。Ansible 会将模板渲染到临时文件,将该文件的路径替换到 %s 中,然后执行命令。只有命令以退出码 0 结束时,目标文件才会被替换。在模板中加入一条无效指令并再次运行:任务会因 failed to validate 失败,实际的 /etc/ssh/sshd_config.d/99-hardening.conf 不会被修改,您仍然可以登录服务器。请注意,该检查测试的不只是模板语法。如果 sshd -t 无法读取主机密钥,它会以 sshd: no hostkeys available -- exiting. 退出,Ansible 也会报告相同的 failed to validate。因此,在归咎于模板之前,请先查看该模块的 msg

如何调用 role

# site.yml
---
- name: Base configuration for every server
  hosts: all
  become: true
  roles:
    - common
# inventory.ini
[local]
localhost ansible_connection=local
ansible-playbook -i inventory.ini site.yml

Play 应在回顾信息中以 failed=0 结束。使用展开形式在调用位置传递参数,这样同一个 role 就能服务两组主机:

  roles:
    - role: common
      common_admin_group: ops
      common_permit_root_login: prohibit-password

有一条排序规则几乎会让所有人感到意外。一个 play 可以包含 pre_tasksrolestaskspost_tasks,Ansible 会按该顺序执行它们,无论你在文件中以什么顺序编写。将 tasks: 放在 roles: 上方,roles 仍会先执行。因此,如果某项操作必须在 role 之前执行,就应将其放入 pre_tasks:,而不是放在 tasks: 的顶部。

- name: Ordering demonstration
  hosts: local
  gather_facts: false
  pre_tasks:
    - name: Runs first
      ansible.builtin.debug:
        msg: pre
  roles:
    - common
  tasks:
    - name: Runs after the role
      ansible.builtin.debug:
        msg: task
  post_tasks:
    - name: Runs last
      ansible.builtin.debug:
        msg: post

如果要从任务列表内部调用 role,而不是使用 roles: 键,请使用 import_roleinclude_role

  tasks:
    - name: Static, read when the playbook is parsed
      ansible.builtin.import_role:
        name: common

    - name: Dynamic, resolved when the task runs
      ansible.builtin.include_role:
        name: postgres
      when: "'db' in group_names"

import_role 是静态方式。Ansible 在解析时读取 role,其任务会成为 play 的一部分,因此 ansible-playbook --list-tasks site.yml 会列出这些任务,而导入语句上的标签会应用于其中的每个任务。include_role 是动态方式。只有任务开始运行时才会读取内容,因此可以通过变量或循环决定 role 名称。代价是,这些任务不会显示在 --list-tasks--start-at-task 中。

这里有一个容易踩到的陷阱。include_role 任务上的 when: 会在被包含的 role 的 defaults/main.yml 纳入作用域之前进行求值。在 include 上写入 when: common_packages | length > 0 后,执行会因 'common_packages' is undefined 停止,即使该变量就在要包含的 role 中定义。解决方法是将开关移出 role:将其放入 group_vars/all.yml,使其在所有位置都处于作用域内;role 的默认值则仅保留给 role 自身使用的变量。

哪个变量优先:默认值、group_vars、vars、extra vars

Ansible 记录了20多个变量优先级。实际使用中,几乎所有争议都由其中4个级别决定。以下按优先级从低到高排列。

  • roles/<name>/defaults/main.yml 位于优先级较低的位置。几乎任何其他位置设置的值都会覆盖它,因此它适合存放角色中可调整的参数。
  • group_vars/host_vars/ 位于中间位置。站点自己的配置值应放在这里,并且可以明确覆盖角色默认值。
  • roles/<name>/vars/main.yml 的优先级高于 host_vars。此处设置的值无法被 inventory 覆盖。应将角色必须保持内部一致的值保留在这里,例如必须与服务名称匹配的软件包名称。
  • 在调用位置传入的角色参数会覆盖 vars/main.yml,而命令行中的 -e 会覆盖所有内容,包括角色参数。

你可以在大约1分钟内观察这个优先级解析过程。为一个小型角色设置一个默认值和一个角色变量,然后在 host_vars 中设置相同的变量名。

# roles/prec/defaults/main.yml
---
prec_tunable: from-defaults
prec_internal: from-defaults
# roles/prec/vars/main.yml
---
prec_internal: from-rolevars
# host_vars/localhost.yml
---
prec_tunable: from-hostvars
prec_internal: from-hostvars
# roles/prec/tasks/main.yml
---
- name: Show which value survived
  ansible.builtin.debug:
    msg: "tunable={{ prec_tunable }} internal={{ prec_internal }}"
ansible-playbook -i inventory.ini prec.yml
ansible-playbook -i inventory.ini prec.yml -e prec_internal=from-cli

第一次运行会输出 tunable=from-hostvars internal=from-rolevars。inventory 覆盖了角色默认值,但优先级低于角色变量。第二次运行会输出 internal=from-cli,因为 extra vars 位于最高优先级,下面的任何设置都无法覆盖它。这也是为什么 -e 适合一次性运行,却不适合保留在脚本中:它会悄然覆盖仓库中所有经过设计的配置决策。

实际规则是:如果希望某个值可被设置,就将它放在 defaults/ 中。将它放在 vars/ 中,就等于告诉角色的所有未来使用者,inventory 不得修改该值。有时这正是你的意图,但通常是一个意外。

证明该角色具有幂等性:运行两次

值得信任的 Ansible 运行结果在第二次执行时应保持不变,并报告没有任何更改。将 playbook 运行两次,然后查看执行摘要。

ansible-playbook -i inventory.ini site.yml
ansible-playbook -i inventory.ini site.yml

第二次执行摘要应如下所示:

PLAY RECAP *********************************************************************
localhost   : ok=4  changed=0  unreachable=0  failed=0  skipped=0  rescued=0  ignored=0

changed=0 表示每个模块都检查了当前状态,并发现所需操作已经完成。第二次运行中出现 changed=2,表示有两个任务无法识别当前状态,因此会不断重写文件并重启服务。通常原因是 commandshell,因为 Ansible 无法判断任意命令执行了什么操作。

# traps.yml
---
- name: Command modules do not know what they changed
  hosts: local
  gather_facts: false
  tasks:
    - name: This appends a line on every run
      ansible.builtin.shell: "echo run >> /tmp/grow.txt"

    - name: This appends a line only once
      ansible.builtin.shell: "echo run >> /tmp/guarded.txt"
      args:
        creates: /tmp/guarded.txt

将该 playbook 运行两次,然后统计包含 wc -l /tmp/grow.txt /tmp/guarded.txt 的行数。/tmp/grow.txt 包含两行,/tmp/guarded.txt 包含一行。第二次运行时,受条件保护的任务完全没有执行,其结果包含消息 skipped, since /tmp/guarded.txt exists,因为 creates 会先为模块提供一个可检查的结果。当命令不会留下这样的结果时,请注册其输出,然后使用 changed_when 自行判断。

ansible-playbook --check --diff site.yml 会在不执行更改的情况下预测将发生的更改,--diff 会打印模板将要重写的确切行。查看输出时请注意:shellcommand 任务在检查模式下会被跳过,因此看似没有问题的计划仍可能隐藏待执行的操作。

为什么 Ansible 提示找不到角色

Ansible 会先在 playbook 文件旁边查找 roles/ 目录,然后再查找 roles_path。查找路径取决于 playbook,而不是当前 shell 所在的目录。

ERROR! the role 'common' was not found in /home/deploy/lonely/roles:/home/deploy/lonely

这条消息表示 site.ymlroles/ 已经不一致,并且会列出 Ansible 尝试过的路径。请将两者放在同一目录中。从父目录运行也没有问题,因为实际使用的是 playbook 路径:

ansible-playbook -i infra/inventory.ini infra/site.yml

同一问题还有一种不易察觉的情况。如果当前目录对所有用户可写,Ansible 会忽略其中的 ansible.cfg,因为服务器上的任何用户都可以在此放置配置文件,从而改变运行结果。

[WARNING]: Ansible is being run in a world writable directory (/tmp/infra), ignoring it as an ansible.cfg source.

这样,roles_pathinventory 设置会被静默忽略,角色查找失败的原因也就与角色本身无关。ansible --version 会打印它实际加载的 config fileansible-config dump --only-changed 会打印所有不同于内置默认值的设置。当运行结果表明配置文件不存在时,请同时检查这两项。

角色共享:requirements.yml 和固定版本

其他人编写的角色会被安装,而不是复制。只需声明一次:

# requirements.yml
---
roles:
  - name: postgres
    src: https://github.com/example/ansible-role-postgres
    scm: git
    version: v1.4.0
ansible-galaxy install -r requirements.yml -p galaxy_roles

始终设置 version。如果不设置,运行命令当天使用的就是默认分支内容。因此,即使您自己的仓库没有任何改动,上个月还能正常运行的部署也可能失败。将 roles_path 指向下载目录,并将该目录排除在 git 之外:

# ansible.cfg
[defaults]
inventory = inventory.ini
roles_path = ./galaxy_roles

playbook 旁的 roles/ 中的角色仍然可以找到,因为除了 roles_path 之外,系统始终还会搜索该路径。因此,您自己的角色可以提交并经过审核,而第三方角色则是固定到某个标签的可复现下载内容。

角色不再适用的场景

角色是一次 Ansible 运行中的可复用单元。它不会在云服务商处创建服务器或 DNS 记录。试图让角色完成这些工作,最终会使 playbook 变成无人愿意维护的内容。开始之前,建议阅读Ansible 与 Terraform 的工作划分。角色也不能替代 inventory 设计:当服务器数量超过少数几台后,如何对这些服务器分组并访问它们比任务如何归档更重要。

common 角色安装的加固配置也需要单独决策。上面的 drop-in 只设置了两条指令,没有更多设置。因此,在决定哪些内容应加入您管理的每台主机的角色之前,请阅读哪些 SSH 设置确实值得修改以及如何让 Ubuntu 自动应用安全更新

FAQ

何时应将 Ansible playbook 转换为 role?

当同一组任务需要在第二个 play 中运行,或需要针对第二组主机运行时,就应转换。需要在多个 playbook 之间复制任务就是信号,因为从那时起,每个修复都必须应用两次,而某一天可能只应用了一次。一个不超过约 100 行、始终只针对一个主机组的 playbook 无法从 role 中获得实际收益,额外的目录反而会降低可读性。

role 会在同一 play 中的任务之前运行吗?

会。Ansible 会先运行 pre_tasks,然后运行 roles: 中列出的所有内容,再运行 tasks:,最后运行 post_tasks:;它不会按照这些键在文件中的出现顺序执行。将 tasks: 写在 roles: 上方,并不会让这些任务先运行。如果某项操作必须在 role 之前执行,请将其放入 pre_tasks:

为什么我的 group_vars 值无法覆盖 role 中的值?

检查该变量是否设置在 role 的 vars/main.yml 中,而不是 defaults/main.yml 中。按照 Ansible 的优先级顺序,vars/ 位于 group_varshost_vars 之上,因此 inventory 无法覆盖它。将该变量移到 defaults/main.yml。它接近优先级顺序的底部,适合存放调用方应能够修改的变量。要确认原因确实是优先级而不是拼写错误,请使用 -e name=value 运行一次;它的优先级高于其他所有来源。

为什么 Ansible 提示找不到 role?

搜索会从 playbook 文件所在位置旁开始,因此 site.ymlroles/ 必须位于同一目录中。错误信息会显示 Ansible 尝试过的路径,例如 the role 'common' was not found in /home/deploy/lonely/roles:/home/deploy/lonely。从父目录运行 playbook 没有问题,因为搜索依据的是 playbook 路径,而不是 shell 的当前工作目录。如果依赖 ansible.cfg 中的 roles_path,请使用 ansible --version 确认该文件已加载,因为当工作目录对所有用户可写时,Ansible 会忽略它。

创建 role 是否必须使用 ansible-galaxy init?

不需要。role 只是使用预期名称创建的一组目录,因此 mkdir -p roles/common/tasks 加上一个 tasks/main.yml 就已经是可用的 role。ansible-galaxy init --init-path roles common 可以减少输入,并提供完整的骨架,包括 meta/main.yml 和 README 模板。删除保持为空的目录,因为空的 vars/main.yml 会掩盖 role 中哪些文件实际会执行操作。

#ansible#roles#playbook#structure#automation