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
ポートで起動しています。
// ※ 見やすさのためにエラーハンドリング等の実装を省略している部分があります。
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.SyscallConnでsyscall.RawConnを取得したのちに、syscall.RawConn.Controlメソッドを呼び出してます。
// ※ 見やすさのためにエラーハンドリング等の実装を省略している部分があります。
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に聞いてみると以下のような原因を挙げてくれました。
- SO_REUSEPORT が Go 標準パッケージでサポートされていない
- ➡ SetsockoptIntを使ってるので関係ない
- OSのバージョンやカーネル設定が原因
- カーネルのバージョンが3.9以降になっていない可能性
- ➡ Linuxといいつつ wsl 環境なので可能性はある
- syscallパッケージを用いた手動設定が必要
- syscallパッケージを使って手動でソケットオプションを設定する
- ➡ すでに使ってるので関係ない
- マルチプロセスの実行を考慮する
- 同じポートを使うすべてのサーバでオプションを適用しないといけない
- ➡ サーバのソースコードは同じなのでこれも原因ではない
- SO_REUSEADDR との併用の問題
- SO_REUSEADDRを有効にしていることが前提のケースがある
- ➡ SO_REUSEADDRも適用されているようなので原因ではない
- バインドアドレスが一致しているか
- ➡ サーバのソースコードは同じなのでこれも原因ではない
- リスナーの作成タイミングが適切か
- ➡ 無理やり適用した感があるので原因の可能性がある
- カーネルの設定や制限
- 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無しで複数サーバを起動するで示したソースコードを以下のように書き換えます。
// ※ 見やすさのためにエラーハンドリング等の実装を省略している部分があります。
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やネットワークを理解していないと想定通りに動かないことも往々にしてあります。
今回の検証を通して、プログラムが想定通りに動くかどうかは実際に動かしてみるまでは分からないということを改めて思い知らされました。