Shogo's Blog

Nov 23, 2015 - 2 minute read - go golang

Go言語でGraceful Restartをするときに取りこぼしを少なくする

少し前にStarletにGraceful Restartが時たま上手く動かない問題を修正するpullreqを投げました。 原因は割り込みハンドラ内でexitを呼んでいたからでした。 「割り込みハンドラ内ではフラグを建てるだけ」 「メインのプログラム内でそのフラグを見て分岐する」という原則があるのですが、それを守るのは難しいということですね。 (しかし新たな問題を産んでしまいrevertされてしまいましたが・・・ まあ修正後のコードも考え方は一緒です。割り込みホント難しい・・・)

このpullreqを取り込んでもらうときに再現実験をやってみたのですが、 Goでもちゃんと動くのかな?と気になったので Go言語でGraceful Restartをするで紹介した プログラムに同じテストをやってみました。

2017-01-22追記: Go1.8以降でGraceful Shutdownがbuild-inになるので、この記事で紹介したライブラリは不要となりました。 詳しくはGo1.8のGraceful Shutdownとgo-gracedownの対応を参照。

mannersでテストしてみる

前回の記事ではmannersgo-server-starterの 組み合わせが良さそうとの結論になったので、この組み合わせでテストしてみます。 以下テストに使用したコードです。 (今回の内容とは直接関係は無いですが、go-server-starterに変更が入ってFallbackのやり方が前回から少し変わってます)

package main

import (
        "fmt"
        "log"
        "net"
        "net/http"
        "os"
        "os/signal"
        "syscall"
        "time"

        "github.com/braintree/manners"
        "github.com/lestrrat/go-server-starter/listener"
)

var now = time.Now()

func main() {
        log.Printf("start pid %d\n", os.Getpid())

        signal_chan := make(chan os.Signal)
        signal.Notify(signal_chan, syscall.SIGTERM)
        go func() {
                for {
                        s := <-signal_chan
                        if s == syscall.SIGTERM {
                                log.Printf("SIGTERM!!!!\n")
                                manners.Close()
                        }
                }
        }()

        listeners, err := listener.ListenAll()
        if err != nil && err != listener.ErrNoListeningTarget {
                panic(err)
        }
        var l net.Listener
        if err == listener.ErrNoListeningTarget {
                // Fallback if not running under Server::Starter
                l, err = net.Listen("tcp", ":8080")
                if err != nil {
                        panic("Failed to listen to port 8080")
                }
        } else {
                l = listeners[0]
        }

        manners.Serve(l, newHandler())
}

func newHandler() http.Handler {
        mux := http.NewServeMux()
        mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
                fmt.Fprintf(
                        w,
                        "from pid %d.\n",
                        os.Getpid(),
                )
        })
        return mux
}

1秒毎にgraceful restartを行いながら負荷をかけます。 以下のコマンドを別々のターミナルから実行します。 go run main.goだと自分で書いたプログラムがシグナルを受け取れなくなってしまうので、 go build main.goとコンパイルしてから実行するところがポイントです。

$ start_server --port 8080 --pid-file app.pid -- ./main
$ while true; do kill -HUP `cat app.pid`; sleep 1; done
$ ab -r -n 1000000 -c 10 http://localhost:8080/

2017-01-22追記: 上記コマンド、start_serverの引数が--pid app.pidとなっていましたが、--pid-file app.pidでした。 Perl版のServer::Starterは一番名前の近いオプションに勝手に解釈してくれる(ちょっとお節介過ぎると思う)ので、 間違っていても動きますが、Go版では動きません。

mannersを使った場合の実験結果へのリンクを貼っておきます。 「Failed requests: 122」となっており、残念ながら取りこぼしが発生してしまいました。 負荷をかけた時間は72.437秒なので、70回ほどリスタートをかけたことになります。 github-flowを採用しているようなところだと毎日数回デプロイをするということも珍しくないので、 1〜2ヶ月も運用していれば一度くらいはこの現象に遭遇することになります(秒間1万リクエストさばく必要のあるようなサービスの場合ですが)。 ちょっと気になりますね。

自分で書いてみた

mannersの改造、難しそうだったので、自分で書いてみました。

mannersと全く同じインタフェースなので、s/manners/gracedown/するだけです。 これを使って実験してみた結果がこちら https://gist.github.com/shogo82148/a1524f31292202ec34f3#file-gracedown 「Failed requests: 0」やったね!

その他メモ

これ書くのに色々しらべたのでメモとして残しておきます。

acceptの直後にcloseされた場合の挙動について

Starletで起こっていた不具合の原因は、 acceptから処理が帰ってきてからcanExitフラグを落とすまでにわずかな期間があるのが問題でした。 この期間にシグナルを受け取ると間違えてサーバを終了させてしまいます。 GoでもacceptしてからステートがStateNewになるまでの間に若干の時間差があるので、 ここが問題にならないか少し気になっていました。

net/httpの処理を追ってみると、acceptとStateNewはServe(net.Listener)を実行したのと同じgoroutineで実行されているようです。 したがって、サーバシャットダウンの判定もServe(net.Listener)と同じgoroutineで行えば、 誤ってacceptしてからステートがStateNewになるまでの間にサーバをシャットダウンしてしまうことは防げるということがわかりました。

Acceptがブロックしているのを解除する方法について

UnixListener.Closeでソケットファイルが消えて困っている という記事に

POSIX では Close() を呼んだからといって Accept() が制御を戻してくれる保証はないといことでしょうか。

という一文が書いてありました。

これについて実際はどうなんだろうと調べてみたところhttps://golang.org/pkg/net/#Listenerのコメントに

Close closes the listener. Any blocked Accept operations will be unblocked and return errors.

とありました。 このコメントを読む限りGo言語では「Close() を呼んだらAccept()が制御を戻す」と考えて良さそうです。 POSIXでどう規定されているかまでは調査しきれていませんが、 たとえどう規定されていようとも互換性を保つために裏で色々やってくれていると信じています。

この記事の主題である「UnixListener.Closeでソケットファイルが消えて困っている」件についても調べてはみたのですが、 結論は出ませんでした・・・。 たしかにソケットファイルは使い終わったらunlinkすることが推奨されているということがわかったくらいです。 nameが「@」で始まっていると「abstract socket address」と見なされて削除されなくなるから、「@」をテキトウにつけるとか・・・?

keep-aliveの挙動について

mannersはKeep-Aliveなコネクションがあった場合でも、それがIdle状態であればシャットダウンしてしまいます。 それに対してgo-gracedownは全部のコネクションがClosedになるまでまちます。 終了処理に入った段階でKeep-Aliveは無効にしているので、 go-gracedown側で特に操作しなくてもnet/httpがそのうちクローズしてくれるだろうとの考えからです。

Keep-Aliveはクライアントからリクエストがないと切断できない(レスポンスに「Connection: Close」ヘッダを含める必要があるため)ので リクエストがないと永遠にシャットダウンできません。 それでは困るので一応タイムアウトも入れてあります。

この挙動のおかげでboom(http benchで検索したら一番上にきた)でのベンチでも エラー無しで処理できています。 ちなみにApache Benchでも-kオプションでKeep-Aliveを有効にできるのですが、 HTTP/1.0だと「Connection: Close」を送る方法が使えないので、残念ながらエラーが出てしまいました

まとめ

  • 実験の結果mannersはときどきGraceful Shutdownに失敗する場合があることがわかった
  • go-gracedownというのを書いてみた
    • 今回行った再現実験ではすべてのリクエストを正常に処理できました
  • Graceful Restartむずかしい