[インデックス 15879] ファイルの概要
このコミットは、Go言語の net/http/fcgi
パッケージにおけるシャットダウン時の競合状態(shutdown race)を修正するものです。具体的には、FastCGIハンドラがリクエストボディを完全に消費しなかった場合に発生する、ソケットの早期クローズによるRST(Reset)パケット送信と、それによるホスト(特にNginx)からの空のレスポンスボディの問題を解決します。
コミット
- コミットハッシュ:
d7c1f67cb92d29622de35b86288b2c6032285965
- Author: Brad Fitzpatrick bradfitz@golang.org
- Date: Thu Mar 21 14:07:24 2013 -0700
GitHub上でのコミットページへのリンク
https://github.com/golang/go/commit/d7c1f67cb92d29622de35b86288b2c6032285965
元コミット内容
net/http/fcgi: fix a shutdown race
If a handler didn't consume all its Request.Body, child.go was
closing the socket while the host was still writing to it,
causing the child to send a RST and the host (at least nginx)
to send an empty response body.
Now, we tell the host we're done with the request/response
first, and then close our input pipe after consuming a bit of
it. Consuming the body fixes the problem, and flushing to the
host first to tell it that we're done increases the chance
that the host cuts off further data to us, meaning we won't
have much to consume.
No new tests, because this package is lacking in tests.
Tested by hand with nginx. See issue for testing details.
Fixes #4183
R=golang-dev, rsc
CC=golang-dev
https://golang.org/cl/7939045
変更の背景
このコミットは、Go言語の net/http/fcgi
パッケージにおいて、FastCGIアプリケーションがリクエストボディを完全に読み込まない場合に発生する問題に対処しています。
従来の動作では、FastCGIハンドラが http.Request.Body
からすべてのデータを読み込む前に処理を終了した場合、child.go
内でソケットが早期にクローズされていました。このとき、FastCGIホスト(例えばNginx)はまだソケットにデータを書き込んでいる途中である可能性がありました。結果として、GoのFastCGI子プロセスはTCP RST(Reset)パケットを送信し、ホスト側は予期せぬ接続切断を検知し、クライアントに対して空のレスポンスボディを送信してしまうという問題が発生していました。
この問題は、特に大きなリクエストボディを持つリクエストで、ハンドラがボディの一部しか必要としない場合や、エラーが発生して早期に処理を中断する場合に顕著でした。TCP RSTは、通常、予期せぬエラーや接続の強制終了を示すものであり、正常なプロトコルシーケンスではありません。これにより、クライアント側では不完全なレスポンスやエラーとして扱われる可能性がありました。
このコミットの目的は、このようなシャットダウン時の競合状態を解消し、FastCGIプロトコルに則ったクリーンな接続終了を実現することです。
前提知識の解説
このコミットを理解するためには、以下の技術的な概念を把握しておく必要があります。
-
FastCGI (Fast Common Gateway Interface):
- Webサーバー(Nginx, Apacheなど)とアプリケーション(Go, PHP, Pythonなど)の間で動的なコンテンツを生成するためのプロトコルです。
- CGIの進化版であり、CGIがリクエストごとにプロセスを起動するのに対し、FastCGIは永続的なプロセス(FastCGIサーバーまたはアプリケーション)を起動し、複数のリクエストを処理することでパフォーマンスを向上させます。
- WebサーバーはFastCGIプロトコルを使用してアプリケーションにリクエストを渡し、アプリケーションは処理結果をFastCGIプロトコルでWebサーバーに返します。
- Goの
net/http/fcgi
パッケージは、GoアプリケーションをFastCGIサーバーとして動作させるための機能を提供します。
-
http.Request.Body
:- HTTPリクエストのボディ部分を表す
io.ReadCloser
インターフェースです。 - クライアントから送信されたデータ(POSTデータ、ファイルアップロードなど)を読み込むために使用されます。
io.ReadCloser
であるため、読み込みが完了したらClose()
メソッドを呼び出してリソースを解放する必要があります。
- HTTPリクエストのボディ部分を表す
-
TCP RST (Reset):
- TCPプロトコルにおけるリセットフラグです。
- 通常、予期せぬエラー、接続の強制終了、または無効なセグメントを受信した場合に送信されます。
- RSTを受信した側は、その接続が突然終了したと判断し、保留中のデータ送信を中止します。
- 正常な接続終了はFIN(Finish)フラグによるものです。RSTは、FINによる正常なシャットダウンシーケンスとは異なり、データが失われる可能性があり、エラー状態を示します。
-
シャットダウン競合状態 (Shutdown Race Condition):
- 複数の並行プロセスやスレッドが、共有リソース(この場合はネットワークソケット)の終了処理を同時に行おうとしたときに発生する問題です。
- 一方のプロセスがリソースをクローズしようとしている間に、もう一方のプロセスがそのリソースにアクセスしようとすると、予期せぬ動作やエラー(RSTの送信など)が発生する可能性があります。
- このコミットのケースでは、GoのFastCGI子プロセスがソケットをクローズしようとしている間に、FastCGIホストがまだリクエストボディをソケットに書き込んでいるという状況が競合状態を引き起こしていました。
技術的詳細
このコミットの技術的な核心は、FastCGIプロトコルにおけるリクエストボディの処理と、接続の正常な終了シーケンスの確保にあります。
問題の根本原因は、GoのFastCGIハンドラが http.Request.Body
を完全に消費する前に、Go側がソケットをクローズしてしまうことにありました。FastCGIプロトコルでは、アプリケーションがリクエストの処理を終えたことをホストに伝えるために FCGI_END_REQUEST
レコードを送信します。しかし、このレコードを送信する前にソケットがクローズされると、ホストはまだデータを送信しようとしているため、Go側がTCP RSTを送信してしまいます。
この修正は、以下の2つの主要な変更によってこの問題を解決します。
-
FCGI_END_REQUEST
レコードの早期送信:- ハンドラが
ServeHTTP
メソッドの実行を終えた後、ソケットをクローズする前に、まずc.conn.writeEndRequest(req.reqId, 0, statusRequestComplete)
を呼び出して、ホストにリクエストの処理が完了したことを明示的に伝えます。 - これにより、ホストはこれ以上リクエストボディのデータを送信する必要がないことを認識し、自身の書き込みを停止する可能性が高まります。
- ハンドラが
-
残りのリクエストボディの消費:
FCGI_END_REQUEST
を送信した後、io.CopyN(ioutil.Discard, body, 100<<20)
を使用して、http.Request.Body
に残っている可能性のあるデータを最大100MBまで読み込み、破棄します。ioutil.Discard
は、書き込まれたデータをすべて破棄するio.Writer
です。これにbody
からのデータをコピーすることで、ソケットバッファに残っているデータを読み出し、TCPスタックがRSTを送信するのを防ぎます。io.CopyN
を使用することで、無限に読み込み続けることを避け、最大サイズ(100MB)で制限しています。これは、悪意のあるクライアントが非常に大きなボディを送信し、アプリケーションがそれをすべて消費するまで待機させられることを防ぐための安全策でもあります。- この処理の後で
body.Close()
が呼び出されます。これにより、body
に関連付けられたリソースが適切に解放されます。
これらの変更により、GoのFastCGI子プロセスは、ホストがまだデータを送信している可能性のある状況でソケットを突然クローズするのではなく、まずプロトコルレベルでリクエストの終了を通知し、その後、残りのデータを消費してクリーンにソケットを閉じることができるようになります。これにより、TCP RSTの送信が回避され、ホスト側も正常な接続終了を認識できるようになります。
また、handleRecord
関数内の typeBeginRequest
と typeParams
の処理において、return nil
の位置が変更されています。これは、typeBeginRequest
のチェックが req != nil
の後に移動され、typeParams
の処理後に return nil
が追加されたことで、より明確な制御フローとエラーハンドリングが実現されています。これにより、同じリクエストIDで新しいリクエストが開始された場合の早期エラー検出が改善されています。
コアとなるコードの変更箇所
src/pkg/net/http/fcgi/child.go
ファイルに以下の変更が加えられています。
--- a/src/pkg/net/http/fcgi/child.go
+++ b/src/pkg/net/http/fcgi/child.go
@@ -162,14 +162,15 @@ func (c *child) handleRecord(rec *record) error {
// The spec says to ignore unknown request IDs.
return nil
}\n-\tif ok && rec.h.Type == typeBeginRequest {\n-\t\t// The server is trying to begin a request with the same ID\n-\t\t// as an in-progress request. This is an error.\n-\t\treturn errors.New("fcgi: received ID that is already in-flight")\n-\t}\n \n switch rec.h.Type {\n case typeBeginRequest:\n+\t\tif req != nil {\n+\t\t\t// The server is trying to begin a request with the same ID\n+\t\t\t// as an in-progress request. This is an error.\n+\t\t\treturn errors.New("fcgi: received ID that is already in-flight")\n+\t\t}\n+\n var br beginRequest\n if err := br.read(rec.content()); err != nil {\n return err\n@@ -179,6 +180,7 @@ func (c *child) handleRecord(rec *record) error {\n return nil\n }\n c.requests[rec.h.Id] = newRequest(rec.h.Id, br.flags)\n+\t\treturn nil\n case typeParams:\n // NOTE(eds): Technically a key-value pair can straddle the boundary\n // between two packets. We buffer until we've received all parameters.\n@@ -187,6 +190,7 @@ func (c *child) handleRecord(rec *record) error {\n return nil\n }\n req.parseParams()\n+\t\treturn nil\n case typeStdin:\n content := rec.content()\n if req.pw == nil {\n@@ -207,24 +210,29 @@ func (c *child) handleRecord(rec *record) error {\n } else if req.pw != nil {\n req.pw.Close()\n }\n+\t\treturn nil\n case typeGetValues:\n values := map[string]string{"FCGI_MPXS_CONNS": "1"}\n c.conn.writePairs(typeGetValuesResult, 0, values)\n+\t\treturn nil\n case typeData:\n // If the filter role is implemented, read the data stream here.\n+\t\treturn nil\n case typeAbortRequest:\n+\t\tprintln("abort")\n delete(c.requests, rec.h.Id)\n c.conn.writeEndRequest(rec.h.Id, 0, statusRequestComplete)\n if !req.keepConn {\n // connection will close upon return\n return errCloseConn\n }\n+\t\treturn nil\n default:\n b := make([]byte, 8)\n b[0] = byte(rec.h.Type)\n c.conn.writeRecord(typeUnknownType, 0, b)\n+\t\treturn nil\n }\n-\treturn nil\n }\n \n func (c *child) serveRequest(req *request, body io.ReadCloser) {\n@@ -238,9 +246,19 @@ func (c *child) serveRequest(req *request, body io.ReadCloser) {\n httpReq.Body = body\n c.handler.ServeHTTP(r, httpReq)\n }\n-\tbody.Close()\n \tr.Close()\n \tc.conn.writeEndRequest(req.reqId, 0, statusRequestComplete)\n+\n+\t// Consume the entire body, so the host isn't still writing to\n+\t// us when we close the socket below in the !keepConn case,\n+\t// otherwise we'd send a RST. (golang.org/issue/4183)\n+\t// TODO(bradfitz): also bound this copy in time. Or send\n+\t// some sort of abort request to the host, so the host\n+\t// can properly cut off the client sending all the data.\n+\t// For now just bound it a little and\n+\tio.CopyN(ioutil.Discard, body, 100<<20)\n+\tbody.Close()\n+\n \tif !req.keepConn {\n \t\tc.conn.Close()\n \t}\n```
## コアとなるコードの解説
このコミットの主要な変更は、`serveRequest` 関数と `handleRecord` 関数に集中しています。
### `serveRequest` 関数における変更
`serveRequest` 関数は、個々のFastCGIリクエストを処理するGoハンドラを呼び出し、その後のクリーンアップを行う役割を担っています。
変更前:
```go
httpReq.Body = body
c.handler.ServeHTTP(r, httpReq)
body.Close() // ここでボディが閉じられる
r.Close()
c.conn.writeEndRequest(req.reqId, 0, statusRequestComplete) // その後でEND_REQUESTが送信される
if !req.keepConn {
c.conn.Close()
}
変更後:
httpReq.Body = body
c.handler.ServeHTTP(r, httpReq)
r.Close()
c.conn.writeEndRequest(req.reqId, 0, statusRequestComplete) // まずEND_REQUESTを送信
// Consume the entire body, so the host isn't still writing to
// us when we close the socket below in the !keepConn case,
// otherwise we'd send a RST. (golang.org/issue/4183)
// TODO(bradfitz): also bound this copy in time. Or send
// some sort of abort request to the host, so the host
// can properly cut off the client sending all the data.
// For now just bound it a little and
io.CopyN(ioutil.Discard, body, 100<<20) // 残りのボディを消費
body.Close() // その後でボディを閉じる
if !req.keepConn {
c.conn.Close()
}
この変更のポイントは以下の通りです。
-
c.conn.writeEndRequest
の呼び出し順序の変更:- 以前は
body.Close()
の後にc.conn.writeEndRequest
が呼び出されていました。これにより、Go側がソケットをクローズする準備をしている間に、ホストがまだデータを送信している可能性がありました。 - 変更後は、
c.handler.ServeHTTP
の直後、かつbody.Close()
の前にc.conn.writeEndRequest
が呼び出されます。これにより、Goアプリケーションがリクエストの処理を完了したことをFastCGIホストに早期に通知し、ホストがこれ以上データを送信しないように促します。
- 以前は
-
io.CopyN(ioutil.Discard, body, 100<<20)
の追加:- これは、リクエストボディに残っている可能性のあるデータを最大100MBまで読み込み、破棄するための重要な行です。
ioutil.Discard
は、書き込まれたデータをすべて捨てるio.Writer
です。io.CopyN
を使用してbody
からioutil.Discard
へデータをコピーすることで、ソケットの受信バッファに残っているデータを読み出し、TCPスタックがRSTを送信するのを防ぎます。100<<20
は100MBを意味し、無限にデータを読み込むことを防ぐための上限設定です。これにより、悪意のあるクライアントからの大量のデータ送信に対する耐性も向上します。- この処理により、
body
が完全に消費されるか、上限に達するまで読み込まれるため、body.Close()
が呼び出される際には、ソケットがクリーンな状態になっていることが期待されます。
handleRecord
関数における変更
handleRecord
関数は、FastCGIプロトコルで受信した各レコード(リクエストの開始、パラメータ、標準入力など)を処理します。
変更前:
if ok && rec.h.Type == typeBeginRequest {
// The server is trying to begin a request with the same ID
// as an in-progress request. This is an error.
return errors.New("fcgi: received ID that is already in-flight")
}
switch rec.h.Type {
case typeBeginRequest:
// ...
}
return nil // 各ケースの最後にまとめてnilを返す
変更後:
switch rec.h.Type {
case typeBeginRequest:
if req != nil { // reqがnilでない場合(既にリクエストが進行中の場合)にチェック
// The server is trying to begin a request with the same ID
// as an in-progress request. This is an error.
return errors.New("fcgi: received ID that is already in-flight")
}
// ...
c.requests[rec.h.Id] = newRequest(rec.h.Id, br.flags)
return nil // 各ケースの処理後にnilを返す
case typeParams:
// ...
req.parseParams()
return nil // 各ケースの処理後にnilを返す
// ... 他のケースも同様に return nil が追加
default:
// ...
return nil // 各ケースの処理後にnilを返す
}
この変更は、主にコードの構造とエラーハンドリングの明確化を目的としています。
-
typeBeginRequest
のチェック位置の変更:- 以前は
switch
ステートメントの前にtypeBeginRequest
のチェックがありましたが、これはtypeBeginRequest
のケース内に移動されました。 - これにより、
typeBeginRequest
レコードを受信したときにのみ、同じIDのリクエストが既に進行中であるかどうかのチェックが行われるようになり、コードの意図がより明確になります。
- 以前は
-
各
case
の末尾へのreturn nil
の追加:- 以前は
handleRecord
関数の最後にまとめてreturn nil
がありましたが、変更後は各case
の処理が完了した直後にreturn nil
が追加されています。 - これは、各レコードタイプが独立して処理され、その処理が完了したらすぐに制御を返すという意図を明確にします。これにより、コードの可読性が向上し、将来的な変更やデバッグが容易になります。機能的な変更というよりは、コードスタイルの改善と明確化が主目的です。
- 以前は
これらの変更は、FastCGIプロトコルにおけるリクエストとレスポンスのライフサイクルをより堅牢にし、特にリクエストボディの不完全な消費による問題を防ぐことで、GoのFastCGIアプリケーションの安定性と信頼性を向上させています。
関連リンク
- Go Issue #4183: https://golang.org/issue/4183
- Go CL 7939045: https://golang.org/cl/7939045
参考にした情報源リンク
- Go言語のソースコード(
src/pkg/net/http/fcgi/child.go
) - TCP RSTに関する一般的な情報
- FastCGIプロトコルに関する一般的な情報
io.CopyN
およびioutil.Discard
のGoドキュメント- Web検索結果:
golang.org/issue/4183
(Goの内部イシュー参照に関する情報)