Webサービスのシャットダウン実装完全ガイド|新規停止から410 Goneまで、5フェーズで安全に閉じる手順

約15分で読めます by ぽんたぬき
Webサービスのシャットダウン実装完全ガイド|新規停止から410 Goneまで、5フェーズで安全に閉じる手順

Webサービスのシャットダウン実装完全ガイド|新規停止から410 Goneまで、5フェーズで安全に閉じる手順

はじめに:サービスを「止める」のは、作るより難しい

「ドメインを解約したから終わり」と思っていませんか?

実際の開発現場では、ドメインを落としただけではサービスは完全に終わっていません。Stripeのサブスクリプションが生き続け、ユーザーへの課金が継続したまま——そういった事故は珍しくないのです。また、削除したつもりの個人データがバックアップに残り続けていたり、検索結果に404リンクが散乱してクローラーを悩ませるケースも頻繁に起きます。

この記事では、Cloudflare・Supabase・Stripeを使った小規模SaaSを例に、サービス終了を「実装タスク」として段階的に進める手順を解説します。他のスタックでも考え方は共通なので、適宜読み替えてご活用ください。


サービス終了のフェーズ設計:なぜ一気に止めてはいけないのか

サービス終了を一括で実施すると、以下の3つのトラブルが発生しやすくなります。

  1. 利用中ユーザーのデータ消失クレーム
  2. 課金済み期間が残っているユーザーへの未返金問題
  3. 外部連携先(Webhook受信側)の障害誘発

実際の開発現場では、「閉店作業」と同様に段階的なフェーズ設計がベストプラクティスとされています。

フェーズ 内容 推奨期間
フェーズ1 新規登録・新規決済の停止 告知と同時
フェーズ2 読み取り専用(データエクスポート猶予) 1〜2ヶ月
フェーズ3 データ削除・インフラ解約・410移行 フェーズ2終了後
フェーズ4 事後確認(課金ゼロ・キー失効・410実測) 随時

【手順1】フェーズ1:新規登録を止める

フィーチャーフラグで停止スイッチを用意する

環境変数でフェーズを管理し、アプリのミドルウェアで新規登録エンドポイントを弾く設計が最もシンプルです。

// middleware.js(Next.js / Hono等での例)
const SHUTDOWN_PHASE = parseInt(process.env.SHUTDOWN_PHASE ?? "0");

function shutdownMiddleware(req) {
  const isSignupPath = req.url.includes("/signup") || req.url.includes("/register");

  if (SHUTDOWN_PHASE >= 1 && isSignupPath) {
    return new Response(
      JSON.stringify({ error: "新規登録は終了しました。" }),
      { status: 403, headers: { "Content-Type": "application/json" } }
    );
  }
  return null;
}

module.exports = { shutdownMiddleware };

SHUTDOWN_PHASE=1 に切り替えるだけで新規登録を停止できます。段階的に値を上げていくことで、後のフェーズへの移行も管理しやすくなります。

Supabase Authのサインアップ無効化

ダッシュボードの Authentication → Settings → Enable signups をオフにします。ただし、以下の抜け穴に注意してください。

  • 招待リンク・Magic Linkが有効なまま残る → 招待メール機能も無効化する
  • OAuthプロバイダー経由の登録が通ってしまう → 各プロバイダーの設定も確認する
  • APIを直接叩かれるケース → RLSポリシー側で auth.uid() が既存ユーザーであることを担保する

⚠️ よくあるつまずき:フロントエンドだけ「登録ボタン」を隠してもAPIが生きているため、必ずバックエンド側でもブロックしてください。


【手順2】決済を止める:Stripeの落とし穴

最大の落とし穴:Webhookが送られ続ける

Stripeは、エンドポイントが200系を返すまで指数バックオフでリトライを繰り返す仕様です。サービスを消したままWebhookエンドポイントを削除し忘れると、不達イベントがStripeダッシュボード上に積み上がり続けます。

ステップ2-1:商品・Priceを非公開にして新規購入を止める

import stripe
stripe.api_key = "sk_live_..."

# 全アクティブなPriceを非公開化
prices = stripe.Price.list(active=True)
for price in prices.auto_paging_iter():
    stripe.Price.modify(price.id, active=False)

# 全アクティブなProductを非公開化
products = stripe.Product.list(active=True)
for product in products.auto_paging_iter():
    stripe.Product.modify(product.id, active=False)

ステップ2-2:既存サブスクリプションを解約する

解約方法 特徴 向いているケース
cancel_at_period_end: true 期末解約・返金不要 有料プランが月次更新の場合
即時キャンセル + 日割り返金(prorate) ユーザーへの誠意を示せる 高額プランや年次契約
# 全アクティブサブスクリプションを期末解約
subscriptions = stripe.Subscription.list(status="active")
for sub in subscriptions.auto_paging_iter():
    stripe.Subscription.modify(sub.id, cancel_at_period_end=True)

ステップ2-3:Webhookエンドポイントを安全に閉じる(順序が重要)

正しい順序は以下のとおりです。順序を間違えると再送ループが発生します。

  1. ダミーハンドラに差し替える(署名検証だけしてログを出力し、即200を返す)
  2. 全イベント処理完了を確認(Stripeダッシュボードで未処理イベントがゼロになるまで待つ)
  3. Webhook Endpointを削除する
// ダミーWebhookハンドラ(即200を返すだけ)
app.post("/webhook/stripe", async (req) => {
  const sig = req.headers["stripe-signature"];
  try {
    stripe.webhooks.constructEvent(await req.text(), sig, process.env.STRIPE_WEBHOOK_SECRET!);
    console.log("[SHUTDOWN] Stripe webhook received and acknowledged");
  } catch (e) {
    console.error("[SHUTDOWN] Webhook signature failed:", e);
  }
  return new Response("OK", { status: 200 });
});

【手順3】データ削除とコンプライアンス対応

法律は「消せ」と言っている

法律 削除義務 タイムライン
GDPR Art.17(EU) 利用目的終了後は遅滞なく削除 原則1ヶ月以内
APPI(日本個人情報保護法) 利用の必要がなくなったデータは遅延なく消去・匿名化 執行強化中

「個人データがある場所」は思っているより多い

本番DBだけを削除して終わりではありません。以下の棚卸しが必要です。

  • 本番DB(Supabase / RDS等)
  • ストレージ(Supabase Storage / Cloudflare R2)
  • ログファイル(アクセスログ・エラーログ)
  • 分析ツール(PostHog / Mixpanel等)
  • メール配信サービスの宛先リスト(SendGrid / Resend等)
  • サポートツール(Intercom / Zendesk等)

本番データを削除する

外部キー制約に注意し、子テーブルから先に削除します。また、論理削除(deleted_atフラグ)ではなく物理削除にすることで、「個人データを保持していない」という証明が可能になります。

BEGIN;

-- 削除前に件数を確認
SELECT COUNT(*) FROM user_profiles; -- 例: 1,234件

-- 子テーブルから順に削除
DELETE FROM user_activity_logs;
DELETE FROM user_profiles;
DELETE FROM users;

-- 削除後に確認
SELECT COUNT(*) FROM users; -- → 0件

COMMIT;

バックアップからも消す(最難関)

バックアップに個人データが残っていると「保持している」と見なされる可能性があります。現実的な対応策は以下の3つです。

  1. 保持期間の経過を待って自然消滅させる(期間を文書化して明記する)
  2. バックアップ世代を明示的に破棄する
  3. 匿名化・仮名化して「個人データではない状態」にする

どの方法を選んだかを判断記録として残すことが重要です。「消しました」を証明できない削除は不十分です。


【手順4】HTTPレスポンスで「終わった」ことを表明する

なぜ404ではなく410 Goneなのか

  • 404 Not Found:「一時的に見つからない」→ クローラーがリトライを続ける
  • 410 Gone:「永続的に消滅した」→ 検索エンジンがインデックスを迅速に削除し、リトライを止める

Cloudflare Workersでの実装例

// wrangler.toml でルートを全パスに向けた上で
export default {
  async fetch(req: Request): Promise<Response> {
    const body = `<!DOCTYPE html>
<html lang="ja">
<head><meta charset="UTF-8"><title>サービス終了</title></head>
<body>
  <h1>本サービスは終了しました</h1>
  <p>2026年8月31日をもって、サービスを終了しました。</p>
  <p>お問い合わせ: support@example.com</p>
</body>
</html>`;
    return new Response(body, {
      status: 410,
      headers: { "Content-Type": "text/html; charset=utf-8" },
    });
  },
};

APIエンドポイントもJSON形式で410を返します。

// /api/* 向けのハンドラ
return new Response(
  JSON.stringify({
    error: "gone",
    message: "このAPIは2026年8月31日をもって終了しました。",
    contact: "support@example.com",
  }),
  { status: 410, headers: { "Content-Type": "application/json" } }
);

curlで外部から実測確認する

デプロイ成功はサービス終了成功と同義ではありません。CDNキャッシュやブラウザキャッシュに騙されないよう、必ず外部から実測してください。

# Web画面の確認
curl -I https://example.com/
# → HTTP/2 410 が返ることを確認

# APIの確認(APIバージョンパスも含めて網羅する)
curl -I https://example.com/api/v1/users
# → HTTP/2 410 が返ることを確認

# Webhook受信パスの確認
curl -I https://example.com/webhook/stripe
# → HTTP/2 410 が返ることを確認

【手順5】外部サービスの解約:プロジェクト削除だけでは終わらない

落とし穴:プロジェクトを消してもAPIキーが残る

組織レベルで発行されたトークンやPersonal Access Tokenは、プロジェクトを削除しても生き続けます。これが後日漏洩した場合のリスクは甚大です。

各サービスの解約チェックリスト

## 外部サービス解約チェックリスト

### Cloudflare
- [ ] Workers / Pages のデプロイ削除
- [ ] DNSレコード解除
- [ ] APIトークンの失効(ダッシュボード → My Profile → API Tokens)
- [ ] R2 / KV / D1 などデータストアの削除

### Supabase
- [ ] Edge Functions の削除
- [ ] Database Webhooks の削除
- [ ] Storage バケットの削除(中身ごと)
- [ ] Service Role Key / anon Key の失効確認
- [ ] プロジェクト削除(最後に実施)

### Stripe
- [ ] Restricted Key の削除
- [ ] Webhook Endpoint の削除
- [ ] ダッシュボード通知・アラートの解除
- [ ] チームメンバー権限の整理

### GitHub / CI・CD
- [ ] Personal Access Token の失効
- [ ] Deploy Key の削除
- [ ] Actions Secrets の削除
- [ ] Dependabot / 外部CI連携の無効化

### その他SaaS
- [ ] SendGrid / Resend:送信ドメイン・宛先リスト削除
- [ ] Sentry / PostHog:プロジェクト削除・データエクスポート
- [ ] Google Analytics:プロパティ削除

まとめ:サービス終了は「実装タスク」である

段階的に理解を深めていきましょう——サービス終了の本質は、ビジネス判断ではなく実装タスクの集合体です。以下の5原則を押さえておけば、漏れなく閉じることができます。

  1. 段階的に止める:新規停止 → 読み取り専用 → 完全撤去の順で
  2. 決済はWebhookから先に安全化する:ダミーハンドラ → 完了確認 → Endpoint削除の順序を守る
  3. データ削除は証跡を残す:削除前後の件数・日時・実行者を記録する
  4. 終了の宣言は410 Goneで行う:404ではなく410で、Web画面とAPIの両方を確認する
  5. 外部サービスはチェックリストで潰す:プロジェクト削除だけでなく、APIキー・トークン類まで明示的に失効させる

サービスを丁寧に閉じることは、ユーザーへの最後の誠意であり、エンジニアとしての技術的な責任でもあります。本記事のチェックリストをコピーして、漏れのない終了作業を実施してください。

関連記事

BM25でCodexのトークン消費を30%削減する — 7,000ファイル規模で実証したコード検索RAG実践

BM25でCodexのトークン消費を30%削減する — 7,000ファイル規模で実証したコード検索RAG実践 AIコーディングエージェントの月次請求額を見て青ざめた経験はないでしょうか。原因の多くは「エージェントがリポジトリを探し回ること」そのものにあります。本記事では、40年前から使われている古典的アルゴリズムBM25を検索前段に挟むだけで、Codexのトークン消費が1,812,066 → 1,...

Mojo 1.0が正式リリース|Pythonのように書き、C++のように動くAI向け言語の全貌

Mojo 1.0が正式リリース|Pythonのように書き、C++のように動くAI向け言語の全貌

Mojo 1.0が正式リリース|Pythonのように書き、C++のように動くAI向け言語の全貌 メタディスクリプション: Chris Lattner率いるModularのAI特化言語「Mojo」がついに1.0に到達。Pythonとの互換性、圧倒的なパフォーマンス、1.0での変更点、非同期・パターンマッチングを含む今後のロードマップまで、AI/MLエンジニア目線で解説します。 --- AIエンジニア...

Amazon EKS の HPA が最大40倍高速に|Provisioned Control Plane で変わるスケーリング設計

Amazon EKS の HPA が最大40倍高速に|Provisioned Control Plane で変わるスケーリング設計

Amazon EKS の HPA が最大40倍高速に|Provisioned Control Plane で変わるスケーリング設計 フラッシュセールが始まった瞬間、ダッシュボードのエラーレートが急上昇する。「HPA は設定済みのはずなのに、なぜ Pod がまだ増え始めていないのか」——本番運用をしているエンジニアなら、一度は経験したことのある焦りです。 2026年7月、AWS はそのボトルネックに...

MIXIのAI研修2026を全解剖|新卒エンジニアに「何をどこまで」教えているのか

MIXIのAI研修2026を全解剖|新卒エンジニアに「何をどこまで」教えているのか

MIXIのAI研修2026を全解剖|新卒エンジニアに「何をどこまで」教えているのか MIXIは2025年7月27日、2026年度新卒エンジニア向け技術研修12科目の資料・動画・実習リポジトリをすべて無料公開しました。中でも最大の話題を呼んでいるのがAI研修です。前年の1日から2日間へと大幅拡充され、Day1のスライドが339ブクマ、Day2が424ブクマと業界に大きな反響を生んでいます。 本記事で...

コメント

0/2000