fix(setup): 실험대에서 35편을 끝까지 밟고 어긋난 명령·결과 31건을 고친다

test-server 를 비우고 다시 세운 뒤 Setup 기록 35편(virtualization 9 ·
keycloak-session-store 26)을 문서에 적힌 명령 그대로 쳤다. 어긋난 자리를
기록과 SSOT 양쪽에 실측과 함께 넣었다.

막히던 것
- 04 의 인증서 경로가 live/hyeonworks.com 이라 nginx 가 [emerg] 로 안 떴다.
  실제 계보는 live/auth.hyeonworks.com 이고 「문제가 생기면」은 진단이 거꾸로였다
- 인증서가 와일드카드가 아니다. SAN 이 auth·app1·app2 셋뿐이라 그 밖의 이름은
  TLS 에서 끊기고 curl 이 exit 60 · %{http_code} 000 을 낸다. SSOT 안에서
  두 문단이 서로 어긋나 있었다
- A-7 14번 ①이 kc-lab-1 에서 여섯 줄 다 실패하는데 마지막 date 만 「차단」을 찍는다

검사가 실패할 수 없던 자리
- B-1 의 세션 키 고르기는 앞 단계가 $KEY 를 채워 둬서 루프가 한 건도 못 맞혀도
  통과한다. KEY= 로 비우고 키마다 1/0 을 찍게 바꿨다
- k3s-agent 유닛의 sed -i 는 패턴에 $HOME 이 들어 있어 아무 줄도 안 바꾼 채 성공한다

certbot
- renew --dry-run 의 종료 코드는 성공도 0, 실패도 0, 다른 사유의 실패는 1 이다.
  본문의 renew failure(s) 로만 판정할 수 있다
- --dry-run 은 staging 서버를 쓰는데 renewal/*.conf 의 account= 는 운영 계정을
  가리킨다. 실패한 dry-run 이 staging 계정을 하나 더 만들어 다음 실행이 계속 멎는다
- 훅을 755 로 놓고 시뮬레이션이 성공해도 Running deploy-hook command 는 안 나온다.
  certbot 2.1.0 에는 --run-deploy-hooks 도 없다
- 강제 갱신은 실제로 쳤고 서빙까지 닿았다. serial 06F3E0EF…1373 → 065547…3DF1,
  notAfter Dec 3 → Dec 16, nginx worker 2629 4712 → 4745 4754

독자가 칠 수 있는 형태로
- 안 되는 형태가 번호 붙은 단계에 앉아 있던 8곳을 뒤집고, 되는 형태를 ①로 올렸다
- 랩 안에서 공개 이름을 치는 curl 65줄에 --resolve 를 붙였다. 붙인 형태를 실제로
  쳐서 문서가 적은 값과 같은지 확인했다
- 힙독·sed -i·echo >>·&&·|| 를 편집기 + 파일 리스팅 + 분할 형태로 바꿨다
- 닫는 코드펜스가 빠져 뒤 200여 줄의 블록 종류가 뒤집혀 있던 곳을 포함해 3곳을 고쳤다

관문: check_body PASS · check_prose error 0 · check_evidence 두 프로젝트 문제 없음 ·
verify-tech-log-tree error 0 · verify-project-layout error 0 · 코드펜스 전수 0건

남은 것: B-0 주입은 keycloak-pattern 저장소의 소스를 고치고 이미지를 다시 구워야
해서 안 했다(unknown).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-09-17 19:38:12 +09:00
co-authored by Claude Opus 5
parent 4a457afde9
commit 32e39e20aa
150 changed files with 6066 additions and 269 deletions
+581 -31
View File
@@ -8325,6 +8325,21 @@ source ~/.bashrc
virsh uri
```
libvirt 쪽 설정도 같다. 디렉터리는 명령으로 만들고 파일은 편집기로 연다.
```bash
mkdir -p ~/.config/libvirt
nano ~/.config/libvirt/libvirt.conf
```
그 파일에 이 줄을 더한다.
```text
uri_default = "qemu:///system"
```
이 나눈 형태는 이 실험대에서 치지 않았다(unknown).
**확인** — 지금 셸이 어느 하이퍼바이저를 보고 있는가
```bash
@@ -8414,16 +8429,68 @@ virsh list --all
- (observed) libvirt `12.7.0` · `QEMU emulator version 11.1.1` · 그룹 세 개 ·
`default` 네트워크 `active`/autostart `yes` · `virsh uri``qemu:///system`
- (unknown) `lsmod | grep kvm` 의 출력은 캡처해 두지 않았다. 가이드도 줄 모양만
적고 값을 싣지 않았
- (unknown) 가이드의 실측 줄은 「이 실험대의 호스트는 16 코어 전부에서
지원한다」인데 §178 의 대상 환경은 **논리 코어 8**(i5-1135G7)이다. 두 값이
어긋나고, 어느 쪽이 이 호스트의 값인지는 재지 않았다
- (observed) `lsmod | grep kvm` 의 출력을 2026-09-17 에 받았다. 세 줄이고 마지막
칸이 그 모듈을 쓰는 수
```text
kvm_intel 524288 11
kvm 1490944 6 kvm_intel
irqbypass 16384 1 kvm
```
- (observed) **이 호스트는 논리 코어 8 이다.** 가이드의 「16 코어 전부」가 아니라
§178 의 값이 맞다. 2026-09-17 에 `nproc``8`, `lscpu`
`11th Gen Intel(R) Core(TM) i5-1135G7`, 물리 4 코어에 코어당 스레드 2 라고 답했다
```bash
nproc
lscpu | grep -E "^Model name|^CPU\(s\):|^Core\(s\)|^Thread\(s\)"
```
- (observed) `systemctl status libvirtd.socket` 도 같은 날 쳤다. 보는 줄은 `Active`
`Listen``Triggers` 셋이고, `Triggers``libvirtd.service` 를 가리키는
것이 소켓 활성화가 걸렸다는 뜻이다
```text
● libvirtd.socket - libvirt legacy monolithic daemon socket
Loaded: loaded (/usr/lib/systemd/system/libvirtd.socket; enabled; preset: disabled)
Active: active (running) since Thu 2026-09-03 19:00:35 KST; 1 week 6 days ago
Triggers: ● libvirtd.service
Listen: /run/libvirt/libvirt-sock (Stream)
```
- (observed) 볼륨 일곱이 실제로 먹는 양도 그날 쟀다. **선언한 크기와 실제 할당이
크게 다르다** — 오버레이라 쓴 만큼만 먹는다. 철거 절의 「엣지 디스크는 재 두지
않았다」와 거기서 역산한 「1GB 안팎」이 이 값으로 다시 보인다
```bash
virsh vol-info --pool default kc-lab-edge.qcow2
```
```text
base.qcow2 Capacity=3.00 GiB Allocation=323.25 MiB
kc-lab-1.qcow2 Capacity=20.00 GiB Allocation=5.40 GiB
kc-lab-2.qcow2 Capacity=20.00 GiB Allocation=3.85 GiB
kc-lab-edge.qcow2 Capacity=10.00 GiB Allocation=316.26 MiB
seed-kc-lab-{1,2,edge}.iso Capacity=370.00 KiB Allocation=372.00 KiB
```
- (observed) `cloud-init status --long` 의 마지막 줄이 시드를 `bus=virtio` 로 붙이는
이유를 그대로 뒷받침한다. `seed=/dev/vdb` 이므로 CD-ROM(`sr0`)이 아니라 디스크로
잡혔고, cloud-init 이 거기서 데이터소스를 찾았다
```text
status: done
boot_status_code: enabled-by-generator
last_update: Thu, 17 Sep 2026 04:24:22 +0000
detail:
DataSourceNoCloud [seed=/dev/vdb][dsmode=net]
```
- (unknown) **원본 가이드에 되돌리는 절차가 없다.** 패키지·그룹·유닛·`.bashrc`·
네트워크 autostart 를 걷어내 본 적이 없다
- (unknown) `sudo modprobe kvm_intel` `systemctl status libvirtd.socket`
막혔을 때 치라고 가이드가 적어 둔 것이고, 이 실험대에서는 막히지 않아 치지
않았다
- (unknown) `sudo modprobe kvm_intel` 은 막혔을 때 치라고 가이드가 적어 둔
것이고, 이 실험대에서는 막히지 않아 치지 않았다
- (external) Debian/Ubuntu 패키지 이름은 가이드가 참고로 적어 둔 것이고 이
실험대는 Arch 로 세웠다
@@ -8548,6 +8615,24 @@ cat ~/.ssh/id_ed25519.pub
openssl rand -base64 18
```
**따라 하는 사람은 ① 을 둘로 나눈다.** `[ -f … ] || …` 는 「키가 있으면 두고
없으면 만든다」를 한 줄에 접어 넣은 형태라, 배울 것보다 단축 평가를 먼저 읽게
된다. 먼저 꺼내 보고, 없을 때만 만든다.
```bash
cat ~/.ssh/id_ed25519.pub
```
`ssh-ed25519 …` 한 줄이 나오면 키가 이미 있는 것이고 아래를 건너뛴다.
`No such file or directory` 면 키가 없다.
```bash
ssh-keygen -t ed25519 -N '' -f ~/.ssh/id_ed25519
```
만든 뒤 다시 `cat` 해서 공개키 줄을 꺼낸다. 이 나눈 형태는 이 실험대에서 치지
않았다(unknown).
**이 실험대는 이렇게 했다**(원본 01 본문) — 자리표시자 셋을 `sed` 로 한 번에
바꿨다.
@@ -9023,9 +9108,20 @@ sudo journalctl -u cloud-init -n 50
제7부 §198 이 따로 잰다
- (unknown) `kc-lab-1.yaml` 을 템플릿에서 어떻게 만드는지가 원본에도 없다.
`kc-lab.yaml.example``source/` 에 없어 대조하지 못했다
- (unknown) cloud-init 의 `packages` 에 certbot 이 들어 있었는지가 가이드 안에서
다. 01 의 예시와 03 의 본문은 `curl`·`nftables` 뿐이라 적고, 04 는
`kc-lab.yaml.example` `packages` 에 certbot 이 있다고 적는다
- (observed) **cloud-init 의 `packages` 에 certbot 은 없다** — 2026-09-17 에
다. 뜬 게스트가 실제로 받은 user-data 가 `packages: [curl, nftables]`
줄이고, `kc-lab.yaml.example` 에는 `nginx`·`certbot`·
`python3-certbot-dns-cloudflare` 셋이 **주석으로 막혀** 있다. 04 의 표에
`kc-lab-edge``nginx · certbot` 으로 적힌 것은 그 게스트가 끝나면 맡을
역할이지 cloud-init 이 깔아 준 것이 아니다
```bash
sudo grep -n "packages" /var/lib/cloud/instance/user-data.txt
```
```text
19:packages: [curl, nftables]
```
- (unknown) `scp` 다섯 줄 형태, `virsh vol-list`·`vol-info`·`net-dumpxml
--inactive`·`cloud-init status --long`·`virsh console` 은 가이드가 적어 둔
명령이고 이 실험대가 캡처한 출력이 없다
@@ -9230,7 +9326,31 @@ ssh kc-lab-1 'sudo openssl x509 -in /var/lib/rancher/k3s/server/tls/serving-kube
```
`IP Address:127.0.0.1``IP Address:192.168.122.11` 이 둘 다 보인다. 8번의 SSH
터널이 `127.0.0.1` 로 붙을 수 있는 것도 같은 까닭이다.
터널이 `127.0.0.1` 로 붙을 수 있는 것도 같은 까닭이다. **2026-09-17 에 그 출력을 실제로
받았다**(observed).
```text
X509v3 Subject Alternative Name:
DNS:kubernetes, DNS:kubernetes.default, DNS:kubernetes.default.svc,
DNS:kubernetes.default.svc.cluster.local, DNS:localhost, DNS:kc-lab-1,
IP Address:127.0.0.1, IP Address:0:0:0:0:0:0:0:1,
IP Address:192.168.122.11, IP Address:10.43.0.1, IP Address:192.168.122.11
```
`192.168.122.11` 이 두 번 들어 있는데 중복이고 동작에는 영향이 없다. `10.43.0.1`
클러스터 안에서 API 서버를 부르는 Service 주소다.
**그리고 `journalctl -u k3s-agent` 는 정상일 때도 `E` 로 시작하는 줄이 가득하다**(observed).
이것을 모르면 「막혔을 때 여기를 보라」는 안내대로 열었다가 멀쩡한 노드를 고장으로 읽는다.
같은 순간 `kubectl get nodes` 는 둘 다 `Ready` 였다.
```text
E0917 07:42:16.919786 conn.go:353] "Error on socket receive" err="read tcp 127.0.0.1:10250->127.0.0.1:55428: use of closed network connection"
```
`kubectl exec``logs` 로 연결했다가 끊을 때마다 한 줄씩 남는다. **봐야 할 것은 `E` 인지가
아니라 문구다** — 토큰이나 주소가 틀렸으면 `failed to get CA certs``401 Unauthorized`
나온다.
**3. 토큰을 꺼낸다**
@@ -9360,6 +9480,32 @@ rm ~/node-token
이 여덟 줄 형태는 2026-09-17 에 쳤다 — 설치 자체는 이 판으로 다시 돌리지 않았고,
유닛을 고쳐 다시 시작하는 쪽만 확인했다(observed).
**따라 하는 사람은 유닛을 편집기로 연다.** `sed -i` 는 패턴이 안 맞아도 조용히 성공하고,
여기 패턴에는 `$HOME` 이 들어 있어서 설치할 때와 다른 계정으로 치면 아무 줄도 안 바뀐
`daemon-reload``restart` 가 이어서 돈다. 그 뒤 노드는 지금 떠 있으니 정상으로
보이고, 다음 재부팅에서야 `Waiting for file` 로 멎는다.
```bash
sudo nano /etc/systemd/system/k3s-agent.service
```
`ExecStart=` 아래에서 `'--token-file'` 다음 줄의 경로를 `/etc/rancher/node-token` 으로
고친다. 고친 뒤 그 두 줄이 이렇게 보인다.
```
'--token-file' \
'/etc/rancher/node-token' \
```
저장하고 나와서 유닛을 다시 읽힌다.
```bash
sudo systemctl daemon-reload
sudo systemctl restart k3s-agent
```
이 편집기 형태는 이 실험대에서 치지 않았다(unknown).
**확인** — agent 가 클러스터에 들어왔는가, 그리고 **제 주소로** 들어왔는가
```bash
@@ -10186,11 +10332,34 @@ curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' http://auth.hyeonworks.
301 https://auth.hyeonworks.com/
```
**★ 이 `301` 04 이후의 값이다.** 03 까지만 한 상태라면 ② 와 같은 `404`
정상이고, 이 층이 묻는 것은 코드값이 아니라 **「밖에서 친 것이 엣지까지
닿았는가」**다 — ② 와 같은 응답이 밖에서도 오면 DNAT 과 구멍이 살아 있다는 뜻이다.
**★ 이 `301` 04 이후의 값이다.** 03 까지만 했을 때 나오는 것은 `302` 이고 보내는
곳도 다르다. 2026-09-17 에 구멍을 뚫고 밖에서 재 보니 이랬다(observed).
2026-09-17 에 밖에서 재 보니 **닿지 않았다**(observed).
```
302 https://auth.hyeonworks.com/admin/
```
**리다이렉트를 내는 것이 둘이다.** 04 를 끝내면 엣지 nginx 의
`return 301 https://$host$request_uri` 가 먼저 답해 `301 https://auth.hyeonworks.com/`
이 되고, 03 까지만 했으면 그 블록이 없어 요청이 Keycloak 까지 가서 **Keycloak 이** `/`
`/admin/` 으로 보낸다. 코드도 `301` 이 아니라 `302` 다. `Location``https://`
것도 Keycloak 이 정한다 — `KC_HOSTNAME=https://auth.hyeonworks.com`
`KC_HOSTNAME_STRICT=true` 가 그렇게 시키고, **요청이 `http` 로 들어와도 자기가 아는
주소로 답한다.**
**같은 엣지에 어떻게 치느냐가 코드를 가른다**(observed).
| 어떻게 치나 | 코드 |
|---|---|
| Host 없이 엣지 IP 로 (층 ②) | `404` — Traefik 에 매칭되는 Ingress 가 없다 |
| `Host: auth.hyeonworks.com` 으로 엣지 IP 에 | `302 https://auth.hyeonworks.com/admin/` |
| 밖에서 도메인으로 (층 ③) | `302 https://auth.hyeonworks.com/admin/` |
| 밖에서 도메인 + `/realms/master` | **`200`** |
마지막 줄이 중요하다 — **TLS 를 얹기 전에도 `/realms/master` 는 `200` 이다.** 층 ④ 의
`200` 은 「TLS 가 섰다」가 아니라 「끝까지 이어졌다」를 재는 값이다.
구멍을 뚫기 전에는 같은 `curl` 이 이렇게 끝났다(observed).
```
curl: (7) Failed to connect to auth.hyeonworks.com port 80 after 54 ms
@@ -10204,7 +10373,8 @@ curl: (7) Failed to connect to auth.hyeonworks.com port 80 after 54 ms
**확인 ④ 끝까지 닿나** (TLS 이후)
```bash
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
curl -s -o /dev/null -w '%{http_code}\n' --resolve auth.hyeonworks.com:443:192.168.122.10 \
https://auth.hyeonworks.com/realms/master
```
```
@@ -10216,7 +10386,7 @@ curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/mast
번은 헤더까지 보고, 그다음부터 이 형태로 줄인다.
```bash
curl -I https://auth.hyeonworks.com/realms/master
curl -I --resolve auth.hyeonworks.com:443:192.168.122.10 https://auth.hyeonworks.com/realms/master
```
**이 결과가 의미하는 것** — `200` 이면 nginx → Traefik → 파드까지 2홉이 다
@@ -10309,6 +10479,10 @@ error 로그에 있었는데 잘려 있었고, access 로그에는 **3492자**
구멍이 필요한지는 재지 않았다(§183)
- **되돌리기는 가이드 7편 가운데 이 편에만 있다.** 위 네 줄이 원문 그대로이고,
그 네 줄을 실제로 쳐서 걷어내 본 기록은 없다(unknown)
- (unknown) 2026-09-17 에 더한 CoreDNS 매니페스트(`coredns-custom`)는 가이드의
되돌리기 표에 없다. 걷어내려면 `kubectl -n kube-system delete configmap coredns-custom`
하고 `kubectl -n kube-system delete pod -l k8s-app=kube-dns` 인데 이 실험대에서
치지는 않았다
**배포된 것과 적어 둔 것이 다르다**
@@ -10341,11 +10515,250 @@ grep -c ExecStartPost /etc/systemd/system/lab-edge-dnat.service
써서 `sudo systemctl daemon-reload && sudo systemctl restart lab-edge-dnat.service`
로 올린다.
**그런데 고쳤는지는 이번에 확인하지 못했다**(unknown). 이 호스트의 `sudo`
비밀번호를 묻고 이번 작업에는 그 비밀번호가 없었다. 그래서 `nft list table ip
lab_edge` 도 `guest_input` 체인 조회도 못 돌렸고, 두 파일을 다시 배포하지도
않았다. 위 표는 **파일 내용과 타임스탬프만으로** 적은 것이고, `reject` 줄의
카운터가 실제로 올라갔는지는 안 봤다.
**2026-09-17 늦게 그 `sudo` 가 열려 셋을 마저 봤다**(observed). 결론은 **절반만
고쳐져 있다**이다.
```text
grep -c 'chain forward' /etc/nftables.d/lab-edge-dnat.nft → 1 (0 이어야 한다)
grep -c ExecStartPost /etc/systemd/system/lab-edge-dnat.service → 1 (맞다)
```
유닛에는 `ExecStartPost` 가 들어갔는데 `.nft` 는 아직 옛 판이다 — 효과 없는
`forward` 체인을 아무도 걷어내지 않았다. `.nft` 를 다시 쓰고
`sudo systemctl restart lab-edge-dnat.service` 로 올려야 §180 이 싣는 판이 된다.
```bash
sudo nft list chain ip libvirt_network guest_input
```
```text
oif "virbr0" ip daddr 192.168.122.10 tcp dport { 80, 443 } ct state new counter packets 44 bytes 2640 accept
oif "virbr0" ip daddr 192.168.122.10 tcp dport { 80, 443 } ct state new counter packets 0 bytes 0 accept
oif "virbr0" ip daddr 192.168.122.0/24 ct state established,related counter packets 141705 bytes 2375116597 accept
oif "virbr0" counter packets 5 bytes 300 reject
oif "virbr0" ip daddr 192.168.122.10 tcp dport { 80, 443 } ct state new counter packets 10 bytes 600 accept
```
**구멍이 하나가 아니라 셋이다.** `ExecStartPost``nft insert` 로 맨 앞에 끼워
넣으므로 유닛을 다시 시작할 때마다 같은 규칙이 한 줄씩 는다 — **여러 번 쳐도 안전한
형태가 아니다.** 기능은 멀쩡하다. 맨 앞 하나가 통과시키고 나머지는 카운터가 안
오르거나(둘째 `0`) 아예 닿지 않는다(다섯째는 `reject` 뒤다). `reject``5`
구멍이 들어가기 전에 막힌 것이고 그 뒤로는 안 오른다.
**그리고 출발지를 덮는 것이 무엇인지도 그때 갈렸다 — Tailscale 이다**(observed).
```bash
sudo nft list ruleset | grep -nE 'masquerade|snat'
sudo iptables -t nat -S | grep ts-postrouting
sudo tailscale debug prefs | grep -i snat
```
```text
chain ts-forward { iifname "tailscale0" counter ... xt target "MARK" }
-A ts-postrouting -m mark --mark 0x40000/0xff0000 -j MASQUERADE
"NoSNAT": false
```
`tailscale0` 으로 들어와 전달되는 패킷에 Tailscale 이 표식 `0x40000` 을 찍고,
`ts-postrouting` 이 그 표식이 붙은 것을 전부 `MASQUERADE` 한다. 나가는 인터페이스가
`virbr0` 이라 출발지가 `192.168.122.1` 이 된다. `NoSNAT``false` 인 것이 그 동작이
켜져 있다는 뜻이고 그것이 Tailscale 의 기본값이다.
**그래서 §180 의 경고는 옳았는데 엉뚱한 테이블을 지켰다.** 「masquerade 를 붙이면
엣지가 모든 클라이언트를 `192.168.122.1` 로 보게 된다」는 그대로 일어났고, 다만 그
`masquerade` 를 붙인 것이 우리 테이블이 아니라 Tailscale 이다. libvirt 의 마스커레이드는
무관하다 — `ip saddr 192.168.122.0/24 ip daddr != 192.168.122.0/24` 이라 게스트에서 밖으로
나가는 것만 고르고, 밖에서 들어오는 이 패킷은 목적지가 그 대역 안이라 안 걸린다.
**클러스터 안에서도 공개 이름에 못 닿는다**(2026-09-17, observed).
랩 호스트만의 일이 아니다. 파드 안에서도 같은 이름이 같은 이유로 막힌다.
```bash
kubectl -n keycloak-lab exec keycloak-0 -- sh -c "getent hosts auth.hyeonworks.com"
```
```text
100.83.212.4 auth.hyeonworks.com
```
같은 파드에서 `100.83.212.4` 의 80 과 443 을 두드리면 둘 다 닫혀 있고, 엣지 게스트의
`192.168.122.10:80` 은 열려 있다. **길은 있는데 이름이 그 길을 안 가리킨다.** 클러스터
DNS 어디에도 그 매핑이 없다 — CoreDNS 의 `NodeHosts` 에는 노드 둘뿐이고 노드의
`/etc/hosts` 에도 없으며, `deploy/lab/k8s/` 의 매니페스트 여덟 장에도 없다.
nginx 가 호스트에 있던 동안에는 파드가 `100.83.212.4:443` 을 치면 그 nginx 에 닿았다.
03 이 옮기면서 그 경로가 끊겼고, 대신 놓아 줄 것을 어느 문서도 안 놓는다.
고치는 한 단계는 CoreDNS 에 서버 블록을 하나 더 주는 것이다. **이 실험대는 매니페스트를
힙독으로 흘려 넣었다**(observed).
```bash
kubectl apply -f - <<'YAML'
apiVersion: v1
kind: ConfigMap
metadata:
name: coredns-custom
namespace: kube-system
data:
hyeonworks.server: |
hyeonworks.com:53 {
hosts {
192.168.122.10 auth.hyeonworks.com
192.168.122.10 app1.hyeonworks.com
192.168.122.10 app2.hyeonworks.com
fallthrough
}
forward . /etc/resolv.conf
}
YAML
kubectl -n kube-system delete pod -l k8s-app=kube-dns --wait=false
```
**따라 하는 사람은 매니페스트를 파일로 쓴다.** 힙독은 이 ConfigMap 을 터미널 명령 안에
묻어 버려서, 나중에 서버 블록을 고치려면 같은 힙독을 처음부터 다시 친다. 파일로 두면
`kubectl apply` 를 다시 쳐서 고칠 수 있고 무엇이 들어갔는지도 열어서 본다.
```bash
nano coredns-custom.yaml
```
파일에 위 힙독 안의 `apiVersion:` 부터 마지막 `}` 까지를 그대로 쓴다. 그리고 적용한 뒤
CoreDNS 파드를 다시 띄운다.
```bash
kubectl apply -f coredns-custom.yaml
kubectl -n kube-system delete pod -l k8s-app=kube-dns --wait=false
```
이 나눈 형태는 이 실험대에서 치지 않았다(unknown).
**파일 이름이 `.server` 인 것이 핵심이다.** k3s 는 `*.override` 를 기본 서버 블록 안에
끼워 넣는데 그 블록은 이미 `hosts /etc/coredns/NodeHosts``hosts` 를 한 번 쓴다.
`.override``hosts` 를 또 쓰면 CoreDNS 가 `CrashLoopBackOff` 로 안 뜬다.
```text
plugin/hosts: this plugin can only be used once per Server Block
```
`.server` 는 별개의 서버 블록으로 들어가므로 그 안에서 `hosts` 를 처음 쓰는 것이 된다.
뜨고 나면 로그 머리에 듣는 영역이 둘로 찍히고, 파드에서 이름이 엣지로 풀린다.
```text
.:53
hyeonworks.com.:53
```
```text
192.168.122.10 auth.hyeonworks.com
```
`192.168.122.10:80` 이 파드에서 열린다. **443 은 여전히 닫혀 있다** — 그것은 04 가
인증서를 얹어야 열린다.
**03 을 밟고 나면 랩 호스트에서 공개 URL 을 못 친다**(2026-09-17, observed).
nginx 가 호스트에 있던 동안에는 호스트에서 `curl https://auth.hyeonworks.com`
그냥 됐다. 03 이 nginx 를 엣지 게스트로 옮기면서 그것이 끊긴다. 80 도 443 도
안 열린다.
```bash
curl -s -m 10 -o /dev/null -w "%{http_code}\n" https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs
```
```text
000
Failed to connect to auth.hyeonworks.com:443 after 22 ms: Could not connect to server
```
이유는 두 줄이다.
```bash
getent hosts auth.hyeonworks.com
ss -lnt | grep -E ":(80|443) "
```
```text
100.83.212.4 auth.hyeonworks.com
```
두 번째 줄은 아무것도 안 낸다. `auth.hyeonworks.com` 은 호스트 자신의 tailnet
주소로 풀리는데 호스트에는 그 포트를 듣는 것이 없고, 03 의 DNAT 은 `iifname
"tailscale0"` 만 매칭하므로 호스트가 스스로 낸 패킷은 그 규칙을 안 탄다. §190 이
자기 4번 확인을 엣지가 아닌 다른 머신으로 보낸 것과 같은 이유다.
**이 전제가 실험 기록 14편 · 명령 55줄에 걸려 있다**(observed). `[lab host]` 라벨이
붙은 코드블록 안에서 `auth`·`app1`·`app2` 의 공개 URL 을 치는 줄을 센 것이고,
많은 쪽부터 B-6(12) · B-5(10) · D-1(9) 순이다. 그 줄들은 nginx 가 호스트에 있던
때의 배치로 쓰였고 지금 배치에서는 `000` 을 낸다.
랩 호스트에서 꼭 쳐야 하면 이름은 그대로 두고 주소만 엣지로 못박는다.
```bash
curl -s -m 10 --resolve auth.hyeonworks.com:80:192.168.122.10 \
http://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs
```
이 형태는 응답을 받는다(observed). 다만 DNAT 을 안 거치므로 **층 ③ 을 재는 것이
아니다.** 층 ② 를 도메인 이름으로 재는 것이고, 4·5 번이 제대로 들어갔는지는
여전히 밖에서 쳐야 갈린다.
**`X-Forwarded-For` 계약은 이 실험대에서 성립하지 않는다**(2026-09-17, observed).
위 4번이 SNAT 을 안 거는 이유로 「masquerade 를 붙이면 엣지가 모든 클라이언트를
`192.168.122.1` 로 보게 된다」고 적었고, §215 의 표도 그 줄을 감수한 비용으로
싣는다. 그런데 밖에서 한 번 치고 엣지의 로그를 열었더니 **이미 그 상태였다.**
```bash
curl -s -o /dev/null http://app1.hyeonworks.com/api/echo
```
```bash
sudo tail -3 /var/log/nginx/access.log
```
```text
192.168.122.1 - - [17/Sep/2026:07:10:45 +0000] "GET /api/echo HTTP/1.1" 200 617 "-" "curl/8.5.0"
```
`lab_edge` 테이블에는 `masquerade``snat` 도 없다 — 위에 실은 `.nft` 파일
그대로다. 그러므로 출발지를 덮는 것은 이 테이블이 아니고, **무엇이 덮는지는 가리지
못했다**(unknown). 그것을 보려면 호스트에서 `nft list ruleset` 을 쳐야 하는데 그
`sudo` 가 비밀번호를 묻는다. 결과만 놓고 보면 `proxy_set_header X-Forwarded-For
$remote_addr` 는 시키는 대로 동작하고 있고 `$remote_addr` 가 이미 호스트의 브리지
주소다. §259 가 적은 신뢰 경계 논의는 그대로 유효하지만, **이 실험대에서 그 헤더로
클라이언트를 가릴 수는 없다.**
**Traefik 이 그 헤더를 한 번 더 덮는다 — 03 에 없던 한 단계**(2026-09-17, observed).
2번이 세운 헤더가 앱까지 가는지를 처음으로 쟀다. 안 간다. Traefik 은 자기가
신뢰하지 않는 곳에서 온 `X-Forwarded-*` 를 자기 연결 기준으로 다시 쓰고, 기본값의
신뢰 목록은 비어 있다.
```bash
curl -s http://app1.hyeonworks.com/api/echo
```
| 헤더 | 이 한 단계를 안 밟았을 때 | 밟은 뒤 |
|---|---|---|
| `x-real-ip` | `10.42.0.1` — flannel 게이트웨이. 엣지가 보낸 값이 사라졌다 | `192.168.122.1` — 엣지가 보낸 값 그대로 |
| `remoteAddr` | `10.42.0.1` | `192.168.122.1` |
| `x-forwarded-for` | 없다 | 없다 |
빠진 한 단계는 두 줄이고, 저장소에 파일(`deploy/lab/k8s/traefik-forwarded-headers.yaml`)
이 있는데 **setup 아홉 편 어디에도 이 명령이 없었다.**
```bash
kubectl apply -f deploy/lab/k8s/traefik-forwarded-headers.yaml
kubectl -n kube-system rollout status deploy/traefik --timeout=180s
```
신뢰 목록에는 파드 대역 `10.42.0.0/16` 하나만 담는다. Traefik 의 Service 가
`externalTrafficPolicy: Cluster` 라 svclb 가 출발지를 덮고, 그래서 Traefik 에 닿는
주소는 엣지가 아니라 파드 대역에서 온다. 노드·호스트 대역을 같이 넣어서도 재
봤는데 위 표의 값이 하나도 안 바뀌었다 — 나타날 수 없는 대역이라 넣어도 소용이
없다(observed). `x-forwarded-for` 는 이 단계를 밟아도 앱까지 안 오고, **왜
사라지는지는 안 가렸다**(unknown).
## 190. 단계 04 — Let's Encrypt 와 인증서 갱신
@@ -10749,6 +11162,49 @@ server {
| 443 블록 | 없었다 | 인증서와 함께 새로 |
| `X-Forwarded-Proto` | `http` | **`https`** |
**★ 위 블록의 인증서 경로가 틀렸다**(2026-09-17, observed). `live/hyeonworks.com/` 이라고
적혀 있는데 certbot 이 만드는 디렉터리는 **`live/auth.hyeonworks.com/`** 이다. 이름을
여럿 담은 인증서라도 디렉터리 이름은 `-d` 로 처음 준 이름 하나를 쓴다. 그대로 치면
`nginx -t` 가 막힌다.
```text
[emerg] cannot load certificate "/etc/letsencrypt/live/hyeonworks.com/fullchain.pem":
BIO_new_file() failed (SSL: error:80000002:system library::No such file or directory
nginx: configuration file /etc/nginx/nginx.conf test failed
```
```bash
sudo ls /etc/letsencrypt/live/
sudo certbot certificates | grep 'Certificate Path'
```
```text
README
auth.hyeonworks.com
Certificate Path: /etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem
```
경로를 그 이름으로 고치자 `syntax is ok` · `test is successful` 로 통과하고 엣지가 443 을
듣기 시작했다. **§204 의 「문제가 생기면」에 적힌 진단은 방향이 거꾸로다** —
`live/auth.hyeonworks.com/` 이 맞는 경로이고 없는 것은 `live/hyeonworks.com/` 쪽이다.
**그리고 이 실험대의 certbot 은 DNS-01 로 등록되어 있다**(observed). 두 문서가 갈렸던
대목이 닫혔다.
```bash
sudo sh -c 'grep -H "authenticator\|dns_cloudflare_credentials\|server =" /etc/letsencrypt/renewal/*.conf'
```
```text
authenticator = dns-cloudflare
dns_cloudflare_credentials = /etc/letsencrypt/cloudflare.ini
server = https://acme-v02.api.letsencrypt.org/directory
```
**`sudo grep ... /etc/letsencrypt/renewal/*.conf` 로 치면 안 된다**(observed). 글로브를 일반
사용자 셸이 먼저 펼치는데 그 디렉터리를 못 읽어서 `no matches found` 로 끝나고 `grep`
시작도 안 한다. `sudo sh -c '...'` 로 sudo 안에서 펼쳐야 한다.
**`cert.pem` 이 아니라 `fullchain.pem`.** 서버 인증서만 보내면 중간 인증서가 빠져
체인이 끊긴다. 브라우저는 대개 캐시나 AIA 로 보완해서 **정상으로 보이고**, 캐시가
없는 클라이언트에서만 깨진다. 그래서 발견이 늦다. 2번에서 certbot 이 찍어 준 경로와
@@ -10944,6 +11400,31 @@ curl -v https://auth.hyeonworks.com/ -o /dev/null
**`subject``hyeonworks.com` 인 것이 맞다** — 와일드카드 인증서라 CN 은 apex
이름이고 `auth.hyeonworks.com` 은 SAN 의 `*.hyeonworks.com` 에 걸린다.
**★ 그런데 이 실험대에서는 CN 이 `auth.hyeonworks.com` 이다**(2026-09-17,
observed). 가이드대로 와일드카드를 받으면 위 문단처럼 보인다. 이 실험대는
와일드카드를 안 받았고 계보도 `auth.hyeonworks.com` 하나뿐이라, 앞에서 적은
「이 실험대는 처음에 와일드카드를 안 쉈고」와 같은 사실을 가리킨다. 그 둘이
같은 문서 안에서 서로 어긋나 있었다.
```bash
echo | openssl s_client -connect 192.168.122.10:443 -servername auth.hyeonworks.com 2>/dev/null | openssl x509 -noout -subject -ext subjectAltName -dates
```
```text
subject=CN=auth.hyeonworks.com
X509v3 Subject Alternative Name:
DNS:app1.hyeonworks.com, DNS:app2.hyeonworks.com, DNS:auth.hyeonworks.com
notBefore=Sep 4 11:29:18 2026 GMT
notAfter=Dec 3 11:29:17 2026 GMT
```
그래서 CN 을 합격 조건으로 읽으면 안 된다. 와일드카드를 받았으면
`hyeonworks.com`, 이름을 나열해 받았으면 첫 번째 `-d` 의 이름이 CN 이 된다.
보는 것은 `SSL certificate verify ok.` 한 줄과, 지금 친 이름이 SAN 에 있는가다.
이름 셋 밖은 TLS 단계에서 끝나 `curl` 이 종료 코드 `60` 을 내고 `%{http_code}`
`000` 이 되는데, 화면에서는 「서버가 죽었다」와 같아 보인다. A-4 의 Grafana
탐침이 그렇게 쓸모없어졌다.
**이 결과가 의미하는 것** — 네 줄이 다 나오면 인증서가 붙었고 체인이 클라이언트
기준으로 검증됐다. **인증서를 처음 붙인 직후에는 코드 한 칸이 아니라 이 화면을
본다.** `verify` 줄 대신 `unable to get local issuer certificate` 가 나오면 중간
@@ -10955,6 +11436,14 @@ curl -v https://auth.hyeonworks.com/ -o /dev/null
curl -s -o /dev/null -w '%{http_code} tls=%{ssl_verify_result}\n' https://auth.hyeonworks.com/
```
**랩 호스트에서 칠 때만 `--resolve` 를 붙인다**(2026-09-17, observed). 거기서는 이 이름이 호스트
자신의 tailnet 주소로 풀리고 그 주소에는 443 을 듣는 것이 없다.
```bash
curl -s -o /dev/null -w '%{http_code} tls=%{ssl_verify_result}\n' --resolve auth.hyeonworks.com:443:192.168.122.10 \
https://auth.hyeonworks.com/
```
**실측**(observed) — 2026-09-11, tailnet 클라이언트에서
```
@@ -11204,7 +11693,8 @@ ls deploy/lab/k8s/
[04] 의 확인 ① 을 그대로 다시 친다.
```bash
curl -s -o /dev/null -w '%{http_code} tls=%{ssl_verify_result}\n' https://auth.hyeonworks.com/
curl -s -o /dev/null -w '%{http_code} tls=%{ssl_verify_result}\n' --resolve auth.hyeonworks.com:443:192.168.122.10 \
https://auth.hyeonworks.com/
```
**어디를 봐야 하는가** — 04 에서 잰 `404 tls=0` 이 그대로인가. **앞의 코드를 같이
@@ -11692,7 +12182,8 @@ vendor_cluster_size{cache_manager="keycloak",node="keycloak-0-46674"} 2.0
**확인 ④ 밖에서 닿나** — 2홉을 다 지나 파드까지
```bash
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
curl -s -o /dev/null -w '%{http_code}\n' --resolve auth.hyeonworks.com:443:192.168.122.10 \
https://auth.hyeonworks.com/realms/master
```
```
@@ -11709,7 +12200,7 @@ Service 뒤에 파드가 있는지(2-4), 파드가 Ready 인지(2-2) 순서다.
헤더가 필요하면 값만 뽑는 형태를 버리고 읽는 형태로 바꾼다.
```bash
curl -I https://auth.hyeonworks.com/realms/master
curl -I --resolve auth.hyeonworks.com:443:192.168.122.10 https://auth.hyeonworks.com/realms/master
```
**★ 이 `200` 은 04 를 끝냈을 때의 값이다.** 04 를 건너뛰고 05 만 했다면 443 을 듣는
@@ -12103,7 +12594,8 @@ kubectl -n observability exec deploy/prometheus -- \
응답을 받아 보는 것이 가장 짧다.
```bash
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
curl -s -o /dev/null -w '%{http_code}\n' --resolve auth.hyeonworks.com:443:192.168.122.10 \
https://auth.hyeonworks.com/realms/master
```
**어디를 봐야 하는가** — 코드 한 칸. 여기서 값만 뽑는 형태를 쓰는 것은 `up` 의 1/0 과
@@ -12219,10 +12711,9 @@ kubectl -n observability port-forward svc/grafana 3000:3000
돌고 있는 실험대가 없어지기 때문이다 — **가이드대로 쳐서 이 상태가 다시 서는지**
는 확인된 적이 없다 (unknown)
- 호스트의 코어 수가 가이드의 「16 코어 전부」와 §178 의 「논리 코어 8」로
린다. 어느 쪽이 이 실험대인지 (unknown)
- cloud-init 의 `packages` 에 certbot 이 있었는가. 01·03 은 `curl`·`nftables`
뿐이라 적고 04 는 들어 있다고 적는다. `kc-lab.yaml.example``source/`
없어 대조하지 못했다 (unknown)
렸다 → **8 이다.** 2026-09-17 에 `nproc``lscpu` 로 갈랐다 (observed)
- cloud-init 의 `packages` 에 certbot 이 있었는가**없다.** 2026-09-17 에
게스트의 user-data 와 저장소 템플릿을 둘 다 열어서 갈랐다 (observed)
- `lsmod | grep kvm` 의 실제 출력, 03 의 `curl -I http://192.168.122.11` 출력,
04 의 `curl -v` 협상 출력 — 셋 다 캡처해 두지 않았다 (unknown)
- k3s 토큰 108자는 판올림에 따라 달라진다. 다른 판에서 몇 자인지는 재지 않았다
@@ -12759,6 +13250,39 @@ tar 하나에 30초고, 답을 알고 나면 지우면 된다.
sudo tar czf ~/letsencrypt-backup-$(date +%Y%m%d-%H%M%S).tgz -C /etc letsencrypt
```
**그런데 이 정책이 지키는 디렉터리가 03·04 뒤로 바뀌었다**(2026-09-17, observed).
위 명령은 랩 호스트의 `/etc/letsencrypt/` 를 묶는데, 03 이 nginx 를
`kc-lab-edge` 로 옮겼고 04 가 certbot 을 거기 깔았다. **지금 서빙하는 인증서는
그 게스트 안에 산다.** 그리고 철거 1번은 게스트를 `--remove-all-storage`
지운다 — 인증서는 그 안에서 같이 사라지고, 위 백업에는 안 들어간다.
```bash
ls -la /etc/letsencrypt/
ssh kc-lab-edge 'sudo ls -la /etc/letsencrypt/'
```
| 기계 | `/etc/letsencrypt/` | certbot | 타이머 | 서빙 |
|---|---|---|---|---|
| 랩 호스트 | 있다 (읽으려면 `sudo`) | `/usr/bin/certbot` | 없다 | 안 한다 — 80·443 을 안 듣는다 |
| `kc-lab-edge` | 있다 — `cli.ini` · `renewal-hooks` | `/usr/bin/certbot` | `certbot.timer` | **한다** |
묶어야 할 것은 게스트 쪽이다.
```bash
ssh kc-lab-edge "sudo tar czf /tmp/letsencrypt-backup-$(date +%Y%m%d-%H%M%S).tgz -C /etc letsencrypt"
scp kc-lab-edge:/tmp/letsencrypt-backup-*.tgz ~/
```
**「철거해도 남는 것」 표의 패키지 줄도 같은 이유로 틀렸다.** 그 줄은 `nginx`
`certbot` 을 남는 쪽에 적어 두었는데 둘 다 게스트 안에 있으므로 게스트와 함께
사라진다. 랩 호스트에 남는 것은 `libvirt`·`qemu`·`kubectl` 과, 쓰이지 않는 호스트
`certbot` 이다.
**철거 전 네 값은 2026-09-17 에 다시 받았다**(observed). 도메인 3 대에 볼륨 7 개,
예약 3 줄까지는 2026-09-10 과 같고 **디스크만 11G 가 아니라 18G 다** — 그사이에
k3s 와 컨테이너 이미지가 쌓였다. 이 네 값은 이 호스트의 정답이 아니라 매 실행에서
받아 두는 대조군이다.
복원은 반대로 한 줄이다.
```bash
@@ -17514,6 +18038,20 @@ k3s가 컨테이너에 SIGTERM을 보낸다. 유예 시간이 짧으면 **Postgr
강제 종료되어 다음 기동에 crash recovery가 돈다.** 미리 내려두면 그 위험이
없다.
**따라 하는 사람은 3번의 두 게스트를 `&&` 로 잇지 않고 한 줄씩 친다.**
```bash
virsh shutdown kc-lab-1
virsh shutdown kc-lab-2
```
두 게스트는 서로 앞뒤가 없고, `A && B` 는 A 가 성공했을 때만 B 를 실행한다.
`kc-lab-1` 이 이미 `shut off``virsh shutdown``domain is not running` 으로
실패하므로 `kc-lab-2` 는 켜진 채로 남고, 바로 다음 줄의 `systemctl poweroff`
그 게스트를 강제로 끈다 — 이 절차가 막으려던 바로 그 상태다. §226 의
`net-start && net-autostart` 와 같은 자리다. 이 나눈 형태는 이 실험대에서 치지
않았다(unknown).
**clean shutdown 확인**
```bash
@@ -17537,6 +18075,18 @@ kubectl -n keycloak-lab scale statefulset/keycloak --replicas=2
**스케일을 0으로 내려두면 자동으로 복구되지 않는다.** 명시적으로 올려야 한다.
여기 첫 줄도 `&&` 로 잇지 않고 한 줄씩 친다.
```bash
virsh start kc-lab-1
virsh start kc-lab-2
```
`kc-lab-1` 이 이미 `running` 이면 `virsh start``domain is already active`
실패하고 `kc-lab-2` 는 안 뜬다. 그러면 `kubectl get nodes` 에 노드가 하나만 나오는데,
증상이 게스트를 안 띄운 것과 노드가 안 붙은 것 사이에서 갈리지 않는다.
이 나눈 형태는 이 실험대에서 치지 않았다(unknown).
## 335. qcow2 파일을 다른 물리 서버로 옮기면 무엇이 따라가나
1층의 「qcow2와 backing store」가 **오버레이 구조**를, 「qcow2 파일 내부는