LLM WikiAccess-protected knowledge portal
← 스터디 홈
4편 · 약 23분

파일시스템과 VFS

왜 VFS가 필요한가

Linux는 ext4, btrfs, XFS, NFS, tmpfs, procfs, sysfs, FAT32 등 수십 가지 파일시스템을 지원한다. 사용자 프로그램이 open("/proc/cpuinfo", O_RDONLY)를 호출할 때 그것이 procfs인지, ext4인지 몰라도 된다. 이 투명한 추상화가 VFS(Virtual File System)다.

VFS는 커널의 fs/ 디렉토리에 구현되어 있으며, 시스템 콜과 실제 파일시스템 드라이버 사이의 객체 지향 인터페이스 계층이다. 각 파일시스템은 VFS가 정의한 함수 포인터 테이블을 구현해서 커널에 등록한다.

VFS의 네 가지 핵심 객체

VFS는 네 가지 객체로 파일 시스템 전체를 표현한다.

superblock 마운트된
파일시스템 전체
s_type, s_root
s_op, s_blocksize
→ 포함
inode 파일 메타데이터
(이름 제외)
i_ino, i_mode
i_size, i_op, i_fop
← 가리킴
dentry 경로 컴포넌트
(이름 → inode)
d_name, d_inode
d_parent, d_op
← open() 생성
file 열린 파일 인스턴스
(프로세스·fd당)
f_inode, f_pos
f_flags, f_op
객체 수의 관계 superblock 1개 → inode 다수 (파일 개수만큼) inode 1개 → dentry 여러 개 (하드링크 수만큼) dentry 1개 → file 여러 개 (동시 open 횟수만큼)
VFS 핵심 객체와 계층 관계

superblock — 파일시스템의 닻

struct super_block은 마운트된 파일시스템의 전역 메타데이터를 담는다.

struct super_block {
    dev_t            s_dev;       // 블록 디바이스 번호 (예: /dev/sda1)
    unsigned long    s_blocksize; // 블록 크기 (보통 4096 bytes)
    struct file_system_type *s_type; // "ext4", "btrfs", "tmpfs", ...
    struct dentry   *s_root;      // 이 파일시스템의 루트 dentry
    const struct super_operations *s_op; // alloc_inode, write_inode, sync_fs, ...
    struct list_head s_inodes;    // 모든 inode 연결 리스트
};

mount() 시스템 콜이 호출되면 커널은 해당 파일시스템 드라이버의 fill_super() 함수를 호출해 superblock을 초기화하고 루트 inode를 읽어온다.

inode — 파일의 정체성

inode는 파일 이름을 제외한 파일의 모든 메타데이터를 보유한다. 디스크 상의 inode(ext4의 ext4_inode, 160 bytes)와 메모리 상의 VFS inode(struct inode)가 별개로 존재하며, 커널이 디스크 inode를 읽어 메모리 inode를 채운다.

struct inode {
    umode_t          i_mode;       // 파일 유형(S_IFREG, S_IFDIR...) + 권한(rwxrwxrwx)
    kuid_t           i_uid;        // 소유자 UID
    kgid_t           i_gid;        // 소유 그룹 GID
    loff_t           i_size;       // 파일 크기 (bytes)
    struct timespec64 i_atime;     // 마지막 접근 시각
    struct timespec64 i_mtime;     // 마지막 내용 수정 시각
    struct timespec64 i_ctime;     // 마지막 메타데이터 변경 시각
    unsigned long    i_ino;        // inode 번호 (파일시스템 내 고유)
    unsigned int     i_nlink;      // 하드링크 수 (0이 되면 데이터 삭제)
    const struct inode_operations *i_op;  // create, lookup, mkdir, rename, ...
    const struct file_operations  *i_fop; // read, write, mmap, ioctl, ...
    struct address_space *i_mapping;      // 페이지 캐시 연결점
};

하드링크는 동일한 inode를 가리키는 여러 dentry다. i_nlink 카운터가 0이 되어야(마지막 링크가 unlink()되어야) inode와 데이터 블록이 실제로 해제된다.

심볼릭 링크는 별도의 inode를 가진 일반 파일로, 내용이 대상 경로 문자열이다. S_IFLNK 타입으로 구분한다.

dentry — 경로와 inode 사이의 다리

dentry(directory entry)는 경로 컴포넌트(파일명 하나)와 inode를 연결한다. /etc/passwd는 세 개의 dentry로 구성된다: /, etc, passwd.

dentry는 디스크에 저장되지 않는다 — 완전히 메모리 내의 캐시다. dcache(dentry cache)는 커널의 주요 경로 조회 캐시로, LRU 방식으로 관리된다.

struct dentry {
    struct qstr      d_name;   // 파일명 문자열 + 해시값
    struct inode    *d_inode;  // 연결된 inode (NULL이면 negative dentry)
    struct dentry   *d_parent; // 부모 디렉토리 dentry
    const struct dentry_operations *d_op;
    struct hlist_bl_node d_hash; // dcache 해시 테이블에 연결
};

Negative dentry: 존재하지 않는 경로(/usr/bin/nonexistent)도 캐시에 기록해 반복 조회 시 디스크 읽기를 건너뛴다. 대량의 "파일 없음" 조회가 있는 환경에서 성능에 중요하다.

Linux 2.6.38부터 dcache 조회는 RCU 기반 lock-free 패스를 사용한다. d_seq seqlock이 동시 수정을 감지하며, 일반적인 캐시 히트 경로에서 spinlock을 전혀 잡지 않는다.

file — 열린 파일의 상태

open() 시스템 콜은 struct file을 생성하고, 프로세스의 파일 디스크립터 테이블에 등록한 후 fd 번호를 반환한다. 같은 파일을 10번 열면 10개의 file 구조체가 만들어지지만 inode는 하나다.

struct file {
    struct path       f_path;    // dentry와 마운트 정보
    struct inode     *f_inode;   // 단축 참조 (f_path.dentry->d_inode)
    loff_t            f_pos;     // 현재 읽기/쓰기 오프셋 (per-fd, 독립적)
    unsigned int      f_flags;   // O_RDONLY, O_NONBLOCK, O_APPEND, ...
    fmode_t           f_mode;    // FMODE_READ, FMODE_WRITE
    const struct file_operations *f_op; // read, write, mmap, ioctl, ...
};

f_posfile 구조체마다 독립적이므로, 같은 파일을 두 프로세스가 각각 열면 각자의 오프셋을 유지한다. 반면 dup()으로 복제된 fd들은 같은 file을 공유하므로 f_pos도 공유된다.

경로 탐색(pathname lookup): open()의 여정

open("/etc/passwd", O_RDONLY) 호출 시 커널의 do_filp_open()link_path_walk() 경로:

  1. 루트 dentry(/)에서 시작 (절대 경로) 또는 현재 작업 디렉토리 dentry(상대 경로).
  2. 다음 컴포넌트(etc)를 dcache 해시 테이블에서 RCU 방식으로 조회.

- 캐시 히트: dentry 즉시 반환 → inode 획득. - 캐시 미스: 파일시스템 드라이버의 inode_operations->lookup() 호출 → 디스크 읽기 → 새 dentry 생성 후 캐시에 추가.

  1. dentry의 d_inode->i_mode로 디렉토리인지 확인, 접근 권한 검사(MAY_EXEC).
  2. 심볼릭 링크면 대상 경로로 재귀 탐색 (최대 40회).
  3. passwd 컴포넌트에 동일 과정 반복.
  4. 최종 inode로 struct file 생성 → 파일 디스크립터 번호 반환.
# 실시간 경로 탐색 추적 (strace)
strace -e trace=openat,stat ls /etc/passwd
# openat(AT_FDCWD, "/etc/passwd", O_RDONLY) = 3

페이지 캐시: 읽기/쓰기 성능의 핵심

페이지 캐시(page cache)는 파일 데이터를 메모리에 캐싱하는 커널의 핵심 성능 요소다. read(), write() 모두 실제로는 페이지 캐시를 통해 이루어진다.

읽기 경로 (캐시 히트)
유저 read(fd, buf, n) sys_read() → vfs_read() file_operations.read() 페이지 캐시 히트 유저 버퍼로 복사
읽기 경로 (캐시 미스 — Major Page Fault)
페이지 캐시 미스 address_space_operations
.readpage()
블록 I/O
submit_bio()
디스크 읽기
(수 ms)
캐시 저장 후
유저로 반환
쓰기 경로 (buffered write)
write(fd, buf, n) 페이지 캐시에
Dirty 표시 후 즉시 반환
writeback 데몬
dirty 페이지 flush
디스크
fsync(fd) 호출 시 즉시 flush 보장 — DB는 반드시 fsync를 호출해야 내구성(Durability) 확보
read() 시스템 콜의 VFS 통과 경로
# 페이지 캐시가 얼마나 사용 중인지 확인
free -h
#               total  used  free  shared  buff/cache  available
# Mem:            31G   12G   2.1G   1.2G       17G       17G

# 페이지 캐시 강제 drop (성능 테스트 전 초기화용)
sync && echo 3 > /proc/sys/vm/drop_caches

ext4 디스크 레이아웃 심화

ext4는 Linux에서 가장 널리 쓰이는 범용 파일시스템이다. 디스크를 블록 그룹(block group)으로 분할해 지역성을 높이고 단편화를 줄인다.

Boot
Sector
(1KB)
Super
Block
(1KB)
GDT
그룹 서술자
테이블
Block Group 0 (기본 128 MB)
inode
bitmap
block
bitmap
inode
table
data
blocks
Block Group 1 ~ N 동일 구조 반복
ext4 inode (160 bytes) 핵심 필드 i_mode(권한 비트) | i_uid/gid | i_size_lo/hi | i_links_count i_atime/mtime/ctime/crtime | i_flags (has_journal, extents, ...) i_block[15]: Extent Tree 루트 — 데이터 블록 위치를 범위(extent)로 기술
기본값: 블록 크기 4 KB, 그룹당 32,768 블록 = 128 MB 단위로 관리
ext4 디스크 레이아웃 (블록 그룹 구조)

Extent Tree: 데이터 블록 매핑

ext4는 구식 indirect block 방식 대신 Extent Tree를 사용한다. extent는 (논리 블록 번호, 물리 블록 번호, 길이) 형태로 연속된 블록 범위를 하나의 항목으로 기술한다. 1GB 연속 파일을 단 하나의 extent로 표현할 수 있다.

# 파일의 extent 레이아웃 확인
filefrag -v /var/lib/mysql/data/bigfile.ibd
# Filesystem type is: ef53
# Extent 0: 0..2097151  → 131072..2228223  (8192 블록, 연속)

ext4 저널링 (JBD2)

ext4는 갑작스러운 전원 차단 시 일관성을 보장하기 위해 저널(journal)을 사용한다. 저널 자체는 파일시스템 내 숨겨진 파일(inode 8)이며, 대개 파일시스템 가운데 위치한다.

저널 모드동작성능일관성
writeback메타데이터만 저널, 데이터 순서 미보장가장 빠름가장 낮음
ordered메타데이터 저널 전 데이터 먼저 기록 (기본)중간중간
journal메타데이터 + 데이터 모두 저널가장 느림가장 높음
# 마운트 옵션에서 저널 모드 확인
mount | grep ext4
# /dev/sda1 on / type ext4 (rw,relatime,data=ordered)

inode_operations vs file_operations

VFS의 핵심은 함수 포인터 테이블이다. 파일시스템 드라이버는 이 테이블을 구현한다.

테이블연결 위치주요 함수
super_operationssuperblock.s_opalloc_inode, write_inode, sync_fs, put_super
inode_operationsinode.i_opcreate, lookup, mkdir, rename, readlink
file_operationsinode.i_fopfile.f_opread, write, mmap, ioctl, fsync, poll
dentry_operationsdentry.d_opd_revalidate, d_hash, d_compare
address_space_operationsinode.i_mapping->a_opsreadpage, writepage, writepages, set_page_dirty

ext4에서 일반 파일의 i_opext4_file_inode_operations, i_fopext4_file_operations로 설정된다.

// ext4가 VFS에 자신을 등록하는 방식 (단순화)
static struct file_system_type ext4_fs_type = {
    .name   = "ext4",
    .mount  = ext4_mount,   // superblock 초기화
    .kill_sb = kill_block_super,
};

// 파일 읽기 경로 (VFS -> ext4)
// vfs_read() -> file->f_op->read() -> ext4_file_read_iter()
//   -> generic_file_read_iter() -> 페이지 캐시 조회
//   -> ext4_readpage() -> submit_bio() -> 디스크

다양한 파일시스템 유형

VFS 덕분에 성격이 완전히 다른 파일시스템들이 동일한 read()/write() 인터페이스로 공존한다.

파일시스템유형핵심 특징
ext4블록 기반, 저널링안정성·범용성, 대부분의 Linux 루트 FS
btrfsCOW B-tree스냅샷, 서브볼륨, 인라인 체크섬, 압축
XFS블록 기반, 저널링대용량 파일·고병렬 I/O에 최적화
tmpfsRAM 기반 가상재부팅 시 소멸, /tmp, /dev/shm, cgroup
procfs가상 (pseudo)커널 내부 상태 노출, /proc
sysfs가상 (pseudo)디바이스·드라이버 계층 구조, /sys
NFS네트워크원격 파일시스템을 로컬처럼 마운트
# 마운트된 모든 파일시스템 목록
findmnt --output TARGET,SOURCE,FSTYPE,OPTIONS

# 파일의 inode 번호 및 메타데이터 확인
ls -i /etc/passwd         # inode 번호 출력
stat /etc/passwd          # 전체 메타데이터 출력

# dcache / inode 캐시 사용 현황
cat /proc/sys/fs/dentry-state
# 1234567 987654 45 0 0 0   (cached, unused, age_limit, ...)

cat /proc/sys/fs/inode-state
# 234567 12345 0 0 0 0 0    (inode 개수, unused, ...)

실전: 파일 삭제의 내부 동작

rm /etc/test.conf 명령 실행 시 발생하는 일:

  1. unlinkat() 시스템 콜 → VFS vfs_unlink().
  2. 부모 디렉토리 inode의 inode_operations->unlink() 호출 (ext4: ext4_unlink()).
  3. 디렉토리 항목(디렉토리 내 dentry 데이터)에서 test.conf 삭제, i_nlink--.
  4. i_nlink == 0 이고 해당 inode를 참조하는 file 구조체도 없으면 → 데이터 블록 해제, inode 번호 반환.
  5. dcache에서 dentry 제거 (또는 negative로 전환).

열린 채로 삭제(delete-while-open): i_nlink가 0이 되어도 file 구조체가 살아있으면 데이터는 유지된다. 마지막 close()가 호출될 때 비로소 해제된다. 이 특성을 활용해 임시 파일을 만들자마자 unlink()하는 패턴이 있다 — 프로세스가 죽어도 파일이 남지 않는다.

References