PostgreSQL statement logging shows keycloak-1 reading and updating the session created on keycloak-0. The same transaction reveals optimistic locking via VERSION, SKIP LOCKED, and synchronous_commit turned off. Fixes the earlier concept note that credited Infinispan with cross-node propagation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
3432 lines
151 KiB
Markdown
3432 lines
151 KiB
Markdown
# 세션 저장소 실험대 — 개념 사전
|
||
|
||
`develop-keycloak-session-store` 작업에서 등장하는 개념을 누적 기록한다.
|
||
대화는 흘러가지만 이 문서는 남는다.
|
||
|
||
**모든 항목은 네 가지를 갖춘다.**
|
||
|
||
1. **무엇인가** — 정의
|
||
2. **왜 여기 나오나** — 이 실험대에서 맡은 역할
|
||
3. **없거나 틀리면** — 실제로 관찰되는 실패 양상
|
||
4. **확인** — 상태를 직접 볼 수 있는 명령
|
||
|
||
개념이 새로 나올 때마다 해당 층에 추가한다. 층은 아래에서 위로 쌓인다.
|
||
|
||
- 1층 가상화 — VM을 만드는 층
|
||
- 2층 가상 네트워크 — VM끼리, VM과 호스트를 잇는 층
|
||
- 3층 호스트 진입 — 브라우저가 들어오는 층
|
||
- 4층 TLS — 그 진입을 암호화하는 층
|
||
- 5층 k3s — VM 안에서 컨테이너를 굴리는 층
|
||
- 6층 Arch 특이사항 — 배포판 때문에 달라지는 것
|
||
- 7층 git — 저장소 운영
|
||
|
||
---
|
||
|
||
## 0. "이건 Arch라서 하는 건가?"에 대한 답
|
||
|
||
이 실험대 구성에서 낯선 명령이 쏟아지는 이유는 **Arch 때문이 아니다.**
|
||
평소 리눅스 서버를 쓸 때 이런 걸 안 했던 진짜 이유는 셋 중 하나다.
|
||
|
||
| 왜 안 해봤나 | 해당 작업 | 이번에 하는 이유 |
|
||
|---|---|---|
|
||
| **클라우드가 대신 해줬다** | KVM, libvirt, virbr0, cloud-init, DHCP 예약 | EC2를 쓰면 AWS가 하이퍼바이저다. 여기선 **우리가 하이퍼바이저**다 |
|
||
| **이미 누가 해뒀다** | nginx `upstream`, certbot, k3s 설치 | 완성된 서버에 배포만 하던 것과, 서버를 처음부터 세우는 것의 차이 |
|
||
| **진짜 Arch 특유** | `conf.d` include 부재, 롤링 업그레이드, `libvirtd.socket`, 패키지명 | 6층 참고 — 전체의 아주 일부다 |
|
||
|
||
즉 낯선 것의 대부분은 **가상화·네트워크 층을 직접 만지기 때문**이고,
|
||
Arch 고유는 6층에 모아둔 몇 개뿐이다. 같은 구성을 Ubuntu에서 해도
|
||
1~5층은 명령 이름만 조금 바뀔 뿐 개념은 100% 동일하다.
|
||
|
||
---
|
||
|
||
## 0-1. 왜 호스트에 직접 깔지 않고 VM 2대인가
|
||
|
||
나중에 반드시 다시 묻게 되는 판단이므로 근거를 남긴다.
|
||
|
||
| # | 이유 | 호스트 직접 설치로는 왜 안 되나 |
|
||
|---|---|---|
|
||
| 1 | **독립 커널이 2개 필요** | 물리 머신이 1대뿐이다. 같은 커널에 k3s server와 agent를 올리면 "노드"가 이름뿐이라 노드 간 방화벽·파티션·노드 상실이 **성립하지 않는다** |
|
||
| 2 | **파괴 실험 후 복원** | VM은 qcow2 오버레이를 지우면 몇 초 만에 초기 상태다. 호스트는 재설치 말고 되돌릴 방법이 없다 |
|
||
| 3 | **관측자를 살려둔다** | 노드를 죽이는 실험인데 그 노드가 호스트면 SSH·libvirt·nginx가 같이 죽는다. **관측 수단이 실험 대상과 함께 죽으면 안 된다** |
|
||
| 4 | **호스트 오염 방지** | k3s는 nftables 규칙·CNI 인터페이스·커널 모듈·systemd 유닛을 대량으로 심는다. 호스트는 진입점과 하이퍼바이저로만 남기는 편이 깨끗하다 |
|
||
| 5 | **운영 배포판과 일치** | 호스트는 Arch다. 운영 k3s가 다른 배포판이면 커널·systemd 차이가 잡음이 된다. 게스트를 운영과 같게 맞추면 그 잡음이 사라진다 |
|
||
| 6 | **netem 격리** | 커널이 분리되어 지연 주입이 게스트 안에 갇힌다. 호스트에서 걸면 SSH까지 느려진다 |
|
||
|
||
**정직한 반대편** — 계약 검증(2홉 헤더, 쿠키/origin)만 볼 거라면 호스트에
|
||
단일 노드 k3s를 직접 깔아도 충분하고 그게 더 빠르다. VM 경로가 필요해지는
|
||
것은 **클러스터와 장애 실험부터**다.
|
||
|
||
**채택하지 않은 절충안** — "호스트를 노드 1, VM을 노드 2로." 게스트 OS
|
||
하나(약 350MB)와 설치 수고를 아끼지만 3번과 4번을 포기하게 된다.
|
||
7.4Gi 예산에서 그 350MB보다 관측자 분리가 더 값지다고 판단했다.
|
||
|
||
---
|
||
|
||
## 0-2. 전체 구조 한눈에 보기
|
||
|
||
개별 개념을 읽기 전에 이 그림을 먼저 본다. 가장 자주 오해하는 지점은
|
||
**시드 ISO를 OS 이미지로 착각하는 것**이다. 시드는 OS가 아니라 설정
|
||
데이터만 담은 370KB짜리 별도 디스크다.
|
||
|
||
### VM 한 대의 디스크 구성
|
||
|
||
```
|
||
kc-lab-1 (VM)
|
||
┌───────────────────────────────────────────────────────┐
|
||
│ │
|
||
│ vda 20G vdb 370K │
|
||
│ ┌───────────────┐ ┌───────────────┐ │
|
||
│ │ / ext4 │ │ CIDATA │ │
|
||
│ │ 운영체제 │ │ iso9660 │ │
|
||
│ │ ★ 여기서 │ │ 읽기 전용 │ │
|
||
│ │ 부팅한다 │ │ 마운트 안 됨 │ │
|
||
│ └───────┬───────┘ └───────┬───────┘ │
|
||
└───────────┼───────────────────────────────┼───────────┘
|
||
│ │
|
||
kc-lab-1.qcow2 (264M) seed-kc-lab-1.iso (370K)
|
||
변경분만 쌓이는 오버레이 user-data
|
||
│ meta-data
|
||
backing │
|
||
▼
|
||
base.qcow2 (333M)
|
||
Debian 12 · 절대 수정되지 않음
|
||
kc-lab-2 의 오버레이도 같은 것을 공유
|
||
```
|
||
|
||
`base.qcow2` **하나를 두 VM이 공유**하고 각자 변경분만 자기 오버레이에
|
||
쌓는다. 그래서 20G 디스크 두 개인데 실사용은 합쳐 850M 남짓이다.
|
||
노드를 늘려도 base는 하나면 된다.
|
||
|
||
### 설정 파일이 게스트에 도달하는 경로
|
||
|
||
```
|
||
kc-lab-1.yaml
|
||
(사람이 편집)
|
||
│ 복사 (이름이 반드시 user-data 여야 함)
|
||
▼
|
||
seed-1/user-data ┐
|
||
├── xorrisofs -volid CIDATA ──▶ seed-kc-lab-1.iso
|
||
seed-1/meta-data ┘ │
|
||
(instance-id, │
|
||
local-hostname) virsh vol-upload│
|
||
▼
|
||
/var/lib/libvirt/images/
|
||
(홈은 700 이라 qemu 가 못 읽음)
|
||
│
|
||
--disk device=disk,bus=virtio,readonly=on
|
||
▼
|
||
게스트의 vdb
|
||
```
|
||
|
||
세 곳에 같은 내용이 존재한다. **원본 YAML만 고치면 VM에 반영되지 않는다.**
|
||
|
||
### 부팅할 때 일어나는 일
|
||
|
||
```
|
||
1. QEMU 가 vda 에서 부팅 → Debian 커널 시작
|
||
2. cloud-init 서비스 기동 → 모든 블록 장치를 스캔
|
||
3. vdb 에서 LABEL=CIDATA 발견 → 잠깐 마운트
|
||
4. user-data / meta-data 읽기 → 사용자·hostname·sudo·패키지 적용
|
||
5. 언마운트 → SSH 로그인 가능
|
||
```
|
||
|
||
3번이 실패하면 hostname 이 `localhost` 로 남고 사용자가 생성되지 않는다.
|
||
`virsh screenshot` 으로 로그인 프롬프트만 봐도 즉시 판정할 수 있다.
|
||
|
||
### 실험대 전체 배치 (2026-09-03 구축 완료, 실측값)
|
||
|
||
```
|
||
노트북 브라우저 / SSH
|
||
│
|
||
│ https://auth.hyeonworks.com (Cloudflare DNS only → 100.83.212.4)
|
||
│ https://app1.hyeonworks.com
|
||
│ https://app2.hyeonworks.com
|
||
▼
|
||
┌────────────────────────────────────────────────────────────┐
|
||
│ test-server Arch · i5-1135G7 · RAM 7.4Gi · WiFi only │
|
||
│ LAN 192.168.0.200 · tailnet 100.83.212.4 │
|
||
│ │
|
||
│ nginx :443 ── TLS 종료 (Let's Encrypt) ──┐ │
|
||
│ nginx :80 ── 301 → https │ │
|
||
│ sites-available/keycloak-lab │ upstream │
|
||
│ ▼ │
|
||
│ libvirt / KVM virbr0 192.168.122.0/24 (NAT) │
|
||
│ ┌────────────────────────────────────────────────────┐ │
|
||
│ │ kc-lab-1 .11 kc-lab-2 .12 │ │
|
||
│ │ RAM 3584M · vCPU 2 RAM 2560M · vCPU 2 │ │
|
||
│ │ Debian 12 genericcloud Debian 12 │ │
|
||
│ │ k3s server (v1.36.4) k3s agent │ │
|
||
│ │ Traefik :80 ◀──────────┐ Traefik :80 ◀──────┐ │ │
|
||
│ └─────────────────────────┼──────────────────────┼───┘ │
|
||
│ └──── servicelb ───────┘ │
|
||
└────────────────────────────────────────────────────────────┘
|
||
|
||
앞으로 올릴 것 : Keycloak ×2 · PostgreSQL · Redis · BFF · oauth2-proxy
|
||
```
|
||
|
||
`nginx → Traefik`의 **2홉 구조**가 운영(`desktop`)과 같다는 점이 이 배치의
|
||
핵심이다. 운영은 `nginx → 127.0.0.1:30080(NodePort) → Traefik`이고
|
||
여기는 `nginx → 노드 IP:80(servicelb) → Traefik`으로, **단일 노드냐 2노드냐의
|
||
차이만 있다.**
|
||
|
||
**구축 완료 판정 기준** — 아래가 전부 통과해야 다음 단계로 넘어간다.
|
||
|
||
```bash
|
||
kubectl get nodes # Ready 2개
|
||
dig A auth.hyeonworks.com +short # 100.83.212.4
|
||
curl -sI https://auth.hyeonworks.com | head -1 # HTTP/2 404
|
||
curl -s -o /dev/null -w '%{ssl_verify_result}\n' https://auth.hyeonworks.com # 0
|
||
curl -sI http://auth.hyeonworks.com | head -1 # 301
|
||
systemctl is-active nginx certbot-renew.timer # active active
|
||
```
|
||
|
||
**`404`가 성공 신호다.** TLS가 정상 종료되고 Traefik까지 도달했으나 매칭되는
|
||
Ingress 규칙이 없다는 뜻이다. 여기서 `502`나 `connection refused`가 나오면
|
||
체인 어딘가가 끊긴 것이다.
|
||
|
||
---
|
||
|
||
## 1층. 가상화
|
||
|
||
### VT-x / AMD-V (하드웨어 가상화 확장)
|
||
|
||
**무엇인가** — CPU가 제공하는 명령어 확장. 게스트 OS의 특권 명령을
|
||
호스트 커널이 소프트웨어로 흉내내지 않고 CPU가 직접 처리하게 해준다.
|
||
Intel은 `vmx`, AMD는 `svm`이라는 플래그로 노출된다.
|
||
|
||
**왜 여기 나오나** — 이게 없으면 VM이 못 뜨는 게 아니라, **50배쯤 느려진다.**
|
||
QEMU가 TCG(Tiny Code Generator)라는 순수 소프트웨어 에뮬레이션으로
|
||
폴백하기 때문이다. k3s 노드를 그 위에서 굴리는 건 사실상 불가능하다.
|
||
|
||
**없거나 틀리면** — BIOS/UEFI에서 꺼져 있으면 `/dev/kvm`이 아예 생성되지
|
||
않는다. `virt-install`이 "KVM 가속 없음" 경고를 내고 진행한다.
|
||
|
||
**확인**
|
||
|
||
```bash
|
||
grep -om1 -E 'vmx|svm' /proc/cpuinfo # 한 줄이라도 나오면 지원
|
||
ls -l /dev/kvm # 없으면 BIOS에서 꺼진 것
|
||
```
|
||
|
||
### KVM
|
||
|
||
**무엇인가** — 리눅스 커널 모듈(`kvm.ko` + `kvm_intel.ko`). 커널 자체를
|
||
하이퍼파이저로 바꾸고 `/dev/kvm`이라는 문자 디바이스를 노출한다.
|
||
KVM은 CPU와 메모리 가상화만 담당하고, 디스크·네트워크·화면 같은
|
||
장치 에뮬레이션은 하지 않는다.
|
||
|
||
**왜 여기 나오나** — 그 "장치 에뮬레이션을 안 한다"는 점 때문에 항상
|
||
QEMU와 짝을 이룬다. 둘의 역할 분담을 모르면 왜 패키지를 둘 다 깔아야
|
||
하는지가 이해되지 않는다.
|
||
|
||
**없거나 틀리면** — `/dev/kvm` 권한이 없으면(그룹 `kvm` 미소속)
|
||
"Permission denied"로 VM 생성이 실패한다.
|
||
|
||
**확인**
|
||
|
||
```bash
|
||
lsmod | grep -E '^kvm'
|
||
ls -l /dev/kvm # crw-rw-rw- 또는 그룹 kvm 소속이어야 함
|
||
```
|
||
|
||
### QEMU
|
||
|
||
**무엇인가** — 장치 에뮬레이터. 가상 디스크 컨트롤러, NIC, 시리얼 포트,
|
||
그래픽 어댑터를 소프트웨어로 만들어낸다. `-accel kvm` 옵션으로 CPU/메모리
|
||
부분만 KVM에 위임한다.
|
||
|
||
**왜 여기 나오나** — VM 하나는 실제로는 **호스트에서 도는 QEMU 프로세스
|
||
하나**다. `ps`로 보면 보인다. 이 사실을 알면 "VM 메모리 3584M"이 호스트
|
||
입장에선 그냥 프로세스 RSS라는 게 납득되고, 7.4Gi 예산 계산이 직관적으로
|
||
이해된다.
|
||
|
||
**확인**
|
||
|
||
```bash
|
||
ps aux | grep qemu-system-x86_64 # VM 하나당 프로세스 하나
|
||
```
|
||
|
||
### libvirt / virsh / libvirtd
|
||
|
||
**무엇인가** — QEMU를 직접 다루면 명령줄 인자가 수십 개가 된다. libvirt는
|
||
그 위에 얹는 관리 계층으로, VM 정의를 XML로 저장하고 시작·정지·스냅샷·
|
||
네트워크를 통일된 API로 제공한다. `virsh`는 그 CLI 클라이언트다.
|
||
|
||
**왜 여기 나오나** — VM을 재부팅 후에도 유지하고, 고정 IP 예약을 걸고,
|
||
`virsh destroy`로 "노드 상실"을 재현하려면 관리 계층이 필요하다.
|
||
|
||
**없거나 틀리면** — libvirt 없이 QEMU만 쓰면 VM 정의가 어디에도 저장되지
|
||
않아 재부팅 시 전부 사라진다.
|
||
|
||
**확인**
|
||
|
||
```bash
|
||
virsh list --all # 정의된 VM 전체
|
||
virsh dumpxml kc-lab-1 # 그 VM의 실제 정의
|
||
```
|
||
|
||
### 연결 URI — `qemu:///system` vs `qemu:///session`
|
||
|
||
**무엇인가** — libvirt는 **완전히 분리된 두 개의 인스턴스**를 동시에 운영한다.
|
||
|
||
| URI | 데몬 | 실행 주체 | VM/네트워크 저장 위치 |
|
||
|---|---|---|---|
|
||
| `qemu:///system` | 시스템 데몬 | root | `/etc/libvirt/`, `/var/lib/libvirt/` |
|
||
| `qemu:///session` | 사용자별 데몬 | 로그인 사용자 | `~/.config/libvirt/` |
|
||
|
||
둘은 이름 공간이 다르다. 시스템 인스턴스의 `default` 네트워크는
|
||
세션 인스턴스에서 **존재하지 않는다.**
|
||
|
||
**왜 여기 나오나** — `virsh`는 **root로 실행하면 `qemu:///system`,
|
||
일반 사용자로 실행하면 `qemu:///session`**을 기본값으로 잡는다.
|
||
그래서 `sudo virsh net-start default`는 성공하는데 이어서
|
||
`virsh net-dumpxml default`(sudo 없이)는 "Network not found"가 난다.
|
||
같은 명령을 sudo 유무만 다르게 쳤을 뿐인데 **다른 서버에 물어본 셈**이다.
|
||
|
||
**없거나 틀리면** — `error: failed to get network 'default'` /
|
||
`Network not found: no network with matching name 'default'`.
|
||
네트워크가 없어서가 아니라 **엉뚱한 인스턴스를 보고 있어서** 나는 오류다.
|
||
|
||
**해결** — 셸 프로필에 기본 URI를 박아두면 sudo도 `-c`도 필요 없어진다.
|
||
|
||
```bash
|
||
echo 'export LIBVIRT_DEFAULT_URI=qemu:///system' >> ~/.zshrc
|
||
exec zsh # 또는 재로그인
|
||
```
|
||
|
||
**확인**
|
||
|
||
```bash
|
||
virsh uri # qemu:///system 이 나와야 함
|
||
virsh -c qemu:///system net-list --all # URI를 매번 명시하는 방법
|
||
```
|
||
|
||
**sudo와 비-sudo를 섞지 말 것** — 이 문제는 한 번 고쳐도 반복해서 재발한다.
|
||
`sudo`는 기본적으로 환경 변수를 물려주지 않으므로, `~/.zshrc`에
|
||
`LIBVIRT_DEFAULT_URI`를 걸어둬도 **`sudo virsh`에는 전달되지 않는다.**
|
||
다만 sudo는 root로 실행되니 결과적으로 `qemu:///system`이 되어 동작한다.
|
||
그래서 두 방식 모두 되긴 하는데, **섞어 쓰면 어떤 명령은 되고 어떤 명령은
|
||
"Network not found"가 나는 상황**이 만들어진다.
|
||
|
||
**한 가지만 고른다. 권장은 sudo를 쓰지 않는 쪽이다.**
|
||
|
||
```bash
|
||
echo 'export LIBVIRT_DEFAULT_URI=qemu:///system' >> ~/.zshrc
|
||
exec zsh
|
||
virsh uri # qemu:///system 확인 후, 이제 sudo 없이 모든 virsh 명령
|
||
```
|
||
|
||
**증상 → 원인 대조표**
|
||
|
||
| 증상 | 실제 원인 |
|
||
|---|---|
|
||
| `Network not found: no network with matching name 'default'` | 세션 인스턴스를 보고 있다. 네트워크가 없는 게 아니다 |
|
||
| `sudo`로는 되는데 그냥은 안 됨 | 위와 동일 |
|
||
| `net-update`가 오류 없이 끝났는데 반영이 안 됨 | `--config`만 주고 `--live`를 빠뜨렸다 (또는 반대) |
|
||
| 재부팅하니 설정이 사라짐 | `--live`만 주고 `--config`를 빠뜨렸다 |
|
||
|
||
**변경이 실제로 남았는지 보는 법** — libvirt는 "실행 중 정의"와
|
||
"영구 정의"를 따로 들고 있다. 둘 다 확인해야 한다.
|
||
|
||
```bash
|
||
virsh net-dumpxml default # 실행 중 정의 (--live 가 반영되는 곳)
|
||
virsh net-dumpxml --inactive default # 영구 정의 (--config 가 반영되는 곳)
|
||
```
|
||
|
||
### 보조 그룹과 재로그인
|
||
|
||
**무엇인가** — `usermod -aG libvirt $USER`는 `/etc/group` 파일을 수정한다.
|
||
그런데 프로세스의 그룹 목록은 **로그인 시점에 한 번 읽혀서 고정**되고,
|
||
이미 떠 있는 셸에는 소급 적용되지 않는다.
|
||
|
||
**왜 여기 나오나** — `usermod` 직후 같은 터미널에서 `virsh -c qemu:///system`을
|
||
치면 권한 거부가 날 수 있다. 명령이 잘못된 게 아니라 셸이 옛날 그룹 목록을
|
||
들고 있는 것이다. 새 SSH 세션이나 재로그인이면 정상 동작한다.
|
||
|
||
**확인**
|
||
|
||
```bash
|
||
id # 현재 셸이 들고 있는 그룹 (여기 libvirt 가 보여야 함)
|
||
getent group libvirt # /etc/group 의 실제 내용 (이쪽엔 바로 반영됨)
|
||
```
|
||
|
||
두 결과가 다르면 재로그인이 필요하다는 뜻이다.
|
||
급하면 `newgrp libvirt`로 해당 셸만 갱신할 수 있다.
|
||
|
||
### 멱등성과 `&&` 단축 평가
|
||
|
||
**무엇인가** — 멱등(idempotent)한 명령은 여러 번 실행해도 결과가 같다.
|
||
libvirt 명령 중에는 그렇지 않은 것이 있다.
|
||
|
||
| 명령 | 이미 그 상태일 때 | 멱등한가 |
|
||
|---|---|---|
|
||
| `virsh net-start default` | `error: network is already active` | **아니오** |
|
||
| `virsh net-autostart default` | 조용히 성공 | 예 |
|
||
|
||
**왜 여기 나오나** — `A && B`는 **A가 성공했을 때만 B를 실행**한다.
|
||
그래서 `net-start && net-autostart`를 두 번째로 실행하면
|
||
`net-start`가 "이미 active"로 실패하면서 `net-autostart`가 **아예 실행되지
|
||
않는다.** 오류 메시지만 보면 둘 다 실패한 것처럼 보이지만,
|
||
실제로는 앞선 실행에서 이미 목적을 달성한 상태다.
|
||
|
||
**다시 실행해도 안전한 형태** — `&&` 대신 `;`를 쓰고 실패를 삼킨다.
|
||
|
||
```bash
|
||
virsh net-start default 2>/dev/null; virsh net-autostart default
|
||
```
|
||
|
||
### systemd 소켓 활성화 (`libvirtd.socket`)
|
||
|
||
**무엇인가** — `.service`가 아니라 `.socket`을 활성화하는 방식.
|
||
systemd가 대신 소켓을 열어두고 있다가, 누군가 접속하면 그때 데몬을
|
||
띄우고 연결을 넘겨준다.
|
||
|
||
**왜 여기 나오나** — 그래서 `systemctl enable --now libvirtd.socket`이
|
||
맞고 `libvirtd.service`가 아니다. 데몬은 `virsh`를 처음 실행하는 순간
|
||
자동으로 뜬다. 최신 libvirt는 여기서 더 나아가 `virtqemud`, `virtnetworkd`
|
||
처럼 기능별로 데몬이 쪼개져 있다(모듈러 데몬).
|
||
|
||
**없거나 틀리면** — `.service`를 찾다가 "Unit not found"가 나거나,
|
||
"failed to connect to the hypervisor"로 `virsh`가 실패한다.
|
||
|
||
**확인**
|
||
|
||
```bash
|
||
systemctl status libvirtd.socket
|
||
virsh -c qemu:///system version # 여기서 데몬이 자동 기동됨
|
||
```
|
||
|
||
### qcow2와 backing store (오버레이)
|
||
|
||
**무엇인가** — qcow2는 QEMU Copy-On-Write v2 디스크 포맷이다.
|
||
**backing store**는 원본 이미지를 읽기 전용으로 두고, 변경분만 별도
|
||
파일에 쌓는 방식이다. 새 디스크는 처음에 수백 KB에서 시작한다.
|
||
|
||
**왜 여기 나오나** — VM 2대에 같은 base 이미지를 쓰면서 디스크를 20G씩
|
||
두 번 복사하지 않아도 된다. 그리고 실험을 망쳤을 때 오버레이만 지우면
|
||
**몇 초 만에 초기 상태로 되돌아간다.** 반복 실험에서 이 속도가 크다.
|
||
|
||
**없거나 틀리면** — **base 이미지를 지우거나 옮기면 그 위의 모든 오버레이가
|
||
동시에 깨진다.** 오버레이는 base 경로를 절대경로로 기억한다.
|
||
|
||
**확인**
|
||
|
||
```bash
|
||
qemu-img info /var/lib/libvirt/images/kc-lab-1.qcow2
|
||
# "backing file:" 줄이 원본을 가리켜야 정상
|
||
```
|
||
|
||
### 왜 OS를 설치하지 않아도 VM이 뜨는가
|
||
|
||
가장 자주 막히는 지점이다. "VM은 격리된 빈 공간이니 거기에 OS를 설치해야
|
||
하는 것 아닌가?" — 격리는 맞지만, **설치는 필수가 아니다.**
|
||
|
||
**출발점: VM의 디스크는 호스트의 파일 하나다.**
|
||
`kc-lab-1.qcow2`라는 파일이 게스트에게는 20GB 하드디스크로 보인다.
|
||
게스트는 그것이 파일인 줄 모른다. QEMU가 디스크인 척 해주기 때문이다.
|
||
|
||
**그렇다면 "OS를 설치한다"는 것은 무슨 작업인가.**
|
||
|
||
```
|
||
빈 디스크
|
||
│ 설치 프로그램이 수행하는 일
|
||
├─ 파티션 테이블 작성
|
||
├─ 파일시스템 생성 (ext4, vfat …)
|
||
├─ 패키지 수천 개를 풀어 배치
|
||
├─ 부트로더 기록
|
||
└─ 초기 설정 작성
|
||
▼
|
||
"부팅 가능한 특정 바이트 배열" 상태의 디스크
|
||
```
|
||
|
||
**설치 과정 자체는 목적이 아니라 수단이다.** 목적은 마지막 줄의 상태다.
|
||
그리고 그 상태는 결국 **파일 하나의 내용**이다.
|
||
|
||
**그러면 그 결과물을 복사하면 되지 않나 → 그게 클라우드 이미지다.**
|
||
Debian과 Ubuntu는 자기들 빌드 서버에서 설치를 **한 번** 수행하고,
|
||
완성된 디스크 상태를 qcow2 파일로 떠서 공개한다. 우리는 그 파일을
|
||
내려받아 붙이기만 하면 된다.
|
||
|
||
> 소스에서 컴파일하는 것과 이미 빌드된 바이너리를 받는 것의 차이와 같다.
|
||
> 결과물은 동일하고 시간만 아낀다.
|
||
|
||
**하지만 그대로 복사하면 생기는 문제 → 그래서 cloud-init이 있다.**
|
||
디스크를 그대로 복제하면 **모든 복사본이 완전히 동일**해진다.
|
||
서버 100대의 hostname이 전부 같고, SSH 호스트 키가 같고, machine-id가 같다.
|
||
심각한 문제다.
|
||
|
||
그래서 클라우드 이미지는 일부러 **비워둔 상태**로 배포된다.
|
||
|
||
| 항목 | 클라우드 이미지에서의 상태 |
|
||
|---|---|
|
||
| hostname | 미설정 (`localhost`) |
|
||
| 사용자 계정 | 없음 |
|
||
| 비밀번호 | 없음 |
|
||
| SSH 호스트 키 | 없음 — 첫 부팅에 새로 생성 |
|
||
| machine-id | 비어 있음 |
|
||
|
||
**cloud-init은 이 빈칸을 첫 부팅에 채우는 장치다.**
|
||
정리하면 이렇다.
|
||
|
||
```
|
||
전통적 설치 : [설치 + 개인화]를 부팅 전에 대화형으로 수행
|
||
클라우드 : [설치]는 배포자가 미리 완료
|
||
[개인화]만 첫 부팅에 cloud-init 이 자동 수행
|
||
```
|
||
|
||
**격리는 그대로다.** "설치를 안 했으니 격리가 약한가?"는 오해다.
|
||
격리는 **실행 시점에 KVM/QEMU가 만드는 것**이지 설치 과정이 만드는 것이
|
||
아니다. 게스트는 자기 커널로 부팅하고 자기 메모리 공간에서 돈다.
|
||
디스크 내용을 어떻게 얻었는지와는 무관하다.
|
||
|
||
**디스크를 채우는 세 가지 방법**
|
||
|
||
| 방법 | 채우는 주체 | 소요 시간 |
|
||
|---|---|---|
|
||
| ISO 설치 | 설치 프로그램 (대화형) | 10~30분 |
|
||
| **클라우드 이미지** | **이미 채워진 파일을 다운로드** | **수 초** |
|
||
| 템플릿 복제 | 만들어둔 VM을 복사 | 수 초 |
|
||
|
||
이 실험대는 두 번째를 쓴다. 그리고 한 걸음 더 나아가 **복사조차 하지
|
||
않는다** — `base.qcow2`를 읽기 전용으로 두고 오버레이에 변경분만 쌓는다
|
||
(qcow2 backing store 항목 참고). 그래서 20G VM 두 대의 실사용량이
|
||
850M 남짓이다.
|
||
|
||
### 디스크 이미지를 "복사한다"는 것의 실제 원리
|
||
|
||
앞 항목의 "완성된 디스크를 파일로 떠서 배포한다"가 물리적으로 어떻게
|
||
가능한지를 아래에서 단계적으로 푼다.
|
||
|
||
**1단계 — 디스크는 바이트의 1차원 배열이다**
|
||
|
||
하드디스크나 SSD는 운영체제에게 **섹터(보통 512B 또는 4096B)가 0번부터
|
||
쭉 늘어선 배열**로 보인다. 그 이상의 구조는 없다.
|
||
|
||
```
|
||
섹터: 0 1 2 3 ... N
|
||
┌────────┬────────┬────────┬────────┬─────┬────────┐
|
||
│ MBR/GPT│ 파티션 │ 파일시스템 메타 │ 데이터 … │
|
||
└────────┴────────┴────────┴────────┴─────┴────────┘
|
||
```
|
||
|
||
파티션 테이블도, 파일시스템도, 부트로더도 **전부 이 배열 안의 특정 위치에
|
||
기록된 바이트**일 뿐이다. 디스크 바깥에 따로 보관되는 정보가 없다.
|
||
|
||
**2단계 — 그래서 배열 전체를 파일에 담을 수 있다**
|
||
|
||
배열을 처음부터 끝까지 그대로 파일에 쓰면 그것이 **raw 이미지**다.
|
||
|
||
```bash
|
||
dd if=/dev/sda of=disk.img bs=4M # 디스크 전체를 파일로
|
||
dd if=disk.img of=/dev/sdb bs=4M # 파일을 다른 디스크로 되돌림
|
||
```
|
||
|
||
되돌린 디스크는 원본과 **바이트 단위로 동일**하므로 똑같이 부팅된다.
|
||
"OS를 복사했다"는 말의 실체가 이것이다. 특별한 마법이 아니라
|
||
**배열을 그대로 옮긴 것**이다.
|
||
|
||
**3단계 — VM에서는 그 파일이 곧 디스크다**
|
||
|
||
물리 디스크로 되돌릴 필요조차 없다. QEMU에게 "이 파일을 디스크로 취급하라"고
|
||
하면 게스트는 그것을 진짜 디스크로 인식한다. 게스트가 섹터 1234를 읽으면
|
||
QEMU가 파일의 해당 오프셋을 읽어 돌려준다.
|
||
|
||
```
|
||
게스트 커널: "섹터 1234 읽어줘"
|
||
│
|
||
▼
|
||
QEMU: 파일의 1234 × 512 바이트 위치를 읽음
|
||
│
|
||
▼
|
||
호스트 파일시스템: kc-lab-1.qcow2
|
||
```
|
||
|
||
**4단계 — qcow2는 raw의 개선판이다**
|
||
|
||
raw 이미지는 20GB짜리 디스크면 파일도 20GB다. qcow2는 세 가지를 더한다.
|
||
|
||
| 기능 | 내용 |
|
||
|---|---|
|
||
| 희소 저장 | 실제로 쓰인 영역만 파일에 담는다 (20G 디스크 → 264M 파일) |
|
||
| backing file | 다른 이미지를 "바탕"으로 삼고 차이만 저장 |
|
||
| 스냅샷 | 특정 시점 상태를 보존 |
|
||
|
||
qcow2 내부는 **2단계 페이지 테이블**과 같은 구조다.
|
||
|
||
```
|
||
게스트 섹터 주소
|
||
│
|
||
▼
|
||
┌─────────┐ ┌─────────┐ ┌──────────────┐
|
||
│ L1 테이블│ ─────▶ │ L2 테이블│ ─────▶ │ 데이터 클러스터│
|
||
└─────────┘ └─────────┘ │ (기본 64KB) │
|
||
│ └──────────────┘
|
||
│ 항목이 비어 있으면
|
||
▼
|
||
backing file 로 위임
|
||
(base.qcow2)
|
||
```
|
||
|
||
**읽기**: L1 → L2를 따라가 클러스터를 찾는다. 항목이 비어 있으면
|
||
**backing file에게 그 주소를 다시 묻는다.** 그래서 오버레이에 아무것도
|
||
없어도 base의 내용이 그대로 보인다.
|
||
|
||
**쓰기 (copy-on-write)**: 그 클러스터를 backing에서 읽어와 오버레이에
|
||
복사한 뒤 수정한다. 이후 그 클러스터는 오버레이에서 직접 읽힌다.
|
||
**base 파일은 절대 수정되지 않는다.**
|
||
|
||
이것이 20G VM 두 대가 850M만 쓰는 이유이고, 실험을 망쳤을 때
|
||
**오버레이만 지우면 몇 초 만에 초기 상태로 돌아가는** 이유다.
|
||
|
||
**5단계 — 그대로 복제할 때 남는 문제**
|
||
|
||
디스크가 바이트 단위로 같으므로 **안에 적힌 식별자도 같아진다.**
|
||
|
||
| 항목 | 중복되면 |
|
||
|---|---|
|
||
| machine-id | systemd/DHCP가 두 기계를 같은 기계로 오인 |
|
||
| SSH 호스트 키 | 두 서버가 같은 신원을 주장 → MITM 탐지 무력화 |
|
||
| 파일시스템 UUID | `/etc/fstab`이 엉뚱한 디스크를 마운트 |
|
||
| hostname | 로그·클러스터에서 노드 구분 불가 |
|
||
|
||
클라우드 이미지가 이 값들을 **비워둔 채 배포하고**, cloud-init이 첫 부팅에
|
||
채우는 이유가 바로 이것이다. 앞 항목의 "빈칸" 표와 여기가 연결된다.
|
||
|
||
### multipass, virt-install, virsh — 무엇이 다른가
|
||
|
||
**흔한 오해: "Ubuntu는 multipass, Debian은 virsh"가 아니다.**
|
||
둘은 배포판이 아니라 **계층이 다른 도구**다.
|
||
|
||
```
|
||
multipass (Ubuntu 전용 런처) ┐
|
||
vagrant (범용 런처) │
|
||
virt-manager (GUI) ├──▶ libvirt ──▶ QEMU + KVM ──▶ CPU
|
||
virt-install (CLI, VM 생성) │
|
||
virsh (CLI, VM 관리) ┘
|
||
```
|
||
|
||
**multipass도 결국 QEMU/KVM 위에서 돈다.** 리눅스에서는 기본 드라이버가
|
||
`qemu`이고, `multipass set local.driver=libvirt`로 libvirt를 쓰게 할 수도 있다.
|
||
즉 우리가 쓴 것과 같은 토대다.
|
||
|
||
**multipass가 대신 해주던 일** — 이번에 손으로 한 작업이 정확히 그것이다.
|
||
|
||
| multipass 가 자동으로 | 이번에 우리가 한 것 |
|
||
|---|---|
|
||
| Ubuntu 클라우드 이미지 자동 다운로드·캐시 | `curl`로 base.qcow2 내려받기 |
|
||
| cloud-init user-data 자동 생성 | `kc-lab-1.yaml` 작성 |
|
||
| 시드 ISO 생성·연결 | `xorrisofs` + virtio 디스크 연결 |
|
||
| SSH 키 자동 생성·주입 | `ssh-keygen` + `ssh_authorized_keys` |
|
||
| 네트워크·DHCP 구성 | `virbr0` + DHCP 예약 |
|
||
| `multipass shell` 로 즉시 접속 | `~/.ssh/config` 별칭 작성 |
|
||
|
||
**multipass를 안 쓴 이유는 배포판이 아니라 범위 때문이다.**
|
||
multipass는 **Ubuntu 이미지만** 공식 지원해서 Debian 게스트를 띄울 수 없다.
|
||
반대로 libvirt는 Ubuntu 게스트도 얼마든지 띄운다. 그리고 이 실험대는
|
||
`virsh destroy`로 노드를 죽이고, NetworkPolicy로 포트를 막고,
|
||
스냅샷으로 되돌리는 **저수준 제어**가 실험의 본체라 관리 계층이 필요했다.
|
||
|
||
> multipass가 쉬웠던 이유는 이 모든 것을 감춰줬기 때문이고,
|
||
> 그래서 세부를 배울 기회도 없었다. 지금 개념이 쏟아지는 이유가 이것이다.
|
||
|
||
### 클라우드 이미지와 cloud-init
|
||
|
||
**무엇인가** — 클라우드 이미지는 OS 설치가 이미 끝난 qcow2 파일이다.
|
||
설치 과정이 없으므로 부팅하면 바로 로그인 화면 직전 상태다.
|
||
다만 사용자 계정과 SSH 키가 비어 있는데, 그 빈칸을 첫 부팅에 채우는
|
||
장치가 **cloud-init**이다. `user-data`라는 YAML을 읽어서 계정 생성,
|
||
SSH 키 등록, 패키지 설치, 임의 스크립트 실행을 수행한다.
|
||
|
||
**왜 여기 나오나** — VM 2대를 ISO로 설치하면 대화형 설치를 두 번 해야
|
||
한다. 클라우드 이미지 + cloud-init이면 `virt-install` 한 줄로 끝나고,
|
||
**두 대가 정확히 동일한 상태로 만들어진다.** 실험 재현성의 기본이다.
|
||
|
||
**없거나 틀리면** — user-data 없이 클라우드 이미지를 부팅하면 로그인할
|
||
방법이 없다. 콘솔에 붙어도 비밀번호를 모른다.
|
||
|
||
**왜 cloud-init이어야 하나 — 대안 비교**
|
||
|
||
게스트에 계정과 키를 심는 방법은 셋이다.
|
||
|
||
| 방법 | 비용 | 재생성 |
|
||
|---|---|---|
|
||
| ISO로 정식 설치 | VM마다 대화형 설치 | 매번 처음부터 반복 |
|
||
| 이미지를 미리 개조 (`virt-customize`, `guestfish`) | libguestfs 설치 + 이미지 마운트 | 개조본을 따로 관리해야 함 |
|
||
| **cloud-init** | **YAML 한 장** | **명령 한 줄** |
|
||
|
||
**이 실험대에서 세 번째가 결정적인 이유** — 우리는 `virsh destroy`와
|
||
오버레이 삭제로 **게스트를 반복해서 지우고 다시 만드는 것이 실험 그 자체**다.
|
||
재생성 비용이 낮아야 실험이 굴러간다. 그리고 두 노드가 **바이트 단위로
|
||
동일한 초기 상태**로 만들어져야 한다. 손으로 설치하면 미묘하게 달라지고,
|
||
그 차이가 실험 결과를 오염시킨다.
|
||
|
||
**우리 user-data가 실제로 하는 일**
|
||
|
||
| 항목 | 없으면 |
|
||
|---|---|
|
||
| `users` + `ssh_authorized_keys` | **접속 자체가 불가능** (아래 닭-달걀 참고) |
|
||
| `hostname` / `fqdn` | 두 노드가 같은 이름이라 k3s가 혼동 |
|
||
| `manage_etc_hosts: true` | 호스트명이 안 풀려 JGroups가 자기 주소를 못 정함 |
|
||
| `sudo: NOPASSWD` | 비대화형 설치 스크립트가 비밀번호를 물으며 멈춤 |
|
||
| `packages` | 게스트마다 손으로 설치 |
|
||
|
||
**이미지 종류 고르기** — Debian은 같은 버전을 여러 변종으로 배포한다.
|
||
|
||
| 변종 | 용도 |
|
||
|---|---|
|
||
| `genericcloud` | **가상화 환경 전용.** virtio 드라이버만 담아 가볍다 → **KVM에는 이걸** |
|
||
| `generic` | 베어메탈 포함. 드라이버가 많아 더 크다 |
|
||
| `nocloud` | cloud-init 없이 기본 계정이 박혀 있는 변종 |
|
||
|
||
**`--cloud-init`이 실제로 하는 일** — virt-install은 `user-data` 파일을
|
||
읽어 **NoCloud 시드 ISO**라는 작은 이미지를 만들고, 그것을 VM에 CD-ROM으로
|
||
붙인다. 게스트의 cloud-init은 부팅 시 그 디스크를 찾아 설정을 읽는다.
|
||
그래서 `user-data` 파일이 **명령 실행 시점에 존재해야** 한다. 없으면
|
||
`Couldn't acquire file ...: No such file or directory`로 즉시 실패한다.
|
||
|
||
**user-data 파일은 반드시 `#cloud-config`로 시작해야 한다.** 이 첫 줄이
|
||
없으면 cloud-init이 YAML로 인식하지 못하고 조용히 무시한다.
|
||
증상은 "부팅은 됐는데 계정이 없다"로 나타난다.
|
||
|
||
**YAML 작성에서 실제로 걸린 함정 세 가지**
|
||
|
||
1. **탭 문자는 들여쓰기로 쓸 수 없다.** YAML 명세가 금지한다. 반드시
|
||
스페이스여야 한다. 에디터가 탭을 넣도록 설정돼 있으면 파일 전체가
|
||
파싱 실패한다. 눈으로는 구분이 안 되므로 다음으로 확인한다.
|
||
|
||
```bash
|
||
grep -Pn '\t' user-data.yaml # 아무것도 안 나와야 정상
|
||
```
|
||
|
||
2. **리스트 항목의 하위 키는 `-` 다음 컬럼에 맞춰 더 들여쓴다.**
|
||
|
||
```yaml
|
||
users:
|
||
- name: donghyeon # '-' 는 2칸
|
||
groups: [sudo] # 하위 키는 4칸 ('n' 과 같은 열)
|
||
shell: /bin/bash
|
||
```
|
||
|
||
`groups`를 `-`와 같은 열에 두면 리스트 항목 밖으로 빠져나가
|
||
구조가 깨진다.
|
||
|
||
3. **`NOPASSWD` 오타는 YAML을 통과하지만 게스트를 망가뜨린다.**
|
||
cloud-init은 이 문자열을 `/etc/sudoers.d/90-cloud-init-users`에
|
||
그대로 쓴다. `NOPASSD`처럼 잘못된 태그가 들어가면 sudoers 문법 오류가
|
||
되어 **그 게스트에서 sudo 전체가 동작하지 않는다.** k3s 설치가
|
||
시작조차 못 한다. YAML 검증기로는 잡히지 않는 종류의 오류다.
|
||
|
||
**디스크 확장(growpart)** — 클라우드 이미지의 파티션은 원본 크기(2GB 안팎)
|
||
그대로다. `--disk size=20`으로 20GB를 줘도 루트 파티션은 처음엔 2GB다.
|
||
cloud-init의 `growpart` 모듈이 첫 부팅에 파티션과 파일시스템을 디스크
|
||
끝까지 자동 확장한다. Debian 클라우드 이미지는 이 모듈이 기본 활성화라
|
||
따로 설정할 필요가 없다.
|
||
|
||
**반드시 비상 접근 수단을 남겨둘 것 (실제로 겪은 교훈)**
|
||
|
||
`ssh_pwauth: false` + 키 인증만 설정한 상태에서 cloud-init이 실패하면
|
||
**그 게스트에는 들어갈 방법이 전혀 없다.** 사용자가 생성되지 않았으니
|
||
키도 비밀번호도 없고, 콘솔에 붙어도 로그인할 수 없다. 즉
|
||
**실패 원인을 기록한 `/var/log/cloud-init.log`를 읽을 수가 없다.**
|
||
진단이 불가능해서 VM을 지우고 다시 만드는 것 외에 선택지가 없어진다.
|
||
|
||
콘솔 로그인용 비밀번호를 넣어두면 이 막다른 골목을 피할 수 있다.
|
||
`ssh_pwauth: false`는 그대로 둬도 된다 — 콘솔 로그인은 sshd가 아니라
|
||
로컬 PAM을 타므로 영향받지 않는다.
|
||
|
||
```yaml
|
||
users:
|
||
- name: donghyeon
|
||
lock_passwd: false
|
||
plain_text_passwd: labpass # 콘솔 전용 비상구
|
||
...
|
||
ssh_pwauth: false # SSH 비밀번호 인증은 계속 차단
|
||
```
|
||
|
||
### 확정된 함정: `--cloud-init` + Debian `genericcloud` 조합은 동작하지 않는다
|
||
|
||
**증상** — VM은 정상 부팅하는데 cloud-init이 아무것도 적용하지 않는다.
|
||
hostname이 `localhost` 그대로이고, 사용자가 생성되지 않아
|
||
`Permission denied (publickey)`로 SSH가 거부된다. **오류 메시지가 어디에도
|
||
남지 않는다.**
|
||
|
||
**원인** — 두 가지가 겹친다.
|
||
|
||
1. `virt-install --cloud-init`은 시드 ISO를 **SATA CD-ROM**으로 붙인다
|
||
(`<target dev='sda' bus='sata'/>`).
|
||
2. Debian **`genericcloud`** 변종은 크기를 줄이려고 **물리 하드웨어 드라이버를
|
||
제외**한 이미지다. virtio 계열만 들어 있어 **AHCI/SATA 장치를 보지 못한다.**
|
||
|
||
그래서 게스트 입장에서 시드 ISO는 **존재하지 않는 장치**다. cloud-init은
|
||
`cidata` 레이블을 가진 블록 장치를 찾지 못하고 데이터소스 없이 조용히 종료한다.
|
||
|
||
**해결 — 시드를 virtio 디스크로 붙인다.** NoCloud 데이터소스는 CD-ROM을
|
||
요구하지 않는다. **레이블이 `cidata`인 블록 장치면 무엇이든 된다.**
|
||
ISO 파일을 그대로 virtio 디스크로 붙이면 게스트에 `vdb`로 보이고
|
||
정상 인식된다.
|
||
|
||
```bash
|
||
# 1) 시드 ISO 를 직접 만든다 (virt-install 의 임시 ISO 에 의존하지 않는다)
|
||
mkdir -p seed-1
|
||
cp kc-lab-1.yaml seed-1/user-data
|
||
printf 'instance-id: kc-lab-1-001\nlocal-hostname: kc-lab-1\n' > seed-1/meta-data
|
||
xorrisofs -output seed-kc-lab-1.iso -volid CIDATA -joliet -rock \
|
||
seed-1/user-data seed-1/meta-data
|
||
|
||
# 2) libvirt 풀에 올린다 (홈이 700 이면 qemu 가 못 읽는다)
|
||
SZ=$(stat -c%s seed-kc-lab-1.iso)
|
||
virsh vol-create-as default seed-kc-lab-1.iso "$SZ" --format raw
|
||
virsh vol-upload --pool default seed-kc-lab-1.iso seed-kc-lab-1.iso
|
||
|
||
# 3) --cloud-init 대신 virtio 디스크로 붙인다
|
||
virt-install --name kc-lab-1 --memory 3584 --vcpus 2 \
|
||
--disk size=20,backing_store=/var/lib/libvirt/images/base.qcow2 \
|
||
--disk vol=default/seed-kc-lab-1.iso,device=disk,bus=virtio,readonly=on \
|
||
--network network=default,mac=52:54:00:aa:bb:11 \
|
||
--import --os-variant debian12 --noautoconsole
|
||
```
|
||
|
||
**성공 판정**
|
||
|
||
```bash
|
||
virsh domblklist kc-lab-1 # vdb 에 seed ISO 가 보여야 한다 (sda 가 아니라)
|
||
ssh kc-lab-1 'hostname; lsblk -o NAME,LABEL,FSTYPE | grep -i cidata'
|
||
# kc-lab-1
|
||
# vdb CIDATA iso9660
|
||
```
|
||
|
||
### 시드 디렉터리 구조와 파일명 규칙
|
||
|
||
NoCloud 데이터소스는 ISO 루트에서 **정확히 `user-data`와 `meta-data`라는
|
||
이름**의 파일을 찾는다. `kc-lab-1.yaml` 같은 이름으로는 인식하지 못한다.
|
||
그리고 `xorrisofs`는 입력 파일을 **basename 그대로** ISO 루트에 넣는다.
|
||
그래서 스테이징 디렉터리에 규정된 이름으로 복사해서 굽는 것이다.
|
||
|
||
```
|
||
kc-lab-1.yaml 원본 (사람이 편집)
|
||
seed-1/user-data 사본 — ISO 안에서 이 이름이어야 함
|
||
seed-1/meta-data instance-id + local-hostname
|
||
seed-kc-lab-1.iso 구워진 결과 (volid=CIDATA)
|
||
```
|
||
|
||
**`meta-data`는 생략할 수 없다.** user-data만 있으면 NoCloud가 그 장치를
|
||
데이터소스로 인정하지 않는다. 최소 내용은 두 줄이다.
|
||
|
||
```
|
||
instance-id: kc-lab-1-001
|
||
local-hostname: kc-lab-1
|
||
```
|
||
|
||
**`instance-id`의 의미** — cloud-init은 사용자 생성 같은 per-instance 모듈을
|
||
**instance-id당 한 번만** 실행한다. 같은 id로 재부팅하면 다시 실행하지 않는다.
|
||
디스크를 유지한 채 user-data를 재적용하려면 instance-id를 바꿔야 한다.
|
||
|
||
**스테이징 디렉터리를 없애는 방법** — `-graft-points`로 ISO 안의 경로를
|
||
직접 지정하면 복사본이 필요 없다.
|
||
|
||
```bash
|
||
xorrisofs -output seed-kc-lab-1.iso -volid CIDATA -joliet -rock -graft-points \
|
||
/user-data=kc-lab-1.yaml /meta-data=meta-kc-lab-1
|
||
```
|
||
|
||
**주의: 같은 내용이 세 곳에 존재한다** — 원본 YAML, ISO 안, 그리고 libvirt
|
||
풀에 업로드된 사본. **원본 YAML을 고쳐도 실행 중인 VM에는 아무 영향이 없다.**
|
||
ISO 재생성 → 풀 재업로드 → VM 재생성까지 해야 반영된다. 이 세 단계를
|
||
스크립트로 묶어두지 않으면 "고쳤는데 왜 안 바뀌지"로 시간을 잃는다.
|
||
|
||
**다른 선택지** — 시드를 SATA로 두고 싶다면 base 이미지를 `genericcloud`가
|
||
아니라 **`generic`** 변종으로 바꾸면 된다. 드라이버가 더 들어 있어 SATA를
|
||
인식한다. 대신 이미지가 커진다.
|
||
|
||
### 진단 도구: `virsh screenshot`
|
||
|
||
**이 문제를 푼 결정적 도구다.** 게스트에 로그인할 수 없을 때 화면을
|
||
그대로 PNG로 떠서 볼 수 있다.
|
||
|
||
```bash
|
||
virsh screenshot kc-lab-1 /tmp/kc1.ppm # 확장자와 무관하게 PNG 로 저장된다
|
||
```
|
||
|
||
`localhost login:`이 보이면 cloud-init 미실행,
|
||
`kc-lab-1 login:`이면 실행됨. **hostname 한 줄이 곧 판정**이다.
|
||
`virsh console`은 tty를 요구하고 새 출력이 없으면 아무것도 안 보이지만,
|
||
screenshot은 현재 화면 상태를 항상 보여준다.
|
||
|
||
키 입력이 필요하면 `virsh send-key`로 보낼 수 있다.
|
||
|
||
```bash
|
||
for k in KEY_R KEY_O KEY_O KEY_T KEY_ENTER; do
|
||
virsh send-key kc-lab-1 --codeset linux "$k"
|
||
done
|
||
```
|
||
|
||
### base 이미지가 무엇인지 확인하는 법
|
||
|
||
변종을 잘못 받았는지 의심될 때는 공식 체크섬과 대조하면 확실하다.
|
||
|
||
```bash
|
||
H=$(sha512sum /var/lib/libvirt/images/base.qcow2 | cut -d' ' -f1)
|
||
curl -sSL https://cloud.debian.org/images/cloud/bookworm/latest/SHA512SUMS \
|
||
| grep -i "^$H"
|
||
# -> debian-12-genericcloud-amd64.qcow2
|
||
```
|
||
|
||
**`--noautoconsole`의 대가** — 이 옵션을 주면 virt-install이 즉시 반환하고
|
||
`/var/lib/libvirt/boot/`의 임시 cloud-init ISO를 정리한다. 첫 부팅을
|
||
눈으로 확인할 수 없어서, cloud-init 성공 여부를 **SSH가 될 때까지 알 수 없다.**
|
||
시드 ISO를 위처럼 영구 볼륨으로 직접 관리하면 이 문제도 함께 사라진다.
|
||
콘솔에서 빠져나올 때는 `Ctrl + ]`.
|
||
|
||
**확인** (게스트 안에서)
|
||
|
||
```bash
|
||
cloud-init status --long # done 이어야 정상
|
||
sudo cat /var/log/cloud-init-output.log
|
||
sudo grep -iE 'error|warn|traceback' /var/log/cloud-init.log | head -30
|
||
cat /run/cloud-init/result.json
|
||
sudo blkid | grep -i cidata # NoCloud 시드 ISO 가 실제로 보였는지
|
||
df -h / # growpart 가 동작했는지 (20G 근처여야 함)
|
||
```
|
||
|
||
마지막에서 두 번째 줄이 핵심이다. `cidata` 레이블이 안 보이면 게스트가
|
||
user-data를 **아예 받지 못한 것**이고, 보이는데도 실패했다면 YAML 내용이나
|
||
모듈 실행 단계의 문제다. 원인 범위를 절반으로 줄여준다.
|
||
|
||
### UEFI / OVMF (`edk2-ovmf`)
|
||
|
||
**무엇인가** — VM에 제공할 펌웨어. 기본값은 SeaBIOS(레거시 BIOS)이고,
|
||
OVMF는 UEFI 펌웨어 구현이다.
|
||
|
||
**왜 여기 나오나** — x86 generic 클라우드 이미지는 대개 BIOS로도 부팅되니
|
||
**필수는 아니다.** 다만 최근 클라우드(EC2 UEFI 부팅 모드 포함)와 Secure
|
||
Boot 환경을 흉내내려면 필요하고, UEFI 전용 이미지를 만나면 없으면 못 뜬다.
|
||
"깔아두면 손해 없는" 부류다.
|
||
|
||
**확인**
|
||
|
||
```bash
|
||
ls /usr/share/edk2/x64/OVMF_CODE.4m.fd # Arch 기준 경로
|
||
```
|
||
|
||
### `--os-variant` / osinfo
|
||
|
||
**무엇인가** — 게스트 OS 종류를 libvirt에 알려주는 값. libvirt는 이걸로
|
||
적절한 가상 장치 모델(virtio 사용 여부, 디스크 버스, NIC 모델)을 고른다.
|
||
|
||
**왜 여기 나오나** — 잘못 주거나 생략하면 성능이 크게 떨어진다.
|
||
예를 들어 virtio 대신 e1000 에뮬레이션 NIC이 붙으면 네트워크 처리량이
|
||
몇 배 나빠지고, 그러면 우리가 측정하려는 노드 간 지연이 오염된다.
|
||
|
||
**확인**
|
||
|
||
```bash
|
||
osinfo-query os | grep -i debian # 사용 가능한 값 목록
|
||
```
|
||
|
||
---
|
||
|
||
## 2층. 가상 네트워크
|
||
|
||
### libvirt `default` 네트워크와 `virbr0`
|
||
|
||
**무엇인가** — libvirt가 만드는 소프트웨어 브리지(`virbr0`)와 그에 붙은
|
||
NAT 규칙. 기본 대역은 `192.168.122.0/24`이고, 호스트가 `.1`을 가진다.
|
||
VM들은 이 브리지에 연결되어 서로 직접 통신하고, 외부로 나갈 때만
|
||
호스트 IP로 마스커레이딩된다.
|
||
|
||
**왜 여기 나오나** — **VM끼리는 완전히 자유롭게 통신한다**는 점이 핵심이다.
|
||
그래서 노드 간 실험(JGroups 차단, 파티션, 클러스터 형성)은 NAT여도
|
||
아무 지장이 없다. NAT가 막는 건 "외부 → VM" 방향뿐이고, 그건 호스트
|
||
nginx가 해결한다.
|
||
|
||
**확인**
|
||
|
||
```bash
|
||
ip -brief addr show virbr0
|
||
virsh net-dumpxml default
|
||
```
|
||
|
||
**`virbr0`이 `DOWN`으로 보이는 것은 정상이다** — 리눅스 브리지는
|
||
활성 포트가 하나도 붙어 있지 않으면 캐리어가 없는 것으로 간주되어
|
||
`DOWN`/`NO-CARRIER`로 표시된다. VM이 한 대라도 뜨면 그 VM의 `vnetN`
|
||
인터페이스가 브리지에 붙으면서 `UP`으로 바뀐다. IP(`192.168.122.1/24`)가
|
||
이미 할당되어 있다면 네트워크 정의 자체는 정상이다.
|
||
|
||
### dnsmasq (libvirt 내장 DHCP/DNS)
|
||
|
||
**무엇인가** — 경량 DHCP + DNS 서버. libvirt가 `default` 네트워크마다
|
||
dnsmasq 인스턴스를 하나씩 띄워서 VM에 IP를 나눠주고 이름을 해석해준다.
|
||
|
||
**왜 여기 나오나** — 이 패키지가 없으면 **VM이 부팅은 되는데 IP를 못 받는다.**
|
||
증상이 "네트워크가 안 된다"로 나타나서 원인을 찾기 어렵다.
|
||
|
||
**확인**
|
||
|
||
```bash
|
||
ps aux | grep dnsmasq | grep virbr0
|
||
virsh net-dhcp-leases default # 실제로 나간 IP 목록
|
||
```
|
||
|
||
### DHCP 예약 (`ip-dhcp-host`)과 MAC `52:54:00`
|
||
|
||
**무엇인가** — MAC 주소와 IP를 1:1로 묶어두는 dnsmasq 설정.
|
||
"이 MAC을 가진 기계가 DHCP를 요청하면 항상 이 IP를 줘라"는 규칙이다.
|
||
libvirt에서는 `virsh net-update`로 네트워크 정의에 넣는다.
|
||
`52:54:00`은 QEMU/KVM에 할당된 OUI(제조사 식별 접두사)로,
|
||
이 대역을 쓰면 실제 NIC 제조사의 MAC과 충돌하지 않는다.
|
||
|
||
**인과 순서에 주의** — "upstream에 IP를 박으려고 예약을 건다"가 아니라
|
||
반대다. **고정 주소가 필요한 이유가 여러 개 있고**, 그걸 충족하는 수단이
|
||
DHCP 예약이며, 그 결과로 얻은 주소를 upstream에도 적는 것이다.
|
||
|
||
**고정이 필요한 이유 (중요도 순)**
|
||
|
||
1. **k3s가 IP를 설정 파일과 인증서에 굽는다.** `--node-ip`, `--tls-san`,
|
||
agent의 `K3S_URL=https://192.168.122.11:6443`, kubeconfig의 `server:`
|
||
필드가 전부 IP를 담는다. server 노드의 IP가 바뀌면 agent가 클러스터에
|
||
합류하지 못하고, API 서버 인증서의 SAN도 어긋나 **재발급이나 재설치**가
|
||
필요해진다. 되돌리기가 가장 비싼 항목이다.
|
||
2. **nginx는 upstream 주소를 기동 시점에 한 번만 해석한다.**
|
||
오픈소스판 nginx는 `upstream` 블록의 이름을 설정 로드 때 해석하고
|
||
런타임에 다시 조회하지 않는다(재조회하려면 `resolver` + 변수 트릭이나
|
||
상용판이 필요). 그래서 뒤쪽 IP가 바뀌면 reload 전까지 계속 502다.
|
||
3. **VM을 반복해서 죽이는 것이 실험 그 자체다.** `virsh destroy`로 노드
|
||
상실을 재현하는데, 되살릴 때마다 주소가 달라질 여지가 있으면 실험이
|
||
성립하지 않는다.
|
||
4. **장애 주입 규칙이 주소 기반이다.** "kc-lab-2로 가는 7800을 막아라"
|
||
같은 규칙에서 IP가 어긋나면 **조용히 엉뚱한 것을 막는다.** 실패가
|
||
드러나지 않는 종류라 특히 위험하다.
|
||
|
||
**왜 DHCP 예약인가 (다른 방법 대비)**
|
||
|
||
| 방법 | 문제 |
|
||
|---|---|
|
||
| 게스트 안에서 static IP 설정 | cloud-init이 복잡해지고, libvirt는 그 사실을 모른다. 설정이 두 곳에 흩어진다 |
|
||
| upstream에 호스트명 사용 | libvirt dnsmasq가 이름을 풀어주긴 하지만 호스트의 리졸버가 virbr0을 바라봐야 하고, 위 2번(기동 시 1회 해석)은 그대로 남는다 |
|
||
| **DHCP 예약** | **주소 관리가 libvirt 한 곳에 모인다.** 게스트는 평범한 DHCP 클라이언트로 두면 된다 |
|
||
|
||
**명령 분해**
|
||
|
||
```bash
|
||
virsh net-update default add ip-dhcp-host \
|
||
"<host mac='52:54:00:aa:bb:11' name='kc-lab-1' ip='192.168.122.11'/>" \
|
||
--live --config
|
||
```
|
||
|
||
| 토큰 | 의미 |
|
||
|---|---|
|
||
| `net-update` | 네트워크 정의 XML을 **부분 수정**. 전체를 편집기로 여는 `net-edit`과 달리 특정 섹션만 건드린다 |
|
||
| `default` | 대상 네트워크 이름 |
|
||
| `add` | 수행할 동작. 다른 값으로 `add-first`, `modify`, `delete` |
|
||
| `ip-dhcp-host` | 수정할 섹션. 네트워크 XML의 `<ip><dhcp><host>` 요소를 가리킨다 |
|
||
| `"<host .../>"` | 삽입할 XML 조각. `mac`=대상 식별, `ip`=줄 주소, `name`=dnsmasq DNS에 등록될 이름(선택) |
|
||
| `--live` | **실행 중인** 네트워크에 즉시 적용. libvirt가 dnsmasq 설정을 다시 쓰고 재로드시킨다 |
|
||
| `--config` | **영구 정의**(`/etc/libvirt/qemu/networks/default.xml`)에도 저장 |
|
||
|
||
**XML이 실제로 어떻게 바뀌나**
|
||
|
||
```xml
|
||
<!-- before -->
|
||
<ip address='192.168.122.1' netmask='255.255.255.0'>
|
||
<dhcp>
|
||
<range start='192.168.122.2' end='192.168.122.254'/>
|
||
</dhcp>
|
||
</ip>
|
||
|
||
<!-- after -->
|
||
<ip address='192.168.122.1' netmask='255.255.255.0'>
|
||
<dhcp>
|
||
<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'/>
|
||
</dhcp>
|
||
</ip>
|
||
```
|
||
|
||
**부팅 시 실제로 일어나는 일**
|
||
|
||
1. VM 부팅 → 게스트 커널이 virtio NIC 인식 → DHCP 클라이언트가
|
||
`DHCPDISCOVER`를 브로드캐스트한다. 이 프레임의 출발지 MAC이
|
||
`52:54:00:aa:bb:11`이다.
|
||
2. `virbr0`에 붙어 있는 dnsmasq가 수신하고, 예약 테이블에서 그 MAC을 찾는다.
|
||
3. 매칭되면 동적 범위에서 아무 주소나 고르는 대신 `192.168.122.11`을
|
||
`DHCPOFFER`로 제시한다.
|
||
4. 게스트가 `DHCPREQUEST` → dnsmasq가 `DHCPACK`. 게스트에 그 IP가 적용된다.
|
||
리스가 만료되어 갱신할 때도 같은 규칙이 적용되므로 주소가 유지된다.
|
||
|
||
**가장 흔한 실패: MAC 불일치** — 예약의 `mac`과 VM 생성 시
|
||
`--network network=default,mac=52:54:00:aa:bb:11`의 값이 **정확히 같아야
|
||
한다.** 다르면 예약이 조용히 무시되고 동적 범위에서 아무 주소나 받는다.
|
||
오류 메시지가 없으므로 증상은 "왜 IP가 다르지?"로만 나타난다.
|
||
|
||
**동적 범위와의 겹침** — 현재 범위는 `.2`~`.254`라 예약 주소 `.11`, `.12`가
|
||
그 안에 들어간다. dnsmasq는 정적으로 예약된 주소를 다른 클라이언트에게
|
||
내주지 않으므로 **이대로도 정상 동작한다.** 더 방어적으로 가려면 범위를
|
||
`.100`~`.254`로 좁혀 예약 대역과 분리할 수 있다.
|
||
|
||
**`--live`가 실패할 때** — 네트워크가 비활성 상태면 `--live`는 쓸 수 없다.
|
||
그때는 `--config`만 주고 네트워크를 시작하면 된다.
|
||
|
||
**확인**
|
||
|
||
```bash
|
||
virsh net-dumpxml default | grep -A5 dhcp # <host> 항목이 들어갔는지
|
||
virsh net-dhcp-leases default # 실제로 나간 리스
|
||
ssh kc-lab-1 ip -brief addr # 게스트가 받은 주소
|
||
```
|
||
|
||
**삭제**
|
||
|
||
```bash
|
||
virsh net-update default delete ip-dhcp-host \
|
||
"<host mac='52:54:00:aa:bb:11'/>" --live --config
|
||
```
|
||
|
||
### `--live --config`
|
||
|
||
**무엇인가** — libvirt의 변경 적용 범위 플래그.
|
||
`--live`는 지금 실행 중인 객체에만, `--config`는 영구 정의에만 적용한다.
|
||
**둘 다 줘야 "지금부터, 그리고 재부팅 후에도" 적용된다.**
|
||
|
||
**없거나 틀리면** — `--config`만 주면 지금은 반영이 안 되고,
|
||
`--live`만 주면 재부팅 시 사라진다. 둘 다 "왜 적용이 안 되지"로 시간을
|
||
잡아먹는 대표적인 함정이다.
|
||
|
||
### NAT vs 브리지 vs macvtap
|
||
|
||
| 모드 | VM 주소 | LAN에서 VM 접근 | 이 실험대에서 |
|
||
|---|---|---|---|
|
||
| NAT (`virbr0`) | 192.168.122.x (사설) | 불가 (포워딩 필요) | **채택** |
|
||
| 브리지 (`br0`) | LAN에서 직접 IP | 가능 | **WiFi라 불가** |
|
||
| macvtap | LAN에서 직접 IP | 가능(호스트↔VM은 제외) | WiFi라 불가 |
|
||
|
||
### WiFi에서 브리지가 안 되는 이유
|
||
|
||
**무엇인가** — 802.11 데이터 프레임은 기본적으로 주소 필드가 3개다
|
||
(3-address 모드). AP는 연결(association)된 station의 MAC만 알고 있고,
|
||
그 station이 **자기 것이 아닌 출발지 MAC을 단 프레임**을 보내면 버린다.
|
||
브리지된 VM은 정확히 그런 프레임을 보낸다 — 자기 MAC을 출발지로 쓰기
|
||
때문이다.
|
||
|
||
**왜 여기 나오나** — `test-server`에 이더넷이 없고 `wlo1`만 있다.
|
||
그래서 "VM에 LAN IP를 직접 주자"는 계획이 물리적으로 성립하지 않는다.
|
||
이 제약 하나가 토폴로지 전체를 NAT + 호스트 nginx로 확정시켰다.
|
||
|
||
**우회 수단** — 4-address 모드(WDS)를 AP와 클라이언트 드라이버가 모두
|
||
지원하면 가능하지만 실제로는 거의 지원되지 않는다. 현실적인 우회는
|
||
USB 이더넷 어댑터를 꽂는 것이다.
|
||
|
||
**확인**
|
||
|
||
```bash
|
||
ip -brief link | grep -v lo # 이더넷 인터페이스가 있는지
|
||
iw dev # 무선 인터페이스 정보
|
||
```
|
||
|
||
### SSH 키는 "머신"이 아니라 "홉" 단위다
|
||
|
||
**무엇인가** — SSH 인증은 항상 **클라이언트 1대 → 서버 1대**의 관계다.
|
||
클라이언트가 개인키를 들고, 서버의 `~/.ssh/authorized_keys`에 그 공개키가
|
||
있어야 한다. 그래서 필요한 키의 개수는 **머신 수가 아니라 홉의 수**로
|
||
정해진다.
|
||
|
||
**이 실험대의 홉**
|
||
|
||
| 홉 | 클라이언트(개인키 보유) | 서버(공개키 등록) | 상태 |
|
||
|---|---|---|---|
|
||
| 1 | 노트북 | test-server | 이미 있음 |
|
||
| 2 | **test-server** | kc-lab-1 / kc-lab-2 | **새로 생김** |
|
||
|
||
2번 홉에서는 **test-server가 처음으로 "클라이언트" 역할을 맡는다.**
|
||
지금까지 test-server는 서버이기만 했으므로 개인키가 없었다.
|
||
새 키가 필요한 이유는 "키가 부족해서"가 아니라 **역할이 바뀌었기 때문**이다.
|
||
|
||
**대안과 트레이드오프**
|
||
|
||
| 방법 | test-server에 개인키 | 비대화형 스크립트 | 비고 |
|
||
|---|---|---|---|
|
||
| test-server에 키 생성 | 있음 | **가능** | 가장 단순 |
|
||
| 에이전트 포워딩 (`ssh -A`) | 없음 | **불가** | 대화형 세션에만 에이전트가 산다 |
|
||
| ProxyJump (`ssh -J`) | 없음 | 불가(노트북 기준으로는 가능) | 노트북에서 게스트로 직행 |
|
||
|
||
**왜 이 실험대는 첫 번째인가** — k3s 설치, 장애 주입, 반복 실행을
|
||
**test-server에서 스크립트로** 돌린다. 에이전트 포워딩은 대화형 로그인
|
||
세션에만 유효해서 cron·systemd·백그라운드 스크립트에서는 인증이 실패한다.
|
||
|
||
**권장 구성 — 두 공개키를 모두 게스트에 넣는다.** 그러면 노트북에서
|
||
직행(ProxyJump)도 되고 test-server에서 자동화도 된다.
|
||
|
||
```yaml
|
||
ssh_authorized_keys:
|
||
- <test-server 의 ~/.ssh/id_ed25519.pub> # 홉 2 자동화용
|
||
- <노트북의 ~/.ssh/id_ed25519_test_server.pub> # 노트북 직행용
|
||
```
|
||
|
||
**노트북에서 게스트로 직행하기** (`~/.ssh/config`)
|
||
|
||
```
|
||
Host kc-lab-1
|
||
HostName 192.168.122.11
|
||
User donghyeon
|
||
ProxyJump test-server
|
||
IdentityFile ~/.ssh/id_ed25519_test_server
|
||
```
|
||
|
||
`ProxyJump`는 test-server를 **터널로만** 쓰고 인증은 게스트와 직접 한다.
|
||
그래서 test-server에 개인키를 두지 않아도 노트북에서 게스트로 붙을 수 있다.
|
||
|
||
**`ssh-copy-id`를 쓸 수 없는 이유 (닭과 달걀)** — 보통은 서버를 만든 뒤
|
||
`ssh-copy-id`로 공개키를 밀어 넣는다. 그런데 클라우드 이미지에는
|
||
**비밀번호가 설정된 계정이 아예 없다.** 비밀번호 로그인이 불가능하므로
|
||
키를 밀어 넣을 최초의 통로 자체가 없다.
|
||
|
||
그래서 키는 **부팅 전에** 심어야 하고, 그것이 cloud-init의 존재 이유다.
|
||
`ssh_authorized_keys`는 게스트가 처음 부팅하는 순간 이미 적용되어 있다.
|
||
순서가 `키 생성 → cloud-init에 기입 → VM 생성`인 것은 이 제약 때문이다.
|
||
|
||
**게스트 재생성과 호스트 키 변경** — 실험 중 VM을 지우고 다시 만들면
|
||
게스트의 **호스트 키가 매번 새로 생성된다.** 같은 IP에 다른 호스트 키가
|
||
오므로 SSH가 중간자 공격으로 간주하고 접속을 거부한다.
|
||
|
||
```
|
||
WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!
|
||
```
|
||
|
||
자동화 스크립트가 여기서 멈춘다. 폐기 가능한 실험용 게스트에 한해
|
||
아래 설정으로 우회한다.
|
||
|
||
```
|
||
Host kc-lab-*
|
||
StrictHostKeyChecking no
|
||
UserKnownHostsFile /dev/null
|
||
```
|
||
|
||
**이 설정은 실험용 사설망 게스트에만 쓴다.** 호스트 키 검증을 끄는 것은
|
||
중간자 공격 탐지를 포기하는 것이므로, 실제 서버 대상으로는 절대 쓰지 않는다.
|
||
|
||
### `~/.ssh/config`의 first-match-wins 규칙
|
||
|
||
**무엇인가** — SSH 클라이언트 설정 파일. 경로는 `~/.ssh/config`이고
|
||
**확장자가 없다.**
|
||
|
||
**가장 중요한 규칙 — 먼저 나온 값이 이긴다.** 대부분의 설정 파일은
|
||
나중 값이 앞 값을 덮어쓰지만, `ssh_config`는 **반대다.**
|
||
각 키워드에 대해 **파일에서 처음 만난 값**을 채택하고 이후 값은 무시한다.
|
||
|
||
```
|
||
Host kc-lab-1 # 구체적인 것이 위
|
||
HostName 192.168.122.11
|
||
User donghyeon
|
||
|
||
Host kc-lab-* # 와일드카드가 아래
|
||
StrictHostKeyChecking no
|
||
UserKnownHostsFile /dev/null
|
||
LogLevel ERROR
|
||
```
|
||
|
||
한 호스트에 여러 블록이 매칭되면 **매칭된 모든 블록의 키워드가 합쳐지되,
|
||
같은 키워드는 먼저 나온 것이 이긴다.** 위 예에서 `kc-lab-1`은
|
||
두 블록에 모두 매칭되고, 키워드가 겹치지 않으므로 둘 다 적용된다.
|
||
|
||
**틀리면** — 와일드카드 블록을 위에 두고 거기에 `User`를 적으면,
|
||
아래의 구체적인 블록에 쓴 `User`가 **조용히 무시된다.** 오류가 없어서
|
||
"왜 설정이 안 먹지"로만 나타난다.
|
||
|
||
**파일 권한 규칙** — OpenSSH는 설정 파일이 아래 조건을 만족해야 읽는다.
|
||
|
||
- 소유자가 **자기 자신 또는 root**
|
||
- **group/other 쓰기 권한이 없을 것**
|
||
|
||
위반하면 `Bad owner or permissions on /home/…/.ssh/config`로 **접속 자체가
|
||
거부된다.** `sudo`로 파일을 만들면 root 소유가 되는데, 읽기 전용(644)이면
|
||
동작은 하지만 본인이 수정할 수 없다. 소유권을 넘겨두는 편이 낫다.
|
||
|
||
```bash
|
||
sudo chown "$USER:$USER" ~/.ssh/config
|
||
chmod 600 ~/.ssh/config
|
||
```
|
||
|
||
**`LogLevel ERROR`을 넣는 이유** — `UserKnownHostsFile /dev/null`을 쓰면
|
||
접속할 때마다 `Warning: Permanently added ... to the list of known hosts.`가
|
||
출력된다. 스크립트 출력이 이 경고로 뒤덮이므로 함께 눌러둔다.
|
||
|
||
**확인 — `ssh -G`가 최종 판정이다**
|
||
|
||
```bash
|
||
ssh -G kc-lab-1
|
||
```
|
||
|
||
실제로 접속하지 않고 **모든 블록을 해석한 최종 설정값**을 출력한다.
|
||
`hostname`, `user`, `identityfile`, `stricthostkeychecking` 줄이
|
||
의도한 값인지 여기서 확인한다. 파일을 눈으로 읽는 것보다 정확하다.
|
||
|
||
**확인**
|
||
|
||
```bash
|
||
ssh -v donghyeon@192.168.122.11 2>&1 | grep -i 'offering\|accepted'
|
||
```
|
||
|
||
### `/etc/hosts`와 이름 해석 순서
|
||
|
||
**무엇인가** — DNS에 물어보기 **전에** 먼저 참조하는 로컬 이름↔주소 매핑
|
||
파일. 조회 순서는 `/etc/nsswitch.conf`의 `hosts:` 줄이 정하며,
|
||
`files`가 곧 `/etc/hosts`다. 파일에서 답을 찾으면 DNS로 나가지 않는다.
|
||
|
||
**`127.0.0.1 localhost`가 필요한 이유** — `localhost`라는 이름은 DNS에
|
||
존재하지 않는다. 로컬 파일로만 해석된다. 그런데 수많은 소프트웨어가
|
||
`localhost`로 접속한다(JDBC URL, 헬스체크 스크립트, `curl localhost`,
|
||
프록시 대상).
|
||
|
||
`::1 localhost`만 있고 IPv4 줄이 없으면 **IPv6로만 해석된다.**
|
||
IPv4 소켓으로만 리스닝하는 서버에 `localhost`로 붙으면 `::1`로 시도하다
|
||
`Connection refused`가 난다. 반대 상황도 생긴다.
|
||
이 실패는 "ping은 되는데 접속이 안 된다"는 형태로 나타나서 진단이 오래 걸린다.
|
||
|
||
**`127.0.1.1 <호스트명>`이 필요한 이유** — 자기 자신의 호스트명이 해석
|
||
가능해야 하는 프로그램이 있다.
|
||
|
||
| 프로그램 | 해석 실패 시 |
|
||
|---|---|
|
||
| `sudo` | `unable to resolve host` 경고, 타임아웃만큼 느려짐 |
|
||
| `hostname -f` | FQDN 조회 실패 |
|
||
| Java `InetAddress.getLocalHost()` | 예외. **JGroups가 로컬 주소를 정할 때 이 경로를 탄다** |
|
||
|
||
마지막 줄이 이 실험대와 직결된다. Keycloak 클러스터링은 JGroups를 쓰고,
|
||
JGroups는 자기 주소를 결정해야 한다. 게스트에서 호스트명이 안 풀리면
|
||
클러스터 형성 단계에서 엉뚱한 오류가 난다.
|
||
|
||
**`127.0.0.1`이 아니라 `127.0.1.1`을 쓰는 이유** — 루프백 대역
|
||
(`127.0.0.0/8`) 안이지만 `localhost`와는 **구분되는** 주소를 쓰기 위해서다.
|
||
호스트명을 `127.0.0.1`에 직접 붙이면 `localhost`와 같은 주소가 되어,
|
||
호스트명으로 바인딩한 서비스가 의도치 않게 `localhost`로도 노출된다.
|
||
Debian 계열의 관례이며 Arch에서도 같은 이유로 유용하다.
|
||
|
||
**게스트에서는 cloud-init이 대신 해준다** — `cloud-init-*.yaml`에 넣은
|
||
`manage_etc_hosts: true`가 정확히 이 작업을 수행한다. 게스트의 `/etc/hosts`에
|
||
호스트명 매핑을 자동으로 써준다. **호스트(test-server)에는 cloud-init이
|
||
없으므로 직접 써야 한다.**
|
||
|
||
**최종 내용** (Arch 기본값 + 호스트명 한 줄)
|
||
|
||
```
|
||
# Static table lookup for hostnames.
|
||
# See hosts(5) for details.
|
||
127.0.0.1 localhost
|
||
::1 localhost
|
||
127.0.1.1 test-server
|
||
```
|
||
|
||
**확인**
|
||
|
||
```bash
|
||
grep '^hosts:' /etc/nsswitch.conf # 조회 순서
|
||
getent hosts localhost # 127.0.0.1 이 나와야 함
|
||
getent hosts "$(hostname)" # 127.0.1.1 이 나와야 함
|
||
```
|
||
|
||
`getent`는 실제 이름 해석 경로를 그대로 타므로 `ping`보다 정확한 확인이다.
|
||
|
||
---
|
||
|
||
## 3층. 호스트 진입
|
||
|
||
### 리버스 프록시와 `upstream`
|
||
|
||
**무엇인가** — 클라이언트 요청을 대신 받아 뒤쪽 서버로 전달하는 서버.
|
||
nginx의 `upstream` 블록은 **뒤쪽 서버 여러 대를 하나의 논리 이름으로
|
||
묶는다.** `proxy_pass http://이름;`으로 그 그룹을 가리키면 nginx가
|
||
요청을 분배한다.
|
||
|
||
**왜 여기 나오나** — 지금 저장소의
|
||
[`deploy/reverse-proxy/nginx-keycloak.conf`](reverse-proxy-headers.md)는
|
||
`proxy_pass http://keycloak:8080`으로 **단일 대상**을 가리킨다.
|
||
멀티노드 실험을 하려면 반드시 `upstream` 형태로 바꿔야 한다.
|
||
|
||
### 왜 TLS를 끊어서 내용을 보는가
|
||
|
||
**TLS 종료(termination)**란 프록시가 암호를 풀어 평문 HTTP를 읽는 것이다.
|
||
"굳이 왜 푸는가"에 대한 답은 넷이고, 첫 번째가 근본적이다.
|
||
|
||
**1. 내용을 안 보면 어디로 보낼지 결정할 수 없다**
|
||
|
||
여러 도메인이 **하나의 IP와 443 포트를 공유**한다. 어느 서비스로 보낼지는
|
||
HTTP `Host` 헤더에 적혀 있는데, **그 헤더는 TLS 안에 암호화되어 있다.**
|
||
풀지 않으면 읽을 수 없고, 읽지 못하면 분기할 수 없다.
|
||
|
||
```
|
||
암호문 그대로 보면 : ████████████████ ← 어디로 보내지?
|
||
TLS 를 풀면 : GET / HTTP/1.1
|
||
Host: id.example.com ← 이걸 보고 분기
|
||
```
|
||
|
||
> **예외 — SNI**: TLS 핸드셰이크의 평문 부분(ClientHello)에 도메인이
|
||
> 들어 있어서, 암호를 풀지 않고 **도메인 단위 분기**는 가능하다
|
||
> (nginx `stream` + `ssl_preread`). 그러나 **경로 단위 분기는 불가능**하고,
|
||
> 인증서를 백엔드마다 따로 관리해야 한다.
|
||
|
||
**2. 인증서 관리를 한 곳에 모은다**
|
||
|
||
TLS를 통과시키면 **백엔드마다 인증서를 넣어야 한다.** 서비스가 다섯이면
|
||
발급·갱신·배포를 다섯 벌 관리한다. 프록시에서 끊으면 Let's Encrypt 갱신이
|
||
한 곳에서 끝난다.
|
||
|
||
**3. 헤더를 주입하려면 HTTP를 만질 수 있어야 한다**
|
||
|
||
`X-Forwarded-Proto: https`, `X-Forwarded-Host` 같은 헤더는 평문 HTTP를
|
||
편집할 수 있어야 넣을 수 있다. **TLS를 통과시키면 넣을 수 없다.**
|
||
그리고 Keycloak이 `iss` 클레임과 redirect URL을 외부 주소로 올바르게
|
||
생성하려면 이 헤더가 반드시 필요하다. 즉 **이 실험대의 구조에서는
|
||
TLS 종료가 선택이 아니라 전제다.**
|
||
|
||
**4. L7에서만 가능한 처리들**
|
||
|
||
실제 운영 설정(`desktop`)에서 뽑은 증거다. 모두 L4로는 불가능하다.
|
||
|
||
| 설정 | 하는 일 | L4로 가능한가 |
|
||
|---|---|---|
|
||
| `location = /metrics { return 404; }` | 특정 **경로** 차단 | 불가 — 경로를 모른다 |
|
||
| `map $http_upgrade …` | WebSocket 업그레이드 처리 | 불가 — 헤더를 못 읽는다 |
|
||
| `client_max_body_size 512m` | 요청 **본문** 크기 제한 | 불가 — 본문 경계를 모른다 |
|
||
| `proxy_read_timeout 3600s` | 장수명 HTTP 연결 유지 | 부분적 |
|
||
| `proxy_set_header Host …` | Host 헤더 고정 | 불가 |
|
||
|
||
여기에 압축·캐싱·리다이렉트·레이트 리밋·접근 로그·WAF가 모두 포함된다.
|
||
|
||
**끊는 대가**
|
||
|
||
| 대가 | 이 실험대에서 |
|
||
|---|---|
|
||
| 프록시 뒤 구간이 평문이 된다 | 운영은 `127.0.0.1`, lab 은 `virbr0` — 둘 다 머신 밖으로 안 나간다 |
|
||
| 신뢰 경계가 프록시까지 확장된다 | 프록시가 복호문을 볼 수 있다. 그래서 프록시 보안이 곧 전체 보안 |
|
||
| **클라이언트 인증서가 사라진다** | mTLS 를 백엔드가 검증해야 하면 종료하면 안 된다 |
|
||
|
||
**끊지 않는(passthrough) 선택이 맞는 경우**
|
||
|
||
- mTLS — 백엔드가 클라이언트 인증서를 직접 검증해야 할 때
|
||
- 백엔드가 자기 인증서로 신원을 증명해야 할 때
|
||
- 프록시 운영자를 신뢰할 수 없을 때 (멀티테넌트 CDN 등)
|
||
- 규정상 종단 간 암호화가 요구될 때
|
||
|
||
이 경우 L4 통과 구성을 쓰며, 그것이 앞의 NLB 자리다.
|
||
|
||
### `X-Forwarded-*`와 신뢰 경계
|
||
|
||
**무엇인가** — 프록시가 뒤쪽 서버에게 "원래 클라이언트는 이랬다"고
|
||
알려주는 관례적 헤더군. `X-Forwarded-Proto`(원래 스킴),
|
||
`X-Forwarded-Host`(원래 호스트), `X-Forwarded-For`(원래 IP).
|
||
|
||
**왜 여기 나오나** — TLS를 nginx에서 끊으면 Keycloak은 평문 HTTP로 요청을
|
||
받는다. 그러면 Keycloak이 만드는 리다이렉트 URL과 토큰의 `iss` 클레임이
|
||
`http://`로 나가버린다. 이걸 막는 게 이 헤더들이다.
|
||
|
||
**핵심은 "신뢰 경계"다.** 이 헤더들은 **누구나 위조할 수 있는 평범한 HTTP
|
||
헤더**다. 그래서 뒤쪽 서버는 "신뢰하는 프록시가 붙여준 것"만 믿어야 하고,
|
||
신뢰하는 프록시는 클라이언트가 보낸 값을 **반드시 덮어써야** 한다
|
||
(`proxy_set_header`가 append가 아니라 set인 이유).
|
||
|
||
**없거나 틀리면** — Keycloak이 신뢰하지 않는 곳에서 이 헤더를 받으면
|
||
공격자가 `X-Forwarded-Host`를 조작해 인증 흐름을 자기 도메인으로 돌릴 수
|
||
있다. 반대로 헤더가 아예 없으면 `KC_HOSTNAME_STRICT=true` 아래에서
|
||
호스트 불일치로 요청이 거부된다.
|
||
|
||
**이 실험대의 쟁점** — 운영이 `nginx → Traefik` 2홉이라
|
||
[`docs/reverse-proxy-headers.md`](reverse-proxy-headers.md)의 1홉 가정과
|
||
어긋난다. nginx가 세팅한 값을 Traefik이 덮어쓰는지, 신뢰하는지,
|
||
이어붙이는지에 따라 결과가 갈린다. **가장 먼저 실측할 항목.**
|
||
|
||
### 스티키 세션
|
||
|
||
**무엇인가** — 같은 클라이언트의 요청을 항상 같은 백엔드 노드로 보내는 것.
|
||
nginx 오픈소스판에서는 `ip_hash`(클라이언트 IP 해시)나
|
||
`hash <키> consistent`로 구현한다.
|
||
|
||
**왜 여기 나오나** — Keycloak은 로그인 진행 중에 "인증 세션"이라는 임시
|
||
상태를 만든다. 노드가 매 요청 바뀌면 그 상태를 다른 노드에서 가져와야 해서
|
||
느려진다(Infinispan이 라우팅해주므로 **실패하지는 않는다**).
|
||
Keycloak 공식 권장은 `AUTH_SESSION_ID` 쿠키 기반 스티키다.
|
||
|
||
**실험 설계상 의미** — 스티키를 껐다 켜면서 동작과 지연을 비교하는 것이
|
||
가장 값싼 멀티노드 관찰이다. 그래서 `ip_hash` 한 줄을 주석 스위치로 둔다.
|
||
|
||
**주의** — `ip_hash`는 클라이언트 IP로 해시하는데, 브라우저 한 대로
|
||
실험하면 항상 같은 노드로만 가서 분산 자체가 관찰되지 않는다.
|
||
`AUTH_SESSION_ID` 기반은 로그인 전에 쿠키가 없다는 반대 문제가 있다.
|
||
|
||
### 진입점 자체가 죽으면 — 로드밸런서의 재귀 문제
|
||
|
||
**호스트 nginx는 이 실험대의 단일 장애점(SPOF)이다.** 숨길 이유가 없다.
|
||
물리 머신도 한 대이므로 그것 역시 SPOF다. 실험대의 알려진 한계로 남겨둔다.
|
||
|
||
**ALB와 NLB는 계층이 다른 것이 아니다** — 자주 오해하는 지점이다.
|
||
둘 다 **클러스터 밖의 로드밸런서**이고, 같은 자리를 놓고 고르는 두 선택지다.
|
||
Ingress Controller와 대응되는 관계가 아니다.
|
||
|
||
| | ALB (L7) | NLB (L4) |
|
||
|---|---|---|
|
||
| 이해하는 것 | HTTP/HTTPS | TCP/UDP |
|
||
| 라우팅 기준 | 호스트명·경로 | 포트 |
|
||
| TLS | 종료함 | 통과 또는 종료 |
|
||
| `X-Forwarded-*` | **추가함** | 추가 안 함 (PROXY protocol 사용) |
|
||
|
||
우리 호스트 nginx는 TLS를 끊고 `X-Forwarded-*`를 넣으므로 **ALB에 가깝다.**
|
||
|
||
**그렇다면 NLB 자리에는 무엇이 오는가**
|
||
|
||
먼저 전제를 분명히 한다. **진입점 자리는 하나다.** ALB와 NLB를 나란히 두
|
||
개 배치하지 않는다. 그리고 **L7 처리는 어딘가에서 반드시 한 번 일어난다** —
|
||
HTTP 라우팅이 필요하기 때문이다. 배치의 차이는 **진입점과 L7 처리기가 같은
|
||
장비인가 다른 장비인가**뿐이다.
|
||
|
||
```
|
||
[ALB 패턴]
|
||
브라우저 ─▶ ALB (L7 · TLS 종료 · 경로 라우팅) ─▶ Pod
|
||
└ 진입점이자 L7 처리기. 하나가 두 역할.
|
||
|
||
[NLB 패턴]
|
||
브라우저 ─▶ NLB (L4 · 통과) ─▶ ingress controller (L7 · TLS 종료) ─▶ Pod
|
||
└ 진입점만 └ L7 처리기. 역할이 둘로 나뉜다.
|
||
```
|
||
|
||
두 번째 그림의 ingress controller는 **NLB가 아니라 L7**이다.
|
||
"ALB와 NLB를 같이 쓴다"가 아니라 "진입점을 L4로 두고 L7 처리를 클러스터
|
||
안으로 옮긴다"는 뜻이다.
|
||
|
||
**이 실험대와 `desktop`은 둘 중 어느 쪽도 아니다 — L7이 두 겹이다.**
|
||
|
||
```
|
||
브라우저 ─▶ nginx (L7 · TLS 종료) ─▶ Traefik (L7 · Ingress 라우팅) ─▶ Pod
|
||
```
|
||
|
||
| 배치 | 진입점 | L7 처리 위치 |
|
||
|---|---|---|
|
||
| ALB 단독 | ALB (L7) | 진입점 한 곳 |
|
||
| NLB + ingress | NLB (L4) | 클러스터 안 한 곳 |
|
||
| **L7 + ingress** | **nginx (L7)** | **두 곳 모두** ← 이 실험대, `desktop` |
|
||
|
||
**L7을 두 겹 쌓는 이유는 역할이 다르기 때문이다.**
|
||
|
||
| | 호스트 nginx | Traefik |
|
||
|---|---|---|
|
||
| 담당 | 공개 진입점, TLS·인증서, 헤더 주입 | 클러스터 내부 라우팅 |
|
||
| 대상 | **고정** IP:포트 | **동적** — 파드 생성·소멸을 추적 |
|
||
| 갱신 | 사람이 파일 수정 후 reload | API 서버를 감시하며 자동 |
|
||
|
||
nginx는 클러스터의 존재를 모른다. 파드 IP가 바뀌는 것도 모른다.
|
||
그래서 **바깥세상과의 접점**만 맡고, **안에서 누가 어디 있는지**는
|
||
Traefik이 맡는다. 이 2홉이 곧 `X-Forwarded-*` 검증의 대상이다.
|
||
|
||
**NLB를 고르는 이유**
|
||
|
||
| 이유 | 설명 |
|
||
|---|---|
|
||
| 클라이언트 IP 보존 | L4라 원본 IP가 그대로 도달. ALB는 `X-Forwarded-For`로만 전달 |
|
||
| 고정 IP | AZ당 고정 IP 부여 가능. ALB는 DNS 이름만 준다 |
|
||
| HTTP가 아닌 것 | LDAP, PostgreSQL, MQTT, 원시 TCP/UDP |
|
||
| **mTLS 통과** | 클라이언트 인증서를 **백엔드가 직접 검증**해야 할 때 |
|
||
| 지연·성능 | L4가 더 가볍다 |
|
||
|
||
**Keycloak 맥락에서 네 번째가 중요하다.** X.509 클라이언트 인증서 인증을
|
||
Keycloak이 수행하려면 TLS가 Keycloak까지 **끊기지 않고 도달**해야 한다.
|
||
앞단에서 TLS를 종료하면 클라이언트 인증서가 사라져 불가능해진다.
|
||
그래서 이런 요구가 있으면 L7이 아니라 L4 통과 구성을 쓴다.
|
||
|
||
**이 실험대에서 NLB에 해당하는 것은 아직 없다.** 필요해지면
|
||
nginx의 `stream {}` 블록이 그 자리다. **nginx는 한 프로세스에서
|
||
L7과 L4를 동시에 수행할 수 있다** — AWS에서 ALB와 NLB가 별개 제품인 것과
|
||
다른 점이다.
|
||
|
||
```nginx
|
||
http {
|
||
# L7 : TLS 종료 + X-Forwarded-* + 경로 라우팅 ← ALB 역할
|
||
}
|
||
|
||
stream {
|
||
# L4 : TCP 를 그대로 통과시킨다 ← NLB 역할
|
||
upstream k8s_api {
|
||
server 192.168.122.11:6443;
|
||
server 192.168.122.12:6443;
|
||
}
|
||
server {
|
||
listen 6443;
|
||
proxy_pass k8s_api;
|
||
}
|
||
}
|
||
```
|
||
|
||
`stream` 블록이 실제로 필요해지는 경우는 셋이다.
|
||
|
||
- k3s API 서버(6443)를 밖에서 접근 — 클라이언트 인증서 기반이라 TLS 통과 필수
|
||
- PostgreSQL(5432)·Redis(6379)를 게스트 밖에서 직접 관찰
|
||
- Keycloak mTLS 실험
|
||
|
||
| 자리 | 클라우드 | 이 실험대 |
|
||
|---|---|---|
|
||
| L7 진입 (TLS 종료·경로 라우팅) | ALB | 호스트 nginx `http {}` |
|
||
| L4 진입 (TCP 통과·IP 보존) | NLB | 호스트 nginx `stream {}` (아직 없음) |
|
||
| 클러스터 내 L7 라우팅 | ingress controller | Traefik |
|
||
|
||
**한 머신 안에서 nginx를 여러 개 띄우는 것은 의미가 없다**
|
||
|
||
nginx는 이미 **master 프로세스 1개 + worker N개** 구조다. worker들이 리스닝
|
||
소켓을 공유하며 CPU 코어 수만큼 병렬로 처리한다. 즉 프로세스 다중화는 이미
|
||
되어 있다. 그리고 같은 머신에 인스턴스를 늘려도 **그 머신이 죽으면 전부
|
||
죽는다.** 가용성은 전혀 늘지 않는다.
|
||
|
||
**진짜 이중화는 머신을 늘리는 것이고, 그러면 새 질문이 생긴다 —
|
||
"그럼 어느 nginx로 갈지는 누가 정하는가?"**
|
||
|
||
앞에 LB를 또 두면 그 LB가 SPOF다. **재귀가 끝나지 않는다.**
|
||
실무에서 이 재귀는 **소프트웨어가 아니라 네트워크 계층의 장치**로 끊는다.
|
||
|
||
| 방법 | 재귀를 끊는 원리 | 전환 시간 |
|
||
|---|---|---|
|
||
| **VIP + VRRP** (keepalived) | 선택자가 없다. **IP 자체가 이동**한다 | 1~3초 |
|
||
| **DNS 다중 A 레코드** | 클라이언트가 고른다. 실패 시 다음 IP로 재시도 | TTL 의존, 느림 |
|
||
| **애니캐스트 + BGP/ECMP** | 라우터가 가장 가까운 경로로 보낸다 | 즉시, 대규모 전용 |
|
||
| **클라우드 LB에 위임** | AWS가 내부적으로 다중 AZ로 이중화. 사용자는 DNS 이름만 받음 | 관리 불필요 |
|
||
|
||
**VRRP가 동작하는 방식** — 가장 흔한 온프레미스 답이다.
|
||
|
||
```
|
||
VIP 192.168.0.100 (가상 IP, 한 번에 한 대만 보유)
|
||
│
|
||
┌───────┴───────┐
|
||
│ │
|
||
nginx-1 nginx-2
|
||
MASTER BACKUP
|
||
(VIP 보유) (대기, MASTER 생존 신호를 감시)
|
||
|
||
MASTER 사망 → BACKUP 이 VIP 를 가져가고
|
||
gratuitous ARP 를 브로드캐스트
|
||
→ 스위치의 MAC 테이블이 갱신됨
|
||
→ 같은 IP 인데 트래픽이 다른 장비로 흐른다
|
||
```
|
||
|
||
**핵심은 "선택하는 주체가 없다"는 점이다.** 클라이언트는 계속 같은 IP로
|
||
접속하고, 그 IP가 어느 장비에 붙어 있는지가 바뀔 뿐이다. L2 계층의 ARP를
|
||
이용해 재귀를 끊는다.
|
||
|
||
**클라우드가 편한 이유가 여기 있다.** ALB/NLB는 내부적으로 여러 AZ에
|
||
이중화되어 있고, 사용자는 DNS 이름 하나만 받는다. **재귀를 AWS가 대신
|
||
풀어준 것**이지 재귀가 없는 것이 아니다.
|
||
|
||
**이 실험대에서는 하지 않는다.** 물리 머신이 한 대라 keepalived를 구성해도
|
||
그 머신이 죽으면 끝이라 의미가 없고, 검증 대상은 Keycloak의 세션·토큰이지
|
||
LB 가용성이 아니다. 다만 **Traefik은 이미 두 노드에 떠 있으므로**
|
||
"노드 하나를 죽이고 호스트 nginx의 upstream이 어떻게 반응하는지"는
|
||
그대로 관찰할 수 있다. 그것이 이 실험대가 다루는 범위다.
|
||
|
||
### `nginx -t`
|
||
|
||
**무엇인가** — 설정 파일 문법 검사. 실제로 적용하지 않고 파싱만 한다.
|
||
|
||
**왜 여기 나오나** — `systemctl reload nginx`는 설정이 깨져 있으면
|
||
**기존 프로세스까지 죽인다.** `nginx -t && systemctl reload nginx`로
|
||
연결해서 검사를 통과했을 때만 reload하는 게 습관이 되어야 한다.
|
||
|
||
---
|
||
|
||
## 4층. TLS
|
||
|
||
### ACME
|
||
|
||
**무엇인가** — Automatic Certificate Management Environment. 인증서
|
||
발급을 자동화하는 프로토콜(RFC 8555). Let's Encrypt가 대표 구현체이고,
|
||
certbot·Caddy·acme.sh 등이 클라이언트다.
|
||
|
||
**왜 여기 나오나** — 자체 서명 인증서를 쓰면 브라우저가 경고를 띄우고,
|
||
그 상태에서 관찰한 쿠키 동작은 신뢰할 수 없다. 실인증서가 있어야
|
||
`Secure` 쿠키·`SameSite`·HSTS가 운영과 동일하게 동작한다.
|
||
|
||
### 도메인 검증: HTTP-01 vs DNS-01
|
||
|
||
**무엇인가** — "이 도메인이 정말 네 것이냐"를 증명하는 두 방식.
|
||
|
||
| | HTTP-01 | DNS-01 |
|
||
|---|---|---|
|
||
| 증명 방법 | `http://도메인/.well-known/acme-challenge/<토큰>`에 파일 배치 | 도메인의 `_acme-challenge` TXT 레코드에 값 등록 |
|
||
| 인바운드 80 포트 | **필요** | **불필요** |
|
||
| 와일드카드 발급 | **불가** | **가능** |
|
||
| 필요한 권한 | 웹서버 접근 | DNS API 토큰 |
|
||
|
||
**왜 여기 나오나** — 두 줄이 결정적이다. 첫째, 우리 VM은 NAT 뒤에 있어서
|
||
외부에서 80 포트로 들어올 수 없다. 둘째, `auth`/`app1`/`app2` 여러
|
||
서브도메인이 필요한데 **와일드카드는 ACME 명세상 DNS-01로만 발급된다.**
|
||
둘 다 DNS-01을 가리킨다.
|
||
|
||
**확인**
|
||
|
||
```bash
|
||
sudo certbot certificates # 발급된 인증서와 도메인 목록
|
||
sudo certbot renew --dry-run # 갱신이 실제로 되는지 예행연습
|
||
```
|
||
|
||
### `fullchain.pem` / `privkey.pem` / `cert.pem` / `chain.pem`
|
||
|
||
**무엇인가** — certbot이 만드는 네 파일.
|
||
|
||
| 파일 | 내용 |
|
||
|---|---|
|
||
| `cert.pem` | 내 도메인 인증서(리프)만 |
|
||
| `chain.pem` | 중간 CA 인증서들만 |
|
||
| `fullchain.pem` | 리프 + 중간 CA (= 위 둘을 이어붙인 것) |
|
||
| `privkey.pem` | 개인키 |
|
||
|
||
**왜 여기 나오나** — nginx의 `ssl_certificate`에는 **반드시 `fullchain.pem`**을
|
||
줘야 한다. `cert.pem`을 주면 중간 CA가 빠져서, 데스크톱 브라우저에서는
|
||
멀쩡한데 **모바일이나 curl에서만 신뢰 실패**하는 골치아픈 증상이 난다.
|
||
|
||
### 공개 DNS에 사설 IP를 넣는 것
|
||
|
||
**무엇인가** — `*.lab.example.com`의 A 레코드로 `192.168.0.200`을 등록하는 것.
|
||
|
||
**왜 안전한가** — DNS 레코드는 이름을 주소로 바꿔줄 뿐 접근 권한을 주지
|
||
않는다. 사설 대역(RFC 1918) 주소는 인터넷에서 라우팅되지 않으므로,
|
||
외부인이 그 이름을 조회해도 도달할 수 없다. 노출되는 정보는 "내부에
|
||
그런 IP를 쓴다" 정도다.
|
||
|
||
**대안** — 각 클라이언트의 `/etc/hosts`에 넣기. 노출이 아예 없지만
|
||
기기마다 관리해야 한다. 집 밖에서 tailnet(`100.83.212.4`)으로 붙을 때는
|
||
어차피 `/etc/hosts` 덮어쓰기가 필요하다.
|
||
|
||
---
|
||
|
||
## 5층. k3s
|
||
|
||
### k3s server / agent / node-token
|
||
|
||
**무엇인가** — k3s는 쿠버네티스를 단일 바이너리로 압축한 배포판이다.
|
||
`server`는 컨트롤 플레인(API 서버, 스케줄러, etcd 대체 SQLite)을 포함하고,
|
||
`agent`는 워크로드만 실행한다. agent가 server에 합류할 때 쓰는 공유
|
||
비밀이 **node-token**이다.
|
||
|
||
**왜 여기 나오나** — 2노드 구성의 최소 단위가 server 1 + agent 1이다.
|
||
이걸 서로 다른 VM(= 서로 다른 커널)에 두는 것이 "진짜 노드 상실"과
|
||
"노드 간 방화벽" 실험의 전제 조건이다.
|
||
|
||
**확인**
|
||
|
||
```bash
|
||
sudo cat /var/lib/rancher/k3s/server/node-token # server에서
|
||
kubectl get nodes -o wide # Ready 2개
|
||
```
|
||
|
||
### `--node-ip` / `--tls-san`
|
||
|
||
**무엇인가** — `--node-ip`는 노드가 자기 주소로 광고할 IP를 고정한다.
|
||
`--tls-san`은 API 서버 인증서의 SAN(Subject Alternative Name) 목록에
|
||
값을 추가한다.
|
||
|
||
**왜 여기 나오나** — 인터페이스가 여러 개면(우리 VM은 `enp1s0` 외에
|
||
CNI 인터페이스들이 생긴다) k3s가 엉뚱한 IP를 고를 수 있다.
|
||
`--tls-san`이 없으면 호스트에서 `kubectl`로 붙을 때
|
||
"certificate is valid for 127.0.0.1, not 192.168.122.11" 오류가 난다.
|
||
|
||
### kubeconfig의 `127.0.0.1` 문제
|
||
|
||
**무엇인가** — k3s가 만드는 `/etc/rancher/k3s/k3s.yaml`은 서버 주소가
|
||
`https://127.0.0.1:6443`이다. 노드 자신에서 쓰는 걸 전제하기 때문이다.
|
||
|
||
**왜 여기 나오나** — 이 파일을 호스트로 복사하면 호스트 자기 자신의
|
||
6443을 가리키게 되어 연결이 실패한다. `sed`로 VM IP로 바꿔야 한다.
|
||
|
||
```bash
|
||
mkdir -p ~/.kube # 이 줄을 빠뜨리면 아래가 실패한다
|
||
ssh kc-lab-1 'sudo cat /etc/rancher/k3s/k3s.yaml' \
|
||
| sed 's/127.0.0.1/192.168.122.11/' > ~/.kube/config
|
||
chmod 600 ~/.kube/config
|
||
```
|
||
|
||
**이 명령은 호스트(test-server)에서 실행한다.** 게스트 안에서 실행하면
|
||
`ssh kc-lab-1` 부분이 자기 자신에게 다시 접속하는 꼴이 되고, 애초에
|
||
게스트에서는 `sudo k3s kubectl`을 쓰면 되므로 kubeconfig가 필요 없다.
|
||
|
||
**리다이렉션은 셸이 명령보다 먼저 처리한다** — 자주 걸리는 함정이다.
|
||
|
||
```
|
||
sudo cat 원본 > ~/.kube/config
|
||
└──┬──┘ └─────┬─────┘
|
||
│ │
|
||
│ └─ ① 셸이 먼저 이 파일을 연다 (현재 사용자 권한으로)
|
||
└─ ② 그 다음에야 명령이 실행된다
|
||
```
|
||
|
||
그래서 `~/.kube` 디렉터리가 없으면 `cat`이 시작되기도 전에
|
||
`No such file or directory`로 끝난다. **`>`는 파일을 열 뿐 경로를 만들지
|
||
않는다.** 같은 이유로, `sudo`를 붙였는데도 출력 파일 쓰기가 거부되는
|
||
현상이 생긴다 — `sudo`는 `cat`에만 적용되고 `>`에는 적용되지 않기 때문이다.
|
||
그럴 때는 `sudo tee`를 쓴다.
|
||
|
||
```bash
|
||
echo 내용 | sudo tee /root/전용경로 > /dev/null
|
||
```
|
||
|
||
### Traefik (k3s 기본 ingress)
|
||
|
||
**무엇인가** — k3s가 기본으로 배포하는 ingress 컨트롤러.
|
||
`--disable=traefik`으로 끌 수 있다.
|
||
|
||
**왜 여기 나오나** — **운영 환경이 k3s이므로 운영에도 Traefik이 있다.**
|
||
그래서 실험대에서 끄면 안 된다. 우리가 검증하려는 2홉 헤더 문제가
|
||
정확히 `nginx → Traefik` 경계에서 발생한다.
|
||
|
||
### 호스트 nginx와 Traefik은 무엇이 다른가 — 둘 다 필요한 이유
|
||
|
||
**대체 관계가 아니다. 서로 다른 층이다.** 하나는 클러스터 밖, 하나는 안이다.
|
||
|
||
| | 호스트 nginx | Traefik (k3s ingress) |
|
||
|---|---|---|
|
||
| 사는 곳 | 클러스터 **밖**, 호스트 OS의 프로세스 | 클러스터 **안**, 파드 |
|
||
| 아는 대상 | IP:포트 (고정) | 쿠버네티스 Service/Ingress (동적) |
|
||
| 설정 방법 | 파일 편집 + `nginx -s reload` | `kubectl apply` 로 Ingress 리소스 |
|
||
| 대상이 바뀌면 | **사람이 고쳐야 함** | **자동 반영** |
|
||
| 결정하는 것 | **어느 노드로 보낼까** | **어느 파드로 보낼까** |
|
||
| TLS | 여기서 종료 | 평문으로 받음 |
|
||
|
||
**왜 Traefik만으로는 부족한가** — Traefik은 각 노드 위에서 돈다.
|
||
servicelb 덕에 두 노드의 80/443에 모두 바인딩되지만, **브라우저는 어느
|
||
노드로 가야 할지 모른다.** 그리고 그 노드가 죽으면 그 IP도 죽는다.
|
||
|
||
즉 **Traefik은 노드 안에서 파드로 나눠주지만, 노드들 사이에서는 나눠주지
|
||
못한다.** 그 일을 할 무언가가 클러스터 밖에 있어야 한다. 클라우드에서는
|
||
ALB/NLB가 그 자리이고, 이 실험대에는 클라우드 LB가 없으므로 호스트 nginx가
|
||
그 역할을 맡는다.
|
||
|
||
```
|
||
브라우저
|
||
│
|
||
▼
|
||
호스트 nginx ← 클러스터 밖 · TLS 종료 · "어느 노드로?"
|
||
├──▶ 192.168.122.11:80 (kc-lab-1 의 Traefik)
|
||
└──▶ 192.168.122.12:80 (kc-lab-2 의 Traefik)
|
||
│
|
||
▼
|
||
Traefik ← 클러스터 안 · "어느 파드로?"
|
||
├──▶ keycloak Pod
|
||
└──▶ bff Pod
|
||
```
|
||
|
||
**Ingress와 Ingress Controller의 관계** — 자주 혼동되는 지점이다.
|
||
|
||
| | 정체 |
|
||
|---|---|
|
||
| Ingress | **설정을 적어둔 쿠버네티스 리소스**. 그 자체로는 아무 일도 하지 않는다 |
|
||
| Ingress Controller | 그 설정을 **실제로 수행하는 프로그램**. Traefik, ingress-nginx 등 |
|
||
|
||
컨트롤러는 API 서버를 계속 감시하다가 Ingress 리소스가 생기거나 바뀌면
|
||
자기 라우팅 설정을 갱신한다. **컨트롤러가 없으면 Ingress를 아무리 만들어도
|
||
트래픽은 흐르지 않는다.** 반대로 호스트 nginx는 이런 감시 기능이 없어서
|
||
대상이 바뀌면 사람이 파일을 고쳐야 한다.
|
||
|
||
**한쪽만 쓰면 안 되나**
|
||
|
||
| 시도 | 문제 |
|
||
|---|---|
|
||
| Traefik만 (노드 IP 직접 지정) | 그 노드가 죽으면 전체 다운 → **노드 상실 실험이 무의미**해진다. TLS도 클러스터 안에서 관리해야 함 |
|
||
| nginx만 (Traefik 비활성화) | Ingress 리소스를 못 쓴다. 서비스가 늘거나 파드 IP가 바뀔 때마다 수동 수정 |
|
||
|
||
그리고 **두 경우 모두 운영 구조와 달라진다.** 운영이
|
||
`host nginx → k3s(Traefik)`이므로, 실험대도 그 2홉을 복제해야
|
||
`X-Forwarded-*` 신뢰 경계 결론이 그대로 이전된다. 이것이 결정적인 이유다.
|
||
|
||
**클라우드와의 대응**
|
||
|
||
| 이 실험대 | AWS |
|
||
|---|---|
|
||
| 호스트 nginx | ALB / NLB |
|
||
| Traefik | ingress-nginx, ALB Ingress Controller |
|
||
| servicelb | 클라우드 LB 컨트롤러 |
|
||
|
||
### servicelb (klipper-lb)
|
||
|
||
**무엇인가** — k3s 내장 LoadBalancer 컨트롤러. 클라우드 LB가 없는
|
||
환경에서 `type: LoadBalancer` 서비스를 처리하기 위해, **모든 노드에**
|
||
hostPort를 여는 DaemonSet 파드를 띄운다.
|
||
|
||
**왜 여기 나오나** — 이것 덕분에 Traefik이 `192.168.122.11:80`과
|
||
`192.168.122.12:80` **양쪽 모두에서** 응답한다. 그래서 호스트 nginx의
|
||
`upstream`에 VM 두 대를 그냥 나열하면 된다. 별도 LB 구성이 필요 없다.
|
||
|
||
**확인**
|
||
|
||
```bash
|
||
kubectl -n kube-system get svc traefik # EXTERNAL-IP에 노드 IP들이 뜸
|
||
kubectl -n kube-system get ds # svclb-* DaemonSet
|
||
```
|
||
|
||
### flannel VXLAN
|
||
|
||
**무엇인가** — k3s 기본 CNI(컨테이너 네트워크 인터페이스) 백엔드.
|
||
노드가 다르면 파드 간 트래픽을 UDP 8472로 캡슐화해서 전달한다.
|
||
|
||
**왜 여기 나오나** — Keycloak 파드 두 개가 서로 다른 노드에 있으면
|
||
JGroups 통신이 이 VXLAN 터널을 탄다. 노드 간 방화벽 실험을 할 때
|
||
"무엇을 막을 것인가"가 여기에 달려 있다.
|
||
|
||
### NetworkPolicy와 k3s의 내장 컨트롤러
|
||
|
||
**무엇인가** — 파드 간 트래픽을 L3/L4에서 제어하는 쿠버네티스 리소스.
|
||
flannel 자체는 정책을 강제하지 않으므로 별도 컨트롤러가 필요하다.
|
||
k3s는 kube-router의 netpol 패키지를 **k3s 서버 프로세스 안에 내장**해서
|
||
기본 활성화한다(`--disable-network-policy`로 끌 수 있음).
|
||
|
||
**정정** — 이전 답변에서 `kubectl -n kube-system get pods | grep kube-router`
|
||
로 확인하라고 했는데 **틀렸다.** 내장 구현이라 별도 파드로 뜨지 않는다.
|
||
올바른 확인은 아래와 같다.
|
||
|
||
```bash
|
||
# 1) 비활성화 플래그가 걸려 있지 않은지
|
||
sudo grep -i 'disable-network-policy' /etc/systemd/system/k3s.service
|
||
|
||
# 2) 실제로 강제되는지 — 테스트 정책을 적용해보는 것이 확실하다
|
||
kubectl create ns netpol-test
|
||
kubectl -n netpol-test apply -f - <<'EOF'
|
||
apiVersion: networking.k8s.io/v1
|
||
kind: NetworkPolicy
|
||
metadata:
|
||
name: deny-all
|
||
spec:
|
||
podSelector: {}
|
||
policyTypes: [Ingress]
|
||
EOF
|
||
# 이 네임스페이스의 파드로 들어가는 트래픽이 막히면 컨트롤러가 동작 중
|
||
```
|
||
|
||
**왜 여기 나오나** — JGroups 7800 포트만 골라서 막는 실험을 nftables가
|
||
아니라 NetworkPolicy로 하면, **운영에서 쓸 방식 그대로** 검증하게 된다.
|
||
|
||
### 매니페스트 읽는 법 — `deploy/lab/k8s/echo.yaml`을 예로
|
||
|
||
이 실험대에서 쓰는 쿠버네티스 설정을 항목별로 정리한다.
|
||
파일 하나에 네 리소스가 `---`로 이어져 있다.
|
||
|
||
```
|
||
Namespace header-lab 격리 경계
|
||
Deployment echo 파드를 몇 개, 어떤 모습으로 유지할지
|
||
Service echo 파드 집합에 고정된 이름과 주소를 부여
|
||
Ingress echo 외부 호스트명·경로를 Service 로 연결
|
||
```
|
||
|
||
`---`는 YAML의 **문서 구분자**다. 한 파일에 독립된 문서 여러 개를 담을 수
|
||
있고, `kubectl apply -f`는 그것들을 순서대로 적용한다.
|
||
|
||
#### Namespace
|
||
|
||
**무엇인가** — 리소스 이름의 유효 범위. 다른 네임스페이스에 같은 이름의
|
||
Deployment가 있어도 충돌하지 않는다. RBAC·ResourceQuota·NetworkPolicy의
|
||
적용 단위이기도 하다.
|
||
|
||
**왜 여기 나오나** — 실험마다 네임스페이스를 나누면 **정리가 한 줄로 끝난다.**
|
||
`kubectl delete ns header-lab` 하나로 그 실험의 모든 흔적이 사라진다.
|
||
반복 실험이 본체인 이 실험대에서 중요한 성질이다.
|
||
|
||
**격리가 아니다** — 네임스페이스는 **이름의 범위**일 뿐 자원을 격리하지 않는다.
|
||
ResourceQuota를 따로 걸지 않으면 한 네임스페이스가 노드 메모리를 다 먹을 수 있다.
|
||
|
||
```bash
|
||
kubectl get ns
|
||
kubectl -n header-lab get all
|
||
```
|
||
|
||
#### Deployment · ReplicaSet · Pod
|
||
|
||
**계층 구조** — 세 개가 자동으로 얹혀 만들어진다.
|
||
|
||
```
|
||
Deployment "echo 를 2개 유지하고, 바뀌면 무중단으로 교체하라"
|
||
│ 생성
|
||
ReplicaSet "이 템플릿의 파드를 정확히 2개 유지하라" (버전마다 하나씩)
|
||
│ 생성
|
||
Pod 실제로 도는 컨테이너 묶음
|
||
```
|
||
|
||
**Deployment를 직접 쓰는 이유** — 파드를 직접 만들면 죽었을 때 아무도
|
||
되살리지 않는다. **노드를 죽이는 실험을 하는데 파드가 안 살아나면 실험이
|
||
안 된다.** ReplicaSet은 Deployment가 알아서 만들므로 손댈 일이 없다.
|
||
|
||
**롤아웃** — 이미지나 env를 바꾸면 Deployment가 **새 ReplicaSet을 만들고**
|
||
파드를 점진 교체한다. 이전 ReplicaSet은 0개로 줄어든 채 남아 롤백 경로가 된다.
|
||
|
||
```bash
|
||
kubectl -n header-lab get deploy,rs,pods
|
||
kubectl -n header-lab rollout status deployment/echo
|
||
kubectl -n header-lab rollout undo deployment/echo # 직전 버전으로
|
||
```
|
||
|
||
#### 라벨과 셀렉터 — 쿠버네티스의 근본 관용구
|
||
|
||
```yaml
|
||
spec:
|
||
selector:
|
||
matchLabels:
|
||
app: echo # ← 이 라벨을 가진 파드를 내 것으로 삼는다
|
||
template:
|
||
metadata:
|
||
labels:
|
||
app: echo # ← 만들어질 파드에 붙는 라벨
|
||
```
|
||
|
||
**쿠버네티스는 리소스를 이름이 아니라 라벨로 연결한다.** Deployment도,
|
||
Service도, NetworkPolicy도 전부 "이 라벨을 가진 파드"를 가리킨다.
|
||
느슨한 결합이라 파드가 몇 개든, 어느 노드든 상관없이 성립한다.
|
||
|
||
**틀리면** — `selector`와 `template.labels`가 어긋나면 Deployment가 자기가
|
||
만든 파드를 자기 것으로 인식하지 못하고 **무한히 새 파드를 만든다.**
|
||
Service의 `selector`가 어긋나면 엔드포인트가 비어 502가 난다.
|
||
|
||
```bash
|
||
kubectl -n header-lab get pods --show-labels
|
||
kubectl -n header-lab get endpoints echo # 비어 있으면 셀렉터 불일치
|
||
```
|
||
|
||
마지막 명령이 "Service는 있는데 502" 상황의 첫 확인 지점이다.
|
||
|
||
#### `replicas: 2`와 `topologySpreadConstraints`
|
||
|
||
```yaml
|
||
topologySpreadConstraints:
|
||
- maxSkew: 1
|
||
topologyKey: kubernetes.io/hostname
|
||
whenUnsatisfiable: ScheduleAnyway
|
||
labelSelector:
|
||
matchLabels:
|
||
app: echo
|
||
```
|
||
|
||
| 항목 | 의미 |
|
||
|---|---|
|
||
| `topologyKey` | 무엇을 기준으로 나눌지. `kubernetes.io/hostname`이면 **노드 단위** |
|
||
| `maxSkew: 1` | 그룹 간 개수 차이를 최대 1로 유지 → 2노드에 2개면 1:1 |
|
||
| `whenUnsatisfiable` | 만족 못 할 때 **`ScheduleAnyway`**(그래도 배치) / `DoNotSchedule`(대기) |
|
||
|
||
**왜 필요한가** — 두 파드가 한 노드에 몰리면 **호스트 nginx의 upstream 분배를
|
||
관찰할 수 없다.** 어느 노드로 보내든 같은 파드가 답하기 때문이다.
|
||
스티키 세션 실험도 성립하지 않는다.
|
||
|
||
**`ScheduleAnyway`를 고른 이유** — 한 노드를 죽이는 실험을 할 때
|
||
`DoNotSchedule`이면 남은 파드가 **배치되지 못하고 Pending에 머문다.**
|
||
장애 실험에서는 "그래도 뜨는" 쪽이 맞다.
|
||
|
||
```bash
|
||
kubectl -n header-lab get pods -o wide # NODE 열이 갈려야 한다
|
||
```
|
||
|
||
**파드 IP로도 노드를 알 수 있다.** flannel이 노드마다 `/24`를 하나씩 준다.
|
||
|
||
```
|
||
10.42.0.x → kc-lab-1
|
||
10.42.1.x → kc-lab-2
|
||
```
|
||
|
||
`/api/echo`가 돌려주는 `localAddr`이 이 파드 IP이므로, **응답만 보고 어느
|
||
노드가 처리했는지 알 수 있다.**
|
||
|
||
#### 프로브 — readiness와 liveness는 하는 일이 다르다
|
||
|
||
가장 자주 혼동되는 항목이다.
|
||
|
||
| | readinessProbe | livenessProbe |
|
||
|---|---|---|
|
||
| 질문 | "지금 **트래픽을 받을 수 있나**" | "이 프로세스가 **살아 있나**" |
|
||
| 실패하면 | Service 엔드포인트에서 **제외**. 파드는 계속 돈다 | 컨테이너를 **죽이고 재시작** |
|
||
| 용도 | 기동 중, 일시적 과부하, 의존성 끊김 | 데드락, 응답 불능 |
|
||
|
||
**둘을 같게 설정하면 위험하다.** 일시적으로 느려졌을 뿐인데 liveness가
|
||
재시작을 걸면, 부하가 몰린 상황에서 **재시작 폭풍**이 일어난다.
|
||
그래서 liveness의 `initialDelaySeconds`와 주기를 readiness보다 넉넉히 준다
|
||
(여기서는 45초 / 15초 대 15초 / 5초).
|
||
|
||
Spring Boot는 `management.endpoint.health.probes.enabled: true`일 때
|
||
`/actuator/health/readiness`와 `/actuator/health/liveness`를 따로 노출한다.
|
||
[`backend/src/main/resources/application.yml`](../backend/src/main/resources/application.yml)에
|
||
이미 켜져 있다.
|
||
|
||
```bash
|
||
kubectl -n header-lab describe pod <파드명> | grep -A3 -E 'Readiness|Liveness'
|
||
```
|
||
|
||
#### `resources` — requests와 limits의 역할이 다르다
|
||
|
||
```yaml
|
||
resources:
|
||
requests: { memory: 320Mi, cpu: 100m }
|
||
limits: { memory: 512Mi }
|
||
```
|
||
|
||
| | requests | limits |
|
||
|---|---|---|
|
||
| 쓰이는 곳 | **스케줄러**가 배치할 노드를 고를 때 | **커널**이 실행 중 강제할 때 |
|
||
| 메모리 초과 | — | **OOMKilled** (컨테이너 강제 종료) |
|
||
| CPU 초과 | — | 스로틀링 (죽지는 않음) |
|
||
|
||
**`cpu: 100m`의 `m`은 milli-core다.** `1000m` = 1코어. `100m`은 0.1코어.
|
||
|
||
**limits를 안 주면** 한 파드가 노드 메모리를 다 먹고 **다른 파드까지
|
||
말려든다.** RAM 3584M / 2560M짜리 게스트에서 이건 현실적인 위험이다.
|
||
|
||
**CPU limit을 일부러 안 걸었다** — CPU 스로틀링은 지연을 만드는데,
|
||
이 실험대는 **타이밍(refresh token 경쟁, 세션 복제 지연)을 측정**하므로
|
||
인위적 스로틀링이 결과를 오염시킨다.
|
||
|
||
```bash
|
||
kubectl -n header-lab top pods # 실제 사용량
|
||
kubectl -n header-lab describe pod <파드명> | grep -i -A2 'Last State' # OOMKilled 확인
|
||
```
|
||
|
||
#### `JAVA_TOOL_OPTIONS: -XX:MaxRAMPercentage=70`
|
||
|
||
**문제** — JVM은 기본적으로 **호스트 전체 메모리**를 보고 힙 크기를 정한다.
|
||
컨테이너 메모리 limit이 512Mi인데 게스트 RAM이 3584M이면, JVM이 그것을
|
||
기준으로 힙을 잡았다가 **limit을 넘겨 OOMKilled**된다.
|
||
|
||
**해결** — 최신 JVM은 cgroup limit을 인식하지만, 비율을 명시하는 편이 확실하다.
|
||
`MaxRAMPercentage=70`이면 512Mi의 70%인 약 358Mi를 힙 상한으로 삼고, 나머지를
|
||
메타스페이스·스레드 스택·네이티브 메모리에 남긴다.
|
||
|
||
**`-Xmx`가 아니라 백분율을 쓰는 이유** — limit을 바꿀 때마다 `-Xmx`를 같이
|
||
고쳐야 하는 이중 관리를 피한다.
|
||
|
||
```bash
|
||
kubectl -n header-lab exec deploy/echo -- java -XX:+PrintFlagsFinal -version 2>/dev/null | grep -i maxheapsize
|
||
```
|
||
|
||
#### 포트에 이름 붙이기
|
||
|
||
```yaml
|
||
ports:
|
||
- containerPort: 8081
|
||
name: http # ← 이름
|
||
...
|
||
readinessProbe:
|
||
httpGet:
|
||
port: http # ← 숫자 대신 이름으로 참조
|
||
...
|
||
# Service
|
||
targetPort: http
|
||
```
|
||
|
||
**왜** — 포트 번호를 한 곳에서만 관리하기 위해서다. 8081을 바꿔야 할 때
|
||
`containerPort` 한 줄만 고치면 프로브와 Service가 따라온다. 숫자를 여기저기
|
||
적어두면 한 군데를 빠뜨려 조용히 깨진다.
|
||
|
||
#### Service
|
||
|
||
```yaml
|
||
spec:
|
||
selector: { app: echo }
|
||
ports:
|
||
- port: 8081 # Service 가 여는 포트
|
||
targetPort: http # 파드 쪽 포트(이름)
|
||
```
|
||
|
||
**무엇인가** — 파드 집합에 **고정된 이름과 가상 IP(ClusterIP)** 를 준다.
|
||
파드는 죽고 다시 뜨며 IP가 매번 바뀌지만, Service 이름은 바뀌지 않는다.
|
||
클러스터 안에서는 `echo.header-lab.svc.cluster.local`로 접근한다.
|
||
|
||
**타입을 안 적으면 `ClusterIP`가 기본이다** — 클러스터 내부에서만 접근 가능.
|
||
외부 노출은 Ingress가 담당하므로 이게 맞다.
|
||
|
||
**부하 분산 방식** — kube-proxy가 iptables/IPVS 규칙으로 **무작위 분배**한다.
|
||
**세션 어피니티는 기본적으로 없다.** 필요하면
|
||
`spec.sessionAffinity: ClientIP`를 주지만, 프록시 뒤에서는 모든 요청의
|
||
출발지가 Traefik이라 사실상 무의미하다. **스티키는 호스트 nginx 층에서
|
||
거는 것이 맞다.**
|
||
|
||
```bash
|
||
kubectl -n header-lab get svc
|
||
kubectl -n header-lab get endpoints echo # 파드 IP 목록이 채워져야 정상
|
||
```
|
||
|
||
#### Ingress
|
||
|
||
```yaml
|
||
spec:
|
||
ingressClassName: traefik
|
||
rules:
|
||
- host: app1.hyeonworks.com
|
||
http:
|
||
paths:
|
||
- path: /api
|
||
pathType: Prefix
|
||
backend:
|
||
service: { name: echo, port: { number: 8081 } }
|
||
```
|
||
|
||
| 항목 | 의미 |
|
||
|---|---|
|
||
| `ingressClassName` | **어느 컨트롤러가 이 규칙을 처리할지.** k3s 기본은 `traefik` |
|
||
| `host` | HTTP `Host` 헤더가 이 값일 때만 매칭 |
|
||
| `path` + `pathType` | 경로 매칭 |
|
||
| `backend` | 어느 Service의 어느 포트로 보낼지 |
|
||
|
||
**`pathType` 세 가지**
|
||
|
||
| 값 | 매칭 |
|
||
|---|---|
|
||
| `Prefix` | 경로 세그먼트 단위 접두사. `/api`는 `/api`, `/api/echo`에 매칭되고 `/apifoo`에는 안 된다 |
|
||
| `Exact` | 완전 일치만 |
|
||
| `ImplementationSpecific` | 컨트롤러 재량. 이식성이 없으므로 피한다 |
|
||
|
||
**`ingressClassName`을 빼면** 기본 IngressClass가 지정돼 있지 않은 한
|
||
**어느 컨트롤러도 이 규칙을 집지 않는다.** 리소스는 생성되는데 트래픽이
|
||
흐르지 않고 오류도 없다.
|
||
|
||
**`host`가 중요한 이유** — 호스트 nginx가 `proxy_set_header Host $host`로
|
||
원래 호스트명을 그대로 넘기기 때문에, Traefik이 그 값으로 이 규칙을 찾는다.
|
||
nginx가 Host를 자기 것으로 덮어쓰면 **여기서 404가 난다.**
|
||
지금 보이는 404가 정상 신호인 것도 같은 원리다 — 규칙이 없으면 404다.
|
||
|
||
```bash
|
||
kubectl -n header-lab get ingress
|
||
kubectl -n header-lab describe ingress echo
|
||
kubectl -n kube-system logs -l app.kubernetes.io/name=traefik --tail=30
|
||
```
|
||
|
||
### 무엇을 어디에 설치하는가
|
||
|
||
| 도구 | lab host | 게스트 | 워크스테이션 |
|
||
|---|---|---|---|
|
||
| libvirt / QEMU | 필요 | — | — |
|
||
| nginx | 필요 (L7 진입점) | — | — |
|
||
| certbot | 필요 | — | — |
|
||
| kubectl / helm / k9s | 필요 | 불필요 | — |
|
||
| **k3s** | — | **필요** | — |
|
||
| **docker** | **설치 금지** | **설치 금지** | 필요 (이미지 빌드) |
|
||
| java / maven | 불필요 | 불필요 | 불필요 |
|
||
|
||
**kubectl을 게스트에 안 깔아도 되는 이유** — k3s 바이너리가 kubectl을
|
||
내장한다. 게스트에서는 `sudo k3s kubectl ...`로 쓰고, 평소 조작은 lab host의
|
||
kubectl로 한다.
|
||
|
||
**java/maven이 아무 데도 필요 없는 이유** — Keycloak도 애플리케이션도
|
||
컨테이너로 돈다. 이미지 안에 JRE가 들어 있고, 빌드는 Dockerfile의 Maven
|
||
스테이지가 컨테이너 안에서 수행한다.
|
||
|
||
### Docker를 lab host에 설치하면 안 되는 이유
|
||
|
||
**결론부터: 이미지 저장소가 둘로 갈려서 `docker build`한 이미지를 k3s가
|
||
보지 못하게 된다.**
|
||
|
||
**컨테이너 런타임의 층 구조**
|
||
|
||
```
|
||
dockerd 사용자 편의 계층 — 빌드, 볼륨, 네트워크, CLI
|
||
│
|
||
containerd 컨테이너 수명주기 데몬 — 이미지를 자기 저장소에 보관
|
||
│
|
||
runc 프로세스를 실제로 격리해 실행하는 저수준 도구
|
||
```
|
||
|
||
**k3s는 자체 containerd를 번들한다.** Docker와 무관하게 이미 완결된 스택이다.
|
||
|
||
| | k3s | Docker |
|
||
|---|---|---|
|
||
| 소켓 | `/run/k3s/containerd/containerd.sock` | `/run/containerd/containerd.sock` |
|
||
| 이미지 저장 | `/var/lib/rancher/k3s/agent/containerd/` | `/var/lib/docker/` |
|
||
|
||
Docker를 설치하면 **containerd 인스턴스가 두 개**가 된다. 그리고 둘은 서로의
|
||
이미지를 알지 못한다.
|
||
|
||
```
|
||
docker build ─▶ dockerd ─▶ /var/lib/docker/ ← k3s 는 여기를 안 본다
|
||
파드 생성 ─▶ k3s containerd ─▶ /var/lib/rancher/... ← 이미지 없음
|
||
```
|
||
|
||
증상은 **`docker images`에는 보이는데 파드는 `ErrImageNeverPull`** 이다.
|
||
쿠버네티스 입문에서 가장 흔한 혼란이며, 원인이 눈에 보이지 않아 오래 헤맨다.
|
||
|
||
**저장소 분리 말고도 충돌 지점이 있다**
|
||
|
||
| 자원 | 충돌 내용 |
|
||
|---|---|
|
||
| cgroup 드라이버 | dockerd 기본은 `cgroupfs`, k3s는 `systemd`. 한 노드에서 두 관리자가 cgroup 트리를 다툰다 |
|
||
| iptables/nftables | Docker가 `DOCKER`, `DOCKER-USER` 체인과 MASQUERADE 규칙을 심는다. flannel 규칙과 순서가 엉키면 파드 트래픽이 Docker 규칙에 걸린다 |
|
||
| 브리지 대역 | `docker0`가 `172.17.0.0/16`을 점유한다. 클러스터 서비스 대역이나 사내망과 겹치면 라우팅이 깨진다 |
|
||
| 디스크 | 같은 이미지가 두 벌 저장된다 |
|
||
|
||
**이 실험대에는 이유가 하나 더 있다.** lab host에는 libvirt가
|
||
`virbr0` NAT와 자체 방화벽 규칙을 운영 중이다. Docker의 iptables 규칙이
|
||
여기에 얹히면 게스트 네트워크가 예측 불가능해진다. **네트워크 장애를
|
||
의도적으로 주입하는 실험대에서 원인 불명의 네트워크 변수를 늘리는 것은
|
||
치명적이다** — 실험 결과인지 환경 문제인지 구분할 수 없게 된다.
|
||
|
||
> `k3s server --docker`로 Docker를 런타임으로 지정하는 방법이 과거에
|
||
> 있었지만, 쿠버네티스 1.24의 dockershim 제거 이후 별도 `cri-dockerd`를
|
||
> 요구하며 권장되지 않는다. 얻는 것이 없다.
|
||
|
||
### 그러면 이미지는 어떻게 넣는가
|
||
|
||
| 방법 | 적합한 경우 |
|
||
|---|---|
|
||
| 공개 레지스트리에서 pull | **Keycloak·PostgreSQL·Redis 등 공식 이미지** — 아무 준비도 필요 없다 |
|
||
| **`ctr images import`** | **자체 빌드 이미지가 소수일 때** ← 이 실험대 |
|
||
| 클러스터 내 레지스트리 | 빌드·배포 반복이 잦아질 때 |
|
||
|
||
자체 이미지는 애플리케이션(BFF, token-mediator, echo)뿐이므로 두 번째로 충분하다.
|
||
|
||
```
|
||
워크스테이션 (docker 보유) lab host (경유만) 게스트 (k3s containerd)
|
||
docker build
|
||
docker save ──── ssh ────▶ ──── ssh ────▶ sudo k3s ctr images import -
|
||
```
|
||
|
||
```bash
|
||
docker build -t keycloak-pattern-api:lab backend
|
||
docker save keycloak-pattern-api:lab \
|
||
| ssh test-server "ssh kc-lab-1 'sudo k3s ctr images import -'"
|
||
docker save keycloak-pattern-api:lab \
|
||
| ssh test-server "ssh kc-lab-2 'sudo k3s ctr images import -'"
|
||
```
|
||
|
||
**주의 세 가지**
|
||
|
||
1. **노드마다 따로 반입한다.** 스케줄러가 어느 노드에 배치할지 모른다.
|
||
한쪽에만 있으면 반대편에 배치될 때 실패한다.
|
||
2. **매니페스트에 `imagePullPolicy: Never`를 준다.** 없으면 로컬에 이미지가
|
||
있어도 레지스트리에서 당기려 시도하다 실패한다.
|
||
3. **`ctr`이 아니라 `k3s ctr`을 쓴다.** `k3s ctr`은 k3s의 containerd 소켓을
|
||
가리키는 래퍼다. 시스템에 별도 `ctr`이 있으면 다른 소켓을 보게 되어
|
||
"성공했는데 파드는 이미지를 못 찾는" 상태가 된다.
|
||
|
||
**ssh가 두 번 중첩되는 이유** — 게스트가 lab host의 libvirt NAT 뒤에 있어서
|
||
워크스테이션에서 직접 접속할 수 없다. lab host의 `~/.ssh/config`에 있는
|
||
`kc-lab-*` 별칭을 거쳐야 한다.
|
||
|
||
**확인**
|
||
|
||
```bash
|
||
ssh test-server "ssh kc-lab-1 'sudo k3s ctr images ls -q | grep keycloak-pattern'"
|
||
kubectl -n header-lab get pods -o wide # ErrImageNeverPull 이면 반입 실패
|
||
```
|
||
|
||
---
|
||
|
||
## 6층. Arch 특이사항
|
||
|
||
여기 있는 것만이 진짜 "Arch라서" 하는 일이다.
|
||
|
||
### nginx 설정 구조 — `sites-available`은 nginx 기능이 아니다
|
||
|
||
**중요한 사실부터.** `sites-available` / `sites-enabled`는 **nginx의 기능이
|
||
아니라 Debian/Ubuntu 패키지 메인테이너가 만든 관례**다. nginx가 아는 것은
|
||
`include` 지시어 하나뿐이고, 나머지는 패키지가 미리 깔아둔 디렉터리 구조다.
|
||
|
||
**두 가지 관례가 있다**
|
||
|
||
| | `sites-available` + `sites-enabled` | `conf.d` |
|
||
|---|---|---|
|
||
| 출처 | Debian / Ubuntu 패키지 | nginx 업스트림, RHEL 계열 |
|
||
| include 줄 | `include /etc/nginx/sites-enabled/*;` | `include /etc/nginx/conf.d/*.conf;` |
|
||
| 켜기 | `sites-enabled`에 **심볼릭 링크** 생성 | `.conf` 확장자로 파일 배치 |
|
||
| 끄기 | 링크만 삭제 (원본은 보존) | 확장자 변경 (`.conf.disabled`) |
|
||
|
||
`sites-available` 방식의 목적은 **파일을 지우지 않고 껐다 켜는 것**이다.
|
||
원본은 `sites-available`에 그대로 두고 링크만 조작한다.
|
||
|
||
**Arch는 둘 다 만들어주지 않는다.** `/etc/nginx/nginx.conf` 한 파일이
|
||
전부이고 include 줄도 없다. 그래서 어느 쪽을 쓸지 **직접 정해서 만들어야
|
||
한다.** 처음 Arch에서 nginx를 다룰 때 "경로가 없다"고 당황하는 이유다.
|
||
|
||
```bash
|
||
grep -n 'include.*\(conf.d\|sites-enabled\)' /etc/nginx/nginx.conf
|
||
ls -d /etc/nginx/sites-available /etc/nginx/conf.d 2>&1
|
||
```
|
||
|
||
**이 실험대는 `sites-available` 방식을 쓴다.** 운영(`desktop`)이 Ubuntu라
|
||
그 구조를 쓰고 있으므로, 설정을 운영으로 옮길 때 경로가 그대로 맞는 편이
|
||
낫다는 판단이다. nginx 동작에는 차이가 없다.
|
||
|
||
```bash
|
||
sudo mkdir -p /etc/nginx/sites-available /etc/nginx/sites-enabled
|
||
sudo sed -i 's|^http {|http {\n include /etc/nginx/sites-enabled/*;|' /etc/nginx/nginx.conf
|
||
```
|
||
|
||
**틀리면** — Debian 감각으로 `sites-enabled`에 파일을 넣었는데 include 줄이
|
||
없으면 **아무 일도 일어나지 않는다. 오류조차 나지 않는다.**
|
||
`nginx -T`(대문자)로 최종 병합된 설정을 출력해 내 파일이 실제로 들어갔는지
|
||
확인하는 것이 확실하다.
|
||
|
||
```bash
|
||
sudo nginx -T | grep -n 'server_name\|upstream'
|
||
```
|
||
|
||
**Arch 기본 nginx.conf에는 자체 `server` 블록이 있다** (38~80줄 부근,
|
||
`listen 80; server_name localhost;`). 지우지 않아도 된다. 내 블록에
|
||
`listen 80 default_server;`를 주면 명시적 지정이 암묵적 기본값을 이긴다.
|
||
(`default_server`를 **두 블록에** 주면 그때는 오류가 난다.)
|
||
|
||
### 롤링 릴리스와 부분 업그레이드 금지
|
||
|
||
Arch는 고정 릴리스가 없고 패키지가 계속 갱신된다. 그리고 **부분 업그레이드를
|
||
지원하지 않는다.** `pacman -Sy 패키지`처럼 DB만 갱신하고 일부만 설치하면
|
||
공유 라이브러리 버전이 어긋나 시스템이 깨질 수 있다.
|
||
|
||
| 명령 | 의미 | 안전한가 |
|
||
|---|---|---|
|
||
| `pacman -Syu` | DB 갱신 + 전체 업그레이드 | **안전** |
|
||
| `pacman -S 패키지` | 현재 DB 기준 설치 | 대체로 안전 |
|
||
| `pacman -Sy 패키지` | DB만 갱신 후 일부 설치 | **위험 — 쓰지 말 것** |
|
||
|
||
**실험 운영 규칙** — 실험 시작 전에 `pacman -Syu` + 재부팅을 끝내두고,
|
||
**실험 기간에는 업그레이드하지 않는다.** 커널이 올라가면 재부팅이 필요하고,
|
||
재부팅하면 VM이 전부 내려가서 실험이 중단된다.
|
||
|
||
### 패키지명 대응표
|
||
|
||
| 역할 | Arch | Debian/Ubuntu |
|
||
|---|---|---|
|
||
| QEMU 전체 | `qemu-full` | `qemu-system-x86` |
|
||
| VM 생성 CLI | `virt-install` | `virtinst` |
|
||
| UEFI 펌웨어 | `edk2-ovmf` | `ovmf` |
|
||
| certbot DNS 플러그인 | `certbot-dns-cloudflare` | `python3-certbot-dns-cloudflare` |
|
||
|
||
### 없어서 오히려 편한 것
|
||
|
||
Arch에는 SELinux도 AppArmor도 기본 활성화되어 있지 않다.
|
||
RHEL 계열에서 k3s를 설치할 때 필요한 SELinux 정책 패키지
|
||
(`k3s-selinux`)와 컨텍스트 문제가 여기선 아예 없다.
|
||
|
||
### 게스트 배포판: Debian이란 무엇이고 Ubuntu와 무엇이 다른가
|
||
|
||
**Debian은 리눅스 배포판이다.** 1993년에 시작된 가장 오래되고 영향력 큰
|
||
배포판 중 하나이며, **Ubuntu·Linux Mint·Raspberry Pi OS·Proxmox·Kali가
|
||
전부 Debian에서 파생**됐다. Ubuntu는 2004년 Debian unstable을 기반으로
|
||
시작했고 지금도 Debian에서 패키지를 가져와 다듬는다.
|
||
|
||
그래서 서버 운영 관점에서 둘은 **매우 비슷하다.** `apt`/`dpkg` 패키지 도구,
|
||
`/etc/apt/sources.list`, systemd, 디렉터리 구조가 전부 같다.
|
||
Debian을 다뤄본 적이 없어도 Ubuntu 경험이 그대로 통한다.
|
||
|
||
**호스트가 Arch인 것과는 무관하다.** 호스트와 게스트는 커널도 파일시스템도
|
||
완전히 분리되어 있어 배포판을 맞출 이유가 없다. Debian을 고른 이유는 셋이다.
|
||
|
||
1. 공식 클라우드 이미지가 잘 관리되고 체크섬이 공개되어 있다
|
||
2. `genericcloud` 변종이 333M로 가볍다
|
||
3. cloud-init 지원이 표준적이다
|
||
|
||
참고로 **Arch는 공식 클라우드 이미지가 없다.** 게스트를 호스트에 맞추고
|
||
싶어도 선택지가 아니었다.
|
||
|
||
**운영 관점 비교**
|
||
|
||
| 축 | Debian | Ubuntu Server |
|
||
|---|---|---|
|
||
| 릴리스 주기 | 약 2년, 준비되면 릴리스 | 6개월, LTS는 2년마다(4월) |
|
||
| 지원 기간 | 정규 3년 + LTS 2년 ≈ 5년 | LTS 5년 + 유료 ESM 최대 12년 |
|
||
| 패키지 신선도 | 보수적, 버전이 오래됨 | 상대적으로 최신 |
|
||
| 커널 | 보수적 | 최신 + HWE 커널 선택 가능 |
|
||
| 상용 지원 | 없음 (커뮤니티) | Canonical 유료 지원 |
|
||
| snap | 없음 | 기본 탑재, 일부 패키지는 snap 전용 |
|
||
| 무인 보안 업데이트 | 기본 비활성 | `unattended-upgrades` **기본 활성** |
|
||
| AppArmor | 설치되나 기본 비활성 | **기본 활성** |
|
||
| 방화벽 도구 | nftables 직접 | `ufw` 제공 |
|
||
| 클라우드 기본 계정 | `debian` | `ubuntu` |
|
||
|
||
**이 실험대에서 실제로 체감될 세 가지**
|
||
|
||
1. **`unattended-upgrades`** — Ubuntu는 보안 업데이트를 자동 설치한다.
|
||
장애 실험 도중 패키지가 바뀌면 **재현성이 깨진다.** Ubuntu를 쓴다면
|
||
실험 기간에는 꺼두는 것이 맞다.
|
||
```bash
|
||
sudo systemctl disable --now unattended-upgrades
|
||
```
|
||
2. **AppArmor** — Ubuntu는 기본 활성이다. 컨테이너 런타임이나 Keycloak의
|
||
파일 접근이 원인 모르게 거부될 때 의심 대상이 하나 늘어난다.
|
||
Debian에서는 이 변수가 없다.
|
||
3. **snap** — Ubuntu의 snap 패키지는 자동 갱신된다. 이것도 재현성의 적이다.
|
||
|
||
**k3s 관점에서는 둘 다 공식 지원**이며 설치 스크립트도 동일하다.
|
||
따라서 **선택 기준은 "운영 환경과 같은 것"뿐이다.** 기술적 우열이 아니라
|
||
게스트와 운영의 커널·systemd·기본 설정 차이에서 오는 잡음을 없애는 것이
|
||
VM을 쓰는 이유 중 하나였기 때문이다.
|
||
|
||
**Ubuntu로 교체하는 방법** (k3s 설치 전이라면 10분이면 된다)
|
||
|
||
```bash
|
||
sudo curl -L -o /var/lib/libvirt/images/base-ubuntu.qcow2 \
|
||
https://cloud-images.ubuntu.com/releases/24.04/release/ubuntu-24.04-server-cloudimg-amd64.img
|
||
```
|
||
|
||
확장자가 `.img`지만 **내용은 qcow2**다. Ubuntu의 관례이며
|
||
`qemu-img info`로 확인하면 `file format: qcow2`가 나온다.
|
||
VM 재생성 시 `backing_store` 경로와 `--os-variant ubuntu24.04`만 바꾸면 되고,
|
||
**cloud-init YAML과 시드 ISO는 그대로 재사용**할 수 있다.
|
||
|
||
---
|
||
|
||
## 7층. git
|
||
|
||
### `.gitignore` 패턴 앵커링
|
||
|
||
**무엇인가** — 패턴에 슬래시가 어디 있느냐로 적용 범위가 달라진다.
|
||
|
||
| 패턴 | 매칭 범위 |
|
||
|---|---|
|
||
| `target/` | **모든 깊이**의 `target` 디렉터리 |
|
||
| `/target/` | 저장소 **루트**의 `target`만 |
|
||
| `backend/target/` | 루트 기준 그 경로 하나만 |
|
||
| `**/target/` | `target/`과 사실상 동일 (중복) |
|
||
|
||
**규칙** — 패턴 중간에 슬래시가 있으면 git은 그것을 **루트 기준 경로**로
|
||
간주하고 앵커링한다. 슬래시가 끝에만 있으면(디렉터리 표시) 앵커링하지
|
||
않고 모든 깊이에 적용한다.
|
||
|
||
**왜 여기 나오나** — 기존 `.gitignore`에 `backend/target/`이 있었는데,
|
||
나중에 생긴 `bff/target`과 `token-mediator/target`이 빠졌다.
|
||
`target/`으로 바꾸면 한 줄로 전부 커버된다.
|
||
|
||
### 이미 추적 중인 파일은 무시되지 않는다
|
||
|
||
**무엇인가** — `.gitignore`는 **추적되지 않는 파일**에만 적용된다.
|
||
이미 인덱스에 들어간 파일은 패턴을 추가해도 계속 추적된다.
|
||
|
||
**해결** — `git rm -r --cached <경로>`로 인덱스에서만 제거한다
|
||
(작업 디렉터리 파일은 남는다).
|
||
|
||
**확인**
|
||
|
||
```bash
|
||
git ls-files | grep '/target/' # 0줄이면 rm --cached 불필요
|
||
git check-ignore -v bff/target # 어느 규칙이 무시시키는지 출력
|
||
git status --short # ?? 목록에서 사라졌는지
|
||
```
|
||
|
||
---
|
||
|
||
## 8층. 패키지 저장소와 설치 원리
|
||
|
||
`pacman -S qemu-full`이나 cloud-init의 `packages: [curl, nftables]`가
|
||
실제로 무슨 일을 하는지. 배포판이 달라도 **원리는 동일하다.**
|
||
|
||
### 저장소(repository)란 무엇인가
|
||
|
||
거창해 보이지만 실체는 단순하다. **HTTP 서버에 올려둔 파일 트리와,
|
||
그 안에 무엇이 있는지 적어둔 목록 파일(인덱스)**이다.
|
||
|
||
```
|
||
https://deb.debian.org/debian/
|
||
├── dists/bookworm/ ← 인덱스 영역
|
||
│ ├── InRelease 전체 목록의 요약 + GPG 서명
|
||
│ └── main/binary-amd64/
|
||
│ └── Packages.gz 패키지 이름·버전·의존성·해시·경로
|
||
└── pool/main/c/curl/ ← 실제 파일 영역
|
||
└── curl_7.88.1-10_amd64.deb
|
||
```
|
||
|
||
핵심은 **인덱스와 실제 파일이 분리**되어 있다는 점이다. 클라이언트는
|
||
인덱스만 먼저 받아서 계산하고, 필요한 파일만 골라 내려받는다.
|
||
|
||
### 설치는 다섯 단계로 진행된다
|
||
|
||
배포판과 무관하게 순서가 같다.
|
||
|
||
```
|
||
1. 인덱스 갱신 저장소의 목록 파일을 받아 로컬에 저장
|
||
2. 의존성 해결 "curl 을 깔려면 libcurl4, libssl3 … 이 필요"를 계산
|
||
3. 다운로드 필요한 패키지 파일들을 내려받음
|
||
4. 검증 GPG 서명과 해시를 확인
|
||
5. 설치 압축을 풀어 파일시스템에 배치, 설치 후 스크립트 실행
|
||
```
|
||
|
||
**2번이 패키지 관리자의 존재 이유다.** 의존성은 사슬로 이어지고 충돌하기도
|
||
해서, 사람이 손으로 풀기 어렵다. 이 계산을 대신해주는 것이 `apt`와 `pacman`이다.
|
||
|
||
### apt (Debian / Ubuntu)
|
||
|
||
**저장소 목록**
|
||
|
||
```
|
||
/etc/apt/sources.list
|
||
/etc/apt/sources.list.d/*.list ← 추가 저장소는 여기에 파일로
|
||
```
|
||
|
||
**`apt update` 가 하는 일** — 인덱스만 받는다. 패키지는 받지 않는다.
|
||
|
||
```
|
||
dists/bookworm/InRelease → 서명된 요약. 각 인덱스의 체크섬 포함
|
||
dists/bookworm/main/binary-amd64/Packages.gz
|
||
↓ 저장 위치
|
||
/var/lib/apt/lists/
|
||
```
|
||
|
||
**`apt install curl` 이 하는 일**
|
||
|
||
```
|
||
/var/lib/apt/lists/ 의 인덱스로 의존성 계산
|
||
↓
|
||
pool/ 에서 .deb 파일들 다운로드 → /var/cache/apt/archives/
|
||
↓
|
||
해시 검증
|
||
↓
|
||
dpkg 가 실제 설치
|
||
```
|
||
|
||
**`apt` 와 `dpkg` 의 역할 분담** — 자주 헷갈리는 지점이다.
|
||
|
||
| 도구 | 담당 |
|
||
|---|---|
|
||
| `apt` | 저장소 접근, 의존성 해결, 다운로드 |
|
||
| `dpkg` | 받아온 `.deb` 하나를 실제로 푸는 저수준 도구 |
|
||
|
||
그래서 `dpkg -i foo.deb`는 의존성을 해결하지 못하고 실패할 수 있다.
|
||
|
||
**`.deb` 파일의 정체** — `ar` 아카이브다. 마법이 없다.
|
||
|
||
```bash
|
||
ar t curl_7.88.1-10_amd64.deb
|
||
# debian-binary 포맷 버전
|
||
# control.tar.xz 메타데이터 + 설치 전/후 스크립트
|
||
# data.tar.xz 실제 파일들 (/usr/bin/curl 등)
|
||
```
|
||
|
||
### pacman (Arch)
|
||
|
||
**저장소 목록**
|
||
|
||
```
|
||
/etc/pacman.conf [core] [extra] 섹션
|
||
/etc/pacman.d/mirrorlist 실제 서버 주소 목록
|
||
```
|
||
|
||
**`pacman -Sy`** — 인덱스(`core.db`, `extra.db`)를 받아
|
||
`/var/lib/pacman/sync/`에 저장한다. `.db` 파일은 패키지 메타데이터를 모은
|
||
tar 아카이브다.
|
||
|
||
**`pacman -S qemu-full`** — 의존성을 계산하고
|
||
`.pkg.tar.zst` 파일과 별도 서명 파일 `.sig`를 내려받아 검증 후 설치한다.
|
||
설치된 패키지 정보는 `/var/lib/pacman/local/`에 기록된다.
|
||
|
||
**부분 업그레이드가 금지된 진짜 이유** — 6층에서 언급한 규칙의 근거가 여기 있다.
|
||
|
||
```
|
||
현재 설치: libfoo 1.0 (glibc 2.38 기준으로 빌드됨)
|
||
pacman -Sy → 인덱스만 최신으로 갱신
|
||
pacman -S bar → bar 최신판을 받음 (glibc 2.39 기준으로 빌드됨)
|
||
↓
|
||
bar 실행 시 symbol not found → 깨진다
|
||
```
|
||
|
||
Arch는 롤링 릴리스라 **패키지들이 서로 같은 시점의 라이브러리 버전을
|
||
전제하고 빌드**된다. 일부만 최신으로 올리면 이 전제가 깨진다.
|
||
Debian은 릴리스마다 버전을 고정하므로 이 문제가 없다.
|
||
|
||
### 왜 HTTP로 받아도 안전한가 — 서명 신뢰 사슬
|
||
|
||
저장소 주소가 `https`가 아니어도 안전하다. 신뢰가 **전송 경로가 아니라
|
||
서명**에 걸려 있기 때문이다.
|
||
|
||
```
|
||
배포판 공개키 (OS 이미지에 미리 들어 있음)
|
||
│ 이 키로 검증
|
||
▼
|
||
InRelease / *.db.sig (인덱스에 대한 서명)
|
||
│ 인덱스 안에 각 패키지의 해시가 적혀 있음
|
||
▼
|
||
개별 패키지 파일 (해시가 일치해야 설치)
|
||
```
|
||
|
||
키의 위치:
|
||
|
||
| 배포판 | 신뢰 키 저장 위치 |
|
||
|---|---|
|
||
| Debian/Ubuntu | `/etc/apt/trusted.gpg.d/`, `/usr/share/keyrings/` |
|
||
| Arch | `/etc/pacman.d/gnupg/` (`pacman-key`로 관리) |
|
||
|
||
**그래서 미러가 성립한다.** 전 세계 수백 개 서버가 같은 내용을 복제해
|
||
배포할 수 있고, 어느 미러에서 받든 서명이 맞으면 정품이다. 미러 운영자가
|
||
파일을 바꿔치기해도 서명 검증에서 걸린다.
|
||
|
||
```bash
|
||
# Debian 계열: 신뢰하는 키 목록
|
||
apt-key list 2>/dev/null || ls /etc/apt/trusted.gpg.d/
|
||
# Arch: 키링 상태
|
||
pacman-key --list-keys | head
|
||
```
|
||
|
||
### 세 배포판 대조표
|
||
|
||
| | Debian/Ubuntu | Arch |
|
||
|---|---|---|
|
||
| 인덱스 갱신 | `apt update` | `pacman -Sy` |
|
||
| 설치 | `apt install <pkg>` | `pacman -S <pkg>` |
|
||
| 전체 업그레이드 | `apt upgrade` / `full-upgrade` | `pacman -Syu` |
|
||
| 삭제 | `apt remove` / `purge` | `pacman -R` / `-Rns` |
|
||
| 설치된 것 검색 | `dpkg -l` | `pacman -Q` |
|
||
| 파일이 속한 패키지 | `dpkg -S <경로>` | `pacman -Qo <경로>` |
|
||
| 패키지 형식 | `.deb` (ar 아카이브) | `.pkg.tar.zst` |
|
||
| 인덱스 위치 | `/var/lib/apt/lists/` | `/var/lib/pacman/sync/` |
|
||
| 저수준 도구 | `dpkg` | `pacman` 자체 |
|
||
|
||
### 이 실험대에서 어디에 나타나는가
|
||
|
||
- 호스트(Arch): `pacman -S qemu-full libvirt nginx certbot …`
|
||
- 게스트(Debian): cloud-init의 `packages: [curl, nftables]` → 내부적으로 `apt`
|
||
- 게스트: `package_update: true` → 부팅 시 `apt update` 수행
|
||
- k3s 설치: `curl … | sh` — **저장소를 거치지 않고 바이너리를 직접 받는다.**
|
||
그래서 패키지 관리자가 추적하지 못하고, 제거는 전용 스크립트
|
||
(`/usr/local/bin/k3s-uninstall.sh`)로 해야 한다.
|
||
|
||
마지막 항목이 중요하다. 패키지 관리자를 우회하는 설치는 **서명 검증도,
|
||
의존성 추적도, 일괄 업그레이드도 없다.** k3s처럼 자체 업그레이드 경로를
|
||
제공하는 소프트웨어에서만 받아들일 만한 방식이다.
|
||
|
||
---
|
||
|
||
## 9층. `deploy/` — 무엇이 살아 있고 무엇이 참조인가
|
||
|
||
저장소에 있으나 지금 적용되지 않는 설정이 여럿이다. 죽은 코드가 아니라
|
||
**의도적으로 남겨둔 참조 자산**이며, 그 구분을 여기 기록한다.
|
||
|
||
### 전체 지도
|
||
|
||
| 경로 | 상태 | 대상 배포 형태 | 검증 |
|
||
|---|---|---|---|
|
||
| `lab/host/nginx-keycloak-lab.conf` | **적용 중** | 2노드 k3s 실험대 | `lab/scripts/verify-lab.sh` |
|
||
| `lab/cloud-init/kc-lab.yaml.example` | **적용 중**(템플릿) | 실험대 게스트 | 게스트 부팅 |
|
||
| `lab/k8s/echo.yaml` | **적용 중** | 실험대 | `kubectl apply` |
|
||
| `reverse-proxy/nginx-keycloak.conf` | 참조 | 단일 호스트 Compose | `scripts/verify-reverse-proxy-headers.sh` |
|
||
| `reverse-proxy/keycloak.env.example` | 참조 | 위와 한 쌍 | 동일 |
|
||
| `tls/nginx.conf` | 참조 | 단일 호스트, 운영자가 인증서 관리 | `scripts/verify-https-termination-config.sh` |
|
||
| `tls/Caddyfile` | 참조 | 단일 호스트, ACME 자동화 | 동일 |
|
||
| `tunnel/cloudflared-config.yml` | **미채택** | 공개 도메인 터널 | `scripts/verify-public-tunnel-config.sh` |
|
||
|
||
**세 가지 상태**
|
||
|
||
- **적용 중** — 지금 실험대에서 실제로 도는 설정
|
||
- **참조** — 다른 배포 형태의 예제. 실행되지는 않지만 **문법·계약 검증은 받는다**
|
||
- **미채택** — 조건이 맞지 않아 고르지 않은 경로. 근거를 남기려고 보존한다
|
||
|
||
### 왜 적용하지 않는 것을 남겨두는가
|
||
|
||
**1. 이 저장소의 목적이 비교다.** 네 인증 패턴을 같은 인프라에서 비교하는
|
||
학습 프로젝트이므로, **배포 형태도 선택지를 나란히 두고 트레이드오프를
|
||
기록하는 것 자체가 산출물**이다. 하나만 남기면 "왜 이걸 골랐는가"의 근거가
|
||
사라지고, 조건이 바뀌었을 때 재검토할 자료가 없어진다.
|
||
|
||
**2. 죽은 코드가 아니라 테스트되는 코드다.** 각 파일에 대응하는
|
||
`scripts/verify-*.sh`가 붙어 있다.
|
||
|
||
```
|
||
scripts/verify-reverse-proxy-headers.sh → deploy/reverse-proxy/ 두 파일의 계약 짝
|
||
scripts/verify-https-termination-config.sh → deploy/tls/ 두 파일을 실제 이미지로 validate
|
||
scripts/verify-public-tunnel-config.sh → deploy/tunnel/ ingress 구조
|
||
```
|
||
|
||
특히 두 번째는 임시 자체서명 인증서를 만들어 **nginx와 Caddy 두 벤더 이미지에서
|
||
각각 설정을 검증하고** 임시 파일을 지운다. 실행되지 않을 뿐 **깨지면 드러난다.**
|
||
|
||
**3. 배포 형태가 바뀌면 되살아난다.** 지금은 2노드 k3s지만 단일 호스트로
|
||
옮기면 `reverse-proxy/`가 곧바로 쓰인다. 그래서 **실험대 전용 설정은
|
||
`lab/` 아래로 분리**해 일반 배포 설정과 섞이지 않게 두었다.
|
||
|
||
### `reverse-proxy/` — 1홉 계약의 원본
|
||
|
||
**`keycloak.env.example`** — Keycloak 쪽이 지켜야 할 네 줄이다.
|
||
|
||
| 설정 | 의미 | 없거나 틀리면 |
|
||
|---|---|---|
|
||
| `KC_HTTP_ENABLED=true` | 프록시가 TLS를 끊었으므로 Keycloak은 평문 HTTP를 받는다 | 기동 거부 |
|
||
| `KC_PROXY_HEADERS=xforwarded` | **`X-Forwarded-*`를 신뢰하겠다는 명시적 옵트인** | 헤더를 통째로 무시한다 |
|
||
| `KC_HOSTNAME=https://auth.example.test` | 외부에서 보이는 주소를 고정 | 내부 주소가 `iss`에 박힌다 |
|
||
| `KC_HOSTNAME_STRICT=true` | Host 헤더를 믿지 않고 위 값만 쓴다 | Host 조작으로 흐름을 돌릴 여지 |
|
||
|
||
**두 번째 줄이 이 실험대의 핵심 개념과 직결된다.** `/api/echo`에서 확인한
|
||
Spring의 `forward-headers-strategy`와 **정확히 같은 성격의 스위치**다.
|
||
프레임워크는 기본적으로 forwarded 헤더를 믿지 않으며, 신뢰는 명시적으로
|
||
켜야 한다. 켜지 않으면 프록시가 아무리 올바른 헤더를 넣어도 무시된다.
|
||
|
||
**`nginx-keycloak.conf`** — 프록시 쪽 짝이다. **이것이 1홉을 가정한 원본**이며,
|
||
[`docs/reverse-proxy-headers.md`](reverse-proxy-headers.md)가 문서화한 계약이다.
|
||
|
||
실험대의 `lab/host/nginx-keycloak-lab.conf`와 세 곳이 다르다.
|
||
|
||
| | `reverse-proxy/` (원본) | `lab/host/` (실험대) |
|
||
|---|---|---|
|
||
| upstream | `keycloak:8080` 단일 | 노드 2개 (`.11`, `.12`) |
|
||
| TLS | 없음 (앞단이 따로 종료) | 여기서 종료 (Let's Encrypt) |
|
||
| `X-Forwarded-For` | `$proxy_add_x_forwarded_for` (덧붙이기) | `$remote_addr` (**덮어쓰기**) |
|
||
|
||
세 번째 줄이 신뢰 경계의 차이다. 덧붙이면 클라이언트가 위조한 값이
|
||
사슬 앞부분에 남고, 덮어쓰면 사라진다. **이 차이를 실측으로 확정하는 것이
|
||
첫 실험의 목적이다.**
|
||
|
||
### `tls/` — 같은 일을 하는 두 구현
|
||
|
||
`nginx.conf`와 `Caddyfile`은 **동일한 결과**를 만든다. 공개 443에서 TLS를
|
||
종료하고 사설 네트워크의 `keycloak:8080`으로 평문 전달한다.
|
||
|
||
```
|
||
nginx Caddy
|
||
ssl_certificate …crt tls /etc/tls/tls.crt /etc/tls/tls.key
|
||
ssl_certificate_key …key
|
||
proxy_set_header Host $host header_up Host {host}
|
||
proxy_set_header X-Forwarded-* header_up X-Forwarded-*
|
||
```
|
||
|
||
**차이는 인증서 수명주기를 누가 관리하는가 하나뿐이다.**
|
||
|
||
| | nginx | Caddy |
|
||
|---|---|---|
|
||
| 발급·갱신 | **운영자**가 담당 (certbot 등) | **프록시가 ACME로 자동** |
|
||
| 설정 분량 | 많다 | 적다 |
|
||
| 통제력 | 세밀 | 자동화에 위임 |
|
||
|
||
**이 실험대는 nginx + certbot을 골랐다.** DNS-01 와일드카드가 필요했고,
|
||
인증서 발급 시점과 방식을 직접 통제해야 했기 때문이다.
|
||
|
||
**둘을 동시에 진입점으로 띄우지 않는다.** 같은 443을 두 프로세스가 잡을 수
|
||
없다. 예제가 둘인 것은 선택지를 보여주기 위해서다.
|
||
|
||
### `tunnel/` — 채택하지 않은 이유를 남긴 자산
|
||
|
||
`cloudflared-config.yml`은 Cloudflare named tunnel 설정이다.
|
||
**아웃바운드 연결만 쓰므로 포트포워딩 없이 공개 HTTPS 이름을 얻는다.**
|
||
공유기를 건드릴 수 없는 환경에서 매력적인 선택지다.
|
||
|
||
```yaml
|
||
ingress:
|
||
- hostname: auth.example.test
|
||
service: http://reverse-proxy:8080 # ← 127.0.0.1 이 아니다
|
||
- service: http_status:404 # ← catch-all
|
||
```
|
||
|
||
- `service:`에 `127.0.0.1`을 쓰면 **cloudflared 컨테이너 자신**을 가리킨다.
|
||
Compose 서비스 DNS 이름을 써야 한다
|
||
- 마지막 catch-all은 알 수 없는 hostname을 404로 끝낸다. 없으면 오류가 난다
|
||
|
||
**그런데 이 실험대는 채택하지 않았다.** 이유가 실험의 성격과 맞물린다.
|
||
|
||
```
|
||
터널 사용 : 브라우저 → Cloudflare 엣지 → nginx → Traefik → Pod (3홉)
|
||
현재 구성 : 브라우저 → nginx → Traefik → Pod (2홉)
|
||
```
|
||
|
||
**Cloudflare 엣지가 TLS를 끊고 다시 맺으면서 홉이 하나 늘고**,
|
||
`CF-Connecting-IP` 같은 자체 헤더가 섞인다. 이 실험대가 측정하려는 것이
|
||
정확히 **`nginx → Traefik` 2홉의 forwarded 헤더 계약**이므로,
|
||
앞에 한 겹이 더 붙으면 **측정이 오염된다.**
|
||
|
||
그래서 tailnet 직결을 택했다. 조건이 바뀌어(예: 다른 회선으로 이전) 공개
|
||
접근이 필요해지면 이 파일이 그대로 쓰인다.
|
||
|
||
### `.example` 접미사 관례
|
||
|
||
`keycloak.env.example`, `kc-lab.yaml.example`처럼 **비밀이 들어갈 자리가 있는
|
||
파일은 `.example`로 커밋하고 실파일은 무시한다.** 저장소가 `.env.example`에
|
||
쓰는 것과 같은 규칙이다.
|
||
|
||
```
|
||
.env.example → .env (gitignore)
|
||
deploy/lab/cloud-init/kc-lab.yaml.example → kc-lab-1.yaml, kc-lab-2.yaml (gitignore)
|
||
```
|
||
|
||
`kc-lab.yaml.example`이 감추는 것은 `plain_text_passwd`(콘솔 비상용 비밀번호)와
|
||
SSH 공개키 두 줄이다. 공개키 자체는 비밀이 아니지만, **저장소가 공개이므로
|
||
호스트 신원 정보를 불필요하게 노출하지 않는다.**
|
||
|
||
---
|
||
|
||
## 10층. 쿠버네티스 리소스 — 이 실험대에서 실제로 쓴 것들
|
||
|
||
5층이 k3s 자체라면 여기는 그 위에 올린 리소스들이다.
|
||
|
||
### 워크로드 세 종류 — 무엇을 언제 쓰는가
|
||
|
||
| | 보장하는 것 | 이 실험대에서 |
|
||
|---|---|---|
|
||
| **Deployment** | 파드 N개를 유지. 이름은 매번 바뀐다 | postgres, grafana, prometheus, echo |
|
||
| **StatefulSet** | **안정된 이름**(`-0`, `-1`)과 순서 | **keycloak** |
|
||
| **DaemonSet** | **노드마다 정확히 하나** | node-exporter, svclb |
|
||
|
||
**StatefulSet을 Keycloak에 쓴 이유** — Infinispan이 **파드 이름 + 랜덤 접미사**를
|
||
클러스터 노드 식별자로 쓴다(`keycloak-0-49501`). Deployment면 이름이
|
||
`keycloak-7d9f8b-x4k2p`처럼 매번 달라져서, 로그와 `JGROUPS_PING` 테이블을
|
||
대조하기가 어려워진다.
|
||
|
||
**`podManagementPolicy`**
|
||
|
||
| 값 | 동작 |
|
||
|---|---|
|
||
| `OrderedReady` (기본) | `-0`이 Ready가 된 뒤에야 `-1`을 만든다 |
|
||
| **`Parallel`** | **동시에 시작한다** |
|
||
|
||
이 실험대는 `Parallel`을 쓴다. 두 파드가 **동시에 클러스터 등록을 시도하는 것**이
|
||
운영에서 실제로 일어나는 상황이기 때문이다.
|
||
|
||
**DaemonSet을 node-exporter에 쓴 이유** — replica 수를 지정하지 않는다.
|
||
노드가 늘면 자동으로 늘고, 줄면 준다. **죽을 노드에도 반드시 있어야**
|
||
꺼지기 직전의 마지막 샘플이 남는다.
|
||
|
||
```bash
|
||
kubectl get deploy,sts,ds -A
|
||
```
|
||
|
||
### 저장소 — PVC · PV · StorageClass
|
||
|
||
```
|
||
PersistentVolumeClaim (PVC) "5Gi 짜리 읽기쓰기 볼륨을 주세요" ← 요청
|
||
│ storageClassName: local-path
|
||
▼
|
||
StorageClass 어떻게 만들지 아는 프로비저너
|
||
│
|
||
▼
|
||
PersistentVolume (PV) 실제로 만들어진 볼륨 ← 결과
|
||
```
|
||
|
||
**PVC는 요청서, PV는 실물이다.** 파드는 PVC 이름만 알면 되고, 그 뒤가
|
||
로컬 디스크인지 NFS인지 클라우드 블록 스토리지인지 몰라도 된다.
|
||
|
||
**`accessModes`**
|
||
|
||
| 값 | 의미 |
|
||
|---|---|
|
||
| **`ReadWriteOnce` (RWO)** | **한 노드에서만** 읽기/쓰기 |
|
||
| `ReadOnlyMany` | 여러 노드에서 읽기만 |
|
||
| `ReadWriteMany` | 여러 노드에서 읽기/쓰기 (NFS 등) |
|
||
|
||
**RWO가 `strategy: Recreate`를 강제한다.** 기본값 `RollingUpdate`는 새 파드를
|
||
띄운 뒤 옛 파드를 내리는데, RWO 볼륨은 **두 파드가 동시에 마운트할 수 없어서**
|
||
새 파드가 영원히 Pending에 머문다.
|
||
|
||
```yaml
|
||
strategy:
|
||
type: Recreate # 옛 파드를 먼저 내리고 새 파드를 띄운다
|
||
```
|
||
|
||
**k3s의 `local-path` 프로비저너 — 볼륨이 노드에 못박힌다**
|
||
|
||
```json
|
||
"nodeAffinity": {
|
||
"required": { "nodeSelectorTerms": [{
|
||
"matchExpressions": [{ "key": "kubernetes.io/hostname", "values": ["kc-lab-2"] }]
|
||
}]}
|
||
}
|
||
경로: /var/lib/rancher/k3s/storage/pvc-<uuid>_<ns>_<name>
|
||
```
|
||
|
||
**그 노드의 로컬 디스크에 디렉터리를 만드는 것이 전부**다. 따라서
|
||
**PVC를 쓰는 파드는 그 노드를 벗어날 수 없다.**
|
||
|
||
| 결과 | |
|
||
|---|---|
|
||
| 노드가 죽으면 | **파드가 다른 노드로 재배치되지 못한다** |
|
||
| 실험 관점 | **결함이 아니라 조건이다.** "DB가 있는 노드가 죽으면"이 의미를 갖는다 |
|
||
|
||
```bash
|
||
kubectl get pvc -A
|
||
kubectl get pv
|
||
kubectl get pv <name> -o jsonpath='{.spec.nodeAffinity}' | python3 -m json.tool
|
||
```
|
||
|
||
### Secret — 감춰지지 않는다
|
||
|
||
```yaml
|
||
kind: Secret
|
||
type: Opaque
|
||
stringData:
|
||
POSTGRES_PASSWORD: lab-postgres-change-me
|
||
```
|
||
|
||
`stringData`는 평문으로 쓰고 쿠버네티스가 base64로 인코딩해 저장한다.
|
||
`data`는 직접 base64로 넣는다.
|
||
|
||
**base64는 암호화가 아니라 인코딩이다.**
|
||
|
||
```bash
|
||
kubectl -n keycloak-lab get secret keycloak-lab-secrets -o jsonpath='{.data.POSTGRES_PASSWORD}' | base64 -d
|
||
```
|
||
|
||
한 줄로 읽힌다. etcd에도 그대로 들어 있다.
|
||
|
||
| 그래도 Secret을 쓰는 이유 | |
|
||
|---|---|
|
||
| RBAC로 접근을 나눌 수 있다 | ConfigMap과 별도로 권한 관리 |
|
||
| 로그·`describe`에 값이 안 찍힌다 | 사고로 노출될 확률이 준다 |
|
||
| 볼륨·env 주입 방식이 표준화된다 | |
|
||
|
||
**진짜 보호는 별도 계층이다** — SealedSecret, 외부 KMS, 또는 클라우드
|
||
시크릿 매니저. 로드맵 11번의 주제다.
|
||
|
||
### RBAC — ServiceAccount · ClusterRole · Binding
|
||
|
||
Prometheus가 쿠버네티스 API에 물어서 타깃을 찾으려면 **읽기 권한**이 필요하다.
|
||
|
||
```
|
||
ServiceAccount 파드가 쓰는 신원 (누구인가)
|
||
│
|
||
ClusterRoleBinding 신원과 권한을 잇는다
|
||
│
|
||
ClusterRole 무엇을 할 수 있는가 (리소스 × 동사)
|
||
```
|
||
|
||
```yaml
|
||
rules:
|
||
- apiGroups: [""]
|
||
resources: [nodes, nodes/metrics, nodes/proxy, services, endpoints, pods]
|
||
verbs: [get, list, watch]
|
||
```
|
||
|
||
**`Role`과 `ClusterRole`의 차이** — `Role`은 한 네임스페이스 안에서만,
|
||
`ClusterRole`은 클러스터 전체에서 유효하다. 노드는 네임스페이스에 속하지
|
||
않으므로 **노드를 읽으려면 반드시 `ClusterRole`**이다.
|
||
|
||
**서브리소스가 따로 있다 — 실제로 걸린 함정**
|
||
|
||
`nodes`, `nodes/metrics`, `nodes/proxy`는 **서로 다른 권한**이다.
|
||
|
||
```
|
||
/api/v1/nodes/<name>/proxy/metrics
|
||
─────
|
||
이 경로에는 nodes/proxy 가 필요
|
||
```
|
||
|
||
`nodes/proxy`를 빠뜨렸을 때 kubelet 타깃만 **403 Forbidden**으로 실패하고
|
||
나머지 잡은 전부 정상이었다. **부분 실패라 `rollout status`는 성공이라고
|
||
말한다.** 타깃 목록을 직접 봐야 드러난다.
|
||
|
||
```bash
|
||
kubectl auth can-i get nodes/proxy --as=system:serviceaccount:observability:prometheus
|
||
kubectl describe clusterrole prometheus
|
||
```
|
||
|
||
### 배치 제어 — nodeSelector · 라벨 · taint
|
||
|
||
```yaml
|
||
nodeSelector:
|
||
node-role.kubernetes.io/control-plane: "true"
|
||
```
|
||
|
||
**호스트 이름 대신 역할 라벨을 쓴다.** `kubernetes.io/hostname: kc-lab-1`로
|
||
못박으면 노드 이름이 바뀔 때 깨지고, **왜 거기 두는지가 드러나지 않는다.**
|
||
|
||
k3s는 server 노드에 `node-role.kubernetes.io/control-plane=true`를 붙인다.
|
||
|
||
```bash
|
||
kubectl get nodes --show-labels
|
||
kubectl get nodes -l node-role.kubernetes.io/control-plane=true
|
||
```
|
||
|
||
**taint와 toleration**
|
||
|
||
| | |
|
||
|---|---|
|
||
| **taint** | 노드에 붙는 "여기 오지 마" 표시 |
|
||
| **toleration** | 파드가 갖는 "그래도 갈 수 있음" 면제권 |
|
||
|
||
```yaml
|
||
tolerations:
|
||
- operator: Exists # 어떤 taint 든 무시한다
|
||
```
|
||
|
||
node-exporter에 이걸 주는 이유는 **관측이 빠지는 노드가 있으면 안 되기**
|
||
때문이다. taint가 걸린 노드에서도 떠야 한다.
|
||
|
||
**배치를 정하는 세 수단의 차이**
|
||
|
||
| 수단 | 성격 |
|
||
|---|---|
|
||
| `nodeSelector` | **반드시** 그 라벨의 노드에 |
|
||
| `topologySpreadConstraints` | **골고루** 퍼뜨린다 |
|
||
| taint / toleration | 노드가 **거부**하고 파드가 **면제**받는다 |
|
||
|
||
### k3s server와 agent — 죽였을 때가 다르다
|
||
|
||
```bash
|
||
kubectl get nodes -o custom-columns=\
|
||
'NODE:.metadata.name,CP:.metadata.labels.node-role\.kubernetes\.io/control-plane'
|
||
```
|
||
|
||
| | kc-lab-1 (**server**) | kc-lab-2 (**agent**) |
|
||
|---|---|---|
|
||
| 실행 | API 서버 · 스케줄러 · etcd(SQLite) | kubelet · containerd |
|
||
| 이 실험대에서 | keycloak-1 · traefik · **coredns** · metrics-server · local-path-provisioner | keycloak-0 · postgres |
|
||
| 죽이면 | **`kubectl`이 안 된다. DNS·인그레스도 사라진다** | 클러스터 제어는 살아 있다 |
|
||
|
||
**노드 상실 실험은 agent를 죽이는 것이다.** server를 죽이는 것은 노드 상실이
|
||
아니라 **컨트롤 플레인 상실**이며 성격이 완전히 다르다.
|
||
|
||
이 사실을 모르고 "keycloak 하나만 있는 노드를 죽이자"고 계획했다가
|
||
실제 배치를 조회한 뒤 정정했다.
|
||
|
||
---
|
||
|
||
## 11층. Keycloak 클러스터링 내부 — Infinispan과 JGroups
|
||
|
||
### 두 층으로 되어 있다
|
||
|
||
```
|
||
Infinispan 분산 캐시. "세션을 어디에 두고 어떻게 복제할까"
|
||
│
|
||
JGroups 그룹 통신. "누가 멤버이고 어떻게 메시지를 주고받을까"
|
||
│
|
||
TCP 7800 실제 소켓
|
||
```
|
||
|
||
Keycloak은 Infinispan을 쓰고, Infinispan은 JGroups 위에서 돈다.
|
||
로그의 `org.infinispan.CLUSTER`와 `vendor_jgroups_*` 지표가 각각 이 두 층이다.
|
||
|
||
### 디스커버리와 트랜스포트는 다른 경로다
|
||
|
||
**이것이 이 실험대를 2노드로 만든 이유다.**
|
||
|
||
| 단계 | 경로 | 끊기면 |
|
||
|---|---|---|
|
||
| **디스커버리** — 서로를 찾는다 | PostgreSQL `JGROUPS_PING` 테이블 | 상대의 존재를 모른다 |
|
||
| **트랜스포트** — 실제로 대화한다 | **TCP 7800** | **DB엔 등록되는데 클러스터가 안 붙는다** |
|
||
|
||
`JGROUPS_PING` 한 테이블에 두 메커니즘이 다 보인다.
|
||
|
||
```
|
||
name | cluster_name | ip | coord
|
||
------------------+--------------+-----------------+-------
|
||
keycloak-0-49501 | ISPN | 10.42.1.18:7800 | f
|
||
keycloak-1-26938 | ISPN | 10.42.0.16:7800 | t
|
||
───────────────────────────── ──── ─
|
||
디스커버리 결과 트랜스포트 경로 코디네이터
|
||
```
|
||
|
||
전체 스키마는 `address / name / cluster_name / ip / coord / last_update /
|
||
coordinated_by`이고 기본키는 `address`다.
|
||
|
||
> 오래된 자료에는 `own_addr`, `ping_data` 같은 컬럼명이 나오지만 Keycloak 26의
|
||
> 실제 스키마는 위와 같다. 쿼리 전에 `\d jgroups_ping`으로 확인한다.
|
||
|
||
**`jdbc-ping`을 쓰는 이유** — 예전에는 UDP 멀티캐스트로 서로를 찾았다.
|
||
쿠버네티스나 클라우드에서는 멀티캐스트가 막혀 있는 경우가 많아,
|
||
**이미 있는 데이터베이스를 게시판처럼 쓰는** 방식으로 바뀌었다.
|
||
Keycloak 26의 기본값이다.
|
||
|
||
### 코디네이터
|
||
|
||
`coord = t` 인 노드가 **코디네이터**다. 뷰 변경을 확정하고 리밸런싱을
|
||
주도한다. 특별한 권한이 아니라 **역할**이며, 그 노드가 사라지면 남은 멤버가
|
||
인계받는다.
|
||
|
||
실험대를 전원 종료했다 켰을 때 코디네이터가 `keycloak-1` → `keycloak-0`으로
|
||
바뀌는 것을 관찰했다. **먼저 뜬 쪽이 맡는다.**
|
||
|
||
### 클러스터 뷰
|
||
|
||
```
|
||
ISPN000094: Received new cluster view for channel ISPN:
|
||
[keycloak-1-26938(v=16.0.12)|1] (2) [keycloak-1-26938, keycloak-0-49501]
|
||
─────────────────────────── ─ ─ ────────────────────────────────────
|
||
뷰를 만든 코디네이터 뷰 ID 멤버 수 멤버 목록
|
||
```
|
||
|
||
**뷰(view)는 "지금 이 순간의 멤버 명단"** 이다. 멤버가 들어오거나 나가면
|
||
새 뷰가 발행되고 뷰 ID가 올라간다.
|
||
|
||
| 로그 코드 | 의미 |
|
||
|---|---|
|
||
| `ISPN000094` | 새 클러스터 뷰를 받았다 |
|
||
| `ISPN000079` | 자기 주소와 물리 주소(7800) |
|
||
| `ISPN100000` | 노드가 합류했다 |
|
||
|
||
```bash
|
||
kubectl -n keycloak-lab logs keycloak-0 | grep -E 'ISPN000094|ISPN000079|ISPN100000'
|
||
```
|
||
|
||
### 주요 JGroups 프로토콜 — 지표 이름에 그대로 나온다
|
||
|
||
| 프로토콜 | 하는 일 | 관련 지표 |
|
||
|---|---|---|
|
||
| **GMS** (Group Membership Service) | 멤버십 관리, 뷰 발행 | `vendor_jgroups_gms_*` |
|
||
| **FD_SOCK2** (Failure Detection) | **TCP 소켓으로 상대 생존 감시** | `..._get_num_suspected_members` |
|
||
| **MERGE3** | **split brain 후 다시 합치기** | `..._merge3_get_views` |
|
||
| **NAKACK2** | 신뢰성 있는 메시지 전달, 재전송 | `..._nakack2_*` |
|
||
| **TCP** | 트랜스포트 | `..._tcp_*` |
|
||
|
||
**7800을 막으면 FD_SOCK2가 먼저 반응한다.** 소켓 연결이 끊기면 상대를
|
||
suspect 하고, GMS가 그 멤버를 뷰에서 제외한다. 각자 자기만 있는 뷰가 되면
|
||
**split brain**이고, 통신이 복구되면 MERGE3가 합친다.
|
||
|
||
### 세션은 어디에 있는가 — 두 곳이되 역할이 다르다
|
||
|
||
Keycloak 26의 기본값 `persistent-user-sessions`에서는
|
||
|
||
| 저장소 | 역할 | 노드 간 공유 |
|
||
|---|---|---|
|
||
| **PostgreSQL** | **진실의 원천.** 재시작에도 살아남는다 | **여기서만 일어난다** |
|
||
| **Infinispan `sessions`** | **자기 노드가 로그인시킨 세션만** 담는 룩어사이드 캐시 | **일어나지 않는다** |
|
||
|
||
> **처음에 이 표에 "Infinispan = 캐시 + 노드 간 실시간 전파"라고 썼는데
|
||
> 틀렸다.** 실험 0에서 측정해보니 세션 엔트리는 노드 사이를 건너가지 않는다.
|
||
> 두 노드가 같은 답을 하는 이유는 복제가 아니라 같은 DB를 보기 때문이고,
|
||
> 반대편 노드가 실제로 날리는 `SELECT ... FROM OFFLINE_USER_SESSION` 을
|
||
> PostgreSQL 로그에서 직접 잡았다.
|
||
> → [`docs/experiment-00-session-replication.md`](experiment-00-session-replication.md)
|
||
|
||
`--features-disabled=persistent-user-sessions`로 끄면 Infinispan만 남는
|
||
**volatile** 모드가 되고, 그때는 캐시가 곧 진실의 원천이므로 **복제가
|
||
반드시 일어나야 한다.** 이 둘의 차이가 로드맵 2번의 주제다.
|
||
|
||
### 세션 쓰기 트랜잭션의 세 가지 설계 결정
|
||
|
||
PostgreSQL 문장 로깅으로 잡은 갱신 트랜잭션 하나에 다 들어 있다.
|
||
|
||
| 보이는 것 | 뜻 |
|
||
|---|---|
|
||
| `update ... where ... and VERSION=$5` | **낙관적 락.** 읽을 때의 버전과 같을 때만 쓴다 |
|
||
| `for no key update ... skip locked` | 잠긴 행을 **기다리지 않고 건너뛴다.** 대기 대신 재시도 |
|
||
| **`SET LOCAL synchronous_commit TO OFF`** | **WAL 플러시를 기다리지 않고 커밋한다** |
|
||
|
||
마지막 것이 특히 중요하다 — **DB가 강제 종료되면 직전 수백 밀리초의 세션
|
||
갱신이 사라질 수 있다.** 버그가 아니라 의도된 트레이드오프다.
|
||
`LAST_SESSION_REFRESH` 갱신은 매우 잦고, 잃어도 사용자가 다시 갱신하면 된다.
|
||
|
||
---
|
||
|
||
## 12층. 관측성 — Prometheus의 구조
|
||
|
||
### 세 부분으로 되어 있다
|
||
|
||
```
|
||
수집(scrape) ──▶ 저장(TSDB) ──▶ 질의(PromQL)
|
||
15초마다 로컬 디스크 Grafana 또는 API
|
||
HTTP GET /metrics 시계열
|
||
```
|
||
|
||
**Prometheus는 pull 방식이다.** 대상이 보내주는 것이 아니라 Prometheus가
|
||
주기적으로 `/metrics`를 긁어간다.
|
||
|
||
| 결과 | |
|
||
|---|---|
|
||
| 대상이 죽으면 | 긁기가 실패하고 **`up`이 0이 된다** — 죽은 사실 자체가 데이터가 된다 |
|
||
| 방화벽 방향 | Prometheus → 대상. 대상이 Prometheus 주소를 알 필요가 없다 |
|
||
| 짧은 작업 | 긁히기 전에 끝나면 잡히지 않는다 (Pushgateway가 필요한 경우) |
|
||
|
||
### exporter 패턴
|
||
|
||
애플리케이션이 Prometheus 형식을 모를 때, **번역기**를 옆에 둔다.
|
||
|
||
| exporter | 무엇을 노출하는가 |
|
||
|---|---|
|
||
| **node-exporter** | 머신 — CPU, 메모리, 디스크, 네트워크 |
|
||
| kube-state-metrics | 쿠버네티스 오브젝트 상태 |
|
||
| postgres-exporter | PostgreSQL 내부 통계 |
|
||
|
||
**Keycloak과 Traefik은 exporter가 필요 없다.** 자체적으로 Prometheus 형식
|
||
엔드포인트를 제공한다(`KC_METRICS_ENABLED=true`).
|
||
|
||
### 서비스 디스커버리 — 타깃을 적어두지 않는다
|
||
|
||
```yaml
|
||
kubernetes_sd_configs:
|
||
- role: endpoints
|
||
namespaces: { names: [keycloak-lab] }
|
||
```
|
||
|
||
**파드 IP는 재시작마다 바뀐다.** 실험대를 전원 종료했다 켜니 모든 파드가
|
||
새 주소를 받았다(`10.42.1.22` → `10.42.1.25`). 정적 목록은 그때마다 깨진다.
|
||
|
||
`role`에 따라 무엇을 찾을지가 달라진다.
|
||
|
||
| role | 찾는 것 |
|
||
|---|---|
|
||
| `endpoints` | 서비스 뒤의 실제 파드들 ← 애플리케이션 지표 |
|
||
| `node` | 노드 |
|
||
| `pod` | 파드 직접 |
|
||
| `service` | 서비스 |
|
||
|
||
### relabel — 걸러내고 이름을 붙인다
|
||
|
||
디스커버리는 **전부 다** 가져온다. 그중 필요한 것만 남기는 것이 relabel이다.
|
||
|
||
```yaml
|
||
relabel_configs:
|
||
- source_labels: [__meta_kubernetes_service_name, __meta_kubernetes_endpoint_port_name]
|
||
action: keep
|
||
regex: keycloak-headless;management
|
||
- source_labels: [__meta_kubernetes_pod_name]
|
||
target_label: pod
|
||
```
|
||
|
||
| `action` | 하는 일 |
|
||
|---|---|
|
||
| `keep` | regex에 맞는 것만 남긴다 |
|
||
| `drop` | 맞는 것을 버린다 |
|
||
| `replace` (기본) | 라벨 값을 만든다 |
|
||
| `labelmap` | 메타 라벨을 일반 라벨로 복사 |
|
||
|
||
**`__`로 시작하는 라벨은 내부용**이며 저장되지 않는다. `__meta_*`는
|
||
디스커버리가 붙여준 정보이고, 필요하면 `target_label`로 옮겨야 남는다.
|
||
|
||
**`pod`과 `node` 라벨을 붙이는 것이 실험에서 결정적이다.** 없으면
|
||
"어느 파드가, 어느 노드에서"에 답할 수 없다.
|
||
|
||
### 메트릭 타입
|
||
|
||
| 타입 | 성질 | 예 |
|
||
|---|---|---|
|
||
| **counter** | **누적. 줄지 않는다** (재시작 시 0으로) | `..._requests_total` |
|
||
| **gauge** | 오르내린다 | `node_memory_MemAvailable_bytes` |
|
||
| **histogram** | 구간별 분포 + 합계 + 개수 | `..._seconds_bucket/_sum/_count` |
|
||
| summary | 분위수를 클라이언트가 계산 | |
|
||
|
||
**counter는 그대로 보면 의미가 없다.** 변화율을 봐야 한다.
|
||
|
||
```promql
|
||
rate(http_requests_total[5m])
|
||
```
|
||
|
||
**histogram은 세 지표가 한 벌**이다. `_bucket`으로 분위수를 계산한다.
|
||
|
||
```promql
|
||
histogram_quantile(0.95, rate(keycloak_session_expiration_task_seconds_bucket[5m]))
|
||
```
|
||
|
||
### `up` — 가장 중요한 합성 지표
|
||
|
||
```promql
|
||
up
|
||
up{job="keycloak"}
|
||
```
|
||
|
||
Prometheus가 **직접 만드는** 지표다. 긁기에 성공하면 1, 실패하면 0.
|
||
|
||
**장애 실험에서 이것이 핵심인 이유** — 다른 지표는 대상이 죽으면 **사라진다.**
|
||
사라진 데이터로는 "언제부터 죽었나"를 알 수 없다. `up`은 **0이라는 값으로
|
||
남기 때문에** 사후에 시각을 특정할 수 있다.
|
||
|
||
```promql
|
||
up == 0 # 지금 죽은 타깃
|
||
changes(up[1h]) # 1시간 동안 몇 번 오르내렸나
|
||
min_over_time(up[10m]) # 10분 중 한 번이라도 죽었나
|
||
```
|
||
|
||
### TSDB와 보존 기간
|
||
|
||
```yaml
|
||
--storage.tsdb.path=/prometheus
|
||
--storage.tsdb.retention.time=7d
|
||
```
|
||
|
||
로컬 디스크에 시계열로 저장한다. **보존 기간이 지나면 삭제**되므로 볼륨이
|
||
무한히 커지지 않는다.
|
||
|
||
`emptyDir`에 두면 파드 재시작 시 **실험 기록이 통째로 사라진다.**
|
||
사후 추적이 목적이면 PVC여야 한다.
|
||
|
||
### 관측 시스템의 장애 도메인
|
||
|
||
**관측 시스템은 관측 대상과 같이 죽으면 안 된다.** 죽는 순간을 기록해야
|
||
하는데 같이 죽으면 기록이 없다.
|
||
|
||
노드가 둘뿐인 실험대에서는 완전히 피할 수 없으므로 **규칙으로 정한다.**
|
||
|
||
```
|
||
kc-lab-1 (server) 관측 스택을 둔다. 죽이지 않는다
|
||
kc-lab-2 (agent) 장애 주입 대상
|
||
```
|
||
|
||
`nodeSelector`로 못박아 실험이 재현 가능하게 만든다.
|
||
|
||
---
|
||
|
||
## 13층. 가상화 운영 — 실행 중 바꾸는 것들
|
||
|
||
### VM 메모리 재배분 — 게스트를 다시 만들지 않는다
|
||
|
||
```bash
|
||
virsh setmaxmem kc-lab-1 5120M --config
|
||
virsh setmem kc-lab-1 5120M --config
|
||
```
|
||
|
||
| 명령 | 바꾸는 것 |
|
||
|---|---|
|
||
| `setmaxmem` | **상한**. 부팅 시 게스트가 보는 총량 |
|
||
| `setmem` | **현재 할당**. 상한 이하여야 한다 |
|
||
|
||
**순서가 중요하다.** 현재값을 상한보다 크게 줄 수 없으므로 `setmaxmem`이
|
||
먼저다.
|
||
|
||
| 플래그 | 적용 범위 |
|
||
|---|---|
|
||
| `--config` | 영구 정의. **다음 부팅부터** |
|
||
| `--live` | 실행 중인 도메인에 즉시 |
|
||
| 둘 다 | 지금과 앞으로 |
|
||
|
||
`setmaxmem --live`는 대개 거부된다 — 게스트가 부팅 시 메모리 맵을 정하기
|
||
때문이다. **상한을 바꾸려면 게스트를 껐다 켜야 한다.**
|
||
|
||
```bash
|
||
virsh dominfo kc-lab-1 | grep -i memory
|
||
ssh kc-lab-1 free -m # 게스트가 실제로 인식한 값
|
||
```
|
||
|
||
호스트에서 8GB→12GB로 물리 증설한 뒤 이 방법으로 재배분했다.
|
||
**게스트 재생성이나 디스크 조작은 전혀 필요 없었다.**
|
||
|
||
### 안전한 종료 순서
|
||
|
||
전원을 내리기 전에 **위에서부터** 정리한다.
|
||
|
||
```bash
|
||
# 1. 애플리케이션 — 클러스터에서 정상 탈퇴
|
||
kubectl -n keycloak-lab scale statefulset/keycloak --replicas=0
|
||
kubectl -n keycloak-lab wait --for=delete pod -l app=keycloak --timeout=120s
|
||
|
||
# 2. 데이터베이스 — 마지막에, 충분한 시간을 주고
|
||
kubectl -n keycloak-lab scale deployment/postgres --replicas=0
|
||
kubectl -n keycloak-lab wait --for=delete pod -l app=postgres --timeout=120s
|
||
|
||
# 3. 게스트 — ACPI 정상 종료
|
||
virsh shutdown kc-lab-1 && virsh shutdown kc-lab-2
|
||
|
||
# 4. 호스트
|
||
sudo systemctl poweroff
|
||
```
|
||
|
||
**왜 순서가 중요한가** — `virsh shutdown`은 게스트 systemd가 k3s를 멈추고,
|
||
k3s가 컨테이너에 SIGTERM을 보낸다. 유예 시간이 짧으면 **PostgreSQL이
|
||
강제 종료되어 다음 기동에 crash recovery가 돈다.** 미리 내려두면 그 위험이
|
||
없다.
|
||
|
||
**clean shutdown 확인**
|
||
|
||
```bash
|
||
ssh kc-lab-2 'sudo ls /var/lib/rancher/k3s/storage/*postgres-data*/pgdata/postmaster.pid'
|
||
```
|
||
|
||
**`postmaster.pid`가 남아 있지 않아야 정상**이다. 남아 있으면 비정상 종료였고
|
||
다음 기동에 복구 절차가 실행된다.
|
||
|
||
### 복구 순서 — 종료의 역순
|
||
|
||
```bash
|
||
virsh start kc-lab-1 && virsh start kc-lab-2
|
||
kubectl get nodes # Ready 2개 대기
|
||
kubectl -n keycloak-lab scale deployment/postgres --replicas=1
|
||
kubectl -n keycloak-lab rollout status deployment/postgres
|
||
kubectl -n keycloak-lab scale statefulset/keycloak --replicas=2
|
||
```
|
||
|
||
**PostgreSQL이 먼저다.** Keycloak이 DB 없이 뜨면 기동에 실패한다.
|
||
|
||
**스케일을 0으로 내려두면 자동으로 복구되지 않는다.** 명시적으로 올려야 한다.
|
||
|
||
---
|
||
|
||
## 아직 기록하지 않은 개념
|
||
|
||
실험을 진행하면서 이 문서에 추가한다.
|
||
|
||
- `persistent-user-sessions` / `volatile-user-sessions` 의 실제 차이 (로드맵 2번)
|
||
- refresh token rotation·revoke·max reuse 와 동시 갱신 경쟁 (로드맵 5번)
|
||
- SSO 세션 vs 애플리케이션 세션, `KEYCLOAK_IDENTITY`, `AUTH_SESSION_ID`
|
||
- 백채널 로그아웃과 `sid` 역인덱스
|
||
- Redis 영속화(RDB/AOF)와 세션 복구
|
||
- Spring Session / `OAuth2AuthorizedClientService` 의 저장 구조
|
||
- `tc netem` 지연 주입
|
||
- OOM killer 와 `oom_score`
|
||
- fsync 와 페이지 캐시, EBS IOPS
|
||
|
||
### 이번에 채운 것 (2026-09-04)
|
||
|
||
10~13층으로 기록 완료 — StatefulSet·DaemonSet, PVC/PV/StorageClass,
|
||
Secret, RBAC 와 서브리소스, nodeSelector·taint, k3s server/agent 차이,
|
||
Infinispan·JGroups(디스커버리 vs 트랜스포트, GMS/FD_SOCK2/MERGE3),
|
||
Prometheus(pull·SD·relabel·메트릭 타입·`up`·TSDB), VM 메모리 재배분,
|
||
안전한 종료·복구 순서.
|