Ansible Playbook 101

2023-12-01, 星期五, 16:02

DevOpsInfrastructure as Code培训

正如本文标题中的 101 声称的那样,这只是一篇入门文章,旨在提供一种比编写 Shell 脚本更简明直观地批量设置 Linux 机器的方法。Ansible 有许多其他概念(如「角色」)并没有在本文中提及,你只有在大规模使用 Ansible,需要工程化,封装这样的概念时才用得到,到那个阶段自然就不需要参考本文了。

场景一:你管理了数个项目的近百台机器,现在是周五下午五点二十分,云服务商的联系人发来了一个压缩包,是要求下周前部署好的云监控 agent。

场景二:参考本站的 拿到 ECS 后需要做什么(CentOS & Anolis OS 版),完成这么一长串任务清单还是有点工作量的。

场景三:你需要将一个历史项目从一个服务器集群迁移到另一个集群中,而原来的部署文档散落在知识库、聊天记录、Git 和零碎的 Office 文档中。

对于场景一,有很多种方法可以在远程机器上执行命令,比如 ssh <remote> 'command',或者使用基于 Python 的 fabric

场景二、三则立马引出了 IaC(Infrastructure as Code),Snowflake and Phoenix,Zero Privilege Architectures 之类的有趣话题。这些听起来就好到不真实的点子主张通过代码和自动化而不是手工操作来完成基础设施 / 配置 / 服务的管理和发布,防止知识丢失 / 权限泄漏等一系列问题。

此外把这些工作转化为代码立即产生了一种要求所有人使用相同表达方式思考问题的约束,减少了偏好差异导致的思维负担(和风险),顺带立即获得了版本控制系统带来的各种好处。

这方面也有很多工具比如 TerraformSalt 什么的,不过他们要么太复杂,显得杀鸡用牛刀,要么需要在每一台机器上都安装 agent,不那么开箱即用。

Ansible,或者说本文特指的 Community Ansible 是 RedHat 旗下的自动化工具。Ansible 主要有两种用法,一种是 Ad-hoc 模式,不多做介绍,另一种就是 playbook。

Playbook 是一种针对主机组的声明式的任务列表编排系统,play 代表了在一组主机上执行的任务序列,整个 playbook 使用 YAML 语法编写,使用 ansible-playbook <playbook.yml> 这样的命令执行。

前置条件

  • 你需要在主机中选择一台作为控制节点,之后几乎所有的操作都在控制节点上进行
  • 你需要确保所有节点都安装了 3.5+ 的 Python
  • 你需要确保控制节点可以使用 SSH 和密钥访问所有其他节点
ssh-keygen -t ed25519
ssh-copy-id -i ~/.ssh/id_ed25519.pub <user>@<target-host>

准备工作

Ansible 不依赖受控端的 agent,只需要在控制端安装软件。

如果使用 CentOS 7 以及之后时间发布的 RH 系发行版(AnolisOS、AlmaLinux 什么的),事情就会轻松很多,使用 sudo dnf install ansible-core 就行。如果是 Ubuntu 之类的发行版,需要通过 pip 安装,使用 pip3 install --user ansible

接下来编辑 /etc/ansible/hosts 添加受控主机,这是一个 INI 格式的文件。

mail.example.com

[web]
www.example.com

[log]
mail.example.com
foo.example.com:8022

主机可以使用 IP 地址或 FQDN 标识,并通过 [group-name] 分配到组。除了示例配置中的 web 组和 log 组,还隐式地含有 ungroupedall 两个组。一台主机要么存在于 ungrouped 组,要么存在于某个(些)具名组,所有主机都归属于 all 组。

一般来说不推荐以远端 root 用户执行操作,而不同节点上的普通用户可能有各种各样的名字,可以使用 ansible_user 参数指定:

bmo ansible_user=bmo
red.ddrpa.cc ansible_user=root

其他配置需求,例如使用不同的 SSH 端口等,可以参考 Connecting to hosts: behavioral inventory parameters 中提及的配置项。

开始使用

如果 Playbook 中使用的模块支持测试模式,就可以使用 --check 参数执行测试。这个模式下 Ansible 会报告将要做出什么改变但不会执行。

ansible-playbook --check <playbook>.yml

不过如果 Playbook 中的任务存在依赖关系,例如需要先创建目录再把软件部署到目录中,就没法测试了。

使用 ansible-playbook <playbook>.yml 真正执行 Playbook。

编写 Playbook

你需要在开发机上安装 Ansible 和 Ansible Lint。

pip3 install --user ansible ansible-lint

推荐使用安装了 Ansible Language Support 插件的 Visual Studio Code 编辑 playbook。

怎样编写任务:以部署数据采集器任务为例

本例中将展示如何编写一个部署 Categraf 数据采集器的 playbook,你可以在代码仓库里找到原始版本。

先编写 play 的一些声明信息:

# 部署 Categraf 数据采集器
---
- name: Install and config Categraf
  # 由于所有主机都需要安装,使用 all 分组
  hosts: all
  # 在受控节点上以 root 用户身份执行
  remote_user: root

你也可以使用具有 sudo 权限的非 root 用户执行任务,只需要在需要 sudo 的任务模块中设置 sudo: yes,并在 ansible-playbook 命令中添加 --ask-sudo-pass 参数。

接下来在 tasks 列表中声明要执行的任务。任务会按声明的顺序执行,并且一个任务在所有主机上都执行完毕后才会继续下一个。如果某个主机的任务执行失败,那其将会被移出本次运行。

用 shell 命令描述部署过程,应该和这样差不多:

# 把压缩包上传到目标机器
scp ./categraf-slim-v0.3.39-linux-amd64.tar.gz root@<remote>:...
# 登录目标机器
...

# 在目标机器创建 /opt/categraf 并把压缩包内容解压到该目录下
mkdir /opt/categraf
tar -xzvf categraf-slim-v0.3.39-linux-amd64.tar.gz -C /opt/categraf --strip-components=1
# 编辑配置文件
vi /opt/categraf/conf/config.toml
...

# 注册并启动 systemd 单元
cp /opt/categraf/conf/categraf.service /etc/systemd/system/categraf.service
systemctl enable categraf.service --now

把上述操作转换成任务列表,注意这里为了美观省略了一级缩进。

tasks:
  # 数据采集器部署到 /opt/categraf 目录下,确保这个目录已经存在
  - name: Ensure target directory exists
    ansible.builtin.file:
      path: /opt/categraf
      state: directory
      mode: '0755'
  - name: Unarchive file
    ansible.builtin.unarchive:
      # 简单起见,我们先用手动方式把压缩包上传到控制节点
      # 而不是使用制品分发通道
      src: categraf-slim-v0.3.39-linux-amd64.tar.gz
      # 解压到目标机器的这个目录
      dest: /opt/categraf
      extra_opts:
        - --strip-components=1
  - name: Override config file
    ansible.builtin.copy:
      # 控制节点上有一个修改好的配置文件
      src: config.toml
      # 覆盖目标机器上的这个文件
      dest: /opt/categraf/conf/config.toml
      mode: '0644'
  # 复制单元文件到指定位置
  - name: Copy systemd service to /etc/systemd/system
    ansible.builtin.copy:
      src: /opt/categraf/conf/categraf.service
      dest: /etc/systemd/system/categraf.service
      mode: '0644'
      remote_src: true
  # 启动单元
  - name: Enable and start categraf service
    ansible.builtin.systemd_service:
      name: categraf
      state: started
      enabled: true

有的朋友可能会收到 ERROR! couldn't resolve module/action 'ansible.builtin.systemd_service' 这种信息,从 Ansible 版本和文档上没看出什么原因,可以改成别名 ansible.builtin.systemd 试试,最好用 ansible-doc 列举本机已安装成功的模块。

ansible-doc --list | grep systemd
> ansible.builtin.systemd                    ...
> ansible.builtin.systemd_service            ...
> containers.podman.podman_generate_systemd  Generate systemd unit fro...

在上面的例子中可以看到任务模块符合如下模式:

- name: <name-of-task>
  <ansible-module>:
    <property>: <value>
    <property>: <value>

不管是创建文件和目录、通过包管理器安装软件、修改文本文件、管理 systemd 单元 …… 几乎每一个操作都能找到对应的 Ansible 模块,实在没有也可以用 ansible.builtin.shell。大部分任务模块都来自 ansible.builtin 这个命名空间,可以在 Ansible.Builtin 查看对应的说明与示例。

值得注意的是,Ansible 期望你的操作是幂等的,也就是说像「创建目录」、「下载文件」这样的任务无论执行几次产生的影响都是一样的。如果你在执行 play 时发现了错误,大可以修改后再重新执行。不过如果有些任务步骤比较耗时,也可以设置从某个特定的任务开始执行:

ansible-playbook <playbook>.yml --start-at="<name-of-task>"

也可以要求 Ansible 在每个任务执行前询问是否执行(或跳过)当前任务:

ansible-playbook <playbook>.yml --step

怎样编写任务:以部署与更新应用为例

本例中笔者创建了一个简单的 Spring Boot 示例应用,访问 8000 端口就会返回 Hello World。模拟的工作流程是开发机(或者说 CI Worker)打包好制品后上传到制品库,再由 Ansible 控制节点将其部署到目标机器群上。

# 使用 SHA-256 计算制品的散列值用于校验
sha256sum app.jar > sha256sum.txt

# 上传制品文件和校验值
curl -u finn-the-human:$finn --upload-file app.jar https://repo.ddrpa.cc/release/ansible-playground/1.0.0/app.jar
curl -u finn-the-human:$finn --upload-file sha256sum.txt https://repo.ddrpa.cc/release/ansible-playground/1.0.0/sha256sum.txt

目标机器登记在 alma 分组下,操作以 cloud-user 账号身份执行。

通过 vars 引入一些变量,这样后续如果用于升级应用,只需简单修改 app_version 再重新执行 play。制品库进行了简单的认证,这里认证信息暂且写在 play 中,如果对这一点有要求,也可以通过环境变量传递给 Ansible。

- name: Deployment Spring Boot application
  hosts: alma
  remote_user: cloud-user
  vars:
    repo_password: '**********'
    app_name: ansible-playground
    app_version: 1.0.0

要从远端 URL 下载制品,以关键字 ansible module download file 搜索:

tasks:
  - name: Download artifacts from repository with SHA256 checksum
    ansible.builtin.get_url:
      url: https://repo.ddrpa.cc/release/{{app_name}}/{{app_version}}/app.jar
      dest: /opt/{{app_name}}/app.jar
      # checksum 未改变时,Ansible 不会替换本地已下载的制品文件
      checksum: sha256:https://repo.ddrpa.cc/release/{{app_name}}/{{app_version}}/sha256sum.txt
      username: jack-the-dog
      password: '{{repo_password}}'
      mode: '0755'
  - name: Run application
    ansible.builtin.shell:
      cmd: nohup java -jar app.jar > /dev/null 2>&1 &
      chdir: /opt/{{app_name}}

检查应用是否正常运行。

curl http://alma.ddrpa.cc:8000/
> Hello World!%

尝试修改 vars.app_version1.0.1 更新应用。由于涉及到进程结束与重新启动,更新显然不会成功。我们当然可以使用 ansible.builtin.shell 查找并 kill 指定进程,替换文件后再启动它,不过更简单的方法是使用 systemd 来管理,这样只要 restart 即可。

编写 systemd unit 文件可参考本站的 用 systemd 管理系统和应用程序。Ansible 支持 Jinja2 模版引擎,支持读取 play 中设置的变量。

# {{app_name}}.service
# Generated by ansible at {{ now() }}

[Unit]
Description=Demo service return hello world
Wants=network-online.target
After=network-online.target

[Service]
Type=simple
WorkingDirectory=/opt/{{ app_name }}/
ExecStart=/usr/bin/java -jar /opt/{{ app_name }}/app.jar
Restart=always

[Install]
WantedBy=default.target

整个 play 的逻辑差不多是这样:

flowchart TD create_dir[创建应用目录] --> check_unit[检查单元文件] check_unit --单元文件不存在--> create_unit[创建单元文件] create_unit --> download_artifact check_unit --单元文件存在--> download_artifact[下载制品] download_artifact --> restart_service[重启服务]

你可以用 ansible.builtin.stat 模块判断给定的目录 / 文件是否存在(和 *nix 上的 stat 差不多),并把一个布尔值保存在指定变量中(称为 register)。

这里我们检查用户的相应目录下是否存在指定单元文件。

- name: Check if systemd unit file exist
  ansible.builtin.stat:
    path: '{{ansible_env.HOME}}/.config/systemd/user/{{app_name}}.service'
  register: unit_file_exist

你可能注意到 path 的属性值用引号包裹起来了,这是为了防止 YAML 解析时将其视为字典。涉及到 {{ 开始的行都需要使用引号包裹。

指定单元文件不存在时,通过 ansible.builtin.template 与 Jinja2 模版生成单元文件。

- name: Create systemd unit file by template
  ansible.builtin.template:
    src: service.j2
    dest: '{{ansible_env.HOME}}/.config/systemd/user/{{app_name}}.service'
    mode: '0644'
  when: not unit_file_exist.stat.exists

启动应用任务则替换为重启服务单元任务。由于这里我们是以非 root 用户执行的任务,scope: user 对应 systemctl 命令中的 --user 参数。

- name: Restart systemd service
  ansible.builtin.systemd:
    scope: user
    name: '{{ app_name }}.service'
    state: restarted
    enabled: true

测试通过重放 playbook 升级应用版本:

curl http://alma.ddrpa.cc:8000/
> Hello World!%

# replay playbook with vars.app_version=1.0.1
...

curl http://alma.ddrpa.cc:8000/
> Hello World! v1.0.1%