컴퓨터공학 300 주제 시리즈의 115번째 글이다. 전체 지도는 여기.

한 줄 요약

유닉스 계열 파일 시스템에서 파일의 실체는 번호로 식별되는 아이노드(메타데이터 + 데이터 위치)이고, 디렉터리는 “이름 → 아이노드 번호” 표를 담은 특수한 파일일 뿐이어서, 하나의 파일이 여러 이름을 가질 수도(하드 링크) 이름 없이 살아 있을 수도 있다.

왜 필요한가

다음 현상들은 모두 “이름과 파일은 다르다”는 한 가지 사실에서 나온다.

  • rm 으로 큰 로그를 지웠는데 디스크 공간이 돌아오지 않는다.
  • mv 는 같은 파일 시스템 안에서 수 GB 파일도 즉시 끝나는데, 다른 파일 시스템으로는 한참 걸린다.
  • 설정 파일을 에디터로 고쳤더니 그 파일을 감시하던 프로그램이 변경을 못 알아챈다.
  • 디렉터리에 파일이 수백만 개 있으면 ls 가 느리다.

아이노드와 디렉터리 구조를 알면 이 현상들이 모두 예측 가능해진다.

핵심 개념

아이노드에 들어 있는 것

아이노드(inode, index node)는 파일 하나에 대한 메타데이터 구조체다. 파일 시스템 안에서 아이노드 번호로 식별된다.

들어 있는 것 예
파일 종류와 권한 일반 파일/디렉터리/심볼릭 링크/장치, rwxr-xr-x
소유자 UID, GID
크기 바이트 수
시각 atime(접근), mtime(내용 수정), ctime(아이노드 변경)
링크 수 이 아이노드를 가리키는 디렉터리 항목 수
데이터 위치 블록 포인터 또는 익스텐트

들어 있지 않은 것이 중요하다. 바로 파일 이름이다. 이름은 디렉터리에 있다. inode(7) 매뉴얼이 이 필드들을 stat 구조체와 함께 정리하고 있다.

디렉터리는 표다

디렉터리도 아이노드를 가진 파일이다. 그 내용이 이름과 아이노드 번호의 목록일 뿐이다.

디렉터리 /home/u (아이노드 500) 의 내용
+------------+--------------+
| 이름        | 아이노드 번호  |
+------------+--------------+
| .          | 500          |  자기 자신
| ..         | 2            |  부모
| notes.txt  | 7731         |
| backup.txt | 7731         |  같은 아이노드를 가리키는 두 번째 이름
| docs       | 812          |  하위 디렉터리
+------------+--------------+

항목이 적을 때는 단순 선형 목록이면 되지만, 수만~수백만 개가 되면 이름 찾기가 느려진다. ext4 는 이를 위해 이름의 해시로 정렬한 트리 인덱스(htree)를 디렉터리에 둘 수 있다. 커널의 ext4 문서에 형식이 정리되어 있다.

경로 탐색

open("/home/u/notes.txt") 를 하면 커널은

  1. 루트 디렉터리 아이노드(ext 계열에서는 2번)에서 시작해 home 을 찾고,
  2. 그 아이노드의 데이터에서 u 를 찾고,
  3. 다시 notes.txt 를 찾아 아이노드 7731 에 도달한다.

각 단계마다 디렉터리 실행 권한(x, 탐색 권한)을 검사한다. 이 과정을 매번 디스크에서 하면 느리므로, 리눅스는 이름→아이노드 결과를 dentry 캐시에 보관한다. 상세 규칙은 path_resolution(7) 에 있다.

하드 링크와 심볼릭 링크

  하드 링크 심볼릭 링크
정체 같은 아이노드를 가리키는 또 하나의 디렉터리 항목 대상 경로 문자열을 내용으로 가진 별도 아이노드
링크 수 증가 한다 하지 않는다
원본 이름 삭제 시 데이터 그대로, 다른 이름으로 접근 가능 끊어진 링크(dangling)가 된다
파일 시스템 경계 넘을 수 없다(아이노드 번호는 FS 안에서만 의미) 넘을 수 있다
디렉터리 대상 일반적으로 금지(순환 방지) 가능

rm 이 부르는 시스템 콜은 unlink 다. 이름은 “삭제”가 아니라 디렉터리 항목 하나를 지우고 링크 수를 1 줄이는 것이다. 커널은 다음 두 조건이 모두 성립할 때에만 아이노드와 데이터 블록을 실제로 해제한다.

  1. 링크 수가 0 이다.
  2. 그 파일을 열고 있는 프로세스가 없다.

그래서 프로세스가 열고 있는 로그 파일을 지우면 이름은 사라지지만 공간은 그대로다.

rename 은 원자적이다

같은 파일 시스템 안의 rename 은 디렉터리 항목만 바꾸므로 파일 크기와 무관하게 빠르고, POSIX 는 대상 이름이 이미 있을 때 그 교체가 원자적이도록 규정한다. “임시 파일에 쓰고 fsync 한 뒤 rename 으로 덮어쓰기”가 설정 파일을 안전하게 바꾸는 표준 방법인 이유다. 다른 파일 시스템으로의 mv 는 rename 이 EXDEV 로 실패하므로 복사 후 삭제로 처리된다.

직접 해 보기

하드 링크, 심볼릭 링크, 열린 채로 지운 파일을 차례로 관찰한다.

import os, tempfile, stat

d = tempfile.mkdtemp(); os.chdir(d)
open("a.txt", "w").write("hello\n")
os.link("a.txt", "b.txt")          # 하드 링크: 같은 아이노드에 이름 하나 더
os.symlink("a.txt", "s.txt")       # 심볼릭 링크: 경로 문자열을 담은 별도 아이노드

def info(name):
    s = os.lstat(name)             # lstat: 링크를 따라가지 않는다
    kind = "symlink" if stat.S_ISLNK(s.st_mode) else "file"
    print(f"{name}: inode {s.st_ino}  links {s.st_nlink}  type {kind}  size {s.st_size}")
for n in ("a.txt", "b.txt", "s.txt"): info(n)

print("디렉터리 항목 :", [(e.name, e.inode()) for e in os.scandir(".")])
print("'.' 의 링크 수:", os.stat(".").st_nlink)

os.unlink("a.txt")                 # 원래 이름 삭제
print("a 삭제 후 b 읽기:", open("b.txt").read().strip(), "/ links", os.stat("b.txt").st_nlink)
print("심볼릭 링크 따라가기:", os.path.exists("s.txt"), "(대상 이름이 사라짐)")

# 열린 채로 지운 파일: 이름은 없지만 아이노드와 데이터는 살아 있다
f = open("b.txt")
os.unlink("b.txt")
print("이름 다 지운 뒤 디렉터리:", os.listdir("."), "/ 열린 fd 로 읽기:", f.read().strip(),
      "/ links", os.fstat(f.fileno()).st_nlink)
print("/proc 에서 보이는 모습:", os.readlink(f"/proc/self/fd/{f.fileno()}"))
f.close()

ext4 에서의 결과다.

a.txt: inode 3801115  links 2  type file  size 6
b.txt: inode 3801115  links 2  type file  size 6
s.txt: inode 3801116  links 1  type symlink  size 5
디렉터리 항목 : [('a.txt', 3801115), ('b.txt', 3801115), ('s.txt', 3801116)]
'.' 의 링크 수: 2
a 삭제 후 b 읽기: hello / links 1
심볼릭 링크 따라가기: False (대상 이름이 사라짐)
이름 다 지운 뒤 디렉터리: ['s.txt'] / 열린 fd 로 읽기: hello / links 0
/proc 에서 보이는 모습: /tmp/tmpvs6q7hbm/b.txt (deleted)
  • a.txt 와 b.txt 는 아이노드 번호가 같다. 둘은 “원본과 사본”이 아니라 대등한 두 이름이다.
  • 심볼릭 링크의 크기 5 는 담고 있는 문자열 a.txt 의 길이다.
  • 빈 디렉터리의 링크 수 2 는 부모 디렉터리의 항목과 자기 안의 . 이다(ext4 기준, 하위 디렉터리가 생기면 각자의 .. 만큼 늘어난다).
  • 마지막 장면이 핵심이다. 이름이 하나도 없고 링크 수가 0 인데 열린 파일 디스크립터로 내용을 읽을 수 있다. /proc/self/fd 는 이 상태를 (deleted) 로 표시한다.

현업에서는

  • 지웠는데 공간이 안 돌아올 때: lsof +L1 은 링크 수가 1 미만(즉 0)인데 열려 있는 파일을 보여 준다. 로그 로테이션 후 애플리케이션이 옛 파일을 계속 쥐고 있는 경우가 대부분이다. 프로세스에 로그 재열기 시그널을 보내거나(많은 데몬이 SIGHUP 을 쓴다), 급하면 /proc/PID/fd/N 을 truncate 해서 공간을 되찾는다.
  • 쿠버네티스 ConfigMap 볼륨의 심볼릭 링크: ConfigMap 을 볼륨으로 마운트하면 파일들이 숨김 디렉터리를 가리키는 심볼릭 링크로 보이고, 갱신 시 디렉터리 링크를 원자적으로 바꿔 끼운다. 그래서 파일의 아이노드를 직접 감시하는 프로그램은 변경을 놓칠 수 있고, subPath 로 마운트한 파일은 아예 갱신되지 않는다는 제약이 문서에 적혀 있다.
  • 원자적 설정 교체: 임시 파일 작성 → fsync → rename 패턴을 쓰면 읽는 쪽은 옛 파일 아니면 새 파일만 보고, 반쯤 쓰인 파일은 절대 보지 않는다.
  • 아이노드 고갈: 작은 파일을 대량으로 만드는 워크로드(빌드 캐시, 메일 큐)는 df -i 를 모니터링 대상에 넣는다.

확인 문제

  1. 아이노드에 저장되지 않는 대표적인 파일 정보는 무엇이며, 어디에 저장되는가?
  2. 하드 링크가 다른 파일 시스템의 파일을 가리킬 수 없는 이유는?
  3. 열린 로그 파일을 rm 했는데 공간이 돌아오지 않는 이유를 링크 수와 연결해 설명하라.
  4. 같은 파일 시스템 안에서 10GB 파일의 mv 가 즉시 끝나는 이유는?
  5. 심볼릭 링크의 대상 파일을 지우면 어떻게 되는가?

풀이

  1. 파일 이름이다. 이름은 부모 디렉터리의 데이터(이름 → 아이노드 번호 항목)에 저장된다.
  2. 하드 링크는 아이노드 번호를 가리키는데, 아이노드 번호는 각 파일 시스템 안에서만 고유하기 때문이다.
  3. rm 은 링크 수를 0 으로 만들 뿐이고, 커널은 링크 수 0 과 “여는 프로세스 없음”이 모두 만족될 때만 블록을 해제한다. 프로세스가 아직 열고 있으므로 해제되지 않는다.
  4. rename 은 디렉터리 항목만 바꾸고 데이터 블록은 옮기지 않기 때문이다.
  5. 심볼릭 링크는 경로 문자열만 가지고 있으므로 그대로 남지만, 따라가면 대상이 없어 끊어진 링크가 된다.

더 읽을거리 (References)