1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

個人開発のVPSが突然死した原因がRedisの再起動だった件

1
Posted at

ある日曜の朝、サイトが死んでいた

個人開発で中古バイクの検索サイト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制限中」 の赤いバッジが表示されていました。

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

image.png
再起動前は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%使われていたということは、常時メモリ不足をディスクで補っていたということです。

学んだこと

  1. docker compose restart は気軽に打つな。 Redisが入っているなら restart app web で限定する
  2. Redis AOF永続化は必須。 インメモリDBを永続化なしで本番運用するのは爆弾を抱えているのと同じ
  3. フルインポートを毎日回すな。 差分同期の仕組みを最初から作るべきだった
  4. 4GBのVPSには限界がある。 MySQL + Meilisearch + Redisの3つを載せるなら8GB以上を推奨
  5. Swapが増え始めたらスケールアップのサイン。 「動いているから大丈夫」は危険

最後に

結局、さくらVPSを4GB → 8GBにスケールアップしました。月額3,960円 → 7,920円。

個人開発で月4,000円の追加出費は痛いですが、日曜日を丸一日サーバー障害対応に費やすよりはマシです。

皆さんも docker compose restart を打つ前に、一呼吸置いてください。そのRedis、永続化されてますか?

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?