ゆっくり復習するHTTPー WireSharkで確認するHTTP/1.1〜HTTP/3
目的
Webアプリケーションのフロントやバックエンドの開発でHTTPを使用したプログラムを実装している人は多いかと思います。
しかしながら、実際、そのデータがどう流れているかを具体的に意識する機会は少ないです。
本記事の目的はHTTP/1.1→HTTP/2→HTTP/3で、送受信されるデータがどのように変わったかを確認することを目的とします。
以下のケースで、データがどのように流れているかをWiresharkを使用して確認します。
- HTTP/1.1 によるブラウザとサーバー間のデータ
- TLS1.3+HTTP/1.1 によるブラウザとサーバー間のデータ
- HTTP/2 によるブラウザとサーバー間のデータ
- HTTP/3 によるブラウザとサーバー間のデータ
今回は再送時やエラー時の検証などは範囲外とし、各ケースにおいて簡単なデータの送受信でどのような違いがあるかを確認するのにとどめます。
また、実験方法と結果について記載はしますが、環境やバージョンによって必ずしも完全に一致する結果にはなりません。
本記事は、次のような読者を想定しています。
- Webアプリケーションのフロントエンド・バックエンドを「なんとなく作っている」が、実際どんなデータが流れているかを確認したい人
- HTTP/2, HTTP/3でどのような変更があったかを、実際のデータをみながら確認したい人
また、以下の程度の知識を前提とします。
- HTTP の基本的な仕組み(リクエスト/レスポンス、ヘッダーなど)
- Wiresharkの使用経験
- 何らかのプログラミング言語で簡単なサンプルコードを書いた経験(Node.jsとgoが望ましい)
実験用フロントエンドのコード
以下のHTMLとjavascriptを使用して、ブラウザとサーバーの通信を確認します。
javascriptは5個のREST APIをサーバーに対して実行します。
動作するブラウザはChrome 143.0.7499.41とします。
実験コード
index.html
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<title>Axios keep-alive / 接続検証</title>
</head>
<body>
<h1>Axios + ブラウザの接続再利用検証</h1>
<button id="button-seq">順番に 5 リクエスト</button>
<button id="button-par">並列に 5 リクエスト</button>
<pre id="log" style="border:1px solid #ccc; padding:8px; max-height:400px; overflow:auto;"></pre>
<!-- axios CDN(楽をする) -->
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script src="/client.js"></script>
</body>
</html>
client.js
// client.js
const logEl = document.getElementById("log");
const btnSeq = document.getElementById("button-seq");
const btnPar = document.getElementById("button-par");
function log(msg) {
console.log(msg);
logEl.textContent += msg + "\n";
logEl.scrollTop = logEl.scrollHeight;
}
// axios インスタンス(設定を変えたいならここで)
const api = axios.create({
baseURL: "/",
// withCredentials: false,
// ブラウザ版 axios は keep-alive を直接制御できない(ブラウザ任せ)
});
async function callOnce(i) {
const res = await api.get(`/api/test?i=${i}`);
const data = res.data;
log(
`i=${i} -> ${JSON.stringify(data)}`
);
}
// 順番に 5 回呼ぶ(直列)
btnSeq.addEventListener("click", async () => {
log("---- 順番に 5 リクエスト ----");
for (let i = 0; i < 5; i++) {
await callOnce(i);
}
});
// 並列に 5 回呼ぶ
btnPar.addEventListener("click", async () => {
log("---- 並列に 5 リクエスト ----");
await Promise.all(
Array.from({ length: 5 }, (_, i) => {
return callOnce(i);
})
);
});
HTTP/1.1の確認
平文でHTTP/1.1通信を行った場合に、どのようなデータが流れているかを検証します。
HTTP/1.1の詳しいメッセージ構文、メッセージ解析、接続管理などは以下を参照してください。
RFC 9112 – HTTP/1.1
HTTP/1.1実験用のサーバー作成
Node.jsの環境を構築後以下のサーバーを動かす。
HTTP/1.1用サーバーコード
apiのレスポンスについてランダムで遅延を起こしています。
// http1-server.js
'use strict';
const fs = require('fs');
const http = require('http');
const path = require('path');
let socketCounter = 0;
let requestCounter = 0;
const server = http.createServer((req, res) => {
console.log('--- HTTP/1.1 request ---');
console.log('url :', req.url);
console.log('method :', req.method);
console.log('httpVersion:', req.httpVersion);
console.log('headers :', req.headers);
// 静的ファイル(ブラウザ用)を返す部分
if (req.url === "/" || req.url === "/index.html") {
const html = fs.readFileSync(path.join(__dirname, "index.html"), "utf8");
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(html);
return;
}
if (req.url.startsWith("/client.js")) {
const js = fs.readFileSync(path.join(__dirname, "client.js"), "utf8");
res.writeHead(200, { "Content-Type": "text/javascript; charset=utf-8" });
res.end(js);
return;
}
// ここから API 部分(axios が叩く先)
if (req.url.startsWith("/api")) {
requestCounter++;
const thisReqId = requestCounter;
const socketId = req.socket.__id; // connection イベントで振った ID
console.log(
`REQ#${thisReqId} on SOCKET#${socketId} ${req.method} ${req.url}`
);
// あえて少し待つと、並列リクエストの挙動が分かりやすい
const delay = 200 + Math.random() * 800;
setTimeout(() => {
const body = JSON.stringify({
requestId: thisReqId,
socketId,
url: req.url,
connectionHeader: req.headers["connection"] || null,
now: new Date().toISOString(),
});
res.writeHead(200, {
"Content-Type": "application/json; charset=utf-8",
"Content-Length": Buffer.byteLength(body),
});
res.end(body);
}, delay);
return;
}
// その他は 404
res.writeHead(404);
res.end("Not Found");
});
// 新しい TCP 接続ごとに呼ばれる
server.on("connection", (socket) => {
socketCounter++;
socket.__id = socketCounter;
console.log(
`NEW SOCKET#${socket.__id} from ${socket.remoteAddress}:${socket.remotePort}`
);
socket.on("end", () => {
console.log(`SOCKET#${socket.__id} end`);
});
socket.on("close", (hadError) => {
console.log(`SOCKET#${socket.__id} closed`);
});
socket.on("error", (err) => {
console.log(`SOCKET#${socket.__id} error`, err);
});
});
const PORT = 8080;
server.listen(PORT, () => {
console.log(`HTTP/1.1 server listening on http://localhost:${PORT}`);
});
実験
実験方法は以下のとおりです。
- HTTP/1.1用のサーバーコードを起動
node http1-server.js
- Wiresharkを起動し、Loopback: lo0をキャプチャ
-
tcp.port == 8080 || udp.port == 8080でフィルタをかける
- Chromeを起動
- 開発者ツールのNetworkタブを開く。この際、ヘッダを右クリックしてProtocolを表示する
-
http://localhost:8080/index.htmlにアクセス - 「並列に5リクエスト」ボタンを押す
Chrome開発者ツールの結果

localhostとの通信のProtocolがhttp/1.1になっていることが確認できます。
なお、axios.min.jsは cdn.jsdelivr.net との通信のためh2ーつまりhttp/2になります。
Wiresharkの結果



まず、TCPレベルの通信がおおいので(tcp.port == 8080 || udp.port == 8080) && httpフィルタをかけて確認しましょう。

これにより、複数のポートが使われていることがわかります。
- 56635: GET /index.html, /client.js, /favicon.ico
- 56636: GET /.well-known/appspecific/com.chrome.devtools.json
- 56740: GET /api/test?i=0 HTTP/1.1
- 56741: GET /api/test?i=1 HTTP/1.1
- 56742: GET /api/test?i=2 HTTP/1.1
- 56743: GET /api/test?i=3 HTTP/1.1
- 56744: GET /api/test?i=4 HTTP/1.1
つまり、HTTP/1.1で接続した場合、ブラウザからは複数のソケットが作成されて通信が行われています。

chromeの場合はホストあたり最大6接続おこなわれます。
なお、上記の例で7接続しているようにみえますが、56635と56636は56740が接続される前に通信が終了していることが確認できます。

では次に、それぞれソケットの詳細を確認してみましょう。ここではポート:56740で行ったGET /api/test?i=0リクエストでどのようなやり取りをしたかを確認します。

図でまとめると以下のような流れになります。

まずTCPのソケットを接続するために以下の電文が流れていることが確認できます。
- クライアントから[SYN]
- サーバーから[SYN, ACK]
- クライアントから[ACK]
これが three-way (or three message) handshake (3WHS) と呼ばれるものになります。[1]
接続が完了した後に、サーバー側が[TCP Window Update]+[ACK]でサーバ側の受信ウィンドウの通知を行います。[2]
ここでようやく、ブラウザ側がGET /api/test?i=0のリクエストがテキストとして送られることが確認できます。

サーバーはリクエストを受信をすると、その受信を確認するACKを送信します。
サーバーの処理が終わって、クライアントにレスポンスを返します。これもTCPの上にテキストとして送信されていることが確認できます。

クライアントはレスポンスを受信すると、その受信を確認するACKを送信します。
通信が終わってしばらくするとサーバー側から[FIN, ACK]が送信されてソケットが終了します。
TLS1.3+HTTP/1.1の確認
HTTP/2, HTTP/3について確認をする前にTLS1.3で暗号化をした場合にどうなるかを確認します。
HTTP/2についてはプロトコルの仕様的には暗号化は不要ですが、一般的なブラウザから実験する場合は暗号化が必須となります[3]。
この章では、自己署名証明書を用いたサーバーを作成してHTTPSの電文をWiresharkで確認します。
TLS1.3対応のサーバーの作成
まず、opensslを使用してサーバーの秘密鍵ファイルとサーバー証明書を作成します。
# カレントディレクトリに server.key / server.crt を作成
openssl req -x509 -newkey rsa:2048 -nodes \
-keyout server.key -out server.crt \
-subj "/CN=localhost" -days 365
次に作成したserver.crtからSPKI Fingerprint (Base64(SHA-256(SPKI DER)))を出力します。
openssl x509 -pubkey -noout -in server.crt \
| openssl pkey -pubin -outform der \
| openssl dgst -sha256 -binary \
| openssl enc -base64
ここで出力されたBase64の文字列はChromeなどのブラウザの起動オプション--ignore-certificate-errors-spki-listにあたえることで、自己署名証明書のサーバーのテストが容易になります。
次に作成したserver.key, server.crtを使用して、Node.jsでHTTPSサーバーを作成します。
TLS1.3対応のサーバーのコード
httpモジュールをhttpsモジュールに置き換えます。
// https1-server.js
'use strict';
const fs = require('fs');
const https = require('https'); // ★ http → https
const path = require('path');
let socketCounter = 0;
let requestCounter = 0;
// ★ 証明書と秘密鍵を読み込む
const options = {
key: fs.readFileSync(path.join(__dirname, 'server.key')),
cert: fs.readFileSync(path.join(__dirname, 'server.crt')),
};
// createSecureServer に差し替え、あとはそのまま
const server = https.createServer(options, (req, res) => {
console.log('--- HTTPS (HTTP/1.1) request ---');
console.log('url :', req.url);
console.log('method :', req.method);
console.log('httpVersion:', req.httpVersion);
console.log('headers :', req.headers);
if (req.url === "/" || req.url === "/index.html") {
const html = fs.readFileSync(path.join(__dirname, "index.html"), "utf8");
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(html);
return;
}
if (req.url.startsWith("/client.js")) {
const js = fs.readFileSync(path.join(__dirname, "client.js"), "utf8");
res.writeHead(200, { "Content-Type": "text/javascript; charset=utf-8" });
res.end(js);
return;
}
if (req.url.startsWith("/api")) {
requestCounter++;
const thisReqId = requestCounter;
const socketId = req.socket.__id;
console.log(
`REQ#${thisReqId} on SOCKET#${socketId} ${req.method} ${req.url}`
);
const delay = 200 + Math.random() * 800;
setTimeout(() => {
const body = JSON.stringify({
requestId: thisReqId,
socketId,
url: req.url,
connectionHeader: req.headers["connection"] || null,
now: new Date().toISOString(),
});
res.writeHead(200, {
"Content-Type": "application/json; charset=utf-8",
"Content-Length": Buffer.byteLength(body),
});
res.end(body);
}, delay);
return;
}
res.writeHead(404);
res.end("Not Found");
});
// connection ハンドラはそのまま使える
server.on("connection", (socket) => {
socketCounter++;
socket.__id = socketCounter;
console.log(
`NEW SOCKET#${socket.__id} from ${socket.remoteAddress}:${socket.remotePort}`
);
socket.on("end", () => {
console.log(`SOCKET#${socket.__id} end`);
});
socket.on("close", (hadError) => {
console.log(`SOCKET#${socket.__id} closed`);
});
socket.on("error", (err) => {
console.log(`SOCKET#${socket.__id} error`, err);
});
});
const PORT = 8443;
server.listen(PORT, () => {
console.log(`HTTPS (HTTP/1.1) server listening on https://localhost:${PORT}`);
});
暗号化された通信をWiresharkで閲覧する方法
ブラウザなどがSSLKEYLOGFILE形式でログを出力し、Wiresharkがそれを参照することで暗号化された通信を解析することが可能になります。
ブラウザ側起動方法
まず、環境変数 SSLKEYLOGFILEにファイルのパスを指定します。
export SSLKEYLOGFILE=/work/techblog/http2/tls_keys.log
次に、以前に出力したSPKI Fingerprint (Base64(SHA-256(SPKI DER)))の指定を行いChromeを起動します。
# 5ZXuDPc8AV/4PslNz7Th+6ocVtv7Zya2cy5HMSwBaA4=がSPKI
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
--ignore-certificate-errors-spki-list="5ZXuDPc8AV/4PslNz7Th+6ocVtv7Zya2cy5HMSwBaA4=" \
--user-data-dir=/tmp/temp-chrome
Chrome起動後にブラウジングするとtls_keys.logに以下のような項目が追記されていきます。
CLIENT_HANDSHAKE_TRAFFIC_SECRET 5c6c19a279e98ce289cfccf11fb92a17abb21b406b00c24b82858542a36a1ba5 9f01c9660a1e5a8844655bf0f581255493598c3bf17ad472ed11786c1ff38529
SERVER_HANDSHAKE_TRAFFIC_SECRET 5c6c19a279e98ce289cfccf11fb92a17abb21b406b00c24b82858542a36a1ba5 07a2fe5d4952970487dbbea9b32c820ab7a37386e3988a17cef4042998979cd0
CLIENT_HANDSHAKE_TRAFFIC_SECRET 8ff14b8b00ee17aedc918948ece2f2044e72f60f9f6d94d40d3678ff0135cf45 04282d3f59f5ac36c757879af0cefa771e632db8c38222bc46c7740fd098101e
SERVER_HANDSHAKE_TRAFFIC_SECRET 8ff14b8b00ee17aedc918948ece2f2044e72f60f9f6d94d40d3678ff0135cf45 86bfb053e77506cc9fc770ef338206169f88bab4ad9a0036890071fecaae886e
CLIENT_HANDSHAKE_TRAFFIC_SECRET cd96bf36f7e812458f1dd65a282462f852c0cce5a2724673842a4d91a6f0db30 76ed3e784da27cc290fd684ffa9d0996b033b52761ec0cb6bf9c4616ed8a0b7c
SERVER_HANDSHAKE_TRAFFIC_SECRET cd96bf36f7e812458f1dd65a282462f852c0cce5a2724673842a4d91a6f0db30 152654e664bdf545c95413bdf61a407d194aa9f0a3aebcab1182181a3ab187a3
CLIENT_TRAFFIC_SECRET_0 8ff14b8b00ee17aedc918948ece2f2044e72f60f9f6d94d40d3678ff0135cf45 0bad06ad316c9f7d720021f75e3cd0a9735207063cc315ff9d92a5c48c4701c3
SERVER_TRAFFIC_SECRET_0 8ff14b8b00ee17aedc918948ece2f2044e72f60f9f6d94d40d3678ff0135cf45 46b80f7a12e303c5bfe8b6bc840fc60de136c4d8896971c0ffa879ff458aec26
EXPORTER_SECRET 8ff14b8b00ee17aedc918948ece2f2044e72f60f9f6d94d40d3678ff0135cf45 9c06904b2e8326ff564bf64e6e8aca89a254bdb746c4d8975926a8846bbbb44b
CLIENT_TRAFFIC_SECRET_0 5c6c19a279e98ce289cfccf11fb92a17abb21b406b00c24b82858542a36a1ba5 276d9749c7148e701e2cb218bc5068235cb30021fcc45a35c4a0967a2400e75d
SERVER_TRAFFIC_SECRET_0 5c6c19a279e98ce289cfccf11fb92a17abb21b406b00c24b82858542a36a1ba5 d158ec1ade0903db23acced3efa340e6f6d06dccb684c756f1bdce5f368e26dc
EXPORTER_SECRET 5c6c19a279e98ce289cfccf11fb92a17abb21b406b00c24b82858542a36a1ba5 df212635631d0b7ad96ceced2981d19db0213650f990f3354ba24b942d6387a2
... 以下略
Wiresharkで確認する方法
メニューのWireshark → Preferencesを選択

Preferencesダイアログの左ツリーよりProtocolsを選択

TLSを選択後、(Pre-)Master-Secret log filename or RSA keys list にtls_keys.logを指定

以後、Wiresharkでキャプチャを行うと暗号化されているはずのHTTPの確認が可能となる。

実験
実験方法は以下のとおりです。
- HTTPS用のサーバーコードを起動
node https1-server.js
- Wiresharkを起動し、Loopback: lo0をキャプチャ
-
tcp.port == 8443 || udp.port == 8443でフィルタをかける - SSLKEYLOGFILEを環境変数としてChromeを起動
export SSLKEYLOGFILE=/work/techblog/http2/tls_keys.log
# 5ZXuDPc8AV/4PslNz7Th+6ocVtv7Zya2cy5HMSwBaA4=がSPKI
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
--ignore-certificate-errors-spki-list="5ZXuDPc8AV/4PslNz7Th+6ocVtv7Zya2cy5HMSwBaA4=" \
--user-data-dir=/tmp/temp-chrome
- 開発者ツールのNetworkタブを開く。この際、ヘッダを右クリックしてProtocolを表示する
-
https://localhost:8443/index.htmlにアクセス - 「並列に5リクエスト」ボタンを押す
Chrome開発者ツールの結果
開発者ツールのNetworkレベルではHTTP/1.1の時と結果に違いはありません。

Wiresharkの結果
同じ操作ですが、より煩雑になっていることが確認できます。




使用しているポートの数的には違いがありません。

しかし、各ポートでの通信量が増えていることが確認できます。ここではポート:50918とポート:50919で、どのようなやり取りをしたかを確認します。


この図でまとめると以下のような流れになります。

ブラウザからのClientHello
three-way (or three message) handshake (3WHS) と [TCP Window Update]+[ACK]までは平文の時と同じです。
HTTPSで接続した場合はTLSv1.3のプロトコルでブラウザからClientHelloが送信されます。

ClientHelloではクライアントから通信に使用する設定の希望を通知します。
例として以下について確認してみます。
- key_share 拡張において、鍵交換に使用するクライアント側の公開鍵を送信している
- ALPN 拡張ではブラウザがサポートするアプリケーションプロトコルとして h2(http/2)とhttp/1.1 が提示されている

サーバーからのServerHello~Finished
ClientHelloを受け取ったサーバーはServerHello、ChangeCipherSpec、EncryptedExtensions、Certificate、CertificateVerify、Finishをクライアントに送信します。

二回目以降の通信などでは、Certificate、CertificateVerifyは送信されないケースもあります。
ServerHello
ServerHelloではクライアントから通知された通信に使用する設定の採用結果を通知します。
たとえば、サーバーが選択した TLS のバージョンや鍵交換に使用するサーバー側の公開鍵が確認できます。

ChangeCipherSpec
TLS1.3 の仕様では ChangeCipherSpec は意味を持たないので無視してください。[4]

EncryptedExtensions
ClientHello の拡張に対するサーバ側の応答(ALPN, SNI 応答など)を、暗号化された状態でまとめて送ります。

この例では、クライアントが提示したh2(http/2)とhttp/1.1 のうち、http/1.1を採用したことを表します。
Certificate
サーバー証明書の送信します。

送信したCertificatesの内容はserver.crt中のものと一致します。
もし、自分の目で確認したい場合は以下のコマンドでserver.crtを人が見やすい形式に出力が可能です。
openssl x509 -in server.crt -text -noout
server.crtの結果
Certificate:
Data:
Version: 3 (0x2)
Serial Number:
0e:c7:d2:0f:09:a8:03:2c:9f:7c:59:cb:1b:6f:e7:b3:45:c9:53:bf
Signature Algorithm: sha256WithRSAEncryption
Issuer: CN=localhost
Validity
Not Before: Dec 3 13:31:50 2025 GMT
Not After : Dec 3 13:31:50 2026 GMT
Subject: CN=localhost
Subject Public Key Info:
Public Key Algorithm: rsaEncryption
Public-Key: (2048 bit)
Modulus:
00:85:bc:54:a3:2d:ee:36:b0:bc:5d:4d:8f:6d:cf:
d6:ec:eb:19:75:a3:bf:08:a0:6a:fd:54:44:59:b6:
ae:81:0c:c8:e6:88:bc:da:5e:4e:49:c0:e5:08:e1:
7a:63:09:68:c6:10:43:0c:00:c0:5f:cb:a5:d3:b6:
34:b5:7b:3e:2b:67:b0:1e:fc:21:fa:44:18:87:60:
d9:cc:86:49:d5:d2:c4:fa:84:c3:3d:57:47:83:cc:
0a:8e:2c:cf:30:4e:8d:0c:e0:1b:c2:83:5b:6c:55:
8a:0c:8f:a4:7d:38:0a:fc:37:e1:c6:36:40:05:f8:
16:bd:c2:d4:2a:2e:9d:6f:81:d5:be:e0:c5:4a:3e:
1b:e2:ad:01:08:23:3c:3a:c7:be:fd:99:d7:fb:39:
02:74:e5:81:a5:f1:51:e4:26:ea:15:2d:96:08:5e:
67:e5:97:27:43:7b:0c:0f:03:80:dc:6a:cf:88:e7:
fe:83:5f:9d:49:28:29:b7:b2:04:97:25:60:53:3c:
b0:aa:eb:6d:8a:0f:c5:55:98:62:87:57:40:16:6f:
f9:fd:7f:c6:9c:62:08:bd:1c:ec:4f:85:61:f4:eb:
13:ed:04:6f:6a:08:7e:95:14:8a:89:5d:cd:9b:81:
f4:8a:7a:ee:8f:dc:f4:d1:bd:c8:10:d5:0e:fb:79:
80:85
Exponent: 65537 (0x10001)
X509v3 extensions:
X509v3 Subject Key Identifier:
F0:71:16:9E:84:E8:63:17:FB:13:9E:24:89:F6:DB:B3:6A:03:BD:50
X509v3 Authority Key Identifier:
F0:71:16:9E:84:E8:63:17:FB:13:9E:24:89:F6:DB:B3:6A:03:BD:50
X509v3 Basic Constraints: critical
CA:TRUE
Signature Algorithm: sha256WithRSAEncryption
Signature Value:
56:9e:50:11:13:60:bd:34:13:b5:f1:a6:98:a0:5b:3b:26:7e:
73:3f:76:f1:ae:a3:35:a1:7b:ea:30:6f:db:80:f7:67:77:c2:
6d:42:fd:c3:35:16:e1:68:15:15:8c:d1:60:de:9e:61:db:20:
fc:b5:d3:9b:95:ae:26:78:55:b5:9a:46:d0:04:bf:69:8a:7f:
06:3f:37:bd:9f:f1:28:ea:9e:97:4d:da:b9:6b:fb:c2:40:b3:
53:73:a2:0e:f2:29:59:b4:8f:9e:4a:95:e1:25:2a:88:ec:29:
46:05:ab:8c:47:b4:05:ed:5c:9e:46:03:92:05:2c:e2:2e:d8:
c2:8e:a1:cb:70:69:e1:e7:fe:9b:05:c6:5b:e0:6c:72:b0:d9:
07:b0:06:d5:ca:a4:97:33:b3:92:e7:bd:ff:26:50:89:d7:e5:
ce:1b:29:69:4e:b0:71:e4:07:72:45:5e:cb:92:40:6d:e4:df:
15:80:0a:99:53:bd:eb:12:1b:8e:70:07:f3:32:92:08:15:48:
83:50:09:23:15:5a:a2:ae:02:1c:b8:8d:23:91:5f:66:25:6f:
32:dd:af:9d:06:03:fe:21:5e:94:c9:54:7b:de:4c:48:5a:1f:
23:e5:66:2e:6a:e2:76:e4:07:24:a0:a4:9a:2d:80:57:97:70:
c3:a4:5c:85
Serial Number:などと一致することが確認できます。
Certificateは二回目以降、送られない場合もあります。
CertificateVerify
サーバーがサーバー証明書に対応する秘密鍵を保持していることを示すために、秘密鍵で署名したデータを送信します。
クライアントはサーバー証明書に含まれる公開鍵とこの署名データで検証し、サーバーに対するなりすましが行われていないことを確認します。

CertificateVerifyは二回目以降、送られない場合もあります。
Finished
これまでのハンドシェイクメッセージの履歴をハッシュした結果であるTranscript Hashを入力として計算したメッセージ認証コード(MAC)を送ります。
相手側は自分でも同じ計算を行い、値が一致することを確認することで、ハンドシェイクが改ざんされておらず、同じ内容と鍵を共有できていることを確かめます。

ブラウザからのChangeCipherSpec、Finished

TLS1.3 の仕様では ChangeCipherSpecは意味がなく、Finished で、TLS1.3 のハンドシェイクが完了します。
サーバーからのNewSessionTicket
“NewSessionTicket” を送って、この先、往復回数 0 でデータ送信開始する 0-RTTによる再接続を可能にします。
複数チケットを送ってもよいです。

NewSessionTicketは二回目以降、送られない場合もあります。実際、ポート:50919には存在しますがポート:50918には存在しません。
HTTP通信以降
その後はHTTPプロトコルでリクエストとレスポンスが帰っていることが確認できます。
これについては前述のHTTP/1.1と変わりません。
HTTP/2の確認
HTTP/1.1では実質的にブラウザの実装として複数ソケットが前提でした[5]が、HTTP/2 は RFC 9113 – HTTP/2では1本のソケット上で多重化+ヘッダ圧縮+優先度制御を行います。
HTTP/2のデータ送受信のイメージ図は以下のとおりです。

1つのコネクションの中に複数のストリームが存在し、ストリームの中をデータフレーム単位で送受信します。
なお、HTTP/2であってもHead-of-line blocking問題は完全には解決されていません。[6]
HTTP/2対応のサーバーの作成
httpモジュールではなくhttp2モジュールを使用することで実現可能です。
今回はindex.html読み込み時にEarly Hints を使用してclient.jsを読み込むようにしています。
Early Hintsは、ページ本体の応答が準備される前に、ブラウザに必要なリソース(JavaScript や CSS など)の読み込みを先行して開始させるための仕組みです。HTTP/1.1 の時代から仕様上は利用可能でしたが、ブラウザの制限による1 コネクションあたり 1 リクエストという制約のため、実際の効果は限定的でした[5:1]。HTTP/2 では 1 コネクション内で複数のストリームを同時に扱えるため、Early Hints によるプリロードの効果が大きく発揮されます
HTTP/2のサーバー例
// http2-server.js
'use strict';
const fs = require('fs');
const http2 = require('http2'); // ★ https → http2
const path = require('path');
let socketCounter = 0;
let requestCounter = 0;
let sessionCounter = 0;
// ★ 証明書と秘密鍵を読み込む
const options = {
key: fs.readFileSync(path.join(__dirname, 'server.key')),
cert: fs.readFileSync(path.join(__dirname, 'server.crt')),
// HTTP/2 非対応クライアント用に HTTP/1.1 も許可(任意)
allowHTTP1: true,
};
// createSecureServer(HTTP/2互換API) に差し替え、ハンドラはほぼそのまま
const server = http2.createSecureServer(options, (req, res) => {
console.log('--- HTTPS (HTTP/2) request ---');
console.log('url :', req.url);
console.log('method :', req.method);
console.log('httpVersion:', req.httpVersion); // たとえば "2.0" が入る
console.log('headers :', req.headers);
if (req.url === "/" || req.url === "/index.html") {
// ★ Early Hints (103) を送る例
// - 主に HTTP/2 / HTTP/3 で意味がある
// - Link ヘッダで client.js を preload させる
if (typeof res.writeEarlyHints === "function" && req.httpVersion.startsWith("2")) {
const links = [
"</client.js>; rel=preload; as=script", // JS を preload
// "</style.css>; rel=preload; as=style", // CSS があればこんな感じで追加
];
console.log("sending 103 Early Hints");
res.writeEarlyHints({
// Node 側は小文字 'link' 1個で OK(配列で複数指定できる)
link: links,
});
}
setTimeout(() => {
const html = fs.readFileSync(path.join(__dirname, "index.html"), "utf8");
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(html);
}, 5000)
return;
}
if (req.url.startsWith("/client.js")) {
const js = fs.readFileSync(path.join(__dirname, "client.js"), "utf8");
res.writeHead(200, { "Content-Type": "text/javascript; charset=utf-8" });
res.end(js);
return;
}
if (req.url.startsWith("/api")) {
requestCounter++;
const thisReqId = requestCounter;
const sessionId = req.stream.session.__id;
console.log(
`REQ#${thisReqId} on SESSION#${sessionId} ${req.method} ${req.url}`
);
const delay = 200 + Math.random() * 800;
setTimeout(() => {
const body = JSON.stringify({
requestId: thisReqId,
sessionId,
url: req.url,
connectionHeader: req.headers["connection"] || null,
now: new Date().toISOString(),
});
res.writeHead(200, {
"Content-Type": "application/json; charset=utf-8",
"Content-Length": Buffer.byteLength(body),
});
res.end(body);
}, delay);
return;
}
res.writeHead(404);
res.end("Not Found");
});
// ★ HTTP/2 の「接続ごと」に呼ばれるイベント
server.on('session', (session) => {
sessionCounter++;
session.__id = sessionCounter;
console.log(`NEW SESSION#${session.__id}`);
});
// connection ハンドラはそのまま使える(http2.Server は tls.Server を継承)
server.on("connection", (socket) => {
socketCounter++;
socket.__id = socketCounter;
console.log(
`NEW SOCKET#${socket.__id} from ${socket.remoteAddress}:${socket.remotePort}`
);
socket.on("end", () => {
console.log(`SOCKET#${socket.__id} end`);
});
socket.on("close", (hadError) => {
console.log(`SOCKET#${socket.__id} closed`);
});
socket.on("error", (err) => {
console.log(`SOCKET#${socket.__id} error`, err);
});
});
const PORT = 8443;
server.listen(PORT, () => {
console.log(
`HTTPS (HTTP/2) server listening on https://localhost:${PORT}`
);
});
実験
実験方法は以下のとおりです。
- HTTP/2 用のサーバーコードを起動
node http2-server.js
- Wiresharkを起動し、Loopback: lo0をキャプチャ
-
tcp.port == 8443 || udp.port == 8443でフィルタをかける - SSLKEYLOGFILEを環境変数としてChromeを起動
export SSLKEYLOGFILE=/work/techblog/http2/tls_keys.log
# 5ZXuDPc8AV/4PslNz7Th+6ocVtv7Zya2cy5HMSwBaA4=がSPKI
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
--ignore-certificate-errors-spki-list="5ZXuDPc8AV/4PslNz7Th+6ocVtv7Zya2cy5HMSwBaA4=" \
--user-data-dir=/tmp/temp-chrome
- 開発者ツールのNetworkタブを開く。この際、ヘッダを右クリックしてProtocolを表示する
-
https://localhost:8443/index.htmlにアクセス - 「並列に5リクエスト」ボタンを押す
Chrome開発者ツールの結果
開発者ツールのNetworkを確認するとprotocolの列が"h2"と表示されていることが確認できます。

Wiresharkの結果



http2でフィルタをかけると使用されているポート数が減っていることが確認できます。

ポート49906でのみほとんどの通信をしており、ポート49907はほぼ使われていません。
前述したとおり、1つのコネクション中で同時に送受信が行われていることが確認できます。
ポート49906にながれるデータを整理すると以下の図になります。
SnはストリームIDを表します。今回の図の例ではS0, S1, S3の最大3並行で動作しています。

接続プレフィックス
HTTP/2 クライアントがコネクション確立直後に必ず送る 24 バイトの固定文字列が送信されています。

SETTINGSフレーム
相手が“このコネクション上でフレームをどう送ってくるか”を制御するための、受信側からの設定宣言を行います。[7]
このフレームを受け取った側は、Flagsに0x01(ACK)を設定して応答する必要があります。
今回の例では以下のようになっています。
- 627 ブラウザ→サーバー SETTINGS
- 635 サーバー→ブラウザ SETTINGS
- 637 ブラウザ→サーバー SETTINGS(ACK)
- 639 サーバー→ブラウザ SETTINGS(ACK)
627 ブラウザからサーバーのSETTINGSフレームの内容は以下の通りです。

- SETTINGS_HEADER_TABLE_SIZE : 65536
- HPACK(HTTP/2 のヘッダ圧縮)の 動的テーブルの上限サイズ。
- SETTINGS_ENABLE_PUSH : 0
- サーバープッシュを無効とする
- SETTINGS_INITIAL_WINDOW_SIZE : 6291456
- HTTP/2 のフロー制御ウィンドウ。1つのストリームにつき、最初に約6MBまで DATA を送っていいと宣言している
- SETTINGS_MAX_HEADER_LIST_SIZE
- 1回のリクエスト/レスポンスで送っていいヘッダの合計最大サイズ
このフレームについてサーバーは639で応答を返しています。

一方サーバー側もサーバーからの最初の送信(635)でSETTINGSフレームを送っています。

これは特に変更したい値がないため、「デフォルト設定を使用する」という意味になります。
これに対してクライアントは637で応答を返しています。

WINDOW_UPDATEフレーム
クライアントからの初回送信時に、接続プレフィックス、SETTINGSフレームと共に送信されていることが確認できます。

これはフロー制御ウィンドウを増やすための処理で、受け取れるデータ量の上限を示すカウンタをあらわします。
今回は DATAの総量を64KBから15MBに増やしています。
フロー制御ウィンドウはDATAフレームを受信すると減っていき、WINDOW_UPDATEフレームを受信すると増えます。
今回のキャプチャの結果でも709, 921とWINDOW_UPDATEフレームが送信されてフロー制御ウィンドウの数値を増やしています。


Early Hintsの挙動の確認
今回はindex.htmlを読み込み時にEarly Hintsでclient.jsを返すような設定になっています。
そのため、GET /の内容であるindex.htmlを返す前に、103でレスポンスを返し、先にclient.jsを取得するような挙動になっていることが確認できます。

まず、ブラウザーからのHEADERSフレームを使用して、さまざまなリクエストヘッダーとともにStream ID 1で GET /をリクエストします。

このStream ID 1のリクエストに対してサーバーはEaryl Hintsを用いてclient.jsを取得するようなHEADERSフレームを返します。

ブラウザはStream ID 3でclient.jsを取得するため、HEADERSフレームを使用して GET /client.jsをリクエストします。

サーバーをStream ID 3にレスポンスを返します。
HEADERSフレームでレスポンスヘッダー、DATAフレームにclient.jsの内容を載せます。

今回のサーバーからの通信ではDATAフレームのFlagsは0x00であるため、すべてのデータがそろっていません。
次のStream ID 3で受信したDATAフレームのFlagsがEND_STREAMがついているため、データが全て揃ったことがわかります。

しばらくすると、サーバーはindex.htmlの作成が終わり、Stream ID 1にレスポンスを返します。
client.jsのレスポンスと同様にStream ID 1にHEADERSフレームとDATAフレームが送信されて、最後のDATAフレームのFlagsにはEND_STREAMがつきます。


client.jsが2回リクエストされている!
Wiresharkの受信データをよく見ていると、HEADERS GET /client.jsが2回実行されている場合があります。

本来はEarly Hintsによりclient.jsを取得した場合、ブラウザのキャッシュが働いて、2回目のリクエストを行いません。
しかし、Chromeで、自己署名証明書を用いたサーバーに対してリクエストを行う場合、キャッシュが効かない挙動になります。このため、client.jsは2回リクエストされることになります。
これは2011年ころから報告されている挙動です。
Need to test that caching is disabled with certificate errors.
なお、同じ操作をFirefoxで行うと再現しません。

Early Hintsと同じようなことができる機能として HTTP/2 のサーバープッシュがありますが、現在は Chrome や Firefox をはじめとする主要ブラウザーでサポートが廃止されており、実質的に利用できなくなっています。
HEADERSフレームの圧縮
HTTP/2ではヘッダーが圧縮されるようになりました。
HPACKという方式で圧縮されて、Header Block Fragmentに格納されています。

Wiresharkではこの結果を解析した内容がHeaderとして表示されています。ここでは同じようにHeader Block Fragmentを解析してみたいと思います。
まずHeader Block Fragmentのデータを以下のようにコピーして保存しておいてください。

今回は以下のHEADERSフレームのHeader Block Fragmentを解析してみます。
- HEADERS GET /
- HEADERS GET /client.js
そのために、golang.org/x/net/http2/hpackを使用した解析プログラムを作成し、Header Block Fragmentを解析します。
HTTP/2ヘッダー解析プログラムと出力結果
サンプルコード
// go get golang.org/x/net/http2/hpack@latest
package main
import (
"encoding/hex"
"fmt"
"log"
"golang.org/x/net/http2/hpack"
)
// HEADERS GET /のHeader Block Fragment
const headerBlock1Hex = "82418aa0e41d139d09b8f34d33878440874148b1275ad1ffb8fe711cf350552f4f61e92ff3f7de0fe42d33fcfd29fcde9ec3d26b69fe7efbc1fc85a67f9fa53f9d274a90ff576c1d527f3f7de0fe44d7f3408b4148b1275ad1ad49e33505023f30408d4148b1275ad1ad5d034ca7b29f07226d61634f53224092b6b9ac1c8558d520a4b6c2ad617b5a54251f01317ad9d07f66a281b0dae053fad0321aa49d13fda992a49685340c8a6adca7e28104416e277fb521aeba0bc8b1e632586d975765c53facd8f7e8cff4a506ea5531149d4ffda97a7b0f49580b4cae05c0b814dc394761986d975765cf53e5497ca589d34d1f43aeba0c41a4c7a98f33a69a3fdf9a68fa1d75d0620d263d4c79a68fbed00177fe8d48e62b03ee697e8d48e62b1e0b1d7f46a4731581d754df5f2c7cfdf6800bbdf43aeba0c41a4c7a9841a6a8b22c5f249c754c5fbef046cfdf6800bbbf408a4148b4a549275906497f83a8f517408a4148b4a549275a93c85f86a87dcd30d25f408a4148b4a549275ad416cf023f31408a4148b4a549275a42a13f8690e4b692d49f50929bd9abfa5242cb40d25fa523b3e94f684c9f5193e83fa2d4b70ddf7da002effd16afbed00177bf4086aec31ec327d785b6007d286f"
// HEADERS GET /client.js の Header Block Fragment
const headerBlock2Hex = "82cb870487609418b5257e8853032a2f2a7f068840e92ac7b0d31aaf7f0685a8eb10f6237f0584412c356973919d29ad1718628390744e7426e3cd34cb1fcbc5c47f0485b600fd286f"
func mustDecodeHex(s string) []byte {
b, err := hex.DecodeString(s)
if err != nil {
log.Fatalf("hex decode error: %v", err)
}
return b
}
func printHeaderBlock(dec *hpack.Decoder, block []byte, label string) {
hfs, err := dec.DecodeFull(block)
if err != nil {
log.Fatalf("hpack decode error (%s): %v", label, err)
}
fmt.Printf("=== %s ===\n", label)
for _, hf := range hfs {
fmt.Printf("%s: %s\n", hf.Name, hf.Value)
}
fmt.Println()
}
func main() {
// 動的テーブルサイズはひとまず 4096。必要に応じて変えてよい。
dec := hpack.NewDecoder(4096, nil)
block1 := mustDecodeHex(headerBlock1Hex)
block2 := mustDecodeHex(headerBlock2Hex)
printHeaderBlock(dec, block1, "HEADERS #1")
printHeaderBlock(dec, block2, "HEADERS #2")
}
出力結果
=== HEADERS #1 ===
:method: GET
:authority: localhost:8443
:scheme: https
:path: /
sec-ch-ua: "Google Chrome";v="143", "Chromium";v="143", "Not A(Brand";v="24"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "macOS"
upgrade-insecure-requests: 1
user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36
accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
sec-fetch-site: none
sec-fetch-mode: navigate
sec-fetch-user: ?1
sec-fetch-dest: document
accept-encoding: gzip, deflate, br, zstd
accept-language: ja,en-US;q=0.9,en;q=0.8
priority: u=0, i
=== HEADERS #2 ===
:method: GET
:authority: localhost:8443
:scheme: https
:path: /client.js
accept: */*
sec-fetch-site: same-origin
sec-fetch-mode: no-cors
sec-fetch-dest: script
referer: https://localhost:8443/
user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36
accept-encoding: gzip, deflate, br, zstd
accept-language: ja,en-US;q=0.9,en;q=0.8
priority: u=1, i
このプログラムを確認するとわかりますが、最初のHeader Block Fragmentと次のHeader Block Fragmentのサイズに大きな違いがあることがわかります。
ヘッダーの圧縮には、過去に同じ方向(クライアント→サーバー)に送信した HEADERS フレーム のデータを利用して圧縮しているため、初回のサイズは大きく、それ以降は小さなサイズになります。
PINGフレーム
接続状態が機能しているかを検証するために使用します。
PINGフレームを受信した場合、ACK フラグを設定した PING フレームを返信する必要があります。
ブラウザ→サーバーへのPING

上記の応答

HTTP/3の確認
HTTP/3ではTCPではなく、UDP上のQUICというプロトコルを利用してHTTP/3を実現しています。

これにより、レイテンシが改善されて、HTTPにおけるHead-of-line blocking問題も解消することができます。いくつかのペーパーでHTTP/2とHTTP/3のパフォーマンスの比較が行われています。[8][9]
QUICやHTTP/3の通信プログラムはNode.jsでは実装が難しい[10]のでgoを使用して必要なプログラムを行います。
もし、goでHTTPサーバーを実装したくない場合は、caddyなどでプロキシサーバをH3で動かし、実際の処理はHTTP/1.1またはHTTP/2のままとする方法もあります。
QUICの検証
まず、HTTP/3の実験を行う前にgoで簡単なQUICのクライアントとサーバーを作成して、その通信内容を確認します。
以下に簡単なサンプルコードを提示します。
QUICのサンプルコード
サーバー: quic_server.go
// quic_server.go
// TODO:
// go get github.com/quic-go/quic-go@latest
package main
import (
"context"
"crypto/tls"
"log"
"net"
quic "github.com/quic-go/quic-go"
)
func main() {
addr, err := net.ResolveUDPAddr("udp", "0.0.0.0:8443")
if err != nil {
log.Fatalf("ResolveUDPAddr: %v", err)
return
}
udpConn, err := net.ListenUDP("udp", addr)
if err != nil {
log.Fatalf("ListenUDP: %v", err)
return
}
defer udpConn.Close()
tr := &quic.Transport{
Conn: udpConn,
}
log.Printf("Transport: %v", tr)
// サーバー証明書と秘密鍵を読み込む
cert, err := tls.LoadX509KeyPair("server.crt", "server.key")
if err != nil {
log.Fatalf("LoadX509KeyPair: %v", err)
}
tlsConfig := &tls.Config{
Certificates: []tls.Certificate{cert}, // サーバー用
NextProtos: []string{"quic-echo-example"}, // ALPN
MinVersion: tls.VersionTLS13,
MaxVersion: tls.VersionTLS13,
}
quicConf := &quic.Config{Allow0RTT: true}
ln, err := tr.Listen(tlsConfig, quicConf)
if err != nil {
log.Fatalf("tr.Listen: %v", err)
return
}
defer ln.Close()
for {
// quic-go の Listener.Accept は ctx 付きで呼び出すのが現行仕様
// "Accept returns new connections. It should be called in a loop."
// :contentReference[oaicite:2]{index=2}
conn, err := ln.Accept(context.Background())
if err != nil {
log.Printf("Accept error: %v", err)
continue
}
log.Printf("accepted QUIC conn from %v", conn.RemoteAddr())
// 検証用途ならここで適当にストリームを開いたり受けたりすればOK
go handleConn(conn)
}
}
func handleConn(conn *quic.Conn) {
defer conn.CloseWithError(0, "bye")
for {
// ★ クライアント側が OpenStreamSync したストリームを受ける
stream, err := conn.AcceptStream(context.Background())
if err != nil {
log.Printf("AcceptStream error from %v: %v", conn.RemoteAddr(), err)
return
}
go handleStream(stream)
}
}
func handleStream(stream *quic.Stream) {
defer stream.Close()
buf := make([]byte, 4096)
n, err := stream.Read(buf)
if err != nil {
log.Printf("stream.Read error (ID=%d): %v", stream.StreamID(), err)
return
}
msg := string(buf[:n])
log.Printf("received on stream %d: %q", stream.StreamID(), msg)
// そのままエコーバック
if _, err := stream.Write([]byte("echo: " + msg)); err != nil {
log.Printf("stream.Write error (ID=%d): %v", stream.StreamID(), err)
return
}
log.Printf("replied on stream %d", stream.StreamID())
}
クライアント: quic_client.go
// quic_client.go
// TODO:
// go get github.com/quic-go/quic-go@latest
package main
import (
"context"
"crypto/tls"
"crypto/x509"
"io"
"log"
"os"
quic "github.com/quic-go/quic-go"
)
func main() {
// --- TLS クライアント設定 ---
// サーバ証明書(自己署名 or 独自 CA)を RootCAs に追加
caCert, err := os.ReadFile("server.crt")
if err != nil {
log.Fatalf("read ca cert error: %v", err)
}
rootCAs := x509.NewCertPool()
if !rootCAs.AppendCertsFromPEM(caCert) {
log.Fatalf("failed to append ca cert")
}
// ★ キーログファイルを開く
keyLogFile, err := os.OpenFile(
"/work/techblog/http2/tls_keys.log",
os.O_CREATE|os.O_WRONLY|os.O_TRUNC,
0600,
)
if err != nil {
log.Fatalf("open key log file: %v", err)
}
defer keyLogFile.Close()
tlsConf := &tls.Config{
InsecureSkipVerify: true, // セキュリティを緩める
RootCAs: rootCAs, // クライアントがサーバ証明書を検証するための CA
ServerName: "localhost", // server.crt の CN または SAN と一致させる
NextProtos: []string{"quic-echo-example"}, // サーバと同じ ALPN
MinVersion: tls.VersionTLS13,
MaxVersion: tls.VersionTLS13,
// KeyLogWriter を設定すれば Wireshark で復号も可能
KeyLogWriter: keyLogFile,
}
// QUIC の設定(とりあえずデフォルトで十分)
quicConf := &quic.Config{
// 0-RTT を試したければ Allow0RTT: true にして DialAddrEarly を使う
}
ctx := context.Background()
// --- QUIC 接続確立 ---
// DialAddr は addr へ新しい UDP ソケットを作って QUIC 接続を張るヘルパー
// https://pkg.go.dev/github.com/quic-go/quic-go#DialAddr
conn, err := quic.DialAddr(ctx, "127.0.0.1:8443", tlsConf, quicConf)
if err != nil {
log.Fatalf("DialAddr: %v", err)
}
defer conn.CloseWithError(0, "bye")
log.Printf("connected: local=%v remote=%v", conn.LocalAddr(), conn.RemoteAddr())
// --- ストリームで簡単に 1 往復してみる(任意) ---
stream, err := conn.OpenStreamSync(ctx)
if err != nil {
log.Fatalf("OpenStreamSync: %v", err)
}
defer stream.Close()
msg := "hello QUIC"
if _, err := stream.Write([]byte(msg)); err != nil {
log.Fatalf("stream.Write: %v", err)
}
log.Printf("sent: %q", msg)
// サーバ側がまだ何も書いていないなら、ここで Read せず終わってもよい
reply, err := io.ReadAll(stream)
if err != nil {
log.Fatalf("stream.Read (io.ReadAll): %v", err)
}
log.Printf("got reply: %q", string(reply))
log.Printf("done")
}
サーバー側はクライアントの送信をまち、受信したデータをエコーします。
クライアントはサーバーにhello QUICと送信するだけです。keyLogFileに環境変数SSLKEYLOGFILEに格納されているファイルを指定することでWiresharkでクライアントーサーバー間の通信の内容を確認することが可能になります。
実験手順は以下のとおりです。
- サーバーを以下のコマンドで起動します。
go run quic_server.go
- Wiresharkを起動してキャプチャを始める。
- クライアントを以下のコマンドで起動します。
export SSLKEYLOGFILE=/work/techblog/http2/tls_keys.log
go run quic_client.go
Wiresharkの結果

このデータを見るとInitial, Handshake...といったQUICパケットがUDPで送信されていることがわかります。
QUICパケットの中にはCRYPTO、PADDING、ACK...などのフレームが含まれています。
これらのパケットやフレームの詳細については以下で定義されています。
このパケットの流れをまとめると以下のような図になります。

クライアントからのClientHello
クライアントからのTLSのハンドシェイクのメッセージはCRYPTOフレームで構成されたInitial パケットに乗せて送信されます。
クライアントから送信するInitial パケットには少なくとも1200バイトのペイロードが必要なため、必要に応じてPADDINGフレームで穴埋めをします。[11]
次が実際のQUICパケットの内容になります。


今回送信されたCRYPTOフレームを全て合わせると以下のようなClient Helloになります。

サーバーからのServerHello〜Finished
サーバーからのServerHello、EncryptedExtensions、Certificate CertificateVerify、Finishedを送信しハンドシェイクを行います。
サーバーはクライアントからのClientHelloによりパケット番号0 と パケット番号1を受信しました。
まず、パケット番号0 を受信したことを ACK フレームで通知します。

ACKフレームのみの場合だと、クライアントのACKを誘発しないのでPADDINGフレームを送りません。[12]
次に以下のフレームを含むInitialパケットを送信します。
- パケット番号1 に対してのACKフレーム
- ACKを誘発するフレームを送くる場合で、UDPデータグラムのペイロードを少なくとも1200バイトにするためにPADDINGフレームを送信
- CRYPTOフレームでServerHelloの送信
ServerHello送信後はHandshakeパケットでCRYPTOフレームにEncryptedExtensions、Certificate CertificateVerify、Finishedを乗せて送信します。

NEW_CONNECTION_IDフレームの送信
サーバーがハンドシェイクをした送信したのち、サーバーからNEW_CONNECTION_IDフレームが送信されます。

これはクライアントが今後、Destination Connection ID(DCID)として使用していいIdです。どのタイミングでその ID に切り替えるかはクライアント側の裁量となります。
ここで現れた978b7549は、のちのクライアントからの送信時にDCIDとしてつかわれていることが確認できます。

ハンドシェイクに対するクライアントの応答
クライアントはServerHello を含む Initial パケットを受信したため、そのパケット番号を ACK フレームで通知します。

サーバーからのHandshakeに対する返信のHandshakeパケットとShort Header パケットをクライアントから送信します。

- Handshakeパケット
- ACKフレーム:サーバーの Handshake パケットを受信済みであることを示す
- CRYPTOフレーム : クライアントからの TLS1.3のFinished
- Short Header パケット
- RETIRE_CONNECTION_IDフレーム: NEW_CONNECTION_IDフレームを受信し、より新しいConnection IDを使用開始した結果、古いConnection IDが不要になったことを通知する
サーバーからのNew Session Ticket
サーバーがTLS1.3のNew Session Ticketを送信します。

- Short Header パケット
- ACKフレーム
- RETIRE_CONNECT_IDフレームを含むパケットを受信したことを通知する
- CRYPTO
- TLS1.3のNew Session Ticket
- HANDSHAKE_DONEフレーム
- サーバがハンドシェイク完了をクライアントに通知するために送るフレーム
- NEW_TOKENフレーム
- 次回の再接続高速化のためのトークンを渡す
- ACKフレーム
クライアントからの文字の送信
クライアントからサーバーに対してhello QUICという文字列を送ります。

ここでのACKフレームはサーバから受信したNEW_CONNECTION_IDフレームを含むパケット番号をACKフレームで通知します。
STREAMフレームには送信したい文字列を載せます。今回のStream Dataは以下のようになっています。

サーバーのエコー
サーバーはクライアントから文字を受信したら、その内容をエコーします。

クライアントからのSTREAMフレームを含むパケットを受信したことをACKフレームで通知します。
STREAMフレームでエコーする内容を乗せます。今回のStream Dataは以下のようになっています。

クライアントの終了処理
クライアントはサーバーからのエコーを含むパケットを受信したことをACKフレームで通知します。

その後、クライアントはCONNECTION_CLOSEフレームを送信して通信を終了します。

HTTP/3対応のサーバーの作成
goではgithub.com/quic-go/quic-go/http3を使用することでHTTP/3対応を行うことが可能になっています。
HTTP/3 サーバーのコード
http3-server.go
// http3-server.go
// go mod init http3test
// go get github.com/quic-go/quic-go/http3@latest
// go run http3-server.go
package main
import (
"encoding/json"
"fmt"
"log"
"math/rand"
"net/http"
"os"
"path/filepath"
"sync/atomic"
"time"
http3 "github.com/quic-go/quic-go/http3"
)
var requestCounter int64
func main() {
rand.Seed(time.Now().UnixNano())
mux := http.NewServeMux()
mux.HandleFunc("/", handleRoot)
mux.HandleFunc("/index.html", handleRoot)
mux.HandleFunc("/client.js", handleClientJS)
mux.HandleFunc("/api/", handleAPI)
baseDir, err := filepath.Abs(".")
if err != nil {
log.Fatal(err)
}
log.Printf("baseDir: %s", baseDir)
handler := loggingMiddleware(mux)
log.Println("HTTP/1.1, HTTP/2, HTTP/3 server listening on https://localhost:8443")
// ListenAndServeTLS が内部で
// - TCP/TLS サーバ(HTTP/1.1 & HTTP/2)
// - QUIC/UDP サーバ(HTTP/3)
// を両方起動し、Alt-Svc も勝手に付けてくれる
if err := http3.ListenAndServeTLS(":8443", "server.crt", "server.key", handler); err != nil {
log.Fatal(err)
}
}
// 共通ログ用ミドルウェア
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Println("--- HTTPS request ---")
log.Println("url :", r.URL.String())
log.Println("method :", r.Method)
log.Println("httpVersion:", r.Proto) // 例: "HTTP/3", "HTTP/2.0", "HTTP/1.1"
log.Println("headers :")
for k, v := range r.Header {
log.Printf(" %s: %v", k, v)
}
next.ServeHTTP(w, r)
})
}
// "/" or "/index.html"
func handleRoot(w http.ResponseWriter, r *http.Request) {
path := filepath.Join(".", "index.html")
data, err := os.ReadFile(path)
if err != nil {
http.Error(w, "index.html not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(data)
}
// "/client.js"
func handleClientJS(w http.ResponseWriter, r *http.Request) {
path := filepath.Join(".", "client.js")
data, err := os.ReadFile(path)
if err != nil {
http.Error(w, "client.js not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(data)
}
// "/api"
func handleAPI(w http.ResponseWriter, r *http.Request) {
reqID := atomic.AddInt64(&requestCounter, 1)
// 200〜1000ms のランダムディレイ
delayMs := 200 + rand.Intn(801)
time.Sleep(time.Duration(delayMs) * time.Millisecond)
bodyStruct := map[string]interface{}{
"requestId": reqID,
"url": r.URL.String(),
"connectionHeader": firstOrNil(r.Header["Connection"]),
"now": time.Now().UTC().Format(time.RFC3339Nano),
}
body, err := json.Marshal(bodyStruct)
if err != nil {
http.Error(w, "json encode error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(body)))
w.WriteHeader(http.StatusOK)
_, _ = w.Write(body)
}
func firstOrNil(v []string) interface{} {
if len(v) == 0 {
return nil
}
return v[0]
}
Chromeで自己署名証明書でのHTTP/3サーバーにアクセスする方法
Chromeで自己署名証明書のHTTP/3サーバーにアクセスする場合は以下のオプションを指定する必要があります。
-
--ignore-certificate-errors-spki-list: server.crtから出力したSPKI Fingerprint -
--origin-to-force-quic-on: 指定したオリジンに対して、事前条件を満たしていなくても QUIC(HTTP/3)での接続を“優先的に試行する”開発用フラグ
# 5ZXuDPc8AV/4PslNz7Th+6ocVtv7Zya2cy5HMSwBaA4=がSPKI
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
--origin-to-force-quic-on=localhost:8443 \
--ignore-certificate-errors-spki-list="5ZXuDPc8AV/4PslNz7Th+6ocVtv7Zya2cy5HMSwBaA4=" \
--user-data-dir=/tmp/temp-chrome
参考:
Not being able to skip checking localhost certificates for QUIC/WebTransport
実験
実験方法は以下のとおりです。
- HTTP/3 用のサーバーコードを起動
go run http3-server.go
- Wiresharkを起動し、Loopback: lo0をキャプチャ
-
tcp.port == 8443 || udp.port == 8443でフィルタをかける - SSLKEYLOGFILEを環境変数に指定後、
--origin-to-force-quicオプションを指定してChromeを起動
export SSLKEYLOGFILE=/work/techblog/http2/tls_keys.log
# 5ZXuDPc8AV/4PslNz7Th+6ocVtv7Zya2cy5HMSwBaA4=がSPKI
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
--origin-to-force-quic-on=localhost:8443 \
--ignore-certificate-errors-spki-list="5ZXuDPc8AV/4PslNz7Th+6ocVtv7Zya2cy5HMSwBaA4=" \
--user-data-dir=/tmp/temp-chrome
- 開発者ツールのNetworkタブを開く。この際、ヘッダを右クリックしてProtocolを表示する
-
https://localhost:8443/index.htmlにアクセス - 「並列に5リクエスト」ボタンを押す
HTTP/3のプロトコルについては下記を参照してください。
RFC9114 HTTP/3
Chrome開発者ツールの結果
開発者ツールのNetworkタブでProtocolを確認するとh3と表示されることが確認できます。

Wiresharkの結果
Wiresharkを確認するとQUICプロトコルとHTTP/3プロトコルのみが存在することが確認できます。(TCPは存在しない)


プロトコルをhttp3でフィルタするとHTTP/2と同様に1つのポートしか使用していないことが確認できます。

未知のフレーム
QUICプロトコルをよくみると、先ほどの単純なQUICのクライアント・サーバーの結果には存在していないフレームがいくつか存在します。
- PINGフレーム
- MAX_STREAMS (BIDI)フレーム
PINGフレーム
クライアントからのInitialパケットに複数のPINGフレームが存在していることが確認できます。
これは、将来の拡張のため、Initial パケットに PING / PADDING をランダムに挟む実装をあえてしています。[13]
どのようにInitialパケットにPINGフレームやPADDINGフレームを入れているかについては、以下の実装を参照してください。
quic/core/quic_chaos_protector.cc
MAX_STREAMSフレーム
双方向ストリームを 最大いくつまで開いてよいかをピアに通知するためのフレームです。

HTTP/3 の SETTINGS フレーム
相手が“このコネクション上でフレームをどう送ってくるか”を制御するための、受信側からの設定宣言を行います。
クライアントとサーバーからそれぞれHTTP/3 の SETTINGS フレームが送信されています。
クライアント→サーバー
クライアントが自分が受信できる設定を宣言します。

サーバー→クライアント
サーバーが自分が受信できる設定を宣言します。

リクエスト処理
クライアントがindex.htmlをリクエストするパケットは以下の通りです。

PRIORITY_UPDATEフレームではストリームの優先度を更新するためのフレームです。
HEADERSフレームではリクエストヘッダを設定してGET /index.htmlを取得します。
レスポンス処理
サーバーがindex.htmlを返すパケットは以下のとおりです。

HEADERSフレームとDATAフレームが同じストリーム上でクライアントに送信されます。
まとめ
今回はHTTP/1.1〜HTTP/3の簡単な通信について、実際にどのようなデータが流れているかをWiresharkを使用して検証しました。
普通にWebアプリケーションを作成するだけの場合は、必要はありませんが、それぞれのプロトコルがどういう特性を持っているかについて簡単に抑えることができたのではないかと思います。
参考資料
- RFC8446 The Transport Layer Security (TLS) Protocol Version 1.3
- RFC7541 HPACK: Header Compression for HTTP/2
- RFC 9110 – HTTP Semantics
- RFC 9112 – HTTP/1.1
- RFC 9113 – HTTP/2
- RFC 9114 – HTTP/3
- RFC9000 QUIC: A UDP-Based Multiplexed and Secure Transport
- RFC9001 Using TLS to Secure QUIC
- SSL/TLS実践入門──Webの安全性を支える暗号化技術の設計思想 WEB+DB PRESS plus
- Real World HTTP 第3版 ―歴史とコードに学ぶインターネットとウェブ技術
- HTTP/2 in Action
- Validating HTTP/3 Protocol Support on a Local Site
- ChromeがQUICのInitialパケットに施すChaos Protection
-
RFC9293 Transmission Control Protocol (TCP) 3.4.1. Initial Sequence Number Selection ↩︎
-
RFC9293 Transmission Control Protocol (TCP) 3.8.6.2.2. Receiver's Algorithm -- When to Send a Window Update ↩︎
-
Does HTTP/2 require encryption?に
currently no browser supports HTTP/2 unencrypted.↩︎ -
RFC8446 The Transport Layer Security (TLS) Protocol Version 1.3 D.4 より
Either side can send change_cipher_spec at any time during the handshake, as they must be ignored by the peer, but if the client sends a non-empty session ID, the server MUST send the change_cipher_spec as described in this appendix.↩︎ -
HTTP/1.1でもkeep-alive + pipeliningを使用して1つのソケットで多重化が可能でした。しかしながら、FirefoxやChromeは pipelining を廃止しています。 ↩︎ ↩︎
-
HTTP/2は単一ソケット上で複数のストリームを送信できますが、パケットロス時に接続全体がHead-of-line blocking問題の影響を受ける可能性があります。Domain-Sharding for Faster HTTP/2 in Lossy Cellular Networksではパケットロスが多い環境下でのパフォーマンスの低下について言及しています。 ↩︎
-
RFC 9113 – HTTP/2 6.5.2. Defined Settingsに設定できる項目が定義されている ↩︎
-
Performance Evaluation of HTTP/2 and
HTTP/3(QUIC) using LighthouseNotably, HTTP/3 consistently surpasses HTTP/2 across all metrics except for Throughput where HTTP/2 gained little advantage. The result reveals HTTP/3’s superior capability to manage network instabilities and packet loss.↩︎ -
Performance Comparison of HTTP/3 and HTTP/2 with Proxy Integration
While optimized H2 can match H3 in some settings, H3 is more robust overall, showing less sensitivity to proxies, impairments, and congestion control variations.↩︎ -
Update on QUIC:2025年時点でNode.jsでQUICを使用するのが困難な理由が記載されている。 ↩︎
-
RFC 9000 QUIC: A UDP-Based Multiplexed and Secure Transport 8.1
Clients MUST ensure that UDP datagrams containing Initial packets have UDP payloads of at least 1200 bytes, adding PADDING frames as necessary.↩︎ -
RFC 9000 QUIC: A UDP-Based Multiplexed and Secure Transport 14.1
Similarly, a server MUST expand the payload of all UDP datagrams carrying ack-eliciting Initial packets to at least the smallest allowed maximum datagram size of 1200 bytes↩︎
Discussion