VFS 계층 (Virtual Filesystem Switch)

Linux 커널 VFS 구조, superblock, dentry, file operations, 경로 탐색 종합 가이드.

관련 표준: POSIX.1-2017 (파일 I/O 시맨틱, 디렉토리, 퍼미션) — VFS는 POSIX 파일 시맨틱의 커널 측 구현입니다. 종합 목록은 참고자료 — 표준 & 규격 섹션을 참고하세요.

VFS 개요

VFS(Virtual Filesystem Switch)는 다양한 파일시스템(ext4, XFS, btrfs, NFS 등)에 대해 통일된 인터페이스를 제공하는 커널 추상화 계층입니다. 사용자 프로그램은 open(), read(), write() 등의 시스템 콜을 통해 어떤 파일시스템이든 동일한 방식으로 접근합니다.

VFS 계층 구조 User Process: open(), read(), write() System Call Interface (sys_open, sys_read, ...) VFS Layer (superblock, inode, dentry, file) ext4 XFS btrfs Block Layer → Storage Devices
VFS: 사용자 공간과 실제 파일시스템 사이의 추상화 계층

VFS 핵심 객체

Superblock

struct super_block은 마운트된 파일시스템을 나타냅니다. 파일시스템 유형, 블록 크기, 마운트 옵션, 루트 dentry 등의 정보를 포함합니다.

struct super_operations {
    struct inode *(*alloc_inode)(struct super_block *sb);
    void (*destroy_inode)(struct inode *);
    void (*dirty_inode)(struct inode *, int flags);
    int (*write_inode)(struct inode *, struct writeback_control *);
    int (*statfs)(struct dentry *, struct kstatfs *);
    int (*sync_fs)(struct super_block *sb, int wait);
    /* ... */
};

Dentry (Directory Entry)

struct dentry는 경로명의 각 구성 요소를 나타냅니다. /home/user/file.txt에서 /, home, user, file.txt 각각이 dentry입니다. dentry 캐시(dcache)는 경로 탐색 속도를 크게 향상시킵니다.

File Object

struct file은 프로세스가 파일을 열 때 생성되는 런타임 객체입니다. 파일 오프셋, 접근 모드, file_operations 등을 포함합니다.

struct file_operations {
    struct module *owner;
    loff_t (*llseek)(struct file *, loff_t, int);
    ssize_t (*read)(struct file *, char __user *, size_t, loff_t *);
    ssize_t (*write)(struct file *, const char __user *, size_t, loff_t *);
    int (*open)(struct inode *, struct file *);
    int (*release)(struct inode *, struct file *);
    int (*mmap)(struct file *, struct vm_area_struct *);
    long (*unlocked_ioctl)(struct file *, unsigned int, unsigned long);
    int (*fsync)(struct file *, loff_t, loff_t, int);
    /* ... */
};

경로 탐색 (Path Lookup)

VFS는 /home/user/file.txt 같은 경로를 순차적으로 탐색합니다. 각 구성 요소마다 dentry 캐시를 검색하고, 없으면 실제 파일시스템의 lookup 연산을 호출합니다.

💡

dentry 캐시는 RCU로 보호되어 경로 탐색 시 잠금 없이 빠르게 수행됩니다 (RCU-walk 모드). RCU-walk가 실패하면 ref-walk 모드로 폴백합니다.

파일시스템 등록

static struct file_system_type myfs_type = {
    .owner    = THIS_MODULE,
    .name     = "myfs",
    .mount    = myfs_mount,
    .kill_sb  = kill_litter_super,
};

static int __init myfs_init(void)
{
    return register_filesystem(&myfs_type);
}

static void __exit myfs_exit(void)
{
    unregister_filesystem(&myfs_type);
}

페이지 캐시 (Page Cache)

페이지 캐시는 Linux 커널에서 디스크 기반 파일 데이터를 메모리에 캐싱하는 핵심 메커니즘입니다. 파일을 읽으면 디스크에서 가져온 데이터가 페이지 캐시에 저장되고, 이후 동일 데이터에 대한 읽기는 디스크 I/O 없이 메모리에서 직접 서비스됩니다. 쓰기 역시 먼저 페이지 캐시에 기록된 후 비동기적으로 디스크에 반영(writeback)됩니다.

페이지 캐시의 역할

User Process read() / write() / mmap() VFS Layer Page Cache (struct address_space) XArray (i_pages) offset → folio 매핑 LRU Lists active / inactive Dirty Tracking writeback flusher cache hit (fast) cache miss / writeback Block I/O Layer → Disk

struct address_space

각 파일(inode)은 하나의 struct address_space를 가지며, 이것이 해당 파일의 페이지 캐시를 관리하는 핵심 구조체입니다. 내부적으로 XArray(i_pages)를 사용하여 파일 오프셋 → folio 매핑을 관리합니다.

/* include/linux/fs.h */
struct address_space {
    struct inode        *host;        /* 소유 inode */
    struct xarray       i_pages;      /* 페이지 캐시 XArray (offset → folio) */
    atomic_t            i_mmap_writable; /* VM_SHARED 쓰기 매핑 수 */
    struct rb_root_cached i_mmap;      /* mmap된 VMA 트리 */
    unsigned long       nrpages;      /* 캐시된 총 페이지 수 */
    pgoff_t             writeback_index; /* writeback 시작 오프셋 */
    const struct address_space_operations *a_ops; /* 페이지 캐시 연산 */
    unsigned long       flags;        /* AS_* 플래그 */
    struct rw_semaphore i_mmap_rwsem; /* i_mmap 보호 */
    errseq_t            wb_err;       /* writeback 에러 추적 */
    gfp_t               gfp_mask;     /* 페이지 할당 시 GFP 플래그 */
    void                *private_data; /* 파일시스템 전용 데이터 */
};

XArray 전환 (v4.20+): 페이지 캐시의 인덱싱은 과거 radix tree(page_tree)에서 XArray(i_pages)로 전환되었습니다. XArray는 RCU 안전한 조회, 더 나은 API, 멀티 오더 엔트리를 지원하여 folio와 자연스럽게 통합됩니다.

address_space_operations

struct address_space_operations는 파일 데이터와 페이지 캐시 간의 I/O를 정의합니다. 파일시스템마다 고유한 구현을 제공합니다:

struct address_space_operations {
    int (*writepage)(struct page *, struct writeback_control *);
    int (*read_folio)(struct file *, struct folio *);
    void (*readahead)(struct readahead_control *);
    int (*write_begin)(struct file *, struct address_space *,
                        loff_t, unsigned, struct folio **,
                        void **);
    int (*write_end)(struct file *, struct address_space *,
                      loff_t, unsigned, unsigned,
                      struct folio *, void *);
    int (*dirty_folio)(struct address_space *, struct folio *);
    int (*direct_IO)(struct kiocb *, struct iov_iter *);
    bool (*release_folio)(struct folio *, gfp_t);
    void (*invalidate_folio)(struct folio *, size_t, size_t);
    bool (*dirty_folio)(struct address_space *, struct folio *);
};
콜백호출 시점역할
read_folio캐시 미스 시디스크에서 folio로 데이터 읽기. 과거 readpage 대체
readahead순차 읽기 감지여러 folio를 배치로 미리 읽기. 과거 readpages 대체
writepage페이지 회수 시dirty 페이지 하나를 디스크에 기록
write_beginwrite() 시작folio 잠금 + 필요 시 부분 읽기 (read-modify-write)
write_endwrite() 완료dirty 마킹 + folio 잠금 해제
dirty_foliofolio 수정 시dirty 비트 설정 + inode를 writeback 큐에 등록
direct_IOO_DIRECT I/O페이지 캐시 우회 직접 DMA 전송
release_foliofolio 해제 전FS 전용 private 데이터 해제 가능 여부 확인
invalidate_foliotruncate/hole-punchfolio의 지정 범위를 무효화

페이지 캐시 읽기 경로

read() 시스템 콜의 주요 경로인 filemap_read()는 다음 순서로 동작합니다:

/* mm/filemap.c: 간략화한 읽기 흐름 */
ssize_t filemap_read(struct kiocb *iocb, struct iov_iter *iter,
                     ssize_t already_read)
{
    struct address_space *mapping = file->f_mapping;
    struct folio_batch fbatch;
    pgoff_t index = iocb->ki_pos >> PAGE_SHIFT;

    for (;;) {
        /* 1단계: XArray에서 folio 조회 (RCU 보호) */
        folio = filemap_get_folio(mapping, index);

        if (IS_ERR(folio)) {
            /* 2단계: 캐시 미스 → readahead 트리거 */
            page_cache_sync_readahead(mapping, ra, file,
                                     index, last_index - index);
            folio = filemap_get_folio(mapping, index);
            if (IS_ERR(folio))
                goto no_cached_folio;
        }

        /* 3단계: folio가 최신(uptodate)인지 확인 */
        if (!folio_test_uptodate(folio)) {
            /* 아직 I/O 완료 안 됨 → 대기 */
            folio_lock(folio);
            if (!folio_test_uptodate(folio))
                goto readpage_error;
        }

        /* 4단계: 비동기 readahead 마크 확인 → 다음 배치 프리페치 */
        if (folio_test_readahead(folio))
            page_cache_async_readahead(mapping, ra, file,
                                      folio, index, last_index - index);

        /* 5단계: 페이지 캐시 → 사용자 버퍼 복사 */
        copied = copy_folio_to_iter(folio, offset, bytes, iter);
    }
}

페이지 캐시 쓰기 경로

Buffered write()는 페이지 캐시에만 기록하고 즉시 반환됩니다. 실제 디스크 기록은 flusher 스레드가 비동기로 수행합니다:

/* 간략화한 buffered write 흐름 */
/*
 * generic_perform_write() 내부:
 *
 * for (각 페이지 오프셋) {
 *     a_ops->write_begin()
 *       → folio 할당 또는 기존 folio 조회
 *       → folio 잠금
 *       → 부분 기록 시 디스크에서 기존 데이터 읽기 (read-modify-write)
 *
 *     copy_from_user()
 *       → 사용자 버퍼 → folio에 데이터 복사
 *
 *     a_ops->write_end()
 *       → folio를 dirty로 마킹 (PG_dirty 플래그)
 *       → inode를 superblock의 dirty 목록에 등록
 *       → folio 잠금 해제
 * }
 */

/* ext4의 write_begin 구현 예시 */
static int ext4_write_begin(struct file *file,
        struct address_space *mapping, loff_t pos,
        unsigned len, struct folio **foliop, void **fsdata)
{
    /* 1. folio 조회 또는 할당 */
    folio = grab_cache_folio_write_begin(mapping, index);

    /* 2. 저널 트랜잭션 시작 (ext4 jbd2) */
    handle = ext4_journal_start(inode, EXT4_HT_WRITE_PAGE, needed);

    /* 3. 블록 매핑 확보 (delayed alloc or 즉시 할당) */
    ext4_map_blocks(handle, inode, &map, ...);

    *foliop = folio;
    return 0;
}

페이지 캐시 조회/삽입 API

/* === 캐시 조회 === */

/* RCU 보호 조회 (가장 빈번) */
struct folio *folio = filemap_get_folio(mapping, index);
/* 성공 시 folio 참조 카운트 증가, 실패 시 ERR_PTR(-ENOENT) */

/* 잠금 획득까지 대기하며 조회 */
folio = filemap_lock_folio(mapping, index);

/* 없으면 할당하여 삽입 (grab = get + lock) */
folio = __filemap_get_folio(mapping, index,
        FGP_LOCK | FGP_CREAT, gfp_mask);

/* === 캐시 삽입 === */

/* folio를 페이지 캐시에 추가 */
int err = filemap_add_folio(mapping, folio, index, gfp);
/* 이미 같은 index에 folio가 있으면 -EEXIST 반환 */

/* === 캐시 제거 === */

/* 특정 folio 무효화 (truncate, hole punch) */
filemap_remove_folio(folio);

/* 파일의 모든 캐시 페이지 무효화 */
truncate_inode_pages(&inode->i_data, 0);

/* 범위 지정 무효화 */
truncate_inode_pages_range(&inode->i_data, lstart, lend);

/* === folio 상태 확인 === */
folio_test_uptodate(folio);   /* 디스크에서 읽기 완료? */
folio_test_dirty(folio);      /* 수정됨 (writeback 필요)? */
folio_test_writeback(folio);  /* writeback I/O 진행 중? */
folio_test_locked(folio);     /* 잠금 상태? */

Folio 기반 페이지 캐시 (5.16+)

Linux 5.16부터 struct foliostruct page를 대체하여 페이지 캐시를 관리합니다. folio는 하나 이상의 연속 페이지를 나타내며, 복합 페이지(compound page)를 자연스럽게 표현하고 타입 안전성을 제공합니다.

/* struct folio: 페이지 캐시의 기본 단위 (include/linux/page-flags.h) */
struct folio {
    union {
        struct {
            unsigned long flags;       /* PG_* 페이지 플래그 */
            struct list_head lru;      /* LRU 리스트 연결 */
            struct address_space *mapping; /* 소속 address_space */
            pgoff_t index;             /* 파일 내 페이지 오프셋 */
            void *private;             /* FS 전용 (buffer_head 등) */
            atomic_t _mapcount;        /* 페이지 테이블 매핑 수 */
            atomic_t _refcount;        /* 참조 카운트 */
        };
        struct page page;           /* 호환성 */
    };
};

/* folio와 page 간 변환 */
struct folio *folio = page_folio(page);     /* page → folio (head page) */
struct page *page = folio_page(folio, n);  /* folio의 n번째 page */
size_t size = folio_size(folio);            /* folio 전체 바이트 크기 */
unsigned order = folio_order(folio);        /* 2^order 페이지 */

/* 대용량 folio: THP와 통합 (6.x+) */
/* 파일시스템이 readahead 시 order > 0 folio를 할당하면
 * 하나의 folio가 여러 페이지를 커버 → 메타데이터 오버헤드 감소
 * XFS, ext4 등이 large folio를 점진적으로 지원 */
API (레거시 page)API (folio)설명
find_get_page()filemap_get_folio()캐시 조회
add_to_page_cache_lru()filemap_add_folio()캐시 삽입
lock_page()folio_lock()folio 잠금
SetPageDirty()folio_mark_dirty()dirty 마킹
PageUptodate()folio_test_uptodate()읽기 완료 확인
wait_on_page_writeback()folio_wait_writeback()writeback 완료 대기
put_page()folio_put()참조 카운트 감소

미리 읽기 (Readahead) 메커니즘

커널은 순차 읽기 패턴을 감지하면 아직 요청하지 않은 데이터를 미리 페이지 캐시에 로드합니다. 이를 통해 디스크 I/O 대기 시간을 응용 프로그램의 데이터 처리 시간과 중첩시킵니다.

/* 파일별 readahead 상태 (struct file_ra_state) */
struct file_ra_state {
    pgoff_t start;       /* readahead 윈도우 시작 */
    unsigned int size;   /* readahead 윈도우 크기 (페이지) */
    unsigned int async_size; /* 비동기 readahead 트리거 지점 */
    unsigned int ra_pages;  /* 최대 readahead 크기 */
    loff_t prev_pos;     /* 이전 읽기 위치 (패턴 감지용) */
};

/*
 * 적응형 readahead 알고리즘 (mm/readahead.c):
 *
 * 1. 초기 읽기: 작은 윈도우 (보통 4~8 페이지)
 * 2. 순차 감지: 연속된 읽기 패턴 확인
 * 3. 윈도우 확장: 순차 읽기 확인 시 윈도우를 2배씩 확장
 * 4. 최대 크기: ra_pages (기본 128KB = 32 페이지, 디바이스별 조정)
 * 5. 비동기 트리거: 윈도우의 async_size 지점을 읽으면
 *    다음 배치를 백그라운드로 프리페치
 *
 * [===== 현재 윈도우 =====|== async ==]
 *                          ↑ 여기 도달 시 다음 배치 시작
 */

/* readahead 콜백 구현 (파일시스템) */
static void ext4_readahead(struct readahead_control *rac)
{
    /* readahead_control은 mapping, 시작 index, 페이지 수를 포함 */
    mpage_readahead(rac, ext4_get_block);
    /* 여러 페이지를 연속 bio로 합쳐 디스크에 배치 I/O */
}
# readahead 크기 확인 및 조정
cat /sys/block/sda/queue/read_ahead_kb
# 128 (기본값, KB 단위)

# NVMe SSD는 더 큰 readahead가 유리할 수 있음
echo 256 > /sys/block/nvme0n1/queue/read_ahead_kb

# RAID/대규모 순차 워크로드
echo 4096 > /sys/block/md0/queue/read_ahead_kb

# 랜덤 I/O 워크로드 (DB 등): readahead 비활성화
echo 0 > /sys/block/sda/queue/read_ahead_kb

# 프로그래밍 방식으로 파일별 readahead 제어
# posix_fadvise(fd, offset, len, POSIX_FADV_SEQUENTIAL); → 공격적 readahead
# posix_fadvise(fd, offset, len, POSIX_FADV_RANDOM);     → readahead 비활성
# posix_fadvise(fd, offset, len, POSIX_FADV_WILLNEED);   → 즉시 prefetch
# posix_fadvise(fd, offset, len, POSIX_FADV_DONTNEED);   → 캐시에서 제거

Dirty 페이지와 Writeback

페이지 캐시에 기록된(dirty) 데이터는 flusher 스레드(kworker/u*:* flush-*)가 비동기적으로 디스크에 기록합니다. 커널은 dirty 페이지 비율에 따라 writeback 속도를 조절하며, 임계치를 초과하면 write() 호출을 throttle합니다.

# dirty 페이지 관련 커널 파라미터
sysctl vm.dirty_ratio
# 20 (전체 메모리 대비 dirty 비율 %, 초과 시 write()가 동기적 writeback)

sysctl vm.dirty_background_ratio
# 10 (이 비율 초과 시 flusher가 백그라운드 writeback 시작)

sysctl vm.dirty_expire_centisecs
# 3000 (30초 이상 dirty 상태인 페이지를 writeback 대상으로 선정)

sysctl vm.dirty_writeback_centisecs
# 500 (flusher 스레드 wakeup 주기: 5초)

# 절대값으로 설정 (대용량 메모리 시스템에 유용)
sysctl vm.dirty_bytes=268435456         # 256MB
sysctl vm.dirty_background_bytes=134217728  # 128MB

# 현재 dirty 페이지 상태
cat /proc/meminfo | grep -i dirty
# Dirty:              12340 kB   ← writeback 대기 중
# Writeback:            256 kB   ← writeback I/O 진행 중

grep -E "nr_dirty|nr_writeback" /proc/vmstat

Writeback 지연 함정: dirty_ratio가 높으면 write()가 빠르게 반환되지만, 갑작스런 fsync()나 시스템 메모리 부족 시 대량의 dirty 페이지를 한꺼번에 기록해야 하므로 긴 지연이 발생합니다. DB 워크로드에서는 dirty_ratio를 낮추거나 dirty_bytes로 절대값을 지정하여 writeback을 분산시키십시오.

LRU 리스트와 페이지 회수

페이지 캐시의 folio는 LRU (Least Recently Used) 리스트로 관리됩니다. 메모리가 부족하면 kswapd 또는 direct reclaim이 LRU의 끝에서 folio를 회수합니다.

LRU 리스트대상설명
LRU_INACTIVE_FILE파일 캐시 (비활성)최근 접근이 없는 파일 페이지. 회수 1순위
LRU_ACTIVE_FILE파일 캐시 (활성)최근 접근된 파일 페이지. 보호됨
LRU_INACTIVE_ANON익명 메모리 (비활성)최근 접근 없는 heap/stack 페이지
LRU_ACTIVE_ANON익명 메모리 (활성)최근 접근된 heap/stack 페이지
LRU_UNEVICTABLE회수 불가mlock()된 페이지, ramdisk 등
/*
 * 페이지 캐시 회수의 2차 기회(Second Chance) 알고리즘:
 *
 * 1. 새로 캐시된 folio → inactive 리스트 끝에 삽입
 * 2. 접근 시 PG_referenced 플래그 설정
 * 3. 회수 스캔 시:
 *    - PG_referenced 설정됨 → active 리스트로 승격 (2차 기회)
 *    - PG_referenced 미설정 → 회수 대상
 * 4. active 리스트의 folio가 오래 접근 안 되면 → inactive로 강등
 *
 * 파일 캐시는 익명 메모리보다 회수 우선순위가 높음 (재생성 가능하므로)
 */

/* 회수 우선순위 조정 */
/* vm.swappiness (기본 60): 0에 가까울수록 파일 캐시 회수 우선
 * swappiness = 0   → 익명 메모리 스왑 최소화, 파일 캐시 적극 회수
 * swappiness = 100 → 익명 메모리와 파일 캐시를 동등하게 회수
 * swappiness = 200 → zswap/zram 환경에서 익명 메모리 스왑 적극적 (v5.8+)
 */
# LRU 리스트 상태 확인
grep -E "^(nr_inactive_file|nr_active_file|nr_inactive_anon|nr_active_anon)" /proc/vmstat
# nr_inactive_file  524288
# nr_active_file    1048576
# nr_inactive_anon  262144
# nr_active_anon    786432

# 수동으로 페이지 캐시 드롭 (테스트/벤치마크용)
sync                                      # dirty 페이지를 먼저 디스크에 기록
echo 1 > /proc/sys/vm/drop_caches         # 페이지 캐시만 드롭
echo 2 > /proc/sys/vm/drop_caches         # dentries + inodes 캐시 드롭
echo 3 > /proc/sys/vm/drop_caches         # 페이지 캐시 + dentries + inodes

# MGLRU (Multi-Gen LRU, v6.1+) 확인
cat /sys/kernel/mm/lru_gen/enabled
# 0x0007 (각 비트: lru_gen core, mm_walk, nonresident)
💡

MGLRU (Multi-Gen LRU): Linux 6.1에서 도입된 새로운 페이지 회수 알고리즘입니다. 기존 2단계(active/inactive) 대신 여러 세대(generation)를 사용하여 접근 빈도를 더 정밀하게 추적합니다. 특히 메모리 부족 상황에서 기존 LRU 대비 thrashing이 크게 감소하며, ChromeOS, Android에서 먼저 적용되었습니다.

페이지 캐시 모니터링

# === /proc/meminfo에서 페이지 캐시 통계 ===
cat /proc/meminfo | grep -E "Cached|Buffers|Active\(file\)|Inactive\(file\)"
# Buffers:            512000 kB   ← 블록 디바이스 메타데이터 캐시
# Cached:            8192000 kB   ← 파일 데이터 페이지 캐시
# Active(file):      4096000 kB   ← 활성 파일 캐시
# Inactive(file):    4096000 kB   ← 비활성 파일 캐시
#
# MemAvailable은 Cached + reclaimable slab을 포함하므로
# "Cached가 많다" ≠ "메모리가 부족하다"

# === 프로세스별 페이지 캐시 사용량 ===
# /proc/PID/smaps에서 파일 매핑된 RSS 확인
grep -E "^(Rss|Referenced|Shared_Clean|Shared_Dirty)" /proc/<pid>/smaps_rollup

# === vmstat으로 캐시 히트율 추정 ===
vmstat 1
#  bi  bo   ← bi(blocks in)가 낮으면 캐시 히트율 높음

# === cachestat 시스템 콜 (v6.5+) ===
# 파일별 캐시 통계를 정확하게 조회하는 새로운 API
# struct cachestat {
#     __u64 nr_cache;      /* 캐시된 페이지 수 */
#     __u64 nr_dirty;      /* dirty 페이지 수 */
#     __u64 nr_writeback;  /* writeback 중 페이지 수 */
#     __u64 nr_evicted;    /* 회수된 페이지 수 */
#     __u64 nr_recently_evicted; /* 최근 회수된 페이지 수 */
# };

# === perf로 캐시 히트/미스 추적 ===
perf stat -e filemap:mm_filemap_add_to_page_cache \
          -e filemap:mm_filemap_delete_from_page_cache \
          -p <pid> -- sleep 10

# === bpftrace로 실시간 캐시 이벤트 추적 ===
bpftrace -e 'kprobe:filemap_get_folio { @hits = count(); }
             kretprobe:filemap_get_folio /retval < 0/ { @misses = count(); }'

# === fincore로 파일별 캐시 적재 상태 확인 ===
fincore /var/log/syslog
# RES  PAGES  SIZE FILE
# 128K    32  450K /var/log/syslog

페이지 캐시 튜닝 가이드

워크로드핵심 파라미터권장값이유
파일 서버 read_ahead_kb 256~1024 대용량 순차 읽기 최적화
DB (PostgreSQL 등) dirty_bytes
dirty_background_bytes
256MB
64MB
writeback 분산, checkpoint 시 I/O 스파이크 방지
DB (자체 캐시: MySQL InnoDB) O_DIRECT - 이중 캐싱 방지, 메모리를 DB 버퍼 풀에 집중
스트리밍 / 대용량 순차 쓰기 dirty_background_ratio
dirty_ratio
5
10
빈번한 소량 writeback으로 I/O 평탄화
랜덤 I/O (OLTP) read_ahead_kb
swappiness
0~16
10
불필요한 readahead 방지, 캐시 유지 우선
컨테이너 (cgroup) memory.high 워크로드별 cgroup 단위 캐시 회수 조절 (v2)
💡

fadvise와 madvise 활용: 응용 프로그램 수준에서 posix_fadvise()madvise()를 사용하면 커널 전체 파라미터를 변경하지 않고도 파일별/영역별로 캐싱 전략을 세밀하게 제어할 수 있습니다. FADV_DONTNEED로 일회성 데이터의 캐시 오염을 방지하고, FADV_WILLNEED로 곧 필요한 데이터를 미리 로드하십시오.

마운트 서브시스템

Linux의 마운트는 struct mountstruct vfsmount로 관리됩니다. 마운트 네임스페이스별로 독립된 마운트 트리를 가집니다.

/* 파일시스템 마운트 구현 (new mount API, 5.2+) */
static int myfs_get_tree(struct fs_context *fc)
{
    return get_tree_nodev(fc, myfs_fill_super);
}

static const struct fs_context_operations myfs_context_ops = {
    .get_tree = myfs_get_tree,
};

static int myfs_init_fs_context(struct fs_context *fc)
{
    fc->ops = &myfs_context_ops;
    return 0;
}

static struct file_system_type myfs_type = {
    .name              = "myfs",
    .init_fs_context   = myfs_init_fs_context,
    .kill_sb           = kill_litter_super,
};

Direct I/O

Direct I/O는 페이지 캐시를 우회하여 유저 버퍼와 디스크 간에 직접 데이터를 전송합니다. 데이터베이스 등 자체 캐싱을 하는 어플리케이션에 유용합니다.

/* O_DIRECT로 열면 direct_IO가 호출됨 */
int fd = open("/data/file", O_RDWR | O_DIRECT);

/* 커널 내부: generic_file_read_iter()에서 분기 */
if (iocb->ki_flags & IOCB_DIRECT) {
    /* a_ops->direct_IO() 호출 */
    retval = mapping->a_ops->direct_IO(iocb, iter);
} else {
    /* 페이지 캐시 경유 읽기 */
    retval = filemap_read(iocb, iter, retval);
}

파일 잠금 (File Locking)

리눅스 커널은 여러 프로세스가 동일 파일에 동시 접근할 때 데이터 일관성을 보장하기 위해 VFS 계층에서 다양한 파일 잠금 메커니즘을 제공합니다. 잠금은 크게 POSIX 잠금(fcntl), BSD 잠금(flock), OFD 잠금(Open File Description) 세 가지로 구분됩니다.

Linux File Locking 계층 구조 flock(fd, op) BSD 잠금 (전체 파일) fcntl(fd, F_SETLK) POSIX 잠금 (바이트 범위) fcntl(fd, F_OFD_SETLK) OFD 잠금 (바이트 범위) VFS Lock Layer (fs/locks.c) struct file_lock → inode->i_flctx (file_lock_context) 로컬 FS (ext4, XFS, Btrfs) 네트워크 FS (NFS, CIFS) F_SETLEASE / F_NOTIFY (inode 수준 알림)

struct file_lock 커널 구조

모든 파일 잠금은 커널 내부에서 struct file_lock으로 표현되며, inode의 file_lock_context에 리스트로 관리됩니다.

/* include/linux/filelock.h — 파일 잠금 핵심 구조체 */
struct file_lock {
    struct file_lock  *fl_blocker;    /* 이 잠금을 차단 중인 잠금 */
    struct list_head  fl_list;        /* file_lock_context 리스트 연결 */
    struct list_head  fl_blocked_requests; /* 차단된 대기 요청 */
    struct list_head  fl_blocked_member;   /* blocker의 blocked_requests 멤버 */

    fl_owner_t        fl_owner;       /* POSIX: files_struct, OFD: struct file */
    unsigned int      fl_flags;       /* FL_POSIX, FL_FLOCK, FL_OFDLCK 등 */
    unsigned char     fl_type;        /* F_RDLCK, F_WRLCK, F_UNLCK */
    pid_t             fl_pid;         /* 잠금 소유 프로세스 PID */

    /* 바이트 범위 */
    loff_t            fl_start;       /* 잠금 시작 오프셋 */
    loff_t            fl_end;         /* 잠금 끝 오프셋 (OFFSET_MAX = EOF) */

    struct file       *fl_file;       /* 잠금이 설정된 struct file */
    unsigned int      fl_wait_time;   /* 대기 시간 통계 */

    const struct file_lock_operations  *fl_ops;  /* FS별 콜백 */
    const struct lock_manager_operations *fl_lmops; /* 잠금 관리자 (NFS 등) */

    union {
        struct nfs_lock_info   nfs_fl;   /* NFS 전용 */
        struct nfs4_lock_info  nfs4_fl;  /* NFSv4 전용 */
    } fl_u;
};

/* inode별 잠금 컨텍스트 — inode->i_flctx */
struct file_lock_context {
    struct list_head  flc_flock;   /* flock() 잠금 리스트 */
    struct list_head  flc_posix;   /* POSIX/OFD 잠금 리스트 */
    struct list_head  flc_lease;   /* lease 잠금 리스트 */
    spinlock_t        flc_lock;    /* 리스트 보호 스핀락 */
};
fl_flags 구분: 커널은 fl_flags 필드로 잠금 유형을 구분합니다. FL_POSIX(프로세스 기반 POSIX 잠금), FL_FLOCK(open file description 기반 BSD 잠금), FL_OFDLCK(OFD 잠금)가 있으며, 각각 충돌 검사 및 해제 규칙이 다릅니다. FL_LEASE는 lease 잠금을 나타냅니다.

POSIX 잠금 (fcntl — F_SETLK / F_SETLKW / F_GETLK)

POSIX 잠금은 fcntl() 시스템 콜로 바이트 범위 단위의 잠금을 설정합니다. 잠금의 소유 단위는 프로세스(PID)이며, 같은 프로세스 내에서는 여러 fd에서 설정한 잠금이 하나로 병합됩니다.

#include <fcntl.h>

struct flock fl = {
    .l_type   = F_WRLCK,     /* F_RDLCK(공유), F_WRLCK(배타), F_UNLCK(해제) */
    .l_whence = SEEK_SET,    /* 오프셋 기준: SEEK_SET, SEEK_CUR, SEEK_END */
    .l_start  = 0,           /* 잠금 시작 오프셋 */
    .l_len    = 0,           /* 0 = 파일 끝까지 (현재+미래 영역 포함) */
    .l_pid    = 0,           /* F_GETLK 시 커널이 채움 */
};

/* F_SETLK: 비차단 — 충돌 시 즉시 EAGAIN 반환 */
int ret = fcntl(fd, F_SETLK, &fl);

/* F_SETLKW: 차단 — 잠금 획득까지 대기 (인터럽트 가능, EINTR) */
int ret = fcntl(fd, F_SETLKW, &fl);

/* F_GETLK: 충돌하는 잠금 조회 — 없으면 fl.l_type = F_UNLCK */
fcntl(fd, F_GETLK, &fl);
if (fl.l_type != F_UNLCK)
    printf("PID %d가 잠금 보유\n", fl.l_pid);
POSIX 잠금의 치명적 함정: POSIX 잠금은 프로세스 기반이므로, 같은 파일을 여러 fd로 열어도 잠금이 공유됩니다. 어떤 fd라도 close()하면 해당 프로세스의 모든 POSIX 잠금이 해제됩니다. 이는 라이브러리가 내부적으로 같은 파일을 열고 닫으면 애플리케이션의 잠금이 예기치 않게 해제되는 심각한 문제를 초래합니다.
/* POSIX 잠금의 위험한 동작 — close()에 의한 잠금 해제 */
int fd1 = open("data.db", O_RDWR);
int fd2 = open("data.db", O_RDWR);

/* fd1에 배타 잠금 설정 */
struct flock fl = { .l_type = F_WRLCK, .l_whence = SEEK_SET };
fcntl(fd1, F_SETLK, &fl);   /* 잠금 획득 성공 */

/* fd2를 닫으면... fd1의 잠금도 해제됨! */
close(fd2);  /* ⚠ fd1의 POSIX 잠금이 함께 해제됨 */

/* 커널 내부 (fs/locks.c):
 * locks_remove_posix() → 같은 fl_owner(files_struct)의
 * 모든 POSIX 잠금을 순회하며 제거 */
/* fs/locks.c — POSIX 잠금 설정 핵심 경로 */
int posix_lock_file(struct file *filp,
                    struct file_lock *request,
                    struct file_lock *conflock)
{
    struct file_lock_context *ctx;
    struct file_lock *fl;

    ctx = locks_get_lock_context(file_inode(filp), request->fl_type);

    spin_lock(&ctx->flc_lock);
    /* flc_posix 리스트에서 같은 owner의 겹치는 잠금 검색 */
    list_for_each_entry(fl, &ctx->flc_posix, fl_list) {
        if (!posix_same_owner(fl, request))
            continue;
        /* 겹치는 범위: 병합, 분할, 또는 교체 */
        if (locks_overlap(fl, request)) {
            /* 기존 잠금과 새 잠금의 범위를 조정
             * - 완전 포함: 기존 잠금 교체
             * - 부분 겹침: 분할하여 최대 2개 잠금 생성
             * - 인접: 동일 타입이면 병합 */
        }
    }
    /* 다른 owner와 충돌 검사 */
    list_for_each_entry(fl, &ctx->flc_posix, fl_list) {
        if (posix_same_owner(fl, request))
            continue;
        if (locks_conflict(fl, request)) {
            /* 충돌: F_SETLK → -EAGAIN, F_SETLKW → 대기 */
        }
    }
    spin_unlock(&ctx->flc_lock);
}

BSD 잠금 (flock)

flock()파일 전체에 대한 잠금을 설정합니다. POSIX 잠금과 달리 소유 단위가 open file description(fd가 아닌 커널의 struct file)이므로, dup()/fork()로 복제된 fd는 같은 잠금을 공유하지만, 같은 파일을 별도로 open()하면 독립적인 잠금이 됩니다.

#include <sys/file.h>

int fd = open("data.db", O_RDWR);

/* 배타 잠금 (비차단) */
if (flock(fd, LOCK_EX | LOCK_NB) == -1) {
    if (errno == EWOULDBLOCK)
        fprintf(stderr, "다른 프로세스가 잠금 보유 중\n");
}

/* 공유 잠금 (차단) */
flock(fd, LOCK_SH);   /* 배타 잠금 해제까지 대기 */

/* 잠금 해제 */
flock(fd, LOCK_UN);

/* 또는 fd를 close()하면 자동 해제 */
close(fd);
/* flock()과 dup()/fork()의 잠금 공유 */
int fd1 = open("data.db", O_RDWR);
flock(fd1, LOCK_EX);

int fd2 = dup(fd1);
/* fd2는 fd1과 같은 struct file → 같은 잠금 공유
 * fd2를 close()해도 fd1이 열려 있으면 잠금 유지 */
close(fd2);  /* 잠금 여전히 유지됨 (struct file 참조 카운트 > 0) */

int fd3 = open("data.db", O_RDWR);
/* fd3는 새로운 struct file → 독립적인 잠금
 * fd3로 LOCK_EX 시도하면 fd1의 잠금과 충돌 */
flock(fd3, LOCK_EX | LOCK_NB);  /* EWOULDBLOCK */
/* fs/locks.c — flock 커널 구현 */
SYSCALL_DEFINE2(flock, unsigned int, fd, unsigned int, cmd)
{
    struct file_lock fl = {
        .fl_file  = f.file,
        .fl_flags = FL_FLOCK,
        .fl_type  = (cmd & LOCK_SH) ? F_RDLCK : F_WRLCK,
        .fl_end   = OFFSET_MAX,  /* 항상 전체 파일 */
    };
    /* fl_owner는 struct file 포인터
     * → dup()/fork()로 공유된 fd는 같은 owner */
    return locks_lock_file_wait(f.file, &fl);
}
실용적 선택: 단순히 파일 전체를 잠그는 용도(PID 파일, 설정 파일 직렬화 등)에는 flock()이 POSIX 잠금보다 안전합니다. close()에 의한 예기치 않은 잠금 해제 문제가 없고, API가 단순합니다. 단, NFS에서는 flock()이 로컬에서만 동작하거나 POSIX 잠금으로 에뮬레이션될 수 있으므로 주의가 필요합니다.

OFD 잠금 (Open File Description Locks, Linux 3.15+)

OFD 잠금은 POSIX 잠금의 설계 결함을 해결하기 위해 Linux 3.15(2014)에 도입되었습니다. 바이트 범위 잠금을 지원하면서도 소유 단위가 프로세스가 아닌 open file description(struct file)입니다. 멀티스레드 프로그램에서 가장 권장되는 잠금 방식입니다.

#include <fcntl.h>

struct flock fl = {
    .l_type   = F_WRLCK,
    .l_whence = SEEK_SET,
    .l_start  = 0,
    .l_len    = 4096,    /* 처음 4KB 영역만 잠금 */
    .l_pid    = 0,       /* OFD 잠금에서는 반드시 0으로 설정 */
};

/* F_OFD_SETLK: 비차단 OFD 잠금 */
int ret = fcntl(fd, F_OFD_SETLK, &fl);

/* F_OFD_SETLKW: 차단 OFD 잠금 */
int ret = fcntl(fd, F_OFD_SETLKW, &fl);

/* F_OFD_GETLK: 충돌 잠금 조회 */
fcntl(fd, F_OFD_GETLK, &fl);
/* OFD 잠금의 핵심 장점: close() 안전성 */
int fd1 = open("data.db", O_RDWR);
int fd2 = open("data.db", O_RDWR);

/* fd1에 OFD 잠금 설정 */
struct flock fl = { .l_type = F_WRLCK, .l_whence = SEEK_SET, .l_pid = 0 };
fcntl(fd1, F_OFD_SETLK, &fl);

/* fd2를 닫아도 fd1의 잠금은 유지됨! (POSIX 잠금과 다름) */
close(fd2);  /* ✓ fd1의 OFD 잠금 영향 없음 */

/* 멀티스레드에서도 안전 — 각 스레드가 독립 fd로 독립 잠금 가능 */
/* Thread A: fd_a = open(...); fcntl(fd_a, F_OFD_SETLK, ...);
 * Thread B: fd_b = open(...); fcntl(fd_b, F_OFD_SETLK, ...);
 * → 서로 다른 struct file → 독립적 잠금으로 충돌 검사 수행 */
/* fs/locks.c — OFD 잠금 커널 처리 */
static int do_lock_file_wait(struct file *filp,
                             unsigned int cmd,
                             struct file_lock *fl)
{
    /* OFD 잠금: fl_owner = struct file 포인터 */
    if (fl->fl_flags & FL_OFDLCK) {
        fl->fl_owner = filp;  /* ← POSIX는 current->files */
        fl->fl_pid = 0;       /* PID 무의미 */
    }
    /* 잠금 충돌 검사는 POSIX와 동일한 posix_lock_file() 사용
     * 단, owner 비교가 struct file 기반으로 변경됨 */
    return posix_lock_file(filp, fl, NULL);
}

잠금 유형 비교

속성 POSIX (fcntl) BSD (flock) OFD (Linux 3.15+)
바이트 범위 지원 (l_start, l_len) 전체 파일만 지원 (l_start, l_len)
소유 단위 프로세스 (PID) open file description open file description
close() 동작 같은 inode의 아무 fd close 시 모든 잠금 해제 해당 struct file의 마지막 fd close 시 해제 해당 struct file의 마지막 fd close 시 해제
fork() 상속 상속되지 않음 (자식은 별도 프로세스) fd 복제로 잠금 공유 fd 복제로 잠금 공유
dup() 동작 같은 프로세스이므로 잠금 공유 같은 struct file이므로 잠금 공유 같은 struct file이므로 잠금 공유
멀티스레드 안전 위험 (프로세스 전체 공유) 주의 필요 안전 (fd별 독립 잠금)
NFS 지원 완전 지원 (NLM/NFSv4) 제한적 (로컬 또는 에뮬레이션) 완전 지원
데드락 감지 지원 (EDEADLK) 미지원 미지원
커널 플래그 FL_POSIX FL_FLOCK FL_OFDLCK

Lease 잠금 (F_SETLEASE)

Lease 잠금은 다른 프로세스가 파일을 열거나 truncate하려 할 때 잠금 보유자에게 시그널(기본 SIGIO)로 알림을 보내는 메커니즘입니다. 삼바(Samba) 서버의 oplock(Opportunistic Lock) 구현에 핵심적으로 사용되며, 로컬 파일의 변경 감시에도 활용됩니다.

#include <fcntl.h>
#include <signal.h>

static void lease_break_handler(int sig, siginfo_t *info, void *ctx)
{
    int fd = info->si_fd;     /* lease가 깨진 fd */
    /* 정리 작업 수행 후 lease 해제 */
    fcntl(fd, F_SETLEASE, F_UNLCK);
}

int main(void)
{
    struct sigaction sa = {
        .sa_sigaction = lease_break_handler,
        .sa_flags = SA_SIGINFO,
    };
    sigaction(SIGIO, &sa, NULL);

    int fd = open("data.db", O_RDWR);

    /* 시그널을 받을 프로세스 설정 */
    fcntl(fd, F_SETOWN, getpid());
    fcntl(fd, F_SETSIG, SIGIO);

    /* 쓰기 lease 설정 — 다른 프로세스가 open() 시 알림 */
    if (fcntl(fd, F_SETLEASE, F_WRLCK) == -1)
        perror("lease 설정 실패");

    /* 읽기 lease: 다른 프로세스의 쓰기용 open() 시 알림
     * 쓰기 lease: 다른 프로세스의 모든 open() 시 알림 */
}
/* fs/locks.c — lease break 커널 경로 */
int break_lease(struct inode *inode, unsigned int mode)
{
    struct file_lock_context *ctx = inode->i_flctx;
    struct file_lock *fl;
    int error = 0;

    percpu_down_read(&file_rwsem);
    spin_lock(&ctx->flc_lock);
    list_for_each_entry(fl, &ctx->flc_lease, fl_list) {
        /* 충돌하는 lease가 있으면 break 시그널 전송 */
        if (locks_conflict(fl, &breaker)) {
            /* lease 보유자에게 SIGIO 전송 */
            fl->fl_lmops->lm_break(fl);
            /* /proc/sys/fs/lease-break-time 초 동안 대기
             * (기본 45초) — 응답 없으면 lease 강제 해제 */
        }
    }
    spin_unlock(&ctx->flc_lock);
    percpu_up_read(&file_rwsem);
    return error;
}
lease-break-time: /proc/sys/fs/lease-break-time (기본 45초)은 lease 보유자가 lease를 해제하거나 다운그레이드할 때까지 대기하는 최대 시간입니다. 이 시간 내에 응답하지 않으면 커널이 lease를 강제 해제합니다. Samba 서버에서는 이 값을 클라이언트 응답 시간에 맞게 조정합니다.

Advisory vs Mandatory 잠금

리눅스의 파일 잠금은 기본적으로 advisory(권고)입니다. 잠금을 확인하지 않는 프로세스는 잠금된 파일에 자유롭게 읽기/쓰기할 수 있습니다. Mandatory(강제) 잠금은 커널이 모든 read()/write()에서 잠금을 강제하는 방식이지만, 리눅스에서는 Linux 5.15부터 완전히 제거되었습니다.

# Mandatory 잠금 (Linux 5.14 이하에서만 동작, 이후 제거됨)
# 1. 파일시스템을 -o mand 옵션으로 마운트
mount -o mand /dev/sda1 /mnt/data

# 2. 파일에 set-group-ID + group-execute 제거
chmod g+s,g-x /mnt/data/protected_file
# → 커널이 read()/write() 시 잠금을 강제 확인
Mandatory 잠금 제거 (Linux 5.15): Mandatory 잠금은 커널 5.15에서 완전히 제거되었습니다 (commit f7e33bdb). 제거 이유:
  • 정확한 구현이 불가능 — read()/write()를 잠금 검사와 원자적으로 수행할 수 없어 경쟁 조건 존재
  • mmap()을 통한 우회가 가능 — mandatory 잠금이 설정된 파일도 mmap()으로 매핑하면 잠금 없이 접근 가능
  • 심각한 성능 저하 — 모든 I/O 경로에 잠금 검사 오버헤드 추가
  • DoS 공격 벡터 — 악의적인 프로세스가 잠금을 영구히 보유하여 다른 프로세스 차단 가능
새로운 코드에서는 advisory 잠금만 사용하십시오. 강제 잠금이 필요한 경우 애플리케이션 수준에서 구현하거나 F_SETLEASE를 활용하십시오.

데드락 감지

POSIX 잠금은 커널 수준의 데드락 감지를 지원합니다. F_SETLKW로 대기 시, 커널은 잠금 대기 그래프를 순회하여 순환 의존성을 감지하면 EDEADLK을 반환합니다.

/* fs/locks.c — POSIX 잠금 데드락 감지 */
static int posix_locks_deadlock(struct file_lock *caller_fl,
                                struct file_lock *block_fl)
{
    struct file_lock *fl;
    int count = 0;

    /* 잠금 대기 체인을 따라 순환 탐색
     * caller → block_fl의 owner → 그 owner가 대기 중인 잠금 → ...
     * 최종적으로 caller에 도달하면 데드락 */
next_task:
    if (posix_same_owner(fl, caller_fl))
        return 1;  /* 데드락 감지! → EDEADLK */
    /* 과도한 순회 방지: 최대 깊이 제한 */
    if (count++ > 10)
        return 1;  /* 보수적으로 데드락 판정 */
    goto next_task;
}
flock/OFD 데드락: flock()과 OFD 잠금은 커널 수준 데드락 감지를 지원하지 않습니다. 두 프로세스가 서로의 flock을 기다리면 영구히 교착 상태에 빠집니다. 애플리케이션에서 LOCK_NB/F_OFD_SETLK(비차단)와 타임아웃 로직을 조합하여 데드락을 방지해야 합니다.

/proc/locks — 잠금 모니터링

/proc/locks는 시스템의 모든 파일 잠금 상태를 실시간으로 보여줍니다.

# /proc/locks 출력 형식
cat /proc/locks
# 순번: 타입  방식     범위  PID  major:minor:inode  시작     끝
# 1: POSIX  ADVISORY  WRITE 1234 08:01:131073       0        EOF
# 2: FLOCK  ADVISORY  WRITE 5678 08:01:131074       0        EOF
# 3: OFDLCK ADVISORY  READ  9012 08:01:131075       0        4095
# 4: LEASE  ADVISORY  WRITE 3456 08:01:131076       0        EOF
# 5: POSIX  ADVISORY  READ  7890 08:01:131073       4096     8191
# 6: POSIX  ADVISORY  WRITE 1111 00:33:27 -> 1234   0        EOF
#    ↑ -> PID는 이 잠금이 PID 1234의 잠금에 의해 차단 중임을 의미
# 실용적인 잠금 디버깅 명령어

# 특정 파일의 잠금 상태 확인 (inode 번호로 필터)
stat -c '%i' /path/to/file  # inode 번호 확인
grep '08:01:131073' /proc/locks

# lslocks — 사람이 읽기 쉬운 형태로 잠금 표시 (util-linux)
lslocks
# COMMAND  PID   TYPE  SIZE MODE  M START   END PATH
# mysqld   1234  POSIX  16K WRITE 0 0       EOF /var/lib/mysql/ib_logfile0
# smbd     5678  FLOCK   4K WRITE 0 0       EOF /var/run/samba/locking.tdb

# 특정 프로세스의 잠금만 확인
lslocks -p 1234

# 잠금 차단 관계 추적 — /proc/locks의 "->" 마커 확인
grep '\->' /proc/locks
# blocked_lock -> blocking_pid 형태로 대기 중인 잠금 표시
잠금 문제 진단 흐름: 프로세스가 멈춘 것처럼 보일 때 — (1) lslocks로 잠금 상태 확인 → (2) /proc/locks에서 -> 마커로 차단 관계 파악 → (3) /proc/<pid>/stack으로 커널 콜스택 확인 (locks_lock_file_wait이 보이면 잠금 대기 중) → (4) 차단하는 프로세스의 /proc/<pid>/fd로 어떤 fd가 잠금을 보유하는지 확인.

파일 잠금 주요 함정과 모범 사례

함정 설명 해결책
POSIX close() 해제 같은 파일의 아무 fd를 close()하면 프로세스의 모든 POSIX 잠금이 해제됨 OFD 잠금 또는 flock() 사용
fork() 후 POSIX 잠금 자식 프로세스는 부모의 POSIX 잠금을 상속하지 않으나, flock/OFD는 fd 공유로 잠금이 공유됨 fork() 후 잠금 재설정 또는 CLOEXEC 사용
NFS flock() NFS에서 flock()은 서버에 전달되지 않거나 POSIX 잠금으로 에뮬레이션됨 NFS에서는 fcntl() POSIX/OFD 잠금 사용
잠금 없는 I/O Advisory 잠금은 잠금을 확인하지 않는 프로세스에 무력 모든 참여 프로세스가 동일한 잠금 프로토콜 사용 필수
잠금 범위 확장 l_len=0은 현재 EOF를 넘어 미래에 추가될 데이터까지 잠금 의도치 않은 범위 잠금 주의, 필요한 범위만 지정
POSIX vs flock 독립 POSIX 잠금과 flock()은 서로 충돌을 감지하지 않음 프로젝트 내에서 하나의 잠금 방식만 일관되게 사용
/* 모범 사례: OFD 잠금 기반 안전한 파일 갱신 패턴 */
int safe_update_file(const char *path,
                     const void *data, size_t len)
{
    int fd = open(path, O_RDWR);
    if (fd < 0) return -1;

    /* OFD 배타 잠금 (전체 파일) */
    struct flock fl = {
        .l_type = F_WRLCK, .l_whence = SEEK_SET, .l_pid = 0,
    };
    if (fcntl(fd, F_OFD_SETLKW, &fl) == -1)
        goto err;

    /* 데이터 쓰기 */
    if (write(fd, data, len) != (ssize_t)len)
        goto err;

    /* 디스크에 동기화 */
    if (fsync(fd) == -1)
        goto err;

    /* close()가 OFD 잠금을 자동 해제 */
    close(fd);
    return 0;

err:
    close(fd);
    return -1;
}

dentry 캐시 (dcache) 상세

dcache는 경로명 → inode 매핑을 캐싱하여 디스크 접근을 최소화합니다. 해시 테이블로 구현되며, dentry 상태에 따라 관리됩니다:

상태설명i_count
Used유효한 inode에 연결, 참조 중> 0
Unused유효하지만 참조 없음 (LRU)= 0
Negative존재하지 않는 경로 (negative lookup 캐싱)N/A

Negative dentry 캐싱은 존재하지 않는 파일에 대한 반복적인 stat() 호출의 비용을 줄입니다. /proc/sys/fs/dentry-state로 dcache 통계를 확인할 수 있습니다.

RCU-walk 경로 탐색

Linux 2.6.38에서 도입된 RCU-walk는 경로 탐색 시 dentry의 참조 카운트를 증가시키지 않고 RCU로 보호합니다. 잠금 경쟁이 없어 멀티코어 확장성이 뛰어납니다.

/* 경로 탐색 모드 */
/* 1. RCU-walk (fast path): 잠금 없이 dcache 탐색 */
/*    - d_seq (seqcount)로 dentry 유효성 확인 */
/*    - 실패 시 ref-walk로 폴백 */

/* 2. ref-walk (slow path): dentry 참조 카운트 사용 */
/*    - d_lock spinlock 획득 필요 */
/*    - 항상 성공하지만 느림 */

/* fs/namei.c: path_lookupat() */
static int path_lookupat(struct nameidata *nd,
                         unsigned flags, struct path *path)
{
    /* 먼저 RCU-walk 시도 */
    err = link_path_walk(s, nd);
    if (!err && !(nd->flags & LOOKUP_RCU))
        err = complete_walk(nd);
    /* RCU-walk 실패 시 LOOKUP_REVAL로 ref-walk 재시도 */
}

저널링과 데이터 일관성 (Journaling & Consistency)

저널링 모드

모드저널 대상안전성성능ext4 옵션
journal 메타데이터 + 데이터 최고 (완전한 원자적 쓰기) 낮음 (모든 데이터 이중 기록) data=journal
ordered (기본) 메타데이터만 높음 (데이터 먼저 기록 후 메타데이터 커밋) 중간 data=ordered
writeback 메타데이터만 낮음 (데이터와 메타데이터 순서 무보장) 높음 data=writeback

fsync, fdatasync, barrier

/* fsync 호출 시 커널 내부 흐름 */
/*
 * 1. 더티 페이지 캐시 → 디스크 writeback 시작
 * 2. 저널 커밋 (메타데이터 일관성)
 * 3. 블록 디바이스에 flush/FUA 명령 전송
 * 4. 디스크 캐시 → 영구 저장 매체에 도달 보장
 */

/* file_operations의 fsync 구현 */
static int ext4_sync_file(struct file *file,
        loff_t start, loff_t end, int datasync)
{
    /* datasync=1이면 fdatasync → 메타데이터 중 크기/데이터 변경만 */
    /* datasync=0이면 fsync → 모든 메타데이터(mtime, 권한 등) 포함 */
    filemap_write_and_wait_range(mapping, start, end);
    return ext4_force_commit(inode->i_sb);
}

/* barrier — 디스크 쓰기 순서 보장 */
/* mount -o barrier (기본 활성) / nobarrier (위험!) */
/* 비활성화 시: 정전으로 저널이 손상될 수 있음 */
/* 배터리 백업 캐시(BBU) 있는 RAID 컨트롤러에서만 nobarrier 고려 */
fsync 성능 함정:
  • fsync storm — 많은 프로세스가 동시에 fsync하면 저널 커밋이 직렬화되어 I/O 병목. 특히 데이터베이스(WAL 파일)에서 문제
  • rename + fsync 패턴 — 안전한 파일 교체: write(tmp) → fsync(tmp) → rename(tmp, target) → fsync(dir). 디렉토리 fsync 누락 시 rename이 유실될 수 있음
  • EXT4 delayed allocation — 데이터가 디스크에 기록되기 전에 크래시하면 파일 크기만 늘어나고 내용이 0으로 채워질 수 있음 (data=ordered가 이를 방지)

Writeback 메커니즘

/* 더티 페이지 → 디스크 기록 경로 */
/*
 * 1. write() → 페이지 캐시에 기록 → 페이지를 dirty 마킹
 * 2. 주기적 writeback (dirty_writeback_centisecs = 500 = 5초)
 *    또는 dirty 비율 임계치 초과 시 background writeback
 * 3. flusher 스레드가 address_space_operations->writepages() 호출
 * 4. 블록 I/O 계층 → 디스크
 */

/* 핵심 writeback 파라미터 */
/* /proc/sys/vm/ */
dirty_ratio = 20              /* 전체 메모리 대비 dirty 비율 임계치 (동기 쓰기) */
dirty_background_ratio = 10  /* background writeback 시작 임계치 */
dirty_expire_centisecs = 3000 /* 30초 이상 dirty인 페이지 강제 기록 */
dirty_writeback_centisecs = 500 /* flusher 주기 (5초) */

Extent 기반 할당 (ext4, XFS, Btrfs)

파일시스템할당 방식최대 extent 크기특징
ext4 Extent tree (최대 4 depth) 128MB (단일 extent) delayed allocation, fallocate, bigalloc 지원
XFS B+ tree extent map 8EB (이론적) 실시간 서브볼륨, reflinking(CoW), reverse mapping
Btrfs CoW B-tree 128MB (기본) 스냅샷, 압축, 체크섬, RAID, 서브볼륨

파일시스템 성능 고려사항

파일시스템 성능 최적화 체크리스트:
  • 마운트 옵션noatime (읽기 시 atime 갱신 비활성화 → 메타데이터 쓰기 감소), discard/fstrim (SSD TRIM)
  • 블록 크기 — 대용량 순차 I/O에서는 4KB보다 큰 블록이 유리하나 소파일 많으면 공간 낭비
  • I/O 스케줄러 — SSD: none 또는 mq-deadline, HDD: bfq
  • Direct I/O vs Buffered I/O — DB처럼 자체 캐시가 있는 경우 O_DIRECT로 이중 캐싱 회피
  • fallocate — 사전 할당으로 단편화 방지. DB 파일, 로그 파일에 효과적
  • 디렉토리 인덱싱 — ext4 dir_index (기본 활성), XFS는 B+ tree 디렉토리. 수만 파일 디렉토리에서 중요
  • 저널 크기 — 대용량 쓰기 워크로드에서 저널이 작으면 빈번한 커밋으로 성능 저하. mkfs.ext4 -J size=

VFS 관련 주요 버그 사례

VFS는 리눅스 커널에서 가장 오래되고 복잡한 서브시스템 중 하나이며, 역사적으로 심각한 보안 취약점과 데이터 무결성 결함이 발견되어 왔습니다. 아래는 커널 개발자가 반드시 알아야 할 주요 사례입니다.

Symlink 경쟁 조건 (TOCTOU 취약점)

보안 경고: 심볼릭 링크를 통한 TOCTOU(Time-of-Check to Time-of-Use) 공격은 로컬 권한 상승의 대표적인 벡터입니다. /tmp와 같은 공유 writable 디렉토리에서 특히 위험하며, 공격자가 심볼릭 링크 해석과 실제 파일 접근 사이의 시간 차를 악용하여 임의 파일에 접근할 수 있습니다.

TOCTOU 경쟁 조건은 파일 경로를 확인(check)하는 시점과 실제 사용(use)하는 시점 사이에 심볼릭 링크가 변경될 수 있다는 점을 악용합니다. 예를 들어, 권한 있는 프로세스가 /tmp/myapp.log를 열기 전에 공격자가 이를 /etc/shadow로의 심볼릭 링크로 교체하면 민감한 파일이 덮어씌워질 수 있습니다.

커널은 이를 방어하기 위해 다음 메커니즘을 제공합니다:

/* 유저스페이스 방어: O_NOFOLLOW와 openat() 사용 */
#include <fcntl.h>

/* 심볼릭 링크를 따라가지 않음 — 심볼릭 링크이면 ELOOP 반환 */
int fd = open("/tmp/myapp.log", O_WRONLY | O_CREAT | O_NOFOLLOW, 0644);

/* openat()으로 디렉토리 기준 상대 경로 사용 — TOCTOU 창 최소화 */
int dirfd = open("/tmp", O_RDONLY | O_DIRECTORY);
int fd = openat(dirfd, "myapp.log", O_WRONLY | O_CREAT | O_NOFOLLOW, 0644);
/* 커널 내부: namei.c의 심볼릭 링크 탐색 제어 플래그 */
#define LOOKUP_FOLLOW         0x0001  /* 마지막 컴포넌트에서 심볼릭 링크 따라감 */
#define LOOKUP_NO_SYMLINKS    0x010000 /* 경로 전체에서 심볼릭 링크 금지 */

/* AT_SYMLINK_NOFOLLOW: fstatat(), linkat() 등에서 심볼릭 링크 미추적 */
fstatat(dirfd, "target", &st, AT_SYMLINK_NOFOLLOW);
sysctl 보호: fs.protected_symlinks = 1 (기본값)은 sticky 비트가 설정된 디렉토리(예: /tmp)에서 심볼릭 링크 소유자가 아닌 사용자가 해당 링크를 따라가는 것을 차단합니다. 이는 커널 3.6에서 도입되었으며, 대부분의 TOCTOU 공격을 효과적으로 완화합니다.

fsync() 에러 처리 결함 (2018)

데이터 무결성 위험: 2018년까지 리눅스 커널의 writeback 에러 처리에는 심각한 결함이 있었습니다. fsync()가 writeback 에러를 보고한 후, 같은 파일 디스크립터에서 다시 fsync()를 호출하면 에러가 이미 소비되어 두 번째 호출은 성공(0)을 반환했습니다. 이로 인해 PostgreSQL 등의 데이터베이스에서 데이터가 디스크에 기록되었다고 잘못 판단하는 심각한 무결성 문제가 발생했습니다.

문제의 핵심은 AS_EIO/AS_ENOSPC 플래그가 address_space 전역 상태로 관리되어, 한 번 확인하면 클리어되는 구조였습니다. 여러 파일 디스크립터가 동일 inode를 참조할 때, 하나의 fd에서 에러를 확인하면 다른 fd에서는 에러를 감지할 수 없었습니다.

/* 수정 전: 에러 플래그가 전역적으로 클리어됨 (문제 코드) */
static int filemap_check_errors(struct address_space *mapping)
{
    int ret = 0;
    /* test_and_clear — 한 번 확인하면 에러가 사라짐 */
    if (test_and_clear_bit(AS_EIO, &mapping->flags))
        ret = -EIO;
    if (test_and_clear_bit(AS_ENOSPC, &mapping->flags))
        ret = -ENOSPC;
    return ret;
}
/* 수정 후: errseq_t 기반 에러 추적 (commit 735e4ae5ba28) */
typedef struct {
    u32 counter;  /* 상위 비트: 에러 코드, 하위 비트: 시퀀스 번호 */
} errseq_t;

/* 각 fd가 마지막으로 관찰한 에러 시퀀스를 기록 */
struct file {
    /* ... */
    errseq_t  f_wb_err;  /* 이 fd가 마지막으로 본 writeback 에러 시퀀스 */
};

/* errseq_check_and_advance: fd별로 새로운 에러만 보고 */
int errseq_check_and_advance(errseq_t *eseq, errseq_t *since)
{
    errseq_t old = READ_ONCE(*eseq);
    /* since 이후 새로운 에러가 있으면 보고하고 시퀀스 전진 */
    if (errseq_sample(old) != errseq_sample(*since)) {
        *since = old;
        return -(old >> ERRSEQ_SHIFT);
    }
    return 0;
}
교훈: errseq_t 도입 이후에도 애플리케이션은 fsync() 반환값을 반드시 확인해야 합니다. 에러 발생 시 파일을 닫고 다시 여는 것만으로는 에러 상태가 해소되지 않으며, 데이터를 다시 써야 합니다. 데이터베이스 개발자는 WAL(Write-Ahead Log) 전략과 함께 fsync() 에러를 치명적 오류로 처리해야 합니다.

O_DIRECT와 버퍼 I/O 혼합 시 데이터 손상

데이터 손상 위험: 같은 파일에 대해 O_DIRECT(직접 I/O)와 일반 buffered I/O를 동시에 사용하면 페이지 캐시와 디스크 데이터 간 일관성이 파괴됩니다. 커널은 이 혼합 사용을 경고하거나 방지하지 않으며, 이는 미정의 동작(undefined behavior)으로 분류됩니다.

O_DIRECT는 페이지 캐시를 우회하여 디스크에 직접 읽고 쓰는 반면, buffered I/O는 페이지 캐시를 통해 데이터를 관리합니다. 두 경로가 동시에 동작하면 다음과 같은 시나리오가 발생합니다:

/* 위험한 혼합 사용 예시 — 절대 이렇게 하지 마십시오 */

/* 프로세스 A: buffered write → 데이터가 페이지 캐시에 저장 */
int fd_buf = open("data.db", O_WRONLY);
write(fd_buf, buf_a, 4096);  /* 페이지 캐시에 기록 (dirty page) */

/* 프로세스 B: O_DIRECT read → 디스크에서 직접 읽음 */
int fd_dio = open("data.db", O_RDONLY | O_DIRECT);
read(fd_dio, buf_b, 4096);   /* 디스크의 오래된 데이터를 읽음! */

/* 프로세스 C: O_DIRECT write → 디스크에 직접 기록 */
int fd_dio2 = open("data.db", O_WRONLY | O_DIRECT);
write(fd_dio2, buf_c, 4096); /* 디스크에 기록, 페이지 캐시는 여전히 구 데이터 */

/* 이후 buffered read → 페이지 캐시의 stale 데이터 반환! */
read(fd_buf, buf_d, 4096);   /* 프로세스 C의 쓰기가 보이지 않음 */
데이터베이스의 O_DIRECT 전용 사용 이유: PostgreSQL, MySQL(InnoDB), Oracle 등의 데이터베이스는 자체 버퍼 풀을 관리하므로 커널 페이지 캐시와의 이중 캐싱을 피하기 위해 O_DIRECT를 사용합니다. 이 경우 모든 I/O를 반드시 O_DIRECT로 통일해야 하며, 같은 파일에 buffered I/O를 혼합하면 데이터 손상이 발생할 수 있습니다. 파일시스템에 따라 O_DIRECT 쓰기 시 invalidate_inode_pages2()로 페이지 캐시를 무효화하려 시도하지만, 이는 완전한 보호를 보장하지 않습니다.

경로 탐색(Path Lookup) 성능 버그

VFS의 경로 탐색(path lookup)은 /usr/local/bin/program과 같은 경로를 구성 요소별로 분해하여 최종 dentry/inode에 도달하는 과정입니다. 이 과정에서 심볼릭 링크 해석, 마운트 포인트 횡단 등이 발생하며, 깊은 중첩이나 과도한 심볼릭 링크에서 심각한 성능 및 안정성 문제가 발생할 수 있습니다.

무한 루프 방지: 심볼릭 링크가 서로를 가리키는 순환 참조가 존재하면, LOOKUP_FOLLOW 플래그를 사용하는 경로 탐색이 무한 루프에 빠질 수 있습니다. 이를 방지하기 위해 커널은 MAXSYMLINKS (40)를 도입하여 단일 경로 탐색에서 따라갈 수 있는 심볼릭 링크 수를 제한합니다. 이 한계를 초과하면 ELOOP 에러가 반환됩니다.
/* include/linux/namei.h */
#define MAXSYMLINKS 40  /* 단일 경로 탐색에서 최대 심볼릭 링크 추적 횟수 */

/* fs/namei.c: 경로 탐색의 심볼릭 링크 추적 루프 */
static int trailing_symlink(struct nameidata *nd)
{
    nd->total_link_count++;
    if (nd->total_link_count >= MAXSYMLINKS)
        return -ELOOP;  /* "Too many levels of symbolic links" */
    /* ... 심볼릭 링크 해석 계속 ... */
}

RCU-walk과 REF-walk 모드의 성능 차이도 중요한 고려사항입니다:

/* fs/namei.c: 경로 탐색의 두 가지 모드 */

/*
 * RCU-walk (빠른 경로): 락 없이 RCU 보호 하에 dentry 탐색
 * - d_seq seqcount로 동시 수정 감지
 * - 실패 시 REF-walk로 폴백
 * - 대부분의 경로 탐색은 RCU-walk으로 완료됨
 */
static int link_path_walk(const char *name, struct nameidata *nd)
{
    /* LOOKUP_RCU 플래그가 설정되면 RCU-walk 모드 */
    if (nd->flags & LOOKUP_RCU) {
        /* seqcount 기반 락프리 탐색 — 매우 빠름 */
        /* 동시 수정 감지 시 unlazy_walk()으로 REF-walk 전환 */
    }
    /* ... */
}

/*
 * REF-walk (느린 경로): dentry 참조 카운트 획득 후 탐색
 * - d_lock spinlock 사용으로 경합 발생 가능
 * - 마운트 포인트 횡단, 권한 검사 등 복잡한 경우에 사용
 * - RCU-walk 실패 시 처음부터 REF-walk로 재시도
 */
static int unlazy_walk(struct nameidata *nd)
{
    /* RCU-walk → REF-walk 전환 */
    /* 이 전환이 빈번하면 심각한 성능 저하 */
    nd->flags &= ~LOOKUP_RCU;
    /* dentry와 vfsmount의 참조 카운트 획득 */
    /* ... */
}
dcache 메모리 소비: 대규모 파일시스템에서 dcache(디렉토리 엔트리 캐시)는 수 GB의 메모리를 소비할 수 있습니다. 수백만 개의 파일이 있는 환경에서 ls -R이나 find /와 같은 명령은 dcache를 급격히 팽창시킵니다. 메모리 압박 시 커널의 dcache/icache shrinker가 동작하지만, 이 과정에서 LRU 리스트 탐색과 negative dentry 정리에 상당한 CPU 시간이 소요될 수 있습니다. /proc/sys/fs/dentry-state로 현재 dcache 상태를 모니터링할 수 있습니다.