들어가며 — 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의 차이는 디렉터리 엔트리가 사라진 파일입니다. 유닉스에서 파일은 링크 카운트가 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가 이 상황을 어떻게 보여 주는지 알아 두면 오해가 줄어듭니다. df의 Avail은 예약분을 뺀 값이고 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
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일 때만 동작합니다. 조건이 안 맞으면 옵션 자체가 거부됩니다. - 쿠버네티스의 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 |
| 삭제된 열린 파일 | df와 du가 크게 어긋남, NLINK 0 | lsof -nP +L1 | 프로세스 재개방, logrotate 설정 수정 |
| 예약 블록 | 루트는 쓰기 성공, 일반 계정만 실패 | 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, 후자는 바인드 마운트로 가릅니다.- 루트로는 되고 서비스 계정으로는 안 되면 예약 블록입니다.
tune2fs -m으로 조정합니다. - 파일시스템은 멀쩡한데 특정 프로세스만 이 에러를 내면 inotify를 의심합니다.
- 알람은 블록과 inode 두 개를 겁니다. 그리고 사용률보다 소진 예정 시각이 더 유용합니다.
현재 단락 (1/146)
증상은 이렇게 시작합니다. 애플리케이션이 로그를 못 쓰고, 배포가 임시 파일 생성에서 실패하고, DB가 WAL을 못 씁니다. 들어가서 확인하면 디스크는 여유가 있습니다.