Oleksandr Kuryzhev

Originally published on kuryzhev.cloud


新しいインスタンスに対して初めてAnsibleブートストラッププレイブックを実行すると失敗し、30秒後に再実行すると正常に動作します。このような現象に心当たりがあるなら、ansible bootstrap playbookが断続的に失敗する問題に直面しており、原因はほぼ例外なくネットワークではありません。私はAWS EC2、Hetzner、DigitalOcean dropletsでこのパターンを何度も経験しましたが、毎回同じ修正方法があります。それは「インスタンスがrunning状態」=「インスタンスがready状態」と考えないことです。

症状

通常は複数の症状が同時に発生し、非決定的であるため非常に厄介です。最初の症状:新しく作成したインスタンスでUNREACHABLE! => Failed to connect to the host via sshが発生し、2回目の試行では完全に解消される。実際のエラーは以下の通りです:

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」(タイムアウトではない)というメッセージが特徴です。これはネットワーク層ではポートが開いているものの、sshdがまだバインドしていないことを示しています。2番目の症状:become: trueのタスクが{"msg": "Missing sudo password"}で失敗するが、手動でSSH接続してsudo whoamiを実行するとパスワードなしで動作する。3番目の症状:プレイブックが「GATHERING FACTS」で1分以上停止した後にタイムアウトするか、次のaptタスクでE: Could not get lock /var/lib/dpkg/lock-frontendが発生する。

個別にみると3つの無関係なバグのように見えますが、根本原因は1つです。コントロールノードがcloud-initと競合しているのです。

根本原因

Ansibleが初回接続を試みる時点で、cloud-initがSSHデーモンとユーザーアカウントの設定を完了していないのです。API上ではインスタンスが「running」状態でも、実際に使用可能になるまでには時間がかかります。これはタイミング競合の問題であり、ファイアウォールやセキュリティグループの設定ミスではありません。

さらに、ansible_python_interpreterの自動検出が、python2シンボリックリンクとpython3の両方が存在するディストリビューションで誤ったバイナリを選択し、モジュールがサイレントに失敗する原因となります。また、ブートストラップユーザー向けにNOPASSWDのsudoersエントリが設定されていないため、ansible_become_passが設定されていない限りbecomeが失敗します。これは通常、ベースイメージ作成者が対話式sudoを想定していたためです。

最後に、クラウドプロバイダーのunattended-upgradesプロセスやcloud-initの初期apt実行が、プレイブックのapt: update_cache=yes実行時にちょうど/var/lib/dpkg/lock-frontendのdpkgロックを取得します。2つのプロセスが1つのロックを奪い合う形になります。

修正方法 #1 — タスク実行前にSSHとcloud-initの準備完了を待機する

ホストの準備が整ったことを確認してからファクト収集を開始してください。wait_for_connectionをプレイの最初のタスクとして使用します。これは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

次に、sleep()で推測するのではなく、cloud-init完了の実際のマーカーを確認します:

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

注意点: SSH接続プラグインにリトライロジックを追加しても、プレイレベルでgather_facts: trueのままにすると、Ansibleは準備完了タスク実行前にファクト収集を試みます。その結果、リトライが機能する前にプレイ全体がタイムアウトします。プレイレベルでgather_facts: falseを設定し、準備完了チェック完了後に明示的にファクト収集を行ってください。また、ansible.cfgのデフォルト値を調整してください。標準の[ssh_connection] retries = 3, timeout = 10は温かいホスト向けに調整されており、コールドブートのクラウドインスタンスには適していません。

修正方法 #2 — 適切な権限昇格でbecome/sudoの失敗を修正する

最もクリーンな修正方法は、Ansibleが接続する前に行います。インスタンス作成時のcloud-init user-dataにsudoersファイルを組み込みます。

# 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_stringansible_become_passを保存してください。group_vars/all.ymlに平文のsudoパスワードがコミットされ、プッシュされているリポジトリを複数見てきました。これは非常に悪い事態を防ぐための5分間の修正です。また、その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      # native lock handling, no manual fuser loop needed
  become: true
  register: apt_result
  retries: 5
  delay: 10
  until: apt_result is succeeded

古いAnsible coreでlock_timeoutが利用できない場合は、手動待機にフォールバックしてください。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です。これは上記の修正を1つのインスタンスだけでなく、フリート全体で安定して動作させるためのものです:

# 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の下にあり、ファクトキャッシュの別設定ではありません。実際のノブがすぐそこにあるのに、20分もキャッシュ設定を掘り下げていたエンジニアを何度も見てきました。

予防策

本当の修正はプレイブック内にはありません。ベースイメージまたはuser-dataスクリプトにあります。イメージに準備完了状態(SSH設定、sudoers、パッケージマネージャーの状態)を組み込んで、Ansibleが最初から競合状態と戦わなくて済むようにします。それが症状を修正するのと根本原因を除去するのとの違いです。

次回のための実用的なチェックリスト:

  • 実際のクラウドAPIに触れる前に、CIでDockerドライバ(molecule-plugins[docker] v23.x)を使用してMolecule経由でブートストラッププレイブックを実行する。
  • requirementsファイルでansible-coreを固定し、requirements.ymlでコレクションバージョンをロックする — インタープリタとモジュールの動作はマイナーリリース間でサイレントに変化する。
  • ホストごとのブートストラップ実行時間と失敗タイプをログに記録する。イメージ更新後にcloud-initが突然90秒長くなった場合、怒りのSlackメッセージではなく、それを表示するグラフが必要。
  • 無制限のwait_for_connectionタイムアウトに注意 — 200ホストのインベントリで1ホストあたり600秒以上かかると、ホストがゆっくり失敗する代わりに素早く失敗する場合、CIランナーの請求額に30分以上がサイレントに追加される。
  • boot-finishedが表示されない場合は、まずターゲット上の/var/log/cloud-init-output.logを確認する — そこに実際のcloud-initエラーが通常存在する。

オートスケーリンググループやスポットフリートに対してAnsibleを実行する場合に頻繁に発生するプロビジョニングのエッジケースとCI強化パターンについては、kuryzhev.cloudでさらに詳しく取り上げています。完全なパラメータリストについては公式のwait_for_connection module docsを参照し、特定のプロバイダーのイメージでboot-finishedマーカーがいつ書き込まれるかを正確に理解する必要がある場合は、cloud-init自身のboot stages documentationを参照してください。

関連記事