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
+16 -4
View File
@@ -5,7 +5,7 @@
## 범위
가상화 전반을 담을 프로젝트다. SSOT 는 부이고 절 번호가 문서 전체에서 이어진다.
가상화 전반을 담을 프로젝트다. SSOT 는 아홉 부이고 절 번호가 문서 전체에서 이어진다.
| 부 | 절 | 반입 전 원본 |
|---|---|---|
@@ -13,10 +13,22 @@
| 제2부 — 메모리 가상화 | §29~§88 | `kvm-memory-virtualization-ssot.md` (절 1~60) |
| 제3부 — 네트워크 가상화 | §89~§126 | `kvm-network-virtualization-ssot.md` (절 1~38) |
| 제4부 — 스토리지 가상화 | §127~§177 | `kvm-storage-virtualization-ssot.md` (절 0~50) |
| 제5부 — 실험대에서 실제로 확인한 것 | §178~§183 | `docs/guides/` · `docs/lab-virtualization.md` · `docs/session-lab-concepts.md` 에서 골라 다시 씀 |
| 제6부 — 실험대는 어떻게 세워졌나 | §184~§194 | `docs/guides/` 00~06 (묶음 3,223줄)을 다시 씀 |
| 제7부 — 실험대에서 실제로 잰 값 | §195~§205 | `docs/lab-virtualization.md` (496줄) 전문 |
| 제8부 — 설정 원본이 자기 안에 적어 둔 것 | §206~§210 | `deploy/lab/edge/` 네 파일의 주석 59줄 |
| 제9부 — 실험대 개념 사전 | §211~§338 | `docs/session-lab-concepts.md` (4,725줄) 전문 |
반입할 때 heading 단계를 한 칸 내리고 절 번호만 옮겼다. heading 이 아닌 줄은 한 글자도
바꾸지 않았고, 원본과 대조해 확인했다. PCIe/VFIO/IOMMU 상세와 K3s 네트워크·컨테이너
런타임 상세는 어느 부에도 없다.
**제1~4부**는 반입할 때 heading 단계를 한 칸 내리고 절 번호만 옮겼다. heading 이 아닌
줄은 한 글자도 바꾸지 않았고, 원본과 대조해 확인했다. PCIe/VFIO/IOMMU 상세와 K3s
네트워크·컨테이너 런타임 상세는 어느 부에도 없다.
**제7·9부**도 같은 방식인데 heading 단계를 한 칸 **올렸다** — 실제 항목이 사는 단계를
절로 삼았다. 두 부 모두 heading 이 아닌 줄을 원본과 줄 단위로 대조했고, 다른 곳은
비밀 한 자리뿐이다(`plain_text_passwd` 의 값을 `__CONSOLE_PW__` 로 바꿨다).
**제5·6·8부**는 옮겨 쓴 것이 아니라 원본을 읽고 다시 쓴 것이다. 제8부만 원본 주석을
영어 원문 그대로 인용한다.
## 끝난 프로젝트의 폴더
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,289 @@
{
"version": "1.1",
"id": "nftables-forward-hook-chain-order",
"title": "같은 forward 훅에 붙은 base 체인 둘이 우선순위 순으로 이어서 평가되고, 앞 체인의 accept 는 뒤 체인의 reject 를 막지 못한다",
"question": "밖에서 온 packet 하나가 forward 훅에서 어느 규칙을 어떤 순서로 지나 어디서 끝났고, insert 로 넣은 구멍은 그 순서의 어디에 들어가는가",
"type": "network",
"direction": "LR",
"audience": [
"iptables 감각으로 nftables 규칙을 쓰다 밖에서만 막히는 것을 보는 사람",
"libvirt NAT 뒤의 게스트에 밖에서 들어오는 경로를 여는 사람"
],
"summary": "밖에서 온 packet 은 priority filter - 10 인 forward 체인의 ct state new accept 를 지나고도 평가가 끝나지 않아 libvirt_network guest_input 으로 이어지고, 거기서 체인 끝 reject 에 닿아 connection refused 가 되며, insert 로 넣은 구멍은 같은 체인의 맨 앞에 서서 같은 packet 을 엣지 nginx 로 보낸다.",
"alt": "밖에서 온 packet 이 forward 훅에 붙은 base 체인 둘을 우선순위 순으로 지나는 경로도. 앞 체인의 accept 뒤에 libvirt guest_input 이 이어지고, 구멍을 넣기 전 경로는 그 체인 끝 reject 로, 넣은 뒤 경로는 체인 맨 앞의 구멍을 지나 엣지 nginx 로 갈라진다.",
"long_description": "왼쪽에서 오른쪽으로 읽는다. 왼쪽 끝이 밖에서 친 curl 이 만든 inbound packet 이고 forward 훅으로 들어간다. 첫 점선 상자가 DNAT 파일에 둔 base 체인이다. priority filter - 10 이라 먼저 돌고, 그 안의 ct state new accept 가 이 packet 을 통과시킨다. 다음 점선 상자가 libvirt 가 만든 ip libvirt_network 테이블의 guest_input 체인이다. 앞 체인의 accept 는 평가를 끝내지 않으므로 같은 packet 이 이 체인으로 이어진다. 그 안의 상자 둘은 같은 체인의 맨 앞과 맨 끝이다. 구멍을 넣기 전에는 맨 앞이 비어 있어 packet 이 ct state established,related accept 에 걸리지 못한 채 체인 끝 reject 에 닿았고 connection refused 로 끝났다. 그 reject 규칙의 카운터가 4 패킷 240 바이트다. insert 로 구멍을 넣으면 그 규칙이 맨 앞에 서서 같은 packet 을 먼저 받아 192.168.122.10 의 엣지 nginx 로 보낸다. 실선이 구멍을 넣은 뒤의 경로이고 점선이 넣기 전의 경로다. 이 그림은 그 규칙이 libvirt 네트워크를 다시 세우면 사라진다는 것은 말하지 않는다.",
"source_context": {
"document": "docs/virtualization/final/document.md",
"document_sha256": "60d902c7aed218ba637b48603a3bd0e6b193fea59c9de97ffdb8e05d5087aedd",
"anchor": {
"kind": "heading",
"value": "180. nftables 는 앞 체인의 `accept` 로 뒤 체인의 `reject` 를 막지 못한다",
"line": 7805
}
},
"composition": {
"profile": "component-flow",
"diagram_only": true,
"reference_ids": [
"payment-event-flow"
],
"rationale": "이 절이 세운 것은 방향이 있는 경로 하나다 — 밖에서 온 packet 이 base 체인 둘을 우선순위 순으로 지나 두 결말 가운데 하나에 닿는다. 두 체인은 소유가 다른 실제 containment 라 groups 로 둘렀다. comparison 은 관계선을 지워도 뜻이 남는 자리의 문법인데 여기서는 지우면 「앞 체인의 accept 를 지나고도 뒤 체인에 닿는다」가 통째로 사라져 규칙 목록만 남는다. sequence 는 주고받는 참가자가 둘 이상일 때의 문법이고 여기서 움직이는 것은 packet 하나뿐이다.",
"focus_node": "tail-reject"
},
"groups": [
{
"id": "our-forward-chain",
"label": "forward · priority filter - 10",
"kind": "system",
"role": "zone",
"description": "DNAT 를 정의한 파일에 둔 base 체인. 같은 훅에서 libvirt 체인보다 먼저 돈다.",
"evidence": [
{
"start_line": 7829,
"end_line": 7830
}
],
"assumption": false
},
{
"id": "libvirt-guest-input",
"label": "ip libvirt_network · guest_input",
"kind": "system",
"role": "zone",
"description": "libvirt 가 자기 테이블 안에 만드는 base 체인. 같은 훅에서 뒤에 돈다.",
"evidence": [
{
"start_line": 7818,
"end_line": 7824
}
],
"assumption": false
}
],
"nodes": [
{
"id": "inbound-packet",
"label": "inbound packet",
"kind": "packet",
"role": "source",
"shape": "box",
"details": [
"curl http://100.83.212.4"
],
"description": "밖에서 친 curl 이 만든 packet. 호스트가 직접 듣는 리스너가 없어 FORWARD 로 간다.",
"evidence": [
{
"start_line": 7814,
"end_line": 7814
},
{
"start_line": 7809,
"end_line": 7809
}
],
"assumption": false
},
{
"id": "our-accept",
"label": "ct state new accept",
"kind": "rule",
"role": "service",
"shape": "box",
"group": "our-forward-chain",
"description": "우리가 먼저 돌게 해 둔 체인의 규칙. 이 체인은 통과시키지만 평가를 끝내지는 않는다.",
"evidence": [
{
"start_line": 7829,
"end_line": 7833
}
],
"assumption": false
},
{
"id": "inserted-accept",
"label": "inserted accept",
"kind": "rule",
"role": "service",
"shape": "box",
"group": "libvirt-guest-input",
"emphasis": "primary",
"details": [
"nft insert rule",
"daddr 192.168.122.10",
"tcp dport {80,443}"
],
"description": "insert 로 guest_input 맨 앞에 넣은 구멍. add 로 넣으면 맨 뒤라 reject 뒤에 선다.",
"evidence": [
{
"start_line": 7835,
"end_line": 7840
}
],
"assumption": false
},
{
"id": "tail-reject",
"label": "reject",
"kind": "rule",
"role": "service",
"shape": "box",
"group": "libvirt-guest-input",
"emphasis": "primary",
"details": [
"chain tail",
"counter packets 4",
"bytes 240"
],
"description": "guest_input 체인을 끝내는 규칙. 앞의 ct state established,related accept 에 걸리지 못한 packet 이 여기 닿는다. 이 카운터가 밖에서 친 curl 횟수와 일치해 범인 확정에 쓰였다.",
"evidence": [
{
"start_line": 7821,
"end_line": 7823
},
{
"start_line": 7826,
"end_line": 7827
}
],
"assumption": false
},
{
"id": "edge-nginx",
"label": "edge nginx",
"kind": "service",
"role": "sink",
"shape": "box",
"details": [
"192.168.122.10"
],
"description": "게스트에서 도는 엣지. 호스트에서 친 요청에는 이미 404 로 응답하고 있었다.",
"evidence": [
{
"start_line": 7813,
"end_line": 7813
},
{
"start_line": 7839,
"end_line": 7840
}
],
"assumption": false
},
{
"id": "connection-refused",
"label": "connection refused",
"kind": "result",
"role": "sink",
"shape": "box",
"description": "drop 이 아니라 reject 라 기다리지 않고 즉시 돌아온 결과.",
"evidence": [
{
"start_line": 7814,
"end_line": 7814
},
{
"start_line": 7816,
"end_line": 7816
}
],
"assumption": false
}
],
"edges": [
{
"id": "packet-to-our-chain",
"from": "inbound-packet",
"to": "our-accept",
"label": "forward hook",
"kind": "data",
"style": "solid",
"evidence": [
{
"start_line": 7814,
"end_line": 7814
},
{
"start_line": 7829,
"end_line": 7830
}
],
"assumption": false
},
{
"id": "our-chain-after-insert",
"from": "our-accept",
"to": "inserted-accept",
"label": "after insert",
"kind": "data",
"style": "solid",
"evidence": [
{
"start_line": 7831,
"end_line": 7833
},
{
"start_line": 7835,
"end_line": 7836
}
],
"assumption": false
},
{
"id": "our-chain-before-insert",
"from": "our-accept",
"to": "tail-reject",
"label": "before insert",
"kind": "data",
"style": "dashed",
"evidence": [
{
"start_line": 7831,
"end_line": 7833
},
{
"start_line": 7821,
"end_line": 7823
}
],
"assumption": false
},
{
"id": "inserted-to-edge",
"from": "inserted-accept",
"to": "edge-nginx",
"label": "accept",
"kind": "result",
"style": "solid",
"evidence": [
{
"start_line": 7835,
"end_line": 7840
}
],
"assumption": false
},
{
"id": "reject-to-refused",
"from": "tail-reject",
"to": "connection-refused",
"label": "reject",
"kind": "result",
"style": "dashed",
"evidence": [
{
"start_line": 7823,
"end_line": 7823
},
{
"start_line": 7814,
"end_line": 7816
}
],
"assumption": false
}
],
"legend": [],
"metadata": {
"rationale": "network 으로 고른 이유는 이 절이 답하는 물음이 「packet 하나가 어느 규칙을 어떤 순서로 지나는가」이기 때문이다. 상자는 체인이 아니라 규칙 하나씩이다 — 체인을 상자로 두면 「앞 체인 accept 다음에 뒤 체인」이라는 자리 관계가 안 보이고, 그 자리가 이 사건의 전부다. 실선은 구멍을 넣은 뒤의 경로이고 점선은 넣기 전의 경로다. 색이 아니라 선 모양으로 갈랐다. guest_input 의 ct state established,related accept 는 상자로 세우지 않았다 — 세우면 그 체인 그룹이 두 칸을 차지하고 bbox 가 그룹 밖인 엣지 nginx 를 삼켜 엣지가 libvirt 체인 안에 있는 것처럼 보였다(렌더 확인). 그 규칙의 원문은 기록 본문 코드블록에 그대로 있다. ExecStartPost 로 규칙을 되살리는 수명은 시간 축이라 본문에 두었다."
}
}
@@ -0,0 +1,28 @@
# 같은 forward 훅에 붙은 base 체인 둘이 우선순위 순으로 이어서 평가되고, 앞 체인의 accept 는 뒤 체인의 reject 를 막지 못한다
## Alternative text
밖에서 온 packet 이 forward 훅에 붙은 base 체인 둘을 우선순위 순으로 지나는 경로도. 앞 체인의 accept 뒤에 libvirt guest_input 이 이어지고, 구멍을 넣기 전 경로는 그 체인 끝 reject 로, 넣은 뒤 경로는 체인 맨 앞의 구멍을 지나 엣지 nginx 로 갈라진다.
## Long description
왼쪽에서 오른쪽으로 읽는다. 왼쪽 끝이 밖에서 친 curl 이 만든 inbound packet 이고 forward 훅으로 들어간다. 첫 점선 상자가 DNAT 파일에 둔 base 체인이다. priority filter - 10 이라 먼저 돌고, 그 안의 ct state new accept 가 이 packet 을 통과시킨다. 다음 점선 상자가 libvirt 가 만든 ip libvirt_network 테이블의 guest_input 체인이다. 앞 체인의 accept 는 평가를 끝내지 않으므로 같은 packet 이 이 체인으로 이어진다. 그 안의 상자 둘은 같은 체인의 맨 앞과 맨 끝이다. 구멍을 넣기 전에는 맨 앞이 비어 있어 packet 이 ct state established,related accept 에 걸리지 못한 채 체인 끝 reject 에 닿았고 connection refused 로 끝났다. 그 reject 규칙의 카운터가 4 패킷 240 바이트다. insert 로 구멍을 넣으면 그 규칙이 맨 앞에 서서 같은 packet 을 먼저 받아 192.168.122.10 의 엣지 nginx 로 보낸다. 실선이 구멍을 넣은 뒤의 경로이고 점선이 넣기 전의 경로다. 이 그림은 그 규칙이 libvirt 네트워크를 다시 세우면 사라진다는 것은 말하지 않는다.
## Elements and evidence
- **Boundary: forward · priority filter - 10** (system): DNAT 를 정의한 파일에 둔 base 체인. 같은 훅에서 libvirt 체인보다 먼저 돈다. Evidence: L7829L7830.
- **Boundary: ip libvirt_network · guest_input** (system): libvirt 가 자기 테이블 안에 만드는 base 체인. 같은 훅에서 뒤에 돈다. Evidence: L7818L7824.
- **inbound packet** (packet): 밖에서 친 curl 이 만든 packet. 호스트가 직접 듣는 리스너가 없어 FORWARD 로 간다. Evidence: L7814L7814, L7809L7809.
- **ct state new accept** (rule): 우리가 먼저 돌게 해 둔 체인의 규칙. 이 체인은 통과시키지만 평가를 끝내지는 않는다. Evidence: L7829L7833.
- **inserted accept** (rule): insert 로 guest_input 맨 앞에 넣은 구멍. add 로 넣으면 맨 뒤라 reject 뒤에 선다. Evidence: L7835L7840.
- **reject** (rule): guest_input 체인을 끝내는 규칙. 앞의 ct state established,related accept 에 걸리지 못한 packet 이 여기 닿는다. 이 카운터가 밖에서 친 curl 횟수와 일치해 범인 확정에 쓰였다. Evidence: L7821L7823, L7826L7827.
- **edge nginx** (service): 게스트에서 도는 엣지. 호스트에서 친 요청에는 이미 404 로 응답하고 있었다. Evidence: L7813L7813, L7839L7840.
- **connection refused** (result): drop 이 아니라 reject 라 기다리지 않고 즉시 돌아온 결과. Evidence: L7814L7814, L7816L7816.
## Relationships
- **inserted accept → edge nginx:** accept. Evidence: L7835L7840.
- **ct state new accept → inserted accept:** after insert. Evidence: L7831L7833, L7835L7836.
- **ct state new accept → reject:** before insert. Evidence: L7831L7833, L7821L7823.
- **inbound packet → ct state new accept:** forward hook. Evidence: L7814L7814, L7829L7830.
- **reject → connection refused:** reject. Evidence: L7823L7823, L7814L7816.
@@ -0,0 +1,30 @@
# 같은 forward 훅에 붙은 base 체인 둘이 우선순위 순으로 이어서 평가되고, 앞 체인의 accept 는 뒤 체인의 reject 를 막지 못한다
# Question: 밖에서 온 packet 하나가 forward 훅에서 어느 규칙을 어떤 순서로 지나 어디서 끝났고, insert 로 넣은 구멍은 그 순서의 어디에 들어가는가
direction: right
g0: "forward · priority filter - 10" {
n1: "ct state new accept" {
shape: rectangle
}
}
g1: "ip libvirt_network · guest_input" {
n2: "inserted accept" {
shape: rectangle
}
n3: "reject" {
shape: rectangle
}
}
n0: "inbound packet" {
shape: rectangle
}
n4: "edge nginx" {
shape: rectangle
}
n5: "connection refused" {
shape: rectangle
}
n0 -> g0.n1: "forward hook"
g0.n1 -> g1.n2: "after insert"
g0.n1 -> g1.n3: "before insert"
g1.n2 -> n4: "accept"
g1.n3 -> n5: "reject"
@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="UTF-8"?>
<mxfile host="app.diagrams.net" modified="2026-07-23T00:00:00.000Z" agent="techviz-harness" version="24.7.17" type="device">
<diagram id="nftables-forward-hook-chain-order" name="같은 forward 훅에 붙은 base 체인 둘이 우선순위 순으로 이어서 평가되고, 앞 체인의 accept 는 뒤 체인의 reject 를 막지 못한다">
<mxGraphModel dx="1298" dy="434" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1298" pageHeight="1169" math="0" shadow="0">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="g_our-forward-chain" value="forward · priority filter - 10" style="swimlane;html=1;rounded=1;startSize=30;horizontal=1;dashed=1;strokeWidth=1.5;fillColor=#f7f9fb;strokeColor=#66788a;fontStyle=1;fontSize=13;" vertex="1" parent="1">
<mxGeometry x="402.0" y="144.0" width="227.0" height="136.0" as="geometry"/>
</mxCell>
<mxCell id="g_libvirt-guest-input" value="ip libvirt_network · guest_input" style="swimlane;html=1;rounded=1;startSize=30;horizontal=1;dashed=1;strokeWidth=1.5;fillColor=#f7f9fb;strokeColor=#66788a;fontStyle=1;fontSize=13;" vertex="1" parent="1">
<mxGeometry x="729.0" y="35.0" width="234.0" height="354.0" as="geometry"/>
</mxCell>
<mxCell id="n_inbound-packet" value="inbound packet&lt;br/&gt;curl http://100.83.212.4" tooltip="밖에서 친 curl 이 만든 packet. 호스트가 직접 듣는 리스너가 없어 FORWARD 로 간다. | Evidence: L7814-L7814, L7809-L7809" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxGeometry x="70.0" y="186.5" width="202.0" height="71.0" as="geometry"/>
</mxCell>
<mxCell id="n_our-accept" value="ct state new accept" tooltip="우리가 먼저 돌게 해 둔 체인의 규칙. 이 체인은 통과시키지만 평가를 끝내지는 않는다. | Evidence: L7829-L7833" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxGeometry x="432.0" y="190.0" width="167.0" height="64.0" as="geometry"/>
</mxCell>
<mxCell id="n_inserted-accept" value="inserted accept&lt;br/&gt;nft insert rule&lt;br/&gt;daddr 192.168.122.10&lt;br/&gt;tcp dport {80,443}" tooltip="insert 로 guest_input 맨 앞에 넣은 구멍. add 로 넣으면 맨 뒤라 reject 뒤에 선다. | Evidence: L7835-L7840" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;strokeColor=#2563eb;strokeWidth=2;" vertex="1" parent="1">
<mxGeometry x="759.0" y="81.0" width="174.0" height="105.0" as="geometry"/>
</mxCell>
<mxCell id="n_tail-reject" value="reject&lt;br/&gt;chain tail&lt;br/&gt;counter packets 4&lt;br/&gt;bytes 240" tooltip="guest_input 체인을 끝내는 규칙. 앞의 ct state established,related accept 에 걸리지 못한 packet 이 여기 닿는다. 이 카운터가 밖에서 친 curl 횟수와 일치해 범인 확정에 쓰였다. | Evidence: L7821-L7823, L7826-L7827" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;strokeColor=#2563eb;strokeWidth=2;" vertex="1" parent="1">
<mxGeometry x="769.5" y="258.0" width="153.0" height="105.0" as="geometry"/>
</mxCell>
<mxCell id="n_edge-nginx" value="edge nginx&lt;br/&gt;192.168.122.10" tooltip="게스트에서 도는 엣지. 호스트에서 친 요청에는 이미 404 로 응답하고 있었다. | Evidence: L7813-L7813, L7839-L7840" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxGeometry x="1098.0" y="118.5" width="150.0" height="71.0" as="geometry"/>
</mxCell>
<mxCell id="n_connection-refused" value="connection refused" tooltip="drop 이 아니라 reject 라 기다리지 않고 즉시 돌아온 결과. | Evidence: L7814-L7814, L7816-L7816" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxGeometry x="1093.0" y="261.5" width="160.0" height="64.0" as="geometry"/>
</mxCell>
<mxCell id="e_inserted-to-edge" value="accept" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_inserted-accept" target="n_edge-nginx">
<mxGeometry relative="1" as="geometry">
<mxPoint x="1039.5" y="143.8" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_our-chain-after-insert" value="after insert" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_our-accept" target="n_inserted-accept">
<mxGeometry relative="1" as="geometry">
<mxPoint x="703.0" y="173.2" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_our-chain-before-insert" value="before insert" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_our-accept" target="n_tail-reject">
<mxGeometry relative="1" as="geometry">
<mxPoint x="708.2" y="270.8" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_packet-to-our-chain" value="forward hook" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_inbound-packet" target="n_our-accept">
<mxGeometry relative="1" as="geometry">
<mxPoint x="352.0" y="194.0" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_reject-to-refused" value="reject" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_tail-reject" target="n_connection-refused">
<mxGeometry relative="1" as="geometry">
<mxPoint x="1031.8" y="302.0" as="offset"/>
</mxGeometry>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>
@@ -0,0 +1,31 @@
{
"harness_version": "0.2.0",
"spec_id": "nftables-forward-hook-chain-order",
"spec_version": "1.1",
"spec_sha256": "7c412e3a6278b75551ce56f35693dd236d0b3bf0962c2788a87703123f009f9f",
"source_context": {
"document": "docs/virtualization/final/document.md",
"document_sha256": "60d902c7aed218ba637b48603a3bd0e6b193fea59c9de97ffdb8e05d5087aedd",
"anchor": {
"kind": "heading",
"value": "180. nftables 는 앞 체인의 `accept` 로 뒤 체인의 `reject` 를 막지 못한다",
"line": 7805
}
},
"outputs": [
"nftables-forward-hook-chain-order.svg",
"nftables-forward-hook-chain-order.drawio",
"nftables-forward-hook-chain-order.mmd",
"nftables-forward-hook-chain-order.d2",
"nftables-forward-hook-chain-order.excalidraw",
"nftables-forward-hook-chain-order.alt.md"
],
"lint_issue_count": 0,
"assumption_count": 0,
"assumptions_allowed": false,
"composition_profile": "component-flow",
"reference_ids": [
"payment-event-flow"
],
"diagram_only": true
}
@@ -0,0 +1,18 @@
%% 같은 forward 훅에 붙은 base 체인 둘이 우선순위 순으로 이어서 평가되고, 앞 체인의 accept 는 뒤 체인의 reject 를 막지 못한다
%% question: 밖에서 온 packet 하나가 forward 훅에서 어느 규칙을 어떤 순서로 지나 어디서 끝났고, insert 로 넣은 구멍은 그 순서의 어디에 들어가는가
flowchart LR
subgraph g_our_forward_chain["forward · priority filter - 10"]
n1["ct state new accept"]
end
subgraph g_libvirt_guest_input["ip libvirt_network · guest_input"]
n2["inserted accept"]
n3["reject"]
end
n0["inbound packet"]
n4["edge nginx"]
n5["connection refused"]
n0 -->|"forward hook"| n1
n1 -->|"after insert"| n2
n1 -->|"before insert"| n3
n2 -->|"accept"| n4
n3 -->|"reject"| n5
@@ -0,0 +1,110 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="1298" height="434" viewBox="0 0 1298 434" role="img" aria-labelledby="diagram-title diagram-description">
<title id="diagram-title">같은 forward 훅에 붙은 base 체인 둘이 우선순위 순으로 이어서 평가되고, 앞 체인의 accept 는 뒤 체인의 reject 를 막지 못한다</title>
<desc id="diagram-description">왼쪽에서 오른쪽으로 읽는다. 왼쪽 끝이 밖에서 친 curl 이 만든 inbound packet 이고 forward 훅으로 들어간다. 첫 점선 상자가 DNAT 파일에 둔 base 체인이다. priority filter - 10 이라 먼저 돌고, 그 안의 ct state new accept 가 이 packet 을 통과시킨다. 다음 점선 상자가 libvirt 가 만든 ip libvirt_network 테이블의 guest_input 체인이다. 앞 체인의 accept 는 평가를 끝내지 않으므로 같은 packet 이 이 체인으로 이어진다. 그 안의 상자 둘은 같은 체인의 맨 앞과 맨 끝이다. 구멍을 넣기 전에는 맨 앞이 비어 있어 packet 이 ct state established,related accept 에 걸리지 못한 채 체인 끝 reject 에 닿았고 connection refused 로 끝났다. 그 reject 규칙의 카운터가 4 패킷 240 바이트다. insert 로 구멍을 넣으면 그 규칙이 맨 앞에 서서 같은 packet 을 먼저 받아 192.168.122.10 의 엣지 nginx 로 보낸다. 실선이 구멍을 넣은 뒤의 경로이고 점선이 넣기 전의 경로다. 이 그림은 그 규칙이 libvirt 네트워크를 다시 세우면 사라진다는 것은 말하지 않는다.</desc>
<metadata>{&quot;techviz&quot;:{&quot;spec_version&quot;:&quot;1.1&quot;,&quot;id&quot;:&quot;nftables-forward-hook-chain-order&quot;,&quot;profile&quot;:&quot;component-flow&quot;},&quot;source_context&quot;:{&quot;document&quot;:&quot;docs/virtualization/final/document.md&quot;,&quot;document_sha256&quot;:&quot;60d902c7aed218ba637b48603a3bd0e6b193fea59c9de97ffdb8e05d5087aedd&quot;,&quot;anchor&quot;:{&quot;kind&quot;:&quot;heading&quot;,&quot;value&quot;:&quot;180. nftables 는 앞 체인의 `accept` 로 뒤 체인의 `reject` 를 막지 못한다&quot;,&quot;line&quot;:7805}},&quot;evidence_policy&quot;:&quot;Each factual element cites source lines or is marked assumption.&quot;,&quot;diagram_only&quot;:true}</metadata>
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" />
</marker>
<style>
:root { color-scheme: light; }
text { font-family: Inter, Pretendard, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #111827; }
.canvas { fill: #ffffff; }
.group-box { fill: #ffffff; stroke: #9ca3af; stroke-width: 1.4; stroke-dasharray: 7 5; }
.group-label-bg { fill: #ffffff; }
.group-label { font-size: 13px; font-weight: 650; fill: #374151; }
.edge { fill: none; stroke: #374151; stroke-width: 1.8; stroke-linejoin: round; stroke-linecap: round; marker-end: url(#arrow); }
.edge.style-dashed, .edge.semantic-dashed, .edge.assumption { stroke-dasharray: 7 5; }
.edge.style-dotted { stroke-dasharray: 2 5; }
.edge.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.edge.emphasis-muted { stroke: #9ca3af; }
.edge.emphasis-warning, .edge.kind-failure, .edge.kind-error { stroke: #dc2626; stroke-width: 2.2; }
.edge-label-bg { fill: #ffffff; }
.edge-label { font-size: 12px; font-weight: 560; text-anchor: middle; }
.node-shape { fill: #ffffff; stroke: #4b5563; stroke-width: 1.7; }
.node-shape.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.node-shape.emphasis-muted { stroke: #9ca3af; fill: #f9fafb; }
.node-shape.emphasis-warning { stroke: #d97706; stroke-width: 2; fill: #fffdf5; }
.node-shape.kind-database, .node-shape.kind-datastore, .node-shape.kind-storage { fill: #f8fafc; }
.node-shape.kind-queue, .node-shape.kind-event, .node-shape.kind-topic { fill: #fafafa; }
.node-shape.assumption { stroke-dasharray: 4 4; }
.storage-bottom, .controller-divider { fill: none; stroke: #4b5563; stroke-width: 1.4; }
.controller-led { fill: #4b5563; }
.actor-symbol { fill: none; stroke: #4b5563; stroke-width: 1.8; stroke-linecap: round; }
.actor-symbol.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.node-label { font-size: 14px; font-weight: 650; text-anchor: middle; }
.node-role { font-size: 10px; letter-spacing: 0.04em; text-anchor: middle; fill: #6b7280; }
.node-detail-divider { stroke: #d1d5db; stroke-width: 1; }
.node-detail { font-size: 11px; fill: #374151; }
.assumption-badge { font-size: 9px; font-weight: 700; fill: #92400e; }
.failure-mark { stroke: #dc2626; stroke-width: 4; stroke-linecap: round; }
.lifeline { stroke: #9ca3af; stroke-width: 1.2; stroke-dasharray: 5 5; }
.timeline-axis { stroke: #374151; stroke-width: 1.8; marker-end: url(#arrow); }
.timeline-stem { stroke: #6b7280; stroke-width: 1.3; }
.timeline-marker { fill: #ffffff; stroke: #374151; stroke-width: 1.7; }
.timeline-marker.primary { fill: #2563eb; stroke: #2563eb; }
.timeline-marker.warning { fill: #dc2626; stroke: #dc2626; }
.timeline-label { font-size: 13px; font-weight: 650; text-anchor: middle; }
.timeline-detail { font-size: 11px; fill: #4b5563; text-anchor: middle; }
</style>
</defs>
<rect class="canvas" width="1298" height="434" />
<rect class="group-box" x="402.0" y="144.0" width="227.0" height="136.0" rx="8" />
<rect class="group-label-bg" x="416.0" y="134.0" width="232.0" height="22" />
<text class="group-label" x="426.0" y="149.0">forward · priority filter - 10</text>
<rect class="group-box" x="729.0" y="35.0" width="234.0" height="354.0" rx="8" />
<rect class="group-label-bg" x="743.0" y="25.0" width="246.0" height="22" />
<text class="group-label" x="753.0" y="40.0">ip libvirt_network · guest_input</text>
<polyline class="edge kind-result style-solid emphasis-normal" points="933.0,133.5 1015.5,133.5 1015.5,154.0 1098.0,154.0" data-evidence="7835-7840" />
<rect class="edge-label-bg" x="1010.4" y="129.8" width="58.2" height="22" rx="3" />
<text class="edge-label" x="1039.5" y="144.8">accept</text>
<polyline class="edge kind-data style-solid emphasis-normal" points="599.0,213.0 679.0,213.0 679.0,133.5 759.0,133.5" data-evidence="7831-7833,7835-7836" />
<rect class="edge-label-bg" x="653.8" y="159.2" width="98.4" height="22" rx="3" />
<text class="edge-label" x="703.0" y="174.2">after insert</text>
<polyline class="edge kind-data style-dashed emphasis-normal" points="599.0,231.0 684.2,231.0 684.2,310.5 769.5,310.5" data-evidence="7831-7833,7821-7823" />
<rect class="edge-label-bg" x="655.7" y="256.8" width="105.1" height="22" rx="3" />
<text class="edge-label" x="708.2" y="271.8">before insert</text>
<polyline class="edge kind-data style-solid emphasis-normal" points="272.0,222.0 352.0,222.0 352.0,222.0 432.0,222.0" data-evidence="7814-7814,7829-7830" />
<rect class="edge-label-bg" x="302.8" y="180.0" width="98.4" height="22" rx="3" />
<text class="edge-label" x="352.0" y="195.0">forward hook</text>
<polyline class="edge kind-result style-dashed emphasis-normal" points="922.5,310.5 1007.8,310.5 1007.8,293.5 1093.0,293.5" data-evidence="7823-7823,7814-7816" />
<rect class="edge-label-bg" x="1002.6" y="288.0" width="58.2" height="22" rx="3" />
<text class="edge-label" x="1031.8" y="303.0">reject</text>
<g id="node-inbound-packet">
<rect class="node-shape kind-packet emphasis-normal role-source" data-evidence="7814-7814,7809-7809" x="70.0" y="186.5" width="202.0" height="71.0" rx="7" />
<text class="node-label" x="171.0" y="213.5">inbound packet</text>
<line class="node-detail-divider" x1="84.0" y1="234.5" x2="258.0" y2="234.5" />
<text class="node-detail" x="86.0" y="251.5">curl http://100.83.212.4</text>
</g>
<g id="node-our-accept">
<rect class="node-shape kind-rule emphasis-normal role-service" data-evidence="7829-7833" x="432.0" y="190.0" width="167.0" height="64.0" rx="7" />
<text class="node-label" x="515.5" y="220.0">ct state new accept</text>
</g>
<g id="node-inserted-accept">
<rect class="node-shape kind-rule emphasis-primary role-service" data-evidence="7835-7840" x="759.0" y="81.0" width="174.0" height="105.0" rx="7" />
<text class="node-label" x="846.0" y="108.0">inserted accept</text>
<line class="node-detail-divider" x1="773.0" y1="129.0" x2="919.0" y2="129.0" />
<text class="node-detail" x="775.0" y="146.0">nft insert rule</text>
<text class="node-detail" x="775.0" y="162.0">daddr 192.168.122.10</text>
<text class="node-detail" x="775.0" y="178.0">tcp dport {80,443}</text>
</g>
<g id="node-tail-reject">
<rect class="node-shape kind-rule emphasis-primary role-service" data-evidence="7821-7823,7826-7827" x="769.5" y="258.0" width="153.0" height="105.0" rx="7" />
<text class="node-label" x="846.0" y="285.0">reject</text>
<line class="node-detail-divider" x1="783.5" y1="306.0" x2="908.5" y2="306.0" />
<text class="node-detail" x="785.5" y="323.0">chain tail</text>
<text class="node-detail" x="785.5" y="339.0">counter packets 4</text>
<text class="node-detail" x="785.5" y="355.0">bytes 240</text>
</g>
<g id="node-edge-nginx">
<rect class="node-shape kind-service emphasis-normal role-sink" data-evidence="7813-7813,7839-7840" x="1098.0" y="118.5" width="150.0" height="71.0" rx="7" />
<text class="node-label" x="1173.0" y="145.5">edge nginx</text>
<line class="node-detail-divider" x1="1112.0" y1="166.5" x2="1234.0" y2="166.5" />
<text class="node-detail" x="1114.0" y="183.5">192.168.122.10</text>
</g>
<g id="node-connection-refused">
<rect class="node-shape kind-result emphasis-normal role-sink" data-evidence="7814-7814,7816-7816" x="1093.0" y="261.5" width="160.0" height="64.0" rx="7" />
<text class="node-label" x="1173.0" y="291.5">connection refused</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 9.7 KiB

File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
9465582b5d1630eb4ae7c4e078021486919bf6b6
@@ -0,0 +1,31 @@
#!/usr/sbin/nft -f
# Forward the tailnet entry point to the edge guest.
#
# This is the ONLY lab traffic rule the physical host carries. Everything else
# that used to live here — nginx config, certificates, certbot, the deploy hook
# — now lives on kc-lab-edge and is destroyed with it.
#
# DNAT only, never SNAT. The guests' default route is the host, so replies come
# back through here and conntrack reverses the translation on its own. Adding a
# masquerade would rewrite the source and the edge would see 192.168.122.1 for
# every client — which would silently invalidate the X-Forwarded-For contract
# that this lab measures.
#
# PREROUTING nat runs before the routing decision, so this wins over any local
# socket on :80/:443. That makes the cutover atomic and the rollback a single
# `nft delete table ip lab_edge`.
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
}
# No forward chain here on purpose. libvirt's guest_input chain ends in
# `oif virbr0 ... reject`, and an accept in an earlier base chain does NOT
# stop a later chain from rejecting — that is nftables, not iptables. The
# hole is punched inside libvirt's own chain by the unit's ExecStartPost.
}
@@ -0,0 +1,21 @@
[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
# libvirt's own guest_input chain ends in `oif virbr0 ... reject`, and nftables
# does NOT let an accept in an earlier base chain override a reject in a later
# one. So the hole has to be punched inside libvirt's chain, at the top.
# `-` because libvirt_network only exists once the virtual network is up; if it
# is missing the DNAT still loads and this can be re-applied with a restart.
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
@@ -0,0 +1,61 @@
# Lab entry point. Deployed on the lab host as
# /etc/nginx/sites-available/keycloak-lab
# and symlinked from sites-enabled/.
#
# Arch does not ship the Debian sites-available convention, so nginx.conf needs
# include /etc/nginx/sites-enabled/*;
# inside its http { } block before this file has any effect.
#
# This is the outer of two L7 hops. It terminates TLS and hands plain HTTP to
# the Traefik instance running on each k3s node.
upstream k3s_traefik {
# Sticky-session switch. Keycloak recommends affinity on AUTH_SESSION_ID;
# ip_hash is the cheap stand-in for a single-browser lab. Leaving it off is
# the interesting case: Infinispan still routes correctly, only slower.
# ip_hash;
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 {
# The http2 parameter of listen, not the separate `http2 on;` directive:
# that directive needs nginx >= 1.25.1 and the edge guest is Debian 12
# (nginx 1.22). This form works on both and is what the lab actually runs.
listen 443 ssl http2 default_server;
server_name _;
# Lineage is named after the FIRST -d, so a wildcard cert issued as
# -d hyeonworks.com -d '*.hyeonworks.com'
# lands in live/hyeonworks.com/, not live/auth.hyeonworks.com/.
# fullchain.pem, never cert.pem: omitting the intermediates passes on
# desktop browsers and fails on mobile and curl.
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;
# $remote_addr, not $proxy_add_x_forwarded_for. This is the trust
# boundary: a client-supplied X-Forwarded-For must be discarded, not
# extended, or nothing downstream can rely on the value.
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}
+12
View File
@@ -0,0 +1,12 @@
#!/bin/sh
# certbot deploy hook. Install as
# /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh (chmod +x)
#
# deploy/ runs only when a certificate was actually renewed (RENEWED_LINEAGE is
# set). post/ would run twice a day whether or not anything changed, reloading
# nginx for nothing.
#
# Without this, D-4 measured the failure exactly: the renewal succeeds, the
# timer reports SUCCESS, and the old certificate keeps being served for 38m25s
# — with no error anywhere.
nginx -t && nginx -s reload
@@ -0,0 +1,239 @@
# 00 — lab host 준비
## 이 단계가 끝나면
`virsh list` 가 sudo 없이 돌고, VM 을 만들 수 있는 상태가 된다.
## 전제
물리 기계 한 대. 이 실험대는 Arch Linux 를 썼지만 배포판은 상관없다 —
패키지 이름만 다르다.
---
## 0. 저장소를 lab host 에 받는다
**뒤 단계가 `deploy/` 아래 파일을 쓴다.** 05·06 의 `kubectl apply -f
deploy/lab/k8s/...` 가 그것이고, 경로는 **저장소 루트 기준**이다. lab host 에
저장소가 없으면 `cp: cannot stat` / `error: the path ... does not exist`
막힌다 — 이 실험대에서 실제로 겪은 형태다.
**하기**`[lab host]`
```bash
git clone https://git.learn.hyeonworks.com/donghyeon.kang/keycloak-pattern.git ~/workspace/keycloak-pattern
cd ~/workspace/keycloak-pattern
```
**확인**
```bash
ls deploy/lab/k8s/
```
**어디를 봐야 하는가**`keycloak-cluster.yaml``observability.yaml`
보이는가.
**이 결과가 의미하는 것** — 이후 단계에서 `deploy/...` 로 시작하는 상대경로가
나오면 **전부 이 디렉터리 안에서 치는 것**이다. 다른 데서 치면 파일을 못 찾는다.
**★ 이미 한 번 실험대를 세웠다가 철거했다면 이 디렉터리가 없을 수 있다.**
철거는 VM·디스크·네트워크만 지우고 저장소는 각자 관리라, `~/workspace`
cloud-init seed 만 남아 있는 상태가 흔하다. `ls ~/workspace` 로 먼저 본다.
---
## 1. CPU 가상화가 켜져 있는가
BIOS 에서 꺼져 있으면 아무것도 못 한다. 먼저 본다.
**확인** — CPU 가 하드웨어 가상화 확장을 내놓고 있는가
```bash
grep -Eo 'vmx|svm' /proc/cpuinfo | head -1
```
**어디를 봐야 하는가** — 출력 한 줄이 전부다. `vmx`(Intel) 또는 `svm`(AMD)
중 하나가 찍히는가, 아니면 아무것도 안 찍히는가.
**이 결과가 의미하는 것** — 찍혔으면 이 호스트에서 KVM 을 쓸 수 있다. 다음
단계로 간다. 빈 출력은 「CPU 가 못 한다」가 아니라 대개 **BIOS 에서 꺼져
있다**는 뜻이다 — 재부팅해 Intel VT-x / AMD-V 를 켜고 다시 잰다. 여기서
막히면 뒤의 어떤 단계도 의미가 없으므로 진행하지 않는다.
**실측** — 이 실험대의 호스트는 16 코어 전부에서 지원한다.
## 2. KVM 모듈이 올라와 있는가
**확인** — 커널이 그 확장을 실제로 잡고 있는가
```bash
lsmod | grep kvm
```
**형태** (이 실험대에서 캡처해 두지 않았다 — 줄 모양만)
```
kvm_intel ...
kvm ...
```
**어디를 봐야 하는가** — 왼쪽 첫 열의 모듈 이름 두 개. 벤더 모듈
(`kvm_intel` 또는 `kvm_amd`)과 공용 `kvm`**둘 다** 있어야 한다. 셋째 열은
이 모듈을 쓰고 있는 쪽의 수라서, 아직 VM 이 없으면 0 인 것이 정상이다.
**이 결과가 의미하는 것** — 두 줄이면 커널이 하드웨어 가상화를 쓸 준비가 됐고
`virt-install` 이 KVM 가속으로 뜬다. `kvm` 만 있고 벤더 모듈이 없으면 1번의
BIOS 설정이 커널까지 안 넘어온 것이다 — `sudo modprobe kvm_intel` 로 직접
올려 보면 거부 사유가 그대로 나온다. 아무것도 없으면 1번으로 돌아간다.
> **왜 이걸 먼저 보나.** KVM 없이도 QEMU 는 돌지만 **소프트웨어 에뮬레이션**이
> 되어 수십 배 느리다. VM 두 대가 「뜨긴 뜨는데 느리다」면 대개 여기다.
## 3. 패키지 설치
**하기** (Arch)
```bash
sudo pacman -S --needed qemu-full libvirt virt-install dnsmasq
```
Debian/Ubuntu 면 이름이 다르다.
```bash
sudo apt install qemu-system-x86 libvirt-daemon-system virtinst cloud-image-utils
```
| 무엇 | 하는 일 |
|---|---|
| qemu | 실제로 가상 기계를 돌리는 것 |
| libvirt | qemu 를 관리하는 층 (`virsh` 가 여기 붙는다) |
| virt-install | VM 을 만드는 명령 |
| dnsmasq | 가상 네트워크의 DHCP·DNS |
**확인** — 두 실행 파일이 PATH 에 들어왔는가
```bash
virsh --version
qemu-system-x86_64 --version
```
**어디를 봐야 하는가** — 판 번호 두 줄. 명령을 못 찾는다(`command not found`)면
패키지가 안 깔린 것이고, 번호가 나오면 깔린 것이다.
**이 결과가 의미하는 것** — 여기서 나오는 libvirt 판 번호가 뒤의 옵션 이름을
가른다. 이 실험대는 **libvirt 12.7.0 · QEMU emulator version 11.1.1** 이었다
(문서 끝 실측값 블록). 훨씬 낮은 판이면 `virt-install --cloud-init` 같은
옵션의 동작이 다를 수 있으니, 01 에서 막힐 때 이 번호를 같이 본다.
## 4. libvirt 를 띄우고 권한을 받는다
**하기**
```bash
sudo systemctl enable --now libvirtd.socket
sudo usermod -aG libvirt "$USER"
```
그리고 **로그아웃했다 다시 들어온다.** 보조 그룹은 로그인할 때 정해지므로
`usermod` 만으로는 지금 셸에 반영되지 않는다.
**확인** — 지금 이 셸이 libvirt 에 sudo 없이 붙는가
```bash
groups # libvirt 가 보여야 한다
virsh list --all # sudo 없이 돌아야 한다
```
**실측**
```
donghyeon libvirt wheel
```
**어디를 봐야 하는가**`groups` 출력에 `libvirt` 가 끼어 있는가, 그리고
`virsh list --all` 이 **머리글만 있는 빈 표**라도 오류 없이 끝나는가. 아직
VM 을 안 만들었으므로 표가 비어 있는 것이 정상이다 — 봐야 할 것은 표의
내용이 아니라 명령이 통과했다는 사실이다.
**이 결과가 의미하는 것** — 둘 다 통과하면 이 셸에서 VM 을 만들 수 있고,
01 로 넘어가도 된다. `groups``libvirt` 가 없으면 `usermod` 는 됐지만
**지금 로그인 세션이 옛 그룹 목록을 들고 있는** 것이다 — 로그아웃/로그인
한다. `groups` 에는 있는데 `virsh``Permission denied` 면 그룹이 아니라
소켓 문제이므로 `systemctl status libvirtd.socket` 을 본다.
> **`libvirtd.service` 가 아니라 `.socket` 을 켠 이유.** 소켓 활성화라서
> 데몬이 미리 떠 있지 않아도 `virsh` 가 접속하는 순간 systemd 가 띄운다.
> 자원을 아끼고, 데몬을 재시작해도 클라이언트가 끊기지 않는다.
## 5. 연결 URI 를 고정한다
`virsh` 는 기본으로 `qemu:///session`(사용자 단위)에 붙는데, VM 은
`qemu:///system`(시스템 단위)에 만들어야 한다. **이걸 안 맞추면 만든 VM 이
안 보인다.**
**하기** — 셸 프로필에 넣는다
```bash
echo 'export LIBVIRT_DEFAULT_URI=qemu:///system' >> ~/.bashrc
```
**확인** — 지금 셸이 어느 하이퍼바이저를 보고 있는가
```bash
virsh uri
```
```
qemu:///system
```
**어디를 봐야 하는가** — 끝의 한 낱말. `system` 인가 `session` 인가.
**이 결과가 의미하는 것**`qemu:///system` 이면 뒤에서 만들 VM 과 지금
`virsh` 가 같은 곳을 본다. `qemu:///session` 이면 사용자 단위 하이퍼바이저를
보고 있어 **VM 은 만들어졌는데 `virsh list` 에 안 나오는** 상태가 된다.
`.bashrc` 에 넣은 것은 **새로 여는 셸에만** 적용되므로, 지금 셸에서는
`export LIBVIRT_DEFAULT_URI=qemu:///system` 을 한 번 더 치거나 새 셸을 연다.
## 6. 기본 네트워크
**확인** — VM 이 붙을 가상 네트워크가 살아 있는가
```bash
virsh net-list --all
```
**실측**
```
Name State Autostart Persistent
--------------------------------------------
default active yes yes
```
**어디를 봐야 하는가**`default` 행의 **State 와 Autostart 두 칸**.
`--all` 을 준 이유가 여기 있다 — 빼면 `inactive` 인 네트워크는 아예 목록에
안 나와서 「없음」과 「꺼짐」을 구분할 수 없다.
**이 결과가 의미하는 것**`active` + `yes` 면 지금도, 호스트를 재부팅한
뒤에도 `virbr0``192.168.122.0/24` 가 있다. `inactive` 면 VM 을 만들어도
DHCP 가 없어 IP 를 못 받는다. Autostart 가 `no` 면 **지금은 되지만 호스트를
재부팅한 다음 01 의 SSH 가 전부 실패**하고, 원인을 게스트에서 찾게 된다.
둘 중 하나라도 어긋나면 아래 두 줄로 맞춘다.
```bash
virsh net-start default
virsh net-autostart default
```
이 네트워크가 `virbr0` 브리지와 `192.168.122.0/24` 대역을 만든다. VM 들이
여기 붙는다.
---
## 막히면
| 증상 | 원인 | 확인 |
|---|---|---|
| `virsh list` 에 permission denied | 그룹 반영 안 됨 | 로그아웃/로그인 했는가. `groups` 에 libvirt 가 있는가 |
| 만든 VM 이 목록에 없다 | URI 가 `session` | `virsh uri` |
| VM 이 극단적으로 느리다 | KVM 미사용 | `lsmod \| grep kvm` · BIOS |
| `net-start default` 실패 | dnsmasq 없음 | 패키지 설치 확인 |
---
## 이 단계의 실측값
돌고 있는 실험대에서 그대로 읽은 것이다.
```
libvirt 12.7.0
qemu QEMU emulator version 11.1.1
그룹 donghyeon libvirt wheel
네트워크 default / active / autostart yes
```
@@ -0,0 +1,474 @@
# 01 — VM 세 대
## 이 단계가 끝나면
`kc-lab-edge`·`kc-lab-1`·`kc-lab-2` 세 게스트가 뜨고, lab host 에서 SSH 가
키로 붙는다.
## 전제
[00](../00-lab-host/) 이 끝나 `virsh list` 가 sudo 없이 돈다.
## 왜 VM 세 대인가
**k3s 노드 두 대** — 이 실험대의 질문 전부가 **「인스턴스가 둘 이상이고
요청이 어느 쪽으로 갈지 모른다」** 를 전제한다. 한 대면 세션 공유도 분단도
노드 상실도 실험이 되지 않는다.
**엣지 한 대** — nginx·인증서·certbot 이 사는 곳이다. 이것을 물리 호스트에
두면 **되돌릴 수가 없다.** 자주 고치고 자주 갈아엎는 층인데 물리 기계에
쌓이기 때문이다. VM 이면 초기화가 `virsh undefine` 한 줄이고, 물리 호스트에는
DNAT 규칙 하나와 DHCP 예약만 남는다. 자세한 이유는 [03](../03-nginx/) 의
「왜 엣지가 물리 호스트가 아니라 VM 인가」에 있다.
그리고 호스트에 직접 깔면 **되돌릴 수가 없다** — 이 실험대는 노드를
죽이고 DB 를 crash 시키는 것이 목적이라 망가뜨릴 수 있는 층이 필요하다.
| 게스트 | IP | MAC 끝 | 메모리 | 무엇이 도나 |
|---|---|---|---|---|
| `kc-lab-edge` | 192.168.122.10 | `:10` | 1024MB | nginx · certbot |
| `kc-lab-1` | 192.168.122.11 | `:11` | 5120MB | k3s server · Traefik |
| `kc-lab-2` | 192.168.122.12 | `:12` | 4096MB | k3s agent · Traefik |
---
## 1. base 이미지를 받는다
OS 를 설치하지 않는다. 클라우드 이미지는 **이미 설치가 끝난 디스크**이고,
첫 부팅에서 cloud-init 이 사용자·SSH 키·호스트명을 채운다.
**하기**
```bash
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
```
**확인** — 받은 파일이 온전한 qcow2 인가
```bash
qemu-img info /var/lib/libvirt/images/base.qcow2
```
**어디를 봐야 하는가** — 세 줄이다. `file format:``qcow2` 인가(`raw`
읽히면 내려받기가 중간에 끊겨 HTML 오류 페이지를 저장한 것이다),
`virtual size:``disk size:` 보다 훨씬 큰가(qcow2 는 희소 파일이라 이게
정상이다), `backing file:` 줄이 **없는가**. base 는 아무것도 뒤에 두지 않는다.
**이 결과가 의미하는 것** — 셋이 맞으면 이 파일을 5번에서 오버레이의 바닥으로
쓸 수 있다. 여기서 형식이 틀린 채로 진행하면 증상이 `virt-install` 이 아니라
**게스트가 부팅을 못 하는 것**으로 나타나 원인을 찾기 어려워진다.
## 2. cloud-init 을 쓴다
게스트마다 하나씩 만든다. 템플릿은
[`deploy/lab/cloud-init/kc-lab.yaml.example`](../../../deploy/lab/cloud-init/kc-lab.yaml.example).
```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]
```
### 세 값을 어디서 가져오나
자리표시자 셋을 실제 값으로 바꿔야 한다. **찾는 명령이 있다.**
```bash
# ① lab host 공개키 — 없으면 만든다
[ -f ~/.ssh/id_ed25519.pub ] || ssh-keygen -t ed25519 -N '' -f ~/.ssh/id_ed25519
cat ~/.ssh/id_ed25519.pub
# ② 워크스테이션 공개키 — 워크스테이션에서
cat ~/.ssh/id_ed25519.pub
# ③ 콘솔용 비밀번호 — 만들어서 보관한다
openssl rand -base64 18
```
셋을 넣는다.
```bash
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
```
**확인** — 자리표시자가 남아 있으면 cloud-init 이 그대로 넣는다
```bash
grep -c '__' kc-lab-1.yaml # 0 이어야 한다
grep -c 'ssh-ed25519\|ssh-rsa' kc-lab-1.yaml # 2 여야 한다
python3 -c 'import yaml,sys; yaml.safe_load(open("kc-lab-1.yaml")); print("YAML OK")'
```
**어디를 봐야 하는가** — 숫자 두 개와 낱말 하나. 첫 줄이 `0` 이 아니면
`__LAB_HOST_KEY__` 같은 문자열이 남아 있는 것이고, 둘째 줄이 `2` 가 아니면
`sed` 치환 중 하나가 안 먹은 것이다. 셋째 줄은 `YAML OK` 가 찍히는가만 본다 —
찍히지 않으면 대신 파이썬 예외 줄이 나오고, 거기 적힌 `line N` 이 문제의
줄 번호다. 이 셋은 값을 뽑는 것이 아니라 **찍힌 숫자를 눈으로 비교하는**
용도라 이 형태가 맞다.
**이 결과가 의미하는 것**`0` / `2` / `YAML OK` 셋이 다 맞아야 시드를 만든다.
어긋난 채로 3번을 진행하면 **cloud-init 은 파싱에 실패해도 아무 오류를 남기지
않으므로**, 증상이 「SSH 가 안 붙는다」로만 나타나고 원인이 이 파일에 있다는
사실이 드러나지 않는다. 여기서 거르는 것이 뒤에서 30분 걸릴 일을 없앤다.
> **`yamllint` 는 이 실험대에 없다.** 있으면 좋지만 없다고 설치하러 가지
> 않는다 — 위 세 줄로 충분하다.
**게스트가 한 대라도 떠 있으면 한 단계 더 볼 수 있다.** YAML 로 파싱된다는
것과 **cloud-config 로 유효하다**는 것은 다르다. 키 이름 오타(`user` vs
`users`)는 위 검사를 그냥 통과한다. cloud-init 자신의 스키마 검사기가
게스트에 들어 있다 — `kc-lab-2` 용 파일은 `kc-lab-1` 에서 검사할 수 있다.
```bash
# 이 파일에는 콘솔 비밀번호가 평문으로 들어 있다 — /tmp 가 아니라
# 자기 홈에 600 으로 두고, 검사가 끝나면 바로 지운다
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'
```
**실측** — 통과하면 이 한 줄이다.
```
Valid cloud-config: /home/donghyeon/kc-lab-edge.yaml
```
**어디를 봐야 하는가** — 마지막 한 줄. 통과하면 유효하다는 한 줄만 나오고,
아니면 `Invalid cloud-config` 아래에 **어느 키가 왜 틀렸는지**가 나열된다.
경고(`deprecated`)와 오류(`error`)는 다르다 — 경고는 지금 동작한다.
> **★ `sudo` 는 리스트가 아니라 문자열로 쓴다.** 이 실험대의 첫 두 게스트는
> 이렇게 되어 있었는데, 게스트의 cloud-init 22.4.2 스키마 검사기가 거부한다.
>
> ```yaml
> sudo: ['ALL=(ALL) NOPASSWD:ALL'] # 거부된다
> sudo: "ALL=(ALL) NOPASSWD:ALL" # 통과한다
> ```
>
> **실측** — 리스트 형태로 검사하면 이렇게 나온다.
>
> ```
> Error: Cloud config schema errors: users.0: {'name': 'donghyeon', 'groups':
> ['sudo'], 'shell': '/bin/bash', ...} is not valid under any of the given schemas
> ```
>
> 어느 키가 문제인지 **안 알려 준다** — `users.0` 전체를 통째로 찍고
> 「어느 스키마에도 안 맞는다」고만 한다. 그래서 키를 하나씩 바꿔 가며
> 좁혀야 한다. 리스트 형태도 **부팅은 된다**(`kc-lab-1`·`kc-lab-2` 가 그
> 상태로 NOPASSWD sudo 가 멀쩡히 돌고 있다). 검사기만 거부하는 것이라
> 「검사는 실패했는데 왜 되지」로 헷갈리기 쉽다.
**이 결과가 의미하는 것** — 오류가 나면 그 키는 **조용히 무시된다.**
`users``user` 로 잘못 쓰면 계정이 안 생기고, 증상은 역시 「SSH 가 안
붙는다」 하나뿐이다. 첫 게스트(`kc-lab-1`)를 만들 때는 검사할 게스트가 아직
없으니 위 세 줄로 가고, 둘째부터는 이 검사를 거친다.
세 가지가 의도적이다.
| | 왜 |
| --------------------- | -------------------------------------------------------------------------------------------------------------------- |
| `NOPASSWD:ALL` | k3s 설치와 장애 주입이 비대화식으로 돌아야 한다. 비밀번호를 물으면 멈춘다 |
| `plain_text_passwd` | **cloud-init 이 실패했을 때의 유일한 탈출구.** 키가 안 들어가면 SSH 가 막히므로 콘솔로 들어가 로그를 봐야 한다 |
| 키 두 개 | lab host 에서 자동화가 돌고(에이전트 포워딩 없음), 워크스테이션에서는 ProxyJump 로 직접 붙는다 |
> **들여쓰기는 공백만.** YAML 은 탭을 금지하고, cloud-init 은 파싱에 실패해도
> **아무 오류도 남기지 않는다.** 게스트가 `localhost` 로 뜨고 로그인이 안 되는
> 것이 유일한 증상이다.
## 3. 시드 이미지를 만든다
cloud-init 은 `cidata` 라벨이 붙고 안에 `user-data`·`meta-data` 라는 **정확한
이름**의 파일이 있는 볼륨을 찾는다.
**하기**
```bash
printf 'instance-id: kc-lab-1-%s\nlocal-hostname: kc-lab-1\n' "$(date +%s)" > meta-kc-lab-1
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
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
virsh vol-list default
```
**어디를 봐야 하는가**`seed-kc-lab-1.iso` 행이 목록에 있는가. 있으면
크기까지 본다.
```bash
virsh vol-info --pool default seed-kc-lab-1.iso
```
**Capacity** 가 방금 만든 로컬 파일 크기(`stat -c%s seed-kc-lab-1.iso`)와
같아야 한다.
**이 결과가 의미하는 것**`vol-create-as` 는 **빈 볼륨을 만들 뿐**이고
내용은 `vol-upload` 가 채운다. 두 명령 중 뒤엣것을 빠뜨리면 목록에는 이름이
보이지만 안이 0 으로 채워져 있고, cloud-init 은 `cidata` 라벨을 못 찾아
조용히 끝난다 — 증상은 또 「SSH 가 안 붙는다」다. 크기가 맞으면 4번으로 간다.
`instance-id` 에 타임스탬프를 넣는 이유가 있다. cloud-init 은 **인스턴스마다
한 번만** 초기화 모듈을 돌린다. id 가 같으면 이미 한 것으로 보고 건너뛰므로,
user-data 를 고쳐도 반영되지 않는다.
## 4. DHCP 로 IP 를 고정한다
**★ 이 단계가 VM 생성보다 먼저다.** 순서가 반대면 게스트가 동적 대역에서
아무 주소나 받아 버리고, 그 뒤에 예약을 넣어도 **이미 잡은 리스가 유지된다.**
되돌리려면 리스 만료를 기다리거나 게스트를 재시작해야 한다.
MAC 은 5번의 `virt-install --network mac=` 에 쓸 값을 **여기서 미리 정하는
것**이다. 아직 게스트가 없어도 예약은 들어간다 — 예약은 「이 MAC 이 나타나면
이 IP 를 줘라」이지 「지금 있는 게스트에게 줘라」가 아니다.
**하기** — 게스트 수만큼 친다
```bash
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
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
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
```
> **zsh 에서 루프로 돌리지 않는다.** zsh 는 따옴표 없는 변수를 단어 분리하지
> 않아서, bash 에서 되던 `set -- $entry` 가 `name=""` 로 끝난다. 증상은
> `XML error: Cannot use host name '' in network 'default'` 다. 세 줄을 값
> 그대로 쓰는 편이 안전하다.
`--live --config` 둘 다 준다. `--live` 만 주면 재부팅에 사라지고, `--config`
만 주면 지금 반영되지 않는다.
**확인** — 예약이 실제로 들어갔는가
```bash
virsh net-dumpxml default | grep -E "host mac|range start"
```
**실측**
```
<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'/>
```
넣을 때 나오는 한 줄은 이것이다.
```
Updated network default persistent config and live state
```
**이미 있는 예약을 또 넣으면 이렇게 거부된다.** 오류처럼 보이지만 **이미
들어가 있다는 뜻**이라 그냥 넘어가면 된다.
```
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' ... />"
```
> **`grep ip-dhcp-host` 로 확인하지 않는다.** `ip-dhcp-host` 는
> `net-update` 의 **섹션 이름**이지 XML 안에 있는 문자열이 아니다. 그렇게
> 치면 예약이 멀쩡히 들어가 있어도 **아무것도 안 나오고**, 예약이 안
> 들어갔다고 오독하게 된다. XML 안의 실제 요소는 `<host mac=...>` 다.
`persistent config``live state` **두 마디가 다 나와야** `--live --config`
가 제대로 먹은 것이다.
**어디를 봐야 하는가**`<host>` 세 줄의 **MAC 끝 두 자리와 IP 끝 숫자가
짝이 맞는가**(`:10``.10`, `:11``.11`, `:12``.12`). 이 MAC 을 5번의
`virt-install --network mac=`**한 글자도 다르지 않게** 쓴다. `<range>`
줄은 예약이 아니라 동적 대역이라, 예약 IP 가 이 안에 들어 있어도 상관없다.
**이 결과가 의미하는 것** — 세 줄이 다 보이면 게스트는 재부팅해도 같은 IP 를
받는다. 빠진 줄이 있으면 `--live --config` 중 하나를 빠뜨린 것이다.
`net-dumpxml` 은 **지금 돌고 있는 정의**를 보여 주므로 여기 보이는 것은
`--live` 가 먹었다는 뜻이고, 재부팅 뒤에도 남는지는
`virsh net-dumpxml --inactive default` 로 따로 본다.
## 5. VM 을 만든다
**하기**
```bash
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
```
나머지 둘은 **이름·메모리·MAC·시드**만 바꾼다.
```bash
# kc-lab-2 — 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
# kc-lab-edge — 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
```
**실측** — 엣지 생성 출력이다.
```
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 를
실제로 쓰지 않는다.
**★ 시드를 `--cloud-init` 으로 붙이지 않는다.** 그 옵션은 시드를 SATA CD-ROM
으로 붙이는데, Debian `genericcloud` 이미지는 크기를 줄이려고 물리 하드웨어
드라이버를 뺐다. **AHCI 장치가 보이지 않아** cloud-init 이 데이터소스를
못 찾고 조용히 끝난다. `bus=virtio` 로 디스크로 붙여야 한다.
`--disk size=20,backing_store=...` 는 복사가 아니라 **오버레이**다. base 는
읽기 전용으로 두고 변경분만 새 파일에 쌓이므로, 20GB 를 두 개 만들어도
실제 디스크는 몇백 MB 만 쓴다.
## 6. 붙어 본다
**확인** — 떴는가, 그리고 cloud-init 이 제 일을 했는가
```bash
virsh list --all
ssh donghyeon@192.168.122.11 'hostname; cat /etc/os-release | head -1'
```
**실측**
```
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)"
```
엣지도 같은 방법으로 본다. `cloud-init status` 까지 한 번에 친다.
```bash
ssh kc-lab-edge 'hostname; ip -4 -br addr show enp1s0; cloud-init status'
```
**실측**
```
kc-lab-edge
enp1s0 UP 192.168.122.10/24 metric 100
status: done
```
**어디를 봐야 하는가** — 세 줄이다. 호스트명이 `kc-lab-edge` 인가, IP 가
**예약한 `.10`** 인가, `cloud-init status``done` 인가. `running` 이면
아직 패키지를 받는 중이니 기다린다 — 이 실험대에서는 **약 50초** 걸렸다.
`error``cloud-init status --long` 으로 어느 모듈이 실패했는지 본다.
**어디를 봐야 하는가** — 세 가지다. ① State 가 두 대 다 `running` 인가
(`shut off` 면 아직 안 뜬 것이고, Id 칸이 `-` 로 비어 있다). ② SSH 가
**비밀번호를 묻지 않고** 통과했는가. ③ `hostname``kc-lab-1` 인가
`localhost` 인가.
**이 결과가 의미하는 것** — 이 세 줄이 cloud-init 성공의 판정 기준 전부다.
호스트명이 바뀌어 있다는 것은 시드가 읽혔다는 뜻이고, 시드가 읽혔으면
같은 파일에 있던 SSH 키도 들어갔다는 뜻이다. **`localhost` 가 나오면 SSH
설정을 고치지 말고 시드부터 의심한다** — 아래 「막히면」의 화면 캡처로 간다.
Id 번호가 2 보다 큰 것은 아무 뜻도 없다(만들고 지운 이력일 뿐이다).
---
## 막히면
여기서 실제로 겪은 것들이다.
| 증상 | 원인 | 확인 |
| ----------------------------------------------------------------- | ------------------------------------------------------------------------- | ----------------------- |
| SSH`Permission denied (publickey)` · hostname 이 `localhost` | **cloud-init 이 안 돌았다.** 시드를 SATA 로 붙였거나 YAML 파싱 실패 | 아래 화면 캡처 |
| user-data 를 고쳤는데 반영 안 됨 | `instance-id` 가 같아 건너뜀 | meta-data 의 id 확인 |
| IP 가 매번 바뀐다 | DHCP 예약이`--config` 없이 들어감 | `net-dumpxml` |
| VM 이 느리다 | KVM 미사용 | [00](../00-lab-host/) 로 |
게스트에 못 들어갈 때는 **화면을 직접 뜬다.**
```bash
virsh screenshot kc-lab-1 /tmp/kc1.ppm # 확장자와 무관하게 PNG 로 저장된다
```
**어디를 봐야 하는가** — 이미지를 열어 **로그인 프롬프트 앞의 호스트명 한
낱말**만 본다. `localhost login:` 인가 `kc-lab-1 login:` 인가.
**이 결과가 의미하는 것**`localhost` 면 cloud-init 이 아예 안 돌았다.
시드를 못 찾은 것(5번의 `bus=virtio`)이거나 YAML 파싱 실패(2번)이므로 SSH
쪽은 볼 필요가 없다. `kc-lab-1` 이면 cloud-init 은 돌았고 **그 안의 사용자·키
단계에서 틀린** 것이라, 이제 콘솔로 들어가 게스트 안의 로그를 본다.
```bash
virsh console kc-lab-1 # 빠져나오려면 Ctrl+]
# 게스트 안에서
sudo cloud-init status --long
sudo journalctl -u cloud-init -n 50
```
콘솔 로그인에 쓸 비밀번호가 2번의 `plain_text_passwd` 다. **이 한 장과 이 두
줄이 「SSH 가 안 되는 이유」를 절반으로 줄인다.**
---
## 실측값
```
kc-lab-1 vCPU 2 메모리 5120MB 192.168.122.11
kc-lab-2 vCPU 2 메모리 4096MB 192.168.122.12
게스트 OS Debian GNU/Linux 12 (bookworm)
```
> 메모리가 처음 만들 때(3584MB)와 다르다. 호스트가 12GB 뿐이라 실험을 늘리며
> 재배분했다. VM 을 다시 만들지 않고 바꾸는 방법은
> [`session-lab-concepts.md`](../../session-lab-concepts.md) 13층에 있다.
@@ -0,0 +1,407 @@
# 02 — k3s 두 노드
## 이 단계가 끝나면
lab host 에서 `kubectl get nodes` 를 치면 두 노드가 `Ready` 로 나온다.
`sudo``ssh` 도 붙이지 않는다.
## 전제
[01](../01-vms/) 이 끝나 lab host 에서 두 게스트에 SSH 가 붙는다.
## 어디서 치는가
**이 단계는 전부 `[lab host]` 에서 친다.** 게스트에 로그인하지 않는다.
자세한 이유는 [가이드 공통 규약](../README.md#어느-기계에서-치는가) 에 있고,
요점만 옮기면 이렇다.
- 게스트에는 lab host 의 개인키도 `~/.ssh/config` 도 없다. 게스트 안에서
`ssh kc-lab-1` 을 치면 `Host key verification failed` 로 끝난다.
- 그 실패를 `TOKEN=$(...)` 로 감싸면 **오류는 화면으로 새고 변수는 빈 채로**
남는다. 셸은 불평하지 않는다.
- 그래서 3번의 토큰이 비고, 4번의 agent 설치가 `--token is required`
죽는다. 설치 스크립트는 그 전까지를 다 성공으로 찍고 끝나기 때문에
**설치 출력만 보면 성공으로 읽힌다.**
셸을 하나만 쓰면 이 문제가 통째로 없어진다.
---
## 1. server 를 깐다 (kc-lab-1)
**하기**`[lab host]`
```bash
ssh kc-lab-1 'curl -sfL https://get.k3s.io | sudo sh -s - server --node-ip 192.168.122.11'
```
게스트에 들어가지 않고 원격 실행한다. cloud-init 이 `NOPASSWD:ALL`
넣어 두었으므로 비대화식 `sudo` 가 멈추지 않는다.
`--node-ip` 를 준다. 게스트에 인터페이스가 여럿이면 k3s 가 엉뚱한 것을 고를 수
있고, 그러면 두 노드가 서로를 다른 주소로 알게 된다.
**확인** — 서버가 떴고 자기 자신을 노드로 등록했는가
```bash
ssh kc-lab-1 'sudo systemctl is-active k3s; sudo kubectl get nodes'
```
**어디를 봐야 하는가** — 유닛이 `active` 인가, 그리고 `get nodes`
`kc-lab-1` 한 줄이 `Ready` 로 있는가. **설치 직후 30초 남짓은 `NotReady`
이거나 아예 목록이 비어 있는 것이 정상이다** — CNI 가 아직 안 올라온
시간이다. 한 번 더 친다.
이 가이드에서 `kubectl` 을 게스트 쪽에서 돌리는 것은 여기 한 번뿐이다.
lab host 에는 아직 kubeconfig 가 없기 때문이고, 그것을 두는 것이 바로 2번이다.
**이 결과가 의미하는 것**`active` + `Ready` 면 API 서버가 살아 있고
kubeconfig 도 자리를 잡았다는 뜻이라 2번으로 간다. 유닛이 `active` 인데
`get nodes` 가 접속 오류를 내면 API 서버가 아직 기동 중이다. 유닛이
`activating` 에서 안 넘어가거나 `failed` 면 설치 자체가 실패한 것이니
로그를 본다.
```bash
ssh kc-lab-1 'sudo journalctl -u k3s -n 50 --no-pager'
```
## 2. lab host 에 kubeconfig 를 둔다
**왜 여기서 하나** — 이 뒤로 `kubectl` 을 계속 쓴다. 지금 한 번 해두면
남은 단계에서 `ssh``sudo` 도 붙이지 않는다. **그리고 agent 노드에는
kubeconfig 가 없으므로**(6번) 클러스터를 볼 자리를 먼저 정해 두는 편이 낫다.
**하기**`[lab host]`
```bash
mkdir -p ~/.kube
ssh kc-lab-1 'sudo cat /etc/rancher/k3s/k3s.yaml' \
| sed 's|127.0.0.1|192.168.122.11|' > ~/.kube/config
chmod 600 ~/.kube/config
```
세 줄 다 필요하다.
| 줄 | 빠뜨리면 |
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `mkdir -p ~/.kube` | `>` 는 파일을 열 뿐 경로를 만들지 않는다. `cat` 이 시작되기도 전에 `No such file or directory` 로 끝난다 |
| `sed` | k3s 가 쓴 주소는`https://127.0.0.1:6443` 이다. **게스트 안에서만 맞는 주소**라 lab host 에서는 자기 자신의 6443 을 두드리게 된다 |
| `chmod 600` | 이 파일은 클러스터 admin 자격증명이다. 비밀번호 파일과 같은 급으로 다룬다 |
> `sudo` 는 `cat` 에만 걸리고 `>` 에는 걸리지 않는다. 리다이렉션은 셸이
> **명령보다 먼저** 현재 사용자 권한으로 처리하기 때문이다. 그래서 출력
> 파일은 홈 아래(`~/.kube/config`)에 둔다.
**확인** — 주소가 바뀌었고, 밖에서 붙는가
```bash
grep server: ~/.kube/config
kubectl get nodes
```
**실측**
```
server: https://192.168.122.11:6443
NAME STATUS ROLES AGE VERSION
kc-lab-1 Ready control-plane 47m v1.36.4+k3s1
```
**어디를 봐야 하는가**`server:` 값에 `127.0.0.1` 이 남아 있으면 `sed`
안 먹은 것이다. 그다음 `get nodes`**`sudo` 없이** 도는가. 아직 노드는
한 줄뿐인 것이 정상이다 — agent 는 4번에서 붙인다.
**이 결과가 의미하는 것** — 통과하면 이 뒤의 `kubectl` 은 전부 lab host 에서
친다. `x509` 오류가 나면 파일 안의 CA 와 서버가 지금 쓰는 CA 가 어긋난
것이다 — k3s 를 다시 깔았다면 이 복사도 다시 해야 한다. `connection refused`
면 주소는 맞는데 API 서버가 아직 안 뜬 것이다.
> **인증서 SAN 에 두 주소가 다 들어 있어서** 이 `sed` 가 통한다. 확인하려면
> `ssh kc-lab-1 'sudo openssl x509 -in /var/lib/rancher/k3s/server/tls/serving-kube-apiserver.crt -noout -ext subjectAltName'`
> 을 친다. `IP Address:127.0.0.1` 과 `IP Address:192.168.122.11` 이 둘 다
> 보인다. 8번의 SSH 터널이 `127.0.0.1` 로 붙을 수 있는 것도 같은 이유다.
## 3. 토큰을 꺼낸다
**하기**`[lab host]`. 화면에 찍어 눈으로 옮기지 말고 변수로 받는다
```bash
TOKEN=$(ssh kc-lab-1 'sudo cat /var/lib/rancher/k3s/server/node-token')
echo "${#TOKEN}" # 값이 아니라 길이만 확인한다
```
**실측** — 이 실험대에서는 108자였다. (`K10<해시>::server:<비밀번호>` 형식이라
k3s 판올림에 따라 자릿수가 달라진다. **중요한 것은 값이 아니라 `0` 이 아니라는
것이다.**)
**어디를 봐야 하는가** — 찍히는 것은 **자릿수 하나뿐**이다. 값은 보지
않는다 — 화면에 띄우는 순간 터미널 스크롤백과 셸 히스토리에 남는다.
**이 결과가 의미하는 것** — 세 자리 수가 나오면 토큰을 손에 쥔 것이니 4번으로
넘어간다. `0` 이면 변수가 비었다는 뜻이고 원인은 셋 중 하나다.
| `0` 인 이유 | 확인 |
| ------------------------------------------ | ---------------------------------------------------------------------- |
| **게스트 안에서 쳤다** (가장 흔하다) | 프롬프트가`kc-lab-1` 이면 `exit` 로 lab host 로 나온다 |
| server 가 아직 안 떠서 파일이 없다 | `ssh kc-lab-1 'sudo ls -l /var/lib/rancher/k3s/server/node-token'` |
| 새 셸을 열어 변수가 사라졌다 | `echo "${#TOKEN} 자"`**4번을 칠 바로 그 셸에서** 다시 친다 |
> **4번을 3번과 같은 셸에서 친다.** 변수는 셸 밖으로 나가지 않는다. 창을
> 새로 열거나 `ssh` 로 어딘가 들어갔다 나오면 `TOKEN` 은 없다.
## 4. agent 를 붙인다 (kc-lab-2)
**하기**`[lab host]`. 3번과 **같은 셸**에서 친다
```bash
[ ${#TOKEN} -ge 50 ] || echo "TOKEN 이 비었다 — 3번으로 돌아간다"
ssh kc-lab-2 "curl -sfL https://get.k3s.io | sudo sh -s - agent \
--server https://192.168.122.11:6443 \
--token '$TOKEN' \
--node-ip 192.168.122.12"
```
첫 줄의 가드를 빼지 않는다. `$TOKEN` 이 비면 게스트에는
`--token '' --node-ip ...` 가 전달되고, agent 는 기동 즉시
`level=fatal msg="Error: --token is required"` 로 죽는다. **그런데 설치
스크립트는 내려받기·유닛 생성·`enable` 까지 다 성공으로 찍고 끝나고**, 유닛은
`Restart=always` 라 5초마다 조용히 재시도한다. 가드 한 줄이 그 몇 분을 막는다.
> 토큰을 셸 히스토리에 남기고 싶지 않으면 파일로 넘긴다. 이러면 변수를 쓰지
> 않으므로 3번의 「같은 셸」 제약도 없어진다.
>
> ```bash
> 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"
> ```
**확인** — agent 가 클러스터에 들어왔는가, 그리고 **제 주소로** 들어왔는가
```bash
kubectl get nodes -o wide
```
**실측**
```
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 가 다른 대역(예: flannel 이나 다른 인터페이스 주소)으로 잡혀 있으면
지금은 아무 증상이 없다가 **03 의 nginx upstream 과 A층의 노드 상실
실험에서** 어긋난다 — 그때 고치는 것보다 지금 재설치가 싸다. `kc-lab-2`
아예 안 보이면 join 이 실패한 것이니 agent 쪽 로그를 본다.
```bash
ssh kc-lab-2 'sudo journalctl -u k3s-agent -n 30 --no-pager'
```
## 5. 유닛 이름이 다르다
| 노드 | 유닛 |
| ------ | --------------------- |
| server | `k3s.service` |
| agent | `k3s-agent.service` |
**확인** — 어느 노드가 무슨 이름으로, 어떤 인자로 돌고 있는가
```bash
ssh kc-lab-1 'systemctl cat k3s | grep -A3 ExecStart='
ssh kc-lab-2 'systemctl cat k3s-agent | grep -A4 ExecStart='
```
**실측**
```
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`)과
그 뒤의 인자**. 설치 스크립트에 준 옵션이 여기 그대로 굳어 있다. 유닛 이름을
틀리면(`systemctl cat k3s` 를 agent 노드에서) `No files found` 가 나오는데,
그것 자체가 「이 노드는 agent 다」라는 답이다.
**이 결과가 의미하는 것** — 4번의 `get nodes -o wide`**k3s 가 보고한**
IP 이고, 이 줄은 **우리가 준** IP 다. 둘이 다르면 옵션이 안 먹은 것이다.
그리고 뒤의 실험에서 노드를 멈출 때 칠 유닛 이름이 노드마다 다르다는 것을
여기서 확인해 둔다 — `systemctl stop k3s` 를 agent 노드에서 치면 아무 일도
일어나지 않고, 「주입했는데 증상이 없다」로 오독하게 된다.
> 이 차이가 A-4 에서 결과를 갈랐다. server 노드를 잃으면 `kubectl` 자체가
> 불통이 되고, agent 를 잃으면 `kubectl` 은 되지만 그 위의 워크로드가 사라진다.
## 6. agent 노드에서는 `kubectl` 이 안 된다
`kc-lab-2` 에 들어가 `sudo kubectl get pods -A` 를 치면 이렇게 끝난다.
```
Get "http://localhost:8080/api?timeout=32s": dial tcp [::1]:8080: connect: connection refused
```
**`kubectl` 명령 자체는 있다.** 설치 스크립트가
`/usr/local/bin/kubectl -> k3s` 심볼릭 링크를 만들기 때문이다. 없는 것은
**붙을 곳을 알려 주는 파일**, 곧 kubeconfig 다.
| 어디를 찾나 | kc-lab-1 | kc-lab-2 |
| ----------------------------- | -------------- | -------------- |
| `$KUBECONFIG` | (비어 있음) | (비어 있음) |
| `~/.kube/config` | 없음 | 없음 |
| `/etc/rancher/k3s/k3s.yaml` | **있음** | **없음** |
넷 다 못 찾으면 kubectl 은 오류를 내지 않고 하드코딩된 기본값
`http://localhost:8080` 으로 넘어간다. 쿠버네티스 1.20 이전 API 서버가
평문으로 열던 레거시 포트인데 지금은 아무도 열지 않는다.
> **`localhost:8080` 이 보이면 네트워크 문제가 아니라 「설정을 하나도 못
> 찾았다」는 뜻이다.** 이 주소는 어디에도 적혀 있지 않다. 방화벽이나 k3s 를
> 의심하기 전에 kubeconfig 부터 본다.
**워커라서 파드가 안 보이는 것이 아니다.** kubectl 은 그냥 HTTP 클라이언트라
어디서 실행하든 상관없다. `k3s.yaml` 을 kc-lab-2 로 복사해 넣으면 거기서도
전부 보인다. **그래도 복사하지 않는다** — 워커 한 대가 털리면 클러스터 전체가
털리는 구성이 된다. agent 가 가진 자격증명은 급이 다르다.
```
subject=O = system:nodes, CN = system:node:kc-lab-2
```
이 신원은 Node authorizer 와 NodeRestriction admission 이 **자기 노드에
배정된 객체만** 다루도록 제한한다. 게다가 그 자격증명은 kubelet 전용 경로
(`/var/lib/rancher/k3s/agent/`)에 있어 kubectl 이 읽지도 않는다. 그래서 실패가
「권한 없음(403)」이 아니라 「설정 없음(`localhost:8080`)」으로 나타난다.
**그래서 클러스터는 2번에서 만든 lab host 의 kubeconfig 로 본다.** 개념
설명은 [`session-lab-concepts.md`](../../session-lab-concepts.md) 의
「agent 노드에는 kubeconfig 가 없다」에 있다.
## 7. k3s 가 기본으로 딸려 오는 것
따로 설치하지 않아도 이미 있다.
| | 무엇 |
| ---------------------- | -------------------------------------------- |
| Traefik | 인그레스 컨트롤러.`:80` 을 듣는다 |
| servicelb (klipper-lb) | LoadBalancer 타입을 호스트 포트로 매핑 |
| local-path | 기본 StorageClass.**노드 로컬 디스크** |
| flannel | 파드 네트워크 (VXLAN) |
| kube-router | NetworkPolicy 집행 |
**확인**`[lab host]`. 무엇이 이미 돌고 있고, 기본 저장소가 무엇인가
```bash
kubectl get pods -A
kubectl get storageclass
```
**실측**
```
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
```
**어디를 봐야 하는가**`get pods -A` 에서는 **NAMESPACE 열이 `kube-system`
인 줄들의 STATUS**. `Running``Completed` 가 섞여 있는 것이 정상이다 —
`helm-install-traefik-*` 는 일회성 잡이라 `Completed` 로 남는다.
`svclb-traefik-*` 가 **두 줄**인 것도 봐 둔다. DaemonSet 이라 노드마다 하나씩
뜨는 것이고, 이 두 줄이 4번의 join 이 실제로 먹었다는 또 하나의 증거다.
`get storageclass` 에서는 이름 뒤의 **`(default)` 표시**가 어디 붙어 있는가.
**이 결과가 의미하는 것** — 여기 뜬 것들은 우리가 안 깔았는데 있는 것이고,
뒤 단계에서 「왜 80 포트가 이미 잡혀 있지」·「왜 PVC 가 이 노드에만 묶이지」의
답이 전부 이 목록에 있다. `local-path``(default)` 가 붙어 있으면
05 의 PVC 는 StorageClass 를 안 적어도 이것으로 만들어진다. `Pending` 이나
`CrashLoopBackOff` 가 섞여 있으면 그 파드부터 `describe` 로 본다.
> `local-path` 가 기본이라는 것이 A-4 에서 비용을 청구한다. PVC 가 **만들어진
> 노드에 묶여** 다른 노드로 재배치되지 않는다.
## 8. 워크스테이션에서 쓰려면
**2번의 kubeconfig 를 그대로 가져와도 안 된다.** 게스트는 libvirt NAT 안에
있어서 워크스테이션에서 `192.168.122.11` 로 가는 경로가 없다.
```bash
[워크스테이션] $ ping -c1 192.168.122.11
1 packets transmitted, 0 received, 100% packet loss
```
lab host 를 거치는 터널을 뚫는다.
**하기**`[워크스테이션]`
```bash
# 1) 터널. 이 창은 열어 둔다
ssh -N -L 6443:192.168.122.11:6443 test-server
# 2) 다른 창에서 — 게스트 원본을 그대로 가져온다. sed 가 필요 없다
mkdir -p ~/.kube
ssh test-server "ssh kc-lab-1 'sudo cat /etc/rancher/k3s/k3s.yaml'" > ~/.kube/kc-lab.yaml
chmod 600 ~/.kube/kc-lab.yaml
export KUBECONFIG=~/.kube/kc-lab.yaml
```
**2번과 달리 `sed` 를 치지 않는다.** k3s 원본이 이미
`https://127.0.0.1:6443` 이고, 터널 덕에 워크스테이션에서는 그 주소가 맞기
때문이다. 2번이 `192.168.122.11` 로 바꿨던 것은 lab host 에서 볼 때
`127.0.0.1` 이 lab host 자신을 가리키기 때문이었다. **같은 파일이라도 어느
기계에서 읽느냐에 따라 맞는 주소가 다르다.**
**확인**
```bash
grep server: ~/.kube/kc-lab.yaml
kubectl get nodes
```
**어디를 봐야 하는가**`server:``https://127.0.0.1:6443` 인가. 그다음
`get nodes` 가 4번과 **같은 두 줄**을 내놓는가.
**이 결과가 의미하는 것** — 터널 덕에 워크스테이션의 6443 이 `kc-lab-1`
6443 이다. API 서버 인증서 SAN 에 `127.0.0.1` 이 들어 있어서(2번의 각주)
인증서 검증도 통과한다. 타임아웃이면 1)의 터널 창이 닫힌 것이고,
`connection refused` 면 터널은 살아 있는데 반대편 API 서버가 죽은 것이다.
> **터널이 닫히면 `kubectl` 이 통째로 멎는다.** 이 실험대의 A층 실험은 노드를
> 죽이고 살리는 것이 목적이라, 터널 상태와 클러스터 상태가 섞여 오독하기
> 쉽다. **A층 실험은 lab host 에서 치는 것을 권한다.**
---
## 막히면
| 증상 | 원인 | 확인 |
| -------------------------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------- |
| `echo "${#TOKEN} 자"``0` | 게스트 안에서 쳤거나, 셸이 바뀌었다 | 프롬프트가`kc-lab-1` 이 아닌지. 3번 표 |
| agent 설치는 성공했는데 노드가 안 보임 | `--token` 이 빈 문자열 | `ssh kc-lab-2 'sudo journalctl -u k3s-agent -n 30 --no-pager'``--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 가 없다.**정상이다** | 위 6번 |
| 워크스테이션에서 타임아웃 | 터널이 없다 | 위 8번 |
| `x509` 오류 | k3s 재설치로 CA 가 바뀌었다 | 2번의 복사를 다시 한다 |
| 파드가 한 노드에만 몰림 | 스케줄러 판단 | `topologySpreadConstraints` 로 강제 |
@@ -0,0 +1,591 @@
# 03 — 엣지 nginx 라우팅 (kc-lab-edge)
## 이 단계가 끝나면
밖에서 보낸 요청이 **호스트 DNAT → 엣지 nginx → Traefik → 파드**로 닿는다.
아직 TLS 는 없다.
## 전제
[02](../02-k3s/) 가 끝나 두 노드가 `Ready` 이고, [01](../01-vms/) 에서
`kc-lab-edge`(192.168.122.10) 까지 세 대가 떠 있다.
**엣지에 nginx 는 아직 없다.** cloud-init 이 까는 것은 `curl``nftables`
뿐이라 0번에서 직접 깐다.
## 왜 프록시가 두 겹인가
nginx 와 Traefik 이 하는 일이 다르다.
| | 맡는 것 |
|---|---|
| 엣지 nginx (`kc-lab-edge`) | 바깥세상과의 접점 — TLS 종단 · 인증서 · `X-Forwarded-*` |
| Traefik | 클러스터 안의 동적 라우팅 — Ingress 를 보고 서비스를 고른다 |
**이 2홉이 운영 구조와 같다는 것이 이 배치의 핵심**이고, 동시에 B-4 의 헤더
실험이 성립하는 이유다. 1홉을 가정하고 쓴 계약이 2홉에서도 유효한지를
재려면 두 겹이 있어야 한다.
## 왜 엣지가 물리 호스트가 아니라 VM 인가
**L7 홉 수는 그대로 2홉이다.** 늘어난 것은 커널이 하는 L4 전달 한 번뿐이다.
바뀐 것은 **더러워지는 층이 어디냐**다. nginx 설정 · 인증서 · certbot · deploy
훅은 자주 고치고 자주 갈아엎는 것들인데, 그것이 물리 호스트에 있으면
「깨끗하게 초기화하고 다시」가 불가능하다. 엣지가 VM 이면 초기화가
`virsh undefine kc-lab-edge --remove-all-storage` 한 줄이 된다.
물리 호스트에 남는 실험대 설정은 **DNAT 규칙 하나와 DHCP 예약 세 줄**뿐이고,
둘 다 한 번 쓰고 다시 안 건드린다.
덤으로 **엣지 장애를 실험할 수 있게 된다.** 엣지가 물리 호스트일 때는
`systemctl stop nginx` 가 진입 경로(SSH)까지 위험하게 만들어서 A층 실험 9건
어디에도 엣지 장애가 없었다. VM 이면 A-4 와 똑같이 `virsh destroy` 로 뽑는다.
---
## 0. nginx 가 깔려 있는지부터 본다
**cloud-init 이 깐 것은 `curl` 과 `nftables` 뿐이다** ([01](../01-vms/) 의
`packages:` 줄). 엣지 VM 을 새로 만들었으면 **nginx 는 아직 없다.**
**하기**`[kc-lab-edge]`
```bash
which nginx
```
**관측** — 2026-09-11, 새로 만든 `kc-lab-edge` 에서 실제로 나온 것
```
donghyeon@kc-lab-edge:~$ cd /etc/nginx/
-bash: cd: /etc/nginx/: No such file or directory
```
**어디를 봐야 하는가**`which nginx`**아무것도 안 찍으면** 미설치다.
`/etc/nginx` 디렉터리는 패키지가 만든다. 그것이 없다는 것은 **경로를 잘못
찾은 것이 아니라 설치가 안 된 것**이다.
**설치**`[kc-lab-edge]`
```bash
sudo apt update && sudo apt install -y nginx
```
**확인**
```bash
systemctl status nginx --no-pager | head -5
ls /etc/nginx/
```
**어디를 봐야 하는가**`Active:` 줄이 `active (running)` 인가, 그리고 `ls`
결과에 **`sites-available``sites-enabled` 가 둘 다** 있는가. Debian 계열은
설치와 동시에 기동까지 한다 — 따로 `systemctl start` 를 칠 일이 없다.
**이 결과가 의미하는 것** — 이 시점에 nginx 는 **이미 80 포트를 잡고 있다.**
그것을 잡고 있는 것은 `sites-enabled/default` 이고, 1번에서 쓸 설정도
`listen 80 default_server`**그대로 두면 겹친다.**
**하기** — 기본 사이트를 끈다. `[kc-lab-edge]`
```bash
ls -l /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default
```
**어디를 봐야 하는가**`ls -l` 의 화살표다. `default -> ../sites-available/
default` 처럼 **심볼릭 링크**다. 지우는 것은 링크뿐이고 원본은
`sites-available/default` 에 그대로 남는다 — 되돌리려면 `ln -s` 로 다시
걸면 된다.
**이 결과가 의미하는 것** — 안 지우면 2번의 `nginx -t`
`a duplicate default server for 0.0.0.0:80` 으로 막는다. 설정이 틀린 것이
아니라 **기본 사이트와 겹친 것**이다.
**`sites-available` 은 복수형이다.** `site-available` 로 치면 `nano`
군말 없이 **빈 새 파일을 연다.** 저장해도 nginx 는 그 파일을 영원히 안 읽고,
`nginx -t` 는 멀쩡히 통과한다 — **아무 에러 없이 아무 일도 안 일어나는** 가장
찾기 어려운 형태다. 설정을 썼는데 변화가 없으면 경로 오타부터 의심한다.
```bash
ls /etc/nginx/sites-available/
```
**어디를 봐야 하는가** — 방금 쓴 파일 이름이 **여기** 보이는가. 안 보이면
다른 데다 썼다. 어디다 썼는지는 `sudo find /etc/nginx -name 'keycloak*'`
찾는다.
> **Arch 호스트에는 이 구조가 없다.** `sites-available`/`sites-enabled` 는
> Debian 패키징 관례다. Arch 의 nginx 는 `nginx.conf` 에 직접 쓰거나
> `conf.d/` 를 쓴다. 이 실험대는 **운영과 맞추려고 Debian 게스트를 엣지로
> 두었다** — 그래서 여기서는 Debian 관례가 그대로 통한다.
---
## 1. 설정을 쓴다 — `[kc-lab-edge]`
**이 단계에서는 80 만 세운다.** TLS 는 [04](../04-tls/) 에서 얹는다.
인증서가 없는데 `ssl_certificate` 줄을 미리 써 두면 **설정 전체가 실패해서
80 블록까지 안 뜬다** — nginx 는 그 파일을 나중이 아니라 **기동·reload
시점에** 읽기 때문이다.
**하기** — 편집기를 연다. `[kc-lab-edge]`
```bash
sudo nano /etc/nginx/sites-available/keycloak-lab
```
내용은 이것이다. 저장은 `Ctrl+O``Enter`, 나가기는 `Ctrl+X`.
```nginx
# 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;
}
}
```
**어디를 봐야 하는가**`X-Forwarded-Proto``http` 다. **04 에서 `https`
로 바꾼다.** 이 헤더는 뒷단에 「원래 요청이 무슨 스킴이었나」를 알려주는
값이라, 실제로 80 으로 들어오는데 `https` 라고 적으면 Keycloak 이 리다이렉트
주소를 `https://` 로 만들고 **로그인 도중에 끊긴다.** 거짓말하면 안 되는
헤더다.
**활성화한다.** `[kc-lab-edge]`
```bash
sudo ln -sf /etc/nginx/sites-available/keycloak-lab /etc/nginx/sites-enabled/
ls -l /etc/nginx/sites-enabled/
```
**어디를 봐야 하는가**`keycloak-lab ->` 링크가 생겼는가, 그리고 **`default`
가 없는가**(0번에서 지웠다). 둘 다 `listen 80 default_server` 라 같이 있으면
2번의 `nginx -t``duplicate default server` 로 막는다.
## 2. 문법을 보고 적용한다
**하기**`[kc-lab-edge]`
```bash
sudo nginx -t && sudo systemctl reload nginx
```
통과하면 이런 형태다. Debian 12 는 `[warn]` 한 줄을 늘 같이 내놓는다.
```
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
```
**어디를 봐야 하는가**`nginx -t` 가 내놓는 **마지막 줄**이다. `syntax is
ok``test is successful` 두 마디가 다 나와야 통과다. 앞에 나오는
`[warn]` 줄(예: `types_hash_max_size`)은 통과를 막지 않는다 — 04 에서 이
경고를 실패로 오독하는 일이 실제로 벌어지므로, 여기서 **경고와 오류를
구분하는 눈**을 들여 둔다. 실패면 `[emerg]` 줄에 파일과 줄 번호가 찍힌다.
**이 결과가 의미하는 것** — 통과했으면 `&&` 뒤의 reload 가 이어서 돌고,
`systemctl reload` 는 아무 말 없이 끝난다(무소식이 좋은 소식이다). 실패했으면
`&&` 가 reload 를 **막아 준 것**이고, 지금 돌고 있는 nginx 는 옛 설정 그대로
멀쩡하다. 설정이 깨진 상태에서 reload 하면 새 워커를 못 띄운다 — `-t`
먼저 통과시키고 그때만 reload 하는 이유다.
reload 가 정말 반영됐는지는 워커가 갈렸는지로 본다. 같은 판정 방법을
04 에서 인증서 갱신에 그대로 쓴다.
```bash
systemctl status nginx --no-pager | head -20
```
**어디를 봐야 하는가**`Active:` 줄이 `active (running)` 인가, 그리고 그
아래 프로세스 트리에 `nginx: master process` 하나와 `nginx: worker process`
여럿이 붙어 있는가. 워커 줄의 **PID** 를 눈에 담아 둔다.
**이 결과가 의미하는 것** — reload 는 마스터를 그대로 두고 **워커만** 갈아
끼운다. 그래서 reload 전후로 워커 PID 가 바뀌면 새 설정이 실제로 적용된
것이고, 안 바뀌었으면 `-t` 는 통과했는데 reload 가 안 간 것이다.
04 에서 인증서 갱신이 서빙까지 닿았는지를 정확히 이 방법으로 판정한다.
## 3. 호스트에서 엣지로 넘긴다 (DNAT) — `[lab host]`
여기까지 하면 엣지 nginx 는 살아 있는데 **아무도 거기로 안 보낸다.** tailnet
주소 `100.83.212.4:443` 을 받는 것은 여전히 물리 호스트다. 그 트래픽을
엣지로 넘기는 것이 이 단계고, **물리 호스트가 실험대를 위해 하는 일의
전부**다.
**★ 이 단계만 엣지가 아니라 물리 호스트에서 친다.** 0~2 번은
`[kc-lab-edge]` 였다. 여기서 바뀌는 이유는 두 가지다.
| | |
|---|---|
| **`tailscale0` 이 호스트에만 있다** | 규칙의 첫 줄이 `iifname "tailscale0"` 이다. **VM 에는 Tailscale 을 넣지 않기로 했으므로** 엣지에는 그 인터페이스 자체가 없다 |
| **엣지는 받는 쪽이다** | 넘기는 것은 `192.168.122.10` **으로** 가는 트래픽이다. 넘기는 주체는 그 앞에 있는 호스트다 |
엣지에 SSH 해서 치면 `tailscale0` 이 없어 규칙이 의미가 없다.
**하기** — 규칙 파일을 쓴다. `[lab host]`
```bash
sudo mkdir -p /etc/nftables.d
sudo nano /etc/nftables.d/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
}
}
```
**어디를 봐야 하는가** — 맨 위의 `table ip lab_edge`
`delete table ip lab_edge` 두 줄, 그리고 **포트 숫자**다.
- **두 줄짜리 관용구** — 없는 테이블을 지우면 에러이므로 한 번 만들고 지운다.
이래야 같은 파일을 몇 번 적용해도 안전하다.
- **`443``433` 으로 치지 않는다.** `433` 도 유효한 포트라 nft 가 군말 없이
받는다. 80 은 멀쩡히 넘어가므로 3·4번은 다 통과하고, **04 에서 HTTPS 만
안 되는 형태로 뒤늦게 터진다.** 이 실험대에서 실제로 나왔던 오타다.
**하기** — 유닛 파일을 쓴다. `[lab host]`
```bash
sudo nano /etc/systemd/system/lab-edge-dnat.service
```
```ini
# 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
```
**★ 경로를 끝까지 친다 — `/etc/systemd/` 가 아니라 `/etc/systemd/system/`.**
`nano` 는 없는 파일이면 **말없이 새로 만든다.** 그래서 한 단계 위에 만들어도
아무 경고가 없고, systemd 는 거기를 유닛 디렉터리로 읽지 않아
`Unit lab-edge-dnat.service does not exist` 만 반복된다. 이 실험대에서 실제로
겪은 형태다 — `/etc/systemd/``journald.conf` 같은 **systemd 자체 설정**이
사는 곳이다.
**확인** — 두 파일이 제자리에 있는지 본다. `[lab host]`
```bash
ls -l /etc/nftables.d/lab-edge-dnat.nft /etc/systemd/system/lab-edge-dnat.service
```
**어디를 봐야 하는가****두 줄이 다 나와야 한다.** 한 줄이라도 `No such
file` 이면 다음 명령은 무조건 실패한다. systemctl 이 이상한 것이 아니라
**파일이 없는 것**이다.
**적용한다.** `[lab host]`
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now lab-edge-dnat.service
```
**`daemon-reload` 를 빠뜨리지 않는다.** 유닛 파일을 새로 써도 systemd 는
다시 읽기 전까지 모른다. 파일은 제자리에 있는데 `does not exist` 가 나오면
이것이다.
규칙의 알맹이는 두 줄이고, **둘이 사는 곳이 다르다.**
| 하는 일 | 어디에 |
|---|---|
| `iifname "tailscale0" tcp dport { 80, 443 } dnat to 192.168.122.10` | 우리 테이블 `lab_edge` (`.nft` 파일) |
| `oif virbr0 ip daddr 192.168.122.10 … ct state new accept` | **libvirt 테이블 `libvirt_network` 의 `guest_input` 체인** (유닛의 `ExecStartPost`) |
**★ SNAT 을 걸지 않는다.** 게스트의 기본 게이트웨이가 호스트라 응답은
어차피 여기로 돌아오고, conntrack 이 알아서 되돌린다. masquerade 를 붙이면
출발지가 덮여서 **엣지가 모든 클라이언트를 `192.168.122.1` 로 본다** — 이
실험대가 재고 있는 `X-Forwarded-For` 계약이 통째로 무의미해진다.
**★ 두 번째 줄이 왜 libvirt 테이블 안으로 들어가나 — 여기가 이 단계에서
가장 많이 막히는 곳이다.**
libvirt 는 게스트 대역으로 **새로 들어오는 연결을 거절**한다. 자기 테이블
`libvirt_network``guest_input` 체인이 이렇게 끝난다.
```
oif "virbr0" ip daddr 192.168.122.0/24 ct state established,related accept
oif "virbr0" reject ← 여기서 죽는다
```
**우리 테이블에 아무리 먼저 `accept` 를 놔도 소용이 없다.** nftables 는
앞 체인의 `accept`**뒤 체인의 `reject` 를 막아 주지 않는다** — 여러 base
체인이 같은 훅에 붙어 있으면 전부 평가된다. iptables 감각으로 쓰면 여기서
정확히 틀린다.
그래서 구멍은 **libvirt 체인 맨 앞에** 뚫는다. `insert` 가 체인 맨 앞에
넣는다는 점이 핵심이다(`add` 는 맨 뒤 = reject 뒤 = 의미 없음).
**손으로 한 번 넣어 볼 때**`[lab host]`
```bash
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
```
**`'{80,443}'` 의 따옴표를 빼지 않는다.** bash·zsh 가 중괄호를
**`80 443` 두 낱말로 펼쳐** 버려서 `Error: syntax error, unexpected ct`
난다. 규칙은 안 들어갔는데 에러만 보고 넘기기 쉽다.
**확인** — 새 규칙이 `reject` **위**에 있는가
```bash
sudo nft -a list chain ip libvirt_network guest_input
```
**이 규칙은 휘발성이다.** libvirt 가 네트워크를 다시 세우면(호스트 재부팅,
`virsh net-start`, libvirtd 재시작) `guest_input` 을 새로 쓰면서 날아간다.
그래서 유닛의 `ExecStartPost` 에 넣어 두고, 날아갔으면
`sudo systemctl restart lab-edge-dnat.service` 로 다시 넣는다.
**확인**
```bash
sudo nft list table ip lab_edge
```
**어디를 봐야 하는가**`dnat to 192.168.122.10` 한 줄이 있는가,
**포트가 `80, 443` 인가**, 그리고 **`masquerade``snat` 이 없는가.**
**★ 포트 숫자를 꼭 눈으로 읽는다.** `443``433` 으로 치면 nft 는 군말 없이
받는다(유효한 포트 번호다). 80 은 멀쩡히 넘어가므로 3·4번은 다 통과하고,
**04 에서 HTTPS 만 안 되는 형태로 뒤늦게 터진다.** 이 실험대에서 실제로
나왔던 오타다.
**이 결과가 의미하는 것** — PREROUTING nat 은 라우팅 결정보다 먼저 돌기
때문에 이 규칙이 **호스트 자신의 `:443` 소켓보다 우선한다.** 그래서 물리
호스트에 nginx 가 아직 떠 있어도 트래픽은 엣지로 간다 — 전환이 원자적이고,
되돌리기도 한 줄이다.
## 4. 층별로 확인한다 — 아래에서 위로
한 번에 밖에서 치지 말고, **가까운 층부터** 본다. 어디서 끊겼는지가 바로 나온다.
> **`curl` 을 두 형태로 쓴다.** 처음 볼 때는 `-I`(헤더까지 읽는 형태)로
> **응답을 눈으로 읽고**, 같은 것을 여러 번 재거나 두 노드를 나란히 비교할
> 때만 `-w '%{http_code}'`(값만 뽑는 형태)로 바꾼다. 값만 뽑는 형태는
> 골라 놓은 한 칸 말고는 전부 버리므로, **무엇이 잘못됐는지 모르는 상태**
> 에서는 쓸 것이 못 된다.
**확인 ①** Traefik 이 듣고 있나 (nginx 를 건너뛴다)
```bash
curl -I http://192.168.122.11
```
**형태** (이 실험대에서 캡처해 두지 않았다 — 봐야 할 줄만)
```
HTTP/1.1 404 Not Found
...
```
**어디를 봐야 하는가** — 첫 줄의 상태 줄 하나. 그리고 그 앞에 **아무 오류도
없이** 헤더가 나왔다는 사실. `curl: (7) Failed to connect` 이면 응답 자체가
없는 것이라 상태 코드를 볼 일도 없다.
**이 결과가 의미하는 것****`404` 가 성공 신호다.** 게스트의 80 을 누가
듣고 있고(Traefik), 그가 요청을 받아 「매칭되는 Ingress 규칙이 없다」고
답한 것이다. 이 층은 통과. `502` 면 Traefik 은 떴는데 그 뒤 백엔드가 없는
것이고, 연결 거부·타임아웃이면 Traefik 이 안 떴거나 게스트가 죽은 것이라
**02 로 돌아간다.**
두 노드가 같은지 볼 때는 값만 뽑는 형태가 낫다. 두 줄을 나란히 놓고 눈으로
비교하는 것이 목적이기 때문이다.
```bash
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
```
**어디를 봐야 하는가** — 두 줄이 **같은 코드**인가.
**이 결과가 의미하는 것** — 둘이 같으면 nginx 가 어느 쪽으로 보내도 같은
결과가 나온다. 한쪽만 다르면 upstream 두 개 중 하나가 죽은 것이고, 그
상태에서는 **요청의 절반만 실패**해서 「가끔 안 된다」로 보인다.
**확인 ②** 엣지 nginx 가 직접 응답하나 (DNAT 을 건너뛴다)
```bash
curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' http://192.168.122.10
```
**어디를 봐야 하는가**`301``Location` 이 나오는가. 여기서 막히면
문제는 **엣지 안**이다(설정·기동). 통과하는데 아래 ③ 이 안 되면 문제는
**DNAT** 이다. 이 한 층을 끼워 두면 그 둘을 헷갈리지 않는다.
**확인 ③** 밖에서, 즉 DNAT 을 거쳐 닿나
```bash
curl -I http://auth.hyeonworks.com
```
**형태** (봐야 할 두 줄만)
```
HTTP/1.1 301 Moved Permanently
Location: https://auth.hyeonworks.com/
...
```
**어디를 봐야 하는가** — 상태 줄과 **`Location:` 헤더 한 줄**. `Location`
`https://` 로 시작하고 원래 호스트명을 그대로 들고 있는가. `$host` 대신
설정에 이름을 박아 두면 여기서 엉뚱한 호스트가 나온다.
**이 결과가 의미하는 것** — 301 이 나왔다는 것은 **바깥 요청이 DNAT 을 거쳐
엣지 nginx 까지 닿았다**는 뜻이다(①은 Traefik 에 직접, ②는 엣지에 직접 친
것이라 DNAT 을 안 거쳤다). DNS·DNAT·80 리스너가 전부 살아 있다. 응답이 아예 없으면 nginx 가 안 떴거나
80 이 막힌 것이다. 리다이렉트를 따라가 끝까지 보려면 `-L` 을 붙인다.
값을 반복해서 잴 때의 형태는 이것이고, 아래가 이 실험대의 실측이다.
```bash
curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' http://auth.hyeonworks.com
```
```
301 https://auth.hyeonworks.com/
```
**확인 ④** 끝까지 닿나 (TLS 이후)
```bash
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
```
```
200
```
**어디를 봐야 하는가** — 코드 한 칸. 여기서만 값만 뽑는 형태를 바로 쓰는
이유는, 이 200 이 **04·05 에서 매번 같은 명령으로 다시 잴 기준값**이기
때문이다. 처음 한 번은 `curl -I https://auth.hyeonworks.com/realms/master`
로 헤더까지 보고, 그다음부터 이 형태로 줄인다.
**이 결과가 의미하는 것**`200` 이면 nginx → Traefik → 파드까지 2홉이 다
이어졌다. `502` 는 nginx 는 살아 있는데 upstream 을 못 잡은 것(4번의 로그를
본다), `curl: (60)` 같은 인증서 오류는 아직 04 를 안 한 것이다. TLS 단계에서
막히면 코드만 보지 말고 `curl -v` 로 협상 과정을 읽는다 — 04 에서 그렇게 한다.
## 5. upstream 이 둘인 이유
```
upstream k3s_traefik {
server 192.168.122.11:80;
server 192.168.122.12:80;
}
```
두 노드 모두 Traefik 이 뜨므로 어느 쪽으로 보내도 된다. nginx 는 기본
라운드로빈으로 번갈아 보내고, **한쪽이 죽으면 자동으로 뺀다.**
그 「빼는」 동작이 로그에 이렇게 남는다.
```
connect() failed (113: No route to host) ← 호스트에 못 닿는다
connect() failed (111: Connection refused) ← 포트에 아무도 없다
no live upstreams ← 둘 다 죽었다고 판단
```
**113 과 111 은 대응이 다르다.** 113 은 네트워크, 111 은 프로세스다.
A-4 에서 노드를 잃었을 때 이 세 줄이 1분 안에 순서대로 나왔다.
---
## 막히면
| 증상 | 어디서 끊겼나 | 확인 |
|---|---|---|
| ① 이 연결 거부 | Traefik 이 안 떴거나 게스트가 죽음 | `kubectl get pods -n kube-system` |
| ① 이 `502` | Traefik 은 떴는데 백엔드가 없음 | Ingress 확인 |
| ② 가 응답 없음 | nginx 가 안 떴거나 방화벽 | `systemctl status nginx` |
| ③ 이 `502` | 인증서 문제 또는 upstream 다운 | [04](../04-tls/) · 아래 로그 |
| `/etc/nginx: No such file or directory` | **nginx 미설치.** cloud-init 은 안 깐다 | `which nginx` → [0번](#0-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``listen 443 ssl http2` 형태로 |
| `systemctl reload` 가 실패한다 | 설정이 `nginx -t` 를 못 통과했다 | **reload 말고 `sudo nginx -t` 를 먼저** 친다 |
| `cp: cannot stat 'deploy/...'` | 엣지에서 쳤거나, lab host 에 리포가 없다 | `ls ~/workspace` → 없으면 3번의 **B** 로 |
| `Unit lab-edge-dnat.service does not exist` | 유닛 파일이 없거나, `/etc/systemd/` 에 썼거나, `daemon-reload` 를 안 했다 | `find /etc/systemd -name 'lab-edge-dnat*'``system/` 아래여야 한다 |
| 80 은 되는데 HTTPS 만 안 된다 | `.nft` 의 포트가 `433` 오타 | `grep dport /etc/nftables.d/lab-edge-dnat.nft` |
| 호스트 안에서는 404 인데 **밖에서만 connection refused** | libvirt `guest_input``reject`. 3번의 두 번째 줄이 빠졌거나 날아갔다 | `sudo nft -a list chain ip libvirt_network guest_input``reject` 줄의 카운터가 올라가면 그것이다 |
| `Error: syntax error, unexpected ct` | 셸이 `{80,443}` 을 펼쳤다 | `'{80,443}'` 로 따옴표 |
**로그를 볼 때** — 실무자가 치는 형태다.
```bash
journalctl -u nginx -p err -n 5 # 최근 에러만
journalctl -u nginx -f # 지금 벌어지는 것
```
**어디를 봐야 하는가** — 각 줄의 **괄호 안 errno**(`113`·`111`)와 그 뒤의
`upstream: "http://192.168.122.1x:80/..."` 부분. 어느 upstream 이 문제인지가
거기 적혀 있다. 그리고 **타임스탬프** — 방금 친 요청 시각과 안 맞으면 지금
보고 있는 것은 옛 사고다.
**이 결과가 의미하는 것**`-p err` 로 걸러도 아무것도 안 나오면 nginx 는
정상이고 문제는 더 위(Traefik·파드)에 있다. 두 번째 명령은 **띄워 놓은
채로 다른 창에서 요청을 치는** 용도다 — 요청과 로그 줄을 눈으로 짝지으면
「이 요청이 어느 upstream 으로 갔나」가 바로 보인다. 끝내려면 Ctrl+C.
**★ nginx 에러 로그는 2048바이트에서 잘린다.** 긴 URL 이 끝에서 단어 중간에
끊겨 보이면 그것이다. 되찾으려면 **access 로그를 본다** — 거기엔 제한이 없다.
```bash
grep oauth2/callback /var/log/nginx/access.log | tail -1
```
**어디를 봐야 하는가** — 그 한 줄의 **끝**이다. 요청 URL 이 `"` 로 제대로
닫혀 있으면 온전한 줄이고, 단어 중간에서 멈춰 있으면 잘린 것이다. 길이가
궁금하면 세어 본다.
```bash
grep oauth2/callback /var/log/nginx/access.log | tail -1 | wc -c
```
**이 결과가 의미하는 것** — error 로그와 access 로그의 같은 요청 길이가 다르면
**error 쪽이 잘린 것**이지 요청이 잘린 것이 아니다. 이 실험대에서 B-7 의
502 원인이 error 로그에 있었는데 잘려 있었고, access 로그에는 3492자로 온전히
남아 있었다. 잘린 문자열을 놓고 원인을 추측하면 없는 문제를 좇게 된다.
---
## 되돌리기
**세우는 절차가 아니다.** 이 단계를 걷어낼 때만 본다.
| 무엇을 | `[어디서]` | 명령 |
|---|---|---|
| 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` 로 다시 걸면 복구된다.
**`.nft` 파일 맨 위의 `delete` 는 이것과 다르다.** 그 두 줄은 **파일을
적용할 때마다 자동으로 도는 재적용 안전장치**(없는 테이블을 지우면 에러라서
빈 테이블을 한 번 만들고 지운다)이고, 위 표의 `nft delete` 는 **사람이 끄는
버튼**이다.
@@ -0,0 +1,655 @@
# 04 — TLS
## 이 단계가 끝나면
`https://` 가 열리고 체인이 완전하며, 갱신이 자동으로 **서빙까지** 닿는다.
## 전제
[03](../03-nginx/) 이 끝나 엣지 nginx 가 Traefik 으로 프록시한다.
## 어디서 치는가
**1~3 번은 전부 `[kc-lab-edge]` 에서 친다. 단 4번 확인만 tailnet 에 붙은
다른 머신에서 친다** — 엣지에는 Tailscale 이 없어 자기 자신을 밖에서 들어오는
경로로 부를 수 없다.
인증서·certbot·갱신 타이머·deploy 훅이 전부 엣지 게스트에 산다. 물리 호스트에는 아무것도 두지 않는다 —
그래야 `virsh undefine kc-lab-edge` 한 줄로 이 계층을 통째로 되돌릴 수 있다.
## ★ 검증 방식을 먼저 정한다 — HTTP-01 이냐 DNS-01 이냐
같은 Let's Encrypt 인증서인데 **「이 도메인이 네 것이냐」를 증명하는 방법**만
다르다. 그리고 이 실험대에서는 **선택의 여지가 없다.**
| | HTTP-01 | DNS-01 |
|---|---|---|
| 검증 방향 | Let's Encrypt **→ 우리 서버** (인바운드) | certbot **→ DNS 공급자 API** (아웃바운드) |
| 공개 인터넷에서 보여야 하나 | **그렇다** | 아니다 |
| 와일드카드 | 불가 | 가능 |
| 필요한 것 | 80 포트 · 공개 A 레코드 | DNS 공급자 API 토큰 |
**이 실험대는 공개 인터넷을 쓰지 않는다.** 도메인 세 개는 tailnet 주소를
가리킨다.
```bash
dig +short auth.hyeonworks.com
```
```
100.83.212.4
```
`100.64.0.0/10` 은 CGNAT 용으로 예약된 대역이라 **공개 인터넷에서 라우팅
자체가 되지 않는다.** 방화벽을 여는 문제가 아니라 그 주소가 인터넷에 존재하지
않는다. Let's Encrypt 를 tailnet 에 초대할 방법도 없다. **그래서 HTTP-01 은
쓸 수 없고 DNS-01 을 쓴다.**
> **공개 서버라면 HTTP-01 이 맞다.** 토큰도 DNS 연동도 필요 없어서 관리할
> 것이 적다. DNS-01 이 더 좋은 방식이어서 고르는 것이 아니라, HTTP-01 이
> 못 쓰이는 환경이라 고르는 것이다. 개념은
> [`session-lab-concepts.md`](../../session-lab-concepts.md) 의
> 「DNS-01 은 언제 쓰는가」.
---
## 1. certbot 을 깐다
cloud-init 이 이미 깔았다면 건너뛴다 —
[`kc-lab.yaml.example`](../../../deploy/lab/cloud-init/kc-lab.yaml.example) 의
`packages` 에 들어 있다.
**하기**`[kc-lab-edge]`
```bash
sudo apt install -y certbot python3-certbot-dns-cloudflare
```
**확인** — 쓸 수 있는 검증 방식이 무엇인가
```bash
certbot plugins 2>/dev/null | grep -E '^\*'
```
**실측**
```
* dns-cloudflare
* standalone
* webroot
```
**어디를 봐야 하는가**`dns-cloudflare` 한 줄이 있는가. 없으면 플러그인
패키지가 안 깔린 것이고, `--dns-cloudflare` 를 줘도 `unrecognized arguments`
로 끝난다.
## 2. 인증서를 받는다
**순서** — 토큰 발급(브라우저) → 토큰 파일 → 토큰 검증 → 시험 발급 → 실제
발급 → 확인. 2-2 부터는 전부 `[kc-lab-edge]` 에서 친다.
### 2-1. 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**
**★ 토큰 값은 이 화면에서 한 번만 보인다.** 창을 닫으면 복구가 없다.
**`All zones` 로 두지 않는다.** 계정의 모든 도메인에 대한 DNS 수정 권한이
엣지 VM 안 평문 파일에 놓인다. Global API Key 는 더 나쁘다 — 계정 전체 만능
키라 폐기하면 그 키를 쓰던 다른 것들이 전부 같이 죽는다.
### 2-2. 토큰 파일
```bash
sudo install -m 600 /dev/null /etc/letsencrypt/cloudflare.ini
sudo nano /etc/letsencrypt/cloudflare.ini
```
```ini
# file: /etc/letsencrypt/cloudflare.ini
dns_cloudflare_api_token = 발급받은_토큰_값
```
**`install -m 600` 을 먼저 치는 이유** — 파일을 **비어 있을 때** 미리 600 으로
만든다. 토큰을 쓰고 나서 권한을 고치면 그사이가 열려 있다.
**확인**
```bash
ls -l /etc/letsencrypt/cloudflare.ini
sudo wc -c /etc/letsencrypt/cloudflare.ini
```
**어디를 봐야 하는가**`-rw-------` 이고 바이트 수가 0 이 아닌가.
`sudo` 없이 `wc` 를 치면 `Permission denied` 가 나는데 **그게 정상**이다
(600 = root 만 읽기). 토큰 값 자체는 출력하지 않는다.
### 2-3. 토큰 검증
```bash
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"
```
**어디를 봐야 하는가**`"status":"active"``"success":true`.
`"code":6003` 이면 토큰 값이 틀렸거나 잘렸고, `"code":9109` 면 권한 범위가
모자라다(`Zone` · `Zone` · `Read` 를 한 줄 더한다).
**이 결과가 의미하는 것** — 여기서 걸러 두면 뒤에서 실패했을 때 「DNS 문제인지
토큰 문제인지」를 헷갈리지 않는다.
### 2-4. 시험 발급 — `--dry-run`
```bash
sudo certbot certonly --dns-cloudflare \
--dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
-d hyeonworks.com -d '*.hyeonworks.com' --dry-run
```
**어디를 봐야 하는가** — 마지막 줄 `The dry run was successful.`
**★ dry-run 은 인증서를 저장하지 않는다.** 스테이징 서버에 대고 시험만 하는
것이라 `/etc/letsencrypt/live/` 에는 아무것도 안 생긴다. **여기서
`certbot certificates` 를 치면 `No certificates found` 가 나오는 것이 정상**
이다. 이걸 먼저 돌리는 이유는 Let's Encrypt 의 **주당 중복 인증서 5장** 한도를
dry-run 이 쓰지 않기 때문이다.
**★ 최초 실행이면 계정 등록 대화가 먼저 뜬다.**
| 질문 | 답 |
|---|---|
| `Enter email address` | **본인 이메일** (빈 값이면 `Invalid email address: .` 로 되묻는다) |
| Terms of Service | **Y** |
| EFF 뉴스레터 | **N** — 발급과 무관하다 |
최초 1회뿐이다. 프롬프트 없이 돌리려면 `-m <이메일> --agree-tos --no-eff-email`
을 붙인다. `--register-unsafely-without-email` 은 쓰지 않는다 — 갱신 실패를
알려줄 통로가 사라지는데, 5번이 재는 것이 바로 그 갱신이다.
### 2-5. 실제 발급 — `--dry-run` 을 뺀 같은 명령
```bash
sudo certbot certonly --dns-cloudflare \
--dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
-d hyeonworks.com -d '*.hyeonworks.com'
```
**어디를 봐야 하는가**`Successfully received certificate.` 와 그 아래
저장 경로. **DNS-01 은 느리다** — TXT 가 퍼질 때까지 기다리느라 수십 초
걸린다. 중간에 끊지 않는다.
### 2-6. 확인
```bash
sudo certbot certificates
```
**어디를 봐야 하는가** — 네 줄이다.
| 줄 | 값 |
|---|---|
| `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` |
**★ 디렉터리 이름과 인증서가 덮는 이름은 별개다.** 여기서 가장 많이 헷갈린다.
| | 무엇 | 정해지는 방식 |
|---|---|---|
| 디렉터리 이름 `live/hyeonworks.com/` | certbot 이 이 묶음(lineage)을 관리하려고 붙인 **라벨** | **첫 번째 `-d`** 에서 따온다. 서빙과 무관 |
| SAN 목록 | 브라우저가 보는 **실제 유효 호스트명** | `-d` 로 준 이름 전부 |
그래서 `auth.hyeonworks.com` 으로 **다시 받을 필요가 없다.**
`*.hyeonworks.com` 한 장이 `auth`·`app1`·`app2` 를 전부 덮는다. 다만 3번의
nginx 설정에는 **디렉터리 경로**를 한 글자도 다르지 않게 적어야 한다 —
`live/auth.hyeonworks.com/` 이라고 적으면 `cannot load certificate` 로 막힌다.
**확인** — 인증서가 실제로 어떤 이름에 유효한가
```bash
sudo openssl x509 -noout -ext subjectAltName -in /etc/letsencrypt/live/hyeonworks.com/fullchain.pem
```
**어디를 봐야 하는가**`DNS:hyeonworks.com, DNS:*.hyeonworks.com` 두 개.
`auth.hyeonworks.com` 은 두 번째에 걸린다.
**★ 와일드카드는 한 단계만 덮는다.** `a.b.hyeonworks.com` 은 포함되지 않고,
apex 인 `hyeonworks.com` 자신도 `*.hyeonworks.com` 에 들어가지 않는다 —
그래서 `-d` 를 둘 준 것이다.
**이 결과가 의미하는 것** — 여기 나온 경로가 곧 nginx 가 읽을 파일이다.
`*.hyeonworks.com` 한 장이 `auth`·`app1`·`app2` 를 전부 덮으므로, 이름을
하나 더 쓰고 싶어도 재발급이 필요 없다.
> **이 실험대는 처음에 와일드카드를 안 썼고 그 비용이 B-7 에서 청구됐다.**
> oauth2-proxy 를 올릴 네 번째 이름이 없어 Grafana 의 `app2` 를 빌려야 했다.
> D-4 계열 실험 기록에 `live/auth.hyeonworks.com/` 경로가 남아 있는 것은
> 그때의 실측이다.
## 3. nginx 에 443 을 얹는다
[03](../03-nginx/) 에서는 **80 만** 세웠다. 인증서가 생겼으니 이제 443 블록을
더하고, 80 은 리다이렉트로 바꾼다.
**★ 먼저 nginx 버전을 본다.** `[kc-lab-edge]`
```bash
nginx -v
```
**실측** — 같은 설정인데 배포판에서 갈린다.
```
엣지 (Debian 12): nginx version: nginx/1.22.1
물리 호스트 (Arch): nginx version: nginx/1.30.4
```
**어디를 봐야 하는가****1.25.1** 이 경계다. 그 미만이면 `http2 on;`
**지시어**가 없다. 아래처럼 `listen` 의 **파라미터**로 쓰면 1.22 와 1.30
양쪽에서 다 돈다. 지시어 형태로 쓰면 이렇게 막힌다.
```
[emerg] 1287#1287: unknown directive "http2" in /etc/nginx/sites-enabled/keycloak-lab:14
nginx: configuration file /etc/nginx/nginx.conf test failed
```
**하기**`[kc-lab-edge]`
```bash
sudo nano /etc/nginx/sites-available/keycloak-lab
```
03 에서 쓴 파일을 **이 내용으로 바꾼다.** 저장은 `Ctrl+O``Enter`,
나가기는 `Ctrl+X`.
```nginx
# 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;
}
}
```
**어디를 봐야 하는가** — 03 에서 바뀐 곳이 셋이다.
| 줄 | 03 에서는 | 지금 |
|---|---|---|
| 80 블록 | `location / { proxy_pass … }` | `return 301 https://…` 리다이렉트만 |
| 443 블록 | 없었다 | 인증서와 함께 새로 |
| `X-Forwarded-Proto` | `http` | **`https`** |
**`cert.pem` 이 아니라 `fullchain.pem`.** 서버 인증서만 보내면 중간 인증서가
빠져 체인이 끊긴다. 브라우저는 대개 캐시나 AIA 로 보완해서 **정상으로 보이고**,
캐시가 없는 클라이언트에서만 깨진다. 그래서 발견이 늦다. 2번에서 certbot 이
찍어 준 경로와 **한 글자도 다르면 안 된다.**
**하기** — 적용한다. `[kc-lab-edge]`
```bash
sudo nginx -t && sudo systemctl reload nginx
```
**어디를 봐야 하는가**`syntax is ok``test is successful` 두 마디가
다 나와야 통과다. `types_hash_max_size` 같은 `[warn]` 줄은 통과를 막지
않는다 — **경고와 오류를 구분한다.**
## 4. 확인 — 열리는가, 체인이 완전한가
**★ 이 단계만 `[kc-lab-edge]` 가 아니다 — tailnet 에 붙은 머신에서 친다.**
1~3 번은 전부 엣지에서 쳤지만, 확인은 **밖에서** 들어와야 의미가 있다.
엣지 안에서 치면 이렇게 막힌다.
```
* connect to 100.83.212.4 port 443 failed: Connection refused
```
`auth.hyeonworks.com` 은 호스트의 tailnet 주소 `100.83.212.4` 로 풀리는데,
**엣지 VM 에는 Tailscale 이 없다**(그렇게 결정했다). 그래서 엣지에서 나간
패킷은 호스트의 `virbr0` 으로 들어가고, DNAT 규칙은 `iifname "tailscale0"`
만 매칭하므로 안 걸린다 → 호스트 443 에 리스너가 없어 거절된다.
**설정 문제가 아니라 친 위치 문제다.**
**확인 ①** 열리나 — **처음 한 번은 협상 과정을 읽는다**
```bash
curl -v https://auth.hyeonworks.com/ -o /dev/null
```
**★ 경로는 `/` 다.** Keycloak 은 [05](../05-keycloak/) 에서 올린다. 아직
Ingress 가 없으므로 **`404` 가 정상**이고, 이 단계가 재는 것은 응답 코드가
아니라 **TLS 가 붙었는가**다. `/realms/master` 같은 Keycloak 경로를 여기서
쓰면 「TLS 가 안 된 건지 Keycloak 이 없는 건지」가 섞인다.
**형태** (이 실험대에서 캡처해 두지 않았다 — 읽어야 할 줄만)
```
* 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
```
**`subject``hyeonworks.com` 인 것이 맞다.** 와일드카드 인증서라 CN 은
apex 이름이고, `auth.hyeonworks.com` 은 SAN 의 `*.hyeonworks.com` 에 걸린다.
**어디를 봐야 하는가**`*` 로 시작하는 줄 넷이다. 어떤 TLS 판으로
협상했는가, `subject` 의 CN 이 지금 친 이름과 같은가, `issuer` 가 Let's
Encrypt 인가, 그리고 **`SSL certificate verify ok.`** 가 있는가. 그 아래
`<` 로 시작하는 첫 줄이 응답 상태다. `-o /dev/null` 은 본문만 버리는 것이라
이 줄들은 그대로 남는다.
**이 결과가 의미하는 것** — 이 네 줄이 다 나오면 인증서가 붙었고 체인이
클라이언트 기준으로 검증됐다. TLS 에서 막힐 때 봐야 할 것이 전부 여기
있으므로, **인증서를 처음 붙인 직후에는 코드 한 칸이 아니라 이 화면을
본다.** `verify` 줄 대신 `unable to get local issuer certificate` 가 나오면
중간 인증서가 빠진 것이고, 그 원인은 3번의 `cert.pem`/`fullchain.pem`
이다 — 확인 ②로 간다.
같은 것을 반복해서 재거나 03·05 의 값과 나란히 비교할 때만 값만 뽑는
형태로 줄인다.
```bash
curl -s -o /dev/null -w '%{http_code} tls=%{ssl_verify_result}\n' https://auth.hyeonworks.com/
```
**실측** — 2026-09-11, tailnet 클라이언트에서
```
404 tls=0
```
**어디를 봐야 하는가**`tls=0` 이 인증서 검증 통과다. 앞의 `404` 는 05
이후에 `200` 으로 바뀐다.
**확인 ②** 체인 단계와 검증
```bash
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'
```
**실측** — 이름을 따로 받던 시절의 기록이라 0번 `s:`
`auth.hyeonworks.com` 이다. 와일드카드로 받으면 `CN = hyeonworks.com`
되고, **봐야 할 구조는 똑같다.**
```
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)
```
**어디를 봐야 하는가** — 왼쪽의 **번호(0·1·2·3)가 몇까지 가는가**, 그리고
각 단계의 `i:`(발급자)가 **바로 다음 단계의 `s:`(주체)와 같은가**. 0번이
우리 서버 인증서이고, 위 실측에서 0의 `i:``CN = YE2` 인데 1의 `s:`
같은 `CN = YE2` 다 — 사슬이 이어져 있다는 뜻이다. 마지막이
`Verify return code: 0 (ok)`.
**이 결과가 의미하는 것****단계가 1개면 `cert.pem` 을 쓴 것이다.**
서버가 자기 인증서만 보내고 중간 인증서를 안 보낸 상태다. 이때 브라우저는
대개 캐시나 AIA 로 보완해서 **정상으로 보이므로**, 이 명령이 유일하게
믿을 수 있는 판정이다. 고치는 곳은 03 의 `ssl_certificate` 한 줄이고,
고친 뒤 `nginx -t && systemctl reload nginx` 하고 여기서 다시 잰다.
`Verify return code` 가 0 이 아니면 숫자마다 뜻이 다르다 —
`10` 은 만료, `20` 은 발급자를 못 찾음, `21` 은 첫 인증서를 검증 못 함.
**확인 ③** 이름 세 개가 한 인증서인가
```bash
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
echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \
| openssl x509 -noout -ext subjectAltName
```
## 5. ★ 갱신이 서빙까지 닿게 한다 — 여기가 이 단계의 핵심이다
**타이머가 도는 것만으로는 부족하다.**
**확인** — 타이머
```bash
systemctl list-timers certbot-renew.timer
```
**어디를 봐야 하는가** — 네 칸이다. `NEXT`(다음 실행 시각)와 `LEFT`(남은
시간)가 채워져 있는가, `LAST`/`PASSED` 가 하루 안쪽인가, `UNIT` 옆의
`ACTIVATES``certbot-renew.service` 를 가리키는가. **표가 통째로 비어
나오면 타이머가 없는 것이다** — 이름이 배포판마다 다르니
`systemctl list-timers --all | grep -i certbot` 로 찾는다.
**이 결과가 의미하는 것** — 여기까지가 「갱신이 돌기는 하는가」의 답이고,
대부분의 문서가 여기서 끝난다. **그런데 이것이 `active` 여도 갱신된 인증서가
서빙되지는 않는다.** nginx 는 인증서를
기동 시점에 읽어 메모리에 들고 있고, certbot 은 `live/` 심볼릭 링크만 갈아
끼운다. **경로는 그대로이고 내용만 바뀌므로 nginx 는 모른다.**
배포판 기본 유닛에는 reload 를 부르는 것이 없다.
```bash
systemctl cat certbot-renew.service
```
```
[Service]
Type=oneshot
ExecStart=/usr/bin/certbot -q renew
PrivateTmp=true
```
**어디를 봐야 하는가**`ExecStart=` 한 줄과, 그 아래에 `ExecStartPost=`
**있는지 없는지**. 그리고 `ExecStart` 의 인자에 `--deploy-hook` 이 붙어
있는지. 여기 없는 것을 보는 것이 이 명령의 목적이다.
**이 결과가 의미하는 것**`ExecStartPost``--deploy-hook` 도 없다.
즉 이 유닛은 **인증서를 새로 받는 데까지만** 책임지고, 받은 것을 누가
읽게 만드는 일은 아무도 하지 않는다. 배포판이 이렇게 준다는 것이 요점이다 —
「기본값이니 괜찮겠지」가 바로 이 결함의 서식지다. 여기 뭔가 적혀 있는
배포판이라면 아래 훅은 필요 없고, 대신 그 명령이 nginx 를 reload 하는지만
확인하면 된다.
**하기** — 훅 하나를 넣는다. `[kc-lab-edge]`
```bash
sudo nano /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
```
```sh
# file: /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
#!/bin/sh
nginx -t && nginx -s reload
```
**실행 권한을 준다.** 없으면 certbot 이 **조용히 건너뛴다.**
```bash
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
ls -l /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
```
**어디를 봐야 하는가** — 권한 문자열에 `x` 가 세 번(`-rwxr-xr-x`) 보이는가.
`-rw-r--r--` 면 아직 실행 파일이 아니다.
> **이 훅은 한동안 저장소에 없었다.** 호스트에만 있어서, 호스트를 초기화하면
> **아무 오류 없이 사라지고** D-4 가 측정한 상태(갱신 성공 · 서빙 38분 25초
> 지연 · 타이머는 `SUCCESS`)로 되돌아갔다. 저장소에 두는 이유가 이것이다.
`deploy/` 에 넣는다. `post/` 는 갱신이 없어도 매번 돌아 하루 두 번 워커를
갈아치운다. `deploy/`**실제로 갱신됐을 때만** 실행된다.
**확인** — 실제로 도는지
이 확인은 **상태를 바꾼다.** `--force-renewal` 은 인증서를 실제로 새로
받으므로 발급 한도(주당 중복 5장)를 깎는다. 먼저 `--dry-run` 으로 훅이
호출되는 것까지만 보고, 진짜 판정이 필요할 때만 강제 갱신을 한 번 쓴다.
```bash
sudo certbot renew --dry-run
```
**어디를 봐야 하는가** — 출력 끝의 `Running deploy-hook command` 줄과
`simulated renewals` 요약. 훅 줄이 아예 안 나오면 파일이 `deploy/` 가 아닌
곳에 있거나 실행 권한이 없는 것이다(`ls -l``x` 를 본다).
**이 결과가 의미하는 것** — dry-run 은 훅이 **호출되는지**까지만 말해 준다.
호출된 훅이 nginx 를 정말 갈아 끼웠는지는 dry-run 으로 알 수 없다 —
그래서 아래를 한 번 한다.
```bash
# 강제 갱신 전에 워커 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
```
**어디를 봐야 하는가** — 두 출력의 **첫 열(PID)과 둘째 열 묶음(lstart, 프로세스
시작 시각)**. 앞뒤로 놓고 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 하는 감시를 걸면 성공한 훅을 실패로 오독한다.**
**실측** — 이 실험대에서 잰 차이
| | 훅 없음 | 훅 있음 |
|---|---|---|
| 갱신 → 서빙 | **2305초 (38분 25초)** | **1~2초** |
| 무엇이 reload 했나 | 사람이 직접 | certbot deploy 훅 |
| 아무도 안 했다면 | 다음 nginx 재시작까지 = 사실상 무기한 | — |
**88일 동안 이 결함이 보이지 않는다.** 타이머는 정상이고 매번 `SUCCESS`
끝나며, 만료 30일 전까지는 갱신 자체를 하지 않아 발현할 기회가 없다.
발현하는 날의 증상은 **인증서 만료**이고, 그날에도 로그에는 `SUCCESS` 라고
적혀 있다.
원문: [D-4](../../experiment-d4-certificate-renewal.md) ·
[D-4a](../../experiment-d4a-deploy-hook.md) ·
증거 [`evidence/d4-certificate-renewal/`](../../evidence/d4-certificate-renewal/)
## 6. reload 는 무중단인가 — 쟀다
궁금할 것이므로 결과만 적는다. **무중단이다.**
새 연결 8856건 전부 200, p95 는 205.7ms 대 204.3ms 로 변화 없음. 그리고
845KB 를 20k/s 로 받는 중이던 요청이 **전송 12초째에 reload 를 맞고도**
845361바이트를 온전히 받았다(연결수 1). 옛 워커가 그 요청을 끝까지 책임진다.
---
## 막히면
| 증상 | 원인 | 확인 |
|---|---|---|
| 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 '^\*'` |
| DNS-01 이 오래 걸림 | TXT 전파 대기 | **정상이다.** 끊지 않는다 |
| 재구축 뒤 인증서가 없음 | 발급하지 말고 **백업을 되돌린다** | 한도를 아끼는 길이다 |
---
## 근거를 재려면 (선택)
평소에는 필요 없다. **문서에 남길 근거가 필요할 때만** 이렇게까지 한다.
갱신 중 가용성을 재려면 **주입 전에 대조군부터** 잡는다. 평시 오류율을 모르면
갱신 중에 나온 실패 한 건을 해석할 수 없다.
```bash
# 대조군 — 0.2초 × 900회 = 180초
i=0; while [ $i -lt 900 ]; do
curl -s -o /dev/null -w '%{http_code} %{time_total}\n' --max-time 5 \
https://auth.hyeonworks.com/realms/master
i=$((i+1)); sleep 0.2
done > /tmp/control.txt
awk '{print $1}' /tmp/control.txt | sort | uniq -c
```
**여기서는 값만 뽑는 형태가 맞다.** 900번을 재서 코드별로 세는 것이 목적이고,
헤더는 볼 일이 없다. 앞의 확인 ①과 형태가 다른 이유가 이것이다.
**어디를 봐야 하는가**`uniq -c` 가 내놓는 **줄이 몇 개인가**. 한 줄이면
900번이 전부 같은 코드였다는 뜻이고, 그 줄의 왼쪽 수가 900 인지 본다.
두 줄 이상이면 그 자체가 평시 오류가 있다는 뜻이다. 응답 시간이 궁금하면
둘째 열을 따로 본다.
```bash
awk '{print $2}' /tmp/control.txt | sort -n | tail -1 # 최악값
```
**이 결과가 의미하는 것** — 이 실험대의 대조군은 **900건 전부 200, 오류 0**
이었다. 그래서 갱신 중 비200 이 한 번이라도 나오면 갱신 탓으로 귀속할 수
있었다. 대조군에 이미 오류가 섞여 있다면 주입 중의 오류 한 건은 아무것도
증명하지 못하므로, **대조군이 깨끗해질 때까지는 주입을 하지 않는다.**
**그리고 두 기계의 시각을 나란히 놓기 전에 시계부터 잰다.**
```bash
A=$(date -u +%s.%N); B=$(ssh test-server 'date -u +%s.%N'); C=$(date -u +%s.%N)
# 왜곡 ≈ B (A+C)/2 , 어느 쪽이 맞는지는 외부 기준으로 가른다
curl -sI https://www.google.com | grep -i '^date:'
timedatectl show -p NTP -p NTPSynchronized
```
**어디를 봐야 하는가** — 세 수 `A`·`B`·`C` 를 눈으로 빼서 **초 단위 차이가
몇인가**. `A``C` 는 같은 기계에서 SSH 왕복 직전·직후에 찍은 것이라, 그
가운데가 「저쪽 시각을 잰 순간의 이쪽 시각」이다. 그다음 `Date:` 헤더의
시각이 둘 중 어느 쪽에 가까운가. 마지막으로 `NTPSynchronized=yes` 인가.
**이 결과가 의미하는 것** — 차이가 수초 이내면 두 기계의 로그를 그대로
나란히 놓아도 된다. 크면 **먼저 어느 쪽이 틀렸는지 가른 다음** 보정한다.
이 실험대는 test-server 가 NTP 미동기로 106초 빨랐고, 보정하지 않은 첫
계산은 훅이 인증서 발급보다 104초 먼저 실행된 것이 되어 물리적으로
불가능했다 — **음수 지연이 나오면 계산이 아니라 시계를 의심한다.**
@@ -0,0 +1,545 @@
# 05 — Keycloak 2노드 + PostgreSQL
## 이 단계가 끝나면
`https://auth.hyeonworks.com` 에서 관리 콘솔에 로그인되고, 두 Keycloak 이
하나의 클러스터로 보인다.
## 전제
[04](../04-tls/) 까지 끝나 `https://` 가 열린다.
---
## 1. 매니페스트를 적용한다
**하기**`[lab host]`, **저장소 루트에서**([00](../00-lab-host/) 에서 클론했다)
```bash
cd ~/workspace/keycloak-pattern
kubectl apply -f deploy/lab/k8s/keycloak-cluster.yaml
```
**★ 네임스페이스를 따로 만들지 않는다.** 매니페스트 첫 문서가
`kind: Namespace``apply` 가 같이 만든다. `kubectl create namespace`
먼저 치면 두 번째 실행부터 `AlreadyExists` 로 막힌다.
**확인** — 적용이 끝날 때까지 기다린다
```bash
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=300s
```
```
partitioned roll out complete: 2 new pods have been updated...
```
**어디를 봐야 하는가** — 이 명령은 **끝날 때까지 아무것도 안 찍고 멈춰 있다.**
그 침묵이 정상이다. 마지막에 나오는 한 줄에서 `complete` 라는 낱말과 파드
개수 `2` 를 본다. 300초를 다 쓰고 타임아웃으로 끝나면 그것도 답이다 —
「안 떴다」가 확정된 것이니 3번으로 간다.
**이 결과가 의미하는 것**`complete` 면 두 파드가 다 Ready 가 됐다는 뜻이라
2번의 층별 확인으로 넘어간다. `rollout status` 를 쓰는 이유는 `get pods`
반복해서 치는 것보다 나아서만이 아니라, **언제 끝났는지를 사람이 판정하지
않아도 되기** 때문이다. StatefulSet 은 파드를 하나씩 순서대로 띄우므로
중간에 `0/2` 로 한참 멈춰 있는 것은 정상이다.
---
## 2. 리소스가 제대로 만들어졌는지 — 층별로 본다
`kubectl get pods` 만 보면 놓치는 것이 많다. **위에서 아래로** 확인한다.
### 2-1. 무엇이 만들어졌나
**확인** — 이 네임스페이스에 무엇이 서 있는가
```bash
kubectl -n keycloak-lab get all
```
**어디를 봐야 하는가** — 종류별로 묶여 나오는 왼쪽 이름 열을 훑고, 파드
줄에서는 **READY 칸의 `1/1`** 과 **RESTARTS 칸**을 본다. RESTARTS 가 0 이
아니면 지금은 `Running` 이어도 한 번 죽었다 살아난 것이라, 3번의
`logs --previous` 를 볼 이유가 된다.
**이 결과가 의미하는 것** — 여기 보이는 것이 워크로드의 전부다. 그런데
`all` 은 이름과 달리 전부는 아니다 — **Secret·ConfigMap·PVC·Ingress 는 안
나온다.** 이 넷이 빠졌다는 사실을 모르고 「다 만들어졌다」고 판정하는 것이
흔한 오독이라, 한 번 더 친다.
```bash
kubectl -n keycloak-lab get secret,configmap,pvc,ingress
```
**어디를 봐야 하는가** — 네 종류가 **하나씩이라도 있는가**. PVC 줄의
STATUS 는 `Bound` 인가, Ingress 줄의 HOSTS 칸이 `auth.hyeonworks.com` 인가.
**이 결과가 의미하는 것** — 매니페스트에 있는데 여기 없는 종류가 있다면
`apply` 가 부분적으로만 먹은 것이다. Ingress 의 호스트 이름이 04 에서 발급한
인증서의 이름과 다르면, 밖에서는 TLS 는 되는데 404 가 나온다.
### 2-2. Deployment → ReplicaSet → Pod 사슬
Deployment 는 파드를 직접 만들지 않는다. **ReplicaSet 을 만들고 그것이 파드를
만든다.** 이 사슬 어디서 끊겼는지가 진단의 출발점이다.
**이 단계에서 Deployment 는 `postgres` 하나뿐이다.** Keycloak 은 StatefulSet
이라 이 사슬을 타지 않는다.
**확인** — 사슬 어디까지 갔는가
```bash
kubectl -n keycloak-lab get rs,pod -l app=postgres
```
**실측** — 2026-09-11
```
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 이름의 해시(`7b474b88c8`)가 파드 이름
가운데 해시와 **같은가**. 그리고 `DESIRED`·`CURRENT`·`READY` 세 숫자가 다
`1` 인가.
**`-l app=postgres` 에 Deployment 줄이 안 나오는 것이 정상이다.** 이 매니페스트는
`app: postgres` 라벨을 **파드 템플릿에만** 달았고 Deployment 객체 자신에는 안
달았다. ReplicaSet 과 파드는 템플릿에서 라벨을 물려받지만 Deployment 는 아니다.
Deployment 를 보려면 라벨 없이 친다.
```bash
kubectl -n keycloak-lab get deploy
```
**★ StatefulSet 은 ReplicaSet 을 만들지 않는다.** 파드를 직접 만든다.
```bash
kubectl -n keycloak-lab get sts,rs,pod -l app=keycloak
```
```
NAME READY STATUS RESTARTS AGE
pod/keycloak-0 1/1 Running 0 19m
pod/keycloak-1 1/1 Running 0 19m
```
ReplicaSet 줄이 하나도 없다. **`keycloak-0` 처럼 순번 이름이 붙는 것도 이
때문이다** — 해시를 끼워 넣을 중간 객체가 없다.
**이 결과가 의미하는 것** — 배포를 여러 번 한 Deployment 는 **ReplicaSet 이
여러 개 쌓인다.** 새로 만들고 옛것은 `0` 으로 남기기 때문이고, 그래서
`kubectl rollout undo` 가 가능하다. 파드가 옛 해시를 달고 있으면 새 배포가
아직 안 넘어온 것이고, 그 상태로 실험하면 **고친 적 없는 코드를 재게 된다.**
B 계열에서 BFF 를 여러 번 배포하면 이 목록이 실제로 일곱 줄까지 늘어난다.
사슬이 어디서 끊겼는지는 이렇게 읽는다.
| 보이는 것 | 뜻 |
|---|---|
| Deployment 는 있는데 RS 가 없다 | 컨트롤러가 못 돌았다 — RBAC·admission 확인 |
| RS 는 있는데 DESIRED 만 있고 CURRENT 가 0 | 파드를 못 만든다 — 이벤트를 본다 |
| Pod 은 있는데 `0/1` | 컨테이너가 안 뜬다 — 로그와 describe |
> StatefulSet 은 ReplicaSet 을 쓰지 않고 파드를 직접 만든다. 그래서
> `keycloak-0`·`keycloak-1` 처럼 **이름이 고정**이고, A-4 에서 `Terminating`
> 파드가 안 지워지면 대체 파드가 안 생기는 이유가 이것이다.
### 2-3. Secret 이 실제로 들어갔나 — 세 층으로 본다
값이 있는 것과 파드가 그 값을 받은 것은 다르다.
**확인 ①** Secret 에 무슨 키가, 얼마만큼 들어 있나 — **값은 찍지 않는다**
```bash
kubectl -n keycloak-lab describe secret keycloak-lab-secrets
```
**실측** — 아래쪽 `Data` 절만 옮긴 것이다
```
Data
====
KC_BOOTSTRAP_ADMIN_PASSWORD: 19 bytes
POSTGRES_PASSWORD: 22 bytes
```
**어디를 봐야 하는가**`Data` 절의 **키 이름과 그 옆의 바이트 수** 두 칸.
`describe` 는 값을 절대 찍지 않고 길이만 보여 준다 — 그래서 키 목록 확인과
「비어 있지 않은가」 확인이 **한 명령으로 끝난다.** `0 bytes` 인 키가 있으면
그 자리가 비어 있는 것이다.
**이 결과가 의미하는 것** — 매니페스트가 기대하는 키 이름이 여기 그대로
있어야 한다. 이름이 하나라도 다르면 파드는 `CreateContainerConfigError`
멈추고, 이유는 `describe pod` 의 Events 에 키 이름까지 적혀 나온다.
바이트 수가 뜻밖에 크면(예: 20 이어야 할 것이 21) **`echo` 로 만들면서 개행이
같이 들어간** 경우다 — 흔한 사고이고, 증상은 「비밀번호가 틀렸다」로 나온다.
> **`-o yaml` 로 보지 않는다.** base64 는 암호화가 아니라 인코딩이라
> 화면·스크롤백·화면 공유·터미널 로그에 값이 그대로 남는다.
> D-3 이 잰 것이 이것이다 — [`experiment-d3-secret-management.md`](../../experiment-d3-secret-management.md)
**확인 ②** 특정 키 하나를 따져 볼 때 — **길이만**
`describe` 가 보여 주는 바이트 수는 base64 를 푼 뒤의 길이다. 어떤 키
하나가 의심스러워 다시 잴 때만 이 형태를 쓴다.
```bash
kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.POSTGRES_PASSWORD}' | base64 -d | wc -c
```
```
22
```
**어디를 봐야 하는가** — 숫자 하나. 그리고 그것이 확인 ①의 `22 bytes`
같은가.
**이 결과가 의미하는 것** — 두 값이 같으면 Secret 쪽은 더 볼 것이 없다.
`base64: invalid input` 이 나오면 키 이름을 잘못 쓴 것이다(없는 키는 빈
문자열로 나온다). 여기까지는 **Secret 안에 무엇이 있나**이고, 파드가 그것을
받았는지는 아직 모른다.
**확인 ③** 파드 안에 주입됐나 — 여기가 진짜다
```bash
kubectl -n keycloak-lab exec keycloak-0 -- \
sh -c 'echo "길이=${#KC_BOOTSTRAP_ADMIN_PASSWORD}"'
```
```
길이=19
```
**어디를 봐야 하는가** — 숫자 하나. `${#VAR}` 는 값이 아니라 **글자 수**만
내놓는다. 이것이 확인 ①의 `19 bytes` 와 같은가.
**이 결과가 의미하는 것** — 같으면 Secret → 파드 환경변수까지 이어졌다.
`길이=0` 이면 Secret 에는 있는데 **이 파드가 그것을 안 받은** 것이다 —
`envFrom`/`valueFrom` 을 빠뜨렸거나, 파드가 Secret 을 고치기 **전에** 떠서
옛 값을 들고 있는 경우다(환경변수로 주입한 Secret 은 값을 바꿔도 파드를
다시 만들기 전까지 갱신되지 않는다).
**어느 환경변수가 어느 Secret 에서 왔는지**도 볼 수 있다.
```bash
kubectl -n keycloak-lab get pod keycloak-0 \
-o jsonpath='{range .spec.containers[0].env[*]}{.name}{"\t"}{.valueFrom.secretKeyRef.name}{"\n"}{end}'
```
```
KC_DB
KC_DB_URL
KC_DB_USERNAME
KC_DB_PASSWORD keycloak-lab-secrets ← Secret 에서 온 것만 오른쪽에 이름이 있다
```
**어디를 봐야 하는가****오른쪽 칸이 채워진 줄만**. 왼쪽만 있는 줄은
매니페스트에 값이 그대로 적힌 것이고, 오른쪽에 이름이 있는 줄이 Secret 을
참조하는 것이다.
**이 결과가 의미하는 것** — 비밀이어야 할 변수의 오른쪽이 비어 있으면
**그 값은 매니페스트에 평문으로 적혀 있다는 뜻**이고, 그 파일은 대개 git 에
들어간다. 여기서는 `KC_DB_PASSWORD` 만 Secret 에서 온다.
### 2-4. Service 가 파드를 잡고 있나 — Endpoints
Service 가 있어도 **셀렉터가 안 맞으면 뒤가 비어 있다.** 이때 증상은
「연결은 되는데 응답이 없다」라 원인을 찾기 어렵다.
**확인** — 실무자가 가장 자주 쓰는 형태
```bash
kubectl -n keycloak-lab describe svc keycloak | grep -i endpoints
```
```
Endpoints: 10.42.0.67:8080,10.42.1.155:8080
```
**어디를 봐야 하는가** — 쉼표로 갈린 **주소가 몇 개인가**, 그리고 그 IP 들이
`kubectl -n keycloak-lab get pods -o wide` 의 파드 IP 와 같은가. 포트 번호가
컨테이너가 실제로 듣는 포트인가도 함께 본다.
**이 결과가 의미하는 것** — 두 개면 Service 가 두 파드를 다 잡고 있다.
**비어 있으면** Service 는 있는데 뒤가 없는 것이고, 이때 증상은
「연결은 되는데 응답이 없다」라 원인이 Service 에 있다는 것이 잘 안 보인다.
하나뿐이면 나머지 한 파드가 readiness 를 통과하지 못한 것이라, 그 상태로
A층 실험을 하면 **이미 한쪽으로만 가고 있던 트래픽**을 이중화 실패로
오독하게 된다.
목록으로 보려면 **EndpointSlice** 를 쓴다.
```bash
kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak
```
```
NAME ADDRESSTYPE PORTS ENDPOINTS AGE
keycloak-xdph6 IPv4 8080 10.42.0.67,10.42.1.155 3d23h
```
> **`kubectl get endpoints` 는 쓰지 않는다.** v1.33 부터 deprecated 이고
> 실행하면 경고가 나온다.
> ```
> Warning: v1 Endpoints is deprecated in v1.33+; use discovery.k8s.io/v1 EndpointSlice
> ```
> 옛 문서와 블로그에 이 형태가 많으니 주의한다.
준비 상태까지 함께 보려면 이렇게 뽑는다.
```bash
kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak \
-o jsonpath='{range .items[*].endpoints[*]}{.addresses[0]}{"\t"}{.conditions.ready}{"\n"}{end}'
```
```
10.42.0.67 true
10.42.1.155 true
```
**어디를 봐야 하는가** — 오른쪽 칸이 두 줄 다 `true` 인가. 여기서만 값을
뽑는 형태를 쓰는 이유는, 이 두 칸이 **A층 실험 전후로 반복해서 비교할
값**이기 때문이다. 처음 볼 때는 위의 `describe svc` 로 충분하다.
**이 결과가 의미하는 것**`ready``false` 면 파드는 있는데 **readiness
프로브를 통과하지 못한** 것이라, Service 가 그 파드로 트래픽을 보내지 않는다.
파드 목록에서는 `Running` 으로 보이므로 `get pods` 만 봐서는 알 수 없다 —
`0/1` 인지 `1/1` 인지가 같은 사실을 말해 준다.
**비어 있으면** 셀렉터와 파드 라벨이 안 맞는 것이다.
```bash
kubectl -n keycloak-lab get svc keycloak -o jsonpath='{.spec.selector}'; echo
kubectl -n keycloak-lab get pods --show-labels
```
### 2-5. PVC 가 실제로 붙었나
**확인** — 볼륨이 실제로 잡혔는가
```bash
kubectl -n keycloak-lab get pvc
```
**어디를 봐야 하는가** — STATUS 칸(`Bound`/`Pending`)과 VOLUME 칸(비어 있는지),
그리고 STORAGECLASS 칸이 `local-path` 인가. StatefulSet 이면 PVC 이름 끝에
파드 번호가 붙어 있어(`...-keycloak-0`) 어느 파드 것인지 바로 보인다.
**이 결과가 의미하는 것**`Bound` 면 볼륨이 붙었다. `Pending` 이면
StorageClass 가 없거나 노드에 자리가 없다. **`local-path` 는 파드가 스케줄될
때까지 기다린다**(WaitForFirstConsumer)므로, 파드가 안 뜨면 PVC 도 `Pending`
인 것이 정상이다. 둘이 서로를 기다리는 것처럼 보이지만 파드 쪽 원인을 먼저
본다. 사유는 PVC 의 이벤트에 적혀 있다.
```bash
kubectl -n keycloak-lab describe pvc # 이름을 안 주면 전부 나온다
```
각 PVC 절의 맨 아래 `Events``waiting for first consumer` 인지
`no persistent volumes available` 인지가 적혀 있고, 둘은 대응이 다르다 —
앞엣것은 파드를 고치는 문제, 뒤엣것은 저장소를 고치는 문제다.
---
## 3. 안 뜰 때 — 순서가 있다
**① 이벤트부터.** 로그보다 먼저다. 스케줄링·이미지·볼륨 실패가 여기 나온다.
```bash
kubectl -n keycloak-lab get events --sort-by=.lastTimestamp | tail -20
```
**어디를 봐야 하는가** — 맨 아랫줄부터 거꾸로 읽는다. `--sort-by` 를 준 이유가
그것이다. TYPE 이 `Warning` 인 줄, REASON 칸(`FailedScheduling`·`Failed`·
`BackOff`), 그리고 OBJECT 칸이 어느 파드인가. **이벤트는 기본 한 시간만
남는다** — 아무것도 없으면 「문제가 없다」가 아니라 「이미 지워졌다」일 수 있다.
**이 결과가 의미하는 것** — REASON 하나가 다음 행동을 정한다.
`FailedScheduling` 은 노드에 자리가 없는 것이라 파드가 아니라 클러스터를
봐야 하고, `ErrImagePull` 은 이미지 이름 문제라 로그를 볼 것도 없다.
`BackOff` 는 컨테이너가 떴다가 죽는 중이라는 뜻이라 ③으로 간다.
**② describe.** 그 파드에 한정된 이벤트와 상태를 함께 본다.
```bash
kubectl -n keycloak-lab describe pod keycloak-0
```
**어디를 봐야 하는가** — 위에서부터 세 곳이다. `Conditions` 절에서 `Ready`
`False` 인가, 컨테이너 절의 `State`/`Last State` 와 그 안의 **`Exit Code`**,
그리고 맨 아래 `Events`. Exit Code 는 그것만으로 말이 된다 — `137`
OOM 이나 강제 종료, `1` 은 애플리케이션이 스스로 끝낸 것, `127` 은 명령을
못 찾은 것이다.
**이 결과가 의미하는 것** — Exit Code 가 `137` 이면 로그에는 아무 단서도
없을 수 있다(맞아 죽은 쪽은 유언을 못 남긴다) — 메모리 한도를 본다.
`Ready``False` 이고 컨테이너는 살아 있으면 readiness 프로브 문제이므로
2-4 로 돌아간다.
**③ 로그.** 컨테이너가 떴는데 죽는 경우다.
```bash
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
kubectl -n keycloak-lab exec -it keycloak-0 -- sh
```
---
## 4. 클러스터가 형성됐는지 확인한다
파드가 둘 다 `Running` 인 것과 **하나의 클러스터로 묶인 것**은 다르다.
**확인 ①** 로그
```bash
kubectl -n keycloak-lab logs keycloak-0 | grep ISPN000094 | tail -1
```
```
ISPN000094: Received new cluster view for channel ISPN:
[keycloak-0-10001|1] (2) [keycloak-0-10001, keycloak-1-52537]
```
**어디를 봐야 하는가** — 세 군데다. 괄호 안의 **`(2)` 가 멤버 수**,
대괄호 안의 **이름 목록**, 그리고 `|1`**뷰 번호**(멤버가 들고 날 때마다
올라간다). `tail -1` 을 붙였으므로 지금 보고 있는 것은 **가장 최근 뷰** 하나다.
**이 결과가 의미하는 것**`(2)` 면 이 노드는 상대를 봤다. `(1)` 이면 혼자
있다고 알고 있는 것이다. 뷰 번호가 계속 오르고 있으면 멤버가 붙었다
떨어지기를 반복하는 중이라, 「지금 2 다」라는 스냅숏보다 그 사실이 더 중요하다.
**이 줄은 「그때 그렇게 보였다」는 과거형이다** — 지금 상태는 확인 ③에서 본다.
`grep` 이 아무것도 못 찾으면 클러스터링을 아직 시작도 못 한 것이니 로그를
통째로 본다.
**확인 ②** 디스커버리 테이블
```bash
kubectl -n keycloak-lab exec deploy/postgres -- \
psql -U keycloak -d keycloak -c 'select name, ip from jgroups_ping'
```
**어디를 봐야 하는가** — 나온 **행이 몇 개인가**, 그리고 `ip` 칸이 2-4 에서
본 파드 IP 와 같은가. 끝의 `(N rows)` 한 줄이 개수를 말해 준다.
**이 결과가 의미하는 것** — 이 표는 「**등록**되어 있다」이지 「서로 말이
통한다」가 아니다. 두 행이 다 있는데 확인 ①이 `(1)` 이면, 서로를 찾기는
했는데 7800 포트로 메시지가 안 가는 것이다 — A-1 에서 정확히 그 일이
벌어졌다. 옛 파드의 행이 남아 있을 수도 있으므로 IP 를 지금 파드와 대조한다.
**확인 ③** 지표
**★ Keycloak 컨테이너에는 `curl` 이 없다.** 공식 이미지가 최소 구성이라
`wget``nc` 도 없다. 안에서 치면 이렇게 된다.
```
sh: line 1: curl: command not found
command terminated with exit code 127
```
그래서 밖에서 물어본다. **Prometheus 에 묻는 것이 가장 짧다.**
```bash
kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size'
```
**어디를 봐야 하는가** — 응답은 **줄바꿈 없는 JSON 한 줄**로 온다.
`data.result` 배열에서 원소가 **몇 개인가**(노드 수), 각 원소에서 두 군데만
읽는다 — `"metric"` 안의 `node` 라벨(어느 Keycloak 인가)과 `"value"` 배열의
**둘째 원소**(따옴표에 싸인 값). **이 실험대에는 `jq` 가 없다.** 파서를
따로 짜지 말고 화면에 나온 JSON 을 그대로 읽는다.
**실측** — 그렇게 읽어낸 값이다
```
keycloak-1 → 2
keycloak-0 → 2
```
**이 결과가 의미하는 것** — 원소가 둘이고 값이 둘 다 2 면 두 노드가 서로를
보고 있다. 원소가 하나뿐이면 나머지 노드의 스크레이프가 실패한 것이라
**클러스터 문제가 아니라 관측 문제**일 수 있다 — 06 의 targets 를 본다.
Prometheus 가 아직 없다면 임시 파드를 띄운다.
```bash
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'"
```
```
vendor_cluster_size{cache_manager="keycloak",node="keycloak-0-46674"} 2.0
```
**어디를 봐야 하는가** — 줄 끝의 **숫자**와, 중괄호 안 `node=` 라벨이 **어느
파드인가**. 이 형태는 그 파드 하나에게 직접 물은 것이라 라벨의 노드 이름과
`$K0` 로 고른 파드가 반드시 일치한다.
**이 결과가 의미하는 것**`--rm` 을 붙였으므로 파드는 끝나면 사라진다.
`curlimages/curl` 을 쓰는 이유는 **Keycloak 이미지에 도구가 없기** 때문이고,
같은 이유로 이 방법은 Keycloak 뿐 아니라 최소 이미지 전부에 쓴다. 값이
안 나오고 연결 거부가 나면 9000(관리 포트)이 안 열린 것이다.
> **두 값이 다를 수 있다.** 각 노드가 자기가 아는 멤버 수를 보고하므로,
> 분단되면 한쪽은 2 다른 쪽은 1 이 된다. **한 노드만 보면 분단을 놓친다.**
> **셋이 다른 것을 본다.** 로그는 「그때 그렇게 보였다」이고, 테이블은
> 「지금 등록되어 있다」이며, 지표는 「지금 그 노드가 그렇게 안다」이다.
> A-1 에서 이 셋이 갈렸다 — 테이블에는 둘 다 있는데 메시지는 안 갔다.
---
## 5. 밖에서 닿는지
**확인** — 2홉을 다 지나 파드까지 닿는가
```bash
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
```
```
200
```
**어디를 봐야 하는가** — 코드 한 칸. 여기서 값만 뽑는 형태를 쓰는 것은
**03·04 에서 잰 것과 같은 명령으로 같은 값이 나오는지** 비교하는 것이
목적이기 때문이다. 이 자리에서 처음 보는 것이 아니다.
**이 결과가 의미하는 것**`200` 이면 nginx → Traefik → Ingress → Service →
파드가 전부 이어졌다. `502`·`503` 이면 뒤에서부터 되짚는다 — Ingress 가
있는지(2-1), Service 뒤에 파드가 있는지(2-4), 파드가 Ready 인지(2-2) 순서다.
처음 보는 오류라 헤더가 필요하면 값만 뽑는 형태를 버리고 읽는 형태로 바꾼다.
```bash
curl -I https://auth.hyeonworks.com/realms/master
```
브라우저로 `https://auth.hyeonworks.com/admin` 에 들어가 관리자로 로그인한다.
비밀번호는 위 2-3 의 Secret 에 있다.
---
## 막히면
| 증상 | 어디를 보나 |
|---|---|
| 파드가 `Pending` | `describe pod` 의 Events — 스케줄 불가 사유 |
| `ImagePullBackOff` | 이미지 이름·태그. 자체 빌드면 두 노드 모두에 반입했는가 |
| `CrashLoopBackOff` | `logs --previous` |
| `Running` 인데 `0/1` | readiness 프로브 실패. `describe` 의 Conditions |
| 밖에서 502 | Ingress → Service → Endpoints 순으로 뒤를 본다 |
| 클러스터가 1로 보임 | 7800 이 막혔거나 디스커버리 실패. 위 4번 셋 다 확인 |
---
## 근거를 재려면 (선택)
세션이 실제로 어디 저장되는지는 DB 를 직접 본다.
```bash
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`. **로그인
전과 후에 두 번 재서 그 수의 차이**를 본다. 한 번만 재면 아무것도 알 수
없다. 행이 아예 없으면(`0 rows`) 표는 있는데 비어 있는 것이다.
**이 결과가 의미하는 것** — 로그인 뒤 수가 늘면 세션이 DB 에 남는 것
(`persistent-user-sessions` 켜짐)이고, 안 늘면 메모리에만 있는 것이다.
그 차이가 A층 결론 전체를 뒤집는다 — 메모리에만 있으면 파드를 재시작하는
순간 세션이 사라지고, DB 에 있으면 살아남는다.
[A-7](../../experiment-a7-volatile-comparison.md)
@@ -0,0 +1,199 @@
# 06 — 관측
## 이 단계가 끝나면
Prometheus 가 Keycloak 을 긁고, `vendor_cluster_size` 로 클러스터 상태를
밖에서 볼 수 있다.
## 전제
[05](../05-keycloak/) 가 끝나 Keycloak 두 노드가 떴다.
## 왜 필요한가
실험의 판정을 **밖에서만** 하면 놓친다. A-1 에서 7800 을 끊었는데 외부 응답이
전부 200 이었다 — 분단된 노드가 스스로 로드밸런서에서 빠졌기 때문이다.
클러스터 안을 보는 눈이 따로 있어야 한다.
---
## 1. 적용
**하기**`[lab host]`, **저장소 루트에서**([00](../00-lab-host/) 에서 클론했다)
```bash
cd ~/workspace/keycloak-pattern
kubectl apply -f deploy/lab/k8s/observability.yaml
kubectl -n observability rollout status deploy/prometheus --timeout=180s
```
**확인** — 무엇이 몇 개 떴는가
```bash
kubectl -n observability get pods
```
**실측**
```
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·메모리·디스크 지표가 통째로 없는 채로 실험을 하게 된다 —
이때는 관측이 아니라 02 의 노드 상태부터 본다. 어느 노드에 붙었는지는
`-o wide` 로 확인한다.
```bash
kubectl -n observability get pods -o wide
```
## 2. 무엇을 긁고 있나 — 여기가 중요하다
**확인** — Prometheus 가 스스로 밝히는 대상 목록
```bash
kubectl -n observability exec deploy/prometheus -- \
wget -qO- localhost:9090/api/v1/targets | grep -o '"job":"[^"]*"' | sort -u
```
**실측**
```
"job":"keycloak"
"job":"kubelet"
"job":"node-exporter"
"job":"prometheus"
```
**어디를 봐야 하는가** — **거기 있는 이름이 아니라 없는 이름**이다. 응답은
JSON 한 덩어리이고 그대로는 못 읽는다. **이 실험대에는 `jq` 가 없으므로**
`grep -o` 로 필요한 필드만 뽑고 `sort -u` 로 중복을 없앤 것이다 — 여기까지가
사람이 손으로 치는 선이고, 그 이상 가공해야 한다면 파서를 짜지 말고 화면에
나온 JSON 을 그대로 읽는다.
**이 결과가 의미하는 것****★ Redis · BFF · PostgreSQL 이 없다.** 이 실험대는
그것들을 긁지 않는다. 그래서 B층 실험 대부분에 Grafana 화면이 없는데,
**안 찍은 것이 아니라 지표가 없는 것**이다. 어떤 실험에서 지표를 못 찾으면
「측정이 실패했다」로 적기 전에 **이 목록에 그 job 이 있었는지부터** 본다.
> 이것을 「스크린샷 누락」이 아니라 **측정된 공백**으로 기록했다.
> [`evidence/followup/04-observability-gap.txt`](../../evidence/followup/04-observability-gap.txt)
목록에 있는데도 값이 안 나온다면 그다음은 **상태**다. 같은 응답에서
`health` 만 훑는다.
```bash
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). 3번에서 값이 한 노드만 나오는
증상의 원인이 대개 여기 있고, 그때 **클러스터가 아니라 스크레이프가 문제**다.
## 3. 클러스터 상태를 본다
**확인** — 두 노드가 각각 몇 명을 보고 있는가
```bash
kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size'
```
**어디를 봐야 하는가** — 응답은 **줄바꿈 없는 JSON 한 줄**이다. `data.result`
배열의 원소가 **몇 개인가**(보고하는 노드 수), 각 원소에서 두 군데만 읽는다 —
`"metric"` 안의 `node` 라벨과 `"value"` 배열의 **둘째 원소**(따옴표에 싸인
값). `jq` 가 없으므로 눈으로 읽는다.
**실측** — 그렇게 읽어낸 값이다
```
keycloak-1 → 2
keycloak-0 → 2
```
**이 결과가 의미하는 것****두 노드가 각각 자기가 아는 멤버 수를 보고한다.**
둘 다 2 면 클러스터가 온전하다. 분단되면 한쪽은 2, 다른 쪽은 1 이 된다 —
**한 노드만 보면 분단을 놓친다.** 원소가 하나뿐이면 분단이 아니라 스크레이프
실패일 수 있으므로 2번의 `health` 를 먼저 본다. 값이 아예 안 나오면
`"result":[]` 로 빈 배열이 오는데, 이는 「0 이다」가 아니라 **「그런 지표가
없다」**는 뜻이다.
자주 보는 지표들이다.
| 지표 | 무엇 |
|---|---|
| `vendor_cluster_size` | 이 노드가 아는 멤버 수 |
| `vendor_jgroups_*` | JGroups 프로토콜별 카운터 |
| `vendor_statistics_approximate_entries_unique{cache="sessions"}` | 이 노드의 세션 캐시 엔트리 수 |
| `agroal_*` | JDBC 커넥션 풀 |
| `up` | 스크레이프 성공 여부 |
## 4. `up` 을 믿지 않는다
**A-2 에서 503 이 나는 동안에도 `up` 은 1 이었다.** 프로세스가 살아 있고
`/metrics` 가 응답하기만 하면 1 이므로 **「살아 있지만 쓸모없는」 상태를
보지 못한다.**
```bash
kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=up'
```
**어디를 봐야 하는가** — 원소마다 `job` 라벨과 값(`"1"`/`"0"`). 값이 1 이라는
것은 **마지막 스크레이프가 성공했다**는 사실 하나만 말한다.
**이 결과가 의미하는 것**`up=1` 은 「프로세스가 살아 있고 `/metrics`
응답했다」이지 「그 서비스가 쓸모 있다」가 아니다. 그래서 경보를 `up == 0`
하나로 걸면 **A-2 같은 「살아 있지만 503」 상태를 통째로 놓친다.**
기능 지표를 함께 본다 — 밖에서 실제 응답을 받아 보는 것이 가장 짧다.
```bash
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
```
**어디를 봐야 하는가** — 코드 한 칸. 여기서 값만 뽑는 형태를 쓰는 것은
`up` 의 1/0 과 **나란히 놓고 비교하기 위해서**다. 처음 보는 오류를 파고들
때는 `curl -I``curl -v` 로 바꾼다(04 참조).
**이 결과가 의미하는 것**`up=1` 인데 이쪽이 200 이 아니면 그 조합이 곧
「살아 있지만 쓸모없는」 상태의 증거다. 그 두 값을 같이 기록해 두는 것이
A-2 의 판정 근거였다.
## 5. Grafana 를 볼 때
**하기** — 밖에 열지 않고 포트포워드로 본다
```bash
kubectl -n observability port-forward svc/grafana 3000:3000
```
**어디를 봐야 하는가**`Forwarding from 127.0.0.1:3000 -> 3000` 한 줄이
찍히고 **명령이 그대로 멈춰 있는가**. 이 명령은 끝나지 않는 것이 정상이라,
터미널 하나를 여기에 내준다. 브라우저를 열면 그 아래에 `Handling connection`
줄이 하나씩 붙는다 — 그것이 붙지 않으면 브라우저가 다른 곳을 보고 있는 것이다.
**이 결과가 의미하는 것** — 이 터널은 **명령을 실행한 기계에서만** 열린다.
워크스테이션에서 쳤으면 워크스테이션 브라우저로 `http://localhost:3000`,
lab host 에서 쳤으면 lab host 에서 봐야 한다. `bind: address already in use`
면 3000 을 이미 누가 쓰는 것이니 `3001:3000` 처럼 왼쪽만 바꾼다. Ctrl+C 로
끊으면 터널도 사라진다 — 밖에 포트를 여는 것이 아니라 **보는 동안만 뚫는
것**이라 실험대의 노출면이 늘지 않는다.
> 실험 중에는 Grafana 보다 **Prometheus 쿼리 API** 가 편하다. 값을 그대로
> 뽑아 비교할 수 있고 스크린샷보다 근거로 남기기 좋다.
---
## 막히면
| 증상 | 원인 | 확인 |
|---|---|---|
| Keycloak 지표가 안 보임 | 9000 이 안 열렸거나 스크레이프 설정 누락 | 위 2번 targets |
| 값이 한 노드만 나옴 | 다른 노드 스크레이프 실패 | targets 의 `health` 필드 |
| 컨테이너 안에서 curl 실패 | **Keycloak 이미지에 curl 이 없다** | 밖에서 Prometheus 로 묻는다 |
| Grafana 에 데이터 없음 | 데이터소스 주소 오류 | Prometheus 서비스 이름 확인 |
@@ -0,0 +1,113 @@
# 실습 가이드 — 직접 쳐보면서 만드는 실험대
이 문서 묶음은 **읽는 문서가 아니라 따라 치는 문서**다. 기존
[`experiment-*.md`](../) 가 「무엇을 발견했나」를 적었다면, 여기는
「그 발견을 재현하려면 무엇을 어떤 순서로 치는가」를 적는다.
## 두 종류의 명령을 구별해 적는다
실무자가 터미널에서 치는 명령과, 근거를 남기려고 재는 명령은 길이도 목적도
다르다. 이 가이드는 둘을 섞지 않는다.
| 표시 | 무엇인가 |
|---|---|
| **하기** · **확인** | 실무자가 실제로 치는 형태. 짧고, 한 번에 하나씩 |
| **근거를 재려면** | 이 실험대가 문서에 남기려고 쓴 긴 형태. 평소에는 필요 없다 |
예를 들어 nginx 에러 로그가 잘렸을 때, 실무자는 잘린 걸 보고 access 로그로
넘어간다. 길이를 재서 2048인지 확인하는 것은 **몰라서 재는** 것이고, 알면
재지 않는다.
같은 이유로 `curl` 도 두 형태가 있다.
```bash
curl -I https://auth.hyeonworks.com/realms/master # 한 번 볼 때
curl -s -o /dev/null -w '%{http_code}\n' <url> # 여러 번 재서 비교할 때
```
이 가이드의 **확인**은 값을 대조해야 해서 두 번째 형태를 자주 쓴다. 실제로
터미널에서 눈으로 볼 때는 첫 번째로 충분하다.
## 자리표시자를 두지 않는다
`<토큰>` 처럼 적어 두면 그 값을 어디서 가져오는지가 문서 밖으로 나간다.
이 가이드는 **값을 찾는 명령을 함께 적는다.**
```bash
TOKEN=$(ssh kc-lab-1 'sudo cat /var/lib/rancher/k3s/server/node-token')
echo "${#TOKEN}" # 값이 아니라 길이만 확인한다
```
비밀은 길이나 존재 여부만 확인하고 값을 찍지 않는다. 터미널 스크롤백과
화면 공유에 남기 때문이다.
## 어느 기계에서 치는가
이 실험대에는 셸이 네 개 있고, **같은 명령이 어디서 도느냐에 따라 결과가
달라진다.** 그래서 모든 코드 블록 앞에 어디서 치는지를 붙인다.
| 표시 | 어느 기계 | 어떻게 들어가나 |
|---|---|---|
| `[워크스테이션]` | 평소 쓰는 개발 머신 | — |
| `[lab host]` | `test-server`. `virsh` 가 도는 곳 | `ssh test-server` |
| `[kc-lab-edge]` | 엣지 게스트 — nginx · certbot | `ssh kc-lab-edge` (**lab host 에서만**) |
| `[kc-lab-1]` | k3s server 게스트 | `ssh kc-lab-1` (**lab host 에서만**) |
| `[kc-lab-2]` | k3s agent 게스트 | `ssh kc-lab-2` (**lab host 에서만**) |
**기본은 `[lab host]` 다.** 게스트는 libvirt NAT(`192.168.122.0/24`) 안에
있어서 워크스테이션에서 직접 닿지 않는다. `ssh kc-lab-1` 이라는 별칭도
lab host 의 `~/.ssh/config` 에만 있다.
```bash
[워크스테이션] $ ping -c1 192.168.122.11
1 packets transmitted, 0 received, 100% packet loss # 경로가 없다
```
**게스트 안에 들어가서 다음 단계를 치지 않는다.** 게스트에는 lab host 의
개인키도 `~/.ssh/config` 도 없으므로, 게스트 안에서 `ssh kc-lab-1` 을 치면
이렇게 끝난다.
```bash
[kc-lab-1] $ ssh kc-lab-1 'sudo cat /var/lib/rancher/k3s/server/node-token'
Host key verification failed.
```
**이 실패가 조용한 이유** — 위 명령을 `TOKEN=$(...)` 로 감싸면 오류는
stderr 로 흘러가고 `TOKEN` 에는 **빈 문자열**이 담긴다. 셸은 아무 불평도
하지 않는다. 그래서 게스트에 로그인한 채 다음 단계를 치면 몇 단계 뒤에
가서야 증상이 나타난다.
그래서 이 가이드는 게스트에 **로그인하지 않고** lab host 에서
`ssh kc-lab-1 '...'` 형태로 원격 실행한다. 셸이 하나뿐이면 「지금 어디
있더라」가 생기지 않는다.
## 순서
앞 단계가 끝나야 다음이 된다. 각 단계 첫머리에 「이 단계가 끝나면」이 있고,
그 상태를 확인하는 명령이 있다. **그것이 통과해야 다음으로 넘어간다.**
| 단계 | 무엇을 세우나 | 끝나면 확인되는 것 |
|---|---|---|
| [00](00-lab-host/) | lab host 가상화 준비 | `virsh list` 가 돈다 |
| [01](01-vms/) | VM 세 대 (엣지 + k3s 2노드) | 세 게스트에 SSH 가 붙는다 |
| [02](02-k3s/) | k3s server + agent | `kubectl get nodes` 에 둘 다 Ready |
| [03](03-nginx/) | 엣지 nginx 라우팅 + 호스트 DNAT | 밖에서 요청이 파드까지 닿는다 |
| [04](04-tls/) | Let's Encrypt | `https://` 가 열리고 체인이 4단계 |
| [05](05-keycloak/) | Keycloak 2노드 + PostgreSQL | 관리 콘솔 로그인이 된다 |
| [06](06-observability/) | Prometheus · Grafana | `vendor_cluster_size` 가 2 |
| [experiments](experiments/) | 실험 26건 | 각 실험의 판정 기준 |
## 이 가이드가 검증된 방식
**읽기 전용 확인은 돌아가는 실험대에서 실제로 실행해 출력을 그대로 실었다.**
버전·IP·메모리 같은 값은 지어내지 않았다.
**만드는 명령은 다르다.** VM 을 다시 만들거나 k3s 를 다시 깔면 지금 돌고 있는
실험대가 없어지므로, 그 명령들은 **실제로 구축할 때 쓴 것을 그대로 옮겼고**
결과 상태를 확인하는 것으로 대신했다. 어느 쪽인지 각 단계에 표시한다.
## 막혔을 때
각 단계 끝에 **「막히면」** 표가 있다. 거기 적힌 증상은 전부 이 실험대가
실제로 겪은 것이고, 원문은 [`../evidence/`](../evidence/) 에 있다.
지어낸 실패 사례는 없다.
@@ -0,0 +1,496 @@
# 실험대 가상화 계층 — 실측 기록
## 이 문서가 무엇인가
[`docs/guides/`](guides/) 가 **「무엇을 어떤 순서로 치는가」** 를 적는다면, 이
문서는 **「그때 실제로 어떤 값이 나왔는가」** 를 적는다. 가이드에 이미 있는
절차는 반복하지 않는다.
여기 적힌 숫자는 전부 **2026-09-10 에 `test-server` 에서 실제로 돌려 받은
출력**이다. 추정값·예상값은 없다. 없는 값은 「미측정」이라고 쓴다.
| 이 문서가 답하는 것 | 가이드가 답하는 것 |
|---|---|
| 5120MB 를 줬는데 실제로 얼마를 쓰나 | 메모리를 얼마로 주나 |
| 20GB 오버레이가 디스크를 얼마나 먹나 | 오버레이를 어떻게 만드나 |
| cloud-init 이 몇 초 걸리나 | cloud-init 을 어떻게 쓰나 |
| 철거하면 무엇이 남나 | 무엇을 세우나 |
---
## 1. 측정 환경
```bash
lscpu | grep -E "^Model name|^CPU\(s\):|^Thread|^Core"
free -m | head -2
df -h /
virsh --version; qemu-system-x86_64 --version | head -1; uname -r
```
**실측**
```
CPU(s): 8
Model name: 11th Gen Intel(R) Core(TM) i5-1135G7 @ 2.40GHz
Thread(s) per core: 2
Core(s) per socket: 4
total used free shared buff/cache available
Mem: 11648 5642 2599 4 3776 6005
/dev/nvme0n1p3 226G 9.9G 204G 5% /
12.7.0
QEMU emulator version 11.1.1
7.2.2-arch1-1
```
**어디를 봐야 하는가**`Core(s) per socket` 4 에 `Thread(s) per core` 2 라
논리 코어가 8 이다. **VM 에 주는 vCPU 는 물리 코어가 아니라 논리 코어를 나눠
쓰는 것**이고, 합이 8 을 넘어도 libvirt 는 막지 않는다(오버커밋). 이 실험대는
`2 + 2 + 1 = 5` 로 잡아 여유를 뒀다.
`free``available`(6005MB)이 `free`(2599MB)보다 훨씬 큰 것이 정상이다 —
`buff/cache` 3776MB 는 필요하면 회수된다. **VM 을 얼마나 더 띄울 수 있는지는
`free` 가 아니라 `available` 로 본다.**
### 중첩 가상화
```bash
lscpu | grep Virtualization
cat /sys/module/kvm_intel/parameters/nested
```
```
Virtualization: VT-x
Y
```
**켜져 있지만 이 실험대는 쓰지 않는다.** 일회용으로 만들려는 층(게스트)은
이미 일회용이고, 비싼 층(호스트의 인증서·DNS)은 중첩으로 해결되지 않는다.
그리고 A-6 의 지연 주입처럼 **시간을 재는 실험**에서 중첩은 측정값을 왜곡한다.
---
## 2. 자원 — 할당과 실사용은 다르다
VM 에 준 메모리는 **상한이지 점유가 아니다.** virtio-balloon 이 안 쓰는 만큼
호스트에 돌려준다.
```bash
for v in kc-lab-1 kc-lab-2; do
printf "%-10s 할당 %sMB 실사용 %sMB\n" "$v" \
"$(( $(virsh dommemstat $v | awk '/actual/{print $2}') / 1024 ))" \
"$(( ($(virsh dommemstat $v | awk '/actual/{print $2}') \
- $(virsh dommemstat $v | awk '/^unused/{print $2}')) / 1024 ))"
done
```
**실측** — k3s 만 떠 있고 Keycloak 은 아직 안 올린 상태
```
kc-lab-1 할당 5120MB 실사용 353MB
kc-lab-2 할당 3120MB 실사용 301MB
```
**어디를 봐야 하는가** — 두 가지다.
**실사용이 할당의 7% 밖에 안 된다.** k3s server 가 353MB 다. 「5GB 를 줬으니
5GB 를 쓴다」가 아니다.
**`kc-lab-2` 의 할당이 4096 이 아니라 3120 이다.** `virt-install --memory 4096`
으로 만들었는데 줄어 있다. virtio-balloon 이 회수해 간 것으로 보인다.
`dommemstat``actual` 은 **현재 할당**이지 선언한 상한이 아니다. 상한은
`virsh dominfo``Max memory` 에 있다 — **다만 이 실험대에서 그 값을 나란히
찍어 보지는 않았다(미측정).** 둘을 헷갈리면 「메모리가 왜 줄었지」가 된다.
**이 결과가 의미하는 것** — 합계 8240MB 를 할당했지만 실제 점유는 654MB 다.
그래서 11.6GB 짜리 호스트에서 VM 세 대가 무리 없이 돈다. **다만 이것은 지금
k3s 만 떠 있어서다** — Keycloak 2 파드 + PostgreSQL + Redis + Prometheus 가
올라가면 늘어난다. 그 시점의 값은 **미측정**이다.
---
## 3. 디스크 — 오버레이는 얼마나 쓰나
게스트 디스크는 `base.qcow2` 위의 **오버레이**다. 20GB 를 두 개 만들어도
바닥 이미지는 한 벌이고 변경분만 쌓인다.
```bash
qemu-img info /var/lib/libvirt/images/base.qcow2 | head -4
ls -l /var/lib/libvirt/images/
```
**실측** — 엣지를 만들기 **전**, k3s 2 노드만 있던 시점이다.
```
image: /var/lib/libvirt/images/base.qcow2
file format: qcow2
virtual size: 3 GiB (3221225472 bytes)
disk size: 335 MiB
-rw-r--r-- base.qcow2 351404032 (335 MiB)
-rw------- kc-lab-1.qcow2 1521025024 (1.4 GiB) ← 선언 20GB
-rw------- kc-lab-2.qcow2 697499648 (665 MiB) ← 선언 20GB
-rw------- seed-kc-lab-1.iso 378880 (370 KiB)
-rw------- seed-kc-lab-2.iso 378880 (370 KiB)
```
**어디를 봐야 하는가**`base.qcow2``virtual size` 3GiB 와 `disk size`
335MiB 의 차이. **그리고 게스트 디스크가 20GB 로 선언됐는데 1.4GB 와 665MB
라는 것.** k3s server 가 agent 보다 두 배 이상 쓰는 것은 컨트롤 플레인
바이너리와 SQLite 때문이다.
**이 결과가 의미하는 것** — 20GB × 2 = 40GB 를 선언했지만 실제로는 2.1GB 를
썼다. 디스크는 병목이 아니다. **`df` 로 본 사용량과 VM 에 선언한 크기를 같은
것으로 보면 안 된다.**
### 스토리지 풀
```bash
virsh pool-info default
```
```
Name: default
State: running
Persistent: yes Autostart: yes
Capacity: 225.31 GiB
Allocation: 7.84 GiB
Available: 217.46 GiB
```
`Allocation`**풀 전체**(호스트 루트 파일시스템)의 사용량이지 VM 만의
사용량이 아니다. VM 이 얼마를 쓰는지는 위 `ls -l` 로 본다.
---
## 4. 부팅 — cloud-init 은 얼마나 걸리나
```bash
for i in $(seq 1 30); do
ssh -o BatchMode=yes -o ConnectTimeout=4 kc-lab-edge 'cloud-init status' 2>/dev/null \
| grep -q 'status: done' && { echo "완료 (약 $((i*10))초)"; break; }
sleep 10
done
ssh kc-lab-edge 'hostname; ip -4 -br addr show enp1s0; cloud-init status'
```
**실측**`package_update: true` 에 패키지 5개(`curl` `nftables` `nginx`
`certbot` `python3-certbot-dns-cloudflare`)를 받는 게스트
```
완료 (약 50초)
kc-lab-edge
enp1s0 UP 192.168.122.10/24 metric 100
status: done
```
**어디를 봐야 하는가**`cloud-init status` 의 세 상태를 구분한다.
| 값 | 뜻 |
|---|---|
| `running` | 아직 진행 중. **기다린다** |
| `done` | 끝났다 |
| `error` | 실패. `cloud-init status --long` 으로 어느 모듈인지 본다 |
**이 결과가 의미하는 것** — SSH 가 붙는 것과 cloud-init 이 끝난 것은 다르다.
SSH 는 먼저 열리고 패키지 설치는 뒤에 이어진다. **`done` 을 안 기다리고 다음
단계를 치면 「방금 깐 패키지가 없다」가 나온다.**
---
## 5. 네트워크 — DHCP 예약의 실제 동작
```bash
virsh net-update default add ip-dhcp-host \
"<host mac='52:54:00:aa:bb:10' name='kc-lab-edge' ip='192.168.122.10'/>" \
--live --config
```
**실측**
```
Updated network default persistent config and live state
```
**어디를 봐야 하는가****`persistent config``live state` 두 마디가 다
나오는가.** `--live` 만 주면 앞의 것이, `--config` 만 주면 뒤의 것이 빠진다.
한쪽만 나온 것을 성공으로 읽으면 「재부팅하니 IP 가 바뀐다」가 된다.
### 예약을 먼저, VM 을 나중에
이 실험대는 **예약을 넣고 나서 `virt-install`** 했고, 게스트가 첫 부팅에서
바로 `.10` 을 받았다.
```
enp1s0 UP 192.168.122.10/24 metric 100
```
순서가 반대면 게스트가 동적 대역(`192.168.122.2``.254`)에서 아무 주소나 받고,
예약이 먹으려면 리스 만료를 기다리거나 재부팅해야 한다.
### 리스는 예약과 별개로 남는다
```bash
virsh net-dhcp-leases default
```
```
Expiry Time MAC address IP address Hostname
2026-09-10 09:59:54 52:54:00:aa:bb:11 192.168.122.11/24 kc-lab-1
2026-09-10 09:58:46 52:54:00:aa:bb:12 192.168.122.12/24 kc-lab-2
```
`net-dumpxml` 의 예약은 **줄 의도**이고, `net-dhcp-leases` 는 **실제로 준
기록**이다. 둘이 다를 수 있다.
### virbr0 는 게스트가 없으면 내려간다
```bash
ip -br addr show virbr0
```
VM 세 대가 돌 때:
```
virbr0 UP 192.168.122.1/24
```
전부 철거한 뒤:
```
virbr0 DOWN 192.168.122.1/24
```
**주소는 그대로 있고 상태만 `DOWN` 이다.** 브리지에 붙은 tap 인터페이스가
하나도 없어서다. 네트워크 정의가 사라진 것이 아니므로 **VM 을 다시 띄우면
자동으로 `UP` 이 된다.** 이걸 고장으로 오독해 `virsh net-start` 를 찾아
헤매지 않는다.
---
## 6. 철거 — 실제 출력 전문
가이드에는 세우는 절차만 있고 철거가 없다. 2026-09-10 에 실제로 돌린 기록이다.
### 게스트
```bash
for v in kc-lab-edge kc-lab-2 kc-lab-1; do
virsh destroy "$v"
virsh undefine "$v" --remove-all-storage
done
```
**실측** (한 대분)
```
Domain 'kc-lab-edge' destroyed
Domain 'kc-lab-edge' has been undefined
Volume 'vda'(/var/lib/libvirt/images/kc-lab-edge.qcow2) removed.
Volume 'vdb'(/var/lib/libvirt/images/seed-kc-lab-edge.iso) removed.
```
**어디를 봐야 하는가**`Volume` 줄이 **두 개** 나오는가. `vda`(오버레이
디스크)와 `vdb`(시드 ISO)다. `--remove-all-storage` 를 빠뜨리면 도메인만
사라지고 **디스크 파일이 남아 다음 `virt-install` 이 「이미 있다」로 실패**한다.
`destroy` 는 종료 신호가 아니라 **전원을 뽑는 것**이다. 정상 종료를 원하면
`virsh shutdown` 을 쓰고 꺼질 때까지 기다린다.
### DHCP 예약
```bash
virsh net-update default delete ip-dhcp-host \
"<host mac='52:54:00:aa:bb:10' name='kc-lab-edge' ip='192.168.122.10'/>" \
--live --config
```
**★ 삭제할 때도 `mac`·`name`·`ip` 세 속성을 다 준다.** 하나라도 비면 이렇게
거부된다.
```
error: Failed to update network default
error: XML error: Cannot use host name '' in network 'default'
```
> **zsh 에서 루프로 돌리면 이 오류를 만난다.** zsh 는 따옴표 없는 변수를
> **단어 분리하지 않는다.** bash 에서 되던 `set -- $entry` 가 zsh 에서는
> `$1` 에 문자열 전체를 넣고 `$2`·`$3` 을 비운다. 그래서 `name=""` 이 된다.
> 세 줄을 값 그대로 쓰는 편이 안전하다.
**철거 후**
```
### 남은 예약
(없음)
### dhcp 블록
<dhcp>
<range start='192.168.122.2' end='192.168.122.254'/>
</dhcp>
```
동적 대역만 남는 것이 정상이다.
### 철거 전후 비교 — 실측
| | 철거 전 | 철거 후 |
|---|---|---|
| `virsh list --all` | 3 대 running | (없음) |
| `virsh vol-list default` | 7 개 | `base.qcow2` 1 개 |
| DHCP 예약 | 3 줄 | 0 줄 |
| `df -h /` | 11G | **7.9G** |
| `virbr0` | UP | DOWN |
**3.1GB 가 회수됐다.** 내역은 `kc-lab-1` 1.4GB + `kc-lab-2` 665MB + 시드 ISO
3개(각 370KB)이고, **`kc-lab-edge` 의 디스크 크기는 재 두지 않았다(미측정)** —
합계에서 역산하면 1GB 안팎이다.
`base.qcow2` 335MB 는 남긴다 — 다음 재구축의 바닥이고, 다시 받으면 몇 분이다.
---
## 7. 실측으로 드러난 함정 셋
전부 **아무 오류 없이 조용히 지나가거나, 엉뚱한 곳에서 증상이 나오는** 유형이다.
### ① cloud-init `sudo` 는 리스트가 아니라 문자열
```bash
ssh kc-lab-1 'cloud-init schema -c ~/chk.yaml'
```
리스트 형태(`sudo: ['ALL=(ALL) NOPASSWD:ALL']`)일 때:
```
Error: Cloud config schema errors: users.0: {'name': 'donghyeon', 'groups':
['sudo'], 'shell': '/bin/bash', ...} is not valid under any of the given schemas
```
문자열 형태(`sudo: "ALL=(ALL) NOPASSWD:ALL"`)일 때:
```
Valid cloud-config: /home/donghyeon/chk.yaml
```
**어느 키가 문제인지 안 알려 준다.** `users.0` 블록을 통째로 찍고 「어느
스키마에도 안 맞는다」고만 한다. 그리고 **리스트 형태도 부팅은 된다**
`kc-lab-1`·`kc-lab-2` 가 그 상태로 NOPASSWD sudo 가 멀쩡히 돌았다. 검사기만
거부하는 것이라 「검사는 실패인데 왜 되지」로 헷갈린다.
게스트 cloud-init 버전: `22.4.2` (Debian 12 genericcloud).
### ② nginx `http2 on;` 은 배포판에 따라 없다
```
엣지 (Debian 12): nginx version: nginx/1.22.1
lab host (Arch): nginx version: nginx/1.30.4
```
`http2 on;` 은 nginx 1.25.1 이상이다. Arch 에서 쓰던 설정을 Debian 게스트로
그대로 옮기면:
```
[emerg] 1339#1339: unknown directive "http2" in /etc/nginx/sites-enabled/keycloak-lab:29
nginx: configuration file /etc/nginx/nginx.conf test failed
```
`listen 443 ssl http2;` 형태는 1.22 와 1.30 양쪽에서 다 돈다.
### ③ Debian 기본 사이트가 `default_server` 를 먹고 있다
Debian 계열은 `/etc/nginx/sites-enabled/default` 가 처음부터 붙어 있고 `:80`
`default_server` 로 선언돼 있다. 실험대 설정의 `listen 80 default_server`
**충돌한다.** 심볼릭 링크를 걸 때 같이 지운다.
```bash
sudo rm -f /etc/nginx/sites-enabled/default
```
Arch 는 `sites-available` 관례 자체가 없어서 이 함정이 없는 대신, `nginx.conf`
`include /etc/nginx/sites-enabled/*;` 를 직접 넣어야 한다. **배포판이 바뀌면
함정도 바뀐다.**
---
## 8. 재구축할 때 무엇이 남아 있나
철거해도 남는 것과 사라지는 것을 구분해 둔다. 이걸 모르면 재구축이 「왜
이건 이미 있지」와 「왜 이건 없지」의 반복이 된다.
| | 상태 | 왜 |
|---|---|---|
| `base.qcow2` (335MB) | **남는다** | 다음 오버레이의 바닥 |
| 패키지 (libvirt·qemu·nginx·certbot·kubectl) | **남는다** | 재설치가 무의미 |
| `~/workspace/cloud/kc-lab-{1,2}.yaml` | **남는다** | 키와 비밀번호가 들어 있다 |
| `~/.ssh/config``kc-lab-*` 항목 | **남는다** | 재구축해도 IP 가 같다 |
| libvirt `default` 네트워크 정의 | **남는다** | 예약만 지웠다 |
| `/etc/letsencrypt/` | **남긴다** (정책) | 아래 참고 |
| 게스트 디스크·시드 ISO | 사라진다 | `--remove-all-storage` |
| DHCP 예약 | 사라진다 | `net-update delete` |
| k3s·Keycloak·모든 워크로드 | 사라진다 | 게스트와 함께 |
### 인증서를 지우지 않는 이유
**한도 때문이 아니다.** Let's Encrypt 의 「같은 이름 조합에 주당 중복 5장」
제한은 가끔 재구축하는 정도로는 근처에도 못 간다.
**진짜 이유는 「지금 재발급이 되는지를 모른다」는 것이다.** 이 실험대의 이름
셋은 tailnet 주소를 가리킨다.
```bash
dig +short auth.hyeonworks.com
```
```
100.83.212.4
```
`100.64.0.0/10` 은 CGNAT 용 예약 대역이라 **공개 인터넷에서 라우팅되지
않는다.** HTTP-01 검증은 Let's Encrypt 가 우리 서버로 들어오는 방식이므로,
이 주소로는 검증이 성립하지 않는다.
| 지금 설정이 | 지우고 나면 |
|---|---|
| `dns-cloudflare` (DNS-01) | 다시 받으면 끝. **백업은 헛수고였던 것** |
| `webroot`·`standalone` (HTTP-01) | 검증 방식부터 손봐야 한다 |
**「못 한다」가 아니라 「일이 하나 생긴다」이다.** 최악의 경우라도 A 레코드를
공인 IP 로 잠깐 돌려 받거나 DNS-01 로 전환하면 된다. 다만 재구축하려고 앉은
자리에서 그 일부터 하게 된다.
**어느 쪽인지 모르는 채로는 지우지 않는다**는 것이 이 정책의 전부다. 백업은
tar 하나에 30초고, 답을 알고 나면 지우면 된다.
```bash
sudo tar czf ~/letsencrypt-backup-$(date +%Y%m%d-%H%M%S).tgz -C /etc letsencrypt
```
복원은 반대로 한 줄이다.
```bash
sudo tar xzf ~/letsencrypt-backup-<stamp>.tgz -C /etc
```
**미측정** — 이 실험대의 certbot 이 HTTP-01 인지 DNS-01 인지 아직 확인하지
않았다. 호스트의 `sudo` 가 비밀번호를 요구해 비대화식으로 읽지 못했다.
다음 한 줄이 답이다.
```bash
sudo grep -H authenticator /etc/letsencrypt/renewal/*.conf
```
`webroot`·`standalone` 이면 HTTP-01 이라 위 문제가 실재하고, `dns-cloudflare`
면 DNS-01 이라 tailnet 안에서도 재발급·갱신이 된다. 플러그인은 이미 깔려
있다 — `certbot plugins``dns-cloudflare` 가 보인다.
---
## 관련 문서
| 문서 | 무엇 |
|---|---|
| [`guides/01-vms/`](guides/01-vms/) | VM 세 대를 세우는 절차 |
| [`guides/00-lab-host/`](guides/00-lab-host/) | 호스트 가상화 준비 |
| [`session-lab-concepts.md`](session-lab-concepts.md) | 여기 나온 개념의 정의 |
| [`deploy/lab/host/teardown-host.sh`](../deploy/lab/host/teardown-host.sh) | 호스트 계층 철거 |
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,165 @@
---
kind: CASE
slug: an-empty-token-installed-the-agent-anyway
title: 빈 토큰이 조용히 흘러갔다 — 설치 출력은 성공이었고 agent 만 5초마다 다시 죽었다
topic: build-completion-judgment
topicName: 끝났다는 판정
project: virtualization
status: 게시 전
sourceRevision: 9465582b5d1630eb4ae7c4e078021486919bf6b6
source:
- final/document.md#185-가이드-묶음이-스스로-정한-규약
- final/document.md#188-단계-02-k3s-server-와-agent
- final/document.md#184-이-부의-출처와-범위
---
# 빈 토큰이 조용히 흘러갔다 — 설치 출력은 성공이었고 agent 만 5초마다 다시 죽었다
k3s agent 설치는 오류 한 줄 없이 끝났는데 kubectl get nodes 에는 노드가 하나뿐이었다. 게스트 안에서 친 ssh 가 실패해 토큰이 빈 문자열로 넘어갔고, 설치 스크립트는 그 전까지를 성공으로 찍었다. 값을 찍지 않고 길이만 재는 한 줄을 설치 앞에 두면 잡힌다.
## 관계
- **도구가 낸 출력은 대상의 상태가 아니다 — 그 명령이 무엇을 세는지부터 가른다**
설치 스크립트의 출력을 노드가 붙었다는 뜻으로 읽은 사건이라 그 규칙의 사례가 된다.
- **가까운 층부터 한 층씩 건너뛰며 확인한다 — 층마다 성공 신호가 다르다**
설치가 끝났다는 출력 대신 각 층에서 무엇이 성공 신호인지로 판정을 옮기는 절차다.
- **cloud-init 이 안 도는 원인은 넷인데 증상은 「SSH 가 안 붙는다」 하나였다**
같은 구축의 한 단계 앞에서 벌어진 일이고, 거기서도 실패가 아무 오류 없이 지나갔다.
- **가이드대로 다시 쳐서 이 실험대가 같은 상태로 서는가**
여기 적은 설치 명령을 다시 쳐서 같은 상태가 되는지는 확인된 적이 없다.
- **단계별 구축 문서는 작성 시점이 아니라 실행 순서로 검증한다**
명령을 어느 셸에서 치는지를 단계마다 확인하라는 규칙이고, 그 확인이 빠졌을 때 나온 실패가 이것이다.
## 문제
k3s agent 설치가 끝까지 돌았고 출력에 오류가 없었다. 그런데 두 노드가 Ready 로 나와야 할 kubectl get nodes 에 두 번째 노드가 나타나지 않았다.
설치 출력의 오류 : x
kubectl get nodes 의 노드 수 : 1
k3s-agent 유닛 : 5초마다 재시작
journalctl -u k3s-agent : level=fatal msg="Error: --token is required"
실패가 화면에 나오지 않아 설치는 끝난 것으로 읽힌다.
## 결론
토큰을 꺼내는 ssh 를 게스트 안에서 쳤고 그 명령이 Host key verification failed. 로 끝났다. 명령 치환으로 감싸면 오류는 stderr 로 흘러가고 변수에는 빈 문자열이 담긴다. 셸은 아무 불평도 하지 않는다.
agent 는 --token '' 을 받아 level=fatal msg="Error: --token is required" 로 죽지만, 설치 스크립트는 그 전까지를 다 성공으로 찍고 끝난다. 그래서 설치 출력만 보면 성공이고, 유닛이 Restart=always 라 5초마다 조용히 재시도한다.
해결 : 게스트에 들어가지 않고 lab host 한 셸에서 ssh kc-lab-1 '...' 형태로 친다
가드 : 값을 쓰기 전에 길이로 가른다. 이 실험대의 토큰은 108자였다
히스토리에 남기지 않으려면 : --token-file 로 넘긴다. 토큰을 꺼낸 셸과 같은 셸에서 쳐야 한다는 제약도 없어진다
## 검증 환경
호스트 : test-server, Arch Linux
CPU : i5-1135G7, 논리 코어 8
RAM : 11,648MiB
QEMU : 11.1.1
libvirt : 12.7.0
게스트 : Debian 12 genericcloud
k3s : v1.36.4+k3s1
server 노드 : kc-lab-1, 192.168.122.11
agent 노드 : kc-lab-2, 192.168.122.12
토큰 길이 : 108자
토큰 형식 : K10<해시>::server:<비밀번호>
## 재현 조건
1. lab host 에서 게스트 세 대를 세우고 kc-lab-1 에 k3s server 를 깐다.
2. lab host 에서 ssh kc-lab-1 로 게스트에 로그인한다.
3. 게스트 프롬프트에서 TOKEN=$(ssh kc-lab-1 'sudo cat /var/lib/rancher/k3s/server/node-token') 을 친다.
4. echo "${#TOKEN} 자" 가 0 을 낸다.
5. 같은 셸에서 agent 설치 명령에 --token "$TOKEN" 을 넘긴다. 설치 출력은 오류 없이 끝난다.
6. lab host 로 돌아와 kubectl get nodes 를 친다. 노드가 하나뿐이다.
7. ssh kc-lab-2 'journalctl -u k3s-agent' 에서 --token is required 를 찾는다.
## 본문
<!-- body:start -->
## 토큰이 지나는 길과 명령을 치는 셸
k3s server 는 `kc-lab-1` 에, agent 는 `kc-lab-2` 에 깔고 둘 다 lab host 에서 `ssh` 로 원격 실행한다. 가이드가 코드 블록마다 어느 기계에서 치는지를 붙여 둔 까닭이 여기에 있다 — 게스트는 libvirt NAT(`192.168.122.0/24`) 안에 있어 워크스테이션에서 직접 닿지 않고, `ssh kc-lab-1` 이라는 별칭도 lab host 의 `~/.ssh/config` 에만 있다.
agent 가 server 에 붙으려면 server 가 만든 node-token 이 필요하고, 그 값을 lab host 에서 꺼내 같은 셸에서 설치 명령에 넘긴다.
```bash
TOKEN=$(ssh kc-lab-1 'sudo cat /var/lib/rancher/k3s/server/node-token')
echo "${#TOKEN}" # 값이 아니라 길이만 확인한다
```
비밀은 길이나 존재 여부만 확인하고 값을 찍지 않는다. 가이드가 먼저 정한 표기 규약이고, 값을 찍으면 터미널 스크롤백과 화면 공유에 그대로 남기 때문이다.
```bash
ssh kc-lab-2 "curl -sfL https://get.k3s.io | sudo sh -s - agent \
--server https://192.168.122.11:6443 \
--token '$TOKEN' \
--node-ip 192.168.122.12"
```
## 게스트 안에서 친 ssh 는 빈 문자열이 된다
토큰을 꺼내려고 게스트에 먼저 들어가면 같은 명령이 다르게 끝난다. 게스트에는 lab host 의 개인키도 `~/.ssh/config` 도 없다.
```bash
[kc-lab-1] $ ssh kc-lab-1 'sudo cat /var/lib/rancher/k3s/server/node-token'
Host key verification failed.
```
여기까지는 오류 문구가 찍힌다. 문제는 이 명령을 `TOKEN=$(...)` 로 감쌌을 때다. 명령 치환은 표준 출력만 변수에 담으므로 오류는 stderr 로 흘러가고 `TOKEN` 에는 빈 문자열이 담기며, 셸은 아무 불평도 하지 않는다. 뒤이어 치는 설치 명령은 `--token ''` 을 넘긴 것과 같아진다.
## 설치 출력이 성공으로 끝나는 경로
빈 문자열을 받은 설치도 끝까지 돈다. agent 가 `--token ''` 을 받아 `level=fatal msg="Error: --token is required"` 로 죽는데, 설치 스크립트는 그 전까지를 다 성공으로 찍고 끝나므로 설치 출력만 보면 성공이다. 성공으로 찍힌 것은 내려받기와 유닛 생성과 `enable` 까지다. 유닛은 `Restart=always` 라 5초마다 조용히 재시도하고, 그래서 실패가 화면이 아니라 `journalctl -u k3s-agent` 안에서만 되풀이된다.
k3s server 와 agent 를 세우는 단계가 끝났다는 판정은 lab host 에서 노드 목록을 쳐서 두 노드가 `Ready` 로 나오는 것이다.
```bash
kubectl get nodes -o wide
```
```
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
```
빈 토큰으로 깔린 agent 는 이 목록에 올라오지 않는다.
## 값을 찍지 않고 길이만 잰다
토큰을 화면에 찍어 눈으로 대조하는 방법은 표기 규약이 막아 두었다. 그래서 설치 앞에 길이를 재는 한 줄을 둔다.
```bash
[ ${#TOKEN} -ge 50 ] || echo "TOKEN 이 비었다 — 3번으로 돌아간다"
```
이 실험대의 토큰은 108자였고 형식이 `K10<해시>::server:<비밀번호>` 라 k3s 판올림에 따라 자릿수가 달라진다. 그래서 이 가드는 값을 맞춰 보지 않고 길이가 `0` 이 아닌지만 본다.
길이가 `0` 으로 나오는 길은 셋이다. 가이드는 게스트 안에서 친 경우를 그중 가장 흔하다고 적어 두었고, 나머지 둘은 server 가 아직 안 떠서 토큰 파일이 없거나 새 셸을 열어 변수가 사라진 경우를 가리킨다. 가드 한 줄은 그 몇 분을 막는다 — 설치가 성공으로 끝나고, 노드 목록에서 한 줄이 빠진 것을 알아채고, `journalctl` 까지 가는 동안이다.
## 토큰을 파일로 넘기면 같은 셸 제약이 없어진다
위 가드는 `TOKEN` 이 셸 변수라 토큰을 꺼낸 셸과 같은 셸에서 쳐야 하고, 다른 창에서는 비어 있다. 토큰이 명령줄에 들어가는 것도 걸리면 파일로 넘긴다.
```bash
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"
```
토큰이 셸 히스토리에 남지 않고, 변수를 쓰지 않으니 셸이 달라도 된다.
## 확인하지 못한 것
설치 출력도 `journalctl` 출력도 `final/evidence/` 에 남기지 않았다. `level=fatal msg="Error: --token is required"``Host key verification failed.` 는 SSOT 본문에서 옮긴 것이고 명령 출력 파일이 근거가 아니다. 토큰 108자도 같다.
이 실패를 일부러 다시 만들어 본 기록도 없어서, 빈 토큰으로 설치하면 설치 출력이 성공으로 끝난다는 것은 한 번의 관측이다. 재현이 쉽지 않은 까닭은 이 부의 검증 방식에 있다 — 만드는 명령은 다시 치면 돌고 있는 실험대가 없어지므로 구축할 때 쓴 것을 옮기고 결과 상태를 확인하는 것으로 대신했다. 재현 없이 남길 수 있는 것은 `journalctl` 쪽이고, 그 출력을 `final/evidence/raw/` 에 남기면 진단 절에 증거가 붙는다.
가드 한 줄이 실제로 빈 토큰을 잡아 본 기록도 없다.
<!-- body:end -->
@@ -0,0 +1,196 @@
---
kind: CASE
slug: cloud-init-failures-all-look-like-ssh-refused
title: cloud-init 이 안 도는 원인은 넷인데 증상은 「SSH 가 안 붙는다」 하나였다
topic: build-completion-judgment
topicName: 끝났다는 판정
project: virtualization
status: 게시 전
sourceRevision: 9465582b5d1630eb4ae7c4e078021486919bf6b6
source:
- final/document.md#187-단계-01-게스트-세-대
- final/document.md#186-단계-00-lab-host-가상화-준비
---
# cloud-init 이 안 도는 원인은 넷인데 증상은 「SSH 가 안 붙는다」 하나였다
SSH 가 안 붙는 원인은 넷이고 넷 다 게스트 밖에서 정해진다. 그래서 SSH 설정을 고치기 전에 호스트명 한 낱말을 본다. 시드를 붙인 방식, YAML 파싱, vol-upload 누락, 가상 네트워크 autostart 가 그 넷이고, cloud-init 은 어느 쪽이든 오류를 남기지 않는다.
## 관계
- **빈 토큰이 조용히 흘러갔다 — 설치 출력은 성공이었고 agent 만 5초마다 다시 죽었다**
바로 다음 단계에서 벌어진 같은 모양의 실패이고, 거기서도 성공한 출력이 실패를 덮었다.
- **도구가 낸 출력은 대상의 상태가 아니다 — 그 명령이 무엇을 세는지부터 가른다**
스키마 검사기의 통과와 거부를 게스트의 상태로 읽으면 양쪽 방향으로 다 틀린다.
- **가이드대로 다시 쳐서 이 실험대가 같은 상태로 서는가**
게스트를 만드는 명령은 다시 쳐 본 적이 없어 여기 적은 원인 넷도 그 물음에 걸린다.
- **qcow2 파일 한 장이 담는 것 — 매핑표와 데이터 클러스터가 같은 파일 안에 있고, 파일 밖을 가리키는 것은 백킹 파일 경로 하나다**
게스트 디스크가 base 이미지 위의 오버레이라 `virt-install` 이 10GB 를 즉시 할당한 것으로 찍힌다.
- **단계별 구축 문서는 작성 시점이 아니라 실행 순서로 검증한다**
시드를 만드는 순서와 DHCP 예약을 넣는 순서가 결과를 가르는 단계라 그 규칙이 받는다.
## 문제
게스트 세 대가 running 인데 lab host 에서 SSH 가 키로 붙지 않는다.
virsh list --all : kc-lab-1 · kc-lab-2 · kc-lab-edge 모두 running
ssh donghyeon@192.168.122.11 : Permission denied (publickey)
게스트의 hostname : localhost
cloud-init 이 남긴 오류 : x
cloud-init 이 돌지 않으면 사용자도 SSH 키도 들어가지 않는다. 그런데 cloud-init 은 데이터소스를 못 찾아도 YAML 파싱에 실패해도 오류를 남기지 않아서, 원인 넷이 전부 SSH 하나로만 나타난다.
## 결론
증상 하나 뒤에 원인이 넷이고 넷 다 게스트 밖에서 정해진다.
시드를 --cloud-init 으로 붙였다 : SATA CD-ROM 으로 붙는데 Debian genericcloud 이미지에는 그 드라이버가 없다. 디스크로, bus=virtio 로 붙인다
YAML 파싱에 실패했다 : cloud-init 이 아무 오류를 남기지 않는다
vol-upload 를 빠뜨렸다 : 목록에는 이름이 보이는데 안이 0 으로 채워져 있어 cidata 라벨을 못 찾는다
default 네트워크의 autostart 가 no 다 : 지금은 되고 호스트를 재부팅한 다음에야 세 게스트의 SSH 가 한꺼번에 실패한다
판정 : SSH 설정을 고치기 전에 호스트명 한 낱말을 본다. ssh kc-lab-edge 'hostname' 이 kc-lab-edge 를 내면 시드가 읽혔고, 시드가 읽혔으면 같은 파일에 있던 SSH 키도 들어갔다
못 들어가면 : virsh screenshot 으로 화면을 떠서 로그인 프롬프트 앞의 호스트명을 읽는다
키가 안 들어갔을 때의 탈출구 : cloud-init 의 plain_text_passwd 로 콘솔 로그인
반대 방향도 하나 있다. 게스트의 cloud-init 22.4.2 스키마 검사기는 sudo 를 리스트로 쓴 것을 거부한다. 그런데 그 표기로도 부팅은 되고, kc-lab-1 과 kc-lab-2 에서 그 상태로 NOPASSWD sudo 가 돌고 있다. 검사가 통과해도 안 도는 쪽이 넷이고 검사에 걸려도 도는 쪽이 하나라, 어느 방향이든 검사 결과를 게스트의 상태로 읽으면 틀린다.
## 검증 환경
호스트 : test-server, Arch Linux
RAM : 11,648MiB
QEMU : 11.1.1
libvirt : 12.7.0
base 이미지 : Debian 12 genericcloud amd64
게스트 OS : Debian GNU/Linux 12 (bookworm)
게스트 : kc-lab-edge 192.168.122.10 · kc-lab-1 192.168.122.11 · kc-lab-2 192.168.122.12
시드 : seed-<이름>.iso, CIDATA 라벨, bus=virtio 로 붙임
게스트의 cloud-init : 22.4.2
cloud-init 이 깐 패키지 : curl · nftables
running 에서 done 까지 : 약 50초
## 재현 조건
1. base 이미지를 받고 게스트마다 cloud-init user-data 를 쓴다.
2. 시드 iso 를 만들고 virsh vol-create-as 로 볼륨을 만든 뒤 virsh vol-upload 로 채운다.
3. DHCP 예약을 먼저 넣고 virt-install 로 게스트를 만든다. 시드는 bus=virtio 로 붙인다.
4. virsh list --all 로 세 게스트가 running 인지 본다.
5. lab host 에서 ssh kc-lab-edge 'hostname; ip -4 -br addr show enp1s0; cloud-init status' 를 친다.
6. 호스트명이 게스트 이름이고 cloud-init status 가 done 이면 통과다. localhost 가 나오면 2번과 3번을 다시 본다.
7. SSH 가 안 붙으면 virsh screenshot kc-lab-1 /tmp/kc1.ppm 으로 화면을 떠서 로그인 프롬프트 앞의 호스트명을 읽는다.
## 본문
<!-- body:start -->
## 시드가 게스트에 들어가는 길
cloud-init 이 게스트 안에서 사용자를 만들고 SSH 키를 넣으려면 먼저 데이터소스를 찾아야 한다. 이 실험대는 그것을 `cidata` 라벨이 붙은 볼륨으로 준다. 그 안에는 `user-data``meta-data` 라는 정확한 이름의 파일 둘이 들어 있다.
```bash
printf 'instance-id: kc-lab-1-%s\nlocal-hostname: kc-lab-1\n' "$(date +%s)" > meta-kc-lab-1
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
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
```
볼륨을 만드는 것과 채우는 것이 다른 명령이다. `vol-create-as` 는 빈 볼륨을 만들 뿐이고 내용은 `vol-upload` 가 넣는다. 뒤엣것을 빠뜨리면 목록에는 이름이 보이는데 안이 0 으로 채워져 있고, cloud-init 은 `cidata` 라벨을 못 찾은 채 끝난다.
`instance-id` 에 타임스탬프를 넣는 것은 cloud-init 이 인스턴스마다 한 번만 초기화 모듈을 돌리기 때문이다. id 가 같으면 user-data 를 고쳐도 반영되지 않는다.
## 증상 하나에 원인 넷
| 무엇이 어긋났나 | 게스트에서 무엇으로 나타나나 |
|---|---|
| 시드를 `--cloud-init` 으로 붙였다 | SSH 가 `Permission denied (publickey)` · 호스트명이 `localhost` |
| YAML 파싱에 실패했다 | 같다 |
| `vol-upload` 를 빠뜨렸다 | 같다 |
| `default` 네트워크의 autostart 가 `no` 다 | 호스트를 재부팅한 뒤 세 게스트가 한꺼번에 |
넷 가운데 이 실험대에서 관측으로 적힌 것은 시드를 붙인 방식이다. `--cloud-init` 옵션은 시드를 SATA CD-ROM 으로 붙이는데, Debian `genericcloud` 이미지는 크기를 줄이려고 물리 하드웨어 드라이버를 뺐다. AHCI 장치가 보이지 않아 cloud-init 이 데이터소스를 못 찾고 조용히 끝난다. 그래서 시드를 디스크로, `bus=virtio` 로 붙인다.
```bash
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
```
마지막 원인은 한 단계 앞에서 만들어진다. lab host 를 준비할 때 `default` 네트워크의 autostart 를 켜지 않으면 지금은 세 게스트가 다 붙고, 호스트를 재부팅한 다음에야 SSH 가 한꺼번에 실패한다. 그때 원인을 게스트 안에서 찾게 된다.
## 호스트명 한 낱말이 시드와 SSH 를 가른다
게스트에 SSH 가 붙는다면 확인은 한 줄로 끝난다.
```bash
ssh kc-lab-edge 'hostname; ip -4 -br addr show enp1s0; cloud-init status'
```
```
kc-lab-edge
enp1s0 UP 192.168.122.10/24 metric 100
status: done
```
호스트명이 바뀌었다는 것은 시드가 읽혔다는 뜻이고, 시드가 읽혔으면 같은 파일에 있던 SSH 키도 들어갔다. `localhost` 가 나오면 SSH 설정을 고치지 말고 시드부터 의심한다. `cloud-init status``running` 이면 아직 패키지를 받는 중이고, 이 실험대에서는 `done` 까지 약 50초 걸렸다.
게스트에 못 들어가면 화면을 직접 뜬다.
```bash
virsh screenshot kc-lab-1 /tmp/kc1.ppm # 확장자와 무관하게 PNG 로 저장된다
```
로그인 프롬프트 앞의 호스트명 한 낱말만 읽는다. `localhost login:` 이면 cloud-init 이 아예 안 돌았으므로 SSH 쪽은 볼 것이 없다. `kc-lab-1 login:` 이면 cloud-init 은 돌았고 그 안의 사용자·키 단계에서 틀린 것이라 콘솔로 들어가 로그를 본다. 콘솔 로그인에 쓰는 비밀번호가 cloud-init 의 `plain_text_passwd` 이고, 키가 안 들어갔을 때 게스트로 들어가는 길이 이것 하나다.
순서를 이렇게 정한 까닭이 여기에 있다. 화면 한 장과 콘솔에서 보는 로그 두 줄이 「SSH 가 안 되는 이유」를 절반으로 줄인다. 호스트명이 그 절반을 가르므로, 어느 쪽 절반을 볼지가 정해지기 전에는 SSH 설정을 열 이유가 없다.
## 검사가 거부해도 부팅은 된다
게스트의 cloud-init `22.4.2` 스키마 검사기는 `sudo` 를 리스트로 쓴 것을 거부한다.
```yaml
sudo: ['ALL=(ALL) NOPASSWD:ALL'] # 거부된다
sudo: "ALL=(ALL) NOPASSWD:ALL" # 통과한다
```
```
Error: Cloud config schema errors: users.0: {'name': 'donghyeon', 'groups':
['sudo'], 'shell': '/bin/bash', ...} is not valid under any of the given schemas
```
어느 키가 문제인지는 알려 주지 않는다. `users.0` 전체를 통째로 찍고 어느 스키마에도 안 맞는다고만 한다. 그리고 리스트 표기로도 부팅은 된다 — `kc-lab-1``kc-lab-2` 가 그 표기로 만들어졌고 NOPASSWD sudo 가 멀쩡히 돌고 있다.
`NOPASSWD:ALL` 을 넣은 것은 k3s 설치와 장애 주입이 비대화식으로 돌아야 해서다. 비밀번호를 물으면 원격 실행이 거기서 멈춘다. 검사기가 거부한 줄이 바로 그 줄이다.
검사가 통과해도 cloud-init 이 안 도는 경로가 넷이고, 검사가 거부해도 도는 경로가 하나다.
## 시드를 만들기 전에 세 줄로 거른다
```bash
grep -c '__' kc-lab-1.yaml # 0 이어야 한다
grep -c 'ssh-ed25519\|ssh-rsa' kc-lab-1.yaml # 2 여야 한다
python3 -c 'import yaml,sys; yaml.safe_load(open("kc-lab-1.yaml")); print("YAML OK")'
```
`0``2``YAML OK` 셋이 맞아야 시드를 만든다. 셋이 맞아도 cloud-config 로 유효하지는 않다 — 키 이름을 `users` 대신 `user` 로 친 오타는 이 세 줄을 그냥 통과한다. 그래서 게스트가 한 대라도 떠 있으면 cloud-init 자신의 스키마 검사기를 쓴다.
```bash
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'
```
이 파일에는 콘솔 비밀번호가 평문으로 들어 있어 `/tmp` 가 아니라 자기 홈에 `600` 으로 두고, 검사가 끝나면 바로 지운다.
## 확인하지 못한 것
넷 가운데 SSOT 가 관측으로 표시한 것은 시드를 SATA 로 붙였을 때 AHCI 장치가 보이지 않는 것과 스키마 검사기의 거부 문구뿐이다. YAML 파싱 실패와 `vol-upload` 누락과 네트워크 autostart 셋은 가이드가 막히면 표에 적어 둔 항목이라, 이 실험대에서 실제로 그 증상을 본 것인지 예상해 적은 것인지 SSOT 가 가르지 않았다. 이 글에서 그 셋은 그렇게 되는 구조까지이고 그렇게 됐다가 아니다.
명령 출력은 원문으로 남아 있지 않다. `Permission denied (publickey)``cloud-init status: done` 도 약 50초도 SSOT 본문이 근거다. `final/evidence/` 에 그 출력을 담은 파일이 없다. `virsh screenshot` 으로 뜬 화면도 저장해 두지 않았다.
만드는 명령은 재실행으로 검증되지 않았다. 게스트를 다시 만들면 돌고 있는 실험대가 없어지므로 시드와 관련된 셋은 다시 재현하기 어렵다. 네트워크 autostart 만은 호스트를 재부팅해 확인할 수 있는데 그 기록도 없다.
<!-- body:end -->
@@ -0,0 +1,186 @@
---
kind: CASE
slug: renewal-succeeded-while-the-old-certificate-kept-serving
title: 갱신은 매번 SUCCESS 였고 옛 인증서가 계속 나갔다 — 2305초와 1~2초
topic: build-completion-judgment
topicName: 끝났다는 판정
project: virtualization
status: 게시 전
sourceRevision: 9465582b5d1630eb4ae7c4e078021486919bf6b6
source:
- final/document.md#190-단계-04-let's-encrypt-와-인증서-갱신
- final/document.md#189-단계-03-엣지-nginx-라우팅과-호스트-dnat
- final/document.md#194-이-부에서-파생될-open-question
---
# 갱신은 매번 SUCCESS 였고 옛 인증서가 계속 나갔다 — 2305초와 1~2초
갱신은 매번 SUCCESS 로 끝나는데 nginx 는 옛 인증서를 계속 내보냈다. certbot-renew.service 에 훅이 없어 갱신에서 서빙까지 2305초 걸렸고, deploy 훅을 두니 1~2초였다. 만료 30일 전까지는 갱신을 안 해 88일 동안 아무 표시도 나지 않는다.
## 관계
- **인증서는 DNS-01 로 받는다 — 실험대 주소가 공개 인터넷에 없어 HTTP-01 이 성립하지 않는다**
이 인증서를 어떻게 받기로 했는지가 그 결정이고, 여기서는 받은 뒤의 갱신을 다룬다.
- **도구가 낸 출력은 대상의 상태가 아니다 — 그 명령이 무엇을 세는지부터 가른다**
갱신 로그의 SUCCESS 와 훅 로그의 error output 이 둘 다 상태를 잘못 말한 사건이다.
- **가까운 층부터 한 층씩 건너뛰며 확인한다 — 층마다 성공 신호가 다르다**
이 단계의 확인을 어느 기계에서 치느냐가 결과를 바꾸고, 층별 확인이 그 순서를 정한다.
- **단계별 구축 문서는 작성 시점이 아니라 실행 순서로 검증한다**
훅을 호스트에 두었다가 잃은 것도, 확인 명령을 엣지 안에서 친 것도 그 규칙이 받는 결함이다.
- **가이드대로 다시 쳐서 이 실험대가 같은 상태로 서는가**
2305초를 잰 배치와 지금 배치가 다르므로 다시 세운 실험대에서 재야 할 값이 여기 있다.
## 문제
인증서 갱신은 매번 성공으로 끝나는데 서버가 내보내는 인증서는 옛것이었다.
certbot-renew.timer : 정상
certbot-renew.service 의 결과 : SUCCESS
유닛의 ExecStartPost 또는 --deploy-hook : x
nginx 가 새 인증서를 읽었나 : x
갱신에서 서빙까지 : 2305초 (38분 25초)
무엇이 reload 했나 : 사람이 직접
발현하는 날의 증상은 인증서 만료이고, 그날에도 로그에는 SUCCESS 라고 적혀 있다.
## 결론
certbot-renew.service 는 /usr/bin/certbot -q renew 한 줄이고 인증서를 새로 받는 데까지만 책임진다. nginx 는 인증서를 기동 시점에 읽어 메모리에 들고 있고 certbot 은 live/ 심볼릭 링크만 갈아 끼우므로, 경로는 그대로이고 내용만 바뀌어 nginx 는 바뀐 줄 모른다.
훅도 사람도 없으면 다음 nginx 재시작까지, 사실상 무기한으로 옛 인증서가 나간다.
해결 : /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh 에 nginx -t && nginx -s reload 를 두고 chmod +x 를 준다
실행 권한이 없으면 : certbot 이 조용히 건너뛴다
post/ 가 아니라 deploy/ 인 까닭 : post/ 는 갱신이 없어도 매번 돌아 하루 두 번 워커를 갈아치운다
훅을 저장소에 두는 까닭 : 호스트에만 두었을 때는 호스트를 초기화하면 아무 오류 없이 사라졌다
판정 : 로그 문구가 아니라 강제 갱신 전후의 nginx 워커 PID 로 한다
훅을 넣어도 안전했다. reload 는 무중단이었고 새 연결 8856건이 전부 200, p95 는 205.7ms 대 204.3ms 로 변화가 없었다.
## 검증 환경
엣지 게스트 : kc-lab-edge, 192.168.122.10, Debian 12
nginx : nginx/1.22.1
certbot 플러그인 : dns-cloudflare
발급 대상 : -d hyeonworks.com -d '*.hyeonworks.com'
lineage 디렉터리 : /etc/letsencrypt/live/hyeonworks.com/
nginx 가 읽는 파일 : fullchain.pem · privkey.pem
타이머 : certbot-renew.timer → certbot-renew.service
인증서 유효기간 : 오늘 + 90일
2305초를 잴 때의 배치 : 훅이 물리 호스트에 있었다
지금의 배치 : certbot · 인증서 · 갱신 타이머 · deploy 훅이 전부 엣지 게스트에 있다
## 재현 조건
1. 엣지 게스트에 certbot 과 python3-certbot-dns-cloudflare 를 깔고 와일드카드 인증서를 받는다.
2. nginx 443 블록에 fullchain.pem 과 privkey.pem 경로를 적고 reload 한다.
3. systemctl cat certbot-renew.service 로 ExecStartPost 와 --deploy-hook 이 없는지 본다.
4. ps -eo pid,lstart,args 로 nginx 워커 PID 를 적어 둔다.
5. sudo certbot renew --force-renewal 을 친다.
6. 워커 PID 를 다시 본다. 바뀌지 않았으면 옛 인증서가 계속 나가고 있다.
7. deploy/reload-nginx.sh 를 두고 chmod +x 를 준 뒤 4번부터 6번까지를 다시 한다.
## 본문
<!-- body:start -->
## 배포판 유닛이 어디까지 책임지나
Let's Encrypt 인증서는 90일짜리이고 갱신은 `certbot-renew.timer` 가 건다. 타이머가 부르는 유닛을 열어 보면 한 줄이다.
```bash
systemctl cat certbot-renew.service
```
```
[Service]
Type=oneshot
ExecStart=/usr/bin/certbot -q renew
PrivateTmp=true
```
`ExecStartPost``--deploy-hook` 도 없다. 이 유닛은 인증서를 새로 받는 데까지만 책임지고, 받은 것을 nginx 가 읽게 만드는 일은 아무도 하지 않는다. 배포판이 이렇게 준다는 것이 요점이라, 「기본값이니 괜찮겠지」가 이 결함이 사는 곳이다.
그래서 이 글의 훅이 모든 배포판에 필요하지는 않다. 위 유닛에 `ExecStartPost``--deploy-hook` 이 이미 적혀 있는 배포판이라면 훅을 새로 넣을 것이 아니라 거기 적힌 명령이 nginx 를 reload 하는지만 본다.
## nginx 는 인증서를 언제 읽나
nginx 는 `ssl_certificate` 에 적힌 파일을 기동과 reload 시점에 읽어 메모리에 들고 있고, certbot 은 `live/` 아래의 심볼릭 링크를 새 파일로 갈아 끼운다. 설정에 적힌 경로는 한 글자도 바뀌지 않고 그 경로가 가리키는 파일만 바뀌므로, nginx 쪽에서는 다시 읽을 계기가 생기지 않는다. 갱신이 끝난 뒤에 nginx 로 reload 를 걸어 주는 것이 없으면 옛 인증서가 계속 나간다.
## 88일 동안 이 결함이 보이지 않는다
타이머는 정상이고 갱신은 매번 `SUCCESS` 로 끝난다. 만료 30일 전까지는 갱신 자체를 하지 않으므로 88일 동안 발현할 기회가 없다. 발현하는 날의 증상은 인증서 만료이고, 그날에도 로그에는 `SUCCESS` 라고 적혀 있다.
훅을 저장소에 두는 까닭도 여기에 있다. 호스트에만 두었을 때는 호스트를 초기화하면 아무 오류 없이 사라졌고, 사라진 것도 갱신이 실제로 일어나는 날까지는 드러나지 않는다.
## 훅 두 줄과 실행 권한
```sh
# file: /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
#!/bin/sh
nginx -t && nginx -s reload
```
```bash
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
```
실행 권한이 없으면 certbot 이 조용히 건너뛴다. `post/` 가 아니라 `deploy/` 에 넣는 것은 `post/` 가 갱신이 없어도 매번 돌아 하루 두 번 워커를 갈아치우기 때문이다.
이 실험대에서 잰 차이는 이렇다.
| | 훅 없음 | 훅 있음 |
|---|---|---|
| 갱신 → 서빙 | 2305초 (38분 25초) | 1~2초 |
| 무엇이 reload 했나 | 사람이 직접 | certbot deploy 훅 |
훅도 사람도 없었다면 다음 nginx 재시작까지, 사실상 무기한이다.
## 판정은 로그 문구가 아니라 워커 PID 로 한다
```bash
sudo certbot renew --dry-run
```
dry-run 은 훅이 호출되는지까지만 말해 준다. 호출된 훅이 nginx 를 정말 갈아 끼웠는지는 따로 본다.
순서가 dry-run 먼저인 것은 뒤엣것이 상태를 바꾸기 때문이다. `--force-renewal` 은 인증서를 실제로 새로 받아 발급 한도(주당 중복 5장)를 깎으므로, 진짜 판정이 필요할 때 한 번만 쓴다.
```bash
# 강제 갱신 전에 워커 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
```
reload 는 마스터를 그대로 두고 워커만 갈아 끼우므로, 전후로 워커 PID 가 바뀌면 새 설정이 적용됐다. `lstart` 를 같이 뽑는 것은 PID 가 우연히 재사용됐을 때를 가르기 위해서다.
certbot 이 `Hook 'deploy-hook' ran with error output` 이라고 찍는데 실패가 아니다. nginx 의 `types_hash` 경고가 stderr 로 나갔을 뿐이고 내용은 `test is successful``signal process started` 다. 로그에서 `error` 를 grep 하는 감시를 걸면 성공한 훅을 실패로 오독한다.
## 훅을 넣어도 안전한가
갱신 때마다 reload 가 도는 것이 서비스에 영향을 주는지도 쟀다. 새 연결 8856건이 전부 200 이었고 p95 는 205.7ms 대 204.3ms 로 변화가 없었다. 845KB 를 20k/s 로 받는 중이던 요청은 전송 12초째에 reload 를 맞고도 845361바이트를 온전히 받았다(연결수 1). 옛 워커가 그 요청을 끝까지 책임진다.
## 수치를 내기 전에 시계를 쟀다
갱신 시각과 reload 시각이 다른 기계에 찍히므로, 두 시각을 빼기 전에 시계부터 쟀다. test-server 가 NTP 미동기로 106초 빨랐고, 보정하지 않은 첫 계산은 훅이 인증서 발급보다 104초 먼저 실행된 것이 되어 물리적으로 불가능했다. 음수 지연이 나오면 계산이 아니라 시계를 의심한다.
```bash
A=$(date -u +%s.%N); B=$(ssh test-server 'date -u +%s.%N'); C=$(date -u +%s.%N)
# 왜곡 ≈ B (A+C)/2 , 어느 쪽이 맞는지는 외부 기준으로 가른다
curl -sI https://www.google.com | grep -i '^date:'
timedatectl show -p NTP -p NTPSynchronized
```
## 확인하지 못한 것
2305초는 훅이 물리 호스트에만 있던 시절의 값이다. 지금은 certbot 과 인증서와 갱신 타이머와 deploy 훅이 전부 엣지 게스트에 있고, 그 배치에서 다시 재면 같은 수가 나오는지는 재지 않았다. 그래서 이 글의 2305초는 훅이 없으면 이만큼 벌어진다는 한 번의 측정이고 지금 배치의 값이 아니다. 1~2초 쪽도 같은 시기에 잰 값이다.
측정 출력을 `final/evidence/` 에 남기지 않았다. 2305초도 8856건도 p95 두 값도 845361바이트도 106초도 SSOT 본문에만 있다. 워커 PID 를 전후로 비교한 출력도 저장해 두지 않았다.
88일이라는 잠복 기간은 만료 30일 전에야 갱신을 시작한다는 동작에서 끌어낸 것이지, 한 주기를 실제로 돌려 본 값이 아니다. 훅을 뺀 채 만료일까지 가 본 기록은 없고 그런 기록이 있을 까닭도 없다.
<!-- body:end -->
@@ -0,0 +1,96 @@
---
kind: QUESTION
slug: does-the-guide-rebuild-this-lab
title: 가이드대로 다시 쳐서 이 실험대가 같은 상태로 서는가
topic: build-completion-judgment
topicName: 끝났다는 판정
project: virtualization
status: 게시 전
questionStatus: OPEN
sourceRevision: 9465582b5d1630eb4ae7c4e078021486919bf6b6
source:
- final/document.md#194-이-부에서-파생될-open-question
- final/document.md#184-이-부의-출처와-범위
---
# 가이드대로 다시 쳐서 이 실험대가 같은 상태로 서는가
생성 명령을 다시 쳐서 같은 실험대가 서는지는 확인된 적이 없다. 돌고 있는 실험대를 멈출 수 없어 구축할 때 쓴 명령을 옮기고 결과 상태를 확인하는 것으로 대신했다. 다시 세우는 데 필요한 매니페스트 둘과 cloud-init 템플릿도 저장소에 없다.
## 관계
- **단계별 구축 문서는 작성 시점이 아니라 실행 순서로 검증한다**
그 기준이 요구하는 검증을 이 물음이 실제로 실행한다. 그 기록은 스스로 「이 기준으로 가이드를 고친 뒤 처음부터 다시 따라가 본 기록이 아직 없다」고 적었다.
- **WiFi 전용 호스트에서 대용량 qcow2 를 옮기는 데 실제로 몇 시간이 걸리는가**
같은 물음의 다른 절반이다. 옮기는 것과 다시 세우는 것 가운데 어느 쪽이 복원 경로인지는 두 값이 다 나와야 정해진다.
- **cloud-init 이 안 도는 원인은 넷인데 증상은 「SSH 가 안 붙는다」 하나였다**
재구축에서 01 단계가 가장 먼저 걸린다. 그 기록이 센 네 원인이 다시 나오는지가 이 검증의 한 칸이다.
- **빈 토큰이 조용히 흘러갔다 — 설치 출력은 성공이었고 agent 만 5초마다 다시 죽었다**
02 단계에서 같은 일이 벌어진다. 길이 가드 한 줄이 실제로 빈 토큰을 잡아 본 기록이 없으므로, 재구축이 그 가드를 처음으로 시험하게 된다.
- **도구가 낸 출력은 대상의 상태가 아니다 — 그 명령이 무엇을 세는지부터 가른다**
다시 선 실험대가 같은 상태인지를 판정할 근거가 전부 그런 출력이다. 통과 조건 일곱을 그대로 믿을지 여러 층으로 견줄지가 그 기준에 걸려 있다.
## 사실
- §184 가 이 부의 검증 방식을 갈라 적었다. 읽기 전용 확인은 돌아가는 실험대에서 실제로 실행해 출력을 그대로 실었다.
- 만드는 명령은 그렇게 하지 못했다. VM 을 다시 만들거나 k3s 를 다시 깔면 돌고 있는 실험대가 없어지므로, 구축할 때 쓴 명령을 그대로 옮기고 결과 상태를 확인하는 것으로 대신했다.
- §186 부터 §192 까지의 생성 명령에는 unknown 이 붙어 있다. 「지금 다시 쳐도 같은 상태가 된다」가 확인되지 않았다.
- 세울 대상과 단계마다의 통과 조건은 §185 의 표에 일곱 줄로 적혀 있다. virsh list 가 돈다 · 세 게스트에 SSH 가 붙는다 · kubectl get nodes 에 둘 다 Ready · 밖에서 요청이 파드까지 닿는다 · https 가 열리고 체인이 4단계 · 관리 콘솔 로그인이 된다 · vendor_cluster_size 가 2.
- 다시 세울 때 필요한 것 가운데 일부가 저장소에 없다. 매니페스트 둘(keycloak-cluster.yaml · observability.yaml)과 cloud-init 템플릿 kc-lab.yaml.example 이 source/ 에 반입되지 않았고, 가이드가 화면에 옮겨 적은 만큼만 있다.
- 가이드를 순서대로 따라가다 나온 결함 여섯을 §182 가 이미 표로 적었고, 공통 원인 하나를 inferred 로 붙였다.
- 「단계별 구축 문서는 작성 시점이 아니라 실행 순서로 검증한다」가 스스로 밝혔다. 그 규칙으로 가이드를 고친 뒤 처음부터 다시 따라가 본 기록이 아직 없고, 규칙이 결함을 막아 냈다는 관측도 아직 없다.
## 가정
- 검증을 시작하는 판이 지금 source/ 에 있는 가이드라고 본다. 그 가이드가 §182 의 결함 여섯을 고친 판인지는 대조하지 않았다.
- 밖에서 받아 오는 것들이 그때와 같은 판이라고 전제한다. Debian 12 genericcloud 이미지와 get.k3s.io 설치 스크립트와 apt 저장소의 nginx 와 certbot 이 그것이다. 판 번호가 달라지면 같은 명령이 다른 상태를 만든다.
- 도메인과 Cloudflare 토큰과 tailnet 주소는 다시 세울 때도 그대로 쓴다고 본다. 04 단계 전체가 그 셋에 묶여 있다.
- 새로 세우는 기계의 CPU 가 하드웨어 가상화를 지원한다고 전제한다. 아니면 00 단계부터 다른 이유로 막히고, 그 막힘은 가이드의 결함이 아니다.
## 미지수
- 지금의 가이드 7단계를 빈 호스트에서 처음부터 순서대로 쳤을 때 어느 단계에서 멈추는지. 멈춘다면 그것이 §182 가 이미 센 여섯 중 하나인지 그때는 안 보이던 새 결함인지.
- source/ 에 없는 매니페스트 둘과 cloud-init 템플릿 없이 02 와 05 와 06 단계가 문서만으로 서는지.
- 다시 선 실험대가 지금과 같은 상태인지를 무엇으로 판정할지. 통과 조건 일곱이 같은 값을 내는 것으로 충분한지, 판 번호까지 같아야 하는지 — libvirt 12.7.0 · QEMU emulator version 11.1.1 · v1.36.4+k3s1 · nginx/1.22.1 이 지금 값이다.
## 제약
- 지금 돌고 있는 실험대를 멈출 수 없다. §184 가 생성 명령을 재실행으로 검증하지 않은 이유가 그것이고, 이 물음도 같은 제약 아래에서 답해야 한다.
- 이 호스트에 한 벌 더 세우기에는 메모리가 모자란다. §187 의 배치는 세 게스트 합이 10240MB 이고 §178 의 호스트 RAM 은 11,648MiB 다.
- 04 단계는 공개 인터넷이 아니라 tailnet 과 Cloudflare 계정에 묶여 있다. 다른 기계에서 재려면 그 둘에 닿아야 한다.
- Let's Encrypt 의 주당 중복 인증서 5장 한도가 있다. 재구축을 여러 번 돌리면 04 단계가 거기 걸린다.
- 한 번 끝까지 따라가는 것으로 답한다. 여러 번 돌려 분포를 보는 것은 이 물음의 범위 밖이다.
## 선택지
### 1. 다른 기계에 빈 호스트를 두고 00 부터 06 까지 순서대로 친다
돌고 있는 실험대를 건드리지 않고 가이드만 시험한다. 하드웨어가 달라도 상관없는 대신, 막힌 단계가 가이드의 결함인지 하드웨어 차이인지를 가르는 일이 따로 붙는다.
결과는 네 줄로 적는다.
멈춘 단계 : 00 부터 06 까지 중 어디인가
멈춘 이유 : 그 시점에 리소스가 없어서인가, 그 셸에서 안 도는 명령이라서인가, 둘 다 아닌가
§182 의 여섯과 겹치나 : o 또는 x
통과 조건 일곱 : 단계마다 같은 값이 나왔는가
### 2. 이 호스트에 게스트 세 대를 새 이름으로 한 벌 더 세운다
하드웨어 차이가 없으므로 막힌 단계를 가이드 쪽으로 좁힐 수 있다. 대신 §187 의 배치로는 메모리가 모자라니 게스트 크기를 줄여 돌리고, 그 사실을 결과에 함께 적는다. 크기를 줄인 채로 나온 값은 05 단계와 06 단계에서 지금 실험대와 다를 수 있다.
### 3. 문서만 읽어 빠진 단계를 찾는다 — 제외
§182 가 이미 답을 적었다. 개별 명령은 전부 실제로 돌았던 것이고 틀린 것은 명령이 아니라 그 명령이 놓인 위치라, 각 줄은 참인데 순서대로 따라가면 막힌다. 그런 결함은 문서를 읽어서는 안 나오고 실행해야 나온다.
## 다음 검증
1. 매니페스트 둘과 cloud-init 템플릿을 source/ 로 반입해 final/ 에 넣는다. 없이 시작하면 이 검증이 재는 것이 「가이드가 서는가」가 아니라 「빠진 파일을 다시 만들 수 있는가」로 바뀐다.
2. 대상을 정한다. 다른 기계면 1번, 이 호스트에 한 벌 더면 2번이고, 2번을 고르면 게스트 메모리를 줄인 값을 먼저 적는다.
3. 가이드 그대로 00 부터 06 까지 순서대로 친다. §184 가 명령을 두 이름으로 갈라 적어 둔 곳에서는 어느 쪽을 칠지부터 정한다 — 「이 실험대는 이렇게 했다」는 실제로 친 명령 그대로이고, 「따라 하는 사람은」 쪽은 이 실험대에서 한 번도 치지 않았다. 각 단계의 「이 단계가 끝나면」 명령을 치고 출력을 final/evidence/raw/ 에 원문으로 남기고, meta/ 에 명령과 cwd 와 실행 시각과 종료 코드를 적는다.
4. 04 단계는 --dry-run 을 먼저 돌린다. 주당 중복 인증서 5장 한도를 dry-run 은 쓰지 않는다.
5. 막힌 단계마다 무엇이 없어서 막혔는지를 §182 의 두 축으로 분류해 적는다. 그 시점에 리소스가 없었나, 그 셸에서 안 도는 명령이었나. 어느 축에도 안 들어가면 그것을 새 축으로 적는다.
6. 끝나면 판 번호 넷을 적어 지금 실험대의 값과 나란히 둔다.
닫는 조건 : 00 부터 06 까지 통과 조건 일곱이 전부 같은 값을 내면 「가이드만으로 이 실험대가 다시 선다」고 적고 닫는다. 그러면 「WiFi 전용 호스트에서 대용량 qcow2 를 옮기는 데 실제로 몇 시간이 걸리는가」가 재는 이동 시간과 견줄 대상이 생겨, 옮기는 편이 빠른지 다시 세우는 편이 빠른지가 그때 정해진다. 옮기는 것이 현실적이지 않다면 이 실험대의 복원 경로는 문서 하나가 된다.
어느 단계에서든 막히면 그 단계를 §182 의 결함 표에 행으로 더하고, 「단계별 구축 문서는 작성 시점이 아니라 실행 순서로 검증한다」의 「규칙이 결함을 막아 냈다는 관측은 아직 얻지 못했다」를 그 결과로 바꾼다. 막힌 단계가 그 규칙의 두 축 안이면 규칙이 통한 것이고, 밖이면 축이 모자란 것이라 규칙을 고친다. 어느 쪽이든 §186 부터 §192 까지의 생성 명령에 붙은 unknown 이 그때 확인이나 반증으로 바뀐다.
@@ -0,0 +1,92 @@
---
kind: REFERENCE
slug: check-the-nearest-layer-first
title: 가까운 층부터 한 층씩 건너뛰며 확인한다 — 층마다 성공 신호가 다르다
topic: build-completion-judgment
topicName: 끝났다는 판정
project: virtualization
status: 게시 전
sourceRevision: 9465582b5d1630eb4ae7c4e078021486919bf6b6
source:
- final/document.md#189-단계-03-엣지-nginx-라우팅과-호스트-dnat
- final/document.md#185-가이드-묶음이-스스로-정한-규약
- final/document.md#191-단계-05-keycloak-2노드와-postgresql
---
# 가까운 층부터 한 층씩 건너뛰며 확인한다 — 층마다 성공 신호가 다르다
가장 가까운 층부터 치고 한 칸씩 밖으로 나오며, 층마다 성공 신호를 미리 적는다. 200 이 아닌 층이 있다 — 03 단계 네 칸의 실측은 404 와 301 과 301 과 200 이었다. 밖에서 한 번 쳐서 받은 값 하나로는 여섯 층 가운데 어디서 끊겼는지가 나오지 않는다.
## 관계
- **빈 토큰이 조용히 흘러갔다 — 설치 출력은 성공이었고 agent 만 5초마다 다시 죽었다**
가장 안쪽 칸에서 연결 거부나 타임아웃이 나오면 02 단계로 돌아가는데, 그 기록이 거기서 노드가 안 붙는 이유 하나를 끝까지 따라간다.
- **갱신은 매번 SUCCESS 였고 옛 인증서가 계속 나갔다 — 2305초와 1~2초**
04 단계의 판정도 같은 모양으로 갈린다. 그 기록은 로그 문구가 아니라 워커 PID 로 판정하라는 결론을 낸다.
- **도구가 낸 출력은 대상의 상태가 아니다 — 그 명령이 무엇을 세는지부터 가른다**
여기서는 어디서 끊겼는지까지만 좁힌다. 좁힌 층의 출력을 어떻게 읽고 무엇을 잘못 읽기 쉬운지는 그쪽에 적혀 있다.
- **packet 이 어디서 끊겼는지는 계층마다 capture 해서 가른다**
같은 「어디서 끊겼나」를 응답 코드가 아니라 네 지점의 capture 로 좁힌다. 응답 코드로는 가릴 수 없을 때 그쪽으로 넘어간다.
- **호스트 안에서는 404, 밖에서는 connection refused — 앞 체인의 accept 가 libvirt 의 reject 를 막지 못했다**
가장 안쪽 칸이 404 인데 밖에서만 막히는 상태를 그 기록이 다룬다. 층을 좁힌 뒤 남은 원인이 방화벽이었던 경우다.
## 목적
밖에서 한 번 친 값 하나로는 고칠 층을 고를 수 없다. §189 은 03 단계의 확인을 네 칸으로 나누고 칸마다 건너뛰는 층을 하나씩 늘렸다. 첫 칸은 엣지 nginx 를 건너뛰고 게스트의 80 을 직접 치고, 둘째 칸은 호스트 DNAT 을 건너뛰고 엣지를 직접 치고, 셋째 칸은 밖에서 도메인으로 치고, 넷째 칸은 TLS 가 붙은 뒤를 친다. 둘째 칸이 통과하는데 셋째 칸이 안 되면 막힌 곳은 DNAT 이고, 둘째 칸에서 막히면 막힌 곳은 엣지 안이다. 이 한 칸을 끼워 두면 그 둘이 섞이지 않는다.
이 순서를 쓰려면 층마다 무엇이 성공인지를 먼저 적어야 한다. 첫 칸의 404 는 게스트의 80 을 Traefik 이 듣고 있고 매칭되는 Ingress 규칙이 없다고 답한 것이라 성공이다. 502 면 Traefik 은 떴는데 뒤에 백엔드가 없는 것이고, 연결 거부나 타임아웃이면 02 단계의 노드 상태로 돌아간다. 미리 적어 두지 않으면 404 를 보고 nginx 설정부터 고치기 시작한다. 성공 신호를 적어 두지 않아 통과한 출력을 실패로 읽는 일은 이 실험대에서 이미 있었다. §189 은 nginx -t 가 Debian 12 에서 늘 같이 내놓는 경고 한 줄을 04 단계에서 실패로 오독하는 일이 실제로 벌어졌다고 적고, 그래서 경고와 오류를 구분하는 눈을 03 단계에서 들여 둔다.
05 단계도 같은 순서로 판정한다. 밖에서 200 이면 nginx 에서 Traefik, Ingress, Service 를 지나 파드까지 전부 이어졌다. 502 나 503 이면 Ingress 가 있는지, Service 뒤에 파드가 있는지, 파드가 Ready 인지를 뒤에서부터 되짚는다.
## 규칙
### 1. 가장 가까운 층에서 시작해 한 칸씩 밖으로 나오며 친다
밖에서 시작하면 응답 하나에 여섯 층이 전부 들어가 있어 어느 층이 답한 것인지 가릴 수 없다. 안쪽에서 시작해 한 칸씩 층을 더하면, 값이 처음 달라지는 칸에서 더한 층이 끊긴 층이다.
### 2. 층마다 성공 신호를 미리 적는다. 200 이 아닌 층이 있다
03 단계 네 칸의 실측은 404 와 301 과 301 과 200 이었다. 첫 칸의 404 와 둘째·셋째 칸의 301 은 그 층이 제 일을 했다는 뜻이라, 성공 신호를 적어 두지 않으면 이 셋이 전부 실패로 읽힌다.
### 3. 무엇이 잘못됐는지 모르는 동안에는 값만 뽑는 명령을 쓰지 않는다
§185 의 ① 이 확인 명령을 두 종류로 갈라 적는다. 실무자가 한 번 볼 때 치는 curl -I 는 헤더를 통째로 내놓고, 여러 번 재서 비교할 때 쓰는 curl -s -o /dev/null -w 는 골라 놓은 한 칸 말고 전부 버린다. 그래서 03 단계의 확인이 첫 칸에서 -I 로 시작해 넷째 칸에서 http_code 한 칸으로 줄어든다. 순서가 반대면 첫 칸에서 무엇이 잘못됐는지 알려 줄 헤더를 스스로 버리게 된다.
### 4. 층을 좁힌 뒤에는 그 층이 내는 문구와 errno 와 종료 코드를 읽는다
같은 「안 된다」가 층마다 다른 낱말로 나온다. nginx upstream 의 connect() failed (113: No route to host) 는 네트워크 쪽이고, (111: Connection refused) 는 프로세스 쪽이며, no live upstreams 는 둘 다 죽었다는 판단이다. 노드를 잃었을 때 이 세 줄이 1분 안에 순서대로 나왔다. k3s agent 노드의 dial tcp [::1]:8080: connect: connection refused 는 네트워크 문제가 아니라 kubeconfig 을 하나도 못 찾아 하드코딩된 기본값으로 넘어간 것이다. 파드의 Exit Code 도 그것만으로 말이 된다 — 137 은 OOM 이나 강제 종료, 1 은 애플리케이션이 스스로 끝낸 것, 127 은 명령을 못 찾은 것이다.
### 5. 그 층 안에 물어볼 도구가 없으면 밖에서 묻는다
Keycloak 컨테이너에는 curl 이 없다. 공식 이미지가 최소 구성이라 wget 도 nc 도 없고, 그때 나오는 것이 curl: command not found 와 command terminated with exit code 127 이다. 127 을 규칙 4 대로 읽으면 서버가 내려간 것이 아니라 명령이 없는 것이므로, 안에서 묻기를 그만두고 Prometheus 로 묻거나 curlimages/curl 임시 파드를 띄워 밖에서 묻는다.
## 적용 조건
- 프록시나 컨트롤러가 겹쳐 있어 밖에서 한 번 쳐서는 어디서 끊겼는지 알 수 없는 스택. 이 실험대의 요청 경로는 여섯 층이다
- 층마다 성공 신호를 미리 적을 수 있을 때. 모르면 그것부터 한 층씩 재서 적는다
- 각 칸을 어느 기계에서 치는지가 이미 정해져 있을 때
- 밖에서 200 이 나오면 전부 이어졌다고 말할 수 있는 스택. 05 단계가 그렇다
## 예외
치는 위치가 틀리면 층 판정이 통째로 무의미해진다. 04 단계의 확인을 엣지 게스트 안에서 치면 connect to 100.83.212.4 port 443 failed: Connection refused 가 돌아오는데, 이것은 어느 층의 답도 아니다. 엣지에서 나간 패킷은 호스트의 virbr0 으로 들어가고 DNAT 규칙은 tailscale0 으로 들어온 것만 매칭하므로 규칙에 안 걸리고, 호스트 443 에 리스너가 없어 거절된다. 그래서 이 절차를 쓰기 전에 각 칸을 어느 기계에서 치는지가 정해져 있어야 한다. 문서가 그것을 매번 적게 만드는 방법은 「단계별 구축 문서는 작성 시점이 아니라 실행 순서로 검증한다」에 있다.
네 칸이 다 통과하고도 나중에 터지는 것이 있다. DNAT 규칙의 443 을 433 으로 친 오타가 이 실험대에서 실제로 나왔는데, 433 도 유효한 포트라 nft 가 군말 없이 받고 80 은 멀쩡히 넘어가므로 03 단계의 확인은 다 통과하고 04 단계에서 443 쪽만 안 되는 형태로 뒤늦게 터진다.
이 절차는 어디서 끊겼는지를 좁힐 뿐 왜 끊겼는지를 말하지 않는다. 첫 칸에서 404 가 나와도 그 뒤의 값이 틀렸을 수 있고, 02 단계의 INTERNAL-IP 가 그렇다 — 두 노드가 Ready 인데 보고된 IP 가 우리가 준 값과 다르면 지금은 아무 증상이 없다가 03 단계의 upstream 에서 어긋난다. 로그를 읽어 좁히려다 잘린 문구를 붙들 수도 있다. nginx 에러 로그는 2048바이트에서 잘린다. 이 실험대에서도 502 원인이 error 로그에서는 잘린 채로 있었고, access 로그에는 3492자로 온전히 남아 있었다. 그쪽은 「도구가 낸 출력은 대상의 상태가 아니다」가 받는다.
제3부의 「packet 이 어디서 끊겼는지는 계층마다 capture 해서 가른다」와는 재는 것이 다르다. 그쪽은 네 지점에서 capture 를 떠 패킷이 사라진 구간을 좁히고, 이쪽은 층을 건너뛴 요청의 응답 코드로 좁힌다. 응답이 아예 안 돌아오고 패킷도 안 보이는 상태에서는 이 절차가 답을 못 내므로 그때 그쪽으로 넘어간다.
## 예시
- 첫 칸 : nginx 를 건너뛰고 curl -I http://192.168.122.11 을 쳐서 404. 이것이 성공 신호다
- 둘째 칸 : DNAT 을 건너뛰고 http://192.168.122.10 을 쳐서 301
- 셋째 칸 : 밖에서 http://auth.hyeonworks.com 을 쳐서 301 https://auth.hyeonworks.com/
- 넷째 칸 : TLS 이후 https://auth.hyeonworks.com/realms/master 를 쳐서 200
- 둘째 칸은 되는데 셋째 칸이 안 된다 : 막힌 곳이 DNAT 이다
- 둘째 칸에서 막힌다 : 막힌 곳이 엣지 안이다
- 첫 칸이 502 : Traefik 은 떴고 뒤에 백엔드가 없다
- 첫 칸이 연결 거부나 타임아웃 : 02 단계의 노드 상태로 돌아간다
- 노드를 잃었을 때 1분 안에 순서대로 나온 세 줄 : 113 은 네트워크, 111 은 프로세스, no live upstreams 는 둘 다 죽었다는 판단
- Keycloak 컨테이너 안에서 curl 이 exit code 127 : 서버가 아니라 명령이 없다. 밖에서 묻는다
- 04 단계 확인을 엣지 안에서 쳤을 때의 connection refused : 층의 답이 아니라 친 위치의 답이다
@@ -0,0 +1,115 @@
---
kind: REFERENCE
slug: tool-output-is-not-the-subject-state
title: 도구가 낸 출력은 대상의 상태가 아니다 — 그 명령이 무엇을 세는지부터 가른다
topic: build-completion-judgment
topicName: 끝났다는 판정
project: virtualization
status: 게시 전
sourceRevision: 9465582b5d1630eb4ae7c4e078021486919bf6b6
source:
- final/document.md#191-단계-05-keycloak-2노드와-postgresql
- final/document.md#192-단계-06-prometheus-와-grafana
- final/document.md#186-단계-00-lab-host-가상화-준비
---
# 도구가 낸 출력은 대상의 상태가 아니다 — 그 명령이 무엇을 세는지부터 가른다
상태를 묻는 명령의 출력을 대상의 상태로 바로 읽지 않고, 그 명령이 무엇을 세고 무엇을 안 세는지 먼저 적는다. 7800 포트를 끊었을 때 외부 응답은 전부 200 이었고, 503 이 나는 동안에도 up 은 1 이었다.
## 관계
- **빈 토큰이 조용히 흘러갔다 — 설치 출력은 성공이었고 agent 만 5초마다 다시 죽었다**
설치 명령의 출력과 설치된 상태가 어긋났다. 출력은 끝까지 성공이었고 실패는 journalctl 안에만 있었다.
- **cloud-init 이 안 도는 원인은 넷인데 증상은 「SSH 가 안 붙는다」 하나였다**
검사가 통과해도 안 도는 쪽과 검사에 걸려도 도는 쪽이 한 사건 안에 같이 있다. 검사 결과를 상태로 읽으면 어느 방향이든 틀린다는 것이 그 기록의 결론이다.
- **갱신은 매번 SUCCESS 였고 옛 인증서가 계속 나갔다 — 2305초와 1~2초**
로그 문구가 SUCCESS 인데 서빙되는 인증서는 옛것이었다. 판정을 문구가 아니라 워커 PID 로 옮긴 기록이다.
- **가까운 층부터 한 층씩 건너뛰며 확인한다 — 층마다 성공 신호가 다르다**
어디서 끊겼는지를 그 기준이 좁히고, 좁힌 층의 출력을 어떻게 읽는지를 이쪽이 받는다.
- **가이드대로 다시 쳐서 이 실험대가 같은 상태로 서는가**
단계마다의 통과 조건 일곱이 전부 이런 출력이라, 다시 세운 실험대가 같은 상태인지를 무엇으로 판정할지가 그 물음에 걸려 있다.
## 목적
판정을 밖에서만 하면 놓친다. §192 는 그래서 클러스터 안을 보는 관측대(Prometheus 와 Grafana)를 따로 세웠다. 7800 포트를 끊었을 때 외부 응답이 전부 200 이었고, 분단된 노드가 스스로 로드밸런서에서 빠져 밖에서는 아무 일도 없어 보였다.
안쪽에 세운 관측대도 지표 하나로는 같은 실패를 되풀이한다. 503 이 나는 동안에도 up 은 1 이었다. 프로세스가 살아 있고 metrics 경로가 응답하기만 하면 1 이 되므로, 살아 있지만 쓸모없는 상태를 up 은 보지 못한다. 경보를 up 이 0 인지 하나로 걸면 그 상태를 통째로 놓친다.
같은 일이 구축 7단계 전체에서 되풀이된다. 목록이 비어 있어서 없다고 읽은 것, 검사기가 통과해서 동작한다고 읽은 것, 아무것도 안 찍혀서 멈췄다고 읽은 것이 전부 같은 오독이다. 이 기준은 그 셋을 하나로 묶고, 판정하기 전에 그 명령이 무엇을 세는지 적게 한다.
## 규칙
### 1. 그 명령이 무엇을 세는지 먼저 적는다
어느 연결에 붙어 있나. virsh 는 기본으로 qemu:///session 에 붙는데 VM 은 qemu:///system 에 만들므로, 어긋나면 VM 은 만들어졌는데 virsh list 에 안 나온다. 빈 목록이 VM 의 부재가 아니라 다른 연결을 보고 있다는 뜻이다.
꺼진 것도 세나. net-list 는 --all 을 빼면 inactive 인 네트워크가 아예 안 나와 「없음」과 「꺼짐」이 구분되지 않는다. 이 실험대에서 default 네트워크의 autostart 가 no 면 지금은 되고 호스트를 재부팅한 다음 01 단계의 SSH 가 전부 실패하는데, 그때는 원인을 게스트에서 찾게 된다.
이름대로 다 내놓나. kubectl get all 은 이름과 달리 Secret 과 ConfigMap 과 PVC 와 Ingress 를 내놓지 않으므로, 그 넷이 빠진 줄 모르고 다 만들어졌다고 판정하게 된다. -l app=postgres 에 Deployment 줄이 없는 것도 라벨을 파드 템플릿에만 달았기 때문이지 Deployment 가 없는 것이 아니다.
어디까지 남기나. nginx 에러 로그는 2048바이트에서 잘리고 쿠버네티스 이벤트는 기본 한 시간만 남는다. 이 실험대에서 502 원인이 잘린 채로 error 로그에 있었고 access 로그에는 3492자로 온전히 남아 있었다.
### 2. 빈 출력을 낼 때 「없다」와 「못 봤다」를 갈라 적는다
Prometheus 질의가 빈 배열을 내면 그 값이 0 이라는 뜻이 아니라 그런 지표가 없다는 뜻이다. 스크레이프 대상 목록에서는 거기 없는 이름이 답을 준다 — 이 실험대는 Redis 와 BFF 와 PostgreSQL 을 긁지 않으므로 그 지표가 안 나오는 것이 측정 실패가 아니라 측정된 공백이다. §192 는 그것을 스크린샷 누락이 아니라 측정된 공백으로 적었다. grep 이 아무것도 안 내놓을 때도 같다. ip-dhcp-host 로 grep 하면 DHCP 예약이 멀쩡히 들어가 있어도 아무것도 안 나오는데, 그것은 net-update 의 섹션 이름이라 XML 안에 그 문자열이 없기 때문이다. 이벤트가 하나도 없는 것도 무사하다는 뜻이 아니라 한 시간이 지났다는 뜻일 수 있다.
### 3. 한 근거로 판정하지 않고 시제나 층이 다른 것을 함께 본다
§191 이 클러스터가 섰는지를 근거 셋으로 보고, 그 셋이 서로 다른 것을 본다고 적었다. 로그 ISPN000094 는 「그때 그렇게 보였다」이고, 테이블 jgroups_ping 은 「지금 등록되어 있다」이며, 지표 vendor_cluster_size 는 「지금 그 노드가 그렇게 안다」다. 테이블에는 둘 다 있는데 로그가 (1) 이면 서로를 찾기는 했는데 7800 포트로 메시지가 안 가는 것이고, 이 실험대에서 실제로 그 일이 벌어졌다. 각 노드가 자기가 아는 멤버 수를 보고하므로 한 노드만 보면 분단을 놓친다.
저장과 주입도 다른 층이다. describe 가 보여 주는 19 bytes 와 22 bytes 는 Secret 에 저장된 값이고, 파드 안에서 잰 길이 19 는 그 파드가 받은 값이다. 두 수가 같아야 Secret 에서 파드 환경변수까지 이어진 것이고, 길이가 0 이면 Secret 에는 있는데 이 파드가 그것을 안 받았다.
보고된 값과 준 값도 다르다. kubectl get nodes -o wide 의 INTERNAL-IP 는 k3s 가 보고한 값이고 systemctl cat 의 ExecStart 줄은 우리가 준 값이라 둘을 견준다. 값을 안 찍고 길이만으로 확인하는 §185 의 ② 도 같은 갈래다. 비밀은 값을 보지 않고 0 이 아니라는 것만 확인한다.
### 4. 검사기가 통과한 것을 동작하는 상태로 읽지 않는다
sites-available 을 site-available 로 잘못 치면 빈 새 파일이 열리고, 저장해도 nginx 는 그 파일을 읽지 않는데 nginx -t 는 멀쩡히 통과한다. 아무 에러 없이 아무 일도 안 일어나므로, 설정을 썼는데 변화가 없으면 경로 오타부터 의심한다. nft 도 같다. .nft 의 포트를 433 으로 쳐도 433 이 유효한 포트라 군말 없이 받고 80 은 멀쩡히 넘어가므로, 03 단계는 다 통과한 뒤 04 단계에서 HTTPS 만 안 되는 형태로 드러난다. 이 실험대에서 실제로 나왔던 오타다.
노드도 그렇다. kubectl get nodes 두 줄이 Ready 여도 -o wide 의 INTERNAL-IP 는 --node-ip 로 준 값과 다를 수 있다. 그때는 지금 아무 증상이 없다가 03 단계의 upstream 과 노드 상실 실험에서 어긋난다. 유닛 이름이 노드마다 달라 agent 노드에서 systemctl stop k3s 를 치면 아무 일도 일어나지 않고, 그것이 「주입했는데 증상이 없다」로 읽힌다.
파드 둘이 Running 이어도 Endpoints 가 하나면 트래픽은 이미 한쪽으로만 가고 있고, 그 상태에서 이중화 실험을 하면 그것을 이중화 실패로 오독하게 된다. node-exporter 로 시작하는 줄이 하나뿐일 때도 같다. 그 노드의 CPU 와 메모리와 디스크 지표가 통째로 없는 채로 실험을 하게 된다.
인증서도 같다. cert.pem 을 쓰면 중간 인증서가 빠져 체인이 끊긴다. 그런데 브라우저는 대개 캐시나 AIA(Authority Information Access) 로 보완해서 정상으로 보이고, 캐시가 없는 클라이언트에서만 깨진다. 그래서 체인이 이어졌는지는 openssl s_client 의 단계 수로 판정한다. 이 실험대의 실측은 0 부터 3 까지 네 단계와 Verify return code: 0 (ok) 였고, 단계가 1개면 cert.pem 을 쓴 것이다.
### 5. 침묵과 경고도 상태가 아니다
kubectl rollout status 는 끝날 때까지 아무것도 안 찍고 그 침묵이 정상이다. StatefulSet 은 파드를 하나씩 순서대로 띄우므로 중간에 0/2 로 한참 멈춰 있는 것도 정상이고, 300초를 다 쓰고 타임아웃으로 끝나면 그것도 답이다 — 안 떴다가 확정된다. 반대쪽에서는 경고가 실패로 읽힌다. nginx -t 의 [warn] could not build optimal types_hash 줄은 통과를 막지 않고, 실패는 [emerg] 줄에 파일과 줄 번호로 나온다. 04 단계에서 이 경고를 실패로 오독하는 일이 실제로 벌어졌다.
## 적용 조건
- 상태를 묻는 명령의 출력으로 구축 단계의 통과를 판정할 때
- 같은 대상을 보는 명령이 여럿이고 서로 다른 시제나 층을 볼 때. 로그와 테이블과 지표가 그런 셋이다
- 검사기나 문법 검사가 앞에 있는 단계. nginx -t 와 nft 와 cloud-init 스키마 검사기가 그렇다
- 목록이나 질의 결과가 비어 있을 때. 판정하기 전에 그 명령이 무엇을 세는지 먼저 적는다
## 예외
이 기준은 출력을 상태로 읽는 오독을 잡고, 출력 자체가 정확한지는 보지 않는다. 세 근거가 다 통과로 나와도 그 셋이 다 같은 층에서 나왔으면 여전히 한 근거다. 밖에서 친 200 이 분단을 가린 것이 그런 경우이고, 그래서 관측대를 클러스터 안쪽에 따로 세웠다. 그 관측대도 up 하나로는 같은 실패를 되풀이하므로 기능 지표를 함께 본다.
근거를 늘리는 데는 비용이 든다. 명령이 늘고 손으로 치는 선을 넘으면 파서를 짜게 되는데, §192 는 그 선을 grep -o 와 tr 로 쉼표마다 줄을 나누는 데까지로 그었다. 그 이상 가공해야 하면 파서를 짜지 않고 화면에 나온 JSON 을 그대로 읽는다.
「없다」와 「못 봤다」를 가르는 일도 도구가 대신해 주지 않는다. 스크레이프 대상 목록에서 없는 이름을 알아보려면 사람이 그 이름을 미리 알고 있어야 한다. Redis 와 BFF 와 PostgreSQL 이 빠진 것을 그 목록만 보고 알아낼 방법은 없다.
이 기준의 근거는 대부분 이 실험대 한 대에서 한 번씩 본 것이다. 다른 판 번호나 다른 배포판에서 같은 명령이 같은 것을 세는지는 재지 않았다.
가이드에 실린 출력이 이 호스트의 것인지도 한 군데에서 어긋난다. §186 의 실측 줄은 이 호스트가 16 코어 전부에서 지원한다고 적었는데 §178 의 대상 환경은 논리 코어 8(i5-1135G7)이다. 어느 쪽이 이 호스트의 값인지는 재지 않았다.
## 예시
- virsh list 가 비었다 : VM 이 없는 것인가 qemu:///session 에 붙은 것인가. virsh uri 로 가른다
- net-list 에 그 네트워크가 없다 : --all 을 줬는가. 없음과 꺼짐이 구분되나
- kubectl get all 이 다 나왔다 : Secret · ConfigMap · PVC · Ingress 는 거기 없다. 따로 한 번 더 친다
- Prometheus 질의가 빈 배열 : 0 이 아니라 그런 지표가 없다
- 스크레이프 대상에 Redis · BFF · PostgreSQL 이 없다 : 측정 실패가 아니라 측정된 공백이다
- ip-dhcp-host 로 grep 해서 아무것도 안 나온다 : 섹션 이름이라 XML 에 그 문자열이 없다. host mac 으로 찾는다
- 이벤트가 하나도 없다 : 기본 한 시간만 남는다. 무사하다는 뜻이 아니다
- 로그는 (1) 인데 jgroups_ping 에는 둘 다 있다 : 서로를 찾았고 7800 으로 메시지가 안 간다
- describe 가 19 bytes 인데 파드 안 길이가 0 : 저장은 됐고 주입이 안 됐다
- 두 노드가 Ready 인데 INTERNAL-IP 가 --node-ip 와 다르다 : 지금 증상 없음. 03 과 노드 상실 실험에서 터진다
- nginx -t 통과 : site-available 로 잘못 쳐도 통과한다. 설정을 썼는데 변화가 없으면 경로 오타다
- nft 가 433 을 받았다 : 80 은 되고 04 에서 HTTPS 만 안 된다
- 파드 둘이 Running 인데 Endpoints 가 하나 : 이미 한쪽으로만 가고 있다
- node-exporter 줄이 하나 : 그 노드의 지표가 통째로 없다
- up 이 1 : 503 중에도 1 이었다. 기능 지표를 함께 본다
- rollout status 가 아무것도 안 찍는다 : 정상이다. 타임아웃으로 끝나는 것도 답이다
@@ -18,7 +18,7 @@ source:
# Keycloak 작업의 VM Exit 분포는 idle · CPU-bound · I/O-bound 와 어떻게 다른가
VM Exit 은 게스트를 실행하던 CPU 가 하이퍼바이저가 개입해야 하는 조건을 만나 제어권을 KVM 쪽으로 넘기는 전환이다. 정상적인 가상화 동작이라 Exit 이 있다는 것만으로 문제가 되지 않는다. 다만 하이퍼바이저가 개입해야 하는 Exit 이 특정 작업에서 지나치게 빈번하고 처리 비용이 커지면 성능에 영향을 줄 수 있다. Keycloak 을 돌리는 구간이 그런 작업인지 보려면 다른 구간의 Exit 분포와 견줘야 하는데, 이 호스트에서 Exit 을 관측하는 도구가 열리는지부터 확인하지 않았다.
이 호스트에서 Exit 을 받아 본 기록이 없고 관측 도구가 열리는지부터 확인하지 않았다. VM Exit 은 게스트를 실행하던 CPU 가 제어권을 KVM 쪽으로 넘기는 정상 동작이라, 있다는 것만으로 문제가 되지 않는다. Keycloak 구간에서 Exit 이 얼마나 잦은지는 나머지 네 구간의 분포와 견줘야 갈린다.
## 관계
@@ -40,6 +40,7 @@ VM Exit 은 게스트를 실행하던 CPU 가 하이퍼바이저가 개입해야
- Exit 이 많다는 관측 하나로 장애라고 판단하지 않는다.
- 환경이 지원하면 perf kvm 으로 KVM 관련 실행 통계를 확인할 수 있고, 예로 든 명령은 sudo perf kvm stat live 다.
- 지원되는 명령과 표시되는 Exit 이유는 커널과 perf 버전, CPU 아키텍처와 설정에 따라 다를 수 있어 perf kvm --help 를 함께 확인한다. 필요하면 KVM tracepoint 를 이용한 별도 추적도 검토한다.
- §197 은 2026-09-10 에 이 호스트에서 커널 7.2.2-arch1-1 과 QEMU emulator version 11.1.1 을 받아 적었다. CPU 는 11th Gen Intel(R) Core(TM) i5-1135G7 @ 2.40GHz 이고 lscpu 의 Virtualization 은 VT-x 다. perf 버전은 적혀 있지 않다.
- 비교 후보로 적어 둔 구간은 idle, CPU-bound 작업, I/O 가 많은 작업, Keycloak 정상 요청, Keycloak 부하 테스트 다섯이다.
이 목록은 개념 문서가 앞선 물음 자리에 적어 둔 것이고, 이 질문의 제목이 든 것은 그 가운데 앞쪽 셋이다. 받아야 하는 구간은 제목이 든 것보다 둘 많다.
@@ -64,7 +65,7 @@ VM Exit 은 게스트를 실행하던 CPU 가 하이퍼바이저가 개입해야
- 표시되는 Exit 이유는 커널과 perf 버전, CPU 아키텍처와 설정에 따라 달라지므로 다른 환경에서 나온 분포와 이 호스트의 분포를 바로 견주지 않는다.
- Exit 횟수만 세지 않는다. Exit 이유와 작업 종류, 처리 위치, 같은 구간의 지연을 함께 받아 적는다.
- 도구가 열려야 측정이 시작된다. 열리지 않으면 이 질문은 값 없이 닫힌다.
- sudo 가 필요한 명령이라 이 호스트에서 그 권한으로 실행할 수 있어야 한다.
- sudo 가 필요한 명령인데, §204 는 이 호스트의 sudo 가 비밀번호를 요구해 비대화식으로는 읽지 못했다고 적었다. 그래서 콘솔에 붙어 직접 치는 실행으로 잡는다.
- 이 호스트에서 Exit 을 실제로 받아 본 기록이 없다.
## 선택지
@@ -17,7 +17,7 @@ source:
# 이 호스트의 NUMA 토폴로지는 가상 머신 성능을 고려해야 할 구조인가
멀티소켓이나 NUMA(Non-Uniform Memory Access) 구조의 호스트에서는 vCPU 가 어느 노드의 CPU 에서 실행되고 그 가상 머신의 메모리가 어느 노드에 놓였는지가 성능에 영향을 줄 수 있다. 이 물음은 그 영향을 재는 것이 아니라, 이 호스트가 애초에 그 조건에 들어가는지를 먼저 가른다. 단일 노드로 나오면 지금 실험에서 우선순위를 낮추고, 다중 노드로 나오면 vCPU 와 메모리 배치를 따로 다룬다.
이 호스트의 NUMA 노드 수를 확인한 기록이 없다. §197 이 받은 `lscpu` 출력에 NUMA 줄이 없어서다. NUMA(Non-Uniform Memory Access)는 어느 CPU 가 어느 메모리에 접근하느냐에 따라 접근 비용이 달라지는 구조다. 단일 노드면 지금 실험에서 우선순위를 낮추고, 다중 노드면 vCPU 와 메모리 배치를 따로 다룬다.
## 관계
@@ -33,7 +33,10 @@ source:
단일 NUMA 노드 : 지금 실험에서 우선순위를 낮춘다
다중 NUMA 노드 : vCPU 와 메모리 배치를 별도 Case 후보로 올린다
개념 문서가 남긴 열린 물음 열둘 가운데 닫는 갈래를 스스로 적어 둔 것은 이 하나다.
- 개념 문서는 이 확인에 쓸 명령을 적지 않았다. 다른 확인 항목과 달리 NUMA 쪽에는 예로 든 명령이 없다.
- §24.11 은 이 확인에 쓸 명령을 적지 않았다. 다른 확인 항목과 달리 NUMA 쪽에는 예로 든 명령이 없다.
- 메모리 가상화 쪽 §78 이 그 명령을 든다. 노드 수는 `lscpu` 의 NUMA 줄로 보라고 적고 `numactl --hardware` 를 추가로 든다. QEMU 프로세스별 메모리 분포는 `numastat -p <QEMU_PID>`, vCPU 배치는 `virsh vcpupin <VM_NAME>``virsh vcpuinfo <VM_NAME>` 이다.
- §197 이 2026-09-10 에 이 호스트에서 받은 `lscpu` 출력은 `Model name``11th Gen Intel(R) Core(TM) i5-1135G7 @ 2.40GHz`, `CPU(s)` 가 8, `Core(s) per socket` 이 4, `Thread(s) per core` 가 2 다. 네 줄만 `grep` 으로 걸러 받아서 NUMA 줄은 거기 없다.
- §24.11 이 문제를 두는 조건은 멀티소켓 또는 NUMA 구조인데, 그 네 줄에는 `Socket(s)` 도 NUMA 줄도 없다.
- 이 호스트의 NUMA 노드 수를 확인한 기록이 없다.
## 가정
@@ -42,19 +45,20 @@ source:
- 단일 노드로 나오면 vCPU 가 실행되는 노드와 메모리가 놓인 노드가 갈리지 않으므로 다른 노드의 메모리를 읽을 일이 지금 실험에서 성능 차이를 만들지 않는다고 본다.
그 추론을 이 호스트에서 확인하지는 않았다.
- 두 가상 머신의 메모리 배치가 실행 중에 바뀌지 않는다고 전제한다.
§198 은 virtio-balloon 이 안 쓰는 만큼 호스트에 돌려준다고 적었으므로 할당된 양은 실행 중에 바뀐다. 노드 배치까지 따라 바뀌는지는 적혀 있지 않다.
## 미지수
- 이 호스트가 단일 NUMA 노드인지 다중 NUMA 노드인지.
- 다중이라면 두 가상 머신의 vCPU 와 메모리가 각각 어느 노드에 배치되어 있는지.
- 노드 수와 배치를 이 환경에서 어떤 명령으로 읽는지. 개념 문서가 그 명령을 적지 않아 실행하는 쪽이 정한다.
- §78 이 든 명령 가운데 `numactl``numastat` 이 이 호스트에 깔려 있는지.
- 다중으로 나왔을 때 배치를 바꾸는 일이 지금 가상 머신 구성에서 가능한지.
## 제약
- 이 질문이 다중 노드로 닫혀도 여기서 NUMA 튜닝을 정하지 않는다. 상세한 메모리 배치는 메모리 가상화를 다루는 개념 문서가 받는다.
- 쓸 명령을 개념 문서가 정해 주지 않으므로, 실행한 명령과 그 출력을 함께 증거로 남겨야 다음 사람이 같은 값을 다시 읽을 수 있다.
- 이 호스트에서 잰 값이 없어 다른 장비의 노드 구성을 근거로 삼지 않는다.
- 이 질문이 다중 노드로 닫혀도 여기서 NUMA 튜닝을 정하지 않는다. 상세한 메모리 배치는 §73~§78 과 그것을 받을 메모리 가상화 개념 기록이 다룬다.
- §24.11 쪽에는 예로 든 명령이 없고 §78 쪽 명령은 메모리 가상화 절에 있다. 그래서 실행한 명령과 그 출력을 함께 증거로 남긴다.
- 이 호스트의 노드 수를 잰 값이 없어 다른 장비의 노드 구성을 근거로 삼지 않는다.
## 선택지
@@ -70,16 +74,16 @@ source:
단일로 나오면 함께 받은 배치는 쓰지 않게 되는데, 대신 실행이 한 번으로 끝난다.
### 3. 메모리 가상화 개념 문서를 쓸 때 함께 본다 — 제외
### 3. 메모리 가상화 쪽에서 함께 본다 — 제외
상세한 메모리 배치를 그쪽에서 다루 했으니 확인도 그 하자는 방법이다.
지금 필요한 판단은 이 항목을 실험 목록의 어디에 둘지 하나이고, 그것은 노드 수만으로 갈린다. 메모리 가상화 쪽을 기다리면 그동안 우선순위를 정하지 못한 채로 둔다.
개념 문서가 적은 다음 기반 영역은 메모리 가상화가 아니라 네트워크 가상화이고, 메모리 쪽을 언제 정리하는지는 적혀 있지 않다.
상세한 메모리 배치를 §73~§78 이 다루로 확인도 그쪽에서 하자는 방법이다.
지금 필요한 판단은 이 항목을 실험 목록의 어디에 둘지 하나이고, 그것은 노드 수만으로 갈린다.
§78 은 먼저 topology 를 측정하고 NUMA 최적화가 필요한지 판단한다고 적었다. 그 순서대로면 노드 수는 여기서 재고 배치 조정을 그쪽이 받는다.
## 다음 검증
1. 호스트의 NUMA 노드 구성을 확인해 노드 수를 적는다.
2. 이 확인에 쓴 명령과 그 출력을 함께 증거로 남긴다. 개념 문서가 명령을 적어 두지 않아 실행한 쪽이 무엇을 썼는지 기록해야 한다.
3. 다중 노드로 나오면 vCPU 가 실행되는 노드와 가상 머신 메모리가 배치된 노드까지 이어서 적는다.
1. `lscpu` 를 NUMA 줄까지 받아 노드 수를 적고 `numactl --hardware` 로 한 번 더 본다 (§78).
2. 실행한 명령과 그 출력을 함께 증거로 남긴다. §24.11 쪽에 예로 든 명령이 없어서 무엇을 썼는지 적어 두지 않으면 다음 사람이 같은 값을 다시 읽지 못한다.
3. 다중 노드로 나오면 `virsh vcpupin <VM_NAME>` 으로 vCPU 배치를, `numastat -p <QEMU_PID>` 로 그 QEMU 프로세스의 메모리 분포를 이어서 적는다 (§78).
닫는 조건 : 단일 NUMA 노드로 나오면 지금 실험에서 우선순위를 낮추고 닫는다. 다중 NUMA 노드로 나오면 vCPU 와 메모리 배치를 별도 Case 후보로 올리고, 이 프로젝트가 메모리 가상화 쪽 정리를 가질 때 그리로 넘긴다.
닫는 조건 : 단일 NUMA 노드로 나오면 §27 이 적은 대로 지금 실험에서 우선순위를 낮추고 닫는다. 다중 NUMA 노드로 나오면 vCPU 와 메모리 배치를 별도 Case 후보로 올리고 §24.11 과 함께 §73~§78 쪽으로 넘긴다.
@@ -18,7 +18,7 @@ source:
# 게스트가 유휴 상태일 때 vCPU 스레드는 이 테스트 환경에서 어떻게 보이는가
게스트에 할 일이 없으면 vCPU 스레드가 잠들거나 대기 상태로 들어가고, 그동안 호스트 그 물리 CPU 를 다른 작업에 쓸 수 있다. 서술이 이 환경에서도 그대로 보이는지는 QEMU 프로세스의 스레드 목록을 유휴와 부하 두 상태에서 찍어 봐야 아는데, 아직 어느 쪽도 찍지 않았다. 이 QEMU 버전에서 vCPU 스레드가 어떤 이름으로 나오는지도 모른다.
유휴와 부하 두 상태에서 QEMU 프로세스의 스레드 목록을 아직 어느 쪽도 찍지 않았다. §10 은 게스트에 할 일이 없으면 vCPU 스레드가 잠들거나 대기 상태로 들어가고, 그동안 호스트 그 물리 CPU 를 다른 작업에 쓸 수 있다고 적었다. 서술이 이 환경에서도 보이는지, 이 QEMU 에서 스레드가 어떤 이름으로 나오는지가 남았다.
## 관계
@@ -53,7 +53,11 @@ Guest 실행 재개
§10 은 깨우는 이유로 타이머, 인터럽트, I/O 완료를 든다.
§14.6 은 ps -T -p <QEMU_PID> 또는 top -H -p <QEMU_PID> 로 vCPU 관련 스레드를 호스트에서 관찰할 수 있다고 적으면서, 환경과 QEMU 버전에 따라 이름은 다를 수 있다는 단서를 함께 단다.
§14.6 은 ps -T -p <QEMU_PID> 또는 top -H -p <QEMU_PID> 로 vCPU 관련 스레드를 호스트에서 관찰할 수 있다고 적는다. 환경과 QEMU 버전에 따라 이름은 다를 수 있다는 단서를 함께 단다.
§197 은 2026-09-10 에 이 호스트에서 qemu-system-x86_64 --version 을 받아 QEMU emulator version 11.1.1 을 적었다. §14.6 이 이름이 다를 수 있다고 든 조건 가운데 QEMU 버전이 여기서 정해진다.
§222 는 가상 머신 하나가 호스트에서 QEMU 프로세스 하나로 돈다고 적었고, §218 은 kc-lab-1 과 kc-lab-2 에 각각 vCPU 2 를 적었다.
§20 은 게스트의 유휴 상태와 CPU 부하 상태를 비교하라고 적고 확인 명령으로 둘을 든다.
@@ -62,9 +66,9 @@ ps -eLo pid,tid,psr,pcpu,stat,comm
## 가정
QEMU 프로세스의 PID 를 먼저 찾아야 한다. 가상 머신 두 대가 도는 호스트에서 어느 PID 가 어느 가상 머신인지 가리는 방법 아직 정하지 않았다.
QEMU 프로세스의 PID 를 먼저 찾아야 한다. §197 이 잰 시점의 이 실험대는 게스트가 세 대라 §14.5 의 ps -ef | grep '[q]emu' 는 프로세스 셋을 준다. 셋 가운데 하나는 엣지 게스트이고 vCPU 1 이다(§194). 어느 PID 가 어느 가상 머신인지 가리는 방법 아직 정하지 않았다.
vCPU 스레드를 이름으로 못 가리면 스레드 수를 그 가상 머신의 vCPU 수와 맞춰 가린다고 전제한다.
vCPU 스레드를 이름으로 못 가리면 스레드 수를 그 가상 머신의 vCPU 수와 맞춰 가린다고 전제한다. kc-lab-1 과 kc-lab-2 라면 그 수가 2 다.
유휴라고 부를 상태를 이 가상 머신에서 만들 수 있다. 가상 머신 안에서 도는 것이 있으면 완전한 유휴가 아니다.
@@ -84,7 +88,7 @@ vCPU 스레드 말고 어떤 스레드가 같은 QEMU 프로세스 아래에 함
## 제약
이 호스트에서 찍은 출력이 없다. §10 은 개념 흐름을 그린 것이고 이 환경의 실제 출력이 아니다. 결과가 §10 과 같으면 개념에 흡수되고 다르면 Case 가 된다는 갈림을 처음에는 이 물음을 따로 세우지 않을 이유로 봤다. 지금은 그것을 그대로 닫는 조건으로 쓴다 — 어느 쪽으로 갈리든 물음은 닫힌다.
이 호스트에서 그 스레드 목록을 찍은 출력이 없다. §10 은 개념 흐름을 그린 것이고 이 환경의 실제 출력이 아니다. 결과가 §10 과 같으면 개념에 흡수되고 다르면 Case 가 되므로, 어느 쪽으로 갈리든 물음은 닫힌다.
§14.6 이 스레드 이름은 환경과 QEMU 버전에 따라 다를 수 있다고 적기 때문에, 이름으로 거르는 절차를 미리 굳혀 둘 수 없다.
@@ -18,9 +18,8 @@ source:
# 운영 서버는 CPU 가상화 계층의 영향을 받는 구조인가
§18 과 §20 은 K3s 가 호스트 스케줄러로 바로 가는 경로와 게스트 · vCPU · 하이퍼바이저를 더 지나는 경로를 나란히 적었다.
§25 의 진단표는 문제마다 주된 계층을 갈라 놓아서, Virtualization 과 Host 계층에만 걸리는 행이 따로 있다.
운영 서버가 두 경로 중 어느 쪽인지는 SSOT 에 적혀 있지 않은데, 그 구조가 정해져야 진단표의 어느 행을 운영 진단에 쓸지 고를 수 있다.
운영 서버가 bare-metal 인지 상위 하이퍼바이저나 클라우드 가상 머신 위인지는 SSOT 에 적혀 있지 않다.
§25 의 진단표는 Virtualization 과 Host 계층에만 걸리는 행이 따로 있어서, 그 구조가 정해져야 어느 행을 운영 진단에 쓸지 고를 수 있다.
## 관계
@@ -51,6 +50,9 @@ CPU contention · Scheduling latency : Host Scheduler
Steal time 증가 : Guest 에서 관측
Host CPU saturation : Host
§218 은 운영을 `desktop` 이라 부르고 그 배치를 `nginx → 127.0.0.1:30080(NodePort) → Traefik` 으로 적었다. 이 실험대의 배치와 다른 것은 단일 노드냐 2노드냐 하나다.
§284 는 그 `desktop` 이 Ubuntu 라고 적는다. 같은 절이 이 실험대가 `sites-available` 방식을 쓰는 이유도 적었다. 「운영(`desktop`)이 Ubuntu라 그 구조를 쓰고 있으므로, 설정을 운영으로 옮길 때 경로가 그대로 맞는 편이 낫다는 판단이다.」 SSOT 가 운영과 같다고 적은 것은 그 2홉 경로와 이 설정 관례 둘이다.
운영 서버가 bare-metal 인지 상위 하이퍼바이저나 클라우드 가상 머신 위인지는 SSOT 에 적혀 있지 않다.
## 가정
@@ -65,7 +67,7 @@ Host CPU saturation : Host
운영 서버가 bare-metal 호스트에 직접 K3s 를 설치한 것인가, 상위 하이퍼바이저나 클라우드 가상 머신 위에 있는가.
하이퍼바이저 위라면 그 호스트의 CPU 지표를 우리가 볼 수 있는가.
하이퍼바이저 위라면 그 호스트의 CPU 지표를 볼 수 있는가.
클라우드 가상 머신이면 호스트 쪽 실행 대기열과 사용률을 읽을 수 없어 §25 의 Host 행을 그대로 쓰기 어려워진다.
운영 서버에 직접 붙어 명령을 돌릴 수 있는가.
@@ -74,8 +76,8 @@ Host CPU saturation : Host
이 물음은 부하를 걸어 재는 것이 아니라 구성을 확인해서 닫는다.
§14.1~§14.3 은 그 장비가 가상 머신을 직접 돌리는 호스트 인지를 보여 준다.
그 장비 자신이 어떤 하이퍼바이저의 게스트 인지는 이 세 명령이 말해 주지 않는다.
§14.1~§14.3 은 그 장비가 가상 머신을 직접 돌리는 호스트인지를 보여 준다.
그 장비 자신이 어떤 하이퍼바이저의 게스트인지는 이 세 명령이 말해 주지 않는다.
구조가 정해지기 전에는 §25 의 어느 행을 운영 진단에 넣을지 고를 수 없다.
@@ -18,7 +18,7 @@ source:
# CPU pinning 전후로 Keycloak 지연과 vCPU 스케줄링 변동이 달라지는가
vCPU 스레드가 실행될 호스트 논리 CPU 를 특정 집합으로 제한하는 설정을 CPU pinning 이라고 한다. 적절히 걸면 스케줄링 변동이 줄지만 잘못 걸면 특정 CPU 에만 작업이 몰린다. 이 호스트에서는 pinning 을 걸지 않은 상태의 Keycloak 지연도, 건 상태의 지연도 아직 재지 않았으므로 pinning 이 지금 작업에 이점을 주는지는 말할 수 없다.
이 호스트에서는 pinning 을 걸지 않은 상태의 Keycloak 지연도, 건 상태의 지연도 아직 재지 않았다. CPU pinning 은 vCPU 스레드가 실행될 호스트 논리 CPU 를 특정 집합으로 제한하는 설정이다. 논리 코어 8 과 게스트별 vCPU 2 는 §197 · §218 에 나왔지만, 묶은 뒤 지연과 CPU 별 사용률이 어떻게 달라지는지는 재 봐야 갈린다.
## 관계
@@ -36,6 +36,7 @@ vCPU 스레드가 실행될 호스트 논리 CPU 를 특정 집합으로 제한
치우침을 만드는 쪽에 호스트 프로세스가 함께 들어 있다.
- 스레드가 최근 실행된 논리 CPU 는 ps -eLo pid,tid,psr,pcpu,comm | grep qemu 의 PSR 로 볼 수 있다.
- PSR 값 하나가 vCPU 가 물리 CPU 에 영구 고정되어 있다는 뜻은 되지 않는다. pinning 을 걸지 않았다면 스케줄링에 따라 달라질 수 있다.
- 이 호스트의 논리 코어는 8 이고(§197), kc-lab-1 과 kc-lab-2 에 준 vCPU 는 각각 2 다(§218). 실험대가 잡은 vCPU 는 2 + 2 + 1 = 5 다(§197).
- pinning 이 지금 작업에서 실제 이점을 주는지는 실험으로 확인하기로 했고, 아직 그 실험을 돌리지 않았다.
## 가정
@@ -52,14 +53,14 @@ vCPU 스레드가 실행될 호스트 논리 CPU 를 특정 집합으로 제한
- pinning 뒤 Keycloak 지연이 달라지는지, 달라진다면 어느 방향인지.
- 같은 tid 의 PSR 변동이 실제로 줄어드는지.
- 변동이 줄어드는 대신 특정 논리 CPU 로 작업이 몰리는지.
- 어떤 논리 CPU 집합에 묶을지. 이 호스트의 논리 CPU 수와 각 가상 머신의 vCPU 수를 아직 적지 않았다.
- 호스트 쪽 Nginx 와 다른 호스트 프로세스가 그 집합을 함께 쓰는지.
- 어떤 논리 CPU 집합에 묶을지. 논리 코어 8 과 게스트별 vCPU 2 는 §197 · §218 에 나왔지만, 그 여덟 가운데 어느 코어에 묶을지는 CPU 별 사용률을 보고 정해야 한다.
- 호스트 쪽 Nginx 와 다른 호스트 프로세스가 그 집합을 함께 쓰는지. §179 는 그 Nginx 를 엣지 게스트로 옮겼다고 적었으므로, 묶을 집합을 고르기 전에 호스트에 무엇이 남아 있는지부터 이번 실행에서 적는다.
## 제약
- pinning 여부만으로 판정하지 않는다. 지연과 PSR 변동, CPU 별 사용률 셋을 같은 실행에서 받아 적는다.
- 「전」과 「후」 두 실행의 부하가 같아야 지연 차이를 pinning 쪽으로 읽을 수 있다.
- 이 호스트에서 잰 값이 하나도 없어 견줄 값을 이번 실행이 함께 만든다.
- 이 호스트에서 지연도 PSR 변동도 CPU 별 사용률도 잰 값이 없어 견줄 값을 이번 실행이 함께 만든다.
- 이 실행 전에 pinning 을 쓸지 말지를 먼저 정하지 않는다. 잰 값이 없는 상태로 정하면 그 선택이 감수하는 비용을 적을 수 없다.
- 묶는 대상은 vCPU 스레드다. 게스트 안의 Keycloak 파드 배치는 이 질문이 다루지 않는다.
@@ -18,10 +18,8 @@ source:
# 가상 머신 두 대를 CPU-bound 로 만들면 게스트 steal time 은 얼마나 오르는가
§24.4 는 steal time 을 게스트에 실행할 작업이 있는데도 하이퍼바이저나 호스트가 그 vCPU 스레드를 즉시 실행시키지 못한 시간으로 적었다.
게스트의 `top` 에서 `%st` 로 보이는 값이지만, §13 과 §24.4 는 그 값 하나로 원인을 확정해서는 안 된다고 함께 못 박았다.
§27 은 이 물음에서 호스트의 CPU 사용률과 실행 대기열, QEMU vCPU 스레드, 각 게스트의 `%st` 를 함께 재라고 적었다.
이 호스트에서 `%st` 를 잰 값이 없어서, 부하 전 기준값부터 남겨야 증가폭을 숫자로 적을 수 있다.
이 호스트에서 게스트의 steal time(`%st`)을 잰 값이 없어 부하 전 기준값부터 남겨야 한다.
논리 코어는 8 이고(§197) 두 게스트에 준 vCPU 는 각각 2 다(§218). 둘을 더해도 코어 수를 넘지 않는다. 그런 구성에서도 두 대를 동시에 CPU-bound 로 만들면 `%st` 가 오르는지는 §27 이 든 네 항목을 부하 전후로 찍어야 갈린다.
## 관계
@@ -49,6 +47,10 @@ source:
§24.3 은 경쟁 대상이 QEMU vCPU 스레드만은 아니라고 적었다.
호스트에 Nginx 를 직접 설치하고 가상 머신 두 대를 실행하는 테스트 환경에서는 가상 머신 밖의 호스트 작업도 CPU 경쟁에 포함된다.
§197 은 2026-09-10 에 이 호스트에서 잰 값을 적었다. Core(s) per socket 4 에 Thread(s) per core 2 라 논리 코어가 8 이다.
같은 절은 VM 에 주는 vCPU 가 논리 코어를 나눠 쓰는 것이고 합이 8 을 넘어도 libvirt 는 막지 않는다고 적으면서, 이 실험대는 2 + 2 + 1 = 5 로 잡아 여유를 뒀다고 덧붙였다.
§218 의 2026-09-03 배치는 kc-lab-1 과 kc-lab-2 에 각각 vCPU 2 를 적었다.
§27 의 OQ-7 은 함께 측정할 항목으로 넷을 들었다.
Host CPU utilization
@@ -61,7 +63,7 @@ QEMU vCPU thread
## 가정
두 가상 머신을 동시에 CPU-bound 로 만들면 vCPU 스레드끼리 호스트 CPU 시간을 두고 경쟁하게 된다고 전제한다.
이 호스트의 논리 CPU 수와 두 가상 머신의 vCPU 합계가 SSOT 에 적혀 있지 않아서, 경쟁이 생기는 구성인지도 아직 전제로만 두었다.
두 게스트의 vCPU 합 4 는 논리 코어 8 보다 적어서 §12 가 그린 초과 할당 상태는 아니다. 그래도 경쟁이 생기는지는 같은 코어를 호스트 쪽 작업이 얼마나 쓰는지에 달려 있어 아직 전제로다.
두 게스트에 같은 정도의 부하를 걸 수 있다고 전제한다.
한쪽이 더 세게 걸리면 두 %st 를 나란히 놓고 읽을 수 없다.
@@ -75,7 +77,7 @@ QEMU vCPU thread
그 증가가 호스트 실행 대기열과 같은 방향으로 움직이는가.
이 호스트의 논리 CPU 수는 얼마이고 두 가상 머신의 vCPU 합계는 그 수에 견줘 어느 정도인가.
부하 구간에 호스트 쪽 작업이 같은 논리 코어를 얼마나 쓰는가.
## 제약
@@ -87,6 +89,8 @@ QEMU vCPU thread
부하 구간에 호스트에서 Nginx 같은 다른 작업이 함께 돌면 %st 의 증가분을 두 가상 머신 사이의 경쟁으로만 읽을 수 없다.
§24.3 이 그 목록을 그릴 때 둔 환경은 호스트에 Nginx 를 직접 설치하고 가상 머신 두 대를 실행하는 구성이다. §179 는 그 Nginx 를 엣지 게스트로 옮겼고 호스트가 직접 들던 `:443` 에는 지금 리스너가 없다고 적었으므로, 부하 구간에 호스트 쪽에서 무엇이 도는지는 그 목록이 아니라 그때의 호스트에서 받아 적는다.
OQ-1 과 이 물음은 같은 부하 실행에서 값을 얻는다. 그래도 한 물음으로 합치지 않았다.
합치면 경쟁이 있었다는 결론과 그것이 %st 로 얼마나 보였다는 결론 가운데 어느 쪽 기준으로 닫혔는지가 남지 않는다.
@@ -18,7 +18,7 @@ source:
# cgroup CPU 제한과 호스트 vCPU 경쟁을 지표로 가를 수 있는가
가상 머신 안에서 K3s 를 돌리는 지금 구성에서는 Keycloak 파드가 자신에게 걸린 CPU 상한에 막혀 느려진 것(cgroup throttling)과 호스트 CPU 를 제때 받지 못해 느려진 것(Host vCPU contention)이 같은 애플리케이션 지연으로 관찰될 수 있다. 계층별 진단표는 두 원인을 다른 계층에 갈라 놓고 먼저 볼 항목도 따로 적어 두었다. 다만 이 호스트에서 두 원인을 각각 재현해 그 항목들이 실제로 다르게 움직이는지는 확인하지 않았다.
이 호스트에서 throttled time 도 steal time 도 잰 값이 없다. 가상 머신 안에서 K3s 를 돌리는 구성이라, 파드가 자기 CPU 상한에 막힌 것과 호스트 CPU 를 제때 못 받은 것이 같은 애플리케이션 지연으로 보일 수 있다. §25 가 두 원인에 갈라 적은 확인 항목이 실제로 갈리는지는 두 원인을 각각 재현해야 안다.
## 관계
@@ -43,6 +43,7 @@ source:
cgroup 쪽은 파드가 CPU 를 더 쓰려 해도 상한에 막힌 것이고, 경쟁 쪽은 실행 가능한 작업이 늘면서 지연이 늘어난 것이다.
갈라 적힌 현상은 각 계층에서 보이는 모습이라, 애플리케이션 지연만 보고 있으면 그 구분이 드러나지 않는다.
- 그 표는 지표 하나로 원인을 확정하려고 만든 것이 아니라 어느 계층부터 조사할지 범위를 줄인다.
- §197 은 2026-09-10 에 이 호스트의 논리 코어를 8 로, 실험대가 잡은 vCPU 를 2 + 2 + 1 = 5 로 적었다. §218 은 kc-lab-1 과 kc-lab-2 에 각각 vCPU 2 를 적었다.
- 이 호스트에서 throttled time 도 steal time 도 잰 값이 없다.
## 가정
@@ -67,8 +68,8 @@ source:
- 두 재현이 비슷한 크기의 지연 증가를 만들어야 지표 조합을 견줄 수 있다.
- 지표 하나로 원인을 확정하지 않는다. 게스트 · 호스트 · K3s 세 쪽을 같은 시각에 받아 적는다.
- refresh 경쟁 실험은 CPU 자원을 여유 있게 둔 상태에서 먼저 돌리므로 이 재현은 그 실험과 같은 시간에 걸지 않는다.
- 이 호스트의 논리 CPU 수와 두 가상 머신의 vCPU 수를 개념 문서가 적어 두지 않았다.
부하 쪽 재현이 실제로 경쟁을 만드는지는 그 두 값을 적은 뒤에 판단한다.
- 두 게스트의 vCPU 합 4 가 논리 코어 8 보다 적어서, 부하 쪽 재현이 경쟁을 만들려면 호스트 쪽 작업까지 같은 코어를 써야 한다.
부하를 걸었는데 경쟁이 나오지 않는 것도 이 재현의 결과로 받는다.
## 선택지
@@ -18,10 +18,8 @@ source:
# vCPU 를 늘릴수록 이 호스트에서 Keycloak 처리량도 계속 오르는가
§11 은 4 vCPU 를 실행 컨텍스트 4개를 주는 것으로 적었지, 물리 CPU 4개를 가상 머신이 영구적으로 소유하는 것으로 적지 않았다.
§24.6 은 vCPU 를 많이 할당한다고 항상 성능이 좋아지지는 않는다고 적었다.
§27 은 비교 구성으로 2 vCPU · 4 vCPU · 8 vCPU 를 들었다.
세 구성에 같은 Keycloak 부하를 걸어 본 기록이 이 호스트에 없어서, 처리량이 어디서부터 더 오르지 않는지는 모른다.
이 호스트에서 vCPU 바꿔 가며 Keycloak 처리량을 잰 값이 없다.
논리 코어가 8 이라(§197) §27 이 든 8 vCPU 구성에서는 한 게스트의 vCPU 수가 코어 수와 같아진다. 처리량이 어디서부터 더 오르지 않는지는 세 구성에 같은 부하를 걸어야 갈린다.
## 관계
@@ -43,6 +41,9 @@ source:
§27 의 OQ-8 은 vCPU 추가가 실제 처리량과 지연에 어떤 영향을 주는지 확인하는 비교 구성으로 2 vCPU / 4 vCPU / 8 vCPU 를 들었다.
§197 은 2026-09-10 에 이 호스트의 논리 코어를 8 로 적었다. Core(s) per socket 4 에 Thread(s) per core 2 다.
같은 절은 VM 에 주는 vCPU 가 논리 코어를 나눠 쓰는 것이고 합이 8 을 넘어도 libvirt 는 막지 않는다고 적으면서, 이 실험대는 2 + 2 + 1 = 5 로 잡아 여유를 뒀다고 덧붙였다.
이 호스트에서 vCPU 수를 바꿔 가며 Keycloak 처리량을 잰 값은 없다.
## 가정
@@ -63,13 +64,15 @@ vCPU 를 2 에서 4, 8 로 올렸을 때 처리량과 지연이 어디서부터
좋아지지 않는다면 원인이 작업의 병렬성 부족인가 호스트 전체 CPU 부족인가.
이 호스트의 논리 CPU 수는 얼마이고, 8 vCPU 구성이 그 수를 넘는가.
8 vCPU 구성을 잴 때 나머지 게스트를 함께 띄우는가. 총 vCPU 가 논리 코어 8 을 넘긴 채로 잰 값인지 아닌지가 §24.6 의 두 원인 가운데 어느 쪽을 보는지를 가른다.
## 제약
vCPU 수만 바꾸고 Keycloak 설정과 부하는 세 실행에서 고정한다.
8 vCPU 가 호스트 논리 CPU 수를 넘는지에 따라 재는 것이 작업의 병렬성인지 초과 할당인지 달라지므로, 호스트 논리 CPU 먼저 적는다.
논리 코어가 8 이라 8 vCPU 구성에서는 한 게스트의 vCPU 수가 코어 수와 같아진다. 나머지 게스트를 함께 띄우면 합이 8 을 넘으므로, 세 실행마다 그때 떠 있던 게스트와 총 vCPU 를 함께 적는다.
§186 은 가이드의 실측 줄이 「이 실험대의 호스트는 16 코어 전부에서 지원한다」인데 §178 의 대상 환경은 논리 코어 8 이라고 적고, 어느 쪽이 이 호스트의 값인지는 재지 않았다고 남겼다. 이 기록이 8 을 놓고 짜는 것은 §197 이 2026-09-10 에 그 값을 직접 받아 적었기 때문이다.
§24.6 이 든 두 원인을 가르려면 처리량과 지연만으로는 부족하다. 게스트의 %st 를 같은 실행에서 남긴다.
@@ -18,9 +18,8 @@ source:
# pinning 없이 vCPU 스레드는 호스트 논리 CPU 사이를 옮겨 다니는가
§5 는 QEMU vCPU 스레드도 다른 호스트 스레드와 마찬가지로 Linux 스케줄러가 실행할 논리 CPU 를 정한다고 적었다.
관찰 수단은 §14.7 이 든다. `PSR``ps` 가 찍어 주는 값이고, 그 스레드가 최근 실행된 논리 CPU 를 가리킨다.
다만 이 호스트에서 `PSR` 을 찍어 본 기록이 없어서, 같은 vCPU 스레드가 시간에 따라 다른 논리 CPU 로 옮겨 가는지는 아직 모른다.
이 호스트에서 `PSR` 을 찍어 본 기록이 없어서, pinning 없이 같은 vCPU 스레드가 논리 CPU 사이를 옮겨 다니는지 아직 모른다.
`PSR``ps` 가 찍어 주는 값이고 그 스레드가 최근 실행된 논리 CPU 를 가리킨다(§14.7). 이 호스트의 논리 코어는 8 이고 두 게스트의 vCPU 는 각각 2 다(§197 · §218).
## 관계
@@ -43,6 +42,9 @@ CPU pinning 을 적용하면 실행 위치를 특정 논리 CPU 집합으로 제
§20 의 OQ-5 는 PSR 과 스케줄러 추적으로 관찰한다고만 적었을 뿐 관찰한 결과는 남기지 않았다.
§197 은 2026-09-10 에 이 호스트에서 잰 값을 적었다. Core(s) per socket 4 에 Thread(s) per core 2 라 논리 코어가 8 이고, 실험대가 잡은 vCPU 는 2 + 2 + 1 = 5 다.
§218 의 2026-09-03 배치는 kc-lab-1 과 kc-lab-2 에 각각 vCPU 2 를 적었다.
이 호스트에서 PSR 을 실제로 찍은 기록은 SSOT 에 없다.
## 가정
@@ -68,7 +70,7 @@ CPU pinning 을 적용하면 실행 위치를 특정 논리 CPU 집합으로 제
## 제약
프로젝트에는 이 호스트에서 잰 값이 하나도 없어서, 지금 이동 여부를 적으면 관측이 아니라 추측이 된다.
호스트에서 PSR 을 찍은 값이 없어서, 지금 이동 여부를 적으면 관측이 아니라 추측이 된다.
PSR 은 최근 실행된 논리 CPU 하나만 보여 주므로 두 표본 사이에 일어난 이동은 잡히지 않는다.
@@ -18,7 +18,7 @@ source:
# Keycloak 동시 refresh 실험 중 CPU 가상화 계층이 결과를 흔들 만큼 포화되는가
Keycloak refresh token 경쟁 실험은 가상 머신 두 대 위에서 돌기 때문에 원래 검증하려던 구조 아래에 KVM 계층이 하나 더 붙는다. 실험 구간에서 요청이 느려지거나 실패했을 때 그것을 refresh 경쟁 문제로 읽어도 되는지는 같은 구간의 호스트 CPU 사용량과 steal time 을 봐야 갈린다. steal time 은 게스트의 vCPU 에 실행할 작업이 있는데도 다른 작업 때문에 곧바로 실행되지 못한 상황을 가리키는 지표다(§13). 그런데 §20 이 함께 관찰하라고 든 다섯 지표를 아직 한 번도 같이 찍지 않았다.
§20 이 함께 관찰하라고 든 다섯 지표를 아직 한 번도 같이 찍지 않았다. Keycloak refresh 경쟁 실험은 가상 머신 두 대 위에서 돌기 때문에, 원래 검증하려던 구조 아래에 KVM 계층이 하나 더 붙는다. 실험 구간의 지연을 refresh 경쟁로 읽어도 되는지는 같은 구간의 호스트 CPU steal time 이 갈라 준다.
## 관계
@@ -68,6 +68,8 @@ Host : CPU contention · CPU overcommit · Host saturation
§26 은 refresh 경쟁을 검증하는 첫 실험에서 가능하면 CPU 자원을 여유 있게 유지하고, 그 상태에서 같은 세션과 같은 refresh token 에 대한 동시 요청을 만들어 동시성 문제를 먼저 확인하라고 적는다. 트래픽을 늘려 CPU/DB/Redis/K3s 자원 포화를 관찰하는 것은 별도의 부하·스트레스 Case 로 나눈다.
§13 은 steal time 을 게스트 vCPU 에 실행할 작업이 있는데 다른 작업 때문에 즉시 실행되지 못하는 상황을 가리키는 단서로 적었다.
§20 이 refresh 경쟁 실험 중 동시에 관찰하라고 든 다섯
Host CPU
@@ -78,11 +80,14 @@ DB/Redis latency
§20 은 그 목적이 refresh 경쟁과 호스트 자원 경쟁을 분리하는 것이라고 밝힌다.
§197 은 2026-09-10 에 이 호스트에서 논리 코어 8 과 Mem: 11648 을 받아 적었고, 실험대가 잡은 vCPU 를 2 + 2 + 1 = 5 로 적었다. 실험 구간의 값은 아직 없다.
## 가정
refresh 경쟁 실험이 Keycloak latency 와 DB/Redis latency 를 이미 재고 있다고 전제한다. 그 실험이 어떤 값을 어떤 주기로 내는지는 이 근거 문서에 적혀 있지 않다.
기준값을 찍는 구간과 실험 구간 사이에 호스트의 다른 작업이 달라지지 않는다.
§211 은 2026-09-03 과 2026-09-10 사이에 호스트 RAM 이 물리 증설되고 게스트가 두 대에서 세 대로 늘었다고 적으면서, 두 값이 어긋나 보이면 「틀린 것이 아니라 다른 날이다」라고 못 박았다. 기준값과 실험 구간은 같은 날 같은 구성에서 받아야 이 전제가 선다.
다섯 지표의 시각을 맞춰 읽을 수 있다고 전제한다. 호스트 쪽과 게스트 쪽 시계가 어긋나면 부하 구간을 겹쳐 놓을 수 없기 때문이다.
@@ -104,7 +109,7 @@ refresh 경쟁 실험이 Keycloak latency 와 DB/Redis latency 를 이미 재고
§26 이 첫 실험을 CPU 여유 상태로 못박기 때문에, 이 질문은 실험 조건을 바꾸지 않고 관찰 지표만 덧붙여 답해야 한다. §17.1 이 refresh token 경쟁을 확인하는 데 반드시 호스트 CPU 를 100% 까지 밀 필요는 없다고 적었으므로, 실험을 그대로 두고 관찰만 덧붙이는 것이 §26 의 조건과도 어긋나지 않는다.
이 호스트에서 잰 값이 없어 기준값부터 만들어야 한다. 실험 구간의 값만 있으면 그 값이 평소 값인지 실험 때문에 오른 값인지 갈리지 않는다.
이 호스트에서 그 다섯 지표를 잰 값이 없어 기준값부터 만들어야 한다. 실험 구간의 값만 있으면 그 값이 평소 값인지 실험 때문에 오른 값인지 갈리지 않는다.
§22 의 Claim 12·13 은 이 환경에서 실제로 그러한지를 주장하는 것이라, 재기 전에는 Case 도 Question 도 되지 않는다고 보고 그대로 두었다. 이 물음을 그와 갈라 먼저 올린 것은 아는 것과 모르는 것이 실험 전에도 갈리기 때문이다.
@@ -18,7 +18,7 @@ source:
# 실제 작업에서 주로 발생하는 VM Exit 은 무엇인가
VM Exit 은 게스트를 실행하던 CPU 가 하이퍼바이저가 개입해야 하는 조건을 만나 KVM 쪽으로 제어권을 넘기는 전환이고, 가상 머신이 꺼지는 것이 아니다(§7.2). §8 은 Exit 을 낼 수 있는 동작을 일곱 가지로 들지만, 그 가운데 무엇이 실제로 Exit 을 내는지는 이 호스트의 VMX 설정과 돌리는 작업이 정한다. 유휴 구간과 CPU-bound 구간, Keycloak 부하 구간이 서로 다른 Exit 분포를 낼지는 재 봐야 알고, 그 전에 `perf kvm` 이 이 환경에서 열리는지부터 확인하지 않았다.
`perf kvm` 이 이 환경에서 열리는지부터 아직 확인하지 않았다. VM Exit 은 게스트를 실행하던 CPU 가 KVM 쪽으로 제어권을 넘기는 전환이 가상 머신이 꺼지는 것이 아니다(§7.2). §8 이 든 일곱 동작 가운데 무엇이 실제로 Exit 을 내는지는 이 호스트의 VMX 설정과 돌리는 작업이 정한다.
## 관계
@@ -52,6 +52,8 @@ CPU 에는 MSR(Model-Specific Register)이 있고 RDMSR 과 WRMSR 로 접근한
§14.9 는 환경이 지원하면 perf kvm 으로 KVM 관련 실행 통계를 확인할 수 있다고 하면서 sudo perf kvm stat live 를 예로 든다. 지원되는 명령과 표시되는 Exit 이유는 커널, perf 버전, CPU 아키텍처 및 설정에 따라 다를 수 있다. 그래서 perf kvm --help 를 함께 확인하고, 필요하면 KVM tracepoint 를 이용한 별도 추적도 검토하라고 적는다.
§197 은 2026-09-10 에 이 호스트에서 커널 7.2.2-arch1-1 과 QEMU emulator version 11.1.1 을 받아 적었다. CPU 는 11th Gen Intel(R) Core(TM) i5-1135G7 @ 2.40GHz 이고 lscpu 의 Virtualization 은 VT-x 다. §14.9 가 표시되는 Exit 이유를 좌우한다고 든 것 가운데 커널과 CPU 아키텍처는 여기서 정해진다. perf 버전은 적혀 있지 않다.
§20 이 든 비교 후보 다섯
idle
@@ -64,7 +66,7 @@ Keycloak 부하 테스트
다섯 작업을 이 호스트에서 각각 만들 수 있다. Keycloak 정상 요청과 부하 테스트는 이미 돌리는 실험을 그대로 쓴다고 전제한다.
호스트에서 sudo 로 perf 를 돌릴 수 있다.
호스트에서 sudo 로 perf 를 돌릴 수 있다. §204 는 이 호스트의 sudo 가 비밀번호를 요구해 비대화식으로는 읽지 못했다고 적었으므로, 콘솔에 붙어 직접 치는 실행으로 잡는다.
한 작업을 재는 동안 다른 가상 머신이 내는 Exit 이 결과에 섞이지 않는다고 전제하는데, perf kvm 이 호스트 전체를 보는지 프로세스 하나만 보는지는 확인하지 않았다.
@@ -84,7 +86,7 @@ perf kvm 이 열리지 않을 때 KVM tracepoint 로 같은 것을 볼 수 있
## 제약
이 호스트에서 잰 값이 없기 때문에, 분포를 재기 전에 도구가 열리는지부터 확인해야 한다. 도구가 이 환경에서 되는지부터 봐야 한다는 것은 이 물음을 접을 이유가 아니라 다음 검증의 첫 단계다. 열리지 않는 것으로 확인되는 것도 이 물음을 닫는 결과이기 때문이다.
이 호스트에서 Exit 분포를 잰 값이 없기 때문에, 분포를 재기 전에 도구가 열리는지부터 확인해야 한다. 열리지 않는 것으로 확인되는 것도 이 물음을 닫는 결과다.
§8 에 따르면 어떤 동작이 Exit 을 내는지는 VMX 설정이 정하기 때문에, 나온 분포는 이 호스트의 설정에 딸린 값이라 다른 호스트로 옮겨 읽을 수 없다.
@@ -0,0 +1,134 @@
---
kind: CONCEPT
slug: two-l7-hops-and-the-entry-point-recursion
title: L7 이 두 겹인 이유와, 진입점 하나를 이중화하려 할 때 끝나지 않는 재귀
topic: lab-entry-path-and-measurement-integrity
topicName: 실험대의 진입 경로
project: virtualization
status: 게시 전
basisVersion: 이 실험대의 2026-09-03 배치 · 호스트 nginx 1.30.4 (Arch) · k3s v1.36.4 의 기본 ingress 인 Traefik
sourceRevision: 9465582b5d1630eb4ae7c4e078021486919bf6b6
source:
- final/document.md#261-진입점-자체가-죽으면-로드밸런서의-재귀-문제
- final/document.md#275-호스트-nginx와-traefik은-무엇이-다른가-둘-다-필요한-이유
- final/document.md#257-리버스-프록시와-upstream
- final/document.md#258-왜-tls를-끊어서-내용을-보는가
---
# L7 이 두 겹인 이유와, 진입점 하나를 이중화하려 할 때 끝나지 않는 재귀
이 실험대의 요청은 HTTP 를 읽는 서버를 두 번 지난다. 바깥의 nginx 가 TLS 를 끊고 어느 노드로 보낼지 정하고, 그 노드의 Traefik 이 어느 파드로 보낼지 정한다. 한쪽만으로는 안 되는 이유가 서로 다르고, 진입점을 이중화하려 하면 「어느 진입점으로 갈지 누가 정하는가」가 새로 생겨 재귀가 끝나지 않는다.
## 관계
- **엣지 nginx 를 호스트에서 게스트 VM 으로 옮긴다 — 성능이 아니라 더러워지는 층의 격리**
그 결정이 든 근거가 「L7 홉 수는 전후 모두 2홉」인데 왜 2홉인지는 그 기록에 없다. 이 글이 그 앞을 맡는다.
- **신뢰 경계에서는 forwarded 헤더를 덧붙이지 않고 덮어쓴다**
그 기준이 말하는 경계가 두 홉 가운데 어느 쪽인지를 이 글이 먼저 정한다.
- **공개 터널을 쓰지 않고 tailnet 직결로 둔다 — 홉이 하나 늘면 재려던 계약이 오염된다**
여기 적은 두 겹에 세 번째를 붙이지 않기로 한 결정이다.
- **엣지 게스트에 nginx 를 세우고 호스트 DNAT 으로 밖에서 들어오는 길을 연다**
바깥 홉을 실제로 세우는 절차가 거기 있다.
- **Guest 의 packet 이 Host Physical NIC 에 닿기까지 — virtio-net · virtqueue · vhost-net · TAP · Bridge**
이 두 겹 아래에서 패킷이 게스트까지 가는 길을 그 글이 설명한다.
- **가까운 층부터 한 층씩 건너뛰며 확인한다 — 층마다 성공 신호가 다르다**
진입 경로가 층으로 나뉘어 있어서 한 칸씩 건너뛰며 칠 수 있다.
## 본문
<!-- body:start -->
## 요청 하나가 L7 을 두 번 지난다
브라우저가 보낸 요청은 파드에 닿기까지 HTTP 를 읽는 서버를 두 번 지난다.
```text
브라우저 ─▶ nginx (L7 · TLS 종료) ─▶ Traefik (L7 · Ingress 라우팅) ─▶ Pod
```
리버스 프록시는 클라이언트 요청을 대신 받아 뒤쪽 서버로 전달하는 서버를 말한다. nginx 의 `upstream` 블록은 뒤쪽 서버 여러 대를 하나의 논리 이름으로 묶고, `proxy_pass http://이름;` 으로 그 그룹을 가리키면 nginx 가 요청을 분배한다. 저장소의 단일 호스트용 설정은 `proxy_pass http://keycloak:8080` 으로 대상 하나를 가리키는데, 멀티노드로 재려면 `upstream` 형태로 바꿔야 한다. 바깥 홉이 이 형태이고 묶인 것은 게스트 두 대의 `80` 포트다.
엣지 nginx 는 물리 호스트에서 게스트 한 대로 옮겨졌는데 그때도 HTTP 를 읽는 홉의 수는 2 그대로였다. 늘어난 것은 호스트 커널이 하는 L4 전달 한 번이고, 커널은 HTTP 를 읽지 않는다.
## 바깥 홉이 TLS 를 끊는 이유
TLS 종료는 프록시가 암호를 풀어 평문 HTTP 를 읽는 것을 말한다. 굳이 푸는 첫째 이유는 내용을 안 보면 어디로 보낼지 정할 수 없기 때문이다. 여러 도메인이 하나의 IP 와 443 포트를 공유하고, 어느 서비스로 보낼지는 HTTP `Host` 헤더에 적혀 있는데 그 헤더가 TLS 안에 암호화돼 있다. TLS 핸드셰이크의 평문 부분에 도메인이 들어 있어서 도메인 단위 분기는 풀지 않고도 된다. 다만 경로 단위 분기는 그렇게 할 수 없고, 인증서도 백엔드마다 따로 관리해야 한다.
이 실험대에는 이유가 하나 더 있다. `X-Forwarded-Proto: https``X-Forwarded-Host` 같은 헤더는 평문 HTTP 를 편집할 수 있어야 넣을 수 있고, Keycloak 이 `iss` 클레임과 redirect URL 을 외부 주소로 만들려면 그 헤더가 필요하다. 그래서 이 구조에서 TLS 종료는 고를 수 있는 것이 아니라 전제다.
대가는 셋이다. 프록시 뒤 구간이 평문이 된다 — 운영은 `127.0.0.1`, lab 은 `virbr0` 로 둘 다 머신 밖으로 안 나간다. 신뢰 경계가 프록시까지 넓어져서 프록시 보안이 곧 전체 보안이 된다. 그리고 클라이언트 인증서가 사라지는데, 백엔드가 그것을 직접 검증해야 하면 TLS 를 끊으면 안 되고 그때는 TCP 를 그대로 흘리는 L4 통과 구성을 쓴다.
## 바깥과 안이 아는 것이 다르다
| 무엇을 견주나 | 바깥의 nginx | Traefik (k3s ingress) |
|---|---|---|
| 사는 곳 | 클러스터 밖의 프로세스 | 클러스터 안, 파드 |
| 아는 대상 | IP 와 포트 (고정) | 쿠버네티스 Service 와 Ingress (동적) |
| 설정하는 방법 | 파일 편집 뒤 `nginx -s reload` | `kubectl apply` 로 Ingress 리소스 |
| 대상이 바뀌면 | 사람이 고쳐야 한다 | 자동 반영 |
| 무엇을 정하나 | 어느 노드로 보낼까 | 어느 파드로 보낼까 |
| TLS | 여기서 종료 | 평문으로 받음 |
nginx 는 클러스터의 존재를 모르고 파드 IP 가 바뀌는 것도 모른다. Ingress 는 설정을 적어 둔 쿠버네티스 리소스여서 그 자체로는 아무 일도 하지 않고, 그 설정을 실제로 수행하는 프로그램이 Ingress Controller 다. 컨트롤러는 API 서버를 계속 감시하다가 Ingress 리소스가 생기거나 바뀌면 자기 라우팅 설정을 갱신한다.
Traefik 만 쓰면 어느 노드로 보낼지를 정할 것이 없다. k3s 에 딸린 servicelb 덕분에 Traefik 이 두 노드의 80 과 443 에 모두 바인딩되지만, 브라우저는 어느 노드로 가야 할지 모르고 그 노드가 내려가면 그 IP 로는 아무도 받지 못한다. Traefik 은 노드 안에서 파드로 나눠 주고, 노드들 사이에서 나눠 주는 일은 클러스터 밖의 무언가가 맡아야 한다.
nginx 만 쓰고 Traefik 을 끄면 Ingress 리소스를 못 쓰게 되고 서비스가 늘거나 파드 IP 가 바뀔 때마다 사람이 파일을 고친다. 그리고 두 경우 모두 운영 구조와 달라진다. 운영이 `host nginx → k3s(Traefik)` 이므로 실험대도 그 2홉을 복제해야 `X-Forwarded-*` 신뢰 경계 결론이 그대로 이전되고, 그것이 둘 다 두는 결정적인 이유다.
## ALB 와 NLB 는 같은 곳을 놓고 고르는 두 선택지다
ALB 와 NLB 는 AWS 의 두 제품이고 이 실험대에는 없다. 여기서는 진입점 배치를 견주는 이름으로만 쓴다. 둘 다 클러스터 밖의 로드밸런서여서 Ingress Controller 와 대응되는 짝이 아니다.
| 무엇이 다른가 | ALB (L7) | NLB (L4) |
|---|---|---|
| 이해하는 것 | HTTP/HTTPS | TCP/UDP |
| 라우팅 기준 | 호스트명·경로 | 포트 |
| TLS | 종료함 | 통과 또는 종료 |
| `X-Forwarded-*` | 추가함 | 추가 안 함 (PROXY protocol 사용) |
진입점은 한 곳이고 HTTP 를 읽는 처리는 어딘가에서 반드시 한 번 일어난다. 배치의 차이는 진입점과 L7 처리기가 같은 장비인가 다른 장비인가에 있다.
```text
[ALB 패턴]
브라우저 ─▶ ALB (L7 · TLS 종료 · 경로 라우팅) ─▶ Pod
└ 진입점이자 L7 처리기. 하나가 두 역할.
[NLB 패턴]
브라우저 ─▶ NLB (L4 · 통과) ─▶ ingress controller (L7 · TLS 종료) ─▶ Pod
└ 진입점만 └ L7 처리기. 역할이 둘로 나뉜다.
```
| 배치 | 진입점 | L7 처리 위치 |
|---|---|---|
| ALB 단독 | ALB (L7) | 진입점 한 곳 |
| NLB + ingress | NLB (L4) | 클러스터 안 한 곳 |
| L7 + ingress | nginx (L7) | 두 곳 모두 |
이 실험대는 두 패턴 중 어느 쪽도 아니다. 바깥의 nginx 가 TLS 를 끊고 `X-Forwarded-*` 를 넣으므로 ALB 에 가까운데, 그 뒤의 Traefik 이 또 HTTP 를 읽는다.
## 진입점을 이중화하려 하면 재귀가 끝나지 않는다
한 머신 안에서 nginx 를 여러 개 띄우는 것은 의미가 없다. nginx 는 이미 master 프로세스 1개와 worker N개 구조이고, worker 들이 리스닝 소켓을 공유하며 CPU 코어 수만큼 병렬로 처리한다. 같은 머신에 인스턴스를 늘려도 그 머신이 내려가면 전부 함께 내려가므로 가용성은 늘지 않는다.
진짜 이중화는 머신을 늘리는 것이고, 그러면 「어느 nginx 로 갈지는 누가 정하는가」가 새로 생긴다. 앞에 로드밸런서를 또 두면 이번에는 그것이 단일 장애점이 되어 같은 물음이 한 칸 앞으로 옮겨 갈 뿐이다. 실무는 이 재귀를 소프트웨어가 아니라 네트워크 계층의 장치로 끊는다.
| 방법 | 재귀를 끊는 원리 | 전환 시간 |
|---|---|---|
| VIP + VRRP (keepalived) | 선택자가 없다. IP 자체가 이동한다 | 1~3초 |
| DNS 다중 A 레코드 | 클라이언트가 고른다. 실패 시 다음 IP로 재시도 | TTL 의존, 느림 |
| 애니캐스트 + BGP/ECMP | 라우터가 가장 가까운 경로로 보낸다 | 즉시, 대규모 전용 |
| 클라우드 LB에 위임 | AWS가 내부적으로 다중 AZ로 이중화. 사용자는 DNS 이름만 받음 | 관리 불필요 |
VRRP 는 가상 IP 하나를 여러 장비가 번갈아 갖게 하는 방식으로 재귀를 끊는다. 가상 IP 는 한 번에 한 대만 갖고, MASTER 가 내려가면 BACKUP 이 그 IP 를 가져간 뒤 gratuitous ARP 를 브로드캐스트해 스위치의 MAC 테이블을 갱신한다. 클라이언트는 계속 같은 IP 로 접속하는데 그 IP 가 어느 장비에 붙어 있는지만 바뀐다. 고르는 주체가 없어서 「누가 정하는가」라는 물음 자체가 생기지 않는다.
클라우드 로드밸런서를 쓰면 AWS 가 여러 가용 영역에 걸쳐 이 재귀를 대신 풀어 주고 사용자는 DNS 이름 하나만 받는다.
## 이 실험대가 관측한 범위
바깥의 nginx 는 이 실험대의 단일 장애점이고 물리 머신도 한 대라 그것도 단일 장애점이다. 숨길 이유가 없으므로 알려진 한계로 적어 둔다.
이 실험대는 진입점을 이중화하지 않는다. 물리 머신이 한 대라 keepalived 를 구성해도 그 머신이 내려가면 끝이고, 검증 대상은 Keycloak 의 세션과 토큰이지 로드밸런서 가용성이 아니다. 다만 Traefik 은 이미 두 노드에 떠 있으므로, 노드 하나를 내리고 바깥 nginx 의 `upstream` 이 어떻게 반응하는지는 그대로 관찰할 수 있다.
ALB 와 NLB 의 대조는 AWS 의 두 제품을 기준으로 적은 것이고 이 실험대에서 관측한 것이 아니다. VRRP 도 keepalived 의 일반 동작이고 이 실험대에 구성하지 않았다. NLB 에 해당하는 것 역시 이 실험대에 아직 없고, 필요해지면 nginx 의 `stream {}` 블록이 그 일을 맡는다. k3s API 서버를 밖에서 접근하거나 PostgreSQL 과 Redis 를 게스트 밖에서 직접 관찰하거나 Keycloak 의 mTLS 를 실험할 때가 그런 경우다. nginx 는 한 프로세스에서 L7 과 L4 를 함께 수행할 수 있어서, AWS 에서 두 제품으로 나뉘어 있는 것과 다르다.
<!-- body:end -->
@@ -0,0 +1,66 @@
---
kind: PROJECT_DECISION
slug: no-public-tunnel-because-a-third-hop-pollutes-the-measurement
title: 공개 터널을 쓰지 않고 tailnet 직결로 둔다 — 홉이 하나 늘면 재려던 계약이 오염된다
topic: lab-entry-path-and-measurement-integrity
topicName: 실험대의 진입 경로
project: virtualization
status: 게시 전
decisionStatus: ADOPTED
sourceRevision: 9465582b5d1630eb4ae7c4e078021486919bf6b6
source:
- final/document.md#305-tunnel-채택하지-않은-이유를-남긴-자산
- final/document.md#302-왜-적용하지-않는-것을-남겨두는가
- final/document.md#300-9층-deploy-무엇이-살아-있고-무엇이-참조인가
- final/document.md#301-전체-지도
---
# 공개 터널을 쓰지 않고 tailnet 직결로 둔다 — 홉이 하나 늘면 재려던 계약이 오염된다
Cloudflare named tunnel 을 앞에 붙이면 브라우저에서 파드까지가 3홉이 되고, 이 실험대가 재려는 것이 정확히 2홉의 forwarded 헤더 계약이라 측정이 오염된다. 그래서 tailnet 직결로 두고 터널 설정 파일은 채택하지 않은 선택지로 남긴다.
## 근거
§305 가 `deploy/` 아래의 `tunnel/cloudflared-config.yml` 을 두고 채택하지 않았다고 적으면서 기각 이유를 전후 홉 그림으로 남겼다. 기각한 파일을 지우지 않는 방침은 §300 부터 §302 에 따로 있다. 저장소에 있으나 적용되지 않는 설정이 여럿인데, 그것들이 죽은 코드가 아니라 의도적으로 남겨 둔 참조 자산이라고 전체 지도와 함께 적는다.
- **L7 이 두 겹인 이유와, 진입점 하나를 이중화하려 할 때 끝나지 않는 재귀**
이 결정이 지키려는 2홉이 무엇이고 그 둘의 역할이 왜 다른지를 그 글이 설명한다.
- **신뢰 경계에서는 forwarded 헤더를 덧붙이지 않고 덮어쓴다**
앞에 한 겹이 더 붙으면 경계가 Cloudflare 엣지로 옮겨 가 그 기준을 거기서 다시 세워야 한다.
- **인증서는 DNS-01 로 받는다 — 실험대 주소가 공개 인터넷에 없어 HTTP-01 이 성립하지 않는다**
이 결정이 청구한 비용 하나를 그 결정이 받는다.
- **엣지 nginx 를 호스트에서 게스트 VM 으로 옮긴다 — 성능이 아니라 더러워지는 층의 격리**
같은 2홉을 지킨 채 엣지의 위치만 바꾼 결정이라 홉 수를 건드리지 않는다.
- **이 실험대의 certbot 은 HTTP-01 로 받고 있는가 DNS-01 로 받고 있는가**
재구축 때 인증서 디렉터리를 지워도 되는지가 그 물음에 걸려 있다.
## 결정문
공개 터널을 쓰지 않고 tailnet 직결로 둔다. 기각한 설정 파일 `tunnel/cloudflared-config.yml` 은 지우지 않고 채택하지 않은 선택지로 남긴다.
조건이 바뀌어 공개 접근이 필요해지면(예를 들어 다른 회선으로 이전) 그 파일을 그대로 쓴다.
## 판단 이유
Cloudflare named tunnel 은 아웃바운드 연결만 쓰므로 포트포워딩 없이 공개 HTTPS 이름을 얻는다. 공유기를 건드릴 수 없는 환경에서는 매력적인 선택지이고, §305 는 그 설정의 함정 둘까지 적어 두었다. `service:``127.0.0.1` 을 쓰면 cloudflared 컨테이너 자신을 가리키므로 Compose 서비스 DNS 이름을 써야 하고, 마지막 catch-all 이 없으면 오류가 난다.
기각 근거는 홉 수다. 터널을 쓰면 브라우저가 Cloudflare 엣지와 nginx 와 Traefik 을 지나 파드에 닿아 3홉이 된다. 지금은 브라우저가 nginx 와 Traefik 만 지나 파드에 닿는 2홉이다. Cloudflare 엣지가 TLS 를 끊고 다시 맺으면서 HTTP 를 읽는 홉이 하나 늘고 `CF-Connecting-IP` 같은 자체 헤더가 섞인다. 이 실험대가 측정하려는 것이 정확히 `nginx → Traefik` 2홉의 forwarded 헤더 계약이므로, 앞에 한 겹이 더 붙으면 측정이 오염된다.
터널이 무엇을 해 주는지가 아니라 그것이 이 실험대의 측정 대상에 무엇을 하는지를 보고 정했다.
## 영향
진입 경로가 2홉으로 고정된다. 밖에서 들어오는 요청을 처음 받는 프록시가 하나뿐이라 신뢰 경계가 한 곳이고, forwarded 헤더를 누가 쓰는지가 갈리지 않는다.
감수한 비용은 진입 주소다. `dig` 가 내놓는 `100.83.212.4``100.64.0.0/10` 안에 있고 그 대역이 CGNAT 용으로 예약돼 있어 공개 인터넷에서 라우팅되지 않는다.
그 대가가 두 곳에서 청구됐다.
인증서 발급 : HTTP-01 로 받을 수 없어 DNS-01 로 갔다
재구축 : 인증서 디렉터리 `/etc/letsencrypt/` 를 지워도 되는지가 미확정이다
기각한 파일을 지우지 않는 방침에도 근거가 셋 있다.
① 저장소의 목적이 비교다 : 선택지를 나란히 두고 트레이드오프를 적는 것 자체가 산출물이라, 하나만 남기면 왜 이것을 골랐는지를 뒷받침할 근거가 없어진다
② 죽은 코드가 아니라 테스트되는 코드다 : `scripts/verify-public-tunnel-config.sh` 가 붙어 있어 실행되지 않을 뿐 깨지면 드러난다
③ 실험대 전용 설정은 `lab/` 아래로 분리했다 : 일반 배포 설정과 섞이지 않는다
재지 않은 것도 있다. 터널을 붙인 상태와 지금을 같은 방법으로 잰 비교는 이 저장소에 없다. 기각의 근거는 홉 수와 섞이는 헤더이지 두 구성을 재서 견준 값이 아니다. 인증서를 지금 어느 방식으로 받고 있는지도 이 실험대에서 아직 재지 않았다.
@@ -0,0 +1,93 @@
---
kind: REFERENCE
slug: overwrite-a-forwarded-header-at-the-trust-boundary-do-not-extend-it
title: 신뢰 경계에서는 forwarded 헤더를 덧붙이지 않고 덮어쓴다
topic: lab-entry-path-and-measurement-integrity
topicName: 실험대의 진입 경로
project: virtualization
status: 게시 전
sourceRevision: 9465582b5d1630eb4ae7c4e078021486919bf6b6
source:
- final/document.md#209-nginx-keycloak-lab-conf-스티키-스위치와-신뢰-경계
- final/document.md#259-x-forwarded--와-신뢰-경계
- final/document.md#207-lab-edge-dnat-nft-dnat-파일이-자기-안에-적어-둔-네-가지
- final/document.md#206-이-부의-출처와-범위
---
# 신뢰 경계에서는 forwarded 헤더를 덧붙이지 않고 덮어쓴다
맨 바깥 프록시는 클라이언트가 보낸 `X-Forwarded-For` 를 버리고 자기가 본 주소로 덮어쓴다. nginx 에서는 `$proxy_add_x_forwarded_for` 가 아니라 `$remote_addr` 를 쓴다는 뜻이다. 커널 쪽에도 같은 계약의 절반이 있어서, 경계 앞에서는 출발지 주소를 바꾸지 않는다.
## 관계
- **L7 이 두 겹인 이유와, 진입점 하나를 이중화하려 할 때 끝나지 않는 재귀**
이 기준이 말하는 경계가 두 홉 가운데 어느 쪽인지를 그 글이 먼저 정한다.
- **엣지 게스트에 nginx 를 세우고 호스트 DNAT 으로 밖에서 들어오는 길을 연다**
이 기준이 적용되는 설정을 실제로 쓰는 절차다. 헤더 네 줄과 DNAT 규칙이 거기 있다.
- **엣지 nginx 를 호스트에서 게스트 VM 으로 옮긴다 — 성능이 아니라 더러워지는 층의 격리**
그 이동으로 경계가 호스트에서 엣지 게스트로 옮겨 갔다.
- **공개 터널을 쓰지 않고 tailnet 직결로 둔다 — 홉이 하나 늘면 재려던 계약이 오염된다**
경계 앞에 한 겹을 더 세우지 않기로 한 결정이다. 그 결정의 이유가 이 실험대가 재려는 계약이다.
- **호스트 안에서는 404, 밖에서는 connection refused — 앞 체인의 accept 가 libvirt 의 reject 를 막지 못했다**
경계 프록시가 아닌 경로를 막는 일이 방화벽의 몫이라는 것을 그 기록이 보여 준다.
## 목적
`X-Forwarded-For` 는 누구나 보낼 수 있는 평범한 HTTP 헤더다. 값을 믿을 만하게 만드는 것은 헤더 이름이 아니라 그 값을 쓴 주체이고, 쓰는 주체를 하나로 좁히는 것이 이 기준이다. 맨 바깥 프록시가 클라이언트의 값을 이어 붙이면 위조된 값이 사슬 앞에 남고, 그러면 뒤쪽의 어느 것도 그 헤더를 근거로 쓸 수 없다.
설정 정본이 그 줄 위에 이유를 적어 두었다. 원문은 영어이고 그대로 옮긴다.
$remote_addr, not $proxy_add_x_forwarded_for. This is the trust boundary: a client-supplied X-Forwarded-For must be discarded, not extended, or nothing downstream can rely on the value.
§206 은 설정 원본의 주석 59줄을 대조하면서 이 저장소 어디에도 없는 것 둘을 셌고, 이 설명이 그중 하나다. §189 과 §190 은 그 줄을 옮겨 적기만 하고 왜 그 형태여야 하는지는 적지 않았다.
## 규칙
### 1. 맨 바깥 프록시는 클라이언트가 보낸 `X-Forwarded-For` 를 버리고 자기가 본 주소로 덮어쓴다
nginx 에서는 `proxy_set_header X-Forwarded-For $remote_addr;` 이고 `$proxy_add_x_forwarded_for` 가 아니다. 둘의 차이는 클라이언트가 보낸 값을 사슬 앞에 남기느냐 버리느냐에 있다. `proxy_set_header` 가 값을 이어 붙이지 않고 덮어쓰는 지시어인 이유도 여기에 있다.
### 2. `X-Forwarded-Proto` 와 `X-Forwarded-Host` 도 같은 규칙으로 다룬다
셋 다 프록시가 뒤쪽 서버에게 원래 클라이언트가 어땠는지 알려 주는 관례적 헤더이고, 클라이언트가 임의로 보낼 수 있다. 하나만 덮어쓰고 나머지를 이어 붙이면, 뒤쪽 서버는 클라이언트가 보낸 값을 그대로 근거로 삼는다. 공격자가 `X-Forwarded-Host` 를 조작해 인증 흐름을 자기 도메인으로 돌릴 수 있는 것도 이 경로다.
### 3. 경계 앞에서는 출발지 주소를 바꾸지 않는다
호스트의 DNAT 파일은 DNAT 만 걸고 SNAT 는 걸지 않는다. 파일 자신이 「DNAT only, never SNAT」이라고 적고 그 이유를 바로 뒤에 붙였다. masquerade 를 걸면 출발지가 다시 쓰여 엣지가 모든 클라이언트를 `192.168.122.1` 로 보게 되고, 그러면 이 실험대가 재는 `X-Forwarded-For` 계약이 조용히 무효가 된다. SNAT 없이도 응답이 돌아오는 것은 게스트의 기본 경로가 호스트이기 때문이다. 응답이 호스트를 다시 지나고, conntrack 이 변환을 알아서 되돌린다. 경계 앞에서는 출발지를 바꾸지 않고 경계에서는 클라이언트가 준 값을 버린다는 두 문장이 한 계약이다.
### 4. 경계가 어디인지는 「그 앞에 우리가 통제하지 않는 것이 있는가」로 가른다
이름에 엣지가 붙었다고 해서 그 프록시가 경계가 되는 것은 아니다. 우리가 통제하지 않는 쪽에서 요청을 직접 받는 첫 프록시가 경계이고, 그 한 대만 이 기준을 따른다. 이 실험대에서는 엣지 게스트의 nginx 가 그렇고, 클라우드라면 ALB 가 같은 곳에 선다.
### 5. 프록시를 한 겹 더 넣거나 옮길 때 이 기준을 다시 적용한다
설정을 처음 쓸 때보다 구성을 바꿀 때 이 기준이 실제로 쓰인다. 이 실험대가 엣지를 호스트에서 게스트로 옮겼을 때 경계도 함께 옮겨 갔다. 공개 터널을 앞에 붙였다면 경계가 Cloudflare 엣지로 한 번 더 옮겨 갔을 것이다. 옮겨 간 뒤에도 옛 경계가 클라이언트의 값을 이어 붙이고 있으면 사슬은 다시 믿을 수 없게 된다.
## 적용 조건
- 신뢰 경계에 선 맨 바깥 프록시 한 대. 이 실험대에서는 엣지 게스트의 nginx 이고, 클라우드라면 ALB 가 같은 곳에 선다
- 대상 헤더 : `X-Forwarded-For` · `X-Forwarded-Proto` · `X-Forwarded-Host` 셋. 셋 다 관례적 헤더이고 클라이언트가 임의로 보낼 수 있다
- 적용 시점 : 설정을 처음 쓸 때가 아니라 프록시를 한 겹 더 넣거나 옮길 때
- 경계 판정 : 그 프록시 앞에 우리가 통제하지 않는 것이 있을 때
## 예외
경계 안쪽의 두 번째 홉은 반대다. 이 실험대의 Traefik 처럼 신뢰하는 프록시 뒤에 서는 것은 앞이 쓴 값을 이어받아야 하고, 거기서 덮어쓰면 원래 클라이언트 주소가 없어진다. `$proxy_add_x_forwarded_for` 자체가 틀린 값은 아니고, 쓰는 곳이 따로 정해져 있다. 경계에서 쓰면 클라이언트가 위조한 값을 그대로 통과시킨다.
이 실험대는 그 두 번째 홉을 아직 재지 않았다. 저장소에 적힌 forwarded 헤더 계약이 1홉을 가정한 것이라 `nginx → Traefik` 2홉과 어긋나고, Traefik 이 앞이 쓴 값을 덮어쓰는지 신뢰하는지 이어 붙이는지에 따라 결과가 갈린다. 이 실험대가 가장 먼저 실측할 항목이 그것이다.
L4 통과 구성에는 적용되지 않는다. NLB 처럼 TCP 를 그대로 흘리면 원본 IP 가 보존되어 헤더가 아예 필요 없다. 그때 원본 주소를 알리는 데 쓰는 것은 PROXY protocol 이라 이 기준의 대상이 아니다.
경계 앞에 CDN 이나 터널이 있으면 그 공급자의 헤더가 정본이 된다. Cloudflare 라면 `CF-Connecting-IP` 이고, 그때는 그 헤더를 놓고 이 기준을 다시 세운다.
이 기준만으로 신뢰가 완성되지 않는다. 경계 프록시를 거치지 않는 경로로 뒤쪽에 직접 닿을 수 있으면 헤더를 어떻게 쓰든 소용이 없다. 그 경로를 막는 것은 방화벽과 네트워크 배치의 일이고, 이 실험대에서는 게스트가 libvirt NAT 뒤에 있는 것이 그 몫을 한다.
## 예시
- 경계의 nginx : `proxy_set_header X-Forwarded-For $remote_addr;`
- 경계 안쪽 두 번째 홉 : `$proxy_add_x_forwarded_for` 로 앞이 쓴 값을 이어받는다
- 호스트 커널 : DNAT 만 걸고 masquerade 는 걸지 않는다
- masquerade 를 걸었을 때 엣지가 보는 클라이언트 주소 : `192.168.122.1` 하나
- 경계 앞에 Cloudflare 가 있을 때의 정본 헤더 : `CF-Connecting-IP`
- 클라이언트가 보낸 값을 이어 붙인 사슬 : 위조된 값이 앞에 남아 뒤쪽에서 근거로 못 쓴다
- L4 통과 구성 : 헤더가 필요 없고 PROXY protocol 을 쓴다
@@ -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 -->
@@ -164,7 +164,7 @@ cat /sys/kernel/mm/transparent_hugepage/enabled
## 이 글이 확정하지 않는 것
이 호스트에서 잰 값은 하나도 없다. THP 정책도 `HugePages_Total` 도 읽지 않았고 가상 머신의 메모리 backing 설정도 확인하지 않았다. 원문 제2부는 개념 서술이고 실제 서버에 딸린 값은 OQ(Open Question) 번호가 붙은 확인 목록 열넷으로 미뤄져 있다.
이 호스트에서 huge page 를 잰 값은 하나도 없다. THP 정책도 `HugePages_Total` 도 읽지 않았고 가상 머신의 메모리 backing 설정도 확인하지 않았다. 원문 제2부는 개념 서술이고 실제 서버에 딸린 값은 OQ(Open Question) 번호가 붙은 확인 목록 열넷으로 미뤄져 있다.
호스트 쪽 값은 OQ-4 가 받는다. THP 정책과 함께 `AnonHugePages`, `HugePages_Total`, `HugePages_Free`, `Hugepagesize` 를 읽어 이 서버가 어느 방식을 쓰고 있는지 확정하는 확인이다.
@@ -174,6 +174,6 @@ cat /sys/kernel/mm/transparent_hugepage/enabled
virsh dumpxml <VM_NAME>
```
두 확인이 끝나기 전에는 이 글의 세 질문 가운데 어느 것도 이 서버에 대해 답이 없다. 그리고 두 확인이 끝나도 셋째 질문은 남는다. EPT 매핑에서 큰 매핑을 쓰는가를 이 호스트에서 재라고 적은 항목은 그 열넷에 없다. 원문 커널·QEMU·libvirt 버전을 적지 않아 이 글도 특정 버전에 고정하지 않았고, THP 정책의 기본값이 배포판마다 다르다는 점도 원문이 확인하라고만 적었다.
두 확인이 끝나기 전에는 이 글의 세 질문 가운데 어느 것도 이 서버에 대해 답이 없다. 그리고 두 확인이 끝나도 셋째 질문은 남는다. EPT 매핑에서 큰 매핑을 쓰는가를 이 호스트에서 재라고 적은 항목은 그 열넷에 없다. 원문 제2부가 THP 와 HugeTLB 를 서술하면서 커널·QEMU·libvirt 버전을 적지 않아 이 글도 특정 버전에 고정하지 않았다. 이 실험대의 커널 `7.2.2-arch1-1` 과 QEMU 11.1.1, libvirt 12.7.0 은 §197 이 적어 두었다. 그 위에서 huge page 값을 읽은 기록은 없다. THP 정책의 기본값이 배포판마다 다르다는 점도 원문이 확인하라고만 적었다.
<!-- body:end -->
@@ -62,7 +62,11 @@ VM3 configured = 16 GiB
Total configured = 48 GiB
```
이 숫자는 개념을 보이려고 든 예시이고 이 테스트 서버에서 잰 값이 아니다. 설정한 용량과, 그 가상 머신이 실제로 쓰고 있는 메모리(working set)나 호스트에서 그 몫으로 붙잡고 있는 메모리(resident memory)가 같지 않을 수 있기 때문에 이런 구성이 가능할 수 있다. 같은 예시에서 세 가상 머신이 실제로 쓰는 양을 각각 약 5G, 4G, 3G 로 잡으면 합이 약 12G 라 32 GiB 안에 들어간다. 세 대의 수요가 동시에 증가하면 그때부터 문제가 발생한다. 이 호스트의 값은 근거 문서 어디에도 없다. 물리 RAM 도 스왑 설정도 가상 머신들에 설정한 메모리의 합도 적혀 있지 않아서, 아래에 적는 경로가 이 환경에서 지금 돌고 있는지는 이 글이 정하지 못한다.
이 숫자는 개념을 보이려고 든 예시이고 이 테스트 서버에서 잰 값이 아니다. 설정한 용량과, 그 가상 머신이 실제로 쓰고 있는 메모리(working set)나 호스트에서 그 몫으로 붙잡고 있는 메모리(resident memory)가 같지 않을 수 있기 때문에 이런 구성이 가능할 수 있다. 같은 예시에서 세 가상 머신이 실제로 쓰는 양을 각각 약 5G, 4G, 3G 로 잡으면 합이 약 12G 라 32 GiB 안에 들어간다. 세 대의 수요가 동시에 증가하면 그때부터 문제가 발생한다.
이 실험대는 그 구성이 아니다. 호스트 물리 RAM 은 §178 이 `free -m` 으로 실측한 11,648MiB 다. 게스트 세 대에 배정한 메모리는 §187 이 `kc-lab-1` 5120MB, `kc-lab-2` 4096MB, `kc-lab-edge` 1024MB 로 적었고 합이 10,240MB 다. 배정 합이 호스트 RAM 보다 작다. 배정률은 10240/11648 = 87.9% 이고, 배정하지 않고 남은 것이 1,408MiB 다. 그래서 이 배치는 메모리 초과 할당이 아니다. 호스트의 스왑 설정은 아직 근거 문서에 없다.
그 배정이 처음부터 이 숫자였던 것은 아니다. §187 은 게스트를 만들 때 메모리가 3584MB 였고 실험을 늘리며 5120/4096 으로 재배분했다고 적었다. 왜 그때 늘릴 수 있었는지는 §332 에 있다 — 「호스트에서 8GB→12GB로 물리 증설한 뒤 이 방법으로 재배분했다.」 그 방법은 `virsh setmaxmem` 으로 상한을 올리고 `virsh setmem` 으로 현재 할당을 맞추는 두 줄인데, 현재 할당을 상한보다 크게 줄 수 없어 순서가 정해져 있다. 게스트를 다시 만들거나 디스크를 건드리지 않아도 되는 대신 `setmaxmem --live` 는 대개 거부되므로, 상한을 바꾸려면 게스트를 껐다 켠다.
CPU 가 모자랄 때와 RAM 이 모자랄 때 Linux 가 하는 일이 다르다. CPU 가 모자라면 스케줄러가 실행 시간을 나누고 실행 가능한 작업은 자기 차례를 기다리는데, 시간은 잘라서 뒤로 미룰 수 있기 때문이다. RAM 이 모자랄 때는 미룰 것이 없어서 지금 존재해야 하는 페이지를 어디에 둘 것인가만 남는다. 그래서 메모리 압박에서는 회수와 스왑, ballooning, OOM 같은 메커니즘이 추가로 개입한다. 메모리 초과 할당은 CPU 초과 할당과 동일한 성격의 자원 공유가 아니다.
@@ -109,10 +113,10 @@ cgroup 메모리 상한이 걸린 환경에서는 호스트 전체 RAM 에 여
## 이 문서가 확정하지 않는 것
이 글이 확정하는 것은 구조가 어떻게 동작하는가까지다. 이 테스트 서버에서 잰 값은 하나도 없다.
이 글이 확정하는 것은 구조가 어떻게 동작하는가까지다. 이 테스트 서버에서 잰 값 가운데 여기 쓴 것은 호스트 물리 RAM 과 게스트 배정, §198 의 할당·실사용뿐이고, 압박 경로 자체를 이 서버에서 재현한 적은 없다.
호스트 물리 RAM 도, 스왑 설정도, 가상 머신들에 설정한 메모리의 합도 근거 문서에 적혀 있지 않다. 그래서 이 환경이 초과 할당 상태인지조차 아직 사실이 아니다. 설정한 메모리와 게스트가 실제로 쓰는 메모리, 호스트에서 그 몫으로 붙잡고 있는 메모리를 가상 머신마다 적으면 그 답이 나온다.
호스트 물리 RAM 11,648MiB 와 게스트 배정 합 10,240MB 는 앞 절에 실측으로 적었고, 배정 합이 더 작아 이 배치는 초과 할당이 아니다. 남은 것은 호스트의 스왑 설정과, 가상 머신마다의 실제 사용량 그리고 호스트에서 그 몫으로 붙잡고 있는 메모리다. §198 이 `virsh dommemstat` 으로 k3s 두 대를 재서 `kc-lab-1` 은 할당 5120MB 에 실사용 353MB, `kc-lab-2` 는 할당 3120MB 에 실사용 301MB 였다고 적었다. `kc-lab-2` 의 할당이 배정한 4096MB 보다 작은데, 같은 절은 그것을 virtio-balloon 이 회수해 간 것으로 보인다고 적고 단정하지 않았다. 그 측정은 k3s 만 떠 있고 Keycloak 은 올리기 전이라 나머지가 올라간 뒤의 값은 미측정이다. `kc-lab-edge` 는 그 측정에 들어 있지 않다.
스왑이 지금 오가고 있는지도 재지 않았다. `Swap Used` 값 하나로는 진행 중인 활동인지 과거에 내려간 뒤 남아 있는 cold page 인지 갈리지 않으니 게스트와 호스트를 같은 시각에 관측해야 한다. 호스트 압박이 게스트 애플리케이션 지연을 실제로 밀어 올리는지는 압박이 없는 구간과 있는 구간을 나눠 재야 알 수 있고, 그 구간에서 스토리지 지연과 CPU 도 같이 기록해야 원인을 메모리 쪽으로 좁힐 수 있다. 세 확인은 각각 열린 질문으로 두었고 관계에 걸어 두었다.
스왑이 지금 오가고 있는지도 재지 않았다. §178 과 §197 에 있는 호스트 `free -m` 출력은 `head -2` 로 잘려 `Mem:` 줄까지만 남아 스왑 줄이 없다. `Swap Used` 값 하나로는 진행 중인 활동인지 과거에 내려간 뒤 남아 있는 cold page 인지 갈리지 않으니 게스트와 호스트를 같은 시각에 관측해야 한다. 호스트 압박이 게스트 애플리케이션 지연을 실제로 밀어 올리는지는 압박이 없는 구간과 있는 구간을 나눠 재야 알 수 있다. 그 구간에서 스토리지 지연과 CPU 도 같이 기록해야 원인을 메모리 쪽으로 좁힐 수 있다. 세 확인은 각각 열린 질문으로 두었고 관계에 걸어 두었다.
<!-- body:end -->
@@ -67,7 +67,7 @@ vCPU 를 특정 CPU 집합에 묶는 설정을 pinning 이라고 한다. 어떤
![QEMU vCPU Thread, VM1 Memory Backing, Node 0 CPU, Node 0 RAM, Node 1 RAM 다섯 참여자 사이의 순서도. 1번에서 vCPU thread 가 Node 0 CPU 에 pinning 되고 2번에서 VM1 의 memory backing 이 Node 1 에 놓이면 3번의 접근이 점선으로 Node 1 RAM 까지 간다. 4번에서 memory 를 Node 0 에 묶은 구성에서는 5번의 같은 접근이 Node 0 RAM 에서 끝난다.](../../../final/assets/diagrams/numa-vcpu-and-memory-placement/numa-vcpu-and-memory-placement.svg)
이 호스트가 노드 몇 개인지는 근거 문서에 없다. 하나로 나오면 지금 말한 어긋남은 이 환경에 성립하지 않는다.
이 호스트가 노드 몇 개인지는 근거 문서에 없다. §197 이 논리 코어를 8 로 적었지만 NUMA 노드 수는 읽지 않았다. 그 절은 `lscpu | grep -E "^Model name|^CPU\(s\):|^Thread|^Core"` 로 네 줄만 걸러 받았고, 거기 남은 `Core(s) per socket` 4 와 `Thread(s) per core` 2 로 논리 코어 8 이 나온다. `Socket(s)` 줄과 `NUMA node(s)` 줄은 그 네 패턴에 걸리지 않아 실측에 없다. 그 출력을 다시 읽어도 노드 수가 나오지 않으므로 아래 절의 `lscpu` 를 거르지 않고 한 번 더 친다. 노드가 하나로 나오면 지금 말한 어긋남은 이 환경에 성립하지 않는다.
## 큰 가상 머신에서는 게스트에게 토폴로지를 보여 준다
@@ -75,7 +75,7 @@ vCPU 를 특정 CPU 집합에 묶는 설정을 pinning 이라고 한다. 어떤
노출한 뒤에는 게스트가 인식하는 토폴로지와 실제 호스트 배치가 합리적으로 대응되도록 구성한다. 게스트 NUMA 0 이 호스트 NUMA 0 에, 게스트 NUMA 1 이 호스트 NUMA 1 에 대응하지 않으면, 게스트가 로컬이라고 판단해 고른 메모리가 호스트에서는 원격이 된다.
프로젝트의 가상 머신 크기인지는 근거 문서에 적혀 있지 않다. vCPU 수도 설정한 RAM 도 나오지 않아서, 이 절이 이 환경에 걸리는 이야기인지는 그 값을 적은 뒤에 정해진다.
실험대의 가상 머신 그 크기가 아니다. §187 이 적은 게스트 세 대는 vCPU 가 한 개나 두 개이고 배정한 메모리가 1024MB 에서 5120MB 사이다. §197 은 그 vCPU 합을 `2 + 2 + 1 = 5` 로 적었다.
## 이 장비의 토폴로지부터 읽는다
@@ -114,7 +114,7 @@ virsh vcpuinfo <VM_NAME>
## 이 문서가 확정하지 않는 것
이 글이 확정하는 것은 배치가 어긋나면 무엇이 원격 접근이 되는가까지다. 이 테스트 서버에서 잰 값은 하나도 없다.
이 글이 확정하는 것은 배치가 어긋나면 무엇이 원격 접근이 되는가까지다. 이 테스트 서버에서 NUMA 를 잰 값은 하나도 없다.
이 호스트가 노드 하나인지 여럿인지가 정해지지 않았다. 노드 하나로 나오면 앞 절의 어긋남이 이 환경에 성립하지 않으므로 NUMA 를 현재 실험에서 뒤로 미룬다. 여럿으로 나와야 QEMU 프로세스의 노드별 메모리 분포와 vCPU 배치를 나란히 적는 확인이 의미를 갖는다. 토폴로지 확인은 CPU 가상화 쪽 질문이 받고 있다.
@@ -203,11 +203,11 @@ Host Kernel
└─ Host OOM
```
원문의 분류에는 Dynamic Memory 와 NUMA(Non-Uniform Memory Access) 가지가 더 있고, 위에 옮긴 셋이 fault 가 걸리는 가지다. 게스트 쪽 fault 가 늘었으면 게스트의 reclaim 과 swap 을 같이 보고, 호스트 쪽 major fault 가 늘었으면 호스트의 reclaim 과 swap 을 같이 본다. 어느 쪽 지표를 먼저 여느냐가 이 구분에서 정해진다. 다만 이 호스트에서는 어느 쪽도 아직 열지 않았다. 게스트 쪽 fault 지표도 호스트 쪽 major fault 도 읽은 적이 없어서, 이 분류는 아직 어느 지표부터 열지 정하는 데만 쓰인다.
원문의 분류에는 Dynamic Memory 와 NUMA(Non-Uniform Memory Access) 가지가 더 있고, 위에 옮긴 셋이 fault 가 걸리는 가지다. 게스트 쪽 fault 가 늘었으면 게스트의 reclaim 과 swap 을 같이 보고, 호스트 쪽 major fault 가 늘었으면 호스트의 reclaim 과 swap 을 같이 본다. 어느 쪽 지표를 먼저 여느냐가 이 구분에서 정해진다. 다만 이 호스트에서는 게스트 쪽 fault 지표도 호스트 쪽 major fault 도 아직 열지 않았다. 그래서 이 분류는 지금 어느 지표부터 열지 정하는 데만 쓰인다.
## 이 글이 확정하지 않는 것
이 호스트에서 잰 값은 하나도 없다. 게스트 쪽 fault 지표도 호스트 쪽 major fault 도 읽지 않았고, 앞의 다섯 원인 가운데 무엇이 이 환경에서 실제로 일어나는지도 세지 않았다. 원문 제2부는 개념 서술이고 실제 서버에 딸린 값은 OQ(Open Question) 번호가 붙은 확인 목록 열넷으로 미뤄져 있다.
이 호스트에서 fault 를 잰 값은 하나도 없다. 게스트 쪽 fault 지표도 호스트 쪽 major fault 도 읽지 않았고, 앞의 다섯 원인 가운데 무엇이 이 환경에서 실제로 일어나는지도 세지 않았다. 원문 제2부는 개념 서술이고 실제 서버에 딸린 값은 OQ(Open Question) 번호가 붙은 확인 목록 열넷으로 미뤄져 있다.
게스트 쪽은 OQ-13 이 받는다. page-fault 관련 지표를 관측한 뒤 그 증가가 무엇에서 왔는지를 네 갈래로 갈라 보는 확인이고, 증가 자체를 오류로 읽지 않는 것이 조건이다.
@@ -232,6 +232,6 @@ Guest application latency
가운데 계층은 그 목록이 받지 않는다. 위 분류에는 EPT 관련 사건과 TLB 압박이 한 가지로 들어 있는데, 그것을 이 호스트에서 재라고 적은 항목은 열넷 가운데 없다. 이 글이 갈라 놓은 세 계층에서 확인 계획이 붙은 것은 게스트 쪽과 호스트 쪽 둘이다.
원문 커널 버전을 적지 않아 이 글도 특정 버전에 고정하지 않았다. 여기 적은 것은 그 문서가 서술한 처리 구조이고, 이 서버에서 어떤 값이 나오는지는 위 두 확인을 돌려야 안다.
원문 제2부가 fault 처리를 서술하면서 커널 버전을 적지 않아 이 글도 특정 버전에 고정하지 않았다. 이 실험대의 커널은 §197 이 `7.2.2-arch1-1` 로 적었다. 그 위에서 fault 지표를 읽은 기록은 없다. 여기 적은 것은 그 문서가 서술한 처리 구조이고, 이 서버에서 어떤 값이 나오는지는 위 두 확인을 돌려야 안다.
<!-- body:end -->
@@ -55,13 +55,13 @@ source:
이 장치는 게스트 RAM 자체를 제공하지 않는다. 이미 존재하는 게스트 RAM 을 호스트와 게스트가 협력해 회수하고 반환하는 데 쓰는 가상 장치다.
이 프로젝트의 가상 머신에 이 장치가 붙어 있는지는 근거 문서에 없다. 아래 두 절이 적는 것은 장치가 있을 때 어느 방향으로 움직이는가다.
이 프로젝트의 가상 머신에 이 장치가 붙어 있는지는 libvirt 설정을 읽어야 정해지고 그 기록이 아직 없다. 아래 두 절이 적는 것은 장치가 있을 때 어느 방향으로 움직이는가다.
## Inflate — 게스트 안의 balloon 이 커진다
호스트가 게스트 메모리를 회수하려고 하면 balloon target 을 조정해 balloon 을 부풀린다. 이 동작을 inflate 라고 한다. 그 요청이 `virtio-balloon` 을 지나 게스트 안의 balloon 드라이버에 닿으면 드라이버가 게스트 페이지를 확보한다. 게스트 안의 balloon 이 커지기 때문에 게스트가 쓸 수 있는 RAM 이 그만큼 줄고, 호스트 쪽에서는 회수할 수 있는 메모리가 는다.
드라이버가 하는 일은 확보한 페이지를 일반적인 게스트 작업이 쓰지 못하도록 붙잡아 두고 그 사실을 호스트 쪽에 알리는 것까지다. 그 뒤 호스트가 그 메모리를 실제로 언제 어떻게 놓아주는지는 QEMU/KVM 버전과 backing 종류 및 설정에 따라 달라질 수 있다. 근거 문서는 그 동작을 하나로 단정하지 않았고 이 환경에서도 확인하지 않았다. 그 문서는 이 호스트의 QEMU/KVM 버전을 한 번도 적지 않았다. 버전과 설정이 갈라 놓는 동작이라 버전을 적기 전에는 이 환경이 어느 쪽인지 말할 수 없다.
드라이버가 하는 일은 확보한 페이지를 일반적인 게스트 작업이 쓰지 못하도록 붙잡아 두고 그 사실을 호스트 쪽에 알리는 것까지다. 그 뒤 호스트가 그 메모리를 실제로 언제 어떻게 놓아주는지는 QEMU/KVM 버전과 backing 종류 및 설정에 따라 달라질 수 있다. 근거 문서는 그 동작을 하나로 단정하지 않았고 이 환경에서도 확인하지 않았다. 이 실험대의 버전은 §197 이 적어 두었다 — QEMU 11.1.1 과 libvirt 12.7.0, 커널 `7.2.2-arch1-1` 이다. 버전은 정해졌지만 그 위에서 호스트가 언제 놓아주는지를 잰 기록도, backing 설정을 읽은 기록도 없다.
![virtio-balloon Driver 와 virtqueue, QEMU virtio-balloon Device, Host Memory Management 네 참여자 사이를 여섯 개의 메시지가 오가는 순서도. 1번부터 3번까지는 Host 쪽에서 정한 balloon target 조정이 QEMU device 와 virtqueue 를 지나 VM Boundary 를 넘어 Guest kernel 의 driver 에 닿는 방향이고, 4번부터 6번까지는 driver 가 확보한 Guest page 가 같은 경계를 반대로 건너 Host 의 회수 가능 backing 이 되는 방향이다.](../../../final/assets/diagrams/virtio-balloon-inflate-deflate/virtio-balloon-inflate-deflate.svg)
@@ -86,6 +86,21 @@ ballooning 은 게스트에 이미 설정된 메모리 용량 안에서 호스
현대 가상화에는 `virtio-mem` 같은 다른 동적 메모리 관리 방식도 있어서, 가상 머신의 동적 메모리 관리를 ballooning 하나로 일반화하지 않는다. 근거 문서에 있는 것은 그런 방식이 존재한다는 사실까지다.
## 이 실험대에서 할당이 줄어 있던 값 하나
앞에서 적었듯 balloon 장치가 붙어 있는지를 설정으로 확인한 기록은 아직 없다. 대신 값이 하나 나와 있다 — §198 이 k3s 게스트 두 대에 `virsh dommemstat` 을 돌려 받은 출력이다.
```text label="§198 의 실측 — k3s 만 떠 있고 Keycloak 은 올리기 전"
kc-lab-1 할당 5120MB 실사용 353MB
kc-lab-2 할당 3120MB 실사용 301MB
```
`kc-lab-2` 는 `virt-install --memory 4096` 으로 만들었는데 현재 할당이 3120MB 로 나왔다. §198 은 이것을 virtio-balloon 이 회수해 간 것으로 보인다고 적고 거기서 멈췄다. 같은 절은 `dommemstat` 의 `actual` 이 현재 할당이지 선언한 상한이 아니라고 덧붙였다. 상한은 `virsh dominfo` 의 `Max memory` 에 있는데, 이 실험대는 그 두 값을 나란히 찍어 보지 않았다.
§198 은 이 값이 언제의 값인지도 함께 적었다 — 「다만 이것은 지금 k3s 만 떠 있어서다」이고, Keycloak 2 파드와 PostgreSQL, Redis, Prometheus 가 올라간 뒤의 값은 미측정이다. 게스트 세 대 가운데 `kc-lab-edge` 는 이 측정에 들어 있지 않다.
이 숫자로 알 수 있는 것은 `kc-lab-2` 의 현재 할당이 선언값보다 작다는 것까지다. 그 차이가 balloon 회수 때문인지와 이 가상 머신들에 장치가 실제로 붙어 있는지는 설정과 게스트 쪽 드라이버 상태를 읽어야 갈린다.
## 근거 문서가 번호를 붙여 둔 문장
근거 문서는 이 장치에서 고정한 문장에 번호를 붙여 두었다. 실험 기록에서 어느 문장을 확인했는지 가리킬 때 이 번호를 쓴다.
@@ -97,10 +112,10 @@ ballooning 은 게스트에 이미 설정된 메모리 용량 안에서 호스
## 이 문서가 확정하지 않는 것
이 글이 확정하는 것은 balloon 이 어떤 구조로 어느 방향으로 동작하는가까지다. 이 테스트 서버에서 잰 값은 하나도 없다.
이 글이 확정하는 것은 balloon 이 어떤 구조로 어느 방향으로 동작하는가까지다. 이 테스트 서버에서 잰 값 가운데 여기 쓴 것은 §198 의 할당과 실사용뿐이고, balloon 을 움직여 본 적은 없다.
이 프로젝트의 가상 머신에 `virtio-balloon` 이 붙어 있는지조차 근거 문서에 적혀 있지 않다. libvirt 설정에 balloon 관련 요소가 있는지, 있다면 게스트 안에서 그 드라이버가 어떤 이름으로 올라와 있는지를 먼저 확인해야 한다. 장치가 없으면 balloon target 실험은 성립하지 않는다.
이 프로젝트의 가상 머신에 `virtio-balloon` 이 붙어 있는지를 설정으로 확인한 기록이 없다. 앞 절의 §198 실측은 inflate 방향과 들어맞지만 근거 문서도 그것을 단정하지 않다. libvirt 설정에 balloon 관련 요소가 있는지, 있다면 게스트 안에서 그 드라이버가 어떤 이름으로 올라와 있는지를 먼저 확인해야 한다. 장치가 없으면 balloon target 실험은 성립하지 않는다.
값을 한 단계 바꿨을 때 게스트가 쓸 수 있는 메모리가 얼마나 움직이는지, 그 반영이 즉시인지 늦는지, 어느 값부터 게스트 안에서 회수와 스왑이 시작되는지도 재지 않았다. 호스트가 그 메모리를 언제 놓아주는지가 QEMU 버전과 이 backing 설정에서 어떻게 되는지도 마찬가지다. 두 확인은 각각 열린 질문으로 두었고 관계에 걸어 두었다.
값을 한 단계 바꿨을 때 게스트가 쓸 수 있는 메모리가 얼마나 움직이는지, 그 반영이 즉시인지 늦는지, 어느 값부터 게스트 안에서 회수와 스왑이 시작되는지도 재지 않았다. 호스트가 그 메모리를 언제 놓아주는지가 QEMU 11.1.1 과 이 backing 설정에서 어떻게 되는지도 마찬가지다. 두 확인은 각각 열린 질문으로 두었고 관계에 걸어 두었다.
<!-- body:end -->
@@ -19,9 +19,7 @@ source:
# balloon target 을 바꾸면 게스트가 쓸 수 있는 메모리는 어떻게 따라 움직이는가
virtio-balloon 은 게스트에 RAM 을 새로 붙여 주는 장치가 아니라, 이미 있는 게스트 RAM 의 backing 을 호스트와 게스트가 협력해 회수하고 되돌려주는 가상 장치다. 호스트는 balloon target 을 조정해 그 balloon 을 부풀리거나 줄인다. 개념 문서는 방향까지 적어 두었는데, inflate 하면 게스트가 쓸 수 있는 RAM 이 줄고 deflate 하면 늘어난다.
개념 문서가 방향만 적고 크기는 적지 않아서, 이 물음이 받는 것은 방향이 아니라 크기와 시점이다. 이 호스트에서 목표값을 한 단계 움직였을 때 게스트가 쓸 수 있는 메모리가 얼마나 줄어드는지를 아직 값으로 받아 본 적이 없다. 그 변화가 언제 보이는지, 어느 지점부터 게스트가 reclaim 과 스왑을 시작하는지도 마찬가지다.
이 호스트에서 balloon target 을 한 단계 움직였을 때 게스트가 쓸 수 있는 메모리가 얼마나 줄어드는지 아직 값으로 받아 보지 못했다. virtio-balloon 은 게스트 RAM 의 backing 을 회수하고 되돌려주는 장치다. 개념 문서inflate 하면 줄고 deflate 하면 늘어난다는 방향만 적혀 있다.
## 관계
@@ -69,7 +67,7 @@ virtio-balloon 은 게스트에 RAM 을 새로 붙여 주는 장치가 아니라
- 단계마다 Host · VM · Workload 조건을 함께 적는다. 조건을 남기지 않은 결과는 다른 환경에서 다시 쓰기 어렵다.
- 게스트 OOM 까지 밀어 볼지는 이 실험에서 정하지 않는다.
- 개념 문서가 목표값을 바꾸는 명령을 적어 주지 않았으므로 실행한 명령과 그 출력을 증거로 함께 남긴다.
- 호스트가 실제로 회수한 backing memory 는 이 실험에서 재지 않는다. 개념 문서가 그 동작을 QEMU 와 KVM 버전, backing 종류, 설정에 따라 달라질 수 있다고만 적고 단정을 피했고, §83 OQ-9 가 준 관측 순서도 호스트 설정을 바꾼 뒤 게스트 쪽 값과 게스트의 reclaim 과 스왑 변화를 보는 데까지다.
- 호스트가 실제로 회수한 backing memory 는 이 실험에서 재지 않는다. 개념 문서가 그 동작을 QEMU 와 KVM 버전, backing 종류, 설정에 따라 달라질 수 있다고만 적고 단정을 피했기 때문이다. §83 OQ-9 가 준 관측 순서도 호스트 설정을 바꾼 뒤 게스트 쪽 값과 게스트의 reclaim 과 스왑 변화를 보는 데까지다.
## 선택지
@@ -18,11 +18,7 @@ source:
# 게스트 page fault 증가는 workload 변화를 따라가는가
게스트에서 page fault 가 늘었다는 관측 하나로는 무슨 일이 일어났는지 갈리지 않다. 개념 문서가 든 대표적인 원인은 다섯인데, 그 가운데 demand paging 과 copy-on-write 는 정상 동작이다. 나머지 중 swap-in 은 게스트 RAM 이 모자란다는 신호이고, invalid access 만 프로그램 오류로 이어진다.
이 물음은 이 호스트의 게스트에서 부하를 올렸을 때 fault 지표가 함께 오르는지를 구간을 나눠 받는다. 올랐다면 그 증가가 다섯 원인 가운데 어느 쪽으로 설명되는지까지 본다.
다섯 원인을 하나씩 묻지 않고 한 물음으로 묶었다. 원인마다 물음을 세우면 개념 문서에서 한 줄이던 것이 다섯 편이 되고, §83 OQ-13 이 갈라 보라고 한 네 갈래는 같은 구간의 같은 지표에서 나온다.
이 호스트의 게스트에서 부하를 올렸을 때 page fault 지표가 함께 오르는지 아직 찍어 보지 않다. 올랐다면 개념 문서가 든 다섯 원인 가운데 어느 쪽으로 설명되는지까지 본다. 다섯 가운데 demand paging 과 copy-on-write 는 정상 동작이라, 지표가 늘었다는 관측 하나로는 오류인지 아닌지 갈리지 않는다.
## 관계
@@ -68,6 +64,7 @@ source:
## 제약
- 다섯 원인을 하나씩 따로 묻지 않고 한 물음으로 묶어 받는다. 나눠 세우면 개념 문서에서 한 줄이던 것이 다섯 편이 되고, §83 OQ-13 이 갈라 보라고 한 네 갈래는 같은 구간의 같은 지표에서 나온다.
- 같은 구간의 스왑 활동을 함께 적지 않으면 demand paging 과 swap-in 이 갈리지 않는다.
- fault 지표 하나로 원인을 확정하지 않는다. 개념 문서가 네 갈래를 갈라 보라고 적은 이유가 이것이다.
- 부하를 만드는 방법을 두 실행에서 같게 둔다. 방법이 바뀌면 fault 증가가 부하 때문인지 방법 때문인지 갈리지 않는다.
@@ -77,7 +74,7 @@ source:
### 1. idle 구간과 부하 구간을 나눠 시간에 따른 변화를 받는다
두 구간에서 vmstat 1 로 시간에 따른 값을 받고, 같은 구간의 스왑 활동과 애플리케이션의 working set 을 함께 남긴다. 부하가 올라간 시각과 fault 가 오른 시각이 겹치는지가 그대로 보이고, 스왑 활동이 없는데 fault 만 올랐으면 demand paging 쪽으로 좁혀진다.
두 구간에서 vmstat 1 로 시간에 따른 값을 받고, 같은 구간의 스왑 활동과 애플리케이션의 working set 을 함께 남긴다. 부하가 올라간 시각과 fault 가 오른 시각이 겹치는지가 그대로 보인다. 스왑 활동이 없는데 fault 만 올랐으면 demand paging 쪽으로 좁혀진다.
minor 와 major 를 프로세스 단위로 가르는 것까지는 이 방법으로 나오지 않는다.
@@ -18,9 +18,7 @@ source:
# 호스트 major fault 와 스토리지 지연은 같은 시간축에서 함께 움직이는가
개념 문서는 호스트의 메모리 압박에서 시작해 애플리케이션 지연으로 끝나는 사슬을 그려 두었다. 호스트 메모리 압박이 reclaim 과 스왑을 부르고, 그 스왑 I/O 가 스토리지 I/O 를 늘리고, 늘어난 I/O 가 한 물리 장치에서 경합하면 데이터베이스 지연을 거쳐 애플리케이션 지연까지 간다는 순서다.
사슬의 각 마디는 개념으로 이어져 있지만, 이 호스트에서 네 마디가 같은 구간에 함께 움직이는지는 아직 받아 본 적이 없다. 이 물음은 압박 실험을 한 번 돌릴 때 네 계열을 같은 타임스탬프로 받아 그 사슬이 여기서 이어지는지 끊기는지를 가른다.
이 호스트에서 major fault 와 스왑 활동, 스토리지 지연, 게스트 애플리케이션 지연 네 계열을 같은 시간축에 놓고 본 적이 아직 없다. 개념 문서가 그려 둔 사슬은 호스트 메모리 압박에서 시작한다. 압박이 reclaim 과 스왑을 부르고, 그 스왑 I/O 가 스토리지 경합을 거쳐 데이터베이스 지연 애플리케이션 지연까지 간다.
## 관계
@@ -19,9 +19,7 @@ source:
# 호스트 메모리 압박이 게스트 애플리케이션 지연을 실제로 밀어 올리는가
게스트는 자기가 RAM 에 접근한다고 생각하는데, 그 backing page 가 호스트 RAM 에 없으면 호스트에서 page fault 와 swap-in I/O 를 기다린 뒤에야 게스트 실행이 이어진다. 개념 문서는 그 경로가 스토리지 경합을 거쳐 애플리케이션 지연까지 이어질 수 있다고 그려 두었을 뿐 이 호스트에서 재지 않았다.
이 물음은 baseline 을 잡고 호스트에 메모리 압박을 유도한 뒤 게스트 지연을 다시 재서, 그 경로가 실제로 이어지는지 확인한다.
개념 문서가 그린 경로대로 호스트 메모리 압박이 스토리지 경합을 거쳐 게스트 애플리케이션 지연까지 이어지는지 이 호스트에서 재지 않았다. 게스트는 자기가 RAM 에 접근한다고 본다. 그런데 backing page 가 호스트 RAM 에 없으면 호스트에서 page fault 와 swap-in I/O 를 기다린 뒤에야 게스트 실행이 이어진다.
## 관계
@@ -81,13 +79,13 @@ source:
- 압박을 유도한 상태로 운영 실험을 겹쳐 돌리지 않는다. 같은 스토리지를 쓰는 다른 실험이 있으면 시간을 나눈다.
- 지연이 올랐다는 관측만으로 원인을 호스트 메모리로 확정하지 않는다. 같은 구간의 CPU 와 스토리지 지표를 함께 놓고 §86 의 분류로 계층을 가른다.
- 압박의 허용 범위를 정하는 일은 이 물음 밖이다. 여기서는 경로가 이어지는지만 본다.
- 이 실험을 순서에서 앞당기지 않는다. §84 의 권장 실험 순서에서 압박 실험은 열두 단계 가운데 열 번째이고, 앞의 다섯 단계가 호스트와 가상 머신 조건을 채워 두므로 먼저 돌리면 세 구간에 남길 조건을 그때 다시 모아야 한다.
- 이 실험을 순서에서 앞당기지 않는다. §84 의 권장 실험 순서에서 압박 실험은 열두 단계 가운데 열 번째다. 앞의 다섯 단계가 호스트와 가상 머신 조건을 채워 두므로, 먼저 돌리면 세 구간에 남길 조건을 그때 다시 모아야 한다.
## 선택지
### 1. 세 구간을 한 번에 돌리고 네 종류 지표를 같이 남긴다
baseline, 압박, 압박 해제 세 구간에서 게스트 지연 · 호스트 vmstat · 스토리지 지연 · CPU 를 같은 시각에 기록한다. §83 OQ-7 이 적은 순서 그대로이고, 세 구간이 한 표에 들어가면 지연 변화가 어느 지표와 함께 움직였는지 그 표에서 읽힌다.
baseline, 압박, 압박 해제 세 구간에서 게스트 지연 · 호스트 vmstat · 스토리지 지연 · CPU 를 같은 시각에 기록한다. §83 OQ-7 이 적은 순서 그대로다. 세 구간이 한 표에 들어가면 지연 변화가 어느 지표와 함께 움직였는지 그 표에서 읽힌다.
압박을 유도하는 방법을 먼저 정해야 하고, 게스트에 같은 부하를 세 번 거는 준비가 필요하다.
@@ -18,11 +18,7 @@ source:
# 이 호스트의 THP 정책과 huge page 상태는 무엇인가
THP(Transparent Huge Pages, 투명한 대형 페이지)는 커널이 조건이 맞는 메모리 영역에 Huge Page 를 알아서 활용하려는 기능이다. 그 활용 범위를 정하는 정책 값 커널과 배포판, 호스트 설정에 따라 다르다 보니, 개념 문서는 값을 적지 않고 실제 시스템에서 읽으라고만 했다.
이 물음은 그 값을 읽는 데서 끝난다. 정책이 always 인지 madvise 인지 never 인지, 그리고 huge page 항목 넷의 값이 호스트와 두 게스트에 대해 적히면 닫힌다.
§83 이 적은 열린 물음 열넷 가운데 확인 명령이 함께 적힌 것은 아홉이고 OQ-4 가 그 아홉에 든다. 명령이 없는 다섯은 전부 구간을 나눠 견주는 실험이고, 이 물음은 명령 두 줄의 출력으로 닫힌다.
이 호스트의 THP 정책이 always 인지 madvise 인지 never 인지, huge page 항목 넷의 값이 얼마인지 아직 읽지 않았다. THP(Transparent Huge Pages, 투명한 대형 페이지)는 커널이 조건이 맞는 영역에 Huge Page 를 활용하려는 기능이다. 정책 값 커널과 배포판에 따라 다르다.
## 관계
@@ -52,6 +48,7 @@ THP(Transparent Huge Pages, 투명한 대형 페이지)는 커널이 조건이
HugePages_Total
HugePages_Free
Hugepagesize
- §83 이 적은 열린 물음 열넷 가운데 확인 명령이 함께 적힌 것은 아홉이고, OQ-4 가 거기 든다. 명령이 없는 나머지 다섯은 전부 구간을 나눠 견주는 실험이다.
- 이 호스트에서 그 다섯을 읽은 기록이 개념 문서에 없다. 게스트 쪽 값도 없다.
## 가정
@@ -99,4 +96,4 @@ THP(Transparent Huge Pages, 투명한 대형 페이지)는 커널이 조건이
3. 같은 두 명령을 가상 머신마다 따로 찍고 호스트 값과 나란히 적는다.
4. 실행한 명령과 출력, 찍은 시각을 함께 증거로 남긴다.
닫는 조건 : 다섯 항목의 값이 호스트와 게스트에 대해 적히면 닫는다. HugePages_Total 이 0 이 아니면 그 풀을 가상 머신이 쓰고 있는지는 「가상 머신 RAM 이 HugeTLB 로 명시적으로 backing 되어 있는가」가 받는다. 정책이 always 로 나오고 지연에 민감한 워크로드를 돌리고 있으면 §54 가 든 compaction 영향을 측정할지는 Decision 으로 넘긴다. 이 물음 자체는 정책 값을 확정하는 데서 끝난다.
닫는 조건 : 다섯 항목의 값이 호스트와 게스트마다 적히면 닫는다. HugePages_Total 이 0 이 아니면 그 풀을 가상 머신이 쓰고 있는지는 「가상 머신 RAM 이 HugeTLB 로 명시적으로 backing 되어 있는가」가 받는다. 정책이 always 로 나오고 지연에 민감한 워크로드를 돌리고 있으면 §54 가 든 compaction 영향을 측정할지는 Decision 으로 넘긴다. 이 물음 자체는 정책 값을 확정하는 데서 끝난다.
@@ -18,11 +18,7 @@ source:
# NUMA remote access 가 이 작업의 지연을 실제로 바꾸는가
NUMA(Non-Uniform Memory Access, 비균일 메모리 접근)는 어느 CPU 가 어느 RAM 에 접근하느냐에 따라 비용이 달라질 수 있는 구조다. 배치가 어긋나 있다는 것과 그 어긋남 때문에 느려진다는 것은 다른 확인이다.
개념 문서는 remote access 가 local access 와 같은 비용이라고 가정할 수 없다고까지 적었는데, 얼마나 다른지는 적지 않았다. 단순 토폴로지만 보고 성능 문제라고 단정하지 말라는 단서도 같은 항목에 달렸다. 이 물음은 이 호스트에서 같은 워크로드를 local 배치와 remote 위주 배치로 각각 돌려 두 값을 견주는 데까지 간다.
§83 의 열린 물음 열넷 가운데 둘은 이 주제로 올리지 않고 CPU 가상화 쪽 물음에 합쳤다. 노드 수와 vCPU 배치는 한 번 읽으면 닫히고 그 물음이 이미 같은 것을 묻고 있어서다. 이 물음은 합치지 않았는데, 토폴로지를 아는 것과 지연이 달라지는 것이 한 번의 측정으로 함께 닫히지 않기 때문이다.
이 호스트에서 local 배치와 remote 위주 배치로 같은 워크로드를 돌려 두 값을 견준 기록이 없다. NUMA(Non-Uniform Memory Access, 비균일 메모리 접근)는 어느 CPU 가 어느 RAM 에 접근하느냐에 따라 비용이 달라질 수 있는 구조다. 배치가 어긋나 있다는 것과 그 어긋남 때문에 느려진다는 것은 다른 확인이다.
## 관계
@@ -38,10 +34,11 @@ NUMA(Non-Uniform Memory Access, 비균일 메모리 접근)는 어느 CPU 가
## 사실
- remote access 는 local access 와 동일한 비용이라고 가정할 수 없으며, 추가 지연과 대역폭 비용이 있을 수 있다.
- 큰 가상 머신에서는 게스트에게 NUMA 토폴로지 자체를 노출할 수 있고, 가능하면 게스트가 인식하는 토폴로지와 실제 호스트 배치가 합리적으로 맞물리도록 구성할 수 있다. 개념 문서가 든 예는 노드마다 vCPU 여덟과 RAM 32 GiB 를 둔 가상 머신이고, 이 프로젝트의 가상 머신이 그 크기인지는 적혀 있지 않다.
- 큰 가상 머신에서는 게스트에게 NUMA 토폴로지 자체를 노출할 수 있고, 가능하면 게스트가 인식하는 토폴로지와 실제 호스트 배치가 합리적으로 맞물리도록 구성할 수 있다. 개념 문서가 든 예는 노드마다 vCPU 여덟과 RAM 32 GiB 를 둔 가상 머신이다. 이 프로젝트의 가상 머신이 그 크기인지는 적혀 있지 않다.
- 개념 문서가 적은 실험 순서는 local 배치로 baseline 을 잡고 지연과 처리량과 메모리 지표를 받은 뒤, remote 위주 배치로 바꿔 동일 워크로드를 다시 돌려 견주는 것이다.
- 같은 항목이 NUMA node 가 2개 이상인 경우에만 우선순위를 높이라고 적었다.
- 단순 토폴로지만 보고 성능 문제라고 단정하지 않는다는 단서도 같은 항목에 있다.
- §83 의 열린 물음 열넷 가운데 둘은 이 주제로 올리지 않고 CPU 가상화 쪽 물음에 합쳤다. 노드 수와 vCPU 배치는 한 번 읽으면 닫히는 데다, 그 물음이 이미 같은 것을 묻고 있어서다. 이 물음은 합치지 않았는데, 토폴로지를 아는 것과 지연이 달라지는 것이 한 번의 측정으로 함께 닫히지 않기 때문이다.
- 개념 문서는 이 비교에 쓸 워크로드도, 지연과 처리량을 재는 명령도 적지 않았다. 실험 순서만 적혀 있다.
- 이 호스트에서 두 배치를 각각 구성해 본 기록이 없다.
@@ -71,7 +68,7 @@ NUMA(Non-Uniform Memory Access, 비균일 메모리 접근)는 어느 CPU 가
### 1. 같은 워크로드를 두 배치에서 돌려 값을 견준다
local 배치에서 지연과 처리량과 메모리 지표를 받고, remote 위주로 바꿔 같은 워크로드를 같은 조건으로 다시 돌린다. 개념 문서가 적은 순서 그대로이고, 차이가 나든 나지 않든 그 값이 다음 판단의 근거가 된다.
local 배치에서 지연과 처리량과 메모리 지표를 받고, remote 위주로 바꿔 같은 워크로드를 같은 조건으로 다시 돌린다. 개념 문서가 적은 순서 그대로 따르는 방법이라, 차이가 나든 나지 않든 그 값이 다음 판단의 근거가 된다.
배치를 바꾸는 방법을 먼저 정해야 하고, 두 실행 사이에 다른 조건을 고정하는 데 손이 간다.
@@ -19,9 +19,7 @@ source:
# QEMU 메모리는 vCPU 가 도는 NUMA node 와 같은 node 에 있는가
NUMA(Non-Uniform Memory Access)는 어느 CPU 가 어느 RAM 에 닿느냐에 따라 접근 비용이 달라지는 구조를 말한다. 게스트의 vCPU 는 호스트에서 QEMU 의 vCPU 스레드이고, 그 스레드를 어느 논리 CPU 에 올릴지는 호스트 스케줄러가 정한다. 그 가상 머신의 메모리를 실제로 떠받치는 호스트 쪽 페이지가 어느 노드에 있는지는 그것과 따로 정해진다. 둘이 다른 노드로 갈리면 게스트 안에서는 평범한 memory load 로 보이는 동작이 실제 하드웨어에서는 노드 사이 interconnect 를 건넌다.
이 물음이 받는 것은 그 어긋남이 성능을 바꾸는지가 아니다. 이 호스트의 가상 머신마다 vCPU 배치와 메모리 배치가 지금 어디에 놓여 있는지를 값으로 적는 데까지다.
이 호스트의 가상 머신마다 vCPU 가 도는 노드와 메모리가 놓인 노드가 같은지 다른지 적은 값이 없다. NUMA(Non-Uniform Memory Access)는 어느 CPU 가 어느 RAM 에 닿느냐에 따라 접근 비용이 달라지는 구조를 말한다. 이 물음은 지금 배치를 값으로 적는 데까지다. 그 어긋남이 지연을 바꾸는지는 다른 물음이 받는다.
## 관계
@@ -37,7 +35,7 @@ NUMA(Non-Uniform Memory Access)는 어느 CPU 가 어느 RAM 에 닿느냐에
## 사실
- 게스트 vCPU 는 호스트에서 QEMU 의 vCPU 스레드이고, 그 스레드는 호스트 Linux 스케줄러를 거쳐 호스트 논리 CPU 에서 실행된다.
- 어떤 가상 머신의 vCPU 스레드가 Node 0 의 CPU 에서 도는데 그 가상 머신의 호스트 쪽 physical backing page 가 Node 1 에 있으면 remote access 가 생길 수 있다. 게스트 안에서는 그것도 단순한 memory load 로 보다.
- 어떤 가상 머신의 vCPU 스레드가 Node 0 의 CPU 에서 도는데 그 가상 머신의 호스트 쪽 physical backing page 가 Node 1 에 있으면 remote access 가 생길 수 있다. 게스트 안에서는 그것도 단순한 memory load 로 보이지만 실제 하드웨어에서는 NUMA interconnect 를 건널 수 있다.
- vCPU 를 특정 노드의 CPU 에 pinning 해도 메모리가 다른 노드에 주로 배치되어 있으면 pinning 이후에도 remote memory access 가 많아질 수 있다. 그래서 vCPU 배치와 메모리 배치를 함께 본다.
- 개념 문서가 이상적인 예로 든 구성은 한 가상 머신의 vCPU 넷이 Node 0 의 CPU 에 붙고 그 가상 머신의 메모리 backing 도 Node 0 RAM 인 경우다.
- 개념 문서는 이 확인에 쓸 명령을 적어 두었다.
@@ -79,14 +77,14 @@ QEMU 프로세스마다 numastat -p 를 돌리고, 같은 시각에 virsh vcpuin
### 2. 부하를 준 상태에서 한 번 더 받는다
idle 상태의 배치만 보면 메모리가 아직 실제로 할당되지 않은 구간을 볼 수 있다. 게스트에서 workload 를 돌린 뒤 같은 두 값을 다시 받으면 실제로 쓰이는 메모리가 어느 노드에 잡히는지까지 나온다.
idle 상태의 배치만 보면 메모리가 아직 실제로 할당되지 않은 구간을 볼 수 있다. 게스트에서 워크로드를 돌린 뒤 같은 두 값을 다시 받으면 실제로 쓰이는 메모리가 어느 노드에 잡히는지까지 나온다.
실행이 두 번으로 늘고 어느 workload 를 쓸지 먼저 정해야 하는데, 그 선택이 결과를 바꾸기 때문에 workload 조건을 함께 적는다.
실행이 두 번으로 늘고 어느 워크로드를 쓸지 먼저 정해야 하는데, 그 선택이 결과를 바꾸기 때문에 Workload 조건을 함께 적는다.
### 3. 게스트 안에서 numactl 로 확인한다 — 제외
게스트에서도 NUMA 를 볼 수 있으니 게스트 안에서 확인하자는 방법이다.
게스트가 보는 토폴로지는 가상 머신에 노출된 것이 이 물음이 찾는 것은 호스트 쪽 physical backing 이 어느 노드에 있느냐이므로, 게스트 쪽 값으로는 그 배치를 알 수 없다.
게스트가 보는 토폴로지는 가상 머신에 노출된 것이다. 이 물음이 찾는 것은 호스트 쪽 physical backing 이 어느 노드에 있느냐이므로, 게스트 쪽 값으로는 그 배치를 알 수 없다.
개념 문서는 큰 가상 머신에서 게스트에게 NUMA 토폴로지 자체를 노출하고 게스트 노드와 호스트 배치가 대응되도록 구성할 수 있다고 적어 두었다. 이 프로젝트의 가상 머신이 그런 구성인지는 그 문서에 없어서, 게스트 값이 호스트 배치를 어디까지 반영하는지도 재기 전에는 모른다.
## 다음 검증
@@ -94,7 +92,7 @@ idle 상태의 배치만 보면 메모리가 아직 실제로 할당되지 않
1. 호스트가 다중 NUMA 노드라는 확인을 CPU 가상화 쪽 물음에서 먼저 받는다.
2. 가상 머신마다 QEMU 프로세스를 찾아 numastat -p <QEMU_PID> 로 노드별 메모리 분포를 찍는다.
3. 같은 시각에 virsh vcpuinfo <VM_NAME> 와 virsh vcpupin <VM_NAME> 로 vCPU 배치를 적는다.
4. 두 값을 가상 머신마다 한 표로 나란히 놓고, 노드가 같은지 갈리는지 적는다.
4. 두 값을 가상 머신마다 한 표로 나란히 놓고, 노드가 같은지 다른지 적는다.
5. Host · VM · Workload 조건을 같은 기록에 남긴다.
닫는 조건 : 노드별 메모리 분포와 vCPU 배치가 가상 머신마다 한 표로 적히면 닫는다. 둘이 같은 노드로 모여 있으면 개념 문서가 경고한 어긋남이 이 환경에는 없다고 적고 닫는다. 어긋나 있으면 그 표가 Case 가 되고, 그 어긋남이 지연까지 바꾸는지는 「NUMA remote access 가 이 작업의 지연을 실제로 바꾸는가」가 받는다. memory binding 을 걸지 말지는 그 뒤 Decision 으로 넘긴다. 단일 NUMA 노드로 나오면 이 물음은 이 환경에 적용되지 않는다고 적고 닫는다.
@@ -19,16 +19,14 @@ source:
# QEMU 프로세스가 실제로 붙잡고 있는 호스트 메모리는 얼마이고 어떻게 나뉘어 있는가
게스트 RAM 은 QEMU(Quick Emulator, 가상 머신을 실행하는 호스트 userspace 프로그램) 프로세스의 주소 공간 안에 마련된다고 §40 이 적었다. 그래서 이 가상 머신이 호스트 메모리를 얼마나 쓰고 있는가는 그 프로세스가 지금 얼마나 큰지를 읽는 물음이 된다.
이 물음은 실행 중인 가상 머신마다 QEMU 프로세스가 호스트에 얼마나 resident 한지, 그 값이 설정한 메모리(configured memory)와 얼마나 벌어져 있는지, 그리고 그 backing 이 anonymous 인지 huge page 인지를 적는다. 이 호스트에서 그 값을 읽은 기록은 없다.
이 호스트에서 QEMU 프로세스가 붙잡고 있는 메모리를 읽은 기록이 없다. 게스트 RAM 은 QEMU(Quick Emulator, 가상 머신을 실행하는 호스트 userspace 프로그램) 프로세스의 주소 공간 안에 마련된다고 §40 이 적었다. 그래서 이 가상 머신이 호스트 메모리를 얼마나 쓰고 있는가는 그 프로세스가 얼마나 큰지를 읽는 물음이 된다.
## 관계
- **Guest 메모리 주소가 물리 RAM 에 닿기까지 — GVA · GPA · HPA**
QEMU 가 마련한 호스트 주소 공간이 게스트 물리 주소와 어떻게 이어지는지를 그 기록이 설명한다.
- **이 호스트의 가상 머신들은 설정한 메모리와 지금 잡고 있는 메모리가 얼마나 다른가**
같은 접속에서 값을 받는다. 그쪽이 적는 설정 값을 옆에 놓아야 여기서 벌어짐을 적을 수 있다.
같은 접속에서 값을 받는다. 그쪽이 적는 설정 값을 옆에 놓아야 여기서 얼마나 벌어져 있는지 적을 수 있다.
- **가상 머신 RAM 이 HugeTLB 로 명시적으로 backing 되어 있는가**
여기서 huge page 항목이 보이면 그 물음으로 넘긴다.
- **메모리 실험 결과에는 Host · VM · Workload 조건을 함께 남긴다**
@@ -42,7 +40,7 @@ source:
§41 은 QEMU 가 마련한 호스트 userspace 메모리 영역이 게스트 GPA 의 어느 범위를 떠받치는지를 KVM 에 등록한다고 적고, 대표 ioctl 로 KVM_SET_USER_MEMORY_REGION 을 들었다. 역할은 셋으로 갈린다. QEMU 는 게스트 RAM 을 위한 호스트 userspace backing 을 내주고, KVM 은 게스트의 메모리 영역과 가상화 매핑을 관리하며, 실행 중의 주소 변환은 CPU 가 한다.
§42 는 설정한 메모리와 게스트가 현재 실제 사용하는 메모리, 호스트에서 현재 resident 한 물리 메모리 셋이 같지 않을 수 있다고 적었다. 그 차이를 만드는 것으로 호스트의 demand paging, backing 종류, HugeTLB, memory locking, preallocation, overcommit 정책을 들었다.
§42 는 설정한 메모리(configured memory)와 게스트가 현재 실제 사용하는 메모리, 호스트에서 현재 resident 한 물리 메모리 셋이 같지 않을 수 있다고 적었다. 그 차이를 만드는 것으로 호스트의 demand paging, backing 종류, HugeTLB, memory locking, preallocation, overcommit 정책을 들었다.
§83 의 OQ-3 은 확인 명령으로 ps -ef | grep qemu 와 ps -o pid,rss,vsz,cmd -p <QEMU_PID> 를 적었다. 필요하면 cat /proc/<QEMU_PID>/status 와 cat /proc/<QEMU_PID>/smaps_rollup 을 보고, 설정한 메모리와 RSS/anonymous/huge-page 상태를 비교하라고 했다.
@@ -74,7 +72,7 @@ smaps_rollup 을 읽을 권한이 있다고 전제한다. 다른 사용자의
이 호스트에서 QEMU 프로세스의 크기를 읽은 값이 없다. §40 의 8 GiB 와 §42 의 16 GiB 는 설명을 위한 예시이므로 이 환경의 값으로 옮겨 쓰지 않는다.
설정한 메모리를 옆에 놓지 않으면 벌어짐을 적을 수 없다. 그 값은 「이 호스트의 가상 머신들은 설정한 메모리와 지금 잡고 있는 메모리가 얼마나 다른가」가 받는다.
설정한 메모리를 옆에 놓지 않으면 얼마나 벌어져 있는지 적을 수 없다. 그 값은 「이 호스트의 가상 머신들은 설정한 메모리와 지금 잡고 있는 메모리가 얼마나 다른가」가 받는다.
§42 가 게스트 사용량과 호스트 resident 를 다른 값으로 갈라 놓았기 때문에, resident 크기 하나로는 게스트가 그 메모리를 지금 쓰고 있는지 알 수 없다.
@@ -105,7 +103,7 @@ backing 이 anonymous 인지 huge page 인지는 나오지 않는다. 그 항목
1. ps -ef | grep qemu 로 실행 중인 QEMU 프로세스의 PID 를 찾고 가상 머신 이름과 짝짓는다 (§83 OQ-3).
2. 프로세스마다 ps -o pid,rss,vsz,cmd -p <QEMU_PID> 를 찍는다.
3. cat /proc/<QEMU_PID>/status 와 cat /proc/<QEMU_PID>/smaps_rollup 을 남겨 anonymous 와 huge page 항목을 읽는다.
4. 같은 접속에서 설정한 메모리와 지금 쓰는 메모리를 묻는 물음의 값을 옆에 놓고 벌어짐을 적는다.
4. 같은 접속에서 설정한 메모리와 지금 쓰는 메모리를 묻는 물음의 값을 옆에 놓고 얼마나 벌어져 있는지 적는다.
5. 남기는 조건은 메모리 실험 조건 기준을 따른다.
닫는 조건 : 가상 머신마다 설정 값 · RSS · VSZ 와 anonymous/huge-page 구성이 한 표에 적히면 닫는다. RSS 가 설정 값에 크게 못 미치면 §42 가 말한 차이를 이 호스트에서 확인한 것이 되므로, 주소 변환 개념 기록의 확인 사례로 넣는다. huge page backing 이 보이면 HugeTLB backing 을 묻는 물음으로 넘긴다.
@@ -18,7 +18,7 @@ source:
# 지금 게스트와 호스트에서 swap 이 실제로 오가고 있는가
Swap Used 가 2 GiB 로 나와도 그것이 지금 메모리 압박이 심하다는 뜻은 아니다. 그 값이 과거에 swap-out 된 cold page 때문에 커져 있을 수도 있어서, §63 은 값이 얼마인가 대신 지금 swap-in/out 이 지속되는가를 물으라고 적었다. 이 물음은 그 질문을 이 호스트와 게스트에서 같은 시각에 던진다. 게스트 swap 과 호스트 swap 은 서로 다른 경로이므로 한쪽만 보고 다른 쪽을 짐작하지 않는다.
Swap Used 가 2 GiB 로 나와도 그것이 지금 메모리 압박이 심하다는 뜻은 아니다. 그 값이 과거에 swap-out 된 cold page 때문에 커져 있을 수도 있어서, §63 은 값이 얼마인가 대신 지금 swap-in/out 이 지속되는가를 물으라고 적었다. 이 물음은 그 질문을 이 호스트와 게스트 세 대에서 같은 시각에 던진다. 게스트 swap 과 호스트 swap 은 서로 다른 경로이므로 한쪽만 보고 다른 쪽을 짐작하지 않는다.
## 관계
@@ -46,14 +46,16 @@ Swap Used 가 2 GiB 로 나와도 그것이 지금 메모리 압박이 심하다
- §61 은 게스트 swap 을 게스트 애플리케이션에서 시작해 게스트 메모리 압박, 게스트 커널, 게스트 swap, /dev/vda, virtio-blk, QEMU 를 거쳐 호스트 스토리지로 내려가는 경로로 그렸다.
- §61 은 호스트 swap 을 게스트 RAM 이 QEMU 의 메모리 backing 을 거쳐 호스트 메모리 압박을 받고 호스트 커널이 호스트 swap 으로 내리는 경로로 그렸다. 같은 절이 두 경로를 같지 않다고 적고, 게스트가 메모리 여유가 있어 보이는데 호스트에서 swap 이나 reclaim 이 심할 수도 있다고 덧붙였다.
- 개념 문서는 vmstat 출력에서 어느 열을 swap-in 과 swap-out 으로 읽는지 적지 않았다.
- 호스트와 두 게스트에서 free -h 나 vmstat 를 찍은 기록이 개념 문서에 없다.
- 호스트에서 free 를 찍은 기록은 §178 과 §197 에 있다. 2026-09-10 에 test-server 에서 free -m 을 head -2 로 잘라 받은 출력이라 Mem 줄까지만 남아 있고 Swap 줄이 없다.
- 게스트 세 대에서 free 나 vmstat 를 찍은 기록도, 호스트에서 vmstat 를 돌린 기록도 개념 문서에 없다.
- §187 이 적은 게스트는 kc-lab-edge · kc-lab-1 · kc-lab-2 세 대다.
## 가정
- 호스트와 게스트에 동시에 접속해 같은 구간을 관측할 수 있다고 본다.
- 호스트와 게스트 세 대에 동시에 접속해 같은 구간을 관측할 수 있다고 본다.
- 관측하는 동안 실험용 부하를 따로 걸지 않고 평상시 상태를 재는 것으로 둔다. 그 상태가 이 호스트의 대표적인 상태인지는 한 번의 관측으로 알 수 없다.
- 게스트와 호스트의 시계가 맞아 두 기록을 같은 시간축에 놓을 수 있다고 전제한다.
- 두 가상 머신 모두 swap 영역을 갖고 있다고 보고 게스트 쪽을 읽는다. 설정에 swap 이 없으면 게스트 쪽 관측은 그 사실로 끝난다.
- 게스트 세 대 모두 swap 영역을 갖고 있다고 보고 게스트 쪽을 읽는다. 설정에 swap 이 없으면 게스트 쪽 관측은 그 사실로 끝난다.
## 미지수
@@ -61,6 +63,7 @@ Swap Used 가 2 GiB 로 나와도 그것이 지금 메모리 압박이 심하다
- Swap Used 가 0 이 아니라면 그 값이 과거에 내려간 cold page 때문인지 지금 진행 중인 swap 활동 때문인지.
- §63 이 든 나머지 셋 가운데 reclaim pressure 와 major fault 를 이 환경에서 어떤 값으로 읽는지. 그 둘을 읽는 명령은 개념 문서에 없다.
- 관측을 얼마나 오래 해야 지속 여부를 말할 수 있는지. 개념 문서는 vmstat 1 만 적고 관측 길이를 적지 않았다.
- 호스트에 swap 영역이 설정돼 있는지. §178 과 §197 의 free 출력이 Mem 줄까지만 잘려 있어 Swap 줄을 보지 못했다.
## 제약
@@ -75,9 +78,9 @@ Swap Used 가 2 GiB 로 나와도 그것이 지금 메모리 압박이 심하다
### 1. 게스트와 호스트에서 같은 구간을 동시에 관측한다
곳에서 free -h 를 한 번 찍어 기준을 남기고, vmstat 1 을 같은 시각에 시작해 같은 길이로 돌린다. §61 이 갈라 놓은 두 경로가 같은 시간축의 값으로 남아 한쪽만 움직이는 경우와 둘 다 움직이는 경우를 구분할 수 있다.
호스트와 게스트 세 대, 네 곳에서 free -h 를 한 번 찍어 기준을 남기고 vmstat 1 을 같은 시각에 시작해 같은 길이로 돌린다. §61 이 갈라 놓은 두 경로가 같은 시간축의 값으로 남아 한쪽만 움직이는 경우와 둘 다 움직이는 경우를 구분할 수 있다.
곳에 동시에 붙어야 하고 관측 길이를 먼저 정해야 한다.
곳에 동시에 붙어야 하고 관측 길이를 먼저 정해야 한다.
### 2. 호스트만 먼저 관측한다
@@ -91,9 +94,9 @@ free -h 한 번씩으로 끝내는 방법이다. §63 이 그 값만으로 판
## 다음 검증
1. 호스트와 각 게스트에서 free -h 를 한 번씩 찍어 그 시점의 Swap Used 를 기준으로 남긴다.
2. 곳에서 vmstat 1 을 같은 시각에 시작해 미리 정한 길이만큼 돌리고, 출력에서 swap-in 과 swap-out 으로 읽은 열의 이름을 함께 적는다.
1. 호스트와 각 게스트에서 free -h 를 한 번씩 찍어 그 시점의 Swap Used 를 기준으로 남긴다. 게스트는 kc-lab-edge · kc-lab-1 · kc-lab-2 세 대다.
2. 곳에서 vmstat 1 을 같은 시각에 시작해 미리 정한 길이만큼 돌리고, 출력에서 swap-in 과 swap-out 으로 읽은 열의 이름을 함께 적는다.
3. 같은 구간에서 §63 이 든 나머지 셋 가운데 읽을 수 있는 것 — reclaim pressure · major fault · storage latency — 을 어떤 명령으로 읽었는지와 함께 남긴다.
4. 기록을 같은 시간축에 놓고 어느 쪽에서 무엇이 움직였는지 적는다.
4. 기록을 같은 시간축에 놓고 어느 쪽에서 무엇이 움직였는지 적는다.
닫는 조건 : 관측 구간 내내 게스트와 호스트 모두 swap-in 과 swap-out 이 0 이면 지금은 swap 이 오가지 않는다고 적고 닫는다. Swap Used 가 0 이 아니어도 그렇게 적는다. 한쪽에서 swap 이 오가면 §61 의 두 경로 중 어느 쪽인지를 적고, 그 구간의 스토리지 지연과 게스트 지연을 함께 적어 「호스트 메모리 압박이 게스트 애플리케이션 지연을 실제로 밀어 올리는가」로 넘긴다.
@@ -18,7 +18,7 @@ source:
# 이 가상 머신들에 virtio-balloon 이 붙어 있는가
virtio-balloon 은 게스트 커널 쪽 드라이버와 QEMU 쪽 장치가 virtqueue 로 맞물려 동작한다. 장치가 없거나 게스트 쪽 드라이버가 올라와 있지 않으면 balloon target 을 바꾸는 실험 자체가 성립하지 않으므로, 동적 메모리 회수를 다루기 전에 이 확인이 먼저다. 개념 문서는 확인 명령까지만 적고 이 호스트의 설정은 읽지 않았다.
libvirt 설정에서 balloon 장치를 읽은 기록이 아직 없다. 다만 §198 의 virsh dommemstat 실측에서 kc-lab-2 의 현재 할당이 선언한 4096MB 가 아니라 3120MB 로 나왔다. 근거 문서는 그것을 virtio-balloon 이 회수해 간 것으로 보인다고 적었다. 장치가 없거나 게스트 쪽 드라이버가 올라와 있지 않으면 balloon target 을 바꾸는 실험 성립하지 않으므로, 설정과 게스트 쪽 상태를 읽는 이 확인이 먼저다.
## 관계
@@ -40,11 +40,18 @@ virtio-balloon 은 게스트 커널 쪽 드라이버와 QEMU 쪽 장치가 virtq
- §83 OQ-8 은 환경에 따라 드라이버 이름과 표시 방식이 달라질 수 있으므로 실제 장비에서 검증하라는 단서를 달았다.
그래서 개념 문서에는 게스트 쪽에서 무엇을 찾아야 하는지가 이름으로 적혀 있지 않다.
- §83 OQ-2 는 가상 머신 메모리 확인 명령으로 virsh dominfo <VM_NAME> · virsh dumpxml <VM_NAME> · virsh dommemstat <VM_NAME> 셋을 들었다.
- §198 은 VM 에 준 메모리가 상한이지 점유가 아니라고 적으면서 virtio-balloon 이 안 쓰는 만큼 호스트에 돌려준다고 덧붙였다.
- §198 이 virsh dommemstat 으로 k3s 게스트 두 대를 재서 아래 값을 받았다. k3s 만 떠 있고 Keycloak 은 올리기 전 상태다.
kc-lab-1 : 할당 5120MB · 실사용 353MB
kc-lab-2 : 할당 3120MB · 실사용 301MB
- kc-lab-2 는 virt-install --memory 4096 으로 만들었는데 현재 할당이 3120MB 로 나왔다. §198 은 그것을 virtio-balloon 이 회수해 간 것으로 보인다고 적고 단정하지 않았다.
- §198 은 dommemstat 의 actual 이 현재 할당이고 선언한 상한은 virsh dominfo 의 Max memory 에 있다고 적으면서, 이 실험대에서 그 두 값을 나란히 찍어 보지 않았다고 미측정으로 남겼다.
- §187 이 적은 게스트는 kc-lab-edge · kc-lab-1 · kc-lab-2 세 대이고, §198 의 측정에 kc-lab-edge 는 들어 있지 않다.
- 이 호스트의 가상 머신 설정에서 balloon 관련 요소를 읽은 기록이 개념 문서에 없다.
## 가정
- 두 가상 머신 모두 libvirt 로 정의되어 있어 virsh dumpxml 로 설정을 통째로 읽을 수 있다고 본다.
- 게스트 세 대 모두 libvirt 로 정의되어 있어 virsh dumpxml 로 설정을 통째로 읽을 수 있다고 본다.
- 설정에 balloon 장치가 있으면 게스트 쪽에도 대응하는 드라이버가 보인다고 전제한다. 그 전제가 이 게스트 배포판에서 맞는지는 확인하지 않았다.
- 게스트에 접속해 장치 목록이나 커널 모듈 상태를 읽을 수 있다고 본다.
@@ -54,10 +61,13 @@ virtio-balloon 은 게스트 커널 쪽 드라이버와 QEMU 쪽 장치가 virtq
- 있다면 게스트 안에서 그 드라이버가 실제로 올라와 있는지.
- 이 환경에서 그 드라이버나 장치가 어떤 이름으로 보이는지. 개념 문서가 환경마다 다를 수 있다고만 적고 이름을 남기지 않았다.
- 게스트 쪽 상태를 어떤 명령으로 읽는지. 개념 문서가 게스트 확인 명령을 적지 않아 실행하는 쪽이 정한다.
- kc-lab-2 의 현재 할당이 선언값보다 작은 것이 balloon 회수 때문인지 다른 경로로 정해진 값인지. §332 는 virsh setmem 이 현재 할당을 바꾸는 명령이라고 적었다. §187 은 게스트 메모리를 3584MB 에서 5120MB 와 4096MB 로 재배분했다고 적었다.
- kc-lab-edge 의 값. §198 이 그 게스트를 재지 않았다.
## 제약
- 이 물음에서 balloon target 을 움직이지 않는다. 구성 여부를 적는 것이 전부다.
- §198 의 값은 설정 확인을 대신하지 않는다. 근거 문서가 회수해 간 것으로 보인다고 적은 것을 이 물음이 관측으로 올리지 않는다.
- 설정에 장치가 있다는 것만으로 동작한다고 적지 않는다. §83 OQ-8 이 게스트 쪽 상태도 확인하라고 적었다.
- 설정과 드라이버가 둘 다 보여도 호스트가 backing 을 실제로 언제 회수하는지는 이 확인으로 알 수 없다. §67 이 정확한 Host-side release 동작은 QEMU/KVM 버전, backing 종류 및 설정에 따라 달라질 수 있다고 적고 그 동작을 단정하지 않았다. 개념 문서가 단정하지 않은 것을 이 물음이 대신 단정하지 않는다.
- 게스트 쪽에서 쓴 명령과 그 출력을 그대로 남긴다. 이름이 환경마다 다르므로 다음 사람이 같은 것을 찾으려면 무엇을 봤는지가 필요하다.
@@ -70,18 +80,19 @@ virtio-balloon 은 게스트 커널 쪽 드라이버와 QEMU 쪽 장치가 virtq
가상 머신마다 virsh dumpxml 을 찍어 balloon 관련 요소를 인용하고, 이어서 각 게스트에서 드라이버나 장치 상태를 읽어 실제로 보이는 이름과 함께 적는다. §83 OQ-8 이 요구한 두 확인이 한 번에 끝난다.
게스트 대에 접속해야 하고, 게스트 쪽 확인 명령을 실행하는 쪽이 정해야 한다.
게스트 대에 접속해야 하고, 게스트 쪽 확인 명령을 실행하는 쪽이 정해야 한다.
### 2. 호스트 설정만 읽고 넘어간다
virsh dumpxml 두 번으로 끝난다. 설정에 balloon 장치가 없으면 게스트 쪽을 볼 이유가 없어 이 물음이 거기서 닫힌다.
게스트마다 virsh dumpxml 을 한 번씩 치는 것으로 끝난다. 설정에 balloon 장치가 없으면 게스트 쪽을 볼 이유가 없어 이 물음이 거기서 닫힌다.
장치가 있는 것으로 나오면 게스트 쪽 드라이버가 올라와 있는지를 확인하러 다시 가야 한다.
## 다음 검증
1. 가상 머신마다 virsh dumpxml <VM_NAME> 을 남기고 balloon 관련 요소를 그대로 인용한다.
1. 가상 머신마다 virsh dumpxml <VM_NAME> 을 남기고 balloon 관련 요소를 그대로 인용한다. 게스트는 kc-lab-edge · kc-lab-1 · kc-lab-2 세 대다.
2. 각 게스트에서 balloon 관련 드라이버나 장치 상태를 확인하고, 실행한 명령과 실제로 보인 이름을 함께 적는다.
3. 같은 실행에서 virsh dommemstat <VM_NAME> 출력도 받아 남긴다. 「이 호스트의 가상 머신들은 설정한 메모리와 지금 잡고 있는 메모리가 얼마나 다른가」가 같은 출력을 읽는다.
3. 같은 실행에서 virsh dommemstat <VM_NAME> 과 virsh dominfo <VM_NAME> 을 나란히 찍어 현재 할당과 Max memory 를 함께 남긴다. §198 이 dommemstat 만 찍어 그 둘을 나란히 보지 않았다.
4. 세 게스트의 값을 같은 표에 적는다. 「이 호스트의 가상 머신들은 설정한 메모리와 지금 잡고 있는 메모리가 얼마나 다른가」가 같은 출력을 읽는다.
닫는 조건 : 설정과 게스트 쪽 상태가 둘 다 적히면 닫는다. 붙어 있지 않으면 이 환경에서는 ballooning 이 동작하지 않는다고 적고 「balloon target 을 바꾸면 게스트가 쓸 수 있는 메모리는 어떻게 따라 움직이는가」는 열지 않은 채로 둔다. 장치가 없으면 그 실험이 성립하지 않는다. 붙어 있으면 그 물음으로 넘긴다.
@@ -18,9 +18,7 @@ source:
# 이 호스트의 가상 머신들은 설정한 메모리와 지금 잡고 있는 메모리가 얼마나 다른가
§42 는 가상 머신에 설정한 메모리와 게스트가 지금 실제로 쓰는 메모리, 그리고 호스트에서 지금 resident 한 물리 메모리를 서로 다른 세 값으로 갈라 놓았다. 이 물음은 그 이 이 호스트에서 각각 얼마인지를 가상 머신마다 적는다.
세 값이 벌어져 있는지에 따라 다음에 무엇을 잴지가 갈린다. 설정한 총량이 호스트 RAM 을 넘으면 §57 이 서술한 overcommit 구성에 이 환경이 들어가고, 넘지 않으면 그 절의 시나리오는 여기 걸리지 않는다. 이 호스트의 물리 RAM 도 각 가상 머신에 설정한 메모리도 개념 문서에 적혀 있지 않다.
호스트 RAM 11,648MiB 와 게스트 배정 합 10,240MB 가 실측으로 나왔고 배정 합이 더 작다. 게스트가 지금 실제로 쓰는 메모리 호스트에서 resident 한 물리 메모리는 아직 안 쟀다. 이 물음은 그 을 가상 머신마다 적어 설정 값과 얼마나 벌어지는지를 본다.
## 관계
@@ -45,11 +43,21 @@ Host 에서 현재 resident 한 Physical Memory
세 값을 갈라 놓는 요인으로 §42 가 든 것은 호스트의 demand paging, backing 종류, HugeTLB, memory locking, preallocation, overcommit 정책이다. 그래서 가상 머신 RAM 16 GiB 를 호스트 RAM 에서 고정된 연속 16 GiB 로 단순화하면 안 된다고 같은 절이 적었다.
§57 은 게스트에 설정한 메모리 총량이 호스트 물리 RAM 보다 큰 구성이 가능할 수 있는 이유를, 설정 용량과 현재 실제 working set 또는 resident 메모리가 같지 않을 수 있다는 데서 찾았다. 다만 모든 가상 머신의 실제 수요가 동시에 증가하면 문제가 발생한다고 이어 적었다.
설정 용량과 현재 실제 working set 또는 resident 메모리가 같지 않을 수 있기 때문에, 게스트에 설정한 메모리 총량이 호스트 물리 RAM 보다 큰 구성도 가능할 수 있다고 §57 이 적었다. 다만 모든 가상 머신의 실제 수요가 동시에 증가하면 문제가 발생한다고 이어 적었다.
§83 의 OQ-2 는 확인 명령을 호스트 쪽과 게스트 쪽으로 나눠 적었다. 호스트에서는 virsh dominfo <VM_NAME> 과 virsh dumpxml <VM_NAME>, virsh dommemstat <VM_NAME> 을 쓰고, 게스트에서는 free -h 와 cat /proc/meminfo 를 쓴 뒤 호스트의 QEMU 프로세스 상태와 비교한다.
이 호스트의 물리 RAM 과 각 가상 머신에 설정한 메모리를 적은 값은 개념 문서 어디에도 없다. §57 의 32 GiB 와 16 GiB, §42 의 16 GiB 는 설명을 위한 예시다.
이 호스트의 물리 RAM 은 §178 이 실측으로 적었다. 2026-09-10 에 test-server 에서 free -m 으로 받은 출력의 Mem 줄 total 이 11648 이고, free -m 은 MiB 단위라 11,648MiB 다.
게스트 세 대에 배정한 메모리는 §187 이 적었다.
kc-lab-edge : 1024MB
kc-lab-1 : 5120MB
kc-lab-2 : 4096MB
배정 합은 10,240MB 다. 호스트 RAM 11,648MiB 보다 작아서 배정률은 10240/11648 = 87.9% 이고, 배정하지 않고 남은 것이 1,408MiB 다. 그래서 「Guest configured memory 총량이 Host physical RAM보다 크다」는 §57 의 조건에 이 배치는 해당하지 않는다.
§57 의 32 GiB 와 16 GiB, §42 의 16 GiB 는 설명을 위한 예시라 이 환경의 값이 아니다.
§85 가 실험마다 함께 남기라고 적은 VM 조건에 Configured RAM 과 Current RAM 이 들어 있다. 이 물음이 내는 값은 여기서 한 번 쓰고 마는 것이 아니라 뒤따르는 메모리 실험의 조건 칸으로 그대로 들어간다.
@@ -65,21 +73,19 @@ Host 에서 현재 resident 한 Physical Memory
## 미지수
이 호스트의 물리 RAM 은 얼마인가.
지금 각 게스트가 실제로 쓰고 있는 메모리는 얼마인가.
각 가상 머신에 설정한 메모리는 얼마이고, 그 합이 호스트 RAM 을 넘는가.
호스트에서 그 가상 머신 몫으로 resident 한 메모리는 얼마인가.
넘든 넘지 않든, 지금 각 게스트가 실제로 쓰고 있는 메모리는 얼마이고 호스트에서 그 가상 머신 몫으로 resident 한 메모리는 얼마인가.
세 값이 설정 값과 얼마나 벌어져 있는가.
그 두 값이 배정한 값과 얼마나 벌어져 있는가.
## 제약
이 호스트에서 잰 값이 없다. §42 와 §57 이 든 숫자는 개념을 보이려고 든 예시이므로 이 환경의 값으로 옮겨 쓰지 않는다.
게스트 사용량과 호스트 resident 는 이 호스트에서 잰 값이 없다. §42 와 §57 이 든 숫자는 개념을 보이려고 든 예시이므로 이 환경의 값으로 옮겨 쓰지 않는다.
§84 의 권장 실험 순서는 가상 머신 설정 값 확인과 게스트 free/meminfo 확인을 둘째와 셋째로 나눠 적었다. 여기서는 그 둘을 한 물음으로 묶는다. 이 물음이 답할 것이 세 값의 차이라서 호스트 쪽과 게스트 쪽을 다른 시각에 찍으면 그 차이가 어느 시점의 것인지 말할 수 없다.
세 값을 서로 다른 시각에 찍으면 비교가 성립하지 않는다. 게스트 사용량은 workload 가 달라지면 같이 움직인다.
세 값을 서로 다른 시각에 찍으면 비교가 성립하지 않는다. 게스트 사용량은 워크로드가 달라지면 같이 움직인다.
이 물음은 설정한 값과 실제 사용량의 차이를 적는 데까지다. 그 차이가 있을 때 무엇이 일어나는지는 swap 과 호스트 메모리 압박을 다루는 물음들이 받는다.
@@ -89,15 +95,15 @@ QEMU 프로세스 쪽 값은 여기서 닫지 않는다. virsh 가 보고하는
### 1. 실행 중인 가상 머신 전부를 한 번에 찍는다
virsh list 로 실행 중인 가상 머신을 세고, 각각에 대해 호스트 세 명령과 게스트 두 명령을 같은 시각에 돌린다. 설정 총량과 호스트 RAM 을 그 자료 하나로 견줄 수 있어서 overcommit 여부가 이 실행에서 정해진다.
virsh list 로 실행 중인 가상 머신을 세고, 가상 머신마다 호스트 세 명령과 게스트 두 명령을 같은 시각에 돌린다. 게스트 사용량과 호스트 resident 를 가상 머신마다 같은 시각의 값으로 받는다.
가상 머신이 여럿이면 명령 수가 늘어 같은 시각을 지키기 어려워진다. 그럴 때는 호스트 쪽을 먼저 한 번에 돌리고 게스트 쪽을 이어서 돌린 뒤 두 시각을 함께 적는다.
### 2. 가상 머신 하나로 표 모양을 먼저 정하고 나머지로 넓힌다
한 대에 대해 다섯 명령을 돌려 세 값을 어디서 읽는지 확정한 다음 나머지에 같은 순서를 적용한다. 값을 잘못 읽어 표를 다시 만드는 일을 줄인다.
한 대에 다섯 명령을 돌려 세 값을 어디서 읽는지 확정한 다음 나머지에 같은 순서를 적용한다. 값을 잘못 읽어 표를 다시 만드는 일을 줄인다.
두 번 붙어야 하고, 첫 실행과 두 번째 실행 사이에 게스트 사용량이 달라진다. overcommit 판정에 필요한 설정 값은 그 사이에 바뀌지 않으므로 판정 자체는 갈리지 않는다.
두 번 붙어야 하고, 첫 실행과 두 번째 실행 사이에 게스트 사용량이 달라진다. 이 물음에 남은 것이 사용량과 resident 두 값뿐이라 그 차이가 답에 그대로 들어간다.
### 3. QEMU 프로세스 값까지 이번에 함께 읽는다 — 제외
@@ -110,7 +116,7 @@ QEMU 쪽은 별도의 물음이 이미 받고 있고 그쪽은 anonymous 인지
1. virsh list 로 실행 중인 가상 머신 이름을 적는다.
2. 가상 머신마다 호스트에서 virsh dominfo <VM_NAME> 으로 configured/current memory 를, virsh dumpxml <VM_NAME> 으로 memory backing 설정을, virsh dommemstat <VM_NAME> 으로 balloon 계열 값을 남긴다 (§83 OQ-2).
3. 같은 시각에 각 게스트에서 free -h 와 cat /proc/meminfo 를 찍는다.
4. 호스트의 free -h 도 함께 남겨 물리 RAM 과 현재 여유를 적는다.
4. 호스트의 free -h 도 같은 시각에 남겨 그 시점의 여유를 적는다.
5. 남기는 조건은 메모리 실험 조건 기준을 따른다. QEMU 프로세스의 resident 값은 QEMU 쪽 물음이 받는다.
닫는 조건 : 설정 값 · 게스트 사용량 · 호스트 resident 세 값을 가상 머신마다 한 표로 적으면 닫는다. 설정 총량이 호스트 RAM 을 넘으면 이 환경이 overcommit 상태라는 사실을 확정한 것이 되고, 그 상태에서 무엇이 일어나는지는 swap 을 묻는 물음과 호스트 메모리 압박을 묻는 물음이 받는다. 넘지 않으면 §57 의 시나리오가 이 환경에 적용되지 않는다.
닫는 조건 : 설정 값 · 게스트 사용량 · 호스트 resident 세 값을 가상 머신마다 한 표로 적으면 닫는다. 설정 총량이 호스트 RAM 을 넘는지는 §178 과 §187 이 이미 답했고 넘지 않아서 §57 의 시나리오는 이 환경에 적용되지 않는다. 호스트 메모리가 압박을 받을 때 무엇이 일어나는지는 swap 을 묻는 물음과 호스트 메모리 압박을 묻는 물음이 받는다.
@@ -18,7 +18,7 @@ source:
# 가상 머신 RAM 이 HugeTLB 로 명시적으로 backing 되어 있는가
HugeTLB 는 관리자가 미리 잡아 둔 Huge Page pool 을 가상 머신이나 애플리케이션이 명시적으로 지정해 쓰는 방식이다. 이 호스트의 두 가상 머신이 그 방식으로 RAM 을 받고 있는지는 libvirt 설정 한 곳만 봐서는 끝나지 않다. §83 OQ-5 가 설정을 읽은 뒤 호스트 /proc/meminfo, QEMU smaps 계열과 교차 검증하라고 적었기 때문이다. 세 자료가 서로 맞는지까지 확인해야 이 환경이 어느 쪽으로 backing 되어 있는지가 정해진다.
HugeTLB 는 관리자가 미리 잡아 둔 Huge Page pool 을 가상 머신이나 애플리케이션이 명시적으로 지정해 쓰는 방식이다. 이 호스트의 게스트 세 대가 그 방식으로 RAM 을 받고 있는지는 아직 읽지 않다. §83 OQ-5 가 libvirt 설정을 읽은 뒤 호스트 /proc/meminfo, QEMU smaps 계열과 교차 검증하라고 적어서 설정 한 곳만 봐서는 끝나지 않는다.
## 관계
@@ -45,13 +45,15 @@ HugeTLB 는 관리자가 미리 잡아 둔 Huge Page pool 을 가상 머신이
- §83 OQ-5 는 확인 명령으로 virsh dumpxml <VM_NAME> 을 들고, libvirt memory backing 관련 설정을 확인한 뒤 호스트 /proc/meminfo, QEMU smaps 계열과 교차 검증하라고 적었다.
- §83 OQ-3 이 QEMU 프로세스 쪽 명령으로 cat /proc/<QEMU_PID>/smaps_rollup 을 들었다.
smaps 계열에서 huge page 사용량을 어느 항목 이름으로 읽는지는 개념 문서가 적지 않았다.
- 세 자료 가운데 호스트 쪽은 항목 이름이 적혀 있다. §56 호스트 확인 명령을 적으면서 AnonHugePages 와 HugePages_Total 같은 의미가 아니라고 못 박았고, §83 OQ-4 도 THP policy · AnonHugePages · HugePages_Total · HugePages_Free · Hugepagesize 를 확인 항목으로 들었다. 이름이 없는 것은 QEMU smaps 쪽 하나다.
- 이 호스트의 libvirt 설정을 읽은 기록이 개념 문서에 없다.
- 세 자료 가운데 호스트 쪽은 항목 이름이 적혀 있다. §56 호스트 확인 명령을 적으면서 AnonHugePages 와 HugePages_Total 같은 의미가 아니라고 못 박았다. §83 OQ-4 도 THP policy · AnonHugePages · HugePages_Total · HugePages_Free · Hugepagesize 를 확인 항목으로 들었다. 이름이 없는 것은 QEMU smaps 쪽 하나다.
- §187 이 적은 게스트는 kc-lab-edge · kc-lab-1 · kc-lab-2 세 대이고 배정한 메모리는 차례로 1024MB · 5120MB · 4096MB 다.
- §197 이 이 실험대의 libvirt 12.7.0 과 QEMU 11.1.1, 커널 7.2.2-arch1-1 을 적었다.
- 이 호스트의 libvirt 설정을 읽은 기록이 개념 문서에 없다. 호스트 /proc/meminfo 의 huge page 값을 찍은 기록도 없다. huge page 와 THP, HugeTLB 는 SSOT 제2부에서만 서술되고, 실험대를 세우고 값을 잰 제5~9부에는 그 이름이 한 번도 나오지 않는다. 그래서 이 물음이 다시 읽을 기존 출력이 없고 세 자료를 새로 찍어야 한다.
## 가정
- 두 가상 머신 모두 libvirt 로 정의되어 있어 virsh dumpxml 로 설정을 통째로 읽을 수 있다고 본다.
- 설정에 memory backing 관련 요소가 없으면 명시적 HugeTLB backing 을 쓰지 않는 것으로 읽는다. 그 해석이 libvirt 버전에서 맞는지는 확인하지 않았다.
- 게스트 세 대 모두 libvirt 로 정의되어 있어 virsh dumpxml 로 설정을 통째로 읽을 수 있다고 본다.
- 설정에 memory backing 관련 요소가 없으면 명시적 HugeTLB backing 을 쓰지 않는 것으로 읽는다. 그 해석이 §197 이 적은 libvirt 12.7.0 에서 맞는지는 확인하지 않았다.
- 세 자료를 같은 시각에 찍으면 같은 순간의 상태를 가리킨다고 전제한다.
- QEMU 프로세스 번호를 가상 머신마다 가려낼 수 있다고 본다.
@@ -74,7 +76,7 @@ HugeTLB 는 관리자가 미리 잡아 둔 Huge Page pool 을 가상 머신이
### 1. libvirt 설정부터 읽고 backing 항목이 없으면 거기서 끝낸다
virsh dumpxml 을 번 찍는 것으로 시작한다. 설정 모두 memory backing 관련 요소가 없으면 명시적 HugeTLB backing 이 아니라는 답이 거의 정해지고, 호스트 HugePages_Total 이 0 인지만 확인하면 닫힌다.
게스트마다 virsh dumpxml 을 찍는 것으로 시작한다. 설정 모두 memory backing 관련 요소가 없으면 명시적 HugeTLB backing 이 아니라는 답이 거의 정해지고, 호스트 HugePages_Total 이 0 인지만 확인하면 닫힌다.
설정에 항목이 있으면 결국 세 자료를 다 받아야 하므로 호스트와 QEMU 쪽을 다시 찍으러 간다.
@@ -82,7 +84,7 @@ virsh dumpxml 을 두 번 찍는 것으로 시작한다. 두 설정 모두 memor
설정과 호스트 /proc/meminfo, QEMU smaps_rollup 을 같은 시각에 받아 나란히 둔다. OQ-5 가 요구한 교차 검증이 한 번의 실행으로 끝나고, 설정과 실제 값이 어긋나는 경우에도 그 어긋남이 같은 시각의 자료로 남는다.
QEMU 프로세스 번호를 먼저 가려내야 하고, 두 가상 머신이 같은 시각에 켜져 있어야 한다.
QEMU 프로세스 번호를 먼저 가려내야 하고, 게스트 세 대가 같은 시각에 켜져 있어야 한다.
### 3. 호스트 HugePages_Total 하나로 가른다 — 제외
@@ -20,9 +20,7 @@ source:
# 메모리 증상 하나로 계층을 단정하지 않는다
§86 은 메모리 지연이나 OOM(Out Of Memory, 커널이 필요한 메모리를 확보하지 못해 프로세스를 종료할 수 있는 상태)이 보일 때 한 번에 「메모리 부족」이라고 결론내리지 말적었다. 대신 증상을 먼저 다섯 갈래로 가르는 분류를 두었다. Guest Virtual Memory · Virtualization Translation · Host Memory · Dynamic Memory · NUMA 다섯이다. 마지막의 NUMA(Non-Uniform Memory Access, 비균일 메모리 접근)는 어느 CPU 가 어느 RAM 에 접근하느냐에 따라 비용이 달라질 수 있는 구조다.
같은 요구가 개념 문서 안에서 세 번 더 나온다. Swap Used 값 하나로 판단하지 않고(§63), NUMA 최적화를 토폴로지를 재기 전에 정하지 않으며(§78), 게스트 하나의 free -h 만 보고 메모리 상태를 판단하지 않는다(§88). 이 기준은 그 넷을 한 판독 절차로 묶는다. 뒤의 셋을 따로 규칙으로 세우지 않은 것은 보는 대상만 다를 뿐 §86 과 같은 말을 하기 때문이다. 규칙이 세 벌이면 판독하는 사람이 어느 것을 따르는지가 갈린다.
메모리 지연이나 OOM 이 보이면 「메모리 부족」으로 닫지 말고 §86 의 다섯 갈래로 먼저 가른다. Guest Virtual Memory · Virtualization Translation · Host Memory · Dynamic Memory · NUMA 다섯이다. §63 · §78 · §88 이 따로 요구한 판독도 이 한 절차로 묶었다.
## 관계
@@ -43,10 +41,12 @@ source:
## 목적
이 기준은 증상 하나를 원인으로 바로 옮기는 판독을 막는다. §86 이 든 예가 메모리 지연과 OOM 인데, 둘 다 다섯 계층 어디서든 나올 수 있다.
이 기준은 증상 하나를 원인으로 바로 옮기는 판독을 막는다. §86 이 든 예가 메모리 지연과 OOM(Out Of Memory, 커널이 필요한 메모리를 확보하지 못해 프로세스를 종료할 수 있는 상태)인데, 둘 다 다섯 계층 어디서든 나올 수 있다.
다섯 갈래를 개념 한 편 안에 넣지 않고 따로 세운 것은 갈래마다 받는 개념이 다르기 때문이다. 어느 한 개념 안에 두면 그 글이 나머지 넷을 가리키지 못한다.
§63 과 §78, §88 을 따로 기준으로 세우지 않은 것은 보는 대상만 다를 뿐 §86 과 같은 말을 하기 때문이다. 규칙이 세 벌이면 판독하는 사람이 어느 것을 따르는지가 갈린다. Swap Used 값 하나로 판단하지 않는 것(§63), 토폴로지를 재기 전에 NUMA 최적화를 정하지 않는 것(§78), 게스트 하나의 free -h 로 닫지 않는 것(§88)은 각각 2 번과 5 번, 6 번 규칙이다.
좁히는 것과 확정하는 것을 갈라 둔 이유도 여기 있다. 이 기준은 어느 갈래인지까지만 좁히고, 원인 확정은 §85 의 조건을 함께 남긴 측정이 한다.
## 규칙
@@ -61,6 +61,8 @@ Host Memory : Host reclaim · Host swap · Host major fault · Host OOM
Dynamic Memory : Balloon target · Guest pressure · Hotplug/virtio-mem 여부
NUMA : vCPU placement · memory placement · remote access
마지막 갈래의 NUMA(Non-Uniform Memory Access, 비균일 메모리 접근)는 어느 CPU 가 어느 RAM 에 접근하느냐에 따라 비용이 달라질 수 있는 구조다.
### 2. Swap Used 값 하나로 메모리 압박을 단정하지 않는다
§63 은 Swap Used = 2 GiB 라는 값만으로 지금 메모리 압박이 심하다고 단정할 수 없다고 적었다. 과거에 swap-out 된 cold page 가 남아 있을 수도 있기 때문이다.
@@ -96,6 +98,8 @@ cgroup memory limit 이 걸린 환경은 예외로 둔다. 호스트 전체 RAM
§88 은 실제 테스트 서버에서 게스트 하나의 free -h 만 보고 메모리 상태를 판단하지 않는다고 적었다. Guest → QEMU → Host → NUMA → Storage 영향을 같은 시간축에서 관측해야 한다고 했다.
이 실험대에는 아직 그 시간축의 한쪽만 있다. Prometheus 가 긁는 node-exporter 는 게스트 안에서 돌아 게스트 커널이 내놓는 값을 읽고, 호스트 쪽 지표는 긁지 않는다(§192 · §193). 거기서 나오는 메모리와 스왑 값은 전부 게스트가 본 것이라, 여기서 이 규칙을 지키려면 호스트에서 재는 값을 따로 들고 와야 한다.
## 적용 조건
메모리 증상을 원인으로 옮기려는 판독 전부에 걸린다. 지연 증가, 스왑 관측, OOM, fault 증가가 여기 들어간다.
@@ -106,6 +110,8 @@ cgroup memory limit 이 걸린 환경은 예외로 둔다. 호스트 전체 RAM
cgroup memory limit 이 걸린 환경에서는 호스트 전체 RAM 에 여유가 있어도 그 경계에서 OOM 이 나기 때문에, Host Memory 갈래를 곧바로 지우면 안 된다(§72).
이 실험대의 Keycloak 은 K3s 파드로 돈다. 파드가 죽었을 때 describe 의 Exit Code 가 137 이면 OOM 이나 강제 종료이고 그때 보는 것은 파드의 메모리 한도라고 §191 이 적었는데, 그 한도가 얼마로 걸려 있는지는 매니페스트가 source/ 에 없어 대조할 방법이 지금은 없다(§194).
NUMA node 가 하나인 호스트에서는 NUMA 갈래의 우선순위를 낮춰도 된다고 §78 이 적었다. 그것도 토폴로지를 잰 뒤의 이야기다.
이 기준은 갈래를 좁힐 뿐 원인을 확정하지 않는다. 확정은 §85 의 조건을 남긴 측정이 한다.
@@ -19,8 +19,6 @@ source:
메모리 측정은 값만 남기면 다음 사람이 그 값을 어디에 쓸 수 있는지 알 수 없다. §85 는 실험마다 함께 적을 조건을 Host 여덟 · VM 일곱 · Workload 다섯으로 못박았다. 이유도 한 줄로 적었는데, 조건을 남기지 않으면 「Memory pressure에서 느려졌다」는 결과를 다른 환경에 재사용하기 어렵다는 것이다.
이 기준은 그 세 묶음을 메모리 측정 기록의 필수 항목으로 둔다. 어느 단계에서 그 값을 얻는지는 §84 의 권장 실험 순서에서 가져왔다. 이 주제의 열린 물음 열둘이 전부 같은 규칙에 걸려서, 조건 목록을 물음마다 되풀이하는 대신 여기 한 편에 두고 각 물음이 가리킨다.
## 관계
- **메모리 증상 하나로 계층을 단정하지 않는다**
@@ -58,6 +56,8 @@ vCPU, Configured RAM, Current RAM, Memory backing 설정, Balloon device, Guest
§42 는 configured memory 와 게스트가 지금 실제로 쓰는 메모리, 호스트에서 지금 resident 인 물리 메모리 셋이 같지 않을 수 있다고 적었다. RAM 을 한 값으로 줄여 적으면 나중에 그 숫자가 셋 가운데 무엇이었는지 알 수 없다.
Configured RAM 도 한 번 정하고 끝나는 값이 아니다. 이 실험대의 게스트 메모리는 처음 만들 때 3584MB 였고 실험을 늘리며 5120/4096 으로 재배분했는데(§187), 언제 재배분했는지는 그 절이 적지 않았다.
### 3. Workload 조건 다섯 항목을 적는다
Application, Heap/Memory 설정, Request concurrency, DB workload, 측정 시간 다섯이다.
@@ -80,7 +80,7 @@ Application, Heap/Memory 설정, Request concurrency, DB workload, 측정 시간
## 적용 조건
메모리 관련 측정을 남기는 실험 전부에 걸린다. §83 의 OQ-1 부터 OQ-14 까지가 여기 들어가고, 이 주제의 열린 물음 열둘도 같은 규칙을 따른다.
메모리 관련 측정을 남기는 실험 전부에 걸린다. §83 의 OQ-1 부터 OQ-14 까지가 여기 들어가고, 이 주제의 열린 물음 열둘도 같은 규칙을 따른다. 세 묶음은 그 물음마다 되풀이하지 않고 여기 한 편에 두었고, 각 물음이 이 기준을 가리킨다.
남길 조건은 세 묶음이다.
@@ -94,7 +94,7 @@ Workload : Application · Heap/Memory 설정 · Request concurrency · DB worklo
baseline 과 부하 구간을 비교하는 실험에서는 세 묶음을 두 시점에 각각 남긴다. 한 번만 남기면 비교한 두 시점 가운데 한쪽의 조건이 빈다.
이 프로젝트는 조건 목록을 실제 장비 값으로 채운 적이 없다. §85 가 준 것은 항목 이름이고 값은 아직 없다.
이 프로젝트는 조건 목록을 측정 하나에 붙여 채운 적이 없다. 값이 아예 없지는 않다 — §178 이 호스트를 i5-1135G7(논리 코어 8) · RAM 11,648MiB · QEMU 11.1.1 · libvirt 12.7.0 으로 적었고 §187 이 게스트 세 대의 vCPU 와 메모리를 적었다. 채우려고 잰 것이 아니라 실험대를 세우며 적힌 값이고, NUMA topology 와 Swap 설정, Kernel version, THP policy, Physical storage 는 아직 없다.
## 예시
@@ -50,7 +50,7 @@ source:
# Guest 의 packet 이 Host Physical NIC 에 닿기까지 — virtio-net · virtqueue · vhost-net · TAP · Bridge
가상 머신 안의 Keycloak 이 HTTP 요청 하나를 받으려면 그 패킷이 먼저 호스트의 물리 NIC 에 닿아야 한다. 거기서 Linux Bridge 와 TAP, vhost-net, virtqueue 를 지나 게스트 커널의 TCP/IP 스택까지 올라다. virtio-net 은 그 경로 어딘가에 놓인 프로그램 하나가 아니다. 게스트 쪽 프런트엔드 드라이버와 호스트 쪽 백엔드를 잇는 I/O 계약이고, 그 백엔드 자리는 QEMU 사용자 공간이 맡을 수도 호스트 커널의 vhost-net 이 맡을 수도 있다. 가상 머신 두 대에서 Keycloak 멀티 노드 실험을 돌리 요청이 느려지거나 아예 닿지 않는 일이 생긴다. 이 경로를 알아 두면 그 증상을 애플리케이션·저장소 쪽 문제와 네트워크 가상화 계층 문제로 갈라 볼 수 있다.
가상 머신 안의 Keycloak 이 HTTP 요청 하나를 받으려면 그 패킷이 호스트의 물리 NIC 에서 Linux Bridge 와 TAP, vhost-net, virtqueue 를 지나 게스트 커널의 TCP/IP 스택까지 올라와야 한다. virtio-net 은 그 경로에 놓인 프로그램 하나가 아니 게스트 쪽 프런트엔드 드라이버와 호스트 쪽 백엔드를 잇는 I/O 계약이고, 그 백엔드 자리는 QEMU 사용자 공간이 맡을 수도 호스트 커널의 vhost-net 이 맡을 수도 있다. 가상 머신 두 대 Keycloak 멀티 노드 실험을 돌리 요청이 느려지거나 아예 닿지 않으면, 이 경로를 알아 그 증상을 애플리케이션·저장소 쪽 문제와 네트워크 가상화 계층 문제로 갈라 볼 수 있다.
## 관계
@@ -59,7 +59,7 @@ source:
- **Keycloak 실험 결과를 애플리케이션 원인으로 읽기 전에 network 계층을 따로 검증한다**
이 경로가 실험의 여러 노드에 공유되기 때문에 생기는 오귀속을 막는 기준이다.
- **이 호스트의 가상 머신 네트워크는 Bridge 인가 NAT 인가 Routing 인가**
이 글은 Bridge 와 라우팅, NAT 가 각각 무엇을 하는지까지만 적었다. 이 호스트가 그중 무엇으로 구성돼 있는지는 확인하지 않았다.
이 글은 Bridge 와 라우팅, NAT 가 각각 무엇을 하는지까지만 적었다. 이 호스트가 NAT 로 돈다는 것은 실험대를 세운 기록이 나중에 적었고, 그 구성에서 프레임이 어느 계층을 지나는지는 그 물음이 받는다.
- **VM1 과 VM2 의 TAP/vnet interface 는 무엇이고 어디에 붙어 있는가**
TAP 이 게스트의 이더넷 프레임과 호스트 네트워크를 잇는 접점이라는 설명이 이 물음의 전제다.
- **이 호스트에서 vhost-net 이 실제로 packet datapath 를 맡고 있는가**
@@ -254,7 +254,7 @@ Intel Physical NIC
네트워크 성능에서 큰 비용 하나가 패킷 데이터를 복사하는 일이다. virtio 와 virtqueue, vhost 구조는 버퍼 디스크립터를 써서 불필요한 복사와 컨텍스트 스위치를 줄이는 방향으로 설계돼 있다. 다만 이것을 항상 zero-copy 라고 일반화하지 않는다. 실제로 복사가 일어나는지는 커널 버전과 QEMU 버전, vhost 설정, 오프로드, NIC 의 기능, 패킷이 지나는 경로, GSO/GRO/TSO 에 따라 달라질 수 있다.
게스트와 호스트는 큐에 새 패킷이나 버퍼가 들어왔다는 것을 서로 알려야 한다. 게스트가 보낼 때는 virtqueue 에 디스크립터를 등록하고 호스트 백엔드에 알리면 백엔드가 처리하고, 받을 때는 호스트가 virtqueue 에 버퍼를 반영하고 게스트에 알리면 게스트 드라이버가 처리한다. 패킷마다 인터럽트와 알림이 지나치게 많이 발생하면 오버헤드가 커지기 때문에 batching 과 interrupt moderation, queueing 을 쓴다.
게스트와 호스트는 큐에 새 패킷이나 버퍼가 들어왔다는 것을 서로 알려야 한다. 게스트가 보낼 때는 virtqueue 에 디스크립터를 등록하고 호스트 백엔드에 알리면 백엔드가 처리한다. 받을 때는 호스트가 virtqueue 에 버퍼를 반영하고 게스트에 알리면 게스트 드라이버가 처리한다. 패킷마다 인터럽트와 알림이 지나치게 많이 발생하면 오버헤드가 커지기 때문에 batching 과 interrupt moderation, queueing 을 쓴다.
큐를 하나만 쓰면 특정 vCPU 나 처리 경로에 부하가 몰릴 수 있다. virtio-net 은 multi-queue 를 쓸 수 있고, RX 큐를 vCPU 마다 하나씩 붙여 패킷 처리를 병렬로 돌리고 큐 하나에 몰리는 병목을 완화한다. 효과는 부하의 성격과 CPU affinity, IRQ(Interrupt Request) 배치, 큐 설정에 따라 달라진다.
@@ -328,11 +328,11 @@ sudo tcpdump -ni <guest-interface>
## 이 문서가 확인하지 않은 것
이 호스트에서 잰 값은 하나도 없다. 위의 명령들은 무엇을 볼 수 있는지 적어 둔 목록이고 아직 실행하지 않았다. 이 가상 머신들의 네트워크가 Bridge 인지 NAT 인지 라우팅인지, VM1 과 VM2 의 TAP 또는 vnet 인터페이스가 무엇인지를 아직 확인하지 않았다. vhost-net 이 실제로 데이터 경로를 맡고 있는지, multi-queue 가 켜져 있는지도 마찬가지다. 그래서 이 글은 「이 구조에서는 이렇게 동작한다」까지만 말하고 「이 서버가 그 구조다」라고는 말하지 않는다. QEMU 백엔드와 vhost-net 의 성능 차이가 이 호스트에서 관찰되는지, Keycloak 부하 시험에서 네트워크 가상화가 지연에 영향을 줄 만큼 호스트 CPU 를 쓰는지도 같은 상태다.
위의 명령을 이 호스트에서 돌린 출력은 대부분 없다. 네트워크 구성만은 나중에 갈렸다. 실험대를 세운 기록이 이 호스트에 이더넷 없이 WiFi 만 있어 브리지를 못 쓰고 libvirt NAT 를 골랐다고 적었고, 게스트는 그 NAT 가 만드는 브리지에 붙는다. 브리지를 막은 것은 무선 링크다. 802.11 데이터 프레임은 기본적으로 주소 필드가 3개라, AP(Access Point, 무선 접속 장치) 는 연결된 단말의 MAC 만 알고 있고 그 단말이 자기 것이 아닌 출발지 MAC 을 단 프레임을 보내면 버린다. 브리지된 가상 머신이 보내는 것이 정확히 그런 프레임이다. 우회로 4-address 모드(WDS) 가 있지만 AP 와 클라이언트 드라이버가 모두 지원해야 하고 실제로는 거의 지원되지 않아, 그 기록은 현실적인 우회로 USB 이더넷 어댑터를 들었다. 같은 기록이 NAT 와 브리지와 macvtap 을 견준 표에서 뒤의 둘은 나란히 WiFi 라 불가다. TAP 또는 vnet 인터페이스의 이름과 vhost-net 이 실제로 데이터 경로를 맡는지, multi-queue 가 켜져 있는지는 아직 읽지 않았다. 그래서 이 글은 「이 구조에서는 이렇게 동작한다」까지만 말하고 「이 서버가 그 구조다」라고는 말하지 않는다. QEMU 백엔드와 vhost-net 의 성능 차이가 이 호스트에서 관찰되는지, Keycloak 부하 시험에서 네트워크 가상화가 지연에 영향을 줄 만큼 호스트 CPU 를 쓰는지도 재지 않았다.
이 문서가 Keycloak 테스트 환경에 얹어 그린 경로도 마찬가지다. 호스트 Nginx 아래에 가상 머신 두 대가 있고 그 안에서 K3s 와 Keycloak 노드가 돈다는 앞부분은 실험 구성으로 밝혀 두었다. 그 아래를 브리지와 라우팅·NAT, TAP, vhost-net, virtqueue 로 펼친 뒷부분은 네트워크 구성과 TAP 이름, vhost-net 사용 여부를 확인하기 전의 가정이다. 이 환경이 실제로 그렇다는 주장이어서, 재기 전에는 이 글의 그림으로 올리지 않고 근거가 모자란 후보로 남겼다.
이 문서가 Keycloak 테스트 환경에 얹어 그린 경로는 그대로 쓰지 못한다. 그 그림은 호스트의 nginx 가상 머신 두 대로 프록시하는 모양인데, 실험대는 그 뒤에 nginx 를 게스트 한 대로 옮기고 호스트에는 커널 DNAT(Destination NAT, 목적지 주소 변환) 만 두었다. 게스트도 둘이 아니라 셋이고, 엣지 한 대와 K3s 노드 두 대다. 브리지와 라우팅·NAT, TAP, vhost-net, virtqueue 로 펼친 뒷부분은 TAP 이름 vhost-net 사용 여부를 확인하기 전의 가정이어서, 재기 전에는 이 글의 그림으로 올리지 않다.
여기 적은 것은 가장 기본적인 조합 안에서만 성립한다. 실제 환경은 Bridge 와 NAT, routed network, macvtap, SR-IOV, VFIO(Virtual Function I/O) passthrough, Open vSwitch, Kubernetes CNI(Container Network Interface) 에 따라 달라질 수 있다. 이 문서에 커널과 QEMU, libvirt 버전이 한 번도 적혀 있지 않아서 특정 버전에 고정하지도 못한다. 복사 동작 하나만 봐도 커널 버전과 QEMU 버전, vhost 설정, 오프로드, NIC 의 기능에 따라 달라지므로, 버전이 바뀌어 이 서술이 낡았는지는 위 확인 명령을 실제 호스트에서 돌릴 때 드러난다.
여기 적은 것은 가장 기본적인 조합 안에서만 성립한다. 실제 환경은 Bridge 와 NAT, routed network, macvtap, SR-IOV, VFIO(Virtual Function I/O) passthrough, Open vSwitch, Kubernetes CNI(Container Network Interface) 에 따라 달라질 수 있다. 이 서술 자체는 판 번호를 달고 있지 않다. 실험대를 잰 기록이 그 호스트의 libvirt 12.7.0 과 QEMU 11.1.1, 커널 7.2.2-arch1-1 을 적었다. 여기 적은 동작은 그 판에서 읽은 것이 아니라 구조를 서술한 것이라, 세 값은 확인할 때의 조건으로 쓴다. 복사 동작 하나만 봐도 커널 버전과 QEMU 버전, vhost 설정, 오프로드, NIC 의 기능에 따라 달라지므로, 버전이 바뀌어 이 서술이 낡았는지는 위 확인 명령을 실제 호스트에서 돌릴 때 드러난다.
가상 머신 위에 올린 K3s 안쪽도 이 문서 밖이다. K3s 의 CNI 와 Service, Pod 네트워크는 이 네트워크 가상화 위에 더해지는 계층이라 따로 분석한다.
@@ -20,7 +20,7 @@ source:
# Host Nginx 에서 Keycloak 까지 packet 은 실제로 어디를 지나는가
§116 은 이 테스트 환경의 요청 경로를 클라이언트에서 Keycloak 까지 한 줄로 그렸다. 가상 머신 네트워크까지 펼치면 물리 NIC 에서 브리지와 TAP 을 지나 vhost-net 과 virtqueue 를 거쳐 게스트 안으로 들어간다고 적었다. 그 펼친 그림은 §94 가 기준으로 삼은 구조를 이 환경에 얹은 것이고, 이 호스트에서 패킷을 잡아 본 결과가 아니다. 여기서 묻는 것은 호스트 Nginx 를 지난 요청이 실제로 어느 브리지와 어느 TAP 을 거쳐 어느 가상 머신으로 들어가는가 하나다.
경로가 한 번 바뀌었다. §179 는 nginx 를 물리 호스트에서 엣지 게스트로 옮기고 호스트에는 커널 DNAT(Destination NAT, 목적지 주소 변환) 만 두었다고 적었다. §116 이 그린 그림은 옮기기 전 모양이고, 네 지점에 tcpdump 를 걸어 본 기록은 아직 없다.
## 관계
@@ -42,7 +42,7 @@ source:
- §116 이 가상 머신 네트워크까지 펼친 경로
Client → Physical NIC → Host Network Stack / Bridge / Route / NAT → TAP(vm1) / TAP(vm2) → vhost-net → virtqueue → virtio-net → Guest Network Stack → K3s networking → Keycloak
- §116 은 그 뒤에 K3s 내부의 CNI · Service · Pod network 가 추가되므로 별도 계층으로 분석한다고 적었다.
- §94 는 이 문서가 기준으로 삼은 구조를 virtio-net + vhost-net + TAP + Linux Bridge 로 놓고 수신 방향과 송신 방향을 각각 그렸으며, 실제 환경 Bridge · NAT · Routed Network · macvtap · SR-IOV · VFIO passthrough · Open vSwitch · Kubernetes CNI 에 따라 달라질 수 있다고 밝혔다.
- §94 는 이 문서가 기준으로 삼은 구조를 virtio-net + vhost-net + TAP + Linux Bridge 로 놓고 수신 방향과 송신 방향을 각각 그렸다. 실제 환경 Bridge · NAT · Routed Network · macvtap · SR-IOV · VFIO passthrough · Open vSwitch · Kubernetes CNI 에 따라 달라질 수 있다고 밝혔다.
- §125 는 같은 경로를 vhost-net 을 쓸 때와 QEMU backend 를 쓸 때로 나눠 다시 그렸다. 두 그림은 TAP 다음이 vhost-net 인지 QEMU virtio backend 인지에서 갈리고 나머지 구간은 같다.
- §119 는 경로를 확인하는 방법으로 호스트의 physical NIC · bridge · tap 또는 vnet 세 곳과 게스트의 guest interface 한 곳에 sudo tcpdump -ni 를 걸고, 어디까지 보이는지로 의심 구간을 좁히라고 적었다.
- §119 가 든 판정 예 셋
@@ -50,20 +50,34 @@ source:
TAP O · Guest NIC X : virtio/vhost/Guest NIC 계층을 의심한다
Guest NIC O · Socket X : Guest routing/firewall/listen 상태를 의심한다
- §122 OQ-6 은 Host NIC · Bridge · TAP · Guest NIC 에서 tcpdump 로 추적한다고만 적었다. 어느 이름의 인터페이스인지는 적혀 있지 않다.
- §116 의 그림은 앞부분과 뒷부분의 근거가 다르다. 호스트 Nginx 아래에 가상 머신 두 대가 있고 그 안에서 K3s 와 Keycloak 노드가 돈다는 앞부분은 §89 가 밝힌 실험 구성이고, 브리지와 라우팅·NAT, TAP, vhost-net 으로 펼친 뒷부분은 OQ-1 과 OQ-2, OQ-3 를 확인하기 전의 가정이다.
- 이 호스트에서 패킷을 잡아 본 기록이 없다. §116 이 펼친 경로는 확인한 결과가 아니라 §94 의 기준 구조를 이 환경에 얹은 그림이다.
- §116 의 그림은 앞부분과 뒷부분의 근거가 다르다. 호스트 Nginx 아래에 가상 머신 두 대가 있고 그 안에서 K3s 와 Keycloak 노드가 돈다는 앞부분은 §89 가 밝힌 실험 구성이다. 브리지와 라우팅·NAT, TAP, vhost-net 으로 펼친 뒷부분은 OQ-1 과 OQ-2, OQ-3 를 확인하기 전의 가정이다.
- §179 와 §254 는 같은 nginx 를 물리 호스트에서 엣지 게스트로 옮겼다고 적고 전후를 나란히 그렸다.
전 : `tailnet:443` → 호스트 nginx → Traefik(게스트 .11/.12)
후 : `tailnet:443` → 호스트 커널 DNAT → 엣지 nginx(.10) → Traefik(.11/.12)
- §179 는 그렇게 옮겨도 L7 홉 수는 2홉 그대로라고 관측으로 적었다. 늘어난 것은 커널이 하는 L4 전달 한 번이다. 지금 호스트에는 `:443` 을 듣는 리스너가 없다.
- §179 는 옮긴 이유를 성능이 아니라 더러워지는 층의 격리로 적었다. nginx 설정과 인증서, certbot, deploy 훅은 자주 갈아엎는 것들인데 호스트에 있으면 초기화가 불가능하고, 엣지 장애 실험이 SSH 까지 위험하게 만든다. 그 대가로 일곱 가지가 새로 필요해졌고 §179 는 그중 DNAT 와 libvirt 방화벽 구멍 둘을 이 이동의 본질로 꼽았다. 그 둘을 본질로 본 것은 추론이라고 밝혔다.
- §254 는 호스트에서 게스트로 가는 요청이 OUTPUT 경로라 필터를 타지 않는다고 적었다. 밖에서 게스트로 들어오는 요청은 FORWARD 경로라 libvirt 의 guest_input 체인을 지난다.
- §207 은 DNAT 파일에 forward 체인을 두지 않은 이유를 그 파일의 주석으로 적어 두었다. libvirt 의 guest_input 체인은 virbr0 으로 나가는 프레임을 reject 하며 끝난다. nftables 에서는 앞 base 체인의 accept 가 뒤 체인의 reject 를 멈추지 못하므로 구멍을 libvirt 체인 맨 앞에 뚫었고, 그래서 그 파일에 forward 체인이 없는 것은 실수가 아니다.
- §207 은 이 DNAT 가 PREROUTING nat 에 있어 라우팅 결정보다 먼저 돌고 호스트의 로컬 소켓보다 이긴다고 적었다. 그래서 엣지를 띄운 채 전환하고, 되돌릴 때는 테이블 하나를 지운다.
- §207 은 DNAT 만 하고 SNAT 는 하지 않는다고 적었다. 게스트의 기본 경로가 호스트여서 응답이 이 경로를 다시 지나고 conntrack 이 변환을 알아서 되돌린다. masquerade 를 붙이면 엣지가 모든 클라이언트를 192.168.122.1 로 보게 된다.
- §208 은 DNAT 유닛의 ExecStartPost 앞에 붙은 하이픈을 설명했다. libvirt_network 테이블은 가상 네트워크가 떠 있어야 존재하는데, 부팅 순서에 따라 이 유닛이 먼저 돌 수 있다. 하이픈이 없으면 그때 규칙 삽입이 실패하면서 DNAT 까지 같이 안 실린다. 하이픈을 두면 DNAT 는 실리고 구멍만 빠진 상태가 되어 systemctl restart 한 번으로 다시 뚫린다.
- §208 은 그 상태에서 유닛이 active 이고 아무 오류도 없다고 적고, 유닛이 active 라는 것은 DNAT 가 실렸다는 뜻이지 구멍이 뚫렸다는 뜻이 아니라고 추론으로 밝혔다. 그 상태를 실제로 재현해 보지는 않았다고 적혀 있다.
- §180 은 구멍이 빠졌을 때의 출력을 관측으로 남겼다. 호스트에서 게스트 주소로 친 curl 은 404 였고, 밖에서 tailnet 주소로 친 curl 은 connection refused 였다. reject 규칙의 카운터는 packets 4 bytes 240 으로 밖에서 친 횟수와 맞았다.
- §254 는 안쪽과 바깥쪽을 한 번씩 쳐서 같은 값이 나오는지 보는 확인을 적었다. 둘 다 404 면 경로가 이어진 것이고, 안쪽만 404 면 nginx 설치 · DNAT · libvirt 구멍 셋 가운데 하나가 빠진 것이다.
- 이 호스트에서 계층마다 패킷을 잡아 본 기록은 없다. §116 이 펼친 경로는 확인한 결과가 아니라 §94 의 기준 구조를 이 환경에 얹은 그림이다.
## 가정
- 호스트 Nginx 를 통해 Keycloak 요청을 한 번 보낼 수 있고, 그 요청이 두 가상 머신 가운데 어느 쪽으로 갔는지 확인하는 쪽이 알 수 있다고 본다.
- 밖에서 tailnet 주소로 Keycloak 요청을 한 번 보낼 수 있고, 그 요청이 두 k3s 노드 가운데 어느 쪽으로 갔는지 확인하는 쪽이 알 수 있다고 본다.
- 네 지점에 동시에 캡처를 걸어 둘 수 있다고 전제한다. 지점마다 따로 걸면 같은 요청을 본 것인지 갈리지 않는다.
- §116 이 그린 구조가 지금 실험 환경과 같다고 전제한다. 가상 머신이 둘이고 각각 K3s 노드와 Keycloak 을 돌린다는 것까지가 근거 문서에 적힌 전부다.
- §116 이 그린 뒷부분 가운데 TAP 다음 구간은 지금도 같다고 전제한다. 앞부분은 §179 가 바꿔 놓았고, TAP 뒤쪽 서술이 그 이동으로 달라졌는지는 적힌 것이 없다.
- 오프로드 설정이 캡처하는 동안 바뀌지 않는다.
## 미지수
- 호스트 Nginx 를 지난 요청이 어느 브리지를 거치는지, 그 브리지에서 어느 TAP 으로 나가는지.
- 그 TAP 이 두 가상 머신 가운데 어느 쪽의 인터페이스인지.
- 밖에서 온 요청이 호스트 커널 DNAT 를 지난 뒤 virbr0 에서 어느 TAP 으로 나가 엣지 게스트로 들어가는지.
- 엣지 nginx 가 k3s 노드로 넘기는 두 번째 홉이 virbr0 안에서 L2 로만 도는지, 호스트의 L3/Netfilter 를 지나는지. §119 의 네 지점은 밖에서 게스트로 들어오는 한 홉만 본다.
- 그 두 번째 홉의 TAP 이 kc-lab-1 과 kc-lab-2 가운데 어느 쪽의 인터페이스인지.
- 게스트 안의 인터페이스에서 같은 요청이 보이는지.
- 네 지점의 결과가 §116 이 펼친 그림과 같은지, 다르다면 어느 지점부터 다른지.
- 지금 오프로드 설정이 무엇인지. 캡처에서 본 패킷 크기와 체크섬을 wire 값으로 읽어도 되는지가 이것으로 갈린다.
@@ -73,13 +87,14 @@ source:
- 캡처 지점의 이름을 먼저 확정해야 한다. 어느 브리지와 어느 TAP 인지 모르면 tcpdump 를 걸 곳을 고르지 못한다. 그 이름은 네트워크 구성을 묻는 물음과 인터페이스를 묻는 물음이 댄다.
- K3s 안에서 Keycloak Pod 까지 가는 구간은 이 물음이 닫는 범위 밖이다. §116 이 CNI · Service · Pod network 를 별도 계층으로 미뤄 두었다.
- 요청이 중간 지점에서 끊기면 그것은 이 물음의 답이 아니라 장애다. §119 가 든 세 판정 예를 따라 의심 구간을 적고 계층별 캡처 기준으로 넘긴다.
- 이 호스트에서 잰 값이 없어 §116 의 그림을 확인된 경로로 삼지 않는다. 이 환경이 실제로 그렇다는 주장이라 재기 전에는 Case 도 Concept 도 아니라고 보고 근거가 모자란 후보로 남겨 두었으며, OQ-1 과 OQ-2, OQ-3, OQ-6 이 답하면 다시 판정한다.
- §116 의 그림은 엣지를 옮기기 전 모양이다. 지금 구성으로 캡처하면 호스트 nginx 였던 칸에 커널 DNAT 와 엣지 게스트가 들어가므로 캡처 지점이 그만큼 늘어난다.
- 이 호스트에서 잰 값이 없어 §116 의 그림을 확인된 경로로 삼지 않는다. 이 환경이 실제로 그렇다는 주장이라 재기 전에는 Case 도 Concept 도 아니라고 보고 근거가 모자란 후보로 남겨 두었다. OQ-1 과 OQ-2, OQ-3, OQ-6 이 답하면 다시 판정한다.
## 선택지
### 1. 네 지점에 한 번에 걸고 요청을 한 번 보낸다
§119 가 든 호스트 세 지점과 게스트 한 지점에 동시에 tcpdump 를 걸어 두고, 호스트 Nginx 를 통해 Keycloak 요청을 한 번 보낸다. 같은 요청 하나가 네 지점에서 각각 보이는지로 경로가 확정되기 때문에 실행이 한 번으로 끝난다.
§119 가 든 호스트 세 지점과 게스트 한 지점에 동시에 tcpdump 를 걸어 두고, 밖에서 tailnet 주소로 Keycloak 요청을 한 번 보낸다. 같은 요청 하나가 네 지점에서 각각 보이는지로 경로가 확정되기 때문에 실행이 한 번으로 끝난다.
지점 이름을 미리 확정해 두어야 하고 네 곳을 동시에 열어 두어야 한다.
@@ -102,8 +117,8 @@ Nginx 가 어느 주소로 요청을 넘기는지 설정만 읽어도 어느 가
## 다음 검증
1. 캡처를 걸 지점의 이름을 먼저 확정한다. 어느 브리지와 어느 TAP 인지는 네트워크 구성을 묻는 물음과 인터페이스를 묻는 물음이 답한다.
2. 호스트에서 sudo tcpdump -ni 뒤에 physical NIC 이름 · bridge 이름 · tap 또는 vnet 이름을 넣어 세 곳에 걸고, 게스트에서 같은 명령을 guest interface 이름에 건다 (§122 OQ-6 · §119).
3. 호스트 Nginx 를 통해 Keycloak 요청을 한 번 보낸다.
2. 호스트에서 sudo tcpdump -ni 뒤에 physical NIC 이름 · bridge 이름 · tap 또는 vnet 이름을 넣어 세 곳에 걸고, 게스트에서 같은 명령을 guest interface 이름에 건다 (§122 OQ-6 · §119). 엣지 게스트의 TAP 과 k3s 노드의 TAP 을 함께 열어야 §179 가 적은 두 홉이 한 실행에서 잡힌다.
3. 밖에서 tailnet 주소로 Keycloak 요청을 한 번 보낸다. §179 대로라면 그 요청은 호스트 커널 DNAT 를 지나 엣지 nginx 로 가고, 거기서 다시 k3s 노드로 넘어간다.
4. 네 지점에서 그 요청이 보였는지를 §119 처럼 O 와 X 로 적는다.
5. 캡처하는 동안의 오프로드 상태를 함께 적는다. §113 은 오프로드가 켜져 있으면 tcpdump 에서 보이는 패킷 크기나 체크섬이 실제 wire 에서 보이는 것과 다르게 보일 수 있다고 적었다.
@@ -22,7 +22,7 @@ source:
# 이 호스트에서 vhost-net 이 실제로 packet datapath 를 맡고 있는가
§103 은 패킷이 TAP 에서 게스트로 올라오는 길을 두 가지로 갈라 적었다. QEMU 백엔드를 직접 쓰면 QEMU virtio backend 가 그 사이에 들어가고, vhost-net 을 쓰면 호스트 커널의 vhost-net 이 들어간다. 둘 중 어느 쪽이 이 호스트에서 돌고 있는지 SSOT 에 없어서, 이 물음은 가상 머신 두 대 각각의 데이터 경로 백엔드를 확정한다.
§103 은 패킷이 TAP 에서 게스트로 올라오는 길을 로 갈라 적었다. QEMU 백엔드를 쓰면 그 사이에 QEMU virtio backend 가 들어가고, vhost-net 을 쓰면 호스트 커널의 vhost-net 이 들어간다. 어느 쪽으로 도는지 SSOT 에 없어서, 이 물음은 가상 머신 두 대의 데이터 경로 백엔드를 각각 확정한다.
## 관계
@@ -43,15 +43,17 @@ source:
vhost-net 을 쓰는 경우 : TAP → vhost-net → virtqueue → Guest
- §104 는 TAP → vhost-net → QEMU → virtqueue 를 일반적인 경로로 그리면 안 된다고 못 박았다. vhost-net 의 목적 하나가 데이터 경로에서 QEMU 사용자 공간을 우회하는 것이기 때문이다.
- §106 은 장치를 만들고 관리하는 주체와 실제로 패킷을 나르는 주체가 다르다는 것을 CPU 가상화에 견주어 적었다. QEMU 가 vCPU 를 만들어도 게스트의 ADD · MOV · SUB 를 전부 QEMU 가 실행하지는 않는다.
- §107 은 QEMU 사용자 공간이 패킷마다 I/O 를 처리하면 호스트 커널과 사용자 공간 사이의 전환이 쌓이고, 초당 지나는 패킷이 많아질수록 userspace/kernel transition · scheduling · copy · notification 비용이 커질 수 있다고 적었다.
- §107 은 QEMU 사용자 공간이 패킷마다 I/O 를 처리하면 호스트 커널과 사용자 공간 사이의 전환이 쌓인다고 적었다. 초당 지나는 패킷이 많아질수록 userspace/kernel transition · scheduling · copy · notification 비용이 커질 수 있다.
- §108 은 vhost-net 을 써도 QEMU 가 남아서 맡는 일을 열거했다.
VM lifecycle · Virtual hardware model · virtio device 생성
Feature negotiation · Queue configuration · Backend 연결
Device reset · Control/configuration handling
- §117.4 는 초당 지나는 패킷이 많은데 QEMU 사용자 공간이 데이터 경로를 직접 처리하면 CPU 오버헤드가 커질 수 있다고 밝히고, 관찰 대상으로 QEMU CPU usage · vhost thread · packet rate · latency · context switch 를 들었다.
- §117.4 는 초당 지나는 패킷이 많은데 QEMU 사용자 공간이 데이터 경로를 직접 처리하면 CPU 오버헤드가 커질 수 있다고 밝혔다. 관찰 대상으로 QEMU CPU usage · vhost thread · packet rate · latency · context switch 를 들었다.
- 확인 명령으로 §122 OQ-3 과 §118 이 든 것은 lsmod | grep vhost 하나다. §122 OQ-3 은 그 뒤에 QEMU arguments 와 libvirt domain XML 을 추가로 확인한다고 적었지만, 그 둘을 읽는 명령은 제3부 어디에도 적혀 있지 않다.
- §123 은 개념에서 열린 질문을 거쳐 Case 로 가는 순서를 설명하면서 이 물음을 예로 들었다. 개념 자리에 「vhost-net은 QEMU userspace를 우회해 packet datapath를 처리할 수 있다」를, 물음 자리에 「현재 테스트 Host에서 vhost-net이 실제 활성화되어 있는가?」를 놓고, 답이 나오면 「libvirt/QEMU virtio-net backend 구성 확인 및 vhost-net 사용 검증」이 Case 가 된다고 적었다.
- 이 호스트의 가상 머신 두 대가 어느 백엔드로 도는지, vhost 모듈이 올라와 있는지를 확인한 기록은 SSOT 에 없다.
- §123 은 개념에서 열린 질문을 거쳐 Case 로 가는 순서를 설명하면서 이 물음을 예로 들었다. 개념 자리에 「vhost-net은 QEMU userspace를 우회해 packet datapath를 처리할 수 있다」를, 물음 자리에 「현재 테스트 Host에서 vhost-net이 실제 활성화되어 있는가?」를 놓았다. 답이 나오면 「libvirt/QEMU virtio-net backend 구성 확인 및 vhost-net 사용 검증」이 Case 가 된다고 적었다.
- §178 은 이 실험대의 게스트를 3대로 적었고, §202 의 철거 출력이 그 domain 이름을 kc-lab-edge · kc-lab-1 · kc-lab-2 로 남겼다. §122 OQ-3 이 vm1 과 vm2 로 적은 두 대가 그중 k3s 노드 쪽이다.
- §197 은 이 호스트의 판 번호를 실측으로 적었다. libvirt 12.7.0 · QEMU emulator version 11.1.1 · 커널 7.2.2-arch1-1 이다. 어느 backend 로 도는지는 그 줄에서 나오지 않는다.
- 실험대를 적은 제5부부터 제9부까지 vhost 라는 낱말이 한 번도 나오지 않는다. 두 가상 머신이 어느 백엔드로 도는지도, vhost 모듈이 올라와 있는지도 확인한 기록이 SSOT 에 없다.
## 가정
@@ -59,6 +61,7 @@ source:
- 모듈이 올라와 있다는 것과 그 가상 머신이 그 백엔드를 쓴다는 것을 다른 사실로 놓고 물음을 세웠다. §103 은 백엔드 선택을 가상 머신의 구성으로 적었지 모듈 적재로 적지 않았다.
- 두 가상 머신이 같은 백엔드를 쓴다고 전제하지 않는다. 가상 머신마다 따로 읽어 각각 적는다.
- 읽는 동안 가상 머신을 재시작하지 않는다고 전제한다. 백엔드는 §105 가 적은 설정 경로에서 정해지므로 재시작하면 달라질 수 있다.
- 게스트가 떠 있어야 QEMU 실행 인자를 읽는다. SSOT 가 마지막으로 적은 게스트 상태는 §202 의 철거이고 그 뒤 기록이 없다.
## 미지수
@@ -94,7 +97,7 @@ lsmod | grep vhost 로 호스트에 그 기능이 있는지 먼저 보고, 나
## 다음 검증
1. lsmod | grep vhost 로 vhost 커널 모듈이 올라와 있는지 본다.
2. 두 가상 머신의 libvirt domain 설정에서 인터페이스의 driver 지정을 읽는다. 무엇으로 읽었는지 명령을 함께 적는다.
2. libvirt domain 설정에서 인터페이스의 driver 지정을 읽는다. domain 이름은 §202 가 적은 kc-lab-1 과 kc-lab-2 를 쓰고, 엣지 게스트 kc-lab-edge 도 같이 읽는다. 무엇으로 읽었는지 명령을 함께 적는다.
3. 같은 가상 머신의 QEMU 실행 인자에서 백엔드 지정을 읽는다. 이것도 명령을 함께 적는다.
4. 세 출력을 가상 머신별로 짝지어 적고 실행한 명령과 함께 증거로 남긴다.
@@ -20,7 +20,7 @@ source:
# 부하 중 network 가상화가 지연을 바꿀 만큼 호스트 CPU 를 쓰는가
§117.7 은 vhost-net 과 QEMU 스레드와 softirq 도 호스트 CPU 를 쓰므로, 네트워크 문제처럼 보이는 것이 CPU 스케줄링 문제일 수 있다고 적었다. 그 셋 가운데 QEMU 스레드와 게스트 쪽 지표는 CPU 가상화를 다루는 물음 둘이 이미 같은 실험 구간에서 재기로 해 두었다. 그래서 여기서는 그 둘이 보지 않는 vhost 커널 스레드와 softirq 만 본다. Keycloak 부하 시험 구간에서 이 둘이 호스트 CPU 를 얼마나 쓰는지, 그 사용량이 네트워크 지연과 같이 움직이는지로 물음을 좁힌다.
§117.7 은 vhost-net 과 QEMU 스레드와 softirq 도 호스트 CPU 를 쓰므로, 네트워크 문제처럼 보이는 것이 CPU 스케줄링 문제일 수 있다고 적었다. 그 셋 가운데 QEMU 스레드와 게스트 쪽 지표는 CPU 가상화를 다루는 물음 둘이 같은 실험 구간에서 이미 재기로 해 두었다. 그래서 여기서는 Keycloak 부하 시험 구간에서 vhost 커널 스레드와 softirq 호스트 CPU 를 얼마나 쓰는지, 그 사용량이 네트워크 지연과 같이 움직이는지만 본다.
## 관계
@@ -38,8 +38,8 @@ source:
## 사실
- §117.7 은 vhost-net · QEMU thread · softirq 도 호스트 CPU 를 쓰고, 따라서 네트워크 문제처럼 보여도 CPU 스케줄링 문제일 수 있다고 적었다.
- §107 은 QEMU 사용자 공간이 패킷마다 I/O 를 처리하면 호스트 커널과 사용자 공간을 오가는 전환 비용이 쌓이고, 초당 지나는 패킷이 많아질수록 userspace/kernel transition · scheduling · copy · notification 비용이 커질 수 있다고 밝혔다.
- §111 은 게스트와 호스트가 큐에 새 패킷이나 버퍼가 있음을 서로 알려야 한다고 적고, 패킷마다 인터럽트나 알림이 지나치게 많이 발생하면 오버헤드가 커지므로 batching · interrupt moderation · queueing 이 중요하다고 밝혔다.
- §107 은 QEMU 사용자 공간이 패킷마다 I/O 를 처리하면 호스트 커널과 사용자 공간을 오가는 전환 비용이 쌓인다고 밝혔다. 초당 지나는 패킷이 많아질수록 userspace/kernel transition · scheduling · copy · notification 비용이 커질 수 있다.
- §111 은 게스트와 호스트가 큐에 새 패킷이나 버퍼가 있음을 서로 알려야 한다고 적었다. 패킷마다 인터럽트나 알림이 지나치게 많이 발생하면 오버헤드가 커지므로 batching · interrupt moderation · queueing 이 중요하다고 밝혔다.
- §120 은 Refresh Token 경쟁 자체가 virtio-net 문제는 아니지만 클라이언트부터 PostgreSQL/Redis 까지 같은 경로를 공유하므로 네트워크 경로를 별도로 검증한다고 적었다.
- §120 이 Refresh Token 경쟁이나 DB lock 으로 오해할 수 있다고 든 다섯
Node1 요청만 지연
@@ -57,6 +57,9 @@ source:
- 이 여섯 가운데 QEMU CPU 와 Guest CPU 는 CPU 가상화 쪽 물음이 같은 실험 구간에서 이미 잰다. §14.7 이 호스트 스레드를 보는 명령으로 적은 ps -eLo pid,tid,psr,pcpu,comm | grep qemu 는 이름이 qemu 인 스레드만 걸러 내므로 vhost 커널 스레드는 그 출력에 나오지 않는다.
- softirq 시간을 읽는 명령이 근거 문서에 없다. §118 의 네트워크 확인 명령 목록에도, CPU 관측 명령을 모아 둔 §14 에도 softirq 항목이 없고 이 낱말은 §117.7 과 §122 OQ-7 두 곳에만 나온다.
- §108 은 vhost-net 을 써도 QEMU 가 VM lifecycle · virtio device 생성 · feature negotiation · queue configuration · backend 연결 · device reset 을 계속 맡는다고 적었다.
- §179 는 nginx 를 물리 호스트에서 엣지 게스트로 옮기고 호스트에는 커널 DNAT 만 두었다고 관측으로 적었다. 지금 호스트에는 `:443` 을 듣는 리스너가 없다.
- §197 과 §218 은 이 호스트의 논리 코어가 8 이고 게스트 셋에 vCPU 2 · 2 · 1 을 배분했다고 적었다. 부하 구간의 호스트 CPU 사용량은 이 수와 나란히 읽는다.
- §192 가 적은 스크레이프 대상은 keycloak · kubelet · node-exporter · prometheus 넷이고 node-exporter 는 게스트 노드마다 하나씩 붙는다. §193 은 이 실험대가 호스트 쪽 지표를 긁지 않는다고 적었다.
- 이 호스트에서 부하 구간의 vhost 스레드 CPU 사용량이나 softirq 시간을 잰 기록이 없다.
## 가정
@@ -65,13 +68,13 @@ source:
- 호스트에서 vhost 커널 스레드를 스레드 단위로 구분해 볼 수 있다고 전제한다. §108 이 vhost-net 을 써도 QEMU 가 설정과 수명 주기를 계속 맡는다고 적었으므로, QEMU 프로세스의 CPU 사용량과 vhost 커널 스레드의 CPU 사용량을 따로 세야 한다.
- 부하 도구가 네트워크 지연을 이미 내고 있다고 전제한다. 그 도구가 어떤 값을 어떤 주기로 내는지는 근거 문서에 적혀 있지 않다.
- 호스트와 게스트 둘의 시각을 맞춰 읽을 수 있다. 시계가 어긋나면 세 값을 같은 부하 구간에 겹쳐 놓지 못한다.
- 호스트 쪽 Nginx 도 같은 물리 CPU 를 쓴다. 부하 구간의 호스트 CPU 상승을 전부 네트워크 가상화 몫으로 읽으면 이 전제가 깨진다.
- 엣지 게스트의 nginx 가 쓰는 CPU 는 그 게스트를 돌리는 QEMU 프로세스 쪽에 나타난다고 본다. §179 가 nginx 를 게스트로 옮긴 뒤로 호스트에는 그 프로세스가 없으므로, 부하 구간의 호스트 CPU 상승을 전부 네트워크 가상화 몫으로 읽으면 이 전제가 깨진다.
## 미지수
- 부하를 걸기 전 vhost 커널 스레드의 CPU 사용량과 softirq 시간.
- 부하 구간에서 그 둘이 얼마나 오르는지.
- 오른 몫이 QEMU vCPU 스레드 사용량과 어떻게 나뉘는지.
- 오른 몫이 QEMU vCPU 스레드 사용량과 어떻게 나뉘는지. 게스트가 셋이라 엣지 게스트를 돌리는 QEMU 프로세스의 몫도 k3s 노드 두 대와 갈라 세야 한다.
- vhost 커널 스레드와 softirq 의 움직임이 네트워크 지연과 같은 시간축에서 함께 움직이는지.
- 두 지표가 얼마나 움직여야 실험 결과를 다르게 읽어야 하는지. 그 문턱을 아직 정하지 않았다.
- softirq 시간을 이 환경에서 어떤 도구로 읽는지. 근거 문서가 명령을 적지 않아 실행하는 쪽이 정한다.
@@ -81,7 +84,9 @@ source:
- 실험 조건을 바꾸지 않고 관찰만 덧붙인다. CPU 가상화 쪽 물음이 같은 실험 구간을 쓰기로 되어 있어 부하 수준을 바꾸면 두 기록을 겹쳐 읽지 못한다.
- 기준값을 먼저 찍는다. 부하 구간의 값만 있으면 그것이 평소 값인지 부하 때문에 오른 값인지 판정할 수 없다.
- 이 물음이 새로 만드는 값은 vhost 커널 스레드 사용량과 softirq 시간 둘이다. QEMU CPU 와 Guest CPU 와 steal time 은 CPU 가상화 쪽 기록을 그대로 쓰고, 네트워크 지연은 부하 도구가 낸 값을 쓴다.
- 호스트 쪽 값은 손으로 읽어 남긴다. §192 의 관측 스택은 게스트 안에서 돌고 §193 이 적은 대로 호스트 지표를 긁지 않는다. §193 은 node-exporter 가 게스트 커널이 내놓는 값을 읽으므로 그 값이 전부 게스트가 본 것이고 호스트에서 같은 것을 재면 다른 수가 나올 수 있다고 추론으로 덧붙였다.
- 두 지표가 움직이지 않았다는 결과가 나와도 Refresh Token 실험의 결론이 바뀌지는 않는다. §120 이 Refresh Token 경쟁과 네트워크 가상화를 별개 문제로 놓았기 때문에, 여기서 갈리는 것은 그 실험 결과를 애플리케이션과 저장소 쪽으로 읽어도 되는지 하나다.
- 스레드가 어느 논리 CPU 에서 돌았는지는 §14.7 의 psr 로 읽히지만, 그 번호가 어느 물리 코어인지는 SSOT 에서 나오지 않는다. §197 이 남긴 lscpu 출력이 grep 으로 걸러져 Model name 과 CPU(s), Thread(s) per core, Core(s) per socket 네 줄뿐이기 때문이다. vhost 스레드를 어느 CPU 에 둘지를 나중에 정하려면 그 배치를 호스트에서 다시 읽어야 한다.
- 이 호스트에서 잰 값이 없어 다른 장비의 수치를 근거로 삼지 않는다.
## 선택지
@@ -20,18 +20,18 @@ source:
# QEMU backend 와 vhost-net 의 차이가 이 호스트에서 실제로 보이는가
backend 는 virtqueue 를 사이에 두고 게스트와 버퍼를 주고받는 호스트 쪽을 가리킨다. §107 은 패킷 처리를 QEMU 사용자 공간(userspace)에서 호스트 커널로 옮기면 컨텍스트 스위치와 사용자 공간 오버헤드가 줄어든다고 적었다. 줄어든다는 방향만 적혀 있을 뿐 이 호스트에서 두 backend 를 나란히 재 본 값은 없다. §110 실제로 복사가 일어나는지 커널 버전과 offload 를 비롯한 여러 조건에 따라 달라질 수 있다고 밝혔으므로, 다른 환경에서 나온 수치를 이 호스트 값으로 옮겨 쓸 수도 없다. 이 물음은 §122 OQ-4 가 든 여섯 축을 이 호스트에서 나란히 재서 차이가 보이는지 가른다.
백엔드(backend)는 virtqueue 를 사이에 두고 게스트와 버퍼를 주고받는 호스트 쪽을 가리킨다. §107 은 패킷 처리를 QEMU 사용자 공간(userspace)에서 호스트 커널로 옮기면 컨텍스트 스위치와 사용자 공간 오버헤드가 줄어든다고 적었지만, 방향만 있을 뿐 이 호스트에서 두 백엔드를 나란히 재 본 값은 없다. §110 실제로 복사가 일어나는지 커널 버전과 offload 를 비롯한 여러 조건에 따라 달라질 수 있다고 밝혔으, 다른 환경 수치를 이 호스트 값으로 옮겨 쓸 수도 없다. 이 물음은 §122 OQ-4 가 든 여섯 축을 이 호스트에서 나란히 재서 차이가 보이는지 가른다.
## 관계
- **Guest 의 packet 이 Host Physical NIC 에 닿기까지 — virtio-net · virtqueue · vhost-net · TAP · Bridge**
그 경로에서 두 backend 가 갈리는 곳은 TAP 다음 한 칸이고, 이 물음은 거기서 무엇이 달라지는지를 잰다.
그 경로에서 두 백엔드가 갈리는 곳은 TAP 다음 한 칸이고, 이 물음은 거기서 무엇이 달라지는지를 잰다.
- **이 호스트에서 vhost-net 이 실제로 packet datapath 를 맡고 있는가**
지금 어느 쪽으로 돌고 있는지가 정해져야 견줄 두 값 가운데 한쪽이 고정된다.
- **부하 중 network 가상화가 지연을 바꿀 만큼 호스트 CPU 를 쓰는가**
두 물음이 QEMU CPU 와 Host CPU 를 같은 부하에서 읽으므로 측정을 한 번으로 묶을 수 있다.
- **Keycloak 실험 결과를 애플리케이션 원인으로 읽기 전에 network 계층을 따로 검증한다**
backend 차이가 지연에 얼마나 들어오는지를 이 물음이 재면, 그 기준이 그 값을 가져다 쓴다.
백엔드 차이가 지연에 얼마나 들어오는지를 이 물음이 재면, 그 기준이 그 값을 가져다 쓴다.
## 사실
@@ -40,12 +40,12 @@ backend 는 virtqueue 를 사이에 두고 게스트와 버퍼를 주고받는
QEMU userspace backend : TAP → QEMU → virtqueue
vhost-net kernel backend : TAP → vhost-net → virtqueue
- §125 는 최종 기준 구조에 두 Data Path 를 나란히 그렸다. 열한 칸 가운데 다른 곳은 TAP 다음 한 칸이고, 거기에 vhost-net 이 오느냐 QEMU virtio backend 가 오느냐로 갈린다.
- §107 이 든 최적화 방향은 패킷마다 QEMU 사용자 공간이 끼어들던 처리를 커널 backend 로 옮겨 컨텍스트 스위치와 사용자 공간 오버헤드를 줄이는 것이다.
- §110 은 virtio · virtqueue · vhost 구조가 버퍼 디스크립터(descriptor)로 불필요한 복사와 컨텍스트 스위치를 줄이도록 설계되어 있다고 적고, 그렇다고 이를 항상 zero-copy 라고 일반화하면 안 된다고 못 박았다. 실제로 복사가 일어나는지는 아래에 따라 달라질 수 있다.
- §107 이 든 최적화 방향은 패킷마다 QEMU 사용자 공간이 끼어들던 처리를 커널 백엔드로 옮겨 컨텍스트 스위치와 사용자 공간 오버헤드를 줄이는 것이다.
- §110 은 virtio · virtqueue · vhost 구조가 버퍼 디스크립터(descriptor)로 불필요한 복사와 컨텍스트 스위치를 줄이도록 설계되어 있다고 적었다. 그렇다고 이를 항상 zero-copy 라고 일반화하면 안 된다고 못 박았다. 실제로 복사가 일어나는지는 아래에 따라 달라질 수 있다.
Kernel version · QEMU version · vhost configuration
offload · NIC capability · packet path · GSO/GRO/TSO
- §111 은 게스트와 호스트가 큐에 새 패킷이 들어왔음을 서로 알려야 한다고 적고, 패킷마다 인터럽트와 알림이 지나치게 많이 나가면 오버헤드가 커질 수 있다고 덧붙였다. 그래서 batching 과 interrupt moderation 과 queueing 이 중요하다.
- §117.4 는 초당 패킷 수가 높은 구간에서 QEMU 사용자 공간이 처리 경로를 직접 맡으면 CPU 오버헤드가 커질 수 있다고 밝히고, 관찰할 것으로 QEMU CPU usage · vhost thread · packet rate · latency · context switch 를 들었다.
- §117.4 는 초당 패킷 수가 높은 구간에서 QEMU 사용자 공간이 처리 경로를 직접 맡으면 CPU 오버헤드가 커질 수 있다고 밝혔다. 관찰할 것으로 QEMU CPU usage · vhost thread · packet rate · latency · context switch 를 들었다.
- §122 OQ-4 는 비교할 축 여섯을 적었다.
Latency
Throughput
@@ -54,53 +54,59 @@ backend 는 virtqueue 를 사이에 두고 게스트와 버퍼를 주고받는
Context Switch
Packet rate
- §122 OQ-4 는 축의 이름만 적어 두었고, 여섯을 무슨 도구로 어떻게 재는지는 제3부에 없다. 같은 §122 에서 OQ-1 과 OQ-2 는 돌릴 명령을 그대로 적었고 OQ-3 은 lsmod | grep vhost 를 확인 후보로 들었다.
- 이 호스트에서 두 backend 를 재 본 값은 SSOT 에 없다.
- §197 은 이 호스트의 판 번호를 실측으로 적었다.
libvirt : 12.7.0
QEMU emulator version : 11.1.1
커널 : 7.2.2-arch1-1
- 그래서 §110 이 든 조건 여섯 가운데 kernel version 과 QEMU version 은 이제 적을 수 있다. vhost configuration 과 offload, NIC capability 는 이 호스트에서 읽은 값이 없다.
- §202 와 §204 는 게스트를 지우고 다시 세울 때 무엇이 사라지고 무엇이 남는지 적었다. 게스트 디스크와 시드 ISO 는 사라지고 base.qcow2 와 libvirt 의 default 네트워크 정의는 남으며, 재구축해도 IP 가 같다. 다만 백엔드를 바꿔 다시 세운 기록은 없다.
- 이 호스트에서 두 백엔드를 재 본 값은 SSOT 에 없다.
## 가정
- 같은 가상 머신을 다른 backend 로 다시 구성해 띄울 수 있다고 본다. 그것이 이 실험 환경에서 되는지는 SSOT 에 적혀 있지 않다.
- 같은 가상 머신을 다른 백엔드로 다시 구성해 띄울 수 있다고 본다. 그것이 이 실험 환경에서 되는지는 SSOT 에 적혀 있지 않다.
- 두 구성에 같은 부하를 걸 수 있다고 전제한다. 부하 도구와 요청 구성이 같아야 여섯 축을 견줄 수 있기 때문이다.
- 부하를 걸기 전 값과 부하 중 값의 차이가 backend 차이보다 작다고 전제하지 않는다. 그래서 부하 전 값을 먼저 찍어 둔다.
- 부하를 걸기 전 값과 부하 중 값의 차이가 백엔드 차이보다 작다고 전제하지 않는다. 그래서 부하 전 값을 먼저 찍어 둔다.
- 두 구성을 같은 시각에 나란히 돌릴 수 없다고 보고 차례로 잰다. 그 사이에 호스트의 다른 부하가 달라지면 값이 흔들릴 수 있다.
## 미지수
- 같은 부하를 두 backend 로 돌렸을 때 여섯 축이 실제로 얼마나 달라지는지.
- 같은 부하를 두 백엔드로 돌렸을 때 여섯 축이 실제로 얼마나 달라지는지.
- 그 차이가 이 실험의 결과를 다르게 읽어야 할 만큼인지, 아니면 측정 흔들림 안인지.
- 이 호스트가 내는 초당 패킷 수가 §107 이 말한 「높아질수록 비용이 커지는」 구간에 들어가는지.
- 여섯 축을 이 환경에서 무엇으로 재는지. 제3부가 도구를 적지 않아 실행하는 쪽이 정한다.
## 제약
- 지금 backend 가 무엇인지는 이 물음이 정하지 않는다. 그것을 확정하는 물음이 닫힌 뒤에 시작한다. §116 은 이 테스트 환경의 경로를 펼치면서 TAP 다음 칸에 vhost-net 을 적어 두었는데, 그 칸이 실제로 그런지를 §122 가 OQ-3 으로 아직 묻고 있으므로 그 그림을 지금 backend 의 근거로 쓰지 않는다.
- §110 이 든 조건들(kernel version · QEMU version · vhost configuration · offload · NIC capability · packet path · GSO/GRO/TSO)을 함께 적지 않으면 이 측정을 다른 환경에 재사용할 수 없다.
- 이 호스트에서 잰 값이 없어 다른 장비의 backend 비교 수치를 근거로 삼지 않는다.
- 여기서 어느 backend 로 실험을 고정할지는 정하지 않는다. 그것은 측정 결과가 나온 뒤의 Decision 이다.
- 지금 백엔드가 무엇인지는 이 물음이 정하지 않는다. 그것을 확정하는 물음이 닫힌 뒤에 시작한다. §116 은 이 테스트 환경의 경로를 펼치면서 TAP 다음 칸에 vhost-net 을 적어 두었다. 그 칸이 실제로 그런지를 §122 가 OQ-3 으로 아직 묻고 있으므로 그 그림을 지금 백엔드의 근거로 쓰지 않는다.
- §110 이 든 조건들(kernel version · QEMU version · vhost configuration · offload · NIC capability · packet path · GSO/GRO/TSO)을 함께 적지 않으면 이 측정을 다른 환경에 재사용할 수 없다. 앞의 둘은 §197 이 이미 적었으므로 잴 때 나머지를 채운다.
- 이 호스트에서 잰 값이 없어 다른 장비의 백엔드 비교 수치를 근거로 삼지 않는다.
- 여기서 어느 백엔드로 실험을 고정할지는 정하지 않는다. 그것은 측정 결과가 나온 뒤의 Decision 이다.
## 선택지
### 1. 지금 backend 만 먼저 찍어 나중에 견줄 값을 만든다
### 1. 지금 백엔드만 먼저 찍어 나중에 견줄 값을 만든다
구성을 바꾸지 않고 부하 전과 부하 중의 여섯 축을 한 번씩 기록한다. 가상 머신을 다시 구성하지 않으므로 지금 돌고 있는 Keycloak 실험을 멈추지 않아도 되고, 나중에 다른 backend 를 잴 때 견줄 값이 생긴다.
구성을 바꾸지 않고 부하 전과 부하 중의 여섯 축을 한 번씩 기록한다. 가상 머신을 다시 구성하지 않으므로 지금 돌고 있는 Keycloak 실험을 멈추지 않아도 되고, 나중에 다른 백엔드를 잴 때 견줄 값이 생긴다.
한 구성의 값만으로는 이 물음이 닫히지 않는다.
### 2. 두 backend 로 바꿔 가며 같은 부하를 건다
### 2. 두 백엔드로 바꿔 가며 같은 부하를 건다
§122 OQ-4 가 요구하는 비교가 이것이다. 같은 가상 머신을 다른 backend 로 다시 구성하고 같은 부하를 양쪽에 걸어 여섯 축을 같은 시각에 기록한다. 차이가 나면 그 표가 그대로 Case 가 된다.
§122 OQ-4 가 요구하는 비교가 이것이다. 같은 가상 머신을 다른 백엔드로 다시 구성하고 같은 부하를 양쪽에 걸어 여섯 축을 같은 시각에 기록한다. 차이가 나면 그 표가 그대로 Case 가 된다.
가상 머신을 다시 구성하고 재시작해야 하므로 그동안 Keycloak 실험을 멈춰야 하고, 두 측정 사이에 호스트 상태가 달라지지 않도록 관리해야 한다.
### 3. 문헌의 backend 비교 수치를 가져다 쓴다 — 제외
### 3. 문헌의 백엔드 비교 수치를 가져다 쓴다 — 제외
§110 은 실제로 복사가 일어나는지가 여러 조건에 따라 달라질 수 있다고 밝혔다. 그 조건은 kernel version · QEMU version · vhost configuration · offload · NIC capability · packet path · GSO/GRO/TSO 다. 조건이 이만큼 걸려 있으니 다른 환경에서 나온 수치는 이 호스트의 값이 되지 못한다. 이 물음은 이 호스트에서 잰 값으로만 닫힌다.
## 다음 검증
1. 지금 backend 가 무엇인지 먼저 확정한다. 그것을 묻는 물음이 닫히기 전에는 견줄 두 값 가운데 한쪽이 무엇인지 알 수 없다.
1. 지금 백엔드가 무엇인지 먼저 확정한다. 그것을 묻는 물음이 닫히기 전에는 견줄 두 값 가운데 한쪽이 무엇인지 알 수 없다.
2. 부하를 걸기 전에 여섯 축을 한 번 찍어 둔다.
3. 지금 구성에 부하를 걸고 여섯 축을 같은 시각에 기록한다. Latency 와 Throughput 과 Packet rate 는 부하 도구가 내는 값을 쓰고, QEMU CPU 와 Host CPU 와 Context Switch 는 호스트에서 읽는다.
4. 같은 가상 머신을 다른 backend 로 구성하고 같은 부하를 걸어 3 을 되풀이한다.
4. 같은 가상 머신을 다른 백엔드로 구성하고 같은 부하를 걸어 3 을 되풀이한다.
5. 두 구성의 여섯 축을 나란히 적고, §110 이 든 조건들과 실행한 명령을 함께 증거로 남긴다.
닫는 조건 : 두 구성의 여섯 축을 나란히 놓으면 닫는다. 차이가 부하 전 값의 흔들림 안이면 이 호스트가 내는 초당 패킷 수에서는 backend 선택이 결과를 바꾸지 않는다고 적고 닫는다. 차이가 나면 그 측정이 Case 가 되고, 어느 backend 로 실험을 고정할지는 그 Case 뒤에 Decision 으로 넘긴다.
닫는 조건 : 두 구성의 여섯 축을 나란히 놓으면 닫는다. 차이가 부하 전 값의 흔들림 안이면 이 호스트가 내는 초당 패킷 수에서는 백엔드 선택이 결과를 바꾸지 않는다고 적고 닫는다. 차이가 나면 그 측정이 Case 가 되고, 어느 백엔드로 실험을 고정할지는 그 Case 뒤에 Decision 으로 넘긴다.
@@ -19,7 +19,7 @@ source:
# VM1 과 VM2 의 TAP/vnet interface 는 무엇이고 어디에 붙어 있는가
§99 는 TAP 을 가상 머신의 이더넷 프레임과 호스트 리눅스 네트워크를 잇는 접점으로 놓았다. 그 접점의 이름은 호스트마다 다르게 붙는데, 이 호스트에서 두 가상 머신에 각각 무엇이 붙었는지는 SSOT 에 없다. 이름을 모르면 §119 가 적은 계층별 tcpdump 대상을 채우지 못하고, §117.1 이 든 「특정 VM 만 통신 불가」 어느 가상 머신을 가리키는지도 가릴 수 없다. 이 물음은 두 가상 머신의 호스트 쪽 인터페이스 이름과 그것이 붙어 있는 곳을 확정한다.
§99 는 TAP 을 가상 머신의 이더넷 프레임과 호스트 리눅스 네트워크를 잇는 접점으로 놓았다. 그 접점의 이름은 호스트마다 다르게 붙는데, 이 호스트 두 가상 머신에 무엇이 붙었는지는 SSOT 에 없다. 이름을 모르면 §119 계층별 tcpdump 대상을 채우지 못하고, §117.1 이 든 「특정 VM 만 통신 불가」 어느 가상 머신인지 가릴 수 없다. 이 물음은 두 가상 머신의 호스트 쪽 인터페이스 이름과 붙어 있는 곳을 확정한다.
## 관계
@@ -54,20 +54,30 @@ source:
특정 VM 만 통신 불가
- §117.1 이 그 증상에서 확인하라고 든 명령은 ip link · bridge link · bridge fdb show · virsh domiflist 다.
- §116 은 이 테스트 환경의 경로를 펼치면서 TAP 칸을 TAP(vm1) 과 TAP(vm2) 로 적었다. 호스트에서 읽은 이름은 그 그림에도 없다.
- 두 가상 머신스트 쪽 인터페이스 이름도, 그 인터페이스가 어느 브리지에 붙어 있는지도 SSOT 에는 없다.
- §178 은 이 실험대스트를 Debian 12 genericcloud 3대로 적었다. 엣지 1대와 k3s 2노드이고, §122 OQ-2 가 vm1 과 vm2 로 적은 두 대가 그중 k3s 노드 쪽이다.
- §202 는 2026-09-10 에 돌린 철거 명령의 출력을 그대로 남겼고, 거기서 libvirt domain 이름이 kc-lab-edge · kc-lab-1 · kc-lab-2 로 확인된다. 한 대분 출력의 첫 줄은 Domain 'kc-lab-edge' destroyed 다.
- §201 과 §247 이 적은 세 게스트의 MAC 주소와 IP 주소
kc-lab-edge : `52:54:00:aa:bb:10` · 192.168.122.10
kc-lab-1 : `52:54:00:aa:bb:11` · 192.168.122.11
kc-lab-2 : `52:54:00:aa:bb:12` · 192.168.122.12
- §247 은 `52:54:00` 이 QEMU/KVM 에 할당된 OUI(제조사 식별 접두사)라고 적고, 예약의 mac 과 VM 을 만들 때 준 mac 이 정확히 같아야 한다고 밝혔다. 다르면 예약이 조용히 무시되고 게스트가 동적 범위에서 아무 주소나 받는다.
- §201 은 예약과 리스를 다른 것으로 갈라 적었다. virsh net-dumpxml 의 예약은 줄 의도이고 virsh net-dhcp-leases 는 실제로 준 기록이라 둘이 다를 수 있다. 실제로 준 기록에는 `52:54:00:aa:bb:11` 이 192.168.122.11/24 을, `52:54:00:aa:bb:12` 가 192.168.122.12/24 을 각각 kc-lab-1 과 kc-lab-2 이름으로 받은 줄이 남아 있다.
- §178 과 §245 는 게스트가 libvirt 의 default 네트워크에 붙는다고 적었다. §245 는 VM 이 한 대라도 뜨면 그 VM 의 vnetN 인터페이스가 virbr0 에 붙으면서 브리지가 UP 으로 바뀐다고 덧붙였다.
- §201 은 VM 세 대가 돌 때 virbr0 이 UP 이고 전부 철거한 뒤에는 DOWN 이라는 출력을 남겼다. 상태가 갈린 이유를 브리지에 붙은 tap 인터페이스가 하나도 없어서라고 적었다.
- 호스트 쪽 인터페이스의 이름은 SSOT 에 없다. virsh domiflist 를 돌린 출력도 ip link 출력도 없다.
## 가정
- 호스트에 붙어 virsh 와 ip 계열 명령을 실행할 수 있다고 본다.
- §122 OQ-2 명령에 적은 vm1 과 vm2 가 이 호스트에 실재하는 domain 이름이라고 본다. 실제 이름이 다르면 그 이름으로 바꿔 돌린다. §99 와 §118 은 같은 명령에 넣을 domain 을 비워 두었고, 가상 머신 이름을 그대로 적은 곳은 §90.1 의 virsh domiflist vm1 과 §122 OQ-2 다.
- §122 OQ-2 명령에 vm1 과 vm2 를 적었지만 §202 의 철거 출력에 찍힌 domain 이름은 kc-lab-1 과 kc-lab-2 다. 그래서 명령에 넣을 이름은 뒤쪽을 쓴다. §99 와 §118 은 같은 명령에 넣을 domain 을 비워 두었다.
- virsh domiflist 출력에 인터페이스 이름과 MAC 주소가 함께 나온다고 전제한다. §99 도 §118 도 이 명령의 출력 형식은 적지 않았다.
- 두 가상 머신이 켜져 있는 동안 읽는다고 전제한다. 꺼진 가상 머신의 TAP 이 호스트에 남아 있는지는 SSOT 에 적혀 있지 않다.
## 미지수
- VM1 과 VM2 의 호스트 쪽 인터페이스 이름이 각각 무엇인지.
- 각 인터페이스의 MAC 주소와 NIC model 이 무엇인지.
- 인터페이스가 같은 브리지에 붙어 있는지, 서로 다른 곳에 붙어 있는지.
- kc-lab-1 과 kc-lab-2 의 호스트 쪽 인터페이스 이름이 각각 무엇인지. 엣지 게스트 kc-lab-edge 의 것도 같이 모른다.
- 각 인터페이스의 NIC model 이 무엇인지. MAC 주소는 §247 의 예약이 대지만 virsh domiflist 가 같은 값을 내는지는 대조해야 갈린다.
- 인터페이스가 모두 virbr0 에 붙어 있는지. §245 는 default 네트워크의 VM 들이 이 브리지에 연결된다고 적었고, bridge link 로 포트 목록을 읽은 출력은 없다.
- ip tuntap show 에 나오는 TAP 목록과 virsh domiflist 가 대는 이름이 그대로 맞아떨어지는지.
## 제약
@@ -75,12 +85,13 @@ source:
- 이름과 어디에 붙어 있는지를 적는 데서 끊는다. 그 경로로 패킷이 실제로 흘렀는지는 tcpdump 를 쓰는 물음이 받는다.
- 두 가상 머신을 같은 시점에 읽는다. 한쪽을 재시작한 뒤 다른 쪽을 읽으면 이름이 바뀌어도 알 수 없기 때문이다.
- 이 호스트에서 읽은 출력이 없어 tap0 이나 vnet0 같은 §99 의 예시 이름을 이 호스트의 값으로 쓰지 않는다.
- SSOT 가 마지막으로 적은 게스트 상태는 §202 의 철거다. 그 뒤에 다시 세웠는지는 적혀 있지 않으므로 세 게스트가 떠 있는 상태에서 읽는다. 철거된 상태로 읽으면 볼 것이 없다 — §201 은 세 대를 전부 철거한 뒤 virbr0 이 DOWN 인 이유를 브리지에 붙은 tap 인터페이스가 하나도 없어서라고 적었다.
## 선택지
### 1. libvirt 가 대는 이름을 먼저 받아 호스트에서 대조한다
virsh domiflist 로 가상 머신마다 붙은 인터페이스를 받고, 그 이름이 ip link 목록에 실재하는지 확인한 뒤 bridge link 로 어느 브리지의 port 인지 잡는다. §122 OQ-2 가 적은 네 줄이 이 순서다. 가상 머신과 인터페이스의 짝이 처음부터 정해져 나오므로 두 대의 것을 헷갈리지 않는다.
virsh domiflist 로 가상 머신마다 붙은 인터페이스를 받고, 그 이름이 ip link 목록에 실재하는지 확인한 뒤 bridge link 로 어느 브리지의 포트인지 잡는다. §122 OQ-2 가 적은 네 줄이 이 순서다. 가상 머신과 인터페이스의 짝이 처음부터 정해져 나오므로 두 대의 것을 헷갈리지 않는다.
libvirt 가 모르는 인터페이스는 이 순서에서 빠진다.
@@ -96,9 +107,9 @@ ip link 와 ip tuntap show 로 호스트에 있는 TAP 을 모두 적고, bridge
## 다음 검증
1. virsh domiflist vm1 과 virsh domiflist vm2 로 각 가상 머신에 붙은 인터페이스를 읽는다.
1. virsh domiflist 에 §202 가 적은 domain 이름 kc-lab-1 · kc-lab-2 · kc-lab-edge 를 차례로 넣어 각 가상 머신에 붙은 인터페이스를 읽는다.
2. ip link 로 그 이름이 호스트에 실재하는지 대조한다.
3. bridge link 로 각 인터페이스가 어느 브리지의 port 인지 적는다.
3. bridge link 로 각 인터페이스가 어느 브리지의 포트인지 적는다.
4. 두 가상 머신의 결과를 인터페이스 이름 · MAC 주소 · 붙어 있는 브리지로 나란히 적고, 실행한 명령과 출력을 함께 증거로 남긴다.
닫는 조건 : 가상 머신마다 인터페이스 이름과 MAC 주소와 붙어 있는 브리지를 적어 두 대를 나란히 놓으면 닫는다. 이 목록이 계층별 캡처 기준이 요구하는 지점의 이름이 되고, 이것이 없으면 실제 패킷 경로를 묻는 물음의 tcpdump 를 어느 인터페이스에 걸지 정할 수 없다. 두 가상 머신이 서로 다른 브리지에 붙어 있으면 §117.1 의 「특정 VM 만 통신 불가」를 진단할 때 그 사실을 먼저 본다고 적는다.
@@ -19,14 +19,14 @@ source:
# 이 가상 머신들의 virtio-net 에 multi-queue 가 켜져 있는가
multi-queue 는 virtio-net 이 송수신 큐를 여러 개 두고 쓰는 구성이다. §112 는 큐를 하나만 쓰면 패킷 처리가 한 vCPU 나 한 처리 경로에 몰릴 수 있어서 이것을 최적화 방향으로 들었다. 이 물음은 그 쏠림이 실제로 일어나는지를 재지 않고, 두 가상 머신이 애초에 큐를 몇 개 쓰도록 구성되어 있는지를 읽는다. 근거 문서는 multi-queue 를 쓸 수 있다고만 적었을 뿐 이 가상 머신들의 큐 수는 적지 않았다.
multi-queue 는 virtio-net 이 송수신 큐를 여러 개 두고 쓰는 구성이다. §112 는 큐를 하나만 쓰면 패킷 처리가 한 vCPU 나 한 처리 경로에 몰릴 수 있어서 이것을 최적화 방향으로 들었을 뿐, 이 가상 머신들의 큐 수는 적지 않았다. 이 물음은 그 쏠림이 실제로 일어나는지를 재지 않고, 두 가 애초에 큐를 몇 개 쓰도록 구성되어 있는지를 읽는다.
## 관계
- **Guest 의 packet 이 Host Physical NIC 에 닿기까지 — virtio-net · virtqueue · vhost-net · TAP · Bridge**
게스트와 호스트 backend 가 virtqueue 로 무엇을 주고받는지를 이 개념이 설명한다. 큐를 몇 개 두느냐는 그 구조 위의 설정이다.
게스트와 호스트 백엔드가 virtqueue 로 무엇을 주고받는지를 이 개념이 설명한다. 큐를 몇 개 두느냐는 그 구조 위의 설정이다.
- **이 호스트에서 vhost-net 이 실제로 packet datapath 를 맡고 있는가**
큐를 실제로 돌리는 backend 가 어느 쪽이냐에 따라 큐 수를 읽을 곳이 달라진다.
큐를 실제로 돌리는 백엔드가 어느 쪽이냐에 따라 큐 수를 읽을 곳이 달라진다.
- **부하 중 network 가상화가 지연을 바꿀 만큼 호스트 CPU 를 쓰는가**
큐가 하나로 나왔을 때 그것이 실제 병목인지는 부하 구간의 vCPU 별 사용량이 답한다.
@@ -37,8 +37,8 @@ multi-queue 는 virtio-net 이 송수신 큐를 여러 개 두고 쓰는 구성
Packet processing 병렬화
Single queue bottleneck 완화
Multi-core 활용
- §112 는 그 효과가 workload · CPU affinity · IRQ placement · queue configuration 에 따라 달라진다고 덧붙였다. IRQ(Interrupt Request, 인터럽트 요청)는 장치가 처리할 일이 생겼음을 CPU 에 알리는 신호다. §117.5 가 그 분포를 확인 대상으로 든 것을 보면, 큐를 나눠 두어도 그 신호를 한 vCPU 가 몰아서 받으면 처리는 한 곳에 몰릴 수 있다.
- §100 은 virtqueue 를 게스트와 호스트 backend 가 디스크립터(descriptor)를 써서 I/O 버퍼를 주고받는 공유 큐 구조로 놓고, 네트워크에서는 보통 TX/RX 큐를 쓴다고 적었다. TX virtqueue 는 게스트에서 호스트로, RX virtqueue 는 호스트에서 게스트로 버퍼를 넘긴다.
- §112 는 그 효과가 workload · CPU affinity · IRQ placement · queue configuration 에 따라 달라진다고 덧붙였다. IRQ(Interrupt Request, 인터럽트 요청)는 장치가 처리할 일이 생겼음을 CPU 에 알리는 신호다.
- §100 은 virtqueue 를 게스트와 호스트 백엔드가 디스크립터(descriptor)를 써서 I/O 버퍼를 주고받는 공유 큐 구조로 놓고, 네트워크에서는 보통 TX/RX 큐를 쓴다고 적었다. TX virtqueue 는 게스트에서 호스트로, RX virtqueue 는 호스트에서 게스트로 버퍼를 넘긴다.
- §117.5 는 single queue bottleneck 을 큐 하나나 vCPU 하나에 패킷 처리가 몰리는 문제로 놓고, 확인 대상 넷을 들었다.
virtio multi-queue
IRQ distribution
@@ -50,7 +50,10 @@ multi-queue 는 virtio-net 이 송수신 큐를 여러 개 두고 쓰는 구성
queue count
IRQ distribution
- 같은 §122 에서 OQ-1 과 OQ-2 는 돌릴 명령을 그대로 적었고, OQ-5 는 확인 대상 넷의 이름만 적었다. §117.5 가 든 넷도 이름이다.
- 두 가상 머신에 설정된 큐 수가 근거 문서에 없다. 게스트가 몇 개를 쓰고 있는지도, IRQ어느 vCPU 에 붙어 있는지도 적혀 있지 않다.
- §197 과 §218 은 이 실험대의 vCPU 배분을 실측으로 적었다. k3s 노드 kc-lab-1 과 kc-lab-2 가 각각 vCPU 2 이고 엣지 게스트가 1 이며, 합 5 를 논리 코어 8 위에 얹었다. §112큐와 vCPU 를 하나씩 짝지어 든 예는 넷씩이라 이 게스트들의 수와 다르다.
- §247 은 VM 이 부팅하면 게스트 커널이 virtio NIC 를 인식하고 DHCP 클라이언트가 DHCPDISCOVER 를 브로드캐스트한다고 적었다. 이 게스트들의 NIC 가 virtio 라는 것은 거기까지 나온다.
- §201 은 게스트 안에서 본 인터페이스 이름을 한 줄 남겼다. 엣지 게스트가 첫 부팅에서 enp1s0 으로 192.168.122.10/24 을 받았다. ethtool 에 넣을 이름이 그 줄에서 나오지만 k3s 노드 두 대의 인터페이스 이름은 적혀 있지 않다.
- 두 가상 머신에 설정된 큐 수가 SSOT 에 없다. 게스트가 몇 개를 쓰고 있는지도, IRQ 가 어느 vCPU 에 붙어 있는지도 적혀 있지 않다.
- §126 이 적은 실습 순서 열둘 가운데 multi-queue / offload 확인은 마지막 열두째다.
- 네트워크 계층의 확인 명령을 모아 둔 §118 에는 큐 수나 IRQ 분포를 읽는 명령이 없다. Guest NIC 항목에 적힌 것은 ip link · ip addr · ip route · ip neigh 이고, virtio 장치 항목은 lspci 와 lsmod | grep virtio 다. ethtool 은 Physical NIC 항목에서 인터페이스 이름을 받는 형태로만 나온다.
@@ -59,6 +62,7 @@ multi-queue 는 virtio-net 이 송수신 큐를 여러 개 두고 쓰는 구성
- 두 가상 머신의 구성과 게스트 내부를 지금 읽을 수 있다고 본다.
- §112 가 든 RX Queue 넷과 vCPU 넷의 짝은 multi-queue 를 설명하려고 든 예시이지 이 호스트에서 읽은 값이 아니다.
- 설정에 적힌 큐 수와 게스트가 실제로 쓰는 큐 수를 따로 읽어야 한다고 전제한다. 설정에 여럿을 적어 두면 게스트 드라이버가 그만큼 쓴다는 서술이 근거 문서에 없기 때문이다.
- 큐를 여럿 두어도 IRQ 를 한 vCPU 가 몰아서 받으면 처리가 한쪽에 몰린다고 본다. §117.5 는 IRQ distribution 을 확인 대상으로 들었을 뿐 그렇게 된다고 적지는 않았다.
- 두 가상 머신의 NIC 구성이 확인하는 동안 바뀌지 않는다.
## 미지수
@@ -66,7 +70,7 @@ multi-queue 는 virtio-net 이 송수신 큐를 여러 개 두고 쓰는 구성
- 두 가상 머신의 virtio-net 에 설정된 큐 수.
- 게스트가 실제로 쓰고 있는 큐 수, 그리고 그 수가 설정값과 같은지.
- 각 큐의 IRQ 가 여러 vCPU 에 흩어져 있는지 한 vCPU 에 몰려 있는지.
- 두 가상 머신의 vCPU 수. §112 가 큐와 vCPU 를 하나씩 짝지어 든 예 수를 나란히 놓아야 읽히는데, vCPU 수도 네트워크 쪽 근거에는 없다.
- 설정된 큐 수와 vCPU 수의 관계. vCPU 는 §218 이 두 노드 모두 2 로 적었으므로, 큐가 몇이면 §112 가 든 하나씩 대응이 되는지 수를 읽어야 갈린다.
- 이 환경의 RSS/RPS/XPS 설정. §117.5 가 확인 대상으로 들었지만 값을 읽는 방법은 적지 않았다.
## 제약
@@ -74,6 +78,7 @@ multi-queue 는 virtio-net 이 송수신 큐를 여러 개 두고 쓰는 구성
- 이 호스트에서 잰 값이 하나도 없다. 근거는 개념을 정리한 문서 한 편이고 §112 의 큐 넷과 vCPU 넷은 예시 숫자다.
- 이 물음은 큐 구성이 무엇인지까지만 답한다. 쏠림이 실제 병목인지는 부하를 걸어야 갈리고, 그 부하 측정은 다른 물음이 가져간다.
- 확인하는 동안 NIC 구성을 바꾸지 않는다. 큐 수를 늘려 놓고 읽으면 지금 실험이 어떤 구성에서 돌았는지 못 본다.
- 게스트 안에서 읽는 항목이 둘이라 게스트가 떠 있어야 한다. SSOT 가 마지막으로 적은 게스트 상태는 §202 의 철거이고, 그 뒤 다시 세웠는지는 적혀 있지 않다.
- §118 이 큐 수와 IRQ 분포를 읽는 명령을 적지 않았으므로, 실행한 명령과 그 출력을 함께 증거로 남겨야 다음 사람이 같은 값을 다시 읽는다.
## 선택지
@@ -21,7 +21,7 @@ source:
# 이 호스트의 가상 머신 네트워크는 Bridge 인가 NAT 인가 Routing 인가
§98 은 가상 머신 네트워크를 분석하기 전에 Bridge 기반인가 · Routing 기반인가 · NAT(Network Address Translation, 네트워크 주소 변환) 기반인가를 구분하라고 적었다. 셋은 프레임이 지나는 계층이 다르고, 그에 따라 호스트의 L3 경로와 Netfilter 가 끼어드는지도 갈린다. 이 물음은 성능을 재지 않는다. 제3부가 서술한 경로 가운데 어느 절이 이 호스트에 그대로 적용되는지를 먼저 확정한다.
이 실험대는 NAT(Network Address Translation, 네트워크 주소 변환) 를 쓴다. 이더넷 없이 WiFi 만 있어 브리지를 못 쓴다고 §178 이 관측으로 적었다. §249 가 세 모드를 견준 표에서 브리지와 macvtap 은 둘 다 「WiFi라 불가」이고 채택 표시는 NAT 한 줄에만 붙어 있다. 감수한 것도 같은 표에 적혀 있다 — VM 주소가 사설이라 LAN 에서 VM 으로 바로 들어가지 못하고 포워딩이 필요하다. 엣지를 게스트로 옮긴 뒤 호스트 커널 DNAT(Destination NAT, 목적지 주소 변환) 와 libvirt 체인의 구멍이 새로 필요해진 것이 그 포워딩이다. 다만 §122 OQ-1 이 요구한 다섯 명령의 출력이 없어 virbr0 에 무엇이 붙어 있고 라우팅이 어떻게 걸려 있는지는 아직 적지 못한다.
## 관계
@@ -37,14 +37,14 @@ source:
## 사실
- §96 은 Linux Bridge 를 호스트 커널 안의 L2 소프트웨어 스위치로 적었다. 이더넷 프레임의 Destination MAC 을 보고 어느 포트로 보낼지 정하고, MAC learning 을 하며, 여러 가상 포트와 물리 포트를 잇는다.
- §97 은 Routing 을 L3 에서 IP 를 보고 내리는 결정으로 놓았다. Bridge 가 같은 이더넷 네트워크를 잇는 것과 달리 Routing 은 서로 다른 IP 네트워크를 잇고, destination IP 를 보고 어느 인터페이스나 next-hop 으로 보낼지 정한다.
- §98 은 NAT 을 패킷의 IP/Port 정보를 바꾸는 것으로 놓고, 가상 머신이 private subnet 을 쓰면 호스트가 NAT gateway 처럼 동작할 수 있다는 예를 들었다.
- §97 은 Routing 을 L3 에서 IP 를 보고 내리는 결정으로 놓았다. Bridge 가 같은 이더넷 네트워크를 잇는 것과 달리 Routing 은 서로 다른 IP 네트워크를 잇고, 목적지 IP 를 보고 어느 인터페이스나 next-hop 으로 보낼지 정한다.
- §98 은 NAT 을 패킷의 IP 와 포트 정보를 바꾸는 것으로 놓고, 가상 머신이 사설 서브넷을 쓰면 호스트가 NAT 게이트웨이처럼 동작할 수 있다는 예를 들었다.
VM : 192.168.122.10
Host NAT 를 지난 뒤 : 203.0.113.10
- §96 과 §97 은 절 끝에 확인 명령을 달았다. §96 은 bridge link · bridge fdb show · ip link show type bridge 를, §97 은 ip route 를 다. 셋을 구분하라고 적은 §98 에는 확인 명령이 없다.
- §96 과 §97 은 절 끝에 확인 명령을 달았다. §96 은 bridge link · bridge fdb show · ip link show type bridge 를, §97 은 ip route 를 들었다. 셋을 구분하라고 적은 §98 에는 확인 명령이 없다.
- §114 는 Bridge 가 단순 L2 forwarding 만 하는 구성이면 프레임이 호스트의 일반적인 L3 TCP/IP 스택을 지나지 않고 다른 TAP 으로 나갈 수 있다고 적었다. 호스트가 Routing · NAT · Host-local termination · Firewall 을 맡으면 그때는 L3/Netfilter 경로가 끼어든다.
- 그래서 §114 는 Physical NIC → Host TCP/IP Stack → Bridge 를 고정된 패킷 경로로 보면 안 되고, 실제 경로는 bridge/routing/NAT 구성에 따라 달라진다고 못 박았다.
- §94 는 이 문서가 기준으로 삼은 구조를 virtio-net + vhost-net + TAP + Linux Bridge 로 적고, 실제 환경은 Bridge · NAT · Routed Network · macvtap · SR-IOV · VFIO passthrough · Open vSwitch · Kubernetes CNI 에 따라 달라질 수 있다고 덧붙였다.
- §94 는 이 문서가 기준으로 삼은 구조를 virtio-net + vhost-net + TAP + Linux Bridge 로 적었다. 실제 환경은 Bridge · NAT · Routed Network · macvtap · SR-IOV · VFIO passthrough · Open vSwitch · Kubernetes CNI 에 따라 달라질 수 있다고 덧붙였다.
- 확인할 명령은 §122 OQ-1 이 다섯 줄로 적어 두었다.
정의된 가상 네트워크 열거 : virsh net-list --all
그 가상 네트워크의 정의 읽기 : virsh net-dumpxml 에 이름을 넣는다
@@ -52,26 +52,39 @@ source:
어느 인터페이스가 어느 브리지의 포트인지 : bridge link
라우팅 테이블 : ip route
- §118 이 같은 계층에 든 명령 가운데 virsh net-info 와 ip rule 은 OQ-1 의 다섯 줄에 없다.
- 이 호스트에서 그 명령을 돌린 출력은 SSOT 에 없다. 셋 중 무엇인지도 적혀 있지 않다.
- §178 은 이 실험대의 대상 환경을 관측으로 적었다. test-server 는 Arch Linux 이고 이더넷 없이 WiFi 만 있어 브리지를 못 쓴다. 그래서 libvirt NAT(virbr0) 과 호스트 진입 구조를 택했고, 게스트는 Debian 12 genericcloud 3대로 엣지 1대와 k3s 2노드다.
- §250 은 WiFi 에서 브리지가 안 되는 이유를 적었다. 802.11 데이터 프레임은 기본적으로 주소 필드가 3개다(3-address 모드). AP(Access Point, 무선 접속 장치)는 연결(association)된 단말(station)의 MAC 만 알고 있어서, 그 단말이 자기 것이 아닌 출발지 MAC 을 단 프레임을 보내면 버린다. 브리지된 가상 머신은 자기 MAC 을 출발지로 쓰므로 정확히 그런 프레임을 보낸다.
- §250 이 든 우회 수단은 둘이다.
4-address 모드(WDS) : AP 와 클라이언트 드라이버가 모두 지원해야 하는데 실제로는 거의 지원되지 않는다
USB 이더넷 어댑터를 꽂는 것 : 현실적인 우회
- §250 은 test-server 에 이더넷이 없고 wlo1 만 있어서 「VM 에 LAN IP 를 직접 주자」는 계획이 물리적으로 성립하지 않는다고 적었다. 이 제약 하나가 토폴로지 전체를 NAT + 호스트 nginx 로 확정시켰다고 밝혔다. 이더넷 인터페이스가 있는지는 ip -brief link 로 보고 무선 인터페이스 정보는 iw dev 로 본다고 확인 명령을 달았다.
- §249 는 세 모드를 한 표로 놓고 이 실험대가 무엇을 골랐는지 적었다.
NAT(virbr0) : VM 주소는 192.168.122.x 사설, LAN 에서 VM 접근 불가, 채택
브리지(br0) : LAN 에서 직접 IP, WiFi 라 불가
macvtap : LAN 에서 직접 IP(호스트↔VM 은 제외), WiFi 라 불가
- §245 는 libvirt 의 default 네트워크를 소프트웨어 브리지 virbr0 과 거기 붙은 NAT 규칙으로 적었다. 기본 대역은 192.168.122.0/24 이고 호스트가 .1 을 가지며, VM 들은 이 브리지에 연결되어 서로 직접 통신하고 외부로 나갈 때만 호스트 IP 로 마스커레이딩된다. NAT 가 막는 것은 외부에서 VM 으로 들어오는 방향이다.
- §201 은 이 호스트에서 ip -br addr show virbr0 을 돌린 출력을 남겼다. VM 세 대가 돌 때는 virbr0 이 UP 이고 192.168.122.1/24 을 갖고, 전부 철거한 뒤에는 주소는 그대로인 채 DOWN 이다.
- §180 과 §255 는 밖에서 게스트로 들어오는 방향이 FORWARD 경로라 libvirt 의 guest_input 체인을 지난다고 적었다. 그 체인이 oif "virbr0" reject 로 끝나 게스트 대역으로 새로 들어오는 연결을 거절했고, 밖에서 친 curl 은 connection refused 를 받았다. 호스트 안에서 같은 게스트 주소로 친 curl 은 OUTPUT 경로라 forward 를 타지 않아 404 로 응답했다. 그 reject 줄을 범인으로 확정한 것은 카운터다. 밖에서 curl 을 네 번 쳤을 때 그 줄에 packets 4 bytes 240 이 찍혀 있었다.
- 이 호스트에서 §122 OQ-1 의 다섯 명령을 돌린 출력은 SSOT 에 없다.
## 가정
- 호스트에 붙어 virsh 와 ip 계열 명령을 실행할 수 있다고 본다.
- libvirt 가상 네트워크로 정의된 구성이면 virsh net-list --all 에 이름이 나온다고 본다. 호스트가 미리 만들어 둔 브리지에 가상 머신을 직접 붙인 구성이면 그 목록에 아무 이름도 나오지 않을 수 있다.
- §201 과 §247 이 virsh net-update default 로 DHCP 예약을 넣었으므로 이 호스트의 가상 머신이 libvirt 의 default 네트워크를 쓴다고 본다. 그 네트워크 말고 다른 정의가 더 있는지는 열거해 봐야 갈린다.
- 확인하는 동안 네트워크 구성이 바뀌지 않는다고 전제한다.
- 셋 가운데 하나로 갈린다고 보고 물음을 세웠다. 다만 §114 가 Bridge 구성에도 Routing 과 NAT 과 Firewall 이 함께 걸릴 수 있다고 적었으므로, 하나로 갈리지 않으면 걸린 것을 모두 적는다.
## 미지수
- 이 호스트의 가상 머신 네트워크가 Bridge 기반인지 NAT 기반인지 Routing 기반인지.
- libvirt 가상 네트워크로 정의되어 있는지, 아니면 호스트의 브리지에 가상 머신이 직접 붙어 있는지.
- 정의되어 있다면 그 가상 네트워크의 forward mode 와 bridge 이름이 무엇인지.
- 가상 머신을 떠난 프레임이 호스트의 L3/Netfilter 경로를 지나는지.
- §178 과 §249 가 NAT 이라고 적은 것이 libvirt 네트워크 정의의 forward mode 로도 그렇게 적혀 있는지. virsh net-dumpxml default 를 통째로 찍은 출력이 SSOT 에 없고, §247 이 인용한 것은 그 정의의 dhcp 절뿐이다.
- default 말고 정의된 가상 네트워크가 더 있는지. virsh net-list --all 의 출력이 없다.
- virbr0 에 어느 인터페이스가 포트로 붙어 있는지, 그리고 이 호스트의 라우팅 테이블이 무엇인지. bridge link 와 ip route 의 출력이 없다.
- 게스트끼리 오가는 프레임과 게스트가 밖으로 나가는 프레임이 호스트의 L3/Netfilter 경로를 지나는지. 밖에서 게스트로 들어오는 방향만 §180 이 FORWARD 로 관측했다.
## 제약
- 구성이 셋 중 무엇인지를 확정하는 데서 끊는다. 그 구성이 지연에 얼마나 영향을 주는지는 부하 중 호스트 CPU 사용을 보는 물음이 받는다.
- 이 호스트에서 읽은 출력이 없어 다른 장비의 구성을 근거로 삼지 않는다.
- SSOT 에 남은 출력은 §201 의 virbr0 주소와 상태뿐이다. 나머지 네 명령은 다른 장비에서 읽은 값으로 대신하지 않는다.
- 돌릴 명령은 §122 OQ-1 이 정해 두었다. 실행한 명령과 출력을 함께 남겨야 다음 사람이 같은 값을 다시 읽는다.
## 선택지
@@ -86,11 +99,11 @@ libvirt 로 정의하지 않고 호스트 브리지에 직접 붙인 구성이
ip link 로 실재하는 인터페이스를 세우고, bridge link 로 어느 인터페이스가 어느 브리지의 포트인지를 잡고, ip route 로 L3 결정을 본다. libvirt 로 정의했든 안 했든 호스트에 실재하는 것을 읽으므로 구성 방식과 무관하게 답이 나온다.
forward mode 라는 이름으로 적힌 의도는 나오지 않으므로, NAT 이 걸려 있는지는 routing 과 방화벽 규칙을 따로 봐야 한다. §117.3 이 NAT/Firewall 오류에 든 것 nftables · iptables · NAT rules · IP forwarding 이라는 확인 대상 이름이고 돌릴 명령이 아니다.
forward mode 라는 이름으로 적힌 의도는 나오지 않으므로, NAT 이 걸려 있는지는 routing 과 방화벽 규칙을 따로 봐야 한다. §117.3 이 NAT/Firewall 오류에 든 것 nftables · iptables · NAT rules · IP forwarding 이라는 확인 대상 이름이고 돌릴 명령이 아니다. 방화벽 쪽 명령은 §255 가 대신 적어 두었다 — libvirt 체인 하나를 열어 보는 sudo nft -a list chain ip libvirt_network guest_input 이다. 그 명령이 통하는 것은 이 호스트가 nftables 백엔드이기 때문이고, §180 은 libvirt 의 firewall_backend 가 iptables 일 때도 같은지는 재지 않았다고 적었다.
### 3. tcpdump 로 경로부터 잡는다 — 제외
§119 는 Host NIC · Bridge · TAP · Guest NIC 에서 tcpdump 로 패킷을 추적하는 순서를 적어 두었다. 다만 그 추적은 캡처를 걸 인터페이스 이름을 이미 알고 있을 때 성립한다. 지금은 브리지 이름도 TAP 이름도 모르므로 명령의 대상을 채울 수 없다. §126 의 실습 순서에서도 Bridge/NAT/Route 확인이 셋째이고 Host Nginx → VM packet path tcpdump 는 여덟째다. 이 방법은 이 물음이 닫힌 뒤 실제 패킷 경로를 묻는 물음이 받는다.
§119 는 Host NIC · Bridge · TAP · Guest NIC 에서 tcpdump 로 패킷을 추적하는 순서를 적어 두었다. 다만 그 추적은 캡처를 걸 인터페이스 이름을 이미 알고 있을 때 성립한다. 브리지 이름은 §245 와 §201 이 virbr0 으로 적었지만 TAP 이름은 아직 없으므로 게스트 쪽 대상을 채울 수 없다. §126 의 실습 순서에서도 Bridge/NAT/Route 확인이 셋째이고 Host Nginx → VM packet path tcpdump 는 여덟째다. 이 방법은 이 물음이 닫힌 뒤 실제 패킷 경로를 묻는 물음이 받는다.
## 다음 검증
@@ -23,9 +23,7 @@ source:
# packet 이 어디서 끊겼는지는 계층마다 capture 해서 가른다
가상 머신이 밖과 통신하지 못할 때 게스트 안에서만 원인을 찾으면 호스트의 브리지와 TAP 연결은 마지막에야 보게 된다. 그 사이의 계층은 애플리케이션 로그에 아무것도 남기지 않는다.
그래서 패킷이 지나야 할 지점마다 캡처를 걸고 어디까지 보였는지로 구간을 좁힌다. 호스트의 물리 NIC(Network Interface Card) 와 브리지, 가상 머신에 붙은 TAP, 게스트 안의 인터페이스 넷이 그 지점이고, 보이지 않은 첫 지점의 앞 구간이 의심 구간이 된다.
가상 머신이 밖과 통신하지 못하면 게스트부터 뒤지지 말고 패킷이 지나야 할 네 지점에 캡처를 걸어 어디까지 보였는지로 구간을 좁힌다. 호스트의 물리 NIC(Network Interface Card) 와 브리지, 가상 머신에 붙은 TAP, 게스트 안의 인터페이스가 그 지점이다. 보이지 않은 첫 지점의 앞 구간이 의심 구간이 된다.
## 관계
@@ -44,7 +42,7 @@ source:
이 기준은 두 가지를 막는다.
가상 머신이 통신하지 못할 때 게스트 안쪽과 애플리케이션부터 뒤지느라 호스트의 브리지와 TAP 연결을 늦게 보는 일이 하나다.
가상 머신이 통신하지 못할 때 게스트 안쪽과 애플리케이션부터 뒤지느라 호스트의 브리지와 TAP 연결을 늦게 보는 일이 하나다. 그 사이의 계층은 애플리케이션 로그에 아무것도 남기지 않는다.
오프로드 때문에 실제 wire 와 다르게 보이는 정상 패킷을 결함으로 판정하는 일이 다른 하나다.
@@ -90,7 +88,9 @@ tap 에는 보이는데 게스트 NIC 에 안 보이면 virtio 와 vhost, 게스
가상 머신에서 인터넷으로 나가지 못하거나 외부에서 가상 머신에 접근하지 못하거나 특정 포트만 실패하면 nftables 와 iptables, NAT(Network Address Translation) 규칙, IP(Internet Protocol) forwarding 설정을 확인 대상으로 둔다.
증상 셋을 각각 다른 기준으로 나누지 않은 것은 셋이 모두 어느 지점에서 끊겼는가로 환원되기 때문이다. 따로 적으면 같은 규칙의 부분 증상이 셋으로 늘어난다.
이 실험대가 가장 오래 막힌 지점도 여기였다. 밖에서 온 요청만 엣지 게스트에 닿지 않았고, 거절한 것은 libvirt 가 자기 테이블의 guest_input 체인 끝에 둔 reject 규칙이었다(§180).
증상 셋을 각각 다른 기준으로 나누지 않은 것은 셋이 모두 어느 지점에서 끊겼는가 하나로 모이기 때문이다. 따로 적으면 같은 규칙의 부분 증상이 셋으로 늘어난다.
### 5. 패킷 크기와 체크섬이 예상과 다르다는 이유만으로 결함이라고 읽지 않는다
@@ -122,10 +122,12 @@ KVM(Kernel-based Virtual Machine)/QEMU/libvirt 로 만든 가상 머신이 외
브리지가 L2 전달만 하는 구성에서는 호스트의 L3 관측에 안 보이는 것이 정상인데, 여섯 번째 규칙이 그 경우다.
네 지점이 모두 보이는데 느린 경우는 이 기준이 다루지 않는다. Keycloak 실험 결과를 애플리케이션 원인으로 읽기 전에 network 계층을 따로 검증한다 쪽으로 넘긴다.
네 지점이 모두 보이는데 느린 경우는 이 기준이 다루지 않는다. Keycloak 실험 결과를 애플리케이션 원인으로 읽기 전에 network 계층을 따로 검증한다 쪽으로 넘긴다.
이 저장소에는 아직 이 절차를 실제로 돌린 출력이 없다. 여기 적은 판독은 원문 문서가 서술한 것이고 이 호스트의 캡처로 확인한 것이 아니다.
§180 의 사건에서도 구간을 좁힌 것은 네 지점 캡처가 아니었다. 호스트에서 친 curl 은 404 를 받는데 밖에서 친 curl 만 connection refused 를 받았다는 차이가 단서였고, 범인은 그 reject 규칙의 카운터가 4 패킷 240 바이트로 밖에서 친 횟수와 맞아떨어져서 확정했다.
## 예시
- 물리 NIC 에 보이고 브리지에도 보이는데 tap 에 안 보임 : 호스트의 브리지와 tap 연결을 먼저 확인한다

Some files were not shown because too many files have changed in this diff Show More