私は現在、個人開発者として2つのプロダクトを運用しています。ひとつは現場で見積もりを出す職人向けの現場見積もりアプリ、もうひとつは求人、中古品売買、リアルタイムメッセージングを備えたコミュニティマーケットプレイスプラットフォームです。
プロダクトもユーザーも全く違いますが、技術スタックはほぼ同一です。そしてどちらも、Fly.ioの最安ティアであるshared-cpu-1xマシンだけで動いています。この制約は意図的なものです。ピーク負荷に合わせてサーバーを大きくするのではなく、小さなマシン1台から絞り出せる性能を最大化し、どこで崩壊するかを正確に測定し、その実測された限界を軸にFlyのオートスケーラーにマシンの追加と削除を任せます。この記事では、それを可能にしている最適化、閾値の根拠となった負荷テストの数値、そしてFly.ioでスケールアップ/ダウンが実際にどう動くのかを紹介します。
哲学:1台を測定し、それを掛け算する#
両アプリともスケーリング戦略は同じ3ステップです。
- 小さなマシン1台を、合理的な範囲で可能な限り速くする。
- 壊れるまで負荷テストを行い、レイテンシが崩壊する同時実行数を記録する。
- その直前で2台目のマシンが起動するようFlyのオートスケーラーを設定し、トラフィックが減ったらアイドルマシンを停止させる。
こうすればキャパシティはマシン数にほぼ線形に比例し、ベースラインコストはアプリごとに小さなVM 1台です。以下の内容はすべて、この3ステップのいずれかのためにあります。
小さなマシン1台を速くする#
Granian:APIはWSGI、WebSocketはuvloop ASGI#
両バックエンドともDjango 6 + Django REST Frameworkです。APIはRustベースのサーバーGranianがサービングしますが、ASGIではなくブロッキングスレッドプールを使うWSGIで動かしています。
granian --interface wsgi project.wsgi:application \
--workers 1 --blocking-threads 16 \
--workers-max-rss 1400 \
--workers-kill-timeout 15 --respawn-failed-workers最初は--interface asgi --loop uvloopで始めましたが、誤った選択でした。これらのアプリのHTTPビューはすべて同期(sync)で、DjangoのASGIハンドラは同期ビューをthread_sensitiveで—ワーカーあたり一度に1つずつ—実行します。つまりASGIは、ワーカーあたりの同時リクエストを実質1つに絞りながら、asyncio/uvloop/Channelsのスタック一式をただメモリに載せていただけでした。--blocking-threadsを使うWSGIは、同じ同期ビューを本物のスレッドプールで走らせます。ワーカー1つ × スレッド16 = I/Oバウンドなリクエスト(DB読み取り、S3のプリサイン)16件を同時に、非同期スタックより低い常駐メモリでさばきます。
なぜワーカーが1つなのか?VMがshared-cpu-1x—1コアだからです。2つ目のワーカーは1コアではPythonを並列に走らせられない(GIL + 1コア)くせに、常駐するDjangoプロセスをまるごと1つ余分に食います。Granianはまさにこの点を警告します。ワーカー1つにスレッド16が、I/Oバウンドな処理における1 vCPUマシンの正直な上限です。--workers-max-rss 1400はワーカーがメモリ上限を超えると再生成するので、暴走したPDFや画像処理がマシンをOOMへ引きずっていく代わりにリサイクルされます。
WebSocketは自前のGranianプロセスに切り出しました。uvloopと--wsを使うASGIアプリで、長寿命コネクションのためにワーカー1つで動かします。同期APIにはASGIの経路はまったく不要です。ビューがソケットへプッシュしたいときはRedis経由でasync_to_sync(channel_layer.group_send)を呼ぶので、メッセージは変わらずWebSocketプロセスへ渡ります。2つのサーバーが、それぞれ本当に得意なインターフェースを走らせているわけです。ローカルのDockerも同じWSGIスレッドプールで動かしているため、テスト時は私のラップトップがプロダクションマシン1台の近似になります。
ホットパスにはRust#
PythonのホットパスにRustベースの代替があれば、必ず採用します。
- orjson(
drf-orjson-renderer経由)— すべてのAPIレスポンスが標準ライブラリのJSONエンコーダーではなくRustでシリアライズされます。 - blurhash-rs — 画像プレースホルダーをアップロード時に計算。
- uuid-utils — RustによるUUID生成。
- psycopg 3 (binary) — Cで高速化されたPostgresドライバー。
これらはアーキテクチャを変えません。2台目のマシンが必要になる前に、1 vCPUがこなせる上限を引き上げるだけです。
隠れたレイテンシ税を潰す#
どちらのコードベースでも最大の改善はアルゴリズムではなく、コネクションの再利用でした。Granianのスレッドプールでは、新しいスレッドごとにboto3のS3クライアントを作り直しており、写真やPDFを返すすべてのエンドポイントがそのコストを払っていました。カスタムのSharedConnectionS3Storageクラスでクライアントをプロセス単位にキャッシュするようにしたところ:
新規スレッドでの署名付きURL生成が約150msから約1msになりました。
同じ教訓はデータベースにも当てはまります。FlyのPostgresはPgBouncerの背後にあるので、アプリはconn_max_age=0にしてコネクション再利用をプーラーに任せ、古くなったSSLコネクションでワーカーが固まらないようconnect_timeoutを5秒にして素早く失敗させます。
本当のボトルネックの前にキャッシュを置く#
マーケットプレイスの負荷テストで、覚えておく価値のある事実が判明しました。天井はCPUではなくPostgresのコネクションだったのです。閲覧トラフィックは圧倒的に匿名でユーザー間で同一であり、負荷がかかると、その同一のリストクエリ群がCPUは暇なままコネクションプールを飽和させます。
対策は匿名リストページ用のRedisキャッシュです。無効化はネームスペースごとのバージョンカウンターで行います。書き込みが起きたらカウンターをひとつ進めるだけで(O(1)、キーの管理は不要)、そのリソースのキャッシュ済みページはすべて即座に無効になります。短いTTLが取りこぼしをカバーします。閲覧ページをRedisから直接返すことで本当のボトルネックが消え、小さなマシンはコネクションをログイン済みトラフィックに使えるようになります。
ブローカーなしのバックグラウンド処理#
両アプリともCeleryではなく、Djangoのデータベースバックエンドのタスクフレームワークdjango-tasks-dbを使っています。独立したdb_workerプロセスがデータベースをポーリングし、画像処理、PDF生成、機械翻訳、リマインダーといったジョブを処理します。ブローカーはなく、サイズを見積もって監視すべき追加サービスもなく、APIプロセスが重い処理でブロックされることもありません。さらにワーカーはPgBouncerをバイパスしてPostgresに直接接続します。長時間走るジョブはトランザクションプーリングと相性が悪いからです。
メモリ:jemallocとスワップのクッション#
画像とPDFの処理は、定常状態ベースの見積もりでは予測できないメモリスパイクを起こします。安上がりな緩和策が2つ。Dockerイメージにjemallocをプリロードして断片化を抑えること、そして全マシンにスワップを持たせることです(APIマシンに2GB、WebSocketマシンに1GB)。スパイクが来てもプロセスがOOMで殺される代わりにディスクへスワップされます。スワップは性能のための機能ではなく、メモリ割り当てを小さく保つための保険です。
負荷テスト:膝(knee)を見つける#
閾値は、測定されたものだけが正当化できます。プロジェクトごとに専用のハーネスがあります。
- 見積もりアプリには、同時実行数を掃引してRPS、p50/p95/p99、エラー率を報告するカスタムPythonストレスハーネス(
scripts/stress-test.py)と、リスト系エンドポイントが現実のデータ量に対してクエリされるよう数百件のリアルなジョブを生成する管理コマンドがあります。 - マーケットプレイスはLocustで30秒刻みのステップ負荷により50 → 500ユーザーまでランプして飽和点を探し、実際の閲覧中心トラフィックに合わせたエンドポイント重み付けを使い、さらに別建てのk6スイートが仮想ユーザー5 → 50をp95 < 2秒の閾値付きでプロダクションに対してランプします。
見積もりアプリのスイープの代表的な数値です(Granianワーカー1 × ブロッキングスレッド16、開発マシン上のDocker):
| エンドポイント | 同時実行 | RPS | p95 | エラー |
|---|---|---|---|---|
| health | 25 | 602 | 52ms | 0% |
| ジョブ一覧 | 25 | 309 | 260ms | 0% |
| 顧客一覧 | 25 | 357 | 61ms | 0% |
| ジョブ一覧 | 50 | 309 | 347ms | 0% |
そして他のすべてを決める数字が、shared-cpu-1xマシン1台でマーケットプレイスを負荷テストした結果から出てきます。旧来のASGIビルドでは、これが固い壁でした。同期ビューが実質3スロットほどに直列化されるため、p95は同時リクエスト約5までは300ms未満を保ち、すべてがthread_sensitiveのロックの後ろに並ぶことで約8〜10を超えると崩壊していました。この膝を外側へ押し出したのが、APIをWSGIスレッドプールへ移したことです—マシンあたりの実質同時スロットが約3ではなく16になり、この新しい天井がオートスケーリング設定全体の根拠です。
Fly.ioでのスケーリングの仕組み#
Flyのモデルはシンプルです。アプリはプロキシの背後にある同一マシンの集合で、プロキシがマシンごとの負荷を数えます。宣言するのは2つの数字だけです。
[http_service.concurrency]
type = 'requests'
soft_limit = 12 # これを超えたら別のマシンを起動
hard_limit = 24 # これを超えたらキューイングせず負荷を遮断
auto_stop_machines = 'stop'
auto_start_machines = true
min_machines_running = 1スケールアップ: あるマシンの処理中(in-flight)リクエストがsoft limitを超えると、プロキシは停止中のマシンを起動し、新しいトラフィックをそちらへ流します。WSGIスレッドプールによってマシンあたり実質16の同時スロットがあるので、soft limitは12—プールの約75%で、ユーザーが数秒のレイテンシを体験した後ではなく、スレッドにまだ余裕があるうちに2台目が起動します。hard limitの24(プールの1.5倍)はサーキットブレーカーです。短いバーストはスレッド上でキューイングされ、それを超えたら、1台のマシンが溶け落ちるのを許すのではなく、プロキシが負荷を遮断します。(旧来のASGIビルドは実質スロットが約3で8/20でした。プールを広げたことで両方の数字を引き上げられたのです。)
スケールダウン: トラフィックが減ると、Flyはアイドルマシンを自動的に停止し、min_machines_running = 1まで縮小します。停止中のマシンはコンピューティング費用がかからず(rootfsのストレージ分のみ)、プロキシが再び必要とすれば1秒未満でコールドスタートします。各アプリのベースラインコストは文字通り小さなVM 1台で、バースト用のキャパシティは常に待機しています。
ひとつ微妙なポイント:同時実行のタイプがconnectionsではなくrequestsであることです。現代のクライアントはkeep-aliveコネクションを開きっぱなしにするため、コネクションを数えても実負荷でほとんど変動せず、オートスケーラーが発火しません。処理中リクエストを数えることで、実際の仕事量が追跡できます。
WebSocketは別の軸でスケールする#
マーケットプレイスはWebSocketサーバーを、同じDockerイメージを共有する2つ目のFlyアプリに分離しています。APIアプリはWSGIでワーカー1つ × ブロッキングスレッド16を動かし、リクエストを数えます(soft limit 12)。WebSocketアプリは512MBのマシンでASGIでuvloopとともにワーカー1つを動かし、コネクションを数えます。
[http_service.concurrency]
type = 'connections'
soft_limit = 1500
hard_limit = 2000長寿命のソケットは維持コストこそほぼゼロですが、スロットを何時間も占有します。一方HTTPリクエストがスロットを占有するのはミリ秒単位です。同じオートスケーリングシグナルで両方を賄うことはできません。分離すれば各フリートがそれぞれに正直なメトリクスでスケールし、アイドル状態のWebSocketキャパシティのコストは2GBではなく512MBで済みます。Channels側では、Redisレイヤーをcapacity: 1500、メッセージ有効期限60秒で動かし、30秒ごとにソケットのkeepaliveを送ります。FlyのRedisプロキシが、Channelsの依存する長寿命のBRPOPソケットをアイドルと見なして切断してしまうからです。
コンピューティングをエッジとクライアントへオフロードする#
最も安いサーバー処理は、サーバーが一切やらない処理です。
- 静的ファイルとメディアはFlyのS3互換ストレージであるTigrisに置かれ、非公開メディアは署名付きURLでCDN経由配信されます。画像のバイト列はDjangoマシンを一切通りません。
- AI推論はユーザーのスマートフォン上で動きます。 両アプリともllama.rnでローカルLLMを組み込んでいます(マーケットプレイスは量子化された4Bモデル約2.7GBをストリーミングし、見積もりアプリはデータレイヤーにツールコーリングを接続して、アシスタントがジョブの作成・更新まで行えます)。サーバー側の推論コストはゼロで、オフラインでも動作します。
- オフラインファーストのモバイル(Expo SDK 57、端末ではSQLite、WebではDexie、リトライ付きの同期キュー)のおかげで、アプリはキー入力のたびにAPIを叩くおしゃべりなリクエストではなく、書き込みをバッチにまとめ、電波の届かない場所にも耐えます。
残りのスタックを手短に#
両アプリともFirebase AuthとWebAuthnパスキーを使い、カスタムミドルウェアがアップグレード完了前にWebSocketコネクションを認証します。プロダクションはPostgres、開発はSQLite。管理画面はdjango-unfold、監査証跡はdjango-simple-history。ツーリングはuvとruff。モバイルはReact Native 0.86 / React 19 / TypeScriptにExpo Router、Gluestack UI v3、Tailwind 4で、OTAアップデートはセルフホストのExpo Updates、サブスクリプションはRevenueCatです。品質ゲートはバックエンドがpytest + mypy + bandit、クライアントがJest + Detox + Playwright。
持ち帰ってほしいもの#
移植できるアイデアは特定のライブラリではなく、ループそのものです。安いマシン1台を本当に速くする(Rustのホットパス、コネクション再利用、本当のボトルネックの前のキャッシュ、CDNとクライアントへのオフロード)。どの同時実行数で壊れるかを特定できるまで負荷テストする。オートスケーラーのsoft limitをその膝に置き、アイドルマシンは停止させ、最小フリートは1に保つ。プロダクションアプリ2つ、リアルタイム機能、AIアシスタント — それでも定常時の請求額は、プラットフォームで最安のVM数台分です。

