SwingCompassAI · 本番 · 2026-10-16〜17

SwingCompass 本番修正記録

本番デプロイの残作業中に見つかった不具合と、その修正の記録です。コードの修正は 3 件、決定事項の記録は 2 件、残りは設定の変更です。各項目に、症状・原因・修正・確認の結果を載せています。

本番の構成と、直した場所

flowchart LR
  U["利用者のブラウザ
swing.aicompass-golf.com"] --> FE["Vercel
frontend (Next.js 15)"] AD["管理者"] --> AF["Vercel
admin-frontend"] FE -- "CORS ③" --> API["Fly: swingcompass-api-prod
FastAPI"] AF --> API API -- "② 起動時にメール設定を検査" --> RS["Resend
noreply@aicompass-golf.com"] API -- "① パスワード" --> R[("Fly: swingcompass-redis-prod")] WK["Fly: swingcompass-worker-prod
Celery"] -- "①" --> R API --> DB[("Neon Postgres")] WK --> DB API --> S3[("Cloudflare R2")] WK --> S3 DNS["Cloudflare DNS
CNAME swing ③"] -.-> FE

① Redis の起動コマンド(6861a62) ② メール設定ガード(PR #11) ③ 設定のみの変更。ログイン停止(b9a2c57)は frontend の中だけの修正です。

1
6861a62infra/fly/redis/fly.tomlmain・本番反映・確認済み

Redis のパスワードが展開されていなかった

症状

Redis を使う処理が、すべて AuthenticationError で 500 になっていました。パスキーの challenge 保存(backend/app/routers/passkey.py:165)と、Celery への解析投入です。

原因

Fly の [processes] のコマンドはシェルを通らずに実行されるため、${REDIS_PASSWORD} が展開されません。この文字列そのものが Redis のパスワードになっていました。API と worker の REDIS_URL には本物のパスワードが入っているので、認証が合いませんでした。

  • 実測: 文字列 ${REDIS_PASSWORD} で redis-cli -a すると PONG、secret の値では WRONGPASS
修正
infra/fly/redis/fly.toml
@@ -10,11 +10,14 @@ # 起動コマンド+# Fly は process コマンドをシェルを通さず実行し ${VAR} を展開しない。+# 直書きすると "${REDIS_PASSWORD}" という文字列がパスワードになる (2026-10-16 本番で発生)。+# 必ず sh -c で包んで環境変数を展開させる。 [processes]-  app = "redis-server --requirepass ${REDIS_PASSWORD} --appendonly yes --dir /data"+  app = "sh -c 'exec redis-server --requirepass \"$REDIS_PASSWORD\" --appendonly yes --dir /data'"
確認
  • Redis を再デプロイした後、API から ping → True
  • 文字列 ${REDIS_PASSWORD} での認証 → 失敗
  • パスキーのログイン開始(options)→ 200
2
b9a2c57frontend/src/app/auth/login/page.tsxmain・本番反映・確認済み

ログイン後に「LOGGING IN...」で止まる

症状

swing.aicompass-golf.com を初めて開いてログインすると、ボタンが「LOGGING IN...」のまま止まりました。ログインの API 自体は成功していて(トークン保存済み・/auth/me 200)、再読み込みするとダッシュボードが開きました。

原因

ログイン画面が表示されたときに /dashboard を router.prefetch していました。その時点では sca_token cookie が無いので、middleware(frontend/src/middleware.ts:12-16)が先読みを /auth/login?redirect=/dashboard にリダイレクトし、Next.js(15.1.9)がその結果をキャッシュします。ログイン後の router.push('/dashboard') はキャッシュを使って通信せず、ログイン画面に留まります。pending は成功時に戻さない作りなので、ボタンの表示がそのまま残りました。

sequenceDiagram
  participant B as ブラウザ(ログイン画面)
  participant RC as Router のキャッシュ
  participant MW as middleware
  participant API as API
  B->>MW: 表示時に prefetch /dashboard(cookie なし)
  MW-->>RC: /auth/login?redirect=/dashboard へリダイレクト
  Note over RC: この結果を /dashboard 用に保持
  B->>API: POST /auth/login
  API-->>B: 200(トークン)→ cookie sca_token を保存
  B->>RC: router.push('/dashboard')
  RC-->>B: キャッシュを返す → URL は /auth/login のまま
  • 本番で再現: cookie なしでログイン画面を開くと、/dashboard への通信は先読みの 1 回(104 ms)だけでした。cookie を戻して push しても 5 秒後にログイン画面のままで、router.refresh() の後に push すると /dashboard に移りました
  • next dev は prefetch しないので、ローカルでは起きません
修正
frontend/src/app/auth/login/page.tsx
@@ -56,12 +56,6 @@     return raw.startsWith("/") && !raw.startsWith("//") ? raw : "/dashboard";   }, [searchParams]); -  // 遷移先を事前コンパイル/プリフェッチしておき、ログイン直後のちらつきを防ぐ-  useEffect(() => {-    router.prefetch(redirectTo);-    router.prefetch("/dashboard");-  }, [router, redirectTo]);-
frontend/src/__tests__/app/auth/login.test.tsx(回帰テスト・抜粋)
// src/middleware.ts の PROTECTED_PATHS と同じconst PROTECTED_PATHS = ["/dashboard", "/sessions", "/admin", "/my-coach"]; it.each([  ["redirect 指定なし", ""],  ["redirect=/sessions/123", "redirect=/sessions/123"],])("ログイン前に保護ページを prefetch しない (%s)", (_label, search) => {  mockSearch = search;  // render は act で包まれるので、戻った時点でマウント時の effect は実行済み  render(<LoginPage />);  expect(prefetchedProtectedPaths()).toEqual([]);});
確認
  • 回帰テスト: 修正前のコードで 2 件とも失敗(受け取った値 ["/sessions/123", "/dashboard"])→ 修正後に通過。frontend の jest は 67 件すべて通過
  • Codex レビュー: 指摘 0 件
  • 本番(Vercel の production、commit b9a2c57): cookie なしでログイン画面を開いて 3 秒間、/dashboard への通信は 0 件。cookie を戻して push すると、5 秒後に /dashboard
  • 利用者として、ログアウト → パスキーでログインしてダッシュボードまで移れた

変わる点: ログイン後の /dashboard の読み込みは、ボタンを押した後に始まります。その間は「LOGGING IN...」の表示が続きます。

3
PR #11bcfae3abackendPR・本番デプロイ前

本番でメールが送れない設定なら API を起動しない

症状

10/16、本番で新規登録しても確認メールが届きませんでした。本番 API に EMAIL_PROVIDER が設定されておらず、既定値の console(backend/app/config.py:242)のままでした。console はメールを送らず、本文をログに書くだけなので、確認トークン入りの本文が本番のログに出ていました。

当日の対処は Fly secrets の設定だけです(EMAIL_PROVIDER=resend ほか)。この PR は、同じ設定ミスが再び起きたときに止めるためのものです。

置き場所

本番 worker は ENVIRONMENT=production ですが、EMAIL_PROVIDER を持っていません(未設定なので console 扱い)。共通の Settings の validator に入れると worker が起動しなくなるので、API の lifespan にだけ置きました。worker はメールを送らず、app.main も import しません(scripts/worker_entry.py → app.celery_app)。

修正
backend/app/services/email_service.py
+def assert_email_configured_for_production() -> None:+    """本番で送信できない EMAIL_PROVIDER 設定なら API を起動させない。"""+    if settings.ENVIRONMENT != "production":+        return+    # _send_email と同じ判定にそろえる (ここだけ緩めると起動後に送信で落ちる)+    provider = settings.EMAIL_PROVIDER.lower()+    if provider != "resend":+        raise RuntimeError(+            f"本番では EMAIL_PROVIDER=resend が必要です (現在: '{settings.EMAIL_PROVIDER}')。"+            " console のままだとメールは送られず、本文がログに出ます"+        )+    if not settings.RESEND_API_KEY.strip():+        raise RuntimeError("本番では RESEND_API_KEY の設定が必要です (EMAIL_PROVIDER=resend)")
backend/app/main.py
 async def lifespan(app: FastAPI):-    # 起動時: DBテーブルを自動作成+    # 起動時: 本番でメールが送れない設定なら、DB に触る前に止める+    assert_email_configured_for_production()+    # DBテーブルを自動作成     await init_db()
backend/requirements.txt(本番イメージに入っている版に固定)
-google-genai>=1.0.0+google-genai==1.55.0-rtmlib>=0.0.13+rtmlib==0.0.15

保証するのは「resend が選ばれ、キーが入っている」ことまでです。キーの有効性と送信元ドメインの検証状態は見ません。送信に失敗しても send_* がログ(Failed to send email)に書くだけで、呼び出し元には返りません。

テスト

backend/tests/test_email_guard.py: ガード単体 7 件と、fastapi_app.router.lifespan_context を通すテスト 2 件。既存の client fixture は ASGITransport なので lifespan を実行しません。lifespan からの呼び出しを消すと、このテストが 1 件失敗することを確かめました。

同じ条件(docker run)で全テスト失敗成功skip
main123013
このブランチ123103

失敗している 12 件は main と同じテストです。test_passkey 4 件、test_sso 3 件、test_phase2〜4 の DB 保存 4 件、test_storage_service の署名付き URL 1 件。

レビュー
  • 実装前の仕様を Codex がレビュー: P1 2 件・P2 8 件。8 件を取り込み、2 件は見送り(ENVIRONMENT の大文字小文字の揺れ: 既存の JWT ガードも完全一致で判定し、値は infra/fly/api/fly.toml に固定。alembic stamp head がガードより先に走る: 今も起動のたびに走っている既存の動き)
  • 実装後のブランチを Codex がレビュー: 指摘 0 件
デプロイ

API のマシンは 1 台で、今のイメージは 2026-09-04 のビルドです。app/・alembic/ の .py 237 件は、改行コードをそろえると本番と main で一致しました。次の順で入れます(DEPLOY.md §8)。

  • fly deploy --build-only --push でイメージだけを作る
  • 稼働中のイメージと、pip freeze(116 件)・Python の版(3.11.16)・/app のファイル一覧(401 件・326.0 MB)を比べる。差分が 1 件でもあれば投入しない
  • 確かめたのと同じイメージを --strategy bluegreen で入れる。新しいマシンが health check を通るまで、古いマシンが残る
  • 戻すときは今のイメージ deployment-01M1PF7ADZ5C5YY8MZ6AQR72V3 を指定する。このイメージにはガードが無いので、戻す前にメールの設定を確かめる
4
コードの変更なし本番反映済み

設定だけの変更

目的場所変更
CORSFly swingcompass-api-prod secretsALLOWED_ORIGINS に swingcompass.vercel.app・swingcompass-admin.vercel.app・swing.aicompass-golf.com
メール送信同上EMAIL_PROVIDER=resend、RESEND_API_KEY(送信専用・ドメイン限定)、EMAIL_FROM_ADDRESS=noreply@aicompass-golf.com、EMAIL_FROM_NAME=SwingCompassAI
メール内リンク同上FRONTEND_URL=https://swing.aicompass-golf.com、ADMIN_FRONTEND_URL=https://swingcompass-admin.vercel.app
パスキー同上WEBAUTHN_RP_ID=aicompass-golf.com、WEBAUTHN_ALLOWED_ORIGINS=https://swing.aicompass-golf.com(既定値は localhost・backend/app/config.py:228)
独自ドメインVercel swingcompassswing.aicompass-golf.com を追加(Let's Encrypt、期限 2027-01-14)
独自ドメインCloudflare DNS(aicompass-golf.com)swing の CNAME を Vercel へ(proxy なし)

決定事項は DEPLOY.md に記録しました: メール送信ドメイン(876f334・§4)、パスキーの親ドメイン(571c42a・§9)。パスキーは独自ドメインで開いたときだけ使え、*.vercel.app では RP ID が一致しないので使えません。

まだ残っていること

項目場所状態
メールの送信に失敗しても、呼び出し元に返らない(ログだけ)backend/app/services/email_service.py の send_*別タスク
PR #11 の本番デプロイ(イメージ比較 → bluegreen)Fly swingcompass-api-prod作業中
main でも失敗しているテスト 12 件backend/tests/未着手