ある朝、自分のサイトの src/generated/articles.json が何バイトあるのかを、ふと数字で答えようとして、答えられないことに気づきました。
私は個人開発で、Next.js を Cloudflare Workers に載せた Web サイトを自分で運営しています。記事は MDX で書き、ビルドの前に HTML へ変換して、本文は静的アセットとして置いています。JSON に入るのはタイトルや説明といったメタデータだけです。この分け方が安全だという感覚はありましたが、理由を数字で説明できてはいませんでした。
この記事は、その感覚を手元のファイルで数え直した記録です。結論を先に書くと、本文まで JSON に入れた場合は約 28.5 MiB、メタデータだけなら約 0.93 MiB でした。そして、数え直して一番驚いたのは、上限までの余白ではなく、メタデータの内訳のほうでした。
先に確かめた上限の値
Cloudflare のドキュメントでは、Workers のサイズ上限は非圧縮で 64 MiB と書かれています。圧縮後のサイズに上限はなく、Wrangler が表示する gzip 後の数字は参考値だそうです。静的アセットは、1 ファイル 25 MiB まで、ファイル数は無料プランで 20,000、有料プランで 100,000 です。
古い情報では「圧縮後 3 MB/10 MB」という数字を見かけます。私も最初はそちらを思い浮かべていました。いまの公式ページを開いて確かめるまで、64 MiB という値は頭にありませんでした。上限は更新されることがあるので、数え直す前に値のほうを確かめるのが先だと思います。
数え方: 同じ記事を二通りに束ねる
数えたいのは単純な問いです。「本文を JSON に含めたら何バイトか。含めなかったら何バイトか」。ビルドを走らせなくても、MDX のフロントマターと本文を読めば近似できます。
#!/usr/bin/env python3
"""記事本文を JSON に入れた場合と、メタデータだけにした場合の余白を数える。
使い方: python3 bundle_guard.py <repo> [月あたりの新規記事数]"""
import json, os, re, sys
LIMIT = 64 * 1024 * 1024 # Workers の非圧縮バンドル上限(公式ドキュメントの値)
META_ALERT = 2 * 1024 * 1024 # メタデータ JSON がこれを超えたら設計を見直す(私の線引き)
def read_articles (root, locale):
base = os.path.join(root, "content" , "articles" , locale)
for cat in sorted (os.listdir(base)):
d = os.path.join(base, cat)
if not os.path.isdir(d):
continue
for name in sorted (os.listdir(d)):
if not name.endswith( ".mdx" ):
continue
text = open (os.path.join(d, name), encoding = "utf-8" ).read()
m = re.match( r " ^ --- \n (. *? ) \n --- \n (. * )$ " , text, re.S)
if not m: # フロントマターが壊れた記事は数えずに知らせる
print ( "skip:" , locale, cat, name, file = sys.stderr)
continue
yield cat, name[: - 4 ], m.group( 1 ), m.group( 2 )
def field (fm, key):
m = re.search( rf '^ { key } :\s*"?(.*?)"?\s*$' , fm, re.M)
return m.group( 1 ) if m else ""
def main (root, per_month = 40 ):
meta_total = full_total = pairs = 0
for loc in ( "ja" , "en" ):
meta, full = [], []
for cat, slug, fm, body in read_articles(root, loc):
row = {k: field(fm, k) for k in ( "title" , "level" , "date" , "description" )}
row.update( slug = slug, category = cat)
meta.append(row)
full.append({ ** row, "content" : body})
sep = ( "," , ":" )
meta_total += len (json.dumps(meta, separators = sep, ensure_ascii = False ).encode())
full_total += len (json.dumps(full, separators = sep, ensure_ascii = False ).encode())
pairs = max (pairs, len (meta))
per_pair_full, per_pair_meta = full_total / pairs, meta_total / pairs
print ( f "記事数(1言語): { pairs } " )
print ( f "メタのみ: { meta_total / 1024 / 1024 :.2f } MiB / 本文込み: { full_total / 1024 / 1024 :.2f } MiB" )
print ( f "本文込みで上限まで: あと約 { ( LIMIT - full_total) / per_pair_full :.0f } 組 ≒ { ( LIMIT - full_total) / per_pair_full / per_month :.1f } か月" )
print ( f "メタのみで上限まで: あと約 { ( LIMIT - meta_total) / per_pair_meta :.0f } 組" )
return 1 if meta_total > META_ALERT else 0
if __name__ == "__main__" :
sys.exit(main(sys.argv[ 1 ], float (sys.argv[ 2 ]) if len (sys.argv) > 2 else 40 ))
手元のリポジトリ(日本語・英語とも 917 記事)で実行した出力は、次のとおりです。
記事数(1言語): 917
メタのみ: 0.93 MiB / 本文込み: 28.54 MiB
本文込みで上限まで: あと約 1140 組 ≒ 28.5 か月
メタのみで上限まで: あと約 62334 組
ここでの「組」は、日本語版と英語版を一対にした数です。最近の公開ペースは月に 40 本前後なので、本文込みでも 2 年以上は持つ計算になります。
数字を並べると
ただし、この「本文込み」は Markdown の原文での数字です。実際にバンドルへ入れるのは変換後の HTML で、コードのシンタックスハイライトが付くぶん、さらに膨らみます。私の環境ではビルドの依存を入れていないため、HTML の実測値は取れていません。ですから 28.5 MiB は下限の目安であり、余白はこれより小さいと考えるのが安全です。
もう一つ、見落としやすい点があります。64 MiB には Next.js 本体や React、そのほかの依存も含まれます。JSON だけで上限に届くずっと手前で、デプロイが落ち始める可能性があります。
直感に反した内訳: 重いのは本文ではなく説明文でした
上限の話は、ほぼ予想どおりでした。数え直して意外だったのは、メタデータ JSON の中身です。日本語版の 917 件について、フィールドごとの割合を出しました。
import json, re, os
def fm_of (path):
t = open (path, encoding = "utf-8" ).read()
return re.match( r " ^ --- \n (. *? ) \n --- \n " , t, re.S).group( 1 )
def val (fm, key):
m = re.search( rf '^ { key } :\s*"?(.*?)"?\s*$' , fm, re.M)
return m.group( 1 ) if m else ""
rows = []
base = "content/articles/ja"
for cat in sorted (os.listdir(base)):
for f in sorted (os.listdir( f " { base } / { cat } " )):
fm = fm_of( f " { base } / { cat } / { f } " )
hl = re.search( r " ^ highlights: \n ((?:\s + - . * \n ? ) + ) " , fm + " \n " , re.M)
rows.append({ "title" : val(fm, "title" ), "description" : val(fm, "description" ),
"highlights" : hl.group( 1 ) if hl else None })
mini = len (json.dumps(rows, separators = ( "," , ":" ), ensure_ascii = False ).encode())
pretty = len (json.dumps(rows, indent = 2 , ensure_ascii = False ).encode())
print ( "pretty" , pretty, "minified" , mini, "saved_pct" , round ( 100 * (pretty - mini) / pretty, 1 ))
print ( "with_highlights" , sum ( 1 for r in rows if r[ "highlights" ]))
for k in ( "title" , "description" , "highlights" ):
s = sum ( len (json.dumps(r[k], ensure_ascii = False ).encode()) for r in rows)
print (k, s, f " { 100 * s / mini :.1f } %" )
pretty 669087 minified 647078 saved_pct 3.3
with_highlights 424
title 107114 16.6%
description 288303 44.6%
highlights 214980 33.2%
読み取れたことは三つです。
説明文(description)が、ここで数えた三つのフィールドの約 45% を占めていました。1 件あたり平均で 314 バイトあり、タイトルの 117 バイトの三倍近くです。
highlights が約 33% で、917 件のうち 424 件が持っています。有料記事の見出しとして付けている項目が、一覧用の JSON にも全件ぶん乗っていました。
indent: 2 の整形を外しても、減るのは約 3.3% でした。「整形を外せばだいぶ軽くなるはず」という私の予想は外れました。
なお、この内訳は、実際の articles.json に入るフィールド(タグや更新日など)の一部を省いた近似です。傾向を見るための数字として読んでいただければと思います。
現行のコードが何をしているか
本文を外している箇所は、ビルド前の変換スクリプトの終わりにあります。
// Metadata only — content is served as a static asset
result[locale]. push ({
title: data.title || "" ,
slug,
category,
level: data.level || "beginner" ,
date: data.date || "" ,
description: data.description || "" ,
tags: data.tags || [],
premium: data.premium || false ,
... (data.highlights ? { highlights: data.highlights } : {}),
highlights: data.highlights || null ,
noindex: data.noindex || false ,
});
本文の HTML は public/content/ に別ファイルとして書き出され、実行時には ASSETS バインディング経由で読みます。読み込みに失敗した場合は、空の本文を返さないよう一度だけ再試行します。
for ( let attempt = 0 ; attempt < 2 ; attempt ++ ) {
try {
const { env } = getCloudflareContext ();
const assets = (env as any ). ASSETS ;
if ( ! assets?.fetch) return "" ;
const res = await assets. fetch ( new URL (path, "https://assets.local" ));
if (res.ok) return await res. text ();
} catch {
// fall through to retry
}
if (attempt === 0 ) await new Promise (( r ) => setTimeout (r, 150 ));
}
return "" ;
数え直しているうちに、highlights がスプレッド構文と通常の指定で二度書かれていることにも気づきました。後ろが勝つので出力は変わりませんが、読む人を迷わせる書き方です。この記事を書き終えたら直します。
落とし穴
ファイル数の上限は別の勘定です。 本文を静的アセットに出すと、1 記事 1 ファイルで増えます。現在は日英あわせて 1,834 本の MDX と、ブログ 70 本があり、無料プランの 20,000 ファイルまでは遠いものの、画像を足すと話が変わります。
40 KB を超える記事が 57 本ありました。 最大は約 64 KB(日本語版)です。1 ファイルの上限 25 MiB とは桁が違うので問題ありませんが、一覧用の JSON に混ぜると影響が出る大きさです。
gzip 後の数字で安心しないでください。 本文込みの JSON は圧縮すると約 10 MB(日英あわせて)まで縮みますが、上限の判定は非圧縮です。Wrangler の出力を見て「まだ余裕」と思うのは、いまのドキュメントの書き方では危ない読み方です。
見直す順番を決める
数字が出たあと、私は次の順で手を付けることにしました。根拠は、上の内訳で大きかった順です。
説明文の長さに上限を置きます。 一覧に出すだけなら、先頭の 120 文字ほどで足りるはずです。全文は記事ページ側のメタ情報に残します。
highlights を一覧用の JSON から外します。 有料記事のカードに必要な件数だけ、別ファイルから読む形にできないか、と考えています。
整形(インデント)は後回しにします。 減るのは約 3% で、読みやすさを手放すほどの得はありません。
先に手を付けるのは、効果の大きい 1 と 2 です。どちらも、サイトの見た目を変えずに済むところが気に入っています。
Claude Code に任せるときの渡し方
この種の数え直しは、bundle_guard.py を置いたうえで、CLAUDE.md に一行書いておくと頼みやすくなります。たとえば「コンテンツを JSON に追加する変更では、先に bundle_guard.py を実行し、メタデータが 2 MiB を超えていたら止めて報告すること」といった形です。終了コードを使っているので、フックやビルド前のチェックにもそのまま載せられます。
数えてわかった線引き
本文は外へ出し、メタデータは絞って持つ、という線引きです。 上限が遠いうちに守ると決めておくのが、いちばん安く済むのだと思います。
次にやることは一つです。手元のサイトで、コンテンツを束ねている JSON を一つ選び、バイト数を数えてみてください。数字を一度書き留めておけば、次に重いと感じたとき、感覚ではなく差分で話せます。私は説明文と highlights の見直しから、順に手を付けていくつもりです。いま思えば、もっと早く数えておけばよかったのかもしれません。