GoでHTTPサーバーまたはクライアントを書くとき、タイムアウトは、最も間違えやすく、そして最も軽微な間違えです。選択する対象が数多くあり、間違えても、ネットワークの不具合やプロセスがハングアップするまで、長い間、何の影響もありません。

HTTPは複雑な多段階プロトコルであるため、タイムアウトには万能な解決策はありません。ストリーミングエンドポイントと JSON API とCometエンドポイントを考えてみてください。確かに、デフォルトは多くの場合、あなたが望むものではありません。

この記事では、お客様がタイムアウトを適用する必要があるかもしれないさまざまな段階を取り出し、サーバーとクライアント側の両方で、これを行う色々な方法を見ていきます。

SetDeadline

まず、タイムアウトを実装するためにGoが公開するネットワークプリミティブの「期限」について知る必要があります。

net.ConnのSet[Read|Write]Deadline(time.Time)メソッドで公開されているように、期限は絶対時間で、期限に達するとすべてのI/O操作がタイムアウトエラーで失敗します。

期限はタイムアウトではありません。一度セットされると、ずっと機能したままになります(または SetDeadlineに次の呼び出しがくるまで)、その間接続が使用されているか、どのように使用されているかは関係ありません。ですから、 SetDeadlineを使ってタイムアウトを構築するには、すべての読み/書き操作の前にそれを呼び出す必要があります。

お客様は、おそらく自らSetDeadlineを呼び出したいとは考えないはずです。代わりにnet/httpに、より高いレベルのタイムアウトを使って呼び出してもらうことになるでしょう。しかし、すべてのタイムアウトは期限付きで実装され、データが送受信されるたびにリセットわけではないことに注意してください。

サーバーのタイムアウト

ブログ記事「So you want to expose Go on the Internet」(Goをインターネットで公開する) では、サーバータイムアウトに関する情報を多く扱っており、特に、HTTP/2 とGo 1.7のバグについての詳細な説明をしています。

HTTP server phases

インターネットに公開されるHTTP サーバーが、クライアント接続でタイムアウトを実行することは重要です。タイムアウトが実行されなければ、クライアントが非常に遅かったり消えたりして、ファイル記述子の漏えいにつながる可能性があり、最終的に次のようなエラーが生じることになります。

http: Accept error: accept tcp [::]:80: accept4: too many open files; retrying in 5ms

http.Serverでは、 ReadTimeoutと`WriteTimeoutの2つのタイムアウトが公開されています。この二つを設定するには、サーバーを明示的に使用して行います。

srv := &http.Server{
    ReadTimeout: 5 * time.Second,
    WriteTimeout: 10 * time.Second,
}
log.Println(srv.ListenAndServe())

ReadTimeoutは、接続が許可されてからリクエスト本文が完全に読まれるまでの時間をカバーします(本文を読む場合、読まない場合はヘッダーの終わりまで)。これは「許可」した直後にSetReadDedlineを呼び出すことでnet/httpで実装されます。

WriteTimeoutは、通常、リクエストヘッダーの読み込みが完了した後から応答書き込みの最後(ServeHttpの有効期間)までの時間をカバーします。readRequestの最後にSetWriteDeadlineを呼び出すことでこれを行います。

ただし、接続がHTTPSの場合、「許可」の直後にSetWriteDeadlineが呼び出されます。これは、TLSハンドシェイクの一部として書かれたパケットをカバーするためです。やっかいなことに、これは(この場合のみ)WriteTimeoutに最終的にヘッダーの読み込みと最初のバイトの待ち時間が含まれることを意味します。

信頼できないクライアントやネットワークを処理する場合は、両方のタイムアウトを設定する必要があります。これにより、書き込みや読み取りが遅くなることでクライアントの接続が断たれないようにするのです。

最後に、 http.TimeoutHandlerがあります。これは、サーバーパラメーターではなく、ハンドラーラッパーで、 ServeHTTPの呼び出しの最大時間を制限するものです。これは、応答をバッファリングし、期限を超えた場合は、504 Gateway Timeoutを送信します。1.6では壊れていましたが、1.6.2では修正されたことに注意してください。

http.ListenAndServeのやり方は間違っている

ちなみに、これはhttp.Serverをhttp.ListenAndServe、 http.ListenAndServeTLS、 http.Serveのようにバイパスさせるパッケージレベルの利便性はパブリックインターネットサーバーには適さないという意味です。

これらの機能は、デフォルトでタイムアウト値をオフのままにし、これを有効にする方法もないため、これらの機能を使用すると、すぐに接続がリークし、ファイル記述子が不足します。私自身、少なくとも6回はこのミスを経験しました。

その代わりに、数段落前で示したように、ReadTimeoutとWriteTimeoutでhttp.Serverインスタンスを作って、その対応するメソッドを使用します。

ストリーミングについて

非常にやっかいなことに、ServeHTTPからnet.Connの基盤となるものにアクセスする方法がないため、応答をストリームしようとするサーバーが、WriteTimeoutを取り消さざるを得なくなります(これが、デフォルトで0になっている理由とも関係しているかもしれません)。その理由は、net.Connアクセスがなければ、各「Write(書き込み)」が、適切なアイドル(完全ではない)タイムアウトを実装する前に、SetWriteDeadlineを呼び出す方法がないためです。

また、ブロックされたResponseWriter.Writeをキャンセルする方法もありません。それは、ResponseWriter.Close (インターフェイスのアップグレードを介してアクセス可能)が、同時書き込みを解除するように文書化されていないためです。したがって、Timerを使って手動でタイムアウトを構築する方法もないのです。

残念ながら、これはストリーミングサーバーが低速なクライアントから身を守ることができないことを意味します。

私は問題とともにいくつかの提案をあげておりますので、そちらで、ぜひフィードバックをお願いします。

クライアントのタイムアウト

HTTP Client phases

クライアント側のタイムアウトは、使用するタイムアウトによって、より簡単になる場合もあれば、はるかに複雑になる場合もありますが、リソースの漏えいやフリーズを防止するためにも同様に重要です。

最も使いやすいのは、http.ClientのTimeoutフィールドです。これはダイアルから(接続が再利用されていない場合)本文の読み込みまで、交換(exchange)全体をカバーするものです。

c := &http.Client{
    Timeout: 15 * time.Second,
}
resp, err := c.Get("https://blog.filippo.io/")

上記のサーバー側のケースと同様に、http.Getのようなパッケージレベルの機能はクライアントをタイムアウトなしで使用するため、オープンなインターネットでの使用は危険です。

他にもきめ細かな制御があり、お客様側で設定できるタイムアウトは他にも多くあります。

  • net.Dialer.Timeoutは、TCP接続の確立に要する時間を制限します (新しい接続が必要な場合)。
  • http.Transport.TLSHandshakeTimeoutは、TLSハンドシェイクの実行に要する時間を制限します。
  • http.Transport.ResponseHeaderTimeoutは、レスポンスのヘッダーを読み取る時間を制限します。
  • HTTP.Transport.ExpectContinueTimeoutは、「Expect:100-continue」を含むリクエストヘッダーを送信してから本文を送信する許可を受けるまでの間、クライアントが待機する時間を制限します。1.6でこれを設定すると、HTTP/2が無効になること注意してください。(DefaultTransportは、1.6.2から特別なケースになっています)。
c := &http.Client{
    Transport: &http.Transport{
        Dial: (&net.Dialer{
                Timeout:   30 * time.Second,
                KeepAlive: 30 * time.Second,
        }).Dial,
        TLSHandshakeTimeout:   10 * time.Second,
        ResponseHeaderTimeout: 10 * time.Second,
        ExpectContinueTimeout: 1 * time.Second,
    }
}

私が知る限り、リクエストを送信するのにかかる時間を具体的に制限する方法はありません。リクエスト本文の読み取りにかかる時間は、time.Timerで手動でコントロールすることができます。これは、クライアントメソッドが返された後の出来事だからです(リクエストをキャンセルする方法については、以下を参照してください)。

最後に、新しい1.7にはhttp.Transport.IdleConnTimeoutがあります。これは、クライアントリクエストのブロッキングフェーズを制御するものではありませんが、アイドル接続が、接続プールで待機する時間を制御します。

デフォルトでは、クライアントはリダイレクトに従うことに留意してください。 http.Client.Timeoutには、リダイレクト後に費やされるすべての時間が含まれますが、詳細なタイムアウトはリクエストごとに固有です。これは、 http.Transport がリダイレクトの概念をもたない下位レベルのシステムのためです。

キャンセルとコンテキスト

net/httpでは、クライアントリクエストを取り消す方法が2つあります。 Request.Cancelと、1.7の新機能、Contextです。

Request.Cancelはオプショナルなチャネルで、設定してから閉じると、まるで Request.Timeout が選択されたように、リクエストが停止されます。(これらは実際に同じメカニズムで実装されますが、この記事を書いている間に、1.7で バグを1つみつけました。そのバグでは、すべてのキャンセルがタイムアウトエラーとして返されます。)

より細かいタイムアウトの設定を行うには、Request.Cancelとtime.Timerを使用することができます。これならストリーミングが許可され、本文からデータを読み込むたびに、期限が引き伸ばされます。

package main

import (
	"io"
	"io/ioutil"
	"log"
	"net/http"
	"time"
)

func main() {
	c := make(chan struct{})
	timer := time.AfterFunc(5*time.Second, func() {
		close(c)
	})

        // Serve 256 bytes every second.
	req, err := http.NewRequest("GET", "http://httpbin.org/range/2048?duration=8&chunk_size=256", nil)
	if err != nil {
		log.Fatal(err)
	}
	req.Cancel = c

	log.Println("Sending request...")
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		log.Fatal(err)
	}
	defer resp.Body.Close()

	log.Println("Reading body...")
	for {
		timer.Reset(2 * time.Second)
                // Try instead: timer.Reset(50 * time.Millisecond)
		_, err = io.CopyN(ioutil.Discard, resp.Body, 256)
		if err == io.EOF {
			break
		} else if err != nil {
			log.Fatal(err)
		}
	}
}

上記の例では、5秒のタイムアウトをリクエストを実行するフェーズに設定していますが、8ラウンドで本文を読み取るのに少なくとも8秒間を費やし、そのたびに2秒のタイムアウトが必要になります。このようにしながら、途切れることなく、ストリーミングを永久に続けることができます。本文のデータの受信に2秒以上かかる場合は、io.CopyNが「net/http: request canceled」を返します。

1.7では、contextパッケージはスタンダードライブラリに進化しています。そこでは コンテキストに関する情報がたくさんありますが、ここでコンテキストは、Request.Cancelに代わるもので、非推奨であることを理解してください。

コンテキストを使ってリクエストをキャンセルするには、context.WithCancelで、新しいコンテキストとcancel()機能を入手して、Request.WithContextと結び付けられているリクエストを作成します。リクエストをキャンセルしたい場合には、 cancel()を呼び出すことで、コンテキストをキャンセルします。 (キャンセルチャネルを閉じる代わりにこれを行います)。

ctx, cancel := context.WithCancel(context.TODO())
timer := time.AfterFunc(5*time.Second, func() {
	cancel()
})

req, err := http.NewRequest("GET", "http://httpbin.org/range/2048?duration=8&chunk_size=256", nil)
if err != nil {
	log.Fatal(err)
}
req = req.WithContext(ctx)

コンテキストには利点があり、親コンテキスト(我々がcontext.WithCancelに送信したもの)がキャンセルされると、我々の分もキャンセルされ、コマンドがパイプライン全体に伝達されます。

これでおしまいです。読者のみなさまのReadDeadlineを超過していないといいのですが!

もし、Goスタンダードライブラリに関する詳細情報などにご関心がおありでしたら、当社では新しい人材を、ロンドン、オースティン(TX)、シャンペーン(IL)、サンフランシスコ、シンガポールで募集中です。