WordPressからEmDashへ 移行の記録(後編)

WordPressからEmDashへ移行する(後編)本番デプロイと、無料プランで詰んだ話

Cloudflare Workersの無料プランでは、EmDash標準のWordPressインポータが構造的に完走しない。24件で止まった理由、データベースを直接移送して迂回した手順、そしてパスキーで管理画面から締め出された話。

前編ではローカル環境での検証まで書いた。この記事は、そこから本番(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 deploy

d1 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 -y

1.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> --remote

content-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に書き換えればいい。

やったことはこうだ。

  1. DELETE FROM users (消えたのは使えないパスキー1件だけ)
  2. セットアップウィザードで本番ドメインのパスキーを登録
  3. ec_posts.author_id と ec_pages.author_id を新しいIDに一括更新
  4. 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属性はエクスポートに含まれないため

ドメイン切り替えまで済んだら、また書く。