2GBのLightsailでMySQLをSQLiteに移行すべきか — Next.js + Docker構成での判断基準と落とし穴

目次

2GB環境でMySQLコンテナが実際に食っているもの

まず現状を数値で押さえる。docker stats を実行すると、MySQL 8系のコンテナは何もしていなくても400〜500MB程度のRSSを持っていくことが多い。これは innodb_buffer_pool_size のデフォルト128MBに加えて、パフォーマンススキーマ、各種キャッシュ、コネクションごとのバッファが積み上がった結果だ。

docker stats --no-stream --format "table {{.Name}}\t{{.MemUsage}}\t{{.MemPerc}}"

2GB RAMのインスタンスでは、OSとDockerデーモンで300MB前後、Next.jsのランタイムで300〜500MB(next build を同一インスタンス上で走らせるなら一時的に1GB超)を消費する。ここにMySQLが加わると、平常時は動くがビルド時にOOM Killerが顔を出す、という綱渡りの構成になる。

SQLiteに移行した場合の配分イメージは以下のようになる。SQLiteはNext.jsプロセスに組み込まれるライブラリなので、独立したメモリ常駐プロセスが消える点が大きい。

2GB RAMにおけるメモリ配分の比較 2048 MB 現行 (MySQLコンテナ) OS + Docker 約320MB MySQL 約480MB Next.js 約420MB 空き 約830MB SQLite構成 OS + Docker Next.js + SQLite 空き 約1280MB (ビルド時の余裕)

ここで重要なのは、削減されるのはメモリだけではないという点だ。コンテナが1本減ることで docker compose の依存関係定義、ヘルスチェック待ち、起動順序の制御が不要になる。個人開発や小規模サービスにおいては、この運用面の単純化のほうが実利が大きいケースが多い。

SQLiteは本番で使えるのか

「SQLiteは組み込み用」という前提はすでに古い。判断すべきは汎用的な良し悪しではなく、自分のアクセスパターンが SQLite の並行性モデルに収まるかどうかだ。

書き込みは常に直列化される

SQLiteはデフォルトのロールバックジャーナルモードだと、書き込み中は読み取りもブロックされる。本番運用では WALモード(Write-Ahead Logging)が必須になる。

PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA busy_timeout = 5000;
PRAGMA foreign_keys = ON;

WALモードでは読み取りと書き込みが互いをブロックしなくなるが、同時に書き込めるのは常に1つの接続だけという制約は残る。この制約に他の接続がぶつかると SQLITE_BUSY が返るため、busy_timeout を設定して待機させるのが定石だ。

WALモードにおける読み書きの流れ 読み取り処理 A 読み取り処理 B 書き込み処理 main.db 確定済みデータ main.db-wal 追記ログ チェックポイント 書き込みは同時に1つのみ(他はbusy_timeoutで待機) 読み取りは書き込みにブロックされない

Next.jsのアプリケーションサーバーは基本的に単一のNodeプロセスとして動くため、この制約は思ったほど問題にならない。むしろ危険なのは、PM2のクラスタモードや複数コンテナへのスケールアウトを将来的に検討している場合だ。同一のDBファイルを複数プロセスから開く構成は、ネットワークファイルシステム上でなければ動作はするが、書き込み競合が一気に顕在化する。

移行が向くケース・向かないケース

判断軸を整理する。自分のサービスがどちらに寄っているかを確認してほしい。

観点SQLiteが向くMySQLを維持すべき
書き込み頻度数req/s以下、ほとんどが読み取り継続的な書き込みが多い、バッチ更新が走る
プロセス構成Next.jsコンテナ1本で完結複数プロセス・複数コンテナから接続
スケール計画当面は垂直スケールで十分水平スケールやDB分離を視野に入れている
外部からのDB接続不要(アプリ経由のみ)管理ツールやバッチから直接接続する
全文検索FTS5で足りるngramパーサやmroongaに依存している
分析クエリ単純なJOIN・集計中心ウィンドウ関数を多用した重い集計

書き込みが少なく単一プロセスで完結しているなら、SQLiteはむしろ高速だ。ネットワークやIPCを経由しない関数呼び出しでクエリが完結するため、1クエリあたりのレイテンシは数十マイクロ秒のオーダーに落ちる。N+1的なクエリが混ざっているアプリでは、移行後に体感速度が明確に上がることもある。

移行前に検討すべきMySQL側のチューニング

移行はそれなりのコストを伴うため、先にMySQL側で解決できないかを試す価値はある。compose.yaml に以下を追加するだけで、メモリ使用量は200MB前後まで落ちることがある。

services:
  db:
    image: mysql:8.0
    command: >
      --innodb-buffer-pool-size=64M
      --performance-schema=OFF
      --innodb-log-buffer-size=4M
      --max-connections=20
      --table-open-cache=64
    deploy:
      resources:
        limits:
          memory: 400M

performance_schema の無効化だけでも100MB近く削減できる。これで足りるなら、移行しないという判断が最も合理的だ。逆にこれでも逼迫するなら、SQLite移行かインスタンスのアップグレードの二択になる。

実際の移行で詰まるポイント

型とスキーマの差異

SQLiteは動的型付けで、宣言された型は「型親和性」でしかない。MySQLからの移行で必ず引っかかる箇所を挙げる。

MySQLSQLiteでの扱い注意点
AUTO_INCREMENTINTEGER PRIMARY KEYAUTOINCREMENT は通常不要。付けると別テーブルを消費し遅くなる
DATETIMETEXT または INTEGERISO8601文字列かUnix時刻で保存。タイムゾーン概念がない
ENUMTEXT + CHECK制約Prismaのenumはsqliteプロバイダで非サポート
BOOLEANINTEGER (0/1)MySQL側も実質TINYINTなので影響は小さい
utf8mb4_unicode_ci照合順序はバイナリ比較大文字小文字を区別しない検索は COLLATE NOCASE が必要(ASCII限定)
DECIMALTEXT 推奨REALで持つと丸め誤差が出る。金額カラムは要注意
TEXTへの全文検索FTS5仮想テーブル日本語はトークナイザ設定が別途必要

DECIMAL を扱っているなら、ここが最大の地雷になる。SQLiteにネイティブの固定小数点型はないため、文字列保存+アプリ側でのdecimal.js等による演算に切り替えるか、最小単位の整数(円単位)で持つ設計に改める必要がある。

DBファイルの配置とDockerボリューム

SQLiteのファイルは、必ずコンテナ外の永続領域に置く。overlayfs上に置くとコンテナの再作成で消える。

services:
  app:
    build: .
    volumes:
      - ./data:/app/data
    environment:
      DATABASE_URL: "file:/app/data/production.db"

バインドマウントを使う場合、ホスト側ディレクトリのパーミッションとコンテナ内ユーザーのUIDを揃えておかないと、書き込み時に SQLITE_CANTOPEN が出る。Next.jsの公式Dockerfileは nextjs ユーザー(UID 1001)で実行されるため、ホスト側も合わせておく。

もう一点、WALモードではDBファイル本体に加えて -wal-shm の2ファイルが生成される。バックアップやコピーの際にこれらを取りこぼすと、未チェックポイントのデータが失われる。

ネイティブモジュールのビルド

better-sqlite3 はネイティブアドオンなので、マルチステージビルドでイメージのベースが変わるとABI不整合で落ちる。Alpine(musl)とDebian系(glibc)を混在させないこと、ビルドステージと実行ステージのNodeメジャーバージョンを揃えることが前提になる。

FROM node:22-bookworm-slim AS builder
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
# ...
FROM node:22-bookworm-slim AS runner

Node.js 22.5以降には標準の node:sqlite モジュールが入っているため、要件が単純ならこちらでネイティブビルド自体を回避する手もある。

バックアップ戦略を作り直す

mysqldump をcronで回していた運用は、そのまま置き換えられない。稼働中のDBファイルを cp するのは破損の原因になるため、SQLiteのAPIを使う。

sqlite3 /app/data/production.db "VACUUM INTO '/backup/prod-$(date +%Y%m%d).db'"

VACUUM INTO は一貫性のあるスナップショットを作りつつ、断片化も解消する。ただしこれは日次バックアップであり、障害時のデータ損失は最大1日分になる。継続的なレプリケーションが必要なら、Litestreamを組み合わせてS3へWALを逐次送る構成が現実的だ。Lightsailからであれば、同一リージョンのS3への転送コストはほぼ無視できる。

なお、MySQLでは当たり前に使えていた「別ホストからDBに接続して調査する」という運用は失われる。SQLiteのファイルはサーバー上にしか存在しないため、ssh してから sqlite3 を叩くか、管理用のエンドポイントをアプリ側に用意する必要がある。この運用変更を許容できるかは、移行判断の隠れた分岐点になる。

データ移行とORM側の切り替え

Prismaを使っているなら、providersqlite に変えたうえで、非対応機能を潰していく作業になる。enum、@db. 属性、スカラーリストはすべて書き換え対象だ。マイグレーション履歴もプロバイダ間で互換性がないため、prisma/migrations を作り直すことになる。

データ本体の移行は、MySQLからCSVで吐いてSQLiteに取り込むのが最も確実だ。

mysqldump --tab=/tmp/dump --fields-terminated-by=',' \
  --fields-enclosed-by='"' myapp

sqlite3 production.db <<'EOF'
.mode csv
.import /tmp/dump/users.txt users
EOF

件数の少ないテーブルなら、両方のDBに接続するスクリプトを一度書いてORM経由で流し込むほうが、型変換を明示的に制御できて安全だ。移行後は、外部キー制約が実際に張られているか(SQLiteは接続ごとに PRAGMA foreign_keys を有効化しないと制約が効かない)と、インデックスがすべて再作成されているかを必ず確認する。移行直後は問題なく見えても、データ量が増えた段階でインデックス漏れによる全表スキャンが表面化する。

目次