Ansible 与滚动升级

2023-12-20, 星期三, 17:26

DevOpsSystem AdministrationInfrastructure as CodeCI/CD培训

本文全部代码可在 ddrpa/ansible-rolling-update-example - GitHub 上获取,注意代码删去了一些凭证、主机信息,因此不能直接运行,仅做文章说明配合之用。

本站 Ansible Playbook 101 的「怎样编写任务:以部署与更新应用为例」章节展示了如何部署和更新单个进程的应用程序。如果你的应用程序符合下面这样的部署模式,即每个节点都用相同的方式部署一个应用程序实例,那么并不需要多少改动就可以拿来使用。

C4Deployment title Deployment Diagram for Showcase System System_Ext(lb, "Load balance", "NGINX") Deployment_Node(ecs1, "NODE-1", "ECS") { Container("inst1", "Application Instance 1", "Java") } Deployment_Node(ecs2, "NODE-2", "ECS") { Container("inst2", "Application Instance 2", "Java") } Rel(lb, inst1, "proxy") Rel(lb, inst2, "proxy")
  1. 选择一个节点
  2. 下载新版本应用程序可执行文件
  3. 关闭旧的应用程序
  4. 替换可执行文件
  5. 启动新的应用程序
  6. 检查应用是否工作正常
  7. 选择另一个节点,从步骤 2 开始

在 Playbook 开始的时候设置 serial: 1,这样 Ansible 一次只会改动一个节点,让你有机会在修改下一个节点前停下来看看哪里出了问题。

ansible.builtin.wait_for 模块 可以在一个指定的时间段内测试某些端口是否有响应,一般足以说明应用是否正常,再搭配 ansible.builtin.uri 模块 请求 /health 端点则可以获得更准确的信息。

---
- name: Rolling update Spring Boot application
  hosts: ansibleplayground
  serial: 1
  # ...
  tasks:
    # 一些更新应用的常规操作
    # ...
    # 检查应用是否成功启动
    - name: Wait for application to be ready
      ansible.builtin.wait_for:
        port: '{{ app_port }}'
        # 通过设置 sleep 让示例应用程序的冷启动时间来到了 11 秒
        # Ansible 会在 13 秒后开始检测
        delay: 13
        # 23 秒后仍然未成功就视为启动失败,报错退出
        timeout: 23
        msg: 'Application is not ready'

如果应用未在指定时间内成功启动,就会收到错误并退出 play。

当然,这里面会有一些限制,例如我们认为升级后的数据库 schema(以及其他一些依赖)对旧版本的应用程序也是有效的,所以升级(以及回滚)是比较灵活的。如果数据持久化的层面发生了不兼容的改变,还是老老实实停机升级吧。

当应用启动速度足够快,且对非工作时段的服务可用性要求没有那么苛刻时,这个方案完全可以满足需求。然而,如果我们在升级过程中使用 Locust 等工具进行压力测试,会发现有部分请求失败。虽然这种测试方法并不十分严谨(例如,如果在初始化之前 sleep(10),错误率会大幅上升),但它足以揭示我们正在讨论的问题。

Type Name       # reqs     # fails|Avg Min  Max Med | req/s failures/s
----|----------|------|-----------|---|---|----|----|------|----------
GET  /port      139717 2933(2.10%)| 11   4 2099  10 |849.40       0.00
----|----------|------|-----------|---|---|----|----|------|----------
     Aggregated 139717 2933(2.10%)| 11   4 2099  10 |849.40       0.00

这是由于我们没有告诉负载均衡服务(本例中是 NGINX,有的时候还有可能是 HAProxy)怎样去做故障转移与恢复。

upstream hello {
    # 在 fail_timeout 指定的时间窗口内失败超过 max_fails 次就下线 server
    # 一旦下线就开始计时,在 fail_timeout 时间后重新上线
    server 127.0.0.1:3000 max_fails=3 fail_timeout=13s;
    server 192.168.200.47:3000 max_fails=3 fail_timeout=13s;
}
server {
    ...
    location / {
        # 当 upstream 中的 server 超时或遇到错误时,尝试转发到下一个 server
        proxy_next_upstream error timeout;
        # 设置转发到下一个 server 的超时时间
        proxy_next_upstream_timeout 200ms;
        ....

通过为 upstream > server 添加额外的配置,可以让 NGINX 在请求上游节点遇到若干次问题后将其标记为不可用,并在指定时间后尝试将其放回节点列表(被动式健康检查,小子)。

location 块中的配置则表示请求失败时尝试把请求重新发送到节点列表中的下一个成员。为了防止出现重放问题,NGINX 一般不会重发 HTTP POST 之类看起来就不会幂等的请求,可以通过设置 proxy_next_upstream error timeout non_idempotent; 解决这个问题。但是假如说后端服务接收了这个请求,然后执行了什么操作,紧接着就被关闭了,没有把响应送回 NGINX,于是这个请求就有可能被处理两次。

SLB 之类的云服务会告诉你通过某种方法 上线 / 下线 服务,NGINX 本身也有主动式健康检查、动态配置什么的高级玩意,但那都是 NGINX Plus 的功能,对于 NGINX 普通用户,就只能不停地修改 upstream 配置块并 reload 来实现了。

Load balance during rolling update

我用 Procreate + pyAPNG + Pillow 做了这个有点奇怪的小动画。

  1. 选择一个节点
  2. 将节点从负载均衡中注销
  3. 下载新版本应用程序可执行文件
  4. 关闭旧的应用程序
  5. 替换可执行文件
  6. 启动新的应用程序
  7. 检查应用是否工作正常
  8. 将节点注册到负载均衡
  9. 选择另一个节点,从步骤 2 开始

由于工作脚本可以预见地增长了复杂度,顺便看一看如何组织一个工程化的 ansible 目录。如下目录结构对 Content Organization 中的最佳实践做了一些简化,全部代码可以在 ddrpa/ansible-rolling-update-example - GitHub 上获取。

./ansible
├── group_vars
│   ├── all
│   └── appservers
├── inventory.yml
├── roles
│   └── app
│       ├── tasks
│       │   └── main.yml
│       └── templates
│           └── hello.service.j2
├── rolling_update.yml
└── templates
    └── hello.conf.j2

由于需要给主机节点添加一些额外的信息(例如执行操作的用户),换成 YAML 会清晰一些,于是将主机列表配置从 /etc/ansible/hosts 转移到工作目录的 inventory.yml 文件内。执行 playbook 时通过 -i 参数指定 inventory 配置,如 ansible-playbook -i inventory.yml rolling_update.yml,使用 ansible-inventory -i ./inventory.yml --list 检查文件是否有错误。

---
appservers:
  hosts:
    xiaowang:
      ansible_user: root
    alma.red:
      ansible_user: yufan
    niexiawei:
      ansible_host: 192.168.200.46
      ansible_user: root
loadbalance:
  hosts:
    loadbalance01:
      ansible_host: alma.red
      ansible_user: root

这里配置了两次 alma.red 主机,一次是作为 non-root 用户 yufan 部署应用,另一次是作为 root 用户配置负载均衡。而由于 non-root 用户 yufan 没有 sudo 权限,所以没法使用 become_user: root 方法在目标节点上提升权限,只能声明为两个不同的主机。

你可能注意到主机配置的 ansible_password 属性可以用来传递登录口令,这样就不用配置 SSH 密钥对了。若要使用该功能,配合 Protecting sensitive variables with ansible-vault 对口令字段进行加密。

使用角色可以对任务进行封装,由于大部分任务是指定应用的部署与升级,我们可以将其封装为一个 app 角色。使用 ansible-galaxy init app 创建一个标准的目录结构,再按需删除一些目录。

./roles/app
├── README.md
├── defaults
│   └── main.yml
├── files
├── handlers
│   └── main.yml
├── meta
│   └── main.yml
├── tasks
│   └── main.yml
├── templates
├── tests
│   ├── inventory
│   └── test.yml
└── vars
    └── main.yml

可以把之前写过的 playbook 中的 tasks 部分复制到 app/tasks/main.yml 中,systemd 单元文件的模版可以写入 app/templates/hello.service.j2。由于这个目录结构是符合 Ansible 预期的,同角色内的任务可以直接通过文件名称引用模版。

- name: Update application service unit file
  ansible.builtin.template:
    src: hello.service.j2
    dest: '{{ systemd_unit_path }}/{{ app_name }}.service'
    mode: '0644'

你可能会注意到这里有一个 systemd_unit_path 变量,这是因为 niexiaweixiaowang 两个主机使用 root 用户部署应用,对应地配置服务单元的位置和作用域均有区别。虽然生产实践中不推荐这么做,我们还是可以使用 ansible.builtin.set_fact 模块 根据用户类型给变量赋值。

- name: Setup systemd unit file path and systemctl scope variables determined by user
  ansible.builtin.set_fact:
    systemd_unit_path: '{{ "/etc/systemd/system" if ansible_user_id == "root" else ansible_env.HOME + "/.config/systemd/user" }}'
    systemctl_scope: '{{ "system" if ansible_user_id == "root" else "user" }}'

在要执行的 playbook 中,设置 app 相关的角色,Ansible 会自动在 appservers 组的主机上执行 roles/app/tasks/main.yaml 中定义好的任务清单。

- name: Rolling update app server with zero-downtime
  hosts: appservers
  serial: 1
...
  roles:
    - app
...

至于在负载均衡中 上线下线 服务,则可以放在 play 的 pre_taskspost_tasks 部分。根据之前的设计我们每次只修改一个节点,所以只需要在 play 执行的过程中分别获取所有主机 IP 以及正在修改的节点的 IP(以及刨除当前 IP 剩下的可用主机 IP)。

可以通过预设变量做这件事,在 group_vars 中以主机分组名称 appservers 或者 all 分组创建变量清单,使用字典描述主机与 IP 的关系。

ip_in_load_balance:
  xiaowang: '192.168.200.47'
  alma.red.com: '192.168.200.40'
  niexiawei: '192.168.200.46'

通过 Ansible 过滤器操作这些数据获取想要的 IP 列表:

- name: Read all app server's IP and current node into facts
  ansible.builtin.set_fact:
    # 提取字典的 values 组成全量 IP 列表
    all_app_server_ip_list: '{{ ip_in_load_balance | dict2items | map(attribute="value") | list }}'
    # 使用当前主机名查询节点 IP
    current_node_ip: '{{ ip_in_load_balance[inventory_hostname] }}'
- name: Load all available app server's IP into facts
  ansible.builtin.set_fact:
    # 全量 IP 列表与当前 IP 差分获得剩余可用 IP 列表
    online_app_server_ip_list: '{{ all_app_server_ip_list | difference([current_node_ip]) }}'

有些同学可能认为 {{ ansible_default_ipv4 }} 也可以拿到主机 IP,这里简单起见(而且不易出错),就不去分析应该读取哪个网卡作为默认配置这件事了。

利用 Jinja2 生成 NGINX upstream 配置:

upstream hello {
{% for host in available_hosts %}
    server {{ host }}:3000 max_fails=3 fail_timeout=13s;
{% endfor %}
}

接下来使用委托功能临时到其他主机上执行任务:

- name: Reconfigure load balance with alive IPs
  delegate_to: '{{ groups["loadbalance"][0] }}'
  ansible.builtin.template:
    src: hello.conf.j2
    dest: /etc/nginx/conf.d/hello.conf
    mode: '0644'
  vars:
    available_hosts: '{{ online_app_server_ip_list }}'

为了直观地看到 NGINX 配置的变化,我们可以在执行 playbook 时添加 --step 参数(ansible-playbook <play.yml> --step)单步执行任务,在配置负载均衡任务完成后查看 /etc/nginx/conf.d/hello.conf 的变化。不过更好的方案是使用 ansible.builtin.pause 模块,Ansible 执行到这个任务时会在控制台弹出提示,直到用户按下 Enter 继续。

...
[Pause and check load balance configuration manually]
Please check load balance configuration manually. Press Enter to continue:
ok: [niexiawei]
...

这时候 when: 属性也可以拿来当成功能开关,只需要在 group_vars/all 中设置一个变量:

- name: Pause and check load balance configuration manually
  ansible.builtin.pause:
    prompt: "Please check load balance configuration manually. Press Enter to continue"
  when: manually_confirm_required is defined and manually_confirm_required is true