TerraformでSONiC-VSをデプロイしてみた

# はじめに

[terraform-provider-libvirtを使ってみた](https://qiita.com/masru0714/items/925f095e88a76cb95feb)の続きです。(やっぱりSONiCかよ! という感想をお持ちの方がいらっしゃいましたら、そのとおりでした! と開き直りますw)

`module`の話と`libvirt_network`の話も交えて、解説します。

# SONiC-VS
SONiCはホワイトボックススイッチ用のNOSであり、SONiC-VSはVirtual Switchの名の通り仮想マシンで動作するスイッチです。SONiC-VSの起動イメージである`sonic-vs.img`はqcow2フォーマットです。

```
$ file sonic-vs.img 
sonic-vs.img: QEMU QCOW2 Image (v3), 17179869184 bytes
```

ですので、Terraformで扱う際もイメージファイルを置き換えるだけで、Debianのときと同様にSONiC-VS単体を起動させること自体は簡単です。`vcpu = 1`、`memory = 2048` で動きます。

ただし、SONiC-VSをそれ単体で動かすことにあまり意味はありません。スイッチなので複数のネットワークインタフェースを持っていて、他のルータ・スイッチ・VMと接続し、パケットを中継するのが役割となります。つまり、Terraformで複数VMを定義し、またVM間のネットワーク接続を定義することではじめてSONiC-VSをTerraformでデプロイする意味が出てきます。

# 複数のネットワークインタフェース

前回の記事でのDebian VMはネットワークインタフェースをひとつだけ持っていました。`libvirt_domain`リソースの定義の中に下記のような記述がありました。

```
  network_interface {
    network_name = "default"
  }
```

複数のネットワークインタフェースを定義するには、この`network_interface`ブロックを複数並べます。SONiC-VSでは一番上の`network_interface`が`eth0`(マネジメントポート)となり、2番目以降が`Ethernet0`, `Ethernet4`, という並びになります。
`network_name`については後述します。

# Terraform module

記事「[terraform-provider-libvirtを使ってみた](https://qiita.com/masru0714/items/925f095e88a76cb95feb)」ではDebianのVMをデプロイする例を紹介しました。この例では、デプロイするVMはひとつでした。では、複数のVMを起動するにはどうすればいいでしょうか。

同じ記述をコピペして名前を変えていけば、たしかに複数のVMをデプロイすることができます。しかしそれは非効率ですし変更が生じたときに漏れが発生しがちだったりします。ここで出てくるのが`module`という概念です。

## moduleとは

moduleは繰り返し使われるリソース定義の共通部分を切り出し、別のディレクトリ(別のtfファイル)て提供することで再利用するというものです。名前やIPアドレスなど個別に違う値を必要とする部分については変数を定義し、呼び出し元で指定することができます。

## 例

簡単な例です。ファイルを2つ用意します。呼び出し元とmoduleです。

```:ls
test/main.tf
test/modules/sonic/sonic.tf
```

呼び出し元です。

`source = `で利用するmoduleを指定します。`hostname =`でmodule側で参照される変数の値を定義しています。ふつうのプログラミング言語の関数呼び出しのイメージです。

```HCL:main.tf
module "sonic-1" {
  source = "modules/sonic"
  hostname = "s1"
}
module "sonic-2" {
  source = "modules/sonic"
  hostname = "s2"
}
```

moduleの定義です。

変数を宣言して、文字列中であれば `${var.hostname}`で参照しています。VMごとにディスクイメージのファイル名を変える必要があるため、このようにします。(全部書くと長くなるので、前後は省いていますのでご注意ください)

`${path.module}`は、このディレクトリ(`modules/sonic`)を示します。

```HCL:modules/sonic/sonic.tf
variable hostname { default = "sonic" }

provider "libvirt" {
  uri = "qemu:///system"
}

resource "libvirt_volume" "os-image" {
  name = "${var.hostname}-disk.qcow2"
  source = "${path.module}/sonic-vs.img"
  format = "qcow2"
}
```

`module`の中で生成された値を呼び出し元に返すこともできますが、今回は利用していません。

上記例で、2つのSONiC-VSを用意することができます。

ハマりどころとしては、Terraform registryにないproviderを引き込むための`terraform`ブロックは`module`側に書く必要があるというところです。正確には、libvirt_XXXXリソースを記述するファイルの先頭に書いておく必要があります。

# ネットワークを定義する

L2あるいはL3のネットワークを定義して、`network_interface`で指定することで、複数のネットワークインタフェースをそれぞれ別々のネットワークに接続することができます。ネットワークの定義はlibvirt_networkリソースで行います。公式の解説は[こちら](https://github.com/dmacvicar/terraform-provider-libvirt/blob/master/website/docs/r/network.markdown)。

VM間をつなぐためにネットワークを定義するので、libvirt_networkリソースの定義はmodule側ではなく呼び出し側で定義することになります。つまり`terraform`ブロックが呼び出し側の`main.tf`にも必要になりますので追記をお忘れなく。

libvirt_networkリソースですが、名前の他に`mode`を指定する必要があります。

- `none`
- `nat` (default)
- `route`
- `bridge`

`none`以外はホストネットワークと接続するので、仮想マシン間の接続だけに留める場合modeに`none`を指定したlibvirt_networkリソースを定義することになります。また、`nat`ではホストから仮想マシンにアクセスできませんので、ホストからアクセスしたい場合は`bridge`あるいは`route`を指定したlibvirt_networkリソースを定義します。

定義したlibvirt_networkリソースは、仮想マシンの`network_interface`で名前を指定することで接続します。

```
resource "libvirt_network" "s1-s2" {
  name = "c1"
  mode = "none"
  dns { local_only = true }
}
```

# ネットワークインタフェースをネットワークに接続する

こんなふうにつないでみます。

```
(default)                      (default)
eth0|                              |eth0
 ---+---                        ---+---
 | s1  |                        | s2  |
 ---+---                        ---+---
    |Ethernet0            Ethernet0|
    ---------------c1---------------
```

module側では2つ目の`network_interface`でネットワーク名を変数で指定できるようにします。今回は`Ethernet0`のみ利用しますが、今後ルーティングすることを想定して`Ethernet4`もコードを用意しておきました。関係ある部分を一部抜粋します。

```HCL:modules/sonic/sonic.tf
variable eth0 { default = "default" }
variable Ethernet0 { default = "default" }
variable Ethernet4 { default = "default" }

resource "libvirt_domain" "sonic-domain" {

  network_interface {
    network_name = var.eth0
  }
  network_interface {
    network_name = var.Ethernet0
  }
  network_interface {
    network_name = var.Ethernet4
  }
```

呼び出し側ではlibvirt_networkリソースを定義して、指定します。

```HCL:main.tf
resource "libvirt_network" "s1-s2" {
  name = "c1"
  mode = "none"
  dns { local_only = true }
}

module "sonic-1" {
  source = "./modules/sonic"
  hostname = "s1"
  Ethernet0 = "c1"
}

module "sonic-2" {
  source = "./modules/sonic"
  hostname = "s2"
  Ethernet0 = "c1"
}
```

# デプロイ

```
terraform init
terraform apply
```

できました。`terraform state list`でリソースを確認できます。

```
$ terraform state list
libvirt_network.s1-s2
module.sonic-1.libvirt_domain.domain-sonic
module.sonic-1.libvirt_volume.os-image
module.sonic-2.libvirt_domain.domain-sonic
module.sonic-2.libvirt_volume.os-image
```

また、`virsh list`や`virsh net-list`などでも作成できていることが確認できます。

```
$ virsh list
 Id   Name     State
------------------------
 41   s2       running
 42   s1       running

$ virsh net-list
 Name      State    Autostart   Persistent
--------------------------------------------
 c1        active   no          yes
 default   active   yes         yes

$ virsh net-port-list c1
 UUID
---------------------------------------
 3bebbe2b-ff78-49d5-9bea-cbf28385154d
 55867b00-38cf-45da-909b-67eecaa7537b

$ 
```

# 動作確認

`virt-viewer`でs1とs2のコンソールを開きます。
公式Jenkinsから拾ったイメージなら `admin/YourPaSsWoRd` で、自分でビルドしたイメージならそこで指定したパスワードでログインします。

## インタフェースはupしているか?

起動したとき、デフォルトのconfigが同一なためどちらのVMもIPアドレスが同じなので、`/etc/sonic/config_db.json`を書き換えます。`Ethernet0`以外は今回使わないので思い切って削除。編集がおわったらリブートします。(下記は`INTERFACE`部分だけを引っ張り出してます)

```json:s1
{
  INTERFACE {
    "Ethernet0|10.0.0.0/31": {}
  }
}
```

```json:s2
{
  INTERFACE {
    "Ethernet0|10.0.0.1/31": {}
  }
}
```

再起動してきたら、それぞれのVMで`show ip interfaces`を実行し、upしているかを確認してみます。

```:s1
admin@sonic:~$ show ip interfaces
Interface    Master    IPv4 address/mask    Admin/Oper    BGP Neighbor    Neighbor IP
-----------  --------  -------------------  ------------  --------------  -------------
Ethernet0              10.0.0.0/31          up/up         N/A             N/A
Loopback0              10.1.0.1/32          up/up         N/A             N/A
docker0                240.127.1.1/24       up/down       N/A             N/A
eth0                   192.168.122.240/24   up/up         N/A             N/A
lo                     127.0.0.1/16         up/up         N/A             N/A

```

```:s2
admin@sonic:~$ show ip interfaces
Interface    Master    IPv4 address/mask    Admin/Oper    BGP Neighbor    Neighbor IP
-----------  --------  -------------------  ------------  --------------  -------------
Ethernet0              10.0.0.1/31          up/up         N/A             N/A
Loopback0              10.1.0.1/32          up/up         N/A             N/A
docker0                240.127.1.1/24       up/down       N/A             N/A
eth0                   192.168.122.57/24    up/up         N/A             N/A
lo                     127.0.0.1/16         up/up         N/A             N/A

```

大丈夫そうです。

## pingは通るのか?

では、`ping`を使って実際に通信できることを確認してみましょう。

```:s1
admin@sonic:~$ ping 10.0.0.1
PING 10.0.0.1 (10.0.0.1) 56(64) bytes of data.
PING 10.0.0.0 icmp_seq=1 Destination Host Unreachable
PING 10.0.0.0 icmp_seq=2 Destination Host Unreachable
PING 10.0.0.0 icmp_seq=3 Destination Host Unreachable
```

……返答がありません。あれ?

`s2`から`10.0.0.0`に向かってpingしても結果は同じ。なぜ?
そもそもパケットが届いていないのか? と思い、`s2`で`tcpdump`しておいて`s1`から`ping`してみると

```:s2
admin@sonic:~$ sudo tcpdump -i Ethernet0
tcpdump: verbose output suggested, use -v or -vv for full protocol decode.
listning on Ethernet0, link-type EM10MB (Ethernet), capture size 262144 bytes
02:13:06.079409 ARP, Request who-has 10.0.0.1 tell 10.0.0.0, length 20
02:13:06.079442 ARP, Reply 10.0.0.1 is-at 52.54:00:12:34:56 (oui Unknown), length 20 
```

ここでMACアドレスをよく見ていればすぐ気づいたのですが、ARP届いてるし返事してるわーと思って別のところもいろいろ調べて首を傾げていました。

そいやパケットは来てたけどarp tableちゃんとしてる? と思い `show arp`で確認して原因がわかりました。

```:s1
admin@sonic:~$ show arp
Address        MacAddress         Iface      Vlan
-------------  -----------------  ---------  ------
10.0.0.1       52:54:00:12:34:56  Ethernet0  -
192.168.122.1  52:54:00:31:dd:3a  eth0       -
Total number of entries 2 
```

```:s2
admin@sonic:~$ show arp
Address        MacAddress         Iface      Vlan
-------------  -----------------  ---------  ------
10.0.0.0       52:54:00:12:34:56  Ethernet0  -
192.168.122.1  52:54:00:31:dd:3a  eth0       -
Total number of entries 2 
```

`192.168.122.1`は外部接続のgatewayなので同じでいいとして、`Ethernet0`の……

**この2つのVM、MACアドレスが同じ! ダメ……!!**

そういえば、`/etc/sonic/config_db.json`の`DEVICE_METADATA`の中にMACアドレスの情報がベタっと書かれてるのを思い出しました。

```json:config_db.json
{
  "DEBICE_METADATA": {
    "mac": "52:54:00:12:34:56"
  }
}
```

これだ! というわけで、どちらか片方だけでいいので別の値に書き換えます。外に出ていくわけではないのでテキトーに。たとえば`56`を`57`に。書き換えた後は、`sudo config reload`でもいい気がしますが、リブート。

再起動してきたら、あらためて`ping`してみます。

```:s1
admin@sonic:~$ ping 10.0.0.1
PING 10.0.0.1 (10.0.0.1) 56(64) bytes of data.
64 bytes from 10.0.0.1: icmp_seq=1 ttl=64 time=0.550 ms
64 bytes from 10.0.0.1: icmp_seq=2 ttl=64 time=0.354 ms
```

**無事`ping`がとおりました!** めでたしめでたし。

しかし、ここ(MACアドレス設定のユニーク化)を自動化する方法は現時点では発見できていません。

- 起動前に設定? `virt-install`したあとのディスクイメージにアクセスできる?
- 起動後に設定? `virsh console`が動かないので無理かも……
- イメージに細工? `config_db.json`から`mac`を消したらどうなる?
- ZTPを使う? 別サーバを別途建ててそちらに設定を仕込む必要が……

実機だとぶつかることはないので、無理に自動化しなくてもいいという考え方もあります。もっとも、実機だとTerraformでデプロイできませんが……

※2021/01/26追記: `network_interface` に `mac = "XX:Xx:XX:XX:XX:XX"` を加えることで明示的にMACアドレスを変更できるようでした。

おそらくDebianのVMで同じことをやれば、ハマることはなかったのではないかと思います。

# どのくらいリソースを食うの?

DebianやSONiCのVMに2GBメモリを指定したら、ほぼそのまま2GB消費してました。2つ起動で4GB。4つ起動すれば8GB。手元では、32GBメモリ搭載マシンでなにもデプロイしてない状態で利用中メモリが7.5GB程度。つまりVMを12個起動するとアップアップということに。10個動かせれば十分なのかも知れませんが、メモリ増設したくなる瞬間でした。

# おわりに

SONiCのVMを複数起動してインタフェースをつなぐことができました。もう少し規模を増やして、両端にホストVMをつなぐなどすれば、いろいろなテストが捗るのではないかと思います。

もっとも、SONiCの場合VMで動くが実機で動かないということがありうるので油断は禁物なのですが。実機で規模の大きなテスト環境を用意するのは時間も金額も人手も大変なので、仮想環境で少しでも楽できるといいなあと思っています。

今回作成したtfファイルをgithubで公開しました。
https://github.com/iMasaruOki/terraform-libvirt-sonic-example
参考になれば幸いです。