기록 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>
224 lines
15 KiB
Markdown
224 lines
15 KiB
Markdown
---
|
|
kind: CONCEPT
|
|
slug: inside-a-qcow2-file-the-mapping-table-and-its-clusters
|
|
title: qcow2 파일 안 — 매핑표와 클러스터, 그리고 항목이 0 이면 바닥에 다시 묻는다
|
|
topic: lab-environment-build
|
|
topicName: 실험대 환경 구성
|
|
project: virtualization
|
|
status: 게시 전
|
|
basisVersion: QEMU 11.1.1 의 qcow2 v3 · cluster_size 65536(기본값) · 실측은 Debian 12 genericcloud base.qcow2 한 장 · 압축은 zlib
|
|
sourceRevision: 9465582b5d1630eb4ae7c4e078021486919bf6b6
|
|
source:
|
|
- final/document.md#231-qcow2-파일-내부는-어떻게-생겼나-매핑표가-전부다
|
|
- final/document.md#230-디스크-이미지를-"복사한다"는-것의-실제-원리
|
|
- final/document.md#228-qcow2와-backing-store-오버레이
|
|
- final/document.md#232-qemu-img-와-qemu-system-x86_64-는-다른-도구다
|
|
- final/document.md#233-오버레이는-docker-레이어와-같은-아이디어다
|
|
- final/document.md#234-그래서-마이그레이션과-스냅샷이-된다
|
|
---
|
|
|
|
# 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 로 잡힌 실측이 그 기록에 있다.
|
|
|
|
## 본문
|
|
|
|
<!-- body:start -->
|
|
|
|
## raw 는 배열을 그대로 담는다
|
|
|
|
디스크는 섹터가 0번부터 늘어선 1차원 배열로 보인다. 파티션 테이블도 파일시스템도 부트로더도 전부 그 배열 안의 특정 위치에 기록된 바이트이고, 디스크 바깥에 따로 보관되는 정보가 없다. 그래서 배열을 처음부터 끝까지 그대로 파일에 쓰면 raw 이미지가 되고, 되돌린 디스크는 원본과 바이트 단위로 같아 똑같이 부팅한다.
|
|
|
|
물리 디스크로 되돌릴 필요도 없다. QEMU 에게 이 파일을 디스크로 취급하라고 하면 게스트는 진짜 디스크로 인식하고, 게스트가 섹터 1234 를 읽으면 QEMU 가 파일의 해당 오프셋을 읽어 돌려준다.
|
|
|
|
대신 안 쓴 구간까지 0 으로 가득 채워 기록하기 때문에 20GB 디스크는 20GB 파일이 된다.
|
|
|
|
## qcow2 가 더하는 것은 매핑표 하나다
|
|
|
|
qcow2 는 「가상 디스크의 이 위치가 파일 안의 어디에 있는가」를 적어 둔 매핑표를 더하고, 안 쓴 구간은 아예 기록하지 않는다.
|
|
|
|
```text label="가상 디스크의 위치를 파일 안의 오프셋으로 옮긴다"
|
|
가상 디스크 20GB 실제 파일 1.4GB
|
|
0 ~ 64KB ── 매핑 ──▶ 파일 오프셋 0x50000
|
|
64 ~ 128KB ── 없음 ──▶ 기록 안 함. 읽으면 0 을 만들어 돌려줌
|
|
128 ~ 192KB ── 매핑 ──▶ 파일 오프셋 0x60000
|
|
⋮
|
|
```
|
|
|
|
표만 담는 것이 아니다. 표는 같은 파일 안의 오프셋을 가리키고, 가리켜진 데이터 클러스터도 그 파일 안에 함께 들어 있다. 배포본 `base.qcow2` 의 `disk size` 가 335 MiB 인데, 표만이라면 수십 KB 로 끝난다.
|
|
|
|
그리고 표에 적히는 값은 호스트 물리 디스크의 주소가 아니라 파일 안의 몇 번째 바이트다. 그래서 파일을 다른 기계로 통째로 옮겨도 표가 그대로 유효하다. 파일 밖을 가리키는 것은 백킹 파일 경로 하나뿐이라 그것만 따로 챙긴다.
|
|
|
|
## 클러스터 — 매핑의 최소 단위
|
|
|
|
섹터 512B 하나하나를 매핑하면 표가 너무 커지기 때문에 클러스터라는 덩어리 단위로 끊고, 그 기본값이 64KB 다.
|
|
|
|
```bash label="바닥 이미지의 포맷과 크기를 읽는다"
|
|
qemu-img info /var/lib/libvirt/images/base.qcow2
|
|
```
|
|
|
|
```text label="실측 — Debian 12 genericcloud 배포본"
|
|
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 가 되고, 대부분이 비어 있는데도 항상 들고 있어야 한다. 그래서 두 단계로 나눈다.
|
|
|
|
```text label="게스트 오프셋이 세 조각으로 쓰인다"
|
|
게스트가 읽으려는 위치
|
|
│
|
|
├─ 상위 비트 ──▶ [ 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` 이 들어간다.
|
|
|
|
```text label="파일 앞에서부터의 배치"
|
|
┌──────────┬────────────┬──────────┬─────────────┬──────────────┐
|
|
│ 헤더 │ refcount 표 │ L1 표 │ L2 표들 │ 데이터 클러스터 │
|
|
└──────────┴────────────┴──────────┴─────────────┴──────────────┘
|
|
```
|
|
|
|
## refcount 가 스냅샷을 순식간에 만든다
|
|
|
|
qcow2 는 클러스터마다 참조 횟수를 따로 관리한다.
|
|
|
|
```text label="refcount 가 쓰기를 가른다"
|
|
refcount = 1 → 나만 쓰는 클러스터. 그냥 덮어쓴다
|
|
refcount > 1 → 스냅샷과 공유 중. 쓰기 전에 복사본을 만들고 거기에 쓴다
|
|
```
|
|
|
|
이것이 copy-on-write 다. `virsh snapshot-create-as` 로 스냅샷을 뜨면 데이터를 복사하지 않고 refcount 만 올린다. 그래서 스냅샷이 순식간에 찍히고 그 뒤로 바뀌는 부분만 용량을 먹는다.
|
|
|
|
## 배포용 이미지는 이미 압축돼 있다
|
|
|
|
qcow2 는 클러스터 단위 zlib 압축을 지원하고 배포용 클라우드 이미지는 그것을 켜서 만든다. 희소 저장만으로는 크기가 설명되지 않는다.
|
|
|
|
```bash label="어느 구간이 실제로 할당됐는지 본다"
|
|
qemu-img map --output=json /var/lib/libvirt/images/base.qcow2
|
|
```
|
|
|
|
```text label="실측 — Debian 12 genericcloud"
|
|
{'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 레이어와 같은 생각이고 쓰임이 다르다
|
|
|
|
체인은 여러 겹이 될 수 있다.
|
|
|
|
```text label="세 겹으로 쌓은 예"
|
|
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 로 앉는다. 두 수의 차이를 가를 출력은 이 저장소에 없다.
|
|
|
|
<!-- body:end -->
|