GeekFactory

int128.hatenablog.com

特定URLへのアクセスを契機としたHTTPサーバのGraceful Shutdown

Goで特定のURLへのアクセス(例:GET /shutdown)を受けたらHTTPサーバを停止するにはどうすればよいか考えてみました。

HTTPサーバを停止する

http.Server にはGraceful Shutdownを行う Shutdown メソッドがあります。/shutdown へのリクエストを受けた契機で Server.Shutdown を実行すれば停止できそうです。

func main() {
    m := http.NewServeMux()
    s := http.Server{Addr: ":8000", Handler: m}
    m.HandleFunc("/shutdown", func(w http.ResponseWriter, r *http.Request) {
        s.Shutdown(context.Background())
    })
    if err := s.ListenAndServe(); err != nil && err != http.ErrServerClosed {
        log.Fatal(err)
    }
    log.Printf("Finished")
}

HTTPサーバを実行して /shutdown にリクエストを投げると、期待通りにHTTPサーバが終了することが分かります。

% go run main.go
2018/03/28 20:22:23 Finished
% curl -v localhost:8000/shutdown
*   Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 8000 (#0)
> GET /shutdown HTTP/1.1
> Host: localhost:8000
> User-Agent: curl/7.54.0
> Accept: */*
>
* Empty reply from server
* Connection #0 to host localhost left intact
curl: (52) Empty reply from server

レスポンスを返す

Shutdown メソッドを実行する前にレスポンスを出力してみます。

func main() {
    m := http.NewServeMux()
    s := http.Server{Addr: ":8000", Handler: m}
    m.HandleFunc("/shutdown", func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("OK"))    // レスポンスを返す
        if err := s.Shutdown(context.Background()); err != nil {
            log.Fatal(err)
        }
    })
    if err := s.ListenAndServe(); err != nil && err != http.ErrServerClosed {
        log.Fatal(err)
    }
    log.Printf("Finished")
}

残念ながらレスポンスは返ってきません。HTTPサーバがレスポンスを返す前に停止してしまうためです。

% curl -v localhost:8000/shutdown
*   Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 8000 (#0)
> GET /shutdown HTTP/1.1
> Host: localhost:8000
> User-Agent: curl/7.54.0
> Accept: */*
>
* Empty reply from server
* Connection #0 to host localhost left intact
curl: (52) Empty reply from server

解1: goroutineからShutdownを呼び出す

そこで、HTTPハンドラとは別のgoroutineから Shutdown メソッドを実行してみます。

func main() {
    m := http.NewServeMux()
    s := http.Server{Addr: ":8000", Handler: m}
    m.HandleFunc("/shutdown", func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("OK"))
        go func() {
            if err := s.Shutdown(context.Background()); err != nil {
                log.Fatal(err)
            }
        }()
    })
    if err := s.ListenAndServe(); err != nil && err != http.ErrServerClosed {
        log.Fatal(err)
    }
    log.Printf("Finished")
}

レスポンスが返された後にHTTPサーバが停止していることが分かります。

% go run main.go
2018/03/28 22:43:09 Finished
% curl -v localhost:8000/shutdown
*   Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 8000 (#0)
> GET /shutdown HTTP/1.1
> Host: localhost:8000
> User-Agent: curl/7.54.0
> Accept: */*
>
< HTTP/1.1 200 OK
< Date: Wed, 28 Mar 2018 13:43:09 GMT
< Content-Length: 2
< Content-Type: text/plain; charset=utf-8
<
* Connection #0 to host localhost left intact
OK

手元で試した限りでは Shutdown メソッドは何回も呼ばれても問題ないようです。

% for i in {1..10}; do curl localhost:8000/shutdown &; done
...
OKOKOK
...

解2: context.Contextで待ち合わせる

Shutdown メソッドのコメントに気になる記述がありました。Shutdown メソッドが制御を返してからプログラムを終了すべきとのことです。

When Shutdown is called, Serve, ListenAndServe, and ListenAndServeTLS immediately return ErrServerClosed. Make sure the program doesn't exit and waits instead for Shutdown to return.

そこで、Context がキャンセルされたらHTTPサーバを停止してプログラムを終了するようにします。

func main() {
    m := http.NewServeMux()
    s := http.Server{Addr: ":8000", Handler: m}
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()
    m.HandleFunc("/shutdown", func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("OK"))
        // shutdown endpointへのリクエストを受けたらcontextをキャンセルする
        cancel()
    })
    go func() {
        if err := s.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatal(err)
        }
    }()
    select {
    case <-ctx.Done():
        // contextがキャンセルされたらHTTPサーバを停止する
        s.Shutdown(ctx)
    }
    log.Printf("Finished")
}

レスポンスが返された後にHTTPサーバが停止していることが分かります。

% go run main.go
2018/03/28 20:33:53 Finished
% curl -v localhost:8000/shutdown
*   Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 8000 (#0)
> GET /shutdown HTTP/1.1
> Host: localhost:8000
> User-Agent: curl/7.54.0
> Accept: */*
>
< HTTP/1.1 200 OK
< Date: Wed, 28 Mar 2018 11:33:53 GMT
< Content-Length: 2
< Content-Type: text/plain; charset=utf-8
<
* Connection #0 to host localhost left intact
OK

リクエストの内容を使った後続処理

HTTPサーバを停止した後、リクエストのクエリパラメータを使って後続処理を行うことを考えます。

リクエストを受けたらchannelにクエリパラメータを送るように修正します。

func main() {
    m := http.NewServeMux()
    s := http.Server{Addr: ":8000", Handler: m}
    codeCh := make(chan string)
    m.HandleFunc("/shutdown", func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("OK"))
        // クエリパラメータをchannelに送る
        codeCh <- r.URL.Query().Get("code")
    })
    go func() {
        if err := s.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatal(err)
        }
    }()
    select {
    case code := <-codeCh:
        // HTTPサーバを停止した後にクエリパラメータを使った処理を行う
        s.Shutdown(context.Background())
        log.Printf("Got code=%s", code)
    }
    log.Printf("Finished")
}

/shutdown にリクエストを投げると、クエリパラメータが表示されてからプログラムが終了することが分かります。

% go run main.go
2018/03/28 23:16:26 Got code=abc
2018/03/28 23:16:26 Finished
% curl -v "localhost:8000/shutdown?code=abc"
*   Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 8000 (#0)
> GET /shutdown?code=abc HTTP/1.1
> Host: localhost:8000
> User-Agent: curl/7.54.0
> Accept: */*
>
< HTTP/1.1 200 OK
< Date: Wed, 28 Mar 2018 14:16:26 GMT
< Content-Length: 2
< Content-Type: text/plain; charset=utf-8
<
* Connection #0 to host localhost left intact
OK

まとめ

本稿で紹介した方法を使うと、シグナルの代わりにREST APIでHTTPサーバを停止することが可能です。また、int128/kubeloginではこの方法を応用することでOpenID Connectの認可コードの受け取りと後続処理を行っています。