ZFS 소스 학습 (1/8): 라벨·GUID·uberblock - 디스크 바이트 지도부터

2026/08/29 OpenZFS Filesystem 6295자 · 약 18분

ZFS 소스 학습 시리즈 (1/8). 소스: OpenZFS master(2.4.99-917-ge939b2d7e) 실제 소스 기준.

디스크의 첫 512바이트부터 ZFS가 시작됩니다. 파티션 테이블을 기대하고 덤프하면 나오는 건 0의 연속인데, 이 0조차 ZFS가 예약해 둔 라벨(label)의 첫 영역 pad1입니다. ZFS는 풀 이름, GUID, 최신 커밋 위치를 디스크 맨 앞과 맨 뒤에 각각 두 장씩, 총 4장의 라벨로 새겨 둡니다.

이 글의 모든 숫자는 OpenZFS master(2.4.99-917-ge939b2d7e) 실제 소스에서 뽑았고, hexdump와 zdb로 실제 디스크 바이트를 직접 확인하며 검증한 실측 기반입니다.

이 내용을 시리즈 1편으로 두는 이유는 하나입니다. 바닥의 바이트부터 읽으면 위의 추상화가 전부 이해된다는 것입니다. 풀, txg, MOS 같은 상위 개념이 전부 라벨 안 몇백 바이트 위에 서 있습니다. 이 바닥을 먼저 밟아 두면 이후 편들이 거꾸로 읽힙니다.

TL;DR

  • 라벨은 디스크마다 4개 × 256KiB입니다. 시작에 2개(L0 @ 0x0, L1 @ 0x40000), 끝에 2개(L2 @ psize-0x80000, L3 @ psize-0x40000).
  • 라벨 내부는 pad1 8KiB + bootenv 8KiB + 설정 nvlist 112KiB + uberblock 링 128KiB의 4층 구조입니다.
  • 설정 nvlist는 XDR(big-endian)로 인코딩되어 hexdump에서 풀 이름 “tank”가 ASCII 그대로 보입니다.
  • pool GUID의 위치는 상수가 아니라 공식입니다. 풀 이름이 tank(4글자)면 L0의 0x40B8.
  • uberblock 링은 128KiB이고 슬롯 수는 ashift가 결정합니다. ashift≤10이면 1KiB×128슬롯이고 txg 3000은 라벨+0x2E000에 기록됩니다.
  • 검증은 zdb -l -u /dev/sdX 한 줄이면 충분하고, hexdump 직독 레시피도 함께 담았습니다.

라벨 4개의 절대 주소

기준은 OpenZFS master 소스의 vdev_label_offset()입니다. 함수가 사실상 한 줄이라 그대로 옮깁니다.

return (offset + l * sizeof (vdev_label_t) +
    (l < VDEV_LABELS / 2 ? 0 : psize - VDEV_LABELS * sizeof (vdev_label_t)));

라벨 번호 l이 0, 1이면 디스크 앞에서, 2, 3이면 psize - 1MiB를 기준으로 계산합니다. 여기에 psize가 256KiB의 배수라는 조건이 더해져 절대 주소 4개가 나옵니다.

라벨절대 주소1GiB vdev에서 (psize=0x40000000)
L00x00000000 (시작)0x00000000
L10x00040000 (256KiB)0x00040000
L2psize - 0x80000 (끝-512KiB)0x3FF80000
L3psize - 0x40000 (끝-256KiB)0x3FFC0000

왜 앞 2개, 뒤 2개로 갈라 놓았을까요? 헤드 크래시로 앞이 깎이거나 파티션 테이블이 손상돼도 한쪽 끝만 살아있으면 라벨이 남는 배치이기 때문입니다. 주소를 알았으니 이제 디스크 전체에서 이 네 점이 어디쯤인지 한 장으로 봅니다.

디스크 hex 지도 전체

1GiB 단일 디스크 vdev 기준으로 절대 오프셋을 쭉 편 표입니다. ZFS가 예약하는 영역은 양 끝뿐이고 가운데는 풀의 99.9%를 차지하는 데이터와 메타데이터가 씁니다.

절대 오프셋내용
0x00000000라벨 0 (256KiB)
0x00040000라벨 1 (256KiB)
0x00080000boot 예약 영역 3.5MiB
0x00400000데이터 · 메타데이터 시작 (풀의 99.9%)
0x3FF80000라벨 2 (psize - 0x80000)
0x3FFC0000라벨 3 (psize - 0x40000)
0x40000000psize (디스크 끝)

아래 지도가 위 표의 그림 버전입니다. 라벨 내부까지 확대해 uberblock 링 위치까지 담았고, L2/L3도 같은 구조가 같은 순서로 기록됩니다.

디스크 바이트 지도: 라벨 4개와 uberblock 위치 (1GiB 예시) 라벨 0 256KiB 라벨 1 256KiB boot 예약 3.5MiB 데이터 / 메타데이터 풀의 99.9% (MOS · 블록) 라벨 2 256KiB 라벨 3 256KiB 0x00000000 0x00040000 0x00080000 0x00400000 0x3FF80000 0x3FFC0000 psize=0x40000000 라벨 1개 내부 (256KiB 확대) vl_pad1 8KiB · 0 채움 vl_be bootenv 8KiB vl_vdev_phys 112KiB 설정 nvlist(XDR) · pool GUID ★ vl_uberblock 128KiB uberblock 링 (슬롯 배열) +0x0000 +0x2000 +0x4000 +0x20000 +0x40000 uberblock 링 = 라벨 베이스 + 0x20000부터 128KiB 축척 왜곡: 라벨 4개 합계 1MiB는 1GiB의 0.1%도 안 됩니다

그림에서 라벨 하나가 사실 4층짜리 상자라는 게 보입니다. 이제 그 내부로 들어갑니다.

라벨 내부는 256KiB 4층 구조

라벨 하나는 vdev_label_t 구조체를 디스크에 그대로 눕힌 정확히 256KiB입니다.

라벨 내 오프셋크기필드내용
0x000008KiBvl_pad1예약 패딩, 전부 0
0x020008KiBvl_bebootenv(GRUB 부트 환경)
0x04000112KiBvl_vdev_phys설정 nvlist(XDR) + 끝에 vp_zbt 체섬 @ 0x1FFD8
0x20000128KiBvl_uberblockuberblock 링(슬롯 배열)
0x40000256KiB라벨 끝

vp_zbt는 zio_eck_t, 매직 8바이트 + 체섬 32바이트 = 40바이트로 112KiB 전체의 무결성을 검사합니다.

매직 넘버 2종을 혼동하지 마세요. uberblock의 매직은 UBERBLOCK_MAGIC = 0x00bab10c(“oo-ba-bloc!”)이고, 라벨 체섬(vdev_phys/bootenv 끝)의 매직은 ZEC_MAGIC = 0x210da7ab10c7a11입니다. hexdump에서 둘 다 등장하므로 감별 포인트로 씁니다.

4층 중 실질적인 내용물은 3층의 설정 nvlist입니다. 디스크가 서로를 알아보는 방식이 여기 들어 있습니다.

nvlist XDR 인코딩과 tank 실전 계산

라벨의 설정은 nvlist_pack(..., NV_ENCODE_XDR)로 팩됩니다. XDR은 big-endian이라 x86 덤프에 익숙한 눈에는 순서가 반대지만, GUID가 사람이 읽는 순서 그대로 저장된다는 반전의 편리함이 있습니다. 문법은 다음과 같습니다.

[nvlist 헤더]  int32 nvl_version = 0
               int32 nvl_nvflag = 1 (NV_UNIQUE_NAME)
[엔트리 반복]   int32 encode_size
               int32 decode_size
               int32 name_len              (strlen + 1, NUL 포함)
               char  name[name_len]        (4바이트 배수 패딩)
               int32 data_type             (UINT64=8, STRING=9, NVLIST=19)
               int32 nelem
               byte  value[...]
[종료]          00 00 00 00                (encode_size=0)

키 순서는 config_generate()가 정하는데, 라벨 안에서는 version → name → state → txg → pool_guid → errata → hostid → hostname → vdev_tree 순입니다. GUID가 두 번 나오는 이유도 여기에 있습니다. 최상위 pool_guid는 풀의 신분증, vdev_tree 안의 각 guid는 디스크(vdev)의 신분증입니다.

풀 이름이 tank(4글자)면 엔트리 크기를 더해 pool GUID의 자리를 정확히 계산할 수 있습니다.

#이름(패딩)타입엔트리 크기시작(nvlist 내)
-nvlist 헤더--80x00
1version7→8UINT64360x08
2name4→8STRING(“tank”)400x2C
3state5→8UINT64360x54
4txg3→4UINT64320x78
5pool_guid9→12UINT64400x98

엔트리 5의 값은 프레이밍 32바이트(4+4+4+12+4+4) 뒤에 옵니다.

nvlist 내 pool_guid 값 오프셋 = 0x98 + 0x20 = 0xB8
L0 절대 오프셋              = 0x4000 + 0xB8 = 0x40B8
L2/L3에서도                 = label_base + 0x40B8

실제 덤프에서 눈에 보이는 모습입니다.

주소(L0 절대)  바이트                                                        읽는 법
00004000  00 00 00 00 00 00 00 01  00 00 00 24 00 00 00 24   헤더(ver=0) | 'version' 엔트리 시작
00004010  00 00 00 08 76 65 72 73  69 6f 6e 00 00 00 00 08   name_len=8 | "version" | type=8(UINT64)
00004020  00 00 00 01 00 00 00 00  00 00 13 88 00 00 00 28   값=5000 | 'name' 엔트리 시작
00004030  00 00 00 28 00 00 00 05  6e 61 6d 65 00 00 00 00   name_len=5 | "name" | pad
00004040  00 00 00 09 00 00 00 01  00 00 00 05 74 61 6e 6b   type=9(STRING) | len=5 | "tank" ← 풀 이름
000040a0  00 00 00 0a 70 6f 6f 6c  5f 67 75 69 64 00 00 00   name_len=10 | "pool_guid"
000040b0  00 00 00 08 00 00 00 01  12 34 56 78 9a bc de f0   type=8 | pool GUID @ 0x40B8 ★
0001ffd8  21 0d a7 ab 10 c7 a1 11  (체섬 32B)               vp_zbt: ZEC_MAGIC + 체섬
00020000  ...                                                   uberblock 링 시작

크기 워드의 정확한 값은 구현 디테일이므로, 덤프를 읽을 때는 문자열과 타입 코드, GUID를 앵커로 삼는 것이 안전합니다. 0x20000에서 링이 시작된다고 했으니, 이제 이 링의 산수를 합니다.

uberblock 링 산수

링은 라벨 내 0x20000부터 128KiB입니다. 슬롯 크기와 개수는 ashift가 결정합니다.

shift = MIN(MAX(ashift, 10), 13)     // 10 = UBERBLOCK_SHIFT(하한), 13 = 상한
count = 128KiB >> shift              // 슬롯 수
slot  = txg % (count - m)            // m = mmp=on이면 1 (MMP 심박용 마지막 1슬롯 예약)
위치  = label_base + 0x20000 + (slot << shift)
ashift슬롯 크기슬롯 수비고
9, 101KiB128하한 UBERBLOCK_SHIFT=10
112KiB64 
124KiB32현대 SSD 일반적
13+8KiB16상한 MAX_UBERBLOCK_SHIFT=13

모든 유효 슬롯은 uberblock 첫 필드인 ub_magic 때문에 정확히 00 ba b1 0c로 시작합니다. tank 풀 txg 3000을 넣으면 3000 % 128 = 56, 즉 0x20000 + 56×0x400 = 0x2E000에 기록돼 있어야 합니다.

0002e000  00 ba b1 0c 00 00 00 00  00 00 00 00 00 00 13 88   ub_magic=0x00bab10c | ub_version=5000
0002e010  00 00 00 00 00 00 0b b8  ab cd ef 98 76 54 32 10   ub_txg=3000 ★ | ub_guid_sum

커밋은 매 txg마다 다음 슬롯에 순환 기록됩니다. txg가 가장 큰 유효 슬롯이 현재 커밋이고, 그 안의 ub_rootbp가 풀 전체로 뻗어 나가는 유일한 실입니다.

uberblock 링: 128슬롯 순환과 산수 (ashift≤10 · 슬롯 1KiB) 슬롯 0 링의 시작 슬롯 55 txg 2999 · 과거 슬롯 56 ★ txg 3000 · 현재 슬롯 57 txg 3001 · 다음 슬롯 127 다음은 슬롯 0 쓰기는 매 txg마다 다음 슬롯으로: 127을 넘으면 다시 0으로 순환 ① 슬롯 크기 shift = MIN(MAX(ashift, 10), 13) ashift 9~10이면 1KiB ② 슬롯 수 count = 128KiB >> shift 1KiB면 128개, 4KiB면 32개 ③ 슬롯 번호 slot = txg % (count - m) 3000 % 128 = 56 tank 풀 · txg 3000의 uberblock 절대 위치 0x20000 + (56 << 10) = 0x20000 + 0xE000 = 0x2E000 mmp=on이면 마지막 1슬롯을 MMP 심박으로 예약 (m=1)

공식과 덤프가 서로 맞는지는 직접 확인해 볼 차례입니다.

검증 레시피: hexdump와 zdb

실 디스크(/dev/sdX)가 있다면 아래 순서로 전부 확인할 수 있습니다.

# ① 라벨 L0의 nvlist: 풀 이름과 GUID가 눈에 보임
sudo hexdump -C -s 0x4000 -N 320 /dev/sdX
# ② pool GUID 직독 (tank 예: 0x40B8의 8바이트, big-endian)
sudo hexdump -C -s 0x40B8 -N 8 /dev/sdX
# ③ uberblock 링 스캔: 00 ba b1 0c 시그니처 찾기
sudo hexdump -C -s 0x20000 -N 4096 /dev/sdX | grep -i "00 ba b1 0c"
# ④ 끝 라벨 L2/L3은 psize가 필요: L2 = psize-0x80000, L3 = psize-0x40000
sudo blockdev --getsize64 /dev/sdX
# ⑤ 사람이 읽는 디코드 (권장): 라벨 nvlist + uberblock 목록 전체
sudo zdb -l -u /dev/sdX
sudo zdb -C tank

체크리스트: ① 0x4000에서 풀 이름이 보이는가 ② 0x40B8(또는 계산 오프셋)의 8바이트가 zdb -C의 pool guid와 일치하는가 ③ L2/L3의 nvlist가 L0/L1보다 최신인가 ④ 예상 슬롯 수만큼 00bab10c가 보이는가. 마지막으로 실수하기 쉬운 지점 다섯 가지를 남깁니다.

함정 노트 5가지

  1. 오프셋은 상수가 아니라 공식입니다. pool_guid 위치는 풀 이름 길이의 함수, leaf vdev의 guid는 hostname 길이까지의 함수입니다. “항상 0x40B8”이 아니라 “tank일 때 0x40B8”이 정확한 표현입니다.
  2. XDR은 big-endian입니다. little-endian에 익숙한 눈에는 순서가 반대지만 GUID 0x1234...12 34 ...로 저장돼 있어 오히려 사람 눈에는 편합니다.
  3. 문자열 길이 워드는 NUL을 포함합니다. XDR 문자열은 strlen+1을 길이로 쓰고 4바이트 배수로 패딩합니다. 4글자 “tank”의 길이 워드가 5인 이유입니다.
  4. L0/L1과 L2/L3의 갱신 시점은 다릅니다. L0/L1의 vdev_phys는 풀 생성·라벨 복구 시(vdev_label_init) 쓰이고, 갱신은 vdev_label_sync가 L2/L3에만 씁니다. L0/L1이 과거 트리를 담고 있어도 정상이며, import는 전부 읽고 체섬과 txg로 최선을 고릅니다.
  5. 슬롯 크기는 디스크마다 다릅니다. 1KiB 간격으로 훑다가 4KiB 슬롯(ashift 12) 디스크를 만나면 4개를 하나로 세는 실수를 합니다. zdb -l을 쓰면 이 산수를 건너뛸 수 있습니다.

다음 편 예고: ZFS 소스 학습 (2/8): 아키텍처에서 uberblock의 ub_rootbp가 가리키던 MOS가 풀 전체를 어떻게 조립하는지 봅니다.

HeonJe Lee | 선임연구원
게이트웨이 On-promise 제품 팀에서 시스템 모니터링 및 관리를 쉽게 다가갈 수 있도록 하기 위한 업무를 하고 있습니다.

Contact: lhjnano@gmail.com

Search

    Table of Contents