SYNON 記事 マーケティングオートメーション
SYNON CLOUDFLARE OPERATIONS

MA拡張機能 Cloudflare 構築・運用手順書

AI-LP生成/配信/計測基盤の作り方と回し方
v1.02026-08-15構築7ステップ図版19点シンオン株式会社
MA拡張機能 Cloudflare 構築・運用手順書作り方と回し方 / v1.0構築STEP 1〜7デプロイlocal → staging → prod運用日次 / 週次 / 月次 / 四半期備え監視・障害・移行

MA拡張機能 Cloudflare 構築・運用手順書

— AI-LP生成/配信/計測基盤の作り方と回し方 —

項目 内容
文書名 MA拡張機能 Cloudflare 構築・運用手順書
バージョン v1.0
作成日 2026年8月15日
対象 MA_拡張機能仕様書_v2.0.md(F-11〜F-24)の実装・運用担当者
前提リポジトリ cf-internal-app-starter(Hono + React + D1 + R2 + KV + Vectorize + Access)
関連文書 MA_拡張機能仕様書_v2.0.mdMA_機能仕様書.md(v1.2)、cloudflare-feature-catalog-synon.mdcf-internal-app-starter/CLAUDE.md

目次

  1. この手順書の使い方
  2. 構成方針の決定 — 案A / 案B
  3. Cloudflare サービスの役割分担
  4. 事前準備
  5. 構築手順 STEP 1 — リポジトリとリソースの作成
  6. 構築手順 STEP 2 — バインディングとスキーマ
  7. 構築手順 STEP 3 — 認証とドメイン
  8. 構築手順 STEP 4 — LP配信 Worker
  9. 構築手順 STEP 5 — 計測パイプライン
  10. 構築手順 STEP 6 — AI生成パイプライン
  11. 構築手順 STEP 7 — 統計バッチ
  12. デプロイ運用
  13. 日次・週次・月次の運用手順
  14. 監視とアラート
  15. コスト管理
  16. トラブルシューティング
  17. 障害対応・緊急停止
  18. セキュリティ運用
  19. 移行手順(案B を選んだ場合)
  20. チェックリスト
  21. 付録: 主要コマンド早見表

1. この手順書の使い方

構築の7ステップSTEP 1リポジトリとリソース作成wrangler d1/r2/kv createSTEP 2バインディングとスキーマ3箇所を同時更新 / D1 migrationsSTEP 3認証とドメインAccess / Turnstile / routesSTEP 4LP配信Worker文脈→割付→描画→記録STEP 5計測パイプラインsendBeacon → /c → AESTEP 6AI生成パイプラインWorkflows + 事実チェックSTEP 7統計バッチCron / 優越確率 / 逐次淘汰
図 1上から順に実行できるよう構成している

本書は上から順に実行できる手順書である。章ごとに「やること → コマンド → 確認方法 → よくある失敗」の形式で記述する。

読者 読むべき章
これから構築する人 2 → 4 → 5〜11 → 12 → 20
日々運用する人 13 → 14 → 16 → 17 → 21
判断する立場の人 2 → 3 → 15 → 19

記法

数値の扱い: 本書の料金・上限値は 2026年8月時点の Cloudflare 公式ドキュメントに基づく。変わりうるので、実際の課金判断の前に必ず公式ページを確認すること。 確認が取れなかった項目は「要確認」と明記している。


2. 構成方針の決定 — 案A / 案B

最初に決めること — 案A(部分適用)か 案B(全面移行)かv1.2 の実装は完了しているか?YESNO本番データが蓄積しているか?YESNO案A 部分適用から始めて、効果確認後に案Bを検討する案B 全面移行最初から一本化したほうが総工数が小さい案B 全面移行最初から一本化したほうが総工数が小さい受託案件へ横展開するなら迷わず案Bテンプレート化して顧客案件に転用できる
図 2決めずに手を動かすと、途中で全部やり直しになる

最初にこれを決める。 決めずに手を動かすと、途中で全部やり直しになる。

2.1 二択の中身

案A: 部分適用 案B: 全面移行
対象 新機能(F-11〜F-24)のみ Cloudflare MA本体(F-01〜F-10)も含めて Cloudflare
既存MA そのまま(Node.js/Python + SQLite) 廃止し Workers + D1 へ移植
Worker数 1〜2(LP配信 + 計測) 1(管理画面SPA + API + LP配信を同梱)
認証 既存の認証を継続 + 新規APIはトークン認証 Cloudflare Access に一本化
企業マスタ 既存DBが正。定期同期でD1へコピー D1 が正
初期工数の目安 2〜3人月 5〜8人月(既存の実装量による)
月額インフラ費 既存サーバ費 + 約 $5 約 $5(サーバ費・DB費が消える)
運用対象 2系統(既存 + Cloudflare) 1系統
主なリスク 企業マスタ同期のズレ、認証が2系統 移行期間中の二重運用、Workers制約への適合

2.2 決定フロー

                v1.2(F-01〜F-10)の実装は完了しているか?
                          │
              ┌───────────┴───────────┐
             YES                      NO
              │                        │
     本番データが蓄積しているか?        │
              │                        │
      ┌───────┴───────┐               │
     YES              NO               │
      │                │               │
   【案A】          【案B】         【案B】
  部分適用から      全面移行        最初から一本化
  始めて、効果      したほうが      したほうが
  確認後に案Bを     総工数が小さい   総工数が小さい
  検討する

受託案件への横展開を視野に入れるなら、迷わず案B。 構成をそのままテンプレート化して顧客案件に転用できる価値が大きい。

2.3 案Aを選んだ場合の追加考慮

企業マスタの同期方式を決める必要がある。

方式 内容 遅延 推奨
定期バッチ同期 既存MA → D1 へ日次で洗い替え 最大24時間 △(ABM-LPの鮮度が落ちる)
差分同期 更新分のみを毎時 最大1時間 ○ まずこれ
API参照 LP配信時に既存MAのAPIを都度呼ぶ なし ×(LPのTTFBが既存サーバに引きずられる)
Webhook 既存MA側で更新時にD1へPUSH ほぼなし ◎(既存MAに手を入れられるなら)

API参照方式は選ばないこと。 LPの表示速度が既存サーバの応答速度に律速され、CWVが崩れる。


3. Cloudflare サービスの役割分担

3.1 サービス選定表

全体アーキテクチャ — どのデータをどこに置くかエッジ / 実行層WorkersLP配信 + API + SPAStatic AssetsSPA・計測JSCron Triggers統計バッチWorkflows生成パイプラインQueues非同期ジョブDurable Objectsバンディット状態データ層D1業務データの正 / 実験・バリアント・割付SQL・即時整合KV配信重み・設定のキャッシュ結果整合。業務データは置かないR2LP HTML・スクショ・アーカイブegress無料。Worker経由で配る計測・分析層Analytics Engine生イベント / カーディナリティ無制限 / 3か月Web AnalyticsCookieless RUM・CWV実測GA4 (既存F-09)全社共通指標・経営報告用AI 層・Workers AI下書き・分類・埋め込み / 無料枠1万Neurons/日・AI Gateway外部LLM中継 / コスト可視化・キャッシュ・Vectorizeバリアントの重複検査セキュリティ / 補助・Cloudflare Access管理画面の認証。自前セッションを作らない・Turnstile全フォームのボット対策・Secrets Store / wrangler secretAPIキーFree プランでは本番運用しないCPU時間 10ms/呼び出しの制限があり、LP配信処理(文脈構築 + D1参照 + 割付 + 計測)は確実に超える。Workers Paid $5/月 は必須と考える
図 3エッジ / データ / 計測 / AI / セキュリティの5層
用途 使うもの 理由 無料枠 / 費用(2026年8月時点)
LP配信・API・SPA配信 Workers (Static Assets) Cloudflare公式が新規はWorkers推奨。SPAとAPIが1デプロイ単位 Free 10万req/日 / Paid $5月〜(月1,000万req込み)
業務データの正 D1 SQL・結合・即時整合。実験/バリアント/割付 Free 読取500万行/日・書込10万行/日 / Paid 月25億読取・5,000万書込込み
配信重み・設定のキャッシュ KV 読み >>> 書き。結果整合でも実害なし Free 読取10万/日・書込1,000/日
ファイル実体 R2 LP HTML・スクショ・アーカイブ。egress無料 Free 10GB-月・ClassA 100万・ClassB 1,000万
生イベント計測 Workers Analytics Engine カーディナリティ無制限。バリアント×企業×セグメントを気にせず書ける 現在無課金。将来 Free 書込10万/日
バンディット状態 Durable Objects (SQLite backend) カウンタ更新を直列化。D1へのロック競合を避ける Free 10万req/日
生成パイプライン Workflows 50件の生成→検査→結合を耐久実行。途中失敗から再開できる Free ステップ3,000/日
非同期処理 Queues 生成ジョブ・同期ジョブの投入 Free 1万操作/日(1操作=64KB)
AI生成(下書き・分類・埋め込み) Workers AI 安価・低レイテンシ Free 10,000 Neurons/日
AI生成(本番コピー) AI Gateway 経由の外部LLM 品質。コスト可視化・キャッシュ・レート制限が付く コア機能無料
重複検査 Vectorize 埋め込みの類似検索。埋め込みは日本語対応モデルを使う Free クエリ次元3,000万/月
プレビュー画像 Browser Run(旧 Browser Rendering) スクリーンショット・OGP生成 Free 10分/日・同時3ブラウザ
画像最適化 Cloudflare Images LPの画像配信。LCP改善 Free 月5,000ユニーク変換
ボット対策 Turnstile LPフォーム・資料DLフォーム 無料
管理画面の認証 Cloudflare Access 自前セッションを実装しない Zero Trust Free枠
タグ・同意管理 Zaraz 外部送信規律への対応 月100万イベント無料
サイト全体のRUM Cloudflare Web Analytics Cookieless。CWVの実測 無料
シークレット管理 Secrets Store(オープンベータ)または wrangler secret LLM/媒体APIキー
定期処理 Cron Triggers 統計バッチ・同期
CI/CD Workers Builds または GitHub Actions Git連携ビルド Free 月3,000ビルド分

3.2 使ってはいけない組合せ(重要)

使ってはいけない組合せここを外すと、動くけれど壊れているシステムができる×バリアントを Workers の「バージョン」として持つ直近100版まで / 配分変更にデプロイが要るバリアントはD1のデータ。単一Workerが描画×割付結果・計測イベントを KV に書く結果整合。書いた直後に古い値が返るD1 / Analytics Engine / Durable Objects×生イベントを D1 に1件ずつINSERT行書込課金 + ロック競合Analytics Engine の writeDataPoint()×カウンタを D1 に高頻度UPDATE同一行への競合でエラー多発Durable Objects で直列化 → 日次でD1へ×LP HTML を D1 に入れる1DB最大10GB / 行サイズ上限2MB実体はR2、D1には r2_key だけ×配信時にモンテカルロ10万回CPU時間を使い切る日次バッチ + KVキャッシュ。配信時は1サンプル×robots.txt で実験LPを Disallowリンク経由で検索結果に出る / AdsBotを塞ぐnoindex メタタグで除外する
図 4×と○の対応表
❌ やってはいけないこと 理由 代わりにすること
バリアントを Workers の「バージョン」として持つ Gradual Deployments は直近100バージョンまで、配分変更のたびにデプロイが要る、割付粒度がリクエスト単位 バリアントはD1のデータとして持ち、単一Workerが描画する
割付結果・計測イベントを KV に書く KVは結果整合。書いた直後に読むと古い値が返る。計測が壊れる D1 / Analytics Engine / Durable Objects
生イベントを D1 に1件ずつINSERTする 書込行数課金 + ロック競合。月100万イベントで詰まる Analytics Engine に writeDataPoint()
バリアントのカウンタを D1 に高頻度UPDATE 同一行への競合でエラー多発 Durable Objects で直列化 → 日次でD1へ同期
LP HTML を D1 に入れる D1は1DB最大10GB(Paid)。行サイズ上限2MB 実体はR2、D1には r2_key だけ
R2 のオブジェクトを公開URLで配る URLを知れば誰でも取れる 必ずWorker経由で権限チェック
配信時に優越確率のモンテカルロ10万回を回す CPU時間を使い切る 日次バッチで計算しKVにキャッシュ。配信時は1サンプルのみ
Free プランでLP配信を本番運用 CPU時間10ms/呼出。割付+D1+計測+統計で確実に超える Workers Paid($5/月)は必須と考える
robots.txt で実験LPディレクトリを Disallow 他ページからのリンク経由で検索結果に出うる。AdsBotも塞ぐと広告に支障 <meta name="robots" content="noindex,follow">

4. 事前準備

4.1 必要なもの

# 項目 確認方法
1 Cloudflare アカウント(Account ID) ダッシュボード右下
2 Workers Paid プラン($5/月) ダッシュボード > Workers & Pages > Plans
3 ゾーン(ドメイン)が Cloudflare で管理されていること DNS > 該当ドメインが Active
4 LP用のドメイン/サブドメインの決定 4.2参照
5 Zero Trust の有効化(Access を使う場合) Zero Trust ダッシュボード
5-b Zero Trust のシート数。「Free は50ユーザーまで」という情報があるが公式ページで確定できなかった。社員数がこれに近い場合は契約前に必ず確認する(1ユーザーが Access ログインまたは WARP の Gateway 接続をすると1シート消費。アプリ数・ログイン回数によらず1人1シート) Zero Trust > Users
6 Node.js 20 以上 $ node -v
7 GitHub リポジトリ(CI/CD用)
8 🔒 LLM APIキー(外部LLMを使う場合)

Workers Free プランでは本番運用しない。 CPU時間 10ms/呼び出しの制限があり、LP配信処理(文脈構築 + D1参照 + 割付 + 計測書き込み)は確実に超える。$5/月は投資判断が不要なレベル。

4.2 ドメイン設計

ドメイン設計 — LPをコーポレートサイトと同じドメインに置かないwww.synon.co.jpコーポレートサイトindex(既存)・既存の評価を守るlp.synon.co.jpLP配信 / 計測エンドポイント代表LPのみ index・実験・ABM・広告LPは noindex・/c で計測を受けるma.synon.co.jp管理画面全ページ noindex・Cloudflare Access で保護・IP制限 + レート制限
図 5分離する理由: 評価の波及防止 / インデックス事故の隔離 / 障害影響の分離

LPをコーポレートサイトと同じドメインに置かない。理由は次の3つ。

  1. 大量生成LPの評価が本体ドメインに影響しうる
  2. インデックス制御の事故が本体に波及しない
  3. 障害の影響範囲を分離できる
用途 ドメイン例 インデックス
コーポレートサイト www.synon.co.jp index(既存)
LP配信 lp.synon.co.jp 代表LPのみ index、実験/ABM/広告LPは noindex
管理画面 ma.synon.co.jp 全ページ noindex + Access保護
計測エンドポイント lp.synon.co.jp/c

✅ 確認: dig lp.synon.co.jp が Cloudflare のIPを返すこと

4.3 命名規約

後から変えられないので最初に決める。

リソース 本番 ステージング
Worker synon-ma-lp synon-ma-lp-staging
D1 synon-ma-db synon-ma-db-staging
R2 synon-ma-assets synon-ma-assets-staging
KV synon-ma-cache synon-ma-cache-staging
Vectorize synon-ma-variants synon-ma-variants-staging
Analytics Engine データセット ma_lp_events ma_lp_events_staging
Durable Object クラス ExperimentCounter (同じ)
Queue synon-ma-jobs synon-ma-jobs-staging

5. 構築手順 STEP 1 — リポジトリとリソースの作成

5.1 スターターから開始する

社内には cf-internal-app-starter があり、Hono + React + D1 + R2 + KV + Access の構成が既に組んである。ゼロから作らない。

# 1. スターターを複製
$ cp -r cf-internal-app-starter synon-ma-lp
$ cd synon-ma-lp
$ rm -rf .git && git init

# 2. 名前を置き換える
#    package.json の name, wrangler.jsonc の name / database_name / bucket_name
$ npm install

# 3. Cloudflare にログイン
$ npx wrangler login
$ npx wrangler whoami     # ✅ Account ID が表示されること

5.2 リソースを作成する

スターターには scripts/bootstrap.sh があり、リソース作成とID埋め込みを自動化している。まずそれを実行する。

$ npm run bootstrap

自動化されていない、または本プロジェクト固有のリソースは手動で作る。

# --- D1 (本番 / ステージング) ---
$ npx wrangler d1 create synon-ma-db
$ npx wrangler d1 create synon-ma-db-staging
#   → 出力された database_id を wrangler.jsonc に貼る

# --- R2 ---
$ npx wrangler r2 bucket create synon-ma-assets
$ npx wrangler r2 bucket create synon-ma-assets-staging

# --- KV ---
$ npx wrangler kv namespace create CACHE
$ npx wrangler kv namespace create CACHE --env staging

# --- Vectorize (バリアント重複検査用) ---
#   ⚠ 先に埋め込みモデルを決めること。次元数はモデル依存(768次元が一般的)で、
#      作成後に変更できない(インデックス削除→再作成が必要)。
#      日本語コピーの類似度を測るので、必ず日本語(多言語)対応モデルを選ぶ。
$ npx wrangler vectorize create synon-ma-variants \
    --dimensions=768 --metric=cosine

# --- Queues ---
$ npx wrangler queues create synon-ma-jobs
$ npx wrangler queues create synon-ma-jobs-dlq     # 失敗ジョブの退避先

✅ 確認:

$ npx wrangler d1 list
$ npx wrangler r2 bucket list
$ npx wrangler kv namespace list
$ npx wrangler vectorize list
$ npx wrangler queues list

よくある失敗

症状 原因 対処
vectorize create が次元数エラー 使う埋め込みモデルの次元と不一致 先にモデルを決めてから作る。作り直しはインデックス削除が必要
ステージングのKV IDを本番に貼ってしまう 目視ミス wrangler deploy --dry-run でバインディング一覧を確認する習慣をつける

6. 構築手順 STEP 2 — バインディングとスキーマ

6.1 バインディングを追加する

バインディングを足したら、必ず3箇所を同時に更新する1つ忘れると本番で undefined になるwrangler.jsonc本番と env.staging の両方"analytics_engine_datasets": [ { "binding": "EVENTS", ... }src/worker/types.tsEnv インターフェースexport interface Env { EVENTS: AnalyticsEngineDataset;CLAUDE.md次に触る人・AIが読む表| 生イベント | Analytics Engine || (env.EVENTS) |確認方法npx wrangler deploy --dry-run --env="" と --env staging の両方でバインディング一覧を見るdry-run はバインディングの解決状況を一覧表示するので、設定漏れがその場でわかる
図 6wrangler.jsonc / types.ts / CLAUDE.md

バインディングを足したら、必ず3箇所を同時に更新する。1つ忘れると本番で undefined になる。

  1. wrangler.jsonc本番と env.staging の両方
  2. src/worker/types.tsEnv インターフェース
  3. CLAUDE.md の表(次にこのリポジトリを触る人・AIが読む)

wrangler.jsonc に追加するブロック(スターターの既存分に足す):

{
  "name": "synon-ma-lp",
  "main": "src/worker/index.ts",
  "compatibility_date": "2026-08-01",
  "compatibility_flags": ["nodejs_compat"],

  "assets": {
    "directory": "./dist/client",
    "binding": "ASSETS",
    "not_found_handling": "single-page-application"
  },

  // --- 追加: Analytics Engine (生イベント) ---
  "analytics_engine_datasets": [
    { "binding": "EVENTS", "dataset": "ma_lp_events" }
  ],

  // --- 追加: Durable Objects (バンディット状態) ---
  "durable_objects": {
    "bindings": [
      { "name": "EXPERIMENT", "class_name": "ExperimentCounter" }
    ]
  },
  "migrations": [
    {
      "tag": "v1",
      // SQLite バックエンドを使う (Cloudflare が新規名前空間で推奨)
      "new_sqlite_classes": ["ExperimentCounter"]
    }
  ],

  // --- 追加: Queues ---
  "queues": {
    "producers": [ { "binding": "JOBS", "queue": "synon-ma-jobs" } ],
    "consumers": [
      {
        "queue": "synon-ma-jobs",
        "max_batch_size": 10,
        "max_retries": 3,
        "dead_letter_queue": "synon-ma-jobs-dlq"
      }
    ]
  },

  // --- 追加: Browser Rendering (プレビュー生成) ---
  "browser": { "binding": "BROWSER" },

  // --- 追加: Workflows (生成パイプライン) ---
  "workflows": [
    {
      "name": "lp-generation",
      "binding": "LP_GEN",
      "class_name": "LpGenerationWorkflow"
    }
  ],

  // --- Cron: 統計バッチ (すべて UTC。JST は +9h) ---
  "triggers": {
    "crons": [
      "0 * * * *",     // 毎時00分 : イベント集計 → D1 日次サマリ
      "5 * * * *",     // 毎時05分 : GA4 Data API 増分同期 (既存F-09)
      "0 17 * * *",    // JST 02:00 : 優越確率の再計算
      "30 17 * * *",   // JST 02:30 : 因子効果の推定
      "0 18 * * *",    // JST 03:00 : 逐次淘汰の判定
      "0 19 * * *",    // JST 04:00 : スコア減衰・架電キュー再構築
      "0 20 * * *",    // JST 05:00 : 広告オーディエンスの同期
      "0 21 * * *",    // JST 06:00 : noindex/インデックス率の監視
      "0 22 * * 0",    // JST 月07:00 : 週次レポートのメール配信
      "0 15 1 * *"     // JST 1日00:00 : 3か月超の生イベントを R2 へアーカイブ
    ]
  }
}

Cron は UTC で書く。 JST の時刻から9時間を引く。日付をまたぐ場合は曜日指定もずらす。

src/worker/types.ts:

export interface Env {
  // 既存(スターター)
  ASSETS: Fetcher;
  DB: D1Database;
  FILES: R2Bucket;
  CACHE: KVNamespace;
  VECTORIZE: VectorizeIndex;
  AI: Ai;
  // 追加
  EVENTS: AnalyticsEngineDataset;
  EXPERIMENT: DurableObjectNamespace;
  JOBS: Queue;
  BROWSER: Fetcher;
  LP_GEN: Workflow;
  // vars
  ACCESS_TEAM_DOMAIN: string;
  ACCESS_AUD: string;
  DEV_BYPASS_AUTH: string;
  APP_ENV: "production" | "staging" | "development";
  LP_DOMAIN: string;
  // secrets
  LLM_API_KEY: string;
  AI_GATEWAY_URL: string;
  COOKIE_SIGNING_KEY: string;
}

✅ 確認:

$ npm run cf-typegen        # 型定義を再生成
$ npm run typecheck
$ npx wrangler deploy --dry-run --env=""
#   → バインディング一覧が表示される。抜けがあればここで気づける
$ npx wrangler deploy --dry-run --env staging

6.2 D1 マイグレーション

既存のマイグレーションファイルは絶対に編集しない。 適用済みの環境が壊れる。必ず新しいファイルを足す。

$ npx wrangler d1 migrations create DB lp_core
#   → migrations/0002_lp_core.sql が生成される

migrations/0002_lp_core.sql の骨格(仕様書11章に対応):

-- LPライブラリ ---------------------------------------------------------
CREATE TABLE lp_libraries (
  id            TEXT PRIMARY KEY,
  name          TEXT NOT NULL,
  version       INTEGER NOT NULL DEFAULT 1,
  design_tokens TEXT NOT NULL,
  factors       TEXT NOT NULL,
  banned_phrases TEXT,
  required_notices TEXT,
  is_locked     INTEGER NOT NULL DEFAULT 0,
  created_at    TEXT NOT NULL DEFAULT (datetime('now')),
  updated_at    TEXT NOT NULL DEFAULT (datetime('now'))
);

-- 実験 -----------------------------------------------------------------
CREATE TABLE lp_experiments (
  id            TEXT PRIMARY KEY,
  name          TEXT NOT NULL,
  status        TEXT NOT NULL DEFAULT 'draft',
  library_id    TEXT NOT NULL REFERENCES lp_libraries(id),
  library_version INTEGER NOT NULL,
  target_def    TEXT NOT NULL,
  service_desc  TEXT NOT NULL,
  cv_type       TEXT NOT NULL,
  primary_metric TEXT NOT NULL DEFAULT 'cv',
  factors_used  TEXT NOT NULL,
  min_sessions_per_variant INTEGER NOT NULL DEFAULT 300,
  min_days      INTEGER NOT NULL DEFAULT 7,
  epsilon       REAL NOT NULL DEFAULT 0.05,
  prior_kappa   REAL NOT NULL DEFAULT 100,
  current_round INTEGER NOT NULL DEFAULT 0,
  winner_variant_id TEXT,
  started_at    TEXT,
  fixed_at      TEXT,
  created_at    TEXT NOT NULL DEFAULT (datetime('now'))
);

-- バリアント -----------------------------------------------------------
CREATE TABLE lp_variants (
  id            TEXT PRIMARY KEY,
  experiment_id TEXT NOT NULL REFERENCES lp_experiments(id),
  factor_levels TEXT NOT NULL,
  content_json  TEXT NOT NULL,
  html_r2_key   TEXT,
  screenshot_r2_key TEXT,
  embedding_id  TEXT,
  screen_score  REAL,
  fact_check_status TEXT NOT NULL DEFAULT 'pending',
  fact_check_detail TEXT,
  status        TEXT NOT NULL DEFAULT 'generated',
  eliminated_round INTEGER,
  alpha         REAL NOT NULL DEFAULT 0,
  beta          REAL NOT NULL DEFAULT 0,
  sessions      INTEGER NOT NULL DEFAULT 0,
  cta_clicks    INTEGER NOT NULL DEFAULT 0,
  scroll50      INTEGER NOT NULL DEFAULT 0,
  cvs           INTEGER NOT NULL DEFAULT 0,
  approved_by   TEXT,
  approved_at   TEXT,
  created_at    TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_variants_exp ON lp_variants(experiment_id, status);

-- 割付 -----------------------------------------------------------------
CREATE TABLE lp_assignments (
  id            TEXT PRIMARY KEY,
  experiment_id TEXT NOT NULL,
  variant_id    TEXT NOT NULL,
  visitor_id    TEXT NOT NULL,
  company_id    TEXT,
  tracking_code TEXT,
  context       TEXT,
  assigned_at   TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE UNIQUE INDEX idx_assign_visitor ON lp_assignments(experiment_id, visitor_id);
CREATE UNIQUE INDEX idx_assign_company ON lp_assignments(experiment_id, company_id)
  WHERE company_id IS NOT NULL;

-- 日次サマリ -----------------------------------------------------------
CREATE TABLE lp_events (
  id            TEXT PRIMARY KEY,
  date          TEXT NOT NULL,
  experiment_id TEXT NOT NULL,
  variant_id    TEXT NOT NULL,
  segment_key   TEXT NOT NULL DEFAULT '*',
  sessions      INTEGER NOT NULL DEFAULT 0,
  views         INTEGER NOT NULL DEFAULT 0,
  scroll50      INTEGER NOT NULL DEFAULT 0,
  scroll100     INTEGER NOT NULL DEFAULT 0,
  cta_clicks    INTEGER NOT NULL DEFAULT 0,
  form_starts   INTEGER NOT NULL DEFAULT 0,
  form_submits  INTEGER NOT NULL DEFAULT 0,
  cvs           INTEGER NOT NULL DEFAULT 0,
  engaged_ms_sum INTEGER NOT NULL DEFAULT 0,
  engaged_ms_count INTEGER NOT NULL DEFAULT 0,
  lcp_p75       REAL, inp_p75 REAL, cls_p75 REAL
);
CREATE UNIQUE INDEX idx_events_key ON lp_events(date, variant_id, segment_key);
CREATE INDEX idx_events_exp_date ON lp_events(experiment_id, date);

-- 以降 factor_effects / abm_pages / touchpoints / … を同様に定義

適用:

# ローカル(D1のローカルエミュレーション)
$ npm run db:migrate:local
$ npm run db:seed:local

# ステージング → 本番の順に適用する。逆順は絶対にしない
$ npx wrangler d1 migrations apply DB --remote --env staging
$ npx wrangler d1 migrations apply DB --remote --env=""

✅ 確認:

$ npx wrangler d1 execute DB --local \
    --command "SELECT name FROM sqlite_master WHERE type='table'"

SQL実装の鉄則


7. 構築手順 STEP 3 — 認証とドメイン

7.1 管理画面 — Cloudflare Access

管理画面の認証 — Google Workspace 認証 × WARP 端末制限自前のログイン画面は作らない社員PCWARP有効Cloudflare Accessポリシー判定WorkerrequireAccessc.get("user")email/sub/groupsWARP経由Cf-Access-Jwt-AssertionAccess ポリシーIncludeLogin MethodGoogle WorkspaceIncludeEmails ending in@synon.co.jpRequireWARPCloudflare One Client が接続RequireGatewayGateway に接続(併用推奨)IdP は必ず「Google Workspace」「Google」統合 → 任意のGmail可 グループ条件が使えない「Google Workspace」統合 → 自社ドメイン限定 → Googleグループを条件にできる最大の落とし穴 — 「Require WARP」だけでは会社端末に限定できない公式に「all versions of WARP, including the consumer version」と明記。私物PCに無料版WARPを入れただけでも条件を満たしうる。会社端末に厳密に限定するなら、Require に Client certificate(mTLS) か Device serial numbers を追加する。Worker 側でも必ず JWT を再検証(JWKS / aud / iss)workers_dev = false も設定(素通り経路を塞ぐ)
図 7Google Workspace 認証 + WARP 端末制限 + Worker側の再検証

自前のログイン画面を作らない。Access に寄せる。

7.1.1 目標とする条件

「Google Workspace アカウントで認証」かつ「WARP が有効な会社端末から」。当社は全社員のPCに Cloudflare One Client(WARP)を導入済みなので、この2つを AND で要求する。

社員PC (WARP有効)
   │
   ▼
Cloudflare Access ── Include : Login Method = Google Workspace
   │                          Emails ending in = @synon.co.jp
   │               ── Require : WARP        ← 端末条件
   │                          Gateway      ← 併用推奨(7.1.4)
   │                          Client certificate 等 ← 会社端末限定を厳密にするなら
   ▼
Worker ── requireAccess で JWT を再検証

2026年8月14日に追加された「Worker 単位のワンクリック Access」は、この用途には使えない。 あれは Worker に紐づく workers.dev・カスタムドメイン・ルート・プレビューURLをまとめて保護できる便利な機能だが、身元の条件が「Cloudflareアカウントのメンバー / メールアドレス / メールドメイン」に限られ、WARP などの Device Posture 条件を付けられない。 ステージングやプレビューURLを塞ぐ用途には最適なので、本番の管理画面は下記の通常の Access アプリケーション、ステージング/プレビューはワンクリック Access、と使い分けるのがよい。 (Changelog 2026-08-14)

7.1.2 手順 ① Google Workspace を IdP として登録する

Access の IdP には 「Google」と「Google Workspace」の2種類がある。必ず後者を選ぶ。

Google Google Workspace
対象 Gmail を含む任意の Google アカウント 自社 Workspace ドメインに限定
Google グループをポリシー条件に使えるか 不可
  1. Google Cloud Console: 新規プロジェクトを作成 → Admin SDK API を有効化
  2. OAuth 同意画面を Internal で設定
  3. OAuth クライアントID(ウェブアプリケーション)を作成 - JavaScript origins: https://<team-name>.cloudflareaccess.com - リダイレクトURI: https://<team-name>.cloudflareaccess.com/cdn-cgi/access/callback
  4. Google 管理コンソール: セキュリティ > アクセスとデータ管理 > API の管理 > 「Trust internal apps」を有効化
  5. Zero Trust ダッシュボード: Integrations > Identity providers > Google Workspace を追加。Client ID / Secret / Workspace ドメインを入力 → 保存後に出る管理者承認リンクを実行
  6. Test を実行し、ログインとグループ取得が通ることを確認する

⚠ Google Workspace 統合は SCIM プロビジョニング非対応。また、Access で保護した Google Workspace アカウント自体には使えない。

7.1.3 手順 ② WARP のデバイス登録も Google に寄せる

Zero Trust > Team & Resources > Devices > Device profiles > Management の Device enrollment permissions で、登録時のログイン方法に Google Workspace を指定する。WARP の登録と Access のログインが同じIdPになるため、社員は実質1回のログインで済む。

7.1.4 手順 ③ Access アプリケーションとポリシー

  1. Zero Trust > Access > Applications > Add an application > Self-hosted
  2. Application domain: ma.synon.co.jp
  3. ポリシーを作る
ルール セレクタ
Include Login Method Google Workspace
Include Emails ending in @synon.co.jp(またはIdP group で特定のGoogleグループ)
Require WARP Cloudflare One Client が接続されていること
Require Gateway Gateway に接続されていること(下記の理由で併用推奨)

なぜ WARP と Gateway を併用するのか: Access のセレクタは Warp(クライアントが接続されている)と Gateway(Gateway に接続されている)が別物で、WARP は Gateway プロキシを使わないモードでも「接続」扱いになりうる。「社内ネットワーク経由であること」まで担保したいなら両方を Require に入れる。挙動はプランと WARP のモードで変わりうるので、必ず実機で確認すること。

  1. Session duration を設定(既定24時間。15分〜1か月で設定可)
  2. Application Audience (AUD) Tag をコピーwrangler.jsoncACCESS_AUD に設定

7.1.5 ⚠ 最大の落とし穴 — 「Require WARP」だけでは会社端末に限定できない

公式ドキュメントに次の記載がある。

「This device posture attribute will check for all versions of WARP, including the consumer version.」 (Require WARP)

つまり 私物PCに無料のコンシューマー版WARPを入れただけの端末でも、この条件は満たしうる。 「WARP必須 = 会社端末限定」ではない。

会社支給端末に厳密に限定したい場合は、次のいずれかを Require に追加する。

追加する posture check 内容 強度
Client certificate (mTLS) 会社が配布したデバイス証明書を持つ端末のみ (推奨)
Device serial numbers 資産管理台帳のシリアル番号リストと突合 強(台帳の維持が要る)
Device UUID 登録済みデバイスのUUID
OS version / Disk encryption / Firewall 端末の状態要件 補助的

各 posture check がどのプランで使えるかを明記した公式ページは見つからなかった。契約前にダッシュボードで実際に選択できるか確認すること。

Access をルートに紐付けていても、Worker 側で必ず JWT を再検証する。 workers.dev などから直接叩かれる経路が残りうる。スターターの src/worker/lib/access.ts がその実装。このファイルは触らない。

ブラウザ
  ▼
Cloudflare Access ── メールOTP(@synon.co.jp限定) ──▶ 認証済み
  │  Cf-Access-Jwt-Assertion ヘッダを付与
  ▼
Worker ── requireAccess ミドルウェア
  │  JWKS (team domain の /cdn-cgi/access/certs) で署名検証
  │  aud / iss / email クレームを確認
  ▼
c.get("user") = { email, sub, groups }

DEV_BYPASS_AUTH はローカル開発専用。APP_ENV=production では効かないよう二重に条件が付いている(テストで担保済み)。

7.2 LP配信 — 認証なし・公開

LPは公開ページなので Access をかけない。同一ホスト内でパスごとに分ける場合は、公開したいパスに decision = Bypass のポリシーを別途作る(Access はパス指定できる)。

対象 設定
/lp/* /r/* /a/* 公開。Bypass ポリシー(Everyone)
/api/* requireAccess 配下。例外は /api/health のみ
/c (計測受信) 公開。ただしレート制限(1 visitor_id 60イベント/分)と Origin 検証
/lp/*/submit 公開。Turnstile 検証必須
管理画面 SPA Access(7.1)

CI や外部システムから /api/* を叩く必要がある場合は Service Token を使う。

  1. Zero Trust > Access > Service Auth でトークンを発行(Client ID / Secret)
  2. 該当パスのポリシーに Action = Service Auth の Include ルールを追加(通常の Allow ポリシーとは別立て)
  3. 呼び出し側は CF-Access-Client-Id / CF-Access-Client-Secret ヘッダーを付与

⚠ ブラウザ用の Allow ポリシーに Service Token を混ぜない。必ず別ポリシーにする。

/api/* に認証不要のエンドポイントを増やさない。 どうしても必要なら、専用のパス空間(/public/*)を切って明示する。

7.1.6 workers.dev の直接アクセスを塞ぐ

Access はホスト名に対して効くため、*.workers.dev から直接叩かれる経路が残ると素通りになる。

# Settings > Domains & Routes から無効化するだけでは不十分
# wrangler 設定にも書かないと、次回デプロイで復活する
{
  "workers_dev": false
}

✅ 確認: curl -I https://synon-ma-lp.<subdomain>.workers.dev/ が到達しないこと

7.1.7 退職者の遮断

Google Workspace 側でアカウントを停止するだけでは、Cloudflare 側で発行済みのセッション(Cookie / JWT)が即座に無効になるとは限らない。 退職手続きに両方を入れる。

# 操作 場所
1 Google Workspace アカウントを停止 Google 管理コンソール
2 Revoke(セッションを即時終了) Zero Trust > Team & Resources > Users
3 Remove(シートを解放) 同上

7.3 ルートの割り当て

"routes": [
  { "pattern": "lp.synon.co.jp/*", "zone_name": "synon.co.jp" },
  { "pattern": "ma.synon.co.jp/*", "zone_name": "synon.co.jp" }
]

✅ 確認: $ curl -I https://lp.synon.co.jp/api/health が 200 を返すこと

7.4 Turnstile

  1. ダッシュボード > Turnstile > Add site
  2. Widget mode: Managed(推奨)
  3. Site Key(公開)→ フロントエンドに埋め込む
  4. 🔒 Secret Key → wrangler secret put TURNSTILE_SECRET

サーバ側で https://challenges.cloudflare.com/turnstile/v0/siteverify に POST して検証する。検証をスキップできる分岐を作らない。


8. 構築手順 STEP 4 — LP配信 Worker

8.1 リクエスト処理の流れ

STEP 4 LP配信 Worker のリクエスト処理GET /r/{code} または /lp/{slug}① 文脈(context)の構築D1参照は1回にまとめる(JOIN済みビュー)② 既存割付の確認Cookie ma_v を HMAC 検証 → 有効なら再利用③ バリアント選択KV から配信重み → exploring / bandit / fixed④ 描画R2からHTML断片(Cache API) → 差込展開 → ストリーミング⑤ 記録c.executionCtx.waitUntil( ... ) ★レスポンスをブロックしない必須ルールグローバルスコープに可変状態を置かないHTML断片はCache APIでエッジキャッシュCookieは必ずHMAC署名console.logに個人情報を出さない文脈の中身・company_id・industry・pref / size・channel・approach_count・prior_reaction・utm_*・device⑤ は必ず waitUntil() に逃がす計測処理でレスポンスを待たせると LCP が悪化し、「計測のためにCVRを下げる」という本末転倒になる
図 8①〜⑤ の処理順と必須ルール
GET /r/{tracking_code}  または  /lp/{slug}
        │
 ┌──────▼──────────────────────────────────────────┐
 │ ① 文脈(context)の構築                            │
 │    /r/{code} → D1: tracking_code → approach →     │
 │                company_id, industry, size, pref    │
 │    /lp/{slug} → UTM / Referer / CF-IPCountry       │
 │    ※ D1参照は1回にまとめる(JOIN済みのビューを使う) │
 └──────┬──────────────────────────────────────────┘
        │
 ┌──────▼──────────────────────────────────────────┐
 │ ② 既存割付の確認                                  │
 │    Cookie `ma_v` を HMAC 検証 → 有効なら再利用     │
 │    無ければ D1 の company_id 割付を確認            │
 └──────┬──────────────────────────────────────────┘
        │ 未割付
 ┌──────▼──────────────────────────────────────────┐
 │ ③ バリアント選択                                  │
 │    KV から配信重み(日次バッチが書いたもの)を取得    │
 │    status=exploring → 候補から均等ランダム         │
 │    status=bandit    → Beta分布から1サンプルずつ     │
 │                       argmax。確率εで一様ランダム   │
 │    status=fixed     → 勝者95% / 探索5%             │
 └──────┬──────────────────────────────────────────┘
        │
 ┌──────▼──────────────────────────────────────────┐
 │ ④ 描画                                            │
 │    R2 から HTML 断片を取得(Cache API でキャッシュ)  │
 │    差込変数を展開 → HTMLResponse をストリーミング   │
 │    Set-Cookie: ma_v (署名付き / SameSite=Lax / 90d) │
 └──────┬──────────────────────────────────────────┘
        │
 ┌──────▼──────────────────────────────────────────┐
 │ ⑤ 記録(レスポンスをブロックしない)                 │
 │    c.executionCtx.waitUntil(                       │
 │      EVENTS.writeDataPoint({...})   ← 生イベント    │
 │      EXPERIMENT.get(id).increment() ← カウンタ      │
 │      DB.insert(lp_assignments)      ← 初回のみ      │
 │    )                                               │
 └────────────────────────────────────────────────────┘

⑤は必ず waitUntil() に逃がす。 計測処理でレスポンスを待たせると LCP が悪化し、計測のために CVR を下げるという本末転倒になる。

8.2 実装上の必須ルール

# ルール 理由
1 Worker のグローバルスコープに可変状態を置かない isolate は再利用される。他ユーザーのデータが漏れる
2 HTML断片は Cache API でエッジキャッシュする R2への Class B 操作を減らす。TTFB短縮
3 Cookie は HMAC 署名する 改ざんで任意バリアントを見られると計測が汚れる
4 D1 参照は 1リクエスト1回にまとめる Worker呼出あたりのクエリ数と CPU時間の節約
5 未使用ブロックをクライアントに送らない LCP・INP の悪化を防ぐ
6 外部CDNを読み込まない LCP悪化 + 外部送信規律の対象が増える
7 console.log に個人情報・APIキーを出さない Workers Logs に残る
8 CPU時間 30秒(Paidデフォルト)を意識する 重い処理は Queues / Workflows に逃がす

8.3 バンディット状態(Durable Object)

実験ごとに1インスタンス。SQLite バックエンドを使う。

export class ExperimentCounter {
  constructor(private state: DurableObjectState, private env: Env) {
    this.state.blockConcurrencyWhile(async () => {
      this.state.storage.sql.exec(`
        CREATE TABLE IF NOT EXISTS arms (
          variant_id TEXT PRIMARY KEY,
          n_trial    INTEGER NOT NULL DEFAULT 0,
          n_success  INTEGER NOT NULL DEFAULT 0,
          n_cta      INTEGER NOT NULL DEFAULT 0,
          n_scroll50 INTEGER NOT NULL DEFAULT 0
        )`);
    });
  }
  // POST /trial   { variant_id }
  // POST /success { variant_id, metric }
  // GET  /snapshot → 全アームの (n_trial, n_success)
}

8.4 ローカル開発

$ npm run dev          # Vite + wrangler dev を同時起動
$ npx wrangler d1 execute DB --local \
    --command "SELECT id,status,current_round FROM lp_experiments"

9. 構築手順 STEP 5 — 計測パイプライン

9.1 全体像

STEP 5 計測パイプラインブラウザ lp.jsgzip後 8KB以内 / deferIntersectionObserverPage Visibility APIweb-vitalssendBeaconWorker POST /c常に 204 を返す(エラーを返さない)Origin 検証レート制限 60/分/visitorボット判定(フラグを立てる)waitUntil で書込保存先Analytics Engine生イベント / 3か月 / SQL APIDurable ObjectsバンディットのカウンタD1 (毎時Cron)日次サマリ / 5年AE制約: 1呼出250データポイント / blob 20個・合計16KB / double 20個 / index は1個だけ(96バイト) → index には experiment_id を入れる
図 9ブラウザ → /c → Analytics Engine / DO / D1
[ブラウザ]                    [Worker /c]              [保存先]
 lp.js (8KB以内)               ├ Origin検証             ├ Analytics Engine
  ├ IntersectionObserver ──┐   ├ レート制限(60/分)      │   (生イベント/3か月)
  ├ Page Visibility API    ├──▶├ ボット判定       ──────┤
  ├ web-vitals             │   ├ 正規化               ├ Durable Object
  └ sendBeacon ────────────┘   └ waitUntil で書込      │   (カウンタ)
                                                        └ (日次Cron)→ D1

9.2 クライアント計測スクリプト

要件

項目
サイズ gzip後 8KB 以内
読み込み defer。LPのレンダリングをブロックしない
送信 navigator.sendBeacon()visibilitychange(hidden) と pagehide で発火
スクロール IntersectionObserver(threshold 0.5)でセクション単位。1セクション1回
滞在時間 Page Visibility API で可視状態のみ積算
CWV web-vitalsonLCP / onINP / onCLS
外部通信 自ドメイン /c のみ
// 最小構成のイメージ
const q = [];
const push = (e) => q.push({ ...e, t: Date.now() });
const flush = () => {
  if (!q.length) return;
  navigator.sendBeacon('/c', JSON.stringify({ v: VID, s: SID, e: q.splice(0) }));
};
addEventListener('visibilitychange', () => document.visibilityState === 'hidden' && flush());
addEventListener('pagehide', flush);

const seen = new Set();
new IntersectionObserver((es) => {
  for (const e of es) {
    const d = e.target.dataset.depth;
    if (e.isIntersecting && !seen.has(d)) { seen.add(d); push({ n: 'lp_scroll', d: +d }); }
  }
}, { threshold: 0.5 }).observe; // 実際は各セクションに .observe()

unload イベントは使わない。 モバイルで発火しないことがあり、計測が欠ける。

9.3 サーバ側(/c)

app.post('/c', async (c) => {
  const body = await c.req.json();
  // 1. Origin 検証(自ドメイン以外は破棄。エラーは返さない)
  // 2. レート制限: visitor_id あたり 60イベント/分。超過は静かに破棄
  // 3. ボット判定 → is_bot フラグを立てる(削除はしない)
  // 4. 書き込みは waitUntil に逃がす
  c.executionCtx.waitUntil((async () => {
    for (const e of body.e) {
      c.env.EVENTS.writeDataPoint({
        blobs:   [e.n, body.v, body.s, e.variant_id ?? '', e.company_id ?? '',
                  e.channel ?? '', e.segment ?? '', e.page ?? ''],
        doubles: [e.d ?? 0, e.ms ?? 0, e.lcp ?? 0, e.inp ?? 0, e.cls ?? 0],
        indexes: [e.experiment_id ?? 'none'],
      });
    }
  })());
  return c.body(null, 204);   // 常に 204。エラーを返さない
});

Analytics Engine の制約(公式値)

項目 上限
1 Worker呼出あたりのデータポイント 250
blob 20個、合計16KB以内
double 20個
index 1個、96バイト以内
保持期間 3か月

indexes は1つしか持てない。 集計軸として最も使うもの(= experiment_id)を入れる。他は blob に入れて SQL API で絞る。

9.4 集計(毎時 Cron)

-- Analytics Engine SQL API
SELECT
  blob4 AS variant_id,
  blob7 AS segment,
  count()                                   AS events,
  countIf(blob1 = 'lp_view')                AS views,
  countIf(blob1 = 'lp_scroll' AND double1 >= 50) AS scroll50,
  countIf(blob1 = 'lp_cta_click')           AS cta_clicks,
  countIf(blob1 = 'lp_cv')                  AS cvs
FROM ma_lp_events
WHERE index1 = '{experiment_id}'
  AND timestamp >= NOW() - INTERVAL '1' HOUR
GROUP BY blob4, blob7

結果を lp_events(D1)に UPSERT する。D1 には日次サマリだけを置く。


10. 構築手順 STEP 6 — AI生成パイプライン

10.1 Workflows で組む理由

STEP 6 Workflows で生成パイプラインを組む理由各ステップの結果が永続化され、失敗箇所から再開できる。待機中はCPU課金されないexpand-factors50組gen-{i}LLMcheck-{i}事実embed-{i}埋込dedupe類似renderHTMLscreenshot画像score第0層persistD1gen-{i} は retries を設定する{ limit: 3, delay: '10 seconds', backoff: 'exponential' }ステップ名は必ずユニークに(再開時のキーになる)LLMの使い分け分類・要約・埋め込み → Workers AI(無料枠1万Neurons/日)本番コピー → AI Gateway 経由の外部LLM(コスト可視化・キャッシュ)Workers AI のモデルID(@cf/...)は変わりうる実装前に必ず npx wrangler ai models で現在の一覧を確認すること
図 109ステップの耐久実行

50件の生成は「LLM呼び出し → 検査 → 埋め込み → 結合 → スクショ」の5段×50回。途中で1件失敗したときに最初からやり直すのは無駄が大きい。Workflows は各ステップの結果を永続化し、失敗箇所から再開できる。待機中はCPU課金されない。

10.2 パイプライン定義

export class LpGenerationWorkflow extends WorkflowEntrypoint<Env, Params> {
  async run(event, step) {
    const combos = await step.do('expand-factors', async () =>
      expandFactorProduct(event.payload.factors));   // → 50組

    const generated = [];
    for (const [i, combo] of combos.entries()) {
      // ステップ名をユニークにする(再開時のキーになる)
      const content = await step.do(`gen-${i}`, {
        retries: { limit: 3, delay: '10 seconds', backoff: 'exponential' },
      }, async () => callLLM(combo, event.payload));

      const checked = await step.do(`check-${i}`, async () =>
        factCheck(content, event.payload.facts));    // 事実・禁止表現・数値

      // ⚠ 埋め込みモデルは必ず日本語(多言語)対応のものを選ぶ。
      //    英語特化モデルを使うと日本語コピーの類似度が正しく出ず、
      //    重複検知の見落とし / 正常バリアントの誤脱落(閾値0.95)が起きる。
      //    npx wrangler ai models で現在の多言語埋め込みモデルを確認すること。
      const emb = await step.do(`embed-${i}`, async () =>
        this.env.AI.run(EMBEDDING_MODEL, { text: [summarize(content)] }));

      generated.push({ combo, content, checked, emb });
    }

    await step.do('dedupe', async () => vectorDedupe(generated, 0.95));
    await step.do('render', async () => renderAndStoreToR2(generated));
    await step.do('screenshot', async () => captureAll(generated));
    await step.do('score', async () => screen0Score(generated));
    await step.do('persist', async () => saveVariants(generated));
  }
}

10.3 LLM の使い分け

用途 使うもの 理由
分類・要約・埋め込み Workers AI 無料枠 10,000 Neurons/日 で足りる。低レイテンシ
本番コピー生成 AI Gateway 経由の外部LLM 日本語コピーの品質。Gateway でコスト可視化・キャッシュ・レート制限が付く
画像生成(将来) Workers AI (FLUX系 / SDXL系) ブランド一貫性の整理が先(仕様書16章)

🔒 APIキーは Secrets Store または wrangler secret put に置く。vars に平文で書かない。

$ npx wrangler secret put LLM_API_KEY
$ npx wrangler secret put LLM_API_KEY --env staging

Workers AI のモデルID(@cf/...)は変わりうる。 実装前に必ず現在の一覧を確認する。

$ npx wrangler ai models        # 利用可能なモデルの一覧

10.4 生成コストの目安

1バリアント = 入力 約200トークン / 出力 約800トークン。50件で入力1万・出力4万トークン。

方式 1回(50件)のコスト
Workers AI (Llama 3.1 70B 相当) 約 8,500 Neurons → 無料枠(10,000/日)にほぼ収まる
外部LLM(中位モデル) 数十円〜数百円/回(モデルによる)

AI Gateway のキャッシュを有効にする。 同じプロンプトでの再生成が無料になり、開発中の試行錯誤コストが大きく下がる。

10.5 事実チェックの実装

AIの出力を信じない。機械で照合する。

[1] 実績・事例  → content_json.proof.case_ids が事例DBに存在するか
                  → 存在しないIDが1つでもあれば FAIL
[2] 数値        → 本文から /\d+(\.\d+)?\s*(%|時間|円|社|名|件|倍)/ を抽出
                  → 入力データに無い数値があれば FAIL
[3] 固有名詞    → 顧客名・製品名・技術名を辞書照合 → 未登録語は WARN
[4] 禁止表現    → banned_phrases の正規表現に一致すれば FAIL
[5] 必須表記    → プライバシーポリシー・外部送信公表・会社名のリンクがあるか

FAIL が1件でもあれば fact_check_status='fail' とし、承認APIが403を返す(G-02)。


11. 構築手順 STEP 7 — 統計バッチ

11.1 Cron の割り当て(UTC / JST 併記)

Cron スケジュール(JST)wrangler.jsonc には UTC で書く。JST から9時間を引く00020406081012141618202224毎時毎時00分イベント集計 → D1 日次サマリ02:00優越確率のモンテカルロ(10万回) → KV02:30因子効果・セグメント効果の階層ベイズ03:00逐次淘汰の判定・ラウンド進行04:00スコア減衰(3%)・架電キュー再構築05:00広告オーディエンスの同期06:00noindex 欠落監視・インデックス率チェックUTC 変換の例JST 02:000 17 * * *JST 03:000 18 * * *JST 06:000 21 * * *毎時00分0 * * * *Cron 実行も Workers の CPU時間制限を受ける10万回×バリアント数のモンテカルロが重い場合は、実験単位で Queues に分割して投げる
図 11日付をまたぐ場合は曜日指定もずらすこと

仕様書 12.4「定期実行(Cron)」の10ジョブすべてを実装する。1つでも欠けると、その機能は静かに動かなくなる。

UTC JST 処理 対応する機能
0 * * * * 毎時00分 Analytics Engine → D1 日次サマリ集計 F-14
5 * * * * 毎時05分 GA4 Data API 増分同期 既存 F-09
0 17 * * * 02:00 優越確率のモンテカルロ(10万回)→ KV へ F-15
30 17 * * * 02:30 因子効果・セグメント効果の階層ベイズ推定 F-15
0 18 * * * 03:00 逐次淘汰の判定・ラウンド進行 F-15
0 19 * * * 04:00 スコア減衰(3%)・架電キュー再構築 F-20 / F-23
0 20 * * * 05:00 広告オーディエンスの同期 F-18
0 21 * * * 06:00 noindex 欠落監視・インデックス率チェック(Search Console の URL Inspection API。下記) F-24
0 22 * * 0 月 07:00 週次レポートのメール配信 F-23
0 15 1 * * 1日 00:00 3か月超の生イベントを R2 へアーカイブ F-14

⚠ 曜日・日付指定を含む Cron は、UTC変換で日付がずれる。「JST 月曜07:00」は UTC では日曜22:00(0 22 * * 0)、「JST 毎月1日00:00」は UTC では前月末日15:00 になるため 0 15 1 * * では厳密には JST 1日09:00 になる。時刻の厳密さが要る場合は Worker 内で JST に変換して判定すること。

src/worker/index.ts:

export default {
  fetch: app.fetch,
  async scheduled(event: ScheduledController, env: Env, ctx: ExecutionContext) {
    switch (event.cron) {
      case '0 * * * *':   ctx.waitUntil(aggregateHourly(env)); break;
      case '5 * * * *':   ctx.waitUntil(syncGa4(env)); break;
      case '0 17 * * *':  ctx.waitUntil(recomputePosteriors(env)); break;
      case '30 17 * * *': ctx.waitUntil(estimateFactorEffects(env)); break;
      case '0 18 * * *':  ctx.waitUntil(advanceRounds(env)); break;
      case '0 19 * * *':  ctx.waitUntil(decayScores(env)); break;
      case '0 20 * * *':  ctx.waitUntil(syncAdAudiences(env)); break;
      case '0 21 * * *':  ctx.waitUntil(auditIndexing(env)); break;
      case '0 22 * * 0':  ctx.waitUntil(sendWeeklyReport(env)); break;
      case '0 15 1 * *':  ctx.waitUntil(archiveEventsToR2(env)); break;
    }
  },
};

インデックス監視の実装メモ(G-03 / G-06) 「大量生成LPが誤ってインデックスされていないか」は Search Console でしか確認できない。日次バッチで URL Inspection API を叩き、公開中バリアントの indexingState が「インデックス登録を許可 = いいえ」であることを確認する。 クォータはサイトあたり 600 QPM / 2,000 QPD。1回の公開が50ページ以内(G-06)なので、全バリアントを毎日検査しても十分収まる。 🔒 認証は Google サービスアカウント。鍵は Secrets Store に置く(GA4連携と同じ鍵は使わない)。 ⚠ robots.txt でブロックしていると Google が noindex を読めず、検査結果が常に「許可 = はい」になる。robots.txt を使わない理由の一つがこれ(監視が効かなくなる)。

Cron 実行も Workers の CPU 時間制限を受ける。 10万回×バリアント数のモンテカルロが重い場合は、実験単位で Queues に分割して投げる。

11.2 優越確率の計算

1. D1 から実験ごとの (variant_id, alpha, beta) を取得
2. 経験ベイズ事前分布を計算
     p̄ = Σsᵢ / Σnᵢ ,  κ = 100
     α₀ = p̄ × κ ,  β₀ = (1 - p̄) × κ
3. 各バリアント: αᵢ' = α₀ + sᵢ , βᵢ' = β₀ + (nᵢ - sᵢ)
4. M = 100,000 回サンプリング → P(i が最良) と期待損失 EL(i)
5. KV に書く:  key = `exp:{id}:weights`, TTL 3600
     { variants: [{id, p_best, el, post_mean, ci_low, ci_high}], updated_at }
6. 確定条件を判定
     max(p_best) >= 0.95  かつ  min(EL) <= 0.001
     かつ 各バリアントの累計CV >= 20  かつ 経過日数 >= min_days
   → 満たせば status='fixed', winner_variant_id を設定

ベータ分布のサンプリング: JS標準に無いため、ガンマ分布経由(Marsaglia-Tsang法)で実装するか jstat を使う。

X ~ Gamma(α, 1), Y ~ Gamma(β, 1) のとき  X / (X + Y) ~ Beta(α, β)

11.3 逐次淘汰(Sequential Halving)

for each experiment where status = 'exploring':
    候補 = status='active' のバリアント
    if 全候補の sessions >= min_sessions_per_variant:
        proxy_score を計算
          0.40*z(cta_rate) + 0.30*z(scroll50_rate)
        + 0.20*z(median_engaged_ms) + 0.10*z(form_start_rate)
        下位 50% を status='eliminated', eliminated_round=current_round に更新
        current_round += 1
        if 残り <= 5:  status = 'bandit'
    早期打ち切り:
        P(そのバリアントが最良) < 0.01 のものは min_sessions 未達でも脱落

✅ 確認: ラウンド進行のたびに監査ログを残し、Slack に通知する。気づかないうちにバリアントが消えている状態を作らない。


12. デプロイ運用

12.1 環境

環境 Worker名 D1 用途
local ローカルエミュレーション 開発
staging synon-ma-lp-staging synon-ma-db-staging 検証。本番と同じ手順を必ず先に通す
production synon-ma-lp synon-ma-db 本番

12.2 デプロイ手順(標準)

デプロイ手順 — マイグレーションは必ずデプロイより先localローカルエミュレーションnpm run devnpm run db:migrate:localnpm teststagingsynon-ma-lp-stagingd1 migrations apply --env stagingnpm run deploy:staging★本番と同じ手順を先に通すproductionsynon-ma-lpd1 migrations apply --env=""npm run deploynpm run tail (5分監視)後方互換のないスキーマ変更は2段階に分ける1回目: カラム追加(NULL許容)+ 新旧両対応 → 2回目: 旧カラム削除ロールバックしてもDBスキーマは戻らないマイグレーションを伴う変更は、切り戻し用SQLを事前に用意する
図 12local → staging → production の順を崩さない
# 0. 前提: main ブランチが最新、テストが通っている
$ npm run typecheck && npm test

# 1. ステージングへ
$ npx wrangler d1 migrations apply DB --remote --env staging
$ npm run deploy:staging

# 2. ステージングで確認(12.4のチェックリスト)

# 3. 本番へ
$ npx wrangler d1 migrations apply DB --remote --env=""
$ npm run deploy

# 4. 監視(5分間)
$ npm run tail

マイグレーションはデプロイより先に適用する。 逆にすると、新コードが存在しないカラムを参照して本番が落ちる。

後方互換のないスキーマ変更は2段階に分ける。 1回目: カラム追加(NULL許容)+ 新旧両対応のコード → 2回目: 旧カラム削除。

12.3 段階デプロイ(大きな変更のとき)

# バージョンをアップロードするだけ(トラフィックは流さない)
$ npx wrangler versions upload --env=""

# 10% だけ新バージョンに流す
$ npx wrangler versions deploy --env=""
#   → 対話式で新旧バージョンと配分(%)を指定

# 問題なければ 100% に

段階デプロイの割付はリクエスト単位。 同一ユーザーでも毎回別バージョンに当たる。バリアント実験と混同すると計測が汚れるので、実験期間中は段階デプロイを使わないか、Cloudflare-Workers-Version-Key ヘッダで固定する。

12.4 デプロイ後の確認

# 確認項目 方法
1 ヘルスチェック curl -I https://lp.synon.co.jp/api/health → 200
2 LP が表示される 実際にブラウザで /lp/{slug} を開く
3 バリアントが割り当たる Cookie ma_v が付き、リロードで同じ面が出る
4 計測が飛ぶ DevTools Network で /c に 204
5 Analytics Engine に入る SQL API で直近5分を SELECT
6 noindex が付いている curl -s .../lp/{slug} \| grep -i "robots"
7 Cron が動く wrangler tail で次回実行を待つ、または手動トリガ
8 エラー率 ダッシュボード > Workers > Metrics

12.5 ロールバック

# 直前のデプロイに戻す
$ npm run rollback

# 特定バージョンへ戻す
$ npx wrangler deployments list --env=""
$ npx wrangler rollback [version-id] --env=""

ロールバックしてもDBスキーマは戻らない。 マイグレーションを伴う変更のロールバックは、事前に切り戻し用SQLを用意しておく。

12.6 CI/CD

Workers Builds(Cloudflare のGit連携ビルド。Free 月3,000ビルド分 / Paid 月6,000ビルド分)か、GitHub Actions のどちらかを使う。スターターには .github/workflows/deploy.yml がある。

推奨フロー:

PR 作成      → typecheck + test + dry-run
main マージ  → staging へ自動デプロイ
タグ push    → production へデプロイ(手動承認を挟む)

🔒 CLOUDFLARE_API_TOKEN は GitHub の Secrets に置く。権限は最小に絞る(Workers Scripts:Edit, D1:Edit, R2:Edit, Workers KV:Edit)。


13. 日次・週次・月次の運用手順

運用サイクル毎日5〜10分□ エラー率・実行数□ Cron の成功可否□ 実験の進捗□ 逐次淘汰ログ□ 架電キューの新規投入□ 計測イベント数の推移□ LP の CWV毎週(月)30分□ 週次レポートの確認□ 実験の確定判定□ 因子効果のレビュー□ セグメント別最良の確認□ 生成キューの消化□ コンプライアンス確認□ コスト確認毎月1時間□ アトリビューションレポート□ 実測CPL/CVRでベンチマーク更新□ 生イベントのR2アーカイブ□ D1 / R2 の容量確認□ 依存パッケージ更新□ compatibility_date 見直し四半期半日□ 法令要件のレビュー□ 外部送信タグの棚卸し□ 法務確認フラグの再確認□ Cloudflare 料金・上限の確認□ Access ポリシーの棚卸し□ 実験運用の振り返り□ 復旧訓練(D1 Time Travel)
図 13毎日 / 毎週 / 毎月 / 四半期

13.1 毎日(所要 5〜10分)

# やること 見る場所 異常時
1 Worker のエラー率・実行数 ダッシュボード > Workers > Metrics 16章
2 Cron の成功可否 同 > Cron Events / Slack通知 16.4
3 実験の進捗(セッション数・ラウンド) S-17 実験ダッシュボード
4 バリアントの逐次淘汰ログ S-17 / Slack 想定外の脱落は原因を確認
5 架電キューの新規投入 S-25 営業へ引き渡し
6 計測イベント数の推移 Analytics Engine 前日比 ±30% 超で調査
7 LP の CWV Web Analytics / S-17 LCP 2.5秒超が続くなら 16.5

13.2 毎週(月曜・所要 30分)

# やること 判断基準
1 週次レポートの確認(自動配信) チャネル別CPL・CVR・商談化
2 実験の確定判定 p_best >= 95% かつ EL <= 0.1% かつ 経過7日以上
3 因子効果のレビュー どの訴求軸・構成が効いているか。次回生成に反映
4 セグメント別最良の確認 全体最良と違うセグメントがあれば出し分けを有効化
5 生成キューの消化 差戻しになったバリアントの再生成
6 コンプライアンス・ダッシュボード noindex 欠落ゼロ、外部送信公表が最新
7 コスト確認 15章

13.3 毎月

# やること
1 月次アトリビューションレポート作成(S-26)
2 実測CPL/CVRで仕様書4.2のベンチマークを更新(外部の公開値を自社実測で置き換える)
3 3か月超の生イベントをR2へアーカイブ
4 D1 のサイズ確認(上限10GB)。lp_events の古い行を退避
5 R2 の容量・オブジェクト数確認
6 依存パッケージの更新(npm outdated → wrangler は特に追随する)
7 compatibility_date の見直し(半年〜1年に1回、テスト後に更新)

13.4 四半期

# やること
1 法令要件のレビュー(改正個人情報保護法の施行スケジュールに追随。仕様書14章)
2 外部送信タグ一覧と公表テキストの棚卸し
3 法務確認フラグ(G-09/G-10)の再確認
4 Cloudflare の料金・上限の変更確認(公式ページ)
5 Access ポリシーの棚卸し(退職者・権限過剰)
6 実験運用の振り返り(仮説と結果の突き合わせ)
7 バックアップからの復旧訓練(D1 Time Travel の実行確認)

14. 監視とアラート

14.1 監視項目

監視とアラート — 通知疲れを避ける設計にする即時通知 (Slack + メール)noindex 欠落 — 1件でも未承認バリアントの公開試行Cron 実行失敗Worker エラー率 1%超が5分継続日次サマリ (Slack)D1 行書込 / DBサイズ 80%R2 ストレージ 80%Workers AI Neurons 80%Analytics Engine 書込 前日比±30%LP の LCP p75 > 2.5秒CVR 前週比 −30%月次 (メール)月額見込みが予算の80%依存パッケージの更新compatibility_date の見直し通知が多すぎると誰も見なくなる — 守るルール① 即時通知は「人が今すぐ動く必要があるもの」だけ ② それ以外は日次サマリにまとめる ③ 同じアラートが5回続いたら自動抑制し「継続中」の1通に切り替える
図 143階層の通知設計
対象 指標 閾値 通知先
Worker エラー率 1% 超が5分継続 Slack #ma-alert
Worker p99 レスポンス時間 1秒超 Slack
Worker CPU時間 上限の80%到達 Slack
Cron 実行失敗 1回でも Slack(即時)
D1 行書込数 無料枠の80% Slack(日次)
D1 DBサイズ 8GB(上限10GBの80%) Slack(日次)
R2 ストレージ 8GB(無料枠10GBの80%) Slack(日次)
Analytics Engine 書込数 前日比 ±30% Slack(日次)
Workers AI Neurons 消費 無料枠の80% Slack(日次)
LP LCP p75 2.5秒超 Slack(日次)
LP CVR 前週比 −30% Slack(日次)
コンプライアンス noindex 欠落 1件でも Slack(即時)+ メール
コンプライアンス 未承認バリアントの公開試行 1件でも 同上
課金 月額見込み 予算の80% Slack + メール

14.2 設定方法

  1. Cloudflare Notifications: ダッシュボード > Notifications で、Workers のエラー率・課金アラートを設定(無料)
  2. Slack 通知: notify.ts(スターターにある)から Webhook を叩く。🔒 Webhook URL は Secrets Store に置く
  3. 合成監視: Zero Trust の DEX で LP への到達性を常時監視する(無料)
  4. ログ: observability.enabled = true を設定済み。wrangler tail でリアルタイム確認
$ npm run tail                                 # 本番ログ
$ npx wrangler tail --env staging --format json
$ npx wrangler tail --status error             # エラーのみ

14.3 通知疲れを避ける

通知が多すぎると誰も見なくなる。 以下を守る。


15. コスト管理

15.1 月額試算

月額コスト — 当面 $5/月 で耐えられる前提: LP 月間10万PV / CV率5% / 生成は週1回50パターンFree プラン$0数値上は無料枠に収まるしかし CPU時間 10ms/呼び出し で動かないAnalytics Engine も 10万件/日 で不足しうるWorkers Paid$5/ 月 — このトラフィック規模では基本料金のみWorkers / D1 / R2 / KV / AE / DO / Browser RenderingTurnstile / Web Analytics / AI Gateway すべて無料枠内スケールしても当面は変わらない月10万PV$5月100万PV$5月1,000万PV超過発生
図 15外部LLMを使う場合は別途(週1回×50件で数百〜数千円/月)

前提: LP 月間10万PV、CV率5%、生成は週1回50パターン、イベント約20件/セッション。

サービス 使用量 Free プラン Workers Paid
Workers 基本 $0 $5.00
Workers リクエスト 約20万/月 無料枠内(10万/日) 無料枠内(1,000万/月)
Workers CPU時間 10ms/呼出で足りない 無料枠内(3,000万ms/月)
Workers AI 約8,500 Neurons/回 無料枠内(1万/日) 無料枠内
D1 読取・書込 数十万件/月 無料枠内 無料枠内
KV 数千操作/月 無料枠内 無料枠内
R2 数GB 無料枠内(10GB) 無料枠内
Analytics Engine 約200万件/月 ⚠ 10万件/日で不足しうる 無料枠内(1,000万/月)
Durable Objects 約20万req/月 無料枠内 無料枠内
Browser Rendering 週1回×50件 無料枠内(10分/日) 無料枠内
Turnstile / Web Analytics / AI Gateway 無料 無料
合計(Cloudflare) $0 だが運用不可 約 $5/月
外部LLM(使う場合) 週1回×50件 別途 数百〜数千円/月

Free プランは数値上は無料枠に収まるが、CPU時間 10ms/呼び出しの制約で実際には動かない。$5/月の Workers Paid を前提にすること。

15.2 スケール時の見通し

月間PV 想定される追加課金
10万 なし($5のまま)
100万 ほぼなし($5のまま。D1書込・AE書込とも無料枠内)
1,000万 Workers リクエスト超過($0.30/100万)、D1書込超過($1.00/100万行)、AE書込超過($0.25/100万件)が発生し始める

当面 $5/月で耐えられる設計になっている。 これが Cloudflare を選ぶ最大の理由の一つ。

15.3 コストを膨らませる典型的なミス

ミス 影響 防ぎ方
生イベントを D1 に1件ずつINSERT 行書込課金が跳ねる Analytics Engine を使う
毎リクエストでモンテカルロ10万回 CPU時間超過 日次バッチ + KVキャッシュ
生成を毎日フルで回す LLM費用 週1回。差分生成。AI Gateway のキャッシュを有効に
R2 の HTML を毎回取得 Class B 操作 Cache API でエッジキャッシュ
ボットトラフィックを計測 AE書込 + 統計汚染 ボット判定を先に通す
LP画像を最適化しない 帯域(R2はegress無料だが)+ LCP悪化 Cloudflare Images / format=auto

15.4 確認方法

# 課金の実績はダッシュボードで確認
#   Workers & Pages > Plans / Usage
#   R2 / D1 / Workers AI はそれぞれの Metrics タブ

月初に前月分を確認し、予算超過があれば原因を 15.3 の表で切り分ける。


16. トラブルシューティング

トラブルシュート — 症状から入口を引くバインディングが undefined原因: wrangler.jsonc の片方にしか書いていない対処: dry-run を両環境で実行D1 が locked / 競合原因: 同一行への高頻度UPDATE対処: Durable Objects で直列化計測データが入らない原因: waitUntil 内の例外はレスポンスに出ない対処: try/catch でログを出すCron が動かない原因: UTC で書いていない / scheduled 未export対処: JST−9h。日跨ぎは曜日もずらすLP が遅い (LCP>2.5秒)原因: TTFB / HTMLサイズ / LCP要素 の順で切り分け対処: 外部リクエストを削除1バリアントのCVRが異常に高い原因: サンプル数が少ない対処: 経験ベイズ事前分布(κ)とガードレールを確認いつまでも確定しない原因: そもそも必要サンプル数に届いていない対処: 因子を減らす / 判定指標をプロキシに変えるWorkers AI が 429原因: テキスト生成は既定300req/分対処: Workflows のリトライに任せる
図 16詳細は16章の各節へ

16.1 バインディングが undefined

症状 env.EVENTS is undefined のようなエラー
原因 wrangler.jsonc本番か staging の片方にしか書いていない
確認 npx wrangler deploy --dry-run --env=""--env staging の両方でバインディング一覧を見る
対処 3箇所(wrangler.jsonc 両環境 / types.ts / CLAUDE.md)を同時に更新

16.2 D1 のエラー

症状 原因 対処
D1_ERROR: too many SQL variables bind パラメータが100超 バッチを分割する
database is locked 相当の競合 同一行への高頻度UPDATE Durable Objects で直列化する
マイグレーションが適用済み扱いになる 既存ファイルを編集した 編集しない。 新しいマイグレーションで修正する
クエリが30秒でタイムアウト 全件スキャン インデックスを確認。EXPLAIN QUERY PLAN
# 適用状況の確認
$ npx wrangler d1 migrations list DB --remote --env=""

16.3 計測データが入らない

チェック順:
 1. ブラウザ DevTools > Network に /c への POST があるか
    → 無い: クライアントスクリプトが読み込まれていない / JSエラー
 2. /c が 204 を返しているか
    → 4xx: Origin検証・レート制限に引っかかっている
 3. wrangler tail で writeDataPoint が呼ばれているか
    → 呼ばれていない: waitUntil 内で例外が出ている(try/catchでログ出力を追加)
 4. Analytics Engine SQL API で直近5分を SELECT
    → 空: dataset 名の不一致。blob の順序ズレ
 5. 集計 Cron のログ
    → D1 の lp_events に反映されない: UPSERT のキー不一致

waitUntil 内の例外はレスポンスに現れない。 必ず try/catch でログを出す。

16.4 Cron が動かない

確認 方法
Cron が登録されているか ダッシュボード > Worker > Settings > Trigger Events
UTC で書いたか JST から9時間引く。日付をまたぐ場合は曜日もずらす
scheduled ハンドラが export されているか export default { fetch, scheduled }
CPU時間を超えていないか ログで実行時間を確認。重い処理は Queues に分割
ローカルでテストしたいとき wrangler dev --test-scheduledcurl "http://localhost:8787/__scheduled?cron=0+17+*+*+*"

16.5 LP が遅い(LCP が 2.5秒を超える)

切り分け順:
 1. TTFB は速いか(200ms以下)
    遅い → Worker 側。D1参照が複数回、同期処理がレスポンスをブロック
 2. HTML サイズは 200KB 以下か
    大きい → 未使用ブロックが混ざっている / インライン画像
 3. LCP要素は何か(DevTools > Performance)
    画像 → Cloudflare Images で最適化、fetchpriority="high"、サイズ指定
    フォント → 自己ホスト + font-display: swap + サブセット化
 4. 外部リクエストがあるか
    ある → 削除する(CDN・タグ・埋め込み)
 5. 計測スクリプトがブロックしていないか
    → defer が付いているか、8KB以内か

16.6 実験の結果がおかしい

症状 原因の候補
1バリアントのCVRが異常に高い サンプル数が少ない。経験ベイズ事前分布(κ)が効いているか確認。ガードレール(累計100セッション未満は非表示)が働いているか
同じ人に違うバリアントが出る Cookie が付いていない / 署名検証に失敗 / 段階デプロイと併用している
セッション数がバリアント間で偏る バンディットが効いている(正常)。exploring 中なら割付ロジックのバグ
CVが記録されない CVイベントの発火条件。サンクスページの計測漏れ。フォーム送信後のリダイレクトで visitor_id が切れている
いつまでも確定しない そもそも必要サンプル数に届いていない。 S-15 の試算を見直し、因子を減らすか判定指標をプロキシに変える

16.7 Workers AI が失敗する

症状 対処
モデルIDが見つからない npx wrangler ai models で現在のIDを確認。モデル名は変わりうる
レート制限(429) テキスト生成は既定300req/分。大型モデルはさらに低い。Workflows のリトライ(指数バックオフ)に任せる
Neurons を使い切った 無料枠は1日10,000。UTC 0時にリセット。生成を週1回にする、または外部LLMへ切り替える
ローカルで動かない Workers AI はローカルエミュレーション無し。"remote": true を付ける

17. 障害対応・緊急停止

17.1 緊急停止の判断基準

事象 対応
LPに誤った実績・数値が公開されている 該当バリアントを即時停止(17.2)→ 原因究明 → 事実チェックの穴を塞ぐ
個人情報が意図せず表示されている LP全体を停止 → 影響範囲の特定 → 法務・経営へ報告
LPが表示されない/エラー多発 ロールバック(12.5)
計測が完全に停止 LP配信は継続。計測のみ復旧作業(LP表示を止めない)
広告費が想定を大きく超過 媒体側で配信停止 → オーディエンス同期を停止
Cloudflare 側の障害 Cloudflare Status を確認。復旧を待つ

17.2 バリアントの緊急停止

緊急停止 — 事前に仕組みを作っておくバリアント単位KV に disabled リストを書くkv key put "exp:{id}:disabled"数十秒反映までLP 全体KV にメンテナンスフラグkv key put "lp:maintenance" "1"数十秒反映まで即時性が要る場合ロールバック / ルート解除npm run rollback即時反映までWorker 側は毎リクエストで disabled リストを参照する実装にしておく — この仕組みが無いと、緊急時にデプロイが必要になる
図 173段階の停止手段
# 1. 該当バリアントを無効化(即時反映)
$ npx wrangler kv key put --binding=CACHE \
    "exp:{experiment_id}:disabled" '["{variant_id}"]' --env=""

# 2. D1 のステータスも更新
$ npx wrangler d1 execute DB --remote --env="" \
    --command "UPDATE lp_variants SET status='eliminated' WHERE id='{variant_id}'"

⚠ Worker 側は毎リクエストで disabled リストを参照し、含まれるバリアントを候補から外す実装にしておくこと。この仕組みを事前に作っておかないと、緊急時にデプロイが必要になる。

17.3 LP 全体の停止

# メンテナンス画面へ切り替え(KVフラグ)
$ npx wrangler kv key put --binding=CACHE "lp:maintenance" "1" --env=""

# 解除
$ npx wrangler kv key delete --binding=CACHE "lp:maintenance" --env=""

KV は結果整合のため、反映まで最大数十秒かかる。即時性が要る場合はロールバックかルート解除を使う。

17.4 データ復旧(D1 Time Travel)

D1 は Time Travel が常時有効。Paid は過去30日、Free は過去7日の任意時点に復元できる。

# 復元ポイントの確認
$ npx wrangler d1 time-travel info DB --env=""

# 特定時刻へ復元(⚠ 破壊的操作。実行前に必ず現状をエクスポート)
$ npx wrangler d1 export DB --remote --env="" --output=backup_$(date +%Y%m%d).sql
$ npx wrangler d1 time-travel restore DB --timestamp=... --env=""

復元は破壊的操作。 復元前に必ずエクスポートを取る。四半期に1回、訓練として復元手順を通しておく。

17.5 事後対応

  1. 発生時刻・影響範囲・原因・対処を記録する
  2. 再発防止策をガードレール(F-24)として実装できないか検討する。人の注意力に頼る対策は採用しない
  3. 監査ログを確認し、誰の操作が起点だったかを特定する(責任追及ではなく、UIの改善点を見つけるため)

18. セキュリティ運用

18.1 秘密情報

種類 置き場所
複数Workerで共有する秘密(Slack Webhook 等) Secrets Store(オープンベータ)
1つのWorkerでしか使わない秘密 wrangler secret put
CI/CD 用のAPIトークン GitHub Secrets(権限は最小に)
Cookie署名鍵 wrangler secret put COOKIE_SIGNING_KEY

🔒 やってはいけないこと

18.2 定期作業

頻度 作業
四半期 Access ポリシーの棚卸し(退職者・権限過剰)
四半期 APIトークンのローテーション
半期 LLM APIキー・媒体APIキーのローテーション
随時 依存パッケージの脆弱性確認(npm audit)

18.3 LPのセキュリティ設定

項目 設定
CSP default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; 外部を許可しない
Cookie SameSite=Lax、署名付き。個人情報を入れない(識別子のみ)
フォーム Turnstile 必須。サーバ側で必ず検証
R2 公開バケット・署名付きURLを使わない。必ず Worker 経由
WAF 管理画面(ma.synon.co.jp)にレート制限とIP制限
ヘッダ X-Content-Type-Options: nosniffReferrer-Policy: strict-origin-when-cross-origin

19. 移行手順(案B を選んだ場合)

MA本体(F-01〜F-10)を Cloudflare へ移す場合の順序。一度に全部移さない。

19.1 移行の順序

案B を選んだ場合の移行順序 — 一度に全部移さないPhase 1参照系の画面から【易】D1に読み取り用レプリカを作る既存システムが正Phase 2書き込みをD1 に切替【中】更新を D1 正へ二重書き込み期間で差分を突合Phase 3送信・計測系を移す【中】SendGrid送信/WebhookGA同期・計測リダイレクト外部API中心で相性良Phase 4収集系を移す★最難関【難】リストアップ/抽出→Workflows+HTMLRewriterフォーム営業→Browser RunPhase 5認証を Access に一本化【易】既存システムを停止Hyperdrive は SQLite に接続できない対応は PostgreSQL / MySQL のみ。Phase 1 は D1 レプリカ方式で行うPhase 4 だけが「移植」ではなく「作り直し」Queue/Cron は実時間15分上限 → Workflows へ。Playwright → Puppeteer
図 185フェーズ。難しいものを最後に置き、引き返せる状態を保つ

重要な前提: Hyperdrive は PostgreSQL / MySQL 専用で、SQLite には接続できない。 v1.2 の DB は SQLite のため、「既存DBを Hyperdrive 経由で参照しつつ画面だけ Workers 化する」という橋渡しは使えない。代わりに Phase 1 で D1 に読み取り用レプリカを作る方式を取る。 (出典: Hyperdrive — 対応DBは PostgreSQL / MySQL / PostgreSQL互換のみ)

Phase 1  参照系の画面から移す                          【易】
         └ SQLite → D1 へ定期同期した「読み取り用レプリカ」を作る
           企業検索・一覧・分析画面だけ Workers で作り直す
           既存システムが正。D1は捨てても困らない状態を保つ
             ▼
Phase 2  書き込みを D1 に切り替える                     【中】
         └ 企業・アプローチ・テンプレートの更新を D1 正に移す
           既存システムは参照のみ。二重書き込み期間を設けて差分突合
             ▼
Phase 3  送信・計測系を移す                             【中】
         └ SendGrid送信(F-08) / Event Webhook受信 / GA同期(F-09)
           /r/{tracking_code} の計測リダイレクト → Workers + Cron
           ※ 外部API呼び出しが中心で、Workers と相性が良い
             ▼
Phase 4  収集系を移す ★最難関                          【難】
         └ リストアップ(F-01) / 連絡先抽出(F-02) → Workflows + HTMLRewriter
           フォーム営業(F-06) → Browser Run (旧 Browser Rendering)
           ジョブキュー(BullMQ/Celery) → Queues + Workflows
           ※ ここだけは「移植」ではなく「作り直し」になる(19.4項)
             ▼
Phase 5  認証を Access に一本化し、既存システムを停止    【易】

Phase 4 を最後に置く理由: ここが唯一アーキテクチャの再設計を要求する。Phase 1〜3 が終わっていれば、Phase 4 が想定より重いと分かった時点で「収集系だけ既存サーバに残す」という妥協が選べる。逆順にすると引き返せなくなる。

19.2 SQLite → D1 の移行

D1 は SQLite ベースだが、そのままコピーはできない

# 1. 既存 SQLite からスキーマとデータをエクスポート
$ sqlite3 ma.db .schema > schema.sql
$ sqlite3 ma.db .dump > dump.sql

# 2. D1 非対応の構文を書き換える
#    - PRAGMA 文を削除
#    - 一部の関数・拡張(FTS5 の設定など)を確認
#    - AUTOINCREMENT の扱いを確認

# 3. マイグレーションとして取り込む
$ npx wrangler d1 migrations create DB import_from_sqlite

# 4. データ投入(大きい場合は分割)
$ npx wrangler d1 execute DB --remote --env staging --file=./data_part1.sql

注意点

項目 内容 判定
FTS5 D1 は FTS5 を公式にサポートしている(fts5vocab 含む)。v1.2 の全文検索はそのまま移せる。ただし CREATE VIRTUAL TABLE ... USING fts5(...)小文字で書く(大文字 FTS5not authorized になるという報告あり)。インデックス更新の性能は実機で確認する
JSON関数 / Math関数 公式にサポート
ビュー / 生成列 サポート(PRAGMA table_xinfo に生成列が出る)
外部キー PRAGMA foreign_keys / defer_foreign_keys をサポート
トリガー / AUTOINCREMENT / 日付関数 公式ドキュメントに明記が無い。実機で全DDLを流して確認する
明示的トランザクション BEGIN / COMMIT は使えない前提で設計する。 D1 の原子性の単位は batch()。v1.2 5.3項の「大量INSERTをトランザクションでまとめる」設計は batch() へ書き換えが必要
WALモード / busy_timeout D1 では意識不要(マネージド)。v1.2 5.3項の設計方針はそのまま不要になる
DBサイズ 1DB最大 10GB(Paid) / 500MB(Free)
インポート wrangler d1 execute --file は 5GiB まで。SQL文長の上限は10万バイトなので大量INSERTは250行程度に分割する。参照先テーブルを先に投入するか PRAGMA defer_foreign_keys = true を使う

公式の「非対応機能の一覧」ページは存在しない。移行前に v1.2 の全 DDL とクエリをステージングの D1 に流して1本ずつ通すこと。 「移してみないと分からない」部分が残る前提で日程を組む。

19.3 移行時のリスクと対策

リスク 対策
移行中のデータ不整合 二重書き込み期間を設け、日次で差分を突合する
Cron / Queue consumer の wall clock 15分上限 クロール・一括送信を1ジョブで回さない。Workflows(1ステップの実時間は無制限、ステップ数上限25,000)へ逃がす。19.4項
Workers の CPU時間制限 I/O待ち(fetch・D1クエリ)は CPU時間に加算されないため、クロール自体は思ったより通る。CPUを食うのは解析・集計側
既存 Playwright 資産が使えない可能性 Browser Run の第一級サポートは Puppeteer。移行判断の前に、フォーム営業のPoCを Browser Run で1本通す
認証切り替えでログインできなくなる Access のポリシーを先に検証環境で通す。切り戻し経路を残す
想定外の機能欠落 移行前に F-01〜F-10 の受入テストケースを作り、移行後に全件通す

19.4 Phase 4(収集系)が「移植」ではなく「作り直し」になる理由

v1.2 の非同期処理は「BullMQ / Celery に数分〜数十分のジョブを投げる」前提で設計されている。Cloudflare にはこの実行モデルが無い。

v1.2 の前提 Cloudflare の制約 作り直しの内容
ジョブキューに長時間ジョブを投げる Queue consumer / Cron は wall clock 15分(CPU時間ではなく実時間) 1ジョブ=1企業の粒度に分割し、Workflows のステップとして実装する。Workflows は1ステップの実時間が無制限で、待機中は同時実行数にカウントされない
BullMQ (Redis前提) Redis 前提の npm パッケージは動かない Queues(配送) + Workflows(実行制御) の2つに分けて再実装
Celery (Python worker) Workers 上で C拡張を含む Python ライブラリは動かない TypeScript へ書き換え
Playwright でフォーム操作 Browser Run(2026年4月に Browser Rendering から改称)は Puppeteer が第一級。セッションのアイドルは60秒、keep_alive で最大10分 Puppeteer ベースへ書き換え。1フォーム=1セッションに収める設計
外部プロセス起動 node:child_process はスタブ実装(import できるが動かない) 該当処理があれば設計変更
クロールでのHTML解析 HTMLRewriter に DOM API は無く、JavaScript も実行されない 静的HTMLの抽出(F-02 のフォームURL・メール抽出)は HTMLRewriter で足りる。JS描画が必要なサイトは Browser Run に回すという2段構えにする

Browser Run のスループット試算(Workers Paid、1件30秒と仮定)

同時ブラウザ 1時間あたり 月20営業日 × 8時間
1 120件 約 19,200件
10 1,200件 約 192,000件

追加ブラウザ時間の単価は $0.09(2025年8月の課金開始時。現在の単価は料金ページで要確認)。フォーム営業の実務量ならスループットも費用も問題にならない。効いてくるのは既存 Playwright スクリプトの書き換え工数のほう。


20. チェックリスト

20.1 構築完了チェック

20.2 実験開始前チェック

20.3 実験確定前チェック


21. 付録: 主要コマンド早見表

21.1 開発

npm run dev                    # Vite + wrangler dev を同時起動
npm run typecheck              # 型検査
npm test                       # ユニットテスト
npm run cf-typegen             # バインディングの型定義を再生成
npx wrangler dev --test-scheduled   # Cron のローカルテスト

21.2 D1

npm run db:migrate:local                                    # ローカルへ適用
npm run db:seed:local                                       # テストデータ投入
npx wrangler d1 migrations create DB <説明>                  # マイグレーション作成
npx wrangler d1 migrations list   DB --remote --env=""      # 適用状況
npx wrangler d1 migrations apply  DB --remote --env staging # ステージングへ適用
npx wrangler d1 migrations apply  DB --remote --env=""      # 本番へ適用
npx wrangler d1 execute DB --local --command "SELECT ..."   # ローカル参照
npx wrangler d1 execute DB --remote --env="" --command "..."# 本番参照(⚠慎重に)
npx wrangler d1 export  DB --remote --env="" --output=b.sql # エクスポート
npx wrangler d1 time-travel info    DB --env=""             # 復元ポイント確認
npx wrangler d1 time-travel restore DB --timestamp=...      # 復元(⚠破壊的)

21.3 デプロイ

npx wrangler deploy --dry-run --env=""      # バインディング確認(デプロイしない)
npm run deploy:staging                       # ステージングへ
npm run deploy                               # 本番へ
npx wrangler versions upload --env=""        # バージョンのみ作成
npx wrangler versions deploy --env=""        # 段階デプロイ(配分を指定)
npx wrangler deployments list --env=""       # デプロイ履歴
npm run rollback                             # 直前へ戻す

21.4 秘密情報

npx wrangler secret put   <NAME> --env=""       # 登録・更新
npx wrangler secret list  --env=""              # 一覧(値は出ない)
npx wrangler secret delete <NAME> --env=""      # 削除

21.5 KV / R2 / Queues

npx wrangler kv key put    --binding=CACHE "key" "value" --env=""
npx wrangler kv key get    --binding=CACHE "key" --env=""
npx wrangler kv key delete --binding=CACHE "key" --env=""
npx wrangler kv key list   --binding=CACHE --env=""

npx wrangler r2 object put    synon-ma-assets/path/file.html --file=./f.html
npx wrangler r2 object get    synon-ma-assets/path/file.html
npx wrangler r2 object delete synon-ma-assets/path/file.html

npx wrangler queues list
npx wrangler queues info synon-ma-jobs

21.6 ログ・監視

npm run tail                              # 本番ログをストリーム
npx wrangler tail --status error          # エラーのみ
npx wrangler tail --env staging --format json
npx wrangler ai models                    # Workers AI の利用可能モデル
npx wrangler whoami                       # ログイン中のアカウント

改訂履歴

日付 内容
v1.0 2026-08-15 新規作成

本手順書の前提と限界

  • 料金・上限値は 2026年8月時点の Cloudflare 公式ドキュメントに基づく。変わりうる。 課金判断の前に必ず公式ページで確認すること
  • Workers AI のモデルIDは変わりうる。実装前に wrangler ai models で現在の一覧を確認すること
  • Secrets Store はオープンベータ。GA前に仕様が変わる可能性がある
  • Cron Trigger 数の上限について、公式ドキュメント内に「アカウント単位」と「Worker単位」の表記の混在がある。多数の Cron を使う場合は事前に実機で確認すること
  • 本書のコマンドは、実行前に必ずステージングで通すこと