nginxが一瞬のDNS失敗で起動不能になった話と、proxy_pass の落とし穴

TL;DR

nginxの proxy_pass に外部ドメインをリテラルで書くと、起動時のDNS解決が一瞬失敗しただけで設定テストが落ち、起動不能になる。変数経由+resolver で回避した実例と、rewrite・SNIの注意点をまとめました。

はじめに

自分のポートフォリオサイトが数日間ダウンしていたことに気づかず、慌てて調査したところ、原因はnginxの proxy_pass に書いていた外部ドメインが、起動タイミングで一瞬だけ名前解決に失敗したことでした。個人サイトとはいえ、公開している以上、落ちていることに気づかないのはさすがにまずい。今回の経緯と対策を、自分への戒めとして残しておきます。

事象サマリ

先に結論から書きます。

  • 症状: HTTPS/HTTPともに Connection refused。ICMPは通る
  • 真犯人: nginxが停止したまま数日間放置されていた
  • 直接の引き金: 何らかのトリガーでnginxの再起動が走った際、proxy_pass に書かれた外部ドメインの名前解決が一瞬失敗 → 設定テストが [emerg] で落ちた
  • なぜ復旧しなかったか: systemd側の挙動で、設定テスト失敗時は起動せず自動リトライもしない構成だった
  • 対策: upstream ドメインを変数経由に変更し、起動時解決を排除

環境構成

構成はよくあるヘッドレスWordPress + Nuxtです。

  [User Browser]
        │
        │ HTTPS
        ▼
  ┌─────────────────────┐
  │       VPS           │
  │  ┌───────────────┐  │
  │  │    nginx      │  │
  │  │ (リバースプロキシ) │
  │  └───┬───────┬───┘  │
  │      │       │      │
  │      ▼       │      │
  │   [Nuxt]     │      │
  │   (127.0.0.1 │      │
  │    :3000)    │      │
  └──────────────┼──────┘
                 │ HTTPS
                 ▼
        ┌─────────────────┐
        │  WordPress      │
        │  (別のホスト)    │
        │  cms.example.com│
        └─────────────────┘

nginxはフロントの手前に立ちつつ、一部のパス(/wp-uploads/)をCMS側のホストへプロキシしてメディアファイルを配信する役割も担っています。今回引き金になったのは、このプロキシ設定でした。

症状の切り分け

気づいたときの最初の確認はこんな感じです。

curl -I https://morinoupa.jp
# curl: (7) Failed to connect to morinoupa.jp port 443: Connection refused

ping -c 2 morinoupa.jp
# 64 bytes from ...: icmp_seq=1 ttl=54 time=7.73 ms

ここで頭に入れておきたい切り分けのロジックはシンプルです。

  • ICMPが通らない → サーバーダウン、ネットワーク断、ファイアウォール遮断などを疑う
  • ICMPは通るがポートが閉じている → リバースプロキシ/アプリが起動していない
  • 接続は成立するが5xxが返る → アプリが落ちているが、プロキシは生きている

今回は真ん中のパターンでした。アプリ(Nuxt)が落ちているだけなら、プロキシが 502 Bad Gateway を返すはずです。今回は接続自体が拒否されているので、プロキシ層そのものが死んでいる疑いが濃い。

原因調査のプロセス

とりあえずさくらVPSのコンソールから再起動してサイトを復旧させた後、落ち着いてログを掘っていきます。

systemdの起動履歴を遡る

last reboot
# reboot   system boot  6.8.0-107-generic Tue Apr 14 18:56   still running
# reboot   system boot  6.8.0-36-generic  Tue Feb 24 13:59   still running

2月から4月まで再起動していないので、稼働中に何かが起きたことがわかります。

前ブートのログを見る

ブート単位でログを絞り込みたいときは journalctl -b を使います。-b 単独なら現在のブート、-b -1 でひとつ前のブートのログに絞れます。今回は停止していた時間帯を確認したいので、前ブートを指定します。

journalctl -u nginx -b -1 --no-pager | tail -40

出力の末尾にこれが残っていました。

Apr 10 06:48:03 Stopping nginx.service - A high performance web server and a reverse proxy server...
Apr 10 06:48:03 nginx[443481]: 2026/04/10 06:48:03 [emerg] 443481#443481:
                 host not found in upstream "cms.example.com"
                 in /etc/nginx/sites-enabled/morinoupa.jp:52
Apr 10 06:48:04 nginx[443481]: nginx: configuration file /etc/nginx/nginx.conf test failed

そしてこの後に、Started nginx.service のログがありません。つまり 4/10の朝に停止して以来、起動していない状態が続いていた ということ。4日近く落ちっぱなしだったことになります。

AIに伴走してもらった

ここまで書くと整然としていますが、実際の調査では途中からAIに手伝ってもらっています。

私の手元ではAIにSSHの鍵へのアクセス権を渡していて、自分でサーバーにログインしてコマンドを叩ける状態にしてあります。ただし sudo はこちらで叩くスタイルで、権限昇格が必要な操作は人が明示的に実行する運用です。

この切り分けだと、AIは ログ確認や設定の読み取りなど、コマンドを投げて結果を眺める系の作業 で真価を発揮します。たとえば「前ブートのnginxログを遡って、emergなどの異常を抜き出して」と頼むと、journalctl -u nginx -b -1 を叩き、該当する行を抜粋して返してくれる。人間がやると、長大なログを眺めてパターンを探す時間が地味にかかる作業です。それを「ここにnginx停止の形跡があります」と数秒で返してくれると、思考の速度がまるで変わります。

またメモリ不足を最初に疑ってから「OOM killerの形跡はない」と結論づけるまでの工程も、dmesg -T | grep -i oom や関連journalを突き合わせて一緒に潰してくれるので、仮説を早く切り替えられました。

「危険な操作は人間、調査と提案はAI」 という切り分けは、安全性とスピードのバランスが取りやすい運用だと感じています。

何が引き金だったのか

実ログだけでは、何が最初にnginxに再起動をかけたのか、完全には断定できませんでした。systemd のログには Stopping nginx.service と、その直後に nginx -t[emerg] で失敗した記録だけが残っていて、そこから先の Started が出ていない、という状態。時間帯的に見るとapt の unattended-upgrades による nginxパッケージ更新や、certbotの定期更新あたりが候補ですが、決定打は出せませんでした。

いずれにせよ、「設定テストに通らないため起動できない」状態に陥ったこと自体が問題で、トリガー側を追うよりもそちらを塞ぐ方が実効性が高いと判断しました。

なぜnginxが起動できなくなるのか

本題の技術要素はこちらです。proxy_pass に外部ドメインをリテラルで書くと、起動時の名前解決に依存してしまいます。

nginxの proxy_pass には、ドキュメント上ふたつの挙動モードがあります。

モード1: リテラル記述

location /wp-uploads/ {
    proxy_pass https://cms.example.com/wp-content/uploads/;
}

この書き方だと、nginxは設定ファイルを読み込むとき(起動時 or reload時)に cms.example.com の名前解決を一度だけ行い、IPアドレスをキャッシュします。以降はそのIPにアクセスし続ける。この挙動には以下の問題があります。

  • 起動時にDNSが一瞬でも詰まっていると、設定テストが [emerg] で失敗し、nginxが起動できない
  • 解決結果はキャッシュされるため、起動後にupstreamのIPが変わっても追従しない(ELBやホスティング移行などで地味に困る)

モード2: 変数経由

location /wp-uploads/ {
    resolver 8.8.8.8 1.1.1.1 valid=300s ipv6=off;
    set $cms_upstream "cms.example.com";
    proxy_pass https://$cms_upstream;
}

proxy_pass に変数が含まれると、nginxは起動時の名前解決をスキップし、リクエストが来たタイミングで resolver を使って毎回解決します。このモードだと以下のメリットがあります。

  • 起動時のDNS揺らぎに左右されない(設定テストは常に通る)
  • upstreamのIP変動に自動追従する
  • valid= で指定した秒数だけキャッシュされるので、過度な問い合わせも防げる

対策として入れた変更

実際に適用した差分はこちらです。

変更前

location /wp-uploads/ {
    proxy_pass https://cms.example.com/wp-content/uploads/;
    proxy_set_header Host cms.example.com;
    proxy_set_header Authorization "";
    proxy_http_version 1.1;
    expires 7d;
    add_header Cache-Control "public, immutable";
}

変更後

location /wp-uploads/ {
    resolver 8.8.8.8 1.1.1.1 valid=300s ipv6=off;
    resolver_timeout 5s;
    set $cms_upstream "cms.example.com";
    rewrite ^/wp-uploads/(.*)$ /wp-content/uploads/$1 break;
    proxy_pass https://$cms_upstream;
    proxy_set_header Host cms.example.com;
    proxy_set_header Authorization "";
    proxy_http_version 1.1;
    proxy_ssl_server_name on;
    expires 7d;
    add_header Cache-Control "public, immutable";
}

いくつか補足します。

なぜ rewrite が必要か

proxy_pass にURIパス部分(/wp-content/uploads/)を書いていると、nginxはlocationのプレフィックス(/wp-uploads/)を自動的に置き換えてくれます。便利な機能ですが、proxy_pass に変数が入っている場合、このURI自動書き換えは使えません

そのため、パスの書き換えを rewrite で明示する必要があります。break フラグを付けることで、書き換え後のURIが同じlocation内で処理されるようにしています。

なぜ proxy_ssl_server_name on が必要か

HTTPSでのupstream通信では、SNI(Server Name Indication)でホスト名を通知します。リテラル記述だと自動でSNIに載りますが、変数経由だとSNIが載らなくなるので、このディレクティブで明示的に有効化する必要があります。これを忘れると、共用ホスティング環境のupstreamでSSL証明書ミスマッチが発生することがあります。

resolver の設定値

  • 8.8.8.8 1.1.1.1: Google PublicDNSとCloudflareのDNSを併記。nginxの resolver は複数指定時に片方をフォールバックに回すのではなく、並列で使う挙動になります
  • valid=300s: 解決結果を5分キャッシュ(TTLに近い短めの値)
  • ipv6=off: IPv6対応していないupstreamなら無駄なAAAA問い合わせを抑制
  • resolver_timeout 5s: 名前解決のタイムアウト。デフォルトの30秒は長すぎるので短縮

検証

設定を反映する前に nginx -t で必ずチェックします。

sudo cp /etc/nginx/sites-enabled/morinoupa.jp /etc/nginx/sites-enabled/morinoupa.jp.bak
# ↑ 注意: sites-enabled/ にバックアップを置くとnginxが両方読み込んでconflictするので
#        実際は /home/ubuntu/ など別の場所に退避させる
sudo nginx -t
# nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
# nginx: configuration file /etc/nginx/nginx.conf test is successful

sudo systemctl reload nginx

curl -I https://morinoupa.jp
# HTTP/2 200

上のコメントに書いた通り、バックアップファイルの置き場所には注意が必要 です。/etc/nginx/nginx.conf は通常 include /etc/nginx/sites-enabled/*; で全ファイルを読み込むため、拡張子に関係なく同ディレクトリ内のファイルが読み込まれて server_name の重複警告が出ます。バックアップは別ディレクトリに置くのが安全です。

再発防止の他のアプローチ

今回入れた変数化だけでも同種の起動失敗はほぼ防げますが、二重化を考えるならいくつかの追加対策もあります。

1. systemdでの自動リトライ

nginxのsystemd unit fileに Restart=on-failure + RestartSec=30 を入れておくと、設定テスト失敗後もリトライしてくれます。ただしこれは設定そのものが壊れている場合に無限リトライで負荷になるリスクもあるので、回数制限 (StartLimitBurst) と併用が推奨です。

2. certbot deploy-hookの見直し

certbotの更新で nginx reload が走る際、失敗時の挙動を明示しておくと気付きやすくなります。hook内でreload失敗時にログ通知する、といった実装が有効。

3. 外形監視

今回の一番の反省点は、4日気づかなかったこと 自体でした。UptimeRobot等の外形監視サービスは無料枠でも数分間隔で叩いてくれて、落ちたらメール通知してくれます。個人サイトでも導入コストは5分程度なので、本来は最初に入れておくべきです。

まとめ

振り返ってみると、以下が今回の学びです。

  • ICMPは通るがポートが閉じている というシグナルは、リバースプロキシ層のダウンを疑う典型
  • nginxの proxy_pass に外部ドメインをリテラルで書くと、起動時のDNS解決失敗で設定テストが落ちる という落とし穴がある
  • 対策は ドメインを変数経由に変えて、resolver を明示指定 する。ただし rewriteproxy_ssl_server_name on の追加が必要な場面もある
  • バックアップファイルを sites-enabled/ に置くとnginxが両方を読み込んでしまうので別の場所に退避させる
  • 設定の堅牢化と並行して、外形監視を入れる のが再発防止の本丸

構築して終わり、ではなく、落ちたときにすぐ気づける仕組みまで含めて「運用できている」と言える。当たり前のことを改めて自分に言い聞かせる回になりました。