サンプルアプリ
こんな感じのコードをgracefulに色々できるようにしていく。
package main import ( "fmt" "net/http" ) func handler(w http.ResponseWriter, r *http.Request) { time.Sleep(500 * time.Millisecond) // ちょっと重い処理を想定 fmt.Fprintf(w, "Hello, World") } func main() { http.HandleFunc("/", handler) http.ListenAndServe(":8080", nil) }
この状態でabコマンドを実行しながらコードを書き換えてサーバを再起動させた時にどれくらいリクエストが失敗していくかをみていく。abはだいたい1000リクエストを100同時接続で実行していく
サンプルアプリで実行
# 起動 $ go run main.go # ab実行 $ ab -n 1000 -c 100 http://localhost:8080/ # ソース書き換え $ vim main.go # プロセス停止 & 再起動 $ pkill go & go run main.go
とやるとまあabがBenchmarking localhost (be patient)...apr_socket_recv: Connection refused (111)
で死ぬので全リクエスト失敗という結果にしておく
Graceful Restartを実装していく
取れそうな戦略はSO_REUSERPORTを指定して複数プロセス上げていく手法を実装してみる。プロセスAを起動しておきソース書き替え済みのプロセスBを起動し、プロセスAを止めるという方法。
実装はこんな感じ。
package main import ( "context" "fmt" "net" "net/http" "os" "syscall" "time" "golang.org/x/sys/unix" ) func main() { fmt.Println(os.Getpid()) http.HandleFunc("/", handler) lc := net.ListenConfig{ Control: listenCtrl, } l, err := lc.Listen(context.Background(), "tcp4", ":8080") if err != nil { panic(err) } svc := http.Server{} err = svc.Serve(l) if err != nil { panic(err) } } func handler(w http.ResponseWriter, r *http.Request) { time.Sleep(500 * time.Millisecond) fmt.Fprintf(w, "Hello, World\n") } func listenCtrl(network string, address string, c syscall.RawConn) error { opt := map[int]int{ unix.SO_REUSEPORT: 1, } for k, v := range opt { var operr error var fn = func(s uintptr) { operr = unix.SetsockoptInt(int(s), unix.SOL_SOCKET, k, v) // ここでSO_REUSEPORTを指定 } if err := c.Control(fn); err != nil { return err } if operr != nil { return operr } return nil } return nil }
結果はこんな感じ。最初にやったものとは違いabが最後まで走り切った。失敗したリクエストは46とめちゃめちゃ減った。こんだけ失敗するのはなぜ?というのはkillされるプロセスAが処理中であろうがプロセスが死んでしまうのが問題である。じゃあどうするかはGraceful Shutdownを実装していくことで解決する(はず)
Concurrency Level: 100 Time taken for tests: 5.553 seconds Complete requests: 1000 Failed requests: 46 (Connect: 0, Receive: 0, Length: 46, Exceptions: 0)
Graceful Shutdown
コード雑すぎますがシグナルを受けたらshutdownを呼び出すようにしています。
package main import ( "context" "fmt" "net" "net/http" "os" "os/signal" "syscall" "time" "golang.org/x/sys/unix" ) func main() { fmt.Println(os.Getpid()) http.HandleFunc("/", handler) lc := net.ListenConfig{ Control: listenCtrl, } l, err := lc.Listen(context.Background(), "tcp4", ":8080") if err != nil { panic(err) } svc := http.Server{} go func() { err = svc.Serve(l) if err != nil { panic(err) } }() // ブロックするのでgoroutineで呼ぶ sig := make(chan os.Signal, 1) signal.Notify(sig, syscall.SIGTERM, syscall.SIGHUP, syscall.SIGINT) <-sig ctx, _ := context.WithTimeout(context.Background(), 5*time.Second) svc.Shutdown(ctx) // 今回の肝はこれ } func handler(w http.ResponseWriter, r *http.Request) { time.Sleep(500 * time.Millisecond) fmt.Fprintf(w, "Hello, World\n") } func listenCtrl(network string, address string, c syscall.RawConn) error { opt := map[int]int{ unix.SO_REUSEPORT: 1, } for k, v := range opt { var operr error var fn = func(s uintptr) { operr = unix.SetsockoptInt(int(s), unix.SOL_SOCKET, k, v) } if err := c.Control(fn); err != nil { return err } if operr != nil { return operr } return nil } return nil }
結果はこちらだいぶ失敗するものが少なくなりました。
Concurrency Level: 10 Time taken for tests: 5.518 seconds Complete requests: 100 Failed requests: 5 (Connect: 0, Receive: 0, Length: 5, Exceptions: 0)
結果のまとめと考察
1000リクエストを100同時接続で投げた際にアップデート作業などをして失敗するリクエスト数
- 素朴な実装 -> 途中でテストが落ちる
- Graceful restartを実装 -> 5%くらい落ちる
- Graceful shutdownも一緒に実装 -> 1%ぐらい落ちる
という結果でした。Graceful restartもGraceful shutdownもやって1%落ちるのは考察にはなりますがSO_REUSERPORTの弱点としてワーカーごとに listen キューが作られるため到着したリクエストがカーネルによってプロセスAに割り振られてaccept(2)されていない状態でshutdown処理が走るとそのリクエストは取りこぼしてしまうとういものがあるのでそれかなといった感じでした。
Linux カーネル v5.14 から取り込まれたというnet.ipv4.tcp_migrate_reqを有効化することで解決しそうだがどうなんだろう?(手元になかったので未検証...)
これを有効にするとカーネルが本来コネクションを破棄していたタイミングで、同じ IP アドレスとポート番号への通信を待ち受けるソケットをランダムに選択してコネクションを移植します