0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

TerraformでProxmox VEにCloud-init対応VMを複数台作成する

0
Last updated at Posted at 2026-09-02

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_endpoint
  • template_vm_id
  • template_node_name
  • target_datastore
  • cloud_init_datastore
  • network_bridge
  • dns_servers
  • default_gateway
  • virtual_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_namecpu_cores などは optional() にしているため、よく使う値は省略できます。
template_vm_idtemplate_node_namecloud_init_username を VM ごとに指定すると、Ubuntu 24.04 と AlmaLinux 9 のように異なるテンプレートを混在できます。
未指定の場合は、共通変数の template_vm_idtemplate_node_namecloud_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.UseVM.Config.CDROM など、エラーに出てくる権限を task log と照らし合わせながらロールへ追加していくと切り分けやすくなります。

自宅ラボや検証クラスタの VM 作成を何度も繰り返すなら、Terraform 化しておく価値はかなりあります。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?