110 KiB
세션 저장소 실험대 — 개념 사전
develop-keycloak-session-store 작업에서 등장하는 개념을 누적 기록한다.
대화는 흘러가지만 이 문서는 남는다.
모든 항목은 네 가지를 갖춘다.
- 무엇인가 — 정의
- 왜 여기 나오나 — 이 실험대에서 맡은 역할
- 없거나 틀리면 — 실제로 관찰되는 실패 양상
- 확인 — 상태를 직접 볼 수 있는 명령
개념이 새로 나올 때마다 해당 층에 추가한다. 층은 아래에서 위로 쌓인다.
- 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노드냐의
차이만 있다.
구축 완료 판정 기준 — 아래가 전부 통과해야 다음 단계로 넘어간다.
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 가속 없음" 경고를 내고 진행한다.
확인
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 생성이 실패한다.
확인
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 예산 계산이 직관적으로
이해된다.
확인
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 정의가 어디에도 저장되지 않아 재부팅 시 전부 사라진다.
확인
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도 필요 없어진다.
echo 'export LIBVIRT_DEFAULT_URI=qemu:///system' >> ~/.zshrc
exec zsh # 또는 재로그인
확인
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를 쓰지 않는 쪽이다.
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는 "실행 중 정의"와 "영구 정의"를 따로 들고 있다. 둘 다 확인해야 한다.
virsh net-dumpxml default # 실행 중 정의 (--live 가 반영되는 곳)
virsh net-dumpxml --inactive default # 영구 정의 (--config 가 반영되는 곳)
보조 그룹과 재로그인
무엇인가 — usermod -aG libvirt $USER는 /etc/group 파일을 수정한다.
그런데 프로세스의 그룹 목록은 로그인 시점에 한 번 읽혀서 고정되고,
이미 떠 있는 셸에는 소급 적용되지 않는다.
왜 여기 나오나 — usermod 직후 같은 터미널에서 virsh -c qemu:///system을
치면 권한 거부가 날 수 있다. 명령이 잘못된 게 아니라 셸이 옛날 그룹 목록을
들고 있는 것이다. 새 SSH 세션이나 재로그인이면 정상 동작한다.
확인
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가 아예 실행되지
않는다. 오류 메시지만 보면 둘 다 실패한 것처럼 보이지만,
실제로는 앞선 실행에서 이미 목적을 달성한 상태다.
다시 실행해도 안전한 형태 — && 대신 ;를 쓰고 실패를 삼킨다.
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가 실패한다.
확인
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 경로를 절대경로로 기억한다.
확인
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 이미지다.
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 작성에서 실제로 걸린 함정 세 가지
-
탭 문자는 들여쓰기로 쓸 수 없다. YAML 명세가 금지한다. 반드시 스페이스여야 한다. 에디터가 탭을 넣도록 설정돼 있으면 파일 전체가 파싱 실패한다. 눈으로는 구분이 안 되므로 다음으로 확인한다.
grep -Pn '\t' user-data.yaml # 아무것도 안 나와야 정상 -
리스트 항목의 하위 키는
-다음 컬럼에 맞춰 더 들여쓴다.users: - name: donghyeon # '-' 는 2칸 groups: [sudo] # 하위 키는 4칸 ('n' 과 같은 열) shell: /bin/bashgroups를-와 같은 열에 두면 리스트 항목 밖으로 빠져나가 구조가 깨진다. -
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을 타므로 영향받지 않는다.
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가 거부된다. 오류 메시지가 어디에도
남지 않는다.
원인 — 두 가지가 겹친다.
virt-install --cloud-init은 시드 ISO를 SATA CD-ROM으로 붙인다 (<target dev='sda' bus='sata'/>).- Debian
genericcloud변종은 크기를 줄이려고 물리 하드웨어 드라이버를 제외한 이미지다. virtio 계열만 들어 있어 AHCI/SATA 장치를 보지 못한다.
그래서 게스트 입장에서 시드 ISO는 존재하지 않는 장치다. cloud-init은
cidata 레이블을 가진 블록 장치를 찾지 못하고 데이터소스 없이 조용히 종료한다.
해결 — 시드를 virtio 디스크로 붙인다. NoCloud 데이터소스는 CD-ROM을
요구하지 않는다. 레이블이 cidata인 블록 장치면 무엇이든 된다.
ISO 파일을 그대로 virtio 디스크로 붙이면 게스트에 vdb로 보이고
정상 인식된다.
# 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
성공 판정
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 안의 경로를
직접 지정하면 복사본이 필요 없다.
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로 떠서 볼 수 있다.
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로 보낼 수 있다.
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 이미지가 무엇인지 확인하는 법
변종을 잘못 받았는지 의심될 때는 공식 체크섬과 대조하면 확실하다.
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 + ].
확인 (게스트 안에서)
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 전용 이미지를 만나면 없으면 못 뜬다. "깔아두면 손해 없는" 부류다.
확인
ls /usr/share/edk2/x64/OVMF_CODE.4m.fd # Arch 기준 경로
--os-variant / osinfo
무엇인가 — 게스트 OS 종류를 libvirt에 알려주는 값. libvirt는 이걸로 적절한 가상 장치 모델(virtio 사용 여부, 디스크 버스, NIC 모델)을 고른다.
왜 여기 나오나 — 잘못 주거나 생략하면 성능이 크게 떨어진다. 예를 들어 virtio 대신 e1000 에뮬레이션 NIC이 붙으면 네트워크 처리량이 몇 배 나빠지고, 그러면 우리가 측정하려는 노드 간 지연이 오염된다.
확인
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가 해결한다.
확인
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를 못 받는다. 증상이 "네트워크가 안 된다"로 나타나서 원인을 찾기 어렵다.
확인
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에도 적는 것이다.
고정이 필요한 이유 (중요도 순)
- k3s가 IP를 설정 파일과 인증서에 굽는다.
--node-ip,--tls-san, agent의K3S_URL=https://192.168.122.11:6443, kubeconfig의server:필드가 전부 IP를 담는다. server 노드의 IP가 바뀌면 agent가 클러스터에 합류하지 못하고, API 서버 인증서의 SAN도 어긋나 재발급이나 재설치가 필요해진다. 되돌리기가 가장 비싼 항목이다. - nginx는 upstream 주소를 기동 시점에 한 번만 해석한다.
오픈소스판 nginx는
upstream블록의 이름을 설정 로드 때 해석하고 런타임에 다시 조회하지 않는다(재조회하려면resolver+ 변수 트릭이나 상용판이 필요). 그래서 뒤쪽 IP가 바뀌면 reload 전까지 계속 502다. - VM을 반복해서 죽이는 것이 실험 그 자체다.
virsh destroy로 노드 상실을 재현하는데, 되살릴 때마다 주소가 달라질 여지가 있으면 실험이 성립하지 않는다. - 장애 주입 규칙이 주소 기반이다. "kc-lab-2로 가는 7800을 막아라" 같은 규칙에서 IP가 어긋나면 조용히 엉뚱한 것을 막는다. 실패가 드러나지 않는 종류라 특히 위험하다.
왜 DHCP 예약인가 (다른 방법 대비)
| 방법 | 문제 |
|---|---|
| 게스트 안에서 static IP 설정 | cloud-init이 복잡해지고, libvirt는 그 사실을 모른다. 설정이 두 곳에 흩어진다 |
| upstream에 호스트명 사용 | libvirt dnsmasq가 이름을 풀어주긴 하지만 호스트의 리졸버가 virbr0을 바라봐야 하고, 위 2번(기동 시 1회 해석)은 그대로 남는다 |
| DHCP 예약 | 주소 관리가 libvirt 한 곳에 모인다. 게스트는 평범한 DHCP 클라이언트로 두면 된다 |
명령 분해
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이 실제로 어떻게 바뀌나
<!-- 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>
부팅 시 실제로 일어나는 일
- VM 부팅 → 게스트 커널이 virtio NIC 인식 → DHCP 클라이언트가
DHCPDISCOVER를 브로드캐스트한다. 이 프레임의 출발지 MAC이52:54:00:aa:bb:11이다. virbr0에 붙어 있는 dnsmasq가 수신하고, 예약 테이블에서 그 MAC을 찾는다.- 매칭되면 동적 범위에서 아무 주소나 고르는 대신
192.168.122.11을DHCPOFFER로 제시한다. - 게스트가
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만 주고 네트워크를 시작하면 된다.
확인
virsh net-dumpxml default | grep -A5 dhcp # <host> 항목이 들어갔는지
virsh net-dhcp-leases default # 실제로 나간 리스
ssh kc-lab-1 ip -brief addr # 게스트가 받은 주소
삭제
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 이더넷 어댑터를 꽂는 것이다.
확인
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에서 자동화도 된다.
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)이면
동작은 하지만 본인이 수정할 수 없다. 소유권을 넘겨두는 편이 낫다.
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가 최종 판정이다
ssh -G kc-lab-1
실제로 접속하지 않고 모든 블록을 해석한 최종 설정값을 출력한다.
hostname, user, identityfile, stricthostkeychecking 줄이
의도한 값인지 여기서 확인한다. 파일을 눈으로 읽는 것보다 정확하다.
확인
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
확인
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는
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의 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가 별개 제품인 것과
다른 점이다.
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을 가리킨다.
확인
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(= 서로 다른 커널)에 두는 것이 "진짜 노드 상실"과 "노드 간 방화벽" 실험의 전제 조건이다.
확인
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로 바꿔야 한다.
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를 쓴다.
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 구성이 필요 없다.
확인
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
로 확인하라고 했는데 틀렸다. 내장 구현이라 별도 파드로 뜨지 않는다.
올바른 확인은 아래와 같다.
# 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로 하면, 운영에서 쓸 방식 그대로 검증하게 된다.
무엇을 어디에 설치하는가
| 도구 | 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 -
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 -'"
주의 세 가지
- 노드마다 따로 반입한다. 스케줄러가 어느 노드에 배치할지 모른다. 한쪽에만 있으면 반대편에 배치될 때 실패한다.
- 매니페스트에
imagePullPolicy: Never를 준다. 없으면 로컬에 이미지가 있어도 레지스트리에서 당기려 시도하다 실패한다. ctr이 아니라k3s ctr을 쓴다.k3s ctr은 k3s의 containerd 소켓을 가리키는 래퍼다. 시스템에 별도ctr이 있으면 다른 소켓을 보게 되어 "성공했는데 파드는 이미지를 못 찾는" 상태가 된다.
ssh가 두 번 중첩되는 이유 — 게스트가 lab host의 libvirt NAT 뒤에 있어서
워크스테이션에서 직접 접속할 수 없다. lab host의 ~/.ssh/config에 있는
kc-lab-* 별칭을 거쳐야 한다.
확인
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를 다룰 때 "경로가 없다"고 당황하는 이유다.
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 동작에는 차이가 없다.
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(대문자)로 최종 병합된 설정을 출력해 내 파일이 실제로 들어갔는지
확인하는 것이 확실하다.
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을 고른 이유는 셋이다.
- 공식 클라우드 이미지가 잘 관리되고 체크섬이 공개되어 있다
genericcloud변종이 333M로 가볍다- 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 |
이 실험대에서 실제로 체감될 세 가지
unattended-upgrades— Ubuntu는 보안 업데이트를 자동 설치한다. 장애 실험 도중 패키지가 바뀌면 재현성이 깨진다. Ubuntu를 쓴다면 실험 기간에는 꺼두는 것이 맞다.sudo systemctl disable --now unattended-upgrades- AppArmor — Ubuntu는 기본 활성이다. 컨테이너 런타임이나 Keycloak의 파일 접근이 원인 모르게 거부될 때 의심 대상이 하나 늘어난다. Debian에서는 이 변수가 없다.
- snap — Ubuntu의 snap 패키지는 자동 갱신된다. 이것도 재현성의 적이다.
k3s 관점에서는 둘 다 공식 지원이며 설치 스크립트도 동일하다. 따라서 선택 기준은 "운영 환경과 같은 것"뿐이다. 기술적 우열이 아니라 게스트와 운영의 커널·systemd·기본 설정 차이에서 오는 잡음을 없애는 것이 VM을 쓰는 이유 중 하나였기 때문이다.
Ubuntu로 교체하는 방법 (k3s 설치 전이라면 10분이면 된다)
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 <경로>로 인덱스에서만 제거한다
(작업 디렉터리 파일은 남는다).
확인
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 아카이브다. 마법이 없다.
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로 관리) |
그래서 미러가 성립한다. 전 세계 수백 개 서버가 같은 내용을 복제해 배포할 수 있고, 어느 미러에서 받든 서명이 맞으면 정품이다. 미러 운영자가 파일을 바꿔치기해도 서명 검증에서 걸린다.
# 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처럼 자체 업그레이드 경로를 제공하는 소프트웨어에서만 받아들일 만한 방식이다.
아직 기록하지 않은 개념
실험 설계 단계에서 아래 항목을 이 문서에 추가한다.
- Infinispan,
DIST_SYNC,numOwners, 캐시별 설정 - JGroups,
JDBC_PING, 디스커버리와 트랜스포트의 분리, TCP 7800 persistent-user-sessions/volatile-user-sessions- 원격 Infinispan(Hot Rod)과 multi-site
- refresh token rotation, revoke, max reuse, 동시 갱신 경쟁
- SSO 세션 vs 애플리케이션 세션,
KEYCLOAK_IDENTITY,AUTH_SESSION_ID - 백채널 로그아웃과
sid역인덱스 - 쿠키
Secure/SameSite/HttpOnly - Redis 영속화(RDB/AOF)와 세션 복구
tc netem, OOM killer와oom_score, fsync와 페이지 캐시