本番環境でデフォルトの HTTP Client を使ってはいけない理由について

2023-10-11

はじめに

こんにちは。Belong でエンジニアをしている Mohiro です。
今回はタイトルの通り、Go では本番環境でデフォルトの HTTP Client を使ってはいけない理由についてまとめました。
以下書籍の中で取り上げられた内容の一つで、私自身がこの間違いを犯してしまっていたで戒めとして本ブログを書きました。
Go 言語 100Tips ありがちなミスを把握し、実装を最適化する

デフォルトの HTTP Client を使ってはいけない理由

理由 1:タイムアウトが設定されない

HTTP Client はデフォルトではタイムアウトを設定しません。 以下のコードからデフォルトクライアントがサーバーの応答を待ち続ける挙動が確認できます。

package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"
	"time"
)

func main() {
	svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		time.Sleep(time.Minute)
	}))
	time.Now()
	defer svr.Close()
	startTime := time.Now()
	fmt.Println("making request")
	http.Get(svr.URL)
	fmt.Printf("finished request %v", time.Since(startTime))
}

解決策:タイムアウトが設定を設定する

解決策は当然タイムアウトを設定することなのですが、注意点としてタイムアウトはステップ毎に決める必要があります。 以下は HTTP Request のステップとクライアントの主なタイムアウトとの関係を示す図です。

「Go net/http タイムアウト」の完全ガイド The Types of Timeouts Diagram

各タイムアウトの説明は以下の通りです。

  • http.Client.Timeout:リクエストの完了までに要する時間を制限する。
  • net.Dialer.Timeout:TCP 接続の確立に要する時間を制限する。
  • http.Transport.TLSHandshakeTimeout:TLS ハンドシェイクの実行に要する時間を制限する。
  • http.Transport.ResponseHeaderTimeout:レスポンスのヘッダーを読み取る時間を制限する。
  • http.Transport.IdleConnTimeout:コネクションを閉じる前の Idle 時間を制御する。

先ほどのコードに上図に登場したタイムアウトを追加したコードが以下になります。 実行するとタイムアウトエラーが返されていることが確認できます。

package main

import (
	"fmt"
	"net"
	"net/http"
	"net/http/httptest"
	"time"
)

func main() {
	client := &http.Client{
		Timeout: 5 * time.Second,
		Transport: &http.Transport{
			DialContext: (&net.Dialer{
				Timeout: time.Second,
			}).DialContext,
			TLSHandshakeTimeout:   time.Second,
			ResponseHeaderTimeout: time.Second,
			IdleConnTimeout: time.Second,
		},
	}
	svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		time.Sleep(time.Minute)
	}))
	defer svr.Close()
	startTime := time.Now()
	fmt.Println("making request")
	client.Get(svr.URL)
	fmt.Printf("finished request %v", time.Since(startTime))
}



// Output:
making request
finished request
httptest.Server blocked in Close after 5 seconds, waiting for connections:
*net.TCPConn 0x14000092010 127.0.0.1:57323 in state active

理由 2:コネクションの処理方法

デフォルトでは、Go の HTTP クライアントはコネクションプールを実行します。リクエストが完了すると、その接続はアイドル接続タイムアウト (デフォルトは 90 秒) まで開いたままになります。新しい接続を作成する代わりに、同じ確立された接続を使用する別のリクエストが来た場合、アイドル接続時間が経過すると、接続はプールに戻ります。

コネクションプールを使用すると、開いている接続が少なくなり、最小限のサーバーリソースでより多くのリクエストが処理されます。
http.Client でトランスポートを定義していない場合は、デフォルトのトランスポートが使用されます(ソースコード)。

問題点となるのは DefaultMaxIdleConnsPerHost の値が 2 であるという点です(ソースコード)。
この設定では、例えば同一のホストに 100 リクエストを送った場合、その後にコネクションプールに残るのは 2 つのみとなります。そのため、再度 100 リクエストを送る際には 98 リクエスト分のコネクションを再び確立することになります。

解決策:MaxIdleConnsPerHost を増やす。

以下のようなコードで MaxIdleConnsPerHost を増やすことができます。

t := http.DefaultTransport.(*http.Transport).Clone()
t.MaxIdleConns = 100
t.MaxConnsPerHost = 100
t.MaxIdleConnsPerHost = 100

httpClient = &http.Client{
Timeout:   10 * time.Second,
Transport: t,
}

おわりに

今回は本番環境でデフォルトの HTTP Client を使ってはいけない理由についてまとめました。
他言語ではグローバルのデフォルトタイムアウト設定が利用されるものもあるので忘れがちな点かもしれないですね。
弊社 Belong では一緒にサービスを育てる仲間を募集しています。
もし弊社に興味を持っていただけたら 弊社の紹介ページ をご覧いただけたら幸いです。

参考資料