feat(pipeline): keycloak-session-store 25편·virtualization 59편을 S3→S5→S6 으로 돌린다
기록 84편을 계약 에이전트로 다시 썼다. 기존 71편(kss 25 · virt 46)과, 계약에만 있고 안 쓰여 있던 새 글감 13편이다. 원장 84개를 열어 단계마다 스킬 영수증과 관문 종료 코드를 적었고 verify-pipeline-run.py 가 error 0 으로 닫는다. SSOT 결함 둘을 고쳤다. - kss 의 `약 58일` 이 반입 중 `약 59일` 로 바뀌어 있었다. 원 증거 파일이 「남은 일수: 88일 … 실제 갱신까지 약 58일」로 산수를 직접 적는다. D-4a 쪽 `약 59일` 은 강제 갱신 뒤(`VALID: 89 days`)라 맞는 값이라 그대로 뒀다. - virt §198 의 `11.6GB` 는 §178 의 원 측정 `Mem: 11648`(MiB)과 어긋나는데 원 가이드의 표기 그대로라 고치지 않고 쓰이는 자리에 대조를 적었다. 기록의 수치 오류 셋을 고쳤다 — CASE 요약의 「게스트 셋에 8240MB」(5120+3120 은 둘이다), k3s 편이 같은 것을 여섯·일곱·여덟로 세던 것, no-docker 편의 「셋을 더 든다」(§281 의 표는 네 행이고 디스크 행이 빠져 있었다). 계약을 셋 고쳤다. - kss 의 sourceRepository 리비전이 cdac9b8 이었는데 그 커밋에는 docs/guides/** 28개가 아예 없다. 9465582b 로 바꾸고, 반입한 바이트가 어느 커밋과도 같지 않다는 것을 측정값과 함께 적었다 — 반입은 커밋이 아니라 그 시점의 작업 트리에서 떠 온 것이다(kss 297/306 · virt 12/14 가 작업 트리와 같고, 200 커밋을 거슬러 전수 대조했을 때 가장 가까운 커밋도 28개가 어긋났다). - virt 계약이 「2026-09-11 재배분」이라고 적는데 SSOT 는 재배분 날짜를 적지 않고 재배분 뒤 값은 이미 2026-09-10 측정에 찍혀 있다. - kss 후보 대장이 지나친 절 아홉에 처분을 적었다(warn 9 → 0). 새 글감은 0건이고 넷은 앵커가 h3 슬러그의 접두가 아니라 중간 토막이라 검사기가 못 본 것이었다. style_profile.mjs 의 결함 둘을 고쳤다 — frontmatter 가 문장으로 세어져 (실측 398자짜리 「문장」 하나) 평균 길이를 기준 안으로 밀어 올리고 있었고, engPerSent 의 분자는 목록을 포함한 글에서, 분모는 목록을 걷어낸 글에서 세고 있었다(Question 기록에서 11.94 → 3.86). verify-pipeline.py 전 항목 PASS · error 0 · unittest 334건 OK. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d473609e0a
commit
2109f726fe
@@ -0,0 +1 @@
|
||||
9465582b5d1630eb4ae7c4e078021486919bf6b6
|
||||
@@ -0,0 +1,31 @@
|
||||
#!/usr/sbin/nft -f
|
||||
# Forward the tailnet entry point to the edge guest.
|
||||
#
|
||||
# This is the ONLY lab traffic rule the physical host carries. Everything else
|
||||
# that used to live here — nginx config, certificates, certbot, the deploy hook
|
||||
# — now lives on kc-lab-edge and is destroyed with it.
|
||||
#
|
||||
# DNAT only, never SNAT. The guests' default route is the host, so replies come
|
||||
# back through here and conntrack reverses the translation on its own. Adding a
|
||||
# masquerade would rewrite the source and the edge would see 192.168.122.1 for
|
||||
# every client — which would silently invalidate the X-Forwarded-For contract
|
||||
# that this lab measures.
|
||||
#
|
||||
# PREROUTING nat runs before the routing decision, so this wins over any local
|
||||
# socket on :80/:443. That makes the cutover atomic and the rollback a single
|
||||
# `nft delete table ip lab_edge`.
|
||||
|
||||
table ip lab_edge
|
||||
delete table ip lab_edge
|
||||
|
||||
table ip lab_edge {
|
||||
chain prerouting {
|
||||
type nat hook prerouting priority dstnat; policy accept;
|
||||
iifname "tailscale0" tcp dport { 80, 443 } dnat to 192.168.122.10
|
||||
}
|
||||
|
||||
# No forward chain here on purpose. libvirt's guest_input chain ends in
|
||||
# `oif virbr0 ... reject`, and an accept in an earlier base chain does NOT
|
||||
# stop a later chain from rejecting — that is nftables, not iptables. The
|
||||
# hole is punched inside libvirt's own chain by the unit's ExecStartPost.
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
[Unit]
|
||||
Description=Lab edge DNAT (tailnet :80/:443 -> kc-lab-edge)
|
||||
After=network-online.target libvirtd.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
ExecStart=/usr/sbin/nft -f /etc/nftables.d/lab-edge-dnat.nft
|
||||
|
||||
# libvirt's own guest_input chain ends in `oif virbr0 ... reject`, and nftables
|
||||
# does NOT let an accept in an earlier base chain override a reject in a later
|
||||
# one. So the hole has to be punched inside libvirt's chain, at the top.
|
||||
# `-` because libvirt_network only exists once the virtual network is up; if it
|
||||
# is missing the DNAT still loads and this can be re-applied with a restart.
|
||||
ExecStartPost=-/usr/sbin/nft insert rule ip libvirt_network guest_input oif virbr0 ip daddr 192.168.122.10 tcp dport {80,443} ct state new counter accept
|
||||
|
||||
ExecStop=/usr/sbin/nft delete table ip lab_edge
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,61 @@
|
||||
# Lab entry point. Deployed on the lab host as
|
||||
# /etc/nginx/sites-available/keycloak-lab
|
||||
# and symlinked from sites-enabled/.
|
||||
#
|
||||
# Arch does not ship the Debian sites-available convention, so nginx.conf needs
|
||||
# include /etc/nginx/sites-enabled/*;
|
||||
# inside its http { } block before this file has any effect.
|
||||
#
|
||||
# This is the outer of two L7 hops. It terminates TLS and hands plain HTTP to
|
||||
# the Traefik instance running on each k3s node.
|
||||
|
||||
upstream k3s_traefik {
|
||||
# Sticky-session switch. Keycloak recommends affinity on AUTH_SESSION_ID;
|
||||
# ip_hash is the cheap stand-in for a single-browser lab. Leaving it off is
|
||||
# the interesting case: Infinispan still routes correctly, only slower.
|
||||
# ip_hash;
|
||||
server 192.168.122.11:80;
|
||||
server 192.168.122.12:80;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80 default_server;
|
||||
server_name _;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
# The http2 parameter of listen, not the separate `http2 on;` directive:
|
||||
# that directive needs nginx >= 1.25.1 and the edge guest is Debian 12
|
||||
# (nginx 1.22). This form works on both and is what the lab actually runs.
|
||||
listen 443 ssl http2 default_server;
|
||||
server_name _;
|
||||
|
||||
# Lineage is named after the FIRST -d, so a wildcard cert issued as
|
||||
# -d hyeonworks.com -d '*.hyeonworks.com'
|
||||
# lands in live/hyeonworks.com/, not live/auth.hyeonworks.com/.
|
||||
# fullchain.pem, never cert.pem: omitting the intermediates passes on
|
||||
# desktop browsers and fails on mobile and curl.
|
||||
ssl_certificate /etc/letsencrypt/live/hyeonworks.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/hyeonworks.com/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
|
||||
location / {
|
||||
proxy_pass http://k3s_traefik;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header X-Forwarded-Port 443;
|
||||
|
||||
# $remote_addr, not $proxy_add_x_forwarded_for. This is the trust
|
||||
# boundary: a client-supplied X-Forwarded-For must be discarded, not
|
||||
# extended, or nothing downstream can rely on the value.
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
#!/bin/sh
|
||||
# certbot deploy hook. Install as
|
||||
# /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh (chmod +x)
|
||||
#
|
||||
# deploy/ runs only when a certificate was actually renewed (RENEWED_LINEAGE is
|
||||
# set). post/ would run twice a day whether or not anything changed, reloading
|
||||
# nginx for nothing.
|
||||
#
|
||||
# Without this, D-4 measured the failure exactly: the renewal succeeds, the
|
||||
# timer reports SUCCESS, and the old certificate keeps being served for 38m25s
|
||||
# — with no error anywhere.
|
||||
nginx -t && nginx -s reload
|
||||
@@ -0,0 +1,239 @@
|
||||
# 00 — lab host 준비
|
||||
|
||||
## 이 단계가 끝나면
|
||||
|
||||
`virsh list` 가 sudo 없이 돌고, VM 을 만들 수 있는 상태가 된다.
|
||||
|
||||
## 전제
|
||||
|
||||
물리 기계 한 대. 이 실험대는 Arch Linux 를 썼지만 배포판은 상관없다 —
|
||||
패키지 이름만 다르다.
|
||||
|
||||
---
|
||||
|
||||
## 0. 저장소를 lab host 에 받는다
|
||||
|
||||
**뒤 단계가 `deploy/` 아래 파일을 쓴다.** 05·06 의 `kubectl apply -f
|
||||
deploy/lab/k8s/...` 가 그것이고, 경로는 **저장소 루트 기준**이다. lab host 에
|
||||
저장소가 없으면 `cp: cannot stat` / `error: the path ... does not exist` 로
|
||||
막힌다 — 이 실험대에서 실제로 겪은 형태다.
|
||||
|
||||
**하기** — `[lab host]`
|
||||
```bash
|
||||
git clone https://git.learn.hyeonworks.com/donghyeon.kang/keycloak-pattern.git ~/workspace/keycloak-pattern
|
||||
cd ~/workspace/keycloak-pattern
|
||||
```
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
ls deploy/lab/k8s/
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `keycloak-cluster.yaml` 과 `observability.yaml` 이
|
||||
보이는가.
|
||||
|
||||
**이 결과가 의미하는 것** — 이후 단계에서 `deploy/...` 로 시작하는 상대경로가
|
||||
나오면 **전부 이 디렉터리 안에서 치는 것**이다. 다른 데서 치면 파일을 못 찾는다.
|
||||
|
||||
**★ 이미 한 번 실험대를 세웠다가 철거했다면 이 디렉터리가 없을 수 있다.**
|
||||
철거는 VM·디스크·네트워크만 지우고 저장소는 각자 관리라, `~/workspace` 에
|
||||
cloud-init seed 만 남아 있는 상태가 흔하다. `ls ~/workspace` 로 먼저 본다.
|
||||
|
||||
---
|
||||
|
||||
## 1. CPU 가상화가 켜져 있는가
|
||||
|
||||
BIOS 에서 꺼져 있으면 아무것도 못 한다. 먼저 본다.
|
||||
|
||||
**확인** — CPU 가 하드웨어 가상화 확장을 내놓고 있는가
|
||||
```bash
|
||||
grep -Eo 'vmx|svm' /proc/cpuinfo | head -1
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 출력 한 줄이 전부다. `vmx`(Intel) 또는 `svm`(AMD)
|
||||
중 하나가 찍히는가, 아니면 아무것도 안 찍히는가.
|
||||
|
||||
**이 결과가 의미하는 것** — 찍혔으면 이 호스트에서 KVM 을 쓸 수 있다. 다음
|
||||
단계로 간다. 빈 출력은 「CPU 가 못 한다」가 아니라 대개 **BIOS 에서 꺼져
|
||||
있다**는 뜻이다 — 재부팅해 Intel VT-x / AMD-V 를 켜고 다시 잰다. 여기서
|
||||
막히면 뒤의 어떤 단계도 의미가 없으므로 진행하지 않는다.
|
||||
|
||||
**실측** — 이 실험대의 호스트는 16 코어 전부에서 지원한다.
|
||||
|
||||
## 2. KVM 모듈이 올라와 있는가
|
||||
|
||||
**확인** — 커널이 그 확장을 실제로 잡고 있는가
|
||||
```bash
|
||||
lsmod | grep kvm
|
||||
```
|
||||
|
||||
**형태** (이 실험대에서 캡처해 두지 않았다 — 줄 모양만)
|
||||
```
|
||||
kvm_intel ...
|
||||
kvm ...
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 왼쪽 첫 열의 모듈 이름 두 개. 벤더 모듈
|
||||
(`kvm_intel` 또는 `kvm_amd`)과 공용 `kvm` 이 **둘 다** 있어야 한다. 셋째 열은
|
||||
이 모듈을 쓰고 있는 쪽의 수라서, 아직 VM 이 없으면 0 인 것이 정상이다.
|
||||
|
||||
**이 결과가 의미하는 것** — 두 줄이면 커널이 하드웨어 가상화를 쓸 준비가 됐고
|
||||
`virt-install` 이 KVM 가속으로 뜬다. `kvm` 만 있고 벤더 모듈이 없으면 1번의
|
||||
BIOS 설정이 커널까지 안 넘어온 것이다 — `sudo modprobe kvm_intel` 로 직접
|
||||
올려 보면 거부 사유가 그대로 나온다. 아무것도 없으면 1번으로 돌아간다.
|
||||
|
||||
> **왜 이걸 먼저 보나.** KVM 없이도 QEMU 는 돌지만 **소프트웨어 에뮬레이션**이
|
||||
> 되어 수십 배 느리다. VM 두 대가 「뜨긴 뜨는데 느리다」면 대개 여기다.
|
||||
|
||||
## 3. 패키지 설치
|
||||
|
||||
**하기** (Arch)
|
||||
```bash
|
||||
sudo pacman -S --needed qemu-full libvirt virt-install dnsmasq
|
||||
```
|
||||
|
||||
Debian/Ubuntu 면 이름이 다르다.
|
||||
```bash
|
||||
sudo apt install qemu-system-x86 libvirt-daemon-system virtinst cloud-image-utils
|
||||
```
|
||||
|
||||
| 무엇 | 하는 일 |
|
||||
|---|---|
|
||||
| qemu | 실제로 가상 기계를 돌리는 것 |
|
||||
| libvirt | qemu 를 관리하는 층 (`virsh` 가 여기 붙는다) |
|
||||
| virt-install | VM 을 만드는 명령 |
|
||||
| dnsmasq | 가상 네트워크의 DHCP·DNS |
|
||||
|
||||
**확인** — 두 실행 파일이 PATH 에 들어왔는가
|
||||
```bash
|
||||
virsh --version
|
||||
qemu-system-x86_64 --version
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 판 번호 두 줄. 명령을 못 찾는다(`command not found`)면
|
||||
패키지가 안 깔린 것이고, 번호가 나오면 깔린 것이다.
|
||||
|
||||
**이 결과가 의미하는 것** — 여기서 나오는 libvirt 판 번호가 뒤의 옵션 이름을
|
||||
가른다. 이 실험대는 **libvirt 12.7.0 · QEMU emulator version 11.1.1** 이었다
|
||||
(문서 끝 실측값 블록). 훨씬 낮은 판이면 `virt-install --cloud-init` 같은
|
||||
옵션의 동작이 다를 수 있으니, 01 에서 막힐 때 이 번호를 같이 본다.
|
||||
|
||||
## 4. libvirt 를 띄우고 권한을 받는다
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
sudo systemctl enable --now libvirtd.socket
|
||||
sudo usermod -aG libvirt "$USER"
|
||||
```
|
||||
|
||||
그리고 **로그아웃했다 다시 들어온다.** 보조 그룹은 로그인할 때 정해지므로
|
||||
`usermod` 만으로는 지금 셸에 반영되지 않는다.
|
||||
|
||||
**확인** — 지금 이 셸이 libvirt 에 sudo 없이 붙는가
|
||||
```bash
|
||||
groups # libvirt 가 보여야 한다
|
||||
virsh list --all # sudo 없이 돌아야 한다
|
||||
```
|
||||
|
||||
**실측**
|
||||
```
|
||||
donghyeon libvirt wheel
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `groups` 출력에 `libvirt` 가 끼어 있는가, 그리고
|
||||
`virsh list --all` 이 **머리글만 있는 빈 표**라도 오류 없이 끝나는가. 아직
|
||||
VM 을 안 만들었으므로 표가 비어 있는 것이 정상이다 — 봐야 할 것은 표의
|
||||
내용이 아니라 명령이 통과했다는 사실이다.
|
||||
|
||||
**이 결과가 의미하는 것** — 둘 다 통과하면 이 셸에서 VM 을 만들 수 있고,
|
||||
01 로 넘어가도 된다. `groups` 에 `libvirt` 가 없으면 `usermod` 는 됐지만
|
||||
**지금 로그인 세션이 옛 그룹 목록을 들고 있는** 것이다 — 로그아웃/로그인
|
||||
한다. `groups` 에는 있는데 `virsh` 가 `Permission denied` 면 그룹이 아니라
|
||||
소켓 문제이므로 `systemctl status libvirtd.socket` 을 본다.
|
||||
|
||||
> **`libvirtd.service` 가 아니라 `.socket` 을 켠 이유.** 소켓 활성화라서
|
||||
> 데몬이 미리 떠 있지 않아도 `virsh` 가 접속하는 순간 systemd 가 띄운다.
|
||||
> 자원을 아끼고, 데몬을 재시작해도 클라이언트가 끊기지 않는다.
|
||||
|
||||
## 5. 연결 URI 를 고정한다
|
||||
|
||||
`virsh` 는 기본으로 `qemu:///session`(사용자 단위)에 붙는데, VM 은
|
||||
`qemu:///system`(시스템 단위)에 만들어야 한다. **이걸 안 맞추면 만든 VM 이
|
||||
안 보인다.**
|
||||
|
||||
**하기** — 셸 프로필에 넣는다
|
||||
```bash
|
||||
echo 'export LIBVIRT_DEFAULT_URI=qemu:///system' >> ~/.bashrc
|
||||
```
|
||||
|
||||
**확인** — 지금 셸이 어느 하이퍼바이저를 보고 있는가
|
||||
```bash
|
||||
virsh uri
|
||||
```
|
||||
```
|
||||
qemu:///system
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 끝의 한 낱말. `system` 인가 `session` 인가.
|
||||
|
||||
**이 결과가 의미하는 것** — `qemu:///system` 이면 뒤에서 만들 VM 과 지금
|
||||
`virsh` 가 같은 곳을 본다. `qemu:///session` 이면 사용자 단위 하이퍼바이저를
|
||||
보고 있어 **VM 은 만들어졌는데 `virsh list` 에 안 나오는** 상태가 된다.
|
||||
`.bashrc` 에 넣은 것은 **새로 여는 셸에만** 적용되므로, 지금 셸에서는
|
||||
`export LIBVIRT_DEFAULT_URI=qemu:///system` 을 한 번 더 치거나 새 셸을 연다.
|
||||
|
||||
## 6. 기본 네트워크
|
||||
|
||||
**확인** — VM 이 붙을 가상 네트워크가 살아 있는가
|
||||
```bash
|
||||
virsh net-list --all
|
||||
```
|
||||
|
||||
**실측**
|
||||
```
|
||||
Name State Autostart Persistent
|
||||
--------------------------------------------
|
||||
default active yes yes
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `default` 행의 **State 와 Autostart 두 칸**.
|
||||
`--all` 을 준 이유가 여기 있다 — 빼면 `inactive` 인 네트워크는 아예 목록에
|
||||
안 나와서 「없음」과 「꺼짐」을 구분할 수 없다.
|
||||
|
||||
**이 결과가 의미하는 것** — `active` + `yes` 면 지금도, 호스트를 재부팅한
|
||||
뒤에도 `virbr0` 와 `192.168.122.0/24` 가 있다. `inactive` 면 VM 을 만들어도
|
||||
DHCP 가 없어 IP 를 못 받는다. Autostart 가 `no` 면 **지금은 되지만 호스트를
|
||||
재부팅한 다음 01 의 SSH 가 전부 실패**하고, 원인을 게스트에서 찾게 된다.
|
||||
둘 중 하나라도 어긋나면 아래 두 줄로 맞춘다.
|
||||
|
||||
```bash
|
||||
virsh net-start default
|
||||
virsh net-autostart default
|
||||
```
|
||||
|
||||
이 네트워크가 `virbr0` 브리지와 `192.168.122.0/24` 대역을 만든다. VM 들이
|
||||
여기 붙는다.
|
||||
|
||||
---
|
||||
|
||||
## 막히면
|
||||
|
||||
| 증상 | 원인 | 확인 |
|
||||
|---|---|---|
|
||||
| `virsh list` 에 permission denied | 그룹 반영 안 됨 | 로그아웃/로그인 했는가. `groups` 에 libvirt 가 있는가 |
|
||||
| 만든 VM 이 목록에 없다 | URI 가 `session` | `virsh uri` |
|
||||
| VM 이 극단적으로 느리다 | KVM 미사용 | `lsmod \| grep kvm` · BIOS |
|
||||
| `net-start default` 실패 | dnsmasq 없음 | 패키지 설치 확인 |
|
||||
|
||||
---
|
||||
|
||||
## 이 단계의 실측값
|
||||
|
||||
돌고 있는 실험대에서 그대로 읽은 것이다.
|
||||
|
||||
```
|
||||
libvirt 12.7.0
|
||||
qemu QEMU emulator version 11.1.1
|
||||
그룹 donghyeon libvirt wheel
|
||||
네트워크 default / active / autostart yes
|
||||
```
|
||||
@@ -0,0 +1,474 @@
|
||||
# 01 — VM 세 대
|
||||
|
||||
## 이 단계가 끝나면
|
||||
|
||||
`kc-lab-edge`·`kc-lab-1`·`kc-lab-2` 세 게스트가 뜨고, lab host 에서 SSH 가
|
||||
키로 붙는다.
|
||||
|
||||
## 전제
|
||||
|
||||
[00](../00-lab-host/) 이 끝나 `virsh list` 가 sudo 없이 돈다.
|
||||
|
||||
## 왜 VM 세 대인가
|
||||
|
||||
**k3s 노드 두 대** — 이 실험대의 질문 전부가 **「인스턴스가 둘 이상이고
|
||||
요청이 어느 쪽으로 갈지 모른다」** 를 전제한다. 한 대면 세션 공유도 분단도
|
||||
노드 상실도 실험이 되지 않는다.
|
||||
|
||||
**엣지 한 대** — nginx·인증서·certbot 이 사는 곳이다. 이것을 물리 호스트에
|
||||
두면 **되돌릴 수가 없다.** 자주 고치고 자주 갈아엎는 층인데 물리 기계에
|
||||
쌓이기 때문이다. VM 이면 초기화가 `virsh undefine` 한 줄이고, 물리 호스트에는
|
||||
DNAT 규칙 하나와 DHCP 예약만 남는다. 자세한 이유는 [03](../03-nginx/) 의
|
||||
「왜 엣지가 물리 호스트가 아니라 VM 인가」에 있다.
|
||||
|
||||
그리고 호스트에 직접 깔면 **되돌릴 수가 없다** — 이 실험대는 노드를
|
||||
죽이고 DB 를 crash 시키는 것이 목적이라 망가뜨릴 수 있는 층이 필요하다.
|
||||
|
||||
| 게스트 | IP | MAC 끝 | 메모리 | 무엇이 도나 |
|
||||
|---|---|---|---|---|
|
||||
| `kc-lab-edge` | 192.168.122.10 | `:10` | 1024MB | nginx · certbot |
|
||||
| `kc-lab-1` | 192.168.122.11 | `:11` | 5120MB | k3s server · Traefik |
|
||||
| `kc-lab-2` | 192.168.122.12 | `:12` | 4096MB | k3s agent · Traefik |
|
||||
|
||||
---
|
||||
|
||||
## 1. base 이미지를 받는다
|
||||
|
||||
OS 를 설치하지 않는다. 클라우드 이미지는 **이미 설치가 끝난 디스크**이고,
|
||||
첫 부팅에서 cloud-init 이 사용자·SSH 키·호스트명을 채운다.
|
||||
|
||||
**하기**
|
||||
|
||||
```bash
|
||||
cd /var/lib/libvirt/images
|
||||
sudo curl -fL --output /var/lib/libvirt/images/base.qcow2 \
|
||||
https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2
|
||||
```
|
||||
|
||||
**확인** — 받은 파일이 온전한 qcow2 인가
|
||||
|
||||
```bash
|
||||
qemu-img info /var/lib/libvirt/images/base.qcow2
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 세 줄이다. `file format:` 이 `qcow2` 인가(`raw` 로
|
||||
읽히면 내려받기가 중간에 끊겨 HTML 오류 페이지를 저장한 것이다),
|
||||
`virtual size:` 가 `disk size:` 보다 훨씬 큰가(qcow2 는 희소 파일이라 이게
|
||||
정상이다), `backing file:` 줄이 **없는가**. base 는 아무것도 뒤에 두지 않는다.
|
||||
|
||||
**이 결과가 의미하는 것** — 셋이 맞으면 이 파일을 5번에서 오버레이의 바닥으로
|
||||
쓸 수 있다. 여기서 형식이 틀린 채로 진행하면 증상이 `virt-install` 이 아니라
|
||||
**게스트가 부팅을 못 하는 것**으로 나타나 원인을 찾기 어려워진다.
|
||||
|
||||
## 2. cloud-init 을 쓴다
|
||||
|
||||
게스트마다 하나씩 만든다. 템플릿은
|
||||
[`deploy/lab/cloud-init/kc-lab.yaml.example`](../../../deploy/lab/cloud-init/kc-lab.yaml.example).
|
||||
|
||||
```yaml
|
||||
#cloud-config
|
||||
hostname: kc-lab-1
|
||||
fqdn: kc-lab-1
|
||||
manage_etc_hosts: true
|
||||
|
||||
users:
|
||||
- name: donghyeon
|
||||
groups: [sudo]
|
||||
shell: /bin/bash
|
||||
sudo: ['ALL=(ALL) NOPASSWD:ALL']
|
||||
lock_passwd: false
|
||||
plain_text_passwd: __CONSOLE_PW__
|
||||
ssh_authorized_keys:
|
||||
- __LAB_HOST_KEY__
|
||||
- __WORKSTATION_KEY__
|
||||
|
||||
ssh_pwauth: false
|
||||
package_update: true
|
||||
packages: [curl, nftables]
|
||||
```
|
||||
|
||||
### 세 값을 어디서 가져오나
|
||||
|
||||
자리표시자 셋을 실제 값으로 바꿔야 한다. **찾는 명령이 있다.**
|
||||
|
||||
```bash
|
||||
# ① lab host 공개키 — 없으면 만든다
|
||||
[ -f ~/.ssh/id_ed25519.pub ] || ssh-keygen -t ed25519 -N '' -f ~/.ssh/id_ed25519
|
||||
cat ~/.ssh/id_ed25519.pub
|
||||
|
||||
# ② 워크스테이션 공개키 — 워크스테이션에서
|
||||
cat ~/.ssh/id_ed25519.pub
|
||||
|
||||
# ③ 콘솔용 비밀번호 — 만들어서 보관한다
|
||||
openssl rand -base64 18
|
||||
```
|
||||
|
||||
셋을 넣는다.
|
||||
|
||||
```bash
|
||||
sed -i \
|
||||
-e "s|__LAB_HOST_KEY__|$(cat ~/.ssh/id_ed25519.pub)|" \
|
||||
-e "s|__WORKSTATION_KEY__|<워크스테이션에서 복사해 온 값>|" \
|
||||
-e "s|__CONSOLE_PW__|$(openssl rand -base64 18)|" \
|
||||
kc-lab-1.yaml
|
||||
```
|
||||
|
||||
**확인** — 자리표시자가 남아 있으면 cloud-init 이 그대로 넣는다
|
||||
|
||||
```bash
|
||||
grep -c '__' kc-lab-1.yaml # 0 이어야 한다
|
||||
grep -c 'ssh-ed25519\|ssh-rsa' kc-lab-1.yaml # 2 여야 한다
|
||||
python3 -c 'import yaml,sys; yaml.safe_load(open("kc-lab-1.yaml")); print("YAML OK")'
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 숫자 두 개와 낱말 하나. 첫 줄이 `0` 이 아니면
|
||||
`__LAB_HOST_KEY__` 같은 문자열이 남아 있는 것이고, 둘째 줄이 `2` 가 아니면
|
||||
`sed` 치환 중 하나가 안 먹은 것이다. 셋째 줄은 `YAML OK` 가 찍히는가만 본다 —
|
||||
찍히지 않으면 대신 파이썬 예외 줄이 나오고, 거기 적힌 `line N` 이 문제의
|
||||
줄 번호다. 이 셋은 값을 뽑는 것이 아니라 **찍힌 숫자를 눈으로 비교하는**
|
||||
용도라 이 형태가 맞다.
|
||||
|
||||
**이 결과가 의미하는 것** — `0` / `2` / `YAML OK` 셋이 다 맞아야 시드를 만든다.
|
||||
어긋난 채로 3번을 진행하면 **cloud-init 은 파싱에 실패해도 아무 오류를 남기지
|
||||
않으므로**, 증상이 「SSH 가 안 붙는다」로만 나타나고 원인이 이 파일에 있다는
|
||||
사실이 드러나지 않는다. 여기서 거르는 것이 뒤에서 30분 걸릴 일을 없앤다.
|
||||
|
||||
> **`yamllint` 는 이 실험대에 없다.** 있으면 좋지만 없다고 설치하러 가지
|
||||
> 않는다 — 위 세 줄로 충분하다.
|
||||
|
||||
**게스트가 한 대라도 떠 있으면 한 단계 더 볼 수 있다.** YAML 로 파싱된다는
|
||||
것과 **cloud-config 로 유효하다**는 것은 다르다. 키 이름 오타(`user` vs
|
||||
`users`)는 위 검사를 그냥 통과한다. cloud-init 자신의 스키마 검사기가
|
||||
게스트에 들어 있다 — `kc-lab-2` 용 파일은 `kc-lab-1` 에서 검사할 수 있다.
|
||||
|
||||
```bash
|
||||
# 이 파일에는 콘솔 비밀번호가 평문으로 들어 있다 — /tmp 가 아니라
|
||||
# 자기 홈에 600 으로 두고, 검사가 끝나면 바로 지운다
|
||||
ssh donghyeon@192.168.122.11 'umask 077; cat > ~/kc-lab-2.yaml' < kc-lab-2.yaml
|
||||
ssh donghyeon@192.168.122.11 'cloud-init schema -c ~/kc-lab-2.yaml; rm -f ~/kc-lab-2.yaml'
|
||||
```
|
||||
|
||||
**실측** — 통과하면 이 한 줄이다.
|
||||
|
||||
```
|
||||
Valid cloud-config: /home/donghyeon/kc-lab-edge.yaml
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 마지막 한 줄. 통과하면 유효하다는 한 줄만 나오고,
|
||||
아니면 `Invalid cloud-config` 아래에 **어느 키가 왜 틀렸는지**가 나열된다.
|
||||
경고(`deprecated`)와 오류(`error`)는 다르다 — 경고는 지금 동작한다.
|
||||
|
||||
> **★ `sudo` 는 리스트가 아니라 문자열로 쓴다.** 이 실험대의 첫 두 게스트는
|
||||
> 이렇게 되어 있었는데, 게스트의 cloud-init 22.4.2 스키마 검사기가 거부한다.
|
||||
>
|
||||
> ```yaml
|
||||
> sudo: ['ALL=(ALL) NOPASSWD:ALL'] # 거부된다
|
||||
> sudo: "ALL=(ALL) NOPASSWD:ALL" # 통과한다
|
||||
> ```
|
||||
>
|
||||
> **실측** — 리스트 형태로 검사하면 이렇게 나온다.
|
||||
>
|
||||
> ```
|
||||
> Error: Cloud config schema errors: users.0: {'name': 'donghyeon', 'groups':
|
||||
> ['sudo'], 'shell': '/bin/bash', ...} is not valid under any of the given schemas
|
||||
> ```
|
||||
>
|
||||
> 어느 키가 문제인지 **안 알려 준다** — `users.0` 전체를 통째로 찍고
|
||||
> 「어느 스키마에도 안 맞는다」고만 한다. 그래서 키를 하나씩 바꿔 가며
|
||||
> 좁혀야 한다. 리스트 형태도 **부팅은 된다**(`kc-lab-1`·`kc-lab-2` 가 그
|
||||
> 상태로 NOPASSWD sudo 가 멀쩡히 돌고 있다). 검사기만 거부하는 것이라
|
||||
> 「검사는 실패했는데 왜 되지」로 헷갈리기 쉽다.
|
||||
|
||||
**이 결과가 의미하는 것** — 오류가 나면 그 키는 **조용히 무시된다.**
|
||||
`users` 를 `user` 로 잘못 쓰면 계정이 안 생기고, 증상은 역시 「SSH 가 안
|
||||
붙는다」 하나뿐이다. 첫 게스트(`kc-lab-1`)를 만들 때는 검사할 게스트가 아직
|
||||
없으니 위 세 줄로 가고, 둘째부터는 이 검사를 거친다.
|
||||
|
||||
세 가지가 의도적이다.
|
||||
|
||||
| | 왜 |
|
||||
| --------------------- | -------------------------------------------------------------------------------------------------------------------- |
|
||||
| `NOPASSWD:ALL` | k3s 설치와 장애 주입이 비대화식으로 돌아야 한다. 비밀번호를 물으면 멈춘다 |
|
||||
| `plain_text_passwd` | **cloud-init 이 실패했을 때의 유일한 탈출구.** 키가 안 들어가면 SSH 가 막히므로 콘솔로 들어가 로그를 봐야 한다 |
|
||||
| 키 두 개 | lab host 에서 자동화가 돌고(에이전트 포워딩 없음), 워크스테이션에서는 ProxyJump 로 직접 붙는다 |
|
||||
|
||||
> **들여쓰기는 공백만.** YAML 은 탭을 금지하고, cloud-init 은 파싱에 실패해도
|
||||
> **아무 오류도 남기지 않는다.** 게스트가 `localhost` 로 뜨고 로그인이 안 되는
|
||||
> 것이 유일한 증상이다.
|
||||
|
||||
## 3. 시드 이미지를 만든다
|
||||
|
||||
cloud-init 은 `cidata` 라벨이 붙고 안에 `user-data`·`meta-data` 라는 **정확한
|
||||
이름**의 파일이 있는 볼륨을 찾는다.
|
||||
|
||||
**하기**
|
||||
|
||||
```bash
|
||||
printf 'instance-id: kc-lab-1-%s\nlocal-hostname: kc-lab-1\n' "$(date +%s)" > meta-kc-lab-1
|
||||
|
||||
xorrisofs -quiet -output seed-kc-lab-1.iso -volid CIDATA -joliet -rock \
|
||||
-graft-points /user-data=kc-lab-1.yaml /meta-data=meta-kc-lab-1
|
||||
|
||||
virsh vol-create-as default seed-kc-lab-1.iso "$(stat -c%s seed-kc-lab-1.iso)" --format raw
|
||||
virsh vol-upload --pool default seed-kc-lab-1.iso seed-kc-lab-1.iso
|
||||
```
|
||||
|
||||
**확인** — 볼륨이 풀에 올라갔고 비어 있지 않은가
|
||||
|
||||
```bash
|
||||
virsh vol-list default
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `seed-kc-lab-1.iso` 행이 목록에 있는가. 있으면
|
||||
크기까지 본다.
|
||||
|
||||
```bash
|
||||
virsh vol-info --pool default seed-kc-lab-1.iso
|
||||
```
|
||||
|
||||
**Capacity** 가 방금 만든 로컬 파일 크기(`stat -c%s seed-kc-lab-1.iso`)와
|
||||
같아야 한다.
|
||||
|
||||
**이 결과가 의미하는 것** — `vol-create-as` 는 **빈 볼륨을 만들 뿐**이고
|
||||
내용은 `vol-upload` 가 채운다. 두 명령 중 뒤엣것을 빠뜨리면 목록에는 이름이
|
||||
보이지만 안이 0 으로 채워져 있고, cloud-init 은 `cidata` 라벨을 못 찾아
|
||||
조용히 끝난다 — 증상은 또 「SSH 가 안 붙는다」다. 크기가 맞으면 4번으로 간다.
|
||||
|
||||
`instance-id` 에 타임스탬프를 넣는 이유가 있다. cloud-init 은 **인스턴스마다
|
||||
한 번만** 초기화 모듈을 돌린다. id 가 같으면 이미 한 것으로 보고 건너뛰므로,
|
||||
user-data 를 고쳐도 반영되지 않는다.
|
||||
|
||||
## 4. DHCP 로 IP 를 고정한다
|
||||
|
||||
**★ 이 단계가 VM 생성보다 먼저다.** 순서가 반대면 게스트가 동적 대역에서
|
||||
아무 주소나 받아 버리고, 그 뒤에 예약을 넣어도 **이미 잡은 리스가 유지된다.**
|
||||
되돌리려면 리스 만료를 기다리거나 게스트를 재시작해야 한다.
|
||||
|
||||
MAC 은 5번의 `virt-install --network mac=` 에 쓸 값을 **여기서 미리 정하는
|
||||
것**이다. 아직 게스트가 없어도 예약은 들어간다 — 예약은 「이 MAC 이 나타나면
|
||||
이 IP 를 줘라」이지 「지금 있는 게스트에게 줘라」가 아니다.
|
||||
|
||||
**하기** — 게스트 수만큼 친다
|
||||
|
||||
```bash
|
||||
virsh net-update default add ip-dhcp-host \
|
||||
"<host mac='52:54:00:aa:bb:10' name='kc-lab-edge' ip='192.168.122.10'/>" \
|
||||
--live --config
|
||||
virsh net-update default add ip-dhcp-host \
|
||||
"<host mac='52:54:00:aa:bb:11' name='kc-lab-1' ip='192.168.122.11'/>" \
|
||||
--live --config
|
||||
virsh net-update default add ip-dhcp-host \
|
||||
"<host mac='52:54:00:aa:bb:12' name='kc-lab-2' ip='192.168.122.12'/>" \
|
||||
--live --config
|
||||
```
|
||||
|
||||
> **zsh 에서 루프로 돌리지 않는다.** zsh 는 따옴표 없는 변수를 단어 분리하지
|
||||
> 않아서, bash 에서 되던 `set -- $entry` 가 `name=""` 로 끝난다. 증상은
|
||||
> `XML error: Cannot use host name '' in network 'default'` 다. 세 줄을 값
|
||||
> 그대로 쓰는 편이 안전하다.
|
||||
|
||||
`--live --config` 둘 다 준다. `--live` 만 주면 재부팅에 사라지고, `--config`
|
||||
만 주면 지금 반영되지 않는다.
|
||||
|
||||
**확인** — 예약이 실제로 들어갔는가
|
||||
|
||||
```bash
|
||||
virsh net-dumpxml default | grep -E "host mac|range start"
|
||||
```
|
||||
|
||||
**실측**
|
||||
|
||||
```
|
||||
<range start='192.168.122.2' end='192.168.122.254'/>
|
||||
<host mac='52:54:00:aa:bb:11' name='kc-lab-1' ip='192.168.122.11'/>
|
||||
<host mac='52:54:00:aa:bb:12' name='kc-lab-2' ip='192.168.122.12'/>
|
||||
<host mac='52:54:00:aa:bb:10' name='kc-lab-edge' ip='192.168.122.10'/>
|
||||
```
|
||||
|
||||
넣을 때 나오는 한 줄은 이것이다.
|
||||
|
||||
```
|
||||
Updated network default persistent config and live state
|
||||
```
|
||||
|
||||
**이미 있는 예약을 또 넣으면 이렇게 거부된다.** 오류처럼 보이지만 **이미
|
||||
들어가 있다는 뜻**이라 그냥 넘어가면 된다.
|
||||
|
||||
```
|
||||
error: Requested operation is not valid: there is an existing dhcp host entry
|
||||
in network 'default' that matches "<host mac='52:54:00:aa:bb:12' ... />"
|
||||
```
|
||||
|
||||
> **`grep ip-dhcp-host` 로 확인하지 않는다.** `ip-dhcp-host` 는
|
||||
> `net-update` 의 **섹션 이름**이지 XML 안에 있는 문자열이 아니다. 그렇게
|
||||
> 치면 예약이 멀쩡히 들어가 있어도 **아무것도 안 나오고**, 예약이 안
|
||||
> 들어갔다고 오독하게 된다. XML 안의 실제 요소는 `<host mac=...>` 다.
|
||||
|
||||
`persistent config` 와 `live state` **두 마디가 다 나와야** `--live --config`
|
||||
가 제대로 먹은 것이다.
|
||||
|
||||
**어디를 봐야 하는가** — `<host>` 세 줄의 **MAC 끝 두 자리와 IP 끝 숫자가
|
||||
짝이 맞는가**(`:10` ↔ `.10`, `:11` ↔ `.11`, `:12` ↔ `.12`). 이 MAC 을 5번의
|
||||
`virt-install --network mac=` 에 **한 글자도 다르지 않게** 쓴다. `<range>`
|
||||
줄은 예약이 아니라 동적 대역이라, 예약 IP 가 이 안에 들어 있어도 상관없다.
|
||||
|
||||
**이 결과가 의미하는 것** — 세 줄이 다 보이면 게스트는 재부팅해도 같은 IP 를
|
||||
받는다. 빠진 줄이 있으면 `--live --config` 중 하나를 빠뜨린 것이다.
|
||||
`net-dumpxml` 은 **지금 돌고 있는 정의**를 보여 주므로 여기 보이는 것은
|
||||
`--live` 가 먹었다는 뜻이고, 재부팅 뒤에도 남는지는
|
||||
`virsh net-dumpxml --inactive default` 로 따로 본다.
|
||||
|
||||
## 5. VM 을 만든다
|
||||
|
||||
**하기**
|
||||
|
||||
```bash
|
||||
virt-install --name kc-lab-1 --memory 5120 --vcpus 2 \
|
||||
--disk size=20,backing_store=/var/lib/libvirt/images/base.qcow2 \
|
||||
--disk vol=default/seed-kc-lab-1.iso,device=disk,bus=virtio,readonly=on \
|
||||
--network network=default,mac=52:54:00:aa:bb:11 \
|
||||
--import --os-variant debian12 --noautoconsole
|
||||
```
|
||||
|
||||
나머지 둘은 **이름·메모리·MAC·시드**만 바꾼다.
|
||||
|
||||
```bash
|
||||
# kc-lab-2 — k3s agent
|
||||
virt-install --name kc-lab-2 --memory 4096 --vcpus 2 \
|
||||
--disk size=20,backing_store=/var/lib/libvirt/images/base.qcow2 \
|
||||
--disk vol=default/seed-kc-lab-2.iso,device=disk,bus=virtio,readonly=on \
|
||||
--network network=default,mac=52:54:00:aa:bb:12 \
|
||||
--import --os-variant debian12 --noautoconsole
|
||||
|
||||
# kc-lab-edge — nginx + certbot 만 돌므로 훨씬 작아도 된다
|
||||
virt-install --name kc-lab-edge --memory 1024 --vcpus 1 \
|
||||
--disk size=10,backing_store=/var/lib/libvirt/images/base.qcow2 \
|
||||
--disk vol=default/seed-kc-lab-edge.iso,device=disk,bus=virtio,readonly=on \
|
||||
--network network=default,mac=52:54:00:aa:bb:10 \
|
||||
--import --os-variant debian12 --noautoconsole
|
||||
```
|
||||
|
||||
**실측** — 엣지 생성 출력이다.
|
||||
|
||||
```
|
||||
Starting install...
|
||||
Allocating 'kc-lab-edge.qcow2' | 10 GB 00:00
|
||||
Creating domain... | 00:00
|
||||
Domain creation completed.
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `Domain creation completed.` 한 줄. 그 위
|
||||
`Allocating` 이 **즉시(00:00) 끝나는 것이 정상**이다 — 오버레이라 10GB 를
|
||||
실제로 쓰지 않는다.
|
||||
|
||||
**★ 시드를 `--cloud-init` 으로 붙이지 않는다.** 그 옵션은 시드를 SATA CD-ROM
|
||||
으로 붙이는데, Debian `genericcloud` 이미지는 크기를 줄이려고 물리 하드웨어
|
||||
드라이버를 뺐다. **AHCI 장치가 보이지 않아** cloud-init 이 데이터소스를
|
||||
못 찾고 조용히 끝난다. `bus=virtio` 로 디스크로 붙여야 한다.
|
||||
|
||||
`--disk size=20,backing_store=...` 는 복사가 아니라 **오버레이**다. base 는
|
||||
읽기 전용으로 두고 변경분만 새 파일에 쌓이므로, 20GB 를 두 개 만들어도
|
||||
실제 디스크는 몇백 MB 만 쓴다.
|
||||
|
||||
## 6. 붙어 본다
|
||||
|
||||
**확인** — 떴는가, 그리고 cloud-init 이 제 일을 했는가
|
||||
|
||||
```bash
|
||||
virsh list --all
|
||||
ssh donghyeon@192.168.122.11 'hostname; cat /etc/os-release | head -1'
|
||||
```
|
||||
|
||||
**실측**
|
||||
|
||||
```
|
||||
Id Name State
|
||||
-----------------------------
|
||||
2 kc-lab-1 running
|
||||
4 kc-lab-2 running
|
||||
5 kc-lab-edge running
|
||||
|
||||
kc-lab-1
|
||||
PRETTY_NAME="Debian GNU/Linux 12 (bookworm)"
|
||||
```
|
||||
|
||||
엣지도 같은 방법으로 본다. `cloud-init status` 까지 한 번에 친다.
|
||||
|
||||
```bash
|
||||
ssh kc-lab-edge 'hostname; ip -4 -br addr show enp1s0; cloud-init status'
|
||||
```
|
||||
|
||||
**실측**
|
||||
|
||||
```
|
||||
kc-lab-edge
|
||||
enp1s0 UP 192.168.122.10/24 metric 100
|
||||
status: done
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 세 줄이다. 호스트명이 `kc-lab-edge` 인가, IP 가
|
||||
**예약한 `.10`** 인가, `cloud-init status` 가 `done` 인가. `running` 이면
|
||||
아직 패키지를 받는 중이니 기다린다 — 이 실험대에서는 **약 50초** 걸렸다.
|
||||
`error` 면 `cloud-init status --long` 으로 어느 모듈이 실패했는지 본다.
|
||||
|
||||
**어디를 봐야 하는가** — 세 가지다. ① State 가 두 대 다 `running` 인가
|
||||
(`shut off` 면 아직 안 뜬 것이고, Id 칸이 `-` 로 비어 있다). ② SSH 가
|
||||
**비밀번호를 묻지 않고** 통과했는가. ③ `hostname` 이 `kc-lab-1` 인가
|
||||
`localhost` 인가.
|
||||
|
||||
**이 결과가 의미하는 것** — 이 세 줄이 cloud-init 성공의 판정 기준 전부다.
|
||||
호스트명이 바뀌어 있다는 것은 시드가 읽혔다는 뜻이고, 시드가 읽혔으면
|
||||
같은 파일에 있던 SSH 키도 들어갔다는 뜻이다. **`localhost` 가 나오면 SSH
|
||||
설정을 고치지 말고 시드부터 의심한다** — 아래 「막히면」의 화면 캡처로 간다.
|
||||
Id 번호가 2 보다 큰 것은 아무 뜻도 없다(만들고 지운 이력일 뿐이다).
|
||||
|
||||
---
|
||||
|
||||
## 막히면
|
||||
|
||||
여기서 실제로 겪은 것들이다.
|
||||
|
||||
| 증상 | 원인 | 확인 |
|
||||
| ----------------------------------------------------------------- | ------------------------------------------------------------------------- | ----------------------- |
|
||||
| SSH`Permission denied (publickey)` · hostname 이 `localhost` | **cloud-init 이 안 돌았다.** 시드를 SATA 로 붙였거나 YAML 파싱 실패 | 아래 화면 캡처 |
|
||||
| user-data 를 고쳤는데 반영 안 됨 | `instance-id` 가 같아 건너뜀 | meta-data 의 id 확인 |
|
||||
| IP 가 매번 바뀐다 | DHCP 예약이`--config` 없이 들어감 | `net-dumpxml` |
|
||||
| VM 이 느리다 | KVM 미사용 | [00](../00-lab-host/) 로 |
|
||||
|
||||
게스트에 못 들어갈 때는 **화면을 직접 뜬다.**
|
||||
|
||||
```bash
|
||||
virsh screenshot kc-lab-1 /tmp/kc1.ppm # 확장자와 무관하게 PNG 로 저장된다
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 이미지를 열어 **로그인 프롬프트 앞의 호스트명 한
|
||||
낱말**만 본다. `localhost login:` 인가 `kc-lab-1 login:` 인가.
|
||||
|
||||
**이 결과가 의미하는 것** — `localhost` 면 cloud-init 이 아예 안 돌았다.
|
||||
시드를 못 찾은 것(5번의 `bus=virtio`)이거나 YAML 파싱 실패(2번)이므로 SSH
|
||||
쪽은 볼 필요가 없다. `kc-lab-1` 이면 cloud-init 은 돌았고 **그 안의 사용자·키
|
||||
단계에서 틀린** 것이라, 이제 콘솔로 들어가 게스트 안의 로그를 본다.
|
||||
|
||||
```bash
|
||||
virsh console kc-lab-1 # 빠져나오려면 Ctrl+]
|
||||
# 게스트 안에서
|
||||
sudo cloud-init status --long
|
||||
sudo journalctl -u cloud-init -n 50
|
||||
```
|
||||
|
||||
콘솔 로그인에 쓸 비밀번호가 2번의 `plain_text_passwd` 다. **이 한 장과 이 두
|
||||
줄이 「SSH 가 안 되는 이유」를 절반으로 줄인다.**
|
||||
|
||||
---
|
||||
|
||||
## 실측값
|
||||
|
||||
```
|
||||
kc-lab-1 vCPU 2 메모리 5120MB 192.168.122.11
|
||||
kc-lab-2 vCPU 2 메모리 4096MB 192.168.122.12
|
||||
게스트 OS Debian GNU/Linux 12 (bookworm)
|
||||
```
|
||||
|
||||
> 메모리가 처음 만들 때(3584MB)와 다르다. 호스트가 12GB 뿐이라 실험을 늘리며
|
||||
> 재배분했다. VM 을 다시 만들지 않고 바꾸는 방법은
|
||||
> [`session-lab-concepts.md`](../../session-lab-concepts.md) 13층에 있다.
|
||||
@@ -0,0 +1,407 @@
|
||||
# 02 — k3s 두 노드
|
||||
|
||||
## 이 단계가 끝나면
|
||||
|
||||
lab host 에서 `kubectl get nodes` 를 치면 두 노드가 `Ready` 로 나온다.
|
||||
`sudo` 도 `ssh` 도 붙이지 않는다.
|
||||
|
||||
## 전제
|
||||
|
||||
[01](../01-vms/) 이 끝나 lab host 에서 두 게스트에 SSH 가 붙는다.
|
||||
|
||||
## 어디서 치는가
|
||||
|
||||
**이 단계는 전부 `[lab host]` 에서 친다.** 게스트에 로그인하지 않는다.
|
||||
자세한 이유는 [가이드 공통 규약](../README.md#어느-기계에서-치는가) 에 있고,
|
||||
요점만 옮기면 이렇다.
|
||||
|
||||
- 게스트에는 lab host 의 개인키도 `~/.ssh/config` 도 없다. 게스트 안에서
|
||||
`ssh kc-lab-1` 을 치면 `Host key verification failed` 로 끝난다.
|
||||
- 그 실패를 `TOKEN=$(...)` 로 감싸면 **오류는 화면으로 새고 변수는 빈 채로**
|
||||
남는다. 셸은 불평하지 않는다.
|
||||
- 그래서 3번의 토큰이 비고, 4번의 agent 설치가 `--token is required` 로
|
||||
죽는다. 설치 스크립트는 그 전까지를 다 성공으로 찍고 끝나기 때문에
|
||||
**설치 출력만 보면 성공으로 읽힌다.**
|
||||
|
||||
셸을 하나만 쓰면 이 문제가 통째로 없어진다.
|
||||
|
||||
---
|
||||
|
||||
## 1. server 를 깐다 (kc-lab-1)
|
||||
|
||||
**하기** — `[lab host]`
|
||||
|
||||
```bash
|
||||
ssh kc-lab-1 'curl -sfL https://get.k3s.io | sudo sh -s - server --node-ip 192.168.122.11'
|
||||
```
|
||||
|
||||
게스트에 들어가지 않고 원격 실행한다. cloud-init 이 `NOPASSWD:ALL` 을
|
||||
넣어 두었으므로 비대화식 `sudo` 가 멈추지 않는다.
|
||||
|
||||
`--node-ip` 를 준다. 게스트에 인터페이스가 여럿이면 k3s 가 엉뚱한 것을 고를 수
|
||||
있고, 그러면 두 노드가 서로를 다른 주소로 알게 된다.
|
||||
|
||||
**확인** — 서버가 떴고 자기 자신을 노드로 등록했는가
|
||||
|
||||
```bash
|
||||
ssh kc-lab-1 'sudo systemctl is-active k3s; sudo kubectl get nodes'
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 유닛이 `active` 인가, 그리고 `get nodes` 에
|
||||
`kc-lab-1` 한 줄이 `Ready` 로 있는가. **설치 직후 30초 남짓은 `NotReady`
|
||||
이거나 아예 목록이 비어 있는 것이 정상이다** — CNI 가 아직 안 올라온
|
||||
시간이다. 한 번 더 친다.
|
||||
|
||||
이 가이드에서 `kubectl` 을 게스트 쪽에서 돌리는 것은 여기 한 번뿐이다.
|
||||
lab host 에는 아직 kubeconfig 가 없기 때문이고, 그것을 두는 것이 바로 2번이다.
|
||||
|
||||
**이 결과가 의미하는 것** — `active` + `Ready` 면 API 서버가 살아 있고
|
||||
kubeconfig 도 자리를 잡았다는 뜻이라 2번으로 간다. 유닛이 `active` 인데
|
||||
`get nodes` 가 접속 오류를 내면 API 서버가 아직 기동 중이다. 유닛이
|
||||
`activating` 에서 안 넘어가거나 `failed` 면 설치 자체가 실패한 것이니
|
||||
로그를 본다.
|
||||
|
||||
```bash
|
||||
ssh kc-lab-1 'sudo journalctl -u k3s -n 50 --no-pager'
|
||||
```
|
||||
|
||||
## 2. lab host 에 kubeconfig 를 둔다
|
||||
|
||||
**왜 여기서 하나** — 이 뒤로 `kubectl` 을 계속 쓴다. 지금 한 번 해두면
|
||||
남은 단계에서 `ssh` 도 `sudo` 도 붙이지 않는다. **그리고 agent 노드에는
|
||||
kubeconfig 가 없으므로**(6번) 클러스터를 볼 자리를 먼저 정해 두는 편이 낫다.
|
||||
|
||||
**하기** — `[lab host]`
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.kube
|
||||
ssh kc-lab-1 'sudo cat /etc/rancher/k3s/k3s.yaml' \
|
||||
| sed 's|127.0.0.1|192.168.122.11|' > ~/.kube/config
|
||||
chmod 600 ~/.kube/config
|
||||
```
|
||||
|
||||
세 줄 다 필요하다.
|
||||
|
||||
| 줄 | 빠뜨리면 |
|
||||
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `mkdir -p ~/.kube` | `>` 는 파일을 열 뿐 경로를 만들지 않는다. `cat` 이 시작되기도 전에 `No such file or directory` 로 끝난다 |
|
||||
| `sed` | k3s 가 쓴 주소는`https://127.0.0.1:6443` 이다. **게스트 안에서만 맞는 주소**라 lab host 에서는 자기 자신의 6443 을 두드리게 된다 |
|
||||
| `chmod 600` | 이 파일은 클러스터 admin 자격증명이다. 비밀번호 파일과 같은 급으로 다룬다 |
|
||||
|
||||
> `sudo` 는 `cat` 에만 걸리고 `>` 에는 걸리지 않는다. 리다이렉션은 셸이
|
||||
> **명령보다 먼저** 현재 사용자 권한으로 처리하기 때문이다. 그래서 출력
|
||||
> 파일은 홈 아래(`~/.kube/config`)에 둔다.
|
||||
|
||||
**확인** — 주소가 바뀌었고, 밖에서 붙는가
|
||||
|
||||
```bash
|
||||
grep server: ~/.kube/config
|
||||
kubectl get nodes
|
||||
```
|
||||
|
||||
**실측**
|
||||
|
||||
```
|
||||
server: https://192.168.122.11:6443
|
||||
NAME STATUS ROLES AGE VERSION
|
||||
kc-lab-1 Ready control-plane 47m v1.36.4+k3s1
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `server:` 값에 `127.0.0.1` 이 남아 있으면 `sed` 가
|
||||
안 먹은 것이다. 그다음 `get nodes` 가 **`sudo` 없이** 도는가. 아직 노드는
|
||||
한 줄뿐인 것이 정상이다 — agent 는 4번에서 붙인다.
|
||||
|
||||
**이 결과가 의미하는 것** — 통과하면 이 뒤의 `kubectl` 은 전부 lab host 에서
|
||||
친다. `x509` 오류가 나면 파일 안의 CA 와 서버가 지금 쓰는 CA 가 어긋난
|
||||
것이다 — k3s 를 다시 깔았다면 이 복사도 다시 해야 한다. `connection refused`
|
||||
면 주소는 맞는데 API 서버가 아직 안 뜬 것이다.
|
||||
|
||||
> **인증서 SAN 에 두 주소가 다 들어 있어서** 이 `sed` 가 통한다. 확인하려면
|
||||
> `ssh kc-lab-1 'sudo openssl x509 -in /var/lib/rancher/k3s/server/tls/serving-kube-apiserver.crt -noout -ext subjectAltName'`
|
||||
> 을 친다. `IP Address:127.0.0.1` 과 `IP Address:192.168.122.11` 이 둘 다
|
||||
> 보인다. 8번의 SSH 터널이 `127.0.0.1` 로 붙을 수 있는 것도 같은 이유다.
|
||||
|
||||
## 3. 토큰을 꺼낸다
|
||||
|
||||
**하기** — `[lab host]`. 화면에 찍어 눈으로 옮기지 말고 변수로 받는다
|
||||
|
||||
```bash
|
||||
TOKEN=$(ssh kc-lab-1 'sudo cat /var/lib/rancher/k3s/server/node-token')
|
||||
echo "${#TOKEN} 자" # 값이 아니라 길이만 확인한다
|
||||
```
|
||||
|
||||
**실측** — 이 실험대에서는 108자였다. (`K10<해시>::server:<비밀번호>` 형식이라
|
||||
k3s 판올림에 따라 자릿수가 달라진다. **중요한 것은 값이 아니라 `0` 이 아니라는
|
||||
것이다.**)
|
||||
|
||||
**어디를 봐야 하는가** — 찍히는 것은 **자릿수 하나뿐**이다. 값은 보지
|
||||
않는다 — 화면에 띄우는 순간 터미널 스크롤백과 셸 히스토리에 남는다.
|
||||
|
||||
**이 결과가 의미하는 것** — 세 자리 수가 나오면 토큰을 손에 쥔 것이니 4번으로
|
||||
넘어간다. `0` 이면 변수가 비었다는 뜻이고 원인은 셋 중 하나다.
|
||||
|
||||
| `0` 인 이유 | 확인 |
|
||||
| ------------------------------------------ | ---------------------------------------------------------------------- |
|
||||
| **게스트 안에서 쳤다** (가장 흔하다) | 프롬프트가`kc-lab-1` 이면 `exit` 로 lab host 로 나온다 |
|
||||
| server 가 아직 안 떠서 파일이 없다 | `ssh kc-lab-1 'sudo ls -l /var/lib/rancher/k3s/server/node-token'` |
|
||||
| 새 셸을 열어 변수가 사라졌다 | `echo "${#TOKEN} 자"` 를 **4번을 칠 바로 그 셸에서** 다시 친다 |
|
||||
|
||||
> **4번을 3번과 같은 셸에서 친다.** 변수는 셸 밖으로 나가지 않는다. 창을
|
||||
> 새로 열거나 `ssh` 로 어딘가 들어갔다 나오면 `TOKEN` 은 없다.
|
||||
|
||||
## 4. agent 를 붙인다 (kc-lab-2)
|
||||
|
||||
**하기** — `[lab host]`. 3번과 **같은 셸**에서 친다
|
||||
|
||||
```bash
|
||||
[ ${#TOKEN} -ge 50 ] || echo "TOKEN 이 비었다 — 3번으로 돌아간다"
|
||||
|
||||
ssh kc-lab-2 "curl -sfL https://get.k3s.io | sudo sh -s - agent \
|
||||
--server https://192.168.122.11:6443 \
|
||||
--token '$TOKEN' \
|
||||
--node-ip 192.168.122.12"
|
||||
```
|
||||
|
||||
첫 줄의 가드를 빼지 않는다. `$TOKEN` 이 비면 게스트에는
|
||||
`--token '' --node-ip ...` 가 전달되고, agent 는 기동 즉시
|
||||
`level=fatal msg="Error: --token is required"` 로 죽는다. **그런데 설치
|
||||
스크립트는 내려받기·유닛 생성·`enable` 까지 다 성공으로 찍고 끝나고**, 유닛은
|
||||
`Restart=always` 라 5초마다 조용히 재시도한다. 가드 한 줄이 그 몇 분을 막는다.
|
||||
|
||||
> 토큰을 셸 히스토리에 남기고 싶지 않으면 파일로 넘긴다. 이러면 변수를 쓰지
|
||||
> 않으므로 3번의 「같은 셸」 제약도 없어진다.
|
||||
>
|
||||
> ```bash
|
||||
> ssh kc-lab-1 'sudo cat /var/lib/rancher/k3s/server/node-token' \
|
||||
> | ssh kc-lab-2 'sudo tee /tmp/token >/dev/null'
|
||||
> ssh kc-lab-2 "curl -sfL https://get.k3s.io | sudo sh -s - agent \
|
||||
> --server https://192.168.122.11:6443 --token-file /tmp/token \
|
||||
> --node-ip 192.168.122.12; rm -f /tmp/token"
|
||||
> ```
|
||||
|
||||
**확인** — agent 가 클러스터에 들어왔는가, 그리고 **제 주소로** 들어왔는가
|
||||
|
||||
```bash
|
||||
kubectl get nodes -o wide
|
||||
```
|
||||
|
||||
**실측**
|
||||
|
||||
```
|
||||
NAME STATUS ROLES AGE VERSION INTERNAL-IP
|
||||
kc-lab-1 Ready control-plane 47m v1.36.4+k3s1 192.168.122.11
|
||||
kc-lab-2 Ready <none> 21m v1.36.4+k3s1 192.168.122.12
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `-o wide` 를 준 이유가 마지막 열이다. **INTERNAL-IP
|
||||
두 개가 1번·4번에서 `--node-ip` 로 준 값과 같은가.** 그다음이 STATUS 두 줄
|
||||
`Ready`, 그다음이 ROLES 열이다. `<none>` 은 오류가 아니라 **역할 라벨이
|
||||
없다**는 뜻이다 — agent 는 원래 그렇다.
|
||||
|
||||
**이 결과가 의미하는 것** — 두 줄이 `Ready` 이고 IP 가 맞으면 이 단계는 끝났다.
|
||||
IP 가 다른 대역(예: flannel 이나 다른 인터페이스 주소)으로 잡혀 있으면
|
||||
지금은 아무 증상이 없다가 **03 의 nginx upstream 과 A층의 노드 상실
|
||||
실험에서** 어긋난다 — 그때 고치는 것보다 지금 재설치가 싸다. `kc-lab-2` 가
|
||||
아예 안 보이면 join 이 실패한 것이니 agent 쪽 로그를 본다.
|
||||
|
||||
```bash
|
||||
ssh kc-lab-2 'sudo journalctl -u k3s-agent -n 30 --no-pager'
|
||||
```
|
||||
|
||||
## 5. 유닛 이름이 다르다
|
||||
|
||||
| 노드 | 유닛 |
|
||||
| ------ | --------------------- |
|
||||
| server | `k3s.service` |
|
||||
| agent | `k3s-agent.service` |
|
||||
|
||||
**확인** — 어느 노드가 무슨 이름으로, 어떤 인자로 돌고 있는가
|
||||
|
||||
```bash
|
||||
ssh kc-lab-1 'systemctl cat k3s | grep -A3 ExecStart='
|
||||
ssh kc-lab-2 'systemctl cat k3s-agent | grep -A4 ExecStart='
|
||||
```
|
||||
|
||||
**실측**
|
||||
|
||||
```
|
||||
ExecStart=/usr/local/bin/k3s server '--node-ip' '192.168.122.11'
|
||||
ExecStart=/usr/local/bin/k3s agent '--node-ip' '192.168.122.12'
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `ExecStart=` 줄의 **부분명령(`server`/`agent`)과
|
||||
그 뒤의 인자**. 설치 스크립트에 준 옵션이 여기 그대로 굳어 있다. 유닛 이름을
|
||||
틀리면(`systemctl cat k3s` 를 agent 노드에서) `No files found` 가 나오는데,
|
||||
그것 자체가 「이 노드는 agent 다」라는 답이다.
|
||||
|
||||
**이 결과가 의미하는 것** — 4번의 `get nodes -o wide` 는 **k3s 가 보고한**
|
||||
IP 이고, 이 줄은 **우리가 준** IP 다. 둘이 다르면 옵션이 안 먹은 것이다.
|
||||
그리고 뒤의 실험에서 노드를 멈출 때 칠 유닛 이름이 노드마다 다르다는 것을
|
||||
여기서 확인해 둔다 — `systemctl stop k3s` 를 agent 노드에서 치면 아무 일도
|
||||
일어나지 않고, 「주입했는데 증상이 없다」로 오독하게 된다.
|
||||
|
||||
> 이 차이가 A-4 에서 결과를 갈랐다. server 노드를 잃으면 `kubectl` 자체가
|
||||
> 불통이 되고, agent 를 잃으면 `kubectl` 은 되지만 그 위의 워크로드가 사라진다.
|
||||
|
||||
## 6. agent 노드에서는 `kubectl` 이 안 된다
|
||||
|
||||
`kc-lab-2` 에 들어가 `sudo kubectl get pods -A` 를 치면 이렇게 끝난다.
|
||||
|
||||
```
|
||||
Get "http://localhost:8080/api?timeout=32s": dial tcp [::1]:8080: connect: connection refused
|
||||
```
|
||||
|
||||
**`kubectl` 명령 자체는 있다.** 설치 스크립트가
|
||||
`/usr/local/bin/kubectl -> k3s` 심볼릭 링크를 만들기 때문이다. 없는 것은
|
||||
**붙을 곳을 알려 주는 파일**, 곧 kubeconfig 다.
|
||||
|
||||
| 어디를 찾나 | kc-lab-1 | kc-lab-2 |
|
||||
| ----------------------------- | -------------- | -------------- |
|
||||
| `$KUBECONFIG` | (비어 있음) | (비어 있음) |
|
||||
| `~/.kube/config` | 없음 | 없음 |
|
||||
| `/etc/rancher/k3s/k3s.yaml` | **있음** | **없음** |
|
||||
|
||||
넷 다 못 찾으면 kubectl 은 오류를 내지 않고 하드코딩된 기본값
|
||||
`http://localhost:8080` 으로 넘어간다. 쿠버네티스 1.20 이전 API 서버가
|
||||
평문으로 열던 레거시 포트인데 지금은 아무도 열지 않는다.
|
||||
|
||||
> **`localhost:8080` 이 보이면 네트워크 문제가 아니라 「설정을 하나도 못
|
||||
> 찾았다」는 뜻이다.** 이 주소는 어디에도 적혀 있지 않다. 방화벽이나 k3s 를
|
||||
> 의심하기 전에 kubeconfig 부터 본다.
|
||||
|
||||
**워커라서 파드가 안 보이는 것이 아니다.** kubectl 은 그냥 HTTP 클라이언트라
|
||||
어디서 실행하든 상관없다. `k3s.yaml` 을 kc-lab-2 로 복사해 넣으면 거기서도
|
||||
전부 보인다. **그래도 복사하지 않는다** — 워커 한 대가 털리면 클러스터 전체가
|
||||
털리는 구성이 된다. agent 가 가진 자격증명은 급이 다르다.
|
||||
|
||||
```
|
||||
subject=O = system:nodes, CN = system:node:kc-lab-2
|
||||
```
|
||||
|
||||
이 신원은 Node authorizer 와 NodeRestriction admission 이 **자기 노드에
|
||||
배정된 객체만** 다루도록 제한한다. 게다가 그 자격증명은 kubelet 전용 경로
|
||||
(`/var/lib/rancher/k3s/agent/`)에 있어 kubectl 이 읽지도 않는다. 그래서 실패가
|
||||
「권한 없음(403)」이 아니라 「설정 없음(`localhost:8080`)」으로 나타난다.
|
||||
|
||||
**그래서 클러스터는 2번에서 만든 lab host 의 kubeconfig 로 본다.** 개념
|
||||
설명은 [`session-lab-concepts.md`](../../session-lab-concepts.md) 의
|
||||
「agent 노드에는 kubeconfig 가 없다」에 있다.
|
||||
|
||||
## 7. k3s 가 기본으로 딸려 오는 것
|
||||
|
||||
따로 설치하지 않아도 이미 있다.
|
||||
|
||||
| | 무엇 |
|
||||
| ---------------------- | -------------------------------------------- |
|
||||
| Traefik | 인그레스 컨트롤러.`:80` 을 듣는다 |
|
||||
| servicelb (klipper-lb) | LoadBalancer 타입을 호스트 포트로 매핑 |
|
||||
| local-path | 기본 StorageClass.**노드 로컬 디스크** |
|
||||
| flannel | 파드 네트워크 (VXLAN) |
|
||||
| kube-router | NetworkPolicy 집행 |
|
||||
|
||||
**확인** — `[lab host]`. 무엇이 이미 돌고 있고, 기본 저장소가 무엇인가
|
||||
|
||||
```bash
|
||||
kubectl get pods -A
|
||||
kubectl get storageclass
|
||||
```
|
||||
|
||||
**실측**
|
||||
|
||||
```
|
||||
NAMESPACE NAME READY STATUS RESTARTS AGE
|
||||
kube-system coredns-54996dc9b4-x5bsz 1/1 Running 0 49m
|
||||
kube-system helm-install-traefik-cnv8z 0/1 Completed 2 (48m ago) 48m
|
||||
kube-system helm-install-traefik-crd-c6vft 0/1 Completed 0 48m
|
||||
kube-system local-path-provisioner-77b9867795-5k8d2 1/1 Running 0 49m
|
||||
kube-system metrics-server-6dc596dfb8-nzwt2 1/1 Running 0 49m
|
||||
kube-system svclb-traefik-a18ee1fc-2s9rl 2/2 Running 0 48m
|
||||
kube-system svclb-traefik-a18ee1fc-xmrrt 2/2 Running 0 22m
|
||||
kube-system traefik-59b7647586-rsrbc 1/1 Running 0 48m
|
||||
|
||||
NAME PROVISIONER RECLAIMPOLICY VOLUMEBINDINGMODE ALLOWVOLUMEEXPANSION AGE
|
||||
local-path (default) rancher.io/local-path Delete WaitForFirstConsumer false 49m
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `get pods -A` 에서는 **NAMESPACE 열이 `kube-system`
|
||||
인 줄들의 STATUS**. `Running` 과 `Completed` 가 섞여 있는 것이 정상이다 —
|
||||
`helm-install-traefik-*` 는 일회성 잡이라 `Completed` 로 남는다.
|
||||
`svclb-traefik-*` 가 **두 줄**인 것도 봐 둔다. DaemonSet 이라 노드마다 하나씩
|
||||
뜨는 것이고, 이 두 줄이 4번의 join 이 실제로 먹었다는 또 하나의 증거다.
|
||||
`get storageclass` 에서는 이름 뒤의 **`(default)` 표시**가 어디 붙어 있는가.
|
||||
|
||||
**이 결과가 의미하는 것** — 여기 뜬 것들은 우리가 안 깔았는데 있는 것이고,
|
||||
뒤 단계에서 「왜 80 포트가 이미 잡혀 있지」·「왜 PVC 가 이 노드에만 묶이지」의
|
||||
답이 전부 이 목록에 있다. `local-path` 에 `(default)` 가 붙어 있으면
|
||||
05 의 PVC 는 StorageClass 를 안 적어도 이것으로 만들어진다. `Pending` 이나
|
||||
`CrashLoopBackOff` 가 섞여 있으면 그 파드부터 `describe` 로 본다.
|
||||
|
||||
> `local-path` 가 기본이라는 것이 A-4 에서 비용을 청구한다. PVC 가 **만들어진
|
||||
> 노드에 묶여** 다른 노드로 재배치되지 않는다.
|
||||
|
||||
## 8. 워크스테이션에서 쓰려면
|
||||
|
||||
**2번의 kubeconfig 를 그대로 가져와도 안 된다.** 게스트는 libvirt NAT 안에
|
||||
있어서 워크스테이션에서 `192.168.122.11` 로 가는 경로가 없다.
|
||||
|
||||
```bash
|
||||
[워크스테이션] $ ping -c1 192.168.122.11
|
||||
1 packets transmitted, 0 received, 100% packet loss
|
||||
```
|
||||
|
||||
lab host 를 거치는 터널을 뚫는다.
|
||||
|
||||
**하기** — `[워크스테이션]`
|
||||
|
||||
```bash
|
||||
# 1) 터널. 이 창은 열어 둔다
|
||||
ssh -N -L 6443:192.168.122.11:6443 test-server
|
||||
|
||||
# 2) 다른 창에서 — 게스트 원본을 그대로 가져온다. sed 가 필요 없다
|
||||
mkdir -p ~/.kube
|
||||
ssh test-server "ssh kc-lab-1 'sudo cat /etc/rancher/k3s/k3s.yaml'" > ~/.kube/kc-lab.yaml
|
||||
chmod 600 ~/.kube/kc-lab.yaml
|
||||
export KUBECONFIG=~/.kube/kc-lab.yaml
|
||||
```
|
||||
|
||||
**2번과 달리 `sed` 를 치지 않는다.** k3s 원본이 이미
|
||||
`https://127.0.0.1:6443` 이고, 터널 덕에 워크스테이션에서는 그 주소가 맞기
|
||||
때문이다. 2번이 `192.168.122.11` 로 바꿨던 것은 lab host 에서 볼 때
|
||||
`127.0.0.1` 이 lab host 자신을 가리키기 때문이었다. **같은 파일이라도 어느
|
||||
기계에서 읽느냐에 따라 맞는 주소가 다르다.**
|
||||
|
||||
**확인**
|
||||
|
||||
```bash
|
||||
grep server: ~/.kube/kc-lab.yaml
|
||||
kubectl get nodes
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `server:` 가 `https://127.0.0.1:6443` 인가. 그다음
|
||||
`get nodes` 가 4번과 **같은 두 줄**을 내놓는가.
|
||||
|
||||
**이 결과가 의미하는 것** — 터널 덕에 워크스테이션의 6443 이 `kc-lab-1` 의
|
||||
6443 이다. API 서버 인증서 SAN 에 `127.0.0.1` 이 들어 있어서(2번의 각주)
|
||||
인증서 검증도 통과한다. 타임아웃이면 1)의 터널 창이 닫힌 것이고,
|
||||
`connection refused` 면 터널은 살아 있는데 반대편 API 서버가 죽은 것이다.
|
||||
|
||||
> **터널이 닫히면 `kubectl` 이 통째로 멎는다.** 이 실험대의 A층 실험은 노드를
|
||||
> 죽이고 살리는 것이 목적이라, 터널 상태와 클러스터 상태가 섞여 오독하기
|
||||
> 쉽다. **A층 실험은 lab host 에서 치는 것을 권한다.**
|
||||
|
||||
---
|
||||
|
||||
## 막히면
|
||||
|
||||
| 증상 | 원인 | 확인 |
|
||||
| -------------------------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------- |
|
||||
| `echo "${#TOKEN} 자"` 가 `0` | 게스트 안에서 쳤거나, 셸이 바뀌었다 | 프롬프트가`kc-lab-1` 이 아닌지. 3번 표 |
|
||||
| agent 설치는 성공했는데 노드가 안 보임 | `--token` 이 빈 문자열 | `ssh kc-lab-2 'sudo journalctl -u k3s-agent -n 30 --no-pager'` 에 `--token is required` |
|
||||
| agent 가`NotReady` | 토큰·주소 오타 | `ssh kc-lab-2 'sudo journalctl -u k3s-agent -n 30 --no-pager'` |
|
||||
| 노드 IP 가 예상과 다름 | `--node-ip` 없이 설치 | `kubectl get nodes -o wide` |
|
||||
| lab host 에서`kubectl` 이 `No such file` | `mkdir -p ~/.kube` 를 빠뜨렸다 | 위 2번 |
|
||||
| lab host 에서`connection refused` | kubeconfig 의`127.0.0.1` 을 안 바꿨다 | `grep server: ~/.kube/config` |
|
||||
| `kc-lab-2` 에서 `localhost:8080` | agent 에는 kubeconfig 가 없다.**정상이다** | 위 6번 |
|
||||
| 워크스테이션에서 타임아웃 | 터널이 없다 | 위 8번 |
|
||||
| `x509` 오류 | k3s 재설치로 CA 가 바뀌었다 | 2번의 복사를 다시 한다 |
|
||||
| 파드가 한 노드에만 몰림 | 스케줄러 판단 | `topologySpreadConstraints` 로 강제 |
|
||||
@@ -0,0 +1,591 @@
|
||||
# 03 — 엣지 nginx 라우팅 (kc-lab-edge)
|
||||
|
||||
## 이 단계가 끝나면
|
||||
|
||||
밖에서 보낸 요청이 **호스트 DNAT → 엣지 nginx → Traefik → 파드**로 닿는다.
|
||||
아직 TLS 는 없다.
|
||||
|
||||
## 전제
|
||||
|
||||
[02](../02-k3s/) 가 끝나 두 노드가 `Ready` 이고, [01](../01-vms/) 에서
|
||||
`kc-lab-edge`(192.168.122.10) 까지 세 대가 떠 있다.
|
||||
|
||||
**엣지에 nginx 는 아직 없다.** cloud-init 이 까는 것은 `curl` 과 `nftables`
|
||||
뿐이라 0번에서 직접 깐다.
|
||||
|
||||
## 왜 프록시가 두 겹인가
|
||||
|
||||
nginx 와 Traefik 이 하는 일이 다르다.
|
||||
|
||||
| | 맡는 것 |
|
||||
|---|---|
|
||||
| 엣지 nginx (`kc-lab-edge`) | 바깥세상과의 접점 — TLS 종단 · 인증서 · `X-Forwarded-*` |
|
||||
| Traefik | 클러스터 안의 동적 라우팅 — Ingress 를 보고 서비스를 고른다 |
|
||||
|
||||
**이 2홉이 운영 구조와 같다는 것이 이 배치의 핵심**이고, 동시에 B-4 의 헤더
|
||||
실험이 성립하는 이유다. 1홉을 가정하고 쓴 계약이 2홉에서도 유효한지를
|
||||
재려면 두 겹이 있어야 한다.
|
||||
|
||||
## 왜 엣지가 물리 호스트가 아니라 VM 인가
|
||||
|
||||
**L7 홉 수는 그대로 2홉이다.** 늘어난 것은 커널이 하는 L4 전달 한 번뿐이다.
|
||||
|
||||
바뀐 것은 **더러워지는 층이 어디냐**다. nginx 설정 · 인증서 · certbot · deploy
|
||||
훅은 자주 고치고 자주 갈아엎는 것들인데, 그것이 물리 호스트에 있으면
|
||||
「깨끗하게 초기화하고 다시」가 불가능하다. 엣지가 VM 이면 초기화가
|
||||
`virsh undefine kc-lab-edge --remove-all-storage` 한 줄이 된다.
|
||||
|
||||
물리 호스트에 남는 실험대 설정은 **DNAT 규칙 하나와 DHCP 예약 세 줄**뿐이고,
|
||||
둘 다 한 번 쓰고 다시 안 건드린다.
|
||||
|
||||
덤으로 **엣지 장애를 실험할 수 있게 된다.** 엣지가 물리 호스트일 때는
|
||||
`systemctl stop nginx` 가 진입 경로(SSH)까지 위험하게 만들어서 A층 실험 9건
|
||||
어디에도 엣지 장애가 없었다. VM 이면 A-4 와 똑같이 `virsh destroy` 로 뽑는다.
|
||||
|
||||
---
|
||||
|
||||
## 0. nginx 가 깔려 있는지부터 본다
|
||||
|
||||
**cloud-init 이 깐 것은 `curl` 과 `nftables` 뿐이다** ([01](../01-vms/) 의
|
||||
`packages:` 줄). 엣지 VM 을 새로 만들었으면 **nginx 는 아직 없다.**
|
||||
|
||||
**하기** — `[kc-lab-edge]`
|
||||
```bash
|
||||
which nginx
|
||||
```
|
||||
|
||||
**관측** — 2026-09-11, 새로 만든 `kc-lab-edge` 에서 실제로 나온 것
|
||||
|
||||
```
|
||||
donghyeon@kc-lab-edge:~$ cd /etc/nginx/
|
||||
-bash: cd: /etc/nginx/: No such file or directory
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `which nginx` 가 **아무것도 안 찍으면** 미설치다.
|
||||
`/etc/nginx` 디렉터리는 패키지가 만든다. 그것이 없다는 것은 **경로를 잘못
|
||||
찾은 것이 아니라 설치가 안 된 것**이다.
|
||||
|
||||
**설치** — `[kc-lab-edge]`
|
||||
```bash
|
||||
sudo apt update && sudo apt install -y nginx
|
||||
```
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
systemctl status nginx --no-pager | head -5
|
||||
ls /etc/nginx/
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `Active:` 줄이 `active (running)` 인가, 그리고 `ls`
|
||||
결과에 **`sites-available` 과 `sites-enabled` 가 둘 다** 있는가. Debian 계열은
|
||||
설치와 동시에 기동까지 한다 — 따로 `systemctl start` 를 칠 일이 없다.
|
||||
|
||||
**이 결과가 의미하는 것** — 이 시점에 nginx 는 **이미 80 포트를 잡고 있다.**
|
||||
그것을 잡고 있는 것은 `sites-enabled/default` 이고, 1번에서 쓸 설정도
|
||||
`listen 80 default_server` 라 **그대로 두면 겹친다.**
|
||||
|
||||
**하기** — 기본 사이트를 끈다. `[kc-lab-edge]`
|
||||
```bash
|
||||
ls -l /etc/nginx/sites-enabled/
|
||||
sudo rm /etc/nginx/sites-enabled/default
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `ls -l` 의 화살표다. `default -> ../sites-available/
|
||||
default` 처럼 **심볼릭 링크**다. 지우는 것은 링크뿐이고 원본은
|
||||
`sites-available/default` 에 그대로 남는다 — 되돌리려면 `ln -s` 로 다시
|
||||
걸면 된다.
|
||||
|
||||
**이 결과가 의미하는 것** — 안 지우면 2번의 `nginx -t` 가
|
||||
`a duplicate default server for 0.0.0.0:80` 으로 막는다. 설정이 틀린 것이
|
||||
아니라 **기본 사이트와 겹친 것**이다.
|
||||
|
||||
**★ `sites-available` 은 복수형이다.** `site-available` 로 치면 `nano` 가
|
||||
군말 없이 **빈 새 파일을 연다.** 저장해도 nginx 는 그 파일을 영원히 안 읽고,
|
||||
`nginx -t` 는 멀쩡히 통과한다 — **아무 에러 없이 아무 일도 안 일어나는** 가장
|
||||
찾기 어려운 형태다. 설정을 썼는데 변화가 없으면 경로 오타부터 의심한다.
|
||||
|
||||
```bash
|
||||
ls /etc/nginx/sites-available/
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 방금 쓴 파일 이름이 **여기** 보이는가. 안 보이면
|
||||
다른 데다 썼다. 어디다 썼는지는 `sudo find /etc/nginx -name 'keycloak*'` 로
|
||||
찾는다.
|
||||
|
||||
> **Arch 호스트에는 이 구조가 없다.** `sites-available`/`sites-enabled` 는
|
||||
> Debian 패키징 관례다. Arch 의 nginx 는 `nginx.conf` 에 직접 쓰거나
|
||||
> `conf.d/` 를 쓴다. 이 실험대는 **운영과 맞추려고 Debian 게스트를 엣지로
|
||||
> 두었다** — 그래서 여기서는 Debian 관례가 그대로 통한다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 설정을 쓴다 — `[kc-lab-edge]`
|
||||
|
||||
**이 단계에서는 80 만 세운다.** TLS 는 [04](../04-tls/) 에서 얹는다.
|
||||
인증서가 없는데 `ssl_certificate` 줄을 미리 써 두면 **설정 전체가 실패해서
|
||||
80 블록까지 안 뜬다** — nginx 는 그 파일을 나중이 아니라 **기동·reload
|
||||
시점에** 읽기 때문이다.
|
||||
|
||||
**하기** — 편집기를 연다. `[kc-lab-edge]`
|
||||
```bash
|
||||
sudo nano /etc/nginx/sites-available/keycloak-lab
|
||||
```
|
||||
|
||||
내용은 이것이다. 저장은 `Ctrl+O` → `Enter`, 나가기는 `Ctrl+X`.
|
||||
|
||||
```nginx
|
||||
# file: /etc/nginx/sites-available/keycloak-lab
|
||||
upstream k3s_traefik {
|
||||
server 192.168.122.11:80;
|
||||
server 192.168.122.12:80;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80 default_server;
|
||||
server_name _;
|
||||
|
||||
location / {
|
||||
proxy_pass http://k3s_traefik;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Proto http;
|
||||
proxy_set_header X-Forwarded-Port 80;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `X-Forwarded-Proto` 가 `http` 다. **04 에서 `https`
|
||||
로 바꾼다.** 이 헤더는 뒷단에 「원래 요청이 무슨 스킴이었나」를 알려주는
|
||||
값이라, 실제로 80 으로 들어오는데 `https` 라고 적으면 Keycloak 이 리다이렉트
|
||||
주소를 `https://` 로 만들고 **로그인 도중에 끊긴다.** 거짓말하면 안 되는
|
||||
헤더다.
|
||||
|
||||
**활성화한다.** `[kc-lab-edge]`
|
||||
```bash
|
||||
sudo ln -sf /etc/nginx/sites-available/keycloak-lab /etc/nginx/sites-enabled/
|
||||
ls -l /etc/nginx/sites-enabled/
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `keycloak-lab ->` 링크가 생겼는가, 그리고 **`default`
|
||||
가 없는가**(0번에서 지웠다). 둘 다 `listen 80 default_server` 라 같이 있으면
|
||||
2번의 `nginx -t` 가 `duplicate default server` 로 막는다.
|
||||
|
||||
## 2. 문법을 보고 적용한다
|
||||
|
||||
**하기** — `[kc-lab-edge]`
|
||||
```bash
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
통과하면 이런 형태다. Debian 12 는 `[warn]` 한 줄을 늘 같이 내놓는다.
|
||||
|
||||
```
|
||||
nginx: [warn] could not build optimal types_hash, you should increase either
|
||||
types_hash_max_size: 1024 or types_hash_bucket_size: 64
|
||||
nginx: configuration file /etc/nginx/nginx.conf syntax is ok
|
||||
nginx: configuration file /etc/nginx/nginx.conf test is successful
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `nginx -t` 가 내놓는 **마지막 줄**이다. `syntax is
|
||||
ok` 와 `test is successful` 두 마디가 다 나와야 통과다. 앞에 나오는
|
||||
`[warn]` 줄(예: `types_hash_max_size`)은 통과를 막지 않는다 — 04 에서 이
|
||||
경고를 실패로 오독하는 일이 실제로 벌어지므로, 여기서 **경고와 오류를
|
||||
구분하는 눈**을 들여 둔다. 실패면 `[emerg]` 줄에 파일과 줄 번호가 찍힌다.
|
||||
|
||||
**이 결과가 의미하는 것** — 통과했으면 `&&` 뒤의 reload 가 이어서 돌고,
|
||||
`systemctl reload` 는 아무 말 없이 끝난다(무소식이 좋은 소식이다). 실패했으면
|
||||
`&&` 가 reload 를 **막아 준 것**이고, 지금 돌고 있는 nginx 는 옛 설정 그대로
|
||||
멀쩡하다. 설정이 깨진 상태에서 reload 하면 새 워커를 못 띄운다 — `-t` 를
|
||||
먼저 통과시키고 그때만 reload 하는 이유다.
|
||||
|
||||
reload 가 정말 반영됐는지는 워커가 갈렸는지로 본다. 같은 판정 방법을
|
||||
04 에서 인증서 갱신에 그대로 쓴다.
|
||||
|
||||
```bash
|
||||
systemctl status nginx --no-pager | head -20
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `Active:` 줄이 `active (running)` 인가, 그리고 그
|
||||
아래 프로세스 트리에 `nginx: master process` 하나와 `nginx: worker process`
|
||||
여럿이 붙어 있는가. 워커 줄의 **PID** 를 눈에 담아 둔다.
|
||||
|
||||
**이 결과가 의미하는 것** — reload 는 마스터를 그대로 두고 **워커만** 갈아
|
||||
끼운다. 그래서 reload 전후로 워커 PID 가 바뀌면 새 설정이 실제로 적용된
|
||||
것이고, 안 바뀌었으면 `-t` 는 통과했는데 reload 가 안 간 것이다.
|
||||
04 에서 인증서 갱신이 서빙까지 닿았는지를 정확히 이 방법으로 판정한다.
|
||||
|
||||
## 3. 호스트에서 엣지로 넘긴다 (DNAT) — `[lab host]`
|
||||
|
||||
여기까지 하면 엣지 nginx 는 살아 있는데 **아무도 거기로 안 보낸다.** tailnet
|
||||
주소 `100.83.212.4:443` 을 받는 것은 여전히 물리 호스트다. 그 트래픽을
|
||||
엣지로 넘기는 것이 이 단계고, **물리 호스트가 실험대를 위해 하는 일의
|
||||
전부**다.
|
||||
|
||||
**★ 이 단계만 엣지가 아니라 물리 호스트에서 친다.** 0~2 번은
|
||||
`[kc-lab-edge]` 였다. 여기서 바뀌는 이유는 두 가지다.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **`tailscale0` 이 호스트에만 있다** | 규칙의 첫 줄이 `iifname "tailscale0"` 이다. **VM 에는 Tailscale 을 넣지 않기로 했으므로** 엣지에는 그 인터페이스 자체가 없다 |
|
||||
| **엣지는 받는 쪽이다** | 넘기는 것은 `192.168.122.10` **으로** 가는 트래픽이다. 넘기는 주체는 그 앞에 있는 호스트다 |
|
||||
|
||||
엣지에 SSH 해서 치면 `tailscale0` 이 없어 규칙이 의미가 없다.
|
||||
|
||||
**하기** — 규칙 파일을 쓴다. `[lab host]`
|
||||
```bash
|
||||
sudo mkdir -p /etc/nftables.d
|
||||
sudo nano /etc/nftables.d/lab-edge-dnat.nft
|
||||
```
|
||||
|
||||
```
|
||||
# file: /etc/nftables.d/lab-edge-dnat.nft
|
||||
#!/usr/sbin/nft -f
|
||||
table ip lab_edge
|
||||
delete table ip lab_edge
|
||||
|
||||
table ip lab_edge {
|
||||
chain prerouting {
|
||||
type nat hook prerouting priority dstnat; policy accept;
|
||||
iifname "tailscale0" tcp dport { 80, 443 } dnat to 192.168.122.10
|
||||
}
|
||||
|
||||
}
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 맨 위의 `table ip lab_edge` 와
|
||||
`delete table ip lab_edge` 두 줄, 그리고 **포트 숫자**다.
|
||||
|
||||
- **두 줄짜리 관용구** — 없는 테이블을 지우면 에러이므로 한 번 만들고 지운다.
|
||||
이래야 같은 파일을 몇 번 적용해도 안전하다.
|
||||
- **`443` 을 `433` 으로 치지 않는다.** `433` 도 유효한 포트라 nft 가 군말 없이
|
||||
받는다. 80 은 멀쩡히 넘어가므로 3·4번은 다 통과하고, **04 에서 HTTPS 만
|
||||
안 되는 형태로 뒤늦게 터진다.** 이 실험대에서 실제로 나왔던 오타다.
|
||||
|
||||
**하기** — 유닛 파일을 쓴다. `[lab host]`
|
||||
```bash
|
||||
sudo nano /etc/systemd/system/lab-edge-dnat.service
|
||||
```
|
||||
|
||||
```ini
|
||||
# file: /etc/systemd/system/lab-edge-dnat.service
|
||||
[Unit]
|
||||
Description=Lab edge DNAT (tailnet :80/:443 -> kc-lab-edge)
|
||||
After=network-online.target libvirtd.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
ExecStart=/usr/sbin/nft -f /etc/nftables.d/lab-edge-dnat.nft
|
||||
ExecStartPost=-/usr/sbin/nft insert rule ip libvirt_network guest_input oif virbr0 ip daddr 192.168.122.10 tcp dport {80,443} ct state new counter accept
|
||||
ExecStop=/usr/sbin/nft delete table ip lab_edge
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
**★ 경로를 끝까지 친다 — `/etc/systemd/` 가 아니라 `/etc/systemd/system/`.**
|
||||
`nano` 는 없는 파일이면 **말없이 새로 만든다.** 그래서 한 단계 위에 만들어도
|
||||
아무 경고가 없고, systemd 는 거기를 유닛 디렉터리로 읽지 않아
|
||||
`Unit lab-edge-dnat.service does not exist` 만 반복된다. 이 실험대에서 실제로
|
||||
겪은 형태다 — `/etc/systemd/` 는 `journald.conf` 같은 **systemd 자체 설정**이
|
||||
사는 곳이다.
|
||||
|
||||
**확인** — 두 파일이 제자리에 있는지 본다. `[lab host]`
|
||||
```bash
|
||||
ls -l /etc/nftables.d/lab-edge-dnat.nft /etc/systemd/system/lab-edge-dnat.service
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **두 줄이 다 나와야 한다.** 한 줄이라도 `No such
|
||||
file` 이면 다음 명령은 무조건 실패한다. systemctl 이 이상한 것이 아니라
|
||||
**파일이 없는 것**이다.
|
||||
|
||||
**적용한다.** `[lab host]`
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now lab-edge-dnat.service
|
||||
```
|
||||
|
||||
**★ `daemon-reload` 를 빠뜨리지 않는다.** 유닛 파일을 새로 써도 systemd 는
|
||||
다시 읽기 전까지 모른다. 파일은 제자리에 있는데 `does not exist` 가 나오면
|
||||
이것이다.
|
||||
|
||||
규칙의 알맹이는 두 줄이고, **둘이 사는 곳이 다르다.**
|
||||
|
||||
| 하는 일 | 어디에 |
|
||||
|---|---|
|
||||
| `iifname "tailscale0" tcp dport { 80, 443 } dnat to 192.168.122.10` | 우리 테이블 `lab_edge` (`.nft` 파일) |
|
||||
| `oif virbr0 ip daddr 192.168.122.10 … ct state new accept` | **libvirt 테이블 `libvirt_network` 의 `guest_input` 체인** (유닛의 `ExecStartPost`) |
|
||||
|
||||
**★ SNAT 을 걸지 않는다.** 게스트의 기본 게이트웨이가 호스트라 응답은
|
||||
어차피 여기로 돌아오고, conntrack 이 알아서 되돌린다. masquerade 를 붙이면
|
||||
출발지가 덮여서 **엣지가 모든 클라이언트를 `192.168.122.1` 로 본다** — 이
|
||||
실험대가 재고 있는 `X-Forwarded-For` 계약이 통째로 무의미해진다.
|
||||
|
||||
**★ 두 번째 줄이 왜 libvirt 테이블 안으로 들어가나 — 여기가 이 단계에서
|
||||
가장 많이 막히는 곳이다.**
|
||||
|
||||
libvirt 는 게스트 대역으로 **새로 들어오는 연결을 거절**한다. 자기 테이블
|
||||
`libvirt_network` 의 `guest_input` 체인이 이렇게 끝난다.
|
||||
|
||||
```
|
||||
oif "virbr0" ip daddr 192.168.122.0/24 ct state established,related accept
|
||||
oif "virbr0" reject ← 여기서 죽는다
|
||||
```
|
||||
|
||||
**우리 테이블에 아무리 먼저 `accept` 를 놔도 소용이 없다.** nftables 는
|
||||
앞 체인의 `accept` 가 **뒤 체인의 `reject` 를 막아 주지 않는다** — 여러 base
|
||||
체인이 같은 훅에 붙어 있으면 전부 평가된다. iptables 감각으로 쓰면 여기서
|
||||
정확히 틀린다.
|
||||
|
||||
그래서 구멍은 **libvirt 체인 맨 앞에** 뚫는다. `insert` 가 체인 맨 앞에
|
||||
넣는다는 점이 핵심이다(`add` 는 맨 뒤 = reject 뒤 = 의미 없음).
|
||||
|
||||
**손으로 한 번 넣어 볼 때** — `[lab host]`
|
||||
```bash
|
||||
sudo nft insert rule ip libvirt_network guest_input oif virbr0 ip daddr 192.168.122.10 tcp dport '{80,443}' ct state new counter accept
|
||||
```
|
||||
|
||||
**★ `'{80,443}'` 의 따옴표를 빼지 않는다.** bash·zsh 가 중괄호를
|
||||
**`80 443` 두 낱말로 펼쳐** 버려서 `Error: syntax error, unexpected ct` 가
|
||||
난다. 규칙은 안 들어갔는데 에러만 보고 넘기기 쉽다.
|
||||
|
||||
**확인** — 새 규칙이 `reject` **위**에 있는가
|
||||
```bash
|
||||
sudo nft -a list chain ip libvirt_network guest_input
|
||||
```
|
||||
|
||||
**이 규칙은 휘발성이다.** libvirt 가 네트워크를 다시 세우면(호스트 재부팅,
|
||||
`virsh net-start`, libvirtd 재시작) `guest_input` 을 새로 쓰면서 날아간다.
|
||||
그래서 유닛의 `ExecStartPost` 에 넣어 두고, 날아갔으면
|
||||
`sudo systemctl restart lab-edge-dnat.service` 로 다시 넣는다.
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo nft list table ip lab_edge
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `dnat to 192.168.122.10` 한 줄이 있는가,
|
||||
**포트가 `80, 443` 인가**, 그리고 **`masquerade` 나 `snat` 이 없는가.**
|
||||
|
||||
**★ 포트 숫자를 꼭 눈으로 읽는다.** `443` 을 `433` 으로 치면 nft 는 군말 없이
|
||||
받는다(유효한 포트 번호다). 80 은 멀쩡히 넘어가므로 3·4번은 다 통과하고,
|
||||
**04 에서 HTTPS 만 안 되는 형태로 뒤늦게 터진다.** 이 실험대에서 실제로
|
||||
나왔던 오타다.
|
||||
|
||||
**이 결과가 의미하는 것** — PREROUTING nat 은 라우팅 결정보다 먼저 돌기
|
||||
때문에 이 규칙이 **호스트 자신의 `:443` 소켓보다 우선한다.** 그래서 물리
|
||||
호스트에 nginx 가 아직 떠 있어도 트래픽은 엣지로 간다 — 전환이 원자적이고,
|
||||
되돌리기도 한 줄이다.
|
||||
|
||||
## 4. 층별로 확인한다 — 아래에서 위로
|
||||
|
||||
한 번에 밖에서 치지 말고, **가까운 층부터** 본다. 어디서 끊겼는지가 바로 나온다.
|
||||
|
||||
> **`curl` 을 두 형태로 쓴다.** 처음 볼 때는 `-I`(헤더까지 읽는 형태)로
|
||||
> **응답을 눈으로 읽고**, 같은 것을 여러 번 재거나 두 노드를 나란히 비교할
|
||||
> 때만 `-w '%{http_code}'`(값만 뽑는 형태)로 바꾼다. 값만 뽑는 형태는
|
||||
> 골라 놓은 한 칸 말고는 전부 버리므로, **무엇이 잘못됐는지 모르는 상태**
|
||||
> 에서는 쓸 것이 못 된다.
|
||||
|
||||
**확인 ①** Traefik 이 듣고 있나 (nginx 를 건너뛴다)
|
||||
```bash
|
||||
curl -I http://192.168.122.11
|
||||
```
|
||||
|
||||
**형태** (이 실험대에서 캡처해 두지 않았다 — 봐야 할 줄만)
|
||||
```
|
||||
HTTP/1.1 404 Not Found
|
||||
...
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 첫 줄의 상태 줄 하나. 그리고 그 앞에 **아무 오류도
|
||||
없이** 헤더가 나왔다는 사실. `curl: (7) Failed to connect` 이면 응답 자체가
|
||||
없는 것이라 상태 코드를 볼 일도 없다.
|
||||
|
||||
**이 결과가 의미하는 것** — **`404` 가 성공 신호다.** 게스트의 80 을 누가
|
||||
듣고 있고(Traefik), 그가 요청을 받아 「매칭되는 Ingress 규칙이 없다」고
|
||||
답한 것이다. 이 층은 통과. `502` 면 Traefik 은 떴는데 그 뒤 백엔드가 없는
|
||||
것이고, 연결 거부·타임아웃이면 Traefik 이 안 떴거나 게스트가 죽은 것이라
|
||||
**02 로 돌아간다.**
|
||||
|
||||
두 노드가 같은지 볼 때는 값만 뽑는 형태가 낫다. 두 줄을 나란히 놓고 눈으로
|
||||
비교하는 것이 목적이기 때문이다.
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w '%{http_code}\n' http://192.168.122.11
|
||||
curl -s -o /dev/null -w '%{http_code}\n' http://192.168.122.12
|
||||
```
|
||||
|
||||
**실측** — `.11` 에서 잰 값이다.
|
||||
```
|
||||
404
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 두 줄이 **같은 코드**인가.
|
||||
|
||||
**이 결과가 의미하는 것** — 둘이 같으면 nginx 가 어느 쪽으로 보내도 같은
|
||||
결과가 나온다. 한쪽만 다르면 upstream 두 개 중 하나가 죽은 것이고, 그
|
||||
상태에서는 **요청의 절반만 실패**해서 「가끔 안 된다」로 보인다.
|
||||
|
||||
**확인 ②** 엣지 nginx 가 직접 응답하나 (DNAT 을 건너뛴다)
|
||||
```bash
|
||||
curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' http://192.168.122.10
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `301` 과 `Location` 이 나오는가. 여기서 막히면
|
||||
문제는 **엣지 안**이다(설정·기동). 통과하는데 아래 ③ 이 안 되면 문제는
|
||||
**DNAT** 이다. 이 한 층을 끼워 두면 그 둘을 헷갈리지 않는다.
|
||||
|
||||
**확인 ③** 밖에서, 즉 DNAT 을 거쳐 닿나
|
||||
```bash
|
||||
curl -I http://auth.hyeonworks.com
|
||||
```
|
||||
|
||||
**형태** (봐야 할 두 줄만)
|
||||
```
|
||||
HTTP/1.1 301 Moved Permanently
|
||||
Location: https://auth.hyeonworks.com/
|
||||
...
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 상태 줄과 **`Location:` 헤더 한 줄**. `Location` 이
|
||||
`https://` 로 시작하고 원래 호스트명을 그대로 들고 있는가. `$host` 대신
|
||||
설정에 이름을 박아 두면 여기서 엉뚱한 호스트가 나온다.
|
||||
|
||||
**이 결과가 의미하는 것** — 301 이 나왔다는 것은 **바깥 요청이 DNAT 을 거쳐
|
||||
엣지 nginx 까지 닿았다**는 뜻이다(①은 Traefik 에 직접, ②는 엣지에 직접 친
|
||||
것이라 DNAT 을 안 거쳤다). DNS·DNAT·80 리스너가 전부 살아 있다. 응답이 아예 없으면 nginx 가 안 떴거나
|
||||
80 이 막힌 것이다. 리다이렉트를 따라가 끝까지 보려면 `-L` 을 붙인다.
|
||||
|
||||
값을 반복해서 잴 때의 형태는 이것이고, 아래가 이 실험대의 실측이다.
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' http://auth.hyeonworks.com
|
||||
```
|
||||
```
|
||||
301 https://auth.hyeonworks.com/
|
||||
```
|
||||
|
||||
**확인 ④** 끝까지 닿나 (TLS 이후)
|
||||
```bash
|
||||
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
|
||||
```
|
||||
```
|
||||
200
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 코드 한 칸. 여기서만 값만 뽑는 형태를 바로 쓰는
|
||||
이유는, 이 200 이 **04·05 에서 매번 같은 명령으로 다시 잴 기준값**이기
|
||||
때문이다. 처음 한 번은 `curl -I https://auth.hyeonworks.com/realms/master`
|
||||
로 헤더까지 보고, 그다음부터 이 형태로 줄인다.
|
||||
|
||||
**이 결과가 의미하는 것** — `200` 이면 nginx → Traefik → 파드까지 2홉이 다
|
||||
이어졌다. `502` 는 nginx 는 살아 있는데 upstream 을 못 잡은 것(4번의 로그를
|
||||
본다), `curl: (60)` 같은 인증서 오류는 아직 04 를 안 한 것이다. TLS 단계에서
|
||||
막히면 코드만 보지 말고 `curl -v` 로 협상 과정을 읽는다 — 04 에서 그렇게 한다.
|
||||
|
||||
## 5. upstream 이 둘인 이유
|
||||
|
||||
```
|
||||
upstream k3s_traefik {
|
||||
server 192.168.122.11:80;
|
||||
server 192.168.122.12:80;
|
||||
}
|
||||
```
|
||||
|
||||
두 노드 모두 Traefik 이 뜨므로 어느 쪽으로 보내도 된다. nginx 는 기본
|
||||
라운드로빈으로 번갈아 보내고, **한쪽이 죽으면 자동으로 뺀다.**
|
||||
|
||||
그 「빼는」 동작이 로그에 이렇게 남는다.
|
||||
|
||||
```
|
||||
connect() failed (113: No route to host) ← 호스트에 못 닿는다
|
||||
connect() failed (111: Connection refused) ← 포트에 아무도 없다
|
||||
no live upstreams ← 둘 다 죽었다고 판단
|
||||
```
|
||||
|
||||
**113 과 111 은 대응이 다르다.** 113 은 네트워크, 111 은 프로세스다.
|
||||
A-4 에서 노드를 잃었을 때 이 세 줄이 1분 안에 순서대로 나왔다.
|
||||
|
||||
---
|
||||
|
||||
## 막히면
|
||||
|
||||
| 증상 | 어디서 끊겼나 | 확인 |
|
||||
|---|---|---|
|
||||
| ① 이 연결 거부 | Traefik 이 안 떴거나 게스트가 죽음 | `kubectl get pods -n kube-system` |
|
||||
| ① 이 `502` | Traefik 은 떴는데 백엔드가 없음 | Ingress 확인 |
|
||||
| ② 가 응답 없음 | nginx 가 안 떴거나 방화벽 | `systemctl status nginx` |
|
||||
| ③ 이 `502` | 인증서 문제 또는 upstream 다운 | [04](../04-tls/) · 아래 로그 |
|
||||
| `/etc/nginx: No such file or directory` | **nginx 미설치.** cloud-init 은 안 깐다 | `which nginx` → [0번](#0-nginx-가-깔려-있는지부터-본다) |
|
||||
| `nginx -t` 가 `duplicate default server` | `sites-enabled/default` 가 살아 있다 | `ls -l /etc/nginx/sites-enabled/` |
|
||||
| 설정을 썼는데 아무 변화가 없다 | 경로 오타 — `site-available`(단수)에 썼다 | `ls /etc/nginx/sites-available/` |
|
||||
| `unknown directive "http2"` | nginx < 1.25.1. Debian 12 는 1.22 다 | `nginx -v` → `listen 443 ssl http2` 형태로 |
|
||||
| `systemctl reload` 가 실패한다 | 설정이 `nginx -t` 를 못 통과했다 | **reload 말고 `sudo nginx -t` 를 먼저** 친다 |
|
||||
| `cp: cannot stat 'deploy/...'` | 엣지에서 쳤거나, lab host 에 리포가 없다 | `ls ~/workspace` → 없으면 3번의 **B** 로 |
|
||||
| `Unit lab-edge-dnat.service does not exist` | 유닛 파일이 없거나, `/etc/systemd/` 에 썼거나, `daemon-reload` 를 안 했다 | `find /etc/systemd -name 'lab-edge-dnat*'` — `system/` 아래여야 한다 |
|
||||
| 80 은 되는데 HTTPS 만 안 된다 | `.nft` 의 포트가 `433` 오타 | `grep dport /etc/nftables.d/lab-edge-dnat.nft` |
|
||||
| 호스트 안에서는 404 인데 **밖에서만 connection refused** | libvirt `guest_input` 의 `reject`. 3번의 두 번째 줄이 빠졌거나 날아갔다 | `sudo nft -a list chain ip libvirt_network guest_input` — `reject` 줄의 카운터가 올라가면 그것이다 |
|
||||
| `Error: syntax error, unexpected ct` | 셸이 `{80,443}` 을 펼쳤다 | `'{80,443}'` 로 따옴표 |
|
||||
|
||||
**로그를 볼 때** — 실무자가 치는 형태다.
|
||||
```bash
|
||||
journalctl -u nginx -p err -n 5 # 최근 에러만
|
||||
journalctl -u nginx -f # 지금 벌어지는 것
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 각 줄의 **괄호 안 errno**(`113`·`111`)와 그 뒤의
|
||||
`upstream: "http://192.168.122.1x:80/..."` 부분. 어느 upstream 이 문제인지가
|
||||
거기 적혀 있다. 그리고 **타임스탬프** — 방금 친 요청 시각과 안 맞으면 지금
|
||||
보고 있는 것은 옛 사고다.
|
||||
|
||||
**이 결과가 의미하는 것** — `-p err` 로 걸러도 아무것도 안 나오면 nginx 는
|
||||
정상이고 문제는 더 위(Traefik·파드)에 있다. 두 번째 명령은 **띄워 놓은
|
||||
채로 다른 창에서 요청을 치는** 용도다 — 요청과 로그 줄을 눈으로 짝지으면
|
||||
「이 요청이 어느 upstream 으로 갔나」가 바로 보인다. 끝내려면 Ctrl+C.
|
||||
|
||||
**★ nginx 에러 로그는 2048바이트에서 잘린다.** 긴 URL 이 끝에서 단어 중간에
|
||||
끊겨 보이면 그것이다. 되찾으려면 **access 로그를 본다** — 거기엔 제한이 없다.
|
||||
|
||||
```bash
|
||||
grep oauth2/callback /var/log/nginx/access.log | tail -1
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 그 한 줄의 **끝**이다. 요청 URL 이 `"` 로 제대로
|
||||
닫혀 있으면 온전한 줄이고, 단어 중간에서 멈춰 있으면 잘린 것이다. 길이가
|
||||
궁금하면 세어 본다.
|
||||
|
||||
```bash
|
||||
grep oauth2/callback /var/log/nginx/access.log | tail -1 | wc -c
|
||||
```
|
||||
|
||||
**이 결과가 의미하는 것** — error 로그와 access 로그의 같은 요청 길이가 다르면
|
||||
**error 쪽이 잘린 것**이지 요청이 잘린 것이 아니다. 이 실험대에서 B-7 의
|
||||
502 원인이 error 로그에 있었는데 잘려 있었고, access 로그에는 3492자로 온전히
|
||||
남아 있었다. 잘린 문자열을 놓고 원인을 추측하면 없는 문제를 좇게 된다.
|
||||
|
||||
---
|
||||
|
||||
## 되돌리기
|
||||
|
||||
**세우는 절차가 아니다.** 이 단계를 걷어낼 때만 본다.
|
||||
|
||||
| 무엇을 | `[어디서]` | 명령 |
|
||||
|---|---|---|
|
||||
| DNAT 만 끈다 | `[lab host]` | `sudo systemctl disable --now lab-edge-dnat.service` |
|
||||
| 규칙만 즉시 걷어낸다 | `[lab host]` | `sudo nft delete table ip lab_edge` |
|
||||
| libvirt 에 뚫은 구멍을 막는다 | `[lab host]` | `sudo nft -a list chain ip libvirt_network guest_input` 로 handle 을 보고 `sudo nft delete rule ip libvirt_network guest_input handle <번호>` |
|
||||
| 엣지 라우팅을 끈다 | `[kc-lab-edge]` | `sudo rm /etc/nginx/sites-enabled/keycloak-lab && sudo nginx -t && sudo systemctl reload nginx` |
|
||||
|
||||
`sites-available` 의 원본은 남으므로 `ln -sf` 로 다시 걸면 복구된다.
|
||||
|
||||
**★ `.nft` 파일 맨 위의 `delete` 는 이것과 다르다.** 그 두 줄은 **파일을
|
||||
적용할 때마다 자동으로 도는 재적용 안전장치**(없는 테이블을 지우면 에러라서
|
||||
빈 테이블을 한 번 만들고 지운다)이고, 위 표의 `nft delete` 는 **사람이 끄는
|
||||
버튼**이다.
|
||||
@@ -0,0 +1,655 @@
|
||||
# 04 — TLS
|
||||
|
||||
## 이 단계가 끝나면
|
||||
|
||||
`https://` 가 열리고 체인이 완전하며, 갱신이 자동으로 **서빙까지** 닿는다.
|
||||
|
||||
## 전제
|
||||
|
||||
[03](../03-nginx/) 이 끝나 엣지 nginx 가 Traefik 으로 프록시한다.
|
||||
|
||||
## 어디서 치는가
|
||||
|
||||
**1~3 번은 전부 `[kc-lab-edge]` 에서 친다. 단 4번 확인만 tailnet 에 붙은
|
||||
다른 머신에서 친다** — 엣지에는 Tailscale 이 없어 자기 자신을 밖에서 들어오는
|
||||
경로로 부를 수 없다.
|
||||
|
||||
인증서·certbot·갱신 타이머·deploy 훅이 전부 엣지 게스트에 산다. 물리 호스트에는 아무것도 두지 않는다 —
|
||||
그래야 `virsh undefine kc-lab-edge` 한 줄로 이 계층을 통째로 되돌릴 수 있다.
|
||||
|
||||
## ★ 검증 방식을 먼저 정한다 — HTTP-01 이냐 DNS-01 이냐
|
||||
|
||||
같은 Let's Encrypt 인증서인데 **「이 도메인이 네 것이냐」를 증명하는 방법**만
|
||||
다르다. 그리고 이 실험대에서는 **선택의 여지가 없다.**
|
||||
|
||||
| | HTTP-01 | DNS-01 |
|
||||
|---|---|---|
|
||||
| 검증 방향 | Let's Encrypt **→ 우리 서버** (인바운드) | certbot **→ DNS 공급자 API** (아웃바운드) |
|
||||
| 공개 인터넷에서 보여야 하나 | **그렇다** | 아니다 |
|
||||
| 와일드카드 | 불가 | 가능 |
|
||||
| 필요한 것 | 80 포트 · 공개 A 레코드 | DNS 공급자 API 토큰 |
|
||||
|
||||
**이 실험대는 공개 인터넷을 쓰지 않는다.** 도메인 세 개는 tailnet 주소를
|
||||
가리킨다.
|
||||
|
||||
```bash
|
||||
dig +short auth.hyeonworks.com
|
||||
```
|
||||
```
|
||||
100.83.212.4
|
||||
```
|
||||
|
||||
`100.64.0.0/10` 은 CGNAT 용으로 예약된 대역이라 **공개 인터넷에서 라우팅
|
||||
자체가 되지 않는다.** 방화벽을 여는 문제가 아니라 그 주소가 인터넷에 존재하지
|
||||
않는다. Let's Encrypt 를 tailnet 에 초대할 방법도 없다. **그래서 HTTP-01 은
|
||||
쓸 수 없고 DNS-01 을 쓴다.**
|
||||
|
||||
> **공개 서버라면 HTTP-01 이 맞다.** 토큰도 DNS 연동도 필요 없어서 관리할
|
||||
> 것이 적다. DNS-01 이 더 좋은 방식이어서 고르는 것이 아니라, HTTP-01 이
|
||||
> 못 쓰이는 환경이라 고르는 것이다. 개념은
|
||||
> [`session-lab-concepts.md`](../../session-lab-concepts.md) 의
|
||||
> 「DNS-01 은 언제 쓰는가」.
|
||||
|
||||
---
|
||||
|
||||
## 1. certbot 을 깐다
|
||||
|
||||
cloud-init 이 이미 깔았다면 건너뛴다 —
|
||||
[`kc-lab.yaml.example`](../../../deploy/lab/cloud-init/kc-lab.yaml.example) 의
|
||||
`packages` 에 들어 있다.
|
||||
|
||||
**하기** — `[kc-lab-edge]`
|
||||
```bash
|
||||
sudo apt install -y certbot python3-certbot-dns-cloudflare
|
||||
```
|
||||
|
||||
**확인** — 쓸 수 있는 검증 방식이 무엇인가
|
||||
```bash
|
||||
certbot plugins 2>/dev/null | grep -E '^\*'
|
||||
```
|
||||
|
||||
**실측**
|
||||
```
|
||||
* dns-cloudflare
|
||||
* standalone
|
||||
* webroot
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `dns-cloudflare` 한 줄이 있는가. 없으면 플러그인
|
||||
패키지가 안 깔린 것이고, `--dns-cloudflare` 를 줘도 `unrecognized arguments`
|
||||
로 끝난다.
|
||||
|
||||
## 2. 인증서를 받는다
|
||||
|
||||
**순서** — 토큰 발급(브라우저) → 토큰 파일 → 토큰 검증 → 시험 발급 → 실제
|
||||
발급 → 확인. 2-2 부터는 전부 `[kc-lab-edge]` 에서 친다.
|
||||
|
||||
### 2-1. Cloudflare 토큰 발급 — 브라우저에서
|
||||
|
||||
certbot 이 인증용 TXT 레코드를 **직접 만들었다 지운다.** 그래서 DNS 쓰기
|
||||
권한이 필요하다.
|
||||
|
||||
1. `https://dash.cloudflare.com/profile/api-tokens` → **Create Token**
|
||||
2. **`Edit zone DNS`** 템플릿 → **Use template**
|
||||
3. **Permissions** — `Zone` · `DNS` · `Edit` (템플릿이 채워 준 그대로)
|
||||
4. **Zone Resources** — `Include` · `Specific zone` · **`hyeonworks.com`**
|
||||
5. **Continue to summary** → **Create Token**
|
||||
|
||||
**★ 토큰 값은 이 화면에서 한 번만 보인다.** 창을 닫으면 복구가 없다.
|
||||
**★ `All zones` 로 두지 않는다.** 계정의 모든 도메인에 대한 DNS 수정 권한이
|
||||
엣지 VM 안 평문 파일에 놓인다. Global API Key 는 더 나쁘다 — 계정 전체 만능
|
||||
키라 폐기하면 그 키를 쓰던 다른 것들이 전부 같이 죽는다.
|
||||
|
||||
### 2-2. 토큰 파일
|
||||
|
||||
```bash
|
||||
sudo install -m 600 /dev/null /etc/letsencrypt/cloudflare.ini
|
||||
sudo nano /etc/letsencrypt/cloudflare.ini
|
||||
```
|
||||
|
||||
```ini
|
||||
# file: /etc/letsencrypt/cloudflare.ini
|
||||
dns_cloudflare_api_token = 발급받은_토큰_값
|
||||
```
|
||||
|
||||
**`install -m 600` 을 먼저 치는 이유** — 파일을 **비어 있을 때** 미리 600 으로
|
||||
만든다. 토큰을 쓰고 나서 권한을 고치면 그사이가 열려 있다.
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
ls -l /etc/letsencrypt/cloudflare.ini
|
||||
sudo wc -c /etc/letsencrypt/cloudflare.ini
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `-rw-------` 이고 바이트 수가 0 이 아닌가.
|
||||
`sudo` 없이 `wc` 를 치면 `Permission denied` 가 나는데 **그게 정상**이다
|
||||
(600 = root 만 읽기). 토큰 값 자체는 출력하지 않는다.
|
||||
|
||||
### 2-3. 토큰 검증
|
||||
|
||||
```bash
|
||||
CF=$(sudo awk -F'= *' '/api_token/{print $2}' /etc/letsencrypt/cloudflare.ini)
|
||||
curl -s https://api.cloudflare.com/client/v4/user/tokens/verify -H "Authorization: Bearer $CF"
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `"status":"active"` 와 `"success":true`.
|
||||
`"code":6003` 이면 토큰 값이 틀렸거나 잘렸고, `"code":9109` 면 권한 범위가
|
||||
모자라다(`Zone` · `Zone` · `Read` 를 한 줄 더한다).
|
||||
|
||||
**이 결과가 의미하는 것** — 여기서 걸러 두면 뒤에서 실패했을 때 「DNS 문제인지
|
||||
토큰 문제인지」를 헷갈리지 않는다.
|
||||
|
||||
### 2-4. 시험 발급 — `--dry-run`
|
||||
|
||||
```bash
|
||||
sudo certbot certonly --dns-cloudflare \
|
||||
--dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
|
||||
-d hyeonworks.com -d '*.hyeonworks.com' --dry-run
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 마지막 줄 `The dry run was successful.`
|
||||
|
||||
**★ dry-run 은 인증서를 저장하지 않는다.** 스테이징 서버에 대고 시험만 하는
|
||||
것이라 `/etc/letsencrypt/live/` 에는 아무것도 안 생긴다. **여기서
|
||||
`certbot certificates` 를 치면 `No certificates found` 가 나오는 것이 정상**
|
||||
이다. 이걸 먼저 돌리는 이유는 Let's Encrypt 의 **주당 중복 인증서 5장** 한도를
|
||||
dry-run 이 쓰지 않기 때문이다.
|
||||
|
||||
**★ 최초 실행이면 계정 등록 대화가 먼저 뜬다.**
|
||||
|
||||
| 질문 | 답 |
|
||||
|---|---|
|
||||
| `Enter email address` | **본인 이메일** (빈 값이면 `Invalid email address: .` 로 되묻는다) |
|
||||
| Terms of Service | **Y** |
|
||||
| EFF 뉴스레터 | **N** — 발급과 무관하다 |
|
||||
|
||||
최초 1회뿐이다. 프롬프트 없이 돌리려면 `-m <이메일> --agree-tos --no-eff-email`
|
||||
을 붙인다. `--register-unsafely-without-email` 은 쓰지 않는다 — 갱신 실패를
|
||||
알려줄 통로가 사라지는데, 5번이 재는 것이 바로 그 갱신이다.
|
||||
|
||||
### 2-5. 실제 발급 — `--dry-run` 을 뺀 같은 명령
|
||||
|
||||
```bash
|
||||
sudo certbot certonly --dns-cloudflare \
|
||||
--dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
|
||||
-d hyeonworks.com -d '*.hyeonworks.com'
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `Successfully received certificate.` 와 그 아래
|
||||
저장 경로. **DNS-01 은 느리다** — TXT 가 퍼질 때까지 기다리느라 수십 초
|
||||
걸린다. 중간에 끊지 않는다.
|
||||
|
||||
### 2-6. 확인
|
||||
|
||||
```bash
|
||||
sudo certbot certificates
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 네 줄이다.
|
||||
|
||||
| 줄 | 값 |
|
||||
|---|---|
|
||||
| `Domains:` | `hyeonworks.com *.hyeonworks.com` — **한 줄에** 둘 다 |
|
||||
| `Expiry Date:` | 오늘 + 90일, `VALID` |
|
||||
| `Certificate Path:` | `/etc/letsencrypt/live/hyeonworks.com/fullchain.pem` |
|
||||
| `Private Key Path:` | `/etc/letsencrypt/live/hyeonworks.com/privkey.pem` |
|
||||
|
||||
**★ 디렉터리 이름과 인증서가 덮는 이름은 별개다.** 여기서 가장 많이 헷갈린다.
|
||||
|
||||
| | 무엇 | 정해지는 방식 |
|
||||
|---|---|---|
|
||||
| 디렉터리 이름 `live/hyeonworks.com/` | certbot 이 이 묶음(lineage)을 관리하려고 붙인 **라벨** | **첫 번째 `-d`** 에서 따온다. 서빙과 무관 |
|
||||
| SAN 목록 | 브라우저가 보는 **실제 유효 호스트명** | `-d` 로 준 이름 전부 |
|
||||
|
||||
그래서 `auth.hyeonworks.com` 으로 **다시 받을 필요가 없다.**
|
||||
`*.hyeonworks.com` 한 장이 `auth`·`app1`·`app2` 를 전부 덮는다. 다만 3번의
|
||||
nginx 설정에는 **디렉터리 경로**를 한 글자도 다르지 않게 적어야 한다 —
|
||||
`live/auth.hyeonworks.com/` 이라고 적으면 `cannot load certificate` 로 막힌다.
|
||||
|
||||
**확인** — 인증서가 실제로 어떤 이름에 유효한가
|
||||
```bash
|
||||
sudo openssl x509 -noout -ext subjectAltName -in /etc/letsencrypt/live/hyeonworks.com/fullchain.pem
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `DNS:hyeonworks.com, DNS:*.hyeonworks.com` 두 개.
|
||||
`auth.hyeonworks.com` 은 두 번째에 걸린다.
|
||||
|
||||
**★ 와일드카드는 한 단계만 덮는다.** `a.b.hyeonworks.com` 은 포함되지 않고,
|
||||
apex 인 `hyeonworks.com` 자신도 `*.hyeonworks.com` 에 들어가지 않는다 —
|
||||
그래서 `-d` 를 둘 준 것이다.
|
||||
|
||||
**이 결과가 의미하는 것** — 여기 나온 경로가 곧 nginx 가 읽을 파일이다.
|
||||
`*.hyeonworks.com` 한 장이 `auth`·`app1`·`app2` 를 전부 덮으므로, 이름을
|
||||
하나 더 쓰고 싶어도 재발급이 필요 없다.
|
||||
|
||||
> **이 실험대는 처음에 와일드카드를 안 썼고 그 비용이 B-7 에서 청구됐다.**
|
||||
> oauth2-proxy 를 올릴 네 번째 이름이 없어 Grafana 의 `app2` 를 빌려야 했다.
|
||||
> D-4 계열 실험 기록에 `live/auth.hyeonworks.com/` 경로가 남아 있는 것은
|
||||
> 그때의 실측이다.
|
||||
|
||||
## 3. nginx 에 443 을 얹는다
|
||||
|
||||
[03](../03-nginx/) 에서는 **80 만** 세웠다. 인증서가 생겼으니 이제 443 블록을
|
||||
더하고, 80 은 리다이렉트로 바꾼다.
|
||||
|
||||
**★ 먼저 nginx 버전을 본다.** `[kc-lab-edge]`
|
||||
```bash
|
||||
nginx -v
|
||||
```
|
||||
|
||||
**실측** — 같은 설정인데 배포판에서 갈린다.
|
||||
|
||||
```
|
||||
엣지 (Debian 12): nginx version: nginx/1.22.1
|
||||
물리 호스트 (Arch): nginx version: nginx/1.30.4
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **1.25.1** 이 경계다. 그 미만이면 `http2 on;`
|
||||
**지시어**가 없다. 아래처럼 `listen` 의 **파라미터**로 쓰면 1.22 와 1.30
|
||||
양쪽에서 다 돈다. 지시어 형태로 쓰면 이렇게 막힌다.
|
||||
|
||||
```
|
||||
[emerg] 1287#1287: unknown directive "http2" in /etc/nginx/sites-enabled/keycloak-lab:14
|
||||
nginx: configuration file /etc/nginx/nginx.conf test failed
|
||||
```
|
||||
|
||||
**하기** — `[kc-lab-edge]`
|
||||
```bash
|
||||
sudo nano /etc/nginx/sites-available/keycloak-lab
|
||||
```
|
||||
|
||||
03 에서 쓴 파일을 **이 내용으로 바꾼다.** 저장은 `Ctrl+O` → `Enter`,
|
||||
나가기는 `Ctrl+X`.
|
||||
|
||||
```nginx
|
||||
# file: /etc/nginx/sites-available/keycloak-lab
|
||||
upstream k3s_traefik {
|
||||
server 192.168.122.11:80;
|
||||
server 192.168.122.12:80;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80 default_server;
|
||||
server_name _;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2 default_server;
|
||||
server_name _;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/hyeonworks.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/hyeonworks.com/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
|
||||
location / {
|
||||
proxy_pass http://k3s_traefik;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header X-Forwarded-Port 443;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 03 에서 바뀐 곳이 셋이다.
|
||||
|
||||
| 줄 | 03 에서는 | 지금 |
|
||||
|---|---|---|
|
||||
| 80 블록 | `location / { proxy_pass … }` | `return 301 https://…` 리다이렉트만 |
|
||||
| 443 블록 | 없었다 | 인증서와 함께 새로 |
|
||||
| `X-Forwarded-Proto` | `http` | **`https`** |
|
||||
|
||||
**`cert.pem` 이 아니라 `fullchain.pem`.** 서버 인증서만 보내면 중간 인증서가
|
||||
빠져 체인이 끊긴다. 브라우저는 대개 캐시나 AIA 로 보완해서 **정상으로 보이고**,
|
||||
캐시가 없는 클라이언트에서만 깨진다. 그래서 발견이 늦다. 2번에서 certbot 이
|
||||
찍어 준 경로와 **한 글자도 다르면 안 된다.**
|
||||
|
||||
**하기** — 적용한다. `[kc-lab-edge]`
|
||||
```bash
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `syntax is ok` 와 `test is successful` 두 마디가
|
||||
다 나와야 통과다. `types_hash_max_size` 같은 `[warn]` 줄은 통과를 막지
|
||||
않는다 — **경고와 오류를 구분한다.**
|
||||
|
||||
## 4. 확인 — 열리는가, 체인이 완전한가
|
||||
|
||||
**★ 이 단계만 `[kc-lab-edge]` 가 아니다 — tailnet 에 붙은 머신에서 친다.**
|
||||
1~3 번은 전부 엣지에서 쳤지만, 확인은 **밖에서** 들어와야 의미가 있다.
|
||||
|
||||
엣지 안에서 치면 이렇게 막힌다.
|
||||
|
||||
```
|
||||
* connect to 100.83.212.4 port 443 failed: Connection refused
|
||||
```
|
||||
|
||||
`auth.hyeonworks.com` 은 호스트의 tailnet 주소 `100.83.212.4` 로 풀리는데,
|
||||
**엣지 VM 에는 Tailscale 이 없다**(그렇게 결정했다). 그래서 엣지에서 나간
|
||||
패킷은 호스트의 `virbr0` 으로 들어가고, DNAT 규칙은 `iifname "tailscale0"`
|
||||
만 매칭하므로 안 걸린다 → 호스트 443 에 리스너가 없어 거절된다.
|
||||
**설정 문제가 아니라 친 위치 문제다.**
|
||||
|
||||
**확인 ①** 열리나 — **처음 한 번은 협상 과정을 읽는다**
|
||||
```bash
|
||||
curl -v https://auth.hyeonworks.com/ -o /dev/null
|
||||
```
|
||||
|
||||
**★ 경로는 `/` 다.** Keycloak 은 [05](../05-keycloak/) 에서 올린다. 아직
|
||||
Ingress 가 없으므로 **`404` 가 정상**이고, 이 단계가 재는 것은 응답 코드가
|
||||
아니라 **TLS 가 붙었는가**다. `/realms/master` 같은 Keycloak 경로를 여기서
|
||||
쓰면 「TLS 가 안 된 건지 Keycloak 이 없는 건지」가 섞인다.
|
||||
|
||||
**형태** (이 실험대에서 캡처해 두지 않았다 — 읽어야 할 줄만)
|
||||
```
|
||||
* SSL connection using TLSv1.3 / ...
|
||||
* subject: CN=hyeonworks.com
|
||||
* issuer: C=US; O=Let's Encrypt; CN=...
|
||||
* SSL certificate verify ok.
|
||||
< HTTP/1.1 404 Not Found
|
||||
```
|
||||
|
||||
**`subject` 가 `hyeonworks.com` 인 것이 맞다.** 와일드카드 인증서라 CN 은
|
||||
apex 이름이고, `auth.hyeonworks.com` 은 SAN 의 `*.hyeonworks.com` 에 걸린다.
|
||||
|
||||
**어디를 봐야 하는가** — `*` 로 시작하는 줄 넷이다. 어떤 TLS 판으로
|
||||
협상했는가, `subject` 의 CN 이 지금 친 이름과 같은가, `issuer` 가 Let's
|
||||
Encrypt 인가, 그리고 **`SSL certificate verify ok.`** 가 있는가. 그 아래
|
||||
`<` 로 시작하는 첫 줄이 응답 상태다. `-o /dev/null` 은 본문만 버리는 것이라
|
||||
이 줄들은 그대로 남는다.
|
||||
|
||||
**이 결과가 의미하는 것** — 이 네 줄이 다 나오면 인증서가 붙었고 체인이
|
||||
클라이언트 기준으로 검증됐다. TLS 에서 막힐 때 봐야 할 것이 전부 여기
|
||||
있으므로, **인증서를 처음 붙인 직후에는 코드 한 칸이 아니라 이 화면을
|
||||
본다.** `verify` 줄 대신 `unable to get local issuer certificate` 가 나오면
|
||||
중간 인증서가 빠진 것이고, 그 원인은 3번의 `cert.pem`/`fullchain.pem`
|
||||
이다 — 확인 ②로 간다.
|
||||
|
||||
같은 것을 반복해서 재거나 03·05 의 값과 나란히 비교할 때만 값만 뽑는
|
||||
형태로 줄인다.
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w '%{http_code} tls=%{ssl_verify_result}\n' https://auth.hyeonworks.com/
|
||||
```
|
||||
|
||||
**실측** — 2026-09-11, tailnet 클라이언트에서
|
||||
```
|
||||
404 tls=0
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `tls=0` 이 인증서 검증 통과다. 앞의 `404` 는 05
|
||||
이후에 `200` 으로 바뀐다.
|
||||
|
||||
**확인 ②** 체인 단계와 검증
|
||||
```bash
|
||||
echo | openssl s_client -connect auth.hyeonworks.com:443 \
|
||||
-servername auth.hyeonworks.com 2>/dev/null \
|
||||
| grep -E '^ *[0-9]+ s:|^ *i:|Verify return code'
|
||||
```
|
||||
|
||||
**실측** — 이름을 따로 받던 시절의 기록이라 0번 `s:` 가
|
||||
`auth.hyeonworks.com` 이다. 와일드카드로 받으면 `CN = hyeonworks.com` 이
|
||||
되고, **봐야 할 구조는 똑같다.**
|
||||
```
|
||||
0 s:CN = auth.hyeonworks.com
|
||||
i:C = US, O = Let's Encrypt, CN = YE2
|
||||
1 s:C = US, O = Let's Encrypt, CN = YE2
|
||||
i:C = US, O = ISRG, CN = Root YE
|
||||
2 s:C = US, O = ISRG, CN = Root YE
|
||||
i:C = US, O = Internet Security Research Group, CN = ISRG Root X2
|
||||
3 s:C = US, O = Internet Security Research Group, CN = ISRG Root X2
|
||||
i:C = US, O = Internet Security Research Group, CN = ISRG Root X1
|
||||
Verify return code: 0 (ok)
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 왼쪽의 **번호(0·1·2·3)가 몇까지 가는가**, 그리고
|
||||
각 단계의 `i:`(발급자)가 **바로 다음 단계의 `s:`(주체)와 같은가**. 0번이
|
||||
우리 서버 인증서이고, 위 실측에서 0의 `i:` 가 `CN = YE2` 인데 1의 `s:` 가
|
||||
같은 `CN = YE2` 다 — 사슬이 이어져 있다는 뜻이다. 마지막이
|
||||
`Verify return code: 0 (ok)`.
|
||||
|
||||
**이 결과가 의미하는 것** — **단계가 1개면 `cert.pem` 을 쓴 것이다.**
|
||||
서버가 자기 인증서만 보내고 중간 인증서를 안 보낸 상태다. 이때 브라우저는
|
||||
대개 캐시나 AIA 로 보완해서 **정상으로 보이므로**, 이 명령이 유일하게
|
||||
믿을 수 있는 판정이다. 고치는 곳은 03 의 `ssl_certificate` 한 줄이고,
|
||||
고친 뒤 `nginx -t && systemctl reload nginx` 하고 여기서 다시 잰다.
|
||||
`Verify return code` 가 0 이 아니면 숫자마다 뜻이 다르다 —
|
||||
`10` 은 만료, `20` 은 발급자를 못 찾음, `21` 은 첫 인증서를 검증 못 함.
|
||||
|
||||
**확인 ③** 이름 세 개가 한 인증서인가
|
||||
```bash
|
||||
for H in auth app1 app2; do
|
||||
echo | openssl s_client -connect $H.hyeonworks.com:443 -servername $H.hyeonworks.com 2>/dev/null \
|
||||
| openssl x509 -noout -serial
|
||||
done
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 찍히는 세 줄의 **일련번호가 서로 같은가**. 값 자체는
|
||||
아무 의미가 없고 **셋이 일치하는지만** 본다.
|
||||
|
||||
**이 결과가 의미하는 것** — 셋이 같으면 SAN 하나에 이름 셋이 들어 있는
|
||||
인증서 한 장이고, 갱신도 한 번에 끝난다. 다르면 인증서가 여러 장이라
|
||||
**갱신 훅도 장마다 따로 돌고**, 한 장만 갱신됐을 때 나머지 이름이 만료되는
|
||||
상황이 생긴다. 이름별로 무엇이 실려 있는지 보려면 SAN 을 직접 편다.
|
||||
|
||||
```bash
|
||||
echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \
|
||||
| openssl x509 -noout -ext subjectAltName
|
||||
```
|
||||
|
||||
## 5. ★ 갱신이 서빙까지 닿게 한다 — 여기가 이 단계의 핵심이다
|
||||
|
||||
**타이머가 도는 것만으로는 부족하다.**
|
||||
|
||||
**확인** — 타이머
|
||||
```bash
|
||||
systemctl list-timers certbot-renew.timer
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 네 칸이다. `NEXT`(다음 실행 시각)와 `LEFT`(남은
|
||||
시간)가 채워져 있는가, `LAST`/`PASSED` 가 하루 안쪽인가, `UNIT` 옆의
|
||||
`ACTIVATES` 가 `certbot-renew.service` 를 가리키는가. **표가 통째로 비어
|
||||
나오면 타이머가 없는 것이다** — 이름이 배포판마다 다르니
|
||||
`systemctl list-timers --all | grep -i certbot` 로 찾는다.
|
||||
|
||||
**이 결과가 의미하는 것** — 여기까지가 「갱신이 돌기는 하는가」의 답이고,
|
||||
대부분의 문서가 여기서 끝난다. **그런데 이것이 `active` 여도 갱신된 인증서가
|
||||
서빙되지는 않는다.** nginx 는 인증서를
|
||||
기동 시점에 읽어 메모리에 들고 있고, certbot 은 `live/` 심볼릭 링크만 갈아
|
||||
끼운다. **경로는 그대로이고 내용만 바뀌므로 nginx 는 모른다.**
|
||||
|
||||
배포판 기본 유닛에는 reload 를 부르는 것이 없다.
|
||||
|
||||
```bash
|
||||
systemctl cat certbot-renew.service
|
||||
```
|
||||
```
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/bin/certbot -q renew
|
||||
PrivateTmp=true
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `ExecStart=` 한 줄과, 그 아래에 `ExecStartPost=` 가
|
||||
**있는지 없는지**. 그리고 `ExecStart` 의 인자에 `--deploy-hook` 이 붙어
|
||||
있는지. 여기 없는 것을 보는 것이 이 명령의 목적이다.
|
||||
|
||||
**이 결과가 의미하는 것** — `ExecStartPost` 도 `--deploy-hook` 도 없다.
|
||||
즉 이 유닛은 **인증서를 새로 받는 데까지만** 책임지고, 받은 것을 누가
|
||||
읽게 만드는 일은 아무도 하지 않는다. 배포판이 이렇게 준다는 것이 요점이다 —
|
||||
「기본값이니 괜찮겠지」가 바로 이 결함의 서식지다. 여기 뭔가 적혀 있는
|
||||
배포판이라면 아래 훅은 필요 없고, 대신 그 명령이 nginx 를 reload 하는지만
|
||||
확인하면 된다.
|
||||
|
||||
**하기** — 훅 하나를 넣는다. `[kc-lab-edge]`
|
||||
```bash
|
||||
sudo nano /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
|
||||
```
|
||||
|
||||
```sh
|
||||
# file: /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
|
||||
#!/bin/sh
|
||||
nginx -t && nginx -s reload
|
||||
```
|
||||
|
||||
**실행 권한을 준다.** 없으면 certbot 이 **조용히 건너뛴다.**
|
||||
```bash
|
||||
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
|
||||
ls -l /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 권한 문자열에 `x` 가 세 번(`-rwxr-xr-x`) 보이는가.
|
||||
`-rw-r--r--` 면 아직 실행 파일이 아니다.
|
||||
|
||||
> **이 훅은 한동안 저장소에 없었다.** 호스트에만 있어서, 호스트를 초기화하면
|
||||
> **아무 오류 없이 사라지고** D-4 가 측정한 상태(갱신 성공 · 서빙 38분 25초
|
||||
> 지연 · 타이머는 `SUCCESS`)로 되돌아갔다. 저장소에 두는 이유가 이것이다.
|
||||
|
||||
`deploy/` 에 넣는다. `post/` 는 갱신이 없어도 매번 돌아 하루 두 번 워커를
|
||||
갈아치운다. `deploy/` 는 **실제로 갱신됐을 때만** 실행된다.
|
||||
|
||||
**확인** — 실제로 도는지
|
||||
|
||||
이 확인은 **상태를 바꾼다.** `--force-renewal` 은 인증서를 실제로 새로
|
||||
받으므로 발급 한도(주당 중복 5장)를 깎는다. 먼저 `--dry-run` 으로 훅이
|
||||
호출되는 것까지만 보고, 진짜 판정이 필요할 때만 강제 갱신을 한 번 쓴다.
|
||||
|
||||
```bash
|
||||
sudo certbot renew --dry-run
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 출력 끝의 `Running deploy-hook command` 줄과
|
||||
`simulated renewals` 요약. 훅 줄이 아예 안 나오면 파일이 `deploy/` 가 아닌
|
||||
곳에 있거나 실행 권한이 없는 것이다(`ls -l` 로 `x` 를 본다).
|
||||
|
||||
**이 결과가 의미하는 것** — dry-run 은 훅이 **호출되는지**까지만 말해 준다.
|
||||
호출된 훅이 nginx 를 정말 갈아 끼웠는지는 dry-run 으로 알 수 없다 —
|
||||
그래서 아래를 한 번 한다.
|
||||
|
||||
```bash
|
||||
# 강제 갱신 전에 워커 PID 를 적어 둔다
|
||||
ps -eo pid,lstart,args | grep 'nginx: worker' | grep -v grep
|
||||
|
||||
sudo certbot renew --force-renewal
|
||||
|
||||
# 워커 PID 가 바뀌었으면 reload 된 것이다
|
||||
ps -eo pid,lstart,args | grep 'nginx: worker' | grep -v grep
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 두 출력의 **첫 열(PID)과 둘째 열 묶음(lstart, 프로세스
|
||||
시작 시각)**. 앞뒤로 놓고 PID 집합이 통째로 바뀌었는지만 본다. `lstart` 를
|
||||
같이 뽑는 이유는 PID 가 우연히 재사용됐을 때를 가르기 위해서다. 마스터
|
||||
프로세스는 그대로이고 **워커만** 갈리는 것이 정상이다.
|
||||
|
||||
**이 결과가 의미하는 것** — PID 가 바뀌었으면 훅이 돌아 nginx 가 새 인증서를
|
||||
읽었다. 안 바뀌었으면 인증서는 갱신됐는데 **서빙되는 것은 옛것**이고,
|
||||
이 상태가 아래 표의 왼쪽 칸이다.
|
||||
|
||||
**판정은 로그 문구가 아니라 워커 PID 로 한다.** certbot 이
|
||||
`Hook 'deploy-hook' ran with error output` 이라고 찍는데 **실패가 아니다** —
|
||||
nginx 의 `types_hash` 경고가 stderr 로 나갔을 뿐이고 내용은
|
||||
`test is successful` · `signal process started` 다.
|
||||
|
||||
> **로그에서 `error` 를 grep 하는 감시를 걸면 성공한 훅을 실패로 오독한다.**
|
||||
|
||||
**실측** — 이 실험대에서 잰 차이
|
||||
|
||||
| | 훅 없음 | 훅 있음 |
|
||||
|---|---|---|
|
||||
| 갱신 → 서빙 | **2305초 (38분 25초)** | **1~2초** |
|
||||
| 무엇이 reload 했나 | 사람이 직접 | certbot deploy 훅 |
|
||||
| 아무도 안 했다면 | 다음 nginx 재시작까지 = 사실상 무기한 | — |
|
||||
|
||||
**88일 동안 이 결함이 보이지 않는다.** 타이머는 정상이고 매번 `SUCCESS` 로
|
||||
끝나며, 만료 30일 전까지는 갱신 자체를 하지 않아 발현할 기회가 없다.
|
||||
발현하는 날의 증상은 **인증서 만료**이고, 그날에도 로그에는 `SUCCESS` 라고
|
||||
적혀 있다.
|
||||
|
||||
원문: [D-4](../../experiment-d4-certificate-renewal.md) ·
|
||||
[D-4a](../../experiment-d4a-deploy-hook.md) ·
|
||||
증거 [`evidence/d4-certificate-renewal/`](../../evidence/d4-certificate-renewal/)
|
||||
|
||||
## 6. reload 는 무중단인가 — 쟀다
|
||||
|
||||
궁금할 것이므로 결과만 적는다. **무중단이다.**
|
||||
|
||||
새 연결 8856건 전부 200, p95 는 205.7ms 대 204.3ms 로 변화 없음. 그리고
|
||||
845KB 를 20k/s 로 받는 중이던 요청이 **전송 12초째에 reload 를 맞고도**
|
||||
845361바이트를 온전히 받았다(연결수 1). 옛 워커가 그 요청을 끝까지 책임진다.
|
||||
|
||||
---
|
||||
|
||||
## 막히면
|
||||
|
||||
| 증상 | 원인 | 확인 |
|
||||
|---|---|---|
|
||||
| certbot 검증 실패 | DNS 가 이 호스트를 안 가리킴 · 80 이 막힘 | `dig +short auth.hyeonworks.com` · 밖에서 `curl -I http://auth.hyeonworks.com` |
|
||||
| 체인 단계가 1개 | `cert.pem` 을 씀 | 위 확인 ② |
|
||||
| 갱신은 됐는데 옛 인증서가 나감 | **deploy 훅 없음** | 워커 PID · `sudo ls /etc/letsencrypt/renewal-hooks/deploy/` |
|
||||
| 훅이 실패한 것처럼 보임 | stderr 경고를 error 로 표시 | 문구 말고 **워커 PID** |
|
||||
| 발급 한도 | 주당 중복 인증서 5장 | `--dry-run` 으로 먼저 시험 |
|
||||
| `unrecognized arguments: --dns-cloudflare` | 플러그인 미설치 | `certbot plugins \| grep '^\*'` |
|
||||
| DNS-01 이 오래 걸림 | TXT 전파 대기 | **정상이다.** 끊지 않는다 |
|
||||
| 재구축 뒤 인증서가 없음 | 발급하지 말고 **백업을 되돌린다** | 한도를 아끼는 길이다 |
|
||||
|
||||
---
|
||||
|
||||
## 근거를 재려면 (선택)
|
||||
|
||||
평소에는 필요 없다. **문서에 남길 근거가 필요할 때만** 이렇게까지 한다.
|
||||
|
||||
갱신 중 가용성을 재려면 **주입 전에 대조군부터** 잡는다. 평시 오류율을 모르면
|
||||
갱신 중에 나온 실패 한 건을 해석할 수 없다.
|
||||
|
||||
```bash
|
||||
# 대조군 — 0.2초 × 900회 = 180초
|
||||
i=0; while [ $i -lt 900 ]; do
|
||||
curl -s -o /dev/null -w '%{http_code} %{time_total}\n' --max-time 5 \
|
||||
https://auth.hyeonworks.com/realms/master
|
||||
i=$((i+1)); sleep 0.2
|
||||
done > /tmp/control.txt
|
||||
awk '{print $1}' /tmp/control.txt | sort | uniq -c
|
||||
```
|
||||
|
||||
**여기서는 값만 뽑는 형태가 맞다.** 900번을 재서 코드별로 세는 것이 목적이고,
|
||||
헤더는 볼 일이 없다. 앞의 확인 ①과 형태가 다른 이유가 이것이다.
|
||||
|
||||
**어디를 봐야 하는가** — `uniq -c` 가 내놓는 **줄이 몇 개인가**. 한 줄이면
|
||||
900번이 전부 같은 코드였다는 뜻이고, 그 줄의 왼쪽 수가 900 인지 본다.
|
||||
두 줄 이상이면 그 자체가 평시 오류가 있다는 뜻이다. 응답 시간이 궁금하면
|
||||
둘째 열을 따로 본다.
|
||||
|
||||
```bash
|
||||
awk '{print $2}' /tmp/control.txt | sort -n | tail -1 # 최악값
|
||||
```
|
||||
|
||||
**이 결과가 의미하는 것** — 이 실험대의 대조군은 **900건 전부 200, 오류 0**
|
||||
이었다. 그래서 갱신 중 비200 이 한 번이라도 나오면 갱신 탓으로 귀속할 수
|
||||
있었다. 대조군에 이미 오류가 섞여 있다면 주입 중의 오류 한 건은 아무것도
|
||||
증명하지 못하므로, **대조군이 깨끗해질 때까지는 주입을 하지 않는다.**
|
||||
|
||||
**그리고 두 기계의 시각을 나란히 놓기 전에 시계부터 잰다.**
|
||||
|
||||
```bash
|
||||
A=$(date -u +%s.%N); B=$(ssh test-server 'date -u +%s.%N'); C=$(date -u +%s.%N)
|
||||
# 왜곡 ≈ B − (A+C)/2 , 어느 쪽이 맞는지는 외부 기준으로 가른다
|
||||
curl -sI https://www.google.com | grep -i '^date:'
|
||||
timedatectl show -p NTP -p NTPSynchronized
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 세 수 `A`·`B`·`C` 를 눈으로 빼서 **초 단위 차이가
|
||||
몇인가**. `A` 와 `C` 는 같은 기계에서 SSH 왕복 직전·직후에 찍은 것이라, 그
|
||||
가운데가 「저쪽 시각을 잰 순간의 이쪽 시각」이다. 그다음 `Date:` 헤더의
|
||||
시각이 둘 중 어느 쪽에 가까운가. 마지막으로 `NTPSynchronized=yes` 인가.
|
||||
|
||||
**이 결과가 의미하는 것** — 차이가 수초 이내면 두 기계의 로그를 그대로
|
||||
나란히 놓아도 된다. 크면 **먼저 어느 쪽이 틀렸는지 가른 다음** 보정한다.
|
||||
이 실험대는 test-server 가 NTP 미동기로 106초 빨랐고, 보정하지 않은 첫
|
||||
계산은 훅이 인증서 발급보다 104초 먼저 실행된 것이 되어 물리적으로
|
||||
불가능했다 — **음수 지연이 나오면 계산이 아니라 시계를 의심한다.**
|
||||
@@ -0,0 +1,545 @@
|
||||
# 05 — Keycloak 2노드 + PostgreSQL
|
||||
|
||||
## 이 단계가 끝나면
|
||||
|
||||
`https://auth.hyeonworks.com` 에서 관리 콘솔에 로그인되고, 두 Keycloak 이
|
||||
하나의 클러스터로 보인다.
|
||||
|
||||
## 전제
|
||||
|
||||
[04](../04-tls/) 까지 끝나 `https://` 가 열린다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 매니페스트를 적용한다
|
||||
|
||||
**하기** — `[lab host]`, **저장소 루트에서**([00](../00-lab-host/) 에서 클론했다)
|
||||
```bash
|
||||
cd ~/workspace/keycloak-pattern
|
||||
kubectl apply -f deploy/lab/k8s/keycloak-cluster.yaml
|
||||
```
|
||||
|
||||
**★ 네임스페이스를 따로 만들지 않는다.** 매니페스트 첫 문서가
|
||||
`kind: Namespace` 라 `apply` 가 같이 만든다. `kubectl create namespace` 를
|
||||
먼저 치면 두 번째 실행부터 `AlreadyExists` 로 막힌다.
|
||||
|
||||
**확인** — 적용이 끝날 때까지 기다린다
|
||||
```bash
|
||||
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=300s
|
||||
```
|
||||
|
||||
```
|
||||
partitioned roll out complete: 2 new pods have been updated...
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 이 명령은 **끝날 때까지 아무것도 안 찍고 멈춰 있다.**
|
||||
그 침묵이 정상이다. 마지막에 나오는 한 줄에서 `complete` 라는 낱말과 파드
|
||||
개수 `2` 를 본다. 300초를 다 쓰고 타임아웃으로 끝나면 그것도 답이다 —
|
||||
「안 떴다」가 확정된 것이니 3번으로 간다.
|
||||
|
||||
**이 결과가 의미하는 것** — `complete` 면 두 파드가 다 Ready 가 됐다는 뜻이라
|
||||
2번의 층별 확인으로 넘어간다. `rollout status` 를 쓰는 이유는 `get pods` 를
|
||||
반복해서 치는 것보다 나아서만이 아니라, **언제 끝났는지를 사람이 판정하지
|
||||
않아도 되기** 때문이다. StatefulSet 은 파드를 하나씩 순서대로 띄우므로
|
||||
중간에 `0/2` 로 한참 멈춰 있는 것은 정상이다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 리소스가 제대로 만들어졌는지 — 층별로 본다
|
||||
|
||||
`kubectl get pods` 만 보면 놓치는 것이 많다. **위에서 아래로** 확인한다.
|
||||
|
||||
### 2-1. 무엇이 만들어졌나
|
||||
|
||||
**확인** — 이 네임스페이스에 무엇이 서 있는가
|
||||
```bash
|
||||
kubectl -n keycloak-lab get all
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 종류별로 묶여 나오는 왼쪽 이름 열을 훑고, 파드
|
||||
줄에서는 **READY 칸의 `1/1`** 과 **RESTARTS 칸**을 본다. RESTARTS 가 0 이
|
||||
아니면 지금은 `Running` 이어도 한 번 죽었다 살아난 것이라, 3번의
|
||||
`logs --previous` 를 볼 이유가 된다.
|
||||
|
||||
**이 결과가 의미하는 것** — 여기 보이는 것이 워크로드의 전부다. 그런데
|
||||
`all` 은 이름과 달리 전부는 아니다 — **Secret·ConfigMap·PVC·Ingress 는 안
|
||||
나온다.** 이 넷이 빠졌다는 사실을 모르고 「다 만들어졌다」고 판정하는 것이
|
||||
흔한 오독이라, 한 번 더 친다.
|
||||
|
||||
```bash
|
||||
kubectl -n keycloak-lab get secret,configmap,pvc,ingress
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 네 종류가 **하나씩이라도 있는가**. PVC 줄의
|
||||
STATUS 는 `Bound` 인가, Ingress 줄의 HOSTS 칸이 `auth.hyeonworks.com` 인가.
|
||||
|
||||
**이 결과가 의미하는 것** — 매니페스트에 있는데 여기 없는 종류가 있다면
|
||||
`apply` 가 부분적으로만 먹은 것이다. Ingress 의 호스트 이름이 04 에서 발급한
|
||||
인증서의 이름과 다르면, 밖에서는 TLS 는 되는데 404 가 나온다.
|
||||
|
||||
### 2-2. Deployment → ReplicaSet → Pod 사슬
|
||||
|
||||
Deployment 는 파드를 직접 만들지 않는다. **ReplicaSet 을 만들고 그것이 파드를
|
||||
만든다.** 이 사슬 어디서 끊겼는지가 진단의 출발점이다.
|
||||
|
||||
**이 단계에서 Deployment 는 `postgres` 하나뿐이다.** Keycloak 은 StatefulSet
|
||||
이라 이 사슬을 타지 않는다.
|
||||
|
||||
**확인** — 사슬 어디까지 갔는가
|
||||
```bash
|
||||
kubectl -n keycloak-lab get rs,pod -l app=postgres
|
||||
```
|
||||
|
||||
**실측** — 2026-09-11
|
||||
```
|
||||
NAME DESIRED CURRENT READY AGE
|
||||
replicaset.apps/postgres-7b474b88c8 1 1 1 80m
|
||||
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
pod/postgres-7b474b88c8-ll87p 1/1 Running 0 80m
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — ReplicaSet 이름의 해시(`7b474b88c8`)가 파드 이름
|
||||
가운데 해시와 **같은가**. 그리고 `DESIRED`·`CURRENT`·`READY` 세 숫자가 다
|
||||
`1` 인가.
|
||||
|
||||
**★ `-l app=postgres` 에 Deployment 줄이 안 나오는 것이 정상이다.** 이 매니페스트는
|
||||
`app: postgres` 라벨을 **파드 템플릿에만** 달았고 Deployment 객체 자신에는 안
|
||||
달았다. ReplicaSet 과 파드는 템플릿에서 라벨을 물려받지만 Deployment 는 아니다.
|
||||
Deployment 를 보려면 라벨 없이 친다.
|
||||
|
||||
```bash
|
||||
kubectl -n keycloak-lab get deploy
|
||||
```
|
||||
|
||||
**★ StatefulSet 은 ReplicaSet 을 만들지 않는다.** 파드를 직접 만든다.
|
||||
|
||||
```bash
|
||||
kubectl -n keycloak-lab get sts,rs,pod -l app=keycloak
|
||||
```
|
||||
```
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
pod/keycloak-0 1/1 Running 0 19m
|
||||
pod/keycloak-1 1/1 Running 0 19m
|
||||
```
|
||||
|
||||
ReplicaSet 줄이 하나도 없다. **`keycloak-0` 처럼 순번 이름이 붙는 것도 이
|
||||
때문이다** — 해시를 끼워 넣을 중간 객체가 없다.
|
||||
|
||||
**이 결과가 의미하는 것** — 배포를 여러 번 한 Deployment 는 **ReplicaSet 이
|
||||
여러 개 쌓인다.** 새로 만들고 옛것은 `0` 으로 남기기 때문이고, 그래서
|
||||
`kubectl rollout undo` 가 가능하다. 파드가 옛 해시를 달고 있으면 새 배포가
|
||||
아직 안 넘어온 것이고, 그 상태로 실험하면 **고친 적 없는 코드를 재게 된다.**
|
||||
B 계열에서 BFF 를 여러 번 배포하면 이 목록이 실제로 일곱 줄까지 늘어난다.
|
||||
사슬이 어디서 끊겼는지는 이렇게 읽는다.
|
||||
|
||||
| 보이는 것 | 뜻 |
|
||||
|---|---|
|
||||
| Deployment 는 있는데 RS 가 없다 | 컨트롤러가 못 돌았다 — RBAC·admission 확인 |
|
||||
| RS 는 있는데 DESIRED 만 있고 CURRENT 가 0 | 파드를 못 만든다 — 이벤트를 본다 |
|
||||
| Pod 은 있는데 `0/1` | 컨테이너가 안 뜬다 — 로그와 describe |
|
||||
|
||||
> StatefulSet 은 ReplicaSet 을 쓰지 않고 파드를 직접 만든다. 그래서
|
||||
> `keycloak-0`·`keycloak-1` 처럼 **이름이 고정**이고, A-4 에서 `Terminating`
|
||||
> 파드가 안 지워지면 대체 파드가 안 생기는 이유가 이것이다.
|
||||
|
||||
### 2-3. Secret 이 실제로 들어갔나 — 세 층으로 본다
|
||||
|
||||
값이 있는 것과 파드가 그 값을 받은 것은 다르다.
|
||||
|
||||
**확인 ①** Secret 에 무슨 키가, 얼마만큼 들어 있나 — **값은 찍지 않는다**
|
||||
```bash
|
||||
kubectl -n keycloak-lab describe secret keycloak-lab-secrets
|
||||
```
|
||||
|
||||
**실측** — 아래쪽 `Data` 절만 옮긴 것이다
|
||||
```
|
||||
Data
|
||||
====
|
||||
KC_BOOTSTRAP_ADMIN_PASSWORD: 19 bytes
|
||||
POSTGRES_PASSWORD: 22 bytes
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `Data` 절의 **키 이름과 그 옆의 바이트 수** 두 칸.
|
||||
`describe` 는 값을 절대 찍지 않고 길이만 보여 준다 — 그래서 키 목록 확인과
|
||||
「비어 있지 않은가」 확인이 **한 명령으로 끝난다.** `0 bytes` 인 키가 있으면
|
||||
그 자리가 비어 있는 것이다.
|
||||
|
||||
**이 결과가 의미하는 것** — 매니페스트가 기대하는 키 이름이 여기 그대로
|
||||
있어야 한다. 이름이 하나라도 다르면 파드는 `CreateContainerConfigError` 로
|
||||
멈추고, 이유는 `describe pod` 의 Events 에 키 이름까지 적혀 나온다.
|
||||
바이트 수가 뜻밖에 크면(예: 20 이어야 할 것이 21) **`echo` 로 만들면서 개행이
|
||||
같이 들어간** 경우다 — 흔한 사고이고, 증상은 「비밀번호가 틀렸다」로 나온다.
|
||||
|
||||
> **`-o yaml` 로 보지 않는다.** base64 는 암호화가 아니라 인코딩이라
|
||||
> 화면·스크롤백·화면 공유·터미널 로그에 값이 그대로 남는다.
|
||||
> D-3 이 잰 것이 이것이다 — [`experiment-d3-secret-management.md`](../../experiment-d3-secret-management.md)
|
||||
|
||||
**확인 ②** 특정 키 하나를 따져 볼 때 — **길이만**
|
||||
|
||||
`describe` 가 보여 주는 바이트 수는 base64 를 푼 뒤의 길이다. 어떤 키
|
||||
하나가 의심스러워 다시 잴 때만 이 형태를 쓴다.
|
||||
|
||||
```bash
|
||||
kubectl -n keycloak-lab get secret keycloak-lab-secrets \
|
||||
-o jsonpath='{.data.POSTGRES_PASSWORD}' | base64 -d | wc -c
|
||||
```
|
||||
```
|
||||
22
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 숫자 하나. 그리고 그것이 확인 ①의 `22 bytes` 와
|
||||
같은가.
|
||||
|
||||
**이 결과가 의미하는 것** — 두 값이 같으면 Secret 쪽은 더 볼 것이 없다.
|
||||
`base64: invalid input` 이 나오면 키 이름을 잘못 쓴 것이다(없는 키는 빈
|
||||
문자열로 나온다). 여기까지는 **Secret 안에 무엇이 있나**이고, 파드가 그것을
|
||||
받았는지는 아직 모른다.
|
||||
|
||||
**확인 ③** 파드 안에 주입됐나 — 여기가 진짜다
|
||||
```bash
|
||||
kubectl -n keycloak-lab exec keycloak-0 -- \
|
||||
sh -c 'echo "길이=${#KC_BOOTSTRAP_ADMIN_PASSWORD}"'
|
||||
```
|
||||
```
|
||||
길이=19
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 숫자 하나. `${#VAR}` 는 값이 아니라 **글자 수**만
|
||||
내놓는다. 이것이 확인 ①의 `19 bytes` 와 같은가.
|
||||
|
||||
**이 결과가 의미하는 것** — 같으면 Secret → 파드 환경변수까지 이어졌다.
|
||||
`길이=0` 이면 Secret 에는 있는데 **이 파드가 그것을 안 받은** 것이다 —
|
||||
`envFrom`/`valueFrom` 을 빠뜨렸거나, 파드가 Secret 을 고치기 **전에** 떠서
|
||||
옛 값을 들고 있는 경우다(환경변수로 주입한 Secret 은 값을 바꿔도 파드를
|
||||
다시 만들기 전까지 갱신되지 않는다).
|
||||
|
||||
**어느 환경변수가 어느 Secret 에서 왔는지**도 볼 수 있다.
|
||||
```bash
|
||||
kubectl -n keycloak-lab get pod keycloak-0 \
|
||||
-o jsonpath='{range .spec.containers[0].env[*]}{.name}{"\t"}{.valueFrom.secretKeyRef.name}{"\n"}{end}'
|
||||
```
|
||||
```
|
||||
KC_DB
|
||||
KC_DB_URL
|
||||
KC_DB_USERNAME
|
||||
KC_DB_PASSWORD keycloak-lab-secrets ← Secret 에서 온 것만 오른쪽에 이름이 있다
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **오른쪽 칸이 채워진 줄만**. 왼쪽만 있는 줄은
|
||||
매니페스트에 값이 그대로 적힌 것이고, 오른쪽에 이름이 있는 줄이 Secret 을
|
||||
참조하는 것이다.
|
||||
|
||||
**이 결과가 의미하는 것** — 비밀이어야 할 변수의 오른쪽이 비어 있으면
|
||||
**그 값은 매니페스트에 평문으로 적혀 있다는 뜻**이고, 그 파일은 대개 git 에
|
||||
들어간다. 여기서는 `KC_DB_PASSWORD` 만 Secret 에서 온다.
|
||||
|
||||
### 2-4. Service 가 파드를 잡고 있나 — Endpoints
|
||||
|
||||
Service 가 있어도 **셀렉터가 안 맞으면 뒤가 비어 있다.** 이때 증상은
|
||||
「연결은 되는데 응답이 없다」라 원인을 찾기 어렵다.
|
||||
|
||||
**확인** — 실무자가 가장 자주 쓰는 형태
|
||||
```bash
|
||||
kubectl -n keycloak-lab describe svc keycloak | grep -i endpoints
|
||||
```
|
||||
```
|
||||
Endpoints: 10.42.0.67:8080,10.42.1.155:8080
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 쉼표로 갈린 **주소가 몇 개인가**, 그리고 그 IP 들이
|
||||
`kubectl -n keycloak-lab get pods -o wide` 의 파드 IP 와 같은가. 포트 번호가
|
||||
컨테이너가 실제로 듣는 포트인가도 함께 본다.
|
||||
|
||||
**이 결과가 의미하는 것** — 두 개면 Service 가 두 파드를 다 잡고 있다.
|
||||
**비어 있으면** Service 는 있는데 뒤가 없는 것이고, 이때 증상은
|
||||
「연결은 되는데 응답이 없다」라 원인이 Service 에 있다는 것이 잘 안 보인다.
|
||||
하나뿐이면 나머지 한 파드가 readiness 를 통과하지 못한 것이라, 그 상태로
|
||||
A층 실험을 하면 **이미 한쪽으로만 가고 있던 트래픽**을 이중화 실패로
|
||||
오독하게 된다.
|
||||
|
||||
목록으로 보려면 **EndpointSlice** 를 쓴다.
|
||||
```bash
|
||||
kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak
|
||||
```
|
||||
```
|
||||
NAME ADDRESSTYPE PORTS ENDPOINTS AGE
|
||||
keycloak-xdph6 IPv4 8080 10.42.0.67,10.42.1.155 3d23h
|
||||
```
|
||||
|
||||
> **`kubectl get endpoints` 는 쓰지 않는다.** v1.33 부터 deprecated 이고
|
||||
> 실행하면 경고가 나온다.
|
||||
> ```
|
||||
> Warning: v1 Endpoints is deprecated in v1.33+; use discovery.k8s.io/v1 EndpointSlice
|
||||
> ```
|
||||
> 옛 문서와 블로그에 이 형태가 많으니 주의한다.
|
||||
|
||||
준비 상태까지 함께 보려면 이렇게 뽑는다.
|
||||
```bash
|
||||
kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak \
|
||||
-o jsonpath='{range .items[*].endpoints[*]}{.addresses[0]}{"\t"}{.conditions.ready}{"\n"}{end}'
|
||||
```
|
||||
```
|
||||
10.42.0.67 true
|
||||
10.42.1.155 true
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 오른쪽 칸이 두 줄 다 `true` 인가. 여기서만 값을
|
||||
뽑는 형태를 쓰는 이유는, 이 두 칸이 **A층 실험 전후로 반복해서 비교할
|
||||
값**이기 때문이다. 처음 볼 때는 위의 `describe svc` 로 충분하다.
|
||||
|
||||
**이 결과가 의미하는 것** — `ready` 가 `false` 면 파드는 있는데 **readiness
|
||||
프로브를 통과하지 못한** 것이라, Service 가 그 파드로 트래픽을 보내지 않는다.
|
||||
파드 목록에서는 `Running` 으로 보이므로 `get pods` 만 봐서는 알 수 없다 —
|
||||
`0/1` 인지 `1/1` 인지가 같은 사실을 말해 준다.
|
||||
|
||||
**비어 있으면** 셀렉터와 파드 라벨이 안 맞는 것이다.
|
||||
```bash
|
||||
kubectl -n keycloak-lab get svc keycloak -o jsonpath='{.spec.selector}'; echo
|
||||
kubectl -n keycloak-lab get pods --show-labels
|
||||
```
|
||||
|
||||
### 2-5. PVC 가 실제로 붙었나
|
||||
|
||||
**확인** — 볼륨이 실제로 잡혔는가
|
||||
```bash
|
||||
kubectl -n keycloak-lab get pvc
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — STATUS 칸(`Bound`/`Pending`)과 VOLUME 칸(비어 있는지),
|
||||
그리고 STORAGECLASS 칸이 `local-path` 인가. StatefulSet 이면 PVC 이름 끝에
|
||||
파드 번호가 붙어 있어(`...-keycloak-0`) 어느 파드 것인지 바로 보인다.
|
||||
|
||||
**이 결과가 의미하는 것** — `Bound` 면 볼륨이 붙었다. `Pending` 이면
|
||||
StorageClass 가 없거나 노드에 자리가 없다. **`local-path` 는 파드가 스케줄될
|
||||
때까지 기다린다**(WaitForFirstConsumer)므로, 파드가 안 뜨면 PVC 도 `Pending`
|
||||
인 것이 정상이다. 둘이 서로를 기다리는 것처럼 보이지만 파드 쪽 원인을 먼저
|
||||
본다. 사유는 PVC 의 이벤트에 적혀 있다.
|
||||
|
||||
```bash
|
||||
kubectl -n keycloak-lab describe pvc # 이름을 안 주면 전부 나온다
|
||||
```
|
||||
|
||||
각 PVC 절의 맨 아래 `Events` 에 `waiting for first consumer` 인지
|
||||
`no persistent volumes available` 인지가 적혀 있고, 둘은 대응이 다르다 —
|
||||
앞엣것은 파드를 고치는 문제, 뒤엣것은 저장소를 고치는 문제다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 안 뜰 때 — 순서가 있다
|
||||
|
||||
**① 이벤트부터.** 로그보다 먼저다. 스케줄링·이미지·볼륨 실패가 여기 나온다.
|
||||
```bash
|
||||
kubectl -n keycloak-lab get events --sort-by=.lastTimestamp | tail -20
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 맨 아랫줄부터 거꾸로 읽는다. `--sort-by` 를 준 이유가
|
||||
그것이다. TYPE 이 `Warning` 인 줄, REASON 칸(`FailedScheduling`·`Failed`·
|
||||
`BackOff`), 그리고 OBJECT 칸이 어느 파드인가. **이벤트는 기본 한 시간만
|
||||
남는다** — 아무것도 없으면 「문제가 없다」가 아니라 「이미 지워졌다」일 수 있다.
|
||||
|
||||
**이 결과가 의미하는 것** — REASON 하나가 다음 행동을 정한다.
|
||||
`FailedScheduling` 은 노드에 자리가 없는 것이라 파드가 아니라 클러스터를
|
||||
봐야 하고, `ErrImagePull` 은 이미지 이름 문제라 로그를 볼 것도 없다.
|
||||
`BackOff` 는 컨테이너가 떴다가 죽는 중이라는 뜻이라 ③으로 간다.
|
||||
|
||||
**② describe.** 그 파드에 한정된 이벤트와 상태를 함께 본다.
|
||||
```bash
|
||||
kubectl -n keycloak-lab describe pod keycloak-0
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 위에서부터 세 곳이다. `Conditions` 절에서 `Ready` 가
|
||||
`False` 인가, 컨테이너 절의 `State`/`Last State` 와 그 안의 **`Exit Code`**,
|
||||
그리고 맨 아래 `Events`. Exit Code 는 그것만으로 말이 된다 — `137` 은
|
||||
OOM 이나 강제 종료, `1` 은 애플리케이션이 스스로 끝낸 것, `127` 은 명령을
|
||||
못 찾은 것이다.
|
||||
|
||||
**이 결과가 의미하는 것** — Exit Code 가 `137` 이면 로그에는 아무 단서도
|
||||
없을 수 있다(맞아 죽은 쪽은 유언을 못 남긴다) — 메모리 한도를 본다.
|
||||
`Ready` 만 `False` 이고 컨테이너는 살아 있으면 readiness 프로브 문제이므로
|
||||
2-4 로 돌아간다.
|
||||
|
||||
**③ 로그.** 컨테이너가 떴는데 죽는 경우다.
|
||||
```bash
|
||||
kubectl -n keycloak-lab logs keycloak-0
|
||||
kubectl -n keycloak-lab logs keycloak-0 --previous # 재시작 직전 로그
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 첫 명령에서는 **마지막 줄들**, 둘째 명령에서는
|
||||
스택 트레이스의 **맨 윗줄**(가장 안쪽 예외가 아니라 최초 원인 줄)이다.
|
||||
Keycloak 은 기동에 성공하면 `Keycloak ... started in` 한 줄을 남기므로,
|
||||
그 줄이 있는지 없는지가 「기동 중」과 「기동 실패」를 가른다.
|
||||
|
||||
**이 결과가 의미하는 것** — `--previous` 가 중요하다. CrashLoopBackOff 면
|
||||
지금 컨테이너는 방금 뜬 것이라 **죽은 이유는 이전 컨테이너 로그에 있다.**
|
||||
`--previous` 가 `not found` 를 내면 아직 한 번도 재시작하지 않은 것이고,
|
||||
그러면 지금 로그가 곧 전부다.
|
||||
|
||||
**④ 그래도 모르면 안에서 본다.**
|
||||
```bash
|
||||
kubectl -n keycloak-lab exec -it keycloak-0 -- sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 클러스터가 형성됐는지 확인한다
|
||||
|
||||
파드가 둘 다 `Running` 인 것과 **하나의 클러스터로 묶인 것**은 다르다.
|
||||
|
||||
**확인 ①** 로그
|
||||
```bash
|
||||
kubectl -n keycloak-lab logs keycloak-0 | grep ISPN000094 | tail -1
|
||||
```
|
||||
```
|
||||
ISPN000094: Received new cluster view for channel ISPN:
|
||||
[keycloak-0-10001|1] (2) [keycloak-0-10001, keycloak-1-52537]
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 세 군데다. 괄호 안의 **`(2)` 가 멤버 수**,
|
||||
대괄호 안의 **이름 목록**, 그리고 `|1` 이 **뷰 번호**(멤버가 들고 날 때마다
|
||||
올라간다). `tail -1` 을 붙였으므로 지금 보고 있는 것은 **가장 최근 뷰** 하나다.
|
||||
|
||||
**이 결과가 의미하는 것** — `(2)` 면 이 노드는 상대를 봤다. `(1)` 이면 혼자
|
||||
있다고 알고 있는 것이다. 뷰 번호가 계속 오르고 있으면 멤버가 붙었다
|
||||
떨어지기를 반복하는 중이라, 「지금 2 다」라는 스냅숏보다 그 사실이 더 중요하다.
|
||||
**이 줄은 「그때 그렇게 보였다」는 과거형이다** — 지금 상태는 확인 ③에서 본다.
|
||||
`grep` 이 아무것도 못 찾으면 클러스터링을 아직 시작도 못 한 것이니 로그를
|
||||
통째로 본다.
|
||||
|
||||
**확인 ②** 디스커버리 테이블
|
||||
```bash
|
||||
kubectl -n keycloak-lab exec deploy/postgres -- \
|
||||
psql -U keycloak -d keycloak -c 'select name, ip from jgroups_ping'
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 나온 **행이 몇 개인가**, 그리고 `ip` 칸이 2-4 에서
|
||||
본 파드 IP 와 같은가. 끝의 `(N rows)` 한 줄이 개수를 말해 준다.
|
||||
|
||||
**이 결과가 의미하는 것** — 이 표는 「**등록**되어 있다」이지 「서로 말이
|
||||
통한다」가 아니다. 두 행이 다 있는데 확인 ①이 `(1)` 이면, 서로를 찾기는
|
||||
했는데 7800 포트로 메시지가 안 가는 것이다 — A-1 에서 정확히 그 일이
|
||||
벌어졌다. 옛 파드의 행이 남아 있을 수도 있으므로 IP 를 지금 파드와 대조한다.
|
||||
|
||||
**확인 ③** 지표
|
||||
|
||||
**★ Keycloak 컨테이너에는 `curl` 이 없다.** 공식 이미지가 최소 구성이라
|
||||
`wget` 도 `nc` 도 없다. 안에서 치면 이렇게 된다.
|
||||
|
||||
```
|
||||
sh: line 1: curl: command not found
|
||||
command terminated with exit code 127
|
||||
```
|
||||
|
||||
그래서 밖에서 물어본다. **Prometheus 에 묻는 것이 가장 짧다.**
|
||||
|
||||
```bash
|
||||
kubectl -n observability exec deploy/prometheus -- \
|
||||
wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size'
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 응답은 **줄바꿈 없는 JSON 한 줄**로 온다.
|
||||
`data.result` 배열에서 원소가 **몇 개인가**(노드 수), 각 원소에서 두 군데만
|
||||
읽는다 — `"metric"` 안의 `node` 라벨(어느 Keycloak 인가)과 `"value"` 배열의
|
||||
**둘째 원소**(따옴표에 싸인 값). **이 실험대에는 `jq` 가 없다.** 파서를
|
||||
따로 짜지 말고 화면에 나온 JSON 을 그대로 읽는다.
|
||||
|
||||
**실측** — 그렇게 읽어낸 값이다
|
||||
```
|
||||
keycloak-1 → 2
|
||||
keycloak-0 → 2
|
||||
```
|
||||
|
||||
**이 결과가 의미하는 것** — 원소가 둘이고 값이 둘 다 2 면 두 노드가 서로를
|
||||
보고 있다. 원소가 하나뿐이면 나머지 노드의 스크레이프가 실패한 것이라
|
||||
**클러스터 문제가 아니라 관측 문제**일 수 있다 — 06 의 targets 를 본다.
|
||||
|
||||
Prometheus 가 아직 없다면 임시 파드를 띄운다.
|
||||
|
||||
```bash
|
||||
K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
|
||||
kubectl -n keycloak-lab run m --rm -i --restart=Never \
|
||||
--image=curlimages/curl:8.11.1 --quiet --command -- \
|
||||
sh -c "curl -s http://$K0:9000/metrics | grep '^vendor_cluster_size'"
|
||||
```
|
||||
|
||||
```
|
||||
vendor_cluster_size{cache_manager="keycloak",node="keycloak-0-46674"} 2.0
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 줄 끝의 **숫자**와, 중괄호 안 `node=` 라벨이 **어느
|
||||
파드인가**. 이 형태는 그 파드 하나에게 직접 물은 것이라 라벨의 노드 이름과
|
||||
`$K0` 로 고른 파드가 반드시 일치한다.
|
||||
|
||||
**이 결과가 의미하는 것** — `--rm` 을 붙였으므로 파드는 끝나면 사라진다.
|
||||
`curlimages/curl` 을 쓰는 이유는 **Keycloak 이미지에 도구가 없기** 때문이고,
|
||||
같은 이유로 이 방법은 Keycloak 뿐 아니라 최소 이미지 전부에 쓴다. 값이
|
||||
안 나오고 연결 거부가 나면 9000(관리 포트)이 안 열린 것이다.
|
||||
|
||||
> **두 값이 다를 수 있다.** 각 노드가 자기가 아는 멤버 수를 보고하므로,
|
||||
> 분단되면 한쪽은 2 다른 쪽은 1 이 된다. **한 노드만 보면 분단을 놓친다.**
|
||||
|
||||
> **셋이 다른 것을 본다.** 로그는 「그때 그렇게 보였다」이고, 테이블은
|
||||
> 「지금 등록되어 있다」이며, 지표는 「지금 그 노드가 그렇게 안다」이다.
|
||||
> A-1 에서 이 셋이 갈렸다 — 테이블에는 둘 다 있는데 메시지는 안 갔다.
|
||||
|
||||
---
|
||||
|
||||
## 5. 밖에서 닿는지
|
||||
|
||||
**확인** — 2홉을 다 지나 파드까지 닿는가
|
||||
```bash
|
||||
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
|
||||
```
|
||||
```
|
||||
200
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 코드 한 칸. 여기서 값만 뽑는 형태를 쓰는 것은
|
||||
**03·04 에서 잰 것과 같은 명령으로 같은 값이 나오는지** 비교하는 것이
|
||||
목적이기 때문이다. 이 자리에서 처음 보는 것이 아니다.
|
||||
|
||||
**이 결과가 의미하는 것** — `200` 이면 nginx → Traefik → Ingress → Service →
|
||||
파드가 전부 이어졌다. `502`·`503` 이면 뒤에서부터 되짚는다 — Ingress 가
|
||||
있는지(2-1), Service 뒤에 파드가 있는지(2-4), 파드가 Ready 인지(2-2) 순서다.
|
||||
처음 보는 오류라 헤더가 필요하면 값만 뽑는 형태를 버리고 읽는 형태로 바꾼다.
|
||||
|
||||
```bash
|
||||
curl -I https://auth.hyeonworks.com/realms/master
|
||||
```
|
||||
|
||||
브라우저로 `https://auth.hyeonworks.com/admin` 에 들어가 관리자로 로그인한다.
|
||||
비밀번호는 위 2-3 의 Secret 에 있다.
|
||||
|
||||
---
|
||||
|
||||
## 막히면
|
||||
|
||||
| 증상 | 어디를 보나 |
|
||||
|---|---|
|
||||
| 파드가 `Pending` | `describe pod` 의 Events — 스케줄 불가 사유 |
|
||||
| `ImagePullBackOff` | 이미지 이름·태그. 자체 빌드면 두 노드 모두에 반입했는가 |
|
||||
| `CrashLoopBackOff` | `logs --previous` |
|
||||
| `Running` 인데 `0/1` | readiness 프로브 실패. `describe` 의 Conditions |
|
||||
| 밖에서 502 | Ingress → Service → Endpoints 순으로 뒤를 본다 |
|
||||
| 클러스터가 1로 보임 | 7800 이 막혔거나 디스커버리 실패. 위 4번 셋 다 확인 |
|
||||
|
||||
---
|
||||
|
||||
## 근거를 재려면 (선택)
|
||||
|
||||
세션이 실제로 어디 저장되는지는 DB 를 직접 본다.
|
||||
|
||||
```bash
|
||||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "select offline_flag, count(*) from offline_user_session group by 1"
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `offline_flag` 가 `0` 인 행의 `count`. **로그인
|
||||
전과 후에 두 번 재서 그 수의 차이**를 본다. 한 번만 재면 아무것도 알 수
|
||||
없다. 행이 아예 없으면(`0 rows`) 표는 있는데 비어 있는 것이다.
|
||||
|
||||
**이 결과가 의미하는 것** — 로그인 뒤 수가 늘면 세션이 DB 에 남는 것
|
||||
(`persistent-user-sessions` 켜짐)이고, 안 늘면 메모리에만 있는 것이다.
|
||||
그 차이가 A층 결론 전체를 뒤집는다 — 메모리에만 있으면 파드를 재시작하는
|
||||
순간 세션이 사라지고, DB 에 있으면 살아남는다.
|
||||
[A-7](../../experiment-a7-volatile-comparison.md)
|
||||
@@ -0,0 +1,199 @@
|
||||
# 06 — 관측
|
||||
|
||||
## 이 단계가 끝나면
|
||||
|
||||
Prometheus 가 Keycloak 을 긁고, `vendor_cluster_size` 로 클러스터 상태를
|
||||
밖에서 볼 수 있다.
|
||||
|
||||
## 전제
|
||||
|
||||
[05](../05-keycloak/) 가 끝나 Keycloak 두 노드가 떴다.
|
||||
|
||||
## 왜 필요한가
|
||||
|
||||
실험의 판정을 **밖에서만** 하면 놓친다. A-1 에서 7800 을 끊었는데 외부 응답이
|
||||
전부 200 이었다 — 분단된 노드가 스스로 로드밸런서에서 빠졌기 때문이다.
|
||||
클러스터 안을 보는 눈이 따로 있어야 한다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 적용
|
||||
|
||||
**하기** — `[lab host]`, **저장소 루트에서**([00](../00-lab-host/) 에서 클론했다)
|
||||
```bash
|
||||
cd ~/workspace/keycloak-pattern
|
||||
kubectl apply -f deploy/lab/k8s/observability.yaml
|
||||
kubectl -n observability rollout status deploy/prometheus --timeout=180s
|
||||
```
|
||||
|
||||
**확인** — 무엇이 몇 개 떴는가
|
||||
```bash
|
||||
kubectl -n observability get pods
|
||||
```
|
||||
|
||||
**실측**
|
||||
```
|
||||
grafana-845b5678cf-b6gvc 1/1 Running
|
||||
node-exporter-9qk9w 1/1 Running
|
||||
node-exporter-c2mz4 1/1 Running
|
||||
prometheus-6774f94f7c-pzr2t 1/1 Running
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **줄이 네 개인가**, 그리고 READY 칸이 전부 `1/1`
|
||||
인가. 특히 `node-exporter` 로 시작하는 줄이 **둘**인지 센다.
|
||||
|
||||
**이 결과가 의미하는 것** — node-exporter 가 둘인 것은 DaemonSet 이라 노드마다
|
||||
하나씩 뜨기 때문이다. **하나뿐이면 노드 하나가 빠진 것**이고, 그러면 그
|
||||
노드의 CPU·메모리·디스크 지표가 통째로 없는 채로 실험을 하게 된다 —
|
||||
이때는 관측이 아니라 02 의 노드 상태부터 본다. 어느 노드에 붙었는지는
|
||||
`-o wide` 로 확인한다.
|
||||
|
||||
```bash
|
||||
kubectl -n observability get pods -o wide
|
||||
```
|
||||
|
||||
## 2. 무엇을 긁고 있나 — 여기가 중요하다
|
||||
|
||||
**확인** — Prometheus 가 스스로 밝히는 대상 목록
|
||||
```bash
|
||||
kubectl -n observability exec deploy/prometheus -- \
|
||||
wget -qO- localhost:9090/api/v1/targets | grep -o '"job":"[^"]*"' | sort -u
|
||||
```
|
||||
|
||||
**실측**
|
||||
```
|
||||
"job":"keycloak"
|
||||
"job":"kubelet"
|
||||
"job":"node-exporter"
|
||||
"job":"prometheus"
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **거기 있는 이름이 아니라 없는 이름**이다. 응답은
|
||||
JSON 한 덩어리이고 그대로는 못 읽는다. **이 실험대에는 `jq` 가 없으므로**
|
||||
`grep -o` 로 필요한 필드만 뽑고 `sort -u` 로 중복을 없앤 것이다 — 여기까지가
|
||||
사람이 손으로 치는 선이고, 그 이상 가공해야 한다면 파서를 짜지 말고 화면에
|
||||
나온 JSON 을 그대로 읽는다.
|
||||
|
||||
**이 결과가 의미하는 것** — **★ Redis · BFF · PostgreSQL 이 없다.** 이 실험대는
|
||||
그것들을 긁지 않는다. 그래서 B층 실험 대부분에 Grafana 화면이 없는데,
|
||||
**안 찍은 것이 아니라 지표가 없는 것**이다. 어떤 실험에서 지표를 못 찾으면
|
||||
「측정이 실패했다」로 적기 전에 **이 목록에 그 job 이 있었는지부터** 본다.
|
||||
|
||||
> 이것을 「스크린샷 누락」이 아니라 **측정된 공백**으로 기록했다.
|
||||
> [`evidence/followup/04-observability-gap.txt`](../../evidence/followup/04-observability-gap.txt)
|
||||
|
||||
목록에 있는데도 값이 안 나온다면 그다음은 **상태**다. 같은 응답에서
|
||||
`health` 만 훑는다.
|
||||
|
||||
```bash
|
||||
kubectl -n observability exec deploy/prometheus -- \
|
||||
wget -qO- localhost:9090/api/v1/targets | tr ',' '\n' | grep -E '"(job|health|lastError)"'
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `"health":"up"` 이 아닌 줄과, 그 **바로 뒤에 붙는
|
||||
`lastError`**. `tr ',' '\n'` 으로 쉼표마다 줄을 나눴으므로 필드가 원래
|
||||
순서대로 세로로 늘어선다 — job 줄 아래에 그 대상의 health 가 온다.
|
||||
|
||||
**이 결과가 의미하는 것** — `down` 인 대상이 있으면 `lastError` 가 이유를
|
||||
그대로 말해 준다(연결 거부·타임아웃·404). 3번에서 값이 한 노드만 나오는
|
||||
증상의 원인이 대개 여기 있고, 그때 **클러스터가 아니라 스크레이프가 문제**다.
|
||||
|
||||
## 3. 클러스터 상태를 본다
|
||||
|
||||
**확인** — 두 노드가 각각 몇 명을 보고 있는가
|
||||
```bash
|
||||
kubectl -n observability exec deploy/prometheus -- \
|
||||
wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size'
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 응답은 **줄바꿈 없는 JSON 한 줄**이다. `data.result`
|
||||
배열의 원소가 **몇 개인가**(보고하는 노드 수), 각 원소에서 두 군데만 읽는다 —
|
||||
`"metric"` 안의 `node` 라벨과 `"value"` 배열의 **둘째 원소**(따옴표에 싸인
|
||||
값). `jq` 가 없으므로 눈으로 읽는다.
|
||||
|
||||
**실측** — 그렇게 읽어낸 값이다
|
||||
```
|
||||
keycloak-1 → 2
|
||||
keycloak-0 → 2
|
||||
```
|
||||
|
||||
**이 결과가 의미하는 것** — **두 노드가 각각 자기가 아는 멤버 수를 보고한다.**
|
||||
둘 다 2 면 클러스터가 온전하다. 분단되면 한쪽은 2, 다른 쪽은 1 이 된다 —
|
||||
**한 노드만 보면 분단을 놓친다.** 원소가 하나뿐이면 분단이 아니라 스크레이프
|
||||
실패일 수 있으므로 2번의 `health` 를 먼저 본다. 값이 아예 안 나오면
|
||||
`"result":[]` 로 빈 배열이 오는데, 이는 「0 이다」가 아니라 **「그런 지표가
|
||||
없다」**는 뜻이다.
|
||||
|
||||
자주 보는 지표들이다.
|
||||
|
||||
| 지표 | 무엇 |
|
||||
|---|---|
|
||||
| `vendor_cluster_size` | 이 노드가 아는 멤버 수 |
|
||||
| `vendor_jgroups_*` | JGroups 프로토콜별 카운터 |
|
||||
| `vendor_statistics_approximate_entries_unique{cache="sessions"}` | 이 노드의 세션 캐시 엔트리 수 |
|
||||
| `agroal_*` | JDBC 커넥션 풀 |
|
||||
| `up` | 스크레이프 성공 여부 |
|
||||
|
||||
## 4. `up` 을 믿지 않는다
|
||||
|
||||
**A-2 에서 503 이 나는 동안에도 `up` 은 1 이었다.** 프로세스가 살아 있고
|
||||
`/metrics` 가 응답하기만 하면 1 이므로 **「살아 있지만 쓸모없는」 상태를
|
||||
보지 못한다.**
|
||||
|
||||
```bash
|
||||
kubectl -n observability exec deploy/prometheus -- \
|
||||
wget -qO- 'localhost:9090/api/v1/query?query=up'
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 원소마다 `job` 라벨과 값(`"1"`/`"0"`). 값이 1 이라는
|
||||
것은 **마지막 스크레이프가 성공했다**는 사실 하나만 말한다.
|
||||
|
||||
**이 결과가 의미하는 것** — `up=1` 은 「프로세스가 살아 있고 `/metrics` 가
|
||||
응답했다」이지 「그 서비스가 쓸모 있다」가 아니다. 그래서 경보를 `up == 0`
|
||||
하나로 걸면 **A-2 같은 「살아 있지만 503」 상태를 통째로 놓친다.**
|
||||
기능 지표를 함께 본다 — 밖에서 실제 응답을 받아 보는 것이 가장 짧다.
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 코드 한 칸. 여기서 값만 뽑는 형태를 쓰는 것은
|
||||
`up` 의 1/0 과 **나란히 놓고 비교하기 위해서**다. 처음 보는 오류를 파고들
|
||||
때는 `curl -I` 나 `curl -v` 로 바꾼다(04 참조).
|
||||
|
||||
**이 결과가 의미하는 것** — `up=1` 인데 이쪽이 200 이 아니면 그 조합이 곧
|
||||
「살아 있지만 쓸모없는」 상태의 증거다. 그 두 값을 같이 기록해 두는 것이
|
||||
A-2 의 판정 근거였다.
|
||||
|
||||
## 5. Grafana 를 볼 때
|
||||
|
||||
**하기** — 밖에 열지 않고 포트포워드로 본다
|
||||
```bash
|
||||
kubectl -n observability port-forward svc/grafana 3000:3000
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `Forwarding from 127.0.0.1:3000 -> 3000` 한 줄이
|
||||
찍히고 **명령이 그대로 멈춰 있는가**. 이 명령은 끝나지 않는 것이 정상이라,
|
||||
터미널 하나를 여기에 내준다. 브라우저를 열면 그 아래에 `Handling connection`
|
||||
줄이 하나씩 붙는다 — 그것이 붙지 않으면 브라우저가 다른 곳을 보고 있는 것이다.
|
||||
|
||||
**이 결과가 의미하는 것** — 이 터널은 **명령을 실행한 기계에서만** 열린다.
|
||||
워크스테이션에서 쳤으면 워크스테이션 브라우저로 `http://localhost:3000`,
|
||||
lab host 에서 쳤으면 lab host 에서 봐야 한다. `bind: address already in use`
|
||||
면 3000 을 이미 누가 쓰는 것이니 `3001:3000` 처럼 왼쪽만 바꾼다. Ctrl+C 로
|
||||
끊으면 터널도 사라진다 — 밖에 포트를 여는 것이 아니라 **보는 동안만 뚫는
|
||||
것**이라 실험대의 노출면이 늘지 않는다.
|
||||
|
||||
> 실험 중에는 Grafana 보다 **Prometheus 쿼리 API** 가 편하다. 값을 그대로
|
||||
> 뽑아 비교할 수 있고 스크린샷보다 근거로 남기기 좋다.
|
||||
|
||||
---
|
||||
|
||||
## 막히면
|
||||
|
||||
| 증상 | 원인 | 확인 |
|
||||
|---|---|---|
|
||||
| Keycloak 지표가 안 보임 | 9000 이 안 열렸거나 스크레이프 설정 누락 | 위 2번 targets |
|
||||
| 값이 한 노드만 나옴 | 다른 노드 스크레이프 실패 | targets 의 `health` 필드 |
|
||||
| 컨테이너 안에서 curl 실패 | **Keycloak 이미지에 curl 이 없다** | 밖에서 Prometheus 로 묻는다 |
|
||||
| Grafana 에 데이터 없음 | 데이터소스 주소 오류 | Prometheus 서비스 이름 확인 |
|
||||
@@ -0,0 +1,113 @@
|
||||
# 실습 가이드 — 직접 쳐보면서 만드는 실험대
|
||||
|
||||
이 문서 묶음은 **읽는 문서가 아니라 따라 치는 문서**다. 기존
|
||||
[`experiment-*.md`](../) 가 「무엇을 발견했나」를 적었다면, 여기는
|
||||
「그 발견을 재현하려면 무엇을 어떤 순서로 치는가」를 적는다.
|
||||
|
||||
## 두 종류의 명령을 구별해 적는다
|
||||
|
||||
실무자가 터미널에서 치는 명령과, 근거를 남기려고 재는 명령은 길이도 목적도
|
||||
다르다. 이 가이드는 둘을 섞지 않는다.
|
||||
|
||||
| 표시 | 무엇인가 |
|
||||
|---|---|
|
||||
| **하기** · **확인** | 실무자가 실제로 치는 형태. 짧고, 한 번에 하나씩 |
|
||||
| **근거를 재려면** | 이 실험대가 문서에 남기려고 쓴 긴 형태. 평소에는 필요 없다 |
|
||||
|
||||
예를 들어 nginx 에러 로그가 잘렸을 때, 실무자는 잘린 걸 보고 access 로그로
|
||||
넘어간다. 길이를 재서 2048인지 확인하는 것은 **몰라서 재는** 것이고, 알면
|
||||
재지 않는다.
|
||||
|
||||
같은 이유로 `curl` 도 두 형태가 있다.
|
||||
|
||||
```bash
|
||||
curl -I https://auth.hyeonworks.com/realms/master # 한 번 볼 때
|
||||
curl -s -o /dev/null -w '%{http_code}\n' <url> # 여러 번 재서 비교할 때
|
||||
```
|
||||
|
||||
이 가이드의 **확인**은 값을 대조해야 해서 두 번째 형태를 자주 쓴다. 실제로
|
||||
터미널에서 눈으로 볼 때는 첫 번째로 충분하다.
|
||||
|
||||
## 자리표시자를 두지 않는다
|
||||
|
||||
`<토큰>` 처럼 적어 두면 그 값을 어디서 가져오는지가 문서 밖으로 나간다.
|
||||
이 가이드는 **값을 찾는 명령을 함께 적는다.**
|
||||
|
||||
```bash
|
||||
TOKEN=$(ssh kc-lab-1 'sudo cat /var/lib/rancher/k3s/server/node-token')
|
||||
echo "${#TOKEN} 자" # 값이 아니라 길이만 확인한다
|
||||
```
|
||||
|
||||
비밀은 길이나 존재 여부만 확인하고 값을 찍지 않는다. 터미널 스크롤백과
|
||||
화면 공유에 남기 때문이다.
|
||||
|
||||
## 어느 기계에서 치는가
|
||||
|
||||
이 실험대에는 셸이 네 개 있고, **같은 명령이 어디서 도느냐에 따라 결과가
|
||||
달라진다.** 그래서 모든 코드 블록 앞에 어디서 치는지를 붙인다.
|
||||
|
||||
| 표시 | 어느 기계 | 어떻게 들어가나 |
|
||||
|---|---|---|
|
||||
| `[워크스테이션]` | 평소 쓰는 개발 머신 | — |
|
||||
| `[lab host]` | `test-server`. `virsh` 가 도는 곳 | `ssh test-server` |
|
||||
| `[kc-lab-edge]` | 엣지 게스트 — nginx · certbot | `ssh kc-lab-edge` (**lab host 에서만**) |
|
||||
| `[kc-lab-1]` | k3s server 게스트 | `ssh kc-lab-1` (**lab host 에서만**) |
|
||||
| `[kc-lab-2]` | k3s agent 게스트 | `ssh kc-lab-2` (**lab host 에서만**) |
|
||||
|
||||
**기본은 `[lab host]` 다.** 게스트는 libvirt NAT(`192.168.122.0/24`) 안에
|
||||
있어서 워크스테이션에서 직접 닿지 않는다. `ssh kc-lab-1` 이라는 별칭도
|
||||
lab host 의 `~/.ssh/config` 에만 있다.
|
||||
|
||||
```bash
|
||||
[워크스테이션] $ ping -c1 192.168.122.11
|
||||
1 packets transmitted, 0 received, 100% packet loss # 경로가 없다
|
||||
```
|
||||
|
||||
**게스트 안에 들어가서 다음 단계를 치지 않는다.** 게스트에는 lab host 의
|
||||
개인키도 `~/.ssh/config` 도 없으므로, 게스트 안에서 `ssh kc-lab-1` 을 치면
|
||||
이렇게 끝난다.
|
||||
|
||||
```bash
|
||||
[kc-lab-1] $ ssh kc-lab-1 'sudo cat /var/lib/rancher/k3s/server/node-token'
|
||||
Host key verification failed.
|
||||
```
|
||||
|
||||
**이 실패가 조용한 이유** — 위 명령을 `TOKEN=$(...)` 로 감싸면 오류는
|
||||
stderr 로 흘러가고 `TOKEN` 에는 **빈 문자열**이 담긴다. 셸은 아무 불평도
|
||||
하지 않는다. 그래서 게스트에 로그인한 채 다음 단계를 치면 몇 단계 뒤에
|
||||
가서야 증상이 나타난다.
|
||||
|
||||
그래서 이 가이드는 게스트에 **로그인하지 않고** lab host 에서
|
||||
`ssh kc-lab-1 '...'` 형태로 원격 실행한다. 셸이 하나뿐이면 「지금 어디
|
||||
있더라」가 생기지 않는다.
|
||||
|
||||
## 순서
|
||||
|
||||
앞 단계가 끝나야 다음이 된다. 각 단계 첫머리에 「이 단계가 끝나면」이 있고,
|
||||
그 상태를 확인하는 명령이 있다. **그것이 통과해야 다음으로 넘어간다.**
|
||||
|
||||
| 단계 | 무엇을 세우나 | 끝나면 확인되는 것 |
|
||||
|---|---|---|
|
||||
| [00](00-lab-host/) | lab host 가상화 준비 | `virsh list` 가 돈다 |
|
||||
| [01](01-vms/) | VM 세 대 (엣지 + k3s 2노드) | 세 게스트에 SSH 가 붙는다 |
|
||||
| [02](02-k3s/) | k3s server + agent | `kubectl get nodes` 에 둘 다 Ready |
|
||||
| [03](03-nginx/) | 엣지 nginx 라우팅 + 호스트 DNAT | 밖에서 요청이 파드까지 닿는다 |
|
||||
| [04](04-tls/) | Let's Encrypt | `https://` 가 열리고 체인이 4단계 |
|
||||
| [05](05-keycloak/) | Keycloak 2노드 + PostgreSQL | 관리 콘솔 로그인이 된다 |
|
||||
| [06](06-observability/) | Prometheus · Grafana | `vendor_cluster_size` 가 2 |
|
||||
| [experiments](experiments/) | 실험 26건 | 각 실험의 판정 기준 |
|
||||
|
||||
## 이 가이드가 검증된 방식
|
||||
|
||||
**읽기 전용 확인은 돌아가는 실험대에서 실제로 실행해 출력을 그대로 실었다.**
|
||||
버전·IP·메모리 같은 값은 지어내지 않았다.
|
||||
|
||||
**만드는 명령은 다르다.** VM 을 다시 만들거나 k3s 를 다시 깔면 지금 돌고 있는
|
||||
실험대가 없어지므로, 그 명령들은 **실제로 구축할 때 쓴 것을 그대로 옮겼고**
|
||||
결과 상태를 확인하는 것으로 대신했다. 어느 쪽인지 각 단계에 표시한다.
|
||||
|
||||
## 막혔을 때
|
||||
|
||||
각 단계 끝에 **「막히면」** 표가 있다. 거기 적힌 증상은 전부 이 실험대가
|
||||
실제로 겪은 것이고, 원문은 [`../evidence/`](../evidence/) 에 있다.
|
||||
지어낸 실패 사례는 없다.
|
||||
@@ -0,0 +1,496 @@
|
||||
# 실험대 가상화 계층 — 실측 기록
|
||||
|
||||
## 이 문서가 무엇인가
|
||||
|
||||
[`docs/guides/`](guides/) 가 **「무엇을 어떤 순서로 치는가」** 를 적는다면, 이
|
||||
문서는 **「그때 실제로 어떤 값이 나왔는가」** 를 적는다. 가이드에 이미 있는
|
||||
절차는 반복하지 않는다.
|
||||
|
||||
여기 적힌 숫자는 전부 **2026-09-10 에 `test-server` 에서 실제로 돌려 받은
|
||||
출력**이다. 추정값·예상값은 없다. 없는 값은 「미측정」이라고 쓴다.
|
||||
|
||||
| 이 문서가 답하는 것 | 가이드가 답하는 것 |
|
||||
|---|---|
|
||||
| 5120MB 를 줬는데 실제로 얼마를 쓰나 | 메모리를 얼마로 주나 |
|
||||
| 20GB 오버레이가 디스크를 얼마나 먹나 | 오버레이를 어떻게 만드나 |
|
||||
| cloud-init 이 몇 초 걸리나 | cloud-init 을 어떻게 쓰나 |
|
||||
| 철거하면 무엇이 남나 | 무엇을 세우나 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 측정 환경
|
||||
|
||||
```bash
|
||||
lscpu | grep -E "^Model name|^CPU\(s\):|^Thread|^Core"
|
||||
free -m | head -2
|
||||
df -h /
|
||||
virsh --version; qemu-system-x86_64 --version | head -1; uname -r
|
||||
```
|
||||
|
||||
**실측**
|
||||
|
||||
```
|
||||
CPU(s): 8
|
||||
Model name: 11th Gen Intel(R) Core(TM) i5-1135G7 @ 2.40GHz
|
||||
Thread(s) per core: 2
|
||||
Core(s) per socket: 4
|
||||
|
||||
total used free shared buff/cache available
|
||||
Mem: 11648 5642 2599 4 3776 6005
|
||||
|
||||
/dev/nvme0n1p3 226G 9.9G 204G 5% /
|
||||
|
||||
12.7.0
|
||||
QEMU emulator version 11.1.1
|
||||
7.2.2-arch1-1
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `Core(s) per socket` 4 에 `Thread(s) per core` 2 라
|
||||
논리 코어가 8 이다. **VM 에 주는 vCPU 는 물리 코어가 아니라 논리 코어를 나눠
|
||||
쓰는 것**이고, 합이 8 을 넘어도 libvirt 는 막지 않는다(오버커밋). 이 실험대는
|
||||
`2 + 2 + 1 = 5` 로 잡아 여유를 뒀다.
|
||||
|
||||
`free` 의 `available`(6005MB)이 `free`(2599MB)보다 훨씬 큰 것이 정상이다 —
|
||||
`buff/cache` 3776MB 는 필요하면 회수된다. **VM 을 얼마나 더 띄울 수 있는지는
|
||||
`free` 가 아니라 `available` 로 본다.**
|
||||
|
||||
### 중첩 가상화
|
||||
|
||||
```bash
|
||||
lscpu | grep Virtualization
|
||||
cat /sys/module/kvm_intel/parameters/nested
|
||||
```
|
||||
|
||||
```
|
||||
Virtualization: VT-x
|
||||
Y
|
||||
```
|
||||
|
||||
**켜져 있지만 이 실험대는 쓰지 않는다.** 일회용으로 만들려는 층(게스트)은
|
||||
이미 일회용이고, 비싼 층(호스트의 인증서·DNS)은 중첩으로 해결되지 않는다.
|
||||
그리고 A-6 의 지연 주입처럼 **시간을 재는 실험**에서 중첩은 측정값을 왜곡한다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 자원 — 할당과 실사용은 다르다
|
||||
|
||||
VM 에 준 메모리는 **상한이지 점유가 아니다.** virtio-balloon 이 안 쓰는 만큼
|
||||
호스트에 돌려준다.
|
||||
|
||||
```bash
|
||||
for v in kc-lab-1 kc-lab-2; do
|
||||
printf "%-10s 할당 %sMB 실사용 %sMB\n" "$v" \
|
||||
"$(( $(virsh dommemstat $v | awk '/actual/{print $2}') / 1024 ))" \
|
||||
"$(( ($(virsh dommemstat $v | awk '/actual/{print $2}') \
|
||||
- $(virsh dommemstat $v | awk '/^unused/{print $2}')) / 1024 ))"
|
||||
done
|
||||
```
|
||||
|
||||
**실측** — k3s 만 떠 있고 Keycloak 은 아직 안 올린 상태
|
||||
|
||||
```
|
||||
kc-lab-1 할당 5120MB 실사용 353MB
|
||||
kc-lab-2 할당 3120MB 실사용 301MB
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 두 가지다.
|
||||
|
||||
① **실사용이 할당의 7% 밖에 안 된다.** k3s server 가 353MB 다. 「5GB 를 줬으니
|
||||
5GB 를 쓴다」가 아니다.
|
||||
|
||||
② **`kc-lab-2` 의 할당이 4096 이 아니라 3120 이다.** `virt-install --memory 4096`
|
||||
으로 만들었는데 줄어 있다. virtio-balloon 이 회수해 간 것으로 보인다.
|
||||
`dommemstat` 의 `actual` 은 **현재 할당**이지 선언한 상한이 아니다. 상한은
|
||||
`virsh dominfo` 의 `Max memory` 에 있다 — **다만 이 실험대에서 그 값을 나란히
|
||||
찍어 보지는 않았다(미측정).** 둘을 헷갈리면 「메모리가 왜 줄었지」가 된다.
|
||||
|
||||
**이 결과가 의미하는 것** — 합계 8240MB 를 할당했지만 실제 점유는 654MB 다.
|
||||
그래서 11.6GB 짜리 호스트에서 VM 세 대가 무리 없이 돈다. **다만 이것은 지금
|
||||
k3s 만 떠 있어서다** — Keycloak 2 파드 + PostgreSQL + Redis + Prometheus 가
|
||||
올라가면 늘어난다. 그 시점의 값은 **미측정**이다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 디스크 — 오버레이는 얼마나 쓰나
|
||||
|
||||
게스트 디스크는 `base.qcow2` 위의 **오버레이**다. 20GB 를 두 개 만들어도
|
||||
바닥 이미지는 한 벌이고 변경분만 쌓인다.
|
||||
|
||||
```bash
|
||||
qemu-img info /var/lib/libvirt/images/base.qcow2 | head -4
|
||||
ls -l /var/lib/libvirt/images/
|
||||
```
|
||||
|
||||
**실측** — 엣지를 만들기 **전**, k3s 2 노드만 있던 시점이다.
|
||||
|
||||
```
|
||||
image: /var/lib/libvirt/images/base.qcow2
|
||||
file format: qcow2
|
||||
virtual size: 3 GiB (3221225472 bytes)
|
||||
disk size: 335 MiB
|
||||
|
||||
-rw-r--r-- base.qcow2 351404032 (335 MiB)
|
||||
-rw------- kc-lab-1.qcow2 1521025024 (1.4 GiB) ← 선언 20GB
|
||||
-rw------- kc-lab-2.qcow2 697499648 (665 MiB) ← 선언 20GB
|
||||
-rw------- seed-kc-lab-1.iso 378880 (370 KiB)
|
||||
-rw------- seed-kc-lab-2.iso 378880 (370 KiB)
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `base.qcow2` 의 `virtual size` 3GiB 와 `disk size`
|
||||
335MiB 의 차이. **그리고 게스트 디스크가 20GB 로 선언됐는데 1.4GB 와 665MB
|
||||
라는 것.** k3s server 가 agent 보다 두 배 이상 쓰는 것은 컨트롤 플레인
|
||||
바이너리와 SQLite 때문이다.
|
||||
|
||||
**이 결과가 의미하는 것** — 20GB × 2 = 40GB 를 선언했지만 실제로는 2.1GB 를
|
||||
썼다. 디스크는 병목이 아니다. **`df` 로 본 사용량과 VM 에 선언한 크기를 같은
|
||||
것으로 보면 안 된다.**
|
||||
|
||||
### 스토리지 풀
|
||||
|
||||
```bash
|
||||
virsh pool-info default
|
||||
```
|
||||
|
||||
```
|
||||
Name: default
|
||||
State: running
|
||||
Persistent: yes Autostart: yes
|
||||
Capacity: 225.31 GiB
|
||||
Allocation: 7.84 GiB
|
||||
Available: 217.46 GiB
|
||||
```
|
||||
|
||||
`Allocation` 은 **풀 전체**(호스트 루트 파일시스템)의 사용량이지 VM 만의
|
||||
사용량이 아니다. VM 이 얼마를 쓰는지는 위 `ls -l` 로 본다.
|
||||
|
||||
---
|
||||
|
||||
## 4. 부팅 — cloud-init 은 얼마나 걸리나
|
||||
|
||||
```bash
|
||||
for i in $(seq 1 30); do
|
||||
ssh -o BatchMode=yes -o ConnectTimeout=4 kc-lab-edge 'cloud-init status' 2>/dev/null \
|
||||
| grep -q 'status: done' && { echo "완료 (약 $((i*10))초)"; break; }
|
||||
sleep 10
|
||||
done
|
||||
ssh kc-lab-edge 'hostname; ip -4 -br addr show enp1s0; cloud-init status'
|
||||
```
|
||||
|
||||
**실측** — `package_update: true` 에 패키지 5개(`curl` `nftables` `nginx`
|
||||
`certbot` `python3-certbot-dns-cloudflare`)를 받는 게스트
|
||||
|
||||
```
|
||||
완료 (약 50초)
|
||||
|
||||
kc-lab-edge
|
||||
enp1s0 UP 192.168.122.10/24 metric 100
|
||||
status: done
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `cloud-init status` 의 세 상태를 구분한다.
|
||||
|
||||
| 값 | 뜻 |
|
||||
|---|---|
|
||||
| `running` | 아직 진행 중. **기다린다** |
|
||||
| `done` | 끝났다 |
|
||||
| `error` | 실패. `cloud-init status --long` 으로 어느 모듈인지 본다 |
|
||||
|
||||
**이 결과가 의미하는 것** — SSH 가 붙는 것과 cloud-init 이 끝난 것은 다르다.
|
||||
SSH 는 먼저 열리고 패키지 설치는 뒤에 이어진다. **`done` 을 안 기다리고 다음
|
||||
단계를 치면 「방금 깐 패키지가 없다」가 나온다.**
|
||||
|
||||
---
|
||||
|
||||
## 5. 네트워크 — DHCP 예약의 실제 동작
|
||||
|
||||
```bash
|
||||
virsh net-update default add ip-dhcp-host \
|
||||
"<host mac='52:54:00:aa:bb:10' name='kc-lab-edge' ip='192.168.122.10'/>" \
|
||||
--live --config
|
||||
```
|
||||
|
||||
**실측**
|
||||
|
||||
```
|
||||
Updated network default persistent config and live state
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **`persistent config` 와 `live state` 두 마디가 다
|
||||
나오는가.** `--live` 만 주면 앞의 것이, `--config` 만 주면 뒤의 것이 빠진다.
|
||||
한쪽만 나온 것을 성공으로 읽으면 「재부팅하니 IP 가 바뀐다」가 된다.
|
||||
|
||||
### 예약을 먼저, VM 을 나중에
|
||||
|
||||
이 실험대는 **예약을 넣고 나서 `virt-install`** 했고, 게스트가 첫 부팅에서
|
||||
바로 `.10` 을 받았다.
|
||||
|
||||
```
|
||||
enp1s0 UP 192.168.122.10/24 metric 100
|
||||
```
|
||||
|
||||
순서가 반대면 게스트가 동적 대역(`192.168.122.2`–`.254`)에서 아무 주소나 받고,
|
||||
예약이 먹으려면 리스 만료를 기다리거나 재부팅해야 한다.
|
||||
|
||||
### 리스는 예약과 별개로 남는다
|
||||
|
||||
```bash
|
||||
virsh net-dhcp-leases default
|
||||
```
|
||||
|
||||
```
|
||||
Expiry Time MAC address IP address Hostname
|
||||
2026-09-10 09:59:54 52:54:00:aa:bb:11 192.168.122.11/24 kc-lab-1
|
||||
2026-09-10 09:58:46 52:54:00:aa:bb:12 192.168.122.12/24 kc-lab-2
|
||||
```
|
||||
|
||||
`net-dumpxml` 의 예약은 **줄 의도**이고, `net-dhcp-leases` 는 **실제로 준
|
||||
기록**이다. 둘이 다를 수 있다.
|
||||
|
||||
### virbr0 는 게스트가 없으면 내려간다
|
||||
|
||||
```bash
|
||||
ip -br addr show virbr0
|
||||
```
|
||||
|
||||
VM 세 대가 돌 때:
|
||||
```
|
||||
virbr0 UP 192.168.122.1/24
|
||||
```
|
||||
|
||||
전부 철거한 뒤:
|
||||
```
|
||||
virbr0 DOWN 192.168.122.1/24
|
||||
```
|
||||
|
||||
**주소는 그대로 있고 상태만 `DOWN` 이다.** 브리지에 붙은 tap 인터페이스가
|
||||
하나도 없어서다. 네트워크 정의가 사라진 것이 아니므로 **VM 을 다시 띄우면
|
||||
자동으로 `UP` 이 된다.** 이걸 고장으로 오독해 `virsh net-start` 를 찾아
|
||||
헤매지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 6. 철거 — 실제 출력 전문
|
||||
|
||||
가이드에는 세우는 절차만 있고 철거가 없다. 2026-09-10 에 실제로 돌린 기록이다.
|
||||
|
||||
### 게스트
|
||||
|
||||
```bash
|
||||
for v in kc-lab-edge kc-lab-2 kc-lab-1; do
|
||||
virsh destroy "$v"
|
||||
virsh undefine "$v" --remove-all-storage
|
||||
done
|
||||
```
|
||||
|
||||
**실측** (한 대분)
|
||||
|
||||
```
|
||||
Domain 'kc-lab-edge' destroyed
|
||||
Domain 'kc-lab-edge' has been undefined
|
||||
Volume 'vda'(/var/lib/libvirt/images/kc-lab-edge.qcow2) removed.
|
||||
Volume 'vdb'(/var/lib/libvirt/images/seed-kc-lab-edge.iso) removed.
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `Volume` 줄이 **두 개** 나오는가. `vda`(오버레이
|
||||
디스크)와 `vdb`(시드 ISO)다. `--remove-all-storage` 를 빠뜨리면 도메인만
|
||||
사라지고 **디스크 파일이 남아 다음 `virt-install` 이 「이미 있다」로 실패**한다.
|
||||
|
||||
`destroy` 는 종료 신호가 아니라 **전원을 뽑는 것**이다. 정상 종료를 원하면
|
||||
`virsh shutdown` 을 쓰고 꺼질 때까지 기다린다.
|
||||
|
||||
### DHCP 예약
|
||||
|
||||
```bash
|
||||
virsh net-update default delete ip-dhcp-host \
|
||||
"<host mac='52:54:00:aa:bb:10' name='kc-lab-edge' ip='192.168.122.10'/>" \
|
||||
--live --config
|
||||
```
|
||||
|
||||
**★ 삭제할 때도 `mac`·`name`·`ip` 세 속성을 다 준다.** 하나라도 비면 이렇게
|
||||
거부된다.
|
||||
|
||||
```
|
||||
error: Failed to update network default
|
||||
error: XML error: Cannot use host name '' in network 'default'
|
||||
```
|
||||
|
||||
> **zsh 에서 루프로 돌리면 이 오류를 만난다.** zsh 는 따옴표 없는 변수를
|
||||
> **단어 분리하지 않는다.** bash 에서 되던 `set -- $entry` 가 zsh 에서는
|
||||
> `$1` 에 문자열 전체를 넣고 `$2`·`$3` 을 비운다. 그래서 `name=""` 이 된다.
|
||||
> 세 줄을 값 그대로 쓰는 편이 안전하다.
|
||||
|
||||
**철거 후**
|
||||
|
||||
```
|
||||
### 남은 예약
|
||||
(없음)
|
||||
|
||||
### dhcp 블록
|
||||
<dhcp>
|
||||
<range start='192.168.122.2' end='192.168.122.254'/>
|
||||
</dhcp>
|
||||
```
|
||||
|
||||
동적 대역만 남는 것이 정상이다.
|
||||
|
||||
### 철거 전후 비교 — 실측
|
||||
|
||||
| | 철거 전 | 철거 후 |
|
||||
|---|---|---|
|
||||
| `virsh list --all` | 3 대 running | (없음) |
|
||||
| `virsh vol-list default` | 7 개 | `base.qcow2` 1 개 |
|
||||
| DHCP 예약 | 3 줄 | 0 줄 |
|
||||
| `df -h /` | 11G | **7.9G** |
|
||||
| `virbr0` | UP | DOWN |
|
||||
|
||||
**3.1GB 가 회수됐다.** 내역은 `kc-lab-1` 1.4GB + `kc-lab-2` 665MB + 시드 ISO
|
||||
3개(각 370KB)이고, **`kc-lab-edge` 의 디스크 크기는 재 두지 않았다(미측정)** —
|
||||
합계에서 역산하면 1GB 안팎이다.
|
||||
|
||||
`base.qcow2` 335MB 는 남긴다 — 다음 재구축의 바닥이고, 다시 받으면 몇 분이다.
|
||||
|
||||
---
|
||||
|
||||
## 7. 실측으로 드러난 함정 셋
|
||||
|
||||
전부 **아무 오류 없이 조용히 지나가거나, 엉뚱한 곳에서 증상이 나오는** 유형이다.
|
||||
|
||||
### ① cloud-init `sudo` 는 리스트가 아니라 문자열
|
||||
|
||||
```bash
|
||||
ssh kc-lab-1 'cloud-init schema -c ~/chk.yaml'
|
||||
```
|
||||
|
||||
리스트 형태(`sudo: ['ALL=(ALL) NOPASSWD:ALL']`)일 때:
|
||||
|
||||
```
|
||||
Error: Cloud config schema errors: users.0: {'name': 'donghyeon', 'groups':
|
||||
['sudo'], 'shell': '/bin/bash', ...} is not valid under any of the given schemas
|
||||
```
|
||||
|
||||
문자열 형태(`sudo: "ALL=(ALL) NOPASSWD:ALL"`)일 때:
|
||||
|
||||
```
|
||||
Valid cloud-config: /home/donghyeon/chk.yaml
|
||||
```
|
||||
|
||||
**어느 키가 문제인지 안 알려 준다.** `users.0` 블록을 통째로 찍고 「어느
|
||||
스키마에도 안 맞는다」고만 한다. 그리고 **리스트 형태도 부팅은 된다** —
|
||||
`kc-lab-1`·`kc-lab-2` 가 그 상태로 NOPASSWD sudo 가 멀쩡히 돌았다. 검사기만
|
||||
거부하는 것이라 「검사는 실패인데 왜 되지」로 헷갈린다.
|
||||
|
||||
게스트 cloud-init 버전: `22.4.2` (Debian 12 genericcloud).
|
||||
|
||||
### ② nginx `http2 on;` 은 배포판에 따라 없다
|
||||
|
||||
```
|
||||
엣지 (Debian 12): nginx version: nginx/1.22.1
|
||||
lab host (Arch): nginx version: nginx/1.30.4
|
||||
```
|
||||
|
||||
`http2 on;` 은 nginx 1.25.1 이상이다. Arch 에서 쓰던 설정을 Debian 게스트로
|
||||
그대로 옮기면:
|
||||
|
||||
```
|
||||
[emerg] 1339#1339: unknown directive "http2" in /etc/nginx/sites-enabled/keycloak-lab:29
|
||||
nginx: configuration file /etc/nginx/nginx.conf test failed
|
||||
```
|
||||
|
||||
`listen 443 ssl http2;` 형태는 1.22 와 1.30 양쪽에서 다 돈다.
|
||||
|
||||
### ③ Debian 기본 사이트가 `default_server` 를 먹고 있다
|
||||
|
||||
Debian 계열은 `/etc/nginx/sites-enabled/default` 가 처음부터 붙어 있고 `:80`
|
||||
에 `default_server` 로 선언돼 있다. 실험대 설정의 `listen 80 default_server`
|
||||
와 **충돌한다.** 심볼릭 링크를 걸 때 같이 지운다.
|
||||
|
||||
```bash
|
||||
sudo rm -f /etc/nginx/sites-enabled/default
|
||||
```
|
||||
|
||||
Arch 는 `sites-available` 관례 자체가 없어서 이 함정이 없는 대신, `nginx.conf`
|
||||
에 `include /etc/nginx/sites-enabled/*;` 를 직접 넣어야 한다. **배포판이 바뀌면
|
||||
함정도 바뀐다.**
|
||||
|
||||
---
|
||||
|
||||
## 8. 재구축할 때 무엇이 남아 있나
|
||||
|
||||
철거해도 남는 것과 사라지는 것을 구분해 둔다. 이걸 모르면 재구축이 「왜
|
||||
이건 이미 있지」와 「왜 이건 없지」의 반복이 된다.
|
||||
|
||||
| | 상태 | 왜 |
|
||||
|---|---|---|
|
||||
| `base.qcow2` (335MB) | **남는다** | 다음 오버레이의 바닥 |
|
||||
| 패키지 (libvirt·qemu·nginx·certbot·kubectl) | **남는다** | 재설치가 무의미 |
|
||||
| `~/workspace/cloud/kc-lab-{1,2}.yaml` | **남는다** | 키와 비밀번호가 들어 있다 |
|
||||
| `~/.ssh/config` 의 `kc-lab-*` 항목 | **남는다** | 재구축해도 IP 가 같다 |
|
||||
| libvirt `default` 네트워크 정의 | **남는다** | 예약만 지웠다 |
|
||||
| `/etc/letsencrypt/` | **남긴다** (정책) | 아래 참고 |
|
||||
| 게스트 디스크·시드 ISO | 사라진다 | `--remove-all-storage` |
|
||||
| DHCP 예약 | 사라진다 | `net-update delete` |
|
||||
| k3s·Keycloak·모든 워크로드 | 사라진다 | 게스트와 함께 |
|
||||
|
||||
### 인증서를 지우지 않는 이유
|
||||
|
||||
**한도 때문이 아니다.** Let's Encrypt 의 「같은 이름 조합에 주당 중복 5장」
|
||||
제한은 가끔 재구축하는 정도로는 근처에도 못 간다.
|
||||
|
||||
**진짜 이유는 「지금 재발급이 되는지를 모른다」는 것이다.** 이 실험대의 이름
|
||||
셋은 tailnet 주소를 가리킨다.
|
||||
|
||||
```bash
|
||||
dig +short auth.hyeonworks.com
|
||||
```
|
||||
```
|
||||
100.83.212.4
|
||||
```
|
||||
|
||||
`100.64.0.0/10` 은 CGNAT 용 예약 대역이라 **공개 인터넷에서 라우팅되지
|
||||
않는다.** HTTP-01 검증은 Let's Encrypt 가 우리 서버로 들어오는 방식이므로,
|
||||
이 주소로는 검증이 성립하지 않는다.
|
||||
|
||||
| 지금 설정이 | 지우고 나면 |
|
||||
|---|---|
|
||||
| `dns-cloudflare` (DNS-01) | 다시 받으면 끝. **백업은 헛수고였던 것** |
|
||||
| `webroot`·`standalone` (HTTP-01) | 검증 방식부터 손봐야 한다 |
|
||||
|
||||
**「못 한다」가 아니라 「일이 하나 생긴다」이다.** 최악의 경우라도 A 레코드를
|
||||
공인 IP 로 잠깐 돌려 받거나 DNS-01 로 전환하면 된다. 다만 재구축하려고 앉은
|
||||
자리에서 그 일부터 하게 된다.
|
||||
|
||||
**어느 쪽인지 모르는 채로는 지우지 않는다**는 것이 이 정책의 전부다. 백업은
|
||||
tar 하나에 30초고, 답을 알고 나면 지우면 된다.
|
||||
|
||||
```bash
|
||||
sudo tar czf ~/letsencrypt-backup-$(date +%Y%m%d-%H%M%S).tgz -C /etc letsencrypt
|
||||
```
|
||||
|
||||
복원은 반대로 한 줄이다.
|
||||
|
||||
```bash
|
||||
sudo tar xzf ~/letsencrypt-backup-<stamp>.tgz -C /etc
|
||||
```
|
||||
|
||||
**미측정** — 이 실험대의 certbot 이 HTTP-01 인지 DNS-01 인지 아직 확인하지
|
||||
않았다. 호스트의 `sudo` 가 비밀번호를 요구해 비대화식으로 읽지 못했다.
|
||||
다음 한 줄이 답이다.
|
||||
|
||||
```bash
|
||||
sudo grep -H authenticator /etc/letsencrypt/renewal/*.conf
|
||||
```
|
||||
|
||||
`webroot`·`standalone` 이면 HTTP-01 이라 위 문제가 실재하고, `dns-cloudflare`
|
||||
면 DNS-01 이라 tailnet 안에서도 재발급·갱신이 된다. 플러그인은 이미 깔려
|
||||
있다 — `certbot plugins` 에 `dns-cloudflare` 가 보인다.
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
|
||||
| 문서 | 무엇 |
|
||||
|---|---|
|
||||
| [`guides/01-vms/`](guides/01-vms/) | VM 세 대를 세우는 절차 |
|
||||
| [`guides/00-lab-host/`](guides/00-lab-host/) | 호스트 가상화 준비 |
|
||||
| [`session-lab-concepts.md`](session-lab-concepts.md) | 여기 나온 개념의 정의 |
|
||||
| [`deploy/lab/host/teardown-host.sh`](../deploy/lab/host/teardown-host.sh) | 호스트 계층 철거 |
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user