🪦

うっかりミスでアクティブユーザー1万人超えのサービスをサ終させた話

に公開
1
43

うっかりミスでアクティブユーザー1万人超えのサービスをサ終させた話

本番環境などでやらかしちゃった人 Advent Calendar 2025 の25日目の記事です。

タイトルを見てもらえればこの記事が何なのかわかると思います。
懺悔も兼ねて。

原因が幼稚すぎるので、もしかしたら期待はずれと感じる人もいるかもしれません。

そもそもどういうサービスだったのか

大体の概要を説明します。

おおまかなサービスの説明。クリエイターはアセットをサービスにアップロードし、サービスはコンパイラーでコンパイルした後にレジストリに登録し、ユーザーはクライアントでアクセスする
サービスのおおまかな概要

  • とあるゲームの二次創作ゲームをアップロード・プレイするサービス。
    • 二次創作界隈の中枢と言っていいほどのサービスでした。
      • というのも、上の図のコンパイラーの環境構築をやりたくない人がほとんどで、ほぼ全員がサービス内のコンパイラーを使っていたからです。
    • コンテンツのモデレーションや日本語・英語以外への翻訳はコミュニティで担当していましたが、開発やホストに関しては自分ひとりで行っていました。
    • xkcd: Dependencyをイメージしてもらえれば分かりやすいと思います。
  • 決して本家のガイドラインに準拠しているとは言えない。
    • そのため、サービスにPaywallは全く設けていませんでした。広告も入れていません。
      • 二次創作の同人誌とかを書くイメージです。
  • プレイには特殊なクライアントが必要。
    • 具体的にはスマホ/タブレットにサイドロードでクライアントを入れる必要がありました。
      • 特にiOS/iPadOSだとAltStoreをセットアップしないといけないので面倒です。
    • そのクライアントは一応他のサービスにも接続できますが(あくまでインタプリター、Flash Playerみたいなイメージ)、殆どのユーザーは自分のサービス目当てだったと思います。
      • Fediverseでかなりのインスタンスがあるのに一つのサーバーに人が殺到するイメージを思い浮かべてもらえればわかりやすいと思います。
  • 月あたりのアクティブユーザーは平均でおおよそ1万人。
    • その月でコンテンツに高評価をした人、をアクティブユーザーとしてカウントしました。
    • サ終直前の月はアクティブユーザー1.5万人ほど行っていました。
    • Cloudflareの月Unique Visitorsはおおよそ100万人ほど行っていたと思います。(記録が残っていないので過去のスクショからの推定ですが…)
    • コンテンツを投稿した人はおおよそ月400人程度。
  • プレイ動画はかなり有名。
    • どれくらい有名かというと、YouTubeにて100万再生の動画が10本くらいあり、10万再生が70本くらい、1万再生まで数えると200本くらいあるかも?
    • またそのプレイ動画をメインコンテンツにしてYouTubeで登録者数5桁を達成する人もいました。
  • 公式Discordのユーザー数は1万人ほど。
  • PRは大きな機能のみで使い、基本的にはmainにプッシュする運用でした。
    • メインのサービスのテストを整備したいなぁと思いつつも、知識不足とモチベ不足で整備されないままでした。
      • たまにそれでプチやらかしをしていました(例えば公開範囲を指定せずに検索エンドポイントを直で叩くと公開範囲の条件が指定されず、下書き段階を含め全てのコンテンツが見れてしまっていたなど)

転機の前

サービスはおおよそ1k~1.5k req/minほどで動いていました。金欠だったため、Oracle Cloudの無料インスタンスを2コア12GBで動かしていました。
主な月額費用はストレージ代で、およそ$5/月程度でした。
ポケットマネーで動かせる範囲の、趣味のサービスでした。

転機

プレイに必要なクライアントがApp Storeにて正式リリースされるという知らせが入りました。
コミュニティではふざけて正式リリースをサ終と言ったり、「サーバー爆発するんじゃね?」みたいな会話もされていました。
その時はまだ笑えていたのですが...

クライアントが正式リリースされた後

プレイ動画だけ見ていた人が一気に自サービスに殺到しました。
当時のGrafanaのスクショが残っていたので紹介します。
青いグラフがそのサービスのリクエスト数、水色の縦線がクライアントの正式リリースです。

リクエスト数のグラフのスクショ。正式リリース前までは1000req/分台だったのが、おおよそ2~3時間で2000req/分、12時間後には5000req/分まで増加している
グロ画像 その1。1000req/minが12時間で5000req/minに

もちろんポケットマネーサーバーでは耐えきれませんでした。
gotopのスクショ。CPU使用率が100%に張り付いている。
グロ画像 その2。

そして、自分はちょうどその時に...
39.3度を差している体温計の画像
「死」
コロナに罹患してしまいました...

ベッドでノートPCを使って自宅内sshをしながら、少ない知識と死にかけの頭でなんとかOpenTelemetryの導入やキャッシュ追加やクエリの最適化などに挑戦してなんとかサーバーを復旧させようとしていました。

スロークエリログも取っていました。
サーバーも強化していました。具体的には8コア32GBまでアップグレードしました。しかし...
Grafanaの画像。8コアすべてが90%を超えている。
グロ画像 その3。火を見るより明らかな過負荷

はい。
全く間に合っていませんでした。
またログにはコネクションプールのタイムアウトのエラーが流れ続けていました。

そして、スロークエリログはとんでもないことになっていました:
スロークエリのログ。とんでもない量のパラメータがかかったSELECT文が走っている。
実際のスロークエリログ。
テーブル全走査してそうな雰囲気がありますが、これは管理者画面用のエンドポイントからであると結論づけました。現在投稿されているコンテンツの統計を取る機能が存在しているので、そこでミスってたのでしょう。
実際、以前にもそういうプチやらかしはしていましたし、一般向けエンドポイントのOpenTelemetryには正常なDBクエリが発行されたログが残っていました。
とりあえず一般向けのエンドポイントを最適化するのが先です。

そうやって頭を抱えている間にもリクエスト数は増え続けました。
Grafanaのスクリーンショット。リクエスト数が1日で数十倍に膨れ上がっている様子がわかる。
Grafanaのスクリーンショットを集めて合成したもの。縦軸は揃ってるはず。

そしてその状況が2日ほど続いた後、

  • 自分のその界隈への興味、すなわち運営のモチベの低下、
  • スケールに弱いコンテンツモデレーションの仕組み、
  • ユーザー数の爆発的な増加、[1]
  • 収益化が不可能な状態で月8000円というサーバーコスト、[2]
  • 同じサーバーで動かしている他のアプリへの影響、[3]

など、さまざまな問題点が出てきました。

そして....

Discordのスクリーンショット。「サービス終了のお知らせ」という見出しがついている。
Discordのサ終アナウンス。
サービスを終了しました。

Docker compose downを走らせた時のターミナル。コンテナやネットワークがRemoved表記になっている。
docker compose downを走らせたときのターミナル。今までありがとうね。

そして明かされる、真の原因

サービスを終了させた後は、エンディングムービーとかを作ったり療養したりしながら代替サービスの開発を見守っていました。

また、Misskeyなどのように、誰でも別インスタンスを建てられるようにするため、コードベースの後始末などをしていたその時。

過負荷の真の原因を見つけました。

ユーザー数の増加による負荷よりも、何万倍もの負荷を加えるコードを見つけました。
なんと、最適化の途中に入れてしまった、たった1コミットのミスのせいで、このとんでもない負荷が発生していました。

その原因となったコミットを見てみましょう。

diff --git a/backend/app/controllers/sonolus/levels_controller.rb b/backend/app/controllers/sonolus/levels_controller.rb
index 76ceb620..65d7e5d9 100644
--- a/backend/app/controllers/sonolus/levels_controller.rb
+++ b/backend/app/controllers/sonolus/levels_controller.rb
@@ -294,7 +294,7 @@ def list
         self.class.search_options.all? do |option|
           %i[q_sort q_genres].include?(option[:query]) ||
             params[option[:query]].blank?
-        end
+        end && params[:type] == "advanced"
 
       charts =
         charts.where(charts: { rating: (params[:q_rating_min]).. }) if params[
@@ -469,15 +469,21 @@ def list
             Chart.get_num_charts_with_cache(genres: genres)
           else
             charts.unscope(:group, :having).count
-            charts.offset([params[:page].to_i * 20, 0].max).limit(20)
           end
         charts =
-          if cacheable && params[:page].to_i.zero? && genres == Chart::GENRES.keys
+          if cacheable && params[:page].to_i.zero? &&
+               genres == Chart::GENRES.keys
             Rails.logger.debug "Fetching charts from cache"
             Rails
               .cache
               .fetch("sonolus:charts", expires_in: 5.minutes) do
-                charts.unscope(:group, :having).limit(20)
+                Chart.preload(
+                  :author,
+                  :tags,
+                  file_resources: {
+                    file_attachment: :blob
+                  }
+                ).where(visibility: :public)
               end
           else
             charts

おわかりいただけただろうか。

               .fetch("sonolus:charts", expires_in: 5.minutes) do
-                charts.unscope(:group, :having).limit(20)
+                Chart.preload(
+                  :author,
+                  :tags,
+                  file_resources: {
+                    file_attachment: :blob
+                  }
+                ).where(visibility: :public)
               end

そう、.limit(20)が消えているのです。
ActiveRecordでは、.limitの無いクエリは基本的にテーブルのすべての行を取得します。

あのテーブル全走査は管理者画面ではなく、ユーザーの99%が通る、コンテンツ一覧画面からのものでした。すなわち、ほぼすべてのアクセスでテーブル全走査 + 返却を行っていたのです。

そして、このテーブルには条件に引っかかるものだけでも1.7万件、引っかからないものを含めると4.7万件ほどのデータを走査していたことになり、またそれが終わっても集めた1.7万件に紐づくタグやユーザー情報、更にはアセットのクエリも待っており、それが終わったら1.7万件のデータのJSONシリアライズも待っています。
しかも、これはほとんどのユーザーが通るコンテンツ一覧ページ。先程の情報と合わせると150req/秒ほど受けることになります。

アセットの数などを合わせて雑に計算すると、毎秒2750万行[4]をクエリして、合計12.75GB[5]のJSONを送信しようとしていたことになります。
8コアすべてが100%に張り付くのも納得です。これに耐えられる業務サーバーはないと思います。

実際、一般向けエンドポイントのOpenTelemetryを見る限りだと、DBのクエリは至って正常でした。

データが残っていたので見てみたところ、記録されているリクエストはすべて全走査ルートを通らないものでした。
となると、テーブル全走査するルートはそもそも記録されていないと言うことが推測できます。
おそらく、リクエストが終了する前に再デプロイを走らせてしまっていたとか、RailsのOpenTelemetryの仕様か何かで記録されていなかったとかでしょう。
そのコミットに気がついたのはサービスを終了させた後でした...

蛇足

正直なところ、結局このポカを入れてなかったとしても、VPS1台構成(docker composeでPostgresまで動かしている)で5000~10000req/minを運用、さらに100%ポケットマネー運営は無茶な気がします。
DBのスケールアウト、具体的にはAWSとかそういうのが提供しているDBサーバーの使用、とかをしたら流石に月額のランニングコストが1万円を超えそうな気がして、流石にモチベと支出が釣り合ってないため、どちらにせよサ終はしていたと思います…

なぜ「やらかし」は起きてしまったのか、その「やらかし」を起こさないために次どうすればいか

コロナに罹ったままクリティカルなコーディングをしたから

明白ですね。コードを書くとき、特に最適化という論理的思考力が重要なコードを書くときは健康な肉体と精神が必要です。
病気に罹ったらちゃんと療養しましょう。

焦っていたから

早くサービスを復旧させなければ、という焦りがありました。
もちろん可能なら焦らずに冷静にコードを書く必要があります。

コードレビューがされていなかったから

もう一人レビュワー的な人がいれば、またはLLMにコードレビューを頼めば、このミスは防げていました。
実際GPT 5 Thinkingはこの問題を指摘できています:
ChatGPTのスクリーンショット。「ページングの欠落:(中略)今回の差分で削除/移動により実質消えています。その結果、ページ0でも全件をキャッシュ&返却する可能性が高いです。」という文言が含まれている。
ChatGPT 5にレビューを頼んでみた。

ローカルのテスト環境が不十分だったから

テストが後回しになっておりほとんど整備されていませんでした。また、まともなseederも存在していませんでした。手入力で何件かのデータが入っているだけ。

ローカルで1ページを超えるデータを含めており、なおかつテストで「20件返す」といったものが存在していればテストが落ちることによりこの問題を防げていました。

判断が早すぎたから

例えば「長期メンテナンス」にしてやり過ごす手もあったと思います。
まぁ自分がサービスを終わらせる(界隈から離れる)良い機会を探していた[6]、サーバー費が怖かった、というのもあったのですが...

(追記)config.active_record.query_log_tags_enabledを知らなかったから

Railsにはconfig.active_record.query_log_tags_enabledというオプションがあります。
これを有効にすると、ログにクエリのタグが付き、どのリクエストから発行されたクエリなのかが分かるようになります。
テスト用のアプリケーションを用意しました:
https://github.com/sevenc-nanashi/no-more-yarakashi-10k-au

このアプリケーションでconfig.active_record.query_log_tags_enabled = trueにしてから、以下のようなコードを書いてみます:

class ApplicationController < ActionController::API
  def list_data_good
    params.permit(:text)
    data =
      Mydata.where("text LIKE ?", "%#{params[:text]}%").order(:int1).limit(1000) # <- Good: limit the number of results
    lucky_number = 0
    data.each { |item| lucky_number += item.int2 % 7 }
    render json: { lucky_number: lucky_number }
  end

  def list_data_bad
    params.permit(:text)
    data = Mydata.where("text LIKE ?", "%#{params[:text]}%").order(:int1) # <- Bad: no limit on results
    lucky_number = 0
    data.each { |item| lucky_number += item.int2 % 7 }
    render json: { lucky_number: lucky_number }
  end
end

そして、試しにスロークエリログを有効にした状態でlist_data_badにアクセスし、postgresのログを見てみると...

postgres-1  | 2025-12-26 13:43:20.462 UTC [104] LOG:  duration: 3891.074 ms  statement: SELECT "mydata".* FROM "mydata" WHERE (text LIKE '%%') ORDER BY "mydata"."int1" ASC /*action='list_data_bad',application='NoMoreYarakashi10kAu',controller='application'*/

このように、action='list_data_bad'というタグが付いています。
これにより、どのエンドポイントから発行されたクエリなのかが分かります。
もしこのオプションを知っていれば、サービス稼働中にスロークエリログを見て、どのエンドポイントが問題を起こしているのかを特定できたかもしれません...

実はこのオプションはテンプレートではDevelopment環境で有効になっています。
しかし、このオプションがテンプレートで有効になったのはRails 8.0からであり、自分が最初にrails newしたときはRails 7.0.2でした。
もしRails 8より前に作成したプロジェクトを今でも使っているという方がいれば、ぜひこのオプションを覚えておいてください。
いつか役に立つときが来るかもしれません。

まとめ

コードを書くとき、特に最適化などのコードを書くときは、

  • まずテストを用意したうえで、
  • 冷静に判断できる状態で、
  • 他人からのチェックをもらう、またはダブルチェックをしたうえで、

コードを書くべきです。
特に個人開発ではチェックを貰う機会が少ないので、

  • テストなどで機械的に防ぐ
  • Copilot Reviewerなどを使ってレビュワーを「買う」

などはするべきでしょう。
個人開発は名前のとおり個人で行います。自分で自分のコードの面倒を見る必要があります。
そして、そこでミスをするとこうなるかもしれません:
xkcdのDependencyで描かれている積み木が崩れている様子。
あーあ。

レビュワーがいないと、独断で素早く開発できる一方、それと同時に素早くバグを埋め込むこともあるのです。

その後、界隈はどうなったか

界隈は大騒ぎになり、似たような別の界隈でも話に出てくるくらいの騒ぎになりました。
自分のサービスはサ終して、エンディングムービーまで作って公開してしまった手前、もうサービスの復活は言い出せる雰囲気ではなかったため、そのままサ終したままになりました。

界隈では、サービス終了から1.5ヶ月ほどは、一般ユーザー向けの、コンパイラーのラッパーを使って、ローカルに書き出したファイルをDiscordで細々共有するみたいな感じの事が行われていました。

おおよそ1.5ヶ月後、代替サービスが2つ稼働し始め、少なくとも右下の柱は取り戻せました。
しかし、コミュニティを見る限りだと、かつて自分がサービスを運用していた頃の活気はまだ取り戻せていないように感じます...

xkcdのDependencyの画像を元にした画像。最低限大きな積み木は立っているが、床には細々とした積み木の残骸が転がっており、また立っている積み木の高さは以前よりも低い。
「戻らないもの」

最後に

今回の記事をざっくりまとめるとこうなります:

  • 自動テストが十分にされていないコードベースで
  • コロナに罹ったままコードレビュー無しでコードを書いて
  • 一界隈の中枢のサービスを破壊してしまった話

防ぐのは簡単です。今すぐできるのはテストケースの追加でしょう。
テストケースはあればあるほど安全になります。簡単なテスト、例えば「このエンドポイントは20件返す」というテストでも、将来の自分を救うかもしれません。
テストケース書いてないな、と思ったそこのあなた。今すぐテストケースを書きましょう。


一個人のミスで界隈の活気を消し飛ばしてしまうという、とんでもない損失を発生させてしまいました。
この場を借りて懺悔したいと思います。

ここまで読んでいただきありがとうございました。この記事がいつか誰かのやらかしを防ぐことを祈って。

ちなみに今日(12/25)は自分の誕生日です。GitHub Sponsors欲しいものリストもあるので、支援していただけるととても喜びます。

脚注
  1. クライアントが、iPhoneのAppStore無料ゲームランキング5位、iPadのAppStoreではなんとランキング1位に行くレベルでユーザーが殺到していました ↩︎

  2. なんとか最適化できればマシにできるかと思いましたが、企業ソシャゲレベルのユーザーが来ているので諦めの気持ちもありました ↩︎

  3. 一つのサーバーを複数サービスの運用に使っていました。念のため言っておくと、すべてのサービスはDocker Compose内に隔離しているのでデータが漏れる可能性は十分低いです。DBのインスタンスもサービスごとに分けた上で、パスワードもランダム生成していました ↩︎

  4. (4.7万行 + 1.7万 * (タグ3つ + アセット4つ + 投稿者1人)) * 150req ≒ 2750万 ↩︎

  5. 1.7万行 * 5kB * 150req ≒ 12.75GB ↩︎

  6. 代替サービスが存在しないからという消極的な理由でサービスを継続していました ↩︎

GitHubで編集を提案
43

Discussion

FAMASoonFAMASoon

執筆ありがとうございます。
余計なお世話かもしれないですが、何向けのサービスか伏せているという事なので一応連絡します。
ソースコードやdiff等に当該ゲーム名が書かれています。
もしかすると伏せ忘れかもしれないのでコメントしますね

ログインするとコメントできます
43