Linux 시리즈 #03 — 파일·inode 내부: ext4/XFS 온디스크 구조와 익스텐트

이 글의 핵심

온디스크 inode가 메타데이터와 데이터 위치를 어떤 자료구조로 표현하는지(ext4 익스텐트·XFS 포크·B+트리)를 운영·성능 튜닝 관점에서 풀어 씁니다.

시리즈 안내

#03 | 실무 트러블슈팅: Linux 디스크 full vs inode · 블록·저널·I/O 스케줄러: #09 디스크·블록 계층 · 개요: Linux 커널 내부 동작


들어가며: 디스크는 남는데 파일이 안 만들어질 때

df -h로 보면 용량이 넉넉한데 touch 한 번에 No space left on device가 뜨는 상황이 있습니다. 이때 df -i를 찍어 보면 inode 사용률이 100%인 경우가 많습니다. 디스크 용량과 inode 개수는 서로 다른 자원이고, 어느 한쪽만 바닥나도 새 파일을 만들 수 없습니다.

UNIX와 리눅스에서 말하는 “파일”은 겉으로 보이는 경로 문자열과, 커널이 실제로 다루는 inode(아이노드)가 분리되어 있습니다. 용량이 남아도 파일 생성이 안 되는 현상, 하드 링크와 cp의 차이, rm한 파일에 프로세스가 계속 쓰는 현상은 대부분 이 분리로 설명됩니다. 이 글은 VFS, ext4, XFS를 차례로 보고 온디스크 inode의 익스텐트 구조까지 내려간 뒤, inode 고갈과 메타데이터 I/O 문제를 운영 관점에서 정리합니다. 저널, 블록 할당, I/O 스케줄러는 #09에서 다룹니다.


파일 시스템 개요: VFS, ext4, XFS

VFS(가상 파일시스템)의 역할

리눅스 커널은 VFS 계층에서 open/read/write/mmap 같은 공통 인터페이스와 inode·dentry·file 추상화를 제공하고, 그 아래에서 ext4, XFS, Btrfs, NFS가 각자의 온디스크 포맷을 구현합니다. 사용자는 경로로 파일을 다루지만, 커널은 (파일시스템, inode 번호) 쌍으로 파일을 식별하고 캐시합니다. 경로 → dentry → inode 변환이 자주 일어날수록 dentry·inode 캐시 적중률이 성능을 좌우하며, 대규모 빌드에서 느끼는 지연의 일부가 여기서 나옵니다.

ext4(Fourth Extended File System)

ext4는 저널링을 지원하는 익스텐트 기반 범용 로컬 파일시스템입니다. 블록 그룹 단위로 inode 비트맵, 블록 비트맵, inode 테이블이 배치되고, flex_bg 등으로 메타데이터를 묶어 배치를 최적화할 수 있습니다. 소수의 대용량 파일보다는 다수의 작은 파일이 섞인 워크로드에서 inode 개수와 디렉터리·메타데이터 I/O가 병목이 되기 쉽습니다. 관리 도구로는 tune2fs, debugfs 등이 대표적입니다.

XFS

XFS는 디스크를 할당 그룹(AG, allocation group)으로 나누고, 자유 공간과 inode 정보를 B+트리로 관리합니다. 포맷할 때 inode 테이블을 고정 크기로 깔아 두는 ext4와 달리, XFS는 필요할 때 inode 청크를 동적으로 할당합니다. 그래서 ext4처럼 “inode 슬롯이 다 찼다”기보다는 전체 공간 중 inode에 쓸 수 있는 비율(imaxpct)과 AG별 여유 공간이 함께 문제가 됩니다. 지연 할당 덕분에 순차 쓰기와 대용량 파일에 강합니다. 관측에는 xfs_info, xfs_bmap, xfs_db 등을 씁니다.

무엇을 고를까

조직마다 의견이 갈리는 부분이지만 대략 이렇게 정리할 수 있습니다. ext4는 어디에나 깔기 무난하고 도구가 익숙해서 루트 파티션에 많이 쓰입니다. XFS는 RHEL 계열의 기본 파일시스템이고, 대용량·순차 I/O가 많은 데이터 볼륨이나 inode를 미리 정해 두기 어려운 경우에 자주 선택됩니다. 결정은 실제 워크로드로 벤치마크해 보고 내리는 것이 맞습니다.

어느 쪽이든 결국 inode(정확히는 VFS inode와 온디스크 inode)와 익스텐트 이야기로 수렴하므로, 이제 inode에 무엇이 들어가는지 보겠습니다.


Inode 구조: inode란 무엇인가, 저장 정보

inode의 역할(개념)

inode는 데이터가 디스크 어디에 얼마나 있는지, 누가 소유하고 권한은 무엇인지를 적어 둔 메타데이터 레코드입니다. 파일 이름은 inode에 들어 있지 않습니다. 이름과 inode 번호의 대응은 부모 디렉터리의 엔트리가 들고 있으므로, 같은 파일시스템 안에서 파일 이름을 바꾸는 것은 inode는 그대로 두고 디렉터리 엔트리만 고치는 작업입니다.

일반적으로 inode(및 VFS inode)에 들어가는 정보

  • 파일 타입 (일반, 디렉터리, 심볼릭 링크, FIFO, 소켓, 블록/캐릭터 디바이스 등)
  • 접근 권한(permission bits)과 특수 비트 (setuid, setgid, sticky)
  • 소유자 UID, 그룹 GID
  • 크기, 블록 수
  • 타임스탬프 (atime, mtime, ctime; ext4와 XFS v5는 생성 시각 btime도 저장)
  • 하드 링크 수(nlink) — 0이 되고 열려 있는 fd도 없으면 inode와 데이터 블록이 해제됨
  • 데이터 위치 — 익스텐트, 포인터 트리 등(파일시스템별)

VFS struct inode vs 온디스크 inode

  • VFS struct inode: 슬랩 캐시에 할당되고 락, 참조 카운터, i_mapping, 그리고 i_op/i_fop로 파일시스템별 연산에 연결되는 인메모리 객체입니다.
  • 온디스크 inode: mkfs 시점에 정한 inode 크기(ext4 기본 256B)와 슈퍼블록 파라미터에 맞춘 고정 레이아웃의 레코드입니다. ext4, XFS, Btrfs는 필드와 트리 구조가 서로 다릅니다. 둘을 같은 “inode”로 혼동하면 안 됩니다.

Inode vs 파일명: 디렉터리의 역할

이름은 디렉터리에, inode는 별도

/var/log/nginx/access.log를 열면, 커널은 루트 → var → log → nginx → access.log 순으로 각 디렉터리를 읽으며 이름에 대응하는 inode 번호를 찾습니다. 같은 inode를 서로 다른 이름이 가리킬 수 있고(하드 링크), 한 inode에 붙은 이름의 개수가 링크 카운트입니다.

“같은 디렉터리 안”과 “다른 파티션”

  • 하드 링크는 같은 파일시스템 안에서만 만들 수 있습니다. inode 번호는 파일시스템마다 따로 매겨지므로 다른 파티션의 파일을 가리킬 방법이 없습니다. 이때는 경로 문자열만 저장하는 심볼릭 링크를 씁니다.

디렉터리의 온디스크 표현(요지)

ext4 디렉터리는 작을 때는 단순한 엔트리 목록이지만, 커지면 해시 트리(htree) 인덱스를 써서 이름 조회를 트리 탐색으로 바꿉니다. 같은 ls라도 캐시가 비어 있을 때는 I/O 패턴이 달라지고, 운영에서는 이것이 메타데이터 IOPS로 나타납니다. 뒤의 성능 절과 이어지는 내용입니다.


하드 링크: 동일 inode 참조, 예제

정의

하드 링크는 새로운 디렉터리 엔트리(이름)를 만들어 기존 inode를 가리키게 하는 것입니다. ls -i로 보면 inode 번호가 같고, stat의 Links(nlink)가 1 늘어납니다.

예제

echo 'hello' > a.txt
ln a.txt b.txt
ls -li a.txt b.txt
stat a.txt

a.txt와 b.txt는 같은 inode를 공유하므로, 한쪽을 수정하면 다른 쪽에도 즉시 반영됩니다. cp로 복사할 때와 달리 데이터 블록은 늘지 않고 디렉터리 엔트리 하나만 추가됩니다.

제약과 운영 팁

  • 같은 파일시스템 안에서만 만들 수 있습니다. 경계를 넘으면 EXDEV 오류가 납니다.
  • 리눅스는 디렉터리에 대한 하드 링크를 root에게도 허용하지 않습니다. 디렉터리 트리에 순환이 생기면 find나 fsck가 끝나지 않기 때문입니다.
  • 백업·동기화 도구는 하드 링크를 보존할지 옵션으로 정합니다. rsync는 -H를 줘야 하드 링크를 보존하고, cp -a는 같은 복사 작업 안의 하드 링크를 보존합니다. cp -l은 복사 대신 하드 링크를 만드는 옵션입니다.

하드 링크와 심볼릭 링크를 고르는 기준

배포 스크립트에서 흔히 부딪치는 질문입니다. 같은 inode를 공유하는 하드 링크는 원본 이름이 지워져도 데이터가 살아 있다는 장점이 있지만, 마운트 경계를 넘을 수 없습니다. 배포 디렉터리와 공유 데이터가 다른 볼륨에 있다면 선택지는 심볼릭 링크뿐입니다. 심볼릭 링크를 쓸 때는 상대 경로로 만들었다가 링크 위치가 바뀌면서 깨지는 사고가 잦으므로, 절대 경로로 만들지 상대 경로로 만들지를 스크립트 규칙으로 정해 두는 편이 좋습니다.


정의

심볼릭 링크(소프트 링크)는 자체 inode를 갖는 특수 파일이며, 내용으로 다른 경로 문자열을 저장합니다. 대상이 삭제되거나 이동되면 끊긴(dangling) 링크가 됩니다. 다른 파티션이나 디렉터리를 가리킬 수 있어 하드 링크보다 유연합니다.

하드 링크와의 차이

하드 링크는 inode를 공유하므로 ls -i로 보면 번호가 같고, 데이터를 새로 쓰는 것이 아니라 디렉터리에 이름을 하나 더 만드는 것에 가깝습니다. 같은 파일시스템 안에서만 만들 수 있습니다. 심볼릭 링크는 별도의 inode를 가지며 내용은 경로 문자열입니다. 파일시스템 경계를 넘을 수 있는 대신, 원본이 사라지면 끊어지고, 상대 경로로 만들었다면 링크 파일이 있는 위치를 기준으로 해석되므로 링크를 옮기면 의미가 바뀝니다.

예제

ln -s /var/log/app app.log
ls -l app.log
readlink -f app.log

상대 심볼릭 링크(ln -s ../data/foo)는 링크가 놓인 디렉터리를 기준으로 해석되므로, 링크를 다른 곳으로 옮기면 깨질 수 있습니다. 배포, 컨테이너 이미지, 스크립트에서는 어떤 경로 방식을 쓸지 규칙으로 정해 두는 것이 안전합니다.


Inode 테이블: 고갈 문제, df -i

df와 df -i의 차이

  • df -h: 데이터 블록이 얼마나 남았는지 보여 줍니다.
  • df -i: inode가 얼마나 남았는지 보여 줍니다.

용량이 남는데도 No space left on device가 뜨면 먼저 inode를 의심합니다. 반대로 inode는 넉넉한데 블록이 떨어지는 경우도 물론 있습니다.

df -h /
df -i /

ext4의 inode 수

ext4는 포맷할 때 inode 밀도(mkfs.ext4 -i bytes-per-inode 또는 -N)에 따라 전체 inode 수가 정해지고, 이후에는 바꿀 수 없습니다. 작은 파일이 아주 많이 생기는 애플리케이션을 올릴 계획이라면 포맷 전에 inode 수를 계산해 두어야 합니다. 사후에 늘리려면 대개 백업 후 다시 포맷해야 합니다.

XFS의 경우

XFS는 inode를 동적으로 할당하므로 df -i의 전체 inode 수도 여유 공간에 따라 달라집니다. 그래서 df -i만 보지 말고 imaxpct 설정과 AG별 여유 공간도 함께 확인해야 합니다. 자세한 대응 순서는 inode full 트러블슈팅을 참고하세요.


파일 메타데이터: stat, 권한, 타임스탬프

stat으로 inode와 타임스탬프

stat /path/to/file
ls -l --time=atime
ls -l --time=ctime
  • atime: 마지막 접근 시각. 마운트 옵션(relatime이 기본, noatime)에 따라 갱신 빈도가 달라집니다.
  • mtime: 내용이 마지막으로 수정된 시각.
  • ctime: inode 메타데이터가 마지막으로 바뀐 시각. chmod, chown, 링크 추가, 그리고 내용 수정 시에도 갱신됩니다. 생성 시각(creation time)이 아니라는 점에 주의하세요.

생성 시각은 statx(2)가 지원하는 파일시스템에서 stat 출력의 Birth 항목으로 볼 수 있습니다. 지원하지 않는 파일시스템에서는 비어 있습니다.

ls가 보여 주는 권한 문자열

ls -l

맨 앞 한 글자는 파일 타입이고, 이어지는 9자는 user/group/others의 rwx입니다. 권한 문자열 끝에 +가 붙어 있으면 POSIX ACL이 설정된 것이므로 getfacl로 확인합니다. .이 붙어 있으면 SELinux 보안 컨텍스트가 있다는 뜻이고 ls -Z로 볼 수 있습니다.


파일 타입: 일반, 디렉터리, 심볼릭, 블록/캐릭터

ls -l 맨 앞 한 글자

맨 앞이 -면 일반 파일, d면 디렉터리, l이면 심볼릭 링크(뒤에 ->로 대상이 표시됨)입니다. b와 c는 블록 디바이스와 캐릭터 디바이스, p는 FIFO(이름 있는 파이프), s는 소켓입니다. 이 첫 글자만 보면 내용이 있는 파일인지, 디렉터리인지, 링크인지, 드라이버와 연결된 특수 파일인지 구분됩니다.

디바이스 파일

/dev 아래의 블록·캐릭터 파일은 디스크에 저장된 바이트가 아니라 드라이버에 연결된 특수 inode입니다. ls -l에서 크기 자리에 major, minor 번호가 표시됩니다. 컨테이너에서는 cgroup의 디바이스 접근 제어와 노출된 장치가 호스트와 다르므로, 같은 경로라도 접근 가능 여부가 다를 수 있습니다.


권한 시스템: rwx, chmod, umask

rwx(읽기/쓰기/실행)

  • 파일: 읽기, 쓰기, 실행(바이너리나 스크립트).
  • 디렉터리: r은 이름 목록 읽기, w는 엔트리 생성·삭제·이름 변경(x도 함께 있어야 함), x는 디렉터리 통과와 그 안의 파일 접근입니다. r만 있고 x가 없으면 ls로 이름은 보이지만 파일의 속성을 읽거나 열 수 없습니다. 반대로 x만 있고 r이 없으면 목록은 볼 수 없어도 정확한 이름을 알면 파일에 접근할 수 있습니다.

8진수 chmod와 심볼 chmod

chmod 644 file.txt
chmod u+rw,go-w somedir

umask

새로 만드는 파일과 디렉터리의 기본 권한에서 뺄 비트를 지정하는 값으로, 프로세스마다 따로 가집니다.

umask
( umask 027; touch a; mkdir d; ls -ld a d )

애플리케이션이 open()에 mode를 명시하면 최종 권한은 mode & ~umask가 됩니다. 그래서 코드에서 0666을 주어도 umask가 022면 0644로 만들어집니다.


소유권: user, group, chown, chgrp

sudo chown user:group /path
sudo chgrp group /path
  • 파일 소유자를 바꾸는 것은 root(정확히는 CAP_CHOWN)만 할 수 있습니다. 일반 사용자는 자기 파일의 그룹을 자신이 속한 그룹 중 하나로 바꾸는 것만 가능합니다.
  • 디렉터리에 setgid(chmod g+s)를 걸면 그 안에서 새로 만든 파일의 그룹이 디렉터리 그룹을 따르므로, 팀 공유 디렉터리에서 자주 쓰는 패턴입니다.

특수 비트: setuid, setgid, sticky

setuid(4000) / setgid(2000) — chmod u+s / g+s

  • setuid: 실행 파일에 걸면 실행하는 사용자가 아니라 파일 소유자의 권한으로 동작합니다(전통적 예: passwd). 보안상 민감하므로 불필요한 setuid는 제거합니다. 리눅스는 스크립트의 setuid 비트를 무시합니다.
  • setgid: 실행 파일에 걸면 파일 그룹의 권한으로 동작합니다. 디렉터리에 걸면 새 파일이 디렉터리의 그룹을 상속하고, 새 하위 디렉터리에도 setgid가 이어집니다.
chmod u+s /path/bin
chmod g+s /path/shared_dir

sticky bit(1000) — chmod +t (흔한 예: /tmp)

디렉터리에 걸면, 그 안의 항목은 항목 소유자, 디렉터리 소유자, root만 삭제하거나 이름을 바꿀 수 있습니다. 누구나 쓸 수 있는 공용 디렉터리에서 남의 파일을 지우는 것을 막는 용도입니다.

ls -ld /tmp
# ... drwxrwxrwt ...  <- 마지막 t

공용 쓰기 디렉터리의 전형적인 권한은 chmod 1777입니다.

ls로 한눈에: s, S, t, T

  • 실행 비트 자리에 소문자 s/t가 보이면 실행 권한과 특수 비트가 함께 켜진 것이고, 대문자 S/T면 실행 권한 없이 특수 비트만 켜진 것입니다. 대문자는 대개 설정 실수이므로 발견하면 확인해 볼 만합니다.

Inode 넘버 확인: ls -i, stat

ls -i
find . -samefile /path/to/file
stat -c '%i %n' /path/to/file
  • find -samefile: 같은 inode를 가리키는 다른 이름을 모두 찾을 때 유용합니다. 탐색하는 디렉터리에 대한 권한이 있어야 합니다.

파일 삭제 메커니즘: unlink와 링크 카운트

unlink(2) / rm이 하는 일

rm은 unlink(2)로 디렉터리에서 이름(엔트리)을 제거합니다. 그 결과 링크 수가 0이 되고 해당 inode를 열고 있는 프로세스도 없으면, 그때 데이터 블록과 inode가 해제됩니다. 프로세스가 파일을 열어 둔 채 마지막 이름을 지우면 디렉터리에서는 사라지지만 inode는 살아 있고, 열린 fd로 읽기와 쓰기가 계속됩니다. 공간은 그 프로세스가 fd를 닫을 때 비로소 반환됩니다. 로그 로테이션, 임시 파일 처리, 포렌식에서 모두 중요한 동작입니다.

lsof로 “삭제되었는데 공간을 잡고 있는” 파일 찾기

lsof +L1은 링크 수가 1 미만, 즉 이름은 지워졌지만 아직 열려 있는 파일을 보여 줍니다. 출력의 NAME 칸에 (deleted)가 붙어 있습니다. /proc/PID/fd를 직접 살펴보는 방법도 있습니다. 자세한 내용은 아래 lsof 절에서 다룹니다.


백업과 로그 로테이션에서 만나는 inode

백업과 inode·하드 링크

  • rsync --link-dest로 만드는 하드 링크 기반 증분 백업은 바뀌지 않은 파일을 이전 백업과 같은 inode로 연결해 공간을 아낍니다. 대상이 같은 파일시스템이어야 하고, 어느 백업이 어떤 inode를 공유하는지 복구 절차에 적어 두어야 합니다.
  • 작은 파일이 아주 많다면 tar나 squashfs 같은 아카이브로 묶어 보관하는 편이 백업 스캔과 복원 모두에서 유리한 경우가 많습니다.

로그 로테이션과 “공간이 안 늘어남”

일반적 패턴:

# 예시: 앱이 /var/log/app.log 를 열어둔 상태에서
mv /var/log/app.log /var/log/app.log.1
# 앱이 reopen 하지 않으면, 여전히 옛 inode에 쓰는 경우가 있음
  • mv는 이름만 바꿀 뿐 inode는 그대로이므로, 앱은 계속 app.log.1에 씁니다. 해결책은 앱이 로그 파일을 다시 열게 하는 것입니다. logrotate의 postrotate에서 시그널(예: USR1)을 보내거나, 앱이 reopen을 지원하지 않는다면 copytruncate를 씁니다. 같은 이유로, 열려 있는 로그 파일을 rm해도 df가 줄지 않습니다(위 unlink 절).

logrotate 구성 스케치(개념)

아래는 개념 전달용 예시이므로 실제 데몬 문서에 맞게 조정해야 합니다. copytruncate는 앱이 reopen을 지원하지 않을 때 쓰지만, 복사와 truncate 사이에 쓰인 로그가 유실될 수 있다는 점을 알고 써야 합니다.

# /etc/logrotate.d/app-example (개념 예시)
/var/log/app/*.log {
    daily
    rotate 14
    compress
    delaycompress
    missingok
    notifempty
    sharedscripts
    postrotate
        /bin/kill -USR1 `cat /var/run/app.pid` 2>/dev/null || true
    endscript
}

sharedscripts는 여러 로그를 한 번에 돌릴 때 postrotate를 한 번만 실행하게 합니다. Nginx처럼 USR1 시그널로 로그 파일을 다시 여는 데몬이 많지만, 시그널 의미는 데몬마다 다르므로 공식 문서를 기준으로 합니다.

find로 inode·같은 파일 추적

find /var/tmp -xdev -inum 12345678 -ls
find /data -samefile /data/a/file.bin

inode 번호는 파일시스템마다 따로 매겨지므로, -inum으로 찾을 때는 -xdev로 다른 파일시스템에 넘어가지 않게 해야 엉뚱한 파일이 섞이지 않습니다. 하드 링크가 많이 생긴 디렉터리에서 실제 공간 사용량을 따질 때도 도움이 됩니다.


lsof로 디버깅하고 inode 고갈 해결하기

lsof 기본

sudo lsof /path
sudo lsof -p PID
lsof -nP -i
  • 어떤 프로세스가 파일, 소켓, mmap 영역을 잡고 있는지 보여 줍니다. 삭제된 대용량 로그가 fd에 살아 있으면, 프로세스를 재시작하거나 로그를 다시 열게 하기 전까지 df가 회복되지 않습니다.

inode도 남는데 파일을 만들 수 없을 때

  • 사용자·그룹 쿼터에 걸렸는지 확인합니다(quota, xfs_quota). 쿼터는 블록과 inode 모두에 걸 수 있습니다.
  • ext4에서 한 디렉터리에 엔트리가 너무 많으면 Directory index full 경고와 함께 생성이 실패할 수 있습니다. dmesg를 확인합니다.
  • XFS에서는 전체 여유 공간이 있어도 새 inode 청크를 할당할 연속 공간이 없으면 생성이 실패할 수 있습니다. 이 경우도 dmesg와 위 트러블슈팅 글을 참고합니다.

ext4: debugfs로 익스텐트 확인

sudo debugfs -R 'stat /path/to/file' /dev/sdXN
  • debugfs는 기본적으로 읽기 전용으로 엽니다. 출력의 EXTENTS: 항목이 비정상적으로 길면 조각화나 작은 랜덤 쓰기를 의심합니다. 마운트된 파일시스템을 읽을 때는 캐시에만 있는 최신 변경이 보이지 않을 수 있습니다.

XFS: xfs_bmap

sudo xfs_bmap -v /path/to/file

파일의 논리 오프셋이 어느 물리 익스텐트에 대응하는지 보여 줍니다. 공간은 예약됐지만 아직 기록되지 않은(unwritten) 구간의 표시 방식은 버전에 따라 다릅니다.


성능 고려 사항: inode·dentry·page 캐시

  • VFS: inode·dentry 캐시가 적중하면 경로 해석과 stat 폭주 비용이 줄어듭니다. 수백만 경로를 무작위로 건드리는 워크로드는 디렉터리 샤딩, 아카이브, 캐시 설계가 필요할 수 있습니다.
  • ext4 htree: 큰 디렉터리의 이름 조회는 트리 탐색이 되므로, 캐시가 비어 있을 때 I/O 패턴이 달라집니다.
  • XFS: 지연 할당과 AG 분할이 순차 쓰기와 병렬 쓰기에 유리합니다. 자세한 할당 동작은 #09에서 다룹니다.
  • xattr, SELinux 라벨, ACL이 많이 붙는 환경에서는 XFS의 attr 포크나 ext4의 외부 EA 블록이 메타데이터 I/O를 늘릴 수 있습니다.

VFS inode 캐시와 dentry 캐시

경로를 열 때마다 커널은 dentry(경로 구성 요소) 체인과 inode 객체를 채웁니다. 같은 트리를 반복해서 탐색하는 워크로드(빌드, 정적 사이트 생성, 큰 저장소의 git status)는 캐시 적중의 이익을 보지만, 수백만 개의 서로 다른 경로를 한 번씩만 건드리는 워크로드는 캐시 미스가 이어져 메타데이터 IOPS가 한계에 닿기 쉽습니다. 디스크 대역폭은 남는데 iowait와 메타데이터 지연만 큰 패턴이 이때 흔합니다.

page cache와 inode의 관계

파일 내용은 페이지 캐시에 올라가고, inode는 address space(i_mapping)를 통해 어느 페이지가 이 파일에 속하는지 연결합니다. 대용량 순차 읽기는 페이지 캐시의 이점을 크게 보고, 작은 랜덤 쓰기는 익스텐트 트리 갱신과 저널을 자주 건드립니다. 성능을 볼 때는 한 inode에 쓰기가 얼마나 몰리는지와 동시에 몇 개의 inode를 갱신하는지를 나누어 보는 것이 유효합니다.

메타데이터 I/O를 키우는 대표 패턴

  • 짧은 시간에 대량 create/unlink: CI 아티팩트, 임시 빌드 트리, 수백만 조각으로 쪼개진 캐시.
  • 거대 디렉터리에 readdir + stat: 백업 스캐너, 바이러스 스캐너, 비효율적인 find 조합.
  • xattr·ACL 동시 갱신: 컨테이너 이미지 추출이나 대량 chown -R이 겹치면 메타데이터 락과 저널 경합이 생깁니다.

drop_caches와 inode(주의)

트러블슈팅 중에 sync; echo 3 > /proc/sys/vm/drop_caches로 페이지·dentry·inode 캐시를 비우는 경우가 있습니다. 하지만 비운 직후부터 모든 접근이 디스크로 가므로 I/O가 순간적으로 치솟습니다. 프로덕션에서는 원인 분석과 워크로드 조정이 먼저이고, 캐시 비우기는 측정용이나 일시적인 조치로만 봐야 합니다.

컨테이너·OverlayFS·상위 볼륨

OverlayFS 같은 합성 마운트에서는 레이어마다 inode 번호 체계가 달라, 호스트에서 본 inode 번호와 컨테이너 안에서 본 번호가 다를 수 있습니다. 반면 바인드 마운트한 볼륨은 호스트 파일시스템의 inode를 그대로 쓰므로, 그 볼륨의 inode 고갈은 호스트 파일시스템의 포맷 설정에 직접 달려 있습니다. 같은 경로인데 inode가 달라 보이면 마운트 경계부터 확인합니다.


VFS·온디스크 inode(심화): ext4 i_block과 익스텐트

ext4: 블록 그룹, inode 테이블, i_block

온디스크 inode에는 고정 필드와 함께 데이터 위치를 담는 60바이트의 i_block 영역(__le32 15개)이 있습니다. ext2/ext3 호환 방식에서는 이 영역을 직접 블록 12개와 단일·이중·삼중 간접 블록 포인터로 해석합니다. 파일시스템에 extents 기능이 켜져 있고 inode에 익스텐트 플래그가 있으면, 같은 60바이트를 익스텐트 트리의 루트로 해석합니다.

익스텐트 헤더와 트리(요지)

  • 헤더: 매직 넘버, 현재 엔트리 수, 최대 엔트리 수, 트리 깊이
  • 리프 엔트리: (논리 시작 블록, 길이, 물리 시작 블록). 연속된 구간 하나를 엔트리 하나로 표현하므로, 조각이 적으면 메타데이터가 아주 작습니다.
  • 인덱스 엔트리: 담당하는 논리 블록의 시작과 자식 노드 블록 위치

i_block 안에는 헤더와 엔트리 4개가 들어가므로, 조각이 4개 이하인 파일은 별도 블록 없이 inode 안에서 끝납니다. 그보다 조각나면 트리가 깊어지고 메타데이터 쓰기와 지연이 늘어나므로, 조각화는 데이터 읽기 속도뿐 아니라 메타데이터 I/O 문제이기도 합니다.

인라인 데이터·fast commit(포인트)

  • 인라인 데이터: inline_data 기능을 켜고 포맷하면 아주 작은 파일은 데이터 블록 없이 inode 여유 공간에 저장됩니다. 기본으로 켜져 있는 기능은 아니며, inode 크기를 키울수록 밀도가 떨어지는 트레이드오프가 있습니다.
  • fast commit: fsync 지연을 줄이기 위한 가벼운 저널 경로입니다. 상세는 #09에서 다룹니다.

XFS(심화): dinode, 포크, BMBT, 스펙 프리온

AG와 dinode(요지)

XFS의 온디스크 inode(dinode)는 코어, 데이터 포크(data fork), 속성 포크(attr fork)로 나뉩니다. 각 포크는 내용이 짧으면 inode 안에 직접(local) 저장하고, 커지면 익스텐트 목록으로, 더 커지면 B+트리(BMBT, block map B+tree)로 확장됩니다.

attr 포크·xattr

SELinux 라벨이나 컨테이너 관련 xattr이 많이 붙은 파일은 데이터는 작은데 attr 포크가 커질 수 있습니다. 이럴 때는 getfattr로 속성을 확인하고, xfs_bmap -a로 attr 포크의 익스텐트를 볼 수 있습니다.

투기적 사전 할당(speculative preallocation)

XFS는 파일 끝에 순차 쓰기가 이어지면 조각화를 줄이려고 EOF 너머의 공간을 미리 잡아 둡니다. 그래서 쓰는 도중에는 du가 실제 크기보다 크게 보일 수 있고, 파일을 닫거나 일정 시간이 지나면 백그라운드 작업이 남는 공간을 회수합니다. 여러 파일에 동시에 로그를 붙여 쓰는 서버에서 du와 ls -l 크기가 어긋나 보이는 이유가 대개 이것입니다.

ext4와 XFS를 inode 관점에서 비교하면

ext4는 블록 그룹마다 inode 테이블과 비트맵이 고정 배치되어 있고, 데이터 위치는 i_block에서 시작하는 익스텐트 트리로 읽습니다. 작은 파일은 선택적으로 inode 안에 인라인 저장할 수 있고, inode 안에 다 못 들어가는 xattr은 별도 EA 블록에 붙습니다. 도구는 tune2fs, debugfs가 익숙합니다. XFS는 AG로 나뉘고, dinode의 데이터·속성 포크가 local, 익스텐트, BMBT 형식으로 커집니다. xattr이 많은 환경에서는 attr 포크의 크기가 눈에 띄게 됩니다. 관측 도구는 xfs_info, xfs_bmap, xfs_db입니다. 두 파일시스템 모두 “inode”라는 같은 단어를 쓰지만 내부 구조는 꽤 다르다는 점을 기억해 두면 문서를 읽을 때 덜 헷갈립니다.


운영에서 inode가 문제 되는 지점(메타데이터 I/O)

  • 수백만 개의 작은 파일: inode, dentry, 디렉터리가 병목이 되므로 샤딩, 아카이브, 캐시를 검토합니다.
  • 대용량 단일 로그: 익스텐트 수는 적어도 저널, flush, 락이 맞물려 지연이 생길 수 있습니다.
  • xattr 폭증: XFS attr 포크나 ext4 EA 블록이 메타데이터 공간과 I/O를 압박합니다.
  • DB 임시 파일 폭주: DB 내부 저장소와 별개로 파일시스템의 inode와 할당 문제가 드러납니다.

rsync·백업과 하드 링크

rsync는 기본적으로 하드 링크를 알아보지 못하고 각 이름을 별개 파일로 전송하므로, 대상에서는 데이터가 여러 벌로 늘어납니다. 소스의 하드 링크 구조를 그대로 옮기려면 -H를 명시해야 하고, 증분 백업에서 이전 스냅샷과 inode를 공유하려면 --link-dest를 씁니다. 이렇게 하면 inode와 블록을 아끼는 대신, 어느 스냅샷이 어떤 inode를 공유하는지 복구 절차에 적어 두어야 추적할 수 있습니다.

CI·빌드·패키지 캐시

프런트엔드 의존성, 도커 빌드 캐시, 대형 tar 추출은 짧은 시간에 inode를 대량으로 소비합니다. 이런 캐시를 루트 파티션에 두면 서비스와 무관한 빌드 때문에 inode가 가득 찰 수 있습니다. 전용 볼륨을 쓰거나 캐시 정리 작업을 두고, 캐시 경로를 분리해 두면 영향 범위를 줄일 수 있습니다.

Too many open files vs inode

EMFILE/ENFILE은 열린 fd 개수 제한(프로세스별/시스템 전체)에 걸린 것이고, inode와는 다른 축입니다. df -i가 넉넉해도 데몬이 fd를 닫지 않고 누수하면 같은 오류가 납니다. 반대로 inode가 바닥나면 파일 생성 자체가 막히므로, 모니터링과 알람에는 df -h와 df -i를 함께 넣는 것이 좋습니다.

경로에서 inode로(개념도)

VFS는 경로를 dentry 체인으로 풀고 마지막에 inode를 적재합니다. NFS, FUSE, OverlayFS에서는 캐시 재검증 단계가 추가되므로, 로컬 ext4에서 쌓은 직관이 그대로 맞지 않을 수 있습니다.

flowchart LR
  path[사용자 경로] --> dent[dentry / 이름 해석]
  dent --> ino[inode 캐시]
  ino --> meta[모드·UID·rdev·nlink]
  ino --> data[익스텐트/포인터]

모니터링에서 볼 것

프로덕션에서 inode는 눈에 잘 띄지 않는 한정 자원이자 메타데이터 I/O의 단위입니다. 알람과 용량 계획을 잡을 때 inode 사용률(%)만이 아니라 초당 파일 생성·삭제 수 같은 변화율도 함께 보면, 어떤 워크로드가 inode를 소비하는지 가려내기 쉽습니다.


같이 보면 좋은 글