기록 84편을 계약 에이전트로 다시 썼다. 기존 71편(kss 25 · virt 46)과, 계약에만 있고 안 쓰여 있던 새 글감 13편이다. 원장 84개를 열어 단계마다 스킬 영수증과 관문 종료 코드를 적었고 verify-pipeline-run.py 가 error 0 으로 닫는다. SSOT 결함 둘을 고쳤다. - kss 의 `약 58일` 이 반입 중 `약 59일` 로 바뀌어 있었다. 원 증거 파일이 「남은 일수: 88일 … 실제 갱신까지 약 58일」로 산수를 직접 적는다. D-4a 쪽 `약 59일` 은 강제 갱신 뒤(`VALID: 89 days`)라 맞는 값이라 그대로 뒀다. - virt §198 의 `11.6GB` 는 §178 의 원 측정 `Mem: 11648`(MiB)과 어긋나는데 원 가이드의 표기 그대로라 고치지 않고 쓰이는 자리에 대조를 적었다. 기록의 수치 오류 셋을 고쳤다 — CASE 요약의 「게스트 셋에 8240MB」(5120+3120 은 둘이다), k3s 편이 같은 것을 여섯·일곱·여덟로 세던 것, no-docker 편의 「셋을 더 든다」(§281 의 표는 네 행이고 디스크 행이 빠져 있었다). 계약을 셋 고쳤다. - kss 의 sourceRepository 리비전이 cdac9b8 이었는데 그 커밋에는 docs/guides/** 28개가 아예 없다. 9465582b 로 바꾸고, 반입한 바이트가 어느 커밋과도 같지 않다는 것을 측정값과 함께 적었다 — 반입은 커밋이 아니라 그 시점의 작업 트리에서 떠 온 것이다(kss 297/306 · virt 12/14 가 작업 트리와 같고, 200 커밋을 거슬러 전수 대조했을 때 가장 가까운 커밋도 28개가 어긋났다). - virt 계약이 「2026-09-11 재배분」이라고 적는데 SSOT 는 재배분 날짜를 적지 않고 재배분 뒤 값은 이미 2026-09-10 측정에 찍혀 있다. - kss 후보 대장이 지나친 절 아홉에 처분을 적었다(warn 9 → 0). 새 글감은 0건이고 넷은 앵커가 h3 슬러그의 접두가 아니라 중간 토막이라 검사기가 못 본 것이었다. style_profile.mjs 의 결함 둘을 고쳤다 — frontmatter 가 문장으로 세어져 (실측 398자짜리 「문장」 하나) 평균 길이를 기준 안으로 밀어 올리고 있었고, engPerSent 의 분자는 목록을 포함한 글에서, 분모는 목록을 걷어낸 글에서 세고 있었다(Question 기록에서 11.94 → 3.86). verify-pipeline.py 전 항목 PASS · error 0 · unittest 334건 OK. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
15 KiB
kind, slug, title, topic, topicName, project, status, basisVersion, sourceRevision, source
| kind | slug | title | topic | topicName | project | status | basisVersion | sourceRevision | source | ||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| CONCEPT | inside-a-qcow2-file-the-mapping-table-and-its-clusters | qcow2 파일 안 — 매핑표와 클러스터, 그리고 항목이 0 이면 바닥에 다시 묻는다 | lab-environment-build | 실험대 환경 구성 | virtualization | 게시 전 | QEMU 11.1.1 의 qcow2 v3 · cluster_size 65536(기본값) · 실측은 Debian 12 genericcloud base.qcow2 한 장 · 압축은 zlib | 9465582b5d1630eb4ae7c4e078021486919bf6b6 |
|
qcow2 파일 안 — 매핑표와 클러스터, 그리고 항목이 0 이면 바닥에 다시 묻는다
qcow2 는 raw 에 매핑표 하나를 더한 것이고, 표가 가리키는 값은 호스트 주소가 아니라 파일 안의 몇 번째 바이트다. 매핑 항목이 0 이면 바닥 파일의 같은 위치를 읽는다. 오버레이와 스냅샷과 압축이 전부 그 규칙 위에서 돈다.
관계
- qcow2 파일 한 장이 담는 것 — 매핑표와 데이터 클러스터가 같은 파일 안에 있고, 파일 밖을 가리키는 것은 백킹 파일 경로 하나다 그 기록이 「파일을 다른 호스트로 들고 갔을 때 무엇이 따라가나」를 다루면서 L1·L2 표의 안쪽을 기준 밖으로 선언했다. 이 글이 그 안쪽이다.
- WiFi 전용 호스트에서 대용량 qcow2 를 옮기는 데 실제로 몇 시간이 걸리는가
그 물음은
qemu-img convert로 먼저 줄인 뒤 옮기는 편이 변환 시간까지 합쳐도 나은지를 묻는다. 무엇이 줄어드는지가 여기 있다. - 이 disk image 는 qcow2 인가 RAW 인가, 그리고 Guest 가 보는 크기와 Host 가 실제로 쓰는 크기는 얼마나 다른가
qemu-img info와du와ls가 왜 다른 수를 내는지를 그 물음이 잰다. - 설치를 안 했는데 왜 뜨는가 — 클라우드 이미지는 설치가 끝난 디스크이고 cloud-init 이 빈칸을 채운다 받아 오는 바닥 이미지가 왜 설치 없이 부팅하는지를 그 글이 대고, 이 글은 그 파일의 안쪽을 연다.
- /dev/vda 뒤에 있는 것 — qcow2 · RAW · Host block device 게스트의 가상 디스크 아래에 올 수 있는 백엔드 셋을 그 글이 가른다. 여기서는 그중 qcow2 하나만 판다.
- 5120MB 를 줬는데 353MB 를 쓰고 있었다 — 선언한 양과 실제로 드는 양 20GB 를 선언한 오버레이가 1.4GiB 로 잡힌 실측이 그 기록에 있다.
본문
raw 는 배열을 그대로 담는다
디스크는 섹터가 0번부터 늘어선 1차원 배열로 보인다. 파티션 테이블도 파일시스템도 부트로더도 전부 그 배열 안의 특정 위치에 기록된 바이트이고, 디스크 바깥에 따로 보관되는 정보가 없다. 그래서 배열을 처음부터 끝까지 그대로 파일에 쓰면 raw 이미지가 되고, 되돌린 디스크는 원본과 바이트 단위로 같아 똑같이 부팅한다.
물리 디스크로 되돌릴 필요도 없다. QEMU 에게 이 파일을 디스크로 취급하라고 하면 게스트는 진짜 디스크로 인식하고, 게스트가 섹터 1234 를 읽으면 QEMU 가 파일의 해당 오프셋을 읽어 돌려준다.
대신 안 쓴 구간까지 0 으로 가득 채워 기록하기 때문에 20GB 디스크는 20GB 파일이 된다.
qcow2 가 더하는 것은 매핑표 하나다
qcow2 는 「가상 디스크의 이 위치가 파일 안의 어디에 있는가」를 적어 둔 매핑표를 더하고, 안 쓴 구간은 아예 기록하지 않는다.
가상 디스크 20GB 실제 파일 1.4GB
0 ~ 64KB ── 매핑 ──▶ 파일 오프셋 0x50000
64 ~ 128KB ── 없음 ──▶ 기록 안 함. 읽으면 0 을 만들어 돌려줌
128 ~ 192KB ── 매핑 ──▶ 파일 오프셋 0x60000
⋮
표만 담는 것이 아니다. 표는 같은 파일 안의 오프셋을 가리키고, 가리켜진 데이터 클러스터도 그 파일 안에 함께 들어 있다. 배포본 base.qcow2 의 disk size 가 335 MiB 인데, 표만이라면 수십 KB 로 끝난다.
그리고 표에 적히는 값은 호스트 물리 디스크의 주소가 아니라 파일 안의 몇 번째 바이트다. 그래서 파일을 다른 기계로 통째로 옮겨도 표가 그대로 유효하다. 파일 밖을 가리키는 것은 백킹 파일 경로 하나뿐이라 그것만 따로 챙긴다.
클러스터 — 매핑의 최소 단위
섹터 512B 하나하나를 매핑하면 표가 너무 커지기 때문에 클러스터라는 덩어리 단위로 끊고, 그 기본값이 64KB 다.
qemu-img info /var/lib/libvirt/images/base.qcow2
image: /var/lib/libvirt/images/base.qcow2
file format: qcow2
virtual size: 3 GiB (3221225472 bytes)
disk size: 335 MiB
cluster_size: 65536
클러스터는 qcow2 파일 안에서만 쓰는 논리 단위라 물리 디스크와 무관하다. 「블록 크기」가 층마다 따로 있어서 헷갈리기 쉽다.
| 어느 층인가 | 단위 이름 | 크기 | 누가 정하나 |
|---|---|---|---|
| 물리 SSD/HDD | 섹터(물리) | 512B / 4096B | 하드웨어 |
| 호스트 파일시스템 | 블록 | 보통 4KB | 호스트 mkfs |
| qcow2 파일 | 클러스터 | 64KB (기본) | qemu-img create |
| 게스트가 보는 디스크 | 섹터(논리) | 512B | QEMU 가 흉내냄 |
| 게스트 파일시스템 | 블록 | 보통 4KB | 게스트 mkfs |
다섯 층의 크기가 서로 달라도 상관없고, 각 층이 자기 위층을 자기 단위로 쪼개 담는다. FAT 과 NTFS 도 할당 단위를 클러스터라고 부르는데, 같은 낱말이고 다른 층이다.
2단계 매핑 — L1 에서 L2 로
매핑표를 한 장으로 만들면 20GB 디스크 하나에 표만 수 MB 가 되고, 대부분이 비어 있는데도 항상 들고 있어야 한다. 그래서 두 단계로 나눈다.
게스트가 읽으려는 위치
│
├─ 상위 비트 ──▶ [ L1 표 ] ──▶ L2 표가 있는 클러스터 위치
│ │
├─ 중간 비트 ──────────────────────▶ [ L2 표 ] ──▶ 데이터 클러스터 위치
│ │
└─ 하위 16비트 ────────────────────────────────────────▶ 그 안의 몇 번째 바이트
아래 비트 나누기는 잰 값이 아니라 cluster_size 65536 에서 따라 나오는 계산이다. 클러스터가 64KB(2^16)이고 표 항목이 8바이트이므로 L2 표 하나에 항목이 65536 / 8 = 8192(2^13)개 들어간다.
| 게스트 오프셋의 어느 비트인가 | 무엇을 가리키나 |
|---|---|
| 하위 16비트 | 클러스터 안에서의 위치 |
| 그다음 13비트 | L2 표에서 몇 번째 항목인가 |
| 그 위 전부 | L1 표에서 몇 번째 항목인가 |
운영체제의 페이지 테이블과 같은 구조다. 필요한 L2 표만 만들면 되므로 안 쓴 영역은 L1 항목이 0 인 채로 끝난다.
항목이 0 이면 바닥에 다시 묻는다
오버레이가 성립하는 규칙이 여기 있다.
| L2 항목이 | 바닥 파일이 | 읽으면 무엇이 돌아오나 |
|---|---|---|
| 값이 있음 | (무관) | 이 파일의 그 위치를 읽는다 |
| 0 | 없음 | 0 으로 채운 64KB 를 만들어 돌려준다 |
| 0 | 있음 | 바닥 파일의 같은 위치를 읽는다 |
그래서 kc-lab-1.qcow2 는 자기가 바꾼 클러스터만 들고 있고 나머지는 전부 base.qcow2 를 본다. 20GB 를 선언한 파일이 1.4GB 로 잡히는 까닭이 이 규칙에 있다.
바닥 경로는 헤더에 backing_file_offset 으로 자리를 잡고 문자열로 들어가서, 바닥을 옮기거나 이름을 바꾸면 게스트가 부팅하지 못한다. 오버레이만 다른 기계로 복사하면 안 되는 까닭도 여기 있고, 경로를 절대경로로 주는 것도 같은 이유다.
헤더에는 매직값 QFI\xfb, 버전, cluster_bits, 가상 디스크 크기, l1_table_offset, refcount_table_offset, backing_file_offset 이 들어간다.
┌──────────┬────────────┬──────────┬─────────────┬──────────────┐
│ 헤더 │ refcount 표 │ L1 표 │ L2 표들 │ 데이터 클러스터 │
└──────────┴────────────┴──────────┴─────────────┴──────────────┘
refcount 가 스냅샷을 순식간에 만든다
qcow2 는 클러스터마다 참조 횟수를 따로 관리한다.
refcount = 1 → 나만 쓰는 클러스터. 그냥 덮어쓴다
refcount > 1 → 스냅샷과 공유 중. 쓰기 전에 복사본을 만들고 거기에 쓴다
이것이 copy-on-write 다. virsh snapshot-create-as 로 스냅샷을 뜨면 데이터를 복사하지 않고 refcount 만 올린다. 그래서 스냅샷이 순식간에 찍히고 그 뒤로 바뀌는 부분만 용량을 먹는다.
배포용 이미지는 이미 압축돼 있다
qcow2 는 클러스터 단위 zlib 압축을 지원하고 배포용 클라우드 이미지는 그것을 켜서 만든다. 희소 저장만으로는 크기가 설명되지 않는다.
qemu-img map --output=json /var/lib/libvirt/images/base.qcow2
{'start': 0, 'length': 65536, 'data': True, 'compressed': True} ← 압축된 클러스터
{'start': 65536, 'length': 983040, 'data': False, 'zero': True} ← 구멍
{'start': 1048576, 'length': 65536, 'data': True, 'compressed': True}
compressed 구간: 606개 / 전체 1236개
| 3 GiB 가 324 MiB 가 되는 이유 | 이 이미지에서 |
|---|---|
| ① 안 쓴 공간을 안 적음 (희소) | 3 GiB 중 2.01 GiB 가 구멍 |
| ② 쓴 부분을 zlib 압축 | 남은 1010 MiB → 324 MiB |
| ③ genericcloud 자체가 작음 | GUI·문서·물리 하드웨어 드라이버 없음 |
압축도 우리가 한 것이 아니라 Debian 이 배포 시점에 한 것이다. qemu-img convert -c 를 돌린 적이 없는데도 compressed: True 가 나온다.
압축 클러스터는 읽을 때 자동으로 풀린다. 게스트가 그 클러스터에 쓰면 압축하지 않은 형태로 새로 할당하므로, 오버레이에 쌓이는 것은 비압축 클러스터다. 바닥은 작은데 오버레이가 상대적으로 커 보이는 까닭 가운데 하나가 여기 있다.
압축 단위가 클러스터이므로 1바이트를 읽어도 그 클러스터 전체를 풀어야 한다. 그래서 쓰기가 잦은 디스크에는 압축을 걸지 않는다.
backing chain 은 Docker 레이어와 같은 생각이고 쓰임이 다르다
체인은 여러 겹이 될 수 있다.
base.qcow2 ← k3s-golden.qcow2 ← kc-lab-1.qcow2
(배포본) (k3s 설치까지) (실험 중 변경분)
| 무엇이 다른가 | Docker | qcow2 backing chain |
|---|---|---|
| 언제 쌓나 | 빌드 시점에 의도적으로 | 주로 런타임 파생 |
| 층의 정체성 | 레이어마다 다이제스트 | 경로 문자열 |
| 배포 단위 | 레이어 tar 여러 개 + 매니페스트 | 파일들 (바닥까지 전부 필요) |
| 층이 깊어지면 | 읽기 성능 영향 적음 | 읽을 때마다 사슬을 거슬러 올라간다 |
체인이 깊으면 읽기가 느려진다. 클러스터가 어느 층에 있는지 찾으려면 L2 항목이 0 일 때마다 한 층 아래로 내려가기 때문이다. 실험대에서 층을 두세 겹 넘게 쌓지 않는 까닭이 여기 있다.
| 사슬을 끊는 명령 | 무엇을 하나 | 결과 |
|---|---|---|
qemu-img commit <오버레이> |
오버레이의 변경분을 바닥에 병합 | 바닥이 바뀐다. 다른 오버레이가 있으면 그것들이 깨진다 |
qemu-img convert -O qcow2 <오버레이> <새파일> |
사슬 전체를 읽어 단일 파일로 평탄화 | 바닥과 무관해진다. 용량은 늘어난다 |
다른 기계로 게스트를 보낼 때 오버레이만 복사하면 바닥이 없어 부팅하지 못하므로, 옮길 때는 convert 로 평탄화해 파일 하나로 완결시킨다.
qemu-img 는 VM 을 돌리지 않는다
이름이 비슷한 두 도구가 하는 일이 다르다. qemu-img 는 디스크 이미지 파일을 만들고 읽고 변환하는 도구라 VM 이 꺼져 있어도 돌고 애초에 VM 이 없어도 된다. qemu-system-x86_64 는 가상 머신을 실행한다.
virt-install --disk size=20,backing_store=... 이 내부적으로 qemu-img create 를 부른다. 골든 이미지를 만들거나 오버레이만 초기화할 때 이 도구를 직접 쓴다.
포맷을 알아보는 것도 헤더의 매직값이다. qemu-img info 가 file format: raw 로 읽으면 그 파일은 qcow2 가 아니고, 바닥 이미지를 받다가 끊겨 HTML 오류 페이지를 저장했을 때 그렇게 나온다.
이 설명이 걸려 있는 것
실측은 배포본 base.qcow2 한 장에 대한 것이고 qemu-img info 와 qemu-img map --output=json 의 출력이 근거다. 오버레이 쪽 매핑을 같은 명령으로 떠 본 기록은 없다.
비트 나누기는 cluster_size 65536 에서 따라 나오는 계산이라 다른 클러스터 크기로 만든 이미지에서는 숫자가 달라진다.
압축은 이 이미지에서 zlib 로 관측됐고 포맷 자체는 zstd 도 지원한다. 이 실험대에서 zstd 로 만든 이미지를 본 적은 없다.
같은 절이 이 파일의 크기를 두 수로 적는다. qemu-img info 의 disk size 는 335 MiB 인데 압축 내역 표는 324 MiB 로 앉는다. 두 수의 차이를 가를 출력은 이 저장소에 없다.