プロキシ
実装:
network/proxy// 実行:go test ./network/proxy/
ネットワーク編の集大成。クライアントとサーバの間に立つ「代理人」を作る。受けたリクエストを裏のサーバに転送して応答を返すだけの1枚だが、これを挟むと、リクエストの振り分けも、暗号化の解除も、キャッシュも、流量の制限も、全部この1箇所に集約できる。nginx や Envoy、ロードバランサの正体で、ここまで作った HTTP サーバをそのまま土台に使う。
この章で作るもの
HTTP サーバを土台に、リバースプロキシを作る。 リクエストを受けて、裏のサーバ(バックエンド)に転送し、応答をクライアントに返す。 さらに複数バックエンドへのラウンドロビン負荷分散も付ける。
押さえることは3つある。
- プロキシは「クライアントとバックエンドの間に立つ代理人」。1枚挟むだけで 負荷分散・TLS 終端(暗号化された接続をここで解き、裏へは平文で流す)・キャッシュ・ レート制限を1箇所に集約できる
- L4(TCP をそのまま中継) と L7(HTTP を理解して転送・書き換え) の違い
- X-Forwarded-For で「本当のクライアントは誰か」をバックエンドに伝える
前提章
HTTP サーバ(リクエストのパースと Accept ループ)を土台にする。
フォワードプロキシとリバースプロキシ
同じ「代理人」でも、どちら側の代理かで2種類ある。
- フォワードプロキシ: クライアント側の代理。社内から外部への通信をまとめる (会社のプロキシ、VPN)。「誰が外に出るか」を制御する
- リバースプロキシ: サーバ側の代理。外部からのリクエストを受けて裏のサーバに配る (nginx、ロードバランサ)。「誰が中に入るか」を制御する
この章で作るのは後者、リバースプロキシ。
クライアント ──▶ プロキシ ──┬──▶ backend-1
├──▶ backend-2
└──▶ backend-3
(1つの入口) (複数の実体)L4 と L7: どの層で中継するか
プロキシには「どの層で中身を見るか」で2種類ある。ここが実装の分かれ目。
| 見るもの | できること | 速さ | |
|---|---|---|---|
| L4 プロキシ | TCP のバイト列(中身は見ない) | 素通しの中継、TCPレベルの分散 | 速い |
| L7 プロキシ | HTTP を解釈 | パス/ヘッダで振り分け、書き換え、キャッシュ | 賢いが重い |
L4 は、クライアントとバックエンドの間で TCP のバイトを両方向にコピーするだけ (io.Copy を2本)。中身が HTTP か何かも知らない。速いが、パスやヘッダでの 判断はできない。
L7 は、HTTP をパースして理解する。だから「/api は バックエンドA、/img はB」のようなパスベースの振り分けや、ヘッダの書き換えができる。 この章の実装は L7:
// Serve は接続を受け付け、1接続ごとに handle する。構造は httpserver と同じ Accept ループ。
func (p *Proxy) Serve(ln net.Listener) error {
for {
conn, err := ln.Accept()
if err != nil {
return err
}
go p.handle(conn)
}
}
// handle は1リクエストを受けて、選んだバックエンドに転送する。
func (p *Proxy) handle(client net.Conn) {
defer client.Close()
// L7 プロキシなので、まずリクエストを「理解」する。
req, err := httpserver.ParseRequest(bufio.NewReader(client))
if err != nil {
httpserver.Text(400, "bad request").Write(client)
return
}
// クライアントの IP を X-Forwarded-For に記録する。
// これがないと、バックエンドから見た送信元は常にプロキシになり、
// 「本当のクライアントが誰か」が失われる。プロキシの重要な責務。
clientIP := hostOf(client.RemoteAddr().String())
backend := p.pick()
if err := p.forward(backend, req, clientIP, client); err != nil {
httpserver.Text(502, "bad gateway: "+err.Error()).Write(client)
}
}X-Forwarded-For: 本当のクライアントを伝える
L7 プロキシの重要な責務が1つ。バックエンドから見ると、接続してくるのは常にプロキシで、 本当のクライアントの IP が分からなくなる。プロキシがログや制限で「誰から来たか」を 使いたいバックエンドのために、元の IP を X-Forwarded-For ヘッダに書いて渡す。
// forward は選んだバックエンドに接続し、リクエストを組み直して送り、応答をそのまま
// クライアントへ中継する。
func (p *Proxy) forward(backend string, req *httpserver.Request, clientIP string, client net.Conn) error {
bconn, err := net.Dial("tcp", backend)
if err != nil {
return fmt.Errorf("dial backend: %w", err)
}
defer bconn.Close()
// リクエストを再構築してバックエンドへ送る。X-Forwarded-For を付ける。
var sb strings.Builder
fmt.Fprintf(&sb, "%s %s %s\r\n", req.Method, req.Path, req.Version)
for k, v := range req.Headers {
fmt.Fprintf(&sb, "%s: %s\r\n", k, v)
}
fmt.Fprintf(&sb, "X-Forwarded-For: %s\r\n", clientIP)
sb.WriteString("\r\n")
if _, err := io.WriteString(bconn, sb.String()); err != nil {
return fmt.Errorf("write to backend: %w", err)
}
if len(req.Body) > 0 {
bconn.Write(req.Body)
}
// バックエンドの応答をそのままクライアントへ流す(ストリーム中継)。
if _, err := io.Copy(client, bconn); err != nil {
return fmt.Errorf("relay response: %w", err)
}
return nil
}
// hostOf は "127.0.0.1:54321" から "127.0.0.1" を取り出す。
func hostOf(addr string) string {
host, _, err := net.SplitHostPort(addr)
if err != nil {
return addr
}
return host
}リクエストを組み直すとき X-Forwarded-For: <クライアントIP> を足しているのがそれ。 Rate Limiter が「IP ごとに制限」できたのも、プロキシがこのヘッダで 本当の IP を伝えているから。プロキシの背後でこのヘッダを信じている部品は多い。
負荷分散: どのバックエンドに送るか
バックエンドが複数あるとき、どれに送るかを決めるのが負荷分散(ロードバランシング)。 一番単純なのがラウンドロビン。順番に1つずつ配る。
// Proxy はバックエンド群への振り分けを行うリバースプロキシ。
type Proxy struct {
backends []string
next atomic.Uint64 // ラウンドロビンの次番号
}
// New はバックエンド一覧を持つプロキシを返す(空ならパニック)。
func New(backends []string) *Proxy {
p, err := newChecked(backends)
if err != nil {
panic(err)
}
return p
}
func newChecked(backends []string) (*Proxy, error) {
if len(backends) == 0 {
return nil, errors.New("proxy: need at least one backend")
}
return &Proxy{backends: backends}, nil
}
// pick はラウンドロビンで次のバックエンドを選ぶ。
// atomic なので複数接続が同時に来ても番号が重複しない。
func (p *Proxy) pick() string {
i := p.next.Add(1) - 1
return p.backends[i%uint64(len(p.backends))]
}atomic.Uint64 で番号を進めているのは、複数の接続が同時に来ても番号が 重複しないようにするため(Rate Limiter の原子性と同じ話)。
試す: リクエストを送るたびに、プロキシが backend-1 → 2 → 3 → 1 … と 順番に振り分け、各バックエンドの受信数が均等に増えるのが見える。
通信経路がつながった
ここまでの章を積み上げて、Web の通信経路が一通り見えた。
- TCP: 落ちたり追い越したりするパケットの上に、信頼できるバイト列を作る
- DNS リゾルバ: 名前を IP に変える(通信の一番最初)
- HTTP サーバ: TCP の上のテキストを読んで返す
- この章: その通信を代理し、束ね、配る
ブラウザに URL を打つ → DNS で IP を引く → その IP(実はプロキシ)に HTTP で繋ぐ → プロキシがバックエンドに配る という、普段ブラックボックスな流れが、 全部自分の書いたコードで追えるようになった。
設計の観点
- 間に1枚入れると、そこに何でも置ける: 負荷分散も TLS 終端もキャッシュもレート制限も、経路上の1点を通ると決めた瞬間に置き場所が生まれる。プロキシの価値は機能でなく、その位置にある
- 入口を1つに見せる: バックエンドが何台あってもクライアントからは1つに見える。台数を変えることと、呼び出し側を変えることが切り離せる
- どの層で中継するかで、できることが決まる: 中身を読まなければ速くて汎用(L4)、読めば内容で振り分けられる(L7)。読むかどうかが、そのまま能力と費用の両方を決める
- 自分の存在を伝える責任: 間に入ると相手のアドレスが自分のものにすり替わる。
X-Forwarded-Forは、入ったことで壊した情報を自分で補うための仕組みになる - 1点に集めた代償: 集約した場所は、落ちると全部止まる場所でもある。集約と単一障害点は同じことの表と裏になる
- 境界を1つ持てる意味: 外との境目が1か所に定まると、そこに認証・ログ・遮断を置ける。内側を単純に保てるのは、境界を引いたからになる
メリット・デメリットと実例
| 中継する層 | 見るもの | できること | 費用 |
|---|---|---|---|
| L4(TCP) | アドレスとポートだけ | 転送、接続数での分散。プロトコルを問わない | 安い。中身を組み立て直さない |
| L7(HTTP、この章) | メソッド・パス・ヘッダ | パスで振り分け、書き換え、キャッシュ、レート制限 | 解釈のぶん重い |
得るものは、機能を1か所に集約できることと、バックエンドの増減を隠せること。 払うのは、1ホップぶんの遅延と、そこが単一障害点になりうることになる。
実例:
- nginx / HAProxy / Envoy: リバースプロキシと負荷分散の定番。Envoy は L7 の制御を動的に更新できる作りで、サービスメッシュの部品になっている
- CDN のエッジ / AWS の ALB: 世界中に置かれた巨大なリバースプロキシ。TLS 終端とキャッシュを利用者の近くで行う
- Kubernetes の Ingress: クラスタの入口に立つ L7 プロキシ。振り分け規則を設定として宣言する形になっている
裏どり:
X-Forwarded-Forは信用できない: クライアントが自分で付けて送れるので、一番外側のプロキシが上書きし、内側は信頼できる相手からの値だけを採るという運用が要る。信頼するホップ数を設定するのが定番になる- 標準化された後継: 継ぎ足しの慣習だったヘッダを整理したのが
Forwardedヘッダ(RFC 7239)。forprotohostを1本にまとめられるが、普及ではX-Forwarded-*が依然多い - TLS 終端の位置が設計を決める: プロキシで終端すると中身を読めて L7 の機能が全部使えるが、そこから内側は平文になる。内側も暗号化するなら再度張り直すことになり、TLS ハンドシェイクの費用を2回払う
- hop-by-hop ヘッダは転送しない:
ConnectionやTransfer-Encodingは隣の相手との取り決めなので、そのまま先へ流すと壊れる。規格が転送してはいけないヘッダを定めている - 単一障害点への答え: プロキシ自体は複数台置き、その手前を DNS か L4 の分散で振り分ける。ロードバランサの章で見た振り分けが、プロキシの前段にもう一度出てくる形になる
簡略化したこと
- L7 のみ: L4 の生 TCP 中継(
io.Copy2本)は説明にとどめた - ラウンドロビンのみ: 重み付き・最小接続数・IPハッシュ・ヘルスチェックなし
- リトライ/サーキットブレーカーなし: バックエンド不通は即 502
- keep-alive なし・ヘッダ書き換えは XFF 追加のみ: 実物は Host 書き換え、 Connection ヘッダの扱い、hop-by-hop ヘッダの除去などがある
参考資料
- nginx: Reverse Proxy — 定番の設定
- Go の httputil.ReverseProxy — 標準ライブラリの本格実装
- Envoy architecture — L7 プロキシの現代的な設計