新規投稿のお知らせを受信されたい方は、サブスクリプションをご登録ください:

ハイパーHTTPライブラリの中に潜むバグを発見した方法

2026-06-22

12分で読了
この投稿はEnglishおよび한국어でも表示されます。

このコンテンツは自動機械翻訳サービスによる翻訳版であり、皆さまの便宜のために提供しています。原本の英語版と異なる誤り、省略、解釈の微妙な違いが含まれる場合があります。ご不明な点がある場合は、英語版原本をご確認ください。

Imagesサービスは、Rustで構築されWorkers上で動作しており、Cloudflareのエッジネットワーク上のすべてのマシンで実行されています。クライアント接続を処理するために、Rust用のオープンソースHTTPライブラリであるhyperを使用しています。

昨年、私たちはImagesバインディングを導入し、Workersでリモート画像を処理するためのカスタムのプログラムワークフローを可能にしました。2025年末には、WorkersランタイムとImagesサービス間のより直接的なローカル接続を提供するために、バインディングを再設計しました。

ロールアウト直後、バインディングからのTransformationsリクエストが失敗しているという報告を受けましたが、それは断続的かつ大きな画像のみでした。さらに奇妙なことに、これらのリクエストに対する応答は、エラーがログに記録されることなく、200 ステータスを返しました。画像データは単に切り捨てられ、2メガバイトになるはずの応答が数百キロバイトで届くことがあります。

私たちは6週間、特定の条件下でのみ発生する競合状態である、Imagesバインディングが処理した画像データをクライアントに返す方法に影響を与えるハイパーライブラリのバグを追跡することに費やしました。最終的に、その修正には4行のコードが必要になりました。

ホップ、ハンドオフ、ハイパー

Cloudflare上で開発を行う場合、開発者はバインディングを通じてWorkersからアクセスできる一連のプラットフォームサービスからフルスタックアプリケーションを作成します。バインディングは、コンピューティングストレージAI推論メディア処理など、開発者プラットフォーム上のリソースに直接APIを提供します。

Imagesのバインディングは、画像の最適化を配信から切り離します。出力をHTTPレスポンスとして返すことなく、画像のトランスコード、合成、操作を行うことができます。また、最適化パラメーターを、URLインターフェースによって課される固定された順序に従うのではなく、任意の順序で適用することもできます。ここでは、Workerは画像データをImages APIに直接渡し、操作を連鎖させ、処理された結果をストリームとして返すことができます。

const result = await env.IMAGES
  .input(image)
  .transform({ width: 800, rotate: 90 })
  .output({ format: "image/avif" });
return result.response();

大まかにいうと、このように画像データはさまざまなサービスを介して移動します。

パイプは、中間者とImagesの間のソケット接続を表し、データはカーネルのバッファを介して、あるプロセスから次のプロセスに引き渡されます。

バインディングは、Workersランタイムによって管理されるソケット接続を介してImagesと通信します。ソケット接続は、2つのプロセス間の通信チャネルです。ソケットの各エンドには、オペレーティングシステムのカーネルによって管理されるバッファがあります。これらのバッファは、データの一方が書き込んだ後、他方の読み取り前に位置する一時的な保持領域です。

HyperはImagesサービス側の接続を管理し、ソケットから届いたリクエストを読み取り、レスポンスを書き込みます。

リクエストがImagesバインディングを使用する場合、Imagesサービスは入力を読み取り、要求された最適化操作を実行し、結果をエンコードします。そして、エンコードされた画像全体を単一のメモリ内ブロックとしてhyperに渡します。

Hyperは、このレスポンスデータを自身の内部バッファに書き込みます。この時点で、ハイパーは送信する必要のあるすべてのバイトがあるため、エンコーディングの作業が完了したと判断します。次のステップは、内部バッファをソケットのアウトバウンドバッファに消去し、データをImagesサービスから反対側の仲介者に移動することです。

相手側のリーダーが高速である場合、ハイパーフラッドは一度のパスですべてを消去できます。リーダーは到着するのと同じくらいの速さでデータを消費するため、アウトバウンドバッファに余地があるでしょう。すべてのデータが送信されると、hyperはソケット上でシャットダウンを発行し、接続が終了し、それ以上データが書き込まれないことを通知します。しかし、Readerの速度が数ミリ秒でも遅いと、アウトバウンドバッファがいっぱいになり、ハイパーは書き込みを続けるための余地ができるまで待たなければなりません。

ローカルの

Cloudflareネットワーク上のすべての着信トラフィックは、FLを通過します。FLは、セキュリティ機能とパフォーマンス機能を実行し、リクエストを適切なバックエンドにルーティングする内部仲介サービスです。バインディングを最初にローンチしたとき、画像データはWorkersランタイムからFLを経由してImagesサービスへ流れてきました。

このパスは当社の最初のリリースに自然に適合しており、URLインターフェースと同じアーキテクチャに従います。しかし、時が経つにつれて、このFLとの組み合わせが制約になりました。バインディングに変更を加えるたびに、FLのリリースサイクルに従わなければならないという制約があったのです。

2025年12月、ImagesチームはFLを同じマシン上で動作する新しい中間サービスである内部Workerバインディングに置き換えました。元のアーキテクチャでは、データはFLを介してネットワークソケットを経由して移動していました。このパスは、DNSルックアップやルーティングなど、FLの完全な処理パイプラインのオーバーヘッドをもたらしました。

内部バインディングは、これらをUnixソケットに置き換えて、同じマシン上のサービスを直接接続し、FLとネットワークスタックのオーバーヘッドをバイパスします。これにより、Imagesへのリクエストパスが高速化され、チームはバインディングリリースを独立して制御できるようになりました。

ロールアウト後数日以内に、最初のお客様からの報告を受けました。

200 OK (OKではありません)

最初のトラブルの兆候は、あるお客様からでした。非標準設定は、1つのパイプラインの中に別のパイプラインがネストされている、2つの画像処理レイヤーでした。

まず、WorkerはImagesバインディングを使用して、R2の複数大きなソース画像(JPEG背景とPNGオーバーレイレイヤー)を1つの結合JPEGに結合しました。次に、URLインタフェースを介して結果をさらに圧縮し、トランスコードし、サイズ変更します。

このバグは、内部パイプラインのリターンパスで発生し、応答が外部パイプラインに到達する前に切り捨てられました。

内部パイプライン(変換バインディング)が、コンポジットを処理しました。外側のパイプライン(Transformations URL)は、スケーリングやフォーマット変換といった配信の最適化を処理しました。この階層型アプローチは、内部パイプラインがサイレントに切り捨てられたレスポンスを返した時に、1レベル上の唯一の目に見えるエラーが現れることを意味します。

error reading a body from connection: end of file before message length reached

外側のパイプラインは、内側のパイプラインからHTTP 200 を受け取り、Content-Length ヘッダーは数メガバイトを約束しました。実際の本文はそのほんの一部でした。1回のリクエストでは、予定されている3.3MBのうち、約200KBしか到着しなかったのです。エラーは外側のパイプラインで表面化しましたが、切り捨てはバインディング、中間サービス、Imagesサービス、またはそれらの間のどこかで発生した可能性があります。

ブラウザが切り捨てられた画像を受信すると、結果が表示されます。フォーマットによって、画像は部分的にレンダリングするか(例:下の半分がない、またはグレー)、または完全にデコードされず、壊れた画像が表示されます。

秘密環境でのデバッグ

ここから、リクエストパスをたどって内部的な作業を行い、各層をテストして、切り捨てが発生している場所を分離しました。こうした取り組みのいくつかは行き詰りました。また、検索を絞り込んだブリーフクラムを残している人もいます。

  • 複製の作成。お客様のネストされたセットアップを模倣したワーカーを作成し、バインディングだけでバグをトリガーできるようになるまでレイヤーを取り除きました。小さなスクリプトでリクエストをバッチ処理することができます。初期の1回の実行では、25のリクエストのうち19件が失敗しました。到着したデータの量は—およそ200KB—は、本番環境のソケットバッファのサイズに疑わしいほど悪用されました。これにより、問題は顧客の設定に限定されないことが確認され、オンデマンドでバグをトリガーする確実な方法が得られたのです。

  • タイムアウトの調査中。当初、切り捨てがタイムアウト動作(つまり、時間制限後に接続がクローズされている)に関連しているのではないかと疑いました。切り捨てはリクエスト期間と相関しないため、この理論は成り立ちません。

  • ハイパーバージョンを更新中。最初にバグが報告された当時、当社は0.14.xを実行していましたが、最新のハイパーバージョンは約1.8.xでした。当社では、ハイパーバージョン0.14、1.7、1.8でテストを行いましたが、最も明白な答えが正しい(そして最も簡単な)答えであった場合に備えて、しかし、バグは各バージョンで発生するもので、アップストリームへの修正プログラムが存在しないということです。

  • ローカルでの再現。macOSとDebian仮想マシンでローカル統合テストを実施しました。かなりの負荷がかかっているにもかかわらず、当社のローカルリクエストが障害を引き起こすことはありません。バインディングソケットに直接curlリクエストを行い、キャプチャされたリクエストを再生することが、常に動作しているように見えました。バグは、ソケットの反対側に実際の同時実行と実際のWorkersランタイムクライアントがある場合にのみ、完全な本番パスで発生しました。このため、ランタイム自体を疑うことになりました。

  • Workersランタイムを除外。Workersランタイムがバインディングソケットを介してImagesと通信するために使用するHTTPクライアントを調査しました。接続の両側のトレースのいずれにも、予期しないクローズまたは早期の終了を示すsyscallが示されませんでした。クライアントは正しく動作し、他の複数のサービスが同じクライアントを問題なく使用していることが確認されました。

  • 分散トレース。エンドツーエンドのリクエストトレースを検査することで、切り捨てられた本文がお客様のセットアップの外部Transformations層に到達する前にすでに存在していることを確認しました。これにより、問題は内部パイプライン、つまりImagesサービスを介したバインディングパスに狭められました。

  • 仲介サービスのインストルメンテーション。仲介サービスに、レスポンスデータを転送する前にボディサイズを測定するためのインスツルメンテーションを追加しました。本文はImagesサービスを離れる時点ですでに切り捨てられていたため、仲介者は除外されました。

  • Imagesサービス内のより深いトレース。サービスレベルでは、リクエストが処理され、画像は適切にエンコードされ、レスポンスはHTTP 200で送信されました。

唯一一貫した兆候は、バグがタイミングに依存するということでした。それは本番パスで、実際に同時実行されている上にだけ、より大きな画像に対してのみ現れました。

情報の核

アプリケーションレベルのデバッグ用ツールには、システムが実行していると考えていることだけを伝えました。しかし、システムによるとすべて問題ありませんでした。トレースは、レスポンスが送信されたと述べました。ログ記録はエラーを報告せず、Imagesサービスはすべてのリクエストで200を返しました。

システムが実際に行っていたことを確認するために、Imagesサービスにstraceをアタッチしました。straceは、プロセスがカーネルに行うsyscallを記録します。これにより、どのバイトが書かれたか、いつシャットダウンが呼び出されたか、そしてクライアントが終了シグナルを送信したかどうかを正確に示すことができます。

トレースの設定は繊細でした。straceは、syscallが発生したときにそれを傍受することで機能し、それぞれにわずかなタイミングオーバーヘッドが追加されます。狭い範囲のsyscallのセットをフィルタリングすることで、そのオーバーヘッドを最小限に抑えることができました。しかし、フィルターの幅を広げることで、消去とシャットダウンチェックのタイミングを変えるのに十分なほどプロセスを遅らせ、バグを完全に消えさせることができました。これだけでも、問題はタイミングを重要視するという私たちの理論を裏付けるものです。

Replicated Workerを使用して、バグをトリガーし、リクエストが成功した場合と失敗した場合のsyscallの出力を比較しました。

リクエストが成功した場合、レスポンスはソケットバッファが許可するようにチャンクで書かれ、すべてのデータが送信された後にのみシャットダウンが呼び出されます。例えば、これは次のようになります:

sendto(42, "HTTP/1.1 200 OK\r\nContent-Length: 14991808\r\n...", ...) = 219264
sendto(42, "\xff\xd8\xff\xe0...", 292352) = 292352
// ... keeps writing until buffer drains ...
sendto(42, "...", 292352) = 292352
shutdown(42, SHUT_WR) = 0

このバグを再現したところ、失敗したリクエストは次のようになりました。

sendto(42, "HTTP/1.1 200 OK\r\nContent-Length: 14991808\r\n...", ...) = 219264
shutdown(42, SHUT_WR) = 0

ここでは、シャットダウンがすぐに呼び出される前に、1回の書き込み(ヘッダーと本文のほんの一部)しかありません。14.9MBのレスポンスのうち、約219KBしか送信されなかったのです。残りの約14.8 MBの画像データがhyperの内部バッファを離れることはなく、書き込みとシャットダウンの間にクライアントからの終了信号もありませんでした。その代わりに、Imagesサービスは、接続が完了したと信じて、自ら接続を急いでシャットダウンしました。

失敗したリクエストにより、バグが断続的にトリガーされる競合状態であることが確認されました。リクエストが成功するか失敗するかは、消去とシャットダウンの操作がリクエストからリクエストへと変化するかどうかによって決まります。ハイパーが接続が終了したと判断した瞬間、バッファがまだ満杯になっていたとき、データは失われました。

リーダーの消費がハイパー書き込みより遅いと、アウトバウンドバッファがいっぱいになります。バッファが枯渇する前にハイパーが接続を遮断すると、仲介者に到達するのはレスポンスのごく一部だけです。この不完全なデータは、Workersランタイムとクライアントに転送されます。

12月の再構築では、複数のメジャーバージョンにわたって何年も前から存在していたバグを発見しませんでした。しかし、新しい仲介者によって、ソケットの応答側で読み取る人が変更されました。私たちの実用的な理論は、以前の仲介者であるFLが十分な速度でデータを消費したため、ソケットバッファが応答中にほとんど満たされないということです。新しいリーダーは、より大きな応答の中にバッファが一杯になるようなペースで読むことがあります。

この数ミリ秒のバックプレッシャーは、他のすべてを高速化する改善によってもたらされ、潜在的に隠れていた欠陥を表面化させるのに必要な全てでした。

ディスパッチループ内

HyperのHTTP/1接続ライフサイクルは、dispatch.rsというファイル内のステートマシンによって駆動されます。リクエストを読み取り、応答を書き込み、書き込みバッファをソケットに消去し、シャットダウンのタイミングを決定するループを実行します。簡略化された形式:

fn poll_loop(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Error>> {
    loop {
        let _ = self.poll_read(cx)?;
        let _ = self.poll_write(cx)?;
        let _ = self.poll_flush(cx)?;

        if !self.conn.wants_read_again() {
            return Poll::Ready(Ok(()));
        }
    }
}

より正確には、poll_flashの前のlet_がバグがある場所です。

Rustでは、let _ = exprは、Poll::Pending(フラッシュがまだ完了していないことを示すシグナル)を含め、式の結果を破棄します。フラッシュのバッファにはまだバイトがバッファにあるかもしれませんが、ループはそれを見つけることはありません。

リクエストが失敗した場合の正確なイベントシーケンスは次の通りです。

  1. Imagesサービスは、画像のエンコーディングを完了し、Hyperへのレスポンス全体を単一のメモリ内ブロックとして引き渡します。

  2. Hyperはブロックを内部バッファに書き込み、書き込み状態をWriting::Closedとしてマークします。エンコーディングの観点から見ると、作業が完了しました。エンコードすることは何もありません。

  3. Hyperはpoll_flushを呼び出し、バッファリングされたデータをソケットに移動します。先ほどの例では、ソケットは約219KBを受け入れました。残りの約14.8MBはハイパーのバッファに留まります。ソケットがフルになっているため、カーネルはPoll::Pendingを返します。

  4. poll_loopPoll::Pendinglet _と共に破棄します。

  5. wants_read_again()を確認します。完全なリクエストはすでに受信しているため、これはfalseを返します。

  6. poll_loopPoll::Ready(Ok(()))を返し、フラッシュが完了していないにもかかわらず、ループが終了したことを知らせます。

  7. poll_shutdown()が起動します。SHUT_WRシステムコールが発行されます。

  8. クライアントは、14.9 MBを想定しているにもかかわらず、接続がクローズされていることを示す219KBとEOF(end-of-file)を受け取ります。

2番目のステップでは、ハイパーはレスポンス本文が実際に消去されたときではなく、バッファリングされた瞬間(つまり、エンコーディングが終了したとき)に書き込み操作が完了したものとしてマークします。ほとんどの場合、消去はシングルパスで完了するため、この区別は見えません。稀なケースでは、ソケットバッファがフルの場合、flashは待機しなければなりません。ハイパーは待機しなくても、です。バイトはまだハイパーのバッファにあり、ソケットにプッシュされるのを待ちます。Hyperは、このデータをまだバッファにある状態で、接続をシャットダウンします。

これは、curlがバグをトリガーしなかった理由も説明しています。curlは到着と同じ速さでデータを読み取ります。ソケットバッファは決して満たされず、消去は常に即座に完了し、破棄される戻り値は無害です。本番環境のパスは読み取りが数ミリ秒間一時停止することもあり、バッファが間違った瞬間に収まったのは唯一の設定でした。

コンプライアンスを忘れない

数週間に及ぶ調査の後、���正自体は概念的にシンプルでした。Hyperは、移動する前に、消去が実際に行われたかどうかを確認する必要がありました。

当社のレプリケーションワーカーはバグの存在を確認しましたが、あるリクエストが失敗した理由を知ることはできませんでした。修正を書く前に、hyper内の正確なソケット条件をトリガーできるテストが必要でした。

私たちは、データのチャンクを受け入れてからブロックするソケットが、バグのトリガーとなる条件を把握していました。制御されたシナリオでテストするために、完全なソケットバッファをシミュレートするTCPストリームの周りのカスタムラッパーを構築しました。ラッパーは最初の書き込みで8KBを受け入れ、それ以降の書き込みごとにPoll::Pendingを返し、バッファの消費を止めたリーダーを模倣しました。

テストでは、この制約のあるソケットを介して500KBのレスポンスを送信し、492KBがまだバッファリングされている間に、シャットダウンと呼ばれるハイパーリングが実行されるかどうかを確認しました。修正せず対処してしまう可能性はありました。修正後は、待ちました。

当初、ハイパーのディスパッチループに修正を適用しました。poll_flushの結果を破棄する代わりに、実際にフラッシュが行われたかどうかを確認しました。

let flush_result = self.poll_flush(cx)?;

if flush_result.is_pending() {
    return Poll::Pending;
}

if !self.conn.wants_read_again() {
    return Poll::Ready(Ok(()));
}

消去が完了していない場合、ループは非同期ランタイムにPoll::Pendingを返します。ランタイムはソケットが書き込み可能になるのを待ち、キャッシュを消去するためにバックアップを起動します。すべてのデータが送信された後にのみ、接続はシャットダウンします。

この修正をデプロイした場合、すべてのバイトが書き込まれ、バッファが実際に空になった後にのみシャットダウンが呼び出されたことが確認されました。最初の報告を行ったお客様も、問題が解消したことを確認しています。

最初のソリューションは機能していましたが、ディスパッチループは修正には適切な場所ではありませんでした。早期にPoll::Pendingを返すと、読み取りのポーリング頻度が減り、同じ接続上の他の操作が遅くなり、意図しないバックプレッシャーが発生する可能性があります。また、単一の接続が複数のリクエストを順番に処理するキープアライブ接続を正しく処理できません。これらは、前のレスポンスがまだ削除されている間でも、再利用可能である必要があります。どちらの問題も(キープアライブが無効になっている場合)特定のサービスには影響を与えませんでしたが、修正がアップストリームに貢献した場合は、他のハイパーユーザーに影響を与える可能性があります。

弊社は、Hyperの接続ライフサイクルを追跡し、より的を絞ったアプローチを発見しました。ディスパッチループの動作を変更するのではなく、実際にシャットダウンが呼び出された時点で修正を適用しました。ソケットをシャットダウンする前に、ハイパーはバッファに残っているデータをすべて消去します。

pub(crate) fn poll_shutdown(
    &mut self,
    cx: &mut Context<'_>,
) -> Poll<io::Result<()>> {
    ready!(self.poll_flush(cx)?);
    Pin::new(&mut self.io).poll_shutdown(cx)
}

これにより、ディスパッチループは変更されません。過負荷状態であれば、データ損失が発生するであろうポイント、つまり、シャットダウンの直前にのみフラッシュを追加します。

Cloudflareに残されたもの

アプリケーションレベルのツールは、いずれも有用な手がかりとなるエラーやクラッシュ、ログエントリーを表面化するものではありませんでした。アプリケーションレベルの可観測性は、認識以下に存在するバグの盲点を生じる可能性があります。

この障害は断続的に発生し、レスポンスのサイズによって拡大し、curlのような簡単なツールでは再現できず、システムをより詳しく観察した時には消失しました。これらのシグナルは、アプリケーションのロジックではなく、接続層のタイミング依存のバグを指摘していました。

私たちのブレイクスルーは、カーネルレベルのツールであるstraceを使用することで実現しました。straceは、ソケット上で実際に起こったことを記録する1つのレイヤーです。根本的なバグは、部分的なフラッシュから時期尚早なシャットダウンまでの数ミリ秒の間に発生しました。このウィンドウは、システムを高速化した後に初めて開いたのです。

PR #4018で、修正と決定論的テストをhyperium/hyperに統合しました。これは将来のハイパーリリースで利用可能になる予定で、ハイパーのHTTP/1実装を使用するサービスは同じ競合状態でレスポンスデータを失うことはありません。

同時に、パッチを適用した内部フォークを実行しています。この修正により、バインディングのアーキテクチャが安定し、機能を拡張するための信頼性の高い基盤ができました。

Imagesのバインディングは当初、リモートImagesのTransformationsだけをカバーしていました。今月初め、Imagesバインディングがホストされた画像の操作に対応しましたと発表しました。これにより、開発者はCloudflare上でメディアリッチなアプリケーションを構築するための統一された方法を利用できるようになります。

バインディングの仕組みについて詳しくは、当社のドキュメントをご覧ください。

Cloudflareは企業ネットワーク全体を保護し、お客様がインターネット規模のアプリケーションを効率的に構築し、あらゆるWebサイトやインターネットアプリケーションを高速化し、DDoS攻撃を退けハッカーの侵入を防ぎゼロトラスト導入を推進できるようお手伝いしています。

ご使用のデバイスから1.1.1.1 にアクセスし、インターネットを高速化し安全性を高めるCloudflareの無料アプリをご利用ください。

より良いインターネットの構築支援という当社の使命について、詳しくはこちらをご覧ください。新たなキャリアの方向性を模索中の方は、当社の求人情報をご覧ください。
Image OptimizationCloudflare Images開発者開発者プラットフォームCloudflare Workersオープンソース

Xでフォロー

Cloudflare|@cloudflare

関連ブログ投稿

2026年6月24日

OAuthで、すべての人にCloudflareアプリエコシステムを開放

Self-Managed OAuthは、Cloudflareのすべての開発者が利用できるようになりました。これを実現するために、コアOAuthエンジンのダウンタイムゼロで移行を実行した方法を次に示します。...

2026年6月19日

AIエージェント用一時Cloudflareアカウント

エージェントが何かをデプロイする必要が出た瞬間、人間のために作られた壁に真っ向から立ち向かうことになります。本日、Cloudflare Workersで一時的アカウントを展開します。すべてのエージェントがwrangler deploy(一時的)を実行し、数秒でライブWorkerを取得できるようになりました。...

2026年6月17日

Cloudflareに、Flueを皮切りに、より多くのエージェントの活用とフレームワークを導入

Agents SDKは、あらゆるエージェントフレームワークが構築できるランタイムです。本日、Agents SDKプリミティブを公開し、FlueをAgents SDKをターゲットとする最初のフレームワークとして、ダッシュボードでエージェントを展開します。...