# 세션 저장소 실험대 — 개념 사전 `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**으로 붙인다 (``). 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 \ "" \ --live --config ``` | 토큰 | 의미 | |---|---| | `net-update` | 네트워크 정의 XML을 **부분 수정**. 전체를 편집기로 여는 `net-edit`과 달리 특정 섹션만 건드린다 | | `default` | 대상 네트워크 이름 | | `add` | 수행할 동작. 다른 값으로 `add-first`, `modify`, `delete` | | `ip-dhcp-host` | 수정할 섹션. 네트워크 XML의 `` 요소를 가리킨다 | | `""` | 삽입할 XML 조각. `mac`=대상 식별, `ip`=줄 주소, `name`=dnsmasq DNS에 등록될 이름(선택) | | `--live` | **실행 중인** 네트워크에 즉시 적용. libvirt가 dnsmasq 설정을 다시 쓰고 재로드시킨다 | | `--config` | **영구 정의**(`/etc/libvirt/qemu/networks/default.xml`)에도 저장 | **XML이 실제로 어떻게 바뀌나** ```xml ``` **부팅 시 실제로 일어나는 일** 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 # 항목이 들어갔는지 virsh net-dhcp-leases default # 실제로 나간 리스 ssh kc-lab-1 ip -brief addr # 게스트가 받은 주소 ``` **삭제** ```bash virsh net-update default delete ip-dhcp-host \ "" --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: - # 홉 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 ` | `pacman -S ` | | 전체 업그레이드 | `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-__ ``` **그 노드의 로컬 디스크에 디렉터리를 만드는 것이 전부**다. 따라서 **PVC를 쓰는 파드는 그 노드를 벗어날 수 없다.** | 결과 | | |---|---| | 노드가 죽으면 | **파드가 다른 노드로 재배치되지 못한다** | | 실험 관점 | **결함이 아니라 조건이다.** "DB가 있는 노드가 죽으면"이 의미를 갖는다 | ```bash kubectl get pvc -A kubectl get pv kubectl get pv -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//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 메모리 재배분, 안전한 종료·복구 순서.