2020-03-20 Ansible最核心的组件playbook

1. playbook基本语法

Ansible的playbook文件格式为YAML语法,所以对于编写剧本的初学者,建议先对YAML语法结构有一定的了解,否则在运行playbook的时候会经常碰到语法错误。关于YAML的语法的详细介绍信息可以通过https://yaml.org/spec/1.2/spec.html网站进行了解。
安装部署Nginx服务的剧本编写案例:

[root@m01 ~]# cat nginx.yaml 
---
- hosts: all
  tasks:
      - name: Install Nginx Package
        yum: name=nginx state=present
      - nane: Copy Nginx.conf
        copy: src=./nginx.conf dest=/etc/nginx/nginx.conf mode=0644

对于以上playbook剧本代码信息,这里进行简单说明解释:

  • 第1行表示该文件注释说明,YAML文件中通常使用三个短横线表示注释,也可以使用#。
  • 第2行定义该playbook剧本管理的目标主机,all表示针对所有的主机,这个位置定义支持Ansible Ad-Hoc模式的所有参数。
  • 第3行定义该playbook所有的任务集合信息,比如次剧本代码中定义了两个任务。
  • 第4行定义一个任务的名称,非必须,建议根据实际任务命名。
  • 第5行定义一个任务的具体操作动作,比如这里使用yum实现nginx软件包的安装。
  • 第6到7行表示使用copy模块,将本地的nginx配置文件推送分发给其他所有被管理的主机,并且修改设置文件权限为644。

编写剧本主要需要注意两点规范:第一就是剧本内容组成规范;第二就是剧本编写语法规范。

1.1 playbook内容组成规范

Ansible的playbook由最基本的两个部分组成——hosts定义剧本所管理的主机信息,tasks定义所管理的主机需要执行的任务信息。

Ansible playbook组成部分示意图

定义剧本的host部分可以有多种方式,常见的方式有以下几种:

方式一:定义所管理的主机IP地址
- hosts: 192.168.9.5
  tasks: 
  ---- 任务内容先省略 ----

方式二:定义所管理的主机名称信息
- hosts: backup_host
  tasks:
  ---- 任务内容先省略 ----

方式三:定义所管理的主机组信息
- hosts: rsync_server
  tasks:
  ---- 任务内容先省略 ----
- hosts: rsync_client
  tasks:
  ---- 任务内容先省略 ----

方式四:定义所管理的多个主机信息
- hosts: 192.168.9.5, backup_host
  tasks:
  ---- 任务内容先省略 ----

方式五:定义所管理所有主机信息
- hosts: all
  tasks:
  ---- 任务内容先省略 ----

企业可根据自身需求,对以上常用方式进行自行扩展。以上定义剧本所管理的主机信息的几种方法有一个最重要的前提,即所管理的主机在Ansible主机清单文件中必须有相应定义,即默认/etc/ansible/hosts文件中必须有定义,否则剧本将不能直接管理相应主机。
定义剧本的tasks部分也可以有多种方式,常见的方式有以下几种:

方式一:采用变量格式设置任务信息
tasks:
  - name: make sure apache is running
    service: name=httpd state=running
当需要传入的参数列表过长时,可以将其分隔到多行
tasks:
  - name: copy ansible inventory file to client
    copy: src=/etc/ansible/hosts dest=/etc/ansible/hosts
          owner=root group=root mode=0644

方式二:采用字典格式设置任务信息
tasks:
  - name: copy ansible inventory file to client
    copy:
        src: /etc/ansible/hosts
        dest: /etc/ansible/hosts
        owner: root
        group: root
        mode: 0644

1.2 playbook编写语法规范

(1)注意剧本编写缩进规范
在编写剧本时,需要注意不同行信息之间有时需要有缩进关系,一般将两个空格作为一个缩进。

- hosts: oldboy
task:
- name: exec scripts
script: /server/scripts/oldboy.sh

(2)主机剧本编写字典规范
在编写剧本时,有时需要定义变量信息或设置模块参数的配置信息,可以采用字典格式进行设置,字典配置信息格式为:

key: value
key和value之间用冒号加空格进行分割

具体编写的剧本样例:

- hosts: oldboy
tasks:
- name: create file
file:
    path: /oldboy/oldboy.txt
    state: directory
    mode: 644
    owner: oldboy
    group: oldboy

(3)主机剧本编写列表规范
在编写剧本时,剧本中定义的有些信息可能会重复出现,并且缩进关系一致,以及他们表达的意思也比较相近,这样不同行的信息就构成了列表,列表信息格式为:

- list01
- list02
- list03
短横线和列表信息中间有空格

2. playbook执行方式

剧本编写完成之后,需要进行运行,才能完成剧本的主机管理功能。在Ansible程序中,加载使用模块信息时,可以使用Ansible命令,加载执行剧本文件时,可以使用ansible-playbook命令。

ansible-playbook oldboy.yml

说明:可以使用相对路径加载剧本文件,也可以使用绝对路径加载剧本文件。
查看剧本执行时输出的详细信息:

ansible-playbook oldboy.yml --verbose

查看剧本执行时会影响哪些主机信息:

ansible-playbook oldboy.yml --list-hosts

执行playbook时指定加载的主机清单文件:

ansible-playbook oldboy.yml -i /etc/ansible/hosts

执行playbook时检查剧本语法是否正确:

ansible-playbook oldboy.yml --syntax-check

执行playbook时只是模拟执行,不会影响主机的配置:

ansible-playbook oldboy.yml -c

3. playbook的输出

剧本在执行过程中,会产生相应输出,根据输出的信息可以掌握剧本是否完整执行、每个执行过程是否正确,以及根据输出的错误提示信息,可以排查剧本编写中的逻辑问题。
剧本在执行时,任务中的每个Action会调用一个模块,然后在模块中检查当前系统状态并决定是否需要重新执行。

  • 如果本地执行了,那么Action会得到返回值changed。
  • 如果不需要执行,那么Action会得到返回值ok。

模块的执行状态的具体判断规则由各个模块自己决定和实现。例如,copy模块的判断方法是比较文件的checksum,copy模块的代码如下:

checksum_src = module.sha1(src)
...
checksum_dest = module.sha1(dest)
...
if checksum_src != checksum_dest or os.path.islink(b_dest):
  ...
  changed = True
else:
  changed = False

下面以一个copy文件的任务为例,展示在执行任务状态到底有什么不同的行为:

- hosts: oldboy 
  tasks: 
    - name: copy the /etc/hosts
      copy: src=/etc/hosts dest=/etc/hosts

第一次执行,执行结果如下所示:

[root@m01 ~]# ansible-playbook copy_hosts.yaml 
PLAY [oldboy] **************************************************************************
TASK [Gathering Facts] *****************************************************************
ok: [192.168.9.6]
ok: [192.168.9.5]
TASK [copy the /etc/hosts] *************************************************************
changed: [192.168.9.5]
changed: [192.168.9.6]
PLAY RECAP *****************************************************************************
192.168.9.5                : ok=2    changed=1    unreachable=0    failed=0   
192.168.9.6                : ok=2    changed=1    unreachable=0    failed=0

第二次执行,执行结果如下所示:

[root@m01 ~]# ansible-playbook copy_hosts.yaml 
PLAY [oldboy] **************************************************************************
TASK [Gathering Facts] *****************************************************************
ok: [192.168.9.6]
ok: [192.168.9.5]
TASK [copy the /etc/hosts] *************************************************************
ok: [192.168.9.6]
ok: [192.168.9.5]
PLAY RECAP *****************************************************************************
192.168.9.5                : ok=2    changed=0    unreachable=0    failed=0   
192.168.9.6                : ok=2    changed=0    unreachable=0    failed=0

由于第一次执行copy_hosts.yaml时,已经复制过文件,因此Ansible会根据文件的状态避免重复复制。
接着更改192.168.9.5主机的/etc/hosts再执行,发现只有192.168.9.5的主机状态是changed,另外一台远程主机的状态是ok:

[root@m01 ~]# ansible-playbook copy_hosts.yaml 
PLAY [oldboy] **************************************************************************
TASK [Gathering Facts] *****************************************************************
ok: [192.168.9.6]
ok: [192.168.9.5]
TASK [copy the /etc/hosts] *************************************************************
ok: [192.168.9.6]
changed: [192.168.9.5]
PLAY RECAP *****************************************************************************
192.168.9.5                : ok=2    changed=1    unreachable=0    failed=0   
192.168.9.6                : ok=2    changed=0    unreachable=0    failed=0

通过以上执行剧本输出的信息,可以将剧本执行过程输出的信息总结为三个部分,具体说明参见下表。

剧本执行过程输出的信息

4. playbook扩展配置

4.1 playbook设置变量功能

在剧本中可以通过设置变量信息,实现相应参数的配置功能,在某些场景下,可以简化对剧本的修改调整。在playbook中,常用的几种变量设置方法如下:
1)在playbook中用户自定义的变量。
2)用户无须定义,Ansible会在执行playbook之前去管理主机上收集关于远程主机系统的信息的变量。
3)在文件模板中,可以直接使用上述两种变量。
4)把任务的运行结果作为一个变量来使用,这个叫作注册变量。
5)为了使playbook更灵活,通用性更强,允许用户在执行playbook时传入变量的值,这个时候就需要用到额外变量。

(1)在playbook中用户自定义的变量
用户可以在playbook中,通过vars关键字自定义变量,之后再用{{}}调用即可。

  • playbook中定义和变量的方法

例如:下面的例子中,用户定义变量为http_port,其值为80。在tasks下的firewalld中,可通过{{ http_port }}调用该变量。

- hosts: web
  vars:
    http_port: 80
  remote_user: root
  tasks:
    - name: insert firewalld rule for httpd
      firewalld: port={{ http_port }}/tcp permanent=true state=enabled imme-diate=yes
  • 将变量配置在单独文件中

当变量较多的时候,或者变量需要在多个playbook中重用的时候,可以把变量放到一个单独的文件中,之后通过关键字“var_files”可将该变量引用到playbook中。使用变量的方法和在文件中定义变量的方法相同:

- hosts: web
  vars_files:
    - vars/server_vars.yml
  remote_user: root
  tasks:
    - name: insert firewalld rule for httpd
      firewalld: port={{ http_port }}/tcp permanent=true state=enabled imme-diate=yes

变量文件/vars/server_vars.yml的内容为:

http_port: 80
  • 定义和使用复杂的变量
    在某些场景中需要使用的变量的值不是简单的字符串或者数字,而是一个对象。对象的定义语法如下,格式为YAML的字典格式:
foo:
  field1: one
  field2: two

访问复杂变量中的子属性,可以利用中括号或者点号:

foo['field1']
foo.field1

(2)远程主机的系统变量(Facts)
Ansible会通过模块“setup”来搜集主机的系统信息,这些搜集到的系统信息称为Facts。每个playbook在执行前都会默认执行setup模块,所以这些Facts信息可以直接以变量的形式使用。
可以通过在命令行中调用setup模块命令,查看所有可以调用的Facts变量信息:

ansible all -m setup -u root

在剧本中调用收集到的Facts变量信息:

- hosts: all
  user: root
  tasks:
    - name: print system info
      debug: msg={{ ansible_os_family }}
    - name: install git on Debian linux
      apt: name=git state=installed
      when: ansible_os_family == "Debian"
    - name: install git on RedHat linux
      yum: name=git state=installed
      when: ansible_os_family == "RedHat"
  • 使用复杂的Facts变量

一般在系统中搜集到如下信息时,复杂的、多层级的Facts变量是如何进行调取的呢?

"ansible_eth0": {
"active": true,
  "device": "eth0",
  "ipv4": {
    "address": "10.0.0.200",
    "broadcast": "10.0.0.255",
    "netmask": "255.255.255.0",
    "network": "10.0.0.0"
  },
}
...

可以通过下面的两种方式访问复杂变量中的子属性:

  • 中括号调用
{{ ansible_eth0["ipv4"]["address"] }}
  • 点号调用
{{ ansible_eth0.ipv4.address }}
  • 关闭Facts

搜集Facts信息会消耗额外的时间,如果不需要Facts信息,则可以在playbook中,通过关键字gather_facts来控制是否搜集远程系统的信息。如果不搜集系统信息,那么上面的Facts变量就不能在该playbook中使用了:

- hosts: oldboy
  gather_facts: no

通过setup模块搜集主机信息时,会发现很多可以作为剧本的facts变量信息,以下为企业中常用的Facts变量信息说明。

企业常用的Facts变量信息

(3)文件模板中使用的变量
template模块在Ansible中十分常用,而它在使用中并没有显式地指定template文件中的值,所以有时候用户会对template文件中的变量感到困惑,所以这里强调以下它的变量的使用。

  • template中变量的定义

在playbook中定义的变量,可以直接在template中使用,同时Facts变量可以直接在template中使用,当然在Inventory中定义的Hosts和Group变量也是如此。所有在playbook中可以访问的变量,都可以在template文件中使用。
下面的playbook脚本中使用了template模块来复制文件index.html.j2,并且替换index.html.j2中的变量为playbook中定义的变量值。

- hosts: web
  vars:
    http_port: 80
    defined_name: "Hello My name is oldboy"
  remote_user: root
  tasks:
    - name: write the default index.html file
      template: src=templates/index.html.j2 dest=/var/www/html/index.html
  • template中变量的使用

在上面的剧本举例中,index.html.j2模板文件直接使用了以下变量信息:
系统定义变量:{{ ansible_hostname }} {{ ansible_default_ipv4.address }}
用户定义变量:{{ defined_name }}
index.html.j2文件的内容如下:


Demo

#46 Demo {{ defined_name }}

Served by {{ ansible_hostname }} {{{ ansible_default_ipv4.address }}}.

(4)运行结果注册变量
把任务的执行结果当作一个变量的值也是可以的。这个时候就需要用到“注册变量”,即把执行结果注册到一个变量中,待后面的任务使用。把执行结果注册到变量中的关键字时register,使用方法如下:

- hosts: web
  tasks:
    - shell: ls
      register: result
      ignore_errors: True
    - shell: echo "{{ result.stdout }}"
      when: result.rc == 5
    - debug: msg=""{{ result.stdout }}

注册变量经常和debug模块一起使用,这样可以得到更多的关于执行错误的信息,以帮助用户调试剧本内容。
(5)用命令行传递变量信息
为了使playbook更灵活,通用性更强,允许用户在执行的时候传入指定变量的值,此时就需要用到“额外变量”。

  • 定义命令变量

在oldboy.yml文件中,hosts和user都定义为变量,它们需要从命令行传递变量值。如果在命令行中不传入值,那么执行playbook是会报错的:

- hosts: '{{ hosts }}'
  remote_user: '{{ user }}'
  tasks:
    - ...

当然也可以直接在playbook中定义变量信息。例如下面的剧本,如果在命令行中传入新的值,那么会覆盖playbook中的值,未在命令行中的传入值也不会报错:

- hosts: localhost
  remote_user: root
  vars:
    test_name: "Value in playbook file"
  tasks:
    - debug: msg=""{{ test_name }}"
  • 使用命令行变量
ansible-playbook oldboy.yml --extra-vars "hosts=web user=root"

还可以用JSON格式传递参数:

ansible-playbook oldboy.yml --extra-vars "{'hosts':'web', 'user':'root'}"

4.2 playbook逻辑控制语句

在playbook中也可以设置一些逻辑控制语句(类似于Shell脚本中的逻辑语句信息),使剧本配置方式更加灵活多样,在剧本中常用的逻辑语句的参数如下:

  • when:条件判断语句,类似编程语言中的if。
  • loop:循环语句,类似编程语言中while。
  • block:把几个任务组成一个代码块,以便针对一组操作的异常进行处理。

(1)条件判断语句when

  • when的基本用法

有时候很可能需满足特定条件才执行某一个特定的步骤,例如在某一个特定版本的系统中安装软件包,或者只在磁盘空间不足的文件系统上执行清理操作。这些操作在playbook中用when语句实现。
若远程主机为Debian Linux系统,则立刻关闭主机系统:

tasks:
  - name: "shutdown Debian system"
    command: /sbin/shutdown -t now
    when: ansible_os_family == "Debian"

进行判断的方式有多种。
1)简单方式:

command: echo oldboy
when: ansible_os_family == "centos"
说明:当指定条件满足时,执行模块动作

2)取反方式:

command: echo oldboy
when: ansible_os_family != "centos"
说明:当指定条件不满足时,执行模块动作

3)多个条件:

command: echo oldboy
when: ansible_os_family == " centos" and ansible_hosts == "web01"
说明:当多个条件同时满足时,执行模块动作
command: echo oldboy
when: ansible_os_family == "centos" or ansible_hosts == "web01"
说明:当多个条件其中之一满足时,执行模块动作

(2)逻辑循环语句loop

  • 标准循环

为了保持简洁,重复的任务可以用以下简写方式:

- name: add server users
  user: name={{ item }} state=present group=oldboy
  with_item:
    - testuser1
    - testuser2

如果在变量文件中或者“vars”区域定义了一组列表变量somelist,也可以进行如下配置:

vars:
  somelist: ["testuser1", "testuser2"]
tasks:
  - name: add server user
    user: name={{ item }} state=present groups=oldboy
    with_items: "{{ somelist }}"

“with_item”用于迭代的list类型变量,不仅支持简单的字符串列表,也可以支持哈希列表,那么可以用以下方式来引用子项:

- name: add server user
  user: name={{ item.name }} state=present groups={{ item.groups }}
  with_items:
    - { name: 'testuser1', groups: 'test1' }
    - { name: 'testuser2', groups: 'test2' }

注意:如果同时使用when和with_items,那么when声明会针对每个条目单独判断一次。

  • 嵌套循环

循环也可以嵌套,用[]访问内存和外层的循环:

- name: give users access to multiple databases
  mysql_user: name={{ item[0] }} priv={{ item[1] }}.*:ALL append_privs=yes password=foo
  with_nested:
    - [ 'alice', 'bob' ]
    - [ 'clientdb', 'employeedb', 'providerd' ]

或者用点号(.)访问内存和外层的变量:

- name: give users access to multiple databases
  mysql_user: name={{ item.0 }} priv={{ item.1 }}.*:ALL append_privs=yes password=foo
  with_nested:
    - [ 'alice', 'bob' ]
    - [ 'clientdb', 'employeedb', 'providerd' ]

4.3 playbook调试功能配置

编写剧本时,可以加入一些调试功能,以便在剧本执行报错时进行调试修改,常用的调试功能有以下几种:

  • ignore_errors:忽略剧本执行过程中的报错信息。
  • tags:给剧本打标签。

(1)剧本执行错误忽略功能
在执行剧本时,由于Ansible具有串行执行特性,即一个任务执行成功,才会执行下一个任务,如果一个剧本中的某任务执行失败了,就会停止剧本的执行。在引入剧本执行报错忽略功能后,可以先忽略有些可能有错误的任务,确保剧本中的其他任务执行完毕,之后再研究出现错误的任务。
实现忽略错误的剧本信息为:

tasks:
- name: install software
shell: yum install -y rsync
- name: create user
    shell: useradd oldboy
    ignore_errors: yes
- name: boot server
shell: systemctl start rsyncd

(2)剧本标签功能
在某些场景中编写剧本任务的步骤会非常烦琐复杂,在进行测试时,某一个任务很可能出现问题,从而需要对剧本进行调试,而剧本调试完毕后,重新测试时,又会反复执行已经成功执行的任务,影响剧本的调试效率。实际上,可以利用剧本标签功能只执行某个剧本任务。
添加标签功能的剧本信息如下:

tasks:
- name: create file info
file: path:/tmp/this_is_{{ ansible_hostname }}_file state=touch
  when: (ansible_hostname == "nfs01") or (ansible_hostname == "backup")
    tags: t1

- name: install httpd
  yum: name=httpd state=installed
  when: (ansible_all_ipv4_addresses == ["192.168.9.5","192.168.9.6"])
    tags: t2

以上剧本包含了两个任务信息,分别对它们做了标记,可以利用Ansible执行剧本的命令参数,以进行如下操作。
执行指定标签任务的命令:

ansible-playbook test_tags.yml -t t1

跳过指定标签任务的命令:

ansible-playbook test_tags.yml --skip-tags t2

4.4 playbook触发功能

(1)什么是剧本触发功能(handlers)
每个主流的编程语言都有Event机制,而handlers就是playbook的Event。
handlers里面的每一个触发器信息都是对模块的一次调用。而handlers与任务不同,任务会默认地按照定义顺序执行,而handlers则不会,它需要在任务中调用,才有可能得到执行。
任务表中的任务都是有状态的:changed或者ok。在Ansible中,只有在任务的执行状态为changed时,才会执行该任务调用的handler。这也是handler与普通的Event机制不同的地方。
(2)剧本触发功能应用场景
如果在任务中修改了Apache的配置文件,那么需要重启Apache。如果还安装了Apache的插件,那么还需要重启Apache。像这样的应用场景,重启Apache就可以设计成一个handler。
一个handler最多只执行一次,并且是在所有的任务都执行完之后再执行。如果有多个任务调用(notify)同一个handler,那么只执行一次。
在下面的例子中Apache重启只执行一次:

- hosts: lb
  remote_user: root
  vars:
      random_number1: "{{ 10000| random }}"
      random_number2: "{{ 10000000| random }}"
    tasks:
      - name: copy the /etc/hosts to /tmp/hosts.{{ random_number1 }}
        copy: src=/etc/hosts dest=/tmp/hosts.{{ random_number1 }}
        notify:
          - call in every action
      - name: copy the /etc/hosts to /tmp/hosts.{{ random_number2 }}
        copy: src=/etc/hosts dest=/tmp/hosts.{{ random_number2 }}
        notify:
          - call in every action
    handlers:
      - name: call in every action
        debug: msg=""call in every action, but execute only one time"

只有是changed状态的任务才会触发handler的执行。
下面的剧本执行了两次,执行结果是不同的。

  • 第一次执行时:
    任务的状态都是changed,会触发两次handler。
  • 第二次执行时:
    第一个任务的状态是ok,因而不会触发handlers “call by /tmp/hosts”;
    第二个任务的状态是changed,触发了handler “call by /tmp/hosts random_number”。

测试代码如下:

- hosts: lb
  remote_user: root
  vars:
      random_number: "{{ 10000| random }}"
    tasks:
      - name: copy the /etc/hosts to /tmp/hosts
        copy: src=/etc/hosts dest=/tmp/hosts
        notify:
          - call by /tmp/hosts
      - name: copy the /etc/hosts to /tmp/hosts.{{ random_number }}
        copy: src=/etc/hosts dest=/tmp/hosts.{{ random_number }}
        notify:
          - call by /tmp/hosts.random_number

    handlers:
      - name: call by /tmp/hosts
        debug: msg=""call first time"
      - name: call by /tmp/hosts.random_number
        debug: msg=""call by /tmp/hosts.random_number"

(3)按定义的顺序执行触发功能
handler是按照定义的顺序执行的,而不是按照所安装的任务中调用的顺序执行的。下面的例子定义的顺序是1>2>3,调用的顺序是3>2>1,实际执行顺序是1>2>3。

- hosts: lb
  remote_user: root
  gather_facts: no
  vars:
      random_number1: "{{ 10000| random }}"
      random_number2: "{{ 10000000| random }}"
    tasks:
      - name: copy the /etc/hosts to /tmp/hosts.{{ random_number1 }}
        copy: src=/etc/hosts dest=/tmp/hosts.{{ random_number1 }}
        notify:
          - define the 3nd handler
      - name: copy the /etc/hosts to /tmp/hosts.{{ random_number2 }}
        copy: src=/etc/hosts dest=/tmp/hosts.{{ random_number2 }}
        notify:
          - define the 2nd handler
          - define the 1nd handler
    handlers:
      - name: define the 1nd handler
        debug: msg""" define the 1nd handler""
      - name: define the 2nd handler
        debug: msg""" define the 2nd handler""
      - name: define the 3nd handler
        debug: msg""" define the 3nd handler""

4.5 playbook整合

在编写多个剧本信息时,有时会实现自动化批量管理,需要执行多个剧本,这时可以将需要执行的多个剧本的信息进行整合,省去利用ansible-playbook命令逐个加载执行剧本的低效工作。实现剧本整合的方式常见的有两种。
1. 只定义单个剧本任务信息
在整合多个剧本信息时,可以只在每个剧本中定义具体的任务信息,而无须定义hosts管理的主机信息,在汇总剧本中灵活调用整合多个剧本,并定义需要执行任务的hosts主机信息,具体的剧本配置信息如下:

- hosts: all
  remote_user: root
  tasks:
  - include_tasks: f1.yml
  - include_tasks: f2.yml

2. 直接将编写好的剧本进行整合
在整合剧本信息时,比较简单的方式就是找到相应剧本,直接利用import_playbook参数进行整合,在执行时会按照整合加载的顺序,执行每一个剧本,具体剧本配置信息如下:

- import_playbook: base.yml
- import_playbook: rsync.yml
- import_playbook: nfs.yml

你可能感兴趣的:(2020-03-20 Ansible最核心的组件playbook)