HTTPS 化したら動画のシークが 10 秒止まるようになった話

自宅サーバの動画ビューワーを HTTPS で見られるようにしたら、スキップ(シーク)を続けたときに再生が 10 秒前後止まるようになった。原因は HTTPS そのものではなく、一緒に有効になった HTTP/2 だった。

HTTP/2 では、同じサイトへの要求が 1 本の接続を共有する。先読みのために開いておいた別の動画が、その接続の受信枠を読み残しで埋めてしまい、シークで出した新しい要求にデータが届かなくなっていた。HTTP/1.1 に戻したら直った。

サーバもディスクも回線も測ると正常で、原因に辿り着くまで遠回りしたので、経過を残しておく。

構成と、変えたこと#

ビューワーは自作で、作りは単純だ。

  • サーバは Python の標準ライブラリの HTTP サーバで、mp4 を Range 要求に応じてそのまま返す。HTTP/1.1 だけを話す。
  • 再生ページは、再生中の 1 本に加えて、次の 2 本を画面に出さない <video preload="auto"> で先読みしておく。「次へ」を要素の差し替えだけで済ませるためだ。
  • ブラウザは Chrome(157)。

これまでは http://サーバ名:ポート/ に直接つないでいた。これを、Tailscale の tailscale serve を前に置いて HTTPS で開く形に変えた。serve は TLS を終端して裏の HTTP サーバへ中継するリバースプロキシで、ブラウザとの間は HTTP/2 になる。

その翌日から、数秒のスキップをいくつか続けると読み込みで止まる、という場面が出るようになった。ただし気づいた時点では、経路を変えたこととの関係ははっきりしていなかった。

調べて原因ではなかったもの#

疑わしい箇所を順に測ったが、どれも正常だった。

箇所 測ったこと 結果
サーバ 転送速度、スレッドや接続の溜まり、エラー記録 正常
リバースプロキシ 遅い読み手との同居、読み止めて 150 秒後の再開(curl と自作スクリプト) 止まらない
視聴端末までの回線 ブラウザから 50MB を取得 660〜876 Mbps
動画ファイル moov の位置、音声と映像の並び 問題なし
ディスク(HDD) エラー記録、未読部分を間を空けて読む待ち時間 最大 0.5 秒
書き込みとの競合 6GB 書きながらの読み込み待ち 最大 1.3 秒

ヘッドレスの Chrome で再生ページを開いて数分流しても、止まらなかった。後から分かったことだが、試し方が悪かった。ページを開いて 20 秒後から操作を始めていて、その時点では問題の起きる条件がもう消えていた。

途中で一つだけ後に効いてくる観察があった。Chrome は動画を少し先までしか読まず、先読みの要求は読むのを止めたまま約 15 秒握り、その後自分で打ち切る。

決め手: 止まった瞬間をページ側で記録する#

外から測っても分からないので、再生ページ自身に記録させた。再生中の動画が waiting のまま 1 秒以上止まったら、次のものをサーバに送ってログに 1 行残す。

  • 止まった位置と長さ、再生速度
  • 止まる前後のバッファの範囲(video.buffered)
  • 直前 20 秒の出来事(seeking、stalled、動画の切り替えなど)
  • 終わった動画の要求の開始・終了時刻とプロトコル(Resource Timing)

普段どおり使っているうちに 5 回記録された。止まった長さは 9.4〜14 秒で、並べると共通点がはっきりしていた。

  • 5 回とも、シークの直後に止まっている。
  • 5 回とも、先読み中の別の動画の要求が終わった(Chrome が打ち切った)時刻の 50 ミリ秒以内に再開している。
  • その先読みの要求は、どれも開始から 15〜17 秒経っていた。
  • プロトコルはすべて h2。

つまり、シークで出した新しい要求が、先読みの要求が消えるまで待たされていた。ここから再現手順も分かった。ページを開いて 3 秒後から 20 秒スキップを 14 回続けると、HTTP/2 では 12.2 秒止まり、HTTP/1.1 での直接接続では止まらない。

原因: 接続全体の受信枠を、読みかけの先読みが埋める#

HTTP/2 には、受け取る側が「あとどれだけ送ってよいか」を相手に伝えるしくみ(フロー制御)がある。枠は要求(ストリーム)ごとと、接続全体の 2 段で管理される。受け取ったまま読まれていないデータは、接続全体の枠も消費したままになる。

Chrome の動画の読み方は、これと相性が悪い。

  1. 動画は少し先まで溜めたら、それ以上読まない。最後まで見るとは限らないからだ。
  2. 止めた要求はすぐには切らず、約 15 秒握る。続きが要るようになったとき、読むのを再開するだけで済む。
  3. その間、サーバから届いたが読まれていないデータが、要求ごとに数 MB 残る。

HTTP/1.1 なら要求ごとに別の TCP 接続なので、止まるのはその接続だけで、他には響かない。HTTP/2 では全部が 1 本の接続に載るので、先読み 2 本の読み残しが接続全体の枠の大半を占める。Chrome は読み終えた分の枠もある程度まとめてから返すので、その未返却分と合わせて枠が尽きる。シークでバッファの外に出ると新しい要求が要るが、サーバはもう何も送れない。Chrome が先読みを打ち切って枠を返すまで、再生は止まったままになる。

どちらかが間違っているわけではない。「読みかけで止めてしばらく握る」のは 1 要求 1 接続の時代には筋の通った工夫で、接続を共有するようになって初めて他の要求に響く。複数の動画を先読みするページでなければ、まず目立たない。

最小構成での再現#

ビューワーや Tailscale に固有の話ではないことを確かめるため、何も依存しない形で再現した。

  • 動画: ffmpeg で作った 25 分・約 4Mbps の合成映像を 3 本(moov は先頭)。
  • サーバ: Node.js の標準モジュールだけで書いた、Range 付きでファイルを返すサーバ。同じ中身を HTTP/1.1(平文)、HTTP/1.1(TLS)、HTTP/2(TLS)の 3 通りで出す。
  • ページ: <video preload="auto"> を 3 つ置き、1 本だけ再生する。3 秒後から 0.3 秒おきに currentTime += 20 を 14 回。
  • ブラウザ: ヘッドレスの Chrome 157。
html
<video id="a" muted playsinline preload="auto" src="a.mp4"></video>
<video id="b" muted playsinline preload="auto" src="b.mp4"></video>
<video id="c" muted playsinline preload="auto" src="c.mp4"></video>
<script>
const a = document.getElementById("a");
a.play();
setTimeout(async () => {
  for (let i = 0; i < 14; i++) {
    a.currentTime += 20;
    await new Promise(r => setTimeout(r, 300));
  }
}, 3000);
</script>
出し方 先読みの 2 本 結果
HTTP/1.1(平文) あり 止まらない
HTTP/1.1(TLS) あり 止まらない
HTTP/2 なし 止まらない
HTTP/2 あり 約 12 秒止まる

TLS の有無は関係がなく、HTTP/2 と先読みの組み合わせで起きる。ただし最後の行は「必ず」ではない。その話は次の節に書く。

通信記録で見えたこと#

Chrome を --log-net-log 付きで起動すると、HTTP/2 のフレーム単位の記録が取れる。止まった回の経過は次のとおりだった。接続全体の受信枠は約 15.7MB、ストリームごとの枠は 6MB である。

時刻 出来事
0 秒 動画 3 本の要求。最初の 2 秒でそれぞれ約 16MB を受信し、そこで止まる
3.0 秒 シーク。再生中の要求を取り消し(RST_STREAM)、位置を指定した新しい要求を出す
3.6〜6.6 秒 シークのたびに取り消しと新しい要求(さらに 6 本)。応答ヘッダは返るが、本体は 1 バイトも届かない
15.3 秒 Chrome が先読みの 2 本を取り消す。同時に接続全体の枠を 7.2MB と 7.9MB、計 15.0MB 返す(WINDOW_UPDATE)
15.3 秒〜 データが流れ始め、再生が再開する

接続全体の枠 15.7MB のうち 15.0MB が、先読みを取り消すまでサーバに返されていなかったことになる。内訳は、先読み 2 本の読み残しが 8〜9MB、残りは Chrome が読み終えたがまだ返していなかった分とみられる。Chrome は枠をある程度まとめて返すようで、これは記録の数字からの推定である。

遠回り: 止まらない回がある理由を取り違えた#

最小構成を作った当初、HTTP/2 でも止まらなかった。ビューワーとの違いを一つずつ足していくと、動画の音声を Web Audio(createMediaElementSource)に通したときだけ止まった。ビューワーは音量の増幅のために Web Audio を使っているので、これが条件の一つだと思い込んだ。

これは誤りだった。通信記録を並べて分かったのは次のことだ。

  • 要求の属性(CORS のモードや認証情報の扱い)は、Web Audio の有無でまったく同じだった。
  • 記録を取れた止まらなかった回では、Chrome が同じサイトへ HTTP/2 の接続を 2 本持っていた。動画 3 本が 1 本目に載った直後、favicon の要求で 2 本目が張られ、それ以降の新しい要求は 2 本目に載っていた。
  • 2 本目の枠は空いているので、シーク後の要求は詰まった 1 本目を避ける形になり、止まらない。
  • Web Audio を使うと動画の読み込み開始がわずかに遅れ、接続の切り替えが先に済む。動画もシーク後の要求も同じ接続に載るので、止まる。

つまり分かれ目は「シーク後の要求が、先読みと同じ接続に載るかどうか」で、Web Audio は接続の張られ方のタイミングをずらしていただけだった。Web Audio なしでも、接続が 1 本の回は同じように 12.3 秒止まる。

ただし、止まらなかった回のうち記録を取って接続の本数まで確かめたのは 1 回だけで、止まらない回がすべてこの形なのかは確かめていない。

条件を変えて比べるときは、結果だけでなく接続の本数も一緒に見ないと、無関係な要因を原因と取り違える。実際のビューワーは長く開いたまま使うので、接続は 1 本に落ち着き、逃げ道がなかった。

対処#

ブラウザと HTTP/1.1 で話すように戻した。tailscale serve には HTTP/2 だけを切る設定が無かったので、HTTP のプロキシをやめて、TLS だけ終端して TCP をそのまま裏へ渡す形にした。

bash
tailscale serve --https=12345 off
tailscale serve --bg --tls-terminated-tcp=12443 tcp://127.0.0.1:9876

この形だと serve は ALPN で h2 を名乗らず、ブラウザは裏のサーバと直接 HTTP/1.1 で話す(tailscale 1.104.5 で確認)。URL も公開範囲も変わらない。同じ操作で止まらなくなった。

張り替えた直後、開いたままだったブラウザでは 404 になった。張り替え前の HTTP/2 接続を Chrome が使い回していて、そこに届いた要求には行き先が無いからだ。ブラウザを開き直すと直る。

ほかに考えられる対処は次のとおりだが、どれも試していない。

  • 先読みを preload="metadata" にして、読み残しを減らす。
  • 先読みの本数を減らす。
  • 動画だけ別のポートから出して、接続を分ける。ホスト名を変えるだけでは、同じ IP アドレスと証明書なら Chrome が接続をまとめることがある。

確かめていないこと#

  • Firefox など、Chrome 以外のブラウザでの動き。
  • nginx や Caddy を前に置いた場合。Node.js のサーバ単体と、Node.js で書いたプロキシ越し、tailscale serve の 3 通りでは再現した。
  • Chrome が先読みを約 15 秒で打ち切る根拠。挙動として観測しただけで、Chromium のソースでは確かめていない。読むのを止める理由や握る理由も、筆者の理解である。
  • Chrome が 2 本目の接続を張る条件。favicon の要求で張られた回を見ただけで、いつそうなるかは追っていない。

まとめ#

  • 複数の動画を先読みするページを HTTP/2 で出すと、先読みの読み残しが接続全体の受信枠を埋め、シーク後の読み込みが 10 秒前後止まることがある。
  • HTTPS 化やリバースプロキシの導入は、HTTP/2 を連れてくることが多い。経路を変えた後に挙動が変わったら、プロトコルが変わっていないかを見る。
  • サーバ側をいくら測っても分からないときは、ページ側で「止まった瞬間」を記録するのが早い。今回は 5 件の記録で方向が決まった。
  • ご想像の通り、AI による記述です。