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 符號連結與 python3 一起提供的發行版上選擇錯誤的二進位檔案,這會靜默地破壞模組而不是大聲失敗。同時,沒有人為引導使用者配置 NOPASSWD sudoers 條目,所以 become 會失敗,除非設定 ansible_become_pass——而這通常沒有設定,因為建立基礎映像的人假設互動式 sudo 沒問題。

最後,雲端提供者的無人值守升級程序或 cloud-init 的初始 apt 執行會在您的劇本 apt: update_cache=yes 觸發時,恰好取得 /var/lib/dpkg/lock-frontend 的 dpkg 鎖。兩個程序,一把鎖,一個輸家。

修復 #1 — 在執行任務前等待 SSH 和 cloud-init 就緒

在主機證明它已就緒之前,不要收集 facts。使用 wait_for_connection 作為 play 中的第一個任務——它從 Ansible 2.3 開始就內建於 ansible.builtin,不需要安裝額外的 collection。

- name: Wait for SSH to become available
  ansible.builtin.wait_for_connection:
    delay: 10       # 給 cloud-init 一個先發優勢再探測
    timeout: 300     # 某些提供者的冷啟動需要 2-4 分鐘
    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 會在您的就緒任務執行前就嘗試收集 facts——整個 play 會在您的重試啟動前逾時。在 play 層級設定 gather_facts: false,並在就緒檢查通過後明確收集 facts。另外提高 ansible.cfg 的預設值——標準 [ssh_connection] retries = 3, timeout = 10 是針對溫啟動主機調整的,而不是冷啟動雲端執行個體。

修復 #2 — 使用適當的特權提升修復 become/sudo 失敗

最乾淨的修復發生在 Ansible 連線之前:在執行個體建立時將 sudoers 檔案烘焙到 cloud-init user-data 中。

# cloud-init user-data 片段
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
# 結束代碼 0 = 無密碼 sudo 正常運作
# 結束代碼 1 = 設定錯誤,請先修復再繼續

在劇本中,使用清楚的訊息快速失敗,而不是讓後續每個任務都以隱晦的錯誤死亡:

- 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 探索。特別是在 Debian 11/12 和 Ubuntu 22.04/24.04 映像中,明確地在 group_vars 中固定它,因為探索邏輯之前已經讓人們受傷:

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      # 原生鎖處理,不需要手動 fuser 迴圈
  become: true
  register: apt_result
  retries: 5
  delay: 10
  until: apt_result is succeeded

如果您使用的是沒有 lock_timeout 的舊版 Ansible core,請改用手動等待——檢查兩個鎖路徑,因為不同 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

這是完整的組合劇本,已經在針對 Ubuntu 22.04 和 Debian 12 的 Python 3.11 控制節點上的 Ansible core 2.16.3 上測試過:

# 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 — 針對冷啟動雲端執行個體調整
[defaults]
timeout = 30
host_key_checking = False
retry_files_enabled = False

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

請注意,收集 facts 的逾時位於 [defaults] timeout 下,而不是某個獨立的 fact-caching 設定。我見過工程師在快取設定中鑽研二十分鐘,而真正的旋鈕就放在那裡。

預防

真正的修復根本不在劇本中——它在上游,在基礎映像或 user-data 指令碼中。將就緒狀態(SSH 設定、sudoers、套件管理員狀態)烘焙到映像中,這樣 Ansible 就不必一開始就與競速條件搏鬥。這是修補症狀與移除根本原因之間的差別。

下次使用的實用檢查清單:

  • 在它觸及真實雲端 API 之前,透過 Molecule 使用 Docker 驅動程式(molecule-plugins[docker] v23.x)在 CI 中執行引導劇本。
  • 在您的 requirements 檔案中固定 ansible-core,並在 requirements.yml 中鎖定 collection 版本——直譯器和模組行為會在次要版本之間靜默地改變。
  • 記錄每個主機的引導執行持續時間和失敗類型。如果 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 強化模式——如果您對自動擴展群組或 spot 機群執行 Ansible,而這個競速經常出現,值得一看。參考官方 wait_for_connection 模組文件 以取得完整參數清單,如果您需要確切了解特定提供者映像上何時寫入 boot-finished 標記,請參考 cloud-init 自己的 啟動階段文件

相關