From 9465582b5d1630eb4ae7c4e078021486919bf6b6 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Thu, 10 Sep 2026 15:55:36 +0900 Subject: [PATCH] =?UTF-8?q?chore:=20=EC=8B=A4=ED=96=89=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=20=EA=B5=AC=EC=84=B1=20=EB=AC=B8=EC=84=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/lab/README.md | 24 +- deploy/lab/cloud-init/kc-lab.yaml.example | 13 +- deploy/lab/edge/lab-edge-dnat.nft | 33 ++ deploy/lab/edge/lab-edge-dnat.service | 13 + .../{host => edge}/nginx-keycloak-lab.conf | 6 +- deploy/lab/edge/reload-nginx.sh | 12 + deploy/lab/host/teardown-host.sh | 68 +++ docs/guides/01-vms/README.md | 164 +++++- docs/guides/02-k3s/README.md | 323 +++++++++--- docs/guides/03-nginx/README.md | 147 +++++- docs/guides/04-tls/README.md | 112 +++- docs/guides/README.md | 44 +- docs/guides/experiments/a4-node-loss.md | 2 +- docs/lab-virtualization.md | 482 ++++++++++++++++++ docs/session-lab-concepts.md | 161 +++++- docs/session-lab-operations.md | 13 +- docs/two-hop-proxy-header-contract.md | 14 +- 17 files changed, 1489 insertions(+), 142 deletions(-) create mode 100644 deploy/lab/edge/lab-edge-dnat.nft create mode 100644 deploy/lab/edge/lab-edge-dnat.service rename deploy/lab/{host => edge}/nginx-keycloak-lab.conf (87%) create mode 100755 deploy/lab/edge/reload-nginx.sh create mode 100755 deploy/lab/host/teardown-host.sh create mode 100644 docs/lab-virtualization.md diff --git a/deploy/lab/README.md b/deploy/lab/README.md index cac4a54..4a281cf 100644 --- a/deploy/lab/README.md +++ b/deploy/lab/README.md @@ -18,15 +18,23 @@ 브라우저 / SSH (tailnet) │ https://{auth,app1,app2}.hyeonworks.com → 100.83.212.4 ▼ - lab host ── nginx :443 TLS 종료 · X-Forwarded-* 주입 - │ nginx :80 301 → https + lab host ── nftables DNAT :80,:443 → 192.168.122.10 + │ (물리 호스트가 실험대를 위해 하는 일의 전부) │ │ 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 + ├──▶ kc-lab-edge .10 nginx :443 TLS 종료 · X-Forwarded-* 주입 + │ │ nginx :80 301 → https + │ │ certbot · 갱신 타이머 · deploy 훅 + │ ├──▶ kc-lab-1 .11 Traefik :80 ──▶ Pod + │ └──▶ kc-lab-2 .12 Traefik :80 ──▶ Pod + ├──▶ kc-lab-1 .11 k3s server + └──▶ kc-lab-2 .12 k3s agent ``` +**L7 홉은 두 겹 그대로다**(엣지 nginx → Traefik). 앞에 늘어난 것은 커널이 +하는 L4 전달 한 번뿐이고, 그 대가로 **인증서·nginx 설정·certbot 이 전부 +일회용 게스트 안**으로 들어갔다. + `nginx → Traefik` **2홉**이 운영 구조와 같다는 점이 이 배치의 핵심이다. L7 프록시가 두 겹인 이유는 역할이 다르기 때문이다 — nginx는 바깥세상과의 접점(TLS·인증서·헤더)을, Traefik은 클러스터 내부의 동적 라우팅을 맡는다. @@ -36,7 +44,11 @@ L7 프록시가 두 겹인 이유는 역할이 다르기 때문이다 — nginx | 경로 | 역할 | |---|---| | `cloud-init/kc-lab.yaml.example` | 게스트 부트스트랩 템플릿 | -| `host/nginx-keycloak-lab.conf` | lab host의 `sites-available/keycloak-lab` | +| `edge/nginx-keycloak-lab.conf` | `kc-lab-edge` 의 `sites-available/keycloak-lab` | +| `edge/reload-nginx.sh` | certbot deploy 훅. 없으면 갱신이 서빙에 반영되지 않는다 (D-4) | +| `edge/lab-edge-dnat.nft` | 물리 호스트의 유일한 트래픽 규칙 | +| `edge/lab-edge-dnat.service` | 위 규칙을 부팅 때 적용 | +| `scripts/migrate-to-edge.sh` | 엣지 계층을 호스트에서 게스트로 옮긴다 | | `k8s/echo.yaml` | 2홉 헤더 계약 측정용 워크로드 | | `scripts/rebuild-seed.sh` | cloud-init 시드 ISO 재생성 + 풀 업로드 | | `scripts/build-and-import.sh` | 이미지 빌드 → 각 노드 containerd 반입 | diff --git a/deploy/lab/cloud-init/kc-lab.yaml.example b/deploy/lab/cloud-init/kc-lab.yaml.example index 6a25399..b9deec9 100644 --- a/deploy/lab/cloud-init/kc-lab.yaml.example +++ b/deploy/lab/cloud-init/kc-lab.yaml.example @@ -17,7 +17,12 @@ users: 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'] + # + # A string, not a list. The list form still boots, but `cloud-init schema -c` + # (22.4.2 on the guests) rejects it and prints the whole users.0 block with + # "is not valid under any of the given schemas" — naming no key. That makes + # the guide's own validation step look broken when it is not. + 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. @@ -35,3 +40,9 @@ package_update: true packages: - curl - nftables +# kc-lab-edge only. The k3s nodes do not need these, and the edge does not need +# anything else — nginx terminates TLS and certbot renews the certificate, both +# inside this disposable guest. +# - nginx +# - certbot +# - python3-certbot-dns-cloudflare diff --git a/deploy/lab/edge/lab-edge-dnat.nft b/deploy/lab/edge/lab-edge-dnat.nft new file mode 100644 index 0000000..8b2b8d6 --- /dev/null +++ b/deploy/lab/edge/lab-edge-dnat.nft @@ -0,0 +1,33 @@ +#!/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 + } + + # libvirt's own forward rules accept RELATED,ESTABLISHED into the guest + # subnet but not a NEW inbound connection. This runs ahead of them. + chain forward { + type filter hook forward priority filter - 10; policy accept; + ip daddr 192.168.122.10 tcp dport { 80, 443 } ct state new accept + } +} diff --git a/deploy/lab/edge/lab-edge-dnat.service b/deploy/lab/edge/lab-edge-dnat.service new file mode 100644 index 0000000..a0ecd41 --- /dev/null +++ b/deploy/lab/edge/lab-edge-dnat.service @@ -0,0 +1,13 @@ +[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 +ExecStop=/usr/sbin/nft delete table ip lab_edge + +[Install] +WantedBy=multi-user.target diff --git a/deploy/lab/host/nginx-keycloak-lab.conf b/deploy/lab/edge/nginx-keycloak-lab.conf similarity index 87% rename from deploy/lab/host/nginx-keycloak-lab.conf rename to deploy/lab/edge/nginx-keycloak-lab.conf index e115a6c..dedbad0 100644 --- a/deploy/lab/host/nginx-keycloak-lab.conf +++ b/deploy/lab/edge/nginx-keycloak-lab.conf @@ -25,8 +25,10 @@ server { } server { - listen 443 ssl default_server; - http2 on; + # 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 _; # fullchain.pem, never cert.pem: omitting the intermediates passes on diff --git a/deploy/lab/edge/reload-nginx.sh b/deploy/lab/edge/reload-nginx.sh new file mode 100755 index 0000000..c66ce5f --- /dev/null +++ b/deploy/lab/edge/reload-nginx.sh @@ -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 diff --git a/deploy/lab/host/teardown-host.sh b/deploy/lab/host/teardown-host.sh new file mode 100755 index 0000000..5f2ae71 --- /dev/null +++ b/deploy/lab/host/teardown-host.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# Remove the lab's host layer from test-server. Packages stay. +# +# sudo bash deploy/lab/host/teardown-host.sh +# +# The host's sudo asks for a password, so run this in a terminal — not over a +# non-interactive ssh, where sudo fails silently into an empty result. +# +# ★ Certificates are BACKED UP, NOT DELETED. Let's Encrypt allows 5 duplicate +# certificates per week for the same name set, and this lab's names resolve to +# a tailnet address (100.64.0.0/10, not routable from the public internet), so +# an HTTP-01 reissue cannot be validated from here. Deleting the files turns a +# free restore into a problem that has to be solved first. Restoring is: +# +# sudo tar xzf ~/letsencrypt-backup-.tgz -C /etc +set -u + +STAMP="$(date +%Y%m%d-%H%M%S)" +HOME_DIR="${SUDO_USER:+/home/$SUDO_USER}" +HOME_DIR="${HOME_DIR:-$HOME}" + +echo "===== 1) 인증서 백업 (지우지 않는다) =====" +if [ -d /etc/letsencrypt ]; then + out="$HOME_DIR/letsencrypt-backup-$STAMP.tgz" + tar czf "$out" -C /etc letsencrypt + chown "${SUDO_USER:-root}" "$out" + echo "백업: $out ($(du -h "$out" | cut -f1))" + echo "현재 인증서:" + certbot certificates 2>/dev/null | grep -E "Certificate Name|Domains|Expiry Date" || true + echo "검증 방식 (재발급이 되는지의 답):" + grep -H authenticator /etc/letsencrypt/renewal/*.conf 2>/dev/null || echo " (renewal 설정 없음)" +else + echo "/etc/letsencrypt 없음 — 건너뜀" +fi +echo + +echo "===== 2) nginx 실험대 설정 제거 =====" +if [ -f /etc/nginx/sites-available/keycloak-lab ]; then + cp /etc/nginx/sites-available/keycloak-lab "$HOME_DIR/keycloak-lab.nginx.$STAMP.bak" + echo "백업: $HOME_DIR/keycloak-lab.nginx.$STAMP.bak" +fi +rm -fv /etc/nginx/sites-enabled/keycloak-lab +rm -fv /etc/nginx/sites-available/keycloak-lab +systemctl disable --now nginx +echo + +echo "===== 3) certbot 갱신 타이머 정지 =====" +# 인증서 파일은 남기지만, 갱신 시도는 멈춘다. 지금 DNS 로는 HTTP-01 검증이 +# 실패하고, 실패가 로그에만 쌓이면서 「왜 안 되지」의 원인이 된다. +systemctl disable --now certbot-renew.timer 2>/dev/null || true +echo + +echo "===== 4) 엣지 DNAT (있으면) =====" +systemctl disable --now lab-edge-dnat.service 2>/dev/null || true +rm -fv /etc/systemd/system/lab-edge-dnat.service /etc/nftables.d/lab-edge-dnat.nft +systemctl daemon-reload +nft delete table ip lab_edge 2>/dev/null || true +echo + +echo "===== 5) 확인 =====" +echo "-- nginx: $(systemctl is-active nginx) / $(systemctl is-enabled nginx 2>&1)" +echo "-- certbot timer: $(systemctl is-active certbot-renew.timer 2>&1) / $(systemctl is-enabled certbot-renew.timer 2>&1)" +echo "-- 80/443 리스너:"; ss -tlnp | grep -E ':(80|443) ' || echo " (없음 — 정상)" +echo "-- sites-enabled:"; ls -A /etc/nginx/sites-enabled 2>/dev/null || echo " (비었음 — 정상)" +echo "-- letsencrypt:"; ls /etc/letsencrypt/live 2>/dev/null || echo " (없음)" +echo "-- libvirt 도메인:"; virsh list --all 2>/dev/null | tail -n +3 | grep -v '^$' || echo " (없음 — 정상)" +echo +echo "완료. 패키지(nginx · libvirt · qemu · certbot · kubectl)와 base.qcow2 는 남아 있다." diff --git a/docs/guides/01-vms/README.md b/docs/guides/01-vms/README.md index 8102527..0b634ee 100644 --- a/docs/guides/01-vms/README.md +++ b/docs/guides/01-vms/README.md @@ -1,20 +1,35 @@ -# 01 — VM 두 대 +# 01 — VM 세 대 ## 이 단계가 끝나면 -`kc-lab-1`·`kc-lab-2` 두 게스트가 뜨고, 호스트에서 SSH 가 키로 붙는다. +`kc-lab-edge`·`kc-lab-1`·`kc-lab-2` 세 게스트가 뜨고, lab host 에서 SSH 가 +키로 붙는다. ## 전제 [00](../00-lab-host/) 이 끝나 `virsh list` 가 sudo 없이 돈다. -## 왜 VM 두 대인가 +## 왜 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 이미지를 받는다 @@ -23,13 +38,15 @@ OS 를 설치하지 않는다. 클라우드 이미지는 **이미 설치가 끝 첫 부팅에서 cloud-init 이 사용자·SSH 키·호스트명을 채운다. **하기** + ```bash cd /var/lib/libvirt/images -sudo curl -LO https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2 -sudo mv debian-12-genericcloud-amd64.qcow2 base.qcow2 +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 ``` @@ -87,6 +104,7 @@ openssl rand -base64 18 ``` 셋을 넣는다. + ```bash sed -i \ -e "s|__LAB_HOST_KEY__|$(cat ~/.ssh/id_ed25519.pub)|" \ @@ -96,6 +114,7 @@ sed -i \ ``` **확인** — 자리표시자가 남아 있으면 cloud-init 이 그대로 넣는다 + ```bash grep -c '__' kc-lab-1.yaml # 0 이어야 한다 grep -c 'ssh-ed25519\|ssh-rsa' kc-lab-1.yaml # 2 여야 한다 @@ -129,10 +148,37 @@ 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`)를 만들 때는 검사할 게스트가 아직 @@ -140,11 +186,11 @@ ssh donghyeon@192.168.122.11 'cloud-init schema -c ~/kc-lab-2.yaml; rm -f ~/kc-l 세 가지가 의도적이다. -| | 왜 | -|---|---| -| `NOPASSWD:ALL` | k3s 설치와 장애 주입이 비대화식으로 돌아야 한다. 비밀번호를 물으면 멈춘다 | +| | 왜 | +| --------------------- | -------------------------------------------------------------------------------------------------------------------- | +| `NOPASSWD:ALL` | k3s 설치와 장애 주입이 비대화식으로 돌아야 한다. 비밀번호를 물으면 멈춘다 | | `plain_text_passwd` | **cloud-init 이 실패했을 때의 유일한 탈출구.** 키가 안 들어가면 SSH 가 막히므로 콘솔로 들어가 로그를 봐야 한다 | -| 키 두 개 | lab host 에서 자동화가 돌고(에이전트 포워딩 없음), 워크스테이션에서는 ProxyJump 로 직접 붙는다 | +| 키 두 개 | lab host 에서 자동화가 돌고(에이전트 포워딩 없음), 워크스테이션에서는 ProxyJump 로 직접 붙는다 | > **들여쓰기는 공백만.** YAML 은 탭을 금지하고, cloud-init 은 파싱에 실패해도 > **아무 오류도 남기지 않는다.** 게스트가 `localhost` 로 뜨고 로그인이 안 되는 @@ -156,6 +202,7 @@ 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 @@ -167,6 +214,7 @@ virsh vol-upload --pool default seed-kc-lab-1.iso seed-kc-lab-1.iso ``` **확인** — 볼륨이 풀에 올라갔고 비어 있지 않은가 + ```bash virsh vol-list default ``` @@ -193,6 +241,7 @@ user-data 를 고쳐도 반영되지 않는다. ## 4. VM 을 만든다 **하기** + ```bash virt-install --name kc-lab-1 --memory 5120 --vcpus 2 \ --disk size=20,backing_store=/var/lib/libvirt/images/base.qcow2 \ @@ -201,7 +250,36 @@ virt-install --name kc-lab-1 --memory 5120 --vcpus 2 \ --import --os-variant debian12 --noautoconsole ``` -`kc-lab-2` 는 이름·메모리(4096)·MAC(`...:12`)·시드만 바꾼다. +나머지 둘은 **이름·메모리·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` 이미지는 크기를 줄이려고 물리 하드웨어 @@ -217,6 +295,7 @@ virt-install --name kc-lab-1 --memory 5120 --vcpus 2 \ MAC 을 정해 두었으니 그 MAC 에 IP 를 예약한다. **하기** + ```bash virsh net-update default add ip-dhcp-host \ "" \ @@ -227,20 +306,33 @@ virsh net-update default add ip-dhcp-host \ 만 주면 지금 반영되지 않는다. **확인** — 예약이 실제로 들어갔는가 + ```bash virsh net-dumpxml default | grep ip-dhcp-host -A3 ``` **실측** + ``` + ``` +**★ 예약을 먼저 넣고 VM 을 띄운다.** 순서가 반대면 게스트가 동적 대역에서 +아무 주소나 받아 버리고, 예약이 먹으려면 리스 만료를 기다리거나 재부팅해야 +한다. 넣을 때 나오는 한 줄은 이것이다. + +``` +Updated network default persistent config and live state +``` + +`persistent config` 와 `live state` **두 마디가 다 나와야** `--live --config` +가 제대로 먹은 것이다. + **어디를 봐야 하는가** — `` 두 줄의 **MAC 끝 두 자리와 IP 끝 숫자가 -짝이 맞는가**(`:11` ↔ `.11`, `:12` ↔ `.12`). MAC 은 4번의 `virt-install ---network mac=` 에 쓴 값과 한 글자도 다르면 안 된다. `` 줄은 예약이 +짝이 맞는가**(`:11` ↔ `.11`, `:12` ↔ `.12`). MAC 은 4번의 `virt-install --network mac=` 에 쓴 값과 한 글자도 다르면 안 된다. `` 줄은 예약이 아니라 동적 대역이라, 예약 IP 가 이 안에 들어 있어도 상관없다. **이 결과가 의미하는 것** — 두 줄이 다 보이면 게스트는 재부팅해도 같은 IP 를 @@ -252,22 +344,44 @@ virsh net-dumpxml default | grep ip-dhcp-host -A3 ## 6. 붙어 본다 **확인** — 떴는가, 그리고 cloud-init 이 제 일을 했는가 + ```bash virsh list --all ssh donghyeon@192.168.122.11 'hostname; cat /etc/os-release | head -1' ``` **실측** + ``` - Id Name State --------------------------- - 3 kc-lab-2 running - 4 kc-lab-1 running + 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` 인가 @@ -285,12 +399,12 @@ 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/) 로 | +| 증상 | 원인 | 확인 | +| ----------------------------------------------------------------- | ------------------------------------------------------------------------- | ----------------------- | +| 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/) 로 | 게스트에 못 들어갈 때는 **화면을 직접 뜬다.** diff --git a/docs/guides/02-k3s/README.md b/docs/guides/02-k3s/README.md index 1cb2d69..6f0b7b1 100644 --- a/docs/guides/02-k3s/README.md +++ b/docs/guides/02-k3s/README.md @@ -2,29 +2,49 @@ ## 이 단계가 끝나면 -`kubectl get nodes` 에 두 노드가 `Ready` 로 나온다. +lab host 에서 `kubectl get nodes` 를 치면 두 노드가 `Ready` 로 나온다. +`sudo` 도 `ssh` 도 붙이지 않는다. ## 전제 -[01](../01-vms/) 이 끝나 두 게스트에 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 +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 -sudo systemctl is-active k3s -sudo kubectl get nodes +ssh kc-lab-1 'sudo systemctl is-active k3s; sudo kubectl get nodes' ``` **어디를 봐야 하는가** — 유닛이 `active` 인가, 그리고 `get nodes` 에 @@ -32,6 +52,9 @@ sudo kubectl get nodes 이거나 아예 목록이 비어 있는 것이 정상이다** — CNI 가 아직 안 올라온 시간이다. 한 번 더 친다. +이 가이드에서 `kubectl` 을 게스트 쪽에서 돌리는 것은 여기 한 번뿐이다. +lab host 에는 아직 kubeconfig 가 없기 때문이고, 그것을 두는 것이 바로 2번이다. + **이 결과가 의미하는 것** — `active` + `Ready` 면 API 서버가 살아 있고 kubeconfig 도 자리를 잡았다는 뜻이라 2번으로 간다. 유닛이 `active` 인데 `get nodes` 가 접속 오류를 내면 API 서버가 아직 기동 중이다. 유닛이 @@ -39,42 +62,115 @@ kubeconfig 도 자리를 잡았다는 뜻이라 2번으로 간다. 유닛이 `ac 로그를 본다. ```bash -sudo journalctl -u k3s -n 50 --no-pager +ssh kc-lab-1 'sudo journalctl -u k3s -n 50 --no-pager' ``` -## 2. 토큰을 꺼낸다 +## 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} 자" # 값이 아니라 길이만 확인한다 ``` -**실측** — 이 실험대에서는 56자였다. +**실측** — 이 실험대에서는 108자였다. (`K10<해시>::server:<비밀번호>` 형식이라 +k3s 판올림에 따라 자릿수가 달라진다. **중요한 것은 값이 아니라 `0` 이 아니라는 +것이다.**) **어디를 봐야 하는가** — 찍히는 것은 **자릿수 하나뿐**이다. 값은 보지 않는다 — 화면에 띄우는 순간 터미널 스크롤백과 셸 히스토리에 남는다. -**이 결과가 의미하는 것** — 두 자리 수가 나오면 토큰을 손에 쥔 것이니 3번으로 -넘어간다. `0` 이면 변수가 비었다는 -뜻이고 원인은 둘 중 하나다: server 가 아직 안 떠서 파일이 없거나, `sudo` 가 -비대화식 SSH 에서 비밀번호를 물어 실패했거나. 어느 쪽인지는 파일부터 본다. +**이 결과가 의미하는 것** — 세 자리 수가 나오면 토큰을 손에 쥔 것이니 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 -ssh kc-lab-1 'sudo ls -l /var/lib/rancher/k3s/server/node-token' -``` +[ ${#TOKEN} -ge 50 ] || echo "TOKEN 이 비었다 — 3번으로 돌아간다" -## 3. agent 를 붙인다 (kc-lab-2) - -**하기** — 토큰을 그대로 넘긴다 -```bash ssh kc-lab-2 "curl -sfL https://get.k3s.io | sudo sh -s - agent \ --server https://192.168.122.11:6443 \ --token '$TOKEN' \ --node-ip 192.168.122.12" ``` -> 토큰을 셸 히스토리에 남기고 싶지 않으면 파일로 넘긴다. +첫 줄의 가드를 빼지 않는다. `$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' @@ -84,18 +180,21 @@ ssh kc-lab-2 "curl -sfL https://get.k3s.io | sudo sh -s - agent \ > ``` **확인** — agent 가 클러스터에 들어왔는가, 그리고 **제 주소로** 들어왔는가 + ```bash -sudo kubectl get nodes -o wide +kubectl get nodes -o wide ``` **실측** + ``` -kc-lab-1 Ready control-plane v1.36.4+k3s1 192.168.122.11 -kc-lab-2 Ready v1.36.4+k3s1 192.168.122.12 +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 21m v1.36.4+k3s1 192.168.122.12 ``` **어디를 봐야 하는가** — `-o wide` 를 준 이유가 마지막 열이다. **INTERNAL-IP -두 개가 1번·3번에서 `--node-ip` 로 준 값과 같은가.** 그다음이 STATUS 두 줄 +두 개가 1번·4번에서 `--node-ip` 로 준 값과 같은가.** 그다음이 STATUS 두 줄 `Ready`, 그다음이 ROLES 열이다. `` 은 오류가 아니라 **역할 라벨이 없다**는 뜻이다 — agent 는 원래 그렇다. @@ -109,20 +208,22 @@ IP 가 다른 대역(예: flannel 이나 다른 인터페이스 주소)으로 ssh kc-lab-2 'sudo journalctl -u k3s-agent -n 30 --no-pager' ``` -## 4. 유닛 이름이 다르다 +## 5. 유닛 이름이 다르다 -| 노드 | 유닛 | -|---|---| -| server | `k3s.service` | -| agent | `k3s-agent.service` | +| 노드 | 유닛 | +| ------ | --------------------- | +| 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' @@ -133,7 +234,7 @@ ExecStart=/usr/local/bin/k3s agent '--node-ip' '192.168.122.12' 틀리면(`systemctl cat k3s` 를 agent 노드에서) `No files found` 가 나오는데, 그것 자체가 「이 노드는 agent 다」라는 답이다. -**이 결과가 의미하는 것** — 3번의 `get nodes -o wide` 는 **k3s 가 보고한** +**이 결과가 의미하는 것** — 4번의 `get nodes -o wide` 는 **k3s 가 보고한** IP 이고, 이 줄은 **우리가 준** IP 다. 둘이 다르면 옵션이 안 먹은 것이다. 그리고 뒤의 실험에서 노드를 멈출 때 칠 유닛 이름이 노드마다 다르다는 것을 여기서 확인해 둔다 — `systemctl stop k3s` 를 agent 노드에서 치면 아무 일도 @@ -142,27 +243,91 @@ IP 이고, 이 줄은 **우리가 준** IP 다. 둘이 다르면 옵션이 안 > 이 차이가 A-4 에서 결과를 갈랐다. server 노드를 잃으면 `kubectl` 자체가 > 불통이 되고, agent 를 잃으면 `kubectl` 은 되지만 그 위의 워크로드가 사라진다. -## 5. k3s 가 기본으로 딸려 오는 것 +## 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 집행 | +| | 무엇 | +| ---------------------- | -------------------------------------------- | +| Traefik | 인그레스 컨트롤러.`:80` 을 듣는다 | +| servicelb (klipper-lb) | LoadBalancer 타입을 호스트 포트로 매핑 | +| local-path | 기본 StorageClass.**노드 로컬 디스크** | +| flannel | 파드 네트워크 (VXLAN) | +| kube-router | NetworkPolicy 집행 | + +**확인** — `[lab host]`. 무엇이 이미 돌고 있고, 기본 저장소가 무엇인가 -**확인** — 무엇이 이미 돌고 있고, 기본 저장소가 무엇인가 ```bash -sudo kubectl get pods -A -sudo kubectl get storageclass +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)` 표시**가 어디 붙어 있는가. **이 결과가 의미하는 것** — 여기 뜬 것들은 우리가 안 깔았는데 있는 것이고, @@ -174,43 +339,69 @@ sudo kubectl get storageclass > `local-path` 가 기본이라는 것이 A-4 에서 비용을 청구한다. PVC 가 **만들어진 > 노드에 묶여** 다른 노드로 재배치되지 않는다. -## 6. 워크스테이션에서 쓰려면 +## 8. 워크스테이션에서 쓰려면 + +**2번의 kubeconfig 를 그대로 가져와도 안 된다.** 게스트는 libvirt NAT 안에 +있어서 워크스테이션에서 `192.168.122.11` 로 가는 경로가 없다. -**하기** ```bash -ssh kc-lab-1 'sudo cat /etc/rancher/k3s/k3s.yaml' > ~/.kube/kc-lab.yaml -sed -i 's|127.0.0.1|192.168.122.11|' ~/.kube/kc-lab.yaml +[워크스테이션] $ 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 ``` -kubeconfig 안의 서버 주소가 `127.0.0.1` 이다. **게스트 안에서만 맞는 주소**라 -밖에서 쓰려면 바꿔야 한다. +**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://192.168.122.11:6443` -인가(`127.0.0.1` 이 남아 있으면 `sed` 가 안 먹은 것이다). 그다음 `get nodes` -가 3번과 **같은 두 줄**을 내놓는가. 이제부터는 `sudo` 도 `ssh` 도 붙이지 -않는다. +**어디를 봐야 하는가** — `server:` 가 `https://127.0.0.1:6443` 인가. 그다음 +`get nodes` 가 4번과 **같은 두 줄**을 내놓는가. -**이 결과가 의미하는 것** — 통과하면 워크스테이션에서 곧장 클러스터를 볼 수 -있고, 05 이후의 `kubectl` 은 전부 여기서 친다. `connection refused` 면 주소는 -맞는데 API 서버가 안 뜬 것이고, 타임아웃이면 워크스테이션에서 -`192.168.122.0/24` 로 가는 경로가 없는 것이다(호스트를 거치는 SSH 터널이나 -ProxyJump 가 필요하다). `x509` 오류면 파일 안의 CA 와 서버가 지금 쓰는 CA 가 -어긋난 것이다 — k3s 를 다시 깔았다면 kubeconfig 도 다시 복사해야 한다. +**이 결과가 의미하는 것** — 터널 덕에 워크스테이션의 6443 이 `kc-lab-1` 의 +6443 이다. API 서버 인증서 SAN 에 `127.0.0.1` 이 들어 있어서(2번의 각주) +인증서 검증도 통과한다. 타임아웃이면 1)의 터널 창이 닫힌 것이고, +`connection refused` 면 터널은 살아 있는데 반대편 API 서버가 죽은 것이다. + +> **터널이 닫히면 `kubectl` 이 통째로 멎는다.** 이 실험대의 A층 실험은 노드를 +> 죽이고 살리는 것이 목적이라, 터널 상태와 클러스터 상태가 섞여 오독하기 +> 쉽다. **A층 실험은 lab host 에서 치는 것을 권한다.** --- ## 막히면 -| 증상 | 원인 | 확인 | -|---|---|---| -| agent 가 `NotReady` | 토큰·주소 오타 | `journalctl -u k3s-agent -n 30` | -| 노드 IP 가 예상과 다름 | `--node-ip` 없이 설치 | `kubectl get nodes -o wide` | -| 밖에서 kubectl 이 안 붙음 | kubeconfig 의 `127.0.0.1` | 위 6번 | -| 파드가 한 노드에만 몰림 | 스케줄러 판단 | `topologySpreadConstraints` 로 강제 | +| 증상 | 원인 | 확인 | +| -------------------------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------- | +| `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` 로 강제 | diff --git a/docs/guides/03-nginx/README.md b/docs/guides/03-nginx/README.md index 2bb3c17..78847b5 100644 --- a/docs/guides/03-nginx/README.md +++ b/docs/guides/03-nginx/README.md @@ -1,12 +1,14 @@ -# 03 — 호스트 nginx 라우팅 +# 03 — 엣지 nginx 라우팅 (kc-lab-edge) ## 이 단계가 끝나면 -밖에서 보낸 요청이 **nginx → Traefik → 파드**로 닿는다. 아직 TLS 는 없다. +밖에서 보낸 요청이 **호스트 DNAT → 엣지 nginx → Traefik → 파드**로 닿는다. +아직 TLS 는 없다. ## 전제 -[02](../02-k3s/) 가 끝나 두 노드가 `Ready`. +[02](../02-k3s/) 가 끝나 두 노드가 `Ready` 이고, [01](../01-vms/) 에서 +`kc-lab-edge`(192.168.122.10) 까지 세 대가 떠 있다. ## 왜 프록시가 두 겹인가 @@ -14,20 +16,36 @@ nginx 와 Traefik 이 하는 일이 다르다. | | 맡는 것 | |---|---| -| 호스트 nginx | 바깥세상과의 접점 — TLS 종단 · 인증서 · `X-Forwarded-*` | +| 엣지 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` 로 뽑는다. + --- ## 1. 설정을 쓴다 -원본은 [`deploy/lab/host/nginx-keycloak-lab.conf`](../../../deploy/lab/host/nginx-keycloak-lab.conf). +원본은 [`deploy/lab/edge/nginx-keycloak-lab.conf`](../../../deploy/lab/edge/nginx-keycloak-lab.conf). -**하기** +**하기** — `[kc-lab-edge]`. lab host 에서 원격 실행해도 된다. ```bash sudo tee /etc/nginx/sites-available/keycloak-lab > /dev/null <<'EOF' upstream k3s_traefik { @@ -69,22 +87,53 @@ EOF > **인증서 경로는 아직 없다.** [04](../04-tls/) 에서 만든다. 그전까지는 443 > 블록을 주석 처리하고 80 만 `proxy_pass` 로 두면 이 단계를 먼저 확인할 수 있다. -Arch 는 `sites-available` 관례가 없다. 직접 만들고 `nginx.conf` 의 `http` 블록 -안에서 include 한다. +**엣지가 Debian 이라 `sites-available` 관례가 기본으로 있다.** 물리 호스트 +(Arch) 였을 때는 디렉터리를 직접 만들고 `nginx.conf` 에 include 를 넣어야 +했는데, 그 손질이 없어졌다. 운영도 Debian 계열이라 관례가 맞아떨어진다. ```bash -sudo mkdir -p /etc/nginx/sites-{available,enabled} -sudo ln -s /etc/nginx/sites-available/keycloak-lab /etc/nginx/sites-enabled/ -# nginx.conf 의 http { } 안에: include /etc/nginx/sites-enabled/*; +ssh kc-lab-edge ' + sudo ln -sf /etc/nginx/sites-available/keycloak-lab /etc/nginx/sites-enabled/ + sudo rm -f /etc/nginx/sites-enabled/default' ``` +`default` 를 지우는 것을 빠뜨리지 않는다. Debian 기본 사이트가 `:80` 에 +`default_server` 로 붙어 있어서, 우리 설정의 `listen 80 default_server` 와 +**충돌해 `nginx -t` 가 실패한다.** + +> **★ `http2 on;` 을 쓰지 않는다.** 그 지시어는 nginx 1.25.1 이상이다. +> +> ``` +> 엣지 (Debian 12): nginx version: nginx/1.22.1 +> 물리 호스트 (Arch): nginx version: nginx/1.30.4 +> ``` +> +> 물리 호스트에서 쓰던 설정을 그대로 옮기면 **실측으로 이렇게 막힌다.** +> +> ``` +> [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 양쪽에서 다 돈다. + ## 2. 문법을 보고 적용한다 -**하기** +**하기** — `[kc-lab-edge]` ```bash sudo nginx -t && sudo systemctl reload nginx ``` +**실측** — 04 를 아직 안 했다면 여기서 이렇게 막히는 것이 **정상**이다. +설정은 맞는데 참조하는 파일이 아직 없는 것뿐이다. + +``` +[emerg] 1431#1431: cannot load certificate +"/etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem": BIO_new_file() failed +(SSL: ... No such file or directory ...) +nginx: configuration file /etc/nginx/nginx.conf test failed +``` + **어디를 봐야 하는가** — `nginx -t` 가 내놓는 **마지막 줄**이다. `syntax is ok` 와 `test is successful` 두 마디가 다 나와야 통과다. 앞에 나오는 `[warn]` 줄(예: `types_hash_max_size`)은 통과를 막지 않는다 — 04 에서 이 @@ -113,7 +162,58 @@ systemctl status nginx --no-pager | head -20 것이고, 안 바뀌었으면 `-t` 는 통과했는데 reload 가 안 간 것이다. 04 에서 인증서 갱신이 서빙까지 닿았는지를 정확히 이 방법으로 판정한다. -## 3. 층별로 확인한다 — 아래에서 위로 +## 3. 호스트에서 엣지로 넘긴다 (DNAT) + +여기까지 하면 엣지 nginx 는 살아 있는데 **아무도 거기로 안 보낸다.** tailnet +주소 `100.83.212.4:443` 을 받는 것은 여전히 물리 호스트다. 그 트래픽을 +엣지로 넘기는 것이 이 단계고, **물리 호스트가 실험대를 위해 하는 일의 +전부**다. + +**하기** — `[lab host]`. 원본은 +[`deploy/lab/edge/lab-edge-dnat.nft`](../../../deploy/lab/edge/lab-edge-dnat.nft) + +```bash +sudo mkdir -p /etc/nftables.d +sudo cp deploy/lab/edge/lab-edge-dnat.nft /etc/nftables.d/ +sudo cp deploy/lab/edge/lab-edge-dnat.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now lab-edge-dnat.service +``` + +규칙의 알맹이는 두 줄이다. + +``` +iifname "tailscale0" tcp dport { 80, 443 } dnat to 192.168.122.10 +ip daddr 192.168.122.10 tcp dport { 80, 443 } ct state new accept +``` + +**★ SNAT 을 걸지 않는다.** 게스트의 기본 게이트웨이가 호스트라 응답은 +어차피 여기로 돌아오고, conntrack 이 알아서 되돌린다. masquerade 를 붙이면 +출발지가 덮여서 **엣지가 모든 클라이언트를 `192.168.122.1` 로 본다** — 이 +실험대가 재고 있는 `X-Forwarded-For` 계약이 통째로 무의미해진다. + +**두 번째 줄이 왜 필요한가** — libvirt 의 기본 네트워크 규칙은 게스트 대역으로 +들어가는 `RELATED,ESTABLISHED` 만 허용한다. **밖에서 새로 들어오는 연결은 +막는다.** 그래서 명시적으로 열어 준다. + +**확인** +```bash +sudo nft list table ip lab_edge +``` + +**어디를 봐야 하는가** — `dnat to 192.168.122.10` 한 줄과 `accept` 한 줄이 +다 있는가. 그리고 **`masquerade` 나 `snat` 이 없는가.** + +**이 결과가 의미하는 것** — PREROUTING nat 은 라우팅 결정보다 먼저 돌기 +때문에 이 규칙이 **호스트 자신의 `:443` 소켓보다 우선한다.** 그래서 물리 +호스트에 nginx 가 아직 떠 있어도 트래픽은 엣지로 간다 — 전환이 원자적이고, +되돌리기도 한 줄이다. + +```bash +sudo nft delete table ip lab_edge # rollback +``` + +## 4. 층별로 확인한다 — 아래에서 위로 한 번에 밖에서 치지 말고, **가까운 층부터** 본다. 어디서 끊겼는지가 바로 나온다. @@ -163,7 +263,16 @@ curl -s -o /dev/null -w '%{http_code}\n' http://192.168.122.12 결과가 나온다. 한쪽만 다르면 upstream 두 개 중 하나가 죽은 것이고, 그 상태에서는 **요청의 절반만 실패**해서 「가끔 안 된다」로 보인다. -**확인 ②** nginx 가 80 에서 리다이렉트하나 +**확인 ②** 엣지 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 ``` @@ -179,9 +288,9 @@ Location: https://auth.hyeonworks.com/ `https://` 로 시작하고 원래 호스트명을 그대로 들고 있는가. `$host` 대신 설정에 이름을 박아 두면 여기서 엉뚱한 호스트가 나온다. -**이 결과가 의미하는 것** — 301 이 나왔다는 것은 **바깥 요청이 호스트 nginx -까지 닿았다**는 뜻이다(①은 게스트에 직접 친 것이므로 nginx 를 안 거쳤다). -DNS·방화벽·80 리스너가 전부 살아 있다. 응답이 아예 없으면 nginx 가 안 떴거나 +**이 결과가 의미하는 것** — 301 이 나왔다는 것은 **바깥 요청이 DNAT 을 거쳐 +엣지 nginx 까지 닿았다**는 뜻이다(①은 Traefik 에 직접, ②는 엣지에 직접 친 +것이라 DNAT 을 안 거쳤다). DNS·DNAT·80 리스너가 전부 살아 있다. 응답이 아예 없으면 nginx 가 안 떴거나 80 이 막힌 것이다. 리다이렉트를 따라가 끝까지 보려면 `-L` 을 붙인다. 값을 반복해서 잴 때의 형태는 이것이고, 아래가 이 실험대의 실측이다. @@ -193,7 +302,7 @@ curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' http://auth.hyeonworks. 301 https://auth.hyeonworks.com/ ``` -**확인 ③** 끝까지 닿나 (TLS 이후) +**확인 ④** 끝까지 닿나 (TLS 이후) ```bash curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master ``` @@ -211,7 +320,7 @@ curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/mast 본다), `curl: (60)` 같은 인증서 오류는 아직 04 를 안 한 것이다. TLS 단계에서 막히면 코드만 보지 말고 `curl -v` 로 협상 과정을 읽는다 — 04 에서 그렇게 한다. -## 4. upstream 이 둘인 이유 +## 5. upstream 이 둘인 이유 ``` upstream k3s_traefik { diff --git a/docs/guides/04-tls/README.md b/docs/guides/04-tls/README.md index 479b80d..8c7fa4d 100644 --- a/docs/guides/04-tls/README.md +++ b/docs/guides/04-tls/README.md @@ -6,31 +6,104 @@ ## 전제 -[03](../03-nginx/) 이 끝나 nginx 가 Traefik 으로 프록시한다. 그리고 **공개 -DNS 에 이름 세 개가 이 호스트를 가리키고 있어야 한다** — Let's Encrypt 가 -HTTP-01 로 검증하러 오기 때문이다. +[03](../03-nginx/) 이 끝나 엣지 nginx 가 Traefik 으로 프록시한다. + +## 어디서 치는가 + +**이 단계는 전부 `[kc-lab-edge]` 에서 친다.** 인증서·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 pacman -S certbot certbot-nginx # Arch -sudo apt install certbot python3-certbot-nginx # Debian/Ubuntu +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. 인증서를 받는다 -이름 세 개를 **한 인증서**에 넣는다. +DNS-01 이면 **와일드카드를 받을 수 있다.** 이 실험대는 처음에 이름 셋을 +따로 받았고, 그 비용이 B-7 에서 청구됐다 — oauth2-proxy 를 올릴 네 번째 +이름이 없어 Grafana 의 `app2` 를 빌려야 했다. + +**하기** — `[kc-lab-edge]`. 토큰은 **존 하나 + DNS:Edit** 으로 좁힌다. +계정 전역 API Key 를 쓰지 않는다. -**하기** ```bash -sudo certbot certonly --webroot -w /var/www/html \ - -d auth.hyeonworks.com -d app1.hyeonworks.com -d app2.hyeonworks.com +sudo install -m 600 /dev/null /etc/letsencrypt/cloudflare.ini +sudo tee /etc/letsencrypt/cloudflare.ini >/dev/null <<'EOF' +dns_cloudflare_api_token = +EOF + +sudo certbot certonly --dns-cloudflare \ + --dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \ + -d hyeonworks.com -d '*.hyeonworks.com' --dry-run ``` -**확인** — 인증서가 실제로 생겼고 이름 셋이 다 들어갔는가 +**`--dry-run` 을 먼저 붙인다.** Let's Encrypt 는 같은 이름 조합에 대해 +**주당 중복 인증서 5장** 제한이 있고, `--dry-run` 은 그 한도를 쓰지 않는다. +통과하면 `--dry-run` 만 떼고 다시 친다. + +> **DNS-01 은 느리다.** TXT 레코드가 퍼질 때까지 기다려야 해서 발급이 수십 +> 초 걸린다. certbot 이 기본 대기 시간을 두고 있으니 중간에 끊지 않는다. + +**확인** — 인증서가 실제로 생겼고 이름이 다 들어갔는가 ```bash sudo certbot certificates ``` @@ -218,6 +291,20 @@ EOF sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh ``` +**저장소에 같은 파일이 있다** — +[`deploy/lab/edge/reload-nginx.sh`](../../../deploy/lab/edge/reload-nginx.sh). +여기 손으로 치지 말고 그걸 밀어 넣는 편이 낫다. + +```bash +cat deploy/lab/edge/reload-nginx.sh \ + | ssh kc-lab-edge 'sudo tee /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh >/dev/null' +ssh kc-lab-edge 'sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh' +``` + +> **이 훅은 한동안 저장소에 없었다.** 호스트에만 있어서, 호스트를 초기화하면 +> **아무 오류 없이 사라지고** D-4 가 측정한 상태(갱신 성공 · 서빙 38분 25초 +> 지연 · 타이머는 `SUCCESS`)로 되돌아갔다. 저장소에 두는 이유가 이것이다. + `deploy/` 에 넣는다. `post/` 는 갱신이 없어도 매번 돌아 하루 두 번 워커를 갈아치운다. `deploy/` 는 **실제로 갱신됐을 때만** 실행된다. @@ -301,6 +388,9 @@ nginx 의 `types_hash` 경고가 stderr 로 나갔을 뿐이고 내용은 | 갱신은 됐는데 옛 인증서가 나감 | **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 전파 대기 | **정상이다.** 끊지 않는다 | +| 재구축 뒤 인증서가 없음 | 발급하지 말고 **백업을 되돌린다** | 한도를 아끼는 길이다 | --- diff --git a/docs/guides/README.md b/docs/guides/README.md index 8b9be8e..923b636 100644 --- a/docs/guides/README.md +++ b/docs/guides/README.md @@ -41,6 +41,46 @@ 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 '...'` 형태로 원격 실행한다. 셸이 하나뿐이면 「지금 어디 +있더라」가 생기지 않는다. + ## 순서 앞 단계가 끝나야 다음이 된다. 각 단계 첫머리에 「이 단계가 끝나면」이 있고, @@ -49,9 +89,9 @@ echo "${#TOKEN} 자" # 값이 아니라 길이만 확인한다 | 단계 | 무엇을 세우나 | 끝나면 확인되는 것 | |---|---|---| | [00](00-lab-host/) | lab host 가상화 준비 | `virsh list` 가 돈다 | -| [01](01-vms/) | VM 두 대 | 두 게스트에 SSH 가 붙는다 | +| [01](01-vms/) | VM 세 대 (엣지 + k3s 2노드) | 세 게스트에 SSH 가 붙는다 | | [02](02-k3s/) | k3s server + agent | `kubectl get nodes` 에 둘 다 Ready | -| [03](03-nginx/) | 호스트 nginx 라우팅 | 밖에서 요청이 파드까지 닿는다 | +| [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 | diff --git a/docs/guides/experiments/a4-node-loss.md b/docs/guides/experiments/a4-node-loss.md index a7b727c..820ace3 100644 --- a/docs/guides/experiments/a4-node-loss.md +++ b/docs/guides/experiments/a4-node-loss.md @@ -354,7 +354,7 @@ done 503 nginx·Traefik 은 살아 있고 뒤로 보낼 파드가 없다 ``` -호스트 nginx 의 upstream 에는 **두 노드가 다 들어 있다** +엣지 nginx(`kc-lab-edge`) 의 upstream 에는 **두 노드가 다 들어 있다** ([`03-nginx`](../03-nginx/) 1절). ``` diff --git a/docs/lab-virtualization.md b/docs/lab-virtualization.md new file mode 100644 index 0000000..4df01e1 --- /dev/null +++ b/docs/lab-virtualization.md @@ -0,0 +1,482 @@ +# 실험대 가상화 계층 — 실측 기록 + +## 이 문서가 무엇인가 + +[`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 \ + "" \ + --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 \ + "" \ + --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 블록 + + + +``` + +동적 대역만 남는 것이 정상이다. + +### 철거 전후 비교 — 실측 + +| | 철거 전 | 철거 후 | +|---|---|---| +| `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 가 우리 서버로 들어오는 방식이므로, +이 주소로는 검증이 성립하지 않는다. **지우면 되살리기 전에 검증 방식 문제부터 +풀어야 한다.** 백업만 뜨고 파일은 남긴다. + +```bash +sudo tar czf ~/letsencrypt-backup-$(date +%Y%m%d-%H%M%S).tgz -C /etc letsencrypt +``` + +복원은 반대로 한 줄이다. + +```bash +sudo tar xzf ~/letsencrypt-backup-.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) | 호스트 계층 철거 | diff --git a/docs/session-lab-concepts.md b/docs/session-lab-concepts.md index f38b24c..f36f529 100644 --- a/docs/session-lab-concepts.md +++ b/docs/session-lab-concepts.md @@ -1661,6 +1661,75 @@ sudo certbot certificates # 발급된 인증서와 도메인 목록 sudo certbot renew --dry-run # 갱신이 실제로 되는지 예행연습 ``` +### DNS-01 은 언제 쓰는가 — 네 가지 경우 + +**DNS-01 이 HTTP-01 보다 좋은 방식이 아니다.** HTTP-01 이 못 쓰이는 자리에 +쓰는 것이다. 공개 웹서버 한 대라면 HTTP-01 이 옳다 — 토큰도 DNS 연동도 +필요 없고, DNS 공급자를 옮겨도 안 깨진다. + +**① 와일드카드가 필요할 때** — 선택이 아니라 규칙이다. + +`*.example.com` 은 **그 도메인 아래 모든 이름**에 대한 권한이다. 호스트 +한 대에 파일을 놓는 것은 **그 이름 하나를 통제한다**는 증명밖에 안 된다. +DNS 존의 TXT 레코드를 고칠 수 있다는 것은 **도메인 전체를 통제한다**는 +증명이다. 증명의 급이 다르기 때문에 ACME 명세가 와일드카드를 DNS-01 로만 +허용한다. + +**② 서버가 공개 인터넷에서 안 보일 때** — 내부망, VPN 뒤, 사설 IP, +CGNAT 뒤. **이 실험대가 여기다.** tailnet 주소 `100.83.212.4` 는 +`100.64.0.0/10`(CGNAT 예약 대역)이라 공개 인터넷에서 라우팅 자체가 안 된다. +방화벽을 여는 문제가 아니다 — **그 주소는 인터넷에 존재하지 않는다.** +Let's Encrypt 를 tailnet 에 초대할 방법도 없다. + +**③ 80 포트를 못 쓸 때** — 가정용 회선은 ISP 가 80 을 막는 경우가 흔하다. +이미 다른 서비스가 점유한 경우도 같다. 443 이 살아 있으면 TLS-ALPN-01 도 +대안이 된다. + +**④ 인증서를 쓸 기계와 발급받는 기계가 다를 때** — 실무에서 이 이유가 가장 +크다. + +| 상황 | HTTP-01 이 곤란한 이유 | +|---|---| +| 로드밸런서 뒤 N 대 | 검증 요청이 **어느 대로 갈지 모른다.** 전부가 같은 토큰에 답해야 하니 공유 스토리지나 challenge 전용 라우팅이 필요하다 | +| CDN 뒤 | 오리진이 직접 응답할 수 없다 | +| CI 에서 발급해 배포 | **서버가 아직 없어도** 발급해야 한다 | +| 쿠버네티스 cert-manager | Ingress 가 여럿이거나 클러스터가 내부망이면 solver 를 DNS-01 로 둔다 | + +DNS-01 은 **어디서 돌리든 상관없다.** 인증서를 쓸 기계와 무관하게 발급된다. + +**값으로 치르는 것** + +| | 내용 | +|---|---| +| **API 토큰이 서버에 있어야 한다** | 유출되면 **도메인 전체의 DNS 를 조작**당한다. 인증서 한 장보다 피해가 크다 — MX 를 바꿔 메일을 가로챌 수도 있다. 그래서 토큰은 **존 하나 + DNS:Edit** 으로 좁힌다. 계정 전역 API Key 를 쓰지 않는다 | +| 공급자에 묶인다 | 플러그인이 공급자마다 다르다. DNS 를 옮기면 재설정 | +| 느리다 | TXT 레코드가 퍼질 때까지 기다려야 한다. certbot 기본 대기 10초, 느린 공급자는 더 준다 | +| 공급자가 API 를 안 주면 못 쓴다 | | + +**한 줄 판단** + +``` +와일드카드가 필요한가? → 예: DNS-01 (다른 선택지 없음) +Let's Encrypt 가 내 서버에 HTTP 로 닿는가? → 예: HTTP-01 아니오: DNS-01 +``` + +> **이 실험대의 문서 두 개가 어긋나 있다.** 위 「HTTP-01 vs DNS-01」이 +> 「둘 다 DNS-01 을 가리킨다」로 결론냈는데, +> [`docs/guides/04-tls/README.md`](guides/04-tls/README.md) 는 +> `certbot certonly --webroot`(HTTP-01)로 적혀 있고 전제도 「공개 DNS 에 +> 이름 셋이 이 호스트를 가리켜야 한다」이다. 지금 DNS 는 tailnet 주소를 +> 가리키므로 **그 전제는 성립하지 않는다.** 어느 쪽이 실제인지는 아래로 +> 확인한다. + +**확인** + +```bash +sudo grep -H authenticator /etc/letsencrypt/renewal/*.conf # webroot/standalone = HTTP-01 +certbot plugins | grep -E '^\*' # 쓸 수 있는 검증 방식 +sudo certbot renew --dry-run # 갱신이 실제로 되는가 +dig +short auth.hyeonworks.com # LE 가 올 수 있는 주소인가 +``` + ### `fullchain.pem` / `privkey.pem` / `cert.pem` / `chain.pem` **무엇인가** — certbot이 만드는 네 파일. @@ -1739,7 +1808,9 @@ chmod 600 ~/.kube/config **이 명령은 호스트(test-server)에서 실행한다.** 게스트 안에서 실행하면 `ssh kc-lab-1` 부분이 자기 자신에게 다시 접속하는 꼴이 되고, 애초에 -게스트에서는 `sudo k3s kubectl`을 쓰면 되므로 kubeconfig가 필요 없다. +**server 게스트**에서는 `sudo kubectl`이 `/etc/rancher/k3s/k3s.yaml`을 +자동으로 집으므로 kubeconfig가 따로 필요 없다. **agent 게스트는 다르다** — +아래 「agent 노드에는 kubeconfig가 없다」를 본다. **리다이렉션은 셸이 명령보다 먼저 처리한다** — 자주 걸리는 함정이다. @@ -1761,6 +1832,94 @@ sudo cat 원본 > ~/.kube/config echo 내용 | sudo tee /root/전용경로 > /dev/null ``` +### agent 노드에는 kubeconfig가 없다 — `localhost:8080` 오류 + +**무엇인가** — kubeconfig는 kubectl에게 **① 어느 API 서버로 ② 어떤 CA로 +검증하고 ③ 누구 자격으로** 붙을지 알려주는 파일이다. kubectl은 이 셋을 +스스로 알지 못한다. + +**왜 여기 나오나** — agent 노드(`kc-lab-2`)에도 `kubectl` **명령은 있다.** +설치 스크립트가 심볼릭 링크를 만들기 때문이다. + +``` +/usr/local/bin/kubectl -> k3s +``` + +k3s는 단일 바이너리라 자기가 어떤 이름으로 불렸는지(`argv[0]`)를 보고 +동작을 바꾼다. **명령이 있다는 것과 붙을 곳이 있다는 것은 다르다.** + +**없으면 무슨 일이 생기나** — agent에서 `sudo kubectl get pods -A`를 치면 +이렇게 끝난다. + +``` +Get "http://localhost:8080/api?timeout=32s": dial tcp [::1]:8080: connect: connection refused +``` + +kubectl이 kubeconfig를 찾는 순서는 `--kubeconfig` 플래그 → `$KUBECONFIG` +→ `~/.kube/config`이고, k3s 내장 kubectl은 여기에 +`/etc/rancher/k3s/k3s.yaml`을 하나 더 본다. **넷 다 없으면 오류를 내지 않고** +client-go에 하드코딩된 기본값 `http://localhost:8080`으로 넘어간다. +쿠버네티스 1.20 이전 API 서버가 평문으로 열던 레거시 포트인데 지금은 +아무도 열지 않는다. + +> **`localhost:8080`이 보이면 네트워크 문제가 아니라 설정이 없다는 뜻이다.** +> 이 주소는 어디에도 적혀 있지 않다 — kubectl이 아무것도 못 찾았을 때만 +> 나온다. 방화벽이나 k3s를 의심하기 전에 kubeconfig부터 본다. + +> **오해 주의 — 「워커라서 파드가 안 보인다」가 아니다.** kubectl은 그냥 HTTP +> 클라이언트라 어디서 실행하든 상관없다(노트북에서도 된다). 필요한 것은 +> 주소와 자격증명뿐이고, kc-lab-1의 `k3s.yaml`을 kc-lab-2로 복사해 넣으면 +> `get pods -A`는 워커에서도 전부 나온다. 노드 역할이 시야를 가리는 것이 +> 아니라 **자격증명 파일이 없을 뿐이다.** 그래서 실패가 「권한 없음(403)」이 +> 아니라 「설정 없음(localhost:8080)」으로 나타난다. agent가 가진 +> `system:node:kc-lab-2` 자격증명은 kubelet 전용이라 kubectl이 읽는 경로에 +> 애초에 없다. 그렇다고 복사해 넣으면 안 되는 이유는 바로 아래에 있다. + +**왜 agent에는 주지 않나** — 역할이 다르다. + +| | server (`kc-lab-1`) | agent (`kc-lab-2`) | +|---|---|---| +| 도는 것 | API 서버 · 스케줄러 · controller-manager · SQLite | kubelet · kube-proxy · containerd · flannel | +| 6443 LISTEN | O | **X** | +| `/etc/rancher/k3s/k3s.yaml` | 있음 (admin 자격증명, `0600 root`) | **없음** | +| 결정하는 것 | 클러스터 상태 | 없음. 시킨 것을 실행할 뿐 | + +agent도 API 서버와 통신은 한다. `127.0.0.1:6444`에 k3s-agent가 자체 +로드밸런서를 열고 그걸 통해 server의 6443으로 넘긴다. 하지만 **자격증명의 +급이 다르다.** + +``` +subject=O = system:nodes, CN = system:node:kc-lab-2 +``` + +이 신원은 Node authorizer와 NodeRestriction admission 플러그인이 **자기 +노드에 배정된 객체만** 다루도록 제한한다. `get pods -A`(전체 네임스페이스)는 +처음부터 권한 밖이다. 워커 한 대가 털려도 클러스터 전체가 털리지 않게 하려는 +설계이므로, **편하다고 `k3s.yaml`을 agent로 복사해 넣지 않는다.** + +**어디서 치나** — 셋 중 하나다. + +```bash +# ① server 게스트에서 +ssh kc-lab-1 'sudo kubectl get pods -A' + +# ② 호스트(test-server)에서 — 위 「kubeconfig의 127.0.0.1 문제」의 복사를 먼저 한다 +export KUBECONFIG=~/.kube/config +kubectl get pods -A + +# ③ agent가 클러스터에 붙었는지만 보고 싶을 때 (kubectl 필요 없다) +ssh kc-lab-2 'systemctl is-active k3s-agent' +``` + +**확인** + +```bash +ssh kc-lab-2 'ls -l /usr/local/bin/kubectl' # -> k3s 심볼릭 링크. 명령은 있다 +ssh kc-lab-2 'sudo ls /etc/rancher/k3s/ 2>&1' # No such file — 이게 정상이다 +ssh kc-lab-1 'sudo ls -l /etc/rancher/k3s/k3s.yaml' # 여기에만 있다 +ssh kc-lab-2 'sudo openssl x509 -in /var/lib/rancher/k3s/agent/client-kubelet.crt -noout -subject' +``` + ### Traefik (k3s 기본 ingress) **무엇인가** — k3s가 기본으로 배포하는 ingress 컨트롤러. diff --git a/docs/session-lab-operations.md b/docs/session-lab-operations.md index 6c976ae..a1dd151 100644 --- a/docs/session-lab-operations.md +++ b/docs/session-lab-operations.md @@ -301,12 +301,17 @@ git checkout feature/keycloak-multinode-cluster-jdbc-ping 생긴다 — nginx 설정에서 실제로 겪었다 ([`two-hop-proxy-header-contract.md`](two-hop-proxy-header-contract.md) 9절). -### 호스트 nginx +### 엣지 nginx (`kc-lab-edge`) + +**lab host 가 아니라 엣지 게스트에서 돈다.** 2026-09-10 에 엣지 계층을 +물리 호스트에서 `kc-lab-edge`(192.168.122.10) 로 옮겼다 — +[03](guides/03-nginx/) 의 「왜 엣지가 물리 호스트가 아니라 VM 인가」. ```bash -sudo cp deploy/lab/host/nginx-keycloak-lab.conf /etc/nginx/sites-available/keycloak-lab -sudo nginx -t && sudo systemctl reload nginx -sudo nginx -T | grep -n 'upstream\|server_name' # 최종 병합 설정 +cat deploy/lab/edge/nginx-keycloak-lab.conf \ + | ssh kc-lab-edge 'sudo tee /etc/nginx/sites-available/keycloak-lab >/dev/null' +ssh kc-lab-edge "sudo nginx -t && sudo systemctl reload nginx" +ssh kc-lab-edge "sudo nginx -T | grep -n 'upstream\|server_name'" # 최종 병합 설정 ``` **`nginx -t`를 통과한 뒤에만 reload한다.** 깨진 설정으로 reload하면 서비스가 diff --git a/docs/two-hop-proxy-header-contract.md b/docs/two-hop-proxy-header-contract.md index 4d3292f..7e3624f 100644 --- a/docs/two-hop-proxy-header-contract.md +++ b/docs/two-hop-proxy-header-contract.md @@ -37,7 +37,7 @@ Keycloak을 올린 뒤에 로그인이 깨지면 **세션 문제인지 프록시 │ │ │ [스위치 1] nginx ← 호스트 OS 의 프로세스 │ │ /etc/nginx/sites-available/keycloak-lab │ -│ = deploy/lab/host/nginx-keycloak-lab.conf │ +│ = deploy/lab/edge/nginx-keycloak-lab.conf │ │ │ │ ┌─ kc-lab-1 (VM) ─────────────┐ ┌─ kc-lab-2 (VM) ────────┐ │ │ │ svclb 파드 :80 │ │ svclb 파드 :80 │ │ @@ -50,9 +50,15 @@ Keycloak을 올린 뒤에 로그인이 깨지면 **세션 문제인지 프록시 └──────────────────────────────────────────────────────────────┘ ``` +> **이 측정 당시 nginx 는 물리 호스트에서 돌았다.** 2026-09-10 에 엣지 계층이 +> `kc-lab-edge`(192.168.122.10) 게스트로 옮겨졌고, 설정 파일도 +> `deploy/lab/host/` 에서 `deploy/lab/edge/` 로 옮겼다. **홉 수는 그대로 2홉** +> 이라 아래 계약은 유효하지만, 앞단에 커널 DNAT 한 겹이 생겼으므로 +> `X-Forwarded-For` 는 이동 후 다시 재야 한다. + | # | 무엇 | 사는 곳 | 설정 파일 | |---|---|---|---| -| 1 | nginx | **호스트 OS의 프로세스** | `deploy/lab/host/nginx-keycloak-lab.conf` | +| 1 | nginx | **호스트 OS의 프로세스** (이동 후: `kc-lab-edge` 게스트) | `deploy/lab/edge/nginx-keycloak-lab.conf` | | 2 | Traefik | **클러스터 안 파드 1개** | `HelmChartConfig` (kube-system) | | 3 | 앱 | **클러스터 안 파드 N개** | 각 앱의 매니페스트 `env` | @@ -507,7 +513,7 @@ downstream 앱은 **이 헤더를 믿고 "누가 로그인했는지"를 판단** | | | |---|---| -| 저장소 파일 | `deploy/lab/host/nginx-keycloak-lab.conf` | +| 저장소 파일 | `deploy/lab/edge/nginx-keycloak-lab.conf` | | 서버 배포 위치 | `/etc/nginx/sites-available/keycloak-lab` | | 활성화 | `/etc/nginx/sites-enabled/keycloak-lab` 심볼릭 링크 | @@ -532,7 +538,7 @@ downstream 앱은 **이 헤더를 믿고 "누가 로그인했는지"를 판단** ```bash cd ~/workspace/keycloak-pattern && git pull -sudo cp deploy/lab/host/nginx-keycloak-lab.conf /etc/nginx/sites-available/keycloak-lab +sudo cp deploy/lab/edge/nginx-keycloak-lab.conf /etc/nginx/sites-available/keycloak-lab sudo nginx -t && sudo systemctl reload nginx grep -n 'X-Forwarded-Proto\|X-Forwarded-Port' /etc/nginx/sites-available/keycloak-lab ```