HTTPサーバーを自作する
TCPソケットをlistenして生のHTTPを手でパースする最小サーバーを作れば、リクエスト行・ヘッダ・Content-Length・keep-aliveの正体が腑に落ち、フレームワークが何を肩代わりしているかが一目で分かる。
- HTTPはTCP上のテキストプロトコル。ソケットをlistenしacceptで接続を1本得たら、あとは受信バイト列を『リクエスト行→ヘッダ→空行→ボディ』の順に区切って読むだけで動く。
- ボディの終端はソケットの切断では判定しない。keep-aliveで接続を使い回すため、Content-Lengthヘッダのバイト数だけボディを読み切るのが正しい。
- 最小実装は1接続ずつ順番に捌く逐次サーバー。並行処理・チャンク転送・エラー処理・TLSを足していくと、フレームワークがなぜ要るかが実感として分かる。
何を作るか
作るのは、ブラウザや curl からのHTTPリクエストを受け取り、200 OK とともに固定のHTMLを返す最小のWebサーバーです。フレームワークもHTTPライブラリも使わず、OSが提供するTCPソケットだけを土台にします。ゴールは curl http://localhost:8080/ が本文を表示し、ブラウザでも同じページが開くこと。それだけです。
これを一度手で書くと、普段ライブラリの奥に隠れている事実が腑に落ちます。第一に、HTTP/1.1はTCPコネクション上を流れる素朴なテキストであること。第二に、サーバーの仕事の本質は「受信したバイト列を正しい区切りで分解し、規約どおりのバイト列を書き戻す」ことに尽きること。第三に、Content-Lengthやkeep-aliveといった一見地味なヘッダが、なぜ必須なのかということです。TCPそのものの仕組みはネットワーク、ソケットがOSカーネル内でどう扱われるかはOSの各トピックが土台になります。
本稿はテキストベースのHTTP/1.1に絞ります。HTTP/2以降はバイナリのフレーム構造とヘッダ圧縮(HPACK)が入り、手書きパースの難度が跳ね上がるためです。原理を掴む目的では、まず/1.1を完全に手で組むのが近道です。
最小実装の全体像
横にスクロール
サーバーは3つのパーツで構成します。第一にリスニングソケット(listenするTCP入口)。第二にacceptループ(接続を1本ずつ受け付ける繰り返し)。第三にリクエストハンドラ(受信バイト列をパースして応答を書き戻す処理)です。組み方は下の対比が分かりやすいです。
| パーツ | 役割 | OSの主なシステムコール |
|---|---|---|
| リスニングソケット | ポートを確保しTCP接続を待ち受ける | socket / bind / listen |
| acceptループ | 接続要求を1本取り出し、接続済みソケットを得る | accept |
| リクエストハンドラ | リクエストを読みパースし応答を書く | recv / send |
最初に作るのは、1接続を最後まで捌いてから次を受ける逐次(シングルスレッド)サーバーです。並行処理はあえて省きます。動作の骨格を擬似コードで示します。
socket() # TCP用のソケットを作る
bind(("0.0.0.0", 8080)) # ポート8080を確保
listen(backlog=128) # 接続待ち行列を開く
loop: # acceptループ
conn = accept() # 接続を1本取り出す(無ければブロック)
request = read_request(conn) # 生バイトを読みパース
response = handle(request) # 応答を組み立てる
conn.send(response) # 書き戻す
conn.close() # まずは1リクエストで閉じる
段階を追って作る
ステップ1: 待ち受けとacceptループ
まずTCPの入口を用意します。socket → bind → listen の3手でポートを確保し、accept で接続を1本得ます。accept は接続要求が来るまで処理を止める(ブロックする)点が肝で、来た瞬間に「その接続専用のソケット」を返します。以降の読み書きはこの接続ソケットに対して行います。ここではPythonの標準ソケットAPIで書きますが、名前はそのままOSのシステムコールに対応します。
import socket
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) # 再起動直後の再bindを許す
server.bind(("0.0.0.0", 8080))
server.listen(128) # 128は接続待ち行列(backlog)の上限
while True:
conn, addr = server.accept() # 接続を1本取り出す
with conn:
handle_connection(conn)
SO_REUSEADDR を立てるのは、サーバーを落として即再起動したとき、直前の接続が TIME_WAIT に残っていても同じポートへ再びbindできるようにするためです。これが無いと開発中に「アドレスが使用中」で頻繁に弾かれます。
ステップ2: リクエスト行とヘッダをパースする
接続が取れたら、recv で受信バイト列を読みます。HTTP/1.1リクエストの構造は厳密です。1行目がリクエスト行(メソッド・パス・バージョンを空白区切り)、続いてヘッダが1行1つ、そして空行(CRLF すなわち \r\n だけの行)が来て、その後ろがボディです。行区切りは常に \r\n である点に注意します。
GET /index.html HTTP/1.1\r\n ← リクエスト行
Host: localhost:8080\r\n ← ヘッダ
User-Agent: curl/8.0\r\n
\r\n ← 空行(ヘッダの終端)
(ここからボディ。GETなら通常は空)
パースの要点は、まず \r\n\r\n(空行)でヘッダ部とボディ部を割ることです。ヘッダ部を \r\n で分割し、先頭をリクエスト行、残りを 名前: 値 として辞書に入れます。ヘッダ名は大文字小文字を区別しない規約なので、引くときに困らないよう小文字へ正規化しておきます。
def parse_request(raw: bytes):
head, _, body = raw.partition(b"\r\n\r\n") # 空行で頭部と本体を分離
lines = head.split(b"\r\n")
method, path, version = lines[0].decode().split(" ") # リクエスト行
headers = {}
for line in lines[1:]:
name, _, value = line.partition(b": ")
headers[name.decode().lower()] = value.decode() # 名前は小文字化
return method, path, headers, body
TCPはバイトストリームであり、メッセージの区切りを保存しません。1回の recv が返すのはたまたま届いた分だけで、リクエストの途中で切れることもあれば、複数リクエストが1回で届くこともあります。堅牢に作るには、少なくとも空行 \r\n\r\n が現れるまで受信を繰り返してヘッダを読み切る必要があります。
ステップ3: Content-Lengthでボディを読み切る
POSTのようにボディを伴うリクエストでは、「どこまでがボディか」を知る必要があります。TCPの切断で判断してはいけません。keep-aliveでは1本の接続を複数リクエストで使い回すため、送り手はボディを送り終えても接続を閉じないからです。そこで送り手は Content-Length ヘッダにボディのバイト数を明記し、受け手はその数だけ読み切ります。
def read_body(conn, headers, already: bytes) -> bytes:
length = int(headers.get("content-length", "0"))
body = already # ヘッダと一緒に届いていた分
while len(body) < length: # 宣言バイト数に達するまで
chunk = conn.recv(4096)
if not chunk: # 相手が予期せず切断
break
body += chunk
return body[:length]
ここが「バイトストリームには自前でフレーム(境界)を引く」というネットワークプログラミングの核心です。長さを先に宣言する(Content-Length)か、後述の区切りマーカーを使う(チャンク転送)か、どちらかで境界を作らない限り、受け手はメッセージの終わりを知りようがありません。
ステップ4: 応答を組み立てて書き戻す
応答も構造は対称です。1行目がステータス行(バージョン・ステータスコード・理由句)、続いてヘッダ、空行、ボディの順。ボディを返すなら Content-Length を必ず添えます。相手(ブラウザやcurl)も同じ規約でボディの終端を判定するからです。
def build_response(status: str, body: bytes) -> bytes:
headers = (
f"HTTP/1.1 {status}\r\n"
f"Content-Type: text/html; charset=utf-8\r\n"
f"Content-Length: {len(body)}\r\n"
f"Connection: close\r\n"
f"\r\n" # 空行でヘッダ終端
)
return headers.encode() + body # ヘッダ(テキスト)+ボディ(バイト)
def handle_connection(conn):
raw = conn.recv(65536) # 簡略化のため一括受信
method, path, headers, body = parse_request(raw)
page = b"<h1>Hello from a hand-made server</h1>"
conn.sendall(build_response("200 OK", page)) # sendallで全部送り切る
sendall を使うのは、send が一度に全バイトを送れるとは限らず、送信バッファが一杯なら途中までしか送らないためです。書き込み側も「送り切るまで繰り返す」必要があり、sendall はそれを内部でやってくれます。ここまでで curl http://localhost:8080/ がHTMLを返す、動くサーバーが完成です。
ステップ5: keep-aliveの初歩
HTTP/1.1では接続の使い回し(keep-alive)が既定です。1リクエストごとにTCPを張り直すと3ウェイハンドシェイクの往復が毎回乗るため、同じ接続で複数リクエストを続けて捌けると速くなります。実装は「応答を書いたら閉じずにループへ戻り、次のリクエストを同じ接続から読む」だけです。ただし各リクエストの境界を Content-Length で正しく読み切れていることが大前提になります。
def serve(conn):
while True:
request = read_one_request(conn) # 1リクエストを厳密に読み切る
if request is None: # 相手が接続を閉じた
break
conn.sendall(build_response("200 OK", render(request)))
if request.headers.get("connection") == "close":
break # 明示的なcloseなら終了
keep-aliveの罠は、前のリクエストのボディを読み残したまま次のループへ進むと、残りバイトを次のリクエスト行と誤読してパースが崩壊することです。keep-aliveを入れる条件は「毎リクエストをContent-Length(またはチャンク)で最後の1バイトまで読み切れていること」。ここが逐次サーバーと本物のサーバーの最初の分岐点です。
発展と本物との違い
動くものはできましたが、実運用のサーバーはここから多くを足しています。省いた要素と、フレームワークやWebサーバーが肩代わりしているものを整理します。
| 論点 | 本稿の最小実装 | 実運用の実装が足すもの |
|---|---|---|
| 並行処理 | 1接続ずつ逐次。遅い相手が全体を止める | スレッド/プロセスプール、または非同期I/O(イベントループ)で多接続を同時に捌く |
| ボディ長不定 | Content-Length前提 | チャンク転送(Transfer-Encoding: chunked)で長さ未定のまま送受信 |
| 異常系 | ほぼ未処理 | 不正な行・巨大ヘッダ・タイムアウト・遅延読み取り攻撃への防御 |
| ルーティング | 固定応答のみ | パス/メソッドごとのハンドラ振り分け、ミドルウェア |
| 暗号化 | 平文 | TLS終端(HTTPS)。ハンドシェイクと証明書検証 |
最大の弱点は並行処理です。逐次サーバーは、1つの遅いクライアント(回線が細い、あるいは意図的にゆっくり送る)が接続を占有すると、後続の全接続が待たされます。現実的なサーバーは、接続ごとにスレッドやプロセスを割り当てるか、1スレッドで多数の接続を多重化する非同期I/O(イベントループ)で同時並行に捌きます。C10K問題(1万同時接続をどう捌くか)以降、後者が主流です。
Content-Length を先に確定できない場合、送り手はチャンク転送(Transfer-Encoding: chunked)を使います。ボディを「16進の長さ+そのバイト列」の塊の連なりとして送り、長さ0の塊で終端を示す方式で、動的生成でサイズが事前に決まらない応答に必須です。受け手側のパースは本稿のContent-Length方式より一段複雑になります。
さらに実運用では、壊れたリクエスト行、宣言と実際が食い違うボディ長、巨大なヘッダによるメモリ枯渇、ヘッダを極端にゆっくり送り続けて接続を占有する攻撃(Slowloris)など、無数の異常系を捌く必要があります。加えてパスやメソッドごとの振り分け(ルーティング)、共通処理の差し込み(ミドルウェア)、そしてHTTPSのためのTLS終端が乗ります。
自作すると分かるのは、フレームワークの価値が「HTTPを喋れること」ではなく、その先にあるという点です。パース自体は数十行で書けます。しかし並行処理モデル、チャンク・keep-aliveの正確な実装、膨大な異常系とセキュリティ対策、ルーティングやミドルウェアの土台——これらを堅牢かつ標準準拠で用意するのが本質的な負担で、そこを肩代わりするのがフレームワークです。原理を一度手で通しておくと、フレームワークが隠している層が明確に見え、性能問題や挙動の謎を追うときの解像度が上がります。
ここから先を掘るなら、HTTP自体の意味論やステータスコード設計はWeb、Pythonでの非同期I/Oやプログラムからのソケット操作の詳細はプログラミングの各トピックが続きです。まずは本稿の逐次サーバーを動かし、そこへ並行処理を1段足すところから始めると、実装の重心がどこにあるかが体で分かります。
自作で学ぶの記事ガイド
HTTPサーバーを自作するを実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
HTTP
比較で見る軸
難易度: advanced / カテゴリ: 自作で学ぶ / タグ数: 6
導入後に効く点
ボディの終端はソケットの切断では判定しない。keep-aliveで接続を使い回すため、Content-Lengthヘッダのバイト数だけボディを読み切るのが正しい。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- 自作で学ぶ
- タグ数
- 6
判断チェックリスト
- 自社の用途が「HTTP / TCP」に近いか確認する。
- 強みである「HTTPはTCP上のテキストプロトコル。ソケットをlistenしacceptで接続を1本得たら、あとは受信バイト列を『リクエスト行→ヘッダ→空行→ボディ』の順に区切って読むだけで動く。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。