ロードバランサを自作する
TCP接続をバックエンドへ振り分けるL4ロードバランサを100行規模で自作し、ラウンドロビン・ヘルスチェック・least-connまでを手を動かして理解でき、NginxやHAProxyの中身が読めるようになる。
- L4ロードバランサの本体は、受理したクライアント接続に対しバックエンドへもう1本TCPを張り、両方向のバイト列をコピーするだけ。中身のHTTPは一切解釈しない。
- 振り分けは接続確立の瞬間に1回だけ決まる。ラウンドロビンは順番、least-connは現在接続数が最小のノード、consistent hashは送信元IPで同じノードへ固定する。
- ヘルスチェックで死んだノードを候補から外し、コネクションドレインで既存接続を保護する。L7との差はペイロードを読むか否かで、URLやCookieで振るならL7が要る。
何を作るか
作るのは、1つのポートで待ち受け、入ってきたTCP接続を複数のバックエンドサーバーへ振り分ける最小のL4(レイヤ4)ロードバランサです。L4とは、振り分けの判断にIPアドレスとTCPポートまでしか使わず、その上に流れるHTTPやgRPCといったアプリケーション層のデータ(ペイロード)を一切読まない、という意味です。
これを自作すると、いくつかの「なぜ」が腑に落ちます。第一に、ロードバランサがなぜHTTPを知らなくても動くのか。第二に、振り分けアルゴリズム(ラウンドロビン・least-conn・consistent hash)が具体的にどのデータ構造で実装されるのか。第三に、ヘルスチェックやコネクションドレインといった運用機能が、本体のどこに差し込まれるのか。NginxやHAProxy、クラウドのNLBはこれらを高度化したものであり、骨格は本稿の100行程度と同じです。基礎となるネットワークの各層の役割を押さえておくと理解が速くなります。
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_backend の alive フィルタと連動している点が要です。振り分けロジックとヘルス状態を疎結合にし、「候補集合を絞る」責務だけをヘルスチェッカに持たせると、アルゴリズムを差し替えても除外が効き続けます。
ラウンドロビンを 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はバイト列を素通しするだけなので、あるバックエンドが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、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。