terraform-provider-esxiを使ってVMとネットワークを作ってみた

# はじめに

無償で使えるVMware ESXiの上にTerraformをつかってVMを複数作り、ネットワークをつなげるというのをやってみたところ、思ってたよりも手こずったので、備忘録として残しておきます。

:::note warn
2023/09/22注記
VMware ESXiのネットワークアダプタの割り当てですが、複数並べたときにネットワークアダプタ1がゲストOSで一番若い番号のNICになるとは限らないことがわかりました。

P2Vの関係なのか、PCIスロットを詰めないように配置するアルゴリズムのようです。ゲストOSではPCIアドレスの若い順にNICが割り当てられるようで、これは不一致になるはずですわ。

よって下記の後半に書かれているIPアドレス割当は、残念ながら想定していたとおりに動作しません。どうやってゲストOSとESXi上のネットワークアダプタとの並びを一致させられるか、今後調査して報告できればと思います。
:::

# 今回やったこと

- VMware ESXi上にベースのVMを用意
- Terraformを使い、ベースVMをcloneして複数VMを作成
- Terraformでportgroup, vswitchを作成し、VM間を接続
- cloud-initを使い固定IPアドレスをTerraform経由で設定

# Git repo

例示したコードをまとめたリポジトリを作りました。
https://github.com/iMasaruOki/terraform-provider-esxi-demo

Apache-2.0 Licenseにしてみました。ご自由にどうぞ。

# 今回の実行環境

これでやってみたというだけなので、実際に試される場合に合わせる必要はありません。

- Terraform実行環境: Ubuntu 18.04LTS
- VMware ESXi環境
  - Xeon Silver 4110 CPU @ 2.10GHzマシン (メモリ96GB, SSD240GB)
  - VMware ESXi 7.0U3

環境の都合でUbuntu 18.04LTSで試しましたが、20.04LTSでも22.04LTSでも同じだと思います。

# ベースVMイメージの準備

デプロイするVMはDebian 12にしてみます。まず`virt-builder` を使ってqcow2イメージを作成します。あらかじめ`libguestfs-tools`をインストールしておきましょう。

```shell
USERNAME=debian
PASSWORD=debian
sudo virt-builder debian-12 \
  --format qcow2 \
  --hostname debian \
  --root-password password:tekito \
  --install sudo \
  --install open-vm-tools \
　--install cloud-init \
  --install netplan \
  --install sysctl \
  --run-command 'useradd -m -G sudo,operator -s /bin/bash ${USERNAME}' \
  --run-command 'chage -M -1 ${USERNAME}' \
  --run-command 'echo ${USERNAME}:${PASSWORD} | chpasswd' \
  --edit '/etc/network/interfaces: s/ens3/enp192/' \
  --firstboot-command 'env DEBIAN_FRONTEND=noninteractive dpkg-reconfigure openssh-server' \
  -o debian12.qcow2
chown $USER.$GID debian12.qcow2
```

これでできあがった`debian12.qcow2`をVMDKフォーマットに変換するのですが、VMware ESXiで利用するにはパラメータにひと工夫必要でした。もしかすると作業マシンがUbuntu 18.04LTSと古すぎるので、新しいOSの`qemu-img`だと指定不要かもしれません。

```shell
qemu-img convert \
  -o adapter_type=lsilogic,subformat=streamOptimized,compat6 \
  -O vmdk \
  debian12.qcow2 debian12.vmdk
```

これをつけずに作ったVMDKはディスクタイプが古いらしく、ESXiに持っていってVMにくっつけても起動に失敗します。エラーメッセージが「ディスクタイプ2は云々」と出てくるので訳がわからず、しばらく悩みました。

# ベースVMをESXiにデプロイ

作成した`debian12.vmdk`をデータストアブラウザを使ってESXi上に放り込みます。そして新規仮想マシンの作成。

- 名前: `debian12`
- OS: Linux - Debian11(64bit)
- VCPU数やメモリは適宜
- 既存ハードディスク (debian12.vmdk) を指定

既存ハードディスクイメージを選択して放り込んだVMDKファイルを指定します。OSの選択肢まだ12がないようなのでDebian11で。

なんかうまくいかないようなら、空のVM作ってOVFエクスポートして、「インポートする」でOVFファイルと`debian12.vmdk`を放り込んでみてください。

# ベースVMいろいろ調整

ふつうに起動して手動でネットワークなどつないで、cloneした側に引き継いでほしい設定をしていきます。

`virt-builder`でインストールした`cloud-init`ですが、VMware経由での初期設定に対応させるにはファイルの編集が必要でした。

```:/etc/cloud/cloud.cfg
datasource_list: [VMware]
```

どこかに書いておいてください。`virt-builder`のパラメータでファイル編集したほうが楽かもです。

準備が終わったらお掃除。とくに`/etc/network/interfaces.d/*`は、そのままにしておくとネットワーク設定をスキップされてしまうので削除を忘れないように。

```shell
sudo rm /etc/network/interfaces.d/50-clout-init
sudo rm -r /var/lib/cloud
sudo cloud-init clean --logs --machine-id
```

掃除が終わったらシャットダウンしておきます。動きっぱなしだとcloneに失敗します。

# `terraform-provider-esxi`

HashiCorp公式から提供される`terraform-provider-vsphere`は有償版でのみ提供されるAPIを呼び出します。むしょうばんではつかえませんので、今回はAPIを呼ばずssh経由で操作する設計のため無償版に対応している`terraform-provider-esxi`を使います。

公式は[こちら](https://registry.terraform.io/providers/josenk/esxi/latest)
ソースコード(GitHub)は[こちら](https://github.com/josenk/terraform-provider-esxi)。

Terraformで宣言すれば拾ってくるので、`git clone`する必要はありません。

## 必要なもの

- [`terraform`](https://www.terraform.io/)
- [`ovftool`](https://developer.vmware.com/ja_JP/tool/ovf/4.1.0)
- [VMware ESXi](https://customerconnect.vmware.com/jp/evalcenter?p=free-esxi7)

`terraform`と`ovftool`は作業マシンにインストールしておいてください。
VMware ESXiはアプリケーションでなくOS相当のハイパーバイザですので、それなりの容量の物理マシンにインストールすることをおすすめします。とくにディスクはわりと必要です。

## 基本設定

適当なディレクトリを作って、そこにHCLファイルを置いていきます。

```hcl:version.tf
terraform {
  required_version = ">= 0.13"
  required_providers {
    esxi = {
      source  = "josenk/esxi"
      version = ">= 1.8.0"
    }
}
```

などと書いて利用を宣言して、下記のように基本設定を書きます。

```hcl:providers.tf
provider "esxi" {
    esxi_hostname = "ホスト名かIPアドレス"
    esxi_username = "操作権限のあるアカウント"
    esxi_password = "パスワード"
}
```

でもってVMリソースを書けばひとまず完成です。

```hcl:vm.tf
resource "esxi_guest" "vm" {
  clone_from_vm = "debian12"
  guest_name = "debian12-clone"
  power = "on"
  disk_store = "datastore1"
  network_interfaces {
    virtual_network = "VM Network"
  }
}
```

データストアは環境に合わせて適宜変更してください。

## ESXi側の準備

ESXi側では予めSSHを有効にしておきます。


## デプロイする

```
terraform init
terraform apply
```

ふつうは`plan`とか`validate`で書式のチェックをするのですが、`apply`でもおかしければエラーになるので省いちゃいます。

試した環境では、デプロイに3分くらいかかりました。ESXiのWebUIからコンソールを呼び出して、ログインなどしてみてください。環境によってはNICにIPアドレスが振られるので、すぐ外からログインできるかもしれません。

# 複数VMを用意する

## 設計

最終的に、こんなふうにしたいと考えます。

```
.-------.
| HostA |10.0.0.1/8----.
`-------'              |
    |192.168.0.1/24    |管理用NW
    |                  |-------(VM Network)
    |192.168.0.2/24    |
.-------.              |
| HostB |10.0.0.2/8----+
`-------'              |
    |172.21.0.2/24     |
    |                  |
    |172.21.0.1/24     |
.-------.              |
| HostC |10.0.0.3/8---'
`-------'   
```

現在NICがひとつ生えてますがこれは管理用として`VM Network`にぶらさげます。他に2つずつNICを用意し、図のようにつなげるよう設定を用意します。

## local variablesの定義

Terraformでは`locals`の中に変数を定義できますので、てきとーに書いてみます。ネットワーク設定に特化した書式にしました。clone元VMの情報を埋めたりとか拡張できるとは思いますが、今回はこれで。

:::note info
2023/09/20修正: `jinja_template`側の処理を変えて、NIC名を何度も書かずに済む書式に変更しました。
:::

```hcl:locals.tf
locals {
  eth_prefix = "ens"
  eth_start_num = 35
  hosts = {
    "HostA" = {
      "VM Network" = [ "10.0.0.1/8" ],
      "HostAB"     = [ "192.168.0.1/24" ]
    },
    "HostB" = {
      "VM Network" = [ "10.0.0.2/8" ],
      "HostAB"     = ["192.168.0.2/24" ],
      "HostBC"     = [ "172.21.0.2/24" ]
    },
    "HostC" = {
      "VM Network" = [ "10.0.0.3/8" ],
      "HostBC"     = [ "172.21.0.1/24" ]
    }
  }
}
```

NIC名(`ens35`とか)は実際のVMに合わせてください。

上記ではネットワークに名前をつけました。これはportgroupやvswitchを作る際の名前として使う予定です。が、このままループで値を引っ張り出すと重複してしまします。そこで、重複しない集合も作っておきます。

```hcl:private.tf
locals {
  private_network = [for nic in disinct(flatten([
    for host in values(local.hosts) : keys(host)
  ])): nic if nic != "VM Network"]
}
```
ざっくり説明すると

- 内側の`for`でホストごとのネットワーク一覧からネットワーク名だけを取り出し
- 構造化されてるのでフラットにして重複を取り除いて
- そこから`"VM Network"を取り除く

というふうです。

説明の関係で分けましたが、`locals.tf`に混ぜていただいても大丈夫です。

## ネットワークの定義

portgroup, vswitchを作ります。`for_each`で繰り返しを定義します。一般的なプログラミング言語とはちょっと違うので最初は戸惑うかもしれません。

```hcl:network.tf
resource "esxi_vswitch" "vswitch" {
  for_each = toset(local.private_network)
  name = etch.key
}

resource "esxi_portgroup" "portgroup" {
  for_each = toset(local.private_network)
  name = each.key
  vswitch = each.key
}
```

## 複数VMの定義

こちらも`for_each`で。NICを複数生やすところは`dynamic`を使います。

:::note info
`2023/09/20修正: dynamic network_interfaces`を`for_each`でまわすときもオーダーを気にする必要があったので修正しました。
:::

```hcl:vm.tf
resource "esxi_guest" "vm" {
  for_each = local.hosts
  clone_from_vm = "debian12"
  guest_name = each.key
  power = "on"
  disk_store = "datastore1"
  dynamic network_interfaces {
    for_each = toset(keys(each.value))
    content {
      virtual_network = network_interfaces.key
  }
}
```

## デプロイする

いちおう書き損じがないか`tellaform validate`で確認しておくといいです。でもって、いちいち`yes`とか打ってられないのでパラメータ追加。何度もやる場合ヒストリから呼び出すので、最初は長くても我慢。

```
terraform apply -auto-approve
```

数分待たされます。portgroupとvswitchの作成の依存関係処理がいまいちなのか、portgroup作成時にエラーが出ることがあります。あわてず騒がず二度実行すると成功します。ESXiサーバーの能力にもよるような気がします。

# IPアドレスを割り当てる

ここまではわりとすんなりできたのですが、IPアドレスを割り当てようとして苦労しました。

## cloud-initを使ったIPアドレス割当

`resource "esxi_guest"`に`guestinfo`を設定することで、TerraformからVMに向かって`cloud-init`用初期設定を流し込むことができます。おおよそ下記のような感じです。外部ファイルも用意できますがVMの数が増えると管理が大変なので、ここでは直接テキストをheredocで埋め込んでいます。

```hcl
  guestinfo = {
    "userdata" ~ base64gzip("#cloud-config\n")
    "userdata.encoding" = "gzip+base64"
    "metadata" = <<EOT
instance-id: HostA
local-hostname: HostA
network:
  version: 2
  ethernets:
    ens36:
      addresses:
        - 192.168.0.2/24
EOT
    "metadata.encoding" = "gzip+base64"
  }
```

直接指定する文にはだらだら書けばすむのですが、これを上の方で定義した`local.hosts`から持ってくるには工夫が必要でした。なぜならNICの本数が一定ではなかったからです。

紆余曲折は省いて、どうやって実現したかだけ書きます。

### `terraform-provider-jinja`のdatasource `jinja_template`を使う

どうにかできないかあれこれ探していたのですが、Jinja2テンプレートを展開するproviderがあったので使ってみることにしました。

※追記: --[String Templates](https://developer.hashicorp.com/terraform/language/expressions/strings#string-templates)や[`templatefile()`](https://developer.hashicorp.com/terraform/language/functions/templatefile)を使うほうが素直かもしれません。あとで書き直してみます。--機能が足りなくてうまく書けませんでした。

公式は[こちら](https://registry.terraform.io/providers/NikolaLohinski/jinja/latest)。
ソースコード(GitHub)は[こちら](https://github.com/NikolaLohinski/terraform-provider-jinja)

`terraform` コンテキストに追加し、`provider`を宣言することで使えるようになります。

```hcl:version.tf
terraform {
  required_version = ">= 0.13"
  required_providers {
    esxi = {
      source  = "josenk/esxi"
      version = ">= 1.8.0"
    }
    jinja = {
      source  = "NikolaLohinski/jinja"
      version = ">= 1.17.0"
    }
}
```

```hcl:providers.tf
provider "esxi" {
    esxi_hostname = "ホスト名かIPアドレス"
    esxi_username = "操作権限のあるアカウント"
    esxi_password = "パスワード"
}

provider "jinja" {}
```

### jinja2テンプレートを用意する

 ~~1ファイル用意します。最初はheredocを使いインラインで書こうと思ったのですが、Terraformが勝手に解釈してエラーでご機嫌斜めになるのでやむなく分離しました。~~ 

 ~~ここに全ホストの全NICのIPアドレス情報を押し込めるため、デリミタを埋め込みます。テンプレート処理されて戻ってくる文字列をデリミタで`split`すると、ホストの並び順になるのでindexを使って取り出せるという仕掛けです。~~ 

:::note info
2023/09/20追記・修正
`data`でも`for_each`が使えることがわかったので、うまくホストごとの文字列を生成するように書き換えました。全VMが同じOSだとインタフェース名の付き方も同じなので、いちいちデータに書くのがしんどいので共通化します。
:::

```jinja:metadata.j2
instance-id: {{ host }}
local-hostname: {{ host }}
network:
  version: 2
  ethernets:
{%   set namespace(ns, count=0)
{%   for net in nets %}
    {{ eth_prefix }}{{ eth_start_num + loop.index - 1 }};
      addresses:
{%     for address in addressess[net] %}
        - {{ address }}
{%     endfor %}
{%   endfor %}
{% endfor %}
```

### `jinja_template`データを作る

テンプレートと入力データを指定します。

```hcl:metadata.tf
data "jinja_template" "metadata" {
  for_each = local.hosts
  template = "./metadata.j2"
  context {
    type = "json"
    data = format(<<EOT
{
  "eth_prefix": "%s",
  "eth_start_num": %d,
  "host":  "%s",
  "nets": %v,
  "addresses\":%v
}
EOT
,
    local.eth_prefix,
    local.eth_start_num,
    each.key,
    keys(each.value),
    each.value)
  }
}
```

わざわざ`keys(each.value)を渡しています。`each.value`自体がmapであるため、単純に`for`を回すと順序が記述どおりとならないため、アドレスを付与したいNICと実際に付与されるNICの不一致が発生していまいます。これを防ぐために順女性が保証されるリストを渡して記述順に処理されるようにしました。

こうしておくと、他リソースから`data.jinja_template.metadata[each.key].result`と記述して文字列をとりだせます。

### `guestinfo.metadata`で結果を取り込む

他の要素は省きます。

```hcl
  guestinfo = {
    metadata = data.jinja_template.metadata[each.key].result
  }
```

### デプロイする

完成したら`terraform apply` してみます。

```
terraform apply
```

VMの数が多いとその分時間がかかります。のんびり待ちましょう。

### 設定状況の確認

ためしにVMのコンソールを開いてログインしてみます。IPアドレスがついているか確認です。

```console
Debian GNU/Linux 12 HostA tty1
HostA login: debian
Password:
Linux nrm-controller 6.1.0-9-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.27-1 (2023-05-08) x86_64

The programs included with the Debian GNU/Linux system are free software;
the exact distribution terms for each program are described in the
individual files in /usr/share/doc/*/copyright.

Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent
permitted by applicable law.
Last login: Wed Sep 13 12:58:27 2023
debian@HostA:~$ ip addr
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host 
       valid_lft forever preferred_lft forever
2: ens35: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000
    link/ether 00:0c:29:98:45:e1 brd ff:ff:ff:ff:ff:ff
    altname enp2s3
    inet 10.0.0.1/8 brd 10.207.9.255 scope global ens35
       valid_lft forever preferred_lft forever
    inet6 fe80::20c:29ff:fe98:45e1/64 scope link 
       valid_lft forever preferred_lft forever
3: ens36: <BROADCAST,MULTICAST,UP,LOWER UP> mtu 1500 qdisc fq_codel state DOWN group default qlen 1000
    link/ether 00:0c:29:98:45:eb brd ff:ff:ff:ff:ff:ff
    altname enp2s4
    inet 192.168.0.1/24 brd 100.64.0.255 scope global ens36
       valid_lft forever preferred_lft forever
debian@HostA:~$ 

```

無事ついてました。めでたしめでたし。

# トラブルシュート

## `cloud-init`に渡したはずのデータが反映されない!

VM側で確認できます。ホスト名が設定されていなければ、反映されていません。

渡される情報は下記で確認できます。

```shell
vmware-rpctool "info-get guestinfo.metadata" | base64 --decode | gzip -d
```

おそらくここがちゃんとできていないのが問題です。

- `vmware-rpctool`が入ってなければクローン元のベースVMに`open-vm-tools`を入れましょう。
- 空っぽの場合 `/etc/cloud/cloud.cfg` の `datastore_list` の設定を再確認。`[VMware]`になってますか?
- `Network:`の`version`が2の場合、それ以下のデータは何も考えずnetplanに渡されます。netplan入ってますか?
- 想定通りの値になってますか?
- `/etc/network/interfaces.d`にゴミが残ってたりしませんか?
- 念のため `vmware rpctool "info-get guestinfo.metadata.encoding"`も確認を。
- `/var/log/cloud-init.log`や`/var/log/cloud-init-out.log` でエラー箇所を確認します。`[ERROR]`とは限りません。

# 注意点(?)

あまり頻繁に実行を繰り返していると、下記のメッセージが表示されるようです。

![Screenshot from 2023-09-14 04-39-25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/546046/9af356e8-19bf-7e05-ecf9-5a7ec99ed1db.png)

いつ出たのか、いつ900秒が解消されたのか気づかなかったのですが、ふだんは動くのになぜかうまく動いてないなと思ったら疑ってみてください。

# おわりに

Terraform、エラーがかなり不親切です。`TF_LOG`を指定してもデバッグログもゴミ情報がほとんどで肝心の情報は出てきません。自分でいわゆるprintfデバッグをやるしかなくて、しかも任意の場所で任意の値を表示することもできないので苦痛が大きいです。なぜそのエラーメッセージなのか、そのときそこの値は何なのか、それを把握するだけで一苦労。デバッガでも作ったらめちゃくちゃ感謝されそうだなあと思いつつ、そんな気力はわかないのでした。