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 サーバーが、クライアント接続でタイムアウトを実行することは重要です。タイムアウトが実行されなければ、クライアントが非常に遅かったり消えたりして、ファイル記述子の漏えいにつながる可能性があり、最終的に次のようなエラーが生じることになります。
http: Accept error: accept tcp [::]:80: accept4: too many open files; retrying in 5ms
http: Accept error: accept tcp [::]:80: accept4: too many open files; retrying in 5ms
srv := &http.Server{
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
}
log.Println(srv.ListenAndServe())
http.Server
では、 ReadTimeout
と WriteTimeout
の2つのタイムアウトが公開されています。この二つを設定するには、サーバーを明示的に使用して行います。
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
を使って手動でタイムアウトを構築する方法もないのです。
残念ながら、これはストリーミングサーバーが低速なクライアントから身を守ることができないことを意味します。
私は問題とともにいくつかの提案をあげておりますので、そちらで、ぜひフィードバックをお願いします。
クライアントのタイムアウト
クライアント側のタイムアウトは、使用するタイムアウトによって、より簡単になる場合もあれば、はるかに複雑になる場合もありますが、リソースの漏えいやフリーズを防止するためにも同様に重要です。
c := &http.Client{
Timeout: 15 * time.Second,
}
resp, err := c.Get("https://blog.filippo.io/")
最も使いやすいのは、http.Clientの Timeout
フィールドです。これはダイアルから(接続が再利用されていない場合)本文の読み込みまで、交換(exchange)全体をカバーするものです。
上記のサーバー側のケースと同様に、 http.Get
のようなパッケージレベルの機能はクライアントをタイムアウトなしで使用するため、オープンなインターネットでの使用は危険です。
他にもきめ細かな制御があり、お客様側で設定できるタイムアウトは他にも多くあります。
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,
}
}
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つみつけました。そのバグでは、すべてのキャンセルがタイムアウトエラーとして返されます。)
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)
}
}
}
より細かいタイムアウトの設定を行うには、 Request.Cancel
と time.Timer
を使用することができます。これならストリーミングが許可され、本文からデータを読み込むたびに、期限が引き伸ばされます。
上記の例では、5秒のタイムアウトをリクエストを実行するフェーズに設定していますが、8ラウンドで本文を読み取るのに少なくとも8秒間を費やし、そのたびに2秒のタイムアウトが必要になります。このようにしながら、途切れることなく、ストリーミングを永久に続けることができます。本文のデータの受信に2秒以上かかる場合は、io.CopyNが「 net/http: request canceled
」を返します。
1.7では、contextパッケージはスタンダードライブラリに進化しています。そこでは コンテキストに関する情報がたくさんありますが、ここでコンテキストは、Request.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
で、新しいコンテキストと cancel()
機能を入手して、 Request.WithContext
と結び付けられているリクエストを作成します。リクエストをキャンセルしたい場合には、 cancel()
を呼び出すことで、コンテキストをキャンセルします。 (キャンセルチャネルを閉じる代わりにこれを行います)。
コンテキストには利点があり、親コンテキスト(我々がcontext.WithCancel
に送信したもの)がキャンセルされると、我々の分もキャンセルされ、コマンドがパイプライン全体に伝達されます。
これでおしまいです。読者のみなさまのReadDeadline
を超過していないといいのですが!
もし、Goスタンダードライブラリに関する詳細情報などにご関心がおありでしたら、当社では新しい人材を、ロンドン、オースティン(TX)、シャンペーン(IL)、サンフランシスコ、シンガポールで募集中です。