ロードバランサを自作する

TCP接続をバックエンドへ振り分けるL4ロードバランサを100行規模で自作し、ラウンドロビン・ヘルスチェック・least-connまでを手を動かして理解でき、NginxやHAProxyの中身が読めるようになる。

応用ロードバランサL4TCPラウンドロビンヘルスチェックネットワーク最終更新: 2026-07-29
3つの要点
TL;DR
  1. L4ロードバランサの本体は、受理したクライアント接続に対しバックエンドへもう1本TCPを張り、両方向のバイト列をコピーするだけ。中身のHTTPは一切解釈しない。
  2. 振り分けは接続確立の瞬間に1回だけ決まる。ラウンドロビンは順番、least-connは現在接続数が最小のノード、consistent hashは送信元IPで同じノードへ固定する。
  3. ヘルスチェックで死んだノードを候補から外し、コネクションドレインで既存接続を保護する。L7との差はペイロードを読むか否かで、URLやCookieで振るならL7が要る。

何を作るか

作るのは、1つのポートで待ち受け、入ってきたTCP接続を複数のバックエンドサーバーへ振り分ける最小のL4(レイヤ4)ロードバランサです。L4とは、振り分けの判断にIPアドレスとTCPポートまでしか使わず、その上に流れるHTTPやgRPCといったアプリケーション層のデータ(ペイロード)を一切読まない、という意味です。

これを自作すると、いくつかの「なぜ」が腑に落ちます。第一に、ロードバランサがなぜHTTPを知らなくても動くのか。第二に、振り分けアルゴリズム(ラウンドロビン・least-conn・consistent hash)が具体的にどのデータ構造で実装されるのか。第三に、ヘルスチェックやコネクションドレインといった運用機能が、本体のどこに差し込まれるのか。NginxやHAProxy、クラウドのNLBはこれらを高度化したものであり、骨格は本稿の100行程度と同じです。基礎となるネットワークの各層の役割を押さえておくと理解が速くなります。

L4は接続を橋渡しするだけ

L4ロードバランサの仕事は「クライアントとバックエンドの間にTCP接続を2本張り、バイト列をそのまま右から左へ流す」ことに尽きます。HTTPのメソッドもパスもヘッダも解釈しません。だからHTTPでもWebSocketでも任意の独自プロトコルでも、TCPでありさえすれば同じコードで中継できます。

最小実装の全体像

横にスクロール

接続単位の選択と双方向中継を分離するための最小構成と処理順序を示す図
入力から最小構成の部品を通り、検証と拡張へ進む実装経路を整理します。

パーツは4つです。(1)受理ループ: 待ち受けソケットで accept() し続け、接続が来るたびに次を決める。(2)バランサ: バックエンド一覧から1台を選ぶ(ここにアルゴリズムが入る)。(3)中継: クライアント接続とバックエンド接続の間で、両方向のバイト列をコピーする。(4)ヘルスチェッカ: 定期的に各バックエンドの生死を確認し、候補から出し入れする。

重要な設計判断は、振り分けは接続ごとに1回だけという点です。一度あるバックエンドへ接続を張ったら、その接続が閉じるまで相手は変わりません。L4は個々のパケットの中身を見ないので、同じTCP接続のパケットを別のノードへ送ると、相手のバックエンドはそのTCPシーケンスの続きを知らず接続が壊れます。したがって「粘着(stickiness)」はL4では例外ではなく原理的な既定動作です。

段階を追って作る

まずバックエンドの表現と受理ループです。各バックエンドは宛先アドレスと、生死・現在接続数といった状態を持ちます。

class Backend:
    def __init__(self, host, port):
        self.host, self.port = host, port
        self.healthy = True
        self.active = 0        # 現在張られている接続数(least-conn用)

backends = [Backend("10.0.0.11", 8080),
            Backend("10.0.0.12", 8080),
            Backend("10.0.0.13", 8080)]

listener = socket.create_server(("0.0.0.0", 80))
while True:
    client, addr = listener.accept()      # 1接続受理
    be = pick_backend(addr)               # ここで振り分け先を決定
    if be is None:                        # 生きた候補がない
        client.close(); continue
    spawn(handle, client, be)             # 別スレッドで中継へ

次が振り分けの中核 pick_backend です。ラウンドロビンは、生きているノードだけを順に選びます。死んだノードを飛ばすため、単純な剰余ではなく「健全な候補リストから順番に取る」形にします。

rr_index = 0
def pick_backend(addr):
    global rr_index
    alive = [b for b in backends if b.healthy]
    if not alive:
        return None
    be = alive[rr_index % len(alive)]     # 順番に1台
    rr_index += 1
    return be

中継部が本体です。クライアントから来たバイト列をバックエンドへ、バックエンドから来たバイト列をクライアントへ、双方向にコピーします。片方向ずつ別スレッド(またはイベントループ)で回すのが定石です。どちらかが閉じたら両方を閉じ、接続数を戻します。

def handle(client, be):
    be.active += 1
    upstream = socket.create_connection((be.host, be.port))  # もう1本張る
    try:
        # 2方向を並行にコピー。中身は見ずにバイト列を素通し
        t = spawn(pump, client, upstream)   # client -> upstream
        pump(upstream, client)              # upstream -> client
        t.join()
    finally:
        client.close(); upstream.close()
        be.active -= 1                      # 接続が終わったので戻す

def pump(src, dst):
    while True:
        data = src.recv(65536)
        if not data:                        # 相手がクローズ
            dst.shutdown(SHUT_WR); return
        dst.sendall(data)

この時点で、HTTPを一切解釈しない中継が動きます。recv したバイト列を中身に触れず sendall するだけなので、L4である、という主張がコードに現れています。

最後にヘルスチェッカを足します。別スレッドで一定間隔ごとに各バックエンドへTCP接続を試み、失敗が続けば healthy=False にして候補から外します。回復したら戻します。フラッピング(生死の高速な往復)を防ぐため、実運用では「N回連続成功で復帰」のような閾値を設けます。

def health_loop():
    while True:
        for b in backends:
            ok = tcp_ping(b.host, b.port, timeout=1)  # 繋がるか試すだけ
            b.healthy = ok
        sleep(2)

ヘルスチェックが pick_backendalive フィルタと連動している点が要です。振り分けロジックとヘルス状態を疎結合にし、「候補集合を絞る」責務だけをヘルスチェッカに持たせると、アルゴリズムを差し替えても除外が効き続けます。

least-connへの発展は数行

ラウンドロビンを least-conn(最小接続数)へ変えるのは、選び方を1行差し替えるだけです。min(alive, key=lambda b: b.active) で現在の active が最小のノードを選びます。処理時間にばらつきがある(重いリクエストが混じる)場合、順番だけのラウンドロビンより負荷が均ります。active は中継の開始で+1、終了で-1しているので、そのまま指標に使えます。

送信元IPで振り分け先を固定したい場合は consistent hash(コンシステントハッシュ)を使います。hash(送信元IP) をノードのリング上へ写像し、時計回りで最初のノードを選ぶと、同じクライアントは常に同じバックエンドへ向かい、ノードの増減時に再配置される接続が最小限で済みます。単純な hash(IP) % ノード数 はノード数が変わると大半の対応が変わってしまうため、リング方式が使われます。分散の考え方はデータベースのシャーディングと共通です。

アルゴリズム選び方向く状況弱点
ラウンドロビン健全ノードを順番にリクエストが均質処理時間のばらつきに弱い
least-conn現在接続数が最小のノード処理時間がまちまち接続数と負荷が比例しない場合に外す
consistent hash送信元IPをリングへ写像同一クライアントを固定したいクライアント偏在でノード間が不均等

発展と本物との違い

本稿は原理を通すために多くを省きました。実運用のロードバランサは、この骨格に次を足しています。

コネクションドレイン(接続の抜き取り)。ノードを撤去・更新する際、いきなり切ると通信中の接続が失敗します。正しくは「新規の振り分け候補からは即座に外し、既存の接続はゼロになるまで(あるいは猶予時間まで)流し続けてから閉じる」。本稿の構造なら、healthy=False とは別に draining フラグを設け、pick_backend の候補からは除外しつつ handle 内の既存接続は生かす、という2状態で実装できます。active が0になった時点で安全に落とせます。

TLS終端。L4は中身を見ないので、暗号化されたHTTPSもそのまま中継できますが、証明書の検査やHTTPヘッダでの振り分けはできません。ロードバランサでTLSを終端(復号)すると、平文になったHTTPを読めるようになり、URLパスやCookie、Host ヘッダに基づく振り分けが可能になります。これがL7(レイヤ7)ロードバランサです。復号の負荷を肩代わりする一方、平文がロードバランサを通るため信頼境界の設計が要ります。

観点L4ロードバランサL7ロードバランサ
見る情報IP・TCP/UDPポートまでHTTPのパス・ヘッダ・Cookie等
振り分け単位接続(コネクション)リクエスト単位で可
できること高速な素通し中継パスやCookieでの振り分け・書き換え
コスト軽い(ペイロード非解釈)重い(復号・解析が必要)
代表例NLB・LVS・HAProxy(tcp)Nginx・Envoy・ALB

さらに本物は、C10K問題を避けるための多重化(スレッドではなく epoll/kqueue によるイベント駆動)、パケットを書き換えず宛先MACだけ変えてバックエンドから直接クライアントへ返す DSR(Direct Server Return)、複数台のロードバランサ自身を冗長化するVIPとフェイルオーバ、そして重み付け(性能差のあるノードへ比率を変えて配る weighted round-robin)を備えます。

L4では中身に応じた再送・書き換えができない

L4はバイト列を素通しするだけなので、あるバックエンドが5xxを返しても「別ノードへ再試行」はできません(HTTPを解釈しないため5xxだと気づけない)。パスやヘッダでの振り分け、レスポンスの圧縮・書き換え、リクエスト単位のリトライが要件なら、L4では原理的に不可能でL7が必要になります。速さのL4か、賢さのL7かを要件で選び分けます。

以上で、待ち受け・振り分け・双方向中継・ヘルスチェックという最小の4部品から、L4ロードバランサの原理が一通り追えました。ここに drain・重み付け・イベント駆動・TLS終端を足していけば、実運用の実装へ地続きに拡張できます。関連する下位層の仕組みはネットワークを参照してください。

自作で学ぶの記事ガイド

ロードバランサを自作するを実務で読む

TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。

解決すること

ロードバランサ

比較で見る軸

難易度: advanced / カテゴリ: 自作で学ぶ / タグ数: 6

導入後に効く点

振り分けは接続確立の瞬間に1回だけ決まる。ラウンドロビンは順番、least-connは現在接続数が最小のノード、consistent hashは送信元IPで同じノードへ固定する。

先に潰すリスク

用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。

数字・仕様の読み方
難易度
advanced
カテゴリ
自作で学ぶ
タグ数
6

判断チェックリスト

  • 自社の用途が「ロードバランサ / L4」に近いか確認する。
  • 強みである「L4ロードバランサの本体は、受理したクライアント接続に対しバックエンドへもう1本TCPを張り、両方向のバイト列をコピーするだけ。中身のHTTPは一切解釈しない。」が本当に評価軸になるか確認する。
  • 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
  • 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
  • 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
  • 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。

次に確認する観点

ロードバランサL4TCPラウンドロビンヘルスチェック