ホームラボ向け Proxmox 自動構築 Part1 ── ISO 自動生成からノード発見・固定 IP 化まで
概要
ホームラボの Proxmox VE ノードを、できるだけ手動操作なしで構築する仕組みを Ansible で作りました。
-
カスタム ISO の自動生成:
answer.tomlを埋め込んだ自動インストール ISO を Ansible で作成 - ノード探索と UUID 照合: DHCP で起動したノードをネットワーク走査で発見し、UUID でインベントリと照合
- 固定 IP・ホスト名の自動設定: 照合結果に基づいて静的 IP とホスト名を設定し、運用可能な状態にする
手動操作は「ISO からブートする」の 1 ステップだけです。
背景
筆者のホームラボでは 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 ステップで構成されています。
-
ISO ビルド ──
ansible-playbook playbooks/build_iso.ymlでanswer.toml埋め込み済みカスタム ISO を生成 - ISO ブート(唯一の手動操作) ── 物理マシンを ISO から起動し、Proxmox VE を自動インストール。DHCP で接続された状態になる
-
ブートストラップ ──
ansible-playbook playbooks/bootstrap.ymlで以下の 3 フェーズを実行- Phase 1: Discovery ── ネットワーク走査で DHCP ノードを発見
- Phase 2: Identification ── UUID/serial でインベントリと照合
- Phase 3: Configuration ── 固定 IP・ホスト名を設定して再起動
-
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)でノードを個体識別する点です。
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 の走査範囲やネットワーク共通設定を定義します。
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 ロールを呼び出し、以下の処理を行います。
- Proxmox VE の公式 ISO をダウンロード
-
proxmox-auto-install-assistantとxorrisoをインストール -
answer.tomlをテンプレートから生成 - 応答ファイルを埋め込んだカスタム ISO を作成
answer.toml の構成
answer.toml は Proxmox の自動インストーラが参照する応答ファイルです。インストーラの対話操作(キーボード、タイムゾーン、パスワードなど)をすべてこのファイルで定義します。
[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 アドレスのリストに変換します。
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 のネットワーク全体でも数秒で走査が完了します。
- 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_group は add_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_uuid と product_serial の両方をチェックしています。ハードウェアによって UUID が格納されるフィールドが異なる場合があるためです。
configure_host.yml の処理内容
UUID が一致したノードに対して、以下の設定を順に適用します。
- ホスト名設定
/etc/hosts更新-
静的 IP 設定(
/etc/network/interfacesテンプレート適用) -
環境固有の SSH 公開鍵を配置 ──
answer.tomlで埋め込んだ鍵とは別に、環境ごとの運用鍵を追加します - ネットワーク反映のため再起動
- 新 IP での SSH 接続確認(最大 5 回リトライ)
ネットワーク設定のテンプレートでは、Proxmox 標準の Linux Bridge(vmbr0)を構成します。
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