Vercel から Cloudflare Workers へ移行した実戦ログ: 何が壊れ、どのコードで直したか by Codex


[前提]この記事は90%AIによって生成されました。



今回はこのリポジトリを Vercel から Cloudflare Workers に移したときの記録を、実装レイヤーごとにまとめます。

移行中に発生した問題は、単なるホスティング先の差分というより、実行モデルの差分がほとんどでした。とくに Pages Router でルート数が多く、src/_notesrc/_memo/contentsrc/_@ro1dev のようにコンテンツが分散している構成では、ローカルで見えているものが本番で落ちる問題が連続しました。

この記事では抽象的な話だけではなく、最終的にどのファイルをどう書き換えて復旧したかまで落として書きます。

##先に全体像

今回の移行で詰まったポイントは、だいたい次の5層に分かれます。

  1. 配信プラットフォーム層
  2. ビルドとバンドル層
  3. コンテンツ読み込み層
  4. OGP API 実装層
  5. ランタイム設定層

復旧もこの順に進めると速くなりました。上流を直さずに下流だけ触ると、別のルートで同じ障害が再発します。

##何が壊れたか

実際に出た代表的なエラーはこうでした。

  • Your Worker exceeded the size limit of 3 MiB(Free プラン上限超過)
  • no such file or directory, readdir '/bundle/src/_note'
  • no such file or directory, readdir '/bundle/src/_@ro1dev'
  • no such file or directory, readdir '/bundle/src/_snake'
  • render2 is not a function
  • OGImageResponse is not a constructor
  • env.IMAGES binding is not defined

ここからわかる通り、同時に壊れていたのは1箇所ではなく、サイズ制限、fs 依存、OGP 実装、binding 設定が重なっていました。

##レイヤー1: 配信プラットフォーム層

まず Free プランの 3MiB 制限に引っかかりました。ここで重要なのは、静的アセット総量と Worker script サイズは別という点です。アセットのアップロードが成功していても、最後の script validation で落ちます。

最終的に効いたのは wrangler 側の minify を明示することでした。

/Users/rrih/workspace/ro1/wrangler.jsonc

{
  "name": "ro1-dev",
  "main": ".open-next/worker.js",
  "minify": true,
  "compatibility_date": "2026-02-07",
  "compatibility_flags": ["nodejs_compat"],
  "workers_dev": true,
  "preview_urls": true,
  "assets": {
    "directory": ".open-next/assets",
    "binding": "ASSETS"
  },
  "images": {
    "binding": "IMAGES"
  }
}

/Users/rrih/workspace/ro1/package.json

{
  "scripts": {
    "deploy:cf": "bun run build:cf && wrangler deploy --minify"
  }
}

この変更を入れてから gzip 後の script サイズが 3MiB 未満に収まり、同じビルドでも deploy が通るようになりました。

##レイヤー2: ビルドとバンドル層

次に重要だったのが、コンテンツを実行時に読む設計をやめることでした。Workers では /bundle/src/_note のようなパスを readdir できず、本番だけ 500 になる典型例が出ます。

やったことはシンプルで、ビルド前に Markdown/MDX を snapshot JSON に固める方式です。

/Users/rrih/workspace/ro1/scripts/generate-content-snapshots.mjs

const buildMemoSnapshot = () => {
  const contentDir = path.join(rootDir, 'src/_memo/content');
  const files = walk(contentDir, ['.mdx']);
  const posts = files
    .map((filePath) => {
      const relativePath = path
        .relative(contentDir, filePath)
        .replace(/\\/g, '/')
        .replace(/\.mdx$/, '');
      const fileContent = fs.readFileSync(filePath, 'utf8');
      const parsed = matter(fileContent);
      return {
        slug: relativePath,
        ...(parsed.data || {}),
        content: parsed.content
      };
    })
    .sort(sortByDateDesc);

  writeJson('src/lib/memo/snapshot.json', posts);
};

const main = () => {
  buildMemoSnapshot();
  buildFlatMarkdownSnapshot('src/_note', 'src/lib/note/snapshot.json');
  buildFlatMarkdownSnapshot('src/_@ro1dev', 'src/lib/@ro1dev/snapshot.json');
  buildFlatMarkdownSnapshot('src/_snake', 'src/lib/app/snake/snapshot.json');
};

build の前でこのスクリプトを回すようにしたので、Worker 実行時は JSON 参照だけで動けるようになりました。

/Users/rrih/workspace/ro1/package.json

{
  "scripts": {
    "sync:content-snapshots": "node scripts/generate-content-snapshots.mjs",
    "prebuild": "bun run sync:content-snapshots"
  }
}

##レイヤー3: コンテンツ読み込み層

snapshot を作るだけでは不十分で、実際の API 側も「fs が使えないときは snapshot へフォールバック」という実装に寄せる必要があります。

/Users/rrih/workspace/ro1/src/lib/note/api.ts

import snapshot from "@/lib/note/snapshot.json";

const snapshotPosts = snapshot as SnapshotPost[];

export function getPostSlugs() {
  try {
    const slugs = fs.readdirSync(postsDirectory);
    if (slugs.length > 0) {
      return slugs;
    }
  } catch {
    // noop
  }
  return snapshotPosts.map((post) => `${post.slug}.md`);
}

export function getPostBySlug(slug: string, fields: string[] = []) {
  const realSlug = slug.replace(/\.md$/, "");
  let data: Record<string, string> = {};
  let content = "";

  try {
    const fullPath = join(postsDirectory, `${realSlug}.md`);
    const fileContents = fs.readFileSync(fullPath, "utf8");
    const parsed = matter(fileContents);
    data = parsed.data as Record<string, string>;
    content = parsed.content;
  } catch {
    const post = snapshotPosts.find((item) => item.slug === realSlug);
    if (post) {
      data = post as unknown as Record<string, string>;
      content = post.content ?? "";
    }
  }

  // ...
}

同じパターンを @ro1devsnakememo にも適用しています。これで /note/@ro1dev/app/snake/release が本番で落ちる問題を潰せました。

###memo は slug の扱いが特に重要

memoYYYY/MM/slug の多段スラッグなので、getStaticPaths を単一 slug 前提で書くと詳細ページが 404 になります。

/Users/rrih/workspace/ro1/src/pages/memo/[...slug].tsx

export const getStaticPaths: GetStaticPaths = async () => {
  const slugs = getMemoSlugs();
  return {
    paths: slugs.map((slug) => ({
      params: {
        slug: slug.split("/"),
      },
    })),
    fallback: "blocking",
  };
};

split("/") で配列パラメータに変換して返す形が必要でした。

###MDX 変換失敗時のフォールバック

Workers で MDX runtime 変換が失敗した場合に 404 に倒してしまうと、本文があるのに記事が消えます。ここは markdown fallback を持たせたほうが運用上は強いです。

/Users/rrih/workspace/ro1/src/pages/memo/[...slug].tsx

let htmlContent = "";
try {
  htmlContent = await mdxToHtml(post.content ?? "");
} catch (error) {
  htmlContent = await markdownToHtml(post.content ?? "");
}

##レイヤー4: OGP API 実装層

ここは最初にかなりハマりました。next/og@vercel/og 前提コードが Cloudflare 上で壊れやすく、render2 is not a functionOGImageResponse is not a constructor が出ます。

方針を変えて、まずは Node runtime で SVG を直接返す実装に寄せました。

/Users/rrih/workspace/ro1/src/pages/api/app/conan_movie_title_font_gen/ogp.tsx

export const config = {
  runtime: "nodejs",
};

export default function handler(req: NextApiRequest, res: NextApiResponse) {
  const rawTitle = typeof req.query.title === "string" ? req.query.title : "";
  const title = extractTitle(rawTitle);
  const svg = buildSvg(title);

  res.setHeader("Content-Type", "image/svg+xml; charset=utf-8");
  res.setHeader("Cache-Control", "public, max-age=0, s-maxage=86400");
  res.status(200).send(svg);
}

/Users/rrih/workspace/ro1/src/pages/api/app/snake/ogp/index.tsx

export const config = {
  runtime: "nodejs",
};

export default function handler(req: NextApiRequest, res: NextApiResponse) {
  const score = toSafeString(req.query.score);
  const name = toSafeString(req.query.name);
  const snake = parseSnake(toSafeString(req.query.snake));
  const svg = buildSvg(score, name, snake);

  res.setHeader("Content-Type", "image/svg+xml; charset=utf-8");
  res.setHeader("Cache-Control", "public, max-age=0, s-maxage=3600");
  res.status(200).send(svg);
}

最初にやるべきは「既存プレビューと完全一致」より「本番で確実に返る」でした。ここを分けて進めると復旧が早いです。

##レイヤー5: ランタイム設定層

###Image optimization の binding

env.IMAGES binding is not defined はコード側では解決しません。wrangler.jsonc の binding 追加が必要です。

{
  "images": {
    "binding": "IMAGES"
  }
}

###Dashboard と wrangler.jsonc の差分解消

routesworkers_devpreview_urls が毎回差分警告になる状態は、運用として不安定です。どちらかを真実源に決める必要があります。今回は wrangler.jsonc を正に寄せました。

##実運用で効いた検証手順

ルート数が多いプロジェクトでは、代表ページだけ見ても意味が薄いです。今回の反省から、次の順で毎回確認しています。

  1. 記事一覧と詳細(/note/memo/@ro1dev
  2. 特殊ルート(/app/snake/release/[slug]
  3. OGP API(/api/app/.../ogp
  4. 画像最適化経路(/_next/image?...

この順で見れば、fs 依存、runtime 不整合、binding 漏れを短時間で炙り出せます。

##このリポジトリ特有で学んだこと

  • コンテンツを大量に持つ Pages Router では、実行時 fs 依存は本番差分の温床になりやすいです
  • multi-segment slug のページは、getStaticPaths の配列化を最初に疑うべきです
  • OGP は描画品質より先に、runtime 互換を優先して段階的に戻すほうが安全です
  • Workers Free の 3MiB 制限は、script 側を見ないと見誤ります

##次に同じ移行をするときの最短ルート

次回同規模の移行をやるなら、最初から次の順で進めます。

  1. wrangler 設定の一元化
  2. snapshot 生成導線の追加
  3. 読み込み API の fs fallback 実装
  4. OGP API の runtime 互換化
  5. ルート横断の疎通確認

この順で進めると、今回のような 404/500 の連鎖をかなり短時間で止められます。