404
サーバーはリクエストの意味を理解したうえで、そのパスに出せるものを何も持っていません。今どきの構成では、リンク切れよりもルーティングかデプロイの取りこぼしであることのほうがずっと多いコードです。
同じ 3 桁でも、リクエストのどちら側にいるかで別々の問題になります。自分に当てはまるブロックを読んでください。
これが出ている理由
だいたい 3 つの形に収まります。1 つ目、SPA のルートがクリックでは開くのに、リロードや新しいタブで開くと 404 になる——クリックは History API が処理した一方、リロードはサーバーにルールのない本物の HTTP リクエストだからです。2 つ目、デプロイ直後にアセットだけが 404 になる——キャッシュに残った `index.html` が、前のビルドのハッシュ付きファイル名をまだ要求しています。3 つ目、アドレスバーでは開ける URL が `fetch` からだと 404 になる——たいていリクエストが別の場所へ飛んでいます。別オリジン、付いたか剥がれたプロキシの接頭辞、あるいはサイトのルートではなく現在のルートを基準に解決された相対パスです。
やること
ソースに書いてある URL ではなく、ネットワークパネルに出ている実際のリクエスト URL を読んでください。その差分がバグそのものです——先頭のスラッシュ、二重になった `/api/api/`、ルーターが別ルートとして扱う末尾のスラッシュ、あるいは呼ぶつもりのなかったホスト。リロードで落ちる件は、一致しないパスに `index.html` を返す History API 用のフォールバックがサーバー側に要ります。nginx なら `try_files $uri /index.html`、他のホストなら同等の rewrite です。静的書き出しの場合は、URL が末尾スラッシュで終わるかどうかをホスト側と合わせておく必要があります。古いアセットの件は、HTML 自体をキャッシュさせず、ハッシュ付きファイルだけを不変にしてください。そうすればブラウザが、もう存在しないファイルを指す古い文書を握り続けることはなくなります。
これが出ている理由
そのメソッドとパスの組にマッチするルートがないか、アプリケーションが見る前に手前の何かがパスを書き換えたかのどちらかです。外から見ると区別がつかず、犯人はたいていリバースプロキシです。`location /api/` の `proxy_pass` が末尾スラッシュで終わるかどうかで接頭辞が剥がれるか残るかが決まるので、たった 1 文字でアプリケーションが見るパスが `/users` にも `/api/users` にもなります。残りの 2 つは大文字小文字と末尾スラッシュです。Linux のオリジンでは URL のパスは大文字小文字を区別しますし、多くのルーターは明示しない限り `/users` と `/users/` を別のルートとして扱います。
やること
まずアプリケーションに、実際に登録されているルートを聞いてください——どのフレームワークにもルート一覧のコマンドがあります。それをソースのパスではなく、アクセスログに残っている実際のパスと突き合わせます。次にプロキシを外して考えます。`curl --resolve example.com:443:203.0.113.10 https://example.com/api/users` は、元のホスト名と SNI を送りながらオリジンのアドレスへ直接つなぐので、これでも 404 ならアプリケーション、消えるならプロキシです。オリジン側のアクセスログも見てください。そこに現れないリクエストはそもそも到着していないということで、DNS、別のバーチャルホスト、あるいは設定に無いホスト名を黙って拾っているデフォルトサーバーが疑わしくなります。
これが出ている理由
リンクとしては存在するけれど、ページとしては存在しないアドレスです。文字が 1 つ欠けた状態で入力かコピーされたか、ページが本当に移動または削除されたか、あるいは古いメール・ブックマーク・検索結果から来たリンクで、その後サイトが再編成されたかのいずれかです。検索エンジンはページが消えたあともしばらく索引に残すので、検索結果から 404 に着くのは普通のことで、あなたの端末や回線がおかしいわけではありません。
やること
まずアドレスバーを見てください。文字が 1 つ足りない、チャットから貼ったときに空白が紛れた、メールで 2 行に折り返された URL——この 3 つでほとんどが説明できます。次にアドレスを 1 階層ずつ削っていきます。`/blog/2024/some-post` を `/blog/2024/`、さらに `/blog/` と短くしていって、開くページに当たったらそこから辿ってください。移動したページは、検索エンジンよりもサイト内検索のほうが速く見つかることが多いです。本当に消えていて、それでも中身が必要なら、Internet Archive の Wayback Machine に当時のコピーが残っていることがよくあります。
失敗した URL に対してそのまま実行してください。エラーページを表示せずにステータスだけを出すので、ブラウザが描画したものではなくサーバーが言ったことが見えます。
curl -sS -o /dev/null -L -w '%{http_code} %{num_redirects} %{url_effective}\n' https://example.com/no-such-page`-sS` は進捗表示だけを消してエラーメッセージは残し、`-o /dev/null` はエラーページの本文を捨て、`-L` は `Location` ヘッダーを辿り、`-w` は最終的なステータス・辿ったリダイレクト数・そのステータスを返した URL を出します。最後のフィールドがこのコマンドの主眼です。curl 自身のドキュメントが、`%{url_effective}` はリダイレクトを辿らせたときに最も意味を持つと書いていて、リダイレクトの終点で出る 404 は、自分が打った URL で出る 404 とは別のバグ——書き換えが存在しない場所へ送っている、という意味になります。直接叩いたつもりの URL で `%{num_redirects}` が 0 より大きければ、それが手がかりです。エッジとオリジンのどちらが 404 を作ったかは、同じリクエストを 2 回投げれば分かります。1 回はそのまま、もう 1 回は `--resolve example.com:443:203.0.113.10` を付けて。これは元のホスト名と SNI を送りつつ、指定したアドレスへ接続します。答えが食い違うなら、エッジがオリジンには無いものを配っている——このコードの場合、キャッシュされた 404 であることがよくあります。
RFC 9110 §15.5.5 は 404 を、オリジンサーバーが対象リソースの現在の表現を見つけられなかったか、あるいはそれが存在することを明かすつもりがない状態、と定義しています。この二つはどちらも効いてきます。前半が意味するのは、404 が語っているのは「いまこの URL」だけであって、そのリソースが以前あったかどうかにも、これから現れるかどうかにも一切触れていないということです。同じ節は、恒久的に無くなったと分かっているなら 410 (Gone) のほうが望ましい、とも書いています。後半は 404 と 403 が重なる理由そのものです。§15.5.4 は、禁止されたリソースの存在自体を隠したいオリジンサーバーが代わりに 404 を返すことを明示的に認めているので、「絶対にあるはずの URL」で出る 404 は、変装したアクセス制御の判断であることがあります。もう一つ、障害の最中に人を驚かせる性質があります。404 は RFC 9110 §15.1 がヒューリスティックにキャッシュ可能と列挙している数少ないコードの一つで、`Cache-Control` を明示していなくてもプロキシや CDN が保存してかまいません。だから壊れたデプロイ中に 404 になった URL は、デプロイを直したあとも、保存された応答が期限切れになるか誰かがパージするまで 404 を返し続けます。そして 404 はメソッドについては何も言っていません。GET しか受けないパスへの POST は 404 ではなく 405 であって、そこで 404 を返すフレームワークは「どちらが違っていたのか」を教えないことを選んでいます。
| 紛らわしいコード | 見分け方 |
|---|---|
| 403 | 403 は「意味は分かったうえで拒否する」、404 は「そこには何も無い」です。RFC 9110 §15.5.4 はこの二つをわざと曖昧にしています。禁止されたリソースの存在を認めたくないオリジンサーバーが、代わりに 404 を返すことを明示的に許しているからです。だから「あるはずの URL」で 404 が出たら、ルーティングのバグを探しに行く前に、資格情報を付けてもう一度試す価値があります。オブジェクトストレージのバケットや非公開リポジトリはまさにこれをやります。 |
410 | 410 (Gone) は、そのリソースが恒久的に終わったとサーバーが分かっているときの答えで、RFC 9110 はその場合 404 より 410 が望ましいと書いています。違いは人向けではなく機械向けの信号です。404 は再試行を招き、410 は索引に「もう聞かなくていい」と伝えます。意図的にコンテンツを畳むなら、410 のほうが正直で、検索結果から消えるのも速いです。 |
| 405 | 405 (Method Not Allowed) は「パスはあるが、そのメソッドでは無い」で、RFC 9110 は通るメソッドを列挙した `Allow` ヘッダーを付けることを要求しています。GET 専用ルートへの POST に 404 を返すフレームワークは規格上は許されていますが、答えの役に立つ半分を隠しています。パスを疑う前に、メソッドを確かめてください。 |
soft 404 | soft 404 は、本文では「見つかりません」と謝っているのにステータス行は 200、というページです。HTTP としては禁じられていませんが、検索エンジンはエラーページを本文として索引しますし、スクリプトは成功ステータスを見て謝罪文をデータとして解析します。無いと分かっているページが curl で 404 を返さないなら、その食い違い自体がバグです。 |
nginx | nginx が 404 を出す場所は、ファイルが無いときだけではありません。`=404` で終わる `try_files` は意図的に返しますし、`Host` がどの `server_name` にも一致しないリクエストはデフォルトサーバーに落ちて、そこはたいてい root が空のスタブです。末尾スラッシュを失った `alias` は 1 階層足りないパスを組み立てます。エラーログには実際に試したパスが記録されるので、この 3 つを見分けるにはそれが一番速い方法です。 |
どれもブラウザ内で完結します。アップロードは発生しません。
404 を調べているとき、ついでに読むことになりがちなコードです。
ページではなく応答ヘッダーを読んでください。curl -sS -o /dev/null -D - https://example.com/path が本文なしでヘッダーだけを出し、server・via・cf-ray の 3 つで、答えたのがどの層かが分かります。cf-ray の値があれば応答を扱ったのは Cloudflare で、その値はサポートが聞いてくる ID です。エッジを完全に外して確かめたいなら、--resolve example.com:443:203.0.113.10 を付けて同じリクエストを投げ直します。元のホスト名と SNI を送りつつ、指定したオリジンのアドレスへ接続するので、答えが変われば、エッジとオリジンの言い分が食い違っているということです。
ありません。分岐条件にしてはいけません。RFC 9112 はクライアントに理由句を無視するよう求めていますし、サーバーは自由に変えられますし、HTTP/2 と HTTP/3 にはそもそも理由句が存在しません。HTTP/1.1 では 404 Not Found として届くものが、HTTP/2 では素の 404 として届きます。このサイトが登録済みの理由句を載せているのは、それが検索される語であり HTTP/1.1 のログに出る文字列だからで、何かのソフトウェアがそれに依存しているからではありません。
5xx と 429 は再試行してよく、4xx はしないでください。次に投げても中身は同じだからです。応答に Retry-After が付いていればそれに従ってください。RFC 9110 がまさにこのために定義していて、値は秒数でも日時でも構いません。付いていなければ、ジッター付きの指数バックオフと明確な上限を使い、しかも冪等なメソッドに限ってください。再試行された POST はカードに二重で課金しかねません。すでに失敗しているサーバーへの再試行の嵐は、短い障害を長い障害に変える典型的な経路です。
変えられます。守るべき規則は 1 つだけ、そのコードが本当であること。応答を自動で読むもの——検索エンジン、監視、キャッシュ、クライアント側の再試行ロジック——はすべて数字だけで判断し、ページの中身は一切見ません。エラーページを 200 で返せば自分の監視から障害が隠れますし、無いページを 200 で返せばエラーが本文として索引されますし、単に不正なリクエストに 500 を返せば、当番の人をスタックの間違った半分へ送り込むことになります。
404 でまだ詰まっているなら、ステータスコード一覧をすべて見る。あるいは上に戻って、自分の立ち位置に向けて書かれたブロックを読んでください。