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_stringでansible_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を参照してください。
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.