この記事でできること
記事数が多いサイトでは、sitemapをいくつかのファイルに分割し、それらを束ねるsitemap index(/sitemap.xml)から参照する形がよく使われます。分割の仕方として手軽なのは、「新しい順に並べた全記事を1,000件ずつ区切る」方法です。
この区切り方は、どのリクエストに対しても、その時点では正しいsitemapを返します。困るのは、クローラーがsitemap indexと各分割を別々のタイミングで取りに来ることです。その間に記事が1件増えるだけで、分割の中身がずれます。
この記事は、個人開発している技術記事キュレーションサービスDevPickで、件数で区切る分割を見直したときの確認項目をまとめたものです。DevPickはNext.jsのApp Routerで、generateSitemapsを使ってsitemapを分割しています。ただ、確認項目そのものはフレームワークに依存しません。
確認1:分割の境界が、他の記事の増減で動かないか
「新しい順に並べて1,000件ずつ」の場合、新着記事が1件入ると、それより古い記事はすべて1つ後ろへずれます。1番目の分割の最後にあった記事は2番目の分割の先頭に移り、2番目の最後の記事は3番目へ移ります。保持期間切れで古い記事が消えるときも、逆向きに同じことが起きます。
このずれは、次のような形で表に出ます。
- クローラーが分割を順に取得している途中で記事が増減すると、境界にある記事が2つの分割に重複するか、どちらにも載らない
- 記事が減って件数が1,000件の境界を下回ると、取得済みのsitemap indexに載っている最後の分割が404になる
- 実際には更新されていない記事を含めて、全分割の中身が毎回変わる
sitemap indexにキャッシュを効かせているなら、ずれは起きやすくなります。DevPickでは、sitemap indexを1時間、stale-while-revalidateを含めると最大1日キャッシュしていました。新着記事は日中ほぼ常に入るので、クロールの間に境界がずれる状況はいつでも起こり得ました。
確認すること:
- 記事がどの分割に入るかを、並び順の位置(offset)で決めていないか
- 1件の記事を追加・削除したときに、他の記事が属する分割が変わらないことをテストで確かめているか
確認2:分割のキーが、後から変わる値になっていないか
offsetをやめるなら、記事ごとに決まる値で分割を決めることになります。このとき、その値が後から変わらないかを確かめます。
公開日時のように外部のフィードから受け取る値は、再取得や修正で書き換わることがあります。書き換わると、その記事は別の分割へ移ります。必要なのは「他の記事の増減で動かない」ことと「その記事自身が更新されても動かない」ことの両方です。
DevPickでは、採番したあとは変えない記事IDを1,000件の幅で区切り、id // 1000の値を分割番号にしました。
確認すること:
- 分割キーに使う値が、記事の更新・補完・再取り込みで書き換わらないか
- 外部から取得した値(公開日時、カテゴリなど)ではなく、自分のシステムで決めた値を使っているか
確認3:1つの分割がsitemapの上限を超えない形になっているか
sitemapの仕様では、1つのファイルに載せられるのは50,000 URLまで、サイズは非圧縮で50MBまでと決まっています(sitemaps.org)。
月や日で分割すると、1つの分割に入る記事の数は、その期間に何件取り込んだかで決まります。上限を超えそうな月が出てきたら、その中をさらに分ける必要があります。
IDの範囲で分割すると、1つの分割に入る記事の数は、範囲の幅を超えません。DevPickは日本語版と英語版のURLを載せるので1記事あたり最大2 URLで、1,000件の幅なら最大2,000 URLです。上限との差は、取り込み件数がどれだけ増えても変わりません。
確認すること:
- 多言語のURLや画像のエントリを含めて、1記事あたり何URLになるかを数えたか
- 分割の幅から、1ファイルあたりのURL数の上限が決まる形になっているか
確認4:一度インデックスに載せた分割が、空になっても404にならないか
IDの範囲で分割すると、保持期間を過ぎた古い記事が消えるにつれて、IDの小さい分割から順に空になります。キャッシュされたsitemap indexにはその分割が載ったままなので、空になった時点で404を返すと、確認1と同じ404が起きます。
DevPickでは、空の分割も404にせず、URLが0件の<urlset>を200で返すようにしました。
Next.jsのgenerateSitemapsは、返したIDの分だけsitemapのルートを作り、返さなかったIDは404になります。「今記事がある分割」だけを返す作りだと、ここで空の分割が消えます。DevPickでは、分割番号の最大値をDBに記録しておき、generateSitemapsは0からその値までの全IDを返すようにしました。
確認すること:
- 記事が0件になった分割に、404ではなく空のsitemapを返しているか
- 有効な分割IDの一覧を「今ある記事」から作っていないか。作っているなら、記事が減ったときに一覧も減っていないか
- 一覧の元になる値を、どのワーカーからも同じ値が見える場所に置いているか
確認5:分割方式を切り替えるとき、古いURLの意味を変えていないか
分割の方式を変えても、クローラーの手元には古いsitemap indexが残っています。DevPickでは、それまで/sitemap/0.xml、/sitemap/1.xmlのような数値のURLを使っていました。
新しい方式でも同じ数値のURLを使うと、/sitemap/1.xmlが指す記事の範囲が、古いindexと新しいindexで食い違います。そこで、新しい分割は/sitemap/chunk-1.xmlのように別の名前にしました。古い数値のURLは、今も従来どおりの区切り方で記事を返しています。
確認すること:
- 新しい分割のURLが、古い分割のURLと同じ名前にならないか
- 古いURLを、キャッシュの期間より長く返し続けるか
確認6:分割の一覧を取れなかったとき、少ない一覧で代わりに返していないか
分割の一覧はバックエンドに問い合わせて作ります。この問い合わせに失敗したとき、「とりあえず静的ページの分割だけ」の一覧で代わりに返すと、その間に生成したルートでは記事の分割がすべて404になります。
DevPickでは、実行時に一覧を取れなかった場合は代わりの一覧を返さず、エラーにしています。5xxであれば、クローラーは後で取り直します。例外は、バックエンドが無い状態で行うビルドの間だけです。
確認すること:
- 一覧の取得に失敗したとき、有効な分割を404にする形で処理を続けていないか
まとめ
- 新しい順に並べて件数で区切ると、記事が1件増減するたびに全分割の境界がずれる。sitemap indexと各分割は別々に取得されるので、重複・欠落・404につながる
- 分割キーには、他の記事の増減でも、その記事自身の更新でも変わらない値を使う。DevPickでは採番済みの記事IDを1,000件の幅で区切った
- IDの幅で区切ると、1ファイルあたりのURL数の上限が取り込み件数に依存せずに決まる
- 空になった分割は404にせず、空のsitemapを200で返す。有効なIDの一覧は、これまでの最大値をDBに保存してそこから作る
- 方式を切り替えるときは、新しいURLに古いURLと違う名前を付け、古いURLもしばらく返す
- 一覧を取れないときは、少ない一覧で代わりに返さずにエラーにする
公開月で分ける案から記事IDの範囲に落ち着くまでの経緯は、Zenn版に書いています。