Go 1.8 から Graceful Shutdown 機能が標準で提供されるようになりました。これまではサードパーティーライブラリを使ってその機能を実装していた方も多いのではないでしょうか。
標準ライブラリにあるのだからサードパーティーライブラリから移行しようと考えている方も多いでしょう。私も少し前に移行を行ったのですが、移行前に使っていたサードパーティーライブラリと振る舞いが違うという話を同僚から聞いたので Go 1.8 の Graceful Shutdown の振る舞いについて調べてみました。
調査に使ったソースコードは以下になります。
基本的な使い方
context を使ってタイムアウト制御できるのが Go らしくて便利です。例えば、以下のように任意の時間を設定して Shutdown()
を呼び出すことでクライアントと通信中であっても強制的に終了することができます。
ctx, cancel := context.WithTimeout(context.Background(), *s.ShutdownTimeout)
defer cancel()
if err := httpServer.Shutdown(ctx); err != nil {
log.Println("Failed to gracefully shutdown HTTPServer:", err)
}
log.Println("HTTPServer shutdown.")
Shutdown すると Serve から ErrServerClosed が返る
ここで Shutdown()
の中身をちょっと覗いてみます。
func (srv *Server) Shutdown(ctx context.Context) error {
...
srv.closeDoneChanLocked()
shutdown()
処理の内部で srv.closeDoneChanLocked()
が呼び出されて doneChan
が close されます。そうすると Serve()
から ErrServerClosed エラーが返されます。
func (srv *Server) Serve(l net.Listener) error {
...
for {
rw, e := l.Accept()
if e != nil {
select {
case <-srv.getDoneChan():
return ErrServerClosed
例えば、以下のように ListenAndServe()
でエラーが返ってくることを意図していないコードだと問題になるかもしれません。
if err := httpServer.ListenAndServe(); err != nil {
log.Fatalln("HTTPServer closed with error:", err)
}
その場合は、以下のように ErrServerClosed を除外するようにしないといけないかもしれません。
if err := httpServer.ListenAndServe(); err != nil {
log.Println("ListenAndServe returns an error", err)
if err != http.ErrServerClosed {
log.Fatalln("HTTPServer closed with error:", err)
}
}
log.Println("ListenAndServe goroutine completed.")
net/http/#Server.Serve のドキュメントにもそう書いてありますが、見逃していて後から気付く人も多いのではないでしょうか。
Serve always returns a non-nil error. After Shutdown or Close, the returned error is ErrServerClosed.
また、たまたま見つけたのですが、net/http: document ErrServerClosed #19085 で ErrServerClosed にもコメントが追加されていました。
// ErrServerClosed is returned by the Server's Serve, ListenAndServe,
// and ListenAndServeTLS methods after a call to Shutdown or Close.
var ErrServerClosed = errors.New("http: Server closed")
Shutdown がタイムアウトしても通信中のコネクションはクローズされない
この振る舞いがなにか問題を引き起こすのかどうか、私はよく分かっていないのですが、こういった動きになっているという話も同僚から聞いたので調べてみました。
以下のようにわざと時間のかかるハンドラー処理を実装します。
func hello(w http.ResponseWriter, r *http.Request) {
log.Println("hello called")
w.Header().Set("Content-Type", "text/plain")
w.Write([]byte("hello\n"))
time.Sleep(5 * time.Second)
log.Println("write again")
w.Write([]byte("hello again\n"))
time.Sleep(5 * time.Second)
log.Println("write bye")
w.Write([]byte("bye\n"))
}
普通の用途なら Shutdown()
が正常終了しようがタイムアウトしようが、シャットダウン後の制御としてそのサーバープロセスを終了するだけであれば、あまり気にする必要はないのかもしれません。
ここでは以下の s.Serve(sigCh)
が context の DeadlineExceeded が発生して、タイムアウトによって制御が返ってくると仮定します。その後、スリープしてすぐにプロセスが終了しないようにしています。
s := NewServer(*port, shutdownTimeout, *mustClose)
s.Serve(sigCh)
log.Printf("Server shutdown, but waiting 5 second to exit process ...")
time.Sleep(5 * time.Second)
このときプロセスの終了前であれば、シャットダウン (タイムアウト) 後もクライアントとのコネクションは接続されたままとなっていてプロセスの終了前であれば、そのまま通信できてしまいます。
$ curl -v localhost:8000/hello
* Trying ::1...
* Connected to localhost (::1) port 8000 (#0)
> GET /hello HTTP/1.1
> Host: localhost:8000
> User-Agent: curl/7.43.0
> Accept: */*
>
< HTTP/1.1 200 OK
< Content-Type: text/plain
< Date: Sat, 18 Mar 2017 05:28:32 GMT
< Content-Length: 22
<
hello
hello again
bye
* Connection #0 to host localhost left intact
もしこの振る舞いを抑制したいのであれば、Shutdown()
を呼び出してタイムアウトが発生したら明示的に Close()
を呼び出すようにします。
ctx, cancel := context.WithTimeout(context.Background(), *s.ShutdownTimeout)
defer cancel()
if err := httpServer.Shutdown(ctx); err != nil {
log.Println("Failed to gracefully shutdown HTTPServer:", err)
httpServer.Close()
log.Println("Server closed immediately")
}
log.Println("HTTPServer shutdown.")
$ curl -v localhost:8000/hello
* Trying ::1...
* Connected to localhost (::1) port 8000 (#0)
> GET /hello HTTP/1.1
> Host: localhost:8000
> User-Agent: curl/7.43.0
> Accept: */*
>
* Empty reply from server
* Connection #0 to host localhost left intact
curl: (52) Empty reply from server
今度はシャットダウン (タイムアウト) 後、すぐにコネクションがクローズされてエラーになりました。
まとめ
大した話題ではありませんが、サードパーティーのライブラリから移行するときにサーバーの振る舞いが異なると、ログ監視などで発見して調べる人もいるんじゃないかなと思います。