はじめに
GoでHTTPのリバースプロキシを作るときには標準パッケージである net/http/httputil の ReverseProxy が使えるので、提供している機能やざっくりした使い方などについて紹介しようと思います。
これは Makuake Development Team Advent Calendar 2019 の22日目の投稿です。
tl;dr
- hop-by-hop ヘッダーを自動で捨ててくれるのがうれしい
- X-Forwarded-For を自動で追加してくれてうれしい
- 通過するリクエスト、レスポンスの加工ができるのでやりたいことはだいたいできそう
httputil を利用したリバースプロキシの最小構成
パッケージドキュメントの example からの引用です
package main
import (
	"fmt"
	"io/ioutil"
	"log"
	"net/http"
	"net/http/httptest"
	"net/http/httputil"
	"net/url"
)
func main() {
	backendServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "this call was relayed by the reverse proxy")
	}))
	defer backendServer.Close()
	rpURL, err := url.Parse(backendServer.URL)
	if err != nil {
		log.Fatal(err)
	}
	frontendProxy := httptest.NewServer(httputil.NewSingleHostReverseProxy(rpURL))
	defer frontendProxy.Close()
	resp, err := http.Get(frontendProxy.URL)
	if err != nil {
		log.Fatal(err)
	}
	b, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("%s", b)
}
この例ではプロキシである frontendProxy へのリクエストが backendServer へと伝わり、そのレスポンスが frontendProxy 経由でHTTPリクエストしたクライアントに返却されています。パッと理解できる良い例ですね...
実行するとプロキシ経由で帰ってきたレスポンスが出力されます
$ go run main.go
this call was relayed by the reverse proxy
ReverseProxy は http.Handler を満たしていて嬉しい
さて、このサンプルコードの httputil.NewSingleHostReverseProxy(rpURL) の結果を httptest.NewServer() に食わせている部分に注目してください。
httptest.NewServer() は http.Handler インターフェースを引数に取る関数ですから1、
httputil.ReverseProxy は http.Handler インターフェースを満たしているということです。つまり ServeHTTP(ResponseWriter, *Request) が生えているということですね2。
そのため https://golang.org/pkg/net/http/#ListenAndServe などに渡すこともできます。標準パッケージが proxy 作ってくれていると、このように他の標準パッケージと高度に連携が取れるのが嬉しいところです
hop-by-hop ヘッダーを自動で消してくれて嬉しい
hop-by-hop ヘッダーとは RFC 2616, section 13.5.1 で最初に言及された、プロキシやキャッシュを通過しないヘッダー群です。このRFCでは
- Connection
- Keep-Alive
- Proxy-Authenticate
- Proxy-Authorization
- TE
- Trailers
- Transfer-Encoding
- Upgrade
が hop-by-hop ヘッダーとして定義されています。
また、この仕様は後に RFC 7230, section 6.1 によって更新され、 クライアントと直接通信するサーバとの hop-by-hop な情報については Connection ヘッダーを利用して表現する事になりました。
仕様としては更新されていますが、HTTPは広く利用されているプロトコルなのである程度下位互換も考慮する必要もあり、これらを自前で対応するのは地味に面倒だったりします。
さて、 httputil.ReverseProxy.ServeHTTP() の実装はこの部分 で確認できます。
ここがプロキシ処理の本体ですね。
ざっくり眺めてみると、 RFC7230に準じた形で Connection ヘッダの情報を削除 していたり、 下位互換のためにRFC2616形式の hop-by-hop ヘッダーも削除 していたり、このあたりはしっかりと取り回してくれていることがわかります。
このあたりはRFCなどの仕様に沿った実装が必要な部分であり、標準パッケージが機能を提供してくれていることであまり頭を使わずにプロキシ実装することができます。ありがたいですね...
このあたりの動作を確認できるように、exampleの例に少し手を加えたプログラムが以下です
package main
import (
	"fmt"
	"io/ioutil"
	"log"
	"net/http"
	"net/http/httptest"
	"net/http/httputil"
	"net/url"
)
func main() {
	backendServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// header 見たいので request の dump を response で返す
		dump, err := httputil.DumpRequest(r, false)
		if err != nil {
			fmt.Fprintln(w, err)
		}
		fmt.Fprintln(w, string(dump))
	}))
	rpURL, err := url.Parse(backendServer.URL)
	if err != nil {
		log.Fatal(err)
	}
	frontendProxy := httptest.NewServer(httputil.NewSingleHostReverseProxy(rpURL))
	defer frontendProxy.Close()
	// リクエスト定義
	req, err := http.NewRequest(http.MethodGet, frontendProxy.URL, nil)
	if err != nil {
		log.Fatal(err)
	}
	// Connection ヘッダー追加
	req.Header.Set("Connection", "keep-alive")
	resp, err := new(http.Client).Do(req)
	if err != nil {
		log.Fatal(err)
	}
	b, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("%s", b)
}
プロキシへのリクエストに Connection: keep-alive を追加して、それがバックエンドサーバーまで伝搬しているかチェックしています。
これを実行すると以下のように出力されます
$ go run main.go
GET / HTTP/1.1
Host: 127.0.0.1:57437
Accept-Encoding: gzip
User-Agent: Go-http-client/1.1
X-Forwarded-For: 127.0.0.1
ちゃんと Connection ヘッダの値が削除されていますね!
X-Fowarded-For も自動で追加されています
X-Forwarded-For を自動で追加してくれてうれしい
上の動作確認で見たように、 X-Forwarded-For が自動で挿入されています。
実装はこのあたり ですね
サラっと対応されてますが、プロキシなど挟むときにバックエンドサーバー側に元々のIPを伝えるのは、アプリケーションでログなど出す上で必要になることが多いのでありがたいですね。
プロキシ上での値の加工
ここからは具体例です。
任意のリバースプロキシを設定するとき、
- リクエストを加工したい
- レスポンス加工したい
などがやりたいケースが多いと思います。それぞれ簡易的に実装してみたいと思います。
それぞれ httputil.ReverseProxy.Director、 httputil.ReverseProxy.ModifyResponse というフィールドに request, response を加工する関数を差し込めるようになっているので、これらを使って値を加工してみます。
(Directorの方はリクエストのホスト変更などの目的で提供されているようなので、リクエスト加工は標準パッケージが期待している使われ方ではないかも...実装的にはDirectorの処理をそのまま外部へのRequestに実行するので無制限に書き換えできるはず)
リクエストを加工したい
まずはリクエストの加工です。
この例では httputil.ReverseProxy.Director を設定してリクエストを書き換える形でバックエンドサーバーへのリクエストにヘッダーを付与したいと思います。
以下コードです
package main
import (
	"fmt"
	"io/ioutil"
	"log"
	"net/http"
	"net/http/httptest"
	"net/http/httputil"
	"net/url"
)
func main() {
	backendServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// header 見たいので request の dump を response で返す
		dump, err := httputil.DumpRequest(r, false)
		if err != nil {
			fmt.Fprintln(w, err)
		}
		fmt.Fprintln(w, string(dump))
	}))
	defer backendServer.Close()
	rpURL, err := url.Parse(backendServer.URL)
	if err != nil {
		log.Fatal(err)
	}
	director := func(req *http.Request) {
		req.URL.Scheme = rpURL.Scheme
		req.URL.Host   = rpURL.Host
		// proxy 内部でヘッダに値を追加してみる
		req.Header.Set("X-Forwarded-Proto", rpURL.Scheme)
		req.Header.Set("X-Forwarded-Host", rpURL.Host)
		req.Header.Set("X-Forwarded-Server", rpURL.Host)
	}
	frontendProxy := httptest.NewServer(&httputil.ReverseProxy{Director: director})
	defer frontendProxy.Close()
	// リクエスト定義
	req, err := http.NewRequest(http.MethodGet, frontendProxy.URL, nil)
	if err != nil {
		log.Fatal(err)
	}
	// Connection ヘッダー追加
	req.Header.Set("Connection", "keep-alive")
	resp, err := new(http.Client).Do(req)
	if err != nil {
		log.Fatal(err)
	}
	b, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("%s", b)
}
&httputil.ReverseProxy{Director: director} で、初期化時にRequestを加工する処理を食わせてます。
directorは
director := func(req *http.Request) {
	req.URL.Scheme = rpURL.Scheme
	req.URL.Host   = rpURL.Host
	// proxy 内部でヘッダに値を追加してみる
	req.Header.Set("X-Forwarded-Proto", rpURL.Scheme)
	req.Header.Set("X-Forwarded-Host", rpURL.Host)
	req.Header.Set("X-Forwarded-Server", rpURL.Host)
}
みたいな感じで、シンプルにプロキシサーバーから X-Forwarded 系のヘッダを付与してみました。
実行するとこうなります
$ go run main.go
GET / HTTP/1.1
Host: 127.0.0.1:57534
Accept-Encoding: gzip
User-Agent: Go-http-client/1.1
X-Forwarded-For: 127.0.0.1
X-Forwarded-Host: 127.0.0.1:57533
X-Forwarded-Proto: http
X-Forwarded-Server: 127.0.0.1:57533
ヘッダが加工できてますね。
今回は触ってないですが、Request構造体に含まれるものはすべて変更できるはずなので、なんやかやしたいときは一通りできると思います。
レスポンスを加工したい
次はResponseの加工です。
この例では、 httputil.ReverseProxy.ModifyResponse を利用してレスポンスを書き換えようと思います。
あんまりいい例が浮かばなかったので、任意のHTTPヘッダーを付与する形で加工してみようと思います。
package main
import (
	"fmt"
	"log"
	"net/http"
	"net/http/httptest"
	"net/http/httputil"
	"net/url"
)
func main() {
	backendServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "this call was relayed by the reverse proxy")
	}))
	defer backendServer.Close()
	rpURL, err := url.Parse(backendServer.URL)
	if err != nil {
		log.Fatal(err)
	}
	modifyResponse := func(res *http.Response) error {
		// ヘッダー追加してみる
		// body が JSON などの構造化データであれば要素の追加などもかんたんにできると思います
		res.Header.Set("X-Test-Header", "test header data")
		return nil
	}
	rp := httputil.NewSingleHostReverseProxy(rpURL)
	rp.ModifyResponse = modifyResponse
	frontendProxy := httptest.NewServer(rp)
	defer frontendProxy.Close()
	resp, err := http.Get(frontendProxy.URL)
	if err != nil {
		log.Fatal(err)
	}
	dump, err := httputil.DumpResponse(resp, false)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("%s", string(dump))
}
responseの出力は見づらいのでダンプで出しました。
この例ではヘッダーしか加工していませんが、Response構造体に含まれる情報なら何でも触れるので、やろうとすればなんやかやできると思います。
modifyResponse は以下を利用します
modifyResponse := func(res *http.Response) error {
	// ヘッダー追加してみる
	// body が JSON などの構造化データであれば要素の追加などもかんたんにできると思います
	res.Header.Set("X-Test-Header", "test header data")
	return nil
}
実行すると以下のようになります
$ go run main.go
HTTP/1.1 200 OK
Content-Length: 43
Content-Type: text/plain; charset=utf-8
Date: Fri, 27 Dec 2019 08:02:47 GMT
X-Test-Header: test header data
さいごに
今回は、 net/http/httputil.ReverseProxy を利用したリバースプロキシについてまとめました。
hop-by-hop ヘッダの破棄など、仕様に沿った丁寧な実装が標準パッケージにあるのは嬉しいですね!
近年では GraphQL や gRPC などのHTTPの上に乗ったスキーマベースのAPI呼び出しなどが採用される事例も増えてきましたね。
gRPCのようなRPC的な呼び出しと一体になったものでは今回のような単純なプロキシは(おそらく)利用できませんが、HTTP over Protobuf や GraphQL ではこの httputil.ReverseProxy を利用することでリクエストをプロキシすることができます。HTTPすごい!
実用的なHTTPサーバーとそのutilが言語標準で提供されているので、Goという文化の中でHTTPサーバー関連のコードを一貫した設計で書けるのは思考のノイズが減ってとてもありがたいですね!(x/text 配下のパッケージ紹介 でも似たようなことを言った気がする、ずっと同じこと言ってますね)
参考文献
- https://golang.org/pkg/net/http パッケージドキュメント
- https://golang.org/pkg/net/http/httptest パッケージドキュメント
- https://golang.org/pkg/net/http/httputil パッケージドキュメント
- https://github.com/golang/go/blob/master/src/net/http/httputil/reverseproxy.go 実装