HTTP 高速化:keep-alive・gzip・HTTP/2・CORS を実測

接続の使い回しと毎回接続、gzip レベル、HTTP/1.1 と HTTP/2 を curl・Python・php -S で実測。curl のループの罠と、CORS プリフライトでサーバーに実際に何が届くかも確認。

シリーズ:HTTP 講座 6/6(上級)。 リクエスト、キャッシュ、TLS まで分かった人が、サイトや API を速くし、別オリジンからも呼べるようにするための最終回です。読み終えると接続の使い回しを自分で測り、いつ・どれだけ圧縮するかを決め、HTTP/2 で変わることと変わらないことを説明し、何を守るのかを理解したうえで CORS のプリフライト応答を設定できます。

この記事は、「社内 Wi-Fi では速いのに、スマホのモバイル回線だと遅い」と言われた人、社内ツールのバッチが API を数百回呼ぶだけで何分もかかっている人、そしてフロントエンド担当から「ブラウザのコンソールに blocked by CORS policy と出ます」とスクリーンショットが届いた人のためのものです。3つとも、ここで測る通信レベルのいくつかの選択で決まります。すべて Apple Silicon Mac(M1 Pro)で curl 8.7.1(nghttp2 による HTTP/2 対応、HTTP/3 は非対応)、Python 3.9.6、PHP 8.5.6(php -S)、zlib 1.2.12 を使い、127.0.0.1 上で実行しました。加えて example.com と当サイトに数回だけ読み取り専用のリクエストを送っています。ブラウザの挙動は説明のみで、実行していません。

keep-alive:1本の接続で複数のリクエスト

HTTP/1.1 では、どちらかが Connection: close と言わない限り、応答の後も接続は開いたままです。これが keep-alive(持続的接続)で、次のリクエストは TCP 接続と TLS ハンドシェイクを省略できます。curl の -v で確認できます。講座5の keep-alive 対応サーバー(tls_server.py を証明書なしで、ポート 18171)に送りました。

$ curl -sv -o /dev/null -o /dev/null http://127.0.0.1:18171/a http://127.0.0.1:18171/b
*   Trying 127.0.0.1:18171...
* Connected to 127.0.0.1 (127.0.0.1) port 18171
> GET /a HTTP/1.1
< HTTP/1.1 200 OK
* Connection #0 to host 127.0.0.1 left intact
* Re-using existing connection with host 127.0.0.1
> GET /b HTTP/1.1
< HTTP/1.1 200 OK

-H 'Connection: close' を付けると、2回目は新しい接続を開きます。

* Connection 0 seems to be dead
* Closing connection
*   Trying 127.0.0.1:18171...
* Connected to 127.0.0.1 (127.0.0.1) port 18171
> GET /b HTTP/1.1

すべてのサーバーが接続を保つわけではありません。PHP の組み込みサーバーは講座4でも今回も、毎回 Connection: close で応答し、リクエストごとに Accepted / Closing をログに出していました。この効果を見せるのには使えません。

実測:使い回しと毎回接続

ループバック、1つの curl プロセスで200リクエスト(URL グロブ r[1-200])。3セット中1セット目、すべて同じ Python サーバー、curl 起動1回分を含む合計時間です。

モード 新規接続数 合計
平文 HTTP・使い回し 1 75 ms
平文 HTTP・Connection: close 200 87 ms
HTTPS・使い回し 1 85 ms
HTTPS・Connection: close 200 366 ms
平文 HTTP・curl を200回起動(シェルのループ) 200 1,976 ms

残りのセットも同じ傾向でした(HTTP 83/68 対 86/80 ms、HTTPS 81/77 対 361/356 ms、プロセス200回 1,980 と 2,060 ms 対 1プロセス 79 と 74 ms)。ループバックでは TCP 接続はほぼ無料、TLS ハンドシェイクは CPU で約 1.4 ms、プロセス起動は約 10 ms です。

インターネット越しに、1つの curl プロセスで example.com へ5回送りました。

モード 1回目 2〜5回目 合計
使い回し(HTTP/2) 104 ms 22, 23, 21, 18 ms 0.19 秒
Connection: close(HTTP/1.1) 95 ms 120, 81, 76, 79 ms 0.45 秒

新しい接続はそのたびに DNS・TCP・TLS を払い直します。講座5の中央値(2.6 + 13.5 + 20.7 ms)で約 37 ms、スマホの回線ならもっとかかります。

ループバックで200リクエストを送った合計時間の棒グラフ。平文 HTTP・接続1本の使い回しは 75 ms、HTTPS・使い回しは 85 ms、平文 HTTP・毎回新規接続は 87 ms、HTTPS・毎回新規接続は 366 ms、平文 HTTP・毎回 curl プロセスを起動は 1,976 ms。
ローカルの Python サーバーへの200リクエストの合計時間(3回中1回目、Apple M1 Pro、curl 8.7.1)。接続の使い回しが最も安く、リクエストごとに curl を起動するのが飛び抜けて高い。

よくある失敗:curl のシェルループと、1つの URL にしか効かない -o

やりがちなこと: for i in …; do curl …; done で API を200回呼ぶ。実際に起きること: 毎回新しいプロセスが起動し、何も使い回せません。ループバックで 1,976 ms 対 75 ms、インターネット越しなら毎回フルの TLS ハンドシェイクも加わります。対策: 1つの curl に多数の URL を渡す(r[1-200] のようなグロブ、URL 一覧の --config、--parallel)か、プログラムでは HTTP クライアントのオブジェクトを1つだけ使います。

計測中にもう1つ罠を踏みました。curl -s -o /dev/null -w … URL URL URL URL URL と書いたら端末が HTML で埋まりました。-o は 1つの URL にしか効かず、残り4つの本文は標準出力に出たのです。URL ごとに -o を書くか、--output-dir と -O を使います(グロブの URL は1つの引数なので、-o 1つで全体に効きます)。

圧縮:gzip を実測する

圧縮は、テキストを送る前に小さくする仕組みです。クライアントは Accept-Encoding: gzip, br, … で展開できる形式を伝え、サーバーは Content-Encoding: gzip で答え、キャッシュが2つの版を混同しないよう Vary: Accept-Encoding を付けます。Python の gzip モジュールで3種類の入力を各50回圧縮し、中央値をとりました。

import gzip, random, time, statistics
random.seed(7)
data = {
    "products.json (179,500 B)": open("products.json", "rb").read(),
    "tiny JSON": b'{"ok":true,"id":42}',
    "random bytes (like JPEG)": random.randbytes(100_000),
}
REP = 50
for name, raw in data.items():
    print(f"{name}: raw={len(raw)}")
    for lvl in (1, 6, 9):
        ts = []
        for _ in range(REP):
            t = time.perf_counter(); out = gzip.compress(raw, compresslevel=lvl, mtime=0); ts.append(time.perf_counter() - t)
        td = []
        for _ in range(REP):
            t = time.perf_counter(); gzip.decompress(out); td.append(time.perf_counter() - t)
        print(f"  level {lvl}: {len(out):>7} B ({len(out)/len(raw)*100:5.1f}%)  "
              f"compress {statistics.median(ts)*1000:.3f} ms  decompress {statistics.median(td)*1000:.3f} ms")
products.json (179,500 B): raw=179500
  level 1:   26052 B ( 14.5%)  compress 0.505 ms  decompress 0.176 ms
  level 6:   19711 B ( 11.0%)  compress 1.401 ms  decompress 0.139 ms
  level 9:   18758 B ( 10.5%)  compress 7.087 ms  decompress 0.142 ms
tiny JSON: raw=19
  level 1:      39 B (205.3%)  compress 0.008 ms  decompress 0.011 ms
  …
random bytes (like JPEG): raw=100000
  level 1:  100053 B (100.1%)  compress 1.624 ms  decompress 0.040 ms
  …

分かることは3つです。レベル9はレベル6の5倍の時間をかけて、バイト数は 5% しか減りません。展開はどのレベルでも安い。そして圧縮は、小さな応答(19 → 39)とすでに圧縮されたデータ(ランダムバイトは JPEG・PNG・MP4・ZIP の代わり)を大きくします。だからサーバーは普通、約 1 KB 未満の応答とバイナリのメディアを圧縮しません。2回目の実行でもサイズは同じ、時間の差は 0.07 ms 以内でした。

サイズの棒グラフ。元の JSON は 179,500 バイト。gzip レベル1で 26,052 バイト、レベル6で 19,711 バイト、レベル9で 18,758 バイト。
179,500 バイトの products.json を gzip レベル 1・6・9 で圧縮した後のサイズ(Python gzip、zlib 1.2.12)。圧縮時間の中央値は 0.5・1.4・7.1 ms。

当サイトのトップページは、--compressed なしで 8,912 バイト、ありで 3,450 バイトでした(2回のリクエスト)。

php -S で gzip を送る、そして生バイトの罠

要求されたときだけ圧縮し、CORS にも答える(次の節)小さな API です。products.json の隣に api.php として保存し、php -S 127.0.0.1:18170 api.php で起動します。

<?php
// php -S 127.0.0.1:18170 api.php
$path   = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$method = $_SERVER['REQUEST_METHOD'];
$origin = $_SERVER['HTTP_ORIGIN'] ?? null;
$allowed = ['https://app.example'];

if ($path !== '/api/products') { http_response_code(404); exit; }

// CORS: only for allowed origins, and tell caches the answer depends on Origin
header('Vary: Origin, Accept-Encoding');
if ($origin !== null && in_array($origin, $allowed, true)) {
    header("Access-Control-Allow-Origin: $origin");
}
if ($method === 'OPTIONS') {                      // preflight
    header('Access-Control-Allow-Methods: GET, POST, PUT');
    header('Access-Control-Allow-Headers: Content-Type, Authorization');
    header('Access-Control-Max-Age: 600');
    http_response_code(204);
    exit;
}
error_log("$method $path origin=" . ($origin ?? '-'));   // the server DID run

$body = file_get_contents(__DIR__ . '/products.json');
header('Content-Type: application/json');
if (str_contains($_SERVER['HTTP_ACCEPT_ENCODING'] ?? '', 'gzip')) {
    $body = gzencode($body, 6);
    header('Content-Encoding: gzip');
}
header('Content-Length: ' . strlen($body));
echo $body;
$ curl -s -o /dev/null -w 'plain:      %{http_code} %{size_download} B\n' http://127.0.0.1:18170/api/products
plain:      200 179500 B
$ curl -s --compressed -o /dev/null -w 'compressed: %{http_code} %{size_download} B on the wire\n' http://127.0.0.1:18170/api/products
compressed: 200 19730 B on the wire
$ curl -s --compressed http://127.0.0.1:18170/api/products | wc -c
  179500
$ curl -s -H 'Accept-Encoding: gzip' http://127.0.0.1:18170/api/products | head -c 20 | xxd
00000000: 1f8b 0800 0000 0000 0013 addd ddae 75bd  ..............u.
00000010: 719e e97d                                q..}

最後のコマンドが罠です。ヘッダーを手で付けると「gzip を展開できます」とサーバーに伝わりますが、curl は展開してくれないので、生の gzip(マジックバイト 1f 8b で始まる)が出てきます。ヘッダー送信と展開の両方をする --compressed を使いましょう。アプリの HTTP ライブラリにも「gzip を要求する」と「gzip を展開する」が別々になっているものがあります。

速くなったのでしょうか。各20回の中央値です。

回線 非圧縮(179,500 B) gzip(19,730 B)
ループバック 0.71 ms 2.24 ms
curl --limit-rate 1M 124.88 ms(83〜164) 2.30 ms

ループバックでは圧縮で遅くなりました。運ぶのにコストのかからないバイトのために CPU を 1.5 ms 使ったからです。遅い回線では圧勝です。ただし正直に書くと、curl の帯域制限は 19.7 KB の応答を一気に通してしまったので、1 MB/s の gzip の行は楽観的すぎます。本当に 1 MB/s なら約 19 ms かかるはずです。非圧縮のほうも制限は不正確で、同じ 179,500 バイトが講座4の構成では中央値 173 ms でした。静的ファイルは、リクエストのたびではなくデプロイ時に一度だけ圧縮しておくのが最善です。

HTTP/1.1・HTTP/2・HTTP/3

HTTP/2 は HTTP の意味(メソッド、ステータスコード、ヘッダー)はそのままに、運び方を変えたものです。バイナリのフレーム、1本の接続上で複数リクエストを同時に流す多重化、ヘッダー圧縮(HPACK)。講座5で見たように、TLS ハンドシェイクの中の ALPN で決まります(ALPN: server accepted h2)。example.com に1回ずつ送りました。

--http1.1 -> HTTP/1.1 header_bytes=347 total=0.097737
--http2 -> HTTP/2 header_bytes=291 total=0.069453

同じ応答ヘッダーが HTTP/2 では 56 バイト少なくなりました。1回ずつの計測なので速度については何も言えません。サーバーが HTTP/1.1 しか話さないときの curl の動きです。

$ curl -sv --http2 http://127.0.0.1:18162/      # 平文 HTTP:curl がアップグレードを求める
> GET / HTTP/1.1
> Connection: Upgrade, HTTP2-Settings
> Upgrade: h2c
> HTTP2-Settings: AAMAAABkAAQAoAAAAAIAAAAA
< HTTP/1.1 200 OK
$ curl -sS --http2-prior-knowledge http://127.0.0.1:18162/
curl: (16) Remote peer returned unexpected data while we expected SETTINGS frame.  Perhaps, peer does not support HTTP/2 properly.
$ curl -sS --http3 https://example.com/ -o /dev/null
curl: option --http3: the installed libcurl version doesn't support this

Python のサーバーはアップグレードを無視して HTTP/1.1 で答えました。これは仕様上許されています。--http2-prior-knowledge は交渉を省くので、終了コード 16 で失敗します。HTTP/3 は UDP 上の QUIC で動きます。example.com も当サイトも alt-svc: h3=":443" で対応を告知していますが、この curl では使えないため試していません。

実務で HTTP/2 が変えること(ブラウザの挙動で、ここでは実行していません):ブラウザは HTTP/1.1 では1ホストあたり最大6本の接続を開き、残りは待たせていました。昔のサイトがファイルを複数ドメインに分散し、何でも1つにまとめていたのはそのためです。HTTP/2 では1本の接続ですべて運べるので、ドメイン分散は逆効果になり、まとめる必要も減ります。ただし TCP のヘッドオブラインブロッキングは残ります。パケットが1つ落ちると、その接続上の全ストリームが止まります。これを解決するのが HTTP/3 です。

php -S で CORS プリフライトを見る

https://app.example のページが https://api.example に PUT や JSON の Content-Type で送ろうとすると、ブラウザはまずプリフライト、つまり許可を求める OPTIONS リクエストを送ります。curl で同じリクエストを手で送れます。

$ curl -si -X OPTIONS -H 'Origin: https://app.example' \
    -H 'Access-Control-Request-Method: PUT' \
    -H 'Access-Control-Request-Headers: content-type' http://127.0.0.1:18170/api/products
HTTP/1.1 204 No Content
Host: 127.0.0.1:18170
Date: Thu, 12 Nov 2026 14:31:48 GMT
Connection: close
X-Powered-By: PHP/8.5.6
Vary: Origin, Accept-Encoding
Access-Control-Allow-Origin: https://app.example
Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 600
Content-type: text/html; charset=UTF-8

ブラウザはこれらのヘッダーと送りたい内容を照らし合わせ、答えを最大 Max-Age 秒キャッシュし、それから本物の PUT を送ります。次に、許可されていないオリジンからのリクエストです。

$ curl -si -H 'Origin: https://evil.example' http://127.0.0.1:18170/api/products | head -8
HTTP/1.1 200 OK
…
Vary: Origin, Accept-Encoding
Content-Type: application/json
Content-Length: 179500

サーバーのログはこうです。

[Thu Nov 12 14:31:48 2026] GET /api/products origin=https://evil.example

リクエストは実行され、本文も全部送られています。違いは Access-Control-Allow-Origin がないことだけで、そのせいでブラウザがページの JavaScript から応答を隠します。curl やスクリプトやアプリは気にしません。CORS について一番大事なのはこの点です。

フロー図。https://app.example のページが API に JSON 本文付きの PUT を送ろうとする。ブラウザはまず Origin と Access-Control-Request-Method を付けた OPTIONS を送る。サーバーは Access-Control-Allow-Origin、Allow-Methods、Allow-Headers、Max-Age 付きの 204 を返す。ブラウザは自分のオリジンとメソッドが許可されているか確認し、許可されていれば本物の PUT を送る。許可されていなければ PUT は送られない。単純な GET にはプリフライトがないので、許可されないオリジンからの GET もサーバーに届いて実行されたことがログで確認でき、ブラウザはページから応答を隠すだけである。
CORS のプリフライト。サーバー側は php -S と curl で実行し、ブラウザ側の判定は Fetch Standard からの説明で実行していない。許可されないオリジンのリクエストもサーバーには届く。

CORS のよくある失敗:

  • Access-Control-Allow-Origin: * と Cookie や Authorization の併用。ブラウザはワイルドカードでは資格情報付きのリクエストを拒否します。許可する1つのオリジンを返します。
  • 届いた Origin を何でもそのまま返す。どのサイトもログイン中の応答を読めるようになります。上のように許可リストを使います。
  • Origin で応答を変えているのに Vary: Origin がない。CDN があるオリジン向けのヘッダーを別のオリジンに返すかもしれません。
  • プリフライトでフォームからの POST を止められると思う。単純なリクエスト(フォーム送信)にはプリフライトがありません。CSRF トークンを使います。PHP のセキュリティ:XSS・CSRF・セッション を参照してください。

判断表

状況 すること 理由(今回の数字)
スクリプトが同じ API を何度も呼ぶ クライアント1つ/curl 1回に多数の URL 200プロセス 1,976 ms 対 75 ms
小さな呼び出しが多い HTTPS API 接続を保ち、HTTP/2 を使う 新規 TLS 接続 76〜120 ms 対 使い回し 18〜23 ms
1 KB を超える JSON/HTML/CSS/JS gzip レベル6(またはデプロイ時に事前圧縮) 179,500 → 19,711 B、1.4 ms
画像・動画・ZIP・小さな応答 圧縮しない 100,000 → 100,053 B、19 → 39 B
別オリジンのページから API を呼ぶ 許可リスト付きのプリフライト応答と Vary: Origin CORS はページに何を読ませるかの制御だけ
望まない呼び出し元を止めたい 認証、CSRF トークン、レート制限 許可外オリジンの GET もサーバーで実行された

正直な限界・バージョンの注記

  • 実行したもの: curl 8.7.1(macOS 同梱、LibreSSL 3.3.6、nghttp2 1.68.1、zlib 1.2.12)、Python 3.9.6、PHP 8.5.6 組み込みサーバー、Apple M1 Pro。ループバックの時間には curl の起動1回分を含みます。--limit-rate は帯域を模すだけです。
  • 実行していないもの: ブラウザ全般(CORS の判定、プリフライトのキャッシュ、6本の接続上限、HTTP/2 の優先度制御)、HTTP/3、Brotli(br、RFC 7932)や zstd の圧縮、サーバー側の圧縮モジュール(Apache/LiteSpeed/nginx)。
  • 仕様: HTTP/1.1 のメッセージ形式は RFC 9112、HTTP の意味論は RFC 9110(どちらも2022年6月)。HTTP/2 は RFC 9113(2022年、2015年の RFC 7540 を置き換え)、HTTP/3 は RFC 9114(2022年)、gzip は RFC 1952。CORS は RFC ではなく WHATWG の Fetch Standard で定義されています。
  • リモートの計測は、1つのネットワークから1日に5回ずつ送っただけです。傾向を示すもので、ベンチマークではありません。

練習問題

1. 夜間のスクリプトが 3,000 件の ID について for id in $(cat ids.txt); do curl -s "https://api.example/items/$id" -o "out/$id.json"; done を実行しています。curl が接続を使い回すように書き直し、今回のどの計測が効果を予想させるか答えてください。

2. API が Access-Control-Allow-Origin: * を返していて、フロントエンドからセッション Cookie を送る必要が出てきました。ブラウザのコンソールに CORS エラーが出ます。応答ヘッダーをどう変えますか。

解答

1. 1行に URL と出力先を書いた設定ファイルを作り、curl を1回だけ実行します。

while read id; do
  printf 'url = "https://api.example/items/%s"\noutput = "out/%s.json"\n' "$id" "$id"
done < ids.txt > urls.cfg
curl -s --config urls.cfg

ローカルサーバーと5件の ID でこの形を確認したところ、-w '%{num_connects} ' は 1 0 0 0 0 と表示され、5件すべてが1本の接続でした。1プロセスなら接続を使い回せます(--parallel を付ければ HTTP/2 で同時に複数送ることもできます)。プロセス分の効果はループバックの計測(200件で 1,976 ms 対 75 ms)、接続分の効果は example.com の計測(1件あたり 76〜120 ms 対 18〜23 ms)が予想させます。

2. 許可する特定のオリジンを返し、資格情報を許可します。Access-Control-Allow-Origin: https://app.example、Access-Control-Allow-Credentials: true、Vary: Origin です。資格情報付きではワイルドカードは受け付けられません。Cookie 自体も Secure で、クロスサイトのリクエストなら SameSite=None が必要です(講座3参照)。

次へ

よくある質問

keep-alive は、リクエストごとに接続するのと比べてどれくらい速いですか?

新しい接続のコスト次第です。ループバックで平文 HTTP を200回送ると、使い回し 75〜83 ms、毎回接続 80〜87 ms。HTTPS では 77〜85 ms 対 356〜366 ms でした。インターネット越しの example.com では、使い回した2回目以降が 18〜23 ms、毎回接続は 76〜120 ms。最も遅いのはリクエストごとに curl を起動する方法で、200回で約2秒かかりました。

gzip の圧縮レベルはいくつにすべきですか?

一般的な既定値のレベル6がバランスのよい選択です。179,500 バイトの JSON で、レベル1は 26,052 バイト(0.5 ms)、レベル6は 19,711 バイト(1.4 ms)、レベル9は 18,758 バイト(7.1 ms)でした。小さな応答や圧縮済みファイルは圧縮しないこと。19 バイトの JSON は 39 バイトに、10万バイトのランダムデータは 100,053 バイトに増えました。

CORS は他サイトから API を守ってくれますか?

守りません。CORS は「別オリジンのページに応答を読ませてよいか」をブラウザに伝える仕組みです。リクエスト自体はサーバーに届いて実行されます。php -S のログには https://evil.example からの GET が記録され、Access-Control-Allow-Origin がないだけで 200 の本文が丸ごと返っていました。API の保護は認証と CSRF 対策で行います。

  • #HTTP
  • #上級
  • #パフォーマンス
  • #keep-alive
  • #gzip
  • #HTTP/2
  • #CORS
  • #curl

t.laboratory

実際に作って運用しているアプリ・サーバー・サービスで起きた問題と、その解決に使ったコードを記録する技術ブログです。

運営者情報 · アプリ

↑