2. HTTP は何をやりとりしているのか

1 章本書の対象読者とゴール)では、ブラウザからレスポンスまでの全体像を俯瞰し、登場する役者たちを整理しました。 ここからはいよいよ、各層の中身に踏み込んでいきます。

2 章HTTP は何をやりとりしているのか)の主役は HTTP です。 Web アプリケーションのあらゆる通信は、HTTP というプロトコルの上で行われています。 フレームワークが提供する request.GETresponse.status_code といった便利なオブジェクトも、突き詰めれば HTTP のテキストを Python オブジェクトに変換したものです。 まずは、その変換の元になっている「生のテキスト」の姿を見ていきましょう。

diagram

2.1. リクエストライン

ブラウザがサーバに送る HTTP リクエストの最初の1行を、リクエストラインと呼びます。

GET /users/42/ HTTP/1.1

この1行には、3つの情報が空白で区切られて並んでいます。 メソッドGET)、リクエストターゲット/users/42/)、HTTPバージョンHTTP/1.1)です。

  • メソッド: 「何をしたいのか」を示します。GET は「リソースを取得したい」、POST は「データを送信したい」という意図を表します。メソッドについては本節の後半で改めて整理します。

  • リクエストターゲット: サーバ上のどのリソースに対するリクエストなのかを示すパスです。ブラウザのアドレスバーに入力した URL のうち、ドメイン名より後ろの部分がここに入ります。クエリ文字列がある場合は /search?q=python&page=2 のようにパスの後ろに付きます。

  • HTTP バージョン: このリクエストがどのバージョンの HTTP 仕様に従っているかを示します。

注釈

現在のウェブでは HTTP/2 が主流となっており(サイト全体の約70%)、さらに HTTP/3 も急速に普及しています。 HTTP/1.1 は一部のレガシー環境では今も使われていますが、シェアは大幅に低下しています。

HTTP/2 や HTTP/3 はバイナリプロトコルであるため、パケットをそのままテキストとして人間が読めるのは HTTP/1.1 までです。 本書では HTTP/1.1 を扱います。内部構造を理解するには、人間の目で読めるテキストプロトコルのほうがはるかに学びやすいからです。

リクエストラインの末尾には改行コード \r\n(キャリッジリターン+ラインフィード)が付きます。 HTTP の仕様では、行の区切りとして \r\n を使うことが定められています。

注意

Unix 系の OS で一般的な \n だけではなく、\r\n である点は、後ほどソケットで HTTP を自作するときに地味に重要になります。 うっかり \n だけで書いてしまうと、厳密な HTTP パーサーを持つクライアントやプロキシでは不正なメッセージとして扱われる可能性があります。

2.2. ヘッダー

リクエストラインの次に続くのが、ヘッダーです。ヘッダーはリクエストに関する付加情報を「名前: 値」のペアで伝えます。

GET /users/42/ HTTP/1.1
Host: example.com
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: ja,en-US;q=0.7,en;q=0.3
Accept-Encoding: gzip, deflate, br
Connection: keep-alive

各行がひとつのヘッダーフィールドです。 コロンの左側がフィールド名、右側がフィールド値で、各行の末尾に \r\n が付きます。

代表的なヘッダーの役割をまとめると、次のとおりです。

ヘッダー名

役割

Host

このリクエストがどのドメインに向けたものかを示す。HTTP/1.1 では必須

User-Agent

リクエストを送っているクライアントの種類を伝える

Accept

クライアントが受け取れるコンテンツの形式(MIME タイプ)を伝える

Accept-Language

希望する言語を伝える

Accept-Encoding

対応している圧縮方式(gzip など)を伝える

Connection

TCP コネクションの維持・切断について指示する

Tip

Host ヘッダーは HTTP/1.1 で必須とされています。ひとつのサーバで複数のドメインをホストしている場合(バーチャルホスト)、サーバはこのヘッダーを見てどのサイトへのリクエストかを判断します。

Connection: keep-alive は、このリクエストの後も TCP コネクションを閉じずに維持してほしいという要求です。 HTTP/1.0 では1リクエストごとに TCP コネクションを張り直していましたが、HTTP/1.1 ではデフォルトで接続を維持する(持続的接続)ようになりました。

ヘッダーの一覧が終わると、空行\r\n だけの行)が入ります。 この空行が「ヘッダーの終わり」を示す合図です。 サーバはこの空行を見つけることで、ヘッダーの解析が完了したことを知ります。

2.3. ボディ

空行の後に続くのが、ボディ(メッセージ本文)です。 ただし、すべてのリクエストにボディがあるわけではありません。

  • GET リクエストは通常ボディを持ちません。

  • ボディが登場するのは主に POSTPUT など、クライアントからサーバにデータを送信するリクエストです。

たとえば、ユーザー登録フォームを送信する POST リクエストは次のような形になります。

POST /users/ HTTP/1.1
Host: example.com
Content-Type: application/json
Content-Length: 49

{"name": "Taro Yamada", "email": "[email protected]"}

ヘッダーの Content-Type はボディのデータ形式を示しています。

Content-Type の値

ボディの形式

application/json

JSON データ

application/x-www-form-urlencoded

HTML フォームのデータ

multipart/form-data

ファイルアップロードを含むデータ

Content-Length はボディのバイト数を示します。 サーバはこの値を見て、「あと何バイト読めばボディが終わるか」を判断します。 この値がなければ、サーバはボディの終端を知る手段がありません(HTTP/1.1 には Transfer-Encoding: chunked という別の仕組みもありますが、基本は Content-Length です)。

重要

HTTP リクエスト全体の構造は、リクエストライン・ヘッダー・空行・ボディというシンプルな4要素で成り立っています。

リクエストライン\r\n ヘッダー1\r\n ヘッダー2\r\n …\r\n \r\n ← 空行(ヘッダーの終わり) ボディ(あれば)


この構造は HTTP レスポンスでもほぼ同じです(リクエストラインの代わりにステータスラインになるだけです)。

3 章まずは 1 リクエストだけ処理するサーバを作る)で HTTP サーバを自作する際には、この構造をパースするコードを自分で書くことになります。

2.4. メソッド

リクエストラインに含まれるメソッドについて、もう少し整理しておきましょう。

HTTP の仕様では多くのメソッドが定義されていますが、Web アプリケーション開発で日常的に使うのは主に4つです。

メソッド

用途

安全?

冪等?

GET

リソースの取得

POST

データの送信・リソースの作成

×

×

PUT

リソース全体の置き換え

×

DELETE

リソースの削除

×

用語解説: 安全と冪等

  • 安全(safe): サーバの状態を変更しないメソッドを「安全」と言います。GET は何度呼んでもデータが変わらないため安全です。

  • 冪等(べきとう): 同じリクエストを何度送っても結果が同じになるメソッドを「冪等」と言います。PUT は何度送っても同じデータで上書きされるだけなので冪等です。一方、POST は何度送ると新しいリソースが何個も作られる可能性があるため、冪等ではありません。

各メソッドをもう少し詳しく見てみましょう。

GET は、リソースの取得を要求するメソッドです。 ブラウザでページを開く、API からデータを取得する、といった操作はすべて GET です。 GET リクエストはボディを持たないのが原則で、サーバの状態を変更しない「安全な」メソッドとされています。

POST は、サーバにデータを送信して処理を要求するメソッドです。 フォームの送信、新しいリソースの作成などに使われます。 POST は安全でも冪等でもありません。 同じ POST リクエストを2回送ると、リソースが2つ作られる可能性があります。

PUT は、指定したリソースを送信したデータで置き換えることを要求するメソッドです。 リソース全体の更新に使われます。 PUT は冪等です。同じ PUT リクエストを何度送っても、結果は同じになります。

DELETE は、指定したリソースの削除を要求するメソッドです。 DELETE も冪等です。すでに削除されたリソースに対してもう一度 DELETE を送っても、結果は変わりません。

このほかに PATCH(リソースの部分的な更新)、HEAD(GET と同じだがボディを返さない)、OPTIONS(サーバが対応しているメソッドの問い合わせ)なども存在しますが、まずは GET と POST の2つをしっかり理解しておけば、本書を読み進めるうえで困ることはありません。

注釈

Django を使っている方は、ビュー関数の中で if request.method == 'GET': と分岐を書いたことがあるでしょう。 FastAPI であれば @app.get()@app.post() でデコレータを使い分けています。 これらは結局、HTTP リクエストラインの1つ目のフィールドを見て処理を振り分けているのです。

2.5. ステータスコード

ここまではリクエスト側の話でした。次は、サーバが返すレスポンスの中身を見てみましょう。

HTTP レスポンスの先頭行はステータスラインと呼ばれ、リクエストラインに対応する構造を持っています。

HTTP/1.1 200 OK

HTTP バージョン、ステータスコード200)、理由フレーズOK)の3要素です。 実質的に意味を持つのはステータスコードのほうで、理由フレーズは人間が読むための補助的な文字列です。

ステータスコードは3桁の数字で、先頭の1桁でカテゴリが決まります。

カテゴリ

意味

代表的なコード

1xx

情報(処理継続中)

101 Switching Protocols

2xx

成功

200 OK, 201 Created, 204 No Content

3xx

リダイレクト

301 Moved Permanently, 302 Found

4xx

クライアントエラー

400 Bad Request, 401 Unauthorized, 403 Forbidden, 404 Not Found

5xx

サーバエラー

500 Internal Server Error, 502 Bad Gateway, 503 Service Unavailable

各カテゴリの意味を補足しておきます。

  • 1xx(情報): 処理が継続中であることを示します。日常の開発で目にする機会は少ないですが、WebSocket のアップグレードで使われる 101 Switching Protocols はこのカテゴリです。

  • 2xx(成功): リクエストが正常に処理されたことを示します。200 OK が最も基本的な成功レスポンスです。201 Created はリソースが新しく作成されたことを示し、204 No Content はボディなしの成功レスポンスで DELETE の応答などで使われます。

  • 3xx(リダイレクト): リクエストされたリソースが別の場所にあることを示します。ブラウザはこれらを受け取ると、Location ヘッダーに指定された URL に自動的に再リクエストを送ります。

  • 4xx(クライアントエラー): クライアント側に原因があるエラーです。前章で「404 はどの層で発生するか」を議論しましたが、ステータスコード自体はどの層が返しても同じ 404 です。だからこそ、層の切り分けが重要になります。

  • 5xx(サーバエラー): サーバ側に原因があるエラーです。502 Bad Gateway はリバースプロキシがバックエンドから不正なレスポンスを受け取った、504 Gateway Timeout はリバースプロキシがバックエンドからの応答を待ちきれなかった、という意味です。

レスポンス全体の構造は、リクエストとほぼ対称です。

HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 52

{"id": 42, "name": "Taro Yamada", "email": "[email protected]"}

ステータスライン、ヘッダー、空行、ボディ。この4要素の組み合わせは、リクエストの構造と同じです。

重要

HTTP は、リクエストもレスポンスも同じ設計原則に基づいたテキストプロトコルです。

やりとりされるデータは、リクエストが「リクエストライン・ヘッダー・空行・ボディ」、レスポンスが「ステータスライン・ヘッダー・空行・ボディ」という、対称的でシンプルな構造をしています。この構造が、Web のあらゆる通信の基盤です。

次節では、この HTTP のテキストが実際にどうやってネットワーク上を流れるのか——その土台である TCP ソケットの世界に踏み込みます。

2.6. HTTP/1.1 の基本動作

前節では、HTTP リクエストとレスポンスの構造を確認しました。 リクエストライン、ヘッダー、空行、ボディ——この構造自体は HTTP/1.0 の時代から変わっていません。では、HTTP/1.1 は何が違うのでしょうか。

HTTP/1.1 は 1997 年に策定され、その後 2014 年に改訂された(RFC 7230〜7235)、現在でも使われているバージョンです。 HTTP/1.0 からの最大の進化は、接続の効率化にあります。 HTTP/1.0 では、リクエストのたびに TCP コネクションを張って閉じるという無駄がありました。 HTTP/1.1 はこの問題を解決するために、いくつかの重要な仕組みを導入しました。

注釈

以下で取り上げる HTTP/1.1 の基本的な仕組みは、3 章まずは 1 リクエストだけ処理するサーバを作る)で HTTP サーバを自作する際の実装上の判断に直結する知識です。

diagram

2.6.1. keep-alive

HTTP/1.0 の世界を想像してみてください。 ブラウザが HTML ページを取得するために GET リクエストを送ります。 TCP の3ウェイハンドシェイクで接続を確立し、リクエストを送り、レスポンスを受け取り、接続を閉じます。 ところが HTML の中に CSS ファイル、JavaScript ファイル、画像ファイルへの参照が10個あったとします。 ブラウザはそれぞれのファイルを取得するために、同じサーバに対して10回、TCP コネクションの確立と切断を繰り返さなければなりません。

TCP の3ウェイハンドシェイクは、クライアントとサーバの間で3回のパケットのやりとりが必要です。 HTTPS であれば、さらに TLS ハンドシェイクが加わります。 たった1つのファイルを取得するために、この往復を毎回行うのは明らかに非効率です。

HTTP/1.1 では、持続的接続(persistent connection) がデフォルトの動作になりました。 一度確立した TCP コネクションを閉じずに維持し、同じコネクション上で複数のリクエストとレスポンスをやりとりできるようにしたのです。 これが俗に keep-alive と呼ばれる仕組みです。

バージョン

持続的接続のデフォルト

HTTP/1.0

オフ(使いたい場合は Connection: keep-alive を明示的に送る)

HTTP/1.1

オン(接続を閉じたい場合に Connection: close を送る)

GET /style.css HTTP/1.1
Host: example.com
Connection: close

このリクエストを受け取ったサーバは、レスポンスを返した後に TCP コネクションを閉じます。 Connection: close がなければ、サーバはコネクションを開いたまま次のリクエストを待ちます。

Tip

3 章まずは 1 リクエストだけ処理するサーバを作る)で HTTP サーバを自作するとき、コネクションを閉じるタイミングを自分で判断しなければなりません。これは設計上の重要な分岐点になります。

2.6.2. Host ヘッダー

HTTP/1.1 で必須となったヘッダーが Host です。

GET /users/42/ HTTP/1.1
Host: example.com

なぜ Host が必須なのでしょうか。 リクエストターゲット(/users/42/)にはパスしか含まれておらず、どのドメインに対するリクエストなのかがわかりません。 TCP コネクションの接続先 IP アドレスはわかりますが、ひとつの IP アドレスで複数のドメインをホストしている場合(バーチャルホスト)、IP アドレスだけではどのサイトへのリクエストかを区別できません。

たとえば、ひとつのサーバで shop.example.comblog.example.com の2つのサイトを運用しているとします。 どちらも同じ IP アドレスに解決されます。 サーバは TCP コネクションを受け入れた時点では、どちらのサイトへのリクエストかわかりません。 Host ヘッダーを見て初めて、振り分けが可能になります。

実際のフレームワーク・サーバでの使われ方

  • Nginx のバーチャルホスト設定(server_name ディレクティブ)は、まさにこの Host ヘッダーの値を見てリクエストの振り分け先を決定しています。

  • DjangoALLOWED_HOSTS 設定も、Host ヘッダーの値を検証するセキュリティ機構です。不正な Host ヘッダーを受け入れてしまうと、キャッシュポイズニングなどの攻撃に悪用される可能性があるため、Django はこの検証を厳格に行います。

HTTP/1.0 では Host ヘッダーはオプションでした。 HTTP/1.1 でこれが必須になったのは、インターネットの成長に伴いバーチャルホストが一般的になったためです。

2.6.3. Content-Length

前節でも触れましたが、Content-Length ヘッダーは HTTP/1.1 の動作を理解するうえで非常に重要です。

HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 52

{"id": 42, "name": "Taro Yamada", "email": "[email protected]"}

Content-Length は、ボディのバイト数を10進数で示します。 上の例では、JSON 文字列が正確に 52 バイトであることを伝えています。

なぜこの情報が必要なのでしょうか。 HTTP/1.0 で持続的接続を使わない場合、サーバがレスポンスを送り終えたら TCP コネクションを閉じます。 クライアントは「コネクションが閉じた=ボディが終わった」と判断できます。 しかし、HTTP/1.1 の持続的接続では、レスポンスを返してもコネクションは開いたままです。 クライアントは、ボディがどこで終わるのかを別の手段で知る必要があります。

Content-Length はその最も直接的な手段です。 クライアントは指定されたバイト数だけ読み取れば、「ここまでがこのレスポンスのボディで、この後に来るデータは次のレスポンスだ」と判断できます。

警告

Content-Length が示すのは文字数ではなくバイト数です。 ASCII 文字だけのボディであれば文字数とバイト数は一致しますが、日本語などのマルチバイト文字を含む場合は一致しません。

たとえば、UTF-8 でエンコードされた「山田太郎」は、4文字ですが 12 バイトです。 Content-Length を文字数で計算してしまうと、クライアントはボディの途中で読み取りを終えてしまい、残りのバイトが次のレスポンスの先頭として誤って解釈される——という深刻な事態になります。

Python でサーバを実装する際は、len(body) ではなく len(body.encode('utf-8')) を使うことを意識してください。

3 章まずは 1 リクエストだけ処理するサーバを作る)で HTTP サーバを自作する際、この Content-Length の計算は Python のコードとして自分で書くことになります。 len("Hello")len("Hello".encode("utf-8")) の違いを意識することが、正しく動くサーバを作る鍵です。

2.6.4. chunked transfer encoding

Content-Length が使えない場面もあります。 レスポンスのボディを生成し始めた時点では、最終的なサイズがわからないケースです。

たとえば、次のような場合です。

  • データベースから大量のレコードを取得して1件ずつ JSON に変換しながらストリーミングで返す場合、全件の処理が終わるまでボディの総バイト数は確定しません。

  • リアルタイムにログを流し続けるエンドポイントでは、ボディに終わりがありません。

このような場合に使われるのが、chunked transfer encoding(チャンク転送エンコーディング) です。

HTTP/1.1 200 OK
Content-Type: text/plain
Transfer-Encoding: chunked

1a\r\n
This is the first chunk.\r\n
1c\r\n
And this is the second one.\r\n
0\r\n
\r\n

Transfer-Encoding: chunked がヘッダーに含まれている場合、ボディは複数の「チャンク」に分割されて送信されます。 各チャンクは、次の形式で構成されます。

  1. 16進数のサイズ表記 + \r\n

  2. データ本体 + \r\n

  3. (サイズ 0 のチャンクが終了の合図)

1a は 16 進数で 26、つまり最初のチャンクが 26 バイトであることを示しています。 1c は 28 バイトです。 クライアントはチャンクごとにサイズを読み取ってデータを受信し、サイズ 0 のチャンクに到達したらボディの受信を完了します。

注釈

Content-LengthTransfer-Encoding: chunked は排他的な関係にあります。 両方を同時に指定することは仕様上認められていません。

  • サイズが事前にわかっていれば → Content-Length

  • わからなければ → Transfer-Encoding: chunked

Django の StreamingHttpResponse や FastAPI の StreamingResponse は、内部的にこの chunked transfer encoding を利用しています。 フレームワークが隠してくれているので普段は意識しませんが、仕組みを知っていれば「なぜストリーミングレスポンスでは Content-Length が付かないのか」という疑問にすぐ答えられます。

2.6.5. 接続の再利用

ここまでの仕組みを踏まえて、HTTP/1.1 の接続がどのように再利用されるかを見てみましょう。

ブラウザが https://example.com/ にアクセスすると、まず TCP コネクションが確立されます。 ブラウザは HTML を取得する GET リクエストを送り、サーバはレスポンスを返します。 レスポンスには Content-Length が含まれているので、ブラウザはボディの終端を正確に把握できます。 Connection: close が含まれていなければ、コネクションは開いたままです。

ブラウザは受け取った HTML を解析し、CSS ファイルへの参照を見つけます。 同じサーバ上のファイルなので、先ほどのコネクションをそのまま再利用して CSS の GET リクエストを送ります。 TCP の3ウェイハンドシェイクも TLS ハンドシェイクも不要です。 サーバは CSS ファイルのレスポンスを返し、またコネクションは開いたまま、続けて JavaScript ファイルのリクエスト、画像ファイルのリクエスト、と同じコネクション上でやりとりが続きます。

重要

この接続再利用の仕組みが成り立つためには、各レスポンスのボディの終端が明確でなければなりません。 Content-Length がなく、Transfer-Encoding: chunked でもなければ、クライアントはひとつのレスポンスがどこで終わるのかを判断できず、次のレスポンスの開始位置もわかりません。 接続の再利用は、ボディの終端を知る仕組みがあって初めて成立するのです。

ただし、HTTP/1.1 の持続的接続にはひとつの制約があります。ひとつのコネクション上では、リクエストとレスポンスが順番に処理されるという点です。 リクエスト A を送った後、レスポンス A が返ってくるまで、リクエスト B を送っても処理されません(仕様上はパイプライニングという仕組みもありますが、実装上の問題が多く、ほとんど使われていません)。 これが Head-of-Line Blocking(先頭行ブロッキング) と呼ばれる問題で、HTTP/2 が解決を目指した課題のひとつです。

コラム: なぜブラウザは複数のコネクションを開くのか

ブラウザはこの制約を回避するために、同じサーバに対して複数の TCP コネクション(通常6本程度)を並行して開きます。 6本のコネクションで同時に6つのリソースを取得するわけです。

本番環境の Nginx のログで、ひとつのクライアントから短時間に複数のコネクションが張られているのを見かけたら、それはこの仕組みによるものです。


keep-alive による持続的接続、Host ヘッダーの必須化、Content-Length によるボディ終端の明示、chunked transfer encoding によるストリーミング、そして接続の再利用の仕組み——これらはすべて、3 章まずは 1 リクエストだけ処理するサーバを作る)で HTTP サーバを自作するときに直面する設計判断です。

次節では、HTTP のテキストが実際に流れる通信路——TCP ソケットの世界に踏み込みます。 Python の socket モジュールを使って、HTTP の下にある層を自分の手で触ってみましょう。

2.7. TCP とソケットの超入門

前節まで、HTTP リクエストとレスポンスが「テキストの塊」であることを見てきました。 しかし、このテキストはどうやってネットワーク上を流れるのでしょうか。 ブラウザが組み立てた HTTP リクエストのテキストは、どのような仕組みでサーバに届くのでしょうか。

その答えが TCP(Transmission Control Protocol)ソケット です。

  • HTTP: テキストの形式を定めた仕様。それ自体には「データをどうやって届けるか」の仕組みは含まれていません。

  • TCP: データの配送を担うプロトコル。

  • ソケット: プログラムから TCP を操作するためのインタフェース。

注釈

本節では、ネットワークプログラミングの経験がなくても理解できるよう、TCP とソケットの基本概念をゼロから説明します。 数学的な厳密さよりも「感覚をつかむ」ことを優先しますので、ネットワークの教科書とは少し説明の角度が異なるかもしれません。

diagram

2.7.1. ポートで待ち受けるとは

あなたの自宅の住所を知っていれば、誰かが手紙を届けることができます。 IP アドレスは、ネットワーク上のコンピュータにおけるこの「住所」に相当します。 93.184.216.34 のような数字の並びが、特定のコンピュータを指し示します。

しかし、ひとつのコンピュータの上では複数のプログラムが同時に動いています。 Web サーバ、メールサーバ、データベースサーバ——これらがすべて同じ IP アドレスを持っています。 手紙が届いたとき、それが家の中のどの人宛てなのかを区別する仕組みが必要です。 この「家の中の誰宛てか」を示すのがポート番号です。

ポート番号は 0 から 65535 までの整数で、よく使われるものは慣習的に決まっています。

ポート番号

用途

80

HTTP

443

HTTPS

8000

開発用 Web サーバ(Django など)

8080

代替 HTTP

22

SSH

ブラウザで http://example.com/ と入力すると、ブラウザは暗黙的にポート 80 に接続します。http://example.com:8080/ のように明示的にポートを指定することもできます。 Django の開発サーバがデフォルトでポート 8000 を使うのは、特権ポート(1024 未満)を避けつつ、慣習的にわかりやすい番号を選んでいるためです。

「ポートで待ち受ける」とは、プログラムが OS に対して「このポート番号宛てに届いた通信は、私に渡してください」と宣言することです。

注意

ひとつのポートを同時に待ち受けられるプログラムは原則としてひとつだけです。 もし Gunicorn がポート 8000 で起動している状態で、別のプロセスもポート 8000 で待ち受けようとすると、「Address already in use」 というエラーが発生します。 開発中にこのエラーに出会ったことがある方は多いのではないでしょうか。

つまり、IP アドレスとポート番号の組み合わせ(たとえば 93.184.216.34:80)が、ネットワーク上の特定のプログラムを一意に指定する「宛先」になるのです。

2.7.2. 接続を受けるとは

ポートで待ち受けている状態のサーバに、クライアントが接続を要求します。 ここで行われるのが、前節でも触れた TCP の3ウェイハンドシェイクです。

3ウェイハンドシェイクの流れは、日常的なやりとりに例えるとわかりやすいでしょう。

クライアント → サーバ :「こんにちは、話しかけていいですか?」(SYN)
サーバ → クライアント :「はい、どうぞ。私も話していいですか?」(SYN+ACK)
クライアント → サーバ :「はい、どうぞ」(ACK)

この3回のやりとり(SYN、SYN+ACK、ACK)を経て、クライアントとサーバの間にTCP コネクションが確立されます。 コネクションが確立されると、両者の間に信頼性のある双方向の通信路が開かれます。

Tip

「信頼性のある」というのは、次の3つを TCP が保証してくれるという意味です。

  1. 送ったデータが相手に届くこと

  2. データが送った順番どおりに届くこと

  3. データが途中で壊れていないこと

途中でデータが失われた場合は、TCP が自動的に再送してくれます。

この信頼性があるからこそ、HTTP はテキストを送りっぱなしにできます。「リクエストラインの次にヘッダーが来て、空行の後にボディが来る」という構造は、データが順番どおりに届くことが保証されていなければ成り立ちません。

サーバ側のプログラムから見ると、「接続を受ける」とは、3ウェイハンドシェイクが完了したコネクションを OS から受け取る操作です。 OS はハンドシェイクの処理を自動的に行い、完了したコネクションをキューに溜めてくれます。 サーバプログラムはそのキューからコネクションをひとつ取り出して、クライアントとの通信を開始します。

2.7.3. バイト列を読むとは

TCP コネクションが確立されると、クライアントとサーバはデータを送受信できるようになります。 ここで重要なのは、TCP が扱うのはバイト列(bytes)であり、テキストではないという点です。

クライアントが HTTP リクエストを送信するとき、実際に TCP コネクションに流れるのは GET /users/42/ HTTP/1.1\r\nHost: example.com\r\n\r\n という文字列を UTF-8(あるいは ASCII)でエンコードしたバイト列です。 サーバがこれを受信するとき、TCP から受け取るのもバイト列です。 このバイト列を文字列にデコードし、HTTP の仕様に従ってパース(解析)するのは、サーバ側のプログラムの仕事です。

警告

TCP はメッセージの境界を保持しません。

クライアントが 500 バイトのデータを1回で送ったとしても、サーバ側で1回の受信操作で 500 バイトすべてが届くとは限りません。ネットワークの状況や OS のバッファリングによって、最初の受信で 200 バイトだけ届き、次の受信で残りの 300 バイトが届く、ということが起こりえます。

逆に、クライアントが 100 バイトを2回に分けて送ったデータが、サーバ側では1回の受信で 200 バイトまとめて届くこともあります。

TCP は「バイト列のストリーム(流れ)」を提供するのであって、「ここからここまでがひとまとまりのメッセージです」という区切りは提供しません。

HTTP の場合、メッセージの区切りを判断するのは HTTP の仕様の役割です。 ヘッダーの終わりは \r\n\r\n(空行)で判断し、ボディの終わりは Content-LengthTransfer-Encoding: chunked で判断する——前節で学んだこれらの仕組みは、まさに TCP のこの性質を補うために HTTP が用意したものなのです。

2.7.4. ソケット API のイメージ

ここまでの概念を、プログラムからどう扱うかを見てみましょう。 OS が提供する TCP 通信のインタフェースがソケット API であり、Python では socket モジュールとして利用できます。

ソケットは、ネットワーク通信の「端点」を抽象化したオブジェクトです。 電話に例えると、ソケットは電話機に相当します。 電話機を手に取り、相手の番号をダイヤルし、つながったら話し、終わったら受話器を置く。 ソケットも同じ流れです。

サーバ側のソケット操作の流れは、次のようになります。

import socket

# 1. ソケットを作る(電話機を用意する)
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

# 2. アドレスとポートに紐づける(電話番号を取得する)
server.bind(("127.0.0.1", 8000))

# 3. 待ち受けを開始する(電話が鳴るのを待つ)
server.listen()

# 4. 接続を受け入れる(電話を取る)
client_conn, client_addr = server.accept()

# 5. データを受信する(相手の話を聞く)
data = client_conn.recv(4096)

# 6. データを送信する(こちらから話す)
client_conn.sendall(b"HTTP/1.1 200 OK\r\n\r\nHello")

# 7. 接続を閉じる(電話を切る)
client_conn.close()
server.close()

このコードはまだ動かさなくて構いません。 次節で実際に動かします。 ここでは各操作の意味を把握してください。

操作

意味

socket.socket()

ソケットオブジェクトを作成。AF_INET は IPv4、SOCK_STREAM は TCP を意味します

bind()

ソケットを特定の IP アドレスとポートに紐づけます

listen()

待ち受け状態に入ります

accept()

クライアントからの接続を待ちます(接続が来るまでブロック)

recv(4096)

接続先からデータを最大 4096 バイト受信します

sendall()

データを送信します(すべて送り終えるまで繰り返します)

close()

ソケットを閉じ、通信を終了します

"127.0.0.1" はローカルホスト(自分自身)を指す特別な IP アドレスです。

注釈

accept() は接続が来るまでプログラムをブロック(停止)します。 接続が来ると、そのクライアントとの通信に使う新しいソケット(client_conn)と、クライアントのアドレス情報(client_addr)が返されます。 元の server ソケットは引き続き新しい接続を待ち受けるために使えます。

また、recv() が返すのも sendall() に渡すのも、どちらも bytes 型(バイト列)です。 Python の文字列(str 型)ではありません。 b"HTTP/1.1 200 OK\r\n\r\nHello" の先頭にある b は、これがバイトリテラルであることを示しています。 受信したバイト列を文字列として解釈したければ data.decode("utf-8") のように明示的にデコードする必要があります。


TCP とソケットの基本概念を整理すると、IP アドレスとポート番号でプログラムを特定し、3ウェイハンドシェイクで接続を確立し、バイト列のストリームとしてデータを送受信するという流れになります。ソケット API はこの一連の操作を bind()listen()accept()recv()sendall()close() という関数として提供しています。

次節では、このソケット API を使って、実際に HTTP リクエストを送受信するコードを書いて動かします。 ここまでの知識が、手を動かすことで一気に実感に変わるはずです。

2.8. Python から見る「受信」と「送信」

前節では、TCP とソケットの概念をイメージとして把握しました。 本節では、Python の socket モジュールを実際に動かしながら、各操作の挙動を確かめていきます。

Tip

コードを読むだけでなく、ぜひ手元で実行してみてください。 ターミナルを2つ開いて、一方でサーバを起動し、もう一方からクライアントとして接続する——この体験が、これまでに学んだ概念を身体的な理解に変えてくれます。

diagram

2.8.1. socket.bind

ソケットの最初のステップは、ソケットオブジェクトを作成し、特定のアドレスとポートに紐づけることです。

import socket

server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(("127.0.0.1", 8000))
print("Bound to 127.0.0.1:8000")

socket.socket() の第1引数 AF_INET は IPv4 を使うことを、第2引数 SOCK_STREAM は TCP(ストリーム型の通信)を使うことを指定しています。 UDP を使う場合は SOCK_DGRAM を指定しますが、HTTP は TCP の上に成り立っているので、本書では常に SOCK_STREAM です。

Tip

setsockopt() の行は、開発中に頻繁に遭遇する問題を回避するためのものです。 サーバを停止して即座に再起動すると、OS がまだポートを「使用中」とみなしていて Address already in use エラーが出ることがあります。 SO_REUSEADDR を設定すると、OS はそのポートの即時再利用を許可してくれます。 実験を繰り返す開発中には、この1行があるとないとで快適さが大きく変わります。

bind() にはタプルを渡します。("127.0.0.1", 8000) は「ローカルホストのポート 8000」を意味します。 "127.0.0.1" の代わりに "0.0.0.0" を指定すると、そのマシンが持つすべてのネットワークインタフェースでの接続を受け付けるようになります。 開発中は "127.0.0.1"(自分自身からの接続のみ)で十分です。

bind() が成功した時点では、まだ接続を受け付ける準備はできていません。 ポートを「予約した」だけの状態です。次のステップで、実際に接続を待ち受ける姿勢に入ります。

2.8.2. listen

server.listen(5)
print("Listening...")

listen() を呼ぶと、ソケットはパッシブソケット(接続を待ち受けるソケット)に変わります。 この時点から、OS はこのポート宛ての接続要求(SYN パケット)を受け付け、3ウェイハンドシェイクを自動的に処理し始めます。

引数の 5 は、バックログと呼ばれる値です。 これは「OS が保持できる、完了済みだがまだプログラムに引き渡されていない接続のキューの長さ」を示します。 サーバプログラムが接続の処理に忙しくて accept() を呼ぶのが追いつかないとき、OS はこのキューに接続を溜めておいてくれます。 キューが一杯になると、新しい接続要求は拒否されます。

注釈

本書の学習用コードでは 5 という小さな値で十分ですが、本番環境の Gunicorn や Uvicorn はこの値をもっと大きく設定しています。 大量のリクエストが同時に押し寄せる環境では、バックログが小さいと接続が拒否されてしまうからです。

listen() を呼んだ後、プログラムの制御はすぐに返ってきます。 listen() 自体はブロックしません。「待ち受ける姿勢に入った」だけで、実際にクライアントの接続を待つのは次の accept() です。

2.8.3. accept

client_conn, client_addr = server.accept()
print(f"Connection from {client_addr}")

accept() は、クライアントからの接続を実際に待ち受ける操作です。 ここでプログラムはブロックします。つまり、クライアントが接続してくるまで、この行で処理が止まったままになります。

接続が来ると、accept() は2つの値をタプルで返します。

戻り値

内容

client_conn

そのクライアントとの通信に使う新しいソケットオブジェクト

client_addr

接続してきたクライアントのアドレス情報(例: ("127.0.0.1", 54321)

server ソケットと client_conn ソケットは別物

元の server ソケットと accept() が返した client_conn ソケットは別物です。 server ソケットは引き続き新しい接続を待ち受ける役割を持ち、client_conn ソケットはこの特定のクライアントとの会話に使います。

電話の受付窓口に例えると、server が代表電話、client_conn がそのお客さん専用に割り当てた内線、のようなものです。

client_addr に含まれるクライアント側のポート番号(上の例の 54321)は、OS が自動的に割り当てた一時的なポート(エフェメラルポート)です。 クライアントは接続のたびに異なるポート番号を使うため、サーバは client_addr を見ることで「どの接続からのデータか」を区別できます。

この動作を実際に確認してみましょう。以下のコードをファイルに保存して実行します。

# accept_demo.py
import socket

server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(("127.0.0.1", 8000))
server.listen(5)
print("Waiting for connection on 127.0.0.1:8000 ...")

client_conn, client_addr = server.accept()
print(f"Connected by {client_addr}")

client_conn.close()
server.close()
print("Done.")

ターミナルでこのスクリプトを実行すると、Waiting for connection... と表示されたまま止まります。 別のターミナルを開いて、次のコマンドを実行してください。

curl http://127.0.0.1:8000/

すると、サーバ側のターミナルに Connected by ('127.0.0.1', XXXXX) と表示されるはずです。 curl 側はレスポンスを受け取れないのでエラーになりますが、TCP 接続が確立されたことは確認できます。

2.8.4. recv

接続が確立されたら、クライアントが送ってきたデータを受信します。

data = client_conn.recv(4096)
print(f"Received {len(data)} bytes")
print(data.decode("utf-8"))

recv() は、クライアントからデータが届くまでブロックし、届いたデータをバイト列(bytes 型)として返します。 引数の 4096 は「一度に最大 4096 バイトまで読み取る」という意味です。 この値はバッファサイズであり、実際に届くデータが 4096 バイト未満であれば、届いた分だけが返されます。

先ほどの accept_demo.py を拡張して、curl が送ってくる HTTP リクエストの中身を見てみましょう。

# recv_demo.py
import socket

server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(("127.0.0.1", 8000))
server.listen(5)
print("Waiting for connection on 127.0.0.1:8000 ...")

client_conn, client_addr = server.accept()
print(f"Connected by {client_addr}")

data = client_conn.recv(4096)
print("--- Received ---")
print(data.decode("utf-8"))
print("--- End ---")

client_conn.close()
server.close()

このスクリプトを実行し、別のターミナルから curl http://127.0.0.1:8000/hello を叩くと、サーバ側に次のような出力が表示されます。

--- Received ---
GET /hello HTTP/1.1
Host: 127.0.0.1:8000
User-Agent: curl/8.7.1
Accept: */*

--- End ---

これが、curl が送ってきた HTTP リクエストの生のテキストです。 前節で学んだ構造そのままの姿が、バイト列として TCP ソケットから届いています。 リクエストライン、ヘッダー、空行——すべてが \r\n で区切られたテキストとして、recv() の戻り値に入っているのです。

注意

前節で説明したとおり、recv() が1回の呼び出しでリクエスト全体を返す保証はありません。 今回のような小さなリクエストではほぼ1回で届きますが、大きなリクエスト(ファイルアップロードなど)では複数回の recv() が必要になります。 3 章まずは 1 リクエストだけ処理するサーバを作る)で HTTP サーバを自作する際に、この問題に正面から取り組みます。

2.8.5. send

データを受信したら、レスポンスを送り返します。

response = (
    "HTTP/1.1 200 OK\r\n"
    "Content-Type: text/plain\r\n"
    "Content-Length: 13\r\n"
    "\r\n"
    "Hello, World!"
)
client_conn.sendall(response.encode("utf-8"))

sendall() は、渡されたバイト列をすべて送信するまで内部で繰り返し送信を試みるメソッドです。 send() という別のメソッドもありますが、こちらは渡されたバイト列の一部しか送信できない場合があり、戻り値で実際に送信されたバイト数を確認して、残りを自分で再送する必要があります。 学習用途では sendall() を使うのが安全です。

response.encode("utf-8") で文字列をバイト列に変換している点に注目してください。 ソケットはバイト列しか送受信できません。recv() がバイト列を返し、sendall() がバイト列を受け取るという対称性を意識しておくと、コードの見通しがよくなります。

ここまでの内容をまとめた、最小限の動作するサーバを見てみましょう。

# minimal_server.py
import socket

server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(("127.0.0.1", 8000))
server.listen(5)
print("Serving on 127.0.0.1:8000 ...")

client_conn, client_addr = server.accept()
data = client_conn.recv(4096)
print(data.decode("utf-8"))

body = "Hello from raw socket!"
response = (
    "HTTP/1.1 200 OK\r\n"
    f"Content-Type: text/plain\r\n"
    f"Content-Length: {len(body.encode('utf-8'))}\r\n"
    "\r\n"
    f"{body}"
)
client_conn.sendall(response.encode("utf-8"))
client_conn.close()
server.close()

このスクリプトを実行し、別のターミナルから curl http://127.0.0.1:8000/ を叩くと、Hello from raw socket! というレスポンスが返ってきます。 ブラウザで http://127.0.0.1:8000/ を開いても同じ文字列が表示されます。

重要

たった20行ほどのコードですが、ここには Web サーバの本質がすべて含まれています。 ポートで待ち受け、接続を受け入れ、クライアントが送ってきたバイト列を HTTP リクエストとして読み取り、HTTP レスポンスのバイト列を組み立てて送り返す。 Gunicorn も Uvicorn も Nginx も、煎じ詰めればこの操作を高速に、堅牢に、大量に並行して行っているに過ぎません。

Content-Length の計算で len(body.encode('utf-8')) としている点にも注目してください。 前節で説明したとおり、Content-Length はバイト数を示す必要があります。 len(body) では文字数になってしまうため、エンコード後のバイト列の長さを計算しています。


Python の socket モジュールを使ってここまで確認してきた通り、bind() でポートを予約し、listen() で待ち受け姿勢に入り、accept() で接続を受け入れ、recv() でバイト列を受信し、sendall() でバイト列を送信する——この一連の操作が、Web サーバの最も根源的な動作です。

次節では、このコードに潜む「たまたまうまくいっている」部分を掘り下げます。HTTP はテキストに見えますが、TCP ソケットから届くデータはバイト列の流れであり、メッセージの境界は自分で判断しなければなりません。

2.9. テキストに見えるが、境界を自分で管理する必要がある

前節で、ソケットを使って HTTP リクエストを受信し、レスポンスを返すコードを動かしました。 驚くほどシンプルに見えたかもしれません。 recv() で受け取って sendall() で返すだけ。 しかし、前節のコードにはいくつもの「たまたまうまくいっている」部分が隠れています。

HTTP はテキストベースのプロトコルであり、人間の目で読めます。 この可読性は理解の助けになる一方で、ある危険な錯覚を生みます。 「テキストなのだから、文字列処理のように扱えるだろう」という錯覚です。 実際には、TCP ソケットから届くデータは構造を持たないバイト列の流れであり、HTTP メッセージの境界は自分で判断しなければなりません。

本節では、この「境界の管理」にまつわる4つの問題を掘り下げます。 3 章まずは 1 リクエストだけ処理するサーバを作る)で HTTP サーバを自作するとき、これらの問題すべてに対処するコードを書くことになります。

diagram

2.9.1. 改行の扱い

HTTP の仕様(RFC 9112)は、行の区切りとして \r\n(CR+LF、キャリッジリターン+ラインフィード)を使うことを定めています。

GET /users/42/ HTTP/1.1\r\n
Host: example.com\r\n
Accept: text/html\r\n
\r\n

Unix 系の OS(Linux、macOS)では、テキストファイルの改行は \n(LF のみ)が一般的です。 Python の print()\n を使います。 しかし、HTTP では \n だけでは不十分で、\r\n でなければなりません。

この違いは、自分で HTTP レスポンスを組み立てるときに直接影響します。 前節のコードで "HTTP/1.1 200 OK\r\n" と書いていたのは、この仕様に従うためです。

警告

うっかり \n だけで書いてしまうとどうなるでしょうか。 多くのブラウザや HTTP クライアントは寛容に解釈してくれるため、一見動いてしまいます。 しかし、厳密な HTTP パーサーを持つクライアントやプロキシでは、不正なレスポンスとして拒否される可能性があります。

受信側でも同様の注意が必要です。 HTTP リクエストをパースするとき、ヘッダーの各行を分割するために Python の split("\n") を使いたくなりますが、正しくは split("\r\n") です。 \r が残ったままヘッダーの値を処理すると、"text/html\r" のように末尾にキャリッジリターンが紛れ込み、比較や辞書のキーとして使ったときに一致しない、という見つけにくいバグを生みます。

コラム: なぜ HTTP は \r\n を使うのか

HTTP が \r\n を使うのは、歴史的な経緯によるものです。 初期のインターネットプロトコル(SMTP、FTPなど)が Telnet の改行規則(CR+LF)に従っていたため、同じ時代に生まれた HTTP もそれを踏襲しました。 理由はどうあれ、仕様は仕様です。実装する際は必ず \r\n を使いましょう。

2.9.2. ヘッダー終端

HTTP メッセージの中で最も重要な境界のひとつが、ヘッダーの終わりです。ヘッダーとボディの境界は、空行——つまり \r\n\r\n というバイト列——によって示されます。

GET /hello HTTP/1.1\r\n     ← リクエストライン
Host: example.com\r\n       ← ヘッダー
Accept: text/html\r\n       ← ヘッダー
\r\n                         ← 空行(ヘッダーの終わり)
(ボディがあればここに続く)

各ヘッダー行の末尾に \r\n があり、ヘッダーの最後の行の \r\n に続けて、もうひとつ \r\n が来ます。つまり \r\n\r\n という4バイトの並びが、ヘッダーの終端を示す合図です。

サーバが HTTP リクエストを処理するとき、まず最初にやるべきことは、この \r\n\r\n を見つけることです。 \r\n\r\n が見つかるまでは、ヘッダーがまだ完全に届いていない可能性があります。 見つかった時点で初めて、リクエストラインとすべてのヘッダーを安全にパースできます。

Python のコードで書くと、こんなイメージです。

buffer = b""
while b"\r\n\r\n" not in buffer:
    chunk = client_conn.recv(4096)
    if not chunk:
        break  # クライアントが接続を閉じた
    buffer += chunk

header_part, _, body_part = buffer.partition(b"\r\n\r\n")

partition() は、指定したセパレータで文字列(バイト列)を3つに分割するメソッドです。 セパレータより前の部分、セパレータ自身、セパレータより後の部分がタプルで返されます。 \r\n\r\n より前がヘッダー全体、後がボディの先頭部分(もしあれば)です。

注意

body_part に注意してください。 recv() で受信したデータに、ヘッダーの終端だけでなくボディの一部も含まれている可能性があります。 ヘッダーの終端を見つけた後に「ボディを受信しよう」と recv() を呼び直すと、すでに buffer に入っているボディの先頭部分を取りこぼしてしまいます。 バッファに溜まったデータを慎重に扱う必要がある点が、テキストファイルの処理とは決定的に異なるところです。

2.9.3. ボディ長の判断

ヘッダーの終端がわかったら、次はボディをどれだけ読めばよいかを判断しなければなりません。 前節で説明した Content-LengthTransfer-Encoding: chunked の知識が、ここで実装上の問題として立ちはだかります。

Content-Length がヘッダーに含まれている場合は比較的単純です。 指定されたバイト数だけボディを読み取ればよいのです。

# ヘッダーから Content-Length を抽出する
content_length = 0
for line in header_part.decode("utf-8").split("\r\n"):
    if line.lower().startswith("content-length:"):
        content_length = int(line.split(":", 1)[1].strip())
        break

# すでに buffer に入っているボディ部分を考慮する
body = body_part
while len(body) < content_length:
    chunk = client_conn.recv(4096)
    if not chunk:
        break
    body += chunk

body_part(ヘッダー終端の \r\n\r\n より後にすでに受信済みのデータ)の長さと content_length を比較し、足りなければ追加で recv() を呼ぶ。これがボディ受信の基本パターンです。

Content-Length がない場合はどうでしょうか。

  • GET リクエストであれば、通常ボディはないので問題ありません。

  • POST や PUT でボディがあるのに Content-LengthTransfer-Encoding: chunked もない場合、サーバはボディの終端を知る方法がありません。HTTP/1.1 の仕様では、このようなリクエストに対してサーバは 400 Bad Request を返すことが推奨されています。

Transfer-Encoding: chunked の場合は、各チャンクのサイズを16進数で読み取り、そのバイト数だけデータを読み、サイズ 0 のチャンクに到達したら終了、という処理になります。 実装は Content-Length の場合より複雑ですが、原理は同じです。「あと何バイト読めばよいかを、プロトコルの仕様に従って自分で判断する」のです。

注釈

フレームワークを使っているとき、request.bodyrequest.json() と書くだけでボディが取得できるのは、フレームワーク(あるいはその下のサーバ)がこの面倒な処理をすべて引き受けてくれているからです。

2.9.4. 部分受信の問題

ここまでの話を踏まえて、前節の minimal_server.py を振り返ってみましょう。

data = client_conn.recv(4096)

この1行で、HTTP リクエスト全体を受信できている前提でコードを書いていました。 curl から短い GET リクエストを送る程度であれば、実際にこれで問題なく動きます。 リクエスト全体が数百バイト程度であり、TCP のバッファに収まるため、1回の recv() でまとめて届くからです。

しかし、これが「たまたまうまくいっている」に過ぎないことは、少し考えればわかります。

クライアントが大きなファイルを POST でアップロードしてきた場合を想像してください。 リクエストのボディが 1MB あるとします。 recv(4096) は最大 4096 バイトしか読み取らないので、1回の呼び出しではリクエストの 0.4% しか読めません。 残りの 99.6% は、まだ TCP のバッファに残っているか、ネットワーク上を流れている途中です。

あるいは、もっと微妙な問題もあります。 ヘッダーの途中で recv() が返ってくるケースです。 ネットワークが混雑している状況や、クライアントが低速な回線で接続している状況では、リクエストラインとヘッダーの最初の数行だけが届き、残りのヘッダーがまだ届いていない、ということが起こりえます。 この状態で \r\n\r\n を探しても見つからないため、ヘッダーのパースが正しく行えません。

警告

前節の minimal_server.py が本番環境で使えない根本的な理由はここにあります。 コードが短くてわかりやすいのは、次の暗黙の前提に甘えているからです。

  • 「1回の recv() ですべて届く」

  • 「リクエストは小さい」

  • 「エラーは起きない」

本番品質のサーバでは、次の処理がすべて必要になります。

  1. ヘッダーの終端 \r\n\r\n が見つかるまで繰り返し recv() する

  2. Content-Length に基づいてボディを過不足なく読み取る

  3. クライアントが途中で接続を切った場合(recv() が空のバイト列を返す場合)に適切に処理する

  4. 一定時間データが届かない場合にタイムアウトする

これらの処理を正しく実装することが、「動く HTTP サーバ」と「壊れない HTTP サーバ」の違いです。

Gunicorn や Uvicorn のソースコードが単純な recv() の呼び出しではなく、バッファ管理とループで構成されているのは、まさにこの問題に対処しているからです。


本節で伝えたかったのは、HTTP が「テキストに見える」ことと「テキストのように扱える」ことは別物だ、ということです。 HTTP メッセージの境界——行の区切り、ヘッダーの終端、ボディの終端——はすべて、受信側が自分でバイト列の中から見つけ出さなければなりません。 TCP は順序を保証してくれますが、メッセージの区切りは教えてくれないのです。

3 章まずは 1 リクエストだけ処理するサーバを作る)では、ここで議論した問題に実際に対処しながら、複数のリクエストを処理できる HTTP サーバを組み立てていきます。 改行の扱い、ヘッダー終端の検出、Content-Length に基づくボディの受信——これらすべてを Python のコードとして実装します。

2.10. フレームワーク利用時に見えなくなるもの

本章では、HTTP リクエストとレスポンスの構造、TCP ソケットの基本操作、そしてバイト列の境界管理という、Web 通信の根幹を見てきました。 ソケットから届くのは構造を持たないバイトの流れであり、そこから改行を見つけ、ヘッダーの終端を検出し、ボディの長さを判断する——この作業を自分でやらなければならないことを確認しました。

本節では視点を変えて、普段使っているフレームワークがこの「面倒な作業」をどれほど引き受けてくれているかを振り返ります。 フレームワークの便利さを否定するためではありません。 便利さの正体を知ることで、その便利さをより確かな足場として使えるようになるためです。

diagram

2.10.1. なぜ request オブジェクトが便利なのか

Django のビュー関数を書くとき、引数として渡される request オブジェクトはあまりにも自然に存在しています。

def user_detail(request, user_id):
    if request.method == "GET":
        ...
    name = request.GET.get("name", "")
    host = request.headers["Host"]

request.method にはメソッドが文字列として、request.GET にはクエリパラメータが辞書ライクなオブジェクトとして、request.headers にはヘッダーがキーでアクセスできる形で入っています。あまりにも当然のように使っていますが、本章を通じて学んだ知識があれば、この「当然」の裏で何が起きているかが想像できるはずです。

TCP ソケットから届いたのは、こんなバイト列でした。

GET /users/42/?name=Taro HTTP/1.1\r\nHost: example.com\r\nAccept: text/html\r\n\r\n

この一本のバイト列から request オブジェクトを組み立てるために、フレームワーク(とその下のサーバ)は次の処理を行っています。

  1. \r\n\r\n を見つけてヘッダー部分を切り出す

  2. リクエストラインを空白で分割してメソッド(GET)、パス(/users/42/?name=Taro)、HTTP バージョン(HTTP/1.1)を得る

  3. パスの ? 以降をクエリ文字列として分離し、&= でキーと値に分けて URL デコードを施して辞書に格納する

  4. ヘッダーの各行をコロンで分割してフィールド名を正規化(大文字・小文字の統一など)して辞書に格納する

FastAPI でも同様です。

@app.get("/users/{user_id}")
async def user_detail(user_id: int, name: str = ""):
    ...

パスパラメータ user_idint として渡され、クエリパラメータ namestr として渡されます。 この裏では、パスのパターンマッチングによる値の抽出、型変換、デフォルト値の適用、バリデーションが行われています。

注釈

request オブジェクトの便利さは、生のバイト列を構造化されたデータに変換する作業を完全に隠してくれているところにあります。 開発者は「\r\n で行を分割して……」などと考える必要がなく、request.method と書くだけで済みます。

しかし、この便利さにはトレードオフがあります。 変換の過程が見えないため、変換が期待どおりに行われなかったとき、何が起きているかわからなくなるのです。

たとえば、こんなトラブルがあります。

  • request.headers["Content-Type"]None を返した場合——原因は、ヘッダーのフィールド名が content-type と小文字で送られてきているのに、フレームワークが大文字・小文字を区別するかどうかを知らなかったから、かもしれません(HTTP の仕様ではヘッダーのフィールド名は大文字・小文字を区別しません。Django の request.headers はこれに対応していますが、すべてのフレームワークが同じとは限りません)。

  • クエリパラメータに日本語を含む URL を送ったとき、文字化けが起きた場合——これは URL エンコーディング(パーセントエンコーディング)の扱いに関する問題で、生のバイト列がどう変換されているかを知っていれば、原因の特定は容易です。

重要

request オブジェクトは抽象化です。 抽象化は複雑さを隠してくれますが、隠された層に問題があるとき、その抽象化の向こう側を見る力が必要になります。 本章で学んだ「生の HTTP リクエストの姿」を知っていることが、まさにその力の土台です。

2.10.2. なぜ body parser が必要なのか

HTTP リクエストのボディを扱う場面で、フレームワークの恩恵はさらに大きくなります。 同時に、隠されているものもさらに多くなります。

Django のビュー関数で JSON を受け取るコードを見てみましょう。

import json

def create_user(request):
    data = json.loads(request.body)
    name = data["name"]
    email = data["email"]
    ...

request.body には、リクエストのボディがバイト列として格納されています。 json.loads() でそれを Python の辞書に変換しています。 シンプルに見えますが、request.body が正しいバイト列を保持しているためには、次の前提条件がすべて満たされている必要があります。

  1. ソケットから届いたバイト列の中から \r\n\r\n を見つけてヘッダーとボディの境界を特定する

  2. Content-Length ヘッダーの値を読み取ってその長さだけボディを正確に受信する

  3. 部分受信が発生しても必要なバイト数がすべて揃うまで繰り返し読み取る

これらが揃って初めて、request.body に正しいデータが入ります。 これらの処理は、Django のリクエストハンドラーと、その下の WSGI サーバ(Gunicorn など)が担当しています。

FastAPI の場合は、ボディの扱いがさらに高度になります。

from pydantic import BaseModel

class UserCreate(BaseModel):
    name: str
    email: str

@app.post("/users/")
async def create_user(user: UserCreate):
    print(user.name, user.email)
    ...

引数に UserCreate 型を指定するだけで、リクエストボディの JSON が自動的にパースされ、型バリデーションが行われ、Pydantic モデルのインスタンスとして渡されます。 name が文字列でなかったり、email フィールドが欠落していたりすると、FastAPI は自動的に 422 Unprocessable Entity レスポンスを返します。

この「自動」の裏側では、次の処理が順に行われています。

  1. ASGI サーバ(Uvicorn)が TCP ソケットからバイト列を受信して HTTP リクエストをパースし、ASGI の receive callable を通じてボディのバイト列をフレームワークに渡す

  2. Starlette がそれを受け取り Content-Type ヘッダーを確認して application/json であることを検証する

  3. ボディのバイト列を UTF-8 としてデコードして JSON パーサーに渡す

  4. パース結果の辞書を Pydantic モデルのコンストラクタに渡して各フィールドの型チェックと変換を行う

  5. すべてが成功して初めて、ビュー関数の user 引数に値が届く

Content-Type ヘッダーの重要性

Content-Type の問題は実務では頻繁に遭遇します。 クライアントが JSON を送っているのに Content-Type: application/json ヘッダーを付け忘れた場合、フレームワークによってはボディを JSON として解釈せず、空の辞書を返したり、エラーにしたりします。 ボディのバイト列自体は正しい JSON なのに、ヘッダーが足りないせいでパースされない。

この問題は、HTTP リクエストの構造——ヘッダーとボディが独立した要素であり、Content-Type ヘッダーがボディの解釈方法をサーバに伝える役割を持っている——を理解していれば、すぐに原因がわかります。


本章では、HTTP と TCP ソケットの世界を、フレームワークの助けを借りずに見てきました。HTTP リクエストはテキスト形式のプロトコルであり、そのテキストは TCP ソケットを通じてバイト列として流れ、メッセージの境界は自分で管理しなければなりません。そして、フレームワークはこれらの面倒な作業をすべて引き受けてくれています。

重要

フレームワークの便利さは、こうした「面倒さ」を知って初めて、本当の意味で理解できます。 何が隠されているかを知っている開発者は、フレームワークが提供する抽象に自信を持って乗ることができます。 そして、抽象が破綻したときに、その下に潜って原因を突き止めることができます。

次の3 章まずは 1 リクエストだけ処理するサーバを作る)では、ここで学んだ知識を総動員して、ソケットだけで動く HTTP サーバを自分の手で組み立てます。 改行の扱い、ヘッダー終端の検出、Content-Length に基づくボディの受信、ルーティング、複数リクエストの処理——すべてを自分で実装することで、「フレームワークが隠してくれているもの」の正体を体感してください。

2.11. 現場で起きる問題

本章では HTTP と TCP ソケットの基礎を学び、前節ではフレームワークが隠してくれているものの正体を確認しました。 これらの知識を踏まえて、本番環境で実際に遭遇する問題をいくつか見ていきましょう。

注釈

ここで紹介する問題は、いずれも「HTTP とソケットの仕組みを理解していれば原因が推測できるが、理解していなければまったく見当がつかない」というタイプのものです。 3 章まずは 1 リクエストだけ処理するサーバを作る)で HTTP サーバを自作する前に、「なぜ本番品質のサーバには多くの防御的なコードが必要なのか」を実感しておいてください。

diagram

2.11.1. 巨大リクエスト

あなたの Web アプリケーションにファイルアップロード機能があるとします。 ユーザーがプロフィール画像をアップロードする想定で、数 MB の画像を受け付ける設計です。ある日、誰かが 10GB のファイルを POST してきたらどうなるでしょうか。

サーバが Content-Length の値を確認せずにボディをすべて読み取ろうとすると、メモリにボディ全体を展開することになります。 10GB のデータをメモリに載せれば、サーバプロセスはメモリを食い尽くし、OS に kill されるか、他のリクエストの処理に影響を及ぼします。 悪意のある攻撃者でなくても、ユーザーが誤って巨大なファイルを選択してしまうだけで、この問題は発生します。

これが、本番環境のサーバやフレームワークがリクエストサイズの上限を設けている理由です。

ツール

設定項目

デフォルト値

Nginx

client_max_body_size

1MB

Django

DATA_UPLOAD_MAX_MEMORY_SIZE

約 2.5MB

FastAPI (Starlette)

リクエストボディサイズ制限

設定による

Tip

Nginx をリバースプロキシとして使っている環境では、client_max_body_size の制限に引っかかると Nginx が 413 Request Entity Too Large を返します。このとき、アプリケーションサーバにはリクエストが届いていません。 アプリケーションのログには何も記録されないため、「なぜ 413 が返るのかわからない」と悩むことになります。 1-4 で議論した「どの層で問題が起きているか」の切り分けが、ここでも重要になります。

ヘッダーにも同様の問題があります。 リクエストラインやヘッダーフィールドが異常に長い場合、サーバはそれを際限なく読み続けるべきではありません。 Nginx はリクエストラインとヘッダーの合計サイズに上限を設けており(large_client_header_buffers で設定)、超過すると 414 URI Too Long400 Bad Request を返します。

2.11.2. ヘッダー不正

HTTP リクエストのヘッダーが仕様に従っていない場合、何が起きるでしょうか。

たとえば、Content-Length の値が数値ではなく文字列だった場合。 Content-Length: abc というヘッダーを受け取ったサーバは、ボディの長さを判断できません。 これを int() で変換しようとすれば ValueError が発生します。 適切にハンドリングしなければ、500 エラーとしてクライアントに露出してしまいます。

もっと厄介なのは、ヘッダーの値が「一見正しいが実は不正」なケースです。 Content-Length: 100 と宣言しているのに、実際のボディが 50 バイトしかない場合。 サーバは残りの 50 バイトを待ち続け、最終的にタイムアウトするか、次のリクエストのデータをボディの一部として誤って読み込んでしまいます。 後者は特に危険で、持続的接続(keep-alive)の上で起きると、リクエストの境界がずれて以降のすべての通信が破綻します。

危険

HTTP Request Smuggling(HTTP リクエストスマグリング) と呼ばれる攻撃は、まさにこの問題を悪用するものです。 リバースプロキシとアプリケーションサーバで Content-LengthTransfer-Encoding の解釈が微妙に異なる場合、攻撃者は巧妙に細工したリクエストを送ることで、リバースプロキシには1つのリクエストに見せかけつつ、アプリケーションサーバには2つのリクエストとして解釈させることができます。 本書ではこの攻撃の詳細には踏み込みませんが、「ヘッダーのパースがセキュリティに直結する」という事実は覚えておいてください。

フレームワークが提供する request オブジェクトは、こうした不正なヘッダーをある程度フィルタリングしてくれます。 しかし、フィルタリングの範囲と方法はフレームワークやサーバによって異なります。 何が防がれていて何が防がれていないかを知るには、結局、HTTP ヘッダーの構造そのものを理解している必要があります。

2.11.3. タイムアウト

1-4 で「タイムアウトは複数の層にそれぞれ存在する」と説明しましたが、HTTP とソケットの知識を得た今、その仕組みがより具体的に見えるはずです。

TCP コネクションが確立された後、クライアントが HTTP リクエストを送ってこない場合を考えてみましょう。 accept() は成功し、コネクションは確立されています。 しかし、recv() を呼んでも何もデータが届きません。サーバはいつまで待てばよいのでしょうか。

警告

無期限に待ち続けるサーバを書いてしまうと、何も送ってこないクライアントがコネクションを張るだけで、サーバのリソース(ソケット、メモリ、プロセスまたはスレッド)を占有し続けることになります。 悪意がなくても、ネットワーク障害やクライアント側のクラッシュで、この状態は発生します。

これを意図的に行う攻撃が Slowloris と呼ばれるもので、大量のコネクションを開いてリクエストをゆっくり(あるいはまったく)送らないことで、サーバのリソースを枯渇させます。

ソケットレベルでは、socket.settimeout() でタイムアウトを設定できます。

client_conn.settimeout(30)  # 30秒でタイムアウト
try:
    data = client_conn.recv(4096)
except socket.timeout:
    # タイムアウト処理
    client_conn.close()

各層のタイムアウト設定を比較すると、次のとおりです。

ツール

設定項目

動作

Python ソケット

settimeout(秒)

指定秒数内にデータが来なければ timeout 例外

Gunicorn

--timeout

ワーカーが一定時間内に処理を完了しなければ強制終了

Nginx

proxy_read_timeout

バックエンドサーバからの応答を待つ最大時間

各層が独自のタイムアウトを持ち、それぞれ異なる問題を防いでいることが、ソケットの動作を知ることでより鮮明に理解できるのではないでしょうか。

2.11.4. 不完全な受信

前節で部分受信の問題を説明しましたが、本番環境ではこの問題がより複雑な形で現れます。

典型的なシナリオのひとつは、クライアントがリクエストの送信中に接続を切断するケースです。 ユーザーがフォームを送信した直後にブラウザの「戻る」ボタンを押す、あるいはモバイル端末でネットワークが切り替わる(Wi-Fi から 4G への切り替えなど)——こうした日常的な操作で、リクエストが途中で途切れます。

サーバ側では、recv() がそれまでに届いたデータを返し、次の recv() が空のバイト列 b"" を返します。 空のバイト列は「相手が接続を閉じた」ことを意味するシグナルです。 しかし、サーバがこのシグナルを正しくチェックしていなければ、不完全なデータをパースしようとしてクラッシュする可能性があります。

data = client_conn.recv(4096)
if not data:
    # クライアントが接続を閉じた
    client_conn.close()
    return

Tip

この if not data: のチェックは、ソケットプログラミングにおける基本的な防御です。 ヘッダーを受信するループの中でも、ボディを受信するループの中でも、すべての recv() 呼び出しの後に「相手が消えていないか」を確認することが必要です。

もうひとつのシナリオは、ヘッダーで宣言された Content-Length と実際に届くボディのサイズが一致しないケースです。 これはヘッダー不正の問題と重なりますが、悪意がなくても、プロキシの誤設定や中間のネットワーク機器の問題で発生することがあります。 サーバは Content-Length の値を信じてその分だけ読もうとしますが、それだけのデータが永遠に届かず、最終的にタイムアウトに至る——という流れです。

2.11.5. 接続を閉じるべきタイミング

HTTP/1.1 の持続的接続では、レスポンスを返してもコネクションは開いたまま維持されます。 では、サーバはいつコネクションを閉じるべきなのでしょうか。

最も明確なケースは、クライアントが Connection: close ヘッダーを送ってきた場合です。 この場合、サーバはレスポンスを返した後にコネクションを閉じます。 レスポンスにも Connection: close を含めて、クライアントに閉じることを通知します。

しかし、Connection: close が送られてこない場合はどうでしょう。 クライアントは次のリクエストを送るかもしれないし、送らないかもしれません。 サーバは次のリクエストが来ることを期待して recv() で待ちますが、クライアントがもうリクエストを送るつもりがなければ、このコネクションは無駄にリソースを消費し続けます。

実際のサーバは、keep-alive タイムアウトを設けてこの問題に対処しています。

ツール

設定項目

デフォルト値

備考

Nginx

keepalive_timeout

75秒

クライアントとの接続

Gunicorn

--keep-alive

2秒

Nginx との接続

値が大きく異なるのは、Nginx(リバースプロキシ)とクライアントの間のコネクションと、Nginx と Gunicorn の間のコネクションでは、適切なタイムアウト値が異なるためです。

もうひとつ判断が難しいのは、エラーが発生した場合です。 パースできないリクエストが届いた場合、サーバは 400 Bad Request を返した後にコネクションを閉じるべきです。 リクエストが正しくパースできなかったということは、次のリクエストの境界も正しく判断できない可能性が高いからです。 持続的接続を維持したまま次のリクエストを待つと、データの解釈がずれて予測不能な動作を引き起こします。


本節で取り上げた問題——巨大リクエスト、ヘッダー不正、タイムアウト、不完全な受信、接続を閉じるタイミング——はどれも、HTTP と TCP ソケットの仕組みから自然に生まれる問題です。 フレームワークや本番用のサーバがこれらに対処してくれているからこそ、私たちはビュー関数のビジネスロジックに集中できています。

重要

次の3 章まずは 1 リクエストだけ処理するサーバを作る)では、これらの問題のうちいくつかに実際に対処しながら、最小限の HTTP サーバを自分の手で作り上げます。 すべてを完璧に防御する必要はありません。しかし、「何を防御すべきか」を知っていること自体が、開発者としての力になります。