Ansible 快速入门

Ansible 是一个 IT 自动化软件,类似于 Puppet 和 Chef,是实现 Infra-as-a-code 的一种工具。

QuickStart

理解 Ansible 如何控制远程服务器

Ansible 的所有操作是基于 ssh 协议的,我们可以通过 ssh 命令在远程服务器上执行命令:

$ ssh root@ip 'ping baidu.com'
PING baidu.com (39.156.69.79) 56(84) bytes of data.
64 bytes from 39.156.69.79 (39.156.69.79): icmp_seq=1 ttl=48 time=7.83 ms
64 bytes from 39.156.69.79 (39.156.69.79): icmp_seq=2 ttl=48 time=3.43 ms
64 bytes from 39.156.69.79 (39.156.69.79): icmp_seq=3 ttl=48 time=3.45 ms
64 bytes from 39.156.69.79 (39.156.69.79): icmp_seq=4 ttl=48 time=3.46 ms

在 Ansible 中编写的脚本最终也会被翻译成类似上面命令的方式被执行。

在 Ansible 执行的过程中,需要两种角色的节点,分别对应 ssh 的 client 和 server:

  • 管理节点(执行 ansible 命令的机器)
  • 托管节点(被 ansible 的管理的机器)

Ansible 的管理节点目前支持类 unix 系统,也就是 linux 和 macos。暂时不支持 windows 作为管理节点。管理节点可以是自己本地的电脑,但是由于私有化部署时我们面对的通常都是客户的内网环境,一般都无法在自己的开发机直接访问。为了简化使用方式,我们选择客户的一台服务器及作为管理节点也同时作为托管节点,所有 ansible 的操作总是从管理节点发起。

既然 Ansible 是基于 ssh 协议实现的,我们必须决定以哪个用户的身份登陆,以何种方式进行 ssh 认证,以及确保登陆用户有 sudo 的权限。ssh 常用的认证方式支持密码和证书两种,由于我们希望自动化部署,不想在部署过程中频繁的输入密码,因此配置证书认证是比较好的选择。另外很多客户环境下 sudo 也需要额外的密码,因此需要在初始化环境时配置 ssh 的登陆用户可以免密的进行 sudo 操作。

总结一下,我们需要:

  • 选择一台客户内网的服务器作为管理节点,该节点通常也是我们的托管节点
  • 对托管节点配置基于证书的 ssh 免密登陆
  • 确保 ssh 登陆用户可以免密进行 sudo 操作

Inventory File

Ansible 可同时操作属于一个组的多台主机,组和主机之间的关系通过 inventory 文件配置。默认的文件路径为 /etc/ansible/hosts 。不过我们通常不把 inventory file 放在系统目录下,而是放在单独的配置目录。

我们试着写一个 inventory file :

[webserver]
host1
host2

保存为 hosts ,然后通过 ansible 的命令行执行 adhoc 命令

$ ansible all -i hosts -m ping
host1 | UNREACHABLE! => {
    "changed": false,
    "msg": "Failed to connect to the host via ssh: ssh: Could not resolve hostname host1: nodename nor servname provided, or not known",
    "unreachable": true
}
host2 | UNREACHABLE! => {
    "changed": false,
    "msg": "Failed to connect to the host via ssh: ssh: Could not resolve hostname host2: nodename nor servname provided, or not known",
    "unreachable": true
}

其中 all<host-pattern> , all 是一个特殊的 pattern,匹配所有 hosts。提示报错是预期的结果,因为 host1, host2 并不存在。

Playbook,Role,Task

上一节展示了如何通过 ansible 执行 adhoc 命令,但是大部分时候我们用的更多的是 ansible-playbook 命令。Playbook 是 ansible 的任务编排语言,通过 YAML 来描述。关于 Playbook 和 role, task 的关系,一句话就可以说清楚:剧本 (playbook) 开始,角色们(role) 依次登上舞台,完成自己的任务 (task)。

playbook 一般是 ansible 执行的入口;role 更像是模块,是我们复用代码的基本单位;task 是一个个具体的实现。

先从一个不考虑复用的 playbook 开始:

- hosts: all
  tasks:
    - name: install vim
      yum:
        name: vim
        state: present

    - name: install jdk
      yum:
        name: openjdk
        state: present

这个 playbook 会在所有机器上安装 vim 和 openjdk。yum 是 ansible 提供的一个模块,ansible 拥有非常强大的生态,我们需要几乎所有操作都被 ansible 很好的封装了。所有模块的文档可以参考:https://docs.ansible.com/ansible/latest/modules/modules_by_category.html

随着 task 越来越多,playbook 会变得非常长而且不容易维护,中间如果有重复部分也难以实现代码复用。这个时候就轮到角色(role) 登场了。

编写可复用的脚本

role 从代码上看就是一个特定结构的目录,典型的 role 目录结构如下:

site.yml
webservers.yml
fooservers.yml
roles/
   common/
     files/
     templates/
     tasks/
     handlers/
     vars/
     defaults/
     meta/
   webservers/
     files/
     templates/
     tasks/
     handlers/
     vars/
     defaults/
     meta/

每个目录下默认的入口都是 main.yml 这个文件。然后我们可以在 playbook 中引入这个 role:

---
- hosts: webservers
  roles:
     - common
     - webservers

role 和 role 之间可以设置依赖关系,通过依赖我们可以隐式的执行某些 role。角色依赖需要定义在 meta/main.yml 文件中:

---
dependencies:
  - { role: common, some_parameter: 3 }
  - { role: apache, port: 80 }
  - { role: postgres, dbname: blarg, other_parameter: 12 }

当一个 playbook 包含多个 role,并且 role 依赖了共同的 role 时,可能会有重复执行的情况。ansible 有一些机制避免重复无意义的执行,但是该规则不是很容易直观理解。

变量

变量的定义

Inventory File

[all:vars]
foo1=bar
foo2=bar2

[group1]
host1
host2

[group1:vars]
foo3=bar3

PlayBook

---
- hosts: all
  vars:
    http_port: 80
  vars_prompt:
    - name: service_name
      prompt: "Which service do you want to remove?"
      private: no
  vars_files:
    - /vars/external_vars.yml

Role

有些变量只用于当前 role,可能会定义在 role 当中。一般位于 defaults/mian.yml 或者 vars/ 目录下。

具体的使用参见:https://docs.ansible.com/ansible/latest/user_guide/playbooks_reuse_roles.html

Ansible 预定义

部分变量是 Ansible 自己定义的变量,比如我们要获取机器的 hostname:

{{ ansible_facts['nodename'] }}

这部分预定义变量非常多,可以通过下面命令查看:

$ ansible hostname -m setup
{
    "ansible_all_ipv4_addresses": [
        "REDACTED IP ADDRESS"
    ],
    "ansible_all_ipv6_addresses": [
        "REDACTED IPV6 ADDRESS"
    ],
    "ansible_apparmor": {
        "status": "disabled"
    },
    "ansible_architecture": "x86_64",
    "ansible_bios_date": "11/28/2013"
        ......
}

Command Line

ansible-playbook site.yml --extra-vars='{"foo": "bar"}'

https://docs.ansible.com/ansible/latest/user_guide/playbooks_variables.html

变量的作用域

ansible 的变量作用域主要有 3 种:

  • Global:ansible 配置文件,环境变量,命令行
  • Play:playbook vars, vars_prompt, vars_files; role defaults, vars
  • Host: Inventory 中定义的的 host vars; facts

变量使用

ansible 允许你在各处通过 jinja2 的语法使用变量。jinja2 是一个用 Python 开发的模版引擎,本身并不复杂,核心东西就 3 个:变量的输出,控制流,filter

两个大括号包起来表示输出变量:

I'm {{ name }}

变量输出时可以用过 filter 控制格式,类似 bash 里的 pipeline。filter 本质上是一个 Python 的函数,有一个入参和一个返回结果:

I'm {{ name | trim | title }}

控制流需要区别于输出,用 {% %} 表示。比如一个 for 循环

{% for name in names %}
I'm {{ name | title }}
{% endfor %}

条件判断

{% for name in names %}
{% if ignore_case %}
I'm {{ name | lower }}
{% else %}
I'm {{ name }}
{% endif %}
{% endif %}

上面就是 jinja2 的介绍,更多细节需要去看文档。

需要注意的是,在 Ansible 中如果你要使用 jinja2 的语法去引用一个变量,必须用双引号内使用。

- hosts: all
  vars:
    deploy_path: "{{ home_dir }}/apps"

比如我们想根据各种上下文生成 nginx 的配置文件,可以通过 template 命令来渲染。首先定一个模版文件 nginx.conf.j2

server {
  server_name {{ server_name }};
  listen 80;
  
  location / {
    try_files $uri $uri/ /index.html;
  }
}

我们希望这个配置文件可以覆盖默认的 nginx 配置:

- hosts: nginx
  vars:
    server_name: gio.com
    nginx_user: nginx
    nginx_group: "{{ nginx_user }}"
  tasks:
    - name: generate nginx config file
      template:
        src: nginx.conf.j2
        dest: /etc/nginx/nginx.conf
        owner: "{{ nginx_user }}"
        group: "{{ nginx_group }}"
      become: yes

最佳实践

保持操作的幂等

shell 脚本在执行时,很多命令的执行结果不是确定的。很多时候我们无法避免的需要反复重试某些脚本:

  • 脚本自身有 bug,修完以后需要重试
  • 机器状态不确定。比如脚本执行过程中机器重启了;免密 sudo 忘记配置结果执行到某个需要 sudo 的 task 时跪了。
  • 命令本身执行结果就是不确定的,比如通过 systemd 启动一个进程,systemctl start 命令返回成功并不代表真的启动成功了。

ansible 的写法都是声明式而不是命令式。命令描述过程,声明式描述意图。明确的意图可以让 ansible 可以更好的帮你决定是否要重试某个任务,安全的隐藏细节。比如我们删除某个文件,命令式描述这样的:

- name: delete file
  command: rm /tmp/xxx

但是当我们重复跑这个 task 时,已经被删除文件无法再次被删除,需要在每次删除前检查目标文件是否已经被删除。但是用声明式的写法就很容易实现:

- name: delete file
  file:
    src: /tmp/xxx
    state: absent

编写 ansible 脚本时,要始终记得一件事:不要想着你要做什么操作,而是描述你期望某个对象的状态是怎么样的。

谨慎处理非 root 用户运行时的逻辑

ansible 的 task 有两个属性 becomebecome_user ,分别代表是否要使用 sudo 以及 sudo 的用户。比如创建一个目录并指定目录的 owner 和 group

- name: create dir
  file:
    path: /etc/foo
    state: directory
    owner: foo
    group: foo

如果我们以 root 用户的身份执行 ansible 脚本,上面的脚本没有任何问题。但是如果是一个有 sudo 权限的普通用户,如果没有显式使用 sudo 的话,没有权限在 /etc 目录下创建任何东西。这是时候就需要使用 become

- name: create dir
  file:
    path: /etc/foo
    state: directory
    owner: foo
    group: foo
  become: yes

测试脚本

检查语法:

ansible-playbook --syntax-check <playbook>

Dry-run:

ansible-playbook --check <playbook>

真实执行脚本:

由于测试可能需要反复执行,每次都申请服务器显然不现实,推荐本地用 VirtualBox + Vagrant 来进行测试。

首先定义 Vargrantfile,来创建 3 个虚拟机,hostname 是 hadoop[1-3]

$ cat Vagrantfile                                                                                                                                                    
# -*- mode: ruby -*-
# vi: set ft=ruby :

# All Vagrant configuration is done below. The "2" in Vagrant.configure
# configures the configuration version (we support older styles for
# backwards compatibility). Please don't change it unless you know what
# you're doing.
Vagrant.configure("2") do |config|
  config.vm.define 'hadoop01' do |hadoop01|
    hadoop01.vm.box = 'centos/7'
    hadoop01.vm.hostname = 'hadoop01'
    hadoop01.vm.network "forwarded_port", guest: 80, host: 8000
    hadoop01.vm.network :private_network, :ip => '192.168.10.192'
    hadoop01.vm.provision :hosts, :sync_hosts => true
    hadoop01.vm.provision :hosts, :add_localhost_hostnames => false
    hadoop01.vm.provider :virtualbox do |v|
      v.customize ["modifyvm", :id, "--natdnshostresolver1", "on"]
      v.customize ["modifyvm", :id, "--memory", 2048]
      v.customize ["modifyvm", :id, "--name", "hadoop01"]
    end
  end

  config.vm.define 'hadoop02' do |hadoop02|
    hadoop02.vm.box = 'centos/7'
    hadoop02.vm.hostname = 'hadoop02'
    hadoop02.vm.network "forwarded_port", guest: 80, host: 8001
    hadoop02.vm.network :private_network, :ip => '192.168.10.193'
    hadoop02.vm.provision :hosts, :sync_hosts => true
    hadoop02.vm.provision :hosts, :add_localhost_hostnames => false
    hadoop02.vm.provider :virtualbox do |v|
      v.customize ["modifyvm", :id, "--natdnshostresolver1", "on"]
      v.customize ["modifyvm", :id, "--memory", 2048]
      v.customize ["modifyvm", :id, "--name", "hadoop02"]
    end
  end

  config.vm.define 'hadoop03' do |hadoop03|
    hadoop03.vm.box = 'centos/7'
    hadoop03.vm.hostname = 'hadoop03'
    hadoop03.vm.network "forwarded_port", guest: 80, host: 8002
    hadoop03.vm.network :private_network, :ip => '192.168.10.194'
    hadoop03.vm.provision :hosts, :sync_hosts => true
    hadoop03.vm.provision :hosts, :add_localhost_hostnames => false
    hadoop03.vm.provider :virtualbox do |v|
      v.customize ["modifyvm", :id, "--natdnshostresolver1", "on"]
      v.customize ["modifyvm", :id, "--memory", 2048]
      v.customize ["modifyvm", :id, "--name", "hadoop03"]
    end
  end
end

启动服务器并配置 ssh

$ vagrant up

$ vagrat ssh-config

创建一个用于测试的 inventory file,然后执行 playbook:

ansible-playbook <playbook> -i <inventory_file>

单元测试:

ansible 的单元测试比较接近集成测试。有第一个第三方的框架可以支持:

https://molecule.readthedocs.io/en/latest/

细节比较多这里不单独介绍,执行也需要依赖 docker 或者 vagrant

Reference