ソフトウェア開発2026年6月11日3 分で読了

Nginxは気まぐれなんかじゃない — ただ設定の読み方を間違えているだけ

Nginxは気まぐれなんかじゃない — ただ設定の読み方を間違えているだけ

核心となる考え方:設定は「宣言」であって「スクリプト」ではない

これがNginxに関するほぼすべての誤解の根っこです。次の3つを頭に刻んでください。

1つ目、Nginxはあなたが書いた順番で上から下に実行されるわけではありません。 リクエストが届いたとき、Nginxはif-elseの連鎖のように location ブロックを順番にたどるわけではありません。優先順位アルゴリズム(後述)に基づいて、最もマッチするブロックをただ一つだけ選び、その中のディレクティブを実行します。

2つ目、ディレクティブはコンテキストによって継承されます。 設定は外側から内側へ流れます:httpserverlocationserver レベルで設定したディレクティブは、その下のすべての location に適用されます — 上書きされない限り。単純に聞こえますが、「オール・オア・ナッシング」な継承をする特別なディレクティブ群が存在します。これは罠その5で指摘します。

3つ目、最初の2つを掴めば、以下の古典的な「ハマりどころ」はもう魔法ではなくなります。 すべて同じルールの論理的な帰結なのです。

この枠組みを頭に置いたまま読み進めてください。

なぜNginxは最前線に立つに値するのか

罠を解剖する前に、なぜ業界のほぼ全体が門番の役割を任せて信頼するのかを理解しておきましょう。

ワーカー1つで、1万のコネクション

従来のアプリサーバー(Apache preforkや、Tomcatのスレッドプールを思い浮かべてください)は「リクエストごとに1スレッド」モデルで処理します。客が入ってくるたびに、後ろをついて回る従業員を1人割り当てる方式です。その客がメニューをじっくり眺め始めた瞬間 — つまりI/Oが遅い、ネットワークが重い — その従業員は立ったまま待たされて動けなくなります。同時に1万人の客が来れば1万人の従業員が必要になり、RAMは蒸発し、システムは膝をつきます。

Nginxは別の道を選びました:イベント駆動モデルです。各ワーカープロセスはイベントループを回し、ノンブロッキングI/O(Linuxではepoll)を使って数千のコネクションを同時に抱えます。ワーカーはこの客の注文を取り、厨房に投げ、次の客の注文を取りに行き、厨房ができた料理を順に運ぶ — 誰も誰かを立って待つことがありません。これこそNginxが生まれた理由です。Igor Sysoevは2000年代初頭、C10k問題 — 1台のサーバーで1万の同時接続を捌いても倒れないこと — を解くためにこれを書きました。結果は、わずかなRAMで巨大なコネクションを捌けるようになったのです。

バックエンドの前に立つ「盾」

最も外側に立つNginxは、重くて反復的な作業も引き受けます:SSL/TLSハンドシェイク、圧縮(Gzip/Brotli)、レート制限、ゴミトラフィックの排除、静的ファイルの配信。おかげでバックエンドは、本当に大事な一つのこと — ビジネスロジック — に集中できます。「対外的な」作業を「処理」の作業から切り離す。これがリバースプロキシの本当のアーキテクチャ上の価値であって、単に「かっこつけて一枚レイヤーを足す」ことではありません。

7つの罠 — そして、なぜそれらが完全に予測可能なのか

1. proxy_pass の中の、運命を分ける「/」

これは、自分のキャリアを疑いたくなる伝説の404です。

# パターンA — proxy_pass がホストのみ、URIなし
location /api/ {
    proxy_pass http://backend;
}
# /api/users   ->   バックエンドが受け取る:  /api/users    (そのまま)
# パターンB — proxy_pass にURIあり(たとえ「/」だけでも)
location /api/ {
    proxy_pass http://backend/;
}
# /api/users   ->   バックエンドが受け取る:  /users        (/api/ の部分が / に置換される)

ルールはまさにここにあり、絶対的に一貫しています:proxy_pass がURI部分を持つ場合(たとえ「/」だけでも)、Nginxは location でマッチした正確なセグメントをそのURIで置き換えます。proxy_pass がホストだけの場合、Nginxは元のURIをそのまま転送します。 例外なし、ランダム性なし。バックエンドが404を投げてきて髪をかきむしっているとき、最初にやるべきことはバックエンドのログを有効にして、実際にどのパスを受け取ったかを見ることです — 答えはほぼ確実に、その末尾のスラッシュにあります。

2. location のマッチング優先順位

機密ディレクトリをブロックしたくて、わざわざ deny all ブロックを書きます。それでもNginxは扉を開けっ放しにします。

location /uploads/ {              # 通常のプレフィックス — 優先順位は低い
    deny all;
}

location ~* \.(jpg|jpeg|png)$ {   # 正規表現 — 通常のプレフィックスより優先順位が高い
    proxy_pass http://backend;
}
# /uploads/secret.jpg  ->  先に正規表現にマッチ  ->  deny all は無視される  ->  ファイルが漏洩

核心の考え方を思い出してください:Nginxは上から下へ評価するのではなく、演算子の種類の優先順位で選びます。正確な順序はこうです:完全一致 = → 優先プレフィックス ^~ → 正規表現(~, ~*)を出現順に → 最後にようやく通常のプレフィックス(最長マッチが勝つ)。~* \.jpg$ ブロックは正規表現なので、通常のプレフィックスにすぎない location /uploads/ をすり抜けてしまいます。直し方は、ブロックする側の優先順位を上げることです:

location ^~ /uploads/ {   # ^~ : このプレフィックスにマッチしたら停止、正規表現は見ない
    deny all;
}

Nginxのせいではありません — ルールを知らなかっただけです。

3. デフォルト1MBと、413という痛恨の一撃

ローカルマシンではアップロードがサクサク通ります。Docker化してサーバーにデプロイし、ユーザーが5MBのアバターをアップロードすると — ドカン、413 Request Entity Too Large。クライアントは文句を言い、上司は問い詰めてきます。

理由:Nginxはデフォルトでリクエストボディを1MBに制限します(client_max_body_size 1m)。これはNginxが貫く「安全なデフォルト」哲学の教科書的な例です — そしてその安全なデフォルトは、チューニングしなければ本番に対してしばしば露骨に敵対的です。直すのは簡単です:

client_max_body_size 20M;

ただし2つ覚えておいてください:正しいコンテキストに置くこと(httpserver、または専用のアップロード location)。そして、公開エンドポイントで無謀に 0(無制限)に設定しないこと — それはメモリ枯渇攻撃への招待状です。

4. フェイルオーバーは思っているほど即座ではない

ロードバランシングの後ろに2台のバックエンドがあり、1台がクラッシュします。Nginxが即座に生き残った方へ切り替えると確信していますか?切り替えはします — でも「即座」かどうかは、もう一方がどう死んだかによります:

  • サーバーがコネクションを拒否して死んだ場合(プロセスがkillされた、ポートが閉じている、RSTを返す):コネクションは瞬時に失敗し、Nginxはほぼ即座にもう一方へ移ります。ユーザーはほとんど気づきません。

  • サーバーが音信不通になって死んだ場合(マシン全体がダウン、ファイアウォールが応答なしでパケットを飲み込む):Nginxは proxy_connect_timeoutデフォルトでなんと60秒 — を待ち切るまで諦めません。これこそ、ユーザーが遭遇する「永遠のローディング/時々動く、時々動かない」の真犯人です。

一方、max_fails(デフォルト1)と fail_timeout(デフォルト10秒)は、何回失敗したらNginxがそのサーバーをリストから消すか、そしてどれだけの間消すかを決めます。現実的な設定はこうなるべきです:

upstream backend {
    server 10.0.0.1:8080 max_fails=2 fail_timeout=10s;
    server 10.0.0.2:8080 max_fails=2 fail_timeout=10s;
}

server {
    location / {
        proxy_pass http://backend;
        proxy_connect_timeout 2s;            # すでに死んだサーバーに60秒も待たない
        proxy_next_upstream error timeout http_502 http_503;
    }
}

より深いポイント:オープンソース版Nginxはパッシブヘルスチェックしか持っていません — 実際に失敗したリクエストに基づいてサーバーの生死を判断するのであって、能動的にpingを打つわけではありません。だからデフォルトがすべてを面倒見てくれると期待しないでください:デフォルトは一つの一般的なシナリオを想定しているだけで、あなたのトポロジーはあなた自身が宣言しなければならないのです。

5. 誰も語らない罠:add_header と proxy_set_header が親の設定を「飲み込む」

これは冒頭で約束した「オール・オア・ナッシング」なディレクティブ群です — そして、本人がまったく疑いもしないまま何時間も奪っていく類のものです。

ルール:add_header(と proxy_set_header)では、子ブロックが親のヘッダーを継承するのは、同じ種類のディレクティブを一つも宣言していない場合に限りますlocation の中に add_header をたった一つでも追加した瞬間、server/http から継承されたすべての add_header が、その location から消え去ります。

server {
    add_header X-Frame-Options "DENY";
    add_header Strict-Transport-Security "max-age=31536000";

    location /static/ {
        add_header Cache-Control "public, max-age=86400";
        # 上の X-Frame-Options と HSTS が /static/ から今まさに蒸発した
    }
}

キャッシュ用のヘッダーを一つ足しただけのつもりが、実際にはその location のセキュリティヘッダーを吹き飛ばしていたのです。proxy_set_header も同じロジックです:location の中で proxy_set_header を一つでも宣言すると、継承したものを失い、自分で Host を設定しなければ、バックエンドはクライアントの元の Host ではなく Host: $proxy_host(proxy_pass にある名前)を受け取ります — 多くのアプリでリダイレクトやルーティングを壊すには十分です。だからこそ、この4行はプロキシするたびのお守りのようなものなのです:

location /api/ {
    proxy_pass http://backend;
    proxy_set_header Host              $host;
    proxy_set_header X-Real-IP         $remote_addr;
    proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
}

6. 「動いたり動かなかったり」するCORS — 古典的な幽霊

CORSは、フロントエンドとバックエンドの開発者が最もお互いのせいにし合うエラーであり、本当にイライラさせるのは「ランダム」な部分です。同じエンドポイントなのに、ある呼び出しでは問題なく動き、次の瞬間ブラウザが真っ赤になって "No 'Access-Control-Allow-Origin' header is present" と表示する。ほぼすべての「ランダム」なケースは、3つの原因のどれかに行き着きます — そしてその3つとも、Nginxがヘッダーをどう扱うかから直接来ています。

原因その1:always の欠落。 これが最も巧妙です。Nginxの add_header は、デフォルトではレスポンスコードが2xxまたは3xx(200、201、204、301、302...)のときにしかヘッダーを付けません。つまり、リクエストが成功したときはブラウザはCORSヘッダーをすべて受け取り、問題なく動きます。ところがバックエンドがエラー(500、401、403)を返した瞬間、CORSヘッダーは消え去り、ブラウザは本当の500を報告する代わりに「CORSエラー」と叫びます。だから文字通りの意味で「ランダム」なのです:バックエンドがエラーになるたびにCORSも壊れる。直し方はたった一つのキーワード、always に収まります:

location /api/ {
    add_header Access-Control-Allow-Origin  "https://app.example.com" always;
    add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
    add_header Access-Control-Allow-Headers "Authorization, Content-Type" always;

    # プリフライトはNginx側でここで処理する — ブラウザをバックエンドの応答で待たせない
    if ($request_method = OPTIONS) {
        add_header Access-Control-Allow-Origin  "https://app.example.com" always;
        add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
        add_header Access-Control-Allow-Headers "Authorization, Content-Type" always;
        return 204;
    }

    proxy_pass http://backend;
}

(注目してください:ヘッダーのセットは if ブロックの中で繰り返す必要があります — まさに罠その5の通り、外で宣言したヘッダーは if の中には引き継がれません。)

原因その2:ヘッダーの二重付与。 Nginxとバックエンドの両方Access-Control-Allow-Origin を設定すると、ブラウザは2つの値を受け取り、きっぱり拒否します("contains multiple values")。あるパスではNginxだけがヘッダーを返し、別のパスではバックエンドも返すため、「ランダム」に見えます。原則:CORSに責任を持つ場所を一つだけ選ぶこと。分担しないこと。

原因その3:プリフライトと不揃いな設定。 「単純でない」リクエスト(Authorizationヘッダーを持つ、PUT/DELETEメソッド...)では、ブラウザはまずOPTIONSプリフライトを送ります。NginxがそのOPTIONSを、CORSヘッダーを返さないバックエンドへ転送すると、プリフライトは失敗し、本当のリクエストは決して送られません。同様に、CORS設定が食い違う複数のNginxノードを運用していると、リクエストがどのノードに着地するかでCORSが生きるか死ぬかが決まります — ロードバランシングのおかげで、またしても「ランダム」です(罠その4)。

7. WAFの誤検知 — CORSエラーに化ける

攻撃をブロックするため、最前線にWAF(ModSecurity + OWASP CRS、またはCloudflare/AWSのWAF)を立てます。すると、ある日、数人のユーザーが操作がランダムに失敗すると報告してきます — ある人はフォームを送信して403、別の人は何ともない。バックエンドのログを掘っても、まっさら — リクエストはそもそも届いてすらいません。

それがWAFの**誤検知(false positive)**です:正当なリクエストが、たまたま攻撃検知ルールにマッチしてしまったのです。ユーザーがSQL構文のように見える内容、奇妙な文字を含むペイロード、長いbase64文字列を貼り付ける... WAFはそれをSQLインジェクション/XSSだと思い込み、403で蹴り出します。ルールに触れる特定のペイロードでしか発火しないので「ランダム」に見えます — 普通のリクエストの大半はすり抜けます。

そしてここで、2つの罠がぶつかります — 最も深い部分です:WAFはリクエストがアプリに届く前にブロックするので、その403レスポンスにはCORSヘッダーがまったく付いていません。 ブラウザは Access-Control-Allow-Origin の欠落を見て「CORSエラー」と報告します。こうしてあなたは何時間もCORSを直そうとし、真犯人は静かにブロックを続けているWAFルールなのです。見分け方:エラーはCORSとして現れるが、本当のステータスは403で、バックエンドはリクエストを一切記録していない。

正しい対処は、WAFを引っこ抜くこと(盾を捨てること)ではなく、こうです:

  • WAF自身のログ(ModSecurityの監査ログ)を読む。そこにブロックしたルールIDと本当の理由がある — ブラウザの画面からの当て推量ではなく。

  • チューニングする:特定のエンドポイントで誤検知を起こす特定のルールに除外設定(rule exclusion)を加える、またはCRSが厳しすぎるならparanoia levelを下げる — すべてを無効化するのではなく。

  • WAF/NginxのエラーページにもCORSヘッダーを付ける(またしても always)。そうすれば少なくともブラウザは、CORSエラーの裏に隠す代わりに、本当の403を報告します。

両者に共通する教訓:ブラウザが「CORS」と言っても、バグがCORSにあるとは限りません。 CORSは多くの場合ただの症状にすぎず、本当の病はエラーレスポンスか、あなたがその存在を忘れていたWAFが仕事をしていることにあります。

費やす価値のある15分:デプロイ前に毎回やること

暗記する必要はありません — このチェックリストをたどるだけです:

  • client_max_body_size をアプリの実際のニーズに合わせる(1MBのデフォルトに不意打ちさせない)。

  • プロキシ時は、常に HostX-Real-IPX-Forwarded-ForX-Forwarded-Proto を転送する。

  • add_header を再確認:子の location が親のセキュリティヘッダーをうっかり飲み込んでいないか?

  • ロードバランシングでは、proxy_connect_timeout を下げ、proxy_next_upstream を宣言してフェイルオーバーを決然とさせる。

  • location に対して境界となるパスを試し、実際にどれがマッチするか確認する(=^~、正規表現、プレフィックス)。

  • バックエンドのせいにする前に、proxy_pass の中のすべての / を注意深く読む。

  • すべてのCORSヘッダーに always を付け、エラーレスポンスでも生き残るようにする。

  • 「CORSエラー」を見たら、CORSをいじる前に本当のステータス(403か500か?)とWAFのログを確認する。

まとめ

Nginxはマニュアル車のようなものです:めちゃくちゃ速く、めちゃくちゃ燃費がよく、どんな荒れた地形も走り抜けられる。でもギアの入れ方を知らず、ルールを理解していなければ、ガクガクと跳ねて、容赦なくあなたを路上に放り出します。

Nginxと格闘する人と、Nginxを乗りこなす人の違いは、より多くの設定行を暗記していることではありません — 3つのことを掴んでいるかどうかです:設定は宣言であること、location は優先順位で選ばれること、ディレクティブはコンテキストで継承されること(いくつかのオール・オア・ナッシングの例外つきで)。この3つを理解すれば、試行錯誤でデバッグするのをやめられます — リロードする前に挙動を予測できるようになるのです。

ただ apt install nginx して、ネットからデフォルト設定を拾ってくるだけにしないでください。15分かけてリクエストのルーティングの仕組みを理解すれば、上司は「こいつ、わかってるな」と言い、システムはスムーズに動き — そして何より、デプロイの夜にぐっすり眠れます。


この5つの罠のうち、あなたはどれにやられましたか — それとも、もっと変な罠がありますか?みんなが避けられるように、コメントで教えてください。

関連する読み物

六つの駅、六つの死に方:あるHTTPリクエストの生存の旅システムデザイン
2026年6月14日3 min

六つの駅、六つの死に方:あるHTTPリクエストの生存の旅

技術ノートと省察 —— すべてのリクエストが辿らねばならない道について、そしてなぜその道を辿るのかについて。

読む
10年間、盲目的に信じてきたものを理解するために、DNSサーバーを自作したシステムデザイン
2026年6月13日1 min

10年間、盲目的に信じてきたものを理解するために、DNSサーバーを自作した

毎日使っているのに、一度も正面から向き合ったことのない技術がある。私にとってDNSがまさにそれだった。 10年間、ドメイン名を打ち続けてきた。ドメインを買い、レコードを設定し、「伝播(propagation)」を待ち、上がらないと悪態をつき、上がると喜ぶ——なのに、なぜ上がるのかを本当には理解していなかった。私にとってDNSは壁のスイッチのようなものだった。押せば部屋が明るくなる。壊れたら電気屋を呼ぶ。 あるデプロイの夜まではそうだった。サイトが落ちた。IPへのpingは通る。IPに直接curlするとページが返ってくる。ただドメイン名だけが沈黙していた。私はそれを見つめながら、少し情けない事実に気づいた。どこから直せばいいのかすら分からない。なぜなら、壊れているその当のものを、一度もきちんと理解したことがなかったからだ。 そこで、おそらくエンジニアだけが合理的だと思えることをやった。ドキュメントをざっと読む代わりに、座ってRustでDNSサーバーを丸ごと書いたのだ。プロジェクト名は mini-dns。この記事は、ようやく蓋を開けたとき、そのブラックボックスが私に語ってくれたことの記録である。

読む
「逆LSP(リスコフの置換原則)」の視点:生存本能が原則の破壊を命じるときソフトウェア開発
2026年5月28日2 min

「逆LSP(リスコフの置換原則)」の視点:生存本能が原則の破壊を命じるとき

SOLIDは宗教ではなく、設計原則もまた不変の戒律ではありません。実戦を叩き上げてきたテックリードの視点に立てば、システムの命を救うために、あえてリスコフの置換原則(LSP)を破るという決断こそが、成熟したエンジニアとしての選択肢となる局面もあります。本記事では、4つの古典的なトレードオフのシナリオと、歪みを安全に閉じ込める「クリーンルーム化」の技術について深く分析します。

読む