🛠

Terraformで破壊可能なProxmox VMを管理する

2024/12/16に公開

この記事はUEC Advent Calendar 2024の16日目の記事です。
昨日はへるくんの「SMFのバイナリを読んでみる」でした。今年のアドカレで結構バイナリを読む記事を見るのですが、流行っているのですかね…?(困惑)

UEC 2 Advent Calendar 2024の方ではかいとからあげさんの「コンビニのフライドチキンおいしいランキング」でした。私は期間限定という言葉に弱いタイプなので、ローソンのやつは是非食べてみたいですね。

はじめに

逸般の誤家庭を運用されている皆さんのような方の多くは、Proxmoxのような仮想化ハイパーバイザを利用しているかと思います。VM上でサービスを運用するメリットとして、簡単に目的の環境だけを破壊したり構築したりできることが挙げられます。私も、Proxmoxを使い始めた時、この点に魅力を感じていました。
しかし、環境が複雑になってきた時にクラスターを間違えて破壊してしまうなどすると、頑張って構築した環境が全破壊されてしまい、再構築が非常にめんどくさいです。これが1OSでまとまっていてくれればインストールスクリプトを書いておけばいいのですが、複数のVMを管理している場合、それも難しいです。
そこで、今回は自宅鯖をIaCで管理する試みを紹介します。

この記事で紹介すること

  • TerraformでVMを作成する
  • Terragruntでstateを管理する
  • CloudinitでVMを初期設定する
  • Cloudinitでインストールスクリプトを使う

この記事で紹介しないこと

  • Terraformの基本
  • ProxmoxのLXCの管理
  • Ansible

Terraform

VM管理自動化の第1ステップとして、terraformを使用してVMを作成します。terraformには非公式でproxmox用のproviderが提供されています。

これを使うとterraformのresourceとしてVMを作成できます。

Terragrunt

今回はterraformのコード管理にterragruntを使用します。特に今回受けたいterragruntの恩恵は次の通りです。

  • S3のstate管理
  • variableの管理
  • environmentsの並列管理

特に環境について、terraformで管理する1サービスあたり、1環境という方針で管理します。そういった場合に、terragruntのrun-all機能で全環境を一括で管理できる点が重宝します。

terragruntの設定

以上を踏まえた上で、次のようなディレクトリ構成にします。

.
├── environments
├── modules
└── terragrunt
    ├── config
    │   ├── main.tf.tftpl
    │   ├── provider.tf.tftpl
    │   ├── terraform.tfvars.tftpl
    │   └── variables.tf.tftpl
    └── terragrunt.hcl

terragrunt.hclの内容は次の通りです。

terragrunt/terragrunt.hcl
locals {
  region = "ap-northeast-1"
  env = basename(path_relative_to_include())
}

generate "provider" {
  path = "provider.tf"
  if_exists = "overwrite"
  contents = file("./config/provider.tf.tftpl")
}

remote_state {
  backend = "s3"
  generate = {
    path = "backend.tf"
    if_exists = "overwrite"
  }

  config = {
    bucket = "【S3のbucket名 (作成する必要なし)】"
    key = "${local.env}/terraform.tfstate"
    region = "${local.region}"
    encrypt = true
  }
}

generate "variables" {
  path = "variables.tf"
  if_exists = "overwrite"
  contents = "${file("./config/variables.tf.tftpl")}\n${file("./config/${local.env}/variables.tf.tftpl")}"
}

generate "tfvars" {
  path = "terraform.tfvars"
  if_exists = "overwrite"
  contents = "${file("./config/terraform.tfvars.tftpl")}\n${file("./config/${local.env}/terraform.tfvars.tftpl")}"
}

また、configディレクトリの配下には、次のような内容を持つファイルを配置します。

  • main.tf.tftpl
    • 共通で使いたいterraformのコード
  • provider.tf.tftpl
    • 共通で使いたいproviderの設定
  • terraform.tfvars.tftpl
    • 共通で使いたいterraformの変数の内容
  • variables.tf.tftpl
    • 共通で使いたいterraformの変数の定義

ここに新しい環境を生やしたい場合は、次のようにファイルを追加します。(tailscaleという環境を追加する場合)

environments
└── tailscale
    ├── cloudinit.tf
    ├── locals.tf
    ├── tailscale.sh.tftpl
    └── terragrunt.hcl

環境内のterragrunt.hclの内容は次の通りです。

environments/tailscale/terragrunt.hcl
include {
  path = "../../terragrunt/terragrunt.hcl"
}

加えて、terragrunt/configディレクトリには、tailscaleディレクトリを追加し、次のようなファイルを配置します。

terragrunt/config
└── tailscale
    ├── terraform.tfvars.tftpl
    └── variables.tf.tftpl

それぞれ、各環境で使いたいterraformの変数の内容と定義を記述します。

terragruntの実行

以上の設定を済ました上で、environmentsディレクトリに移動し、次のコマンドを実行します。

terragrunt run-all init

すると、terraform環境の初期化が始まります。具体的には次のような処理が走ります。

  • S3バケット作成 (無ければ)
  • terragrunt.hclで指定したファイルを環境ディレクトリ内に生成
  • 通常のterraformの初期化処理

Cloudinit

ここからresourceを作成していけばVMの作成ができますが、VMを作成して満足する人は少ないと思います。通常はここからサービスのインストールや実行に必要な初期設定を行います。ここからはTerraformを使用して、Cloudinitによる初期設定までを自動化していきます。(本当はAnsibleも使いたいところですが、難しくて諦めました。)

Cloudinitを普通の方法で使うやり方は紹介しないので各自調べてみてください。

今回はalmalinuxのcloudimageからテンプレートVMを作成した状態を前提とします。

proxmox providerでのcloudinitの設定

proxmox providerは標準でcloudinitをサポートしています。cloudinitでOSの初期化設定を記述するための、cloud-configファイルを適用するには、cicustomにファイルを指定することで可能です。

ただしcloud-configファイルは事前にproxmoxのホストに配置しておく必要があるため、次のようなresourceを作成しておきましょう。

resource "terraform_data" "cloud_init_config_files" {
  connection {
    type     = "ssh"
    user     = var.pm_ssh_user
    password = var.pm_ssh_pass
    host     = var.pm_host
  }

  provisioner "file" {
    source      = local_file.cloudconfig_file.filename
    destination = "/var/lib/vz/snippets/${var.cloud_init_user_data}"
  }
}

ちなみに私は次のようなcloud-configファイルを使っています。

cloud-config.yaml.tftpl
#cloud-config

package_update: true
package_upgrade: true

hostname: ${hostname}
manage_etc_hosts: true
fqdn: ${hostname}.${domain}

user: ${user}
password: ${password}
ssh_authorized_keys:
${ssh_keys}

packages:
${packages}

chpasswd:
  expire: False

timezone: Asia/Tokyo
locale: ja_JP.UTF-8
keyboard:
  layout: jp

cloud-configとインストールスクリプトの共存

通常の方法では、cloudinitの設定ファイルとしてcloud-configかインストールスクリプトの片方しか利用できません。しかし、mime/multipartを使うことで、複数の設定ファイルを適用することができます。
external providerでシェル芸をやることでmime/multipartファイルの作成を自動化します。

data "external" "mime_multipart" {
  program = [
    "bash",
    "-c",
    "echo {} | jq '. |= .+{\"content\": \"'`write-mime-multipart ${local_file.cloud_init_user_data.filename}:text/cloud-config ${var.script}:text/x-shellscript | base64 -w0`'\"}'"
  ]
}

resource "local_file" "multipart_file" {
  content  = base64decode(data.external.mime_multipart.result["content"])
  filename = "${path.root}/files/mime_multipart"
}

module化

ここまでProxmox VMにcloudinitを適用するまでの手順を自動化してきましたが、設定項目が非常に多かったかと思います。これをmoduleにまとめておくことで、各環境で設定を再利用できるようになります。

以下のようにmoduleを作成しました。

modules/cloudinit/main.tf
modules/cloudinit/main.tf
terraform {
  required_providers {
    external = {
      source  = "hashicorp/external"
      version = "~> 2"
    }
    http = {
      source  = "hashicorp/http"
      version = "~> 3"
    }
  }
}

data "http" "ssh_keys" {
  url = "https://github.com/${var.github_user}.keys"
}

resource "local_file" "cloud_init_user_data" {
  content = templatefile("${coalesce(var.cloud_init_user_path, path.module)}/${var.cloud_init_user_data}.tftpl", {
    hostname    = var.hostname
    domain      = var.domain
    user        = var.user
    password    = data.external.mkpasswd.result["content"]
    ssh_keys = replace(data.http.ssh_keys.response_body, "/(.+)/", "      - \"$1\"")
    packages    = terraform_data.packages.input
  })
  filename = "${path.root}/files/${var.cloud_init_user_data}"
}

data "external" "mime_multipart" {
  program = [
    "bash",
    "-c",
    "echo {} | jq '. |= .+{\"content\": \"'`write-mime-multipart ${local_file.cloud_init_user_data.filename}:text/cloud-config ${try("${var.script}:text/x-shellscript", "")} | base64 -w0`'\"}'"
  ]
}

data "external" "mkpasswd" {
  program = [ 
    "bash",
    "-c",
  "echo {} | jq '. |= .+{\"content\": \"'`mkpasswd  --method=SHA-512 --rounds=50000 ${var.pass}`'\"}'"
  ]
}

resource "terraform_data" "packages" {
  input = replace(join("\n", var.packages), "/(.+)/", "  - \"$1\"")
}

resource "local_file" "multipart_file" {
  content  = base64decode(data.external.mime_multipart.result["content"])
  filename = "${path.root}/files/mime_multipart"
}

resource "terraform_data" "cloud_init_config_files" {
  connection {
    type     = "ssh"
    user     = var.pm_ssh_user
    password = var.pm_ssh_pass
    host     = var.pm_host
  }

  provisioner "file" {
    source      = local_file.multipart_file.filename
    destination = "/var/lib/vz/snippets/${var.cloud_init_user_data}"
  }
}

次のように使用します。

environments/tailscale/cloudinit.tf
resource "local_file" "install_script" {
  content = templatefile("${path.root}/tailscale.sh.tftpl", {
    tailscale_key = var.tailscale_key
    subnet_routes = local.subnet_routes
  })
  
  filename = "${path.root}/files/tailscale.sh"
}

module "cloudinit" {
  depends_on = [local_file.install_script]

  source = "../../modules/cloudinit"

  clone = "template-almalinux"

  gateway = "172.24.0.1"
  ip      = "172.25.0.5/14"

  hostname = local.hostname
  vmid     = 105

  vm_name     = local.hostname
  target_node = "americano"
  
  pm_host = "172.24.8.3"
  
  onboot = true

  script = local_file.install_script.filename
}

みなさんの環境に合わせてカスタマイズしてみてください。

おわりに

terraformは設定項目が多くDRYしにくいので、積極的にterragruntやmoduleを活用していきたいですね。
明日はいずりなさんの「パスワード管理の面倒くささをどうにかしましょう」です。楽しみですね。

Discussion