Dev Study
← 1行のHTMLが画面に出るまで
レッスン 4 / 8·約 9 分·導線 1行のHTMLが画面に出るまで

接続して送る — TCP接続の上を流れるHTTPリクエストのバイト列

リクエストがアプリケーションに届くまで
  1. ブラウザURL入力今ここ
  2. DNS名前→IP
  3. ルータ経路選択
  4. ロードバランサ振り分け
  5. Nginx受け口
  6. アプリ処理今ここ
  7. DB保存

宛先が決まったので接続を張り、バイト列を送ります。相手は同じ機械の中(loopback)なので、ルータという別の機械はここに現れません。

まず結論

ブラウザがサーバに送るリクエストは特別な形式のデータではありません。TCPの接続の上を流れる、CRLFで区切られたただのバイト列です。

TCPは中身を解釈せず、運ぶのはIPアドレスとポートだけでホスト名は運びません。だからHTTP/1.1はリクエストの側に `Host` を書くことを必須にしています。

? 読む前に予測

サーバへ `GET / HTTP/1.1` の行と `Host:` の行だけを送り、そのあと何も送らないと何が起きるでしょうか。

当てることが目的ではありません。外した予測ほど記憶に残るので、直感で選んでください。

抽象原理

接続は「合意」、その上を流れるのはバイト列

前のレッスン『名前がアドレスになる — localhost は DNS に行かない』で宛先が決まりました。ここから先は、そのアドレスへ接続してリクエストを送る段です。

TCPの接続は、ケーブルが引かれるのでも通り道が予約されるのでもなく、両端がそれぞれ「この相手と話している」という状態を持っただけです。この合意の作り方は第22週『なぜ手を3回握るのか — 「接続」という幻想の作り方』で扱います。

合意ができれば、あとはバイト列を流すだけ。TCPは中身を解釈しません。 ヘッダがどこで終わるかも本文がどこから始まるかも知りません。

だからHTTP/1.1は区切りをバイト列の中に自分で書きます。RFC 9112 §2.1 の規定です。

start-line が1行、ヘッダ行が0本以上、そして CRLF が1つ単独で置かれ(= 空行)、後ろに本文が任意で続きます。この空行が「ヘッダ部はここまで」の印です。

リクエストのバイト列の構造(CRLF = 0d 0a の2バイト)
request-lineGET / HTTP/1.1
CRLF
続けて同じ接続に流す
ヘッダ行(0本以上)Host: localhost:3000 / User-Agent: ... / Accept: ...
各行の末尾にCRLF
ここが境目
空行(CRLFだけ)ヘッダ部の終わりを示す
あれば
message-bodyGETでは無いことが多い
  • ・区切りはすべてバイト列の中にある。TCPは区切りを持たない
  • ・空行が来るまで、受け取る側はヘッダの途中と見なす
具体実物

実際に流れた4行を見る

通し例題のサーバ(<h1>Hello</h1> を返すだけのNode)に curl -v で接続した生の出力です。> が送ったバイト列、< が受け取ったものです。

$ curl -v http://localhost:3999/ 2>&1(検証時は 3999 で取得した。進捗メータの行は省略)コマンド出力
* Host localhost:3999 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
* Trying [::1]:3999...
* Connected to localhost (::1) port 3999
> GET / HTTP/1.1
> Host: localhost:3999
> User-Agent: curl/8.7.1
> Accept: */*
>
* Request completely sent off
< HTTP/1.1 200 OK
< Date: Wed, 16 Sep 2026 02:15:52 GMT
< Connection: keep-alive
< Keep-Alive: timeout=5
< Content-Length: 14
<
{ [14 bytes data]
* Connection #0 to host localhost left intact
<h1>Hello</h1>

注目は最後の > だけの行です。中身が空のこの行が図の「空行」にあたります。送った直後にcurlは "Request completely sent off" と表示しました。ヘッダ部を閉じるまでリクエストは完成しません。

この実機では localhost が ::1 と 127.0.0.1 の両方に解決され、curl は ::1 に繋ぎました。検証時のポートは 3999 です。行末が本当に2バイトなのかは xxd で確かめられます。

$ curl -si http://localhost:3999/ | xxd | head -5(検証時は 3999 で取得した)コマンド出力
00000000: 4854 5450 2f31 2e31 2032 3030 204f 4b0d HTTP/1.1 200 OK.
00000010: 0a44 6174 653a 2057 6564 2c20 3136 2053 .Date: Wed, 16 S
00000020: 6570 2032 3032 3620 3032 3a31 353a 3532 ep 2026 02:15:52
00000030: 2047 4d54 0d0a 436f 6e6e 6563 7469 6f6e GMT..Connection
00000040: 3a20 6b65 6570 2d61 6c69 7665 0d0a 4b65 : keep-alive..Ke

4f 4b が "OK"、直後の 0d 0a が CR LF です。右側では . としか見えませんが、左を見れば2バイトあるとわかります。HTTPが「人に読める」ことと「文字列が運ばれている」ことは別です。

✓ ここまでの確認

HTTP/1.1 で、ヘッダ部が終わったことをサーバはどう知りますか。

抽象原理

TCPはホスト名を運ばない

なぜ Host: の行がわざわざ必要なのでしょうか。

接続の相手は既に決まっています。しかしTCPの接続が持つ情報は両端のIPアドレスとポート番号の組だけで、前のレッスンで打った localhost は消えています。

一方サーバ側は、1つのIPアドレスとポートの組で複数のサイトを提供することがあります。届いたバイト列だけが判断材料なので、どのホスト宛てかはリクエストに書いてもらうしかありません。 RFC 9112 §3.2 の規定です。

送る側にMUST、受け取る側にもMUSTがあります。値はターゲットURIのauthorityと同一で、ポート番号を含みます。実機出力が Host: localhost:3999 とポート込みなのはこのためです。

もう一つ思い込みを外します。「1リクエスト = 1接続」ではありません。 §9.3 は持続接続が既定だと定めます。

実機出力の Connection #0 to host localhost left intact がそれで、応答を受け取ったあともcurlは接続を閉じていません。

具体実物

空行を送らないとどうなるか、Hostを送らないとどうなるか

区切りがバイト列の中にあるなら、送らなければサーバは判断できません。nc で手書きのバイト列を送ります。まず正しい場合。

$ printf 'GET / HTTP/1.1\r\nHost: localhost:3999\r\n\r\n' | nc localhost 3999(検証時は 3999 で取得した)コマンド出力
HTTP/1.1 200 OK
Date: Wed, 16 Sep 2026 02:15:52 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Content-Length: 14
 
<h1>Hello</h1>

printf で組み立てた40バイトだけで応答が返りました。HTTPのリクエストとはこの程度です。

次に末尾の \r\n(空行)を1つ減らし、3秒だけ送信側を開けたままにします。

空行を送らない: (printf 'GET / HTTP/1.1\r\nHost: localhost:3999\r\n'; sleep 3) | nc localhost 3999(1行目が実行開始時刻、以降は各行の受信時刻)コマンド出力
11:16:31.754373000
11:16:34 HTTP/1.1 400 Bad Request
11:16:34 Connection: close
11:16:34
exit=0 11:16:34

11:16:31 から 11:16:34 までの3秒間、応答は返っていません。 サーバは「ヘッダの途中」と見なして待ちました。nc が送信側を閉じた瞬間、ヘッダ部が閉じられないまま切れたとして 400 が返ります。確かめたのは3秒待ったことまでで、それ以上は測っていません。

もう一つ、Host: の行を落とすとどうなるか。

$ printf 'GET / HTTP/1.1\r\n\r\n' | nc localhost 3999(検証時は 3999 で取得した)コマンド出力
HTTP/1.1 400 Bad Request
Connection: close
Date: Wed, 16 Sep 2026 02:15:52 GMT
Transfer-Encoding: chunked
 
0
 
 

この実機のNode(v24.21.0)は 400 を返しました。§3.2 のMUSTと一致しますが、これは「この実装がそうだった」という観測で、どのサーバも必ず400を返すとは言えません。

最後に持続接続です。同じ接続に2つのリクエストを流します。

1本の接続に2リクエスト(2つ目に Connection: close を付けた)コマンド出力
HTTP/1.1 200 OK
Date: Wed, 16 Sep 2026 02:16:34 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Content-Length: 14
 
<h1>Hello</h1>HTTP/1.1 200 OK
Date: Wed, 16 Sep 2026 02:16:34 GMT
Connection: close
Content-Length: 14
 
<h1>Hello</h1>

1つ目の本文の直後に、改行も挟まず2つ目の HTTP/1.1 200 OK が始まっています。1つ目がどこで終わったかは Content-Length: 14 が決めています。 RFC 9112 §6.3 の本文長の判定順のうち Content-Length による経路です。

✓ ここまでの確認

HTTP/1.1 が `Host` ヘッダを必須にしているのはなぜですか。

⇄ 2つを見比べる
具体例 A

curl の `>` 行(HTTPの視点)

curl -v は、HTTPメッセージとして組み立てた行を `>` 付きで表示します。request-line・ヘッダ・空行の構造が見える代わりに、いつカーネルへ渡ったかは見えません。

見えるもの(検証時は 3999 で取得した)コマンド出力
> GET / HTTP/1.1
> Host: localhost:3999
> User-Agent: curl/8.7.1
> Accept: */*
>
具体例 B

システムコールの視点(第13週・第26週)

アプリは自分でネットワークへ書き込めず、必ずカーネルに依頼します。接続を作る呼び出しの後、同じバイト列が書き込みの呼び出しの引数としてカーネルへ渡ります。Linux では strace でこの境界を覗けます(本コースの実機は macOS のため未取得)。

境界のどちら側で何が見えるか表
見る場所 見えるもの 見えないもの
curl の > 行 HTTPの行の構造 カーネルへ渡した時点
システムコール バイト列と渡した時点 HTTPとしての意味

curl が表示する `>` の行と、システムコールのトレースに現れる書き込みの中身は、同じものを見ているのでしょうか。

それぞれが「どの層に立って」観測しているかを考えてください。

具体実物

この先の学びにどう繋がるか

リクエストが届きました。次のレッスン『サーバは何を返すか — Content-Type が無いと何が起きるか』では、返ってくる側のバイト列を見ます。上の実機出力に Content-Type が無かったことに気づいたなら、それが次の主題です。

接続そのものは第22週『なぜ手を3回握るのか — 「接続」という幻想の作り方』で扱います。RFC 9293 §3.5 は、データを載せたセグメントで接続を同期させることを "perfectly legitimate" と書いています(同節 Figure 6 自体は、ACKのみの行とデータを伴う行を別行として示します)。

語彙の網羅は既存のクイズ群「HTTP/1.1(RFC 9112)」「HTTP/1.1用語(単語帳)」「TCP用語(単語帳)」に譲ります。本コースが追うのは、1行のHTMLが画面に出るまでの継ぎ目だけです。

この章のまとめ

  • HTTP/1.1 のリクエストはCRLFで区切られたバイト列。§2.1 の形は start-line CRLF *( field-line CRLF ) CRLF [ message-body ] で、真ん中の単独のCRLFがヘッダ部の終わりを示す空行。
  • TCPは中身を解釈しない。 区切りはバイト列の中に書くしかない。空行を送らずにいると、3秒のあいだ応答が返らなかった。
  • 行末は実際に 0d 0a の2バイト。 ただし §2.2 により、受け取る側が単独のLFを行末と認めるのは MAY。
  • Host が必須なのは、TCPがIPアドレスとポートしか運ばないから。 §3.2 はクライアントにMUST、サーバに400のMUSTを課す。値はポートを含む。
  • 「1リクエスト = 1接続」ではない。 §9.3 により持続接続が既定。境目は Content-Length が決める。
  • 実装の観測と規格の条文は別物。根拠は条文に取りに行く。

自分の現場で確かめる

読んだだけでは身につきません。手元の環境で実際に見てみてください。

環境:

理解度チェック

読んだ直後は「分かった」と感じやすいものです。実際に解いて確かめてください。

問題 1 / 4正解: 0

「HTTP/1.1 はテキストのプロトコルなので、バイト列ではなく文字列として送られる」の評価はどれですか。

用語集(5語)
request-line
リクエストの1行目。RFC 9112 §3 は `request-line = method SP request-target SP HTTP-version` と定める。SPは半角スペース。
CRLF
復帰(CR, 0x0d)と改行(LF, 0x0a)の2バイト。HTTP/1.1 のstart-lineとヘッダ行の終端。ヘッダ部の終わりはCRLFだけの行で示す。
Host ヘッダ
リクエストがどのホスト宛てかを示すヘッダ。RFC 9112 §3.2 でHTTP/1.1の全リクエストに必須(MUST)。値はターゲットURIのauthorityと同一で、ポートを含む。
持続接続 (persistent connection)
1本のTCP接続に複数のリクエストと応答を載せる使い方。RFC 9112 §9.3 により HTTP/1.1 の既定。
Content-Length
メッセージ本文の長さをオクテット数で宣言するヘッダ。RFC 9112 §6.3 の判定順の第6項で、受け取る側はこれで本文の切れ目を決める。