Old-School Networking / 2026 ・ No.10
いにしえのネットワーク入門
第10回 HTTP ── 申請ボタンの中身を、1行ずつ読む
公開
第1回から送り続けてきた POST /api/expenses。名前を引き(第9回)、つなぎ(第6回)、鍵をかけた(第8回)その上で、やっと中身を読みます。
HTTP は、ただの文字の行でできた「頼みごと」と「返事」です。形、メソッド、ステータスコード、キャッシュ(ETag と 304)、クッキー、CORS、圧縮、そして HTTP/1.1・2・3 の違いまで、練習用のサーバーで実測して確かめます。
申請を読む層
新井第1回から9回かけて、やっと申請の中身ですか。長い旅でした。
古井封筒の宛名、郵便の道、書留の鍵。そこまでが下の層だ。今日は便せんを開ける。
新井便せんには何が書いてあるんです?
古井拍子抜けするほど普通の文字だ。「POST /api/expenses、中身は JSON、54 バイト」。人間が読める。
全12回の路線図(いまここ:第10回)
- 形 ── 1行目、ヘッダー、空行、本文。申請は見出し 149 バイト+本文 54 バイト(実測)
- メソッドとステータス ── 何をしてほしいかと、どうなったか。この回で10種類のコードを返させた(実測)
- 速くする工夫 ── 304 で本文 0 バイト、gzip で 2 MB が 6 KB、HTTP/2 で1本の接続に同時に(実測)
- 開発者の落とし穴 ── クッキーの3つの印、CORS のエラーはだれが直すか
HTTP は、TCP/IP の層で言えば第7層、アプリの層のいちばん上の取り決めです。練習用ネットワークは第9回と同じで、PC と、片道 20 ミリ秒のいたずら線の向こうの申請サーバー(Node.js)です。サーバーは、HTTP(3000 番)、HTTPS(3443 番。HTTP/1.1 と HTTP/2 の両方)、暗号なしの HTTP/2(3080 番。観察用)で待ち受けます。
リクエストとレスポンスの形
中身を見るため、いったん鍵なしの HTTP で申請を送り、curl -v と Wireshark で記録しました(Wireshark の画面は表紙の写真)。
# リクエスト:1行目(メソッド・パス・版)、ヘッダー、空行、本文
POST /api/expenses HTTP/1.1
Host: expenses.example:3000
User-Agent: curl/8.5.0
Accept: */*
Content-Type: application/json
Content-Length: 54
{"date":"2026-10-25","item":"交通費","amount":1280}
# レスポンス:1行目(版・ステータスコード)、ヘッダー、空行、本文
HTTP/1.1 201 Created
Location: /api/expenses/EXP-2026-0012
Content-Type: application/json
Content-Length: 43
Date: Sun, 25 Oct 2026 00:28:31 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"id":"EXP-2026-0012","status":"submitted"}
リクエストもレスポンスも、1行目・ヘッダー・空行・本文の4つ
リクエストの1行目は「何を(POST)・どこに(/api/expenses)・どの版で(HTTP/1.1)」。続くヘッダーは「名前: 値」の行で、Host は宛先の名前(1台のサーバーがいくつもの名前を受け持つので必須です)、Content-Type は本文の種類、Content-Length は本文の長さです。空行でヘッダーが終わり、そのあと 54 バイトの JSON が続きます。
第6回で、TCP は区切りのない「流れ」だと書きました。どこまでが1つの申請なのかは、上の層が決めるしかない。その答えがこれです。空行までがヘッダー、そこから Content-Length バイトが本文。サーバーはこの決まりで流れを切り分けます。
レスポンスの1行目は「版・ステータスコード・説明」。201 Created は「作りました」で、Location に新しい申請の場所が書いてあります。最後の Connection: keep-alive と Keep-Alive: timeout=5 は、「この接続は5秒間つないだままにしておくので、次の頼みごとにも使ってよい」という意味です。第6回と第8回で見た、TCP と TLS のあいさつを省く「接続の使い回し」は、ここで約束されます。
Wireshark の4番のパケットは 269 バイト。イーサネット 14、IP 20、TCP 32(時刻の印つき)、HTTP の見出し 149、本文 54 を足すと、ちょうど 269 になります。第2回からの層が、1つのパケットに全部入っています。
メソッド:何をしてほしいか
1行目の最初の語がメソッドです。同じ /api/expenses でも、GET なら「一覧を見せて」、POST なら「新しく申請して」になります。
| メソッド | 意味 | 読むだけ(安全) | 何度やっても同じ(冪等) | 経費アプリでは |
|---|---|---|---|---|
| GET | 取ってくる | ○ | ○ | 一覧を見る |
| POST | 送って処理してもらう | × | × | 申請する |
| PUT | 丸ごと置きかえる | × | ○ | (この練習のサーバーでは 405) |
| PATCH | 一部を書きかえる | × | × | 金額だけ直す |
| DELETE | 消す | × | ○ | 申請を取り下げる |
大事なのは右から2列目、冪等(べきとう)です。「同じ頼みごとを何度送っても、結果は1回のときと同じ」という性質で、HTTP の仕様(RFC 9110)は GET・HEAD・OPTIONS などの安全なメソッドと、PUT・DELETE を冪等としています。冪等なら、返事が来ないまま時間切れになっても、安心して送り直せます。
新井POST は冪等じゃない……。返事が遅いからって申請ボタンを2回押すと?
古井交通費 1,280 円が2件。経理の人に2回謝ることになる。だからボタンは押したら灰色にする、サーバーは同じ申請を見分ける。どちらも要る。
ステータスコード:どうなったか
練習用のサーバーに、わざといろいろな頼み方をして、返事の1行目を並べました。
# 同じ API に、いろいろなリクエストを送ったときの返事(1行目と、関係するヘッダー)
GET /api/expenses → 200 OK
POST /api/expenses(JSON) → 201 Created Location: /api/expenses/EXP-2026-0013
POST(JSON が壊れている) → 400 Bad Request
POST(Content-Type: text/plain) → 415 Unsupported Media Type
POST(amount が文字列) → 422 Unprocessable Entity
PUT /api/expenses → 405 Method Not Allowed Allow: GET, POST
GET /expenses(古い URL) → 301 Moved Permanently Location: /api/expenses
GET /api/expenses/EXP-2026-9999 → 404 Not Found
DELETE(鍵なし) → 401 Unauthorized WWW-Authenticate: Bearer
DELETE(鍵あり) → 204 No Content
最初の数字で5つの組に分かれる(四角はこの回の実測で返ったコード)
3桁の数の最初の数字が組を表します。2xx は成功、3xx は「別の場所へ」、4xx は「頼み方が悪い」、5xx は「サーバーが悪い」。4xx なら直すのは送った側(フロントエンド)、5xx ならサーバー側。障害のときにまず見るのは、この1桁です。
4xx の中にも使い分けがあります。400 は JSON が壊れていて読めない、415 は「JSON しか受け付けないのに text/plain が来た」、422 は「読めたが、金額が文字列で中身がおかしい」。401 は「だれだかわからない(鍵を見せて)」で、WWW-Authenticate に鍵の種類を添えます。だれだかわかったうえで「あなたには許さない」は 403 です。
見るべきは数字で、うしろの説明の文字ではありません。仕様の RFC 9110 は 422 の名前を「Unprocessable Content」に改めましたが、この回の Node.js は昔の「Unprocessable Entity」を返しました。それでも何も困りません。HTTP/2 にいたっては説明の文字そのものがなく、第6章の記録でも HTTP/2 200 と数字だけでした。
キャッシュ:ETag と 304
経費の一覧は、開くたびに同じ中身が返ることがほとんどです。毎回全部を運ぶのはもったいない。そこでサーバーは、一覧と一緒に中身の印、ETag を渡します。
# 1回目:一覧と一緒に、中身の印(ETag)が返る
pc$ curl -D - http://expenses.example:3000/api/expenses
HTTP/1.1 200 OK
ETag: "41dc1e7dc5a9"
Cache-Control: no-cache
(本文 153 bytes)
# 2回目:「この印のものなら持っている」と添えて聞く
pc$ curl -D - -H 'If-None-Match: "41dc1e7dc5a9"' http://expenses.example:3000/api/expenses
HTTP/1.1 304 Not Modified
ETag: "41dc1e7dc5a9"
(本文 0 bytes)
変わっていなければ「304、前のままでよい」とだけ答える
2回目は If-None-Match で「この印のものなら持っている」と添えて聞き、サーバーは中身が変わっていないので 304 Not Modified、本文 0 バイトで答えました。往復の回数は同じですが、一覧が大きいほど効きます。ブラウザは、これを何も言わなくても自動でやっています。
Cache-Control: no-cache は名前に反して「しまうな」ではなく、「しまってよいが、使う前に毎回サーバーに確かめよ」という意味です(RFC 9111)。本当にしまってほしくないときは no-store。max-age=600 なら「10分間は聞かずに使ってよい」で、その間は往復もなくなります。
「直したのに画面が変わらない」の多くはキャッシュです。開発中は、ブラウザの開発者ツールの Network タブにある「Disable cache」を入れておくと、余計な悩みが減ります。
忘れっぽい HTTP とクッキー
HTTP は、1つの頼みごとが終わるとすべて忘れます(ステートレス)。では「ログインしている」は、どうやって覚えているのか。ここからは HTTPS(3443 番)で試しました。
# ログイン前に「わたしは誰?」と聞く
pc$ curl https://expenses.example:3443/api/me
{"error":"login required"}
# ログインすると、サーバーがクッキーを渡す
pc$ curl -c jar.txt -X POST … https://expenses.example:3443/login
HTTP/2 200
set-cookie: sid=480df7…; Path=/; HttpOnly; Secure; SameSite=Lax
# 次のリクエストにクッキーを付けて聞く
pc$ curl -b jar.txt https://expenses.example:3443/api/me
{"user":"arai"}
番号札を渡して、次から毎回見せてもらう
ログインすると、サーバーは Set-Cookie で番号札(sid)を渡します。PC はそれをしまい、以後同じサーバーへの頼みごとに毎回 Cookie として付けます。サーバーは番号から「新井さんだ」と引く。HTTP 自身は忘れっぽいままで、覚えている「ふり」をさせているのがクッキーです。第8回で見た、OSI の第5層「会話の管理」の役は、実際にはここで果たされています。
札の後ろに付いた3つの印は、開発者がいちばん気にするところです。HttpOnly は JavaScript から読めなくする、Secure は HTTPS のときだけ送る、SameSite=Lax はよそのサイトから飛んできたときの多くの頼みごとに付けない。番号札は鍵そのものなので、盗まれにくく、勝手に使われにくくしておくわけです。
Secure の付いたクッキーは、http:// のページには送られません。開発中に「ログインしたのに、すぐ未ログインに戻る」ときは、まずこれを疑います。なお、ブラウザは localhost を特別扱いすることが多く、ここは第11回で。
CORS:ブラウザの番人
画面を https://app.expenses.example に、API を https://expenses.example:3443 に置いたとします。名前が違うので、2つは別の Origin(http/https・名前・番号の組)です。ブラウザは、あるページの JavaScript が別の Origin の返事を読むことを、ふつうは許しません。許すかどうかを API 側が答える取り決めが CORS です。
JSON を POST するとき、ブラウザは本番の前に OPTIONS で「下見」(preflight)をします。その下見を curl で再現しました。
# 下見(preflight):https://app.expenses.example の画面から POST してよいか
OPTIONS /api/expenses
Origin: https://app.expenses.example
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type
→
HTTP/2 204
access-control-allow-origin: https://app.expenses.example
access-control-allow-methods: GET, POST, DELETE
access-control-allow-headers: Content-Type, Authorization
access-control-max-age: 600
# 知らない Origin(https://other.example)からだと、許可のヘッダーが付かない
→
HTTP/2 204
ブラウザが下見をして、許されたときだけ本番を送る
許可した Origin には Access-Control-Allow-Origin などが付き、知らない Origin には何も付きません。どちらも 204 で、サーバーは「断る」とは言っていない点に注意してください。止めるのはブラウザです。許可のヘッダーがなければ、ブラウザは本番を送らず、開発者ツールの Console に CORS のエラーを出します。
新井CORS のエラー、フロントのコードをいくら直しても消えなかったんですよ。
古井だろうな。直す場所はサーバーの返事だ。curl では出ないのも同じ理由で、curl には番人がいない。
圧縮
経費の CSV(2 MB)の書き出しを、10 Mbit/s に絞った線で取りました。
# CSV 2 MB を、そのまま/gzip で(10 Mbit/s・片道 20 ms の線)
pc$ curl https://expenses.example:3443/api/export.csv
2,097,152 bytes 1.934 s
pc$ curl --compressed … (Accept-Encoding: deflate, gzip, br, zstd → Content-Encoding: gzip)
6,179 bytes 0.142 s
PC が Accept-Encoding で「この縮め方なら読める」と伝え、サーバーが Content-Encoding: gzip で縮めて返す。2,097,152 バイトが 6,179 バイトになり、1.934 秒が 0.142 秒になりました。ブラウザも同じことを自動でやっています。
ただし、この CSV は同じ行のくり返しなので、極端に縮みました。番号・日付・金額がばらばらの 2 MB の CSV を作って同じく gzip にかけると、428,597 バイト、約5分の1です。文字のデータ(HTML、JSON、CSV)はよく縮み、もともと縮めてある JPEG や動画はほとんど縮みません。
縮めた返事には Vary: Accept-Encoding を付けます。「この返事は Accept-Encoding によって変わる」という意味で、途中のキャッシュが、gzip を読めない相手に縮めた返事を渡す事故を防ぎます。
HTTP/1.1・2・3
1つの画面は、たいてい何十もの頼みごとでできています。100 ミリ秒かかる報告を6本、HTTPS で取って比べました。
# 100 ms かかる報告を 6 本(HTTPS・片道 20 ms)
HTTP/1.1 で順番に 0.953 s 1本の接続
HTTP/1.1 で同時に 0.246 s 6本の接続
HTTP/2 で同時に 0.245 s 1本の接続
6本の報告を取る時間(実測)
HTTP/1.1 は、1本の接続で一度に1つしか頼めません。順番にやると、使い回しの接続でも 0.953 秒。速くするには接続を何本も張るしかなく、6本で 0.246 秒になりました。ブラウザも同じ理由で、1つの相手に6本ほどの接続を張ります。その分、TCP と TLS のあいさつも6回です。
HTTP/2 は、1本の接続の上で頼みごとに番号(ストリーム)を振り、同時に進めます(多重化)。1本のまま 0.245 秒でした。中身は文字の行ではなく二進の枠(フレーム)になりましたが、メソッドもステータスもヘッダーも意味は同じです。
HTTPS では、どの版で話すかを TLS の握手のついでに決めます(ALPN。第8回で見た Client Hello に「h2 も話せます」と書いて送ります)。ブラウザが HTTP/2 を使うのは HTTPS のときだけで、写真の暗号なしの HTTP/2 は、観察のための特別な設定です。
HTTP の版と、その下の層
HTTP/2 にも弱点が残りました。下の TCP は1本の流れなので、パケットが1つ落ちると、関係ないストリームまで送り直しを待たされます(第7回の「先頭のつかえ」)。そこで HTTP/3 は、UDP の上の QUIC に引っ越しました。QUIC はストリームごとに順番を守り、TLS 1.3 も組み込みです。ブラウザは、返事の Alt-Svc ヘッダーや DNS の HTTPS レコード(第9回)で「この相手は HTTP/3 も話せる」と知り、次から切りかえます。
自分の PC で確かめる
| 調べたいこと | ブラウザ(開発者ツール) | curl |
|---|---|---|
| 頼みごとと返事を1行ずつ | Network タブで行を選び、Headers | curl -v https://example.com/ |
| ステータスコードだけ | Network タブの Status 列 | curl -s -o /dev/null -w '%{http_code}\n' URL |
| 話している版 | 列の見出しを右クリックして Protocol を出す(h2、h3) | curl -sI --http2 URL |
| キャッシュ | Size 列の「(disk cache)」、304 の行 | curl -D - -H 'If-None-Match: "…"' URL |
| 圧縮 | Size 列(運んだ量と、ほどいた大きさ) | curl --compressed -w '%{size_download}\n' URL |
| CORS の下見 | Type が preflight の行、Console のエラー | curl -X OPTIONS -H 'Origin: …' -i URL |
まずは、ふだん使っている Web サイトを開いて Network タブを眺めてみましょう。1枚の画面に、どれだけの頼みごとが、どの版で、どれだけ縮められて流れているかが見えます。
結 — 一枚まとめと、次回
- HTTP は文字の行。1行目・ヘッダー・空行・本文。本文の長さは Content-Length(TCP の流れの区切り方)。
- メソッドは何をしてほしいか。GET・PUT・DELETE は冪等で送り直せる。POST は二重申請に注意。
- ステータスコードは最初の数字で読む。4xx は送った側、5xx はサーバーが直す。説明の文字は飾り。
- ETag と 304 で、変わっていなければ本文 0 バイト。no-cache は「毎回確かめる」。
- HTTP は忘れっぽい。クッキーの番号札で覚えている「ふり」をする。HttpOnly・Secure・SameSite。
- CORS を止めるのはブラウザ、直すのはサーバーの返事。
- gzip で 2 MB が 6 KB(ふつうの CSV なら約5分の1)。HTTP/2 は1本の接続で同時に、HTTP/3 は QUIC(UDP)の上で。
新井これで申請ボタンの旅は全部たどれました! でも、ぼくの PC の localhost:3000 は、この地図のどこにあるんでしょう。
古井いい質問だ。次回は開発者のネットワーク。localhost、Docker の中と外、プロキシ。毎日使っているのに、いちばん迷う場所を歩く。
参考
- RFC 9110 — HTTP Semantics
- RFC 9111 — HTTP Caching
- RFC 9112 — HTTP/1.1
- RFC 9113 — HTTP/2
- RFC 9114 — HTTP/3
- RFC 9000 — QUIC: A UDP-Based Multiplexed and Secure Transport
- RFC 2068 — Hypertext Transfer Protocol -- HTTP/1.1(1997)
- RFC 7540 — Hypertext Transfer Protocol Version 2(2015)
- RFC 6265 — HTTP State Management Mechanism(Cookie)
- RFC 7838 — HTTP Alternative Services(Alt-Svc)
- RFC 9460 — SVCB and HTTPS Resource Records
- WHATWG — Fetch Standard(CORS)
- MDN — Cross-Origin Resource Sharing (CORS)
- MDN — Connection management in HTTP/1.x
- curl — man page
- Node.js — HTTP/2
記録は、筆者がクラウド上の Linux(Ubuntu 24.04、カーネル 6.18)にネットワーク名前空間で作った練習用ネットワークで、2026年10月25日に取ったものです。サーバーは Node.js 22.23 の http・http2 モジュールで書いた練習用のもの、PC 側は curl 8.5.0 で、名前と住所はすべて説明用に予約されたもの、または練習用に作ったものです。PC とサーバーの間は、第6回のいたずら線(片道 20 ミリ秒。圧縮の章だけ 10 Mbit/s)です。表の数字はすべて、この記録とそのときのコマンドの出力から読んだ実際の値です。ばらばらの CSV の圧縮は、筆者が作った 2 MB の CSV を同じ強さ(6)の gzip にかけた値です。登場人物は架空の人物です。
いにしえのネットワーク入門 第10回 — HTTP / 2026.10