Templates(模板)

概述

参考:

Ansible 使用 Jinja2 模板实现动态表达式(在表达式中可以访问 Variables)。比如:

  • 创建一个配置文件模板,根据不同的环境,部署不同的文件,每个环境都有各自不同的数据(e.g. IP、主机名、版本、etc.)
  • 在 Playbook 中使用模板以动态设置任务名称、设置模块参数、etc.

严格地说,Ansible 的运行离不开 Jinja2,Playbook 中所有地方都使用了 Jinja2,包括几乎所有指令的值、template 模板文件、copy 模块的 content 指令的值、lookup 的 template 插件、等等。它们会先经过 Jinja2 渲染,然后再执行相关任务。

当 Ansible 开始执行 Playbook 或任务时,总是会先使用 Jinja2 去解析所有指令的值,然后再执行任务。另一方面,在编写任务的过程中也会经常用到 Jinja2 来实现一些需求。所以,Jinja2 可以重要到成为 Ansible 的命脉。

Jinja2 的内容较多,但对于学习 Ansible 来说,只需要学习其中和 template 相关的一部分 (其它的都和开发有关或 Ansible 中用不上) 以及 Ansible 对 Jinja2 的扩展功能即可。

例如,下面的 playbook 中分别使用了三种 Jinja2 特殊符号。

---
- hosts: localhost
  gather_facts: no
  tasks:
    - debug:
        msg: "hello world, {{inventory_hostname}}"
    - debug:
        msg: "hello world{# comment #}"
    - debug:
        msg: "{% if True %}hello world{% endif %}"

注:jinja2 原生的布尔值应当是小写的 true 和 false,但也支持首字母大写形式的 True 和 False。

执行结果:

TASK [debug] ************************
ok: [localhost] => {
    "msg": "hello world, localhost"
}

TASK [debug] ************************
ok: [localhost] => {
    "msg": "hello world"
}

TASK [debug] ************************
ok: [localhost] => {
    "msg": "hello world"
}

再比如模板文件 a.conf.j2 中使用这三种特殊语法:

{# Comment this line #}
variable value: {{inventory_hostname}}
{% if True %}
in if tag code: {{inventory_hostname}}
{% endif %}

对应的模板渲染任务:

- template:
    src: a.conf.j2
    dest: /tmp/a.conf

执行后,将在 / tmp/a.conf 中生成如下内容:

variable value: localhost
in if tag code: localhost

有些指令比较特殊,它们已经使用隐式的 {{}} 进行了预包围,例如 debug 模块的 var 参数、条件判断when指令,所以这时就不要手动使用 {{}} 再包围指令的值。例如:

- debug:
    var: inventory_hostname

但有时候也确实是需要在 var 或 when 中的一部分使用 {{}} 来包围表示这是一个变量或是一个表达式,而非字符串的。例如:

- debug:
    var: hostvars['{{php}}']
  vars:
    - php: 192.168.200.143

渲染逻辑

所有模板化的工作都在控制节点上完成,然后再将任务发送到目标机器并执行。这种方法最大限度地减少了目标机器上的软件包需求(jinja2 仅需在控制节点上安装)。它还限制了 Ansible 传递给目标机器的数据量。Ansible 在控制节点上解析模板,并将每个任务所需的信息仅传递给目标机器,而不是将所有数据都传递到控制节点并在目标机器上进行解析。

在 Templates 中使用 Tasks 中声明的变量

[!Attention] 在各种任务中通过 Loops(循环) 生成的 item 变量,都可以用在 Templates

比如:

Inventory 文件如下:

all:
  hosts:
    192.168.254.253:
      ansible_host: "192.168.254.253"
  vars:
    scrape_node_exporter:
      - scrape_target: self
        enable: true
      - scrape_target: demo
        enable: true

任务文件如下:

- name: "Node exporter 文件发现配置"
  ansible.builtin.template:
    src: "server/config/prometheus/file_sd/file_sd_node_exporter.yaml.jinja"
    dest: "{{monitor_server_dir}}/config/prometheus/file_sd/file_sd_node_exporter_{{ item.scrape_target }}.yaml"
    lstrip_blocks: true
    trim_blocks: true
    mode: "0644"
  loop: "{{ scrape_node_exporter }}"
  when: item.enable | bool
  tags:
    - file_sd_node_exporter

模板中就可以直接使用 item 变量

{% if item.scrape_target == "self" %}
{% for target in groups['self'] %}
- targets:
    - "{{ hostvars[target]['ansible_host'] }}:9100"
  labels:
    node_type: "demo"
{% endfor %}
{% endif %}
{% if item.scrape_target == "demo" %}
{% for target in groups['demo'] %}
- targets:
    - "{{ hostvars[target]['ansible_host'] }}:9100"
  labels:
    node_type: "demo"
{% endfor %}
{% endif %}

此时会生成两个文件 file_sd_node_exporter_self.yaml 和 file_sd_node_exporter_demo.yaml,每个文件中都是各自的内容

Ansible 扩展的测试函数

模板引擎是多功能的,可以用在很多方面,所以 Jinja2 自身置的大多数功能都是通用功能。使用 Jinja2 的工具可能会对 Jinja2 进行功能扩展,比如 Flask 扩展了一些功能,Ansible 也对 Jinja2 扩展了一些功能。

Ansible 扩展的测试函数官方手册:https://docs.ansible.com/ansible/latest/user_guide/playbooks_tests.html

测试字符串

Ansible 提供了三个正则测试函数:

  • match()
  • search()
  • regex()

它们都返回布尔值,匹配成功时返回 true。

其中,match() 要求从字符串的首字符开始匹配成功。

例如:

"hello123world" is match("\d+")    -> False
"hello123world" is match(".*\d+")  -> True
"hello123world" is search("\d+")   -> True
"hello123world" is regex("\d+")    -> True

版本号大小比较

Ansible 作为配置服务、程序的配置管理工具,经常需要比较版本号的大小是否符合要求。Ansible 提供了一个version测试函数可以用来测试版本号是否大于、小于、等于、不等于给定的版本号。

语法:

version('VERSION',CMP)

其中 CMP 可以是如下几种:

<, lt, <=, le, >, gt, >=, ge, ==, =, eq, !=, <>, ne

例如:

{{ ansible_facts["distribution_version"] is version("7.5","<=") }}

判断操作系统版本号是否小于等于 7.5。

子集、父集测试

  • A is subset(B)测试 A 是否是 B 的子集
  • A is superset(B)测试 A 是否是 B 的父集

例如:

- debug:
    msg: '{{[1,2,3] is subset([1,2,3,4])}}'

成员测试

Jinja2 自己有一个in操作符可以做成员测试,Ansible 另外还实现了一个 contains 测试函数,主要目的是为了结合 select、reject、selectattr 和 rejectattr 筛选器。

官方给了一个示例:

vars:
  lacp_groups:
    - master: lacp0
      network: 10.65.100.0/24
      gateway: 10.65.100.1
      dns4:
        - 10.65.100.10
        - 10.65.100.11
      interfaces:
        - em1
        - em2

    - master: lacp1
      network: 10.65.120.0/24
      gateway: 10.65.120.1
      dns4:
        - 10.65.100.10
        - 10.65.100.11
      interfaces:
          - em3
          - em4

tasks:
  - debug:
      msg: "{{ (lacp_groups|selectattr('interfaces', 'contains', 'em1')|first).master }}"

此外,Ansible 还实现了allany测试函数,all()测试表示当序列中所有元素都返回 true 时,all() 返回 true,any()测试表示当序列中只要有元素返回 true,any() 就返回 true。

仍然是官方给的示例:

  mylist:
      - 1
      - "{{ 3 == 3 }}"
      - True
  myotherlist:
      - False
      - True
tasks:
  - debug:
      msg: "all are true!"
    when: mylist is all

  - debug:
      msg: "at least one is true"
    when: myotherlist is any

测试文件

Ansible 提供了测试文件的相关函数:

  • is exists:是否存在
  • is directory:是否是目录
  • is file:是否是普通文件
  • is link:是否是软链接
  • is abs:是否是绝对路径
  • is same_file(F):是否和 F 是硬链接关系
  • is mount:是否是挂载点
- debug:
    msg: "path is a directory"
  when: mypath is directory

- debug:
    msg: "path is {{ (mypath is abs)|ternary('absolute','relative')}}"

- debug:
    msg: "path is the same file as path2"
  when: mypath is same_file(path2)

- debug:
    msg: "path is a mount"
  when: mypath is mount

测试任务的执行状态

每个任务的执行结果都有 4 种状态:成功、失败、changed、跳过。

Ansible 提供了相关的测试函数:

  • succeeded、success
  • failed、failure
  • changed、change
  • skipped、skip
- shell: /usr/bin/foo
  register: result
  ignore_errors: True

- debug:
    msg: "it failed"
  when: result is failed

- debug:
    msg: "it changed"
  when: result is changed

- debug:
    msg: "it succeeded in Ansible >= 2.1"
  when: result is succeeded

- debug:
    msg: "it succeeded"
  when: result is success

- debug:
    msg: "it was skipped"
  when: result is skipped

Ansible 扩展的 Filter

Ansible 扩展了非常多的 Filter,非常非常多,本来我只想介绍一部分。但是想到有些人不愿看英文,我还是将它们全都写出来,各位权当看中文手册好了。实际上它们也都非常容易,绝大多数筛选器用法几乎都不用动脑,一看便懂。

类型转换类筛选器

例如:

{{"123"|int}}
{{"123"|float}}
{{123|string}}
{{range(1,6)|list}}
{{123|bool}}

注意,没有 dict 筛选器转换成字典类型。

获取当前时间点

Ansible 提供的 now() 可以获取当前时间点。

例如:

- debug:
    msg: "{{now()}}"

得到结果:

ok: [localhost] => {
  "msg": "2020-01-25 00:27:17.563627"
}

可以指定输出的格式化字符串,支持的格式化字符串参考 python 官方手册:https://docs.python.org/3/library/datetime.html#strftime-strptime-behavior

例如:

- debug:
    msg: '{{now().strftime("%Y-%m-%d %H:%M:%S.%f")}}'

YAML、JSON 格式化

Ansible 提供了几个和 YAML、JSON 格式化相关的 Filter:

to_yaml
to_json
to_nice_yaml
to_nice_json

它们都可使用 indent 参数指定缩进的层次。

to_yamlto_json适用于调试,to_nice_yamlto_nice_json适用于用户查看。

例如:

- debug:
    msg: '{{f1|to_nice_json(indent=2)}}'
  vars:
    f1:
      father: "Bob"
      mother: "Alice"
      Children:
        - Judy
        - Tedy

参数忽略

Ansible 提供了一个特殊变量 omit,可以用来忽略模块的参数效果。

官方手册给了一个非常有代表性的示例,如下:

- name: touch files with an optional mode
  file:
    dest: "{{ item.path }}"
    state: touch
    mode: "{{ item.mode | default(omit) }}"
  loop:
    - path: /tmp/foo
    - path: /tmp/bar
    - path: /tmp/baz
      mode: "0444"

当所迭代的元素中不存在 mode 项,则使用默认值,默认值设置为特殊变量 omit,使得 file 模块的 mode 参数被忽略,相当于未书写该参数。只有给定了 mode 项时,mode 参数才生效。

列表元素连接

join可以将列表各个元素根据指定的连接符连接起来:

{{ [1,2,3] | join("-") }}

列表压平

前面的文章曾介绍过 flatten 筛选器,它可以将嵌套列表压平。

例如:

- debug:
    msg: "{{ [3, [4, 2] ] | flatten }}"
- debug:
    msg: "{{ [3, [4, [2]] ] | flatten(levels=1) }}"

并集、交集、差集

Ansible 提供了集合理论类的求值操作:

  • unique:去重
  • union:并集,即两个集合中所有元素
  • intersect:交集,即两个集合中都存在的元素
  • difference:差集,即返回只在第一个集合中,不在第二个集合中的元素
  • symmetric_difference:对称差集,即返回两个集合中不重复的元素
- name: return [1,2,3]
  debug:
    msg: "{{ [1,2,3,2,1] | unique }}"
- name: return [1,2,3,4]
  debug:
    msg: "{{ [1,2,3] | union([2,3,4]) }}"
- name: return [2,3]
  debug:
    msg: "{{ [1,2,3] | intersect([2,3,4]) }}"
- name: return [1]
  debug:
    msg: "{{ [1,2,3] | difference([2,3,4]) }}"
- name: return [1,4]
  debug:
    msg: "{{ [1,2,3] | symmetric_difference([2,3,4]) }}"

dict 和 list 转换

  • dict2items():将字典转换为列表
  • items2dict():将列表转换为字典

对于dict2items,例如:

- debug:
    msg: "{{ p | dict2items }}"
  vars:
    p:
      name: junmajinlong
      age: 28

得到:

[
  {
    "key": "name",
    "value": "junmajinlong"
  },
  {
    "key": "age",
    "value": 28
  }
]

对于items2dict,例如:

- debug:
    msg: "{{ p | items2dict }}"
  vars:
    p:
      - key: name
        value: junmajinlong
      - key: age
        value: 28

得到:

{
  "age": 28,
  "name": "junmajinlong"
}

默认情况下,dict2itemsitems2dict都使用keyvalue来转换,但它们都允许使用key_namevalue_name自定义转换的名称。

例如:

- debug:
    msg: "{{  files | dict2items(key_name='file', value_name='path') }}"
  vars:
    files:
      users: /etc/passwd
      groups: /etc/group

得到:

[
  {
    "file": "users",
    "path": "/etc/passwd"
  },
  {
    "file": "groups",
    "path": "/etc/group"
  }
]

zip 和 zip_longest

zipzip_longest可以将多个列表的元素一一对应并组合起来。它们的区别是,zip 以短序列为主,zip_longest以最长序列为主,缺失的部分使用 null 填充。

例如:

- name: return [[1,"a"], [2,"b"]]
  debug:
    msg: "{{ [1,2] | zip(['a','b']) | list }}"

- name: return [[1,"a"], [2,"b"]]
  debug:
    msg: "{{ [1,2] | zip(['a','b','c']) | list }}"

- name: return [[1,"a"], [2,"b"], [null, "c"]]
  debug:
    msg: "{{ [1,2] | zip_longest(['a','b','c']) | list }}"

- name: return [[1,"a","aa"], [2,"b","bb"]]
  debug:
    msg: "{{ [1,2] | zip(['a','b'], ['aa','bb']) | list }}"

在 Python 中经常会将 zip 的运算结果使用 dict() 构造成字典,Jinja2 中也可以:

- name: !unsafe 'return {"a": 1, "b": 2}'
  debug:
    msg: "{{ dict(['a','b'] | zip([1,2])) }}"

子元素 subelements

subelements 筛选器在前一章节详细解释过,这里不再介绍。

random 生成随机数

Jinja2 自身内置了一个 random 筛选器,Ansible 也有一个 random 筛选器,比 Jinja2 内置的定制性要更强一点。

"{{ ['a','b','c'] | random }}"
# => 'c'

# 生成[0,60)的随机数
"{{ 60 | random }} * * * * root /script/from/cron"
# => '21 * * * * root /script/from/cron'

# 生成[0,100)的随机数,步进为10
{{ 101 | random(step=10) }}
# => 70

# 生成[1,100)的随机数,步进为10
{{ 101 | random(1, 10) }}
# => 31
{{ 101 | random(start=1, step=10) }}
# => 51

# 指定随机数种子。
# 下面指定为主机名,所以同主机生成的随机数相同,但不同主机的随机数不同
"{{ 60 | random(seed=inventory_hostname) }} * * * * root /script/from/cron"

shuffle 打乱顺序

shuffle 可以打乱一个序列的顺序

{{ ['a','b','c'] | shuffle }}
# => ['c','a','b']
{{ ['a','b','c'] | shuffle }}
# => ['b','c','a']

# 指定随机数种子
{{ ['a','b','c'] | shuffle(seed=inventory_hostname) }}
# => ['b','a','c']

json_query

可查询 Json 格式的数据,json_query在 Ansible 中非常实用,是必学 Filter 之一。

Ansible 的json_query基于 jmespath,所以需要先安装 jmespath:

pip3 install jmespath

jmespath 的查询语法相关示例可参见其官方手册:

下面我列出 Ansible 中给出的示例。

例如,对于下面的数据结构:

{
  "domain_definition": {
    "domain": {
      "cluster": [
        {"name": "cluster1"},
        {"name": "cluster2"}
      ],
      "server": [
        {
          "name": "server11",
          "cluster": "cluster1",
          "port": "8080"
        },
        {
          "name": "server12",
          "cluster": "cluster1",
          "port": "8090"
        },
        {
          "name": "server21",
          "cluster": "cluster2",
          "port": "9080"
        },
        {
          "name": "server22",
          "cluster": "cluster2",
          "port": "9090"
        }
      ],
      "library": [
        {
          "name": "lib1",
          "target": "cluster1"
        },
        {
          "name": "lib2",
          "target": "cluster2"
        }
      ]
    }
  }
}

使用

{{ domain_definition | json_query('domain.cluster[*].name') }}

可以获取到名称 cluster1 和 cluster2。

使用

{{ domain_definition | json_query('domain.server[*].name') }}

可以获取到 server11、server12、server21 和 server22。

使用

- debug:
    var: item
  loop: "{{ domain_definition | json_query(server_name_cluster1_query) }}"
  vars:
    server_name_cluster1_query: "domain.server[?cluster=='cluster1'].port"

可以迭代 8080 和 8090 两个端口。

注意上面使用了问号?,这表示后面的是一个表达式。

使用

"{{domain_definition | json_query('domain.server[?cluster==`cluster2`].{name1: name, port1: port}')}}"

可得到:

[
  {
    "name1": "server21",
    "port1": "9080"
  },
  {
    "name1": "server22",
    "port1": "9090"
  }
]

注意上面使用了反引号``而不是单双引号,因为单双引号都被使用过了,再使用就不方便,可读性也差。

ip 地址筛选

Ansible 提供了非常丰富的功能来完成 IP 地址的筛选,用法非常多,绝大多数关于 IP、网络地址类的计算、查询需求都能解决。

使用它需要先安装 python 的 netaddr 包:

pip3 install netaddr

完整用法参考官方手册:https://docs.ansible.com/ansible/latest/user_guide/playbooks_filters_ipaddr.html#playbooks-filters-ipaddr

下面是我整理的一部分用法。

检测是否是合理的 IP 地址:

{{ myvar | ipaddr }}

检测是否是合理的 ipv4 地址、ipv6 地址:

{{ myvar | ipv4 }}
{{ myvar | ipv6 }}

从列表中筛选出合理的 IP 地址:

test_list = ['192.24.2.1', 'host.fqdn', '::1', '192.168.32.0/24', 'fe80::100/10', True, '']

# {{ test_list | ipaddr }}
['192.24.2.1', '::1', '192.168.32.0/24', 'fe80::100/10']

# {{ test_list | ipv4 }}
['192.24.2.1', '192.168.32.0/24']

# {{ test_list | ipv6 }}
['::1', 'fe80::100/10']

获取 IP 地址部分:

{{ '192.0.2.1/24' | ipaddr('address') }}
{{ ipvar | ipv4('address') }}
{{ ipvar | ipv6('address') }}

检测或找出公网 IP 和私网 IP:

# {{ test_list | ipaddr('public') }}
['192.24.2.1']

# {{ test_list | ipaddr('private') }}
['192.168.32.0/24', 'fe80::100/10']

正则表达式筛选器

Ansible 提供了几个正则类的 Filter,主要有:

  • regex_search:普通正则匹配
  • regex_findall:全局匹配
  • regex_replace:正则替换

例如:

{{ 'foobar' | regex_search('(foo)') }}

# 匹配失败时返回空
{{ 'ansible' | regex_search('(foobar)') }}

# 多行模式、忽略大小写的匹配
{{ 'foo\nBAR' | regex_search("^bar", multiline=True, ignorecase=True) }}

# 全局匹配
# 每次匹配到的内容将存放在一个列表中
{{ 'DNS servers 8.8.8.8 and 8.8.4.4' | regex_findall('\\b(?:[0-9]{1,3}\\.){3}[0-9]{1,3}\\b') }}

# 正则替换
# "ansible" 替换为 "able"
{{ 'ansible' | regex_replace('^a.*i(.*)$', 'a\\1') }}

# "foobar" 替换为 "bar"
{{ 'foobar' | regex_replace('^f.*o(.*)$', '\\1') }}

# 使用命名捕获,"localhost:80" 替换为 "localhost, 80"
{{ 'localhost:80' | regex_replace('^(?P<host>.+):(?P<port>\\d+)$', '\\g<host>, \\g<port>') }}

# "localhost:80" 替换为 "localhost"
{{ 'localhost:80' | regex_replace(':80') }}

URL 处理筛选器

urlsplit筛选器可以从一个 URL 中提取 fragment、hostname、netloc、password、path、port、query、scheme、以及 username。如果不传递任何参数,则直接返回一个包含了所有字段的字典。

{{ "http://user:passwd@www.acme.com:9000/dir/index.html?query=term#fragment" | urlsplit('hostname') }}
# => 'www.acme.com'

{{ "http://user:password@www.acme.com:9000/dir/index.html?query=term#fragment" | urlsplit('netloc') }}
# => 'user:passwd@www.acme.com:9000'

{{ "http://user:passwd@www.acme.com:9000/dir/index.html?query=term#fragment" | urlsplit('username') }}
# => 'user'

{{ "http://user:passwd@www.acme.com:9000/dir/index.html?query=term#fragment" | urlsplit('password') }}
# => 'passwd'

{{ "http://user:passwd@www.acme.com:9000/dir/index.html?query=term#fragment" | urlsplit('path') }}
# => '/dir/index.html'

{{ "http://user:passwd@www.acme.com:9000/dir/index.html?query=term#fragment" | urlsplit('port') }}
# => '9000'

{{ "http://user:passwd@www.acme.com:9000/dir/index.html?query=term#fragment" | urlsplit('scheme') }}
# => 'http'

{{ "http://user:passwd@www.acme.com:9000/dir/index.html?query=term#fragment" | urlsplit('query') }}
# => 'query=term'

{{ "http://user:passwd@www.acme.com:9000/dir/index.html?query=term#fragment" | urlsplit('fragment') }}
# => 'fragment'

{{ "http://user:passwd@www.acme.com:9000/dir/index.html?query=term#fragment" | urlsplit }}
# =>
#   {
#       "fragment": "fragment",
#       "hostname": "www.acme.com",
#       "netloc": "user:password@www.acme.com:9000",
#       "password": "passwd",
#       "path": "/dir/index.html",
#       "port": 9000,
#       "query": "query=term",
#       "scheme": "http",
#       "username": "user"
#   }

编写注释的筛选器

在模板渲染中,有可能需要在目标文件中生成一些注释信息。Ansible 提供了comment筛选器来完成该任务。

{{ "Plain style (default)" | comment }}

会得到:

#
# Plain style (default)
#

可以自定义注释语法:

{{ "My Special Case" | comment(decoration="! ") }}

得到:

!
! My Special Case
!

extract 结合 map 使用时,可以根据索引 (列表索引或字典索引) 从列表或字典中提取对应元素的值。

{{ [0,2] | map('extract', ['x','y','z']) | list }}
{{ ['x','y'] | map('extract', {'x': 42, 'y': 31}) | list }}

得到:

['x', 'z']
[42, 31]

dict 合并

combine 筛选器可以将多个 dict 中同名 key 进行合并 (以覆盖的方式合并)。

{{ {'a':1, 'b':2} | combine({'b':3}) }}

得到:

{'a':1, 'b':3}

使用recursive=True参数,可以递归到嵌套 dict 中进行覆盖合并:

{{ {'a':{'foo':1, 'bar':2}, 'b':2} | combine({'a':{'bar':3, 'baz':4}}, recursive=True) }}

将得到:

{'a':{'foo':1, 'bar':3, 'baz':4}, 'b':2}

可以合并多个 dict,如下:

{{ a | combine(b, c, d) }}

d 中同名 key 会覆盖 c,c 会覆盖 b,b 会覆盖 a。

hash 值计算

计算字符串的 sha1:

{{ 'test1' | hash('sha1') }}

计算字符串的 md5:

{{ 'test1' | hash('md5') }}

计算字符串的 checksum,默认即hash('sha1')值:

{{ 'test2' | checksum }}

计算 sha512 密码 (随机 salt):

{{ 'passwd' | password_hash('sha512') }}

计算 sha256 密码 (指定 salt):

{{ passwd' | password_hash('sha256', 'mysalt') }}

同一主机生成的密码相同:

{{ 'passwd' | password_hash('sha512', 65534 | random(seed=inventory_hostname) | string) }}

根据字符串生成 UUID 值:

{{ hostname | to_uuid }}

base64 编解码筛选器

{{ encoded_str | b64decode }}
{{ decoded_str | b64encode }}

例如,Ansible 有一个slurp模块,它的作用类似于 fetch 模块,它可以从目标节点中读取指定文件的内容,然后以 base64 方式编码返回,所以要获取其原始内容,需要 base64 解码。

例如:

- slurp:
    src: "/var/run/sshd.pid"
  register: sshd_pid
- debug:
    msg: "base64_pid: {{sshd_pid.content}}"
- debug:
    msg: "sshd_pid: {{sshd_pid.content|b64decode}}"

结果:

TASK [slurp] ***************
ok: [localhost]

TASK [debug] ******************
ok: [localhost] => {
    "msg": "base64_pid: MTE4OAo="
}

TASK [debug] *****************
ok: [localhost] => {
    "msg": "base64_pid: 1188\n"
}

文件名处理

  • basename:获取字符串中的文件名部分
  • dirname:获取字符串中目录名部分
  • expanduser:扩展家目录,即将~替换为家目录
  • realpath:获取软链接的原始路径
  • splitext:扩展名分离

对于 splitext 筛选器,例如:

{{ "nginx.conf" | splitext }}
#=> ("nginx",".conf")

{{ '/etc/my.cnf' | splitext }}
#=> ("/etc/my",".cnf")

日期时间类处理

相对来说,Ansible 中处理日期时间的机会是比较少的。但是 Ansible 也提供了比较方便的处理日期时间的方式。

使用strftime将当前时间或给定时间 (只能以 epoch 数值指定) 按照给定的格式输出:

# 将当前时间点以year-month-day hour:min:sec格式输出
{{ '%Y-%m-%d %H:%M:%S' | strftime }}

# 将指定的时间按照指定格式输出
{{ '%Y-%m-%d' | strftime(0) }}          # => 1970-01-01
{{ '%Y-%m-%d' | strftime(1441357287) }} # => 2015-09-04

使用to_datetime可以将日期时间字符串转换为 Python 日期时间对象,既然得到了对象,就可以进行时间比较、时间运算等操作。

# 计算时间差(单位秒)
# 默认解析的日期时间字符串格式为%Y-%m-%d %H:%M:%S,但可以自定义格式
{{ (("2016-08-14 20:00:12" | to_datetime) - ("2015-12-25" | to_datetime("%Y-%m-%d"))).total_seconds() }}
#=>20203212.0

# 计算相差多少天。只考虑天数,不考虑时分秒等
{{ (("2016-08-14 20:00:12" | to_datetime) - ("2015-12-25" | to_datetime('%Y-%m-%d'))).days  }}

human_to_bytes 和 human_readable

human_readable将数值转换为人类可读的数据量大小单位:

{{ 1 | human_readable }}

{{ 1 | human_readable(isbits=True) }}

{{ 10240 | human_readable }}

{{ 102400000 | human_readable }}

{{ 102400000 | human_readable(unit="G") }}

{{ 102400000 | human_readable(isbits=True, unit="G") }}

human_to_bytes将人类可读的单位转换为数值:

{{ '0' | human_to_bytes }}        #= 0
{{ '0.1' | human_to_bytes }}      #= 0
{{ '0.9' | human_to_bytes }}      #= 1
{{ '1' | human_to_bytes }}        #= 1
{{ '10.00 KB' | human_to_bytes }} #= 10240
{{ '11 MB' | human_to_bytes }}    #= 11534336
{{ '1.1 GB' | human_to_bytes }}   #= 1181116006
{{ '10.00 Kb' | human_to_bytes(isbits=True) }} #= 10240

其它筛选器

quote为字符串加引号,比如方便编写 shell 模块的命令:

- shell: echo {{ string_value | quote }}

ternary根据 true、false 来决定返回哪个值:

{{ (gender == "male") | ternary('Mr','Ms') }}

{{ enabled | ternary('no shutdown', 'shutdown', omit) }}

product生成笛卡尔积:

{{['foo', 'bar'] | product(['com'])|list}}
#=> [["foo","com"], ["bar","com"]]

使用示例

- name: 将foo.j2文件输出到指定主机的/etc/file.con
  template:
    src: /mytemplates/foo.j2 # 指定源文件,是一个用jinja2语言写的文件
    dest: /etc/file.conf # 指定要生成的目的文件
    mode: 0744 # 必须添加一个前导零,以便Ansible的YAML解析器知道它是一个八进制数(例如0644或01777)或将其引号(例如'644'或'1777'),以便Ansible接收字符串并可以从字符串进行自己的转换成数字。

模板文件示例:

{
{% if docker.registryMirrors is defined %} {# 如果docker.registryMirrors变量存在,则执行最后一行之前的语句 #}
  "registry-mirrors": [{% for mirror in docker.registryMirrors %} {# 输出 "registry-mirrors": 后执行for循环,将docker.registryMirrors变量的多个值逐一传递给mirror变量,直到docker.registryMirros变量里的值全部引用完成 #}

    "{{ mirror}}"{% if not loop.last %},{% endif %} {# 输出 mirror 变量的值。如果循环没有结束,则输出一个逗号 #}
  {%- endfor %} {# 结束for循环 #}

  ],
{% endif %} {# 结束if结构 #}
}

输出结果示例:

{
  "registry-mirrors": [
    "https://ac1rmo5p.mirror.aliyuncs.com",
    "https://123.123.123"
  ]
}

检查模板渲染效果

ansible -i inventory/ ${MyHost} -m template \
  -a "src=./path/to/template.yaml.jinja dest=/tmp/config.yaml" \
  --connection local --check --diff

也可以去掉 –check 与 –diff,直接 cat /tmp/config.yaml 查看渲染结果

ansible -i inventory/ ${MyHost} -m template \
  -a "src=./path/to/template.yaml.jinja dest=/tmp/config.yaml" \
  --connection local && cat /tmp/config.yaml

完全自定义的 nginx 虚拟主机配置

在生产中,一个开发不太完善的系统可能时不时就要去 nginx 虚拟主机中添加一个 location 配置段落,如果有多个 nginx 节点要配置,无疑这是一件让人悲伤的事情。

值得庆幸,Ansible 通过 Jinja2 模板可以很容易地解决这个看上去复杂的事情。

首先提供相关的变量文件vhost_vars.yml,内容如下:

servers:
  - server_name: www.abc.com
    listen: 80
    locations:
      - match_method: ""
        uri: "/"
        root: "/usr/share/nginx/html/abc/"
        index: "index.html index.htm"
        gzip_types:
          - css
          - js
          - plain

      - match_method: "="
        uri: "/blogs"
        root: "/usr/share/nginx/html/abc/blogs/"
        index: "index.html index.htm"

      - match_method: "~"
        uri: "\\.php$"
        fastcgi_pass: "127.0.0.1:9000"
        fastcgi_index: "index.php"
        fastcgi_param: "SCRIPT_FILENAME /usr/share/www/php$fastcgi_script_name"
        include: "fastcgi_params"

  - server_name: www.def.com
    listen: 8080
    locations:
      - match_method: ""
        uri: "/"
        root: "/usr/share/nginx/html/def/"
        index: "index.html index.htm"

      - match_method: "~"
        uri: "/imgs/.*\\.(png|jpg|jpeg|gif)$"
        root: "/usr/share/nginx/html/def/imgs"
        index: "index.html index.htm"

从上面提供的变量文件来看,应该能看出来它的目的是为了能够自动生成一个或多个 server 段,而且允许随意增删改每个 server 段中的 location 及其它指令。这样一来,编写 nginx 虚拟主机配置的任务就变成了编写这个变量文件。

需注意,每个 location 段有两个变量名match_methoduri,作用是生成 nginx location 配置项的前一部分,即location METHOD URI {}。除这两个变量名外,剩余的变量名都会直接当作 nginx 配置指令渲染到配置文件中,所以它们都需和 nginx 指令名相同,比如 index 变量名渲染后会得到 nginx 的 index 指令。

剩下的就是写一个 Jinja2 模板文件,模板中 Jinja2 语句块标签部分我没有使用缩进,这样比较容易控制格式。文件内容如下:

{# 负责渲染每个指令 #}
{% macro config(key,value) %}
{% if (value is sequence) and (value is not string) and (value is not mapping) %}
{# 如果指令是列表 #}
{% for item in value -%}
{# 如生成的结果是:gzip_types css js plain; #}
{{ key ~ ' ' ~ item if loop.first else item}}{{' ' if not loop.last else ';'}}
{%- endfor %}
{% else %}
{# 如果指令不是列表 #}
{{key}} {{value}};
{% endif %}
{% endmacro %}

{# 负责渲染location指令 #}
{% macro location(d) %}
location {{d.match_method}} {{d.uri}} {
{% for item in d|dict2items if item.key != "match_method" and item.key != "uri" %}
    {{ config(item.key, item.value) }}
{%- endfor %}
  }
{% endmacro %}

{% for server in servers %}
server {
{% for item in server|dict2items %}
{# 非location指令部分 #}
{% if item.key != "locations" %}
  {{ config(item.key,item.value) }}
{%- else %}
{# 各个location指令部分 #}
{% for l in item.value|default([],true) %}
  {{ location(l) }}
{% endfor %}
{% endif %}
{%- endfor %}
}
{% endfor %}

然后使用 template 模块去渲染即可:

- hosts: localhost
  gather_facts: no
  vars_files:
    - vhost_vars.yml
  tasks:
    - template:
        src: "vhost.conf.j2"
        dest: /tmp/vhost.conf

渲染得到的结果:

server {
  server_name www.abc.com;
  listen 80;
  location  / {
    root /usr/share/nginx/html/abc/;
    index index.html index.htm;
    gzip_types css js plain;  }

  location = /blogs {
    root /usr/share/nginx/html/abc/blogs/;
    index index.html index.htm;
  }

  location ~ \.php$ {
    fastcgi_pass 127.0.0.1:9000;
    fastcgi_index index.php;
    fastcgi_param SCRIPT_FILENAME /usr/share/www/php$fastcgi_script_name;
    include fastcgi_params;
  }
}
server {
  server_name www.def.com;
  listen 8080;
  location  / {
    root /usr/share/nginx/html/def/;
    index index.html index.htm;
  }

  location ~ /imgs/.*\.(png|jpg|jpeg|gif)$ {
    root /usr/share/nginx/html/def/imgs;
    index index.html index.htm;
  }
}

最后修改 November 28, 2025: optimize ch and ansible (3362271d)