第 4-3 章 Linux Ansible 实践梳理

作者: Brinnatt 分类: ARM64 Linux CICD 研发 发布时间: 2025-04-01 10:37

前面通过理论和实践结合对 ansible 有了一些了解,在生产上用起来不会有什么大的问题。不过,ansible 还有很多功能和细节没有涉及到,生产上遇到了再说,下面只是针对实践中主要的功能梳理一下,用起来更顺。

4.1、变量

Ansible 的变量很复杂,因为 Ansible 的变量来源很多,查一下官方手册对变量的介绍,相当震撼,还搞了一个优先级,不过前面我们提到的一些变量也能把 ansible 用起来,不必非得都了解。

4.1.1、访问列表、字典变量

Ansible 中经常需要访问列表和字典类型的变量。例如下面的字典类型:

p: 
  a: aa
  b: bb

files:
  - /tmp/a.txt
  - /tmp/b.txt

对于这类变量的访问,Ansible 中提供了两种方式:

  1. 按照 Python 字典或列表的索引下标方式访问。例如 p["a"]files[0]

  2. 按照对象访问方式。例如 p.bfiles.0

值得一提的是,如果字典的 key 名称和 Python 的字典方法名冲突了,就会有问题。比如:

p: 
  a: aa
  b: bb
  keys: "tic"

如果用 p.keys 来访问这个字典变量会出错,因为 Python 的字典类型有一个称为 keys 的方法名。

4.1.2、–extra-vars 选项

ansible-playbook 命令的 -e 选项或 --extra-vars 选项可定义变量或引入变量文件。

# 定义单个变量
$ ansible-playbook -e 'var1="value1"' xxx.yml

# 定义多个变量
$ ansible-playbook -e 'var1="value1" var2="value2"' xxx.yml

# 引入单个变量文件
$ ansible-playbook -e '@varfile1.yml' xxx.yml

# 引入多个变量文件
$ ansible-playbook -e '@varfile1.yml' -e '@varfile2.yml' xxx.yml

因为是通过选项的方式来定义变量的,所以它所定义的变量是全局的,对所有 play 都有效。

通常来说不建议使用 -e 选项,因为这对用户来说是不透明也不友好的,要求用户记住定义了哪些变量,冲突可能性也比较大。

4.1.3、inventory 变量

在解析 inventory 时,会收集 inventory 相关的变量。

inventory 变量主要分为两种:

  1. 连接目标节点时的行为控制变量,即决定如何连接目标节点

  2. 主机变量

行为控制变量(如 ansible_portansible_host 等)用于指定 Ansible 端连接目标节点时的连接参数,可设置的参数项比较多,可参见官方手册

inventory 的主机变量有多种定义途径,例如直接在 inventory 文件中为某个主机定义变量,也可以在主机组中定义变量,主机组变量会在解析 inventory 的时候整理到主机变量中去。此外还可以将变量定义在 host_vars/group_vars/ 目录内。

# 在主机上直接定义变量
[dev]
192.168.200.42 aaa=333 bbb=444 ansible_port=22
192.168.200.43
192.168.200.44

# 在主机组上定义变量
[dev:vars]
xxx=555
yyy=666
ansible_port=22

# 也可以在特殊的主机组ungrouped和all上定义变量
[all]
zzz=777

主机变量除了可以直接定义在 inventory 文件中,还可以定义在和 inventory 文件同目录的 host_varsgroup_vars 目录中,其中 host_vars 目录中定义主机变量,group_vars 目录中定义主机组变量。

例如,默认的 inventory 文件是 /etc/ansible/hosts,那么可以在 /etc/ansible 目录下创建 host_varsgroup_vars 目录,并在其中创建一些主机、主机组的变量文件或变量目录:

$ tree /etc/ansible/
/etc/ansible/
├── ansible.cfg
├── hosts
├── group_vars
│   ├── all.yml         # all主机组变量文件
│   ├── nginx           # nginx主机组变量目录,其内所有文件都会被读取
│   │   ├── main.yml    # nginx的主配置变量文件
│   │   └── vhost.yml   # nginx的虚拟主机配置变量文件
│   └── php.yml         # php主机组变量文件
└── host_vars
    └── 192.168.200.42.yml  # 192.168.200.42节点的变量文件

上面的目录结构已经解释完所有内容了,这里总结下:

  1. 定义在 group_vars/ 目录中的变量文件可以是普通文件(比如 all.yml、php.yml),也可以是变量目录(比如 nginx 目录)。
  2. 如果是目录,则目录名必须和主机组名相同,目录中的所有文件都会在解析 inventory 的时候被读取。
  3. 如果是普通文件,则文件名可带可不带后缀,不带后缀时,文件名和主机组名相同,带后缀时,前缀和主机组名相同,后缀只允许 .yml .yaml .json
  4. host_vars/ 目录只能为每个节点都单独定义属于它们的变量文件。

记住比较实用的 group_vars/host_vars/ 存放位置:

  1. inventory 文件的同目录
  2. playbook 文件的同目录

所以,下面和 playbook 文件 lnmp.yml 同目录层次的 group_vars/ 也是有效的:

# tree -L 2 -F .
.
├── common.yml
├── group_vars/
│   └── all.yml
├── inventory_lnmp
├── lnmp.yml
├── mysql.yml
├── nginx.yml
├── php.yml
└── roles/
    ├── common/
    ├── mysql/
    ├── nginx/
    └── php/

在 inventory 文件同目录下创建 {group,host}_vars/ 好理解,为什么还要支持在 playbook 同目录下允许这两个目录呢?主要原因还是为了扩充 Role 的能力。

lnmp 实践中,在 nginx Role 中想要跨 Role 访问 php Role 中的变量 phpfpm_port,其中方法之一也是最友好的方法,就是在 playbook 文件的同目录下提供 {group,host}_vars/,并在其中设置多个 Role 共享的变量。

也就是说,在 playbook 文件同层次的 {group,host}_vars/ 下定义主机或主机组变量,可以实现 playbook 全局变量的功能。任何人都能一眼看到这两个目录,而且只要看到 playbook 文件同目录下有这两个目录,就知道它们定义了全局变量,是所有 Role 都能访问到的变量。

需要说明或提醒几点:

  1. 不要忘记 all 主机组的存在,为 all 主机组设置变量表示为所有节点设置主机变量

  2. 主机变量是绑定在主机上的,和 play、task 没有关系,所以这些变量都是全局变量,甚至节点 A 执行任务时还能访问节点 B 的主机变量。

  3. 所有的主机变量都可以通过 ansible-inventory 工具列出来。

  4. 所有变量,包括主机变量,都保存在 Ansible 的变量表 hostvars 中,通过这个全局变量表,任何一个节点都能访问其它节点的变量。

4.1.4、Role 变量

Role 中主要有两个地方定义变量:

  1. roles/ROLE_NAME/defaults/main.yml

  2. roles/ROLE_NAME/vars/main.yml

需要提醒大家,Role defaults 变量的优先级非常低,几乎可以被其它任何同名变量覆盖。

Role 变量都是 play 级别的变量。换句话说,如果 play 中执行了 Role 之后还有 tasks 指令的任务,则 tasks 的任务中可以引用 Role 中的变量。

---
- hosts: localhost
  gather_facts: false
  roles: 
    - role: test_role
  tasks: 
    - debug: 
        var: var_from_role

4.1.5、play 变量

play 级别可以通过 varsvars_filesvars_prompt 指令来定义变量。因为它们属于 play 级别,所以只在当前 play 有效。另一方面,每个 play 都有选中的目标节点,所以所有选中的目标节点都能访问这些 play 变量。

关于 varsvars_files 前面已经解释过,所以不再解释,这里简单介绍下 vars_prompt 指令的用法。

vars_prompt 指令用于交互式提示用户输入数据,并将输入内容赋值给指定的变量。

---
- hosts: localhost
  gather_facts: false
  vars_prompt: 
    - name: username
      prompt: "Your Name?"
      private: no
      default: "root"

    - name: passwd
      prompt: "Your Password"
  tasks: 
    - debug: 
        msg: "username: {{username}}, password: {{passwd}}"

上面定义了两个变量 usernamepasswd,都会提示用户输入对应的值。private: no 表示不要隐藏用户输入的字符(默认会隐藏),default 表示指定变量的默认值。

vars_prompt 主要用于保护隐私数据,比如密码,有时候也用于交互式选择,比如让用户自己输入要安装的软件包名称。它的用法不难,更详细的用法参见官方手册

4.1.6、task 变量

task 变量有多种定义方式,稍作总结:

  1. register 指令

  2. set_fact 指令

  3. vars 指令

  4. include_vars 指令

它们的用法都介绍过,下面给个示例看一眼即可:

---
- hosts: localhost
  gather_facts: false
  tasks: 
    # var.yml变量文件中定义了变量a
    - include_vars: 
        file: var.yml

    - shell: echo tictoc
      register: res
    - set_fact: name="{{res.stdout}}"

    - debug:
        msg: "a: {{a}}, name: {{name}}, age: {{age}}"
      vars:
        age: 18
        gender: male

4.1.7、block 变量

block 作为一个特殊的层次级别,它也支持定义变量,只不过这个 block 层次的变量只对当前 block 内的所有任务有效。

---
- hosts: localhost
  gather_facts: false
  tasks: 
    - block:
        - debug:
           var: name
      vars: 
        name: "toc"

4.1.8、预定义特殊变量

Ansible 作为一个功能复杂的程序,它自身也维护了一些暴露给用户的预定义变量,这些变量都是特殊变量(官方也称为魔法变量),它们都是能直接访问的变量,且对用户只读。其实在之前的文章中已经接触了好几个这类变量,比如 hostvars

写 playbook 的时候如果想要用关于 play、role、task、inventory、host 等 Ansible 内部信息,可以查找预定义变量官方手册

- ansible_forks
表示最大的进程数。这也暗含了多少个节点作为一批。(如果忘记了一批是什么意思,可回介绍playbook的那一章末尾复习)

- hostvars
保存了inventory中所有主机和主机变量

- inventory_hostname
当前执行任务的节点在inventory中的主机名

- inventory_hostname_short
当前执行任务的节点在inventory中的短主机名

- inventory_dir
inventory文件所在的目录

- inventory_file
inventory文件名

- group_names
当前正在执行任务的节点所在主机组列表,注意是一个列表,因为一个节点可能存在于多个主机组

- groups
inventory中所有的主机组以及各组内的主机列表

- ansible_play_batch
当前play中可执行任务的主机列表。Ansible动态维护该变量,默认情况下执行任务失败或连接失败的节点会从此变量中移除

- ansible_play_hosts
等价于ansible_play_batch

- play_hosts
已废弃,等价于ansible_play_batch

- playbook_dir
playbook所在目录,该playbook是ansible-playbook命令所执行的playbook,而不是import_playbook导入的playbook

- ansible_play_name
当前正在执行的play的name。Ansible 2.8才添加的变量

- ansible_play_hosts_all
当前play所选中的所有节点,等价于ansible_play_batch + 失败的节点

- ansible_play_role_names
当前play中包含的Role列表。注意,因依赖关系而隐式导入的Role不在列表内

- role_names
已废弃,等价于ansible_play_role_names

- ansible_role_names
当前play中包含的Role列表,包括因依赖关系而隐式导入的Role

- role_name
当前正在执行的Role的名称

- role_path
当前正在执行的Role的路径

ansible_run_tags
所有--tags筛选出来的tag列表

ansible_skip_tags
所有--skip_tags筛选出来的tag列表

ansible_version
Ansible版本号信息,是一个字典,字典的key: full, major, minor, revision以及string

- omit
这是一个非常特殊的变量,可直接忽略一个模块的参数。通常结合Filter和`default(omit)`使用。用法见下文

下面介绍下 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"

上面的示例通过迭代的方式创建多个文件,其中迭代创建前两个文件时,将以 umask 值来设置所创建文件的权限,而第三个文件因为存在 mode,所以将权限设置为 0444。{ item.mode | default(omit) } 的作用是:如果 item.mode 不存在,则忽略 file 模块的 mode 参数,否则 mode 参数则生效。

4.1.9、变量作用域

前面介绍了几种主要的变量类型,除了需要知道它们的用法之外,还需要搞清楚这些变量的生效范围,也即它们的作用域。其实在前面介绍各种变量的时候都提到过它们各自的生效范围,这里做个总结。

Ansible中变量主要有五种作用域概念:

  1. 全局作用域:Ansible 配置文件、环境变量、命令行选项 -e,--extra-vars 设置的变量都是全局变量。

  2. Play 作用域:整个 Play 中都有效的变量,vars_filesvars_prompt、play 级别的 vars 以及 Role 的变量,它们都是 play 级别的变量。

  3. 主机变量:绑定在各主机上的变量,各种方式定义的 inventory 变量、Facts 信息变量(这个就划分在这吧)、set_factregisterinclude_vars 都是主机变量。

  4. 任务变量:只在当前任务中生效的变量,task 级别的 vars 定义的变量属于任务变量。

  5. block 变量:只在当前 block 内生效,block 级别的 vars 定义的变量属于 block 变量。

最后还有预定义特殊变量未分类,这些变量由 Ansible 自身内部维护,有些是全局变量,有些是 play 变量,有些是主机变量,所以不方便对它们分类。

4.2、handler

前面介绍过 handler,这里再对其做一点补充,如何触发多个 handler 任务。

另外,还有一个关于”如何解决因某任务失败而导致 handler 未执行”的问题,该内容将在后文介绍异常和错误处理的时候再做补充。

如何触发执行多个 handler 任务?比如,将重启 nginx 的 handler 分为多步:

  1. 检查 nginx 语法
  2. 检查 nginx 进程是否已存在
  3. 如果 nginx 进程已存在,则 reload
  4. 如果 nginx 进程还不存在,则 start

第一种实现方式,在 handler 任务中使用 notify,将多个任务链在一起。

# check config file syntax
- name: "reload nginx step 1"
  shell: |
    nginx -c /etc/nginx/nginx.conf -t
  changed_when: true
  notify: "reload nginx step 2"

# check nginx process is started or not
- name: "reload nginx step 2"
  shell: |
    killall -0 nginx &>/dev/null
  notify: 
    - "start nginx"
    - "reload nginx"
  changed_when: true
  register: step2
  failed_when: false

# start nginx when nginx process is not running
- name: "start nginx"
  shell: |
    nginx -c /etc/nginx/nginx.conf
  when: step2.rc == 1

# reload nginx when nginx is running
- name: "reload nginx"
  shell: |
    nginx -s reload -c /etc/nginx/nginx.conf
  when: step2.rc == 0

第二种方式,在触发 handler 处定义 handler 列表:

- template: 
    src: nginx.conf.j2
    dest: /etc/nginx/nginx.conf
  notify: 
    - check nginx syntax
    - check nginx process
    - start nginx
    - reload nginx

注意,handler 任务的执行顺序不是根据 notify 顺序决定的,而是根据 handler 任务的定义顺序。

然后定义如下 handlers:

- name: "check nginx syntax"
  shell: |
    nginx -c /etc/nginx/nginx.conf -t
  changed_when: true

- name: "check nginx process"
  shell: |
    killall -0 nginx &>/dev/null
  changed_when: true
  register: step2
  failed_when: false

- name: "start nginx"
  shell: |
    nginx -c /etc/nginx/nginx.conf
  when: step2.rc == 1

- name: "reload nginx"
  shell: |
    nginx -s reload -c /etc/nginx/nginx.conf
  when: step2.rc == 0

第三种方式,在每个 handler 任务中使用 Ansible 2.2 提供的 listen 指令,它可以监听 notify 发送的信息:

- template: 
    src: nginx.conf.j2
    dest: /etc/nginx/nginx.conf
  notify: start or reload nginx

然后定义如下 handlers,每个 handler 都定义 listen: start or reload nginx,只要 notify 发送 start or reload nginx,则这些任务都会被触发。

- name: "check nginx syntax"
  shell: |
    nginx -c /etc/nginx/nginx.conf -t
  changed_when: true
  listen: start or reload nginx

- name: "check nginx process"
  shell: |
    killall -0 nginx &>/dev/null
  changed_when: true
  register: step2
  failed_when: false
  listen: start or reload nginx

- name: "start nginx"
  shell: |
    nginx -c /etc/nginx/nginx.conf
  when: step2.rc == 1
  listen: start or reload nginx

- name: "reload nginx"
  shell: |
    nginx -s reload -c /etc/nginx/nginx.conf
  when: step2.rc == 0
  listen: start or reload nginx

4.3、when 条件判断

4.3.1、同时满足多个条件

按照一般编程语言的语法,结合多个条件判断语句时要么使用逻辑与符号(通常是 and&&),要么使用逻辑或符号(通常是 or||)。Ansible 当然也支持这种结合方式,例如:

when: item > 3 and item < 10

但如果是想要同时满足多个条件,则可以将这些条件以列表的方式提供。例如:

---
- hosts: localhost
  gather_facts: false
  tasks: 
    - debug: 
        var: item
      when:
        - item > 3
        - item < 5
      loop: [1,2,3,4,5,6]

4.3.2、按条件导入文件

Linux 发行版不同,服务名称、软件包名称等都不相同,对于这种因环境不同而配置不同任务的场景,通常是为不同环境编写不同任务文件或不同变量文件,然后根据when的环境判断加载不同环境的文件。

以 Redhat 和 Debian 系列安装 Apache httpd 软件包的任务文件为例:

---
- hosts: localhost
  gather_facts: yes
  tasks:
    - include_tasks: RedHat.yml
      when: ansible_os_family == "RedHat"
    - include_tasks: Debian.yml
      when: ansible_os_family == "Debian"

4.4、循环迭代

4.4.1、with_list

最简单的循环就是迭代一个列表。例如,在 Ansible 本地端的 /tmp 目录下创建两个文件:

---
- hosts: localhost
  gather_facts: false
  tasks: 
    - file:
        name: "/tmp/{{item}}"
        state: touch
      with_list: 
        - "filename1"
        - "filename2"

与上面 with_list 等价的 loop 语法:

- file:
    name: "/tmp/{{item}}"
    state: touch
  loop: 
    - "filename1"
    - "filename2"

4.4.2、with_items

有时候列表中会嵌套列表,如果想要迭代嵌套列表结构,使用 with_items。注意,它也可以迭代普通非嵌套列表,所以它可以完全替代 with_list

---
- hosts: localhost
  gather_facts: false
  vars: 
    a: [b, c, [d, e], f]
  tasks: 
    - debug: 
        var: item
      with_items: "{{a}}"

注意,with_items 只压平嵌套列表的第一层,不会递归压平第二层、第三层…

[b, c, [d, e], f, [g, h, [i, j], k]]
# with_items压平后得到:
[b, c, d, e, f, g, h, [i, j], k]

with_items 等价的 loop 指令的写法为:

loop: "{{ nested_list | flatten(levels=1) }}"

由上面的写法可推测,筛选器函数 flatten() 默认会递归压平所有嵌套列表,如果只是压平第一层,需指定参数 levels=1

4.4.3、with_flattened

递归压平所有嵌套层次:

---
- hosts: localhost
  gather_facts: false
  vars: 
    a: [b, c, [d, e], f, [g,h,[i,j], k]]
  tasks: 
    - debug: 
        var: item
      with_flattened: "{{a}}"

4.4.4、with_dict

with_dict 用于迭代一个字典结构,迭代时可以使用 item.key 表示每个字典元素的 key,item.value 表示每个字典元素的 value。

---
# test.yml
- hosts: localhost
  gather_facts: false
  vars: 
    users:
      tiktoc_key:
        name: tiktoc
        age: 18
      fairy_key:
        name: fairy
        age: 22
  tasks:
    - debug:
        msg: "who: {{item.key}} && 
              name: {{item.value.name}} && 
              age: {{item.value.age}}"
      with_dict: "{{users}}"

with_dict 等价的 loop 指令有两种写法:

loop: "{{lookup('dict', users)}}"
loop: "{{users | dict2items}}"

4.4.5、循环控制loop_control

loop_control指令可以控制循环时的特性,该指令有一些参数,每种参数都是一个控制开关。

4.4.5.1、label 参数

使用 label 参数可以自定义迭代时显示的内容来替代默认显示的 item。例如:

---
- hosts: localhost
  gather_facts: false
  vars: 
    mylist: [11,22]
  tasks:
    - shell: echo {{item}}
      loop: "{{ mylist }}"
      register: res
    - debug: 
        var: item.stdout
      loop: "{{res.results}}"
      loop_control:
        label: "toc"

对比一下,看使用 label 和不使用 label 的区别。

4.4.5.2、pause 参数

loop_control 的 pause 参数可以控制每轮迭代之间的时间间隔。

---
- hosts: localhost
  gather_facts: false
  vars: 
    mylist: [11,22]
  tasks:
    - debug: 
        var: item
      loop: "{{mylist}}"
      loop_control:
        pause: 1

这表示第一轮迭代后,等待一秒,再进入第二轮迭代。

4.4.5.3、index_var 参数

index_var 参数可以指定一个变量,这个变量可以记录每轮循环迭代过程中的索引位,也即表示当前是第几轮迭代。

---
- hosts: localhost
  gather_facts: false
  vars: 
    mylist: [11,22]
  tasks:
    - debug: 
        msg: "index: {{idx}}, value: {{item}}"
      loop: "{{mylist}}"
      loop_control:
        index_var: idx

通过 index_var,可以进行一些条件判断。比如只在第一轮循环时执行某任务:

---
- hosts: localhost
  gather_facts: false
  vars: 
    mylist: [11,22]
  tasks:
    - debug: 
        msg: "index: {{idx}}, value: {{item}}"
      when: idx == 0
      loop: "{{mylist}}"
      loop_control:
        index_var: idx

4.4.5.4、extended 参数

从 Ansible 2.8 开始,还支持使用 extended 参数来获取更多循环的信息。

loop_control:
  extended: yes

打开 extended 的开关后,将可以在循环内部使用下面一些变量。

变量名 含义
ansible_loop.allitems 循环中所有的item
ansible_loop.index 本轮迭代的索引位,即第几轮迭代(从1开始计数)
ansible_loop.index0 本轮迭代的索引位,即第几轮迭代(从0开始计数)
ansible_loop.revindex 本轮迭代的逆向索引位(距离最后一个item的长度,从1开始计数)
ansible_loop.revindex0 本轮迭代的逆向索引位(距离最后一个item的长度,从0开始计数)
ansible_loop.first 如果本轮迭代是第一轮,则该变量值为True
ansible_loop.last 如果本轮迭代是最后一轮,则该变量值为True
ansible_loop.length 循环要迭代的轮数,即item的数量
ansible_loop.previtem 本轮迭代的前一轮的item值,如果当前是第一轮,则该变量未定义
ansible_loop.nextitem 本轮迭代的下一轮的item值,如果当前是最后一轮,则该变量未定义

来个示例,循环迭代一个包含 4 个元素的列表,并在第一轮迭代时输出上面所有变量的值。

---
- hosts: localhost
  gather_facts: false
  tasks: 
    - debug:
        msg: 
          - "ansible_loop.allitems: {{ansible_loop.allitems}}"
          - "ansible_loop.index: {{ansible_loop.index}}"
          - "ansible_loop.index0: {{ansible_loop.index0}}"
          - "ansible_loop.revindex: {{ansible_loop.revindex}}"
          - "ansible_loop.revindex0: {{ansible_loop.revindex0}}"
          - "ansible_loop.first: {{ansible_loop.first}}"
          - "ansible_loop.last: {{ansible_loop.last}}"
          - "ansible_loop.length: {{ansible_loop.length}}"
          - "ansible_loop.previtem: {{ansible_loop.previtem | default('')}}"
          - "ansible_loop.nextitem: {{ansible_loop.nextitem}}"
      when: ansible_loop.first
      loop: "{{mylist}}"
      loop_control:
        extended: yes
      vars: 
        mylist: ['a','b','c','d']

上面的 when 指令使用了 ansible_loop.first 来判断是否是第一轮迭代,通过 ansible_loop.index 也可以判断。此外,刚才也介绍过使用 index_var 的方式来判断处于第几轮判断。

另外,上面的 msg 参数使用了列表形式,如果不使用列表,debug 输出的时候,所有内容都会输出在同一行中,而使用列表则每项都单独成行输出。

4.5、异常

任何程序都有 bug,如果你没有遇到,说明你的程序没有遇到合适的时间,地点,场景,或者用户。

默认情况下,Ansible 端无法连接某个节点时、节点执行某个任务失败时,Ansible 都会将这个节点从活动节点列表中(play_hosts)移除,以避免该节点继续执行之后的任务。

用户可以去修改 Ansible 对这种异常现象的默认处理方式,比如遇到错误也不让该节点退出舞台,而是继续执行后续任务,又或者某节点执行任务失败就让整个 play 都失败。

4.5.1、fail 模块

使用 fail 模块,可以人为制造一个失败的任务。

---
- hosts: nginx
  gather_facts: no
  tasks: 
    - fail:
        msg: "oh, not me"
      when: inventory_hostname == groups['nginx'][0]
    - debug: 
        msg: "hello"

上面的 fail 会任务失败,并使得此节点不会执行后续任务,但其它节点会继续执行任务。

4.5.2、assert 模块

对于当满足某某条件时就失败的逻辑,可以使用 fail 模块加 when 指令来实现,也可使用更为直接的 assert 模块进行断言。

---
- hosts: localhost
  gather_facts: no
  tasks: 
    - assert:
        that:
          - 100 > 20
          - 200 > 200
        fail_msg: "oh, not me"
        success_msg: "oh, it's me"

其中 that 参数接收一个列表,用于定义一个或多个条件,如果条件全为 true,则任务成功,只要有一个条件为 false,则任务失败。fail_msg 定义任务失败时的信息,success_msg 定义任务成功时的信息。

4.5.3、ignore_errors

当某个任务执行失败(或被 Ansible 认为失败,比如通过返回值判断)时,如果不想让这个失败的任务导致节点退出,可以使用 ignore_errors 指令来忽略失败。

---
- hosts: localhost
  gather_facts: no
  tasks:
    - shell: ls /tmp/klakas/kjlasd8293
      ignore_errors: yes
    - debug: 
        msg: "hello world"

4.5.4、failed_when

当条件表达式为 true 时任务强制失败,当条件表达式为 false 时,任务强制不失败。

例如,下面的示例中不管 shell 模块是否正确执行,都认为这个任务成功执行:

---
- hosts: localhost
  gather_facts: no
  tasks:
    - shell: ls /tmp/klakas/kjlasd8293
      failed_when: false
    - debug: 
        msg: "hello world"

failed_when 经常会和 shell 或 command 模块以及 register 指令一起使用,用来手动定义失败的退出状态码。比如,退出状态码为 0 1 2 都认为任务成功执行,其它状态码都认为执行失败。

- shell: COMMAND
  register: res
  failed_when: res.rc not in (0, 1, 2)

如果这时候去查看 res 变量,将会发现多出了一项 failed_when_result

{
  "changed": true,
  "cmd": "ls /tmp/klakas/kjlasd8293",
  "delta": "0:00:00.002469",
  "end": "2020-01-17 00:18:49.437885",
  "failed": false,
  "failed_when_result": false,
  "msg": "non-zero return code",
  "rc": 2,
  "start": "2020-01-17 00:18:49.435416",
  "stderr": "ls: cannot access /tmp/klakas/kjlasd8293: No such file or directory",
  "stderr_lines": [
      "ls: cannot access /tmp/klakas/kjlasd8293: No such file or directory"
  ],
  "stdout": "",
  "stdout_lines": []
}

failed_when_result 记录了 failed_when 指令的渲染结果是 true 还是 false,本例中渲染结果为 false。

此外,failed_whenwhen 一样都可以将多个条件表达式写成列表的形式来表示逻辑与。例如:

- shell: xxxxxx
  register: res
  failed_when: 
    - res.rc != 0 
    - res.stdout == "yyyyy"

4.5.5、rescue 和 always

Ansible 允许在任务失败的时候,去执行某些任务,还允许不管任务失败与否,都执行某些任务。这功能类似于编程语言的 try…catch 异常捕获。

关于这两个指令:

1、rescue 和 always 都是 block 级别的指令

2、rescue 表示 block 中任意任务失败后,都执行 rescue 中定义的任务,但如果 block 中没有任务失败,则不执行 rescue 中的任务

3、always 表示 block 中任务无论失败与否,都执行 always 中定义的任务

---
- hosts: localhost
  gather_facts: no
  tasks: 
    - block:
        - fail: 
        - debug: msg="hello world"
      rescue:
        - debug: msg="rescue1"
        - debug: msg="rescue2"
      always:
        - debug: msg="always1"
        - debug: msg="always2"

block 中的 fail 任务会失败,于是跳转到 rescue 中开始执行任务,然后再跳转到 always 中执行任务。

如果注释掉 block 中的 fail 模块任务,则 block 中没有任务失败,于是 rescue 中的任务不会执行,但是在执行完 block 中所有任务后会跳转到 always 中继续执行任务。

4.5.6、any_errors_fatal

如果想让某个失败的任务直接导致整个 play 的失败,可在 play 级别使用 any_errors_fatal 指令。

---
- hosts: nginx
  gather_facts: no
  any_errors_fatal: true
  tasks: 
    - fail:
        msg: "oh, not me"
      when: inventory_hostname == groups['nginx'][0]
    - debug: 
        msg: "hello"

- hosts: localhost
  gather_facts: no
  tasks: 
    - debug: 
        msg: "HELLO WORLD"

any_errors_fatal 设置为 true 后,nginx 组第一个节点只要一开始执行 fail 任务,整个 playbook 中所有后续任务都将不再执行,就连其它 play 也一样不执行。

注意观察 playbook 的执行结果,它将提示 ”NO MORE HOSTS LEFT”:

.........
TASK [fail] *********************
fatal: [192.168.200.42]: FAILED! => {"changed": false, "msg": "oh, not me"}
skipping: [192.168.200.43]
skipping: [192.168.200.44]

NO MORE HOSTS LEFT **************

PLAY RECAP *************
.........

4.5.7、异常打断 handler 处理

如果某节点执行某任务失败,但在失败任务之前已经触发了 handler,那么该节点将因为失败而无法执行 handler 任务。

有时候这种默认的异常处理并非理想的处理方式。比如 copy 了一个 nginx 配置文件并触发了重启 Nginx 服务的 handler,但是在重启之前执行某个任务失败了,那么该节点将不会重启 nginx,但配置文件确实已经拷贝过去了且发生改变了。

可以在命令行上使用 --force-handlers 选项,也可在 play 级别使用 force_handlers: true 指令,它们都表示即使该节点执行任务失败了,也会执行已经触发的 handler 任务。

但要注意,只有因任务执行失败的情况才能强制执行 Handler 任务,如果是因为 unreachable 而导致的失败,显然是没有办法的。

--force-handlersforce_handlers: true 是对 play 全局生效的,如果想针对单个任务,也可以使用 rescue 或 always 的方式来 flush handler。例如:

tasks: 
  - block:
      - template: 
          src: nginx.conf.j2
          dest: /etc/nginx/nginx.conf
        notify: restart nginx
    rescue: 
      - meta: flush_handlers

4.6、Jinja2

这里假设我们会一门编程语言,shell 编程也算,熟悉一下 jinja2 语法即可上手。

Ansible 的运行离不开 Jinja2,当 Ansible 开始执行 playbook 或任务时,总是会先使用 Jinja2 去解析所有指令的值,然后再执行任务。在编写任务的过程中可以使用 Jinja2 来实现一些需求。

4.6.1、jinja2 模板概念

假设要发送一个文件给一个或多个目标节点,要发送的文件内容如下:

hello, __NAME__

其中 __NAME__ 部分是根据目标节点的主机名来确定,比如发送给 centos 节点时,内容应该为 hello, centos,发送给 ubuntu 节点时,内容应该为hello, ubuntu。换句话说,__NAME__ 是一个能够根据不同场景动态生成不同字符串的代码小片段。能动态生成字符串的特殊代码片段就是模板。

给模板下一个定义:字符串中嵌入特殊语法表达式组成的整体就是模板,这个模板需要通过一个引擎去渲染,把特殊语法表达式替换成想要的字符串,而在不同场景,替换的结果不尽相同,这个引擎就是 jinja2。

例如上面示例中,__NAME__ 使用了前后两个下划线包围,表示这部分是模板表达式,它是需要进行替换的,而 ”hello” 是普通字符串,模板引擎不会去管它。

Jinja2 模板引擎提供了三种特殊符号来包围模板表达式:

  • "{{ xxx }}":双大括号包围变量或表达式(Ansible 中的变量就是它包围的)

  • "{# xxx #}":Jinja2 的注释符号

  • "{% xxx %}":Jinja2 的一些特殊关键字标签,比如 if 语句、for 循环语句等等

模板更多用在 web 编程中来生成 HTML 页面,但绝不限于 web 编程,它可以用在很多方面,比如 Ansible 就使用 Jinja2 模板引擎来解析 YAML 中的字符串,也用在 template 模块渲染模板文件。

Jinja2 的内容较多,但对于学习 Ansible 来说,只需要学习其中和 template 相关的一部分以及 Ansible 对 Jinja2 的扩展功能即可。

4.6.2、Ansible 在哪用 Jinja2

严格地说,playbook 中所有地方都使用了 Jinja2,包括几乎所有指令的值、template 模板文件、copy 模块的 content 指令的值、lookup 的 template 插件等等。它们会先经过 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。

再比如模板文件 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

4.6.3、Jinja2 访问元素

Jinja2 模板引擎允许使用点 . 来访问列表或字典元素,比如 mylist=["a","b","c"] 列表,在 Jinja2 中既可以使用 mylist[1] 来访问第二个元素,也可以使用 mylist.1 来访问它。

  • 使用 x.y 时,先搜索 Python 对象的属性名或方法名,搜索不到时再搜索 Jinja2 变量。
  • 使用 X["Y"] 时,先搜索 Jinja2 变量,搜索失败时再搜索 Python 对象的属性名或方法名。

所以,使用 X.Y 方式时需要小心一些,使用 X["Y"] 更保险。当然,使用哪种方式都无所谓,出错了也知道如何去调整。

4.6.4、Jinja2 条件判断

Jinja2 中可以使用 if 语句进行条件判断。

{% if CONDITION1 %}
  string_or_expression1
{% elif CONDITION2 %}
  string_or_expression2
{% elif CONDITION3 %}
  string_or_expression3
{% else %}
  string_or_expression4
{% endif %}

其中 elif 和 else 分支都是可省略的。CONDITION 部分是条件表达式。

例如,模板文件 a.txt.j2 内容如下:

今天星期几:
{% if whatday == "0" %}
  星期日
{% elif whatday == "1" %}
  星期一
{% elif whatday == "2" %}
  星期二
{% elif whatday == "3" %}
  星期三
{% elif whatday == "4" %}
  星期四
{% elif whatday == "5" %}
  星期五
{% elif whatday == "6" %}
  星期六
{% else %}
  错误数值
{% endif %}

上面判断变量 whatday 的值,然后输出对应的星期几。因为 whatday 变量的值是字符串,所以让它和字符串形式的数值进行等值比较。当然,也可以使用筛选器将字符串转换为数值后进行数值比较:whatday|int == 0

playbook 内容如下:

---
- hosts: localhost
  gather_facts: no
  vars_prompt:
    - name: whatday
      default: 0
      prompt: "星期几(0->星期日,1->星期一...):"
      private: no
  tasks: 
    - template:
        src: a.txt.j2
        dest: /tmp/a.txt

如果 if 语句的分支比较简单(没有 elif 逻辑),那么可以使用行内 if 表达式。

string_or_expr1 if CONDITION else string_or_expr2

因为行内 if 是表达式而不是语句块,所以不使用 "{%%}" 符号,而使用 "{{}}"

比如:

- debug:
    msg: "{{'周末' if whatday|int > 5 else '工作日'}}"

4.6.5、for 循环

4.6.5.1、迭代列表

for 循环的语法:

{% for i in LIST %}
    string_or_expression
{% endfor %}

还支持直接条件判断筛选要参与迭代的元素:

{% for i in LIST if CONDITION %}
    string_or_expression
{% endfor %}

此外,Jinja2 的 for 语句还允许使用 else 分支,如果 for 所迭代的列表 LIST 是空列表(或没有元素可迭代),则会执行 else 分支。

{% for i in LIST %}
    string_or_expression
{% else %}
    string_or_expression
{% endfor %}

例如,在模板文件 a.txt.j2 中有如下内容:

{% for file in files %}
<{{file}}>
{% else %}
no file in files
{% endfor %}

playbook 文件内容如下:

---
- hosts: localhost
  gather_facts: no
  tasks: 
    - template:
        src: a.txt.j2
        dest: /tmp/a.txt
      vars: 
        files:
          - /tmp/a1
          - /tmp/a2
          - /tmp/a3

执行 playbook 之后,将生成包含如下内容的 /tmp/a.txt 文件:

</tmp/a1>
</tmp/a2>
</tmp/a3>

如果将 playbook 中的 files 变量设置为空列表,则会执行 else 分支,所以生成的 /tmp/a.txt 的内容为:

no file in files

如果 files 变量未定义或变量类型不是 list,则默认会报错。针对未定义变量,可采用如下策略提供默认空列表:

{% for file in (files|default([])) %}
<{{file}}>
{% else %}
no file in files
{% endfor %}

如果不想迭代文件列表中的 /tmp/a3,则可以加上条件判断:

{% for file in (files|default([])) if file != "/tmp/a3" %}
<{{file}}>
{% else %}
no file in files
{% endfor %}

Jinja2 的 for 循环没有提供 break 和 continue 的功能,所以只能通过 "{% for...if...%}" 来间接实现类似功能。

4.6.5.2、迭代字典

默认情况下,Jinja2 的 for 语句只能迭代列表。

如果要迭代字典结构,需要先使用字典的 items() 方法进行转换。如果没有学过 python,我下面做个简单解释:

对于下面的字典结构:

p: 
  name: tiktoc
  age: 18

如果使用 p.items(),将计算得到如下结果:

[('name', 'tiktoc'), ('age', 18)]

然后 for 语句中使用两个迭代变量分别保存各列表元素中的子元素即可。下面设置了两个迭代变量 key 和 value:

{% for key,value in p.items() %}

那么第一轮迭代时,key 变量保存的是 name 字符串,value 变量保存的是 tiktoc 字符串,那么第二轮迭代时,key 变量保存的是 age 字符串,value 变量保存的是18 数值。

如果 for 迭代时不想要 key 或不想要 value,则使用 _ 来丢弃对应的值。也可以使用 keys() 方法和 values() 方法分别获取字典的 key 组成的列表、字典的 value 组成的列表。例如:

{% for key,_ in p.items() %}
{% for _,values in p.items() %}
{% for key in p.keys() %}
{% for value in p.values() %}

将上面的解释整理成下面的示例。playbook 内容如下:

- hosts: localhost
  gather_facts: no
  tasks: 
    - template:
        src: a.txt.j2
        dest: /tmp/a.txt
      vars:
        p1:
          name: "tiktoc"
          age: 18

模板文件 a.txt.j2 内容如下:

{% for key,value in p1.items() %}
key: {{key}}, value: {{value}}
{% endfor %}

执行结果:

key: name, value: tiktoc
key: age, value: 18

4.6.5.3、特殊控制变量

在 for 循环内部,可以使用一些特殊变量,如下:

Variable Description
loop 循环本身
loop.index 本轮迭代的索引位,即第几轮迭代(从1开始计数)
loop.index0 本轮迭代的索引位,即第几轮迭代(从0开始计数)
loop.revindex 本轮迭代的逆向索引位(距离最后一个item的长度,从1开始计数)
loop.revindex0 本轮迭代的逆向索引位(距离最后一个item的长度,从0开始计数)
loop.first 如果本轮迭代是第一轮,则该变量值为True
loop.last 如果本轮迭代是最后一轮,则该变量值为True
loop.length 循环要迭代的轮数,即item的数量
loop.previtem 本轮迭代的前一轮的item值,如果当前是第一轮,则该变量未定义
loop.nextitem 本轮迭代的下一轮的item值,如果当前是最后一轮,则该变量未定义
loop.depth 在递归循环中,表示递归的深度,从1开始计数
loop.depth0 在递归循环中,表示递归的深度,从0开始计数
loop.cycle 一个函数,可指定序列作为参数,for每迭代一次便同步迭代序列中的一个元素
loop.changed(*val) 如果本轮迭代时的val值和前一轮迭代时的val值不同,则返回True

前面有介绍过,在 Ansible 的循环开启 extended 功能之后也能获取一些特殊变量。不难发现,Ansible 循环开启 extended 后可获取的变量和此处 Jinja2 提供的循环变量大多是类似的。

4.6.6、macro

{% macro delimiter(loop) -%}
{{ ' ' if not loop.last else ';' }}
{%- endmacro %}

上面表示定义了一个名为 delimiter 的 Macro,它能接一个表示 for 循环的参数。

定义好这个 Macro 之后,就可以在任意需要的时候去”调用”它。例如:

log {% for item in log_args %}
'{{item}}'{{delimiter(loop)}}
{%- endfor %}

gzip {% for item in gzip_args %}
'{{item}}'{{delimiter(loop)}}
{%- endfor %}

提供一个 playbook,内容如下:

- hosts: localhost
  gather_facts: no
  tasks: 
    - template:
        src: a.txt.j2
        dest: /tmp/a.txt
      vars:
        log_args: 
          - host
          - port
          - date
        gzip_args: ['css','js','html']

渲染出来的结果如下:

log 'host' 'port' 'date';
gzip 'css' 'js' 'html';

Macro 的参数还可以指定默认值,”调用” Macro 并传递参数时,还可以用 key=value 的方式传递。例如:

{# 定义Macro时,指定默认值 #}
{% macro delimiter(loop,sep=" ",deli=";") -%}
{{ sep if not loop.last else deli }}
{%- endmacro %}

{# "调用"Macro时,使用key=value传递参数值 #}
log {% for item in log_args %}
'{{item}}'{{delimiter(loop,sep=",")}}
{%- endfor %}

gzip {% for item in gzip_args %}
'{{item}}'{{delimiter(loop,deli="")}}
{%- endfor %}

渲染得到的结果:

log 'host','port','date';
gzip 'css' 'js' 'html'

4.6.7、block

有些服务程序的配置文件可以使用 include 指令来包含额外的配置文件,这样可以按不同功能来分类管理配置文件中的配置项。在解析配置文件的时候,会将 include 指令所指定的文件内容加载并合并到主配置文件中。

Jinja2 的 block 功能有点类似于 include 指令的功能,block 的用法是这样的:先在一个类似于主配置文件的文件中定义 block,称为 base block 或父 block,然后在其它文件中继承 base block,称为子 block。在模板解析的时候,会将子 block 中的内容填充或覆盖到父 block 中。

例如,在 base.conf.j2 文件中定义如下内容:

server {
  listen       80;
  server_name  www.abc.com;

{% block root_page %}
location / {
  root   /usr/share/nginx/html;
  index  index.html index.htm;
}
{% endblock root_page %}

  error_page   500 502 503 504  /50x.html;
{% block err_50x %}{% endblock err_50x %}
{% block php_pages %}{% endblock php_pages %}

}

这其实是一个 Nginx 的虚拟主机配置模板文件。在其中定义了三个 block:

  • 名为 root_page 的 block,其内部有内容,这个内容是默认内容
  • 名为 err_50x 的 block,没有内容
  • 名为 php_pages 的 block,没有内容

如果定义了同名子 block,则会使用子 block 来覆盖父 block,如果没有定义同名子 block,则会采用默认内容。

下面专门用于定义子 block 内容的 child.conf.j2 文件,内容如下:

{% extends 'base.conf.j2' %}

{% block err_50x %}
location = /50x.html {
  root   /usr/share/nginx/html;
}
{% endblock err_50x %}

  {% block php_pages %}
  location ~ \.php$ {
    fastcgi_pass   "192.168.200.43:9000";
    fastcgi_index  index.php;
    fastcgi_param  SCRIPT_FILENAME /usr/share/www/php$fastcgi_script_name;
    include        fastcgi_params;
  }
  {% endblock php_pages %}

子 block 文件中第一行需要使用 jinja2 的 extends 标签来指定父 block 文件。这个子 block 文件中,没有定义名为 root_page 的 block,所以会使用父 block 文件中同名 block 的默认内容,err_50xphp_pages 则直接覆盖父 block。

在 template 模块渲染文件时,需要指定子 block 作为其源文件。例如:

- hosts: localhost
  gather_facts: no
  tasks: 
    - template:
        src: child.conf.j2
        dest: /tmp/nginx.conf

渲染得到的结果:

server {
  listen       80;
  server_name  www.abc.com;

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

  error_page   500 502 503 504  /50x.html;
location = /50x.html {
  root   /usr/share/nginx/html;
}
  location ~ \.php$ {
    fastcgi_pass   "192.168.200.43:9000";
    fastcgi_index  index.php;
    fastcgi_param  SCRIPT_FILENAME /usr/share/www/php$fastcgi_script_name;
    include        fastcgi_params;
  }

}

4.6.8、变量赋值和作用域

在 Jinja2 中也能变量赋值,语法为:

{% set var = value %}
{% set var1,var2 = value1,value2 %}

例如:

{% set mystr = "hello world" %}
{% set mylist1,mylist2 = [1,2,3],[4,5,6] %}

如果是在 if、for 等语句块外进行的变量赋值,则可以在 if、for 等语句块内引用。例如:

{% set mylist = [1,2,3] %}
{% for item in mylist %}
  {{item}}
{% endfor %}

但是除 if 语句块外,其它类型的语句块都有自己的作用域。比如 for 语句块内部赋值的变量在 for 退出后就消失。

例如:

{% set mylist = [1,2,3] %}
{% set mystr = "hello" %}
{% for item in mylist %}
{{item}}
{%set mystr="world"%}
{% endfor %}
{{mystr}}

最后一行渲染的结果是 hello 而不是 world

4.6.9、Jinja2 的空白处理

通常在模板文件中,会将模板代码片段按照编程语言的代码一样进行换行、缩进,但因为它们是嵌套在普通字符串中的,模板引擎并不知道那是一个普通字符串中的空白还是代码格式规范化的空白,而有时候这会带来问题。

比如,模板文件 a.txt.j2 文件中的内容如下:

line start
line left {% if true %}
  <line1>
{% endif %} line right
line end

这个模板文件中的代码部分看上去非常规范,有换行有缩进。一般来说,这段模板文件想要渲染得到的文本内容应该是:

line start
line left
<line1>
line right
line end

或者:
line start
line left <line1> line right
line end

但实际渲染得到的结果:

line start
line left   <line1>
 line right
line end

渲染的结果中格式很不规范,主要原因就是 Jinja2 语句块前后以及语句块自身的换行符处理、空白符号处理导致的问题。

Jinja2 提供了两个配置项:lstrip_blockstrim_blocks,它们的意义分别是:

  • lstrip_blocks:设置为 true 时,会将 Jinja2 语句块前面的本行前缀空白符号移除
  • trim_blocks:设置为 true 时,Jinja2 语句块后的换行符会被移除掉

对于 Ansible 的 template 模块,lstrip_blocks 默认设置为 False,trim_blocks 默认设置为 true。也就是说,默认情况下,template 模块会将语句块后面的换行符移除掉,但是会保留语句块前的本行前缀空白符号。

例如,对于下面这段模板片段:

line start
    {% if true %}
  <line1>
{% endif %}
line end

"{% if" 前的 4 个空格会保留,"true %}" 后的换行符会被移除,于是 line1 渲染的时候会移到第二行去。再看 "{% endif %}""{%" 前面的空白符号会保留,"%}" 后面的换行符会被移除,所以 line end 在渲染时会移动到第三行。第二行和第三行的换行符是由 line1 这行提供的。

所以结果是:

line start
      <line1>
line end

一般来说,将 lstrip_blockstrim_blocks 都设置为 true,比较符合大多数情况下的空白处理需求。例如:

- template:
    src: a.txt.j2
    dest: /tmp/a.txt
    lstrip_blocks: true
    trim_blocks: true

渲染得到的结果:

line start
  <line1>
line end

更符合一般需求的模板格式是,Jinja2 指令部分(比如 if、endif、for、endfor 等)不要使用任何缩进格式,非 Jinja2 指令部分按需缩进

line start
{% if true %}
  <line1>
{% endif %}
line end

除了 lstrip_blocks 以 及trim_blocks 可以控制空白外,还可以使用 {%- xxx 可以移除本语句块前的所有空白(包括换行符),使用 -%} 可以移除本语句块后的所有空白(包括换行符)。

注意,xxx_blocks 这两个配置项和带 - 符号的效果是不同的,总结下:

  • lstrip_blocks 只移除语句块前紧连着的且是本行前缀的空白符
  • {%- 移除语句块前所有空白
  • trip_blocks 只移除语句块后紧跟着的换行符
  • -%} 移除语句块后所有的空白

例如,下面两个模板片段:

line1
line2 {%- if true %}
        line3
        line4
      {%- endif %}
line44
line5

在两个 xxx_blocks 设置为 true 时,渲染得到的结果是:

line1
line2line3
        line4line44
line5

4.6.10、Jinja2 内置 Filter

通常,模板语言都会带有筛选器,JinJa2 也不例外,每个筛选器函数都是一个功能,作用就类似于函数,而且它也可以接参数。

Jinja2 的筛选器使用方式非常简单,直接使用一根竖线 |,在模板解析时,Jinja2 会将竖线左边的返回值或计算结果当作隐式参数传递给竖线右边的筛选器函数。另外,筛选器是一个表达式,所以写在 "{{}}" 内部。

例如,Jinja2 有一个内置 lower() 筛选器函数,可以将字符串全部转化成小写字母。

- debug:
    msg: "{{'HELLO WORLD'|lower()}}"

如果筛选器函数没有给定参数,则括号可以省略,例如 "HELLO"|lower

有些筛选器函数需要给定参数,例如 replace() 筛选器,可以将字符串中的一部分替换掉。例如,将字符串中的 no 替换成 yes

{% if result %}
{{result|replace('no', 'yes')}}
{%endif%}

JinJa2 内置了 50 多个筛选器函数,Ansible 自身也扩展了一些方便的筛选器函数,所以数量非常多。如下:

abs()               float()             lower()             round()                 tojson()
attr()              forceescape()       map()               safe()                  trim()
batch()             format()            max()               select()                truncate()
capitalize()        groupby()           min()               selectattr()            unique()
center()            indent()            pprint()            slice()                 upper()
default()           int()               random()            sort()                  urlencode()
dictsort()          join()              reject()            string()                urlize()
escape()            last()              rejectattr()        striptags()             wordcount()
filesizeformat()    length()            replace()           sum()                   wordwrap()
first()             list()              reverse()           title()                 xmlattr()

4.6.11、Ansible 扩展的测试函数

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

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

4.6.11.1、测试字符串

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

4.6.11.2、成员测试

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 }}"

4.6.11.3、测试文件

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

# 如果mypath是绝对路径,即is测试返回true,
# 则筛选器返回absolute,否则返回relative
- 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.6.11.4、版本号大小比较

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

语法:

version('VERSION',CMP)

其中 CMP 可以是如下几种:

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

例如:

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

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

4.6.11.5、测试任务的执行状态

每个任务的执行结果都有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

4.6.11.6、Jinja2 内置的 is 测试

jinja2 的 is 操作符可以做很多测试操作,比如测试是否是数值,是否是字符串等等。下表列出了所有 Jinja2 内置的测试函数。

示例一:在 when 条件中测试

- debug: 
    msg: "a string"
  when: name is string
  vars:
    name: tiktoc

示例二:在 jinja2 模板的 if 中测试

{% if name is not string %}
HELLOWORLD
{% endif %}

4.7、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"

从上面提供的变量文件来看,应该能看出来它的目的是为了能够自动生成一个或多个 serve r段,而且允许随意增删改每个 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;
  }

}
标签云