Skip to content

Split View: 용량이 남았는데 No space left on device — inode 고갈, 삭제된 열린 파일, 예약 블록

✨ Learn with Quiz
|

용량이 남았는데 No space left on device — inode 고갈, 삭제된 열린 파일, 예약 블록

들어가며 — 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: 삭제됐지만 열려 있는 파일

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의 차이는 디렉터리 엔트리가 사라진 파일입니다. 유닉스에서 파일은 링크 카운트가 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)에서 이어지므로 문제가 없지만, 그렇지 않으면 예전 오프셋에 계속 쓰기 때문에 파일이 희소(sparse) 파일이 됩니다. 실제 점유 블록은 줄었지만 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: 예약 블록 — 루트만 쓸 수 있는 5%

ext2/3/4는 전체 블록의 일정 비율을 루트 전용으로 남깁니다. 기본값은 5%입니다. 목적은 두 가지인데, 디스크가 가득 차도 관리자가 로그인해 정리할 수 있게 하는 것과, 할당기가 연속 블록을 찾을 여지를 남겨 단편화를 늦추는 것입니다.

이 경우의 증상이 특징적입니다. 루트로는 파일을 만들 수 있는데 서비스 계정으로는 안 됩니다.

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와의 차이나 루트 계정으로의 쓰기 성공에서 옵니다.

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일 때만 동작합니다. 조건이 안 맞으면 옵션 자체가 거부됩니다.
  • 쿠버네티스의 ephemeral-storage: 이것은 상한이 아니라 축출 기준입니다. 초과하면 ENOSPC가 아니라 파드가 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 설정 수정
예약 블록루트는 쓰기 성공, 일반 계정만 실패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. 루트로는 되고 서비스 계정으로는 안 되면 예약 블록입니다. tune2fs -m으로 조정합니다.
  5. 파일시스템은 멀쩡한데 특정 프로세스만 이 에러를 내면 inotify를 의심합니다.
  6. 알람은 블록과 inode 두 개를 겁니다. 그리고 사용률보다 소진 예정 시각이 더 유용합니다.

Space Left but No space left on device — inode exhaustion, deleted open files, reserved blocks

Introduction — df says 38% is left, yet a single touch fails

The symptom starts like this. The application cannot write its logs, a deployment fails while creating a temporary file, the database cannot write its WAL. You log in to check and the disk has room to spare.

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 is 35GB and yet a single zero-byte file cannot be created. It is easy to conclude here that "the monitoring is broken", but df honestly reported block usage and nothing more. ENOSPC is errno 28, and the kernel has several paths to that value besides a shortage of blocks.

This post lists those paths in order of likelihood, with the command that confirms each one, the fix, and how to keep it from coming back. Walking through them in order takes about five minutes.

What to check first — are you really writing to that filesystem

Spend thirty seconds before starting the diagnosis. The most common mistake is running df -h with no argument, skimming the list it prints, and guessing which entry the failing path belongs to. Ask about the path directly.

# The filesystem this path actually belongs to
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

It was not / but /var that was a separate partition, and that is the one that filled up. If this is your case, you are done here. Environments where /tmp is a tmpfs are just as common.

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 uses memory. When it fills up you get ENOSPC with no relation to the disk at all, and that same amount of physical memory is being eaten at the same time.

If the path checks out and Avail still shows room, now we get to the main topic.

Cause 1: inode exhaustion — blocks remain but the file count ran out

The most common cause. Confirming it is one line.

df -i /
# Filesystem        Inodes   IUsed IFree IUse% Mounted on
# /dev/nvme0n1p2   6553600 6553600     0  100% /

IUse% is 100%. ext2/3/4 fix the inode count at format time and it cannot be raised afterwards. The default is decided by the -i option (bytes-per-inode) of mke2fs, and the default profile gives roughly one per 16KB. That is about 6.55 million on a 100GB filesystem. If the average file size of your workload is smaller than 16KB, inodes run out before blocks do.

Here is how to find the guilty directory. du counts size, not the number of files, so it is useless here.

# Top 10 directories by file count (within the same filesystem only)
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 matters. Leave it out and the scan walks into the other mounted filesystems, which takes several times longer and pollutes the result. If the tree is large and the command above takes too long, narrow down from the top level.

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/

The usual suspects are a fixed set. Mail queues (maildrop and deferred of postfix), the PHP session directory, package manager caches, applications that leave session or lock files behind, and container layers that never get cleaned up.

The fix has two stages. For right now, delete. When the file count is large rm -rf runs into the argument length limit, so hand the work to find.

# Delete session files older than 30 days (a safe form when the count is large)
sudo find /var/lib/php/sessions -type f -mtime +30 -delete

# Watching the deletion progress (when there are millions of files)
sudo find /var/spool/postfix/maildrop -type f -mtime +7 -print -delete | pv -l > /dev/null

The root fix is recreating or migrating the filesystem. Raising the inode count on ext4 means formatting again.

# One inode per 4KB (four times the default). All the data is lost
sudo mkfs.ext4 -i 4096 /dev/nvme0n1p6

# Or specify the inode count directly
sudo mkfs.ext4 -N 20000000 /dev/nvme0n1p6

Moving to XFS is a practical choice too. XFS allocates inodes dynamically, so this problem is effectively gone. It is not completely gone, though, because imaxpct caps the share of space that inodes may use.

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

# Raise the cap if you need to (possible while mounted)
sudo xfs_growfs -m 50 /var

Cause 2: files that were deleted but are still open

When df and du disagree by a lot, this is the one.

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 says 20GB is used and du finds only 3.1GB. The 17GB gap is files whose directory entry is gone. On Unix a file is released only when its link count is 0 and the count of open descriptors is 0 as well. rm removes only the link. If somebody is holding that file open the space is not returned, and du walks paths, so it cannot count that file.

Here is the command that confirms it.

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 means print only the open files whose link count is below 1, and the 0 in the NLINK column is exactly that state. The SIZE/OFF column is the number of bytes that are not being returned. The three above add up to about 12GB.

On a minimal image where lsof is not installed, look at /proc directly.

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

The cause is almost always the same. The process never reopened the file after log rotation. When logrotate rotates with the create method it moves the original aside and creates a new file, while the process is still writing to the old inode. A signal has to be sent from the postrotate script, and that part is either missing or failing.

There are two fixes.

# A) Send the reopen signal to the process (cleanest)
sudo systemctl reload nginx        # nginx uses USR1
sudo kill -HUP 1288                # rsyslogd

# B) If a restart is impossible, empty it directly through the fd
sudo truncate -s 0 /proc/21874/fd/12

Option B has a side effect. If the process opened the file with O_APPEND the next write continues at the end of the file, which is now 0, so there is no problem; otherwise it keeps writing at the old offset and the file becomes a sparse file. The blocks actually occupied went down, but the size shown by ls -l still looks just as large. Use it as first aid only; the proper fix is option A.

Fixing the logrotate configuration is what prevents a recurrence.

cat /etc/logrotate.d/app
# /var/log/app/*.log {
#     daily
#     rotate 14
#     compress
#     delaycompress
#     missingok
#     notifempty
#     copytruncate
# }

copytruncate copies the original and then truncates the original to 0. The inode is preserved, so the process has to do nothing. In exchange, the logs written between the copy and the truncation can be lost. If that loss is unacceptable, send a signal from postrotate instead of using copytruncate, and make sure that script does not fail silently.

    postrotate
        systemctl reload app.service || systemctl kill -s USR1 app.service
    endscript

Cause 3: reserved blocks — the 5% only root can use

ext2/3/4 keep a fixed share of all blocks for root only. The default is 5%. There are two purposes: letting an administrator log in and clean up even when the disk is full, and leaving the allocator room to find contiguous blocks so that fragmentation is delayed.

The symptom in this case is distinctive. Root can create the file but the service account cannot.

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 is what confirms it.

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 is 1310720 and Free blocks is almost the same. That means the entire remaining space is the reservation. 1310720 times 4096 is about 5.0GiB, and 1310720 out of 26214400 is exactly 5%.

Knowing how df presents this situation removes a lot of confusion. The Avail of df is the value with the reservation subtracted, and Use% is computed as used divided by (used plus Avail). So the moment only the reservation is left, Avail becomes 0 and Use% becomes 100%. In other words, with this cause df usually shows 100%, and the impression that "there is space left" comes from the gap against du or from a write that succeeds as root.

On a 100GB data volume, 5% is 5GB. For a pure data volume that is not the root filesystem, that ratio is excessive.

# Lower it to 1%. It applies immediately while mounted and the data is safe
sudo tune2fs -m 1 /dev/nvme0n1p2
# Setting reserved blocks percentage to 1% (262144 blocks)

# If you want to specify an absolute count
sudo tune2fs -r 262144 /dev/nvme0n1p2

The recommended baseline is this. Keep 5% on the root filesystem. There is no reason to create a situation the administrator cannot get into. A volume dedicated to logs or data can safely drop to 1%, and on a multi-terabyte volume you can set it to 0 and put solid monitoring in place instead. Note, though, that with the reservation at 0 fragmentation accelerates once the filesystem is nearly full, so it is not recommended for a volume you intend to run above 90% at all times.

Causes 4 and 5: mount shadowing, container overlays, and fake ENOSPC

If df and du disagree but there are no deleted open files, this is the candidate that remains. Mounting another filesystem on a directory that already holds files makes the original files invisible. Those files still occupy space on the filesystem underneath, though. du only walks the mounted top, so it will never find them.

The typical scenario goes like this. Logs had piled up under /var/log, then a new disk was attached and mounted at /var/log. Now 40GB of old logs are trapped inside the root filesystem and invisible to every tool.

The way to check is to bind mount the root somewhere else and look at the original that is not shadowed.

sudo mkdir -p /mnt/rootfs
sudo mount --bind / /mnt/rootfs

# The files that were hidden are visible here
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

# Clean up, then release
sudo rm -f /mnt/rootfs/var/log/*.1
sudo umount /mnt/rootfs

To check whether mounts overlap in the first place, use 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

Containers add one more layer on top of this. Run df inside a container and you see the overlay filesystem, and that value is usually the value of the filesystem that holds the host /var/lib/docker (or the containerd root).

# Inside the container
df -h /
# Filesystem      Size  Used Avail Use% Mounted on
# overlay         500G  310G  190G  63% /

It shows 190GB left, but the amount you can actually use may be different, because three separate caps apply.

  • The storage cap of the runtime: docker run --storage-opt size=10G. On the overlay2 driver it works only when the backing filesystem is XFS mounted with the prjquota option. If the condition is not met, the option itself is rejected.
  • The ephemeral-storage of Kubernetes: this is not a cap but an eviction threshold. Exceeding it does not produce ENOSPC, it gets the pod Evicted. The error message is completely different, so the two are easy to tell apart.
  • The sizeLimit of emptyDir: a memory-backed emptyDir is a tmpfs, so exceeding it produces a real ENOSPC.

Here is how to see what is eating space on the host side.

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

# Reclaim safely (nothing in use is touched)
docker image prune -a --filter 'until=168h'
docker builder prune --filter 'until=168h'

Container logs are frequently forgotten too. The json-file driver grows without bound by default.

sudo du -sh /var/lib/docker/containers/*/*-json.log | sort -h | tail -3
# 8.2G	/var/lib/docker/containers/3f7b91ac.../3f7b91ac...-json.log

# Cap it with the daemon-wide configuration
cat /etc/docker/daemon.json
# {
#   "log-driver": "json-file",
#   "log-opts": { "max-size": "100m", "max-file": "5" }
# }

This setting applies from newly created containers onward. Existing containers have to be recreated.

Cause 5: the same wording produced by inotify

The last trap. inotify_add_watch returns ENOSPC when it reaches the watch count limit. So a tool that uses file watching prints "No space left on device" with nothing to do with the disk at all. If the filesystem is perfectly fine and only one particular process throws this error, suspect this one.

sysctl fs.inotify.max_user_watches fs.inotify.max_user_instances
# fs.inotify.max_user_watches = 65536
# fs.inotify.max_user_instances = 128

# Find the processes holding many watches
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

Preventing a recurrence — a decision table and two alerts

Here is the whole thing as a decision table. Check from the top down.

CauseDecisive signalConfirming commandFix
Wrong pathA different mount is at 100%findmnt -T PATHClean up or expand that filesystem
inode exhaustionIUse% at 100%, blocks to sparedf -iClean up small files, add inodes on reformat, XFS
Deleted open filedf and du disagree by a lot, NLINK 0lsof -nP +L1Reopen in the process, fix the logrotate config
Reserved blocksRoot writes succeed, only regular accounts failtune2fs -ltune2fs -m 1
Mount shadowingdf and du disagree but there are no open filesmount --bind / then du -xDelete the shadowed files
Container capdf inside the container shows room, only writes faildocker system df -vClean up images and logs, cap the log driver
inotify watch exhaustionOnly one process fails, the filesystem is finesysctl fs.inotify.max_user_watchesRaise the limit, watch fewer targets

If you have made it this far, the last job is making sure it never comes back.

Watch both blocks and inodes. Most dashboards look only at block usage. node_exporter exports both metrics, so all you have to do is add the second alert.

# Blocks
(1 - node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"}
   / node_filesystem_size_bytes{fstype!~"tmpfs|overlay"}) > 0.85

# inode — many places do not have this alert
(1 - node_filesystem_files_free{fstype!~"tmpfs|overlay"}
   / node_filesystem_files{fstype!~"tmpfs|overlay"}) > 0.85

Watch the growth rate along with it. Even if the alert fires at 85% usage, it is too late when 100% is 30 minutes away. Taking the projected exhaustion time from predict_linear as the criterion is more practical.

predict_linear(node_filesystem_avail_bytes{mountpoint="/var"}[6h], 4 * 3600) < 0

Verify the log rotation for real. Having a configuration file and having it work are different things.

# Check which files are targeted without running it
sudo logrotate -d /etc/logrotate.conf 2>&1 | grep -E 'considering|rotating|error'

# Force a run to verify the postrotate script as well
sudo logrotate -vf /etc/logrotate.d/app

# Right after the run, check whether deleted open files remain
sudo lsof -nP +L1 | grep -c deleted
# 0

Make the regular check a one-liner. Leaving it behind as a script is better than digging through memory during an incident.

#!/usr/bin/env bash
# fs-health.sh — sweeps the ENOSPC cause candidates in one pass
set -u

echo "== Block usage =="
df -hT -x tmpfs -x devtmpfs

echo; echo "== inode usage =="
df -i -x tmpfs -x devtmpfs

echo; echo "== Deleted but still open files (top 5) =="
lsof -nP +L1 2>/dev/null | awk 'NR>1 {print $7, $1, $2, $NF}' | sort -rn | head -5

echo; echo "== Reserved block percentage =="
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 "== Mount shadowing candidates (df and du mismatch) =="
findmnt --list -o TARGET,SOURCE,FSTYPE,USE% -t ext4,xfs

Closing — do not judge from df alone

If you remember one thing, let it be this. ENOSPC does not mean "there are no blocks". It means "the kernel has no resource to accept this write", and that resource may be blocks, or inodes, or the free space excluding the reservation, or inotify watches.

Compressed into a single diagnostic order, it goes like this.

  1. Pin down the filesystem the path belongs to with findmnt -T. Half the cases end here.
  2. Look at inodes with df -i. They are a resource separate from blocks.
  3. Compare df and du -x. A mismatch means either a deleted open file or mount shadowing. Separate the two with lsof +L1 for the former and a bind mount for the latter.
  4. If root works and the service account does not, it is reserved blocks. Adjust it with tune2fs -m.
  5. If the filesystem is perfectly fine and only one particular process throws this error, suspect inotify.
  6. Set two alerts, one for blocks and one for inodes. And the projected exhaustion time is more useful than the usage percentage.