実践ガイド(2026.09.06 更新)

Astro + Cloudflare Pagesの公開前に確認する3点 ― 実際の失敗と50記事の測定

このブログはAstro(静的サイトジェネレータ)でMarkdown記事をビルドし、Cloudflare Pagesに配信する構成で運用している。有料プランを一切使わず、追加費用はドメイン代くらいで済む規模だが、実際にコマンドを叩いてみるとドキュメントには載っていない小さなつまずきがいくつかあった。ここでは実際に実行したコマンドと出力をそのまま残しながら、詰まった3点を記録する。

デプロイの疎通確認は wrangler whoami では判断できない

Cloudflare PagesへのデプロイはWranglerというCLIで行う。公開前に認証状態を確認しようと、まず素直に wrangler whoami を叩いた。

$ npx wrangler whoami
 ⛅️ wrangler 4.114.0
────────────────────
Getting User settings...

X [ERROR] Failed to automatically retrieve account IDs for the logged in user.
  You may have incorrect permissions on your API token, or your
  authentication may have expired. Try running `wrangler login` to
  re-authenticate. You can also skip this account check by adding an
  `account_id` in your Wrangler configuration file, or by setting the
  value of CLOUDFLARE_ACCOUNT_ID

一見すると認証が切れているように読める。だが実際にはAPIトークン・アカウントIDの環境変数はどちらも設定済みで、このあとの本番デプロイも問題なく成功した。この2つの実行結果から確認できるのは、この環境ではwhoamiの失敗だけでデプロイ不可とは判断できなかったことまでである。権限をCloudflare Pagesの編集に絞っていたことが影響した可能性はあるが、権限構成を変えた比較はしておらず、whoamiが失敗した内部原因は未検証である。

疎通確認としてあてになったのは、実際にデプロイ対象のリソースを直接尋ねるコマンドだった。

$ npx wrangler pages project list
 ⛅️ wrangler 4.114.0
────────────────────
┌───────────────────┬─────────────────────────────────────────────┬──────────────┬───────────────┐
│ Project Name      │ Project Domains                             │ Git Provider │ Last Modified │
├───────────────────┼─────────────────────────────────────────────┼──────────────┼───────────────┤
│ ai-shinka-archive │ ai-shinka-archive.pages.dev, ai-shinka.com  │ No           │ 8 hours ago   │
└───────────────────┴─────────────────────────────────────────────┴──────────────┴───────────────┘

こちらは自分のPagesプロジェクトが正しく見えている。Pagesへの疎通を切り分けるときは、whoamiの結果だけで止めず、実際に使う操作に近い読み取りコマンド(ここではプロジェクト一覧)も確認する、というのが今回の実測から得た手順である。これは1つの権限構成での結果であり、別のトークンやアカウントでも同じになるとは限らない。

Windowsの日本語出力でPythonスクリプトがまるごと落ちる

公開前チェックには、フロントマターと本文を読んで機械的にPASS/FAIL/WARNを出す自作のPythonスクリプトを使っている。ある記事に対して実行したところ、1件目のチェック結果を出力した直後に例外で落ちた。

=== [OK] 2026-08-29-xxxxx.md ===
  OK フロントマター: 検出
Traceback (most recent call last):
  File "<プロジェクト>/tools/check_post.py", line 242, in <module>
    main()
  File "<プロジェクト>/tools/check_post.py", line 232, in main
    print(f"{symbol}{name}: {detail}")
UnicodeEncodeError: 'cp932' codec can't encode character '\xf1'
in position 50: illegal multibyte sequence

原因はスクリプトの中身ではなく、Windowsのターミナルが標準出力に使っている既定の文字コード(cp932、いわゆるShift_JIS系)だった。その日の記事タイトルに含まれていた欧文の特殊文字(アクセント記号付きの“ñ”)がcp932の範囲外で、print()した瞬間に丸ごと例外になっていた。日本語の文章自体は普段から問題なく出力できていたため、原因の切り分けに手間取った。

対処は、標準出力の文字コードをUTF-8に固定することで足りた。

$env:PYTHONIOENCODING = 'utf-8'
python tools/check_post.py site/src/content/posts/2026-08-29-xxxxx.md

=== [OK] 2026-08-29-xxxxx.md ===
  OK フロントマター: 検出
  OK 必須項目 title: "..."
  ...
全項目OK。公開して問題ありません。

上のコマンドはPowerShell用で、記事ファイル名は手元の対象へ置き換えます。標準出力がcp932の環境では、表現できない文字により処理が停止します。Windowsでも端末やUTF-8モードで挙動は変わるため、実際の標準出力の設定を確認してください。2026-09-05にはこのブログのチェックプログラム自体にもUTF-8出力を設定し、主記事3本の検査結果が文字化けせず出力されることを確認しました。

フロントマターの型はビルド時に検証させる

記事のメタデータ(タイトル・公開日・カテゴリ等)はAstroの Content Collections という仕組みで、Zodというライブラリを使って型を定義している。実際の定義はおおよそ次のような形になっている(項目名は簡略化した例)。

import { defineCollection, z } from "astro:content";
import { glob } from "astro/loaders";

const posts = defineCollection({
  loader: glob({ base: "./src/content/posts", pattern: "**/*.md" }),
  schema: z.object({
    title: z.string(),
    description: z.string(),
    pubDate: z.coerce.date(),
    category: z.string().default("日次ログ"),
    tags: z.array(z.string()).default([]),
    sources: z
      .array(z.object({ label: z.string(), url: z.string().url() }))
      .default([]),
    draft: z.boolean().default(false),
  }),
});

export const collections = { posts };

このスキーマの効果は、定義した型に合わないフロントマターをビルド時に検出し、公開処理を止められる点にある。pubDateに日付として解釈できない値が入っていたり、sourcesurlがURL形式でなかったりすると、astro build自体がエラーで止まる。2026年9月6日時点のこのブログでは、全記事検査、ビルド、公開用サイト検査、アップロードを&&でつないだnpm run deployを使っており、いずれかの検査が失敗すればアップロードへ進まない。スキーマが検出するのは定義済み項目の型であり、本文の正しさや必須にしていない値までは保証しないため、別の検査と編集確認も残している。

無料で回せる範囲について、今のところわかっていること

移管前のアイキャッチは、外部の写真素材やAI生成画像を使わず、Pillow(Pythonの画像処理ライブラリ)でタイトル文字と単色背景を描く方式だった。今回の追試更新では、Markdownから静的ページが生成される流れを表した新規のAI生成イラストへ差し替え、画像内に文字・ロゴ・認証情報を入れず、1200×630ピクセルに整えてWebP形式に変換した。外部の写真素材を取り込まない方針は維持している。この構成でここまで運用してきた範囲では、Cloudflare Pages側の無料枠(Direct Upload方式)を超えるような負荷は発生していない。ただし本ブログは記事数・アクセス数ともにまだ小規模であり、記事数が数百本規模に増えた場合のビルド時間やアセット容量については実測していない(未検証)。個人や小規模チームが同じ構成を検討する場合も、この規模感の範囲での再現性として読んでほしい。

今回の3点はいずれも「ドキュメント通りに設定したのに、実行してみると一癖ある」という種類のつまずきだった。次に触るとしたら、記事数が増えた際のビルド時間の実測と、タグ・カテゴリ別ページが増えたときのサイトマップ生成の挙動を確認したい。


2026-09-04追試:50記事規模のビルド時間・生成量・サイトマップを測った

前節で未検証としていた「記事数が増えた際のビルド時間とアセット容量」を、Markdown 50本の現行サイトで追試した。対象読者は、Astro製ブログが数十記事に増え、Cloudflare PagesへDirect Uploadする前にビルド時間・ファイル数・サイトマップを確認したい運営者である。

この記事を読んだ後には、自分の環境でも同じ計測を行い、少なくとも次の3点を判断できる。

  • 連続ビルドが毎回成功し、所要時間が極端にばらついていないか
  • dist のファイル数と各ファイルのサイズが、Direct Uploadの制限に近づいていないか
  • 公開対象の記事URLをサイトマップに含め、保管庫など除外したいURLを含めていないか

再現条件

項目 今回の条件
計測日 2026-09-04(JST)
OS Windows
Node.js v22.14.0
npm 10.9.2
Astro 7.1.3
記事数 Markdown 50本
記事内訳 editorial: original 3本、legacy 47本
実行方法 同一checkoutで npm run build を連続3回

容量とファイル数は、この追試本文と新しいアイキャッチを反映する直前のソースを基準にした。記事自身へ計測値を書き込むと生成HTMLの容量が変わるため、以下の値は公開後の成果物容量を固定的に保証する数字ではなく、再測定時に差分を見るための基準値として扱う。

Astro公式では、Content Collectionsのビルド時データはビルド間でキャッシュでき、多数のコンテンツ項目にも適すると説明している。ただし、それは個別サイトの所要時間を保証するものではない。そこで今回は、同じマシン・同じcheckoutのまま3回連続で実測した。

追試コマンド

以下はPowerShellで実行できる。プロジェクト固有の絶対パス、トークン、アカウント情報は不要である。サイトのディレクトリへ移動してから実行する。

node --version
npm --version
npm ls astro --depth=0

1..3 | ForEach-Object {
  $stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
  npm run build
  $exitCode = $LASTEXITCODE
  $stopwatch.Stop()

  [pscustomobject]@{
    Run      = $_
    Seconds  = [math]::Round($stopwatch.Elapsed.TotalSeconds, 3)
    ExitCode = $exitCode
  }

  if ($exitCode -ne 0) { break }
}

3回とも終了コードが0だった場合だけ、最終回の dist を数える。

$files = Get-ChildItem dist -Recurse -File

[pscustomobject]@{
  Files     = $files.Count
  HtmlFiles = @($files | Where-Object Extension -eq ".html").Count
  Bytes     = ($files | Measure-Object Length -Sum).Sum
  MiB       = [math]::Round(
    ($files | Measure-Object Length -Sum).Sum / 1MB,
    3
  )
}

3回の比較結果

プロセス全体 Astro内部表示 exit 生成ページ
1 4.766秒 1.90秒 0 61 pages
2 4.449秒 1.91秒 0 61 pages
3 4.204秒 1.71秒 0 61 pages

プロセス全体では最短4.204秒、最長4.766秒で、3回の差は0.562秒だった。Astro内部表示は1.71〜1.91秒で、3回とも同じ61ページを生成した。3回目が最速だったが、3回だけではキャッシュ効果と断定できないため、ここでは「同一条件の3回でビルド失敗やページ数の揺れはなかった」とだけ判断する。

出力は次のようになった(時刻や詳細なルート一覧は省略)。

Run Seconds ExitCode
--- ------- --------
  1   4.766        0
  2   4.449        0
  3   4.204        0

Files HtmlFiles   Bytes MiB
----- ---------   ----- ---
  121        61 5034626 4.801

最終 dist は121ファイル、うちHTMLが61ファイル、合計5,034,626 bytes(4.801 MiB)だった。

Direct Upload制限と比べるときの注意

CloudflareのDirect Upload公式ガイドを2026-09-04に確認した時点では、Wranglerによるアップロードは20,000ファイルまで、1ファイルあたり25 MiBまでと記載されていた。今回の121ファイルはファイル数上限の約0.61%であり、ファイル数については大きな余裕がある。

ただし、dist 全体の4.801 MiBと「1ファイル25 MiB」の制限を比較してはいけない。25 MiBは合計容量ではなく、個々のファイルに対する上限である。アップロード前には合計値だけでなく最大ファイルも別に確認する。

Get-ChildItem dist -Recurse -File |
  Sort-Object Length -Descending |
  Select-Object -First 10 Name, Length

今回は最終生成物の合計容量を測定したが、最大ファイルの値は本記事の比較表には記録していない。そのため「単一ファイル25 MiB未満だった」とは、この計測結果だけからは断定しない。

サイトマップのURL数と除外を確認する

Astro公式のSitemap integrationは、プロジェクトをビルドしたときにページをもとにサイトマップを生成する。今回は最終 dist のXMLをPowerShellで読み、インデックスの参照数、実URL数、保管庫URLの混入、この記事のURLの収録を確認した。

[xml]$index = Get-Content -Raw -Encoding utf8 dist/sitemap-index.xml
[xml]$map = Get-Content -Raw -Encoding utf8 dist/sitemap-0.xml
$locations = @($map.urlset.url.loc | ForEach-Object { [string]$_ })
$legacySlugs = @(
  Get-ChildItem src/content/posts -Filter "*.md" -File |
    Where-Object {
      $raw = Get-Content -Raw -Encoding utf8 $_.FullName
      $raw -notmatch '(?m)^editorial:\s*["'']?original["'']?\s*$'
    } |
    ForEach-Object BaseName
)
$legacyPostUrls = @(
  foreach ($slug in $legacySlugs) {
    $expected = "https://ai-shinka.com/posts/$slug/"
    $locations | Where-Object { $_ -eq $expected }
  }
)

[pscustomobject]@{
  SitemapRefs   = @($index.sitemapindex.sitemap).Count
  URLs          = $locations.Count
  ArchiveURLs   = @($locations | Where-Object { $_ -match "/archive(?:/|$)" }).Count
  LegacySlugs   = $legacySlugs.Count
  LegacyPostURLs = $legacyPostUrls.Count
  ExistingPost  = [bool]($locations -match "astro-cloudflare-pages-deploy-pitfalls")
}
SitemapRefs URLs ArchiveURLs LegacySlugs LegacyPostURLs ExistingPost
----------- ---- ----------- ----------- -------------- ------------
          1   12           0          47              0         True

sitemap-index.xmlsitemap-0.xml を1件参照し、sitemap-0.xml には12 URLが入っていた。/archive/ 配下は0件で、47本のlegacy記事slugに一致する個別URLも0件だった。一方、既存のこの記事の個別URLは含まれていた。

生成ページは61なのにサイトマップが12 URLなのは、ビルド失敗を示す数字ではない。このサイトでは旧ニュース記事を保管庫へ分離し、サイトマップの公開対象を絞っているためである。追試時はURL総数だけを見るのではなく、「含めたい個別URLがあるか」「除外したいパスが0件か」をセットで確認する方が異常を見つけやすい。

この追試で失敗しなかったことと、まだ言えないこと

今回の3回ではビルド失敗を再現できず、終了コード・生成ページ数とも安定していた。一方、結論の適用範囲には次の限界がある。

  • 計測は3回だけで、統計的な性能評価ではない
  • Windows上の単一マシンだけで実施し、macOS、Linux、CI環境とは比較していない
  • 静的ビルドだけを測り、同時アクセスや配信時の負荷は測っていない
  • Direct Upload自体の所要時間や失敗率は測っていない
  • Cloudflareのファイル数・ファイルサイズ制限は今後変更される可能性があるため、公開前に公式ガイドを再確認する必要がある
  • dist 全体4.801 MiBは単一ファイル25 MiB制限への適合を証明しない
  • 追試本文と新しいアイキャッチを反映すると生成物の容量は変わるため、表の値は公開直前の基準値である
  • 50記事で約4〜5秒だった結果を、100記事・1,000記事へ比例計算して予測することはできない

実務への影響:現時点では構成変更より再測定を選ぶ

今回の条件では、50記事・121ファイルのAstro静的サイトは3回とも約4.2〜4.8秒でビルドでき、サイトマップの対象・除外も意図どおりだった。したがって現時点で規模を理由に構成変更する材料はない。次に再測定すべきタイミングは、記事や画像が大きく増えたとき、ビルド時間が普段より明確に延びたとき、またはサイトマップ設定を変更したときである。

小規模なブログ運営で優先すべきなのは、別のCMSや有料プランへ急いで移ることではなく、同じコマンドと条件で基準値を残すことだ。次回の結果が今回の約4〜5秒、61ページ、121ファイルから大きく外れたときに、記事数・画像容量・依存パッケージ・サイトマップ設定のどこが変わったかを順に確認できる。反対に、数値が同程度なら、体感だけを理由に構成を変える必要は薄い。公開前の判断は「ビルド成功」「生成ページ数が想定内」「必要なURLがサイトマップにあり、除外対象がない」「最大ファイルが配信先の上限未満」の4点に分けると、合計容量と単一ファイル制限の取り違えも防ぎやすい。

関連する実践記録として、同じWindowsの文字コード問題を無人実行の観点から切り分けた Claude Codeの定期タスク記事も参照できます。

2026-09-06訂正:whoamiの内部原因を実測以上に断定していた記述を未検証へ戻し、現行の公開前検査とアップロードの順序に合わせて説明を更新しました。また、記事の中心題材と一致しないAI系タグを外し、初期作成から公開までの完結手順と誤解されないよう、タイトルを公開前の確認・失敗・測定という本文の範囲へ限定しました。

一次情報・出典

  1. Cloudflare公式:Wrangler Direct Upload(Cloudflare Pagesへのデプロイ手順)
  2. Astro公式:Content Collections ガイド
  3. Astro公式:Sitemap integration ガイド