Goでproxy serverを作るときにハマるポイント

Mercari Advent Calendar 2018 の5日目はSREチームの @catatsuy がお送りします。

メルカリではGoで書かれたproxy serverをサービスの各所で使っています。今回はGoでproxy serverを作るときにハマりそうな、標準ライブラリの挙動や特徴について紹介します。

本エントリーは2018/12/04現在の最新であるGo 1.11.2を元にして書きます。

Hostヘッダーはreq.Header.Set(“Host”, “example.com”)しても上書きできない

https://github.com/golang/go/blob/e8a95aeb75536496432bcace1fb2bbfa449bf0fa/src/net/http/request.go#L1023

https://github.com/golang/go/blob/e8a95aeb75536496432bcace1fb2bbfa449bf0fa/src/net/http/server.go#L1002

この辺りのコードを読んでいくと分かりますが、Hostヘッダーはreq.Hostに代入した後に削除されます。

例えばfront.example.comのリクエストを受けたら、そのリクエストのHostヘッダーをbackend.example.comに書き換えてproxy先に送りたいとします。

その場合はreq.Header.Set("Host", "backend.example.com")をしても動かず、req.Hostを書き換える必要があります。

実装を紹介すると、net/http/httputilパッケージの関数NewSingleHostReverseProxyは以下のような実装になっています。

func NewSingleHostReverseProxy(target *url.URL) *ReverseProxy {
targetQuery := target.RawQuery
director := func(req *http.Request) {
req.URL.Scheme = target.Scheme
req.URL.Host = target.Host
req.URL.Path = singleJoiningSlash(target.Path, req.URL.Path)
if targetQuery == "" || req.URL.RawQuery == "" {
req.URL.RawQuery = targetQuery + req.URL.RawQuery
} else {
req.URL.RawQuery = targetQuery + "&" + req.URL.RawQuery
}
if _, ok := req.Header["User-Agent"]; !ok {
// explicitly disable User-Agent so it's not set to default value
req.Header.Set("User-Agent", "")
}
}
return &ReverseProxy{Director: director}
}

もしproxy先へのリクエストのHostヘッダーを書き換えたい場合はdirectorの中でreq.Hostに代入して書き換えます。

director := func(req *http.Request) {
req.URL.Scheme = target.Scheme
req.URL.Host = target.Host
req.URL.Path = singleJoiningSlash(target.Path, req.URL.Path)
if targetQuery == "" || req.URL.RawQuery == "" {
req.URL.RawQuery = targetQuery + req.URL.RawQuery
} else {
req.URL.RawQuery = targetQuery + "&" + req.URL.RawQuery
}
req.Host = target.Host
if _, ok := req.Header["User-Agent"]; !ok {
// explicitly disable User-Agent so it's not set to default value
req.Header.Set("User-Agent", "")
}
}

Hostヘッダー以外のヘッダーを付与したい場合はreq.Header.Set("Hoge", "fuga")で付与できます。

HTTPヘッダー名の-の後を小文字にできない

Goのhttp.Headerのヘッダー名はnet/textprotoパッケージの関数CanonicalMIMEHeaderKeyで正規化されています。これにより-の後は大文字でそれ以外は小文字に書き換えられます。

Goの標準ライブラリではこの動作を止める方法はありません。そのためGoでproxy serverを作った場合、クライアントが-の後が大文字でなかったり、それ以外に大文字が含まれるヘッダーでリクエストを飛ばしたとしても、proxy先に対して送られてきたヘッダー名のままリクエストを送ることはできません。

さらにややこしいことになるのが、HTTP/2を使用した場合です。HTTP/2ではヘッダー名は小文字で統一されています。クライアントはHTTP/2でリクエストを送るが、proxy server側ではproxy先に対してHTTP/1.1でリクエストを送りたいケースは普通にあると思います。そのケースだとGoはヘッダー名を正規化するために-の後は大文字に変更してからproxy先にリクエストを送ります。

しかしproxy serverとしてよく使われるnginxでは特にヘッダー名を変更しません。nginxはHostヘッダーなど一部のヘッダーを除き、ヘッダー名を変更せずにproxy先にリクエストを送ります。つまりproxy serverをnginxとGoの間で変更するとヘッダー名の挙動が異なることになります。

f:id:catatsuy:20181130171433p:plain
Goとnginxのヘッダーの挙動の違い

Goの場合は前述のようにヘッダー名は常に正規化されるため、小文字のヘッダー名のままリクエストが送られて来ても参照できますが、PHPなど言語によっては正規化されないため、大文字・小文字が異なるとアプリケーション側でヘッダーの内容を参照できなくなる恐れがあります。

こちらは事前にどちらの形式のヘッダー名でも処理できるようにアプリケーション側を変更しておく必要があります。なおRFC 2616 – Hypertext Transfer Protocol — HTTP/1.1によるとHTTPヘッダー名はcase-insensitiveであることと決まっています。

Each header field consists of a name followed by a colon (“:”) and the field value. Field names are case-insensitive.

本来ヘッダー名は大文字でも小文字でも参照できるようにしておくべきです。

//など/が連続するURLは許容されない

https://github.com/golang/go/blob/e8a95aeb75536496432bcace1fb2bbfa449bf0fa/src/net/http/server.go#L2313-L2326

path := cleanPath(r.URL.Path)
// 省略
if path != r.URL.Path {
_, pattern = mux.handler(host, path)
url := *r.URL
url.Path = path
return RedirectHandler(url.String(), StatusMovedPermanently), pattern
}

path := cleanPath(r.URL.Path)した結果、pathの値とr.URL.Pathの値が異なっていたらpathに301リダイレクトされます。cleanPathの中では/が連続している場合は1つにする処理などが動いています。そのためパス内で/が連続しているURLはproxyできません。

nginxではデフォルトでmerge_slashesの設定がonのため、自動で隣接した/を1つに変更します。なのでnginxから切り替える場合は/が連続するリクエストが来ていないかなど、事前に確認が必要だと思います。

Module ngx_http_core_module

ちなみに上で省略したコードは以下のコードです。

// If the given path is /tree and its handler is not registered,
// redirect for /tree/.
if u, ok := mux.redirectToPathSlash(host, path, r.URL); ok {
return RedirectHandler(u.String(), StatusMovedPermanently), u.Path
}

これについてはnginxのlocationのドキュメントによると、

If a location is defined by a prefix string that ends with the slash character, and requests are processed by one of proxy_pass, fastcgi_pass, uwsgi_pass, scgi_pass, memcached_pass, or grpc_pass, then the special processing is performed. In response to a request with URI equal to this string, but without the trailing slash, a permanent redirect with the code 301 will be returned to the requested URI with the slash appended.

なので以下のような設定をして/testに対してリクエストすると/test/に向けて301リダイレクトされます。

location /test/ {
proxy_pass http://test.example.com;
}

そのためこの挙動についてはnginxとほぼ同じと言っていいと思います。

httputil.ReverseProxyは標準のlog.Logger以外のロガーが使えない

httputil.ReverseProxyは型が*log.LoggerErrorLogを持っています。これがnilの時は標準エラー出力、自分の*log.Loggerを代入しておくとそちらにエラー時のログが出力されるようになります。

しかし型が*log.Loggerで決まっているのと、規定のエラーログ以外にログ出力を増やしたい場合は対応できません。弊社ではロガーとしてgo.uber.org/zapを利用することが多いですが、httputil.ReverseProxyでは利用できません。

どうしても対応したい場合はforkしてhttputil.ReverseProxyの実装を内部に取り込むしかありません。もちろんその場合はライブラリ側に変更が入った場合は変更を追う必要があります。

httputil.ReverseProxyはクライアントがコネクションを切断した場合でも502を返す

httputil.ReverseProxyErrorHandlerを設定することができます。設定しない場合はdefaultErrorHandlerが使われるので、エラーが発生した場合はロガーにエラーを出力してhttp.StatusBadGateway(502)を返します。

https://github.com/golang/go/blob/e8a95aeb75536496432bcace1fb2bbfa449bf0fa/src/net/http/httputil/reverseproxy.go#L157-L160

func (p *ReverseProxy) defaultErrorHandler(rw http.ResponseWriter, req *http.Request, err error) {
p.logf("http: proxy error: %v", err)
rw.WriteHeader(http.StatusBadGateway)
}

これだと困るのはどんなエラーが発生してもステータスコードが502を返してしまう点です。proxy serverのエラーはクライアントがコネクションを切った場合でも発生します。proxy serverとしてはクライアントがコネクションを切断したことにより発生したエラーと、proxy server自体やproxy先のエラーは明確に区別したいことがほとんどだと思います。

nginxではクライアント側が原因のエラーと、内部のエラーを区別するためにステータスコード499を使用しています。499は標準のステータスコードではありません。あくまでもアクセスログ上で区別するために使用されています。

499 Client Closed Request — httpstatuses.com

この挙動をするためには例えば以下のような実装をすることが考えられます(完全なコードではありません)。

const (
httpStatusClientClosedRequest = 499
)
errorHandler := func(rw http.ResponseWriter, req *http.Request, err error) {
status := http.StatusBadGateway
switch err {
case context.Canceled:
status = httpStatusClientClosedRequest
case io.ErrUnexpectedEOF:
status = httpStatusClientClosedRequest
default:
log.Printf("http: proxy error: %v", err)
}
rw.WriteHeader(status)
}
reverseProxy := &httputil.ReverseProxy{
Director: director,
ErrorHandler: errorHandler,
}

このようにクライアント側から切断されたと思われるエラーが発生した場合の挙動を変更することで対応できます。

url.Parseは曖昧な文字列でも通るのでチェックする

設定でproxy先のURLを指定できるproxy serverなどを作ることを想定してください。まずは文字列として渡されたURLをパースする必要があります。その場合は標準ライブラリnet/urlurl.Parseを使うと思います。

しかしurl.Parseはホスト名やスキーマを省略しても動いたり、曖昧な文字列に対してもできる限りパースしようとしてくれます。使用できる箇所が広いため、この挙動自体は嬉しいことが多そうですが、変な文字列を渡してもエラーにならずに処理が続行されることがあります。これは想定外の挙動を生む可能性があります。

そこでurl.Parseを直接使うのではなく、簡単なラッパーのような関数を用意すると良いと思います。

例えば以下のような関数です。

func urlParse(ref string) (*url.URL, error) {
u, err := url.Parse(ref)
if err != nil {
return nil, err
}
if u.Scheme != "https" {
return nil, fmt.Errorf("must be a URL beginning with https")
}
if u.Host == "" {
return nil, fmt.Errorf("host is empty")
}
return &url.URL{
Scheme: u.Scheme,
Host:   u.Host,
}, nil
}

url.Parseの返り値はurl.URLのどのフィールドに値が入っていてもおかしくありません。一部しか必要ないなら必要なものだけ返したり、他の値が想定外ならエラーにするなどの仕組みを入れると便利です。

最後に

今回はハマりやすいポイントを紹介しました。Goの場合、ほとんどの標準ライブラリはGoで実装されています。そのため標準ライブラリのコードを読むことで、Goにも標準ライブラリにもhttpの仕様などにも詳しくなれます。非常に勉強になるのでGoでproxy serverなど色々作ってみるのがおすすめです。

明日6日目の執筆担当は @vwxyutarooo です。引き続きお楽しみください。