chore: 실행 환경 구성 문서 추가 및 수정

This commit is contained in:
DongHyeonka
2026-09-10 15:55:36 +09:00
parent 6f6ab86345
commit 9465582b5d
17 changed files with 1489 additions and 142 deletions
+18 -6
View File
@@ -18,15 +18,23 @@
브라우저 / SSH (tailnet) 브라우저 / SSH (tailnet)
│ https://{auth,app1,app2}.hyeonworks.com → 100.83.212.4 │ https://{auth,app1,app2}.hyeonworks.com → 100.83.212.4
lab host ── nginx :443 TLS 종료 · X-Forwarded-* 주입 lab host ── nftables DNAT :80,:443 → 192.168.122.10
nginx :80 301 → https (물리 호스트가 실험대를 위해 하는 일의 전부)
│ virbr0 192.168.122.0/24 (libvirt NAT) │ virbr0 192.168.122.0/24 (libvirt NAT)
├──▶ kc-lab-1 .11 k3s server Traefik :80 ├──▶ kc-lab-edge .10 nginx :443 TLS 종료 · X-Forwarded-* 주입
└──▶ kc-lab-2 .12 k3s agent Traefik :80 │ │ nginx :80 301 → https
└──▶ Pod │ │ 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홉**이 운영 구조와 같다는 점이 이 배치의 핵심이다. `nginx → Traefik` **2홉**이 운영 구조와 같다는 점이 이 배치의 핵심이다.
L7 프록시가 두 겹인 이유는 역할이 다르기 때문이다 — nginx는 바깥세상과의 L7 프록시가 두 겹인 이유는 역할이 다르기 때문이다 — nginx는 바깥세상과의
접점(TLS·인증서·헤더)을, Traefik은 클러스터 내부의 동적 라우팅을 맡는다. 접점(TLS·인증서·헤더)을, Traefik은 클러스터 내부의 동적 라우팅을 맡는다.
@@ -36,7 +44,11 @@ L7 프록시가 두 겹인 이유는 역할이 다르기 때문이다 — nginx
| 경로 | 역할 | | 경로 | 역할 |
|---|---| |---|---|
| `cloud-init/kc-lab.yaml.example` | 게스트 부트스트랩 템플릿 | | `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홉 헤더 계약 측정용 워크로드 | | `k8s/echo.yaml` | 2홉 헤더 계약 측정용 워크로드 |
| `scripts/rebuild-seed.sh` | cloud-init 시드 ISO 재생성 + 풀 업로드 | | `scripts/rebuild-seed.sh` | cloud-init 시드 ISO 재생성 + 풀 업로드 |
| `scripts/build-and-import.sh` | 이미지 빌드 → 각 노드 containerd 반입 | | `scripts/build-and-import.sh` | 이미지 빌드 → 각 노드 containerd 반입 |
+12 -1
View File
@@ -17,7 +17,12 @@ users:
shell: /bin/bash shell: /bin/bash
# NOPASSWD is required: the k3s installer and the fault-injection scripts # NOPASSWD is required: the k3s installer and the fault-injection scripts
# run non-interactively and would block on a password prompt. # 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 # 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. # that cannot be logged into at all, so its own failure log is unreadable.
# ssh_pwauth stays false, so this never widens SSH exposure. # ssh_pwauth stays false, so this never widens SSH exposure.
@@ -35,3 +40,9 @@ package_update: true
packages: packages:
- curl - curl
- nftables - 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
+33
View File
@@ -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
}
}
+13
View File
@@ -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
@@ -25,8 +25,10 @@ server {
} }
server { server {
listen 443 ssl default_server; # The http2 parameter of listen, not the separate `http2 on;` directive:
http2 on; # 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 _; server_name _;
# fullchain.pem, never cert.pem: omitting the intermediates passes on # fullchain.pem, never cert.pem: omitting the intermediates passes on
+12
View File
@@ -0,0 +1,12 @@
#!/bin/sh
# certbot deploy hook. Install as
# /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh (chmod +x)
#
# deploy/ runs only when a certificate was actually renewed (RENEWED_LINEAGE is
# set). post/ would run twice a day whether or not anything changed, reloading
# nginx for nothing.
#
# Without this, D-4 measured the failure exactly: the renewal succeeds, the
# timer reports SUCCESS, and the old certificate keeps being served for 38m25s
# — with no error anywhere.
nginx -t && nginx -s reload
+68
View File
@@ -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-<stamp>.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 는 남아 있다."
+139 -25
View File
@@ -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 없이 돈다. [00](../00-lab-host/) 이 끝나 `virsh list` 가 sudo 없이 돈다.
## 왜 VM 대인가 ## 왜 VM 대인가
이 실험대의 질문 전부가 **「인스턴스가 둘 이상이고 요청이 어느 쪽으로 갈지 **k3s 노드 두 대**이 실험대의 질문 전부가 **「인스턴스가 둘 이상이고
모른다」** 를 전제한다. 한 대면 세션 공유도 분단도 노드 상실도 실험이 되지 요청이 어느 쪽으로 갈지 모른다」** 를 전제한다. 한 대면 세션 공유도 분단도
않는다. 그리고 호스트에 직접 깔면 **되돌릴 수가 없다** — 이 실험대는 노드를 노드 상실도 실험이 되지 않는다.
**엣지 한 대** — nginx·인증서·certbot 이 사는 곳이다. 이것을 물리 호스트에
두면 **되돌릴 수가 없다.** 자주 고치고 자주 갈아엎는 층인데 물리 기계에
쌓이기 때문이다. VM 이면 초기화가 `virsh undefine` 한 줄이고, 물리 호스트에는
DNAT 규칙 하나와 DHCP 예약만 남는다. 자세한 이유는 [03](../03-nginx/) 의
「왜 엣지가 물리 호스트가 아니라 VM 인가」에 있다.
그리고 호스트에 직접 깔면 **되돌릴 수가 없다** — 이 실험대는 노드를
죽이고 DB 를 crash 시키는 것이 목적이라 망가뜨릴 수 있는 층이 필요하다. 죽이고 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 이미지를 받는다 ## 1. base 이미지를 받는다
@@ -23,13 +38,15 @@ OS 를 설치하지 않는다. 클라우드 이미지는 **이미 설치가 끝
첫 부팅에서 cloud-init 이 사용자·SSH 키·호스트명을 채운다. 첫 부팅에서 cloud-init 이 사용자·SSH 키·호스트명을 채운다.
**하기** **하기**
```bash ```bash
cd /var/lib/libvirt/images cd /var/lib/libvirt/images
sudo curl -LO https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2 sudo curl -fL --output /var/lib/libvirt/images/base.qcow2 \
sudo mv debian-12-genericcloud-amd64.qcow2 base.qcow2 https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2
``` ```
**확인** — 받은 파일이 온전한 qcow2 인가 **확인** — 받은 파일이 온전한 qcow2 인가
```bash ```bash
qemu-img info /var/lib/libvirt/images/base.qcow2 qemu-img info /var/lib/libvirt/images/base.qcow2
``` ```
@@ -87,6 +104,7 @@ openssl rand -base64 18
``` ```
셋을 넣는다. 셋을 넣는다.
```bash ```bash
sed -i \ sed -i \
-e "s|__LAB_HOST_KEY__|$(cat ~/.ssh/id_ed25519.pub)|" \ -e "s|__LAB_HOST_KEY__|$(cat ~/.ssh/id_ed25519.pub)|" \
@@ -96,6 +114,7 @@ sed -i \
``` ```
**확인** — 자리표시자가 남아 있으면 cloud-init 이 그대로 넣는다 **확인** — 자리표시자가 남아 있으면 cloud-init 이 그대로 넣는다
```bash ```bash
grep -c '__' kc-lab-1.yaml # 0 이어야 한다 grep -c '__' kc-lab-1.yaml # 0 이어야 한다
grep -c 'ssh-ed25519\|ssh-rsa' kc-lab-1.yaml # 2 여야 한다 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' 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` 아래에 **어느 키가 왜 틀렸는지**가 나열된다. 아니면 `Invalid cloud-config` 아래에 **어느 키가 왜 틀렸는지**가 나열된다.
경고(`deprecated`)와 오류(`error`)는 다르다 — 경고는 지금 동작한다. 경고(`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 가 안 `users``user` 로 잘못 쓰면 계정이 안 생기고, 증상은 역시 「SSH 가 안
붙는다」 하나뿐이다. 첫 게스트(`kc-lab-1`)를 만들 때는 검사할 게스트가 아직 붙는다」 하나뿐이다. 첫 게스트(`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 가 막히므로 콘솔로 들어가 로그를 봐야 한다 | | `plain_text_passwd` | **cloud-init 이 실패했을 때의 유일한 탈출구.** 키가 안 들어가면 SSH 가 막히므로 콘솔로 들어가 로그를 봐야 한다 |
| 키 두 개 | lab host 에서 자동화가 돌고(에이전트 포워딩 없음), 워크스테이션에서는 ProxyJump 로 직접 붙는다 | | 키 두 개 | lab host 에서 자동화가 돌고(에이전트 포워딩 없음), 워크스테이션에서는 ProxyJump 로 직접 붙는다 |
> **들여쓰기는 공백만.** YAML 은 탭을 금지하고, cloud-init 은 파싱에 실패해도 > **들여쓰기는 공백만.** YAML 은 탭을 금지하고, cloud-init 은 파싱에 실패해도
> **아무 오류도 남기지 않는다.** 게스트가 `localhost` 로 뜨고 로그인이 안 되는 > **아무 오류도 남기지 않는다.** 게스트가 `localhost` 로 뜨고 로그인이 안 되는
@@ -156,6 +202,7 @@ cloud-init 은 `cidata` 라벨이 붙고 안에 `user-data`·`meta-data` 라는
이름**의 파일이 있는 볼륨을 찾는다. 이름**의 파일이 있는 볼륨을 찾는다.
**하기** **하기**
```bash ```bash
printf 'instance-id: kc-lab-1-%s\nlocal-hostname: kc-lab-1\n' "$(date +%s)" > meta-kc-lab-1 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 ```bash
virsh vol-list default virsh vol-list default
``` ```
@@ -193,6 +241,7 @@ user-data 를 고쳐도 반영되지 않는다.
## 4. VM 을 만든다 ## 4. VM 을 만든다
**하기** **하기**
```bash ```bash
virt-install --name kc-lab-1 --memory 5120 --vcpus 2 \ virt-install --name kc-lab-1 --memory 5120 --vcpus 2 \
--disk size=20,backing_store=/var/lib/libvirt/images/base.qcow2 \ --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 --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 **★ 시드를 `--cloud-init` 으로 붙이지 않는다.** 그 옵션은 시드를 SATA CD-ROM
으로 붙이는데, Debian `genericcloud` 이미지는 크기를 줄이려고 물리 하드웨어 으로 붙이는데, Debian `genericcloud` 이미지는 크기를 줄이려고 물리 하드웨어
@@ -217,6 +295,7 @@ virt-install --name kc-lab-1 --memory 5120 --vcpus 2 \
MAC 을 정해 두었으니 그 MAC 에 IP 를 예약한다. MAC 을 정해 두었으니 그 MAC 에 IP 를 예약한다.
**하기** **하기**
```bash ```bash
virsh net-update default add ip-dhcp-host \ virsh net-update default add ip-dhcp-host \
"<host mac='52:54:00:aa:bb:11' name='kc-lab-1' ip='192.168.122.11'/>" \ "<host mac='52:54:00:aa:bb:11' name='kc-lab-1' ip='192.168.122.11'/>" \
@@ -227,20 +306,33 @@ virsh net-update default add ip-dhcp-host \
만 주면 지금 반영되지 않는다. 만 주면 지금 반영되지 않는다.
**확인** — 예약이 실제로 들어갔는가 **확인** — 예약이 실제로 들어갔는가
```bash ```bash
virsh net-dumpxml default | grep ip-dhcp-host -A3 virsh net-dumpxml default | grep ip-dhcp-host -A3
``` ```
**실측** **실측**
``` ```
<range start='192.168.122.2' end='192.168.122.254'/> <range start='192.168.122.2' end='192.168.122.254'/>
<host mac='52:54:00:aa:bb:11' name='kc-lab-1' ip='192.168.122.11'/> <host mac='52:54:00:aa:bb:11' name='kc-lab-1' ip='192.168.122.11'/>
<host mac='52:54:00:aa:bb:12' name='kc-lab-2' ip='192.168.122.12'/> <host mac='52:54:00:aa:bb:12' name='kc-lab-2' ip='192.168.122.12'/>
<host mac='52:54:00:aa:bb:10' name='kc-lab-edge' ip='192.168.122.10'/>
``` ```
**★ 예약을 먼저 넣고 VM 을 띄운다.** 순서가 반대면 게스트가 동적 대역에서
아무 주소나 받아 버리고, 예약이 먹으려면 리스 만료를 기다리거나 재부팅해야
한다. 넣을 때 나오는 한 줄은 이것이다.
```
Updated network default persistent config and live state
```
`persistent config``live state` **두 마디가 다 나와야** `--live --config`
가 제대로 먹은 것이다.
**어디를 봐야 하는가**`<host>` 두 줄의 **MAC 끝 두 자리와 IP 끝 숫자가 **어디를 봐야 하는가**`<host>` 두 줄의 **MAC 끝 두 자리와 IP 끝 숫자가
짝이 맞는가**(`:11``.11`, `:12``.12`). MAC 은 4번의 `virt-install 짝이 맞는가**(`:11``.11`, `:12``.12`). MAC 은 4번의 `virt-install --network mac=` 에 쓴 값과 한 글자도 다르면 안 된다. `<range>` 줄은 예약이
--network mac=` 에 쓴 값과 한 글자도 다르면 안 된다. `<range>` 줄은 예약이
아니라 동적 대역이라, 예약 IP 가 이 안에 들어 있어도 상관없다. 아니라 동적 대역이라, 예약 IP 가 이 안에 들어 있어도 상관없다.
**이 결과가 의미하는 것** — 두 줄이 다 보이면 게스트는 재부팅해도 같은 IP 를 **이 결과가 의미하는 것** — 두 줄이 다 보이면 게스트는 재부팅해도 같은 IP 를
@@ -252,22 +344,44 @@ virsh net-dumpxml default | grep ip-dhcp-host -A3
## 6. 붙어 본다 ## 6. 붙어 본다
**확인** — 떴는가, 그리고 cloud-init 이 제 일을 했는가 **확인** — 떴는가, 그리고 cloud-init 이 제 일을 했는가
```bash ```bash
virsh list --all virsh list --all
ssh donghyeon@192.168.122.11 'hostname; cat /etc/os-release | head -1' ssh donghyeon@192.168.122.11 'hostname; cat /etc/os-release | head -1'
``` ```
**실측** **실측**
``` ```
Id Name State Id Name State
-------------------------- -----------------------------
3 kc-lab-2 running 2 kc-lab-1 running
4 kc-lab-1 running 4 kc-lab-2 running
5 kc-lab-edge running
kc-lab-1 kc-lab-1
PRETTY_NAME="Debian GNU/Linux 12 (bookworm)" 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` 인가 **어디를 봐야 하는가** — 세 가지다. ① State 가 두 대 다 `running` 인가
(`shut off` 면 아직 안 뜬 것이고, Id 칸이 `-` 로 비어 있다). ② SSH 가 (`shut off` 면 아직 안 뜬 것이고, Id 칸이 `-` 로 비어 있다). ② SSH 가
**비밀번호를 묻지 않고** 통과했는가. ③ `hostname``kc-lab-1` 인가 **비밀번호를 묻지 않고** 통과했는가. ③ `hostname``kc-lab-1` 인가
@@ -285,12 +399,12 @@ Id 번호가 2 보다 큰 것은 아무 뜻도 없다(만들고 지운 이력일
여기서 실제로 겪은 것들이다. 여기서 실제로 겪은 것들이다.
| 증상 | 원인 | 확인 | | 증상 | 원인 | 확인 |
|---|---|---| | ----------------------------------------------------------------- | ------------------------------------------------------------------------- | ----------------------- |
| SSH `Permission denied (publickey)` · hostname 이 `localhost` | **cloud-init 이 안 돌았다.** 시드를 SATA 로 붙였거나 YAML 파싱 실패 | 아래 화면 캡처 | | SSH`Permission denied (publickey)` · hostname 이 `localhost` | **cloud-init 이 안 돌았다.** 시드를 SATA 로 붙였거나 YAML 파싱 실패 | 아래 화면 캡처 |
| user-data 를 고쳤는데 반영 안 됨 | `instance-id` 가 같아 건너뜀 | meta-data 의 id 확인 | | user-data 를 고쳤는데 반영 안 됨 | `instance-id` 가 같아 건너뜀 | meta-data 의 id 확인 |
| IP 가 매번 바뀐다 | DHCP 예약이 `--config` 없이 들어감 | `net-dumpxml` | | IP 가 매번 바뀐다 | DHCP 예약이`--config` 없이 들어감 | `net-dumpxml` |
| VM 이 느리다 | KVM 미사용 | [00](../00-lab-host/) 로 | | VM 이 느리다 | KVM 미사용 | [00](../00-lab-host/) 로 |
게스트에 못 들어갈 때는 **화면을 직접 뜬다.** 게스트에 못 들어갈 때는 **화면을 직접 뜬다.**
+257 -66
View File
@@ -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) ## 1. server 를 깐다 (kc-lab-1)
**하기** **하기**`[lab host]`
```bash ```bash
ssh kc-lab-1 ssh kc-lab-1 'curl -sfL https://get.k3s.io | sudo sh -s - server --node-ip 192.168.122.11'
curl -sfL https://get.k3s.io | sudo sh -s - server --node-ip 192.168.122.11
``` ```
게스트에 들어가지 않고 원격 실행한다. cloud-init 이 `NOPASSWD:ALL`
넣어 두었으므로 비대화식 `sudo` 가 멈추지 않는다.
`--node-ip` 를 준다. 게스트에 인터페이스가 여럿이면 k3s 가 엉뚱한 것을 고를 수 `--node-ip` 를 준다. 게스트에 인터페이스가 여럿이면 k3s 가 엉뚱한 것을 고를 수
있고, 그러면 두 노드가 서로를 다른 주소로 알게 된다. 있고, 그러면 두 노드가 서로를 다른 주소로 알게 된다.
**확인** — 서버가 떴고 자기 자신을 노드로 등록했는가 **확인** — 서버가 떴고 자기 자신을 노드로 등록했는가
```bash ```bash
sudo systemctl is-active k3s ssh kc-lab-1 'sudo systemctl is-active k3s; sudo kubectl get nodes'
sudo kubectl get nodes
``` ```
**어디를 봐야 하는가** — 유닛이 `active` 인가, 그리고 `get nodes` **어디를 봐야 하는가** — 유닛이 `active` 인가, 그리고 `get nodes`
@@ -32,6 +52,9 @@ sudo kubectl get nodes
이거나 아예 목록이 비어 있는 것이 정상이다** — CNI 가 아직 안 올라온 이거나 아예 목록이 비어 있는 것이 정상이다** — CNI 가 아직 안 올라온
시간이다. 한 번 더 친다. 시간이다. 한 번 더 친다.
이 가이드에서 `kubectl` 을 게스트 쪽에서 돌리는 것은 여기 한 번뿐이다.
lab host 에는 아직 kubeconfig 가 없기 때문이고, 그것을 두는 것이 바로 2번이다.
**이 결과가 의미하는 것**`active` + `Ready` 면 API 서버가 살아 있고 **이 결과가 의미하는 것**`active` + `Ready` 면 API 서버가 살아 있고
kubeconfig 도 자리를 잡았다는 뜻이라 2번으로 간다. 유닛이 `active` 인데 kubeconfig 도 자리를 잡았다는 뜻이라 2번으로 간다. 유닛이 `active` 인데
`get nodes` 가 접속 오류를 내면 API 서버가 아직 기동 중이다. 유닛이 `get nodes` 가 접속 오류를 내면 API 서버가 아직 기동 중이다. 유닛이
@@ -39,42 +62,115 @@ kubeconfig 도 자리를 잡았다는 뜻이라 2번으로 간다. 유닛이 `ac
로그를 본다. 로그를 본다.
```bash ```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 ```bash
TOKEN=$(ssh kc-lab-1 'sudo cat /var/lib/rancher/k3s/server/node-token') TOKEN=$(ssh kc-lab-1 'sudo cat /var/lib/rancher/k3s/server/node-token')
echo "${#TOKEN}" # 값이 아니라 길이만 확인한다 echo "${#TOKEN}" # 값이 아니라 길이만 확인한다
``` ```
**실측** — 이 실험대에서는 56자였다. **실측** — 이 실험대에서는 108자였다. (`K10<해시>::server:<비밀번호>` 형식이라
k3s 판올림에 따라 자릿수가 달라진다. **중요한 것은 값이 아니라 `0` 이 아니라는
것이다.**)
**어디를 봐야 하는가** — 찍히는 것은 **자릿수 하나뿐**이다. 값은 보지 **어디를 봐야 하는가** — 찍히는 것은 **자릿수 하나뿐**이다. 값은 보지
않는다 — 화면에 띄우는 순간 터미널 스크롤백과 셸 히스토리에 남는다. 않는다 — 화면에 띄우는 순간 터미널 스크롤백과 셸 히스토리에 남는다.
**이 결과가 의미하는 것** 자리 수가 나오면 토큰을 손에 쥔 것이니 3번으로 **이 결과가 의미하는 것** 자리 수가 나오면 토큰을 손에 쥔 것이니 4번으로
넘어간다. `0` 이면 변수가 비었다는 넘어간다. `0` 이면 변수가 비었다는 뜻이고 원인은 셋 중 하나다.
뜻이고 원인은 둘 중 하나다: server 가 아직 안 떠서 파일이 없거나, `sudo`
비대화식 SSH 에서 비밀번호를 물어 실패했거나. 어느 쪽인지는 파일부터 본다. | `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 ```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 \ ssh kc-lab-2 "curl -sfL https://get.k3s.io | sudo sh -s - agent \
--server https://192.168.122.11:6443 \ --server https://192.168.122.11:6443 \
--token '$TOKEN' \ --token '$TOKEN' \
--node-ip 192.168.122.12" --node-ip 192.168.122.12"
``` ```
> 토큰을 셸 히스토리에 남기고 싶지 않으면 파일로 넘긴다. 첫 줄의 가드를 빼지 않는다. `$TOKEN` 이 비면 게스트에는
`--token '' --node-ip ...` 가 전달되고, agent 는 기동 즉시
`level=fatal msg="Error: --token is required"` 로 죽는다. **그런데 설치
스크립트는 내려받기·유닛 생성·`enable` 까지 다 성공으로 찍고 끝나고**, 유닛은
`Restart=always` 라 5초마다 조용히 재시도한다. 가드 한 줄이 그 몇 분을 막는다.
> 토큰을 셸 히스토리에 남기고 싶지 않으면 파일로 넘긴다. 이러면 변수를 쓰지
> 않으므로 3번의 「같은 셸」 제약도 없어진다.
>
> ```bash > ```bash
> ssh kc-lab-1 'sudo cat /var/lib/rancher/k3s/server/node-token' \ > ssh kc-lab-1 'sudo cat /var/lib/rancher/k3s/server/node-token' \
> | ssh kc-lab-2 'sudo tee /tmp/token >/dev/null' > | ssh kc-lab-2 '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 가 클러스터에 들어왔는가, 그리고 **제 주소로** 들어왔는가 **확인** — agent 가 클러스터에 들어왔는가, 그리고 **제 주소로** 들어왔는가
```bash ```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 NAME STATUS ROLES AGE VERSION INTERNAL-IP
kc-lab-2 Ready <none> v1.36.4+k3s1 192.168.122.12 kc-lab-1 Ready control-plane 47m v1.36.4+k3s1 192.168.122.11
kc-lab-2 Ready <none> 21m v1.36.4+k3s1 192.168.122.12
``` ```
**어디를 봐야 하는가**`-o wide` 를 준 이유가 마지막 열이다. **INTERNAL-IP **어디를 봐야 하는가**`-o wide` 를 준 이유가 마지막 열이다. **INTERNAL-IP
두 개가 1번·3번에서 `--node-ip` 로 준 값과 같은가.** 그다음이 STATUS 두 줄 두 개가 1번·4번에서 `--node-ip` 로 준 값과 같은가.** 그다음이 STATUS 두 줄
`Ready`, 그다음이 ROLES 열이다. `<none>` 은 오류가 아니라 **역할 라벨이 `Ready`, 그다음이 ROLES 열이다. `<none>` 은 오류가 아니라 **역할 라벨이
없다**는 뜻이다 — agent 는 원래 그렇다. 없다**는 뜻이다 — agent 는 원래 그렇다.
@@ -109,20 +208,22 @@ IP 가 다른 대역(예: flannel 이나 다른 인터페이스 주소)으로
ssh kc-lab-2 'sudo journalctl -u k3s-agent -n 30 --no-pager' ssh kc-lab-2 'sudo journalctl -u k3s-agent -n 30 --no-pager'
``` ```
## 4. 유닛 이름이 다르다 ## 5. 유닛 이름이 다르다
| 노드 | 유닛 | | 노드 | 유닛 |
|---|---| | ------ | --------------------- |
| server | `k3s.service` | | server | `k3s.service` |
| agent | `k3s-agent.service` | | agent | `k3s-agent.service` |
**확인** — 어느 노드가 무슨 이름으로, 어떤 인자로 돌고 있는가 **확인** — 어느 노드가 무슨 이름으로, 어떤 인자로 돌고 있는가
```bash ```bash
ssh kc-lab-1 'systemctl cat k3s | grep -A3 ExecStart=' ssh kc-lab-1 'systemctl cat k3s | grep -A3 ExecStart='
ssh kc-lab-2 'systemctl cat k3s-agent | grep -A4 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 server '--node-ip' '192.168.122.11'
ExecStart=/usr/local/bin/k3s agent '--node-ip' '192.168.122.12' 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` 가 나오는데, 틀리면(`systemctl cat k3s` 를 agent 노드에서) `No files found` 가 나오는데,
그것 자체가 「이 노드는 agent 다」라는 답이다. 그것 자체가 「이 노드는 agent 다」라는 답이다.
**이 결과가 의미하는 것**3번의 `get nodes -o wide`**k3s 가 보고한** **이 결과가 의미하는 것**4번의 `get nodes -o wide`**k3s 가 보고한**
IP 이고, 이 줄은 **우리가 준** IP 다. 둘이 다르면 옵션이 안 먹은 것이다. IP 이고, 이 줄은 **우리가 준** IP 다. 둘이 다르면 옵션이 안 먹은 것이다.
그리고 뒤의 실험에서 노드를 멈출 때 칠 유닛 이름이 노드마다 다르다는 것을 그리고 뒤의 실험에서 노드를 멈출 때 칠 유닛 이름이 노드마다 다르다는 것을
여기서 확인해 둔다 — `systemctl stop k3s` 를 agent 노드에서 치면 아무 일도 여기서 확인해 둔다 — `systemctl stop k3s` 를 agent 노드에서 치면 아무 일도
@@ -142,27 +243,91 @@ IP 이고, 이 줄은 **우리가 준** IP 다. 둘이 다르면 옵션이 안
> 이 차이가 A-4 에서 결과를 갈랐다. server 노드를 잃으면 `kubectl` 자체가 > 이 차이가 A-4 에서 결과를 갈랐다. server 노드를 잃으면 `kubectl` 자체가
> 불통이 되고, agent 를 잃으면 `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` 을 듣는다 | | Traefik | 인그레스 컨트롤러.`:80` 을 듣는다 |
| servicelb (klipper-lb) | LoadBalancer 타입을 호스트 포트로 매핑 | | servicelb (klipper-lb) | LoadBalancer 타입을 호스트 포트로 매핑 |
| local-path | 기본 StorageClass. **노드 로컬 디스크** | | local-path | 기본 StorageClass.**노드 로컬 디스크** |
| flannel | 파드 네트워크 (VXLAN) | | flannel | 파드 네트워크 (VXLAN) |
| kube-router | NetworkPolicy 집행 | | kube-router | NetworkPolicy 집행 |
**확인**`[lab host]`. 무엇이 이미 돌고 있고, 기본 저장소가 무엇인가
**확인** — 무엇이 이미 돌고 있고, 기본 저장소가 무엇인가
```bash ```bash
sudo kubectl get pods -A kubectl get pods -A
sudo kubectl get storageclass 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` **어디를 봐야 하는가**`get pods -A` 에서는 **NAMESPACE 열이 `kube-system`
인 줄들의 STATUS**. `Running``Completed` 가 섞여 있는 것이 정상이다 — 인 줄들의 STATUS**. `Running``Completed` 가 섞여 있는 것이 정상이다 —
`helm-install-traefik-*` 는 일회성 잡이라 `Completed` 로 남는다. `helm-install-traefik-*` 는 일회성 잡이라 `Completed` 로 남는다.
`svclb-traefik-*` 가 **두 줄**인 것도 봐 둔다. DaemonSet 이라 노드마다 하나씩
뜨는 것이고, 이 두 줄이 4번의 join 이 실제로 먹었다는 또 하나의 증거다.
`get storageclass` 에서는 이름 뒤의 **`(default)` 표시**가 어디 붙어 있는가. `get storageclass` 에서는 이름 뒤의 **`(default)` 표시**가 어디 붙어 있는가.
**이 결과가 의미하는 것** — 여기 뜬 것들은 우리가 안 깔았는데 있는 것이고, **이 결과가 의미하는 것** — 여기 뜬 것들은 우리가 안 깔았는데 있는 것이고,
@@ -174,43 +339,69 @@ sudo kubectl get storageclass
> `local-path` 가 기본이라는 것이 A-4 에서 비용을 청구한다. PVC 가 **만들어진 > `local-path` 가 기본이라는 것이 A-4 에서 비용을 청구한다. PVC 가 **만들어진
> 노드에 묶여** 다른 노드로 재배치되지 않는다. > 노드에 묶여** 다른 노드로 재배치되지 않는다.
## 6. 워크스테이션에서 쓰려면 ## 8. 워크스테이션에서 쓰려면
**2번의 kubeconfig 를 그대로 가져와도 안 된다.** 게스트는 libvirt NAT 안에
있어서 워크스테이션에서 `192.168.122.11` 로 가는 경로가 없다.
**하기**
```bash ```bash
ssh kc-lab-1 'sudo cat /etc/rancher/k3s/k3s.yaml' > ~/.kube/kc-lab.yaml [워크스테이션] $ ping -c1 192.168.122.11
sed -i 's|127.0.0.1|192.168.122.11|' ~/.kube/kc-lab.yaml 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 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 ```bash
grep server: ~/.kube/kc-lab.yaml grep server: ~/.kube/kc-lab.yaml
kubectl get nodes kubectl get nodes
``` ```
**어디를 봐야 하는가** 첫 줄의 `server:` 값이 `https://192.168.122.11:6443` **어디를 봐야 하는가**`server:` `https://127.0.0.1:6443` 인가. 그다음
인가(`127.0.0.1` 이 남아 있으면 `sed` 가 안 먹은 것이다). 그다음 `get nodes` `get nodes` 가 4번과 **같은 두 줄**을 내놓는가.
가 3번과 **같은 두 줄**을 내놓는가. 이제부터는 `sudo``ssh` 도 붙이지
않는다.
**이 결과가 의미하는 것**통과하면 워크스테이션에서 곧장 클러스터를 볼 수 **이 결과가 의미하는 것**터널 덕에 워크스테이션의 6443 이 `kc-lab-1`
있고, 05 이후의 `kubectl` 은 전부 여기서 친다. `connection refused` 면 주소는 6443 이다. API 서버 인증서 SAN 에 `127.0.0.1` 이 들어 있어서(2번의 각주)
맞는데 API 서버가 안 뜬 것이고, 타임아웃이면 워크스테이션에서 인증서 검증도 통과한다. 타임아웃이면 1)의 터널 창이 닫힌 것이고,
`192.168.122.0/24` 로 가는 경로가 없는 것이다(호스트를 거치는 SSH 터널이나 `connection refused` 면 터널은 살아 있는데 반대편 API 서버가 죽은 것이다.
ProxyJump 가 필요하다). `x509` 오류면 파일 안의 CA 와 서버가 지금 쓰는 CA 가
어긋난 것이다 — k3s 를 다시 깔았다면 kubeconfig 도 다시 복사해야 한다. > **터널이 닫히`kubectl` 이 통째로 멎는다.** 이 실험대의 A층 실험은 노드를
> 죽이고 살리는 것이 목적이라, 터널 상태와 클러스터 상태가 섞여 오독하기
> 쉽다. **A층 실험은 lab host 에서 치는 것을 권한다.**
--- ---
## 막히면 ## 막히면
| 증상 | 원인 | 확인 | | 증상 | 원인 | 확인 |
|---|---|---| | -------------------------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------- |
| agent 가 `NotReady` | 토큰·주소 오타 | `journalctl -u k3s-agent -n 30` | | `echo "${#TOKEN} 자"``0` | 게스트 안에서 쳤거나, 셸이 바뀌었다 | 프롬프트가`kc-lab-1` 이 아닌지. 3번 표 |
| 노드 IP 가 예상과 다름 | `--node-ip` 설치 | `kubectl get nodes -o wide` | | agent 설치는 성공했는데 노드가 안 보임 | `--token`빈 문자열 | `ssh kc-lab-2 'sudo journalctl -u k3s-agent -n 30 --no-pager'``--token is required` |
| 밖에서 kubectl 이 안 붙음 | kubeconfig 의 `127.0.0.1` | 위 6번 | | agent 가`NotReady` | 토큰·주소 오타 | `ssh kc-lab-2 'sudo journalctl -u k3s-agent -n 30 --no-pager'` |
| 파드가 한 노드에만 몰림 | 스케줄러 판단 | `topologySpreadConstraints` 로 강제 | | 노드 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` 로 강제 |
+128 -19
View File
@@ -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 를 보고 서비스를 고른다 | | Traefik | 클러스터 안의 동적 라우팅 — Ingress 를 보고 서비스를 고른다 |
**이 2홉이 운영 구조와 같다는 것이 이 배치의 핵심**이고, 동시에 B-4 의 헤더 **이 2홉이 운영 구조와 같다는 것이 이 배치의 핵심**이고, 동시에 B-4 의 헤더
실험이 성립하는 이유다. 1홉을 가정하고 쓴 계약이 2홉에서도 유효한지를 실험이 성립하는 이유다. 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. 설정을 쓴다 ## 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 ```bash
sudo tee /etc/nginx/sites-available/keycloak-lab > /dev/null <<'EOF' sudo tee /etc/nginx/sites-available/keycloak-lab > /dev/null <<'EOF'
upstream k3s_traefik { upstream k3s_traefik {
@@ -69,22 +87,53 @@ EOF
> **인증서 경로는 아직 없다.** [04](../04-tls/) 에서 만든다. 그전까지는 443 > **인증서 경로는 아직 없다.** [04](../04-tls/) 에서 만든다. 그전까지는 443
> 블록을 주석 처리하고 80 만 `proxy_pass` 로 두면 이 단계를 먼저 확인할 수 있다. > 블록을 주석 처리하고 80 만 `proxy_pass` 로 두면 이 단계를 먼저 확인할 수 있다.
Arch 는 `sites-available` 관례가 없다. 직접 만들고 `nginx.conf``http` 블록 **엣지가 Debian 이라 `sites-available` 관례가 기본으로 있다.** 물리 호스트
안에서 include 한다. (Arch) 였을 때는 디렉터리를 직접 만들고 `nginx.conf` include 를 넣어야
했는데, 그 손질이 없어졌다. 운영도 Debian 계열이라 관례가 맞아떨어진다.
```bash ```bash
sudo mkdir -p /etc/nginx/sites-{available,enabled} ssh kc-lab-edge '
sudo ln -s /etc/nginx/sites-available/keycloak-lab /etc/nginx/sites-enabled/ sudo ln -sf /etc/nginx/sites-available/keycloak-lab /etc/nginx/sites-enabled/
# nginx.conf 의 http { } 안에: include /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. 문법을 보고 적용한다 ## 2. 문법을 보고 적용한다
**하기** **하기**`[kc-lab-edge]`
```bash ```bash
sudo nginx -t && sudo systemctl reload nginx 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 **어디를 봐야 하는가**`nginx -t` 가 내놓는 **마지막 줄**이다. `syntax is
ok``test is successful` 두 마디가 다 나와야 통과다. 앞에 나오는 ok``test is successful` 두 마디가 다 나와야 통과다. 앞에 나오는
`[warn]` 줄(예: `types_hash_max_size`)은 통과를 막지 않는다 — 04 에서 이 `[warn]` 줄(예: `types_hash_max_size`)은 통과를 막지 않는다 — 04 에서 이
@@ -113,7 +162,58 @@ systemctl status nginx --no-pager | head -20
것이고, 안 바뀌었으면 `-t` 는 통과했는데 reload 가 안 간 것이다. 것이고, 안 바뀌었으면 `-t` 는 통과했는데 reload 가 안 간 것이다.
04 에서 인증서 갱신이 서빙까지 닿았는지를 정확히 이 방법으로 판정한다. 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 두 개 중 하나가 죽은 것이고, 그 결과가 나온다. 한쪽만 다르면 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 ```bash
curl -I http://auth.hyeonworks.com curl -I http://auth.hyeonworks.com
``` ```
@@ -179,9 +288,9 @@ Location: https://auth.hyeonworks.com/
`https://` 로 시작하고 원래 호스트명을 그대로 들고 있는가. `$host` 대신 `https://` 로 시작하고 원래 호스트명을 그대로 들고 있는가. `$host` 대신
설정에 이름을 박아 두면 여기서 엉뚱한 호스트가 나온다. 설정에 이름을 박아 두면 여기서 엉뚱한 호스트가 나온다.
**이 결과가 의미하는 것** — 301 이 나왔다는 것은 **바깥 요청이 호스트 nginx **이 결과가 의미하는 것** — 301 이 나왔다는 것은 **바깥 요청이 DNAT 을 거쳐
까지 닿았다**는 뜻이다(①은 게스트에 직접 친 것이므로 nginx 를 안 거쳤다). 엣지 nginx 까지 닿았다**는 뜻이다(①은 Traefik 에 직접, ②는 엣지에 직접 친
DNS·방화벽·80 리스너가 전부 살아 있다. 응답이 아예 없으면 nginx 가 안 떴거나 것이라 DNAT 을 안 거쳤다). DNS·DNAT·80 리스너가 전부 살아 있다. 응답이 아예 없으면 nginx 가 안 떴거나
80 이 막힌 것이다. 리다이렉트를 따라가 끝까지 보려면 `-L` 을 붙인다. 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/ 301 https://auth.hyeonworks.com/
``` ```
**확인 ** 끝까지 닿나 (TLS 이후) **확인 ** 끝까지 닿나 (TLS 이후)
```bash ```bash
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master curl -s -o /dev/null -w '%{http_code}\n' 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: (60)` 같은 인증서 오류는 아직 04 를 안 한 것이다. TLS 단계에서
막히면 코드만 보지 말고 `curl -v` 로 협상 과정을 읽는다 — 04 에서 그렇게 한다. 막히면 코드만 보지 말고 `curl -v` 로 협상 과정을 읽는다 — 04 에서 그렇게 한다.
## 4. upstream 이 둘인 이유 ## 5. upstream 이 둘인 이유
``` ```
upstream k3s_traefik { upstream k3s_traefik {
+101 -11
View File
@@ -6,31 +6,104 @@
## 전제 ## 전제
[03](../03-nginx/) 이 끝나 nginx 가 Traefik 으로 프록시한다. 그리고 **공개 [03](../03-nginx/) 이 끝나 엣지 nginx 가 Traefik 으로 프록시한다.
DNS 에 이름 세 개가 이 호스트를 가리키고 있어야 한다** — Let's Encrypt 가
HTTP-01 로 검증하러 오기 때문이다. ## 어디서 치는가
**이 단계는 전부 `[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 을 깐다 ## 1. certbot 을 깐다
**하기** cloud-init 이 이미 깔았다면 건너뛴다 —
[`kc-lab.yaml.example`](../../../deploy/lab/cloud-init/kc-lab.yaml.example) 의
`packages` 에 들어 있다.
**하기**`[kc-lab-edge]`
```bash ```bash
sudo pacman -S certbot certbot-nginx # Arch sudo apt install -y certbot python3-certbot-dns-cloudflare
sudo apt install certbot python3-certbot-nginx # Debian/Ubuntu
``` ```
**확인** — 쓸 수 있는 검증 방식이 무엇인가
```bash
certbot plugins 2>/dev/null | grep -E '^\*'
```
**실측**
```
* dns-cloudflare
* standalone
* webroot
```
**어디를 봐야 하는가**`dns-cloudflare` 한 줄이 있는가. 없으면 플러그인
패키지가 안 깔린 것이고, `--dns-cloudflare` 를 줘도 `unrecognized arguments`
로 끝난다.
## 2. 인증서를 받는다 ## 2. 인증서를 받는다
이름 세 개를 **한 인증서**에 넣는다. DNS-01 이면 **와일드카드를 받을 수 있다.** 이 실험대는 처음에 이름 셋을
따로 받았고, 그 비용이 B-7 에서 청구됐다 — oauth2-proxy 를 올릴 네 번째
이름이 없어 Grafana 의 `app2` 를 빌려야 했다.
**하기**`[kc-lab-edge]`. 토큰은 **존 하나 + DNS:Edit** 으로 좁힌다.
계정 전역 API Key 를 쓰지 않는다.
**하기**
```bash ```bash
sudo certbot certonly --webroot -w /var/www/html \ sudo install -m 600 /dev/null /etc/letsencrypt/cloudflare.ini
-d auth.hyeonworks.com -d app1.hyeonworks.com -d app2.hyeonworks.com sudo tee /etc/letsencrypt/cloudflare.ini >/dev/null <<'EOF'
dns_cloudflare_api_token = <Cloudflare API 토큰>
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 ```bash
sudo certbot certificates sudo certbot certificates
``` ```
@@ -218,6 +291,20 @@ EOF
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh 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/` 에 넣는다. `post/` 는 갱신이 없어도 매번 돌아 하루 두 번 워커를
갈아치운다. `deploy/`**실제로 갱신됐을 때만** 실행된다. 갈아치운다. `deploy/`**실제로 갱신됐을 때만** 실행된다.
@@ -301,6 +388,9 @@ nginx 의 `types_hash` 경고가 stderr 로 나갔을 뿐이고 내용은
| 갱신은 됐는데 옛 인증서가 나감 | **deploy 훅 없음** | 워커 PID · `sudo ls /etc/letsencrypt/renewal-hooks/deploy/` | | 갱신은 됐는데 옛 인증서가 나감 | **deploy 훅 없음** | 워커 PID · `sudo ls /etc/letsencrypt/renewal-hooks/deploy/` |
| 훅이 실패한 것처럼 보임 | stderr 경고를 error 로 표시 | 문구 말고 **워커 PID** | | 훅이 실패한 것처럼 보임 | stderr 경고를 error 로 표시 | 문구 말고 **워커 PID** |
| 발급 한도 | 주당 중복 인증서 5장 | `--dry-run` 으로 먼저 시험 | | 발급 한도 | 주당 중복 인증서 5장 | `--dry-run` 으로 먼저 시험 |
| `unrecognized arguments: --dns-cloudflare` | 플러그인 미설치 | `certbot plugins \| grep '^\*'` |
| DNS-01 이 오래 걸림 | TXT 전파 대기 | **정상이다.** 끊지 않는다 |
| 재구축 뒤 인증서가 없음 | 발급하지 말고 **백업을 되돌린다** | 한도를 아끼는 길이다 |
--- ---
+42 -2
View File
@@ -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` 가 돈다 | | [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 | | [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단계 | | [04](04-tls/) | Let's Encrypt | `https://` 가 열리고 체인이 4단계 |
| [05](05-keycloak/) | Keycloak 2노드 + PostgreSQL | 관리 콘솔 로그인이 된다 | | [05](05-keycloak/) | Keycloak 2노드 + PostgreSQL | 관리 콘솔 로그인이 된다 |
| [06](06-observability/) | Prometheus · Grafana | `vendor_cluster_size` 가 2 | | [06](06-observability/) | Prometheus · Grafana | `vendor_cluster_size` 가 2 |
+1 -1
View File
@@ -354,7 +354,7 @@ done
503 nginx·Traefik 은 살아 있고 뒤로 보낼 파드가 없다 503 nginx·Traefik 은 살아 있고 뒤로 보낼 파드가 없다
``` ```
호스트 nginx 의 upstream 에는 **두 노드가 다 들어 있다** 엣지 nginx(`kc-lab-edge`) 의 upstream 에는 **두 노드가 다 들어 있다**
([`03-nginx`](../03-nginx/) 1절). ([`03-nginx`](../03-nginx/) 1절).
``` ```
+482
View File
@@ -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 \
"<host mac='52:54:00:aa:bb:10' name='kc-lab-edge' ip='192.168.122.10'/>" \
--live --config
```
**실측**
```
Updated network default persistent config and live state
```
**어디를 봐야 하는가****`persistent config``live state` 두 마디가 다
나오는가.** `--live` 만 주면 앞의 것이, `--config` 만 주면 뒤의 것이 빠진다.
한쪽만 나온 것을 성공으로 읽으면 「재부팅하니 IP 가 바뀐다」가 된다.
### 예약을 먼저, VM 을 나중에
이 실험대는 **예약을 넣고 나서 `virt-install`** 했고, 게스트가 첫 부팅에서
바로 `.10` 을 받았다.
```
enp1s0 UP 192.168.122.10/24 metric 100
```
순서가 반대면 게스트가 동적 대역(`192.168.122.2``.254`)에서 아무 주소나 받고,
예약이 먹으려면 리스 만료를 기다리거나 재부팅해야 한다.
### 리스는 예약과 별개로 남는다
```bash
virsh net-dhcp-leases default
```
```
Expiry Time MAC address IP address Hostname
2026-09-10 09:59:54 52:54:00:aa:bb:11 192.168.122.11/24 kc-lab-1
2026-09-10 09:58:46 52:54:00:aa:bb:12 192.168.122.12/24 kc-lab-2
```
`net-dumpxml` 의 예약은 **줄 의도**이고, `net-dhcp-leases` 는 **실제로 준
기록**이다. 둘이 다를 수 있다.
### virbr0 는 게스트가 없으면 내려간다
```bash
ip -br addr show virbr0
```
VM 세 대가 돌 때:
```
virbr0 UP 192.168.122.1/24
```
전부 철거한 뒤:
```
virbr0 DOWN 192.168.122.1/24
```
**주소는 그대로 있고 상태만 `DOWN` 이다.** 브리지에 붙은 tap 인터페이스가
하나도 없어서다. 네트워크 정의가 사라진 것이 아니므로 **VM 을 다시 띄우면
자동으로 `UP` 이 된다.** 이걸 고장으로 오독해 `virsh net-start` 를 찾아
헤매지 않는다.
---
## 6. 철거 — 실제 출력 전문
가이드에는 세우는 절차만 있고 철거가 없다. 2026-09-10 에 실제로 돌린 기록이다.
### 게스트
```bash
for v in kc-lab-edge kc-lab-2 kc-lab-1; do
virsh destroy "$v"
virsh undefine "$v" --remove-all-storage
done
```
**실측** (한 대분)
```
Domain 'kc-lab-edge' destroyed
Domain 'kc-lab-edge' has been undefined
Volume 'vda'(/var/lib/libvirt/images/kc-lab-edge.qcow2) removed.
Volume 'vdb'(/var/lib/libvirt/images/seed-kc-lab-edge.iso) removed.
```
**어디를 봐야 하는가**`Volume` 줄이 **두 개** 나오는가. `vda`(오버레이
디스크)와 `vdb`(시드 ISO)다. `--remove-all-storage` 를 빠뜨리면 도메인만
사라지고 **디스크 파일이 남아 다음 `virt-install` 이 「이미 있다」로 실패**한다.
`destroy` 는 종료 신호가 아니라 **전원을 뽑는 것**이다. 정상 종료를 원하면
`virsh shutdown` 을 쓰고 꺼질 때까지 기다린다.
### DHCP 예약
```bash
virsh net-update default delete ip-dhcp-host \
"<host mac='52:54:00:aa:bb:10' name='kc-lab-edge' ip='192.168.122.10'/>" \
--live --config
```
**★ 삭제할 때도 `mac`·`name`·`ip` 세 속성을 다 준다.** 하나라도 비면 이렇게
거부된다.
```
error: Failed to update network default
error: XML error: Cannot use host name '' in network 'default'
```
> **zsh 에서 루프로 돌리면 이 오류를 만난다.** zsh 는 따옴표 없는 변수를
> **단어 분리하지 않는다.** bash 에서 되던 `set -- $entry` 가 zsh 에서는
> `$1` 에 문자열 전체를 넣고 `$2`·`$3` 을 비운다. 그래서 `name=""` 이 된다.
> 세 줄을 값 그대로 쓰는 편이 안전하다.
**철거 후**
```
### 남은 예약
(없음)
### dhcp 블록
<dhcp>
<range start='192.168.122.2' end='192.168.122.254'/>
</dhcp>
```
동적 대역만 남는 것이 정상이다.
### 철거 전후 비교 — 실측
| | 철거 전 | 철거 후 |
|---|---|---|
| `virsh list --all` | 3 대 running | (없음) |
| `virsh vol-list default` | 7 개 | `base.qcow2` 1 개 |
| DHCP 예약 | 3 줄 | 0 줄 |
| `df -h /` | 11G | **7.9G** |
| `virbr0` | UP | DOWN |
**3.1GB 가 회수됐다.** 내역은 `kc-lab-1` 1.4GB + `kc-lab-2` 665MB + 시드 ISO
3개(각 370KB)이고, **`kc-lab-edge` 의 디스크 크기는 재 두지 않았다(미측정)** —
합계에서 역산하면 1GB 안팎이다.
`base.qcow2` 335MB 는 남긴다 — 다음 재구축의 바닥이고, 다시 받으면 몇 분이다.
---
## 7. 실측으로 드러난 함정 셋
전부 **아무 오류 없이 조용히 지나가거나, 엉뚱한 곳에서 증상이 나오는** 유형이다.
### ① cloud-init `sudo` 는 리스트가 아니라 문자열
```bash
ssh kc-lab-1 'cloud-init schema -c ~/chk.yaml'
```
리스트 형태(`sudo: ['ALL=(ALL) NOPASSWD:ALL']`)일 때:
```
Error: Cloud config schema errors: users.0: {'name': 'donghyeon', 'groups':
['sudo'], 'shell': '/bin/bash', ...} is not valid under any of the given schemas
```
문자열 형태(`sudo: "ALL=(ALL) NOPASSWD:ALL"`)일 때:
```
Valid cloud-config: /home/donghyeon/chk.yaml
```
**어느 키가 문제인지 안 알려 준다.** `users.0` 블록을 통째로 찍고 「어느
스키마에도 안 맞는다」고만 한다. 그리고 **리스트 형태도 부팅은 된다**
`kc-lab-1`·`kc-lab-2` 가 그 상태로 NOPASSWD sudo 가 멀쩡히 돌았다. 검사기만
거부하는 것이라 「검사는 실패인데 왜 되지」로 헷갈린다.
게스트 cloud-init 버전: `22.4.2` (Debian 12 genericcloud).
### ② nginx `http2 on;` 은 배포판에 따라 없다
```
엣지 (Debian 12): nginx version: nginx/1.22.1
lab host (Arch): nginx version: nginx/1.30.4
```
`http2 on;` 은 nginx 1.25.1 이상이다. Arch 에서 쓰던 설정을 Debian 게스트로
그대로 옮기면:
```
[emerg] 1339#1339: unknown directive "http2" in /etc/nginx/sites-enabled/keycloak-lab:29
nginx: configuration file /etc/nginx/nginx.conf test failed
```
`listen 443 ssl http2;` 형태는 1.22 와 1.30 양쪽에서 다 돈다.
### ③ Debian 기본 사이트가 `default_server` 를 먹고 있다
Debian 계열은 `/etc/nginx/sites-enabled/default` 가 처음부터 붙어 있고 `:80`
`default_server` 로 선언돼 있다. 실험대 설정의 `listen 80 default_server`
**충돌한다.** 심볼릭 링크를 걸 때 같이 지운다.
```bash
sudo rm -f /etc/nginx/sites-enabled/default
```
Arch 는 `sites-available` 관례 자체가 없어서 이 함정이 없는 대신, `nginx.conf`
`include /etc/nginx/sites-enabled/*;` 를 직접 넣어야 한다. **배포판이 바뀌면
함정도 바뀐다.**
---
## 8. 재구축할 때 무엇이 남아 있나
철거해도 남는 것과 사라지는 것을 구분해 둔다. 이걸 모르면 재구축이 「왜
이건 이미 있지」와 「왜 이건 없지」의 반복이 된다.
| | 상태 | 왜 |
|---|---|---|
| `base.qcow2` (335MB) | **남는다** | 다음 오버레이의 바닥 |
| 패키지 (libvirt·qemu·nginx·certbot·kubectl) | **남는다** | 재설치가 무의미 |
| `~/workspace/cloud/kc-lab-{1,2}.yaml` | **남는다** | 키와 비밀번호가 들어 있다 |
| `~/.ssh/config``kc-lab-*` 항목 | **남는다** | 재구축해도 IP 가 같다 |
| libvirt `default` 네트워크 정의 | **남는다** | 예약만 지웠다 |
| `/etc/letsencrypt/` | **남긴다** (정책) | 아래 참고 |
| 게스트 디스크·시드 ISO | 사라진다 | `--remove-all-storage` |
| DHCP 예약 | 사라진다 | `net-update delete` |
| k3s·Keycloak·모든 워크로드 | 사라진다 | 게스트와 함께 |
### 인증서를 지우지 않는 이유
Let's Encrypt 는 **같은 이름 조합에 대해 주당 중복 인증서 5장** 제한이 있다.
그리고 이 실험대의 이름 셋은 tailnet 주소를 가리킨다.
```bash
dig +short auth.hyeonworks.com
```
```
100.83.212.4
```
`100.64.0.0/10` 은 CGNAT 용 예약 대역이라 **공개 인터넷에서 라우팅되지
않는다.** HTTP-01 검증은 Let's Encrypt 가 우리 서버로 들어오는 방식이므로,
이 주소로는 검증이 성립하지 않는다. **지우면 되살리기 전에 검증 방식 문제부터
풀어야 한다.** 백업만 뜨고 파일은 남긴다.
```bash
sudo tar czf ~/letsencrypt-backup-$(date +%Y%m%d-%H%M%S).tgz -C /etc letsencrypt
```
복원은 반대로 한 줄이다.
```bash
sudo tar xzf ~/letsencrypt-backup-<stamp>.tgz -C /etc
```
**미측정** — 이 실험대의 certbot 이 HTTP-01 인지 DNS-01 인지 아직 확인하지
않았다. 호스트의 `sudo` 가 비밀번호를 요구해 비대화식으로 읽지 못했다.
다음 한 줄이 답이다.
```bash
sudo grep -H authenticator /etc/letsencrypt/renewal/*.conf
```
`webroot`·`standalone` 이면 HTTP-01 이라 위 문제가 실재하고, `dns-cloudflare`
면 DNS-01 이라 tailnet 안에서도 재발급·갱신이 된다. 플러그인은 이미 깔려
있다 — `certbot plugins``dns-cloudflare` 가 보인다.
---
## 관련 문서
| 문서 | 무엇 |
|---|---|
| [`guides/01-vms/`](guides/01-vms/) | VM 세 대를 세우는 절차 |
| [`guides/00-lab-host/`](guides/00-lab-host/) | 호스트 가상화 준비 |
| [`session-lab-concepts.md`](session-lab-concepts.md) | 여기 나온 개념의 정의 |
| [`deploy/lab/host/teardown-host.sh`](../deploy/lab/host/teardown-host.sh) | 호스트 계층 철거 |
+160 -1
View File
@@ -1661,6 +1661,75 @@ sudo certbot certificates # 발급된 인증서와 도메인 목록
sudo certbot renew --dry-run # 갱신이 실제로 되는지 예행연습 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` ### `fullchain.pem` / `privkey.pem` / `cert.pem` / `chain.pem`
**무엇인가** — certbot이 만드는 네 파일. **무엇인가** — certbot이 만드는 네 파일.
@@ -1739,7 +1808,9 @@ chmod 600 ~/.kube/config
**이 명령은 호스트(test-server)에서 실행한다.** 게스트 안에서 실행하면 **이 명령은 호스트(test-server)에서 실행한다.** 게스트 안에서 실행하면
`ssh kc-lab-1` 부분이 자기 자신에게 다시 접속하는 꼴이 되고, 애초에 `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 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) ### Traefik (k3s 기본 ingress)
**무엇인가** — k3s가 기본으로 배포하는 ingress 컨트롤러. **무엇인가** — k3s가 기본으로 배포하는 ingress 컨트롤러.
+9 -4
View File
@@ -301,12 +301,17 @@ git checkout feature/keycloak-multinode-cluster-jdbc-ping
생긴다 — nginx 설정에서 실제로 겪었다 생긴다 — nginx 설정에서 실제로 겪었다
([`two-hop-proxy-header-contract.md`](two-hop-proxy-header-contract.md) 9절). ([`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 ```bash
sudo cp deploy/lab/host/nginx-keycloak-lab.conf /etc/nginx/sites-available/keycloak-lab cat deploy/lab/edge/nginx-keycloak-lab.conf \
sudo nginx -t && sudo systemctl reload nginx | ssh kc-lab-edge 'sudo tee /etc/nginx/sites-available/keycloak-lab >/dev/null'
sudo nginx -T | grep -n 'upstream\|server_name' # 최종 병합 설정 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하면 서비스가 **`nginx -t`를 통과한 뒤에만 reload한다.** 깨진 설정으로 reload하면 서비스가
+10 -4
View File
@@ -37,7 +37,7 @@ Keycloak을 올린 뒤에 로그인이 깨지면 **세션 문제인지 프록시
│ │ │ │
│ [스위치 1] nginx ← 호스트 OS 의 프로세스 │ │ [스위치 1] nginx ← 호스트 OS 의 프로세스 │
│ /etc/nginx/sites-available/keycloak-lab │ │ /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) ────────┐ │ │ ┌─ kc-lab-1 (VM) ─────────────┐ ┌─ kc-lab-2 (VM) ────────┐ │
│ │ svclb 파드 :80 │ │ svclb 파드 :80 │ │ │ │ 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) | | 2 | Traefik | **클러스터 안 파드 1개** | `HelmChartConfig` (kube-system) |
| 3 | 앱 | **클러스터 안 파드 N개** | 각 앱의 매니페스트 `env` | | 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-available/keycloak-lab` |
| 활성화 | `/etc/nginx/sites-enabled/keycloak-lab` 심볼릭 링크 | | 활성화 | `/etc/nginx/sites-enabled/keycloak-lab` 심볼릭 링크 |
@@ -532,7 +538,7 @@ downstream 앱은 **이 헤더를 믿고 "누가 로그인했는지"를 판단**
```bash ```bash
cd ~/workspace/keycloak-pattern && git pull 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 sudo nginx -t && sudo systemctl reload nginx
grep -n 'X-Forwarded-Proto\|X-Forwarded-Port' /etc/nginx/sites-available/keycloak-lab grep -n 'X-Forwarded-Proto\|X-Forwarded-Port' /etc/nginx/sites-available/keycloak-lab
``` ```