はじめに — df は 38% 残っていると言うのに touch 一つが失敗します
症状はこう始まります。アプリケーションがログを書けず、デプロイが一時ファイルの作成で失敗し、DB が WAL を書けません。入って確認するとディスクには余裕があります。
df -h /var
# Filesystem Size Used Avail Use% Mounted on
# /dev/nvme0n1p2 100G 61G 35G 64% /
touch /var/log/test
# touch: cannot touch '/var/log/test': No space left on device
Avail が 35GB もあるのに 0 バイトのファイル一つを作れません。ここで「監視がおかしい」と結論づけたくなりますが、df は正直にブロック使用量を報告しただけです。ENOSPC は errno 28 であり、カーネルがこの値を返す経路はブロック不足のほかにもいくつもあります。
この記事ではその経路を確率順に並べ、それぞれを確定するコマンドと解決策、そして再発防止までを扱います。順番どおりに進めれば 5 分で原因が出ます。
先に確認すること — 本当にそのファイルシステムに書いていますか
診断を始める前に 30 秒を使います。df -h を引数なしで実行して出た一覧を目で追い、失敗したパスがどの項目なのかを推測するのが最もよくある間違いです。パスを直接尋ねてください。
# このパスが実際に属しているファイルシステム
findmnt -T /var/log/app/application.log
# TARGET SOURCE FSTYPE OPTIONS
# /var /dev/nvme0n1p5 xfs rw,relatime,attr2,inode64,logbufs=8
df -h /var/log/app/
# Filesystem Size Used Avail Use% Mounted on
# /dev/nvme0n1p5 20G 20G 0 100% /var
/ ではなく /var が別パーティションで、そちらが埋まっていたのです。このケースならここで終わりです。同じように /tmp が tmpfs という環境もよくあります。
df -h /tmp /dev/shm
# Filesystem Size Used Avail Use% Mounted on
# tmpfs 3.2G 3.2G 0 100% /tmp
# tmpfs 7.9G 6.1G 1.8G 78% /dev/shm
tmpfs はメモリを使います。ここが埋まるとディスクとは無関係に ENOSPC になり、同時にその容量ぶんの物理メモリを消費しています。
パスの確認が終わってもなお Avail に余裕があるなら、ここからが本題です。
原因 1: inode 枯渇 — ブロックは残っていてファイル数が尽きた場合
最もよくある原因です。確認は一行です。
df -i /
# Filesystem Inodes IUsed IFree IUse% Mounted on
# /dev/nvme0n1p2 6553600 6553600 0 100% /
IUse% が 100% です。ext2/3/4 はフォーマット時点で inode 数を確定し、その後は増やせません。既定値は mke2fs の -i オプション(bytes-per-inode)が決めますが、既定プロファイルでは 16KB あたり一つ程度です。つまり 100GB のファイルシステムに約 655 万個です。平均ファイルサイズが 16KB より小さいワークロードなら、ブロックより先に inode が尽きます。
犯人のディレクトリを見つける方法です。du は容量を数えるのであって個数を数えないので、ここでは役に立ちません。
# ディレクトリ別のファイル数 上位 10 件 (同じファイルシステムの中だけ)
sudo find / -xdev -printf '%h\n' 2>/dev/null | sort | uniq -c | sort -rn | head -10
# 2841193 /var/spool/postfix/maildrop
# 84120 /var/lib/php/sessions
# 38914 /home/deploy/.cache/pip/http
# 18422 /var/lib/apt/lists
# 9104 /usr/share/man/man3
-xdev が重要です。これを外すとマウントされた別のファイルシステムまで走査するので時間が何倍にも伸び、結果も汚染されます。ツリーが大きくて上のコマンドに時間がかかるなら、最上位から絞っていきます。
for d in /*/; do
printf '%9s %s\n' "$(sudo find "$d" -xdev 2>/dev/null | wc -l)" "$d"
done | sort -rn | head -5
# 2925104 /var/
# 91208 /usr/
# 42011 /home/
典型的な犯人は決まっています。メールキュー(postfix の maildrop や deferred)、PHP のセッションディレクトリ、パッケージマネージャのキャッシュ、セッションやロックファイルを残すアプリケーション、そして片付けられないコンテナレイヤーです。
解決は二段階です。当面は消します。ファイル数が多いと rm -rf が引数長の上限に引っかかるので、find に渡します。
# 30 日以上経過したセッションファイルを削除 (件数が多いときに安全な形)
sudo find /var/lib/php/sessions -type f -mtime +30 -delete
# 削除の進み具合を見ながら (ファイルが数百万個のとき)
sudo find /var/spool/postfix/maildrop -type f -mtime +7 -print -delete | pv -l > /dev/null
根本的な解決はファイルシステムの再作成または移行です。ext4 で inode を増やすには作り直すしかありません。
# 4KB あたり inode 一つ (既定の 4 倍)。データはすべて消えます
sudo mkfs.ext4 -i 4096 /dev/nvme0n1p6
# または inode 数を直接指定
sudo mkfs.ext4 -N 20000000 /dev/nvme0n1p6
XFS へ移すのも実用的な選択です。XFS は inode を動的に割り当てるので、この問題は事実上ありません。ただし完全にないわけではありません。imaxpct が inode に使える容量の比率を制限するからです。
xfs_info /var | grep -E 'imaxpct|isize'
# meta-data=/dev/nvme0n1p5 isize=512 agcount=4, agsize=1310720 blks
# = sectsz=512 attr=2, projid32bit=1
# data = bsize=4096 blocks=5242880, imaxpct=25
# 必要なら上限を上げます (マウントされた状態で可能)
sudo xfs_growfs -m 50 /var
原因 2: 削除されたのに開かれたままのファイル
df と du が大きく食い違うならこちらです。
df -h /var
# Filesystem Size Used Avail Use% Mounted on
# /dev/nvme0n1p5 20G 20G 0 100% /var
sudo du -shx /var
# 3.1G /var
df は 20GB 使ったと言い、du は 3.1GB しか見つけません。この 17GB の差がディレクトリエントリの消えたファイルです。UNIX ではファイルはリンクカウントが 0 で、開かれたディスクリプタも 0 のときに初めて解放されます。rm はリンクを消すだけです。誰かがそのファイルを開いていれば容量は返らず、du はパスを歩くのでそのファイルを数えられません。
確定するコマンドです。
sudo lsof -nP +L1 | head
# COMMAND PID USER FD TYPE DEVICE SIZE/OFF NLINK NODE NAME
# java 21874 app 12w REG 259,5 8589934592 0 1183241 /var/log/app/application.log (deleted)
# nginx 14022 www 9w REG 259,5 4294967296 0 1183502 /var/log/nginx/access.log (deleted)
# rsyslogd 1288 root 7w REG 259,5 429496729 0 1183210 /var/log/syslog.1 (deleted)
+L1 はリンクカウントが 1 未満のオープンファイルだけを出力せよという意味で、NLINK 列の 0 がまさにその状態です。SIZE/OFF 列が返却されていないバイト数です。上の三つを足すと約 12GB になります。
lsof が入っていない最小イメージでは /proc を直接見ます。
sudo ls -l /proc/[0-9]*/fd/* 2>/dev/null | grep '(deleted)' | awk '{print $9, $10, $11}' | head
# /proc/21874/fd/12 -> /var/log/app/application.log
# /proc/14022/fd/9 -> /var/log/nginx/access.log
原因はほぼ必ず同じです。ログローテーションの後にプロセスがファイルを開き直していないことです。logrotate が create 方式でローテートすると元のファイルを移して新しいファイルを作りますが、プロセスは依然として古い inode に書いています。postrotate スクリプトからシグナルを送らなければならないのに、その部分が抜けているか失敗しているのです。
解決は二つあります。
# A) プロセスに再オープンのシグナルを送る (最もきれい)
sudo systemctl reload nginx # nginx は USR1
sudo kill -HUP 1288 # rsyslogd
# B) 再起動が不可能なら fd を通して直接空にします
sudo truncate -s 0 /proc/21874/fd/12
B 案には副作用があります。プロセスが O_APPEND で開いていれば次の書き込みはファイル末尾(いまや 0)から続くので問題ありませんが、そうでなければ以前のオフセットに書き続けるためファイルがスパースファイルになります。実際に占有するブロックは減っても ls -l のサイズはそのまま大きく見えます。応急処置としてだけ使い、正式な解決は A 案です。
logrotate の設定を直すのが再発防止です。
cat /etc/logrotate.d/app
# /var/log/app/*.log {
# daily
# rotate 14
# compress
# delaycompress
# missingok
# notifempty
# copytruncate
# }
copytruncate は元のファイルをコピーしたあと元を 0 に切り詰めます。inode が維持されるのでプロセスは何もしなくて済みます。その代わりコピーと切り詰めの間に書かれたログが失われることがあります。失われては困るなら copytruncate の代わりに postrotate でシグナルを送り、そのスクリプトが失敗しても黙って通り過ぎないようにします。
postrotate
systemctl reload app.service || systemctl kill -s USR1 app.service
endscript
原因 3: 予約ブロック — root だけが使える 5%
ext2/3/4 は全ブロックの一定比率を root 専用に残します。既定値は 5% です。目的は二つで、ディスクが満杯になっても管理者がログインして片付けられるようにすることと、アロケータが連続ブロックを探す余地を残して断片化を遅らせることです。
このケースの症状は特徴的です。root ではファイルを作れるのにサービスアカウントでは作れません。
sudo touch /var/lib/app/probe && echo "root: ok"
# root: ok
sudo -u appuser touch /var/lib/app/probe2
# touch: cannot touch '/var/lib/app/probe2': No space left on device
確認は tune2fs です。
sudo tune2fs -l /dev/nvme0n1p2 | grep -E 'Block count|Reserved block count|Block size|Free blocks'
# Block count: 26214400
# Reserved block count: 1310720
# Free blocks: 1310698
# Block size: 4096
Reserved block count が 1310720 で Free blocks がほぼ同じです。残っている容量がすべて予約ぶんだという意味です。1310720 掛ける 4096 は約 5.0GiB で、26214400 分の 1310720 はちょうど 5% です。
df がこの状況をどう見せるのかを知っておくと誤解が減ります。df の Avail は予約ぶんを引いた値で、Use% は使用量 割る (使用量 足す Avail)で計算されます。ですから予約ぶんだけが残った瞬間に Avail は 0、Use% は 100% になります。つまりこの原因のとき df はたいてい 100% を見せ、「容量が残っているのに」という印象は du との差や root アカウントでの書き込み成功から来ます。
100GB のデータボリュームで 5% は 5GB です。ルートファイルシステムではない純粋なデータボリュームなら、この比率は過剰です。
# 1% に下げます。マウントされた状態で即時に適用され、データは安全です
sudo tune2fs -m 1 /dev/nvme0n1p2
# Setting reserved blocks percentage to 1% (262144 blocks)
# 絶対数で指定したいなら
sudo tune2fs -r 262144 /dev/nvme0n1p2
推奨基準はこうです。ルートファイルシステムは 5% を維持します。管理者が入れない状況を作る理由はありません。ログやデータ専用のボリュームは 1% に下げても差し支えなく、数 TB のボリュームなら 0 にして代わりに監視をしっかり掛けます。ただし予約を 0 にするとファイルシステムがほぼ埋まったときに断片化が速まるので、常時 90% 以上で運用するボリュームには勧めません。
原因 4 と 5: マウントによる隠蔽、コンテナのオーバーレイ、そして偽の ENOSPC
df と du が食い違うのに削除済みのオープンファイルがないなら、残る候補がこれです。すでにファイルが入っているディレクトリの上に別のファイルシステムをマウントすると、元のファイルは見えなくなります。ところがそれらのファイルは依然として下のファイルシステムの容量を占有しています。du はマウントされた上側だけを歩くので、永遠に見つけられません。
典型的なシナリオはこうです。/var/log にログが溜まっている状態で新しいディスクを追加して /var/log にマウントしました。いまや昔のログ 40GB はルートファイルシステムの中に閉じ込められ、どのツールからも見えません。
確認する方法は、ルートを別の場所にバインドマウントして隠されていない元を見ることです。
sudo mkdir -p /mnt/rootfs
sudo mount --bind / /mnt/rootfs
# 隠されていたファイルがここでは見えます
sudo du -xhd1 /mnt/rootfs/var | sort -h | tail -5
# 1.2G /mnt/rootfs/var/lib
# 2.8G /mnt/rootfs/var/cache
# 41G /mnt/rootfs/var/log
# 46G /mnt/rootfs/var
sudo ls -la /mnt/rootfs/var/log | head
# -rw-r----- 1 syslog adm 18253611008 Mar 2 04:11 syslog.1
# -rw-r----- 1 syslog adm 22091571200 Feb 18 03:52 kern.log.1
# 片付けたら解除
sudo rm -f /mnt/rootfs/var/log/*.1
sudo umount /mnt/rootfs
マウントが重なっているかどうか自体を確認するには findmnt を使います。
findmnt --list -o TARGET,SOURCE,FSTYPE,SIZE,USED,AVAIL | head
# TARGET SOURCE FSTYPE SIZE USED AVAIL
# / /dev/nvme0n1p2 ext4 100G 96G 0
# /var/log /dev/nvme1n1p1 ext4 200G 12G 178G
# /var/lib/docker /dev/nvme2n1p1 xfs 500G 310G 190G
コンテナではここに層がもう一つ加わります。コンテナの中で df を実行するとオーバーレイファイルシステムが見えますが、この値はたいていホストの /var/lib/docker(または containerd のルート)があるファイルシステムの値です。
# コンテナの中
df -h /
# Filesystem Size Used Avail Use% Mounted on
# overlay 500G 310G 190G 63% /
190GB 残っていると表示されますが、実際に使える量は違うことがあります。三つの上限が別々に掛かるからです。
- ランタイムのストレージ上限:
docker run --storage-opt size=10G。overlay2 ドライバではバッキングのファイルシステムがprjquotaオプションでマウントされた XFS のときだけ動作します。条件が合わないとオプション自体が拒否されます。 - Kubernetes の ephemeral-storage: これは上限ではなく退去の基準です。超過しても ENOSPC ではなく Pod が Evicted になります。エラーメッセージがまったく違うので区別できます。
- emptyDir の sizeLimit: メモリバックエンドの emptyDir は tmpfs なので、超過すると本物の ENOSPC が出ます。
ホスト側で何が容量を食っているのかはこう見ます。
docker system df -v | head -20
# TYPE TOTAL ACTIVE SIZE RECLAIMABLE
# Images 148 12 182.4GB 161.2GB (88%)
# Containers 31 12 41.2GB 38.1GB (92%)
# Local Volumes 22 6 84.9GB 71.3GB (83%)
# Build Cache 412 0 38.2GB 38.2GB
# 安全に回収 (使用中のものには触れません)
docker image prune -a --filter 'until=168h'
docker builder prune --filter 'until=168h'
コンテナのログもよく忘れられます。json-file ドライバは既定では無限に大きくなります。
sudo du -sh /var/lib/docker/containers/*/*-json.log | sort -h | tail -3
# 8.2G /var/lib/docker/containers/3f7b91ac.../3f7b91ac...-json.log
# デーモンの全体設定で上限を掛けます
cat /etc/docker/daemon.json
# {
# "log-driver": "json-file",
# "log-opts": { "max-size": "100m", "max-file": "5" }
# }
この設定は新しく作るコンテナから適用されます。既存のコンテナは作り直す必要があります。
原因 5: inotify が出す同じ文言
最後の罠です。inotify_add_watch はウォッチ数の上限に達すると ENOSPC を返します。そのためファイル監視を使うツールが、ディスクとまったく関係なく「No space left on device」と出力します。ファイルシステムは無事なのに特定のプロセスだけがこのエラーを出すなら、こちらを疑ってください。
sysctl fs.inotify.max_user_watches fs.inotify.max_user_instances
# fs.inotify.max_user_watches = 65536
# fs.inotify.max_user_instances = 128
# ウォッチを多く握っているプロセスを探す
sudo find /proc/[0-9]*/fdinfo -type f 2>/dev/null \
| xargs grep -l '^inotify' 2>/dev/null \
| cut -d/ -f3 | sort -u \
| while read -r pid; do
n=$(grep -hc '^inotify' /proc/"$pid"/fdinfo/* 2>/dev/null | paste -sd+ | bc)
printf '%7s %6s %s\n' "$n" "$pid" "$(tr -d '\0' < /proc/"$pid"/comm)"
done | sort -rn | head -5
# 58210 9931 node
# 4102 3311 containerd
echo 'fs.inotify.max_user_watches = 524288' | sudo tee /etc/sysctl.d/60-inotify.conf
sudo sysctl --system
再発防止 — 判断表とアラート二つ
全体を判断表にまとめます。上から順に確認すれば大丈夫です。
| 原因 | 決定的なサイン | 確認コマンド | 解決 |
|---|---|---|---|
| パスの取り違え | 別のマウントが 100% | findmnt -T PATH | 当該ファイルシステムの整理または拡張 |
| inode 枯渇 | IUse% が 100%、ブロックは余裕 | df -i | 小さいファイルの整理、再フォーマット時に inode 増設、XFS |
| 削除済みのオープンファイル | df と du が大きく食い違う、NLINK 0 | lsof -nP +L1 | プロセスの再オープン、logrotate 設定の修正 |
| 予約ブロック | root は書き込み成功、一般アカウントだけ失敗 | tune2fs -l | tune2fs -m 1 |
| マウントによる隠蔽 | df と du が食い違うのにオープンファイルはない | mount --bind / のあと du -x | 隠されたファイルの削除 |
| コンテナの上限 | コンテナ内の df は余裕、書き込みだけ失敗 | docker system df -v | イメージとログの整理、ログドライバの上限設定 |
| inotify watch 枯渇 | 特定のプロセスだけ失敗、ファイルシステムは正常 | sysctl fs.inotify.max_user_watches | 上限の引き上げ、監視対象の縮小 |
ここまで来たら最後は二度と起きないようにする作業です。
ブロックと inode の両方を監視します。ほとんどのダッシュボードはブロック使用率しか見ていません。node_exporter は両方の指標を出力するので、二つ目のアラートを追加すれば済みます。
# ブロック
(1 - node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"}
/ node_filesystem_size_bytes{fstype!~"tmpfs|overlay"}) > 0.85
# inode — このアラートがない現場が多いです
(1 - node_filesystem_files_free{fstype!~"tmpfs|overlay"}
/ node_filesystem_files{fstype!~"tmpfs|overlay"}) > 0.85
増加率も併せて見ます。使用率 85% でアラートが鳴っても 30 分後に 100% になる予定なら手遅れです。predict_linear で枯渇予定時刻を基準にするほうが実用的です。
predict_linear(node_filesystem_avail_bytes{mountpoint="/var"}[6h], 4 * 3600) < 0
ログローテーションを実際に検証します。設定ファイルがあることと動作することは別です。
# 実行せずにどのファイルが対象なのかを確認
sudo logrotate -d /etc/logrotate.conf 2>&1 | grep -E 'considering|rotating|error'
# 強制実行して postrotate スクリプトまで検証
sudo logrotate -vf /etc/logrotate.d/app
# 実行後に削除済みのオープンファイルが残っていないか即座に確認
sudo lsof -nP +L1 | grep -c deleted
# 0
定期点検を一行にしておきます。障害のときに記憶をたどらなくて済むよう、スクリプトとして残すほうがよいです。
#!/usr/bin/env bash
# fs-health.sh — ENOSPC の原因候補を一度に洗い出します
set -u
echo "== ブロック使用率 =="
df -hT -x tmpfs -x devtmpfs
echo; echo "== inode 使用率 =="
df -i -x tmpfs -x devtmpfs
echo; echo "== 削除済みだが開かれたままのファイル (上位 5 件) =="
lsof -nP +L1 2>/dev/null | awk 'NR>1 {print $7, $1, $2, $NF}' | sort -rn | head -5
echo; echo "== 予約ブロックの比率 =="
for dev in $(lsblk -pnro NAME,FSTYPE | awk '$2 ~ /^ext[234]$/ {print $1}'); do
tune2fs -l "$dev" 2>/dev/null \
| awk -v d="$dev" '/Block count/{t=$3} /Reserved block count/{r=$3}
END {if (t) printf "%s %.1f%%\n", d, r*100/t}'
done
echo; echo "== マウントによる隠蔽の候補 (df と du の不一致) =="
findmnt --list -o TARGET,SOURCE,FSTYPE,USE% -t ext4,xfs
おわりに — df だけを見て判断しません
一つだけ覚えるならこれです。ENOSPC は「ブロックがない」という意味ではなく「カーネルにはこの書き込みを受け入れる資源がない」という意味であり、その資源はブロックかもしれず inode かもしれず、予約を除いた空きかもしれず inotify watch かもしれません。
診断の順序を一つに圧縮するとこうなります。
findmnt -Tでパスの属するファイルシステムを確定します。ここで半分は片づきます。df -iで inode を見ます。ブロックとは別の資源です。dfとdu -xを比べます。食い違うなら削除済みのオープンファイルかマウントによる隠蔽です。前者はlsof +L1、後者はバインドマウントで切り分けます。- root ではできてサービスアカウントではできないなら予約ブロックです。
tune2fs -mで調整します。 - ファイルシステムは無事なのに特定のプロセスだけがこのエラーを出すなら inotify を疑います。
- アラートはブロックと inode の二つを掛けます。そして使用率より枯渇予定時刻のほうが役に立ちます。
현재 단락 (1/146)
症状はこう始まります。アプリケーションがログを書けず、デプロイが一時ファイルの作成で失敗し、DB が WAL を書けません。入って確認するとディスクには余裕があります。