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の間で変更するとヘッダー名の挙動が異なることになります。
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は許容されない
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から切り替える場合は/
が連続するリクエストが来ていないかなど、事前に確認が必要だと思います。
ちなみに上で省略したコードは以下のコードです。
// 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.Logger
のErrorLog
を持っています。これがnilの時は標準エラー出力、自分の*log.Logger
を代入しておくとそちらにエラー時のログが出力されるようになります。
しかし型が*log.Logger
で決まっているのと、規定のエラーログ以外にログ出力を増やしたい場合は対応できません。弊社ではロガーとしてgo.uber.org/zapを利用することが多いですが、httputil.ReverseProxy
では利用できません。
どうしても対応したい場合はforkしてhttputil.ReverseProxy
の実装を内部に取り込むしかありません。もちろんその場合はライブラリ側に変更が入った場合は変更を追う必要があります。
httputil.ReverseProxyはクライアントがコネクションを切断した場合でも502を返す
httputil.ReverseProxy
はErrorHandler
を設定することができます。設定しない場合はdefaultErrorHandler
が使われるので、エラーが発生した場合はロガーにエラーを出力してhttp.StatusBadGateway
(502)を返します。
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/url
のurl.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 です。引き続きお楽しみください。