516 lines
18 KiB
Markdown
516 lines
18 KiB
Markdown
---
|
||
tags: [ansible, playbook, infra, automation, iaas]
|
||
create time: 2026-07-04 13:30
|
||
---
|
||
|
||
# Ansible Playbook 基础
|
||
|
||
## 概述
|
||
|
||
Ansible 是一个无 Agent 的自动化运维工具,通过 SSH 协议远程管理主机。**Playbook** 是 Ansible 的核心概念——用 YAML 描述的自动化任务编排文件。xinfra 的进阶目标中要求使用 Ansible Playbook 部署 MySQL Server、PostgreSQL 和 Redis Cluster,这是实现数据库层**基础设施即代码(IaC)**的关键技能。
|
||
|
||
## 核心概念
|
||
|
||
### 工作原理
|
||
|
||
```mermaid
|
||
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 连接会话——临时模块脚本仍通过原始身份推送,但实际运行时切换身份。
|
||
|
||
```yaml
|
||
# 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 结构骨架
|
||
|
||
```yaml
|
||
- 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 |
|
||
|
||
### 变量优先级体系(由低到高)
|
||
|
||
```yaml
|
||
# 变量加载顺序(低 → 高):
|
||
# 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
|
||
# 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 模板实战
|
||
|
||
```ini
|
||
# 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` 兼容更好。
|
||
|
||
#### 基础用法
|
||
|
||
```yaml
|
||
- 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": ...}, ...]` 结构:
|
||
|
||
```yaml
|
||
- 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`:
|
||
> ```yaml
|
||
> loop: "{{ query('inventory_hostnames', 'all') }}"
|
||
> # 或
|
||
> loop: "{{ lookup('inventory_hostnames', 'all', wantlist=True) }}"
|
||
> ```
|
||
|
||
#### 循环控制(loop_control)
|
||
|
||
```yaml
|
||
- 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` 不是用来遍历数据的——它是 **轮询重试** 模式,适用于可能暂时失败、最终会成功的操作(如等待服务启动):
|
||
|
||
```yaml
|
||
- 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` 后需手动处理:
|
||
> ```yaml
|
||
> loop: "{{ [1, [2, 3], 4] | flatten(1) }}"
|
||
> ```
|
||
|
||
### 条件判断(when)
|
||
|
||
```yaml
|
||
- 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 机制详解
|
||
|
||
```yaml
|
||
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 结束后执行一次**。这正是幂等性设计的一部分——避免重复重启服务。
|
||
|
||
### 结果捕获与错误处理
|
||
|
||
```yaml
|
||
# 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 触发:
|
||
|
||
```yaml
|
||
# ❌ 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 等价物,适合需要 **原子化操作 + 自动回滚** 的场景:
|
||
|
||
```yaml
|
||
- 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` 检查
|
||
|
||
错误写法:
|
||
```yaml
|
||
- ansible.builtin.command: systemctl start mysqld # ❌ 每次都会尝试启动,即使已在运行
|
||
```
|
||
|
||
正确写法:
|
||
```yaml
|
||
- ansible.builtin.service: # ✅ Ansible 自带的 service 模块会检查当前状态
|
||
name: mysqld
|
||
state: started
|
||
```
|
||
|
||
### 2. Inventory 分层管理
|
||
|
||
把开发、测试、生产环境的机器分开管理:
|
||
```ini
|
||
# 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
|
||
|
||
```bash
|
||
# --check 只做预演,不实际执行
|
||
ansible-playbook deploy.yml --check --diff
|
||
```
|
||
|
||
### 4. 警惕 shell/command 失去幂等性
|
||
|
||
```yaml
|
||
# ❌ 每次运行都会在日志末尾追加一行
|
||
- ansible.builtin.shell: echo "deployed" >> /tmp/deploy-history
|
||
|
||
# ✅ 用 file module 保证状态一致性
|
||
- ansible.builtin.file:
|
||
path: /tmp/deploy-marker
|
||
state: touched
|
||
modification_time: preserve
|
||
```
|
||
|
||
## 延伸阅读
|
||
|
||
- [[technical/xinfra-preview/k8s-rke2-fundamentals]] — K8s 内的实例 vs Ansible 管理的裸机/VM
|
||
- [[technical/xinfra-preview/cachecloud-overview]] — CacheCloud 管 Redis,Ansible 负责底层部署
|
||
- [[technical/xinfra-preview/cloud-dm-overview]] — Ansible 部署的 MySQL 由 CloudDM 做 SQL 审核
|
||
- [官方 Playbook 指南](https://github.com/ansible/ansible-documentation/tree/devel/docs/docsite/rst/playbook_guide) — Ansible 文档源码,涵盖从入门到高级语法的全部章节
|
||
- https://www.ansible.com/docs — Ansible 官方文档入口
|
||
- [Ansible Vault 文档](https://docs.ansible.com/ansible/latest/cli/ansible-vault.html) — 加密敏感凭据(密码、密钥),解决 Become 中的安全合规问题
|
||
- [入门教程](https://www.cnblogs.com/easonscx/p/10622781.html) — Ansible 基础用法参考
|