GOで実装したHTTPサーバでソケットオプションが効かなかった話


![icon.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/705474/b4e01b1e-07d2-ab2e-932c-f9c86f88c03b.jpeg)

Tuxの画像は[Pinguine](http://www.home.unix-ag.org/simon/penguin/)より引用

# はじめに

先日、[GO言語](https://go.dev/)で実装したHTTPサーバに、`SO_REUSEPORT`のソケットオプションを適用してみました。
このオプションを使うと同じポート番号で複数のサーバを起動できます。なのですが、最初、想定通りに動作せず１時間ほどはまってしまったのでその時のことを記事にしてみました。

# １. SO_REUSEPORTとは

`SO_REUSEPORT`とは、一言でいうとLinux上で動作する**複数の**アプリケーションが**同一のポート**を利用できるようにするソケットオプションのことです。
このソケットオプションはLinuxカーネル3.9で導入されています。

例えばHTTPサーバを`localhost:8080`で動作させることを考えます。通常であれば`localhost:8080`で起動できるサーバは１台だけで２台目以降は起動できずにエラーとなります。ところが`SO_REUSEPORT`のソケットオプションを適用した場合、２台目以降のサーバも`localhost:8080`で起動できるようになります。
複数台のサーバを起動した場合、リクエストの振り分けは概ねランダムになるようです。

参考資料を記載しておきますので、より詳しく知りたい方はそちらを参照してください。

参考資料：
- [socket(7) — Linux manual page](https://man7.org/linux/man-pages/man7/socket.7.html)
- [The SO_REUSEPORT socket option](https://lwn.net/Articles/542629/)
- [SO_REUSEPORT 完全に理解した（い）](https://ja.tak-cslab.org/archives/2069)

## 2. SO_REUSEPORT無しで複数サーバを起動する

[１. SO_REUSEPORTとは](#１-so_reuseportとは)では、SO_REUSEPORTを利用すると同一ポートで複数のサーバを起動できると説明しました。
実際にSO_REUSEPORTを適用してみる検証は後ろのセクションに譲り、ここではSO_REUSEPORTを利用せずに複数のサーバを起動したときの挙動を確認してみます。

検証用のソースコードはこちらです。
HTTPサーバを`:8080`ポートで起動しています。

```go:main.go
// ※ 見やすさのためにエラーハンドリング等の実装を省略している部分があります。
func main() {
	// TCPリスナーを作成。
	lc := &net.ListenConfig{}
	ln, err := lc.Listen(context.Background(), "tcp", ":8080")
	if err != nil {
		panic(err)
	}

	// 作成したTCPリスナーを使ってHTTPサーバを作成。
	svr := http.Server{
		Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			w.Write([]byte("OK"))
		}),
	}

	// HTTPサーバを起動。
	println("server listening on :8080")
	svr.Serve(ln)
}

```

上記のコードを `go run main.go` で実行し、curlコマンドで動作確認します。
なお curlコマンドは異なるターミナルを立ち上げて実行しています。

```bash:検証用サーバの実行
$ go run main.go
server listening on :8080
```

```bash:検証用サーバの動作確認
$ curl localhost:8080
OK
```

`OK`と返却されたので、うまくHTTPサーバが動いていることを確認できました。

さて、ここで新たにターミナルを立ち上げてサーバを起動してみます。
ソースコードは上記で示したものを修正せずにそのまま利用してください。

```bash:検証用サーバの実行
$ go run main.go
panic: listen tcp :8080: bind: address already in use

goroutine 1 [running]:
main.main()
        /tmp/so_reuseport/main.go:15 +0x136
exit status 2
```

> **panic: listen tcp :8080: bind: address already in use**

皆さんおなじみのエラーが出ました！！
ポート番号`:8080`は１番目に起動したサーバが利用しているので使えないよとのことです。

では、続いて`SO_REUSEPORT`を使って検証してみます。

筆者がはまった間違った方法を知りたい方は[3. SO_REUSEPORTを適用してみる（間違った方法）](#3-so_reuseportを適用してみる間違った方法)、正しい方法を知りたい方は[4. SO_REUSEPORTを適用してみる（正しい方法））](#4-so_reuseportを適用してみる正しい方法)へ飛んでください。

## 3. SO_REUSEPORTを適用してみる（間違った方法）

SO_REUSEPORTを適用するために、[2. SO_REUSEPORT無しで複数サーバを起動する](2-so_reuseport無しで複数サーバを起動する)で示したソースコードを以下のように書き換えます。
簡単に説明すると[net.TCPListener.SyscallConn](https://pkg.go.dev/net#TCPListener.SyscallConn)で[syscall.RawConn](https://pkg.go.dev/syscall#RawConn)を取得したのちに、[syscall.RawConn.Control](https://pkg.go.dev/syscall#RawConn.Control)メソッドを呼び出してます。

```go:main.go
// ※ 見やすさのためにエラーハンドリング等の実装を省略している部分があります。
func main() {
	// TCPリスナーを作成。
	lc := &net.ListenConfig{}
	ln, err := lc.Listen(context.Background(), "tcp", ":8080")
	if err != nil {
		panic(err)
	}

	// SO_REUSEPORTを適用したつもり。
	tcpLn := ln.(*net.TCPListener)
	conn, _ := tcpLn.SyscallConn()
	conn.Control(func(fd uintptr) {
		syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, unix.SO_REUSEPORT, 1)
	})

	// 作成したTCPリスナーを使ってHTTPサーバを作成。
	svr := http.Server{
		Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			w.Write([]byte("OK"))
		}),
	}

	// HTTPサーバを起動。
	println("server listening on :8080")
	svr.Serve(ln)
}
```

では、同じポート番号で２つのサーバを起動してみましょう。

まずは１つ目のサーバを起動します。

```bash
$ go run main.go
server listening on :8080
```

問題なく起動しました。
続いて２つ目のサーバを起動してみます。

```bash
$go run main.go
panic: listen tcp :8080: bind: address already in use

goroutine 1 [running]:
main.main()
        /tmp/so_reuseport/main.go:18 +0x1a9
exit status 2
```

**なんで！？！？！？**
**SO_REUSEPORTを適用してるはずなのに！！**

動かないものは仕方がないのでデバッグしていきます。
まずは[strace](https://man7.org/linux/man-pages/man1/strace.1.html)コマンドでシステムコールが呼ばれているかを確認します。

```bash
$ strace -C -f -e trace=setsockopt go run main.go
~~~ 略 ~~~
[pid  3301] setsockopt(3, SOL_IPV6, IPV6_V6ONLY, [1], 4) = 0
[pid  3301] setsockopt(4, SOL_IPV6, IPV6_V6ONLY, [0], 4) = 0
[pid  3301] setsockopt(3, SOL_IPV6, IPV6_V6ONLY, [0], 4) = 0
[pid  3301] setsockopt(3, SOL_SOCKET, SO_REUSEADDR, [1], 4) = 0
[pid  3301] setsockopt(3, SOL_SOCKET, SO_REUSEPORT, [1], 4) = 0
server listening on :8080
```

どうやらシステムコールは呼べていそうです。
[ChatGPT](https://chatgpt.com/)に聞いてみると以下のような原因を挙げてくれました。

1. SO_REUSEPORT が Go 標準パッケージでサポートされていない
    - ➡ <font color="green">SetsockoptIntを使ってるので関係ない</font>
2. OSのバージョンやカーネル設定が原因
    - カーネルのバージョンが3.9以降になっていない可能性
    - ➡ <font color="red">Linuxといいつつ wsl 環境なので可能性はある</font>
3. syscallパッケージを用いた手動設定が必要
    - syscallパッケージを使って手動でソケットオプションを設定する
    - ➡ <font color="green">すでに使ってるので関係ない</font>
4. マルチプロセスの実行を考慮する
    - 同じポートを使うすべてのサーバでオプションを適用しないといけない
    - ➡ <font color="green">サーバのソースコードは同じなのでこれも原因ではない</font>
5. SO_REUSEADDR との併用の問題
    - SO_REUSEADDRを有効にしていることが前提のケースがある
    - ➡ <font color="green">SO_REUSEADDRも適用されているようなので原因ではない</font>
6. バインドアドレスが一致しているか
    - ➡ <font color="green">サーバのソースコードは同じなのでこれも原因ではない</font>
7. リスナーの作成タイミングが適切か
    - ➡ <font color="red">無理やり適用した感があるので原因の可能性がある</font>
8. カーネルの設定や制限
    - Linuxの一部のディストリビューションや設定で、SO_REUSEPORT に制限がかかっている場合がある
    - ➡ <font color="red">Linuxといいつつ wsl 環境なので可能性はある</font>

というわけで、ChatGPTが教えてくれた②⑦⑧は確認の余地がありそうです。
まず、[VirtualBox](https://www.virtualbox.org/)で立てた仮想マシンの上で確認してみましたが同じ結果だったので、②⑧に関しては原因ではなさそうです。
[socket(7) — Linux manual page](https://man7.org/linux/man-pages/man7/socket.7.html)を読むと、`SO_REUSEPORTは、bindを呼び出す前に各ソケット（最初のソケットも含む）で設定する必要があります。`との記載があります。
今回のソースコードでは[Listen](https://pkg.go.dev/net#ListenConfig.Listen)を呼び出した後に`SO_REUSEPORT`を適用しているため、明らかにbind後に適用してしまっています。

どうすればbind前にオプションを適用できるのか。
答えは[net.ListenConfig.Control](https://pkg.go.dev/net#ListenConfig.Control)にありました。
[net.ListenConfig.Control](https://pkg.go.dev/net#ListenConfig.Control)のコメントには以下のように記載があります。

> // If Control is not nil, it is called after creating the network
> // connection but before binding it to the operating system.

**before binding !!!**
GOがちゃんと用意してくれていましたのこれを使って検証してみたいと思います。
続きは [4. SO_REUSEPORTを適用してみる（正しい方法）](#4-so_reuseportを適用してみる正しい方法)。

## 4. SO_REUSEPORTを適用してみる（正しい方法）

ここでは[net.ListenConfig.Control](https://pkg.go.dev/net#ListenConfig.Control)を利用して`SO_REUSEPORT`を適用してみます。
`SO_REUSEPORT`はソケットがbindされる前に適用しなければなりません。
[net.ListenConfig.Control](https://pkg.go.dev/net#ListenConfig.Control)を利用することでソケットがbindされるまえに処理を差し込むことができます。

SO_REUSEPORTを適用するために、[2. SO_REUSEPORT無しで複数サーバを起動する](2-so_reuseport無しで複数サーバを起動する)で示したソースコードを以下のように書き換えます。

```go:main.go
// ※ 見やすさのためにエラーハンドリング等の実装を省略している部分があります。
func main() {
	// TCPリスナーを作成。
	lc := &net.ListenConfig{
		Control: func(network, address string, c syscall.RawConn) error {
			c.Control(func(fd uintptr) {
				// SO_REUSEPORTを適用。
				syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, unix.SO_REUSEPORT, 1)
			})
			return nil
		},
	}
	ln, err := lc.Listen(context.Background(), "tcp", ":8080")
	if err != nil {
		panic(err)
	}

	// 作成したTCPリスナーを使ってHTTPサーバを作成。
	svr := http.Server{
		Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			w.Write([]byte("OK"))
		}),
	}

	// HTTPサーバを起動。
	println("server listening on :8080")
	svr.Serve(ln)
}
```

では、同じポート番号で２つのサーバを起動してみましょう。

まずは１つ目のサーバを起動します。

```bash
$ go run main.go
server listening on :8080
```

問題なく起動しました。
続いて２つ目のサーバを起動してみます。

```bash
$ go run main.go
server listening on :8080
```

こちらも動きました！！
同じポート番号で２つのサーバを起動させることに成功しました！！

# 5. (おまけ) SO_REUSEPORTでロードバランスさせてみる

[4. SO_REUSEPORTを適用してみる（正しい方法）](#4-so_reuseportを適用してみる正しい方法)では、２つのサーバを同じポート番号（`:8080`）で起動することができました。
この時、`:8080`に来たリクエストは概ねランダムにサーバに振り分けられるそうです。
実際、どのようなアルゴリズムで振り分けをしてるかまでは理解できませんでしたので、気になる方は[SO_REUSEPORT 完全に理解した（い）](https://ja.tak-cslab.org/archives/2069)などをご参照ください。

以下では、実際にリクエストが振り分けられることを確認してみます。
１つ目のサーバは「○」を返し、２つ目のサーバは「■」を返すようにしてリクエストを投げてみます。

```bash
$ while true; do curl localhost:8080; done
■■■■■■■■○■■○■○○■○○■■■○○■■■■○○○○○○■■■○■○○■○○○○○○■○■○○■■■○■■○○■■■■■○○■■○■○■■○■■○■○○■○■■■○○○■○
■○■○■■■○○○○■○○○○○○■■■■○○■■○○■○■○○○■○○■○■○■○■○○○■○■○■○○○○○○○■○○■■○■■○■○■■■■■■○■○○■■■■■■○■○○○
○○■■○○○○■○■■■■■○■■■○○○○○○■○■○○■○■■○○○○■■■○■○■○■■○■■■○■■■○■■■■■○■○■○■○○○■○○■○○○■○○○■○■○○○○○■
■○○○■■■○■■■■■■○■■■■■■■■■■■■■○■○■○■○■○■○■○■○○■○○■○■○■■○■○○■○■○○○○■○■○○■■○■○■○○■○○■○○○○○○○■○○
■■■○○■■■○■■■■○○■■○○■■■○■■■■■○○■○○○■■○○○○○■○○○■■■○■○○○■○■■○■■○■■■○■■■■○■○○■○○■■○○○○○○■■○○■■■
■■■■■■○○○■■■○■○○○■■■■○■○○■○○○○■○○■○○■○○○■○○■○■○■○○■■■○○○○■○■○○○○○○○○○■○■○○○○○■■■■■○○○■○○■■○
○■○○○■■○○■■○○■○■○■○■■■■○■■■○■■■○○○■■■■■○○○○■○○○○■■○■■○■○■○■■■○○○■■■○○■■○■■■○○■■○■○○○■○○○○○○
■○■○■○■■○■○○○○■○■○■○○○■■○■■■■○■○○■○■○■■■■■○○■○○○■○○■○■○■○○○○■○■○○■■■■■○■○○■■○○○■■■○■○○○■○○■
■■■■■■■○■○○○○○■■■■■○■○■■■○○○■■○■■■○○○○■○■○■○■■■■■■■■○■■■■■■○○■○■■○■○■○■■○■○○○○○○○■○■■○○■■■○
○○○○■○○○■■○○○■■○○○○○■○■■○■○○○○■○○○■■■○■○■■○■■■■■■○■■■■○■○■■○■■■○■○○■■■○■■■○○■○○○■■■○■■○○■■○
■■■○○■■■■■■■○○■■■■■○■■○■○■○○■■■○○○○■○○○■○○○■■○○○■○○■○○○■○○○■○○■○■■○■○■○○■○○○○■○■■■■○○■■○■○■
■○○○■■○■○■○○■■■■○■■○○■■○■■○■○○○■■■■○■○■○○■■○○■○■■○■○○○■○■○○○■■■○○■○○■■○■○■■○○○■○○■○■○○■■○○■
○■■○○■■■■○○○○○■○■■○■○■■○■○○○○■■■○■○■■■■○○○○■■■■■○○○■○○○■○■○■■○■■■■○○○○○○■■○■○■○○■○■■■○○■■○○
```

たしかに、概ねランダムと言ってよさそうな順序でリクエストが振り分けられていることが確認できます。

# 6. まとめ

GOで実装したHTTPサーバに`SO_REUSEPORT`のソケットオプションを適用しました。
`SO_REUSEPORT`はソケットがbindされる前に適用されなければなりません。このようなオプションの場合は、[net.ListenConfig.Control](https://pkg.go.dev/net#ListenConfig.Control)を利用する必要があることがわかりました。
また、`SO_REUSEPORT`は各サーバに概ねランダムにリクエストを振り分けることも確認できました。

GOはネットワークレイヤを比較的簡単にあつかえる言語ですが、Linuxやネットワークを理解していないと想定通りに動かないことも往々にしてあります。
今回の検証を通して、プログラムが想定通りに動くかどうかは実際に動かしてみるまでは分からないということを改めて思い知らされました。

## 参考資料

- [socket(7) — Linux manual page](https://man7.org/linux/man-pages/man7/socket.7.html)
- [The SO_REUSEPORT socket option](https://lwn.net/Articles/542629/)
- [SO_REUSEPORT 完全に理解した（い）](https://ja.tak-cslab.org/archives/2069)
- [https://pkg.go.dev/net#ListenConfig.Control](https://pkg.go.dev/net#ListenConfig.Control)
- [strace.md](https://gist.github.com/graste/929bb122c353bdd90c20)