ZFS 소스 학습 시리즈 (2/8). 소스: OpenZFS master(2.4.99-917-ge939b2d7e) 실제 소스 기준.
이 글의 모든 구조는 OpenZFS master 소스를 직접 읽어 확인했고, 인용은 파일:라인 형식으로 남깁니다(zfs_vnops.c:615, dbuf.c:2330, arc.c:7114). 라인 번호는 커밋마다 흐르므로 함수명으로 grep하는 편이 안전합니다.
이전 편 (1/8) 바이트 지도, 바닥의 바이트를 봤으니 이제 계층을 오른다: ZFS-Study-01-Byte-Map
Hook: 파일 하나를 쓰면 커널 안에서 무슨 일이 벌어지나
echo hello > note.txt 한 줄이면 ZFS 커널 코드의 다섯 개 층을 통과합니다. 그리고 write(2)가 반환하는 순간 디스크는 아직 아무것도 모르고 있습니다. 정작 커밋은 잠시 뒤, 디스크 양 끝 라벨 안쪽의 1KB 슬롯 하나가 덮어써지는 순간에 일어납니다.
여태 관련 레이스 분석처럼 문제가 터진 경로 주변만 깊게 파다 보니, 정작 “데이터가 어떻게 저장되고 읽히는가”라는 정상 경로를 처음부터 끝까지 관통한 적이 없다는 것을 깨달았습니다. 이 시리즈는 그 트렁크를 채우는 여덟 편 중 지도를 그리는 편입니다. 이번 편에서는 계층 다섯 개, 디스크 체인 여섯 고리, 구조체 네 개만 기억하면 됩니다.
TL;DR
- ZFS는 5층 레이어 케이크입니다. ZPL이 POSIX 시맨틱을 접수하면 DMU가 객체 트랜잭션으로 묶고, SPA가 공간을 내고, ZIO가 변환해 발급하며, vdev가 실제 디스크에 I/O를 날립니다.
- 온디스크의 모든 것은 하나의 CoW 트리입니다. uberblock이 MOS를, MOS가 데이터셋을, 데이터셋이 dnode를, dnode의 blkptr이 데이터 블록을 가리킵니다.
- 커밋은 uberblock 교체 한 번입니다. 데이터를 쓰는 행위가 아니라 포인터를 옮기는 행위입니다.
- write(2)의 빠른 반환은 ARC(메모리 캐시)까지만 도달하고, 디스크 반영은 txg(기본 5초) 단위 싱크가 비동기로 처리합니다. fsync만 ZIL이 예외로 즉시 갑니다.
- 구조체 네 개(blkptr, dnode, objset, uberblock)가 온디스크 포맷의 90%입니다.
레이어 케이크: 호출은 항상 한 층 아래로
ZFS는 엄격한 레이어드 아키텍처입니다. 각 층은 바로 아래 층의 API만 호출하고, 층을 건너뛰거나 거슬러 올라가지 않습니다. 이 규칙을 모르면 소스에서 길을 잃습니다. 각 층을 그 층이 던지는 질문 하나로 요약할 수 있습니다.
ZPL(ZFS POSIX Layer)은 “이 파일의 이 오프셋에 이 바이트를 써라”를 받습니다. 진입점인 zfs_write()(zfs_vnops.c:615)가 rangelock(파일 구간 단위 잠금), 쿼터 체크, 블록 크기 결정을 맡습니다. DMU(Data Management Unit)는 “이 object의 이 blkid를 더티로 만들어라”를 수행합니다. 파일을 객체(object), 즉 블록 트리로 추상화하고 트랜잭션(txg)으로 CoW를 보장합니다. SPA(Storage Pool Allocator)는 “이 txg에 이 크기의 블록 자리를 내라”에 응답합니다. 풀 관리, 공간 할당, 스냅샷과 클론 메타, scrub이 여기 살립니다. ZIO는 “이 블록을 압축해서 체섬 달아 발급해라”를 처리하는 비동기 파이프라인입니다. 마지막 vdev(가상 장치)가 “이 vdev의 이 오프셋에 I/O를 날려라”를 실제 bio로 바꿉니다. 미러와 RAIDZ 논리도 여기 있습니다.
한 가지 운영 체감 포인트가 있습니다. ZPL 위의 Linux VFS에서 ZFS는 page cache를 쓰지 않고 자체 캐시인 ARC로 직접 처리합니다. 그래서 free 출력에서 ARC가 메모리를 잡아먹는 것처럼 보이는 일이 벌어집니다. 아래 그림이 다섯 층의 전체 모습입니다.
ARC는 ZPL과 DMU 사이, DMU와 SPA 사이에서 두루 쓰이는 통합 캐시이고, ZIL은 ZPL 옆에 붙는 쓰기 로그입니다. 둘 다 케이크의 한 층이 아닙니다. 그럼 이 다섯 층이 디스크에는 무엇으로 남기는지, 체인을 따라 내려가 봅니다.
온디스크 체인: uberblock에서 데이터 블록까지
ZFS 디스크의 모든 것은 “블록 포인터(blkptr)로 연결된 CoW 트리” 한 문장으로 요약됩니다. 부팅이든 읽기든 결국 이 체인을 타고 내려갑니다. 물리 구조의 시작은 1편에서 바이트 단위로 봤던 라벨 4개(디스크 처음 2개, 끝 2개, 각 512KB)이고, 그 안에 uberblock 슬롯 링이 순환 기록됩니다.
논리 체인은 이렇게 이어집니다. uberblock이 MOS를 가리키고, MOS의 DSL 디렉터리가 각 데이터셋 objset을 등록하며, objset의 메타 dnode가 object 번호를 dnode로 연결하고, dnode의 blkptr이 간접 블록을 거쳐 데이터 블록에 닿습니다.
체인 전체를 표로 남깁니다. 각 고리가 무엇이고 다음 고리를 여는 열쇠가 무엇인지가 이 표의 전부입니다.
| 단계 | 정체 | 다음을 여는 열쇠 | 소스 위치 |
|---|---|---|---|
| pool | vdev 트리. 라벨 4개(처음 2개 + 끝 2개, 각 512KB) | 라벨 속 uberblock 링(1KB 슬롯, 순환 기록) | vdev_label.c |
| uberblock | 풀의 현재 정식 상태를 가리키는 살아있는 포인터 | ub_rootbp (MOS 루트 blkptr) | uberblock_impl.h |
| MOS | objset 0. 풀의 모든 메타데이터(Object Set) | DSL 디렉터리가 데이터셋 목록 관리 | dsl_dir.c |
| dataset objset | 파일시스템/zvol 하나. 메타 dnode + ZIL 헤더 + 쿼터 dnode 포함 | os_meta_dnode (object 번호를 dnode로) | dmu.h |
| dnode | 파일 하나의 inode. blkptr 배열 + 레벨 + 블록 크기 | dn_blkptr (nlevels 1이면 곧 데이터) | dnode.h |
| blkptr | 128바이트. DVA 3개(각 16바이트 비트패킹) + 체크섬 + birth txg | DVA(vdev, 오프셋, asize) | spa.h |
| 데이터 블록 | L0. recordsize 기본 128KB. CoW라 수정은 새 자리에 | (체인의 끝) | spa.h |
커밋은 uberblock 교체 한 번이다
uberblock이 ZFS의 유일한 커밋 포인트입니다. 쓰기 전부가 디스크에 반영됐어도 uberblock이 갱신되지 않으면 그 txg는 존재하지 않았던 것이 됩니다. 반대로 uberblock이 먼저 갱신되면 안 되므로 싱크 순서는 항상 데이터, 메타데이터, uberblock 순서입니다.
왜 유일하게 제자리 덮어쓰기일까요? 모든 블록을 불변으로 만들면 “루트를 가리키는 포인터”도 불변 블록이어야 하고, 그러면 그 포인터를 가리키는 포인터가 또 필요합니다. 무한 회귀를 끊으려면 최상위 포인터 한 곳만 제자리 갱신을 허용하고, 대신 여러 슬롯에 순환 기록하며 안전장치를 겹칩니다. git이 불변 객체 위에서 브랜치 포인터만 옮기는 것과 같은 발상입니다.
정전이 나도 절반만 반영된 상태는 없다
세 가지 시나리오 어느 쪽이어도 결과는 동일합니다. 새 블록 일부만 기록된 채 정전이면 uberblock은 직전 txg를 가리키므로 이전 세계로 부팅합니다. uberblock 슬롯 기록 도중이면 그 슬롯만 체크섬 불일치로 폐기됩니다. 기록 직후면 새 세계가 됩니다. 저널 파일시스템처럼 리플레이하는 대신, 메타데이터에 한해 “두 세계 중 하나를 선택”합니다. 리플레이가 필요한 유일한 영역은 fsync 의미론을 담당하는 ZIL뿐입니다.
이제 체인의 재료가 되는 구조체 네 개를 파일 기준으로 짚고 넘어갑니다.
핵심 자료구조 4개
이 네 개가 온디스크 포맷의 90%입니다. 전부 실제 헤더에서 가져온 요약입니다.
| 구조체 | 정의 위치 | 크기 | 기억할 필드 |
|---|---|---|---|
| blkptr_t | include/sys/spa.h | 128바이트 | blk_dva3, blk_birth(생성 txg), blk_cksum(256비트), blk_fill(하위 블록 수) |
| dnode_phys_t | include/sys/dnode.h | 512바이트~16KB | dn_nlevels(트리 깊이), dn_nblkptr(인라인 bp, 기본 3), dn_datablkszsec(= recordsize), dn_maxblkid(EOF 판정) |
| objset_phys_t | include/sys/dmu.h | 블록 1개 | os_meta_dnode(object 찾기의 루트), os_zil_header(ZIL도 objset 안에), userused dnode(쿼터 집계) |
| uberblock | include/sys/uberblock_impl.h | 약 1KB 슬롯 | ub_rootbp(MOS 루트), ub_txg(커밋 번호), ub_mmp_*(이중 마운트 방지), ub_zec(끝 체크섬) |
주목할 디테일이 둘 있습니다. 첫째, DVA 하나는 16바이트에 “어떤 vdev의 어느 오프셋에 몇 바이트(asize)로 할당됐는가”를 비트패킹합니다. DVA가 3개인 이유는 미러와 copies=N 조합입니다. 둘째, 파일 크기는 dnode에 없습니다. 크기와 UID, mtime 같은 inode 속성은 bonus 버퍼의 SA(System Attribute)에 있고, dnode는 블록 트리의 뼈대만 담습니다.
블록 트리의 3단계 사다리
파일 크기에 따라 트리는 세 단계로 자랍니다. 약 112바이트까지(압축 후)는 데이터가 blkptr 안에 통째로 들어가는 embedded bp라 메타 블록이 0개입니다. 직접 블록만 쓰는 단계(최대 3블록, recordsize 128KB 기준 384KB)도 간접 블록이 없습니다. 그 이상부터 “blkptr 배열만 담은 블록”인 간접 블록이 L1, L2 식으로 자랍니다. ext2의 direct/indirect pointer와 정확히 같은 발상이고, 차이는 blkptr이 주소가 아니라 체크섬과 birth txg까지 담는 128바이트라는 점과 깊이가 고정 3단이 아니라 계속 자란다는 점뿐입니다.
간접 블록과 용량 산수
간접 블록 하나가 담는 포인터 수는 2^(간접블록크기로그 - 7)로, 16KB면 128개, 128KB면 1024개입니다. recordsize 128KB와 간접 블록 16KB 기준으로 nlevels 2는 약 48MB까지, 3은 약 6GB까지, 4에서 5로 100TB급까지 담습니다. 이 master부터 신규 오브젝트의 간접 블록 기본값이 128KB로 커졌습니다(dnode.c:83, DN_MAX_INDBLKSHIFT). 같은 레벨에서 8배 큰 파일을 담을 수 있게 된 셈입니다.
구조는 정리됐습니다. 이 구조를 메모리 위에서 다루는 세 가지 추상화로 넘어갑니다.
3대 추상화: ARC, dbuf, zio
ARC: 캐시가 아니라 메모리 저장소
ARC는 읽기 캐시와 “아직 디스크에 안 쓴 더티 데이터의 보관소”를 겸합니다. 이중 역할이 ZFS 이해의 첫 번째 허들입니다. 읽기에서는 블록을 ARC에서 먼저 찾고, 쓰기에서는 dbuf_dirty()가 ARC 버퍼에 데이터를 쓰고 dirty로 마킹만 합니다. write(2)가 반환된 시점에는 디스크가 아니라 ARC가 사실상의 저장소입니다. 더티 버퍼는 txg가 끝날 때까지 절대 내보내질 수 없습니다.
dbuf: ARC 앞단의 인덱스
dbuf는 (objset, object, level, blkid) 네 키로 관리되는 블록 버퍼입니다. 데이터 자체는 없고 “이 블록의 논리적 상태 + ARC 버퍼 포인터”만 담습니다. 상태는 UNCACHED와 READ를 거쳐 CACHED로 흐르고 쓰기 중에는 FILL을 지나갑니다. 간접 블록과 dnode 자체를 위한 별도 dbuf cache도 있다는 점은 읽기 경로에서 다시 만납니다.
zio: 변환과 발급을 나눈 파이프라인
zio는 I/O 요청 객체이자 변환 파이프라인입니다. 부모-자식 트리를 이루고 압축, 체크섬, 암호화 변환을 통과한 뒤 ready 게이트를 지나 DVA 할당과 vdev 분해가 이뤄집니다. 핵심 설계는 “무엇을 쓸지(변환)”와 “어디에 쓸지(할당)”의 분리입니다. 그래서 압축 크기가 확정된 뒤에 metaslab이 공간을 요청받는 순서가 나옵니다. 전부 비동기 콜백 기반이고, 미러와 RAIDZ도 zio 입장에선 자식 zio를 만드는 것뿐입니다.
이 세 추상화가 움직이는 순서가 곧 쓰기와 읽기 경로입니다. 다음 두 편의 예고를 겸해 한눈에 둡니다.
Write와 Read, 한눈에
쓰기는 여덟 단계로 요약됩니다. zfs_write()가 rangelock을 잡고(①), dmu_tx가 txg를 배정하며(②), dbuf_dirty()가 ARC에 더티 버퍼를 만드는 곳까지가 사용자에게 보이는 반환입니다(③). 이후는 배경입니다. txg가 마감되면(기본 5초 또는 dirty 한도) dsl_pool_sync()가 CoW 연쇄를 일으켜(④⑤) arc_write()에서 zio 파이프라인으로 압축과 체크섬을 거쳐 새 자리에 기록하고(⑥⑦), uberblock 슬롯이 교체되는 순간 커밋됩니다(⑧).
읽기는 더 짧습니다. zfs_read()에서 dbuf_read() 상태머신까지 내려가 ARC에 있으면 즉시 반환하고(I/O 0회), 미스면 zio read로 디스크에서 가져와 체크섬을 검증하고 압축을 푼 뒤 ARC에 적재합니다. 미스였다면 다음 블록을 예측하는 프리페치(dmu_zfetch)도 함께 발동합니다.
소스를 읽기 전에는 “커밋은 데이터를 디스크에 쓰는 행위”라고 알고 있었습니다. 읽고 나니 “커밋은 포인터를 옮기는 행위”가 되었습니다. 이 관점 전환 하나로 스냅샷, scrub, send/recv가 전부 “트리를 공유하거나 순회하는 방식”으로 재해석됩니다. 반환(③)과 커밋(⑧) 사이의 간극이 다음 편의 주제입니다.
용어 사전
시리즈 전체에서 재사용하는 열두 단어입니다.
| 용어 | 뜻 |
|---|---|
| txg | transaction group. 쓰기를 모아 커밋하는 단위. 기본 5초 또는 용량 압박으로 닫히고 open, quiescing, syncing 3상태가 항상 공존 |
| blkid | 파일(객체) 내 블록 번호. 오프셋 나누기 블록 크기. L0은 데이터, L1 이상은 간접 |
| bp / DVA | blkptr은 어디에, 어떤 체크섬으로, 어느 txg에 태어났는지. DVA는 그 안의 개별 주소(vdev + 오프셋 + asize) |
| MOS | Meta Object Set. 풀 메타데이터의 루트. objset 0 |
| objset | 데이터셋 하나. 메타 dnode와 ZIL 헤더, 쿼터 dnode 포함 |
| dbuf | DMU 블록 버퍼. (objset, object, level, blkid) 키. 데이터는 ARC에 있고 dbuf는 상태와 포인터만 |
| ZIL | intent log. fsync 의미론을 위해 txg 커밋 전에 먼저 기록하는 쓰기 로그. 재사용 가능 |
| SA | System Attributes. inode 속성을 bonus 버퍼에 담는 가변 포맷 |
| ZAP | 디렉터리 엔트리용 해시 테이블 |
| gang block | 연속 공간 확보 실패 시 조립식으로 묶는 블록. 단편화 경고 신호 |
| fill | blkptr의 하위 트리 블록 수. destroy 시 회수량 계산에 사용 |
| BP_IS_HOLE | bp가 비어 있다는 뜻. sparse 영역이며 읽으면 0으로 채움 |
다음 편 (3/8) Write Path, zfs_write()에서 uberblock까지 여덟 단계와 그 사이의 락: ZFS-Study-03-Write-Path
게이트웨이 On-promise 제품 팀에서 시스템 모니터링 및 관리를 쉽게 다가갈 수 있도록 하기 위한 업무를 하고 있습니다.
Contact: lhjnano@gmail.com