Linux 디스크 full vs inode full 해결 순서 | 용량·아이노드 트러블슈팅
이 글의 핵심
용량이 남아 있는데도 파일을 만들 수 없다면 블록이 아니라 inode가 바닥난 경우입니다. ext4에서 inode 총량이 왜 포맷 시점에 정해지는지, XFS는 할당 그룹(AG) 단위로 무엇이 다른지, 저널이 장애 때 어떤 의미인지를 먼저 짚고 나서 6단계 점검 절차로 넘어가므로, 증상별로 어디부터 볼지 판단 기준이 생깁니다.
들어가며
Linux 서버에서 “디스크가 찼다”는 알림의 원인은 크게 블록 용량 고갈과 inode 고갈 두 가지입니다. 둘 다 같은 No space left on device(ENOSPC) 오류를 내지만 찾는 방법과 정리 대상이 다르므로, 먼저 어느 쪽이 찼는지 구분한 다음 어느 경로가 비정상인지 좁혀야 합니다.
모니터링 알람이 보통 “디스크 사용률” 하나로만 설정되어 있다는 점도 문제를 키웁니다. 블록 사용률이 40%인데 inode가 100%인 서버는 용량 알람으로는 잡히지 않고, 로그가 기록되지 않거나 새 세션 파일을 만들지 못하는 증상으로만 드러납니다. 이 글은 일반적인 ext4·XFS 환경을 전제로, 먼저 파일 시스템이 inode를 어떻게 관리하는지 짚고 실제 점검 순서로 넘어갑니다.
개념: 블록 vs inode
그림: 같은 오류 메시지라도 df -h는 블록, df -i는 inode 사용률을 보여 주므로 둘 다 확인한다.
| 구분 | 의미 | 대표 원인 |
|---|---|---|
| 블록 | 파일 데이터가 차지하는 저장 공간 | 대용량 로그, 덤프, 미디어 |
| inode | 파일·디렉터리마다 하나씩 있는 메타데이터 레코드 | 작은 파일이 수백만 개 |
df -h는 블록 사용량을, df -ih는 inode 사용량을 보여 줍니다. 파일 하나를 만들려면 inode 하나와(내용이 있다면) 데이터 블록이 모두 필요하고, 파일 시스템은 두 자원을 따로 관리합니다. 그래서 df -h에는 여유가 있는데 df -ih가 100%인 상황이 생깁니다. 이때는 큰 파일을 지워도 inode가 거의 늘지 않으므로 파일 생성 실패가 계속됩니다.
inode 내부 구조
inode가 담는 정보
inode는 파일이라는 객체에 대한 메타데이터 레코드입니다. ext4에서는 고정 크기(기본 256바이트) 레코드입니다. 파일 이름은 inode에 들어 있지 않고 부모 디렉터리의 데이터 블록 안에 (이름 → inode 번호) 형태로 기록됩니다. 같은 inode를 가리키는 이름이 여러 개 있을 수 있는 이유가 이것이고, 이것이 하드 링크입니다.
inode에는 파일 타입(일반 파일, 디렉터리, 심볼릭 링크, 소켓, FIFO, 디바이스), 권한과 UID/GID, 크기와 atime/mtime/ctime, 링크 카운트, 데이터 위치가 들어 있습니다. 데이터 위치는 예전 ext2/ext3에서는 직접·간접 블록 포인터 목록이었고, ext4와 XFS는 연속 구간을 한 번에 가리키는 익스텐트(extent)를 씁니다.
이름과 inode의 분리가 주는 힌트
ls -i로 inode 번호를 볼 수 있습니다. rm은 실제로는 디렉터리에서 이름 하나를 지우고 링크 카운트를 하나 줄이는 연산입니다. 링크 카운트가 0이 되고, 그 파일을 열고 있는 프로세스도 없을 때 비로소 inode와 데이터 블록이 해제됩니다. “삭제했는데 공간이 안 돌아온다”는 증상은 대부분 이 두 번째 조건, 즉 아직 열려 있는 파일 디스크립터 때문입니다.
ext4: 블록 그룹, 비트맵, inode 할당
블록 그룹 구조
ext4는 디바이스를 블록 그룹으로 나누고, 각 그룹에 다음 메타데이터를 둡니다(flex_bg 옵션이 켜져 있으면 여러 그룹의 메타데이터가 한곳에 모입니다).
| 구성 요소 | 역할 |
|---|---|
| 슈퍼블록 | 블록 크기, 총 블록 수, 총 inode 수 같은 전체 파라미터. 일부 그룹에 백업 사본이 있음 |
| 그룹 디스크립터 | 각 그룹의 비트맵과 inode 테이블 위치 |
| inode 비트맵 | 그룹의 inode 슬롯마다 1비트, 사용 중이면 1 |
| 블록 비트맵 | 그룹의 데이터 블록마다 1비트 |
| inode 테이블 | inode 레코드 배열이 연속으로 저장되는 영역 |
새 파일을 만들 때 커널은 inode 비트맵에서 빈 슬롯을 찾습니다. 모든 그룹의 inode 비트맵이 가득 차면 블록이 아무리 남아 있어도 ENOSPC가 반환됩니다. 작은 파일이 폭증하는 상황에서는 inode 비트맵이 먼저 포화되고, 반대로 거대한 파일 몇 개는 블록은 많이 쓰지만 inode는 몇 개만 씁니다.
ext4에서 inode 총량이 고정인 이유
inode 테이블은 mkfs.ext4 시점에 미리 만들어지므로 inode 총량도 그때 정해집니다. 기본값은 /etc/mke2fs.conf의 inode_ratio(일반 파일 시스템은 16KiB당 inode 1개)로 결정되고, -i(bytes-per-inode)나 -N(inode 개수)으로 바꿀 수 있습니다. 평균 파일 크기가 16KiB보다 훨씬 작은 워크로드라면 기본값으로 포맷했을 때 inode가 먼저 바닥납니다.
이미 만든 ext4의 inode 비율은 바꿀 수 없습니다. resize2fs로 파일 시스템을 늘리면 늘어난 블록 그룹만큼 inode도 같은 비율로 늘어나지만, 근본적으로 비율을 바꾸려면 새 파일 시스템을 원하는 옵션으로 만들어 데이터를 옮겨야 합니다. 그래서 작은 파일이 많을 것으로 예상되는 경로(메일 스풀, 캐시)는 처음부터 별도 볼륨으로 분리하고 -i를 작게 잡는 편이 좋습니다.
예약 블록
ext4는 기본적으로 블록의 5%를 root 전용으로 예약합니다. 그래서 일반 사용자 프로세스는 df -h에서 사용률이 95% 근처일 때 이미 ENOSPC를 받고, root로 실행되는 프로세스는 계속 쓸 수 있습니다. 대용량 데이터 볼륨에서는 이 예약분이 수십 GB가 되기도 하므로, root가 아닌 서비스 전용 볼륨이라면 tune2fs -m 1 같은 값으로 줄이는 것을 검토할 수 있습니다.
확인 명령
# ext4 슈퍼블록 요약 (장치 이름은 환경에 맞게)
sudo tune2fs -l /dev/sda1 | grep -E 'Inode count|Free inodes|Inode size|Block count|Free blocks|Reserved block count|Block size'
Inode count가 전체 상한, Free inodes가 남은 개수입니다. 블록 그룹별 상세 정보는 dumpe2fs로 볼 수 있지만 출력이 매우 길어서 장애 대응 중에는 위 요약으로 충분합니다.
XFS: 할당 그룹과 동적 inode 할당
XFS는 디바이스를 할당 그룹(AG)으로 나눕니다. AG는 ext4의 블록 그룹처럼 공간을 나눠 관리하고 병렬로 할당할 수 있게 하는 단위입니다.
inode 관리 방식은 ext4와 크게 다릅니다. XFS는 inode 테이블을 미리 만들지 않고, 새 inode가 필요할 때 64개 단위의 inode 청크를 데이터 공간에서 할당하며, 각 AG의 B+트리로 청크를 추적합니다. 그래서 df -i에 표시되는 XFS의 inode 총량은 고정값이 아니라 “현재 여유 공간으로 만들 수 있는 최대치”에 가까운 추정치입니다.
XFS에서도 inode 부족은 생깁니다. 첫째, inode가 차지할 수 있는 공간 비율 상한(imaxpct)에 도달한 경우입니다. 둘째, 여유 공간은 있지만 조각나 있어서 inode 청크 하나를 담을 연속 공간을 찾지 못하는 경우입니다. 두 번째 경우에는 df -h와 df -i 모두 여유가 있어 보이는데 파일 생성이 실패하므로 특히 헷갈립니다.
# imaxpct, AG 개수(agcount)와 크기, 블록 크기 확인
xfs_info /var
# imaxpct 상한을 올리기 (마운트 상태에서 가능)
sudo xfs_growfs -m 50 /var
XFS는 파일 시스템 축소를 지원하지 않습니다(최근 커널에 마지막 AG만 줄이는 제한적 기능이 있을 뿐입니다). 크기를 크게 잡았다가 줄여야 하는 상황이 생기지 않도록 파티션 계획을 세워야 합니다.
저널(journal)과 장애 시 의미
전원이 끊기거나 커널 패닉이 나면 디스크에는 쓰다 만 메타데이터가 남을 수 있습니다. 저널링 파일 시스템은 메타데이터 변경을 먼저 저널에 트랜잭션으로 기록하고, 재마운트할 때 커밋된 트랜잭션을 다시 적용(replay)해 일관된 상태로 돌아갑니다.
ext4의 저널 모드는 세 가지입니다. data=journal은 데이터까지 저널에 기록해 가장 안전하지만 모든 데이터를 두 번 씁니다. 기본값인 data=ordered는 메타데이터만 저널에 기록하되, 메타데이터를 커밋하기 전에 해당 데이터 블록을 먼저 디스크에 씁니다. 그래서 장애 후 파일이 이전 파일의 잔여 데이터를 가리키는 일이 없습니다. data=writeback은 이 순서 보장이 없어 더 빠를 수 있지만, 장애 후 최근에 쓴 파일에 엉뚱한 옛 데이터가 보일 수 있습니다.
저널 리플레이 자체는 저널 크기만큼만 읽으면 되므로 보통 빠르게 끝납니다. 재부팅 후 e2fsck가 오래 도는 경우는 파일 시스템에 오류 플래그가 기록되어 전체 검사가 강제되었거나, 마운트 횟수·주기 조건으로 정기 검사가 실행된 경우입니다. inode 고갈 자체는 저널 문제를 일으키지 않습니다. XFS도 메타데이터 로그를 같은 목적으로 쓰며, 로그 관련 문제는 dmesg의 XFS 메시지로 확인합니다.
실전 점검 순서
1단계: 블록인지 inode인지 구분
df -h
df -ih
블록 사용률이 100%에 가깝다면 대용량 파일·로그·코어 덤프를 의심합니다. inode 사용률이 100%에 가깝다면 캐시·메일 큐·세션·빌드 산출물처럼 작은 파일이 대량으로 생기는 곳을 의심합니다. 블록이 95% 근처에서 멈춰 있고 root 프로세스만 쓸 수 있다면 앞서 설명한 ext4 예약 블록에 걸린 것입니다.
2단계: 문제 마운트 확정
df -h /var
df -ih /var
findmnt -T /var/lib/docker
경로가 실제로 어느 파일 시스템에 속하는지 확인한 뒤, 그 마운트 안에서만 작업합니다. 아래 명령에 붙는 -x/-xdev 옵션은 다른 마운트 지점으로 내려가지 않게 해서, 원인 파일 시스템만 셀 수 있게 합니다.
3단계: 블록이 찼을 때 큰 디렉터리 찾기
sudo du -xh /var --max-depth=2 2>/dev/null | sort -h | tail -20
/var/log, /var/lib/docker, 애플리케이션 로그 디렉터리, DB 데이터 디렉터리를 먼저 봅니다.
거대한 로그 파일을 당장 비워야 한다면 삭제보다 truncate가 안전합니다.
# 파일을 지우지 않고 크기만 0으로 만듦 (서비스는 계속 같은 파일에 기록)
sudo truncate -s 0 /var/log/very_large_app.log
rm으로 지우면 프로세스가 파일을 계속 열고 있는 동안 공간이 돌아오지 않지만, truncate는 열린 파일의 크기를 바로 줄이므로 즉시 공간이 회수됩니다. 다만 프로세스가 O_APPEND 없이 파일 오프셋을 유지하며 쓰고 있었다면, 다음 쓰기가 예전 오프셋에서 일어나 앞부분이 빈 sparse 파일이 됩니다. 로그 내용이 감사 목적으로 필요하다면 먼저 압축 복사본을 다른 볼륨에 남겨야 합니다. 근본 대책은 logrotate 설정(copytruncate 또는 서비스에 reopen 신호를 보내는 postrotate)을 고치는 것입니다.
4단계: inode가 찼을 때 파일이 몰린 경로 찾기
# coreutils 8.22 이상: 디렉터리별 inode 사용량(하위 포함)
sudo du --inodes -x /var 2>/dev/null | sort -n | tail -20
du --inodes는 디렉터리마다 그 아래 inode 개수를 합산해 주므로, 결과의 아래쪽에 나오는 깊은 경로가 실제 원인 디렉터리입니다. 오래된 coreutils라면 하위 디렉터리마다 개수를 셉니다.
for d in /var/*/; do
printf '%s\t' "$d"
sudo find "$d" -xdev 2>/dev/null | wc -l
done | sort -t$'\t' -k2 -n | tail -20
이 순서가 맞는지 inode가 300개뿐인 10MB tmpfs에서 직접 확인했습니다. cache/a에 빈 파일을 만들다 295개에서 멈췄고, 이때 df -h는 사용률 1%인데 df -ih는 300 300 0 100%였습니다. 다른 디렉터리에 touch하면 No space left on device가 났습니다. 블록은 남았는데 이 오류가 나면 inode를 의심하라는 설명 그대로입니다. du --inodes -x는 296 /small/cache/a, 298 /small/cache, 300 /small 순으로 원인 디렉터리를 가리켰고, 아래 for 반복문도 cache를 298개로 가장 많게 셌습니다. (환경: Docker 컨테이너의 Ubuntu 24.04, GNU coreutils 9.4, tmpfs. ext4·XFS의 tune2fs, xfs_info는 이 환경에서 실행하지 않았습니다.)
흔한 원인은 상한 없이 늘어나는 캐시 디렉터리, /var/spool의 메일 큐, 정리되지 않는 PHP 세션 파일과 임시 파일, CI 작업 공간에 쌓이는 node_modules와 빌드 산출물입니다. 정리하기 전에는 해당 디렉터리를 쓰는 프로세스가 무엇인지 확인하고, 수백만 개 파일을 지울 때는 find <dir> -type f -mmin +60 -delete처럼 조건을 걸어 한 번에 처리하는 편이 rm *(인자 목록이 너무 길다는 오류가 남)보다 낫습니다.
그림: 실제 실행 결과(Ubuntu 24.04, coreutils 9.4) — inode를 200개로 제한한 20MB tmpfs에 빈 파일을 채웠다. 파일 생성이 No space left on device로 실패하지만 df -h는 0% 사용이고, df -ih만 100%를 보여 준다. du --inodes의 아래쪽에 파일이 몰린 cache/a가 나온다. (환경: Linux 6.18.44 · g++ (13.3.0-6ubuntu2~24.04.1))
대규모 트리에서 전수 find나 du는 메타데이터 I/O를 크게 일으켜 이미 느려진 서버를 더 느리게 만들 수 있습니다. 장애 중에는 의심 경로부터 좁혀서 실행하는 것이 안전합니다.
5단계: Docker 호스트
docker system df
sudo du -sh /var/lib/docker/*
docker system df는 이미지·컨테이너·로컬 볼륨·빌드 캐시가 각각 얼마나 쓰는지 요약하고, 회수 가능한 양(RECLAIMABLE)도 보여 줍니다. 놓치기 쉬운 것은 컨테이너 로그입니다. 기본 json-file 로그 드라이버는 크기 제한이 없으므로 /var/lib/docker/containers/<id>/<id>-json.log가 수십 GB까지 커질 수 있습니다. daemon.json에 "log-opts": {"max-size": "100m", "max-file": "3"}를 설정해 두어야 합니다(기존 컨테이너는 재생성해야 적용됩니다).
정리는 docker image prune, docker builder prune처럼 범위를 좁힌 명령부터 씁니다. docker system prune -a는 실행 중인 컨테이너가 쓰지 않는 모든 이미지를 지우므로, 롤백용 이미지가 사라져 재배포가 느려질 수 있습니다. --filter "until=168h"처럼 기간 조건을 붙이는 것이 안전합니다.
6단계: 삭제했는데 df가 줄지 않을 때
# 링크 카운트가 0인데 아직 열려 있는 파일
sudo lsof +L1
# 특정 프로세스가 연 삭제된 파일
sudo ls -l /proc/<pid>/fd | grep '(deleted)'
같은 방식으로 8MB tmpfs에 3MB 로그를 만들고 tail -f로 열어 둔 채 rm했더니 df의 사용량이 2.9M 그대로였고, lsof +L1에 NLINK 0, /big/app.log (deleted)가 나왔습니다. /proc/<pid>/fd에서도 (deleted)로 보였고, tail 프로세스를 끝내자 사용량이 0으로 돌아왔습니다. (환경: 위와 같은 컨테이너.)
삭제된 파일은 경로로는 더 이상 찾을 수 없으므로 lsof /경로로는 보이지 않습니다. lsof +L1이 링크 카운트 0인 열린 파일을 모두 보여 줍니다. 해당 서비스를 재시작하면 디스크립터가 닫히면서 공간이 돌아옵니다. 재시작할 수 없다면 sudo truncate -s 0 /proc/<pid>/fd/<n>으로 열린 파일의 크기를 0으로 만들어 공간만 회수할 수 있습니다.
프로덕션 디버깅
실패한 시스템 콜 추적
애플리케이션이 No space left on device로 실패할 때, 어떤 시스템 콜이 거절되었는지 보면 원인을 좁힐 수 있습니다.
# PID는 문제 프로세스로 교체, ENOSPC로 실패한 호출만 보기
sudo strace -f -e trace=openat,creat,mkdir,mkdirat,write -e status=failed -p 12345 2>&1 | grep ENOSPC
inode 고갈이면 openat(..., O_CREAT)이나 mkdirat처럼 새 파일을 만드는 호출에서 ENOSPC가 나고, 기존 파일에 대한 write는 성공할 수 있습니다. 블록 고갈이면 write에서 ENOSPC가 납니다. -e status=failed는 strace 5.2 이상에서 쓸 수 있습니다.
커널 로그
dmesg -T | tail -100
journalctl -k -b --no-pager | tail -100
ext4나 XFS가 메타데이터 오류를 감지하면 커널 로그에 기록하고, 설정에 따라 파일 시스템을 읽기 전용으로 다시 마운트합니다. 알람 시각 전후로 범위를 좁혀 읽습니다.
모니터링
node_exporter 기준으로 블록은 node_filesystem_avail_bytes와 node_filesystem_size_bytes, inode는 node_filesystem_files_free와 node_filesystem_files로 볼 수 있습니다. avail_bytes는 예약 블록을 뺀 값이라 일반 프로세스가 실제로 쓸 수 있는 양에 가깝습니다. 사용률 임계값 알람과 함께 predict_linear(node_filesystem_files_free[6h], 4*3600) < 0처럼 감소 속도로 고갈 시점을 예측하는 알람을 걸어 두면, 캐시가 갑자기 폭증할 때 더 빨리 알 수 있습니다.
오프라인 점검
# 마운트 해제된 데이터 파티션에서 수정 없이 검사만
sudo umount /data
sudo e2fsck -fn /dev/vg0/data
# XFS는 xfs_repair -n (역시 마운트 해제 상태에서)
sudo xfs_repair -n /dev/vg0/data
마운트된 상태에서 fsck -n을 실행하면 사용 중인 메타데이터가 계속 바뀌어 존재하지 않는 오류가 보고될 수 있으므로, 점검 창에서 마운트를 해제한 뒤 실행합니다. 실제 수정(-y, -n 없는 xfs_repair)은 백업을 확인한 다음에 합니다.
장애 보고서
장애 보고서에는 df -ih 출력과 원인 디렉터리의 inode 개수처럼 관측한 증거를 함께 남기는 것이 좋습니다. 그래야 이후 볼륨 분리나 -i 옵션 재포맷 같은 용량 설계 논의를 근거를 두고 할 수 있습니다.
증상별 조치
| 증상 | 조치 |
|---|---|
No space left on device인데 df -h는 여유 | df -ih로 inode를 확인하고 du --inodes로 원인 경로를 찾습니다. XFS라면 imaxpct와 공간 단편화도 의심합니다. |
| 블록이 95% 근처에서 일반 프로세스만 실패 | ext4 예약 블록(기본 5%)에 걸린 것입니다. |
| 로그를 지웠는데 용량이 그대로 | lsof +L1로 열린 삭제 파일을 찾고 서비스를 재시작하거나 fd를 truncate합니다. |
/tmp만 찼다 | tmpfs인지 디스크인지 확인하고, 재부팅 시 비우는 설정과 앱의 임시 파일 정리 여부를 봅니다. |
| DB가 디스크 full로 멈춤 | 데이터 디렉터리와 WAL·binlog 경로가 같은 파티션인지, 복제 지연으로 로그가 정리되지 못하고 있는지 확인합니다. |