Ansible Roles 与 Jinja2:部署剧本工程化实战

单文件剧本写到三百行的时候,问题开始集中爆发:NFS 的部署逻辑和 rsync 的混在同一份文件里,改一处要通读全文;换台机器复用,全靠复制粘贴。后来我把所有剧本按官方的 roles 规范重构了一遍,顺带用 Jinja2 解决了”每台机器配置不一样”的老大难。这篇把重构的思路和三个实战案例整理出来。

一、为什么要 roles

roles 解决的是解耦:把”装 Nginx””装 MySQL””基础优化”各自拆成独立模块,谁需要就引用谁。

对比一下两种写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 重构前:所有步骤堆在一个剧本里
deploy_all.yml
├── 基础优化 30 行
├── NFS 安装配置 60 行
├── rsync 安装配置 80 行
└── 应用部署 100 行

# 重构后:按角色拆分
roles/
├── base/ # 基础优化
├── install_nfs/ # NFS
├── rsync/ # rsync
└── app/ # 应用
site.yml # 一个入口文件把角色串起来

好处很直接:新项目要用基础优化,直接引用 base 角色;改 NFS 的逻辑不会碰到 rsync 的代码。每个角色最好只干一件事,这是 roles 的核心纪律。

二、Roles 的目录结构

每个角色就是一整套标准目录,各目录各司其职:

1
2
3
4
5
6
7
8
9
roles/install_nfs/
├── tasks/main.yml # 任务清单(核心)
├── handlers/main.yml # 触发器
├── templates/exports.j2 # Jinja2 模板(带变量的配置文件)
├── files/ # 静态文件(原样分发的)
├── vars/main.yml # 变量
├── defaults/main.yml # 低优先级默认变量
├── meta/main.yml # 依赖关系
└── README.md

两个容易混的目录:files/ 放原样分发的文件,templates/ 放要渲染变量的模板(后缀 .j2)。用 copy 模块从 files 取文件,用 template 模块从 templates 取模板,取错目录会直接报找不到文件。

vars 和 defaults 的区别在优先级:vars/main.yml 优先级高(角色内部用),defaults/main.yml 优先级低(给使用者留的覆盖口子)。对外发布的角色,变量放 defaults 让别人能覆盖;内部实现细节放 vars。

用命令生成骨架最省事:

1
ansible-galaxy init install_nfs

三、Jinja2:让配置文件适配每台机器

问题场景:100 台机器要装 Nginx,每台的监听地址、后端节点列表都不一样。把配置文件写死,等于要维护 100 份;用 Jinja2 模板,一份模板渲染出 100 份。

template 模块和 copy 模块的区别就一句话:template 会解析文件里的变量,copy 原样复制。之前推送 rsync 备份脚本想让脚本里带上主机名,用 copy 推过去就是字面的 {{ ansible_fqdn }},换成 template 才渲染成实际主机名。

Jinja2 的三种语法:

1
2
3
{{ EXPR }}        输出变量值
{% for %}/{% if %} 逻辑控制
{# 注释 #}

第一个例子:按机器渲染 motd

1
2
3
4
- name: 分发 motd
template:
src: ./motd.j2
dest: /etc/motd
1
2
3
Welcome to {{ ansible_fqdn }}
This system total mem is: {{ ansible_memtotal_mb }} MB
This system free mem is: {{ ansible_memfree_mb }} MB

登录每台机器,看到的欢迎语带自己的主机名和实际内存——模板的价值一眼可见。

第二个例子:for 循环生成负载均衡后端

1
2
3
4
5
upstream {{ server_name }} {
{% for n in range(21) %}
server 172.18.1.{{ n }}:{{ up_port }};
{% endfor %}
}

配合剧本执行,一份模板直接把整段 upstream 节点列表渲染出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
- hosts: lb_group
vars:
http_port: 80
server_name: www.example.com
up_port: 80
tasks:
- name: 渲染负载均衡配置
template:
src: ./www.example.com.conf.j2
dest: /etc/nginx/conf.d/www.example.com.conf
notify: reload nginx

handlers:
- name: reload nginx
systemd:
name: nginx
state: reloaded

生产上更稳的做法是用 groups 遍历主机列表,而不是手写 range——节点增减只改清单,配置自动跟着变。

第三个例子:if 判断区分主备

keepalived 的主备配置差异(MASTER/BACKUP、优先级)用一段 if 搞定:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
global_defs {
router_id {{ ansible_fqdn }}
}

vrrp_instance VI_1 {
{% if ansible_fqdn == "lb01" %}
state MASTER
priority 150
{% else %}
state BACKUP
priority 100
{% endif %}
interface eth0
virtual_router_id 50
advert_int 1
authentication {
auth_type PASS
auth_pass 1111
}
virtual_ipaddress {
10.0.8.3
}
}

一份模板推给两台机器,主备身份自动分配。记住一个限制:Jinja2 的条件和循环只能写在模板文件里,不能写在 playbook 里——playbook 里的逻辑要用 when 和 loop。

四、实战一:用 roles 重构 NFS 部署

1
ansible-galaxy init install_nfs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# roles/install_nfs/tasks/main.yml
---
- name: 安装 NFS 服务端
yum:
name: nfs-utils
state: present

- name: 渲染 exports 配置
template:
src: exports.j2
dest: /etc/exports
notify: 重启 NFS 服务

- name: 创建共享目录
file:
path: "{{ share_dir }}"
state: directory
owner: www
group: www
mode: '0755'

- name: 启动 NFS 服务
systemd:
name: nfs
state: started
enabled: yes
1
2
3
4
5
# roles/install_nfs/handlers/main.yml
- name: 重启 NFS 服务
systemd:
name: nfs
state: restarted
1
2
# roles/install_nfs/templates/exports.j2
{{ share_dir }} {{ share_ip }}(rw,sync,all_squash,anonuid=666,anongid=666)
1
2
3
# roles/install_nfs/vars/main.yml
share_dir: /data
share_ip: 172.18.1.31
1
2
3
4
5
6
7
8
# site.yml:统一入口
- name: 部署 NFS 服务端
hosts: "{{ target_group | default('all') }}" # -e target_group=prod_nfs 指定范围
remote_user: root
roles:
- role: install_nfs
when: "'nfs01' in inventory_hostname"
tags: nfs
1
ansible-playbook -i inventories/prod/hosts -t nfs site.yml

注意上面 share_dir、share_ip 这些变量的用法:任务文件里不写死路径和 IP,全部引用变量,变量值放在 vars 或 group_vars 里。这样做完,同一套角色换个环境只改变量文件。

五、实战二:rsync 备份服务端 + 客户端

这是最典型的一组”服务端配置 + 客户端脚本”的搭档部署:

1
2
3
4
5
# site.yml
- hosts: prod_backup
remote_user: root
roles:
- rsync
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# roles/rsync/tasks/main.yml
- name: 安装 rsync
yum:
name: rsync
state: present

- name: 分发配置与认证文件(循环带不同权限)
copy:
src: "{{ item.src }}"
dest: "/etc/{{ item.dest }}"
mode: "{{ item.mode }}"
loop:
- { src: "rsyncd.conf", dest: "rsyncd.conf", mode: "0644" }
- { src: "rsync.passwd", dest: "rsync.passwd", mode: "0600" }
notify: 重启 rsync 服务

- name: 启动 rsync 服务
systemd:
name: rsyncd
state: started
enabled: yes

一个细节值得强调:rsyncd.conf 和 rsync.passwd 的权限不一样,用字典循环把”文件 + 目标 + 权限”打包成一组参数,一个任务搞定。认证文件 0600 是硬要求,rsync 对密码文件权限过宽会直接拒绝启动。

服务端跑通之后,客户端侧是把备份脚本推下去、加进 crontab:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 客户端部分:给 web 机器下发备份脚本和定时任务
- hosts: prod_web
tasks:
- name: 下发备份脚本
copy:
src: ./backup.sh
dest: /root/backup.sh
mode: '0755'

- name: 添加定时备份任务
cron:
name: "rsync-backup-daily"
user: root
minute: "30"
hour: "1"
job: "/bin/bash /root/backup.sh >> /var/log/backup.log 2>&1"

部署完用 ansible-playbook --syntax-check 过一遍语法,再用 -C 干跑确认变更范围,最后才正式执行——这套”检查三连”用在所有生产剧本上。

六、实战三:playbook 部署 LAMP

最后看一个完整项目:用剧本部署 httpd + PHP + MariaDB + WordPress:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
- hosts: prod_web
tasks:
- name: 安装 LAMP 组件
yum:
name: "{{ packages }}"
vars:
packages:
- httpd
- mariadb-server
- php
- php-mysql
- php-pdo

- name: 启动 httpd
systemd:
name: httpd
state: started
enabled: yes

- name: 启动 mariadb
systemd:
name: mariadb
state: started
enabled: yes

- name: 下载 WordPress
get_url:
url: "http://files.example.com/software/wordpress-5.0.3-zh_CN.tar.gz"
dest: /var/www/html

- name: 解压
unarchive:
src: /var/www/html/wordpress-5.0.3-zh_CN.tar.gz
dest: /var/www/html
remote_src: yes

这个例子里所有服务写在一个文件中,只适合练手。按前面讲的 roles 纪律,生产环境应该拆成 httpd、php、mariadb 三个独立角色,各自带自己的任务、模板和变量——这样任何项目要装 PHP,引用一下就行。

项目跑通后可以顺着往深挖两步:用 mysql_db 和 mysql_user 模块建库建账号(代码里不用再手敲 grant 语句),再把 WordPress 的数据库连接信息用 Jinja2 模板渲染进 wp-config.php。一套完整的”部署 + 配置”闭环就形成了。

七、角色依赖与社区角色

角色之间可以声明依赖。比如 WordPress 依赖 Nginx 和 PHP,写在 meta 里:

1
2
3
4
# roles/wordpress/meta/main.yml
dependencies:
- { role: nginx }
- { role: php }

执行到 wordpress 角色时,Ansible 会先跑 nginx 和 php——部署顺序不用在 site.yml 里手工排。

社区角色库 Ansible Galaxy 值得会用:

1
2
3
4
ansible-galaxy search nginx              # 搜索
ansible-galaxy info geerlingguy.nginx # 看详情
ansible-galaxy install geerlingguy.nginx # 安装
ansible-galaxy list # 已安装列表

社区角色适合做参考和起步,但生产环境我建议核心角色自己写:社区角色的变量、行为不一定符合你的规范,出了问题也不好排查。参考它的目录组织和变量设计,比直接用更值。

收尾的一点感受

从单文件剧本到 roles 工程化,核心变化就三点:一个角色只干一件事、可变内容全部进变量、配置差异交给 Jinja2 模板。做到这三点,你的一套部署代码就能从几台机器的练习环境,平滑地复制到上百台的业务环境——这才是自动化工具真正的价值所在。