Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

[インデックス 18939] ファイルの概要

このコミットは、Goのnet/httpパッケージにおいて、TLS接続がサーバー側で予期せず閉じられた(EOFが検出された)場合に、その接続を再利用しないようにするための改善です。特に、Amazonのような特定のサーバーとの永続的なHTTPS接続の堅牢性を高めることを目的としています。

コミット

commit cc2c5fc3d28ef2e179e605fa41d5e7eec04e34ac
Author: Brad Fitzpatrick <bradfitz@golang.org>
Date:   Tue Mar 25 10:59:09 2014 -0700

    net/http: don't re-use Transport connections if we've seen an EOF
    
    This the second part of making persistent HTTPS connections to
    certain servers (notably Amazon) robust.
    
    See the story in part 1: https://golang.org/cl/76400046/
    
    This is the http Transport change that notes whether our
    net.Conn.Read has ever seen an EOF. If it has, then we use
    that as an additional signal to not re-use that connection (in
    addition to the HTTP response headers)
    
    Fixes #3514
    
    LGTM=rsc
    R=agl, rsc
    CC=golang-codereviews
    https://golang.org/cl/79240044

GitHub上でのコミットページへのリンク

https://github.com/golang/go/commit/cc2c5fc3d28ef2e179e605fa41d5e7eec04e34ac

元コミット内容

このコミットは、net/httpパッケージのTransportにおいて、接続がEOF(End Of File)を検出した場合にその接続を再利用しないようにするものです。これは、特定のサーバー(特にAmazon)への永続的なHTTPS接続をより堅牢にするための第二段階の変更です。

この変更は、net.Conn.ReadがEOFを検出したかどうかを記録し、その情報をHTTPレスポンスヘッダーに加えて、接続を再利用しないための追加のシグナルとして使用します。

このコミットは、Issue 3514を修正します。関連する第一段階の変更については、CL 76400046を参照してください。

変更の背景

この変更の背景には、Goのnet/httpクライアントが、サーバーによって既に閉じられたTLS接続を再利用しようとすることで発生する問題がありました。これはIssue 3514として報告されており、「net/http: http tries to reuse dead TLS connections」(net/http: httpが死んだTLS接続を再利用しようとする)というタイトルで、特に連続したリクエストを行う際に、サーバーが接続を閉じた後にクライアントがその接続に書き込もうとしてEOFエラーや「http: can't write HTTP request on broken connection」といったエラーが発生する問題が指摘されていました。

この問題は、サーバーがclose_notifyアラートを送信してTLS接続を正常に閉じようとした場合でも、Goのnet/httpクライアントがすぐにそのEOF状態を認識せず、接続がまだ有効であると誤認してしまうレースコンディションに起因していました。

このコミットは、その問題に対する「第二段階」の解決策として位置づけられています。第一段階の変更(CL 76400046)では、crypto/tlsパッケージのReadメソッドがclose_notifyアラートをより積極的に処理し、即座にio.EOFを返すように修正されました。これにより、TLS層での接続終了の検出が改善されました。

しかし、TLS層でのEOF検出だけでは不十分なケースがありました。例えば、サーバーがHTTPレスポンスを送信し終えた後、TCPレベルで接続を突然切断するような場合です。このような場合、TLS層ではclose_notifyが送信されないため、クライアントは接続が閉じられたことを即座に認識できません。このコミットは、net.Conn.Readが実際にEOFを検出したという事実を、接続再利用の判断材料に加えることで、より広範なシナリオでの接続堅牢性を確保しようとするものです。

前提知識の解説

  • HTTP永続的接続 (Persistent Connections / Keep-Alive): HTTP/1.1では、複数のHTTPリクエスト/レスポンスを単一のTCP接続上で送受信できる「永続的接続」がデフォルトで有効になっています。これにより、接続の確立と切断にかかるオーバーヘッドが削減され、パフォーマンスが向上します。
  • TLS (Transport Layer Security): ネットワーク通信のセキュリティを確保するためのプロトコルです。HTTPSはHTTP通信をTLSで暗号化したものです。TLS接続の終了には、通常、両端がclose_notifyアラートを交換して正常に接続を閉じる手順があります。
  • net/http/Transport: Goのnet/httpパッケージにおける、HTTPリクエストの送信とレスポンスの受信を処理する低レベルのコンポーネントです。接続のプール、プロキシの処理、TLSハンドシェイクなどを担当します。Transportは、永続的接続を管理し、アイドル状態の接続を再利用することで効率を高めます。
  • bufio.Reader: io.Readerインターフェースをラップし、バッファリング機能を追加するGoの標準ライブラリの型です。これにより、小さな読み取り操作が効率化されます。
  • io.EOF: io.ReaderReadメソッドが、それ以上読み取るデータがないことを示すために返すエラーです。これは、ストリームの終端に達したことを意味します。
  • 接続の再利用 (Connection Reuse): net/http/Transportは、パフォーマンス向上のために、一度確立されたTCP/TLS接続を複数のHTTPリクエストで再利用しようとします。しかし、サーバーが接続を閉じたにもかかわらずクライアントがそれを認識できない場合、無効な接続を再利用しようとしてエラーが発生します。

技術的詳細

このコミットの技術的な核心は、net.Conn.ReadがEOFを検出したという事実を、net/http/Transportが管理する接続の状態に反映させることです。これにより、たとえHTTPレスポンスヘッダーが接続の永続化を示唆していても、基盤となる接続が既に閉じられている(または閉じられつつある)場合に、その接続の再利用を避けることができます。

具体的な変更点は以下の通りです。

  1. persistConn.sawEOFフィールドの追加: src/pkg/net/http/transport.gopersistConn構造体に、sawEOF boolという新しいフィールドが追加されました。このフィールドは、persistConnがラップするnet.ConnからEOFが検出されたかどうかを追跡します。readLoopによって所有され、読み取り操作中に設定されます。

  2. noteEOFReaderラッパーの導入: io.Readerインターフェースを実装する新しい型noteEOFReaderが定義されました。このラッパーは、内部のio.Reader(ここではnet.Conn)からの読み取り操作を監視します。Readメソッドがio.EOFエラーを返した場合、noteEOFReadersawEOFフラグ(persistConn.sawEOFへのポインタ)をtrueに設定します。

  3. bufio.Readerの初期化変更: Transport.dialConnメソッド内で、persistConn.brbufio.Reader)を初期化する際に、直接pconn.connを渡すのではなく、noteEOFReader{pconn.conn, &pconn.sawEOF}でラップしたものを渡すように変更されました。これにより、bufio.Readerが基盤となる接続から読み取る際に、EOFが検出されるとpconn.sawEOFが自動的に更新されるようになります。

  4. readLoopでのsawEOFの利用: persistConn.readLoopメソッド内で、接続をアイドルプールに戻す前に、pc.sawEOFフラグがチェックされるようになりました。もしpc.sawEOFtrueであれば、たとえ他の条件(例えば、HTTPレスポンスヘッダーがConnection: keep-aliveを示していても)が接続の再利用を許可していても、その接続は再利用されずに閉じられます。具体的には、alive1という接続が再利用可能かどうかを示すフラグが、pc.sawEOFtrueの場合にfalseに設定されます。

これらの変更により、GoのHTTPクライアントは、サーバーが明示的にclose_notifyを送信しない場合や、TCPレベルで突然接続を切断した場合でも、基盤となる接続のEOF状態をより正確に検出し、無効な接続の再利用を避けることができるようになります。これにより、特にAmazonのような、接続管理の挙動が異なる可能性のあるサーバーとの通信において、HTTPクライアントの堅牢性が向上します。

コアとなるコードの変更箇所

src/pkg/net/http/transport.go

--- a/src/pkg/net/http/transport.go
+++ b/src/pkg/net/http/transport.go
@@ -588,7 +588,7 @@ func (t *Transport) dialConn(cm connectMethod) (*persistConn, error) {
 		pconn.conn = tlsConn
 	}
 
-	pconn.br = bufio.NewReader(pconn.conn)
+	pconn.br = bufio.NewReader(noteEOFReader{pconn.conn, &pconn.sawEOF})
 	pconn.bw = bufio.NewWriter(pconn.conn)
 	go pconn.readLoop()
 	go pconn.writeLoop()
@@ -723,6 +723,7 @@ type persistConn struct {
 	tlsState *tls.ConnectionState
 	closed   bool                // whether conn has been closed
 	br       *bufio.Reader       // from conn
+	sawEOF   bool                // whether we've seen EOF from conn; owned by readLoop
 	bw       *bufio.Writer       // to conn
 	reqch    chan requestAndChan // written by roundTrip; read by readLoop
 	writech  chan writeRequest   // written by roundTrip; read by writeLoop
@@ -841,6 +842,9 @@ func (pc *persistConn) readLoop() {
 			if err != nil {
 				alive1 = false
 			}
+			if alive1 && pc.sawEOF {
+				alive1 = false
+			}
 			if alive1 && !pc.t.putIdleConn(pc) {
 				alive1 = false
 			}
@@ -1134,3 +1138,16 @@ type tlsHandshakeTimeoutError struct{}
 func (tlsHandshakeTimeoutError) Timeout() bool   { return true }
 func (tlsHandshakeTimeoutError) Temporary() bool { return true }
 func (tlsHandshakeTimeoutError) Error() string   { return "net/http: TLS handshake timeout" }
+
+type noteEOFReader struct {
+	r      io.Reader
+	sawEOF *bool
+}
+
+func (nr noteEOFReader) Read(p []byte) (n int, err error) {
+	n, err = nr.r.Read(p)
+	if err == io.EOF {
+		*nr.sawEOF = true
+	}
+	return
+}

src/pkg/net/http/transport_test.go

--- a/src/pkg/net/http/transport_test.go
+++ b/src/pkg/net/http/transport_test.go
@@ -11,6 +11,7 @@ import (
 	"bytes"
 	"compress/gzip"
 	"crypto/rand"
+	"crypto/tls"
 	"errors"
 	"fmt"
 	"io"
@@ -1836,6 +1837,71 @@ func TestTransportTLSHandshakeTimeout(t *testing.T) {
 	}
 }
 
+// Trying to repro golang.org/issue/3514
+func TestTLSServerClosesConnection(t *testing.T) {
+	defer afterTest(t)
+	closedc := make(chan bool, 1)
+	ts := httptest.NewTLSServer(HandlerFunc(func(w ResponseWriter, r *Request) {
+		if strings.Contains(r.URL.Path, "/keep-alive-then-die") {
+			conn, _, _ := w.(Hijacker).Hijack()
+			conn.Write([]byte("HTTP/1.1 200 OK\r\nContent-Length: 3\r\n\r\nfoo"))
+			conn.Close()
+			closedc <- true
+			return
+		}
+		fmt.Fprintf(w, "hello")
+	}))
+	defer ts.Close()
+	tr := &Transport{
+		TLSClientConfig: &tls.Config{
+			InsecureSkipVerify: true,
+		},
+	}
+	defer tr.CloseIdleConnections()
+	client := &Client{Transport: tr}
+
+	var nSuccess = 0
+	var errs []error
+	const trials = 20
+	for i := 0; i < trials; i++ {
+		tr.CloseIdleConnections()
+		res, err := client.Get(ts.URL + "/keep-alive-then-die")
+		if err != nil {
+			t.Fatal(err)
+		}
+		<-closedc
+		slurp, err := ioutil.ReadAll(res.Body)
+		if err != nil {
+			t.Fatal(err)
+		}
+		if string(slurp) != "foo" {
+			t.Errorf("Got %q, want foo", slurp)
+		}
+
+		// Now try again and see if we successfully
+		// pick a new connection.
+		res, err = client.Get(ts.URL + "/")
+		if err != nil {
+			errs = append(errs, err)
+			continue
+		}
+		slurp, err = ioutil.ReadAll(res.Body)
+		if err != nil {
+			errs = append(errs, err)
+			continue
+		}
+		nSuccess++
+	}
+	if nSuccess > 0 {
+		t.Logf("successes = %d of %d", nSuccess, trials)
+	} else {
+		t.Errorf("All runs failed:")
+	}
+	for _, err := range errs {
+		t.Logf("  err: %v", err)
+	}
+}
+
 func newLocalListener(t *testing.T) net.Listener {
 	ln, err := net.Listen("tcp", "127.0.0.1:0")
 	if err != nil {

コアとなるコードの解説

src/pkg/net/http/transport.go

  • persistConn.sawEOF boolの追加: persistConnは、HTTPトランスポートが管理する単一の永続的接続を表す構造体です。このsawEOFフィールドは、この接続の基盤となるnet.Connからio.EOFが読み取られたかどうかを記録します。これはreadLoopゴルーチンによってのみ書き込まれ、読み取り操作中にEOFが検出された場合にtrueに設定されます。

  • noteEOFReader構造体とReadメソッド: これはio.Readerインターフェースを満たす新しいヘルパー型です。

    • r io.Reader: ラップする元のリーダー(この場合はnet.Conn)。
    • sawEOF *bool: persistConn.sawEOFフィールドへのポインタ。 Readメソッドは、まず内部のr.Read(p)を呼び出します。もしこの呼び出しがio.EOFを返した場合、*nr.sawEOF(つまりpersistConn.sawEOF)をtrueに設定します。これにより、bufio.Readerが基盤の接続から読み取る際にEOFが検出されると、persistConnの状態が更新されるようになります。
  • Transport.dialConnでのbufio.NewReaderの変更: 以前はpconn.br = bufio.NewReader(pconn.conn)のように直接net.Connbufio.Readerに渡していました。 変更後はpconn.br = bufio.NewReader(noteEOFReader{pconn.conn, &pconn.sawEOF})となり、net.ConnnoteEOFReaderでラップされてからbufio.Readerに渡されます。これにより、bufio.Readerがデータを読み取るたびに、noteEOFReaderがEOFの有無を監視し、persistConn.sawEOFを適切に更新するようになります。

  • persistConn.readLoopでのsawEOFの利用: readLoopは、HTTPレスポンスを読み取り、接続をアイドルプールに戻すかどうかを決定するゴルーチンです。 if alive1 && pc.sawEOF { alive1 = false }という行が追加されました。

    • alive1は、接続が再利用可能であると判断された場合にtrueになるフラグです。
    • この新しい条件は、「もし接続がまだalive(再利用可能と判断されそう)であり、かつsawEOFtrue(基盤の接続でEOFが検出された)ならば、alive1falseにする」ことを意味します。 これにより、たとえHTTPレスポンスヘッダーがConnection: keep-aliveを示していても、実際に基盤の接続が閉じられたことが検出された場合は、その接続はアイドルプールに戻されず、再利用されなくなります。

src/pkg/net/http/transport_test.go

  • TestTLSServerClosesConnectionテストの追加: この新しいテストは、Issue 3514で報告された問題を再現し、このコミットによる修正が正しく機能することを確認するために追加されました。
    • テストサーバーは、/keep-alive-then-dieパスへのリクエストに対して、HTTPレスポンスを送信した直後に接続を強制的に閉じます(conn.Close())。
    • クライアントはまず/keep-alive-then-dieにリクエストを送信し、サーバーが接続を閉じることを確認します。
    • その後、同じクライアントを使って/に別のリクエストを送信します。このとき、以前の接続が再利用されずに新しい接続が確立されることを期待します。
    • テストはこれを複数回(20回)繰り返し、新しい接続が成功裏に確立される回数をカウントします。もし修正がなければ、2回目以降のリクエストでエラーが発生する可能性が高くなります。このテストは、sawEOFフラグが正しく機能し、死んだ接続が再利用されないことを検証します。

これらの変更により、Goのnet/httpクライアントは、サーバーが接続を閉じたことをより確実に検出し、無効な接続を再利用しようとすることを防ぎ、結果としてHTTP通信の堅牢性と信頼性が向上します。

関連リンク

参考にした情報源リンク