地方エンジニアの学習日記

興味ある技術の雑なメモだったりを書いてくブログ。たまに日記とガジェット紹介。

【Go】httpサーバを立ててGracefulに色々やる

サンプルアプリ

こんな感じのコードを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を止めるという方法。

ryuichi1208.hateblo.jp

実装はこんな感じ。

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

go.dev

コード雑すぎますがシグナルを受けたら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処理が走るとそのリクエストは取りこぼしてしまうとういものがあるのでそれかなといった感じでした。

dsas.blog.klab.org

Linux カーネル v5.14 から取り込まれたというnet.ipv4.tcp_migrate_reqを有効化することで解決しそうだがどうなんだろう?(手元になかったので未検証...)

これを有効にするとカーネルが本来コネクションを破棄していたタイミングで、同じ IP アドレスとポート番号への通信を待ち受けるソケットをランダムに選択してコネクションを移植します

kuniyu.jp