外观
项目 10:Ansible Playbook 改造服务器初始化流程
约 4787 字大约 16 分钟
AnsiblePlaybookYAMLvars
2026-05-31
项目目标
本课把第 8 课的服务器初始化脚本改造成 Ansible Playbook:使用变量、模板、条件判断和 handlers 完成用户、SSH、安全软件、防火墙和服务重启等核心任务。
最终提交 init_server.yml、模板文件、运行日志和目标服务器验证截图。
一、Playbook 解决了脚本的哪些痛点
Ad-Hoc 命令适合临时巡检,但不适合描述一套可重复交付的初始化流程。Playbook 的优势是把"要达到什么状态"写成结构化 YAML。
| Shell 脚本 | Ansible Playbook |
|---|---|
| 自己判断用户是否存在 | user 模块负责幂等 |
| 自己写正则改文件 | lineinfile、template 模块声明配置 |
| 自己决定何时重启服务 | handlers 只在配置变化时触发 |
| 日志需要自行组织 | Ansible 自动显示每个 task 的状态 |
| 回滚需要自己记录所有变更 | Playbook 的幂等性保证重复执行安全 |
Playbook 的核心结构:
- name: 初始化服务器
hosts: all
become: true
vars:
ssh_port: 2222
tasks:
- name: 安装基础软件
ansible.builtin.apt:
name: curl
state: present1.1 Playbook 中模块的幂等性哲学
Ansible 模块幂等性 (Idempotency) 演示
声明式配置的核心:只在目标状态与期望不符时才进行修改
Ansible Task
- name: 确保用户存在
user:
name: ops
state: presentVS
Shell Script
# 传统的直接执行
useradd -m ops
目标服务器状态
🖥️
/etc/passwd
root:x:0:0:root:/root:/bin/bash
执行结果输出
Shell 脚本的思维方式是"检查—执行",例如:
if ! id ops; then
useradd -m ops
fiAnsible 模块的设计哲学是"声明式":你只描述期望的最终状态,模块内部自动处理幂等。例如:
- name: 确保 ops 用户存在
ansible.builtin.user:
name: ops
state: present
shell: /bin/bash
create_home: true如果 ops 用户已存在且配置相同,这个 task 的状态是 ok(绿色);如果用户不存在,状态是 changed(黄色);如果创建失败,状态是 failed(红色)。Ansible 输出中的绿/黄/红三色编码不是装饰——它精确反映了每个 task 是否改变了系统。在 Playbook 执行完成后,PLAY RECAP 汇总中的 changed=N 直接告诉你有多少配置项被实际修改。
1.2 从任务卡第 5 课到 Playbook 的映射
回忆第 5 课 SSH 安全初始化的要点,下面是手工操作如何映射为 Ansible task:
| 手工操作 | Playbook task |
|---|---|
adduser ops | ansible.builtin.user: name=ops state=present |
复制公钥到 authorized_keys | ansible.builtin.authorized_key: user=ops key={{ lookup('file', pubkey_file) }} |
编辑 /etc/ssh/sshd_config.d/ | ansible.builtin.template: src=sshd-atsb.conf.j2 dest=/etc/ssh/sshd_config.d/ |
systemctl restart ssh | handlers 中的 ansible.builtin.service: name=ssh state=restarted |
可以看到,每个手工步骤都被封装成了声明式的模块调用,不再需要写 Shell 的 if、grep、sed。
二、YAML 缩进规则
YAML 对缩进非常敏感。课堂里最常见的错误不是模块不会用,而是缩进混乱。YAML 用缩进表示层级关系,相当于 Python 中的缩进或 JSON 中的花括号。
| 规则 | 正确做法 | 错误示例 |
|---|---|---|
| 使用空格 | 不要使用 Tab(编辑器设置 expandtab) | Tab 和空格混用导致解析错误 |
| 同级对齐 | 同一层级的 name、hosts、tasks 要对齐 | tasks 缩进不一致 |
列表用 - | 每个 task 前面都有 - name(短横后必须有空格) | 忘了空格变成 -name |
| 字符串含冒号时加引号 | 例如 "Port {{ ssh_port }}" | Port {{ ssh_port }} 被解析为字典 |
| 布尔值 | true/false 或 yes/no(小写) | True、False、YES 可能被当成字符串 |
2.1 YAML 多行字符串
YAML 中有两种多行字符串写法,在 Playbook 中经常用到:
| 语法 | 行为 | 适用场景 |
|---|---|---|
|(literal) | 保留换行符 | 写入多行配置文件内容 |
>(folded) | 将换行转为空格 | 长描述文本 |
例如在 copy 模块中写入多行内容:
- name: 写入多行配置
ansible.builtin.copy:
content: |
[Service]
ExecStart=
ExecStart=/usr/bin/myapp --config /etc/myapp.conf
dest: /etc/systemd/system/myapp.service| 后面的内容会原样写入,换行符保留。如果有多余缩进,可以用 |-(去除末尾换行)或 |+(保留末尾换行)控制。
先用语法检查:
ansible-playbook -i inventory.ini init_server.yml --syntax-check再用 check 模式预演:
ansible-playbook -i inventory.ini init_server.yml --check--syntax-check 只检查 YAML 语法和 Playbook 结构是否合法(模块是否存在、参数名是否正确),不连接任何受控端。--check 模式会连接受控端并执行 task,但不会真正修改系统(由模块决定——部分模块不支持 check 模式)。在 --apply 之前先 --check,这条原则与 Shell 脚本中的 dry-run 完全对应。
三、模板与 handlers
当 SSH 端口、用户、认证方式来自变量时,直接写死配置文件就不合适了。Ansible 的 template 模块可以把 Jinja2 模板渲染成目标配置文件。
3.1 Jinja2 模板的核心能力
Jinja2 不仅仅能做变量替换,它的常用功能包括:
| 功能 | 语法 | 示例 |
|---|---|---|
| 变量替换 | {{ var }} | Port {{ ssh_port }} |
| 条件判断 | {% if %}...{% endif %} | {% if ssh_port != 22 %}...{% endif %} |
| 循环 | {% for item in list %}...{% endfor %} | 遍历多个监听端口 |
| 过滤器 | {{ var | filter }} | {{ ssh_port | default(22) }} |
| 默认值 | {{ var | default('val') }} | 当变量未定义时取默认值 |
| 注释 | {# comment #} | 不会被渲染到输出文件中 |
模板中使用 conditions 的一个典型场景是:根据变量决定是否写入某行配置:
# SSH 配置模板 sshd-atsb.conf.j2
Port {{ ssh_port }}
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
{% if extra_listen_address is defined %}
ListenAddress {{ extra_listen_address }}
{% endif %}这个模板在 extra_listen_address 变量未定义时,ListenAddress 行不会被写入。这种"按变量决定配置"的能力在 Shell 脚本中需要写多层 if 和 echo 才能实现,而在模板中只需要一行 Jinja2 条件。
问题:Ansible 中用于把 Jinja2 模板渲染到目标主机的模块名是什么?
3.2 handlers 的触发机制
Ansible Handlers 触发机制演示
多个 Task 触发了同一个 Handler,它是执行多次还是合并执行?
Tasks (依次执行)
1. 更新 SSH 端口配置
notify: restart ssh
2. 禁用 root 密码登录
notify: restart ssh
3. 验证某文件是否存在
Handlers 队列 (等待 Play 结束)
通知事件池 (自动去重)
空
Handler: restart ssh
systemctl restart ssh如果模板内容变化,才需要重启 SSH。这个"配置变化后才执行"的动作交给 handlers:
handlers:
- name: restart ssh
ansible.builtin.service:
name: ssh
state: restartedhandlers 只有在被 notify 的任务状态为 changed 时才会运行。理解这一点,才能理解为什么 Ansible 比"每次都 restart"的脚本更稳——它避免了不必要的服务中断。
handlers 的执行时机值得特别注意:所有 task 执行完毕后,handlers 才统一执行一次。即使多个 task 都 notify 了同一个 handler,该 handler 也只执行一次。这是通过 Ansible 内部的 handler 去重机制实现的。
如果需要在 task 执行过程中立即触发 handler(例如先重启 SSH 再测试连接),可以使用 meta: flush_handlers:
- name: 配置 SSH
template:
src: sshd-atsb.conf.j2
dest: /etc/ssh/sshd_config.d/atsb.conf
notify: restart ssh
- name: 立即触发所有待执行的 handlers
meta: flush_handlers
- name: 验证 SSH 服务(此时已重启)
wait_for:
port: "{{ ssh_port }}"
state: started
timeout: 10四、解锁 Playbook
输入 handlers 的中文课堂关键词,解锁 init_server.yml。
问题:Ansible 中用于在配置发生变化后再触发服务重启的机制叫什么?(英文小写复数)
建议目录结构:
ansible-init/
├── ansible.cfg
├── inventory.ini
├── init_server.yml
├── templates/
│ └── sshd-atsb.conf.j2
├── docker/
│ ├── Dockerfile
│ └── docker-compose.yml
└── outputs/使用 Docker 搭建未初始化服务器环境
为什么用 Docker?
传统的 Ansible 学习需要多台物理机或虚拟机作为"受控端"。本课提供一套 Docker 方案:用 Dockerfile 构建一个 安装了 SSH 和 Python3 但尚未执行任何初始化的 Ubuntu 容器,用 docker-compose.yml 一键启动 3 个节点模拟服务器集群。
这样你可以在本地快速验证 Playbook,无需额外申请虚拟机资源。Docker 容器启动后约等于一台"刚装好系统、开了 SSH、等待配置"的空白服务器。
步骤 1:准备 SSH 密钥
Playbook 需要通过 SSH 密钥登录受控端。如果你的 ~/.ssh/ops_ed25519 已在第 5 课生成,请确认公钥文件存在:
ls -la ~/.ssh/ops_ed25519.pub如果不存在,先生成:
ssh-keygen -t ed25519 -f ~/.ssh/ops_ed25519 -N ""步骤 2:创建 docker/ 目录
cd ansible-init
mkdir -p docker步骤 3:编写 Dockerfile
在下方输入 Docker 构建文件的名称,获取完整的 Dockerfile 内容:
问题:用于定义 Docker 镜像构建步骤的文件名叫什么?(全小写,10 个字母)
将解锁的内容保存到 docker/Dockerfile。
Dockerfile 要点说明:
| 步骤 | 作用 |
|---|---|
FROM ubuntu:22.04 | 使用与课程一致的 Ubuntu LTS 版本 |
安装 openssh-server、python3、iptables、rsyslog | SSH 是 Ansible 通道,Python3 是执行环境,iptables 是 UFW 依赖,rsyslog 为 fail2ban 提供 /var/log/auth.log |
创建 ansible 用户 | Playbook 通过此用户登录,拥有免密 sudo 权限 |
PasswordAuthentication yes | 临时开放密码认证,Playbook 执行后会加固为仅密钥登录 |
准备 .ssh 目录 | 供 docker-compose 挂载公钥使用 |
CMD 先启 rsyslogd 再启 sshd | 容器内没有 systemd,手动启动 rsyslog 守护进程来记录 SSH 登录日志 |
步骤 4:编写 docker-compose.yml
在下方输入 Docker 多容器编排工具的名称,获取完整的 docker-compose.yml 内容:
问题:Docker 官方的多容器编排工具叫什么?(全小写,7 个字母)
将解锁的内容保存到 docker/docker-compose.yml。
compose 文件要点说明:
| 配置项 | 说明 |
|---|---|
3 个服务 node1/node2/node3 | 模拟 3 台独立的未初始化服务器 |
端口映射 2201-2203:22 + 2211-2213:2222 | 初始通过 22 端口连接;Playbook 执行后将 SSH 加固到 2222 端口,通过第二组映射验证 |
cap_add: [NET_ADMIN] | 赋予容器操作 iptables 的权限,是 UFW 正常工作的必要条件 |
volumes 挂载公钥 | 将宿主机的 ops_ed25519.pub 注入容器的 authorized_keys,实现免密登录 |
ansible-lab 网络 | 自定义桥接网络,容器间可互相通信 |
步骤 5:构建并启动
cd docker
docker compose build
docker compose up -d验证容器状态:
docker compose ps期望输出:三个服务状态均为 Up,PORTS 列显示 0.0.0.0:2201->22/tcp 等映射。
步骤 6:配置 inventory.ini
返回 ansible-init/ 目录,编辑 inventory.ini:
[servers]
node1 ansible_host=127.0.0.1 ansible_port=2201 ansible_user=ansible
node2 ansible_host=127.0.0.1 ansible_port=2202 ansible_user=ansible
node3 ansible_host=127.0.0.1 ansible_port=2203 ansible_user=ansible
[servers:vars]
ansible_ssh_private_key_file=~/.ssh/ops_ed25519同时编辑 ansible.cfg,禁用主机密钥检查(Docker 容器重建后密钥会变化):
[defaults]
inventory = inventory.ini
host_key_checking = False步骤 7:验证 SSH 连通性
ansible all -m ping期望输出:
node1 | SUCCESS => { "changed": false, "ping": "pong" }
node2 | SUCCESS => { "changed": false, "ping": "pong" }
node3 | SUCCESS => { "changed": false, "ping": "pong" }三台"未初始化服务器"已就绪,接下来可以继续执行 Playbook 了。
Docker 环境清理
完成实验后,清理容器和网络:
cd docker
docker compose down # 停止并删除容器
docker compose down -v # 同时删除网络如果需要彻底清理(删除构建的镜像):
docker compose down --rmi all五、运行流程
5.1 语法检查
ansible-playbook init_server.yml --syntax-check5.2 预演
ansible-playbook init_server.yml --check预演模式不一定能完整模拟服务重启、密钥写入和 UFW 启用,但能提前发现大多数 YAML 和模块参数错误。注意:对于 command 和 shell 模块,check 模式默认跳过执行。如果需要在 check 模式中也执行,添加 check_mode: false:
- name: 这条命令在 check 模式中也执行
command: some-check-command
check_mode: false5.3 正式执行
ansible-playbook init_server.yml | tee outputs/init-server-run.log5.4 验证目标服务器
ansible all -m command -a "id ops"
ansible all -m command -a "sshd -T" --become
ansible all -m command -a "ufw status" --become
ansible all -m command -a "systemctl is-active fail2ban" --becomeUFW 验证需要 --become
ufw status 和 sshd -T 这类命令需要 root 权限。如果不带 --become 参数,会报 you must be root。在 Playbook 中,become: true 已在头部声明,所有 task 自动提权;但 Ad-Hoc 命令必须显式加 --become(或简写 -b)。
如果使用 Docker 环境,容器的 docker-compose.yml 中已配置 cap_add: [NET_ADMIN],UFW 可以正常操作 iptables。但如果 ufw status 返回 inactive 且无法启用,检查 docker compose ps 确认容器启动时使用了更新后的 compose 文件(需要重新 docker compose up -d 使 cap_add 生效)。
如果 SSH 端口发生变化,执行完成后要同步更新 inventory.ini 里的 ansible_port,否则下一次连接仍会尝试旧端口。
5.5 验证 SSH 端口加固(Docker 环境)
Playbook 执行后,每个容器的 SSH 端口从 22 变为 2222(模板中 ssh_port: 2222)。Docker compose 已预映射了 2211-2213:2222,更新 inventory 即可验证:
第一步:更新 inventory 端口
[servers]
node1 ansible_host=127.0.0.1 ansible_port=2211 ansible_user=ansible
node2 ansible_host=127.0.0.1 ansible_port=2212 ansible_user=ansible
node3 ansible_host=127.0.0.1 ansible_port=2213 ansible_user=ansible
[servers:vars]
ansible_ssh_private_key_file=~/.ssh/ops_ed25519第二步:验证新端口的连通性
ansible all -m ping期望输出:三个节点均返回 SUCCESS,证明 SSH 端口已成功变更为 2222。
第三步:验证 Playbook 所有改动仍然生效
ansible all -m command -a "id ops"
ansible all -m command -a "sshd -T" --become
ansible all -m command -a "ufw status" --become
ansible all -m command -a "systemctl is-active fail2ban" --become为什么要预映射 2222 端口?
因为容器内部 SSHD 改端口后,原先的 2201:22 映射会失效(容器不再监听 22)。2211:2222 这组映射正是为"Playbook 执行后"准备的通道,让你能直接验证端口加固是否成功,无需重建容器。
六、register、when 与错误处理
register 用于保存某个任务的执行结果,when 用于条件执行。比如检查 SSH 配置语法后再决定是否继续:
- name: 检查 SSH 配置语法
ansible.builtin.command: sshd -t
register: sshd_check
changed_when: false
- name: 打印 SSH 检查结果
ansible.builtin.debug:
var: sshd_check.rc
when: sshd_check.rc == 0
- name: SSH 配置有误,终止执行
ansible.builtin.fail:
msg: "SSH 配置语法错误:{{ sshd_check.stderr }}"
when: sshd_check.rc != 0这段逻辑体现了 Playbook 的可读性:先做检查,把结果命名,再基于结果决定后续动作。
6.1 block/rescue/always 错误处理
Ansible 提供了类似 try/except/finally 的错误处理结构:
- name: 尝试安装 Docker
block:
- name: 添加 Docker GPG 密钥
ansible.builtin.apt_key:
url: https://download.docker.com/linux/ubuntu/gpg
state: present
- name: 添加 Docker APT 仓库
ansible.builtin.apt_repository:
repo: "deb https://download.docker.com/linux/ubuntu {{ ansible_distribution_release }} stable"
state: present
- name: 安装 Docker
ansible.builtin.apt:
name: docker-ce
state: present
rescue:
- name: 安装失败,回退到系统仓库版本
ansible.builtin.apt:
name: docker.io
state: present
always:
- name: 确保 Docker 服务已启动
ansible.builtin.service:
name: docker
state: started
enabled: trueblock 中的 task 任一个失败都会跳到 rescue,而 always 无论成败都会执行。这种结构在复杂的初始化场景中非常重要——例如你希望优先安装官方最新版 Docker,但如果仓库不可达,就回退到系统仓库的旧版本。
6.2 changed_when 与 failed_when
不是所有命令执行都应该被标记为 changed。例如:
- name: 检查是否已有容器在运行
command: docker ps -q
register: docker_ps_result
changed_when: false # 这只是一个查询,不算"变更"
failed_when: false # docker ps 的 rc 不重要,不当作失败failed_when 可以基于输出内容判断是否失败,而不仅仅依赖命令返回值:
- name: 执行数据库迁移
command: ./migrate.sh
register: migrate_result
failed_when:
- migrate_result.rc != 0
- "'FATAL' in migrate_result.stderr"七、Tags 与执行控制
随着 Playbook 变大,你可能只想运行其中一部分 task。tags 可以解决这个问题:
tasks:
- name: 创建用户
ansible.builtin.user:
name: ops
tags: [user, always]
- name: 配置 SSH
ansible.builtin.template:
src: sshd-atsb.conf.j2
dest: /etc/ssh/sshd_config.d/atsb.conf
tags: [ssh, config]
- name: 安装基础软件
ansible.builtin.apt:
name: "{{ item }}"
loop:
- vim
- git
- curl
tags: [packages]然后可以选择性执行:
# 只执行打了 ssh 标签的任务
ansible-playbook init_server.yml --tags ssh
# 跳过打了 packages 标签的任务
ansible-playbook init_server.yml --skip-tags packages
# 列出所有可用标签
ansible-playbook init_server.yml --list-tagsalways 是一个特殊标签——打了这个标签的 task 无论 --tags 或 --skip-tags 如何设置都会执行。适合用于前置检查(环境检测、配置验证)等必须运行的任务。
八、提交物清单
| 提交物 | 要求 |
|---|---|
init_server.yml | 使用 vars、tasks、register、when、handlers,至少包含用户创建、SSH 配置、防火墙、fail2ban |
templates/sshd-atsb.conf.j2 | 通过变量渲染 SSH 安全配置,至少包含端口、PermitRootLogin、PasswordAuthentication |
| 运行日志 | 保存 ansible-playbook 完整执行输出,包含 PLAY RECAP |
| 验证截图 | 展示用户、SSH、UFW、fail2ban 至少 4 项验证 |
| 简短说明 | 解释 handlers 什么时候会触发,以及为什么第二次运行 changed 数量应减少;说明哪类 task 会每次都是 changed 状态 |
九、故障排查
| 现象 | 可能原因 | 处理方法 |
|---|---|---|
mapping values are not allowed | YAML 冒号或缩进错误 | 检查冒号后是否有空格,字符串必要时加引号;使用 --syntax-check |
template not found | 模板路径不在 templates/ 或文件名写错 | Playbook 中 src 不带路径前缀,Ansible 自动搜 templates/ 目录;确认为 src: sshd-atsb.conf.j2 而非 src: templates/sshd-atsb.conf.j2 |
| SSH 重启后 Ansible 断开 | Inventory 端口仍是旧端口 | 修改 inventory.ini 的 ansible_port 后重试;或先 --check 确认端口变化 |
| handlers 没触发 | 任务状态不是 changed | 确认模板内容是否真的发生变化;检查 notify 中的 handler 名称是否与 handlers 中定义的完全一致 |
| 配置重复 | lineinfile 正则写得太宽或太窄 | 使用明确 regexp,优先用 template 替代 lineinfile 写整个配置文件 |
become 失败 | 用户没有 sudo 权限或需要密码 | 在 ansible.cfg 中配置 become_ask_pass = True,或在命令行加 --ask-become-pass |
某些 task 每次都显示 changed | task 本身的输出不稳定(如 command 模块) | 给查询类 task 加 changed_when: false |
apt 模块卡在等待锁 | 受控端有其他 apt 进程在运行 | 等待系统自动更新完成,或使用 force_apt_get: true 并增加 timeout |
docker compose build 失败 | 网络不通或 Dockerfile 语法错误 | 检查 Docker daemon 是否运行(docker ps);确认 docker/ 目录下有 Dockerfile |
ansible all -m ping 返回 UNREACHABLE | 容器未启动、端口映射错误或密钥路径不正确 | 先 docker compose ps 确认容器状态;检查 inventory.ini 端口是否与 docker-compose.yml 一致;确认 ~/.ssh/ops_ed25519.pub 文件存在 |
Host key verification failed | SSH 主机密钥变更(容器重建后密钥不同) | 在 ansible.cfg 中设置 host_key_checking = False;或手动 ssh-keygen -R [127.0.0.1]:2201 |
Permission denied (publickey) | 公钥未正确挂载到容器 | 确认 docker-compose.yml 中 volumes 路径正确;进入容器检查:docker exec ansible-node1 cat /home/ansible/.ssh/authorized_keys |
fail2ban 启动报 Unable to open /var/log/auth.log | 容器内没有 rsyslog,SSH 日志文件不存在 | 确认 Dockerfile 已安装 rsyslog 且 CMD 先启动了 rsyslogd;重建镜像:docker compose build --no-cache && docker compose up -d |
| Playbook 执行后 SSH 端口变为 2222 | Playbook 修改了 SSH 端口(如模板中设置 ssh_port: 2222) | 更新 inventory.ini 中 ansible_port=2211(Docker compose 已预映射 2211-2213:2222,切换到第二组端口即可验证;详见 5.5 节) |
十、进阶任务
探索任务 1:观察幂等性
连续运行两次 ansible-playbook init_server.yml,比较两次输出中的 changed 数量。说明哪些任务第一次会改变系统(changed=1),第二次为什么不再改变(changed=0)。如果某个 task 每次都是 changed,分析原因并修复。
探索任务 2:使用 tags 分段测试
给 Playbook 的不同功能模块打上不同 tags(如 ssh、firewall、runtime),使用 --tags 和 --skip-tags 选择性执行。记录每种组合下的执行结果差异,理解 tags 在调试大型 Playbook 时的价值。
探索任务 3:增加 block/rescue 错误恢复
为 Docker 安装流程增加 block/rescue 结构:block 中优先安装官方 docker-ce,rescue 中回退到 docker.io。模拟官方仓库不可达的场景(临时改一个不存在的 URL),验证 rescue 是否被正确触发。
