Webアプリケーションのフロントやバックエンドの開発でHTTPを使用したプログラムを実装している人は多いかと思います。
しかしながら、実際、そのデータがどう流れているかを具体的に意識する機会は少ないです。
本記事の目的はHTTP/1.1→HTTP/2→HTTP/3で、送受信されるデータがどのように変わったかを確認することを目的とします。
以下のケースで、データがどのように流れているかをWiresharkを使用して確認します。
今回は再送時やエラー時の検証などは範囲外とし、各ケースにおいて簡単なデータの送受信でどのような違いがあるかを確認するのにとどめます。
また、実験方法と結果について記載はしますが、環境やバージョンによって必ずしも完全に一致する結果にはなりません。
本記事は、次のような読者を想定しています。
また、以下の程度の知識を前提とします。
以下のHTMLとjavascriptを使用して、ブラウザとサーバーの通信を確認します。
javascriptは5個のREST APIをサーバーに対して実行します。
動作するブラウザはChrome 143.0.7499.41とします。
index.html
<!doctypehtml><htmllang="ja"><head><metacharset="utf-8"/><title>Axios keep-alive / 接続検証</title></head><body><h1>Axios + ブラウザの接続再利用検証</h1><buttonid="button-seq">順番に 5 リクエスト</button><buttonid="button-par">並列に 5 リクエスト</button><preid="log"style="border:1px solid#ccc;padding:8px;max-height:400px;overflow:auto;"></pre><!-- axios CDN(楽をする) --><scriptsrc="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script><scriptsrc="/client.js"></script></body></html>client.js
// client.jsconst logEl=document.getElementById("log");const btnSeq=document.getElementById("button-seq");const btnPar=document.getElementById("button-par");functionlog(msg){console.log(msg); logEl.textContent+= msg+"\n"; logEl.scrollTop= logEl.scrollHeight;}// axios インスタンス(設定を変えたいならここで)const api= axios.create({baseURL:"/",// withCredentials: false,// ブラウザ版 axios は keep-alive を直接制御できない(ブラウザ任せ)});asyncfunctioncallOnce(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++){awaitcallOnce(i);}});// 並列に 5 回呼ぶbtnPar.addEventListener("click",async()=>{log("---- 並列に 5 リクエスト ----");awaitPromise.all(Array.from({length:5},(_, i)=>{returncallOnce(i);}));});平文でHTTP/1.1通信を行った場合に、どのようなデータが流れているかを検証します。
HTTP/1.1の詳しいメッセージ構文、メッセージ解析、接続管理などは以下を参照してください。
RFC 9112 – HTTP/1.1
Node.jsの環境を構築後以下のサーバーを動かす。
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 イベントで振った IDconsole.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:newDate().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);});});constPORT=8080;server.listen(PORT,()=>{console.log(`HTTP/1.1 server listening on http://localhost:${PORT}`);});実験方法は以下のとおりです。
node http1-server.js
tcp.port == 8080 || udp.port == 8080でフィルタをかける

http://localhost:8080/index.htmlにアクセス
localhostとの通信のProtocolがhttp/1.1になっていることが確認できます。
なお、axios.min.jsは cdn.jsdelivr.net との通信のためh2ーつまりhttp/2になります。



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

これにより、複数のポートが使われていることがわかります。
つまり、HTTP/1.1で接続した場合、ブラウザからは複数のソケットが作成されて通信が行われています。

chromeの場合はホストあたり最大6接続おこなわれます。
なお、上記の例で7接続しているようにみえますが、56635と56636は56740が接続される前に通信が終了していることが確認できます。
では次に、それぞれソケットの詳細を確認してみましょう。ここではポート:56740で行ったGET /api/test?i=0リクエストでどのようなやり取りをしたかを確認します。

図でまとめると以下のような流れになります。
まずTCPのソケットを接続するために以下の電文が流れていることが確認できます。
これが three-way (or three message) handshake (3WHS) と呼ばれるものになります。[1]
接続が完了した後に、サーバー側が[TCP Window Update]+[ACK]でサーバ側の受信ウィンドウの通知を行います。[2]
ここでようやく、ブラウザ側がGET /api/test?i=0のリクエストがテキストとして送られることが確認できます。
サーバーはリクエストを受信をすると、その受信を確認するACKを送信します。
サーバーの処理が終わって、クライアントにレスポンスを返します。これもTCPの上にテキストとして送信されていることが確認できます。
クライアントはレスポンスを受信すると、その受信を確認するACKを送信します。
通信が終わってしばらくするとサーバー側から[FIN, ACK]が送信されてソケットが終了します。
HTTP/2, HTTP/3について確認をする前にTLS1.3で暗号化をした場合にどうなるかを確認します。
HTTP/2についてはプロトコルの仕様的には暗号化は不要ですが、一般的なブラウザから実験する場合は暗号化が必須となります[3]。
この章では、自己署名証明書を用いたサーバーを作成してHTTPSの電文をWiresharkで確認します。
まず、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サーバーを作成します。
httpモジュールをhttpsモジュールに置き換えます。
// https1-server.js'use strict';const fs=require('fs');const https=require('https');// ★ http → httpsconst 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:newDate().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);});});constPORT=8443;server.listen(PORT,()=>{console.log(`HTTPS (HTTP/1.1) server listening on https://localhost:${PORT}`);});ブラウザなどがSSLKEYLOGFILE形式でログを出力し、Wiresharkがそれを参照することで暗号化された通信を解析することが可能になります。
!SSLKEYLOGFILEに出力される鍵があれば TLS 通信は後からでも復号可能になります。
したがって以下のことを心がけてください。
まず、環境変数 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-chromeChrome起動後にブラウジングするとtls_keys.logに以下のような項目が追記されていきます。
CLIENT_HANDSHAKE_TRAFFIC_SECRET5c6c19a279e98ce289cfccf11fb92a17abb21b406b00c24b82858542a36a1ba59f01c9660a1e5a8844655bf0f581255493598c3bf17ad472ed11786c1ff38529SERVER_HANDSHAKE_TRAFFIC_SECRET5c6c19a279e98ce289cfccf11fb92a17abb21b406b00c24b82858542a36a1ba507a2fe5d4952970487dbbea9b32c820ab7a37386e3988a17cef4042998979cd0CLIENT_HANDSHAKE_TRAFFIC_SECRET8ff14b8b00ee17aedc918948ece2f2044e72f60f9f6d94d40d3678ff0135cf4504282d3f59f5ac36c757879af0cefa771e632db8c38222bc46c7740fd098101eSERVER_HANDSHAKE_TRAFFIC_SECRET8ff14b8b00ee17aedc918948ece2f2044e72f60f9f6d94d40d3678ff0135cf4586bfb053e77506cc9fc770ef338206169f88bab4ad9a0036890071fecaae886eCLIENT_HANDSHAKE_TRAFFIC_SECRETcd96bf36f7e812458f1dd65a282462f852c0cce5a2724673842a4d91a6f0db3076ed3e784da27cc290fd684ffa9d0996b033b52761ec0cb6bf9c4616ed8a0b7cSERVER_HANDSHAKE_TRAFFIC_SECRETcd96bf36f7e812458f1dd65a282462f852c0cce5a2724673842a4d91a6f0db30152654e664bdf545c95413bdf61a407d194aa9f0a3aebcab1182181a3ab187a3CLIENT_TRAFFIC_SECRET_08ff14b8b00ee17aedc918948ece2f2044e72f60f9f6d94d40d3678ff0135cf450bad06ad316c9f7d720021f75e3cd0a9735207063cc315ff9d92a5c48c4701c3SERVER_TRAFFIC_SECRET_08ff14b8b00ee17aedc918948ece2f2044e72f60f9f6d94d40d3678ff0135cf4546b80f7a12e303c5bfe8b6bc840fc60de136c4d8896971c0ffa879ff458aec26EXPORTER_SECRET8ff14b8b00ee17aedc918948ece2f2044e72f60f9f6d94d40d3678ff0135cf459c06904b2e8326ff564bf64e6e8aca89a254bdb746c4d8975926a8846bbbb44bCLIENT_TRAFFIC_SECRET_05c6c19a279e98ce289cfccf11fb92a17abb21b406b00c24b82858542a36a1ba5276d9749c7148e701e2cb218bc5068235cb30021fcc45a35c4a0967a2400e75dSERVER_TRAFFIC_SECRET_05c6c19a279e98ce289cfccf11fb92a17abb21b406b00c24b82858542a36a1ba5d158ec1ade0903db23acced3efa340e6f6d06dccb684c756f1bdce5f368e26dcEXPORTER_SECRET5c6c19a279e98ce289cfccf11fb92a17abb21b406b00c24b82858542a36a1ba5df212635631d0b7ad96ceced2981d19db0213650f990f3354ba24b942d6387a2... 以下略メニューのWireshark → Preferencesを選択
Preferencesダイアログの左ツリーよりProtocolsを選択
TLSを選択後、(Pre-)Master-Secret log filename or RSA keys list にtls_keys.logを指定
以後、Wiresharkでキャプチャを行うと暗号化されているはずのHTTPの確認が可能となる。
実験方法は以下のとおりです。
node https1-server.jstcp.port == 8443 || udp.port == 8443でフィルタをかける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-chromehttps://localhost:8443/index.htmlにアクセス開発者ツールのNetworkレベルではHTTP/1.1の時と結果に違いはありません。

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



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


この図でまとめると以下のような流れになります。
three-way (or three message) handshake (3WHS) と [TCP Window Update]+[ACK]までは平文の時と同じです。
HTTPSで接続した場合はTLSv1.3のプロトコルでブラウザからClientHelloが送信されます。
ClientHelloではクライアントから通信に使用する設定の希望を通知します。
例として以下について確認してみます。


ClientHelloを受け取ったサーバーはServerHello、ChangeCipherSpec、EncryptedExtensions、Certificate、CertificateVerify、Finishをクライアントに送信します。
二回目以降の通信などでは、Certificate、CertificateVerifyは送信されないケースもあります。
ServerHelloではクライアントから通知された通信に使用する設定の採用結果を通知します。
たとえば、サーバーが選択した TLS のバージョンや鍵交換に使用するサーバー側の公開鍵が確認できます。
TLS1.3 の仕様では ChangeCipherSpec は意味を持たないので無視してください。[4]

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

この例では、クライアントが提示したh2(http/2)とhttp/1.1 のうち、http/1.1を採用したことを表します。
サーバー証明書の送信します。
送信したCertificatesの内容はserver.crt中のものと一致します。
もし、自分の目で確認したい場合は以下のコマンドでserver.crtを人が見やすい形式に出力が可能です。
openssl x509 -in server.crt -text -nooutCertificate: 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:85Serial Number:などと一致することが確認できます。
Certificateは二回目以降、送られない場合もあります。
サーバーがサーバー証明書に対応する秘密鍵を保持していることを示すために、秘密鍵で署名したデータを送信します。
クライアントはサーバー証明書に含まれる公開鍵とこの署名データで検証し、サーバーに対するなりすましが行われていないことを確認します。
CertificateVerifyは二回目以降、送られない場合もあります。
これまでのハンドシェイクメッセージの履歴をハッシュした結果であるTranscript Hashを入力として計算したメッセージ認証コード(MAC)を送ります。
相手側は自分でも同じ計算を行い、値が一致することを確認することで、ハンドシェイクが改ざんされておらず、同じ内容と鍵を共有できていることを確かめます。


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

NewSessionTicketは二回目以降、送られない場合もあります。実際、ポート:50919には存在しますがポート:50918には存在しません。
その後はHTTPプロトコルでリクエストとレスポンスが帰っていることが確認できます。
これについては前述のHTTP/1.1と変わりません。
HTTP/1.1では実質的にブラウザの実装として複数ソケットが前提でした[5]が、HTTP/2 はRFC 9113 – HTTP/2では1本のソケット上で多重化+ヘッダ圧縮+優先度制御を行います。
HTTP/2のデータ送受信のイメージ図は以下のとおりです。
1つのコネクションの中に複数のストリームが存在し、ストリームの中をデータフレーム単位で送受信します。
なお、HTTP/2であってもHead-of-line blocking問題は完全には解決されていません。[6]
httpモジュールではなくhttp2モジュールを使用することで実現可能です。
今回はindex.html読み込み時にEarly Hints を使用してclient.jsを読み込むようにしています。
Early Hintsは、ページ本体の応答が準備される前に、ブラウザに必要なリソース(JavaScript や CSS など)の読み込みを先行して開始させるための仕組みです。HTTP/1.1 の時代から仕様上は利用可能でしたが、ブラウザの制限による1 コネクションあたり 1 リクエストという制約のため、実際の効果は限定的でした[5:1]。HTTP/2 では 1 コネクション内で複数のストリームを同時に扱えるため、Early Hints によるプリロードの効果が大きく発揮されます
// http2-server.js'use strict';const fs=require('fs');const http2=require('http2');// ★ https → http2const 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:newDate().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);});});constPORT=8443;server.listen(PORT,()=>{console.log(`HTTPS (HTTP/2) server listening on https://localhost:${PORT}`);});実験方法は以下のとおりです。
node http2-server.jstcp.port == 8443 || udp.port == 8443でフィルタをかける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-chromehttps://localhost:8443/index.htmlにアクセス開発者ツールのNetworkを確認するとprotocolの列が"h2"と表示されていることが確認できます。




http2でフィルタをかけると使用されているポート数が減っていることが確認できます。
ポート49906でのみほとんどの通信をしており、ポート49907はほぼ使われていません。
前述したとおり、1つのコネクション中で同時に送受信が行われていることが確認できます。
ポート49906にながれるデータを整理すると以下の図になります。
SnはストリームIDを表します。今回の図の例ではS0, S1, S3の最大3並行で動作しています。
HTTP/2 クライアントがコネクション確立直後に必ず送る 24 バイトの固定文字列が送信されています。

相手が“このコネクション上でフレームをどう送ってくるか”を制御するための、受信側からの設定宣言を行います。[7]
このフレームを受け取った側は、Flagsに0x01(ACK)を設定して応答する必要があります。
今回の例では以下のようになっています。
627 ブラウザからサーバーのSETTINGSフレームの内容は以下の通りです。
このフレームについてサーバーは639で応答を返しています。
一方サーバー側もサーバーからの最初の送信(635)でSETTINGSフレームを送っています。
これは特に変更したい値がないため、「デフォルト設定を使用する」という意味になります。
これに対してクライアントは637で応答を返しています。
クライアントからの初回送信時に、接続プレフィックス、SETTINGSフレームと共に送信されていることが確認できます。
これはフロー制御ウィンドウを増やすための処理で、受け取れるデータ量の上限を示すカウンタをあらわします。
今回は DATAの総量を64KBから15MBに増やしています。
フロー制御ウィンドウはDATAフレームを受信すると減っていき、WINDOW_UPDATEフレームを受信すると増えます。
今回のキャプチャの結果でも709, 921とWINDOW_UPDATEフレームが送信されてフロー制御ウィンドウの数値を増やしています。


今回は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がつきます。

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 をはじめとする主要ブラウザーでサポートが廃止されており、実質的に利用できなくなっています。
HTTP/2ではヘッダーが圧縮されるようになりました。
HPACKという方式で圧縮されて、Header Block Fragmentに格納されています。
Wiresharkではこの結果を解析した内容がHeaderとして表示されています。ここでは同じようにHeader Block Fragmentを解析してみたいと思います。
まずHeader Block Fragmentのデータを以下のようにコピーして保存しておいてください。
今回は以下のHEADERSフレームのHeader Block Fragmentを解析してみます。
そのために、golang.org/x/net/http2/hpackを使用した解析プログラムを作成し、Header Block Fragmentを解析します。
サンプルコード
// go get golang.org/x/net/http2/hpack@latestpackage mainimport("encoding/hex""fmt""log""golang.org/x/net/http2/hpack")// HEADERS GET /のHeader Block Fragmentconst headerBlock1Hex="82418aa0e41d139d09b8f34d33878440874148b1275ad1ffb8fe711cf350552f4f61e92ff3f7de0fe42d33fcfd29fcde9ec3d26b69fe7efbc1fc85a67f9fa53f9d274a90ff576c1d527f3f7de0fe44d7f3408b4148b1275ad1ad49e33505023f30408d4148b1275ad1ad5d034ca7b29f07226d61634f53224092b6b9ac1c8558d520a4b6c2ad617b5a54251f01317ad9d07f66a281b0dae053fad0321aa49d13fda992a49685340c8a6adca7e28104416e277fb521aeba0bc8b1e632586d975765c53facd8f7e8cff4a506ea5531149d4ffda97a7b0f49580b4cae05c0b814dc394761986d975765cf53e5497ca589d34d1f43aeba0c41a4c7a98f33a69a3fdf9a68fa1d75d0620d263d4c79a68fbed00177fe8d48e62b03ee697e8d48e62b1e0b1d7f46a4731581d754df5f2c7cfdf6800bbdf43aeba0c41a4c7a9841a6a8b22c5f249c754c5fbef046cfdf6800bbbf408a4148b4a549275906497f83a8f517408a4148b4a549275a93c85f86a87dcd30d25f408a4148b4a549275ad416cf023f31408a4148b4a549275a42a13f8690e4b692d49f50929bd9abfa5242cb40d25fa523b3e94f684c9f5193e83fa2d4b70ddf7da002effd16afbed00177bf4086aec31ec327d785b6007d286f"// HEADERS GET /client.js の Header Block Fragmentconst headerBlock2Hex="82cb870487609418b5257e8853032a2f2a7f068840e92ac7b0d31aaf7f0685a8eb10f6237f0584412c356973919d29ad1718628390744e7426e3cd34cb1fcbc5c47f0485b600fd286f"funcmustDecodeHex(sstring)[]byte{b, err:= hex.DecodeString(s)if err!=nil{log.Fatalf("hex decode error: %v", err)}return b}funcprintHeaderBlock(dec*hpack.Decoder, block[]byte, labelstring){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()}funcmain(){// 動的テーブルサイズはひとまず 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: ?0sec-ch-ua-platform: "macOS"upgrade-insecure-requests: 1user-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.36accept: 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.7sec-fetch-site: nonesec-fetch-mode: navigatesec-fetch-user: ?1sec-fetch-dest: documentaccept-encoding: gzip, deflate, br, zstdaccept-language: ja,en-US;q=0.9,en;q=0.8priority: u=0, i=== HEADERS #2 ===:method: GET:authority: localhost:8443:scheme: https:path: /client.jsaccept: */*sec-fetch-site: same-originsec-fetch-mode: no-corssec-fetch-dest: scriptreferer: 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.36accept-encoding: gzip, deflate, br, zstdaccept-language: ja,en-US;q=0.9,en;q=0.8priority: u=1, iこのプログラムを確認するとわかりますが、最初のHeader Block Fragmentと次のHeader Block Fragmentのサイズに大きな違いがあることがわかります。
ヘッダーの圧縮には、過去に同じ方向(クライアント→サーバー)に送信した HEADERS フレーム のデータを利用して圧縮しているため、初回のサイズは大きく、それ以降は小さなサイズになります。
接続状態が機能しているかを検証するために使用します。
PINGフレームを受信した場合、ACK フラグを設定した PING フレームを返信する必要があります。
ブラウザ→サーバーへのPING
上記の応答
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のままとする方法もあります。
まず、HTTP/3の実験を行う前にgoで簡単なQUICのクライアントとサーバーを作成して、その通信内容を確認します。
以下に簡単なサンプルコードを提示します。
サーバー: quic_server.go
// quic_server.go// TODO:// go get github.com/quic-go/quic-go@latestpackage mainimport("context""crypto/tls""log""net"quic"github.com/quic-go/quic-go")funcmain(){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"},// ALPNMinVersion: 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())// 検証用途ならここで適当にストリームを開いたり受けたりすればOKgohandleConn(conn)}}funchandleConn(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}gohandleStream(stream)}}funchandleStream(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@latestpackage mainimport("context""crypto/tls""crypto/x509""io""log""os"quic"github.com/quic-go/quic-go")funcmain(){// --- 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,// クライアントがサーバ証明書を検証するための CAServerName:"localhost",// server.crt の CN または SAN と一致させるNextProtos:[]string{"quic-echo-example"},// サーバと同じ ALPNMinVersion: 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#DialAddrconn, 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.goexport SSLKEYLOGFILE=/work/techblog/http2/tls_keys.log go run quic_client.go
このデータを見るとInitial, Handshake...といったQUICパケットがUDPで送信されていることがわかります。
QUICパケットの中にはCRYPTO、PADDING、ACK...などのフレームが含まれています。
これらのパケットやフレームの詳細については以下で定義されています。
このパケットの流れをまとめると以下のような図になります。
クライアントからのTLSのハンドシェイクのメッセージはCRYPTOフレームで構成されたInitial パケットに乗せて送信されます。
クライアントから送信するInitial パケットには少なくとも1200バイトのペイロードが必要なため、必要に応じてPADDINGフレームで穴埋めをします。[11]
次が実際のQUICパケットの内容になります。

今回送信されたCRYPTOフレームを全て合わせると以下のようなClient Helloになります。
サーバーからのServerHello、EncryptedExtensions、Certificate CertificateVerify、Finishedを送信しハンドシェイクを行います。
サーバーはクライアントからのClientHelloによりパケット番号0 と パケット番号1を受信しました。
まず、パケット番号0 を受信したことを ACK フレームで通知します。
ACKフレームのみの場合だと、クライアントのACKを誘発しないのでPADDINGフレームを送りません。[12]
次に以下のフレームを含むInitialパケットを送信します。

ServerHello送信後はHandshakeパケットでCRYPTOフレームにEncryptedExtensions、Certificate CertificateVerify、Finishedを乗せて送信します。

サーバーがハンドシェイクをした送信したのち、サーバーからNEW_CONNECTION_IDフレームが送信されます。
これはクライアントが今後、Destination Connection ID(DCID)として使用していいIdです。どのタイミングでその ID に切り替えるかはクライアント側の裁量となります。
ここで現れた978b7549は、のちのクライアントからの送信時にDCIDとしてつかわれていることが確認できます。
クライアントはServerHello を含む Initial パケットを受信したため、そのパケット番号を ACK フレームで通知します。
サーバーからのHandshakeに対する返信のHandshakeパケットとShort Header パケットをクライアントから送信します。
サーバーがTLS1.3のNew Session Ticketを送信します。
クライアントからサーバーに対してhello QUICという文字列を送ります。

ここでのACKフレームはサーバから受信したNEW_CONNECTION_IDフレームを含むパケット番号をACKフレームで通知します。
STREAMフレームには送信したい文字列を載せます。今回のStream Dataは以下のようになっています。
サーバーはクライアントから文字を受信したら、その内容をエコーします。
クライアントからのSTREAMフレームを含むパケットを受信したことをACKフレームで通知します。
STREAMフレームでエコーする内容を乗せます。今回のStream Dataは以下のようになっています。
クライアントはサーバーからのエコーを含むパケットを受信したことをACKフレームで通知します。
その後、クライアントはCONNECTION_CLOSEフレームを送信して通信を終了します。
goではgithub.com/quic-go/quic-go/http3を使用することで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.gopackage mainimport("encoding/json""fmt""log""math/rand""net/http""os""path/filepath""sync/atomic""time"http3"github.com/quic-go/quic-go/http3")var requestCounterint64funcmain(){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)}}// 共通ログ用ミドルウェアfuncloggingMiddleware(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"funchandleRoot(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"funchandleClientJS(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"funchandleAPI(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)}funcfirstOrNil(v[]string)interface{}{iflen(v)==0{returnnil}return v[0]}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
実験方法は以下のとおりです。
go run http3-server.gotcp.port == 8443 || udp.port == 8443でフィルタをかける--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-chromehttps://localhost:8443/index.htmlにアクセスHTTP/3のプロトコルについては下記を参照してください。
RFC9114 HTTP/3
開発者ツールのNetworkタブでProtocolを確認するとh3と表示されることが確認できます。
Wiresharkを確認するとQUICプロトコルとHTTP/3プロトコルのみが存在することが確認できます。(TCPは存在しない)


プロトコルをhttp3でフィルタするとHTTP/2と同様に1つのポートしか使用していないことが確認できます。
QUICプロトコルをよくみると、先ほどの単純なQUICのクライアント・サーバーの結果には存在していないフレームがいくつか存在します。
クライアントからのInitialパケットに複数のPINGフレームが存在していることが確認できます。
これは、将来の拡張のため、Initial パケットに PING / PADDING をランダムに挟む実装をあえてしています。[13]
どのようにInitialパケットにPINGフレームやPADDINGフレームを入れているかについては、以下の実装を参照してください。
quic/core/quic_chaos_protector.cc
双方向ストリームを 最大いくつまで開いてよいかをピアに通知するためのフレームです。
相手が“このコネクション上でフレームをどう送ってくるか”を制御するための、受信側からの設定宣言を行います。
クライアントとサーバーからそれぞれHTTP/3 の SETTINGS フレームが送信されています。
クライアント→サーバー
クライアントが自分が受信できる設定を宣言します。
サーバー→クライアント
サーバーが自分が受信できる設定を宣言します。
クライアントがindex.htmlをリクエストするパケットは以下の通りです。
PRIORITY_UPDATEフレームではストリームの優先度を更新するためのフレームです。
HEADERSフレームではリクエストヘッダを設定してGET /index.htmlを取得します。
サーバーがindex.htmlを返すパケットは以下のとおりです。
HEADERSフレームとDATAフレームが同じストリーム上でクライアントに送信されます。
今回はHTTP/1.1〜HTTP/3の簡単な通信について、実際にどのようなデータが流れているかをWiresharkを使用して検証しました。
普通にWebアプリケーションを作成するだけの場合は、必要はありませんが、それぞれのプロトコルがどういう特性を持っているかについて簡単に抑えることができたのではないかと思います。
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 Lighthouse Notably, 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 IntegrationWhile 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.1Clients 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.1Similarly, 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↩︎
