nginxでR2をプロキシしたら不定時間後にBad Gatewayになる問題を治した

書いていく。

ちょっと前からCloudflare R2の署名付きUrlをnginxでプロキシして、不定時間たったらBadGatewayになる問題に当っていた。

TL;DR

レスポンスヘッダでオリジンを指定していて、かつ、DNSラウンドロビンするようなオリジンをプロキシするときは、以下のように2回変数に代入する必要がある。

    location /internal/file {
        internal;
        
        resolver 1.1.1.1 1.0.0.1 valid=5s;
        proxy_method GET;
        proxy_ssl_server_name on;
        set $tmp_url $upstream_http_x_signed_url;
        set $signed_url $tmp_url; #ココ
        proxy_pass $signed_url;
    }

さもなければ、502 Bad Gatewayになる(ログにはcould not be resolved (110: Operation timed out)とある)

やっていたこと

WSOFTダウンロードセンターという自作アプリなどを配布するサイトを再構築していて、 ダウンロードさせるファイルをオブジェクトストレージに置いておくことに決めた。 最初は普通にAWS S3を使おうと思っていたけど、サーバーがオンプレにある都合上、転送量課金がないCloudflare R2を使うことにした。

ここまでだとダウンロード時にR2の署名付きUrlをユーザーに渡せばいいと思われそうだが、ファイルによって認証を書けたりレスポンスヘッダを追加したりと、いろいろしたいのでR2で署名付きUrlを発行して、それをnginxにX-Accel-Redirectヘッダで渡してプロキシすることでファイルを配信することにした。

どういう状況だったか

アプリ側で以下のように署名付きUrlを発行してnginxへはX-Signed-Urlというヘッダで渡し、署名付きUrl専用の設定を行うために一度/internal/fileに内部リダイレクトしている。 ちなみにC#(ASP.NET Core)で書いている。

// R2の署名Urlを生成する
string signedUrl = Utils.Utils.CreateSignedUrl(entry);
// nginxに内部リダイレクトさせる
Response.Headers.Append("X-Accel-Redirect", "/internal/file");
Response.Headers.Append("X-Signed-Url", signedUrl);

return null;

で、対応するnginxの設定はこう。 リクエストヘッダに格納されている署名付きUrlを素朴にプロキシしている。 (途中でsetしているのは、そのままproxy_passの引数に渡すとinvalid number of arguments in "proxy_pass" directiveと怒られてしまうから。)

server {
    # Cloudflare R2への内部リダイレクト用エンドポイント
    # ユーザーが署名付きUrlにあるファイルにアクセスするためのもの
    location /internal/file {
        internal;
        
        proxy_method GET;
        proxy_ssl_server_name on;

        set $signed_url $upstream_http_x_signed_url;
        proxy_pass $signed_url;
    }
}

これで一見動きそう...動く...が、しばらく経つとBadGatewayになってしまう。

Mackerelくんが怒ってくれている様子

エラーログを眺めてみる。

nginx  | 2025/03/02 20:32:04 [error] 20#20: *438 ******.r2.cloudflarestorage.com could not be resolved (110: Operation timed out), 
client: 172.18.0.1, server: localhost, request: "GET /WS30788/content.txt HTTP/1.1", host: "download.wsoft.ws"

出ていたのは最初これだけだった。簡単に考えると名前解決でコケてるのかと思い、nameserverを指定するなどしてみたが、エラーが解決することはなかった。

ログレベルをdebugにして眺めてみる。

2025/03/16 17:18:18 [error] 21#21: *1 connect() to [**********]:443 failed (101: Network unreachable) while connecting to upstream, 
  client: **** server: localhost, request: "GET /WS30788/content.txt HTTP/1.1", upstream: "https://[**********]:443/******", host: "download.wsoft.ws"
2025/03/16 17:18:18 [warn] 21#21: *1 upstream server temporarily disabled while connecting to upstream, 
  client: ****, server: localhost, request: "GET /WS30788/content.txt HTTP/1.1", upstream: "https://[**********]:443/******", host: "download.wsoft.ws"

どうやらログ毎に見ているIPアドレスが違う。DNSラウンドロビンしているみたい。で、どうも署名Urlは前の解決結果を使ってアクセスすると上手くいかないらしい。

そこで、nginxの名前解決をもうすこし調べていた。 (今回の内容については、セクション2が詳しい)

ktrysmt.github.io

この記事によると、nginxはTTLを無視して一度解決した結果を終了時まで保持し続けるので、そうさせないために(何故か?)resolverを指定後、一度Urlを変数に代入するとよいらしい。

ちょっと待ってほしい。変数にならもう代入しているはずである。 set $signed_url $upstream_http_x_signed_url;と書いているではないか。 私はこの思い込みによって数か月ここで止まってしまっていた。

解決編

さっき、もしやと思い恐る恐るもう一段setディレクティブを挟んでみたところ、あっさり動作してしまった。 そのときのnginx.confを置いておく。

@@ -9,13 +9,16 @@ server {
     location /internal/file {
         internal;

+        resolver 1.1.1.1 1.0.0.1 valid=5s;
         proxy_method GET;
         proxy_ssl_server_name on;
-        set $signed_url $upstream_http_x_signed_url;
+        set $tmp_url $upstream_http_x_signed_url;
+        set $signed_url $tmp_url;
         proxy_pass $signed_url;

これだけで解決するなんてびっくり。 同じような構成の人の参考になればいいな。