2026-06-22 — Deanna Lam, Diretnan Domnan, Matt Lewis — 12 min read
Workers 上で Rust で構築された Images サービスは、Cloudflare のエッジ ネットワーク上のすべてのマシンで動作します。クライアント接続を処理するために、Rust 用のオープンソース HTTP ライブラリである hyper を使用しています。昨年、Workers でリモート画像をプログラム的に処理するためのカスタムワークフローを可能にする Images binding を導入しました。2025 年末に、Workers ランタイムと Images サービス間のより直接的でローカルな接続を提供するように binding を再設計しました。
ロールアウト直後、binding からの変換リクエストが失敗するという報告を受けました—ただし間欠的に、かつ大きな画像に対してのみでした。さらに奇妙なことに、これらのリクエストのレスポンスは 200 ステータスを返し、ログにもエラーは残りませんでした。画像データが単純に途中で切れていました:本来は 2 MB 程度あるべきレスポンスが、数百 KB しか届かない、という具合です。
我々は 6 週間かけて、ほとんど見えないバグ――特定の条件下でのみ発生する競合状態(race condition)――を追いかけました。結果的に修正は 4 行のコードで済みました。
Hops, handoffs, and hyper
Cloudflare 上で開発者がアプリケーションを構築するとき、Workers からアクセスできるプラットフォームサービスの集合を用いてフルスタックアプリケーションを構成します。Bindings は compute、storage、AI inference、media processing といった Developer Platform 上のリソースに対する直接 API を提供します。Images binding は画像最適化を配信から切り離します。出力を HTTP レスポンスとして返す必要がなく、transcode、composite、manipulate といった操作を行えます。URL インターフェースが強制する固定順序に従う必要がなく、最適化パラメータを任意の順序で適用できます。
ここで、Worker は画像データを直接 Images API に渡し、操作をチェーンし、結果をストリームとして受け取れます。
const result = await env.IMAGES
.input(image)
.transform({ width: 800, rotate: 90 })
.output({ format: "image/avif" });
return result.response();
大まかに言うと、画像データが各サービスをどのように移動するかは次の通りです:
- パイプは中継プロセスと Images 間のソケット接続を表し、カーネルのバッファを介してデータが一方のプロセスから別のプロセスへ受け渡されます。
- Binding は Workers ランタイムが管理するソケット接続を通じて Images と通信します。ソケット接続は 2 つのプロセス間の通信チャネルで、各端には OS のカーネルが管理するバッファがあります。これらのバッファは、一方が書き込みを行ってから他方が読み取るまでの間、データが一時的に保持される領域です。
- Images サービス側では
hyper が接続を管理し、ソケットからのリクエストを読み取り、レスポンスを書き戻します。リクエストが来ると、Images は入力を読み取り、要求された最適化を実行して結果をエンコードし、そのエンコード済み画像全体を単一のインメモリブロックとして hyper に渡します。
hyper はこのレスポンスデータを自前の内部バッファに書き込みます。この時点で hyper は送るべきバイトをすべて持っているため、エンコード作業は完了と見なします。次のステップは内部バッファをソケットのアウトバウンドバッファにフラッシュすることで、Images サービスから対向の中継へデータを移動します。
- 読み手が速ければ、
hyper は一度で全てをフラッシュでき、アウトバウンドバッファは到着するデータを読み手が消費しているため空きがあります。すべて送信が終わったら、hyper はソケットに対して shutdown を発行し、接続が完了しこれ以上データは書き込まれないことを通知します。
- しかし読み手が遅い(数ミリ秒でも)場合、アウトバウンドバッファが一杯になり、
hyper は書き込みを続けるための空きができるまで待つ必要があります。
Taking the local
Cloudflare の全着信トラフィックは FL と呼ぶ内部中継サービスを通り、セキュリティやパフォーマンス機能を実行し、適切なバックエンドへルーティングします。binding を最初にローンチしたとき、画像データは Workers ランタイムから FL を経由して Images サービスへ流れていました。この経路は初期リリースに適しており、URL インターフェースと同じアーキテクチャに従います。
しかし時間が経つにつれて、FL への結合は制約になっていきました:binding の変更はすべて FL のリリースサイクルに従う必要がありました。2025 年 12 月、Images チームは FL を置き換え、同じマシン上で動作する内部 worker binding という新しい中継サービスを導入しました。元のアーキテクチャではデータがネットワークソケット経由で FL を通って移動しており、DNS ルックアップやルーティングなど FL のフルパイプラインのオーバーヘッドがかかっていました。内部 binding はこれを Unix ソケットで直接サービス同士を接続する形に置き換え、FL とネットワークスタックのオーバーヘッドをバイパスしました。これにより Images へのリクエスト経路は高速化され、binding のリリースをチームが独立して管理できるようになりました。
ロールアウトから数日以内に、最初の顧客報告が届きました。
200 OK (not OK)
最初の異常の兆候は、標準的でない構成を持つ顧客からでした:二重レイヤの画像処理で、あるパイプラインが別のパイプラインにネストされていました。まず、そのワーカーは Images binding を使って R2 から複数の大きなソース画像(JPEG の背景と PNG のオーバーレイ層)を合成して単一の合成 JPEG を作っていました。次に、得られた結果を URL インターフェースでさらに圧縮、トランスコード、リサイズしていました。
バグは内側のパイプラインの返り経路に起因しており、レスポンスが外側のパイプラインに到達する前に切り詰められていました。内側のパイプライン(transformation binding)は合成処理を行い、外側のパイプライン(transformation URL)はスケーリングやフォーマット変換など配信最適化を担当していました。この二層構成では、内側のパイプラインが静かに切り詰められたレスポンスを返すと、唯一目に見えるエラーは一段上に現れます:
error reading a body from connection: end of file before message length reached
外側のパイプラインは内側から HTTP 200 を受け取り、Content-Length ヘッダは数 MB を約束していましたが、実際のボディはその一部しかありませんでした。あるリクエストでは、期待される 3.3 MB のうち約 ~200 KB しか到着しませんでした。エラーは外側で表面化しましたが、切り詰めは binding、あるいは中継サービス、Images サービス、もしくはその間のどこかで発生した可能性がありました。
ブラウザが切り詰められた画像を受け取ると、その結果は視覚的に確認できます。フォーマットによっては部分的にレンダリングされる(例えば下半分が欠ける、灰色になる)か、完全にデコードに失敗して壊れた画像を表示します。
Debugging in the dark
ここから我々はリクエスト経路の内側へと進み、どのレイヤで切り詰めが起きているかを特定するために各レイヤをテストしました。いくつかの試みは行き止まりに当たり、他はいくつかの手がかりを残して探索範囲を狭めました:
- 再現作成。顧客のネスト構成を模倣するワーカーを作り、レイヤを剥ぎ取りながら binding 単体でバグを再現できるところまで削りました。小さなスクリプトでバッチリクエストを発行できるようにしたところ、初期の実行で 25 リクエスト中 19 件が失敗しました。到着したデータ量(約 200 KB)が本番のソケットバッファサイズに非常に近かったため、この問題が顧客固有ではなく再現可能であることが確認できました。
- タイムアウトの調査。初期段階では切り詰めがタイムアウト(接続が時間制限で閉じられている)に関連すると考えましたが、切り詰めはリクエストの所要時間とは相関しませんでした。
hyper バージョンの更新。報告当時、我々は 0.14.x を使っており、最新の hyper はだいたい 1.8.x 周辺でした。0.14、1.7、1.8 といったバージョンでテストしましたが、どのバージョンでもバグは現れ、上流での修正は存在しないことが分かりました。
- ローカルでの再現。macOS と Debian VM 上でローカル統合テストを行いました。かなりの負荷をかけてもローカルでは失敗は発生しませんでした。binding ソケットに直接
curl でリクエストを送ったり、キャプチャしたリクエストをリプレイしたりしても常に動作するように見えました。バグは本番パス上、実際の並行性と Workers ランタイムクライアントがソケットの反対側にいる場合にのみ発生しました。これによりランタイム自身を疑い始めました。
- Workers ランタイムの切り分け。Workers ランタイムが Images と binding ソケットを通じて通信するために使っている HTTP クライアントを調べました。接続の双方からのトレースに、予期しないクローズや早期終了を示す syscalls はありませんでした。クライアントは正しく振る舞っており、他の複数のサービスも同じクライアントを問題なく使っていました。
- 分散トレーシング。エンドツーエンドのトレースを確認することで、切り詰められたボディは顧客の外側の変換レイヤに到達する前ですでに存在していることが確認でき、問題は内側のパイプライン(Images 経由の binding パス)に絞られました。
- 中継サービスでの計測。中継サービスに計測を追加し、転送前のボディサイズを記録しました。ボディは Images サービスを出る時点ですでに切り詰められていたため、中継は除外されました。
- Images サービス内部でのより深いトレース。サービスレベルではリクエストは処理され、画像は正しくエンコードされ、レスポンスは
HTTP 200 で送信されていました。一貫したシグナルは、このバグがタイミング依存であることだけでした:本番パス、実際の並行性、かつ大きな画像でのみ発生していました。
A kernel of truth
アプリケーションレベルのデバッグツールはシステムが「そう考えている」ことしか教えてくれませんでした。システムの出力ではすべてが正常に見えます:トレースはレスポンスが送信されたと示し、ログはエラーを報告せず、Images サービスはすべてのリクエストに対して 200 を返していました。システムが実際に何をしているかを確認するために、我々は Images サービスに strace をアタッチしました。
strace はプロセスがカーネルに対して行う syscalls を記録するため、どのバイトがいつ書き込まれたか、いつ shutdown が呼ばれたか、クライアントが終端シグナルを送ったかどうかを正確に示してくれます。strace のセットアップは繊細でした。strace は syscalls をインターセプトするため各 syscalls に少しのタイミングオーバーヘッドを追加します。追跡する syscall を狭くフィルタリングするとオーバーヘッドは最小限に抑えられますが、フィルタを広げすぎるとプロセスがわずかに遅くなり、flush と shutdown の間のタイミングがずれてバグがまったく現れなくなってしまいました。これだけでも問題がタイミング依存であるという理論を補強しました。
再現ワーカーを使ってバグを誘発し、成功時と失敗時の syscall 出力を比較しました。成功したリクエストでは、ソケットバッファの許す限りチャンクごとにレスポンスが書き込まれ、すべてのデータが送られた後に shutdown が呼ばれていました。例えば次のようになります:
sendto(42, "HTTP/1.1 200 OK\r\nContent-Length: 14991808\r\n...\r\n", ...) = 219264
sendto(42, "\xff\xd8\xff\xe0...", 292352) = 292352
sendto(42, "...", 292352) = 292352
shutdown(42, SHUT_WR) = 0
バグを再現したとき、失敗したリクエストは次のように見えました:
sendto(42, "HTTP/1.1 200 OK\r\nContent-Length: 14991808\r\n...\r\n", ...) = 219264
shutdown(42, SHUT_WR) = 0
ここではヘッダとわずかなボディ分だけが書き込まれた後にすぐ shutdown が呼ばれています。14.9 MB のレスポンスのうち約 219 KB しか送信されておらず、残りの約 14.8 MB の画像データは hyper の内部バッファに残ったままでした。クライアントから write と shutdown の間に終了シグナルが送られた形跡はありませんでした。代わりに、Images サービスは接続が完了したと本当に信じて、自らコネクションを prematurely にシャットダウンしていました。
失敗したリクエストは、このバグが競合状態(race condition)であることを確認しました。