跳到主要内容

Playbook 编写

前面用 ad-hoc 命令(ansible all -m ...)能干的事很有限,真正的日常都写在 playbook 里。这篇笔记按「结构 → 变量 → 流程控制 → 调试」的顺序,把写一个能跑、好维护的 playbook 要用到的东西过一遍。示例环境:控制节点 macOS / Linux,受管节点 Ubuntu 22.04,ansible-core 2.16+。

Playbook 结构​

一个 playbook 文件是一个 YAML 列表,列表里每一项是一个 play。play 负责把「一批主机」和「一串任务」绑在一起:

  • hosts:这个 play 作用到 inventory 里的哪些主机
  • vars / vars_files:定义变量
  • become:是否提权(相当于 sudo)
  • tasks:按顺序执行的任务列表,每个 task 调一个模块
  • handlers:被 notify 触发才执行的特殊任务,通常用来重启服务

task 是最小执行单位,核心写法就一行:模块名(FQCN)+ 参数。

最小完整示例,一个文件就能跑:

---
- name: 给 web 服务器装 nginx
hosts: web
become: true

tasks:
- name: 安装 nginx
ansible.builtin.apt:
name: nginx
state: present
update_cache: true
ansible-playbook -i inventory.ini site.yml
模块名写全 FQCN

apt 是短名,ansible.builtin.apt 是全名(FQCN)。短名目前还能用,但撞上 collection 里同名模块时行为不可预期,写 playbook 一律用全名。

一个完整可跑的示例​

下面这个 playbook 在 Ubuntu 22.04 上部署 nginx:装包、拷首页、起服务,配置变了才重启。后面几节会反复拿它举例。

---
- name: 部署 nginx
hosts: web
become: true

vars:
nginx_port: 8080
site_root: /var/www/demo

tasks:
- name: 安装 nginx
ansible.builtin.apt:
name: nginx
state: present
update_cache: true

- name: 创建站点目录
ansible.builtin.file:
path: "{{ site_root }}"
state: directory
owner: www-data
group: www-data
mode: "0755"

- name: 拷贝首页文件
ansible.builtin.copy:
src: files/index.html
dest: "{{ site_root }}/index.html"
mode: "0644"
notify: 重载 nginx

- name: 确保 nginx 运行并开机自启
ansible.builtin.service:
name: nginx
state: started
enabled: true

handlers:
- name: 重载 nginx
ansible.builtin.service:
name: nginx
state: reloaded

逐段说一下:

  • become: true 让整个 play 的任务默认用 root 执行,装包、改系统目录都需要
  • copy 的 src 是相对 playbook 所在目录的路径,files/index.html 提前准备好
  • 拷贝首页这个 task 带了一行 notify: 重载 nginx:只有当文件真的发生变化(changed)时,play 末尾才会执行对应的 handler;文件没动过就不会打扰服务
  • service 的 enabled: true 是注册开机自启,和 state: started 是两回事,一般两个都写
幂等是核心

这个 playbook 跑一遍和跑十遍效果一样:包已装就跳过、文件没变就不拷贝。写 playbook 的目标是幂等,而不是「把命令敲一遍」。

变量​

变量常见的定义位置,按日常用到的顺序:

位置写法适用场景
play 内 vars上面示例里的写法只服务这一个 play 的值
vars_files单独一个 YAML 文件引入变量较多时拆出去
group_vars/web.yml按 inventory 组定义一组主机共用的值
host_vars/web1.yml按主机定义单台主机的特有值
register接住任务执行结果后一个任务要用前一个的输出
-e 命令行ansible-playbook -e "version=1.2.3"临时覆盖、CI 传参

优先级只需要记住两头:-e 传的 extra vars 最高,role 的 defaults 最低,同级的越靠近具体主机越高(host_vars > group_vars)。完整优先级表有二十多级,真遇到冲突再查官方文档不迟。

vars_files 的写法:

  vars_files:
- vars/common.yml

引用变量用双花括号 {{ nginx_port }}。有一个坑要提前说:值以 {{ 开头时必须加引号,否则 YAML 解析器会先把它当成语法报错:

  vars:
good: "{{ site_root }}/index.html" # 正确
# bad: {{ site_root }}/index.html # 报错:花括号在 YAML 里有特殊含义

注册变量与输出​

任务执行完的结果用 register 接住,后面的任务就能根据结果做判断:

  tasks:
- name: 检查配置文件语法
ansible.builtin.command: nginx -t
register: nginx_test
changed_when: false

- name: 打印检查结果
ansible.builtin.debug:
var: nginx_test

register 接住的是一个字典,常用字段:

字段含义
rc命令退出码(command/shell 类模块)
stdout / stderr标准输出 / 错误输出,多行文本
stdout_lines按行拆好的列表,循环时用这个
changed是否产生了变更(true/false)
failed是否执行失败

ansible.builtin.debug 两种写法:var: nginx_test.stdout_lines 打印变量的值,msg: "端口是 {{ nginx_port }}" 打印拼好的字符串。日常排查 playbook,一大半时间都在用 debug 打印 register 出来的结构。

看完整结构用 var 不要猜字段

不同模块返回的字段不一样(比如 apt 没有 rc)。先用 debug: var=结果 打一遍完整结构,再决定取哪个字段,比翻文档快。

条件与循环​

when 条件​

when 控制任务是否执行,最常见的用法是按发行版分支:

  tasks:
- name: Debian 系安装
ansible.builtin.apt:
name: nginx
state: present
when: ansible_facts.distribution == "Ubuntu"

- name: 调试时只看某些主机
ansible.builtin.debug:
msg: "这是生产机"
when: "'prod' in group_names"

ansible_facts.distribution 这类 facts 是 Ansible 连上主机后自动采集的信息(发行版、IP、内存等),直接拿来当条件用。条件里写 Python 风格的表达式,字符串记得加引号。

loop 循环​

loop 取代老版的 with_items,遍历列表逐项执行:

  tasks:
- name: 安装常用工具
ansible.builtin.apt:
name: "{{ item }}"
state: present
loop:
- vim
- htop
- curl

每次循环里 item 取到当前项。loop 的项也可以是字典,配合 item.name、item.state 取字段。

loop 配合 register​

循环任务 register 之后,结构会变:结果在 results 数组里,每项对应一次循环的执行结果,取单个值要写 结果变量.results[0].stdout,遍历时用 item.stdout 配合新一轮 loop。这是新手最常踩的坑之一——直接访问 结果变量.stdout 会报 undefined。

failed_when / changed_when 自定义判定​

默认规则是 rc != 0 算失败,但有些命令退出码不规矩,可以自定义:

  tasks:
- name: 检查端口占用(查不到不算失败)
ansible.builtin.shell: ss -lnt | grep :8080
register: port_check
changed_when: false
failed_when: port_check.rc > 1

grep 的退出码:0 表示查到、1 表示没查到、2 才是命令出错。上面的写法让「没查到」正常通过、只有真出错才算失败:

  • failed_when:什么条件下算失败,写表达式,结果为 true 则任务失败
  • changed_when:什么条件下算 changed。纯查询类命令(check、show、ss)永远不会有实质变更,惯例是写 changed_when: false,否则 --check 输出和 handler 触发都会被它干扰

Handler 与 notify​

Handler 解决的是「配置改了才重启服务,没改别动」这个需求,规则就几条:

  • task 里 notify: handler 名字,handler 写在 play 的 handlers: 段
  • 只有 task 状态是 changed 时才真正触发,ok 和 skipped 不触发
  • 多个 task notify 同一个 handler,整个 play 跑完只执行一次,合并去重
  • handler 按 handlers: 段里的定义顺序执行,跟 notify 的先后顺序无关

listen 关键字可以给 handler 起「主题名」,一个 notify 同时触发多个 handler:

  handlers:
- name: 重启 nginx
listen: 应用配置变更
ansible.builtin.service:
name: nginx
state: restarted

- name: 记录变更日志
listen: 应用配置变更
ansible.builtin.command: logger "nginx config changed"
changed_when: false

task 里 notify: 应用配置变更,两个 handler 都会跑。

正常流程下 handler 攒到 play 末尾统一执行。如果想让配置变更立刻生效(后面的任务依赖服务已重启),用 flush_handlers 强制立即执行:

    - name: 立即执行已累积的 handler
ansible.builtin.meta: flush_handlers
restarted 和 reloaded 选一个

改的是站点文件这类不涉进程的东西,reloaded 平滑生效就够了;只有改了监听端口、模块加载这类才需要 restarted。无脑 restart 会打断在途连接。

Tags​

Playbook 大了之后,经常只想跑其中一部分,这时给任务打 tag:

  tasks:
- name: 安装 nginx
ansible.builtin.apt:
name: nginx
state: present
tags:
- install

- name: 拷贝首页文件
ansible.builtin.copy:
src: files/index.html
dest: "{{ site_root }}/index.html"
tags:
- config

命令行按需执行:

# 只跑带 config 标签的任务
ansible-playbook site.yml --tags config

# 跑全部,但跳过 install
ansible-playbook site.yml --skip-tags install

# 看看都有哪些标签可用
ansible-playbook site.yml --list-tags

两个特殊标签:

  • always:不带 --skip-tags 显式排除就永远执行,适合放「无论如何都要做的检查」
  • never:除非 --tags 点名否则永远不执行,适合放危险操作,比如清数据

预检与调试​

改动上生产之前,几个不真正执行或只看不做的检查手段:

# 语法检查,不连主机
ansible-playbook site.yml --syntax-check

# 列出会执行的所有 task,不执行
ansible-playbook site.yml --list-tasks

# 干跑:连上主机跑一遍,但不真正修改,配合 --diff 看文件级差异
ansible-playbook site.yml --check --diff

# 每执行一个 task 前停下来等回车确认
ansible-playbook site.yml --step
--check 不是万能的

--check 模式下 command/shell 这类模块默认直接跳过(它无法预判命令会干什么),所以 dry run 的结果仅供参考,别把它当成「预演一遍就完全等价」。验证类任务想让它在 check 模式也跑,加 check_mode: false。

组织拆分​

一个 playbook 写长了,把任务拆到独立文件里。两种引入方式:

  tasks:
- name: 静态引入:加载时就展开
ansible.builtin.import_tasks: tasks/install.yml

- name: 动态引入:执行到这一行才解析
ansible.builtin.include_tasks: tasks/deploy.yml
when: ansible_facts.distribution == "Ubuntu"

区别记住两点:

import_tasksinclude_tasks
解析时机加载 playbook 时就展开,等同于直接写在原处执行到该任务时才解析
when 的行为条件套用到被引入文件里每个任务上条件先判断,不满足整个文件不加载
--list-tasks / tag能看到被引入的任务,tag 正常生效看不到,被引入任务的 tag 需要额外处理

经验法则:默认用 import_tasks(行为可预测、能被 --list-tasks 看见);只有「按运行时条件决定加载哪个文件」或「循环里引入文件」时才用 include_tasks。

常见坑​

按踩坑频率排序:

  1. YAML 缩进:统一两个空格,不许用 Tab。报错信息里的行号基本可信,直接去看。
  2. 冒号后必须空格:name: nginx 对,name:nginx 错。真正的坑是值里出现「冒号+空格」(比如 msg: 状态: 正常),YAML 会把第一个冒号当分隔符直接报错;URL 这类带冒号的字符串虽然多数情况能解析,建议也顺手加引号:msg: "地址是 http://example.com"。
  3. 布尔值统一 true / false:yes/no/on/off 老写法虽然多数情况还认,但 lint 会报,团队里统一成小写 true/false 最省心。
  4. {{ }} 出现在值开头要加引号:path: "{{ site_root }}/a",前面变量一节说过,再强调一次,这个报错信息很不直观。
  5. shell/command 模块的 changed 判定:这类模块默认每次都报 changed,会导致 handler 被反复触发。纯查询的命令补一行 changed_when: false;真正会改东西的,用 creates: 参数或 changed_when 表达式把幂等性找回来:
    - name: 初始化数据库(只做一次)
ansible.builtin.command: /opt/app/init-db.sh
args:
creates: /opt/app/.initialized

小结​

  • playbook = play 的列表,play 把 hosts 和 tasks 绑起来,task 调 FQCN 模块,目标是幂等
  • 变量记两头:-e 最高、role defaults 最低;值以 {{ 开头必须加引号
  • register 接结果、debug 看结构;when 做分支、loop 做循环,循环的结果是 results 数组
  • handler 只在 changed 时触发且合并执行一次,需要立即生效就 flush_handlers
  • 上生产前过一遍 --syntax-check → --check --diff → --step,大改动拆文件用 import_tasks

下一篇讲 playbook 继续变大之后怎么办:用 role 把任务、模板、默认值打包成可复用的单元,以及项目的推荐目录布局。