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)
│ 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 반입 |
+12 -1
View File
@@ -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
+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 {
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
+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 는 남아 있다."
+130 -16
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 없이 돈다.
## 왜 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`)를 만들 때는 검사할 게스트가 아직
@@ -141,7 +187,7 @@ ssh donghyeon@192.168.122.11 'cloud-init schema -c ~/kc-lab-2.yaml; rm -f ~/kc-l
세 가지가 의도적이다.
| | 왜 |
|---|---|
| --------------------- | -------------------------------------------------------------------------------------------------------------------- |
| `NOPASSWD:ALL` | k3s 설치와 장애 주입이 비대화식으로 돌아야 한다. 비밀번호를 물으면 멈춘다 |
| `plain_text_passwd` | **cloud-init 이 실패했을 때의 유일한 탈출구.** 키가 안 들어가면 SSH 가 막히므로 콘솔로 들어가 로그를 봐야 한다 |
| 키 두 개 | lab host 에서 자동화가 돌고(에이전트 포워딩 없음), 워크스테이션에서는 ProxyJump 로 직접 붙는다 |
@@ -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 \
"<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
virsh net-dumpxml default | grep ip-dhcp-host -A3
```
**실측**
```
<range start='192.168.122.2' end='192.168.122.254'/>
<host mac='52:54:00:aa:bb:11' name='kc-lab-1' ip='192.168.122.11'/>
<host mac='52:54:00:aa:bb:12' name='kc-lab-2' ip='192.168.122.12'/>
<host mac='52:54:00:aa:bb:10' name='kc-lab-edge' ip='192.168.122.10'/>
```
**★ 예약을 먼저 넣고 VM 을 띄운다.** 순서가 반대면 게스트가 동적 대역에서
아무 주소나 받아 버리고, 예약이 먹으려면 리스 만료를 기다리거나 재부팅해야
한다. 넣을 때 나오는 한 줄은 이것이다.
```
Updated network default persistent config and live state
```
`persistent config``live state` **두 마디가 다 나와야** `--live --config`
가 제대로 먹은 것이다.
**어디를 봐야 하는가**`<host>` 두 줄의 **MAC 끝 두 자리와 IP 끝 숫자가
짝이 맞는가**(`:11``.11`, `:12``.12`). MAC 은 4번의 `virt-install
--network mac=` 에 쓴 값과 한 글자도 다르면 안 된다. `<range>` 줄은 예약이
짝이 맞는가**(`:11``.11`, `:12``.12`). MAC 은 4번의 `virt-install --network mac=` 에 쓴 값과 한 글자도 다르면 안 된다. `<range>` 줄은 예약이
아니라 동적 대역이라, 예약 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
-----------------------------
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` 인가
@@ -286,7 +400,7 @@ Id 번호가 2 보다 큰 것은 아무 뜻도 없다(만들고 지운 이력일
여기서 실제로 겪은 것들이다.
| 증상 | 원인 | 확인 |
|---|---|---|
| ----------------------------------------------------------------- | ------------------------------------------------------------------------- | ----------------------- |
| SSH`Permission denied (publickey)` · hostname 이 `localhost` | **cloud-init 이 안 돌았다.** 시드를 SATA 로 붙였거나 YAML 파싱 실패 | 아래 화면 캡처 |
| user-data 를 고쳤는데 반영 안 됨 | `instance-id` 가 같아 건너뜀 | meta-data 의 id 확인 |
| IP 가 매번 바뀐다 | DHCP 예약이`--config` 없이 들어감 | `net-dumpxml` |
+245 -54
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)
**하기**
**하기**`[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 <none> 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 <none> 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 열이다. `<none>` 은 오류가 아니라 **역할 라벨이
없다**는 뜻이다 — 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` |
**확인** — 어느 노드가 무슨 이름으로, 어떤 인자로 돌고 있는가
```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 집행 |
**확인** — 무엇이 이미 돌고 있고, 기본 저장소가 무엇인가
**확인** `[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` |
| -------------------------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------- |
| `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` |
| 밖에서 kubectl 이 안 붙음 | kubeconfig 의 `127.0.0.1` | 위 6번 |
| 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 를 보고 서비스를 고른다 |
**이 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 {
+101 -11
View File
@@ -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 = <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
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 전파 대기 | **정상이다.** 끊지 않는다 |
| 재구축 뒤 인증서가 없음 | 발급하지 말고 **백업을 되돌린다** | 한도를 아끼는 길이다 |
---
+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` 가 돈다 |
| [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 |
+1 -1
View File
@@ -354,7 +354,7 @@ done
503 nginx·Traefik 은 살아 있고 뒤로 보낼 파드가 없다
```
호스트 nginx 의 upstream 에는 **두 노드가 다 들어 있다**
엣지 nginx(`kc-lab-edge`) 의 upstream 에는 **두 노드가 다 들어 있다**
([`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 # 갱신이 실제로 되는지 예행연습
```
### 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 컨트롤러.
+9 -4
View File
@@ -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하면 서비스가
+10 -4
View File
@@ -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
```