기록 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>
17 KiB
실험대 가상화 계층 — 실측 기록
이 문서가 무엇인가
docs/guides/ 가 「무엇을 어떤 순서로 치는가」 를 적는다면, 이
문서는 「그때 실제로 어떤 값이 나왔는가」 를 적는다. 가이드에 이미 있는
절차는 반복하지 않는다.
여기 적힌 숫자는 전부 2026-09-10 에 test-server 에서 실제로 돌려 받은
출력이다. 추정값·예상값은 없다. 없는 값은 「미측정」이라고 쓴다.
| 이 문서가 답하는 것 | 가이드가 답하는 것 |
|---|---|
| 5120MB 를 줬는데 실제로 얼마를 쓰나 | 메모리를 얼마로 주나 |
| 20GB 오버레이가 디스크를 얼마나 먹나 | 오버레이를 어떻게 만드나 |
| cloud-init 이 몇 초 걸리나 | cloud-init 을 어떻게 쓰나 |
| 철거하면 무엇이 남나 | 무엇을 세우나 |
1. 측정 환경
lscpu | grep -E "^Model name|^CPU\(s\):|^Thread|^Core"
free -m | head -2
df -h /
virsh --version; qemu-system-x86_64 --version | head -1; uname -r
실측
CPU(s): 8
Model name: 11th Gen Intel(R) Core(TM) i5-1135G7 @ 2.40GHz
Thread(s) per core: 2
Core(s) per socket: 4
total used free shared buff/cache available
Mem: 11648 5642 2599 4 3776 6005
/dev/nvme0n1p3 226G 9.9G 204G 5% /
12.7.0
QEMU emulator version 11.1.1
7.2.2-arch1-1
어디를 봐야 하는가 — Core(s) per socket 4 에 Thread(s) per core 2 라
논리 코어가 8 이다. VM 에 주는 vCPU 는 물리 코어가 아니라 논리 코어를 나눠
쓰는 것이고, 합이 8 을 넘어도 libvirt 는 막지 않는다(오버커밋). 이 실험대는
2 + 2 + 1 = 5 로 잡아 여유를 뒀다.
free 의 available(6005MB)이 free(2599MB)보다 훨씬 큰 것이 정상이다 —
buff/cache 3776MB 는 필요하면 회수된다. VM 을 얼마나 더 띄울 수 있는지는
free 가 아니라 available 로 본다.
중첩 가상화
lscpu | grep Virtualization
cat /sys/module/kvm_intel/parameters/nested
Virtualization: VT-x
Y
켜져 있지만 이 실험대는 쓰지 않는다. 일회용으로 만들려는 층(게스트)은 이미 일회용이고, 비싼 층(호스트의 인증서·DNS)은 중첩으로 해결되지 않는다. 그리고 A-6 의 지연 주입처럼 시간을 재는 실험에서 중첩은 측정값을 왜곡한다.
2. 자원 — 할당과 실사용은 다르다
VM 에 준 메모리는 상한이지 점유가 아니다. virtio-balloon 이 안 쓰는 만큼 호스트에 돌려준다.
for v in kc-lab-1 kc-lab-2; do
printf "%-10s 할당 %sMB 실사용 %sMB\n" "$v" \
"$(( $(virsh dommemstat $v | awk '/actual/{print $2}') / 1024 ))" \
"$(( ($(virsh dommemstat $v | awk '/actual/{print $2}') \
- $(virsh dommemstat $v | awk '/^unused/{print $2}')) / 1024 ))"
done
실측 — k3s 만 떠 있고 Keycloak 은 아직 안 올린 상태
kc-lab-1 할당 5120MB 실사용 353MB
kc-lab-2 할당 3120MB 실사용 301MB
어디를 봐야 하는가 — 두 가지다.
① 실사용이 할당의 7% 밖에 안 된다. k3s server 가 353MB 다. 「5GB 를 줬으니 5GB 를 쓴다」가 아니다.
② kc-lab-2 의 할당이 4096 이 아니라 3120 이다. virt-install --memory 4096
으로 만들었는데 줄어 있다. virtio-balloon 이 회수해 간 것으로 보인다.
dommemstat 의 actual 은 현재 할당이지 선언한 상한이 아니다. 상한은
virsh dominfo 의 Max memory 에 있다 — 다만 이 실험대에서 그 값을 나란히
찍어 보지는 않았다(미측정). 둘을 헷갈리면 「메모리가 왜 줄었지」가 된다.
이 결과가 의미하는 것 — 합계 8240MB 를 할당했지만 실제 점유는 654MB 다. 그래서 11.6GB 짜리 호스트에서 VM 세 대가 무리 없이 돈다. 다만 이것은 지금 k3s 만 떠 있어서다 — Keycloak 2 파드 + PostgreSQL + Redis + Prometheus 가 올라가면 늘어난다. 그 시점의 값은 미측정이다.
3. 디스크 — 오버레이는 얼마나 쓰나
게스트 디스크는 base.qcow2 위의 오버레이다. 20GB 를 두 개 만들어도
바닥 이미지는 한 벌이고 변경분만 쌓인다.
qemu-img info /var/lib/libvirt/images/base.qcow2 | head -4
ls -l /var/lib/libvirt/images/
실측 — 엣지를 만들기 전, k3s 2 노드만 있던 시점이다.
image: /var/lib/libvirt/images/base.qcow2
file format: qcow2
virtual size: 3 GiB (3221225472 bytes)
disk size: 335 MiB
-rw-r--r-- base.qcow2 351404032 (335 MiB)
-rw------- kc-lab-1.qcow2 1521025024 (1.4 GiB) ← 선언 20GB
-rw------- kc-lab-2.qcow2 697499648 (665 MiB) ← 선언 20GB
-rw------- seed-kc-lab-1.iso 378880 (370 KiB)
-rw------- seed-kc-lab-2.iso 378880 (370 KiB)
어디를 봐야 하는가 — base.qcow2 의 virtual size 3GiB 와 disk size
335MiB 의 차이. 그리고 게스트 디스크가 20GB 로 선언됐는데 1.4GB 와 665MB
라는 것. k3s server 가 agent 보다 두 배 이상 쓰는 것은 컨트롤 플레인
바이너리와 SQLite 때문이다.
이 결과가 의미하는 것 — 20GB × 2 = 40GB 를 선언했지만 실제로는 2.1GB 를
썼다. 디스크는 병목이 아니다. df 로 본 사용량과 VM 에 선언한 크기를 같은
것으로 보면 안 된다.
스토리지 풀
virsh pool-info default
Name: default
State: running
Persistent: yes Autostart: yes
Capacity: 225.31 GiB
Allocation: 7.84 GiB
Available: 217.46 GiB
Allocation 은 풀 전체(호스트 루트 파일시스템)의 사용량이지 VM 만의
사용량이 아니다. VM 이 얼마를 쓰는지는 위 ls -l 로 본다.
4. 부팅 — cloud-init 은 얼마나 걸리나
for i in $(seq 1 30); do
ssh -o BatchMode=yes -o ConnectTimeout=4 kc-lab-edge 'cloud-init status' 2>/dev/null \
| grep -q 'status: done' && { echo "완료 (약 $((i*10))초)"; break; }
sleep 10
done
ssh kc-lab-edge 'hostname; ip -4 -br addr show enp1s0; cloud-init status'
실측 — package_update: true 에 패키지 5개(curl nftables nginx
certbot python3-certbot-dns-cloudflare)를 받는 게스트
완료 (약 50초)
kc-lab-edge
enp1s0 UP 192.168.122.10/24 metric 100
status: done
어디를 봐야 하는가 — cloud-init status 의 세 상태를 구분한다.
| 값 | 뜻 |
|---|---|
running |
아직 진행 중. 기다린다 |
done |
끝났다 |
error |
실패. cloud-init status --long 으로 어느 모듈인지 본다 |
이 결과가 의미하는 것 — SSH 가 붙는 것과 cloud-init 이 끝난 것은 다르다.
SSH 는 먼저 열리고 패키지 설치는 뒤에 이어진다. done 을 안 기다리고 다음
단계를 치면 「방금 깐 패키지가 없다」가 나온다.
5. 네트워크 — DHCP 예약의 실제 동작
virsh net-update default add ip-dhcp-host \
"<host mac='52:54:00:aa:bb:10' name='kc-lab-edge' ip='192.168.122.10'/>" \
--live --config
실측
Updated network default persistent config and live state
어디를 봐야 하는가 — persistent config 와 live state 두 마디가 다
나오는가. --live 만 주면 앞의 것이, --config 만 주면 뒤의 것이 빠진다.
한쪽만 나온 것을 성공으로 읽으면 「재부팅하니 IP 가 바뀐다」가 된다.
예약을 먼저, VM 을 나중에
이 실험대는 예약을 넣고 나서 virt-install 했고, 게스트가 첫 부팅에서
바로 .10 을 받았다.
enp1s0 UP 192.168.122.10/24 metric 100
순서가 반대면 게스트가 동적 대역(192.168.122.2–.254)에서 아무 주소나 받고,
예약이 먹으려면 리스 만료를 기다리거나 재부팅해야 한다.
리스는 예약과 별개로 남는다
virsh net-dhcp-leases default
Expiry Time MAC address IP address Hostname
2026-09-10 09:59:54 52:54:00:aa:bb:11 192.168.122.11/24 kc-lab-1
2026-09-10 09:58:46 52:54:00:aa:bb:12 192.168.122.12/24 kc-lab-2
net-dumpxml 의 예약은 줄 의도이고, net-dhcp-leases 는 실제로 준
기록이다. 둘이 다를 수 있다.
virbr0 는 게스트가 없으면 내려간다
ip -br addr show virbr0
VM 세 대가 돌 때:
virbr0 UP 192.168.122.1/24
전부 철거한 뒤:
virbr0 DOWN 192.168.122.1/24
주소는 그대로 있고 상태만 DOWN 이다. 브리지에 붙은 tap 인터페이스가
하나도 없어서다. 네트워크 정의가 사라진 것이 아니므로 VM 을 다시 띄우면
자동으로 UP 이 된다. 이걸 고장으로 오독해 virsh net-start 를 찾아
헤매지 않는다.
6. 철거 — 실제 출력 전문
가이드에는 세우는 절차만 있고 철거가 없다. 2026-09-10 에 실제로 돌린 기록이다.
게스트
for v in kc-lab-edge kc-lab-2 kc-lab-1; do
virsh destroy "$v"
virsh undefine "$v" --remove-all-storage
done
실측 (한 대분)
Domain 'kc-lab-edge' destroyed
Domain 'kc-lab-edge' has been undefined
Volume 'vda'(/var/lib/libvirt/images/kc-lab-edge.qcow2) removed.
Volume 'vdb'(/var/lib/libvirt/images/seed-kc-lab-edge.iso) removed.
어디를 봐야 하는가 — Volume 줄이 두 개 나오는가. vda(오버레이
디스크)와 vdb(시드 ISO)다. --remove-all-storage 를 빠뜨리면 도메인만
사라지고 디스크 파일이 남아 다음 virt-install 이 「이미 있다」로 실패한다.
destroy 는 종료 신호가 아니라 전원을 뽑는 것이다. 정상 종료를 원하면
virsh shutdown 을 쓰고 꺼질 때까지 기다린다.
DHCP 예약
virsh net-update default delete ip-dhcp-host \
"<host mac='52:54:00:aa:bb:10' name='kc-lab-edge' ip='192.168.122.10'/>" \
--live --config
★ 삭제할 때도 mac·name·ip 세 속성을 다 준다. 하나라도 비면 이렇게
거부된다.
error: Failed to update network default
error: XML error: Cannot use host name '' in network 'default'
zsh 에서 루프로 돌리면 이 오류를 만난다. zsh 는 따옴표 없는 변수를 단어 분리하지 않는다. bash 에서 되던
set -- $entry가 zsh 에서는$1에 문자열 전체를 넣고$2·$3을 비운다. 그래서name=""이 된다. 세 줄을 값 그대로 쓰는 편이 안전하다.
철거 후
### 남은 예약
(없음)
### dhcp 블록
<dhcp>
<range start='192.168.122.2' end='192.168.122.254'/>
</dhcp>
동적 대역만 남는 것이 정상이다.
철거 전후 비교 — 실측
| 철거 전 | 철거 후 | |
|---|---|---|
virsh list --all |
3 대 running | (없음) |
virsh vol-list default |
7 개 | base.qcow2 1 개 |
| DHCP 예약 | 3 줄 | 0 줄 |
df -h / |
11G | 7.9G |
virbr0 |
UP | DOWN |
3.1GB 가 회수됐다. 내역은 kc-lab-1 1.4GB + kc-lab-2 665MB + 시드 ISO
3개(각 370KB)이고, kc-lab-edge 의 디스크 크기는 재 두지 않았다(미측정) —
합계에서 역산하면 1GB 안팎이다.
base.qcow2 335MB 는 남긴다 — 다음 재구축의 바닥이고, 다시 받으면 몇 분이다.
7. 실측으로 드러난 함정 셋
전부 아무 오류 없이 조용히 지나가거나, 엉뚱한 곳에서 증상이 나오는 유형이다.
① cloud-init sudo 는 리스트가 아니라 문자열
ssh kc-lab-1 'cloud-init schema -c ~/chk.yaml'
리스트 형태(sudo: ['ALL=(ALL) NOPASSWD:ALL'])일 때:
Error: Cloud config schema errors: users.0: {'name': 'donghyeon', 'groups':
['sudo'], 'shell': '/bin/bash', ...} is not valid under any of the given schemas
문자열 형태(sudo: "ALL=(ALL) NOPASSWD:ALL")일 때:
Valid cloud-config: /home/donghyeon/chk.yaml
어느 키가 문제인지 안 알려 준다. users.0 블록을 통째로 찍고 「어느
스키마에도 안 맞는다」고만 한다. 그리고 리스트 형태도 부팅은 된다 —
kc-lab-1·kc-lab-2 가 그 상태로 NOPASSWD sudo 가 멀쩡히 돌았다. 검사기만
거부하는 것이라 「검사는 실패인데 왜 되지」로 헷갈린다.
게스트 cloud-init 버전: 22.4.2 (Debian 12 genericcloud).
② nginx http2 on; 은 배포판에 따라 없다
엣지 (Debian 12): nginx version: nginx/1.22.1
lab host (Arch): nginx version: nginx/1.30.4
http2 on; 은 nginx 1.25.1 이상이다. Arch 에서 쓰던 설정을 Debian 게스트로
그대로 옮기면:
[emerg] 1339#1339: unknown directive "http2" in /etc/nginx/sites-enabled/keycloak-lab:29
nginx: configuration file /etc/nginx/nginx.conf test failed
listen 443 ssl http2; 형태는 1.22 와 1.30 양쪽에서 다 돈다.
③ Debian 기본 사이트가 default_server 를 먹고 있다
Debian 계열은 /etc/nginx/sites-enabled/default 가 처음부터 붙어 있고 :80
에 default_server 로 선언돼 있다. 실험대 설정의 listen 80 default_server
와 충돌한다. 심볼릭 링크를 걸 때 같이 지운다.
sudo rm -f /etc/nginx/sites-enabled/default
Arch 는 sites-available 관례 자체가 없어서 이 함정이 없는 대신, nginx.conf
에 include /etc/nginx/sites-enabled/*; 를 직접 넣어야 한다. 배포판이 바뀌면
함정도 바뀐다.
8. 재구축할 때 무엇이 남아 있나
철거해도 남는 것과 사라지는 것을 구분해 둔다. 이걸 모르면 재구축이 「왜 이건 이미 있지」와 「왜 이건 없지」의 반복이 된다.
| 상태 | 왜 | |
|---|---|---|
base.qcow2 (335MB) |
남는다 | 다음 오버레이의 바닥 |
| 패키지 (libvirt·qemu·nginx·certbot·kubectl) | 남는다 | 재설치가 무의미 |
~/workspace/cloud/kc-lab-{1,2}.yaml |
남는다 | 키와 비밀번호가 들어 있다 |
~/.ssh/config 의 kc-lab-* 항목 |
남는다 | 재구축해도 IP 가 같다 |
libvirt default 네트워크 정의 |
남는다 | 예약만 지웠다 |
/etc/letsencrypt/ |
남긴다 (정책) | 아래 참고 |
| 게스트 디스크·시드 ISO | 사라진다 | --remove-all-storage |
| DHCP 예약 | 사라진다 | net-update delete |
| k3s·Keycloak·모든 워크로드 | 사라진다 | 게스트와 함께 |
인증서를 지우지 않는 이유
한도 때문이 아니다. Let's Encrypt 의 「같은 이름 조합에 주당 중복 5장」 제한은 가끔 재구축하는 정도로는 근처에도 못 간다.
진짜 이유는 「지금 재발급이 되는지를 모른다」는 것이다. 이 실험대의 이름 셋은 tailnet 주소를 가리킨다.
dig +short auth.hyeonworks.com
100.83.212.4
100.64.0.0/10 은 CGNAT 용 예약 대역이라 공개 인터넷에서 라우팅되지
않는다. HTTP-01 검증은 Let's Encrypt 가 우리 서버로 들어오는 방식이므로,
이 주소로는 검증이 성립하지 않는다.
| 지금 설정이 | 지우고 나면 |
|---|---|
dns-cloudflare (DNS-01) |
다시 받으면 끝. 백업은 헛수고였던 것 |
webroot·standalone (HTTP-01) |
검증 방식부터 손봐야 한다 |
「못 한다」가 아니라 「일이 하나 생긴다」이다. 최악의 경우라도 A 레코드를 공인 IP 로 잠깐 돌려 받거나 DNS-01 로 전환하면 된다. 다만 재구축하려고 앉은 자리에서 그 일부터 하게 된다.
어느 쪽인지 모르는 채로는 지우지 않는다는 것이 이 정책의 전부다. 백업은 tar 하나에 30초고, 답을 알고 나면 지우면 된다.
sudo tar czf ~/letsencrypt-backup-$(date +%Y%m%d-%H%M%S).tgz -C /etc letsencrypt
복원은 반대로 한 줄이다.
sudo tar xzf ~/letsencrypt-backup-<stamp>.tgz -C /etc
미측정 — 이 실험대의 certbot 이 HTTP-01 인지 DNS-01 인지 아직 확인하지
않았다. 호스트의 sudo 가 비밀번호를 요구해 비대화식으로 읽지 못했다.
다음 한 줄이 답이다.
sudo grep -H authenticator /etc/letsencrypt/renewal/*.conf
webroot·standalone 이면 HTTP-01 이라 위 문제가 실재하고, dns-cloudflare
면 DNS-01 이라 tailnet 안에서도 재발급·갱신이 된다. 플러그인은 이미 깔려
있다 — certbot plugins 에 dns-cloudflare 가 보인다.
관련 문서
| 문서 | 무엇 |
|---|---|
guides/01-vms/ |
VM 세 대를 세우는 절차 |
guides/00-lab-host/ |
호스트 가상화 준비 |
session-lab-concepts.md |
여기 나온 개념의 정의 |
deploy/lab/host/teardown-host.sh |
호스트 계층 철거 |