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

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

このコミットは、Go言語の net/http/fcgi パッケージにおけるFastCGIリクエストIDの再利用に関するバグ修正です。特に、nginxが fastcgi_keep_conn on オプションを使用している場合に発生する、リクエストの重複処理とそれに伴うコネクション切断の問題を解決します。

コミット

commit a387f915538abbb6f5661cb39b8fccb606c5ad25
Author: Catalin Patulea <catalinp@google.com>
Date:   Mon Mar 17 15:47:16 2014 -0700

    net/http/fcgi: fix handling of request ID reuse
    
    Request ID reuse is allowed by the FastCGI spec [1]. In particular nginx uses
    the same request ID, 1, for all requests on a given connection. Because
    serveRequest does not remove the request from conn.requests, this causes it to
    treat the second request as a duplicate and drops the connection immediately
    after beginRequest. This manifests with nginx option 'fastcgi_keep_conn on' as
    the following message in nginx error log:
    
    2014/03/17 01:39:13 [error] 730#0: *109 recv() failed (104: Connection reset by peer) while reading response header from upstream, client: x.x.x.x, server: example.org, request: "GET / HTTP/1.1", upstream: "fastcgi://127.0.0.1:9001", host: "example.org"
    
    Because handleRecord and serveRequest run in different goroutines, access to
    conn.requests must now be synchronized.
    
    [1] http://www.fastcgi.com/drupal/node/6?q=node/22#S3.3
    
    LGTM=bradfitz
    R=bradfitz
    CC=golang-codereviews
    https://golang.org/cl/76800043

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

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

元コミット内容

net/http/fcgi: fix handling of request ID reuse

FastCGIの仕様ではリクエストIDの再利用が許可されています。特にnginxは、fastcgi_keep_conn on オプションを使用すると、単一のコネクション上で全てのリクエストに対して同じリクエストID(通常は1)を使用します。serveRequest 関数が conn.requests からリクエストを削除しないため、2番目のリクエストが重複として扱われ、beginRequest の直後にコネクションが切断されていました。これはnginxのエラーログに「recv() failed (104: Connection reset by peer)」というメッセージとして現れていました。

handleRecordserveRequest が異なるゴルーチンで実行されるため、conn.requests へのアクセスを同期する必要がありました。

変更の背景

この変更は、GoのFastCGI実装が、FastCGIプロトコルの重要な側面であるリクエストIDの再利用を正しく処理していなかったというバグを修正するために行われました。特に、nginxのような一般的なFastCGIクライアントが fastcgi_keep_conn on の設定で動作する場合に問題が発生していました。

FastCGIは、単一のTCPコネクション上で複数のリクエストを多重化するためにリクエストIDを使用します。効率化のため、クライアントは同じコネクション上で以前使用したリクエストIDを再利用することがあります。しかし、GoのFastCGIハンドラは、一度処理したリクエストIDを内部のマップ conn.requests から削除していませんでした。このため、同じリクエストIDが再利用されると、Goのハンドラはそれを新しいリクエストではなく、まだ処理中の古いリクエストの重複部分と誤認し、不正な状態としてコネクションを切断していました。

この問題は、特に高負荷な環境や、nginxとGoのFastCGIアプリケーションを組み合わせた場合に、クライアント側で「Connection reset by peer」エラーとして顕在化し、サービスの可用性に影響を与えていました。

前提知識の解説

FastCGI (Fast Common Gateway Interface)

FastCGIは、Webサーバーとアプリケーションプログラム(スクリプト)間のインタフェースを定義するプロトコルです。CGI(Common Gateway Interface)の進化版であり、CGIがリクエストごとに新しいプロセスを起動するのに対し、FastCGIは永続的なプロセスを維持し、複数のリクエストを処理することでオーバーヘッドを削減し、パフォーマンスを向上させます。

FastCGIの主要な特徴は以下の通りです。

  • 永続的なプロセス: アプリケーションプロセスは起動後もメモリに常駐し、複数のリクエストを処理します。
  • 多重化: 単一のTCPコネクション上で複数のリクエストを並行して処理できます。
  • リクエストID: 各リクエストには一意のIDが割り当てられ、Webサーバーとアプリケーション間でリクエストとレスポンスを関連付けます。このIDは、コネクション内で再利用されることがあります。
  • ロール: リクエストのタイプ(例: FCGI_RESPONDER は通常のWebリクエスト、FCGI_AUTHORIZER は認証リクエストなど)を定義します。

Go言語の net/http/fcgi パッケージ

Go言語の標準ライブラリ net/http/fcgi は、FastCGIプロトコルを実装し、GoアプリケーションがFastCGIサーバーとして動作できるようにします。これにより、nginxやApacheなどのWebサーバーからFastCGIプロトコル経由でGoアプリケーションにリクエストを転送し、処理させることが可能になります。

このパッケージ内部では、FastCGIのレコード(ヘッダとボディからなるデータ単位)を解析し、Goの http.Request オブジェクトに変換してアプリケーションのハンドラに渡します。また、アプリケーションからのレスポンスをFastCGIレコードに変換してWebサーバーに返します。

ゴルーチンと同期 (Goroutines and Synchronization)

Go言語のゴルーチンは、軽量な並行処理の単位です。OSのスレッドよりもはるかに少ないリソースで多数のゴルーチンを起動できます。しかし、複数のゴルーチンが共有データに同時にアクセスする場合、競合状態(Race Condition)が発生する可能性があります。これを防ぐために、Goでは sync パッケージが提供されており、sync.Mutex(ミューテックス)はその中でも最も基本的な同期プリミティブです。

  • sync.Mutex: 相互排他ロックを提供します。Lock() メソッドを呼び出すとロックを獲得し、Unlock() メソッドを呼び出すとロックを解放します。ロックが獲得されている間は、他のゴルーチンはロックを獲得しようとするとブロックされ、ロックが解放されるまで待機します。これにより、共有データへのアクセスを一度に一つのゴルーチンに限定し、データの整合性を保ちます。

技術的詳細

このコミットの核心は、FastCGIリクエストIDのライフサイクル管理の改善と、それに伴う並行処理の安全性の確保です。

FastCGIプロトコルでは、リクエストは FCGI_BEGIN_REQUEST レコードで開始され、FCGI_END_REQUEST レコードで終了します。各リクエストには一意のIDが割り当てられますが、このIDはコネクション内で再利用されることが許されています(FastCGI仕様 3.3節)。

Goの net/http/fcgi パッケージの child 構造体には、現在処理中のリクエストを map[uint16]*request 型の requests フィールドで管理しています。このマップはリクエストIDをキーとしています。

問題は、serveRequest ゴルーチンがリクエストの処理を完了した後も、この requests マップから対応するエントリを削除していなかった点にありました。そのため、同じリクエストIDを持つ次のリクエストが到着すると、handleRecord 関数はマップ内に既存のエントリを見つけ、それを新しいリクエストではなく、まだ処理中のリクエストの続きと誤認していました。特に typeBeginRequest の場合、既にマップにエントリが存在すると、GoのFastCGIハンドラはそれを不正な状態と判断し、コネクションを切断していました。

この修正では、以下の2つの主要な変更が行われました。

  1. リクエストの削除: serveRequest 関数がリクエストの処理を完了し、FCGI_END_REQUEST を送信した後、c.requests マップから該当するリクエストエントリを明示的に削除するように変更されました。これにより、リクエストIDが再利用された際に、新しいリクエストとして正しく扱われるようになります。同様に、typeAbortRequest(リクエストの中止)の場合も、マップからリクエストが削除されるようになりました。

  2. 同期の導入: handleRecordserveRequest は異なるゴルーチンで実行されるため、c.requests マップへのアクセスは競合状態を引き起こす可能性がありました。このため、child 構造体に sync.Mutex 型の mu フィールドが追加されました。c.requests マップへの読み書き(c.requests[rec.h.Id] の参照、c.requests[rec.h.Id] = req の代入、delete(c.requests, rec.h.Id))を行う際には、必ず c.mu.Lock() でロックを獲得し、処理後に c.mu.Unlock() でロックを解放するように変更されました。これにより、複数のゴルーチンが同時にマップを操作することによるデータ破損や不正な状態への遷移が防止されます。

これらの変更により、FastCGIリクエストIDの再利用が正しく処理され、nginxの fastcgi_keep_conn on オプションを使用した場合でも、GoのFastCGIアプリケーションが安定して動作するようになりました。

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

変更は src/pkg/net/http/fcgi/child.go ファイルに集中しています。

--- a/src/pkg/net/http/fcgi/child.go
+++ b/src/pkg/net/http/fcgi/child.go
@@ -16,6 +16,7 @@ import (
  	"net/http/cgi"
  	"os"
  	"strings"
+	"sync" // syncパッケージのインポートを追加
  	"time"
  )
  
@@ -126,8 +127,10 @@ func (r *response) Close() error {
  }
  
  type child struct {
-	conn     *conn
-	handler  http.Handler
+	conn    *conn
+	handler http.Handler
+
+	mu       sync.Mutex          // requestsマップを保護するためのミューテックスを追加
  	requests map[uint16]*request // keyed by request ID
  }
  
@@ -157,7 +160,9 @@ var errCloseConn = errors.New("fcgi: connection should be closed")
  var emptyBody = ioutil.NopCloser(strings.NewReader(""))
  
  func (c *child) handleRecord(rec *record) error {
+\tc.mu.Lock() // マップアクセス前にロック
  	req, ok := c.requests[rec.h.Id]
+\tc.mu.Unlock() // マップアクセス後にアンロック
  	if !ok && rec.h.Type != typeBeginRequest && rec.h.Type != typeGetValues {
  		// The spec says to ignore unknown request IDs.
  		return nil
@@ -179,7 +184,10 @@ func (c *child) handleRecord(rec *record) error {
  			c.conn.writeEndRequest(rec.h.Id, 0, statusUnknownRole)
  			return nil
  		}
-\t\tc.requests[rec.h.Id] = newRequest(rec.h.Id, br.flags)
+\t\treq = newRequest(rec.h.Id, br.flags)
+\t\tc.mu.Lock() // マップアクセス前にロック
+\t\tc.requests[rec.h.Id] = req // 新しいリクエストをマップに追加
+\t\tc.mu.Unlock() // マップアクセス後にアンロック
  		return nil
  	case typeParams:
  	\t// NOTE(eds): Technically a key-value pair can straddle the boundary
@@ -220,7 +228,9 @@ func (c *child) handleRecord(rec *record) error {
  	\treturn nil
  	case typeAbortRequest:
  	\tprintln("abort")
+\t\tc.mu.Lock() // マップアクセス前にロック
  	\tdelete(c.requests, rec.h.Id) // リクエスト中止時にマップから削除
+\t\tc.mu.Unlock() // マップアクセス後にアンロック
  	\tc.conn.writeEndRequest(rec.h.Id, 0, statusRequestComplete)
  	\tif !req.keepConn {
  	\t\t// connection will close upon return
@@ -247,6 +257,9 @@ func (c *child) serveRequest(req *request, body io.ReadCloser) {
  	\tc.handler.ServeHTTP(r, httpReq)
  	}
  	r.Close()
+\tc.mu.Lock() // マップアクセス前にロック
+\tdelete(c.requests, req.reqId) // リクエスト処理完了後にマップから削除
+\tc.mu.Unlock() // マップアクセス後にアンロック
  	c.conn.writeEndRequest(req.reqId, 0, statusRequestComplete)
  
  	// Consume the entire body, so the host isn't still writing to

コアとなるコードの解説

  1. import "sync" の追加: child.go の冒頭に sync パッケージがインポートされました。これは、ミューテックス (sync.Mutex) を使用して共有リソース (c.requests マップ) へのアクセスを同期するために必要です。

  2. child 構造体への mu sync.Mutex の追加: child 構造体に mu sync.Mutex フィールドが追加されました。このミューテックスは、requests マップへのアクセスを保護するために使用されます。コメント // protects requests: がその目的を明確に示しています。

  3. handleRecord 関数内の同期とリクエスト追加ロジックの変更:

    • c.requests マップからリクエストを読み取る際 (req, ok := c.requests[rec.h.Id])、その前後に c.mu.Lock()c.mu.Unlock() が追加されました。
    • typeBeginRequest のケースで新しいリクエストを作成し、c.requests マップに追加する際 (c.requests[rec.h.Id] = newRequest(...))、その処理の前後に c.mu.Lock()c.mu.Unlock() が追加されました。これにより、複数のゴルーチンが同時にマップを更新しようとする競合状態が回避されます。
  4. handleRecord 関数内の typeAbortRequest の同期とリクエスト削除:

    • typeAbortRequest のケースでリクエストが中止された際に delete(c.requests, rec.h.Id) を呼び出す際、その前後に c.mu.Lock()c.mu.Unlock() が追加されました。これにより、中止されたリクエストがマップから安全に削除されます。
  5. serveRequest 関数内の同期とリクエスト削除:

    • serveRequest 関数は、HTTPリクエストの処理が完了し、r.Close() が呼び出された後、c.requests マップから該当するリクエストを削除するように変更されました。具体的には、delete(c.requests, req.reqId) が追加されました。
    • この delete 操作の前後に c.mu.Lock()c.mu.Unlock() が追加され、serveRequest ゴルーチンがマップを安全に操作できるようにしました。

これらの変更により、c.requests マップへのすべてのアクセスがミューテックスによって保護され、リクエストの追加と削除が正しく行われるようになりました。特に、serveRequest でのリクエスト削除は、FastCGIリクエストIDの再利用を可能にし、nginxのようなクライアントとの互換性を向上させる上で不可欠な修正です。

関連リンク

参考にした情報源リンク