メインコンテンツまでスキップ

デプロイアーキテクチャ

システム構成

ブランチ戦略

GitHub Flow を採用。main ブランチが唯一のトランク。

API は常設の Staging 環境(subseek-api-staging)を持ち、PR ごとに最新のコードがデプロイされる。 Web は Vercel Preview で PR ごとに自動デプロイされる。staging ブランチは持たない。

なぜ常設 Staging 環境を持つのか

  • Better Auth ダッシュボード: プロジェクト接続に固定の Base URL が必要。動的 PR Preview URL では接続できない
  • 環境変数の安定性: wrangler secret で一度設定すれば PR ごとの再設定が不要
  • ブランチ戦略は GitHub Flow のまま: staging ブランチは導入せず、PR 作成時に常設 Staging にデプロイする
  • 1人開発: 複数 PR が同時にある場合は最後にデプロイされた PR の内容が Staging に反映される

アプリ名・URL 一覧

Production

コンポーネントアプリ名URL状態
Vercel (Web)subseekhttps://subseek.ccデプロイ済み
Cloudflare Workers (API)subseek-apihttps://subseek-api.workers.devデプロイ済み
Meilisearch (Hetzner US)Hetzner VM(Production ポート)デプロイ済み
Cloudflare R2subseek-backups未作成(v0.3 #77)
Turso (DB)subseeklibsql://subseek-saneatsu.aws-us-east-1.turso.io作成済み

Staging

常設の Staging 環境。PR 作成時に API が自動デプロイされる。Web は Vercel Preview で PR ごとに自動デプロイ。

コンポーネントアプリ名URL状態
Vercel (Web)subseek-<hash>-nito.vercel.app(PR ごとに動的)Vercel Preview デプロイ(GitHub 連携で自動)
Cloudflare Workers (API)subseek-api-staginghttps://subseek-api-staging.workers.devデプロイ済み
Meilisearch (Hetzner US)Hetzner VM(Staging ポート)(Production と同一 VM、別ポート)デプロイ済み
Turso (DB)subseek-previewlibsql://subseek-preview-saneatsu.aws-us-east-1.turso.io作成済み

デプロイ済みサービスの詳細

Meilisearch(Hetzner US セルフホスト)

Hetzner US(Ashburn)の VPS で Docker セルフホスト。詳細は 検索インフラ調査 および Meilisearch ホスティング比較 を参照。

  • Docker コンテナで起動(getmeili/meilisearch:v1.13
  • Production(127.0.0.1:7700)/ Staging(127.0.0.1:7701)を同一 VM 上で別コンテナとして運用
  • VM: Hetzner CPX11(Shared, Regular Performance)、IPv4: <HETZNER_VM_IP>
  • API への公開は Cloudflare Tunnel 経由(https://meili.subseek.cc / https://meili-staging.subseek.cc)。Workers の HTTP outbound 制約を回避するため、直 IP + 平文 HTTP は使用しない
  • セットアップ手順: Meilisearch セットアップ
  • BAN リスク対策: Meilisearch ダンプを毎日 R2 に自動保存(v0.3 #77)

注意: infra/meilisearch/fly.toml は旧 Fly.io セルフホスト時の設定ファイル。現在は使用しないが、参考として残している。

Hono API(Cloudflare Workers)

環境URLヘルスチェック
Productionhttps://subseek-api.workers.dev/{"message":"subseek API"}
Staginghttps://subseek-api-staging.workers.dev/{"message":"subseek API"}
  • ランタイム: Cloudflare Workers(V8 isolate)
  • 同一 Worker が fetch(HTTP)と queue(Cloudflare Queues Consumer)の 2 種類のハンドラを export

字幕取得バッチ(Cloudflare Queues + Durable Object)

旧 in-memory SubtitleFetchQueue は Workers の isolate eviction で進捗が消失する問題があり、 v0.1 で Cloudflare Queue + Durable Object ベースに置換した(#328)。

コンポーネントリソース名
字幕取得 Queue (production)subseek-subtitle-fetch (DLQ: subseek-subtitle-fetch-dlq)
字幕取得 Queue (staging)subseek-subtitle-fetch-staging (DLQ: subseek-subtitle-fetch-staging-dlq)
チャット取得 Queue (production)subseek-chat-fetch (DLQ: subseek-chat-fetch-dlq)。Consumer 未配線(フォローアップ)
チャット取得 Queue (staging)subseek-chat-fetch-staging (DLQ: subseek-chat-fetch-staging-dlq)
進捗 DO (production / staging)SubtitleFetchProgressDO(チャンネル別に instance、SQLite migration v1

データフロー:

  1. /api/search がチャンネルを指定して呼ばれると enqueueChannelFetch
    • DO の進捗 state を total = videos.length で初期化
    • 動画 ID を 100 件ごとに SUBTITLE_FETCH_QUEUE.sendBatch で投入
    • live_archive のみ CHAT_FETCH_QUEUE にも投入
  2. 同 Worker の queue export が batch(最大 5 件 / 10 秒)で受け取り、 YouTube transcript fetch → Meilisearch upsert → DB / DO 更新
  3. SSE /channels/:id/subtitles/progress は DO を 2 秒間隔で polling して progress / complete event を配信
  • 互換性フラグ: nodejs_compat, nodejs_compat_populate_process_env
  • 設定ファイル: apps/api/wrangler.toml

Next.js Web(Vercel)

環境URL状態
Productionhttps://subseek.cc200 OK
  • フレームワーク: Next.js(自動検出)
  • Root Directory: apps/web
  • GitHub 連携: nito-tech/subseek リポジトリ

ドメイン

  • ドメイン: subseek.cc(取得済み)
  • DNS 管理: Cloudflare DNS で設定
  • Fly.io はデフォルトドメイン(*.fly.dev)を使用

なぜ API は api.subseek.cc ではなく subseek-api.workers.dev なのか

API の URL はフロントエンドが内部的にリクエストするだけで、ユーザーに直接見えることはない。そのため、カスタムドメインを設定するメリット(ブランド統一)よりも、設定の手間を省く方が合理的と判断した。

必要になった時点で api.subseek.cc を Workers カスタムドメインとして追加することは可能。NEXT_PUBLIC_API_URL 環境変数を変更するだけで対応できる。

CI/CD パイプライン

GitHub Flow を採用。PR 1 本につき 1 つの workflow(ci.yml)で CI → Staging Deploy → E2E を直列に実行する。CI が失敗した PR では Vercel build / Workers デプロイが走らないため、無駄なリソース消費を防ぐ。

Meilisearch インフラは Hetzner US での Docker セルフホストで、CI/CD の管理対象ではない(手動運用)。

paths-filter の監視対象

detect ジョブは dorny/paths-filter で PR の差分を検査し、対応する deploy ジョブ/ステップを起動する。

対象トリガーパス
API (deploy-api)apps/api/**, packages/**, pnpm-lock.yaml, .github/workflows/**
Web (deploy-web)apps/web/**, packages/**, pnpm-lock.yaml, .github/workflows/**
Meilisearch インデックス設定apps/api/src/lib/meilisearch/subtitles-index/**, apps/api/src/lib/meilisearch/scripts/**, .github/workflows/**(→ deploy-api 内で pnpm meilisearch:init を実行)
DB マイグレーションpackages/db/migrations/**(→ deploy-api 内で drizzle-kit migrate を実行)

各トリガーパスの中身は次の通り:

パス内容
apps/api/**Hono API(Cloudflare Workers)のソースコード一式
apps/web/**Next.js Web のソースコード一式
packages/**モノレポ共通パッケージ(@subseek/db の Drizzle スキーマ、@subseek/shared の型・ユーティリティ等)。API と Web の両方が依存しているため両方を再デプロイ対象にしている
pnpm-lock.yamlpnpm のロックファイル。依存関係更新を検知して再デプロイする
.github/workflows/**CI/デプロイ workflow 自体。これも監視対象に含めることで、workflow を編集した PR を staging で動作確認できる
apps/api/src/lib/meilisearch/subtitles-index/**setupSubtitlesIndex() と設定定数(filterableAttributes 等)。検知すると deploy-api ジョブ内で pnpm meilisearch:init を実行し、Meilisearch の設定を反映する(下記「Meilisearch インデックス設定のデプロイ」参照)
apps/api/src/lib/meilisearch/scripts/**Meilisearch 初期化スクリプト
packages/db/migrations/**Drizzle ORM のマイグレーション SQL。検知すると deploy-api ジョブの先頭で drizzle-kit migrate を staging DB に対して実行する

Meilisearch インフラ: infra/meilisearch/**(旧 fly.toml)は現在 CI のトリガー対象外。Meilisearch は Hetzner US の Docker セルフホスト運用で、バージョンアップ・VM 切替などは VM に SSH して手動で対応する(Meilisearch セットアップ)。fly.toml は旧 Fly.io セルフホスト時の設定で参考として残している。

ワークフロー一覧

ファイルトリガー役割
ci.ymlPR 作成/更新lint/typecheck/vitest/knip → CI 全 success 後に paths-filter → staging deploy → E2E までを統合実行
ci-reusable.ymlworkflow_calllint/typecheck/vitest/knip の 4 並列ジョブ
e2e-reusable.ymlworkflow_callPlaywright E2E(base-url input を受け取る)
deploy-production.ymlmain への push(PR マージ)paths-filter → API(Workers)/Web(Vercel)/Meilisearch インデックス設定の本番反映

運用ルール

  • CI ゲート: staging deploy は CI(lint/typecheck/vitest/knip)が全 success のときのみ走る。1 つでも失敗すると detect / deploy / e2e はすべて skip される
  • Draft PR: CI も staging deploy も全 skip。レビュー前の WIP を実インフラに流さないため
  • Web(Vercel): PR ready_for_review 後に Vercel CLI でデプロイし、固定エイリアス staging.subseek.cc に上書き割り当て。main マージで Production (subseek.cc) にデプロイ
  • API(Cloudflare Workers): PR ready_for_review 後に wrangler deploy --env staging で常設 Staging にデプロイ、main マージで wrangler deploy で Production デプロイ
  • Meilisearch インフラ: Hetzner US で Docker セルフホスト。通常デプロイ不要(バージョンアップ・VM 切替時のみ手動)
  • Meilisearch インデックス設定: subtitles-index.ts 等を変更した PR で deploy-api 内の init ステップが自動実行される(下記「Meilisearch インデックス設定のデプロイ」参照)

Meilisearch インデックス設定のデプロイ

Meilisearch に関しては「インフラ」と「インデックス設定」の2 層を区別して管理する。

対象デプロイ手段トリガー(paths-filter)
インフラDocker コンテナ、VM、ポート、バージョン手動(Hetzner VM 上で docker run / 旧 flyctl deploy -c infra/meilisearch/fly.tomlinfra/meilisearch/**
インデックス設定searchableAttributes / filterableAttributes / sortableAttributespnpm --filter @subseek/api run meilisearch:init を CI で自動実行apps/api/src/lib/meilisearch/subtitles-index/**

挙動

setupSubtitlesIndex()apps/api/src/lib/meilisearch/subtitles-index/subtitles-index.ts)は冪等な関数で、内部で:

  1. インデックスが無ければ createIndex({ primaryKey: "id" })
  2. updateSettings()searchableAttributes / filterableAttributes / sortableAttributes を一括適用し、.waitTask() で Meilisearch のタスク完了を待つ

embedders / rankingRules / synonyms などは送信しないため、Meilisearch ダッシュボードで手動設定した値は維持される(PATCH セマンティクス)。

CI/CD での配置

deploy-api ジョブ内で、以下の順で実行する:

1. DB migrations                  (db 検知時のみ)
2. meilisearch:init (meilisearch-settings 検知時のみ) ← waitTask() で完了ブロック
3. wrangler deploy (api 検知時のみ)

meilisearch:initwrangler deployに置くことで、新しい API コードが立ち上がる時点で必ず対応するインデックス設定が揃っている状態を保証する(filter 属性追加時の race condition 回避)。

属性「追加」と「削除」の運用ルール

操作順序実現方法
追加(例: publishedAt を filterable に)init → deploy1 つの PR で設定定義と API コード変更をまとめてよい。CI が正しい順で適用する
削除(例: 使わなくなった属性を filterable から外す)deploy → init2 つの PR に分割する: (1) API コードから参照を削除して deploy → (2) 設定定義から属性を削除して init

削除を 1 PR で行うと、init が先に走って設定から属性が消えた直後に古い API が参照して 400 を返す race が生じる。

ダウンタイム / パフォーマンス影響

  • updateSettings() 中も Meilisearch は検索を停止しない(ダウンタイムゼロ)
  • 属性変更があると Meilisearch は内部的に再インデックスを走らせる。現 subseek 規模では数秒〜数十秒で完了し、UX に知覚できる影響は出ない
  • paths-filter で「設定定義ファイルが変わった PR のみ」トリガーするため、通常の API コード変更では再インデックスは発生しない

手動実行(緊急復旧・初回セットアップ)

# ローカル(.env 経由)
cd apps/api && pnpm meilisearch:init

# staging / production(環境変数を明示)
cd apps/api && \
MEILISEARCH_HOST=https://meili.subseek.cc \
MEILISEARCH_API_KEY=<master-key> \
tsx src/lib/meilisearch/scripts/init-subtitles-index.ts

必要な GitHub Secrets

Secret 名用途
STAGING_MEILISEARCH_HOSTstaging Meilisearch の URL(https://meili-staging.subseek.cc、Cloudflare Tunnel 経由)
STAGING_MEILISEARCH_API_KEYstaging Meilisearch の master / admin key
PROD_MEILISEARCH_HOSTproduction Meilisearch の URL(https://meili.subseek.cc、Cloudflare Tunnel 経由)
PROD_MEILISEARCH_API_KEYproduction Meilisearch の master / admin key

技術選定の背景

なぜ Vercel(Web)なのか

  • Next.js の開発元で互換性 100%、ゼロコンフィグデプロイ
  • next-intl(7言語対応)の動作が保証されている
  • PR 連動の Preview デプロイが標準機能
  • GitHub 連携で CI/CD 設定が不要

なぜ Cloudflare Workers(API)なのか

  • Hono がネイティブサポートするランタイムで、export default app だけでデプロイ可能
  • V8 isolate でメモリ管理・スケーリングが自動(VM の OOM やコールドスタート問題がない)
  • $5/月の Paid プランで 10M リクエスト含む。50 万 PV 以上で Fly.io よりコスト優位
  • Docker ビルド不要で CI が高速
  • Fly.io を使用 → 256MB VM で OOM クラッシュが発生し Workers に移行(2026-04)

なぜ Hetzner US(Meilisearch)なのか

  • Meilisearch Cloud と比べて2〜7倍安い(10万動画で月3,000円 vs 25,500円)
  • 機能差・レイテンシ差なし(同じ US East、同じ Meilisearch 本体)
  • Docker 1コマンドで起動でき、運用は月0.5〜2時間
  • BAN リスクは R2 ダンプバックアップ + Meilisearch Cloud 即移行で対策
  • 詳細は Meilisearch ホスティング比較 を参照

なぜプライマリリージョンは US East(ewr)なのか

subseek の対応言語圏(日本語・英語・中国語・韓国語・スペイン語・フランス語・ドイツ語)の YouTube 利用者数を国別に集計し、各リージョンへの RTT で加重平均を算出した結果、US East(ewr)が最適と判断した。

プライマリカバー人口(RTT < 100ms)加重平均書き込みレイテンシ
東京(nrt)1.72億(日本・韓国・台湾・香港)~155ms
US East(ewr)5.61億(米・加・墨・英・仏・独・西)~72ms

iad は nrt の 3.3倍の人口 を低レイテンシでカバーできる。

設計判断

  • API・DB・Meilisearch はすべて同一リージョン(iad)に配置する: API↔DB 間は 1 リクエストで複数回アクセスするため、ここのレイテンシが最もクリティカル
  • Turso のプライマリロケーションは作成後に変更できない(不変): read replica をプライマリに昇格させる機能も存在しないため、データが少ないうちに最適なリージョンで作成した
  • 将来のマルチリージョン化: ユーザー増加時に東京(nrt)に Fly.io マシン + Turso read replica を追加し、日本・韓国ユーザーのレイテンシを改善する方針
  • Meilisearch(Hetzner US Ashburn): Fly.io API(ewr)と同じ US East。RTT 5〜20ms

なぜ iad(Virginia)ではなく ewr(New Jersey)なのか

Fly.io の iad リージョンでは 2025年2月後半からボリューム付きマシン作成時に insufficient CPUs エラーが継続しており(Fly.io Community)、Meilisearch のデプロイが不可能だった。ewr は同じ米国東海岸に位置し、Turso の US East(Virginia)までの RTT は ~3-5ms と十分近いため、代替リージョンとして採用した。

なぜ Cloudflare Workers ではないのか

  • Meilisearch のセルフホストが不可能(Docker 非対応)
  • API を Workers に置くと Meilisearch(Fly.io)との間にネットワークホップが増加
  • 収益に対するサーバー代比率が 1% 以下と微小なため、コスト差よりも運用のシンプルさを優先

疎通確認結果(2026-04-09)

経路結果備考
Web → API(CORS)OKaccess-control-allow-origin: https://subseek.cc
API → Turso DBOKProduction / Staging 両方
API → Meilisearch(Hetzner、Cloudflare Tunnel 経由)OKhttps://meili.subseek.cc / https://meili-staging.subseek.cc/health で確認
Google OAuth要調査/api/auth/sign-in/social が 500 エラー
API → R2N/Av0.3(#77)で導入予定
Stripe 決済N/AAPI 側ハンドラー未実装(#145)