🪦

Webサービスの終わらせ方

に公開2

作るより消すほうが大変

Webサービスは、作るときより終わらせるときのほうが面倒です。
少なくとも私はそうでした。
作り始めたころは、

「Cloudflare Workers便利!」
「Supabase最高!」
「決済もSaaSでいける!」
「AIも載せちゃおう!」
「監視も入れとくか!」

と、現代Web開発の恩恵を浴びながら、楽しそうに外部サービスを増やしていました。

数か月後。

サービスを閉じることになった私は、それらの管理画面を一つずつ巡回しながら、
「全部俺が登録したんだった」
という当たり前の事実に直面することになります。

Webサービスの終了は、サーバーを停止すれば終わりではありません。

新規登録を止める。
課金を止める。
書き込みを止める。
ユーザーに通知する。
個人データを消す。
Webhookを止める。
APIキーを殺す。
DNSを消す。
リポジトリを片付ける。

サービスを作っていたころには存在しなかった「閉店作業」という巨大なバックログが、最後に突然出現します。

この記事では、小規模なWebサービスを実際に終了した経験をもとに、サービス終了方針の決定から完全撤去までをまとめます。

成功談ではありません。

どちらかというと、
「こういうところで普通にコケました」
という敗戦記です。

サービス名、ドメイン、利用者数など、運営者やユーザーを特定できる情報は匿名化しています。

今回処分することになった技術スタック

対象サービスでは、主に次のサービスを利用していました。

  • Cloudflare Workers:アプリケーションの実行環境
  • Supabase:認証、データベース、Storage
  • Polar:商品の販売と決済履歴
  • Gemini API:生成AI機能
  • Sentry:エラー監視
  • Google Analytics:アクセス解析
  • Google AdSense:広告
  • GitHub:ソースコードとCI/CD
  • 独自ドメイン

作っているときは、この構成を見て
「個人開発でもここまでできる時代、すごいな」
と思っていました。

終了時には、
「個人開発なのに片付けるサービス多すぎない?」
と思いました。

便利なSaaSは、導入するときは5分です。
解約するときは、それぞれ別の管理画面へ行く必要があります。
しかも全部UIが違います。

人生です。

サービス終了では、これらを一気に削除するのではなく、依存関係を考えて段階的に停止しました。
勢いで全部消すと、サービスだけでなく自分の逃げ道まで消えます。

まず「どう死ぬか」を決める

サービス終了で最初にやることは、コードを書くことではありません。

終了方針を決めることです。

私は最初、
「もう閉じるんだし、とりあえず全部止めればよくない?」
と思っていました。

よくありませんでした。
最低でも、次のことを決める必要があります。

  • 最終サービス終了日
  • 新規登録と新規購入を止める日
  • 終了日まで既存ユーザーに許可する操作
  • 購入済みコンテンツの扱い
  • ユーザーデータの削除期限
  • 決済履歴を残すか
  • 問い合わせ先をいつまで維持するか
  • ソースコードを削除するか、非公開で保管するか
  • 本番ユーザーデータをバックアップするか

今回の方針は次のようにしました。

  • 新規登録と新規購入は先に停止
  • 終了日までは既存データの閲覧のみ許可
  • 回答、ペアリングなどの更新操作は停止
  • 終了後はサービスへアクセスできない状態にする
  • ユーザーデータは定めた期限までに削除
  • 本番ユーザーデータのバックアップは残さない
  • DB構造とマイグレーションだけGitに残す
  • 決済事業者側の取引履歴は保持
  • リポジトリは秘密情報を無効化してから非公開でアーカイブ

ここまで決めて初めて、

「これは消す」
「これは残す」
「これは終了日までは必要」

と判断できるようになります。

逆に決めていないと、削除ボタンを押すたびに
「……これ消して大丈夫だっけ?」
というゲームが始まります。

しかもセーブデータは本番環境です。

サービス終了は一撃必殺ではなく撤退戦

サービス終了は、電源を落として終了、ではありません。
今回、ざっくり次の3段階に分けました。

  1. 新しい利用を止める
  2. 終了日までは読み取り専用で維持する
  3. 完全停止してインフラとデータを撤去する

閉店で考えると分かりやすいです。

まず新規客を入れない。
次に店内にいる客を帰す。
最後にレジを閉めて、在庫を処分して、看板を外して、テナントを返す。

Webサービスもだいたい同じです。
違うのは、店の裏側にWebhookがいて、閉店後も何度もノックしてくるところです。

第1段階:これ以上、責任を増やさない

最初に停止したのは、新しい責任が発生する操作です。

  • 新規ユーザー登録
  • 新規購入
  • 新しい回答の保存
  • ペアリング
  • その他のデータ更新

サービスを終了すると決めた翌日に新規ユーザーが入り、
「昨日登録したんですけど」
と言われる世界線は避けたい。

なので、まず入口を閉じます。
ただし、ここで罠があります。

ボタンを消して満足してはいけない

私はWebエンジニアなので当然知っていたはずなのですが、改めて書きます。

UIはセキュリティ境界ではありません。

ボタンを非表示にして、
「よし、新規登録停止!」
ではありません。

そのAPI、まだ生きています。
直接叩けば通ります。

ということで、次の複数の層で停止します。

  • 画面・フォーム
  • サーバー側Action
  • APIエンドポイント
  • 認証サービス
  • データベースのRLSや権限
  • 決済事業者の商品設定

フロントでボタンを消しただけで安心するのは、
玄関に「本日休業」と貼ったあと、裏口を全開にしているようなものです。

読み取り専用モードを作る

サービス終了日までは、既存ユーザーが自分のデータや購入済みコンテンツを確認できる状態を残しました。

そこで、アプリケーション内に終了状態をまとめました。

export const SERVICE_END_DATE = "終了予定日";
export const SERVICE_READ_ONLY = true;

export const READ_ONLY_MESSAGE =
  "サービス終了に伴い、現在は既存データの閲覧のみ利用できます。";

書き込み系APIでは、外部サービスやDBに到達する前に終了させます。

export function serviceUnavailableResponse() {
  return Response.json(
    {
      error: "SERVICE_READ_ONLY",
      message: READ_ONLY_MESSAGE,
    },
    { status: 403 },
  );
}

適用したのは、たとえば次の処理です。

  • 決済Checkoutの作成
  • ユーザーによるデータ更新
  • AIによる生成やキャッシュ更新

ここで地味に重要なのが、
「読む処理」と「書く処理」が本当に分離されているか
です。

終了作業をしていると、
「このGET、なんでDB更新してるんだ?」
「表示するだけなのに、なんでAI API呼んでるんだ?」
みたいな過去の自分との再会が発生します。

コードレビュー担当は未来の自分です。
そして未来の自分はかなり辛辣です。

Authもちゃんと止める

アプリ側でサインアップ画面を消しても、Authの新規登録が有効なら、認証APIを直接呼べる可能性があります。

つまり、

フロントエンドでは閉店しているのに、バックエンドでは営業中

という状態です。

そこでSupabase側でも新規登録を無効化します。
データベースについても、RLSや権限を見直し、一般ユーザーによる直接書き込みを拒否します。

ただし、
「もう終わるサービスだし全部禁止!」
とやると、別の問題が起きます。

終了日までは、次の処理が必要になる場合があります。

  • アカウント削除
  • 既存データの読み取り
  • 決済Webhookによる最終更新
  • 管理者による確認

つまり、雑に全権限を剥奪すると、

サービスを終了するために必要な処理まで終了する

という高度なセルフ妨害が完成します。

ユーザーに終了通知を送る

当然ですが、サービスを終わらせるならユーザーへ通知します。
最低でも次の情報を伝えます。

  • サービスを終了すること
  • 最終利用日
  • 新規登録・購入が停止していること
  • 終了日まで利用できる機能
  • 終了後はアクセスできなくなること
  • 問い合わせ先
  • ユーザーデータの扱い

通知対象が少数なら、手動送信でも問題ありません。
というか個人開発でユーザー数が少ない場合、
「終了通知メール配信基盤を作る」
という新機能開発をサービス終了直前に始めるのは、かなり業が深いです。

私は素直に手で送りました。
重要なのは、送信方法ではありません。
誰に、いつ、何を通知したかを残すことです。

人間の記憶はログではありません。
特にサービス終了作業中の人間の記憶は、かなり信用できません。

完全停止用PRは先に作る

終了日当日にコードを書くのは怖いので、完全停止用PRを先に用意しました。

サービス終了日の23:50に、
「410ってどう返すんだっけ」
とMDNを読む人生は避けたいです。

完全停止後は、通常画面を表示せず、ブラウザには終了案内、APIには機械判定可能なレスポンスを返します。

export function serviceClosedResponse(request: Request) {
  const url = new URL(request.url);
  const isApi =
    url.pathname.startsWith("/api") ||
    request.headers.get("accept")?.includes("application/json");

  if (isApi) {
    return Response.json(
      {
        error: "SERVICE_CLOSED",
        message: "このサービスは終了しました。",
      },
      {
        status: 410,
        headers: {
          "Cache-Control": "no-store",
          "X-Robots-Tag": "noindex, nofollow",
        },
      },
    );
  }

  return new Response("<html>サービス終了のお知らせ</html>", {
    status: 410,
    headers: {
      "Content-Type": "text/html; charset=utf-8",
      "Cache-Control": "no-store",
      "X-Robots-Tag": "noindex, nofollow",
    },
  });
}

410 Gone は、対象リソースが恒久的に利用できなくなったことを示します。

404が
「見つからないですね」
だとすると、

410は
「ありましたが、もう死にました」
です。

サービス終了に非常に似合うステータスコードです。

GitHub Actionsのcronを信じた者の末路

終了時刻に合わせて、停止用PRをGitHub Actionsから自動マージする仕組みも作りました。
完璧な計画です。

指定した時刻にActionが動き、PRがマージされ、デプロイされ、華麗にサービス終了。
人間は寝ていてもよい。
未来です。

実際には、予約実行が予定時刻から大幅に遅れました。
GitHub Actionsのcronは、指定時刻ちょうどに実行される保証がありません。
私は知識としては知っていました。

でもなぜか自分のcronだけは時間通りに動くと思っていました。
人間にはそういうところがあります。
時刻が重要なら、次のような方法が必要です。

  • 余裕を持って早めに実行する
  • 外部スケジューラーを利用する
  • 当日に手動で実行する
  • アプリケーション自身が時刻を判定して停止する
  • CDNやDNS側でも停止時刻を設定する

教訓です。

cronに23:59と書いても、cronはあなたとの23:59の約束を覚えていません。

CI成功!デプロイ成功!なのにサービスが止まらない

そして今回のハイライトです。
停止用PRは無事にマージされました。
CIも成功。
Cloudflareのビルドも成功。
デプロイも成功。
全部緑。

GitHubもCloudflareも私を褒めています。
「やったか!?」
やってませんでした。

公開サイトを開くと、
元気にサービスが動いていました。
サービス終了日にサービスだけが終了を拒否しています。

原因は、停止処理をReact Routerのmiddlewareへ実装していた一方で、設定ではmiddleware機能を無効化していたことでした。

export default {
  future: {
    v8_middleware: false,
  },
};

false
です。

非常に分かりやすい。

停止処理のコードはありました。
型チェックも通りました。
テストも通りました。
ビルドも通りました。
デプロイも通りました。

ただし、
そのコードは実行されません。
完璧です。

何も間違っていないのに何も動いていません。
いや設定が間違っています。

この件で学んだのは、
「デプロイ成功」と「サービス停止成功」は別のイベント
だということです。

最低でも、次を分けて確認する必要があります。

  1. PRがマージされた
  2. 本番ビルドが成功した
  3. 新しいバージョンがデプロイされた
  4. 対象ドメインが新しいバージョンへ向いている
  5. 停止処理が実際のリクエスト経路で実行された
  6. 外部から期待するHTTPレスポンスを確認できた

緑色のチェックマークは心を落ち着かせてくれます。
しかし本番環境は止めてくれません。

最後に信じられるのはcurl

サービス終了後は、ブラウザだけでなくHTTPレスポンスを直接確認します。

curl -I https://example.com/

APIも確認します。

curl -i \
  -H "Accept: application/json" \
  https://example.com/api/example

終了ページを残すなら、たとえば次を期待します。

  • Web画面:410 Gone
  • API:410 Gone
  • レスポンス:SERVICE_CLOSED
  • キャッシュ:no-store
  • 検索エンジン:noindex, nofollow

完全撤去するなら、

  • ルートドメインが解決できない
  • wwwサブドメインが解決できない
  • Workerの直接URLへアクセスできない
  • API・Webhook URLへアクセスできない

まで確認します。

管理画面に、
「Deployment successful」
と出ていてもダメです。

GitHubに、
「All checks have passed」
と出ていてもダメです。

Cloudflareが緑でもダメです。

外からcurlして死んでいたら死んでいます。

最後に信じられるのは観測可能な事実です。
急にSREみたいなことを言い始めましたが、敗戦後なので学習しています。

ここからインフラを墓地へ送る

サービスが外部から利用できなくなったことを確認したら、インフラを撤去します。
順番を雑にすると面倒なので、今回はこちらの順に進めました。

1. 決済Webhookを止める

まずは決済周りです。
アプリケーションを先に消すと、決済サービスがWebhookを再送し続ける可能性があります。
サービス本体は死んでいるのに、

POST /webhook
POST /webhook
POST /webhook

と叩き続ける決済事業者。
ホラー映画なら序盤の演出です。

先に次を行います。

  • 商品を販売停止または非公開にする
  • Checkoutを停止する
  • Webhookを削除する
  • 必要な決済履歴が残っていることを確認する

なお、決済履歴は会計や問い合わせ対応に必要になることがあるので、ユーザーの回答データなどとは別に考えます。

何でもかんでも
「サービス終了!削除!」
すると、あとで会計上の自分に怒られます。

2. Workerとドメインを消す

次に公開経路を撤去します。

  • Cloudflare Worker
  • Custom Domain
  • Worker Route
  • DNSレコード
  • Worker用VariablesとSecrets
  • GitHubとの自動ビルド連携

ここから徐々に、
自分が数か月かけてインターネット上に存在させたものを、自分でインターネットから消す作業
になっていきます。

なかなか味わいがあります。

なお、ドメインの自動更新を止めただけでは、契約期限までは普通に生きています。
「更新停止 = 今すぐ消滅」
ではありません。

ドメインにも余生があります。
即時停止したいなら、DNSやRouteを削除します。

3. ユーザーデータを消す

公開アクセスが止まったことを確認してから、Supabase内のユーザーデータを削除しました。
対象はたとえば次です。

  • Authユーザー
  • 回答データ
  • 利用履歴
  • アプリケーション内の購入権限
  • Storage内のユーザーファイル

今回は、本番ユーザーデータをバックアップしない方針にしました。
サービス終了時にありがちな、
「一応ローカルにdumpしておこうかな」
という誘惑もありました。

でも、その「一応」で個人データを永久保存すると、
サービスは終了したのに、個人情報管理者としての自分だけ続編に出演する
ことになります。

なので持ち出しませんでした。

代わりに、個人情報を含まない範囲で、

  • 削除実施日時
  • 削除前後の件数
  • Authユーザーが0件であること
  • Storageが空であること
  • バックアップを残さない判断

などを記録しました。

最後にSupabaseプロジェクト自体を削除します。
Delete Project。
確認ダイアログ。

プロジェクト名を入力してください。
本当に削除しますか。

あれだけ気軽に作らせてくれたのに、消すときだけ急に何度も意思確認してきます。
正しい設計です。

4. APIキーを一人ずつ介錯する

続いて外部サービスです。

  • Gemini APIキー
  • Sentryプロジェクト
  • Google Analyticsプロパティ
  • AdSenseサイト設定
  • Cloudflare Secrets
  • Supabase Personal Access Token
  • GitHub Actions Secrets
  • GitHub Actions Variables

ここで重要なのが、
プロジェクトを消したからといって、認証情報まで全部死ぬとは限らない
ことです。

たとえばSupabaseプロジェクトを削除しても、アカウント単位で発行したPersonal Access Tokenが残る可能性があります。

私は終了作業をするまで、
「まあプロジェクトなくなったし、ほぼ無害だろ」
という気持ちもありました。

ダメです。

セキュリティに「ほぼ無害だろ」を導入すると、大体ろくなことになりません。
キーはキーで明示的に失効させます。

サービス終了時は、
APIキーたちの介錯フェーズ
があります。

5. GitHubリポジトリを墓石にする

最後にGitHubです。

  • 本番秘密情報を無効化する
  • Actions SecretsとVariablesを削除する
  • 不要なWebhookとDeploy keyを削除する
  • 予約実行Workflowを無効化する
  • .envなどのローカルファイルを整理する
  • リポジトリが非公開であることを確認する
  • リポジトリをArchiveする

DB構造やマイグレーションは、将来の参照や監査に使えるためGitに残しました。
一方、本番ユーザーデータは残していません。

最後にArchiveボタンを押します。
ここだけはちょっと感慨があります。

READMEもある。
Issueもある。
PRもある。
「初回リリース🎉」みたいなコミットもある。

全部残っています。
ただし、もう誰もデプロイしません。

GitHubリポジトリが、
動くソフトウェアから遺跡へ変わる瞬間
です。

敗戦処理チェックリスト

ここまでで、
「サービスって終了するだけでこんなにやることあるの?」
と思った方へ。

私もそう思いました。

なので、二度と頭だけで管理しないためにチェックリスト化しました。

方針

  • 最終終了日を決めた
  • 新規登録・購入の停止日を決めた
  • 既存ユーザーが利用できる機能を決めた
  • 購入済みコンテンツの扱いを決めた
  • ユーザーデータの削除期限を決めた
  • バックアップ方針を決めた
  • 問い合わせ先を決めた

事前停止

  • 新規登録を停止した
  • 新規購入を停止した
  • 書き込み系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した
  • 問い合わせ先を維持した
  • 完了日時を記録した

このチェックリストを全部埋めても、
新機能は0個です。
DAUも増えません。
売上も増えません。

むしろサービス自体がなくなります。
それでもやります。
撤退戦とはそういうものです。

まとめ

サービス終了で重要なのは、
「コードを止めること」ではなく、「サービスが持っていた責任を順番に片付けること」
でした。

今回、特に痛感したのは次の3点です。

  1. UI、API、認証、DBのすべてで新しい利用を止める
  2. マージやデプロイ成功ではなく、本番のHTTPレスポンスで確認する
  3. ユーザーデータ、決済履歴、ソースコードを同じ保存方針で扱わない

そして、もう一つ。

個人開発を始めるとき、
「失敗したらサービス閉じればいいだけだし」
と思うことがあります。

半分正しいです。
閉じられます。

ただし、
閉じるのにも実装があります。

作っている間は、
「ユーザーが増えたらどうしよう」
「スケールしたらどうしよう」
「バズったらどうしよう」
と未来の成功ばかり考えがちです。

実際には、
「誰も使わなくなったときどうするか」
「課金をどう止めるか」
「個人データをどう消すか」
「サービスをどうインターネットから消すか」

も、ちゃんと設計対象でした。

私はそれをサービス終了時に知りました。
遅い。

ただ、サービスを公開した以上、最後まで片付けるところまでが運営です。

ユーザーがいなくなっても。
売上がなくなっても。
アクセスがなくなっても。
Sentryだけが律儀にエラー通知を送ってきても。

最後にAPIキーを失効し、DNSを消し、リポジトリをArchiveして、
ようやく終わります。

個人開発は、デプロイするまでではありません。

Archiveするまでが個人開発です。

そして最後に一番大事な教訓を残しておきます。

「デプロイ成功」の緑チェックを見て安心するな。

curlしろ。

サービス終了の日、私は元気に動いている自分のサービスからそれを教わりました。

Discussion

岸本悠佑_AIサムライ岸本悠佑_AIサムライ

非常に勉強になりました。
ノリで色々作っては出し、を繰り返している身の上なので...。
いざ終了するとなるとこれ全部、とは現実的ですが恐ろしいですね...。

2
shuichishuichi

文章が読みやすくて面白すぎです笑
ためになりましたが、それ以上に読んでる途中に何回も笑いました。

1