Old-School Networking / 2026 ・ No.10

いにしえのネットワーク入門

第10回 HTTP ── 申請ボタンの中身を、1行ずつ読む

公開

Wireshark の画面。PC から 203.0.113.20 の 3000 番への 10 個のパケットが並び、4番の POST /api/expenses HTTP/1.1 が選ばれている。明細の Hypertext Transfer Protocol を開くと、POST /api/expenses HTTP/1.1、Host、User-Agent、Accept、Content-Type: application/json、Content-Length: 54 の各行の終わりに \r\n があり、空行の \r\n のあとに File Data: 54 bytes が続く。6番は HTTP/1.1 201 Created
第1回からずっと送ってきた申請を、暗号なしの HTTP で送って記録したもの。4番が申請、6番が「受け付けました(201 Created)」。明細では、1行ずつ \r\n で終わり、空の \r\n のあとに本文 54 バイトが続くのが見えます(筆者がクラウド上に作った練習用ネットワークで、2026年10月25日に記録したものを Wireshark 4.2.2 で開いて撮影。画面は英語のまま。ウインドウの上の帯と見出しは説明のために描き足したもの)

第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 全体図 2 物理層 3 データリンク層 4 IP アドレス 5 ルーター 6 TCP 7 UDP・ソケット 8 TLS 9 DNS 10 HTTP 11 開発者の網 12 切り分け いまここ 全体図 第1層 第2層 第3層 第4層 第5・6層 第7層 実践
  • 形 ── 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つ

リクエスト(PC → サーバー) POST /api/expenses HTTP/1.1 1行目 Host: expenses.example:3000 Content-Type: application/json Content-Length: 54 ヘッダー 空行 {"date":…,"amount":1280} 本文 見出し 149 バイト+本文 54 バイト レスポンス(サーバー → PC) HTTP/1.1 201 Created 1行目 Location: /api/expenses/EXP-… Content-Type: application/json Content-Length: 43 ヘッダー 空行 {"id":"EXP-2026-0012",…} 本文 見出し+本文 43 バイト ただの文字の行。改行(\r\n)で区切り、空行でヘッダーの終わりを示す 本文の長さは Content-Length に書く(第6回の「区切り」の答え)

リクエストの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 のあいさつを省く「接続の使い回し」は、ここで約束されます。

POINT

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つの組に分かれる(四角はこの回の実測で返ったコード)

1xx 1xx 途中経過 まだ続きがある 2xx 2xx 成功 200 OK、201 作った、204 中身なし 200 201 204 3xx 3xx 別の場所へ 301 引っ越した、304 前のままでよい 301 4xx 4xx 頼み方が悪い 直すのは送った側 400 401 404 405 415 422 5xx 5xx サーバーが悪い 送った側は悪くない (この回は出していない) 四角はこの回の実測で返ってきたコード。最初の数字だけで、だれが直すべきかがわかる

3桁の数の最初の数字が組を表します。2xx は成功、3xx は「別の場所へ」、4xx は「頼み方が悪い」、5xx は「サーバーが悪い」。4xx なら直すのは送った側(フロントエンド)、5xx ならサーバー側。障害のときにまず見るのは、この1桁です。

4xx の中にも使い分けがあります。400 は JSON が壊れていて読めない、415 は「JSON しか受け付けないのに text/plain が来た」、422 は「読めたが、金額が文字列で中身がおかしい」。401 は「だれだかわからない(鍵を見せて)」で、WWW-Authenticate に鍵の種類を添えます。だれだかわかったうえで「あなたには許さない」は 403 です。

POINT

見るべきは数字で、うしろの説明の文字ではありません。仕様の 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、前のままでよい」とだけ答える

PC サーバー GET /api/expenses 200 OK + ETag "41dc…" (153 B) If-None-Match: "41dc…" 304 Not Modified (0 B) 手元に残す 変わっていなければ本文を送らない。往復は減らないが、運ぶ量が減る
Wireshark の画面。表示フィルター http で4つに絞った一覧。4番 GET /api/expenses、6番 HTTP/1.1 200 OK(422 バイト)、14番 GET /api/expenses(193 バイト)、16番 HTTP/1.1 304 Not Modified(226 バイト)。16番の明細に ETag: 41dc1e7dc5a9、Cache-Control: no-cache が見え、本文はない
一覧を2回取ったところ。2回目(14番)は「この印のものなら持っている」と添えたので、答え(16番)は 304 Not Modified。ETag の印は1回目と同じで、本文は付いていません(筆者がクラウド上に作った練習用ネットワークで、2026年10月25日に記録したものを Wireshark 4.2.2 で開いて撮影。画面は英語のまま。ウインドウの上の帯と見出しは説明のために描き足したもの)

2回目は If-None-Match で「この印のものなら持っている」と添えて聞き、サーバーは中身が変わっていないので 304 Not Modified、本文 0 バイトで答えました。往復の回数は同じですが、一覧が大きいほど効きます。ブラウザは、これを何も言わなくても自動でやっています。

Cache-Control: no-cache は名前に反して「しまうな」ではなく、「しまってよいが、使う前に毎回サーバーに確かめよ」という意味です(RFC 9111)。本当にしまってほしくないときは no-store。max-age=600 なら「10分間は聞かずに使ってよい」で、その間は往復もなくなります。

POINT

「直したのに画面が変わらない」の多くはキャッシュです。開発中は、ブラウザの開発者ツールの 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"}

番号札を渡して、次から毎回見せてもらう

PC サーバー GET /api/me 401:だれ? POST /login Set-Cookie: sid=480df7… GET /api/me Cookie: sid=480df7… {"user":"arai"} サーバーの表 sid → arai HTTP は1回ごとに忘れる。覚えている「ふり」をさせるのがクッキー(第5層の会話の管理) HttpOnly=JavaScript から読めない、Secure=HTTPS のときだけ送る SameSite=よそのサイトから来たときは送らない

ログインすると、サーバーは Set-Cookie で番号札(sid)を渡します。PC はそれをしまい、以後同じサーバーへの頼みごとに毎回 Cookie として付けます。サーバーは番号から「新井さんだ」と引く。HTTP 自身は忘れっぽいままで、覚えている「ふり」をさせているのがクッキーです。第8回で見た、OSI の第5層「会話の管理」の役は、実際にはここで果たされています。

札の後ろに付いた3つの印は、開発者がいちばん気にするところです。HttpOnly は JavaScript から読めなくする、Secure は HTTPS のときだけ送る、SameSite=Lax はよそのサイトから飛んできたときの多くの頼みごとに付けない。番号札は鍵そのものなので、盗まれにくく、勝手に使われにくくしておくわけです。

POINT

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 

ブラウザが下見をして、許されたときだけ本番を送る

ブラウザ 画面 app.expenses.example ブラウザの番人 下見してから送る API expenses.example OPTIONS(下見) Access-Control-Allow-Origin 許されたら本番の POST 別の Origin(名前・番号・http/https の組)への fetch は、ブラウザが番をする 守っているのはブラウザ。curl やサーバーどうしの通信は CORS を気にしない CORS のエラーは、サーバー側で許可のヘッダーを返すことで直す

許可した 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 や動画はほとんど縮みません。

POINT

縮めた返事には 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 本 0.953 s HTTP/1.1 同時に 接続 6 本 0.246 s HTTP/2 同時に 接続 1 本 0.245 s 0.0 s 0.5 s 1.0 s HTTP/1.1 は1本の接続で一度に1つ。速くするには接続を何本も張る(ブラウザは最大6本ほど) HTTP/2 は1本の接続の上で、たくさんの頼みごとを同時に進める(多重化)

HTTP/1.1 は、1本の接続で一度に1つしか頼めません。順番にやると、使い回しの接続でも 0.953 秒。速くするには接続を何本も張るしかなく、6本で 0.246 秒になりました。ブラウザも同じ理由で、1つの相手に6本ほどの接続を張ります。その分、TCP と TLS のあいさつも6回です。

HTTP/2 は、1本の接続の上で頼みごとに番号(ストリーム)を振り、同時に進めます(多重化)。1本のまま 0.245 秒でした。中身は文字の行ではなく二進の枠(フレーム)になりましたが、メソッドもステータスもヘッダーも意味は同じです。

Wireshark のパケット一覧。プロトコルはすべて HTTP2。5・6・7番で HEADERS[1]、HEADERS[3]、HEADERS[5] として GET /api/report が3つ、0.041 秒に続けて送られる。18番で HEADERS[1]: 200 OK と HEADERS[3]: 200 OK、DATA[1]、DATA[3] が1つのパケットにまとまって返り、20番で HEADERS[5]、23番で DATA[5] が返る
HTTP/2 で 100 ミリ秒かかる報告を3つ同時に頼んだところ。[ ] の中の数字がストリーム(頼みごと)の番号で、1本の接続の上で3つが同時に進み、0.182 秒に答えが混ざって返ってきました。中身を見るため、この1枚だけは暗号なしの HTTP/2(h2c)で記録しています(筆者がクラウド上に作った練習用ネットワークで、2026年10月25日に記録したものを Wireshark 4.2.2 で開いて撮影。画面は英語のまま。ウインドウの上の帯と見出しは説明のために描き足したもの)

HTTPS では、どの版で話すかを TLS の握手のついでに決めます(ALPN。第8回で見た Client Hello に「h2 も話せます」と書いて送ります)。ブラウザが HTTP/2 を使うのは HTTPS のときだけで、写真の暗号なしの HTTP/2 は、観察のための特別な設定です。

HTTP の版と、その下の層

HTTP/1.1 TLS TCP IP 1997年(RFC 2068)、文字の行 HTTP/2 TLS TCP IP 2015年、二進の枠と多重化 HTTP/3 QUIC(TLS 1.3) UDP IP 2022年、UDP の上へ HTTP/3 は、TCP の先頭のつかえ(第7回)を避けるため、UDP の上に引っ越した 中身の意味(メソッド、ステータス、ヘッダー)は、どの版でも同じ

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 タブで行を選び、Headerscurl -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枚の画面に、どれだけの頼みごとが、どの版で、どれだけ縮められて流れているかが見えます。

結 — 一枚まとめと、次回

  1. HTTP は文字の行。1行目・ヘッダー・空行・本文。本文の長さは Content-Length(TCP の流れの区切り方)。
  2. メソッドは何をしてほしいか。GET・PUT・DELETE は冪等で送り直せる。POST は二重申請に注意。
  3. ステータスコードは最初の数字で読む。4xx は送った側、5xx はサーバーが直す。説明の文字は飾り。
  4. ETag と 304 で、変わっていなければ本文 0 バイト。no-cache は「毎回確かめる」。
  5. HTTP は忘れっぽい。クッキーの番号札で覚えている「ふり」をする。HttpOnly・Secure・SameSite。
  6. CORS を止めるのはブラウザ、直すのはサーバーの返事。
  7. gzip で 2 MB が 6 KB(ふつうの CSV なら約5分の1)。HTTP/2 は1本の接続で同時に、HTTP/3 は QUIC(UDP)の上で。

新井これで申請ボタンの旅は全部たどれました! でも、ぼくの PC の localhost:3000 は、この地図のどこにあるんでしょう。

古井いい質問だ。次回は開発者のネットワーク。localhost、Docker の中と外、プロキシ。毎日使っているのに、いちばん迷う場所を歩く。

参考

記録は、筆者がクラウド上の 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

← 記事一覧へ