Skip to content

プロキシ

実装: 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つの入口)    (複数の実体)
リバースプロキシ。クライアントはプロキシと話しているつもりだが、実際に処理するのは裏のバックエンド。この1枚が負荷分散や TLS 終端の集約点になる

L4 と L7: どの層で中継するか

プロキシには「どの層で中身を見るか」で2種類ある。ここが実装の分かれ目。

見るものできること速さ
L4 プロキシTCP のバイト列(中身は見ない)素通しの中継、TCPレベルの分散速い
L7 プロキシHTTP を解釈パス/ヘッダで振り分け、書き換え、キャッシュ賢いが重い

L4 は、クライアントとバックエンドの間で TCP のバイトを両方向にコピーするだけ (io.Copy を2本)。中身が HTTP か何かも知らない。速いが、パスやヘッダでの 判断はできない。

L7 は、HTTP をパースして理解する。だから「/api は バックエンドA、/img はB」のようなパスベースの振り分けや、ヘッダの書き換えができる。 この章の実装は L7:

go
// 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 ヘッダに書いて渡す。

go
// 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つずつ配る。

go
// 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 … と 順番に振り分け、各バックエンドの受信数が均等に増えるのが見える。

デモリバースプロキシ + ラウンドロビン0 リクエスト
クライアント127.0.0.1
X-Forwarded-For: 127.0.0.1 を付与
プロキシL7 / round-robin
backend-10 件
backend-20 件
backend-30 件

通信経路がつながった

ここまでの章を積み上げて、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)。for proto host を1本にまとめられるが、普及では X-Forwarded-* が依然多い
  • TLS 終端の位置が設計を決める: プロキシで終端すると中身を読めて L7 の機能が全部使えるが、そこから内側は平文になる。内側も暗号化するなら再度張り直すことになり、TLS ハンドシェイクの費用を2回払う
  • hop-by-hop ヘッダは転送しない: ConnectionTransfer-Encoding は隣の相手との取り決めなので、そのまま先へ流すと壊れる。規格が転送してはいけないヘッダを定めている
  • 単一障害点への答え: プロキシ自体は複数台置き、その手前を DNS か L4 の分散で振り分ける。ロードバランサの章で見た振り分けが、プロキシの前段にもう一度出てくる形になる

簡略化したこと

  • L7 のみ: L4 の生 TCP 中継(io.Copy 2本)は説明にとどめた
  • ラウンドロビンのみ: 重み付き・最小接続数・IPハッシュ・ヘルスチェックなし
  • リトライ/サーキットブレーカーなし: バックエンド不通は即 502
  • keep-alive なし・ヘッダ書き換えは XFF 追加のみ: 実物は Host 書き換え、 Connection ヘッダの扱い、hop-by-hop ヘッダの除去などがある

参考資料