TerraformでProxmox VEにCloud-init対応VMを複数台作成する
はじめに
自宅ラボや検証環境で Proxmox VE を使っていると、同じ構成の VM を何台も作りたくなることがあります。
Proxmox UI から 1 台ずつ clone して、CPU、メモリ、IP アドレス、Cloud-init、タグを設定していくこともできますが、台数が増えるとだんだん手作業がつらくなります。
この記事では、Terraform と bpg/proxmox Provider を使って、Cloud-init 対応テンプレートから複数 VM をまとめて作成する構成を紹介します。
対象は次のような用途です。
- Proxmox VE 上に Kubernetes や検証用 Linux VM をまとめて作りたい
- Cloud-init で IP アドレスや SSH 公開鍵を流し込みたい
- VM 定義を
terraform.tfvarsにまとめたい - Proxmox の VM タグも Terraform から付けたい
作るもの
Terraform で以下のような VM 群を作成します。
virtual_machines = {
master1 = {
vm_id = 1001
node_name = "pve"
template_vm_id = 9000
template_node_name = "pve"
cloud_init_username = "ubuntu"
cpu_cores = 2
memory_mb = 2048
disk_size_gb = 33
ipv4_address = "192.168.11.45/24"
vlan_id = null
tags = ["control-plane", "ubuntu"]
}
master2 = {
vm_id = 1002
node_name = "pve"
template_vm_id = 9010
template_node_name = "pve"
cloud_init_username = "almalinux"
cpu_cores = 2
memory_mb = 2048
disk_size_gb = 33
ipv4_address = "dhcp"
vlan_id = null
tags = ["control-plane", "almalinux"]
}
}
共通タグは別変数にして、すべての VM に付与できます。
vm_tags = ["terraform", "cloud-init"]
実際に VM に付くタグは、共通タグと VM ごとのタグを結合したものです。
ファイル構成
今回の Terraform 構成は次のようにしています。
.
├── versions.tf
├── provider.tf
├── variables.tf
├── main.tf
├── outputs.tf
├── terraform.tfvars.example
└── README.md
主な役割は以下です。
| ファイル | 内容 |
|---|---|
versions.tf |
Terraform と Provider のバージョン制約 |
provider.tf |
Proxmox Provider の接続設定 |
variables.tf |
入力変数、型、validation |
main.tf |
VM clone、CPU、メモリ、ディスク、ネットワーク、Cloud-init |
outputs.tf |
作成した VM 情報の出力 |
terraform.tfvars.example |
設定例 |
コピペでファイルを作成する
空のディレクトリから作る場合は、以下をそのまま貼り付ければ Terraform ファイル一式を作成できます。
mkdir -p terraform-proxmox
cd terraform-proxmox
cat <<'EOF' > .gitignore
.terraform/
*.tfstate
*.tfstate.*
terraform.tfvars
crash.log
crash.*.log
override.tf
override.tf.json
*_override.tf
*_override.tf.json
.terraform.tfstate.lock.info
EOF
cat <<'EOF' > versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
proxmox = {
source = "bpg/proxmox"
version = "0.111.1"
}
}
}
EOF
cat <<'EOF' > provider.tf
provider "proxmox" {
endpoint = var.proxmox_endpoint
api_token = var.proxmox_api_token
insecure = var.proxmox_insecure
}
EOF
cat <<'EOF' > variables.tf
variable "proxmox_endpoint" {
description = "Proxmox VE API endpoint URL. Example: https://pve1.example.local:8006/"
type = string
validation {
condition = can(regex("^https://.+:8006/?$", var.proxmox_endpoint))
error_message = "proxmox_endpoint must be an HTTPS URL ending with port 8006, for example https://pve1.example.local:8006/."
}
}
variable "proxmox_api_token" {
description = "Proxmox VE API token in the form user@realm!tokenid=secret. Pass it with TF_VAR_proxmox_api_token."
type = string
sensitive = true
validation {
condition = can(regex("^[^@!]+@[^@!]+![^=]+=.+$", var.proxmox_api_token))
error_message = "proxmox_api_token must look like user@realm!tokenid=secret."
}
}
variable "proxmox_insecure" {
description = "Skip TLS certificate verification. Use true only for test environments with self-signed certificates."
type = bool
}
variable "template_vm_id" {
description = "Source Cloud-init capable template VM ID."
type = number
validation {
condition = var.template_vm_id > 0
error_message = "template_vm_id must be greater than 0."
}
}
variable "template_node_name" {
description = "Proxmox node name where the source template VM exists."
type = string
validation {
condition = length(trimspace(var.template_node_name)) > 0
error_message = "template_node_name must not be empty."
}
}
variable "target_datastore" {
description = "Target datastore for cloned VM disks."
type = string
validation {
condition = length(trimspace(var.target_datastore)) > 0
error_message = "target_datastore must not be empty."
}
}
variable "network_bridge" {
description = "Proxmox network bridge connected to the VM network device."
type = string
validation {
condition = length(trimspace(var.network_bridge)) > 0
error_message = "network_bridge must not be empty."
}
}
variable "cloud_init_datastore" {
description = "Datastore for the Cloud-init disk."
type = string
validation {
condition = length(trimspace(var.cloud_init_datastore)) > 0
error_message = "cloud_init_datastore must not be empty."
}
}
variable "cloud_init_username" {
description = "User name created or configured by Cloud-init."
type = string
validation {
condition = can(regex("^[a-z_][a-z0-9_-]*[$]?$", var.cloud_init_username))
error_message = "cloud_init_username must be a valid Linux user name."
}
}
variable "ssh_public_key_file" {
description = "Path to the SSH public key file passed to Cloud-init."
type = string
validation {
condition = length(trimspace(var.ssh_public_key_file)) > 0
error_message = "ssh_public_key_file must not be empty."
}
}
variable "dns_servers" {
description = "DNS servers passed to Cloud-init."
type = list(string)
validation {
condition = length(var.dns_servers) > 0
error_message = "dns_servers must contain at least one DNS server."
}
}
variable "default_gateway" {
description = "Default IPv4 gateway passed to Cloud-init."
type = string
validation {
condition = can(cidrhost("${var.default_gateway}/32", 0))
error_message = "default_gateway must be a valid IPv4 address."
}
}
variable "vm_tags" {
description = "Tags applied to all Proxmox VMs."
type = list(string)
default = ["terraform", "cloud-init"]
validation {
condition = alltrue([
for tag in var.vm_tags : length(trimspace(tag)) > 0
])
error_message = "vm_tags must not contain empty tags."
}
}
variable "virtual_machines" {
description = "Map of virtual machines. The map key is used as the VM name and Cloud-init hostname."
type = map(object({
vm_id = number
node_name = optional(string, "pve1")
template_vm_id = optional(number)
template_node_name = optional(string)
cpu_cores = optional(number, 2)
memory_mb = optional(number, 4096)
disk_size_gb = optional(number, 33)
ipv4_address = string
cloud_init_username = optional(string)
vlan_id = optional(number)
tags = optional(list(string), [])
}))
validation {
condition = alltrue([
for name, vm in var.virtual_machines :
can(regex("^[a-zA-Z0-9][a-zA-Z0-9-]{0,62}$", name))
])
error_message = "Each virtual_machines key must be a valid DNS hostname label."
}
validation {
condition = alltrue([
for _, vm in var.virtual_machines :
vm.vm_id > 0
])
error_message = "Each vm_id must be greater than 0."
}
validation {
condition = alltrue([
for _, vm in var.virtual_machines :
vm.template_vm_id == null || vm.template_vm_id > 0
])
error_message = "Each template_vm_id must be null or greater than 0."
}
validation {
condition = alltrue([
for _, vm in var.virtual_machines :
vm.template_node_name == null || length(trimspace(vm.template_node_name)) > 0
])
error_message = "Each template_node_name must be null or not empty."
}
validation {
condition = length(distinct([
for _, vm in var.virtual_machines : vm.vm_id
])) == length(var.virtual_machines)
error_message = "Each vm_id must be unique."
}
validation {
condition = alltrue([
for _, vm in var.virtual_machines :
lower(vm.ipv4_address) == "dhcp" || can(cidrhost(vm.ipv4_address, 0))
])
error_message = "Each ipv4_address must be \"dhcp\" or valid IPv4 CIDR notation, for example 192.168.1.101/24."
}
validation {
condition = alltrue([
for _, vm in var.virtual_machines :
vm.cloud_init_username == null || can(regex("^[a-z_][a-z0-9_-]*[$]?$", vm.cloud_init_username))
])
error_message = "Each cloud_init_username must be null or a valid Linux user name."
}
validation {
condition = alltrue([
for _, vm in var.virtual_machines :
vm.vlan_id == null || (vm.vlan_id >= 1 && vm.vlan_id <= 4094)
])
error_message = "Each vlan_id must be null or a number from 1 to 4094."
}
validation {
condition = alltrue(flatten([
for _, vm in var.virtual_machines : [
for tag in vm.tags : length(trimspace(tag)) > 0
]
]))
error_message = "Each VM tag must not be empty."
}
}
EOF
cat <<'EOF' > main.tf
resource "proxmox_virtual_environment_vm" "vm" {
for_each = var.virtual_machines
name = each.key
description = "Managed by Terraform"
tags = distinct(concat(var.vm_tags, each.value.tags))
node_name = each.value.node_name
vm_id = each.value.vm_id
clone {
vm_id = coalesce(each.value.template_vm_id, var.template_vm_id)
node_name = coalesce(each.value.template_node_name, var.template_node_name)
datastore_id = var.target_datastore
full = true
retries = 2
}
agent {
enabled = true
wait_for_ip {
ipv4 = true
}
}
cpu {
cores = each.value.cpu_cores
type = "x86-64-v2-AES"
}
memory {
dedicated = each.value.memory_mb
}
disk {
datastore_id = var.target_datastore
interface = "scsi0"
size = each.value.disk_size_gb
discard = "on"
iothread = true
}
initialization {
datastore_id = var.cloud_init_datastore
upgrade = false
dynamic "dns" {
for_each = lower(each.value.ipv4_address) == "dhcp" ? [] : [var.dns_servers]
content {
servers = dns.value
}
}
ip_config {
ipv4 {
address = each.value.ipv4_address
gateway = lower(each.value.ipv4_address) == "dhcp" ? null : var.default_gateway
}
}
user_account {
username = coalesce(each.value.cloud_init_username, var.cloud_init_username)
keys = [trimspace(file(pathexpand(var.ssh_public_key_file)))]
}
}
network_device {
bridge = var.network_bridge
model = "virtio"
vlan_id = each.value.vlan_id
}
operating_system {
type = "l26"
}
on_boot = true
scsi_hardware = "virtio-scsi-pci"
started = true
}
EOF
cat <<'EOF' > outputs.tf
output "virtual_machines" {
description = "Configured VM information."
value = {
for name, vm in proxmox_virtual_environment_vm.vm : name => {
name = vm.name
vm_id = vm.vm_id
node_name = vm.node_name
ipv4_address = var.virtual_machines[name].ipv4_address
tags = vm.tags
}
}
}
output "qemu_guest_agent_ipv4_addresses" {
description = "IPv4 addresses reported by QEMU Guest Agent. This can be empty until the guest agent starts and reports addresses."
value = {
for name, vm in proxmox_virtual_environment_vm.vm : name => flatten(vm.ipv4_addresses)
}
}
EOF
cat <<'EOF' > terraform.tfvars.example
proxmox_endpoint = "https://pve.example.local:8006/"
proxmox_insecure = true
template_vm_id = 9000
template_node_name = "pve"
target_datastore = "local-lvm"
cloud_init_datastore = "local-lvm"
network_bridge = "vmbr0"
cloud_init_username = "ubuntu"
ssh_public_key_file = "~/.ssh/id_ed25519.pub"
dns_servers = ["192.168.11.1", "8.8.8.8"]
default_gateway = "192.168.11.1"
vm_tags = ["terraform", "cloud-init"]
virtual_machines = {
master1 = {
vm_id = 1001
node_name = "pve"
template_vm_id = 9000
template_node_name = "pve"
cloud_init_username = "ubuntu"
cpu_cores = 2
memory_mb = 2048
disk_size_gb = 33
ipv4_address = "192.168.11.45/24"
vlan_id = null
tags = ["control-plane", "ubuntu"]
}
master2 = {
vm_id = 1002
node_name = "pve"
template_vm_id = 9010
template_node_name = "pve"
cloud_init_username = "almalinux"
cpu_cores = 2
memory_mb = 2048
disk_size_gb = 33
ipv4_address = "dhcp"
vlan_id = null
tags = ["control-plane", "almalinux"]
}
}
EOF
cp terraform.tfvars.example terraform.tfvars
作成後、terraform.tfvars の以下は自分の環境に合わせて変更します。
proxmox_endpointtemplate_vm_idtemplate_node_nametarget_datastorecloud_init_datastorenetwork_bridgedns_serversdefault_gatewayvirtual_machines
API トークンはファイルへ保存せず、後述の環境変数で渡します。
前提条件
この記事では、以下が準備済みである前提です。
- Terraform 1.5 以降
- Proxmox VE
- Cloud-init 対応の VM テンプレート
- Terraform 実行端末の SSH 公開鍵
- Proxmox の API トークン
VMテンプレートについてはこの後提示する手順で作成できます。
Provider は bpg/proxmox を使います。
terraform {
required_version = ">= 1.5.0"
required_providers {
proxmox = {
source = "bpg/proxmox"
version = "0.111.1"
}
}
}
macOS で Terraform を入れる場合は Homebrew が簡単です。
brew tap hashicorp/tap
brew install hashicorp/tap/terraform
terraform --version
Cloud-initテンプレートの準備
Proxmox 側には、あらかじめ Cloud-init 対応の VM テンプレートを作っておきます。
テンプレート VM では、少なくとも以下を確認します。
-
cloud-initがインストールされている -
qemu-guest-agentがインストールされている -
qemu-guest-agentが有効化されている - Cloud-init 用ドライブが設定されている
- OS ディスクが
scsi0として構成されている
ゲスト OS 側では以下のように確認できます。
cloud-init --version
systemctl is-enabled qemu-guest-agent
systemctl status qemu-guest-agent
cloud-init status --long
ip address
テンプレート化する前には、Cloud-init の状態を初期化しておきます。
sudo cloud-init clean --logs
sudo truncate -s 0 /etc/machine-id
sudo rm -f /var/lib/dbus/machine-id
sudo shutdown -h now
その後、Proxmox UI または CLI で VM をテンプレート化します。
Ubuntu 24.04のCloud-initテンプレート作成例
Ubuntu 24.04 Server の cloud image を使う場合は、Proxmox ホスト上でテンプレート VM を作成しておくと、Terraform から clone しやすくなります。
以下は、VM ID 9000 の Ubuntu 24.04 テンプレートを作る例です。
環境に合わせて、以下は読み替えてください。
| 項目 | 例 |
|---|---|
| テンプレート VM ID | 9000 |
| テンプレート名 | ubuntu-2404-cloudinit |
| Proxmox ノード | pve |
| ディスク保存先 | local-lvm |
| Cloud-init ディスク保存先 | local-lvm |
| ネットワークブリッジ | vmbr0 |
| Proxmox タグ | template |
まず、Ubuntu 24.04 の cloud image を取得します。
cd /var/lib/vz/template/iso
wget https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img
次に、Proxmox の VM を作成し、ダウンロードした cloud image を OS ディスクとして取り込みます。
qm create 9000 \
--name ubuntu-2404-cloudinit \
--memory 2048 \
--cores 2 \
--net0 virtio,bridge=vmbr0 \
--tags template \
--scsihw virtio-scsi-single
qm importdisk 9000 /var/lib/vz/template/iso/noble-server-cloudimg-amd64.img local-lvm
qm set 9000 --scsi0 local-lvm:vm-9000-disk-0
qm resize 9000 scsi0 20G
qm set 9000 --ide2 local-lvm:cloudinit
qm set 9000 --boot order=scsi0
qm set 9000 --serial0 socket --vga serial0
qm set 9000 --agent enabled=1
Cloud-init でログインできるように、初回起動用のユーザー、SSH 公開鍵、IP アドレス設定を入れます。
テンプレート確認用の固定 IP アドレスを指定しておくと、初回 SSH ログインまで迷いにくくなります。
この IP アドレスは、clone 後に Terraform 側の initialization で上書きします。
qm set 9000 --ciuser ubuntu
qm set 9000 --sshkeys ~/.ssh/id_rsa.pub
qm set 9000 --ipconfig0 ip=192.168.11.250/24,gw=192.168.11.1
VM を一度起動して、SSH ログインできることを確認します。
qm start 9000
ssh ubuntu@192.168.11.250
ゲスト OS に入ったら、qemu-guest-agent を導入して有効化します。
sudo apt update
sudo apt install -y qemu-guest-agent
sudo systemctl enable --now qemu-guest-agent
systemctl is-enabled qemu-guest-agent
cloud image の初期状態では swap が 0 の場合があります。
テンプレート側で swapfile を作成しておくと、clone 後の VM でも同じ設定を引き継げます。
8GB の swapfile を作る場合は、事前に root filesystem に 8GB 以上の空きがあることを確認します。
free -h
df -h /
sudo fallocate -l 8G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
free -h
時刻が UTC のままになっている場合は、JST に変更します。
date
timedatectl
sudo timedatectl set-timezone Asia/Tokyo
timedatectl
date
qemu-guest-agent を有効化した後であれば、Proxmox ホスト側からもネットワーク情報を確認できます。
qm agent 9000 network-get-interfaces
確認できたら、テンプレート化する前に Cloud-init の状態を初期化して停止します。
sudo cloud-init clean --logs
sudo truncate -s 0 /etc/machine-id
sudo rm -f /var/lib/dbus/machine-id
sudo shutdown -h now
Proxmox ホスト側で停止を確認し、テンプレート化します。
qm status 9000
qm set 9000 --tags template
qm template 9000
このテンプレートを使う場合、Terraform 側の terraform.tfvars では以下のように指定します。
template_vm_id = 9000
template_node_name = "pve"
target_datastore = "local-lvm"
cloud_init_datastore = "local-lvm"
network_bridge = "vmbr0"
cloud_init_username = "ubuntu"
VM clone 後の固定 IP アドレスや SSH 公開鍵は Terraform 側の initialization で上書きするため、テンプレート作成時の 192.168.11.250 は初回確認用と考えます。
AlmaLinux 9のCloud-initテンプレート作成例
AlmaLinux 9 を使う場合も、GenericCloud イメージから Cloud-init 対応テンプレートを作成できます。
以下は、VM ID 9010 の AlmaLinux 9 テンプレートを作る例です。
環境に合わせて、以下は読み替えてください。
| 項目 | 例 |
|---|---|
| テンプレート VM ID | 9010 |
| テンプレート名 | almalinux-9-cloudinit |
| Proxmox ノード | pve |
| ディスク保存先 | local-lvm |
| Cloud-init ディスク保存先 | local-lvm |
| ネットワークブリッジ | vmbr0 |
| CPU type | host |
| Proxmox タグ | template |
まず、AlmaLinux 9 の GenericCloud イメージとチェックサムを取得します。
cd /var/lib/vz/template/iso
wget https://repo.almalinux.org/almalinux/9/cloud/x86_64/images/AlmaLinux-9-GenericCloud-latest.x86_64.qcow2
wget https://repo.almalinux.org/almalinux/9/cloud/x86_64/images/CHECKSUM
sha256sum -c CHECKSUM 2>&1 | grep AlmaLinux-9-GenericCloud-latest.x86_64.qcow2
次に、Proxmox の VM を作成し、ダウンロードした qcow2 イメージを OS ディスクとして取り込みます。
qm create 9010 \
--name almalinux-9-cloudinit \
--memory 2048 \
--cores 2 \
--cpu host \
--net0 virtio,bridge=vmbr0 \
--tags template \
--scsihw virtio-scsi-single
qm importdisk 9010 /var/lib/vz/template/iso/AlmaLinux-9-GenericCloud-latest.x86_64.qcow2 local-lvm
qm set 9010 --scsi0 local-lvm:vm-9010-disk-0
qm resize 9010 scsi0 20G
qm set 9010 --ide2 local-lvm:cloudinit
qm set 9010 --boot order=scsi0
qm set 9010 --serial0 socket --vga serial0
qm set 9010 --agent enabled=1
Cloud-init でログインできるように、初回起動用のユーザー、SSH 公開鍵、IP アドレス設定を入れます。
AlmaLinux の cloud image では almalinux ユーザーを使うと分かりやすいです。
qm set 9010 --ciuser almalinux
qm set 9010 --sshkeys ~/.ssh/id_rsa.pub
qm set 9010 --ipconfig0 ip=192.168.11.251/24,gw=192.168.11.1
VM を一度起動して、SSH ログインできることを確認します。
qm start 9010
ssh almalinux@192.168.11.251
ゲスト OS に入ったら、qemu-guest-agent を導入して有効化します。
sudo dnf update -y
sudo dnf install -y qemu-guest-agent
sudo systemctl enable --now qemu-guest-agent
systemctl is-enabled qemu-guest-agent
cloud image の初期状態では swap が 0 の場合があります。
テンプレート側で swapfile を作成しておくと、clone 後の VM でも同じ設定を引き継げます。
8GB の swapfile を作る場合は、事前に root filesystem に 8GB 以上の空きがあることを確認します。
free -h
df -h /
sudo fallocate -l 8G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
free -h
時刻が UTC のままになっている場合は、JST に変更します。
date
timedatectl
sudo timedatectl set-timezone Asia/Tokyo
timedatectl
date
qemu-guest-agent を有効化した後であれば、Proxmox ホスト側からもネットワーク情報を確認できます。
qm agent 9010 network-get-interfaces
AlmaLinux 9 では NetworkManager がネットワーク設定を管理します。
Cloud-init から IP アドレスを反映できるよう、cloud-init と NetworkManager が入っていることも確認します。
cloud-init --version
systemctl is-enabled NetworkManager
nmcli device status
確認できたら、テンプレート化する前に Cloud-init の状態を初期化して停止します。
sudo cloud-init clean --logs
sudo truncate -s 0 /etc/machine-id
sudo rm -f /var/lib/dbus/machine-id
sudo shutdown -h now
Proxmox ホスト側で停止を確認し、テンプレート化します。
qm status 9010
qm set 9010 --tags template
qm template 9010
このテンプレートを使う場合、Terraform 側の terraform.tfvars では以下のように指定します。
template_vm_id = 9010
template_node_name = "pve"
target_datastore = "local-lvm"
cloud_init_datastore = "local-lvm"
network_bridge = "vmbr0"
cloud_init_username = "almalinux"
Terraform 側の virtual_machines で固定 IP を指定すれば、clone 後は AlmaLinux 9 側にも Cloud-init 経由でネットワーク設定が反映されます。
Proxmox Providerの設定
Provider の接続設定はシンプルです。
provider "proxmox" {
endpoint = var.proxmox_endpoint
api_token = var.proxmox_api_token
insecure = var.proxmox_insecure
}
API トークンは Terraform ファイルや terraform.tfvars には書きません。
環境変数で渡します。
export TF_VAR_proxmox_api_token='terraform@pve!terraform=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
APIユーザーと権限
Proxmox ホストで Terraform 用ユーザーと API トークンを作成します。
pveum user add terraform@pve
pveum user token add terraform@pve terraform --privsep=0
検証環境で素早く試すだけなら Administrator ロールでも動きますが、運用では専用ロールを作る方が安全です。
今回の構成では、少なくとも以下のような権限が必要でした。
| 対象パス | 主な目的 | 権限例 |
|---|---|---|
/vms/<template_vm_id> または /vms
|
テンプレート参照、VM クローン |
VM.Audit, VM.Clone
|
/vms/<作成VM ID> または /vms
|
VM 作成、設定変更、起動、削除 |
VM.Allocate, VM.Config.CPU, VM.Config.Memory, VM.Config.Disk, VM.Config.Network, VM.Config.HWType, VM.Config.Cloudinit, VM.Config.CDROM, VM.Config.Options, VM.PowerMgmt, VM.Audit
|
/storage/<target_datastore> |
クローン先ディスク作成 |
Datastore.Audit, Datastore.AllocateSpace
|
/storage/<cloud_init_datastore> |
Cloud-init ディスク作成 |
Datastore.Audit, Datastore.AllocateSpace
|
/sdn/zones/<zone>/<bridge> または /
|
SDN/ブリッジへの NIC 接続 | SDN.Use |
/nodes/<node> または /
|
ノード情報参照 | Sys.Audit |
ロール作成例です。
pveum role add TerraformVmProvisioner -privs "VM.Audit VM.Clone VM.Allocate VM.Config.CPU VM.Config.Memory VM.Config.Disk VM.Config.Network VM.Config.HWType VM.Config.Cloudinit VM.Config.CDROM VM.Config.Options VM.PowerMgmt Datastore.Audit Datastore.AllocateSpace SDN.Use Sys.Audit"
pveum aclmod / -user terraform@pve -role TerraformVmProvisioner
既存ロールへ追加する場合は role modify を使います。
pveum role modify TerraformVmProvisioner -privs "VM.Audit VM.Clone VM.Allocate VM.Config.CPU VM.Config.Memory VM.Config.Disk VM.Config.Network VM.Config.HWType VM.Config.Cloudinit VM.Config.CDROM VM.Config.Options VM.PowerMgmt Datastore.Audit Datastore.AllocateSpace SDN.Use Sys.Audit"
VMリソース定義
VM は for_each で複数台作成します。
resource "proxmox_virtual_environment_vm" "vm" {
for_each = var.virtual_machines
name = each.key
description = "Managed by Terraform"
tags = distinct(concat(var.vm_tags, each.value.tags))
node_name = each.value.node_name
vm_id = each.value.vm_id
clone {
vm_id = coalesce(each.value.template_vm_id, var.template_vm_id)
node_name = coalesce(each.value.template_node_name, var.template_node_name)
datastore_id = var.target_datastore
full = true
retries = 2
}
agent {
enabled = true
wait_for_ip {
ipv4 = true
}
}
cpu {
cores = each.value.cpu_cores
type = "x86-64-v2-AES"
}
memory {
dedicated = each.value.memory_mb
}
disk {
datastore_id = var.target_datastore
interface = "scsi0"
size = each.value.disk_size_gb
discard = "on"
iothread = true
}
initialization {
datastore_id = var.cloud_init_datastore
upgrade = false
dns {
servers = var.dns_servers
}
ip_config {
ipv4 {
address = each.value.ipv4_address
gateway = var.default_gateway
}
}
user_account {
username = coalesce(each.value.cloud_init_username, var.cloud_init_username)
keys = [trimspace(file(pathexpand(var.ssh_public_key_file)))]
}
}
network_device {
bridge = var.network_bridge
model = "virtio"
vlan_id = each.value.vlan_id
}
operating_system {
type = "l26"
}
on_boot = true
scsi_hardware = "virtio-scsi-single"
started = true
}
ポイントは tags です。
tags = distinct(concat(var.vm_tags, each.value.tags))
これで、全 VM 共通のタグと VM ごとのタグをまとめて付与できます。重複したタグは distinct() で除去します。
テンプレートは、VM ごとの指定を優先し、未指定の場合は共通変数を使います。
vm_id = coalesce(each.value.template_vm_id, var.template_vm_id)
node_name = coalesce(each.value.template_node_name, var.template_node_name)
これにより、同じ Terraform 定義の中で Ubuntu 24.04 テンプレートと AlmaLinux 9 テンプレートを混在できます。
Cloud-init のユーザー名も OS に合わせて変えられるよう、cloud_init_username も VM ごとに上書きできるようにしています。
変数定義
VM 一覧は map(object(...)) にしています。
variable "vm_tags" {
description = "Tags applied to all Proxmox VMs."
type = list(string)
default = ["terraform", "cloud-init"]
validation {
condition = alltrue([
for tag in var.vm_tags : length(trimspace(tag)) > 0
])
error_message = "vm_tags must not contain empty tags."
}
}
variable "virtual_machines" {
description = "Map of virtual machines. The map key is used as the VM name and Cloud-init hostname."
type = map(object({
vm_id = number
node_name = optional(string, "pve1")
template_vm_id = optional(number)
template_node_name = optional(string)
cloud_init_username = optional(string)
cpu_cores = optional(number, 2)
memory_mb = optional(number, 4096)
disk_size_gb = optional(number, 33)
ipv4_address = string
vlan_id = optional(number)
tags = optional(list(string), [])
}))
}
node_name や cpu_cores などは optional() にしているため、よく使う値は省略できます。
template_vm_id、template_node_name、cloud_init_username を VM ごとに指定すると、Ubuntu 24.04 と AlmaLinux 9 のように異なるテンプレートを混在できます。
未指定の場合は、共通変数の template_vm_id、template_node_name、cloud_init_username を使います。
terraform.tfvarsの例
実際の値は terraform.tfvars に書きます。
proxmox_endpoint = "https://pve.example.local:8006/"
proxmox_insecure = true
template_vm_id = 9000
template_node_name = "pve"
target_datastore = "local-lvm"
cloud_init_datastore = "local-lvm"
network_bridge = "vmbr0"
cloud_init_username = "ubuntu"
ssh_public_key_file = "~/.ssh/id_ed25519.pub"
dns_servers = ["192.168.11.1", "8.8.8.8"]
default_gateway = "192.168.11.1"
vm_tags = ["terraform", "cloud-init"]
virtual_machines = {
master1 = {
vm_id = 1001
node_name = "pve"
template_vm_id = 9000
template_node_name = "pve"
cloud_init_username = "ubuntu"
cpu_cores = 2
memory_mb = 2048
disk_size_gb = 33
ipv4_address = "192.168.11.45/24"
vlan_id = null
tags = ["control-plane", "ubuntu"]
}
master2 = {
vm_id = 1002
node_name = "pve"
template_vm_id = 9010
template_node_name = "pve"
cloud_init_username = "almalinux"
cpu_cores = 2
memory_mb = 2048
disk_size_gb = 33
ipv4_address = "dhcp"
vlan_id = null
tags = ["control-plane", "almalinux"]
}
}
実環境に合わせて変更する値
| 値 | 変数または場所 | 仮値 | 変更内容 |
|---|---|---|---|
| Proxmox API URL | proxmox_endpoint |
https://pve1.example.local:8006/ |
実際の Proxmox API URL に変更します。/api2/json は付けません。 |
| Proxmoxノード名 | virtual_machines[*].node_name |
pve1 |
VM を配置するノード名に変更します。 |
| 共通テンプレートVM ID | template_vm_id |
9000 |
Cloud-init 対応テンプレートの VM ID に変更します。VM ごとに virtual_machines[*].template_vm_id で上書きできます。 |
| 共通テンプレート配置ノード | template_node_name |
pve1 |
テンプレートが存在するノード名に変更します。VM ごとに virtual_machines[*].template_node_name で上書きできます。 |
| 保存先ストレージ | target_datastore |
local-lvm |
VM ディスクを置くストレージ名に変更します。 |
| Cloud-init保存先ストレージ | cloud_init_datastore |
local-lvm |
Cloud-init ディスクを置くストレージ名に変更します。 |
| ネットワークブリッジ | network_bridge |
vmbr0 |
VM が接続する Proxmox ブリッジ名に変更します。 |
| VM ID | virtual_machines[*].vm_id |
101, 102, 103
|
Proxmox 上で未使用の VM ID に変更します。 |
| VM名 |
virtual_machines の map キー |
worker1 など |
VM 名および Cloud-init のホスト名として使われます。 |
| VM別テンプレートVM ID | virtual_machines[*].template_vm_id |
9002 |
VM ごとに別テンプレートを使う場合だけ指定します。省略時は template_vm_id が使われます。 |
| VM別テンプレート配置ノード | virtual_machines[*].template_node_name |
pve1 |
VM ごとに別テンプレートの配置ノードを指定する場合だけ指定します。省略時は template_node_name が使われます。 |
| IPアドレス | virtual_machines[*].ipv4_address |
192.168.1.101/24 または dhcp
|
固定IPは CIDR 表記、DHCP は dhcp を指定します。 |
| VM別Cloud-initユーザー | virtual_machines[*].cloud_init_username |
admin |
VM ごとに別ユーザー名を使う場合だけ指定します。省略時は cloud_init_username が使われます。 |
| デフォルトゲートウェイ | default_gateway |
192.168.1.1 |
固定IPのVMに渡すゲートウェイです。ipv4_address = "dhcp" のVMではDHCPから取得します。 |
| DNSサーバー | dns_servers |
192.168.1.1, 1.1.1.1
|
固定IPのVMに渡す DNS サーバーです。ipv4_address = "dhcp" のVMではDHCPから取得します。 |
| 共通タグ | vm_tags |
terraform, cloud-init
|
すべての VM に付与する Proxmox タグを指定します。 |
| VM別タグ | virtual_machines[*].tags |
control-plane など |
VM ごとに追加する Proxmox タグを指定します。 |
| CPU | virtual_machines[*].cpu_cores |
2, 4
|
VM ごとの CPU コア数へ変更します。 |
| メモリ | virtual_machines[*].memory_mb |
4096, 8192
|
VM ごとのメモリ容量 MB へ変更します。 |
| ディスク容量 | virtual_machines[*].disk_size_gb |
33, 64
|
テンプレート以上のサイズを指定します。テンプレートが 32.5G のように表示される場合は、33 以上にします。 |
| VLAN ID | virtual_machines[*].vlan_id |
null |
VLAN を使う場合は 10 のような数値、使わない場合は null にします。 |
| Cloud-initユーザー | cloud_init_username |
ubuntu |
ゲスト OS で使うログインユーザー名に変更します。 |
| SSH公開鍵ファイル | ssh_public_key_file |
~/.ssh/id_ed25519.pub |
Terraform 実行端末の公開鍵ファイルパスに変更します。 |
API トークンだけは terraform.tfvars に書かず、環境変数で渡します。
実行手順
まず初期化します。
terraform init
フォーマットを確認します。
terraform fmt -check
validate します。
terraform validate
plan を確認します。
terraform plan
問題なければ apply します。
terraform apply
複数 VM の同時 clone でタイムアウトする場合は、並列数を下げます。
terraform apply -parallelism=1
作成後の確認
Proxmox UI で VM ID、名前、ノード、ディスク、Cloud-init、ネットワーク設定、タグを確認します。
CLI なら以下のように確認できます。
qm status 1001
qm config 1001
qm guest cmd 1001 network-get-interfaces
SSH 接続できれば、Cloud-init によるユーザーと公開鍵の設定も確認できます。
ssh ubuntu@192.168.11.45
ハマったところ
Permission check failed: SDN.Use
以下のようなエラーが出ることがあります。
Permission check failed (/sdn/zones/localnetwork/vmbr0, SDN.Use)
Proxmox が vmbr0 を SDN 配下のリソースとして扱っている場合、NIC を接続するために SDN.Use が必要です。
対象パスに権限を付けます。
pveum aclmod /sdn/zones/localnetwork/vmbr0 -user terraform@pve -role TerraformVmProvisioner
Permission check failed: VM.Config.CDROM
Cloud-init 用の CD-ROM/drive 設定で、以下の権限が必要になることがあります。
Permission check failed (/vms/1000, VM.Config.CDROM)
この場合はロールに VM.Config.CDROM を追加します。
300.conf failed: File exists
clone 途中で失敗した後などに、VM ID の設定ファイルが残ることがあります。
close (rename) atomic file '/etc/pve/nodes/pvesingle/qemu-server/300.conf' failed: File exists
まず VM ID を確認します。
qm list | grep 300
不要な VM または失敗した clone の残骸であれば削除します。
qm stop 300
qm destroy 300 --purge 1
削除したくない VM であれば、virtual_machines[*].vm_id を未使用の ID に変更します。
disk resize failure
以下のようなエラーが出ることがあります。
disk resize failure: requested size (32G) is lower than current size (32.5G)
Proxmox は clone 後のディスク縮小を許可しません。
テンプレートのディスクが 32.5G と認識されている場合、Terraform 側では 33 以上を指定します。
disk_size_gb = 33
セキュリティ面の注意
API トークンの扱いは特に注意します。
- API トークンを Git に commit しない
-
terraform.tfvarsに API トークンを書かない - 本番環境では
proxmox_insecure = falseを推奨 - Terraform state のアクセス権限に注意する
- API トークンには必要最小限の権限だけを付ける
Terraform state には VM の構成情報が含まれます。チーム利用や CI/CD では、暗号化とアクセス制御のある remote backend を検討してください。
まとめ
Terraform と bpg/proxmox Provider を使うと、Proxmox VE 上の Cloud-init 対応 VM をかなり扱いやすくできます。
特に便利だった点は以下です。
- VM 定義を
terraform.tfvarsに集約できる -
for_eachで複数 VM をまとめて作成できる - Cloud-init で IP アドレスと SSH 公開鍵を設定できる
- 共通タグと VM 別タグを Terraform から管理できる
- Proxmox UI での手作業 clone を減らせる
一方で、Proxmox の権限は環境やバージョンによって少しハマりやすいです。SDN.Use や VM.Config.CDROM など、エラーに出てくる権限を task log と照らし合わせながらロールへ追加していくと切り分けやすくなります。
自宅ラボや検証クラスタの VM 作成を何度も繰り返すなら、Terraform 化しておく価値はかなりあります。