Skip to content
Published on

空き容量はあるのに No space left on device — inode 枯渇、削除済みのオープンファイル、予約ブロック

シェア
Authors

はじめに — 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 の maildropdeferred)、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: 削除されたのに開かれたままのファイル

dfdu が大きく食い違うならこちらです。

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 がこの状況をどう見せるのかを知っておくと誤解が減ります。dfAvail は予約ぶんを引いた値で、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

dfdu が食い違うのに削除済みのオープンファイルがないなら、残る候補がこれです。すでにファイルが入っているディレクトリの上に別のファイルシステムをマウントすると、元のファイルは見えなくなります。ところがそれらのファイルは依然として下のファイルシステムの容量を占有しています。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
削除済みのオープンファイルdfdu が大きく食い違う、NLINK 0lsof -nP +L1プロセスの再オープン、logrotate 設定の修正
予約ブロックroot は書き込み成功、一般アカウントだけ失敗tune2fs -ltune2fs -m 1
マウントによる隠蔽dfdu が食い違うのにオープンファイルはない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 かもしれません。

診断の順序を一つに圧縮するとこうなります。

  1. findmnt -T でパスの属するファイルシステムを確定します。ここで半分は片づきます。
  2. df -i で inode を見ます。ブロックとは別の資源です。
  3. dfdu -x を比べます。食い違うなら削除済みのオープンファイルかマウントによる隠蔽です。前者は lsof +L1、後者はバインドマウントで切り分けます。
  4. root ではできてサービスアカウントではできないなら予約ブロックです。tune2fs -m で調整します。
  5. ファイルシステムは無事なのに特定のプロセスだけがこのエラーを出すなら inotify を疑います。
  6. アラートはブロックと inode の二つを掛けます。そして使用率より枯渇予定時刻のほうが役に立ちます。