2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

GoAdvent Calendar 2024

Day 10

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

Last updated at Posted at 2024-12-09

icon.jpg

Tuxの画像はPinguineより引用

はじめに

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

1. SO_REUSEPORTとは

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

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

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

参考資料:

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

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

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

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コマンドは異なるターミナルを立ち上げて実行しています。

検証用サーバの実行
$ go run main.go
server listening on :8080
検証用サーバの動作確認
$ curl localhost:8080
OK

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

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

検証用サーバの実行
$ 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は1番目に起動したサーバが利用しているので使えないよとのことです。

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

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

3. SO_REUSEPORTを適用してみる(間違った方法)

SO_REUSEPORTを適用するために、2. SO_REUSEPORT無しで複数サーバを起動するで示したソースコードを以下のように書き換えます。
簡単に説明するとnet.TCPListener.SyscallConnsyscall.RawConnを取得したのちに、syscall.RawConn.Controlメソッドを呼び出してます。

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)
}

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

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

$ go run main.go
server listening on :8080

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

$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コマンドでシステムコールが呼ばれているかを確認します。

$ 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に聞いてみると以下のような原因を挙げてくれました。

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

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

どうすればbind前にオプションを適用できるのか。
答えはnet.ListenConfig.Controlにありました。
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を適用してみる(正しい方法)

ここではnet.ListenConfig.Controlを利用してSO_REUSEPORTを適用してみます。
SO_REUSEPORTはソケットがbindされる前に適用しなければなりません。
net.ListenConfig.Controlを利用することでソケットがbindされるまえに処理を差し込むことができます。

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

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)
}

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

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

$ go run main.go
server listening on :8080

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

$ go run main.go
server listening on :8080

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

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

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

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

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

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

6. まとめ

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

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

参考資料

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?