空きメモリはほぼゼロなのに、なぜ Linux サーバーは平気で動き続けるのか?
本番サーバーに入って free -m を叩いたら、16GB 積んでいるのに free の列が 300MB しかない。メモリリークを疑ってアプリのプロセスを見ても、使っているのは数 GB だけだ。残りのメモリは誰が握っていて、なぜサーバーは何事もなく動き続けているのだろうか。
- Linux は使われていないメモリを遊ばせず、一度読み書きしたファイルの中身をページキャッシュとして残しておくため、
free列は小さくなるのが普通である。 - ページキャッシュはアプリがメモリを要求すると捨てて明け渡せるので、「あとどれだけ使えるか」は
freeではなくavailable列で見る。 - コンテナでは cgroup がページキャッシュも使用量に数えるため、監視値は膨らんで見える。ただし上限に達するとまず回収が走り、回収しきれない場合にだけ OOM になる。
ディスクは遅いので、カーネルは読んだ中身を取っておく
ディスクからの読み込みはメモリ上のデータを読むより桁違いに遅い。そこで Linux カーネルは、ファイルを読み書きするときに中身をいったんメモリに置き、次に同じ箇所が読まれたらディスクに行かずにメモリから返す。この「ファイルの中身をメモリに保持しておく領域」をページキャッシュ(page cache)と呼ぶ。ページとは、カーネルがメモリを管理する固定長の単位である。
ページキャッシュは、アプリが明示的に頼まなくても勝手に作られる。ログファイルへの追記、Rails の起動時に読まれる大量の .rb ファイル、DB のデータファイルの読み込み、どれも通常の read() / write() を通る限りページキャッシュを経由する。書き込みも同じで、write() はまずページキャッシュ上のページを書き換えて返り、ディスクへの書き戻しはあとからカーネルが行う。まだ書き戻されていないページはダーティページ(dirty page)と呼ばれる。
カーネルの方針は単純で、空いているメモリは遊ばせておくより、キャッシュに使った方が得だというものだ。だから稼働時間が長いサーバーほど、ページキャッシュが空きメモリを埋めていき、free 列は小さくなっていく。
free の列を正しく読む
free コマンドは /proc/meminfo の値を整形して表示している。列の意味は次のとおりで、初学者が引っかかるのは free と available の違いである。
$ free -m
total used free shared buff/cache available
Mem: 15990 4210 312 120 11468 11402
(数値は説明用の例)free 列は本当に何にも使われていないメモリ(MemFree)である。buff/cache はページキャッシュとカーネルの回収可能なキャッシュ(Buffers + Cached + SReclaimable)の合計だ。そして available は「スワップせずに新しいアプリを起動するのに使えるメモリの見積もり」(MemAvailable)で、空きメモリに加えて、捨てれば明け渡せるページキャッシュ分も含めて計算される。
現行の procps の free では、used 列も total − available として計算される。つまり上の例で本当に気にすべきなのは「available が 11GB 残っている」という事実で、free が 312MB であることはほぼ何も意味しない。MemAvailable はカーネル 3.14 で導入された値で、それ以前のカーネルでは free が近似計算で代用している。
total 16GB の内訳
┌──────────┬────────────────────┬──┐
│ used │ buff/cache │fr│
│ アプリ │ ページキャッシュ │ee│
└──────────┴────────────────────┴──┘
◀──── available ────────▶
捨てれば空く分 + 本当の空き ≒ available
ただし buff/cache の全量が回収できるわけではない。書き戻し前のダーティページはディスクへ書き終えるまで捨てられないし、tmpfs や共有メモリ(shared 列)の中身はファイルの写しではなく実データなので、キャッシュとして捨てられない。available が free + buff/cache より小さいのはこのためである。
図書館の閲覧机に、さっき読んだ本を棚に戻さず積んだままにしておく運用に近い。机の上(メモリ)が本で埋まっていても困らないのは、誰かが机を使いたいと言った瞬間に、書き込みのない本はそのまま書庫(ディスク)に戻せばよいからだ。メモを挟んだ本(ダーティページ)だけは、メモを清書してからでないと片付けられない。机の空き具合を見るなら「何も置いていない面積」ではなく「すぐ片付けられる本も含めた面積」を数えるべきで、それが available である。
本当にメモリが足りないときに起きること
アプリが新しくメモリを要求し、空きページが足りなくなると、カーネルはメモリの回収(reclaim)を始める。回収の対象は主に、しばらく使われていないページキャッシュだ。カーネルはページを最近使われたもの(active)とそうでないもの(inactive)のリストに分けて管理しており、inactive 側から優先して捨てる。ディスクと内容が一致しているページなら、捨てても次に読むときにディスクから読み直せばよいだけなので、データは失われない。
キャッシュを捨てても足りなくなると、アプリが確保した匿名メモリ(anonymous memory、ファイルに対応しないヒープやスタックの領域)をスワップに追い出すか、スワップが無ければ OOM killer(Out Of Memory killer、メモリを解放するためにプロセスを強制終了するカーネルの仕組み)がプロセスを殺す。つまり「メモリが危ない」のサインは free の小ささではなく、available が減り続けること、スワップの使用が増え続けること、そして OOM のログが出ることである。
キャッシュを手動で捨てる echo 3 > /proc/sys/vm/drop_caches という操作もある。1 でページキャッシュ、2 で回収可能な slab(dentry や inode のキャッシュ)、3 で両方を捨てる。しかしカーネルのドキュメント自身が、捨てたものを作り直すために I/O と CPU を大量に使うことがあり、テストやデバッグ以外での使用は推奨しないと明記している。free 列を大きく見せるために定期実行するのは、せっかく温まったキャッシュを毎回冷やすだけの逆効果だ。
コンテナではページキャッシュも「使用量」に数えられる
ここまでの話は、コンテナになると見え方が変わる。コンテナのメモリ上限は cgroup(control group、プロセス群ごとに資源の使用量を計測・制限するカーネルの仕組み)で実装されている。cgroup v2 の memory.current はアプリのヒープだけでなく、そのコンテナが読み書きしたファイルのページキャッシュも含む。ページキャッシュは、そのページを最初に作った cgroup に計上され、解放されるまで計上され続ける。
するとログを大量に書くだけのコンテナでも、メモリ使用量のグラフは上限近くまで上がっていく。これは OOM の前兆のように見える。しかし実際は、上限に近づくとまずそのコンテナ内のページキャッシュが回収され、それでも上限を下回れない場合に限って、そのコンテナ内で OOM killer が動く。キャッシュで膨らんだだけなら、多くの場合は落ちない。
Kubernetes の kubelet はこの事情を踏まえ、ノードの空きメモリ判定に「ワーキングセット」を使う。これは使用量から inactive_file(inactive リストにあるファイル由来のページ)を差し引いた値で、回収しやすいキャッシュを除外している。逆に言えば、最近アクセスされた active なページキャッシュはワーキングセットに残るため、kubectl top や container_memory_working_set_bytes がアプリのヒープより大きく出ることがある。中身を確かめたいときは、コンテナ内の /sys/fs/cgroup/memory.stat の anon(アプリのメモリ)と file(ページキャッシュ)を見比べると切り分けられる。
典型は、監視ダッシュボードの「メモリ使用率 95%」アラートである。ホストの MemFree ベースで閾値を組んでいると、ページキャッシュが育つだけで鳴り続けるので、閾値は MemAvailable ベースに置き換える。コンテナでは、メモリ使用量グラフが上限に張り付いているのに OOMKilled が一度も出ていない場合、memory.stat の file が大半を占めていないかを先に見る。逆に anon が右肩上がりで減らないなら、それはキャッシュではなくアプリのリークを疑う番だ。DB サーバーでは、ページキャッシュがデータファイルの読み込みを肩代わりしているため、キャッシュを捨てた直後にクエリが急に遅くなるという形でも出会う。
Docker のコンテナ内で 300MB のファイルを書き、ページキャッシュが増えること、そしてそれがコンテナの cgroup に file として計上されることを確かめる(cgroup v2 で動く最近の Docker Desktop を想定。/proc/meminfo は Mac 本体ではなく Docker の Linux VM の値を表示する)。
docker run --rm alpine sh -c '
show() {
grep -E "^(MemFree|MemAvailable|Cached):" /proc/meminfo
grep -E "^(anon|file) " /sys/fs/cgroup/memory.stat
echo ---
}
show
dd if=/dev/urandom of=/tmp/big bs=1M count=300 2>/dev/null
show
rm /tmp/big
show
'
書き込み後は Cached と cgroup の file がおよそ 300MB 増え、MemFree は減るはずだ。一方 anon はほとんど変わらない。アプリのメモリは増えていないのに「使用量」は増えた、というコンテナ監視の錯覚がそのまま再現できる。ファイルを消すとそのページキャッシュも不要になり、file が元の水準に戻る。
free列が小さいのはメモリ不足だ — 空きメモリはページキャッシュに使われるのが Linux の通常運転である。新しいアプリに使えるメモリの見積もりはavailable列が表しており、こちらが十分に残っていれば問題はない。- 定期的に
drop_cachesするとサーバーが軽くなる — 捨てたキャッシュは次のアクセスでディスクから読み直しになるため、直後はむしろ I/O が増えて遅くなる。カーネルのドキュメントもテスト・デバッグ以外での使用を推奨していない。 - コンテナのメモリ使用量が上限に近い=もうすぐ OOMKilled — cgroup の使用量にはページキャッシュも含まれ、上限に近づくとまずそれが回収される。OOM が起きるのは回収しても上限を下回れないときで、
memory.statのanonが上限近くまで育っている場合が危ない。
- ページキャッシュ
- カーネルが読み書きしたファイルの中身をメモリに保持しておく領域。不要になれば捨てて明け渡せる。
- MemAvailable
- スワップせずに新しいアプリへ割り当てられるメモリの見積もり。
freeのavailable列の元データ。 - ダーティページ
- 書き換えられたがまだディスクに書き戻されていないページキャッシュ。書き戻すまで捨てられない。
- 匿名メモリ
- ファイルに対応しないメモリ(ヒープやスタック)。捨てられないので、足りなければスワップか OOM になる。
- cgroup
- プロセス群ごとに CPU やメモリの使用量を計測・制限するカーネルの仕組み。コンテナのリソース上限の実体。
- ワーキングセット
- kubelet が使うメモリ指標。使用量から inactive なファイル由来ページを差し引いた値。
- free(1) — Linux manual page — 各列が
/proc/meminfoのどの値から計算されているかの一次情報。 - Documentation for /proc/sys/vm/ — The Linux Kernel documentation —
drop_cachesの挙動と、本番で使うべきでない理由が書かれている。 - Control Group v2 — The Linux Kernel documentation — Memory の節で、ページキャッシュがどの cgroup に計上されるか、
memory.max到達時の回収と OOM の順序が分かる。 - Node-pressure Eviction — Kubernetes — kubelet が
inactive_fileを除いたワーキングセットで空きメモリを判定する理由。