こんにちは。斎藤です。
ここ1〜2年、私は仕事でGolangを書くことが増えています。きっかけは、ITインフラをお預かりする中で、お客様のサーバにツールを置く場合でも1つのバイナリさえ置けば良いという手軽さからだったのですが、最近はScalaと並び手軽に並列処理が書けるプログラミング言語として重宝しています。
さて、今回はGolangで作ったhttpdの接続数をLimitListener
を利用して接続数の制限をしてみようというお話です。以下に紹介するお話は、Githubのリポジトリ "github.com/koemu/go-http-max-connections-demo" にデモプログラムを保存しています。Golangのビルド環境がある方は、実際にビルドしながらお試しいただければと思います。
※Golang 1.5.1でビルドする前提で説明しています
モティベーション
仕事でとあるAPIゲートウェイをGolangで開発しています。それは、定期的に多量のリクエストをさばく必要がある性質がありました。当初は並列度が柔軟にあがりパフォーマンスがあがることを喜んでいたのですが、高負荷試験をするとその柔軟性が仇になり始めました。そう、無限にスレッドがあがり、処理が輻輳してしまっていたのです。
例えば、Rubyであればunicornの待ち受けプロセス数を制限すれば、接続数が増えることによる輻輳の制御は可能になります。それを、Golangで行う必要性が出たのです。
過去に「Handling 1 Million Requests per Minute with Go · marcio.io」という、チャンネルやキューの技法を用いた最大スレッド数制御の事例はありました。ただ、それよりももう少し楽にできないかと考えてたどり着いたのが、LimitListener
を用いる手法でした。
何もしない状態
ここでは、シンプルなhttpdを書いて検証してみます。
まず、あまり考えずにhttpdを書いた場合は次のコードになります。time.Sleep()
している部分がありますが、これは何もしないと1msec程度で処理が終わってしまうため、後に接続の状況を観察する際に見やすくするためにわざと待ち時間を入れています。
func DefaultHttpd(c *cli.Context) {
http.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "text/plain")
time.Sleep(time.Millisecond * 500) // for watch netstat
io.WriteString(w, "Default")
})
http_config := &http.Server{
Addr: fmt.Sprintf(":%d", c.Int("port")),
}
err := http_config.ListenAndServe()
if err != nil {
log.Fatalln(err)
}
}
さて、この状態でApache bench(ab
)をかけてみましょう。接続が多量になる状況を再現するために、200同時接続にしてみます。
$ go build && ./go-http-max-connections-demo default &
$ ab -n 10000 -c 200 http://127.0.0.1:8080/
その上で、デモプログラムが処理中のTCP接続の数をみてみましょう。
$ sudo ss -o state established "( sport = :8080 )" -np | grep users
0 0 ::ffff:127.0.0.1:8080 ::ffff:127.0.0.1:33797 timer:(keepalive,2min59sec,0) users:(("go-http-max-con",9331,40))
0 0 ::ffff:127.0.0.1:8080 ::ffff:127.0.0.1:33947 timer:(keepalive,2min59sec,0) users:(("go-http-max-con",9331,190))
0 0 ::ffff:127.0.0.1:8080 ::ffff:127.0.0.1:33897 timer:(keepalive,2min59sec,0) users:(("go-http-max-con",9331,107))
(以下100行ほど続く)
$ sudo ss -o state established "( sport = :8080 )" -np | grep users | wc -l
119
119です。デフォルトですと結構な数ですね。処理の内容によっては、APサーバに100接続もあると輻輳してしまいかねません。どうにかしたいところです。
※この119という数は実行中にややぶれがありますので、手元で検証できる方は何度かコマンドを実行して観察してみてください。
なお、ab
の結果は、全て正常終了と出ています。
Concurrency Level: 200
Time taken for tests: 25.853 seconds
Complete requests: 10000
Failed requests: 0
Write errors: 0
Total transferred: 1230123 bytes
HTML transferred: 70007 bytes
Requests per second: 386.80 [#/sec] (mean)
Time per request: 517.057 [ms] (mean)
Time per request: 2.585 [ms] (mean, across all concurrent requests)
Transfer rate: 46.47 [Kbytes/sec] received
接続数を制限してみると
では、接続数を10に制限するべく、LimitListener
を使ってみましょう。次のコードになります。少々コードが長くなった程度ですが、これで正しく実行できるのでしょうか。
func Limited(c *cli.Context) {
http.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "text/plain")
time.Sleep(time.Millisecond * 500) // for watch netstat
io.WriteString(w, "Limited")
})
port := fmt.Sprintf(":%d", c.Int("port"))
listener, err := net.Listen("tcp", port)
if err != nil {
log.Fatalln(err)
}
limit_listener := netutil.LimitListener(listener, c.Int("max-connections"))
http_config := &http.Server{
Addr: port,
}
defer limit_listener.Close()
err = http_config.Serve(limit_listener)
if err != nil {
log.Fatalln(err)
}
}
先ほどと同様にab
を同じ条件で実行してみます。デモプログラムの最大同時接続数は10に制限します。
$ go build && ./go-http-max-connections-demo limited -m 10 &
$ ab -n 10000 -c 200 http://127.0.0.1:8080/
その上で、デモプログラムが処理中のTCP接続の数を見てみましょう。
$ sudo ss -o state established "( sport = :8080 )" -np | grep users
0 0 ::ffff:127.0.0.1:8080 ::ffff:127.0.0.1:53668 users:(("go-http-max-con",8536,6))
0 0 ::ffff:127.0.0.1:8080 ::ffff:127.0.0.1:53669 users:(("go-http-max-con",8536,13))
0 0 ::ffff:127.0.0.1:8080 ::ffff:127.0.0.1:53671 users:(("go-http-max-con",8536,9))
0 0 ::ffff:127.0.0.1:8080 ::ffff:127.0.0.1:53667 users:(("go-http-max-con",8536,5))
0 0 ::ffff:127.0.0.1:8080 ::ffff:127.0.0.1:53674 users:(("go-http-max-con",8536,8))
0 0 ::ffff:127.0.0.1:8080 ::ffff:127.0.0.1:53670 users:(("go-http-max-con",8536,14))
0 0 ::ffff:127.0.0.1:8080 ::ffff:127.0.0.1:53666 users:(("go-http-max-con",8536,12))
0 0 ::ffff:127.0.0.1:8080 ::ffff:127.0.0.1:53673 users:(("go-http-max-con",8536,7))
0 0 ::ffff:127.0.0.1:8080 ::ffff:127.0.0.1:53672 users:(("go-http-max-con",8536,11))
0 0 ::ffff:127.0.0.1:8080 ::ffff:127.0.0.1:53675 users:(("go-http-max-con",8536,10))
きれいに10接続で収まっています。これで、意図しない多量のスレッド(goroutine)があがらない状況を作り出すことが確認できました。
なお、ab
の結果は、全て正常終了と出ています。しかし、Time per requestが非常に遅いのです。従って、実運用時はあらかじめベンチマークを実施した上で適切な最大同時接続数を定義し、性能を引き出せるようにしておくことが重要です。
Concurrency Level: 200
Time taken for tests: 501.105 seconds
Complete requests: 10000
Failed requests: 0
Write errors: 0
Total transferred: 1230000 bytes
HTML transferred: 70000 bytes
Requests per second: 19.96 [#/sec] (mean)
Time per request: 10022.091 [ms] (mean)
Time per request: 50.110 [ms] (mean, across all concurrent requests)
Transfer rate: 2.40 [Kbytes/sec] received
実際に利用しているコード例の抜粋
私が現場で利用しているあるコードは、TLSの処理が加わっています。デモコードにはありませんが、参考までにどのように記述しているかをご案内します。
(前略)
// --- Struct
type daemonListener struct {
MaxConnections int
Port string
Handler http.Handler
PublicKey string
PrivateKey string
}
// --- functions
// Daemon mode (agent mode)
func CmdDaemon(c *cli.Context) {
(中略)
// Listener
var lis daemonListener
lis.Port = fmt.Sprintf(":%d", c.Int("port"))
lis.Handler = m // martini handler
lis.MaxConnections = c.Int("max-connections")
lis.PublicKey = c.String("public-key")
lis.PrivateKey = c.String("private-key")
err := lis.listenAndServe()
if err != nil {
log.Fatal(err)
}
}
// HTTPS Listener
func (l *daemonListener) listenAndServe() error {
var err error
cert := make([]tls.Certificate, 1)
cert[0], err = tls.LoadX509KeyPair(l.PublicKey, l.PrivateKey)
if err != nil {
return err
}
tls_config := &tls.Config{
CipherSuites: []uint16{
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
},
PreferServerCipherSuites: true,
MinVersion: tls.VersionTLS12,
NextProtos: []string{"http/1.1"},
Certificates: cert,
}
listener, err := net.Listen("tcp", l.Port)
if err != nil {
return err
}
limit_listener := netutil.LimitListener(listener, l.MaxConnections)
tls_listener := tls.NewListener(limit_listener, tls_config)
http_config := &http.Server{
TLSConfig: tls_config,
Addr: l.Port,
Handler: l.Handler,
ReadTimeout: HTTP_TIMEOUT * time.Second,
WriteTimeout: HTTP_TIMEOUT * time.Second,
}
return http_config.Serve(tls_listener)
}
まとめ
ここまで、LimitListener
を用いてhttpdの最大接続数を制限する方法についてお話ししました。Golangはgoroutineを用いることで非常に手軽に並列処理を書くことができるようになりました。一方で、その手軽さゆえに過負荷時にスレッドが乱立して、I/O処理が輻輳するなどの問題が出てしまうことも珍しくないと聞いています。
Golangの良さを最大限活用できるよう、様々なノウハウがこれからも共有されるといいなと思う、今日この頃でした。
それでは皆様、ごきげんよう。