投稿日:
更新日:

GCE Nested V12n で VT-x on KVM を利用する

Authors

目次

banner.png

はじめに

サーバ仮想化技術は、物理サーバのコンピュートリソースを最大限活用するために、従来から利用されてきました。 サーバ仮想化を実現する代表的なハイパバイザに KVM(Kernel-based Virtual Machine) があります。 KVM は、オープンソースでありながら高い性能と安定性を持ち、Linux に深く統合されていることから、多くのオンプレミス環境やプライベートクラウドで採用されてきた実績があります。

※ サーバ仮想化におけるホスト型仮想化方式とハイパバイザ型仮想化方式については こちら のブログでも紹介しています。

昨今ではパブリッククラウドが普及したことで、オンプレミスのサーバから GCE や EC2、AVM といったコンピュートマシンへのリフトアンドシフトが進んでいます。 大半の場合、パブリッククラウドが提供するコンピュートマシンは、それ自体が仮想化された VM として提供されます。

ここで、従来利用してきた「KVM によるサーバ仮想化はクラウドプロバイダが提供する VM でも利用できるのか」という疑問が生じました。

答え、クラウドプロバイダが提供する VM でも Nested Virtualization(Nested V12n) という仕組みを利用できます。 Nested V12n は、その名の通りネストされた仮想化を意味し、VM の中で別の VM を実行(VM in VM)することを指します。 Nested V12n を使用すると、クラウドプロバイダが提供する VM 内で KVM を利用することができます。

しかし、実際にはオンプレミスの物理マシンとクラウドプロバイダが提供する仮想マシンでは、KVM の構築に際して仕組み上異なる部分があり、後者の場合は特有の制約もあります。

今回のブログでは、Google Cloud の VM(GCE)で Nested V12n を利用し、libvirt を用いて Linux KVM を構築してみたので、クラウドサービスの VM で KVM を実行する方法や、VM ネットワークの違いについて紹介したいと思います。

GCE Nested Virtualization

公式ドキュメント:About nested virtualization

gce-nested-virtualization.png

Google Cloud の Compute Engine(GCE)では Nested Virtualization(Nested V12n)によるネストされた VM が公式にサポートされています。

Google Cloud の仮想環境構成は次のような階層構造になっています。

  • Level 0(L0)
    • Google Cloud のデータセンターにある物理サーバ
    • Google 独自のセキュリティ強化版 KVM ベースのハイパバイザが稼働する(これによって GCE 本体が払い出される)
  • Level 1(L1)
    • ユーザが作成した Compute Engine VM(普段利用している GCE そのもの)
    • この VM 内部で KVM / QEMU を使用して L0 とは別のハイパバイザを利用できる
  • Level 2(L2)
    • ユーザが L1 VM 内で実行するさらに別の VM(L1 ホストのゲストマシン)
    • L1 VM 内のハイパバイザで仮想マシンを実行する
[Google Physical Host]      ← L0
    └── [Compute Engine VM] ← L1
            └── [Nested VM] ← L2

Google Cloud が保有する L0 の物理マシンには、Intel VT-x が仮想化支援機能として搭載されており、GCE の Nested V12n 機能を有効化することで L1 VM から利用できるようになります。 なお、現時点では L0 のチップは AMD-v には対応していないようです。

GCE で Nested V12n を利用するには、VM インスタンスを作成する際に --enable-nested-virtualization フラグを渡します。

$ gcloud compute instances create [GCE_VM_NAME] --enable-nested-virtualization

パフォーマンスに関する考慮事項

ハードウェア支援による Nested V12n の場合でも、ネストされた L2 VM では、

  1. CPU にバインドされたワークロードのパフォーマンスが 10% 以上低下する可能性がある
  2. 入出力にバインドされたワークロードでは 10% を超える低下が発生する可能性がある

という点に注意が必要です。

利用に関する制限事項

L1 VM には次の制限があります。

L1 VM でサポートされるハイパバイザは Linux KVM のみで、Microsoft Hyper-V はサポートされていません。

また、L1 VM(GCE)本体のマシンタイプとして、以下のシリーズは Nested V12n を利用できません。

L2 VM には次の制限があります。

ライセンスが必要な OS を L2 VM で実行する場合は、独自のライセンスを用意する必要があり、BYOL(Bring Your Own License) となります。

例えば、Windows Server 2019 の場合、L1 VM では Google Cloud 経由でライセンス提供(課金込み)されますが、L2 VM で実行する場合は、独自にライセンスを取得する必要があります。

仮想マシンのネットワークトポロジ

オンプレミスネットワーク

KVM におけるデフォルトのネットワーク構成は NAT Mode となるため、ホストマシンとゲストマシンは NAT を介して通信します。

kvm-default-network-architecture.png

この場合、ホストマシンが属する LAN 内のプライベート IP アドレスを用いて直接通信することはできません。

ゲストマシンに LAN 内のプライベート IP アドレスを割り当てる場合、Macvlan という Linux カーネルの機能を用います。 Macvlan を使用するとホストマシンとゲストマシンの間で L2 レベルの疎通を取ることが可能となり、これによってゲストマシンはホストマシンと同一 LAN に接続することができます。

Macvlan の Bridge Mode は、ネットワークブリッジを利用して ホストマシンの物理 NIC に複数の MAC アドレスを独立して追加 します。 通常、NIC は 1 つの MAC アドレスしか持つことができませんが、macvlan インターフェースを作成することで、複数 MAC アドレスを同一の NIC で扱えるようになります。

また、ホストマシン上に構築されたゲストマシンは TAP(Terminal Access Point) と呼ばれる、イーサネットデバイスをシミュレートするトンネルインターフェースを通じて macvlan に接続します。

※ KVM の仮想ネットワーク構成の全体像ついては こちら の記事が参考になります。

Ubuntu の場合、以下のようなスクリプトで 物理 NIC(enp3s0)に紐付く IP アドレスを macvlan(macvlan0)に割り当て直すことができます。参考

#!/bin/sh

HOST_INTERFACE_NAME=enp3s0
HOST_IPV4_ADDR=10.10.0.105/20
DEFAULT_GATEWAY_ADDR=10.10.15.254
BROADCAST_ADDR=10.10.15.255
MACVLAN_NAME=macvlan0

### 1. macvlan I/F を追加して物理 NIC をブリッジ
ip link add dev ${MACVLAN_NAME} link ${HOST_INTERFACE_NAME} type macvlan mode bridge

### 2. 元の物理 NIC から IP アドレスを削除
ip addr del ${HOST_IPV4_ADDR} dev ${HOST_INTERFACE_NAME}

### 3. 物理 NIC に紐付いていた元の IP アドレスを macvlan I/F に付け替える
ip addr add ${HOST_IPV4_ADDR} broadcast ${BROADCAST_ADDR} dev ${MACVLAN_NAME}

### 4. macvlan I/F を有効化
ip link set ${MACVLAN_NAME} up

### 5. 物理 NIC のルート情報をすべて削除
ip route flush dev ${HOST_INTERFACE_NAME}

### 6. デフォルトルートを macvlan I/F に向ける
ip route add default via ${DEFAULT_GATEWAY_ADDR} dev ${MACVLAN_NAME} proto static

### 7. NetworkManager に反映
systemctl restart NetworkManager.service

libvirt で起動した仮想マシンは、TAP として macvtap を生成することでゲストマシンが独自の MAC アドレスと IP アドレスを持ち、L2 レベルでホストマシンと同一 LAN に参加できるようになります。

ホストマシン

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
### ホストマシンの物理 NIC
2: enp3s0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000
    link/ether a8:a1:59:85:11:4a brd ff:ff:ff:ff:ff:ff
    inet6 fe80::aaa1:59ff:fe85:114a/64 scope link
       valid_lft forever preferred_lft forever
### libvirt のデフォルトの仮想ブリッジ
3: virbr0: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc noqueue state DOWN group default qlen 1000
    link/ether 52:54:00:48:72:53 brd ff:ff:ff:ff:ff:ff
    inet 192.168.122.1/24 brd 192.168.122.255 scope global virbr0
       valid_lft forever preferred_lft forever
### 今回作成されたゲスト VM 用の仮想ブリッジ(192.168.121.0/24 の VM 間ローカルネットワーク)
4: virbr1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000
    link/ether 52:54:00:40:cd:eb brd ff:ff:ff:ff:ff:ff
    inet 192.168.121.1/24 brd 192.168.121.255 scope global virbr1
       valid_lft forever preferred_lft forever
### ホストマシンの macvlan I/F(enp3s0 をブリッジ)
5: macvlan0@enp3s0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000
    link/ether f6:a4:58:20:4e:08 brd ff:ff:ff:ff:ff:ff
    inet 10.10.0.105/20 brd 10.10.15.255 scope global macvlan0
       valid_lft forever preferred_lft forever
### ゲストマシン1 が利用する macvtap I/F
6: macvtap0@enp3s0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 500
    link/ether 52:54:00:13:c7:c0 brd ff:ff:ff:ff:ff:ff
    inet6 fe80::5054:ff:fe13:c7c0/64 scope link
       valid_lft forever preferred_lft forever
### ゲストマシン1 が virbr1 に接続するための仮想ブリッジ
7: vnet0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master virbr1 state UNKNOWN group default qlen 1000
    link/ether fe:54:00:29:6c:6b brd ff:ff:ff:ff:ff:ff
### ゲストマシン2 が利用する macvtap I/F
8: macvtap1@enp3s0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 500
    link/ether 52:54:00:cb:66:17 brd ff:ff:ff:ff:ff:ff
    inet6 fe80::5054:ff:fecb:6617/64 scope link
       valid_lft forever preferred_lft forever
### ゲストマシン2 が virbr1 に接続するための仮想ブリッジ
9: vnet1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master virbr1 state UNKNOWN group default qlen 1000
    link/ether fe:54:00:ac:a3:37 brd ff:ff:ff:ff:ff:ff
### ブリッジリスト
$ brctl show
bridge name	bridge id		STP enabled	interfaces
virbr0		8000.525400487253	yes
virbr1		8000.52540040cdeb	yes		vnet0
							            vnet1

### server01 の仮想 NIC
$ virsh domiflist ren510dev_server01
 Interface   Type      Source            Model    MAC
---------------------------------------------------------------------
  vnet0      network   vagrant-libvirt   virtio   52:54:00:29:6c:6b
 macvtap0    direct    enp3s0            virtio   52:54:00:13:c7:c0

### server02 の仮想 NIC
$ virsh domiflist ren510dev_server02
 Interface   Type      Source            Model    MAC
---------------------------------------------------------------------
  vnet1      network   vagrant-libvirt   virtio   52:54:00:ac:a3:37
 macvtap1    direct    enp3s0            virtio   52:54:00:cb:66:17
NICMAC アドレス接続先
lo00:00:00:00:00:00無し
enp3s0a8:a1:59:85:11:4a無し
(ホストマシンの物理 NIC)
virbr052:54:00:48:72:53ホストマシンのデフォルトゲートウェイに NAT 接続
(libvirt のデフォルトネットワーク)
virbr152:54:00:40:cd:ebホストマシンのデフォルトゲートウェイに NAT 接続
(ゲスト VM 用の仮想ネットワーク)
macvlan0f6:a4:58:20:4e:08デフォルトゲートウェイ
enp3s0 をブリッジ)
macvtap052:54:00:13:c7:c0macvlan0
vnet0fe:54:00:29:6c:6bvirbr1
macvtap152:54:00:cb:66:17macvlan0
vnet1fe:54:00:ac:a3:37virbr1

ゲストマシン

  • ゲストマシン 1
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
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000
    link/ether 52:54:00:29:6c:6b brd ff:ff:ff:ff:ff:ff
    altname enp0s5
    altname ens5
    inet 192.168.121.83/24 metric 100 brd 192.168.121.255 scope global dynamic eth0
       valid_lft 2436sec preferred_lft 2436sec
    inet6 fe80::5054:ff:fe29:6c6b/64 scope link
       valid_lft forever preferred_lft forever
3: eth1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000
    link/ether 52:54:00:13:c7:c0 brd ff:ff:ff:ff:ff:ff
    altname enp0s6
    altname ens6
    inet 10.10.10.110/20 brd 10.10.15.255 scope global eth1
       valid_lft forever preferred_lft forever
    inet6 fe80::5054:ff:fe13:c7c0/64 scope link
       valid_lft forever preferred_lft forever
NICMAC アドレス接続先
lo00:00:00:00:00:00無し
eth052:54:00:29:6c:6bvnet0
eth152:54:00:13:c7:c0macvtap0
macvlan0 の TAP)
  • ゲストマシン 2
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
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000
    link/ether 52:54:00:ac:a3:37 brd ff:ff:ff:ff:ff:ff
    altname enp0s5
    altname ens5
    inet 192.168.121.145/24 metric 100 brd 192.168.121.255 scope global dynamic eth0
       valid_lft 2413sec preferred_lft 2413sec
    inet6 fe80::5054:ff:feac:a337/64 scope link
       valid_lft forever preferred_lft forever
3: eth1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000
    link/ether 52:54:00:cb:66:17 brd ff:ff:ff:ff:ff:ff
    altname enp0s6
    altname ens6
    inet 10.10.10.111/20 brd 10.10.15.255 scope global eth1
       valid_lft forever preferred_lft forever
    inet6 fe80::5054:ff:fecb:6617/64 scope link
       valid_lft forever preferred_lft forever
NICMAC アドレス接続先
lo00:00:00:00:00:00無し
eth052:54:00:ac:a3:37vnet1
eth152:54:00:cb:66:17macvtap1
macvlan0 の TAP)

ネットワーク構成

上の接続関係を図にまとめると以下のようになります。

kvm-on-premises-network.png
参考
# frozen_string_literal: true

# -*- mode: ruby -*-
# vi: set ft=ruby :

Vagrant.configure('2') do |config|
  config.vm.boot_timeout = 600

  config.vm.define :server01 do |server01|
    define_machine_spec server01, '8', '32768'
    server01.vm.hostname = 'server01'
    server01.vm.box = 'generic/ubuntu2204'
    server01.vm.network :public_network,
                        ip: '10.10.10.110',
                        netmask: '255.255.240.0',
                        bridge: 'enp3s0',
                        dev: 'enp3s0'
  end

  config.vm.define :server02 do |server02|
    define_machine_spec server02, '8', '24576'
    server02.vm.hostname = 'server02'
    server02.vm.box = 'generic/ubuntu2204'
    server02.vm.network :public_network,
                        ip: '10.10.10.111',
                        netmask: '255.255.240.0',
                        bridge: 'enp3s0',
                        dev: 'enp3s0'
  end

  ## Define macros
  def define_machine_spec(name, use_cpu, use_memory)
    name.vm.provider :libvirt do |libvirt|
      libvirt.driver = 'qemu'
      libvirt.cpus = use_cpu
      libvirt.memory = use_memory
    end
  end
end

赤系統のネットワークは NetworkManager が管理していて、青系統のネットワークは libvirt が管理するデフォルトの仮想ネットワークです。

スクリプトで作成される macvlan0 はホストマシンの物理 NIC(enp3s0)をブリッジする形で LAN 内の他のマシンと同一のネットワークに接続されます。

また、ゲストマシンは TAP デバイス(macvtap0 / macvtap1)を通じてホストマシンの macvlan0 に接続し、最終的に enp3s0 をブリッジすることで、独立した個別のマシンとして LAN に直接参加することができます。

疎通確認

  • ICMP
### ゲストマシン1(10.10.10.110)→ ゲストマシン2(10.10.10.111)
$ ping -c 3 10.10.10.111
PING 10.10.10.111 (10.10.10.111) 56(84) bytes of data.
64 bytes from 10.10.10.111: icmp_seq=1 ttl=64 time=1.79 ms
64 bytes from 10.10.10.111: icmp_seq=2 ttl=64 time=1.53 ms
64 bytes from 10.10.10.111: icmp_seq=3 ttl=64 time=1.62 ms

--- 10.10.10.111 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2004ms
rtt min/avg/max/mdev = 1.534/1.649/1.794/0.108 ms

### ゲストマシン1(10.10.10.110)→ ホストマシン(10.10.0.105)
$ ping -c 3 10.10.0.105
PING 10.10.0.105 (10.10.0.105) 56(84) bytes of data.
64 bytes from 10.10.0.105: icmp_seq=1 ttl=64 time=0.269 ms
64 bytes from 10.10.0.105: icmp_seq=2 ttl=64 time=0.799 ms
64 bytes from 10.10.0.105: icmp_seq=3 ttl=64 time=1.10 ms

--- 10.10.0.105 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2005ms
rtt min/avg/max/mdev = 0.269/0.722/1.100/0.343 ms

### ゲストマシン1(10.10.10.110)→ LAN 内の他のマシン(10.10.0.106)
$ ping -c 3 10.10.0.106
PING 10.10.0.106 (10.10.0.106) 56(84) bytes of data.
64 bytes from 10.10.0.106: icmp_seq=1 ttl=64 time=0.496 ms
64 bytes from 10.10.0.106: icmp_seq=2 ttl=64 time=1.17 ms
64 bytes from 10.10.0.106: icmp_seq=3 ttl=64 time=1.18 ms

--- 10.10.0.106 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2006ms
rtt min/avg/max/mdev = 0.496/0.949/1.184/0.320 ms
  • マッピング情報
### ゲストマシン1 の ARP テーブル
$ arp -na

? (10.10.0.105) at f6:a4:58:20:4e:08 [ether] on eth1  ## ホストマシン macvlan0
? (10.10.0.106) at a8:a1:59:85:13:4c [ether] on eth1  ## LAN 内の他のマシンの物値 NIC
? (10.10.10.111) at 52:54:00:cb:66:17 [ether] on eth1 ## ホストマシン macvtap1(ゲストマシン2 の eth1)

では、Google Cloud でも VPC サブネットの IP アドレスを直接、ゲストマシン(L2 VM)に割り当てることができるのか。

結論、できません。

これは Google Cloud VPC の仕様そのものに起因しており、GCE では基本的に L3 より下のレイヤで行われる通信をハンドリングすることができない ためです。

Google Cloud VPC の制約

Google Cloud の VPC は、物理的なネットワークではなく Google が独自に実装した Andromeda と呼ばれる SDN(Software Defined Networking) 上に構築されており、次のような制約があります。

L2 の非透過性

VPC networks do not support broadcast or multicast addresses within the network.

VPC は L3(IP レイヤの通信)ベースで設計されており、ARP や DHCP の L2 ブロードキャストリクエストが通りません。

そのため、L1 VM のハイパバイザが L2 VM に仮想 NIC(macvtap, bridge 等)をアタッチしても、VPC 内の DHCP サーバ(または相当の機能を担うもの)や、他のホストは L2 VM が持つ MAC アドレスを認識することができません。

IP アドレスの偽装防止

By default, IP forwarding is disabled, and Google Cloud performs strict source address checking.

The next-hop-address of a static route must match an IP address that is assigned to a VM in the route's VPC network.

The anti-spoofing checks verify that VMs do not send traffic with arbitrary source IP addresses.

Google Cloud では Source Address Spoofing Guard(送信元アドレス偽造防止)という仕組みによって、仮想マシンのトラフィックが VM の仮想 NIC に割り当てられた IP アドレスから送信されていることを常に検証しています。

つまり、その IP を使って良いのは、その NIC に紐付いたマシンのみ (1 IP アドレスにつき 1 MAC アドレス) という前提があるため、例えば、ある VM の eth010.10.10.110/32 というアドレスが割り当てられている場合、この VM がこれ以外の IP アドレスを送信元としてパケットを送信しようとすると VPC 内でドロップされます。

Alias IP による L2 VM の透過性

Google Cloud の VPC ネットワークには前述のような制約があるため、L2 VM に直接サブネット内のプライベート IP アドレスを割り当てることができません。

では、どのようにサブネットの IP アドレスを L2 VM に割り当てるかというと、Alias IP という Google Cloud が用意している独自の仕組み利用します。

Alias IP を使用すると、L1 VM が属するサブネット内のプライベート IP アドレスを L2 VM の NIC に 透過的に割り当てる ことができます。 ポイントは "透過的" という部分で、直接 L2 VM の NIC が L1 VM と同じサブネット帯域のプライベート IP アドレスを持つのではなく、L1 VM がその IP アドレスをエイリアスとして持ち、L2 VM への通信を透過的に転送(IP Fowarding)するという仕組みで管理します。

まとめると、

  1. L2 VM にはローカル IP(例:172.15.7.0/24)を設定
  2. L1 VM に Alias IP を追加
  3. L1 VM の netfilter(iptables) で DNAT して L2 VM へパケットを転送(後方通信の場合は SNAT)

という流れになります。

ip-alias-fowarding.png

つまり、L2 VM は 直接 VPC サブネットのプライベート IP アドレスを持ちません。 Alias IP を用いて L2 VM に対する通信を L1 VM で受け取り、iptables でマスカレードすることでパケットを橋渡しします。

これによって VPC 内の他のマシンからは、あたかも L2 VM が直接 VPC サブネットにぶら下がっているように見える わけです。

Nested V12n で L2 VM を構築する

Google Cloud で KVM を扱う際の注意点を抑えたところで、実際に Nested V12n を利用して GCE 上に L2 VM を構築してみます。

VPC の準備

VM を作成する前に、まずは VPC ネットワークを構築します。

今回は、東京リージョンに VPC を用意し、サブネット帯域は 10.10.0.0/20 とします。

Terraform
module "vpc" {
  source  = "terraform-google-modules/network/google"
  version = "11.0.0"

  project_id   = "ren510dev"
  network_name = "vagrant-playground-vpc"
  routing_mode = "GLOBAL"

  subnets = [
    {
      subnet_name           = "vagrant-playground-asia-northeast1-01"
      subnet_ip             = "10.10.0.0/20"
      subnet_region         = "asia-northeast1"
      description           = "VPC subnet for Vagrant Playground"
      subnet_private_access = "true"
      subnet_flow_logs      = "true"
    },
  ]

  firewall_rules = [
    {
      name        = "${module.vpc.network_name}-allow-internal"
      description = "Allow internal traffic"
      direction   = "INGRESS"
      allow = [
        {
          protocol = "icmp"
        },
        {
          protocol = "tcp"
          ports    = ["1-65535"]
        },
        {
          protocol = "udp"
          ports    = ["1-65535"]
        }
      ]
      ranges = module.vpc.subnets_ips
    },
    {
      name        = "${module.vpc.network_name}-allow-iap-ssh"
      description = "Allow IAP SSH traffic"
      direction   = "INGRESS"
      allow = [
        {
          protocol = "tcp"
          ports    = ["22", "3389"]
        },
        {
          protocol = "icmp"
        }
      ]
      ranges = ["35.235.240.0/20"] # https://cloud.google.com/iap/docs/using-tcp-forwarding#create-firewall-rule
    }
  ]
}
vpc-config-01.png

L1 VM の構築

サーバ

次に VPC 上に Nested V12n が有効化された L1 VM、つまり GCE インスタンスそのものを構築します。

IP アドレスは次のように割り当てることにします。

  • ホスト名:vagrant-playground-instance-01
割り当てIP アドレス概要
Primary IP10.10.10.10/32GCE 本体(L1 VM)の IP アドレス
Alias IP10.10.10.110/32L2 VM 1(server01)の IP アドレス
Alias IP10.10.10.111/32L2 VM 2(server02)の IP アドレス
Terraform
resource "google_service_account" "server" {
  account_id   = "vagrant-playground-server"
  display_name = "vagrant-playground-server"
  description  = "GCE Service Account for vagrant-playground-server"
}

resource "google_compute_instance" "server" {
  name         = "vagrant-playground-instance-01"
  description  = "Vagrant Playground Instance 01"
  machine_type = "n2-standard-32" ## 16core / 32thread / 128 GB
  zone         = "asia-northeast1-a"
  tags = [
    "vagrant-playground-instance-01",
  ]
  can_ip_forward = true ## IP フォワーディングを有効化

  advanced_machine_features {
    enable_nested_virtualization = true ## Nested virtualization を有効化
  }

  boot_disk {
    auto_delete = true

    initialize_params {
      image = "ubuntu-os-cloud/ubuntu-2204-lts" ## Ubuntu 22.04 LTS
      size  = 500 ## 500 GB
      type  = "pd-balanced"
    }
  }

  network_interface {
    network    = "vagrant-playground-vpc"
    subnetwork = "vagrant-playground-asia-northeast1-01"
    network_ip = "10.10.10.10"

    alias_ip_range {
      ip_cidr_range         = "10.10.10.110/32" ## server01 の IP アドレス
      subnetwork_range_name = null
    }
    alias_ip_range {
      ip_cidr_range         = "10.10.10.111/32" ## server02 の IP アドレス
      subnetwork_range_name = null
    }

    access_config {
      nat_ip                 = null
      public_ptr_domain_name = null
      network_tier           = "PREMIUM"
    }
  }

  service_account {
    email = google_service_account.server.email
    scopes = [
      "https://www.googleapis.com/auth/devstorage.read_only",
      "https://www.googleapis.com/auth/logging.write",
      "https://www.googleapis.com/auth/monitoring.write",
      "https://www.googleapis.com/auth/service.management.readonly",
      "https://www.googleapis.com/auth/servicecontrol",
      "https://www.googleapis.com/auth/trace.append"
    ]
  }

  depends_on = [
    google_service_account.server
  ]
}
gce-config-01.png

Nested V12n が有効化されているかを確認します。

$ gcloud compute instances describe vagrant-playground-instance-01 \
    --zone=asia-northeast1-a \
    --format="yaml(advancedMachineFeatures.enableNestedVirtualization)"
advancedMachineFeatures:
  enableNestedVirtualization: true

L1 VM の Alias IP が正しく設定されているかを確認します。

$ gcloud compute instances describe vagrant-playground-instance-01 \
    --zone=asia-northeast1-a \
    --format="yaml(networkInterfaces[].aliasIpRanges)"
networkInterfaces:
  - aliasIpRanges:
      - ipCidrRange: 10.10.10.110/32
      - ipCidrRange: 10.10.10.111/32

以下のコマンドで実際に L1 VM がハードウェア仮想化支援機能をサポートしているかを確認します。 Google Cloud の L0 VM は Intel VT-x のみのサポートとなるため、CPU フラグの vmx を確認します。

### CPU フラグが 1 以上であることを確認
$ egrep -c 'vmx' /proc/cpuinfo
64

コマンド出力から、L1 VM の論理 CPU 64 コア分に VT-x が搭載されており、カーネルがそれを認識していることが分かります。

また、KVM を利用するためのカーネルモジュールは以下のコマンドで確認できます。

$ ls -l /dev/kvm
crw-rw---- 1 root kvm 10, 232 Nov  4 06:05 /dev/kvm

クライアント

上で構築した、L2 VM に対するネットワーク疎通を検証するために、VPC サブネット内に別の GCE を配置しておきます。

  • ホスト名:vagrant-playground-instance-02
割り当てIP アドレス概要
Primary IP10.10.10.15/32GCE 本体(L1 VM)の IP アドレス
Terraform
resource "google_service_account" "client" {
  account_id   = "vagrant-playground-client"
  display_name = "vagrant-playground-client"
  description  = "GCE Service Account for vagrant-playground-client"
}

resource "google_compute_instance" "client" {
  name         = "vagrant-playground-instance-02"
  description  = "Vagrant Playground Instance 02"
  machine_type = "e2-micro" # 安価なもので良い
  zone         = "asia-northeast1-a"
  tags = [
    "vagrant-playground-instance-02",
  ]
  can_ip_forward = false

  boot_disk {
    auto_delete = true

    initialize_params {
      image = "ubuntu-os-cloud/ubuntu-2204-lts"
      size  = 500
      type  = "pd-balanced"
    }
  }

  network_interface {
    network    = "vagrant-playground-vpc"
    subnetwork = "vagrant-playground-asia-northeast1-01"
    network_ip = "10.10.10.15"

    access_config {
      nat_ip                 = null
      public_ptr_domain_name = null
      network_tier           = "PREMIUM"
    }
  }

  service_account {
    email = google_service_account.client.email
    scopes = [
      "https://www.googleapis.com/auth/devstorage.read_only",
      "https://www.googleapis.com/auth/logging.write",
      "https://www.googleapis.com/auth/monitoring.write",
      "https://www.googleapis.com/auth/service.management.readonly",
      "https://www.googleapis.com/auth/servicecontrol",
      "https://www.googleapis.com/auth/trace.append"
    ]
  }

  depends_on = [
    google_service_account.client
  ]
}
gce-config-02.png

スペックの確認

L1 VM のマシンスペックを確認してみます。

#!/bin/bash

P_CPU=$(fgrep 'physical id' /proc/cpuinfo | sort -u | wc -l)
CORES=$(fgrep 'cpu cores' /proc/cpuinfo | sort -u | sed 's/.*: //')
L_PRC=$(fgrep 'processor' /proc/cpuinfo | wc -l)
H_TRD=$((L_PRC / P_CPU / CORES))

echo -n "${L_PRC} processer"
[ "${L_PRC}" -ne 1 ] && echo -n "s"
echo -n " = ${P_CPU} socket"
[ "${P_CPU}" -ne 1 ] && echo -n "s"
echo -n " x ${CORES} core"
[ "${CORES}" -ne 1 ] && echo -n "s"
echo -n " x ${H_TRD} thread"
[ "${H_TRD}" -ne 1 ] && echo -n "s"
echo
32 processers = 2 sockets x 8 cores x 2 threads
$ free -h
               total        used        free      shared  buff/cache   available
Mem:           125Gi       788Mi       124Gi       1.0Mi       552Mi       124Gi
Swap:             0B          0B          0B

今回使用している n2-standard-32 タイプは 16 コア 32 スレッド 128GB RAM なので、問題無く起動していることが分かります。

$ df -h
Filesystem      Size  Used Avail Use% Mounted on
/dev/root       485G  2.2G  483G   1% /
tmpfs            63G     0   63G   0% /dev/shm
tmpfs            26G  1.1M   26G   1% /run
tmpfs           5.0M     0  5.0M   0% /run/lock
efivarfs         56K   24K   27K  48% /sys/firmware/efi/efivars
/dev/sda15      105M  6.1M   99M   6% /boot/efi
tmpfs            13G  4.0K   13G   1% /run/user/1014

ストレージには 500 GB の pd-balanced タイプを使用しています。

$ ip a
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: ens4: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1460 qdisc mq state UP group default qlen 1000
    link/ether 42:01:0a:0a:0a:0a brd ff:ff:ff:ff:ff:ff
    inet 10.10.10.10/32 metric 100 scope global dynamic ens4
       valid_lft 85657sec preferred_lft 85657sec
    inet6 fe80::4001:aff:fe0a:a0a/64 scope link
       valid_lft forever preferred_lft forever

L1 VM の NIC(ens4)に 10.10.10.10/32 がプライマリ IP アドレスとして割り当てられていることが分かります。

一方で、Alias IP は L1 VM 内からは直接確認することができません。 これは、Alias IP が Google Cloud 側で管理する VPC ネットワークレイヤに SDN でルーティング可能な IP としてマッピングされているだけであり、OS 側には明示的に付与されていないためです。

L2 VM の構築

続いて、L1 VM 上に L2 VM を構築します。 L2 VM の構築には Vagrant を使用します。

vagrant.png

Vagrant はハイパバイザ(KVM)で仮想環境を構築するためのツールで、Vagrantfile による IaC でゲストマシンを管理できます。 Vagrant はそれ単体で動作するわけではなく、libvirt API 等の抽象化レイヤを挟み、最終的に QEMU を呼び出して仮想マシンを制御します。

kvm-architecture.png

クライアントが Vagrant、バックエンドが libvirt、インフラが QEMU といったイメージです。

Vagrant の開発元は HashiCorp ですが、Terraform のようにクラウドプロバイダを抽象化しているわけではなく、あくまでローカルの仮想環境を構築するためのツールとなっています。

  1. L2 VM の構築に必要なパッケージをインストール
$ sudo apt update && sudo apt upgrade -y
$ sudo apt install -y qemu-kvm libvirt-daemon-system libvirt-clients bridge-utils virtinst vagrant vagrant-libvirt uml-utilities net-tools
  1. libvirt グループに一般ユーザを追加

インストール時点では libvirt がルート権限でシステムサービスとして動作しており、仮想マシンの起動・停止、NIC の管理等を担っています。 そのため、一般ユーザが libvirt を操作するためには libvirt グループに追加してあげる必要があります。

### libvirt グループへユーザを追加
$ sudo usermod -aG libvirt $(whoami)

### グループ変更を反映
$ newgrp libvirt
  1. プラグインのインストール

vagrant-libvirt プラグインを使用することで、Vagrant から libvirt を呼び出せるようになります。

$ vagrant plugin install vagrant-libvirt
Installing the 'vagrant-libvirt' plugin. This can take a few minutes...
Installed the plugin 'vagrant-libvirt (0.7.0)'!
  1. 仮想マシンの起動

以下の Vagrantfile から L2 VM を起動します。

Vagrantfile
# frozen_string_literal: true

# -*- mode: ruby -*-
# vi: set ft=ruby :

Vagrant.configure('2') do |config|
  config.vm.boot_timeout = 600

  config.vm.define :server01 do |server01|
    define_machine_spec server01, '8', '32768'
    server01.vm.hostname = 'server01'
    server01.vm.box = 'generic/ubuntu2204'
    server01.vm.network :private_network,
                        ip: '172.15.7.11',
                        netmask: '255.255.255.0',
                        auto_config: true
  end

  config.vm.define :server02 do |server02|
    define_machine_spec server02, '8', '24576'
    server02.vm.hostname = 'server02'
    server02.vm.box = 'generic/ubuntu2204'
    server02.vm.network :private_network,
                        ip: '172.15.7.12',
                        netmask: '255.255.255.0',
                        auto_config: true
  end

  ## Define macros
  def define_machine_spec(name, use_cpu, use_memory)
    name.vm.provider :libvirt do |libvirt|
      libvirt.driver = 'qemu'
      libvirt.cpus = use_cpu
      libvirt.memory = use_memory
    end
  end
end

今回はゲストマシンに Ubuntu 22.04 を使用します。

また、L2 VM と L1 VM 間のプライベートネットワークは 172.15.7.0/24 としています。

L2 VMIP アドレス
server01172.15.7.11
server02172.15.7.12
$ vagrant up
Bringing machine 'server01' up with 'libvirt' provider...
Bringing machine 'server02' up with 'libvirt' provider...
==> server01: Box 'generic/ubuntu2204' could not be found. Attempting to find and install...
    server01: Box Provider: libvirt
    server01: Box Version: >= 0
==> server01: Loading metadata for box 'generic/ubuntu2204'
    server01: URL: https://vagrantcloud.com/generic/ubuntu2204
==> server01: Adding box 'generic/ubuntu2204' (v4.3.12) for provider: libvirt
    server01: Downloading: https://vagrantcloud.com/generic/boxes/ubuntu2204/versions/4.3.12/providers/libvirt/amd64/vagrant.box
    server01: Calculating and comparing box checksum...
==> server01: Successfully added box 'generic/ubuntu2204' (v4.3.12) for 'libvirt'!
==> server02: Box 'generic/ubuntu2204' could not be found. Attempting to find and install...
    server02: Box Provider: libvirt
    server02: Box Version: >= 0
==> server01: Uploading base box image as volume into Libvirt storage...
==> server02: Loading metadata for box 'generic/ubuntu2204'
Progress: 41%    server02: URL: https://vagrantcloud.com/generic/ubuntu2204
...
...
...
    server01:
    server01: Vagrant insecure key detected. Vagrant will automatically replace
    server01: this with a newly generated keypair for better security.
    server01:
    server01: Inserting generated public key within guest...
    server01: Removing insecure key from the guest if it's present...
    server01: Key inserted! Disconnecting and reconnecting using new SSH key...
==> server01: Machine booted and ready!
==> server01: Setting hostname...
    server02:
    server02: Vagrant insecure key detected. Vagrant will automatically replace
    server02: this with a newly generated keypair for better security.
==> server01: Configuring and enabling network interfaces...
    server02:
    server02: Inserting generated public key within guest...
    server02: Removing insecure key from the guest if it's present...
    server02: Key inserted! Disconnecting and reconnecting using new SSH key...
==> server02: Machine booted and ready!
==> server02: Setting hostname...
==> server02: Configuring and enabling network interfaces...
$ vagrant status
Current machine states:

server01                  running (libvirt)
server02                  running (libvirt)

This environment represents multiple VMs. The VMs are all listed
above with their current state. For more information about a specific
VM, run `vagrant status NAME`.

5 分程度で構築が完了しました。

構成確認

各マシンのネットワークインターフェースを確認してみます。

  • ホストマシン
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
### ホストマシンの仮想 NIC
2: ens4: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1460 qdisc mq state UP group default qlen 1000
    link/ether 42:01:0a:0a:0a:0a brd ff:ff:ff:ff:ff:ff
    inet 10.10.10.10/32 metric 100 scope global dynamic ens4
       valid_lft 85657sec preferred_lft 85657sec
    inet6 fe80::4001:aff:fe0a:a0a/64 scope link
       valid_lft forever preferred_lft forever
### libvirt のデフォルトの仮想ブリッジ
3: virbr0: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc noqueue state DOWN group default qlen 1000
    link/ether 52:54:00:01:8a:56 brd ff:ff:ff:ff:ff:ff
    inet 192.168.122.1/24 brd 192.168.122.255 scope global virbr0
       valid_lft forever preferred_lft forever
### 今回作成されたゲスト VM 用の仮想ブリッジ(192.168.121.0/24 の VM 間ローカルネットワーク)
4: virbr1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000
    link/ether 52:54:00:4a:fd:84 brd ff:ff:ff:ff:ff:ff
    inet 192.168.121.1/24 brd 192.168.121.255 scope global virbr1
       valid_lft forever preferred_lft forever
### 今回作成されたゲスト VM 用の仮想ブリッジ(172.15.7.0/24 の VM 間ローカルネットワーク)
5: virbr2: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000
    link/ether 52:54:00:30:90:11 brd ff:ff:ff:ff:ff:ff
    inet 172.15.7.1/24 brd 172.15.7.255 scope global virbr2
       valid_lft forever preferred_lft forever
### server01 が virbr1 に接続するための仮想ブリッジ
6: vnet2: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master virbr1 state UNKNOWN group default qlen 1000
    link/ether fe:54:00:ad:eb:39 brd ff:ff:ff:ff:ff:ff
    inet6 fe80::fc54:ff:fead:eb39/64 scope link
       valid_lft forever preferred_lft forever
### server01 が virbr2 に接続するための仮想ブリッジ
7: vnet3: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master virbr2 state UNKNOWN group default qlen 1000
    link/ether fe:54:00:53:ac:f2 brd ff:ff:ff:ff:ff:ff
    inet6 fe80::fc54:ff:fe53:acf2/64 scope link
       valid_lft forever preferred_lft forever
### server02 が virbr1 に接続するための仮想ブリッジ
8: vnet0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master virbr1 state UNKNOWN group default qlen 1000
    link/ether fe:54:00:72:ff:31 brd ff:ff:ff:ff:ff:ff
    inet6 fe80::fc54:ff:fe72:ff31/64 scope link
       valid_lft forever preferred_lft forever
### server02 が virbr2 に接続するための仮想ブリッジ
9: vnet1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master virbr2 state UNKNOWN group default qlen 1000
    link/ether fe:54:00:8a:e8:35 brd ff:ff:ff:ff:ff:ff
    inet6 fe80::fc54:ff:fe8a:e835/64 scope link
       valid_lft forever preferred_lft forever
### ブリッジリスト
$ brctl show
bridge name     bridge id               STP enabled     interfaces
virbr0          8000.525400018a56       yes
virbr1          8000.5254004afd84       yes             vnet0
                                                        vnet2
virbr2          8000.525400309011       yes             vnet1
                                                        vnet3

### server01 の仮想 NIC
$ virsh domiflist ren510dev_server01
 Interface   Type      Source            Model    MAC
---------------------------------------------------------------------
 vnet2       network   vagrant-libvirt   virtio   52:54:00:ad:eb:39
 vnet3       network   ren510dev0        virtio   52:54:00:53:ac:f2

### server02 の仮想 NIC
$ virsh domiflist ren510dev_server02
 Interface   Type      Source            Model    MAC
---------------------------------------------------------------------
 vnet0       network   vagrant-libvirt   virtio   52:54:00:72:ff:31
 vnet1       network   ren510dev0        virtio   52:54:00:8a:e8:35
NICMAC アドレス接続先
lo00:00:00:00:00:00無し
ens442:01:0a:0a:0a:0a無し
(ホストマシンの仮想 NIC)
virbr052:54:00:01:8a:56ホストマシンのデフォルトゲートウェイに NAT 接続
(libvirt のデフォルトネットワーク)
virbr152:54:00:4a:fd:84ホストマシンのデフォルトゲートウェイに NAT 接続
(ゲスト VM 用の仮想ネットワーク)
virbr252:54:00:30:90:11ホストマシンのデフォルトゲートウェイに NAT 接続
(ゲスト VM 用の仮想ネットワーク)
vnet2fe:54:00:ad:eb:39virbr1
vnet3fe:54:00:53:ac:f2virbr2
vnet0fe:54:00:72:ff:31virbr1
vnet1fe:54:00:8a:e8:35virbr2
  • L2 VM(server01)
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
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000
    link/ether 52:54:00:ad:eb:39 brd ff:ff:ff:ff:ff:ff
    altname enp0s5
    altname ens5
    inet 192.168.121.46/24 metric 100 brd 192.168.121.255 scope global dynamic eth0
       valid_lft 3214sec preferred_lft 3214sec
    inet6 fe80::5054:ff:fead:eb39/64 scope link
       valid_lft forever preferred_lft forever
3: eth1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000
    link/ether 52:54:00:53:ac:f2 brd ff:ff:ff:ff:ff:ff
    altname enp0s6
    altname ens6
    inet 172.15.7.11/24 brd 172.15.7.255 scope global eth1
       valid_lft forever preferred_lft forever
    inet6 fe80::5054:ff:fe53:acf2/64 scope link
       valid_lft forever preferred_lft forever
NICMAC アドレス接続先
lo00:00:00:00:00:00無し
eth052:54:00:ad:eb:39vnet2
eth152:54:00:53:ac:f2vnet3
  • L2 VM(server02)
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
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000
    link/ether 52:54:00:72:ff:31 brd ff:ff:ff:ff:ff:ff
    altname enp0s5
    altname ens5
    inet 192.168.121.107/24 metric 100 brd 192.168.121.255 scope global dynamic eth0
       valid_lft 3192sec preferred_lft 3192sec
    inet6 fe80::5054:ff:fe72:ff31/64 scope link
       valid_lft forever preferred_lft forever
3: eth1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000
    link/ether 52:54:00:8a:e8:35 brd ff:ff:ff:ff:ff:ff
    altname enp0s6
    altname ens6
    inet 172.15.7.12/24 brd 172.15.7.255 scope global eth1
       valid_lft forever preferred_lft forever
    inet6 fe80::5054:ff:fe8a:e835/64 scope link
       valid_lft forever preferred_lft forever
NICMAC アドレス接続先
lo00:00:00:00:00:00無し
eth052:54:00:72:ff:31vnet0
eth152:54:00:8a:e8:35vnet1

上の接続関係を図にまとめると以下のようになります。

kvm-vpc-network-phase-01.png

赤系統のネットワークはホストマシンとゲストマシン間のプライベートネットワークとなっていて、青系統のネットワークは libvirt が管理するデフォルトの仮想ネットワークです。 いずれも L2 VM から外部ネットワークへの通信は L1 VM 内で NAT して送り出されます。

疎通確認

L2 VM の構築は完了しましたが、この時点では L2 VM はホストマシンとの通信が 172.15.7.0/24 プライベートネットワークに閉じた状態で、L2 VM は VPC サブネットには紐付いていません。

クライアントマシン(vagrant-playground-instance-02)から L2 VM へ ICMP Request を送信すると応答が返ってきますが、これはあくまで L1 VM が返しているだけで、L2 VM が処理しているわけではありません。

### vagrant-playground-instance-02 から L2 VM(10.10.10.110)への ping は返ってくる
$ ping -c 3 10.10.10.110
PING 10.10.10.110 (10.10.10.110) 56(84) bytes of data.
64 bytes from 10.10.10.110: icmp_seq=1 ttl=64 time=2.72 ms
64 bytes from 10.10.10.110: icmp_seq=2 ttl=64 time=1.03 ms
64 bytes from 10.10.10.110: icmp_seq=3 ttl=64 time=1.08 ms

--- 10.10.10.110 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2003ms
rtt min/avg/max/mdev = 1.026/1.606/2.717/0.785 ms

### L2 VM(10.10.10.110)のキャプチャ結果
$ sudo tcpdump -i eth1 icmp and src host 10.10.10.15
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on eth1, link-type EN10MB (Ethernet), snapshot length 262144 bytes

[何も受け取れていない]

これは Alias IP の仕組みに理由があります。

Alias IP を L1 VM に設定すると GCE Guest Agent が経路情報を自動的に追加します。

Setup or remove IP routes in the guest for IP forwarding and IP aliases

addresses.go
var registryEntries []string
for _, ip := range wantIPs {
	// If the IP is not in toAdd, add to registry list and continue.
	if !slices.Contains(toAdd, ip) {
		registryEntries = append(registryEntries, ip)
		continue
	}
	var err error
	if runtime.GOOS == "windows" {
		// Don't addAddress if this is already configured.
		if !slices.Contains(configuredIPs, ip) {
			// In case of forwardedIpv6 we get IPV6 CIDR and Parse IP will return nil.
			netip := net.ParseIP(ip)
			if netip != nil && !isIPv6(netip) {
				// Retains existing behavior for ipv4 addresses.
				err = addAddress(netip, net.IPv4Mask(255, 255, 255, 255), uint32(iface.Index))
			} else {
				err = addIpv6Address(ip, uint32(iface.Index))
			}
		}
	} else {
		err = addLocalRoute(ctx, config, ip, iface.Name)
	}
	if err == nil {
		registryEntries = append(registryEntries, ip)
	} else {
		logger.Errorf("error adding route: %v", err)
	}
}
### L1 VM の経路情報を確認
$ ip route get 10.10.10.110
local 10.10.10.110 dev lo src 10.10.10.110 uid 1015
    cache <local>

ここで、10.10.10.110 という IP 宛のパケットをループバックインターフェース(lo)で受け取るようにローカルの経路情報が構成されていることが分かります。

Alias IP に対するルーティングは Google Cloud 内の VPC ネットワークレイヤで自動的に構成されており、L1 VM がこの IP 宛のパケットをカーネルで処理するように設定されているため、ICMP Request に対して応答が返っているわけです。

When alias IP ranges are configured, Google Cloud automatically installs Virtual Private Cloud (VPC) network routes for primary and alias IP ranges for the subnet of the primary network interface.

You do need to perform in-guest configuration as described in Alias IP ranges key properties.

iptables Chain の追加

実際に L2 VM にパケットを転送するために、L1 VM に iptables の Chain Rule を追加します。

Alias IP と L1 VM が持つ経路情報により、L1 VM は同一 VPC サブネットの他のホストから L2 VM へのパケットを受け取ることができます。 これをマスカレードして最終的に L2 VM へ転送します。

  1. IP Forwarding の有効化

sysctl.d にカスタムルールを追加します。

### /etc/sysctl.d に IP Forwarding の設定を追加
$ echo 'net.ipv4.ip_forward = 1' | sudo tee /etc/sysctl.d/99-ip-forward.conf
net.ipv4.ip_forward = 1

### 反映
$ sudo sysctl --system
  1. iptables Chain Rule を初期化

デフォルトの iptables Chain Rule を一度クリアします。

$ sudo iptables -F
$ sudo iptables -X
$ sudo iptables -t nat -F
$ sudo iptables -t nat -X
$ sudo iptables -t mangle -F
$ sudo iptables -t mangle -X
$ sudo iptables -P INPUT ACCEPT
$ sudo iptables -P FORWARD ACCEPT
$ sudo iptables -P OUTPUT ACCEPT
### フィルタテーブル(INPUT / FORWARD / OUTPUT)の確認
$ sudo iptables -L -v -n
Chain INPUT (policy ACCEPT 46 packets, 4760 bytes)
 pkts bytes target     prot opt in     out     source               destination

Chain FORWARD (policy ACCEPT 0 packets, 0 bytes)
 pkts bytes target     prot opt in     out     source               destination

Chain OUTPUT (policy ACCEPT 26 packets, 3352 bytes)
 pkts bytes target     prot opt in     out     source               destination

### NAT テーブル(PREROUTING)の確認
$ sudo iptables -t nat -L PREROUTING -n -v
Chain PREROUTING (policy ACCEPT 0 packets, 0 bytes)
 pkts bytes target     prot opt in     out     source               destination
  1. フィルタルールの追加

基本的な INPUT / FORWARD / OUTPUT の許可フィルタを追加します。

### INPUT Chain(L2 VM へのパケット受信を許可)
sudo iptables -A INPUT -p icmp -j ACCEPT
sudo iptables -A INPUT -p tcp  -j ACCEPT
sudo iptables -A INPUT -p udp  -j ACCEPT

### OUTPUT Chain(L2 VM からのパケット送信を許可)
sudo iptables -A OUTPUT -p icmp -j ACCEPT
sudo iptables -A OUTPUT -p tcp  -j ACCEPT
sudo iptables -A OUTPUT -p udp  -j ACCEPT

### FORWARD Chain(L2 VM へのパケット転送の許可)
sudo iptables -A FORWARD -p icmp -j ACCEPT
sudo iptables -A FORWARD -p tcp  -j ACCEPT
sudo iptables -A FORWARD -p udp  -j ACCEPT
  1. NAT ルールの追加

L2 VM に対する PREROUTING / POSTROUTING の NAT フィルタを追加します。

### L2 VM(server01)への IP マスカレード
$ sudo iptables -t nat -A PREROUTING -d 10.10.10.110 -j DNAT --to-destination 172.15.7.11
$ sudo iptables -t nat -A POSTROUTING -s 172.15.7.11 -j MASQUERADE

### L2 VM(server02)への IP マスカレード
$ sudo iptables -t nat -A PREROUTING -d 10.10.10.111 -j DNAT --to-destination 172.15.7.12
$ sudo iptables -t nat -A POSTROUTING -s 172.15.7.12 -j MASQUERADE
  1. NAT テーブルの確認

送信元 0.0.0.0/0 から受け取った 10.10.10.110/32 宛のパケットを 172.15.7.11 に転送します。

送信元 0.0.0.0/0 から受け取った 10.10.10.111/32 宛のパケットを 172.15.7.12 に転送します。

作成した時点では、pkts フィールド(処理したパケット数)は 0 ですが、このルールが適用されるとカウンタがインクリメントされます。

$ sudo iptables -t nat -L PREROUTING -n -v
Chain PREROUTING (policy ACCEPT 0 packets, 0 bytes)
 pkts bytes target     prot opt in     out     source               destination
    0     0 DNAT       all  --  *      *       0.0.0.0/0            10.10.10.110         to:172.15.7.11
    0     0 DNAT       all  --  *      *       0.0.0.0/0            10.10.10.111         to:172.15.7.12
  1. 再度、疎通確認
### vagrant-playground-instance-02 から L2 VM(10.10.10.110)への ICMP Request
$ ping -c 3 10.10.10.110
PING 10.10.10.110 (10.10.10.110) 56(84) bytes of data.
64 bytes from 10.10.10.110: icmp_seq=1 ttl=63 time=4.58 ms
64 bytes from 10.10.10.110: icmp_seq=2 ttl=63 time=1.22 ms
64 bytes from 10.10.10.110: icmp_seq=3 ttl=63 time=1.29 ms

--- 10.10.10.110 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2003ms
rtt min/avg/max/mdev = 1.221/2.364/4.579/1.566 ms

### L2 VM(10.10.10.110)のキャプチャ結果
$ sudo tcpdump -i eth1 icmp and src host 10.10.10.15
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on eth1, link-type EN10MB (Ethernet), snapshot length 262144 bytes
16:11:53.739666 IP 10.10.10.15 > 172-15-7-11.lightspeed.stlsmo.sbcglobal.net: ICMP echo request, id 4, seq 1, length 64
16:11:54.739586 IP 10.10.10.15 > 172-15-7-11.lightspeed.stlsmo.sbcglobal.net: ICMP echo request, id 4, seq 2, length 64
16:11:55.741059 IP 10.10.10.15 > 172-15-7-11.lightspeed.stlsmo.sbcglobal.net: ICMP echo request, id 4, seq 3, length 64

vagrant-playground-instance-02 から L2 VM(10.10.10.110)への ping が成功しており、応答パケットを返送しているのが L2 VM(server01)であることが分かります。

  1. 再度、NAT テーブルの確認
$ sudo iptables -t nat -L PREROUTING -n -v
Chain PREROUTING (policy ACCEPT 0 packets, 0 bytes)
 pkts bytes target     prot opt in     out     source               destination
    1    84 DNAT       all  --  *      *       0.0.0.0/0            10.10.10.110         to:172.15.7.11
    0     0 DNAT       all  --  *      *       0.0.0.0/0            10.10.10.111         to:172.15.7.12

iptables の NAT テーブルを確認してみると、L2 VM(10.10.10.110)への PREROUTING カウンタが増加しており、DNAT でパケットが転送されたことが分かります。

これで L2 VM を VPC サブネットと同一のネットワーク上にぶら下げることができました。

kvm-vpc-network-phase-02.png

GCE Nested V12n で KVM を利用した場合、最終的にこのような構成になります。

L1 VM の処理

L2 VM に対するパケットを依然として L1 VM が受け取るためパケットキャプチャでは観測されます。

### ### vagrant-playground-instance-02 から L2 VM(10.10.10.110)への ping
$ ping -c 3 10.10.10.110
PING 10.10.10.110 (10.10.10.110) 56(84) bytes of data.
64 bytes from 10.10.10.110: icmp_seq=1 ttl=63 time=2.57 ms
64 bytes from 10.10.10.110: icmp_seq=2 ttl=63 time=1.16 ms
64 bytes from 10.10.10.110: icmp_seq=3 ttl=63 time=1.15 ms

--- 10.10.10.110 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2002ms
rtt min/avg/max/mdev = 1.151/1.626/2.567/0.665 ms

### L1 VM のキャプチャ結果
$ sudo tcpdump -i ens4 icmp and src host 10.10.10.15
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on ens4, link-type EN10MB (Ethernet), snapshot length 262144 bytes
18:45:25.703327 IP vagrant-playground-instance-02.c.ren510dev.internal > 10.10.10.110: ICMP echo request, id 10, seq 1, length 64
18:45:26.703923 IP vagrant-playground-instance-02.c.ren510dev.internal > 10.10.10.110: ICMP echo request, id 10, seq 2, length 64
18:45:27.704319 IP vagrant-playground-instance-02.c.ren510dev.internal > 10.10.10.110: ICMP echo request, id 10, seq 3, length 64

これは L1 VM の仮想 NIC(ens4)に着信したパケットに対して、iptables の PREROUTING Chain を適用する前にキャプチャツール(libcap)が観測するためです。

packet-flow-within-l1-vm.png

nftables への移行とルールの永続化

最後に iptables のルールを永続化しておきます。

Linux カーネルは、パケットフィルタリングやマングル処理のフレームワークとして netfilter を利用しています。 従来 netfilter の制御は iptables が主流でしたが、Linux 3.13 以降、後継である nftables が徐々に浸透し始め、2023 年現在では大半の Linux ディストリビューションでデフォルトになっています。

iptables と比較して、nftables はルールの記述が簡潔化されており、パフォーマンスも良いとされています。

$ which iptables
/usr/sbin/iptables

$ iptables --version
iptables v1.8.7 (nf_tables)

今回使用している L1 VM(GCE)も nftables がデフォルトで有効になっているため、こちらを利用して Chain を永続化します。

  1. nftables をアクティベート

nftables が有効になっていない場合は、以下のコマンドでアクティベートします。

### nftables の有効化
$ sudo systemctl enable nftables

### nftables の起動
$ sudo systemctl start nftables

### 確認
$ sudo systemctl status nftables
● nftables.service - nftables
     Loaded: loaded (/lib/systemd/system/nftables.service; enabled; vendor preset: enabled)
     Active: active (exited) since Tue 2023-11-04 16:36:22 UTC; 4s ago
       Docs: man:nft(8)
             http://wiki.nftables.org
    Process: 3180 ExecStart=/usr/sbin/nft -f /etc/nftables.conf (code=exited, status=0/SUCCESS)
   Main PID: 3180 (code=exited, status=0/SUCCESS)
        CPU: 11ms

Nov 04 16:36:22 vagrant-playground-instance-01 systemd[1]: Starting nftables...
Nov 04 16:36:22 vagrant-playground-instance-01 systemd[1]: Finished nftables.
  1. デフォルトの nftables.conf を退避

nftables の設定ファイルは /etc/nftables.conf にあります。 既存の設定ファイルを安全のため退避させておきます。

$ sudo cp /etc/nftables.conf /etc/nftables.conf.bak
  1. nftables の設定ファイルを追加
$ sudo vim /etc/nftables.conf

以下の内容を追加します。

#!/usr/sbin/nft -f

flush ruleset

### INPUT / FORWARD / OUTPUT
table inet filter {
    chain input {
        type filter hook input priority 0;
        policy accept;

        ip protocol icmp accept
        ip protocol tcp accept
        ip protocol udp accept
    }

    chain forward {
        type filter hook forward priority 0;
        policy accept;

        ip protocol icmp accept
        ip protocol tcp accept
        ip protocol udp accept
    }

    chain output {
        type filter hook output priority 0;
        policy accept;

        ip protocol icmp accept
        ip protocol tcp accept
        ip protocol udp accept
    }
}

### PREROUTING / POSTROUTING)
table ip nat {
    chain prerouting {
        type nat hook prerouting priority 0;

        ip daddr 10.10.10.110 dnat to 172.15.7.11
        ip daddr 10.10.10.111 dnat to 172.15.7.12
    }

    chain postrouting {
        type nat hook postrouting priority 100;

        ip saddr 172.15.7.11 masquerade
        ip saddr 172.15.7.12 masquerade
    }
}
  1. nftables の設定を適用

以下のコマンドで設定ファイルをシステムに反映します。

$ sudo nft -f /etc/nftables.conf
  1. nftables の設定を確認

先ほど設定したルールが正しく適用されているか確認します。

$ sudo nft list ruleset
table inet filter {
        chain input {
                type filter hook input priority filter; policy accept;
                ip protocol icmp accept
                ip protocol tcp accept
                ip protocol udp accept
        }

        chain forward {
                type filter hook forward priority filter; policy accept;
                ip protocol icmp accept
                ip protocol tcp accept
                ip protocol udp accept
        }

        chain output {
                type filter hook output priority filter; policy accept;
                ip protocol icmp accept
                ip protocol tcp accept
                ip protocol udp accept
        }
}
table ip nat {
        chain prerouting {
                type nat hook prerouting priority filter; policy accept;
                ip daddr 10.10.10.110 dnat to 172.15.7.11
                ip daddr 10.10.10.111 dnat to 172.15.7.12
        }

        chain postrouting {
                type nat hook postrouting priority srcnat; policy accept;
                ip saddr 172.15.7.11 masquerade
                ip saddr 172.15.7.12 masquerade
        }
}

この状態になれば先ほど手動で適用した iptables のルールは不要となるので削除して構いません。

これで L1 VM が再起動した場合でも Chain が保存されているため、起動時に nftables を呼び出すことで、常に L2 VM へのパケット転送が可能になります。

まとめ

今回のブログでは Nested V12n を活用してクラウドプロバイダが提供する VM 内で KVM 環境を構築する方法について紹介しました。 物理サーバと比較すると、クラウドサービスでは特有の制約があるため、特にホストマシンがどのようなネットワーク環境下で稼働しているかを理解することが重要です。

大半のクラウドサービスはネットワークを SDN で運用しているため、L3 より下のレイヤで用いられるプロトコルや Linux カーネルの機能が制限されています。 このため、マイグレーション先のクラウドプロバイダがどのように VM を運用しているのかを把握することで、KVM 環境を適切に構築することができます。

今回は気になって検証しただけですが、実際に Nested V12 を利用して VM in VM の運用をする場合は、パフォーマンスにも注意が必要になると思います。

参考・引用