Oleksandr Kuryzhev

最初发布于 kuryzhev.cloud


您的 Ansible 引导剧本在首次运行时对新创建的实例失败,三十秒后重新运行时却完美工作。如果这种情况听起来很熟悉,您正在处理间歇性失败的 ansible 引导剧本——而且这几乎从来不是网络问题。我在 AWS EC2、Hetzner 和 DigitalOcean droplets 上遇到过完全相同的模式,每次修复方法都相同:不要将“实例正在运行”视为“实例就绪”。

症状

通常以组合形式出现的三个故障特征令人抓狂,因为它们是非确定性的。首先:UNREACHABLE! => Failed to connect to the host via ssh 在全新实例上出现,第二次尝试时完全消失。具体错误为:

fatal: [10.0.1.42]: UNREACHABLE! => {
    "changed": false,
    "msg": "Failed to connect to the host via ssh: ssh: connect to host 10.0.1.42 port 22: Connection refused",
    "unreachable": true
}

“Connection refused”——而不是“timed out”——就是线索。这意味着网络层端口已打开,但 sshd 尚未绑定到该端口。第二个症状:become: true 任务以 {"msg": "Missing sudo password"} 失败,即使您可以手动 SSH 登录并运行 sudo whoami 而无需提示。第三个症状:剧本在“GATHERING FACTS”处停留超过一分钟后超时,或者紧接着的 apt 任务以 E: Could not get lock /var/lib/dpkg/lock-frontend 失败。

单独来看,这些看起来像是三个不相关的错误。综合起来,它们是一个问题:控制节点正在与 cloud-init 竞争。

根本原因

Cloud-init 尚未完成配置 SSH 守护进程和用户账户,Ansible 就尝试首次连接。在 API 看来,您的实例“正在运行”,但实际上远早于可用状态——这是一个时序竞争,完完全全不是防火墙或安全组配置错误。

此外,ansible_python_interpreter 自动发现会在仍然提供 python2 符号链接的发行版上选择错误的二进制文件,导致模块静默中断而非大声失败。同时,没有人为引导用户配置 NOPASSWD sudoers 条目,因此除非设置 ansible_become_pass(通常未设置,因为基础镜像的创建者假定交互式 sudo 即可)。

最后,云提供商自己的无人值守升级进程或 cloud-init 的初始 apt 运行会在您的剧本 apt: update_cache=yes 触发时抢占 /var/lib/dpkg/lock-frontend 的 dpkg 锁。两个进程,一个锁,一个输家。

修复 #1 — 在运行任务前等待 SSH 和 cloud-init 就绪

在主机证明就绪前不要收集事实。将 wait_for_connection 作为 play 中的字面第一个任务使用——它自 Ansible 2.3 起包含在 ansible.builtin 中,无需额外安装集合。

- name: Wait for SSH to become available
  ansible.builtin.wait_for_connection:
    delay: 10       # give cloud-init a head start before probing
    timeout: 300     # cold boots on some providers take 2-4 minutes
    sleep: 5

然后检查实际的 cloud-init 完成标记,而不是用 sleep() 猜测:

- name: Wait for cloud-init to finish
  ansible.builtin.wait_for:
    path: /var/lib/cloud/instance/boot-finished
    timeout: 180

注意:如果您向 SSH 连接插件添加重试逻辑,但保留 play 上的 gather_facts: true,Ansible 会在就绪任务运行前尝试收集事实——整个 play 在重试生效前超时。在 play 级别设置 gather_facts: false,并在就绪检查通过后显式收集事实。同时提高 ansible.cfg 的默认值——原有的 [ssh_connection] retries = 3, timeout = 10 是为温机调整的,不是冷启动的云实例。

修复 #2 — 使用正确的权限提升修复 become/sudo 失败

最干净的修复发生在 Ansible 连接之前:在实例创建时将 sudoers 文件烘焙到 cloud-init user-data 中。

# cloud-init user-data snippet
runcmd:
  - echo "ansible ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/ansible-bootstrap
  - chmod 440 /etc/sudoers.d/ansible-bootstrap

在信任之前验证它是否真正工作:

ansible -m command -a "sudo -n true" host
# exit code 0 = passwordless sudo works
# exit code 1 = misconfigured, fix before continuing

在剧本中,快速失败并显示清晰消息,而不是让每个后续任务都以隐晦错误失败:

- name: Verify passwordless sudo works
  ansible.builtin.command: sudo -n true
  register: sudo_check
  changed_when: false
  failed_when: false

- name: Fail with clear message if sudo not configured
  ansible.builtin.fail:
    msg: "NOPASSWD sudo not configured for {{ ansible_user }} — fix cloud-init user-data"
  when: sudo_check.rc != 0

注意:如果您的环境中强制要求基于密码的 sudo,请使用 ansible-vault encrypt_string 存储 ansible_become_pass。我见过不止一个仓库在 group_vars/all.yml 中有明文 sudo 密码,已提交并推送。这是一个五分钟的修复,可以避免非常糟糕的一天。此外——将该 NOPASSWD 条目仅限于引导用户,并在初始配置完成后轮换或删除它。如果该账户以后被泄露,留下广泛的 NOPASSWD 访问权限会带来权限提升风险。

修复 #3 — 固定 Python 解释器并处理 apt 锁争用

停止依赖 auto 发现。显式固定在 group_vars 中,尤其是在 Debian 11/12 和 Ubuntu 22.04/24.04 镜像上,发现逻辑之前已经让人们受苦:

ansible_python_interpreter: /usr/bin/python3

ansible_python_interpreter: auto_silent 只是隐藏警告——它不会修复错误的二进制选择。如果您不确定发生了什么,请运行 ansible-playbook -vvv 并在 SSH 命令输出中检查确切的解释器路径。确认不匹配的最快方法。

对于 dpkg 锁,最干净的修复是 apt 模块上的原生 lock_timeout 参数(Ansible 2.10+),结合重试循环:

- name: Update apt cache with retry
  ansible.builtin.apt:
    update_cache: true
    cache_valid_time: 3600
    lock_timeout: 60      # native lock handling, no manual fuser loop needed
  become: true
  register: apt_result
  retries: 5
  delay: 10
  until: apt_result is succeeded

如果您使用的是没有 lock_timeout 的较旧 Ansible 核心,请回退到手动等待——检查两个锁路径,因为不同 dpkg 版本使用的锁路径不同:

- name: Wait for dpkg lock to release
  ansible.builtin.shell: |
    while fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1; do sleep 1; done
  changed_when: false
  become: true

以下是完整的组装剧本,已在 Ansible core 2.16.3、Python 3.11 控制节点上测试,针对 Ubuntu 22.04 和 Debian 12:

# playbook: bootstrap.yml
---
- name: Bootstrap fresh cloud servers
  hosts: new_servers
  gather_facts: false
  become: false

  vars:
    ansible_python_interpreter: /usr/bin/python3

  tasks:
    - name: Wait for SSH to become available
      ansible.builtin.wait_for_connection:
        delay: 10
        timeout: 300
        sleep: 5

    - name: Wait for cloud-init to finish
      ansible.builtin.wait_for:
        path: /var/lib/cloud/instance/boot-finished
        timeout: 180

    - name: Now gather facts safely
      ansible.builtin.setup:

    - name: Verify passwordless sudo works
      ansible.builtin.command: sudo -n true
      register: sudo_check
      changed_when: false
      failed_when: false

    - name: Fail with clear message if sudo not configured
      ansible.builtin.fail:
        msg: "NOPASSWD sudo not configured for {{ ansible_user }} — fix cloud-init user-data"
      when: sudo_check.rc != 0

    - name: Wait for dpkg lock to release
      ansible.builtin.shell: |
        while fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1; do sleep 1; done
      changed_when: false
      become: true

    - name: Update apt cache with retry
      ansible.builtin.apt:
        update_cache: true
        cache_valid_time: 3600
        lock_timeout: 60
      become: true
      register: apt_result
      retries: 5
      delay: 10
      until: apt_result is succeeded

以及匹配的 ansible.cfg——这才是让上述修复在整个机群而非单个幸运实例上稳定的原因:

# ansible.cfg — tuned for cold-boot cloud instances
[defaults]
timeout = 30
host_key_checking = False
retry_files_enabled = False

[ssh_connection]
retries = 5
timeout = 30
pipelining = True

注意,事实收集超时位于 [defaults] timeout 下,而不是某个单独的事实缓存设置。我见过工程师在缓存配置中挖掘二十分钟,而真正的旋钮就在那里。

预防

真正的修复根本不在剧本中——而在上游,在基础镜像或 user-data 脚本中。将就绪状态(SSH 配置、sudoers、包管理器状态)烘焙到镜像中,这样 Ansible 就不必首先与竞争条件斗争。这就是修补症状和消除根本原因之间的区别。

下次使用的实用清单:

  • 在触及真实云 API 之前,使用 Docker 驱动程序(molecule-plugins[docker] v23.x)通过 Molecule 在 CI 中运行引导剧本。
  • 在 requirements 文件中固定 ansible-core,并在 requirements.yml 中锁定集合版本——解释器和模块行为在次要版本之间会静默漂移。
  • 记录每个主机的引导运行持续时间和失败类型。如果 cloud-init 在镜像更新后突然多花 90 秒,您需要一个显示该情况的图表,而不是凌晨 2 点的愤怒 Slack 消息。
  • 注意无界 wait_for_connection 超时——200 台主机的清单中每台主机 600 秒以上可能会静默增加 30 多分钟,并在主机缓慢失败而非快速失败时增加您的 CI 运行器账单。
  • 如果 boot-finished 从未出现,请首先在目标上检查 /var/log/cloud-init-output.log——实际的 cloud-init 错误通常就位于那里。

我们在 kuryzhev.cloud 上涵盖了更多这些配置边缘情况和 CI 强化模式——如果您在针对自动扩展组或现货舰队运行 Ansible(这种竞争经常出现),值得一看。参考官方 wait_for_connection 模块文档 获取完整参数列表,以及 cloud-init 自己的 引导阶段文档,如果您需要了解特定提供商镜像上何时写入 boot-finished 标记。

相关