Upgrade to Pro — share decks privately, control downloads, hide ads and more …

GoでTCPサーバーの GracefulShutdownをシンプルにやる

Masato Inoue
September 07, 2024
38

GoでTCPサーバーの GracefulShutdownをシンプルにやる

Masato Inoue

September 07, 2024
Tweet

Transcript

  1. シグナルとは $ kill -l 1) SIGHUP 2) SIGINT 3) SIGQUIT

    4) SIGILL 5) SIGTRAP 6) SIGABRT 7) SIGEMT 8) SIGFPE 9) SIGKILL 10) SIGBUS 11) SIGSEGV 12) SIGSYS 13) SIGPIPE 14) SIGALRM 15) SIGTERM 16) SIGURG 17) SIGSTOP 18) SIGTSTP 19) SIGCONT 20) SIGCHLD 21) SIGTTIN 22) SIGTTOU 23) SIGIO 24) SIGXCPU 25) SIGXFSZ 26) SIGVTALRM 27) SIGPROF 28) SIGWINCH 29) SIGINFO 30) SIGUSR1 31) SIGUSR2 $ kill -15 PID(プロセスID) ‧シグナルとは各osのカーネルに備わる機能でプロセスへ様々なイベントを通知することが出 来る機能 ‧イベントの通知はコマンドでも他プロセスからでも可能 ‧シグナルの⼀覧及び送信は以下のコマンドで実現出来る ※シグナルに関しての詳しい説明は本題から外れるので割愛します󰢛
  2. TCPサーバーにおけるGracefulShutdownの重要性 シグナルを検知して処理中のTCPコネクションの終了を待機してから TCPサーバーのプロセスを終了させる必要がある 本題のTCP サーバーにおいてプロセスが終了するタイミングはいくつか考えられる ‧実装ミスによるPanic及びRecover漏れによる強制終了 ‧デプロイなどによりサーバーが切り替わるタイミングでのプロセス終了 ‧インシデント起因のサーバープロセス終了 ‧ローカル開発におけるcmd+C TCP

    サーバーが終了する際に処理中のTCPコネクションがある時データ整合成やUXで問題が⽣じる可能性が ex)DBへの登録中にプロセスが強制終了してしまいデータが登録されなかった (レスポンスも返せないためリトライもされない) ex)デプロイ時プロセスが終了した時、商品⼀覧のAPIから⼀瞬502が返されユーザー側にエラーが表⽰される
  3. GracefulShutdownなTCPサーバー実装 func main() { // シグナル以外でプロセス全体を終了させたい場合は WithCancelを使う ctx := context.Background()

    // シグナル起点で Done()が呼ばれる ctxを生成 ctx, cancel := signal.NotifyContext( ctx, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT, syscall.SIGHUP, ) defer cancel() srv, err := newServer() if err != nil { panic(err) } // サーバの起動 go srv.serve(ctx) // シグナルごとに処理が変わる場合は switch // シグナル起点で Done()が呼ばれたら処理中のプロセスを待機して終了とする <-ctx.Done() fmt.Println("server shutdown start") // シャットダウン処理 srv.Shutdown() fmt.Println("server shutdown complete") } ① シグナル受信⽤の Context作成 ③ 処理中プロセスの待機 プロセスのクローズ ② TCPサーバーの起動 コネクションの管理
  4. ① シグナル受信⽤のContext作成 func main() { // シグナル以外でプロセス全体を終了させたい場合は WithCancelを使う ctx :=

    context.Background() // シグナル起点で Done()が呼ばれる ctxを生成 ctx, cancel := signal.NotifyContext( ctx, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT, syscall.SIGHUP, ) defer cancel() os/signal packageのNotifyContextでシグ ナル受信のタイミングでDone()される Contextを⽣成 もしシグナルごとにハンドリングを変えたい 場合はチャネルを作ってselectで待ち受けて 分岐する sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT, syscall.SIGHUP) select { case signal := <-sigChan: switch signal { case syscall.SIGINT, syscall.SIGQUIT: fmt.Println("SIGINT, SIGQUIT server shutdown") // something fmt.Println("SIGINT, SIGQUIT server shutdown") return case syscall.SIGTERM: fmt.Println("SIGTERM server shutdown") // something fmt.Println("SIGTERM server shutdown") default: panic("unexpected signal has been received") } default: }
  5. server構造体にListenerとWaitGroupのフィールドを 持つ newServerでIPとPORTを指定してプロセスを起動す る WaitGroupの⽤途は後述 ② TCPサーバーの起動 & コネクションの管理 type

    server struct { listener *net.TCPListener wg sync.WaitGroup } func newServer() (*server, error) { tcpAddr, err := net.ResolveTCPAddr("tcp", "127.0.0.1:8080") if err != nil { return nil, err } l, err := net.ListenTCP("tcp", tcpAddr) if err != nil { return nil, err } return &server{ listener: l, }, nil }
  6. signalのctx.Done()のハンドリングをしつつ、無 限ループでTCPコネクションを貼り続ける TCPコネクションでエラーになることは滅多にな いが念の為リトライ処理 server構造体のwgをAdd/Doneすることで main.goからshutdownが呼ばれた時にwg.Wait で処理をストップできるようにしておく Goのnet packageを⾒るとKeepAliveのデフォル トが15sのため、それ以上の時間でコネクション

    タイムアウトを設定する ② TCPサーバーの起動 & コネクションの管理 func (s *server) serve(ctx context.Context) { defer func() { if r := recover(); r != nil { fmt.Printf("Recovered from:%v\n", r) } s.listener.Close() }() var tempDelay time.Duration LOOP: for { select { case <-ctx.Done(): // シグナルのctx.Done()が来たらreturnして終了 return default: conn, err := s.listener.AcceptTCP() if err != nil { // タイムアウトエラーの場合は時間を遅延しながらリトライ if netErr, ok := err.(net.Error); ok && netErr.Timeout() { tempDelay *= 5 time.Sleep(tempDelay) if tempDelay > 1*time.Second { // 1秒以上の場合は return error return } continue LOOP } return } conn.SetDeadline(time.Now().Add(30 * time.Second)) // TCPコネクションのタイムアウト設定 s.wg.Add(1) // wg.Add(1)でgoroutineの数をカウント go func() { s.handleConnection(ctx, conn) s.wg.Done() // wg.Done()でgoroutineの数をデクリメント }() } } } 参考 https://github.com/golang/go/blob/807e01db4840e25e4d98911b28a8fa54 244b8dfa/src/net/dial.go#L19 // defaultTCPKeepAlive is a default constant value for TCPKeepAlive times // See go.dev/issue/31510 defaultTCPKeepAlive = 15 * time.Second
  7. ③ 処理中プロセスの待機 & プロセスのクローズ main.goでシグナルのctx.Done()を待機 ctx.Done()のタイミングでshutdownを呼び出し ListnerのCloseとwg.Waitを⾏う ②のTCPコネクションのハンドリングにて goroutineの処理をwgで囲っていたため、 wg.Waitで処理中のプロセスが完了するまで待機

    できる func (s *server) shutdown() { s.listener.Close() s.wg.Wait() } // シグナルごとに処理が変わる場合は switch // シグナル起点で Done()が呼ばれたら処理中のプロセスを待機して終了とする <-ctx.Done() fmt.Println("server shutdown start") // シャットダウン処理 srv.Shutdown() fmt.Println("server shutdown complete") func main()
  8. TCPサーバーを起動した上でGoのスクリプトからTCPサーバーへ リクエスト クライアント側で TCPコネクション後にスリープ TCPコネクションのスリープ中にシグナルイベントを発⾏ 処理中のプロセスが完了してからサーバーが終了することを確認 する 動作確認 $ go

    run main.go start to tcp server :8080 conn read start: 127.0.0.1:59750 conn read start: 127.0.0.1:59745 conn read start: 127.0.0.1:59747 conn read start: 127.0.0.1:59746 conn read start: 127.0.0.1:59749 server shutdown start conn read end: 127.0.0.1:59750 conn read end: 127.0.0.1:59745 conn read end: 127.0.0.1:59749 conn read end: 127.0.0.1:59746 conn read end: 127.0.0.1:59747 server shutdown complete func main() { wg := &sync.WaitGroup{} for i := 0; i < 5; i++ { wg.Add(1) fmt.Println("request_count: ", i) go sendSocketWithWG(wg) } wg.Wait() } func sendSocketWithWG(wg *sync.WaitGroup) { defer wg.Done() message := "HELLO SOCKET SERVER !!!" conn, err := net.Dial("tcp", "127.0.0.1:8080") if err != nil { panic(err) } defer conn.Close() time.Sleep(7 * time.Second) conn.Write([]byte(message)) response := make([]byte, 1000_000) readLen, err := conn.Read(response) if err != nil { log.Println("read error") } fmt.Println(string(response)) } client.go