前編ではローカル環境での検証まで書いた。この記事は、そこから本番(Cloudflare Workers)に上げるまでの記録。
先に結論を書くと、用意していた手順書は途中で破棄することになった。無料プランでは、EmDash標準のWordPressインポート機能が構造的に完走しないからだ。同じ道を通る人のために、何が起きて、どう迂回したかを全部書く。
前提:課金せずに運用する
EmDashのテンプレートは初期状態でCloudflare Workers Paid($5/月)が必要になっている。worker_loaders という設定のためで、これはプラグインをV8アイソレートで隔離実行する仕組み。有料プラン限定の機能だ。
使っていない機能だったので外した。
// astro.config.mjs から削除
plugins: [formsPlugin()],
sandboxed: [webhookNotifier], // ← 削除
sandboxRunner: sandbox(), // ← 削除
marketplace: "https://marketplace.emdashcms.com", // ← 削除// wrangler.jsonc から削除
"worker_loaders": [{ "binding": "LOADER" }]webhook-notifier は記事公開時に外部URLへ通知を飛ばすプラグインで、_plugin_state を見たら一度も設定されていなかった。使っていないものだった。
`marketplace` の行も一緒に外す必要がある。 これを残すとビルドが通らない。
Marketplace requires `sandboxRunner` to be configured.マーケットプレイスからプラグインを入れる機能も、同じ隔離実行の仕組みに依存しているためだ。
容量は問題にならなかった
移行前は「画像が多いから無料枠に収まらないのでは」と思っていた。実測するとこうだった。
| 項目 | 実測 | 無料枠 | 使用率 |
|---|---|---|---|
| R2(画像407枚) | 36.6 MB | 10 GB | 0.4% |
| D1(記事データ) | 3.7 MB | 5 GB | 0.07% |
.wpress のバックアップが226MBあったので大きく見えていたが、あれはデータベース・テーマ・プラグインを全部含んだ数字だった。画像だけなら36.6MB。 記事を削る必要も画像を減らす必要もなかった。
デプロイまで
D1(データベース)とR2(画像置き場)を作り、wrangler.jsonc に紐づける。
npx wrangler d1 create lily-blog
npx wrangler r2 bucket create lily-blog-media
npx wrangler secret put EMDASH_ENCRYPTION_KEY
npm run deployd1 create が返す database_id を wrangler.jsonc に書き写す必要がある。これを忘れるとデプロイは通るがDBに繋がらない。
初回は workers.dev のサブドメイン登録を求められる。アカウント全体で1つなので、短い一般的な単語はまず埋まっている。
デプロイ直後に確認したのは、seedのデモ記事が入っていないことだった。EmDashはデプロイ時もDBが空ならテンプレート付属のデモ記事を自動投入する。前編で seed/seed.json から中身を削っておいたのが効いて、「No posts yet」で立ち上がった。ここは初回デプロイでしか確認できない。
壁:インポータが24件で止まる
管理画面のWordPressインポートに、前編で作った clean.xml をアップロードした。
分析は通る。127件、著者マッピングも正しい。実行すると——24件目で止まった。
エラー画面は出ない。むしろ「メディアのインポートへ」と次のステップに進んでしまう。データベースを見ると、こうなっていた。
- 記事24件(本来127件)
- 全部が下書き(本来は公開69・下書き49)
- メディア0件
statusが全部下書きなのが手がかりだった。 インポータは全件を下書きとして入れてから、最後にまとめてステータスを確定する設計になっている。つまり「最後まで到達していない」。
原因はCloudflare Workers無料プランの制限だ。
| 制限 | 無料 | 有料 |
|---|---|---|
| CPU時間 | 10ms / リクエスト | 30秒 |
| サブリクエスト | 50 / リクエスト | 1000 |
EmDashのインポータは127件を1リクエストで処理する。無料プランではどうやっても完走しない。何度やっても同じところで死ぬ。
迂回:データベースを直接移す
ここで手順書を捨てた。
考えてみれば、ローカルの環境はもう「下見」ではなく「完成品」だった。前編でインポートを済ませ、古い記事を整理し、移行で壊れた本文を直し、サイト名も設定してある。これをそのまま本番に持っていけばいい。
- D1のAPIを直接叩くので、Workerの制限を受けない
- 本番側での整理作業・修正作業がすべて不要になる
- WordPressを止められない制約も消える(画像はローカルから送るので、元サイトを参照しない)
罠1:wrangler d1 export --local が空を返す
まずこれで詰まった。
npx wrangler d1 export lily-blog --local --output dump.sql
→ 31バイトのファイルができるwrangler は wrangler.jsonc の database_id を見て、その名前のローカルファイルを探す。ところが実際のデータは、database_idを設定する前に作られた別のファイルに入っていた。
.wrangler/state/v3/d1/miniflare-D1DatabaseObject/e7352547....sqlite ← 実データsqlite3 で直接読むしかない。
罠2:FTSのシャドウテーブルを持っていくと壊れる
EmDashは全文検索にSQLiteのFTS5を使っている。テーブルを数えると60個あるが、そのうち
- 2個が仮想テーブル(
_emdash_fts_posts,_emdash_fts_pages) - 8個がその内部シャドウテーブル(
_data,_idx,_content,_docsize,_config)
シャドウテーブルは移送してはいけない。 実テーブルにINSERTすればトリガーが発火してFTSが自動的に再構築される。
除外したのはほかに _cf_METADATA(Cloudflare内部)、auth_challenges や _emdash_rate_limits などの一時データ。結果、移送対象は45テーブルになった。
罠3:外部キーの順序
D1は外部キー制約を有効にしている(PRAGMA foreign_keys が 1)。なので
- DELETE は子テーブルから
- INSERT は親テーブルから
PRAGMA foreign_key_list を全テーブルに対して実行して依存関係を取り、トポロジカルソートで順序を決めた。
実行
DELETE文とINSERT文を1つのSQLファイルにまとめた。1.4MB、1127行。
npx wrangler d1 execute lily-blog --remote --file prod-load.sql -y1.1秒で終わった。 9,136行が書き込まれ、記事も画像メタデータも設定も、ローカルと完全に一致した。
数十分かけて失敗し続けたインポータが、こちらでは1秒だった。
画像は別途アップロード
R2の中身はデータベースには入っていないので、別に送る必要がある。
ここにも罠があって、ローカルR2のファイルはハッシュ名で保存されている。
.wrangler/state/v3/r2/lily-blog-media/blobs/2740b09c54b8...元のキー(01M0C7X6ENPBHQT2EYYAB9C52T.jpg のような形)との対応表は、別のsqliteファイルの _mf_objects テーブルにある。
.wrangler/state/v3/r2/miniflare-R2BucketObject/<ハッシュ>.sqliteここから key → blob の対応と content-type を引いて、1枚ずつ送る。
npx wrangler r2 object put "lily-blog-media/<key>" --file <blob> --content-type <mime> --remotecontent-type を指定し忘れると、画像がoctet-streamとして配信されてブラウザで表示されない。
407枚で約10分。失敗は1枚だけで、それも再試行で通った。
記事本文の画像URLはローカルで既に /_emdash/api/media/file/<id> に書き換わっているので、キーさえ一致すれば何も設定せずに表示される。
パスキーで締め出される
移送が終わり、サイトは正常に表示された。ところが管理画面に入れない。
Touch IDを押すと失敗する。
パスキーは登録したドメインでしか使えない。 ローカルで登録したものは localhost 用なので、lily-blog.lilychaco.workers.dev では拒否される。仕様通りの正しい動作だ。
データベースごと移送したので、credentials テーブルにはlocalhost用のパスキーが1件入っている。これは本番では永遠に使えない。
メールリンクも使えない
ログイン画面には「メールリンクでサインイン」がある。試すとこう出た。
Email is not configured. Magic link authentication requires an email provider.EmDashのメール送信はプラグイン方式で、標準で入っているのは「コンソールに出力するだけ」のもの。そして実際に送信するプラグインを入れる仕組み(マーケットプレイス)は、無料プラン対応のときに自分で外していた。
自分で塞いだ出口だった。
セットアップ画面をもう一度出す
残る手はセットアップウィザードをやり直して、本番ドメインのパスキーを登録すること。
options テーブルの emdash:setup_complete を false にすればウィザードは出る。しかし進めると弾かれた。
Admin user already existsソースを読むと、判定はこうなっていた。
if (await createKyselyAdapter(emdash.db).countUsers() > 0) {
return apiError("ADMIN_EXISTS", "Admin user already exists", 400);
}ユーザーが1人でもいれば通さない。 ロールは関係ないので、権限を下げても意味がなかった。
つまり users テーブルを空にするしかない。だが記事120本に author_id が入っている。消して大丈夫なのか。
外部キーを全部調べた。
credentials.user_id → users.id (CASCADE)
_emdash_bylines.user_id → users.id (SET NULL)
auth_tokens, oauth_accounts, _emdash_comments ... (すべて0件)`ec_posts.author_id` は外部キーではなかった。 ただの文字列カラムなので、ユーザーを消しても記事は消えない。参照が切れるだけで、あとから新しいIDに書き換えればいい。
やったことはこうだ。
DELETE FROM users(消えたのは使えないパスキー1件だけ)- セットアップウィザードで本番ドメインのパスキーを登録
ec_posts.author_idとec_pages.author_idを新しいIDに一括更新emdash:setup_completeをtrueに戻す
記事43本・画像407枚は一切失われなかった。
完成した状態
| 項目 | 数 |
|---|---|
| 公開記事 | 43 |
| 下書き | 43 |
| 固定ページ | 10 |
| 画像 | 407 |
表示速度は2.0〜2.7秒。ローカルの本番相当ビルドでは0.09秒だったので、D1がリモートになる分のレイテンシがそのまま乗っている。個人ブログとしては許容範囲だが、速さを売りにするならここは調べる余地がある。
無料プランのCPU時間制限(10ms)については、ページ表示では一度も引っかからなかった。引っかかるのは大量データを1リクエストで処理するインポータのような処理だけだ。
学んだこと
1. 「ローカルで検証してから本番」は、検証だけで終わらせない方がいい
当初の計画では、ローカルは表示崩れを見るための下見で、本番では改めてインポートし直す予定だった。実際にはローカルで作った完成品をそのまま移す方が、速くて確実だった。
同じ作業を2回やる計画は、2回目にまた同じ罠を踏む。
2. 失敗の形を読む
24件で止まったとき、最初は「なぜ24件なのか」を考えた。しかし手がかりは件数ではなく、statusが全部下書きだったことだった。設計を推測すれば、どこで死んだかが分かる。
3. 自分で塞いだ出口を覚えておく
メールリンクが使えなかったのは、無料プラン対応でマーケットプレイス機能を外したからだ。外したときには「使っていないから」で正しい判断だったが、それが後で唯一の代替ログイン手段を潰していた。
機能を外すときは、それが何を支えていたかを記録しておくべきだった。
残っていること
- 独自ドメイン
lily-blog.netへの切り替え(NS移管が必要) - 旧URLは
/{slug}/、EmDashは/posts/{slug}なので、43本分の301リダイレクト - メール送信の設定(ドメイン切り替え後にやる方が手戻りがない)
- 画像のalt属性がすべて空。WordPressのalt属性はエクスポートに含まれないため
ドメイン切り替えまで済んだら、また書く。


