跳到主要内容

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 参数的使用姿势

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 %} 这类控制行本身也会占一行并渲染出一个空行。不想要空行就用行内写法,或给标签加减号吞掉换行:{%- 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.ipaddrIP/网段计算{{ '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

收集 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 的前提

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 密码文件绝不能进 git

.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 这几样就够了;剩下的百分之二十——多后端渲染、跨机取变量、加密变量——本篇的示例都覆盖了,照着改即可。