ある日曜の朝、サイトが死んでいた
個人開発で中古バイクの検索サイトMotoHubを運営しています。さくらVPS(4GBメモリ)上にDocker Composeで以下の構成を載せています。
- Laravel 12 + PHP-FPM(アプリケーション)
- MySQL 8.0(27万件の車両データ)
- Meilisearch(全文検索エンジン)
- Redis(キャッシュ)
- Nginx(Webサーバー)
日曜の朝、サイトにアクセスしたら表示が異常に遅い。SSHで入ってみると、System Loadが3.56、Swap使用率71%。明らかにサーバーが悲鳴を上げていました。
最初の判断ミス:docker compose restart
「とりあえず再起動」は開発者の本能です。
docker compose restart
これが最悪の一手でした。
何が起きたか
docker compose restart は全コンテナを再起動します。当然 Redis も再起動 されます。
Redisはインメモリデータベースです。再起動 = キャッシュ全消去。
私のサイトでは、車両一覧・ランキング・相場データなど、ほぼすべてのページでRedisキャッシュを使っています。キャッシュが消えると、全ページのリクエストがMySQLに直撃します。
再起動前
ユーザーアクセス → Redis(キャッシュHIT)→ 即レスポンス
再起動後
ユーザーアクセス → Redis(MISS)→ MySQL(27万件テーブルにクエリ)→ 遅い
全ページ × 全ユーザー × 全ボット = MySQLパンク
docker stats の結果がこちら:
motohub-db: CPU 414.59% MEM 531.7MiB
motohub-app: CPU 120.33% MEM 100.9MiB
MySQL が CPU 414% を叩き出していました。
レスポンスタイム 25秒
$ curl -s -o /dev/null -w "%{http_code} %{time_total}s" https://motohub.jp
504 60.133984s
504 Gateway Timeout。Nginxがphp-fpmからの応答を待ちきれずタイムアウト。さくらVPSのコンパネには 「CPU制限中」 の赤いバッジが表示されていました。

4GBのVPSでMeilisearch 908MBが最大の消費者

再起動前はRedis経由で0.3秒、再起動後はMySQL直撃で25秒
正しい対応
① Redisを巻き込まない再起動
# NG: 全コンテナ再起動(Redisも死ぬ)
docker compose restart
# OK: アプリとWebだけ再起動(Redisはそのまま)
docker compose restart app web
これだけで、キャッシュを保持したままアプリを再起動できます。
② Redis AOF永続化
Redisのデフォルトではデータはメモリ上のみで、再起動すると全消去されます。AOF(Append Only File)を有効にすれば、再起動後もデータが復元されます。
docker-compose.yml に以下を追加:
redis:
image: redis:alpine
command: redis-server --appendonly yes
volumes:
- redis-data:/data
volumes:
redis-data:
これで、万が一Redisが再起動されてもキャッシュが復元されます。
③ Meilisearchのフルインポートを廃止
毎朝5:30に走っていたこのコマンド:
php artisan scout:import 'App\Models\Listing'
27万件のレコードを毎日全件Meilisearchに流し込んでいました。これはDBの読み出しとMeilisearchへの書き込みで、メモリとCPUを大量に消費します。
実はLaravelのSearchableトレイトを使っていれば、Eloquent経由の保存時に自動でMeilisearchに同期されます。ただし、私のサイトではPythonスクレイパーがSQLAlchemyで直接DBに書き込んでいるため、Searchableトレイトが発火しません。
解決策として、needs_reindex フラグ方式を採用しました:
# Python側:保存時にフラグを立てる
listing.needs_reindex = True
// Laravel側:フラグが立ったレコードだけ同期
Listing::where('needs_reindex', true)
->chunk(500, function ($listings) {
$listings->searchable();
Listing::whereIn('id', $listings->pluck('id'))
->update(['needs_reindex' => false]);
});
毎日27万件 → 数千件の差分だけ。DBへの負荷が劇的に減りました。
4GBメモリの内訳
docker stats で見た各コンテナのメモリ使用量:
| コンテナ | メモリ | 割合 |
|---|---|---|
| MySQL | 412MB | 10.5% |
| Meilisearch | 908MB | 23.2% |
| Redis | 159MB | 4.1% |
| PHP-FPM | 128MB | 3.3% |
| Nginx | 20MB | 0.5% |
| OS + その他 | 約800MB | - |
| 合計 | 約2.4GB | - |
一見すると4GBに収まっているように見えますが、MySQLやMeilisearchはピーク時にこの2〜3倍のメモリを使います。そしてSwapが71%使われていたということは、常時メモリ不足をディスクで補っていたということです。
学んだこと
-
docker compose restartは気軽に打つな。 Redisが入っているならrestart app webで限定する - Redis AOF永続化は必須。 インメモリDBを永続化なしで本番運用するのは爆弾を抱えているのと同じ
- フルインポートを毎日回すな。 差分同期の仕組みを最初から作るべきだった
- 4GBのVPSには限界がある。 MySQL + Meilisearch + Redisの3つを載せるなら8GB以上を推奨
- Swapが増え始めたらスケールアップのサイン。 「動いているから大丈夫」は危険
最後に
結局、さくらVPSを4GB → 8GBにスケールアップしました。月額3,960円 → 7,920円。
個人開発で月4,000円の追加出費は痛いですが、日曜日を丸一日サーバー障害対応に費やすよりはマシです。
皆さんも docker compose restart を打つ前に、一呼吸置いてください。そのRedis、永続化されてますか?