diff --git a/.gitignore b/.gitignore
index bb95a1d..37113df 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,3 +12,7 @@ frontend/dist/
bff/target
token-mediator/target
+
+# lab cloud-init contains a console password; keep the filled copy local
+deploy/lab/cloud-init/kc-lab.yaml
+deploy/lab/cloud-init/kc-lab-*.yaml
diff --git a/backend/src/main/java/com/example/keycloakpattern/ApiController.java b/backend/src/main/java/com/example/keycloakpattern/ApiController.java
index e234030..819ad92 100644
--- a/backend/src/main/java/com/example/keycloakpattern/ApiController.java
+++ b/backend/src/main/java/com/example/keycloakpattern/ApiController.java
@@ -1,6 +1,8 @@
package com.example.keycloakpattern;
+import java.util.Collections;
import java.util.LinkedHashMap;
+import java.util.List;
import java.util.Map;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
@@ -9,6 +11,8 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
+import jakarta.servlet.http.HttpServletRequest;
+
@RestController
@RequestMapping("/api")
public class ApiController {
@@ -18,6 +22,35 @@ public class ApiController {
return Map.of("status", "ok", "service", "keycloak-pattern-api");
}
+ /**
+ * Reflects what actually reached the application after the proxy chain.
+ *
+ *
The reverse proxy contract is defined in {@code docs/reverse-proxy-headers.md}
+ * for a single nginx hop. The lab runs {@code nginx -> Traefik -> pod}, so this
+ * endpoint exists to measure the two-hop result instead of assuming it.
+ *
+ *
{@code scheme}, {@code secure} and {@code requestUrl} are the values Keycloak
+ * uses to build the {@code iss} claim and redirect URLs. If forwarded headers are
+ * lost or rewritten, the mismatch shows up here first.
+ */
+ @GetMapping("/echo")
+ public Map echo(HttpServletRequest request) {
+ Map> headers = new LinkedHashMap<>();
+ for (String name : Collections.list(request.getHeaderNames())) {
+ headers.put(name.toLowerCase(), Collections.list(request.getHeaders(name)));
+ }
+
+ Map response = new LinkedHashMap<>();
+ response.put("headers", headers);
+ response.put("remoteAddr", request.getRemoteAddr());
+ response.put("scheme", request.getScheme());
+ response.put("secure", request.isSecure());
+ response.put("serverName", request.getServerName());
+ response.put("serverPort", request.getServerPort());
+ response.put("requestUrl", request.getRequestURL().toString());
+ return response;
+ }
+
@GetMapping("/me")
public Map currentUser(@AuthenticationPrincipal Jwt jwt) {
Map response = new LinkedHashMap<>();
diff --git a/backend/src/main/java/com/example/keycloakpattern/SecurityConfig.java b/backend/src/main/java/com/example/keycloakpattern/SecurityConfig.java
index d4c2129..51ce2a8 100644
--- a/backend/src/main/java/com/example/keycloakpattern/SecurityConfig.java
+++ b/backend/src/main/java/com/example/keycloakpattern/SecurityConfig.java
@@ -17,7 +17,8 @@ public class SecurityConfig {
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(authorize -> authorize
- .requestMatchers("/actuator/health", "/actuator/health/**", "/api/public")
+ .requestMatchers("/actuator/health", "/actuator/health/**", "/api/public",
+ "/api/echo")
.permitAll()
.anyRequest()
.authenticated())
diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml
index 0046b63..2260cf4 100644
--- a/backend/src/main/resources/application.yml
+++ b/backend/src/main/resources/application.yml
@@ -1,5 +1,10 @@
server:
port: ${SERVER_PORT:8081}
+ # Spring ignores X-Forwarded-* unless this is set, so scheme/secure/requestUrl
+ # report the raw connection by default. Keycloak has the same opt-in as
+ # KC_PROXY_HEADERS. Flipping this to "native" is what the two-hop measurement
+ # compares against.
+ forward-headers-strategy: ${SERVER_FORWARD_HEADERS_STRATEGY:none}
spring:
application:
diff --git a/backend/src/test/java/com/example/keycloakpattern/ApiSecurityTest.java b/backend/src/test/java/com/example/keycloakpattern/ApiSecurityTest.java
index 224287b..8ff6b3e 100644
--- a/backend/src/test/java/com/example/keycloakpattern/ApiSecurityTest.java
+++ b/backend/src/test/java/com/example/keycloakpattern/ApiSecurityTest.java
@@ -25,6 +25,18 @@ class ApiSecurityTest {
.andExpect(jsonPath("$.status").value("ok"));
}
+ @Test
+ void echoEndpointReflectsForwardedHeadersWithoutAuthentication() throws Exception {
+ mockMvc.perform(get("/api/echo")
+ .header("X-Forwarded-Proto", "https")
+ .header("X-Forwarded-Host", "app1.example.test"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.headers['x-forwarded-proto'][0]").value("https"))
+ .andExpect(jsonPath("$.headers['x-forwarded-host'][0]").value("app1.example.test"))
+ .andExpect(jsonPath("$.requestUrl").exists())
+ .andExpect(jsonPath("$.remoteAddr").exists());
+ }
+
@Test
void protectedEndpointRejectsAnonymousRequests() throws Exception {
mockMvc.perform(get("/api/me"))
diff --git a/deploy/lab/README.md b/deploy/lab/README.md
new file mode 100644
index 0000000..1ae1a99
--- /dev/null
+++ b/deploy/lab/README.md
@@ -0,0 +1,130 @@
+# Session store lab
+
+세션 저장소·refresh token 경쟁·장애 복구를 검증하는 2노드 k3s 실험대.
+네 인증 패턴(AP1~AP4)을 가로지르는 공통층이므로 별도 축으로 관리한다.
+
+개념 설명은 [`docs/session-lab-concepts.md`](../../docs/session-lab-concepts.md)에
+누적한다. 이 문서는 절차만 담는다.
+
+## 토폴로지
+
+```
+ 브라우저 / SSH (tailnet)
+ │ https://{auth,app1,app2}.hyeonworks.com → 100.83.212.4
+ ▼
+ lab host ── nginx :443 TLS 종료 · X-Forwarded-* 주입
+ │ nginx :80 301 → https
+ │
+ │ virbr0 192.168.122.0/24 (libvirt NAT)
+ ├──▶ kc-lab-1 .11 k3s server Traefik :80
+ └──▶ kc-lab-2 .12 k3s agent Traefik :80
+ └──▶ Pod
+```
+
+`nginx → Traefik` **2홉**이 운영 구조와 같다는 점이 이 배치의 핵심이다.
+L7 프록시가 두 겹인 이유는 역할이 다르기 때문이다 — nginx는 바깥세상과의
+접점(TLS·인증서·헤더)을, Traefik은 클러스터 내부의 동적 라우팅을 맡는다.
+
+## 구성 요소
+
+| 경로 | 역할 |
+|---|---|
+| `cloud-init/kc-lab.yaml.example` | 게스트 부트스트랩 템플릿 |
+| `host/nginx-keycloak-lab.conf` | lab host의 `sites-available/keycloak-lab` |
+| `k8s/echo.yaml` | 2홉 헤더 계약 측정용 워크로드 |
+| `scripts/rebuild-seed.sh` | cloud-init 시드 ISO 재생성 + 풀 업로드 |
+| `scripts/build-and-import.sh` | 이미지 빌드 → 각 노드 containerd 반입 |
+| `scripts/measure-proxy-headers.sh` | 헤더 계약 실측 |
+| `scripts/verify-lab.sh` | 인프라 상태 점검 |
+
+## 상태 점검
+
+```bash
+./deploy/lab/scripts/verify-lab.sh # lab host 에서
+```
+
+**`404`가 성공 신호다.** TLS가 종료되고 Traefik까지 도달했으나 매칭되는
+Ingress 규칙이 없다는 뜻이다. `502`나 연결 거부면 체인이 끊긴 것이다.
+
+## 첫 실험 — 2홉 헤더 계약
+
+[`docs/reverse-proxy-headers.md`](../../docs/reverse-proxy-headers.md)의 계약은
+nginx **1홉**을 가정하고 쓰였다. 실제 배치는 2홉이므로, nginx가 세팅한
+`X-Forwarded-*`를 Traefik이 그대로 넘기는지 덮어쓰는지 **측정해서 확인한다.**
+
+이 결론이 뒤의 모든 실험에 깔린다. Keycloak의 `iss` 클레임, redirect URL,
+쿠키 도메인 검증이 전부 이 헤더에 의존하기 때문이다.
+
+```bash
+# 워크스테이션: 이미지 빌드 후 두 노드에 반입
+./deploy/lab/scripts/build-and-import.sh
+
+# lab host: 배포
+kubectl apply -f deploy/lab/k8s/echo.yaml
+kubectl -n header-lab rollout status deployment/echo
+
+# 어디서든: 실측
+./deploy/lab/scripts/measure-proxy-headers.sh
+```
+
+관측 대상은 넷이다.
+
+1. `X-Forwarded-For` — Traefik이 **덧붙이는가 덮어쓰는가**
+2. `X-Forwarded-Proto` / `-Host` / `-Port` — 그대로 전달되는가
+3. **위조 내성** — 클라이언트가 직접 넣은 `X-Forwarded-*`가 앱까지 도달하는가
+4. `scheme` / `secure` / `requestUrl` — Keycloak이 URL을 만들 때 쓰는 값
+
+3번이 신뢰 경계의 핵심이다. 이 헤더들은 누구나 위조할 수 있는 평범한 HTTP
+헤더이므로, 신뢰 경계에 선 프록시가 **반드시 덮어써야** 한다.
+
+## 이미지 배포 경로
+
+k3s는 containerd를 쓰고 이 실험대에는 레지스트리가 없다.
+
+```
+워크스테이션 docker build → docker save
+ │ ssh (lab host 경유)
+ ▼
+게스트 sudo k3s ctr images import
+매니페스트 imagePullPolicy: Never
+```
+
+**두 노드 모두에 반입해야 한다.** 스케줄러가 어느 노드에 배치할지 모른다.
+Keycloak·PostgreSQL·Redis는 공식 이미지를 그대로 당겨오므로 이 경로가
+필요한 것은 자체 빌드 이미지뿐이다.
+
+**lab host에 Docker를 설치하지 않는다.** k3s의 containerd와 이미지 저장소가
+갈려서 `docker build`한 이미지를 k3s가 보지 못하게 된다.
+
+## 게스트 재생성
+
+파괴적 실험 후 초기화하는 경로다.
+
+```bash
+virsh destroy kc-lab-1
+virsh undefine kc-lab-1 # --remove-all-storage 는 시드 ISO 까지 지운다
+virsh vol-delete --pool default kc-lab-1.qcow2
+
+./deploy/lab/scripts/rebuild-seed.sh 1 # user-data 를 고쳤을 때만
+
+virt-install --name kc-lab-1 --memory 3584 --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
+```
+
+시드는 **virtio 디스크**로 붙인다. `virt-install --cloud-init`은 시드를 SATA
+CD-ROM으로 붙이는데, Debian `genericcloud` 이미지는 크기를 줄이려고 물리
+하드웨어 드라이버를 제외해서 **AHCI 장치를 보지 못한다.** 그러면 cloud-init이
+데이터소스를 찾지 못하고 아무 오류도 남기지 않은 채 종료한다. 증상은
+hostname이 `localhost`로 남고 SSH가 `Permission denied (publickey)`로 거부되는
+것뿐이다.
+
+게스트에 들어갈 수 없을 때는 화면을 직접 뜬다.
+
+```bash
+virsh screenshot kc-lab-1 /tmp/kc1.ppm # 확장자와 무관하게 PNG 로 저장된다
+```
+
+`localhost login:`이면 cloud-init 미실행, `kc-lab-1 login:`이면 실행된 것이다.
diff --git a/deploy/lab/cloud-init/kc-lab.yaml.example b/deploy/lab/cloud-init/kc-lab.yaml.example
new file mode 100644
index 0000000..6a25399
--- /dev/null
+++ b/deploy/lab/cloud-init/kc-lab.yaml.example
@@ -0,0 +1,37 @@
+#cloud-config
+# Template for both lab guests. scripts/rebuild-seed.sh substitutes __NODE__
+# and bakes this into a CIDATA seed image.
+#
+# Copy to kc-lab.yaml and fill the two placeholders. The real file is ignored by
+# git because plain_text_passwd is a credential, however disposable.
+#
+# Indentation is spaces only. YAML forbids tabs, and cloud-init fails silently
+# on a parse error: the guest boots as "localhost" with no user and no way in.
+hostname: kc-lab-__NODE__
+fqdn: kc-lab-__NODE__
+manage_etc_hosts: true
+
+users:
+ - name: donghyeon
+ groups: [sudo]
+ shell: /bin/bash
+ # NOPASSWD is required: the k3s installer and the fault-injection scripts
+ # run non-interactively and would block on a password prompt.
+ sudo: ['ALL=(ALL) NOPASSWD:ALL']
+ # Console-only escape hatch. Without it, a cloud-init failure leaves a guest
+ # that cannot be logged into at all, so its own failure log is unreadable.
+ # ssh_pwauth stays false, so this never widens SSH exposure.
+ lock_passwd: false
+ plain_text_passwd: CHANGE_ME
+ ssh_authorized_keys:
+ # Lab host key: needed because automation runs from the lab host, where
+ # agent forwarding is not available.
+ - CHANGE_ME_LAB_HOST_PUBLIC_KEY
+ # Workstation key: lets ProxyJump reach the guest directly.
+ - CHANGE_ME_WORKSTATION_PUBLIC_KEY
+
+ssh_pwauth: false
+package_update: true
+packages:
+ - curl
+ - nftables
diff --git a/deploy/lab/host/nginx-keycloak-lab.conf b/deploy/lab/host/nginx-keycloak-lab.conf
new file mode 100644
index 0000000..e115a6c
--- /dev/null
+++ b/deploy/lab/host/nginx-keycloak-lab.conf
@@ -0,0 +1,56 @@
+# 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 {
+ listen 443 ssl default_server;
+ http2 on;
+ server_name _;
+
+ # fullchain.pem, never cert.pem: omitting the intermediates passes on
+ # desktop browsers and fails on mobile and curl.
+ ssl_certificate /etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/auth.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;
+ }
+}
diff --git a/deploy/lab/k8s/echo.yaml b/deploy/lab/k8s/echo.yaml
new file mode 100644
index 0000000..98023e0
--- /dev/null
+++ b/deploy/lab/k8s/echo.yaml
@@ -0,0 +1,113 @@
+# Header echo workload for the two-hop proxy contract measurement.
+#
+# browser -> host nginx (TLS termination) -> Traefik -> this pod
+#
+# The image is built from backend/ and imported straight into each node's
+# containerd, so imagePullPolicy must stay Never. See scripts/build-and-import.sh.
+apiVersion: v1
+kind: Namespace
+metadata:
+ name: header-lab
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: echo
+ namespace: header-lab
+spec:
+ replicas: 2
+ selector:
+ matchLabels:
+ app: echo
+ template:
+ metadata:
+ labels:
+ app: echo
+ spec:
+ # One replica per node so the sticky-session switch on the host nginx
+ # upstream has something observable to route between.
+ topologySpreadConstraints:
+ - maxSkew: 1
+ topologyKey: kubernetes.io/hostname
+ whenUnsatisfiable: ScheduleAnyway
+ labelSelector:
+ matchLabels:
+ app: echo
+ containers:
+ - name: echo
+ image: keycloak-pattern-api:lab
+ imagePullPolicy: Never
+ ports:
+ - containerPort: 8081
+ name: http
+ env:
+ - name: SERVER_PORT
+ value: "8081"
+ # "none" makes the app report the raw connection, so scheme/secure/
+ # requestUrl show what arrives without any forwarded-header handling.
+ # Set to "native" and redeploy to see the same request interpreted
+ # with X-Forwarded-* honoured. Keycloak's KC_PROXY_HEADERS is the
+ # same opt-in, which is why measuring both sides matters here.
+ - name: SERVER_FORWARD_HEADERS_STRATEGY
+ value: "none"
+ # The JVM sizes its heap from the container limit, not the host.
+ - name: JAVA_TOOL_OPTIONS
+ value: "-XX:MaxRAMPercentage=70"
+ # /api/echo is permitAll, so the JWT decoder is never exercised.
+ # These stay pointed at the future Keycloak service name.
+ - name: SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI
+ value: "https://auth.hyeonworks.com/realms/keycloak-patterns"
+ - name: SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI
+ value: "https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs"
+ readinessProbe:
+ httpGet:
+ path: /actuator/health/readiness
+ port: http
+ initialDelaySeconds: 15
+ periodSeconds: 5
+ livenessProbe:
+ httpGet:
+ path: /actuator/health/liveness
+ port: http
+ initialDelaySeconds: 45
+ periodSeconds: 15
+ resources:
+ requests:
+ memory: 320Mi
+ cpu: 100m
+ limits:
+ memory: 512Mi
+---
+apiVersion: v1
+kind: Service
+metadata:
+ name: echo
+ namespace: header-lab
+spec:
+ selector:
+ app: echo
+ ports:
+ - port: 8081
+ targetPort: http
+ name: http
+---
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+ name: echo
+ namespace: header-lab
+spec:
+ # k3s ships Traefik as the default ingress controller. Keeping it is what
+ # makes this lab a faithful two-hop replica.
+ ingressClassName: traefik
+ rules:
+ - host: app1.hyeonworks.com
+ http:
+ paths:
+ - path: /api
+ pathType: Prefix
+ backend:
+ service:
+ name: echo
+ port:
+ number: 8081
diff --git a/deploy/lab/scripts/build-and-import.sh b/deploy/lab/scripts/build-and-import.sh
new file mode 100755
index 0000000..b4ebfc7
--- /dev/null
+++ b/deploy/lab/scripts/build-and-import.sh
@@ -0,0 +1,42 @@
+#!/usr/bin/env bash
+# Build the API image on this workstation and import it into each lab node's
+# containerd.
+#
+# k3s does not run Docker and the lab has no registry, so images are shipped as
+# a stream: docker save -> ssh through the lab host -> k3s ctr images import.
+# Every node needs its own copy because the scheduler may place the pod anywhere.
+#
+# ./deploy/lab/scripts/build-and-import.sh
+# IMAGE=keycloak-pattern-api:lab NODES="kc-lab-1" ./deploy/lab/scripts/build-and-import.sh
+set -euo pipefail
+
+IMAGE="${IMAGE:-keycloak-pattern-api:lab}"
+NODES="${NODES:-kc-lab-1 kc-lab-2}"
+LAB_HOST="${LAB_HOST:-test-server}"
+CONTEXT="${CONTEXT:-backend}"
+
+repo_root="$(git rev-parse --show-toplevel)"
+cd "$repo_root"
+
+echo "==> building ${IMAGE} from ${CONTEXT}/"
+docker build -t "$IMAGE" "$CONTEXT"
+
+for node in $NODES; do
+ echo "==> importing into ${node}"
+ # Nested ssh: the workstation cannot reach the guests directly because they
+ # sit behind the lab host's libvirt NAT. The lab host's ~/.ssh/config holds
+ # the kc-lab-* aliases.
+ docker save "$IMAGE" \
+ | ssh "$LAB_HOST" "ssh ${node} 'sudo k3s ctr images import -'"
+done
+
+echo "==> verifying"
+for node in $NODES; do
+ printf ' %-10s ' "$node"
+ ssh "$LAB_HOST" "ssh ${node} 'sudo k3s ctr images ls -q'" \
+ | grep -c "$IMAGE" \
+ | xargs -I{} echo "{} match(es)"
+done
+
+echo
+echo "next: kubectl rollout restart -n header-lab deployment/echo"
diff --git a/deploy/lab/scripts/measure-proxy-headers.sh b/deploy/lab/scripts/measure-proxy-headers.sh
new file mode 100755
index 0000000..949da0f
--- /dev/null
+++ b/deploy/lab/scripts/measure-proxy-headers.sh
@@ -0,0 +1,42 @@
+#!/usr/bin/env bash
+# Measure what the nginx -> Traefik chain actually delivers to the application.
+#
+# docs/reverse-proxy-headers.md documents a single-hop nginx contract. The lab
+# runs two hops, so the forwarded headers are measured rather than assumed.
+# Run from anywhere that can resolve the lab hostnames.
+#
+# ./deploy/lab/scripts/measure-proxy-headers.sh
+set -euo pipefail
+
+HOST="${HOST:-app1.hyeonworks.com}"
+URL="https://${HOST}/api/echo"
+
+jqf() {
+ if command -v jq >/dev/null 2>&1; then jq "$@"; else python3 -m json.tool; fi
+}
+
+echo "=== 1. baseline: what the app sees for a normal request ==="
+curl -s "$URL" | jqf '{
+ scheme, secure, serverName, serverPort, requestUrl, remoteAddr,
+ forwarded: .headers | with_entries(select(.key | startswith("x-forwarded") or . == "x-real-ip" or . == "forwarded"))
+}' 2>/dev/null || curl -s "$URL"
+
+echo
+echo "=== 2. spoof test: client sends its own X-Forwarded-* ==="
+echo " a trusted boundary must overwrite these, not append to them"
+curl -s "$URL" \
+ -H 'X-Forwarded-For: 1.2.3.4' \
+ -H 'X-Forwarded-Proto: http' \
+ -H 'X-Forwarded-Host: evil.example.com' \
+ -H 'X-Real-IP: 1.2.3.4' \
+ | jqf '.headers | with_entries(select(.key | startswith("x-forwarded") or . == "x-real-ip"))' 2>/dev/null
+
+echo
+echo "=== 3. which pod answered (host nginx upstream distribution) ==="
+for _ in 1 2 3 4; do
+ curl -s "$URL" | jqf -r '.headers["x-forwarded-server"] // "n/a"' 2>/dev/null
+done
+
+echo
+echo "=== 4. plain HTTP is redirected, not proxied ==="
+curl -s -o /dev/null -w ' http -> %{http_code} %{redirect_url}\n' "http://${HOST}/api/echo"
diff --git a/deploy/lab/scripts/rebuild-seed.sh b/deploy/lab/scripts/rebuild-seed.sh
new file mode 100755
index 0000000..0e7cf7f
--- /dev/null
+++ b/deploy/lab/scripts/rebuild-seed.sh
@@ -0,0 +1,47 @@
+#!/usr/bin/env bash
+# Rebuild a guest's cloud-init seed image and publish it into the libvirt pool.
+# Run on the lab host.
+#
+# ./rebuild-seed.sh 1
+#
+# The same content lives in three places: the source YAML, the ISO, and the
+# uploaded pool volume. Editing the YAML alone changes nothing, which is why
+# this is a script and not a set of remembered commands.
+#
+# A rebuilt seed only takes effect on a freshly created VM. cloud-init runs its
+# per-instance modules once per instance-id, so an existing guest ignores it.
+set -euo pipefail
+
+N="${1:?usage: rebuild-seed.sh <1|2>}"
+CLOUD_DIR="${CLOUD_DIR:-$HOME/workspace/cloud}"
+POOL="${POOL:-default}"
+export LIBVIRT_DEFAULT_URI="${LIBVIRT_DEFAULT_URI:-qemu:///system}"
+
+cd "$CLOUD_DIR"
+src="kc-lab-${N}.yaml"
+iso="seed-kc-lab-${N}.iso"
+meta="meta-kc-lab-${N}"
+
+[ -f "$src" ] || { echo "missing $CLOUD_DIR/$src" >&2; exit 1; }
+
+# A fresh instance-id makes cloud-init treat the guest as new and re-run the
+# per-instance modules.
+printf 'instance-id: kc-lab-%s-%s\nlocal-hostname: kc-lab-%s\n' \
+ "$N" "$(date +%s)" "$N" > "$meta"
+
+# NoCloud looks for a volume labelled cidata holding files named exactly
+# user-data and meta-data. -graft-points renames them inside the image so no
+# staging directory is needed.
+xorrisofs -quiet -output "$iso" -volid CIDATA -joliet -rock -graft-points \
+ "/user-data=${src}" "/meta-data=${meta}"
+
+size="$(stat -c%s "$iso")"
+virsh vol-delete --pool "$POOL" "$iso" >/dev/null 2>&1 || true
+virsh vol-create-as "$POOL" "$iso" "$size" --format raw >/dev/null
+virsh vol-upload --pool "$POOL" "$iso" "$iso"
+
+echo "$iso published to pool '$POOL' ($size bytes)"
+echo "attach it as a virtio disk, not a SATA cdrom:"
+echo " --disk vol=${POOL}/${iso},device=disk,bus=virtio,readonly=on"
+echo "Debian genericcloud images carry no AHCI driver, so a SATA cdrom is invisible"
+echo "to the guest and cloud-init fails with no error anywhere."
diff --git a/deploy/lab/scripts/verify-lab.sh b/deploy/lab/scripts/verify-lab.sh
new file mode 100755
index 0000000..bec47dc
--- /dev/null
+++ b/deploy/lab/scripts/verify-lab.sh
@@ -0,0 +1,47 @@
+#!/usr/bin/env bash
+# Confirm the lab infrastructure is intact. Run on the lab host.
+#
+# A 404 from the HTTPS entry point is the success signal: TLS terminated and the
+# request reached Traefik, which simply had no matching ingress rule. A 502 or a
+# refused connection means the chain is broken somewhere.
+set -uo pipefail
+
+export LIBVIRT_DEFAULT_URI="${LIBVIRT_DEFAULT_URI:-qemu:///system}"
+HOSTS="${HOSTS:-auth.hyeonworks.com app1.hyeonworks.com app2.hyeonworks.com}"
+NODE_IPS="${NODE_IPS:-192.168.122.11 192.168.122.12}"
+fail=0
+
+check() { # description, expected, actual
+ if [ "$2" = "$3" ]; then printf ' ok %-34s %s\n' "$1" "$3"
+ else printf ' FAIL %-34s got %s, want %s\n' "$1" "$3" "$2"; fail=1; fi
+}
+
+echo "== guests =="
+for name in kc-lab-1 kc-lab-2; do
+ check "$name" running "$(virsh domstate "$name" 2>/dev/null || echo absent)"
+done
+
+echo "== k3s =="
+ready="$(kubectl get nodes --no-headers 2>/dev/null | grep -c ' Ready ')"
+check "nodes Ready" 2 "$ready"
+lb="$(kubectl -n kube-system get svc traefik \
+ -o jsonpath='{.status.loadBalancer.ingress[*].ip}' 2>/dev/null | wc -w)"
+check "traefik node IPs" 2 "$lb"
+
+echo "== host nginx =="
+check "service" active "$(systemctl is-active nginx)"
+check "cert renew timer" active "$(systemctl is-active certbot-renew.timer)"
+for ip in $NODE_IPS; do
+ check "traefik $ip" 404 "$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "http://${ip}/")"
+done
+
+echo "== public entry point =="
+for h in $HOSTS; do
+ check "https://$h" 404 "$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 "https://${h}/")"
+ check "tls verify $h" 0 "$(curl -s -o /dev/null -w '%{ssl_verify_result}' --max-time 8 "https://${h}/")"
+done
+check "http redirect" 301 "$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 "http://${HOSTS%% *}/")"
+
+echo
+[ "$fail" -eq 0 ] && echo "lab is healthy" || echo "lab has failures"
+exit "$fail"
diff --git a/docs/session-lab-concepts.md b/docs/session-lab-concepts.md
new file mode 100644
index 0000000..9460d89
--- /dev/null
+++ b/docs/session-lab-concepts.md
@@ -0,0 +1,2284 @@
+# 세션 저장소 실험대 — 개념 사전
+
+`develop-keycloak-session-store` 작업에서 등장하는 개념을 누적 기록한다.
+대화는 흘러가지만 이 문서는 남는다.
+
+**모든 항목은 네 가지를 갖춘다.**
+
+1. **무엇인가** — 정의
+2. **왜 여기 나오나** — 이 실험대에서 맡은 역할
+3. **없거나 틀리면** — 실제로 관찰되는 실패 양상
+4. **확인** — 상태를 직접 볼 수 있는 명령
+
+개념이 새로 나올 때마다 해당 층에 추가한다. 층은 아래에서 위로 쌓인다.
+
+- 1층 가상화 — VM을 만드는 층
+- 2층 가상 네트워크 — VM끼리, VM과 호스트를 잇는 층
+- 3층 호스트 진입 — 브라우저가 들어오는 층
+- 4층 TLS — 그 진입을 암호화하는 층
+- 5층 k3s — VM 안에서 컨테이너를 굴리는 층
+- 6층 Arch 특이사항 — 배포판 때문에 달라지는 것
+- 7층 git — 저장소 운영
+
+---
+
+## 0. "이건 Arch라서 하는 건가?"에 대한 답
+
+이 실험대 구성에서 낯선 명령이 쏟아지는 이유는 **Arch 때문이 아니다.**
+평소 리눅스 서버를 쓸 때 이런 걸 안 했던 진짜 이유는 셋 중 하나다.
+
+| 왜 안 해봤나 | 해당 작업 | 이번에 하는 이유 |
+|---|---|---|
+| **클라우드가 대신 해줬다** | KVM, libvirt, virbr0, cloud-init, DHCP 예약 | EC2를 쓰면 AWS가 하이퍼바이저다. 여기선 **우리가 하이퍼바이저**다 |
+| **이미 누가 해뒀다** | nginx `upstream`, certbot, k3s 설치 | 완성된 서버에 배포만 하던 것과, 서버를 처음부터 세우는 것의 차이 |
+| **진짜 Arch 특유** | `conf.d` include 부재, 롤링 업그레이드, `libvirtd.socket`, 패키지명 | 6층 참고 — 전체의 아주 일부다 |
+
+즉 낯선 것의 대부분은 **가상화·네트워크 층을 직접 만지기 때문**이고,
+Arch 고유는 6층에 모아둔 몇 개뿐이다. 같은 구성을 Ubuntu에서 해도
+1~5층은 명령 이름만 조금 바뀔 뿐 개념은 100% 동일하다.
+
+---
+
+## 0-1. 왜 호스트에 직접 깔지 않고 VM 2대인가
+
+나중에 반드시 다시 묻게 되는 판단이므로 근거를 남긴다.
+
+| # | 이유 | 호스트 직접 설치로는 왜 안 되나 |
+|---|---|---|
+| 1 | **독립 커널이 2개 필요** | 물리 머신이 1대뿐이다. 같은 커널에 k3s server와 agent를 올리면 "노드"가 이름뿐이라 노드 간 방화벽·파티션·노드 상실이 **성립하지 않는다** |
+| 2 | **파괴 실험 후 복원** | VM은 qcow2 오버레이를 지우면 몇 초 만에 초기 상태다. 호스트는 재설치 말고 되돌릴 방법이 없다 |
+| 3 | **관측자를 살려둔다** | 노드를 죽이는 실험인데 그 노드가 호스트면 SSH·libvirt·nginx가 같이 죽는다. **관측 수단이 실험 대상과 함께 죽으면 안 된다** |
+| 4 | **호스트 오염 방지** | k3s는 nftables 규칙·CNI 인터페이스·커널 모듈·systemd 유닛을 대량으로 심는다. 호스트는 진입점과 하이퍼바이저로만 남기는 편이 깨끗하다 |
+| 5 | **운영 배포판과 일치** | 호스트는 Arch다. 운영 k3s가 다른 배포판이면 커널·systemd 차이가 잡음이 된다. 게스트를 운영과 같게 맞추면 그 잡음이 사라진다 |
+| 6 | **netem 격리** | 커널이 분리되어 지연 주입이 게스트 안에 갇힌다. 호스트에서 걸면 SSH까지 느려진다 |
+
+**정직한 반대편** — 계약 검증(2홉 헤더, 쿠키/origin)만 볼 거라면 호스트에
+단일 노드 k3s를 직접 깔아도 충분하고 그게 더 빠르다. VM 경로가 필요해지는
+것은 **클러스터와 장애 실험부터**다.
+
+**채택하지 않은 절충안** — "호스트를 노드 1, VM을 노드 2로." 게스트 OS
+하나(약 350MB)와 설치 수고를 아끼지만 3번과 4번을 포기하게 된다.
+7.4Gi 예산에서 그 350MB보다 관측자 분리가 더 값지다고 판단했다.
+
+---
+
+## 0-2. 전체 구조 한눈에 보기
+
+개별 개념을 읽기 전에 이 그림을 먼저 본다. 가장 자주 오해하는 지점은
+**시드 ISO를 OS 이미지로 착각하는 것**이다. 시드는 OS가 아니라 설정
+데이터만 담은 370KB짜리 별도 디스크다.
+
+### VM 한 대의 디스크 구성
+
+```
+ kc-lab-1 (VM)
+ ┌───────────────────────────────────────────────────────┐
+ │ │
+ │ vda 20G vdb 370K │
+ │ ┌───────────────┐ ┌───────────────┐ │
+ │ │ / ext4 │ │ CIDATA │ │
+ │ │ 운영체제 │ │ iso9660 │ │
+ │ │ ★ 여기서 │ │ 읽기 전용 │ │
+ │ │ 부팅한다 │ │ 마운트 안 됨 │ │
+ │ └───────┬───────┘ └───────┬───────┘ │
+ └───────────┼───────────────────────────────┼───────────┘
+ │ │
+ kc-lab-1.qcow2 (264M) seed-kc-lab-1.iso (370K)
+ 변경분만 쌓이는 오버레이 user-data
+ │ meta-data
+ backing │
+ ▼
+ base.qcow2 (333M)
+ Debian 12 · 절대 수정되지 않음
+ kc-lab-2 의 오버레이도 같은 것을 공유
+```
+
+`base.qcow2` **하나를 두 VM이 공유**하고 각자 변경분만 자기 오버레이에
+쌓는다. 그래서 20G 디스크 두 개인데 실사용은 합쳐 850M 남짓이다.
+노드를 늘려도 base는 하나면 된다.
+
+### 설정 파일이 게스트에 도달하는 경로
+
+```
+ kc-lab-1.yaml
+ (사람이 편집)
+ │ 복사 (이름이 반드시 user-data 여야 함)
+ ▼
+ seed-1/user-data ┐
+ ├── xorrisofs -volid CIDATA ──▶ seed-kc-lab-1.iso
+ seed-1/meta-data ┘ │
+ (instance-id, │
+ local-hostname) virsh vol-upload│
+ ▼
+ /var/lib/libvirt/images/
+ (홈은 700 이라 qemu 가 못 읽음)
+ │
+ --disk device=disk,bus=virtio,readonly=on
+ ▼
+ 게스트의 vdb
+```
+
+세 곳에 같은 내용이 존재한다. **원본 YAML만 고치면 VM에 반영되지 않는다.**
+
+### 부팅할 때 일어나는 일
+
+```
+ 1. QEMU 가 vda 에서 부팅 → Debian 커널 시작
+ 2. cloud-init 서비스 기동 → 모든 블록 장치를 스캔
+ 3. vdb 에서 LABEL=CIDATA 발견 → 잠깐 마운트
+ 4. user-data / meta-data 읽기 → 사용자·hostname·sudo·패키지 적용
+ 5. 언마운트 → SSH 로그인 가능
+```
+
+3번이 실패하면 hostname 이 `localhost` 로 남고 사용자가 생성되지 않는다.
+`virsh screenshot` 으로 로그인 프롬프트만 봐도 즉시 판정할 수 있다.
+
+### 실험대 전체 배치 (2026-09-03 구축 완료, 실측값)
+
+```
+ 노트북 브라우저 / SSH
+ │
+ │ https://auth.hyeonworks.com (Cloudflare DNS only → 100.83.212.4)
+ │ https://app1.hyeonworks.com
+ │ https://app2.hyeonworks.com
+ ▼
+ ┌────────────────────────────────────────────────────────────┐
+ │ test-server Arch · i5-1135G7 · RAM 7.4Gi · WiFi only │
+ │ LAN 192.168.0.200 · tailnet 100.83.212.4 │
+ │ │
+ │ nginx :443 ── TLS 종료 (Let's Encrypt) ──┐ │
+ │ nginx :80 ── 301 → https │ │
+ │ sites-available/keycloak-lab │ upstream │
+ │ ▼ │
+ │ libvirt / KVM virbr0 192.168.122.0/24 (NAT) │
+ │ ┌────────────────────────────────────────────────────┐ │
+ │ │ kc-lab-1 .11 kc-lab-2 .12 │ │
+ │ │ RAM 3584M · vCPU 2 RAM 2560M · vCPU 2 │ │
+ │ │ Debian 12 genericcloud Debian 12 │ │
+ │ │ k3s server (v1.36.4) k3s agent │ │
+ │ │ Traefik :80 ◀──────────┐ Traefik :80 ◀──────┐ │ │
+ │ └─────────────────────────┼──────────────────────┼───┘ │
+ │ └──── servicelb ───────┘ │
+ └────────────────────────────────────────────────────────────┘
+
+ 앞으로 올릴 것 : Keycloak ×2 · PostgreSQL · Redis · BFF · oauth2-proxy
+```
+
+`nginx → Traefik`의 **2홉 구조**가 운영(`desktop`)과 같다는 점이 이 배치의
+핵심이다. 운영은 `nginx → 127.0.0.1:30080(NodePort) → Traefik`이고
+여기는 `nginx → 노드 IP:80(servicelb) → Traefik`으로, **단일 노드냐 2노드냐의
+차이만 있다.**
+
+**구축 완료 판정 기준** — 아래가 전부 통과해야 다음 단계로 넘어간다.
+
+```bash
+kubectl get nodes # Ready 2개
+dig A auth.hyeonworks.com +short # 100.83.212.4
+curl -sI https://auth.hyeonworks.com | head -1 # HTTP/2 404
+curl -s -o /dev/null -w '%{ssl_verify_result}\n' https://auth.hyeonworks.com # 0
+curl -sI http://auth.hyeonworks.com | head -1 # 301
+systemctl is-active nginx certbot-renew.timer # active active
+```
+
+**`404`가 성공 신호다.** TLS가 정상 종료되고 Traefik까지 도달했으나 매칭되는
+Ingress 규칙이 없다는 뜻이다. 여기서 `502`나 `connection refused`가 나오면
+체인 어딘가가 끊긴 것이다.
+
+---
+
+## 1층. 가상화
+
+### VT-x / AMD-V (하드웨어 가상화 확장)
+
+**무엇인가** — CPU가 제공하는 명령어 확장. 게스트 OS의 특권 명령을
+호스트 커널이 소프트웨어로 흉내내지 않고 CPU가 직접 처리하게 해준다.
+Intel은 `vmx`, AMD는 `svm`이라는 플래그로 노출된다.
+
+**왜 여기 나오나** — 이게 없으면 VM이 못 뜨는 게 아니라, **50배쯤 느려진다.**
+QEMU가 TCG(Tiny Code Generator)라는 순수 소프트웨어 에뮬레이션으로
+폴백하기 때문이다. k3s 노드를 그 위에서 굴리는 건 사실상 불가능하다.
+
+**없거나 틀리면** — BIOS/UEFI에서 꺼져 있으면 `/dev/kvm`이 아예 생성되지
+않는다. `virt-install`이 "KVM 가속 없음" 경고를 내고 진행한다.
+
+**확인**
+
+```bash
+grep -om1 -E 'vmx|svm' /proc/cpuinfo # 한 줄이라도 나오면 지원
+ls -l /dev/kvm # 없으면 BIOS에서 꺼진 것
+```
+
+### KVM
+
+**무엇인가** — 리눅스 커널 모듈(`kvm.ko` + `kvm_intel.ko`). 커널 자체를
+하이퍼파이저로 바꾸고 `/dev/kvm`이라는 문자 디바이스를 노출한다.
+KVM은 CPU와 메모리 가상화만 담당하고, 디스크·네트워크·화면 같은
+장치 에뮬레이션은 하지 않는다.
+
+**왜 여기 나오나** — 그 "장치 에뮬레이션을 안 한다"는 점 때문에 항상
+QEMU와 짝을 이룬다. 둘의 역할 분담을 모르면 왜 패키지를 둘 다 깔아야
+하는지가 이해되지 않는다.
+
+**없거나 틀리면** — `/dev/kvm` 권한이 없으면(그룹 `kvm` 미소속)
+"Permission denied"로 VM 생성이 실패한다.
+
+**확인**
+
+```bash
+lsmod | grep -E '^kvm'
+ls -l /dev/kvm # crw-rw-rw- 또는 그룹 kvm 소속이어야 함
+```
+
+### QEMU
+
+**무엇인가** — 장치 에뮬레이터. 가상 디스크 컨트롤러, NIC, 시리얼 포트,
+그래픽 어댑터를 소프트웨어로 만들어낸다. `-accel kvm` 옵션으로 CPU/메모리
+부분만 KVM에 위임한다.
+
+**왜 여기 나오나** — VM 하나는 실제로는 **호스트에서 도는 QEMU 프로세스
+하나**다. `ps`로 보면 보인다. 이 사실을 알면 "VM 메모리 3584M"이 호스트
+입장에선 그냥 프로세스 RSS라는 게 납득되고, 7.4Gi 예산 계산이 직관적으로
+이해된다.
+
+**확인**
+
+```bash
+ps aux | grep qemu-system-x86_64 # VM 하나당 프로세스 하나
+```
+
+### libvirt / virsh / libvirtd
+
+**무엇인가** — QEMU를 직접 다루면 명령줄 인자가 수십 개가 된다. libvirt는
+그 위에 얹는 관리 계층으로, VM 정의를 XML로 저장하고 시작·정지·스냅샷·
+네트워크를 통일된 API로 제공한다. `virsh`는 그 CLI 클라이언트다.
+
+**왜 여기 나오나** — VM을 재부팅 후에도 유지하고, 고정 IP 예약을 걸고,
+`virsh destroy`로 "노드 상실"을 재현하려면 관리 계층이 필요하다.
+
+**없거나 틀리면** — libvirt 없이 QEMU만 쓰면 VM 정의가 어디에도 저장되지
+않아 재부팅 시 전부 사라진다.
+
+**확인**
+
+```bash
+virsh list --all # 정의된 VM 전체
+virsh dumpxml kc-lab-1 # 그 VM의 실제 정의
+```
+
+### 연결 URI — `qemu:///system` vs `qemu:///session`
+
+**무엇인가** — libvirt는 **완전히 분리된 두 개의 인스턴스**를 동시에 운영한다.
+
+| URI | 데몬 | 실행 주체 | VM/네트워크 저장 위치 |
+|---|---|---|---|
+| `qemu:///system` | 시스템 데몬 | root | `/etc/libvirt/`, `/var/lib/libvirt/` |
+| `qemu:///session` | 사용자별 데몬 | 로그인 사용자 | `~/.config/libvirt/` |
+
+둘은 이름 공간이 다르다. 시스템 인스턴스의 `default` 네트워크는
+세션 인스턴스에서 **존재하지 않는다.**
+
+**왜 여기 나오나** — `virsh`는 **root로 실행하면 `qemu:///system`,
+일반 사용자로 실행하면 `qemu:///session`**을 기본값으로 잡는다.
+그래서 `sudo virsh net-start default`는 성공하는데 이어서
+`virsh net-dumpxml default`(sudo 없이)는 "Network not found"가 난다.
+같은 명령을 sudo 유무만 다르게 쳤을 뿐인데 **다른 서버에 물어본 셈**이다.
+
+**없거나 틀리면** — `error: failed to get network 'default'` /
+`Network not found: no network with matching name 'default'`.
+네트워크가 없어서가 아니라 **엉뚱한 인스턴스를 보고 있어서** 나는 오류다.
+
+**해결** — 셸 프로필에 기본 URI를 박아두면 sudo도 `-c`도 필요 없어진다.
+
+```bash
+echo 'export LIBVIRT_DEFAULT_URI=qemu:///system' >> ~/.zshrc
+exec zsh # 또는 재로그인
+```
+
+**확인**
+
+```bash
+virsh uri # qemu:///system 이 나와야 함
+virsh -c qemu:///system net-list --all # URI를 매번 명시하는 방법
+```
+
+**sudo와 비-sudo를 섞지 말 것** — 이 문제는 한 번 고쳐도 반복해서 재발한다.
+`sudo`는 기본적으로 환경 변수를 물려주지 않으므로, `~/.zshrc`에
+`LIBVIRT_DEFAULT_URI`를 걸어둬도 **`sudo virsh`에는 전달되지 않는다.**
+다만 sudo는 root로 실행되니 결과적으로 `qemu:///system`이 되어 동작한다.
+그래서 두 방식 모두 되긴 하는데, **섞어 쓰면 어떤 명령은 되고 어떤 명령은
+"Network not found"가 나는 상황**이 만들어진다.
+
+**한 가지만 고른다. 권장은 sudo를 쓰지 않는 쪽이다.**
+
+```bash
+echo 'export LIBVIRT_DEFAULT_URI=qemu:///system' >> ~/.zshrc
+exec zsh
+virsh uri # qemu:///system 확인 후, 이제 sudo 없이 모든 virsh 명령
+```
+
+**증상 → 원인 대조표**
+
+| 증상 | 실제 원인 |
+|---|---|
+| `Network not found: no network with matching name 'default'` | 세션 인스턴스를 보고 있다. 네트워크가 없는 게 아니다 |
+| `sudo`로는 되는데 그냥은 안 됨 | 위와 동일 |
+| `net-update`가 오류 없이 끝났는데 반영이 안 됨 | `--config`만 주고 `--live`를 빠뜨렸다 (또는 반대) |
+| 재부팅하니 설정이 사라짐 | `--live`만 주고 `--config`를 빠뜨렸다 |
+
+**변경이 실제로 남았는지 보는 법** — libvirt는 "실행 중 정의"와
+"영구 정의"를 따로 들고 있다. 둘 다 확인해야 한다.
+
+```bash
+virsh net-dumpxml default # 실행 중 정의 (--live 가 반영되는 곳)
+virsh net-dumpxml --inactive default # 영구 정의 (--config 가 반영되는 곳)
+```
+
+### 보조 그룹과 재로그인
+
+**무엇인가** — `usermod -aG libvirt $USER`는 `/etc/group` 파일을 수정한다.
+그런데 프로세스의 그룹 목록은 **로그인 시점에 한 번 읽혀서 고정**되고,
+이미 떠 있는 셸에는 소급 적용되지 않는다.
+
+**왜 여기 나오나** — `usermod` 직후 같은 터미널에서 `virsh -c qemu:///system`을
+치면 권한 거부가 날 수 있다. 명령이 잘못된 게 아니라 셸이 옛날 그룹 목록을
+들고 있는 것이다. 새 SSH 세션이나 재로그인이면 정상 동작한다.
+
+**확인**
+
+```bash
+id # 현재 셸이 들고 있는 그룹 (여기 libvirt 가 보여야 함)
+getent group libvirt # /etc/group 의 실제 내용 (이쪽엔 바로 반영됨)
+```
+
+두 결과가 다르면 재로그인이 필요하다는 뜻이다.
+급하면 `newgrp libvirt`로 해당 셸만 갱신할 수 있다.
+
+### 멱등성과 `&&` 단축 평가
+
+**무엇인가** — 멱등(idempotent)한 명령은 여러 번 실행해도 결과가 같다.
+libvirt 명령 중에는 그렇지 않은 것이 있다.
+
+| 명령 | 이미 그 상태일 때 | 멱등한가 |
+|---|---|---|
+| `virsh net-start default` | `error: network is already active` | **아니오** |
+| `virsh net-autostart default` | 조용히 성공 | 예 |
+
+**왜 여기 나오나** — `A && B`는 **A가 성공했을 때만 B를 실행**한다.
+그래서 `net-start && net-autostart`를 두 번째로 실행하면
+`net-start`가 "이미 active"로 실패하면서 `net-autostart`가 **아예 실행되지
+않는다.** 오류 메시지만 보면 둘 다 실패한 것처럼 보이지만,
+실제로는 앞선 실행에서 이미 목적을 달성한 상태다.
+
+**다시 실행해도 안전한 형태** — `&&` 대신 `;`를 쓰고 실패를 삼킨다.
+
+```bash
+virsh net-start default 2>/dev/null; virsh net-autostart default
+```
+
+### systemd 소켓 활성화 (`libvirtd.socket`)
+
+**무엇인가** — `.service`가 아니라 `.socket`을 활성화하는 방식.
+systemd가 대신 소켓을 열어두고 있다가, 누군가 접속하면 그때 데몬을
+띄우고 연결을 넘겨준다.
+
+**왜 여기 나오나** — 그래서 `systemctl enable --now libvirtd.socket`이
+맞고 `libvirtd.service`가 아니다. 데몬은 `virsh`를 처음 실행하는 순간
+자동으로 뜬다. 최신 libvirt는 여기서 더 나아가 `virtqemud`, `virtnetworkd`
+처럼 기능별로 데몬이 쪼개져 있다(모듈러 데몬).
+
+**없거나 틀리면** — `.service`를 찾다가 "Unit not found"가 나거나,
+"failed to connect to the hypervisor"로 `virsh`가 실패한다.
+
+**확인**
+
+```bash
+systemctl status libvirtd.socket
+virsh -c qemu:///system version # 여기서 데몬이 자동 기동됨
+```
+
+### qcow2와 backing store (오버레이)
+
+**무엇인가** — qcow2는 QEMU Copy-On-Write v2 디스크 포맷이다.
+**backing store**는 원본 이미지를 읽기 전용으로 두고, 변경분만 별도
+파일에 쌓는 방식이다. 새 디스크는 처음에 수백 KB에서 시작한다.
+
+**왜 여기 나오나** — VM 2대에 같은 base 이미지를 쓰면서 디스크를 20G씩
+두 번 복사하지 않아도 된다. 그리고 실험을 망쳤을 때 오버레이만 지우면
+**몇 초 만에 초기 상태로 되돌아간다.** 반복 실험에서 이 속도가 크다.
+
+**없거나 틀리면** — **base 이미지를 지우거나 옮기면 그 위의 모든 오버레이가
+동시에 깨진다.** 오버레이는 base 경로를 절대경로로 기억한다.
+
+**확인**
+
+```bash
+qemu-img info /var/lib/libvirt/images/kc-lab-1.qcow2
+# "backing file:" 줄이 원본을 가리켜야 정상
+```
+
+### 왜 OS를 설치하지 않아도 VM이 뜨는가
+
+가장 자주 막히는 지점이다. "VM은 격리된 빈 공간이니 거기에 OS를 설치해야
+하는 것 아닌가?" — 격리는 맞지만, **설치는 필수가 아니다.**
+
+**출발점: VM의 디스크는 호스트의 파일 하나다.**
+`kc-lab-1.qcow2`라는 파일이 게스트에게는 20GB 하드디스크로 보인다.
+게스트는 그것이 파일인 줄 모른다. QEMU가 디스크인 척 해주기 때문이다.
+
+**그렇다면 "OS를 설치한다"는 것은 무슨 작업인가.**
+
+```
+빈 디스크
+ │ 설치 프로그램이 수행하는 일
+ ├─ 파티션 테이블 작성
+ ├─ 파일시스템 생성 (ext4, vfat …)
+ ├─ 패키지 수천 개를 풀어 배치
+ ├─ 부트로더 기록
+ └─ 초기 설정 작성
+ ▼
+"부팅 가능한 특정 바이트 배열" 상태의 디스크
+```
+
+**설치 과정 자체는 목적이 아니라 수단이다.** 목적은 마지막 줄의 상태다.
+그리고 그 상태는 결국 **파일 하나의 내용**이다.
+
+**그러면 그 결과물을 복사하면 되지 않나 → 그게 클라우드 이미지다.**
+Debian과 Ubuntu는 자기들 빌드 서버에서 설치를 **한 번** 수행하고,
+완성된 디스크 상태를 qcow2 파일로 떠서 공개한다. 우리는 그 파일을
+내려받아 붙이기만 하면 된다.
+
+> 소스에서 컴파일하는 것과 이미 빌드된 바이너리를 받는 것의 차이와 같다.
+> 결과물은 동일하고 시간만 아낀다.
+
+**하지만 그대로 복사하면 생기는 문제 → 그래서 cloud-init이 있다.**
+디스크를 그대로 복제하면 **모든 복사본이 완전히 동일**해진다.
+서버 100대의 hostname이 전부 같고, SSH 호스트 키가 같고, machine-id가 같다.
+심각한 문제다.
+
+그래서 클라우드 이미지는 일부러 **비워둔 상태**로 배포된다.
+
+| 항목 | 클라우드 이미지에서의 상태 |
+|---|---|
+| hostname | 미설정 (`localhost`) |
+| 사용자 계정 | 없음 |
+| 비밀번호 | 없음 |
+| SSH 호스트 키 | 없음 — 첫 부팅에 새로 생성 |
+| machine-id | 비어 있음 |
+
+**cloud-init은 이 빈칸을 첫 부팅에 채우는 장치다.**
+정리하면 이렇다.
+
+```
+전통적 설치 : [설치 + 개인화]를 부팅 전에 대화형으로 수행
+클라우드 : [설치]는 배포자가 미리 완료
+ [개인화]만 첫 부팅에 cloud-init 이 자동 수행
+```
+
+**격리는 그대로다.** "설치를 안 했으니 격리가 약한가?"는 오해다.
+격리는 **실행 시점에 KVM/QEMU가 만드는 것**이지 설치 과정이 만드는 것이
+아니다. 게스트는 자기 커널로 부팅하고 자기 메모리 공간에서 돈다.
+디스크 내용을 어떻게 얻었는지와는 무관하다.
+
+**디스크를 채우는 세 가지 방법**
+
+| 방법 | 채우는 주체 | 소요 시간 |
+|---|---|---|
+| ISO 설치 | 설치 프로그램 (대화형) | 10~30분 |
+| **클라우드 이미지** | **이미 채워진 파일을 다운로드** | **수 초** |
+| 템플릿 복제 | 만들어둔 VM을 복사 | 수 초 |
+
+이 실험대는 두 번째를 쓴다. 그리고 한 걸음 더 나아가 **복사조차 하지
+않는다** — `base.qcow2`를 읽기 전용으로 두고 오버레이에 변경분만 쌓는다
+(qcow2 backing store 항목 참고). 그래서 20G VM 두 대의 실사용량이
+850M 남짓이다.
+
+### 디스크 이미지를 "복사한다"는 것의 실제 원리
+
+앞 항목의 "완성된 디스크를 파일로 떠서 배포한다"가 물리적으로 어떻게
+가능한지를 아래에서 단계적으로 푼다.
+
+**1단계 — 디스크는 바이트의 1차원 배열이다**
+
+하드디스크나 SSD는 운영체제에게 **섹터(보통 512B 또는 4096B)가 0번부터
+쭉 늘어선 배열**로 보인다. 그 이상의 구조는 없다.
+
+```
+섹터: 0 1 2 3 ... N
+ ┌────────┬────────┬────────┬────────┬─────┬────────┐
+ │ MBR/GPT│ 파티션 │ 파일시스템 메타 │ 데이터 … │
+ └────────┴────────┴────────┴────────┴─────┴────────┘
+```
+
+파티션 테이블도, 파일시스템도, 부트로더도 **전부 이 배열 안의 특정 위치에
+기록된 바이트**일 뿐이다. 디스크 바깥에 따로 보관되는 정보가 없다.
+
+**2단계 — 그래서 배열 전체를 파일에 담을 수 있다**
+
+배열을 처음부터 끝까지 그대로 파일에 쓰면 그것이 **raw 이미지**다.
+
+```bash
+dd if=/dev/sda of=disk.img bs=4M # 디스크 전체를 파일로
+dd if=disk.img of=/dev/sdb bs=4M # 파일을 다른 디스크로 되돌림
+```
+
+되돌린 디스크는 원본과 **바이트 단위로 동일**하므로 똑같이 부팅된다.
+"OS를 복사했다"는 말의 실체가 이것이다. 특별한 마법이 아니라
+**배열을 그대로 옮긴 것**이다.
+
+**3단계 — VM에서는 그 파일이 곧 디스크다**
+
+물리 디스크로 되돌릴 필요조차 없다. QEMU에게 "이 파일을 디스크로 취급하라"고
+하면 게스트는 그것을 진짜 디스크로 인식한다. 게스트가 섹터 1234를 읽으면
+QEMU가 파일의 해당 오프셋을 읽어 돌려준다.
+
+```
+ 게스트 커널: "섹터 1234 읽어줘"
+ │
+ ▼
+ QEMU: 파일의 1234 × 512 바이트 위치를 읽음
+ │
+ ▼
+ 호스트 파일시스템: kc-lab-1.qcow2
+```
+
+**4단계 — qcow2는 raw의 개선판이다**
+
+raw 이미지는 20GB짜리 디스크면 파일도 20GB다. qcow2는 세 가지를 더한다.
+
+| 기능 | 내용 |
+|---|---|
+| 희소 저장 | 실제로 쓰인 영역만 파일에 담는다 (20G 디스크 → 264M 파일) |
+| backing file | 다른 이미지를 "바탕"으로 삼고 차이만 저장 |
+| 스냅샷 | 특정 시점 상태를 보존 |
+
+qcow2 내부는 **2단계 페이지 테이블**과 같은 구조다.
+
+```
+ 게스트 섹터 주소
+ │
+ ▼
+ ┌─────────┐ ┌─────────┐ ┌──────────────┐
+ │ L1 테이블│ ─────▶ │ L2 테이블│ ─────▶ │ 데이터 클러스터│
+ └─────────┘ └─────────┘ │ (기본 64KB) │
+ │ └──────────────┘
+ │ 항목이 비어 있으면
+ ▼
+ backing file 로 위임
+ (base.qcow2)
+```
+
+**읽기**: L1 → L2를 따라가 클러스터를 찾는다. 항목이 비어 있으면
+**backing file에게 그 주소를 다시 묻는다.** 그래서 오버레이에 아무것도
+없어도 base의 내용이 그대로 보인다.
+
+**쓰기 (copy-on-write)**: 그 클러스터를 backing에서 읽어와 오버레이에
+복사한 뒤 수정한다. 이후 그 클러스터는 오버레이에서 직접 읽힌다.
+**base 파일은 절대 수정되지 않는다.**
+
+이것이 20G VM 두 대가 850M만 쓰는 이유이고, 실험을 망쳤을 때
+**오버레이만 지우면 몇 초 만에 초기 상태로 돌아가는** 이유다.
+
+**5단계 — 그대로 복제할 때 남는 문제**
+
+디스크가 바이트 단위로 같으므로 **안에 적힌 식별자도 같아진다.**
+
+| 항목 | 중복되면 |
+|---|---|
+| machine-id | systemd/DHCP가 두 기계를 같은 기계로 오인 |
+| SSH 호스트 키 | 두 서버가 같은 신원을 주장 → MITM 탐지 무력화 |
+| 파일시스템 UUID | `/etc/fstab`이 엉뚱한 디스크를 마운트 |
+| hostname | 로그·클러스터에서 노드 구분 불가 |
+
+클라우드 이미지가 이 값들을 **비워둔 채 배포하고**, cloud-init이 첫 부팅에
+채우는 이유가 바로 이것이다. 앞 항목의 "빈칸" 표와 여기가 연결된다.
+
+### multipass, virt-install, virsh — 무엇이 다른가
+
+**흔한 오해: "Ubuntu는 multipass, Debian은 virsh"가 아니다.**
+둘은 배포판이 아니라 **계층이 다른 도구**다.
+
+```
+ multipass (Ubuntu 전용 런처) ┐
+ vagrant (범용 런처) │
+ virt-manager (GUI) ├──▶ libvirt ──▶ QEMU + KVM ──▶ CPU
+ virt-install (CLI, VM 생성) │
+ virsh (CLI, VM 관리) ┘
+```
+
+**multipass도 결국 QEMU/KVM 위에서 돈다.** 리눅스에서는 기본 드라이버가
+`qemu`이고, `multipass set local.driver=libvirt`로 libvirt를 쓰게 할 수도 있다.
+즉 우리가 쓴 것과 같은 토대다.
+
+**multipass가 대신 해주던 일** — 이번에 손으로 한 작업이 정확히 그것이다.
+
+| multipass 가 자동으로 | 이번에 우리가 한 것 |
+|---|---|
+| Ubuntu 클라우드 이미지 자동 다운로드·캐시 | `curl`로 base.qcow2 내려받기 |
+| cloud-init user-data 자동 생성 | `kc-lab-1.yaml` 작성 |
+| 시드 ISO 생성·연결 | `xorrisofs` + virtio 디스크 연결 |
+| SSH 키 자동 생성·주입 | `ssh-keygen` + `ssh_authorized_keys` |
+| 네트워크·DHCP 구성 | `virbr0` + DHCP 예약 |
+| `multipass shell` 로 즉시 접속 | `~/.ssh/config` 별칭 작성 |
+
+**multipass를 안 쓴 이유는 배포판이 아니라 범위 때문이다.**
+multipass는 **Ubuntu 이미지만** 공식 지원해서 Debian 게스트를 띄울 수 없다.
+반대로 libvirt는 Ubuntu 게스트도 얼마든지 띄운다. 그리고 이 실험대는
+`virsh destroy`로 노드를 죽이고, NetworkPolicy로 포트를 막고,
+스냅샷으로 되돌리는 **저수준 제어**가 실험의 본체라 관리 계층이 필요했다.
+
+> multipass가 쉬웠던 이유는 이 모든 것을 감춰줬기 때문이고,
+> 그래서 세부를 배울 기회도 없었다. 지금 개념이 쏟아지는 이유가 이것이다.
+
+### 클라우드 이미지와 cloud-init
+
+**무엇인가** — 클라우드 이미지는 OS 설치가 이미 끝난 qcow2 파일이다.
+설치 과정이 없으므로 부팅하면 바로 로그인 화면 직전 상태다.
+다만 사용자 계정과 SSH 키가 비어 있는데, 그 빈칸을 첫 부팅에 채우는
+장치가 **cloud-init**이다. `user-data`라는 YAML을 읽어서 계정 생성,
+SSH 키 등록, 패키지 설치, 임의 스크립트 실행을 수행한다.
+
+**왜 여기 나오나** — VM 2대를 ISO로 설치하면 대화형 설치를 두 번 해야
+한다. 클라우드 이미지 + cloud-init이면 `virt-install` 한 줄로 끝나고,
+**두 대가 정확히 동일한 상태로 만들어진다.** 실험 재현성의 기본이다.
+
+**없거나 틀리면** — user-data 없이 클라우드 이미지를 부팅하면 로그인할
+방법이 없다. 콘솔에 붙어도 비밀번호를 모른다.
+
+**왜 cloud-init이어야 하나 — 대안 비교**
+
+게스트에 계정과 키를 심는 방법은 셋이다.
+
+| 방법 | 비용 | 재생성 |
+|---|---|---|
+| ISO로 정식 설치 | VM마다 대화형 설치 | 매번 처음부터 반복 |
+| 이미지를 미리 개조 (`virt-customize`, `guestfish`) | libguestfs 설치 + 이미지 마운트 | 개조본을 따로 관리해야 함 |
+| **cloud-init** | **YAML 한 장** | **명령 한 줄** |
+
+**이 실험대에서 세 번째가 결정적인 이유** — 우리는 `virsh destroy`와
+오버레이 삭제로 **게스트를 반복해서 지우고 다시 만드는 것이 실험 그 자체**다.
+재생성 비용이 낮아야 실험이 굴러간다. 그리고 두 노드가 **바이트 단위로
+동일한 초기 상태**로 만들어져야 한다. 손으로 설치하면 미묘하게 달라지고,
+그 차이가 실험 결과를 오염시킨다.
+
+**우리 user-data가 실제로 하는 일**
+
+| 항목 | 없으면 |
+|---|---|
+| `users` + `ssh_authorized_keys` | **접속 자체가 불가능** (아래 닭-달걀 참고) |
+| `hostname` / `fqdn` | 두 노드가 같은 이름이라 k3s가 혼동 |
+| `manage_etc_hosts: true` | 호스트명이 안 풀려 JGroups가 자기 주소를 못 정함 |
+| `sudo: NOPASSWD` | 비대화형 설치 스크립트가 비밀번호를 물으며 멈춤 |
+| `packages` | 게스트마다 손으로 설치 |
+
+**이미지 종류 고르기** — Debian은 같은 버전을 여러 변종으로 배포한다.
+
+| 변종 | 용도 |
+|---|---|
+| `genericcloud` | **가상화 환경 전용.** virtio 드라이버만 담아 가볍다 → **KVM에는 이걸** |
+| `generic` | 베어메탈 포함. 드라이버가 많아 더 크다 |
+| `nocloud` | cloud-init 없이 기본 계정이 박혀 있는 변종 |
+
+**`--cloud-init`이 실제로 하는 일** — virt-install은 `user-data` 파일을
+읽어 **NoCloud 시드 ISO**라는 작은 이미지를 만들고, 그것을 VM에 CD-ROM으로
+붙인다. 게스트의 cloud-init은 부팅 시 그 디스크를 찾아 설정을 읽는다.
+그래서 `user-data` 파일이 **명령 실행 시점에 존재해야** 한다. 없으면
+`Couldn't acquire file ...: No such file or directory`로 즉시 실패한다.
+
+**user-data 파일은 반드시 `#cloud-config`로 시작해야 한다.** 이 첫 줄이
+없으면 cloud-init이 YAML로 인식하지 못하고 조용히 무시한다.
+증상은 "부팅은 됐는데 계정이 없다"로 나타난다.
+
+**YAML 작성에서 실제로 걸린 함정 세 가지**
+
+1. **탭 문자는 들여쓰기로 쓸 수 없다.** YAML 명세가 금지한다. 반드시
+ 스페이스여야 한다. 에디터가 탭을 넣도록 설정돼 있으면 파일 전체가
+ 파싱 실패한다. 눈으로는 구분이 안 되므로 다음으로 확인한다.
+
+ ```bash
+ grep -Pn '\t' user-data.yaml # 아무것도 안 나와야 정상
+ ```
+
+2. **리스트 항목의 하위 키는 `-` 다음 컬럼에 맞춰 더 들여쓴다.**
+
+ ```yaml
+ users:
+ - name: donghyeon # '-' 는 2칸
+ groups: [sudo] # 하위 키는 4칸 ('n' 과 같은 열)
+ shell: /bin/bash
+ ```
+
+ `groups`를 `-`와 같은 열에 두면 리스트 항목 밖으로 빠져나가
+ 구조가 깨진다.
+
+3. **`NOPASSWD` 오타는 YAML을 통과하지만 게스트를 망가뜨린다.**
+ cloud-init은 이 문자열을 `/etc/sudoers.d/90-cloud-init-users`에
+ 그대로 쓴다. `NOPASSD`처럼 잘못된 태그가 들어가면 sudoers 문법 오류가
+ 되어 **그 게스트에서 sudo 전체가 동작하지 않는다.** k3s 설치가
+ 시작조차 못 한다. YAML 검증기로는 잡히지 않는 종류의 오류다.
+
+**디스크 확장(growpart)** — 클라우드 이미지의 파티션은 원본 크기(2GB 안팎)
+그대로다. `--disk size=20`으로 20GB를 줘도 루트 파티션은 처음엔 2GB다.
+cloud-init의 `growpart` 모듈이 첫 부팅에 파티션과 파일시스템을 디스크
+끝까지 자동 확장한다. Debian 클라우드 이미지는 이 모듈이 기본 활성화라
+따로 설정할 필요가 없다.
+
+**반드시 비상 접근 수단을 남겨둘 것 (실제로 겪은 교훈)**
+
+`ssh_pwauth: false` + 키 인증만 설정한 상태에서 cloud-init이 실패하면
+**그 게스트에는 들어갈 방법이 전혀 없다.** 사용자가 생성되지 않았으니
+키도 비밀번호도 없고, 콘솔에 붙어도 로그인할 수 없다. 즉
+**실패 원인을 기록한 `/var/log/cloud-init.log`를 읽을 수가 없다.**
+진단이 불가능해서 VM을 지우고 다시 만드는 것 외에 선택지가 없어진다.
+
+콘솔 로그인용 비밀번호를 넣어두면 이 막다른 골목을 피할 수 있다.
+`ssh_pwauth: false`는 그대로 둬도 된다 — 콘솔 로그인은 sshd가 아니라
+로컬 PAM을 타므로 영향받지 않는다.
+
+```yaml
+users:
+ - name: donghyeon
+ lock_passwd: false
+ plain_text_passwd: labpass # 콘솔 전용 비상구
+ ...
+ssh_pwauth: false # SSH 비밀번호 인증은 계속 차단
+```
+
+### 확정된 함정: `--cloud-init` + Debian `genericcloud` 조합은 동작하지 않는다
+
+**증상** — VM은 정상 부팅하는데 cloud-init이 아무것도 적용하지 않는다.
+hostname이 `localhost` 그대로이고, 사용자가 생성되지 않아
+`Permission denied (publickey)`로 SSH가 거부된다. **오류 메시지가 어디에도
+남지 않는다.**
+
+**원인** — 두 가지가 겹친다.
+
+1. `virt-install --cloud-init`은 시드 ISO를 **SATA CD-ROM**으로 붙인다
+ (``).
+2. Debian **`genericcloud`** 변종은 크기를 줄이려고 **물리 하드웨어 드라이버를
+ 제외**한 이미지다. virtio 계열만 들어 있어 **AHCI/SATA 장치를 보지 못한다.**
+
+그래서 게스트 입장에서 시드 ISO는 **존재하지 않는 장치**다. cloud-init은
+`cidata` 레이블을 가진 블록 장치를 찾지 못하고 데이터소스 없이 조용히 종료한다.
+
+**해결 — 시드를 virtio 디스크로 붙인다.** NoCloud 데이터소스는 CD-ROM을
+요구하지 않는다. **레이블이 `cidata`인 블록 장치면 무엇이든 된다.**
+ISO 파일을 그대로 virtio 디스크로 붙이면 게스트에 `vdb`로 보이고
+정상 인식된다.
+
+```bash
+# 1) 시드 ISO 를 직접 만든다 (virt-install 의 임시 ISO 에 의존하지 않는다)
+mkdir -p seed-1
+cp kc-lab-1.yaml seed-1/user-data
+printf 'instance-id: kc-lab-1-001\nlocal-hostname: kc-lab-1\n' > seed-1/meta-data
+xorrisofs -output seed-kc-lab-1.iso -volid CIDATA -joliet -rock \
+ seed-1/user-data seed-1/meta-data
+
+# 2) libvirt 풀에 올린다 (홈이 700 이면 qemu 가 못 읽는다)
+SZ=$(stat -c%s seed-kc-lab-1.iso)
+virsh vol-create-as default seed-kc-lab-1.iso "$SZ" --format raw
+virsh vol-upload --pool default seed-kc-lab-1.iso seed-kc-lab-1.iso
+
+# 3) --cloud-init 대신 virtio 디스크로 붙인다
+virt-install --name kc-lab-1 --memory 3584 --vcpus 2 \
+ --disk size=20,backing_store=/var/lib/libvirt/images/base.qcow2 \
+ --disk vol=default/seed-kc-lab-1.iso,device=disk,bus=virtio,readonly=on \
+ --network network=default,mac=52:54:00:aa:bb:11 \
+ --import --os-variant debian12 --noautoconsole
+```
+
+**성공 판정**
+
+```bash
+virsh domblklist kc-lab-1 # vdb 에 seed ISO 가 보여야 한다 (sda 가 아니라)
+ssh kc-lab-1 'hostname; lsblk -o NAME,LABEL,FSTYPE | grep -i cidata'
+# kc-lab-1
+# vdb CIDATA iso9660
+```
+
+### 시드 디렉터리 구조와 파일명 규칙
+
+NoCloud 데이터소스는 ISO 루트에서 **정확히 `user-data`와 `meta-data`라는
+이름**의 파일을 찾는다. `kc-lab-1.yaml` 같은 이름으로는 인식하지 못한다.
+그리고 `xorrisofs`는 입력 파일을 **basename 그대로** ISO 루트에 넣는다.
+그래서 스테이징 디렉터리에 규정된 이름으로 복사해서 굽는 것이다.
+
+```
+kc-lab-1.yaml 원본 (사람이 편집)
+seed-1/user-data 사본 — ISO 안에서 이 이름이어야 함
+seed-1/meta-data instance-id + local-hostname
+seed-kc-lab-1.iso 구워진 결과 (volid=CIDATA)
+```
+
+**`meta-data`는 생략할 수 없다.** user-data만 있으면 NoCloud가 그 장치를
+데이터소스로 인정하지 않는다. 최소 내용은 두 줄이다.
+
+```
+instance-id: kc-lab-1-001
+local-hostname: kc-lab-1
+```
+
+**`instance-id`의 의미** — cloud-init은 사용자 생성 같은 per-instance 모듈을
+**instance-id당 한 번만** 실행한다. 같은 id로 재부팅하면 다시 실행하지 않는다.
+디스크를 유지한 채 user-data를 재적용하려면 instance-id를 바꿔야 한다.
+
+**스테이징 디렉터리를 없애는 방법** — `-graft-points`로 ISO 안의 경로를
+직접 지정하면 복사본이 필요 없다.
+
+```bash
+xorrisofs -output seed-kc-lab-1.iso -volid CIDATA -joliet -rock -graft-points \
+ /user-data=kc-lab-1.yaml /meta-data=meta-kc-lab-1
+```
+
+**주의: 같은 내용이 세 곳에 존재한다** — 원본 YAML, ISO 안, 그리고 libvirt
+풀에 업로드된 사본. **원본 YAML을 고쳐도 실행 중인 VM에는 아무 영향이 없다.**
+ISO 재생성 → 풀 재업로드 → VM 재생성까지 해야 반영된다. 이 세 단계를
+스크립트로 묶어두지 않으면 "고쳤는데 왜 안 바뀌지"로 시간을 잃는다.
+
+**다른 선택지** — 시드를 SATA로 두고 싶다면 base 이미지를 `genericcloud`가
+아니라 **`generic`** 변종으로 바꾸면 된다. 드라이버가 더 들어 있어 SATA를
+인식한다. 대신 이미지가 커진다.
+
+### 진단 도구: `virsh screenshot`
+
+**이 문제를 푼 결정적 도구다.** 게스트에 로그인할 수 없을 때 화면을
+그대로 PNG로 떠서 볼 수 있다.
+
+```bash
+virsh screenshot kc-lab-1 /tmp/kc1.ppm # 확장자와 무관하게 PNG 로 저장된다
+```
+
+`localhost login:`이 보이면 cloud-init 미실행,
+`kc-lab-1 login:`이면 실행됨. **hostname 한 줄이 곧 판정**이다.
+`virsh console`은 tty를 요구하고 새 출력이 없으면 아무것도 안 보이지만,
+screenshot은 현재 화면 상태를 항상 보여준다.
+
+키 입력이 필요하면 `virsh send-key`로 보낼 수 있다.
+
+```bash
+for k in KEY_R KEY_O KEY_O KEY_T KEY_ENTER; do
+ virsh send-key kc-lab-1 --codeset linux "$k"
+done
+```
+
+### base 이미지가 무엇인지 확인하는 법
+
+변종을 잘못 받았는지 의심될 때는 공식 체크섬과 대조하면 확실하다.
+
+```bash
+H=$(sha512sum /var/lib/libvirt/images/base.qcow2 | cut -d' ' -f1)
+curl -sSL https://cloud.debian.org/images/cloud/bookworm/latest/SHA512SUMS \
+ | grep -i "^$H"
+# -> debian-12-genericcloud-amd64.qcow2
+```
+
+**`--noautoconsole`의 대가** — 이 옵션을 주면 virt-install이 즉시 반환하고
+`/var/lib/libvirt/boot/`의 임시 cloud-init ISO를 정리한다. 첫 부팅을
+눈으로 확인할 수 없어서, cloud-init 성공 여부를 **SSH가 될 때까지 알 수 없다.**
+시드 ISO를 위처럼 영구 볼륨으로 직접 관리하면 이 문제도 함께 사라진다.
+콘솔에서 빠져나올 때는 `Ctrl + ]`.
+
+**확인** (게스트 안에서)
+
+```bash
+cloud-init status --long # done 이어야 정상
+sudo cat /var/log/cloud-init-output.log
+sudo grep -iE 'error|warn|traceback' /var/log/cloud-init.log | head -30
+cat /run/cloud-init/result.json
+sudo blkid | grep -i cidata # NoCloud 시드 ISO 가 실제로 보였는지
+df -h / # growpart 가 동작했는지 (20G 근처여야 함)
+```
+
+마지막에서 두 번째 줄이 핵심이다. `cidata` 레이블이 안 보이면 게스트가
+user-data를 **아예 받지 못한 것**이고, 보이는데도 실패했다면 YAML 내용이나
+모듈 실행 단계의 문제다. 원인 범위를 절반으로 줄여준다.
+
+### UEFI / OVMF (`edk2-ovmf`)
+
+**무엇인가** — VM에 제공할 펌웨어. 기본값은 SeaBIOS(레거시 BIOS)이고,
+OVMF는 UEFI 펌웨어 구현이다.
+
+**왜 여기 나오나** — x86 generic 클라우드 이미지는 대개 BIOS로도 부팅되니
+**필수는 아니다.** 다만 최근 클라우드(EC2 UEFI 부팅 모드 포함)와 Secure
+Boot 환경을 흉내내려면 필요하고, UEFI 전용 이미지를 만나면 없으면 못 뜬다.
+"깔아두면 손해 없는" 부류다.
+
+**확인**
+
+```bash
+ls /usr/share/edk2/x64/OVMF_CODE.4m.fd # Arch 기준 경로
+```
+
+### `--os-variant` / osinfo
+
+**무엇인가** — 게스트 OS 종류를 libvirt에 알려주는 값. libvirt는 이걸로
+적절한 가상 장치 모델(virtio 사용 여부, 디스크 버스, NIC 모델)을 고른다.
+
+**왜 여기 나오나** — 잘못 주거나 생략하면 성능이 크게 떨어진다.
+예를 들어 virtio 대신 e1000 에뮬레이션 NIC이 붙으면 네트워크 처리량이
+몇 배 나빠지고, 그러면 우리가 측정하려는 노드 간 지연이 오염된다.
+
+**확인**
+
+```bash
+osinfo-query os | grep -i debian # 사용 가능한 값 목록
+```
+
+---
+
+## 2층. 가상 네트워크
+
+### libvirt `default` 네트워크와 `virbr0`
+
+**무엇인가** — libvirt가 만드는 소프트웨어 브리지(`virbr0`)와 그에 붙은
+NAT 규칙. 기본 대역은 `192.168.122.0/24`이고, 호스트가 `.1`을 가진다.
+VM들은 이 브리지에 연결되어 서로 직접 통신하고, 외부로 나갈 때만
+호스트 IP로 마스커레이딩된다.
+
+**왜 여기 나오나** — **VM끼리는 완전히 자유롭게 통신한다**는 점이 핵심이다.
+그래서 노드 간 실험(JGroups 차단, 파티션, 클러스터 형성)은 NAT여도
+아무 지장이 없다. NAT가 막는 건 "외부 → VM" 방향뿐이고, 그건 호스트
+nginx가 해결한다.
+
+**확인**
+
+```bash
+ip -brief addr show virbr0
+virsh net-dumpxml default
+```
+
+**`virbr0`이 `DOWN`으로 보이는 것은 정상이다** — 리눅스 브리지는
+활성 포트가 하나도 붙어 있지 않으면 캐리어가 없는 것으로 간주되어
+`DOWN`/`NO-CARRIER`로 표시된다. VM이 한 대라도 뜨면 그 VM의 `vnetN`
+인터페이스가 브리지에 붙으면서 `UP`으로 바뀐다. IP(`192.168.122.1/24`)가
+이미 할당되어 있다면 네트워크 정의 자체는 정상이다.
+
+### dnsmasq (libvirt 내장 DHCP/DNS)
+
+**무엇인가** — 경량 DHCP + DNS 서버. libvirt가 `default` 네트워크마다
+dnsmasq 인스턴스를 하나씩 띄워서 VM에 IP를 나눠주고 이름을 해석해준다.
+
+**왜 여기 나오나** — 이 패키지가 없으면 **VM이 부팅은 되는데 IP를 못 받는다.**
+증상이 "네트워크가 안 된다"로 나타나서 원인을 찾기 어렵다.
+
+**확인**
+
+```bash
+ps aux | grep dnsmasq | grep virbr0
+virsh net-dhcp-leases default # 실제로 나간 IP 목록
+```
+
+### DHCP 예약 (`ip-dhcp-host`)과 MAC `52:54:00`
+
+**무엇인가** — MAC 주소와 IP를 1:1로 묶어두는 dnsmasq 설정.
+"이 MAC을 가진 기계가 DHCP를 요청하면 항상 이 IP를 줘라"는 규칙이다.
+libvirt에서는 `virsh net-update`로 네트워크 정의에 넣는다.
+`52:54:00`은 QEMU/KVM에 할당된 OUI(제조사 식별 접두사)로,
+이 대역을 쓰면 실제 NIC 제조사의 MAC과 충돌하지 않는다.
+
+**인과 순서에 주의** — "upstream에 IP를 박으려고 예약을 건다"가 아니라
+반대다. **고정 주소가 필요한 이유가 여러 개 있고**, 그걸 충족하는 수단이
+DHCP 예약이며, 그 결과로 얻은 주소를 upstream에도 적는 것이다.
+
+**고정이 필요한 이유 (중요도 순)**
+
+1. **k3s가 IP를 설정 파일과 인증서에 굽는다.** `--node-ip`, `--tls-san`,
+ agent의 `K3S_URL=https://192.168.122.11:6443`, kubeconfig의 `server:`
+ 필드가 전부 IP를 담는다. server 노드의 IP가 바뀌면 agent가 클러스터에
+ 합류하지 못하고, API 서버 인증서의 SAN도 어긋나 **재발급이나 재설치**가
+ 필요해진다. 되돌리기가 가장 비싼 항목이다.
+2. **nginx는 upstream 주소를 기동 시점에 한 번만 해석한다.**
+ 오픈소스판 nginx는 `upstream` 블록의 이름을 설정 로드 때 해석하고
+ 런타임에 다시 조회하지 않는다(재조회하려면 `resolver` + 변수 트릭이나
+ 상용판이 필요). 그래서 뒤쪽 IP가 바뀌면 reload 전까지 계속 502다.
+3. **VM을 반복해서 죽이는 것이 실험 그 자체다.** `virsh destroy`로 노드
+ 상실을 재현하는데, 되살릴 때마다 주소가 달라질 여지가 있으면 실험이
+ 성립하지 않는다.
+4. **장애 주입 규칙이 주소 기반이다.** "kc-lab-2로 가는 7800을 막아라"
+ 같은 규칙에서 IP가 어긋나면 **조용히 엉뚱한 것을 막는다.** 실패가
+ 드러나지 않는 종류라 특히 위험하다.
+
+**왜 DHCP 예약인가 (다른 방법 대비)**
+
+| 방법 | 문제 |
+|---|---|
+| 게스트 안에서 static IP 설정 | cloud-init이 복잡해지고, libvirt는 그 사실을 모른다. 설정이 두 곳에 흩어진다 |
+| upstream에 호스트명 사용 | libvirt dnsmasq가 이름을 풀어주긴 하지만 호스트의 리졸버가 virbr0을 바라봐야 하고, 위 2번(기동 시 1회 해석)은 그대로 남는다 |
+| **DHCP 예약** | **주소 관리가 libvirt 한 곳에 모인다.** 게스트는 평범한 DHCP 클라이언트로 두면 된다 |
+
+**명령 분해**
+
+```bash
+virsh net-update default add ip-dhcp-host \
+ "" \
+ --live --config
+```
+
+| 토큰 | 의미 |
+|---|---|
+| `net-update` | 네트워크 정의 XML을 **부분 수정**. 전체를 편집기로 여는 `net-edit`과 달리 특정 섹션만 건드린다 |
+| `default` | 대상 네트워크 이름 |
+| `add` | 수행할 동작. 다른 값으로 `add-first`, `modify`, `delete` |
+| `ip-dhcp-host` | 수정할 섹션. 네트워크 XML의 `` 요소를 가리킨다 |
+| `""` | 삽입할 XML 조각. `mac`=대상 식별, `ip`=줄 주소, `name`=dnsmasq DNS에 등록될 이름(선택) |
+| `--live` | **실행 중인** 네트워크에 즉시 적용. libvirt가 dnsmasq 설정을 다시 쓰고 재로드시킨다 |
+| `--config` | **영구 정의**(`/etc/libvirt/qemu/networks/default.xml`)에도 저장 |
+
+**XML이 실제로 어떻게 바뀌나**
+
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+**부팅 시 실제로 일어나는 일**
+
+1. VM 부팅 → 게스트 커널이 virtio NIC 인식 → DHCP 클라이언트가
+ `DHCPDISCOVER`를 브로드캐스트한다. 이 프레임의 출발지 MAC이
+ `52:54:00:aa:bb:11`이다.
+2. `virbr0`에 붙어 있는 dnsmasq가 수신하고, 예약 테이블에서 그 MAC을 찾는다.
+3. 매칭되면 동적 범위에서 아무 주소나 고르는 대신 `192.168.122.11`을
+ `DHCPOFFER`로 제시한다.
+4. 게스트가 `DHCPREQUEST` → dnsmasq가 `DHCPACK`. 게스트에 그 IP가 적용된다.
+ 리스가 만료되어 갱신할 때도 같은 규칙이 적용되므로 주소가 유지된다.
+
+**가장 흔한 실패: MAC 불일치** — 예약의 `mac`과 VM 생성 시
+`--network network=default,mac=52:54:00:aa:bb:11`의 값이 **정확히 같아야
+한다.** 다르면 예약이 조용히 무시되고 동적 범위에서 아무 주소나 받는다.
+오류 메시지가 없으므로 증상은 "왜 IP가 다르지?"로만 나타난다.
+
+**동적 범위와의 겹침** — 현재 범위는 `.2`~`.254`라 예약 주소 `.11`, `.12`가
+그 안에 들어간다. dnsmasq는 정적으로 예약된 주소를 다른 클라이언트에게
+내주지 않으므로 **이대로도 정상 동작한다.** 더 방어적으로 가려면 범위를
+`.100`~`.254`로 좁혀 예약 대역과 분리할 수 있다.
+
+**`--live`가 실패할 때** — 네트워크가 비활성 상태면 `--live`는 쓸 수 없다.
+그때는 `--config`만 주고 네트워크를 시작하면 된다.
+
+**확인**
+
+```bash
+virsh net-dumpxml default | grep -A5 dhcp # 항목이 들어갔는지
+virsh net-dhcp-leases default # 실제로 나간 리스
+ssh kc-lab-1 ip -brief addr # 게스트가 받은 주소
+```
+
+**삭제**
+
+```bash
+virsh net-update default delete ip-dhcp-host \
+ "" --live --config
+```
+
+### `--live --config`
+
+**무엇인가** — libvirt의 변경 적용 범위 플래그.
+`--live`는 지금 실행 중인 객체에만, `--config`는 영구 정의에만 적용한다.
+**둘 다 줘야 "지금부터, 그리고 재부팅 후에도" 적용된다.**
+
+**없거나 틀리면** — `--config`만 주면 지금은 반영이 안 되고,
+`--live`만 주면 재부팅 시 사라진다. 둘 다 "왜 적용이 안 되지"로 시간을
+잡아먹는 대표적인 함정이다.
+
+### NAT vs 브리지 vs macvtap
+
+| 모드 | VM 주소 | LAN에서 VM 접근 | 이 실험대에서 |
+|---|---|---|---|
+| NAT (`virbr0`) | 192.168.122.x (사설) | 불가 (포워딩 필요) | **채택** |
+| 브리지 (`br0`) | LAN에서 직접 IP | 가능 | **WiFi라 불가** |
+| macvtap | LAN에서 직접 IP | 가능(호스트↔VM은 제외) | WiFi라 불가 |
+
+### WiFi에서 브리지가 안 되는 이유
+
+**무엇인가** — 802.11 데이터 프레임은 기본적으로 주소 필드가 3개다
+(3-address 모드). AP는 연결(association)된 station의 MAC만 알고 있고,
+그 station이 **자기 것이 아닌 출발지 MAC을 단 프레임**을 보내면 버린다.
+브리지된 VM은 정확히 그런 프레임을 보낸다 — 자기 MAC을 출발지로 쓰기
+때문이다.
+
+**왜 여기 나오나** — `test-server`에 이더넷이 없고 `wlo1`만 있다.
+그래서 "VM에 LAN IP를 직접 주자"는 계획이 물리적으로 성립하지 않는다.
+이 제약 하나가 토폴로지 전체를 NAT + 호스트 nginx로 확정시켰다.
+
+**우회 수단** — 4-address 모드(WDS)를 AP와 클라이언트 드라이버가 모두
+지원하면 가능하지만 실제로는 거의 지원되지 않는다. 현실적인 우회는
+USB 이더넷 어댑터를 꽂는 것이다.
+
+**확인**
+
+```bash
+ip -brief link | grep -v lo # 이더넷 인터페이스가 있는지
+iw dev # 무선 인터페이스 정보
+```
+
+### SSH 키는 "머신"이 아니라 "홉" 단위다
+
+**무엇인가** — SSH 인증은 항상 **클라이언트 1대 → 서버 1대**의 관계다.
+클라이언트가 개인키를 들고, 서버의 `~/.ssh/authorized_keys`에 그 공개키가
+있어야 한다. 그래서 필요한 키의 개수는 **머신 수가 아니라 홉의 수**로
+정해진다.
+
+**이 실험대의 홉**
+
+| 홉 | 클라이언트(개인키 보유) | 서버(공개키 등록) | 상태 |
+|---|---|---|---|
+| 1 | 노트북 | test-server | 이미 있음 |
+| 2 | **test-server** | kc-lab-1 / kc-lab-2 | **새로 생김** |
+
+2번 홉에서는 **test-server가 처음으로 "클라이언트" 역할을 맡는다.**
+지금까지 test-server는 서버이기만 했으므로 개인키가 없었다.
+새 키가 필요한 이유는 "키가 부족해서"가 아니라 **역할이 바뀌었기 때문**이다.
+
+**대안과 트레이드오프**
+
+| 방법 | test-server에 개인키 | 비대화형 스크립트 | 비고 |
+|---|---|---|---|
+| test-server에 키 생성 | 있음 | **가능** | 가장 단순 |
+| 에이전트 포워딩 (`ssh -A`) | 없음 | **불가** | 대화형 세션에만 에이전트가 산다 |
+| ProxyJump (`ssh -J`) | 없음 | 불가(노트북 기준으로는 가능) | 노트북에서 게스트로 직행 |
+
+**왜 이 실험대는 첫 번째인가** — k3s 설치, 장애 주입, 반복 실행을
+**test-server에서 스크립트로** 돌린다. 에이전트 포워딩은 대화형 로그인
+세션에만 유효해서 cron·systemd·백그라운드 스크립트에서는 인증이 실패한다.
+
+**권장 구성 — 두 공개키를 모두 게스트에 넣는다.** 그러면 노트북에서
+직행(ProxyJump)도 되고 test-server에서 자동화도 된다.
+
+```yaml
+ssh_authorized_keys:
+ - # 홉 2 자동화용
+ - <노트북의 ~/.ssh/id_ed25519_test_server.pub> # 노트북 직행용
+```
+
+**노트북에서 게스트로 직행하기** (`~/.ssh/config`)
+
+```
+Host kc-lab-1
+ HostName 192.168.122.11
+ User donghyeon
+ ProxyJump test-server
+ IdentityFile ~/.ssh/id_ed25519_test_server
+```
+
+`ProxyJump`는 test-server를 **터널로만** 쓰고 인증은 게스트와 직접 한다.
+그래서 test-server에 개인키를 두지 않아도 노트북에서 게스트로 붙을 수 있다.
+
+**`ssh-copy-id`를 쓸 수 없는 이유 (닭과 달걀)** — 보통은 서버를 만든 뒤
+`ssh-copy-id`로 공개키를 밀어 넣는다. 그런데 클라우드 이미지에는
+**비밀번호가 설정된 계정이 아예 없다.** 비밀번호 로그인이 불가능하므로
+키를 밀어 넣을 최초의 통로 자체가 없다.
+
+그래서 키는 **부팅 전에** 심어야 하고, 그것이 cloud-init의 존재 이유다.
+`ssh_authorized_keys`는 게스트가 처음 부팅하는 순간 이미 적용되어 있다.
+순서가 `키 생성 → cloud-init에 기입 → VM 생성`인 것은 이 제약 때문이다.
+
+**게스트 재생성과 호스트 키 변경** — 실험 중 VM을 지우고 다시 만들면
+게스트의 **호스트 키가 매번 새로 생성된다.** 같은 IP에 다른 호스트 키가
+오므로 SSH가 중간자 공격으로 간주하고 접속을 거부한다.
+
+```
+WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!
+```
+
+자동화 스크립트가 여기서 멈춘다. 폐기 가능한 실험용 게스트에 한해
+아래 설정으로 우회한다.
+
+```
+Host kc-lab-*
+ StrictHostKeyChecking no
+ UserKnownHostsFile /dev/null
+```
+
+**이 설정은 실험용 사설망 게스트에만 쓴다.** 호스트 키 검증을 끄는 것은
+중간자 공격 탐지를 포기하는 것이므로, 실제 서버 대상으로는 절대 쓰지 않는다.
+
+### `~/.ssh/config`의 first-match-wins 규칙
+
+**무엇인가** — SSH 클라이언트 설정 파일. 경로는 `~/.ssh/config`이고
+**확장자가 없다.**
+
+**가장 중요한 규칙 — 먼저 나온 값이 이긴다.** 대부분의 설정 파일은
+나중 값이 앞 값을 덮어쓰지만, `ssh_config`는 **반대다.**
+각 키워드에 대해 **파일에서 처음 만난 값**을 채택하고 이후 값은 무시한다.
+
+```
+Host kc-lab-1 # 구체적인 것이 위
+ HostName 192.168.122.11
+ User donghyeon
+
+Host kc-lab-* # 와일드카드가 아래
+ StrictHostKeyChecking no
+ UserKnownHostsFile /dev/null
+ LogLevel ERROR
+```
+
+한 호스트에 여러 블록이 매칭되면 **매칭된 모든 블록의 키워드가 합쳐지되,
+같은 키워드는 먼저 나온 것이 이긴다.** 위 예에서 `kc-lab-1`은
+두 블록에 모두 매칭되고, 키워드가 겹치지 않으므로 둘 다 적용된다.
+
+**틀리면** — 와일드카드 블록을 위에 두고 거기에 `User`를 적으면,
+아래의 구체적인 블록에 쓴 `User`가 **조용히 무시된다.** 오류가 없어서
+"왜 설정이 안 먹지"로만 나타난다.
+
+**파일 권한 규칙** — OpenSSH는 설정 파일이 아래 조건을 만족해야 읽는다.
+
+- 소유자가 **자기 자신 또는 root**
+- **group/other 쓰기 권한이 없을 것**
+
+위반하면 `Bad owner or permissions on /home/…/.ssh/config`로 **접속 자체가
+거부된다.** `sudo`로 파일을 만들면 root 소유가 되는데, 읽기 전용(644)이면
+동작은 하지만 본인이 수정할 수 없다. 소유권을 넘겨두는 편이 낫다.
+
+```bash
+sudo chown "$USER:$USER" ~/.ssh/config
+chmod 600 ~/.ssh/config
+```
+
+**`LogLevel ERROR`을 넣는 이유** — `UserKnownHostsFile /dev/null`을 쓰면
+접속할 때마다 `Warning: Permanently added ... to the list of known hosts.`가
+출력된다. 스크립트 출력이 이 경고로 뒤덮이므로 함께 눌러둔다.
+
+**확인 — `ssh -G`가 최종 판정이다**
+
+```bash
+ssh -G kc-lab-1
+```
+
+실제로 접속하지 않고 **모든 블록을 해석한 최종 설정값**을 출력한다.
+`hostname`, `user`, `identityfile`, `stricthostkeychecking` 줄이
+의도한 값인지 여기서 확인한다. 파일을 눈으로 읽는 것보다 정확하다.
+
+**확인**
+
+```bash
+ssh -v donghyeon@192.168.122.11 2>&1 | grep -i 'offering\|accepted'
+```
+
+### `/etc/hosts`와 이름 해석 순서
+
+**무엇인가** — DNS에 물어보기 **전에** 먼저 참조하는 로컬 이름↔주소 매핑
+파일. 조회 순서는 `/etc/nsswitch.conf`의 `hosts:` 줄이 정하며,
+`files`가 곧 `/etc/hosts`다. 파일에서 답을 찾으면 DNS로 나가지 않는다.
+
+**`127.0.0.1 localhost`가 필요한 이유** — `localhost`라는 이름은 DNS에
+존재하지 않는다. 로컬 파일로만 해석된다. 그런데 수많은 소프트웨어가
+`localhost`로 접속한다(JDBC URL, 헬스체크 스크립트, `curl localhost`,
+프록시 대상).
+
+`::1 localhost`만 있고 IPv4 줄이 없으면 **IPv6로만 해석된다.**
+IPv4 소켓으로만 리스닝하는 서버에 `localhost`로 붙으면 `::1`로 시도하다
+`Connection refused`가 난다. 반대 상황도 생긴다.
+이 실패는 "ping은 되는데 접속이 안 된다"는 형태로 나타나서 진단이 오래 걸린다.
+
+**`127.0.1.1 <호스트명>`이 필요한 이유** — 자기 자신의 호스트명이 해석
+가능해야 하는 프로그램이 있다.
+
+| 프로그램 | 해석 실패 시 |
+|---|---|
+| `sudo` | `unable to resolve host` 경고, 타임아웃만큼 느려짐 |
+| `hostname -f` | FQDN 조회 실패 |
+| Java `InetAddress.getLocalHost()` | 예외. **JGroups가 로컬 주소를 정할 때 이 경로를 탄다** |
+
+마지막 줄이 이 실험대와 직결된다. Keycloak 클러스터링은 JGroups를 쓰고,
+JGroups는 자기 주소를 결정해야 한다. 게스트에서 호스트명이 안 풀리면
+클러스터 형성 단계에서 엉뚱한 오류가 난다.
+
+**`127.0.0.1`이 아니라 `127.0.1.1`을 쓰는 이유** — 루프백 대역
+(`127.0.0.0/8`) 안이지만 `localhost`와는 **구분되는** 주소를 쓰기 위해서다.
+호스트명을 `127.0.0.1`에 직접 붙이면 `localhost`와 같은 주소가 되어,
+호스트명으로 바인딩한 서비스가 의도치 않게 `localhost`로도 노출된다.
+Debian 계열의 관례이며 Arch에서도 같은 이유로 유용하다.
+
+**게스트에서는 cloud-init이 대신 해준다** — `cloud-init-*.yaml`에 넣은
+`manage_etc_hosts: true`가 정확히 이 작업을 수행한다. 게스트의 `/etc/hosts`에
+호스트명 매핑을 자동으로 써준다. **호스트(test-server)에는 cloud-init이
+없으므로 직접 써야 한다.**
+
+**최종 내용** (Arch 기본값 + 호스트명 한 줄)
+
+```
+# Static table lookup for hostnames.
+# See hosts(5) for details.
+127.0.0.1 localhost
+::1 localhost
+127.0.1.1 test-server
+```
+
+**확인**
+
+```bash
+grep '^hosts:' /etc/nsswitch.conf # 조회 순서
+getent hosts localhost # 127.0.0.1 이 나와야 함
+getent hosts "$(hostname)" # 127.0.1.1 이 나와야 함
+```
+
+`getent`는 실제 이름 해석 경로를 그대로 타므로 `ping`보다 정확한 확인이다.
+
+---
+
+## 3층. 호스트 진입
+
+### 리버스 프록시와 `upstream`
+
+**무엇인가** — 클라이언트 요청을 대신 받아 뒤쪽 서버로 전달하는 서버.
+nginx의 `upstream` 블록은 **뒤쪽 서버 여러 대를 하나의 논리 이름으로
+묶는다.** `proxy_pass http://이름;`으로 그 그룹을 가리키면 nginx가
+요청을 분배한다.
+
+**왜 여기 나오나** — 지금 저장소의
+[`deploy/reverse-proxy/nginx-keycloak.conf`](reverse-proxy-headers.md)는
+`proxy_pass http://keycloak:8080`으로 **단일 대상**을 가리킨다.
+멀티노드 실험을 하려면 반드시 `upstream` 형태로 바꿔야 한다.
+
+### 왜 TLS를 끊어서 내용을 보는가
+
+**TLS 종료(termination)**란 프록시가 암호를 풀어 평문 HTTP를 읽는 것이다.
+"굳이 왜 푸는가"에 대한 답은 넷이고, 첫 번째가 근본적이다.
+
+**1. 내용을 안 보면 어디로 보낼지 결정할 수 없다**
+
+여러 도메인이 **하나의 IP와 443 포트를 공유**한다. 어느 서비스로 보낼지는
+HTTP `Host` 헤더에 적혀 있는데, **그 헤더는 TLS 안에 암호화되어 있다.**
+풀지 않으면 읽을 수 없고, 읽지 못하면 분기할 수 없다.
+
+```
+ 암호문 그대로 보면 : ████████████████ ← 어디로 보내지?
+ TLS 를 풀면 : GET / HTTP/1.1
+ Host: id.example.com ← 이걸 보고 분기
+```
+
+> **예외 — SNI**: TLS 핸드셰이크의 평문 부분(ClientHello)에 도메인이
+> 들어 있어서, 암호를 풀지 않고 **도메인 단위 분기**는 가능하다
+> (nginx `stream` + `ssl_preread`). 그러나 **경로 단위 분기는 불가능**하고,
+> 인증서를 백엔드마다 따로 관리해야 한다.
+
+**2. 인증서 관리를 한 곳에 모은다**
+
+TLS를 통과시키면 **백엔드마다 인증서를 넣어야 한다.** 서비스가 다섯이면
+발급·갱신·배포를 다섯 벌 관리한다. 프록시에서 끊으면 Let's Encrypt 갱신이
+한 곳에서 끝난다.
+
+**3. 헤더를 주입하려면 HTTP를 만질 수 있어야 한다**
+
+`X-Forwarded-Proto: https`, `X-Forwarded-Host` 같은 헤더는 평문 HTTP를
+편집할 수 있어야 넣을 수 있다. **TLS를 통과시키면 넣을 수 없다.**
+그리고 Keycloak이 `iss` 클레임과 redirect URL을 외부 주소로 올바르게
+생성하려면 이 헤더가 반드시 필요하다. 즉 **이 실험대의 구조에서는
+TLS 종료가 선택이 아니라 전제다.**
+
+**4. L7에서만 가능한 처리들**
+
+실제 운영 설정(`desktop`)에서 뽑은 증거다. 모두 L4로는 불가능하다.
+
+| 설정 | 하는 일 | L4로 가능한가 |
+|---|---|---|
+| `location = /metrics { return 404; }` | 특정 **경로** 차단 | 불가 — 경로를 모른다 |
+| `map $http_upgrade …` | WebSocket 업그레이드 처리 | 불가 — 헤더를 못 읽는다 |
+| `client_max_body_size 512m` | 요청 **본문** 크기 제한 | 불가 — 본문 경계를 모른다 |
+| `proxy_read_timeout 3600s` | 장수명 HTTP 연결 유지 | 부분적 |
+| `proxy_set_header Host …` | Host 헤더 고정 | 불가 |
+
+여기에 압축·캐싱·리다이렉트·레이트 리밋·접근 로그·WAF가 모두 포함된다.
+
+**끊는 대가**
+
+| 대가 | 이 실험대에서 |
+|---|---|
+| 프록시 뒤 구간이 평문이 된다 | 운영은 `127.0.0.1`, lab 은 `virbr0` — 둘 다 머신 밖으로 안 나간다 |
+| 신뢰 경계가 프록시까지 확장된다 | 프록시가 복호문을 볼 수 있다. 그래서 프록시 보안이 곧 전체 보안 |
+| **클라이언트 인증서가 사라진다** | mTLS 를 백엔드가 검증해야 하면 종료하면 안 된다 |
+
+**끊지 않는(passthrough) 선택이 맞는 경우**
+
+- mTLS — 백엔드가 클라이언트 인증서를 직접 검증해야 할 때
+- 백엔드가 자기 인증서로 신원을 증명해야 할 때
+- 프록시 운영자를 신뢰할 수 없을 때 (멀티테넌트 CDN 등)
+- 규정상 종단 간 암호화가 요구될 때
+
+이 경우 L4 통과 구성을 쓰며, 그것이 앞의 NLB 자리다.
+
+### `X-Forwarded-*`와 신뢰 경계
+
+**무엇인가** — 프록시가 뒤쪽 서버에게 "원래 클라이언트는 이랬다"고
+알려주는 관례적 헤더군. `X-Forwarded-Proto`(원래 스킴),
+`X-Forwarded-Host`(원래 호스트), `X-Forwarded-For`(원래 IP).
+
+**왜 여기 나오나** — TLS를 nginx에서 끊으면 Keycloak은 평문 HTTP로 요청을
+받는다. 그러면 Keycloak이 만드는 리다이렉트 URL과 토큰의 `iss` 클레임이
+`http://`로 나가버린다. 이걸 막는 게 이 헤더들이다.
+
+**핵심은 "신뢰 경계"다.** 이 헤더들은 **누구나 위조할 수 있는 평범한 HTTP
+헤더**다. 그래서 뒤쪽 서버는 "신뢰하는 프록시가 붙여준 것"만 믿어야 하고,
+신뢰하는 프록시는 클라이언트가 보낸 값을 **반드시 덮어써야** 한다
+(`proxy_set_header`가 append가 아니라 set인 이유).
+
+**없거나 틀리면** — Keycloak이 신뢰하지 않는 곳에서 이 헤더를 받으면
+공격자가 `X-Forwarded-Host`를 조작해 인증 흐름을 자기 도메인으로 돌릴 수
+있다. 반대로 헤더가 아예 없으면 `KC_HOSTNAME_STRICT=true` 아래에서
+호스트 불일치로 요청이 거부된다.
+
+**이 실험대의 쟁점** — 운영이 `nginx → Traefik` 2홉이라
+[`docs/reverse-proxy-headers.md`](reverse-proxy-headers.md)의 1홉 가정과
+어긋난다. nginx가 세팅한 값을 Traefik이 덮어쓰는지, 신뢰하는지,
+이어붙이는지에 따라 결과가 갈린다. **가장 먼저 실측할 항목.**
+
+### 스티키 세션
+
+**무엇인가** — 같은 클라이언트의 요청을 항상 같은 백엔드 노드로 보내는 것.
+nginx 오픈소스판에서는 `ip_hash`(클라이언트 IP 해시)나
+`hash <키> consistent`로 구현한다.
+
+**왜 여기 나오나** — Keycloak은 로그인 진행 중에 "인증 세션"이라는 임시
+상태를 만든다. 노드가 매 요청 바뀌면 그 상태를 다른 노드에서 가져와야 해서
+느려진다(Infinispan이 라우팅해주므로 **실패하지는 않는다**).
+Keycloak 공식 권장은 `AUTH_SESSION_ID` 쿠키 기반 스티키다.
+
+**실험 설계상 의미** — 스티키를 껐다 켜면서 동작과 지연을 비교하는 것이
+가장 값싼 멀티노드 관찰이다. 그래서 `ip_hash` 한 줄을 주석 스위치로 둔다.
+
+**주의** — `ip_hash`는 클라이언트 IP로 해시하는데, 브라우저 한 대로
+실험하면 항상 같은 노드로만 가서 분산 자체가 관찰되지 않는다.
+`AUTH_SESSION_ID` 기반은 로그인 전에 쿠키가 없다는 반대 문제가 있다.
+
+### 진입점 자체가 죽으면 — 로드밸런서의 재귀 문제
+
+**호스트 nginx는 이 실험대의 단일 장애점(SPOF)이다.** 숨길 이유가 없다.
+물리 머신도 한 대이므로 그것 역시 SPOF다. 실험대의 알려진 한계로 남겨둔다.
+
+**ALB와 NLB는 계층이 다른 것이 아니다** — 자주 오해하는 지점이다.
+둘 다 **클러스터 밖의 로드밸런서**이고, 같은 자리를 놓고 고르는 두 선택지다.
+Ingress Controller와 대응되는 관계가 아니다.
+
+| | ALB (L7) | NLB (L4) |
+|---|---|---|
+| 이해하는 것 | HTTP/HTTPS | TCP/UDP |
+| 라우팅 기준 | 호스트명·경로 | 포트 |
+| TLS | 종료함 | 통과 또는 종료 |
+| `X-Forwarded-*` | **추가함** | 추가 안 함 (PROXY protocol 사용) |
+
+우리 호스트 nginx는 TLS를 끊고 `X-Forwarded-*`를 넣으므로 **ALB에 가깝다.**
+
+**그렇다면 NLB 자리에는 무엇이 오는가**
+
+먼저 전제를 분명히 한다. **진입점 자리는 하나다.** ALB와 NLB를 나란히 두
+개 배치하지 않는다. 그리고 **L7 처리는 어딘가에서 반드시 한 번 일어난다** —
+HTTP 라우팅이 필요하기 때문이다. 배치의 차이는 **진입점과 L7 처리기가 같은
+장비인가 다른 장비인가**뿐이다.
+
+```
+ [ALB 패턴]
+ 브라우저 ─▶ ALB (L7 · TLS 종료 · 경로 라우팅) ─▶ Pod
+ └ 진입점이자 L7 처리기. 하나가 두 역할.
+
+ [NLB 패턴]
+ 브라우저 ─▶ NLB (L4 · 통과) ─▶ ingress controller (L7 · TLS 종료) ─▶ Pod
+ └ 진입점만 └ L7 처리기. 역할이 둘로 나뉜다.
+```
+
+두 번째 그림의 ingress controller는 **NLB가 아니라 L7**이다.
+"ALB와 NLB를 같이 쓴다"가 아니라 "진입점을 L4로 두고 L7 처리를 클러스터
+안으로 옮긴다"는 뜻이다.
+
+**이 실험대와 `desktop`은 둘 중 어느 쪽도 아니다 — L7이 두 겹이다.**
+
+```
+ 브라우저 ─▶ nginx (L7 · TLS 종료) ─▶ Traefik (L7 · Ingress 라우팅) ─▶ Pod
+```
+
+| 배치 | 진입점 | L7 처리 위치 |
+|---|---|---|
+| ALB 단독 | ALB (L7) | 진입점 한 곳 |
+| NLB + ingress | NLB (L4) | 클러스터 안 한 곳 |
+| **L7 + ingress** | **nginx (L7)** | **두 곳 모두** ← 이 실험대, `desktop` |
+
+**L7을 두 겹 쌓는 이유는 역할이 다르기 때문이다.**
+
+| | 호스트 nginx | Traefik |
+|---|---|---|
+| 담당 | 공개 진입점, TLS·인증서, 헤더 주입 | 클러스터 내부 라우팅 |
+| 대상 | **고정** IP:포트 | **동적** — 파드 생성·소멸을 추적 |
+| 갱신 | 사람이 파일 수정 후 reload | API 서버를 감시하며 자동 |
+
+nginx는 클러스터의 존재를 모른다. 파드 IP가 바뀌는 것도 모른다.
+그래서 **바깥세상과의 접점**만 맡고, **안에서 누가 어디 있는지**는
+Traefik이 맡는다. 이 2홉이 곧 `X-Forwarded-*` 검증의 대상이다.
+
+**NLB를 고르는 이유**
+
+| 이유 | 설명 |
+|---|---|
+| 클라이언트 IP 보존 | L4라 원본 IP가 그대로 도달. ALB는 `X-Forwarded-For`로만 전달 |
+| 고정 IP | AZ당 고정 IP 부여 가능. ALB는 DNS 이름만 준다 |
+| HTTP가 아닌 것 | LDAP, PostgreSQL, MQTT, 원시 TCP/UDP |
+| **mTLS 통과** | 클라이언트 인증서를 **백엔드가 직접 검증**해야 할 때 |
+| 지연·성능 | L4가 더 가볍다 |
+
+**Keycloak 맥락에서 네 번째가 중요하다.** X.509 클라이언트 인증서 인증을
+Keycloak이 수행하려면 TLS가 Keycloak까지 **끊기지 않고 도달**해야 한다.
+앞단에서 TLS를 종료하면 클라이언트 인증서가 사라져 불가능해진다.
+그래서 이런 요구가 있으면 L7이 아니라 L4 통과 구성을 쓴다.
+
+**이 실험대에서 NLB에 해당하는 것은 아직 없다.** 필요해지면
+nginx의 `stream {}` 블록이 그 자리다. **nginx는 한 프로세스에서
+L7과 L4를 동시에 수행할 수 있다** — AWS에서 ALB와 NLB가 별개 제품인 것과
+다른 점이다.
+
+```nginx
+http {
+ # L7 : TLS 종료 + X-Forwarded-* + 경로 라우팅 ← ALB 역할
+}
+
+stream {
+ # L4 : TCP 를 그대로 통과시킨다 ← NLB 역할
+ upstream k8s_api {
+ server 192.168.122.11:6443;
+ server 192.168.122.12:6443;
+ }
+ server {
+ listen 6443;
+ proxy_pass k8s_api;
+ }
+}
+```
+
+`stream` 블록이 실제로 필요해지는 경우는 셋이다.
+
+- k3s API 서버(6443)를 밖에서 접근 — 클라이언트 인증서 기반이라 TLS 통과 필수
+- PostgreSQL(5432)·Redis(6379)를 게스트 밖에서 직접 관찰
+- Keycloak mTLS 실험
+
+| 자리 | 클라우드 | 이 실험대 |
+|---|---|---|
+| L7 진입 (TLS 종료·경로 라우팅) | ALB | 호스트 nginx `http {}` |
+| L4 진입 (TCP 통과·IP 보존) | NLB | 호스트 nginx `stream {}` (아직 없음) |
+| 클러스터 내 L7 라우팅 | ingress controller | Traefik |
+
+**한 머신 안에서 nginx를 여러 개 띄우는 것은 의미가 없다**
+
+nginx는 이미 **master 프로세스 1개 + worker N개** 구조다. worker들이 리스닝
+소켓을 공유하며 CPU 코어 수만큼 병렬로 처리한다. 즉 프로세스 다중화는 이미
+되어 있다. 그리고 같은 머신에 인스턴스를 늘려도 **그 머신이 죽으면 전부
+죽는다.** 가용성은 전혀 늘지 않는다.
+
+**진짜 이중화는 머신을 늘리는 것이고, 그러면 새 질문이 생긴다 —
+"그럼 어느 nginx로 갈지는 누가 정하는가?"**
+
+앞에 LB를 또 두면 그 LB가 SPOF다. **재귀가 끝나지 않는다.**
+실무에서 이 재귀는 **소프트웨어가 아니라 네트워크 계층의 장치**로 끊는다.
+
+| 방법 | 재귀를 끊는 원리 | 전환 시간 |
+|---|---|---|
+| **VIP + VRRP** (keepalived) | 선택자가 없다. **IP 자체가 이동**한다 | 1~3초 |
+| **DNS 다중 A 레코드** | 클라이언트가 고른다. 실패 시 다음 IP로 재시도 | TTL 의존, 느림 |
+| **애니캐스트 + BGP/ECMP** | 라우터가 가장 가까운 경로로 보낸다 | 즉시, 대규모 전용 |
+| **클라우드 LB에 위임** | AWS가 내부적으로 다중 AZ로 이중화. 사용자는 DNS 이름만 받음 | 관리 불필요 |
+
+**VRRP가 동작하는 방식** — 가장 흔한 온프레미스 답이다.
+
+```
+ VIP 192.168.0.100 (가상 IP, 한 번에 한 대만 보유)
+ │
+ ┌───────┴───────┐
+ │ │
+ nginx-1 nginx-2
+ MASTER BACKUP
+ (VIP 보유) (대기, MASTER 생존 신호를 감시)
+
+ MASTER 사망 → BACKUP 이 VIP 를 가져가고
+ gratuitous ARP 를 브로드캐스트
+ → 스위치의 MAC 테이블이 갱신됨
+ → 같은 IP 인데 트래픽이 다른 장비로 흐른다
+```
+
+**핵심은 "선택하는 주체가 없다"는 점이다.** 클라이언트는 계속 같은 IP로
+접속하고, 그 IP가 어느 장비에 붙어 있는지가 바뀔 뿐이다. L2 계층의 ARP를
+이용해 재귀를 끊는다.
+
+**클라우드가 편한 이유가 여기 있다.** ALB/NLB는 내부적으로 여러 AZ에
+이중화되어 있고, 사용자는 DNS 이름 하나만 받는다. **재귀를 AWS가 대신
+풀어준 것**이지 재귀가 없는 것이 아니다.
+
+**이 실험대에서는 하지 않는다.** 물리 머신이 한 대라 keepalived를 구성해도
+그 머신이 죽으면 끝이라 의미가 없고, 검증 대상은 Keycloak의 세션·토큰이지
+LB 가용성이 아니다. 다만 **Traefik은 이미 두 노드에 떠 있으므로**
+"노드 하나를 죽이고 호스트 nginx의 upstream이 어떻게 반응하는지"는
+그대로 관찰할 수 있다. 그것이 이 실험대가 다루는 범위다.
+
+### `nginx -t`
+
+**무엇인가** — 설정 파일 문법 검사. 실제로 적용하지 않고 파싱만 한다.
+
+**왜 여기 나오나** — `systemctl reload nginx`는 설정이 깨져 있으면
+**기존 프로세스까지 죽인다.** `nginx -t && systemctl reload nginx`로
+연결해서 검사를 통과했을 때만 reload하는 게 습관이 되어야 한다.
+
+---
+
+## 4층. TLS
+
+### ACME
+
+**무엇인가** — Automatic Certificate Management Environment. 인증서
+발급을 자동화하는 프로토콜(RFC 8555). Let's Encrypt가 대표 구현체이고,
+certbot·Caddy·acme.sh 등이 클라이언트다.
+
+**왜 여기 나오나** — 자체 서명 인증서를 쓰면 브라우저가 경고를 띄우고,
+그 상태에서 관찰한 쿠키 동작은 신뢰할 수 없다. 실인증서가 있어야
+`Secure` 쿠키·`SameSite`·HSTS가 운영과 동일하게 동작한다.
+
+### 도메인 검증: HTTP-01 vs DNS-01
+
+**무엇인가** — "이 도메인이 정말 네 것이냐"를 증명하는 두 방식.
+
+| | HTTP-01 | DNS-01 |
+|---|---|---|
+| 증명 방법 | `http://도메인/.well-known/acme-challenge/<토큰>`에 파일 배치 | 도메인의 `_acme-challenge` TXT 레코드에 값 등록 |
+| 인바운드 80 포트 | **필요** | **불필요** |
+| 와일드카드 발급 | **불가** | **가능** |
+| 필요한 권한 | 웹서버 접근 | DNS API 토큰 |
+
+**왜 여기 나오나** — 두 줄이 결정적이다. 첫째, 우리 VM은 NAT 뒤에 있어서
+외부에서 80 포트로 들어올 수 없다. 둘째, `auth`/`app1`/`app2` 여러
+서브도메인이 필요한데 **와일드카드는 ACME 명세상 DNS-01로만 발급된다.**
+둘 다 DNS-01을 가리킨다.
+
+**확인**
+
+```bash
+sudo certbot certificates # 발급된 인증서와 도메인 목록
+sudo certbot renew --dry-run # 갱신이 실제로 되는지 예행연습
+```
+
+### `fullchain.pem` / `privkey.pem` / `cert.pem` / `chain.pem`
+
+**무엇인가** — certbot이 만드는 네 파일.
+
+| 파일 | 내용 |
+|---|---|
+| `cert.pem` | 내 도메인 인증서(리프)만 |
+| `chain.pem` | 중간 CA 인증서들만 |
+| `fullchain.pem` | 리프 + 중간 CA (= 위 둘을 이어붙인 것) |
+| `privkey.pem` | 개인키 |
+
+**왜 여기 나오나** — nginx의 `ssl_certificate`에는 **반드시 `fullchain.pem`**을
+줘야 한다. `cert.pem`을 주면 중간 CA가 빠져서, 데스크톱 브라우저에서는
+멀쩡한데 **모바일이나 curl에서만 신뢰 실패**하는 골치아픈 증상이 난다.
+
+### 공개 DNS에 사설 IP를 넣는 것
+
+**무엇인가** — `*.lab.example.com`의 A 레코드로 `192.168.0.200`을 등록하는 것.
+
+**왜 안전한가** — DNS 레코드는 이름을 주소로 바꿔줄 뿐 접근 권한을 주지
+않는다. 사설 대역(RFC 1918) 주소는 인터넷에서 라우팅되지 않으므로,
+외부인이 그 이름을 조회해도 도달할 수 없다. 노출되는 정보는 "내부에
+그런 IP를 쓴다" 정도다.
+
+**대안** — 각 클라이언트의 `/etc/hosts`에 넣기. 노출이 아예 없지만
+기기마다 관리해야 한다. 집 밖에서 tailnet(`100.83.212.4`)으로 붙을 때는
+어차피 `/etc/hosts` 덮어쓰기가 필요하다.
+
+---
+
+## 5층. k3s
+
+### k3s server / agent / node-token
+
+**무엇인가** — k3s는 쿠버네티스를 단일 바이너리로 압축한 배포판이다.
+`server`는 컨트롤 플레인(API 서버, 스케줄러, etcd 대체 SQLite)을 포함하고,
+`agent`는 워크로드만 실행한다. agent가 server에 합류할 때 쓰는 공유
+비밀이 **node-token**이다.
+
+**왜 여기 나오나** — 2노드 구성의 최소 단위가 server 1 + agent 1이다.
+이걸 서로 다른 VM(= 서로 다른 커널)에 두는 것이 "진짜 노드 상실"과
+"노드 간 방화벽" 실험의 전제 조건이다.
+
+**확인**
+
+```bash
+sudo cat /var/lib/rancher/k3s/server/node-token # server에서
+kubectl get nodes -o wide # Ready 2개
+```
+
+### `--node-ip` / `--tls-san`
+
+**무엇인가** — `--node-ip`는 노드가 자기 주소로 광고할 IP를 고정한다.
+`--tls-san`은 API 서버 인증서의 SAN(Subject Alternative Name) 목록에
+값을 추가한다.
+
+**왜 여기 나오나** — 인터페이스가 여러 개면(우리 VM은 `enp1s0` 외에
+CNI 인터페이스들이 생긴다) k3s가 엉뚱한 IP를 고를 수 있다.
+`--tls-san`이 없으면 호스트에서 `kubectl`로 붙을 때
+"certificate is valid for 127.0.0.1, not 192.168.122.11" 오류가 난다.
+
+### kubeconfig의 `127.0.0.1` 문제
+
+**무엇인가** — k3s가 만드는 `/etc/rancher/k3s/k3s.yaml`은 서버 주소가
+`https://127.0.0.1:6443`이다. 노드 자신에서 쓰는 걸 전제하기 때문이다.
+
+**왜 여기 나오나** — 이 파일을 호스트로 복사하면 호스트 자기 자신의
+6443을 가리키게 되어 연결이 실패한다. `sed`로 VM IP로 바꿔야 한다.
+
+```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
+```
+
+**이 명령은 호스트(test-server)에서 실행한다.** 게스트 안에서 실행하면
+`ssh kc-lab-1` 부분이 자기 자신에게 다시 접속하는 꼴이 되고, 애초에
+게스트에서는 `sudo k3s kubectl`을 쓰면 되므로 kubeconfig가 필요 없다.
+
+**리다이렉션은 셸이 명령보다 먼저 처리한다** — 자주 걸리는 함정이다.
+
+```
+sudo cat 원본 > ~/.kube/config
+ └──┬──┘ └─────┬─────┘
+ │ │
+ │ └─ ① 셸이 먼저 이 파일을 연다 (현재 사용자 권한으로)
+ └─ ② 그 다음에야 명령이 실행된다
+```
+
+그래서 `~/.kube` 디렉터리가 없으면 `cat`이 시작되기도 전에
+`No such file or directory`로 끝난다. **`>`는 파일을 열 뿐 경로를 만들지
+않는다.** 같은 이유로, `sudo`를 붙였는데도 출력 파일 쓰기가 거부되는
+현상이 생긴다 — `sudo`는 `cat`에만 적용되고 `>`에는 적용되지 않기 때문이다.
+그럴 때는 `sudo tee`를 쓴다.
+
+```bash
+echo 내용 | sudo tee /root/전용경로 > /dev/null
+```
+
+### Traefik (k3s 기본 ingress)
+
+**무엇인가** — k3s가 기본으로 배포하는 ingress 컨트롤러.
+`--disable=traefik`으로 끌 수 있다.
+
+**왜 여기 나오나** — **운영 환경이 k3s이므로 운영에도 Traefik이 있다.**
+그래서 실험대에서 끄면 안 된다. 우리가 검증하려는 2홉 헤더 문제가
+정확히 `nginx → Traefik` 경계에서 발생한다.
+
+### 호스트 nginx와 Traefik은 무엇이 다른가 — 둘 다 필요한 이유
+
+**대체 관계가 아니다. 서로 다른 층이다.** 하나는 클러스터 밖, 하나는 안이다.
+
+| | 호스트 nginx | Traefik (k3s ingress) |
+|---|---|---|
+| 사는 곳 | 클러스터 **밖**, 호스트 OS의 프로세스 | 클러스터 **안**, 파드 |
+| 아는 대상 | IP:포트 (고정) | 쿠버네티스 Service/Ingress (동적) |
+| 설정 방법 | 파일 편집 + `nginx -s reload` | `kubectl apply` 로 Ingress 리소스 |
+| 대상이 바뀌면 | **사람이 고쳐야 함** | **자동 반영** |
+| 결정하는 것 | **어느 노드로 보낼까** | **어느 파드로 보낼까** |
+| TLS | 여기서 종료 | 평문으로 받음 |
+
+**왜 Traefik만으로는 부족한가** — Traefik은 각 노드 위에서 돈다.
+servicelb 덕에 두 노드의 80/443에 모두 바인딩되지만, **브라우저는 어느
+노드로 가야 할지 모른다.** 그리고 그 노드가 죽으면 그 IP도 죽는다.
+
+즉 **Traefik은 노드 안에서 파드로 나눠주지만, 노드들 사이에서는 나눠주지
+못한다.** 그 일을 할 무언가가 클러스터 밖에 있어야 한다. 클라우드에서는
+ALB/NLB가 그 자리이고, 이 실험대에는 클라우드 LB가 없으므로 호스트 nginx가
+그 역할을 맡는다.
+
+```
+ 브라우저
+ │
+ ▼
+ 호스트 nginx ← 클러스터 밖 · TLS 종료 · "어느 노드로?"
+ ├──▶ 192.168.122.11:80 (kc-lab-1 의 Traefik)
+ └──▶ 192.168.122.12:80 (kc-lab-2 의 Traefik)
+ │
+ ▼
+ Traefik ← 클러스터 안 · "어느 파드로?"
+ ├──▶ keycloak Pod
+ └──▶ bff Pod
+```
+
+**Ingress와 Ingress Controller의 관계** — 자주 혼동되는 지점이다.
+
+| | 정체 |
+|---|---|
+| Ingress | **설정을 적어둔 쿠버네티스 리소스**. 그 자체로는 아무 일도 하지 않는다 |
+| Ingress Controller | 그 설정을 **실제로 수행하는 프로그램**. Traefik, ingress-nginx 등 |
+
+컨트롤러는 API 서버를 계속 감시하다가 Ingress 리소스가 생기거나 바뀌면
+자기 라우팅 설정을 갱신한다. **컨트롤러가 없으면 Ingress를 아무리 만들어도
+트래픽은 흐르지 않는다.** 반대로 호스트 nginx는 이런 감시 기능이 없어서
+대상이 바뀌면 사람이 파일을 고쳐야 한다.
+
+**한쪽만 쓰면 안 되나**
+
+| 시도 | 문제 |
+|---|---|
+| Traefik만 (노드 IP 직접 지정) | 그 노드가 죽으면 전체 다운 → **노드 상실 실험이 무의미**해진다. TLS도 클러스터 안에서 관리해야 함 |
+| nginx만 (Traefik 비활성화) | Ingress 리소스를 못 쓴다. 서비스가 늘거나 파드 IP가 바뀔 때마다 수동 수정 |
+
+그리고 **두 경우 모두 운영 구조와 달라진다.** 운영이
+`host nginx → k3s(Traefik)`이므로, 실험대도 그 2홉을 복제해야
+`X-Forwarded-*` 신뢰 경계 결론이 그대로 이전된다. 이것이 결정적인 이유다.
+
+**클라우드와의 대응**
+
+| 이 실험대 | AWS |
+|---|---|
+| 호스트 nginx | ALB / NLB |
+| Traefik | ingress-nginx, ALB Ingress Controller |
+| servicelb | 클라우드 LB 컨트롤러 |
+
+### servicelb (klipper-lb)
+
+**무엇인가** — k3s 내장 LoadBalancer 컨트롤러. 클라우드 LB가 없는
+환경에서 `type: LoadBalancer` 서비스를 처리하기 위해, **모든 노드에**
+hostPort를 여는 DaemonSet 파드를 띄운다.
+
+**왜 여기 나오나** — 이것 덕분에 Traefik이 `192.168.122.11:80`과
+`192.168.122.12:80` **양쪽 모두에서** 응답한다. 그래서 호스트 nginx의
+`upstream`에 VM 두 대를 그냥 나열하면 된다. 별도 LB 구성이 필요 없다.
+
+**확인**
+
+```bash
+kubectl -n kube-system get svc traefik # EXTERNAL-IP에 노드 IP들이 뜸
+kubectl -n kube-system get ds # svclb-* DaemonSet
+```
+
+### flannel VXLAN
+
+**무엇인가** — k3s 기본 CNI(컨테이너 네트워크 인터페이스) 백엔드.
+노드가 다르면 파드 간 트래픽을 UDP 8472로 캡슐화해서 전달한다.
+
+**왜 여기 나오나** — Keycloak 파드 두 개가 서로 다른 노드에 있으면
+JGroups 통신이 이 VXLAN 터널을 탄다. 노드 간 방화벽 실험을 할 때
+"무엇을 막을 것인가"가 여기에 달려 있다.
+
+### NetworkPolicy와 k3s의 내장 컨트롤러
+
+**무엇인가** — 파드 간 트래픽을 L3/L4에서 제어하는 쿠버네티스 리소스.
+flannel 자체는 정책을 강제하지 않으므로 별도 컨트롤러가 필요하다.
+k3s는 kube-router의 netpol 패키지를 **k3s 서버 프로세스 안에 내장**해서
+기본 활성화한다(`--disable-network-policy`로 끌 수 있음).
+
+**정정** — 이전 답변에서 `kubectl -n kube-system get pods | grep kube-router`
+로 확인하라고 했는데 **틀렸다.** 내장 구현이라 별도 파드로 뜨지 않는다.
+올바른 확인은 아래와 같다.
+
+```bash
+# 1) 비활성화 플래그가 걸려 있지 않은지
+sudo grep -i 'disable-network-policy' /etc/systemd/system/k3s.service
+
+# 2) 실제로 강제되는지 — 테스트 정책을 적용해보는 것이 확실하다
+kubectl create ns netpol-test
+kubectl -n netpol-test apply -f - <<'EOF'
+apiVersion: networking.k8s.io/v1
+kind: NetworkPolicy
+metadata:
+ name: deny-all
+spec:
+ podSelector: {}
+ policyTypes: [Ingress]
+EOF
+# 이 네임스페이스의 파드로 들어가는 트래픽이 막히면 컨트롤러가 동작 중
+```
+
+**왜 여기 나오나** — JGroups 7800 포트만 골라서 막는 실험을 nftables가
+아니라 NetworkPolicy로 하면, **운영에서 쓸 방식 그대로** 검증하게 된다.
+
+---
+
+## 6층. Arch 특이사항
+
+여기 있는 것만이 진짜 "Arch라서" 하는 일이다.
+
+### nginx 설정 구조 — `sites-available`은 nginx 기능이 아니다
+
+**중요한 사실부터.** `sites-available` / `sites-enabled`는 **nginx의 기능이
+아니라 Debian/Ubuntu 패키지 메인테이너가 만든 관례**다. nginx가 아는 것은
+`include` 지시어 하나뿐이고, 나머지는 패키지가 미리 깔아둔 디렉터리 구조다.
+
+**두 가지 관례가 있다**
+
+| | `sites-available` + `sites-enabled` | `conf.d` |
+|---|---|---|
+| 출처 | Debian / Ubuntu 패키지 | nginx 업스트림, RHEL 계열 |
+| include 줄 | `include /etc/nginx/sites-enabled/*;` | `include /etc/nginx/conf.d/*.conf;` |
+| 켜기 | `sites-enabled`에 **심볼릭 링크** 생성 | `.conf` 확장자로 파일 배치 |
+| 끄기 | 링크만 삭제 (원본은 보존) | 확장자 변경 (`.conf.disabled`) |
+
+`sites-available` 방식의 목적은 **파일을 지우지 않고 껐다 켜는 것**이다.
+원본은 `sites-available`에 그대로 두고 링크만 조작한다.
+
+**Arch는 둘 다 만들어주지 않는다.** `/etc/nginx/nginx.conf` 한 파일이
+전부이고 include 줄도 없다. 그래서 어느 쪽을 쓸지 **직접 정해서 만들어야
+한다.** 처음 Arch에서 nginx를 다룰 때 "경로가 없다"고 당황하는 이유다.
+
+```bash
+grep -n 'include.*\(conf.d\|sites-enabled\)' /etc/nginx/nginx.conf
+ls -d /etc/nginx/sites-available /etc/nginx/conf.d 2>&1
+```
+
+**이 실험대는 `sites-available` 방식을 쓴다.** 운영(`desktop`)이 Ubuntu라
+그 구조를 쓰고 있으므로, 설정을 운영으로 옮길 때 경로가 그대로 맞는 편이
+낫다는 판단이다. nginx 동작에는 차이가 없다.
+
+```bash
+sudo mkdir -p /etc/nginx/sites-available /etc/nginx/sites-enabled
+sudo sed -i 's|^http {|http {\n include /etc/nginx/sites-enabled/*;|' /etc/nginx/nginx.conf
+```
+
+**틀리면** — Debian 감각으로 `sites-enabled`에 파일을 넣었는데 include 줄이
+없으면 **아무 일도 일어나지 않는다. 오류조차 나지 않는다.**
+`nginx -T`(대문자)로 최종 병합된 설정을 출력해 내 파일이 실제로 들어갔는지
+확인하는 것이 확실하다.
+
+```bash
+sudo nginx -T | grep -n 'server_name\|upstream'
+```
+
+**Arch 기본 nginx.conf에는 자체 `server` 블록이 있다** (38~80줄 부근,
+`listen 80; server_name localhost;`). 지우지 않아도 된다. 내 블록에
+`listen 80 default_server;`를 주면 명시적 지정이 암묵적 기본값을 이긴다.
+(`default_server`를 **두 블록에** 주면 그때는 오류가 난다.)
+
+### 롤링 릴리스와 부분 업그레이드 금지
+
+Arch는 고정 릴리스가 없고 패키지가 계속 갱신된다. 그리고 **부분 업그레이드를
+지원하지 않는다.** `pacman -Sy 패키지`처럼 DB만 갱신하고 일부만 설치하면
+공유 라이브러리 버전이 어긋나 시스템이 깨질 수 있다.
+
+| 명령 | 의미 | 안전한가 |
+|---|---|---|
+| `pacman -Syu` | DB 갱신 + 전체 업그레이드 | **안전** |
+| `pacman -S 패키지` | 현재 DB 기준 설치 | 대체로 안전 |
+| `pacman -Sy 패키지` | DB만 갱신 후 일부 설치 | **위험 — 쓰지 말 것** |
+
+**실험 운영 규칙** — 실험 시작 전에 `pacman -Syu` + 재부팅을 끝내두고,
+**실험 기간에는 업그레이드하지 않는다.** 커널이 올라가면 재부팅이 필요하고,
+재부팅하면 VM이 전부 내려가서 실험이 중단된다.
+
+### 패키지명 대응표
+
+| 역할 | Arch | Debian/Ubuntu |
+|---|---|---|
+| QEMU 전체 | `qemu-full` | `qemu-system-x86` |
+| VM 생성 CLI | `virt-install` | `virtinst` |
+| UEFI 펌웨어 | `edk2-ovmf` | `ovmf` |
+| certbot DNS 플러그인 | `certbot-dns-cloudflare` | `python3-certbot-dns-cloudflare` |
+
+### 없어서 오히려 편한 것
+
+Arch에는 SELinux도 AppArmor도 기본 활성화되어 있지 않다.
+RHEL 계열에서 k3s를 설치할 때 필요한 SELinux 정책 패키지
+(`k3s-selinux`)와 컨텍스트 문제가 여기선 아예 없다.
+
+### 게스트 배포판: Debian이란 무엇이고 Ubuntu와 무엇이 다른가
+
+**Debian은 리눅스 배포판이다.** 1993년에 시작된 가장 오래되고 영향력 큰
+배포판 중 하나이며, **Ubuntu·Linux Mint·Raspberry Pi OS·Proxmox·Kali가
+전부 Debian에서 파생**됐다. Ubuntu는 2004년 Debian unstable을 기반으로
+시작했고 지금도 Debian에서 패키지를 가져와 다듬는다.
+
+그래서 서버 운영 관점에서 둘은 **매우 비슷하다.** `apt`/`dpkg` 패키지 도구,
+`/etc/apt/sources.list`, systemd, 디렉터리 구조가 전부 같다.
+Debian을 다뤄본 적이 없어도 Ubuntu 경험이 그대로 통한다.
+
+**호스트가 Arch인 것과는 무관하다.** 호스트와 게스트는 커널도 파일시스템도
+완전히 분리되어 있어 배포판을 맞출 이유가 없다. Debian을 고른 이유는 셋이다.
+
+1. 공식 클라우드 이미지가 잘 관리되고 체크섬이 공개되어 있다
+2. `genericcloud` 변종이 333M로 가볍다
+3. cloud-init 지원이 표준적이다
+
+참고로 **Arch는 공식 클라우드 이미지가 없다.** 게스트를 호스트에 맞추고
+싶어도 선택지가 아니었다.
+
+**운영 관점 비교**
+
+| 축 | Debian | Ubuntu Server |
+|---|---|---|
+| 릴리스 주기 | 약 2년, 준비되면 릴리스 | 6개월, LTS는 2년마다(4월) |
+| 지원 기간 | 정규 3년 + LTS 2년 ≈ 5년 | LTS 5년 + 유료 ESM 최대 12년 |
+| 패키지 신선도 | 보수적, 버전이 오래됨 | 상대적으로 최신 |
+| 커널 | 보수적 | 최신 + HWE 커널 선택 가능 |
+| 상용 지원 | 없음 (커뮤니티) | Canonical 유료 지원 |
+| snap | 없음 | 기본 탑재, 일부 패키지는 snap 전용 |
+| 무인 보안 업데이트 | 기본 비활성 | `unattended-upgrades` **기본 활성** |
+| AppArmor | 설치되나 기본 비활성 | **기본 활성** |
+| 방화벽 도구 | nftables 직접 | `ufw` 제공 |
+| 클라우드 기본 계정 | `debian` | `ubuntu` |
+
+**이 실험대에서 실제로 체감될 세 가지**
+
+1. **`unattended-upgrades`** — Ubuntu는 보안 업데이트를 자동 설치한다.
+ 장애 실험 도중 패키지가 바뀌면 **재현성이 깨진다.** Ubuntu를 쓴다면
+ 실험 기간에는 꺼두는 것이 맞다.
+ ```bash
+ sudo systemctl disable --now unattended-upgrades
+ ```
+2. **AppArmor** — Ubuntu는 기본 활성이다. 컨테이너 런타임이나 Keycloak의
+ 파일 접근이 원인 모르게 거부될 때 의심 대상이 하나 늘어난다.
+ Debian에서는 이 변수가 없다.
+3. **snap** — Ubuntu의 snap 패키지는 자동 갱신된다. 이것도 재현성의 적이다.
+
+**k3s 관점에서는 둘 다 공식 지원**이며 설치 스크립트도 동일하다.
+따라서 **선택 기준은 "운영 환경과 같은 것"뿐이다.** 기술적 우열이 아니라
+게스트와 운영의 커널·systemd·기본 설정 차이에서 오는 잡음을 없애는 것이
+VM을 쓰는 이유 중 하나였기 때문이다.
+
+**Ubuntu로 교체하는 방법** (k3s 설치 전이라면 10분이면 된다)
+
+```bash
+sudo curl -L -o /var/lib/libvirt/images/base-ubuntu.qcow2 \
+ https://cloud-images.ubuntu.com/releases/24.04/release/ubuntu-24.04-server-cloudimg-amd64.img
+```
+
+확장자가 `.img`지만 **내용은 qcow2**다. Ubuntu의 관례이며
+`qemu-img info`로 확인하면 `file format: qcow2`가 나온다.
+VM 재생성 시 `backing_store` 경로와 `--os-variant ubuntu24.04`만 바꾸면 되고,
+**cloud-init YAML과 시드 ISO는 그대로 재사용**할 수 있다.
+
+---
+
+## 7층. git
+
+### `.gitignore` 패턴 앵커링
+
+**무엇인가** — 패턴에 슬래시가 어디 있느냐로 적용 범위가 달라진다.
+
+| 패턴 | 매칭 범위 |
+|---|---|
+| `target/` | **모든 깊이**의 `target` 디렉터리 |
+| `/target/` | 저장소 **루트**의 `target`만 |
+| `backend/target/` | 루트 기준 그 경로 하나만 |
+| `**/target/` | `target/`과 사실상 동일 (중복) |
+
+**규칙** — 패턴 중간에 슬래시가 있으면 git은 그것을 **루트 기준 경로**로
+간주하고 앵커링한다. 슬래시가 끝에만 있으면(디렉터리 표시) 앵커링하지
+않고 모든 깊이에 적용한다.
+
+**왜 여기 나오나** — 기존 `.gitignore`에 `backend/target/`이 있었는데,
+나중에 생긴 `bff/target`과 `token-mediator/target`이 빠졌다.
+`target/`으로 바꾸면 한 줄로 전부 커버된다.
+
+### 이미 추적 중인 파일은 무시되지 않는다
+
+**무엇인가** — `.gitignore`는 **추적되지 않는 파일**에만 적용된다.
+이미 인덱스에 들어간 파일은 패턴을 추가해도 계속 추적된다.
+
+**해결** — `git rm -r --cached <경로>`로 인덱스에서만 제거한다
+(작업 디렉터리 파일은 남는다).
+
+**확인**
+
+```bash
+git ls-files | grep '/target/' # 0줄이면 rm --cached 불필요
+git check-ignore -v bff/target # 어느 규칙이 무시시키는지 출력
+git status --short # ?? 목록에서 사라졌는지
+```
+
+---
+
+## 8층. 패키지 저장소와 설치 원리
+
+`pacman -S qemu-full`이나 cloud-init의 `packages: [curl, nftables]`가
+실제로 무슨 일을 하는지. 배포판이 달라도 **원리는 동일하다.**
+
+### 저장소(repository)란 무엇인가
+
+거창해 보이지만 실체는 단순하다. **HTTP 서버에 올려둔 파일 트리와,
+그 안에 무엇이 있는지 적어둔 목록 파일(인덱스)**이다.
+
+```
+https://deb.debian.org/debian/
+├── dists/bookworm/ ← 인덱스 영역
+│ ├── InRelease 전체 목록의 요약 + GPG 서명
+│ └── main/binary-amd64/
+│ └── Packages.gz 패키지 이름·버전·의존성·해시·경로
+└── pool/main/c/curl/ ← 실제 파일 영역
+ └── curl_7.88.1-10_amd64.deb
+```
+
+핵심은 **인덱스와 실제 파일이 분리**되어 있다는 점이다. 클라이언트는
+인덱스만 먼저 받아서 계산하고, 필요한 파일만 골라 내려받는다.
+
+### 설치는 다섯 단계로 진행된다
+
+배포판과 무관하게 순서가 같다.
+
+```
+ 1. 인덱스 갱신 저장소의 목록 파일을 받아 로컬에 저장
+ 2. 의존성 해결 "curl 을 깔려면 libcurl4, libssl3 … 이 필요"를 계산
+ 3. 다운로드 필요한 패키지 파일들을 내려받음
+ 4. 검증 GPG 서명과 해시를 확인
+ 5. 설치 압축을 풀어 파일시스템에 배치, 설치 후 스크립트 실행
+```
+
+**2번이 패키지 관리자의 존재 이유다.** 의존성은 사슬로 이어지고 충돌하기도
+해서, 사람이 손으로 풀기 어렵다. 이 계산을 대신해주는 것이 `apt`와 `pacman`이다.
+
+### apt (Debian / Ubuntu)
+
+**저장소 목록**
+
+```
+/etc/apt/sources.list
+/etc/apt/sources.list.d/*.list ← 추가 저장소는 여기에 파일로
+```
+
+**`apt update` 가 하는 일** — 인덱스만 받는다. 패키지는 받지 않는다.
+
+```
+dists/bookworm/InRelease → 서명된 요약. 각 인덱스의 체크섬 포함
+dists/bookworm/main/binary-amd64/Packages.gz
+ ↓ 저장 위치
+/var/lib/apt/lists/
+```
+
+**`apt install curl` 이 하는 일**
+
+```
+/var/lib/apt/lists/ 의 인덱스로 의존성 계산
+ ↓
+pool/ 에서 .deb 파일들 다운로드 → /var/cache/apt/archives/
+ ↓
+해시 검증
+ ↓
+dpkg 가 실제 설치
+```
+
+**`apt` 와 `dpkg` 의 역할 분담** — 자주 헷갈리는 지점이다.
+
+| 도구 | 담당 |
+|---|---|
+| `apt` | 저장소 접근, 의존성 해결, 다운로드 |
+| `dpkg` | 받아온 `.deb` 하나를 실제로 푸는 저수준 도구 |
+
+그래서 `dpkg -i foo.deb`는 의존성을 해결하지 못하고 실패할 수 있다.
+
+**`.deb` 파일의 정체** — `ar` 아카이브다. 마법이 없다.
+
+```bash
+ar t curl_7.88.1-10_amd64.deb
+# debian-binary 포맷 버전
+# control.tar.xz 메타데이터 + 설치 전/후 스크립트
+# data.tar.xz 실제 파일들 (/usr/bin/curl 등)
+```
+
+### pacman (Arch)
+
+**저장소 목록**
+
+```
+/etc/pacman.conf [core] [extra] 섹션
+/etc/pacman.d/mirrorlist 실제 서버 주소 목록
+```
+
+**`pacman -Sy`** — 인덱스(`core.db`, `extra.db`)를 받아
+`/var/lib/pacman/sync/`에 저장한다. `.db` 파일은 패키지 메타데이터를 모은
+tar 아카이브다.
+
+**`pacman -S qemu-full`** — 의존성을 계산하고
+`.pkg.tar.zst` 파일과 별도 서명 파일 `.sig`를 내려받아 검증 후 설치한다.
+설치된 패키지 정보는 `/var/lib/pacman/local/`에 기록된다.
+
+**부분 업그레이드가 금지된 진짜 이유** — 6층에서 언급한 규칙의 근거가 여기 있다.
+
+```
+현재 설치: libfoo 1.0 (glibc 2.38 기준으로 빌드됨)
+pacman -Sy → 인덱스만 최신으로 갱신
+pacman -S bar → bar 최신판을 받음 (glibc 2.39 기준으로 빌드됨)
+ ↓
+bar 실행 시 symbol not found → 깨진다
+```
+
+Arch는 롤링 릴리스라 **패키지들이 서로 같은 시점의 라이브러리 버전을
+전제하고 빌드**된다. 일부만 최신으로 올리면 이 전제가 깨진다.
+Debian은 릴리스마다 버전을 고정하므로 이 문제가 없다.
+
+### 왜 HTTP로 받아도 안전한가 — 서명 신뢰 사슬
+
+저장소 주소가 `https`가 아니어도 안전하다. 신뢰가 **전송 경로가 아니라
+서명**에 걸려 있기 때문이다.
+
+```
+ 배포판 공개키 (OS 이미지에 미리 들어 있음)
+ │ 이 키로 검증
+ ▼
+ InRelease / *.db.sig (인덱스에 대한 서명)
+ │ 인덱스 안에 각 패키지의 해시가 적혀 있음
+ ▼
+ 개별 패키지 파일 (해시가 일치해야 설치)
+```
+
+키의 위치:
+
+| 배포판 | 신뢰 키 저장 위치 |
+|---|---|
+| Debian/Ubuntu | `/etc/apt/trusted.gpg.d/`, `/usr/share/keyrings/` |
+| Arch | `/etc/pacman.d/gnupg/` (`pacman-key`로 관리) |
+
+**그래서 미러가 성립한다.** 전 세계 수백 개 서버가 같은 내용을 복제해
+배포할 수 있고, 어느 미러에서 받든 서명이 맞으면 정품이다. 미러 운영자가
+파일을 바꿔치기해도 서명 검증에서 걸린다.
+
+```bash
+# Debian 계열: 신뢰하는 키 목록
+apt-key list 2>/dev/null || ls /etc/apt/trusted.gpg.d/
+# Arch: 키링 상태
+pacman-key --list-keys | head
+```
+
+### 세 배포판 대조표
+
+| | Debian/Ubuntu | Arch |
+|---|---|---|
+| 인덱스 갱신 | `apt update` | `pacman -Sy` |
+| 설치 | `apt install ` | `pacman -S ` |
+| 전체 업그레이드 | `apt upgrade` / `full-upgrade` | `pacman -Syu` |
+| 삭제 | `apt remove` / `purge` | `pacman -R` / `-Rns` |
+| 설치된 것 검색 | `dpkg -l` | `pacman -Q` |
+| 파일이 속한 패키지 | `dpkg -S <경로>` | `pacman -Qo <경로>` |
+| 패키지 형식 | `.deb` (ar 아카이브) | `.pkg.tar.zst` |
+| 인덱스 위치 | `/var/lib/apt/lists/` | `/var/lib/pacman/sync/` |
+| 저수준 도구 | `dpkg` | `pacman` 자체 |
+
+### 이 실험대에서 어디에 나타나는가
+
+- 호스트(Arch): `pacman -S qemu-full libvirt nginx certbot …`
+- 게스트(Debian): cloud-init의 `packages: [curl, nftables]` → 내부적으로 `apt`
+- 게스트: `package_update: true` → 부팅 시 `apt update` 수행
+- k3s 설치: `curl … | sh` — **저장소를 거치지 않고 바이너리를 직접 받는다.**
+ 그래서 패키지 관리자가 추적하지 못하고, 제거는 전용 스크립트
+ (`/usr/local/bin/k3s-uninstall.sh`)로 해야 한다.
+
+마지막 항목이 중요하다. 패키지 관리자를 우회하는 설치는 **서명 검증도,
+의존성 추적도, 일괄 업그레이드도 없다.** k3s처럼 자체 업그레이드 경로를
+제공하는 소프트웨어에서만 받아들일 만한 방식이다.
+
+---
+
+## 아직 기록하지 않은 개념
+
+실험 설계 단계에서 아래 항목을 이 문서에 추가한다.
+
+- Infinispan, `DIST_SYNC`, `numOwners`, 캐시별 설정
+- JGroups, `JDBC_PING`, 디스커버리와 트랜스포트의 분리, TCP 7800
+- `persistent-user-sessions` / `volatile-user-sessions`
+- 원격 Infinispan(Hot Rod)과 multi-site
+- refresh token rotation, revoke, max reuse, 동시 갱신 경쟁
+- SSO 세션 vs 애플리케이션 세션, `KEYCLOAK_IDENTITY`, `AUTH_SESSION_ID`
+- 백채널 로그아웃과 `sid` 역인덱스
+- 쿠키 `Secure` / `SameSite` / `HttpOnly`
+- Redis 영속화(RDB/AOF)와 세션 복구
+- `tc netem`, OOM killer와 `oom_score`, fsync와 페이지 캐시