Files
Qiniu/technical/xinfra-preview/ansible-playbook-basics.md

18 KiB
Raw Permalink Blame History

tags, create time
tags create time
ansible
playbook
infra
automation
iaas
2026-07-04 13:30

Ansible Playbook 基础

概述

Ansible 是一个无 Agent 的自动化运维工具,通过 SSH 协议远程管理主机。Playbook 是 Ansible 的核心概念——用 YAML 描述的自动化任务编排文件。xinfra 的进阶目标中要求使用 Ansible Playbook 部署 MySQL Server、PostgreSQL 和 Redis Cluster,这是实现数据库层**基础设施即代码(IaC)**的关键技能。

核心概念

工作原理

graph LR
    Controller[控制节点 / Laptop or CI Host]
    SSH[SSH 连接]
    Nodes[(被控主机列表)]
    Controller -->|读取 Playbook YAML| SSH
    SSH -->|推送临时模块脚本| Nodes
    Nodes -->|返回 JSON 结果| Controller

关键特点:

  • 无 Agent:不需要在被控机器上安装任何软件,仅依赖 Python + SSH
  • 幂等性(Idempotent):同一个 Playbook 执行多次效果相同,不会重复创建或修改
  • 模块化:每个操作封装为独立模块(如 ansible.builtin.shell、ansible.builtin.yum、ansible.builtin.template)

特权提升(Become)

become 是 Ansible 的 跨平台权限提升机制,允许非 root 用户登录主机后切换到目标账户执行任务。不改变初始 SSH 连接会话——临时模块脚本仍通过原始身份推送,但实际运行时切换身份。

# Playbook 级别 — 所有任务以 root 执行
- hosts: all_mysql_servers
  become: yes

# Task 级别 — 精细控制
- name: Run command as postgres user
  ansible.builtin.command: /usr/bin/pg_dump mydb
  become: yes
  become_user: postgres
  become_method: sudo           # su | doas | pfexec (OpenSolaris)
  become_flags: '-s /bin/sh'   # 额外标志位
参数 作用 示例值
become 是否启用权限提升 yes / true
become_user 切换的目标用户 root, postgres, apache
become_method 底层实现方式 sudo(默认), su, pfexec
become_flags 传递给底层方法的附加参数 -s /bin/sh

[!warning] 安全注意事项

  • 密码不能明文存储:敏感凭据应通过 Ansible Vault 加密(见延伸阅读链接)
  • Pipelining:在 /etc/ansible/ansible.cfg 中开启 pipelining = True,可避免临时文件写入磁盘,减少安全风险
  • 每台主机仅能启用一种方法:同时配置 sudo + su 会导致冲突
  • 最佳实践:SSH 连入时已有较高权限 → 按需向下切换,比全程用 root 更安全

Playbook 结构骨架

- hosts: all_mysql_servers
  become: yes                        # 提升 root 权限执行
  vars:                              # 变量定义
    mysql_port: 3306
    mysql_data_dir: /data/mysql
  roles:                             # 引用角色
    - common-setup                   # 系统级准备
    - install-mysql                  # MySQL 安装
  tasks:                             # 显式任务列表
    - name: Ensure MySQL is running
      ansible.builtin.service:       # 推荐写全称(命名空间规范)
        name: mysqld
        state: started
      notify: restart mysql          # 触发 handler
  handlers:                          # 响应通知动作
    - name: restart mysql
      ansible.builtin.service:
        name: mysqld
        state: restarted

关键概念对照

概念 作用 类比
Hosts 指定目标机器组(Inventory 中定义) SSH target
Tasks 按顺序执行的操作 脚本命令
Roles 可复用的任务集合 函数库
Handlers 监听通知的触发器(常用于重启服务) 事件回调
Modules 单功能执行单元 子命令
Inventory 被控主机清单 SSH config 中的 host

变量优先级体系(由低到高)

# 变量加载顺序(低 → 高):
#   1. host_vars/<hostname>/all.yml     — 特定主机变量
#   2. group_vars/<group>/all.yml       — 主机组共享变量
#   3. roles/<role>/defaults/main.yml   — 角色默认值(可被覆盖)
#   4. Playbook 顶层 vars               — Playbook 内联变量
#   5. extra_vars (-e 参数)             — 运行时传入,优先级最高

[!tip] 为什么需要多级变量? 同一个 mysql_port 在开发环境可能是 3307,生产环境是 3306。group_vars 按环境隔离 + defaults 提供安全兜底——这是大型 Ansible 项目中最常用的变量管理模式。

变量引用语法

# YAML 文件中用双大括号引用
- name: Set MySQL port
  debug:
    msg: "MySQL is running on port {{ mysql_port }}"

# 在模板文件(.j2)中同样使用
port = {{ mysql_port }}

# 支持字典访问和索引
- name: Access nested variables
  debug:
    msg: "{{ db_config.host }},{{ db_config.credentials.password }}"

MySQL 部署的角色拆分示例

一个典型的 MySQL 部署 Playbook 会拆成多个 Role:

roles/
├── common-setup/
│   ├── tasks/main.yml       # 时间同步、内核参数调优、防火墙规则
│   └── templates/            # 系统级配置文件模板
├── install-mysql/
│   ├── tasks/main.yml       # 安装、配置主从复制
│   └── files/               # 二进制包或 RPM 安装包
└── config-mysql/
    ├── tasks/main.yml       # 编写 my.cnf(Jinja2 模板)
    └── templates/my.cnf.j2  # 根据变量动态生成配置

每个 Role 可以独立测试和复用,不同服务的安装只需替换中间两个 Role。

Jinja2 模板实战

# my.cnf.j2
[mysqld]
port = {{ mysql_port | default(3306) }}
datadir = {{ mysql_data_dir | default('/var/lib/mysql') }}
server-id = {{ inventory_hostname | regex_replace('\\D', '') | int }}
{% if role == 'master' %}
log-bin = mysql-bin
binlog-format = ROW
{% elif role == 'slave' %}
read-only = 1
relay-log = relay-bin
{% endif %}

[!tip] 为什么需要 Jinja2 模板? 同一套 Playbook 要用于 Master 和 Slave,它们的配置有差异。模板 + 变量注入比硬编码多个 Playbook 更灵活——这也是 IaC 的核心思想:一套代码,多种环境。

控制流

循环(Loops)

Ansible 推荐使用简洁的 loop 语法替代旧式的 with_items。loop 在 2.5 版本引入,功能等价于 with_list,但更简洁且与 lookup 兼容更好。

基础用法

- name: Install multiple packages
  ansible.builtin.yum:
    name: "{{ item }}"
    state: present
  loop:
    - vim
    - curl
    - git
    - wget

# 字典列表循环 — 适合批量创建用户/配置
- name: Create database users
  community.mysql.mysql_user:
    name: "{{ item.name }}"
    password: "{{ item.password }}"
    priv: "{{ item.priv }}"
    state: present
  loop: "{{ db_users }}"   # 引用 vars 中定义的变量列表

字典迭代(dict2items)

当需要遍历 YAML 字典时,用 dict2items 将其转为 [{"key": ..., "value": ...}, ...] 结构:

- name: Configure servers from dictionary
  ansible.builtin.debug:
    msg: "{{ item.key }}: IP={{ item.value.ip_address }}, role={{ item.value.role }}"
  loop: "{{ server_configs | dict2items }}"
  vars:
    server_configs:
      web_01:
        ip_address: "10.1.1.50"
        role: "frontend"
      web_02:
        ip_address: "10.1.1.51"
        role: "backend"

[!tip] query vs lookup loop 要求返回一个 list。使用 lookup 插件时,推荐用 query(2.5+),它保证返回列表;如果用 lookup,需加 wantlist=True:

loop: "{{ query('inventory_hostnames', 'all') }}"
# 或
loop: "{{ lookup('inventory_hostnames', 'all', wantlist=True) }}"

循环控制(loop_control)

- name: Create cloud instances with delay
  digital_ocean:
    name: "{{ item.name }}"
    state: present
  loop:
    - { name: server1, disk: 3gb }
    - { name: server2, disk: 4gb }
  loop_control:
    label: "{{ item.name }}"     # 自定义输出标识(避免冗长日志)
    pause: 3                     # 每项之间等待 N 秒(避免瞬时并发压力)
    loop_var: outer_item         # 指定外层循环变量名(嵌套循环防冲突)

自动重试(until 循环)

until 不是用来遍历数据的——它是 轮询重试 模式,适用于可能暂时失败、最终会成功的操作(如等待服务启动):

- name: Wait for service to be ready
  shell: /usr/bin/check_service_ready
  register: result
  until: result.stdout.find("ready") != -1
  retries: 5              # 最大尝试次数
  delay: 10               # 每次间隔秒数

注册变量在循环中的结构是嵌套的:{{ my_loop.results[0].stdout }}、{{ my_loop.results[1].rc }} 等,后续用 when: item.rc != 0 过滤即可。

[!warning] with_items → loop 迁移注意 with_items 有隐式扁平化行为:with_items: [1, [2,3], 4] 会展平为 [1, 2, 3, 4]。切换到 loop 后需手动处理:

loop: "{{ [1, [2, 3], 4] | flatten(1) }}"

条件判断(when)

- name: Configure slave replication
  template:
    src: my.cnf.j2
    dest: /etc/my.cnf
  when: inventory_hostname in groups['mysql_slaves']

# 复杂条件
- name: Only on CentOS 7
  debug:
    msg: "CentOS detected"
  when:
    - ansible_os_family == "RedHat"
    - ansible_distribution_major_version == "7"

[!warning] 何时不用 when? 如果 when 只是过滤几行数据,优先在 Jinja2 模板内部用 {% if %} 处理,而不是写一个全量任务再套 when——后者会在所有目标主机上都执行一次无意义的模块调用。

notify / handlers 机制详解

tasks:
  - name: Update Nginx config
    ansible.builtin.template:
      src: nginx.conf.j2
      dest: /etc/nginx/nginx.conf
    notify: reload nginx       # ← 仅设置"标记",不立即执行

handlers:
  - name: reload nginx
    ansible.builtin.service:
      name: nginx
      state: reloaded           # ← 仅在 Playbook 阶段结束时触发

[!info] notify 的关键语义 即使多个 task 都 notify 同一个 handler,handler 也只会在每个 Play 结束后执行一次。这正是幂等性设计的一部分——避免重复重启服务。

结果捕获与错误处理

# register — 将命令输出保存到变量
- name: Check if MySQL data dir exists
  ansible.builtin.stat:
    path: /data/mysql
  register: mysql_data_check

- name: Initialize data dir if missing
  ansible.builtin.file:
    path: /data/mysql
    state: directory
    owner: mysql
    group: mysql
  when: not mysql_data_check.stat.exists

# ignore_errors — 允许任务失败不中断 Playbook
- name: Try to stop already-stopped service
  ansible.builtin.service:
    name: mysqld
    state: stopped
  ignore_errors: yes

# failed_when — 自定义失败判定逻辑
- name: Check API health
  ansible.builtin.uri:
    url: http://localhost:8080/health
  register: health_check
  failed_when: "'unhealthy' in health_check.content"

# set_fact — 动态设置变量供后续任务使用
- name: Capture private IP
  ansible.builtin.set_fact:
    node_private_ip: "{{ ansible_default_ipv4.address }}"

[!tip] register 的经典使用场景 stat 模块检查文件存在 → when 判断决定是否创建 → 这就是 "先查后做" 的 Ansible 范式,完美体现幂等性原则。

状态控制(changed_when / any_errors_fatal)

除了 failed_when 外,Ansible 还允许通过 changed_when 精确控制任务的变更状态——这直接影响统计报告和 handler 触发:

# ❌ command 永远是 "changed",即使数据库连接无变化
- name: Check DB connection
  command: mysql -e "SELECT 1"

# ✅ 仅在真正有变更时标记为 changed
- name: Check DB connection
  shell: mysql -e "SELECT 1"
  register: db_check
  changed_when: "'ERROR' in db_check.stdout"

# 强制不报告变更(常用于健康检查类任务)
- name: Health check endpoint
  uri:
    url: http://localhost:8080/health
  changed_when: false
参数 作用域 说明
ignore_errors Task 级 失败继续执行,不中断 Play
ignore_unreachable Play 级 跳过网络不可达的主机,继续其他主机
any_errors_fatal Play/Block 级 任一主机报错即终止全部执行
max_fail_percentage Play 级 失败超过阈值(%)时停止
changed_when Task 级 条件性覆盖 "已变更" 报告
failed_when Task 级 条件性覆盖 "失败" 判定

Block / Rescue / Always — 结构化异常处理

这是编程中 try/catch/finally 的 Ansible 等价物,适合需要 原子化操作 + 自动回滚 的场景:

- name: Deploy application with rollback
  block:
    - name: Backup current release
      archive:
        path: /opt/app/current
        dest: /opt/backups/release-$(date +%s).tar.gz
      register: backup_result

    - name: Extract new version
      unarchive:
        src: new-app-v2.tar.gz
        dest: /opt/app/releases/v2
        remote_src: yes

    - name: Symlink to active release
      file:
        src: /opt/app/releases/v2
        dest: /opt/app/current
        state: link

  rescue:
    - name: Log failure details
      debug:
        msg: "Task '{{ ansible_failed_task.name }}' failed on {{ inventory_hostname }}"

    - name: Rollback to previous release
      file:
        src: /opt/app/releases/v1
        dest: /opt/app/current
        state: link
      when: backup_result is succeeded   # 仅当备份成功时才尝试还原

  always:
    - name: Notify deployment team
      ansible.builtin.command: >
        curl -X POST https://hooks.slack.com/deploy-webhook
        -d '{"text":"Deployment {{ ansible_failed_task.name if ansible_failed_task else 'succeeded' }} on {{ inventory_hostname}}"}'
      changed_when: false

[!info] 语义对比表

编程概念 Ansible 关键字 触发条件
try {} block: 正常任务流,始终执行
catch {} rescue: 仅当前置任务返回 failed 时触发
finally {} always: 无论成败均执行
  • Rescue 中的恢复若成功,Play 会继续——但统计日志仍记录一次故障
  • 语法错误、断连等情况不会触发 Rescue,与编程语言不同
  • Handler 可以在 Rescue 中被调用(通过元数据命令触发)

常用模块速查

[!info] 命名空间规范(Namespace Prefix) Ansible 2.10+ 开始引入 collections 概念,推荐始终使用前缀格式:

  • ansible.builtin.copy — 内置模块
  • community.general.selinux — 社区集合
  • 现代 Playbook 中优先写全称;如果省略前缀,Ansible 会按内置 → 已安装的集合顺序自动匹配。
模块 用途 关键参数
ansible.builtin.copy 本地文件复制到远程 src, dest, owner, mode
ansible.builtin.template Jinja2 模板渲染后复制 src (.j2), dest, owner, mode
ansible.builtin.file 管理文件/目录权限和属性 state: touch/file/directory/link/absent, mode
ansible.builtin.yum / ansible.builtin.apt 包管理 name, state: present/installed/latest/absent
ansible.builtin.service 管理服务生命周期 name, state: started/stopped/restarted/reloaded
ansible.builtin.lineinfile 确保某行存在或不存在于文件中 path, line, state, regexp
ansible.builtin.replace 正则替换文件内容 path, regexp, replace
ansible.builtin.command / ansible.builtin.shell 执行命令(慎用) cmd, chdir
ansible.builtin.get_url / ansible.builtin.uri HTTP 下载 / API 调用 url, dest, method, status_code
ansible.builtin.user 管理系统用户 name, state, groups, shell
ansible.builtin.cron 管理 crontab name, minute, job, state
ansible.builtin.debug 调试输出 msg, var
ansible.builtin.stat 检查文件/路径属性 path, returns .stat.exists
community.mysql.mysql_user MySQL 用户管理 name, password, priv, state
community.postgresql.postgresql_user PostgreSQL 用户管理 name, password, role_attr

[!warning] command vs shell 的选择 command 不经过 Shell 解释器(更安全,推荐优先使用),shell 才支持管道 \|、重定向 >、变量扩展 $VAR。能用模块完成的操作绝不碰 command/shell。

常见陷阱与最佳实践

1. 不要跳过 state 检查

错误写法:

- ansible.builtin.command: systemctl start mysqld    # ❌ 每次都会尝试启动,即使已在运行

正确写法:

- ansible.builtin.service:                           # ✅ Ansible 自带的 service 模块会检查当前状态
    name: mysqld
    state: started

2. Inventory 分层管理

把开发、测试、生产环境的机器分开管理:

# inventory/prod
[mysql_prod]
mysql-prod-01 ansible_host=10.0.1.11
mysql-prod-02 ansible_host=10.0.1.12

[redis_prod]
redis-prod-01 ansible_host=10.0.1.21

3. 首次运行时务必先 Dry Run

# --check 只做预演,不实际执行
ansible-playbook deploy.yml --check --diff

4. 警惕 shell/command 失去幂等性

# ❌ 每次运行都会在日志末尾追加一行
- ansible.builtin.shell: echo "deployed" >> /tmp/deploy-history

# ✅ 用 file module 保证状态一致性
- ansible.builtin.file:
    path: /tmp/deploy-marker
    state: touched
    modification_time: preserve

延伸阅读