Ansible 使用 lineinfile 模块修改配置文件

需要用 Ansible 修改配置文件,其实就是在某个文件末尾添加几行内容。直观地想,直接用 shell 模块,echo>> 就完事了。
但仔细一琢磨,很可能会引发一些意想不到的问题,比如:

  • 如果需要添加的配置已经存在,echo 仍会向配置文件底部添加同样的内容
  • 如果添加配置的任务重复执行多次,则配置文件中也会多次出现重复的内容。无法做到幂等
  • 如何做到,当对应的配置已经存在,则将该配置改为期望的值;当对应的配置不存在,不做任何操作(有就修改,没有就不动。好像可以用 sed
  • 如何安全地移除指定的配置项

诸如此类。运维工作常常要关系到生产环境。任何无法预期的效果都可能产生严重的影响。而单纯使用 echo>> 向配置文件中添加内容,具有很大的不确定性。
当然可以形成一个 Shell 脚本,对各种边界进行足够的检查和判定,但这会导致代码量变大,结构复杂难以标准化;同时也容易出现遗漏的情况。

实际上 Ansible 内置的 lineinfile 就是专门用来处理上述任务的模块。

比如针对如下内容的配置文件 test_conf.ini

1
2
FIRST=true
SECOND=2

需要添加一行配置 THIRD=3

可以运行如下内容的 playbook change_config.yml

1
2
3
4
5
6
7
8
9
10
- name: change configuration
gather_facts: false
hosts: localhost

tasks:
- name: change content in test_conf.ini
lineinfile:
path: /home/starky/projects/ansible/practice/test_conf.ini
regexp: '^THIRD'
line: THIRD=3

其中 lineinfile 模块的 path 参数用于指定目标配置文件的路径;regexp 参数则用于指定对文件内容进行匹配时使用的正则表达式;最后的 line 参数表示希望在目标文件中出现的内容。

具体的步骤为:

  • 检查 line 对应的内容是否存在于 path 对应的目标文件中
  • 若已经存在。则目标文件符合要求,不对该文件做任何操作
  • 若不存在。通过 regexp 指定的正则表达式对目标文件进行匹配
  • regexp 匹配到文本行,则将该行内容修改为 line 指定的内容
  • regexp 未匹配到文本行,则将 line 对应的内容作为新的一行添加到目标文件末尾

运行效果:

1
2
3
4
5
6
7
8
9
$ ansible-playbook change_config.yml

PLAY [change configuration] *********************************************************************************************************

TASK [change content in test_conf.ini] **********************************************************************************************
changed: [localhost]

PLAY RECAP **************************************************************************************************************************
localhost : ok=1 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0

此时 test_conf.ini 配置文件的内容被修改为:

1
2
3
FIRST=true
SECOND=2
THIRD=3

若再次运行 ansible-playbook change_config.yml 命令:

1
2
3
4
5
6
7
8
9
$ ansible-playbook change_config.yml

PLAY [change configuration] *********************************************************************************************************

TASK [change content in test_conf.ini] **********************************************************************************************
ok: [localhost]

PLAY RECAP **************************************************************************************************************************
localhost : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0

可以看到修改配置文件的任务执行结果为 ok,而不同于上一次的 changed。这表示 lineinfile 模块对配置文件的内容进行了检查,发现需要添加的配置行已经存在,因此未做任何改动。符合幂等的原则。

假如将配置文件中的 THIRD=3 改为 THIRD=false,再次运行 playbook:

1
2
3
4
5
6
7
8
9
$ ansible-playbook change_config.yml

PLAY [change configuration] *********************************************************************************************************

TASK [change content in test_conf.ini] **********************************************************************************************
changed: [localhost]

PLAY RECAP **************************************************************************************************************************
localhost : ok=1 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0

正则表达式 ^THIRD 会匹配到配置文件的第三行 THIRD=false,再将该行内容替换为 THIRD=3

最终仍可以得到我们想要的内容:

1
2
3
FIRST=true
SECOND=2
THIRD=3

对于 Ansible playbook 而言,我们只需要关注期望达到的状态,而不用纠结为了达到该状态需要执行哪些步骤
lineinfile 模块,line 指定的内容即为我们期望目标文件达到的状态。即该文件最终一定会包含一行与 line 相同的文本。
不管该行内容是本就已经存在的,还是通过修改 regexp 匹配到的文本行得到的,还是直接在目标文件末尾新增的。而我们只需要定义 pathregexpline 三个参数即可。

其他用法

backrefs

lineinfile 默认的行为是若 line 指定的内容未存在,regexp 正则表达式也没有任何匹配,就在文件末尾添加一行 line 指定的内容。
backrefs 参数可以修改此行为。当 backrefs 设定为 true 时,若 line 指定的内容不存在,正则表达式也没有匹配。则不做任何操作。

比如如下 playbook:

1
2
3
4
5
6
7
8
9
10
11
- name: change configuration
gather_facts: false
hosts: localhost

tasks:
- name: change content in test_conf.ini
lineinfile:
path: /home/starky/projects/ansible/practice/test_conf.ini
regexp: '^THIRD'
line: 'THIRD=3'
backrefs: true

当目标文件的内容如下:

1
2
FIRST=true
SECOND=2

playbook 实际不会对其做任何修改,不会在文件末尾添加 THIRD=3。只有当文件中存在如 THIRD=false 这类内容时,playbook 才会完成匹配并替换对应的行。

没有 backrefs 表示匹配就替换,不匹配就在文件末尾添加;有 backrefs 表示匹配就替换,不匹配就不动。

删除一行内容
1
2
3
4
5
6
7
8
9
10
- name: change configuration
gather_facts: false
hosts: localhost

tasks:
- name: change content in test_conf.ini
lineinfile:
path: /home/starky/projects/ansible/practice/test_conf.ini
regexp: '^THIRD'
state: absent
在匹配行前/后添加
1
2
3
4
5
6
7
8
9
10
- name: change configuration
gather_facts: false
hosts: localhost

tasks:
- name: change content in test_conf.ini
lineinfile:
path: /home/starky/projects/ansible/practice/test_conf.ini
insertbefore: '^FIRST'
line: 'ZERO=false'
1
2
3
4
5
6
7
8
9
10
- name: change configuration
gather_facts: false
hosts: localhost

tasks:
- name: change content in test_conf.ini
lineinfile:
path: /home/starky/projects/ansible/practice/test_conf.ini
insertafter: '^THIRD'
line: 'FOURTH=4'

需要注意以下两点:

  • line 指定的内容已经存在于目标文件中时,不管其具体在什么位置,目标文件都不会做任何修改
  • insertbeforeinsertafter 指定的正则表达式没有任何匹配时,都会在文件末尾添加 line 指定的内容

官网示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
# NOTE: Before 2.3, option 'dest', 'destfile' or 'name' was used instead of 'path'
- name: Ensure SELinux is set to enforcing mode
ansible.builtin.lineinfile:
path: /etc/selinux/config
regexp: '^SELINUX='
line: SELINUX=enforcing

- name: Make sure group wheel is not in the sudoers configuration
ansible.builtin.lineinfile:
path: /etc/sudoers
state: absent
regexp: '^%wheel'

- name: Replace a localhost entry with our own
ansible.builtin.lineinfile:
path: /etc/hosts
regexp: '^127\.0\.0\.1'
line: 127.0.0.1 localhost
owner: root
group: root
mode: '0644'

- name: Replace a localhost entry searching for a literal string to avoid escaping
lineinfile:
path: /etc/hosts
search_string: '127.0.0.1'
line: 127.0.0.1 localhost
owner: root
group: root
mode: '0644'

- name: Ensure the default Apache port is 8080
ansible.builtin.lineinfile:
path: /etc/httpd/conf/httpd.conf
regexp: '^Listen '
insertafter: '^#Listen '
line: Listen 8080

- name: Ensure php extension matches new pattern
lineinfile:
path: /etc/httpd/conf/httpd.conf
search_string: '<FilesMatch ".php[45]?$">'
insertafter: '^\t<Location \/>\n'
line: ' <FilesMatch ".php[34]?$">'

- name: Ensure we have our own comment added to /etc/services
ansible.builtin.lineinfile:
path: /etc/services
regexp: '^# port for http'
insertbefore: '^www.*80/tcp'
line: '# port for http by default'

- name: Add a line to a file if the file does not exist, without passing regexp
ansible.builtin.lineinfile:
path: /tmp/testfile
line: 192.168.1.99 foo.lab.net foo
create: yes

# NOTE: Yaml requires escaping backslashes in double quotes but not in single quotes
- name: Ensure the JBoss memory settings are exactly as needed
ansible.builtin.lineinfile:
path: /opt/jboss-as/bin/standalone.conf
regexp: '^(.*)Xms(\d+)m(.*)$'
line: '\1Xms${xms}m\3'
backrefs: yes

# NOTE: Fully quoted because of the ': ' on the line. See the Gotchas in the YAML docs.
- name: Validate the sudoers file before saving
ansible.builtin.lineinfile:
path: /etc/sudoers
state: present
regexp: '^%ADMIN ALL='
line: '%ADMIN ALL=(ALL) NOPASSWD: ALL'
validate: /usr/sbin/visudo -cf %s

# See https://docs.python.org/3/library/re.html for further details on syntax
- name: Use backrefs with alternative group syntax to avoid conflicts with variable values
ansible.builtin.lineinfile:
path: /tmp/config
regexp: ^(host=).*
line: \g<1>{{ hostname }}
backrefs: yes

参考资料

ansible.builtin.lineinfile – Manage lines in text files