Golangで作ったhttpdの接続数を制限してみよう

こんにちは。斎藤です。

ここ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の良さを最大限活用できるよう、様々なノウハウがこれからも共有されるといいなと思う、今日この頃でした。

それでは皆様、ごきげんよう。

株式会社ハートビーツのインフラエンジニアから、ちょっとした情報をお届けします。