feat(pipeline): keycloak-session-store 25편·virtualization 59편을 S3→S5→S6 으로 돌린다

기록 84편을 계약 에이전트로 다시 썼다. 기존 71편(kss 25 · virt 46)과, 계약에만
있고 안 쓰여 있던 새 글감 13편이다. 원장 84개를 열어 단계마다 스킬 영수증과 관문
종료 코드를 적었고 verify-pipeline-run.py 가 error 0 으로 닫는다.

SSOT 결함 둘을 고쳤다.

- kss 의 `약 58일` 이 반입 중 `약 59일` 로 바뀌어 있었다. 원 증거 파일이
  「남은 일수: 88일 … 실제 갱신까지 약 58일」로 산수를 직접 적는다. D-4a 쪽
  `약 59일` 은 강제 갱신 뒤(`VALID: 89 days`)라 맞는 값이라 그대로 뒀다.
- virt §198 의 `11.6GB` 는 §178 의 원 측정 `Mem: 11648`(MiB)과 어긋나는데
  원 가이드의 표기 그대로라 고치지 않고 쓰이는 자리에 대조를 적었다.

기록의 수치 오류 셋을 고쳤다 — CASE 요약의 「게스트 셋에 8240MB」(5120+3120 은
둘이다), k3s 편이 같은 것을 여섯·일곱·여덟로 세던 것, no-docker 편의 「셋을 더
든다」(§281 의 표는 네 행이고 디스크 행이 빠져 있었다).

계약을 셋 고쳤다.

- kss 의 sourceRepository 리비전이 cdac9b8 이었는데 그 커밋에는 docs/guides/**
  28개가 아예 없다. 9465582b 로 바꾸고, 반입한 바이트가 어느 커밋과도 같지 않다는
  것을 측정값과 함께 적었다 — 반입은 커밋이 아니라 그 시점의 작업 트리에서 떠 온
  것이다(kss 297/306 · virt 12/14 가 작업 트리와 같고, 200 커밋을 거슬러 전수
  대조했을 때 가장 가까운 커밋도 28개가 어긋났다).
- virt 계약이 「2026-09-11 재배분」이라고 적는데 SSOT 는 재배분 날짜를 적지 않고
  재배분 뒤 값은 이미 2026-09-10 측정에 찍혀 있다.
- kss 후보 대장이 지나친 절 아홉에 처분을 적었다(warn 9 → 0). 새 글감은 0건이고
  넷은 앵커가 h3 슬러그의 접두가 아니라 중간 토막이라 검사기가 못 본 것이었다.

style_profile.mjs 의 결함 둘을 고쳤다 — frontmatter 가 문장으로 세어져
(실측 398자짜리 「문장」 하나) 평균 길이를 기준 안으로 밀어 올리고 있었고,
engPerSent 의 분자는 목록을 포함한 글에서, 분모는 목록을 걷어낸 글에서 세고
있었다(Question 기록에서 11.94 → 3.86).

verify-pipeline.py 전 항목 PASS · error 0 · unittest 334건 OK.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-09-17 11:01:55 +09:00
co-authored by Claude Opus 5
parent d473609e0a
commit 2109f726fe
574 changed files with 159654 additions and 1551 deletions
@@ -0,0 +1 @@
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