はじめに
無償で使えるVMware ESXiの上にTerraformをつかってVMを複数作り、ネットワークをつなげるというのをやってみたところ、思ってたよりも手こずったので、備忘録として残しておきます。
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
をインストールしておきましょう。
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
だと指定不要かもしれません。
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経由での初期設定に対応させるにはファイルの編集が必要でした。
datasource_list: [VMware]
どこかに書いておいてください。virt-builder
のパラメータでファイル編集したほうが楽かもです。
準備が終わったらお掃除。とくに/etc/network/interfaces.d/*
は、そのままにしておくとネットワーク設定をスキップされてしまうので削除を忘れないように。
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
を使います。
Terraformで宣言すれば拾ってくるので、git clone
する必要はありません。
必要なもの
terraform
とovftool
は作業マシンにインストールしておいてください。
VMware ESXiはアプリケーションでなくOS相当のハイパーバイザですので、それなりの容量の物理マシンにインストールすることをおすすめします。とくにディスクはわりと必要です。
基本設定
適当なディレクトリを作って、そこにHCLファイルを置いていきます。
terraform {
required_version = ">= 0.13"
required_providers {
esxi = {
source = "josenk/esxi"
version = ">= 1.8.0"
}
}
などと書いて利用を宣言して、下記のように基本設定を書きます。
provider "esxi" {
esxi_hostname = "ホスト名かIPアドレス"
esxi_username = "操作権限のあるアカウント"
esxi_password = "パスワード"
}
でもってVMリソースを書けばひとまず完成です。
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の情報を埋めたりとか拡張できるとは思いますが、今回はこれで。
2023/09/20修正: jinja_template
側の処理を変えて、NIC名を何度も書かずに済む書式に変更しました。
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を作る際の名前として使う予定です。が、このままループで値を引っ張り出すと重複してしまします。そこで、重複しない集合も作っておきます。
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
で繰り返しを定義します。一般的なプログラミング言語とはちょっと違うので最初は戸惑うかもしれません。
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
を使います。
2023/09/20修正: dynamic network_interfaces
をfor_each
でまわすときもオーダーを気にする必要があったので修正しました。
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で埋め込んでいます。
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やtemplatefile()
を使うほうが素直かもしれません。あとで書き直してみます。--機能が足りなくてうまく書けませんでした。
terraform
コンテキストに追加し、provider
を宣言することで使えるようになります。
terraform {
required_version = ">= 0.13"
required_providers {
esxi = {
source = "josenk/esxi"
version = ">= 1.8.0"
}
jinja = {
source = "NikolaLohinski/jinja"
version = ">= 1.17.0"
}
}
provider "esxi" {
esxi_hostname = "ホスト名かIPアドレス"
esxi_username = "操作権限のあるアカウント"
esxi_password = "パスワード"
}
provider "jinja" {}
jinja2テンプレートを用意する
1ファイル用意します。最初はheredocを使いインラインで書こうと思ったのですが、Terraformが勝手に解釈してエラーでご機嫌斜めになるのでやむなく分離しました。
ここに全ホストの全NICのIPアドレス情報を押し込めるため、デリミタを埋め込みます。テンプレート処理されて戻ってくる文字列をデリミタでsplit
すると、ホストの並び順になるのでindexを使って取り出せるという仕掛けです。
2023/09/20追記・修正
data
でもfor_each
が使えることがわかったので、うまくホストごとの文字列を生成するように書き換えました。全VMが同じOSだとインタフェース名の付き方も同じなので、いちいちデータに書くのがしんどいので共通化します。
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
データを作る
テンプレートと入力データを指定します。
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
で結果を取り込む
他の要素は省きます。
guestinfo = {
metadata = data.jinja_template.metadata[each.key].result
}
デプロイする
完成したらterraform apply
してみます。
terraform apply
VMの数が多いとその分時間がかかります。のんびり待ちましょう。
設定状況の確認
ためしにVMのコンソールを開いてログインしてみます。IPアドレスがついているか確認です。
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側で確認できます。ホスト名が設定されていなければ、反映されていません。
渡される情報は下記で確認できます。
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]
とは限りません。
注意点(?)
あまり頻繁に実行を繰り返していると、下記のメッセージが表示されるようです。
いつ出たのか、いつ900秒が解消されたのか気づかなかったのですが、ふだんは動くのになぜかうまく動いてないなと思ったら疑ってみてください。
おわりに
Terraform、エラーがかなり不親切です。TF_LOG
を指定してもデバッグログもゴミ情報がほとんどで肝心の情報は出てきません。自分でいわゆるprintfデバッグをやるしかなくて、しかも任意の場所で任意の値を表示することもできないので苦痛が大きいです。なぜそのエラーメッセージなのか、そのときそこの値は何なのか、それを把握するだけで一苦労。デバッガでも作ったらめちゃくちゃ感謝されそうだなあと思いつつ、そんな気力はわかないのでした。