Ansible playbook 与 role 如何选择?区别与用法
了解何时使用扁平 playbook、何时拆分为 role,掌握 role 的 7 个目录、ansible-galaxy init、角色调用方式,以及 defaults 与 vars 的变量优先级。
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.ymltasks/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。如果 handler 使用了错误的单元名称,只有在模板实际发生更改时才会失败,因此问题通常要到数周后才会暴露。
该任务中的 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。
如何调用角色
# site.yml
---
- name: Base configuration for every server
hosts: all
become: true
roles:
- common# inventory.ini
[local]
localhost ansible_connection=localansible-playbook -i inventory.ini site.yml剧本应在回顾中以 failed=0 结束。使用展开形式在调用位置传递参数。这样,同一个角色就能服务于两组主机:
roles:
- role: common
common_admin_group: ops
common_permit_root_login: prohibit-password有一条执行顺序规则几乎会让所有人感到意外。一个剧本可以包含 pre_tasks、roles、tasks 和 post_tasks,Ansible 会始终按该顺序运行它们,不受文件中书写顺序的影响。即使将 tasks: 写在 roles: 上方,角色仍会先运行。因此,如果某项操作必须在角色之前执行,应将其放入 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如果要在任务列表中调用角色,而不是使用 roles: 键,请使用 import_role 或 include_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 会在解析时读取角色,其任务会成为剧本的一部分,因此 ansible-playbook --list-tasks site.yml 会列出这些任务,而且导入语句上的标签会应用于其中的每个任务。include_role 是动态方式。只有任务运行时才会读取角色,因此可以通过变量或循环指定角色名称。代价是,这些任务不会显示在 --list-tasks 和 --start-at-task 中。
这里有一个容易踩的坑。在 include_role 任务上使用 when: 时,条件判断会在被包含角色的 defaults/main.yml 生效前执行。在包含语句上写入 when: common_packages | length > 0 会导致运行因 'common_packages' is undefined 停止,即使该变量就定义在正在包含的角色中。解决方法是将开关移出角色:将其放入 group_vars/all.yml,这样它在所有位置都处于作用域内;角色的默认值则只保留给角色自身使用的变量。
哪个变量优先:defaults、group_vars、vars、extra vars
Ansible 记录了二十多个变量优先级。实际使用中,几乎所有争议都由其中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=0changed=0 表示每个模块都检查了当前状态,并发现所需操作已经完成。第二次运行中的 changed=2 表示有两个任务无法识别状态差异,因此会持续重写文件并无限重启服务。常见原因是 command 或 shell,因为 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 会输出模板将要重写的确切行。阅读输出时请注意:shell 和 command 任务在检查模式下会被跳过,因此看似没有更改的执行计划仍可能隐藏实际操作。
摘要中还有一列同样需要仔细处理:Ansible 无法连接的主机会计入 unreachable,而不是 failed,并且该主机的任何任务都不会运行。因此,在将此角色用于多台机器之前,请先提前决定一个无法访问的主机是否应停止整个运行。
为何 Ansible 提示找不到 role
Ansible 会先在 playbook 文件旁的 roles/ 目录中查找,然后再检查 roles_path。查找依据是 playbook 的位置,而不是当前 shell 所在的目录。
ERROR! the role 'common' was not found in /home/deploy/lonely/roles:/home/deploy/lonely此消息表示 site.yml 和 roles/ 不一致,并会列出它尝试过的路径。请将两者放在同一目录中。从父目录运行也没有问题,因为真正决定查找位置的是 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_path 和 inventory 设置会被静默忽略,role 查找失败的原因与 role 本身无关。ansible --version 会显示实际加载的 config file,ansible-config dump --only-changed 会列出所有不同于内置默认值的设置。当运行表现得像配置不存在时,请同时检查这两项。
共享角色:requirements.yml 与固定版本
其他人编写的角色需要安装,而不是复制。只需声明一次:
# requirements.yml
---
roles:
- name: postgres
src: https://github.com/example/ansible-role-postgres
scm: git
version: v1.4.0ansible-galaxy install -r requirements.yml -p galaxy_roles始终设置 version。不设置时,命令会获取运行当天默认分支中的内容。因此,上个月还能正常运行的部署可能在自己的仓库未做任何更改的情况下失效。将 roles_path 指向下载目录,并将该目录排除在 git 之外:
# ansible.cfg
[defaults]
inventory = inventory.ini
roles_path = ./galaxy_rolesplaybook 旁边的 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 中。vars/ 在 Ansible 的优先级顺序中高于 group_vars 和 host_vars,因此 inventory 无法覆盖它。将变量移到 defaults/main.yml,它位于优先级顺序的较低位置,适合存放调用方应能够修改的变量。要确认原因确实是优先级,而不是拼写错误,请使用 -e name=value 运行一次;该选项的优先级高于其他所有来源。
为什么 Ansible 提示找不到 role?
搜索会从 playbook 文件所在位置开始,因此 site.yml 和 roles/ 必须位于同一目录中。错误信息会显示 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 中哪些文件实际会执行操作。