Jinja2 模板与敏感信息管理
配置文件一旦要「按机器生成」,就该上模板了。这篇笔记记录四块内容:template 模块渲染 Jinja2 模板、常用过滤器速查、facts 与 magic variables 这两个取数来源、以及用 ansible-vault 把密码密钥安全地放进 git。每一块都可以单独翻,示例都是日常运维里直接照抄就能用的。
文中示例基于 ansible-core 2.16/2.17,控制节点为 macOS 或 Linux,受管节点为 Ubuntu 22.04。模块名一律使用 FQCN(如 ansible.builtin.template)。涉及的 ansible.utils.ipaddr 过滤器需要控制节点额外安装 netaddr Python 包。
template 模块
ansible.builtin.template 做的事一句话说完:把控制节点上的 .j2 模板渲染成文本,推到受管节点的目标路径。约定俗成的几点:
- 模板文件放在 playbook 同级(或 role 内)的
templates/目录下,src只写文件名就能找到。 - 文件以
.j2结尾,推送到远端后dest不带这个后缀。 - 渲染发生在控制节点,受管节点上不需要装 Jinja2。
和 ansible.builtin.copy 的区别:copy 是原样搬运静态文件,template 会在搬运前先把 {{ }}、{% %} 这些占位符替换成真实值。文件里没有任何变量就用 copy(还能省一次渲染),有变量就必须用 template。
常用参数:
| 参数 | 作用 |
|---|---|
src | 模板文件名(在 templates/ 下找) |
dest | 受管节点上的目标绝对路径 |
owner / group | 属主、属组 |
mode | 权限,写成字符串 '0644',避免 YAML 把它解析成八进制出错 |
backup | 覆盖前在远端留一份带时间戳的备份,改配置时建议开着 |
validate | 替换前先在远端执行校验命令,%s 代指临时文件路径 |
一个最小的 nginx 站点配置例子:
server {
listen {{ nginx_port }};
server_name {{ nginx_server_name }};
root {{ nginx_docroot }};
index index.html;
}
- name: 下发 nginx 站点配置
ansible.builtin.template:
src: app.conf.j2
dest: /etc/nginx/conf.d/app.conf
owner: root
group: root
mode: '0644'
backup: true
notify: 重载 nginx
validate 接收的是一条命令,Ansible 会先把渲染结果放到远端临时文件,跑一遍校验,通过了才替换正式文件。经典例子是 sshd:validate: /usr/sbin/sshd -t -f %s。注意校验命令要对单文件有效——上面这个 conf.d 片段没法被 nginx -t 单独校验,就别硬加。
Jinja2 基础
模板里只有三种语法需要记住:{{ }} 输出变量、{% %} 写控制逻辑、{# #} 写注释(不会渲染到结果里)。
插值:{{ 变量 }}
listen {{ nginx_port }};
# nginx_port = 8080 时渲染为:listen 8080;
变量不存在默认会报错。想容忍缺省值,配合 default 过滤器(下一节细说)。
条件:{% if %}
{% if enable_https | bool %}
listen 443 ssl;
ssl_certificate /etc/nginx/ssl/{{ ssl_cert_name }}.crt;
ssl_certificate_key /etc/nginx/ssl/{{ ssl_cert_name }}.key;
{% else %}
listen 80;
{% endif %}
判断里可以直接用比较和逻辑运算:{% if ansible_facts['memtotal_mb'] > 4096 %}、{% if env == 'prod' and enable_https %}。
循环:{% for %}
最常见的实战场景:根据清单里的 app 组渲染 nginx upstream,加一台机器不用改模板:
upstream app_backend {
{% for host in groups['app'] %}
server {{ hostvars[host]['ansible_facts']['default_ipv4']['address'] }}:{{ app_port }};
{% endfor %}
}
假设 app 组有三台机器,渲染结果是:
upstream app_backend {
server 10.0.1.11:8000;
server 10.0.1.12:8000;
server 10.0.1.13:8000;
}
循环里还有几个好用的内置变量:loop.index(从 1 开始的序号)、loop.first / loop.last(是否第一轮 / 最后一轮,拼逗号分隔列表时很有用)。
{% for %} 这类控制行本身也会占一行并渲染出一个空行。不想要空行就用行内写法,或给标签加减号吞掉换行:{%- for host in groups['app'] -%}。生成的配置文件对空行不敏感时不用纠结。
常用过滤器速查
过滤器用竖线跟在变量后面:{{ 变量 | 过滤器 }},可以连写 {{ x | trim | upper }}。按使用频率整理,每个配一行示例:
| 过滤器 | 作用 | 示例 |
|---|---|---|
default | 变量未定义时给默认值 | {{ worker_processes | default(ansible_facts['processor_count']) }} |
join | 列表拼成字符串 | {{ allowed_ips | join(',') }} |
length | 取长度 | {{ groups['app'] | length }} |
upper / lower | 大小写转换 | {{ env | upper }} |
replace | 字符串替换 | {{ release | replace('-', '_') }} |
regex_replace | 正则替换 | {{ version_str | regex_replace('^v', '') }} |
to_json | 转 JSON | {{ app_config | to_json }} |
to_nice_yaml | 转 YAML(可读) | {{ app_config | to_nice_yaml(indent=2) }} |
int / float | 转数字 | {{ '8080' | int + 1 }} |
bool | 字符串转布尔 | {{ enable_https | bool }} |
basename / dirname | 路径处理 | {{ '/etc/nginx/nginx.conf' | basename }} |
ansible.utils.ipaddr | IP/网段计算 | {{ '10.0.1.0/24' | ansible.utils.ipaddr('network') }} |
几个值得单独说的:
to_nice_yaml 的缩进问题。把字典渲染进 YAML 文件时,直接 {{ var | to_nice_yaml }} 输出的内容从第一列开始,嵌套在模板里会对不齐。两种解法:用 indent 过滤器整体缩进——{{ var | to_nice_yaml(indent=2) | indent(2) }};或者模板里把它顶格放,靠变量本身的内容对齐。
类型转换不是摆设。从命令行 -e 传入的变量全是字符串,'yes'、'true' 进 if 判断前先过一道 | bool;做数值比较先 | int,否则 '10' > '9' 这种字符串比较会给你惊喜。
ipaddr 系列要装依赖。ansible.utils.ipaddr 依赖控制节点上的 netaddr 包(pip install netaddr),能做取网段、判断 IP 合法性、算广播地址等操作,网络配置模板里常用,这里点到为止。
facts
gather_facts: true(play 的默认行为)时,Ansible 会先跑一遍 ansible.builtin.setup 模块,把受管节点的系统信息收集回来,挂在 ansible_facts 变量下供后续任务和模板使用。
日常用得最多的几个键:
| 键 | 内容 | 示例值 |
|---|---|---|
ansible_facts['distribution'] | 发行版 | Ubuntu |
ansible_facts['distribution_version'] | 发行版版本 | 22.04 |
ansible_facts['os_family'] | 发行版家族(写跨发行版 playbook 靠它) | Debian |
ansible_facts['default_ipv4']['address'] | 默认出口的 IPv4 地址 | 10.0.1.11 |
ansible_facts['memtotal_mb'] | 内存总量(MB) | 7939 |
ansible_facts['processor_count'] | 物理 CPU 颗数(线程数用 processor_vcpus) | 2 |
不跑 playbook、只想临时看一眼,可以直接 ad-hoc 调 setup 模块:
# 看全部 facts(输出很长)
ansible web -m ansible.builtin.setup
# 只看发行版相关,filter 用的是旧式 ansible_ 前缀写法
ansible web -m ansible.builtin.setup -a "filter=ansible_distribution*"
web1 | SUCCESS => {
"ansible_facts": {
"ansible_distribution": "Ubuntu",
"ansible_distribution_version": "22.04"
},
...
}
收集 facts 每台机器要多花一两秒,机器多、任务又不需要系统信息时,在 play 里写 gather_facts: false 能明显提速。想要折中,在 ansible.cfg 里设 gathering = smart:本 playbook 运行期间收集过的 facts 不重复收集,再配 fact_caching(jsonfile 或 redis)还能跨运行缓存。
Magic variables
Ansible 预置了一批和当前执行上下文相关的变量,官方叫 magic variables。不需要收集 facts 就有,常用的五个:
| 变量 | 内容 |
|---|---|
inventory_hostname | 当前主机在清单里的名字 |
groups | 所有组到主机列表的映射 |
hostvars | 所有主机的变量(含 facts),按主机名索引 |
play_hosts | 当前 play 实际要跑的主机列表(受 --limit 影响) |
ansible_playbook_python | 控制节点上执行 Ansible 的 Python 解释器路径 |
inventory_hostname 最常出现在报表和日志路径里:/var/log/deploy/{{ inventory_hostname }}.log。
hostvars 是「跨机器取变量」的唯一通道。比如模板里要填数据库地址,而数据库在 db 组第一台机器上:
DB_HOST={{ hostvars[groups['db'][0]]['ansible_facts']['default_ipv4']['address'] }}
hostvars 里的 facts 只对「本次运行中已经 gather 过 facts 的机器」有值。如果 db 组主机在当前 playbook 里没被任何 play 覆盖到,上面这种写法会拿到 undefined。解决办法是在用之前加一个针对 db 组的空 play 专门收集 facts,或者把这类固定信息直接写进清单变量,不依赖 facts。
ansible-vault
数据库密码、API token、私钥这类敏感变量总要有个地方放。直接明文写进 group_vars 再提交 git,等于把密码贴在公告栏。ansible-vault 是 Ansible 自带的加密方案:加密后的文件照常进 git,运行时凭 vault 密码解密,不需要引入外部系统。
什么时候该用
判断标准很简单:这个值泄进 git 历史会不会让你睡不着觉。会,就 vault。常见的有:数据库口令、sudo/become 密码、TLS 私钥、第三方 API token、云厂商 AccessKey。
encrypt_string:加密单个变量(推荐)
只加密一个变量的值,文件其余部分保持明文,粒度最细,code review 时也能看清结构:
ansible-vault encrypt_string 'S3cret-DB-Pass' --name db_password
Encryption successful
db_password: !vault |
$ANSIBLE_VAULT;1.1;AES256
66386439653136343032396639373365643139393231656630366239363362356439306538613932
...
把这段输出原样贴进 group_vars/all.yml 即可。不想每次交互输入 vault 密码,加 --vault-password-file ~/.vault_pass 一并带上。
encrypt:加密整个文件
敏感变量扎堆时,单独建一个文件整个加密更清爽:
ansible-vault encrypt group_vars/production/vault.yml
日常配合的文件操作:
ansible-vault view group_vars/production/vault.yml # 只读查看
ansible-vault edit group_vars/production/vault.yml # 编辑(解密→改→自动重新加密)
ansible-vault rekey group_vars/production/vault.yml # 换 vault 密码
ansible-vault decrypt group_vars/production/vault.yml # 解密回明文(提交前想清楚)
常见目录约定是每组两个文件:group_vars/production/vars.yml 放明文,group_vars/production/vault.yml 放密文,vars.yml 里用 db_password: "{{ vault_db_password }}" 引用密文文件里的变量。好处是密文文件只存真正的秘密,翻明文文件就能看到变量全貌。
运行时怎么提供 vault 密码
三种方式,按场景选:
# 1. 交互输入,适合手动执行
ansible-playbook site.yml --ask-vault-pass
# 2. 密码文件,适合 CI 和脚本
ansible-playbook site.yml --vault-password-file ~/.vault_pass
第三种是写进 ansible.cfg,之后所有命令自动带上:
[defaults]
vault_password_file = ./.vault_pass
.vault_pass 要是跟着进了仓库,加密就成了行为艺术。第一时间把它加进 .gitignore,文件权限设 chmod 600。团队共享 vault 密码走密码管理器或密钥分发渠道,别走 IM 明文。
playbook 里引用 vault 变量
引用方式和普通变量一模一样,这是 vault 设计上最省心的一点:
- name: 创建数据库用户
community.mysql.mysql_user:
name: app
password: "{{ db_password }}"
priv: "app.*:ALL"
只要运行时提供了 vault 密码,解密是自动的;没提供密码就直接报错中止,不会把密文当明文发出去。
常见坑
default 过滤器对「已定义但为空」不生效
{{ log_level | default('info') }}
只在 log_level 完全没定义时才给 info。如果变量定义了但值是空字符串、空列表或者 false,原样输出。要连空值一起兜住,加第二个参数:
{{ log_level | default('info', true) }}
第二个参数为 true 时,Jinja2 把所有「假值」(空串、空列表、false、none)也当作未定义处理。
vault 文件提交前确认真的加密了
最惨的 vault 事故不是密码泄露,而是以为加密了其实没有——encrypt 之前手快先 git commit 了。提交前养成习惯看一眼文件头:
head -1 group_vars/production/vault.yml
$ANSIBLE_VAULT;1.1;AES256
不是这个开头就还没加密。encrypt_string 同理,贴进 yaml 的内容必须带 !vault | 和 $ANSIBLE_VAULT 头。
模板语法和 YAML 引号冲突
playbook 是 YAML,YAML 里以 { 开头的值会被当成 flow mapping 解析,直接报语法错:
# 错误:YAML 解析器把 {{ ... 当成字典
- name: 打印主机名
ansible.builtin.debug:
msg: {{ inventory_hostname }}
# 正确:整个值加引号
- name: 打印主机名
ansible.builtin.debug:
msg: "{{ inventory_hostname }}"
口诀:值以 {{ 开头就必须整体加引号。变量在字符串中间时不加也没事,但统一加上更不容易踩雷。反过来,模板文件(.j2)里写的 YAML 是给应用看的,不存在这层冲突。
中文模板注意编码
Jinja2 默认按 UTF-8 处理模板,Linux/macOS 下编辑的中文模板没有问题。要躲的是从 Windows 编辑器过来的文件:带 BOM 的 UTF-8 或 GBK 编码会让渲染结果混入乱码或隐形字符,配置文件在受管端解析失败。收到别人给的模板先 file -i templates/xxx.j2 确认是 charset=utf-8,不是就用编辑器另存一遍。
小结
这篇的四块内容其实是一条链:template 模块负责把配置「算出来」,Jinja2 语法和过滤器是算的工具,facts 和 magic variables 提供算的原料(本机信息、跨机信息),ansible-vault 保证原料里的敏感部分能安全入库。日常写 playbook,百分之八十的模板需求用 {{ }}、{% for %}、default、join 这几样就够了;剩下的百分之二十——多后端渲染、跨机取变量、加密变量——本篇的示例都覆盖了,照着改即可。