/note/tech

Webサービスの終わらせ方

要約:

■ 1. サービス終了の困難さ

  • 作るより消すほうが大変:
    • Webサービスは作成時より終了時のほうが面倒
    • サーバーを止めるだけでは終わらず、多数の撤去作業が残る
  • 終了時に必要な作業:
    • 新規登録停止、課金停止、書き込み停止
    • ユーザー通知、個人データ削除、Webhook停止
    • APIキー削除、DNS削除、リポジトリ整理
  • 複数SaaS統合の代償:
    • 各SaaSは導入が容易な一方、解約時はUIの異なる管理画面を巡回する必要がある
  • 処分対象の技術スタック:
    • インフラはCloudflare Workers、Supabase、GitHub
    • APIはGemini API、監視はSentry、分析はGoogle Analytics
    • 収益はPolarとGoogle AdSense、加えて独自ドメイン

■ 2. 終了方針の決定

  • まず「どう死ぬか」を決める:
    • 終了方針の決定が最初の必須作業
    • 方針がなければ削除の判断ごとに迷い、本番環境で試行錯誤する羽目になる
  • 事前に決めるべき項目:
    • 終了日、新規登録停止日、既存ユーザーに許す操作の範囲
    • 購入済みコンテンツの扱い、データ削除期限、決済履歴の保持有無
    • サポート終了時期、ソースコードの保管方針、本番データのバックアップ有無

■ 3. 段階的な撤退戦

  • 一撃必殺ではなく撤退戦:
    • 一度にすべてを止めると、終了処理自体に必要な機能まで止まる
  • 3段階の停止設計:
    • 第1段階は新しい利用の停止
    • 第2段階は終了日まで読み取り専用を維持
    • 第3段階は完全停止とインフラ撤去
  • 店舗の比喩:
    • 新規客の入場禁止、来客をすべて帰す、在庫処分という順序に相当する

■ 4. 第1段階: 責任を増やさない

  • 新規利用の停止:
    • 新規登録、購入、回答保存、ペアリング、データ更新を止める
    • 終了日後に新規ユーザーからサポート要請を受ける事態を避ける
  • ボタンを消して満足してはいけない:
    • UIはセキュリティ境界ではなく、APIを直接叩けばバイパスされる
    • 玄関に本日休業と貼ったあと、裏口を全開にしている状態に等しい
  • 停止すべきレイヤー:
    • 画面・フォーム、サーバーAction、APIエンドポイント
    • 認証サービス、DB権限(RLS)、決済設定

■ 5. 読み取り専用モードと認証停止

  • 読み取り専用モードの実装:
    • 終了日までは既存ユーザーによるデータ閲覧を許可する
    • 環境変数 SERVICE_READ_ONLY で制御し、書き込みは403で拒否する
    • 読み取り処理と書き込み処理が分離できているかの確認にもなる
  • Authもちゃんと止める:
    • フロント側のサインアップ画面を消しても、認証APIが生きたままでは不十分
    • 認証APIを直接叩けばフロントエンドの制限をバイパスできる
  • 認証側で行う対策:
    • Supabase側で登録を無効化する
    • RLSと権限設定により一般ユーザーの直接書き込みを拒否する
  • 権限剥奪のやりすぎに注意:
    • 終了するからと全権限を剥奪すると、終了処理自体が実行できなくなる

■ 6. ユーザーへの終了通知

  • 通知に含める情報:
    • 終了日、新規登録・購入の停止、終了日まで使える機能
    • アクセス不可となる時点、問い合わせ先、データの扱い
  • 通知内容の記録:
    • 人間の記憶はログではなく、終了作業中はとりわけ信用できない

■ 7. 完全停止用PRの事前準備

  • 当日にコードを書かない:
    • 終了日当日に本番環境で緊急にコードを書くリスクを避け、PRを事前に用意する
  • 停止時のレスポンス設計:
    • Web画面は終了案内ページ、APIはJSON形式のエラーレスポンスを返す
    • ステータスコードは410 Goneとする
    • キャッシュ制御は no-store、SEO設定は noindex, nofollow とする

■ 8. スケジュール実行の不確実性

  • GitHub Actionsのcronは時刻を保証しない:
    • 予約実行が予定時刻から大幅に遅延した
    • cronに23:59と書いても、cronは約束を覚えていない
  • 代替手段:
    • 事前実行で余裕を持たせる、外部スケジューラーを使う、当日手動で実行する
    • アプリケーション自体で時刻を判定する、CDN・DNS側で設定する

■ 9. デプロイ成功とサービス停止は別

  • CI成功でもサービスが止まらない:
    • 停止処理をReact Routerのmiddlewareに実装した
    • 設定が v8_middleware: false であり、機能自体が無効化されていた
    • コードは存在し、型チェック・テスト・ビルド・デプロイはすべて成功したが実行されなかった
  • 確認すべき段階:
    • PRのマージ、ビルド成功、デプロイ成功
    • 新バージョンへの経路確保、停止処理の実行、外部から見た期待レスポンス
  • 緑のチェックマークの限界:
    • 緑色のチェックマークは心を落ち着かせるが、本番環境を止めはしない

■ 10. curlによる最終確認

  • 最後に信じられるのはcurl:
    • 管理画面の状態表示ではなく、HTTPレスポンスを直接確認する
    • 外からcurlして死んでいたら死んでいる、観測可能な事実のみを信頼する
  • 確認方法:
    • curl -I でヘッダを確認し、curl -i で詳細を確認する
  • 期待する結果:
    • 410 Gone、SERVICE_CLOSEDエラー、no-storeのキャッシュ制御、noindexのロボット指示
  • 接続不能の確認対象:
    • ルートドメインとwwwサブドメインが解決不可であること
    • Worker直接URLとAPI・Webhook URLが接続不可であること

■ 11. インフラ撤去の順序

  • 撤去は順序が重要:
    • 前後関係を誤るとサービス終了そのものに支障が出る
  • 決済Webhookを最初に止める:
    • アプリケーション削除後も決済サービスがPOSTを再送し続ける
    • 商品を販売停止または非公開にし、Checkoutを止め、Webhookを削除する
    • 決済履歴は会計と問い合わせ対応に必要で、ユーザーデータと同じ保存方針では扱わない
  • Workerとドメインの削除:
    • Cloudflare Worker、Custom Domain、Worker Route、DNSレコードを削除する
    • Worker用のVariablesとSecrets、GitHubの自動ビルド連携も削除する
    • ドメインの自動更新停止は即時消滅を意味せず、契約期限まで存続する
    • 即時停止にはDNSとRouteの削除が必要
  • ユーザーデータの削除:
    • Authユーザー、回答データ、利用履歴、購入権限、Storage内ファイルを削除する
    • 本番ユーザーデータのバックアップは取らない方針を採り、一応ダンプしておく誘惑に抵抗する
    • 削除日時、削除前後の件数、Authユーザー0件、Storage空、バックアップ未実施の判断を記録する
    • 最後にSupabaseプロジェクト自体を削除し、確認ダイアログでプロジェクト名を入力する
  • APIキーを一つずつ失効させる:
    • Gemini APIキー、Sentryプロジェクト、Google Analyticsプロパティ、AdSenseサイト設定を処理する
    • Cloudflare Secrets、Supabase Personal Access Token、GitHub ActionsのSecretsとVariablesを処理する
    • プロジェクト削除は認証情報の自動失効を意味せず、明示的な失効が必須
    • ほぼ無害だろうという判断が危険を招く
  • GitHubリポジトリを墓石にする:
    • 本番の秘密情報を無効化し、Actions SecretsとVariablesを削除する
    • 不要なWebhookとDeploy keyを削除し、予約実行Workflowを無効化する
    • ローカルファイルを整理し、リポジトリを非公開にしたうえでArchiveする
    • DB構造とマイグレーションは参照・監査用にGitへ残し、本番ユーザーデータは残さない

■ 12. 敗戦処理チェックリスト

  • チェックリストの6区分:
    • 方針、事前停止、最終停止、データ削除、外部サービス、最終整理の順に並ぶ
  • 方針の項目:
    • 最終終了日、新規登録・購入停止日、既存ユーザーに残す機能の明示
    • 購入済みコンテンツの扱い、データ削除期限、バックアップ方針、問い合わせ先
  • 事前停止の項目:
    • 新規登録と新規購入の停止、書き込みUIと書き込みAPIの停止
    • 認証サービス側の登録停止、DB権限とRLSの確認、対象ユーザーへの終了通知
  • 最終停止の項目:
    • 停止用変更の本番反映、WebとAPIのレスポンス確認
    • 決済Webhook停止、Worker・サーバー削除、DNSとCustom Domainの削除
    • 外部ネットワークからの接続不能確認
  • データ削除の項目:
    • Authユーザー削除、DB個人データ削除、Storage削除
    • 削除結果の記録、本番バックアップ方針の確認、DBプロジェクト削除
  • 外部サービスの項目:
    • 決済商品とWebhookの停止、AI用APIキーの失効、エラー監視プロジェクトの削除
    • アクセス解析の削除、広告設定の削除、CI/CD Secretsの削除、Personal Access Tokenの失効
  • 最終整理の項目:
    • ドメイン自動更新停止、ローカル秘密情報の削除
    • リポジトリの非公開化とArchive、問い合わせ先の維持、完了日時の記録
  • チェックリストの位置づけ:
    • サービス終了は機能開発と同等の正式な工程管理を要する

■ 13. まとめ

  • サービス終了の本質:
    • 重要なのはコードを止めることではなく、責任を順番に片付けること
  • 痛感した3点:
    • 新規利用の停止はUI・API・認証・DBのすべてのレイヤーで行う
    • 完了の判断はマージやデプロイの成功ではなく本番のHTTPレスポンスで行う
    • ユーザーデータ、決済履歴、ソースコードはそれぞれ別の方針で扱う
  • 個人開発観の転換:
    • 失敗したら閉じればいいという理解は誤り、サービス終了にも実装が必要
    • 個人開発はデプロイまでではなくArchiveまで
  • 最終的な教訓:
    • 緑のチェックマークで満足せず、curlで検証する
    • 終了日に元気に動作していたサービスが、この逆説的な教訓を残した

MEMO: