# 실험대 가상화 계층 — 실측 기록 ## 이 문서가 무엇인가 [`docs/guides/`](guides/) 가 **「무엇을 어떤 순서로 치는가」** 를 적는다면, 이 문서는 **「그때 실제로 어떤 값이 나왔는가」** 를 적는다. 가이드에 이미 있는 절차는 반복하지 않는다. 여기 적힌 숫자는 전부 **2026-09-10 에 `test-server` 에서 실제로 돌려 받은 출력**이다. 추정값·예상값은 없다. 없는 값은 「미측정」이라고 쓴다. | 이 문서가 답하는 것 | 가이드가 답하는 것 | |---|---| | 5120MB 를 줬는데 실제로 얼마를 쓰나 | 메모리를 얼마로 주나 | | 20GB 오버레이가 디스크를 얼마나 먹나 | 오버레이를 어떻게 만드나 | | cloud-init 이 몇 초 걸리나 | cloud-init 을 어떻게 쓰나 | | 철거하면 무엇이 남나 | 무엇을 세우나 | --- ## 1. 측정 환경 ```bash 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` 로 본다.** ### 중첩 가상화 ```bash lscpu | grep Virtualization cat /sys/module/kvm_intel/parameters/nested ``` ``` Virtualization: VT-x Y ``` **켜져 있지만 이 실험대는 쓰지 않는다.** 일회용으로 만들려는 층(게스트)은 이미 일회용이고, 비싼 층(호스트의 인증서·DNS)은 중첩으로 해결되지 않는다. 그리고 A-6 의 지연 주입처럼 **시간을 재는 실험**에서 중첩은 측정값을 왜곡한다. --- ## 2. 자원 — 할당과 실사용은 다르다 VM 에 준 메모리는 **상한이지 점유가 아니다.** virtio-balloon 이 안 쓰는 만큼 호스트에 돌려준다. ```bash 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 를 두 개 만들어도 바닥 이미지는 한 벌이고 변경분만 쌓인다. ```bash 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 에 선언한 크기를 같은 것으로 보면 안 된다.** ### 스토리지 풀 ```bash 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 은 얼마나 걸리나 ```bash 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 예약의 실제 동작 ```bash virsh net-update default add ip-dhcp-host \ "" \ --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`)에서 아무 주소나 받고, 예약이 먹으려면 리스 만료를 기다리거나 재부팅해야 한다. ### 리스는 예약과 별개로 남는다 ```bash 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 는 게스트가 없으면 내려간다 ```bash 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 에 실제로 돌린 기록이다. ### 게스트 ```bash 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 예약 ```bash virsh net-update default delete ip-dhcp-host \ "" \ --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 블록 ``` 동적 대역만 남는 것이 정상이다. ### 철거 전후 비교 — 실측 | | 철거 전 | 철거 후 | |---|---|---| | `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` 는 리스트가 아니라 문자열 ```bash 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` 와 **충돌한다.** 심볼릭 링크를 걸 때 같이 지운다. ```bash 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 주소를 가리킨다. ```bash dig +short auth.hyeonworks.com ``` ``` 100.83.212.4 ``` `100.64.0.0/10` 은 CGNAT 용 예약 대역이라 **공개 인터넷에서 라우팅되지 않는다.** HTTP-01 검증은 Let's Encrypt 가 우리 서버로 들어오는 방식이므로, 이 주소로는 검증이 성립하지 않는다. **지우면 되살리기 전에 검증 방식 문제부터 풀어야 한다.** 백업만 뜨고 파일은 남긴다. ```bash sudo tar czf ~/letsencrypt-backup-$(date +%Y%m%d-%H%M%S).tgz -C /etc letsencrypt ``` 복원은 반대로 한 줄이다. ```bash sudo tar xzf ~/letsencrypt-backup-.tgz -C /etc ``` **미측정** — 이 실험대의 certbot 이 HTTP-01 인지 DNS-01 인지 아직 확인하지 않았다. 호스트의 `sudo` 가 비밀번호를 요구해 비대화식으로 읽지 못했다. 다음 한 줄이 답이다. ```bash sudo grep -H authenticator /etc/letsencrypt/renewal/*.conf ``` `webroot`·`standalone` 이면 HTTP-01 이라 위 문제가 실재하고, `dns-cloudflare` 면 DNS-01 이라 tailnet 안에서도 재발급·갱신이 된다. 플러그인은 이미 깔려 있다 — `certbot plugins` 에 `dns-cloudflare` 가 보인다. --- ## 관련 문서 | 문서 | 무엇 | |---|---| | [`guides/01-vms/`](guides/01-vms/) | VM 세 대를 세우는 절차 | | [`guides/00-lab-host/`](guides/00-lab-host/) | 호스트 가상화 준비 | | [`session-lab-concepts.md`](session-lab-concepts.md) | 여기 나온 개념의 정의 | | [`deploy/lab/host/teardown-host.sh`](../deploy/lab/host/teardown-host.sh) | 호스트 계층 철거 |