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,203 @@
---
kind: CASE
slug: declared-memory-and-disk-are-ceilings-not-occupancy
title: 5120MB 를 줬는데 353MB 를 쓰고 있었다 — 선언한 양과 실제로 드는 양
topic: lab-environment-build
topicName: 실험대 환경 구성
project: virtualization
status: 게시 전
lastVerifiedOn: 2026-09-10
sourceRevision: 9465582b5d1630eb4ae7c4e078021486919bf6b6
source:
- final/document.md#198-자원-할당과-실사용은-다르다
- final/document.md#199-디스크-오버레이는-얼마나-쓰나
- final/document.md#202-철거-실제-출력-전문
- final/document.md#197-측정-환경
- final/document.md#195-이-부의-출처와-범위
- final/document.md#211-이-부의-출처와-범위
---
# 5120MB 를 줬는데 353MB 를 쓰고 있었다 — 선언한 양과 실제로 드는 양
k3s 만 올린 상태에서 kc-lab-1 은 5120MB 를 할당받고 353MB 를 쓰고 있었다. k3s 두 노드에 8240MB 를 선언해 실제 점유는 654MB 였고, 디스크는 40GB 를 선언해 2.1GB 를 썼다. 2026-09-10 에 test-server 에서 쟀다.
## 관계
- **이 호스트의 가상 머신들은 설정한 메모리와 지금 잡고 있는 메모리가 얼마나 다른가**
그 물음이 묻는 세 값 가운데 configured 와 게스트 사용량이 여기서 나왔다. 호스트 resident 는 비어 있어서 그 물음은 닫히지 않았다.
- **이 disk image 는 qcow2 인가 RAW 인가, 그리고 Guest 가 보는 크기와 Host 가 실제로 쓰는 크기는 얼마나 다른가**
그 물음이 요구하는 세 값 가운데 `qemu-img info``ls` 가 여기서 나왔고 `du` 는 돌리지 않았다.
- **QEMU 프로세스가 실제로 붙잡고 있는 호스트 메모리는 얼마이고 어떻게 나뉘어 있는가**
게스트 안에서 본 실사용과 호스트가 실제로 잡아 둔 양은 다른 수다. 후자를 그 물음이 받는다.
- **qcow2 파일 안 — 매핑표와 클러스터, 그리고 항목이 0 이면 바닥에 다시 묻는다**
20GB 를 선언한 파일이 1.4GiB 인 까닭을 그 글이 매핑표와 오버레이로 설명한다.
- **실험대를 철거하고 무엇이 남는지 확인한다**
회수량 3.1GB 를 낸 철거 절차가 그 기록에 있다.
- **실험대를 껐다 켜고 게스트 메모리를 다시 나눈다**
`kc-lab-1` 이 3584M 에서 5120MB 가 된 재배분 절차가 그 기록에 있다.
- **도구가 낸 출력은 대상의 상태가 아니다 — 그 명령이 무엇을 세는지부터 가른다**
`dommemstat``actual`, `free``available`, `pool-info``Allocation` 이 셋 다 이름과 다른 것을 센다.
## 문제
제1~4부에는 이 호스트에서 잰 값이 하나도 없다. 제5·6부는 버전과 주소와 명령까지만 적었다.
게스트 세 대에 메모리 8240MB 와 디스크 40GB 를 선언해 두었는데, 그 선언이 11,648MiB 와 226G 짜리 호스트 한 대에서 실제로 얼마를 먹는지는 어디에도 없었다. 「5GB 를 줬으니 5GB 를 쓴다」와 「20GB 두 장이면 40GB 를 쓴다」가 맞는지 모르는 채로 게스트를 더 띄울지 정해야 했다.
## 결론
선언한 양은 상한이고 점유가 아니다. k3s 만 떠 있고 Keycloak 은 아직 안 올린 상태에서 넷을 쟀다.
메모리 : `kc-lab-1` 은 할당 5120MB 에 실사용 353MB, `kc-lab-2` 는 할당 3120MB 에 실사용 301MB
메모리 합계 : 8240MB 를 할당했고 실제 점유는 654MB
디스크 : `kc-lab-1.qcow2` 1.4GiB, `kc-lab-2.qcow2` 665MiB, 바닥 `base.qcow2``virtual size` 3 GiB 에 `disk size` 335MiB
디스크 합계 : 20GB 를 두 장 선언했고 실제로 쓴 것은 2.1GB
철거 : 게스트 셋을 지우자 `df -h /` 가 11G 에서 7.9G 로 내려 3.1GB 가 회수됐다
`virt-install --memory 4096` 으로 만든 `kc-lab-2` 의 할당이 3120 으로 보인다. `dommemstat``actual` 은 현재 할당이지 선언한 상한이 아니고, 상한은 `virsh dominfo``Max memory` 에 있다. 줄어든 까닭은 virtio-balloon 회수로 보이는데, 두 값을 나란히 찍어 보지는 않았다.
## 검증 환경
측정일 : 2026-09-10
호스트 : `test-server`, Arch Linux
CPU : `11th Gen Intel(R) Core(TM) i5-1135G7 @ 2.40GHz`, 논리 코어 8
RAM : 11,648MiB
루트 파일시스템 : 226G 가운데 9.9G 사용
QEMU : 11.1.1
libvirt : 12.7.0
커널 : `7.2.2-arch1-1`
중첩 가상화 : `nested``Y` 지만 이 실험대는 쓰지 않는다
게스트 : Debian 12 genericcloud 3대 — 엣지 1대, k3s 2노드
워크로드 : k3s 만 떠 있고 Keycloak · PostgreSQL · Redis · Prometheus 는 올리기 전
## 재현 조건
1. k3s 두 노드만 띄우고 Keycloak · PostgreSQL · Redis · Prometheus 는 올리지 않는다.
2. 게스트마다 `virsh dommemstat` 을 돌려 `actual``unused` 를 읽고, 그 차이를 실사용으로 잡는다.
3. `qemu-img info``base.qcow2``virtual size``disk size` 를 읽고, `ls -l /var/lib/libvirt/images/` 로 오버레이와 시드 파일의 바이트 수를 읽는다.
4. 철거하기 전에 `df -h /` 를 읽어 둔다.
5. `virsh destroy``virsh undefine --remove-all-storage` 로 게스트 셋을 지우고 `df -h /` 를 다시 읽는다.
## 본문
<!-- body:start -->
## 이 호스트에서 처음으로 양을 쟀다
이 실험대의 문서는 제5·6부까지 버전과 주소와 명령을 적었고 자원의 양은 적지 않았다. 제7부가 그 양과 시간을 처음 쟀고, 원본이 표기 규약을 스스로 밝혀 두었다.
> 여기 적힌 숫자는 전부 2026-09-10 에 `test-server` 에서 실제로 돌려 받은 출력이다. 추정값·예상값은 없다. 없는 값은 「미측정」이라고 쓴다.
측정 환경부터 읽어 둔다. 논리 코어가 8 이고 게스트 셋에 vCPU 를 2 + 2 + 1 로 잡아 여유를 뒀다.
```text label="측정 환경 — free -m 의 앞 두 줄"
total used free shared buff/cache available
Mem: 11648 5642 2599 4 3776 6005
```
`available` 이 6005 로 `free` 2599 보다 훨씬 큰데, `buff/cache` 3776 이 필요해지면 회수되기 때문이다. 게스트를 몇 대 더 띄울 수 있는지는 `free` 가 아니라 `available` 로 읽는다.
## 메모리 — 할당 5120MB 에 실사용 353MB
게스트마다 `dommemstat` 의 `actual` 에서 `unused` 를 뺀 값이 실사용이다.
```text label="k3s 만 떠 있고 Keycloak 은 아직 안 올린 상태"
kc-lab-1 할당 5120MB 실사용 353MB
kc-lab-2 할당 3120MB 실사용 301MB
```
k3s server 한 대가 353MB 를 쓰니 할당의 7% 다. 둘을 합치면 8240MB 를 할당했고 실제 점유는 654MB 여서, 11,648MiB 짜리 호스트 한 대에서 게스트 세 대가 무리 없이 돈다.
## `kc-lab-2` 의 할당이 4096 이 아니라 3120 이다
`kc-lab-2` 는 `virt-install --memory 4096` 으로 만들었는데 `dommemstat` 이 3120 을 낸다.
`dommemstat` 의 `actual` 은 현재 할당이지 선언한 상한이 아니라서, 상한을 보려면 `virsh dominfo` 의 `Max memory` 를 읽어야 한다. 둘을 같은 값으로 읽으면 「메모리가 왜 줄었지」가 된다.
줄어든 까닭은 virtio-balloon 회수로 보인다. 게스트가 안 쓰는 만큼 balloon 드라이버가 호스트에 돌려주고, 돌려준 만큼 현재 할당이 내려간다. 다만 이 실험대에서 `dommemstat` 의 `actual` 과 `dominfo` 의 `Max memory` 를 나란히 찍어 대조한 기록이 없어서, 3120 이 balloon 회수의 결과라는 것은 관측이 아니라 추론이다.
## 디스크 — 40GB 를 선언해 2.1GB
게스트 디스크는 `base.qcow2` 위의 오버레이다. 20GB 짜리를 두 장 만들어도 바닥은 한 벌이고 변경분만 쌓인다.
```text label="qemu-img info 가 읽은 바닥 이미지"
image: /var/lib/libvirt/images/base.qcow2
file format: qcow2
virtual size: 3 GiB (3221225472 bytes)
disk size: 335 MiB
```
```text label="엣지를 만들기 전, k3s 2 노드만 있던 시점의 ls -l"
-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)
```
바닥은 게스트가 3 GiB 로 보는데 파일은 335MiB 이고, 20GB 로 선언한 오버레이 둘도 실제로는 1.4GiB 와 665MiB 다. 합쳐 40GB 를 선언하고 2.1GB 를 썼다. `kc-lab-1` 이 `kc-lab-2` 의 두 배 이상을 쓰는 것은 k3s server 가 컨트롤 플레인 바이너리와 SQLite 를 들고 있기 때문이다.
## `virsh pool-info` 의 `Allocation` 은 VM 사용량이 아니다
같은 날 `virsh pool-info default` 도 읽었다.
```text label="스토리지 풀 default"
Name: default
State: running
Persistent: yes Autostart: yes
Capacity: 225.31 GiB
Allocation: 7.84 GiB
Available: 217.46 GiB
```
`Allocation` 7.84 GiB 는 풀이 얹힌 호스트 루트 파일시스템 전체의 사용량이다. VM 이 얼마를 쓰는지는 위의 `ls -l` 이 말한다. 두 수를 같은 것으로 읽으면 게스트 둘이 7.84 GiB 를 먹은 것이 된다.
## 철거 — `df -h /` 가 11G 에서 7.9G 로
게스트 셋을 `virsh destroy` 로 내리고 `virsh undefine --remove-all-storage` 로 지웠다. 한 대분 출력은 이렇다.
```text label="kc-lab-edge 한 대를 철거한 출력"
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` 와 시드 ISO `vdb` 다.
| 무엇을 읽었나 | 철거 전 | 철거 후 |
|---|---|---|
| `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)다. `base.qcow2` 335MB 는 다음 재구축의 바닥이라 남긴다. 다시 받아도 몇 분이면 된다.
## 같은 대상의 숫자가 두 벌이다
이 SSOT 안에는 같은 실험대의 스냅샷이 두 벌 있다. §218 은 2026-09-03 값이고 이 기록은 2026-09-10 값이다.
| 무엇 | 2026-09-03 | 2026-09-10 |
|---|---|---|
| 호스트 RAM | `RAM 7.4Gi` | `Mem: 11648` |
| `kc-lab-1` | `RAM 3584M · vCPU 2` | 할당 5120MB |
| `kc-lab-2` | `RAM 2560M · vCPU 2` | 할당 3120MB (선언 4096) |
| 게스트 수 | 2 (엣지 없음) | 3 (엣지 추가) |
그사이에 호스트 RAM 이 8GB 에서 12GB 로 물리 증설됐고 `setmaxmem` 과 `setmem` 으로 게스트 메모리가 재배분됐다. 두 값이 어긋나 보이면 틀린 것이 아니라 다른 날이다.
날짜가 아니라 단위로 갈리는 것도 하나 있다. §198 은 같은 호스트의 RAM 을 `11.6GB` 로도 적는데, 그 표기가 원 가이드에서 온 것이라 고쳐 쓰지 않고 어긋남을 적어 둔다고 스스로 밝힌다. `free -m` 의 `Mem: 11648` 은 MiB 단위이므로 11,648MiB, 약 11.4GiB 다.
## 확인하지 못한 것
이 값은 호스트 한 대의 한 시점이다. Keycloak 2 파드와 PostgreSQL 과 Redis 와 Prometheus 가 올라간 뒤의 메모리는 재지 않았다. 원본도 §198 에서 그 시점의 값을 미측정으로 밝힌다.
디스크 값과 철거 값은 시점이 서로 다르다. `ls -l` 은 엣지를 만들기 전 k3s 2노드만 있던 때의 것이고, 3.1GB 회수는 엣지까지 세 대가 있던 때의 것이다. `kc-lab-edge` 의 디스크 크기는 따로 재 두지 않았고, 회수 합계에서 역산하면 1GB 안팎이다.
게스트 안에서 본 실사용과 QEMU 프로세스가 호스트에서 붙잡고 있는 양은 다른 수인데, 뒤엣것은 이 기록이 재지 않았다.
여기 옮긴 출력은 전부 SSOT 본문의 코드 블록에서 왔고, 이 저장소의 `final/evidence/` 에는 그 명령들의 출력 원문이 파일로 없다. 같은 측정을 다시 돌려 `final/evidence/raw/` 에 남기면 그때 원문을 댈 수 있다.
<!-- body:end -->
@@ -0,0 +1,145 @@
---
kind: CASE
slug: nftables-accept-did-not-stop-the-libvirt-reject
title: 호스트 안에서는 404, 밖에서는 connection refused — 앞 체인의 accept 가 libvirt 의 reject 를 막지 못했다
topic: lab-environment-build
topicName: 실험대 환경 구성
project: virtualization
status: 게시 전
assets:
- key: nftables-forward-hook-chain-order
file: ../../../final/assets/diagrams/nftables-forward-hook-chain-order/nftables-forward-hook-chain-order.svg
sourceRevision: 9465582b5d1630eb4ae7c4e078021486919bf6b6
source:
- final/document.md#180-nftables-는-앞-체인의-accept-로-뒤-체인의-reject-를-막지-못한다
- final/document.md#179-엣지를-물리-호스트에서-vm-으로-옮기면-무엇이-새로-필요해지나
- final/document.md#178-이-부의-출처와-범위
---
# 호스트 안에서는 404, 밖에서는 connection refused — 앞 체인의 accept 가 libvirt 의 reject 를 막지 못했다
밖에서 온 요청은 먼저 돌게 해 둔 forward 체인의 accept 를 지나고도 libvirt 의 guest_input 체인 끝 reject 에서 끊겼다. nftables 가 같은 훅에 붙은 base 체인을 우선순위 순으로 전부 평가하기 때문이다. 그래서 구멍을 libvirt 체인 맨 앞에 넣었다.
## 관계
- **엣지 nginx 를 호스트에서 게스트 VM 으로 옮긴다 — 성능이 아니라 더러워지는 층의 격리**
그 결정으로 새로 필요해진 libvirt 방화벽 구멍을 이 사건에서 실제로 뚫었다.
- **libvirt 의 firewall_backend 가 iptables 일 때도 guest_input 에 구멍이 필요한가**
여기서 재지 않고 넘긴 미확인 항목을 그 질문이 받는다.
- **단계별 구축 문서는 작성 시점이 아니라 실행 순서로 검증한다**
같은 구축에서 나온 문서 결함들을 하나의 규칙으로 정리한 글이다.
- **Guest 의 packet 이 Host Physical NIC 에 닿기까지 — virtio-net · virtqueue · vhost-net · TAP · Bridge**
밖에서 온 패킷이 게스트에 닿기까지의 경로를 그 글이 세우고, 이 사건은 그 경로의 한 구간에서 막혔다.
- **packet 이 어디서 끊겼는지는 계층마다 capture 해서 가른다**
어느 계층까지 패킷이 보이는지로 의심 구간을 좁히는 절차이고, 여기서는 규칙 카운터가 같은 일을 했다.
## 문제
엣지 nginx 를 호스트에서 게스트 VM 으로 옮긴 뒤 밖에서 들어오는 요청만 엣지에 닿지 않았다.
호스트에서 친 요청 : curl http://192.168.122.10 → 404, 엣지 nginx 가 응답한다
밖에서 친 요청 : curl http://100.83.212.4 → connection refused
호스트에서 친 요청에는 404 를 돌려줬으니 엣지 nginx 는 게스트 안에서 돌고 있었다. 밖에서 친 요청만 끊겼고, 타임아웃이 아니라 즉시 거절이었다.
## 결론
밖에서 온 패킷을 거절한 것은 libvirt 가 만든 규칙이다. libvirt 는 자기 테이블 ip libvirt_network 의 guest_input 체인을 ct state established,related accept 다음의 reject 로 끝낸다. 그 reject 규칙의 카운터가 4 패킷 240 바이트로 밖에서 친 curl 횟수와 정확히 일치해서 범인을 확정했다.
DNAT 를 정의한 파일에는 priority filter - 10 을 줘서 먼저 돌게 한 forward 체인이 있는데, 거기 넣은 ct state new accept 가 그 reject 를 막지 못한다. nftables 가 같은 훅에 붙은 base 체인을 우선순위 순으로 전부 평가하기 때문이다. 앞 체인의 accept 는 그 체인을 통과했다는 뜻이지 평가가 끝났다는 뜻이 아니고, 즉시 종결하는 것은 drop 뿐이다.
해결 : 구멍을 libvirt 체인 맨 앞에 넣는다. insert 가 맨 앞이고 add 가 맨 뒤다.
수명 : libvirt 가 네트워크를 다시 세우면 guest_input 을 새로 쓰면서 그 규칙이 날아간다. 그래서 DNAT 유닛의 ExecStartPost 에 넣는다.
## 검증 환경
호스트 : test-server, Arch Linux
CPU : i5-1135G7, 논리 코어 8
RAM : 11,648MiB
QEMU : 11.1.1
libvirt : 12.7.0
libvirt firewall_backend : nftables
호스트 이더넷 : 없다 — WiFi 만 있다
게스트 네트워크 : libvirt NAT, virbr0
게스트 : Debian 12 genericcloud 3대 — 엣지 1대(nginx·certbot), k3s 2노드
엣지 게스트 주소 : 192.168.122.10
## 재현 조건
1. 엣지 nginx 를 게스트 192.168.122.10 에 두고, 호스트 커널의 DNAT 로 밖에서 들어온 요청을 그 게스트로 넘긴다.
2. 호스트에서 curl http://192.168.122.10 을 친다. 엣지 nginx 가 404 로 응답한다.
3. 밖에서 curl http://100.83.212.4 를 친다. connection refused 가 온다.
4. libvirt 테이블 ip libvirt_network 의 guest_input 체인을 규칙과 카운터까지 덤프한다. 마지막 reject 규칙의 패킷 수가 3번을 친 횟수와 맞으면 그 규칙이 그 패킷을 끝냈다.
5. guest_input 맨 앞에 구멍을 넣고 3번을 다시 친다.
## 본문
<!-- body:start -->
## 엣지 nginx 를 게스트로 옮기고 새로 필요해진 것
이 호스트에는 이더넷이 없고 WiFi 만 있어서 브리지를 못 쓰고, libvirt NAT(`virbr0`)에 호스트로 들어온 요청을 넘기는 구조를 택했다. 그 위에 Debian 12 게스트 세 대가 있고, 그중 한 대가 nginx 와 certbot 을 돌리는 엣지다. 엣지 nginx 는 원래 호스트에 있었고 사는 곳만 게스트로 바꿨다.
```text label="엣지 이동 전과 후"
전: tailnet:443 ─▶ [호스트 nginx] ─────────────▶ Traefik(게스트 .11/.12)
후: tailnet:443 ─▶ [호스트 커널 DNAT] ─▶ [엣지 nginx(.10)] ─▶ Traefik(.11/.12)
```
L7 홉 수는 전후 모두 2홉이고 늘어난 것은 커널이 하는 L4 전달 한 번뿐이라 `X-Forwarded-*` 계약은 그대로 성립한다. 옮긴 이유도 성능이 아니라 더러워지는 층의 격리였다. nginx 설정과 인증서, certbot, deploy 훅은 자주 갈아엎는 것들인데 호스트에 있으면 초기화가 불가능하고, 엣지 장애 실험이 SSH 까지 위험하게 만든다.
그 대가로 일곱 가지가 새로 필요해졌는데 그중 둘은 배포판 차이가 아니라 패킷이 지나는 길이 달라져서 생겼다. 하나는 DNAT(Destination NAT) 다. 들어온 패킷의 도착지 주소를 바꿔 다른 기계로 넘기는 것을 말한다. 전에는 호스트가 직접 `:443` 을 들었으니 넘길 일이 없었는데 지금은 호스트에 리스너가 아예 없다. 다른 하나는 libvirt 방화벽에 구멍을 내는 일이다. 호스트가 게스트에 접속할 때는 OUTPUT 경로라 필터를 안 탔지만, 밖에서 게스트로 들어오는 것은 FORWARD 다. 「호스트가 게스트에 접속한다」와 「밖에서 게스트로 들어온다」는 커널이 보기에 완전히 다른 일이다. 그 구멍을 뚫는 데서 이 구축이 가장 오래 막혔다.
## 호스트 안에서는 되는데 밖에서만 안 된다
엣지 nginx 는 호스트에서 친 요청에 404 를 돌려줬으니 게스트 안에서 살아 있었는데, 밖에서 친 요청만 끊겼다.
| 어디서 쳤나 | 결과 |
|---|---|
| 호스트에서 `curl http://192.168.122.10` | 404, 엣지 nginx 가 응답 |
| 밖에서 `curl http://100.83.212.4` | connection refused |
패킷을 조용히 버리는 `drop` 이면 클라이언트가 응답을 기다리다 죽으므로, 타임아웃이 아니라 즉시 거절이 돌아왔다는 것이 단서였다.
## guest_input 체인 끝의 reject 와 카운터 4 패킷
libvirt 는 자기 테이블 `ip libvirt_network` 안에 `guest_input` 체인을 만들고, 이미 맺어진 연결과 그에 딸린 연결만 통과시킨 다음 나머지를 거절하는 규칙으로 그 체인을 끝낸다.
```text label="libvirt 가 만든 guest_input 체인의 끝"
oif "virbr0" ip daddr 192.168.122.0/24 ct state established,related accept
oif "virbr0" counter packets 4 bytes 240 reject ← 여기서 죽는다
```
규칙 하나를 지목해 놓고 시작한 것이 아니다. `nft list ruleset` 에서 `reject` 와 `drop` 이 든 줄만 뽑아 놓고, 밖에서 친 횟수와 카운터가 맞아떨어지는 줄을 찾았다. 이 `reject` 규칙의 카운터가 4 패킷 240 바이트였고, 밖에서 친 `curl` 횟수와 정확히 일치했다. 범인 확정에 쓴 것이 이 숫자다.
## 왜 앞 체인의 accept 가 안 먹혔나
DNAT 를 정의한 파일에는 libvirt 체인보다 먼저 돌도록 `priority filter - 10` 을 준 `forward` 체인을 두고 거기에 `ct state new accept` 를 넣어 두었다. 밖에서 온 패킷은 그 `accept` 를 지나고도 거절됐는데, nftables 가 같은 훅에 붙은 base 체인을 우선순위 순으로 전부 평가하기 때문이다. 앞 체인의 `accept` 는 「이 체인은 통과」라는 뜻이지 「평가 끝」이 아니고, 즉시 종결하는 것은 `drop` 뿐이다. iptables 감각으로 쓰면 정확히 여기서 틀린다.
그 `forward` 체인은 지금 DNAT 파일에 없다. 남은 체인은 `prerouting` 하나이고, 체인을 그냥 지우는 대신 주석 하나를 남겨 두었다 — 여기에 `forward` 체인을 두지 않은 것이 의도이며 구멍은 유닛의 `ExecStartPost` 가 libvirt 자기 체인 안에 넣는다는 내용이다.
![밖에서 온 패킷 하나가 forward 훅에 붙은 base 체인 둘을 우선순위 순으로 지나는 경로도. 왼쪽 점선 상자가 priority filter - 10 인 forward 체인이고 그 안에 ct state new accept 가 있다. 오른쪽 점선 상자가 ip libvirt_network 의 guest_input 체인이고 그 안에 맨 앞의 inserted accept 와 맨 끝의 reject 두 상자가 있다. 점선 화살표는 구멍을 넣기 전의 경로로 체인 끝 reject 를 지나 connection refused 로 끝나고, 실선 화살표는 넣은 뒤의 경로로 맨 앞의 구멍을 지나 엣지 nginx 로 간다.](../../../final/assets/diagrams/nftables-forward-hook-chain-order/nftables-forward-hook-chain-order.svg)
점선 상자 두 개가 같은 훅에 붙은 base 체인 둘이고, 왼쪽이 먼저 돈다. 점선 화살표는 구멍을 넣기 전의 경로다 — 앞 체인의 `accept` 를 지난 패킷이 `guest_input` 으로 이어지고, 위 덤프에 적힌 `ct state established,related accept` 에 걸리지 못한 채 체인 끝 `reject` 에 닿아 connection refused 로 끝난다.
실선 화살표는 다음 절에서 뚫을 구멍을 지나는 경로다. `guest_input` 상자 안에 놓인 두 규칙의 위아래가 체인 안의 순서이고, 구멍이 위에 있어서 같은 패킷이 아래의 `reject` 를 보기 전에 그 규칙에서 `accept` 된다.
## 구멍은 맨 앞에 넣고, 네트워크를 다시 세울 때 다시 넣는다
그래서 구멍을 libvirt 체인 맨 앞에 뚫었다. `insert` 가 맨 앞이고 `add` 가 맨 뒤다.
```bash label="guest_input 맨 앞에 구멍을 넣는다"
nft insert rule ip libvirt_network guest_input \
oif virbr0 ip daddr 192.168.122.10 tcp dport '{80,443}' ct state new counter accept
```
이 규칙은 휘발성이다. libvirt 가 네트워크를 다시 세우면 `guest_input` 을 새로 쓰면서 규칙이 날아가므로, DNAT 유닛의 `ExecStartPost` 에 넣어 네트워크가 다시 설 때마다 같은 규칙이 다시 들어가게 했다.
그 `ExecStartPost` 줄 앞에는 `-` 를 붙였다. `libvirt_network` 테이블은 가상 네트워크가 올라온 뒤에야 생기므로, 그 전에 유닛이 뜨면 이 줄이 실패한다. `-` 를 붙여 두면 그때도 DNAT 은 그대로 올라가고 구멍만 빠지며, 빠진 구멍은 유닛을 다시 시작해서 넣는다. 그리고 그렇게 걸어 둔 뒤 libvirt 네트워크를 실제로 다시 세워 규칙이 되돌아오는지는 확인하지 않았다.
## 확인하지 못한 것
이 호스트 한 대에서만 봤다. libvirt 의 `firewall_backend` 가 iptables 인 호스트에서도 같은 구멍이 필요한지는 재지 않았고, 이 호스트는 nftables 백엔드다.
카운터 4 패킷 240 바이트는 위에 옮긴 규칙 덤프에 찍힌 값이다. 그 덤프와 `curl` 출력의 원문은 `final/evidence/` 에 파일로 남기지 않았다. 같은 재현을 다시 돌려 출력을 파일로 남기면 그때 원문을 댈 수 있다.
<!-- body:end -->
@@ -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 -->
@@ -0,0 +1,72 @@
---
kind: PROJECT_DECISION
slug: dns-01-because-the-lab-is-not-on-the-public-internet
title: 인증서는 DNS-01 로 받는다 — 실험대 주소가 공개 인터넷에 없어 HTTP-01 이 성립하지 않는다
topic: lab-environment-build
topicName: 실험대 환경 구성
project: virtualization
status: 게시 전
decisionStatus: ADOPTED
sourceRevision: 9465582b5d1630eb4ae7c4e078021486919bf6b6
source:
- final/document.md#190-단계-04-let's-encrypt-와-인증서-갱신
- final/document.md#184-이-부의-출처와-범위
---
# 인증서는 DNS-01 로 받는다 — 실험대 주소가 공개 인터넷에 없어 HTTP-01 이 성립하지 않는다
인증서는 Let's Encrypt 의 DNS-01 로 받는다. HTTP-01 은 80 포트와 공개 A 레코드를 요구하는데 이 실험대의 주소는 공개 인터넷에 없고, certbot 이 DNS 공급자 API 로 나가는 DNS-01 만 성립한다. Cloudflare API 토큰이 엣지 VM 안 평문 파일에 놓이는 비용을 감수한다.
## 근거
- **갱신은 매번 SUCCESS 였고 옛 인증서가 계속 나갔다 — 2305초와 1~2초**
이 결정으로 받은 인증서가 갱신 뒤에 실제로 서빙되는지는 이 결정 밖이고 그 기록이 받는다. 발급 방식을 정하는 것과 갱신된 것을 nginx 가 읽게 만드는 것은 다른 일이다.
- **엣지 nginx 를 호스트에서 게스트 VM 으로 옮긴다 — 성능이 아니라 더러워지는 층의 격리**
그 결정이 새로 요구한 일곱 가지 가운데 7번이 certbot 과 인증서와 갱신 훅을 게스트로 옮기는 것이었다. 이 결정이 세우는 것을 전부 엣지 게스트에 두는 이유가 거기서 왔다.
- **도구가 낸 출력은 대상의 상태가 아니다 — 그 명령이 무엇을 세는지부터 가른다**
체인이 이어졌는지를 openssl s_client 의 단계 수로 판정하는 근거다. cert.pem 을 써서 체인이 끊겨도 브라우저는 캐시나 AIA 로 보완해 정상으로 보인다.
- **가까운 층부터 한 층씩 건너뛰며 확인한다 — 층마다 성공 신호가 다르다**
04 단계의 확인을 어느 기계에서 치는지가 그 기준에 걸려 있다. 엣지 게스트 안에서 치면 층의 답이 아니라 친 위치의 답이 돌아온다.
- **단계별 구축 문서는 작성 시점이 아니라 실행 순서로 검증한다**
이 결정이 남긴 이름 규칙을 가이드가 틀리게 적었고, §182 가 그것을 결함 여섯 중 하나로 셌다. 결함 여섯을 나란히 적은 표는 그 기록에 있다.
## 결정문
인증서는 Let's Encrypt 의 DNS-01 로 받는다. 엣지 게스트에 certbot 과 python3-certbot-dns-cloudflare 를 깐다. Cloudflare API 토큰은 /etc/letsencrypt/cloudflare.ini 에 600 으로 두고, 발급 대상은 hyeonworks.com 과 그 와일드카드 둘을 -d 로 준다.
certbot 과 인증서와 갱신 타이머와 deploy 훅은 전부 엣지 게스트에 두고 물리 호스트에는 아무것도 두지 않는다.
## 판단 이유
두 방식은 검증이 오가는 방향이 반대다. HTTP-01 은 Let's Encrypt 가 우리 서버로 들어오는 인바운드 검증이라 80 포트와 공개 A 레코드가 있어야 한다. DNS-01 은 certbot 이 DNS 공급자 API 로 나가는 아웃바운드 검증이라 공개 인터넷에서 보일 필요가 없다. 이 실험대의 주소는 공개 인터넷에 없으므로 HTTP-01 은 성립하지 않는다.
§190 는 이 선택을 우열로 적지 않는다. 공개 서버라면 HTTP-01 이 맞고, 토큰도 DNS 연동도 없어 관리할 것이 적다고 대안 쪽을 먼저 적는다. 이 결정은 더 나은 방식을 고른 것이 아니라 하나만 성립하는 조건에서 그것을 쓴 것이다. 딸려 온 이득이 하나 있는데, DNS-01 은 와일드카드를 받을 수 있어 hyeonworks.com 아래의 이름을 인증서 한 장으로 덮는다. 그 이득은 뒤늦게 챙겼다. 이 실험대는 처음에 와일드카드를 안 쓰고 이름마다 따로 받았고, 네 번째 이름이 없어 다른 실험에서 app2 를 빌려 써야 했다.
정한 대로 이미 서 있다. 엣지 게스트에는 certbot 과 python3-certbot-dns-cloudflare 가 깔려 있다. certbot plugins 는 dns-cloudflare 와 standalone 과 webroot 세 줄을 내고, 자격증명 파일은 600 으로 놓여 있다. 밖에서 친 openssl s_client 는 체인 0 부터 3 까지 네 단계와 Verify return code: 0 (ok) 를 냈고, curl 은 404 tls=0 을 냈다. 설정 원본은 §184 가 가리키는 저장소의 deploy/lab/edge/ 에 있고 리비전은 frontmatter 에 적었다.
없는 것도 분명하다. HTTP-01 을 실제로 시도해 실패한 기록은 없다. 그 경로가 막혔다는 근거는 시도가 아니라 주소 대역이고, 100.64.0.0/10 이 CGNAT(Carrier-Grade NAT) 예약 대역이라는 것은 이 실험대가 잰 값이 아니라 규격이다.
## 영향
감수한 비용이 셋이다.
Cloudflare API 토큰 : 엣지 VM 안 평문 파일에 놓인다
발급 검증 시간 : TXT 가 퍼질 때까지 기다리느라 수십 초 걸린다. 정상이므로 중간에 끊지 않는다
DNS 공급자 의존 : 인증서를 받는 일이 Cloudflare 계정에 묶인다
토큰이 평문으로 놓이는 것을 줄이려고 가드레일을 넷 둔다.
권한 범위 : Edit zone DNS · Specific zone · hyeonworks.com 으로 좁힌다. All zones 로 두면 계정의 모든 도메인에서 DNS 를 고칠 권한이 그 파일에 놓이고, Global API Key 는 폐기하면 그 키를 쓰던 다른 것들이 전부 같이 못 쓰게 된다
파일 권한 : install -m 600 /dev/null 로 비어 있을 때 먼저 600 을 만든다. 토큰을 쓰고 나서 권한을 고치면 그사이가 열려 있다
확인 방법 : ls -l 이 -rw------- 을 내는지와 바이트 수가 0 이 아닌지만 보고 값은 찍지 않는다
토큰 검증 : Cloudflare 의 user/tokens/verify 가 내는 status 가 active 이고 success 가 true 인지 본다. code 가 6003 이면 값이 틀렸거나 잘렸고 9109 면 권한 범위가 모자라다. 여기서 걸러 두면 뒤에서 실패했을 때 DNS 문제인지 토큰 문제인지가 섞이지 않는다
그래도 토큰이 엣지 안에만 있는 것은 아니다. 원본은 Cloudflare 쪽 서버가 들고 있고 엣지에 놓인 것은 사본이라, 엣지 게스트를 지우는 것으로는 계정 쪽 토큰이 없어지지 않는다. 폐기는 Cloudflare 에서 따로 해야 하는데 가이드는 그것을 적지 않았다.
그 배치가 노린 것이 하나 더 있다. virsh undefine kc-lab-edge 한 줄로 이 계층을 통째로 되돌린다는 것이 가이드가 적은 이유인데, 그것은 게스트를 지우면 같이 없어진다는 말이지 걷어내는 절차가 아니다. 원본 가이드 04 에는 걷어내는 순서도, 지운 뒤에 무엇을 확인하는지도 없고, 이 실험대가 그렇게 지워 본 적도 없다.
발급 자체에도 순서가 붙는다. --dry-run 을 먼저 돌리는 것은 Let's Encrypt 의 주당 중복 인증서 5장 한도를 dry-run 이 쓰지 않기 때문이다. dry-run 은 인증서를 저장하지 않으므로 그 직후 certbot certificates 가 No certificates found 를 내는 것이 정상이다.
이 결정이 남긴 이름 규칙이 하나 있다. live/hyeonworks.com/ 은 certbot 이 이 묶음을 관리하려고 첫 번째 -d 에서 따온 라벨이고 서빙과 무관하다. 브라우저가 보는 유효 호스트명은 -d 로 준 이름 전부이므로 auth.hyeonworks.com 으로 다시 받을 필요가 없다. 대신 nginx 설정에는 그 디렉터리 경로를 한 글자도 다르지 않게 적어야 한다. live/auth.hyeonworks.com/ 이라고 적으면 cannot load certificate 로 막히고, §182 가 이것을 가이드 결함 여섯 중 하나로 셌다. 와일드카드는 한 단계만 덮는다. a.b.hyeonworks.com 도, apex 인 hyeonworks.com 자신도 와일드카드에 들어가지 않아 -d 를 둘 준다.
이 결정이 끝내지 못한 것이 하나 있다. 받은 인증서가 갱신 뒤에 실제로 서빙되는지는 발급 방식과 별개이고, 근거로 건 첫 기록이 그것을 받는다.
@@ -0,0 +1,75 @@
---
kind: PROJECT_DECISION
slug: edge-nginx-moved-into-a-guest-vm
title: 엣지 nginx 를 호스트에서 게스트 VM 으로 옮긴다 — 성능이 아니라 더러워지는 층의 격리
topic: lab-environment-build
topicName: 실험대 환경 구성
project: virtualization
status: 게시 전
decisionStatus: ADOPTED
sourceRevision: 9465582b5d1630eb4ae7c4e078021486919bf6b6
source:
- final/document.md#179-엣지를-물리-호스트에서-vm-으로-옮기면-무엇이-새로-필요해지나
- final/document.md#178-이-부의-출처와-범위
- final/document.md#180-nftables-는-앞-체인의-accept-로-뒤-체인의-reject-를-막지-못한다
---
# 엣지 nginx 를 호스트에서 게스트 VM 으로 옮긴다 — 성능이 아니라 더러워지는 층의 격리
호스트에서 돌던 엣지 nginx 를 게스트 한 대(.10) 안으로 옮기고, 호스트는 커널 DNAT 로 tailnet 의 443 포트를 그 게스트에 넘기기만 한다. 바꾼 이유는 성능이 아니라 자주 갈아엎는 층의 격리다. 밖에서 게스트로 들어오는 경로가 FORWARD 가 되면서 libvirt 방화벽에 구멍이 새로 필요해졌다.
## 근거
- **호스트 안에서는 404, 밖에서는 connection refused — 앞 체인의 accept 가 libvirt 의 reject 를 막지 못했다**
이 결정이 새로 요구한 것 가운데 3번을 실제로 치른 기록이다. 밖에서 게스트로 들어오는 경로가 FORWARD 가 되면서 libvirt 방화벽의 reject 에 막혔고, §180 은 이곳을 이 구축에서 가장 오래 막힌 곳으로 적었다.
- **단계별 구축 문서는 작성 시점이 아니라 실행 순서로 검증한다**
이 구성을 세운 기반 7단계 가이드를 순서대로 따라가면서 나온 결함 여섯이 그 기준의 근거다. 여섯 중 셋이 여기서 옮긴 nginx 와 인증서를 다룬다.
- **libvirt 의 firewall_backend 가 iptables 일 때도 guest_input 에 구멍이 필요한가**
§180 이 미확인으로 남긴 것을 받는 물음이다. 아래 3번은 이 호스트의 nftables 백엔드에서만 확인했다.
- **Guest 의 packet 이 Host Physical NIC 에 닿기까지 — virtio-net · virtqueue · vhost-net · TAP · Bridge**
커널 DNAT 뒤에 이어지는 게스트 쪽 경로를 그 글이 설명한다.
- **이 호스트의 가상 머신 네트워크는 Bridge 인가 NAT 인가 Routing 인가**
§178 이 적은 대로 이 호스트는 이더넷이 없어 libvirt NAT 를 택했다. 그 물음이 실제 구성을 이 호스트에서 확인한다.
## 결정문
엣지 nginx 를 물리 호스트에서 게스트 VM 으로 옮기고, 호스트는 커널 DNAT 로 tailnet 의 443 포트를 그 게스트에 넘기기만 한다.
§179 이 전후 경로를 나란히 적었다.
전 : tailnet 의 443 포트에서 호스트 nginx 를 거쳐 게스트 .11/.12 의 Traefik 으로
후 : tailnet 의 443 포트에서 호스트 커널 DNAT 를 거쳐 엣지 nginx(.10) 으로, 거기서 .11/.12 의 Traefik 으로
## 판단 이유
바꾼 이유로 §179 이 든 것은 성능이 아니라 더러워지는 층의 격리다. nginx 설정과 인증서와 certbot 과 deploy 훅은 자주 갈아엎는 것들이라 호스트에 있으면 초기화가 불가능하다. 엣지에 장애를 일부러 넣어 보는 실험도 호스트에서 하면 SSH 까지 위험해진다.
성능은 근거가 아니다. L7 홉 수는 전후 모두 2홉이고(observed), 늘어난 것은 커널이 하는 L4 전달 한 번뿐이어서 X-Forwarded-* 계약은 그대로 성립한다. 전환 전후를 같은 부하로 잰 측정은 이 저장소에 없으므로, 느려지지 않았다는 말은 이 기록이 하지 않는다.
§178 이 적었듯 이 호스트에는 이더넷 없이 WiFi 만 있어 브리지를 못 쓰고 libvirt NAT(virbr0) 와 호스트 진입 구조를 택했다.
견준 것은 호스트에 두기와 게스트로 옮기기 둘이다. 브리지와 NAT 는 고른 것이 아니라 이더넷이 없어 하나만 남았다.
## 영향
감수한 비용을 §179 이 일곱 줄의 표로 적고, 그중 둘을 나머지 다섯과 갈라 놓았다(inferred). 경로가 OUTPUT 에서 FORWARD 로 바뀌면서 생긴 것이 그 둘이다.
DNAT(2번) : 전에는 호스트가 443 포트를 직접 들었으니 넘길 일이 없었다. 지금은 호스트에 리스너가 아예 없다
libvirt 방화벽에 구멍(3번) : 호스트에서 게스트로 가는 것은 OUTPUT 경로라 필터를 안 탔다. 밖에서 게스트로 들어오는 것은 FORWARD 다
호스트가 게스트에 접속하는 것과 밖에서 게스트로 들어오는 것은 커널이 보기에 완전히 다른 일이다. 3번을 뚫는 데 이 구축에서 가장 오래 걸렸다. §180 에서 밖에서 친 curl 은 connection refused 로 돌아왔다. libvirt 의 guest_input 체인을 끝내는 reject 규칙의 카운터 4 패킷이 그때 친 curl 횟수와 정확히 일치했다. 구멍이 필요하다는 것을 몰라서 오래 걸린 것은 아니다. DNAT 파일에는 priority filter - 10 으로 먼저 도는 forward 체인과 거기 넣은 ct state new accept 가 이미 있었고, 그것이면 열린다고 보고 세웠다 — iptables 감각으로 쓰면 정확히 여기서 틀린다고 §180 이 적었다.
같은 계열의 가드레일이 하나 더 붙는다.
SNAT 금지를 명시(4번) : L4 를 한 번 더 타면서 masquerade 를 붙이고 싶어지는데, 붙이면 엣지가 모든 클라이언트를 192.168.122.1 로 본다
나머지 넷은 배포판이 달라서 생긴 잡무다.
nginx 설치(1번) : 호스트에는 이미 있었다. 새 게스트의 cloud-init 은 curl 과 nftables 만 깐다
sites-available 관례(5번) : 호스트는 Arch 라 그 디렉터리가 없어 nginx.conf 에 include 를 직접 넣었다. 게스트는 Debian 이라 기본으로 있다
nginx 버전 차이(6번) : Arch 1.30 vs Debian 12 의 1.22. http2 on; 지시어가 1.25.1 이상이다
certbot 과 인증서와 갱신 훅이 게스트로(7번) : 인증서를 읽는 주체가 nginx 이기 때문이다
얻은 것은 격리다. 자주 갈아엎는 층이 게스트 한 대 안으로 들어가니 그 게스트를 통째로 다시 세울 수 있고, 엣지에 장애를 넣는 실험이 호스트 SSH 를 건드리지 않는다. 그러면서 L7 홉 수는 2홉 그대로라 뒤쪽 Traefik 이 받는 X-Forwarded-* 계약도 바뀌지 않았다.
아직 재지 않은 것이 셋 있다. 전환 전후를 같은 부하로 잰 측정은 이 저장소에 없다. 3번의 구멍은 휘발성이라 libvirt 가 네트워크를 다시 세우면 없어지고, 그래서 DNAT 유닛의 ExecStartPost 에 넣었다. 그 조치가 재기동을 견디는지 실제로 다시 세워 확인한 출력은 없다. libvirt 의 firewall_backend 가 iptables 일 때도 같은 구멍이 필요한지는 §180 이 미확인으로 남겼고, 이 호스트는 nftables 백엔드다.
@@ -0,0 +1,71 @@
---
kind: PROJECT_DECISION
slug: fix-guest-addresses-with-a-dhcp-reservation
title: 게스트 주소는 libvirt DHCP 예약으로 고정한다 — 바꿀 수 없어서가 아니라 되돌리기가 가장 비싸서
topic: lab-environment-build
topicName: 실험대 환경 구성
project: virtualization
status: 게시 전
decisionStatus: ADOPTED
source:
- final/document.md#247-dhcp-예약-ip-dhcp-host-과-mac-52-54-00
- final/document.md#201-네트워크-dhcp-예약의-실제-동작
- final/document.md#245-libvirt-default-네트워크와-virbr0
- final/document.md#246-dnsmasq-libvirt-내장-dhcp-dns
- final/document.md#248---live---config
sourceRevision: 9465582b5d1630eb4ae7c4e078021486919bf6b6
---
# 게스트 주소는 libvirt DHCP 예약으로 고정한다 — 바꿀 수 없어서가 아니라 되돌리기가 가장 비싸서
게스트 IP 를 libvirt 의 DHCP 예약으로 묶고 예약을 넣은 다음에 게스트를 만든다. 주소가 바뀌면 k3s 가 설정 파일과 인증서에 구워 둔 IP 부터 어긋나, 고치는 값이 한 곳이 아니라 재발급이나 재설치가 된다.
## 근거
- **cloud-init 시드로 게스트 세 대를 만들고 SSH 가 키로 붙게 한다**
이 예약을 실제로 넣는 명령이 그 절차에 있고, DHCP 예약이 `virt-install` 보다 먼저여야 한다는 순서도 거기서 지킨다.
- **k3s server 와 agent 를 게스트 두 대에 깔고 lab host 에서 kubectl 로 본다**
고정한 `192.168.122.11``192.168.122.12` 를 그대로 받아 쓰는 절차다. `--node-ip``--tls-san` 과 agent 의 `K3S_URL` 이 그 주소를 담는다.
- **엣지 게스트에 nginx 를 세우고 호스트 DNAT 으로 밖에서 들어오는 길을 연다**
nginx 의 `upstream` 이 뒤쪽 게스트 주소를 적는 곳이다. 주소가 바뀌면 reload 전까지 502 가 이어진다.
- **호스트에 직접 깔지 않고 게스트 VM 두 대로 간다 — 관측자가 실험 대상과 함께 죽으면 안 된다**
이 결정이 서 있는 바닥이다. 게스트를 반복해 죽이는 실험이 전제라서 주소 고정이 필요해졌다.
- **도구가 낸 출력은 대상의 상태가 아니다 — 그 명령이 무엇을 세는지부터 가른다**
`net-dumpxml` 의 예약과 `net-dhcp-leases` 의 리스가 서로 다른 것을 말한다는 것을 그 기준이 일반화한다.
- **L7 이 두 겹인 이유와, 진입점 하나를 이중화하려 할 때 끝나지 않는 재귀**
nginx 가 뒤쪽 노드를 주소로 가리키는 구조를 그 글이 설명한다. 여기서 고정한 주소가 그 upstream 에 적힌다.
## 결정문
게스트의 IP 는 libvirt `default` 네트워크의 DHCP 예약(`ip-dhcp-host`)으로 고정한다. 게스트 안에서 static IP 를 잡지 않고, MAC 은 QEMU/KVM 에 할당된 `52:54:00` 대역을 쓴다. 예약을 먼저 넣고 그다음에 `virt-install` 로 게스트를 만든다.
2026-09-10 에 실제로 돌린 결과가 그렇게 나왔다. `virsh net-update default add ip-dhcp-host ... --live --config``Updated network default persistent config and live state` 를 냈고, 예약을 먼저 넣고 만든 게스트가 첫 부팅에서 바로 `192.168.122.10` 을 받았다.
## 판단 이유
§247 이 인과 순서를 직접 못박아 두었다. upstream 에 IP 를 박으려고 예약을 거는 것이 아니다. 고정 주소가 필요한 이유가 여럿이고 그것을 충족하는 수단이 DHCP 예약이며, 그렇게 얻은 주소를 upstream 에도 적는 순서다.
고정이 필요한 이유를 §247 이 중요도 순으로 넷 든다.
k3s 가 IP 를 설정 파일과 인증서에 굽는다 : `--node-ip``--tls-san`, agent 의 `K3S_URL=https://192.168.122.11:6443`, kubeconfig 의 `server:` 필드가 전부 IP 를 담는다. server 노드의 IP 가 바뀌면 agent 가 클러스터에 합류하지 못한다. API 서버 인증서의 SAN 도 어긋나서 재발급이나 재설치가 필요해진다. 되돌리기가 가장 비싼 항목이다
nginx 는 upstream 주소를 기동 시점에 한 번만 해석한다 : 오픈소스판은 `upstream` 블록의 이름을 설정 로드 때 풀고 런타임에 다시 조회하지 않는다. 그래서 뒤쪽 IP 가 바뀌면 reload 전까지 계속 502 다. 다시 조회하게 하려면 `resolver` 와 변수 조합을 쓰거나 상용판이 필요하다
VM 을 반복해서 죽이는 것이 실험 그 자체다 : `virsh destroy` 로 노드 상실을 재현하는데 되살릴 때마다 주소가 달라지면 실험이 성립하지 않는다
장애 주입 규칙이 주소 기반이다 : 「kc-lab-2 로 가는 7800 을 막아라」에서 IP 가 어긋나면 조용히 엉뚱한 것을 막는다. 실패가 드러나지 않아 특히 위험하다
수단은 셋을 견줬고 §247 이 표로 적었다. 게스트 안에서 static IP 를 설정하면 cloud-init 이 복잡해지고 libvirt 는 그 사실을 모르므로 주소 설정이 두 곳으로 흩어진다. upstream 에 호스트명을 쓰면 libvirt 의 dnsmasq 가 이름을 풀어 주기는 한다. 다만 호스트의 리졸버가 `virbr0` 를 바라봐야 하고, 기동 시 한 번만 해석한다는 둘째 문제는 이 방법으로 없어지지 않는다.
DHCP 예약을 고른 까닭은 주소 관리가 libvirt 한 곳에 모이기 때문이다. 그 한 곳이 libvirt 가 네트워크마다 하나씩 띄우는 dnsmasq 이고, 예약은 네트워크 정의 XML 의 `<ip><dhcp><host>` 요소에 들어간다. 게스트는 평범한 DHCP 클라이언트로 두면 되고, 게스트 쪽 설정 파일은 아무것도 손대지 않는다.
## 영향
값이 맞으면 조용히 되고 틀리면 더 조용히 틀린다. 그래서 치른 비용이 셋 다 「오류가 안 나는 실패」다.
순서가 결과를 바꾼다 : 예약을 넣고 나서 `virt-install` 해야 한다. 게스트를 먼저 만들면 동적 대역(`192.168.122.2`부터 `192.168.122.254`)에서 아무 주소나 받고, 예약이 먹으려면 리스 만료를 기다리거나 재부팅해야 한다
MAC 이 한 글자만 달라도 조용히 무시된다 : 예약의 `mac``virt-install --network` 에 준 `mac=` 이 정확히 같아야 한다. 다르면 오류 메시지 없이 동적 범위에서 아무 주소나 받고, 증상은 「왜 IP 가 다르지」로만 나타난다
플래그 둘을 다 줘야 한다 : `--live` 만 주면 재부팅에 사라지고 `--config` 만 주면 지금 반영이 안 된다. 성공 판정은 출력에 `persistent config``live state` 두 마디가 다 나오는 것이고, 한쪽만 나온 것을 성공으로 읽으면 「재부팅하니 IP 가 바뀐다」가 된다
예약을 조회하는 두 명령도 서로 다른 것을 말한다. `net-dumpxml` 이 보여 주는 예약은 dnsmasq 에게 준 의도이고, `net-dhcp-leases` 가 보여 주는 리스는 실제로 나간 기록이다. 둘이 다를 수 있으므로 예약을 넣었다는 것만으로 게스트가 그 주소를 받았다고 읽지 않는다.
동적 범위가 예약 주소를 품고 있는 것은 이대로 두었다. 현재 범위 `192.168.122.2`부터 `192.168.122.254` 안에 `192.168.122.11``192.168.122.12` 가 들어간다. 그래도 dnsmasq 는 정적으로 예약된 주소를 다른 클라이언트에게 내주지 않아 정상 동작한다. 더 방어적으로 가려면 범위를 `192.168.122.100`부터 `192.168.122.254` 로 좁혀 예약 대역과 나눈다.
네트워크가 비활성일 때는 `--live` 를 쓸 수 없다. 그때는 `--config` 만 주고 네트워크를 시작한다.
@@ -0,0 +1,74 @@
---
kind: PROJECT_DECISION
slug: no-docker-on-the-lab-host
title: lab host 에는 Docker 를 깔지 않는다 — 이미지 저장소가 갈리고, 그 위에 네트워크 변수가 하나 더 붙는다
topic: lab-environment-build
topicName: 실험대 환경 구성
project: virtualization
status: 게시 전
decisionStatus: ADOPTED
source:
- final/document.md#281-docker를-lab-host에-설치하면-안-되는-이유
- final/document.md#282-그러면-이미지는-어떻게-넣는가
- final/document.md#280-무엇을-어디에-설치하는가
sourceRevision: 9465582b5d1630eb4ae7c4e078021486919bf6b6
---
# lab host 에는 Docker 를 깔지 않는다 — 이미지 저장소가 갈리고, 그 위에 네트워크 변수가 하나 더 붙는다
Docker 는 워크스테이션에만 두고 lab host 와 게스트에서는 뺀다. 깔면 containerd 가 둘이 되어 `docker build` 한 이미지를 k3s 가 보지 못하고, 증상이 `docker images` 에는 보이는데 파드는 `ErrImageNeverPull` 인 모양으로 나온다.
## 근거
- **호스트에 직접 깔지 않고 게스트 VM 두 대로 간다 — 관측자가 실험 대상과 함께 죽으면 안 된다**
같은 축의 결정이다. 호스트를 진입점과 하이퍼바이저로만 남긴다는 그 판단이 컨테이너 런타임 쪽에서 한 번 더 쓰였다.
- **k3s server 와 agent 를 게스트 두 대에 깔고 lab host 에서 kubectl 로 본다**
k3s 가 자체 containerd 를 들고 들어오는 절차다. 그 절차가 끝난 뒤에 「이미지는 어떻게 넣지」가 처음 나온다.
- **Keycloak 2노드와 PostgreSQL 을 k3s 에 올리고 클러스터가 묶였는지 확인한다**
공개 이미지만 쓰는 배포라 반입이 필요 없는 쪽의 예다. 자체 빌드 이미지와 갈리는 지점을 그 절차가 보여 준다.
- **Prometheus 와 Grafana 를 올려 클러스터 안을 밖에서 본다**
같은 이유로 공개 이미지를 그대로 당겨 쓴다. 노드마다 따로 반입해야 하는 일이 여기서는 생기지 않는다.
- **도구가 낸 출력은 대상의 상태가 아니다 — 그 명령이 무엇을 세는지부터 가른다**
`docker images` 가 보여 주는 목록이 k3s 의 이미지 목록이 아니라는 것을 그 기준이 일반화한다.
## 결정문
lab host 와 게스트에는 Docker 를 설치하지 않는다. 이미지 빌드는 워크스테이션에서만 하고, 자체 빌드 이미지는 `docker save` 로 내보내 lab host 를 경유해 게스트의 `k3s ctr images import` 로 밀어 넣는다.
§280 의 설치 위치 표가 그 상태를 적는다. libvirt 와 QEMU 와 nginx 와 certbot 과 `kubectl` 은 lab host 에, k3s 는 게스트에, Docker 는 워크스테이션에만 있다.
## 판단 이유
k3s 는 자체 containerd 를 번들한다. Docker 와 무관하게 이미 완결된 스택이고, 소켓도 이미지 저장 경로도 다르다.
k3s 의 소켓 : `/run/k3s/containerd/containerd.sock`
Docker 의 소켓 : `/run/containerd/containerd.sock`
k3s 의 이미지 저장 : `/var/lib/rancher/k3s/agent/containerd/`
Docker 의 이미지 저장 : `/var/lib/docker/`
Docker 를 깔면 containerd 인스턴스가 두 개가 되고, 둘은 서로의 이미지를 알지 못한다. `docker build` 한 것은 `/var/lib/docker/` 로 들어가는데 k3s 는 거기를 보지 않아서, 파드를 만들면 이미지가 없다고 한다. 그래서 증상이 `docker images` 에는 보이는데 파드는 `ErrImageNeverPull` 인 모양으로 나오고, 원인이 눈에 보이지 않아 오래 헤맨다.
충돌은 저장소 말고도 §281 이 넷을 더 든다.
cgroup 드라이버 : dockerd 의 기본은 `cgroupfs` 이고 k3s 는 `systemd` 다. 한 노드에서 두 관리자가 cgroup 트리를 다툰다
iptables 와 nftables : Docker 가 `DOCKER``DOCKER-USER` 체인과 MASQUERADE 규칙을 심는다. flannel 규칙과 순서가 엉키면 파드 트래픽이 Docker 규칙에 걸린다
브리지 대역 : `docker0``172.17.0.0/16` 을 점유한다. 클러스터 서비스 대역이나 사내망과 겹치면 라우팅이 깨진다
디스크 : 같은 이미지가 두 벌 저장된다
이 실험대에는 이유가 하나 더 붙는다. lab host 에서 libvirt 가 `virbr0` NAT 와 자체 방화벽 규칙을 이미 운영하고 있어서, Docker 의 iptables 규칙이 그 위에 얹히면 게스트 네트워크가 예측 불가능해진다. 네트워크 장애를 일부러 주입하는 실험대에서 원인 불명의 네트워크 변수를 늘리면 실험 결과인지 환경 문제인지 구분할 수 없게 된다.
대안 하나를 기각했다. `k3s server --docker` 로 Docker 를 런타임으로 지정하는 방법이 과거에 있었지만, 쿠버네티스 1.24 의 dockershim 제거 이후로는 별도 `cri-dockerd` 를 요구하며 권장되지 않는다. 얻는 것이 없다.
## 영향
이미지를 넣는 길 셋 가운데 둘째로 좁아졌다. 공개 레지스트리에서 당기는 쪽과 클러스터 안에 레지스트리를 두는 쪽이 나머지 둘이고, 지금은 `ctr images import` 로 반입한다.
Keycloak 과 PostgreSQL 과 Redis 처럼 공식 이미지를 쓰는 것은 아무 준비도 필요 없다. 값을 치르는 쪽은 자체 빌드 이미지인 BFF 와 token-mediator 와 echo 다. 워크스테이션에서 `docker save` 로 내보내 lab host 를 거쳐 게스트로 흘려 넣어야 하고, 그때 주의가 셋 붙는다.
노드마다 따로 반입한다 : 스케줄러가 어느 노드에 배치할지 모르기 때문에, 한쪽에만 있으면 반대편에 배치될 때 실패한다
매니페스트에 `imagePullPolicy: Never` 를 준다 : 없으면 로컬에 이미지가 있어도 레지스트리에서 당기려 시도하다 실패한다
`ctr` 이 아니라 `k3s ctr` 을 쓴다 : `k3s ctr` 은 k3s 의 containerd 소켓을 가리키는 래퍼다. 시스템에 별도 `ctr` 이 있으면 다른 소켓을 보게 되어 「성공했는데 파드는 이미지를 못 찾는」 상태가 된다
ssh 가 두 번 중첩되는 것도 값이다. 게스트가 lab host 의 libvirt NAT 뒤에 있어 워크스테이션에서 직접 붙지 못하고, lab host 의 `~/.ssh/config` 에 있는 `kc-lab-*` 별칭을 거쳐야 한다. 명령 한 줄이 `docker save` 와 바깥 ssh 와 안쪽 ssh 와 `k3s ctr images import` 를 한꺼번에 담게 된다.
되돌릴 조건은 정해 두었다. 빌드와 배포 반복이 잦아지면 셋째 길인 클러스터 내 레지스트리로 옮긴다. 지금은 자체 이미지가 셋뿐이라 반입 한 번이 레지스트리를 세우고 유지하는 것보다 싸다.
@@ -0,0 +1,70 @@
---
kind: PROJECT_DECISION
slug: two-guest-vms-instead-of-installing-k3s-on-the-host
title: 호스트에 직접 깔지 않고 게스트 VM 두 대로 간다 — 관측자가 실험 대상과 함께 죽으면 안 된다
topic: lab-environment-build
topicName: 실험대 환경 구성
project: virtualization
status: 게시 전
decisionStatus: ADOPTED
source:
- final/document.md#213-왜-호스트에-직접-깔지-않고-vm-2대인가
- final/document.md#313-k3s-server와-agent-죽였을-때가-다르다
- final/document.md#218-실험대-전체-배치-2026-09-03-구축-완료-실측값
sourceRevision: 9465582b5d1630eb4ae7c4e078021486919bf6b6
---
# 호스트에 직접 깔지 않고 게스트 VM 두 대로 간다 — 관측자가 실험 대상과 함께 죽으면 안 된다
k3s 를 게스트 VM 두 대에 나눠 깔고 물리 호스트는 진입점과 하이퍼바이저로만 남긴다. 머신이 한 대뿐이라 같은 커널에 두 노드를 올리면 노드 상실 실험이 성립하지 않고, 호스트가 노드를 겸하면 SSH 와 libvirt 와 nginx 가 실험 대상과 같이 내려간다.
## 근거
- **엣지 nginx 를 호스트에서 게스트 VM 으로 옮긴다 — 성능이 아니라 더러워지는 층의 격리**
같은 논리를 엣지 한 층에 다시 적용한 결정이다. 그쪽은 게스트 구조가 이미 있다고 놓고 엣지가 어디 사는지를 정했고, 이쪽은 게스트 구조를 쓸 것인지를 정했다.
- **lab host 에 가상화 패키지를 깔고 virsh 가 sudo 없이 돌게 만든다**
이 결정이 요구한 첫 절차다. 호스트에 k3s 대신 libvirt 와 QEMU 가 깔리는 까닭을 이 결정이 댄다.
- **k3s server 와 agent 를 게스트 두 대에 깔고 lab host 에서 kubectl 로 본다**
결정을 실제로 실행한 절차다. server 와 agent 가 서로 다른 게스트로 들어가고 `kubectl` 은 호스트에서 돈다.
- **Prometheus 와 Grafana 를 올려 클러스터 안을 밖에서 본다**
관측 스택을 server 노드에 못박는 절차가 거기 있다. 관측자를 살려 둔다는 이유가 클러스터 안에서 한 번 더 쓰인다.
- **lab host 에는 Docker 를 깔지 않는다 — 이미지 저장소가 갈리고, 그 위에 네트워크 변수가 하나 더 붙는다**
호스트를 깨끗이 남긴다는 같은 축의 결정을 컨테이너 런타임 쪽에 적용한 것이다.
- **실험대를 껐다 켜고 게스트 메모리를 다시 나눈다**
게스트로 갔기 때문에 가능해진 운영이다. `virsh setmaxmem` 으로 메모리를 다시 나누는 일은 호스트에 직접 깔았으면 생기지 않는다.
## 결정문
k3s server 를 게스트 `kc-lab-1` 에, agent 를 게스트 `kc-lab-2` 에 깐다. 물리 호스트 `test-server` 에는 nginx 와 libvirt/KVM 만 두고 k3s 를 설치하지 않는다.
2026-09-03 에 구축이 끝난 배치가 그 상태다. 게스트 둘이 `virbr0` 뒤에서 `192.168.122.11``192.168.122.12` 를 쓰고, 호스트에는 nginx 와 libvirt/KVM 만 있다. 나중에 엣지 게스트가 한 대 더 붙어 셋이 되는데, 그것은 엣지를 게스트로 옮긴 결정이 따로 받는다.
## 판단 이유
물리 머신이 한 대다. 그 한 대로 무엇을 재려 하는가가 이 판단을 갈랐다. §213 은 「나중에 반드시 다시 묻게 되는 판단이므로 근거를 남긴다」로 시작해 이유 여섯을 중요도 순으로 적는다.
독립 커널이 둘 필요하다 : 같은 커널에 k3s server 와 agent 를 올리면 노드가 이름뿐이라 노드 간 방화벽·파티션·노드 상실 실험이 성립하지 않는다
파괴 실험 후 복원 : VM 은 qcow2 오버레이를 지우면 몇 초 만에 초기 상태로 돌아가고, 호스트는 재설치 말고 되돌릴 방법이 없다
관측자를 살려 둔다 : 노드를 죽이는 실험인데 그 노드가 호스트면 SSH 와 libvirt 와 nginx 가 같이 죽는다
호스트 오염 방지 : k3s 는 nftables 규칙과 CNI(Container Network Interface, 컨테이너 네트워크 규격) 인터페이스, 커널 모듈, systemd 유닛을 대량으로 심는다
운영 배포판과 일치 : 호스트는 Arch 인데 운영 k3s 가 다른 배포판이면 커널과 systemd 차이가 잡음이 된다. 게스트를 운영과 같게 맞추면 그 잡음이 사라진다
netem 격리 : 커널이 분리되어 지연 주입이 게스트 안에 갇힌다. 호스트에서 걸면 SSH 까지 느려진다
견준 쪽은 호스트에 단일 노드 k3s 를 직접 까는 것이다. §213 은 그것을 정직한 반대편으로 직접 적었다 — 계약 검증(2홉 헤더, 쿠키/origin)만 볼 거라면 호스트 직접 설치로 충분하고 그게 더 빠르다. VM 경로가 필요해지는 것은 클러스터와 장애 실험부터다.
절충안도 하나 검토하고 채택하지 않았다. 호스트를 노드 1로 쓰고 VM 을 노드 2로 두는 방법이다. 게스트 운영체제 하나(약 350MB)와 설치 수고를 아끼는 대신 관측자 분리와 호스트 오염 방지를 포기하게 된다. 7.4Gi 예산에서 그 350MB 보다 관측자 분리가 더 값지다고 판단했다.
## 영향
실험 범위를 넓히는 대가로 구축 시간과 게스트 운영체제 몫의 메모리를 치렀다. 호스트에 단일 노드로 깔았으면 하루 안에 끝났을 구축이 게스트 생성과 cloud-init 과 k3s 설치로 나뉘었고, 게스트마다 커널과 systemd 가 따로 돌아 그만큼의 메모리를 쓴다.
얻은 것은 되돌릴 수 있는 실험대다. 노드를 죽여도 `virsh start` 로 되살아나고, 게스트를 통째로 지워도 `base.qcow2` 위에 오버레이를 다시 얹으면 몇 초 만에 초기 상태가 된다. 호스트의 인증서와 DNS 와 진입 설정은 그동안 한 번도 건드려지지 않는다.
같은 구분이 클러스터 안에서 한 번 더 나온다. k3s server 와 agent 는 죽였을 때가 다르다.
kc-lab-1(server) 를 죽이면 : `kubectl` 이 안 되고 DNS 와 인그레스도 사라진다
kc-lab-2(agent) 를 죽이면 : 그 노드의 워크로드만 사라지고 클러스터 제어는 살아 있다
그래서 노드 상실 실험은 agent 를 죽이는 것이고, server 를 죽이는 것은 노드 상실이 아니라 컨트롤 플레인 상실이다. §313 은 이 사실을 모르고 「keycloak 하나만 있는 노드를 죽이자」고 계획했다가 실제 배치를 조회한 뒤 정정했다고 적는다. 노드가 둘뿐이라 관측과 실험을 완전히 갈라놓을 수는 없으므로 §330 이 규칙으로 못박았다 — 관측 스택은 server 쪽에 두고 죽이지 않으며, 장애 주입은 agent 쪽에만 건다. `nodeSelector` 로 배치를 고정해야 그 규칙이 재현된다.
확인하지 않은 것이 하나 있다. 호스트 직접 설치와 게스트 두 대를 같은 실험으로 견준 측정은 이 저장소에 없다. 여섯 이유는 무엇이 성립하고 무엇이 성립하지 않는가에 대한 판단이고, 어느 쪽이 얼마나 느린지는 재지 않았다.
@@ -0,0 +1,94 @@
---
kind: QUESTION
slug: guest-input-hole-under-the-iptables-backend
title: libvirt 의 firewall_backend 가 iptables 일 때도 guest_input 에 구멍이 필요한가
topic: lab-environment-build
topicName: 실험대 환경 구성
project: virtualization
status: 게시 전
questionStatus: OPEN
sourceRevision: 9465582b5d1630eb4ae7c4e078021486919bf6b6
source:
- final/document.md#183-이-부에서-파생될-open-question-oq-1
- final/document.md#180-nftables-는-앞-체인의-accept-로-뒤-체인의-reject-를-막지-못한다
---
# libvirt 의 firewall_backend 가 iptables 일 때도 guest_input 에 구멍이 필요한가
libvirt 의 firewall_backend 가 iptables 일 때도 guest_input 에 구멍이 필요한지는 재지 않았다. nftables 백엔드에서는 그 체인의 reject 가 밖에서 온 요청을 connection refused 로 끊었고, forward 체인의 accept 도 막지 못했다. 백엔드만 바꿔 재면 닫힌다.
## 관계
- **호스트 안에서는 404, 밖에서는 connection refused — 앞 체인의 accept 가 libvirt 의 reject 를 막지 못했다**
그 기록이 nftables 백엔드에서 증상과 원인과 해결을 닫았고, 이 물음은 거기서 미확인으로 표시된 한 줄을 받는다.
- **엣지 nginx 를 호스트에서 게스트 VM 으로 옮긴다 — 성능이 아니라 더러워지는 층의 격리**
그 결정이 감수한 일곱 가지 중 3번이 libvirt 방화벽에 구멍을 뚫는 일이라, 이 물음의 답이 그 비용의 적용 범위를 정한다.
- **Guest 의 packet 이 Host Physical NIC 에 닿기까지 — virtio-net · virtqueue · vhost-net · TAP · Bridge**
밖에서 들어온 패킷이 게스트까지 가는 경로를 그 기록이 설명한다.
- **이 호스트의 가상 머신 네트워크는 Bridge 인가 NAT 인가 Routing 인가**
밖에서 게스트로 들어오는 경로가 FORWARD 를 타는지가 그 모드에서 갈린다.
## 사실
- 제3부가 그린 게스트 패킷 경로 위에서 이 구축이 가장 오래 막힌 지점이 여기라고 §180 이 적었다.
- 호스트에서 curl http://192.168.122.10 을 치면 엣지 nginx 가 404 로 응답했다.
- 밖에서 curl http://100.83.212.4 을 치면 connection refused 가 왔다. 드롭이면 기다리다 죽으니, 타임아웃이 아니라 즉시 거절이라는 점이 단서였다.
- libvirt 는 자기 테이블 ip libvirt_network 의 guest_input 체인을 reject 로 끝낸다. 그 앞에는 established,related 를 accept 하는 규칙이 있다.
- 그 reject 규칙의 카운터가 4 패킷 240 바이트였고 밖에서 친 curl 횟수와 정확히 일치했다. 범인은 그 숫자로 확정했다.
- DNAT 파일에는 priority filter - 10 으로 먼저 도는 forward 체인이 있고 거기에 ct state new accept 를 넣어 두었다. 그런데도 패킷은 뒤 체인에서 거절됐다.
- nftables 는 같은 훅에 붙은 base 체인을 우선순위 순으로 전부 평가한다. 앞 체인의 accept 는 이 체인은 통과라는 뜻이고, 평가를 즉시 끝내는 것은 drop 이다. iptables 감각으로 쓰면 정확히 여기서 틀린다.
- 구멍은 libvirt 체인 맨 앞에 뚫었다. insert 가 맨 앞이고 add 가 맨 뒤다.
- 그 규칙은 휘발성이다. libvirt 가 네트워크를 다시 세우면 guest_input 을 새로 쓰면서 날아가므로 DNAT 유닛의 ExecStartPost 에 넣었다.
- 이 호스트는 nftables 백엔드다. firewall_backend 가 iptables 일 때도 같은지는 재지 않았다고 §180 이 미확인으로 표시했다.
## 가정
- 백엔드를 iptables 로 바꿔도 libvirt 가 밖에서 게스트로 들어오는 경로에 자기 규칙을 만든다고 본다. 확인한 것은 nftables 백엔드 하나뿐이라, 규칙을 쓰는 도구만 달라지고 libvirt 가 그 경로를 거른다는 것 자체는 같다고 전제한다.
- 엣지 게스트와 DNAT 구성은 그대로 두고 백엔드만 바꾼다고 본다. 둘을 같이 바꾸면 결과가 어느 쪽 때문인지 가려지지 않는다.
- §178 이 적은 test-server 한 대에서 잰다고 전제한다. 다른 배포판이나 다른 libvirt 버전에서 같은 결과가 나오는지는 이 물음이 묻지 않는다.
- 밖에서 치는 경로는 전과 같다고 본다. §180 의 측정이 밖에서 100.83.212.4 로 친 요청이었으므로 같은 주소를 같은 방법으로 친다.
## 미지수
- firewall_backend 를 iptables 로 둔 호스트에서 밖에서 게스트로 가는 FORWARD 경로를 무엇이 끝내는지.
- 그때 forward 체인의 ct state new accept 가 실제로 먹는지, 아니면 거기서도 libvirt 쪽 규칙에 구멍을 따로 뚫어야 하는지.
- 구멍이 필요하다면 그 방법이 nftables 에서 쓴 insert 맨 앞 규칙과 어떻게 다른지, 그리고 그 규칙도 네트워크를 다시 세우면 날아가는지.
## 제약
- 잴 수 있는 호스트가 한 대다. §178 이 적은 test-server 는 Arch Linux, i5-1135G7 논리 코어 8, RAM 11,648MiB, QEMU 11.1.1 · libvirt 12.7.0 이다.
- 이더넷 없이 WiFi 만 있어 브리지를 못 쓰고 libvirt NAT(virbr0) + 호스트 진입 구조를 택했다. 밖에서 들어온 패킷이 FORWARD 를 타는 것이 그 구성 때문이라, 네트워크 모드가 달라지면 이 물음이 묻는 상황도 달라진다.
- 백엔드를 바꾸려면 libvirt 네트워크를 다시 세워야 하고, 그때 guest_input 에 뚫어 둔 구멍이 날아간다. 엣지 게스트가 밖에서 들어오는 요청을 받는 진입점이므로 되돌릴 절차를 먼저 준비한다.
- 이 물음에 쓸 측정 원문이 아직 없다. §180 의 curl 출력도 규칙 덤프도 final/evidence/ 에 없어서, 카운터 4 패킷 240 바이트의 근거는 SSOT 본문에 옮겨 적힌 덤프뿐이다.
## 선택지
### 1. 구멍을 뺀 채 백엔드만 iptables 로 바꾸고 밖에서 한 번 친다
§183 이 적은 그대로다. 백엔드를 바꾸면서 libvirt 네트워크를 다시 세우면 guest_input 의 구멍은 어차피 날아가기 때문에, 구멍 없는 상태가 저절로 만들어진다. 그 상태에서 밖에서 엣지로 curl 을 치고 응답인지 connection refused 인지, 그리고 거기까지 걸린 시간을 적는다. 같은 시각에 libvirt 가 만든 규칙을 그대로 덤프해 어느 규칙이 패킷을 끝내는지 본다. 규칙마다 카운터가 붙어 있으면 그 값이 친 횟수와 맞는지도 함께 읽는다. nftables 에서 범인을 지목한 것이 그 카운터였다.
§180 이 적은 그 forward 체인은 지금 DNAT 파일에 없다. §189 가 싣는 파일에 남은 체인은 prerouting 하나이고, accept 는 유닛의 ExecStartPost 가 libvirt 체인 안에 넣는다. 그래서 아래 셋 가운데 마지막을 재려면 그 체인을 먼저 되돌려 놓아야 한다.
읽어 낼 것은 셋이다.
밖에서 친 요청의 결과 : 응답인가 connection refused 인가
패킷을 끝낸 규칙 : 어느 테이블의 어느 체인인가
forward 체인의 accept : 먹었는가 먹지 않았는가
### 2. 구멍을 남겨 둔 채 백엔드만 바꾼다 — 제외
구멍이 이미 열려 있으면 요청은 통과하고, 그 통과가 구멍 덕분인지 백엔드가 원래 막지 않아서인지 가려지지 않는다.
### 3. libvirt 가 iptables 백엔드에서 만드는 규칙을 문서로 읽어 견준다 — 제외
호스트를 건드리지 않아도 되지만 답이 나오지 않는다. §180 에서도 규칙 목록이 아니라 카운터가 범인을 지목했으니, 우선순위가 다른 base 체인 둘이 한 훅에 붙었을 때 어느 쪽이 패킷을 끝내는지는 이 구성에서 돌려 봐야 나온다.
## 다음 검증
1. libvirt 의 firewall_backend 를 iptables 로 두고 네트워크를 다시 세운다. 이때 guest_input 에 뚫어 둔 구멍이 날아가므로 구멍 없는 상태에서 시작한다.
2. 밖에서 엣지로 curl 을 치고 결과가 응답인지 connection refused 인지, 그리고 거기까지 걸린 시간을 적는다.
3. 같은 시각에 libvirt 가 만든 규칙을 그대로 덤프해 어느 규칙이 패킷을 끝내는지와 그 카운터가 친 횟수와 맞는지를 본다.
4. 출력 원문은 final/evidence/raw/ 에 남기고, 그 실행의 명령과 cwd 와 실행 시각과 종료 코드는 meta/ 에 적는다.
5. 백엔드를 nftables 로 되돌리고 DNAT 유닛의 ExecStartPost 가 구멍을 다시 뚫는지 확인한다.
닫는 조건 : 백엔드를 iptables 로 둔 상태에서 밖에서 친 요청이 응답을 받으면 그 백엔드에서는 구멍이 필요 없다고 적고 닫는다. 여전히 거절되면 어느 규칙이 끝냈는지와 그때의 구멍 방법을 「호스트 안에서는 404, 밖에서는 connection refused」 기록의 해결 절에 행으로 더한 뒤 닫는다. 어느 쪽이든 그 결과가 엣지를 게스트로 옮기며 감수한 비용 3번의 적용 범위를 정한다. 지금 그 비용은 nftables 백엔드에서만 확인했다.
@@ -0,0 +1,115 @@
---
kind: QUESTION
slug: is-this-lab-issuing-certificates-with-http-01-or-dns-01
title: 이 실험대의 certbot 은 HTTP-01 로 받고 있는가 DNS-01 로 받고 있는가
topic: lab-environment-build
topicName: 실험대 환경 구성
project: virtualization
status: 게시 전
questionStatus: OPEN
sourceRevision: 9465582b5d1630eb4ae7c4e078021486919bf6b6
source:
- final/document.md#204-재구축할-때-무엇이-남아-있나
- final/document.md#266-dns-01-은-언제-쓰는가-네-가지-경우
- final/document.md#265-도메인-검증-http-01-vs-dns-01
---
# 이 실험대의 certbot 은 HTTP-01 로 받고 있는가 DNS-01 로 받고 있는가
어느 방식으로 받고 있는지 아직 읽지 못했다. §266 의 결론과 §190 은 DNS-01 을 가리키는데 원본 가이드 04 는 HTTP-01 로 적혀 있다. `authenticator` 한 값이면 갈리지만 호스트의 `sudo` 가 비밀번호를 요구해 비대화식으로 못 읽었다.
## 관계
- **인증서는 DNS-01 로 받는다 — 실험대 주소가 공개 인터넷에 없어 HTTP-01 이 성립하지 않는다**
그 결정이 적은 것이 의도인지 이 호스트의 실제 설정인지를 이 물음이 가른다.
- **DNS-01 으로 와일드카드 인증서를 받고 갱신이 서빙까지 닿게 한다**
그 절차가 `--dns-cloudflare` 로 받는 길을 적는다. 이 호스트가 그 길로 받았는지는 확인하지 않았다.
- **실험대를 철거하고 무엇이 남는지 확인한다**
그 절차의 「인증서를 지우지 않는다」가 이 물음의 답에 걸려 있다.
- **갱신은 매번 SUCCESS 였고 옛 인증서가 계속 나갔다 — 2305초와 1~2초**
갱신은 됐는데 서빙까지 안 간 사건이다. 이 물음이 캐는 것은 갱신 자체가 도는지다.
- **공개 터널을 쓰지 않고 tailnet 직결로 둔다 — 홉이 하나 늘면 재려던 계약이 오염된다**
공개 인터넷에서 이 호스트에 닿을 길을 두지 않기로 한 결정이고, HTTP-01 이 성립하지 않는 조건을 그 결정이 만든다.
- **가이드대로 다시 쳐서 이 실험대가 같은 상태로 서는가**
그 물음이 재는 7단계 가운데 04 의 통과 조건이 이 답으로 확정된다.
## 사실
§266 이 문서 둘이 어긋나 있다고 직접 적었다. 그 절의 결론과 §190 은 DNS-01 을 가리킨다.
원본 가이드 `docs/guides/04-tls/README.md``certbot certonly --webroot` 로 적혀 있다. 전제도 공개 DNS 에 이름 셋이 이 호스트를 가리켜야 한다는 것이다.
`dig +short auth.hyeonworks.com``100.83.212.4` 를 낸다.
`100.64.0.0/10` 은 CGNAT(Carrier-Grade NAT, 통신사 공용 주소 변환)용 예약 대역이라 공개 인터넷에서 라우팅 자체가 안 된다. 방화벽을 여는 문제가 아니라 그 주소가 인터넷에 존재하지 않는다. 이것은 규격이고 이 실험대가 잰 값이 아니다.
`certbot plugins` 를 돌린 실측이 `dns-cloudflare` · `standalone` · `webroot` 세 줄을 냈으니 플러그인은 깔려 있다.
§190 이 적은 발급 대상은 `-d hyeonworks.com -d '*.hyeonworks.com'` 이고 lineage 디렉터리는 `/etc/letsencrypt/live/hyeonworks.com/` 이다. 자격증명 파일은 `/etc/letsencrypt/cloudflare.ini` 이고 권한이 `600` 이다.
ACME 명세가 와일드카드를 DNS-01 로만 허용한다. 호스트 한 대에 파일을 놓는 것은 그 이름 하나를 통제한다는 증명이고, DNS 존의 TXT 레코드를 고칠 수 있다는 것은 도메인 전체를 통제한다는 증명이라 증명의 급이 다르다.
§204 가 이 항목을 「미측정」으로 적었다. 호스트의 `sudo` 가 비밀번호를 요구해 비대화식으로 읽지 못했다.
## 가정
지금 서빙되는 인증서가 와일드카드라면 발급은 DNS-01 로 이뤄졌을 수밖에 없다. 다만 그 인증서가 실제로 와일드카드인지는 이 저장소에 출력으로 남아 있지 않다.
플러그인이 보인다는 것과 그것으로 받았다는 것을 같게 읽지 않는다. `certbot plugins` 는 설치된 것을 세지 무엇으로 발급했는지를 세지 않는다.
갱신이 돌고 있다는 것도 아직 확인이 아니다. §218 의 구축 완료 판정 기준이 `systemctl is-active nginx certbot-renew.timer``active active` 를 요구하지만, §190 은 `systemctl list-timers certbot-renew.timer` 의 실제 출력이 남아 있지 않다고 적는다.
갱신 설정이 발급 시점의 방식을 그대로 물려받았다고 전제한다. 발급 뒤에 누가 `renewal/*.conf` 를 손으로 고쳤다면 그 전제가 깨진다.
## 미지수
`/etc/letsencrypt/renewal/*.conf``authenticator` 가 무엇인가.
그 값이 `webroot``standalone` 이면 지금 갱신이 실제로 돌고 있는가. 검증이 성립하지 않는 주소에 HTTP-01 로 설정돼 있다면 갱신은 조용히 실패한다.
그 값이 `dns-cloudflare` 라면 `/etc/letsencrypt/cloudflare.ini` 에 든 토큰의 권한이 `존 하나 + DNS:Edit` 으로 좁혀져 있는가. §266 이 그 범위를 값으로 치르는 것이라고 적었는데, 이 호스트의 토큰이 실제로 그 범위인지는 SSOT 에 없다.
지금 서빙되는 인증서가 와일드카드인가, 그리고 lineage 이름이 무엇인가.
## 제약
호스트의 `sudo` 가 비밀번호를 요구해서 비대화식으로는 읽을 수 없고, 콘솔에서 쳐야 한다. 이것이 §204 가 미측정으로 남긴 까닭이다.
비밀 값을 옮기지 않는다. `cloudflare.ini` 의 토큰은 길이와 존재 여부까지만 적고 값을 찍는 명령을 남기지 않는다.
Let's Encrypt 를 tailnet 에 초대할 방법이 없다. 검증 방식을 바꿔 가며 돌려 보는 실험으로 답을 대신할 수 없다.
발급 한도가 같은 이름 조합에 주당 중복 5장이다. 시험은 `--dry-run` 으로 먼저 한다.
## 선택지
### 1. 갱신 설정 파일을 콘솔에서 직접 읽는다
`authenticator` 한 값이 이 물음을 통째로 닫는다. 같은 콘솔 세션에서 쓸 수 있는 방식과 갱신 예행연습과 이름이 가리키는 주소까지 한 번에 찍어 같은 시각의 출력으로 묶는다. 값을 읽을 수 없는 경우는 파일이 없을 때뿐이고, 그때는 이 호스트가 인증서를 받은 적이 없다는 다른 답이 된다.
### 2. 지금 서빙되는 인증서가 와일드카드인지부터 본다
`sudo` 없이도 밖에서 TLS 핸드셰이크만으로 도메인 목록을 볼 수 있다. 와일드카드로 나오면 발급이 DNS-01 이었다는 쪽으로 정황이 좁혀진다. 다만 좁혀질 뿐 `authenticator` 를 대신하지는 못한다. 발급은 와일드카드로 받고 갱신 설정만 다른 경우를 이 방법으로는 가르지 못한다.
### 3. 인증서를 지우고 다시 받아 본다 — 제외
다시 받아 보면 어느 방식이 성립하는지 바로 드러나지만, §204 가 「어느 쪽인지 모르는 채로는 지우지 않는다」로 그 순서를 막아 두었다. HTTP-01 로 설정돼 있으면 재발급이 안 되고, 그러면 재구축을 시작하자마자 검증 방식부터 손봐야 한다. 답을 얻으려고 답이 필요한 상태를 만드는 순서다.
## 다음 검증
§204 와 §266 이 적은 네 줄을 호스트 콘솔에서 그대로 친다.
1. `sudo grep -H authenticator /etc/letsencrypt/renewal/*.conf` 으로 검증 방식을 읽는다.
2. `certbot plugins` 를 돌려 앞에 `*` 가 붙은 줄을 적는다.
3. `sudo certbot renew --dry-run` 으로 갱신이 실제로 되는지 본다.
4. `dig +short auth.hyeonworks.com` 으로 Let's Encrypt 가 올 수 있는 주소인지를 같은 시각에 함께 남긴다.
5. 지금 서빙되는 인증서가 와일드카드인지와 lineage 이름이 무엇인지를 `certbot certificates` 의 도메인 목록으로 적는다.
6. 출력 원문을 `final/evidence/raw/` 에 남기고 `meta/` 에 명령과 cwd 와 실행 시각과 종료 코드를 적는다. `cloudflare.ini` 의 토큰 값은 찍지 않는다.
닫는 조건 : `authenticator` 한 값과 `--dry-run` 결과가 나오면 닫는다.
`dns-cloudflare` 면 「인증서는 DNS-01 로 받는다」가 이 호스트의 현재 상태를 적은 것으로 확인되고, 철거 절차의 「인증서를 지우지 않는다」가 정책에서 선택으로 바뀐다. §204 가 적은 대로 그때는 백업이 헛수고이므로 지워도 되고 재구축 절차가 한 단계 짧아진다.
`webroot``standalone` 이면 그 결정이 적은 것은 의도이고 실제 설정은 다른 것이므로, §190 과 가이드 04 가운데 어느 쪽이 실재인지를 먼저 고친 뒤 그 기록을 다시 판정한다.
`--dry-run` 이 실패하면 갱신이 이미 멈춰 있다는 뜻이라 그 자체가 새 Case 다. 「갱신은 매번 SUCCESS 였고 옛 인증서가 계속 나갔다」가 갱신은 되는데 서빙까지 안 간 것을 다뤘다면, 이번 것은 갱신 자체가 안 되는 쪽이고 증상이 또 조용하다.
@@ -0,0 +1,95 @@
---
kind: QUESTION
slug: qcow2-transfer-time-over-wifi
title: WiFi 전용 호스트에서 대용량 qcow2 를 옮기는 데 실제로 몇 시간이 걸리는가
topic: lab-environment-build
topicName: 실험대 환경 구성
project: virtualization
status: 게시 전
questionStatus: OPEN
sourceRevision: 9465582b5d1630eb4ae7c4e078021486919bf6b6
source:
- final/document.md#183-이-부에서-파생될-open-question-oq-3
- final/document.md#181-qcow2-가-담는-것과-담지-않는-것
- final/document.md#178-이-부의-출처와-범위
---
# WiFi 전용 호스트에서 대용량 qcow2 를 옮기는 데 실제로 몇 시간이 걸리는가
이 호스트의 qcow2 한 장을 WiFi 로 다른 기계에 옮기는 데 몇 시간이 걸리는지 아직 재지 않았다. 이더넷이 없어 쓸 수 있는 링크가 그 WiFi 하나뿐이고, 파일이 몇 바이트인지도 SSOT 에 없다. 크기를 먼저 확정하고 한 번 옮겨 시간을 잰다.
## 관계
- **qcow2 파일 한 장이 담는 것 — 매핑표와 데이터 클러스터가 같은 파일 안에 있고, 파일 밖을 가리키는 것은 백킹 파일 경로 하나다**
옮길 대상이 무엇이고 파일 크기가 왜 가상 크기와 다른지를 그 기록이 설명한다.
- **virsh save 의 RAM 덤프 크기와 소요 시간은 할당 메모리에 어떻게 비례하는가**
같은 이동의 다른 절반이라, 실행 상태까지 옮기려면 그 물음이 재는 파일도 함께 건너간다.
- **이 disk image 는 qcow2 인가 RAW 인가, 그리고 Guest 가 보는 크기와 Host 가 실제로 쓰는 크기는 얼마나 다른가**
옮길 바이트 수를 그 물음이 먼저 확정한다. qemu-img info 와 du 의 같은 출력을 둘이 함께 쓴다.
- **단계별 구축 문서는 작성 시점이 아니라 실행 순서로 검증한다**
옮기는 것이 현실적이지 않으면 이 실험대는 문서로만 복원되고, 그 기준을 지키는 일이 선택에서 전제로 바뀐다.
- **/dev/vda 뒤에 있는 것 — qcow2 · RAW · Host block device**
옮길 파일이 어떤 백엔드 형식인지를 그 기록이 가른다.
## 사실
- §178 은 이 호스트에 이더넷 없이 WiFi 만 있다고 적었다. 브리지를 못 쓰고 libvirt NAT(virbr0) + 호스트 진입 구조를 택한 것도 같은 제약 때문이다.
- §181 은 qcow2 가 희소 할당이지 압축이 아니라고 적었다. 20GB 이미지가 2GB 인 것은 쓴 블록만 파일에 존재하기 때문이고, 1TB 를 채우면 1TB 파일이 된다.
- 메타데이터 오버헤드는 클러스터 64KiB · L2 항목 8B 기준 0.02% 미만이다. 1TiB 당 약 160MiB 다.
- 게스트에서 지워도 파일은 줄지 않는다. 클러스터가 이미 할당된 상태라, 줄이려면 fstrim(디스크에 discard='unmap' 이 있어야 한다)이나 qemu-img convert 를 돌려야 한다.
- 복사하면 따라오는 것과 따라오지 않는 것을 §181 이 갈라 적었다. 실행 중인 프로세스와 페이지 캐시가 따라오지 않는 쪽이고, 파일 밖을 가리키는 것은 헤더에 절대경로로 적히는 백킹 파일 경로 하나다.
- §178 이 적은 이 실험대의 게스트는 Debian 12 genericcloud 3대다. 엣지 1대와 k3s 2노드다.
- 이 호스트의 이미지가 몇 바이트인지는 SSOT 어디에도 없다. 제4부에서 형식과 세 크기를 묻는 물음이 아직 열려 있다.
## 가정
- 게스트를 멈춘 상태에서 옮긴다고 본다. 돌고 있는 채로 복사하면 파일이 바뀌는 중에 읽게 되고, 그렇게 옮긴 이미지를 쓸 수 있는지는 이 물음이 다루지 않는다.
- 받는 쪽 기계와 디스크가 병목이 아니라고 전제한다. 그래야 잰 시간이 WiFi 링크의 값이 된다. 그렇지 않으면 어디가 느린지부터 갈라야 한다.
- 링크 상태가 재는 동안 크게 바뀌지 않는다고 본다. WiFi 는 시간대에 따라 값이 흔들리므로 시작 시각과 종료 시각과 평균 전송률을 함께 적는다.
- 손대지 않으면 파일은 커지기만 한다고 전제한다. §181 이 적었듯 게스트에서 지워도 클러스터는 할당된 채라 파일이 줄지 않아서, 지금 재는 시간을 앞으로의 하한으로 읽는다.
## 미지수
- 이 호스트의 qcow2 파일이 실제로 몇 바이트인지.
- 그 파일을 이 WiFi 링크로 다른 기계에 옮기는 데 몇 분 또는 몇 시간이 걸리는지.
- qemu-img convert 로 먼저 줄인 뒤 옮기는 편이 변환 시간까지 합쳐도 전체 시간을 줄이는지.
## 제약
- 링크가 WiFi 하나다. 이더넷이 없으므로 더 빠른 경로를 골라 견줄 수 없고, 잰 값이 이 호스트에서 낼 수 있는 값이다.
- 파일 크기를 확정하기 전에는 시간을 해석할 수 없다. 가상 크기와 실제 점유를 따로 적는 것이 먼저다.
- 게스트를 멈추는 동안 그 게스트가 하던 일도 멈춘다. 엣지를 고르면 밖에서 들어오는 요청이 끊기므로 대상과 시간대를 그에 맞춰 정한다.
- 한 번의 실측으로 닫는다. 반복해서 분포를 보는 것은 이 물음이 묻는 범위 밖이다.
## 선택지
### 1. 크기를 먼저 확정하고 그 파일 한 장을 한 번 옮긴다
qemu-img info 와 du 로 가상 크기와 실제 점유를 따로 적는다. 제4부에서 형식과 세 크기를 묻는 물음이 같은 출력을 쓰기 때문에 한 번만 돌려 두 물음이 나눠 쓴다. 그다음 게스트를 멈춘 상태에서 그 파일을 다른 기계로 복사하고 시작 시각과 종료 시각과 평균 전송률을 적는다.
옮기는 것이 파일 한 장이 아니다. §187 이 적은 이 실험대의 게스트 디스크 셋은 전부 base.qcow2 위의 오버레이라, 오버레이만 건너가면 헤더에 절대경로로 적힌 그 base 를 받는 쪽에도 같은 경로로 두어야 한다.
적어 두는 값은 네 가지다.
가상 크기 : qemu-img info 가 보여 주는 virtual size
실제 점유 : du 가 보여 주는 값
전송 시간 : 시작 시각과 종료 시각
평균 전송률 : 옮긴 바이트를 걸린 시간으로 나눈 값
### 2. qemu-img convert 로 줄인 사본을 같은 방법으로 한 번 더 옮긴다
1번에 이어서 변환에 걸린 시간과 줄어든 크기를 적고, 변환 시간까지 합친 총 시간을 1번과 견준다. 줄인 쪽이 더 빠르면 옮기기 전에 줄이는 것이 절차가 되고, 아니면 그대로 옮긴다.
### 3. 전송률만 재고 파일 크기로 나눠 시간을 계산한다 — 제외
큰 파일을 실제로 밀어 넣지 않아도 숫자가 나오지만, 계산한 시간은 잰 시간이 아니다. WiFi 는 오래 이어지는 전송에서 값이 흔들리고 옮기는 동안 디스크 읽기도 함께 걸린다. §183 이 물은 것은 현실적으로 몇 시간인가이므로 한 번은 끝까지 옮긴다.
## 다음 검증
1. 옮길 이미지 경로마다 qemu-img info 와 du 를 돌려 가상 크기와 실제 점유를 따로 적는다. 제4부에서 형식과 세 크기를 묻는 물음이 같은 출력을 쓴다.
2. 그 게스트를 멈추고 파일 한 장을 다른 기계로 복사한다. 시작 시각과 종료 시각과 평균 전송률을 적는다.
3. qemu-img convert 로 사본을 줄이고 걸린 시간과 줄어든 크기를 적는다.
4. 줄인 사본을 같은 방법으로 한 번 더 옮기고, 변환 시간까지 합친 총 시간을 2번과 견준다.
5. 출력 원문을 final/evidence/raw/ 에 남기고 meta/ 에 명령과 cwd 와 실행 시각과 종료 코드를 적는다.
닫는 조건 : 파일 크기와 전송 시간이 한 번의 실측으로 나오면 닫는다. 그 시간이 이 실험대를 다른 기계로 옮기거나 백업하는 것이 현실적인 절차인지, 아니면 기반 7단계 가이드를 다시 도는 재구축이 더 빠른지를 가른다. 재구축이 더 빠르다고 나오면 「단계별 구축 문서는 작성 시점이 아니라 실행 순서로 검증한다」가 요구하는 검증이 선택이 아니라 전제가 된다. 옮길 수 없는 실험대는 문서로만 복원된다.
@@ -0,0 +1,97 @@
---
kind: QUESTION
slug: virsh-save-ram-dump-size-and-time
title: virsh save 의 RAM 덤프 크기와 소요 시간은 할당 메모리에 어떻게 비례하는가
topic: lab-environment-build
topicName: 실험대 환경 구성
project: virtualization
status: 게시 전
questionStatus: OPEN
sourceRevision: 9465582b5d1630eb4ae7c4e078021486919bf6b6
source:
- final/document.md#183-이-부에서-파생될-open-question-oq-2
- final/document.md#181-qcow2-가-담는-것과-담지-않는-것
- final/document.md#178-이-부의-출처와-범위
---
# virsh save 의 RAM 덤프 크기와 소요 시간은 할당 메모리에 어떻게 비례하는가
virsh save 가 만드는 RAM 덤프가 몇 바이트이고 save 와 restore 가 각각 몇 초 걸리는지 이 호스트에서 재지 않았다. §181 은 RAM 크기만큼 파일이 더 생긴다고만 적었다. 이 물음은 그 크기가 할당한 RAM 과 게스트가 실제로 쓰던 양 중 무엇에 가까운지를 가른다.
## 관계
- **qcow2 파일 한 장이 담는 것 — 매핑표와 데이터 클러스터가 같은 파일 안에 있고, 파일 밖을 가리키는 것은 백킹 파일 경로 하나다**
실행 상태가 왜 qcow2 에 없는지와 virsh save 가 그것을 어떻게 대신하는지를 그 기록이 설명한다. 이 물음이 재는 수치가 그 설명에서 비어 있는 한 줄을 채운다.
- **WiFi 전용 호스트에서 대용량 qcow2 를 옮기는 데 실제로 몇 시간이 걸리는가**
같은 이동을 두고 재는 값이 다르다. 저쪽은 다른 기계로 가는 파일 전송 시간이고, 이쪽은 같은 호스트에 새로 생기는 메모리 덤프의 크기와 시간이다.
- **balloon target 을 바꾸면 게스트가 쓸 수 있는 메모리는 어떻게 따라 움직이는가**
§183 이 대조군으로 쓰라고 적은 실사용값을 그 물음이 잰다.
- **이 호스트의 가상 머신들은 설정한 메모리와 지금 잡고 있는 메모리가 얼마나 다른가**
덤프 크기를 견줄 설정값과 resident 값을 그 물음이 가상 머신마다 적는다.
- **virtio-balloon 이 Guest 메모리를 회수하고 돌려주는 방식**
할당한 양과 게스트가 실제로 쓰는 양이 갈리는 구조를 그 기록이 설명한다.
## 사실
- §181 은 qcow2 를 통째로 옮겨도 실행 중인 프로세스(PID · FD · 소켓 · JVM 힙)와 페이지 캐시와 아직 내려가지 않은 dirty page 는 따라오지 않는다고 적었다. VM 정의 XML(vCPU · RAM · NIC · machine type · CPU 모델)도 파일 밖에 있다.
- §181 이 적은 실행 상태를 옮기는 길은 둘이다.
virsh save 로 내보내고 복사한 뒤 restore : VM 이 멈추고 RAM 크기만큼 파일이 더 생긴다
virsh migrate --live --copy-storage-all : 두 호스트 libvirt 가 붙고 CPU 모델이 호환돼야 한다
- §181 은 파일이 더 생긴다고만 적고 몇 바이트인지도 몇 초 걸리는지도 대지 않았다.
- §178 이 적은 이 실험대는 test-server 한 대다. Arch Linux, i5-1135G7 논리 코어 8, RAM 11,648MiB, QEMU 11.1.1 · libvirt 12.7.0 이고 게스트는 Debian 12 genericcloud 3대다. 그중 1대가 엣지이고 나머지 둘이 k3s 노드다.
- 게스트에 준 RAM 과 게스트가 실제로 쓰는 양은 갈린다. 그 구조를 제2부가 ballooning 으로 설명했고, 이 호스트에서 그 값을 잰 기록은 없다.
- §183 은 이 물음을 미측정으로 적으면서 「제2부의 balloon 실사용값과 대조하면 재미있는 대조군이 된다」고 덧붙였다. 두 물음을 하나로 합치지 않고 이어 둔 근거가 그 한 줄이다.
## 가정
- virsh save 가 만드는 파일이 한 장이라고 본다. §181 은 파일이 더 생긴다고만 적고 개수를 말하지 않았다.
- 저장하는 동안 호스트의 다른 부하가 크게 바뀌지 않는다고 전제한다. 소요 시간을 재는 것이라 같은 조건에서 두 번 이상 돌린다.
- 할당 RAM 을 바꾼 뒤에도 게스트가 같은 일을 하고 있다고 본다. 하는 일이 함께 바뀌면 덤프 크기가 할당량 때문에 달라진 것인지 부하 때문인지 가려지지 않는다.
- restore 가 성공한다고 전제한다. 실패하면 재는 것이 소요 시간이 아니라 실패 원인으로 바뀐다.
## 미지수
- 게스트 한 대를 virsh save 했을 때 생기는 파일이 실제로 몇 바이트인지.
- 그 크기가 할당한 RAM 과 같은지, 아니면 게스트가 실제로 쓰던 양에 가까운지.
- save 와 restore 가 각각 몇 초 걸리는지.
- 할당 RAM 을 바꾸면 그 셋이 어떻게 움직이는지.
## 제약
- 호스트 RAM 이 11,648MiB 다. 할당 RAM 을 크게 올려 가며 재는 데 한계가 있고, 덤프 파일이 놓일 디스크 공간도 같은 호스트에서 나온다.
- virsh save 는 VM 을 멈춘다. 멈추지 않는 이동은 이 물음의 대상이 아니고, §181 이 적은 다른 길인 virsh migrate 는 호스트 둘을 요구한다. 이 실험대는 한 대다.
- 엣지 게스트에는 nginx 와 certbot 이 올라가 있어 멈추면 밖에서 들어오는 요청이 끊긴다. 재는 대상은 k3s 노드 쪽에서 고른다.
- 이 물음은 크기와 시간까지만 본다. 그 시간이 실험대를 옮기는 절차로 쓸 만한지는 파일 전송 시간을 재는 물음과 함께 놓아야 갈린다.
## 선택지
### 1. 할당 RAM 을 둘 이상 두고 같은 게스트에 save 와 restore 를 돌린다
§183 이 물은 것을 그대로 재는 방법이다. 게스트 하나를 골라 지금 할당으로 한 번, 할당을 바꿔 한 번 더 돌린다. 매번 생긴 파일의 크기와 각 단계의 소요 시간을 적고, 같은 시각에 balloon 쪽 실사용값을 찍어 둔다. 두 점이 있어야 덤프 크기가 할당량 쪽인지 실사용량 쪽인지 갈린다.
그 대조군이 지금 나오는지는 따로 열려 있다. 제2부는 §83 의 OQ-8 로 virtio-balloon 이 이 가상 머신들에 구성되어 있는가부터 묻고 있어서, balloon 쪽 값을 찍으려면 그 확인이 먼저다.
한 번 돌릴 때마다 아래 넷을 적는다.
덤프 파일 크기 : 몇 바이트인가
save 소요 시간 : 몇 초인가
restore 소요 시간 : 몇 초인가
같은 시각의 balloon 실사용값 : 얼마인가
### 2. 지금 할당으로 한 번만 돌린다 — 제외
한 점으로는 크기가 할당량에 비례하는지 실사용량에 비례하는지 가려지지 않는다. 두 값이 우연히 비슷하게 나오면 어느 쪽인지 말할 도리가 없어서다.
### 3. 게스트 안에서 메모리를 채워 놓고 잰다 — 제외
실사용량을 올려 두면 두 값이 확실히 벌어지기 때문에 가르기 쉬워진다. 다만 그렇게 재면 게스트가 하는 일이 평소와 달라지기 때문에, 이 실험대를 실제로 멈췄다 세우는 데 드는 시간과는 다른 값이 나온다. 부하를 준 상태의 측정은 이 물음이 닫힌 뒤에 따로 둔다.
## 다음 검증
1. 재는 대상 게스트를 고르고 그 시점의 할당 RAM 과 balloon 쪽 실사용값을 먼저 적는다.
2. virsh save 를 돌리고 걸린 시간과 생긴 파일의 크기를 적는다.
3. restore 를 돌리고 걸린 시간을 적는다. 게스트가 원래 하던 일을 그대로 이어 가는지도 본다.
4. 같은 게스트의 할당 RAM 을 바꿔 1번부터 다시 돌린다. 두 번 이상 반복한다.
5. 찍은 출력은 final/evidence/raw/ 에 원문 그대로 남기고, meta/ 에 명령과 cwd 와 실행 시각과 종료 코드를 적는다.
닫는 조건 : 할당 RAM 두 값 이상에서 덤프 크기와 소요 시간이 나오고, 그것이 할당량과 실사용량 중 어느 쪽으로 움직이는지 말할 수 있으면 닫는다. 값이 나오면 이 실험대를 멈췄다 다시 세우는 데 드는 시간이 정해지고, 「RAM 크기만큼 파일이 더 생긴다」고만 적힌 qcow2 개념 기록에 이 호스트의 실제 수치가 들어간다. 덤프가 실사용량 쪽으로 움직인다고 나오면 제2부의 balloon 기록이 반대 방향의 근거를 하나 얻는다.
@@ -0,0 +1,98 @@
---
kind: REFERENCE
slug: a-config-file-does-not-mean-the-same-thing-on-two-distros
title: 같은 설정 파일이 두 배포판에서 같은 뜻이 아니다 — 옮기기 전에 세 가지를 본다
topic: lab-environment-build
topicName: 실험대 환경 구성
project: virtualization
status: 게시 전
sourceRevision: 9465582b5d1630eb4ae7c4e078021486919bf6b6
source:
- final/document.md#203-실측으로-드러난-함정-셋
- final/document.md#284-nginx-설정-구조-sites-available-은-nginx-기능이-아니다
- final/document.md#288-게스트-배포판-debian이란-무엇이고-ubuntu와-무엇이-다른가
- final/document.md#286-패키지명-대응표
- final/document.md#287-없어서-오히려-편한-것
- final/document.md#285-롤링-릴리스와-부분-업그레이드-금지
- final/document.md#212-"이건-arch라서-하는-건가-"에-대한-답
- final/document.md#209-nginx-keycloak-lab-conf-스티키-스위치와-신뢰-경계
---
# 같은 설정 파일이 두 배포판에서 같은 뜻이 아니다 — 옮기기 전에 세 가지를 본다
설정 파일을 배포판이 다른 기계로 옮기기 전에 셋을 본다. 그 지시어가 대상의 판올림에 있는가, 패키지가 기본으로 켜 둔 것과 충돌하지 않는가, 그 배포판이 그 관례를 갖고 있는가. 이 실험대의 함정 셋이 전부 여기 걸렸다.
## 관계
- **단계별 구축 문서는 작성 시점이 아니라 실행 순서로 검증한다**
그 기준은 순서와 위치를 보고 이 기준은 대상 기계를 본다. 그 기록이 자기 예외 절에서 배포판 차이를 이쪽으로 넘긴다.
- **엣지 게스트에 nginx 를 세우고 호스트 DNAT 으로 밖에서 들어오는 길을 연다**
Arch 호스트에서 쓰던 nginx 설정이 Debian 12 게스트로 건너가는 절차가 그 기록에 있다.
- **cloud-init 시드로 게스트 세 대를 만들고 SSH 가 키로 붙게 한다**
부트 시점 설정이 게스트 판올림의 검사기를 통과해야 하는 대목이 그 절차 안에 있다.
- **cloud-init 이 안 도는 원인은 넷인데 증상은 「SSH 가 안 붙는다」 하나였다**
원인이 옮긴 파일 안에 없을 때 증상이 어떻게 보이는지를 그 기록이 보여 준다.
- **가까운 층부터 한 층씩 건너뛰며 확인한다 — 층마다 성공 신호가 다르다**
증상이 난 층부터 좁혀 오는 절차이고, 이 기준은 그 층에 닿은 뒤 파일을 의심할지 기계를 의심할지를 가른다.
## 목적
이 기준이 막는 것은 원인이 파일 안에 없는 두 증상이다. 하나는 설정 전체가 뜨지 않는 것이고, 다른 하나는 파일을 제자리에 놓았는데 아무 일도 일어나지 않는 것이다. 둘 다 파일을 아무리 읽어도 나오지 않는다.
§203 이 실측으로 드러난 함정 셋을 적었는데 셋 다 배포판 차이였다. 이 실험대는 호스트가 Arch(nginx 1.30.4)이고 엣지 게스트가 Debian 12(nginx 1.22.1)라 같은 설정이 두 판올림 사이를 오갔다.
## 규칙
### 1. 그 지시어가 대상의 판올림에 있는가
`http2 on;` 은 nginx 1.25.1 이상이다. Arch 에서 쓰던 설정을 Debian 12 게스트로 그대로 옮기면 `unknown directive "http2"` 가 나면서 설정 전체가 죽는다.
없다는 것을 확인하는 데서 멈추면 규칙이 답을 내지 않는다. 양쪽에서 도는 형태가 무엇인지까지 찾는다. `listen 443 ssl http2;` 형태는 1.22 와 1.30 양쪽에서 다 돈다.
설정 정본 `deploy/lab/edge/nginx-keycloak-lab.conf` 의 주석이 그 형태를 고른 까닭을 파일 안에 적어 두었다. 따로 떼어 쓰는 지시어 쪽은 nginx 1.25.1 이상을 요구하는데 엣지 게스트는 nginx 1.22 를 쓰는 Debian 12 다. `listen` 의 인자로 쓰는 형태는 양쪽에서 다 돌고, 이 실험대가 실제로 돌리는 것도 그쪽이다.
### 2. 패키지가 기본으로 켜 둔 것과 충돌하지 않는가
Debian 계열은 `/etc/nginx/sites-enabled/default` 가 처음부터 붙어 있고 `:80``default_server` 로 선언돼 있어서, 실험대 설정의 `listen 80 default_server` 와 충돌한다. 심볼릭 링크를 걸 때 그 기본 사이트를 같이 지운다.
### 3. 그 배포판이 그 관례를 갖고 있는가
`sites-available``sites-enabled` 는 nginx 의 기능이 아니라 Debian/Ubuntu 패키지 메인테이너가 만든 관례다. nginx 가 아는 것은 `include` 지시어 하나뿐이고 나머지는 패키지가 미리 깔아 둔 디렉터리 구조다.
Arch 는 `/etc/nginx/nginx.conf` 한 파일이 전부이고 include 줄도 없다. `http { }` 안에 `include /etc/nginx/sites-enabled/*;` 를 직접 넣어야 이 파일이 효력을 갖는다. 넣지 않으면 아무 일도 일어나지 않고 오류조차 나지 않는다. 최종 병합된 설정에 내 파일이 들어갔는지는 `nginx -T` 로 확인한다.
2번과 3번은 같은 관례의 양면이다. 한쪽은 있어서 충돌하고 한쪽은 없어서 손으로 넣어야 한다.
## 적용 조건
한 기계에서 쓰던 설정 파일을 다른 배포판이나 다른 판올림의 기계로 옮기는 모든 곳에 적용한다. 이 실험대에서는 호스트 Arch 와 게스트 Debian 12 사이, 그리고 운영과 실험대 사이다.
특히 자주 걸리는 것 : 데몬 설정(nginx · systemd 유닛)과 부트 시점 설정(cloud-init)
이 기준은 파일을 옮길 때 한 번 도는 검사이지 막히는 것마다 꺼내 드는 설명이 아니다. §212 가 적었듯 낯선 것의 대부분은 배포판 때문이 아니다. 클라우드가 대신 해 주던 일(KVM · libvirt · cloud-init · DHCP 예약)과 이미 누가 해 두었던 일(nginx upstream · certbot · k3s 설치)이 대부분이고, 진짜 배포판 고유는 얼마 되지 않는다 — 이 실험대에서는 `conf.d` include 부재, 롤링 업그레이드, `libvirtd.socket`, 패키지명이다.
같은 구성을 Ubuntu 에서 해도 가상화와 네트워크 층은 명령 이름만 조금 바뀐다.
## 예외
같은 계열 안에서도 판올림이 다르면 1번이 그대로 걸린다. Debian 과 Ubuntu 는 `apt``dpkg` 와 systemd 와 디렉터리 구조가 같은 계열인데 패키지 판올림이 달라서다.
배포판이 같으면 안 걸리는 것도 아니다. Arch 는 롤링 릴리스이고 부분 업그레이드를 지원하지 않아서, `pacman -Sy 패키지` 로 DB 만 갱신하고 일부만 설치하면 같은 기계 안에서도 공유 라이브러리 판이 어긋난다.
순서와 위치 문제는 이 축에서 안 잡힌다. 「단계별 구축 문서는 작성 시점이 아니라 실행 순서로 검증한다」가 그쪽을 맡고, 그 기록이 자기 예외 절에서 이쪽을 가리킨다. 두 기준이 서로의 사각을 덮는다.
1번은 문서로 판올림을 대조해 예측할 수 있다. 2번과 3번은 그 배포판에 실제로 깔아 봐야 드러난다 — 기본으로 붙어 있는 사이트가 무엇인지와 그 배포판이 어떤 include 관례를 갖는지는 패키지 메인테이너가 정한다.
배포판 차이가 아닌 것을 이 기준으로 설명하지 않는다. SELinux 와 AppArmor 가 Arch 에 기본 활성이 아닌 것은 이 기준이 잡는 종류이지만, RHEL 계열에서 k3s 에 정책 패키지가 필요한 것은 옮긴 설정의 문제가 아니라 그 배포판의 보안 모듈 문제다.
## 예시
`http2 on;` 을 쓴 설정을 Debian 12 의 nginx 1.22.1 로 옮기자 `unknown directive "http2"` 로 설정 검사가 실패했다.
Debian 기본 사이트를 지우지 않은 채 실험대 설정을 켜자 `:80``default_server` 가 두 번 선언됐다.
Arch 에 `sites-enabled` 디렉터리를 만들어 파일을 넣었는데 `include` 줄이 없어 오류 없이 무시됐다.
게스트의 cloud-init 22.4.2 스키마 검사기가 `sudo` 를 리스트로 적은 형태를 거부했다. 어느 키가 걸렸는지는 알려 주지 않고 `users.0` 블록을 통째로 찍은 뒤 어느 스키마에도 안 맞는다고만 한다. 그 형태로도 부팅은 됐고 NOPASSWD sudo 도 멀쩡히 돌았다.
certbot DNS 플러그인의 패키지 이름이 Arch 에서 `certbot-dns-cloudflare` 이고 Debian/Ubuntu 에서 `python3-certbot-dns-cloudflare` 다.
@@ -0,0 +1,85 @@
---
kind: REFERENCE
slug: verify-a-build-guide-in-execution-order
title: 단계별 구축 문서는 작성 시점이 아니라 실행 순서로 검증한다
topic: lab-environment-build
topicName: 실험대 환경 구성
project: virtualization
status: 게시 전
sourceRevision: 9465582b5d1630eb4ae7c4e078021486919bf6b6
source:
- final/document.md#182-이-구축에서-드러난-문서-결함의-공통-원인
- final/document.md#178-이-부의-출처와-범위
---
# 단계별 구축 문서는 작성 시점이 아니라 실행 순서로 검증한다
구축 가이드는 1단계의 환경에서 시작해 실행 순서로 검증한다. 단계마다 그 시점에 리소스가 이미 있는지와 그 명령을 어느 기계에서 치는지를 따로 본다. 기반 7단계 가이드를 순서대로 따라가자 결함 여섯이 나왔고, 틀린 명령은 하나도 없었다.
## 관계
- **엣지 nginx 를 호스트에서 게스트 VM 으로 옮긴다 — 성능이 아니라 더러워지는 층의 격리**
이 기준이 검증하라는 가이드가 그 결정을 실제로 실행한 문서다. 결함 여섯 가운데 셋은 그 이동이 새로 요구한 일곱 가지와 같은 것을 다루는데, nginx 설치와 nginx 버전 차이와 인증서 경로다.
- **호스트 안에서는 404, 밖에서는 connection refused — 앞 체인의 accept 가 libvirt 의 reject 를 막지 못했다**
같은 connection refused 라도 04 단계의 결함은 명령을 칠 셸이 틀려서 났고, 그 기록의 거절은 libvirt 방화벽이 냈다. 어느 셸에서 쳤는지로 좁힌 뒤에도 안 풀리는 막힘을 그 기록이 받는다.
- **WiFi 전용 호스트에서 대용량 qcow2 를 옮기는 데 실제로 몇 시간이 걸리는가**
그 물음의 답이 이 검증을 선택으로 둘지 전제로 둘지를 가른다. 파일을 옮기는 것보다 가이드를 다시 도는 편이 빠르다고 나오면, 이 실험대는 문서로만 복원된다.
## 목적
이 기준이 막는 것은 각 줄이 참인데 순서대로는 못 따라가는 문서다. §182 이 든 여섯 결함에서 틀린 명령은 하나도 없었고 전부 실제로 돌았던 것이다. 다만 구축이 끝난 뒤의 환경에서 확인한 출력이 앞 단계에 적혀 있었다. §178 은 이 여섯이 실린 부의 범위를 구축에서 실제로 막힌 지점으로만 그었고, 막히지 않은 단계는 가이드에 있으니 여기서 반복하지 않는다고 적었다. 그래서 여섯은 가이드를 검토해 골라낸 것이 아니라, 그 단계까지 실제로 따라가다 막혀서 드러났다.
03 단계에서 /etc/nginx: No such file or directory 가 난 것은 설정 블록이 틀려서가 아니라 그 시점에 nginx 가 아직 깔려 있지 않아서다. 둘 중 어느 쪽인지는 문서를 읽어서는 알 수 없고 실제로 돌려 봐야 드러난다.
§182 은 검사할 것도 시점과 셸 둘로 갈라 적었다. 하나로 뭉뚱그리면 막힌 단계에서 무엇을 고쳐야 하는지가 안 나오기 때문이다.
## 규칙
### 1. 검증 순서를 문서 작성 순서가 아니라 실행 순서에 맞춘다
문서를 쓴 사람은 구축이 끝난 환경에 있고 읽는 사람은 아무것도 없는 환경에서 1단계부터 시작하니, 검증도 1단계의 환경에서 시작한다.
### 2. 단계마다 그 시점에 리소스가 이미 존재하는지 따로 본다
여섯 중 셋이 여기서 깨졌다. 03 단계는 nginx 설치 단계가 없어 /etc/nginx: No such file or directory 로 막혔다. 00·03·05·06 단계는 저장소가 lab host 에 있다고 가정해 cp: cannot stat 'deploy/...' 로 막혔다. 05 단계는 BFF 가 한참 뒤에 뜨는데도 그 시점에 -l app=bff 로 리소스를 조회했다.
셋 모두 문서를 읽을 때는 보이지 않는다. 설치 명령이 빠졌다는 것도 파일이 그 기계에 없다는 것도 그 단계까지 실제로 와 봐야 드러나기 때문이다.
### 3. 단계마다 그 명령을 어느 기계에서 치는지 문서가 말하게 한다
04 단계의 확인 명령이 여기서 깨졌다. 게스트에는 Tailscale 이 없기 때문에 엣지 VM 안에서 tailnet 주소를 치면 connection refused 가 돌아온다. 명령 자체는 맞고 칠 위치가 틀렸다.
§178 의 구성에서 호스트는 Arch Linux 이고 게스트는 Debian 12 genericcloud 세 대다. 문서가 주체를 적지 않으면 읽는 사람은 직전 단계에서 쓰던 셸에 그대로 친다.
### 4. 나중 시점의 환경에서 확인한 명령과 출력을 앞 단계로 옮겨 적지 않는다
§182 이 여섯의 공통 원인으로 든 것이 이 하나다. 구축이 끝난 환경에서는 리소스가 전부 서 있고 저장소도 제 기계에 있으니 어떤 확인 명령이든 돈다. 그 출력을 앞 단계에 붙이면 문서의 각 줄은 참이 되고, 순서대로 따라가는 사람만 막힌다. 그래서 이 결함은 문서를 검토해서는 안 나오고 실행해야 나온다. §182 은 결함 여섯에는 observed 를, 이 공통 원인 하나에는 inferred 를 붙였다. 여섯은 따라가다 본 것이고, 그것을 한 원인으로 묶은 것은 여섯을 놓고 내린 판단이다.
## 적용 조건
- 사람이 한 단계씩 따라 실행하도록 쓴 구축·운영 문서
- 앞 단계의 결과 위에 뒤 단계가 서는 문서. 기반 7단계 가이드가 그런 문서다
- 명령을 칠 기계가 둘 이상인 문서. 호스트와 게스트가 갈리면 어느 셸에서 치는지를 따로 본다
- 단계가 하나 끼어들거나 대상 환경이 바뀐 뒤
## 예외
이 기준이 잡는 것은 순서와 위치이고 명령의 정확성은 아니다. §182 이 적었듯 개별 명령은 전부 실제로 돌았던 것이라, 이 검사를 통과해도 오타나 잘못된 플래그나 낡은 옵션은 그대로 지나간다. 04 단계의 인증서 경로가 그런 경우다. 와일드카드로 받은 인증서 묶음의 이름(lineage)은 live/hyeonworks.com/ 인데 문서에는 live/auth.hyeonworks.com/ 이라 적혀 있었다. 단계 순서를 맞춰도 그 줄은 틀린 채다.
배포판 차이도 이 두 검사로는 안 잡힌다. 03 단계의 설정 블록에 있던 http2 on; 은 Debian 12 의 nginx 1.22 에서 unknown directive 가 됐다. 순서를 맞춰도 같은 오류가 나므로 대상 배포판에서 실제로 돌려야 드러난다.
한 번 통과한 문서가 계속 통과하지도 않는다. 단계가 하나 끼어들거나 환경이 바뀌면 같은 검사를 다시 돌린다.
이 저장소에는 이 기준으로 가이드를 고친 뒤 처음부터 다시 따라가 본 기록이 없다. 규칙은 결함 여섯의 공통 원인에서 나왔고, 규칙이 결함을 막아 냈다는 관측은 아직 얻지 못했다.
다시 따라가 보지 않은 데는 까닭이 있다. §184 는 가이드 자신도 읽기 전용 확인만 실제로 돌려 출력을 실었고 만드는 명령은 구축할 때 친 것을 그대로 옮겼다고 적었다. VM 을 다시 만들거나 k3s 를 다시 깔면 지금 돌고 있는 실험대가 없어지기 때문이다. 그래서 단계마다의 생성 명령은 「그때 이렇게 쳤다」까지이고, 지금 다시 쳐도 같은 상태가 되는지는 확인되지 않았다.
## 예시
- 03 단계 : nginx 설치 단계가 없어 /etc/nginx: No such file or directory
- 03 단계 : 설정 블록이 http2 on; 이라 Debian 12 의 nginx 1.22 에서 unknown directive
- 04 단계 : 인증서 경로가 lineage 이름과 다르다. 와일드카드는 live/hyeonworks.com/ 인데 live/auth.hyeonworks.com/ 이라 적혀 있었다
- 00·03·05·06 단계 : 저장소가 lab host 에 있다고 가정해 cp: cannot stat 'deploy/...'
- 05 단계 : 그 단계에 없는 리소스를 -l app=bff 로 조회. BFF 는 한참 뒤에 뜬다
- 04 단계 : 확인 명령을 칠 위치가 틀렸다. 엣지 VM 안에서 tailnet 주소를 치면 connection refused
- 고친 뒤 처음부터 다시 따라가 본 기록 : x
@@ -0,0 +1,588 @@
---
id: f972ca27-7e18-41c1-9494-59cc6f676ae2
kind: SETUP
slug: create-three-guests-with-cloud-init
title: cloud-init 시드로 게스트 세 대를 만들고 SSH 가 키로 붙게 한다
topic: lab-environment-build
topicName: 실험대 환경 구성
project: virtualization
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/f972ca27-7e18-41c1-9494-59cc6f676ae2/edit"
pinnedVersions:
- name: libvirt
version: 12.7.0
- name: Debian GNU/Linux
version: 12 (bookworm)
- name: cloud-init
version: 22.4.2
source:
- final/document.md#187-단계-01-게스트-세-대
- final/document.md#185-가이드-묶음이-스스로-정한-규약
- final/document.md#184-이-부의-출처와-범위
sourceRevision: 9465582b5d1630eb4ae7c4e078021486919bf6b6
---
# cloud-init 시드로 게스트 세 대를 만들고 SSH 가 키로 붙게 한다
Debian 12 클라우드 이미지 한 장 위에 오버레이로 게스트 셋을 만드는 절차다. 운영체제를 설치하지 않고 첫 부팅의 cloud-init 이 사용자와 SSH 키와 호스트명을 채운다. 끝나면 세 게스트가 192.168.122.10 부터 .12 까지를 받는다.
## 관계
- **lab host 에 가상화 패키지를 깔고 virsh 가 sudo 없이 돌게 만든다**
앞 단계다. 여기 나오는 `virt-install``virsh net-update` 가 거기서 고정한 `qemu:///system``default` 네트워크 위에서 돈다.
- **cloud-init 이 안 도는 원인은 넷인데 증상은 「SSH 가 안 붙는다」 하나였다**
이 절차에서 SSH 가 안 붙을 때 원인 넷을 가르는 방법을 그 기록이 받는다. 여기에는 세우는 순서만 있다.
- **k3s server 와 agent 를 게스트 두 대에 깔고 lab host 에서 kubectl 로 본다**
다음 단계이고, 여기서 고정한 192.168.122.11 과 192.168.122.12 를 그대로 쓴다.
- **qcow2 파일 한 장이 담는 것 — 매핑표와 데이터 클러스터가 같은 파일 안에 있고, 파일 밖을 가리키는 것은 백킹 파일 경로 하나다**
게스트 디스크를 base 이미지 위의 오버레이로 만들 수 있는 근거를 그 글이 설명한다.
- **단계별 구축 문서는 작성 시점이 아니라 실행 순서로 검증한다**
DHCP 예약이 게스트 생성보다 먼저여야 하는 까닭이 그 기준이 말하는 순서 문제다.
- **이 호스트의 가상 머신들은 설정한 메모리와 지금 잡고 있는 메모리가 얼마나 다른가**
게스트마다 배정한 메모리를 여기서 정하므로 그 물음이 실제 점유량과 견줄 설정 값이 이 기록에서 나온다.
## 본문
<!-- body:start -->
## 읽기 전에 — 어디서 치는가
기본은 `[lab host]` 다. 예외가 둘이다.
| 무엇 | 어디서 |
|---|---|
| base 이미지 · 시드 · DHCP 예약 · `virt-install` · 붙어 보기 | `[lab host]` |
| `cloud-init schema -c` | `[kc-lab-1]` — 검사기가 게스트 안에만 있다 |
| 워크스테이션 공개키를 꺼내는 한 줄 | `[워크스테이션]` |
게스트에 들어가는 일은 스키마 검사 한 번과 콘솔로 로그를 읽을 때 둘이다. 나머지는 lab host 에서 `ssh <게스트> '...'` 형태로 원격 실행한다. 게스트에는 lab host 의 개인키도 `~/.ssh/config` 도 없어서, 게스트 안에서 다른 게스트로 붙으려 하면 `Host key verification failed` 로 끝난다.
편집기를 여는 곳은 둘이다. 게스트마다의 `#cloud-config` 파일과 그 짝인 meta-data 파일이고, 나머지는 조회와 생성이라 CLI 를 그대로 쓴다.
## 이 단계가 세우는 것
가이드 01 의 「이 단계가 끝나면」은 한 줄이다.
> `kc-lab-edge`·`kc-lab-1`·`kc-lab-2` 세 게스트가 뜨고, lab host 에서 SSH 가 키로 붙는다.
| 게스트 | IP | MAC 끝 | vCPU | 메모리 | 디스크 | 무엇이 도나 |
|---|---|---|---|---|---|---|
| `kc-lab-edge` | 192.168.122.10 | `:10` | 1 | 1024MB | 10 | nginx · certbot |
| `kc-lab-1` | 192.168.122.11 | `:11` | 2 | 5120MB | 20 | k3s server · Traefik |
| `kc-lab-2` | 192.168.122.12 | `:12` | 2 | 4096MB | 20 | k3s agent · Traefik |
세 대를 세우는 까닭은 이 실험대의 질문 전부가 「인스턴스가 둘 이상이고 요청이 어느 쪽으로 갈지 모른다」를 전제하기 때문이다. 한 대면 세션 공유도 분단도 노드 상실도 실험이 되지 않는다. 엣지를 따로 둔 까닭은 그 결정을 담은 기록이 갖는다.
## 전제와 되돌리기
전제는 한 줄이다 — 앞 단계가 끝나 `virsh list``sudo` 없이 돈다.
**되돌리는 절차는 원본 가이드 01 에 없다**(unknown). 다만 01 이 만드는 것이 무엇인지는 단계 03 이 지나가며 한 줄로 적어 두었다.
```bash label="[lab host] 03 이 엣지를 왜 VM 으로 두는지 설명하며 적어 둔 한 줄"
virsh undefine kc-lab-edge --remove-all-storage
```
그 줄은 엣지를 VM 으로 두는 까닭을 설명하면서 나오고, 세 게스트를 걷어내는 절차로 적어 둔 것이 아니다. DHCP 예약 세 줄과 풀에 올린 시드 볼륨 세 개를 어떻게 지우는지는 가이드 어디에도 없고 이 실험대도 지워 본 적이 없다. 그래서 이 절차는 되돌리기를 갖지 않는다.
## 세우기 전에 먼저 본다
**가이드 01 에는 바꾸기 전 상태를 보는 단계가 없다.** 아래 세 줄은 앞 단계의 확인을 그대로 다시 치는 것이고, 01 이 요구하는 전제가 그 셋이다(inferred).
**무엇을 확인하는가** — 앞 단계가 남긴 세 값이 지금 셸에서도 그대로인지.
```bash label="[lab host] 연결 URI · 네트워크 · 도메인 목록을 차례로 본다"
virsh uri
virsh net-list --all
virsh list --all
```
**어디를 봐야 하는가** — `qemu:///system` 인가, `default` 가 `active` 인가, 그리고 `virsh list --all` 이 `sudo` 없이 통과하는가. 세 게스트를 아직 안 만들었으면 마지막 표는 비어 있다.
**이 결과가 의미하는 것** — 이 셋 가운데 하나라도 어긋난 채로 `virt-install` 을 치면 증상이 「VM 이 목록에 없다」나 「IP 를 못 받는다」로 나타나고, 원인은 이 단계가 아니라 앞 단계에 있다. 어긋난 값을 만든 곳으로 돌아간다 — 연결 URI 와 네트워크와 그룹이 각각 다른 번호다.
## 실행 절차
### 1. base 이미지를 받는다
**목적** — 게스트 셋의 디스크가 올라탈 Debian 12 genericcloud 이미지 한 장을 풀 디렉터리에 둔다. 운영체제를 설치하지 않는다.
```bash label="[lab host] ① 풀 디렉터리로 옮겨 이미지를 받는다"
cd /var/lib/libvirt/images
sudo curl -fL --output /var/lib/libvirt/images/base.qcow2 \
https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2
```
```bash label="[lab host] ② 받은 파일이 온전한 qcow2 인지 본다"
qemu-img info /var/lib/libvirt/images/base.qcow2
```
**예상 결과** — 세 줄을 본다. `file format:` 이 `qcow2` 인가, `virtual size:` 가 `disk size:` 보다 훨씬 큰가, `backing file:` 줄이 없는가. 가운데 것은 qcow2 가 희소 파일이라 그렇게 나오는 쪽이 정상이다.
**왜 필요한가** — 게스트 셋의 디스크가 전부 이 파일 위의 오버레이라, base 뒤에 또 무엇이 붙어 있으면 사슬이 한 겹 더 생긴다. base 는 아무것도 뒤에 두지 않는다.
**문제가 생기면** — 받다 끊기면 오류 페이지를 저장해서 `file format:` 이 `raw` 로 읽힌다. 형식이 틀린 채로 진행하면 증상이 `virt-install` 이 아니라 게스트가 부팅을 못 하는 것으로 나타나 원인을 찾기 어려워진다. 지우고 다시 받는다.
### 2. cloud-init 파일에 넣을 값 셋을 모은다
**목적** — 다음 단계의 파일에 넣을 공개키 둘과 콘솔 비밀번호 하나를 손에 든다.
```bash label="[lab host] ① 자기 공개키를 만들거나 꺼낸다"
[ -f ~/.ssh/id_ed25519.pub ] || ssh-keygen -t ed25519 -N '' -f ~/.ssh/id_ed25519
cat ~/.ssh/id_ed25519.pub
```
```bash label="[워크스테이션] ② 워크스테이션 공개키를 꺼낸다"
cat ~/.ssh/id_ed25519.pub
```
```bash label="[lab host] ③ 콘솔용 비밀번호를 만든다"
openssl rand -base64 18
```
**예상 결과** — `ssh-ed25519 ...` 로 시작하는 줄 둘과 base64 한 줄이 화면에 나온다. 셋 다 다음 단계에서 붙여 넣으므로 창을 닫지 않는다.
**왜 필요한가** — 자리표시자를 문서에 남기지 않고 값을 찾는 명령을 함께 둔다. ③ 의 비밀번호는 cloud-init 의 `plain_text_passwd` 로 들어가고, 키가 안 들어갔을 때 게스트로 들어가는 유일한 통로가 된다. 이 문서에는 그 값을 싣지 않는다.
**문제가 생기면** — ② 만 워크스테이션에서 친다. lab host 에서 두 번 쳐서 같은 키를 두 줄 넣으면 워크스테이션에서는 게스트에 못 붙는다.
### 3. 게스트마다 cloud-init 파일을 쓴다
**목적** — 게스트가 첫 부팅에서 읽을 `#cloud-config` 를 게스트 이름별로 하나씩 만든다.
원본 가이드에서 이 단계가 비어 있다(unknown). 템플릿으로 `deploy/lab/cloud-init/kc-lab.yaml.example` 을 걸어 두고 곧바로 자리표시자를 바꾸는 치환 명령으로 넘어가는데, 그 파일을 게스트 이름으로 복사하거나 새로 만드는 명령이 원본에도 없다. 템플릿 원문도 반입되지 않아 대조하지 못했다. 그래서 아래는 원본 본문이 실은 내용을 파일로 옮겨 적었다.
```bash label="[lab host] ① 게스트 이름으로 파일을 연다"
nano kc-lab-1.yaml
```
② `__` 로 둘러싼 세 곳에 2번의 ①②③ 출력을 넣는다.
```yaml label="kc-lab-1.yaml 에 쓸 내용"
#cloud-config
hostname: kc-lab-1
fqdn: kc-lab-1
manage_etc_hosts: true
users:
- name: donghyeon
groups: [sudo]
shell: /bin/bash
sudo: ['ALL=(ALL) NOPASSWD:ALL']
lock_passwd: false
plain_text_passwd: __CONSOLE_PW__
ssh_authorized_keys:
- __LAB_HOST_KEY__
- __WORKSTATION_KEY__
ssh_pwauth: false
package_update: true
packages: [curl, nftables]
```
③ 다른 게스트는 `hostname` 과 `fqdn` 두 줄만 `kc-lab-2` 와 `kc-lab-edge` 로 바꿔 같은 방법으로 만든다.
**예상 결과** — lab host 의 현재 디렉터리에 `kc-lab-1.yaml` 과 `kc-lab-2.yaml` 과 `kc-lab-edge.yaml` 셋이 생긴다.
**왜 필요한가** — 세 가지가 의도적이다.
| 무엇 | 왜 |
|---|---|
| `NOPASSWD:ALL` | k3s 설치와 장애 주입이 비대화식으로 돌아야 한다. 비밀번호를 물으면 멈춘다 |
| `plain_text_passwd` | cloud-init 이 실패했을 때의 유일한 탈출구. 키가 안 들어가면 SSH 가 막히므로 콘솔로 들어가 로그를 봐야 한다 |
| 키 두 개 | lab host 에서 자동화가 돌고, 워크스테이션에서는 ProxyJump 로 직접 붙는다 |
들여쓰기에는 공백만 쓴다. YAML 은 탭을 금지하고, cloud-init 은 파싱에 실패해도 아무 오류를 남기지 않아 증상이 「SSH 가 안 붙는다」 하나로만 나타난다. 편집기로 여는 까닭도 여기에 있다 — 배울 것이 `hostname:` 과 `users:` 와 `packages:` 인데 치환 명령의 구분자와 명령 치환을 먼저 읽어야 하면 시선이 그쪽으로 간다.
이 실험대는 값 셋을 한 번에 치환했다(observed).
```bash label="[lab host] 이 실험대가 실제로 친 형태"
sed -i \
-e "s|__LAB_HOST_KEY__|$(cat ~/.ssh/id_ed25519.pub)|" \
-e "s|__WORKSTATION_KEY__|<워크스테이션에서 복사해 온 값>|" \
-e "s|__CONSOLE_PW__|$(openssl rand -base64 18)|" \
kc-lab-1.yaml
```
**문제가 생기면** — `sudo:` 줄은 4번이 적은 대로 문자열 형태로 고쳐 쓴다. 그대로 두면 부팅은 되고 스키마 검사만 거절한다.
### 4. cloud-init 파일을 검사한다
**목적** — 자리표시자가 남았는지, 공개키가 둘 들어갔는지, cloud-config 로 유효한지를 시드를 굽기 전에 본다.
```bash label="[lab host] ① 자리표시자가 남았는지 센다"
grep -c '__' kc-lab-1.yaml
```
```bash label="[lab host] ② 공개키가 둘 들어갔는지 센다"
grep -c 'ssh-ed25519\|ssh-rsa' kc-lab-1.yaml
```
**예상 결과** — `0` 과 `2`. 첫 줄이 `0` 이 아니면 `__LAB_HOST_KEY__` 같은 문자열이 남아 있고, 둘째 줄이 `2` 가 아니면 키 하나가 안 들어갔다. 두 숫자가 맞아야 시드를 만든다.
첫 파일에서는 여기까지다. cloud-config 스키마까지 보는 검증기는 `cloud-init schema` 인데, 그것은 게스트 안의 cloud-init `22.4.2` 이고 첫 파일을 쓰는 시점에는 게스트가 아직 없다. lab host 에 cloud-init 이 깔려 있는지는 원본 가이드에 적혀 있지 않고 재지도 않았으며(unknown), `yamllint` 은 이 실험대에 없다. 게스트가 한 대라도 뜬 뒤에는 둘째 파일부터 그 검증기로 본다. 파일을 보내고, 들어가고, 검사하고, 지운다.
```bash label="[lab host] ③ 검사할 파일을 게스트로 보낸다"
scp kc-lab-2.yaml donghyeon@192.168.122.11:~/
```
```bash label="[lab host] ④ 그 게스트에 들어간다"
ssh donghyeon@192.168.122.11
```
```bash label="[kc-lab-1] ⑤ 권한을 좁힌다"
chmod 600 ~/kc-lab-2.yaml
```
```bash label="[kc-lab-1] ⑥ cloud-config 스키마로 검사한다"
cloud-init schema -c ~/kc-lab-2.yaml
```
```bash label="[kc-lab-1] ⑦ 검사가 끝나면 지운다"
rm ~/kc-lab-2.yaml
```
```bash label="[kc-lab-1] ⑧ lab host 로 나온다"
exit
```
**예상 결과** — 통과하면 한 줄이다(observed).
```text
Valid cloud-config: /home/donghyeon/kc-lab-edge.yaml
```
통과하면 유효하다는 한 줄만 나오고, 아니면 `Invalid cloud-config` 아래에 어느 키가 왜 틀렸는지가 나열된다. `deprecated` 경고와 `error` 는 다르다 — 경고는 지금 동작한다.
**왜 필요한가** — YAML 로 파싱되는 것과 cloud-config 로 유효한 것은 다르다. 키 이름 오타(`user` 와 `users`)는 앞의 두 `grep` 도, YAML 파서도 그냥 통과한다. 오류가 나면 그 키는 조용히 무시되고, `users` 를 `user` 로 잘못 쓰면 계정이 안 생기며, 증상은 역시 「SSH 가 안 붙는다」 하나로만 나타난다.
이 파일에는 콘솔 비밀번호가 평문으로 들어 있다. `/tmp` 가 아니라 자기 홈에 `600` 으로 두고 검사가 끝나면 바로 지운다. `scp` 가 만드는 동안에는 파일이 잠깐 기본 권한으로 놓이므로 ⑤ 를 바로 뒤에 둔다.
이 실험대는 접속과 리다이렉션과 권한 설정을 두 줄에 몰아넣었다(observed).
```bash label="[lab host] 이 실험대가 실제로 친 형태"
ssh donghyeon@192.168.122.11 'umask 077; cat > ~/kc-lab-2.yaml' < kc-lab-2.yaml
ssh donghyeon@192.168.122.11 'cloud-init schema -c ~/kc-lab-2.yaml; rm -f ~/kc-lab-2.yaml'
```
명령 수가 둘에서 다섯으로 늘고 행동 하나가 명령 하나가 된다. `umask 077` 이 `chmod 600` 으로 바뀌는 것 하나가 다르다. 이 다섯 줄 형태는 이 실험대에서 치지 않았다(unknown).
:::warning
`sudo` 는 리스트가 아니라 문자열로 쓴다. 게스트의 cloud-init 22.4.2 스키마 검사기가 리스트 형태를 거부한다.
:::
```yaml label="kc-lab-1.yaml 의 sudo 줄 — 두 형태"
sudo: ['ALL=(ALL) NOPASSWD:ALL'] # 거부된다
sudo: "ALL=(ALL) NOPASSWD:ALL" # 통과한다
```
```text
Error: Cloud config schema errors: users.0: {'name': 'donghyeon', 'groups':
['sudo'], 'shell': '/bin/bash', ...} is not valid under any of the given schemas
```
**문제가 생기면** — 검사기는 어느 키가 문제인지 안 알려 준다. `users.0` 전체를 통째로 찍고 어느 스키마에도 안 맞는다고만 하므로 키를 하나씩 바꿔 가며 좁혀야 한다. 게다가 리스트 형태도 부팅은 된다 — `kc-lab-1` 과 `kc-lab-2` 가 그 상태로 NOPASSWD sudo 가 멀쩡히 돌고 있다. 검사기만 거부하므로 「검사는 실패했는데 왜 되지」로 읽히기 쉽다.
### 5. 시드 ISO 를 만들어 풀에 올린다
**목적** — user-data 와 meta-data 를 `cidata` 라벨이 붙은 볼륨 하나로 묶어 libvirt 풀에 올린다.
```bash label="[lab host] ① meta-data 파일을 연다"
nano meta-kc-lab-1
```
```yaml label="② meta-kc-lab-1 에 쓸 내용"
instance-id: kc-lab-1-20260912
local-hostname: kc-lab-1
```
`kc-lab-1-` 뒤의 `20260912` 는 예시 값이다. 원래 명령은 거기에 에폭 초를 넣었다. 값 자체에 뜻은 없고, cloud-init 이 이전 실행과 다른 인스턴스로 알아보게 이전 값과 겹치지 않게 둔다. 날짜든 에폭 초든 저번과 다르기만 하면 된다.
```bash label="[lab host] ③ 시드 이미지를 굽는다"
xorrisofs -quiet -output seed-kc-lab-1.iso -volid CIDATA -joliet -rock \
-graft-points /user-data=kc-lab-1.yaml /meta-data=meta-kc-lab-1
```
```bash label="[lab host] ④ 빈 볼륨을 만들고 내용을 채운다"
virsh vol-create-as default seed-kc-lab-1.iso "$(stat -c%s seed-kc-lab-1.iso)" --format raw
virsh vol-upload --pool default seed-kc-lab-1.iso seed-kc-lab-1.iso
```
```bash label="[lab host] ⑤ 볼륨이 풀에 올라갔고 비어 있지 않은지 본다"
virsh vol-list default
virsh vol-info --pool default seed-kc-lab-1.iso
stat -c%s seed-kc-lab-1.iso
```
**예상 결과** — `default` 풀에 `seed-kc-lab-1.iso` 가 잡히고 `Capacity` 가 방금 만든 로컬 파일 크기와 같다.
**왜 필요한가** — 시드는 `cidata` 라벨이 붙고 안에 `user-data` 와 `meta-data` 라는 정확한 이름의 파일이 있는 볼륨이어야 한다. `vol-create-as` 는 빈 볼륨을 만들 뿐이고 내용은 `vol-upload` 가 채운다. 뒤엣것을 빠뜨리면 목록에는 이름이 보이는데 안이 0 으로 채워져 있고, cloud-init 은 `cidata` 라벨을 못 찾아 조용히 끝난다 — 증상은 또 「SSH 가 안 붙는다」다. `instance-id` 를 매번 다른 값으로 두는 것은 cloud-init 이 인스턴스마다 한 번만 초기화 모듈을 돌리기 때문이고, id 가 같으면 user-data 를 고쳐도 반영되지 않는다.
이 실험대는 meta-data 를 명령으로 만들었다(observed).
```bash label="[lab host] 이 실험대가 실제로 친 형태"
printf 'instance-id: kc-lab-1-%s\nlocal-hostname: kc-lab-1\n' "$(date +%s)" > meta-kc-lab-1
```
배울 것이 `instance-id:` 와 `local-hostname:` 두 키인데 그 형태는 서식 문자와 명령 치환을 먼저 읽게 만든다. 나머지 네 줄은 시드를 굽고 볼륨을 올리는 일이 명령 자체의 목적이라 그대로 둔다.
**문제가 생기면** — 게스트가 뜨고도 호스트명이 `localhost` 면 ④ 의 뒷줄을 빠뜨렸는지 본다.
### 6. DHCP 예약을 먼저 넣는다
**목적** — 게스트 셋의 MAC 에 주소를 못 박아 IP 가 매번 바뀌지 않게 한다.
:::warning
이 단계가 게스트 생성보다 먼저다. 순서가 반대면 게스트가 동적 대역에서 아무 주소나 받아 버리고, 그 뒤에 예약을 넣어도 이미 잡은 리스가 유지된다. 되돌리려면 리스 만료를 기다리거나 게스트를 재시작해야 한다.
:::
```bash label="[lab host] ① 엣지 게스트의 주소를 예약한다"
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
```
```bash label="[lab host] ② k3s server 게스트의 주소를 예약한다"
virsh net-update default add ip-dhcp-host \
"<host mac='52:54:00:aa:bb:11' name='kc-lab-1' ip='192.168.122.11'/>" \
--live --config
```
```bash label="[lab host] ③ k3s agent 게스트의 주소를 예약한다"
virsh net-update default add ip-dhcp-host \
"<host mac='52:54:00:aa:bb:12' name='kc-lab-2' ip='192.168.122.12'/>" \
--live --config
```
```bash label="[lab host] ④ 예약이 들어갔는지 본다"
virsh net-dumpxml default | grep -E "host mac|range start"
```
**예상 결과** — 넣을 때 한 줄이 나오고(observed), 확인하면 네 줄이 나온다(observed).
```text
Updated network default persistent config and live state
```
```text
<range start='192.168.122.2' end='192.168.122.254'/>
<host mac='52:54:00:aa:bb:11' name='kc-lab-1' ip='192.168.122.11'/>
<host mac='52:54:00:aa:bb:12' name='kc-lab-2' ip='192.168.122.12'/>
<host mac='52:54:00:aa:bb:10' name='kc-lab-edge' ip='192.168.122.10'/>
```
`persistent config` 와 `live state` 두 마디가 다 나와야 `--live --config` 가 제대로 먹었다. `<host>` 세 줄은 MAC 끝 두 자리와 IP 끝 숫자가 짝이 맞는지 본다. `<range>` 줄은 예약이 아니라 동적 대역이라, 예약한 주소가 그 안에 들어 있어도 상관없다.
**왜 필요한가** — 아직 게스트가 없어도 예약은 들어간다. 예약은 「이 MAC 이 나타나면 이 IP 를 줘라」이지 「지금 있는 게스트에게 줘라」가 아니다. MAC 은 7번의 `virt-install --network mac=` 에 쓸 값을 여기서 미리 정한다. `--live` 만 주면 재부팅에 사라지고 `--config` 만 주면 지금 반영되지 않는다.
이미 있는 예약을 또 넣으면 이렇게 거부된다(observed). 오류처럼 보이지만 이미 들어가 있다는 뜻이라 그냥 넘어가면 된다.
```text
error: Requested operation is not valid: there is an existing dhcp host entry
in network 'default' that matches "<host mac='52:54:00:aa:bb:12' ... />"
```
**문제가 생기면** — 확인은 `<host mac=...>` 로 한다. `ip-dhcp-host` 는 `net-update` 의 섹션 이름이라 XML 안에 그 문자열이 없고, 그것으로 `grep` 하면 예약이 멀쩡히 들어가 있어도 아무것도 안 나온다. 세 줄을 셸 반복문으로 돌리지도 않는다 — zsh 는 따옴표 없는 변수를 단어로 나누지 않아서 `XML error: Cannot use host name ''` 로 끝난다. 값을 그대로 세 번 치는 쪽이 안전하다. 재부팅 뒤에도 남는지는 비활성 정의를 따로 본다.
```bash label="[lab host] 재부팅 뒤에도 남는 정의를 본다"
virsh net-dumpxml --inactive default
```
### 7. 게스트 셋을 만든다
**목적** — base 이미지 위의 오버레이 디스크와 시드 볼륨을 붙여 세 게스트를 띄운다.
```bash label="[lab host] ① k3s server 게스트"
virt-install --name kc-lab-1 --memory 5120 --vcpus 2 \
--disk size=20,backing_store=/var/lib/libvirt/images/base.qcow2 \
--disk vol=default/seed-kc-lab-1.iso,device=disk,bus=virtio,readonly=on \
--network network=default,mac=52:54:00:aa:bb:11 \
--import --os-variant debian12 --noautoconsole
```
```bash label="[lab host] ② k3s agent 게스트"
virt-install --name kc-lab-2 --memory 4096 --vcpus 2 \
--disk size=20,backing_store=/var/lib/libvirt/images/base.qcow2 \
--disk vol=default/seed-kc-lab-2.iso,device=disk,bus=virtio,readonly=on \
--network network=default,mac=52:54:00:aa:bb:12 \
--import --os-variant debian12 --noautoconsole
```
```bash label="[lab host] ③ 엣지 게스트 — nginx 와 certbot 만 돌므로 훨씬 작아도 된다"
virt-install --name kc-lab-edge --memory 1024 --vcpus 1 \
--disk size=10,backing_store=/var/lib/libvirt/images/base.qcow2 \
--disk vol=default/seed-kc-lab-edge.iso,device=disk,bus=virtio,readonly=on \
--network network=default,mac=52:54:00:aa:bb:10 \
--import --os-variant debian12 --noautoconsole
```
MAC 은 6번에서 예약한 값을 한 글자도 다르지 않게 쓴다.
**예상 결과** — 엣지 생성 출력이다(observed).
```text
Starting install...
Allocating 'kc-lab-edge.qcow2' | 10 GB 00:00
Creating domain... | 00:00
Domain creation completed.
```
`Domain creation completed.` 한 줄을 본다. 그 위 `Allocating` 이 `00:00` 으로 즉시 끝나는 쪽이 정상이다 — 오버레이라 10GB 를 실제로 쓰지 않는다.
**왜 필요한가** — `virt-install` 은 VM 을 만드는 것 자체가 목적인 명령이라 옵션이 길어도 이 형태로 둔다. 여기서 준 값이 곧 게스트의 정체다 — 이름, 메모리, vCPU, 오버레이 디스크, 시드 볼륨, 예약해 둔 MAC. `--disk size=20,backing_store=...` 는 복사가 아니라 오버레이라, base 를 읽기 전용으로 두고 변경분만 새 파일에 쌓으므로 20GB 짜리를 둘 만들어도 실제 디스크는 몇백 MB 만 쓴다.
:::warning
시드를 `--cloud-init` 으로 붙이지 않는다. 그 옵션은 시드를 SATA CD-ROM 으로 붙이는데 Debian genericcloud 이미지는 크기를 줄이려고 물리 하드웨어 드라이버를 뺐고, AHCI 장치가 보이지 않아 cloud-init 이 데이터소스를 못 찾고 조용히 끝난다. `bus=virtio` 로 디스크로 붙여야 한다.
:::
**문제가 생기면** — `XML error: Cannot use host name ''` 이 나오면 6번의 예약이 빈 이름으로 들어간 것이니 그 세 줄을 값 그대로 다시 친다.
## 구성 값
게스트 셋의 배치다. 디스크는 전부 base 이미지 위의 오버레이이고 복사가 아니다.
| 게스트 | IP · MAC 끝 | vCPU · 메모리 · 디스크 | 무엇이 도나 |
|---|---|---|---|
| `kc-lab-edge` | 192.168.122.10 · `:10` | 1 · 1024MB · 10GB | nginx · certbot |
| `kc-lab-1` | 192.168.122.11 · `:11` | 2 · 5120MB · 20GB | k3s server · Traefik |
| `kc-lab-2` | 192.168.122.12 · `:12` | 2 · 4096MB · 20GB | k3s agent · Traefik |
이미지와 시드 쪽 값이다.
| 무엇 | 값 |
|---|---|
| base 이미지 | `/var/lib/libvirt/images/base.qcow2` — Debian 12 genericcloud amd64 |
| 게스트 OS | `Debian GNU/Linux 12 (bookworm)` |
| 디스크 | base 위의 오버레이(`backing_store=`). 복사가 아니다 |
| 시드 | `seed-<이름>.iso` — `CIDATA` 라벨 · `user-data`·`meta-data` · `bus=virtio` |
| cloud-init 패키지 | `[curl, nftables]` |
| 스키마 검사기 | 게스트의 cloud-init `22.4.2` |
메모리는 처음 만들 때 세 대 다 3584MB 였고, 실험을 늘리며 5120 과 4096 으로 재배분했다(observed). 세 게스트의 배정 합은 5120+4096+1024 = 10,240MB 이고, 호스트 RAM 은 11,648MiB 다. 배정 합이 더 작아서 배정률은 10240/11648 = 87.9% 이고, 배정하지 않고 남은 것이 1,408MiB 다. 그래서 이 배치는 「Guest configured memory 총량이 Host physical RAM보다 크다」는 Memory Overcommit 에 해당하지 않는다.
호스트 RAM 11,648MiB 는 2026-09-10 에 이 호스트에서 `free -m | head -2` 로 받은 `Mem:` 행의 `total` 이다. `free -m` 은 MiB 로 찍으므로, 자기 호스트에서 이 배치를 다시 잡을 때도 같은 명령으로 재고 MiB 로 읽는다.
표의 메모리 칸은 `virt-install --memory` 에 준 값, 곧 선언한 상한이다. 뒤에 `virsh dommemstat` 으로 보면 `kc-lab-2` 가 4096 이 아니라 3120 으로 나오는데 virtio-balloon 이 회수해 간 것으로 보인다. `dommemstat` 의 `actual` 은 현재 할당이지 선언한 상한이 아니고, 상한은 `virsh dominfo` 의 `Max memory` 에 있다 — 이 실험대는 그 둘을 나란히 찍어 보지 않았다. 둘을 헷갈리면 「메모리가 왜 줄었지」가 된다.
vCPU 합은 2 + 2 + 1 = 5 이고 이 호스트의 논리 코어는 8 이다. libvirt 는 vCPU 합이 논리 코어 수를 넘어도 막지 않으므로, 더 크게 잡아도 `virt-install` 은 통과한다. 여유를 둘지는 이 표에서 정한다.
## 끝났는지 판정한다
### 확인 ① 세 게스트가 떴고 cloud-init 이 제 일을 했는가
**무엇을 확인하는가** — 도메인 셋이 돌고 있는지, 그리고 게스트 안이 시드대로 채워졌는지.
```bash label="[lab host] 도메인 목록과 게스트 안을 함께 본다"
virsh list --all
ssh donghyeon@192.168.122.11 'hostname; cat /etc/os-release | head -1'
```
**실측**(observed)
```text
Id Name State
-----------------------------
2 kc-lab-1 running
4 kc-lab-2 running
5 kc-lab-edge running
kc-lab-1
PRETTY_NAME="Debian GNU/Linux 12 (bookworm)"
```
**어디를 봐야 하는가** — 세 가지다. State 가 세 대 다 `running` 인가(`shut off` 면 아직 안 뜬 것이고 Id 칸이 `-` 로 비어 있다), SSH 가 비밀번호를 묻지 않고 통과했는가, `hostname` 이 `kc-lab-1` 인가 `localhost` 인가.
**이 결과가 의미하는 것** — 이 세 줄이 cloud-init 성공의 판정 기준 전부다. 호스트명이 바뀌었다는 것은 시드가 읽혔다는 뜻이고, 시드가 읽혔으면 같은 파일에 있던 SSH 키도 들어갔다는 뜻이다. `localhost` 가 나오면 SSH 설정을 고치지 말고 시드부터 의심한다. Id 번호가 2 보다 큰 것에는 아무 뜻도 없다 — 만들고 지운 이력이 그렇게 남는다.
### 확인 ② 엣지가 예약한 IP 를 받았고 cloud-init 이 끝났는가
**무엇을 확인하는가** — DHCP 예약이 실제로 먹었는지, 그리고 초기화가 아직 도는 중인지 끝났는지.
```bash label="[lab host] 호스트명 · 주소 · cloud-init 상태를 한 번에 본다"
ssh kc-lab-edge 'hostname; ip -4 -br addr show enp1s0; cloud-init status'
```
**실측**(observed)
```text
kc-lab-edge
enp1s0 UP 192.168.122.10/24 metric 100
status: done
```
**어디를 봐야 하는가** — 세 줄이다. 호스트명이 `kc-lab-edge` 인가, 주소가 예약한 `.10` 인가, `cloud-init status` 가 `done` 인가.
**이 결과가 의미하는 것** — `running` 이면 아직 패키지를 받는 중이니 기다린다. 이 실험대에서는 약 50초 걸렸다(observed). `error` 면 어느 모듈이 실패했는지 길게 본다.
```bash label="[lab host] 어느 모듈이 실패했는지 길게 본다"
ssh kc-lab-edge 'cloud-init status --long'
```
### 확인 ③ 게스트에 못 들어갈 때는 화면을 직접 뜬다
**무엇을 확인하는가** — SSH 가 막힌 원인이 시드 쪽인지 그 뒤인지.
```bash label="[lab host] 게스트 화면을 파일로 받는다"
virsh screenshot kc-lab-1 /tmp/kc1.ppm
```
확장자와 무관하게 PNG 로 저장된다.
**어디를 봐야 하는가** — 이미지를 열어 로그인 프롬프트 앞의 호스트명 한 낱말만 본다. `localhost login:` 인가 `kc-lab-1 login:` 인가.
**이 결과가 의미하는 것** — `localhost` 면 cloud-init 이 아예 안 돌았다. 시드를 못 찾았거나(7번의 `bus=virtio`) YAML 파싱에 실패한 것(3번)이므로 SSH 쪽은 볼 필요가 없다. `kc-lab-1` 이면 cloud-init 은 돌았고 그 안의 사용자·키 단계에서 틀린 것이라, 콘솔로 들어가 게스트 안의 로그를 본다.
```bash label="[lab host] 콘솔로 들어간다. 빠져나오려면 Ctrl+]"
virsh console kc-lab-1
```
```bash label="[kc-lab-1] 게스트 안에서 cloud-init 로그를 읽는다"
sudo cloud-init status --long
sudo journalctl -u cloud-init -n 50
```
콘솔 로그인에 쓰는 비밀번호가 2번 ③ 으로 만든 값이다. 이 한 장과 이 두 줄이 「SSH 가 안 되는 이유」를 절반으로 줄인다.
## 통과 조건을 한 번에 다시 본다
| 무엇 | 명령 | 통과 |
|---|---|---|
| base 이미지 | `qemu-img info /var/lib/libvirt/images/base.qcow2` | `file format: qcow2` · `backing file:` 줄이 없다 |
| 시드 볼륨 | `virsh vol-info --pool default seed-kc-lab-1.iso` | `Capacity` 가 로컬 파일 크기와 같다 |
| 예약 | `virsh net-dumpxml default \| grep -E "host mac\|range start"` | `<host>` 세 줄 · MAC 끝과 IP 끝이 짝 |
| 도메인 | `virsh list --all` | 세 대 다 `running` |
| 게스트 안 | `ssh kc-lab-edge 'hostname; cloud-init status'` | `kc-lab-edge` · `status: done` |
## 막히면
| 증상 | 원인 | 확인 |
|---|---|---|
| SSH `Permission denied (publickey)` · hostname 이 `localhost` | cloud-init 이 안 돌았다. 시드를 SATA 로 붙였거나 YAML 파싱 실패 | `virsh screenshot` |
| user-data 를 고쳤는데 반영 안 됨 | `instance-id` 가 같아 건너뜀 | meta-data 의 id 확인 |
| IP 가 매번 바뀐다 | DHCP 예약이 `--config` 없이 들어감 | `net-dumpxml` |
| `XML error: Cannot use host name ''` | zsh 가 따옴표 없는 변수를 단어 분리하지 않는다 | 세 줄을 값 그대로 친다 |
| 예약 넣을 때 `existing dhcp host entry` | 이미 들어가 있다. 오류가 아니다 | `net-dumpxml` 로 세 줄 확인 |
| 시드는 올라갔는데 cloud-init 이 안 돈다 | `vol-upload` 를 빠뜨려 볼륨이 비었다 | `virsh vol-info --pool default seed-kc-lab-1.iso` |
| `qemu-img info` 가 `raw` 라고 한다 | 내려받기가 끊겨 오류 페이지를 저장했다 | 다시 받는다 |
| VM 이 느리다 | KVM 미사용 | 앞 단계로 |
SSH 가 안 붙는 증상 넷을 갈라 보는 방법은 이 표보다 자세한 기록이 따로 있다.
## 무엇이 관측이고 무엇이 아닌가
- (observed) 게스트 세 대의 IP·MAC·vCPU·메모리, `Debian GNU/Linux 12 (bookworm)`, `cloud-init status: done`, cloud-init 대기 약 50초, 스키마 검사기의 거부 문구, `Valid cloud-config` 한 줄, 예약을 넣을 때와 다시 넣을 때의 문구, `virt-install` 의 네 줄 출력.
- (observed) 메모리는 처음 만들 때 3584MB 였고 실험을 늘리며 5120 과 4096 으로 재배분했다. 세 게스트의 배정 합 10,240MB 는 호스트 RAM 11,648MiB 보다 작고, 배정하지 않고 남은 것이 1,408MiB 다. 「Guest configured memory 총량이 Host physical RAM보다 크다」는 Memory Overcommit 에 해당하지 않는 배치다.
- (unknown) `kc-lab-1.yaml` 을 템플릿에서 어떻게 만드는지가 원본에도 없다. `kc-lab.yaml.example` 이 반입되지 않아 대조하지 못했다.
- (unknown) cloud-init 의 `packages` 에 certbot 이 들어 있었는지가 가이드 안에서 갈린다. 01 의 예시와 03 의 본문은 `curl` 과 `nftables` 뿐이라 적고, 04 는 템플릿의 `packages` 에 certbot 이 있다고 적는다.
- (unknown) 파일을 옮겨 검사하는 다섯 줄 형태, `virsh vol-list` 와 `vol-info`, `net-dumpxml --inactive`, `cloud-init status --long`, `virsh console` 은 가이드가 적어 둔 명령이고 이 실험대가 캡처한 출력이 없다.
- (unknown) 편집기로 `kc-lab-1.yaml` 과 `meta-kc-lab-1` 을 여는 형태도 이 실험대가 치지 않았다. 이 실험대는 치환 명령과 서식 출력으로 만들었고, 같은 상태에 닿는지는 다시 재지 않았다.
- (unknown) 원본 가이드에 되돌리는 절차가 없다. 단계 03 이 지나가며 적은 한 줄 말고는 게스트와 시드 볼륨과 DHCP 예약을 걷어내는 순서가 어디에도 없다.
- (inferred) `virt-install` 세 줄은 구축할 때 친 것을 옮겼고 재실행으로 검증되지 않았다.
<!-- body:end -->
@@ -0,0 +1,563 @@
---
id: 74a7bacf-e5d8-4129-926a-c8cf5cacb8c9
kind: SETUP
slug: edge-nginx-and-host-dnat
title: 엣지 게스트에 nginx 를 세우고 호스트 DNAT 으로 밖에서 들어오는 길을 연다
topic: lab-environment-build
topicName: 실험대 환경 구성
project: virtualization
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/74a7bacf-e5d8-4129-926a-c8cf5cacb8c9/edit"
pinnedVersions:
- name: nginx (엣지 게스트)
version: 1.22.1
- name: Debian GNU/Linux
version: 12 (bookworm)
- name: libvirt
version: 12.7.0
source:
- final/document.md#189-단계-03-엣지-nginx-라우팅과-호스트-dnat
- final/document.md#179-엣지를-물리-호스트에서-vm-으로-옮기면-무엇이-새로-필요해지나
- final/document.md#180-nftables-는-앞-체인의-accept-로-뒤-체인의-reject-를-막지-못한다
- final/document.md#184-이-부의-출처와-범위
sourceRevision: 9465582b5d1630eb4ae7c4e078021486919bf6b6
---
# 엣지 게스트에 nginx 를 세우고 호스트 DNAT 으로 밖에서 들어오는 길을 연다
엣지 게스트에 nginx 를 깔아 80 을 받게 하고 물리 호스트에 nftables DNAT 파일과 그것을 걸어 주는 systemd 유닛을 두는 절차다. 끝나면 밖에서 온 요청이 호스트를 지나 엣지 nginx 로, 거기서 Traefik 으로 닿는다. TLS 는 아직 없다.
## 관계
- **엣지 nginx 를 호스트에서 게스트 VM 으로 옮긴다 — 성능이 아니라 더러워지는 층의 격리**
이 절차가 실행하는 결정이고, 왜 옮겼는지와 무엇을 감수했는지는 그 기록이 갖는다.
- **호스트 안에서는 404, 밖에서는 connection refused — 앞 체인의 accept 가 libvirt 의 reject 를 막지 못했다**
유닛의 `ExecStartPost` 가 뚫는 구멍이 왜 필요한지를 그 사건이 끝까지 따라간다.
- **k3s server 와 agent 를 게스트 두 대에 깔고 lab host 에서 kubectl 로 본다**
앞 단계이고, 거기서 확인한 INTERNAL-IP 두 개가 여기서 upstream 주소가 된다.
- **DNS-01 으로 와일드카드 인증서를 받고 갱신이 서빙까지 닿게 한다**
다음 단계이고, 여기서 `http` 로 둔 `X-Forwarded-Proto` 가 거기서 `https` 로 바뀐다.
- **가까운 층부터 한 층씩 건너뛰며 확인한다 — 층마다 성공 신호가 다르다**
① 의 `404` 를 성공으로 읽는 근거가 그 기준이다.
- **단계별 구축 문서는 작성 시점이 아니라 실행 순서로 검증한다**
세우는 명령이 왜 재실행으로 검증되지 않은 채 남았는지를 그 기준이 가른다.
- **libvirt 의 firewall_backend 가 iptables 일 때도 guest_input 구멍이 필요한가**
이 절차의 유닛이 뚫는 구멍이 다른 백엔드에서도 필요한지는 아직 재지 않았다.
## 본문
<!-- body:start -->
## 읽기 전에 — 어디서 치는가
이 단계는 기계 둘을 오간다.
| 번호 | 무엇 | 어디서 |
|---|---|---|
| 1 | nginx 설치와 기본 사이트 끄기 | `[kc-lab-edge]` |
| 2 · 3 | 라우팅 설정과 링크 · 문법 검사와 reload | `[kc-lab-edge]` |
| 4 | DNAT 파일 | `[lab host]` — 여기만 엣지가 아니다 |
| 5 | systemd 유닛과 libvirt 구멍 | `[lab host]` |
| 판정 | 층별 확인 ①~④ | `[lab host]``[워크스테이션]` |
**4번과 5번만 호스트에서 치는 까닭이 둘이다.** DNAT 규칙의 첫 줄이 `iifname "tailscale0"` 인데 VM 에는 Tailscale 을 넣지 않기로 했으므로 엣지에는 그 인터페이스 자체가 없고, 넘기는 대상이 `192.168.122.10` 으로 가는 트래픽이라 넘기는 주체는 그 앞에 있는 호스트다. 엣지에 들어가서 치면 `tailscale0` 이 없어 규칙이 의미가 없다.
여는 파일은 셋이다 — nginx 라우팅 설정, nftables DNAT 파일, systemd 유닛. 셋 다 사람이 내용을 읽고 고쳐야 하는 파일이라 편집기로 연다. `nano` 는 저장이 `Ctrl+O` 다음 `Enter`, 나가기가 `Ctrl+X` 다. 설치와 링크와 문법 검사와 reload 는 운영자가 그대로 치는 명령을 쓴다.
## 이 단계가 세우는 것
가이드 03 의 「이 단계가 끝나면」은 두 줄이다.
> 밖에서 보낸 요청이 **호스트 DNAT → 엣지 nginx → Traefik → 파드**로 닿는다.
> 아직 TLS 는 없다.
같은 nginx 인데 사는 곳만 바꾼 배치다.
```text
전: tailnet:443 ─▶ [호스트 nginx] ─────────────▶ Traefik(게스트 .11/.12)
후: tailnet:443 ─▶ [호스트 커널 DNAT] ─▶ [엣지 nginx(.10)] ─▶ Traefik(.11/.12)
```
파일 넷이 놓이는 곳과 저장소 원본이다.
| 어디에 | 무엇 | 저장소 원본 |
|---|---|---|
| `[kc-lab-edge]` | `/etc/nginx/sites-available/keycloak-lab` + `sites-enabled/` 심볼릭 링크 | `deploy/lab/edge/nginx-keycloak-lab.conf` |
| `[kc-lab-edge]` | `sites-enabled/default` 를 지운다 | — |
| `[lab host]` | `/etc/nftables.d/lab-edge-dnat.nft` | `deploy/lab/edge/lab-edge-dnat.nft` |
| `[lab host]` | `/etc/systemd/system/lab-edge-dnat.service` | `deploy/lab/edge/lab-edge-dnat.service` |
**왜 프록시가 두 겹인가.** nginx 와 Traefik 이 하는 일이 다르다.
| 어느 쪽 | 맡는 것 |
|---|---|
| 엣지 nginx (`kc-lab-edge`) | 바깥세상과의 접점 — TLS 종단 · 인증서 · `X-Forwarded-*` |
| Traefik | 클러스터 안의 동적 라우팅 — Ingress 를 보고 서비스를 고른다 |
이 2홉이 운영 구조와 같다는 것이 이 배치의 핵심이고, 동시에 헤더 실험이 성립하는 근거가 된다. 1홉을 가정하고 쓴 계약이 2홉에서도 유효한지를 재려면 두 겹이 있어야 한다.
## 전제와 되돌리기
전제는 두 줄이다 — 앞 단계가 끝나 두 노드가 `Ready` 이고, 게스트 세 대가 떠 있다. 그리고 엣지에 nginx 는 아직 없다. cloud-init 이 까는 것은 `curl``nftables` 뿐이라 1번에서 직접 깐다.
**되돌리기가 적힌 것은 가이드 7편 가운데 이 편뿐이다.** 세우는 절차가 아니라 걷어낼 때만 본다.
| 무엇을 | 어디서 | 명령 |
|---|---|---|
| DNAT 만 끈다 | `[lab host]` | `sudo systemctl disable --now lab-edge-dnat.service` |
| 규칙만 즉시 걷어낸다 | `[lab host]` | `sudo nft delete table ip lab_edge` |
| libvirt 에 뚫은 구멍을 막는다 | `[lab host]` | `sudo nft -a list chain ip libvirt_network guest_input` 로 handle 을 보고 `sudo nft delete rule ip libvirt_network guest_input handle <번호>` |
| 엣지 라우팅을 끈다 | `[kc-lab-edge]` | `sudo rm /etc/nginx/sites-enabled/keycloak-lab && sudo nginx -t && sudo systemctl reload nginx` |
`sites-available` 의 원본은 남으므로 `ln -sf` 로 다시 걸면 복구된다. 네 줄이 원문 그대로이고, 그 네 줄을 실제로 쳐서 걷어내 본 기록은 없다(unknown).
**`.nft` 파일 맨 위의 `delete` 는 이것과 다르다.** 그 두 줄은 파일을 적용할 때마다 자동으로 도는 재적용 안전장치이고, 위 표의 삭제 명령은 사람이 끄는 버튼이다.
## 세우기 전에 먼저 본다
엣지 게스트에는 lab host 에서 들어간다. 여기부터 4번 앞까지가 게스트 셸이다.
```bash label="[lab host] 엣지 게스트에 들어간다"
ssh kc-lab-edge
```
**무엇을 확인하는가** — 엣지에 nginx 가 이미 깔려 있는지.
```bash label="[kc-lab-edge] 실행 파일이 있는지 본다"
which nginx
```
**어디를 봐야 하는가** — 아무것도 안 찍히면 미설치다. 2026-09-11 에 새로 만든 `kc-lab-edge` 에서는 이렇게 나왔다(observed).
```text
donghyeon@kc-lab-edge:~$ cd /etc/nginx/
-bash: cd: /etc/nginx/: No such file or directory
```
**이 결과가 의미하는 것** — `/etc/nginx` 디렉터리는 패키지가 만든다. 그것이 없다는 것은 경로를 잘못 짚었다는 뜻이 아니라 설치가 안 됐다는 뜻이다. 앞 단계의 cloud-init 목록에 nginx 가 없으므로 1번에서 깐다.
## 실행 절차
### 1. 엣지에 nginx 를 깔고 기본 사이트를 끈다
**목적** — 80 을 듣는 것이 우리 설정 하나가 되게 만든다.
```bash label="[kc-lab-edge] ① 패키지 목록을 갱신하고 nginx 를 깐다"
sudo apt update && sudo apt install -y nginx
```
```bash label="[kc-lab-edge] ② 떴는지와 데비안 관례의 두 디렉터리가 있는지 본다"
systemctl status nginx --no-pager | head -5
ls /etc/nginx/
```
```bash label="[kc-lab-edge] ③ 지금 무엇이 걸려 있는지 본다"
ls -l /etc/nginx/sites-enabled/
```
```bash label="[kc-lab-edge] ④ 기본 링크를 지운다"
sudo rm /etc/nginx/sites-enabled/default
```
**예상 결과** — `Active:` 줄이 `active (running)` 이고 `ls` 결과에 `sites-available` 과 `sites-enabled` 가 둘 다 있다. ③ 에 `default` 한 줄이 보이고 ④ 뒤에는 목록이 빈다.
**왜 필요한가** — Debian 계열은 설치와 동시에 기동까지 하므로 이 시점에 nginx 는 이미 80 을 잡고 있고, 그것을 잡은 것이 `sites-enabled/default` 다. 2번에서 쓸 설정도 `listen 80 default_server` 라 그대로 두면 겹치고, 안 지우면 3번의 `nginx -t` 가 `a duplicate default server for 0.0.0.0:80` 으로 막는다. ③ 의 화살표를 보면 `default -> ../sites-available/default` 처럼 심볼릭 링크라, 지우는 것은 링크뿐이고 원본은 그대로 있다.
**문제가 생기면** — `nginx -t` 가 `duplicate default server` 를 내면 ④ 를 건너뛴 것이다. Arch 호스트에는 이 구조가 아예 없다 — `sites-available` 과 `sites-enabled` 는 Debian 패키징 관례이고 Arch 의 nginx 는 `nginx.conf` 에 직접 쓰거나 `conf.d/` 를 쓴다. 이 실험대는 운영과 맞추려고 Debian 게스트를 엣지로 두었으므로 여기서는 Debian 관례가 그대로 통한다.
### 2. 라우팅 설정을 쓴다
**목적** — 엣지가 받은 80 요청을 k3s 두 노드의 Traefik 으로 넘기게 한다.
```bash label="[kc-lab-edge] ① 설정 파일을 연다"
sudo nano /etc/nginx/sites-available/keycloak-lab
```
```nginx label="② keycloak-lab 에 쓸 내용"
# file: /etc/nginx/sites-available/keycloak-lab
upstream k3s_traefik {
server 192.168.122.11:80;
server 192.168.122.12:80;
}
server {
listen 80 default_server;
server_name _;
location / {
proxy_pass http://k3s_traefik;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto http;
proxy_set_header X-Forwarded-Port 80;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}
```
```bash label="[kc-lab-edge] ③ 방금 쓴 파일이 제자리에 있는지 본다"
ls /etc/nginx/sites-available/
```
**예상 결과** — ③ 의 목록에 `keycloak-lab` 이 보인다. nginx 는 아직 이 파일을 읽지 않고, 읽게 만드는 것이 3번이다.
**왜 필요한가** — 이 단계에서는 80 만 세운다. 인증서가 없는데 `ssl_certificate` 줄을 미리 써 두면 설정 전체가 실패해서 80 블록까지 안 뜬다 — nginx 는 그 파일을 나중이 아니라 기동과 reload 시점에 읽는다. `X-Forwarded-Proto` 가 `http` 인 것도 지금이 그렇기 때문이고, 다음 단계에서 `https` 로 바뀐다. 실제로 80 으로 들어오는데 `https` 라고 적으면 Keycloak 이 리다이렉트 주소를 `https://` 로 만들고 로그인 도중에 끊긴다. upstream 이 둘인 것은 두 노드 모두 Traefik 이 뜨기 때문이고, nginx 는 기본 라운드로빈으로 번갈아 보내다가 한쪽이 죽으면 자동으로 뺀다.
:::warning
`sites-available` 은 복수형이다. `site-available` 로 치면 `nano` 가 군말 없이 빈 새 파일을 열고, 저장해도 nginx 는 그 파일을 영원히 안 읽고, `nginx -t` 는 멀쩡히 통과한다. 아무 에러 없이 아무 일도 안 일어나는 가장 찾기 어려운 형태라, 설정을 썼는데 변화가 없으면 경로 오타부터 의심한다.
:::
**문제가 생기면** — 어디다 썼는지는 이렇게 찾는다.
```bash label="[kc-lab-edge] 파일을 어디에 썼는지 찾는다"
sudo find /etc/nginx -name 'keycloak*'
```
### 3. 링크를 걸고 문법을 검사한 뒤 reload 한다
**목적** — 2번에서 쓴 파일을 nginx 가 읽는 목록에 올리고, 문법이 맞을 때만 적용한다.
```bash label="[kc-lab-edge] ① sites-enabled 에 링크를 건다"
sudo ln -sf /etc/nginx/sites-available/keycloak-lab /etc/nginx/sites-enabled/
```
```bash label="[kc-lab-edge] ② 링크가 생겼고 default 가 없는지 본다"
ls -l /etc/nginx/sites-enabled/
```
```bash label="[kc-lab-edge] ③ 문법을 보고 통과하면 reload 한다"
sudo nginx -t && sudo systemctl reload nginx
```
```bash label="[kc-lab-edge] ④ reload 가 반영됐는지 프로세스 트리로 본다"
systemctl status nginx --no-pager | head -20
```
**예상 결과** — Debian 12 는 `[warn]` 한 줄을 늘 같이 내놓는다(observed).
```text
nginx: [warn] could not build optimal types_hash, you should increase either
types_hash_max_size: 1024 or types_hash_bucket_size: 64
nginx: configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
```
`syntax is ok` 와 `test is successful` 두 마디가 다 나와야 통과다. 앞의 `[warn]` 줄은 통과를 막지 않는다. 통과했으면 `&&` 뒤의 reload 가 이어서 돌고 `systemctl reload` 는 아무 말 없이 끝난다.
**왜 필요한가** — `&&` 로 이은 것은 문법이 깨진 설정으로 reload 하지 않으려는 것이다. 실패면 `[emerg]` 줄에 파일과 줄 번호가 찍히고 reload 는 아예 안 돌아, 지금 돌고 있는 nginx 는 옛 설정 그대로 멀쩡하다. 경고와 오류를 여기서 갈라 두면 다음 단계에서 같은 `types_hash` 경고를 실패로 오독하지 않는다. ④ 의 프로세스 트리에서 워커 줄의 PID 를 눈에 담아 둔다 — reload 는 마스터를 그대로 두고 워커만 갈아 끼우므로, 전후로 워커 PID 가 바뀌면 새 설정이 적용된 것이다. 다음 단계에서 인증서 갱신이 서빙까지 닿았는지를 똑같은 방법으로 판정한다.
**문제가 생기면** — `unknown directive "http2"` 가 나오면 nginx 판 번호를 본다. Debian 12 는 1.22 이고 그 지시어는 1.25.1 부터다. `systemctl reload` 가 실패하면 reload 말고 `sudo nginx -t` 를 먼저 친다.
### 4. 호스트에 DNAT 파일을 쓴다
**목적** — 밖에서 tailnet 으로 들어온 80 과 443 을 커널이 엣지 게스트로 넘기게 한다. 여기까지 하면 엣지 nginx 는 살아 있는데 아무도 거기로 안 보낸다.
여기부터는 물리 호스트다. 게스트 셸에서 먼저 나온다. 나오지 않고 치면 `tailscale0` 이 없는 기계에 DNAT 을 쓰게 된다.
```bash label="[kc-lab-edge] ⓪ lab host 로 나온다"
exit
```
```bash label="[lab host] ① 저장할 디렉터리를 먼저 만든다"
sudo mkdir -p /etc/nftables.d
```
```bash label="[lab host] ② DNAT 파일을 연다"
sudo nano /etc/nftables.d/lab-edge-dnat.nft
```
```text label="③ lab-edge-dnat.nft 에 쓸 내용"
# file: /etc/nftables.d/lab-edge-dnat.nft
#!/usr/sbin/nft -f
table ip lab_edge
delete table ip lab_edge
table ip lab_edge {
chain prerouting {
type nat hook prerouting priority dstnat; policy accept;
iifname "tailscale0" tcp dport { 80, 443 } dnat to 192.168.122.10
}
}
```
**예상 결과** — 파일만 생긴다. 이 파일은 5번의 유닛이 읽을 때 비로소 커널에 들어간다.
**왜 필요한가** — 디렉터리가 없으면 `nano` 가 저장할 곳을 못 찾으므로 ① 을 먼저 친다. `table ip lab_edge` 와 `delete table ip lab_edge` 두 줄이 파일 맨 앞에 있는 것은 없는 테이블을 지우면 에러가 나기 때문이다. 한 번 만들고 지우면 같은 파일을 몇 번 적용해도 안전해진다. tailnet 주소 `100.83.212.4` 의 80 과 443 을 받는 것은 여전히 물리 호스트이고, 그 트래픽을 엣지로 넘기는 일이 물리 호스트가 실험대를 위해 하는 일의 전부다 — 이 규칙 하나와 게스트를 만들 때 넣은 DHCP 예약 세 줄이고, 둘 다 한 번 쓰고 다시 안 건드린다.
`prerouting` 체인 뒤에 빈 줄이 하나 남아 있다. 처음 쓴 파일에는 거기에 `priority filter - 10` 으로 먼저 도는 `forward` 체인이 있었고 `ct state new accept` 를 넣어 두었는데, 밖에서 오는 요청은 그래도 통과하지 못했다. 앞 체인의 `accept` 는 「이 체인은 통과」라는 뜻이지 「평가 끝」이 아니라서, 같은 훅에 붙은 뒤 체인이 그대로 `reject` 한다. 그래서 그 체인을 지웠고, 구멍은 5번에서 libvirt 체인 안에 뚫는다.
:::warning
`443` 을 `433` 으로 치지 않는다. `433` 도 유효한 포트라 nft 가 군말 없이 받는다. 80 은 멀쩡히 넘어가므로 이 단계와 층별 확인은 다 통과하고, 다음 단계에서 HTTPS 만 안 되는 형태로 뒤늦게 터진다. 이 실험대에서 실제로 나왔던 오타다.
:::
**문제가 생기면** — 80 은 되는데 HTTPS 만 안 되면 포트 숫자부터 본다.
```bash label="[lab host] 포트 숫자를 눈으로 확인한다"
grep dport /etc/nftables.d/lab-edge-dnat.nft
```
### 5. 호스트에 systemd 유닛을 쓰고 켠다
**목적** — 부팅할 때마다 DNAT 파일을 적용하고, libvirt 의 `reject` 앞에 구멍을 뚫는다.
```bash label="[lab host] ① 유닛 파일을 연다. 경로를 system 까지 끝까지 친다"
sudo nano /etc/systemd/system/lab-edge-dnat.service
```
```ini label="② lab-edge-dnat.service 에 쓸 내용"
# file: /etc/systemd/system/lab-edge-dnat.service
[Unit]
Description=Lab edge DNAT (tailnet :80/:443 -> kc-lab-edge)
After=network-online.target libvirtd.service
Wants=network-online.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/sbin/nft -f /etc/nftables.d/lab-edge-dnat.nft
ExecStartPost=-/usr/sbin/nft insert rule ip libvirt_network guest_input oif virbr0 ip daddr 192.168.122.10 tcp dport {80,443} ct state new counter accept
ExecStop=/usr/sbin/nft delete table ip lab_edge
[Install]
WantedBy=multi-user.target
```
```bash label="[lab host] ③ 두 파일이 제자리에 있는지 본다"
ls -l /etc/nftables.d/lab-edge-dnat.nft /etc/systemd/system/lab-edge-dnat.service
```
```bash label="[lab host] ④ 유닛 목록을 다시 읽는다"
sudo systemctl daemon-reload
```
```bash label="[lab host] ⑤ 지금 켜고 부팅에도 걸어 둔다"
sudo systemctl enable --now lab-edge-dnat.service
```
**예상 결과** — ③ 에서 두 줄이 다 나와야 한다. 한 줄이라도 `No such file` 이면 다음 명령은 무조건 실패한다. `enable --now` 가 심볼릭 링크를 만들고 유닛을 한 번 돌리는데, `Type=oneshot` 과 `RemainAfterExit=yes` 라 프로세스는 안 남고 상태만 `active` 로 남는다.
**왜 필요한가** — 규칙의 알맹이는 두 줄이고 둘이 사는 곳이 다르다.
| 하는 일 | 어디에 |
|---|---|
| tailnet 으로 들어온 80 과 443 을 `192.168.122.10` 으로 넘긴다 | 우리 테이블 `lab_edge` (`.nft` 파일) |
| 그 주소로 가는 새 연결을 통과시킨다 | libvirt 테이블 `libvirt_network` 의 `guest_input` 체인 (유닛의 `ExecStartPost`) |
libvirt 는 게스트 대역으로 새로 들어오는 연결을 거절한다. 자기 테이블의 `guest_input` 체인이 `established,related` 만 받고 나머지를 `reject` 로 끝내기 때문인데, 우리 테이블에 아무리 먼저 `accept` 를 놔도 소용이 없다. 그래서 구멍은 libvirt 체인 맨 앞에 뚫고, `insert` 가 맨 앞에 넣는다는 점이 핵심이다 — `add` 는 맨 뒤라 `reject` 뒤가 되어 의미가 없다. 이 규칙은 libvirt 가 네트워크를 다시 세우면(호스트 재부팅, `virsh net-start`, libvirtd 재시작) 날아가므로 유닛에 붙여 둔다.
`ExecStartPost` 앞의 `-` 는 그 명령이 실패해도 유닛을 실패로 보지 않는다는 뜻이다. `libvirt_network` 테이블은 가상 네트워크가 떠 있어야 존재하는데 부팅 순서에 따라 이 유닛이 먼저 돌 수 있고, `-` 가 없으면 구멍이 안 들어가면서 DNAT 까지 같이 안 실린다. `-` 를 두고 감수한 것이 그 반대쪽이다 — DNAT 만 실리고 구멍이 빠진 상태에서도 유닛은 `active` 이고 아무 오류도 안 남는다. 그래서 `systemctl is-active` 가 `active` 라는 것은 DNAT 이 실렸다는 뜻이지 구멍이 뚫렸다는 뜻이 아니고, 그 상태는 호스트 안에서는 되는데 밖에서만 안 되는 형태로 아래 확인 ③ 에서야 드러난다. 이 실험대가 그 상태를 일부러 만들어 확인해 보지는 않았다.
손으로 한 번 넣어 볼 때와 날아갔을 때 다시 넣을 때는 이렇다.
```bash label="[lab host] 손으로 구멍을 한 번 넣어 본다"
sudo nft insert rule ip libvirt_network guest_input oif virbr0 ip daddr 192.168.122.10 tcp dport '{80,443}' ct state new counter accept
```
```bash label="[lab host] 새 규칙이 reject 위에 있는지 본다"
sudo nft -a list chain ip libvirt_network guest_input
```
```bash label="[lab host] 날아갔으면 유닛을 다시 돌려 넣는다"
sudo systemctl restart lab-edge-dnat.service
```
```bash label="[lab host] 우리 테이블이 들어갔는지 본다"
sudo nft list table ip lab_edge
```
마지막 명령에서는 세 가지를 본다. `dnat to 192.168.122.10` 한 줄이 있는가, 포트가 `80, 443` 인가, 그리고 `masquerade` 나 `snat` 이 없는가.
:::warning
SNAT 을 걸지 않는다. 게스트의 기본 게이트웨이가 호스트라 응답은 어차피 여기로 돌아오고 conntrack 이 되돌린다. masquerade 를 붙이면 출발지가 덮여서 엣지가 모든 클라이언트를 `192.168.122.1` 로 보게 되고, 이 실험대가 재고 있는 `X-Forwarded-For` 계약이 통째로 무의미해진다.
:::
**문제가 생기면** — `Unit lab-edge-dnat.service does not exist` 가 되풀이되면 두 곳을 본다. 경로를 `/etc/systemd/` 까지만 쳤는지, 그리고 `daemon-reload` 를 했는지. `nano` 는 없는 파일이면 말없이 새로 만들고, systemd 는 한 단계 위를 유닛 디렉터리로 읽지 않는다. 중괄호에 따옴표를 빼면 셸이 `80 443` 두 낱말로 펼쳐 `Error: syntax error, unexpected ct` 가 나는데, 규칙은 안 들어갔고 에러만 보고 넘기기 쉽다.
## 구성 값
| 무엇 | 값 |
|---|---|
| nginx (엣지) | Debian 12 의 `nginx/1.22.1` |
| upstream | `192.168.122.11:80` · `192.168.122.12:80` — 기본 라운드로빈 |
| DNAT | `iifname "tailscale0" tcp dport { 80, 443 } dnat to 192.168.122.10` |
| libvirt 구멍 | 유닛의 `ExecStartPost` 가 `guest_input` 맨 앞에 `insert` |
PREROUTING nat 은 라우팅 결정보다 먼저 도므로 이 규칙이 호스트 자신의 443 소켓보다 우선한다. 그래서 물리 호스트에 nginx 가 아직 떠 있어도 트래픽은 엣지로 가고, 전환이 원자적이며 되돌리기도 한 줄이 된다.
## 끝났는지 판정한다
한 번에 밖에서 치지 말고 가까운 층부터 본다. 네 명령이 각각 다른 층을 건너뛰므로 어디서 끊겼는지가 바로 나온다.
| # | 무엇을 건너뛰나 | 실측 |
|---|---|---|
| ① `192.168.122.11` | nginx 를 건너뛴다 | `404` |
| ② `192.168.122.10` | DNAT 을 건너뛴다 | `301` |
| ③ 도메인 | 밖에서 | `301 https://auth.hyeonworks.com/` |
| ④ 도메인 · TLS 이후 | — | `200` |
처음 볼 때는 응답을 눈으로 읽는 형태로 치고, 같은 것을 여러 번 재거나 두 노드를 나란히 비교할 때만 값만 뽑는 형태로 바꾼다.
### 확인 ① Traefik 이 듣고 있나
**무엇을 확인하는가** — 게스트의 80 을 누가 듣고 있는지. nginx 를 건너뛴다.
```bash label="[lab host] ① nginx 를 건너뛰고 Traefik 에 직접"
curl -I http://192.168.122.11
```
**어디를 봐야 하는가** — 첫 줄의 상태 줄 하나, 그리고 그 앞에 아무 오류도 없이 헤더가 나왔다는 사실. `curl: (7) Failed to connect` 이면 응답 자체가 없는 것이라 상태 코드를 볼 일도 없다.
**이 결과가 의미하는 것** — `404` 가 성공 신호다. 게스트의 80 을 Traefik 이 듣고 있고, 요청을 받아 「매칭되는 Ingress 규칙이 없다」고 답했다. `502` 면 Traefik 은 떴는데 그 뒤 백엔드가 없는 것이고, 연결 거부나 타임아웃이면 앞 단계로 돌아간다.
두 노드가 같은지 볼 때는 값만 뽑는 형태가 낫다.
```bash label="[lab host] 두 노드를 나란히 비교한다"
curl -s -o /dev/null -w '%{http_code}\n' http://192.168.122.11
curl -s -o /dev/null -w '%{http_code}\n' http://192.168.122.12
```
`.11` 에서 잰 값이 `404` 였다(observed). 둘이 같으면 nginx 가 어느 쪽으로 보내도 같은 결과가 나온다. 한쪽만 다르면 upstream 둘 중 하나가 죽은 것이고, 그 상태에서는 요청의 절반만 실패해서 「가끔 안 된다」로 보인다.
### 확인 ② 엣지 nginx 가 직접 응답하나
**무엇을 확인하는가** — 엣지 안의 설정과 기동이 맞는지. DNAT 을 건너뛴다.
```bash label="[lab host] ② DNAT 을 건너뛰고 엣지 nginx 에 직접"
curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' http://192.168.122.10
```
**어디를 봐야 하는가** — `301` 과 `Location` 이 나오는가.
**이 결과가 의미하는 것** — 여기서 막히면 문제는 엣지 안이다. 통과하는데 아래 ③ 이 안 되면 문제는 DNAT 이다. 이 한 층을 끼워 두면 그 둘을 헷갈리지 않는다.
### 확인 ③ 밖에서, 즉 DNAT 을 거쳐 닿나
**무엇을 확인하는가** — DNS 와 DNAT 과 80 리스너가 전부 살아 있는지.
```bash label="[워크스테이션] ③ 밖에서 도메인으로"
curl -I http://auth.hyeonworks.com
```
**어디를 봐야 하는가** — 상태 줄과 `Location:` 헤더 한 줄. `Location` 이 `https://` 로 시작하고 원래 호스트명을 그대로 들고 있는가. 설정에 `$host` 대신 이름을 박아 두면 여기서 엉뚱한 호스트가 나온다.
**이 결과가 의미하는 것** — `301` 이 나왔다는 것은 바깥 요청이 DNAT 을 거쳐 엣지 nginx 까지 닿았다는 뜻이다. ① 은 Traefik 에 직접, ② 는 엣지에 직접 친 것이라 DNAT 을 안 거쳤다. 응답이 아예 없으면 nginx 가 안 떴거나 80 이 막혔다. 값을 반복해서 잴 때의 형태와 이 실험대의 실측은 이렇다(observed).
```bash label="[워크스테이션] 반복해서 잴 때의 형태"
curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' http://auth.hyeonworks.com
```
```text
301 https://auth.hyeonworks.com/
```
### 확인 ④ 끝까지 닿나
**무엇을 확인하는가** — nginx 에서 Traefik 을 지나 파드까지 2홉이 다 이어졌는지.
```bash label="[워크스테이션] ④ TLS 를 얹은 다음 단계에서 통과한다"
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
```
```text
200
```
**어디를 봐야 하는가** — 코드 한 칸. 여기서만 값을 뽑는 형태를 바로 쓰는 까닭은, 이 `200` 이 다음 두 단계에서 매번 같은 명령으로 다시 잴 기준값이기 때문이다.
**이 결과가 의미하는 것** — `502` 는 nginx 는 살아 있는데 upstream 을 못 잡은 것이고, `curl: (60)` 같은 인증서 오류는 아직 다음 단계를 안 했다는 뜻이다. 여기까지는 TLS 가 없으므로 ④ 는 다음 단계에서 통과한다. 처음 보는 오류라 헤더가 필요하면 읽는 형태로 바꾼다.
```bash label="[워크스테이션] 처음 보는 오류는 헤더까지 읽는다"
curl -I https://auth.hyeonworks.com/realms/master
```
### 확인 ⑤ 로그에 무엇이 남았나
**무엇을 확인하는가** — nginx 가 어느 upstream 에서 무엇으로 실패했는지.
```bash label="[kc-lab-edge] 최근 에러만 · 지금 벌어지는 것"
journalctl -u nginx -p err -n 5 # 최근 에러만
journalctl -u nginx -f # 지금 벌어지는 것
```
**어디를 봐야 하는가** — 각 줄의 괄호 안 errno 와 그 뒤의 upstream 주소, 그리고 타임스탬프. 방금 친 요청 시각과 안 맞으면 지금 보고 있는 것은 옛 사고다.
**이 결과가 의미하는 것** — `-p err` 로 걸러도 아무것도 안 나오면 nginx 는 정상이고 문제는 더 위에 있다. 둘째 명령은 띄워 놓은 채로 다른 창에서 요청을 치는 용도이고, 끝내려면 Ctrl+C 를 누른다. upstream 이 둘이라 한쪽이 죽으면 nginx 가 자동으로 빼는데, 그 동작이 세 줄로 남는다(observed).
```text
connect() failed (113: No route to host) ← 호스트에 못 닿는다
connect() failed (111: Connection refused) ← 포트에 아무도 없다
no live upstreams ← 둘 다 죽었다고 판단
```
113 과 111 은 대응이 다르다 — 113 은 네트워크고 111 은 프로세스다. 노드를 잃었을 때 이 세 줄이 1분 안에 순서대로 나왔다(observed).
:::note
nginx 에러 로그는 2048바이트에서 잘린다. 긴 URL 이 단어 중간에서 끊겨 보이면 이 제한에 걸린 것이고, 되찾으려면 access 로그를 본다. 이 실험대에서 502 원인이 error 로그에 있었는데 잘려 있었고 access 로그에는 3492자로 온전히 남아 있었다.
:::
```bash label="[kc-lab-edge] 잘리지 않은 한 줄을 access 로그에서 본다"
grep oauth2/callback /var/log/nginx/access.log | tail -1
```
```bash label="[kc-lab-edge] 그 줄의 길이를 센다"
grep oauth2/callback /var/log/nginx/access.log | tail -1 | wc -c
```
## 통과 조건을 한 번에 다시 본다
| 무엇 | 명령 | 통과 |
|---|---|---|
| nginx 문법 | `sudo nginx -t` | `syntax is ok` · `test is successful` |
| 링크 | `ls -l /etc/nginx/sites-enabled/` | `keycloak-lab` 이 있고 `default` 가 없다 |
| 유닛 | `systemctl is-active lab-edge-dnat.service` | `active` |
| 우리 테이블 | `sudo nft list table ip lab_edge` | `dnat to 192.168.122.10` · 포트 `80, 443` · SNAT 없음 |
| 층 ① | `curl -I http://192.168.122.11` | `404` |
| 층 ② | `curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' http://192.168.122.10` | `301` |
| 층 ③ | `curl -I http://auth.hyeonworks.com` | `301` · `Location` 이 원래 호스트명 |
층 ④ 는 다음 단계가 인증서를 얹은 뒤에 통과한다.
## 막히면
| 증상 | 어디서 끊겼나 | 확인 |
|---|---|---|
| ① 이 연결 거부 | Traefik 이 안 떴거나 게스트가 죽음 | `kubectl get pods -n kube-system` |
| ① 이 `502` | Traefik 은 떴는데 백엔드가 없음 | Ingress 확인 |
| ② 가 응답 없음 | nginx 가 안 떴거나 방화벽 | `systemctl status nginx` |
| ③ 이 `502` | 인증서 문제 또는 upstream 다운 | 다음 단계 · 위 확인 ⑤ |
| `/etc/nginx: No such file or directory` | nginx 미설치. cloud-init 은 안 깐다 | `which nginx` |
| `nginx -t` 가 `duplicate default server` | `sites-enabled/default` 가 살아 있다 | `ls -l /etc/nginx/sites-enabled/` |
| 설정을 썼는데 아무 변화가 없다 | 경로 오타 — `site-available`(단수)에 썼다 | `ls /etc/nginx/sites-available/` |
| `unknown directive "http2"` | nginx < 1.25.1. Debian 12 는 1.22 다 | `nginx -v` |
| `systemctl reload` 가 실패한다 | 설정이 `nginx -t` 를 못 통과했다 | reload 말고 `sudo nginx -t` 를 먼저 |
| `cp: cannot stat 'deploy/...'` | 엣지에서 쳤거나 lab host 에 저장소가 없다 | `ls ~/workspace` |
| `Unit lab-edge-dnat.service does not exist` | 유닛이 없거나 `/etc/systemd/` 에 썼거나 `daemon-reload` 를 안 했다 | `find /etc/systemd -name 'lab-edge-dnat*'` |
| 80 은 되는데 HTTPS 만 안 된다 | `.nft` 의 포트가 `433` 오타 | `grep dport /etc/nftables.d/lab-edge-dnat.nft` |
| 호스트 안에서는 404 인데 밖에서만 connection refused | libvirt `guest_input` 의 `reject`. 구멍이 빠졌거나 날아갔다 | `sudo nft -a list chain ip libvirt_network guest_input` — `reject` 줄의 카운터가 올라가면 여기다 |
| `Error: syntax error, unexpected ct` | 셸이 `{80,443}` 을 펼쳤다 | `'{80,443}'` 로 따옴표 |
## 무엇이 관측이고 무엇이 아닌가
- (observed) 2026-09-11 새로 만든 엣지에서 `/etc/nginx` 가 없던 것, `nginx -t` 출력 세 줄, 층별 확인 ①~④ 의 코드, `301 https://auth.hyeonworks.com/`, upstream 실패 errno 세 줄, access 로그 3492자.
- (observed) `.nft` 와 유닛 파일의 내용은 저장소 원본과 같다.
- (inferred) 이 이동으로 L7 홉 수가 2홉 그대로라는 판정이 이 배치의 전제다. 늘어난 것은 커널이 하는 L4 전달 한 번뿐이라 `X-Forwarded-*` 계약이 그대로 성립한다.
- (unknown) `curl -I http://192.168.122.11` 의 전체 출력은 캡처해 두지 않았다. 가이드도 봐야 할 줄만 적었다.
- (unknown) `systemctl status nginx` 두 번과 `ls -l /etc/nginx/sites-enabled/`, 파일 찾기, 체인 조회, `journalctl -u nginx`, access 로그 두 줄은 가이드가 적어 둔 명령이고 출력이 남아 있지 않다.
- (unknown) libvirt 의 `firewall_backend` 가 iptables 일 때도 `guest_input` 구멍이 필요한지는 재지 않았다. 이 호스트는 nftables 백엔드다.
- (unknown) 되돌리기 네 줄은 가이드 원문 그대로이고, 그 네 줄을 실제로 쳐서 걷어내 본 기록이 없다.
- (unknown) 세우는 명령은 구축할 때 친 것을 옮긴 것이라 다시 쳐서 같은 상태가 되는지는 확인되지 않았다.
<!-- body:end -->
@@ -0,0 +1,499 @@
---
id: 5e629c2f-e653-4dd9-b1c9-1fe6b2bb181d
kind: SETUP
slug: install-k3s-server-and-agent
title: k3s server 와 agent 를 게스트 두 대에 깔고 lab host 에서 kubectl 로 본다
topic: lab-environment-build
topicName: 실험대 환경 구성
project: virtualization
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/5e629c2f-e653-4dd9-b1c9-1fe6b2bb181d/edit"
pinnedVersions:
- name: k3s
version: v1.36.4+k3s1
- name: Debian GNU/Linux
version: 12 (bookworm)
source:
- final/document.md#188-단계-02-k3s-server-와-agent
- final/document.md#185-가이드-묶음이-스스로-정한-규약
- final/document.md#184-이-부의-출처와-범위
sourceRevision: 9465582b5d1630eb4ae7c4e078021486919bf6b6
---
# k3s server 와 agent 를 게스트 두 대에 깔고 lab host 에서 kubectl 로 본다
`kc-lab-1` 에 k3s server 를 `kc-lab-2` 에 agent 를 깔고 lab host 에서 `kubectl get nodes` 로 두 노드를 보는 절차다. 설치 명령은 각각 한 줄이고, kubeconfig 를 가져오는 것과 토큰을 옮기는 것이 그 앞뒤를 채운다.
## 관계
- **cloud-init 시드로 게스트 세 대를 만들고 SSH 가 키로 붙게 한다**
이 절차가 전제하는 앞 단계이고, 거기서 고정한 192.168.122.11 과 192.168.122.12 를 그대로 쓴다.
- **빈 토큰이 조용히 흘러갔다 — 설치 출력은 성공이었고 agent 만 5초마다 다시 죽었다**
토큰 길이를 재는 확인이 막으려는 실패를 그 기록이 처음부터 끝까지 따라간다.
- **엣지 게스트에 nginx 를 세우고 호스트 DNAT 으로 밖에서 들어오는 길을 연다**
다음 단계이고, 여기서 확인한 INTERNAL-IP 두 개가 거기서 nginx upstream 이 된다.
- **가까운 층부터 한 층씩 건너뛰며 확인한다 — 층마다 성공 신호가 다르다**
agent 에서 kubectl 이 거절되는 것을 어느 층의 신호로 읽어야 하는지 그 기준이 정한다.
- **단계별 구축 문서는 작성 시점이 아니라 실행 순서로 검증한다**
설치 명령 두 줄이 왜 재실행으로 검증되지 않은 채 남았는지를 그 기준이 가른다.
- **가이드대로 다시 쳐서 이 실험대가 같은 상태로 서는가**
이 절차를 다시 쳐서 같은 클러스터가 서는지는 아직 재지 않았다.
## 본문
<!-- body:start -->
## 읽기 전에 — 어디서 치는가
기본은 `[lab host]` 다. 게스트에 로그인해서 치지 않는다.
까닭이 셋이다. 게스트에는 lab host 의 개인키도 `~/.ssh/config` 도 없어서 게스트 안에서 `ssh kc-lab-1` 을 치면 `Host key verification failed.` 로 끝난다. 그 실패를 셸 변수 대입으로 감싸면 오류는 화면으로 새고 변수에는 빈 문자열이 담기는데, 셸은 아무 불평도 하지 않는다. 그래서 토큰이 비고, agent 설치가 `--token is required` 로 죽는데도 설치 스크립트는 그 전까지를 다 성공으로 찍고 끝난다.
셸을 하나만 쓰면 이 문제가 통째로 없어진다. 예외가 둘이다.
| 번호 | 무엇 | 어디서 |
|---|---|---|
| 1 | server 설치와 그 확인 | `[lab host]` — 확인 한 번만 게스트 쪽 `kubectl` 로 돈다 |
| 2 · 3 | kubeconfig 와 토큰 | `[lab host]` |
| 4 | agent 설치 | 토큰 파일을 옮긴 뒤 `[kc-lab-2]` 안에서 |
| 5 | 워크스테이션에서 쓰기 | `[워크스테이션]` |
편집기를 여는 곳은 한 군데다. 2번에서 kubeconfig 의 `server:` 줄 하나를 고친다.
## 이 단계가 세우는 것
가이드 02 의 「이 단계가 끝나면」은 두 줄이다.
> lab host 에서 `kubectl get nodes` 를 치면 두 노드가 `Ready` 로 나온다.
> `sudo` 도 `ssh` 도 붙이지 않는다.
| 무엇 | `kc-lab-1` | `kc-lab-2` |
|---|---|---|
| 역할 | server (control-plane) | agent |
| 유닛 | `k3s.service` | `k3s-agent.service` |
| `--node-ip` | `192.168.122.11` | `192.168.122.12` |
| 판 번호 | `v1.36.4+k3s1` | `v1.36.4+k3s1` |
| kubeconfig | `/etc/rancher/k3s/k3s.yaml` | 없다 |
| ROLES 열 | `control-plane` | `<none>` — 라벨이 없다는 뜻이다 |
k3s 가 따로 설치하지 않아도 딸려 오는 것이 다섯이다. 뒤 단계에서 「왜 80 포트가 이미 잡혀 있지」와 「왜 PVC 가 이 노드에만 묶이지」의 답이 전부 이 목록에 있다.
| 이름 | 무엇 |
|---|---|
| Traefik | 인그레스 컨트롤러. `:80` 을 듣는다 |
| servicelb (klipper-lb) | LoadBalancer 타입을 호스트 포트로 매핑 |
| local-path | 기본 StorageClass. 노드 로컬 디스크 |
| flannel | 파드 네트워크 (VXLAN) |
| kube-router | NetworkPolicy 집행 |
## 전제와 되돌리기
전제는 한 줄이다 — 앞 단계가 끝나 lab host 에서 두 게스트에 SSH 가 붙는다.
**되돌리는 절차는 원본 가이드 02 에 없다**(unknown). k3s 설치 스크립트는 `k3s-uninstall.sh``k3s-agent-uninstall.sh` 를 함께 깔지만 가이드가 그 이름을 한 번도 적지 않았고 이 실험대도 부른 적이 없다. 가이드가 재설치를 말하는 곳은 두 군데인데 둘 다 되돌리는 명령이 아니라 뒤처리를 적어 두었다.
| 어디서 | 무엇을 적었나 |
|---|---|
| 노드 IP 가 다른 대역으로 잡혔을 때 | 그때 고치는 것보다 지금 재설치가 싸다 |
| CA 가 바뀌었을 때 | 2번의 kubeconfig 복사를 다시 한다 |
CA(Certificate Authority)는 인증서에 서명해 주는 쪽을 말한다. k3s 를 다시 깔면 그것이 바뀌므로 lab host 의 kubeconfig 도 같이 못 쓰게 된다. 걷어내는 명령은 여기에 적지 않는다.
## 세우기 전에 먼저 본다
**가이드 02 에도 바꾸기 전 상태를 보는 단계가 없다.** 앞 단계가 남긴 확인을 그대로 다시 치는 것이 이 단계의 전제다(inferred).
**무엇을 확인하는가** — 두 게스트가 돌고 있는지, 그리고 SSH 가 대화 없이 통과하는지.
```bash label="[lab host] 도메인 상태와 게스트 접속을 함께 본다"
virsh list --all
ssh donghyeon@192.168.122.11 'hostname; cat /etc/os-release | head -1'
```
**어디를 봐야 하는가** — 두 게스트가 `running` 인가, 그리고 SSH 가 비밀번호를 묻지 않고 호스트명을 찍는가.
**이 결과가 의미하는 것** — `ssh kc-lab-2` 가 비밀번호를 물으면 3번의 토큰 전달과 4번의 설치가 전부 대화식으로 멈춘다. 여기서 걸러야 설치 도중에 멈추지 않는다.
## 실행 절차
### 1. server 를 깐다
**목적** — `kc-lab-1` 을 control-plane 으로 세우고, 그 노드가 자기 주소를 `192.168.122.11` 로 알게 한다.
```bash label="[lab host] ① server 설치 스크립트를 원격으로 돌린다"
ssh kc-lab-1 'curl -sfL https://get.k3s.io | sudo sh -s - server --node-ip 192.168.122.11'
```
```bash label="[lab host] ② 유닛이 떴고 자기 자신을 노드로 등록했는지 본다"
ssh kc-lab-1 'sudo systemctl is-active k3s; sudo kubectl get nodes'
```
**예상 결과** — 유닛이 `active` 이고 `get nodes` 에 `kc-lab-1` 한 줄이 `Ready` 로 있다. 설치 직후 30초 남짓은 `NotReady` 이거나 아예 목록이 비어 있는 쪽이 정상이다 — 파드 네트워크가 아직 안 올라온 시간이라 한 번 더 친다.
**왜 필요한가** — `--node-ip` 를 준다. 게스트에 인터페이스가 여럿이면 k3s 가 엉뚱한 것을 고를 수 있고, 그러면 두 노드가 서로를 다른 주소로 알게 된다. 이때 Traefik 과 servicelb, local-path, flannel, kube-router 도 함께 선다. 이 가이드에서 `kubectl` 을 게스트 쪽에서 돌리는 일은 ② 한 번으로 끝난다. lab host 에는 아직 kubeconfig 가 없기 때문이고, 그것을 두는 일이 바로 2번이다.
**문제가 생기면** — 설치 출력만으로 판정하지 않는다. 유닛이 `active` 인데 `get nodes` 가 접속 오류를 내면 API 서버가 아직 기동 중이다. 유닛이 `activating` 에서 안 넘어가거나 `failed` 면 설치 자체가 실패한 것이니 로그를 본다.
```bash label="[lab host] server 설치가 실패했을 때 로그를 본다"
ssh kc-lab-1 'sudo journalctl -u k3s -n 50 --no-pager'
```
### 2. kubeconfig 를 lab host 로 가져온다
**목적** — lab host 에서 `sudo` 도 `ssh` 도 붙이지 않고 `kubectl` 을 치게 만든다. agent 노드에는 kubeconfig 가 없으므로 클러스터를 어디서 볼지 먼저 정해 둔다.
```bash label="[lab host] ① 받을 디렉터리를 만든다"
mkdir -p ~/.kube
```
```bash label="[lab host] ② 게스트의 kubeconfig 를 그대로 받는다"
ssh kc-lab-1 'sudo cat /etc/rancher/k3s/k3s.yaml' > ~/.kube/config
```
```bash label="[lab host] ③ 권한을 좁힌다"
chmod 600 ~/.kube/config
```
이 파일은 클러스터 admin 자격증명이라 `600` 으로 둔다. `sudo` 는 `cat` 에만 걸리고 `>` 에는 걸리지 않는다 — 리다이렉션은 셸이 명령보다 먼저 현재 사용자 권한으로 처리한다. 그래서 출력 파일은 홈 아래에 둔다.
```bash label="[lab host] ④ server 주소 한 줄을 고친다"
nano ~/.kube/config
```
`server:` 줄 하나만 고치고 나머지는 그대로 둔다.
```yaml label="고칠 줄"
server: https://192.168.122.11:6443
```
```bash label="[lab host] ⑤ 주소가 바뀌었는지 읽고 밖에서 붙어 본다"
grep server: ~/.kube/config
kubectl get nodes
```
**예상 결과**(observed)
```text
server: https://192.168.122.11:6443
NAME STATUS ROLES AGE VERSION
kc-lab-1 Ready control-plane 47m v1.36.4+k3s1
```
아직 노드가 한 줄뿐인 쪽이 정상이다. agent 는 4번에서 붙인다.
**왜 필요한가** — 세 줄이 다 필요하고 빠뜨렸을 때 깨지는 곳이 다르다.
| 줄 | 빠뜨리면 |
|---|---|
| `mkdir -p ~/.kube` | `>` 는 파일을 열 뿐 경로를 만들지 않는다. `cat` 이 시작되기도 전에 `No such file or directory` 로 끝난다 |
| 주소 고치기 | k3s 가 쓴 `https://127.0.0.1:6443` 은 게스트 안에서만 맞는 주소라 lab host 에서는 자기 자신의 6443 을 두드린다 |
| `chmod 600` | 이 파일은 클러스터 admin 자격증명이다. 비밀번호 파일과 같은 급으로 다룬다 |
주소를 고쳐도 인증서 검증이 통과하는 것은 API 서버 인증서 SAN 에 두 주소가 다 들어 있기 때문이다. 5번의 SSH 터널이 `127.0.0.1` 로 붙을 수 있는 것도 같은 까닭이다.
```bash label="[lab host] SAN 에 두 주소가 들어 있는지 확인한다"
ssh kc-lab-1 'sudo openssl x509 -in /var/lib/rancher/k3s/server/tls/serving-kube-apiserver.crt -noout -ext subjectAltName'
```
이 실험대는 ②와 ④를 치환 한 줄로 이어 붙였다(observed).
```bash label="[lab host] 이 실험대가 실제로 친 형태"
ssh kc-lab-1 'sudo cat /etc/rancher/k3s/k3s.yaml' \
| sed 's|127.0.0.1|192.168.122.11|' > ~/.kube/config
```
치환은 바꾼 줄도 나머지 줄도 보여 주지 않는다. kubeconfig 는 클러스터를 볼 때마다 다시 열게 되는 파일이라 한 번은 전체를 보는 편이 낫고, 나눈 형태는 이 실험대에서 치지 않았다(unknown).
**문제가 생기면** — lab host 에서 `connection refused` 가 나면 ⑤ 의 `grep` 부터 친다. `127.0.0.1` 이 그대로 보이면 ④ 에서 저장이 안 됐다. `x509` 오류는 파일 안의 CA 와 서버가 지금 쓰는 CA 가 어긋난 것이라, k3s 를 다시 깔았다면 이 복사도 다시 한다.
### 3. 토큰을 파일로 꺼내 길이만 본다
**목적** — agent 가 클러스터에 들어갈 때 쓸 node-token 을 lab host 에 내려놓고, 값이 아니라 길이로 비어 있지 않은지 확인한다.
```bash label="[lab host] ① 토큰을 파일로 내려놓는다"
ssh kc-lab-1 'sudo cat /var/lib/rancher/k3s/server/node-token' > node-token
```
```bash label="[lab host] ② 값이 아니라 길이만 본다"
wc -c node-token
```
**예상 결과** — 세 자리 수와 파일 이름 한 줄. 이 실험대가 변수에 담아 잰 토큰은 108자였다(observed). `K10<해시>::server:<비밀번호>` 형식이라 k3s 판올림에 따라 자릿수가 달라진다. 확인할 것은 값이 아니라 `0` 이 아니라는 사실이다.
**왜 필요한가** — 값을 화면에 찍지 않는 것은 터미널 스크롤백과 셸 히스토리에 그대로 남기 때문이다. 그리고 이 한 줄이 4번의 조용한 실패를 여기서 끊는다. 게스트 안에서 토큰을 꺼내려 하면 `Host key verification failed.` 로 끝나는데, 셸은 그것을 오류로 알려 주지 않고 빈 값을 넘긴다.
**문제가 생기면** — `wc -c` 가 `0` 을 내면 원인이 셋 가운데 하나다.
| `0` 인 까닭 | 확인 |
|---|---|
| 게스트 안에서 쳤다 — 가장 흔하다 | 프롬프트가 `kc-lab-1` 이면 `exit` 로 lab host 로 나온다 |
| server 가 아직 안 떠서 파일이 없다 | 아래 한 줄로 파일 유무부터 본다 |
| 다른 창에서 쳤다 | 같은 셸에서 ① 부터 다시 친다 |
```bash label="[lab host] 토큰 파일이 있기는 한지 본다"
ssh kc-lab-1 'sudo ls -l /var/lib/rancher/k3s/server/node-token'
```
### 4. 토큰을 agent 노드로 옮기고 거기서 설치한다
**목적** — `kc-lab-2` 를 agent 로 붙이고, 토큰이 명령줄과 히스토리에 남지 않게 파일로 넘긴다.
```bash label="[lab host] ① 토큰 파일을 agent 노드로 옮긴다"
scp node-token kc-lab-2:~/node-token
```
```bash label="[lab host] ② lab host 쪽 사본을 지운다"
rm node-token
```
```bash label="[lab host] ③ agent 노드에 들어간다"
ssh kc-lab-2
```
```bash label="[kc-lab-2] ④ 권한을 좁힌다"
chmod 600 ~/node-token
```
```bash label="[kc-lab-2] ⑤ agent 를 깐다"
curl -sfL https://get.k3s.io | sudo sh -s - agent \
--server https://192.168.122.11:6443 \
--token-file ~/node-token \
--node-ip 192.168.122.12
```
```bash label="[kc-lab-2] ⑥ 설치가 끝나면 토큰 파일을 지운다"
rm ~/node-token
```
```bash label="[kc-lab-2] ⑦ lab host 로 나온다"
exit
```
**예상 결과** — 설치가 끝나면 `k3s-agent.service` 가 그 노드에 서고, lab host 의 `kubectl get nodes` 에 `kc-lab-2` 가 한 줄 더 붙는다.
**왜 필요한가** — 설치 스크립트는 `sudo` 아래 root 로 도니 홈의 `600` 파일도 읽는다. `--token` 대신 `--token-file` 을 쓰면 토큰이 명령줄에 안 들어가므로 프로세스 목록과 셸 히스토리에 남지 않고, 토큰을 꺼낸 셸과 같은 셸에서 쳐야 한다는 제약도 없어진다.
이 실험대는 두 줄로 했다(observed).
```bash label="[lab host] 이 실험대가 실제로 친 형태"
ssh kc-lab-1 'sudo cat /var/lib/rancher/k3s/server/node-token' \
| ssh kc-lab-2 'sudo tee /tmp/token >/dev/null'
ssh kc-lab-2 "curl -sfL https://get.k3s.io | sudo sh -s - agent \
--server https://192.168.122.11:6443 --token-file /tmp/token \
--node-ip 192.168.122.12; rm -f /tmp/token"
```
두 줄 안에 원격 셸 둘과 `sudo` 둘, 파이프 하나, 설치 스크립트 하나, 마지막 삭제 하나가 겹쳐 있다. 실패했을 때 어느 쪽이 실패했는지 갈리지 않아 위에서는 ① 부터 ⑦ 까지로 나눴다. 토큰이 `/tmp/token` 대신 자기 홈에 놓이고 `sudo tee` 대신 `scp` 와 `chmod 600` 이 그 파일을 만드는 것도 그래서 달라진다. 이 나눈 형태는 이 실험대에서 치지 않았다(unknown).
**문제가 생기면** — agent 설치는 성공했는데 노드가 안 보이면 로그에 `--token is required` 가 있는지 본다. 토큰이 빈 값이었으면 설치 스크립트는 내려받기와 유닛 생성과 활성화까지 다 성공으로 찍고 끝나고, 유닛은 `Restart=always` 라 5초마다 조용히 재시도한다.
```bash label="[lab host] agent 가 왜 못 붙었는지 본다"
ssh kc-lab-2 'sudo journalctl -u k3s-agent -n 30 --no-pager'
```
### 5. 워크스테이션에서도 쓰려면 터널을 뚫는다
**목적** — 개발 머신에서 같은 클러스터를 보게 한다. 이 단계를 건너뛰어도 클러스터는 선다.
```bash label="[워크스테이션] ① 터널을 연다. 이 창은 열어 둔다"
ssh -N -L 6443:192.168.122.11:6443 test-server
```
```bash label="[lab host] ② 게스트 원본을 lab host 로 받는다"
ssh kc-lab-1 'sudo cat /etc/rancher/k3s/k3s.yaml' > kc-lab.yaml
chmod 600 kc-lab.yaml
```
```bash label="[워크스테이션] ③ 다른 창에서 받아 오고 lab host 의 사본은 지운다"
mkdir -p ~/.kube
scp test-server:kc-lab.yaml ~/.kube/kc-lab.yaml
ssh test-server 'rm kc-lab.yaml'
```
```bash label="[워크스테이션] ④ 권한을 좁히고 이 파일을 쓰게 한다"
chmod 600 ~/.kube/kc-lab.yaml
export KUBECONFIG=~/.kube/kc-lab.yaml
```
```bash label="[워크스테이션] ⑤ 주소를 읽고 두 노드가 보이는지 본다"
grep server: ~/.kube/kc-lab.yaml
kubectl get nodes
```
**예상 결과** — `server:` 가 `https://127.0.0.1:6443` 이고, `get nodes` 가 4번과 같은 두 줄을 낸다. 터널 창을 닫으면 바로 멎는다.
**왜 필요한가** — 여기서는 주소를 고치지 않는다. k3s 원본이 이미 `https://127.0.0.1:6443` 이고 터널 덕에 워크스테이션에서는 그 주소가 맞다. 2번에서 고쳤던 것은 lab host 에서 볼 때 `127.0.0.1` 이 lab host 자신을 가리키기 때문이었다. 같은 파일이라도 어느 기계에서 읽느냐에 따라 맞는 주소가 다르다.
이 실험대는 ②와 ③을 `ssh` 두 겹으로 겹쳐 한 줄에 넣었다(observed).
```bash label="[워크스테이션] 이 실험대가 실제로 친 형태"
ssh test-server "ssh kc-lab-1 'sudo cat /etc/rancher/k3s/k3s.yaml'" > ~/.kube/kc-lab.yaml
```
따옴표가 두 겹이라 어느 기계에서 어느 명령이 도는지가 한 줄에 묻힌다. 기계마다 한 명령이 되게 나눈 형태는 이 실험대에서 치지 않았다(unknown).
**문제가 생기면** — 타임아웃이면 ① 의 터널 창이 닫힌 것이고, `connection refused` 면 터널은 살아 있는데 반대편 API 서버가 죽은 것이다. 터널이 닫히면 `kubectl` 이 통째로 멎어 터널 상태와 클러스터 상태가 섞인다. 노드를 죽이고 살리는 실험은 lab host 에서 치는 편이 낫다.
## 구성 값
kubeconfig 사본이 사는 곳은 둘이고, 같은 파일인데 주소가 다르다.
| 어디에 | 무엇 |
|---|---|
| lab host | `~/.kube/config` — `600`. `server:` 를 `https://192.168.122.11:6443` 으로 고친 사본 |
| 워크스테이션 | `~/.kube/kc-lab.yaml` — 원본 그대로(`https://127.0.0.1:6443`) + SSH 터널 |
유닛 이름도 노드마다 다르다.
| 노드 | 유닛 |
|---|---|
| server | `k3s.service` |
| agent | `k3s-agent.service` |
뒤의 실험에서 노드를 멈출 때 칠 이름이 이것이다. `systemctl stop k3s` 를 agent 노드에서 치면 아무 일도 일어나지 않고, 「주입했는데 증상이 없다」로 읽히게 된다. server 노드를 잃으면 `kubectl` 자체가 불통이 되고, agent 를 잃으면 `kubectl` 은 되지만 그 위의 워크로드가 사라진다.
## 끝났는지 판정한다
### 확인 ① 두 노드가 우리가 준 주소로 서 있는가
**무엇을 확인하는가** — agent 가 클러스터에 들어왔는지, 그리고 제 주소로 들어왔는지.
```bash label="[lab host] 마지막 열까지 본다"
kubectl get nodes -o wide
```
**실측**(observed)
```text
NAME STATUS ROLES AGE VERSION INTERNAL-IP
kc-lab-1 Ready control-plane 47m v1.36.4+k3s1 192.168.122.11
kc-lab-2 Ready <none> 21m v1.36.4+k3s1 192.168.122.12
```
**어디를 봐야 하는가** — `-o wide` 를 주는 까닭이 마지막 열이다. INTERNAL-IP 두 개가 1번과 4번에서 `--node-ip` 로 준 값과 같은가. 그다음이 STATUS 두 줄 `Ready`, 그다음이 ROLES 열이다. `<none>` 은 오류가 아니라 역할 라벨이 없다는 뜻이고, agent 는 원래 그렇다.
**이 결과가 의미하는 것** — 두 줄이 `Ready` 이고 IP 가 맞으면 이 단계는 끝났다. IP 가 다른 대역으로 잡혀 있으면 지금은 아무 증상이 없다가 다음 단계의 nginx upstream 과 노드 상실 실험에서 어긋난다. 그때 고치는 것보다 지금 재설치가 싸다. `kc-lab-2` 가 아예 안 보이면 join 이 실패한 것이니 agent 로그를 본다.
### 확인 ② 우리가 준 주소가 유닛에 그렇게 적혔는가
**무엇을 확인하는가** — 설치 스크립트에 준 옵션이 유닛 파일에 굳었는지.
```bash label="[lab host] 유닛 파일의 ExecStart 를 본다"
ssh kc-lab-1 'systemctl cat k3s | grep -A3 ExecStart='
ssh kc-lab-2 'systemctl cat k3s-agent | grep -A4 ExecStart='
```
**실측**(observed)
```text
ExecStart=/usr/local/bin/k3s server '--node-ip' '192.168.122.11'
ExecStart=/usr/local/bin/k3s agent '--node-ip' '192.168.122.12'
```
**어디를 봐야 하는가** — `ExecStart=` 줄의 부분명령(`server` 인가 `agent` 인가)과 그 뒤의 인자.
**이 결과가 의미하는 것** — 확인 ① 은 k3s 가 보고한 주소이고 이 두 줄은 우리가 준 주소다. 둘이 다르면 옵션이 안 먹었다. 유닛 이름을 틀리면 `No files found` 가 나오는데, 그것 자체가 「이 노드는 agent 다」라는 답이 된다.
### 확인 ③ k3s 가 딸려 오게 한 것이 다 떴는가
**무엇을 확인하는가** — 우리가 안 깔았는데 이미 돌고 있는 것과 기본 저장소가 무엇인지.
```bash label="[lab host] 전 네임스페이스의 파드와 StorageClass 를 본다"
kubectl get pods -A
kubectl get storageclass
```
**실측**(observed)
```text
NAMESPACE NAME READY STATUS RESTARTS AGE
kube-system coredns-54996dc9b4-x5bsz 1/1 Running 0 49m
kube-system helm-install-traefik-cnv8z 0/1 Completed 2 (48m ago) 48m
kube-system helm-install-traefik-crd-c6vft 0/1 Completed 0 48m
kube-system local-path-provisioner-77b9867795-5k8d2 1/1 Running 0 49m
kube-system metrics-server-6dc596dfb8-nzwt2 1/1 Running 0 49m
kube-system svclb-traefik-a18ee1fc-2s9rl 2/2 Running 0 48m
kube-system svclb-traefik-a18ee1fc-xmrrt 2/2 Running 0 22m
kube-system traefik-59b7647586-rsrbc 1/1 Running 0 48m
NAME PROVISIONER RECLAIMPOLICY VOLUMEBINDINGMODE ALLOWVOLUMEEXPANSION AGE
local-path (default) rancher.io/local-path Delete WaitForFirstConsumer false 49m
```
**어디를 봐야 하는가** — `kube-system` 줄들의 STATUS 다. `Running` 과 `Completed` 가 섞여 있는 쪽이 정상이고, 일회성 잡은 `Completed` 로 남는다. `svclb-traefik-` 로 시작하는 줄이 둘인 것도 봐 둔다. StorageClass 에서는 이름 뒤의 `(default)` 표시가 어디 붙어 있는가.
**이 결과가 의미하는 것** — `svclb-traefik-` 두 줄은 DaemonSet 이 노드마다 하나씩 뜬 것이라, 4번의 join 이 실제로 먹었다는 또 하나의 증거가 된다. `local-path` 에 `(default)` 가 붙어 있으면 다음 단계의 PVC 는 StorageClass 를 안 적어도 그것으로 만들어진다. `Pending` 이나 `CrashLoopBackOff` 가 섞여 있으면 그 파드부터 `describe` 로 본다.
### 확인 ④ agent 노드에서 kubectl 이 거절되는 것은 정상이다
**무엇을 확인하는가** — agent 노드에서 `kubectl` 을 쳤을 때 나오는 거절이 어느 층의 신호인지.
```text
Get "http://localhost:8080/api?timeout=32s": dial tcp [::1]:8080: connect: connection refused
```
**어디를 봐야 하는가** — 주소 한 칸이다. `localhost:8080` 인가 다른 주소인가.
**이 결과가 의미하는 것** — 명령 자체는 있다. 설치 스크립트가 `/usr/local/bin/kubectl -> k3s` 심볼릭 링크를 만든다. 없는 것은 붙을 곳을 알려 주는 kubeconfig 이고, 넷 다 못 찾으면 kubectl 은 오류를 내지 않고 하드코딩된 기본값으로 넘어간다.
| 어디를 찾나 | kc-lab-1 | kc-lab-2 |
|---|---|---|
| `$KUBECONFIG` | 비어 있음 | 비어 있음 |
| `~/.kube/config` | 없음 | 없음 |
| `/etc/rancher/k3s/k3s.yaml` | 있음 | 없음 |
`http://localhost:8080` 은 쿠버네티스 1.20 이전 API 서버가 평문으로 열던 레거시 포트인데 지금은 아무도 열지 않는다(external). 이 주소는 어디에도 적혀 있지 않으므로, 그것이 보이면 네트워크 문제가 아니라 설정을 하나도 못 찾았다는 뜻이다. 방화벽이나 k3s 를 의심하기 전에 kubeconfig 부터 본다.
:::warning
`k3s.yaml` 을 복사해 넣으면 agent 노드에서도 다 보이지만 복사하지 않는다. 워커 한 대가 털리면 클러스터 전체가 털리는 구성이 된다.
:::
agent 가 원래 가진 신원은 급이 다르다.
```text
subject=O = system:nodes, CN = system:node:kc-lab-2
```
이 신원은 Node authorizer 와 NodeRestriction admission 이 자기 노드에 배정된 객체만 다루도록 제한하고(external), 그 자격증명은 kubelet 전용 경로(`/var/lib/rancher/k3s/agent/`)에 있어 kubectl 이 읽지도 않는다. 그래서 실패가 권한 없음이 아니라 설정 없음으로 나타난다.
## 통과 조건을 한 번에 다시 본다
| 무엇 | 명령 | 통과 |
|---|---|---|
| 두 노드 | `kubectl get nodes -o wide` | 둘 다 `Ready` · INTERNAL-IP 가 `.11` 과 `.12` |
| 어디서 치나 | 위 명령에 `sudo` 도 `ssh` 도 안 붙는다 | lab host 에서 그대로 돈다 |
| server 유닛 | `ssh kc-lab-1 'systemctl cat k3s \| grep -A3 ExecStart='` | `server '--node-ip' '192.168.122.11'` |
| agent 유닛 | `ssh kc-lab-2 'systemctl cat k3s-agent \| grep -A4 ExecStart='` | `agent '--node-ip' '192.168.122.12'` |
| 딸려 온 것 | `kubectl get pods -A` | `svclb-traefik-` 로 시작하는 줄이 둘 |
| 기본 저장소 | `kubectl get storageclass` | `local-path (default)` |
## 막히면
| 증상 | 원인 | 확인 |
|---|---|---|
| `wc -c node-token` 이 `0` | 게스트 안에서 쳤거나, 셸이 바뀌었다 | 프롬프트가 `kc-lab-1` 이 아닌지. 3번 표 |
| agent 설치는 성공했는데 노드가 안 보임 | 토큰이 빈 값으로 넘어갔다 | `journalctl -u k3s-agent` 에 `--token is required` |
| agent 가 `NotReady` | 토큰이나 주소 오타 | `ssh kc-lab-2 'sudo journalctl -u k3s-agent -n 30 --no-pager'` |
| 노드 IP 가 예상과 다름 | `--node-ip` 없이 설치 | `kubectl get nodes -o wide` |
| lab host 에서 `kubectl` 이 `No such file` | `mkdir -p ~/.kube` 를 빠뜨렸다 | 2번 ① |
| lab host 에서 `connection refused` | kubeconfig 의 `127.0.0.1` 을 안 바꿨다 | `grep server: ~/.kube/config` |
| `kc-lab-2` 에서 `localhost:8080` | agent 에는 kubeconfig 가 없다. 정상이다 | 확인 ④ |
| 워크스테이션에서 타임아웃 | 터널이 없다 | 5번 ① |
| `x509` 오류 | k3s 재설치로 CA 가 바뀌었다 | 2번의 복사를 다시 한다 |
| 파드가 한 노드에만 몰림 | 스케줄러 판단 | `topologySpreadConstraints` 로 강제 |
## 무엇이 관측이고 무엇이 아닌가
- (observed) `v1.36.4+k3s1`, 두 노드 `Ready`, INTERNAL-IP 두 개, 토큰 108자, `ExecStart` 두 줄, agent 의 `localhost:8080` 오류와 인증서 subject.
- (observed) `kube-system` 에 뜬 파드 여덟 줄과 `local-path (default)` StorageClass. `svclb-traefik-` 두 줄이 DaemonSet 이 두 노드에 다 떴다는 증거가 된다.
- (external) 쿠버네티스 1.20 이전의 평문 8080, Node authorizer 와 NodeRestriction 의 동작은 이 실험대에서 잰 값이 아니다.
- (unknown) 3번과 4번의 나눈 형태는 이 실험대에서 치지 않았다. 이 실험대가 친 것은 원격 셸 둘을 파이프로 이은 두 줄이고, 나눈 형태로 같은 클러스터가 서는지는 다시 재지 않았다.
- (unknown) 2번의 ②④ 도 이 실험대에서 치지 않았다. 이 실험대가 친 것은 치환 한 줄이고, 받아 놓고 편집기로 고친 kubeconfig 로 같은 클러스터가 보이는지는 다시 재지 않았다.
- (unknown) 5번의 ②③도 이 실험대에서 치지 않았다. 이 실험대가 친 것은 원격 셸을 두 겹으로 겹친 한 줄이다.
- (unknown) 인증서 SAN 조회와 두 `journalctl` 은 가이드가 적어 둔 명령이고 이 실험대가 캡처한 출력이 없다. SAN 에 두 주소가 들어 있다는 것은 주소 치환과 터널이 둘 다 통한다는 사실로 뒷받침된다(inferred).
- (unknown) 원본 가이드에 되돌리는 절차가 없다. `k3s-uninstall.sh` 라는 이름이 가이드에 한 번도 안 나오고 이 실험대도 부른 적이 없다.
- (unknown) 설치 명령 두 줄은 재실행으로 검증되지 않았다. 토큰 108자도 k3s 판올림에 따라 달라진다.
<!-- body:end -->
@@ -0,0 +1,520 @@
---
id: 7b113a04-180a-40ec-9270-033531c22221
kind: SETUP
slug: keycloak-two-nodes-and-postgres-on-k3s
title: Keycloak 2노드와 PostgreSQL 을 k3s 에 올리고 클러스터가 묶였는지 확인한다
topic: lab-environment-build
topicName: 실험대 환경 구성
project: virtualization
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/7b113a04-180a-40ec-9270-033531c22221/edit"
pinnedVersions:
- name: k3s
version: v1.36.4+k3s1
- name: curlimages/curl
version: 8.11.1
sourceRevision: 9465582b5d1630eb4ae7c4e078021486919bf6b6
source:
- final/document.md#191-단계-05-keycloak-2노드와-postgresql
- final/document.md#184-이-부의-출처와-범위
---
# Keycloak 2노드와 PostgreSQL 을 k3s 에 올리고 클러스터가 묶였는지 확인한다
매니페스트 한 장을 `apply` 해 네임스페이스 `keycloak-lab` 에 Keycloak 두 대와 PostgreSQL 하나를 세우는 절차다. 세우는 명령은 두 줄이고, 나머지는 그 둘이 하나의 클러스터로 묶였는지 확인하는 명령이다.
## 관계
- **k3s server 와 agent 를 게스트 두 대에 깔고 lab host 에서 kubectl 로 본다**
여기 적힌 `kubectl` 이 도는 클러스터를 그 단계가 세운다. INTERNAL-IP 가 어긋난 채로 왔으면 여기서 Endpoints 가 한 줄만 나온다.
- **Prometheus 와 Grafana 를 올려 클러스터 안을 밖에서 본다**
클러스터 크기를 지표로 묻는 확인 명령이 그 스택을 전제한다. 아직 없으면 임시 파드를 띄워 같은 값을 받는다.
- **DNS-01 으로 와일드카드 인증서를 받고 갱신이 서빙까지 닿게 한다**
밖에서 도메인으로 `200` 을 받는 마지막 확인이 그 단계가 올린 443 을 지난다.
- **도구가 낸 출력은 대상의 상태가 아니다 — 그 명령이 무엇을 세는지부터 가른다**
`kubectl get all` 이 이름과 달리 전부를 세지 않고, 로그와 테이블과 지표가 각각 다른 시점을 말한다. 그 구분이 이 단계의 판정을 셋으로 가른다.
- **가까운 층부터 한 층씩 건너뛰며 확인한다 — 층마다 성공 신호가 다르다**
밖에서 502 가 나올 때 Ingress 에서 Service 로, Service 에서 Endpoints 로 되짚는 순서가 그 기준에서 나온다.
## 본문
<!-- body:start -->
## 읽기 전에 — 어디서 치는가
이 단계는 앞의 셋보다 단순하다. 전부 `[lab host]` 에서 치고, 저장소 루트에서 친다. 마지막 로그인 확인만 브라우저다.
| 번호 | 무엇 | 어디서 |
|---|---|---|
| 1 | 매니페스트 적용 | `[lab host]``~/workspace/keycloak-pattern` 안에서 |
| 2 | 파드 둘이 설 때까지 기다리기 | `[lab host]` |
| 판정 | 리소스 · 클러스터 · 밖에서 | `[lab host]` |
| 판정 | 관리 콘솔 로그인 | 브라우저 |
`kubectl` 이 lab host 에서 도는 까닭은 앞 단계에 있다. kubeconfig 를 게스트에서 호스트로 가져다 두었으므로 게스트에 들어갈 일이 없다. `deploy/...` 로 시작하는 상대경로는 저장소 루트 기준이라, 다른 디렉터리에서 치면 `error: the path ... does not exist` 로 막힌다.
이 단계에는 손으로 쓰는 파일이 없다. 세우는 것이 전부 매니페스트 한 장에 들어 있고, 그 원문은 저장소의 `deploy/lab/k8s/keycloak-cluster.yaml` 이다.
## 이 단계가 세우는 것
가이드 05 의 「이 단계가 끝나면」은 두 줄이다.
> `https://auth.hyeonworks.com` 에서 관리 콘솔에 로그인되고, 두 Keycloak 이
> 하나의 클러스터로 보인다.
**두 마디가 따로다.** 「로그인된다」는 밖에서 잰 `200` 과 브라우저이고, 「하나의 클러스터로 보인다」는 아래 세 확인이다. 파드가 둘 다 `Running` 인 것과 하나의 클러스터로 묶인 것은 다르다.
| 무엇 | 값 |
|---|---|
| 네임스페이스 | `keycloak-lab` — 매니페스트 첫 문서가 `kind: Namespace``apply` 가 같이 만든다 |
| Keycloak | StatefulSet `keycloak` — 파드 `keycloak-0` · `keycloak-1` |
| PostgreSQL | Deployment `postgres` — ReplicaSet `postgres-7b474b88c8` |
| Service | `keycloak` → Endpoints `10.42.0.67:8080,10.42.1.155:8080` |
| Secret | `keycloak-lab-secrets``KC_BOOTSTRAP_ADMIN_PASSWORD` 19 bytes · `POSTGRES_PASSWORD` 22 bytes |
| PVC | StorageClass `local-path`, 이름 끝에 파드 번호가 붙는다 |
| Ingress | HOSTS 가 `auth.hyeonworks.com` |
| 클러스터링 | JGroups — 디스커버리 테이블 `jgroups_ping`, 메시지 포트 7800 |
| 관리 포트 | `9000``/metrics` 가 거기 있다 |
**판 번호는 여기 없다**(unknown). 가이드 05 는 Keycloak 과 PostgreSQL 의 이미지 태그를 적지 않았고 `keycloak-cluster.yaml` 원문은 반입되지 않았다. 가이드 안에 태그가 찍힌 이미지는 아래 임시 파드의 `curlimages/curl:8.11.1` 하나뿐이다.
## 전제와 되돌리기
전제는 한 줄이다 — 앞 단계까지 끝나 `https` 가 열린다.
**되돌리는 절차는 원본 가이드 05 에 없다**(unknown). `kubectl delete -f` 도, 지우는 순서도 적혀 있지 않다. 이 단계가 클러스터에 남기는 것은 `keycloak-lab` 네임스페이스와 그 안의 전부이고, 그중 PVC 는 성격이 다르다 — StorageClass 가 `local-path` 라 데이터가 파드가 스케줄된 그 노드의 디스크에 놓인다. 네임스페이스를 지울 때 그 디렉터리가 어떻게 되는지는 가이드가 적지 않았고 이 실험대도 확인하지 않았다.
## 세우기 전에 먼저 본다
두 확인은 아무것도 바꾸지 않는다. 하나는 명령이 파일을 찾을 수 있는 곳인지 보고, 하나는 이 이름이 아직 Keycloak 이 아님을 확인한다.
### 확인 ① 지금 이 디렉터리에서 매니페스트가 보이는가
```bash label="[lab host] 저장소 루트로 가서 매니페스트를 본다"
cd ~/workspace/keycloak-pattern
ls deploy/lab/k8s/
```
**어디를 봐야 하는가** — `keycloak-cluster.yaml` 과 `observability.yaml` 이 보이는가.
**이 결과가 의미하는 것** — 보이면 아래 `kubectl apply -f deploy/...` 가 파일을 찾는다. 안 보이면 첫 단계의 저장소 받기로 돌아간다 — 한 번 세웠다 철거한 뒤에는 VM 과 디스크만 지워지고 저장소는 각자 관리라 이 디렉터리가 없는 상태가 흔하다.
### 확인 ② 이 이름이 아직 Keycloak 이 아니다
```bash label="[lab host] 앞 단계에서 잰 값을 그대로 다시 잰다"
curl -s -o /dev/null -w '%{http_code} tls=%{ssl_verify_result}\n' https://auth.hyeonworks.com/
```
**어디를 봐야 하는가** — 앞 단계에서 잰 `404 tls=0` 이 그대로인가. `tls=0` 이 아니면 이 단계를 시작할 때가 아니라 앞 단계로 돌아갈 때다.
**이 결과가 의미하는 것** — 가이드가 그 값 옆에 한 줄을 적어 두었다 — 앞의 `404` 는 이 단계 이후에 `200` 으로 바뀐다. 지금 재 두면 아래의 `200` 이 이 단계가 만든 변화인지가 분명해진다.
## 실행 절차
### 1. 매니페스트 한 장을 적용한다
**목적** — 네임스페이스부터 Ingress 까지 한 파일로 세운다.
```bash label="[lab host] ① 매니페스트가 있는 저장소 루트로 간다"
cd ~/workspace/keycloak-pattern
```
```bash label="[lab host] ② 매니페스트를 적용한다"
kubectl apply -f deploy/lab/k8s/keycloak-cluster.yaml
```
**예상 결과** — 만든 객체가 줄마다 찍힌다. 파드가 서는 것은 그다음이라 이 출력만으로 끝났다고 판정하지 않는다.
**왜 필요한가** — 네임스페이스를 따로 만들지 않는다. 매니페스트 첫 문서가 `kind: Namespace` 라 `apply` 가 같이 만들고, `kubectl create namespace` 를 먼저 치면 두 번째 실행부터 `AlreadyExists` 로 막힌다.
**문제가 생기면** — 적용 자체가 거절되면 클러스터가 서 있는지부터 본다. 앞 단계의 `kubectl get nodes` 가 두 줄을 내는지가 전제다. `error: the path ... does not exist` 면 저장소 루트가 아닌 곳에서 쳤다.
### 2. 두 파드가 다 설 때까지 기다린다
**목적** — Keycloak StatefulSet 의 파드 둘이 Ready 가 될 때까지 다음 명령을 치지 않는다.
```bash label="[lab host] ① 끝날 때까지 멈춰 있는 것이 정상이다"
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=300s
```
**예상 결과**
```text
partitioned roll out complete: 2 new pods have been updated...
```
이 명령은 끝날 때까지 아무것도 안 찍고 멈춰 있다. 그 침묵이 정상이고, 마지막 한 줄에서 `complete` 라는 낱말과 파드 개수 `2` 를 본다. StatefulSet 은 파드를 하나씩 순서대로 띄우므로 중간에 `0/2` 로 한참 멈춰 있는 것도 정상이다.
**왜 필요한가** — 300초를 다 쓰고 타임아웃으로 끝나면 그것도 답이 된다. 「안 떴다」가 확정되고 진단으로 넘어간다. `rollout status` 를 쓰는 까닭은 `get pods` 를 반복해서 치는 것보다 나아서만이 아니라, 언제 끝났는지를 사람이 판정하지 않아도 되기 때문이다. 기다리지 않고 판정으로 가면 아직 안 뜬 것과 못 뜨는 것이 섞인다.
**문제가 생기면** — 타임아웃으로 끝났으면 아래 「막히면」의 순서대로 본다.
## 구성 값
만들어지는 객체는 「이 단계가 세우는 것」의 표에 있고, 여기서는 그 값들이 어디서 왔는지를 적는다.
| 값 | 어디서 정해지나 |
|---|---|
| 네임스페이스 이름 | 매니페스트 첫 문서 |
| 파드 이름의 순번 | StatefulSet 이라 `keycloak-0` · `keycloak-1` |
| 파드 이름의 해시 | Deployment 가 만든 ReplicaSet 이름에서 물려받는다 |
| Secret 의 바이트 수 | 매니페스트가 넣은 값의 길이. 이 문서는 값을 적지 않는다 |
| PVC 가 붙는 노드 | `local-path` 가 파드 스케줄을 기다렸다 그 노드에 만든다 |
| Ingress 의 HOSTS | 앞 단계에서 발급한 인증서의 이름과 같아야 한다 |
Ingress 의 호스트 이름이 인증서의 이름과 다르면, 밖에서는 TLS 는 되는데 `404` 가 나온다.
## 끝났는지 판정한다
`kubectl get pods` 만 보면 놓치는 것이 많다. 위에서 아래로 확인한다.
### 확인 ① 무엇이 만들어졌나
**무엇을 확인하는가** — 이 네임스페이스에 무엇이 서 있는지.
```bash label="[lab host] ① 워크로드와 Service 를 본다"
kubectl -n keycloak-lab get all
```
```bash label="[lab host] ② 앞 명령이 안 세는 넷을 따로 본다"
kubectl -n keycloak-lab get secret,configmap,pvc,ingress
```
**어디를 봐야 하는가** — ① 은 종류별로 묶여 나오는 왼쪽 이름 열을 훑고, 파드 줄에서는 READY 칸의 `1/1` 과 RESTARTS 칸을 본다. RESTARTS 가 0 이 아니면 지금은 `Running` 이어도 한 번 죽었다 살아난 것이다. ② 는 네 종류가 하나씩이라도 있는가, PVC 줄의 STATUS 가 `Bound` 인가, Ingress 줄의 HOSTS 칸이 `auth.hyeonworks.com` 인가.
**이 결과가 의미하는 것** — `get all` 은 이름과 달리 전부가 아니다. Secret 과 ConfigMap 과 PVC 와 Ingress 가 ① 의 출력에 나오지 않으므로, 첫 명령만 보고 「다 만들어졌다」로 판정하면 빠진 것을 모른 채 다음으로 간다. 매니페스트에 있는데 ② 에 없는 종류가 있다면 `apply` 가 부분적으로만 먹었다.
### 확인 ② Deployment 에서 ReplicaSet 을 지나 파드까지 이어졌나
**무엇을 확인하는가** — 사슬 어디까지 갔는지. Deployment 는 파드를 직접 만들지 않고 ReplicaSet 을 만들며 그것이 파드를 만든다.
```bash label="[lab host] 해시로 사슬을 맞춰 본다"
kubectl -n keycloak-lab get rs,pod -l app=postgres
```
**실측**(observed) — 2026-09-11
```text
NAME DESIRED CURRENT READY AGE
replicaset.apps/postgres-7b474b88c8 1 1 1 80m
NAME READY STATUS RESTARTS AGE
pod/postgres-7b474b88c8-ll87p 1/1 Running 0 80m
```
**어디를 봐야 하는가** — ReplicaSet 이름의 해시가 파드 이름 가운데 해시와 같은가, 그리고 `DESIRED` 와 `CURRENT` 와 `READY` 세 숫자가 다 `1` 인가.
**이 결과가 의미하는 것** — 이 출력에 Deployment 줄이 없는 것도 정상이다. 이 매니페스트는 `app: postgres` 라벨을 파드 템플릿에만 달았고, ReplicaSet 과 파드는 템플릿에서 라벨을 물려받지만 Deployment 는 물려받지 않는다. Deployment 를 보려면 라벨 없이 친다.
```bash label="[lab host] Deployment 는 라벨 없이 본다"
kubectl -n keycloak-lab get deploy
```
StatefulSet 쪽은 사슬이 한 마디 짧다. ReplicaSet 을 만들지 않고 파드를 직접 만들어서 `keycloak-0` 처럼 순번 이름이 붙는데, 해시를 끼워 넣을 중간 객체가 없기 때문이다. 이름이 고정이라 같은 이름의 파드가 둘일 수 없고, 그래서 `Terminating` 파드가 안 지워지면 대체 파드도 안 생긴다. 가이드는 그 성질이 어느 실험에서 어떻게 나타나는지까지 적어 두었는데 그 실험 문서는 여기 반입되지 않았고, 이 실험대가 그 상태를 재현해 본 적도 없다.
```bash label="[lab host] Keycloak 쪽에는 ReplicaSet 줄이 없다"
kubectl -n keycloak-lab get sts,rs,pod -l app=keycloak
```
```text
NAME READY STATUS RESTARTS AGE
pod/keycloak-0 1/1 Running 0 19m
pod/keycloak-1 1/1 Running 0 19m
```
배포를 여러 번 한 Deployment 는 ReplicaSet 이 여러 개 쌓인다. 새로 만들고 옛것은 `0` 으로 남기기 때문이고, 그래서 `kubectl rollout undo` 가 가능하다. 파드가 옛 해시를 달고 있으면 새 배포가 아직 안 넘어온 것이고, 그 상태로 실험하면 고친 적 없는 코드를 재게 된다.
| 무엇이 보이나 | 어디를 보나 |
|---|---|
| Deployment 는 있는데 RS 가 없다 | 컨트롤러가 못 돌았다 — RBAC·admission 확인 |
| RS 는 있는데 DESIRED 만 있고 CURRENT 가 0 | 파드를 못 만든다 — 이벤트를 본다 |
| Pod 은 있는데 `0/1` | 컨테이너가 안 뜬다 — 로그와 describe |
### 확인 ③ Secret 이 파드까지 이어졌나
**무엇을 확인하는가** — 값이 있는 것과 파드가 그 값을 받은 것은 다르다. 세 확인 모두 값을 찍지 않고 길이만 본다.
```bash label="[lab host] ① Secret 에 무슨 키가 얼마만큼 들어 있나"
kubectl -n keycloak-lab describe secret keycloak-lab-secrets
```
**실측**(observed) — 아래쪽 `Data` 절만 옮겼다
```text
Data
====
KC_BOOTSTRAP_ADMIN_PASSWORD: 19 bytes
POSTGRES_PASSWORD: 22 bytes
```
```bash label="[lab host] ② 키 하나가 의심스러울 때만 다시 잰다"
kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.POSTGRES_PASSWORD}' | base64 -d | wc -c
```
```bash label="[lab host] ③ 파드 안에 주입됐나 — 여기가 진짜다"
kubectl -n keycloak-lab exec keycloak-0 -- \
sh -c 'echo "길이=${#KC_BOOTSTRAP_ADMIN_PASSWORD}"'
```
```text
길이=19
```
**어디를 봐야 하는가** — ① 은 `Data` 절의 키 이름과 그 옆의 바이트 수 두 칸, ② 와 ③ 은 숫자 하나다. 세 수가 서로 같은가.
**이 결과가 의미하는 것** — `describe` 는 값을 절대 찍지 않고 길이만 보여 주므로 키 목록 확인과 「비어 있지 않은가」 확인이 한 명령으로 끝난다. 세 수가 같으면 Secret 에서 파드 환경변수까지 이어졌다. `길이=0` 이면 Secret 에는 있는데 이 파드가 그것을 안 받은 것이라, `envFrom` 이나 `valueFrom` 을 빠뜨렸거나 파드가 Secret 을 고치기 전에 떠서 옛 값을 들고 있다. 환경변수로 주입한 Secret 은 값을 바꿔도 파드를 다시 만들기 전까지 갱신되지 않는다. 바이트 수가 뜻밖에 크면, 이를테면 20 이어야 할 것이 21 이면 만들면서 개행이 같이 들어간 것이고 증상은 「비밀번호가 틀렸다」로 나온다. 매니페스트가 기대하는 키 이름이 하나라도 다르면 파드는 `CreateContainerConfigError` 로 멈추고, 까닭은 `describe pod` 의 Events 에 키 이름까지 적혀 나온다.
`-o yaml` 로 보지 않는다. base64 는 암호화가 아니라 인코딩이라 화면과 스크롤백과 화면 공유와 터미널 로그에 값이 그대로 찍힌다.
어느 환경변수가 어느 Secret 에서 왔는지도 볼 수 있다.
```bash label="[lab host] Secret 에서 온 변수만 오른쪽에 이름이 붙는다"
kubectl -n keycloak-lab get pod keycloak-0 \
-o jsonpath='{range .spec.containers[0].env[*]}{.name}{"\t"}{.valueFrom.secretKeyRef.name}{"\n"}{end}'
```
```text
KC_DB
KC_DB_URL
KC_DB_USERNAME
KC_DB_PASSWORD keycloak-lab-secrets
```
오른쪽 칸이 채워진 줄만 Secret 을 참조한다. 비밀이어야 할 변수의 오른쪽이 비어 있으면 그 값은 매니페스트에 평문으로 적혀 있다는 뜻이고, 그 파일은 대개 git 에 들어간다.
### 확인 ④ Service 뒤에 파드가 있나
**무엇을 확인하는가** — Service 가 있어도 셀렉터가 안 맞으면 뒤가 비어 있다.
```bash label="[lab host] Endpoints 를 센다"
kubectl -n keycloak-lab describe svc keycloak | grep -i endpoints
```
```text
Endpoints: 10.42.0.67:8080,10.42.1.155:8080
```
**어디를 봐야 하는가** — 쉼표로 갈린 주소가 몇 개인가, 그 IP 들이 파드 IP 와 같은가, 포트 번호가 컨테이너가 실제로 듣는 포트인가.
**이 결과가 의미하는 것** — 두 개면 Service 가 두 파드를 다 잡고 있다. 비어 있으면 Service 는 있는데 뒤가 없는 것이고, 이때 증상이 「연결은 되는데 응답이 없다」라 원인이 Service 에 있다는 것을 알아보기 어렵다. 하나뿐이면 나머지 한 파드가 readiness 를 통과하지 못한 것이라, 그 상태로 이중화 실험을 시작하면 이미 한쪽으로만 가고 있던 트래픽을 이중화 실패로 오독하게 된다.
목록으로 보려면 EndpointSlice 를 쓴다.
```bash label="[lab host] 같은 것을 EndpointSlice 로도 본다"
kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak
```
```text
NAME ADDRESSTYPE PORTS ENDPOINTS AGE
keycloak-xdph6 IPv4 8080 10.42.0.67,10.42.1.155 3d23h
```
`kubectl get endpoints` 는 쓰지 않는다. v1.33 부터 deprecated 이고 실행하면 경고가 나온다(external) — 이 실험대의 k3s 는 `v1.36.4+k3s1` 이라 해당한다. 옛 문서와 블로그에 그 형태가 많다.
준비 상태까지 함께 보려면 이렇게 뽑는다.
```bash label="[lab host] 주소마다 ready 가 true 인지 본다"
kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak \
-o jsonpath='{range .items[*].endpoints[*]}{.addresses[0]}{"\t"}{.conditions.ready}{"\n"}{end}'
```
```text
10.42.0.67 true
10.42.1.155 true
```
`ready` 가 `false` 면 파드는 있는데 readiness 프로브를 통과하지 못한 것이라 Service 가 그 파드로 트래픽을 보내지 않는다. 비어 있으면 셀렉터와 파드 라벨이 안 맞는다.
```bash label="[lab host] 셀렉터와 파드 라벨을 나란히 본다"
kubectl -n keycloak-lab get svc keycloak -o jsonpath='{.spec.selector}'; echo
kubectl -n keycloak-lab get pods --show-labels
```
### 확인 ⑤ PVC 가 실제로 붙었나
**무엇을 확인하는가** — 볼륨이 실제로 잡혔는지.
```bash label="[lab host] PVC 의 상태와 StorageClass 를 본다"
kubectl -n keycloak-lab get pvc
```
**어디를 봐야 하는가** — STATUS 칸이 `Bound` 인가 `Pending` 인가, VOLUME 칸이 비어 있는지, STORAGECLASS 칸이 `local-path` 인가. StatefulSet 이면 PVC 이름 끝에 파드 번호가 붙어 있어 어느 파드 것인지 바로 보인다.
**이 결과가 의미하는 것** — `Bound` 면 볼륨이 붙었다. `Pending` 이면 StorageClass 가 없거나 노드에 자리가 없다. `local-path` 는 파드가 스케줄될 때까지 기다리므로, 파드가 안 뜨면 PVC 도 `Pending` 인 것이 정상이다. 둘이 서로를 기다리는 것처럼 보이지만 파드 쪽 원인을 먼저 본다. 사유는 PVC 의 이벤트에 적혀 있다.
```bash label="[lab host] 이름을 안 주면 전부 나온다"
kubectl -n keycloak-lab describe pvc
```
각 PVC 절의 맨 아래 `Events` 에 `waiting for first consumer` 인지 `no persistent volumes available` 인지가 적혀 있고, 둘은 대응이 다르다 — 앞엣것은 파드를 고치는 문제, 뒤엣것은 저장소를 고치는 문제다.
### 확인 ⑥ 클러스터가 묶였나 — 셋이 서로 다른 것을 말한다
**무엇을 확인하는가** — 두 Keycloak 이 하나의 클러스터로 묶였는지. 셋이 각각 다른 시점을 말한다. 로그는 「그때 그렇게 보였다」이고, 테이블은 「지금 등록되어 있다」이며, 지표는 「지금 그 노드가 그렇게 안다」이다.
```bash label="[lab host] 로그 — 그때 본 클러스터 뷰"
kubectl -n keycloak-lab logs keycloak-0 | grep ISPN000094 | tail -1
```
```text
ISPN000094: Received new cluster view for channel ISPN:
[keycloak-0-10001|1] (2) [keycloak-0-10001, keycloak-1-52537]
```
**어디를 봐야 하는가** — 세 군데다. 괄호 안의 `(2)` 가 멤버 수, 대괄호 안의 이름 목록, 그리고 `|1` 이 뷰 번호다. 뷰 번호는 멤버가 들고 날 때마다 올라간다.
이름 목록의 `keycloak-0-10001` 은 파드 이름 그대로가 아니다. Infinispan 이 파드 이름 뒤에 임의의 접미사를 붙여 클러스터 노드 식별자로 쓰기 때문에, 아래 지표에 나오는 `keycloak-0-46674` 와는 `keycloak-0` 까지만 같다. 그래서 대조할 때 맞춰 보는 것은 접미사 앞의 파드 이름이다. Keycloak 만 StatefulSet 이라 그 앞부분이 고정이고, 덕분에 로그와 `jgroups_ping` 테이블과 지표를 같은 이름으로 견줄 수 있다.
**이 결과가 의미하는 것** — `(2)` 면 이 노드는 상대를 봤다. `(1)` 이면 혼자 있다고 알고 있다. 뷰 번호가 계속 오르고 있으면 멤버가 붙었다 떨어지기를 반복하는 중이라, 「지금 2 다」라는 스냅숏보다 그 사실이 더 중요하다. 이 줄은 과거형이므로 지금 상태는 지표로 본다.
```bash label="[lab host] 테이블 — 지금 등록되어 있는 멤버"
kubectl -n keycloak-lab exec deploy/postgres -- \
psql -U keycloak -d keycloak -c 'select name, ip from jgroups_ping'
```
행이 몇 개인가, 그리고 `ip` 칸이 확인 ④ 에서 본 파드 IP 와 같은가를 본다. 이 표는 「등록되어 있다」이지 「서로 말이 통한다」가 아니다. 두 행이 다 있는데 로그가 `(1)` 이면 서로를 찾기는 했는데 7800 포트로 메시지가 안 가는 것이고, 이 실험대에서 실제로 그 일이 벌어졌다(observed). 옛 파드의 행이 남아 있을 수도 있으므로 IP 를 지금 파드와 대조한다.
```bash label="[lab host] 지표 — 지금 각 노드가 아는 멤버 수"
kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size'
```
**실측**(observed) — 줄바꿈 없는 JSON 한 줄에서 읽어낸 값이다
```text
keycloak-1 → 2
keycloak-0 → 2
```
응답은 줄바꿈 없는 JSON 한 덩어리로 온다. `data.result` 배열에서 원소가 몇 개인가, 각 원소에서 `metric` 안의 `node` 라벨과 `value` 배열의 둘째 원소만 읽는다. 이 실험대에는 `jq` 가 없으므로 파서를 따로 짜지 말고 화면에 나온 JSON 을 그대로 읽는다. 원소가 하나뿐이면 나머지 노드의 스크레이프가 실패한 것이라 클러스터 문제가 아니라 관측 문제일 수 있다. 각 노드가 자기가 아는 멤버 수를 보고하므로 한 노드만 보면 분단을 놓친다 — 분단되면 한쪽은 2, 다른 쪽은 1 이 된다.
:::warning
Keycloak 컨테이너에는 `curl` 이 없다. 공식 이미지가 최소 구성이라 `wget` 도 `nc` 도 없고, 안에서 치면 `command not found` 와 exit code 127 로 끝난다.
:::
```text
sh: line 1: curl: command not found
command terminated with exit code 127
```
Prometheus 가 아직 없으면 임시 파드를 띄운다.
```bash label="[lab host] 파드 IP 를 꺼내 임시 파드에서 긁는다"
K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
kubectl -n keycloak-lab run m --rm -i --restart=Never \
--image=curlimages/curl:8.11.1 --quiet --command -- \
sh -c "curl -s http://$K0:9000/metrics | grep '^vendor_cluster_size'"
```
```text
vendor_cluster_size{cache_manager="keycloak",node="keycloak-0-46674"} 2.0
```
줄 끝의 숫자와 중괄호 안 `node` 라벨이 어느 파드인가를 본다. 이 형태는 그 파드 하나에게 직접 물은 것이라 라벨의 노드 이름과 고른 파드가 반드시 일치한다. `--rm` 을 붙였으므로 파드는 끝나면 사라진다. 값이 안 나오고 연결 거부가 나면 관리 포트 9000 이 안 열렸다.
### 확인 ⑦ 밖에서 닿나
**무엇을 확인하는가** — nginx 에서 Traefik, Ingress, Service 를 지나 파드까지 전부 이어졌는지.
```bash label="[lab host] 앞 두 단계와 같은 명령"
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
```
```text
200
```
**어디를 봐야 하는가** — 코드 한 칸. 여기서 값만 뽑는 형태를 쓰는 것은 앞 두 단계에서 잰 것과 같은 명령으로 같은 값이 나오는지 비교하는 것이 목적이기 때문이다.
**이 결과가 의미하는 것** — `200` 이면 2홉이 다 이어졌다. `502` 나 `503` 이면 뒤에서부터 되짚는다 — Ingress 가 있는지(확인 ①), Service 뒤에 파드가 있는지(확인 ④), 파드가 Ready 인지(확인 ②) 순서다. 처음 보는 오류라 헤더가 필요하면 읽는 형태로 바꾼다.
```bash label="[lab host] 처음 보는 오류는 헤더까지 읽는다"
curl -I https://auth.hyeonworks.com/realms/master
```
### 확인 ⑧ 관리 콘솔에 로그인된다
**무엇을 확인하는가** — 가이드가 적은 「이 단계가 끝나면」의 앞 절반.
브라우저로 `https://auth.hyeonworks.com/admin` 에 들어가 관리자로 로그인한다. 비밀번호는 확인 ③ 의 Secret 에 있고, 이 문서는 그 값을 적지 않는다 — 이 실험대의 확인은 전부 길이까지만 본다.
세션이 실제로 어디 저장되는지까지 보려면 DB 를 직접 본다. 평소에는 필요 없다.
```bash label="[lab host] 로그인 전과 후에 두 번 재서 차이를 본다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select offline_flag, count(*) from offline_user_session group by 1"
```
`offline_flag` 가 `0` 인 행의 `count` 를 로그인 전과 후에 두 번 재서 그 차이를 본다. 한 번만 재면 아무것도 알 수 없다. 로그인 뒤 수가 늘면 세션이 DB 에 남는 것이고, 안 늘면 메모리에만 있다. 메모리에만 있으면 파드를 재시작하는 순간 세션이 사라지고, DB 에 있으면 살아남는다.
## 통과 조건을 한 번에 다시 본다
| 무엇 | 명령 | 통과 |
|---|---|---|
| 롤아웃 | `kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=300s` | `complete` · 파드 `2` |
| Service 뒤 | `kubectl -n keycloak-lab describe svc keycloak \| grep -i endpoints` | 주소 두 개 |
| 클러스터 | `… query=vendor_cluster_size` | 원소 둘 · 값 둘 다 2 |
| 밖에서 | `curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master` | `200` |
## 막히면
가이드 05 가 순서를 정해 두었다. 로그부터 보지 않는다.
**① 이벤트부터.** 스케줄링과 이미지와 볼륨 실패가 여기 나온다.
```bash label="[lab host] 최근 이벤트를 시간순으로 본다"
kubectl -n keycloak-lab get events --sort-by=.lastTimestamp | tail -20
```
맨 아랫줄부터 거꾸로 읽는다. `--sort-by` 를 준 까닭이 그것이다. TYPE 이 `Warning` 인 줄, REASON 칸, 그리고 OBJECT 칸이 어느 파드인가를 본다. 이벤트는 기본 한 시간만 남으므로 아무것도 없다고 문제가 없는 것은 아니다. REASON 하나가 다음 행동을 정한다 — `FailedScheduling` 은 노드에 자리가 없는 것이라 파드가 아니라 클러스터를 봐야 하고, `ErrImagePull` 은 이미지 이름 문제라 로그를 볼 것도 없으며, `BackOff` 는 컨테이너가 떴다가 죽는 중이라 ③ 으로 간다.
**② describe.** 그 파드에 한정된 이벤트와 상태를 함께 본다.
```bash label="[lab host] 파드 하나를 자세히 본다"
kubectl -n keycloak-lab describe pod keycloak-0
```
위에서부터 세 곳이다. `Conditions` 절에서 `Ready` 가 `False` 인가, 컨테이너 절의 `State` 와 `Last State` 와 그 안의 `Exit Code`, 그리고 맨 아래 `Events`. Exit Code 는 그것만으로 말이 된다 — `137` 은 OOM 이나 강제 종료, `1` 은 애플리케이션이 스스로 끝낸 것, `127` 은 명령을 못 찾았다는 뜻이다. `137` 이면 로그에 아무 단서도 없을 수 있어 메모리 한도를 본다. `Ready` 만 `False` 이고 컨테이너는 살아 있으면 readiness 프로브 문제이므로 확인 ④ 로 돌아간다.
**③ 로그.** 컨테이너가 떴는데 죽는 경우를 본다.
```bash label="[lab host] 지금 로그와 재시작 직전 로그"
kubectl -n keycloak-lab logs keycloak-0
kubectl -n keycloak-lab logs keycloak-0 --previous # 재시작 직전 로그
```
첫 명령에서는 마지막 줄들, 둘째 명령에서는 스택 트레이스의 맨 윗줄을 본다. Keycloak 은 기동에 성공하면 `Keycloak ... started in` 한 줄을 남기므로, 그 줄이 있는지 없는지가 기동 중과 기동 실패를 가른다. `--previous` 가 중요하다 — CrashLoopBackOff 면 지금 컨테이너는 방금 뜬 것이라 죽은 까닭은 이전 컨테이너 로그에 있다. `--previous` 가 `not found` 를 내면 아직 한 번도 재시작하지 않은 것이고, 그러면 지금 로그가 곧 전부다.
**④ 그래도 모르면 안에서 본다.**
```bash label="[lab host] 컨테이너 안의 셸로 들어간다"
kubectl -n keycloak-lab exec -it keycloak-0 -- sh
```
| 증상 | 어디를 보나 |
|---|---|
| 파드가 `Pending` | `describe pod` 의 Events — 스케줄 불가 사유 |
| `ImagePullBackOff` | 이미지 이름과 태그. 자체 빌드면 두 노드 모두에 반입했는가 |
| `CrashLoopBackOff` | `logs --previous` |
| `Running` 인데 `0/1` | readiness 프로브 실패. `describe` 의 Conditions |
| 밖에서 502 | Ingress 에서 Service 로, Service 에서 Endpoints 로 뒤를 본다 |
| 클러스터가 1로 보임 | 7800 이 막혔거나 디스커버리 실패. 확인 ⑥ 의 셋 다 |
| `AlreadyExists` | `kubectl create namespace` 를 먼저 쳤다. 매니페스트가 만든다 |
| `error: the path ... does not exist` | 저장소 루트가 아닌 곳에서 쳤다 |
## 무엇이 관측이고 무엇이 아닌가
- (observed) 2026-09-11 의 ReplicaSet 과 파드 이름과 해시, Secret 의 19 와 22 bytes 와 주입된 길이 19 와 복호 길이 22, Endpoints 두 개와 EndpointSlice 의 `ready` 두 줄, `ISPN000094` 뷰 줄, `vendor_cluster_size` 가 둘 다 2, 임시 파드가 읽은 `2.0`, Keycloak 이미지에 `curl` 이 없다는 것과 exit code 127.
- (observed) 디스커버리 테이블에는 둘 다 있는데 메시지가 안 가는 상태를 실제로 겪었다.
- (external) `kubectl get endpoints` 의 v1.33 deprecation 은 쿠버네티스 쪽 규격이고 이 실험대가 잰 값이 아니다.
- (external) Infinispan 이 파드 이름에 임의의 접미사를 붙여 클러스터 노드 식별자로 쓴다는 것은 Infinispan 의 동작이다. 이 기록에 실린 `keycloak-0-10001` 과 `keycloak-0-46674` 가 그렇게 생긴 이름이다(observed).
- (unknown) `keycloak-cluster.yaml` 원문이 반입되지 않아 Keycloak 과 PostgreSQL 의 이미지 태그, 파드 자원 한도, 프로브 설정, `persistent-user-sessions` 설정값은 대조하지 못했다. 위에 적은 객체 이름과 해시와 바이트 수는 돌고 있던 실험대에서 읽은 것이고, 매니페스트가 그것들을 어떤 값으로 선언했는지는 여기서 확인할 수 없다.
- (unknown) `get all` 과 `get deploy` 와 `get pvc` 와 `describe pvc` 와 디스커버리 테이블 조회와 `get events` 와 `describe pod` 와 `logs` 의 출력은 가이드가 봐야 할 줄만 적었고 값이 남아 있지 않다.
- (unknown) 원본 가이드 05 에 되돌리는 절차가 없다. `local-path` PVC 가 노드 디스크에 남긴 데이터가 네임스페이스를 지울 때 어떻게 되는지도 확인하지 않았다.
- (unknown) 이 단계에는 「이 실험대는 이렇게 했다」와 「따라 하는 사람은」 두 갈래가 없다. 05 는 파일을 직접 쓰지 않고 저장소의 매니페스트를 적용한다.
- (unknown) 만드는 명령은 구축할 때 친 것을 옮긴 것이라 다시 쳐서 같은 상태가 되는지는 확인되지 않았다.
<!-- body:end -->
@@ -0,0 +1,199 @@
---
kind: SETUP
slug: power-cycle-the-lab-and-reallocate-guest-memory
title: 실험대를 껐다 켜고 게스트 메모리를 다시 나눈다
topic: lab-environment-build
topicName: 실험대 환경 구성
project: virtualization
status: 게시 전
pinnedVersions:
- name: libvirt
version: 12.7.0
- name: QEMU
version: 11.1.1
- name: k3s
version: v1.36.4+k3s1
- name: 게스트
version: Debian 12 genericcloud
- name: 기준 배치
version: 2026-09-03 · 호스트 RAM 증설 뒤 재배분, 그 뒤 값은 2026-09-10 실측
source:
- final/document.md#332-vm-메모리-재배분-게스트를-다시-만들지-않는다
- final/document.md#333-안전한-종료-순서
- final/document.md#334-복구-순서-종료의-역순
- final/document.md#313-k3s-server와-agent-죽였을-때가-다르다
sourceRevision: 9465582b5d1630eb4ae7c4e078021486919bf6b6
---
# 실험대를 껐다 켜고 게스트 메모리를 다시 나눈다
게스트를 다시 만들지 않고 메모리를 재배분하고, 노트북을 끄기 전에 워크로드를 위에서부터 내리고, 켤 때 그 역순으로 되살리는 절차다. 순서를 틀리면 PostgreSQL 이 강제 종료되어 다음 기동에 crash recovery 가 돈다.
## 관계
- **실험대를 철거하고 무엇이 남는지 확인한다**
지우는 쪽이다. 거기서는 `virsh destroy` 로 전원을 뽑고, 여기서는 `virsh shutdown` 으로 정상 종료한다.
- **Keycloak 2노드와 PostgreSQL 을 k3s 에 올리고 클러스터가 묶였는지 확인한다**
여기서 내리고 올리는 워크로드를 그 절차가 세운다. 네임스페이스와 리소스 이름도 거기서 정해졌다.
- **k3s server 와 agent 를 게스트 두 대에 깔고 lab host 에서 kubectl 로 본다**
`kubectl` 이 lab host 에서 도는 까닭이 그 절차에 있다. 이 편의 명령도 전부 거기서 친다.
- **호스트에 직접 깔지 않고 게스트 VM 두 대로 간다 — 관측자가 실험 대상과 함께 죽으면 안 된다**
server 와 agent 를 죽였을 때가 왜 다른지, 그래서 관측 스택을 어디에 두는지를 그 결정이 받는다.
- **5120MB 를 줬는데 353MB 를 쓰고 있었다 — 선언한 양과 실제로 드는 양**
재배분한 값은 상한이지 점유가 아니다. 상한과 실제 점유를 가르는 방법이 그 기록에 있다.
- **이 호스트의 가상 머신들은 설정한 메모리와 지금 잡고 있는 메모리가 얼마나 다른가**
여기서 준 값이 그 물음이 견줄 설정 값이 된다.
## 본문
<!-- body:start -->
## 읽기 전에 — 어디서 치는가
전부 `[lab host]` 다. `virsh``kubectl` 도 거기서 돌고, 게스트 안을 봐야 할 때만 `ssh kc-lab-1 ...` 형태로 원격 실행한다. 게스트에 로그인해서 치지 않는다.
이 절차는 메모리 값을 바꾸고 워크로드를 올렸다 내리는 일뿐이라 설정 파일을 손대지 않고, 편집기를 여는 곳도 없다.
## 이 절차가 다루는 일 셋
세우는 편 일곱과 지우는 편 하나 사이에 있는 일이다. 실험대를 계속 쓰면 실제로 자주 하는 쪽이 이쪽이다.
| 언제 | 무엇을 |
|---|---|
| 호스트 RAM 을 늘렸거나 게스트가 좁을 때 | 게스트를 다시 만들지 않고 메모리를 재배분한다 |
| 노트북을 끄기 전에 | 워크로드와 게스트를 위에서부터 내린다 |
| 다시 켤 때 | 종료의 역순으로 되살린다 |
셋 다 게스트를 지우지 않아서, 디스크도 시드 ISO 도 그대로 두고 도메인의 값과 파드 수만 바꾼다.
## 실행 절차
### 1. 게스트 메모리를 다시 나눈다
**목적** — 게스트를 다시 만들지 않고 도메인이 쓸 메모리 상한과 현재 할당을 바꾼다.
상한을 먼저 올리고 그다음에 현재 할당을 맞춘 뒤, 도메인과 게스트 양쪽에서 값을 확인한다.
```bash label="[lab host] ① 상한을 올리고 현재 할당을 그 값에 맞춘다"
virsh setmaxmem kc-lab-1 5120M --config
virsh setmem kc-lab-1 5120M --config
```
```bash label="[lab host] ② 도메인이 보는 값과 게스트가 인식한 값을 함께 본다"
virsh dominfo kc-lab-1 | grep -i memory
ssh kc-lab-1 free -m
```
**예상 결과** — `dominfo` 의 `Max memory` 가 바뀐 상한으로 나온다. 게스트의 `free -m` 은 재부팅 전까지 옛 값을 보여 준다.
**왜 필요한가** — 두 명령이 다른 것을 바꾼다.
`setmaxmem` : 상한. 부팅할 때 게스트가 보는 총량
`setmem` : 현재 할당. 상한 이하여야 한다
현재값을 상한보다 크게 줄 수 없으므로 `setmaxmem` 이 먼저다. 거꾸로 치면 두 번째 명령이 상한을 넘는 값을 받아 거부된다. 플래그도 갈린다 — `--config` 는 영구 정의라 다음 부팅부터 먹고, `--live` 는 실행 중인 도메인에 즉시 먹는다. 다만 `setmaxmem --live` 는 게스트가 부팅할 때 메모리 맵을 정하기 때문에 대개 거부된다. 그래서 상한을 바꾸려면 게스트를 껐다 켠다.
**문제가 생기면** — ② 의 `free -m` 이 옛 값이면 아직 재부팅하지 않은 것이다. `dominfo` 쪽도 안 바뀌었으면 `--config` 를 빠뜨렸는지 본다.
### 2. 위에서부터 내린다
**목적** — 애플리케이션과 데이터베이스와 게스트와 호스트를 순서대로 멈춰서 다음 기동에 복구 절차가 돌지 않게 한다.
Keycloak 을 먼저 0 으로 내려 클러스터에서 정상 탈퇴시키고, PostgreSQL 을 그다음에 내리고, 게스트를 정상 종료한 뒤 호스트를 끈다.
```bash label="[lab host] ① Keycloak 을 0 으로 내리고 파드가 사라질 때까지 기다린다"
kubectl -n keycloak-lab scale statefulset/keycloak --replicas=0
kubectl -n keycloak-lab wait --for=delete pod -l app=keycloak --timeout=120s
```
```bash label="[lab host] ② PostgreSQL 을 마지막에, 충분한 시간을 주고 내린다"
kubectl -n keycloak-lab scale deployment/postgres --replicas=0
kubectl -n keycloak-lab wait --for=delete pod -l app=postgres --timeout=120s
```
```bash label="[lab host] ③ 게스트를 ACPI 정상 종료한다"
virsh shutdown kc-lab-1 && virsh shutdown kc-lab-2
```
```bash label="[lab host] ④ 호스트를 끈다"
sudo systemctl poweroff
```
**예상 결과** — ① 과 ② 의 `wait` 가 각각 파드 삭제를 확인하고 돌아온다. ③ 뒤에는 `virsh list --all` 에서 두 게스트가 `shut off` 로 바뀐다.
**왜 필요한가** — ③ 의 `virsh shutdown` 은 게스트 systemd 가 k3s 를 멈추고 k3s 가 컨테이너에 SIGTERM 을 보내는 연쇄다. 유예 시간이 짧으면 PostgreSQL 이 강제 종료되어 다음 기동에 crash recovery 가 돈다. ① 과 ② 로 미리 내려 두면 그 연쇄가 데이터베이스까지 닿지 않는다. 순서를 뒤집어 PostgreSQL 을 먼저 내리면 Keycloak 이 데이터베이스 없이 남아 기동 실패와 재시작을 반복한다.
**문제가 생기면** — `wait` 가 `120s` 안에 안 끝나면 파드가 종료 중에 걸린 것이다. `kubectl -n keycloak-lab get pods -o wide` 로 어느 파드가 어느 노드에서 `Terminating` 인지 보고, 그 상태로 ③ 을 치지 않는다.
### 3. 종료의 역순으로 되살린다
**목적** — 게스트와 클러스터와 워크로드를 반대 순서로 올려 Keycloak 이 데이터베이스를 찾을 수 있게 한다.
게스트를 띄워 노드가 `Ready` 가 되기를 기다린 뒤, PostgreSQL 을 먼저 올리고 Keycloak 을 나중에 올린다.
```bash label="[lab host] ① 게스트를 띄우고 노드가 Ready 가 되기를 기다린다"
virsh start kc-lab-1 && virsh start kc-lab-2
kubectl get nodes
```
```bash label="[lab host] ② PostgreSQL 을 먼저 올리고 기동을 확인한다"
kubectl -n keycloak-lab scale deployment/postgres --replicas=1
kubectl -n keycloak-lab rollout status deployment/postgres
```
```bash label="[lab host] ③ Keycloak 을 두 벌로 올린다"
kubectl -n keycloak-lab scale statefulset/keycloak --replicas=2
```
**예상 결과** — ① 의 `kubectl get nodes` 에 두 노드가 `Ready` 로 나온다. ② 의 `rollout status` 가 배포 완료로 돌아온 뒤에 ③ 을 친다.
**왜 필요한가** — PostgreSQL 이 먼저다. Keycloak 이 데이터베이스 없이 뜨면 기동에 실패하기 때문이다. 그리고 스케일을 0 으로 내려 둔 것은 자동으로 복구되지 않는다. 게스트를 켜고 노드가 `Ready` 가 돼도 파드 수는 0 그대로이므로 ② 와 ③ 을 명시적으로 쳐야 한다.
**문제가 생기면** — ① 에서 노드가 `Ready` 로 안 올라오면 게스트 안의 k3s 유닛부터 본다. ② 의 `rollout status` 가 멈춰 있으면 PostgreSQL 이 crash recovery 중일 수 있으니 아래 확인 방법의 `postmaster.pid` 를 함께 본다.
## 구성 값
기준 배치는 2026-09-03 에 구축이 끝난 상태다.
| 게스트 | 역할 | vCPU · 메모리 |
|---|---|---|
| `kc-lab-1` | k3s server | 2 · 3584M |
| `kc-lab-2` | k3s agent | 2 · 2560M |
이 실험대는 그 뒤에 호스트를 8GB 에서 12GB 로 물리 증설했고, 위 1번의 방법으로 게스트 메모리를 다시 나눴다. 게스트를 다시 만들거나 디스크를 손댈 일은 전혀 없었다. 1번의 예에 쓴 `5120M` 이 그렇게 올린 값이다.
내리고 올리는 워크로드는 둘이다.
| 리소스 | 내릴 때 | 올릴 때 |
|---|---|---|
| `statefulset/keycloak` | 0 (먼저) | 2 (나중) |
| `deployment/postgres` | 0 (나중) | 1 (먼저) |
네임스페이스는 `keycloak-lab` 이고 `wait` 의 제한 시간은 양쪽 다 `120s` 다.
## 확인 방법
**무엇을 확인하는가** — 어느 게스트가 server 이고 어느 게스트가 agent 인지, 그리고 PostgreSQL 이 깨끗이 내려갔는지.
```bash label="[lab host] 노드의 control-plane 라벨을 열로 뽑는다"
kubectl get nodes -o custom-columns=\
'NODE:.metadata.name,CP:.metadata.labels.node-role\.kubernetes\.io/control-plane'
```
```bash label="[lab host] 게스트가 다시 뜬 뒤 postmaster.pid 가 있는지 본다"
ssh kc-lab-2 'sudo ls /var/lib/rancher/k3s/storage/*postgres-data*/pgdata/postmaster.pid'
```
**어디를 봐야 하는가** — 첫 명령에서 `kc-lab-1` 의 `CP` 열에 값이 있고 `kc-lab-2` 는 비어 있다. 둘째 명령은 파일이 없다고 나와야 한다. `postmaster.pid` 가 보이면 비정상 종료였고 다음 기동에 복구 절차가 실행된다.
**이 결과가 의미하는 것** — 둘째 명령은 게스트가 떠 있어야 읽을 수 있으므로 3번의 ① 뒤에 친다. 첫 명령이 필요한 까닭은 두 노드를 같은 것으로 다루면 안 되기 때문이다. `kc-lab-2` 를 죽이면 그 노드의 워크로드만 사라지고 클러스터 제어는 살아 있지만, `kc-lab-1` 을 죽이면 `kubectl` 이 안 되고 DNS 와 인그레스도 함께 사라진다. 노드 상실 실험은 agent 를 죽이는 것이고, server 를 죽이는 것은 컨트롤 플레인 상실이라 성격이 다르다.
## 이 절차가 감당하지 않는 것
관측 스택을 어느 노드에 둘지는 여기서 정하지 않는다. server 와 agent 가 왜 다른지만 위에서 한 번 적었고, 그래서 관측을 server 쪽에 두는 판단은 게스트 두 대를 고른 결정과 Prometheus 를 올리는 편이 받는다.
이 절차의 근거는 제9부의 개념 문서이고, 그 문서의 실측 스냅샷은 2026-09-03, 재배분 기록은 2026-09-11 이다. 제7부가 2026-09-10 에 잰 게스트 메모리 값과 날짜가 엇갈리므로, 게스트 메모리 수치는 각각 그 시점의 것으로 읽는다.
이 순서대로 다시 돌려 검증하지는 않았다. 위 명령들은 원본이 적어 둔 순서를 그대로 옮긴 것이고, 한 번 더 껐다 켜서 같은 출력이 나오는지는 확인하지 못했다.
<!-- body:end -->
@@ -0,0 +1,394 @@
---
id: 7c66a553-0008-4294-a27a-687bd1bda0c1
kind: SETUP
slug: prepare-the-lab-host-for-virtualization
title: lab host 에 가상화 패키지를 깔고 virsh 가 sudo 없이 돌게 만든다
topic: lab-environment-build
topicName: 실험대 환경 구성
project: virtualization
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/7c66a553-0008-4294-a27a-687bd1bda0c1/edit"
pinnedVersions:
- name: libvirt
version: 12.7.0
- name: QEMU
version: 11.1.1
source:
- final/document.md#186-단계-00-lab-host-가상화-준비
- final/document.md#185-가이드-묶음이-스스로-정한-규약
- final/document.md#184-이-부의-출처와-범위
sourceRevision: 9465582b5d1630eb4ae7c4e078021486919bf6b6
---
# lab host 에 가상화 패키지를 깔고 virsh 가 sudo 없이 돌게 만든다
물리 기계 한 대를 게스트를 올릴 수 있는 상태로 바꾸는 절차다. 가상화 패키지 넷을 깔고, libvirt 소켓을 켜고, 사용자를 `libvirt` 그룹에 넣고, 연결 URI 를 시스템 단위로 고정한다. 끝나면 `virsh list --all``sudo` 없이 통과한다.
## 관계
- **cloud-init 시드로 게스트 세 대를 만들고 SSH 가 키로 붙게 한다**
다음 단계이고, 여기서 고정한 `qemu:///system``default` 네트워크 위에서 `virt-install` 이 돈다.
- **qcow2 파일 한 장이 담는 것 — 매핑표와 데이터 클러스터가 같은 파일 안에 있고, 파일 밖을 가리키는 것은 백킹 파일 경로 하나다**
여기서 깐 qemu 가 다루는 디스크 형식을 그 글이 설명한다. 다음 단계의 오버레이가 성립하는 근거도 거기 있다.
- **도구가 낸 출력은 대상의 상태가 아니다 — 그 명령이 무엇을 세는지부터 가른다**
`virsh list` 의 빈 표와 `net-list` 에서 `--all` 을 뺐을 때 안 보이는 네트워크를 어떻게 읽어야 하는지 그 기준이 정한다.
- **단계별 구축 문서는 작성 시점이 아니라 실행 순서로 검증한다**
이 절차의 만드는 명령이 왜 재실행으로 검증되지 않은 채 남았는지를 그 기준이 가른다.
- **가이드대로 다시 쳐서 이 실험대가 같은 상태로 서는가**
같은 명령을 다시 쳐서 이 호스트가 같은 상태가 되는지는 아직 재지 않았다.
## 본문
<!-- body:start -->
## 읽기 전에 — 어디서 치는가
명령은 전부 `[lab host]` 에서 친다. 게스트가 아직 없어서 다른 셸 표시가 나오지 않는다.
`sudo` 가 붙는 곳은 둘이다. 패키지를 깔 때와 systemd 유닛을 만질 때이고, `virsh` 는 3번이 끝나면 `sudo` 없이 돈다. 그 뒤로 `sudo virsh` 를 치면 root 환경으로 돌아 사용자 홈의 설정을 못 보므로, 붙이지 않는 쪽이 맞는 형태다.
| 표시 | 어느 기계 | 어떻게 들어가나 |
|---|---|---|
| `[lab host]` | `test-server`. `virsh` 가 도는 곳 | `ssh test-server` |
편집기를 여는 곳은 한 군데다. 4번에서 `~/.bashrc` 를 연다. 나머지는 전부 조회·설치·유닛 조작이라 운영자가 평소에 치는 CLI 를 그대로 쓴다.
## 이 단계가 세우는 것
가이드 00 의 「이 단계가 끝나면」은 한 줄이다.
> `virsh list` 가 sudo 없이 돌고, VM 을 만들 수 있는 상태가 된다.
그 한 줄이 요구하는 것을 풀면 여덟이다.
| 무엇 | 값 |
|---|---|
| 저장소 | `~/workspace/keycloak-pattern` — 뒤 단계의 `deploy/...` 상대경로가 이 디렉터리 기준이다 |
| 패키지 (Arch) | `qemu-full` · `libvirt` · `virt-install` · `dnsmasq` |
| 패키지 (Debian/Ubuntu) | `qemu-system-x86` · `libvirt-daemon-system` · `virtinst` · `cloud-image-utils` |
| 판 번호 | libvirt `12.7.0` · `QEMU emulator version 11.1.1` |
| 유닛 | `libvirtd.socket``.service` 가 아니다 |
| 그룹 | `donghyeon libvirt wheel` |
| 연결 URI | `LIBVIRT_DEFAULT_URI=qemu:///system` |
| 가상 네트워크 | `default` / `active` / autostart `yes``virbr0` · `192.168.122.0/24` |
네 패키지가 하는 일이 서로 다르다.
| 무엇 | 하는 일 |
|---|---|
| qemu | 실제로 가상 기계를 돌리는 것 |
| libvirt | qemu 를 관리하는 층 (`virsh` 가 여기 붙는다) |
| virt-install | VM 을 만드는 명령 |
| dnsmasq | 가상 네트워크의 DHCP·DNS |
## 전제와 되돌리기
전제는 가이드가 두 줄로 적었다 — 물리 기계 한 대가 있고, 배포판은 상관없다. 이 실험대는 Arch Linux 로 세웠고 배포판이 다르면 패키지 이름만 달라진다. 앞 단계가 없으므로 이 절차는 다른 무엇도 전제하지 않는다.
**되돌리는 절차는 원본 가이드 00 에 없다**(unknown). 가이드 7편 가운데 되돌리기를 적은 편은 단계 03 하나다. 이 단계가 호스트에 남기는 것은 다섯이다.
| 남는 것 | 어디에 |
|---|---|
| 패키지 넷 | 배포판 패키지 데이터베이스 |
| `libvirt` 보조 그룹 | 사용자 계정 |
| `libvirtd.socket` 활성화 | systemd |
| `export` 한 줄 | `~/.bashrc` |
| `default` 네트워크의 autostart | libvirt 설정 |
무엇을 어떤 순서로 걷어내는지는 가이드에 적혀 있지 않고 이 실험대도 걷어내 본 적이 없다. 여기에 되돌리는 명령을 적으려면 지어내야 하므로 적지 않는다.
## 세우기 전에 먼저 본다
두 확인은 아무것도 바꾸지 않는다. 기계를 켤 때 먼저 도는 펌웨어와 그 설정 화면을 BIOS(Basic Input/Output System)라고 부르는데, 거기서 가상화가 꺼져 있으면 뒤가 전부 헛일이므로 먼저 본다.
### 확인 ① CPU 가 하드웨어 가상화 확장을 내놓고 있는가
```bash label="[lab host] CPU 플래그에서 vmx 나 svm 을 찾는다"
grep -Eo 'vmx|svm' /proc/cpuinfo | head -1
```
**어디를 봐야 하는가** — 출력 한 줄이 전부다. `vmx`(Intel) 또는 `svm`(AMD) 중 하나가 찍히는가, 아니면 아무것도 안 찍히는가.
**이 결과가 의미하는 것** — 찍혔으면 이 호스트에서 KVM 을 쓸 수 있다. 빈 출력은 CPU 가 못 한다는 뜻이 아니라 대개 BIOS 에서 꺼져 있다는 뜻이다. 재부팅해 Intel VT-x 나 AMD-V 를 켜고 다시 잰다. 여기서 막히면 뒤의 어떤 단계도 의미가 없으므로 진행하지 않는다.
### 확인 ② 커널이 그 확장을 실제로 잡고 있는가
```bash label="[lab host] 올라온 KVM 모듈을 본다"
lsmod | grep kvm
```
출력은 이 실험대에서 캡처해 두지 않았다(unknown). 가이드도 줄 모양만 적었다.
```text
kvm_intel ...
kvm ...
```
**어디를 봐야 하는가** — 왼쪽 첫 열의 모듈 이름 두 개. 벤더 모듈(`kvm_intel` 또는 `kvm_amd`)과 공용 `kvm` 이 둘 다 있어야 한다. 셋째 열은 이 모듈을 쓰고 있는 쪽의 수라서, 아직 VM 이 없으면 0 으로 나온다.
**이 결과가 의미하는 것** — 두 줄이면 커널이 하드웨어 가상화를 쓸 준비를 마쳤고 `virt-install` 이 KVM 가속으로 뜬다. `kvm` 만 있고 벤더 모듈이 없으면 확인 ① 의 BIOS 설정이 커널까지 안 넘어왔다. 아무것도 없으면 확인 ① 로 돌아간다. 모듈을 직접 올려 보면 거부 사유가 그대로 나온다.
```bash label="[lab host] 벤더 모듈을 손으로 올려 거부 사유를 받는다"
sudo modprobe kvm_intel
```
**왜 이걸 먼저 보나** — KVM 없이도 QEMU 는 돌지만 소프트웨어 에뮬레이션이 되어 수십 배 느려진다. 게스트가 뜨긴 뜨는데 느리다면 대개 여기서 갈린다. 그 상태로 게스트 셋을 올리면 원인을 게스트 안에서 찾게 된다.
## 실행 절차
### 1. 저장소를 lab host 에 받는다
**목적** — 뒤 단계가 `deploy/` 아래 파일을 쓴다. 단계 05·06 의 `kubectl apply -f deploy/lab/k8s/...` 가 그것이고, 경로는 저장소 루트 기준이다.
① 이미 세웠다 철거한 적이 있으면 무엇이 남아 있는지 먼저 본다.
```bash label="[lab host] ① 작업 디렉터리에 무엇이 있는지 본다"
ls ~/workspace
```
② 받는다.
```bash label="[lab host] ② 저장소를 받고 그 안으로 들어간다"
git clone https://git.learn.hyeonworks.com/donghyeon.kang/keycloak-pattern.git ~/workspace/keycloak-pattern
cd ~/workspace/keycloak-pattern
```
**예상 결과** — 매니페스트 두 장이 보인다.
```bash label="[lab host] ③ 뒤 단계가 쓸 매니페스트를 확인한다"
ls deploy/lab/k8s/
```
`keycloak-cluster.yaml` 과 `observability.yaml` 이 보이는가만 본다.
**왜 필요한가** — 이후 단계에서 `deploy/...` 로 시작하는 상대경로가 나오면 전부 이 디렉터리 안에서 친다. lab host 에 저장소가 없으면 `cp: cannot stat` 이나 `error: the path ... does not exist` 로 막히고, 이 실험대가 실제로 그 형태로 겪었다. 한 번 세웠다 철거했으면 이 디렉터리가 없을 수 있다. 철거는 VM 과 디스크와 네트워크만 지우고 저장소는 각자 관리라, `~/workspace` 에 cloud-init 시드만 남아 있는 상태가 흔하다. ① 을 먼저 치는 까닭이 여기 있다.
**문제가 생기면** — `ls` 가 아무것도 못 찾으면 클론이 실패한 것이니 ② 를 다시 친다. 클론은 됐는데 `deploy/lab/k8s/` 가 없으면 다른 브랜치를 받았다.
### 2. 가상화 패키지를 깐다
**목적** — `virsh` 와 `virt-install` 을 PATH 에 올리고 가상 네트워크의 DHCP 를 준비한다. PATH 는 셸이 실행 파일을 찾아 다니는 디렉터리 목록을 말한다.
```bash label="[lab host] ① Arch 에서 네 패키지를 깐다"
sudo pacman -S --needed qemu-full libvirt virt-install dnsmasq
```
Debian 이나 Ubuntu 면 이름이 다르다. 이 실험대는 Arch 로 세웠고 아래 줄은 가이드가 참고로 적어 둔 것이다(external).
```bash label="[lab host] Debian/Ubuntu 라면 이름이 이렇게 바뀐다"
sudo apt install qemu-system-x86 libvirt-daemon-system virtinst cloud-image-utils
```
```bash label="[lab host] ② 두 실행 파일이 PATH 에 들어왔는지 본다"
virsh --version
qemu-system-x86_64 --version
```
**예상 결과** — 판 번호 두 줄. 이 실험대는 libvirt `12.7.0` 과 `QEMU emulator version 11.1.1` 이었다(observed).
**왜 필요한가** — 여기서 나오는 libvirt 판 번호가 뒤의 옵션 이름을 가른다. 훨씬 낮은 판이면 `virt-install --cloud-init` 같은 옵션의 동작이 달라질 수 있으니, 다음 단계에서 막힐 때 이 번호를 같이 본다.
**문제가 생기면** — `command not found` 면 패키지가 안 깔렸다. 번호가 나오면 깔렸다.
### 3. libvirt 를 띄우고 권한을 받는다
**목적** — 지금 이 셸이 `sudo` 없이 libvirt 에 붙게 한다.
```bash label="[lab host] ① 소켓 유닛을 켜고 부팅에도 켜지게 한다"
sudo systemctl enable --now libvirtd.socket
```
```bash label="[lab host] ② 지금 사용자를 libvirt 보조 그룹에 넣는다"
sudo usermod -aG libvirt "$USER"
```
③ 로그아웃했다 다시 들어온다. 보조 그룹은 로그인할 때 정해지므로 `usermod` 만으로는 지금 셸에 반영되지 않는다.
```bash label="[lab host] ④ 그룹과 권한을 함께 확인한다"
groups # libvirt 가 보여야 한다
virsh list --all # sudo 없이 돌아야 한다
```
**예상 결과** — `groups` 가 이렇게 나왔다(observed).
```text
donghyeon libvirt wheel
```
`virsh list --all` 은 머리글만 있는 빈 표를 내놓는다. 아직 VM 을 안 만들었으므로 표가 비어 있는 쪽이 정상이고, 봐야 할 것은 표의 내용이 아니라 명령이 오류 없이 통과했는가다.
**왜 필요한가** — `libvirtd.service` 가 아니라 `.socket` 을 켠다. 소켓 활성화라 데몬이 미리 떠 있지 않아도 `virsh` 가 접속하는 순간 systemd 가 띄우고, 자원을 아끼며, 데몬을 재시작해도 클라이언트가 끊기지 않는다.
**문제가 생기면** — `groups` 에 `libvirt` 가 없으면 `usermod` 는 됐지만 지금 로그인 세션이 옛 그룹 목록을 들고 있다. `usermod -aG` 가 고치는 것은 `/etc/group` 파일이다. 프로세스의 그룹 목록은 로그인할 때 한 번 읽혀 고정되므로 이미 떠 있는 셸에는 소급 적용되지 않는다. 두 곳을 나란히 보면 그 상태가 그대로 드러난다.
```bash label="[lab host] 셸이 들고 있는 목록과 파일의 내용을 나란히 본다"
id # 현재 셸이 들고 있는 그룹 (여기 libvirt 가 보여야 함)
getent group libvirt # /etc/group 의 실제 내용 (이쪽엔 바로 반영됨)
```
두 결과가 다르면 재로그인이 필요하다는 뜻이다. 급하면 `newgrp libvirt` 로 그 셸만 갱신한다.
`groups` 에는 있는데 `virsh` 가 `Permission denied` 를 내면 그룹이 아니라 소켓 문제이므로 유닛 상태를 본다.
```bash label="[lab host] 그룹이 맞는데 거절당할 때 유닛부터 본다"
systemctl status libvirtd.socket
```
### 4. 연결 URI 를 시스템 단위로 고정한다
**목적** — `virsh` 가 VM 을 만들 곳과 같은 하이퍼바이저를 보게 한다. `virsh` 는 기본으로 사용자 단위인 `qemu:///session` 에 붙는데 VM 은 시스템 단위인 `qemu:///system` 에 만들어야 한다. 이걸 안 맞추면 만든 VM 이 목록에 안 보인다.
① 설정 파일을 연다.
```bash label="[lab host] ① 로그인 셸 설정을 편집기로 연다"
nano ~/.bashrc
```
② 파일 끝에 이 줄을 더한다. 저장은 `Ctrl+O` 다음 `Enter`, 나가기는 `Ctrl+X`.
```text
export LIBVIRT_DEFAULT_URI=qemu:///system
```
③ 저장한 설정을 현재 셸에 반영하고 확인한다.
```bash label="[lab host] ③ 지금 셸에 반영하고 어느 하이퍼바이저를 보는지 본다"
source ~/.bashrc
virsh uri
```
**예상 결과**(observed)
```text
qemu:///system
```
끝의 한 낱말만 본다. `system` 인가 `session` 인가.
**왜 필요한가** — `qemu:///system` 이면 뒤에서 만들 VM 과 지금 `virsh` 가 같은 곳을 본다. `qemu:///session` 이면 사용자 단위 하이퍼바이저를 보고 있어, VM 은 만들어졌는데 `virsh list` 에 안 나오는 상태가 된다.
이 실험대는 같은 줄을 편집기 없이 넣었다(observed).
```bash label="[lab host] 이 실험대가 실제로 친 형태"
echo 'export LIBVIRT_DEFAULT_URI=qemu:///system' >> ~/.bashrc
virsh uri
```
따라 하는 사람에게는 편집기 쪽이 맞다. `echo >>` 는 같은 가이드를 두 번 따라 하면 같은 줄을 한 번 더 붙이고, 파일에 이미 무엇이 들어 있는지도 보여 주지 않는다. 파일을 열면 둘 다 해결된다.
**문제가 생기면** — `.bashrc` 에 넣은 것은 새로 여는 셸에만 적용된다. 지금 셸에서 값이 안 바뀌었으면 `source ~/.bashrc` 를 치거나 새 셸을 연다.
`sudo virsh` 와 그냥 `virsh` 를 섞어 치지 않는다. 이 문제는 한 번 고쳐도 반복해서 재발한다. `sudo` 는 환경 변수를 물려주지 않아 여기서 넣은 줄이 전달되지 않는데, root 로 도니 결과적으로 `qemu:///system` 이 되어 그쪽도 동작한다. 둘 다 되기 때문에 섞어 쓰면 어떤 명령은 되고 어떤 명령은 `Network not found: no network with matching name 'default'` 가 나온다. 네트워크가 없어서가 아니라 두 명령이 서로 다른 인스턴스에 물어본 것이다.
### 5. 기본 네트워크를 켠다
**목적** — VM 이 붙을 `virbr0` 와 `192.168.122.0/24` 를 지금도 재부팅 뒤에도 있게 한다.
```bash label="[lab host] ① 가상 네트워크가 살아 있는지 본다"
virsh net-list --all
```
**예상 결과**(observed)
```text
Name State Autostart Persistent
--------------------------------------------
default active yes yes
```
② `default` 행의 State 와 Autostart 두 칸 중 하나라도 어긋나면 아래 두 줄로 맞춘다.
```bash label="[lab host] ② 꺼져 있거나 autostart 가 no 일 때만 친다"
virsh net-start default
virsh net-autostart default
```
두 줄을 `&&` 로 잇지 않는다. `virsh net-start default` 는 이미 `active` 면 `error: network is already active` 로 실패한다. `A && B` 는 A 가 성공했을 때만 B 를 실행하므로, 두 번째로 칠 때는 `net-autostart` 가 아예 돌지 않는다. 화면에는 둘 다 실패한 것처럼 보이지만 앞선 실행에서 이미 목적을 이룬 상태다. 이 가이드를 두 번 이상 따라 한다면 `;` 로 잇고 앞엣것의 실패를 삼킨다.
```bash label="[lab host] 두 번 이상 따라 할 때 쓰는 형태"
virsh net-start default 2>/dev/null; virsh net-autostart default
```
**왜 필요한가** — `active` 이면서 autostart 가 `yes` 면 지금도, 호스트를 재부팅한 뒤에도 `virbr0` 와 `192.168.122.0/24` 가 있다. `inactive` 면 VM 을 만들어도 DHCP 가 없어 IP 를 못 받는다. autostart 가 `no` 면 지금은 되고 호스트를 재부팅한 다음 게스트의 SSH 가 전부 실패하는데, 그때 원인을 게스트에서 찾게 된다.
**문제가 생기면** — `--all` 을 주는 까닭이 여기 있다. 빼면 `inactive` 인 네트워크는 아예 목록에 안 나와서 없는 것과 꺼진 것을 구분할 수 없다. `net-start default` 가 `already active` 말고 다른 사유로 실패하면 dnsmasq 가 안 깔린 것이므로 2번으로 돌아간다.
## 구성 값
| 무엇이 서나 | 어떤 이름과 값으로 |
|---|---|
| 저장소 | `~/workspace/keycloak-pattern` |
| 패키지 (Arch) | `qemu-full` · `libvirt` · `virt-install` · `dnsmasq` |
| 유닛 | `libvirtd.socket` |
| 보조 그룹 | `libvirt` |
| 연결 URI | `qemu:///system` — `~/.bashrc` 의 `LIBVIRT_DEFAULT_URI` |
| 가상 네트워크 | `default` · `active` · autostart `yes` |
| 브리지와 대역 | `virbr0` · `192.168.122.0/24` |
대역이 `192.168.122.0/24` 로 굳으면 다음 단계의 DHCP 예약 세 줄과 그 뒤 모든 단계의 upstream 주소가 그 안에서 정해진다. 게스트 주소를 바꾸고 싶으면 여기서 바꾸는 것이지 게스트 안에서 바꾸는 것이 아니다.
## 끝났는지 판정한다
### 확인 ① virsh 가 sudo 없이 통과하는가
**무엇을 확인하는가** — 이 셸에서 VM 을 만들 수 있는 상태인지.
```bash label="[lab host] 권한과 연결과 네트워크를 차례로 본다"
virsh uri
virsh net-list --all
virsh list --all
```
**어디를 봐야 하는가** — 첫 줄이 `qemu:///system` 인가, 둘째에서 `default` 가 `active` 이고 autostart 가 `yes` 인가, 셋째가 `sudo` 없이 오류 없이 끝나는가.
**이 결과가 의미하는 것** — 셋이 다 통과하면 이 단계는 끝났다. `virsh list --all` 이 `sudo` 없이 오류 없이 끝나는 것이 이 단계의 통과 조건 전부이고, 표의 내용은 아직 볼 것이 없다. 빈 표를 「호스트에 아무것도 없다」로 읽지 않는다 — 지금은 만들지 않았으니 비어 있는 것이고, 다음 단계가 끝난 뒤에 같은 명령이 세 줄을 내놓는다.
### 확인 ② 그룹이 지금 셸에 반영됐는가
**무엇을 확인하는가** — `usermod` 가 파일에만 들어간 것인지, 지금 세션이 들고 있는 목록에도 들어간 것인지.
```bash label="[lab host] 지금 세션이 들고 있는 그룹 목록"
groups
```
**어디를 봐야 하는가** — 출력에 `libvirt` 가 끼어 있는가.
**이 결과가 의미하는 것** — 끼어 있으면 소켓에 붙을 권한이 지금 셸에 있다. 없는데 `virsh` 가 도는 일은 없으므로, 확인 ① 이 `Permission denied` 로 끝났다면 먼저 여기를 본다.
## 통과 조건을 한 번에 다시 본다
| 무엇 | 명령 | 통과 |
|---|---|---|
| 가상화 확장 | `grep -Eo 'vmx\|svm' /proc/cpuinfo \| head -1` | `vmx` 또는 `svm` 한 줄 |
| 그룹 | `groups` | `libvirt` 가 끼어 있다 |
| 연결 URI | `virsh uri` | `qemu:///system` |
| 네트워크 | `virsh net-list --all` | `default` 가 `active` · autostart `yes` |
| 빈 목록 | `virsh list --all` | sudo 없이 통과. 표가 비어 있어도 된다 |
다섯 칸이 다 맞으면 다음 단계로 넘어간다.
## 막히면
| 증상 | 원인 | 확인 |
|---|---|---|
| `virsh list` 에 permission denied | 그룹 반영 안 됨 | 로그아웃과 로그인을 했는가. `groups` 에 libvirt 가 있는가 |
| 만든 VM 이 목록에 없다 | URI 가 `session` | `virsh uri` |
| `sudo` 로는 되는데 그냥은 안 된다 | 세션 인스턴스를 보고 있다 | `virsh uri` |
| `net-start` 가 `already active` | 앞서 켜 두었다 | `virsh net-list --all` 의 State |
| VM 이 극단적으로 느리다 | KVM 미사용 | `lsmod \| grep kvm` · BIOS |
| `net-start default` 실패 | dnsmasq 없음 | 패키지 설치 확인 |
| `cp: cannot stat 'deploy/...'` | lab host 에 저장소가 없다 | `ls ~/workspace` |
## 무엇이 관측이고 무엇이 아닌가
- (observed) libvirt `12.7.0` 과 `QEMU emulator version 11.1.1`, `groups` 의 세 이름, `default` 네트워크가 `active` 이고 autostart 가 `yes` 인 것, `virsh uri` 가 내놓은 `qemu:///system`.
- (unknown) `lsmod | grep kvm` 의 출력은 캡처해 두지 않았다. 가이드도 줄 모양만 적고 값을 싣지 않았다.
- (unknown) 가이드의 실측 줄은 이 호스트가 16 코어 전부에서 지원한다고 적었는데 대상 환경 쪽은 논리 코어 8(i5-1135G7)로 적혀 있다. 두 값이 어긋나고 어느 쪽이 이 호스트의 값인지는 재지 않았다. 세 게스트의 vCPU 합이 5 라 8 에서도 16 에서도 CPU overcommit 이 아니므로 이 단계의 판정은 어느 쪽이어도 바뀌지 않는다.
- (unknown) 원본 가이드에 되돌리는 절차가 없다. 패키지와 그룹과 유닛과 `~/.bashrc` 와 네트워크 autostart 를 걷어내 본 적이 없다.
- (unknown) `sudo modprobe kvm_intel` 과 `systemctl status libvirtd.socket` 은 막혔을 때 치라고 가이드가 적어 둔 명령이고, 이 실험대에서는 막히지 않아 치지 않았다.
- (unknown) `nano ~/.bashrc` 로 여는 형태는 이 실험대가 치지 않았다. 이 실험대는 `echo >>` 로 넣었고, 편집기 쪽은 같은 상태에 닿는 형태로 적었다.
- (external) Debian 과 Ubuntu 의 패키지 이름은 가이드가 참고로 적어 둔 것이고 이 실험대는 Arch 로 세웠다.
- (external) 보조 그룹이 로그인 시점에 고정된다는 것, `virsh net-start` 가 이미 `active` 면 실패한다는 것, `sudo` 가 환경 변수를 물려주지 않는다는 것은 리눅스와 libvirt 의 동작이다. 이 실험대가 그 세 가지를 따로 재 보지는 않았다.
- (unknown) 만드는 명령은 구축할 때 친 것을 옮긴 것이라, 지금 다시 쳐도 같은 상태가 되는지는 확인되지 않았다.
<!-- body:end -->
@@ -0,0 +1,290 @@
---
id: 0cb0f195-b06b-4f8e-b52e-675eb0918805
kind: SETUP
slug: prometheus-and-grafana-for-the-lab
title: Prometheus 와 Grafana 를 올려 클러스터 안을 밖에서 본다
topic: lab-environment-build
topicName: 실험대 환경 구성
project: virtualization
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/0cb0f195-b06b-4f8e-b52e-675eb0918805/edit"
pinnedVersions:
- name: k3s
version: v1.36.4+k3s1
- name: Debian GNU/Linux
version: 12 (bookworm)
sourceRevision: 9465582b5d1630eb4ae7c4e078021486919bf6b6
source:
- final/document.md#192-단계-06-prometheus-와-grafana
- final/document.md#184-이-부의-출처와-범위
---
# Prometheus 와 Grafana 를 올려 클러스터 안을 밖에서 본다
매니페스트 한 장을 `apply` 해 네임스페이스 `observability` 에 Prometheus 와 Grafana 와 node-exporter 를 세우는 절차다. 밖에서만 판정하면 분단된 노드가 스스로 빠지는 것을 놓치기 때문에 이 스택을 둔다.
## 관계
- **Keycloak 2노드와 PostgreSQL 을 k3s 에 올리고 클러스터가 묶였는지 확인한다**
그 단계가 세운 두 노드를 긁는다. 거기서 임시 파드로 물었던 클러스터 크기를 여기서는 Prometheus 에 묻는다.
- **k3s server 와 agent 를 게스트 두 대에 깔고 lab host 에서 kubectl 로 본다**
node-exporter 가 노드마다 하나씩 뜨므로 줄이 하나뿐이면 그 단계의 노드 상태부터 다시 본다.
- **도구가 낸 출력은 대상의 상태가 아니다 — 그 명령이 무엇을 세는지부터 가른다**
`up` 이 1 이라는 것은 프로세스가 살아 있고 응답한다는 뜻이지 그 노드가 쓸모 있다는 뜻이 아니다. 빈 결과가 0 을 뜻하지 않는 것도 같다.
- **가이드대로 다시 쳐서 이 실험대가 같은 상태로 서는가**
스크레이프 대상 넷과 파드 네 줄이 다시 세운 실험대에서도 같은지가 그 질문이 셀 항목에 들어간다.
## 본문
<!-- body:start -->
## 읽기 전에 — 어디서 치는가
전부 `[lab host]` 에서 치고, 매니페스트를 적용하는 명령은 저장소 루트에서 친다.
| 번호 | 무엇 | 어디서 |
|---|---|---|
| 1 | 매니페스트 적용 | `[lab host]``~/workspace/keycloak-pattern` 안에서 |
| 2 | 파드 네 줄 세기 | `[lab host]` |
| 판정 | 대상 · 상태 · 클러스터 크기 | `[lab host]` |
| 판정 | Grafana 포트포워드 | **브라우저로 볼 그 기계** |
**마지막 포트포워드만 예외다.** 그 터널은 명령을 친 기계에서만 열리므로, 브라우저로 볼 기계에서 쳐야 한다.
이 단계에도 손으로 쓰는 파일이 없다. 세우는 것이 전부 매니페스트 한 장에 들어 있고, 그 원문은 저장소의 `deploy/lab/k8s/observability.yaml` 이다.
## 이 단계가 세우는 것
가이드 06 의 「이 단계가 끝나면」은 두 줄이다.
> Prometheus 가 Keycloak 을 긁고, `vendor_cluster_size` 로 클러스터 상태를
> 밖에서 볼 수 있다.
**왜 두는가**(observed) — 판정을 밖에서만 하면 놓친다. 7800 을 끊었는데 외부 응답이 전부 200 이었고, 분단된 노드가 스스로 로드밸런서에서 빠졌기 때문이다. 클러스터 안을 보는 눈이 따로 있어야 한다.
| 무엇 | 값 |
|---|---|
| 네임스페이스 | `observability` |
| 파드 | `grafana` 1 · `prometheus` 1 · `node-exporter` 2 (DaemonSet, 노드마다 하나) |
| 스크레이프 대상 | `keycloak` · `kubelet` · `node-exporter` · `prometheus` |
| Prometheus API | 파드 안 `localhost:9090``/api/v1/targets` · `/api/v1/query` |
| Grafana | 밖에 열지 않고 `port-forward svc/grafana 3000:3000` |
| 도구 | `jq` 가 없다. `grep -o``tr ',' '\n'` 로 필드만 뽑는다 |
**판 번호는 여기 없다**(unknown). 가이드 06 은 Prometheus 와 Grafana 의 이미지 태그를 적지 않았고 `observability.yaml` 원문은 반입되지 않았다. 파드 이름의 해시는 판 번호가 아니다.
## 전제와 되돌리기
전제는 한 줄이다 — 앞 단계가 끝나 Keycloak 두 노드가 떴다.
**되돌리는 절차는 원본 가이드 06 에 없다**(unknown). 이 단계가 남기는 것은 `observability` 네임스페이스와 그 안의 전부이고, `node-exporter` 는 DaemonSet 이라 노드마다 하나씩 붙어 있다. 지우는 순서도 지운 뒤의 확인도 가이드에 없다.
## 세우기 전에 먼저 본다
**무엇을 확인하는가** — 이 스택이 없으면 같은 질문에 어떻게 답하게 되는지. 앞 단계가 이미 한 번 보여 주었다.
```bash label="[lab host] 관측 스택 없이 클러스터 크기를 묻는 형태"
K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
kubectl -n keycloak-lab run m --rm -i --restart=Never \
--image=curlimages/curl:8.11.1 --quiet --command -- \
sh -c "curl -s http://$K0:9000/metrics | grep '^vendor_cluster_size'"
```
**어디를 봐야 하는가** — 이 명령은 고른 파드 하나에게만 물을 수 있다. `keycloak-1` 을 보려면 IP 를 다시 잡아 한 번 더 친다.
**이 결과가 의미하는 것** — 한 노드만 보면 분단을 놓친다. 두 노드를 매번 따로 물어야 하고, 값을 나란히 놓아 비교하기도 어렵다. 이 단계는 그 질문을 한 번에 받아 준다.
## 실행 절차
### 1. 매니페스트 한 장을 적용한다
**목적** — 네임스페이스 `observability` 에 Prometheus 와 Grafana 와 node-exporter 를 세운다.
```bash label="[lab host] ① 매니페스트가 있는 저장소 루트로 간다"
cd ~/workspace/keycloak-pattern
```
```bash label="[lab host] ② 매니페스트를 적용하고 Prometheus 가 설 때까지 기다린다"
kubectl apply -f deploy/lab/k8s/observability.yaml
kubectl -n observability rollout status deploy/prometheus --timeout=180s
```
**예상 결과** — ② 가 끝나면 Prometheus 가 Ready 다. node-exporter 는 DaemonSet 이라 이 명령이 기다리는 대상에 들어가지 않으므로 2번에서 따로 센다.
**왜 필요한가** — `deploy/...` 로 시작하는 상대경로는 저장소 루트 기준이라 다른 디렉터리에서 치면 파일을 못 찾는다. 관측 스택을 두는 까닭은 밖에서만 판정하면 놓치기 때문이고, 그 근거는 위에 적었다.
**문제가 생기면** — ② 가 타임아웃으로 끝나면 2번의 파드 목록부터 보고, 거기서도 안 보이면 k3s 단계의 노드 상태로 돌아간다.
### 2. 파드 네 줄을 센다
**목적** — 노드 둘에 node-exporter 가 하나씩 떴는지 확인한다.
```bash label="[lab host] ① 네임스페이스의 파드를 전부 본다"
kubectl -n observability get pods
```
**예상 결과**(observed)
```text
grafana-845b5678cf-b6gvc 1/1 Running
node-exporter-9qk9w 1/1 Running
node-exporter-c2mz4 1/1 Running
prometheus-6774f94f7c-pzr2t 1/1 Running
```
줄이 네 개인가, READY 칸이 전부 `1/1` 인가, 특히 `node-exporter` 로 시작하는 줄이 둘인가를 센다.
**왜 필요한가** — node-exporter 가 둘인 것은 DaemonSet 이라 노드마다 하나씩 뜨기 때문이다. 하나뿐이면 노드 하나가 빠진 것이고, 그러면 그 노드의 CPU 와 메모리와 디스크 지표가 통째로 없는 채로 실험을 하게 된다.
**문제가 생기면** — 줄이 하나면 관측을 더 볼 것이 아니라 k3s 단계의 노드 상태부터 본다. 어느 노드에 붙었는지는 이렇게 확인한다.
```bash label="[lab host] 어느 노드에 붙었는지 본다"
kubectl -n observability get pods -o wide
```
NODE 열에서 Prometheus 와 Grafana 가 어느 노드에 있는지 본다. 죽는 순간을 기록해야 하는 쪽이 대상과 함께 내려가면 기록이 남지 않으므로, 관측 스택은 관측 대상과 같이 죽으면 안 된다. 노드가 둘뿐인 실험대에서는 완전히 갈라 둘 수 없어서 규칙으로 정했다 — 관측 스택은 server 노드인 `kc-lab-1` 에 두고 장애 주입은 agent 노드인 `kc-lab-2` 에 한다. `nodeSelector` 로 못박아 두면 실험을 다시 돌려도 같은 노드에 뜬다. node-exporter 는 DaemonSet 이라 이 규칙 밖이고 두 노드에 다 떠 있어야 한다.
## 구성 값
자주 보는 지표들이다. 실험 중에는 Grafana 보다 Prometheus 쿼리 API 가 편하다 — 값을 그대로 뽑아 비교할 수 있고 스크린샷보다 근거로 남기기 좋다.
| 지표 | 무엇 |
|---|---|
| `vendor_cluster_size` | 이 노드가 아는 멤버 수 |
| `vendor_jgroups_*` | JGroups 프로토콜별 카운터 |
| `vendor_statistics_approximate_entries_unique{cache="sessions"}` | 이 노드의 세션 캐시 엔트리 수 |
| `agroal_*` | JDBC 커넥션 풀 |
| `up` | 스크레이프 성공 여부 |
이 실험대에는 `jq` 가 깔려 있지 않다. 아래 확인 명령이 `grep -o` 와 `tr` 로 필드를 뽑는 모양인 것은 그 때문이고, 그 이상 가공해야 하면 파서를 짜지 않고 화면에 나온 JSON 을 그대로 읽는다.
## 끝났는지 판정한다
### 확인 ① 무엇을 긁고 있나
**무엇을 확인하는가** — Prometheus 가 어느 대상을 스크레이프하고 있는지.
```bash label="[lab host] 스크레이프 대상의 job 이름만 뽑는다"
kubectl -n observability exec deploy/prometheus -- \
wget -qO- localhost:9090/api/v1/targets | grep -o '"job":"[^"]*"' | sort -u
```
**실측**(observed)
```text
"job":"keycloak"
"job":"kubelet"
"job":"node-exporter"
"job":"prometheus"
```
**어디를 봐야 하는가** — 거기 있는 이름이 아니라 없는 이름이다. 응답은 JSON 한 덩어리이고 그대로는 못 읽으므로 `grep -o` 로 필요한 필드만 뽑고 `sort -u` 로 중복을 없앴다. 사람이 손으로 치는 선이 여기까지다.
**이 결과가 의미하는 것** — Redis 와 BFF 와 PostgreSQL 이 없다. 이 실험대는 그것들을 긁지 않는다. 그래서 어떤 실험에 Grafana 화면이 없는데, 안 찍은 것이 아니라 지표가 없는 것이다. 어떤 실험에서 지표를 못 찾으면 「측정이 실패했다」로 적기 전에 이 목록에 그 job 이 있었는지부터 본다. 가이드는 그것을 스크린샷 누락이 아니라 측정된 공백으로 기록했다.
### 확인 ② 목록에는 있는데 값이 안 나올 때
**무엇을 확인하는가** — 목록에 있는 대상이 실제로 긁히고 있는지.
```bash label="[lab host] job 과 health 와 lastError 를 세로로 늘어놓는다"
kubectl -n observability exec deploy/prometheus -- \
wget -qO- localhost:9090/api/v1/targets | tr ',' '\n' | grep -E '"(job|health|lastError)"'
```
**어디를 봐야 하는가** — `"health":"up"` 이 아닌 줄과 그 바로 뒤의 `lastError`. `tr ',' '\n'` 로 쉼표마다 줄을 나눴으므로 필드가 원래 순서대로 세로로 늘어서고, job 줄 아래에 그 대상의 health 가 온다.
**이 결과가 의미하는 것** — `down` 인 대상이 있으면 `lastError` 가 까닭을 그대로 말해 준다. 연결 거부인지 타임아웃인지 404 인지가 거기 적혀 있다. 확인 ③ 에서 값이 한 노드만 나오는 증상의 원인이 대개 여기 있고, 그때는 클러스터가 아니라 스크레이프가 문제다.
권한이 모자라 대상 하나만 빠지는 일이 이 실험대에서 실제로 있었다. Prometheus 는 타깃을 적어 두지 않고 쿠버네티스 API 에 물어서 찾으므로 읽기 권한이 필요하다. 그 권한을 담는 `ClusterRole` 에서 `nodes/proxy` 를 빠뜨리자 kubelet 타깃만 `403 Forbidden` 으로 실패하고 나머지 잡은 전부 정상이었다. `nodes` 와 `nodes/metrics` 와 `nodes/proxy` 는 서로 다른 권한이라, 노드 지표를 긁는 경로 `/api/v1/nodes/<name>/proxy/metrics` 에는 셋째 것이 따로 있어야 한다. 부분 실패라 1번의 `rollout status` 는 성공이라고 말하고, 타깃 목록을 직접 봐야 드러난다. 권한만 따로 물을 수도 있다.
```bash label="[lab host] 그 ServiceAccount 가 그 동사를 쓸 수 있는지 묻는다"
kubectl auth can-i get nodes/proxy --as=system:serviceaccount:observability:prometheus
kubectl describe clusterrole prometheus
```
### 확인 ③ 클러스터 상태 — 두 노드가 각각 몇 명을 보고 있는가
**무엇을 확인하는가** — 두 Keycloak 이 서로를 보고 있는지.
```bash label="[lab host] 두 노드가 각각 아는 멤버 수"
kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size'
```
**실측**(observed) — 줄바꿈 없는 JSON 한 줄에서 읽어낸 값이다
```text
keycloak-1 → 2
keycloak-0 → 2
```
**어디를 봐야 하는가** — `data.result` 배열의 원소가 몇 개인가, 각 원소에서 `metric` 안의 `node` 라벨과 `value` 배열의 둘째 원소만 읽는다. `jq` 가 없으므로 눈으로 읽는다.
**이 결과가 의미하는 것** — 두 노드가 각각 자기가 아는 멤버 수를 보고한다. 둘 다 2 면 클러스터가 온전하다. 분단되면 한쪽은 2, 다른 쪽은 1 이 된다 — 한 노드만 보면 분단을 놓친다. 원소가 하나뿐이면 분단이 아니라 스크레이프 실패일 수 있으므로 확인 ② 의 `health` 를 먼저 본다. 값이 아예 안 나오면 `"result":[]` 로 빈 배열이 오는데, 이것은 「0 이다」가 아니라 「그런 지표가 없다」는 뜻이다. 지표 이름을 잘못 쳤거나 그 대상을 긁고 있지 않은 것이므로 확인 ① 로 돌아간다.
### 확인 ④ up 을 믿지 않는다
**무엇을 확인하는가** — 스크레이프 성공 지표가 무엇까지 말해 주는지.
```bash label="[lab host] 대상마다 스크레이프가 성공했는지 본다"
kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=up'
```
**어디를 봐야 하는가** — 원소마다 `job` 라벨과 값이다. 값이 1 이라는 것은 마지막 스크레이프가 성공했다는 사실 하나만 말한다.
**이 결과가 의미하는 것** — 503 이 나는 동안에도 `up` 은 1 이었다(observed). 프로세스가 살아 있고 `/metrics` 가 응답하기만 하면 1 이 되므로, 살아 있지만 쓸모없는 상태를 이 지표로는 보지 못한다. 경보를 `up == 0` 하나로 걸면 그 상태를 통째로 놓친다. 그래서 기능 지표를 함께 본다. 밖에서 실제 응답을 받아 보는 것이 가장 짧다.
```bash label="[lab host] up 과 나란히 놓고 비교한다"
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
```
코드 한 칸을 `up` 의 1 과 0 옆에 놓는다. `up=1` 인데 이쪽이 200 이 아니면 그 조합이 곧 「살아 있지만 쓸모없는」 상태의 증거가 된다. 처음 보는 오류를 파고들 때는 값만 뽑는 형태를 버리고 헤더까지 읽는 형태로 바꾼다.
### 확인 ⑤ Grafana 를 볼 때
**무엇을 확인하는가** — 대시보드가 뜨는지. 밖에 열지 않고 포트포워드로 본다.
```bash label="[브라우저로 볼 기계] 보는 동안만 터널을 연다"
kubectl -n observability port-forward svc/grafana 3000:3000
```
**어디를 봐야 하는가** — `Forwarding from 127.0.0.1:3000 -> 3000` 한 줄이 찍히고 명령이 그대로 멈춰 있는가. 이 명령은 끝나지 않는 것이 정상이라 터미널 하나를 여기에 내준다. 브라우저를 열면 그 아래에 `Handling connection` 줄이 하나씩 붙는데, 그것이 안 붙으면 브라우저가 다른 곳을 보고 있다.
**이 결과가 의미하는 것** — 이 터널은 명령을 실행한 기계에서만 열린다. 워크스테이션에서 쳤으면 워크스테이션 브라우저로, lab host 에서 쳤으면 lab host 에서 봐야 한다. `bind: address already in use` 면 3000 을 이미 누가 쓰는 것이니 왼쪽만 바꾼다. Ctrl+C 로 끊으면 터널도 사라진다 — 밖에 포트를 여는 것이 아니라 보는 동안만 뚫는 것이라 실험대의 노출면이 늘지 않는다.
## 통과 조건을 한 번에 다시 본다
| 무엇 | 명령 | 통과 |
|---|---|---|
| 파드 | `kubectl -n observability get pods` | 네 줄 · `node-exporter` 가 둘 |
| 대상 | `… /api/v1/targets \| grep -o '"job":"[^"]*"' \| sort -u` | `keycloak` 이 목록에 있다 |
| 상태 | `… /api/v1/targets \| tr ',' '\n' \| grep -E …` | `"health":"up"` 아닌 줄이 없다 |
| 클러스터 | `… query=vendor_cluster_size` | 원소 둘 · 값 둘 다 2 |
## 막히면
| 증상 | 원인 | 확인 |
|---|---|---|
| Keycloak 지표가 안 보임 | 9000 이 안 열렸거나 스크레이프 설정 누락 | 확인 ① 의 targets |
| 값이 한 노드만 나옴 | 다른 노드 스크레이프 실패 | targets 의 `health` 필드 |
| 컨테이너 안에서 curl 실패 | Keycloak 이미지에 curl 이 없다 | 밖에서 Prometheus 로 묻는다 |
| Grafana 에 데이터 없음 | 데이터소스 주소 오류 | Prometheus 서비스 이름 확인 |
| `node-exporter` 가 한 줄 | 노드 하나가 빠졌다 | k3s 단계의 `kubectl get nodes` |
| `"result":[]` | 그런 지표가 없다 — 0 이 아니다 | 확인 ① 에 그 job 이 있는가 |
| `bind: address already in use` | 3000 을 이미 누가 쓴다 | `3001:3000` 처럼 왼쪽만 바꾼다 |
## 무엇이 관측이고 무엇이 아닌가
- (observed) 파드 네 줄과 job 네 개, `vendor_cluster_size` 가 둘 다 2, 503 중에도 `up` 이 1 이었던 것.
- (observed) Redis 와 BFF 와 PostgreSQL 이 스크레이프 대상에 없다는 것. 안 찍은 것이 아니라 지표가 없는 것이다.
- (observed) `ClusterRole` 에서 `nodes/proxy` 를 빠뜨렸을 때 kubelet 타깃만 403 으로 실패하고 나머지 잡은 정상이었던 것. 그때의 화면은 남아 있지 않다(unknown).
- 관측 스택을 `kc-lab-1` 에 두고 `kc-lab-2` 를 장애 주입 대상으로 삼는 것은 이 실험대가 정한 규칙이다. `observability.yaml` 원문이 없어 `nodeSelector` 가 거기 어떻게 적혀 있는지는 대조하지 못했다(unknown).
- (unknown) `observability.yaml` 원문이 반입되지 않아 Prometheus 와 Grafana 와 node-exporter 의 이미지 태그, 스크레이프 주기, 보존 기간, Grafana 대시보드 구성은 대조하지 못했다. 위에 고정한 버전은 이 스택이 올라탄 k3s 와 게스트 OS 까지다.
- (unknown) `get pods -o wide` 와 targets 의 `health` 훑기, `query=up`, `port-forward` 의 출력은 가이드가 봐야 할 줄만 적었고 값이 남아 있지 않다.
- (unknown) 원본 가이드 06 에 되돌리는 절차가 없다. DaemonSet 이 노드마다 남긴 것을 걷어내 본 적이 없다.
- (unknown) 이 단계에는 「이 실험대는 이렇게 했다」와 「따라 하는 사람은」 두 갈래가 없다. 06 은 파일을 직접 쓰지 않고 저장소의 매니페스트를 적용한다.
- (inferred) node-exporter 가 재는 것은 게스트 안에서 본 값이다. 호스트에서 같은 것을 재면 다른 수가 나올 수 있는데, 스크레이프 대상 넷이 전부 클러스터 안이라 이 실험대는 호스트 쪽 지표를 긁지 않는다.
- (unknown) 만드는 명령은 구축할 때 친 것을 옮긴 것이라 다시 쳐서 같은 상태가 되는지는 확인되지 않았다.
<!-- body:end -->
@@ -0,0 +1,235 @@
---
kind: SETUP
slug: tear-down-the-lab-and-know-what-survives
title: 실험대를 철거하고 무엇이 남는지 확인한다
topic: lab-environment-build
topicName: 실험대 환경 구성
project: virtualization
status: 게시 전
pinnedVersions:
- name: libvirt
version: 12.7.0
- name: QEMU
version: 11.1.1
- name: 커널
version: 7.2.2-arch1-1
- name: 호스트
version: Arch Linux · i5-1135G7 · RAM 11,648MiB
- name: 게스트
version: Debian 12 genericcloud
source:
- final/document.md#202-철거-실제-출력-전문
- final/document.md#204-재구축할-때-무엇이-남아-있나
- final/document.md#195-이-부의-출처와-범위
sourceRevision: 9465582b5d1630eb4ae7c4e078021486919bf6b6
---
# 실험대를 철거하고 무엇이 남는지 확인한다
게스트 세 대와 DHCP 예약을 지우고 무엇이 남았는지까지 확인하는 절차다. 세우는 가이드에는 이 부분이 없어서 2026-09-10 에 직접 돌리며 명령과 출력을 적었다. 되돌리는 명령은 이 절차에 없다.
## 관계
- **cloud-init 시드로 게스트 세 대를 만들고 SSH 가 키로 붙게 한다**
여기서 지우는 것을 그 절차가 만들고, 다시 세울 때 돌아갈 곳도 거기다.
- **실험대를 껐다 켜고 게스트 메모리를 다시 나눈다**
워크로드를 정상으로 내리고 싶으면 그 편의 종료 순서를 먼저 돌고 온다. 여기서는 지우는 것이 목적이라 전원을 뽑는다.
- **5120MB 를 줬는데 353MB 를 쓰고 있었다 — 선언한 양과 실제로 드는 양**
회수된 디스크 3.1GB 를 읽는 방법이 그 기록과 같다. 선언한 크기와 실제로 차지한 크기는 다른 값이다.
- **이 실험대의 certbot 은 HTTP-01 로 받고 있는가 DNS-01 로 받고 있는가**
인증서를 지우지 않고 남기는 까닭이 이 물음이 안 닫혔기 때문이다. 답이 나오면 정책이 바뀐다.
- **가이드대로 다시 쳐서 이 실험대가 같은 상태로 서는가**
철거는 그 물음을 재기 위한 전제라, 지우고 다시 세워 봐야 답이 나온다.
- **도구가 낸 출력은 대상의 상태가 아니다 — 그 명령이 무엇을 세는지부터 가른다**
`virbr0``DOWN` 으로 보이는 것을 고장으로 읽지 않는 근거가 그 기준이다.
## 본문
<!-- body:start -->
## 읽기 전에 — 어디서 치는가
전부 `[lab host]` 다. 게스트를 통째로 지우는 절차라 게스트 안에서 칠 명령이 하나도 없다.
조회와 삭제뿐이라 편집기를 여는 곳도 없고 CLI 를 그대로 쓴다.
## 되돌리기가 없다
`virsh undefine --remove-all-storage` 는 도메인 정의와 오버레이 디스크와 시드 ISO 를 한 번에 지운다. 지운 뒤에 되살리는 명령이 libvirt 에 없다. 실험대를 다시 쓰려면 `base.qcow2` 위에 오버레이를 새로 만들고 시드 ISO 를 다시 구워야 하고, 그 절차는 게스트 세 대를 만드는 편이 받는다.
지우기 전에 알아 둘 것은 이 절차가 건드리지 않는 쪽이다. `base.qcow2``~/workspace/cloud/kc-lab-{1,2}.yaml``~/.ssh/config``kc-lab-*` 항목은 그 자체로 살아 있어서 재구축 때 다시 쓴다. 무엇이 어느 쪽인지는 아래 표가 아홉 행으로 가른다.
## 지우기 전에 먼저 본다
**무엇을 확인하는가** — 지금 무엇이 있는지. 철거 뒤와 견줄 값을 여기서 받아 둔다.
```bash label="[lab host] 도메인 · 볼륨 · 예약 · 디스크 사용량을 한 번에 본다"
virsh list --all
virsh vol-list default
virsh net-dumpxml default | grep -A5 dhcp
df -h /
```
**어디를 봐야 하는가** — 네 값이다. 도메인이 몇 대인가, 볼륨이 몇 개인가, `<host>` 예약이 몇 줄인가, 루트 파일시스템이 얼마나 찼는가. 2026-09-10 의 이 호스트에서는 도메인 3 대에 볼륨 7 개, 예약 3 줄, `df -h /` 가 11G 였다.
**이 결과가 의미하는 것** — 이 네 값이 철거의 성공 판정 기준이 된다. 여기를 건너뛰면 지운 뒤에 「원래 몇 개였지」를 되짚을 방법이 없다.
## 실행 절차
### 1. 게스트 세 대를 지운다
**목적** — 도메인 정의와 그 게스트가 쓰던 디스크를 함께 없앤다.
세 게스트의 전원을 뽑고 정의와 디스크를 지운 뒤, 무엇이 없어졌는지 바로 센다.
```bash label="[lab host] ① 게스트 셋을 전원부터 뽑고 정의와 디스크를 함께 지운다"
for v in kc-lab-edge kc-lab-2 kc-lab-1; do
virsh destroy "$v"
virsh undefine "$v" --remove-all-storage
done
```
```bash label="[lab host] ② 남은 도메인과 볼륨을 센다"
virsh list --all
virsh vol-list default
```
**예상 결과** — 게스트 한 대마다 네 줄이 나온다. 아래는 `kc-lab-edge` 한 대분이다(observed).
```text label="[lab host] ① 의 출력 — 한 대분"
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` 을 돌릴 때 「이미 있다」로 실패한다. 그리고 `virsh destroy` 는 종료 신호를 보내지 않고 전원을 뽑는다. 워크로드를 정상으로 내리고 싶으면 종료 순서를 다루는 편을 먼저 돌고 온다.
**문제가 생기면** — ② 의 `virsh vol-list default` 에 게스트 디스크나 시드 ISO 가 보이면 `--remove-all-storage` 가 빠진 것이다. 남은 볼륨은 `virsh vol-delete --pool default seed-kc-lab-1.iso` 처럼 이름을 하나씩 대서 지운다.
### 2. DHCP 예약 세 줄을 지운다
**목적** — libvirt `default` 네트워크에서 게스트 주소 예약을 빼고 동적 대역만 남긴다.
세 예약을 값 그대로 지운 뒤 네트워크 정의에 무엇이 남았는지 본다. 삭제할 때도 `mac` 과 `name` 과 `ip` 세 속성을 다 준다.
```bash label="[lab host] ① 예약 세 줄을 값 그대로 지운다"
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
virsh net-update default delete ip-dhcp-host \
"<host mac='52:54:00:aa:bb:11' name='kc-lab-1' ip='192.168.122.11'/>" \
--live --config
virsh net-update default delete ip-dhcp-host \
"<host mac='52:54:00:aa:bb:12' name='kc-lab-2' ip='192.168.122.12'/>" \
--live --config
```
```bash label="[lab host] ② 예약이 빠지고 동적 대역만 남았는지 본다"
virsh net-dumpxml default | grep -E "host mac|range start"
```
**예상 결과** — ① 은 삭제마다 `Updated network default persistent config and live state` 를 낸다. ② 에는 `<range>` 한 줄만 남는다.
```text label="[lab host] ② 의 출력 — 동적 대역만 남았다"
<dhcp>
<range start='192.168.122.2' end='192.168.122.254'/>
</dhcp>
```
**왜 필요한가** — 예약을 남겨 두면 다음 구축에서 예약이 두 벌이 되거나, 넣으려 할 때 이미 있다고 거부당한다. `--live` 와 `--config` 를 둘 다 주는 까닭도 넣을 때와 같다. `--live` 만 주면 재부팅에 예약이 되살아나고 `--config` 만 주면 지금 돌고 있는 dnsmasq 가 아직 예약을 들고 있다.
**문제가 생기면** — 속성이 하나라도 비면 이렇게 거부된다.
```text label="[lab host] 이름이 빈 채로 삭제를 시도했을 때"
error: Failed to update network default
error: XML error: Cannot use host name '' in network 'default'
```
zsh 에서 루프로 돌리면 이 오류를 만난다. zsh 는 따옴표 없는 변수를 단어 분리하지 않아서, bash 에서 되던 `set -- $entry` 가 `$1` 에 문자열 전체를 넣고 `$2` 와 `$3` 을 비워서 `name=""` 이 된다. 세 줄을 값 그대로 쓰는 편이 안전하다.
## 인증서는 건드리지 않는다
`/etc/letsencrypt/` 는 정책으로 남긴다. 한도 때문이 아니다 — Let's Encrypt 의 「같은 이름 조합에 주당 중복 5장」 제한은 가끔 재구축하는 정도로는 근처에도 못 간다.
남기는 까닭은 지금 재발급이 되는지를 모르기 때문이다. 이 실험대의 이름 셋은 tailnet 주소를 가리키고, `100.64.0.0/10` 은 CGNAT(Carrier-Grade NAT, 통신사 공용 주소 변환)용 예약 대역이라 공개 인터넷에서 라우팅되지 않는다. HTTP-01 검증은 Let's Encrypt 가 우리 서버로 들어오는 방식이므로 그 주소로는 검증이 성립하지 않는다. 지금 설정이 DNS-01 이면 지우고 다시 받으면 끝이고, HTTP-01 이면 검증 방식부터 손봐야 한다. 어느 쪽인지는 certbot 설정을 읽는 열린 물음이 한 줄로 닫는다.
§204 는 HTTP-01 인 경우를 「못 한다」가 아니라 「일이 하나 생긴다」로 적었다. 최악이라도 A 레코드를 공인 IP 로 잠깐 돌려 받거나 DNS-01 로 전환하면 되고, 다만 재구축을 시작하자마자 그 일부터 하게 된다. 그래서 이 정책은 「어느 쪽인지 모르는 채로는 지우지 않는다」가 전부다.
그래도 지워야 한다면 먼저 백업한다.
```bash label="[lab host] 인증서를 통째로 묶어 둔다"
sudo tar czf ~/letsencrypt-backup-$(date +%Y%m%d-%H%M%S).tgz -C /etc letsencrypt
```
복원은 반대로 한 줄이다. `{{STAMP}}` 는 위 명령이 만든 파일 이름에 찍힌 시각을 그대로 옮겨 넣는다.
```bash label="[lab host] 묶어 둔 인증서를 되돌린다"
sudo tar xzf ~/letsencrypt-backup-{{STAMP}}.tgz -C /etc
```
## 구성 값
지우는 대상은 게스트 셋과 그 셋의 DHCP 예약이다.
| 게스트 | 예약의 MAC · IP | 볼륨 둘 |
|---|---|---|
| `kc-lab-edge` | `52:54:00:aa:bb:10` · 192.168.122.10 | `kc-lab-edge.qcow2` · `seed-kc-lab-edge.iso` |
| `kc-lab-1` | `52:54:00:aa:bb:11` · 192.168.122.11 | `kc-lab-1.qcow2` · `seed-kc-lab-1.iso` |
| `kc-lab-2` | `52:54:00:aa:bb:12` · 192.168.122.12 | `kc-lab-2.qcow2` · `seed-kc-lab-2.iso` |
지우는 순서는 `kc-lab-edge` · `kc-lab-2` · `kc-lab-1` 이다. 1번의 `for` 목록이 그 순서로 적혀 있다.
## 확인 방법
**무엇을 확인하는가** — 철거 전에 받아 둔 네 값이 전부 내려갔는지.
```bash label="[lab host] 철거 뒤 같은 네 값을 다시 본다"
virsh list --all
virsh vol-list default
virsh net-dumpxml default | grep -A5 dhcp
df -h /
ip -br addr show virbr0
```
**어디를 봐야 하는가** — 다섯 줄을 전후로 견준다(observed).
| 무엇을 보는가 | 철거 전 | 철거 후 |
|---|---|---|
| `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 세 개(각 370KB)이고, `kc-lab-edge` 의 디스크 크기는 재 두지 않았다. 합계에서 빼면 1GB 안팎인데 그것은 잰 값이 아니라 역산한 값이다.
`virbr0` 가 `DOWN` 인 것은 고장이 아니다. 브리지에 붙은 tap 인터페이스가 하나도 없어서 캐리어가 없는 것으로 표시될 뿐이고, 주소 `192.168.122.1/24` 는 그대로 있다. VM 을 다시 띄우면 그 VM 의 `vnetN` 인터페이스가 브리지에 붙으면서 `UP` 이 된다. `virsh net-start` 를 찾아 헤매지 않는다.
## 철거해도 남는 것과 사라지는 것
이걸 모르면 재구축이 「왜 이건 이미 있지」와 「왜 이건 없지」의 반복이 된다.
| 무엇 | 어떻게 되나 | 왜 |
|---|---|---|
| `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 · 모든 워크로드 | 사라진다 | 게스트와 함께 |
`~/workspace/cloud/kc-lab-{1,2}.yaml` 이 남는다는 것은 편한 일이면서 위험한 일이다. 그 파일에 SSH 공개키와 콘솔 로그인용 비밀번호가 들어 있으므로, 실험대를 접고 기계를 넘길 때는 이 절차만으로 끝났다고 보지 않는다.
## 이 절차가 감당하지 않는 것
호스트 계층 철거는 여기 없다. 원본이 `deploy/lab/host/teardown-host.sh` 를 가리키지만 그 스크립트가 반입되지 않아 내용을 모른다. nginx 설정과 DNAT 유닛과 certbot 훅을 어디까지 걷어내는지는 확인하지 못했다.
다시 세우는 쪽도 여기 없다. 게스트를 만드는 편부터 일곱 편이 그 순서를 받는다.
이 명령들은 2026-09-10 에 이 호스트에서 실제로 돌려 받은 출력이고, 그 뒤에 다시 돌려 검증하지는 않았다. 다시 돌리면 실험대가 없어지기 때문이다.
<!-- body:end -->
@@ -0,0 +1,574 @@
---
id: 975a6d61-4e34-4034-a0d2-01fea3b498a3
kind: SETUP
slug: wildcard-certificate-with-dns-01-and-a-deploy-hook
title: DNS-01 으로 와일드카드 인증서를 받고 갱신이 서빙까지 닿게 한다
topic: lab-environment-build
topicName: 실험대 환경 구성
project: virtualization
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/975a6d61-4e34-4034-a0d2-01fea3b498a3/edit"
pinnedVersions:
- name: nginx (엣지 게스트)
version: 1.22.1
- name: nginx (물리 호스트)
version: 1.30.4
- name: Debian GNU/Linux
version: 12 (bookworm)
sourceRevision: 9465582b5d1630eb4ae7c4e078021486919bf6b6
source:
- final/document.md#190-단계-04-let's-encrypt-와-인증서-갱신
- final/document.md#184-이-부의-출처와-범위
---
# DNS-01 으로 와일드카드 인증서를 받고 갱신이 서빙까지 닿게 한다
엣지 게스트에 certbot 과 Cloudflare 플러그인을 깔고 DNS-01 로 와일드카드 인증서를 받아 nginx 에 443 을 얹는 절차다. 갱신이 서빙하는 인증서까지 닿게 하는 deploy 훅도 넣는다. 물리 호스트에는 아무것도 두지 않는다.
## 관계
- **인증서는 DNS-01 로 받는다 — 실험대 주소가 공개 인터넷에 없어 HTTP-01 이 성립하지 않는다**
왜 이 방식이어야 했는지와 토큰이 평문으로 놓이는 비용을 그 결정이 갖고, 여기는 그것을 실행하는 명령만 갖는다.
- **갱신은 매번 SUCCESS 였고 옛 인증서가 계속 나갔다 — 2305초와 1~2초**
훅이 없을 때 갱신과 서빙이 얼마나 벌어지는지를 그 기록이 쟀다. 여기서 훅을 넣는 다섯째 단계가 그 측정에서 나왔다.
- **엣지 게스트에 nginx 를 세우고 호스트 DNAT 으로 밖에서 들어오는 길을 연다**
이 절차가 고쳐 쓰는 nginx 파일과 80 서버 블록을 그 단계가 먼저 세운다.
- **도구가 낸 출력은 대상의 상태가 아니다 — 그 명령이 무엇을 세는지부터 가른다**
갱신 로그의 `SUCCESS` 도 훅 로그의 `error output` 도 서버가 지금 무엇을 내보내는지를 말하지 않는다. 그래서 판정을 워커 PID 와 체인 단계 수로 한다.
- **가까운 층부터 한 층씩 건너뛰며 확인한다 — 층마다 성공 신호가 다르다**
확인 명령을 엣지 안에서 치면 층의 답이 아니라 친 곳의 답이 돌아온다. 어느 기계에서 치는지가 이 단계의 판정을 가른다.
- **Keycloak 2노드와 PostgreSQL 을 k3s 에 올리고 클러스터가 묶였는지 확인한다**
다음 단계다. 여기서 잰 `404 tls=0` 이 거기서 `200` 으로 바뀌는 것이 그 단계가 만든 변화다.
## 본문
<!-- body:start -->
## 읽기 전에 — 어디서 치는가
이 단계도 셸이 갈린다.
| 번호 | 무엇 | 어디서 |
|---|---|---|
| 1 | certbot 설치 | `[kc-lab-edge]` |
| 2 | Cloudflare 토큰 발급 | 브라우저 |
| 3 | 토큰 파일 · 토큰 검증 | `[kc-lab-edge]` |
| 4 | 시험 발급 · 실제 발급 · 확인 | `[kc-lab-edge]` |
| 5 | nginx 에 443 을 얹는다 | `[kc-lab-edge]` |
| 6 | 갱신이 서빙까지 닿게 한다 | `[kc-lab-edge]` |
| 판정 | 열리는가 · 체인이 완전한가 | **tailnet 에 붙은 다른 머신** — 여기만 엣지가 아니다 |
**판정만 엣지에서 치지 않는 까닭**을 가이드가 한 줄로 적었다 — 엣지에는 Tailscale 이 없어 자기 자신을 밖에서 들어오는 경로로 부를 수 없다. 엣지 안에서 치면 이렇게 막힌다.
```text
* connect to 100.83.212.4 port 443 failed: Connection refused
```
`auth.hyeonworks.com` 은 호스트의 tailnet 주소로 풀리는데 엣지 게스트에는 Tailscale 이 없다. 엣지에서 나간 패킷은 호스트의 `virbr0` 으로 들어가고 호스트의 DNAT 규칙은 `iifname "tailscale0"` 만 매칭하므로 그 패킷은 규칙에 안 걸리고, 호스트 443 에 리스너가 없어 거절된다. 설정이 틀린 것이 아니라 친 곳이 틀렸다. 가이드는 그 기계를 「tailnet 에 붙은 다른 머신」이라고만 부르고 다섯 셸 이름 중 어느 것인지 짚지 않았다(unknown).
편집기로 여는 파일은 셋이다 — Cloudflare 자격증명, 443 블록을 넣은 nginx 설정, deploy 훅 스크립트. 설치와 발급과 갱신은 certbot 명령을 그대로 쓴다. 비밀은 값을 화면에 찍지 않는다. 토큰이 제대로 들어갔는지는 파일 권한과 크기로 보고, 값이 맞는지는 Cloudflare 에 물어 응답의 상태 문자열로 가른다.
## 이 단계가 세우는 것
가이드 04 의 「이 단계가 끝나면」은 한 줄이다.
> `https://` 가 열리고 체인이 완전하며, 갱신이 자동으로 **서빙까지** 닿는다.
세 마디가 각각 다른 확인을 요구한다. 「열린다」는 `curl -v`, 「체인이 완전하다」는 `openssl s_client`, 「서빙까지 닿는다」는 워커 PID 로 판정한다. 앞의 둘까지만 보고 끝내는 문서가 많고, 이 실험대가 재 둔 결함은 정확히 세 번째에 있다.
| 무엇 | 값 |
|---|---|
| 패키지 | `certbot` · `python3-certbot-dns-cloudflare` |
| 자격증명 | `/etc/letsencrypt/cloudflare.ini``600`, root 만 읽기 |
| 발급 대상 | `-d hyeonworks.com -d '*.hyeonworks.com'` |
| lineage 디렉터리 | `/etc/letsencrypt/live/hyeonworks.com/` — 첫 번째 `-d` 에서 따온 라벨 |
| nginx 가 읽는 것 | `fullchain.pem` · `privkey.pem` |
| 유효기간 | 오늘 + 90일 |
| deploy 훅 | `/etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh` (`chmod +x`) |
| 타이머 | `certbot-renew.timer``certbot-renew.service` |
**제약이 검증 방식을 정했다**(observed). 같은 Let's Encrypt 인증서인데 「이 도메인이 네 것이냐」를 증명하는 방법만 다르고, 이 실험대에는 선택의 여지가 없다.
| 무엇 | HTTP-01 | DNS-01 |
|---|---|---|
| 검증 방향 | Let's Encrypt 가 우리 서버로 (인바운드) | certbot 이 DNS 공급자 API 로 (아웃바운드) |
| 공개 인터넷에서 보여야 하나 | 그렇다 | 아니다 |
| 와일드카드 | 불가 | 가능 |
| 필요한 것 | 80 포트 · 공개 A 레코드 | DNS 공급자 API 토큰 |
가이드는 이 선택을 우열로 적지 않는다. 공개 서버라면 HTTP-01 이 맞고, 토큰도 DNS 연동도 없어 관리할 것이 적다.
아래 절차는 DNS-01 로 받는 형태다. 다만 **지금 돌고 있는 이 실험대의 certbot 이 어느 쪽으로 등록되어 있는지는 아직 읽지 못했다**(unknown) — 호스트의 `sudo` 가 비밀번호를 요구해 `/etc/letsencrypt/renewal/*.conf``authenticator` 를 비대화식으로 읽을 수 없었다. 이 실험대의 문서 두 개도 이 대목에서 서로 어긋나, 한쪽은 DNS-01 로 결론냈고 다른 한쪽은 같은 04 단계를 `certbot certonly --webroot` 로 적어 두었다. 어느 쪽이 실제로 등록되어 있는지는 그 한 줄을 읽어야 갈린다.
## 전제와 되돌리기
전제는 한 줄이다 — 앞 단계가 끝나 엣지 nginx 가 Traefik 으로 프록시한다.
**되돌리는 절차는 원본 가이드 04 에 없다**(unknown). 되돌리기에 가장 가깝게 적힌 문장은 배치의 까닭 한 줄뿐이다.
> 인증서·certbot·갱신 타이머·deploy 훅이 전부 엣지 게스트에 산다. 물리 호스트에는 아무것도 두지 않는다 —
> 그래야 `virsh undefine kc-lab-edge` 한 줄로 이 계층을 통째로 되돌릴 수 있다.
이 문장은 「게스트를 통째로 지우면 같이 없어진다」이고 걷어내는 절차가 아니다. 순서도, 사전 조건도, 지운 뒤 무엇을 확인하는지도 없고 이 실험대가 그렇게 지워 본 기록도 없다. 이 단계가 엣지에 남기는 것은 넷이다.
| 남는 것 | 어디에 |
|---|---|
| 패키지 둘 | 게스트 패키지 데이터베이스 |
| `cloudflare.ini` · 인증서 묶음 · deploy 훅 | `/etc/letsencrypt/` 아래 |
| `certbot-renew.timer` 활성화 | systemd |
| 443 블록 | `/etc/nginx/sites-available/keycloak-lab` |
**Cloudflare 쪽 토큰은 엣지를 지워도 계정에 남는다**(external). 토큰은 그쪽 서버가 들고 있고 엣지에 있던 것은 사본이다. 가이드는 그 폐기를 적지 않았다(unknown).
## 세우기 전에 먼저 본다
두 확인은 아무것도 바꾸지 않는다. 하나는 검증 방식을 정하고, 하나는 5번에서 쓸 설정 문법을 가른다.
여기부터 6번까지가 엣지 게스트 셸이다. lab host 에서 들어간다.
```bash label="[lab host] 엣지 게스트에 들어간다"
ssh kc-lab-edge
```
### 확인 ① 이 도메인이 무엇으로 풀리는가
```bash label="[kc-lab-edge] 도메인이 어느 주소로 풀리는지 본다"
dig +short auth.hyeonworks.com
```
**실측**(observed)
```text
100.83.212.4
```
**어디를 봐야 하는가** — 주소 한 줄. 그 주소가 공개 인터넷 대역인가, `100.64.0.0/10` 안인가.
**이 결과가 의미하는 것** — `100.64.0.0/10` 은 CGNAT 용으로 예약된 대역이라 공개 인터넷에서 라우팅 자체가 되지 않는다(external). 방화벽을 여는 문제가 아니라 그 주소가 인터넷에 존재하지 않고, Let's Encrypt 를 tailnet 에 초대할 방법도 없다. 그래서 HTTP-01 은 쓸 수 없고 DNS-01 을 쓴다. 여기서 공개 주소가 나오는 환경이라면 위 표의 왼쪽을 고르는 편이 낫다.
### 확인 ② nginx 판 번호는 몇인가
```bash label="[kc-lab-edge] 이 게스트의 nginx 판 번호"
nginx -v
```
**실측**(observed) — 같은 설정인데 배포판에서 갈린다.
```text
엣지 (Debian 12): nginx version: nginx/1.22.1
물리 호스트 (Arch): nginx version: nginx/1.30.4
```
**어디를 봐야 하는가** — `1.25.1` 이 경계다. 지금 셸의 번호가 그 위인가 아래인가.
**이 결과가 의미하는 것** — 1.25.1 미만이면 `http2 on;` 지시어가 없다. 5번의 설정처럼 `listen` 의 파라미터로 쓰면 1.22 와 1.30 양쪽에서 다 돈다. 지시어 형태로 쓰면 이렇게 막힌다.
```text
[emerg] 1287#1287: unknown directive "http2" in /etc/nginx/sites-enabled/keycloak-lab:14
nginx: configuration file /etc/nginx/nginx.conf test failed
```
## 실행 절차
### 1. certbot 과 Cloudflare 플러그인을 깐다
**목적** — DNS-01 검증을 할 수 있는 상태로 만든다.
```bash label="[kc-lab-edge] ① 두 패키지를 깐다"
sudo apt install -y certbot python3-certbot-dns-cloudflare
```
```bash label="[kc-lab-edge] ② 쓸 수 있는 검증 방식이 무엇인지 본다"
certbot plugins 2>/dev/null | grep -E '^\*'
```
**예상 결과**(observed)
```text
* dns-cloudflare
* standalone
* webroot
```
`^\*` 로 거른 것은 certbot 이 쓸 수 있다고 표시한 플러그인 앞에 별표를 붙이기 때문이다.
**왜 필요한가** — `dns-cloudflare` 가 이 목록에 없으면 4번의 발급이 `unrecognized arguments: --dns-cloudflare` 로 끝난다. 플러그인은 `certbot` 본체와 별개 패키지라 한쪽만 깔려 있어도 `certbot` 명령 자체는 돈다.
**문제가 생기면** — 목록에 없으면 ① 의 뒤엣것이 깔렸는지 본다. cloud-init 이 이미 깔았다면 이 단계를 건너뛴다.
### 2. Cloudflare 토큰을 발급받는다
**목적** — certbot 이 인증용 TXT 레코드를 직접 만들었다 지운다. 그래서 DNS 쓰기 권한이 필요하다.
브라우저에서 다섯 단계다.
1. `https://dash.cloudflare.com/profile/api-tokens` 에서 **Create Token**
2. **`Edit zone DNS`** 템플릿을 고르고 **Use template**
3. **Permissions** 는 `Zone` · `DNS` · `Edit` — 템플릿이 채워 준 그대로
4. **Zone Resources** 는 `Include` · `Specific zone` · **`hyeonworks.com`**
5. **Continue to summary** 다음 **Create Token**
**예상 결과** — 토큰 값이 화면에 한 번 나온다. 창을 닫으면 복구가 없으므로 바로 3번으로 넘어간다.
**왜 필요한가** — `All zones` 로 두면 계정의 모든 도메인에 대한 DNS 수정 권한이 엣지 게스트 안 평문 파일에 놓인다. Global API Key 는 더 나쁘다 — 계정 전체 만능 키라 폐기하면 그 키를 쓰던 다른 것들이 전부 같이 죽는다. 권한을 어디까지 좁히고 무엇을 감수했는지는 이 절차를 부른 결정이 갖고 있다.
**문제가 생기면** — 값을 놓쳤으면 다시 발급받는다. 이 문서에도 터미널에도 값을 적지 않는다.
### 3. 자격증명 파일을 만들고 권한부터 좁힌다
**목적** — certbot 이 읽을 자격증명을 root 만 읽을 수 있는 파일에 둔다.
```bash label="[kc-lab-edge] ① 빈 파일을 600 으로 먼저 만든다"
sudo install -m 600 /dev/null /etc/letsencrypt/cloudflare.ini
```
```bash label="[kc-lab-edge] ② 그다음에 토큰을 쓴다"
sudo nano /etc/letsencrypt/cloudflare.ini
```
③ 아래의 `{{CLOUDFLARE_API_TOKEN}}` 을 2번에서 화면에 한 번 나온 토큰 값으로 바꿔 쓴다. 발급받을 때마다 값이 달라서 여기 적을 수 없다. 저장은 `Ctrl+O` 다음 `Enter`, 나가기는 `Ctrl+X`.
```ini label="③ cloudflare.ini 에 쓸 내용"
# file: /etc/letsencrypt/cloudflare.ini
dns_cloudflare_api_token = {{CLOUDFLARE_API_TOKEN}}
```
```bash label="[kc-lab-edge] ④ 권한과 크기만 본다"
ls -l /etc/letsencrypt/cloudflare.ini
sudo wc -c /etc/letsencrypt/cloudflare.ini
```
```bash label="[kc-lab-edge] ⑤ 토큰이 살아 있고 권한 범위가 맞는지 묻는다"
CF=$(sudo awk -F'= *' '/api_token/{print $2}' /etc/letsencrypt/cloudflare.ini)
curl -s https://api.cloudflare.com/client/v4/user/tokens/verify -H "Authorization: Bearer $CF"
```
**예상 결과** — ④ 는 `-rw-------` 이고 바이트 수가 0 이 아니다. `sudo` 없이 `wc` 를 치면 `Permission denied` 가 나는데 그것이 정상이다. ⑤ 의 응답에는 `"status":"active"` 와 `"success":true` 가 들어 있다. 값 자체는 셸 변수에만 담기고 화면에 찍히지 않는다.
**왜 필요한가** — 두 명령의 순서가 그 자체로 보안 조치다. `install -m 600 /dev/null` 은 빈 파일을 `600` 으로 만드는 명령이고, 토큰을 먼저 쓰고 나서 권한을 고치면 그사이에 파일이 열려 있다. ⑤ 를 여기서 해 두면 뒤에서 실패했을 때 DNS 문제인지 토큰 문제인지를 헷갈리지 않는다.
**문제가 생기면** — 권한이 `-rw-r--r--` 면 같은 게스트의 다른 사용자도 토큰을 읽는다. 바이트 수가 0 이면 편집기에서 저장을 안 했거나 다른 경로에 썼다. ⑤ 의 응답에 `"code":6003` 이 보이면 토큰 값이 틀렸거나 잘렸고, `"code":9109` 면 권한 범위가 모자라다.
### 4. dry-run 을 먼저 돌리고 그다음에 발급한다
**목적** — 와일드카드를 포함한 인증서를 받는다. 실패를 시험용 한도에서 먼저 만난다.
```bash label="[kc-lab-edge] ① 먼저 시험한다"
sudo certbot certonly --dns-cloudflare \
--dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
-d hyeonworks.com -d '*.hyeonworks.com' --dry-run
```
```bash label="[kc-lab-edge] ② 실제로 받는다"
sudo certbot certonly --dns-cloudflare \
--dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
-d hyeonworks.com -d '*.hyeonworks.com'
```
```bash label="[kc-lab-edge] ③ 무엇을 받았는지 본다"
sudo certbot certificates
```
```bash label="[kc-lab-edge] ④ 인증서가 실제로 어떤 이름에 유효한지 본다"
sudo openssl x509 -noout -ext subjectAltName -in /etc/letsencrypt/live/hyeonworks.com/fullchain.pem
```
**예상 결과** — ① 의 마지막 줄이 `The dry run was successful.` 이고, ② 는 `Successfully received certificate.` 와 저장 경로를 찍는다. ③ 에서 볼 것은 네 줄이다.
| 줄 | 값 |
|---|---|
| `Domains:` | `hyeonworks.com *.hyeonworks.com` — 한 줄에 둘 다 |
| `Expiry Date:` | 오늘 + 90일, `VALID` |
| `Certificate Path:` | `/etc/letsencrypt/live/hyeonworks.com/fullchain.pem` |
| `Private Key Path:` | `/etc/letsencrypt/live/hyeonworks.com/privkey.pem` |
④ 는 `DNS:hyeonworks.com, DNS:*.hyeonworks.com` 두 개를 내놓고 `auth.hyeonworks.com` 은 두 번째에 걸린다.
**왜 필요한가** — dry-run 을 먼저 도는 것은 Let's Encrypt 의 주당 중복 인증서 5장 한도를 dry-run 이 쓰지 않기 때문이다(external). dry-run 은 스테이징 서버에 대고 시험만 하므로 `/etc/letsencrypt/live/` 에는 아무것도 안 생기고, 그 직후 `certbot certificates` 가 `No certificates found` 를 내는 것이 정상이다. `-d` 를 둘 주는 것은 와일드카드가 한 단계만 덮기 때문이고, `a.b.hyeonworks.com` 도 apex 인 `hyeonworks.com` 자신도 `*.hyeonworks.com` 에 들어가지 않는다.
디렉터리 이름과 인증서가 덮는 이름은 별개다(observed). 여기서 가장 많이 헷갈린다.
| 무엇 | 정해지는 방식 |
|---|---|
| 디렉터리 이름 `live/hyeonworks.com/` | certbot 이 이 묶음을 관리하려고 붙인 라벨. 첫 번째 `-d` 에서 따오고 서빙과 무관하다 |
| SAN 목록 | 브라우저가 보는 실제 유효 호스트명. `-d` 로 준 이름 전부 |
**문제가 생기면** — 최초 실행이면 계정 등록 대화가 먼저 뜬다. 이메일을 비우면 `Invalid email address: .` 로 되묻고, 약관은 `Y`, 뉴스레터는 발급과 무관하므로 `N` 이다. 프롬프트 없이 돌리려면 `-m <이메일> --agree-tos --no-eff-email` 을 붙인다. `--register-unsafely-without-email` 은 쓰지 않는다 — 갱신 실패를 알려 줄 통로가 사라지는데, 6번이 재는 것이 바로 그 갱신이다. DNS-01 은 TXT 레코드가 퍼질 때까지 기다리느라 수십 초 걸리므로 중간에 끊지 않는다.
### 5. nginx 에 443 을 얹는다
**목적** — 앞 단계에서는 80 만 세웠다. 인증서가 생겼으니 443 블록을 더하고 80 은 리다이렉트로 바꾼다.
```bash label="[kc-lab-edge] ① 앞 단계에서 쓴 그 파일을 연다"
sudo nano /etc/nginx/sites-available/keycloak-lab
```
```nginx label="② keycloak-lab 을 이 내용으로 바꾼다"
# file: /etc/nginx/sites-available/keycloak-lab
upstream k3s_traefik {
server 192.168.122.11:80;
server 192.168.122.12:80;
}
server {
listen 80 default_server;
server_name _;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2 default_server;
server_name _;
ssl_certificate /etc/letsencrypt/live/hyeonworks.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/hyeonworks.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
location / {
proxy_pass http://k3s_traefik;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Port 443;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}
```
```bash label="[kc-lab-edge] ③ 앞 단계에서 쓴 그 두 줄로 다시 읽힌다"
sudo nginx -t && sudo systemctl reload nginx
```
**예상 결과** — `syntax is ok` 와 `test is successful` 두 마디가 나오고 reload 가 돈다. 앞에 붙는 `types_hash` 경고는 통과를 막지 않는다.
**왜 필요한가** — 앞 단계의 파일에서 바뀐 곳이 셋이다.
| 줄 | 앞 단계에서는 | 지금 |
|---|---|---|
| 80 블록 | `location / { proxy_pass … }` | 리다이렉트만 |
| 443 블록 | 없었다 | 인증서와 함께 새로 |
| `X-Forwarded-Proto` | `http` | `https` |
`ssl_certificate` 에 적는 것은 `cert.pem` 이 아니라 `fullchain.pem` 이다. 서버 인증서만 보내면 중간 인증서가 빠져 체인이 끊기는데, 브라우저는 대개 캐시나 AIA 로 보완해서 정상으로 보이고 캐시가 없는 클라이언트에서만 깨지므로 발견이 늦다. 4번 ③ 에서 certbot 이 찍어 준 경로와 한 글자도 다르면 안 된다.
**문제가 생기면** — `cannot load certificate` 로 막히면 경로를 본다. `live/auth.hyeonworks.com/` 이라고 적으면 그 디렉터리가 없다. 확인 ② 에서 본 판 번호가 1.25.1 미만인데 `http2` 를 지시어로 썼다면 여기서 `unknown directive "http2"` 가 나온다.
### 6. 갱신이 서빙까지 닿게 한다
**목적** — 타이머가 도는 것만으로는 부족하다. 갱신된 인증서를 누가 읽게 만드는 일까지 세운다. 여기가 이 단계의 알맹이다.
```bash label="[kc-lab-edge] ① 타이머가 있는지 본다"
systemctl list-timers certbot-renew.timer
```
```bash label="[kc-lab-edge] ② 표가 비어 나오면 이름이 다른지 찾는다"
systemctl list-timers --all | grep -i certbot
```
```bash label="[kc-lab-edge] ③ 배포판 기본 유닛이 reload 를 부르는지 본다"
systemctl cat certbot-renew.service
```
```text
[Service]
Type=oneshot
ExecStart=/usr/bin/certbot -q renew
PrivateTmp=true
```
```bash label="[kc-lab-edge] ④ 훅 스크립트를 연다"
sudo nano /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
```
```sh label="⑤ reload-nginx.sh 에 쓸 내용"
# file: /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
#!/bin/sh
nginx -t && nginx -s reload
```
```bash label="[kc-lab-edge] ⑥ 실행 권한을 준다"
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
```
```bash label="[kc-lab-edge] ⑦ 실행 파일이 됐는지 본다"
ls -l /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
```
**예상 결과** — ① 은 `NEXT` 와 `LEFT` 가 채워져 있고 `ACTIVATES` 가 `certbot-renew.service` 를 가리킨다. ③ 에는 `ExecStartPost` 도 `--deploy-hook` 도 없고, 여기 없는 것을 보는 것이 그 명령의 목적이다. ⑦ 의 권한 문자열에는 `x` 가 세 번 보인다.
**왜 필요한가** — 타이머가 `active` 여도 갱신된 인증서가 서빙되지는 않는다. nginx 는 인증서를 기동 시점에 읽어 메모리에 들고 있고 certbot 은 `live/` 심볼릭 링크만 갈아 끼우므로, 설정에 적힌 경로는 그대로이고 그 경로가 가리키는 파일만 바뀌어 nginx 에는 다시 읽을 계기가 생기지 않는다. 배포판 기본 유닛은 인증서를 새로 받는 데까지만 책임지고 받은 것을 누가 읽게 만드는 일은 아무도 하지 않는다. 훅이 그 둘을 잇는다. 훅을 `post/` 가 아니라 `deploy/` 에 넣는 것은 `post/` 가 갱신이 없어도 매번 돌아 하루 두 번 워커를 갈아치우기 때문이고, `deploy/` 는 실제로 갱신됐을 때만 실행된다. 실행 권한이 없으면 certbot 이 이 훅을 조용히 건너뛴다.
**문제가 생기면** — ③ 에 무엇인가 적혀 있는 배포판이라면 이 훅은 필요 없고, 대신 그 명령이 nginx 를 reload 하는지만 확인한다. 갱신은 됐는데 옛 인증서가 나가면 ⑦ 부터 다시 본다.
## 구성 값
이 훅이 있고 없고의 차이를 이 실험대가 쟀다(observed).
| 무엇 | 훅 없음 | 훅 있음 |
|---|---|---|
| 갱신에서 서빙까지 | 2305초 (38분 25초) | 1~2초 |
| 무엇이 reload 했나 | 사람이 직접 | certbot deploy 훅 |
| 아무도 안 했다면 | 다음 nginx 재시작까지 = 사실상 무기한 | — |
88일 동안 이 결함이 보이지 않는다. 타이머는 정상이고 매번 `SUCCESS` 로 끝나며, 만료 30일 전까지는 갱신 자체를 하지 않아 발현할 기회가 없다. 발현하는 날의 증상은 인증서 만료이고, 그날에도 로그에는 `SUCCESS` 라고 적혀 있다.
reload 자체는 무중단이었다(observed). 새 연결 8856건 전부 200 이었고 p95 는 205.7ms 대 204.3ms 로 변화가 없었으며, 845KB 를 받는 중이던 요청이 전송 12초째에 reload 를 맞고도 845361바이트를 온전히 받았다. 옛 워커가 그 요청을 끝까지 책임진다.
## 끝났는지 판정한다
세 확인은 tailnet 에 붙은 다른 머신에서 친다. 1번부터 6번까지는 전부 엣지에서 쳤지만 판정은 밖에서 들어와야 의미가 있다.
### 확인 ① 열리나 — 처음 한 번은 협상 과정을 읽는다
**무엇을 확인하는가** — TLS 가 붙었는지. 응답 코드가 아니다.
```bash label="[워크스테이션] 협상 과정을 그대로 읽는다"
curl -v https://auth.hyeonworks.com/ -o /dev/null
```
경로는 `/` 다. Keycloak 은 다음 단계에서 올리므로 아직 Ingress 가 없고 `404` 가 정상이다. `/realms/master` 같은 Keycloak 경로를 여기서 쓰면 TLS 가 안 된 건지 Keycloak 이 없는 건지가 섞인다.
읽어야 할 줄만 옮긴다. 전체 출력은 이 실험대에서 캡처해 두지 않았다(unknown).
```text
* SSL connection using TLSv1.3 / ...
* subject: CN=hyeonworks.com
* issuer: C=US; O=Let's Encrypt; CN=...
* SSL certificate verify ok.
< HTTP/1.1 404 Not Found
```
**어디를 봐야 하는가** — 별표로 시작하는 줄 넷이다. 어떤 TLS 판으로 협상했는가, `subject` 의 CN 이 무엇인가, `issuer` 가 Let's Encrypt 인가, 그리고 `SSL certificate verify ok.` 가 있는가. 그 아래 `<` 로 시작하는 첫 줄이 응답 상태다. `subject` 가 `hyeonworks.com` 인 것이 맞다 — 와일드카드 인증서라 CN 은 apex 이름이고 `auth.hyeonworks.com` 은 SAN 의 `*.hyeonworks.com` 에 걸린다.
**이 결과가 의미하는 것** — 네 줄이 다 나오면 인증서가 붙었고 체인이 클라이언트 기준으로 검증됐다. 인증서를 처음 붙인 직후에는 코드 한 칸이 아니라 이 화면을 본다. `verify` 줄 대신 `unable to get local issuer certificate` 가 나오면 중간 인증서가 빠진 것이고, 원인은 5번의 인증서 파일 이름이라 확인 ② 로 간다.
같은 것을 반복해서 재거나 앞뒤 단계의 값과 나란히 비교할 때만 값만 뽑는 형태로 줄인다.
```bash label="[워크스테이션] 코드와 검증 결과만 뽑는다"
curl -s -o /dev/null -w '%{http_code} tls=%{ssl_verify_result}\n' https://auth.hyeonworks.com/
```
**실측**(observed) — 2026-09-11, tailnet 클라이언트에서
```text
404 tls=0
```
`tls=0` 이 인증서 검증 통과다. 앞의 `404` 는 다음 단계 이후에 `200` 으로 바뀐다.
### 확인 ② 체인 단계와 검증
**무엇을 확인하는가** — 서버가 중간 인증서까지 보내는지.
```bash label="[워크스테이션] 체인 단계를 센다"
echo | openssl s_client -connect auth.hyeonworks.com:443 \
-servername auth.hyeonworks.com 2>/dev/null \
| grep -E '^ *[0-9]+ s:|^ *i:|Verify return code'
```
**실측**(observed) — 이름을 따로 받던 시절의 기록이라 0번 `s:` 가 `auth.hyeonworks.com` 이다. 와일드카드로 받으면 `CN = hyeonworks.com` 이 되고, 봐야 할 구조는 똑같다.
```text
0 s:CN = auth.hyeonworks.com
i:C = US, O = Let's Encrypt, CN = YE2
1 s:C = US, O = Let's Encrypt, CN = YE2
i:C = US, O = ISRG, CN = Root YE
2 s:C = US, O = ISRG, CN = Root YE
i:C = US, O = Internet Security Research Group, CN = ISRG Root X2
3 s:C = US, O = Internet Security Research Group, CN = ISRG Root X2
i:C = US, O = Internet Security Research Group, CN = ISRG Root X1
Verify return code: 0 (ok)
```
**어디를 봐야 하는가** — 왼쪽의 번호가 몇까지 가는가, 그리고 각 단계의 발급자 줄이 다음 단계의 주체 줄과 같은가. 마지막이 `Verify return code: 0 (ok)` 인가.
**이 결과가 의미하는 것** — 단계가 1개면 `cert.pem` 을 쓴 것이다. 서버가 자기 인증서만 보내고 중간 인증서를 안 보낸 상태인데, 이때 브라우저는 대개 캐시나 AIA 로 보완해서 정상으로 보이므로 이 명령이 유일하게 믿을 수 있는 판정이 된다. 고치는 곳은 5번의 `ssl_certificate` 한 줄이고, 고친 뒤 문법 검사와 reload 를 하고 여기서 다시 잰다. `Verify return code` 가 0 이 아니면 숫자마다 뜻이 다르다 — `10` 은 만료, `20` 은 발급자를 못 찾음, `21` 은 첫 인증서를 검증 못 함.
### 확인 ③ 이름 세 개가 한 인증서인가
**무엇을 확인하는가** — 이름마다 다른 인증서가 붙어 있지는 않은지.
```bash label="[워크스테이션] 세 이름의 일련번호를 나란히 뽑는다"
for H in auth app1 app2; do
echo | openssl s_client -connect $H.hyeonworks.com:443 -servername $H.hyeonworks.com 2>/dev/null \
| openssl x509 -noout -serial
done
```
**어디를 봐야 하는가** — 찍히는 세 줄의 일련번호가 서로 같은가. 값 자체에는 뜻이 없고 셋이 일치하는지만 본다.
**이 결과가 의미하는 것** — 셋이 같으면 SAN 하나에 이름 셋이 들어 있는 인증서 한 장이고, 갱신도 한 번에 끝난다. 다르면 인증서가 여러 장이라 갱신 훅도 장마다 따로 돌고, 한 장만 갱신됐을 때 나머지 이름이 만료되는 상황이 생긴다. 이름별로 무엇이 실려 있는지 보려면 SAN 을 직접 편다.
```bash label="[워크스테이션] 지금 서빙되는 인증서의 SAN 을 편다"
echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \
| openssl x509 -noout -ext subjectAltName
```
### 확인 ④ 갱신이 서빙까지 닿는가 — 워커 PID 로 본다
**무엇을 확인하는가** — 훅이 호출되는지, 그리고 호출된 훅이 nginx 를 정말 갈아 끼웠는지.
```bash label="[kc-lab-edge] 훅이 불리는지 먼저 본다. 상태를 바꾸지 않는다"
sudo certbot renew --dry-run
```
출력 끝의 `Running deploy-hook command` 줄과 `simulated renewals` 요약을 본다. 훅 줄이 아예 안 나오면 파일이 `deploy/` 가 아닌 곳에 있거나 실행 권한이 없다. dry-run 은 훅이 호출되는지까지만 말해 준다.
```bash label="[kc-lab-edge] 강제 갱신 전후로 워커를 비교한다. 이 확인은 상태를 바꾼다"
# 강제 갱신 전에 워커 PID 를 적어 둔다
ps -eo pid,lstart,args | grep 'nginx: worker' | grep -v grep
sudo certbot renew --force-renewal
# 워커 PID 가 바뀌었으면 reload 된 것이다
ps -eo pid,lstart,args | grep 'nginx: worker' | grep -v grep
```
`--force-renewal` 은 인증서를 실제로 새로 받으므로 발급 한도를 깎는다. 진짜 판정이 필요할 때만 한 번 쓴다.
**어디를 봐야 하는가** — 두 출력의 첫 열과 둘째 열 묶음이다. 앞뒤로 놓고 PID 집합이 통째로 바뀌었는지만 본다. `lstart` 를 같이 뽑는 것은 PID 가 우연히 재사용됐을 때를 가르기 위해서다. 마스터 프로세스는 그대로이고 워커만 갈리는 쪽이 정상이다.
**이 결과가 의미하는 것** — PID 가 바뀌었으면 훅이 돌아 nginx 가 새 인증서를 읽었다. 안 바뀌었으면 인증서는 갱신됐는데 서빙되는 것은 옛것이다. 판정은 로그 문구가 아니라 워커 PID 로 한다 — certbot 이 `Hook 'deploy-hook' ran with error output` 이라고 찍는데 실패가 아니다. nginx 의 `types_hash` 경고가 stderr 로 나갔을 뿐이고 내용은 `test is successful` 과 `signal process started` 다. 로그에서 `error` 를 grep 하는 감시를 걸면 성공한 훅을 실패로 오독한다.
## 통과 조건을 한 번에 다시 본다
| 무엇 | 명령 | 통과 |
|---|---|---|
| 열리는가 | `curl -v https://auth.hyeonworks.com/ -o /dev/null` | `SSL certificate verify ok.` |
| 검증값 | `curl -s -o /dev/null -w '%{http_code} tls=%{ssl_verify_result}\n' …` | `404 tls=0` |
| 체인 | `openssl s_client … \| grep …` | 번호가 3까지 · `Verify return code: 0 (ok)` |
| 이름 셋 | `for H in auth app1 app2; …` | 일련번호 세 줄이 같다 |
| 서빙까지 | 강제 갱신 앞뒤의 `ps … 'nginx: worker'` | 워커 PID 집합이 바뀐다 |
## 막히면
| 증상 | 원인 | 확인 |
|---|---|---|
| certbot 검증 실패 | DNS 가 이 호스트를 안 가리킴 · 80 이 막힘 | `dig +short auth.hyeonworks.com` · 밖에서 `curl -I http://auth.hyeonworks.com` |
| 체인 단계가 1개 | `cert.pem` 을 씀 | 확인 ② |
| 갱신은 됐는데 옛 인증서가 나감 | deploy 훅 없음 | 워커 PID · `sudo ls /etc/letsencrypt/renewal-hooks/deploy/` |
| 훅이 실패한 것처럼 보임 | stderr 경고를 error 로 표시 | 문구 말고 워커 PID |
| 발급 한도 | 주당 중복 인증서 5장 | `--dry-run` 으로 먼저 시험 |
| `unrecognized arguments: --dns-cloudflare` | 플러그인 미설치 | `certbot plugins \| grep '^\*'` |
| 토큰 응답이 `"code":6003` · `"code":9109` | 토큰 값이 잘렸거나 권한 범위가 좁다 | Cloudflare 에서 토큰을 다시 발급한다 |
| DNS-01 이 오래 걸림 | TXT 전파 대기 | 정상이다. 끊지 않는다 |
| 재구축 뒤 인증서가 없음 | 발급하지 말고 백업을 되돌린다 | 한도를 아끼는 길이다 |
| `cannot load certificate` | 설정에 lineage 디렉터리 이름을 잘못 적었다 | `sudo certbot certificates` 의 `Certificate Path:` 와 대조 |
| 엣지에서 친 `curl` 이 `Connection refused` | 친 곳이 틀렸다 | tailnet 에 붙은 다른 머신에서 다시 친다 |
## 무엇이 관측이고 무엇이 아닌가
- (observed) `dig` 가 낸 `100.83.212.4`, `certbot plugins` 세 줄, `nginx -v` 두 줄, 체인 4단계와 `Verify return code: 0 (ok)`, `404 tls=0`, 배포판 기본 유닛에 훅이 없다는 것, 갱신에서 서빙까지 2305초 대 1~2초, reload 무중단 측정값.
- (external) `100.64.0.0/10` 이 CGNAT 예약 대역이라는 것과 Let's Encrypt 의 발급 한도와 유효기간 90일은 규격이고 이 실험대가 잰 값이 아니다.
- (external) Cloudflare 계정에 남는 토큰은 엣지를 지운다고 없어지지 않는다. 가이드는 폐기를 적지 않았다.
- (inferred) 2305초 측정은 훅이 물리 호스트에만 있던 시절의 기록이라, 엣지 게스트에서 다시 잰 값이 아니다. 지금 배치에서 같은 수가 나오는지는 재지 않았다.
- (unknown) 체인 실측은 이름을 따로 받던 시절 것이고, 와일드카드로 받은 지금의 출력은 싣지 않았다.
- (unknown) `curl -v` 의 전체 출력, `certbot certificates` 의 실제 화면, 타이머 목록의 출력, 확인 ③ 의 일련번호 세 줄은 가이드가 봐야 할 줄만 적었고 값이 남아 있지 않다.
- (unknown) 원본 가이드 04 에 되돌리는 절차가 없다. 패키지와 자격증명과 인증서와 훅과 타이머와 443 블록을 걷어내 본 적이 없다.
- (unknown) 이 단계에는 「이 실험대는 이렇게 했다」와 「따라 하는 사람은」 두 갈래가 없다. 04 는 설정 파일 셋을 전부 편집기로 쓴다.
<!-- body:end -->