AOF Rewriteの深淵:`BGREWRITEAOF` が引き起こすカーネルの悲鳴とメモリ最適化の極意
Redisの運用において、永続化(Persistence)の選択は常にスループットとデータの安全性(Durability)のトレードオフの歴史である。RDBのスナップショットが持つIOバーストとデータロストのリスクを嫌い、AOF(Append Only File)を選択するシステムは多い。
だが、AOFには構造的な宿命がある。すべての書き込みコマンドが時系列で追記されるため、ファイルサイズは時間の経過とともに肥大化する。同じキーに対する無数の `SET`、`HSET`、そして削除されたキーの残骸。これらがディスク容量を圧迫するだけでなく、万が一の再起動時(Recovery Phase)における無限とも思えるリプレイ時間を引き起こす。
この致命的な課題を解決するのが `BGREWRITEAOF` である。
単なる「ファイル圧縮コマンド」などと思ってはならない。これは、Linuxカーネルの仮想記憶管理、Copy-on-Writeの限界、そしてRedisのシングルスレッドモデルの裏側で繰り広げられる、最も洗練されていて、同時に最も危険な低レイヤの芸術なのだ。
本稿では、`BGREWRITEAOF` の内部メカニズムを解体し、プロダクション環境で致命傷を避けるための極限の知見を共有する。
—
1. `BGREWRITEAOF` の内部メカニズム:何が起きているのか?
AOFの書き換えは、既存のAOFファイルを直接編集するわけではない。現在のメモリ上のデータ構造をスキャンし、「現在の状態を再現できる最小限のコマンド列」を全く新しいファイルにゼロから構築するプロセスである。
コマンドが実行された瞬間、Redisの内部で何が起きているのか。そのシーケンスを追う。
[Client] —> BGREWRITEAOF —> [Redis Main Thread]
|
v (fork())
[Child Process]
|
+—> 1. テンポラリファイルへ最小コマンド列を出力 (appendonly.aof.new)
|
[Client] —> WRITE/DEL etc. —> [Parent Process (Main Loop)]
|
+—> 2. AOF Rewrite Buffer に差分コマンドを蓄積
|
v (Rewrite 完了)
[Child Exit & Pipe Notification]
|
v
[Main Thread]
|
+—> 3. AOF Rewrite Buffer の差分をテンポラリファイルに追記
+—> 4. atomicな rename(2) でファイル名を置換
fork(2) のコストと Copy-on-Write (CoW) の罠
すべてのバックグラウンド処理(RDBの `BGSAVE` を含む)と同様に、`BGREWRITEAOF` も最初に `fork()` を呼び出す。ここで親プロセス(メインスレッド)と子プロセスが誕生する。
ここで発生するのが、Linuxカーネルの Copy-on-Write (CoW) によるメモリコピーのオーバーヘッドだ。
Redisが保持するデータセットが 50GB に達しているとしよう。`fork()` 自体はページのコピーを行わずページテーブルを複製するだけだが、子プロセスが生存している間に親プロセスが既存のキーを更新(`SET` や `HSET`)すると、カーネルはそのページを物理的に複製する。
結果として、最悪の場合、メモリ使用量が一時的に倍増(50GB + 50GB)する。
もしマシンの物理メモリやスワップ領域が枯渇すれば、Linuxの OOM Killer が発動し、最も慈悲深いプライマリデータベースプロセスが容赦なく狩り取られることになる。
—
2. AOF Rewrite Buffer と差分の同期メカニズム
子プロセスがディスクへの書き込みを行っている最中も、メインスレッドはクライアントからのリクエストを処理し続け、データは刻一刻と変化している。
子プロセスがスナップショット(メモリ上の静的なビュー)をベースにファイル生成を行っている間、メインスレッドで発生した書き込みはどうなるのか?
Redisは、`fork()` 以降に発生したすべての書き込みコマンドを AOF Rewrite Buffer と呼ばれる専用のメモリ領域にバッファリングし続ける。
/ server.h の抜粋概念(実際の構造とは異なります) /
typedef struct redisServer {
// …
rio aof_rewind_file;
sds aof_rewrite_buf_blocks; // 差分を蓄積するバッファ
// …
} redisServer;
子プロセスがファイル書き出しの終盤に差し掛かると、パイプ経由でメインスレッドに通知を送る。通知を受けたメインスレッドは、以下のクリティカルな処理を実行する(この間、わずか数ミリ秒だがメインスレッドのイベントループがブロックされることがある)。
1. AOF Rewrite Buffer に残っている差分コマンドを、新しく生成されたテンポラリファイルに追記する。
2. 標準的な POSIX システムコールである `rename(2)` を使い、テンポラリファイルを正式な `appendonly.aof` にアトミックに置き換える。
この `rename(2)` のアトミクス性により、書き換えの途中でプロセスがクラッシュしても、古いAOFファイルが破損することは絶対にない。
—
3. 実務で直面する致命傷:パラメータチューニングの極意
デフォルトの設定のまま大規模なRedisインスタンスを運用することは、時限爆弾を抱えて寝るようなものだ。以下のパラメータは、システムの生死を分ける。
`auto-aof-rewrite-percentage` と `auto-aof-rewrite-min-size`
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb
- 知見: デフォルトでは「前回のAOFサイズから100%増え、かつ64MB以上」の時に自動で `BGREWRITEAOF` が走る。
- 危険性: メモリが 100GB ある巨大なインスタンスの場合、これでは頻繁に、かつ中途半端なタイミングでリライトが走り、CPUとディスクI/Oが常に高負荷状態に陥る。大規模環境では `percentage` を 200〜300% に引き上げ、`min-size` を数GB単位に設定すべきである。
`no-appendfsync-on-rewrite`
no-appendfsync-on-rewrite yes
- 知見: これを `no` にしていると、子プロセスがディスクへの書き込み(重いI/O)を行っている最中にも、メインスレッドが `fsync`(`appendfsync everysec` 時)をブロックして実行しようとする。結果として、ディスクI/Oの帯域が飽和し、Redis全体が数秒〜数十秒間完全にフリーズする。
- 結論: プロダクション環境では 必ず `yes` に設定 し、リライト中の `fsync` を一時的に抑制せよ。ただし、この数分間にサーバーの電源が物理的に落ちた場合、書き込みのロストリスクがわずかに高まるトレードオフを許容する必要がある。
—
4. チーフアーキテクトからの提言:AOFか、RDBか、あるいはハイブリッドか
近年、Redis 7.0以降で成熟した AOF Multiparts (Manifest-based AOF) の登場により、AOFの構造はさらに洗練された。基本ファイルとインクリメンタルファイル、そしてRDBファイルを組み合わせることで、肥大化した単一ファイルの呪縛から解放されつつある。
しかし、どれほどアーキテクチャが進化しようとも、`BGREWRITEAOF` が本質的に「CPU、メモリ、ディスクI/Oを激しく消費する高コストなバックグラウンド処理」である事実は変わらない。
大規模システムを設計する際のカディナルルール:
1. メモリのヘッドルーム(空き容量)を常に 50% 以上確保せよ。 さもなければ、CoWによるメモリ枯渇でOOM Killerの餌食になる。
2. ピークタイムの自動リライトを避けよ。 cron等で監視し、夜間バッチ的に `BGREWRITEAOF` を手動制御することも視野に入れる。
3. カーネルの Overcommit Memory 設定を確認せよ。 `/proc/sys/vm/overcommit_memory` が `0` のままだと、CoWのメモリバースト時に `fork()` 自体が失敗し、Redisが致命的なエラーを吐いてクラッシュする。必ず `1`(常時許可)に設定しておけ。
基盤を支えるのは、小手先のコマンド知識ではなく、OSカーネルとメモリ管理機構への深いリスペクトである。Redisの限界を突破したいのであれば、CPUキャッシュとページテーブルの呼吸を感じ取れ。
コメント