Google Kubernetes Engine上のGoアプリケーションでのHTTPリクエストを行う方法

この記事はMERPAY TECH OPENNESS MONTHの最後の記事です。

こんにちは、メルペイのバックエンドエンジニアの@gia.nguyenです。
私は第3回のGopher道場を卒業してから2018年12月に入社しました。

他の記事にも記載されましたが、メルペイのバックエンドは、Google Kubernetes Engine(以下、GKE)を使用して、マイクロサービスアーキテクチャを採用した多数のマイクロサービスから構成されています。マイクロサービスは主にGo言語を使って、開発しています。マイクロサービス間のコミュニケーションはgRPCでやり取りしていますが、外部サービスに対してはほとんどHTTPで通信を行っています。本記事はGKE上のGoアプリケーションでHTTPリクエストを投げる際、いくつか工夫した点を紹介したいと思います。

TL;DR

  • httptraceはデバッグに役立つので使いましょう
  • GKEのDNSが不安定なのでdnscacheを使いましょう
  • サーバーがHTTP/2に対応すれば、Goのクライアントはhttp2.Transportを使うので意識しておいたほうがいいです
  • http2.Transportを使うならGo 1.12以上を使いましょう
  • HTTP/2のMAX_CONCURRENT_STREAMSの設定を上げられなければ、複数のHTTPクライアントか一定のコネクション数を維持するプールを使えば、パフォーマンスがより良くなります

HTTPリクエストの超基本的な投げ方

GoでHTTPリクエストを投げたい場合、まずはnet/httpパッケージのExampleを参照しますよね?
一番簡単なやり方はこちらです。

resp, err := http.Get("http://example.com/")
if err != nil {
    // handle error
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)

GoDocより引用

しかし、これだけだと、GoがDefaultClientを使って、リクエストを行ってしまいます。デフォルトクライアントはTimeoutすら設定していないので使うべきものではありません。下記のように別のクライアントを作って、必要な設定をしてからリクエストを投げたほうがいいでしょう。

client := &http.Client{
    Timeout: 5 * time.Second,
    // ...
}
resp, err := client.Get("http://example.com")
// ...

httptrace

バグがないソフトウェアは存在しません。問題調査、いわゆるデバッギングはソフトウェアエンジニアの日常タスクです。プログラムがどういう状態で問題が発生したかは何らかの手段で把握できれば、問題をすばやく解決できるかもしれません。なので、HTTP通信におけるトレーシングはソフトウェア開発にとって、不可欠な部分です。メルペイでは主にDatadogを利用して、アプリケーションのトレーシングを行っています。Datadogが提供しているdd-trace-goをアプリケーションに組み込んで、トレース情報をDatadogサーバーに送って、Datadogのコンソールで確認は出来ます。詳細は先日cowsysさんが寄稿したこちらの記事を参照してください。

GoはHTTPリクエストのトレースをするために、httptraceパッケージを提供しています。このパッケージはHTTPリクエストの色んなステージでのフックを提供しています。それらのフックを使えば、リクエストがどこまで実行されているか、もしくはどこでエラーになったかを確認出来るようになります。私のチームではリクエストの各ステージでどれくらい時間がかかったか測るために、このパッケージと前述したdd-trace-goパッケージを一緒に利用しています。

func DoRequest(ctx context.Context, req *http.Request) {
    var getConnSpan, dnsSpan, connectSpan, tlsHandshakeSpan, waitForResponseSpan ddtrace.Span
    trace := &httptrace.ClientTrace{
        GetConn: func(hostPort string) {
             getConnSpan, _ = ddtracer.StartSpanFromContext(ctx, "get conn")
        },
        GotConn: func(connInfo httptrace.GotConnInfo) {
             finishSpan(getConnSpan, err)
        },
        DNSStart: func(info httptrace.DNSStartInfo) {
             dnsSpan, _ = ddtracer.StartSpanFromContext(ctx, "dns resolve")
       },
        DNSDone: func(dnsInfo httptrace.DNSDoneInfo) {
            finishSpan(dnsSpan, dnsInfo.Err)
        },
        ConnectStart: func(network, addr string) {
            connectSpan, _ = ddtracer.StartSpanFromContext(ctx, "connect")
        },
        ConnectDone: func(network, addr string, err error) {
            finishSpan(connectSpan, err)
        },
        TLSHandshakeStart: func() {
            tlsHandshakeSpan, _ = ddtracer.StartSpanFromContext(ctx, "tls handshake")
        },
        TLSHandshakeDone: func(state tls.ConnectionState, err error) {
            finishSpan(tlsHandshakeSpan, err)
        },
        WroteRequest: func(info httptrace.WroteRequestInfo) {
            waitForResponseSpan, _ = ddtracer.StartSpanFromContext(ctx, "wait for response")
        },
        GotFirstResponseByte: func() {
            finishSpan(waitForResponseSpan, err)
        },
    }
    req = req.WithContext(httptrace.WithClientTrace(ctx, trace))
    resp, err := client.Do(req)
    // ...
}
func finishSpan(span ddtrace.Span, err error) {
    if span != nil {
        span.Finish(ddtracer.WithError(err), ddtracer.NoDebugStack())
    }
}

dnscache

GKEの環境でDNSが不安定になっているという話がありました。DNS解決には5秒以上かかったことがあります。アプリケーションレイヤでDNSをキャッシュしたほうがいいです。メルカリがOSSにしたdnscacheパッケージを使えば簡単にDNSをキャッシュすることができます。。dnscacheが提供しているDialFuncの戻り値をhttp.TransportDialContextに代入は出来ます。baseDialFuncnilを渡せば、デフォルトのDialerが使われますが、Timeoutが設定されないのでカスタマイズしたものを渡したほうがいいでしょう。

const timeout = 3 * time.Second
resolver, err := dnscache.New(5*time.Minute, timeout, zap.NewNop())
if err != nil {
     return nil, err
}
baseDialFunc := (&net.Dialer{
    Timeout:   timeout,
    KeepAlive: 30 * time.Second,
    DualStack: true,
}).DialContext
client = &http.Client{
    Transport: &http.Transport{
        DialContext: dnscache.DialFunc(resolver, baseDialFunc),
    // ...
    },
    Timeout: timeout,
}    

ここで実際に発生した一つの問題を紹介したいと思います。
メルペイのマイクロサービスはGKEで動いているので、アクセス制御するために、CLOUD IDENTITY-AWARE PROXY(以下IAP)をよく使っています。IAPで制御されているAPIに叩くためには先にサービスアカウントから生成するOIDCトークンを取得リクエストをGoogleサーバーへ投げます。ここでよく使われているのはoauth2パッケージです。
私が担当しているサービスにもそのOIDCトークンを使って、外部サービスにリクエストするAPIがありました。そのAPIがリリースされたあと、p99レイテンシーは許容範囲内なので大丈夫かと思いました。しかし、ある日他のチームからそのAPIのレスポンスが5秒ぐらいかかったせいで、彼らのAPIのレイテンシーが上がってしまったという連絡を受けました。色んなサービスから叩かれる自分のサービスが極一部のリクエストが遅いだけでも他のサービスに大きい影響を与えてしまうことがわかりました。そこで調査を開始しました。
DatadogのApplication Performance Monitoring(以下APM)で見ると、数時間に一回リクエストのレイテンシーが5秒ぐらいかかるとわかりました。調査した結果、前述したoauth2パッケージのOIDCトークンを取得するところでhttp.DefaultClientが使われていることが原因でした。
https://github.com/golang/oauth2/blob/master/oauth2.go#L342
https://github.com/golang/oauth2/blob/master/internal/transport.go#L23-L33

最初に記載したとおりにDefaultClientはTimeoutすら設定していないのでカスタマイズしたClientを使うべきでした。GKEでも、GoogleサーバーのDNS解決するのに時間がかかったのではないかと思って、dnscacheを組み込んだClientを使うように修正しました。

// myClientは上記と同様なもの
ctx = context.WithValue(ctx, oauth2.HTTPClient, myClient)    
client := oauth2.NewClient(ctx, nil)

※ oauth2のExample (CustomHTTP)を参照

この修正を入れたあと、リクエストの最大レイテンシーが下がりました。
f:id:nguyengia:20190613125328p:plain

HTTP/2とGoクライアント

HTTP/1より効率がいいので最近HTTP/2はどんどん普及しています。HTTP/2に関する資料はインターネット上にたくさん存在しています。最近はHTTP/3も誕生したらしいです。HTTP/2のRFCに書いてあるように、Goのクライアントもプロトコルネゴシエーションを行って、サーバーがHTTP/2を対応していることがわかったらhttp2.Transportに切り替えて、TCPコネクションを多重化して、同一コネクションで複数のHTTPリクエストを処理します。プログラムを実行するときに、GODEBUG=http2debug=2の環境変数を設定すれば、ログにヘッダー、フレームなどの情報が出力されるのでこの振る舞いを確かめることができます。また、HTTP/2を使いたくない場合、GODEBUG=http2client=0でHTTP/1のままでリクエストを行うことが出来ます。
ソースコードの中にhttp.Transportを明示的に指定しても裏側でHTTP/2プロトコルを使って、通信を行う可能性があります。普段は問題ないはずですが、HTTP/1と振る舞いが変わるので意識すると問題が発生する時、調査がしやすいかと思います。

例として、実際に起きた問題を一つ紹介したいと思います。私が担当しているサービスには、外部のサービスとHTTPで通信を行うAPIがあります。たまに一瞬だけレイテンシーが急に上がることがありました。httptraceのおかげでgetConnステージに時間がかかっていることがわかりました。HTTP/2を使っていることを意識していたのでプールからコネクション取得するのになにか問題が有ったのではないかと思いつきました。調査した結果、下記のことがわかりました。

HTTP/2プロトコルはMAX_CONCURRENT_STREAMSというパラメーターがあります。このパラメーターは同一のコネクションで実行できるストリーム数を制限するパラメーターです。Goのクライアントのデフォルト値は1000ですが、プロトコルネゴシエーションでサーバーからもらった値が1000より小さい場合、その値に合わせられます。
サーバー側の設定はGODEBUG=http2debug=2curl-opensslなどのコマンドで確認出来ます。

$curl -v https://example.com
...
* Connection state changed (MAX_CONCURRENT_STREAMS == 100)!
...

Go 1.11では、http2.Transportがずっと一つのコネクションしか使いません。リクエスト数がMAX_CONCURRENT_STREAMSを超えても、新しいコネクションを確立せず、ストリームが開放されるまで待ちます。大量のリクエストを一気に送る際、待ち時間が発生するのでレイテンシーが上がってしまいます。詳細はこのissueを参照してください。Go 1.12のデフォルトではこの振る舞いが廃止されます。リクエスト数がMAX_CONCURRENT_STREAMSを超えたら、新規コネクションが確立されます。但し、Go 1.11と同じような振る舞いにしたければ、この設定があるので有効化することが出来ます。
上記より、Go 1.12にアップグレードしないといけませんでした。幸いにも、Goは後方互換性を保証してくれるのでアップグレードは容易でした。

コネクション確立のオーバーヘッド削減

HTTP/2で通信を行う際、リクエスト数がMAX_CONCURRENT_STREAMSを超えたら新しいコネクションが確立されますが、TCP及びTLSのハンドシェイクなどでコネクションを作成するのにオーバヘッドがあります。このオーバーヘッドを削減するために、まずはMAX_CONCURRENT_STREAMSを上げるべきでしょう。ただ、外部のサービスに設定変更を求めるのはなかなか簡単ではないと思いますので、最初から複数のコネクションを作っておけば回避は出来ます。やり方は2つがあると考えています。
– 1つ目の方法は複数Clientを使って、round robinでリクエストを投げる方法です。

const clientNum = 10
var counter uint32
var clients []*http.Client
func InitClients() {
    clients = make([]*http.Client, clientNum)
    for i := 0; i < len(clients); i++ {
        client := &http.Client{
            Timeout: 5 * time.Second,
        }
        clients[i] = client
    }
    counter = math.MaxUint32
}
func Request() {
    i := atomic.AddUint32(&counter, 1) % clientNum
    res, err := clients[i].Get("http://example.com")
    // ...
}
  • 2つ目の方法は一定のコネクション数を維持するコネクションプールを使う方法です。
    http2.TransportはConnPoolというパラメーターが有って、ClientConnPoolインターフェイスを実装したものは設定出来ます。
type Transport struct {
    // ...
    // ConnPool optionally specifies an alternate connection pool to use.
    // If nil, the default is used.
ConnPool ClientConnPool
    // ...
}
// ClientConnPool manages a pool of HTTP/2 client connections.
type ClientConnPool interface {
    GetClientConn(req *http.Request, addr string) (*ClientConn, error)
    MarkDead(*ClientConn)
}

GoDocより引用

ClientConnPoolを実装する時、下記のライブラリを参照出来るかと思います。
https://godoc.org/gopkg.in/fatih/pool.v2

終わりに

まだ改善余地はたくさんあると思いますが、GKE環境で動くGoのアプリケーションでどうやって効率的にHTTPリクエストを行うかいくつかの手段を紹介させていただきました。少しでも皆さんの役に立ったら嬉しいなと思っています。ご清覧ありがとうございました。

  • X
  • Facebook
  • linkedin
  • このエントリーをはてなブックマークに追加