静的サイト 2026年8月16日

存在しないURLがトップページを403で返していた。静的サイトの404を実測して直した話

XECIN コーポレートサイトSEOCloudFront技術検証

きっかけは、ほんの思いつきでした。

自社サイトの導線を整理していて、使われていないページを1本消したんです。消したあと、念のため「消したURLを踏んだ人には何が見えるんだろう」と思ってブラウザで開いてみました。

出てきたのはトップページでした。

一瞬、リダイレクトを仕込んだ覚えがあったかなと考えたんですが、そんな設定はしていません。しかも見た目はトップページなのに、DevToolsのNetworkタブに出ているステータスは404でも301でもなく、403だったんですよね。

自分たちのサイトには src/pages/404.astro がちゃんとあります。ビルドすれば 404.html が出力されるし、S3にもアップロードされている。それなのに、一度も表示されていなかったということになります。

正直なところ、この時点でかなり嫌な予感がしました。

まず実測から始めた

推測で設定をいじると、直ったのか直っていないのかが分からなくなります。なので最初に、現状を数字で押さえることにしました。

# 存在しないパスを叩く
$ curl -s -o /dev/null -w "status=%{http_code} size=%{size_download}\n" \
    https://xecin.jp/this-page-does-not-exist/
status=403 size=55615

# レスポンスヘッダを見る
$ curl -s -D - -o /dev/null https://xecin.jp/this-page-does-not-exist/ \
    | grep -i "^HTTP\|x-cache"
HTTP/1.1 403 Forbidden
X-Cache: Error from cloudfront

# 404.html そのものは配信されているのか
$ curl -s -o /dev/null -w "status=%{http_code} size=%{size_download}\n" \
    https://xecin.jp/404.html
status=200 size=8909

これで状況がはっきりしました。

404.html は 8,909バイトで正しく配信されている。にもかかわらず、存在しないURLに対しては 55,615バイトが返っている。この数字はトップページのHTMLとほぼ同じサイズで、実際に本文を覗いたら <title>XECIN — 途中からでも、ゴールまで伴走。</title> が入っていました。

つまり「404ページが無い」のではなく、「404ページはあるのに、別のものが返されていた」わけです。

X-Cache: Error from cloudfront も地味に重要でした。これはCloudFrontがオリジンのエラーを受けて自前のエラー処理に入ったことを示しています。犯人はAstro側ではなく配信層だと、ここで切り分けができました。

なぜ404ではなく403なのか

S3をオリジンにしたCloudFrontで、存在しないオブジェクトを取りに行くと403が返る。これ自体はよく知られた挙動なんですが、理由を理解していないと直しようがありません。

原因はバケットポリシーの権限範囲でした。当時のポリシーがこれです。

{
  "Sid": "AllowCloudFrontOAC",
  "Effect": "Allow",
  "Principal": { "Service": "cloudfront.amazonaws.com" },
  "Action": "s3:GetObject",
  "Resource": "arn:aws:s3:::company-site-prod/*",
  "Condition": {
    "StringEquals": {
      "AWS:SourceArn": "arn:aws:cloudfront::ACCOUNT_ID:distribution/DISTRIBUTION_ID"
    }
  }
}

許可しているのは s3:GetObject だけで、リソースもオブジェクト(/*)のみです。この状態だと、S3は「そのキーが存在しないのか、それとも見る権限が無いのか」を呼び出し元に区別して伝えられません。存在の有無を答えること自体が情報漏洩になりうるので、S3は安全側に倒して一律 AccessDenied を返します。

なので、バケットのルートに対する s3:ListBucket を足してやると、S3は「キーが無い」と正直に404で答えられるようになります。

ちなみに、上のポリシーに company-site-prod とか ACCOUNT_ID が残っているのが見えると思います。これは infra/ に置いてあるテンプレート側の値で、本番のディストリビューションは当時IaC管理から外れていました。この「テンプレートと実物がずれている」構図が、あとでもう一発効いてきます。

本丸はフォールバック先の指定だった

権限を直せば403は404になります。ただ、それだけだと素っ気ないCloudFrontのXMLが表示されるだけで、自分たちの404ページには辿り着きません。

そして、なぜトップページが返っていたのかという最大の謎もまだ残っています。答えはカスタムエラーレスポンスの設定で、エラー時の表示先が /404.html ではなく /(つまり index.html)に向いていました。

これ、SPAをS3+CloudFrontに載せるときの定番設定なんですよね。クライアントサイドルーティングのアプリだと、どのパスに直接アクセスされてもindex.htmlを返してJS側にルーティングを任せる必要がある。その設定をそのまま静的サイト生成のサイトに持ち込むと、存在しないURLがトップページの中身を返すという結果になります。

Astroで全ページを事前ビルドしている以上、フォールバックは不要でした。書き換えたのがこちらです。

CustomErrorResponses:
  # SSGなので / へのフォールバックは不要。404.html を 404 のまま返す
  - ErrorCode: 403
    ResponsePagePath: "/404.html"
    ResponseCode: 404
    ErrorCachingMinTTL: 10
  - ErrorCode: 404
    ResponsePagePath: "/404.html"
    ResponseCode: 404
    ErrorCachingMinTTL: 10

ResponseCode: 404 を明示しているのがポイントです。ここを書き忘れると、ページの見た目だけ404になってステータスは元のまま返ります。人間の目には正しく見えるのに、クローラーには「200で中身のあるページ」と伝わる状態、いわゆるソフト404です。

ErrorCachingMinTTL を10秒と短くしているのは、エラーレスポンスの長時間キャッシュで検証が回らなくなるのを避けるためです。記事を追加した直後に一時的に404が出た場合、それが何分も貼り付くとリリース確認が地獄になります。

症状実測値原因
存在しないURLでトップが出る55,615 バイトエラー時の表示先が / になっていた
ステータスが404にならない403 ForbiddenListBucket 権限が無く、ResponseCode も未指定
404ページ自体は生きている200 / 8,909 バイトビルドもデプロイも正常。配信層だけの問題

想定外だったのは、404ページの中身が誰にも見られていなかったこと

配信側を直して、ようやく自分たちの404ページが表示されるようになりました。

そこで初めて、その404ページを真面目に見ました。titleが 404 Not Found | 会社名、canonicalが https://xecin.jp/404/、robotsが index, follow

会社名

サイトを作ったときの雛形がそのまま残っていました。表示される経路が無かったので、誰も気づかないまま1年以上動いていたことになります。今思えば、404ページを実際に踏んでみるという当たり前の確認を、リリース時のチェックリストに一度も入れていませんでした。

メタ情報のほうも良くありませんでした。

canonicalが https://xecin.jp/404/ を指していますが、実体は /404.html で、/404/ というURLは存在しません。これは共通レイアウトが Astro.url.pathname から機械的にcanonicalを組み立てているからで、通常ページでは正しく動く処理が、エラーページでだけ存在しないURLを名乗る形になっていました。

robotsが index, follow なのも見過ごせません。404ページはsitemapには載りません(実際 sitemap-0.xml に並んでいたのは110件のみで、404は含まれていませんでした)。ただ、直リンクや外部からの参照でクロールされる可能性はあるので、エラーページ側で明示的に降ろしておくのが安全です。

共通レイアウトに noindex を渡せる口が無かったので、Propsを一つ増やして404ページから指定するようにしました。

---
// BaseLayout.astro に noindex を追加し、エラーページから渡せるようにした
interface Props {
  title: string;
  description?: string;
  noindex?: boolean;
}
const { title, description, noindex = false } = Astro.props;
// canonical は noindex のページでは出さない
const canonicalUrl = noindex
  ? undefined
  : new URL(Astro.url.pathname, 'https://xecin.jp').toString();
---

404側は、社名を入れて文言を整えるだけです。地味ですが、ここが「会社名」のまま検索結果に出るよりはるかにマシなんですよね。

プレビュー環境では、書いてあるのに効いていなかった

もう一つ、これは調べていて肝が冷えた話です。

自分たちはPR単位のプレビュー環境を preview.xecin.jp/pr-{番号}/ という形で運用しています。こちらのディストリビューションはCloudFormationで管理していて、テンプレートにはさっきと同じカスタムエラーレスポンスがきちんと書いてありました。403も404も /404.html に向けて、ResponseCode: 404 も指定済み。

なのに、プレビューの存在しないパスに同じ curl を当てると status=403 size=111 が返ってきました。

111バイト。これはCloudFrontが素で返すAccessDeniedのXMLのサイズです。テンプレートに書いた404ページは出ていません。

理由は ResponsePagePath の解決基準でした。この値はディストリビューションのルートからの絶対パスとして解釈されます。一方、プレビュー環境のビルドはbase pathを付けて /pr-364/ 配下に出力されるので、404.html が置かれるのは /pr-364/404.html です。バケット直下の /404.html は存在しません。存在しないページを表示しようとして失敗し、結局オリジンのエラーがそのまま出ていた、という流れです。

配信の設定はディストリビューション単位、ビルドの出力先はPR単位。この粒度のズレは設定ファイルを読んでいるだけでは絶対に気づけませんでした。

ドキュメントにはこう書いてあるけど実際は、という話ではなく、自分たちが書いた設定を自分たちで検証していなかっただけです。なので、確認をコマンドの形に落としました。

#!/usr/bin/env bash
# 404 の効き方を環境ごとに確認する。CIには未組み込みで、今はリリース前に手で叩いている
for base in "https://xecin.jp" "https://preview.xecin.jp/pr-364"; do
  read -r code size < <(curl -s -o /dev/null \
    -w "%{http_code} %{size_download}" "${base}/__no_such_page__/")
  echo "${base} -> status=${code} size=${size}"
  [ "$code" = "404" ] || echo "  NG: 404 以外が返っている"
  [ "$size" -gt 20000 ] && echo "  NG: 本文が大きすぎる(トップページを返している疑い)"
done

このスクリプトは改善の余地があります。判定をレスポンスサイズという間接的な指標に頼っているのが雑で、本来は返ってきたHTMLのtitleやcanonicalを見て「404ページであること」を確かめるべきです。プレビュー側もPR番号を手で書き換える運用のままなので、そのうちCIに組み込みたいと思いつつ後回しになっています。

確認項目期待する結果ずれていたときに疑うところ
存在しないURLのステータス404403ならバケットポリシー、200ならResponseCode未指定
返ってきた本文404ページのHTMLトップページならフォールバック先の指定
404ページのrobotsnoindex共通レイアウトが一律で出力していないか
base path 環境での挙動本番と同じく404ResponsePagePathがルート基準になっていないか

振り返って

一連の作業で一番効いたのは、設定ファイルを読み比べる前に curl を一本叩いたことでした。

ステータスコードとレスポンスサイズという2つの数字を見た瞬間に「404ページは生きているが配信されていない」と切り分けられたので、原因の候補がAstro側から配信層に一気に絞れました。設定ファイルから読み始めていたら、テンプレートには正しいことが書いてあるので、たぶん納得して引き返していたと思います。

もう一つは、マネージドのホスティングが黙ってやってくれている仕事の量です。VercelやNetlifyのようなサービスなら、フォールバック先の指定もステータスコードの扱いも大体よしなにやってくれます。S3とCloudFrontを自分で組んだということは、その分の面倒を自分で引き受けたということで、権限モデル・エラーレスポンス・base pathの三つが噛み合わないと期待通りに動かない。安いのには理由があるんですよね。

今後は、リリース前チェックに「存在しないURLを1本叩く」を正式に入れるつもりです。上のスクリプトをCIに組み込んで、プレビューのデプロイ後に自動で回すところまで持っていきたい。エラーページって、動いていないことに気づく仕組みが無いと本当に何年でも放置されるので。

もっと良い方法があったら教えてください。