🖥️

ホームラボ向け Proxmox 自動構築 Part1 ── ISO 自動生成からノード発見・固定 IP 化まで

に公開

概要

ホームラボの Proxmox VE ノードを、できるだけ手動操作なしで構築する仕組みを Ansible で作りました。

  • カスタム ISO の自動生成: answer.toml を埋め込んだ自動インストール ISO を Ansible で作成
  • ノード探索と UUID 照合: DHCP で起動したノードをネットワーク走査で発見し、UUID でインベントリと照合
  • 固定 IP・ホスト名の自動設定: 照合結果に基づいて静的 IP とホスト名を設定し、運用可能な状態にする

手動操作は「ISO からブートする」の 1 ステップだけです。

https://github.com/tjst-t/ansible-proxmox-infra

背景

筆者のホームラボでは Proxmox VE のノードを複数台運用しています。検証のためにProxmox VEをOSから再インストールする場面もあります。そのたびに発生するのが以下の手動作業です。

  • ISO からインストーラを起動し、キーボード配列やタイムゾーンを選択する
  • ネットワーク設定を入力する
  • インストール完了後にホスト名と固定 IP を設定する
  • 証明書の発行、レポジトリの設定、ストレージの設定、etc...

1 台なら大した手間ではありません。しかしノードが増えたり、再インストールを繰り返す場面では、この手動作業が積み重なります。

理想はゼロタッチプロビジョニング(ZTP)です。エンタープライズ環境では PXE ブートや MAAS(Metal as a Service)を使って実現しますが、ホームラボの規模では DHCP/TFTP サーバの構築や MAAS の導入は大掛かりすぎます。

そこで目をつけたのが、Proxmox VE 8.1 以降で導入された 自動インストール機能です。CLI ツール proxmox-auto-install-assistant を使うと、応答ファイル(answer.toml)を ISO に埋め込めます。これによりインストーラの対話操作を省略できます。Ansible と組み合わせて、「ISO を焼いてブートする」以外の操作を自動化しました。

全体アーキテクチャ

構築フローは以下の 4 ステップで構成されています。

  1. ISO ビルド ── ansible-playbook playbooks/build_iso.ymlanswer.toml 埋め込み済みカスタム ISO を生成
  2. ISO ブート(唯一の手動操作) ── 物理マシンを ISO から起動し、Proxmox VE を自動インストール。DHCP で接続された状態になる
  3. ブートストラップ ── ansible-playbook playbooks/bootstrap.yml で以下の 3 フェーズを実行
    • Phase 1: Discovery ── ネットワーク走査で DHCP ノードを発見
    • Phase 2: Identification ── UUID/serial でインベントリと照合
    • Phase 3: Configuration ── 固定 IP・ホスト名を設定して再起動
  4. Day2 設定 ── ansible-playbook playbooks/configure_proxmox.yml でクラスタ構築・リポジトリ設定・証明書・VIP 構成

プロジェクトの構成は以下の通りです(ソースコードは概要セクションのリンクから参照できます)。

.
├── filter_plugins/       # カスタムフィルタ(IP 範囲展開)
├── inventory/            # ネットワーク・ホスト定義
│   └── test/
│       ├── credentials/  # 環境専用の SSH 鍵
│       ├── hosts.yml     # ホスト定義(UUID/IP/ホスト名)
│       └── group_vars/all.yml
├── playbooks/
│   ├── build_iso.yml     # ISO ビルド
│   ├── bootstrap.yml     # 初期セットアップ(3 フェーズ)
│   └── configure_proxmox.yml  # Day2 設定
└── roles/
    ├── iso_builder/      # ISO 作成ロール
    └── bootstrap/        # 探索・設定ロール

前提・環境

  • Proxmox VE: 9.1(Debian bookworm ベース)
  • Ansible: 2.15 以上
  • コントロールノード: Ansible を実行するマシン(Linux / macOS)
  • 対象ノード: Proxmox VE をインストールする物理マシン
  • ネットワーク: コントロールノードと対象ノードが同一 L2 ネットワーク上にあること
  • DHCP サーバ: 対象ノードが DHCP で IP を取得できること(家庭用ルータで可)

準備: インベントリ定義

まず、構築対象のノードをインベントリに定義します。この設計の特徴は、UUID(SMBIOS の product_uuid)でノードを個体識別する点です。

inventory/test/hosts.yml
all:
  hosts:
    pve-01:
      ansible_host: "172.16.1.11"
      proxmox_ip: "172.16.1.11"
      proxmox_smbios_id: "5b05f981-d568-47fe-bdca-0015221dee0f"
      proxmox_hostname: "pve01"
      proxmox_gateway: "172.16.1.1"
      proxmox_network_interface: "ens18"

各変数の役割は以下の通りです。

変数 説明
ansible_host / proxmox_ip 最終的に割り当てる固定 IP
proxmox_smbios_id ノードの UUID(マザーボード固有値)
proxmox_hostname 設定するホスト名
proxmox_gateway デフォルトゲートウェイ
proxmox_network_interface 物理 NIC のインターフェース名

グループ変数では、DHCP の走査範囲やネットワーク共通設定を定義します。

inventory/test/group_vars/all.yml
proxmox_root_password: "Passw0rd!"
bootstrap_dhcp_network: "172.16.1.0/24"
bootstrap_dhcp_range: "172.16.1.64-72"
proxmox_keyboard: "jp"
proxmox_timezone: "Asia/Tokyo"
proxmox_netmask: "255.255.255.0"
proxmox_dns: "1.1.1.1"

bootstrap_dhcp_range は、ブートストラップ時にスキャンする IP の範囲です。DHCP サーバのリース範囲に合わせて設定してください。

ステップ 1: 自動インストール ISO ビルド

ISO ビルドは playbooks/build_iso.yml で実行します。

ansible-playbook -i localhost, playbooks/build_iso.yml -K \
  -e "proxmox_root_password=YourPassword"

このプレイブックは iso_builder ロールを呼び出し、以下の処理を行います。

  1. Proxmox VE の公式 ISO をダウンロード
  2. proxmox-auto-install-assistantxorriso をインストール
  3. answer.toml をテンプレートから生成
  4. 応答ファイルを埋め込んだカスタム ISO を作成

answer.toml の構成

answer.toml は Proxmox の自動インストーラが参照する応答ファイルです。インストーラの対話操作(キーボード、タイムゾーン、パスワードなど)をすべてこのファイルで定義します。

roles/iso_builder/templates/answer.toml
[global]
keyboard = "jp"
country = "jp"
fqdn = "proxmox.local"
mailto = "admin@example.com"
timezone = "Asia/Tokyo"
root-password = "{{ proxmox_root_password }}"  # Ansible テンプレートで置換
root-ssh-keys = [
  "{{ proxmox_ssh_keys[0] }}"  # コントロールノードの SSH 公開鍵が埋め込まれる
]
reboot-mode = "reboot"

[network]
source = "from-dhcp"

[disk-setup]
filesystem = "ext4"
disk-list = ["sda"]

[network] セクションで source = "from-dhcp" を指定しています。初回インストール時は固定 IP をまだ設定せず、DHCP で仮の IP を取得します。固定 IP の設定はブートストラップフェーズで行います。

root-ssh-keys にはコントロールノードの SSH 公開鍵を埋め込みます。これにより、インストール直後からパスワード認証なしで SSH 接続が可能になります。

ISO 生成コマンド

proxmox-auto-install-assistant が ISO の書き換えを行います。

proxmox-auto-install-assistant prepare-iso \
  <source_iso> \
  --fetch-from iso \
  --answer-file <answer.toml> \
  --output <output_iso>

--fetch-from iso は、応答ファイルを ISO 内に埋め込む方式を指定しています。他に HTTP サーバから取得する --fetch-from http などもありますが、ホームラボでは ISO 埋め込みが最もシンプルです。

ステップ 2: ISO からインストール

生成されたカスタム ISO を USB メモリに書き込み、物理マシンをブートします。これが唯一の手動操作です。

通常のインストーラとは異なり、answer.toml に基づいてすべての設定が自動的に行われます。キーボード選択やディスク選択の画面は表示されず、インストールが完了すると自動的に再起動します。

再起動後、ノードは DHCP で IP を取得した状態で SSH 接続可能になります。

ステップ 3: ブートストラップ

ブートストラップは playbooks/bootstrap.yml で実行します。3 つのフェーズ(Discovery, Identification, Configuration)で構成されています。

ansible-playbook -i inventory/test/ playbooks/bootstrap.yml

Phase 1: Discovery(探索)

DHCP で起動したノードのIPアドレスはまだわかりません。Phase 1 では、指定したIP範囲をスキャンしてSSHポート(22番)が開いているホストを探します。

- name: "Phase 1: Discovery"
  hosts: localhost
  connection: local
  tasks:
    - name: "Include discovery tasks"
      include_role:
        name: bootstrap
        tasks_from: discovery.yml

    - name: "Add discovered IPs to temporary group"
      add_host:
        name: "{{ item }}"
        groups: discovered_group
      loop: "{{ candidate_nodes | default([]) }}"

IP 範囲の展開には、カスタムフィルタプラグインを使っています。172.16.1.64-72 のような範囲指定を IP アドレスのリストに変換します。

filter_plugins/ip_filters.py
import netaddr

class FilterModule(object):
    def filters(self):
        return {'generate_ips': self.generate_ips}

    def generate_ips(self, ip_input):
        """
        CIDR (例: 192.168.1.0/24) または範囲指定 (例: 192.168.1.10-20) を
        IP アドレスのリストに変換する
        """
        if '-' in ip_input:
            # "192.168.1.10-20" → start="192.168.1.10", end="192.168.1.20"
            parts = [p.strip() for p in ip_input.split('-')]
            start_str = parts[0]
            end_str = parts[1]

            # 短縮形の場合、先頭 IP のプレフィックスを補完する
            if '.' not in end_str:
                prefix = '.'.join(start_str.split('.')[:-1])
                end_str = f"{prefix}.{end_str}"

            return [str(ip) for ip in netaddr.IPRange(start_str, end_str)]
        else:
            return [str(ip) for ip in netaddr.IPNetwork(ip_input).iter_hosts()]

ポートスキャンは Python の concurrent.futures を使って並列実行しています。50 ワーカーでタイムアウト 0.5 秒という設定により、/24 のネットワーク全体でも数秒で走査が完了します。

roles/bootstrap/tasks/discovery.yml
- name: "Scan IPs for port 22 (Parallel)"
  shell: |
    python3 -c "
    import socket
    from concurrent.futures import ThreadPoolExecutor
    def check(ip):
        try:
            with socket.create_connection((ip, 22), timeout=0.5):
                return ip
        except:
            return None
    ips = '{{ ips_to_scan | join(',') }}'.split(',')
    with ThreadPoolExecutor(max_workers=50) as e:
        results = e.map(check, ips)
    print(','.join([r for r in results if r]))
    "
  delegate_to: localhost

Phase 2: Identification(識別)

Phase 1 で発見した IP に SSH 接続し、ansible_facts を収集します。この段階では ansible_ssh_pass を使ったパスワード認証でログインします(answer.toml で設定した root パスワード)。

- name: "Phase 2: Identification"
  hosts: discovered_group
  gather_facts: no
  ignore_unreachable: yes
  tasks:
    - name: "Gather facts from discovered nodes"
      setup:
      vars:
        ansible_user: "root"
        ansible_ssh_pass: "{{ proxmox_root_password | default('') }}"

gather_facts: no を指定しつつ明示的に setup モジュールを呼んでいます。discovered_groupadd_host で動的に作成されたグループであり、インベントリ側に認証情報が定義されていません。タスクレベルの vars でパスワードを渡すために、暗黙の facts 収集を無効化し、明示的に setup を呼ぶ構成にしています。

ignore_unreachable: yes は、接続できないホストをスキップするための設定です。Phase 1 で発見した IP の中には、Proxmox ノード以外のホスト(既存サーバなど)が含まれる可能性があります。

このフェーズで収集される ansible_facts の中に product_uuid(SMBIOS UUID)が含まれています。これが次のフェーズでインベントリとの照合に使われます。

Phase 3: Configuration(設定)

Phase 2 で収集した product_uuid を、インベントリに定義した proxmox_smbios_id と照合します。一致したノードに対して、固定 IP・ホスト名の設定を行います。

- name: "Phase 3: Configuration"
  hosts: "all:!discovered_group:!localhost"
  tasks:
    - name: "Match UUID and assign ansible_host"
      set_fact:
        ansible_host: "{{ item }}"
        node_identified: yes
      loop: "{{ groups['discovered_group'] | default([]) }}"
      when:
        - proxmox_smbios_id is defined
        - hostvars[item].ansible_facts.get('product_uuid') == proxmox_smbios_id or
          hostvars[item].ansible_facts.get('product_serial') == proxmox_smbios_id

    - name: "Run configuration for matched hosts"
      include_role:
        name: bootstrap
        tasks_from: configure_host.yml
      when: node_identified | bool

hosts: "all:!discovered_group:!localhost" は、インベントリに定義されたホスト(pve-01 など)を対象にしています。discovered_group(Phase 1 で発見した DHCP IP のグループ)と localhost は除外しています。

UUID の照合には product_uuidproduct_serial の両方をチェックしています。ハードウェアによって UUID が格納されるフィールドが異なる場合があるためです。

configure_host.yml の処理内容

UUID が一致したノードに対して、以下の設定を順に適用します。

  1. ホスト名設定
  2. /etc/hosts 更新
  3. 静的 IP 設定/etc/network/interfaces テンプレート適用)
  4. 環境固有の SSH 公開鍵を配置 ── answer.toml で埋め込んだ鍵とは別に、環境ごとの運用鍵を追加します
  5. ネットワーク反映のため再起動
  6. 新 IP での SSH 接続確認(最大 5 回リトライ)

ネットワーク設定のテンプレートでは、Proxmox 標準の Linux Bridge(vmbr0)を構成します。

roles/bootstrap/templates/interfaces.j2
auto lo
iface lo inet loopback

iface {{ proxmox_network_interface | default('enp0s1') }} inet manual

auto vmbr0
iface vmbr0 inet static
    address {{ proxmox_ip }}
    netmask {{ proxmox_netmask | default('255.255.255.0') }}
    gateway {{ proxmox_gateway }}
    bridge-ports {{ proxmox_network_interface | default('enp0s1') }}
    bridge-stp off
    bridge-fd 0

ネットワーク設定の変更後は再起動が必要です。DHCP の IP から固定 IP に切り替わるため、再起動後は新しい IP アドレスで SSH 接続を確認します。

動作確認

ブートストラップ完了後、以下を確認します。

# 固定 IP で SSH 接続できること
ssh root@172.16.1.11

# ホスト名が正しく設定されていること
hostname  # → pve01

# Proxmox Web UI にアクセスできること
# ブラウザで https://172.16.1.11:8006 を開く

ここまでで、ISO ブート以外の操作はすべて自動化されています。

Day2 設定に進む場合は configure_proxmox.yml を実行します。

ansible-playbook -i inventory/test/ playbooks/configure_proxmox.yml

つまずきポイント

answer.toml の [network] セクション

当初、answer.toml で固定 IP を直接指定する方法を試みました。しかし、自動インストーラの answer.toml ではノードごとに異なる IP を指定する手段がありません(1 つの ISO に 1 つの answer.toml しか埋め込めない)。このため、初回は DHCP で起動し、後からブートストラップで固定 IP を設定する 2 段階方式を採用しました。

UUID の取得元フィールド

Ansible の setup モジュールが返す UUID は、ハードウェアによって product_uuid に入る場合と product_serial に入る場合があります。最初は product_uuid だけで照合していたため、一部のマシンで照合に失敗しました。両方のフィールドをチェックするよう修正して解決しています。

ポートスキャンのタイミング

ISO からのインストール完了後、ノードが SSH 接続可能になるまでには再起動を含めて数分かかります。ブートストラップを早く実行しすぎると Phase 1 でノードが見つかりません。ノードの再起動が完了してから実行する必要があります。

まとめ

Proxmox VE の自動インストール機能と Ansible を組み合わせることで、手動操作を「ISO からブートする」の 1 ステップに最小化できました。

  • ISO ビルド: answer.toml を埋め込んだカスタム ISO を proxmox-auto-install-assistant で自動生成
  • ブートストラップ: ネットワーク走査 → UUID 照合 → 固定 IP 設定の 3 フェーズで、DHCP 状態のノードを自動的にインベントリ通りの構成に変換
  • 個体識別: SMBIOS UUID を使うことで、DHCP でランダムな IP を取得したノードでもインベントリと確実に照合できる

PXE や MAAS のようなインフラを別途用意する必要がなく、既存の DHCP サーバ(家庭用ルータ)とAnsible だけで完結する点が、ホームラボの規模に適しています。

Discussion