feat(pipeline): keycloak-session-store 25편·virtualization 59편을 S3→S5→S6 으로 돌린다

기록 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>
This commit is contained in:
DongHyeonka
2026-09-17 11:01:55 +09:00
co-authored by Claude Opus 5
parent d473609e0a
commit 2109f726fe
574 changed files with 159654 additions and 1551 deletions
@@ -0,0 +1,175 @@
---
kind: CONCEPT
slug: a-cloud-image-is-an-installed-disk-and-cloud-init-fills-the-blanks
title: 설치를 안 했는데 왜 뜨는가 — 클라우드 이미지는 설치가 끝난 디스크이고 cloud-init 이 빈칸을 채운다
topic: lab-environment-build
topicName: 실험대 환경 구성
project: virtualization
status: 게시 전
basisVersion: Debian 12 genericcloud 위의 cloud-init 22.4.2 · NoCloud 데이터소스 · 호스트는 QEMU 11.1.1 · libvirt 12.7.0
sourceRevision: 9465582b5d1630eb4ae7c4e078021486919bf6b6
source:
- final/document.md#229-왜-os를-설치하지-않아도-vm이-뜨는가
- final/document.md#236-클라우드-이미지와-cloud-init
- final/document.md#235-multipass-virt-install-virsh-무엇이-다른가
- final/document.md#214-전체-구조-한눈에-보기
- final/document.md#215-vm-한-대의-디스크-구성
- final/document.md#216-설정-파일이-게스트에-도달하는-경로
- final/document.md#217-부팅할-때-일어나는-일
---
# 설치를 안 했는데 왜 뜨는가 — 클라우드 이미지는 설치가 끝난 디스크이고 cloud-init 이 빈칸을 채운다
클라우드 이미지는 배포자가 설치를 한 번 끝내 놓은 디스크 파일이라 게스트를 만들 때 설치 단계가 없다. 대신 hostname 과 SSH 호스트키 같은 고유값을 비워 둔 채 배포하고, cloud-init 이 첫 부팅에 그 빈칸을 채운다.
## 관계
- **cloud-init 시드로 게스트 세 대를 만들고 SSH 가 키로 붙게 한다**
그 절차가 「base 이미지를 받아 오버레이로 게스트 셋을 만든다」로 시작한다. 왜 받기만 하고 설치하지 않는지를 이 글이 댄다.
- **cloud-init 이 안 도는 원인은 넷인데 증상은 「SSH 가 안 붙는다」 하나였다**
그 원인 넷이 전부 「시드가 안 읽혔다」로 모인다. 시드가 무엇이고 어느 단계에서 읽히는지가 여기 있다.
- **qcow2 파일 안 — 매핑표와 클러스터, 그리고 항목이 0 이면 바닥에 다시 묻는다**
받은 파일이 어떻게 생겼길래 3 GiB 짜리가 335MiB 인지를 그 글이 연다.
- **qcow2 파일 한 장이 담는 것 — 매핑표와 데이터 클러스터가 같은 파일 안에 있고, 파일 밖을 가리키는 것은 백킹 파일 경로 하나다**
받은 바닥 이미지를 다른 호스트로 들고 갈 때 무엇이 따라가는지를 그 글이 가른다.
- **가이드대로 다시 쳐서 이 실험대가 같은 상태로 서는가**
게스트를 지우고 다시 만드는 비용이 낮다는 것이 이 선택의 근거인데, 그 비용을 실제로 재는 것은 그 물음이다.
## 본문
<!-- body:start -->
## 설치 프로그램이 만드는 것은 결국 파일 하나의 내용이다
VM 의 디스크는 호스트의 파일 하나다. `kc-lab-1.qcow2` 라는 파일이 게스트에게는 20GB 하드디스크로 보이고, 게스트는 그것이 파일인 줄 모르는데 QEMU 가 디스크인 척해 주기 때문이다.
그러면 「OS 를 설치한다」가 무슨 작업인지 풀어 본다.
```text label="설치 프로그램이 빈 디스크에 하는 일"
빈 디스크
│ 설치 프로그램이 수행하는 일
├─ 파티션 테이블 작성
├─ 파일시스템 생성 (ext4, vfat …)
├─ 패키지 수천 개를 풀어 배치
├─ 부트로더 기록
└─ 초기 설정 작성
"부팅 가능한 특정 바이트 배열" 상태의 디스크
```
설치 과정은 수단이고 목적은 마지막 줄의 상태이며, 그 상태는 파일 하나의 내용으로 남는다. Debian 과 Ubuntu 는 자기 빌드 서버에서 이 설치를 한 번 수행하고 완성된 디스크를 qcow2 파일로 떠서 공개하고, 우리는 그 파일을 내려받아 붙인다. 소스를 직접 컴파일하는 대신 이미 빌드된 바이너리를 받아 쓰는 것과 같아서, 결과물은 같고 시간만 아낀다.
## 그대로 복제하면 식별자가 겹치므로 일부러 비워 둔다
디스크가 바이트 단위로 같으면 안에 적힌 식별자도 같아진다.
| 값이 겹치면 | 무엇이 깨지나 |
|---|---|
| machine-id | systemd/DHCP가 두 기계를 같은 기계로 오인 |
| SSH 호스트 키 | 두 서버가 같은 신원을 주장 → MITM 탐지 무력화 |
| 파일시스템 UUID | `/etc/fstab`이 엉뚱한 디스크를 마운트 |
| hostname | 로그·클러스터에서 노드 구분 불가 |
그래서 클라우드 이미지는 이 값들을 비워 둔 채 배포된다.
| 배포본이 비워 두는 것 | 첫 부팅 전 상태 |
|---|---|
| hostname | 미설정 (`localhost`) |
| 사용자 계정 | 없음 |
| 비밀번호 | 없음 |
| SSH 호스트 키 | 없음 — 첫 부팅에 새로 생성 |
| machine-id | 비어 있음 |
cloud-init 이 이 빈칸을 첫 부팅에 채우는 장치다. `user-data` 라는 YAML 을 읽어 계정을 만들고 SSH 키를 등록하고 패키지를 깔고 임의의 스크립트를 실행한다.
```text label="설치와 개인화를 누가 언제 하나"
전통적 설치 : [설치 + 개인화]를 부팅 전에 대화형으로 수행
클라우드 : [설치]는 배포자가 미리 완료
[개인화]만 첫 부팅에 cloud-init 이 자동 수행
```
## 격리는 실행 시점에 KVM/QEMU 가 만든다
「설치를 안 했으니 격리가 약한가」는 오해다. 격리는 실행 시점에 KVM/QEMU 가 만들지 설치 과정이 만들지 않는다. 게스트는 자기 커널로 부팅하고 자기 메모리 공간에서 돌며, 디스크 내용을 어떻게 얻었는지와 무관하다.
## 이 실험대가 cloud-init 을 고른 까닭은 재생성 비용이다
게스트에 계정과 키를 심는 방법은 셋이다.
| 계정과 키를 심는 방법 | 무엇이 드나 | 다시 만들 때 |
|---|---|---|
| ISO로 정식 설치 | VM마다 대화형 설치 | 매번 처음부터 반복 |
| 이미지를 미리 개조 (`virt-customize`, `guestfish`) | libguestfs 설치 + 이미지 마운트 | 개조본을 따로 관리해야 함 |
| cloud-init | YAML 한 장 | 명령 한 줄 |
이 실험대는 `virsh destroy` 와 오버레이 삭제로 게스트를 반복해서 지우고 다시 만드는 것이 실험 그 자체다. 재생성 비용이 낮아야 실험이 굴러간다. 두 노드가 바이트 단위로 같은 초기 상태로 만들어져야 한다는 조건도 붙는다. 손으로 설치하면 미묘하게 달라지고 그 차이가 실험 결과를 오염시킨다.
이미지 종류도 그 축에서 고른다. Debian 은 같은 판을 여러 변종으로 배포한다.
| Debian 변종 | 어디에 쓰나 |
|---|---|
| `genericcloud` | 가상화 환경 전용. virtio 드라이버만 담아 가볍다 → KVM에는 이걸 |
| `generic` | 베어메탈 포함. 드라이버가 많아 더 크다 |
| `nocloud` | cloud-init 없이 기본 계정이 박혀 있는 변종 |
`genericcloud` 가 가벼운 까닭은 물리 하드웨어 드라이버를 뺐다는 데 있고, 시드를 SATA CD-ROM 으로 붙이면 게스트가 그 장치를 보지 못하는 함정도 같은 이유로 생긴다. `virt-install --cloud-init` 은 시드를 `<target dev='sda' bus='sata'/>`, 즉 SATA CD-ROM 으로 붙인다. 그래서 이 조합에서는 게스트가 시드를 아예 장치로 보지 못한다. §237 이 성공 판정을 `virsh domblklist` 의 장치 이름으로 잡아 둔 까닭이 여기 있다 — 시드가 `vdb` 로 보여야 하고, `sda` 로 보이면 게스트가 읽지 못한다.
## 게스트에게 디스크는 두 장이고, 시드는 OS 가 아니다
가장 자주 하는 오해는 시드 ISO 를 OS 이미지로 아는 것이다. 시드는 설정 데이터만 담은 370KB 짜리 별도 디스크다.
| 게스트가 보는 디스크 | 크기 | 무엇이 들었나 |
|---|---|---|
| `vda` | 20G | ext4 루트. 여기서 부팅한다 |
| `vdb` | 370K | `LABEL=CIDATA` 인 iso9660. 읽기 전용이고 마운트되지 않는다 |
`vda` 는 `base.qcow2` 위의 오버레이라 바닥 한 벌을 두 게스트가 공유하고 각자 변경분만 쌓는다. `vdb` 는 원본 YAML 을 구워 만든 ISO 를 풀에 올린 것이다.
```text label="같은 설정이 존재하는 세 곳"
kc-lab-1.yaml ──①──▶ seed-kc-lab-1.iso ──②③──▶ /var/lib/libvirt/images/seed-kc-lab-1.iso
```
①은 `xorrisofs` 가 굽는 단계이고 여기서 파일 이름이 ISO 안의 `/user-data` 와 `/meta-data` 로 바뀌는데, 두 이름이 정확해야 인식된다. ②와 ③은 `virsh vol-create-as` 로 자리를 잡고 `virsh vol-upload` 로 내용을 붓는 단계다. 홈 디렉터리가 `700` 이라 qemu 가 못 읽어서 풀에 둔다.
같은 내용이 세 곳에 있어서 원본만 고치면 VM 에 반영되지 않는다. 셋을 한 번에 맞추는 것이 `deploy/lab/scripts/rebuild-seed.sh` 다.
## 부팅 다섯 단계 가운데 3번이 실패하면 조용히 끝난다
```text label="첫 부팅에 일어나는 다섯 단계"
1. QEMU 가 vda 에서 부팅 → Debian 커널 시작
2. cloud-init 서비스 기동 → 모든 블록 장치를 스캔
3. vdb 에서 LABEL=CIDATA 발견 → 잠깐 마운트
4. user-data / meta-data 읽기 → 사용자·hostname·sudo·패키지 적용
5. 언마운트 → SSH 로그인 가능
```
cloud-init 은 `cidata` 레이블을 가진 블록 장치를 찾지 못하면 데이터소스 없이 종료하기 때문에, 3번이 실패하면 hostname 이 `localhost` 로 남고 사용자가 생성되지 않는다. 오류 메시지는 어디에도 남지 않는다. `virsh screenshot` 으로 로그인 프롬프트만 봐도 즉시 판정할 수 있다.
`user-data` 파일은 `#cloud-config` 로 시작해야 한다. 이 첫 줄이 없으면 cloud-init 이 YAML 로 인식하지 못하고 무시하며, 증상은 「부팅은 됐는데 계정이 없다」로 나타난다.
§236 은 비상 접근 수단을 남기라는 항목 하나를 「실제로 겪은 교훈」이라고 따로 적어 두었다. `ssh_pwauth: false` 에 키 인증만 걸어 둔 상태로 3번이 실패하면 사용자가 생성되지 않아 키도 비밀번호도 없고, 콘솔에 붙어도 로그인할 수 없다. 실패 원인을 적어 둔 `/var/log/cloud-init.log` 를 읽을 방법이 그래서 사라지고, VM 을 지우고 다시 만드는 것 말고 남는 선택지가 없어진다. 콘솔 로그인용 비밀번호를 하나 넣어 두면 그 골목을 피하는데, 콘솔 로그인은 sshd 를 거치지 않으므로 `ssh_pwauth: false` 는 그대로 둔다.
## multipass 가 감춰 주던 것이 이 단계들이다
multipass 와 virt-install 과 virsh 는 서로 배포판이 다른 도구가 아니라 계층이 다른 도구다. multipass 도 리눅스에서는 QEMU/KVM 위에서 돌고, `multipass set local.driver=libvirt` 로 libvirt 를 쓰게 할 수도 있다.
| multipass 가 자동으로 | 이번에 우리가 한 것 |
|---|---|
| Ubuntu 클라우드 이미지 자동 다운로드·캐시 | `curl`로 base.qcow2 내려받기 |
| cloud-init user-data 자동 생성 | `kc-lab-1.yaml` 작성 |
| 시드 ISO 생성·연결 | `xorrisofs` + virtio 디스크 연결 |
| SSH 키 자동 생성·주입 | `ssh-keygen` + `ssh_authorized_keys` |
| 네트워크·DHCP 구성 | `virbr0` + DHCP 예약 |
| `multipass shell` 로 즉시 접속 | `~/.ssh/config` 별칭 작성 |
multipass 를 쓰지 않은 까닭은 배포판이 아니라 범위에 있다. multipass 는 Ubuntu 이미지만 공식 지원해서 Debian 게스트를 띄울 수 없다. 그리고 이 실험대는 `virsh destroy` 로 노드를 죽이고 NetworkPolicy 로 포트를 막고 스냅샷으로 되돌리는 저수준 제어가 실험의 본체라 관리 계층이 필요했다.
## 이 설명이 걸려 있는 판올림
게스트에 깔린 cloud-init 은 22.4.2 이고 데이터소스는 NoCloud 다. 판올림이 바뀌면 같은 YAML 에 대한 스키마 검사기의 판정이 달라진다.
크기 값은 두 날짜에서 왔다. 시드 ISO 370KB 와 게스트가 보는 `vda` 20G · `vdb` 370K 는 2026-09-03 실측이고, 같은 절이 `base.qcow2` 를 333M 로 적는다. 2026-09-10 에 다시 잰 `disk size` 는 335MiB 다.
파일 안이 어떻게 생겼길래 3 GiB 가 335MiB 로 앉는지는 이 글이 다루지 않는다. 시드를 굽는 세 명령의 옵션별 뜻도 마찬가지로 절차 쪽에 있다.
<!-- body:end -->
@@ -0,0 +1,223 @@
---
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 -->
@@ -0,0 +1,93 @@
---
kind: CONCEPT
slug: what-a-qcow2-file-carries
title: qcow2 파일 한 장이 담는 것 — 매핑표와 데이터 클러스터가 같은 파일 안에 있고, 파일 밖을 가리키는 것은 백킹 파일 경로 하나다
topic: lab-environment-build
topicName: 실험대 환경 구성
project: virtualization
status: 게시 전
basisVersion: QEMU 11.1.1 · libvirt 12.7.0 위의 qcow2 · 게스트는 Debian 12 genericcloud · 크기 계산은 클러스터 64KiB · L2 항목 8B 기준
sourceRevision: 9465582b5d1630eb4ae7c4e078021486919bf6b6
source:
- final/document.md#181-qcow2-가-담는-것과-담지-않는-것
- final/document.md#178-이-부의-출처와-범위
---
# qcow2 파일 한 장이 담는 것 — 매핑표와 데이터 클러스터가 같은 파일 안에 있고, 파일 밖을 가리키는 것은 백킹 파일 경로 하나다
qcow2 한 장에는 데이터 클러스터와 그것이 파일의 어느 오프셋에 놓였는지 적은 매핑표가 들어 있다. 파일 밖을 가리키는 것은 백킹 파일 경로 하나다. 실행 중인 프로세스와 안 내려간 dirty page, vCPU·RAM·NIC 를 적어 둔 VM 정의 XML 은 없다. 20GB 이미지가 2GB 로 보이는 것도 압축이 아니라 쓴 블록만 있기 때문이다.
## 관계
- **/dev/vda 뒤에 있는 것 — qcow2 · RAW · Host block device**
게스트의 가상 디스크 아래에 qcow2 와 RAW, 호스트 블록 장치 가운데 무엇이 올 수 있는지를 그 글이 가른다. 이 글은 그중 qcow2 갈래 하나만 이식 쪽으로 이어 적는다.
- **Guest 의 write() 가 virtqueue 에 실리기까지 — VFS · Filesystem · Page Cache · Block Layer · virtio-blk**
게스트가 쓴 내용이 언제 디스크에 닿는지를 그 글이 설명한다. 아직 내려가지 않은 페이지 캐시가 qcow2 에 없는 이유가 거기에서 나온다.
- **이 disk image 는 qcow2 인가 RAW 인가, 그리고 Guest 가 보는 크기와 Host 가 실제로 쓰는 크기는 얼마나 다른가**
이 글은 두 크기가 갈린다는 것까지만 적었다. 이 호스트의 이미지가 실제로 얼마인지는 그 물음이 확정한다.
- **virsh save 의 RAM 덤프 크기와 소요 시간은 할당 메모리에 어떻게 비례하는가**
실행 상태를 파일로 내보내면 RAM 크기만큼 파일이 더 생긴다고만 적었다. 이 호스트에서 실제로 얼마가 되고 얼마나 걸리는지는 재지 않았다.
- **WiFi 전용 호스트에서 대용량 qcow2 를 옮기는 데 실제로 몇 시간이 걸리는가**
희소 할당으로 실제 파일이 얼마나 커져 있는지가 그 시간을 정한다. 이 실험대에는 이더넷이 없어 WiFi 로만 옮긴다.
## 본문
<!-- body:start -->
## 파일 한 장 안에 들어 있는 것
qcow2 는 가상 디스크 한 장의 블록을 담는 파일이다. 블록의 내용이 들어가는 데이터 클러스터와, 그 클러스터가 파일의 어느 오프셋에 놓였는지 적은 매핑표가 같은 파일 안에 함께 있다.
매핑표에 적히는 값은 호스트의 물리 주소가 아니라 파일 안의 오프셋이어서, 파일을 다른 디렉터리로 옮기든 다른 호스트로 복사하든 표가 가리키는 곳은 달라지지 않는다. 파일을 통째로 옮기기만 하면 매핑은 그대로 유효하다.
파일 밖을 가리키는 것은 백킹 파일 경로 하나다. qcow2 헤더에 절대경로 문자열로 적혀 있고, 이식할 때 확인할 외부 참조도 그 하나다.
이 실험대의 게스트 디스크가 전부 그 경우다. 세 대를 다 base 이미지 위의 오버레이(`backing_store=`)로 만들었기 때문에, 이식할 때 확인할 그 참조 하나가 세 장 모두에 들어 있다.
## 복사하면 따라가는 것과 따라가지 않는 것
게스트가 이미 디스크에 써 둔 것은 전부 따라간다. 파일시스템도 설치한 패키지도 설정과 DB 파일도 데이터 클러스터로 파일 안에 들어가 있다. 컨테이너 이미지와 apt 캐시처럼 디스크에 쓰인 캐시도, `machine-id` 와 SSH 호스트키도 게스트 파일시스템 위의 파일이다. 내부 스냅샷은 qcow2 가 자기 안에 보관한다.
따라가지 않는 것은 메모리에 있거나 게스트 디스크 밖에 있다. 실행 중인 프로세스는 프로세스 번호와 열린 파일 기술자, 소켓, JVM 힙이 전부 메모리에 있어서 디스크에 흔적이 없고, 페이지 캐시와 아직 디스크로 내려가지 않은 dirty page 도 같다. VM 정의 XML 에는 vCPU 개수와 RAM 크기, NIC(Network Interface Card, 네트워크 인터페이스 카드) 구성, machine type, CPU 모델이 적혀 있다. 이 파일은 게스트 디스크 밖에 있는 호스트 쪽 구성이라 이미지를 정확히 복사해도 함께 오지 않는다. UEFI NVRAM 과 백킹 파일, 그 밖의 호스트 쪽 구성도 같은 이유로 따라가지 않는다.
| 따라가는 것 | 따라가지 않는 것 |
|---|---|
| 파일시스템 전체, 설치 패키지, 설정, DB 파일 | 실행 중인 프로세스 — PID·FD·소켓·JVM 힙 |
| 디스크에 쓰인 캐시(컨테이너 이미지, apt 캐시) | 페이지 캐시와 안 내려간 dirty page |
| `machine-id`, SSH 호스트키 | VM 정의 XML — vCPU·RAM·NIC·machine type·CPU 모델 |
| 내부 스냅샷 | UEFI NVRAM, 백킹 파일, 호스트 쪽 구성 |
## 희소 할당이지 압축이 아니다
20GB 로 만든 이미지의 파일 크기가 2GB 로 보이는 것은 압축 때문이 아니라 쓴 블록만 파일에 존재하기 때문이다. 게스트가 계속 쓰면 파일도 그만큼 커지고, 1TB 를 채우면 1TB 파일이 된다.
이 실험대에서 그 성질이 눈에 보인 곳은 만드는 쪽이다. 10GB 로 만든 엣지 게스트의 생성 출력에서 `Allocating``00:00` 에 끝났다 — 오버레이라 10GB 를 실제로 쓰지 않는다.
매핑표가 차지하는 몫은 작다. 클러스터 64KiB 에 L2 항목 8B 를 기준으로 하면 메타데이터 오버헤드는 0.02% 미만이고, 1TiB 당 약 160MiB 다.
커지기만 하고 줄지는 않는다. 게스트 안에서 파일을 지워도 클러스터가 이미 할당된 상태이기 때문에 qcow2 파일 크기는 그대로다. 줄이려면 게스트에서 `fstrim` 을 돌리거나(디스크에 `discard='unmap'` 이 있어야 한다) 호스트에서 `qemu-img convert` 로 다시 쓴다.
여기 적은 크기는 전부 qcow2 형식의 성질이고 이 호스트에서 잰 값이 아니다. 20GB 에 2GB 도, 1TiB 당 160MiB 도 그렇다. 이 실험대의 게스트 세 대가 실제로 얼마를 쓰고 있는지는 아직 확인하지 않았다.
## 실행 상태는 파일에 없다
실행 상태까지 옮기려면 qcow2 복사로는 안 되고, 방법이 둘이다.
`virsh save` 는 게스트를 멈추고 메모리 내용을 파일로 내보낸다. 옮기는 동안 게스트는 내려가 있고, 할당한 RAM 만큼 저장 공간이 더 필요하다. 파일을 복사한 뒤 `restore` 로 다시 살린다.
`virsh migrate --live --copy-storage-all` 은 게스트를 켠 채로 옮긴다. 대신 두 호스트가 동시에 떠서 libvirt 끼리 붙어 있어야 하고, 게스트가 보던 CPU 모델이 옮겨 갈 호스트에서도 성립해야 한다.
| 어떻게 옮기나 | 무엇을 요구하나 |
|---|---|
| `virsh save` 로 내보내고 복사한 뒤 `restore` | VM 이 멈추고, RAM 크기만큼 파일이 더 생긴다 |
| `virsh migrate --live --copy-storage-all` | 두 호스트의 libvirt 가 붙어야 하고 CPU 모델이 호환돼야 한다 |
두 방법 다 이 호스트에서 돌려 보지 않았다. `virsh save` 의 덤프 크기와 걸리는 시간, WiFi 로 대용량 qcow2 를 옮기는 시간은 둘 다 재지 않았고 각각 열린 질문으로 걸어 두었다.
## 이 글이 다루지 않는 것
매핑표가 파일 안에서 어떤 구조로 나뉘는지는 이 글에서 다루지 않는다. 이식에 필요한 것은 표에 적히는 값이 파일 안의 오프셋이라는 데까지다. 그 아래를 미룬 것은 이 기록이 아니라 원본 문서다 — 제4부가 스토리지 경로를 세우면서 「qcow2 내부 L1/L2 table, blk-mq tag allocator, NVMe submission/completion queue 같은 세부 구현은 필요 시 별도 문서에서 다룬다」고 적었고, 이 글은 그 경계를 그대로 물려받았다.
온프렘 이미지를 클라우드로 올리는 절차도 이 글 밖이다. 원본 문서가 그 부분을 코드 관측이 아닌 외부 지식이라고 스스로 표시해 두었고 이 실험대에서 한 번도 해 보지 않아서, 원본 문서에만 두고 이 기록으로 옮기지 않았다.
<!-- body:end -->