dev、staging、prod 三套环境,配置 90% 相同——端口、内存、域名、日志级别各不相同。新手做法是三份文件复制粘贴,改一个全局参数要改三处,漏一处就是一次「为什么测试环境好好的,一上线就挂」。这篇用 Jinja2 把配置变成一份模板 + 一份清单,渲染时生成各环境配置,改一处全局生效。
一、为什么是 Jinja2
Jinja2 是 Python 生态最主流的模板引擎(Flask、Ansible 的模板层都是它),用来渲染配置文件恰好三个优点:
- 语法就是「带占位符的原文」:模板长得多像 YAML/INI/nginx conf,渲染结果就长那样——不学习新的配置格式;
- 逻辑够用且克制:循环、条件、过滤器、默认值,覆盖配置场景刚刚好;
- 与 07 篇天然衔接:渲染完的配置用 SFTP 推到对应机器,批量配置管理的自研闭环就齐了。
安装:pip install jinja2(纯 Python,零负担)。
二、一张清单,一份模板
先看数据(服务器清单):
SERVERS = [
{"name": "web-01", "ip": "10.0.0.11", "tags": ["web", "nginx"]},
{"name": "web-02", "ip": "10.0.0.12", "tags": ["web", "nginx"]},
{"name": "db-01", "ip": "10.0.0.21", "port": 2222, "tags": ["db", "primary"]},
]
再看模板(demos/render_config_demo.py 内嵌):
TEMPLATE = """\
# 本文件由 render_config_demo.py 生成,请勿手改
servers:
{% for s in servers %}
- name: {{ s.name }}
host: {{ s.ip }}
port: {{ s.port | default(22) }}
tags: [{{ s.tags | join(', ') }}]
{% endfor %}
"""
模板里用到两个最常用的过滤器:
{{ s.port | default(22) }}——字段缺省时给默认值,db-01自带 2222,其余全部落回 22;{{ s.tags | join(', ') }}——列表拼成字符串。
三、关键开关:StrictUndefined
Jinja2 默认对「不存在的变量」静默渲染成空字符串——这是配置渲染里最危险的默认值:字段名打错一个字母,产出一份字段为空的合法 YAML,服务拿着空配置照样启动,炸在几小时后的线上。
解药一行:
from jinja2 import Environment, StrictUndefined
env = Environment(undefined=StrictUndefined, keep_trailing_newline=True)
StrictUndefined 让「任何未定义变量的访问」立刻抛 UndefinedError——错误从「线上事故」提前到「渲染现场」,这是本篇最重要的一行代码。
四、完整演示(demos/render_config_demo.py)
from jinja2 import Environment, StrictUndefined, UndefinedError
TEMPLATE = """\
# 本文件由 render_config_demo.py 生成,请勿手改
servers:
{% for s in servers %}
- name: {{ s.name }}
host: {{ s.ip }}
port: {{ s.port | default(22) }}
tags: [{{ s.tags | join(', ') }}]
{% endfor %}
"""
SERVERS = [
{"name": "web-01", "ip": "10.0.0.11", "tags": ["web", "nginx"]},
{"name": "web-02", "ip": "10.0.0.12", "tags": ["web", "nginx"]},
{"name": "db-01", "ip": "10.0.0.21", "port": 2222, "tags": ["db", "primary"]},
]
def render(env: Environment, tpl: str, data: dict) -> str:
return env.from_string(tpl).render(**data)
if __name__ == "__main__":
env = Environment(undefined=StrictUndefined, keep_trailing_newline=True)
print("== 正常渲染 ==")
print(render(env, TEMPLATE, {"servers": SERVERS}))
print("== 字段名写错时:StrictUndefined 让错误在渲染期就爆出来 ==")
bad_tpl = "{% for s in servers %}host={{ s.hostname }} {% endfor %}" # 数据里只有 ip
try:
render(env, bad_tpl, {"servers": SERVERS})
except UndefinedError as exc:
print(f" 捕获 UndefinedError: {exc}")
五、真实运行输出
正常渲染部分:
== 正常渲染 ==
# 本文件由 render_config_demo.py 生成,请勿手改
servers:
- name: web-01
host: 10.0.0.11
port: 22
tags: [web, nginx]
- name: web-02
host: 10.0.0.12
port: 22
tags: [web, nginx]
- name: db-01
host: 10.0.0.21
port: 2222
tags: [db, primary]
db-01 用自己的 2222,其余两台落回默认 22——default 过滤器工作正常。
错误路径部分:
== 字段名写错时:StrictUndefined 让错误在渲染期就爆出来 ==
捕获 UndefinedError: 'dict object' has no attribute 'hostname'
报错精确到你写错的那个字段名(hostname,清单里只有 ip),渲染当场终止。对比默认行为:同样的错误,默认 Undefined 会把每台机器的 host 渲染成空串,产出一份「看起来正常」的废配置。
六、循环里的隐形坑:控制渲染的空白
细看上面输出——servers: 和第一个条目之间有一行空行,条目之间也有空行。这是 Jinja2 的默认行为:{% for %} 这类块标签自身占一行,渲染后留下空行。配置变复杂后这些空行会越来越碍眼,两种修法:
{%- for s in servers %} {# 减号吃掉标签前的换行 #}
- name: {{ s.name }}
{% endfor %}
{% for s in servers %}
- name: {{ s.name }}
{% endfor -%} {# 或渲染后对结果 strip 空行 #}
以及最省事的一招:Environment(trim_blocks=True, lstrip_blocks=True),让块标签自动不产生空行。配置模板建议默认开这两个选项。
七、工程化:渲染器与数据分离
真实项目里模板和清单不该写死在代码里,标准布局:
templates/
myapp.yaml.j2 # Jinja2 模板
inventory/
dev.yaml # 各环境清单
staging.yaml
prod.yaml
render.py # 渲染器:python render.py dev > myapp.yaml
配套三条纪律:
- 生成物加「请勿手改」头(模板第一行就是干这个的),防止有人直接改渲染结果,下次渲染被覆盖又找不明白;
- 渲染结果进版本库或做摘要校验,让「线上这份配置是哪版模板、哪个清单渲染的」可追溯;
- 渲染后接校验:YAML 用
yaml.safe_load回读、nginx 用nginx -t,渲染成功 ≠ 语法正确,花一秒钟校验避免推一份废配置上生产(推的动作由 07 篇的 SFTP 完成)。
多环境配置这块拼图就位后,下一篇回到工程质量:这些脚本怎么保证改坏了能第一时间发现。
本篇代码:demos/render_config_demo.py,已在 Python 3.13.15 / Windows(Jinja2 3.1.6)真实运行,输出原样收录。