ゲストVM上でhttpdを起動しホストからアクセスする——TCPだけタイムアウトするvirtio-netのfeature bit設定ミス
virtio-netデバイスを自前実装してTAP経由でホストと通信するではICMP echoによるping疎通までを確認しました。今回はゲスト内で実際にHTTPサーバーを起動し、ホスト側からアクセスできることを確認します。あわせて、ゲストのIPアドレス設定に使っていたRustからの直接ioctl呼び出しをやめ、initramfsにbusyboxを組み込んでシェルとネットワークコマンド一式を使えるようにします。
initramfsへのbusybox組み込み
これまでのinitramfsは自作のRustバイナリ1本だけで構成していましたが、今回はbusyboxを追加し、httpd・ifconfig・insmodといった一通りのコマンドを使えるようにします。busyboxは1つの実行ファイルに数百のコマンドを詰め込んだツールで、コマンド名と同じ名前のファイル(多くはシンボリックリンク)からbusybox本体を起動すると、その名前に応じたコマンドとして振る舞います。
cd initramfs/bin
for app in sh mount uname poweroff ifconfig insmod httpd ls cat ps mkdir ln cp; do
ln -sf busybox "$app"
donevCPUを実行して実際にLinuxカーネルを起動するでも触れた通り、initramfs内のシンボリックリンクは相対パスで作成する必要があります。busybox付属の一括インストールコマンド(busybox --install -s <dir>)を絶対パス指定で実行すると、ホスト側の絶対パスを埋め込んだシンボリックリンクが生成されてしまい、ゲスト内では存在しないパスを指すリンクになります。上記のようにln -sf busybox <name>をinitramfsのビルド作業ディレクトリ内で直接実行すれば、<name> -> busyboxという相対リンクになります。
init: マウント・モジュールロード・IP設定・httpd起動
/initは次のシェルスクリプトです。
#!/bin/sh
mkdir -p /proc /sys /dev
mount -t proc proc /proc
mount -t sysfs sysfs /sys
mount -t devtmpfs devtmpfs /dev 2>/dev/null
insmod /lib/modules/failover.ko
insmod /lib/modules/net_failover.ko
insmod /lib/modules/virtio_net.ko
insmod /lib/modules/virtio_blk.ko
ifconfig eth0 192.168.100.2 netmask 255.255.255.0 up
echo "eth0 up: 192.168.100.2/24"
exec httpd -f -h /www -p 80モジュールのロードとvirtio-netの扱いはvirtio-netデバイスを自前実装してTAP経由でホストと通信すると同じですが、eth0へのIPアドレス設定は生ioctlの代わりにbusyboxのifconfigを使っています。最後のhttpd -f -h /www -p 80は、/wwwディレクトリをドキュメントルートにしてフォアグラウンドで待ち受けるbusybox httpdの起動です。execを付けているため、シェルプロセスをhttpdで置き換え、PID 1のままhttpdが動き続けます。
virtio-netのfeature bit: VIRTIO_NET_F_MAC
virtio仕様では、デバイスが対応する機能はfeature bitという64bitのビット列でホストからゲストへ伝えます。MACアドレスをconfig spaceで公開する機能はVIRTIO_NET_F_MAC(bit 5、値1 << 5)というビットに割り当てられており、このビットだけを立てて他のビットはすべて0にする必要があります。
const VIRTIO_NET_F_MAC: u64 = 1 << 5;このビット値を誤ってbit 0(VIRTIO_NET_F_CSUM)のままにすると、ゲストのvirtio-netドライバは「デバイスがTCP/UDPチェックサムの計算を肩代わりしてくれる」と解釈します。すると送信パケットのチェックサムフィールドを未計算のまま(ハードウェアが後から埋める前提で)送出するようになりますが、今回の実装はこのオフロードを実際には行わず、受け取ったフレームをそのままTAPへ転送するだけです。結果として、チェックサムの合わないTCPパケットがホストの実カーネルに届き、そこで黙って破棄されます。ICMPはチェックサムオフロードの対象にならないため、pingは通るのにcurlによるTCP接続だけがタイムアウトする、という現象になります。VIRTIO仕様のNetwork Deviceの章に、各feature bitの番号と意味が定義されています。
ホストからの直接アクセス
ホスト側でtap0に192.168.100.1/24を設定した状態でVMMを起動し、ゲストのeth0 upログを確認したあと、ホスト自身からcurlでアクセスします。
$ ping -c 2 192.168.100.2
64 bytes from 192.168.100.2: icmp_seq=1 ttl=64 time=1.04 ms
64 bytes from 192.168.100.2: icmp_seq=2 ttl=64 time=0.51 ms
$ curl http://192.168.100.2/
<!DOCTYPE html>
<html>
<head><title>rust-kvm guest</title></head>
<body>
<h1>Hello from rust-kvm guest VM</h1>
<p>Served by busybox httpd inside a self-made KVM microVM, reached from the host over virtio-net.</p>
</body>
</html>pingとHTTPの両方が、ゲスト内のbusybox httpdまで正しく届いていることが確認できました。
別マシンからのアクセス: TCPリバースプロキシを挟む
192.168.100.2はホスト上のtap0にのみ存在するプライベートなアドレスで、同じLAN上の別マシンからは経路がないため直接到達できません。SSHポートフォワードでも中継できますが、ここではホスト上で待ち受けて192.168.100.2:80へそのまま中継する、簡易なTCPリバースプロキシをPythonで書きました。HTTPの中身を一切解釈せず、受け取ったバイト列をそのまま転送するだけの実装です。
import socket
import threading
LISTEN_HOST = "0.0.0.0"
LISTEN_PORT = 8888
TARGET_HOST = "192.168.100.2"
TARGET_PORT = 80
def relay(src, dst):
try:
while True:
data = src.recv(4096)
if not data:
break
dst.sendall(data)
except OSError:
pass
finally:
src.close()
dst.close()
def handle_client(client_sock, addr):
try:
upstream = socket.create_connection((TARGET_HOST, TARGET_PORT), timeout=5)
except OSError as e:
print(f"upstream connect failed for {addr}: {e}")
client_sock.close()
return
threading.Thread(target=relay, args=(client_sock, upstream), daemon=True).start()
threading.Thread(target=relay, args=(upstream, client_sock), daemon=True).start()
def main():
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind((LISTEN_HOST, LISTEN_PORT))
server.listen(128)
while True:
client_sock, addr = server.accept()
threading.Thread(target=handle_client, args=(client_sock, addr), daemon=True).start()
if __name__ == "__main__":
main()接続ごとに2本のスレッドを立て、片方向ずつrecv/sendallで中継しています。プロトコルを解釈しないため、HTTP以外のTCPサービスを中継する場合もコードの変更は不要です。
このプロキシをホスト上で起動し、同じLAN上の別マシンのブラウザから、ホストのIPアドレスとプロキシのポート宛にアクセスしました。

アドレスバーにはホストのIPアドレスが表示されていますが、実際に応答を返しているのはVM内のbusybox httpdです。ブラウザ→ホストのリバースプロキシ→TAP→ゲストのvirtio-net、という経路をすべて経由して届いています。
現時点の実装の範囲
今回のリバースプロキシはTCPのバイト列をそのまま中継するだけで、複数バックエンドへの振り分けやヘルスチェックといった機能は持っていません。またゲスト側はIPアドレスを固定で1つ設定しているだけで、複数ゲストを同時に起動する構成はまだ扱っていません。