Files
document-haness/docs/virtualization/final/document.md
T
DongHyeonkaandClaude Opus 5 32e39e20aa fix(setup): 실험대에서 35편을 끝까지 밟고 어긋난 명령·결과 31건을 고친다
test-server 를 비우고 다시 세운 뒤 Setup 기록 35편(virtualization 9 ·
keycloak-session-store 26)을 문서에 적힌 명령 그대로 쳤다. 어긋난 자리를
기록과 SSOT 양쪽에 실측과 함께 넣었다.

막히던 것
- 04 의 인증서 경로가 live/hyeonworks.com 이라 nginx 가 [emerg] 로 안 떴다.
  실제 계보는 live/auth.hyeonworks.com 이고 「문제가 생기면」은 진단이 거꾸로였다
- 인증서가 와일드카드가 아니다. SAN 이 auth·app1·app2 셋뿐이라 그 밖의 이름은
  TLS 에서 끊기고 curl 이 exit 60 · %{http_code} 000 을 낸다. SSOT 안에서
  두 문단이 서로 어긋나 있었다
- A-7 14번 ①이 kc-lab-1 에서 여섯 줄 다 실패하는데 마지막 date 만 「차단」을 찍는다

검사가 실패할 수 없던 자리
- B-1 의 세션 키 고르기는 앞 단계가 $KEY 를 채워 둬서 루프가 한 건도 못 맞혀도
  통과한다. KEY= 로 비우고 키마다 1/0 을 찍게 바꿨다
- k3s-agent 유닛의 sed -i 는 패턴에 $HOME 이 들어 있어 아무 줄도 안 바꾼 채 성공한다

certbot
- renew --dry-run 의 종료 코드는 성공도 0, 실패도 0, 다른 사유의 실패는 1 이다.
  본문의 renew failure(s) 로만 판정할 수 있다
- --dry-run 은 staging 서버를 쓰는데 renewal/*.conf 의 account= 는 운영 계정을
  가리킨다. 실패한 dry-run 이 staging 계정을 하나 더 만들어 다음 실행이 계속 멎는다
- 훅을 755 로 놓고 시뮬레이션이 성공해도 Running deploy-hook command 는 안 나온다.
  certbot 2.1.0 에는 --run-deploy-hooks 도 없다
- 강제 갱신은 실제로 쳤고 서빙까지 닿았다. serial 06F3E0EF…1373 → 065547…3DF1,
  notAfter Dec 3 → Dec 16, nginx worker 2629 4712 → 4745 4754

독자가 칠 수 있는 형태로
- 안 되는 형태가 번호 붙은 단계에 앉아 있던 8곳을 뒤집고, 되는 형태를 ①로 올렸다
- 랩 안에서 공개 이름을 치는 curl 65줄에 --resolve 를 붙였다. 붙인 형태를 실제로
  쳐서 문서가 적은 값과 같은지 확인했다
- 힙독·sed -i·echo >>·&&·|| 를 편집기 + 파일 리스팅 + 분할 형태로 바꿨다
- 닫는 코드펜스가 빠져 뒤 200여 줄의 블록 종류가 뒤집혀 있던 곳을 포함해 3곳을 고쳤다

관문: check_body PASS · check_prose error 0 · check_evidence 두 프로젝트 문제 없음 ·
verify-tech-log-tree error 0 · verify-project-layout error 0 · 코드펜스 전수 0건

남은 것: B-0 주입은 keycloak-pattern 저장소의 소스를 고치고 이미지를 다시 구워야
해서 안 했다(unknown).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 19:38:12 +09:00

647 KiB
Raw Blame History

KVM/QEMU 가상화 SSOT — vCPU·메모리·네트워크·스토리지가 물리 자원에 닿기까지

이 문서가 프로젝트 virtualization 의 SSOT 다. 아홉 부로 나뉘고, 부마다 출발점이 다른 문서 한 편이었다.

무엇을 따라가는가
제1부 — CPU 가상화 §1~§28 vCPU 가 Host 의 물리 CPU 에서 실행되기까지
제2부 — 메모리 가상화 §29~§88 Guest 의 GVA 가 Host RAM 의 HPA 에 닿기까지
제3부 — 네트워크 가상화 §89~§126 Guest 의 패킷이 Physical NIC 로 나가고 되돌아오기까지
제4부 — 스토리지 가상화 §127~§177 Guest 의 write()·fsync() 가 물리 NVMe 에 닿기까지
제5부 — 실험대에서 실제로 확인한 것 §178~§183 실험대를 세우다 실제로 막힌 지점
제6부 — 실험대는 어떻게 세워졌나 §184~§194 기반 7단계 가이드 — 무엇을 어떤 순서로 세웠나
제7부 — 실험대에서 실제로 잰 값 §195~§205 2026-09-10 에 이 호스트에서 나온 출력
제8부 — 설정 원본이 자기 안에 적어 둔 것 §206~§210 deploy/lab/edge/ 네 파일의 주석
제9부 — 실험대 개념 사전 §211~§338 이 실험대를 세우며 쌓은 개념 정의 열세 층

절 번호는 문서 전체에서 이어진다. 제2·3·4부는 각각 다른 파일로 쓴 SSOT 를 반입하면서 heading 단계를 한 칸 내리고 절 번호를 이 문서의 번호로 옮긴 것이고, heading 이 아닌 줄은 한 글자도 바꾸지 않았다. 반입 전 번호는 제2부가 160, 제3부가 138, 제4부가 0~50 이었다. 제7부와 제9부도 같은 방식으로 반입했고, 그 두 부는 반입 전 heading 을 한 칸 올렸다 — 실제 항목이 사는 단계를 절로 삼았다. 바꾼 자리는 각 부의 첫 절이 표로 적는다.

제1~4부에는 이 테스트 Host 에서 잰 값이 하나도 없다. 부마다 끝에 OPEN QUESTION 이 있고, 그 목록이 그 네 부가 아직 확인하지 않은 것이다. 제5부부터가 이 호스트에서 관측한 것이고, 그중 자원의 양과 시간을 잰 것은 제7부뿐이다.

그 관측에는 캡처한 원문이 없다(observed · 2026-09-16 기준).

무엇 지금
증거 2026-09-17 기준 evidence/raw 43 · evidence/meta 1. 2026-09-16 까지는 두 폴더에 README.txt 한 장씩뿐이고 캡처한 원문이 0건이었다. 2026-09-17 에 실험대를 철거하고 다시 세우며 raw/lab-state-before-rebuild/ 에 42건을 남겼다 (rendered·browser 는 여전히 0건이다)

제5~9부의 코드 블록에 있는 명령 출력은 전부 사람이 문서에 옮겨 적은 것이다. 실행한 명령·cwd·실행 시각·종료 코드를 짝지어 남긴 evidence/meta/ 항목이 하나도 없으므로, 그 값들이 언제 어느 상태에서 나왔는지는 문서가 스스로 밝힌 날짜(제7부 2026-09-10, §218 2026-09-03)까지만 확인된다. 반입한 source/ 에도 캡처 원문은 없었다 — 원본 문서들도 출력을 본문에 옮겨 적는 방식이었다.


제1부 — CPU 가상화

1. 이 문서의 범위

이 문서는 KVM/QEMU 기반 가상화에서 VM의 vCPU가 Host의 물리 CPU에서 실제로 실행되기까지의 CPU 가상화 경로를 정리한다.

현재 목적은 Keycloak 멀티 노드 실험 환경을 만들기 위해 KVM 기반 VM을 사용하면서, 실험 결과가 Keycloak/저장소 문제인지 Host/가상화 자원 문제인지 구분할 수 있는 기반을 만드는 것이다.

제1부는 CPU 가상화만 다룬다. 메모리는 제2부, 네트워크는 제3부, 스토리지는 제4부에 있다. 셋은 이 부를 쓴 뒤에 따로 쓴 SSOT 를 반입한 것이라 서술의 출발점이 부마다 다르다.

다음 영역은 이 문서 어느 부에도 없다.

  • PCIe / VFIO / IOMMU 상세
  • K3s 네트워크 및 컨테이너 런타임 상세

2. 전체 구조

VM을 virsh로 시작했을 때 CPU 실행 경로를 크게 보면 다음과 같다.

사용자
  |
  | virsh start <vm>
  v
virsh
  |
  | libvirt API
  v
libvirt
  |
  | QEMU 프로세스 실행/제어
  v
QEMU Process
  |
  +-- main/control thread
  +-- vCPU thread 0
  +-- vCPU thread 1
  +-- ...
  |
  | open("/dev/kvm"), ioctl()
  v
/dev/kvm
  |
  v
KVM Core
  |
  v
kvm_intel
  |
  v
Intel VMX
  |
  v
Physical CPU / Logical CPU

핵심은 virsh가 VM의 CPU를 직접 실행하는 프로그램이 아니라는 점이다.

virsh는 VM을 관리하는 CLI이고, 실제 VM 실행은 QEMU 프로세스가 담당한다. QEMU는 /dev/kvm을 통해 Linux Kernel의 KVM 기능을 사용하고, KVM은 Intel 환경에서 kvm_intel을 통해 CPU의 VMX 기능을 사용한다.


3. 각 구성요소의 역할

3.1 virsh

virsh는 libvirt 기반 가상 머신을 관리하기 위한 CLI다.

예:

virsh start ubuntu-vm
virsh list
virsh shutdown ubuntu-vm

virsh start를 실행했다고 해서 virsh 프로세스가 VM을 계속 실행하는 것은 아니다.

개념적인 흐름은 다음과 같다.

virsh start ubuntu-vm
        |
        v
     libvirt
        |
        v
 QEMU Process 실행

명령 전달이 끝나면 virsh 자체는 종료될 수 있고, VM을 실제로 실행하는 QEMU 프로세스는 계속 살아 있다.

3.2 libvirt

libvirt는 VM lifecycle과 구성을 관리하는 계층이다.

예를 들어 VM 정의에 다음과 같은 정보가 있다.

RAM: 8 GiB
vCPU: 4
Disk: ...
Network: ...

libvirt는 이 정의를 바탕으로 QEMU를 적절한 옵션과 함께 실행하고 관리한다.

3.3 QEMU

QEMU는 Host userspace에서 실행되는 실제 프로세스다.

4 vCPU VM이라면 개념적으로 다음과 같은 구조가 만들어진다.

QEMU Process
 |
 +-- Main / Control Thread
 +-- vCPU Thread 0
 +-- vCPU Thread 1
 +-- vCPU Thread 2
 +-- vCPU Thread 3

KVM 가속을 사용할 때 Guest의 일반 CPU 명령을 QEMU가 하나씩 소프트웨어로 번역해서 실행하는 것이 핵심 경로는 아니다.

QEMU의 vCPU thread가 KVM을 통해 Guest 실행을 요청하면 Guest 코드는 VMX를 이용해 실제 CPU에서 직접 실행된다.

3.4 /dev/kvm

/dev/kvm은 프로세스가 아니다.

Linux가 userspace 프로그램에 KVM API를 노출하는 character device 인터페이스다.

QEMU는 대략 다음과 같은 방식으로 KVM에 접근한다.

QEMU
 |
 | open("/dev/kvm")
 | ioctl(...)
 v
/dev/kvm
 |
 v
KVM

대표적인 KVM API에는 다음과 같은 동작이 있다.

KVM_CREATE_VM
KVM_CREATE_VCPU
KVM_SET_USER_MEMORY_REGION
KVM_RUN

/dev/kvm은 QEMU와 Kernel KVM 사이의 진입점이다.

3.5 KVM Core

KVM Core는 Linux Kernel 내부의 공통 가상화 로직이다.

CPU 제조사에 독립적인 공통 부분과 제조사별 구현을 분리해서 볼 수 있다.

              KVM Core
                 |
        +--------+--------+
        |                 |
   kvm_intel           kvm_amd
        |                 |
       VMX               SVM
        |                 |
   Intel CPU           AMD CPU

3.6 kvm_intel

Intel CPU 환경에서 KVM이 Intel의 하드웨어 가상화 기능을 사용할 수 있게 하는 커널 모듈이다.

AMD 환경에서는 대응되는 kvm_amd가 사용된다.

3.7 VMX

VMX(Virtual Machine Extensions)는 Intel CPU 자체가 제공하는 하드웨어 가상화 기능이다.

VMX는 프로세스나 Linux 커널 모듈이 아니다.

VMX = Intel CPU의 하드웨어 가상화 기능

VMX에서는 크게 다음 실행 영역을 구분한다.

VMX Root Operation
    Host / Hypervisor 측

VMX Non-Root Operation
    Guest 측

여기서 Root는 Linux의 root 사용자와 관계가 없다.

Guest Linux에서 root 권한으로 프로그램을 실행하더라도 Guest 전체는 VMX 관점에서 여전히 Non-Root Operation에서 실행된다.


4. vCPU와 vCPU Thread

VM에 다음과 같이 4 vCPU를 설정했다고 가정한다.

VM
 |
 +-- vCPU 0
 +-- vCPU 1
 +-- vCPU 2
 +-- vCPU 3

Guest OS는 이를 자신의 CPU처럼 인식한다.

Host에서는 각 vCPU의 실행 주체에 대응하는 QEMU vCPU thread가 존재한다.

Guest                    Host

vCPU 0  ------------>  QEMU vCPU Thread 0
vCPU 1  ------------>  QEMU vCPU Thread 1
vCPU 2  ------------>  QEMU vCPU Thread 2
vCPU 3  ------------>  QEMU vCPU Thread 3

중요한 점은 다음과 같다.

VM에 4 vCPU를 할당한다는 것은 물리 CPU 4개를 VM 전용으로 떼어 놓는다는 의미가 아니다.

CPU pinning이나 별도의 CPU isolation을 하지 않은 일반적인 환경에서 vCPU thread는 Host Linux Scheduler의 스케줄링 대상이다.


5. Host Linux Scheduler와 실제 CPU

예를 들어 Host가 6 Core / 12 Thread라면 Linux에서는 일반적으로 12개의 logical CPU가 스케줄링 대상으로 보인다.

CPU0 CPU1 CPU2 CPU3 ... CPU11

QEMU vCPU thread도 다른 Host thread와 마찬가지로 Linux Scheduler가 실행할 logical CPU를 결정한다.

Chrome Thread ----+
Java Thread ------+--> Linux Scheduler --> CPU0 ... CPU11
QEMU vCPU Thread -+

따라서 시간에 따라 같은 vCPU thread가 서로 다른 logical CPU에서 실행될 수도 있다.

T1: vCPU Thread 0 -> CPU7
T2: 다른 Thread   -> CPU7
T3: vCPU Thread 0 -> CPU3

CPU pinning을 적용하면 특정 logical CPU 집합으로 실행 위치를 제한할 수 있다.


6. KVM_RUN과 Guest 실행

QEMU의 vCPU thread가 Guest vCPU를 실행하려면 KVM에 KVM_RUN을 요청한다.

개념적으로 다음과 같다.

ioctl(vcpu_fd, KVM_RUN, 0);

실행 흐름은 다음과 같다.

QEMU vCPU Thread
      |
      | KVM_RUN
      v
     KVM
      |
      | VM Entry
      v
Physical CPU
      |
      +--> Guest Code
      +--> Guest Code
      +--> Guest Code
      +--> ...

이 상태에서 Guest의 일반적인 명령어는 실제 CPU에서 직접 실행된다.

예:

ADD
MOV
SUB
CMP
JMP

일반 명령마다 QEMU까지 돌아갔다가 다시 실행하는 구조가 아니다.


7. VM Entry와 VM Exit

7.1 VM Entry

KVM이 CPU에게 Guest 실행을 시작하거나 재개하도록 하는 전환이다.

KVM
 |
 | VM Entry
 v
Guest 실행

7.2 VM Exit

VM Exit은 VM 종료가 아니다.

다음과 같은 의미다.

CPU가 VMX Non-Root에서 Guest를 실행하다가 Hypervisor가 개입해야 하는 조건을 만나 Guest 실행에서 빠져나와 VMX Root/KVM 쪽으로 제어권을 넘기는 것.

따라서 다음과는 다르다.

VM Exit != VM shutdown
VM Exit != QEMU 종료
VM Exit != VM 메모리 제거
VM Exit != VM 환경 정리

VM은 그대로 살아 있고, 필요한 처리가 끝나면 다시 VM Entry를 통해 Guest 실행을 이어갈 수 있다.


8. 무엇이 실제로 VM Exit을 발생시키는가

Intel VMX에는 VMCS(Virtual Machine Control Structure)가 있으며, Hypervisor는 VM-Execution Control 등을 통해 어떤 동작을 가로챌지 설정한다.

따라서 "특권 명령이면 전부 VM Exit" 또는 "root가 실행하면 VM Exit" 같은 규칙은 맞지 않는다.

VM Exit 여부는 VMX control 설정과 해당 동작의 종류에 따라 결정된다.

8.1 HLT

Guest OS에 실행할 작업이 없으면 kernel idle path에서 HLT 계열 동작이 사용될 수 있다.

KVM/VMX가 HLT exiting을 사용한다면 다음과 같은 흐름이 가능하다.

Guest Kernel
    |
    | HLT
    v
VM Exit
    |
    v
KVM
    |
    +--> vCPU가 당장 할 일이 없음을 처리

vCPU thread를 block/sleep시킬 수 있으므로 Host의 logical CPU를 계속 점유할 필요가 없다.

8.2 I/O Port 접근 - IN / OUT

x86의 IN, OUT 명령으로 I/O port에 접근하는 경우 Hypervisor가 이를 가로채도록 설정할 수 있다.

예:

out 0x3f8, al

개념적으로:

Guest
 |
 | OUT
 v
VM Exit
 |
 v
KVM
 |
 | userspace device emulation이 필요하다면
 v
KVM_RUN return
 |
 v
QEMU

QEMU가 필요한 가상 장치 동작을 처리한 뒤 다시 KVM_RUN을 호출할 수 있다.

8.3 CPUID

CPUID는 CPU vendor와 feature 등 CPU 정보를 조회하는 x86 명령이다.

Guest에게 보여줄 CPU 모델과 feature는 가상화 설정에 따라 Host CPU와 다를 수 있다.

따라서 CPUID 실행을 가로채서 Guest에 노출할 CPU 정보를 가상화할 수 있다.

Guest
 |
 | CPUID
 v
VM Exit
 |
 v
KVM
 |
 | 가상 CPU 정보 처리
 v
VM Entry

8.4 Control Register 접근

Guest kernel도 CR0, CR3, CR4 등의 control register를 사용한다.

예를 들어 CR3는 페이지 테이블과 관련된 CPU 상태에 사용된다.

mov cr3, rax

하지만 모든 CR 접근이 항상 VM Exit을 발생시키는 것은 아니다.

VMX control을 통해 어떤 접근을 가로챌지 결정할 수 있으며, 현대 가상화에서는 성능을 위해 불필요한 Exit을 줄이는 것이 중요하다.

8.5 MSR 접근

CPU에는 MSR(Model-Specific Register)이 있으며 다음 명령으로 접근할 수 있다.

RDMSR
WRMSR

특정 MSR 접근을 Hypervisor가 intercept하도록 설정했다면 VM Exit이 발생할 수 있다.

8.6 Exception

Page Fault, Breakpoint, Debug Exception 등의 CPU exception도 무조건 VM Exit하는 것은 아니다.

VMX의 Exception Bitmap 등의 설정에 따라 Guest가 직접 처리하게 할 수도 있고 Hypervisor가 가로챌 수도 있다.

8.7 External Interrupt

Guest가 명령을 실행하는 도중 Host가 처리해야 할 physical interrupt가 발생할 수도 있다.

VMX interrupt control 설정에 따라 Guest 실행에서 빠져나와 Host/KVM이 처리해야 하는 경우 VM Exit이 발생할 수 있다.


9. VM Exit 이후 처리

VM Exit이 발생하면 KVM은 Exit Reason을 확인한다.

Guest
 |
 | VM Exit
 v
KVM
 |
 | Exit Reason 확인
 |
 +-----------------------+
 |                       |
 | KVM에서 처리 가능      | QEMU 처리 필요
 v                       v
KVM 처리              KVM_RUN return
 |                       |
 |                      QEMU
 |                       |
 |                    필요한 처리
 |                       |
 |                    KVM_RUN
 |                       |
 +-----------+-----------+
             |
             v
          VM Entry
             |
             v
       Guest 실행 재개

중요한 점은 다음과 같다.

VM Exit이 발생했다고 항상 QEMU userspace까지 돌아가는 것은 아니다.

KVM이 Kernel 안에서 처리할 수 있는 Exit은 처리 후 바로 Guest로 재진입할 수 있다.

QEMU의 userspace device emulation 등 userspace 처리가 필요한 경우에만 KVM_RUN이 반환되고 QEMU가 개입한다.


10. Guest가 idle이면 물리 CPU는 어떻게 되는가

VM에 4 vCPU를 설정했다고 해서 4개의 Host logical CPU가 계속 예약되는 것은 아니다.

Guest가 할 일이 없다면 vCPU가 idle 상태에 들어갈 수 있다.

개념적인 흐름:

Guest에 실행할 작업 없음
        |
        v
Guest Kernel idle
        |
        v
HLT 등
        |
        v
VM Exit
        |
        v
KVM
        |
        v
vCPU Thread block/sleep

이때 Host Scheduler는 물리 CPU를 다른 Host workload에 사용할 수 있다.

나중에 timer, interrupt, I/O completion 등 vCPU를 다시 실행해야 할 이유가 생기면:

vCPU wake-up
    |
    v
runnable
    |
    v
Host Linux Scheduler
    |
    v
Logical CPU에서 vCPU Thread 실행
    |
    v
KVM / VM Entry
    |
    v
Guest 실행 재개

따라서 VM이 idle인 동안 Host가 CPU 자원을 다른 작업에 사용하는 것이 가능하다.


11. VM의 4 vCPU는 정확히 무엇을 의미하는가

4 vCPU는 일반적으로 다음 의미에 가깝다.

Guest OS가 최대 4개의 CPU 실행 흐름을 가질 수 있도록 4개의 가상 CPU 실행 컨텍스트를 제공한다.

다음 의미가 아니다.

Host의 물리 CPU 4개를 VM이 영구적으로 소유한다.

Host CPU가 부족하면 QEMU vCPU thread와 다른 Host workload가 같은 logical CPU 자원을 두고 경쟁할 수 있다.


12. CPU contention과 overcommit

예를 들어 Host에 12 logical CPU가 있다고 하자.

Host: 12 logical CPUs

VM A: 8 vCPU
VM B: 8 vCPU
VM C: 8 vCPU
VM D: 8 vCPU

총 32 vCPU가 12개의 logical CPU 위에서 실행될 수 있다.

모든 VM이 동시에 CPU를 많이 사용하면 vCPU thread끼리 Host CPU 시간을 두고 경쟁한다.

32 vCPU threads
      |
      v
Linux Scheduler
      |
      v
12 logical CPUs

이런 상황에서는 Guest application이 느려졌더라도 원인이 application 자체가 아니라 Host CPU contention일 수 있다.


13. Steal Time

Guest Linux에서 top 등의 CPU 지표를 볼 때 st(steal time)를 확인할 수 있다.

개념적으로 steal time은 다음 상황을 나타내는 중요한 단서다.

Guest vCPU는 실행할 작업이 있음
        |
        v
Host에서 vCPU Thread가 CPU를 필요로 함
        |
        v
다른 workload 때문에 즉시 실행되지 못함

높은 steal time은 가상화 환경에서 Host CPU contention이나 CPU overcommit을 의심할 수 있는 지표 중 하나다.

단, steal time 하나만으로 원인을 확정해서는 안 되며 Host CPU saturation, run queue, affinity, workload 등을 함께 확인해야 한다.


14. 실제 Linux에서 확인할 수 있는 것

14.1 VMX/SVM 지원 확인

Intel:

grep -E 'vmx|svm' /proc/cpuinfo

Intel에서는 vmx, AMD에서는 svm flag를 확인할 수 있다.

14.2 KVM 모듈 확인

lsmod | grep kvm

Intel 환경에서는 일반적으로 다음 모듈을 확인할 수 있다.

kvm_intel
kvm

14.3 /dev/kvm 확인

ls -l /dev/kvm

QEMU가 KVM API에 접근하는 character device가 존재하는지 확인한다.

14.4 실행 중인 VM 확인

virsh list

14.5 QEMU 프로세스 확인

ps -ef | grep '[q]emu'

virsh가 아니라 QEMU 프로세스가 실제 VM lifecycle 동안 살아 있는 것을 확인할 수 있다.

14.6 QEMU thread 확인

ps -T -p <QEMU_PID>

또는:

top -H -p <QEMU_PID>

환경/QEMU 버전에 따라 이름은 다를 수 있지만 vCPU 관련 thread를 Host에서 관찰할 수 있다.

14.7 thread가 실행되는 Host CPU 확인

ps -eLo pid,tid,psr,pcpu,comm | grep qemu

PSR을 통해 thread가 최근 실행된 logical CPU를 관찰할 수 있다.

이는 vCPU가 물리 CPU에 영구 고정되어 있다는 의미가 아니며, pinning을 하지 않았다면 스케줄링에 따라 달라질 수 있다.

14.8 Guest의 steal time 확인

Guest 내부:

top

또는 CPU 통계를 제공하는 다른 Linux 도구에서 steal time을 확인한다.

14.9 KVM Exit 관찰

환경이 지원하면 perf kvm을 이용해 KVM 관련 runtime 통계를 확인할 수 있다.

예:

sudo perf kvm stat live

지원되는 명령과 표시되는 Exit reason은 kernel, perf 버전, CPU architecture 및 설정에 따라 다를 수 있으므로 실제 환경에서는 다음을 함께 확인한다.

perf kvm --help

필요하면 KVM tracepoint를 이용한 별도 tracing도 검토한다.


15. CPU 가상화 관점에서 장애를 보는 방법

VM 안의 application이 느릴 때 바로 application 문제라고 결론 내리지 않는다.

CPU 실행 경로를 기준으로 다음 계층을 분리한다.

Application
    |
    v
Guest OS
    |
    v
vCPU
    |
    v
QEMU vCPU Thread
    |
    v
Host Linux Scheduler
    |
    v
KVM / VMX
    |
    v
Physical CPU

확인할 수 있는 관점은 다음과 같다.

Guest

  • application CPU usage
  • load average
  • steal time
  • vCPU 수

Host / QEMU

  • QEMU vCPU thread CPU usage
  • Host CPU saturation
  • run queue
  • vCPU thread scheduling
  • CPU affinity / pinning
  • CPU overcommit

KVM

  • VM Exit 빈도
  • Exit reason
  • 특정 workload에서 Exit이 과도하게 증가하는지

Hardware

  • VMX/SVM 활성화
  • Host CPU topology
  • 실제 logical CPU 수

16. 현재 Keycloak/K3s 실험과의 관계

이 CPU 가상화 자체가 Keycloak refresh token 경쟁의 원인은 아니다.

현재 원래 검증하려는 구조는 다음과 같다.

Client
   |
   v
Nginx / Load Balancer
   |
   v
K3s
   |
   +--> Keycloak Node 1
   |
   +--> Keycloak Node 2
              |
              v
      Session / Token State
              |
       +------+------+
       |             |
   PostgreSQL      Redis

테스트 환경에서는 이 구조 아래에 KVM 계층이 추가된다.

Physical Host
 |
 +-- Host Nginx
 |
 +-- VM 1
 |    |
 |    +-- K3s Node / Keycloak
 |
 +-- VM 2
      |
      +-- K3s Node / Keycloak

따라서 테스트 결과를 해석할 때 다음 원인을 분리해야 한다.

Keycloak refresh/session 동시성
PostgreSQL contention/locking
Redis 상태 관리
K3s resource scheduling
VM vCPU scheduling
Host CPU saturation
Nginx/LB

KVM CPU 가상화를 이해하는 목적은 refresh token 경쟁을 KVM으로 해결하기 위해서가 아니다.

Keycloak 멀티 노드 실험에서 발생한 지연이나 실패가 application/storage 문제인지, VM/Host 자원 문제인지 구분할 수 있도록 실험 기반을 이해하기 위해서다.


17. 동시성 테스트와 부하 테스트를 분리해야 한다

17.1 동시성 테스트

Refresh token 경쟁이나 동일 세션의 상태 갱신 문제를 확인하려면 반드시 Host CPU를 100%까지 밀 필요는 없다.

예:

Same User
Same Session
Same Refresh Token
       |
       +--> Request A --> Node 1
       |
       +--> Request B --> Node 2
                거의 동시에

핵심은 높은 전체 트래픽이 아니라 동일 상태에 대한 동시 접근이다.

사용자 한 명이라도 race condition은 발생할 수 있다.

사용자와 트래픽이 많아지면 이런 경쟁이 실제 운영에서 발생할 확률이 높아질 뿐이다.

17.2 Load / Stress Test

별도로 전체 부하를 증가시키면서 시스템의 자원 한계를 확인한다.

예:

100 RPS
  |
500 RPS
  |
1000 RPS
  |
...

관찰 대상:

  • Keycloak latency
  • PostgreSQL latency/connection/lock
  • Redis latency
  • Host CPU
  • Guest steal time
  • K3s CPU throttling
  • vCPU contention

동시성 문제와 자원 포화 문제를 같은 실험에서 동시에 발생시키면 원인을 분리하기 어려워진다.


18. Bare-metal K3s와 VM 기반 K3s의 차이

Host OS에 K3s를 직접 설치했다면 일반적인 container workload의 CPU 경로는 다음과 같다.

Keycloak Container
      |
      v
K3s / Container Runtime
      |
      v
Host Linux Scheduler
      |
      v
Physical CPU

이 경우 해당 Host 위에 별도 VM이 없다면 workload가 QEMU -> /dev/kvm -> KVM -> VMX 경로를 타는 것은 아니다.

컨테이너의 프로세스는 Host kernel을 공유하며 Host scheduler의 직접적인 스케줄링 대상이다.

반면 VM 안에 K3s를 구성하면 다음 계층이 추가된다.

Keycloak Container
      |
Guest Linux / K3s
      |
vCPU
      |
QEMU vCPU Thread
      |
Host Linux Scheduler
      |
KVM / VMX
      |
Physical CPU

따라서 동일한 부하 테스트라도 VM 기반 테스트 환경에서는 Host 가상화 자원 병목을 추가로 확인해야 한다.


19. 이 SSOT에서 파생될 CONCEPT

현재는 다음 내용을 하나의 CONCEPT로 관리하는 것이 적절하다.

CONCEPT

KVM에서 vCPU가 물리 CPU에서 실행되기까지

포함 범위:

  • virsh
  • libvirt
  • QEMU
  • /dev/kvm
  • KVM Core
  • kvm_intel
  • Intel VMX
  • vCPU / vCPU thread
  • Linux Scheduler
  • KVM_RUN
  • VM Entry / VM Exit
  • 실제 VM Exit 조건
  • Guest idle
  • CPU contention / overcommit
  • steal time
  • 실제 Linux 명령을 통한 관찰
  • Keycloak/K3s 실험 결과와 Host 자원 문제를 구분하는 기준

현재 단계에서는 이 실행 경로가 하나의 인과 흐름으로 연결되므로 여러 CONCEPT 문서로 과도하게 분할하지 않는다.


20. 이 CONCEPT에서 파생되는 OPEN QUESTION

개념을 이해했다고 실제 환경의 동작이 확정되는 것은 아니다.

따라서 다음 질문은 OPEN QUESTION으로 남기고 실제 실험으로 해소한다.

OQ-1. 현재 테스트 Host에서 VM 두 대에 부하를 주면 vCPU contention이 실제로 발생하는가?

확인 대상:

  • Host logical CPU 수
  • 각 VM vCPU 수
  • QEMU vCPU thread CPU 사용량
  • Host run queue
  • Guest steal time

OQ-2. Keycloak 동시 refresh 실험 중 CPU 가상화 계층이 결과에 영향을 줄 정도로 포화되는가?

Refresh 경쟁 실험 중 다음을 동시에 관찰한다.

  • Host CPU
  • Guest CPU
  • steal time
  • Keycloak latency
  • DB/Redis latency

목적은 refresh 경쟁과 Host resource contention을 분리하는 것이다.

OQ-3. Guest가 idle일 때 vCPU thread는 실제 테스트 환경에서 어떻게 보이는가?

Guest idle 상태와 CPU workload 상태를 비교한다.

확인:

top -H -p <QEMU_PID>
ps -eLo pid,tid,psr,pcpu,stat,comm

OQ-4. 실제 workload에서 어떤 VM Exit이 주로 발생하는가?

환경이 지원한다면 perf kvm 또는 KVM tracepoint를 이용해 확인한다.

비교 후보:

  • idle
  • CPU-bound workload
  • I/O-heavy workload
  • Keycloak 정상 요청
  • Keycloak 부하 테스트

OQ-5. CPU pinning을 하지 않은 상태에서 vCPU thread는 Host logical CPU 사이를 실제로 이동하는가?

PSR, scheduler tracing 등을 통해 관찰한다.

OQ-6. 현재 운영 서버는 CPU 가상화 계층의 영향을 받는 구조인가?

운영 서버가 bare-metal Host에 직접 K3s를 설치한 것인지, 상위 Hypervisor/Cloud VM 위에 있는지 확인한다.

구조에 따라 진단 지표가 달라진다.

Bare metal:
K3s -> Host Scheduler -> Physical CPU

VM:
K3s -> Guest -> vCPU -> Hypervisor -> Physical CPU

21. OPEN QUESTION에서 CASE가 만들어지는 흐름

현재 문서 체계에서는 다음 관계를 사용한다.

SSOT
 |
 v
CONCEPT
 |
 | 이해하면서 검증이 필요한 질문 발생
 v
OPEN QUESTION
 |
 | 실제 구성 / 명령 / 부하 / 관찰
 v
CASE
 |
 | 결과에서 새로운 의문 발견
 +------------------> OPEN QUESTION

즉 OPEN QUESTION은 CASE에서만 나오는 것이 아니다.

CONCEPT -> OPEN QUESTION
CASE    -> OPEN QUESTION

둘 다 가능하다.

그리고 OPEN QUESTION을 실제 실험으로 해소하는 과정에서 새로운 CASE가 만들어질 수 있다.

예:

CONCEPT
"KVM vCPU는 Host Scheduler의 스케줄링 대상이다"
        |
        v
OPEN QUESTION
"VM 2대에 동시에 부하를 주면 현재 Host에서
 실제 steal time이 증가하는가?"
        |
        v
CASE
"VM 2대 CPU contention 재현 및 steal time 측정"

이 구조를 사용하면 개념 문서에 실험 결과를 억지로 섞지 않으면서도 개념 -> 질문 -> 검증의 추적성을 유지할 수 있다.


22. 현재 단계의 핵심 Claim

Claim 1

virsh는 VM 실행 자체를 담당하는 프로세스가 아니라 libvirt 기반 VM 관리 CLI다.

Claim 2

KVM 가속 환경에서 실제 VM lifecycle 동안 QEMU 프로세스가 살아 있으며, vCPU에 대응하는 Host thread가 존재한다.

Claim 3

QEMU는 /dev/kvm을 통해 Kernel의 KVM API를 사용한다.

Claim 4

Intel 환경에서 KVM은 kvm_intel을 통해 CPU의 VMX 하드웨어 가상화 기능을 사용한다.

Claim 5

VM에 N개의 vCPU를 설정하는 것은 Host의 N개 physical/logical CPU를 영구 예약한다는 의미가 아니다.

Claim 6

vCPU thread는 기본적으로 Host Linux Scheduler의 스케줄링 대상이며, pinning하지 않았다면 실행되는 logical CPU가 달라질 수 있다.

Claim 7

vCPU thread가 KVM_RUN을 호출하면 KVM이 VM Entry를 통해 Guest 실행을 시작하며 Guest의 일반 CPU 명령은 실제 CPU에서 실행된다.

Claim 8

VM Exit은 VM 종료가 아니라 Guest 실행에서 Hypervisor/KVM으로 CPU 제어권이 전환되는 동작이다.

Claim 9

VM Exit은 Linux root 권한 여부로 결정되지 않는다. VMX execution control에 의해 intercept되는 명령, exception, interrupt 등의 조건에 따라 발생한다.

Claim 10

모든 VM Exit이 QEMU까지 전달되는 것은 아니다. KVM이 Kernel 내부에서 처리할 수 있는 경우 Guest로 바로 재진입할 수 있다.

Claim 11

Guest가 idle이면 vCPU thread가 block/sleep될 수 있으며, 이때 Host는 해당 CPU 시간을 다른 workload에 사용할 수 있다.

Claim 12

높은 Host CPU contention과 vCPU overcommit은 Guest application 성능에 영향을 줄 수 있으며 steal time은 이를 조사할 때 유용한 지표 중 하나다.

Claim 13

Keycloak refresh token 경쟁은 KVM CPU 가상화 문제와 동일한 문제가 아니다. 다만 VM 기반 실험 환경의 CPU contention이 실험 결과를 왜곡할 수 있으므로 두 문제를 분리해서 측정해야 한다.

Claim 14

Refresh token 경쟁 검증을 위한 concurrency test와 시스템 자원 한계를 확인하기 위한 load/stress test는 목적이 다르므로 분리해서 수행하는 것이 원인 분석에 유리하다.


23. 다음 단계

CPU 가상화에 대해서는 이 SSOT를 기준으로 실제 테스트 Host에서 명령을 실행해 다음을 검증한다.

VMX/SVM
  ->
KVM modules
  ->
/dev/kvm
  ->
virsh VM
  ->
QEMU process
  ->
vCPU threads
  ->
Host logical CPU scheduling
  ->
Guest idle/load 비교
  ->
steal time
  ->
VM Exit 관찰

검증 과정에서 아직 답하지 못한 항목은 OPEN QUESTION으로 유지한다.

실험 결과가 확보되면 각각 CASE로 기록한다.

그 이후 원래 Keycloak 멀티 노드 실험에 필요한 다음 기반 영역인 네트워크 가상화로 이동한다.


24. CPU 가상화 계층에서 발생할 수 있는 문제

CPU 가상화 구조를 이해하는 목적 중 하나는 VM 안의 애플리케이션이 느려졌을 때 어느 계층에서 문제가 발생했는지 구분하는 것이다.

Application / Keycloak
        |
        v
K3s / cgroup
        |
        v
Guest Linux
        |
        v
vCPU
        |
        v
QEMU vCPU Thread
        |
        v
Host Linux Scheduler
        |
        v
KVM / VMX
        |
        v
Physical CPU / NUMA

같은 "CPU가 느리다"는 현상도 실제 원인은 서로 다를 수 있다.

24.1 Guest CPU Saturation

Guest 내부의 애플리케이션이 실제로 할당된 vCPU를 모두 사용하고 있는 경우다.

Keycloak / Application
        |
        v
Guest vCPU 100%

이 경우 Host에 CPU 여유가 있더라도 Guest에 할당한 vCPU 수나 애플리케이션 자체의 CPU 사용 특성이 병목일 수 있다.

확인 대상:

  • Guest top
  • process/thread별 CPU 사용량
  • load average
  • Guest에 할당된 vCPU 수

이 문제는 Host CPU contention과 구분해야 한다.

24.2 CPU Overcommit

Host가 실제로 동시에 실행할 수 있는 logical CPU보다 많은 vCPU를 여러 VM에 할당하는 구성이다.

예:

Host: 12 logical CPUs

VM1: 8 vCPU
VM2: 8 vCPU
VM3: 8 vCPU

Total: 24 vCPU

Overcommit 자체가 바로 장애라는 의미는 아니다. VM들이 대부분 idle이라면 문제가 없을 수 있다.

문제는 여러 VM의 vCPU가 동시에 runnable 상태가 될 때 나타난다.

많은 runnable vCPU threads
          |
          v
Host Scheduler
          |
          v
제한된 logical CPUs

이때 CPU contention과 scheduling latency가 증가할 수 있다.

24.3 CPU Contention

여러 runnable thread가 같은 Host CPU 자원을 두고 경쟁하는 상태다.

경쟁 대상은 QEMU vCPU thread만이 아니다.

QEMU vCPU threads ---+
Nginx ---------------+
Host K3s ------------+--> Linux Scheduler --> Physical CPUs
DB / Redis ----------+
기타 Host process ---+

따라서 Host에 Nginx를 직접 설치하고 VM 두 대를 실행하는 테스트 환경에서는 VM 외부의 Host workload도 CPU 경쟁에 포함된다.

확인 대상:

  • Host CPU utilization
  • per-CPU utilization
  • run queue
  • load average
  • QEMU vCPU thread CPU usage

24.4 Steal Time 증가

Guest에서는 실행할 작업이 있지만 Hypervisor/Host가 해당 vCPU thread를 즉시 실행시키지 못한 시간을 Guest가 steal time으로 관찰할 수 있다.

Guest workload runnable
        |
        v
vCPU 실행 필요
        |
        v
Host CPU를 즉시 받지 못함
        |
        v
Steal Time 증가

Guest에서 top 등의 %st를 확인할 수 있다.

높은 steal time은 Host CPU contention 또는 overcommit을 조사해야 한다는 중요한 단서지만, 단독으로 원인을 확정하는 지표는 아니다.

24.5 vCPU Scheduling Latency

vCPU thread가 runnable 상태가 되었더라도 Host Scheduler가 실제 logical CPU에 배치할 때까지 기다릴 수 있다.

vCPU Thread
  runnable
     |
     | wait
     v
Host Scheduler
     |
     v
Logical CPU

Host가 포화될수록 이 대기 시간이 커질 수 있으며 Guest에서는 application latency 증가로 보일 수 있다.

24.6 vCPU 과다 할당

특정 VM에 vCPU를 많이 할당한다고 항상 성능이 좋아지는 것은 아니다.

Guest workload가 실제로 그만큼의 병렬성을 사용하지 못하거나 Host 전체 CPU에 비해 지나치게 많은 vCPU를 할당하면 scheduling 대상만 증가할 수 있다.

따라서 vCPU 수가 많다 = 항상 빠르다로 판단하지 않는다.

실제 workload의 병렬성과 Host capacity를 함께 확인해야 한다.

24.7 잘못된 CPU Affinity / Pinning

CPU pinning을 사용하면 특정 vCPU thread를 특정 Host logical CPU에 제한할 수 있다.

적절하게 사용하면 scheduling 변동을 줄일 수 있지만 잘못 설정하면 특정 CPU에 workload가 집중될 수 있다.

vCPU0 --+
vCPU1 --+--> CPU2
Host X -+

CPU3, CPU4, CPU5 ... 상대적으로 idle

따라서 pinning 여부만 보는 것이 아니라 실제 per-CPU utilization과 affinity를 함께 확인해야 한다.

24.8 CPU Throttling

K3s/Kubernetes 환경에서는 VM CPU 자원과 별개로 container cgroup의 CPU limit 때문에 application이 제한될 수 있다.

Physical CPU
    |
Host / Hypervisor
    |
Guest Linux
    |
K3s
    |
cgroup CPU limit
    |
Keycloak Pod

이 경우 Host CPU에 여유가 있어도 Keycloak Pod는 설정된 CPU quota 때문에 실행이 제한될 수 있다.

따라서 다음 두 문제를 구분해야 한다.

Host CPU를 받지 못함
    -> contention / steal / scheduling 문제

Pod가 자신의 CPU quota를 초과함
    -> cgroup CPU throttling 문제

CPU throttling 자체는 KVM 문제가 아니지만 VM 안에서 K3s를 운영하는 현재 실험에서는 같은 application latency로 관찰될 수 있으므로 진단 경계에 포함한다.

24.9 과도한 VM Exit

VM Exit은 정상적인 가상화 동작이다.

따라서 VM Exit이 존재한다는 것 자체는 문제가 아니다.

다만 특정 workload에서 Hypervisor가 개입해야 하는 Exit이 지나치게 빈번하고 그 처리 비용이 커진다면 성능에 영향을 줄 수 있다.

VM Entry
   |
Guest
   |
VM Exit
   |
KVM / QEMU 처리
   |
VM Entry
   |
Guest
   |
VM Exit
   ...

확인할 때는 단순 Exit 횟수만 보는 것이 아니라 다음을 같이 봐야 한다.

  • Exit reason
  • workload 종류
  • Exit 처리 위치가 KVM인지 QEMU userspace인지
  • application latency와 Exit 증가가 함께 나타나는지

VM Exit이 많다 = 장애로 바로 판단하지 않는다.

24.10 Host 자체의 CPU Saturation

VM만 관찰하면 놓치기 쉬운 문제다.

현재 테스트 Host에서 Nginx와 여러 Host process가 함께 동작한다면 다음과 같은 경쟁이 가능하다.

Host
 |
 +-- Nginx
 +-- QEMU VM1
 +-- QEMU VM2
 +-- monitoring
 +-- SSH / shell
 +-- 기타 process

Host CPU 자체가 포화되면 VM 내부에서는 Keycloak이나 K3s가 느려진 것처럼 보일 수 있다.

따라서 Guest 지표만으로 결론 내리지 않고 Host와 Guest를 동시에 관찰해야 한다.

24.11 NUMA Locality 문제

멀티소켓 또는 NUMA 구조의 Host에서는 CPU가 실행되는 NUMA node와 VM memory가 위치한 NUMA node의 관계가 성능에 영향을 줄 수 있다.

개념적으로:

NUMA Node 0
  CPU + Local Memory

NUMA Node 1
  CPU + Local Memory

vCPU가 Node 0의 CPU에서 실행되는데 필요한 memory가 주로 Node 1에 배치되어 있다면 remote memory access가 발생할 수 있다.

NUMA는 CPU와 메모리 가상화의 경계에 걸쳐 있으므로 이 문서에서는 문제의 존재와 CPU affinity와의 관계까지만 기록한다. 상세한 memory placement와 NUMA tuning은 메모리 가상화 CONCEPT에서 다룬다.


25. CPU 문제를 계층별로 구분하는 진단표

문제 주된 계층 대표적인 현상 우선 확인할 것
Guest CPU saturation Guest Guest CPU가 지속적으로 높음 Guest CPU, process/thread, load
CPU throttling K3s / cgroup Pod가 CPU를 더 쓰고 싶어도 quota로 제한 CPU limit, throttled time
vCPU 과다 할당 VM 구성 vCPU 증가 대비 성능 향상 없음 또는 scheduling 부담 vCPU 수, workload 병렬성
CPU overcommit Host 구성 여러 VM 부하시 지연 증가 total vCPU, Host logical CPU
CPU contention Host Scheduler runnable workload 증가, latency 증가 Host CPU, run queue, per-CPU usage
Steal time 증가 Guest에서 관측 Guest가 CPU를 제때 받지 못함 %st, Host contention
Scheduling latency Host Scheduler runnable vCPU 실행 지연 run queue, scheduler 관찰
잘못된 pinning Host / VM 설정 특정 CPU만 과도하게 사용 affinity, per-CPU usage
과도한 VM Exit KVM / VMX 특정 workload에서 virtualization overhead 증가 가능 Exit count/reason, workload
Host CPU saturation Host VM 전체가 동시에 느려짐 Host CPU/load/run queue
NUMA locality Hardware / Memory CPU는 여유가 있는데 memory access 비용 증가 가능 NUMA topology, CPU/memory placement

이 표의 목적은 하나의 지표로 장애 원인을 확정하는 것이 아니라, 어느 계층부터 조사해야 하는지 범위를 줄이는 것이다.


26. 현재 Keycloak 실험에서 CPU 문제를 오판하지 않기 위한 기준

Keycloak refresh token 경쟁 실험에서 요청 실패나 latency가 증가했다고 해서 바로 refresh token 또는 저장소 경쟁 문제라고 판단하지 않는다.

최소한 다음 경계를 분리한다.

[Application / Auth]
Refresh Token 경쟁
Session 상태 경쟁
Keycloak 내부 처리
        |
        v
[Storage]
PostgreSQL lock / latency
Redis latency / consistency
        |
        v
[K3s]
Pod CPU throttling
Pod scheduling/resource limit
        |
        v
[Guest]
Guest CPU saturation
        |
        v
[Virtualization]
vCPU scheduling
Steal time
VM Exit overhead
        |
        v
[Host]
CPU contention
CPU overcommit
Host saturation

따라서 refresh 경쟁을 검증하는 첫 실험에서는 가능하면 CPU 자원을 여유 있게 유지한다.

그 상태에서 동일 session/token에 대한 동시 요청을 만들어 concurrency 문제를 먼저 확인한다.

그 다음 별도의 load/stress CASE에서 트래픽을 증가시키며 CPU/DB/Redis/K3s 자원 포화를 관찰한다.

이렇게 해야 다음 두 결과를 분리할 수 있다.

"동일 상태에 동시에 접근해서 발생한 문제"

vs

"시스템 자원이 부족해져서 발생한 문제"

27. 문제 영역에서 파생되는 추가 OPEN QUESTION

OQ-7. VM 두 대를 동시에 CPU-bound 상태로 만들면 Guest steal time은 실제로 얼마나 증가하는가?

Host CPU utilization, run queue, QEMU vCPU thread, 각 Guest의 %st를 함께 측정한다.

OQ-8. vCPU 수를 늘릴수록 현재 테스트 Host에서 Keycloak 처리량도 계속 증가하는가?

예를 들어 2 vCPU / 4 vCPU / 8 vCPU 구성을 비교해 vCPU 추가가 실제 처리량과 latency에 어떤 영향을 주는지 확인한다.

OQ-9. K3s CPU limit으로 발생한 throttling과 Host vCPU contention을 지표로 구분할 수 있는가?

동일한 application latency 증가를 각각 의도적으로 재현하고 Guest/Host/K3s 지표 차이를 비교한다.

OQ-10. CPU pinning 전후로 Keycloak latency와 vCPU scheduling 변동이 달라지는가?

pinning이 현재 workload에서 실제 이점을 주는지는 실험으로 확인한다.

OQ-11. Keycloak workload에서 VM Exit 분포는 idle/CPU-bound/I/O-bound workload와 어떻게 다른가?

가능하면 perf kvm 또는 KVM tracepoint를 사용해 Exit reason 분포를 비교한다.

OQ-12. 현재 Host의 NUMA topology가 VM 성능을 고려해야 할 정도의 구조인가?

Host가 단일 NUMA node라면 현재 실험에서 우선순위를 낮추고, 다중 NUMA node라면 vCPU/memory placement를 별도 CASE 후보로 올린다.


28. CONCEPT -> OPEN QUESTION -> CASE 적용 기준

CPU 가상화 CONCEPT에서는 다음 수준까지만 확정한다.

구조적으로 어떤 문제가 발생할 수 있는가?
어떤 지표로 그 문제를 의심할 수 있는가?
어느 계층에서 확인해야 하는가?

현재 테스트 서버에서 실제로 발생하는지는 CONCEPT에서 사실로 확정하지 않는다.

예:

CONCEPT
CPU overcommit 상황에서는 여러 vCPU thread가 Host CPU를 두고 경쟁할 수 있다.
        |
        v
OPEN QUESTION
현재 VM1 + VM2 구성에서도 부하 시 contention이 실제 발생하는가?
        |
        v
CASE
VM 두 대 동시 CPU 부하에서 Host run queue와 Guest steal time을 측정했다.

반대로 CASE를 수행하다 예상하지 못한 현상이 발견되면 다시 OPEN QUESTION을 생성한다.

CASE
   |
   +--> 예상과 다른 결과
             |
             v
       OPEN QUESTION
             |
             v
         다음 CASE

따라서 현재 문서 체계에서 OPEN QUESTION은 CONCEPT와 CASE 사이를 한 방향으로만 연결하는 단계가 아니라, 아직 검증되지 않은 사실을 명시적으로 보관하고 다음 검증을 만드는 연결점으로 사용한다.

제2부 — 메모리 가상화

목적: KVM/QEMU 기반 VM에서 Guest 프로세스의 메모리 접근이 실제 Host RAM까지 도달하는 경로를 하나의 기준 문서로 정리한다.
범위: GVA/GPA/HPA, Guest Page Table, MMU/TLB, EPT, QEMU/KVM memory backing, Page Fault/EPT Violation, Huge Page/THP/HugeTLB, Memory Overcommit, Reclaim/Swap, Ballooning/OOM, NUMA 및 실제 관측 지점.
원칙: Guest가 보는 메모리 상태와 Host가 실제로 관리하는 메모리 상태를 분리해서 본다.


29. 이 문서에서 먼저 고정할 전체 구조

KVM/QEMU VM의 메모리 접근을 가장 단순하게 표현하면 다음과 같다.

Guest Application
       │
       │ Guest Virtual Address (GVA)
       ▼
Guest Page Table
       │
       │ Guest Physical Address (GPA)
       ▼
EPT (Intel) / NPT (AMD)
       │
       │ Host Physical Address (HPA)
       ▼
Physical RAM

여기서 세 주소를 먼저 구분해야 한다.

주소 의미
GVA Guest 프로세스가 사용하는 Virtual Address
GPA Guest OS가 물리 메모리라고 생각하는 주소
HPA 실제 Host 서버 RAM의 Physical Address

예를 들어 Guest 안에서 실행되는 Keycloak이 어떤 변수를 읽는다고 하자.

Keycloak
   │
   │ GVA 0x7f001234
   ▼
Guest Page Table
   │
   │ GPA 0x00101234
   ▼
EPT
   │
   │ HPA 0x8a101234
   ▼
Physical RAM

Guest Linux는 GPA를 자신의 실제 물리 주소라고 생각한다. 하지만 VM이므로 그 GPA가 실제 서버의 HPA와 같을 필요는 없다. KVM/CPU 가상화 계층이 이 둘을 분리한다.


30. 일반 Linux의 Virtual Memory부터 시작한다

메모리 가상화의 첫 단계는 KVM 고유 기능이 아니다. 일반적인 Linux 프로세스도 실제 RAM 주소를 직접 사용하지 않는다.

Guest 안에 다음 프로세스가 있다고 하자.

Guest VM

├─ Keycloak
├─ PostgreSQL
├─ nginx
└─ systemd

각 프로세스에는 독립적인 Virtual Address Space가 있다.

Keycloak Process

Virtual Address Space
┌─────────────────────────┐
│ 0x1000                  │
│ 0x2000                  │
│ 0x3000                  │
│ ...                     │
└─────────────────────────┘


PostgreSQL Process

Virtual Address Space
┌─────────────────────────┐
│ 0x1000                  │
│ 0x2000                  │
│ 0x3000                  │
│ ...                     │
└─────────────────────────┘

두 프로세스가 모두 0x1000이라는 주소를 사용할 수 있다. 같은 Virtual Address라도 서로 다른 physical frame으로 매핑할 수 있기 때문이다.

Keycloak
Virtual 0x1000
      ↓
Physical Frame A

PostgreSQL
Virtual 0x1000
      ↓
Physical Frame F

VM 내부에서 이 physical address는 정확히는 Guest Physical Address다.


31. Page와 Physical Frame

Linux는 메모리를 주소 하나씩 매핑하지 않는다. 일정 크기의 단위로 나누어 관리한다.

x86-64 Linux에서 흔히 사용하는 기본 page 크기는 4 KiB다.

Virtual Memory

0x0000 ┌───────────────┐
       │ Page 0        │ 4 KiB
0x1000 ├───────────────┤
       │ Page 1        │ 4 KiB
0x2000 ├───────────────┤
       │ Page 2        │ 4 KiB
0x3000 ├───────────────┤
       │ Page 3        │ 4 KiB
0x4000 └───────────────┘

Physical Memory도 page-sized frame 단위로 생각할 수 있다.

Guest Physical Memory

┌───────────────┐
│ Frame 0       │
├───────────────┤
│ Frame 1       │
├───────────────┤
│ Frame 2       │
├───────────────┤
│ Frame 3       │
└───────────────┘

따라서 Page Table의 핵심 역할은 다음과 같다.

Virtual Page
     ↓
Page Table
     ↓
Physical Frame

32. Virtual Address = Page + Offset

예를 들어 기본 page 크기가 4 KiB(0x1000)이고 프로세스가 0x1234에 접근한다고 하자.

Virtual Address
0x1234

┌──────────────┬─────────────┐
│ Virtual Page │   Offset    │
│      1       │    0x234    │
└──────────────┴─────────────┘

Page Table에 다음 mapping이 있다고 가정한다.

Virtual Page 1
       ↓
Guest Physical Frame 7

그러면 주소 변환 후에도 page 내부 offset 0x234는 유지된다.

Virtual Page 1
┌──────────────────────────┐
│              X           │
└──────────────┬───────────┘
               │ offset 0x234
               ▼
          Page Table
               │
               ▼
Physical Frame 7
┌──────────────────────────┐
│              X           │
└──────────────────────────┘

즉 Page Table은 핵심적으로 어느 physical frame으로 갈 것인가를 결정한다.


33. Guest Page Table

Guest Linux Kernel은 각 프로세스의 virtual-memory mapping을 관리한다.

단순화한 예:

Keycloak Page Table

Virtual Page        Guest Physical Frame

Page 1       ─────→ Frame 7
Page 2       ─────→ Frame 12
Page 3       ─────→ Frame 31

Guest Kernel은 프로세스 생성, mmap(), page allocation, permission 변경, COW 등의 상황에서 page table을 생성하거나 변경한다.

하지만 CPU가 메모리에 접근할 때마다 Guest Kernel 코드가 직접 table을 하나씩 검색하는 것은 아니다.


34. MMU: 실제 주소 변환을 수행하는 CPU 하드웨어

주소 변환의 핵심 실행 주체는 CPU의 MMU(Memory Management Unit)다.

CPU
 │
 │ Virtual Address
 ▼
MMU
 │
 │ Page Table 기반 translation
 ▼
Physical Address

현재 Guest 내부 단계만 보면:

Guest Virtual Address
        ↓
       MMU
        │
        │ Guest Page Table
        ▼
Guest Physical Address

역할을 나누면 다음과 같다.

Guest Linux Kernel
       │
       │ Page Table 구성/관리
       ▼
   Page Table
       ▲
       │ 사용
       │
      MMU
       │
       │ 주소 변환
       ▼
Memory Access

35. TLB: 주소 변환 결과의 CPU Cache

매 memory access마다 전체 page-table walk를 수행하면 비용이 크다. CPU는 최근 translation 결과를 TLB(Translation Lookaside Buffer)에 cache한다.

Virtual Address
      ↓
     TLB
   ┌──┴──┐
   │     │
 HIT    MISS
   │     │
   │     ▼
   │ Page Table Walk
   │     │
   └──┬──┘
      ▼
Physical Address

예를 들어:

Virtual Page 1 → Physical Frame 7

이라는 translation이 TLB에 있다면 같은 page의 다음 접근에서 전체 page-table walk를 피할 수 있다.

TLB Miss와 Page Fault는 다르다

TLB Miss:

TLB에 translation cache가 없음
        ↓
Page Table을 조회
        ↓
정상 mapping 존재
        ↓
계속 실행

Page Fault:

Page Table 상태상
현재 접근을 정상 완료할 수 없음

따라서:

TLB Miss ≠ Page Fault

다.


36. Bare Metal과 VM의 차이

Bare-metal Linux에서는 개념적으로 다음으로 끝난다.

Process Virtual Address
        ↓
Page Table
        ↓
Host Physical Address
        ↓
Physical RAM

VM에서는 Guest가 얻은 physical address가 실제 Host physical address가 아니다.

Guest Virtual Address
        ↓
Guest Page Table
        ↓
Guest Physical Address
        ↓
        ???
        ↓
Host Physical Address
        ↓
Physical RAM

GPA → HPA 두 번째 translation을 위해 Intel에서는 EPT를 사용한다.


37. EPT(Extended Page Tables)

EPT는 Intel의 second-level address translation 기술이다. AMD에는 대응되는 NPT 계열 기능이 있다.

              Guest가 관리

GVA
 │
 │ Guest Page Table
 ▼
GPA

           Hypervisor 측

GPA
 │
 │ EPT
 ▼
HPA

합치면:

GVA
 │
 │ Guest Page Table
 ▼
GPA
 │
 │ EPT
 ▼
HPA
 │
 ▼
Physical RAM

핵심 역할은 다음과 같다.

구조 변환 주요 관리 주체
Guest Page Table GVA → GPA Guest OS
EPT GPA → HPA KVM/Host virtualization 계층
실제 runtime translation 두 translation 계층 활용 CPU MMU

Guest Page Table과 EPT는 같은 table이 아니다.


38. 왜 EPT가 필요한가

VM1과 VM2가 각각 8 GiB RAM을 가진다고 하자.

둘 다 Guest 입장에서는 동일한 GPA를 사용할 수 있다.

VM1: GPA 0x1000
VM2: GPA 0x1000

그러나 실제 Host RAM에서는 서로 다른 위치로 연결할 수 있어야 한다.

VM1
GPA 0x1000
    ↓ EPT
HPA 0xA001000

VM2
GPA 0x1000
    ↓ EPT
HPA 0xF501000

따라서 Guest가 보는 physical-memory address space를 실제 Host RAM에서 격리하여 구현할 수 있다.


39. Shadow Page Table과 EPT의 의미

하드웨어 second-level translation이 없던 방식에서는 hypervisor가 Guest page-table 변경을 추적하면서 GVA에서 실제 Host memory까지 연결되는 shadow mapping을 관리하는 방식이 사용될 수 있었다.

개념적으로:

Guest가 원하는 것

GVA
 ↓
Guest Page Table
 ↓
GPA


실제 하드웨어에 필요한 것

GVA
 ↓
HPA

Guest page table이 바뀔 때마다 hypervisor가 관련 mapping을 유지해야 하므로 관리 비용과 복잡성이 커질 수 있다.

EPT/NPT는 CPU가 두 단계 translation을 하드웨어로 지원하게 한다.


40. QEMU는 Guest RAM을 어떻게 준비하는가

VM에 8 GiB RAM을 설정했다고 하자.

QEMU는 Host userspace process다. 따라서 QEMU 자신도 Host Virtual Address Space를 갖는다.

QEMU Process

Host Virtual Address Space

┌──────────────────────────────┐
│                              │
│      Guest RAM Backing       │
│            8 GiB             │
│                              │
└──────────────────────────────┘

QEMU가 직접 "물리 주소 X부터 8 GiB를 달라"고 RAM hardware를 제어하는 것이 아니다.

QEMU memory도 일반 Host process memory처럼:

QEMU Host Virtual Address
        ↓
Host Page Table
        ↓
Host Physical Address

로 관리된다.


41. KVM_SET_USER_MEMORY_REGION

QEMU는 자신이 마련한 Host userspace memory 영역과 Guest GPA 범위의 관계를 KVM에 등록한다.

대표 ioctl:

KVM_SET_USER_MEMORY_REGION

개념적으로 전달하는 정보:

Guest GPA Range
      ↕
QEMU Host Virtual Address Range

예:

Guest GPA

0x00000000
      │
      │ 8 GiB
      ▼
...

       ↕ backing

QEMU HVA

0x7f0000000000
      │
      │ 8 GiB
      ▼
...

역할을 정리하면:

QEMU
→ Guest RAM을 위한 Host userspace backing 제공

KVM
→ Guest memory region 및 virtualization mapping 관리

CPU
→ 실제 runtime address translation 수행

QEMU가 매 memory access마다 EPT를 software로 검색하는 것이 아니다.


42. Configured Memory와 실제 Physical RAM 사용량은 같지 않을 수 있다

VM에 16 GiB를 설정했다고 해서 모든 일반 구성에서 시작 순간 실제 Host RAM 16 GiB가 반드시 모두 즉시 물리적으로 점유되는 것은 아니다.

Configured Memory
        ≠
Guest가 현재 실제 사용하는 Memory
        ≠
Host에서 현재 resident한 Physical Memory

Host의 demand paging, backing 종류, HugeTLB, memory locking, preallocation, overcommit 정책 등에 따라 실제 physical backing 시점과 방식이 달라질 수 있다.

따라서 "VM RAM 16GiB = Host RAM에서 고정된 연속 16GiB"라고 단순화하면 안 된다.


43. Guest Page Table 자체도 메모리에 있다

Nested translation에서 중요한 점이다.

GVA
 ↓
Guest Page Table
 ↓
GPA
 ↓
EPT
 ↓
HPA

그런데 Guest Page Table 자체도 Guest Physical Memory에 저장된 자료구조다.

따라서 CPU가 Guest page-table entry를 읽는 과정에서도 그 entry가 저장된 GPA를 실제 HPA로 변환해야 한다.

개념적으로:

GVA
 ↓
Guest Page Table Walk
 │
 │ Page Table 자체가 Guest Memory에 존재
 └────→ EPT를 이용해 실제 RAM에서 entry를 읽음
 ↓
GPA 획득
 ↓
EPT
 ↓
HPA

그래서 nested page-table walk는 비용이 있고 TLB가 중요하다.


44. 정상 Memory Access는 매번 VM Exit하지 않는다

CPU 가상화에서 VM Exit을 배웠다고 해서 Guest RAM 접근을 다음처럼 생각하면 안 된다.

잘못된 이해

Guest Memory Access
       ↓
VM Exit
       ↓
KVM
       ↓
RAM

정상 mapping이 존재하면 CPU hardware가 직접 translation을 수행한다.

Guest instruction
       ↓
CPU MMU / TLB
       ↓
Guest Page Table + EPT
       ↓
HPA
       ↓
Physical RAM

따라서 정상적인 Guest RAM 접근마다 QEMU/KVM userspace/kernel software 경로를 왕복하지 않는다.


45. Guest Page Fault

Guest Page Fault는 첫 번째 translation 단계에서 발생한다.

GVA
 ↓
Guest Page Table
 ↓
현재 접근을 완료할 수 없음
 ↓
Guest #PF
 ↓
Guest Kernel Page Fault Handler

예를 들어 Guest process가 아직 physical page가 붙지 않은 virtual-memory 영역에 처음 접근할 수 있다.

Keycloak
 ↓
새 Virtual Memory 영역에 첫 접근
 ↓
Guest Page Table
 ↓
현재 usable physical mapping 없음
 ↓
Page Fault
 ↓
Guest Kernel
 ↓
Page 확보 / mapping 갱신
 ↓
Instruction 재시도

Page Fault 자체가 프로그램 오류를 뜻하지 않는다.


46. Page Fault의 대표적인 원인

46.1 Demand Paging

Virtual Memory 영역 존재
 ↓
아직 physical page가 필요하지 않았음
 ↓
첫 실제 접근
 ↓
Page Fault
 ↓
Guest Kernel이 page 준비

46.2 Swap-in

필요한 page가 Guest RAM에 없음
 ↓
Page Fault
 ↓
Guest Kernel
 ↓
Guest Swap에서 읽음
 ↓
RAM 복원
 ↓
Page Table 갱신

46.3 Permission Fault

Page Table Entry에는 mapping뿐 아니라 permission도 있다.

Physical Frame: 1234
Present:        1
Writable:       0
Executable:     0

read-only page에 write하면 fault가 발생할 수 있다.

46.4 Copy-on-Write

write fault를 의도적으로 이용하여 page를 복제하고 새로운 writable mapping을 만드는 메커니즘도 존재한다.

46.5 Invalid Access

Guest Kernel이 정상적인 mapping으로 해결할 수 없는 잘못된 process access라면 SIGSEGV 등으로 이어질 수 있다.

Invalid GVA
 ↓
Page Fault
 ↓
Guest Kernel
 ↓
해결 불가
 ↓
SIGSEGV

따라서:

Page Fault ≠ Segmentation Fault

다.


47. EPT Violation

이번에는 Guest Page Table translation은 성공했다고 하자.

GVA
 ↓
Guest Page Table
 ↓
GPA

그런데 해당 GPA에 대한 second-stage 접근을 현재 EPT 조건으로 완료할 수 없다.

GPA
 ↓
EPT
 ↓
Violation

이것이 EPT Violation이다.

GVA
 ↓
Guest Page Table
 ↓
GPA              ← Guest translation 성공
 ↓
EPT
 ↓
EPT Violation
 ↓
VM Exit
 ↓
KVM

EPT Violation은 Guest Page Fault와 발생 계층이 다르다.


48. Guest Page Fault와 EPT Violation 비교

항목 Guest Page Fault EPT Violation
문제 위치 GVA → GPA GPA → HPA
관련 table Guest Page Table EPT
기본 관점 Guest Virtual Memory Virtualization Memory Mapping
주요 처리 계층 Guest Kernel VM Exit 후 KVM 측
앱 오류를 뜻하는가 반드시 아님 반드시 아님

핵심:

Guest Page Fault
→ Guest가 자기 virtual memory를 처리하는 사건

EPT Violation
→ second-stage virtualization translation에서 hypervisor 처리가 필요한 사건

49. Host Page Fault도 별도로 존재한다

QEMU도 Host의 일반 userspace process이므로 QEMU memory backing에는 Host virtual-memory 관리가 적용된다.

QEMU Host Virtual Address
        ↓
Host Page Table
        ↓
Host Physical Address

따라서 Host 측에서도 demand allocation, reclaim/swap 등의 이유로 page fault가 발생할 수 있다.

QEMU / Guest RAM Backing
        ↓
Host Virtual Memory
        ↓
Host Page Fault
        ↓
Host Kernel
        ↓
필요한 Host page 처리

즉 VM 메모리 분석에서는 적어도 다음을 구분해야 한다.

Guest Page Fault
Host Page Fault
EPT-related virtualization event

50. Huge Page가 필요한 이유

8 GiB를 모두 4 KiB page 단위로 표현하면:

8 GiB / 4 KiB
= 2,097,152 pages

2 MiB page라면:

8 GiB / 2 MiB
= 4,096 pages

1 GiB page라면:

8 GiB / 1 GiB
= 8 pages

큰 page는 더 적은 mapping으로 넓은 memory range를 표현할 수 있다.


51. Huge Page와 TLB Coverage

TLB entry 하나가 표현하는 page가 커지면 하나의 cached translation으로 더 넓은 주소 범위를 커버할 수 있다.

단순 예:

4 KiB page × 512 mappings
= 2 MiB coverage

2 MiB page × 512 mappings
= 1 GiB coverage

실제 CPU는 page size별 TLB 구조와 entry 수가 다르므로 이 숫자를 특정 CPU의 실제 TLB 용량으로 해석하면 안 된다.

핵심은:

Page Size ↑
    ↓
한 translation이 cover하는 범위 ↑
    ↓
TLB pressure 감소 가능

이다.

추가로 page-table entry 수와 page-table walk 부담도 줄어들 가능성이 있다.


52. VM에서 Huge Page를 볼 때 주의할 점

VM에는 두 translation 단계가 있다.

GVA
 │ Guest Page Table
 ▼
GPA
 │ EPT
 ▼
HPA

따라서 "Huge Page를 사용한다"는 말만으로는 부족하다.

  • Guest page-table 단계에서 큰 page를 사용하는가?
  • Host backing이 Huge Page인가?
  • EPT mapping에서 큰 mapping을 활용하는가?

등을 구분해야 한다.

Guest와 Host의 page-size 선택을 하나의 동일한 설정으로 취급하면 안 된다.


53. THP: Transparent Huge Pages

THP는 Linux가 가능한 memory 영역에 대해 Huge Page를 투명하게 활용하려는 기능이다.

Application
 ↓
일반 malloc()/mmap()
 ↓
Linux Kernel
 ↓
조건이 맞으면 Huge Page 활용 시도

상태 확인:

cat /sys/kernel/mm/transparent_hugepage/enabled

예:

always [madvise] never

현재 정책은 kernel/distribution/Host 설정에 따라 다르므로 실제 시스템에서 확인한다.


54. THP의 Trade-off

Huge Page에는 큰 contiguous physical-memory 영역이 필요하다.

2 MiB는 4 KiB page 512개 크기다.

4 KiB × 512 = 2 MiB

memory fragmentation이 심하면 Kernel이 compaction 등의 작업을 수행할 수 있다.

Huge Page 필요
      ↓
큰 contiguous memory 필요
      ↓
Fragmentation
      ↓
Compaction 가능
      ↓
Latency 영향 가능

따라서 THP는 항상 성능을 높인다고 단정할 수 없다. 특히 latency-sensitive workload에서는 측정이 필요하다.


55. HugeTLB

HugeTLB는 명시적인 Huge Page pool을 사용할 수 있는 Linux 메커니즘이다.

THP:

Application
 ↓
일반 Memory Allocation
 ↓
Kernel이 자동적으로 Huge Page 활용

HugeTLB:

관리자가 Huge Page Pool 준비
 ↓
Application / VM이 명시적으로 사용

예:

Physical RAM

┌──────────────────────────┐
│ Normal Memory            │
├──────────────────────────┤
│ HugeTLB Pool             │
│ 2 MiB                    │
│ 2 MiB                    │
│ 2 MiB                    │
│ ...                      │
└──────────────────────────┘

사전 확보를 통해 예측 가능성을 높일 수 있지만 일반 memory allocation의 유연성이 감소하는 trade-off가 있다.


56. THP와 HugeTLB 비교

항목 THP HugeTLB
관리 Kernel의 투명한 활용 명시적 pool
애플리케이션 개입 상대적으로 적음 명시적 구성 가능
유연성 상대적으로 높음 상대적으로 낮음
사전 예약 핵심 방식 아님 가능
compaction 영향 발생 가능 사전 확보로 일부 상황 회피 가능
VM RAM backing 사용 가능 명시적으로 사용 가능

Host 확인:

grep -i huge /proc/meminfo
cat /sys/kernel/mm/transparent_hugepage/enabled

AnonHugePagesHugePages_Total은 같은 의미가 아니다.


57. Memory Overcommit

예를 들어:

Host Physical RAM = 32 GiB

VM1 configured = 16 GiB
VM2 configured = 16 GiB
VM3 configured = 16 GiB

Total configured = 48 GiB

Guest configured memory 총량이 Host physical RAM보다 크다.

이 구성이 가능할 수 있는 이유는 configured capacity와 현재 실제 working set/resident memory가 같지 않을 수 있기 때문이다.

예:

VM1 configured 16G → actual working set 약 5G
VM2 configured 16G → actual working set 약 4G
VM3 configured 16G → actual working set 약 3G

Total working set 약 12G

하지만 모든 VM의 실제 demand가 동시에 증가하면 문제가 발생한다.


58. CPU Overcommit과 Memory Overcommit의 차이

CPU:

CPU 부족
 ↓
Scheduler가 execution time을 나눔
 ↓
Runnable task가 기다림

Memory:

RAM 부족
 ↓
"현재 존재해야 하는 page를 어디에 둘 것인가?"

따라서 Memory pressure에서는 reclaim, swap, ballooning, OOM 등의 추가 메커니즘이 필요하다.

Memory Overcommit은 CPU Overcommit과 동일한 성격의 자원 공유가 아니다.


59. Host Memory Pressure와 Reclaim

Host RAM 수요가 실제 available physical memory에 접근하면 Linux는 memory reclaim을 시도한다.

Memory Pressure 증가
        ↓
Reclaim
        ↓
회수 가능한 cache/page 처리
        ↓
필요하면 anonymous memory swap
        ↓
그래도 부족
        ↓
심각한 pressure / OOM 가능

File-backed clean page

원본이 storage에 있으므로 RAM에서 버리고 필요할 때 다시 읽을 수 있다.

Clean File-backed Page
 ↓
Reclaim
 ↓
RAM에서 제거
 ↓
나중에 Storage에서 다시 읽음

dirty page라면 필요한 writeback 과정이 먼저 필요할 수 있다.

Anonymous page

heap/stack 등의 anonymous memory는 backing file의 원본을 단순히 다시 읽을 수 없으므로 swap 같은 backing이 필요할 수 있다.


60. Host Swap이 VM에 미치는 영향

Guest RAM backing의 Host physical page가 swap-out될 수 있는 구성이라고 하자.

Guest는 단순히 RAM에 접근한다고 생각한다.

Keycloak
 ↓
Guest Memory Load

하지만 Host에서는:

Guest Memory Access
       ↓
필요한 Host backing page가 RAM에 없음
       ↓
Host Page Fault
       ↓
Swap-in I/O
       ↓
Physical RAM으로 복원
       ↓
Guest 실행 계속

가 될 수 있다.

즉 Guest 관점의 RAM access가 Host에서는 storage I/O를 기다리는 상황으로 바뀔 수 있다.


61. Guest Swap과 Host Swap

Guest Swap:

Guest Application
      ↓
Guest Memory Pressure
      ↓
Guest Kernel
      ↓
Guest Swap
      ↓
/dev/vda
      ↓
virtio-blk
      ↓
QEMU
      ↓
Host Storage

Host Swap:

Guest RAM
   ↓
QEMU Memory Backing
   ↓
Host Memory Pressure
   ↓
Host Kernel
   ↓
Host Swap

따라서:

Guest Swap ≠ Host Swap

이다.

Guest가 메모리 여유가 있어 보이는데 Host에서 swap/reclaim이 심할 수도 있다.


62. Memory Pressure와 Storage Contention의 연결

Guest와 Host가 동시에 memory pressure를 겪으면 다음 I/O가 한 storage device로 몰릴 수 있다.

Guest Swap I/O ────────┐
Host Swap I/O ─────────┼──→ Physical NVMe
Database I/O ──────────┤
Filesystem Writeback ──┘

따라서:

Host Memory Pressure
       ↓
Reclaim / Swap
       ↓
Storage I/O 증가
       ↓
Storage Contention
       ↓
DB latency 증가
       ↓
Application latency 증가

가 가능하다.

CPU 사용률이 낮다고 해서 memory/storage 문제가 없는 것은 아니다.


63. Swap Used만 보고 장애를 판단하면 안 된다

예:

Swap Used = 2 GiB

만으로 현재 memory pressure가 심하다고 단정할 수 없다. 과거에 swap-out된 cold page가 남아 있을 수도 있다.

더 중요한 질문:

현재 swap-in/out이 지속되는가?
reclaim pressure가 증가하는가?
major fault가 증가하는가?
storage latency가 같이 증가하는가?

Guest와 Host를 동시에 확인해야 한다.

free -h
vmstat 1

64. Ballooning이 필요한 이유

Host는 QEMU의 Guest RAM backing을 볼 수 있지만 Guest 내부에서 어떤 memory가 중요한지 완전히 알지 못한다.

Guest는 다음 semantics를 알고 있다.

Guest Memory

├─ Application Working Set
├─ JVM Heap
├─ Page Cache
├─ Free
└─ 기타

Host가 무작정 Guest backing을 swap-out하기보다 Guest Kernel과 협력해 불필요한 memory를 반환받는 것이 유리할 수 있다.

대표적인 메커니즘이 virtio-balloon이다.


65. virtio-balloon 구조

             Guest VM

Guest Kernel
     │
virtio-balloon Driver
     │
   virtqueue

════════ VM Boundary ════════

     │
QEMU virtio-balloon Device
     │
     ▼
Host Memory Management

virtio-balloon은 Guest RAM 자체를 제공하는 장치가 아니다. 이미 존재하는 Guest RAM backing을 Host/Guest가 협력하여 회수/반환하는 데 사용하는 가상 장치다.


66. Balloon Inflate

Host가 Guest memory를 회수하려고 할 때 balloon을 inflate한다.

Host/QEMU
   │
   │ Balloon target 조정
   ▼
virtio-balloon
   │
════════ VM Boundary ═══════
   │
   ▼
Guest Balloon Driver
   │
   │ Guest pages 확보
   ▼
Guest usable memory 감소

Guest 안의 balloon이 커지기 때문에 Guest가 사용할 수 있는 RAM이 줄어든다.

Before

┌──────────────────────────┐
│       Guest Usable       │
│          Memory          │
└──────────────────────────┘


After Inflate

┌──────────────────────────┐
│       Guest Usable       │
│          Memory          │
├──────────────────────────┤
│         Balloon          │
└──────────────────────────┘

개념:

Balloon Inflate
→ Guest usable memory ↓
→ Host가 회수할 수 있는 backing memory ↑

67. Balloon Page 반환의 의미

Guest balloon driver는 Guest pages를 확보하고 관련 정보를 Host 측에 전달한다.

Guest

GPA Page A
GPA Page B
GPA Page C
      │
      ▼
Balloon Driver
      │
      │ virtio
══════╪════════════
      ▼
QEMU / Host
      │
      ▼
해당 backing memory를
회수할 기회

정확한 Host-side release 동작은 QEMU/KVM 버전, backing 종류 및 설정에 따라 달라질 수 있다.

핵심은 Guest가 이 page들을 일반적인 Guest workload가 사용하지 않도록 확보하고 Host에 그 사실을 알려준다는 것이다.


68. Balloon Deflate

Host가 Guest에게 memory를 다시 제공할 수 있으면 balloon target을 줄인다.

Host/QEMU
   ↓
Balloon target 감소
   ↓
Guest Balloon Driver
   ↓
Balloon pages 반환
   ↓
Guest usable memory 증가

따라서:

Inflate = Guest usable memory 감소
Deflate = Guest usable memory 증가

다.


69. Ballooning을 과도하게 하면 Guest가 압박을 받는다

Guest application working set이 큰데 balloon을 과도하게 inflate하면:

Balloon Inflate
      ↓
Guest Available Memory 감소
      ↓
Guest Memory Pressure
      ↓
Guest Reclaim
      ↓
Page Cache 회수
      ↓
Guest Swap
      ↓
심하면 Guest OOM

이 될 수 있다.

Host RAM을 확보하려는 조치가 Guest storage I/O와 application latency를 증가시킬 수 있다는 뜻이다.


70. Ballooning과 Memory Hotplug

Ballooning:

기존 Guest Memory Capacity
        ↓
그 범위에서 Host/Guest 간
usable memory를 회수/반환

Memory Hotplug:

기존 Guest RAM
      +
추가 Memory Device/Region
      ↓
Guest가 추가 capacity 인식

따라서:

Ballooning ≠ Memory Hotplug

다.

현대 가상화에서는 virtio-mem 같은 다른 동적 memory 관리 방식도 존재하므로 모든 동적 VM memory 관리를 ballooning 하나로 일반화하면 안 된다.


71. OOM

Linux가 memory allocation을 만족시키지 못하고 reclaim 등의 방법으로도 필요한 memory를 확보하지 못하면 OOM 상황이 발생할 수 있다.

Memory Allocation 필요
       ↓
Reclaim 등 시도
       ↓
충분한 Memory 확보 실패
       ↓
OOM
       ↓
OOM Killer
       ↓
Process 선택/종료 가능
       ↓
Memory 확보

72. Guest OOM과 Host OOM

Guest OOM:

Guest RAM 부족
     ↓
Guest Kernel OOM
     ↓
Guest Process Kill

예: Keycloak process 종료

Host OOM:

Host Physical RAM 부족
      ↓
Host Kernel OOM
      ↓
Host Process Kill 가능

Host OOM에서 QEMU가 victim이 되면:

QEMU process killed
      ↓
해당 VM 전체가 중단

될 수 있다.

따라서:

Guest OOM ≠ Host OOM

이다.

또한 cgroup memory limit이 있는 환경에서는 Host 전체 RAM이 남아 있어도 해당 cgroup boundary에서 OOM이 발생할 수 있으므로 OOM의 발생 계층을 확인해야 한다.


73. NUMA

지금까지는 RAM을 하나의 균일한 자원처럼 표현했다. multi-socket/NUMA 시스템에서는 어느 CPU가 어느 RAM에 접근하느냐에 따라 비용이 달라질 수 있다.

NUMA Node 0                    NUMA Node 1

CPU Socket 0                  CPU Socket 1
├─ Cores                      ├─ Cores
└─ Local RAM                  └─ Local RAM

             Interconnect

NUMA = Non-Uniform Memory Access.


74. Local Memory와 Remote Memory

Local:

NUMA Node 0

CPU
 │
 ▼
Node 0 RAM

Remote:

NUMA Node 0                    NUMA Node 1

CPU
 │
 └──────── Interconnect ───────→ RAM

일반적으로 remote access는 local access와 동일한 비용이라고 가정할 수 없으며 추가 latency/bandwidth 비용이 있을 수 있다.


75. vCPU와 NUMA의 연결

Guest vCPU는 Host에서 QEMU의 vCPU thread다.

Guest vCPU
    ↓
QEMU vCPU Thread
    ↓
Host Linux Scheduler
    ↓
Host Logical CPU

VM1의 vCPU thread가 Node 0 CPU에서 실행되는데 VM1의 Host physical backing page가 Node 1에 있다면:

Node 0 CPU
    │
    │ Remote Access
    ▼
Node 1 RAM

이 될 수 있다.

Guest에서는 단순한 memory load지만 실제 hardware에서는 NUMA interconnect를 건널 수 있다.


76. vCPU Pinning만으로는 NUMA 최적화가 끝나지 않는다

예:

VM1 vCPU
 ↓
Node 0 CPU에 Pinning

VM1 RAM
 ↓
Node 1에 주로 배치

이면 pinning 이후에도 remote memory access가 많아질 수 있다.

따라서:

vCPU Placement
      +
Memory Placement/Binding
      ↓
NUMA Locality

를 함께 봐야 한다.

이상적인 예:

NUMA Node 0

CPU 0 ← VM1 vCPU0
CPU 1 ← VM1 vCPU1
CPU 2 ← VM1 vCPU2
CPU 3 ← VM1 vCPU3

VM1 Memory Backing
→ Node 0 RAM

77. Guest NUMA

큰 VM에서는 Guest에게 NUMA topology 자체를 노출할 수 있다.

예:

Guest VM

Guest NUMA Node 0
├─ vCPU 0~7
└─ RAM 32 GiB

Guest NUMA Node 1
├─ vCPU 8~15
└─ RAM 32 GiB

Host:

Host NUMA Node 0
├─ Physical CPUs
└─ RAM

Host NUMA Node 1
├─ Physical CPUs
└─ RAM

가능하면 Guest가 인식하는 topology와 실제 Host placement가 합리적으로 대응되도록 구성할 수 있다.

Guest NUMA 0 → Host NUMA 0
Guest NUMA 1 → Host NUMA 1

78. NUMA는 실제 장비 topology부터 확인한다

Host가 NUMA node 1개라면 cross-node remote-memory 문제가 주요 이슈가 아닐 수 있다.

lscpu

예:

NUMA node(s):          2
NUMA node0 CPU(s):     0-7
NUMA node1 CPU(s):     8-15

추가:

numactl --hardware

QEMU process별 memory distribution:

numastat -p <QEMU_PID>

vCPU placement:

virsh vcpupin <VM_NAME>
virsh vcpuinfo <VM_NAME>

실제 환경에서는 먼저 topology를 측정하고 NUMA 최적화 필요성을 판단한다.


79. 전체 Memory Virtualization 실행 경로

최종적으로 Guest application의 memory access는 다음 구조로 이해할 수 있다.

                       Guest

                Keycloak / PostgreSQL
                         │
                         │ GVA
                         ▼
                       TLB
                    ┌────┴────┐
                    │         │
                   HIT       MISS
                    │         │
                    │    Page-table walk
                    │         │
                    └────┬────┘
                         ▼
                 Guest Page Table
                         │
                  Guest #PF 가능
                         │
                         ▼
                        GPA
                         │
════════════════════ VM Boundary ════════════════════
                         │
                        EPT
                         │
                EPT Violation 가능
                         │
                         ▼
                        HPA
                         │
                         ▼
                 Host Physical Page
                         │
                  ┌──────┴──────┐
                  │             │
            NUMA Node 0   NUMA Node 1
                RAM           RAM

정상 mapping/TLB 상태에서는 memory access마다 QEMU나 KVM software가 직접 데이터 경로를 처리하지 않는다. CPU MMU가 hardware-assisted translation을 수행한다.


80. 전체 Memory Virtualization 관리 경로

실행 경로와 관리 경로를 분리해야 한다.

User
 ↓
virsh
 ↓
libvirt
 ↓
QEMU
 │
 ├─ Guest RAM backing
 ├─ QEMU HVA
 ├─ virtio-balloon device
 │
 └─ ioctl(KVM_SET_USER_MEMORY_REGION)
          ↓
         KVM
          │
          ├─ Guest memory slots/regions 관리
          └─ EPT 관련 virtualization mapping 관리
                    ↓
                   CPU

즉:

  • virsh/libvirt: VM configuration/management
  • QEMU: Guest RAM Host userspace backing 및 device 구성
  • KVM: Guest memory region과 hardware virtualization 연계
  • CPU MMU/EPT hardware: runtime translation

으로 구분한다.


81. CPU / Network / Storage / Memory 연결

VM을 전체적으로 보면:

                         VM

                   Guest Application
                         │
          ┌──────────────┼──────────────┐
          │              │              │
         CPU           Network        Storage
          │              │              │
        vCPU         virtio-net     virtio-blk
          │          virtqueue      virtqueue
          │              │              │
══════════╪══════════════╪══════════════╪══════════
          │              │              │
      QEMU/KVM       vhost/QEMU      QEMU Block
          │              │              │
          ▼             TAP          qcow2/raw
      Host CPU            │              │
                         Bridge       Host Block
                                          │
                                          ▼
                                         NVMe

Memory는 이 모든 실행을 받친다.

Guest GVA
   ↓
Guest Page Table
   ↓
GPA
   ↓
EPT
   ↓
HPA
   ↓
Host RAM / NUMA

그리고 memory pressure는 storage path까지 영향을 줄 수 있다.

Memory Pressure
 ↓
Reclaim / Swap
 ↓
Storage I/O
 ↓
Storage Contention
 ↓
Application Latency

CPU placement는 NUMA memory locality와 연결된다.

vCPU Pinning
      +
Memory Placement
      ↓
Local / Remote Memory Access

82. 핵심 Claim Registry

CLAIM-MEM-01

Guest application은 일반적으로 Host physical address를 직접 사용하지 않는다.

GVA → GPA → HPA

두 단계의 translation을 거친다.

CLAIM-MEM-02

Guest Page Table은 GVA → GPA mapping을 Guest OS 관점에서 관리한다.

CLAIM-MEM-03

Intel EPT는 GPA → HPA second-stage translation을 hardware-assisted virtualization으로 지원한다.

CLAIM-MEM-04

정상적인 Guest RAM access마다 VM Exit이나 QEMU userspace 처리가 발생하는 것은 아니다.

CLAIM-MEM-05

QEMU는 Guest RAM을 위한 Host userspace backing을 마련하고 KVM에 Guest memory region을 등록한다.

CLAIM-MEM-06

Configured Guest RAM과 Host에서 현재 실제 resident한 physical memory는 항상 동일하지 않다.

CLAIM-MEM-07

TLB Miss와 Page Fault는 다른 사건이다.

CLAIM-MEM-08

Guest Page Fault와 EPT Violation은 서로 다른 translation 단계에서 발생한다.

CLAIM-MEM-09

Page Fault 자체는 프로그램 오류를 의미하지 않는다. Demand paging/COW/swap-in 등 정상 memory management에서도 발생할 수 있다.

CLAIM-MEM-10

Huge Page는 더 넓은 memory range를 하나의 mapping으로 표현하여 TLB/page-table 효율을 개선할 가능성이 있다.

CLAIM-MEM-11

THP와 HugeTLB는 같은 방식이 아니다. THP는 투명한 활용을 지향하고 HugeTLB는 명시적인 huge-page pool을 제공한다.

CLAIM-MEM-12

Memory Overcommit은 CPU Overcommit과 성격이 다르다. RAM pressure에서는 reclaim/swap/ballooning/OOM이 개입할 수 있다.

CLAIM-MEM-13

Guest Swap과 Host Swap은 서로 다른 계층에서 발생한다.

CLAIM-MEM-14

Host memory pressure는 swap/writeback을 통해 storage contention과 application latency를 악화시킬 수 있다.

CLAIM-MEM-15

virtio-balloon은 Guest와 Host가 memory 회수/반환에 협력하기 위한 가상 장치이며 RAM 자체를 제공하는 장치는 아니다.

CLAIM-MEM-16

Balloon inflate가 과도하면 Guest reclaim/swap/OOM을 유발할 수 있다.

CLAIM-MEM-17

Guest OOM과 Host OOM은 영향 범위가 다르다. Host OOM에서 QEMU가 종료되면 VM 전체가 중단될 수 있다.

CLAIM-MEM-18

NUMA 시스템에서는 vCPU placement와 memory placement를 함께 봐야 한다.


83. 실제 환경에서 확인할 OPEN QUESTION

아래 항목은 개념적으로 단정하지 않고 실제 테스트 서버에서 확인해야 한다.

OQ-1. Host의 실제 NUMA topology는 무엇인가?

lscpu
numactl --hardware

확인할 것:

  • NUMA node 수
  • node별 CPU
  • node별 memory
  • node distance

OQ-2. 각 VM의 configured/current memory는 얼마인가?

virsh dominfo <VM_NAME>
virsh dumpxml <VM_NAME>
virsh dommemstat <VM_NAME>

Guest:

free -h
cat /proc/meminfo

Host의 QEMU process 상태와 비교한다.


OQ-3. QEMU process의 Host resident memory는 어떻게 분포하는가?

ps -ef | grep qemu
ps -o pid,rss,vsz,cmd -p <QEMU_PID>

필요하면:

cat /proc/<QEMU_PID>/status
cat /proc/<QEMU_PID>/smaps_rollup

configured memory와 RSS/anonymous/huge-page 상태를 비교한다.


OQ-4. Host THP 정책은 무엇인가?

cat /sys/kernel/mm/transparent_hugepage/enabled
grep -i huge /proc/meminfo

확인할 것:

  • THP policy
  • AnonHugePages
  • HugePages_Total
  • HugePages_Free
  • Hugepagesize

OQ-5. VM RAM이 HugeTLB로 명시적으로 backing되어 있는가?

virsh dumpxml <VM_NAME>

libvirt memory backing 관련 설정을 확인하고 Host /proc/meminfo, QEMU smaps 계열과 교차 검증한다.


OQ-6. Guest와 Host에서 현재 swap이 발생하는가?

Guest:

free -h
vmstat 1

Host:

free -h
vmstat 1

단순 swap-used 값보다 현재 swap-in/out activity와 memory pressure를 함께 본다.


OQ-7. Host memory pressure가 Guest latency에 영향을 주는가?

실험 개념:

Baseline
 ↓
Guest Application Latency 측정
 ↓
Host Memory Pressure 유도
 ↓
Host reclaim/swap 관측
 ↓
Guest latency 재측정

동시에 CPU와 storage도 관측한다.


OQ-8. virtio-balloon이 VM에 구성되어 있는가?

virsh dumpxml <VM_NAME>

Guest에서도 관련 driver/device 상태를 확인한다.

환경에 따라 driver 이름과 표시 방식이 달라질 수 있으므로 실제 장비에서 검증한다.


OQ-9. Balloon target 변화가 Guest available memory에 어떻게 반영되는가?

관측:

Host/libvirt memory setting
        ↓
Guest free -h / /proc/meminfo
        ↓
Guest reclaim/swap 변화

과도한 ballooning 시 Guest latency/swap/OOM 가능성을 별도 실험한다.


OQ-10. VM vCPU는 어느 Host CPU에 배치되어 있는가?

virsh vcpuinfo <VM_NAME>
virsh vcpupin <VM_NAME>

CPU 가상화 SSOT의 pinning/overcommit 관측과 연결한다.


OQ-11. QEMU memory는 어느 NUMA node에 배치되어 있는가?

numastat -p <QEMU_PID>

vCPU placement와 비교한다.

vCPU → Node 0
Memory → Node 0

인지,

vCPU → Node 0
Memory → Node 1

인지 확인한다.


OQ-12. NUMA remote access가 실제 workload latency에 의미 있는 영향을 주는가?

NUMA node가 2개 이상인 경우에만 우선순위를 높인다.

Local placement baseline
        ↓
Latency / throughput / memory metrics
        ↓
Remote-heavy placement
        ↓
동일 workload 비교

단순 topology만 보고 성능 문제라고 단정하지 않는다.


OQ-13. Guest Page Fault가 workload 변화와 함께 증가하는가?

Guest에서 page-fault 관련 지표를 관측하고 다음을 분리한다.

정상 demand paging?
COW?
Guest swap-in?
application working-set 증가?

Page Fault 증가만으로 오류라고 판단하지 않는다.


OQ-14. Host Page Fault/major fault와 storage latency가 상관되는가?

Host memory pressure 실험 시:

Host Fault
   +
Swap activity
   +
Storage latency
   +
Guest application latency

를 같은 시간축으로 비교한다.


84. 권장 실험 순서

개념 검증은 다음 순서가 좋다.

1. Host Physical Memory / NUMA 확인
   ↓
2. VM configured memory 확인
   ↓
3. Guest free/meminfo 확인
   ↓
4. QEMU RSS/HVA backing 상태 확인
   ↓
5. THP/HugeTLB 상태 확인
   ↓
6. Guest/Host vmstat 동시 관측
   ↓
7. Balloon device/config 확인
   ↓
8. vCPU placement 확인
   ↓
9. QEMU NUMA memory distribution 확인
   ↓
10. Memory pressure 실험
   ↓
11. Guest/Host swap 및 storage latency 비교
   ↓
12. 필요 시 NUMA locality 실험

85. 실험 시 반드시 같이 기록할 것

각 실험은 다음 조건을 남긴다.

Host
├─ CPU model
├─ Core / Thread 수
├─ RAM
├─ NUMA topology
├─ Swap 설정
├─ Kernel version
├─ THP policy
└─ Physical storage

VM
├─ vCPU
├─ Configured RAM
├─ Current RAM
├─ Memory backing 설정
├─ Balloon device
├─ Guest swap
└─ Guest kernel

Workload
├─ Application
├─ Heap/Memory 설정
├─ Request concurrency
├─ DB workload
└─ 측정 시간

조건을 남기지 않으면 "Memory pressure에서 느려졌다"는 결과를 다른 환경에 재사용하기 어렵다.


86. 문제를 진단할 때의 분류

Memory latency 또는 OOM이 보이면 한 번에 "메모리 부족"이라고 결론내리지 않는다.

문제
 │
 ├─ Guest Virtual Memory?
 │    ├─ Page Fault
 │    ├─ Guest reclaim
 │    ├─ Guest swap
 │    └─ Guest OOM
 │
 ├─ Virtualization Translation?
 │    ├─ EPT-related event
 │    ├─ TLB pressure
 │    └─ Huge-page/mapping 특성
 │
 ├─ Host Memory?
 │    ├─ Host reclaim
 │    ├─ Host swap
 │    ├─ Host major fault
 │    └─ Host OOM
 │
 ├─ Dynamic Memory?
 │    ├─ Balloon target
 │    ├─ Guest pressure
 │    └─ Hotplug/virtio-mem 여부
 │
 └─ NUMA?
      ├─ vCPU placement
      ├─ memory placement
      └─ remote access

87. 최종 기준 그림

Memory Virtualization을 한 장으로 기억할 때는 다음 그림을 기준으로 한다.

                         [Guest Userspace]

                    Keycloak / PostgreSQL
                             │
                             │ GVA
                             ▼

                         [Guest Kernel]

                       Guest TLB
                             │
                     TLB Miss 가능
                             │
                             ▼
                    Guest Page Table
                             │
                     Guest #PF 가능
                             │
                             ▼
                            GPA

══════════════════════ VM Boundary ══════════════════════

                             │
                             ▼

                        [KVM / CPU]

                            EPT
                             │
                   EPT Violation 가능
                             │
                             ▼
                            HPA

                         [Host RAM]

                  Host Physical Memory
                             │
                    ┌────────┴────────┐
                    │                 │
               NUMA Node 0       NUMA Node 1
                    │                 │
                    └────────┬────────┘
                             │
                       Physical RAM

관리 경로는 별도로 기억한다.

virsh
 ↓
libvirt
 ↓
QEMU
 │
 │ Guest RAM backing
 │ KVM_SET_USER_MEMORY_REGION
 ▼
KVM
 │
 │ EPT 관련 mapping 관리
 ▼
CPU MMU

그리고 자원 압박 경로:

Host Memory Pressure
        │
        ├─ Reclaim
        ├─ Swap
        ├─ Ballooning
        │     ↓
        │ Guest Pressure
        │     ↓
        │ Guest Swap / OOM
        │
        └─ Host OOM

Memory Pressure
        ↓
Storage I/O 증가 가능
        ↓
Storage Contention
        ↓
Application Latency

88. 결론

KVM/QEMU Memory Virtualization을 이해할 때 핵심은 "VM에 RAM을 몇 GB 줬다"를 하나의 단순한 물리 RAM 할당으로 보지 않는 것이다.

실제 구조에는 다음 계층이 있다.

Guest Process
 ↓
GVA
 ↓
Guest Page Table
 ↓
GPA
 ↓
EPT
 ↓
HPA
 ↓
Host Physical RAM

Guest OS는 자신의 virtual-memory와 GPA 공간을 관리하고, QEMU는 Guest RAM의 Host userspace backing을 마련하며, KVM은 이를 virtualization memory region과 연결한다. 정상 runtime translation은 CPU MMU와 EPT hardware가 수행한다.

성능과 장애를 볼 때는 그 위에 다음 요소가 추가된다.

TLB / Page-table Walk
Huge Page / THP / HugeTLB
Guest Page Fault
EPT Violation
Host Page Fault
Memory Overcommit
Reclaim
Guest Swap / Host Swap
virtio-balloon
Guest OOM / Host OOM
NUMA Locality

따라서 실제 테스트 서버에서는 Guest 하나의 free -h만 보고 메모리 상태를 판단하지 않는다.

Guest → QEMU → Host → NUMA → Storage 영향을 같은 시간축에서 관측해야 한다.

이 문서의 개념 부분은 SSOT로 고정하고, 실제 서버에 종속되는 설정과 동작은 OQ-1~OQ-14를 실험하여 CASE로 전환한다.

제3부 — 네트워크 가상화

89. 문서 목적

이 문서는 KVM/QEMU 기반 VM 환경에서 Guest 애플리케이션의 네트워크 요청이 Guest Kernel, virtio-net, virtqueue, vhost-net, TAP, Linux Bridge/라우팅, Physical NIC를 거쳐 외부 네트워크로 나가고 다시 들어오는 구조를 SSOT로 정리한다.

현재 실험 목적은 Host에 VM 2대를 구성하고 각 VM 안의 K3s/Keycloak 노드를 이용해 다음을 검증하기 위한 기반을 만드는 것이다.

  • Keycloak 멀티 노드 구성
  • 동일 세션/동일 Refresh Token의 동시 갱신
  • Refresh Token 경쟁
  • 세션/토큰 상태를 PostgreSQL 또는 Redis에 공유할 때의 동작
  • 단일 저장소를 여러 Keycloak 노드가 공유할 때의 경합과 일관성
  • Host Nginx → VM → K3s → Keycloak 요청 경로
  • 네트워크 계층 문제와 애플리케이션/저장소 문제의 분리

이 문서는 네트워크 가상화 자체에 초점을 둔다.


90. virsh / libvirt / virtio 구분

90.1 virsh

virsh는 사용자가 libvirt에 VM 관리 명령을 전달하는 CLI다.

virsh list --all
virsh start vm1
virsh shutdown vm1
virsh domiflist vm1
virsh net-list --all

virsh는 packet datapath에 직접 참여하지 않는다.

User
 ↓
virsh
 ↓
libvirt
 ↓
QEMU

90.2 libvirt

libvirt는 VM lifecycle 및 configuration을 관리하는 소프트웨어/API 계층이다.

관리 대상 예:

vCPU
Memory
Disk
NIC model
MAC address
Virtual network
Bridge
QEMU arguments

90.3 virtio

virtio는 명령어가 아니다.

또한 하나의 단일 프로그램이나 단일 커널 모듈을 의미하지 않는다.

Virtio는 Guest와 Host/Hypervisor가 가상 I/O 장치를 효율적으로 사용하기 위한 표준화된 인터페이스/프로토콜이다.

대표적인 virtio 장치:

virtio-net       Network
virtio-blk       Block I/O
virtio-scsi      SCSI
virtio-balloon   Memory Balloon

이 문서에서는 virtio-net을 다룬다.


91. virtio-net은 정확히 어디에 있는가

virtio-net을 하나의 위치에 존재하는 하나의 프로세스로 보면 안 된다.

가상 NIC를 성립시키는 구현이 Guest와 Host에 나누어져 있다.

Guest 측

Guest Kernel
  ├─ TCP/IP Stack
  ├─ virtio-net Frontend Driver
  └─ virtqueue

Host 측

Host Userspace
  └─ QEMU virtio-net Device Model

Host Kernel
  ├─ vhost-net   (사용하는 경우)
  ├─ TAP
  ├─ Linux Bridge / Routing / NAT
  └─ Physical NIC Driver

따라서 virtio는 특정 "커널 계층" 자체가 아니라 Guest frontend와 Host backend 사이의 I/O 계약이다.


92. Frontend와 Backend

       Guest                         Host

virtio-net Frontend
      Driver
        │
        ↓
    virtqueue
        │
        │ Virtio protocol
        │
        └──────────────→ Backend
                         ├─ QEMU
                         └─ vhost-net
  • Frontend: Guest Kernel의 virtio-net driver
  • Backend: Guest가 전달한 packet buffer를 Host 쪽에서 처리하는 구현
  • Backend는 QEMU userspace 또는 vhost-net kernel backend가 될 수 있다.

93. Guest OS는 왜 QEMU가 아니라 virtio-net을 사용하는가

물리 서버에서는:

Application
 ↓
Linux TCP/IP Stack
 ↓
Physical NIC Driver
 ↓
Physical NIC

VM에서는:

Application
 ↓
Guest TCP/IP Stack
 ↓
virtio-net Driver
 ↓
Virtual NIC

이다.

Guest는 "QEMU를 호출한다"가 아니라 "내 NIC를 사용한다"고 동작한다.

VM 시작 시 QEMU가 Guest에게 virtio 방식의 virtual NIC를 노출한다.

QEMU
 ↓
Virtual PCI Bus에 virtio NIC 노출
 ↓
Guest Linux
 ↓
virtio device 발견
 ↓
virtio-net driver bind
 ↓
ens3 / eth0 형태의 network interface 생성

Guest에서 확인:

lspci
ip link
ip addr

94. 전체 네트워크 계층

가장 기본적인 virtio-net + vhost-net + TAP + Linux Bridge 구조를 기준으로 한다.

수신 방향

Internet / Client
        ↓
Physical NIC
        ↓
Physical NIC Driver
        ↓
Linux Bridge / Routing / NAT
        ↓
TAP
        ↓
vhost-net
        ↓
RX virtqueue
        ↓
virtio-net Frontend Driver
        ↓
Guest TCP/IP Stack
        ↓
Socket
        ↓
Keycloak

송신 방향

Keycloak
   ↓
Socket
   ↓
Guest TCP/IP Stack
   ↓
virtio-net Frontend Driver
   ↓
TX virtqueue
   ↓
vhost-net
   ↓
TAP
   ↓
Linux Bridge / Routing / NAT
   ↓
Physical NIC Driver
   ↓
Physical NIC
   ↓
Network

실제 환경은 Bridge, NAT, Routed Network, macvtap, SR-IOV, VFIO passthrough, Open vSwitch, Kubernetes CNI 등에 따라 달라질 수 있다.


95. Physical NIC의 역할

NIC는 Network Interface Card다.

Physical NIC는 실제 네트워크 링크와 서버를 연결하는 하드웨어다.

Network
 ↓
Physical NIC
 ↓
NIC Driver
 ↓
Linux Kernel

Linux에서:

ip link

등으로 enp3s0, eno1, eth0 같은 interface를 확인할 수 있다.

주의:

Physical NIC hardware
≠
Linux interface object

NIC hardware를 Host Kernel의 NIC driver가 제어하고 Linux가 network interface로 노출한다.


96. Linux Bridge의 역할

Linux Bridge는 Host Kernel 안의 L2 software switch다.

VM1 TAP ──┐
          │
VM2 TAP ──┼── br0 ── Physical NIC
          │
Host NIC ─┘

Bridge는 Ethernet frame의 Destination MAC을 보고 어느 port로 전달할지 결정한다.

핵심 역할:

L2 forwarding
MAC learning
Frame forwarding
Multiple virtual/physical ports 연결

확인:

bridge link
bridge fdb show
ip link show type bridge

97. Routing의 역할

Routing은 Bridge와 다르다.

Bridge
  → L2
  → MAC 기반
  → 같은 Ethernet network 연결

Routing
  → L3
  → IP 기반
  → 서로 다른 IP network 사이 연결

Linux routing table 확인:

ip route

Routing은 destination IP를 보고 어느 interface 또는 next-hop으로 packet을 보낼지 결정한다.


98. NAT의 역할

NAT는 packet의 IP/Port 정보를 변환한다.

예:

VM
192.168.122.10
 ↓
Host NAT
 ↓
203.0.113.10
 ↓
Internet

VM이 private subnet을 쓰는 경우 Host가 NAT gateway처럼 동작할 수 있다.

따라서 실제 VM network를 분석할 때 다음을 구분해야 한다.

Bridge 기반인가?
Routing 기반인가?
NAT 기반인가?

99. TAP의 역할

TAP은 Host Linux Kernel이 제공하는 가상 Ethernet network interface다.

물리 장치가 아니다.

예:

tap0
vnet0

역할:

VM의 Ethernet frame과 Host Linux networking을 연결하는 접점

Guest Virtual NIC
 ↓
virtio backend
 ↓
TAP
 ↓
Host Linux Network

수신:

Linux Bridge
 ↓
TAP
 ↓
VM

송신:

VM
 ↓
TAP
 ↓
Linux Bridge

확인:

ip link
ip tuntap show
bridge link
virsh domiflist <domain>

100. virtqueue의 역할

virtqueue는 NIC가 아니며 Linux network interface도 아니다.

virtqueue는 Guest와 Host backend가 I/O buffer를 주고받기 위한 descriptor 기반 shared queue 구조다.

네트워크에서는 보통 TX/RX queue를 사용한다.

TX virtqueue
Guest → Host

RX virtqueue
Host → Guest

개념:

Guest RAM

Packet Buffer
    ↑
    │ descriptor
    │
virtqueue
    │
    ↓
Host Backend

핵심은 packet payload를 매번 전통적인 userspace API 호출로 전달하는 방식이 아니라 Guest memory buffer와 descriptor를 효율적으로 공유/참조하도록 설계되어 있다는 점이다.


101. Guest TCP/IP Stack의 역할

Guest TCP/IP Stack은 Guest Linux Kernel의 실제 network stack이다.

VM이라고 해서 TCP/IP stack이 가짜인 것은 아니다.

Guest Kernel에는 실제로 다음이 존재한다.

Socket
TCP
UDP
IP
Routing
Neighbor/ARP
Firewall
Network Driver

101.1 Socket

Application과 Kernel network stack 사이의 인터페이스다.

대표 API:

socket()
bind()
listen()
accept()
connect()
send()
recv()

Keycloak은 Ethernet frame이나 virtqueue를 직접 다루지 않는다.

101.2 TCP

TCP의 대표 책임:

Connection 관리
Port
Sequence
순서 보장
재전송
중복 처리
Flow Control
Congestion Control

예:

Source Port:      53021
Destination Port: 8080

101.3 IP

IP 계층은 IP 주소와 routing을 담당한다.

예:

Source IP:      192.168.122.10
Destination IP: 192.168.122.20

확인:

ip addr
ip route

NIC에 가까운 계층에서는 Ethernet frame과 MAC address를 다룬다.

확인:

ip neigh

102. Packet이 Keycloak까지 올라오는 과정

Ethernet Frame
 ↓
IP Packet
 ↓
TCP Segment / Stream
 ↓
Socket
 ↓
HTTP
 ↓
Keycloak

Keycloak은 다음을 직접 알 필요가 없다.

virtqueue
vhost-net
TAP
Bridge
Physical NIC

Keycloak은 Guest Linux가 제공하는 TCP socket 위에서 HTTP 요청을 처리한다.


103. QEMU virtio Device Model의 역할

QEMU의 virtio Device ModelHost Userspace의 QEMU process 내부에 존재한다.

여기서 역할을 두 개로 분리해야 한다.

역할 A. 장치 생성/설정/관리

QEMU
 ↓
virtio-net Device Model 생성
 ↓
Guest에게 device 노출
 ↓
feature negotiation
 ↓
virtqueue 설정
 ↓
backend 연결

이 역할은 QEMU가 담당한다.

역할 B. 실제 Packet Datapath 처리

QEMU backend를 직접 사용하는 경우

TAP
 ↓
QEMU virtio backend
 ↓
virtqueue
 ↓
Guest

vhost-net을 사용하는 경우

TAP
 ↓
vhost-net
 ↓
virtqueue
 ↓
Guest

반복적인 packet I/O를 Host Kernel에서 처리하고 QEMU userspace를 우회한다.


104. 왜 TAP → vhost-net → QEMU → virtqueue라고 일반화하면 안 되는가

다음 그림:

TAP
 ↓
vhost-net
 ↓
QEMU
 ↓
virtqueue

은 모든 packet이 vhost-net → QEMU 순으로 반드시 지나가는 것처럼 보인다.

하지만 vhost-net의 중요한 목적 중 하나는 packet datapath에서 QEMU userspace를 우회하는 것이다.

vhost-net 사용 시 fast path는 다음처럼 이해한다.

TAP
 ↓
vhost-net
 ↓
virtqueue
 ↓
Guest

QEMU는 사라지는 것이 아니라 device lifecycle과 configuration을 관리한다.


105. Control Path와 Data Path

Control / Setup Path

virsh
 ↓
libvirt
 ↓
QEMU
 ↓
virtio-net Device Model
 ↓
feature negotiation
virtqueue setup
vhost-net setup

여기서 control은 Kubernetes Control Plane을 뜻하지 않는다.

일반적인 시스템 용어로 설정/제어 경로라는 의미다.

Data Path

실제 packet이 반복적으로 흐르는 경로다.

vhost-net 사용 시:

Physical NIC
 ↓
Bridge / Routing
 ↓
TAP
 ↓
vhost-net
 ↓
virtqueue
 ↓
virtio-net Frontend
 ↓
Guest TCP/IP
 ↓
Application

106. QEMU가 Userspace인데 packet이 QEMU를 안 거칠 수 있는 이유

QEMU가 장치의 생성/관리 주체라는 것과 모든 packet의 runtime datapath를 QEMU가 처리한다는 것은 다른 의미다.

CPU 가상화와 비교하면 이해하기 쉽다.

CPU

QEMU
 ↓
vCPU 생성/관리

실제 Guest instruction 실행
 ↓
KVM / VMX

QEMU가 vCPU를 만든다고 Guest의 ADD, MOV, SUB를 전부 QEMU가 실행하는 것은 아니다.

Network

QEMU
 ↓
virtio-net 생성/관리

실제 반복 packet I/O
 ↓
vhost-net / virtqueue

QEMU가 virtual NIC를 만든다고 packet 100만 개를 반드시 QEMU가 하나씩 처리할 필요는 없다.


107. vhost-net 최적화

QEMU userspace가 packet마다 I/O를 처리하면 다음 전환 비용이 누적될 수 있다.

Host Kernel
 ↓
QEMU Userspace
 ↓
Host Kernel
 ↓
...

Packet rate가 높아질수록 userspace/kernel transition, scheduling, copy, notification 비용이 커질 수 있다.

QEMU userspace backend

TAP
 ↓
QEMU
 ↓
virtqueue

vhost-net kernel backend

TAP
 ↓
vhost-net
 ↓
virtqueue

핵심 최적화 방향:

Packet마다 QEMU userspace 개입
        ↓
Kernel backend로 hot path 이동
        ↓
Context switch / userspace overhead 감소

108. vhost-net은 QEMU를 제거하지 않는다

vhost-net 사용 시에도 QEMU는 필요하다.

QEMU의 역할:

VM lifecycle
Virtual hardware model
virtio device 생성
Feature negotiation
Queue configuration
Backend 연결
Device reset
Control/configuration handling

따라서:

vhost-net != QEMU 제거

정확히는:

vhost-net
=
QEMU가 담당하던 반복적인 virtio packet datapath의 상당 부분을
Host Kernel로 offload

라고 이해한다.


109. Fast Path와 Slow/Control Path

Fast Path

빈번하게 반복되는 packet forwarding/data transfer 경로다.

예:

TAP
 ↓
vhost-net
 ↓
virtqueue

Control/Slow Path

상대적으로 빈도가 낮고 설정/예외 처리를 담당한다.

예:

Device 초기화
Feature negotiation
Queue setup
Configuration change
Device reset

QEMU는 이 영역에 계속 중요한 역할을 한다.


110. Data Copy 최적화

네트워크 성능에서 중요한 비용 중 하나는 packet data copy다.

virtio/virtqueue/vhost 구조는 buffer descriptor를 이용해 불필요한 copy와 context switch를 줄이는 방향으로 설계되어 있다.

단, 이를 항상 zero-copy라고 일반화하면 안 된다.

실제 copy 여부는 다음에 따라 달라질 수 있다.

Kernel version
QEMU version
vhost configuration
offload
NIC capability
packet path
GSO/GRO/TSO

111. Interrupt / Notification 최적화

Guest와 Host는 queue에 새로운 packet/buffer가 있음을 서로 알려야 한다.

단순화:

Guest TX
 ↓
virtqueue descriptor 등록
 ↓
Host backend notification
 ↓
backend 처리

수신:

Host RX
 ↓
virtqueue에 buffer/data 반영
 ↓
Guest notification
 ↓
Guest driver 처리

Packet마다 과도한 interrupt/notification이 발생하면 overhead가 커질 수 있다.

따라서 batching, interrupt moderation, queueing이 중요하다.


112. Multi-Queue 최적화

하나의 queue만 사용하면 특정 vCPU/processing path에 부하가 몰릴 수 있다.

virtio-net은 multi-queue를 사용할 수 있다.

RX Queue 0 → vCPU 0
RX Queue 1 → vCPU 1
RX Queue 2 → vCPU 2
RX Queue 3 → vCPU 3

목적:

Packet processing 병렬화
Single queue bottleneck 완화
Multi-core 활용

효과는 workload, CPU affinity, IRQ placement, queue configuration에 따라 달라진다.


113. Offload 최적화

대표적인 offload:

TSO  - TCP Segmentation Offload
GSO  - Generic Segmentation Offload
GRO  - Generic Receive Offload
Checksum Offload

목적:

작은 packet을 하나씩 처리하는 CPU overhead 감소
Segmentation / aggregation 비용 절감

주의:

offload가 활성화되어 있으면 tcpdump에서 보이는 packet size나 checksum이 실제 wire에서 보이는 것과 다르게 보일 수 있다.


114. Linux Bridge가 항상 Host TCP/IP Stack을 거치는 것은 아니다

Bridge가 단순 L2 forwarding을 수행하는 경우 frame이 반드시 Host의 일반적인 L3 TCP/IP stack을 거치는 것은 아니다.

예:

VM1 TAP
 ↓
Linux Bridge
 ↓
VM2 TAP

반면 Host가 다음 역할을 하면 L3/Netfilter 경로가 개입한다.

Routing
NAT
Host-local termination
Firewall

따라서 다음을 고정된 packet path로 보면 안 된다.

Physical NIC
 ↓
Host TCP/IP Stack
 ↓
Bridge

실제 경로는 bridge/routing/NAT 구성에 따라 달라진다.


115. Host Physical NIC로 나갈 때 virtio를 다시 거치지 않는다

Guest
virtio-net
 ↓
vhost-net
 ↓
TAP
 ↓
Linux Bridge
 ↓
Intel NIC Driver
 ↓
Intel Physical NIC

즉:

Guest virtio
→ Host virtio
→ Physical NIC

구조가 아니다.

virtio는 Guest virtual I/O device와 Host backend 사이의 인터페이스다.


116. 현재 Keycloak/K3s 테스트 환경과 연결

Client
 ↓
Host Physical NIC
 ↓
Host Nginx
 ↓
Host Network
 ↓
VM1 / VM2
 ↓
K3s
 ↓
Keycloak Node 1 / 2

VM network까지 펼치면:

Client
 ↓
Physical NIC
 ↓
Host Network Stack / Bridge / Route / NAT
 ↓
TAP(vm1) / TAP(vm2)
 ↓
vhost-net
 ↓
virtqueue
 ↓
virtio-net
 ↓
Guest Network Stack
 ↓
K3s networking
 ↓
Keycloak

이후 K3s 내부에는 CNI, Service, Pod network가 추가되므로 별도 계층으로 분석한다.


117. 이 구조에서 발생할 수 있는 문제

117.1 TAP/Bridge 연결 오류

증상:

VM 외부 통신 불가
Host ↔ VM 통신 불가
특정 VM만 통신 불가

확인:

ip link
bridge link
bridge fdb show
virsh domiflist <vm>

117.2 Routing 오류

증상:

같은 subnet은 통신되지만 다른 subnet은 안 됨
gateway까진 되지만 외부 통신 실패

확인:

ip route
ip rule

117.3 NAT/Firewall 오류

증상:

VM → Internet 실패
외부 → VM 접근 실패
특정 port만 실패

확인 대상:

nftables
iptables
NAT rules
IP forwarding

117.4 vhost-net 미사용 또는 비효율적 datapath

높은 packet rate에서 QEMU userspace가 datapath를 직접 처리하면 CPU overhead가 커질 수 있다.

관찰:

QEMU CPU usage
vhost thread
packet rate
latency
context switch

117.5 Single Queue Bottleneck

하나의 queue/vCPU에 packet processing이 집중될 수 있다.

확인 대상:

virtio multi-queue
IRQ distribution
per-vCPU CPU usage
RSS/RPS/XPS

117.6 Offload 때문에 packet capture가 예상과 다르게 보임

원인 후보:

GSO
GRO
TSO
Checksum offload

117.7 Host CPU Contention으로 network latency 증가

vhost-net, QEMU thread, softirq도 Host CPU를 사용한다.

따라서 network 문제처럼 보여도 CPU scheduling 문제일 수 있다.


118. 실제 Linux에서 확인할 명령어

Physical NIC

ip link
ip addr
ethtool <interface>

Linux Bridge

ip link show type bridge
bridge link
bridge fdb show

TAP / vnet

ip link
ip tuntap show

libvirt VM NIC

virsh domiflist <domain>

libvirt network

virsh net-list --all
virsh net-info <network>
virsh net-dumpxml <network>

Routing

ip route
ip rule

Guest NIC

ip link
ip addr
ip route
ip neigh

virtio 장치

lspci
lsmod | grep virtio

vhost

lsmod | grep vhost

119. 실제 packet path 추적

Host:

sudo tcpdump -ni <physical-nic>
sudo tcpdump -ni <bridge>
sudo tcpdump -ni <tap-or-vnet>

Guest:

sudo tcpdump -ni <guest-interface>

예:

Physical NIC  O
Bridge        O
TAP           X

이면 Guest 내부보다 먼저 Host Bridge/TAP mapping을 의심한다.

TAP           O
Guest NIC     X

이면 virtio/vhost/Guest NIC 계층을 의심한다.

Guest NIC     O
Socket        X

이면 Guest routing/firewall/listen 상태를 의심한다.


120. Keycloak Refresh Token 실험과의 관계

Refresh Token 경쟁 자체는 virtio-net 문제가 아니다.

하지만 다음 경로를 공유하므로 network virtualization 문제가 실험 결과에 영향을 줄 수 있다.

Client
 ↓
Nginx
 ↓
VM1 / VM2
 ↓
K3s
 ↓
Keycloak
 ↓
PostgreSQL / Redis

예:

Node1 요청만 지연
VM2 packet loss
Host bridge misconfiguration
NAT/conntrack issue
Host CPU contention으로 vhost 처리 지연

이런 문제를 Refresh Token 경쟁이나 DB lock으로 오해하지 않도록 network path를 별도로 검증한다.


121. 이 SSOT에서 파생될 CONCEPT

CONCEPT

KVM/QEMU에서 Guest packet이 Host Physical NIC까지 이동하는 과정

포함 범위:

virsh
libvirt
QEMU
virtio
virtio-net
Frontend / Backend
virtqueue
QEMU virtio Device Model
vhost-net
TAP
Linux Bridge
Routing
NAT
Physical NIC
Guest TCP/IP Stack
Socket
Data Path / Control Path
Fast Path
Multi-Queue
Offload
Packet tracing

현재 단계에서는 이 요소들이 하나의 packet 실행 경로를 설명하므로 하나의 CONCEPT로 관리한다.


122. OPEN QUESTION

OQ-1. 현재 VM network는 Bridge, NAT, Routing 중 어떤 구조인가?

virsh net-list --all
virsh net-dumpxml <network>
ip link
bridge link
ip route

OQ-2. VM1/VM2의 TAP/vnet interface는 무엇인가?

virsh domiflist vm1
virsh domiflist vm2
ip link
bridge link

OQ-3. 현재 환경에서 vhost-net이 실제 사용되는가?

확인 후보:

lsmod | grep vhost

추가로 QEMU arguments와 libvirt domain XML을 확인한다.

OQ-4. QEMU backend와 vhost-net의 성능 차이가 현재 Host에서 관찰 가능한가?

비교:

Latency
Throughput
QEMU CPU
Host CPU
Context Switch
Packet rate

OQ-5. Multi-queue가 현재 virtio-net에 활성화되어 있는가?

확인 대상:

QEMU/libvirt NIC configuration
Guest ethtool
queue count
IRQ distribution

OQ-6. Host Nginx에서 VM1/VM2 Keycloak까지 실제 packet path는 무엇인가?

Host NIC, Bridge, TAP, Guest NIC에서 tcpdump로 추적한다.

OQ-7. Keycloak load test 시 network virtualization이 latency에 영향을 줄 정도로 Host CPU를 사용하는가?

관찰:

QEMU CPU
vhost thread
softirq
Host CPU
Guest CPU
network latency

123. OPEN QUESTION → CASE

SSOT
 ↓
CONCEPT
 ↓
OPEN QUESTION
 ↓
실제 packet capture / configuration 확인 / load test
 ↓
CASE

예:

CONCEPT
"vhost-net은 QEMU userspace를 우회해 packet datapath를 처리할 수 있다"
 ↓
OPEN QUESTION
"현재 테스트 Host에서 vhost-net이 실제 활성화되어 있는가?"
 ↓
CASE
"libvirt/QEMU virtio-net backend 구성 확인 및 vhost-net 사용 검증"

124. 핵심 Claim

  1. virsh는 VM/network management CLI이며 packet datapath에 직접 참여하지 않는다.
  2. libvirt는 VM lifecycle 및 NIC/network configuration을 관리한다.
  3. virtio는 단일 process나 단일 kernel module이 아니라 Guest frontend와 Host backend 사이의 가상 I/O 표준이다.
  4. virtio-net frontend driver는 Guest Kernel에 존재한다.
  5. QEMU virtio Device Model은 Host Userspace의 QEMU process 내부에서 virtual NIC의 생성, configuration, negotiation, lifecycle에 관여한다.
  6. virtqueue는 Guest와 Host backend가 I/O buffer를 주고받는 descriptor 기반 shared queue 구조다.
  7. vhost-net을 사용하지 않으면 QEMU userspace가 virtio network datapath backend 역할을 할 수 있다.
  8. vhost-net을 사용하면 반복적인 packet datapath의 상당 부분을 Host Kernel에서 처리해 QEMU userspace를 우회할 수 있다.
  9. 따라서 TAP → vhost-net → QEMU → virtqueue를 모든 환경의 일반적인 packet 경로로 표현하면 안 된다.
  10. TAP은 VM의 Ethernet frame과 Host Linux networking을 연결하는 Host-side virtual network interface다.
  11. Linux Bridge는 L2 software switch 역할을 하며 MAC 기반으로 frame을 forwarding한다.
  12. Routing은 L3/IP 기반으로 서로 다른 network 사이의 packet 경로를 결정한다.
  13. Host Physical NIC로 나갈 때 다시 virtio를 거치는 것이 아니라 Physical NIC의 실제 driver를 사용한다.
  14. Guest TCP/IP Stack은 실제 Guest Linux Kernel의 network stack이며 TCP, IP, routing, socket 등을 처리한다.
  15. Keycloak은 virtio/TAP/Bridge를 직접 알 필요가 없으며 Guest socket을 통해 network를 사용한다.
  16. vhost-net의 목적은 QEMU를 제거하는 것이 아니라 반복적인 hot datapath를 Kernel로 offload해 userspace/kernel 전환 및 packet processing overhead를 줄이는 것이다.
  17. Network virtualization 문제와 Keycloak Refresh Token 경쟁 문제는 별개지만 동일한 실험 환경에서 서로 비슷한 증상으로 보일 수 있으므로 계층별 관측이 필요하다.

125. 최종 기준 구조

Control / Setup

User
 ↓
virsh
 ↓
libvirt
 ↓
QEMU
 ↓
virtio-net Device Model
 ├─ virtual NIC 생성
 ├─ Guest 노출
 ├─ feature negotiation
 ├─ virtqueue 설정
 └─ vhost-net backend 설정

Data Path - vhost-net 사용

Internet / Client
        ↓
Physical NIC
        ↓
Physical NIC Driver
        ↓
Linux Bridge / Routing / NAT
        ↓
TAP
        ↓
vhost-net
        ↓
virtqueue
        ↓
virtio-net Frontend Driver
        ↓
Guest TCP/IP Stack
        ↓
Socket
        ↓
Keycloak

Data Path - QEMU backend 사용

Internet / Client
        ↓
Physical NIC
        ↓
Physical NIC Driver
        ↓
Linux Bridge / Routing / NAT
        ↓
TAP
        ↓
QEMU virtio backend
        ↓
virtqueue
        ↓
virtio-net Frontend Driver
        ↓
Guest TCP/IP Stack
        ↓
Socket
        ↓
Keycloak

126. 다음 실습 순서

1. Physical NIC 확인
2. libvirt virtual network 확인
3. Bridge/NAT/Route 확인
4. VM별 TAP/vnet 확인
5. virtio-net device 확인
6. vhost-net 사용 여부 확인
7. Guest NIC / route 확인
8. Host Nginx → VM packet path tcpdump
9. VM1 ↔ VM2 packet path 확인
10. Keycloak 요청 시 packet flow 확인
11. 부하 발생 시 QEMU/vhost CPU usage 비교
12. multi-queue / offload 확인

검증되지 않은 항목은 OPEN QUESTION으로 남기고 실제 결과가 확보되면 CASE로 전환한다.

그 다음에는 이 네트워크 가상화 위에 추가되는 K3s/CNI/Service/Pod network 계층을 연결한다.

제4부 — 스토리지 가상화

127. 문서 목적

이 문서는 QEMU/KVM 기반 VM에서 Guest 애플리케이션의 write()/fsync()가 실제 Host의 물리 SSD/NVMe까지 어떻게 내려가는지를 하나의 일관된 경로로 설명한다.

핵심 대상은 다음과 같다.

  • Guest VFS / ext4·XFS
  • Guest Page Cache / Writeback
  • Guest Block I/O Layer
  • /dev/vda
  • virtio-blk / virtqueue
  • QEMU virtio device/backend
  • qcow2 / RAW / Host block device
  • Host Page Cache / Direct I/O
  • Host Filesystem / Block Layer / blk-mq
  • I/O Scheduler
  • NVMe Driver / Physical NVMe
  • write(), fsync(), FLUSH
  • QEMU cache mode
  • Storage contention

이 문서는 Storage 가상화의 핵심 실행 경로와 운영상 중요한 문제를 다룬다. qcow2 내부 L1/L2 table, blk-mq tag allocator, NVMe submission/completion queue 같은 세부 구현은 필요 시 별도 문서에서 다룬다.


128. 전체 구조

                    [Guest Userspace]

                 PostgreSQL / Keycloak
                         │
                   read / write
                   fsync / sync
                         ▼

                     [Guest Kernel]

                        VFS
                         ↓
                    ext4 / XFS
                         ↓
                  Guest Page Cache
                         │
                     writeback
                         ↓
                 Guest Block Layer
                         │
               WRITE / FLUSH / etc.
                         ↓
                      /dev/vda
                         ↓
               virtio-blk Frontend
                         ↓
                     virtqueue

════════════════════ VM Boundary ════════════════════

                     [Host Userspace]

                        QEMU
                         │
                virtio device/backend
                         ↓
                 QEMU Block Layer
                         ↓
            ┌────────────┼─────────────┐
            ↓            ↓             ↓
          qcow2         RAW       Block Device
            │            │             │
            └────────────┼─────────────┘
                         ↓

                     [Host Kernel]

              Host Page Cache
              (cache mode에 따라)
                         ↓
                 Host Filesystem
                         ↓
                 Host Block Layer
                         ↓
                      blk-mq
                         ↓
                  I/O Scheduler
                         ↓
                   NVMe Driver
                         ↓

                       [Hardware]

                  NVMe Controller
                         ↓
              Device-side Cache
                         ↓
                Non-volatile Media

핵심 문장은 다음과 같다.

Guest는 /dev/vda를 실제 block device처럼 보지만, Host에서는 그 disk가 qcow2 파일, RAW 파일, 또는 실제 block device에 연결되어 있을 수 있다.


129. Guest Application: read() / write()에서 시작

VM 안의 PostgreSQL이나 Keycloak 같은 process는 SSD나 virtio-blk를 직접 다루지 않는다.

예를 들어 PostgreSQL이 파일에 데이터를 기록하면 개념적으로 다음 system call을 사용한다.

write(fd, buffer, size);
[Guest Userspace]

PostgreSQL
    │
    │ write()
    ▼

════════ System Call ════════

[Guest Kernel]

   VFS

즉 애플리케이션은 저장장치를 직접 조작하는 것이 아니라 Guest Linux Kernel에 파일 연산을 요청한다.

대표적인 파일 관련 system call:

open()
read()
write()
close()
fsync()

이 시점에는 아직 QEMU, qcow2, Host NVMe가 등장하지 않는다.


130. VFS: 공통 파일 인터페이스 계층

VFS(Virtual File System)는 Linux Kernel 내부에서 여러 filesystem을 동일한 API로 사용할 수 있도록 연결하는 공통 계층이다.

Guest가 ext4라면:

PostgreSQL
    ↓
write()
    ↓
VFS
    ↓
ext4

XFS라면:

PostgreSQL
    ↓
write()
    ↓
VFS
    ↓
XFS

VFS의 핵심 역할:

이 fd가 어떤 파일인가?
        ↓
이 파일은 어떤 filesystem에 속하는가?
        ↓
해당 filesystem 구현으로 연산 전달

VFS는 애플리케이션의 공통 파일 연산을 실제 filesystem 구현으로 연결한다.


131. Filesystem(ext4/XFS): 파일 세계를 block 공간에 배치

SSD는 /var/lib/postgresql/data 같은 디렉터리 구조를 모른다.

저장장치 입장에서는 결국 block 단위 공간이다.

Block 0
Block 1
Block 2
Block 3
...

하지만 사용자는 다음과 같이 파일과 디렉터리를 본다.

/
├── etc
├── home
└── var
    └── lib
        └── postgresql
            └── data

이 논리 구조를 제공하고 관리하는 것이 ext4/XFS 같은 filesystem이다.

Filesystem이 관리하는 대표 정보:

  • 파일 이름과 디렉터리 구조
  • 파일 크기
  • owner / permission
  • timestamp
  • inode / metadata
  • 파일 데이터가 저장될 block
  • free space
  • filesystem consistency

개념적으로:

사람/프로그램이 보는 세계

/var/lib/postgresql/data/users
             │
             ▼
          ext4/XFS
             │
             ▼
저장장치가 보는 세계

Block 8142
Block 8143
Block 9201
...

132. inode

inode는 Linux filesystem에서 파일 metadata와 저장 위치 정보를 관리하는 핵심 자료구조다.

"users.db"
    ↓
Directory Entry
    ↓
inode #1234
    │
    ├─ owner
    ├─ permission
    ├─ size
    ├─ timestamps
    └─ file data가 저장된 block 정보

파일 이름 자체와 inode는 같은 것이 아니다.

Storage 가상화를 이해하기 위해 inode 내부 구현까지 파고들 필요는 없지만, filesystem이 파일과 block을 연결한다는 점은 알아야 한다.


133. Page Cache: write()가 바로 SSD write는 아니다

일반적인 buffered I/O에서는 write()가 호출될 때마다 물리 SSD까지 즉시 내려갈 필요가 없다.

Application
    │
    │ write()
    ▼
Linux Kernel
    │
    ▼
Page Cache (RAM)
    │
    │ 나중에 writeback
    ▼
Filesystem / Block Layer
    ↓
SSD

예를 들어 storage에는 현재 ABC가 있는데 애플리케이션이 DEF를 추가했다고 하자.

Page Cache (RAM)
┌──────────────┐
│ ABCDEF       │ ← 최신 상태, dirty
└──────────────┘

SSD
┌──────────────┐
│ ABC          │ ← 아직 이전 상태
└──────────────┘

storage보다 최신인 Page Cache page를 dirty page라고 한다.

이후 kernel writeback이 실제 storage 쪽으로 내려간다.

Dirty Page
    ↓
Filesystem
    ↓
Block Layer
    ↓
Storage

따라서:

write() 성공
    ≠
Physical SSD 영속화 완료

이다.


134. Guest Block I/O Layer

현재 위치:

PostgreSQL
   ↓
write()
   ↓
VFS
   ↓
ext4
   ↓
Page Cache / Writeback
   ↓
Guest Block I/O Layer
   ↓
virtio-blk Driver

Filesystem은 파일과 block allocation을 관리하고, Linux Block I/O subsystem은 그 요청을 아래 block device driver가 처리할 수 있는 I/O 요청으로 전달·관리한다.

Filesystem 세계

/users/data.db
offset 8192에 4KB write
        │
        ▼
──────────────────────
   Block I/O Layer
──────────────────────
        │
        ▼
Block Device 세계

/dev/vda의 특정 위치에
READ / WRITE / FLUSH

대표 요청:

READ
WRITE
FLUSH
DISCARD

실제 Linux 내부에는 bio, request, queue, blk-mq 등이 존재한다.


135. /dev/vda: Guest가 보는 가상 Block Device

물리 머신에서는:

/dev/sda
/dev/nvme0n1

같은 block device가 보일 수 있다.

virtio-blk를 사용하는 VM에서는 흔히:

/dev/vda
/dev/vdb

처럼 보인다.

Guest에서:

lsblk

예시:

NAME   SIZE TYPE MOUNTPOINT
vda    100G disk
├─vda1   1G part /boot
└─vda2  99G part /

Guest Linux는 /dev/vda를 하나의 block device로 인식한다. 하지만 그것이 Host의 실제 SSD라는 뜻은 아니다.


136. /dev/vda와 Filesystem 관계

/dev/vda                 ← Virtual Block Device
  │
  └─ /dev/vda2           ← Partition
         │
         └─ ext4          ← Filesystem
               │
               └─ /

위에서 아래로 보면:

/
↓
ext4
↓
/dev/vda2
↓
/dev/vda

cd /var/lib/postgresql은 filesystem 세계를 보는 것이고, lsblk에서 vda를 보는 것은 block device 세계를 보는 것이다.


137. virtio-blk: Guest의 가상 Block Device Driver

Guest Kernel

ext4
 ↓
Block I/O Layer
 ↓
/dev/vda
 ↓
virtio-blk Driver

구분:

  • /dev/vda = Guest Linux에 보이는 block device
  • virtio-blk = 해당 virtual block device를 제어하는 Guest Kernel driver

Network와 비교:

Network
ens3
 ↓
virtio-net

Storage
/dev/vda
 ↓
virtio-blk

138. virtio-blk와 virtqueue

Guest Block Layer에서 다음과 같은 요청이 내려왔다고 하자.

/dev/vda의 특정 위치에 이 데이터를 WRITE하라.

virtio-blk driver는 이를 Virtio block request로 구성하고 virtqueue에 게시한다.

Guest Kernel

ext4
 ↓
Block I/O Layer
 ↓
/dev/vda
 ↓
virtio-blk
 ↓
virtqueue

Network에서:

TCP/IP Stack
 ↓
virtio-net
 ↓
virtqueue

였던 구조가 Storage에서도 반복된다.


139. virtqueue의 실제 의미

virtqueue를 단순한 "데이터 파이프"로 보면 부정확하다.

Guest memory에 I/O buffer가 있고 descriptor가 그 buffer를 가리킨다.

Guest RAM

┌────────────────────────┐
│ Write할 Data Buffer     │
│ "HELLO..."             │
└────────────────────────┘
           ▲
           │
    virtqueue descriptor
           │
           ▼
┌────────────────────────┐
│ Virtio Block Request   │
│ Operation: WRITE       │
│ Sector: ...            │
│ Data Buffer: ...       │
└────────────────────────┘

의미는 대략:

/dev/vda의 이 위치에 Guest RAM의 이 buffer를 기록해라.

이다.

처리가 끝나면 backend는 completion을 Guest에 돌려준다.


140. VM Boundary를 넘으면 QEMU가 등장

기본적인 QEMU 경로:

Guest
────────────────────────────
/dev/vda
   ↓
virtio-blk
   ↓
virtqueue
   │
════════ VM Boundary ════════
   │
   ▼
Host Userspace
────────────────────────────
QEMU
   │
   ├─ virtio-blk Device Model
   └─ Block Backend
          ↓
    vm1.qcow2
          ↓
Host Kernel
────────────────────────────
Host Filesystem
          ↓
Host Block Layer
          ↓
NVMe Driver
          ↓
Physical NVMe

QEMU는 Guest에게 virtual block device를 노출하고 Guest의 virtual I/O를 Host backend에 연결한다.


141. QEMU가 물리 SSD를 직접 제어하는 것은 아니다

backend가 qcow2 파일이라면 QEMU는 결국 Host Linux에 파일 I/O를 요청한다.

QEMU
 │
 │ pread/pwrite 등
 ▼
Host Kernel
 │
 ▼
Host Filesystem
 │
 ▼
Host Block Layer
 │
 ▼
NVMe Driver
 │
 ▼
Physical NVMe

즉 Guest storage stack 아래에 Host storage stack이 한 번 더 존재할 수 있다.


142. qcow2: Host에서는 파일, Guest에서는 디스크

예를 들어 Host에:

/var/lib/libvirt/images/keycloak-node1.qcow2

라는 파일이 있다고 하자.

Host 관점:

keycloak-node1.qcow2
"파일 하나"

Guest 관점:

/dev/vda
 ├─ /dev/vda1
 └─ /dev/vda2

즉:

Host 관점
────────────────────
vm1.qcow2
"파일"

Guest 관점
────────────────────
/dev/vda
"디스크"

둘 다 맞다.


143. qcow2 Virtual Size와 실제 Host 사용량

qcow2는 가상 disk size와 실제 Host 할당량이 다를 수 있다.

Guest가 보는 공간

/dev/vda
┌──────────────────────────────────────┐
│                100 GB                │
└──────────────────────────────────────┘

Host 실제 할당 공간

vm1.qcow2
┌──────┐
│ 3GB  │
└──────┘

Guest가 데이터를 기록하면서:

처음
Virtual 100GB
Actual 1GB

        ↓ Guest 데이터 기록

Virtual 100GB
Actual 10GB

        ↓ 더 기록

Virtual 100GB
Actual 40GB

처럼 실제 사용량이 늘 수 있다.

확인:

qemu-img info vm1.qcow2

virtual size와 실제 allocation을 구분해서 봐야 한다.


144. RAW Image

RAW는 qcow2보다 구조가 단순하다.

qcow2

Guest Block
    ↓
QEMU qcow2 mapping/metadata 처리
    ↓
qcow2 File I/O

RAW

Guest Block
    ↓
상대적으로 직접적인 offset 대응
    ↓
RAW File I/O

qcow2는 Copy-on-Write, sparse allocation, snapshot 등에 유리하지만 metadata/mapping 처리가 존재한다.

RAW는 상대적으로 단순하다.

다만:

RAW = 무조건 빠름
qcow2 = 무조건 느림

으로 일반화하면 안 된다.

실제 성능은 cache mode, storage backend, workload pattern, queue depth, snapshot chain, underlying filesystem, physical device 등에 영향을 받는다.


145. Host Block Device를 직접 backend로 사용 가능

반드시 파일일 필요는 없다.

Guest /dev/vda
      ↓
virtio-blk
      ↓
QEMU
      ↓
Host /dev/nvme0n1p3

따라서 Guest에 /dev/vda가 있다는 정보만으로 backend 구조를 알 수 없다.

/dev/vda
   ↓

 ┌─────────────┬─────────────┬──────────────────┐
 ↓             ↓             ↓
qcow2          RAW        Host Block Device
file           file       /dev/...

146. 실제 연결 확인

Guest:

lsblk

Host:

virsh domblklist <VM_NAME>

예시:

Target   Source
-----------------------------------------------
vda      /var/lib/libvirt/images/vm1.qcow2

그러면:

Guest                       Host

/dev/vda
   │
   │ virtio-blk
   ▼
QEMU
   │
   ▼
/var/lib/libvirt/images/vm1.qcow2

관계가 확인된다.


147. VM에서는 Page Cache가 두 번 나타날 수 있다

Guest buffered I/O + Host file-backed disk + Host Page Cache를 함께 사용하면:

                 Guest

PostgreSQL
    ↓
Guest ext4
    ↓
Guest Page Cache       ← 첫 번째
    ↓
Guest Block Layer
    ↓
virtio-blk
    ↓
virtqueue

══════════ VM Boundary ══════════

                 Host

QEMU
    ↓
vm1.qcow2
    ↓
Host Page Cache        ← 두 번째
    ↓
Host ext4/XFS
    ↓
Host Block Layer
    ↓
NVMe

같은 데이터가 Guest RAM과 Host RAM 양쪽에 cache될 수 있다.


148. write() 완료와 영속화는 다르다

PostgreSQL
   ↓
Guest Page Cache       ✓
   ↓
virtio                 ✓
   ↓
Host Page Cache        ✓

───────── Host 전원 장애 ─────────

Physical SSD           ✗

가능성이 있다.

따라서:

write() 완료
     ≠
writeback 완료
     ≠
fsync/flush 완료
     ≠
전원 장애에도 안전한 durability

이다.


149. Direct I/O

Buffered I/O:

QEMU
 ↓
Host Page Cache
 ↓
Host Filesystem
 ↓
Block Layer
 ↓
SSD

Direct I/O:

QEMU
 ↓
Host Filesystem / Block I/O Path
 ↓
Block Layer
 ↓
SSD

Linux의 O_DIRECT가 대표적으로 관련된다.

중요한 구분:

Direct I/O
   ≠
자동 durability 보장

Direct I/O의 핵심은 Page Cache 우회다.


150. fsync()가 필요한 이유

write(fd, data, size);

성공만으로 정전 이후 생존을 보장하지 않는다.

필요한 시점에:

fsync(fd);

를 통해 변경 내용을 필요한 영속성 경계까지 반영하도록 요청한다.

VM에서는:

PostgreSQL
     │
   fsync()
     ▼
Guest Filesystem
     │
     ▼
Guest Block Layer
     │
   FLUSH 등
     ▼
virtio-blk
     │
     ▼
QEMU / Backend
     │
     ▼
Host Storage Stack
     │
     ▼
Physical Storage

처럼 전체 stack으로 의미가 전달되어야 한다.


151. FLUSH

단순화하면:

WRITE
  ↓
"이 데이터를 써라"

FLUSH
  ↓
"앞서 쓴 데이터를 필요한 영속성 경계까지
반영하고 완료 상태를 보장해라"

이다.

실제 ordering/durability semantics는 더 복잡하지만 Storage 가상화에서는 이 구분이 핵심이다.


152. 가장 위험한 상황: 거짓 완료

Guest가:

WRITE
 ↓
FLUSH

를 요청했는데 실제 상태가:

Host RAM
┌──────────────┐
│ Data         │
└──────────────┘

Physical Storage
┌──────────────┐
│ Old Data     │
└──────────────┘

인데 Guest에게 FLUSH 완료라고 응답하면 문제가 된다.

PostgreSQL은 durability가 확보되었다고 판단할 수 있고, 직후 Host 전원이 나가면 RAM의 data가 사라진다.

이것은 성능 문제가 아니라 durability contract가 깨지는 correctness 문제다.


153. QEMU Cache Mode

QEMU/libvirt disk에서 대표적으로 볼 수 있는 설정:

cache=none
cache=writeback

이름만 보고:

none = cache 자체가 없음
writeback = 무조건 위험

이라고 해석하면 부정확하다.

핵심은 QEMU가 Host Page Cache와 write completion/flush semantics를 어떤 방식으로 사용할 것인가다.


154. cache=none

개념적으로 Host Page Cache를 우회하는 방향의 I/O 구성이다.

Guest Page Cache
      ↓
virtio
      ↓
QEMU
      ↓
Direct I/O 계열
      ↓
Host Filesystem / Block Path
      ↓
Storage

이중 caching을 줄일 수 있다.

하지만:

Host Page Cache 우회
      ≠
무조건 즉시 durable media 반영

이다.


155. cache=writeback

Host Page Cache를 사용할 수 있는 구성이다.

Guest
 ↓
virtio
 ↓
QEMU
 ↓
Host Page Cache
 ↓
writeback
 ↓
Physical Storage

일반 write는 Host RAM에서 빠르게 completion될 수 있다.

QEMU
 ↓
Host RAM에 기록
 ↓
WRITE completion

       ...

나중에

Host RAM
 ↓
Storage

하지만 cache=writeback 자체가 Guest의 fsync()/FLUSH를 무시한다는 뜻은 아니다.

정상적인 stack이라면:

Guest fsync / FLUSH
 ↓
virtio FLUSH
 ↓
QEMU/backend
 ↓
Host sync/flush
 ↓
Storage
 ↓
필요한 완료 확인
 ↓
Guest completion

으로 durability 요구가 전달되어야 한다.


156. writeback = 위험이라고 단정하면 안 되는 이유

정확한 표현:

writeback caching에서는 volatile cache가 존재할 수 있으므로, Guest의 flush/fsync semantics가 전체 backend/storage stack에서 올바르게 보존되는지가 중요하다.

Guest가 요구한 durability
        │
        ▼
Guest Filesystem
        │
        ▼
Guest Block Layer
        │
        ▼
virtio
        │
        ▼
QEMU/backend
        │
        ▼
Host Storage
        │
        ▼
Device

전체 chain에서 의미가 깨지지 않아야 한다.


157. Device-side Cache

Host Page Cache를 우회했다고 끝이 아니다.

QEMU
 ↓
Direct I/O
 ↓
Host Block Layer
 ↓
NVMe Driver
 ↓
NVMe Controller
 ↓
Device-side Cache
 ↓
Flash

Storage controller/device가 volatile write cache를 가질 수 있다.

따라서:

RAM에서 나갔다
    ≠
Device에 command가 전달됐다
    ≠
전원이 끊겨도 살아남는 상태가 됐다

이다.

실제 운영에서는 device flush/FUA semantics와 power-loss protection 여부도 중요할 수 있다.


158. Host Block Layer

qcow2/RAW file I/O는 Host Filesystem을 거쳐 실제 Host block I/O가 된다.

QEMU
 ↓
vm1.qcow2
 ↓
Host ext4/XFS
 ↓
Host Block Layer
 ↓
/dev/nvme0n1

Host Block Layer는 해당 I/O가 VM PostgreSQL에서 시작했는지 Host process에서 시작했는지를 본질적으로 구분해서 처리하는 계층이 아니다. 모두 Host block request다.


159. 여러 VM이 하나의 NVMe를 공유하면

VM1 QEMU ──┐
           │
VM2 QEMU ──┼──→ Host Block Layer → NVMe
           │
Nginx ─────┤
           │
Host 기타 ─┘

여러 source에서 동시에 I/O가 들어올 수 있다.

VM1
WRITE X
READ Y
WRITE Z

VM2
READ A
WRITE B

Host Process
READ C

이 요청들은 Host Block Layer queue에서 관리되고 device로 dispatch된다.


160. blk-mq: Multi-Queue Block Layer

현대 Linux에서는 blk-mq가 중요하다.

CPU0 ──→ Queue 0 ──┐
CPU1 ──→ Queue 1 ──┤
CPU2 ──→ Queue 2 ──┼──→ NVMe
CPU3 ──→ Queue 3 ──┘

NVMe는 높은 병렬성과 queue depth를 지원하기 때문에 여러 CPU가 병렬로 block I/O를 처리할 수 있는 구조가 중요하다.

Storage 처리 역시 CPU scheduling과 완전히 독립된 세계는 아니다.


161. I/O Scheduler

여러 I/O request가 있다고 해서 항상 들어온 순서 그대로 device에 전달되는 것은 아니다.

READ A
WRITE B
READ C
WRITE D
READ E
    ↓

┌─────────────────────┐
│    I/O Scheduler    │
│ 요청 dispatch 정책  │
└──────────┬──────────┘
           ↓
      Device Driver

대표적으로 볼 수 있는 scheduler:

none
mq-deadline
bfq

scheduler마다 목적과 정책이 다르다.


162. none

none은 복잡한 scheduling 정책을 최소화해서 비교적 직접 device 쪽으로 dispatch하는 방향이다.

NVMe처럼 device 자체가 강한 병렬성과 queueing 기능을 가진 경우 이러한 단순한 정책이 적합할 수 있다.

단:

none = block layer가 아무 일도 하지 않음

은 아니다.


163. 실제 I/O Scheduler 확인

Host:

cat /sys/block/nvme0n1/queue/scheduler

예시:

[none] mq-deadline

대괄호 안이 현재 선택된 scheduler다.

SATA/SCSI device라면:

cat /sys/block/sda/queue/scheduler

처럼 확인한다.


164. NVMe Driver와 Physical Device

Host Block Layer
      ↓
I/O Scheduler
      ↓
NVMe Driver
      ↓
NVMe Controller
      ↓
Physical Storage

NVMe Driver는 Host Linux Kernel의 device driver다.

Network에서 physical NIC driver가 하드웨어를 제어하는 것과 동일한 계층적 위치다.


165. NVMe와 SSD 구분

SSD는 저장장치의 넓은 종류이고, NVMe는 PCIe 기반 non-volatile storage를 위한 protocol/interface다.

SSD
├─ SATA SSD
│    └─ SATA/AHCI
│
└─ NVMe SSD
     └─ PCIe + NVMe

NVMe SSD:

Linux NVMe Driver
      ↓
PCIe
      ↓
NVMe Controller
      ↓
Flash

166. Storage I/O Completion

WRITE 요청은 아래로 내려가고, 완료는 반대 방향으로 올라온다.

Request:

Guest
  │
  │ WRITE
  ▼
virtio-blk
  ↓
virtqueue
  ↓
QEMU/backend
  ↓
Host Block Layer
  ↓
NVMe Driver
  ↓
NVMe

Completion:

NVMe
  │
  │ completion
  ▼
NVMe Driver
  ↓
Host Block Layer
  ↓
QEMU/backend
  ↓
virtqueue completion
  ↓
virtio-blk
  ↓
Guest Block Layer

따라서 virtqueue는 request뿐 아니라 completion 전달 구조까지 포함해서 이해해야 한다.


167. Storage Contention

여러 VM이 동일한 Physical NVMe를 사용하면 storage resource 경쟁이 발생할 수 있다.

VM1 PostgreSQL
      │
      ├────────┐
      │        │
VM2 Keycloak   │
      │        │
      ├────────┤
      │        ▼
      │   Host Block Layer
      │        ↓
      │    I/O Queue
      │        ↓
      └──────→ NVMe

VM1에서 대량 I/O가 발생하면 VM2의 storage latency가 증가할 수 있다.

CPU Contention
→ Host logical CPU 실행 시간 경쟁

Storage Contention
→ IOPS / bandwidth / queue / device 처리시간 경쟁

둘은 다른 자원 경쟁이다.


168. CPU가 정상이어도 Storage 때문에 느릴 수 있다

HTTP Request
 ↓
Keycloak
 ↓
PostgreSQL
 ↓
fsync()
 ↓
Storage

PostgreSQL이 storage completion을 기다리고 있으면 CPU usage가 높지 않을 수도 있다.

CPU 30%

그런데

Request latency 2초

가 가능하다.

따라서 CPU 지표만으로 latency 원인을 판단하면 안 된다.


169. Storage 관측 명령어

대표적인 device I/O 관측:

iostat -xz 1

확인 대상:

  • read/write throughput
  • IOPS
  • request latency
  • queue 상태
  • device utilization 성격의 지표

어떤 process가 I/O를 발생시키는지 볼 때:

iotop

Guest:

lsblk
mount
df -h
cat /proc/mounts
iostat -xz 1

Host:

virsh domblklist <VM_NAME>
qemu-img info <disk-image>
lsblk
cat /sys/block/<device>/queue/scheduler
iostat -xz 1
iotop

170. PostgreSQL 예시: WAL과 Durability

예를 들어:

BEGIN;

UPDATE users
SET balance = 1000
WHERE id = 1;

COMMIT;

을 생각한다.

PostgreSQL은 WAL 등의 durability protocol을 사용하며 필요한 시점에 storage synchronization을 수행한다.

PostgreSQL
     │
     │ WAL write
     ▼
Guest Page Cache
     │
     │ fsync 등
     ▼
Guest Filesystem
     ↓
Guest Block Layer
     ↓
virtio-blk
     ↓
QEMU
     ↓
Host Storage
     ↓
Physical Storage
     │
     │ completion
     ▼
PostgreSQL

"필요한 durability 조건 충족"
        ↓
COMMIT 성공 처리

VM storage layer가 flush/fsync semantics를 제대로 보존하지 않으면 PostgreSQL의 durability assumption과 실제 storage behavior가 어긋날 수 있다.


171. 성능과 Durability의 Trade-off

모든 write에서 storage synchronization을 기다리면 latency가 커질 수 있다.

WRITE
 ↓
Storage까지 동기화
 ↓
completion 대기

특히 DB workload에서는 fsync() latency가 transaction latency와 연결될 수 있다.

더 적극적인 caching
        ↓
write latency 개선 가능

하지만

durability semantics를 반드시 보존해야 함

fsync()를 없애서 빨라졌다면 그것이 최적화가 아니라 durability contract를 제거한 것일 수 있다.


172. Storage Virtualization Canonical Flow

                    [Guest Userspace]

                 PostgreSQL / Keycloak
                         │
                 read()/write()
                     fsync()
                         ▼

                     [Guest Kernel]

                        VFS
                         ↓
                    ext4 / XFS
                         ↓
                  Guest Page Cache
                         │
                     writeback
                         ↓
                 Guest Block Layer
                         ↓
                     /dev/vda
                         ↓
               virtio-blk Frontend
                         ↓
                     virtqueue

════════════════════ VM Boundary ════════════════════

                     [Host Userspace]

                        QEMU
                         ↓
                 QEMU Block Backend
                         ↓

             qcow2 / RAW / Host Block Device
                         ↓

                     [Host Kernel]

              Host Page Cache
              (설정에 따라 우회 가능)
                         ↓
                 Host Filesystem
                         ↓
                 Host Block Layer
                         ↓
                      blk-mq
                         ↓
                  I/O Scheduler
                         ↓
                   NVMe Driver

                       [Hardware]

                 NVMe Controller
                         ↓
              Device-side Cache
                         ↓
               Non-volatile Media

Completion:

Physical Storage
       ↑
   completion
       ↑
NVMe Driver
       ↑
Host Block Layer
       ↑
QEMU/backend
       ↑
virtqueue
       ↑
virtio-blk
       ↑
Guest Block Layer
       ↑
Filesystem
       ↑
Application

173. Network Virtualization과 비교

Network Storage
virtio-net virtio-blk
packet block I/O request
TX/RX virtqueue I/O virtqueue
TAP / network backend QEMU block backend
Linux Bridge/Route Host filesystem/block stack
Physical NIC Physical SSD/NVMe
Guest TCP/IP Stack Guest VFS/Filesystem/Block Layer
send/recv read/write/fsync

이 표는 학습용 대응 관계이며 각 요소가 1:1로 같은 종류라는 뜻은 아니다.


174. 핵심 Claim

Claim 1

Guest의 /dev/vda는 Guest가 보는 virtual block device다. 실제 Host backend는 qcow2, RAW, Host block device 등이 될 수 있다.

Claim 2

virtio-blk + virtqueue가 Guest block I/O를 Host backend와 연결한다.

Claim 3

qcow2가 Host filesystem 위의 파일이면 Guest filesystem 아래에 Host filesystem/storage stack이 한 번 더 존재한다.

Claim 4

Guest와 Host 양쪽에 Page Cache가 존재할 수 있다. Direct I/O와 QEMU cache mode는 Host Page Cache 사용 방식과 연결된다.

Claim 5

write() 완료와 durability는 같은 의미가 아니다.

write()
≠
writeback
≠
fsync/flush 완료
≠
전원 장애에도 안전한 상태

Claim 6

Storage 성능은 Guest 내부만으로 결정되지 않는다. QEMU/backend, Host block queue, I/O scheduler, NVMe, cache, 다른 VM의 storage load가 함께 영향을 준다.


175. 실제 테스트 서버에서 확인할 Open Questions

OQ-1. VM의 /dev/vda는 어떤 Host backend에 연결되어 있는가?

Guest:

lsblk

Host:

virsh domblklist <VM_NAME>

OQ-2. Backend는 qcow2인가 RAW인가?

qemu-img info /path/to/disk-image

OQ-3. qcow2 Virtual Size와 실제 Host 사용량은 얼마나 다른가?

qemu-img info <image>
du -h <image>
ls -lh <image>

세 명령이 보여주는 의미가 서로 다를 수 있으므로 비교한다.

OQ-4. QEMU disk cache mode는 무엇인가?

virsh dumpxml <VM_NAME>

disk driver 설정의 cache 관련 값을 확인한다.

OQ-5. qcow2가 최종적으로 어느 Host block device 위에 있는가?

lsblk
findmnt

OQ-6. Host I/O Scheduler는 무엇인가?

cat /sys/block/<device>/queue/scheduler

OQ-7. VM1 Storage load가 VM2 latency에 영향을 주는가?

VM1에서 별도의 테스트 파일/디스크로 controlled I/O load를 발생시키고 VM2의 application latency와 Host storage 지표를 동시에 본다.

OQ-8. Guest fsync() latency와 Host storage latency가 같이 증가하는가?

Guest application/DB latency와 Host iostat를 시간축으로 함께 관찰한다.


176. 권장 실습 흐름

1. Guest에서 /dev/vda 확인
      ↓
2. Host에서 virsh domblklist로 backend 확인
      ↓
3. qemu-img info로 qcow2/RAW 확인
      ↓
4. Host filesystem → 실제 block device 추적
      ↓
5. I/O Scheduler 확인
      ↓
6. Guest/Host iostat 동시 관찰
      ↓
7. VM1 부하가 VM2 storage latency에 미치는 영향 확인
      ↓
8. DB fsync latency와 Host storage latency 상관관계 확인

177. 최종 요약

Storage 가상화에서 Guest application은 실제 SSD를 직접 다루지 않는다.

Application
 ↓
Guest VFS
 ↓
Guest Filesystem
 ↓
Guest Page Cache
 ↓
Guest Block Layer
 ↓
virtio-blk
 ↓
virtqueue

VM 경계를 넘으면:

QEMU
 ↓
qcow2 / RAW / Host Block Device
 ↓
Host Storage Stack
 ↓
Physical SSD/NVMe

로 이어진다.

이 경로에는 여러 cache, queue, scheduling 지점이 존재한다.

특히 DB workload에서는 다음을 항상 구분해야 한다.

write 완료
    ≠
writeback 완료
    ≠
flush 완료
    ≠
전원 장애에도 살아남는 durability

Storage 문제를 분석할 때 CPU usage만 보지 말고 다음을 함께 본다.

Guest I/O latency
Host I/O queue
Host storage latency
QEMU backend
cache mode
I/O Scheduler
NVMe
다른 VM의 Storage load

이것이 QEMU/KVM 기반 Storage Virtualization을 이해하기 위한 핵심 SSOT다.


제5부 — 실험대에서 실제로 확인한 것

제1~4부는 CPU·메모리·네트워크·스토리지가 어떻게 동작하는가를 적었다. 이 부는 그 위에 실험대 한 대를 실제로 세우면서 무엇이 이론대로였고 무엇이 아니었는가를 적는다.

178. 이 부의 출처와 범위

원본 ../source/docs/guides/ — 기반 7단계 가이드
실측 기록 ../source/docs/lab-virtualization.md
개념 누적 ../source/docs/session-lab-concepts.md
설정 원본 ../source/deploy/lab/edge/
리비전 ../source/.source-revision

대상 환경 (observed) — test-server, Arch Linux, i5-1135G7(논리 코어 8), RAM 11,648MiB(약 11.4GiB), QEMU 11.1.1 · libvirt 12.7.0. 이더넷 없이 WiFi 만 있어 브리지를 못 쓰고 libvirt NAT(virbr0) + 호스트 진입 구조를 택했다. 게스트는 Debian 12 genericcloud 3대 — 엣지 1대(nginx·certbot)와 k3s 2노드.

호스트 RAM 의 원 측정 (observed) — 위 11,648MiB 는 실측 기록 ../source/docs/lab-virtualization.md 의 「측정 환경」이 2026-09-10 에 test-server 에서 free -m | head -2 로 받은 출력이다. 같은 출력이 제7부 §197 에도 있다.

               total        used        free      shared  buff/cache   available
Mem:           11648        5642        2599           4        3776        6005

free -m 은 MiB 단위라 total 이 11,648MiB — 약 11.4GiB 다. 이 부가 「호스트 RAM」이라고 부르는 값은 전부 이 줄에서 나온다.

범위 밖 — 이 부는 구축 과정에서 실제로 막힌 지점만 적는다. 막히지 않은 단계는 가이드에 있고 여기서 반복하지 않는다.

179. 엣지를 물리 호스트에서 VM 으로 옮기면 무엇이 새로 필요해지나

같은 nginx 인데 사는 곳만 바꿨다.

전:  tailnet:443 ─▶ [호스트 nginx] ─────────────▶ Traefik(게스트 .11/.12)
후:  tailnet:443 ─▶ [호스트 커널 DNAT] ─▶ [엣지 nginx(.10)] ─▶ Traefik(.11/.12)

L7 홉 수는 그대로 2홉이다 (observed). 늘어난 것은 커널이 하는 L4 전달 한 번뿐이라 X-Forwarded-* 계약은 그대로 성립한다. 바꾼 이유는 성능이 아니라 더러워지는 층의 격리다 — nginx 설정·인증서·certbot·deploy 훅은 자주 갈아엎는 것들인데, 호스트에 있으면 초기화가 불가능하고 엣지 장애 실험이 SSH 까지 위험하게 만든다.

그 대가로 일곱 가지가 새로 필요해졌다.

# 새로 필요해진 것 전에는 왜 없었나
1 nginx 설치 호스트에는 이미 있었다. 새 게스트의 cloud-init 은 curl·nftables 만 깐다
2 DNAT 호스트가 직접 :443 을 들었으니 넘길 일이 없었다. 지금은 호스트에 리스너가 아예 없다
3 libvirt 방화벽에 구멍 호스트→게스트는 OUTPUT 경로라 필터를 안 탔다. 밖→게스트는 FORWARD
4 SNAT 금지를 명시 L4 를 한 번 더 타면서 masquerade 를 붙이고 싶어지는데, 붙이면 엣지가 모든 클라이언트를 192.168.122.1 로 본다
5 sites-available 관례 호스트는 Arch 라 그 디렉터리가 없어 nginx.conf 에 include 를 직접 넣었다. 게스트는 Debian 이라 기본으로 있다
6 nginx 버전 차이 Arch 1.30 vs Debian 12 의 1.22. http2 on; 지시어가 1.25.1 이상이다
7 certbot·인증서·갱신 훅이 게스트로 인증서를 읽는 주체가 nginx 이기 때문이다

★ 2번과 3번이 이 이동의 본질이다 (inferred). 나머지는 배포판이 달라서 생긴 잡무고, 이 둘은 경로가 OUTPUT 에서 FORWARD 로 바뀌었기 때문에 생긴 구조적 변화다. 「호스트가 게스트에 접속한다」와 「밖에서 게스트로 들어온다」는 커널이 보기에 완전히 다른 일이다.

180. nftables 는 앞 체인의 accept 로 뒤 체인의 reject 를 막지 못한다

제3부가 적은 게스트 패킷 경로 위에서, 가장 오래 막힌 지점이다.

증상 (observed) — 호스트 안에서는 되는데 밖에서만 안 된다.

어디서 쳤나 결과
호스트에서 curl http://192.168.122.10 404 (엣지 nginx 가 응답)
밖에서 curl http://100.83.212.4 connection refused

타임아웃이 아니라 즉시 거절이라는 점이 단서다 — 드롭이면 기다리다 죽는다.

원인 (observed) — libvirt 는 자기 테이블 ip libvirt_networkguest_input 체인을 이렇게 끝낸다.

oif "virbr0" ip daddr 192.168.122.0/24 ct state established,related accept
oif "virbr0" counter packets 4 bytes 240 reject          ← 여기서 죽는다

카운터 4 패킷이 밖에서 친 curl 횟수와 정확히 일치했다. 범인 확정에 쓴 것이 이 숫자다.

왜 우리 규칙이 안 먹혔나 — DNAT 파일에 priority filter - 10 으로 먼저 도는 forward 체인을 두고 ct state new accept 를 넣어 두었다. 그런데 nftables 는 같은 훅에 붙은 base 체인을 우선순위 순으로 전부 평가한다. 앞 체인의 accept 는 「이 체인은 통과」라는 뜻이지 「평가 끝」이 아니다. drop 만이 즉시 종결이다. iptables 감각으로 쓰면 정확히 여기서 틀린다.

해결 (observed) — 구멍을 libvirt 체인 맨 앞에 뚫는다. insert 가 맨 앞, add 가 맨 뒤다.

nft insert rule ip libvirt_network guest_input \
  oif virbr0 ip daddr 192.168.122.10 tcp dport '{80,443}' ct state new counter accept

이 규칙은 휘발성이다 (observed) — libvirt 가 네트워크를 다시 세우면 guest_input 을 새로 쓰면서 날아간다. 그래서 DNAT 유닛의 ExecStartPost 에 넣는다.

미확인 (unknown) — libvirt 의 firewall_backend 가 iptables 일 때도 같은지는 재지 않았다. 이 호스트는 nftables 백엔드다.

181. qcow2 가 담는 것과 담지 않는 것

제4부의 스토리지 가상화를 이식 관점에서 이어 적는다.

qcow2 는 가상 디스크 한 장의 블록을 담는 파일이다 — 매핑표와 데이터 클러스터가 같은 파일 안에 있다. 표에 적히는 값은 호스트 물리 주소가 아니라 파일 안의 오프셋이라, 파일을 통째로 옮겨도 그대로 유효하다. 파일 밖을 가리키는 것은 백킹 파일 경로 하나뿐이다(헤더에 절대경로 문자열).

따라가는 것 따라가지 않는 것
파일시스템 전체, 설치 패키지, 설정, DB 파일 실행 중인 프로세스 — PID·FD·소켓·JVM 힙
디스크에 쓰인 캐시(컨테이너 이미지, apt 캐시) 페이지 캐시와 안 내려간 dirty page
machine-id, SSH 호스트키 VM 정의 XML — vCPU·RAM·NIC·machine type·CPU 모델
내부 스냅샷 UEFI NVRAM, 백킹 파일, 호스트 쪽 구성

희소(sparse) 할당이지 압축이 아니다. 20GB 이미지가 2GB 인 것은 쓴 블록만 파일에 존재하기 때문이고, 1TB 를 채우면 1TB 파일이 된다. 메타데이터 오버헤드는 클러스터 64KiB·L2 항목 8B 기준 0.02% 미만(1TiB 당 약 160MiB). 그리고 게스트에서 지워도 파일은 줄지 않는다 — 클러스터는 이미 할당된 상태라, fstrim(디스크에 discard='unmap' 필요)이나 qemu-img convert 가 필요하다.

실행 상태까지 옮기려면 qcow2 복사로는 안 된다 — virsh save→복사→restore (VM 이 멈추고 RAM 크기만큼 파일이 더 생긴다) 또는 virsh migrate --live --copy-storage-all(두 호스트 libvirt 가 붙고 CPU 모델이 호환돼야 한다).

온프렘 → 클라우드 (external, 코드 관측 아님) — 원리는 같고 파일은 그대로 못 올린다. AWS 는 raw·VMDK·VHD, Azure 는 고정 크기 VHD, GCP 는 import 도구가 여러 포맷을 받는다. 실제 작업량은 포맷 변환이 아니라 게스트 준비에 있다 — 드라이버(ENA·NVMe / hv_*), 게스트 에이전트, cloud-init datasource, 고정 IP→DHCP, fstab·GRUB 을 UUID 로. 어떤 방법도 실행 중 프로세스를 이어주지 않는다(하이퍼바이저가 다르다). 컷오버는 반드시 재부팅이다.

182. 이 구축에서 드러난 문서 결함의 공통 원인

가이드를 실제로 순서대로 따라가자 계열 결함이 나왔다(observed).

결함 어디 증상
nginx 설치 단계가 없다 03 /etc/nginx: No such file or directory
설정 블록이 http2 on; 03 Debian 12 의 nginx 1.22 에서 unknown directive
인증서 경로가 lineage 이름과 다르다 04 와일드카드는 live/hyeonworks.com/ 인데 live/auth.hyeonworks.com/ 이라 적혀 있었다
저장소가 lab host 에 있다고 가정 00·03·05·06 cp: cannot stat 'deploy/...'
해당 단계에 없는 리소스를 조회 05 -l app=bff — BFF 는 한참 뒤에 뜬다
확인 명령을 칠 위치가 틀렸다 04 엣지 VM 안에서 tailnet 주소를 치면 connection refused — 게스트에는 Tailscale 이 없다

공통 원인은 하나다 (inferred) — 개별 명령은 전부 실제로 돌았던 것이다. 틀린 것은 명령이 아니라 그 명령이 놓인 위치다. 나중 시점의 환경에서 확인한 명령과 출력을 앞 단계에 적으면, 각 줄은 참인데 순서대로 따라가면 막힌다.

그래서 이런 문서는 작성 시점이 아니라 실행 순서로 검증해야 한다. 각 단계에서 「이 시점에 이 리소스가 존재하는가」, 「이 셸에서 이 명령이 도는가」를 따로 본다.

183. 이 부에서 파생될 OPEN QUESTION

  • libvirt firewall_backend 가 iptables 일 때 guest_input 구멍이 필요한가, 아니면 그때는 우리 forward 체인 accept 가 실제로 먹는가 (unknown)
  • virsh save/restore 의 RAM 덤프 크기와 소요 시간이 할당 메모리와 어떻게 비례하는가 — 제2부의 balloon 실사용값과 대조하면 재미있는 대조군이 된다 (미측정)
  • WiFi 전용 호스트에서 대용량 qcow2 이동이 현실적으로 몇 시간인가 (미측정)

제6부 — 실험대는 어떻게 세워졌나

제5부는 구축하다 걸려 넘어진 곳을 적었다. 이 부는 그 앞뒤를 채운다 — 무엇을 세웠고, 어떤 순서로 세웠고, 한 단계가 끝났다는 것을 무엇으로 판정했는가.

184. 이 부의 출처와 범위

원본 ../source/docs/guides/README.md — 가이드 묶음이 스스로 정한 규약
../source/docs/guides/ 00-lab-host/ ~ 06-observability/ — 7단계, 묶음 전체 3,223줄
설정 원본 ../source/deploy/lab/edge/lab-edge-dnat.nft · lab-edge-dnat.service · nginx-keycloak-lab.conf · reload-nginx.sh
리비전 ../source/.source-revision9465582b5d1630eb4ae7c4e078021486919bf6b6

제5부와 갈리는 곳 — §178 이 「이 부는 구축 과정에서 실제로 막힌 지점만 적는다」로 범위를 좁혀 두었다. 그래서 제5부에는 nftables·qcow2·문서 결함처럼 걸려 넘어진 것만 있고 세워진 환경 자체가 없었다. 제6부가 그것을 넣는다 — 단계마다 무엇을 세우고, 무슨 명령으로 세우고, 끝났다는 판정을 어디서 읽는가.

표기는 제1~5부와 같다. observed 는 돌고 있는 실험대에서 읽은 값, inferred 는 그 값에서 끌어낸 것, external 은 코드나 실행 관측이 아닌 외부 지식, unknown 은 재지 않은 것.

가이드가 스스로 밝힌 검증 방식(observed) — 읽기 전용 확인은 돌아가는 실험대에서 실제로 실행해 출력을 그대로 실었다. 만드는 명령은 다르다. VM 을 다시 만들거나 k3s 를 다시 깔면 지금 돌고 있는 실험대가 없어지므로, 그 명령들은 구축할 때 쓴 것을 그대로 옮기고 결과 상태를 확인하는 것으로 대신했다. 그래서 아래 §186~§192 의 생성 명령은 「그때 이렇게 쳤다」까지이고, 「지금 다시 쳐도 같은 상태가 된다」는 확인되지 않았다(unknown).

그래서 명령을 두 이름으로 갈라 적은 곳이 있다. 이 실험대가 친 명령 중에는 파일을 echo >>printf > 로 만들거나, ssh 둘을 파이프로 이어 한 줄에 넣은 것이 섞여 있다. 한 번 돌리고 끝내려면 그 편이 짧지만, 처음부터 끝까지 따라 치는 사람에게는 지금 무슨 작업의 어느 지점에 있는지가 안 보인다. 지우지 않고 옆에 갈래를 하나 더 둔다.

이름 무엇인가
이 실험대는 이렇게 했다 실제로 친 명령 그대로. 이 실험대의 기록이다
따라 하는 사람은 같은 상태에 닿는, 행동 하나에 명령 하나인 형태

뒤엣것은 이 실험대에서 치지 않았다(unknown). 가르는 기준은 shell 도구가 보이느냐가 아니라 사람이 내용을 읽고 이해해야 하는 파일을 만드는 단계냐다 — 조회·진단·실행은 grep·virsh·systemctl·긴 virt-install 그대로 둔다.

여기 안 적는 것 — 실험 26건은 이 부의 범위 밖이다(가이드 README 는 experiments/ 를 여덟 번째 줄로 걸어 두었는데 source/ 에 그 폴더는 없다). 비밀은 길이와 존재 여부만 적고 값을 적지 않는다. 가이드가 kubectl apply -f 로 가리키는 deploy/lab/k8s/keycloak-cluster.yaml·observability.yaml 과 cloud-init 템플릿 deploy/lab/cloud-init/kc-lab.yaml.examplesource/ 에 반입되지 않았다 — 매니페스트 원문은 여기 없고, 가이드가 화면에 옮겨 적은 만큼만 있다.

185. 가이드 묶음이 스스로 정한 규약

7단계를 읽기 전에 이 넷을 알아야 각 단계의 코드 블록이 읽힌다.

① 명령을 두 종류로 갈라 적는다. 실무자가 터미널에서 치는 명령과, 근거를 남기려고 재는 명령은 길이도 목적도 다르다.

표시 무엇인가
하기 · 확인 실무자가 실제로 치는 형태. 짧고, 한 번에 하나씩
근거를 재려면 이 실험대가 문서에 남기려고 쓴 긴 형태. 평소에는 필요 없다

curl 이 그 둘로 갈린 대표다.

curl -I https://auth.hyeonworks.com/realms/master        # 한 번 볼 때
curl -s -o /dev/null -w '%{http_code}\n' <url>           # 여러 번 재서 비교할 때

값만 뽑는 뒤엣것은 골라 놓은 한 칸 말고는 전부 버리므로, 무엇이 잘못됐는지 모르는 상태에서는 쓸 것이 못 된다. 03 의 층별 확인이 -I 로 시작해 %{http_code} 로 줄어드는 순서가 그래서 나온다.

② 자리표시자를 두지 않는다. <토큰> 처럼 적으면 그 값을 어디서 가져오는지가 문서 밖으로 나간다. 가이드는 값을 찾는 명령을 함께 적는다.

TOKEN=$(ssh kc-lab-1 'sudo cat /var/lib/rancher/k3s/server/node-token')
echo "${#TOKEN} 자"      # 값이 아니라 길이만 확인한다

비밀은 길이나 존재 여부만 확인하고 값을 찍지 않는다 — 터미널 스크롤백과 화면 공유에 남기 때문이다. 05 의 Secret 확인도 같은 형태로 19 bytes·22 bytes 까지만 본다.

③ 모든 코드 블록에 어디서 치는지를 붙인다. 같은 명령이 어디서 도느냐에 따라 결과가 달라진다.

표시 어느 기계 어떻게 들어가나
[워크스테이션] 평소 쓰는 개발 머신
[lab host] test-server. virsh 가 도는 곳 ssh test-server
[kc-lab-edge] 엣지 게스트 — nginx · certbot ssh kc-lab-edge (lab host 에서만)
[kc-lab-1] k3s server 게스트 ssh kc-lab-1 (lab host 에서만)
[kc-lab-2] k3s agent 게스트 ssh kc-lab-2 (lab host 에서만)

기본은 [lab host] 다. 게스트는 libvirt NAT(192.168.122.0/24) 안에 있어 워크스테이션에서 직접 닿지 않고, ssh kc-lab-1 이라는 별칭도 lab host 의 ~/.ssh/config 에만 있다.

[워크스테이션] $ ping -c1 192.168.122.11
1 packets transmitted, 0 received, 100% packet loss     # 경로가 없다

가이드 본문은 「이 실험대에는 셸이 네 개 있고」로 세는데 표는 다섯 줄이다. 워크스테이션을 실험대 밖으로 보면 lab host 와 게스트 3대가 넷이 된다(inferred).

④ 게스트에 로그인하지 않고 lab host 에서 원격 실행한다. 게스트에는 lab host 의 개인키도 ~/.ssh/config 도 없어서, 게스트 안에서 ssh kc-lab-1 을 치면 이렇게 끝난다.

[kc-lab-1] $ ssh kc-lab-1 'sudo cat /var/lib/rancher/k3s/server/node-token'
Host key verification failed.

이 실패가 조용한 까닭(observed) — 위 명령을 TOKEN=$(...) 로 감싸면 오류는 stderr 로 흘러가고 TOKEN 에는 빈 문자열이 담긴다. 셸은 아무 불평도 하지 않는다. 그래서 02 의 agent 설치가 --token '' 을 받아 level=fatal msg="Error: --token is required" 로 죽는데, 설치 스크립트는 그 전까지를 다 성공으로 찍고 끝나므로 설치 출력만 보면 성공으로 읽힌다. 유닛은 Restart=always 라 5초마다 조용히 재시도한다.

그래서 가이드는 게스트에 들어가지 않고 ssh kc-lab-1 '...' 형태로 친다. 셸이 하나뿐이면 「지금 어디 있더라」가 생기지 않는다.

⑤ 단계마다 통과 조건이 앞에 있다. 각 단계 첫머리에 「이 단계가 끝나면」이 있고 그 상태를 확인하는 명령이 있다. 그것이 통과해야 다음으로 넘어간다.

단계 무엇을 세우나 끝나면 확인되는 것
00 lab host 가상화 준비 virsh list 가 돈다
01 VM 세 대 (엣지 + k3s 2노드) 세 게스트에 SSH 가 붙는다
02 k3s server + agent kubectl get nodes 에 둘 다 Ready
03 엣지 nginx 라우팅 + 호스트 DNAT 밖에서 요청이 파드까지 닿는다
04 Let's Encrypt https:// 가 열리고 체인이 4단계
05 Keycloak 2노드 + PostgreSQL 관리 콘솔 로그인이 된다
06 Prometheus · Grafana vendor_cluster_size 가 2

186. 단계 00 — lab host 가상화 준비

어디서 치는가 — 이 단계는 전부 [lab host] 에서 친다. 게스트가 아직 없어서 다른 셸 표시가 나오지 않는다. sudo 가 붙는 것은 패키지 설치와 유닛 조작 둘뿐이고, virsh 는 4번이 끝나면 sudo 없이 돈다.

이 단계가 세우는 것 — 가이드 00 의 「이 단계가 끝나면」은 한 줄이다.

virsh list 가 sudo 없이 돌고, VM 을 만들 수 있는 상태가 된다.

그 한 줄이 요구하는 것을 풀면 여덟 가지다.

무엇
저장소 ~/workspace/keycloak-pattern — 05·06 의 deploy/... 상대경로가 이 디렉터리 기준이다
패키지 (Arch) qemu-full · libvirt · virt-install · dnsmasq
패키지 (Debian/Ubuntu) qemu-system-x86 · libvirt-daemon-system · virtinst · cloud-image-utils
판 번호 libvirt 12.7.0 · QEMU emulator version 11.1.1
유닛 libvirtd.socket.service 가 아니다
그룹 donghyeon libvirt wheel
연결 URI LIBVIRT_DEFAULT_URI=qemu:///system
가상 네트워크 default / active / autostart yesvirbr0 · 192.168.122.0/24

네 패키지가 하는 일이 서로 다르다.

무엇 하는 일
qemu 실제로 가상 기계를 돌리는 것
libvirt qemu 를 관리하는 층 (virsh 가 여기 붙는다)
virt-install VM 을 만드는 명령
dnsmasq 가상 네트워크의 DHCP·DNS

전제와 되돌리기

전제는 가이드가 두 줄로 적었다 — 「물리 기계 한 대. 이 실험대는 Arch Linux 를 썼지만 배포판은 상관없다 — 패키지 이름만 다르다.」 앞 단계가 없으므로 이 단계는 아무것도 전제하지 않는다.

되돌리는 절차는 원본 가이드 00 에 없다(unknown). 가이드 7편 가운데 되돌리기를 적은 편은 03 하나다. 이 단계가 호스트에 남기는 것은 패키지 넷, libvirt 보조 그룹 하나, libvirtd.socket 활성화, ~/.bashrcexport 한 줄, default 네트워크의 autostart 다섯인데, 무엇을 어떤 순서로 걷어내는지는 가이드에 적혀 있지 않고 이 실험대도 걷어내 본 적이 없다. 여기에 되돌리는 명령을 적으려면 지어내야 하므로 적지 않는다.

먼저 본다 — 바꾸기 전 상태

두 확인은 아무것도 바꾸지 않는다. BIOS(Basic Input/Output System, 기계를 켤 때 먼저 도는 펌웨어와 그 설정 화면)에서 가상화가 꺼져 있으면 그 뒤가 전부 헛일이므로 먼저 본다.

확인 ① CPU 가 하드웨어 가상화 확장을 내놓고 있는가

grep -Eo 'vmx|svm' /proc/cpuinfo | head -1

어디를 봐야 하는가 — 출력 한 줄이 전부다. vmx(Intel) 또는 svm(AMD) 중 하나가 찍히는가, 아니면 아무것도 안 찍히는가.

이 결과가 의미하는 것 — 찍혔으면 이 호스트에서 KVM 을 쓸 수 있다. 빈 출력은 「CPU 가 못 한다」가 아니라 대개 BIOS 에서 꺼져 있다는 뜻이다 — 재부팅해 Intel VT-x / AMD-V 를 켜고 다시 잰다. 여기서 막히면 뒤의 어떤 단계도 의미가 없으므로 진행하지 않는다.

확인 ② 커널이 그 확장을 실제로 잡고 있는가

lsmod | grep kvm

형태만 옮긴다. 이 실험대에서 캡처해 두지 않았다(unknown).

kvm_intel   ...
kvm         ...

어디를 봐야 하는가 — 왼쪽 첫 열의 모듈 이름 두 개. 벤더 모듈(kvm_intel 또는 kvm_amd)과 공용 kvm둘 다 있어야 한다. 셋째 열은 이 모듈을 쓰고 있는 쪽의 수라서, 아직 VM 이 없으면 0 으로 나온다.

이 결과가 의미하는 것 — 두 줄이면 커널이 하드웨어 가상화를 쓸 준비가 됐고 virt-install 이 KVM 가속으로 뜬다. kvm 만 있고 벤더 모듈이 없으면 확인 ① 의 BIOS 설정이 커널까지 안 넘어왔다 — sudo modprobe kvm_intel 로 직접 올려 보면 거부 사유가 그대로 나온다. 아무것도 없으면 확인 ① 로 돌아간다.

sudo modprobe kvm_intel

왜 이걸 먼저 보나. KVM 없이도 QEMU 는 돌지만 소프트웨어 에뮬레이션이 되어 수십 배 느리다. VM 이 「뜨긴 뜨는데 느리다」면 대개 여기다.

실행 절차

번호는 가이드 00 의 것을 그대로 쓴다. 위의 확인 ①·② 가 가이드의 1·2 번이라 여기서는 0 과 3~6 이 남는다.

0. 저장소를 lab host 에 받는다

목적 — 뒤 단계가 deploy/ 아래 파일을 쓴다. 05·06 의 kubectl apply -f deploy/lab/k8s/... 가 그것이고, 경로는 저장소 루트 기준이다. lab host 에 저장소가 없으면 cp: cannot stat / error: the path ... does not exist 로 막힌다 — 이 실험대에서 실제로 겪은 형태다.

① 이미 세웠다 철거한 적이 있으면 무엇이 남아 있는지 먼저 본다.

ls ~/workspace

② 받는다.

git clone https://git.learn.hyeonworks.com/donghyeon.kang/keycloak-pattern.git ~/workspace/keycloak-pattern
cd ~/workspace/keycloak-pattern

확인

ls deploy/lab/k8s/

어디를 봐야 하는가keycloak-cluster.yamlobservability.yaml 이 보이는가.

이 결과가 의미하는 것 — 이후 단계에서 deploy/... 로 시작하는 상대경로가 나오면 전부 이 디렉터리 안에서 치는 것이다. 다른 데서 치면 파일을 못 찾는다.

★ 한 번 세웠다가 철거했다면 이 디렉터리가 없을 수 있다. 철거는 VM·디스크· 네트워크만 지우고 저장소는 각자 관리라, ~/workspace 에 cloud-init seed 만 남아 있는 상태가 흔하다. 그래서 ① 을 먼저 친다.

3. 패키지 설치

목적virshvirt-install 을 PATH(셸이 실행 파일을 찾아 다니는 디렉터리 목록)에 올리고 가상 네트워크의 DHCP 를 준비한다.

sudo pacman -S --needed qemu-full libvirt virt-install dnsmasq

Debian/Ubuntu 면 이름이 다르다. 이 실험대는 Arch 로 세웠고 아래 줄은 가이드가 참고로 적어 둔 것이다(external).

sudo apt install qemu-system-x86 libvirt-daemon-system virtinst cloud-image-utils

확인 — 두 실행 파일이 PATH 에 들어왔는가

virsh --version
qemu-system-x86_64 --version

어디를 봐야 하는가 — 판 번호 두 줄. 명령을 못 찾는다(command not found)면 패키지가 안 깔렸고, 번호가 나오면 깔렸다.

이 결과가 의미하는 것 — 여기서 나오는 libvirt 판 번호가 뒤의 옵션 이름을 가른다. 이 실험대는 libvirt 12.7.0 · QEMU emulator version 11.1.1 이었다 (observed). 훨씬 낮은 판이면 virt-install --cloud-init 같은 옵션의 동작이 다를 수 있으니, 01 에서 막힐 때 이 번호를 같이 본다.

4. libvirt 를 띄우고 권한을 받는다

목적 — 지금 이 셸이 sudo 없이 libvirt 에 붙게 한다.

sudo systemctl enable --now libvirtd.socket
sudo usermod -aG libvirt "$USER"

그리고 로그아웃했다 다시 들어온다. 보조 그룹은 로그인할 때 정해지므로 usermod 만으로는 지금 셸에 반영되지 않는다.

확인

groups            # libvirt 가 보여야 한다
virsh list --all  # sudo 없이 돌아야 한다

실측(observed)

donghyeon libvirt wheel

어디를 봐야 하는가groups 출력에 libvirt 가 끼어 있는가, 그리고 virsh list --all머리글만 있는 빈 표라도 오류 없이 끝나는가. 아직 VM 을 안 만들었으므로 표가 비어 있는 쪽이 정상이다 — 봐야 할 것은 표의 내용이 아니라 명령이 통과했느냐다.

이 결과가 의미하는 것 — 둘 다 통과하면 이 셸에서 VM 을 만들 수 있다. groupslibvirt 가 없으면 usermod 는 됐지만 지금 로그인 세션이 옛 그룹 목록을 들고 있다 — 로그아웃하고 다시 들어온다. groups 에는 있는데 virshPermission denied 면 그룹이 아니라 소켓 문제이므로 유닛 상태를 본다.

systemctl status libvirtd.socket

libvirtd.service 가 아니라 .socket 을 켠다. 소켓 활성화라 데몬이 미리 떠 있지 않아도 virsh 가 접속하는 순간 systemd 가 띄우고, 자원을 아끼며, 데몬을 재시작해도 클라이언트가 끊기지 않는다.

5. 연결 URI 를 고정한다

목적virsh 가 VM 을 만들 곳과 같은 하이퍼바이저를 보게 한다. virsh 는 기본으로 qemu:///session(사용자 단위)에 붙는데 VM 은 qemu:///system(시스템 단위)에 만들어야 한다. 이걸 안 맞추면 만든 VM 이 안 보인다.

이 실험대는 이렇게 했다(observed)

echo 'export LIBVIRT_DEFAULT_URI=qemu:///system' >> ~/.bashrc
virsh uri

★ rc 파일에만 넣으면 ssh lab-host '명령' 에서는 안 먹는다(2026-09-17, observed). 그 형태는 비대화형 셸이라 .bashrc·.zshrc 를 안 읽는다.

ssh 로 한 줄     LIBVIRT_DEFAULT_URI=[(비어 있음)]
대화형 셸        LIBVIRT_DEFAULT_URI=qemu:///system

셸과 무관하게 고정하려면 libvirt 자기 설정에 넣는다. 이 실험대에는 그 파일도 있었다(observed).

mkdir -p ~/.config/libvirt
echo 'uri_default = "qemu:///system"' >> ~/.config/libvirt/libvirt.conf
virsh uri
/home/donghyeon/.config/libvirt/libvirt.conf:uri_default = "qemu:///system"
qemu:///system

둘 중 하나만 있으면 어디선가 어긋난다. rc 만 있으면 ssh 한 줄에서 qemu:///session 을 보게 되고 — 그쪽에는 VM 이 없으므로 목록이 빈 채로 나와 「VM 이 죽었다」로 읽힌다 — libvirt 설정만 있으면 대화형 셸의 echo $LIBVIRT_DEFAULT_URI 가 비어서 「설정이 안 됐다」로 읽힌다.

따라 하는 사람은 편집기로 연다. echo >> 는 가이드를 두 번 따라 하면 같은 줄을 한 번 더 붙이고, 파일에 이미 무엇이 들어 있는지도 보여 주지 않는다.

nano ~/.bashrc

그 파일 끝에 이 줄을 더한다.

export LIBVIRT_DEFAULT_URI=qemu:///system

저장하고 현재 셸에 반영한 뒤 확인한다.

source ~/.bashrc
virsh uri

libvirt 쪽 설정도 같다. 디렉터리는 명령으로 만들고 파일은 편집기로 연다.

mkdir -p ~/.config/libvirt
nano ~/.config/libvirt/libvirt.conf

그 파일에 이 줄을 더한다.

uri_default = "qemu:///system"

이 나눈 형태는 이 실험대에서 치지 않았다(unknown).

확인 — 지금 셸이 어느 하이퍼바이저를 보고 있는가

virsh uri

실측(observed)

qemu:///system

어디를 봐야 하는가 — 끝의 한 낱말. system 인가 session 인가.

이 결과가 의미하는 것qemu:///system 이면 뒤에서 만들 VM 과 지금 virsh 가 같은 곳을 본다. qemu:///session 이면 사용자 단위 하이퍼바이저를 보고 있어 VM 은 만들어졌는데 virsh list 에 안 나오는 상태가 된다. .bashrc 에 넣은 것은 새로 여는 셸에만 적용되므로, 지금 셸에서는 source ~/.bashrc 를 치거나 새 셸을 연다.

6. 기본 네트워크

목적 — VM 이 붙을 virbr0192.168.122.0/24 를 지금도 재부팅 뒤에도 있게 한다.

확인 — 가상 네트워크가 살아 있는가

virsh net-list --all

실측(observed)

 Name      State    Autostart   Persistent
--------------------------------------------
 default   active   yes         yes

어디를 봐야 하는가default 행의 State 와 Autostart 두 칸. --all 을 주는 까닭이 여기 있다 — 빼면 inactive 인 네트워크는 아예 목록에 안 나와서 「없음」과 「꺼짐」을 구분할 수 없다.

이 결과가 의미하는 것active + yes 면 지금도, 호스트를 재부팅한 뒤에도 virbr0192.168.122.0/24 가 있다. inactive 면 VM 을 만들어도 DHCP 가 없어 IP 를 못 받는다. Autostart 가 no지금은 되지만 호스트를 재부팅한 다음 01 의 SSH 가 전부 실패하고, 그때 원인을 게스트에서 찾게 된다. 둘 중 하나라도 어긋나면 아래 두 줄로 맞춘다.

virsh net-start default
virsh net-autostart default

끝났는지 판정한다

위 일곱 확인이 다 통과했으면 이 단계는 끝났다. 다음으로 넘어가기 전에 한 번에 다시 보려면 다섯 칸이다.

무엇 명령 통과
가상화 확장 grep -Eo 'vmx|svm' /proc/cpuinfo | head -1 vmx 또는 svm 한 줄
그룹 groups libvirt 가 끼어 있다
연결 URI virsh uri qemu:///system
네트워크 virsh net-list --all defaultactive · autostart yes
빈 목록 virsh list --all sudo 없이 통과. 표가 비어 있어도 된다
virsh uri
virsh net-list --all
virsh list --all

virsh list --allsudo 없이 오류 없이 끝나는 것이 이 단계의 통과 조건 전부다. 표의 내용은 아직 볼 것이 없다.

막히면

증상 원인 확인
virsh list 에 permission denied 그룹 반영 안 됨 로그아웃/로그인 했는가. groups 에 libvirt 가 있는가
만든 VM 이 목록에 없다 URI 가 session virsh uri
VM 이 극단적으로 느리다 KVM 미사용 lsmod | grep kvm · BIOS
net-start default 실패 dnsmasq 없음 패키지 설치 확인

이 절차가 성립하는 범위

  • (observed) libvirt 12.7.0 · QEMU emulator version 11.1.1 · 그룹 세 개 · default 네트워크 active/autostart yes · virsh uriqemu:///system
  • (observed) lsmod | grep kvm 의 출력을 2026-09-17 에 받았다. 세 줄이고 마지막 칸이 그 모듈을 쓰는 수다
kvm_intel             524288  11
kvm                  1490944  6 kvm_intel
irqbypass              16384  1 kvm
  • (observed) 이 호스트는 논리 코어 8 이다. 가이드의 「16 코어 전부」가 아니라 §178 의 값이 맞다. 2026-09-17 에 nproc8, lscpu11th Gen Intel(R) Core(TM) i5-1135G7, 물리 4 코어에 코어당 스레드 2 라고 답했다
nproc
lscpu | grep -E "^Model name|^CPU\(s\):|^Core\(s\)|^Thread\(s\)"
  • (observed) systemctl status libvirtd.socket 도 같은 날 쳤다. 보는 줄은 ActiveListenTriggers 셋이고, Triggerslibvirtd.service 를 가리키는 것이 소켓 활성화가 걸렸다는 뜻이다
● libvirtd.socket - libvirt legacy monolithic daemon socket
     Loaded: loaded (/usr/lib/systemd/system/libvirtd.socket; enabled; preset: disabled)
     Active: active (running) since Thu 2026-09-03 19:00:35 KST; 1 week 6 days ago
   Triggers: ● libvirtd.service
     Listen: /run/libvirt/libvirt-sock (Stream)
  • (observed) 볼륨 일곱이 실제로 먹는 양도 그날 쟀다. 선언한 크기와 실제 할당이 크게 다르다 — 오버레이라 쓴 만큼만 먹는다. 철거 절의 「엣지 디스크는 재 두지 않았다」와 거기서 역산한 「1GB 안팎」이 이 값으로 다시 보인다
virsh vol-info --pool default kc-lab-edge.qcow2
base.qcow2             Capacity=3.00 GiB     Allocation=323.25 MiB
kc-lab-1.qcow2         Capacity=20.00 GiB    Allocation=5.40 GiB
kc-lab-2.qcow2         Capacity=20.00 GiB    Allocation=3.85 GiB
kc-lab-edge.qcow2      Capacity=10.00 GiB    Allocation=316.26 MiB
seed-kc-lab-{1,2,edge}.iso   Capacity=370.00 KiB   Allocation=372.00 KiB
  • (observed) cloud-init status --long 의 마지막 줄이 시드를 bus=virtio 로 붙이는 이유를 그대로 뒷받침한다. seed=/dev/vdb 이므로 CD-ROM(sr0)이 아니라 디스크로 잡혔고, cloud-init 이 거기서 데이터소스를 찾았다
status: done
boot_status_code: enabled-by-generator
last_update: Thu, 17 Sep 2026 04:24:22 +0000
detail:
DataSourceNoCloud [seed=/dev/vdb][dsmode=net]
  • (unknown) 원본 가이드에 되돌리는 절차가 없다. 패키지·그룹·유닛·.bashrc· 네트워크 autostart 를 걷어내 본 적이 없다
  • (unknown) sudo modprobe kvm_intel 은 막혔을 때 치라고 가이드가 적어 둔 것이고, 이 실험대에서는 막히지 않아 치지 않았다
  • (external) Debian/Ubuntu 패키지 이름은 가이드가 참고로 적어 둔 것이고 이 실험대는 Arch 로 세웠다

187. 단계 01 — 게스트 세 대

어디서 치는가 — 기본은 [lab host] 다. 예외가 둘이다. cloud-init 파일의 스키마 검사는 이미 떠 있는 게스트 안에서 돌아야 해서 [kc-lab-1] 로 들어가고, 워크스테이션 공개키를 읽는 한 줄만 [워크스테이션] 에서 친다.

무엇 어디서
base 이미지 · 시드 · DHCP 예약 · virt-install · 붙어 보기 [lab host]
cloud-init schema -c [kc-lab-1] — 검사기가 게스트 안에만 있다
워크스테이션 공개키 cat ~/.ssh/id_ed25519.pub [워크스테이션]

이 단계가 세우는 것 — 가이드 01 의 「이 단계가 끝나면」은 한 줄이다.

kc-lab-edge·kc-lab-1·kc-lab-2 세 게스트가 뜨고, lab host 에서 SSH 가 키로 붙는다.

게스트 IP MAC 끝 vCPU 메모리 디스크 무엇이 도나
kc-lab-edge 192.168.122.10 :10 1 1024MB 10 nginx · certbot
kc-lab-1 192.168.122.11 :11 2 5120MB 20 k3s server · Traefik
kc-lab-2 192.168.122.12 :12 2 4096MB 20 k3s agent · Traefik
무엇
base 이미지 /var/lib/libvirt/images/base.qcow2 — Debian 12 genericcloud amd64
게스트 OS Debian GNU/Linux 12 (bookworm)
디스크 base 위의 오버레이(backing_store=). 복사가 아니다
시드 seed-<이름>.isoCIDATA 라벨 · user-data·meta-data · bus=virtio
cloud-init 패키지 [curl, nftables]
스키마 검사기 게스트의 cloud-init 22.4.2

왜 세 대인가(가이드 원문) — 이 실험대의 질문 전부가 「인스턴스가 둘 이상이고 요청이 어느 쪽으로 갈지 모른다」를 전제한다. 한 대면 세션 공유도 분단도 노드 상실도 실험이 되지 않는다. 엣지를 따로 둔 이유는 §179 가 적었다.

전제와 되돌리기

전제는 한 줄이다 — 「[00] 이 끝나 virsh list 가 sudo 없이 돈다.」

되돌리는 절차는 원본 가이드 01 에 없다(unknown). 다만 01 이 만드는 것이 무엇인지는 03 이 한 줄로 적어 두었다 — 「엣지가 VM 이면 초기화가 virsh undefine kc-lab-edge --remove-all-storage 한 줄이 된다」. 그 줄은 엣지를 왜 VM 으로 두는지를 설명하면서 나오고, 세 게스트를 걷어내는 절차로 적어 둔 것이 아니다. DHCP 예약 세 줄과 풀에 올린 시드 볼륨 세 개를 어떻게 지우는지는 가이드 어디에도 없고 이 실험대도 지워 본 적이 없다.

먼저 본다 — 바꾸기 전 상태

가이드 01 에는 바꾸기 전 상태를 보는 단계가 없다. 아래 세 줄은 00 의 확인을 그대로 다시 치는 것이고, 01 이 요구하는 전제가 그 셋이다(inferred).

virsh uri
virsh net-list --all
virsh list --all

어디를 봐야 하는가qemu:///system 인가, defaultactive 인가, 그리고 virsh list --all 이 sudo 없이 통과하는가. 세 게스트를 아직 안 만들었으면 마지막 표는 비어 있다.

이 결과가 의미하는 것 — 이 셋 가운데 하나라도 어긋난 채로 virt-install 을 치면 증상이 「VM 이 목록에 없다」나 「IP 를 못 받는다」로 나타나고, 원인은 01 이 아니라 00 에 있다.

실행 절차

번호는 가이드 01 의 것을 그대로 쓴다.

1. base 이미지를 받는다

목적 — OS 를 설치하지 않는다. 클라우드 이미지는 이미 설치가 끝난 디스크 이고, 첫 부팅에서 cloud-init 이 사용자·SSH 키·호스트명을 채운다.

cd /var/lib/libvirt/images
sudo curl -fL --output /var/lib/libvirt/images/base.qcow2 \
https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2

확인 — 받은 파일이 온전한 qcow2 인가

qemu-img info /var/lib/libvirt/images/base.qcow2

어디를 봐야 하는가 — 세 줄이다. file format:qcow2 인가(raw 로 읽히면 내려받기가 중간에 끊겨 HTML 오류 페이지를 저장한 것이다), virtual size:disk size: 보다 훨씬 큰가(qcow2 는 희소 파일이라 그것이 정상이다), backing file: 줄이 없는가. base 는 아무것도 뒤에 두지 않는다.

이 결과가 의미하는 것 — 셋이 맞으면 이 파일을 5번에서 오버레이의 바닥으로 쓸 수 있다. 여기서 형식이 틀린 채로 진행하면 증상이 virt-install 이 아니라 게스트가 부팅을 못 하는 것으로 나타나 원인을 찾기 어려워진다. 제4부 §142·§143 이 적은 qcow2 의 virtual size 와 실제 사용량 차이가 여기서 그대로 보인다.

2. cloud-init 을 쓴다

목적 — 게스트마다 하나씩 만든다. 템플릿은 deploy/lab/cloud-init/kc-lab.yaml.example 이다.

kc-lab-1.yaml 을 어디서 만드는지가 없다(unknown). 원본 01 은 템플릿을 걸어 두고 곧바로 자리표시자를 바꾸는 sed 로 넘어간다 — 그 파일을 게스트 이름으로 복사하거나 새로 만드는 명령이 원본에도 없다. 템플릿 원문은 source/ 에 반입되지 않아 대조하지도 못했다(§184).

자리표시자 셋을 실제 값으로 바꿔야 한다. 찾는 명령이 있다.

# ① lab host 공개키 — 없으면 만든다
[ -f ~/.ssh/id_ed25519.pub ] || ssh-keygen -t ed25519 -N '' -f ~/.ssh/id_ed25519
cat ~/.ssh/id_ed25519.pub

# ② 워크스테이션 공개키 — 워크스테이션에서
cat ~/.ssh/id_ed25519.pub

# ③ 콘솔용 비밀번호 — 만들어서 보관한다
openssl rand -base64 18

따라 하는 사람은 ① 을 둘로 나눈다. [ -f … ] || … 는 「키가 있으면 두고 없으면 만든다」를 한 줄에 접어 넣은 형태라, 배울 것보다 단축 평가를 먼저 읽게 된다. 먼저 꺼내 보고, 없을 때만 만든다.

cat ~/.ssh/id_ed25519.pub

ssh-ed25519 … 한 줄이 나오면 키가 이미 있는 것이고 아래를 건너뛴다. No such file or directory 면 키가 없다.

ssh-keygen -t ed25519 -N '' -f ~/.ssh/id_ed25519

만든 뒤 다시 cat 해서 공개키 줄을 꺼낸다. 이 나눈 형태는 이 실험대에서 치지 않았다(unknown).

이 실험대는 이렇게 했다(원본 01 본문) — 자리표시자 셋을 sed 로 한 번에 바꿨다.

sed -i \
  -e "s|__LAB_HOST_KEY__|$(cat ~/.ssh/id_ed25519.pub)|" \
  -e "s|__WORKSTATION_KEY__|<워크스테이션에서 복사해 온 값>|" \
  -e "s|__CONSOLE_PW__|$(openssl rand -base64 18)|" \
  kc-lab-1.yaml

따라 하는 사람은 파일부터 편집기로 만든다. 배우려는 것이 hostname:users:packages: 인데 sed-e 세 개와 | 구분자와 $(...) 를 먼저 읽어야 하면 시선이 그쪽으로 간다.

nano kc-lab-1.yaml

아래는 원본 01 본문이 실은 내용을 그대로 옮긴 것이다. __ 로 둘러싼 세 자리에 위 ①②③ 의 출력을 넣는다. 다른 게스트는 hostname·fqdn 두 줄만 kc-lab-2·kc-lab-edge 로 바꾼다.

#cloud-config
hostname: kc-lab-1
fqdn: kc-lab-1
manage_etc_hosts: true

users:
  - name: donghyeon
    groups: [sudo]
    shell: /bin/bash
    sudo: ['ALL=(ALL) NOPASSWD:ALL']
    lock_passwd: false
    plain_text_passwd: __CONSOLE_PW__
    ssh_authorized_keys:
      - __LAB_HOST_KEY__
      - __WORKSTATION_KEY__

ssh_pwauth: false
package_update: true
packages: [curl, nftables]

세 가지가 의도적이다.

NOPASSWD:ALL k3s 설치와 장애 주입이 비대화식으로 돌아야 한다. 비밀번호를 물으면 멈춘다
plain_text_passwd cloud-init 이 실패했을 때의 유일한 탈출구. 키가 안 들어가면 SSH 가 막히므로 콘솔로 들어가 로그를 봐야 한다
키 두 개 lab host 에서 자동화가 돌고(에이전트 포워딩 없음), 워크스테이션에서는 ProxyJump 로 직접 붙는다

sudo: 줄은 아래 ★ 가 적은 대로 문자열 형태로 고쳐 쓴다. 들여쓰기는 공백만 쓴다 — YAML 은 탭을 금지하고, cloud-init 은 파싱에 실패해도 아무 오류도 남기지 않는다. 게스트가 localhost 로 뜨고 로그인이 안 되는 것이 유일한 증상이다. plain_text_passwd 에 들어간 값은 ③ 이 만든 18바이트 난수를 base64 로 적은 것이고, 이 문서에는 값을 싣지 않는다.

확인 ① 자리표시자가 남아 있으면 cloud-init 이 그대로 넣는다

이 실험대는 이렇게 했다(observed)

grep -c '__' kc-lab-1.yaml        # 0 이어야 한다
grep -c 'ssh-ed25519\|ssh-rsa' kc-lab-1.yaml   # 2 여야 한다
python3 -c 'import yaml,sys; yaml.safe_load(open("kc-lab-1.yaml")); print("YAML OK")'

어디를 봐야 하는가 — 숫자 두 개와 낱말 하나. 첫 줄이 0 이 아니면 __LAB_HOST_KEY__ 같은 문자열이 남아 있고, 둘째 줄이 2 가 아니면 sed 치환 중 하나가 안 먹었다. 셋째 줄은 YAML OK 가 찍히는가만 본다 — 찍히지 않으면 대신 파이썬 예외 줄이 나오고, 거기 적힌 line N 이 문제의 줄 번호다.

이 결과가 의미하는 것0 / 2 / YAML OK 셋이 다 맞아야 시드를 만든다. 어긋난 채로 3번을 진행하면 cloud-init 은 파싱에 실패해도 아무 오류를 남기지 않으므로, 증상이 「SSH 가 안 붙는다」로만 나타나고 원인이 이 파일에 있다는 사실이 드러나지 않는다. 여기서 거르는 것이 뒤에서 30분 걸릴 일을 없앤다. yamllint 은 이 실험대에 없다. 있으면 좋지만 없다고 설치하러 가지 않는다.

따라 하는 사람은 앞의 두 grep 까지만 그대로 친다. 숫자 두 개를 눈으로 비교하는 용도라 그 형태가 맞다. 셋째 줄은 다르다 — YAML 로 파싱되는지만 보고 cloud-config 의 스키마는 못 본다. 이 형식에는 검증기가 따로 있으므로 쓸 수 있는 곳에서는 그쪽을 먼저 쓴다. 바로 아래 확인 ② 가 그 검증기다.

첫 파일에서는 그 검증기를 못 쓴다. cloud-init schema 는 게스트 안에 있는 cloud-init 22.4.2 이고, 첫 파일을 쓰는 시점에는 게스트가 아직 없다. lab host 에 cloud-init 이 깔려 있는지는 원본 가이드에 적혀 있지 않고 재지도 않았다(unknown). 그래서 첫 파일은 위 세 줄로 가고, 둘째 파일부터 아래처럼 게스트에서 검사한다.

확인 ② cloud-config 로 유효한가

YAML 로 파싱되는 것과 cloud-config 로 유효한 것은 다르다 — 키 이름 오타(userusers)는 확인 ① 을 그냥 통과한다.

이 실험대는 이렇게 했다(observed)

# 이 파일에는 콘솔 비밀번호가 평문으로 들어 있다 — /tmp 가 아니라
# 자기 홈에 600 으로 두고, 검사가 끝나면 바로 지운다
ssh donghyeon@192.168.122.11 'umask 077; cat > ~/kc-lab-2.yaml'  < kc-lab-2.yaml
ssh donghyeon@192.168.122.11 'cloud-init schema -c ~/kc-lab-2.yaml; rm -f ~/kc-lab-2.yaml'

따라 하는 사람은 한 줄에 SSH 접속·리다이렉션·umask·파일 생성·로컬 stdin 전달을 한꺼번에 놓지 않고 나눈다. 파일을 보내고, 들어가고, 검사하고, 지운다.

scp kc-lab-2.yaml donghyeon@192.168.122.11:~/
ssh donghyeon@192.168.122.11

게스트 안에서 친다. [kc-lab-1]

chmod 600 ~/kc-lab-2.yaml
cloud-init schema -c ~/kc-lab-2.yaml
rm ~/kc-lab-2.yaml

명령 수는 둘에서 다섯으로 늘고 행동 하나가 명령 하나가 된다. umask 077chmod 600 으로 바뀌는 것 하나가 다르다 — scp 가 만드는 동안에는 파일이 잠깐 기본 권한으로 놓이므로, 콘솔 비밀번호가 든 파일을 게스트에 두는 시간을 짧게 가져간다. 검사가 끝나면 지우는 것은 양쪽이 같다. 이 다섯 줄 형태는 이 실험대에서 치지 않았다(unknown).

실측 — 통과하면 이 한 줄이다(observed).

Valid cloud-config: /home/donghyeon/kc-lab-edge.yaml

어디를 봐야 하는가 — 마지막 한 줄. 통과하면 유효하다는 한 줄만 나오고, 아니면 Invalid cloud-config 아래에 어느 키가 왜 틀렸는지가 나열된다. 경고(deprecated)와 오류(error)는 다르다 — 경고는 지금 동작한다.

이 결과가 의미하는 것 — 오류가 나면 그 키는 조용히 무시된다. usersuser 로 잘못 쓰면 계정이 안 생기고, 증상은 역시 「SSH 가 안 붙는다」 하나로만 나타난다.

sudo 는 리스트가 아니라 문자열로 쓴다(observed). 게스트의 cloud-init 22.4.2 스키마 검사기가 리스트 형태를 거부한다.

sudo: ['ALL=(ALL) NOPASSWD:ALL']     # 거부된다
sudo: "ALL=(ALL) NOPASSWD:ALL"       # 통과한다
Error: Cloud config schema errors: users.0: {'name': 'donghyeon', 'groups':
['sudo'], 'shell': '/bin/bash', ...} is not valid under any of the given schemas

어느 키가 문제인지 안 알려 준다users.0 전체를 통째로 찍고 「어느 스키마에도 안 맞는다」고만 한다. 그래서 키를 하나씩 바꿔 가며 좁혀야 한다. 그리고 리스트 형태도 부팅은 된다kc-lab-1·kc-lab-2 가 그 상태로 NOPASSWD sudo 가 멀쩡히 돌고 있다. 검사기만 거부하므로 「검사는 실패했는데 왜 되지」로 읽히기 쉽다.

3. 시드 이미지를 만든다

목적 — cloud-init 은 cidata 라벨이 붙고 안에 user-data·meta-data 라는 정확한 이름의 파일이 있는 볼륨을 찾는다.

이 실험대는 이렇게 했다(observed)

printf 'instance-id: kc-lab-1-%s\nlocal-hostname: kc-lab-1\n' "$(date +%s)" > meta-kc-lab-1

xorrisofs -quiet -output seed-kc-lab-1.iso -volid CIDATA -joliet -rock \
  -graft-points /user-data=kc-lab-1.yaml /meta-data=meta-kc-lab-1

virsh vol-create-as default seed-kc-lab-1.iso "$(stat -c%s seed-kc-lab-1.iso)" --format raw
virsh vol-upload --pool default seed-kc-lab-1.iso seed-kc-lab-1.iso

따라 하는 사람은 meta-data 도 편집기로 쓴다. 첫 줄 하나를 읽으려고 printf %s \n $(...) date +%s > 다섯을 해석해야 하는데, 정작 배울 것은 instance-id:local-hostname: 두 키다.

nano meta-kc-lab-1
instance-id: kc-lab-1-20260912
local-hostname: kc-lab-1

kc-lab-1- 뒤의 20260912예시 값이다. 원래 명령은 거기에 date +%s(에폭 초)를 넣었다. 값 자체는 아무 의미가 없고, cloud-init 이 이전 실행과 다른 인스턴스로 알아보게 이전 값과 겹치지 않게 둔다. 날짜든 에폭 초든 저번과 다르기만 하면 된다.

나머지 네 줄은 그대로 친다. 시드 이미지를 굽고 볼륨을 만들어 올리는 일이 그 명령들의 목적이라 CLI 가 맞는 형태다.

xorrisofs -quiet -output seed-kc-lab-1.iso -volid CIDATA -joliet -rock \
  -graft-points /user-data=kc-lab-1.yaml /meta-data=meta-kc-lab-1

virsh vol-create-as default seed-kc-lab-1.iso "$(stat -c%s seed-kc-lab-1.iso)" --format raw
virsh vol-upload --pool default seed-kc-lab-1.iso seed-kc-lab-1.iso

확인 — 볼륨이 풀에 올라갔고 비어 있지 않은가

virsh vol-list default

어디를 봐야 하는가seed-kc-lab-1.iso 행이 목록에 있는가. 있으면 크기까지 본다.

virsh vol-info --pool default seed-kc-lab-1.iso
stat -c%s seed-kc-lab-1.iso

Capacity 가 방금 만든 로컬 파일 크기와 같아야 한다.

이 결과가 의미하는 것vol-create-as빈 볼륨을 만들 뿐이고 내용은 vol-upload 가 채운다. 두 명령 중 뒤엣것을 빠뜨리면 목록에는 이름이 보이는데 안이 0 으로 채워져 있고, cloud-init 은 cidata 라벨을 못 찾아 조용히 끝난다 — 증상은 또 「SSH 가 안 붙는다」다. 크기가 맞으면 4번으로 간다.

instance-id 에 타임스탬프를 넣는 까닭이 있다. cloud-init 은 인스턴스마다 한 번만 초기화 모듈을 돌린다. id 가 같으면 이미 한 것으로 보고 건너뛰므로 user-data 를 고쳐도 반영되지 않는다.

4. DHCP 로 IP 를 고정한다

목적 — 게스트가 재부팅해도 같은 IP 를 받게 한다.

★ 이 단계가 VM 생성보다 먼저다. 순서가 반대면 게스트가 동적 대역에서 아무 주소나 받아 버리고, 그 뒤에 예약을 넣어도 이미 잡은 리스가 유지된다. 되돌리려면 리스 만료를 기다리거나 게스트를 재시작해야 한다.

MAC 은 5번의 virt-install --network mac= 에 쓸 값을 여기서 미리 정하는 것이다. 아직 게스트가 없어도 예약은 들어간다 — 예약은 「이 MAC 이 나타나면 이 IP 를 줘라」이지 「지금 있는 게스트에게 줘라」가 아니다.

virsh net-update default add ip-dhcp-host \
  "<host mac='52:54:00:aa:bb:10' name='kc-lab-edge' ip='192.168.122.10'/>" \
  --live --config
virsh net-update default add ip-dhcp-host \
  "<host mac='52:54:00:aa:bb:11' name='kc-lab-1' ip='192.168.122.11'/>" \
  --live --config
virsh net-update default add ip-dhcp-host \
  "<host mac='52:54:00:aa:bb:12' name='kc-lab-2' ip='192.168.122.12'/>" \
  --live --config

넣을 때 나오는 한 줄이 이것이다(observed).

Updated network default persistent config and live state

persistent configlive state 두 마디가 다 나와야 --live --config 가 제대로 먹었다. --live 만 주면 재부팅에 사라지고, --config 만 주면 지금 반영되지 않는다.

이미 있는 예약을 또 넣으면 이렇게 거부된다(observed). 오류처럼 보이지만 이미 들어가 있다는 뜻이라 그냥 넘어가면 된다.

error: Requested operation is not valid: there is an existing dhcp host entry
in network 'default' that matches "<host mac='52:54:00:aa:bb:12' ... />"

★ zsh 에서 루프로 돌리지 않는다. zsh 는 따옴표 없는 변수를 단어 분리하지 않아서, bash 에서 되던 set -- $entryname="" 로 끝난다. 증상은 XML error: Cannot use host name '' in network 'default' 다. 세 줄을 값 그대로 쓰는 편이 안전하다.

확인 — 예약이 실제로 들어갔는가

virsh net-dumpxml default | grep -E "host mac|range start"

실측(observed)

<range start='192.168.122.2' end='192.168.122.254'/>
<host mac='52:54:00:aa:bb:11' name='kc-lab-1' ip='192.168.122.11'/>
<host mac='52:54:00:aa:bb:12' name='kc-lab-2' ip='192.168.122.12'/>
<host mac='52:54:00:aa:bb:10' name='kc-lab-edge' ip='192.168.122.10'/>

어디를 봐야 하는가<host> 세 줄의 MAC 끝 두 자리와 IP 끝 숫자가 짝이 맞는가(:10.10, :11.11, :12.12). 이 MAC 을 5번의 virt-install --network mac=한 글자도 다르지 않게 쓴다. <range> 줄은 예약이 아니라 동적 대역이라, 예약 IP 가 이 안에 들어 있어도 상관없다.

grep ip-dhcp-host 로 확인하지 않는다. ip-dhcp-hostnet-update섹션 이름이지 XML 안에 있는 문자열이 아니다. 그렇게 치면 예약이 멀쩡히 들어가 있어도 아무것도 안 나오고, 예약이 안 들어갔다고 오독하게 된다. XML 안의 실제 요소는 <host mac=...> 다.

이 결과가 의미하는 것 — 세 줄이 다 보이면 게스트는 재부팅해도 같은 IP 를 받는다. 빠진 줄이 있으면 --live --config 중 하나를 빠뜨렸다. net-dumpxml지금 돌고 있는 정의를 보여 주므로 여기 보이는 것은 --live 가 먹었다는 뜻이고, 재부팅 뒤에도 남는지는 따로 본다.

virsh net-dumpxml --inactive default

5. VM 을 만든다

목적 — 예약한 MAC 을 달고 base 위 오버레이로 게스트를 띄운다.

virt-install --name kc-lab-1 --memory 5120 --vcpus 2 \
  --disk size=20,backing_store=/var/lib/libvirt/images/base.qcow2 \
  --disk vol=default/seed-kc-lab-1.iso,device=disk,bus=virtio,readonly=on \
  --network network=default,mac=52:54:00:aa:bb:11 \
  --import --os-variant debian12 --noautoconsole

나머지 둘은 이름·메모리·MAC·시드만 바꾼다.

# kc-lab-2 — k3s agent
virt-install --name kc-lab-2 --memory 4096 --vcpus 2 \
  --disk size=20,backing_store=/var/lib/libvirt/images/base.qcow2 \
  --disk vol=default/seed-kc-lab-2.iso,device=disk,bus=virtio,readonly=on \
  --network network=default,mac=52:54:00:aa:bb:12 \
  --import --os-variant debian12 --noautoconsole

# kc-lab-edge — nginx + certbot 만 돌므로 훨씬 작아도 된다
virt-install --name kc-lab-edge --memory 1024 --vcpus 1 \
  --disk size=10,backing_store=/var/lib/libvirt/images/base.qcow2 \
  --disk vol=default/seed-kc-lab-edge.iso,device=disk,bus=virtio,readonly=on \
  --network network=default,mac=52:54:00:aa:bb:10 \
  --import --os-variant debian12 --noautoconsole

실측 — 엣지 생성 출력이다(observed).

Starting install...
Allocating 'kc-lab-edge.qcow2'                              |  10 GB  00:00
Creating domain...                                          |         00:00
Domain creation completed.

어디를 봐야 하는가Domain creation completed. 한 줄. 그 위 Allocating즉시(00:00) 끝나는 것이 정상이다 — 오버레이라 10GB 를 실제로 쓰지 않는다. 제4부 §143 이 적은 qcow2 의 희소 할당이 여기서 그대로 보인다.

★ 시드를 --cloud-init 으로 붙이지 않는다(observed). 그 옵션은 시드를 SATA CD-ROM 으로 붙이는데, Debian genericcloud 이미지는 크기를 줄이려고 물리 하드웨어 드라이버를 뺐다. AHCI 장치가 보이지 않아 cloud-init 이 데이터소스를 못 찾고 조용히 끝난다. bus=virtio 로 디스크로 붙여야 한다.

--disk size=20,backing_store=... 는 복사가 아니라 오버레이다. base 는 읽기 전용으로 두고 변경분만 새 파일에 쌓이므로, 20GB 를 두 개 만들어도 실제 디스크는 몇백 MB 만 쓴다.

끝났는지 판정한다

확인 ① 세 게스트가 떴고 cloud-init 이 제 일을 했는가

virsh list --all
ssh donghyeon@192.168.122.11 'hostname; cat /etc/os-release | head -1'

실측(observed)

 Id   Name          State
-----------------------------
 2    kc-lab-1      running
 4    kc-lab-2      running
 5    kc-lab-edge   running

kc-lab-1
PRETTY_NAME="Debian GNU/Linux 12 (bookworm)"

어디를 봐야 하는가 — 세 가지다. ① State 가 세 대 다 running 인가(shut off 면 아직 안 뜬 것이고, Id 칸이 - 로 비어 있다). ② SSH 가 비밀번호를 묻지 않고 통과했는가. ③ hostnamekc-lab-1 인가 localhost 인가.

이 결과가 의미하는 것 — 이 세 줄이 cloud-init 성공의 판정 기준 전부다. 호스트명이 바뀌어 있다는 것은 시드가 읽혔다는 뜻이고, 시드가 읽혔으면 같은 파일에 있던 SSH 키도 들어갔다는 뜻이다. localhost 가 나오면 SSH 설정을 고치지 말고 시드부터 의심한다. Id 번호가 2 보다 큰 것은 아무 뜻도 없다 (만들고 지운 이력일 뿐이다).

확인 ② 엣지가 예약한 IP 를 받았고 cloud-init 이 끝났는가

ssh kc-lab-edge 'hostname; ip -4 -br addr show enp1s0; cloud-init status'

실측(observed)

kc-lab-edge
enp1s0           UP             192.168.122.10/24 metric 100
status: done

어디를 봐야 하는가 — 세 줄이다. 호스트명이 kc-lab-edge 인가, IP 가 예약한 .10 인가, cloud-init statusdone 인가.

이 결과가 의미하는 것running 이면 아직 패키지를 받는 중이니 기다린다 — 이 실험대에서는 약 50초 걸렸다. error 면 어느 모듈이 실패했는지 길게 본다.

ssh kc-lab-edge 'cloud-init status --long'

확인 ③ 게스트에 못 들어갈 때는 화면을 직접 뜬다

virsh screenshot kc-lab-1 /tmp/kc1.ppm    # 확장자와 무관하게 PNG 로 저장된다

어디를 봐야 하는가 — 이미지를 열어 로그인 프롬프트 앞의 호스트명 한 낱말만 본다. localhost login: 인가 kc-lab-1 login: 인가.

이 결과가 의미하는 것localhost 면 cloud-init 이 아예 안 돌았다. 시드를 못 찾은 것(5번의 bus=virtio)이거나 YAML 파싱 실패(2번)이므로 SSH 쪽은 볼 필요가 없다. kc-lab-1 이면 cloud-init 은 돌았고 그 안의 사용자·키 단계에서 틀린 것이라, 이제 콘솔로 들어가 게스트 안의 로그를 본다.

virsh console kc-lab-1        # 빠져나오려면 Ctrl+]

게스트 안에서 친다. [kc-lab-1]

sudo cloud-init status --long
sudo journalctl -u cloud-init -n 50

콘솔 로그인에 쓸 비밀번호가 2번의 plain_text_passwd 다. 이 한 장과 이 두 줄이 「SSH 가 안 되는 이유」를 절반으로 줄인다.

막히면

증상 원인 확인
SSH Permission denied (publickey) · hostname 이 localhost cloud-init 이 안 돌았다. 시드를 SATA 로 붙였거나 YAML 파싱 실패 virsh screenshot
user-data 를 고쳤는데 반영 안 됨 instance-id 가 같아 건너뜀 meta-data 의 id 확인
IP 가 매번 바뀐다 DHCP 예약이 --config 없이 들어감 net-dumpxml
XML error: Cannot use host name '' zsh 가 따옴표 없는 변수를 단어 분리하지 않는다 세 줄을 값 그대로 친다
예약 넣을 때 existing dhcp host entry 이미 들어가 있다. 오류가 아니다 net-dumpxml 로 세 줄 확인
시드는 올라갔는데 cloud-init 이 안 돈다 vol-upload 를 빠뜨려 볼륨이 비었다 virsh vol-info --pool default seed-kc-lab-1.iso
qemu-img inforaw 라고 한다 내려받기가 끊겨 오류 페이지를 저장했다 다시 받는다
VM 이 느리다 KVM 미사용 00 으로

이 절차가 성립하는 범위

  • (observed) 게스트 세 대의 IP·MAC·vCPU·메모리, Debian GNU/Linux 12 (bookworm), cloud-init status: done, cloud-init 대기 약 50초, 스키마 검사기의 거부 문구, Valid cloud-config 한 줄, Updated network ... persistent config and live state, 이미 있는 예약을 다시 넣었을 때의 거부 문구, virt-install 의 네 줄 출력
  • (observed) 메모리는 처음 만들 때 3584MB 였고 실험을 늘리며 5120/4096 으로 재배분했다. 세 게스트의 배정 합은 5120+4096+1024 = 10,240MB 이고 호스트 실측은 11,648MiB(§178)다. 배정 합이 호스트 RAM 보다 작다 — 배정률 10240/11648 = 87.9%, 배정하지 않고 남은 것이 1,408MiB 다. 그래서 「Guest configured memory 총량이 Host physical RAM보다 크다」는 제2부 §57 의 Memory Overcommit 에 이 배치는 해당하지 않는다. 배정이 아니라 실제로 얼마를 점유하는지는 제7부 §198 이 따로 잰다
  • (unknown) kc-lab-1.yaml 을 템플릿에서 어떻게 만드는지가 원본에도 없다. kc-lab.yaml.examplesource/ 에 없어 대조하지 못했다
  • (observed) cloud-init 의 packages 에 certbot 은 없다 — 2026-09-17 에 갈렸다. 뜬 게스트가 실제로 받은 user-data 가 packages: [curl, nftables] 한 줄이고, kc-lab.yaml.example 에는 nginx·certbot· python3-certbot-dns-cloudflare 셋이 주석으로 막혀 있다. 04 의 표에 kc-lab-edgenginx · certbot 으로 적힌 것은 그 게스트가 끝나면 맡을 역할이지 cloud-init 이 깔아 준 것이 아니다
sudo grep -n "packages" /var/lib/cloud/instance/user-data.txt
19:packages: [curl, nftables]
  • (unknown) scp 다섯 줄 형태, virsh vol-list·vol-info·net-dumpxml --inactive·cloud-init status --long·virsh console 은 가이드가 적어 둔 명령이고 이 실험대가 캡처한 출력이 없다
  • (unknown) 원본 가이드에 되돌리는 절차가 없다. 03 이 지나가며 적은 virsh undefine kc-lab-edge --remove-all-storage 한 줄 말고는 게스트·시드 볼륨·DHCP 예약을 걷어내는 순서가 어디에도 없다
  • (inferred) virt-install 세 줄은 구축할 때 친 것을 옮긴 것이라 재실행으로 검증되지 않았다(§184 의 검증 방식)

188. 단계 02 — k3s server 와 agent

어디서 치는가이 단계는 전부 [lab host] 에서 친다. 게스트에 로그인하지 않는다. 까닭은 §185 의 ④ 가 적었고 요점만 옮기면 셋이다.

  • 게스트에는 lab host 의 개인키도 ~/.ssh/config 도 없다. 게스트 안에서 ssh kc-lab-1 을 치면 Host key verification failed 로 끝난다.
  • 그 실패를 TOKEN=$(...) 로 감싸면 오류는 화면으로 새고 변수는 빈 채로 남는다. 셸은 불평하지 않는다.
  • 그래서 3번의 토큰이 비고, 4번의 agent 설치가 --token is required 로 죽는다. 설치 스크립트는 그 전까지를 다 성공으로 찍고 끝나기 때문에 설치 출력만 보면 성공으로 읽힌다.

셸을 하나만 쓰면 이 문제가 통째로 없어진다. 예외가 둘이다. 1번의 확인은 lab host 에 아직 kubeconfig 가 없어 게스트 쪽 kubectl 로 한 번만 돌고, 8번은 [워크스테이션] 에서 친다.

이 단계가 세우는 것 — 가이드 02 의 「이 단계가 끝나면」은 두 줄이다.

lab host 에서 kubectl get nodes 를 치면 두 노드가 Ready 로 나온다. sudossh 도 붙이지 않는다.

무엇 kc-lab-1 kc-lab-2
역할 server (control-plane) agent
유닛 k3s.service k3s-agent.service
--node-ip 192.168.122.11 192.168.122.12
판 번호 v1.36.4+k3s1 v1.36.4+k3s1
kubeconfig /etc/rancher/k3s/k3s.yaml 없다
ROLES 열 control-plane <none> — 라벨이 없다는 뜻이다
어디에 무엇
lab host ~/.kube/config600. server:https://192.168.122.11:6443 으로 고친 사본
워크스테이션 ~/.kube/kc-lab.yaml — 원본 그대로(https://127.0.0.1:6443) + SSH 터널

k3s 가 따로 설치하지 않아도 딸려 오는 것이 다섯이다.

무엇
Traefik 인그레스 컨트롤러. :80 을 듣는다
servicelb (klipper-lb) LoadBalancer 타입을 호스트 포트로 매핑
local-path 기본 StorageClass. 노드 로컬 디스크
flannel 파드 네트워크 (VXLAN)
kube-router NetworkPolicy 집행

전제와 되돌리기

전제는 한 줄이다 — 「[01] 이 끝나 lab host 에서 두 게스트에 SSH 가 붙는다.」

되돌리는 절차는 원본 가이드 02 에 없다(unknown). k3s 설치 스크립트는 k3s-uninstall.shk3s-agent-uninstall.sh 를 함께 깔지만 가이드가 그 이름을 한 번도 적지 않았고 이 실험대도 부른 적이 없다. 가이드가 재설치를 말하는 곳은 두 군데인데 둘 다 되돌리는 명령이 아니라 뒤처리를 적어 두었다 — 노드 IP 가 다른 대역으로 잡혔으면 「그때 고치는 것보다 지금 재설치가 싸다」, CA(Certificate Authority, 인증서에 서명해 주는 쪽)가 바뀌었으면 「2번의 복사를 다시 한다」. 그래서 여기에 걷어내는 명령을 적지 않는다.

먼저 본다 — 바꾸기 전 상태

가이드 02 에도 바꾸기 전 상태를 보는 단계가 없다. 01 이 남긴 확인을 그대로 다시 치는 것이 이 단계의 전제다(inferred).

virsh list --all
ssh donghyeon@192.168.122.11 'hostname; cat /etc/os-release | head -1'

어디를 봐야 하는가 — 두 게스트가 running 인가, 그리고 SSH 가 비밀번호를 묻지 않고 호스트명을 찍는가.

이 결과가 의미하는 것ssh kc-lab-2 가 비밀번호를 물으면 3번의 토큰 전달과 4번의 설치가 전부 대화식으로 멈춘다. 여기서 걸러야 설치 도중에 멈추지 않는다.

실행 절차

번호는 가이드 02 의 것을 그대로 쓴다.

1. server 를 깐다 (kc-lab-1)

목적 — control-plane 을 세우고 자기 자신을 노드로 등록시킨다.

ssh kc-lab-1 'curl -sfL https://get.k3s.io | sudo sh -s - server --node-ip 192.168.122.11'

게스트에 들어가지 않고 원격 실행한다. cloud-init 이 NOPASSWD:ALL 을 넣어 두었으므로 비대화식 sudo 가 멈추지 않는다.

--node-ip 를 준다. 게스트에 인터페이스가 여럿이면 k3s 가 엉뚱한 것을 고를 수 있고, 그러면 두 노드가 서로를 다른 주소로 알게 된다.

확인 — 서버가 떴고 자기 자신을 노드로 등록했는가

ssh kc-lab-1 'sudo systemctl is-active k3s; sudo kubectl get nodes'

어디를 봐야 하는가 — 유닛이 active 인가, 그리고 get nodeskc-lab-1 한 줄이 Ready 로 있는가. 설치 직후 30초 남짓은 NotReady 이거나 아예 목록이 비어 있는 것이 정상이다 — CNI 가 아직 안 올라온 시간이다. 한 번 더 친다.

이 가이드에서 kubectl 을 게스트 쪽에서 돌리는 일은 여기 한 번으로 끝난다. lab host 에는 아직 kubeconfig 가 없기 때문이고, 그것을 두는 일이 바로 2번이다.

이 결과가 의미하는 것active + Ready 면 API 서버가 살아 있고 kubeconfig 도 자리를 잡았다는 뜻이라 2번으로 간다. 유닛이 active 인데 get nodes 가 접속 오류를 내면 API 서버가 아직 기동 중이다. 유닛이 activating 에서 안 넘어가거나 failed 면 설치 자체가 실패한 것이니 로그를 본다.

ssh kc-lab-1 'sudo journalctl -u k3s -n 50 --no-pager'

2. lab host 에 kubeconfig 를 둔다

목적 — 이 뒤로 kubectl 을 계속 쓴다. 지금 한 번 해 두면 남은 단계에서 sshsudo 도 붙이지 않는다. 그리고 agent 노드에는 kubeconfig 가 없으므로 (6번) 클러스터를 어디서 볼지 먼저 정해 두는 편이 낫다.

이 실험대는 이렇게 했다(observed)

mkdir -p ~/.kube
ssh kc-lab-1 'sudo cat /etc/rancher/k3s/k3s.yaml' \
  | sed 's|127.0.0.1|192.168.122.11|' > ~/.kube/config
chmod 600 ~/.kube/config

세 줄이 다 필요하다.

빠뜨리면
mkdir -p ~/.kube > 는 파일을 열 뿐 경로를 만들지 않는다. cat 이 시작되기도 전에 No such file or directory 로 끝난다
sed k3s 가 쓴 주소는 https://127.0.0.1:6443 이다. 게스트 안에서만 맞는 주소라 lab host 에서는 자기 자신의 6443 을 두드리게 된다
chmod 600 이 파일은 클러스터 admin 자격증명이다. 비밀번호 파일과 같은 급으로 다룬다

sudocat 에만 걸리고 > 에는 걸리지 않는다. 리다이렉션은 셸이 명령보다 먼저 현재 사용자 권한으로 처리하기 때문이다. 그래서 출력 파일은 홈 아래 (~/.kube/config)에 둔다.

따라 하는 사람은 받아 놓고 편집기로 고친다. kubeconfig 는 앞으로 클러스터를 볼 때마다 다시 열어 보게 되는 파일이라, 한 번은 전체가 어떻게 생겼는지 보는 편이 낫다. sed 로 치환하면 바꾼 줄도 나머지 줄도 보지 않고 지나간다 (unknown — 이 실험대는 위의 파이프로 했고 아래 형태는 치지 않았다).

mkdir -p ~/.kube
ssh kc-lab-1 'sudo cat /etc/rancher/k3s/k3s.yaml' > ~/.kube/config
chmod 600 ~/.kube/config
nano ~/.kube/config

server: 줄 하나를 고친다. 나머지는 그대로 둔다.

server: https://192.168.122.11:6443

확인 — 주소가 바뀌었고, 밖에서 붙는가

grep server: ~/.kube/config
kubectl get nodes

실측(observed)

    server: https://192.168.122.11:6443
NAME       STATUS   ROLES           AGE   VERSION
kc-lab-1   Ready    control-plane   47m   v1.36.4+k3s1

어디를 봐야 하는가server: 값에 127.0.0.1 이 남아 있으면 sed 가 안 먹었다. 그다음 get nodessudo 없이 도는가. 아직 노드는 한 줄뿐인 것이 정상이다 — agent 는 4번에서 붙인다.

이 결과가 의미하는 것 — 통과하면 이 뒤의 kubectl 은 전부 lab host 에서 친다. x509 오류가 나면 파일 안의 CA 와 서버가 지금 쓰는 CA 가 어긋났다 — k3s 를 다시 깔았다면 이 복사도 다시 해야 한다. connection refused 면 주소는 맞는데 API 서버가 아직 안 떴다.

인증서 SAN 에 두 주소가 다 들어 있어서sed 가 통한다. 확인하려면 이렇게 친다.

ssh kc-lab-1 'sudo openssl x509 -in /var/lib/rancher/k3s/server/tls/serving-kube-apiserver.crt -noout -ext subjectAltName'

IP Address:127.0.0.1IP Address:192.168.122.11 이 둘 다 보인다. 8번의 SSH 터널이 127.0.0.1 로 붙을 수 있는 것도 같은 까닭이다. 2026-09-17 에 그 출력을 실제로 받았다(observed).

X509v3 Subject Alternative Name:
    DNS:kubernetes, DNS:kubernetes.default, DNS:kubernetes.default.svc,
    DNS:kubernetes.default.svc.cluster.local, DNS:localhost, DNS:kc-lab-1,
    IP Address:127.0.0.1, IP Address:0:0:0:0:0:0:0:1,
    IP Address:192.168.122.11, IP Address:10.43.0.1, IP Address:192.168.122.11

192.168.122.11 이 두 번 들어 있는데 중복이고 동작에는 영향이 없다. 10.43.0.1 은 클러스터 안에서 API 서버를 부르는 Service 주소다.

그리고 journalctl -u k3s-agent 는 정상일 때도 E 로 시작하는 줄이 가득하다(observed). 이것을 모르면 「막혔을 때 여기를 보라」는 안내대로 열었다가 멀쩡한 노드를 고장으로 읽는다. 같은 순간 kubectl get nodes 는 둘 다 Ready 였다.

E0917 07:42:16.919786 conn.go:353] "Error on socket receive" err="read tcp 127.0.0.1:10250->127.0.0.1:55428: use of closed network connection"

kubectl execlogs 로 연결했다가 끊을 때마다 한 줄씩 남는다. 봐야 할 것은 E 인지가 아니라 문구다 — 토큰이나 주소가 틀렸으면 failed to get CA certs401 Unauthorized 가 나온다.

3. 토큰을 꺼낸다

목적 — agent 가 클러스터에 들어올 때 낼 자격을 손에 쥔다. 화면에 찍어 눈으로 옮기지 말고 변수로 받는다.

TOKEN=$(ssh kc-lab-1 'sudo cat /var/lib/rancher/k3s/server/node-token')
echo "${#TOKEN} 자"        # 값이 아니라 길이만 확인한다

확인 — 찍히는 것은 자릿수 하나뿐이다. 값은 보지 않는다 — 화면에 띄우는 순간 터미널 스크롤백과 셸 히스토리에 남는다.

실측 — 이 실험대에서는 108자였다(observed). K10<해시>::server:<비밀번호> 형식이라 k3s 판올림에 따라 자릿수가 달라진다. 중요한 것은 값이 아니라 0 이 아니라는 사실이다.

이 결과가 의미하는 것 — 세 자리 수가 나오면 토큰을 손에 쥔 것이니 4번으로 넘어간다. 0 이면 변수가 비었다는 뜻이고 원인은 셋 중 하나다.

0 인 이유 확인
게스트 안에서 쳤다 (가장 흔하다) 프롬프트가 kc-lab-1 이면 exit 로 lab host 로 나온다
server 가 아직 안 떠서 파일이 없다 ssh kc-lab-1 'sudo ls -l /var/lib/rancher/k3s/server/node-token'
새 셸을 열어 변수가 사라졌다 echo "${#TOKEN} 자"4번을 칠 바로 그 셸에서 다시 친다

파일이 있는지부터 볼 때는 이 줄이다.

ssh kc-lab-1 'sudo ls -l /var/lib/rancher/k3s/server/node-token'

★ 4번을 3번과 같은 셸에서 친다. 변수는 셸 밖으로 나가지 않는다. 창을 새로 열거나 ssh 로 어딘가 들어갔다 나오면 TOKEN 은 없다.

4. agent 를 붙인다 (kc-lab-2)

목적 — 두 번째 노드를 클러스터에 넣는다. 3번과 같은 셸에서 친다.

[ ${#TOKEN} -ge 50 ] || echo "TOKEN 이 비었다 — 3번으로 돌아간다"

ssh kc-lab-2 "curl -sfL https://get.k3s.io | sudo sh -s - agent \
  --server https://192.168.122.11:6443 \
  --token '$TOKEN' \
  --node-ip 192.168.122.12"

첫 줄의 가드를 빼지 않는다. $TOKEN 이 비면 게스트에는 --token '' --node-ip ... 가 전달되고, agent 는 기동 즉시 level=fatal msg="Error: --token is required" 로 죽는다. 그런데 설치 스크립트는 내려받기·유닛 생성·enable 까지 다 성공으로 찍고 끝나고, 유닛은 Restart=always 라 5초마다 조용히 재시도한다. 가드 한 줄이 그 몇 분을 막는다.

토큰을 셸 히스토리에 남기고 싶지 않으면 파일로 넘긴다. 이러면 변수를 쓰지 않으므로 3번의 「같은 셸」 제약도 없어진다.

이 실험대는 이렇게 했다(원본 02 본문)

ssh kc-lab-1 'sudo cat /var/lib/rancher/k3s/server/node-token' \
  | ssh kc-lab-2 'sudo tee /tmp/token >/dev/null'
ssh kc-lab-2 "curl -sfL https://get.k3s.io | sudo sh -s - agent \
  --server https://192.168.122.11:6443 --token-file /tmp/token \
  --node-ip 192.168.122.12; rm -f /tmp/token"

따라 하는 사람은ssh 를 파이프로 잇지 않는다. 이 두 줄 안에 원격 셸 둘과 sudo 둘, 파이프 하나, 설치 스크립트 하나, 마지막 rm 하나가 겹쳐 있어 실패했을 때 어느 쪽이 실패했는지 갈리지 않는다. 토큰을 lab host 에 한 번 내려놓고, 옮기고, 게스트에 들어가서 설치한다.

ssh kc-lab-1 'sudo cat /var/lib/rancher/k3s/server/node-token' > node-token
wc -c node-token        # 값이 아니라 길이만 본다
scp node-token kc-lab-2:~/node-token
rm node-token
ssh kc-lab-2

게스트 안에서 친다. [kc-lab-2]

chmod 600 ~/node-token
curl -sfL https://get.k3s.io | sudo sh -s - agent \
  --server https://192.168.122.11:6443 \
  --token-file ~/node-token \
  --node-ip 192.168.122.12
rm ~/node-token

토큰이 /tmp/token 대신 자기 홈에 놓이고, sudo tee 대신 scpchmod 600 이 그 파일을 만든다. 설치 스크립트는 sudo 아래 root 로 도니 홈의 600 파일도 읽는다.

★ 그런데 마지막 줄의 rm ~/node-token 만 치면 이 노드는 다시 못 뜬다. 설치 스크립트가 --token-file 에 준 경로를 유닛의 ExecStart그대로 굽기 때문에, 파일이 사라지면 agent 가 영원히 그 파일을 기다린다. 2026-09-17 에 A-4 를 돌려 노드 전원을 뽑았다 다시 켜면서 그대로 겪었다(observed) — VM 은 떴는데 노드가 NotReady 에서 안 돌아왔다.

k3s[522]: level=info msg="Waiting for file \"/home/donghyeon/node-token\" to be created"
k3s-agent.service: Scheduled restart job, restart counter is at 1.

지우기 전에 토큰을 root 만 읽는 자리로 옮기고 유닛을 그쪽으로 돌린다. 그렇게 하고 홈의 사본을 지우자 agent 를 완전히 세웠다 다시 켜도 active 였고 노드도 Ready 로 돌아왔다(observed).

sudo install -m 600 -o root -g root ~/node-token /etc/rancher/node-token
sudo ls -l /etc/rancher/node-token
sudo sed -i "s|$HOME/node-token|/etc/rancher/node-token|" /etc/systemd/system/k3s-agent.service
sudo systemctl daemon-reload
sudo systemctl restart k3s-agent
rm ~/node-token
-rw------- 1 root root 109 /etc/rancher/node-token
	'--token-file' \
	'/etc/rancher/node-token' \

이 여덟 줄 형태는 2026-09-17 에 쳤다 — 설치 자체는 이 판으로 다시 돌리지 않았고, 유닛을 고쳐 다시 시작하는 쪽만 확인했다(observed).

따라 하는 사람은 유닛을 편집기로 연다. sed -i 는 패턴이 안 맞아도 조용히 성공하고, 여기 패턴에는 $HOME 이 들어 있어서 설치할 때와 다른 계정으로 치면 아무 줄도 안 바뀐 채 daemon-reloadrestart 가 이어서 돈다. 그 뒤 노드는 지금 떠 있으니 정상으로 보이고, 다음 재부팅에서야 Waiting for file 로 멎는다.

sudo nano /etc/systemd/system/k3s-agent.service

ExecStart= 아래에서 '--token-file' 다음 줄의 경로를 /etc/rancher/node-token 으로 고친다. 고친 뒤 그 두 줄이 이렇게 보인다.

	'--token-file' \
	'/etc/rancher/node-token' \

저장하고 나와서 유닛을 다시 읽힌다.

sudo systemctl daemon-reload
sudo systemctl restart k3s-agent

이 편집기 형태는 이 실험대에서 치지 않았다(unknown).

확인 — agent 가 클러스터에 들어왔는가, 그리고 제 주소로 들어왔는가

kubectl get nodes -o wide

실측(observed)

NAME       STATUS   ROLES           AGE   VERSION        INTERNAL-IP
kc-lab-1   Ready    control-plane   47m   v1.36.4+k3s1   192.168.122.11
kc-lab-2   Ready    <none>          21m   v1.36.4+k3s1   192.168.122.12

어디를 봐야 하는가-o wide 를 주는 까닭이 마지막 열이다. INTERNAL-IP 두 개가 1번·4번에서 --node-ip 로 준 값과 같은가. 그다음이 STATUS 두 줄 Ready, 그다음이 ROLES 열이다. <none> 은 오류가 아니라 역할 라벨이 없다는 뜻이다 — agent 는 원래 그렇다.

이 결과가 의미하는 것 — 두 줄이 Ready 이고 IP 가 맞으면 이 단계는 끝났다. IP 가 다른 대역(예: flannel 이나 다른 인터페이스 주소)으로 잡혀 있으면 지금은 아무 증상이 없다가 03 의 nginx upstream 과 A층의 노드 상실 실험에서 어긋난다 — 그때 고치는 것보다 지금 재설치가 싸다. kc-lab-2 가 아예 안 보이면 join 이 실패한 것이니 agent 쪽 로그를 본다.

ssh kc-lab-2 'sudo journalctl -u k3s-agent -n 30 --no-pager'

5. 유닛 이름이 다르다

노드 유닛
server k3s.service
agent k3s-agent.service

확인 — 어느 노드가 무슨 이름으로, 어떤 인자로 돌고 있는가

ssh kc-lab-1 'systemctl cat k3s | grep -A3 ExecStart='
ssh kc-lab-2 'systemctl cat k3s-agent | grep -A4 ExecStart='

실측(observed)

ExecStart=/usr/local/bin/k3s server '--node-ip' '192.168.122.11'
ExecStart=/usr/local/bin/k3s agent  '--node-ip' '192.168.122.12'

어디를 봐야 하는가ExecStart= 줄의 부분명령(server/agent)과 그 뒤의 인자. 설치 스크립트에 준 옵션이 여기 그대로 굳어 있다. 유닛 이름을 틀리면 (systemctl cat k3s 를 agent 노드에서) No files found 가 나오는데, 그것 자체가 「이 노드는 agent 다」라는 답이다.

이 결과가 의미하는 것 — 4번의 get nodes -o widek3s 가 보고한 IP 이고 이 줄은 우리가 준 IP 다. 둘이 다르면 옵션이 안 먹었다. 그리고 뒤의 실험에서 노드를 멈출 때 칠 유닛 이름이 노드마다 다르다는 것을 여기서 확인해 둔다 — systemctl stop k3s 를 agent 노드에서 치면 아무 일도 일어나지 않고, 「주입했는데 증상이 없다」로 오독하게 된다. server 노드를 잃으면 kubectl 자체가 불통이 되고, agent 를 잃으면 kubectl 은 되지만 그 위의 워크로드가 사라진다.

6. agent 노드에서는 kubectl 이 안 된다

kc-lab-2 에 들어가 sudo kubectl get pods -A 를 치면 이렇게 끝난다(observed).

Get "http://localhost:8080/api?timeout=32s": dial tcp [::1]:8080: connect: connection refused

kubectl 명령 자체는 있다. 설치 스크립트가 /usr/local/bin/kubectl -> k3s 심볼릭 링크를 만들기 때문이다. 없는 것은 붙을 곳을 알려 주는 파일, 곧 kubeconfig 다.

어디를 찾나 kc-lab-1 kc-lab-2
$KUBECONFIG (비어 있음) (비어 있음)
~/.kube/config 없음 없음
/etc/rancher/k3s/k3s.yaml 있음 없음

넷 다 못 찾으면 kubectl 은 오류를 내지 않고 하드코딩된 기본값 http://localhost:8080 으로 넘어간다. 쿠버네티스 1.20 이전 API 서버가 평문으로 열던 레거시 포트인데 지금은 아무도 열지 않는다. localhost:8080 이 보이면 네트워크 문제가 아니라 「설정을 하나도 못 찾았다」는 뜻이다. 이 주소는 어디에도 적혀 있지 않다. 방화벽이나 k3s 를 의심하기 전에 kubeconfig 부터 본다.

워커라서 파드가 안 보이는 것이 아니다. kubectl 은 그냥 HTTP 클라이언트라 어디서 실행하든 상관없다. k3s.yaml 을 kc-lab-2 로 복사해 넣으면 거기서도 전부 보인다. 그래도 복사하지 않는다 — 워커 한 대가 털리면 클러스터 전체가 털리는 구성이 된다. agent 가 가진 자격증명은 급이 다르다.

subject=O = system:nodes, CN = system:node:kc-lab-2

이 신원은 Node authorizer 와 NodeRestriction admission 이 자기 노드에 배정된 객체만 다루도록 제한한다. 게다가 그 자격증명은 kubelet 전용 경로 (/var/lib/rancher/k3s/agent/)에 있어 kubectl 이 읽지도 않는다. 그래서 실패가 「권한 없음(403)」이 아니라 「설정 없음(localhost:8080)」으로 나타난다.

7. k3s 가 기본으로 딸려 오는 것

확인[lab host]. 무엇이 이미 돌고 있고, 기본 저장소가 무엇인가

kubectl get pods -A
kubectl get storageclass

실측(observed)

NAMESPACE     NAME                                      READY   STATUS      RESTARTS      AGE
kube-system   coredns-54996dc9b4-x5bsz                  1/1     Running     0             49m
kube-system   helm-install-traefik-cnv8z                0/1     Completed   2 (48m ago)   48m
kube-system   helm-install-traefik-crd-c6vft            0/1     Completed   0             48m
kube-system   local-path-provisioner-77b9867795-5k8d2   1/1     Running     0             49m
kube-system   metrics-server-6dc596dfb8-nzwt2           1/1     Running     0             49m
kube-system   svclb-traefik-a18ee1fc-2s9rl              2/2     Running     0             48m
kube-system   svclb-traefik-a18ee1fc-xmrrt              2/2     Running     0             22m
kube-system   traefik-59b7647586-rsrbc                  1/1     Running     0             48m

NAME                   PROVISIONER             RECLAIMPOLICY   VOLUMEBINDINGMODE      ALLOWVOLUMEEXPANSION   AGE
local-path (default)   rancher.io/local-path   Delete          WaitForFirstConsumer   false                  49m

어디를 봐야 하는가get pods -A 에서는 NAMESPACE 열이 kube-system 인 줄들의 STATUS. RunningCompleted 가 섞여 있는 것이 정상이다 — helm-install-traefik-* 는 일회성 잡이라 Completed 로 남는다. svclb-traefik-*두 줄인 것도 봐 둔다. DaemonSet 이라 노드마다 하나씩 뜨는 것이고, 이 두 줄이 4번의 join 이 실제로 먹었다는 또 하나의 증거다. get storageclass 에서는 이름 뒤의 (default) 표시가 어디 붙어 있는가.

이 결과가 의미하는 것 — 여기 뜬 것들은 우리가 안 깔았는데 있는 것이고, 뒤 단계에서 「왜 80 포트가 이미 잡혀 있지」·「왜 PVC 가 이 노드에만 묶이지」의 답이 전부 이 목록에 있다. local-path(default) 가 붙어 있으면 05 의 PVC 는 StorageClass 를 안 적어도 이것으로 만들어진다. Pending 이나 CrashLoopBackOff 가 섞여 있으면 그 파드부터 describe 로 본다. 제4부 §131·§159 가 적은 노드 로컬 디스크가 local-path 를 통해 여기서 그대로 걸린다.

8. 워크스테이션에서 쓰려면

2번의 kubeconfig 를 그대로 가져와도 안 된다. 게스트는 libvirt NAT 안에 있어서 워크스테이션에서 192.168.122.11 로 가는 경로가 없다. lab host 를 거치는 터널을 뚫는다.

이 실험대는 이렇게 했다(observed). [워크스테이션]

# 1) 터널. 이 창은 열어 둔다
ssh -N -L 6443:192.168.122.11:6443 test-server

# 2) 다른 창에서 — 게스트 원본을 그대로 가져온다. sed 가 필요 없다
mkdir -p ~/.kube
ssh test-server "ssh kc-lab-1 'sudo cat /etc/rancher/k3s/k3s.yaml'" > ~/.kube/kc-lab.yaml
chmod 600 ~/.kube/kc-lab.yaml
export KUBECONFIG=~/.kube/kc-lab.yaml

따라 하는 사람은 ssh 를 겹치지 않는다. 두 번째 줄은 워크스테이션에서 lab host 로 붙고 lab host 가 다시 게스트로 붙는데, 따옴표가 두 겹이라 어느 기계에서 어느 명령이 도는지가 한 줄에 묻힌다. 파일을 게스트에서 lab host 로 한 번, lab host 에서 워크스테이션으로 한 번 옮기면 기계마다 한 명령이 된다(unknown — 이 실험대는 위의 한 줄로 했고 아래 형태는 치지 않았다).

# lab host 에서 — 게스트 원본을 자기 홈에 받는다
ssh kc-lab-1 'sudo cat /etc/rancher/k3s/k3s.yaml' > kc-lab.yaml
chmod 600 kc-lab.yaml
# 워크스테이션에서 — 받아 오고 원본은 지운다
mkdir -p ~/.kube
scp test-server:kc-lab.yaml ~/.kube/kc-lab.yaml
chmod 600 ~/.kube/kc-lab.yaml
export KUBECONFIG=~/.kube/kc-lab.yaml
ssh test-server 'rm kc-lab.yaml'

2번과 달리 sed 를 치지 않는다. k3s 원본이 이미 https://127.0.0.1:6443 이고, 터널 덕에 워크스테이션에서는 그 주소가 맞기 때문이다. 2번이 192.168.122.11 로 바꿨던 것은 lab host 에서 볼 때 127.0.0.1 이 lab host 자신을 가리키기 때문이었다. 같은 파일이라도 어느 기계에서 읽느냐에 따라 맞는 주소가 다르다.

확인

grep server: ~/.kube/kc-lab.yaml
kubectl get nodes

어디를 봐야 하는가server:https://127.0.0.1:6443 인가. 그다음 get nodes 가 4번과 같은 두 줄을 내놓는가.

이 결과가 의미하는 것 — 터널 덕에 워크스테이션의 6443 이 kc-lab-1 의 6443 이다. API 서버 인증서 SAN 에 127.0.0.1 이 들어 있어서 인증서 검증도 통과한다. 타임아웃이면 1)의 터널 창이 닫힌 것이고, connection refused 면 터널은 살아 있는데 반대편 API 서버가 죽었다.

★ 터널이 닫히면 kubectl 이 통째로 멎는다. 이 실험대의 A층 실험은 노드를 죽이고 살리는 것이 목적이라, 터널 상태와 클러스터 상태가 섞여 오독하기 쉽다. A층 실험은 lab host 에서 치는 것을 권한다.

끝났는지 판정한다

무엇 명령 통과
두 노드 kubectl get nodes -o wide 둘 다 Ready · INTERNAL-IP 가 .11·.12
자리 위 명령에 sudossh 도 안 붙는다 lab host 에서 그대로 돈다
유닛 ssh kc-lab-1 'systemctl cat k3s | grep -A3 ExecStart=' server '--node-ip' '192.168.122.11'
유닛 ssh kc-lab-2 'systemctl cat k3s-agent | grep -A4 ExecStart=' agent '--node-ip' '192.168.122.12'
딸려 온 것 kubectl get pods -A svclb-traefik-*두 줄
기본 저장소 kubectl get storageclass local-path (default)

막히면

증상 원인 확인
echo "${#TOKEN} 자"0 게스트 안에서 쳤거나, 셸이 바뀌었다 프롬프트가 kc-lab-1 이 아닌지. 3번 표
agent 설치는 성공했는데 노드가 안 보임 --token 이 빈 문자열 ssh kc-lab-2 'sudo journalctl -u k3s-agent -n 30 --no-pager'--token is required
agent 가 NotReady 토큰·주소 오타 ssh kc-lab-2 'sudo journalctl -u k3s-agent -n 30 --no-pager'
노드 IP 가 예상과 다름 --node-ip 없이 설치 kubectl get nodes -o wide
lab host 에서 kubectlNo such file mkdir -p ~/.kube 를 빠뜨렸다 위 2번
lab host 에서 connection refused kubeconfig 의 127.0.0.1 을 안 바꿨다 grep server: ~/.kube/config
kc-lab-2 에서 localhost:8080 agent 에는 kubeconfig 가 없다. 정상이다 위 6번
워크스테이션에서 타임아웃 터널이 없다 위 8번
x509 오류 k3s 재설치로 CA 가 바뀌었다 2번의 복사를 다시 한다
파드가 한 노드에만 몰림 스케줄러 판단 topologySpreadConstraints 로 강제

이 절차가 성립하는 범위

  • (observed) v1.36.4+k3s1, 두 노드 Ready, INTERNAL-IP 두 개, 토큰 108자, ExecStart 두 줄, agent 의 localhost:8080 오류와 인증서 subject
  • (observed) kube-system 에 뜬 파드 여덟 줄과 local-path (default) StorageClass. svclb-traefik-* 가 두 줄인 것이 DaemonSet 이 두 노드에 다 떴다는 증거다
  • (external) 쿠버네티스 1.20 이전의 평문 8080, Node authorizer·NodeRestriction 의 동작은 이 실험대에서 잰 것이 아니다
  • (unknown) 설치 명령 두 줄은 재실행으로 검증되지 않았다
  • (unknown) openssl x509 ... -ext subjectAltNamesudo journalctl -u k3s -n 50 --no-pager 는 가이드가 적어 둔 명령이고 이 실험대가 캡처한 출력이 없다. SAN 에 두 주소가 들어 있다는 것은 sed 와 터널이 둘 다 통한다는 사실로 뒷받침된다(inferred)
  • (unknown) 원본 가이드에 되돌리는 절차가 없다. k3s-uninstall.sh 라는 이름이 가이드에 한 번도 안 나오고 이 실험대도 부른 적이 없다

189. 단계 03 — 엣지 nginx 라우팅과 호스트 DNAT

어디서 치는가 — 이 단계는 셸이 둘로 갈린다. 0~2 번은 [kc-lab-edge] 에서, 3번은 [lab host] 에서 친다. 4번의 층별 확인은 lab host 에서 친다.

번호 무엇 어디서
0 nginx 설치와 기본 사이트 끄기 [kc-lab-edge]
1 라우팅 설정 파일 [kc-lab-edge]
2 문법 검사와 reload [kc-lab-edge]
3 DNAT 규칙과 유닛 [lab host]여기만 엣지가 아니다
4 층별 확인 ①~④ [lab host]

3번만 호스트에서 치는 까닭이 둘이다. 규칙의 첫 줄이 iifname "tailscale0" 인데 VM 에는 Tailscale 을 넣지 않기로 했으므로 엣지에는 그 인터페이스 자체가 없고, 넘기는 대상이 192.168.122.10 으로 가는 트래픽이라 넘기는 주체는 그 앞에 있는 호스트다. 엣지에 SSH 해서 치면 tailscale0 이 없어 규칙이 의미가 없다.

이 단계가 세우는 것 — 가이드 03 의 「이 단계가 끝나면」은 두 줄이다.

밖에서 보낸 요청이 호스트 DNAT → 엣지 nginx → Traefik → 파드로 닿는다. 아직 TLS 는 없다.

§179 가 이 이동에서 새로 필요해진 일곱 가지를 적었고, §180 이 그중 가장 오래 막힌 guest_inputreject 를 적었다. 여기서는 세운 것 자체를 적는다.

어디에 무엇 저장소 원본
[kc-lab-edge] /etc/nginx/sites-available/keycloak-lab + sites-enabled/ 심볼릭 링크 deploy/lab/edge/nginx-keycloak-lab.conf
[kc-lab-edge] sites-enabled/default 를 지운다
[lab host] /etc/nftables.d/lab-edge-dnat.nft deploy/lab/edge/lab-edge-dnat.nft
[lab host] /etc/systemd/system/lab-edge-dnat.service deploy/lab/edge/lab-edge-dnat.service
무엇
nginx (엣지) Debian 12 의 nginx/1.22.1
upstream 192.168.122.11:80 · 192.168.122.12:80 — 기본 라운드로빈
DNAT iifname "tailscale0" tcp dport { 80, 443 } dnat to 192.168.122.10
libvirt 구멍 유닛의 ExecStartPostguest_input 맨 앞insert

왜 프록시가 두 겹인가. nginx 와 Traefik 이 하는 일이 다르다.

맡는 것
엣지 nginx (kc-lab-edge) 바깥세상과의 접점 — TLS 종단 · 인증서 · X-Forwarded-*
Traefik 클러스터 안의 동적 라우팅 — Ingress 를 보고 서비스를 고른다

이 2홉이 운영 구조와 같다는 것이 이 배치의 핵심이고, 동시에 헤더 실험이 성립하는 까닭이다. 1홉을 가정하고 쓴 계약이 2홉에서도 유효한지를 재려면 두 겹이 있어야 한다.

전제와 되돌리기

전제는 두 줄이다 — 「[02] 가 끝나 두 노드가 Ready 이고, [01] 에서 kc-lab-edge(192.168.122.10) 까지 세 대가 떠 있다.」 그리고 엣지에 nginx 는 아직 없다 — cloud-init 이 까는 것은 curlnftables 뿐이라 0번에서 직접 깐다.

되돌리기가 적힌 것은 가이드 7편 가운데 03 뿐이다. 세우는 절차가 아니라 이 단계를 걷어낼 때만 본다.

무엇을 [어디서] 명령
DNAT 만 끈다 [lab host] sudo systemctl disable --now lab-edge-dnat.service
규칙만 즉시 걷어낸다 [lab host] sudo nft delete table ip lab_edge
libvirt 에 뚫은 구멍을 막는다 [lab host] sudo nft -a list chain ip libvirt_network guest_input 로 handle 을 보고 sudo nft delete rule ip libvirt_network guest_input handle <번호>
엣지 라우팅을 끈다 [kc-lab-edge] sudo rm /etc/nginx/sites-enabled/keycloak-lab && sudo nginx -t && sudo systemctl reload nginx

sites-available 의 원본은 남으므로 ln -sf 로 다시 걸면 복구된다.

.nft 파일 맨 위의 delete 는 이것과 다르다. 그 두 줄은 파일을 적용할 때마다 자동으로 도는 재적용 안전장치(없는 테이블을 지우면 에러라서 빈 테이블을 한 번 만들고 지운다)이고, 위 표의 nft delete사람이 끄는 버튼이다.

먼저 본다 — 바꾸기 전 상태

확인 ① nginx 가 깔려 있는가[kc-lab-edge]

ls /etc/nginx/

관측 — 2026-09-11, 새로 만든 kc-lab-edge 에서 실제로 나온 것(observed)

donghyeon@kc-lab-edge:~$ cd /etc/nginx/
-bash: cd: /etc/nginx/: No such file or directory

어디를 봐야 하는가No such file or directory 가 나오면 미설치다. /etc/nginx 디렉터리는 패키지가 만든다. 그것이 없다는 것은 경로를 잘못 찾은 것이 아니라 설치가 안 된 것이다.

★ 여기에 which nginx 를 쓰지 않는다. 처음에는 그렇게 적어 두었는데 깔려 있을 때도 똑같이 아무것도 안 찍힌다. 2026-09-17 에 nginx 가 돌고 있는 kc-lab-edge 에서 직접 쳐서 확인했다(observed).

which nginx   -> (아무것도 안 찍고 exit 1)
PATH          -> /usr/local/bin:/usr/bin:/bin:/usr/games
sudo nginx -v -> nginx version: nginx/1.22.1

실행 파일은 /usr/sbin/nginx 인데 데비안의 일반 사용자 PATH/usr/sbin 이 없다. 그래서 which nginx 의 빈손은 **「안 깔렸다」가 아니라 「이 PATH 에서는 안 보인다」**이고, 깔린 경우와 안 깔린 경우가 같은 출력이라 판정에 쓸 수 없다. 판 번호를 볼 때도 nginx -v 가 아니라 sudo nginx -v 로 친다.

실행 절차

번호는 가이드 03 의 것을 그대로 쓴다.

0. nginx 를 깔고 기본 사이트를 끈다[kc-lab-edge]

목적 — 80 을 듣는 것이 우리 설정 하나가 되게 만든다.

sudo apt update && sudo apt install -y nginx

확인 — 떴는가, 그리고 Debian 관례의 두 디렉터리가 있는가

systemctl status nginx --no-pager | head -5
ls /etc/nginx/

어디를 봐야 하는가Active: 줄이 active (running) 인가, 그리고 ls 결과에 sites-availablesites-enabled 가 둘 다 있는가. Debian 계열은 설치와 동시에 기동까지 한다 — 따로 systemctl start 를 칠 일이 없다.

이 결과가 의미하는 것 — 이 시점에 nginx 는 이미 80 포트를 잡고 있다. 그것을 잡고 있는 것이 sites-enabled/default 이고, 1번에서 쓸 설정도 listen 80 default_server그대로 두면 겹친다.

기본 사이트를 끈다.

ls -l /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default

어디를 봐야 하는가ls -l 의 화살표다. default -> ../sites-available/default 처럼 심볼릭 링크다. 지우는 것은 링크뿐이고 원본은 sites-available/default 에 그대로 있다 — 되돌리려면 ln -s 로 다시 걸면 된다.

이 결과가 의미하는 것 — 안 지우면 2번의 nginx -ta duplicate default server for 0.0.0.0:80 으로 막는다. 설정이 틀린 것이 아니라 기본 사이트와 겹친 것이다.

sites-available 은 복수형이다. site-available 로 치면 nano 가 군말 없이 빈 새 파일을 연다. 저장해도 nginx 는 그 파일을 영원히 안 읽고, nginx -t 는 멀쩡히 통과한다 — 아무 에러 없이 아무 일도 안 일어나는 가장 찾기 어려운 형태다. 설정을 썼는데 변화가 없으면 경로 오타부터 의심한다.

확인 — 방금 쓴 파일이 제자리에 있는가

ls /etc/nginx/sites-available/

어디를 봐야 하는가 — 방금 쓴 파일 이름이 여기 보이는가. 안 보이면 다른 데다 썼다. 어디다 썼는지는 이렇게 찾는다.

sudo find /etc/nginx -name 'keycloak*'

Arch 호스트에는 이 구조가 없다. sites-available/sites-enabled 는 Debian 패키징 관례다. Arch 의 nginx 는 nginx.conf 에 직접 쓰거나 conf.d/ 를 쓴다. 이 실험대는 운영과 맞추려고 Debian 게스트를 엣지로 두었다 — 그래서 여기서는 Debian 관례가 그대로 통한다.

1. 설정을 쓴다[kc-lab-edge]

목적 — 엣지로 들어온 요청을 두 노드의 Traefik 으로 넘긴다.

이 단계에서는 80 만 세운다. TLS 는 04 에서 얹는다. 인증서가 없는데 ssl_certificate 줄을 미리 써 두면 설정 전체가 실패해서 80 블록까지 안 뜬다 — nginx 는 그 파일을 나중이 아니라 기동·reload 시점에 읽기 때문이다.

파일을 연다.

sudo nano /etc/nginx/sites-available/keycloak-lab

아래 내용을 쓴다. 저장은 Ctrl+OEnter, 나가기는 Ctrl+X.

# file: /etc/nginx/sites-available/keycloak-lab
upstream k3s_traefik {
	server 192.168.122.11:80;
	server 192.168.122.12:80;
}

server {
	listen 80 default_server;
	server_name _;

	location / {
		proxy_pass http://k3s_traefik;
		proxy_http_version 1.1;
		proxy_set_header Host              $host;
		proxy_set_header X-Forwarded-Host  $host;
		proxy_set_header X-Forwarded-Proto http;
		proxy_set_header X-Forwarded-Port  80;
		proxy_set_header X-Forwarded-For   $remote_addr;
		proxy_set_header X-Real-IP         $remote_addr;
		proxy_read_timeout 3600s;
		proxy_send_timeout 3600s;
	}
}

어디를 봐야 하는가X-Forwarded-Protohttp 다. 04 에서 https 로 바꾼다. 이 헤더는 뒷단에 「원래 요청이 무슨 스킴이었나」를 알려 주는 값이라, 실제로 80 으로 들어오는데 https 라고 적으면 Keycloak 이 리다이렉트 주소를 https:// 로 만들고 로그인 도중에 끊긴다. 거짓말하면 안 되는 헤더다.

upstream 이 둘인 까닭. 두 노드 모두 Traefik 이 뜨므로 어느 쪽으로 보내도 된다. nginx 는 기본 라운드로빈으로 번갈아 보내고, 한쪽이 죽으면 자동으로 뺀다.

활성화한다.

sudo ln -sf /etc/nginx/sites-available/keycloak-lab /etc/nginx/sites-enabled/

확인 — 링크가 생겼고 default 가 없는가

ls -l /etc/nginx/sites-enabled/

어디를 봐야 하는가keycloak-lab -> 링크가 생겼는가, 그리고 default 가 없는가(0번에서 지웠다). 둘 다 listen 80 default_server 라 같이 있으면 2번의 nginx -tduplicate default server 로 막는다.

2. 문법을 보고 적용한다[kc-lab-edge]

목적 — 설정이 깨진 채로 reload 하지 않는다.

확인

sudo nginx -t && sudo systemctl reload nginx

통과하면 이런 형태다. 2026-09-11 의 엣지에서는 [warn] 한 줄이 앞에 붙어 세 줄이었다(observed).

nginx: [warn] could not build optimal types_hash, you should increase either
types_hash_max_size: 1024 or types_hash_bucket_size: 64
nginx: configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

★ 그 [warn] 은 늘 나오지는 않는다. 처음에는 「Debian 12 는 늘 같이 내놓는다」고 적었는데, 2026-09-17 에 같은 Debian 12 · 같은 nginx/1.22.1 엣지를 새로 세우고 sudo nginx -t 를 세 번 쳤더니 한 번도 안 나왔다(observed). 두 줄뿐이었다.

nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

그러니 이 줄이 안 보인다고 설정이 덜 읽힌 것이 아니다. 판정은 syntax is oktest is successful 두 마디로만 하고, [warn] 줄은 나오면 무시하고 안 나오면 없는 대로 둔다.

어디를 봐야 하는가nginx -t 가 내놓는 마지막 줄이다. syntax is oktest is successful 두 마디가 다 나와야 통과다. 앞에 나오는 [warn] 줄(예: types_hash_max_size)은 통과를 막지 않는다 — 04 에서 이 경고를 실패로 오독하는 일이 실제로 벌어지므로, 여기서 경고와 오류를 구분하는 눈을 들여 둔다. 실패면 [emerg] 줄에 파일과 줄 번호가 찍힌다.

이 결과가 의미하는 것 — 통과했으면 && 뒤의 reload 가 이어서 돌고, systemctl reload 는 아무 말 없이 끝난다(무소식이 좋은 소식이다). 실패했으면 && 가 reload 를 막아 준 것이고, 지금 돌고 있는 nginx 는 옛 설정 그대로 멀쩡하다.

확인 — reload 가 정말 반영됐는가

systemctl status nginx --no-pager | head -20

어디를 봐야 하는가Active: 줄이 active (running) 인가, 그리고 그 아래 프로세스 트리에 nginx: master process 하나와 nginx: worker process 여럿이 붙어 있는가. 워커 줄의 PID 를 눈에 담아 둔다.

이 결과가 의미하는 것 — reload 는 마스터를 그대로 두고 워커만 갈아 끼운다. 그래서 reload 전후로 워커 PID 가 바뀌면 새 설정이 실제로 적용된 것이고, 안 바뀌었으면 -t 는 통과했는데 reload 가 안 갔다. 04 에서 인증서 갱신이 서빙까지 닿았는지를 정확히 이 방법으로 판정한다.

3. 호스트에서 엣지로 넘긴다 (DNAT)[lab host]

목적 — 여기까지 하면 엣지 nginx 는 살아 있는데 아무도 거기로 안 보낸다. tailnet 주소 100.83.212.4:443 을 받는 것은 여전히 물리 호스트다. 그 트래픽을 엣지로 넘기는 일이 이 단계고, 물리 호스트가 실험대를 위해 하는 일의 전부다 — DNAT 규칙 하나와 DHCP 예약 세 줄이고 둘 다 한 번 쓰고 다시 안 건드린다.

규칙 파일을 쓴다. 디렉터리가 없으면 nano 가 저장할 곳을 못 찾으므로 먼저 만든다.

sudo mkdir -p /etc/nftables.d
sudo nano /etc/nftables.d/lab-edge-dnat.nft
# file: /etc/nftables.d/lab-edge-dnat.nft
#!/usr/sbin/nft -f
table ip lab_edge
delete table ip lab_edge

table ip lab_edge {
    chain prerouting {
        type nat hook prerouting priority dstnat; policy accept;
        iifname "tailscale0" tcp dport { 80, 443 } dnat to 192.168.122.10
    }

}

어디를 봐야 하는가 — 맨 위의 table ip lab_edgedelete table ip lab_edge 두 줄, 그리고 포트 숫자다.

  • 두 줄짜리 관용구 — 없는 테이블을 지우면 에러이므로 한 번 만들고 지운다. 이래야 같은 파일을 몇 번 적용해도 안전하다.
  • 443433 으로 치지 않는다. 433 도 유효한 포트라 nft 가 군말 없이 받는다. 80 은 멀쩡히 넘어가므로 3·4번은 다 통과하고, 04 에서 HTTPS 만 안 되는 형태로 뒤늦게 터진다. 이 실험대에서 실제로 나왔던 오타다.

유닛 파일을 쓴다. 경로를 /etc/systemd/ 가 아니라 /etc/systemd/system/ 까지 친다.

sudo nano /etc/systemd/system/lab-edge-dnat.service
# file: /etc/systemd/system/lab-edge-dnat.service
[Unit]
Description=Lab edge DNAT (tailnet :80/:443 -> kc-lab-edge)
After=network-online.target libvirtd.service
Wants=network-online.target

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/sbin/nft -f /etc/nftables.d/lab-edge-dnat.nft
ExecStartPost=-/usr/sbin/nft insert rule ip libvirt_network guest_input oif virbr0 ip daddr 192.168.122.10 tcp dport {80,443} ct state new counter accept
ExecStop=/usr/sbin/nft delete table ip lab_edge

[Install]
WantedBy=multi-user.target

★ 경로를 끝까지 친다 — /etc/systemd/ 가 아니라 /etc/systemd/system/. nano 는 없는 파일이면 말없이 새로 만든다. 그래서 한 단계 위에 만들어도 아무 경고가 없고, systemd 는 거기를 유닛 디렉터리로 읽지 않아 Unit lab-edge-dnat.service does not exist 만 반복된다. 이 실험대에서 실제로 겪은 형태다 — /etc/systemd/journald.conf 같은 systemd 자체 설정이 사는 곳이다.

확인 — 두 파일이 제자리에 있는가

ls -l /etc/nftables.d/lab-edge-dnat.nft /etc/systemd/system/lab-edge-dnat.service

어디를 봐야 하는가두 줄이 다 나와야 한다. 한 줄이라도 No such file 이면 다음 명령은 무조건 실패한다. systemctl 이 이상한 것이 아니라 파일이 없는 것이다.

적용한다.

sudo systemctl daemon-reload
sudo systemctl enable --now lab-edge-dnat.service

daemon-reload 를 빠뜨리지 않는다. 유닛 파일을 새로 써도 systemd 는 다시 읽기 전까지 모른다. 파일은 제자리에 있는데 does not exist 가 나오면 여기를 의심한다.

규칙의 알맹이는 두 줄이고, 둘이 사는 곳이 다르다.

하는 일 어디에
iifname "tailscale0" tcp dport { 80, 443 } dnat to 192.168.122.10 우리 테이블 lab_edge (.nft 파일)
oif virbr0 ip daddr 192.168.122.10 … ct state new accept libvirt 테이블 libvirt_networkguest_input 체인 (유닛의 ExecStartPost)

★ SNAT 을 걸지 않는다. 게스트의 기본 게이트웨이가 호스트라 응답은 어차피 여기로 돌아오고, conntrack 이 알아서 되돌린다. masquerade 를 붙이면 출발지가 덮여서 엣지가 모든 클라이언트를 192.168.122.1 로 본다 — 이 실험대가 재고 있는 X-Forwarded-For 계약이 통째로 무의미해진다.

★ 두 번째 줄이 왜 libvirt 테이블 안으로 들어가나 — 여기가 이 단계에서 가장 많이 막히는 곳이다. libvirt 는 게스트 대역으로 새로 들어오는 연결을 거절 한다. 자기 테이블 libvirt_networkguest_input 체인이 이렇게 끝난다.

oif "virbr0" ip daddr 192.168.122.0/24 ct state established,related accept
oif "virbr0" reject                                   ← 여기서 죽는다

우리 테이블에 아무리 먼저 accept 를 놔도 소용이 없다. 까닭은 §180 에 있다. 그래서 구멍은 libvirt 체인 맨 앞에 뚫는다. insert 가 체인 맨 앞에 넣는다는 점이 핵심이다(add 는 맨 뒤 = reject 뒤 = 의미 없음).

손으로 한 번 넣어 볼 때는 이 줄이다.

sudo nft insert rule ip libvirt_network guest_input oif virbr0 ip daddr 192.168.122.10 tcp dport '{80,443}' ct state new counter accept

'{80,443}' 의 따옴표를 빼지 않는다. bash·zsh 가 중괄호를 80 443 두 낱말로 펼쳐 버려서 Error: syntax error, unexpected ct 가 난다. 규칙은 안 들어갔는데 에러만 보고 넘기기 쉽다.

확인 — 새 규칙이 reject 에 있는가

sudo nft -a list chain ip libvirt_network guest_input

이 규칙은 휘발성이다. libvirt 가 네트워크를 다시 세우면(호스트 재부팅, virsh net-start, libvirtd 재시작) guest_input 을 새로 쓰면서 날아간다. 그래서 유닛의 ExecStartPost 에 넣어 두고, 날아갔으면 다시 넣는다.

sudo systemctl restart lab-edge-dnat.service

확인 — 우리 테이블이 들어갔는가

sudo nft list table ip lab_edge

어디를 봐야 하는가dnat to 192.168.122.10 한 줄이 있는가, 포트가 80, 443 인가, 그리고 masqueradesnat 이 없는가.

이 결과가 의미하는 것 — PREROUTING nat 은 라우팅 결정보다 먼저 돌기 때문에 이 규칙이 호스트 자신의 :443 소켓보다 우선한다. 그래서 물리 호스트에 nginx 가 아직 떠 있어도 트래픽은 엣지로 간다 — 전환이 원자적이고, 되돌리기도 한 줄이다.

끝났는지 판정한다 — 아래에서 위로

한 번에 밖에서 치지 말고 가까운 층부터 본다. 어디서 끊겼는지가 바로 나온다.

curl 을 두 형태로 쓴다. 처음 볼 때는 -I(헤더까지 읽는 형태)로 응답을 눈으로 읽고, 같은 것을 여러 번 재거나 두 노드를 나란히 비교할 때만 -w '%{http_code}'(값만 뽑는 형태)로 바꾼다. §185 의 ① 이 그 규약이다.

# 무엇을 건너뛰나 명령 실측
nginx 를 건너뛴다 curl -I http://192.168.122.11 404
DNAT 을 건너뛴다 curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' http://192.168.122.10 301
밖에서 curl -I http://auth.hyeonworks.com 301 https://auth.hyeonworks.com/
TLS 이후 curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master 200

확인 ① Traefik 이 듣고 있나 (nginx 를 건너뛴다)

curl -I http://192.168.122.11

형태 (이 실험대에서 캡처해 두지 않았다 — 봐야 할 줄만)

HTTP/1.1 404 Not Found
...

어디를 봐야 하는가 — 첫 줄의 상태 줄 하나. 그리고 그 앞에 아무 오류도 없이 헤더가 나왔다는 사실. curl: (7) Failed to connect 이면 응답 자체가 없는 것이라 상태 코드를 볼 일도 없다.

이 결과가 의미하는 것404 가 성공 신호다. 게스트의 80 을 누가 듣고 있고(Traefik), 그가 요청을 받아 「매칭되는 Ingress 규칙이 없다」고 답했다. 이 층은 통과. 502 면 Traefik 은 떴는데 그 뒤 백엔드가 없는 것이고, 연결 거부· 타임아웃이면 Traefik 이 안 떴거나 게스트가 죽은 것이라 02 로 돌아간다.

두 노드가 같은지 볼 때는 값만 뽑는 형태가 낫다. 두 줄을 나란히 놓고 눈으로 비교하는 것이 목적이기 때문이다.

curl -s -o /dev/null -w '%{http_code}\n' http://192.168.122.11
curl -s -o /dev/null -w '%{http_code}\n' http://192.168.122.12

실측.11 에서 잰 값이다(observed).

404

어디를 봐야 하는가 — 두 줄이 같은 코드인가.

이 결과가 의미하는 것 — 둘이 같으면 nginx 가 어느 쪽으로 보내도 같은 결과가 나온다. 한쪽만 다르면 upstream 두 개 중 하나가 죽은 것이고, 그 상태에서는 요청의 절반만 실패해서 「가끔 안 된다」로 보인다.

확인 ② 엣지 nginx 가 직접 응답하나 (DNAT 을 건너뛴다)

curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' http://192.168.122.10

어디를 봐야 하는가엣지가 답을 하는가. 여기서 막히면 문제는 엣지 안이다(설정·기동). 통과하는데 아래 ③ 이 안 되면 문제는 DNAT 이다. 이 한 층을 끼워 두면 그 둘을 헷갈리지 않는다.

★ 이 단계에서 나오는 코드는 301 이 아니라 404 다. 처음에는 301 이라고 적었는데, 2026-09-17 에 이 절을 그대로 따라 엣지를 다시 세우고 재 보니 이렇게 나왔다(observed).

HTTP/1.1 404 Not Found
Server: nginx/1.22.1
Content-Type: text/plain; charset=utf-8
Content-Length: 19
404 page not found

Server:nginx/1.22.1 이고 본문이 Traefik 의 404 page not found 라는 것이 엣지가 받아서 Traefik 으로 넘겼다는 증거다. 이 단계의 설정에는 리다이렉트를 낼 블록이 없다 — 위 「② 에 쓸 내용」은 listen 80 블록 하나뿐이고 그 안은 proxy_pass 다. 30104 가 443 블록과 80→443 리다이렉트를 얹은 뒤의 값이고, 처음 적은 301 은 다 끝난 실험대에서 잰 것을 이 자리로 옮겨 적은 것이었다. 그래서 이 층의 통과 기준은 코드값이 아니라 curl: (7) 없이 응답이 온다」와 Server: nginx 두 가지다.

확인 ③ 밖에서, 즉 DNAT 을 거쳐 닿나

curl -I http://auth.hyeonworks.com

형태 (봐야 할 두 줄만)

HTTP/1.1 301 Moved Permanently
Location: https://auth.hyeonworks.com/
...

어디를 봐야 하는가 — 상태 줄과 Location: 헤더 한 줄. Locationhttps:// 로 시작하고 원래 호스트명을 그대로 들고 있는가. $host 대신 설정에 이름을 박아 두면 여기서 엉뚱한 호스트가 나온다.

이 결과가 의미하는 것 — 301 이 나왔다는 것은 바깥 요청이 DNAT 을 거쳐 엣지 nginx 까지 닿았다는 뜻이다(①은 Traefik 에 직접, ②는 엣지에 직접 친 것이라 DNAT 을 안 거쳤다). DNS·DNAT·80 리스너가 전부 살아 있다. 응답이 아예 없으면 nginx 가 안 떴거나 80 이 막혔다. 리다이렉트를 따라가 끝까지 보려면 -L 을 붙인다.

값을 반복해서 잴 때의 형태는 이것이고, 아래가 이 실험대의 실측이다(observed).

curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' http://auth.hyeonworks.com
301 https://auth.hyeonworks.com/

★ 이 301 은 04 이후의 값이다. 03 까지만 했을 때 나오는 것은 302 이고 보내는 곳도 다르다. 2026-09-17 에 구멍을 뚫고 밖에서 재 보니 이랬다(observed).

302 https://auth.hyeonworks.com/admin/

리다이렉트를 내는 것이 둘이다. 04 를 끝내면 엣지 nginx 의 return 301 https://$host$request_uri 가 먼저 답해 301 https://auth.hyeonworks.com/ 이 되고, 03 까지만 했으면 그 블록이 없어 요청이 Keycloak 까지 가서 Keycloak 이 //admin/ 으로 보낸다. 코드도 301 이 아니라 302 다. Locationhttps:// 인 것도 Keycloak 이 정한다 — KC_HOSTNAME=https://auth.hyeonworks.comKC_HOSTNAME_STRICT=true 가 그렇게 시키고, 요청이 http 로 들어와도 자기가 아는 주소로 답한다.

같은 엣지에 어떻게 치느냐가 코드를 가른다(observed).

어떻게 치나 코드
Host 없이 엣지 IP 로 (층 ②) 404 — Traefik 에 매칭되는 Ingress 가 없다
Host: auth.hyeonworks.com 으로 엣지 IP 에 302 https://auth.hyeonworks.com/admin/
밖에서 도메인으로 (층 ③) 302 https://auth.hyeonworks.com/admin/
밖에서 도메인 + /realms/master 200

마지막 줄이 중요하다 — TLS 를 얹기 전에도 /realms/master200 이다. 층 ④ 의 200 은 「TLS 가 섰다」가 아니라 「끝까지 이어졌다」를 재는 값이다.

구멍을 뚫기 전에는 같은 curl 이 이렇게 끝났다(observed).

curl: (7) Failed to connect to auth.hyeonworks.com port 80 after 54 ms

같은 시각에 호스트 안에서 친 http://192.168.122.10404 로 답했다. 아래 「막히면」 표의 마지막 줄이 가리키는 상태 그대로이고, 원인도 거기 적힌 대로였다 — 이 호스트에 실제로 깔려 있는 유닛에 ExecStartPost 줄이 없다. 아래 「배포된 것과 적어 둔 것이 다르다」를 본다.

확인 ④ 끝까지 닿나 (TLS 이후)

curl -s -o /dev/null -w '%{http_code}\n' --resolve auth.hyeonworks.com:443:192.168.122.10 \
  https://auth.hyeonworks.com/realms/master
200

어디를 봐야 하는가 — 코드 한 칸. 여기서만 값만 뽑는 형태를 바로 쓰는 까닭은, 이 200 이 04·05 에서 매번 같은 명령으로 다시 잴 기준값이기 때문이다. 처음 한 번은 헤더까지 보고, 그다음부터 이 형태로 줄인다.

curl -I --resolve auth.hyeonworks.com:443:192.168.122.10 https://auth.hyeonworks.com/realms/master

이 결과가 의미하는 것200 이면 nginx → Traefik → 파드까지 2홉이 다 이어졌다. 502 는 nginx 는 살아 있는데 upstream 을 못 잡은 것(아래 로그를 본다), curl: (60) 같은 인증서 오류는 아직 04 를 안 했다는 뜻이다. TLS 단계에서 막히면 코드만 보지 말고 curl -v 로 협상 과정을 읽는다 — 04 에서 그렇게 한다.

로그를 볼 때 — 실무자가 치는 형태다.

journalctl -u nginx -p err -n 5      # 최근 에러만
journalctl -u nginx -f               # 지금 벌어지는 것

어디를 봐야 하는가 — 각 줄의 괄호 안 errno(113·111)와 그 뒤의 upstream: "http://192.168.122.1x:80/..." 부분. 어느 upstream 이 문제인지가 거기 적혀 있다. 그리고 타임스탬프 — 방금 친 요청 시각과 안 맞으면 지금 보고 있는 것은 옛 사고다.

이 결과가 의미하는 것-p err 로 걸러도 아무것도 안 나오면 nginx 는 정상이고 문제는 더 위(Traefik·파드)에 있다. 두 번째 명령은 띄워 놓은 채로 다른 창에서 요청을 치는 용도다 — 요청과 로그 줄을 눈으로 짝지으면 「이 요청이 어느 upstream 으로 갔나」가 바로 보인다. 끝내려면 Ctrl+C.

upstream 이 둘이라 한쪽이 죽으면 nginx 가 자동으로 뺀다. 그 동작이 로그에 세 줄로 남는다(observed).

connect() failed (113: No route to host)     ← 호스트에 못 닿는다
connect() failed (111: Connection refused)   ← 포트에 아무도 없다
no live upstreams                            ← 둘 다 죽었다고 판단

113 과 111 은 대응이 다르다 — 113 은 네트워크, 111 은 프로세스다. 노드를 잃었을 때 이 세 줄이 1분 안에 순서대로 나왔다(observed).

★ nginx 에러 로그는 2048바이트에서 잘린다. 긴 URL 이 끝에서 단어 중간에 끊겨 보이면 이 제한에 걸렸다. 되찾으려면 access 로그를 본다 — 거기엔 제한이 없다.

grep oauth2/callback /var/log/nginx/access.log | tail -1

어디를 봐야 하는가 — 그 한 줄의 이다. 요청 URL 이 " 로 제대로 닫혀 있으면 온전한 줄이고, 단어 중간에서 멈춰 있으면 잘렸다. 길이가 궁금하면 세어 본다.

grep oauth2/callback /var/log/nginx/access.log | tail -1 | wc -c

이 결과가 의미하는 것 — error 로그와 access 로그의 같은 요청 길이가 다르면 error 쪽이 잘린 것이지 요청이 잘린 것이 아니다. 이 실험대에서 502 원인이 error 로그에 있었는데 잘려 있었고, access 로그에는 3492자로 온전히 남아 있었다(observed). 잘린 문자열을 놓고 원인을 추측하면 없는 문제를 좇게 된다.

막히면

증상 어디서 끊겼나 확인
① 이 연결 거부 Traefik 이 안 떴거나 게스트가 죽음 kubectl get pods -n kube-system
① 이 502 Traefik 은 떴는데 백엔드가 없음 Ingress 확인
② 가 응답 없음 nginx 가 안 떴거나 방화벽 systemctl status nginx
③ 이 502 인증서 문제 또는 upstream 다운 04 · 위 로그
/etc/nginx: No such file or directory nginx 미설치. cloud-init 은 안 깐다 ls /etc/nginx/which nginx 는 깔려 있어도 빈손이라 못 가른다
nginx -tduplicate default server sites-enabled/default 가 살아 있다 ls -l /etc/nginx/sites-enabled/
설정을 썼는데 아무 변화가 없다 경로 오타 — site-available(단수)에 썼다 ls /etc/nginx/sites-available/
unknown directive "http2" nginx < 1.25.1. Debian 12 는 1.22 다 sudo nginx -vlisten 443 ssl http2 형태로
systemctl reload 가 실패한다 설정이 nginx -t 를 못 통과했다 reload 말고 sudo nginx -t 를 먼저 친다
cp: cannot stat 'deploy/...' 엣지에서 쳤거나, lab host 에 리포가 없다 ls ~/workspace → 없으면 00 의 0번으로
Unit lab-edge-dnat.service does not exist 유닛 파일이 없거나, /etc/systemd/ 에 썼거나, daemon-reload 를 안 했다 find /etc/systemd -name 'lab-edge-dnat*'system/ 아래여야 한다
80 은 되는데 HTTPS 만 안 된다 .nft 의 포트가 433 오타 grep dport /etc/nftables.d/lab-edge-dnat.nft
호스트 안에서는 404 인데 밖에서만 connection refused libvirt guest_inputreject. 3번의 두 번째 줄이 빠졌거나 날아갔다 sudo nft -a list chain ip libvirt_network guest_inputreject 줄의 카운터가 올라가면 여기다. §180
Error: syntax error, unexpected ct 셸이 {80,443} 을 펼쳤다 '{80,443}' 로 따옴표

이 절차가 성립하는 범위

  • (observed) 2026-09-11 새로 만든 엣지에서 /etc/nginx 가 없던 것, nginx -t 출력 세 줄, 층별 확인 ①~④ 의 코드, 301 https://auth.hyeonworks.com/, upstream 실패 errno 세 줄, access 로그 3492자
  • (observed) 위에 실은 .nft 와 유닛의 내용은 저장소 원본과 같다. 다만 호스트에 실제로 깔려 있는 두 파일은 그것이 아니다 — 바로 아래를 본다
  • (unknown) curl -I http://192.168.122.11 의 전체 출력은 캡처해 두지 않았다. 가이드도 봐야 할 줄만 적었다
  • (unknown) systemctl status nginx 두 번, ls -l /etc/nginx/sites-enabled/, find·nft -a list chain·journalctl -u nginx·access 로그 두 줄은 가이드가 적어 둔 명령이고 출력이 남아 있지 않다
  • (inferred) L7 홉 수가 2홉 그대로라는 §179 의 판정이 이 배치의 전제다
  • (unknown) libvirt 의 firewall_backend 가 iptables 일 때도 guest_input 구멍이 필요한지는 재지 않았다(§183)
  • 되돌리기는 가이드 7편 가운데 이 편에만 있다. 위 네 줄이 원문 그대로이고, 그 네 줄을 실제로 쳐서 걷어내 본 기록은 없다(unknown)
  • (unknown) 2026-09-17 에 더한 CoreDNS 매니페스트(coredns-custom)는 가이드의 되돌리기 표에 없다. 걷어내려면 kubectl -n kube-system delete configmap coredns-custom 하고 kubectl -n kube-system delete pod -l k8s-app=kube-dns 인데 이 실험대에서 치지는 않았다

배포된 것과 적어 둔 것이 다르다

2026-09-17 에 이 절을 따라 실험대를 다시 세우다 호스트의 두 파일을 열어 봤더니 위에 실은 내용과 달랐다(observed). 저장소 원본을 고친 뒤 호스트에 다시 배포하지 않았다 — 파일 시각이 그렇게 말한다.

파일 호스트에 깔린 것 (11:00 · 10:52) 저장소 원본과 위 본문 (13:30)
lab-edge-dnat.nft prerouting 뒤에 forward 체인이 그대로 있다 (priority filter - 10 · ct state new accept) 그 체인이 없다. 자리에 「왜 뺐는지」 주석만 남겼다
lab-edge-dnat.service ExecStartPost 줄이 없다 ExecStartPost=-…insert rule ip libvirt_network guest_input… 이 있다

이것이 위 확인 ③ 의 curl: (7) 을 그대로 설명한다. 고친 쪽의 요점이 「앞 체인의 accept 로는 뒤 체인의 reject 를 못 막으니 구멍은 libvirt 체인 안에 뚫는다」(§180)인데, 호스트에는 효과가 없는 쪽만 깔려 있고 효과가 있는 쪽은 안 깔려 있다. 게다가 그 구멍은 런타임 상태라 libvirt 가 네트워크를 다시 세우면 날아가고, 다시 넣어 줄 ExecStartPost 가 유닛에 없으니 실험대를 다시 세운 직후에는 밖에서 못 들어온다.

그래서 03 을 다시 밟을 때 4·5 번을 「이미 있으니 건너뛴다」로 넘기지 않는다. 파일이 있는 것과 그 파일이 위 본문의 내용인 것은 다르다. 두 파일을 열어 위와 한 줄씩 대조하거나, 갈린 두 자리만 세어 본다.

grep -c 'chain forward' /etc/nftables.d/lab-edge-dnat.nft
grep -c ExecStartPost /etc/systemd/system/lab-edge-dnat.service

01 이 나와야 맞다. 반대로 나오면 고치기 전 판이 깔려 있는 것이고, 다시 써서 sudo systemctl daemon-reload && sudo systemctl restart lab-edge-dnat.service 로 올린다.

2026-09-17 늦게 그 sudo 가 열려 셋을 마저 봤다(observed). 결론은 절반만 고쳐져 있다이다.

grep -c 'chain forward'   /etc/nftables.d/lab-edge-dnat.nft           →  1   (0 이어야 한다)
grep -c ExecStartPost     /etc/systemd/system/lab-edge-dnat.service   →  1   (맞다)

유닛에는 ExecStartPost 가 들어갔는데 .nft 는 아직 옛 판이다 — 효과 없는 forward 체인을 아무도 걷어내지 않았다. .nft 를 다시 쓰고 sudo systemctl restart lab-edge-dnat.service 로 올려야 §180 이 싣는 판이 된다.

sudo nft list chain ip libvirt_network guest_input
oif "virbr0" ip daddr 192.168.122.10 tcp dport { 80, 443 } ct state new counter packets 44 bytes 2640 accept
oif "virbr0" ip daddr 192.168.122.10 tcp dport { 80, 443 } ct state new counter packets 0 bytes 0 accept
oif "virbr0" ip daddr 192.168.122.0/24 ct state established,related counter packets 141705 bytes 2375116597 accept
oif "virbr0" counter packets 5 bytes 300 reject
oif "virbr0" ip daddr 192.168.122.10 tcp dport { 80, 443 } ct state new counter packets 10 bytes 600 accept

구멍이 하나가 아니라 셋이다. ExecStartPostnft insert 로 맨 앞에 끼워 넣으므로 유닛을 다시 시작할 때마다 같은 규칙이 한 줄씩 는다 — 여러 번 쳐도 안전한 형태가 아니다. 기능은 멀쩡하다. 맨 앞 하나가 통과시키고 나머지는 카운터가 안 오르거나(둘째 0) 아예 닿지 않는다(다섯째는 reject 뒤다). reject5 는 구멍이 들어가기 전에 막힌 것이고 그 뒤로는 안 오른다.

그리고 출발지를 덮는 것이 무엇인지도 그때 갈렸다 — Tailscale 이다(observed).

sudo nft list ruleset | grep -nE 'masquerade|snat'
sudo iptables -t nat -S | grep ts-postrouting
sudo tailscale debug prefs | grep -i snat
chain ts-forward {  iifname "tailscale0" counter ... xt target "MARK"  }
-A ts-postrouting -m mark --mark 0x40000/0xff0000 -j MASQUERADE
"NoSNAT": false

tailscale0 으로 들어와 전달되는 패킷에 Tailscale 이 표식 0x40000 을 찍고, ts-postrouting 이 그 표식이 붙은 것을 전부 MASQUERADE 한다. 나가는 인터페이스가 virbr0 이라 출발지가 192.168.122.1 이 된다. NoSNATfalse 인 것이 그 동작이 켜져 있다는 뜻이고 그것이 Tailscale 의 기본값이다.

그래서 §180 의 경고는 옳았는데 엉뚱한 테이블을 지켰다. 「masquerade 를 붙이면 엣지가 모든 클라이언트를 192.168.122.1 로 보게 된다」는 그대로 일어났고, 다만 그 masquerade 를 붙인 것이 우리 테이블이 아니라 Tailscale 이다. libvirt 의 마스커레이드는 무관하다 — ip saddr 192.168.122.0/24 ip daddr != 192.168.122.0/24 이라 게스트에서 밖으로 나가는 것만 고르고, 밖에서 들어오는 이 패킷은 목적지가 그 대역 안이라 안 걸린다.

클러스터 안에서도 공개 이름에 못 닿는다(2026-09-17, observed).

랩 호스트만의 일이 아니다. 파드 안에서도 같은 이름이 같은 이유로 막힌다.

kubectl -n keycloak-lab exec keycloak-0 -- sh -c "getent hosts auth.hyeonworks.com"
100.83.212.4    auth.hyeonworks.com

같은 파드에서 100.83.212.4 의 80 과 443 을 두드리면 둘 다 닫혀 있고, 엣지 게스트의 192.168.122.10:80 은 열려 있다. 길은 있는데 이름이 그 길을 안 가리킨다. 클러스터 DNS 어디에도 그 매핑이 없다 — CoreDNS 의 NodeHosts 에는 노드 둘뿐이고 노드의 /etc/hosts 에도 없으며, deploy/lab/k8s/ 의 매니페스트 여덟 장에도 없다.

nginx 가 호스트에 있던 동안에는 파드가 100.83.212.4:443 을 치면 그 nginx 에 닿았다. 03 이 옮기면서 그 경로가 끊겼고, 대신 놓아 줄 것을 어느 문서도 안 놓는다.

고치는 한 단계는 CoreDNS 에 서버 블록을 하나 더 주는 것이다. 이 실험대는 매니페스트를 힙독으로 흘려 넣었다(observed).

kubectl apply -f - <<'YAML'
apiVersion: v1
kind: ConfigMap
metadata:
  name: coredns-custom
  namespace: kube-system
data:
  hyeonworks.server: |
    hyeonworks.com:53 {
      hosts {
        192.168.122.10 auth.hyeonworks.com
        192.168.122.10 app1.hyeonworks.com
        192.168.122.10 app2.hyeonworks.com
        fallthrough
      }
      forward . /etc/resolv.conf
    }
YAML
kubectl -n kube-system delete pod -l k8s-app=kube-dns --wait=false

따라 하는 사람은 매니페스트를 파일로 쓴다. 힙독은 이 ConfigMap 을 터미널 명령 안에 묻어 버려서, 나중에 서버 블록을 고치려면 같은 힙독을 처음부터 다시 친다. 파일로 두면 kubectl apply 를 다시 쳐서 고칠 수 있고 무엇이 들어갔는지도 열어서 본다.

nano coredns-custom.yaml

파일에 위 힙독 안의 apiVersion: 부터 마지막 } 까지를 그대로 쓴다. 그리고 적용한 뒤 CoreDNS 파드를 다시 띄운다.

kubectl apply -f coredns-custom.yaml
kubectl -n kube-system delete pod -l k8s-app=kube-dns --wait=false

이 나눈 형태는 이 실험대에서 치지 않았다(unknown).

파일 이름이 .server 인 것이 핵심이다. k3s 는 *.override 를 기본 서버 블록 안에 끼워 넣는데 그 블록은 이미 hosts /etc/coredns/NodeHostshosts 를 한 번 쓴다. .overridehosts 를 또 쓰면 CoreDNS 가 CrashLoopBackOff 로 안 뜬다.

plugin/hosts: this plugin can only be used once per Server Block

.server 는 별개의 서버 블록으로 들어가므로 그 안에서 hosts 를 처음 쓰는 것이 된다. 뜨고 나면 로그 머리에 듣는 영역이 둘로 찍히고, 파드에서 이름이 엣지로 풀린다.

.:53
hyeonworks.com.:53
192.168.122.10  auth.hyeonworks.com

192.168.122.10:80 이 파드에서 열린다. 443 은 여전히 닫혀 있다 — 그것은 04 가 인증서를 얹어야 열린다.

03 을 밟고 나면 랩 호스트에서 공개 URL 을 못 친다(2026-09-17, observed).

nginx 가 호스트에 있던 동안에는 호스트에서 curl https://auth.hyeonworks.com 이 그냥 됐다. 03 이 nginx 를 엣지 게스트로 옮기면서 그것이 끊긴다. 80 도 443 도 안 열린다.

curl -s -m 10 -o /dev/null -w "%{http_code}\n" https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs
000
Failed to connect to auth.hyeonworks.com:443 after 22 ms: Could not connect to server

이유는 두 줄이다.

getent hosts auth.hyeonworks.com
ss -lnt | grep -E ":(80|443) "
100.83.212.4    auth.hyeonworks.com

두 번째 줄은 아무것도 안 낸다. auth.hyeonworks.com 은 호스트 자신의 tailnet 주소로 풀리는데 호스트에는 그 포트를 듣는 것이 없고, 03 의 DNAT 은 iifname "tailscale0" 만 매칭하므로 호스트가 스스로 낸 패킷은 그 규칙을 안 탄다. §190 이 자기 4번 확인을 엣지가 아닌 다른 머신으로 보낸 것과 같은 이유다.

이 전제가 실험 기록 14편 · 명령 55줄에 걸려 있다(observed). [lab host] 라벨이 붙은 코드블록 안에서 auth·app1·app2 의 공개 URL 을 치는 줄을 센 것이고, 많은 쪽부터 B-6(12) · B-5(10) · D-1(9) 순이다. 그 줄들은 nginx 가 호스트에 있던 때의 배치로 쓰였고 지금 배치에서는 000 을 낸다.

랩 호스트에서 꼭 쳐야 하면 이름은 그대로 두고 주소만 엣지로 못박는다.

curl -s -m 10 --resolve auth.hyeonworks.com:80:192.168.122.10 \
  http://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs

이 형태는 응답을 받는다(observed). 다만 DNAT 을 안 거치므로 층 ③ 을 재는 것이 아니다. 층 ② 를 도메인 이름으로 재는 것이고, 4·5 번이 제대로 들어갔는지는 여전히 밖에서 쳐야 갈린다.

X-Forwarded-For 계약은 이 실험대에서 성립하지 않는다(2026-09-17, observed).

위 4번이 SNAT 을 안 거는 이유로 「masquerade 를 붙이면 엣지가 모든 클라이언트를 192.168.122.1 로 보게 된다」고 적었고, §215 의 표도 그 줄을 감수한 비용으로 싣는다. 그런데 밖에서 한 번 치고 엣지의 로그를 열었더니 이미 그 상태였다.

curl -s -o /dev/null http://app1.hyeonworks.com/api/echo
sudo tail -3 /var/log/nginx/access.log
192.168.122.1 - - [17/Sep/2026:07:10:45 +0000] "GET /api/echo HTTP/1.1" 200 617 "-" "curl/8.5.0"

lab_edge 테이블에는 masqueradesnat 도 없다 — 위에 실은 .nft 파일 그대로다. 그러므로 출발지를 덮는 것은 이 테이블이 아니고, 무엇이 덮는지는 가리지 못했다(unknown). 그것을 보려면 호스트에서 nft list ruleset 을 쳐야 하는데 그 sudo 가 비밀번호를 묻는다. 결과만 놓고 보면 proxy_set_header X-Forwarded-For $remote_addr 는 시키는 대로 동작하고 있고 $remote_addr 가 이미 호스트의 브리지 주소다. §259 가 적은 신뢰 경계 논의는 그대로 유효하지만, 이 실험대에서 그 헤더로 클라이언트를 가릴 수는 없다.

Traefik 이 그 헤더를 한 번 더 덮는다 — 03 에 없던 한 단계(2026-09-17, observed).

2번이 세운 헤더가 앱까지 가는지를 처음으로 쟀다. 안 간다. Traefik 은 자기가 신뢰하지 않는 곳에서 온 X-Forwarded-* 를 자기 연결 기준으로 다시 쓰고, 기본값의 신뢰 목록은 비어 있다.

curl -s http://app1.hyeonworks.com/api/echo
헤더 이 한 단계를 안 밟았을 때 밟은 뒤
x-real-ip 10.42.0.1 — flannel 게이트웨이. 엣지가 보낸 값이 사라졌다 192.168.122.1 — 엣지가 보낸 값 그대로
remoteAddr 10.42.0.1 192.168.122.1
x-forwarded-for 없다 없다

빠진 한 단계는 두 줄이고, 저장소에 파일(deploy/lab/k8s/traefik-forwarded-headers.yaml) 이 있는데 setup 아홉 편 어디에도 이 명령이 없었다.

kubectl apply -f deploy/lab/k8s/traefik-forwarded-headers.yaml
kubectl -n kube-system rollout status deploy/traefik --timeout=180s

신뢰 목록에는 파드 대역 10.42.0.0/16 하나만 담는다. Traefik 의 Service 가 externalTrafficPolicy: Cluster 라 svclb 가 출발지를 덮고, 그래서 Traefik 에 닿는 주소는 엣지가 아니라 파드 대역에서 온다. 노드·호스트 대역을 같이 넣어서도 재 봤는데 위 표의 값이 하나도 안 바뀌었다 — 나타날 수 없는 대역이라 넣어도 소용이 없다(observed). x-forwarded-for 는 이 단계를 밟아도 앱까지 안 오고, 왜 사라지는지는 안 가렸다(unknown).

190. 단계 04 — Let's Encrypt 와 인증서 갱신

어디서 치는가 — 이 단계도 셸이 갈린다. 1~3·5 번은 전부 [kc-lab-edge] 에서 치고, 4번 확인만 tailnet 에 붙은 다른 머신에서 친다. 2-1 만 터미널이 아니라 브라우저다.

번호 무엇 어디서
1 certbot 설치 [kc-lab-edge]
2-1 Cloudflare 토큰 발급 브라우저
2-2 ~ 2-6 토큰 파일 · 토큰 검증 · 시험 발급 · 실제 발급 · 확인 [kc-lab-edge]
3 nginx 에 443 을 얹는다 [kc-lab-edge]
4 열리는가 · 체인이 완전한가 tailnet 에 붙은 다른 머신 — 여기만 엣지가 아니다
5 갱신이 서빙까지 닿게 한다 [kc-lab-edge]

4번만 엣지에서 치지 않는 까닭을 가이드가 한 줄로 적었다 — 「엣지에는 Tailscale 이 없어 자기 자신을 밖에서 들어오는 경로로 부를 수 없다.」 엣지 안에서 치면 이렇게 막힌다.

* connect to 100.83.212.4 port 443 failed: Connection refused

auth.hyeonworks.com 은 호스트의 tailnet 주소 100.83.212.4 로 풀리는데 엣지 VM 에는 Tailscale 이 없다. 엣지에서 나간 패킷은 호스트의 virbr0 으로 들어가고 DNAT 규칙은 iifname "tailscale0" 만 매칭하므로 안 걸린다 → 호스트 443 에 리스너가 없어 거절된다. 설정 문제가 아니라 친 위치 문제다.

가이드는 그 기계를 「tailnet 에 붙은 다른 머신」이라고만 부르고, §185 의 셸 표에 있는 다섯 이름 중 어느 것인지 짚지 않았다(unknown).

이 단계가 세우는 것 — 가이드 04 의 「이 단계가 끝나면」은 한 줄이다.

https:// 가 열리고 체인이 완전하며, 갱신이 자동으로 서빙까지 닿는다.

세 마디가 각각 다른 확인을 요구한다. 「열린다」는 4번의 curl -v, 「체인이 완전하다」는 4번의 openssl s_client, 「서빙까지 닿는다」는 5번의 워커 PID 로 판정한다. 앞의 둘까지만 보고 끝내는 문서가 많고, 이 실험대가 재 둔 결함은 정확히 세 번째 자리에 있다.

무엇
패키지 certbot · python3-certbot-dns-cloudflare
자격증명 /etc/letsencrypt/cloudflare.ini600, root 만 읽기
발급 대상 -d hyeonworks.com -d '*.hyeonworks.com'
lineage 디렉터리 /etc/letsencrypt/live/hyeonworks.com/첫 번째 -d 에서 따온 라벨
nginx 가 읽는 것 fullchain.pem · privkey.pem
유효기간 오늘 + 90일
deploy 훅 /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh (chmod +x)
타이머 certbot-renew.timercertbot-renew.service

제약이 검증 방식을 정했다(observed). 같은 Let's Encrypt 인증서인데 「이 도메인이 네 것이냐」를 증명하는 방법만 다르고, 이 실험대에는 선택의 여지가 없다.

HTTP-01 DNS-01
검증 방향 Let's Encrypt → 우리 서버 (인바운드) certbot → DNS 공급자 API (아웃바운드)
공개 인터넷에서 보여야 하나 그렇다 아니다
와일드카드 불가 가능
필요한 것 80 포트 · 공개 A 레코드 DNS 공급자 API 토큰

가이드는 이 선택을 우열로 적지 않는다 — 공개 서버라면 HTTP-01 이 맞고, 토큰도 DNS 연동도 없어 관리할 것이 적다. 감수한 비용은 Cloudflare API 토큰이 엣지 VM 안 평문 파일에 놓이는 쪽이고, 그래서 권한을 Edit zone DNS · Specific zone · hyeonworks.com 으로 좁힌다. All zones 로 두면 계정의 모든 도메인에 대한 DNS 수정 권한이 그 파일에 놓이고, Global API Key 는 계정 전체 만능 키라 폐기하면 그 키를 쓰던 다른 것들이 전부 같이 죽는다.

전제와 되돌리기

전제는 한 줄이다 — 「[03] 이 끝나 엣지 nginx 가 Traefik 으로 프록시한다.」

되돌리는 절차는 원본 가이드 04 에 없다(unknown). 되돌리기에 가장 가깝게 적힌 문장은 배치의 이유 한 줄뿐이다.

인증서·certbot·갱신 타이머·deploy 훅이 전부 엣지 게스트에 산다. 물리 호스트에는 아무것도 두지 않는다 — 그래야 virsh undefine kc-lab-edge 한 줄로 이 계층을 통째로 되돌릴 수 있다.

이 문장은 「게스트를 통째로 지우면 같이 없어진다」이고 걷어내는 절차가 아니다. 순서도, 사전 조건도, 지운 뒤 무엇을 확인하는지도 없고 이 실험대가 그렇게 지워 본 기록도 없다. 이 단계가 엣지에 남기는 것은 넷이다 — 패키지 둘, /etc/letsencrypt/ 아래의 cloudflare.ini·인증서 묶음·renewal-hooks/deploy/reload-nginx.sh, certbot-renew.timer 의 활성화, 그리고 nginx 설정의 443 블록.

(external) Cloudflare 쪽 토큰은 엣지를 지워도 Cloudflare 계정에 남는다. 토큰은 그쪽 서버가 들고 있고 엣지에 있던 것은 사본이다. 가이드는 그 폐기를 적지 않았다(unknown).

먼저 본다 — 바꾸기 전 상태

두 확인은 아무것도 바꾸지 않는다. 하나는 검증 방식을 정하고, 하나는 3번에서 쓸 설정 문법을 가른다.

확인 ① 이 도메인이 무엇으로 풀리는가

getent hosts auth.hyeonworks.com

실측(observed)

100.83.212.4    auth.hyeonworks.com

★ 여기에 dig 를 바로 쓰지 않는다. 처음에는 dig +short auth.hyeonworks.com 이라고 적었는데 엣지 게스트에 dig 가 없다. 2026-09-17 에 cloud-init 으로 갓 만든 kc-lab-edge 에서 쳐서 확인했다(observed).

dig: command not found

cloud-init 이 까는 것은 curlnftables 뿐이라 dnsutils 가 안 들어간다. getent hostslibc 가 주는 것이라 어디서나 있고, 주소와 이름을 한 줄로 낸다. dig 의 형태가 꼭 필요하면 먼저 깐다 — 둘 다 같은 100.83.212.4 를 냈다(observed).

sudo apt install -y dnsutils && dig +short auth.hyeonworks.com

어디를 봐야 하는가 — 주소 한 줄. 그 주소가 공개 인터넷 대역인가, 100.64.0.0/10 안인가.

이 결과가 의미하는 것100.64.0.0/10 은 CGNAT 용으로 예약된 대역이라 공개 인터넷에서 라우팅 자체가 되지 않는다. 방화벽을 여는 문제가 아니라 그 주소가 인터넷에 존재하지 않고, Let's Encrypt 를 tailnet 에 초대할 방법도 없다. 그래서 HTTP-01 은 쓸 수 없고 DNS-01 을 쓴다. 여기서 공개 주소가 나오는 환경이라면 위 표의 왼쪽을 고르는 편이 낫다.

확인 ② nginx 판 번호는 몇인가

sudo nginx -v

sudo 가 붙는 까닭은 §189 확인 ① 과 같다. 실행 파일이 /usr/sbin/nginx 인데 데비안 일반 사용자 PATH 에 그 디렉터리가 없다. sudo 없이 치면 nginx: command not found 다 — 안 깔린 것처럼 보이지만 아니다(2026-09-17, observed).

실측(observed) — 같은 설정인데 배포판에서 갈린다.

엣지 (Debian 12):   nginx version: nginx/1.22.1
물리 호스트 (Arch): nginx version: nginx/1.30.4

어디를 봐야 하는가1.25.1 이 경계다. 지금 셸의 번호가 그 위인가 아래인가.

이 결과가 의미하는 것 — 1.25.1 미만이면 http2 on; 지시어가 없다. 3번의 설정처럼 listen파라미터로 쓰면 1.22 와 1.30 양쪽에서 다 돈다. 지시어 형태로 쓰면 이렇게 막힌다.

[emerg] 1287#1287: unknown directive "http2" in /etc/nginx/sites-enabled/keycloak-lab:14
nginx: configuration file /etc/nginx/nginx.conf test failed

실행 절차

번호는 가이드 04 의 것을 그대로 쓴다. 4번은 아래 「끝났는지 판정한다」로 옮겼다.

1. certbot 을 깐다[kc-lab-edge]

목적 — DNS-01 을 돌릴 플러그인까지 함께 올린다. cloud-init 이 이미 깔았다면 건너뛴다 — deploy/lab/cloud-init/kc-lab.yaml.examplepackages 에 들어 있다.

sudo apt install -y certbot python3-certbot-dns-cloudflare

확인 — 쓸 수 있는 검증 방식이 무엇인가

certbot plugins 2>/dev/null | grep -E '^\*'

실측(observed)

* dns-cloudflare
* standalone
* webroot

어디를 봐야 하는가dns-cloudflare 한 줄이 있는가. ^\* 로 거른 것은 certbot 이 쓸 수 있다고 표시한 플러그인 앞에 * 를 붙이기 때문이다.

이 결과가 의미하는 것 — 그 줄이 없으면 플러그인 패키지가 안 깔린 것이고, --dns-cloudflare 를 줘도 unrecognized arguments 로 끝난다.

2-1. Cloudflare 토큰을 발급받는다 — 브라우저

목적 — certbot 이 인증용 TXT 레코드를 직접 만들었다 지운다. 그래서 DNS 쓰기 권한이 필요하다.

  1. https://dash.cloudflare.com/profile/api-tokensCreate Token
  2. Edit zone DNS 템플릿 → Use template
  3. PermissionsZone · DNS · Edit (템플릿이 채워 준 그대로)
  4. Zone ResourcesInclude · Specific zone · hyeonworks.com
  5. Continue to summaryCreate Token

★ 토큰 값은 이 화면에서 한 번만 보인다. 창을 닫으면 복구가 없다.

All zones 로 두지 않는다. 계정의 모든 도메인에 대한 DNS 수정 권한이 엣지 VM 안 평문 파일에 놓인다. Global API Key 는 더 나쁘다 — 계정 전체 만능 키라 폐기하면 그 키를 쓰던 다른 것들이 전부 같이 죽는다.

값은 이 문서에도 터미널에도 적지 않는다. 아래 확인은 전부 길이와 존재 여부만 본다.

2-2. 토큰 파일을 만든다[kc-lab-edge]

목적 — certbot 이 읽을 자격증명을 root 만 읽을 수 있는 파일에 둔다.

sudo install -m 600 /dev/null /etc/letsencrypt/cloudflare.ini
sudo nano /etc/letsencrypt/cloudflare.ini
# file: /etc/letsencrypt/cloudflare.ini
dns_cloudflare_api_token = 발급받은_토큰_값

install -m 600 을 먼저 치는 이유 — 파일을 비어 있을 때 미리 600 으로 만든다. 토큰을 쓰고 나서 권한을 고치면 그사이가 열려 있다.

확인 — 권한과 크기

ls -l /etc/letsencrypt/cloudflare.ini
sudo wc -c /etc/letsencrypt/cloudflare.ini

어디를 봐야 하는가-rw------- 이고 바이트 수가 0 이 아닌가. sudo 없이 wc 를 치면 Permission denied 가 나는데 그게 정상이다(600 = root 만 읽기). 토큰 값 자체는 출력하지 않는다.

이 결과가 의미하는 것 — 권한이 -rw-r--r-- 면 같은 게스트의 다른 사용자도 토큰을 읽는다. 바이트 수가 0 이면 nano 에서 저장을 안 했거나 다른 경로에 썼다.

2-3. 토큰이 살아 있고 권한이 맞는지 본다[kc-lab-edge]

CF=$(sudo awk -F'= *' '/api_token/{print $2}' /etc/letsencrypt/cloudflare.ini)
curl -s https://api.cloudflare.com/client/v4/user/tokens/verify -H "Authorization: Bearer $CF"

어디를 봐야 하는가"status":"active""success":true. "code":6003 이면 토큰 값이 틀렸거나 잘렸고, "code":9109 면 권한 범위가 모자라다(Zone · Zone · Read 를 한 줄 더한다).

이 결과가 의미하는 것 — 여기서 걸러 두면 뒤에서 실패했을 때 「DNS 문제인지 토큰 문제인지」를 헷갈리지 않는다. 값은 셸 변수에만 담기고 화면에 찍히지 않는다.

2-4. 시험 발급 — --dry-run

목적 — 발급 한도를 쓰지 않고 경로 전체를 한 번 돌려 본다.

sudo certbot certonly --dns-cloudflare \
  --dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
  -d hyeonworks.com -d '*.hyeonworks.com' --dry-run

어디를 봐야 하는가 — 마지막 줄 The dry run was successful.

★ dry-run 은 인증서를 저장하지 않는다. 스테이징 서버에 대고 시험만 하는 것이라 /etc/letsencrypt/live/ 에는 아무것도 안 생긴다. 여기서 certbot certificates 를 치면 No certificates found 가 나오는 것이 정상이다. 이걸 먼저 돌리는 이유는 Let's Encrypt 의 주당 중복 인증서 5장 한도를 dry-run 이 쓰지 않기 때문이다.

★ 최초 실행이면 계정 등록 대화가 먼저 뜬다.

질문
Enter email address 본인 이메일 (빈 값이면 Invalid email address: . 로 되묻는다)
Terms of Service Y
EFF 뉴스레터 N — 발급과 무관하다

최초 1회뿐이다. 프롬프트 없이 돌리려면 -m <이메일> --agree-tos --no-eff-email 을 붙인다. --register-unsafely-without-email 은 쓰지 않는다 — 갱신 실패를 알려줄 통로가 사라지는데, 5번이 재는 것이 바로 그 갱신이다.

2-5. 실제 발급 — --dry-run 을 뺀 같은 명령

sudo certbot certonly --dns-cloudflare \
  --dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
  -d hyeonworks.com -d '*.hyeonworks.com'

어디를 봐야 하는가Successfully received certificate. 와 그 아래 저장 경로. DNS-01 은 느리다 — TXT 가 퍼질 때까지 기다리느라 수십 초 걸린다. 중간에 끊지 않는다.

2-6. 무엇을 받았는지 확인한다

sudo certbot certificates

어디를 봐야 하는가 — 네 줄이다.

Domains: hyeonworks.com *.hyeonworks.com한 줄에 둘 다
Expiry Date: 오늘 + 90일, VALID
Certificate Path: /etc/letsencrypt/live/hyeonworks.com/fullchain.pem
Private Key Path: /etc/letsencrypt/live/hyeonworks.com/privkey.pem

★ 디렉터리 이름과 인증서가 덮는 이름은 별개다(observed). 여기서 가장 많이 헷갈린다.

무엇 정해지는 방식
디렉터리 이름 live/hyeonworks.com/ certbot 이 이 묶음(lineage)을 관리하려고 붙인 라벨 첫 번째 -d 에서 따온다. 서빙과 무관
SAN 목록 브라우저가 보는 실제 유효 호스트명 -d 로 준 이름 전부

확인 — 인증서가 실제로 어떤 이름에 유효한가

sudo openssl x509 -noout -ext subjectAltName -in /etc/letsencrypt/live/hyeonworks.com/fullchain.pem

어디를 봐야 하는가DNS:hyeonworks.com, DNS:*.hyeonworks.com 두 개. auth.hyeonworks.com 은 두 번째에 걸린다.

이 결과가 의미하는 것 — 여기 나온 경로가 곧 nginx 가 읽을 파일이다. 그래서 auth.hyeonworks.com 으로 다시 받을 필요가 없고, *.hyeonworks.com 한 장이 auth·app1·app2 를 전부 덮는다. 다만 3번의 nginx 설정에는 디렉터리 경로를 한 글자도 다르지 않게 적어야 한다 — live/auth.hyeonworks.com/ 이라고 적으면 cannot load certificate 로 막힌다.

★ 와일드카드는 한 단계만 덮는다. a.b.hyeonworks.com 은 포함되지 않고, apex 인 hyeonworks.com 자신도 *.hyeonworks.com 에 들어가지 않는다 — 그래서 -d 를 둘 준다.

가이드는 여기에 값을 하나 더 적어 두었다 — 이 실험대는 처음에 와일드카드를 안 썼고, 네 번째 이름이 없어 다른 실험에서 app2 를 빌려 써야 했다. 아래 체인 실측에 live/auth.hyeonworks.com/ 경로가 남아 있는 것도 그때의 기록이다.

3. nginx 에 443 을 얹는다[kc-lab-edge]

목적 — [03] 에서는 80 만 세웠다. 인증서가 생겼으니 443 블록을 더하고 80 은 리다이렉트로 바꾼다.

파일을 연다. 03 에서 쓴 그 파일이다.

sudo nano /etc/nginx/sites-available/keycloak-lab

03 에서 쓴 내용을 이것으로 바꾼다. 저장은 Ctrl+OEnter, 나가기는 Ctrl+X.

# file: /etc/nginx/sites-available/keycloak-lab
upstream k3s_traefik {
	server 192.168.122.11:80;
	server 192.168.122.12:80;
}

server {
	listen 80 default_server;
	server_name _;
	return 301 https://$host$request_uri;
}

server {
	listen 443 ssl http2 default_server;
	server_name _;

	ssl_certificate     /etc/letsencrypt/live/hyeonworks.com/fullchain.pem;
	ssl_certificate_key /etc/letsencrypt/live/hyeonworks.com/privkey.pem;
	ssl_protocols       TLSv1.2 TLSv1.3;

	location / {
		proxy_pass http://k3s_traefik;
		proxy_http_version 1.1;
		proxy_set_header Host              $host;
		proxy_set_header X-Forwarded-Host  $host;
		proxy_set_header X-Forwarded-Proto https;
		proxy_set_header X-Forwarded-Port  443;
		proxy_set_header X-Forwarded-For   $remote_addr;
		proxy_set_header X-Real-IP         $remote_addr;
		proxy_read_timeout 3600s;
		proxy_send_timeout 3600s;
	}
}

어디를 봐야 하는가 — 03 에서 바뀐 곳이 셋이다.

03 에서는 지금
80 블록 location / { proxy_pass … } return 301 https://… 리다이렉트만
443 블록 없었다 인증서와 함께 새로
X-Forwarded-Proto http https

★ 위 블록의 인증서 경로가 틀렸다(2026-09-17, observed). live/hyeonworks.com/ 이라고 적혀 있는데 certbot 이 만드는 디렉터리는 live/auth.hyeonworks.com/ 이다. 이름을 여럿 담은 인증서라도 디렉터리 이름은 -d 로 처음 준 이름 하나를 쓴다. 그대로 치면 nginx -t 가 막힌다.

[emerg] cannot load certificate "/etc/letsencrypt/live/hyeonworks.com/fullchain.pem":
  BIO_new_file() failed (SSL: error:80000002:system library::No such file or directory
nginx: configuration file /etc/nginx/nginx.conf test failed
sudo ls /etc/letsencrypt/live/
sudo certbot certificates | grep 'Certificate Path'
README
auth.hyeonworks.com
    Certificate Path: /etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem

경로를 그 이름으로 고치자 syntax is ok · test is successful 로 통과하고 엣지가 443 을 듣기 시작했다. §204 의 「문제가 생기면」에 적힌 진단은 방향이 거꾸로다live/auth.hyeonworks.com/ 이 맞는 경로이고 없는 것은 live/hyeonworks.com/ 쪽이다.

그리고 이 실험대의 certbot 은 DNS-01 로 등록되어 있다(observed). 두 문서가 갈렸던 대목이 닫혔다.

sudo sh -c 'grep -H "authenticator\|dns_cloudflare_credentials\|server =" /etc/letsencrypt/renewal/*.conf'
authenticator = dns-cloudflare
dns_cloudflare_credentials = /etc/letsencrypt/cloudflare.ini
server = https://acme-v02.api.letsencrypt.org/directory

sudo grep ... /etc/letsencrypt/renewal/*.conf 로 치면 안 된다(observed). 글로브를 일반 사용자 셸이 먼저 펼치는데 그 디렉터리를 못 읽어서 no matches found 로 끝나고 grep 은 시작도 안 한다. sudo sh -c '...' 로 sudo 안에서 펼쳐야 한다.

cert.pem 이 아니라 fullchain.pem. 서버 인증서만 보내면 중간 인증서가 빠져 체인이 끊긴다. 브라우저는 대개 캐시나 AIA 로 보완해서 정상으로 보이고, 캐시가 없는 클라이언트에서만 깨진다. 그래서 발견이 늦다. 2번에서 certbot 이 찍어 준 경로와 한 글자도 다르면 안 된다.

확인 — 문법을 보고 적용한다

sudo nginx -t && sudo systemctl reload nginx

어디를 봐야 하는가syntax is oktest is successful 두 마디가 다 나와야 통과다. types_hash_max_size 같은 [warn] 줄은 통과를 막지 않는다 — 경고와 오류를 구분한다.

이 결과가 의미하는 것 — 통과했으면 && 뒤의 reload 가 이어서 돌고, 실패했으면 && 가 reload 를 막아 준 것이라 지금 돌고 있는 nginx 는 옛 설정 그대로 멀쩡하다. 확인 ② 에서 본 판 번호가 1.25.1 미만인데 http2 on; 을 지시어로 썼다면 여기서 unknown directive "http2" 가 나온다.

5. 갱신이 서빙까지 닿게 한다 — 여기가 이 단계의 알맹이다[kc-lab-edge]

목적 — 타이머가 도는 것만으로는 부족하다. 갱신된 인증서를 누가 읽게 만드는 일까지 세운다.

확인 ① 타이머가 있는가

systemctl list-timers certbot-renew.timer

어디를 봐야 하는가 — 네 칸이다. NEXT(다음 실행 시각)와 LEFT(남은 시간)가 채워져 있는가, LAST/PASSED 가 하루 안쪽인가, UNIT 옆의 ACTIVATEScertbot-renew.service 를 가리키는가. 표가 통째로 비어 나오면 타이머가 없는 것이다 — 이름이 배포판마다 다르니 이렇게 찾는다.

systemctl list-timers --all | grep -i certbot

이 결과가 의미하는 것 — 여기까지가 「갱신이 돌기는 하는가」의 답이고, 대부분의 문서가 여기서 끝난다. 그런데 이것이 active 여도 갱신된 인증서가 서빙되지는 않는다. nginx 는 인증서를 기동 시점에 읽어 메모리에 들고 있고 certbot 은 live/ 심볼릭 링크만 갈아 끼운다. 경로는 그대로이고 내용만 바뀌므로 nginx 는 모른다.

확인 ② 배포판 기본 유닛이 reload 를 부르는가

systemctl cat certbot-renew.service
[Service]
Type=oneshot
ExecStart=/usr/bin/certbot -q renew
PrivateTmp=true

어디를 봐야 하는가ExecStart= 한 줄과, 그 아래에 ExecStartPost=있는지 없는지. 그리고 ExecStart 의 인자에 --deploy-hook 이 붙어 있는지. 여기 없는 것을 보는 것이 이 명령의 목적이다.

이 결과가 의미하는 것ExecStartPost--deploy-hook 도 없다. 이 유닛은 인증서를 새로 받는 데까지만 책임지고, 받은 것을 누가 읽게 만드는 일은 아무도 하지 않는다. 배포판이 이렇게 준다는 것이 요점이다 — 「기본값이니 괜찮겠지」가 바로 이 결함의 서식지다. 여기 뭔가 적혀 있는 배포판이라면 아래 훅은 필요 없고, 대신 그 명령이 nginx 를 reload 하는지만 확인한다.

훅 하나를 넣는다.

sudo nano /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
# file: /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
#!/bin/sh
nginx -t && nginx -s reload

실행 권한을 준다. 없으면 certbot 이 조용히 건너뛴다.

sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh

확인 ③ 훅이 실행 파일인가

ls -l /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh

어디를 봐야 하는가 — 권한 문자열에 x 가 세 번(-rwxr-xr-x) 보이는가. -rw-r--r-- 면 아직 실행 파일이 아니다.

이 결과가 의미하는 것deploy/ 에 넣는 까닭은 post/ 가 갱신이 없어도 매번 돌아 하루 두 번 워커를 갈아치우기 때문이다. deploy/실제로 갱신됐을 때만 실행된다. 이 훅은 한동안 저장소에 없었고 호스트에만 있어서, 호스트를 초기화하면 아무 오류 없이 사라지고 아래 표의 왼쪽 칸으로 되돌아갔다.

확인 ④ 훅이 호출되기는 하는가 — 상태를 바꾸지 않는 쪽부터 친다

sudo certbot renew --dry-run

어디를 봐야 하는가 — 출력 끝의 Running deploy-hook command 줄과 simulated renewals 요약. 훅 줄이 아예 안 나오면 파일이 deploy/ 가 아닌 곳에 있거나 실행 권한이 없다(확인 ③ 의 ls -lx 를 본다).

이 결과가 의미하는 것 — dry-run 은 훅이 호출되는지까지만 말해 준다. 호출된 훅이 nginx 를 정말 갈아 끼웠는지는 dry-run 으로 알 수 없다.

확인 ⑤ 훅이 nginx 를 정말 갈아 끼웠는가 — 이 확인은 상태를 바꾼다

--force-renewal 은 인증서를 실제로 새로 받으므로 발급 한도(주당 중복 5장)를 깎는다. 진짜 판정이 필요할 때만 한 번 쓴다.

# 강제 갱신 전에 워커 PID 를 적어 둔다
ps -eo pid,lstart,args | grep 'nginx: worker' | grep -v grep

sudo certbot renew --force-renewal

# 워커 PID 가 바뀌었으면 reload 된 것이다
ps -eo pid,lstart,args | grep 'nginx: worker' | grep -v grep

어디를 봐야 하는가 — 두 출력의 첫 열(PID)과 둘째 열 묶음(lstart, 프로세스 시작 시각). 앞뒤로 놓고 PID 집합이 통째로 바뀌었는지만 본다. lstart 를 같이 뽑는 이유는 PID 가 우연히 재사용됐을 때를 가르기 위해서다. 마스터 프로세스는 그대로이고 워커만 갈리는 것이 정상이다.

이 결과가 의미하는 것 — PID 가 바뀌었으면 훅이 돌아 nginx 가 새 인증서를 읽었다. 안 바뀌었으면 인증서는 갱신됐는데 서빙되는 것은 옛것이고, 그 상태가 아래 표의 왼쪽 칸이다.

판정은 로그 문구가 아니라 워커 PID 로 한다. certbot 이 Hook 'deploy-hook' ran with error output 이라고 찍는데 실패가 아니다 — nginx 의 types_hash 경고가 stderr 로 나갔을 뿐이고 내용은 test is successful · signal process started 다. 로그에서 error 를 grep 하는 감시를 걸면 성공한 훅을 실패로 오독한다.

실측(observed) — 이 실험대에서 잰 차이다.

훅 없음 훅 있음
갱신 → 서빙 2305초 (38분 25초) 1~2초
무엇이 reload 했나 사람이 직접 certbot deploy 훅
아무도 안 했다면 다음 nginx 재시작까지 = 사실상 무기한

88일 동안 이 결함이 보이지 않는다. 타이머는 정상이고 매번 SUCCESS 로 끝나며, 만료 30일 전까지는 갱신 자체를 하지 않아 발현할 기회가 없다. 발현하는 날의 증상은 인증서 만료이고, 그날에도 로그에는 SUCCESS 라고 적혀 있다.

6. reload 는 무중단인가 — 쟀다

궁금할 것이므로 결과만 적는다. 무중단이다(observed). 새 연결 8856건 전부 200, p95 는 205.7ms 대 204.3ms 로 변화 없음. 그리고 845KB 를 20k/s 로 받는 중이던 요청이 전송 12초째에 reload 를 맞고도 845361바이트를 온전히 받았다(연결수 1). 옛 워커가 그 요청을 끝까지 책임진다.

끝났는지 판정한다 — tailnet 에 붙은 다른 머신에서 친다

가이드 04 의 4번이 여기다. 1~3 번은 전부 엣지에서 쳤지만 확인은 밖에서 들어와야 의미가 있다.

확인 ① 열리나 — 처음 한 번은 협상 과정을 읽는다

curl -v https://auth.hyeonworks.com/ -o /dev/null

★ 경로는 / 다. Keycloak 은 [05] 에서 올린다. 아직 Ingress 가 없으므로 404 가 정상이고, 이 단계가 재는 것은 응답 코드가 아니라 TLS 가 붙었는가다. /realms/master 같은 Keycloak 경로를 여기서 쓰면 「TLS 가 안 된 건지 Keycloak 이 없는 건지」가 섞인다.

형태 — 이 실험대에서 캡처해 두지 않았다(unknown). 읽어야 할 줄만 옮긴다.

*  SSL connection using TLSv1.3 / ...
*  subject: CN=hyeonworks.com
*  issuer: C=US; O=Let's Encrypt; CN=...
*  SSL certificate verify ok.
< HTTP/1.1 404 Not Found

어디를 봐야 하는가* 로 시작하는 줄 넷이다. 어떤 TLS 판으로 협상했는가, subject 의 CN 이 지금 친 이름과 같은가, issuer 가 Let's Encrypt 인가, 그리고 SSL certificate verify ok. 가 있는가. 그 아래 < 로 시작하는 첫 줄이 응답 상태다. -o /dev/null 은 본문만 버리므로 이 줄들은 화면에 그대로 찍힌다. subjecthyeonworks.com 인 것이 맞다 — 와일드카드 인증서라 CN 은 apex 이름이고 auth.hyeonworks.com 은 SAN 의 *.hyeonworks.com 에 걸린다.

★ 그런데 이 실험대에서는 CN 이 auth.hyeonworks.com 이다(2026-09-17, observed). 가이드대로 와일드카드를 받으면 위 문단처럼 보인다. 이 실험대는 와일드카드를 안 받았고 계보도 auth.hyeonworks.com 하나뿐이라, 앞에서 적은 「이 실험대는 처음에 와일드카드를 안 쉈고」와 같은 사실을 가리킨다. 그 둘이 같은 문서 안에서 서로 어긋나 있었다.

echo | openssl s_client -connect 192.168.122.10:443 -servername auth.hyeonworks.com 2>/dev/null | openssl x509 -noout -subject -ext subjectAltName -dates
subject=CN=auth.hyeonworks.com
X509v3 Subject Alternative Name: 
    DNS:app1.hyeonworks.com, DNS:app2.hyeonworks.com, DNS:auth.hyeonworks.com
notBefore=Sep  4 11:29:18 2026 GMT
notAfter=Dec  3 11:29:17 2026 GMT

그래서 CN 을 합격 조건으로 읽으면 안 된다. 와일드카드를 받았으면 hyeonworks.com, 이름을 나열해 받았으면 첫 번째 -d 의 이름이 CN 이 된다. 보는 것은 SSL certificate verify ok. 한 줄과, 지금 친 이름이 SAN 에 있는가다. 이름 셋 밖은 TLS 단계에서 끝나 curl 이 종료 코드 60 을 내고 %{http_code}000 이 되는데, 화면에서는 「서버가 죽었다」와 같아 보인다. A-4 의 Grafana 탐침이 그렇게 쓸모없어졌다.

이 결과가 의미하는 것 — 네 줄이 다 나오면 인증서가 붙었고 체인이 클라이언트 기준으로 검증됐다. 인증서를 처음 붙인 직후에는 코드 한 칸이 아니라 이 화면을 본다. verify 줄 대신 unable to get local issuer certificate 가 나오면 중간 인증서가 빠진 것이고, 원인은 3번의 cert.pem/fullchain.pem 이라 확인 ② 로 간다.

같은 것을 반복해서 재거나 03·05 의 값과 나란히 비교할 때만 값만 뽑는 형태로 줄인다.

curl -s -o /dev/null -w '%{http_code} tls=%{ssl_verify_result}\n' https://auth.hyeonworks.com/

랩 호스트에서 칠 때만 --resolve 를 붙인다(2026-09-17, observed). 거기서는 이 이름이 호스트 자신의 tailnet 주소로 풀리고 그 주소에는 443 을 듣는 것이 없다.

curl -s -o /dev/null -w '%{http_code} tls=%{ssl_verify_result}\n' --resolve auth.hyeonworks.com:443:192.168.122.10 \
  https://auth.hyeonworks.com/

실측(observed) — 2026-09-11, tailnet 클라이언트에서

404 tls=0

어디를 봐야 하는가tls=0 이 인증서 검증 통과다. 앞의 404 는 05 이후에 200 으로 바뀐다.

확인 ② 체인 단계와 검증

echo | openssl s_client -connect auth.hyeonworks.com:443 \
  -servername auth.hyeonworks.com 2>/dev/null \
  | grep -E '^ *[0-9]+ s:|^ *i:|Verify return code'

실측(observed) — 이름을 따로 받던 시절의 기록이라 0번 s:auth.hyeonworks.com 이다. 와일드카드로 받으면 CN = hyeonworks.com 이 되고, 봐야 할 구조는 똑같다.

 0 s:CN = auth.hyeonworks.com
   i:C = US, O = Let's Encrypt, CN = YE2
 1 s:C = US, O = Let's Encrypt, CN = YE2
   i:C = US, O = ISRG, CN = Root YE
 2 s:C = US, O = ISRG, CN = Root YE
   i:C = US, O = Internet Security Research Group, CN = ISRG Root X2
 3 s:C = US, O = Internet Security Research Group, CN = ISRG Root X2
   i:C = US, O = Internet Security Research Group, CN = ISRG Root X1
Verify return code: 0 (ok)

어디를 봐야 하는가 — 왼쪽의 번호(0·1·2·3)가 몇까지 가는가, 그리고 각 단계의 i:(발급자)가 바로 다음 단계의 s:(주체)와 같은가. 0번이 우리 서버 인증서이고, 위 실측에서 0의 i:CN = YE2 인데 1의 s: 가 같은 CN = YE2 다 — 사슬이 이어져 있다는 뜻이다. 마지막이 Verify return code: 0 (ok).

이 결과가 의미하는 것단계가 1개면 cert.pem 을 쓴 것이다. 서버가 자기 인증서만 보내고 중간 인증서를 안 보낸 상태다. 이때 브라우저는 대개 캐시나 AIA 로 보완해서 정상으로 보이므로, 이 명령이 유일하게 믿을 수 있는 판정이다. 고치는 곳은 3번의 ssl_certificate 한 줄이고, 고친 뒤 nginx -t && systemctl reload nginx 하고 여기서 다시 잰다. Verify return code 가 0 이 아니면 숫자마다 뜻이 다르다 — 10 은 만료, 20 은 발급자를 못 찾음, 21 은 첫 인증서를 검증 못 함.

확인 ③ 이름 세 개가 한 인증서인가

for H in auth app1 app2; do
  echo | openssl s_client -connect $H.hyeonworks.com:443 -servername $H.hyeonworks.com 2>/dev/null \
    | openssl x509 -noout -serial
done

어디를 봐야 하는가 — 찍히는 세 줄의 일련번호가 서로 같은가. 값 자체는 아무 의미가 없고 셋이 일치하는지만 본다.

이 결과가 의미하는 것 — 셋이 같으면 SAN 하나에 이름 셋이 들어 있는 인증서 한 장이고, 갱신도 한 번에 끝난다. 다르면 인증서가 여러 장이라 갱신 훅도 장마다 따로 돌고, 한 장만 갱신됐을 때 나머지 이름이 만료되는 상황이 생긴다. 이름별로 무엇이 실려 있는지 보려면 SAN 을 직접 편다.

echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \
  | openssl x509 -noout -ext subjectAltName

다섯 칸으로 다시 본다.

무엇 명령 통과
열리는가 curl -v https://auth.hyeonworks.com/ -o /dev/null SSL certificate verify ok.
검증값 curl -s -o /dev/null -w '%{http_code} tls=%{ssl_verify_result}\n' … 404 tls=0
체인 openssl s_client … | grep … 번호가 3까지 · Verify return code: 0 (ok)
이름 셋 for H in auth app1 app2; … 일련번호 세 줄이 같다
서빙까지 강제 갱신 앞뒤의 ps … 'nginx: worker' 워커 PID 집합이 바뀐다

근거를 재려면 (선택) — 평소에는 필요 없다. 갱신 중 가용성을 문서에 남길 때만 이렇게까지 한다. 주입 전에 대조군부터 잡는다. 평시 오류율을 모르면 갱신 중에 나온 실패 한 건을 해석할 수 없다.

# 대조군 — 0.2초 × 900회 = 180초
i=0; while [ $i -lt 900 ]; do
  curl -s -o /dev/null -w '%{http_code} %{time_total}\n' --max-time 5 \
    https://auth.hyeonworks.com/realms/master
  i=$((i+1)); sleep 0.2
done > /tmp/control.txt
awk '{print $1}' /tmp/control.txt | sort | uniq -c

여기서는 값만 뽑는 형태가 맞다. 900번을 재서 코드별로 세는 것이 목적이고 헤더는 볼 일이 없다. 앞의 확인 ①과 형태가 다른 이유가 이것이다.

어디를 봐야 하는가uniq -c 가 내놓는 줄이 몇 개인가. 한 줄이면 900번이 전부 같은 코드였다는 뜻이고, 그 줄의 왼쪽 수가 900 인지 본다. 두 줄 이상이면 그 자체가 평시 오류가 있다는 뜻이다. 응답 시간이 궁금하면 둘째 열을 따로 본다.

awk '{print $2}' /tmp/control.txt | sort -n | tail -1     # 최악값

이 결과가 의미하는 것 — 이 실험대의 대조군은 900건 전부 200, 오류 0 이었다(observed). 그래서 갱신 중 비200 이 한 번이라도 나오면 갱신 탓으로 귀속할 수 있었다. 대조군에 이미 오류가 섞여 있다면 주입 중의 오류 한 건은 아무것도 증명하지 못하므로, 대조군이 깨끗해질 때까지는 주입을 하지 않는다.

그리고 두 기계의 시각을 나란히 놓기 전에 시계부터 잰다.

A=$(date -u +%s.%N); B=$(ssh test-server 'date -u +%s.%N'); C=$(date -u +%s.%N)
# 왜곡 ≈ B  (A+C)/2 ,  어느 쪽이 맞는지는 외부 기준으로 가른다
curl -sI https://www.google.com | grep -i '^date:'
timedatectl show -p NTP -p NTPSynchronized

어디를 봐야 하는가 — 세 수 A·B·C 를 눈으로 빼서 초 단위 차이가 몇인가. AC 는 같은 기계에서 SSH 왕복 직전·직후에 찍은 것이라, 그 가운데가 「저쪽 시각을 잰 순간의 이쪽 시각」이다. 그다음 Date: 헤더의 시각이 둘 중 어느 쪽에 가까운가. 마지막으로 NTPSynchronized=yes 인가.

이 결과가 의미하는 것 — 차이가 수초 이내면 두 기계의 로그를 그대로 나란히 놓아도 된다. 크면 먼저 어느 쪽이 틀렸는지 가른 다음 보정한다. 이 실험대는 test-server 가 NTP 미동기로 106초 빨랐고, 보정하지 않은 첫 계산은 훅이 인증서 발급보다 104초 먼저 실행된 것이 되어 물리적으로 불가능했다 — 음수 지연이 나오면 계산이 아니라 시계를 의심한다.

막히면

증상 원인 확인
certbot 검증 실패 DNS 가 이 호스트를 안 가리킴 · 80 이 막힘 getent hosts auth.hyeonworks.com · 밖에서 curl -I http://auth.hyeonworks.com
체인 단계가 1개 cert.pem 을 씀 위 확인 ②
갱신은 됐는데 옛 인증서가 나감 deploy 훅 없음 워커 PID · sudo ls /etc/letsencrypt/renewal-hooks/deploy/
훅이 실패한 것처럼 보임 stderr 경고를 error 로 표시 문구 말고 워커 PID
발급 한도 주당 중복 인증서 5장 --dry-run 으로 먼저 시험
unrecognized arguments: --dns-cloudflare 플러그인 미설치 certbot plugins | grep '^\*'
DNS-01 이 오래 걸림 TXT 전파 대기 정상이다. 끊지 않는다
재구축 뒤 인증서가 없음 발급하지 말고 백업을 되돌린다 한도를 아끼는 길이다
cannot load certificate 설정에 lineage 디렉터리 이름을 잘못 적었다 sudo certbot certificatesCertificate Path: 와 대조
엣지에서 친 curlConnection refused 친 위치가 틀렸다 tailnet 에 붙은 다른 머신에서 다시 친다

이 절차가 성립하는 범위

  • (observed) dig 가 낸 100.83.212.4, certbot plugins 세 줄, nginx -v 두 줄, 체인 4단계와 Verify return code: 0 (ok), 404 tls=0, 배포판 기본 유닛에 훅이 없다는 것, 갱신→서빙 2305초 대 1~2초, reload 무중단 측정값, 대조군 900건, 시계 왜곡 106초
  • (external) 100.64.0.0/10 이 CGNAT 예약 대역이라는 것과 Let's Encrypt 의 발급 한도(주당 중복 5장)·유효기간 90일은 규격이고 이 실험대가 잰 값이 아니다
  • (external) Cloudflare 계정에 남는 토큰은 엣지를 지운다고 없어지지 않는다. 가이드는 폐기를 적지 않았다
  • (inferred) 2305초 측정은 훅이 호스트에만 있던 시절의 기록이라, 엣지 VM 에서 다시 잰 값이 아니다. 지금 배치에서 같은 수가 나오는지는 재지 않았다
  • (unknown) 체인 실측은 이름을 따로 받던 시절 것이고, 와일드카드로 받은 지금의 s_client 출력은 싣지 않았다
  • (unknown) curl -v 의 전체 출력, certbot certificates 의 실제 화면, systemctl list-timers certbot-renew.timer 의 출력, 확인 ③ 의 일련번호 세 줄은 가이드가 봐야 할 줄만 적었고 값이 남아 있지 않다
  • (unknown) 원본 가이드 04 에 되돌리는 절차가 없다. 패키지·자격증명·인증서·훅· 타이머·443 블록을 걷어내 본 적이 없다
  • (unknown) §184 가 둔 「이 실험대는 이렇게 했다 / 따라 하는 사람은」 두 갈래를 이 단계에서는 쓰지 않았다 — 04 는 설정 파일 셋을 전부 편집기로 쓴다

191. 단계 05 — Keycloak 2노드와 PostgreSQL

어디서 치는가 — 이 단계는 앞의 셋보다 단순하다. 전부 [lab host] 에서 치고, 저장소 루트에서 친다. 마지막 로그인 확인만 브라우저다.

번호 무엇 어디서
1 매니페스트 적용 [lab host]~/workspace/keycloak-pattern 안에서
2 리소스가 만들어졌는지 층별 확인 [lab host]
3 안 뜰 때의 진단 순서 [lab host]
4 클러스터가 형성됐는지 [lab host]
5 밖에서 닿는지 · 관리 콘솔 로그인 [lab host] · 브라우저

kubectl 이 lab host 에서 도는 까닭은 [02] 에 있다. kubeconfig 를 게스트에서 호스트로 가져다 두었으므로 게스트에 들어갈 일이 없다. deploy/... 로 시작하는 상대경로는 저장소 루트 기준이라, 다른 디렉터리에서 치면 error: the path ... does not exist 로 막힌다.

이 단계가 세우는 것 — 가이드 05 의 「이 단계가 끝나면」은 두 줄이다.

https://auth.hyeonworks.com 에서 관리 콘솔에 로그인되고, 두 Keycloak 이 하나의 클러스터로 보인다.

두 마디가 따로다. 「로그인된다」는 5번의 200 과 브라우저이고, 「하나의 클러스터로 보인다」는 4번의 세 확인이다. 파드가 둘 다 Running 인 것과 하나의 클러스터로 묶인 것은 다르다.

무엇
네임스페이스 keycloak-lab — 매니페스트 첫 문서가 kind: Namespaceapply 가 같이 만든다
Keycloak StatefulSet keycloak — 파드 keycloak-0 · keycloak-1
PostgreSQL Deployment postgres — ReplicaSet postgres-7b474b88c8
Service keycloak → Endpoints 10.42.0.67:8080,10.42.1.155:8080
Secret keycloak-lab-secretsKC_BOOTSTRAP_ADMIN_PASSWORD 19 bytes · POSTGRES_PASSWORD 22 bytes
PVC postgres-data 하나. StorageClass local-path파드 번호가 붙지 않는다(아래)
Ingress HOSTS 가 auth.hyeonworks.com
클러스터링 JGroups — 디스커버리 테이블 jgroups_ping, 메시지 포트 7800
관리 포트 9000/metrics 가 거기 있다

★ 이미지 태그는 이제 안다. 2026-09-17 에 keycloak-pattern @ 9465582bdeploy/lab/k8s/keycloak-cluster.yaml 을 열어 읽었다(observed).

image: postgres:16-alpine
image: quay.io/keycloak/keycloak:26.7.0

판 번호는 여기 없다. 가이드 05 는 Keycloak 과 PostgreSQL 의 이미지 태그를 적지 않았고 keycloak-cluster.yaml 원문은 source/ 에 반입되지 않았다. 이 두 판 번호는 이 문서에서 대조하지 못한다(unknown). 가이드 안에 태그가 찍힌 이미지는 아래 임시 파드의 curlimages/curl:8.11.1 하나뿐이다.

전제와 되돌리기

전제는 한 줄이다 — 「[04] 까지 끝나 https:// 가 열린다.」

되돌리는 절차는 원본 가이드 05 에 없다(unknown). kubectl delete -f 도, 지우는 순서도 적혀 있지 않다. 이 단계가 클러스터에 남기는 것은 keycloak-lab 네임스페이스와 그 안의 전부이고, 그중 PVC 는 성격이 다르다 — StorageClass 가 local-path 라 데이터가 파드가 스케줄된 그 노드의 디스크에 놓인다. 네임스페이스를 지울 때 그 디렉터리가 어떻게 되는지는 가이드가 적지 않았고 이 실험대도 확인하지 않았다.

먼저 본다 — 바꾸기 전 상태

두 확인은 아무것도 바꾸지 않는다. 하나는 명령이 파일을 찾을 수 있는 곳인지 보고, 하나는 이 이름이 아직 Keycloak 이 아님을 확인한다.

확인 ① 지금 이 디렉터리에서 매니페스트가 보이는가

cd ~/workspace/keycloak-pattern
ls deploy/lab/k8s/

어디를 봐야 하는가keycloak-cluster.yamlobservability.yaml 이 보이는가.

이 결과가 의미하는 것 — 보이면 아래 kubectl apply -f deploy/... 가 파일을 찾는다. 안 보이면 [00] 의 0번으로 돌아간다 — 한 번 세웠다 철거한 뒤에는 VM 과 디스크만 지워지고 저장소는 각자 관리라 이 디렉터리가 없는 상태가 흔하다.

확인 ② 이 이름이 아직 Keycloak 이 아니다

[04] 의 확인 ① 을 그대로 다시 친다.

curl -s -o /dev/null -w '%{http_code} tls=%{ssl_verify_result}\n' --resolve auth.hyeonworks.com:443:192.168.122.10 \
  https://auth.hyeonworks.com/

어디를 봐야 하는가 — 04 에서 잰 404 tls=0 이 그대로인가. 앞의 코드를 같이 본다.

tls=0 만 보면 안 된다. 연결이 아예 안 됐을 때도 tls=0 이 나온다 — %{ssl_verify_result} 는 TLS 검증까지 갔을 때만 뜻이 있고 못 갔으면 초기값 0 이 그대로 찍힌다. 2026-09-17 에 04 를 건너뛴 상태에서 재 보니 이렇게 나왔다(observed).

000 tls=0

000 은 응답이 없었다는 뜻이다. 판정은 코드가 404 인 것과 tls=0 이 같이 나오는 것으로 한다. 000 tls=0 이면 04 가 아직 안 됐거나 밖에서 들어오는 길이 막힌 것이라 05 를 시작할 때가 아니다.

이 결과가 의미하는 것 — 가이드 04 가 이 값 옆에 한 줄을 적어 두었다 — 「앞의 404 는 05 이후에 200 으로 바뀐다.」 지금 재 두면 아래 5번의 200 이 이 단계가 만든 변화인지가 분명해진다.

실행 절차

번호는 가이드 05 의 것을 그대로 쓴다. 3번(안 뜰 때)은 아래 「막히면」으로, 4·5 번은 「끝났는지 판정한다」로 옮겼다.

1. 매니페스트를 적용한다[lab host], 저장소 루트에서

목적 — 네임스페이스부터 Ingress 까지 한 파일로 세운다.

cd ~/workspace/keycloak-pattern
kubectl apply -f deploy/lab/k8s/keycloak-cluster.yaml

★ 네임스페이스를 따로 만들지 않는다. 매니페스트 첫 문서가 kind: Namespaceapply 가 같이 만든다. kubectl create namespace 를 먼저 치면 두 번째 실행부터 AlreadyExists 로 막힌다.

확인 — 적용이 끝날 때까지 기다린다

kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=300s
partitioned roll out complete: 2 new pods have been updated...

어디를 봐야 하는가 — 마지막에 나오는 한 줄에서 complete 라는 낱말과 파드 개수 2 를 본다. 그 줄이 나올 때까지 명령이 안 끝나고 멈춰 있는 것이 정상이다.

★ 다만 아무것도 안 찍고 멈춰 있지는 않다. 처음에는 「끝날 때까지 아무것도 안 찍는다」고 적었는데, 2026-09-17 에 갓 세운 클러스터에서 재 보니 기다리는 동안 줄이 먼저 나왔다(observed).

Waiting for 2 pods to be ready...
Waiting for 1 pods to be ready...
partitioned roll out complete: 2 new pods have been updated...

Waiting for … 줄은 진행 상황이지 오류가 아니다. 숫자가 줄어들수록 파드가 하나씩 Ready 가 된다. 300초를 다 쓰고 타임아웃으로 끝나면 그것도 답이다 — 「안 떴다」가 확정된 것이니 아래 「막히면」으로 간다.

이 결과가 의미하는 것complete 면 두 파드가 다 Ready 가 됐다는 뜻이라 2번의 층별 확인으로 넘어간다. rollout status 를 쓰는 이유는 get pods 를 반복해서 치는 것보다 나아서만이 아니라, 언제 끝났는지를 사람이 판정하지 않아도 되기 때문이다. StatefulSet 은 파드를 하나씩 순서대로 띄우므로 중간에 0/2 로 한참 멈춰 있는 것은 정상이다.

2. 리소스가 제대로 만들어졌는지 — 층별로 본다

kubectl get pods 만 보면 놓치는 것이 많다. 위에서 아래로 확인한다.

2-1. 무엇이 만들어졌나

확인 — 이 네임스페이스에 무엇이 서 있는가

kubectl -n keycloak-lab get all

어디를 봐야 하는가 — 종류별로 묶여 나오는 왼쪽 이름 열을 훑고, 파드 줄에서는 READY 칸의 1/1RESTARTS 칸을 본다. RESTARTS 가 0 이 아니면 지금은 Running 이어도 한 번 죽었다 살아난 것이라, 아래 logs --previous 를 볼 이유가 된다.

이 결과가 의미하는 것 — 여기 보이는 것이 워크로드의 전부다. 그런데 all 은 이름과 달리 전부는 아니다 — Secret·ConfigMap·PVC·Ingress 는 안 나온다. 이 넷이 빠졌다는 사실을 모르고 「다 만들어졌다」고 판정하는 것이 흔한 오독이라, 한 번 더 친다.

kubectl -n keycloak-lab get secret,configmap,pvc,ingress

어디를 봐야 하는가 — 네 종류가 하나씩이라도 있는가. PVC 줄의 STATUS 는 Bound 인가, Ingress 줄의 HOSTS 칸이 auth.hyeonworks.com 인가.

이 결과가 의미하는 것 — 매니페스트에 있는데 여기 없는 종류가 있다면 apply 가 부분적으로만 먹었다. Ingress 의 호스트 이름이 04 에서 발급한 인증서의 이름과 다르면, 밖에서는 TLS 는 되는데 404 가 나온다.

2-2. Deployment → ReplicaSet → Pod 사슬

Deployment 는 파드를 직접 만들지 않는다. ReplicaSet 을 만들고 그것이 파드를 만든다. 이 사슬 어디서 끊겼는지가 진단의 출발이다. 이 단계에서 Deployment 는 postgres 하나뿐이다 — Keycloak 은 StatefulSet 이라 이 사슬을 타지 않는다.

확인 — 사슬 어디까지 갔는가

kubectl -n keycloak-lab get rs,pod -l app=postgres

실측(observed) — 2026-09-11

NAME                                  DESIRED   CURRENT   READY   AGE
replicaset.apps/postgres-7b474b88c8   1         1         1       80m

NAME                            READY   STATUS    RESTARTS   AGE
pod/postgres-7b474b88c8-ll87p   1/1     Running   0          80m

어디를 봐야 하는가 — ReplicaSet 이름의 해시(7b474b88c8)가 파드 이름 가운데 해시와 같은가. 그리고 DESIRED·CURRENT·READY 세 숫자가 다 1 인가.

-l app=postgres 에 Deployment 줄이 안 나오는 것이 정상이다. 이 매니페스트는 app: postgres 라벨을 파드 템플릿에만 달았고 Deployment 객체 자신에는 안 달았다. ReplicaSet 과 파드는 템플릿에서 라벨을 물려받지만 Deployment 는 아니다. Deployment 를 보려면 라벨 없이 친다.

kubectl -n keycloak-lab get deploy

★ StatefulSet 은 ReplicaSet 을 만들지 않는다. 파드를 직접 만든다. 그래서 keycloak-0·keycloak-1 처럼 이름이 고정이고, 원본 가이드 05 는 그 성질이 A-4 에서 어떻게 나타나는지를 덧붙인다 — Terminating 파드가 안 지워지면 대체 파드가 안 생긴다. 이름이 고정이라 같은 이름의 파드가 둘일 수 없기 때문이다 (observed — 가이드가 적은 것이고 이 실험대에서 재현해 보지는 않았다). A-4 는 source/ 에 반입되지 않은 실험 문서다(unknown).

kubectl -n keycloak-lab get sts,rs,pod -l app=keycloak
NAME             READY   STATUS    RESTARTS   AGE
pod/keycloak-0   1/1     Running   0          19m
pod/keycloak-1   1/1     Running   0          19m

ReplicaSet 줄이 하나도 없다. keycloak-0 처럼 순번 이름이 붙는 것도 이 때문이다 — 해시를 끼워 넣을 중간 객체가 없다.

이 결과가 의미하는 것 — 배포를 여러 번 한 Deployment 는 ReplicaSet 이 여러 개 쌓인다. 새로 만들고 옛것은 0 으로 남기기 때문이고, 그래서 kubectl rollout undo 가 가능하다. 파드가 옛 해시를 달고 있으면 새 배포가 아직 안 넘어온 것이고, 그 상태로 실험하면 고친 적 없는 코드를 재게 된다. 사슬이 어디서 끊겼는지는 이렇게 읽는다.

보이는 것
Deployment 는 있는데 RS 가 없다 컨트롤러가 못 돌았다 — RBAC·admission 확인
RS 는 있는데 DESIRED 만 있고 CURRENT 가 0 파드를 못 만든다 — 이벤트를 본다
Pod 은 있는데 0/1 컨테이너가 안 뜬다 — 로그와 describe

2-3. Secret 이 실제로 들어갔나 — 세 층으로 본다

값이 있는 것과 파드가 그 값을 받은 것은 다르다. 세 확인 모두 값을 찍지 않고 길이만 본다.

확인 ① Secret 에 무슨 키가, 얼마만큼 들어 있나

kubectl -n keycloak-lab describe secret keycloak-lab-secrets

실측(observed) — 아래쪽 Data 절만 옮겼다

Data
====
KC_BOOTSTRAP_ADMIN_PASSWORD:  19 bytes
POSTGRES_PASSWORD:            22 bytes

어디를 봐야 하는가Data 절의 키 이름과 그 옆의 바이트 수 두 칸. describe 는 값을 절대 찍지 않고 길이만 보여 준다 — 그래서 키 목록 확인과 「비어 있지 않은가」 확인이 한 명령으로 끝난다. 0 bytes 인 키가 있으면 그 키에 아무것도 안 들어갔다.

이 결과가 의미하는 것 — 매니페스트가 기대하는 키 이름이 여기 그대로 있어야 한다. 이름이 하나라도 다르면 파드는 CreateContainerConfigError 로 멈추고, 이유는 describe pod 의 Events 에 키 이름까지 적혀 나온다. 바이트 수가 뜻밖에 크면(예: 20 이어야 할 것이 21) echo 로 만들면서 개행이 같이 들어간 경우다 — 흔한 사고이고, 증상은 「비밀번호가 틀렸다」로 나온다.

-o yaml 로 보지 않는다. base64 는 암호화가 아니라 인코딩이라 화면·스크롤백· 화면 공유·터미널 로그에 값이 그대로 찍힌다.

확인 ② 특정 키 하나를 따져 볼 때 — 길이만

describe 가 보여 주는 바이트 수는 base64 를 푼 뒤의 길이다. 어떤 키 하나가 의심스러워 다시 잴 때만 이 형태를 쓴다.

kubectl -n keycloak-lab get secret keycloak-lab-secrets \
  -o jsonpath='{.data.POSTGRES_PASSWORD}' | base64 -d | wc -c
22

어디를 봐야 하는가 — 숫자 하나. 그리고 그것이 확인 ①의 22 bytes 와 같은가.

이 결과가 의미하는 것 — 두 값이 같으면 Secret 쪽은 더 볼 것이 없다. base64: invalid input 이 나오면 키 이름을 잘못 썼다(없는 키는 빈 문자열로 나온다). 여기까지는 Secret 안에 무엇이 있나이고, 파드가 그것을 받았는지는 아직 모른다.

확인 ③ 파드 안에 주입됐나 — 여기가 진짜다

kubectl -n keycloak-lab exec keycloak-0 -- \
  sh -c 'echo "길이=${#KC_BOOTSTRAP_ADMIN_PASSWORD}"'
길이=19

어디를 봐야 하는가 — 숫자 하나. ${#VAR} 는 값이 아니라 글자 수만 내놓는다. 이것이 확인 ①의 19 bytes 와 같은가.

이 결과가 의미하는 것 — 같으면 Secret → 파드 환경변수까지 이어졌다. 길이=0 이면 Secret 에는 있는데 이 파드가 그것을 안 받은 것이다 — envFrom/valueFrom 을 빠뜨렸거나, 파드가 Secret 을 고치기 전에 떠서 옛 값을 들고 있는 경우다(환경변수로 주입한 Secret 은 값을 바꿔도 파드를 다시 만들기 전까지 갱신되지 않는다).

어느 환경변수가 어느 Secret 에서 왔는지도 볼 수 있다.

kubectl -n keycloak-lab get pod keycloak-0 \
  -o jsonpath='{range .spec.containers[0].env[*]}{.name}{"\t"}{.valueFrom.secretKeyRef.name}{"\n"}{end}'
KC_DB
KC_DB_URL
KC_DB_USERNAME
KC_DB_PASSWORD	keycloak-lab-secrets     ← Secret 에서 온 것만 오른쪽에 이름이 있다

어디를 봐야 하는가오른쪽 칸이 채워진 줄만. 왼쪽만 있는 줄은 매니페스트에 값이 그대로 적힌 것이고, 오른쪽에 이름이 있는 줄이 Secret 을 참조한다.

이 결과가 의미하는 것 — 비밀이어야 할 변수의 오른쪽이 비어 있으면 그 값은 매니페스트에 평문으로 적혀 있다는 뜻이고, 그 파일은 대개 git 에 들어간다. 여기서는 KC_DB_PASSWORD 만 Secret 에서 온다.

2-4. Service 가 파드를 잡고 있나 — Endpoints

Service 가 있어도 셀렉터가 안 맞으면 뒤가 비어 있다. 이때 증상은 「연결은 되는데 응답이 없다」라 원인을 찾기 어렵다.

확인 — 실무자가 가장 자주 쓰는 형태

kubectl -n keycloak-lab describe svc keycloak | grep -i endpoints
Endpoints:   10.42.0.67:8080,10.42.1.155:8080

어디를 봐야 하는가 — 쉼표로 갈린 주소가 몇 개인가, 그리고 그 IP 들이 kubectl -n keycloak-lab get pods -o wide 의 파드 IP 와 같은가. 포트 번호가 컨테이너가 실제로 듣는 포트인가도 함께 본다.

이 결과가 의미하는 것 — 두 개면 Service 가 두 파드를 다 잡고 있다. 비어 있으면 Service 는 있는데 뒤가 없는 것이고, 이때 증상은 「연결은 되는데 응답이 없다」라 원인이 Service 에 있다는 것이 잘 안 보인다. 하나뿐이면 나머지 한 파드가 readiness 를 통과하지 못한 것이라, 그 상태로 이중화 실험을 하면 이미 한쪽으로만 가고 있던 트래픽을 이중화 실패로 오독하게 된다.

목록으로 보려면 EndpointSlice 를 쓴다.

kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak
NAME             ADDRESSTYPE   PORTS   ENDPOINTS                AGE
keycloak-xdph6   IPv4          8080    10.42.0.67,10.42.1.155   3d23h

kubectl get endpoints 는 쓰지 않는다. v1.33 부터 deprecated 이고 실행하면 경고가 나온다 — 이 실험대의 k3s 는 v1.36.4+k3s1 이라 해당한다.

Warning: v1 Endpoints is deprecated in v1.33+; use discovery.k8s.io/v1 EndpointSlice

준비 상태까지 함께 보려면 이렇게 뽑는다.

kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak \
  -o jsonpath='{range .items[*].endpoints[*]}{.addresses[0]}{"\t"}{.conditions.ready}{"\n"}{end}'
10.42.0.67     true
10.42.1.155    true

어디를 봐야 하는가 — 오른쪽 칸이 두 줄 다 true 인가. 여기서만 값을 뽑는 형태를 쓰는 이유는, 이 두 칸이 실험 전후로 반복해서 비교할 값이기 때문이다. 처음 볼 때는 위의 describe svc 로 충분하다.

이 결과가 의미하는 것readyfalse 면 파드는 있는데 readiness 프로브를 통과하지 못한 것이라, Service 가 그 파드로 트래픽을 보내지 않는다. 파드 목록에서는 Running 으로 보이므로 get pods 만 봐서는 알 수 없다 — 0/1 인지 1/1 인지가 같은 사실을 말해 준다. 비어 있으면 셀렉터와 파드 라벨이 안 맞는다.

kubectl -n keycloak-lab get svc keycloak -o jsonpath='{.spec.selector}'; echo
kubectl -n keycloak-lab get pods --show-labels

2-5. PVC 가 실제로 붙었나

확인 — 볼륨이 실제로 잡혔는가

kubectl -n keycloak-lab get pvc

어디를 봐야 하는가 — STATUS 칸(Bound/Pending)과 VOLUME 칸(비어 있는지), 그리고 STORAGECLASS 칸이 local-path 인가.

★ 이 실험대에는 파드 번호가 붙은 PVC 가 없다. 처음에는 「StatefulSet 이면 PVC 이름 끝에 파드 번호가 붙어 있어 어느 파드 것인지 바로 보인다」고 적었는데, 그것은 volumeClaimTemplates 를 쓰는 StatefulSet 의 이야기이고 이 매니페스트의 keycloak StatefulSet 에는 그 칸이 없다. PVC 는 PostgreSQL 이 쓰는 postgres-data 하나뿐이고 그것도 StatefulSet 이 아니라 별도 문서로 선언되어 Deployment 가 claimName 으로 가리킨다. 2026-09-17 에 매니페스트와 클러스터 양쪽에서 확인했다(observed).

NAME            STATUS   VOLUME                                     CAPACITY   STORAGECLASS
postgres-data   Bound    pvc-fe269834-3dd1-4b1f-82f6-a53413310a30   5Gi        local-path

그래서 Keycloak 파드는 볼륨을 안 갖는다. 세션이 어디 사는지를 묻는 이 실험대의 질문에서 그것이 중요하다 — 파드가 죽으면 그 안의 것은 사라지고 postgres-data 만 남는다.

이 결과가 의미하는 것Bound 면 볼륨이 붙었다. Pending 이면 StorageClass 가 없거나 노드에 자리가 없다. local-path 는 파드가 스케줄될 때까지 기다린다(WaitForFirstConsumer)므로, 파드가 안 뜨면 PVC 도 Pending 인 것이 정상이다. 둘이 서로를 기다리는 것처럼 보이지만 파드 쪽 원인을 먼저 본다. 사유는 PVC 의 이벤트에 적혀 있다.

kubectl -n keycloak-lab describe pvc      # 이름을 안 주면 전부 나온다

각 PVC 절의 맨 아래 Eventswaiting for first consumer 인지 no persistent volumes available 인지가 적혀 있고, 둘은 대응이 다르다 — 앞엣것은 파드를 고치는 문제, 뒤엣것은 저장소를 고치는 문제다.

끝났는지 판정한다 — 클러스터가 형성됐는지를 셋으로 본다

가이드 05 의 4·5 번이 여기다. 셋이 다른 것을 본다 — 로그는 「그때 그렇게 보였다」이고, 테이블은 「지금 등록되어 있다」이며, 지표는 「지금 그 노드가 그렇게 안다」이다.

확인 ① 로그 — 그때 그렇게 보였다

kubectl -n keycloak-lab logs keycloak-0 | grep ISPN000094 | tail -1
ISPN000094: Received new cluster view for channel ISPN:
  [keycloak-0-10001|1] (2) [keycloak-0-10001, keycloak-1-52537]

어디를 봐야 하는가 — 세 군데다. 괄호 안의 (2) 가 멤버 수, 대괄호 안의 이름 목록, 그리고 |1뷰 번호(멤버가 들고 날 때마다 올라간다). tail -1 을 붙였으므로 지금 보고 있는 것은 가장 최근 뷰 하나다.

★ 이름 뒤에 (v=…) 가 붙어 나오기도 한다. 2026-09-17 에 새로 세운 클러스터에서 같은 줄을 뽑아 보니 이랬다(observed).

[keycloak-1-3159(v=16.0.12)|1] (2) [keycloak-1-3159(v=16.0.12), keycloak-0-13057(v=16.0.12)]

(v=16.0.12) 는 JGroups 가 붙인 판 번호이고, 볼 것은 그대로 (2) 와 이름 목록과 |1 이다. 맨 앞의 이름이 keycloak-0 이 아니어도 정상이다 — 맨 앞에는 코디네이터가 오고 먼저 뜬 쪽이 그 일을 맡는다. 이 판에서는 keycloak-1 이었다.

이 결과가 의미하는 것(2) 면 이 노드는 상대를 봤다. (1) 이면 혼자 있다고 알고 있다. 뷰 번호가 계속 오르고 있으면 멤버가 붙었다 떨어지기를 반복하는 중이라, 「지금 2 다」라는 스냅숏보다 그 사실이 더 중요하다. 이 줄은 과거형이다 — 지금 상태는 확인 ③ 에서 본다. grep 이 아무것도 못 찾으면 클러스터링을 아직 시작도 못 한 것이니 로그를 통째로 본다.

확인 ② 디스커버리 테이블 — 지금 등록되어 있다

kubectl -n keycloak-lab exec deploy/postgres -- \
  psql -U keycloak -d keycloak -c 'select name, ip from jgroups_ping'

어디를 봐야 하는가 — 나온 행이 몇 개인가, 그리고 ip 칸이 2-4 에서 본 파드 IP 와 같은가. 끝의 (N rows) 한 줄이 개수를 말해 준다.

이 결과가 의미하는 것 — 이 표는 「등록되어 있다」이지 「서로 말이 통한다」가 아니다. 두 행이 다 있는데 확인 ①이 (1) 이면, 서로를 찾기는 했는데 7800 포트로 메시지가 안 가는 것이다 — 이 실험대에서 실제로 그 일이 벌어졌다(observed). 옛 파드의 행이 남아 있을 수도 있으므로 IP 를 지금 파드와 대조한다.

확인 ③ 지표 — 지금 그 노드가 그렇게 안다

★ Keycloak 컨테이너에는 curl 이 없다(observed). 공식 이미지가 최소 구성이라 wgetnc 도 없다. 안에서 치면 이렇게 된다.

sh: line 1: curl: command not found
command terminated with exit code 127

그래서 밖에서 물어본다. Prometheus 에 묻는 것이 가장 짧다([06] 이 그것을 세운다).

kubectl -n observability exec deploy/prometheus -- \
  wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size'

어디를 봐야 하는가 — 응답은 줄바꿈 없는 JSON 한 줄로 온다. data.result 배열에서 원소가 몇 개인가(노드 수), 각 원소에서 두 군데만 읽는다 — "metric" 안의 node 라벨(어느 Keycloak 인가)과 "value" 배열의 둘째 원소(따옴표에 싸인 값). 이 실험대에는 jq 가 없다. 파서를 따로 짜지 말고 화면에 나온 JSON 을 그대로 읽는다.

실측(observed) — 그렇게 읽어낸 값이다

keycloak-1 → 2
keycloak-0 → 2

이 결과가 의미하는 것 — 원소가 둘이고 값이 둘 다 2 면 두 노드가 서로를 보고 있다. 원소가 하나뿐이면 나머지 노드의 스크레이프가 실패한 것이라 클러스터 문제가 아니라 관측 문제일 수 있다 — [06] 의 targets 를 본다. 그리고 각 노드가 자기가 아는 멤버 수를 보고하므로 한 노드만 보면 분단을 놓친다. 분단되면 한쪽은 2, 다른 쪽은 1 이 된다.

Prometheus 가 아직 없다면 임시 파드를 띄운다.

K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
kubectl -n keycloak-lab run m --rm -i --restart=Never \
  --image=curlimages/curl:8.11.1 --quiet --command -- \
  sh -c "curl -s http://$K0:9000/metrics | grep '^vendor_cluster_size'"
vendor_cluster_size{cache_manager="keycloak",node="keycloak-0-46674"} 2.0

어디를 봐야 하는가 — 줄 끝의 숫자와, 중괄호 안 node= 라벨이 어느 파드인가. 이 형태는 그 파드 하나에게 직접 물은 것이라 라벨의 노드 이름과 $K0 로 고른 파드가 반드시 일치한다.

이 결과가 의미하는 것--rm 을 붙였으므로 파드는 끝나면 사라진다. curlimages/curl 을 쓰는 이유는 Keycloak 이미지에 도구가 없기 때문이고, 같은 이유로 이 방법은 Keycloak 뿐 아니라 최소 이미지 전부에 쓴다. 값이 안 나오고 연결 거부가 나면 9000(관리 포트)이 안 열렸다.

확인 ④ 밖에서 닿나 — 2홉을 다 지나 파드까지

curl -s -o /dev/null -w '%{http_code}\n' --resolve auth.hyeonworks.com:443:192.168.122.10 \
  https://auth.hyeonworks.com/realms/master
200

어디를 봐야 하는가 — 코드 한 칸. 여기서 값만 뽑는 형태를 쓰는 것은 03·04 에서 잰 것과 같은 명령으로 같은 값이 나오는지 비교하는 것이 목적이기 때문이다. 이 자리에서 처음 보는 형태가 아니다.

이 결과가 의미하는 것200 이면 nginx → Traefik → Ingress → Service → 파드가 전부 이어졌다. 502·503 이면 뒤에서부터 되짚는다 — Ingress 가 있는지(2-1), Service 뒤에 파드가 있는지(2-4), 파드가 Ready 인지(2-2) 순서다. 처음 보는 오류라 헤더가 필요하면 값만 뽑는 형태를 버리고 읽는 형태로 바꾼다.

curl -I --resolve auth.hyeonworks.com:443:192.168.122.10 https://auth.hyeonworks.com/realms/master

★ 이 200 은 04 를 끝냈을 때의 값이다. 04 를 건너뛰고 05 만 했다면 443 을 듣는 것이 없으므로 여기는 000 이다. 2026-09-17 에 그 상태에서 재 보니 이렇게 나왔다(observed).

curl: (7) Failed to connect to auth.hyeonworks.com:443 after 2 ms: Could not connect to server

그때도 05 가 세운 것까지는 잴 수 있다. TLS 를 빼고 Host 헤더를 실어 80 으로 치면 nginx → Traefik → Ingress → Service → 파드가 이어졌는지가 그대로 나온다. 둘 다 200 이었다(observed).

curl -s -o /dev/null -w '%{http_code}\n' -H 'Host: auth.hyeonworks.com' http://192.168.122.10/realms/master
curl -s -o /dev/null -w '%{http_code}\n' -H 'Host: auth.hyeonworks.com' http://192.168.122.11/realms/master

앞엣것은 엣지 nginx 를 지나고 뒤엣것은 Traefik 에 바로 친다. 둘이 같으면 남은 것은 TLS 뿐이다.

확인 ⑤ 관리 콘솔에 로그인된다

브라우저로 https://auth.hyeonworks.com/admin 에 들어가 관리자로 로그인한다. 비밀번호는 위 2-3 의 Secret 에 있다. 이 문서는 그 값을 적지 않는다 — 확인 ①~③ 에서 보듯 이 실험대의 확인은 전부 길이까지만 본다.

네 칸으로 다시 본다.

무엇 명령 통과
롤아웃 kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=300s complete · 파드 2
Service 뒤 kubectl -n keycloak-lab describe svc keycloak | grep -i endpoints 주소 두 개
클러스터 … query=vendor_cluster_size 원소 둘 · 값 둘 다 2 — healthup 이 된 뒤에 잰다
밖에서 curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master 200

근거를 재려면 (선택) — 세션이 실제로 어디 저장되는지는 DB 를 직접 본다.

kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "select offline_flag, count(*) from offline_user_session group by 1"

어디를 봐야 하는가offline_flag0 인 행의 count. 로그인 전과 후에 두 번 재서 그 수의 차이를 본다. 한 번만 재면 아무것도 알 수 없다. 행이 아예 없으면(0 rows) 표는 있는데 비어 있다.

이 결과가 의미하는 것 — 로그인 뒤 수가 늘면 세션이 DB 에 남는 것(persistent-user-sessions 켜짐)이고, 안 늘면 메모리에만 있다. 메모리에만 있으면 파드를 재시작하는 순간 세션이 사라지고, DB 에 있으면 살아남는다.

막히면

가이드 05 의 3번이 순서를 정해 두었다. 로그부터 보지 않는다.

① 이벤트부터. 스케줄링·이미지·볼륨 실패가 여기 나온다.

kubectl -n keycloak-lab get events --sort-by=.lastTimestamp | tail -20

어디를 봐야 하는가 — 맨 아랫줄부터 거꾸로 읽는다. --sort-by 를 준 이유가 그것이다. TYPE 이 Warning 인 줄, REASON 칸(FailedScheduling·Failed·BackOff), 그리고 OBJECT 칸이 어느 파드인가. 이벤트는 기본 한 시간만 남는다 — 아무것도 없으면 「문제가 없다」가 아니라 「이미 지워졌다」일 수 있다.

이 결과가 의미하는 것 — REASON 하나가 다음 행동을 정한다. FailedScheduling 은 노드에 자리가 없는 것이라 파드가 아니라 클러스터를 봐야 하고, ErrImagePull 은 이미지 이름 문제라 로그를 볼 것도 없다. BackOff 는 컨테이너가 떴다가 죽는 중이라는 뜻이라 ③으로 간다.

② describe. 그 파드에 한정된 이벤트와 상태를 함께 본다.

kubectl -n keycloak-lab describe pod keycloak-0

어디를 봐야 하는가 — 위에서부터 세 곳이다. Conditions 절에서 ReadyFalse 인가, 컨테이너 절의 State/Last State 와 그 안의 Exit Code, 그리고 맨 아래 Events. Exit Code 는 그것만으로 말이 된다 — 137 은 OOM 이나 강제 종료, 1 은 애플리케이션이 스스로 끝낸 것, 127 은 명령을 못 찾은 것이다.

이 결과가 의미하는 것 — Exit Code 가 137 이면 로그에는 아무 단서도 없을 수 있다(맞아 죽은 쪽은 유언을 못 남긴다) — 메모리 한도를 본다. ReadyFalse 이고 컨테이너는 살아 있으면 readiness 프로브 문제이므로 2-4 로 돌아간다.

③ 로그. 컨테이너가 떴는데 죽는 경우다.

kubectl -n keycloak-lab logs keycloak-0
kubectl -n keycloak-lab logs keycloak-0 --previous    # 재시작 직전 로그

어디를 봐야 하는가 — 첫 명령에서는 마지막 줄들, 둘째 명령에서는 스택 트레이스의 맨 윗줄(가장 안쪽 예외가 아니라 최초 원인 줄)이다. Keycloak 은 기동에 성공하면 Keycloak ... started in 한 줄을 남기므로, 그 줄이 있는지 없는지가 「기동 중」과 「기동 실패」를 가른다.

이 결과가 의미하는 것--previous 가 중요하다. CrashLoopBackOff 면 지금 컨테이너는 방금 뜬 것이라 죽은 이유는 이전 컨테이너 로그에 있다. --previousnot found 를 내면 아직 한 번도 재시작하지 않은 것이고, 그러면 지금 로그가 곧 전부다.

④ 그래도 모르면 안에서 본다.

kubectl -n keycloak-lab exec -it keycloak-0 -- sh
증상 어디를 보나
파드가 Pending describe pod 의 Events — 스케줄 불가 사유
ImagePullBackOff 이미지 이름·태그. 자체 빌드면 두 노드 모두에 반입했는가
CrashLoopBackOff logs --previous
Running 인데 0/1 readiness 프로브 실패. describe 의 Conditions
밖에서 502 Ingress → Service → Endpoints 순으로 뒤를 본다
클러스터가 1로 보임 7800 이 막혔거나 디스커버리 실패. 위 확인 ①~③ 셋 다
AlreadyExists kubectl create namespace 를 먼저 쳤다. 매니페스트가 만든다
error: the path ... does not exist 저장소 루트가 아닌 데서 쳤다

이 절차가 성립하는 범위

  • (observed) 2026-09-11 의 ReplicaSet·파드 이름과 해시, Secret 의 19·22 bytes 와 주입된 길이 19·복호 길이 22, Endpoints 두 개와 EndpointSlice ready 두 줄, ISPN000094 뷰 줄, vendor_cluster_size 2·2, 임시 파드가 읽은 vendor_cluster_size … 2.0, Keycloak 이미지에 curl 이 없다는 것과 exit code 127
  • (observed) 디스커버리 테이블에는 둘 다 있는데 메시지가 안 가는 상태를 실제로 겪었다
  • (external) kubectl get endpoints 의 v1.33 deprecation 은 쿠버네티스 쪽 규격이고 이 실험대가 잰 값이 아니다
  • (observed) 2026-09-17 에 이 절차를 갓 세운 클러스터에서 처음부터 다시 밟았다. 같은 값이 다시 나온 것 — postgres-7b474b88c8 이라는 ReplicaSet 해시, Secret 의 19·22 bytes 와 주입된 길이 19·복호 길이 22, Endpoints 두 개와 ready 두 줄, jgroups_ping 2 행, 임시 파드가 읽은 vendor_cluster_size … 2.0, Keycloak 이미지에 curl 이 없다는 것과 exit code 127, kubectl get endpoints 의 deprecation 경고. 파드 IP 와 Infinispan 접미사만 달랐다 — 그것들은 매번 새로 붙는다
  • (observed) keycloak-cluster.yaml 원문을 keycloak-pattern @ 9465582b 에서 읽었다. 이미지 태그는 postgres:16-alpinequay.io/keycloak/keycloak:26.7.0 이다
  • (unknown) 파드 자원 한도, 프로브 설정, persistent-user-sessions 설정값은 아직 이 문서로 옮기지 않았다
  • (unknown) get all·get deploy·get pvc·describe pvc·jgroups_ping 조회· get events·describe pod·logs 의 출력은 가이드가 봐야 할 줄만 적었고 값이 남아 있지 않다
  • (unknown) 원본 가이드 05 에 되돌리는 절차가 없다. local-path PVC 가 노드 디스크에 남긴 데이터가 네임스페이스를 지울 때 어떻게 되는지도 확인하지 않았다
  • (unknown) §184 가 둔 두 갈래를 이 단계에서는 쓰지 않았다 — 05 는 파일을 직접 쓰지 않고 저장소의 매니페스트를 적용한다

192. 단계 06 — Prometheus 와 Grafana

어디서 치는가 — 전부 [lab host] 에서 치고, 1번은 저장소 루트에서 친다. 마지막 포트포워드만 예외다 — 그 터널은 명령을 친 기계에서만 열리므로, 브라우저로 볼 기계에서 쳐야 한다.

번호 무엇 어디서
1 매니페스트 적용 [lab host]~/workspace/keycloak-pattern 안에서
2 무엇을 긁고 있나 [lab host]
3 클러스터 상태 [lab host]
4 up 을 믿지 않는다 [lab host]
5 Grafana 포트포워드 브라우저로 볼 그 기계

이 단계가 세우는 것 — 가이드 06 의 「이 단계가 끝나면」은 두 줄이다.

Prometheus 가 Keycloak 을 긁고, vendor_cluster_size 로 클러스터 상태를 밖에서 볼 수 있다.

왜 두는가(observed) — 판정을 밖에서만 하면 놓친다. 7800 을 끊었는데 외부 응답이 전부 200 이었고, 분단된 노드가 스스로 로드밸런서에서 빠졌기 때문이다. 클러스터 안을 보는 눈이 따로 있어야 한다.

무엇
네임스페이스 observability
파드 grafana 1 · prometheus 1 · node-exporter 2(DaemonSet, 노드마다 하나)
스크레이프 대상 keycloak · kubelet · node-exporter · prometheus
Prometheus API 파드 안 localhost:9090/api/v1/targets · /api/v1/query
Grafana 밖에 열지 않고 port-forward svc/grafana 3000:3000
도구 jq 가 없다. grep -o · tr ',' '\n' 로 필드만 뽑는다

판 번호는 여기 없다. 가이드 06 은 Prometheus 와 Grafana 의 이미지 태그를 적지 않았고 observability.yaml 원문은 source/ 에 반입되지 않았다. 파드 이름의 해시 (grafana-845b5678cf-b6gvc·prometheus-6774f94f7c-pzr2t)는 판 번호가 아니다(unknown).

전제와 되돌리기

전제는 한 줄이다 — 「[05] 가 끝나 Keycloak 두 노드가 떴다.」

되돌리는 절차는 원본 가이드 06 에 없다(unknown). 이 단계가 남기는 것은 observability 네임스페이스와 그 안의 전부이고, node-exporter 는 DaemonSet 이라 노드마다 하나씩 붙어 있다. 지우는 순서도 지운 뒤의 확인도 가이드에 없다.

먼저 본다 — 바꾸기 전 상태

이 단계가 없으면 같은 질문에 어떻게 답하게 되는지를 먼저 본다. [05] 의 확인 ③ 이 이미 한 번 보여 주었다 — Prometheus 가 없으면 클러스터 크기를 물을 때마다 임시 파드를 띄운다.

K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
kubectl -n keycloak-lab run m --rm -i --restart=Never \
  --image=curlimages/curl:8.11.1 --quiet --command -- \
  sh -c "curl -s http://$K0:9000/metrics | grep '^vendor_cluster_size'"

어디를 봐야 하는가 — 이 명령이 물을 수 있는 대상은 $K0 로 고른 파드 하나뿐이다. keycloak-1 을 보려면 IP 를 다시 잡아 한 번 더 친다.

이 결과가 의미하는 것 — 한 노드만 보면 분단을 놓친다. 두 노드를 매번 따로 물어야 하고, 값을 나란히 놓아 비교하기도 어렵다. 이 단계는 그 질문을 한 번에 받아 준다.

실행 절차

1. 매니페스트를 적용한다[lab host], 저장소 루트에서

목적 — Prometheus · Grafana · node-exporter 를 observability 네임스페이스에 세운다.

cd ~/workspace/keycloak-pattern
kubectl apply -f deploy/lab/k8s/observability.yaml
kubectl -n observability rollout status deploy/prometheus --timeout=180s

확인 — 무엇이 몇 개 떴는가

kubectl -n observability get pods

실측(observed)

grafana-845b5678cf-b6gvc       1/1   Running
node-exporter-9qk9w            1/1   Running
node-exporter-c2mz4            1/1   Running
prometheus-6774f94f7c-pzr2t    1/1   Running

어디를 봐야 하는가줄이 네 개인가, 그리고 READY 칸이 전부 1/1 인가. 특히 node-exporter 로 시작하는 줄이 인지 센다.

이 결과가 의미하는 것 — node-exporter 가 둘인 것은 DaemonSet 이라 노드마다 하나씩 뜨기 때문이다. 하나뿐이면 노드 하나가 빠진 것이고, 그러면 그 노드의 CPU·메모리· 디스크 지표가 통째로 없는 채로 실험을 하게 된다 — 이때는 관측이 아니라 [02] 의 노드 상태부터 본다. 어느 노드에 붙었는지는 -o wide 로 확인한다.

kubectl -n observability get pods -o wide

끝났는지 판정한다

확인 ① 무엇을 긁고 있나 — 여기가 중요하다

kubectl -n observability exec deploy/prometheus -- \
  wget -qO- localhost:9090/api/v1/targets | grep -o '"job":"[^"]*"' | sort -u

실측(observed)

"job":"keycloak"
"job":"kubelet"
"job":"node-exporter"
"job":"prometheus"

어디를 봐야 하는가거기 있는 이름이 아니라 없는 이름이다. 응답은 JSON 한 덩어리이고 그대로는 못 읽는다. 이 실험대에는 jq 가 없으므로 grep -o 로 필요한 필드만 뽑고 sort -u 로 중복을 없앤 것이다 — 여기까지가 사람이 손으로 치는 선이고, 그 이상 가공해야 한다면 파서를 짜지 말고 화면에 나온 JSON 을 그대로 읽는다.

이 결과가 의미하는 것★ Redis · BFF · PostgreSQL 이 없다. 이 실험대는 그것들을 긁지 않는다. 그래서 어떤 실험에 Grafana 화면이 없는데, 안 찍은 것이 아니라 지표가 없는 것이다. 어떤 실험에서 지표를 못 찾으면 「측정이 실패했다」로 적기 전에 이 목록에 그 job 이 있었는지부터 본다. 가이드는 그것을 「스크린샷 누락」이 아니라 측정된 공백으로 기록했다.

확인 ② 목록에 있는데 값이 안 나올 때 — 상태를 본다

kubectl -n observability exec deploy/prometheus -- \
  wget -qO- localhost:9090/api/v1/targets | tr ',' '\n' | grep -E '"(job|health|lastError)"'

어디를 봐야 하는가"health":"up" 이 아닌 줄과, 그 바로 뒤에 붙는 lastError. tr ',' '\n' 으로 쉼표마다 줄을 나눴으므로 필드가 원래 순서대로 세로로 늘어선다 — job 줄 아래에 그 대상의 health 가 온다.

이 결과가 의미하는 것down 인 대상이 있으면 lastError 가 이유를 그대로 말해 준다(연결 거부·타임아웃·404). 확인 ③ 에서 값이 한 노드만 나오는 증상의 원인이 대개 여기 있고, 그때 클러스터가 아니라 스크레이프가 문제다.

down 말고 unknown 도 있다. 스택을 막 올린 직후에는 healthunknown 이고 lastError 는 빈 문자열이다 — 실패한 것이 아니라 아직 한 번도 안 긁은 것이다. 2026-09-17 에 rollout status 직후에 잡힌 모습이다(observed).

"job":"keycloak"
"lastError":""
"health":"unknown"

한 주기 뒤에 같은 줄이 "health":"up" 으로 바뀌었다. unknown 을 보고 권한이나 네트워크를 뒤지기 전에 한 번 더 친다.

확인 ③ 클러스터 상태 — 두 노드가 각각 몇 명을 보고 있는가

kubectl -n observability exec deploy/prometheus -- \
  wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size'

실측(observed) — 그렇게 읽어낸 값이다

keycloak-1 → 2
keycloak-0 → 2

어디를 봐야 하는가 — 응답은 줄바꿈 없는 JSON 한 줄이다. data.result 배열의 원소가 몇 개인가(보고하는 노드 수), 각 원소에서 두 군데만 읽는다 — 어느 Keycloak 파드가 보고한 것인지와 "value" 배열의 둘째 원소(따옴표에 싸인 값). jq 가 없으므로 눈으로 읽는다.

★ 어느 파드인지는 node 라벨이 아니라 pod 라벨이 말한다. 처음에는 「node 라벨을 읽는다」고 적었는데, Prometheus 를 거쳐 나온 응답에서 node쿠버네티스 노드 이름(kc-lab-1)이다. Keycloak 이 자기 지표에 붙인 node 는 스크레이프할 때 이름이 겹쳐 exported_node 로 밀린다. 2026-09-17 에 원소 하나를 그대로 옮기면 이렇다(observed).

"metric":{"__name__":"vendor_cluster_size","cache_manager":"keycloak",
 "exported_node":"keycloak-1-3159","instance":"10.42.0.9:9000","job":"keycloak",
 "node":"kc-lab-1","pod":"keycloak-1"},"value":[1789620021.097,"2"]

세 라벨이 각각 다른 것을 말한다 — pod 가 Keycloak 파드(keycloak-1), exported_node 가 Infinispan 멤버 식별자(keycloak-1-3159), node 가 그 파드가 떠 있는 쿠버네티스 노드(kc-lab-1)다. 앞의 확인에서 본 ISPN000094 의 이름과 견줄 것은 exported_node 이고, 어느 노드를 끊었을 때 무엇이 바뀌는지를 볼 때는 node 다.

★ 스택을 막 올린 직후에는 원소가 모자란다. 1번의 rollout status 가 성공한 직후에 이 질의를 치면 Keycloak 대상이 아직 한 번도 안 긁혀서 원소가 하나뿐이거나 아예 없다. 2026-09-17 에 38초 시점과 약 2분 시점에 같은 질의를 쳐서 확인했다(observed).

언제 확인 ② 의 keycloak health 이 질의의 원소
rollout status 직후(38초) "health":"unknown" 1 개
약 2분 뒤 "health":"up" 2 개 · 둘 다 2

unknown 은 실패가 아니라 아직 안 긁었다는 뜻이다. 원소가 모자라면 분단을 의심하기 전에 확인 ② 의 health 를 먼저 보고, unknown 이면 한 주기 기다렸다 다시 친다.

이 결과가 의미하는 것두 노드가 각각 자기가 아는 멤버 수를 보고한다. 둘 다 2 면 클러스터가 온전하다. 분단되면 한쪽은 2, 다른 쪽은 1 이 된다 — 한 노드만 보면 분단을 놓친다. 원소가 하나뿐이면 분단이 아니라 스크레이프 실패일 수 있으므로 확인 ② 의 health 를 먼저 본다. 값이 아예 안 나오면 "result":[] 로 빈 배열이 오는데, 이는 「0 이다」가 아니라 **「그런 지표가 없다」**는 뜻이다.

자주 보는 지표들이다.

지표 무엇
vendor_cluster_size 이 노드가 아는 멤버 수
vendor_jgroups_* JGroups 프로토콜별 카운터
vendor_statistics_approximate_entries_unique{cache="sessions"} 이 노드의 세션 캐시 엔트리 수
agroal_* JDBC 커넥션 풀
up 스크레이프 성공 여부

확인 ④ up 을 믿지 않는다(observed)

503 이 나는 동안에도 up 은 1 이었다. 프로세스가 살아 있고 /metrics 가 응답하기만 하면 1 이므로 「살아 있지만 쓸모없는」 상태를 보지 못한다.

kubectl -n observability exec deploy/prometheus -- \
  wget -qO- 'localhost:9090/api/v1/query?query=up'

어디를 봐야 하는가 — 원소마다 job 라벨과 값("1"/"0"). 값이 1 이라는 것은 마지막 스크레이프가 성공했다는 사실 하나만 말한다.

이 결과가 의미하는 것up=1 은 「프로세스가 살아 있고 /metrics 가 응답했다」이지 「그 서비스가 쓸모 있다」가 아니다. 그래서 경보를 up == 0 하나로 걸면 「살아 있지만 503」 상태를 통째로 놓친다. 기능 지표를 함께 본다 — 밖에서 실제 응답을 받아 보는 것이 가장 짧다.

curl -s -o /dev/null -w '%{http_code}\n' --resolve auth.hyeonworks.com:443:192.168.122.10 \
  https://auth.hyeonworks.com/realms/master

어디를 봐야 하는가 — 코드 한 칸. 여기서 값만 뽑는 형태를 쓰는 것은 up 의 1/0 과 나란히 놓고 비교하기 위해서다. 처음 보는 오류를 파고들 때는 curl -Icurl -v 로 바꾼다([04] 참조).

이 결과가 의미하는 것up=1 인데 이쪽이 200 이 아니면 그 조합이 곧 「살아 있지만 쓸모없는」 상태의 증거다. 그 두 값을 같이 기록해 두는 것을 판정 근거로 삼았다.

확인 ⑤ Grafana 를 볼 때 — 밖에 열지 않고 포트포워드로 본다

kubectl -n observability port-forward svc/grafana 3000:3000

어디를 봐야 하는가Forwarding from 127.0.0.1:3000 -> 3000 한 줄이 찍히고 명령이 그대로 멈춰 있는가. 이 명령은 끝나지 않는 것이 정상이라, 터미널 하나를 여기에 내준다. 브라우저를 열면 그 아래에 Handling connection 줄이 하나씩 붙는다 — 그것이 붙지 않으면 브라우저가 다른 곳을 보고 있다.

이 결과가 의미하는 것 — 이 터널은 명령을 실행한 기계에서만 열린다. 워크스테이션에서 쳤으면 워크스테이션 브라우저로 http://localhost:3000, lab host 에서 쳤으면 lab host 에서 봐야 한다. bind: address already in use 면 3000 을 이미 누가 쓰는 것이니 3001:3000 처럼 왼쪽만 바꾼다. Ctrl+C 로 끊으면 터널도 사라진다 — 밖에 포트를 여는 것이 아니라 보는 동안만 뚫는 것이라 실험대의 노출면이 늘지 않는다.

실험 중에는 Grafana 보다 Prometheus 쿼리 API 가 편하다. 값을 그대로 뽑아 비교할 수 있고 스크린샷보다 근거로 남기기 좋다.

네 칸으로 다시 본다.

무엇 명령 통과
파드 kubectl -n observability get pods 네 줄 · node-exporter 가 둘
대상 … /api/v1/targets | grep -o '"job":"[^"]*"' | sort -u keycloak 이 목록에 있다
상태 … /api/v1/targets | tr ',' '\n' | grep -E … "health":"up" 아닌 줄이 없다
클러스터 … query=vendor_cluster_size 원소 둘 · 값 둘 다 2

막히면

증상 원인 확인
Keycloak 지표가 안 보임 9000 이 안 열렸거나 스크레이프 설정 누락 위 확인 ① targets
값이 한 노드만 나옴 다른 노드 스크레이프 실패 targets 의 health 필드
컨테이너 안에서 curl 실패 Keycloak 이미지에 curl 이 없다 밖에서 Prometheus 로 묻는다
Grafana 에 데이터 없음 데이터소스 주소 오류 Prometheus 서비스 이름 확인
node-exporter 가 한 줄 노드 하나가 빠졌다 [02] 의 kubectl get nodes
"result":[] 그런 지표가 없다 — 0 이 아니다 확인 ① 에 그 job 이 있는가
bind: address already in use 3000 을 이미 누가 쓴다 3001:3000 처럼 왼쪽만 바꾼다

이 절차가 성립하는 범위

  • (observed) 2026-09-17 에 이 절차를 갓 세운 클러스터에서 다시 밟았다. 파드 네 줄과 job 네 개가 그대로 나왔고 ReplicaSet 해시 845b5678cf·6774f94f7c 까지 같았다. 달랐던 것 — rollout status 직후에는 Grafana 가 0/1 이었고 Keycloak 대상의 healthunknown 이라 vendor_cluster_size 의 원소가 하나뿐이었다. 약 2분 뒤에 둘 다 2 가 됐다
  • (observed) 파드 네 줄과 job 네 개, vendor_cluster_size 2·2, 503 중에도 up 이 1 이었던 것
  • (observed) Redis·BFF·PostgreSQL 이 스크레이프 대상에 없다는 것. 「안 찍은 것」이 아니라 「지표가 없는 것」이다
  • (unknown) observability.yaml 원문이 source/ 에 없어 Prometheus·Grafana· node-exporter 의 이미지 태그, 스크레이프 주기, 보존 기간, Grafana 대시보드 구성은 대조하지 못했다
  • (unknown) get pods -o wide·/api/v1/targetshealth 훑기·query=up· port-forward 의 출력은 가이드가 봐야 할 줄만 적었고 값이 남아 있지 않다
  • (unknown) 원본 가이드 06 에 되돌리는 절차가 없다. DaemonSet 이 노드마다 남긴 것을 걷어내 본 적이 없다
  • (unknown) §184 가 둔 두 갈래를 이 단계에서는 쓰지 않았다 — 06 은 파일을 직접 쓰지 않고 저장소의 매니페스트를 적용한다

193. 이 구축이 제1~4부의 어느 구조에 닿나

7단계가 만지는 것이 제1~4부의 어느 절에 적힌 구조인지를 적는다. 절 번호는 이 문서 안에서 매긴 번호다.

단계 무엇이 닿나 SSOT 절
00 vmx/svm 확장과 KVM 모듈 — 하드웨어 가상화가 켜져 있어야 게스트가 KVM 가속으로 돈다 §2 · §3 · §6 · §7
00 virsh·libvirt·QEMU 가 서로 무엇인지 §90 · §103
00 default 네트워크가 만드는 virbr0192.168.122.0/24 §96 · §98
01 vCPU 2·2·1 을 논리 코어 위에 얹는 것 §11 · §12 · §5
01 메모리 5120+4096+1024MB 를 11,648MiB 호스트에 배정한 것 §42 · §57 · §58
01 backing_store 오버레이와 Allocating ... 00:00 §142 · §143 · §181
01 시드와 디스크를 bus=virtio 로 붙인 것 §137 · §138
01 MAC 예약과 게스트 NIC §99 · §92
02 노드 두 대가 VM 이라는 것 자체 §18 · §16
02 flannel VXLAN 파드 네트워크가 게스트 TCP/IP 위에 얹히는 것 §94 · §101 · §102
02 local-path 가 노드 로컬 디스크를 쓰고 그 디스크가 qcow2 오버레이인 것 §131 · §142 · §159
03 호스트 DNAT · virbr0 · FORWARD 경로 §97 · §98 · §99 · §117 · §179 · §180
03 엣지에서 Traefik 까지 패킷이 올라가는 길 §102 · §116
04 엣지에서 tailnet 주소를 쳤을 때 virbr0 으로 들어가 DNAT 에 안 걸리는 것 §97 · §98
05 Keycloak 세션이 PostgreSQL 에 남는지 — write() 완료와 영속화가 다르다 §148 · §150 · §170 · §171
05 PVC 가 노드 로컬이라 게스트 하나의 디스크에 묶이는 것 §145 · §159 · §167
05 7800 으로 오가는 JGroups 메시지 §102 · §116 · §120
06 node-exporter 가 게스트 안에서 재는 값이 호스트 실제와 다를 수 있다는 것 §13 · §14 · §52 · §63
06 계층별로 나눠 보는 진단 §15 · §25 · §86 · §118 · §169

04 는 가상화 계층에 거의 닿지 않는다. 인증서 발급·갱신·훅은 게스트 안 애플리케이션 계층에서 벌어지고, 이 문서의 제1~4부가 적은 것과 이어지는 곳은 위 표의 한 줄이 전부다.

06 의 줄이 제1~4부 전체에 걸린다(inferred). node-exporter 는 게스트 커널이 내놓는 값을 읽으므로, 제1부 §13 의 steal time, 제2부 §52·§63 의 메모리·스왑, 제4부 §169 의 I/O 지표가 전부 「게스트가 본 것」이다. 호스트에서 같은 것을 재면 다른 수가 나올 수 있는데, 이 실험대는 호스트 쪽 지표를 긁지 않는다(§192).

194. 이 부에서 파생될 OPEN QUESTION

가이드가 스스로 「캡처해 두지 않았다」·「재지 않았다」로 남긴 것만 적는다.

  • 생성 명령은 재실행으로 검증되지 않았다. VM 을 다시 만들거나 k3s 를 다시 깔면 돌고 있는 실험대가 없어지기 때문이다 — 가이드대로 쳐서 이 상태가 다시 서는지 는 확인된 적이 없다 (unknown)
  • 호스트의 코어 수가 가이드의 「16 코어 전부」와 §178 의 「논리 코어 8」로 갈렸다 → 8 이다. 2026-09-17 에 nproclscpu 로 갈랐다 (observed)
  • cloud-init 의 packages 에 certbot 이 있었는가 → 없다. 2026-09-17 에 게스트의 user-data 와 저장소 템플릿을 둘 다 열어서 갈랐다 (observed)
  • lsmod | grep kvm 의 실제 출력, 03 의 curl -I http://192.168.122.11 출력, 04 의 curl -v 협상 출력 — 셋 다 캡처해 두지 않았다 (unknown)
  • k3s 토큰 108자는 판올림에 따라 달라진다. 다른 판에서 몇 자인지는 재지 않았다 (unknown)
  • 갱신→서빙 2305초는 훅이 물리 호스트에만 있던 시절의 값이다(inferred). 엣지 VM 배치에서 다시 재면 같은 수가 나오는지 (미측정)
  • 엣지 VM 의 1024MB·vCPU 1 이 nginx 와 certbot 에 충분한지 — 가이드는 「훨씬 작아도 된다」고만 적고 자원 사용량을 재지 않았다 (미측정)
  • 매니페스트 둘(keycloak-cluster.yaml·observability.yaml)과 cloud-init 템플릿이 source/ 에 없다. 파드 자원 한도·프로브·스크레이프 주기를 SSOT 안에서 대조할 방법이 지금은 없다 (unknown)

제7부 — 실험대에서 실제로 잰 값

제5부는 구축하다 걸려 넘어진 곳을, 제6부는 무엇을 어떤 순서로 세웠는지를 적었다. 이 부는 그때 실제로 어떤 값이 나왔는가를 적는다. 절차는 제6부에 있고 여기서 반복하지 않는다.

195. 이 부의 출처와 범위

원본 ../source/docs/lab-virtualization.md — 496줄. 이 부는 그 문서 전문이다
리비전 ../source/.source-revision9465582b5d1630eb4ae7c4e078021486919bf6b6
측정일 2026-09-10, test-server

왜 뒤늦게 들어왔나 — §178 과 §184 가 이 문서를 「실측 기록」으로 가리키기만 하고 값을 옮겨 오지 않았다. 그래서 제5·6부에는 이 실험대의 버전과 IP 는 있는데 할당량·사용량·소요 시간이 없었다. source/ 를 지우면 그 값이 어디에도 남지 않는 상태였다.

옮긴 방식 — heading 줄만 손댔다. 원본의 ## 를 이 문서의 절 번호로 바꾸고 그 아래 ### 는 그대로 두었다. heading 이 아닌 줄은 한 글자도 바꾸지 않았고, 원본과 줄 단위로 대조해 확인했다(차이 0). 원본의 절 번호와 이 문서의 절 번호는 아래 표로 잇는다.

원본 절 이 문서 원본 줄
(머리말) 이 문서가 무엇인가 §196 3
1. 측정 환경 §197 21
2. 자원 — 할당과 실사용은 다르다 §198 75
3. 디스크 — 오버레이는 얼마나 쓰나 §199 114
4. 부팅 — cloud-init 은 얼마나 걸리나 §200 168
5. 네트워크 — DHCP 예약의 실제 동작 §201 204
6. 철거 — 실제 출력 전문 §202 272
7. 실측으로 드러난 함정 셋 §203 354
8. 재구축할 때 무엇이 남아 있나 §204 417
관련 문서 §205 489

표기 — 원본이 스스로 밝힌 규약을 그대로 쓴다. 「여기 적힌 숫자는 전부 2026-09-10 에 test-server 에서 실제로 돌려 받은 출력이다. 추정값·예상값은 없다. 없는 값은 「미측정」이라고 쓴다.」 그러므로 이 부의 코드 블록 안 출력은 observed 이고, 「미측정」이라고 적힌 자리는 unknown 이다.

이 부가 앞의 부와 갈리는 곳 — 제1~4부에는 이 호스트에서 잰 값이 하나도 없다 (문서 머리말이 그렇게 선언한다). 제5·6부는 버전·주소·명령을 관측했다. 이 부가 처음으로 자원의 양과 시간을 잰다.

범위 밖 — 이 문서는 k3s 만 떠 있고 Keycloak·PostgreSQL·Redis·Prometheus 가 올라가기 전 상태를 쟀다. 그것들이 올라간 뒤의 값은 여기 없다(원본이 §198 에서 「미측정」으로 밝힌다).

§205 가 가리키는 deploy/lab/host/teardown-host.shsource/ 에 반입되지 않았다(unknown) — 이 저장소에는 그 스크립트의 원문이 없고 파일 이름만 있다.

196. 이 문서가 무엇인가

docs/guides/「무엇을 어떤 순서로 치는가」 를 적는다면, 이 문서는 「그때 실제로 어떤 값이 나왔는가」 를 적는다. 가이드에 이미 있는 절차는 반복하지 않는다.

여기 적힌 숫자는 전부 2026-09-10 에 test-server 에서 실제로 돌려 받은 출력이다. 추정값·예상값은 없다. 없는 값은 「미측정」이라고 쓴다.

이 문서가 답하는 것 가이드가 답하는 것
5120MB 를 줬는데 실제로 얼마를 쓰나 메모리를 얼마로 주나
20GB 오버레이가 디스크를 얼마나 먹나 오버레이를 어떻게 만드나
cloud-init 이 몇 초 걸리나 cloud-init 을 어떻게 쓰나
철거하면 무엇이 남나 무엇을 세우나

197. 측정 환경

lscpu | grep -E "^Model name|^CPU\(s\):|^Thread|^Core"
free -m | head -2
df -h /
virsh --version; qemu-system-x86_64 --version | head -1; uname -r

실측

CPU(s):                8
Model name:            11th Gen Intel(R) Core(TM) i5-1135G7 @ 2.40GHz
Thread(s) per core:    2
Core(s) per socket:    4

               total        used        free      shared  buff/cache   available
Mem:           11648        5642        2599           4        3776        6005

/dev/nvme0n1p3  226G  9.9G  204G   5% /

12.7.0
QEMU emulator version 11.1.1
7.2.2-arch1-1

어디를 봐야 하는가Core(s) per socket 4 에 Thread(s) per core 2 라 논리 코어가 8 이다. VM 에 주는 vCPU 는 물리 코어가 아니라 논리 코어를 나눠 쓰는 것이고, 합이 8 을 넘어도 libvirt 는 막지 않는다(오버커밋). 이 실험대는 2 + 2 + 1 = 5 로 잡아 여유를 뒀다.

freeavailable(6005MB)이 free(2599MB)보다 훨씬 큰 것이 정상이다 — buff/cache 3776MB 는 필요하면 회수된다. VM 을 얼마나 더 띄울 수 있는지는 free 가 아니라 available 로 본다.

중첩 가상화

lscpu | grep Virtualization
cat /sys/module/kvm_intel/parameters/nested
Virtualization:   VT-x
Y

켜져 있지만 이 실험대는 쓰지 않는다. 일회용으로 만들려는 층(게스트)은 이미 일회용이고, 비싼 층(호스트의 인증서·DNS)은 중첩으로 해결되지 않는다. 그리고 A-6 의 지연 주입처럼 시간을 재는 실험에서 중첩은 측정값을 왜곡한다.


198. 자원 — 할당과 실사용은 다르다

VM 에 준 메모리는 상한이지 점유가 아니다. virtio-balloon 이 안 쓰는 만큼 호스트에 돌려준다.

for v in kc-lab-1 kc-lab-2; do
  printf "%-10s 할당 %sMB  실사용 %sMB\n" "$v" \
    "$(( $(virsh dommemstat $v | awk '/actual/{print $2}') / 1024 ))" \
    "$(( ($(virsh dommemstat $v | awk '/actual/{print $2}') \
        - $(virsh dommemstat $v | awk '/^unused/{print $2}')) / 1024 ))"
done

실측 — k3s 만 떠 있고 Keycloak 은 아직 안 올린 상태

kc-lab-1   할당 5120MB  실사용 353MB
kc-lab-2   할당 3120MB  실사용 301MB

어디를 봐야 하는가 — 두 가지다.

실사용이 할당의 7% 밖에 안 된다. k3s server 가 353MB 다. 「5GB 를 줬으니 5GB 를 쓴다」가 아니다.

kc-lab-2 의 할당이 4096 이 아니라 3120 이다. virt-install --memory 4096 으로 만들었는데 줄어 있다. virtio-balloon 이 회수해 간 것으로 보인다. dommemstatactual현재 할당이지 선언한 상한이 아니다. 상한은 virsh dominfoMax memory 에 있다 — 다만 이 실험대에서 그 값을 나란히 찍어 보지는 않았다(미측정). 둘을 헷갈리면 「메모리가 왜 줄었지」가 된다.

이 결과가 의미하는 것 — 합계 8240MB 를 할당했지만 실제 점유는 654MB 다. 그래서 11.6GB 짜리 호스트에서 VM 세 대가 무리 없이 돈다. 여기 적힌 11.6GB 는 원 가이드의 표기 그대로다 — §178 의 원 측정은 free -mMem: 11648 이고 그것은 MiB 라 11,648MiB, 약 11.4GiB 다. 고쳐 쓰지 않고 어긋남을 적어 둔다. 그리고 이것은 지금 k3s 만 떠 있어서다 — Keycloak 2 파드 + PostgreSQL + Redis + Prometheus 가 올라가면 늘어난다. 그 시점의 값은 미측정이다.


199. 디스크 — 오버레이는 얼마나 쓰나

게스트 디스크는 base.qcow2 위의 오버레이다. 20GB 를 두 개 만들어도 바닥 이미지는 한 벌이고 변경분만 쌓인다.

qemu-img info /var/lib/libvirt/images/base.qcow2 | head -4
ls -l /var/lib/libvirt/images/

실측 — 엣지를 만들기 , k3s 2 노드만 있던 시점이다.

image: /var/lib/libvirt/images/base.qcow2
file format: qcow2
virtual size: 3 GiB (3221225472 bytes)
disk size: 335 MiB

-rw-r--r--  base.qcow2              351404032   (335 MiB)
-rw-------  kc-lab-1.qcow2         1521025024   (1.4 GiB)   ← 선언 20GB
-rw-------  kc-lab-2.qcow2          697499648   (665 MiB)   ← 선언 20GB
-rw-------  seed-kc-lab-1.iso          378880   (370 KiB)
-rw-------  seed-kc-lab-2.iso          378880   (370 KiB)

어디를 봐야 하는가base.qcow2virtual size 3GiB 와 disk size 335MiB 의 차이. 그리고 게스트 디스크가 20GB 로 선언됐는데 1.4GB 와 665MB 라는 것. k3s server 가 agent 보다 두 배 이상 쓰는 것은 컨트롤 플레인 바이너리와 SQLite 때문이다.

이 결과가 의미하는 것 — 20GB × 2 = 40GB 를 선언했지만 실제로는 2.1GB 를 썼다. 디스크는 병목이 아니다. df 로 본 사용량과 VM 에 선언한 크기를 같은 것으로 보면 안 된다.

스토리지 풀

virsh pool-info default
Name:           default
State:          running
Persistent:     yes      Autostart:  yes
Capacity:       225.31 GiB
Allocation:     7.84 GiB
Available:      217.46 GiB

Allocation풀 전체(호스트 루트 파일시스템)의 사용량이지 VM 만의 사용량이 아니다. VM 이 얼마를 쓰는지는 위 ls -l 로 본다.


200. 부팅 — cloud-init 은 얼마나 걸리나

for i in $(seq 1 30); do
  ssh -o BatchMode=yes -o ConnectTimeout=4 kc-lab-edge 'cloud-init status' 2>/dev/null \
    | grep -q 'status: done' && { echo "완료 (약 $((i*10))초)"; break; }
  sleep 10
done
ssh kc-lab-edge 'hostname; ip -4 -br addr show enp1s0; cloud-init status'

실측package_update: true 에 패키지 5개(curl nftables nginx certbot python3-certbot-dns-cloudflare)를 받는 게스트

완료 (약 50초)

kc-lab-edge
enp1s0           UP             192.168.122.10/24 metric 100
status: done

어디를 봐야 하는가cloud-init status 의 세 상태를 구분한다.

running 아직 진행 중. 기다린다
done 끝났다
error 실패. cloud-init status --long 으로 어느 모듈인지 본다

이 결과가 의미하는 것 — SSH 가 붙는 것과 cloud-init 이 끝난 것은 다르다. SSH 는 먼저 열리고 패키지 설치는 뒤에 이어진다. done 을 안 기다리고 다음 단계를 치면 「방금 깐 패키지가 없다」가 나온다.


201. 네트워크 — DHCP 예약의 실제 동작

virsh net-update default add ip-dhcp-host \
  "<host mac='52:54:00:aa:bb:10' name='kc-lab-edge' ip='192.168.122.10'/>" \
  --live --config

실측

Updated network default persistent config and live state

어디를 봐야 하는가persistent configlive state 두 마디가 다 나오는가. --live 만 주면 앞의 것이, --config 만 주면 뒤의 것이 빠진다. 한쪽만 나온 것을 성공으로 읽으면 「재부팅하니 IP 가 바뀐다」가 된다.

예약을 먼저, VM 을 나중에

이 실험대는 예약을 넣고 나서 virt-install 했고, 게스트가 첫 부팅에서 바로 .10 을 받았다.

enp1s0           UP             192.168.122.10/24 metric 100

순서가 반대면 게스트가 동적 대역(192.168.122.2.254)에서 아무 주소나 받고, 예약이 먹으려면 리스 만료를 기다리거나 재부팅해야 한다.

리스는 예약과 별개로 남는다

virsh net-dhcp-leases default
 Expiry Time           MAC address         IP address          Hostname
 2026-09-10 09:59:54   52:54:00:aa:bb:11   192.168.122.11/24   kc-lab-1
 2026-09-10 09:58:46   52:54:00:aa:bb:12   192.168.122.12/24   kc-lab-2

net-dumpxml 의 예약은 줄 의도이고, net-dhcp-leases실제로 준 기록이다. 둘이 다를 수 있다.

virbr0 는 게스트가 없으면 내려간다

ip -br addr show virbr0

VM 세 대가 돌 때:

virbr0           UP             192.168.122.1/24

전부 철거한 뒤:

virbr0           DOWN           192.168.122.1/24

주소는 그대로 있고 상태만 DOWN 이다. 브리지에 붙은 tap 인터페이스가 하나도 없어서다. 네트워크 정의가 사라진 것이 아니므로 VM 을 다시 띄우면 자동으로 UP 이 된다. 이걸 고장으로 오독해 virsh net-start 를 찾아 헤매지 않는다.


202. 철거 — 실제 출력 전문

가이드에는 세우는 절차만 있고 철거가 없다. 2026-09-10 에 실제로 돌린 기록이다.

게스트

for v in kc-lab-edge kc-lab-2 kc-lab-1; do
  virsh destroy "$v"
  virsh undefine "$v" --remove-all-storage
done

실측 (한 대분)

Domain 'kc-lab-edge' destroyed
Domain 'kc-lab-edge' has been undefined
Volume 'vda'(/var/lib/libvirt/images/kc-lab-edge.qcow2) removed.
Volume 'vdb'(/var/lib/libvirt/images/seed-kc-lab-edge.iso) removed.

어디를 봐야 하는가Volume 줄이 두 개 나오는가. vda(오버레이 디스크)와 vdb(시드 ISO)다. --remove-all-storage 를 빠뜨리면 도메인만 사라지고 디스크 파일이 남아 다음 virt-install 이 「이미 있다」로 실패한다.

destroy 는 종료 신호가 아니라 전원을 뽑는 것이다. 정상 종료를 원하면 virsh shutdown 을 쓰고 꺼질 때까지 기다린다.

DHCP 예약

virsh net-update default delete ip-dhcp-host \
  "<host mac='52:54:00:aa:bb:10' name='kc-lab-edge' ip='192.168.122.10'/>" \
  --live --config

★ 삭제할 때도 mac·name·ip 세 속성을 다 준다. 하나라도 비면 이렇게 거부된다.

error: Failed to update network default
error: XML error: Cannot use host name '' in network 'default'

zsh 에서 루프로 돌리면 이 오류를 만난다. zsh 는 따옴표 없는 변수를 단어 분리하지 않는다. bash 에서 되던 set -- $entry 가 zsh 에서는 $1 에 문자열 전체를 넣고 $2·$3 을 비운다. 그래서 name="" 이 된다. 세 줄을 값 그대로 쓰는 편이 안전하다.

철거 후

### 남은 예약
(없음)

### dhcp 블록
    <dhcp>
      <range start='192.168.122.2' end='192.168.122.254'/>
    </dhcp>

동적 대역만 남는 것이 정상이다.

철거 전후 비교 — 실측

철거 전 철거 후
virsh list --all 3 대 running (없음)
virsh vol-list default 7 개 base.qcow2 1 개
DHCP 예약 3 줄 0 줄
df -h / 11G 7.9G
virbr0 UP DOWN

3.1GB 가 회수됐다. 내역은 kc-lab-1 1.4GB + kc-lab-2 665MB + 시드 ISO 3개(각 370KB)이고, kc-lab-edge 의 디스크 크기는 재 두지 않았다(미측정) — 합계에서 역산하면 1GB 안팎이다.

base.qcow2 335MB 는 남긴다 — 다음 재구축의 바닥이고, 다시 받으면 몇 분이다.


203. 실측으로 드러난 함정 셋

전부 아무 오류 없이 조용히 지나가거나, 엉뚱한 곳에서 증상이 나오는 유형이다.

① cloud-init sudo 는 리스트가 아니라 문자열

ssh kc-lab-1 'cloud-init schema -c ~/chk.yaml'

리스트 형태(sudo: ['ALL=(ALL) NOPASSWD:ALL'])일 때:

Error: Cloud config schema errors: users.0: {'name': 'donghyeon', 'groups':
['sudo'], 'shell': '/bin/bash', ...} is not valid under any of the given schemas

문자열 형태(sudo: "ALL=(ALL) NOPASSWD:ALL")일 때:

Valid cloud-config: /home/donghyeon/chk.yaml

어느 키가 문제인지 안 알려 준다. users.0 블록을 통째로 찍고 「어느 스키마에도 안 맞는다」고만 한다. 그리고 리스트 형태도 부팅은 된다kc-lab-1·kc-lab-2 가 그 상태로 NOPASSWD sudo 가 멀쩡히 돌았다. 검사기만 거부하는 것이라 「검사는 실패인데 왜 되지」로 헷갈린다.

게스트 cloud-init 버전: 22.4.2 (Debian 12 genericcloud).

② nginx http2 on; 은 배포판에 따라 없다

엣지 (Debian 12):   nginx version: nginx/1.22.1
lab host (Arch):    nginx version: nginx/1.30.4

http2 on; 은 nginx 1.25.1 이상이다. Arch 에서 쓰던 설정을 Debian 게스트로 그대로 옮기면:

[emerg] 1339#1339: unknown directive "http2" in /etc/nginx/sites-enabled/keycloak-lab:29
nginx: configuration file /etc/nginx/nginx.conf test failed

listen 443 ssl http2; 형태는 1.22 와 1.30 양쪽에서 다 돈다.

③ Debian 기본 사이트가 default_server 를 먹고 있다

Debian 계열은 /etc/nginx/sites-enabled/default 가 처음부터 붙어 있고 :80default_server 로 선언돼 있다. 실험대 설정의 listen 80 default_server충돌한다. 심볼릭 링크를 걸 때 같이 지운다.

sudo rm -f /etc/nginx/sites-enabled/default

Arch 는 sites-available 관례 자체가 없어서 이 함정이 없는 대신, nginx.confinclude /etc/nginx/sites-enabled/*; 를 직접 넣어야 한다. 배포판이 바뀌면 함정도 바뀐다.


204. 재구축할 때 무엇이 남아 있나

철거해도 남는 것과 사라지는 것을 구분해 둔다. 이걸 모르면 재구축이 「왜 이건 이미 있지」와 「왜 이건 없지」의 반복이 된다.

상태
base.qcow2 (335MB) 남는다 다음 오버레이의 바닥
패키지 (libvirt·qemu·nginx·certbot·kubectl) 남는다 재설치가 무의미
~/workspace/cloud/kc-lab-{1,2}.yaml 남는다 키와 비밀번호가 들어 있다
~/.ssh/configkc-lab-* 항목 남는다 재구축해도 IP 가 같다
libvirt default 네트워크 정의 남는다 예약만 지웠다
/etc/letsencrypt/ 남긴다 (정책) 아래 참고
게스트 디스크·시드 ISO 사라진다 --remove-all-storage
DHCP 예약 사라진다 net-update delete
k3s·Keycloak·모든 워크로드 사라진다 게스트와 함께

인증서를 지우지 않는 이유

한도 때문이 아니다. Let's Encrypt 의 「같은 이름 조합에 주당 중복 5장」 제한은 가끔 재구축하는 정도로는 근처에도 못 간다.

진짜 이유는 「지금 재발급이 되는지를 모른다」는 것이다. 이 실험대의 이름 셋은 tailnet 주소를 가리킨다.

dig +short auth.hyeonworks.com
100.83.212.4

100.64.0.0/10 은 CGNAT 용 예약 대역이라 공개 인터넷에서 라우팅되지 않는다. HTTP-01 검증은 Let's Encrypt 가 우리 서버로 들어오는 방식이므로, 이 주소로는 검증이 성립하지 않는다.

지금 설정이 지우고 나면
dns-cloudflare (DNS-01) 다시 받으면 끝. 백업은 헛수고였던 것
webroot·standalone (HTTP-01) 검증 방식부터 손봐야 한다

「못 한다」가 아니라 「일이 하나 생긴다」이다. 최악의 경우라도 A 레코드를 공인 IP 로 잠깐 돌려 받거나 DNS-01 로 전환하면 된다. 다만 재구축하려고 앉은 자리에서 그 일부터 하게 된다.

어느 쪽인지 모르는 채로는 지우지 않는다는 것이 이 정책의 전부다. 백업은 tar 하나에 30초고, 답을 알고 나면 지우면 된다.

sudo tar czf ~/letsencrypt-backup-$(date +%Y%m%d-%H%M%S).tgz -C /etc letsencrypt

그런데 이 정책이 지키는 디렉터리가 03·04 뒤로 바뀌었다(2026-09-17, observed). 위 명령은 랩 호스트의 /etc/letsencrypt/ 를 묶는데, 03 이 nginx 를 kc-lab-edge 로 옮겼고 04 가 certbot 을 거기 깔았다. 지금 서빙하는 인증서는 그 게스트 안에 산다. 그리고 철거 1번은 게스트를 --remove-all-storage 로 지운다 — 인증서는 그 안에서 같이 사라지고, 위 백업에는 안 들어간다.

ls -la /etc/letsencrypt/
ssh kc-lab-edge 'sudo ls -la /etc/letsencrypt/'
기계 /etc/letsencrypt/ certbot 타이머 서빙
랩 호스트 있다 (읽으려면 sudo) /usr/bin/certbot 없다 안 한다 — 80·443 을 안 듣는다
kc-lab-edge 있다 — cli.ini · renewal-hooks /usr/bin/certbot certbot.timer 한다

묶어야 할 것은 게스트 쪽이다.

ssh kc-lab-edge "sudo tar czf /tmp/letsencrypt-backup-$(date +%Y%m%d-%H%M%S).tgz -C /etc letsencrypt"
scp kc-lab-edge:/tmp/letsencrypt-backup-*.tgz ~/

「철거해도 남는 것」 표의 패키지 줄도 같은 이유로 틀렸다. 그 줄은 nginxcertbot 을 남는 쪽에 적어 두었는데 둘 다 게스트 안에 있으므로 게스트와 함께 사라진다. 랩 호스트에 남는 것은 libvirt·qemu·kubectl 과, 쓰이지 않는 호스트 쪽 certbot 이다.

철거 전 네 값은 2026-09-17 에 다시 받았다(observed). 도메인 3 대에 볼륨 7 개, 예약 3 줄까지는 2026-09-10 과 같고 디스크만 11G 가 아니라 18G 다 — 그사이에 k3s 와 컨테이너 이미지가 쌓였다. 이 네 값은 이 호스트의 정답이 아니라 매 실행에서 받아 두는 대조군이다.

복원은 반대로 한 줄이다.

sudo tar xzf ~/letsencrypt-backup-<stamp>.tgz -C /etc

미측정 — 이 실험대의 certbot 이 HTTP-01 인지 DNS-01 인지 아직 확인하지 않았다. 호스트의 sudo 가 비밀번호를 요구해 비대화식으로 읽지 못했다. 다음 한 줄이 답이다.

sudo grep -H authenticator /etc/letsencrypt/renewal/*.conf

webroot·standalone 이면 HTTP-01 이라 위 문제가 실재하고, dns-cloudflare 면 DNS-01 이라 tailnet 안에서도 재발급·갱신이 된다. 플러그인은 이미 깔려 있다 — certbot pluginsdns-cloudflare 가 보인다.


205. 관련 문서

문서 무엇
guides/01-vms/ VM 세 대를 세우는 절차
guides/00-lab-host/ 호스트 가상화 준비
session-lab-concepts.md 여기 나온 개념의 정의
deploy/lab/host/teardown-host.sh 호스트 계층 철거

제8부 — 설정 원본이 자기 안에 적어 둔 것

§189·§190 은 가이드가 치라고 적은 설정을 옮겼다. 그 설정의 정본 파일은 저장소의 deploy/lab/edge/ 에 따로 있고, 거기에는 가이드에 없는 것이 하나 더 들어 있다 — 왜 이렇게 썼는지를 적은 주석이다. 이 부가 그 주석을 옮긴다.

206. 이 부의 출처와 범위

원본 ../source/deploy/lab/edge/ — 네 파일, 합쳐 125줄
리비전 ../source/.source-revision9465582b5d1630eb4ae7c4e078021486919bf6b6

무엇이 이미 있고 무엇이 없었나(observed) — 네 파일의 실행되는 줄은 49줄 전부 가 §189·§190 에 이미 들어 있다. 빠진 것은 주석 59줄이고, 그중 한 줄은 주석 처리된 설정(# ip_hash;)이다.

파일 실행되는 줄 그 줄이 SSOT 에 주석 주석이 SSOT 에
lab-edge-dnat.nft 31 8 전부 있다 (§189) 19 하나도 없다
lab-edge-dnat.service 21 12 전부 있다 (§189) 5 하나도 없다
nginx-keycloak-lab.conf 61 28 전부 있다 (§190) 25 하나도 없다
reload-nginx.sh 12 1 있다 (§190) 10 하나도 없다

125줄 가운데 실행되는 줄이 49, 주석이 59, 나머지가 shebang 2 와 빈 줄이다. 공백을 정규화해 대조했다 — SSOT 는 같은 설정을 탭으로 들여썼다.

주석을 따로 옮기는 이유 — 주석이 적은 것은 설정 값이 아니라 그 값을 고른 이유와, 반대로 했을 때 조용히 깨지는 것이다. 그 중 셋은 SSOT 본문이 같은 말을 다른 자리에서 하고 있고(§179 의 SNAT 금지, §180 의 체인 순서, §190 의 fullchain.pem), 둘은 이 저장소 어디에도 없다 — 스티키 세션 스위치와 X-Forwarded-For 를 덧붙이지 않고 덮어쓰는 이유다.

표기 — 주석은 원문이 영어다. 인용은 원문 그대로 두고 옆에 우리말을 붙인다. 번역이 원문을 대신하지 않는다.

207. lab-edge-dnat.nft — DNAT 파일이 자기 안에 적어 둔 네 가지

파일의 첫 줄이 자기가 무엇인지 한 문장으로 적는다.

# Forward the tailnet entry point to the edge guest.

① 호스트에 남는 트래픽 규칙은 이것 하나뿐이다.

# This is the ONLY lab traffic rule the physical host carries. Everything else
# that used to live here — nginx config, certificates, certbot, the deploy hook
# — now lives on kc-lab-edge and is destroyed with it.

§179 가 「더러워지는 층의 격리」라고 적은 것의 완료 상태를 파일 자신이 선언한 것이다(observed). 옮긴 것의 목록이 여기 그대로 있다 — nginx 설정·인증서·certbot· deploy 훅.

② DNAT 만 하고 SNAT 는 절대 하지 않는다.

# DNAT only, never SNAT. The guests' default route is the host, so replies come
# back through here and conntrack reverses the translation on its own. Adding a
# masquerade would rewrite the source and the edge would see 192.168.122.1 for
# every client — which would silently invalidate the X-Forwarded-For contract
# that this lab measures.

§179 의 네 번째 줄이 같은 말을 한다. 파일 쪽에만 있는 것은 왜 SNAT 없이도 응답이 돌아오는가다 — 게스트의 기본 경로가 호스트이므로 응답이 이 자리를 다시 지나고, conntrack 이 변환을 알아서 되돌린다(observed).

③ PREROUTING 이 먼저 돌기 때문에 전환이 원자적이다.

# PREROUTING nat runs before the routing decision, so this wins over any local
# socket on :80/:443. That makes the cutover atomic and the rollback a single
# `nft delete table ip lab_edge`.

§189 의 되돌리기 표가 sudo nft delete table ip lab_edge 한 줄을 적고, SSOT 본문도 「PREROUTING nat 은 라우팅 결정보다 먼저 돌기」를 적는다. 파일 쪽에만 있는 것은 그 둘을 잇는 말이다 — 호스트에 리스너가 남아 있어도 이 규칙이 이긴다. 그래서 엣지를 띄운 채 전환하고, 실패하면 테이블 하나를 지워 되돌린다.

forward 체인이 없는 것은 실수가 아니다.

    # No forward chain here on purpose. libvirt's guest_input chain ends in
    # `oif virbr0 ... reject`, and an accept in an earlier base chain does NOT
    # stop a later chain from rejecting — that is nftables, not iptables. The
    # hole is punched inside libvirt's own chain by the unit's ExecStartPost.

이것이 §180 의 결말이다. §180 은 「DNAT 파일에 priority filter - 10 으로 먼저 도는 forward 체인을 두고 ct state new accept 를 넣어 두었다」고 적고 그것이 안 먹힌 이유를 설명한 뒤, 고친 파일이 어떻게 되었는지는 적지 않았다. 답은 그 체인을 지웠다이고, 지웠다는 사실과 지운 이유를 파일이 자기 자리에 적어 두었다 (observed). §189 의 코드 블록에도 그 자리에 빈 줄 하나가 남아 있다.

208. lab-edge-dnat.serviceExecStartPost 앞의 - 가 무엇을 봐주나

# libvirt's own guest_input chain ends in `oif virbr0 ... reject`, and nftables
# does NOT let an accept in an earlier base chain override a reject in a later
# one. So the hole has to be punched inside libvirt's chain, at the top.
# `-` because libvirt_network only exists once the virtual network is up; if it
# is missing the DNAT still loads and this can be re-applied with a restart.

§189 는 유닛 파일을 그대로 싣지만 ExecStartPost=-하이픈을 설명하지 않는다. systemd 에서 앞에 붙은 - 는 「이 명령이 실패해도 유닛을 실패로 보지 않는다」는 뜻이다.

여기서 그것이 필요한 이유(observed) — libvirt_network 테이블은 가상 네트워크가 떠 있어야 존재한다. 부팅 순서에 따라 이 유닛이 먼저 돌 수 있고, 그러면 규칙 삽입이 실패한다. - 가 없으면 DNAT 까지 같이 안 실린다. - 를 두면 DNAT 는 실리고 구멍만 빠진 상태가 되어, 나중에 systemctl restart 한 번으로 다시 뚫린다.

감수한 것 — 그 상태는 §180 의 증상과 똑같이 보인다. 호스트 안에서는 되는데 밖에서만 안 된다. 유닛은 active 이고 아무 오류도 없다. 그러므로 이 유닛이 active 라는 것은 DNAT 가 실렸다는 뜻이지 구멍이 뚫렸다는 뜻이 아니다 (inferred — 이 상태를 실제로 재현해 보지는 않았다).

209. nginx-keycloak-lab.conf — 스티키 스위치와 신뢰 경계

§190 이 옮긴 설정과 이 파일의 실행되는 줄은 같다. 다른 것은 셋이다.

① 꺼 둔 스티키 세션 스위치 — SSOT 어디에도 없던 줄이다.

upstream k3s_traefik {
    # Sticky-session switch. Keycloak recommends affinity on AUTH_SESSION_ID;
    # ip_hash is the cheap stand-in for a single-browser lab. Leaving it off is
    # the interesting case: Infinispan still routes correctly, only slower.
    # ip_hash;
    server 192.168.122.11:80;
    server 192.168.122.12:80;
}

주석 처리된 한 줄이 실험 설계다(observed). 켜고 끄며 비교하라고 남긴 스위치이고, 끈 쪽이 관찰할 거리가 있는 상태라고 파일이 적는다 — Infinispan 이 라우팅을 해 주므로 실패하지는 않고 느려질 뿐이다. Keycloak 이 권장하는 것은 ip_hash 가 아니라 AUTH_SESSION_ID 쿠키 기반 어피니티이고, ip_hash 는 브라우저 한 대짜리 실험대에서 쓰는 값싼 대용품이다.

http2 를 지시어가 아니라 listen 의 인자로 쓴 이유

    # The http2 parameter of listen, not the separate `http2 on;` directive:
    # that directive needs nginx >= 1.25.1 and the edge guest is Debian 12
    # (nginx 1.22). This form works on both and is what the lab actually runs.

§179 의 여섯 번째 줄과 §190 의 「막히면」 표가 같은 사실을 적는다. 파일 쪽에만 있는 것은 이 형태가 양쪽에서 다 돈다는 확인이다 — Arch 의 1.30 에서도, Debian 12 의 1.22 에서도.

③ lineage 이름과 fullchain.pem

    # Lineage is named after the FIRST -d, so a wildcard cert issued as
    #   -d hyeonworks.com -d '*.hyeonworks.com'
    # lands in live/hyeonworks.com/, not live/auth.hyeonworks.com/.
    # fullchain.pem, never cert.pem: omitting the intermediates passes on
    # desktop browsers and fails on mobile and curl.

§182 의 결함 표와 §190 이 같은 말을 한다.

X-Forwarded-For 를 덧붙이지 않고 덮어쓰는 이유이 저장소 어디에도 없던 설명이다.

        # $remote_addr, not $proxy_add_x_forwarded_for. This is the trust
        # boundary: a client-supplied X-Forwarded-For must be discarded, not
        # extended, or nothing downstream can rely on the value.
        proxy_set_header X-Forwarded-For   $remote_addr;

§189·§190 은 proxy_set_header X-Forwarded-For $remote_addr; 를 싣기만 하고 왜 $proxy_add_x_forwarded_for 가 아닌지를 적지 않는다. 둘의 차이는 클라이언트가 보낸 값을 사슬 앞에 남기느냐 버리느냐다. 덧붙이면 위조된 값이 사슬에 남고, 그러면 뒤쪽의 어느 것도 그 헤더를 근거로 쓸 수 없다.

★ 이 파일의 머리말은 자기 위치를 틀리게 적고 있다(observed).

# Lab entry point. Deployed on the lab host as
#   /etc/nginx/sites-available/keycloak-lab
# and symlinked from sites-enabled/.
#
# Arch does not ship the Debian sites-available convention, so nginx.conf needs
#   include /etc/nginx/sites-enabled/*;
# inside its http { } block before this file has any effect.
#
# This is the outer of two L7 hops. It terminates TLS and hands plain HTTP to
# the Traefik instance running on each k3s node.

마지막 두 줄은 자리와 무관하게 맞다 — §179 이 「L7 홉 수는 그대로 2홉이다」로 같은 것을 적는다. 틀린 것은 그 위의 다섯 줄이다.

「lab host 에 배포한다」이고 「Arch 는 그 관례를 주지 않는다」인데, 같은 파일의 아래쪽 주석은 「엣지 게스트는 Debian 12」라고 적는다. 파일이 있는 자리도 deploy/lab/**edge**/ 이고, §179 가 적은 대로 엣지는 호스트에서 게스트로 옮겨졌다. 즉 머리말만 옮기기 전 상태로 남았다(inferred).

이것이 §182 가 말한 결함과 같은 종류다 — 각 줄은 어느 시점엔가 참이었고, 틀린 것은 명령이 아니라 그 명령이 놓인 위치다. 다만 §182 는 가이드에서 그것을 찾았고 이것은 설정 정본에서 나왔다. 고치지 않고 그대로 옮긴다 — 원본이 그렇다.

210. reload-nginx.shdeploy/post/ 를 가르는 한 줄

# certbot deploy hook. Install as
#   /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh   (chmod +x)
#
# deploy/ runs only when a certificate was actually renewed (RENEWED_LINEAGE is
# set). post/ would run twice a day whether or not anything changed, reloading
# nginx for nothing.
#
# Without this, D-4 measured the failure exactly: the renewal succeeds, the
# timer reports SUCCESS, and the old certificate keeps being served for 38m25s
# — with no error anywhere.

§190 이 「post/ 는 갱신이 없어도 매번 돌아 하루 두 번 워커를 갈아치운다. deploy/실제로 갱신됐을 때만 실행된다」로 같은 말을 하고, 실측표도 2305초 (38분 25초) 로 같은 수를 적는다. 파일 쪽에만 있는 것은 둘이다.

파일에만 있는 것 무엇
RENEWED_LINEAGE is set deploy/post/ 를 가르는 기계적 기준. certbot 이 이 환경 변수를 세운 실행에서만 deploy/ 를 돈다
D-4 이 실패를 잰 실험의 이름. 원문 experiment-d4-certificate-renewal.mdsource/ 에 반입되지 않았다(unknown)

38m25s2305초 (38분 25초) 는 같은 값이다. 2305 ÷ 60 = 38분 25초.


제9부 — 실험대 개념 사전

제5~8부는 이 실험대에서 일어난 일을 적었다. 이 부는 그 일을 하면서 쌓아 둔 개념 정의다. 원본이 층으로 쌓아 올린 사전이고, 절마다 「무엇인가 · 왜 여기 나오나 · 없거나 틀리면 · 확인」 네 칸을 갖추려 한 문서다.

211. 이 부의 출처와 범위

원본 ../source/docs/session-lab-concepts.md — 4,725줄. 이 부는 그 문서 전문이다
리비전 ../source/.source-revision9465582b5d1630eb4ae7c4e078021486919bf6b6
원본이 밝힌 갱신일 2026-09-04 · 2026-09-11 (§337·§338)
실측 스냅샷 2026-09-03 (§218 의 전체 배치)

왜 뒤늦게 들어왔나 — §178 과 §184 가 이 문서를 「개념 누적」으로 가리키기만 하고 내용을 옮겨 오지 않았다. 제5부의 §179·§180·§181 만이 이 문서의 세 절 (원본 1948·2002·4384행)을 다시 쓴 것이고, 나머지 127절 가운데 SSOT 가 담은 것은 없었다. source/ 를 지우면 통째로 사라지는 상태였다.

옮긴 방식 — heading 줄만 손댔다. 원본에서 실제 항목이 사는 단계### 이므로 그것을 절(##)로 올리고, 그 아래 ####### 로 내렸다. 층 머리 (## N층.)도 절 하나씩 차지한다 — 층 이름이 본문 안에서 「6층 참고」처럼 계속 불리기 때문에 지우지 않았다. heading 이 아닌 줄은 한 글자도 바꾸지 않았고, 원본과 줄 단위로 대조해 확인했다. 차이는 아래 한 곳뿐이다.

바꾼 곳 원본 옮긴 것
원본 1213행 plain_text_passwd: labpass plain_text_passwd: __CONSOLE_PW__ 비밀은 길이·존재 여부만 옮긴다. 가이드와 §187 이 쓰는 자리표시자와 같은 값으로 맞췄다

labpass 는 콘솔 로그인용 비상 비밀번호의 예시 값이고 이 실험대가 실제로 쓴 값인지는 확인하지 않았다(unknown). 어느 쪽이든 SSOT 에 값을 적지 않는다.

층과 절 번호

원본 이 문서 원본 줄
0. "이건 Arch라서 하는 건가?"에 대한 답 §212 25
0-1. 왜 호스트에 직접 깔지 않고 VM 2대인가 §213 42
0-2. 전체 구조 한눈에 보기 §214~§218 65
1층. 가상화 §219~§243 197
2층. 가상 네트워크 §244~§255 1545
3층. 호스트 진입 §256~§262 2054
4층. TLS §263~§268 2352
5층. k3s §269~§282 2486
6층. Arch 특이사항 §283~§288 3186
7층. git §289~§291 3339
8층. 패키지 저장소와 설치 원리 §292~§299 3378
9층. deploy/ — 무엇이 살아 있고 무엇이 참조인가 §300~§306 3557
10층. 쿠버네티스 리소스 — 이 실험대에서 실제로 쓴 것들 §307~§313 3711
11층. Keycloak 클러스터링 내부 — Infinispan과 JGroups §314~§321 4028
12층. 관측성 — Prometheus의 구조 §322~§330 4155
13층. 가상화 운영 — 실행 중 바꾸는 것들 §331~§335 4302
아직 기록하지 않은 개념 §336~§338 4695

이 부가 제5부와 겹치는 세 자리(observed) — 아래 셋은 제5부가 이미 다시 쓴 것이고, 여기 것이 원문이다. 두 판을 나란히 둔다.

원본 절 이 문서 제5부에서
엣지를 물리 호스트에서 VM 으로 옮기면 무엇이 새로 필요해지나 §254 §179
nftables 는 앞 체인의 accept 로 뒤 체인의 reject 를 막지 못한다 §255 §180
qcow2 파일을 다른 물리 서버로 옮기면 무엇이 따라가나 (하위 절 넷 포함) §335 §181

표기가 제1~8부와 다르다. 이 문서는 observed·inferred 같은 딱지를 쓰지 않는다. 원본이 붙인 것은 「실측」이라는 말과 코드 블록이고, 그 표시가 붙은 곳만 관측이다. 나머지는 개념 설명(external 또는 inferred)으로 읽는다. 그 구분을 절마다 다시 붙이지 않았다 — 붙이면 heading 이 아닌 줄을 고치는 것이 되기 때문이다.

시점이 제7부와 다르다. §218 의 전체 배치는 2026-09-03 값이고, 제7부는 2026-09-10 값이다. 그 사이에 호스트 RAM 이 물리 증설됐고(§331) 게스트 메모리가 재배분됐다(§332). 두 값이 어긋나 보이면 틀린 것이 아니라 다른 날이다.

2026-09-03 (§218) 2026-09-10 (제7부)
호스트 RAM RAM 7.4Gi Mem: 11648 (free -m)
kc-lab-1 RAM 3584M · vCPU 2 할당 5120MB
kc-lab-2 RAM 2560M · vCPU 2 할당 3120MB (선언 4096)
게스트 수 2 (엣지 없음) 3 (엣지 추가)
k3s server (v1.36.4) 이 문서는 안 적었다. 단계 02 의 출력이 v1.36.4+k3s1(§188)

§332 가 그 증설을 적는다 — 「호스트에서 8GB→12GB로 물리 증설한 뒤 이 방법으로 재배분했다.」 §187 은 재배분이 있었다는 것만 적고 왜 그때 늘릴 수 있었는지는 적지 않았다. 그 근거가 여기 있다.

범위 — 이 부는 keycloak-session-store 프로젝트와 주제가 겹친다. 원본 제목이 「세션 저장소 실험대 — 개념 사전」이고, §314~§321(Infinispan·JGroups)과 §322~§330(Prometheus)은 그 프로젝트의 SSOT 가 측정으로 더 깊이 다루는 영역이다. 그래도 여기서 빼지 않는다 — 이 문서는 docs/virtualization/source/ 로 반입된 것이고, 빼면 source/ 를 지우는 순간 사라진다. 두 프로젝트를 견줄 때는 저쪽이 측정이고 이쪽이 정의라는 것을 먼저 본다.

원본 안의 링크는 대부분 죽어 있다(observed). reverse-proxy-headers.md, experiment-00-session-replication.md, docs/experiment-plan.md, deploy/lab/k8s/echo.yaml, docs/evidence/keycloak-multinode-cluster/01-cluster-formed.txt 같은 대상은 source/ 에 반입되지 않았다. 링크 문자열을 고치지 않고 그대로 두었다 — 원본이 그 이름으로 가리켰다는 사실이 근거이고, 고치면 heading 이 아닌 줄을 바꾸는 것이 된다. 그 파일들의 원문은 이 저장소에 없다(unknown).

develop-keycloak-session-store 작업에서 등장하는 개념을 누적 기록한다. 대화는 흘러가지만 이 문서는 남는다.

모든 항목은 네 가지를 갖춘다.

  1. 무엇인가 — 정의
  2. 왜 여기 나오나 — 이 실험대에서 맡은 역할
  3. 없거나 틀리면 — 실제로 관찰되는 실패 양상
  4. 확인 — 상태를 직접 볼 수 있는 명령

개념이 새로 나올 때마다 해당 층에 추가한다. 층은 아래에서 위로 쌓인다.

  • 1층 가상화 — VM을 만드는 층
  • 2층 가상 네트워크 — VM끼리, VM과 호스트를 잇는 층
  • 3층 호스트 진입 — 브라우저가 들어오는 층
  • 4층 TLS — 그 진입을 암호화하는 층
  • 5층 k3s — VM 안에서 컨테이너를 굴리는 층
  • 6층 Arch 특이사항 — 배포판 때문에 달라지는 것
  • 7층 git — 저장소 운영

212. "이건 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% 동일하다.


213. 왜 호스트에 직접 깔지 않고 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보다 관측자 분리가 더 값지다고 판단했다.


214. 전체 구조 한눈에 보기

개별 개념을 읽기 전에 이 그림을 먼저 본다. 가장 자주 오해하는 지점은 시드 ISO를 OS 이미지로 착각하는 것이다. 시드는 OS가 아니라 설정 데이터만 담은 370KB짜리 별도 디스크다.

215. 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는 하나면 된다.

216. 설정 파일이 게스트에 도달하는 경로

  kc-lab-1.yaml              meta-kc-lab-1
  (사람이 편집)               (instance-id · local-hostname)
        │                          │
        └──────────┬───────────────┘
                   │
                   │  ①  xorrisofs -volid CIDATA -rock -graft-points
                   │        /user-data=kc-lab-1.yaml     ← ISO 안에서 이름이 바뀐다
                   │        /meta-data=meta-kc-lab-1
                   ▼
        seed-kc-lab-1.iso          내부: /user-data · /meta-data
        (볼륨 레이블 = CIDATA)      두 이름이 정확해야 인식된다
                   │
                   │  ②  virsh vol-create-as   자리를 잡고
                   │  ③  virsh vol-upload      내용을 붓는다
                   ▼
        /var/lib/libvirt/images/seed-kc-lab-1.iso
        (홈은 700 이라 qemu 가 못 읽는다 — 그래서 풀에 둔다)
                   │
                   │  virt-install --disk vol=default/seed-kc-lab-1.iso,
                   │                      device=disk,bus=virtio,readonly=on
                   ▼
        게스트의  vdb              LABEL=CIDATA · iso9660

같은 내용이 세 곳에 존재한다 — 원본 YAML, 구워진 ISO, 풀에 올라간 볼륨. 원본만 고치면 VM 에 반영되지 않는다. 셋을 한 번에 맞추는 것이 deploy/lab/scripts/rebuild-seed.sh 다.

217. 부팅할 때 일어나는 일

  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 으로 로그인 프롬프트만 봐도 즉시 판정할 수 있다.

218. 실험대 전체 배치 (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 → Traefik2홉 구조가 운영(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 규칙이 없다는 뜻이다. 여기서 502connection refused가 나오면 체인 어딘가가 끊긴 것이다.


219. 1층. 가상화

220. 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에서 꺼진 것

221. 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 소속이어야 함

222. QEMU

무엇인가 — 장치 에뮬레이터. 가상 디스크 컨트롤러, NIC, 시리얼 포트, 그래픽 어댑터를 소프트웨어로 만들어낸다. -accel kvm 옵션으로 CPU/메모리 부분만 KVM에 위임한다.

왜 여기 나오나 — VM 하나는 실제로는 호스트에서 도는 QEMU 프로세스 하나다. ps로 보면 보인다. 이 사실을 알면 "VM 메모리 3584M"이 호스트 입장에선 그냥 프로세스 RSS라는 게 납득되고, 7.4Gi 예산 계산이 직관적으로 이해된다.

확인

ps aux | grep qemu-system-x86_64   # VM 하나당 프로세스 하나

223. 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의 실제 정의

224. 연결 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는 기본적으로 환경 변수를 물려주지 않으므로, ~/.zshrcLIBVIRT_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 가 반영되는 곳)

225. 보조 그룹과 재로그인

무엇인가usermod -aG libvirt $USER/etc/group 파일을 수정한다. 그런데 프로세스의 그룹 목록은 로그인 시점에 한 번 읽혀서 고정되고, 이미 떠 있는 셸에는 소급 적용되지 않는다.

왜 여기 나오나usermod 직후 같은 터미널에서 virsh -c qemu:///system을 치면 권한 거부가 날 수 있다. 명령이 잘못된 게 아니라 셸이 옛날 그룹 목록을 들고 있는 것이다. 새 SSH 세션이나 재로그인이면 정상 동작한다.

확인

id                    # 현재 셸이 들고 있는 그룹 (여기 libvirt 가 보여야 함)
getent group libvirt  # /etc/group 의 실제 내용 (이쪽엔 바로 반영됨)

두 결과가 다르면 재로그인이 필요하다는 뜻이다. 급하면 newgrp libvirt로 해당 셸만 갱신할 수 있다.

226. 멱등성과 && 단축 평가

무엇인가 — 멱등(idempotent)한 명령은 여러 번 실행해도 결과가 같다. libvirt 명령 중에는 그렇지 않은 것이 있다.

명령 이미 그 상태일 때 멱등한가
virsh net-start default error: network is already active 아니오
virsh net-autostart default 조용히 성공

왜 여기 나오나A && BA가 성공했을 때만 B를 실행한다. 그래서 net-start && net-autostart를 두 번째로 실행하면 net-start가 "이미 active"로 실패하면서 net-autostart아예 실행되지 않는다. 오류 메시지만 보면 둘 다 실패한 것처럼 보이지만, 실제로는 앞선 실행에서 이미 목적을 달성한 상태다.

다시 실행해도 안전한 형태&& 대신 ;를 쓰고 실패를 삼킨다.

virsh net-start default 2>/dev/null; virsh net-autostart default

227. 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   # 여기서 데몬이 자동 기동됨

228. 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:" 줄이 원본을 가리켜야 정상

229. 왜 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 남짓이다.

230. 디스크 이미지를 "복사한다"는 것의 실제 원리

앞 항목의 "완성된 디스크를 파일로 떠서 배포한다"가 물리적으로 어떻게 가능한지를 아래에서 단계적으로 푼다.

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이 첫 부팅에 채우는 이유가 바로 이것이다. 앞 항목의 "빈칸" 표와 여기가 연결된다.

231. qcow2 파일 내부는 어떻게 생겼나 — 매핑표가 전부다

앞의 「디스크 이미지를 "복사한다"는 것의 실제 원리」가 raw 를 설명했다. 여기서는 raw 에 무엇을 더하면 qcow2 가 되는지를 푼다.

출발점 — 디스크는 섹터가 0번부터 늘어선 1차원 배열이고, 그 배열을 그대로 파일에 쓰면 raw 다. 20GB 디스크는 20GB 파일이 된다. 안 쓴 구간까지 0으로 가득 채워서 기록하기 때문이다.

qcow2 가 더하는 것은 하나 — 「가상 디스크의 이 위치가 파일 안의 어디에 있는가」를 적어 둔 매핑표다. 안 쓴 구간은 아예 기록하지 않고 매핑표에도 안 적는다.

가상 디스크 20GB                       실제 파일 1.4GB
  0 ~ 64KB      ── 매핑 ──▶  파일 오프셋 0x50000
  64 ~ 128KB    ── 없음 ──▶  기록 안 함. 읽으면 0 을 만들어 돌려줌
  128 ~ 192KB   ── 매핑 ──▶  파일 오프셋 0x60000
       ⋮

★ 매핑표만 담는 것이 아니다. 표는 같은 파일 안의 오프셋을 가리키고, 가리켜진 실제 데이터 클러스터도 그 파일 안에 함께 들어 있다. disk size 가 335MiB 인 것이 그 증거다 — 표만이라면 수십 KB 로 끝난다. 그리고 표에 적히는 값은 호스트 물리 디스크의 주소가 아니라 파일 안의 몇 번째 바이트다. 그래서 파일을 다른 기계로 통째로 옮겨도 표가 그대로 유효하다. 파일 밖을 가리키는 것은 백킹 파일 경로 하나뿐이고, 그래서 그것만 따로 챙겨야 한다.

클러스터 — 매핑의 최소 단위

섹터(512B) 하나하나를 매핑하면 표가 너무 커진다. 그래서 클러스터라는 덩어리 단위로 끊는다. 기본값은 64KB 다.

qemu-img info /var/lib/libvirt/images/base.qcow2

실측

image: /var/lib/libvirt/images/base.qcow2
file format: qcow2
virtual size: 3 GiB (3221225472 bytes)
disk size: 335 MiB
cluster_size: 65536

어디를 봐야 하는가virtual size(게스트가 보는 크기)와 disk size (파일이 실제로 차지하는 크기)의 차이, 그리고 cluster_size: 65536. 둘의 차이가 곧 "안 쓴 구간"이다.

★ 클러스터는 물리 디스크와 무관하다. qcow2 파일 안에서만 쓰는 논리 단위다. 「블록 크기」가 층마다 따로 있어서 헷갈리기 쉽다.

단위 이름 크기 누가 정하나
물리 SSD/HDD 섹터(물리) 512B / 4096B 하드웨어
호스트 파일시스템 블록 보통 4KB 호스트 mkfs
qcow2 파일 클러스터 64KB (기본) qemu-img create
게스트가 보는 디스크 섹터(논리) 512B QEMU 가 흉내냄
게스트 파일시스템 블록 보통 4KB 게스트 mkfs

다섯 층의 크기가 서로 달라도 상관없다. 각 층이 자기 위층을 자기 단위로 쪼개 담을 뿐이다. 그리고 FAT·NTFS 도 할당 단위를 "클러스터"라고 부른다 — 같은 단어, 다른 층이다.

2단계 매핑 — L1 → L2 → 데이터

매핑표를 한 장으로 만들면 20GB 디스크에 대해 표만 수 MB 가 된다. 대부분이 비어 있는데도 항상 들고 있어야 한다. 그래서 두 단계로 나눈다.

게스트가 읽으려는 위치
        │
        ├─ 상위 비트 ──▶ [ L1 표 ] ──▶ L2 표가 있는 클러스터 위치
        │                                    │
        ├─ 중간 비트 ──────────────────────▶ [ L2 표 ] ──▶ 데이터 클러스터 위치
        │                                                        │
        └─ 하위 16비트 ────────────────────────────────────────▶ 그 안의 몇 번째 바이트

클러스터가 64KB(2^16)이고 표 항목이 8바이트이므로, L2 표 하나에 항목이 65536 / 8 = 8192(2^13)개 들어간다. 그래서 게스트 오프셋을 이렇게 자른다.

비트 쓰임
하위 16비트 클러스터 안에서의 위치
그다음 13비트 L2 표에서 몇 번째 항목인가
그 위 전부 L1 표에서 몇 번째 항목인가

운영체제의 페이지 테이블과 같은 구조다. 필요한 L2 표만 만들면 되므로, 안 쓴 영역은 L1 항목이 0 인 채로 끝난다.

항목이 0 이면 무슨 일이 생기나

여기가 오버레이의 핵심이다.

L2 항목 바닥(backing file) 이 결과
값이 있음 (무관) 이 파일의 그 위치를 읽는다
0 없음 0 으로 채운 64KB 를 만들어 돌려준다
0 있음 바닥 파일의 같은 위치를 읽는다 ← 오버레이

그래서 kc-lab-1.qcow2자기가 바꾼 클러스터만 들고 있고, 나머지는 전부 base.qcow2 를 본다. 20GB 를 선언해도 1.4GB 인 이유가 이것이다.

★ 바닥 경로는 문자열로 박혀 있다. 헤더에 backing_file_offset 이 있고 거기에 경로가 문자열로 들어간다. 바닥을 옮기거나 이름을 바꾸면 게스트가 부팅하지 못한다. 오버레이만 다른 기계로 복사하면 안 되는 이유다.

qemu-img info kc-lab-1.qcow2 | grep "backing file"

refcount — 스냅샷과 copy-on-write 가 되는 이유

qcow2 는 클러스터마다 참조 횟수(refcount) 를 따로 관리한다.

refcount = 1  →  나만 쓰는 클러스터. 그냥 덮어쓴다
refcount > 1  →  스냅샷과 공유 중. 쓰기 전에 복사본을 만들고 거기에 쓴다

이것이 copy-on-write 다. virsh snapshot-create-as 로 스냅샷을 뜨면 데이터를 복사하는 것이 아니라 refcount 만 올린다. 그래서 스냅샷이 순식간에 찍히고, 그 뒤로 바뀌는 부분만 용량을 먹는다.

파일 맨 앞에는 헤더가 있다

┌──────────┬────────────┬──────────┬─────────────┬──────────────┐
│  헤더     │ refcount 표 │  L1 표   │  L2 표들     │ 데이터 클러스터 │
└──────────┴────────────┴──────────┴─────────────┴──────────────┘

헤더에 들어 있는 것 — 매직값 QFI\xfb, 버전, cluster_bits(64KB 면 16), 가상 디스크 크기, l1_table_offset, refcount_table_offset, backing_file_offset.

섹터 하나하나에는 무엇이 적혀 있나 — 데이터 클러스터 안은 그냥 바이트다. 의미는 위치가 정한다.

섹터 0        MBR/GPT      "파티션 1 은 2048번 섹터부터"
섹터 2048~    슈퍼블록      "블록 크기 4KB, inode 테이블은 여기부터"
그 뒤         inode 테이블  파일마다 "크기·권한·데이터가 몇 번 블록에"
그 뒤         데이터 블록   실제 파일 내용

디스크 바깥에 보관되는 정보가 없다. 그래서 배열을 통째로 옮기면 똑같이 부팅한다. qcow2 는 그 배열을 어떻게 파일에 담을지만 정할 뿐, 안에 무엇이 적히는지에는 관여하지 않는다.

매직값 덕에 포맷을 알아본다. qemu-img infofile format: raw 로 읽으면 그 파일은 qcow2 가 아니다 — 01 에서 base 이미지를 받다가 끊겨 HTML 오류 페이지를 저장했을 때 정확히 그렇게 나온다.

압축 — 배포용 이미지는 실제로 압축돼 있다

qcow2 는 클러스터 단위 zlib 압축을 지원한다. 배포용 클라우드 이미지는 그것을 켜서 만든다. 「희소해서 작다」만으로는 설명이 안 되는 부분이 여기다.

qemu-img map --output=json /var/lib/libvirt/images/base.qcow2

실측 — Debian 12 genericcloud

{'start': 0,       'length': 65536,  'data': True,  'compressed': True}   ← 압축된 클러스터
{'start': 65536,   'length': 983040, 'data': False, 'zero': True}         ← 구멍
{'start': 1048576, 'length': 65536,  'data': True,  'compressed': True}

compressed 구간: 606개 / 전체 1236개

어디를 봐야 하는가compressed: True 항목이 있는가. 그리고 data: False, zero: True 항목(구멍)과 구분되는가.

세 가지가 겹쳐서 3 GiB 가 324 MiB 가 된다.

이유 이 이미지에서
① 안 쓴 공간을 안 적음 (희소) 3 GiB 중 2.01 GiB 가 구멍
② 쓴 부분을 zlib 압축 남은 1010 MiB → 324 MiB
③ genericcloud 자체가 작음 GUI·문서·물리 하드웨어 드라이버 없음

압축 클러스터는 읽기 전용에 가깝다. 읽을 때 자동으로 풀리지만, 게스트가 그 클러스터에 쓰면 압축하지 않은 형태로 새로 할당한다. 그래서 오버레이 (kc-lab-1.qcow2)에 쌓이는 것은 압축되지 않은 클러스터다. 바닥은 작은데 오버레이가 상대적으로 커 보이는 이유 중 하나다.

압축을 직접 걸려면 qemu-img convert -c 를 쓴다. 다만 쓰기가 잦은 디스크에 쓰지 않는다 — 매 쓰기마다 재압축이 아니라 비압축 클러스터 할당으로 흩어져 파편화된다.

backing chain — Docker 의 레이어 쌓기에 해당하는 것

체인은 여러 겹이 될 수 있다. Docker 가 레이어를 쌓는 것과 같은 구조다.

base.qcow2  ←  k3s-golden.qcow2  ←  kc-lab-1.qcow2
 (배포본)        (k3s 설치까지)        (실험 중 변경분)
qemu-img info --backing-chain kc-lab-1.qcow2   # 바닥까지 사슬 전체

Docker 와 쓰임이 다르다.

Docker qcow2 backing chain
언제 쌓나 빌드 시점에 의도적으로 주로 런타임 파생
층의 정체성 레이어마다 다이제스트 경로 문자열
배포 단위 레이어 tar 여러 개 + 매니페스트 파일들 (바닥까지 전부 필요)
층이 깊어지면 읽기 성능 영향 적음 읽을 때마다 사슬을 거슬러 올라간다

Docker 이미지도 「하나의 파일」이 아니다. 레지스트리에는 레이어 blob 이 따로 있고 매니페스트가 묶는다. docker save 로 tar 하나로 뭉칠 수는 있지만, 그건 배포 형태가 아니라 내보내기 형태다.

★ 체인이 깊으면 읽기가 느려진다. 클러스터가 어느 층에 있는지 찾으려면 L2 항목이 0 일 때마다 한 층 아래로 내려가야 한다. 실험대에서 층을 두세 겹 넘게 쌓지 않는 이유다. 굳히려면 qemu-img commit(아래층에 병합)이나 qemu-img convert(단일 파일로 평탄화)를 쓴다.

압축되는 내용은 「그 위치의 바이트」일 뿐이다

클러스터 하나(64KB)를 통째로 zlib 압축해서 저장한다. 안에 파일시스템 메타데이터가 들었는지 파일 내용이 들었는지는 보지 않는다.

L2 항목에 세 가지가 들어간다.

[압축 플래그] [파일 안 오프셋] [압축된 바이트 길이]

읽을 때 그 범위를 읽어 풀면 64KB 가 나온다. 압축 단위가 클러스터이므로 1바이트를 읽어도 그 클러스터 전체를 풀어야 한다.

base 이미지는 만드는 것이 아니라 받는 것이다

여기가 헷갈리기 쉽다. qemu-img 로 base 를 만들지 않는다.

# 바닥 — 받는다. 이미 압축된 qcow2 로 온다
curl -fL --output base.qcow2 \
  https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2

# 오버레이 — 만든다. 즉시 끝나고 몇 KB 다
qemu-img create -f qcow2 -b base.qcow2 -F qcow2 kc-lab-1.qcow2 20G
무엇 어떻게
base.qcow2 Debian 이 배포하는 설치 끝난 디스크 내려받는다
kc-lab-1.qcow2 빈 껍데기 + base 를 가리키는 포인터 qemu-img create -b

★ 받은 파일은 ISO 가 아니다. ISO 는 설치 미디어이고, 이것은 설치가 끝난 디스크다. 그래서 부팅하면 설치 마법사가 아니라 곧바로 로그인 프롬프트가 뜬다. 시드 ISO(seed-kc-lab-1.iso)만이 진짜 ISO 인데, 그것도 운영체제가 아니라 cloud-init 설정 파일 두 개를 담은 데이터 볼륨이다.

★ 압축도 우리가 한 것이 아니다. Debian 이 배포 시점에 압축해서 올린다. qemu-img convert -c 를 돌린 적이 없는데도 compressed: True 가 나오는 이유다. 그래서 「받아서 압축한다」가 아니라 「압축된 것을 받는다」가 맞다.

게스트의 변경사항은 이미 오버레이에 들어 있다

「작업이 끝나면 이미지로 만든다」가 아니다. 게스트가 디스크에 쓰는 순간 QEMU 가 그 클러스터를 오버레이에 할당해 기록한다. kc-lab-1.qcow2그 자체로 변경사항 파일이다. 실시간으로.

그래서 「VM 을 이미지로 뜬다」는 별도 작업이 없다. 필요한 것은 그 파일을 재사용 가능한 바닥으로 굳히는 작업이고, 그건 다른 일이다.

virsh shutdown kc-lab-1        # 반드시 끄고. 켠 채로 복사하면 파일시스템이 깨진 상태로 굳는다
virt-sysprep -a /var/lib/libvirt/images/kc-lab-1.qcow2

virt-sysprep 이 지우는 것 — hostname, machine-id, SSH 호스트키, 로그, cloud-init 실행 상태, 셸 히스토리.

안 하면 무슨 일이 생기나 — 그 이미지로 만든 게스트들이 전부 같은 machine-id 와 같은 SSH 호스트키를 갖는다. DHCP 가 같은 클라이언트로 오인해 IP 를 하나만 주거나, SSH 가 호스트키 충돌로 경고를 뱉는다. 그리고 cloud-init 이 「이미 실행됨」으로 표시돼 있어서 새 게스트에서 아예 돌지 않는다 — 증상은 「호스트명이 안 바뀐다」로 나타난다.

오버레이를 쌓는 법

# ① base 위에 골든을 만든다
qemu-img create -f qcow2 \
  -b /var/lib/libvirt/images/base.qcow2 -F qcow2 \
  /var/lib/libvirt/images/k3s-golden.qcow2 20G
#   → 이 디스크로 VM 을 띄워 k3s 설치 → shutdown → virt-sysprep

# ② 골든 위에 게스트를 만든다
qemu-img create -f qcow2 \
  -b /var/lib/libvirt/images/k3s-golden.qcow2 -F qcow2 \
  /var/lib/libvirt/images/kc-lab-1.qcow2 20G
옵션
-f qcow2 만들 파일의 포맷
-b backing file (바닥)
-F qcow2 바닥의 포맷. 생략하면 거부된다 — 포맷 자동 추측은 보안 문제라 막혀 있다
20G 가상 크기. 바닥보다 작으면 안 된다

virt-install --disk size=20,backing_store=... 가 내부적으로 이것을 부른다. 직접 칠 일은 골든을 만들거나 오버레이만 초기화할 때다.

★ 바닥은 절대 수정하지 않는다. 오버레이는 「바닥이 그대로」를 전제로 변경분만 들고 있다. 바닥을 고치면 그 위 게스트가 전부 깨진다. 골든을 갱신할 때는 수정이 아니라 새 파일을 만들고 새 게스트부터 그것을 쓰게 한다.

★ 경로는 절대경로로 준다. 헤더에 문자열로 박히므로 상대경로면 작업 디렉터리가 바뀌는 순간 못 찾는다.

확인

qemu-img info --backing-chain kc-lab-1.qcow2   # 지우기·고치기 전 항상 이것부터

사슬을 끊는 두 가지 방법

골든을 정리하고 싶은데 오버레이가 물려 있을 때 쓴다.

명령 무엇을 하나 결과
qemu-img commit <오버레이> 오버레이의 변경분을 바닥에 병합 바닥이 바뀐다. 다른 오버레이가 있으면 그것들이 깨진다
qemu-img convert -O qcow2 <오버레이> <새파일> 사슬 전체를 읽어 단일 파일로 평탄화 바닥과 무관해진다. 용량은 늘어난다

옮길 때는 convert 가 안전하다. 다른 기계로 게스트를 보낼 때 오버레이만 복사하면 바닥이 없어 부팅하지 못한다. 평탄화하면 파일 하나로 완결된다.

qemu-img convert -O qcow2 -c kc-lab-1.qcow2 kc-lab-1-standalone.qcow2

-c 를 붙이면 압축까지 해서 옮기기 좋아진다 — 배포용 base 이미지가 그렇게 만들어진다.

raw 와의 비교

raw qcow2
구조 섹터 배열 그대로 헤더 + 매핑표 + 데이터
20GB 선언 시 파일 20GB 쓴 만큼만
backing file 없음 있음 → 오버레이
내부 스냅샷 없음 있음 (refcount)
읽기 성능 매핑이 없어 약간 빠름 매핑 조회가 한 번 더

이 실험대는 게스트 디스크에 qcow2, 시드 ISO 에 raw 를 쓴다. 시드가 raw 라서 내부 스냅샷이 거부된다 — 위 「그래서 마이그레이션과 스냅샷이 된다」 참고.

확인

qemu-img info <파일>                      # 포맷·크기·cluster_size·backing file
qemu-img check <파일>                     # 매핑표와 refcount 정합성 검사
qemu-img map --output=json <파일> | head  # 어느 구간이 실제로 할당됐는지
qemu-img info --backing-chain <파일>      # 바닥까지 사슬 전체

232. qemu-imgqemu-system-x86_64 는 다른 도구다

무엇인가 — 이름이 비슷해서 헷갈리는데 하는 일이 완전히 다르다.

도구 무엇을 하나 VM 을 돌리나
qemu-img 디스크 이미지 파일을 만들고·보고·변환한다 아니다
qemu-system-x86_64 가상 머신을 실행한다 그렇다

qemu-img 는 파일만 만진다. VM 이 꺼져 있어도 돌고, 애초에 VM 이 존재하지 않아도 된다.

qemu-img info  base.qcow2                       # 포맷·크기·backing file 보기
qemu-img create -f qcow2 -b base.qcow2 -F qcow2 new.qcow2 20G   # 오버레이 만들기
qemu-img convert -O raw disk.qcow2 disk.raw     # 포맷 변환

왜 여기 나오나 — 01 의 virt-install --disk size=20,backing_store=... 이 내부적으로 qemu-img create 를 부른다. 게스트를 만들지 않고 디스크만 손보고 싶을 때(골든 이미지, 오버레이 재생성) 이 도구를 직접 쓴다.

확인

qemu-img info /var/lib/libvirt/images/base.qcow2 | head -5

backing file: 줄이 없으면 바닥 이미지고, 있으면 오버레이다.

233. 오버레이는 Docker 레이어와 같은 아이디어다

무엇인가 — 둘 다 copy-on-write다. 바닥은 읽기 전용으로 공유하고 변경분만 새 층에 쌓는다.

qcow2 오버레이 Docker
바닥 base.qcow2 (읽기 전용) base image layer
변경분 kc-lab-1.qcow2 container writable layer
층을 잇는 것 backing file 포인터 레이어 스택
담는 범위 커널 포함 디스크 전체 파일시스템만 (커널은 호스트 공유)
이식 단위 .qcow2 파일 하나 이미지 + 볼륨
전형적 크기 수백 MB ~ 수 GB 수십 MB ~ 수백 MB

결정적 차이는 「담는 범위」 한 줄이다. 컨테이너는 호스트 커널을 빌려 쓰므로 커널을 담지 않는다. VM 은 자기 커널을 들고 있어서 커널 수준 실험이 된다 — 이 실험대가 컨테이너 대신 VM 을 고른 이유다(tc 지연 주입, conntrack 조작, 진짜 노드 상실).

실측 — 20GB 를 선언한 게스트 두 대의 실제 사용량

base.qcow2       335 MiB     virtual size 3 GiB
kc-lab-1.qcow2   1.4 GiB     ← 선언 20GB
kc-lab-2.qcow2   665 MiB     ← 선언 20GB

왜 여기 나오나 — 「20GB 짜리를 두 개 만들면 40GB 를 쓰나」의 답이다. 안 쓴다. 바닥 335MB 한 벌을 공유하고 변경분만 쌓는다.

234. 그래서 마이그레이션과 스냅샷이 된다

디스크가 파일 하나이므로 복사가 곧 이관이다.

하고 싶은 것 방법
다른 기계로 옮기기 .qcow2 를 복사 + 도메인 XML(virsh dumpxml)을 복사
상태를 찍어두고 되돌리기 virsh snapshot-create-as / snapshot-revert
깨끗한 상태로 초기화 오버레이를 지우고 qemu-img create -b base 로 다시
「설치 끝난 상태」를 굳히기 virt-sysprep 으로 고유값 제거 후 새 backing file 로

★ 오버레이를 옮길 때는 바닥도 같이 옮긴다. backing file경로를 문자열로 들고 있어서, 바닥이 없거나 경로가 다르면 게스트가 부팅하지 못한다. 확인은 qemu-img info.

★ 시드 ISO 가 raw 라 내부 스냅샷이 거부된다. 이 실험대의 게스트는 vda(qcow2 오버레이) + vdb(raw 시드 ISO) 두 디스크다. qcow2 내부 스냅샷은 모든 디스크가 qcow2 여야 해서 그냥 치면 Disk 'vdb' does not support snapshotting 이 난다. 빼 주어야 한다.

virsh snapshot-create-as kc-lab-2 clean-k3s \
  --diskspec vda,snapshot=internal --diskspec vdb,snapshot=no

확인

qemu-img info kc-lab-1.qcow2 | grep -E "backing file|disk size|virtual size"
virsh snapshot-list kc-lab-2

235. 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가 쉬웠던 이유는 이 모든 것을 감춰줬기 때문이고, 그래서 세부를 배울 기회도 없었다. 지금 개념이 쏟아지는 이유가 이것이다.

236. 클라우드 이미지와 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 명세가 금지한다. 반드시 스페이스여야 한다. 에디터가 탭을 넣도록 설정돼 있으면 파일 전체가 파싱 실패한다. 눈으로는 구분이 안 되므로 다음으로 확인한다.

    grep -Pn '\t' user-data.yaml    # 아무것도 안 나와야 정상
    
  2. 리스트 항목의 하위 키는 - 다음 컬럼에 맞춰 더 들여쓴다.

    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을 타므로 영향받지 않는다.

users:
  - name: donghyeon
    lock_passwd: false
    plain_text_passwd: __CONSOLE_PW__    # 콘솔 전용 비상구
    ...
ssh_pwauth: false                 # SSH 비밀번호 인증은 계속 차단

237. 확정된 함정: --cloud-init + Debian genericcloud 조합은 동작하지 않는다

증상 — VM은 정상 부팅하는데 cloud-init이 아무것도 적용하지 않는다. hostname이 localhost 그대로이고, 사용자가 생성되지 않아 Permission denied (publickey)로 SSH가 거부된다. 오류 메시지가 어디에도 남지 않는다.

원인 — 두 가지가 겹친다.

  1. virt-install --cloud-init은 시드 ISO를 SATA CD-ROM으로 붙인다 (<target dev='sda' bus='sata'/>).
  2. Debian genericcloud 변종은 크기를 줄이려고 물리 하드웨어 드라이버를 제외한 이미지다. virtio 계열만 들어 있어 AHCI/SATA 장치를 보지 못한다.

그래서 게스트 입장에서 시드 ISO는 존재하지 않는 장치다. cloud-init은 cidata 레이블을 가진 블록 장치를 찾지 못하고 데이터소스 없이 조용히 종료한다.

해결 — 시드를 virtio 디스크로 붙인다. NoCloud 데이터소스는 CD-ROM을 요구하지 않는다. 레이블이 cidata인 블록 장치면 무엇이든 된다. ISO 파일을 그대로 virtio 디스크로 붙이면 게스트에 vdb로 보이고 정상 인식된다.

# 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

238. 시드 ISO 를 굽는 세 명령이 각각 하는 일

xorrisofs -quiet -output seed-kc-lab-1.iso -volid CIDATA -joliet -rock \
  -graft-points /user-data=kc-lab-1.yaml /meta-data=meta-kc-lab-1

virsh vol-create-as default seed-kc-lab-1.iso "$(stat -c%s seed-kc-lab-1.iso)" --format raw
virsh vol-upload --pool default seed-kc-lab-1.iso seed-kc-lab-1.iso

① ISO 를 굽고 ② 풀에 자리를 잡고 ③ 그 자리에 내용을 붓는다.

    호스트 파일              ①  xorrisofs                구워진 ISO
 ┌──────────────────┐                              ┌──────────────────────┐
 │ kc-lab-1.yaml    │──── /user-data= ───────────▶ │ volid: CIDATA        │
 │ meta-kc-lab-1    │──── /meta-data= ───────────▶ │  ├─ /user-data       │
 └──────────────────┘      이름을 바꿔 담는다        │  └─ /meta-data       │
                           (-graft-points)          └──────────────────────┘
                                                              │
                                     ②  vol-create-as         │  크기를 미리 알려준다
                                        default 풀에 ┌────────┴────────┐
                                        빈 볼륨 선언  │  (빈 자리)       │
                                                     └────────┬────────┘
                                     ③  vol-upload            │  내용을 붓는다
                                                     ┌────────┴────────┐
                                                     │ 풀 안의 ISO      │
                                                     └────────┬────────┘
                                                              │
                                        virt-install --disk vol=default/...
                                                     bus=virtio, readonly=on
                                                              ▼
                                                     게스트의  vdb

②와 ③이 나뉘어 있는 이유 — libvirt 는 볼륨을 「선언」과 「기록」 두 단계로 다룬다. ②는 풀에 이름과 크기를 등록할 뿐 내용이 없고, ③이 로컬 파일의 바이트를 그 볼륨에 흘려 넣는다. 그래서 ②의 크기 인자가 실제 ISO 크기와 달라지면 ③에서 잘리거나 남는다.

xorrisofs — 옵션별로

옵션 역할 빠뜨리면
-quiet 진행 로그 억제 출력만 시끄러움
-output <파일> 만들 ISO 경로
-volid CIDATA 볼륨 레이블 cloud-init 이 장치를 못 찾는다
-joliet Joliet 확장 (긴 이름, 윈도우식)
-rock Rock Ridge 확장 (POSIX 이름·퍼미션) 파일명이 잘려 못 찾는다
-graft-points 뒤 인자를 ISO안경로=호스트경로 로 해석 이름을 바꿔 담을 수 없다

-volid CIDATA 가 왜 그 값이어야 하나 — cloud-init 의 NoCloud 데이터소스는 부팅 때 블록 장치를 훑으며 cidata 또는 CIDATA 레이블을 찾는다. 다른 레이블이면 그 장치를 아예 후보로 보지 않고, 오류 없이 데이터소스 없음으로 넘어간다. 증상은 「게스트가 localhost 로 뜨고 SSH 가 안 붙는다」 하나뿐이다.

-rock 이 왜 필요한가user-data 는 9자다. ISO9660 Level 1 의 이름 규칙은 8.3 이라 USER_DAT.;1 처럼 잘린다. NoCloud 는 정확히 user-data 를 찾으므로 잘린 이름으로는 인식하지 못한다. Rock Ridge 확장이 원래 이름을 보존한다. -joliet 도 같은 목적의 다른 확장이라 둘 다 걸어 둔다.

-graft-points 가 무엇을 바꾸나 — 이것이 없으면 xorrisofs 는 입력 파일을 basename 그대로 ISO 루트에 넣는다. kc-lab-1.yaml 이 ISO 안에서도 kc-lab-1.yaml 이 되어 NoCloud 가 못 찾는다. 그래서 예전에는 스테이징 디렉터리에 규정된 이름으로 복사해서 구웠다.

# 예전 방식 — 스테이징 디렉터리가 필요했다
mkdir -p seed-1
cp kc-lab-1.yaml seed-1/user-data
printf '...' > seed-1/meta-data
xorrisofs -output seed.iso -volid CIDATA -joliet -rock seed-1/user-data seed-1/meta-data

-graft-pointsISO 안 경로를 직접 지정하게 해준다.

/user-data=kc-lab-1.yaml
 └ ISO 안에서의 이름   └ 호스트의 파일

원본 이름을 그대로 두고 담을 수 있어 스테이징 디렉터리가 사라졌다. deploy/lab/scripts/rebuild-seed.sh 가 이 방식을 쓴다.

virsh vol-create-as — 풀에 빈 볼륨을 선언

default                         풀 이름
seed-kc-lab-1.iso               볼륨 이름
"$(stat -c%s seed-kc-lab-1.iso)"  크기(바이트)
--format raw                    ISO 는 raw 로 다룬다

크기를 미리 줘야 한다. libvirt 는 볼륨을 만들 때 크기를 요구하므로 stat -c%s 로 실제 ISO 크기를 읽어 넘긴다. 이 값이 실제와 다르면 ③에서 잘리거나 남는다.

virsh vol-upload — 그 볼륨에 내용을 써 넣는다

②는 자리만 잡고 ③이 붓는다. 두 인자의 뜻이 다르다.

virsh vol-upload --pool default  seed-kc-lab-1.iso   seed-kc-lab-1.iso
                                 └ 풀 안의 볼륨 이름  └ 로컬 파일 경로

왜 그냥 cp 로 옮기지 않나

두 가지 때문이다.

이유 내용
권한 /var/lib/libvirt/imagesroot 소유라 일반 사용자가 못 쓴다
읽기 홈에 두면 홈이 700 이라 qemu(libvirt-qemu 사용자)가 못 읽는다

virsh 가 libvirtd 를 통해 대신 쓰므로 sudo 없이 된다. 그리고 풀에 등록되어 virt-install --disk vol=default/seed-kc-lab-1.iso 로 참조할 수 있게 된다.

다시 구울 때는 볼륨을 먼저 지운다

같은 이름의 볼륨이 이미 있으면 vol-create-as 가 실패한다.

virsh vol-delete --pool default seed-kc-lab-1.iso 2>/dev/null || true

그리고 다시 구운 시드는 이미 떠 있는 게스트에 반영되지 않는다. cloud-init 은 per-instance 모듈을 instance-id 당 한 번만 돌린다. 그래서 meta-datainstance-id 에 타임스탬프를 넣어 새 인스턴스로 보이게 하고, 게스트를 새로 만들어야 효과가 있다.

확인

virsh vol-list default                                   # 풀에 올라갔나
virsh domblklist kc-lab-1                                # vdb 로 붙었나 (sda 아님)
ssh kc-lab-1 'lsblk -o NAME,LABEL,FSTYPE | grep -i cidata'   # 게스트가 레이블을 보나
ssh kc-lab-1 'cloud-init status'                         # done 인가

239. 시드 디렉터리 구조와 파일명 규칙

NoCloud 데이터소스는 ISO 루트에서 정확히 user-datameta-data라는 이름의 파일을 찾는다. kc-lab-1.yaml 같은 이름으로는 인식하지 못한다. 그리고 xorrisofs는 입력 파일을 basename 그대로 ISO 루트에 넣는다.

지금은 스테이징 디렉터리를 쓰지 않는다. -graft-points 로 ISO 안 이름을 직접 지정하는 방식으로 바꿨다 — 바로 위 「시드 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를 인식한다. 대신 이미지가 커진다.

240. 진단 도구: 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

241. 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 내용이나 모듈 실행 단계의 문제다. 원인 범위를 절반으로 줄여준다.

242. 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 기준 경로

243. --os-variant / osinfo

무엇인가 — 게스트 OS 종류를 libvirt에 알려주는 값. libvirt는 이걸로 적절한 가상 장치 모델(virtio 사용 여부, 디스크 버스, NIC 모델)을 고른다.

왜 여기 나오나 — 잘못 주거나 생략하면 성능이 크게 떨어진다. 예를 들어 virtio 대신 e1000 에뮬레이션 NIC이 붙으면 네트워크 처리량이 몇 배 나빠지고, 그러면 우리가 측정하려는 노드 간 지연이 오염된다.

확인

osinfo-query os | grep -i debian    # 사용 가능한 값 목록

244. 2층. 가상 네트워크

245. 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

virbr0DOWN으로 보이는 것은 정상이다 — 리눅스 브리지는 활성 포트가 하나도 붙어 있지 않으면 캐리어가 없는 것으로 간주되어 DOWN/NO-CARRIER로 표시된다. VM이 한 대라도 뜨면 그 VM의 vnetN 인터페이스가 브리지에 붙으면서 UP으로 바뀐다. IP(192.168.122.1/24)가 이미 할당되어 있다면 네트워크 정의 자체는 정상이다.

246. 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 목록

247. 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 클라이언트로 두면 된다

명령 분해

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>

부팅 시 실제로 일어나는 일

  1. VM 부팅 → 게스트 커널이 virtio NIC 인식 → DHCP 클라이언트가 DHCPDISCOVER를 브로드캐스트한다. 이 프레임의 출발지 MAC이 52:54:00:aa:bb:11이다.
  2. virbr0에 붙어 있는 dnsmasq가 수신하고, 예약 테이블에서 그 MAC을 찾는다.
  3. 매칭되면 동적 범위에서 아무 주소나 고르는 대신 192.168.122.11DHCPOFFER로 제시한다.
  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만 주고 네트워크를 시작하면 된다.

확인

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

248. --live --config

무엇인가 — libvirt의 변경 적용 범위 플래그. --live는 지금 실행 중인 객체에만, --config는 영구 정의에만 적용한다. 둘 다 줘야 "지금부터, 그리고 재부팅 후에도" 적용된다.

없거나 틀리면--config만 주면 지금은 반영이 안 되고, --live만 주면 재부팅 시 사라진다. 둘 다 "왜 적용이 안 되지"로 시간을 잡아먹는 대표적인 함정이다.

249. NAT vs 브리지 vs macvtap

모드 VM 주소 LAN에서 VM 접근 이 실험대에서
NAT (virbr0) 192.168.122.x (사설) 불가 (포워딩 필요) 채택
브리지 (br0) LAN에서 직접 IP 가능 WiFi라 불가
macvtap LAN에서 직접 IP 가능(호스트↔VM은 제외) WiFi라 불가

250. 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                          # 무선 인터페이스 정보

251. 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

이 설정은 실험용 사설망 게스트에만 쓴다. 호스트 키 검증을 끄는 것은 중간자 공격 탐지를 포기하는 것이므로, 실제 서버 대상으로는 절대 쓰지 않는다.

252. ~/.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'

253. /etc/hosts와 이름 해석 순서

무엇인가 — DNS에 물어보기 전에 먼저 참조하는 로컬 이름↔주소 매핑 파일. 조회 순서는 /etc/nsswitch.confhosts: 줄이 정하며, 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보다 정확한 확인이다.


254. 엣지를 물리 호스트에서 VM 으로 옮기면 무엇이 새로 필요해지나

무엇인가 — 같은 nginx 인데 사는 곳만 바꿨다. 원래는 물리 호스트가 tailnet 주소로 직접 듣고 게스트로 프록시했고, 지금은 엣지 게스트 kc-lab-edge(192.168.122.10) 가 듣는다.

전:  tailnet:443 ─▶ [호스트 nginx] ─────────────▶ Traefik(게스트 .11/.12)
후:  tailnet:443 ─▶ [호스트 커널 DNAT] ─▶ [엣지 nginx(.10)] ─▶ Traefik(.11/.12)

왜 여기 나오나L7 홉 수는 그대로 2홉이다. 늘어난 것은 커널이 하는 L4 전달 한 번뿐이라 헤더 계약(B-4)은 그대로 성립한다. 바꾼 이유는 성능이 아니라 더러워지는 층을 격리하는 것이다. nginx 설정·인증서·certbot·deploy 훅은 자주 갈아엎는 것들인데, 호스트에 있으면 초기화가 불가능하고 엣지 장애 실험(systemctl stop nginx)이 SSH 까지 위험하게 만든다.

그 대가로 새로 필요해진 것 — 아래 일곱 가지가 03 에 새로 생긴 단계들이다.

# 새로 필요해진 것 왜 전에는 없었나
1 nginx 설치 (03 의 0번) 호스트에는 이미 깔려 있었다. 새 게스트의 cloud-init 은 curl·nftables 만 깐다
2 DNAT (03 의 3번) 호스트가 직접 :443 을 들었으니 넘길 일이 없었다. 지금은 호스트에 리스너가 아예 없다
3 libvirt 방화벽에 구멍 호스트→게스트는 OUTPUT 경로라 필터를 안 탔다. 밖→게스트는 FORWARD 라 libvirt 의 guest_input 이 거절한다 → #nftables-는-앞-체인의-accept-로-뒤-체인의-reject-를-막지-못한다
4 SNAT 금지를 명시 호스트 nginx 가 직접 받을 때는 출발지가 그대로였다. L4 를 한 번 더 타면서 masquerade 를 붙이고 싶은 유혹이 생기는데, 붙이면 엣지가 모든 클라이언트를 192.168.122.1 로 본다
5 sites-available 관례 호스트는 Arch 라 그 디렉터리가 없어 nginx.confinclude 를 직접 넣었다. 게스트는 Debian 이라 기본으로 있다 — 운영과 같은 형태
6 nginx 버전 차이 Arch 1.30 vs Debian 12 의 1.22. http2 on; 지시어가 1.25.1 이상이라 게스트에서는 listen 443 ssl http2 형태로 써야 한다
7 certbot·인증서·갱신 훅이 게스트로 전부 호스트에 있었다. 지금은 nginx 옆에 있어야 한다 — 인증서를 읽는 것이 nginx 이기 때문이다

★ 3번과 4번이 이 이동의 본질이다. 나머지는 배포판이 달라서 생긴 잡무고, 이 둘은 경로가 OUTPUT 에서 FORWARD 로 바뀌었기 때문에 생긴 구조적 변화다. 「호스트가 게스트에 접속한다」와 「밖에서 게스트로 들어온다」는 커널이 보기에 완전히 다른 일이다.

없거나 틀리면

빠뜨린 것 증상
DNAT 밖에서 connection refused. 호스트에 리스너가 없다
libvirt 구멍 호스트 안에서는 404 인데 밖에서만 refused
SNAT 을 붙임 다 되는데 X-Forwarded-For 가 전부 192.168.122.1
인증서를 호스트에 둠 발급은 되는데 엣지 nginx 가 못 읽어 cannot load certificate

확인

ssh test-server 'curl -s -o /dev/null -w "%{http_code}\n" http://192.168.122.10/'   # 안쪽 경로
curl -s -o /dev/null -w "%{http_code}\n" http://100.83.212.4/                        # 바깥 경로

어디를 봐야 하는가두 값이 같은가. 안쪽만 404 이고 바깥이 실패하면 1~3번 중 하나가 빠진 것이다. 둘 다 404 면 경로는 완성이고, Ingress 가 없어서 Traefik 이 404 를 주는 정상 상태다.

255. nftables 는 앞 체인의 accept 로 뒤 체인의 reject 를 막지 못한다

무엇인가 — 같은 훅(예: forward)에 base 체인이 여럿 붙어 있으면 우선순위 순으로 전부 평가된다. 앞 체인에서 accept 가 나와도 그것은 「이 체인은 통과」라는 뜻이지 「평가 끝」이 아니다. 뒤 체인이 reject 하면 패킷은 죽는다. drop 만이 즉시 종결이다. iptables 와 다른 지점이다.

왜 여기 나오나 — 엣지 DNAT(3층)에서 정확히 이것에 걸렸다. libvirt 는 자기 테이블 ip libvirt_networkguest_input 체인을 이렇게 끝낸다.

oif "virbr0" ip daddr 192.168.122.0/24 ct state established,related accept
oif "virbr0" reject

게스트 대역으로 새로 들어오는 연결을 거절한다. 그래서 우리 테이블 lab_edgepriority filter - 10 으로 먼저 accept 를 놔도 소용이 없다. 구멍은 libvirt 체인 맨 앞에 뚫어야 한다.

sudo nft insert rule ip libvirt_network guest_input \
  oif virbr0 ip daddr 192.168.122.10 tcp dport '{80,443}' ct state new counter accept

insert 가 맨 앞, add 가 맨 뒤다. add 로 넣으면 reject 뒤라 아무 효과가 없다.

없거나 틀리면 — 증상이 헷갈리게 갈린다.

어디서 쳤나 결과
호스트에서 curl http://192.168.122.10 404 (정상) — OUTPUT 경로라 forward 를 안 탄다
밖에서 curl http://100.83.212.4 connection refused — reject 가 ICMP port-unreachable 을 돌려준다

「안에서는 되는데 밖에서만 안 된다」가 이 결함의 서명이다. 타임아웃이 아니라 즉시 거절이라는 점도 단서다 — 드롭이면 기다리다 죽는다.

이 규칙은 휘발성이다. libvirt 가 네트워크를 다시 세우면(호스트 재부팅, virsh net-start, libvirtd 재시작) guest_input 을 새로 쓰면서 날아간다. 그래서 유닛의 ExecStartPost 에 넣어 재적용되게 한다.

확인

sudo nft -a list chain ip libvirt_network guest_input   # 우리 규칙이 reject 위에 있는가
sudo nft list ruleset | grep -nE 'reject|drop'          # 어느 줄의 카운터가 오르는가

어디를 봐야 하는가reject 줄의 counter 값이다. 밖에서 몇 번 쳤는지와 숫자가 맞아떨어지면 범인이 확정된다. 이 실험대에서는 curl 4 번에 packets 4 bytes 240 이 찍혀 있었다.

256. 3층. 호스트 진입

257. 리버스 프록시와 upstream

무엇인가 — 클라이언트 요청을 대신 받아 뒤쪽 서버로 전달하는 서버. nginx의 upstream 블록은 뒤쪽 서버 여러 대를 하나의 논리 이름으로 묶는다. proxy_pass http://이름;으로 그 그룹을 가리키면 nginx가 요청을 분배한다.

왜 여기 나오나 — 지금 저장소의 deploy/reverse-proxy/nginx-keycloak.confproxy_pass http://keycloak:8080으로 단일 대상을 가리킨다. 멀티노드 실험을 하려면 반드시 upstream 형태로 바꿔야 한다.

258. 왜 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 자리다.

259. 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이 덮어쓰는지, 신뢰하는지, 이어붙이는지에 따라 결과가 갈린다. 가장 먼저 실측할 항목.

260. 스티키 세션

무엇인가 — 같은 클라이언트의 요청을 항상 같은 백엔드 노드로 보내는 것. nginx 오픈소스판에서는 ip_hash(클라이언트 IP 해시)나 hash <키> consistent로 구현한다.

왜 여기 나오나 — Keycloak은 로그인 진행 중에 "인증 세션"이라는 임시 상태를 만든다. 노드가 매 요청 바뀌면 그 상태를 다른 노드에서 가져와야 해서 느려진다(Infinispan이 라우팅해주므로 실패하지는 않는다). Keycloak 공식 권장은 AUTH_SESSION_ID 쿠키 기반 스티키다.

실험 설계상 의미 — 스티키를 껐다 켜면서 동작과 지연을 비교하는 것이 가장 값싼 멀티노드 관찰이다. 그래서 ip_hash 한 줄을 주석 스위치로 둔다.

주의ip_hash는 클라이언트 IP로 해시하는데, 브라우저 한 대로 실험하면 항상 같은 노드로만 가서 분산 자체가 관찰되지 않는다. AUTH_SESSION_ID 기반은 로그인 전에 쿠키가 없다는 반대 문제가 있다.

261. 진입점 자체가 죽으면 — 로드밸런서의 재귀 문제

호스트 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이 어떻게 반응하는지"는 그대로 관찰할 수 있다. 그것이 이 실험대가 다루는 범위다.

262. nginx -t

무엇인가 — 설정 파일 문법 검사. 실제로 적용하지 않고 파싱만 한다.

왜 여기 나오나systemctl reload nginx는 설정이 깨져 있으면 기존 프로세스까지 죽인다. nginx -t && systemctl reload nginx로 연결해서 검사를 통과했을 때만 reload하는 게 습관이 되어야 한다.


263. 4층. TLS

264. ACME

무엇인가 — Automatic Certificate Management Environment. 인증서 발급을 자동화하는 프로토콜(RFC 8555). Let's Encrypt가 대표 구현체이고, certbot·Caddy·acme.sh 등이 클라이언트다.

왜 여기 나오나 — 자체 서명 인증서를 쓰면 브라우저가 경고를 띄우고, 그 상태에서 관찰한 쿠키 동작은 신뢰할 수 없다. 실인증서가 있어야 Secure 쿠키·SameSite·HSTS가 운영과 동일하게 동작한다.

265. 도메인 검증: 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       # 갱신이 실제로 되는지 예행연습

266. DNS-01 은 언제 쓰는가 — 네 가지 경우

DNS-01 이 HTTP-01 보다 좋은 방식이 아니다. HTTP-01 이 못 쓰이는 자리에 쓰는 것이다. 공개 웹서버 한 대라면 HTTP-01 이 옳다 — 토큰도 DNS 연동도 필요 없고, DNS 공급자를 옮겨도 안 깨진다.

① 와일드카드가 필요할 때 — 선택이 아니라 규칙이다.

*.example.com그 도메인 아래 모든 이름에 대한 권한이다. 호스트 한 대에 파일을 놓는 것은 그 이름 하나를 통제한다는 증명밖에 안 된다. DNS 존의 TXT 레코드를 고칠 수 있다는 것은 도메인 전체를 통제한다는 증명이다. 증명의 급이 다르기 때문에 ACME 명세가 와일드카드를 DNS-01 로만 허용한다.

② 서버가 공개 인터넷에서 안 보일 때 — 내부망, VPN 뒤, 사설 IP, CGNAT 뒤. 이 실험대가 여기다. tailnet 주소 100.83.212.4100.64.0.0/10(CGNAT 예약 대역)이라 공개 인터넷에서 라우팅 자체가 안 된다. 방화벽을 여는 문제가 아니다 — 그 주소는 인터넷에 존재하지 않는다. Let's Encrypt 를 tailnet 에 초대할 방법도 없다.

③ 80 포트를 못 쓸 때 — 가정용 회선은 ISP 가 80 을 막는 경우가 흔하다. 이미 다른 서비스가 점유한 경우도 같다. 443 이 살아 있으면 TLS-ALPN-01 도 대안이 된다.

④ 인증서를 쓸 기계와 발급받는 기계가 다를 때 — 실무에서 이 이유가 가장 크다.

상황 HTTP-01 이 곤란한 이유
로드밸런서 뒤 N 대 검증 요청이 어느 대로 갈지 모른다. 전부가 같은 토큰에 답해야 하니 공유 스토리지나 challenge 전용 라우팅이 필요하다
CDN 뒤 오리진이 직접 응답할 수 없다
CI 에서 발급해 배포 서버가 아직 없어도 발급해야 한다
쿠버네티스 cert-manager Ingress 가 여럿이거나 클러스터가 내부망이면 solver 를 DNS-01 로 둔다

DNS-01 은 어디서 돌리든 상관없다. 인증서를 쓸 기계와 무관하게 발급된다.

값으로 치르는 것

내용
API 토큰이 서버에 있어야 한다 유출되면 도메인 전체의 DNS 를 조작당한다. 인증서 한 장보다 피해가 크다 — MX 를 바꿔 메일을 가로챌 수도 있다. 그래서 토큰은 존 하나 + DNS:Edit 으로 좁힌다. 계정 전역 API Key 를 쓰지 않는다
공급자에 묶인다 플러그인이 공급자마다 다르다. DNS 를 옮기면 재설정
느리다 TXT 레코드가 퍼질 때까지 기다려야 한다. certbot 기본 대기 10초, 느린 공급자는 더 준다
공급자가 API 를 안 주면 못 쓴다

한 줄 판단

와일드카드가 필요한가?              → 예: DNS-01 (다른 선택지 없음)
Let's Encrypt 가 내 서버에 HTTP 로 닿는가?  → 예: HTTP-01   아니오: DNS-01

이 실험대의 문서 두 개가 어긋나 있다. 위 「HTTP-01 vs DNS-01」이 「둘 다 DNS-01 을 가리킨다」로 결론냈는데, docs/guides/04-tls/README.mdcertbot certonly --webroot(HTTP-01)로 적혀 있고 전제도 「공개 DNS 에 이름 셋이 이 호스트를 가리켜야 한다」이다. 지금 DNS 는 tailnet 주소를 가리키므로 그 전제는 성립하지 않는다. 어느 쪽이 실제인지는 아래로 확인한다.

확인

sudo grep -H authenticator /etc/letsencrypt/renewal/*.conf   # webroot/standalone = HTTP-01
certbot plugins | grep -E '^\*'                              # 쓸 수 있는 검증 방식
sudo certbot renew --dry-run                                 # 갱신이 실제로 되는가
dig +short auth.hyeonworks.com                               # LE 가 올 수 있는 주소인가

267. 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에서만 신뢰 실패하는 골치아픈 증상이 난다.

268. 공개 DNS에 사설 IP를 넣는 것

무엇인가*.lab.example.com의 A 레코드로 192.168.0.200을 등록하는 것.

왜 안전한가 — DNS 레코드는 이름을 주소로 바꿔줄 뿐 접근 권한을 주지 않는다. 사설 대역(RFC 1918) 주소는 인터넷에서 라우팅되지 않으므로, 외부인이 그 이름을 조회해도 도달할 수 없다. 노출되는 정보는 "내부에 그런 IP를 쓴다" 정도다.

대안 — 각 클라이언트의 /etc/hosts에 넣기. 노출이 아예 없지만 기기마다 관리해야 한다. 집 밖에서 tailnet(100.83.212.4)으로 붙을 때는 어차피 /etc/hosts 덮어쓰기가 필요하다.


269. 5층. k3s

270. 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개

271. --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" 오류가 난다.

272. 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 부분이 자기 자신에게 다시 접속하는 꼴이 되고, 애초에 server 게스트에서는 sudo kubectl/etc/rancher/k3s/k3s.yaml을 자동으로 집으므로 kubeconfig가 따로 필요 없다. agent 게스트는 다르다 — 아래 「agent 노드에는 kubeconfig가 없다」를 본다.

리다이렉션은 셸이 명령보다 먼저 처리한다 — 자주 걸리는 함정이다.

sudo cat 원본 > ~/.kube/config
         └──┬──┘   └─────┬─────┘
            │            │
            │            └─ ① 셸이 먼저 이 파일을 연다 (현재 사용자 권한으로)
            └─ ② 그 다음에야 명령이 실행된다

그래서 ~/.kube 디렉터리가 없으면 cat이 시작되기도 전에 No such file or directory로 끝난다. >는 파일을 열 뿐 경로를 만들지 않는다. 같은 이유로, sudo를 붙였는데도 출력 파일 쓰기가 거부되는 현상이 생긴다 — sudocat에만 적용되고 >에는 적용되지 않기 때문이다. 그럴 때는 sudo tee를 쓴다.

echo 내용 | sudo tee /root/전용경로 > /dev/null

273. agent 노드에는 kubeconfig가 없다 — localhost:8080 오류

무엇인가 — kubeconfig는 kubectl에게 ① 어느 API 서버로 ② 어떤 CA로 검증하고 ③ 누구 자격으로 붙을지 알려주는 파일이다. kubectl은 이 셋을 스스로 알지 못한다.

왜 여기 나오나 — agent 노드(kc-lab-2)에도 kubectl 명령은 있다. 설치 스크립트가 심볼릭 링크를 만들기 때문이다.

/usr/local/bin/kubectl -> k3s

k3s는 단일 바이너리라 자기가 어떤 이름으로 불렸는지(argv[0])를 보고 동작을 바꾼다. 명령이 있다는 것과 붙을 곳이 있다는 것은 다르다.

없으면 무슨 일이 생기나 — agent에서 sudo kubectl get pods -A를 치면 이렇게 끝난다.

Get "http://localhost:8080/api?timeout=32s": dial tcp [::1]:8080: connect: connection refused

kubectl이 kubeconfig를 찾는 순서는 --kubeconfig 플래그 → $KUBECONFIG~/.kube/config이고, k3s 내장 kubectl은 여기에 /etc/rancher/k3s/k3s.yaml을 하나 더 본다. 넷 다 없으면 오류를 내지 않고 client-go에 하드코딩된 기본값 http://localhost:8080으로 넘어간다. 쿠버네티스 1.20 이전 API 서버가 평문으로 열던 레거시 포트인데 지금은 아무도 열지 않는다.

localhost:8080이 보이면 네트워크 문제가 아니라 설정이 없다는 뜻이다. 이 주소는 어디에도 적혀 있지 않다 — kubectl이 아무것도 못 찾았을 때만 나온다. 방화벽이나 k3s를 의심하기 전에 kubeconfig부터 본다.

오해 주의 — 「워커라서 파드가 안 보인다」가 아니다. kubectl은 그냥 HTTP 클라이언트라 어디서 실행하든 상관없다(노트북에서도 된다). 필요한 것은 주소와 자격증명뿐이고, kc-lab-1의 k3s.yaml을 kc-lab-2로 복사해 넣으면 get pods -A는 워커에서도 전부 나온다. 노드 역할이 시야를 가리는 것이 아니라 자격증명 파일이 없을 뿐이다. 그래서 실패가 「권한 없음(403)」이 아니라 「설정 없음(localhost:8080)」으로 나타난다. agent가 가진 system:node:kc-lab-2 자격증명은 kubelet 전용이라 kubectl이 읽는 경로에 애초에 없다. 그렇다고 복사해 넣으면 안 되는 이유는 바로 아래에 있다.

왜 agent에는 주지 않나 — 역할이 다르다.

server (kc-lab-1) agent (kc-lab-2)
도는 것 API 서버 · 스케줄러 · controller-manager · SQLite kubelet · kube-proxy · containerd · flannel
6443 LISTEN O X
/etc/rancher/k3s/k3s.yaml 있음 (admin 자격증명, 0600 root) 없음
결정하는 것 클러스터 상태 없음. 시킨 것을 실행할 뿐

agent도 API 서버와 통신은 한다. 127.0.0.1:6444에 k3s-agent가 자체 로드밸런서를 열고 그걸 통해 server의 6443으로 넘긴다. 하지만 자격증명의 급이 다르다.

subject=O = system:nodes, CN = system:node:kc-lab-2

이 신원은 Node authorizer와 NodeRestriction admission 플러그인이 자기 노드에 배정된 객체만 다루도록 제한한다. get pods -A(전체 네임스페이스)는 처음부터 권한 밖이다. 워커 한 대가 털려도 클러스터 전체가 털리지 않게 하려는 설계이므로, 편하다고 k3s.yaml을 agent로 복사해 넣지 않는다.

어디서 치나 — 셋 중 하나다.

# ① server 게스트에서
ssh kc-lab-1 'sudo kubectl get pods -A'

# ② 호스트(test-server)에서 — 위 「kubeconfig의 127.0.0.1 문제」의 복사를 먼저 한다
export KUBECONFIG=~/.kube/config
kubectl get pods -A

# ③ agent가 클러스터에 붙었는지만 보고 싶을 때 (kubectl 필요 없다)
ssh kc-lab-2 'systemctl is-active k3s-agent'

확인

ssh kc-lab-2 'ls -l /usr/local/bin/kubectl'          # -> k3s 심볼릭 링크. 명령은 있다
ssh kc-lab-2 'sudo ls /etc/rancher/k3s/ 2>&1'        # No such file — 이게 정상이다
ssh kc-lab-1 'sudo ls -l /etc/rancher/k3s/k3s.yaml'  # 여기에만 있다
ssh kc-lab-2 'sudo openssl x509 -in /var/lib/rancher/k3s/agent/client-kubelet.crt -noout -subject'

274. Traefik (k3s 기본 ingress)

무엇인가 — k3s가 기본으로 배포하는 ingress 컨트롤러. --disable=traefik으로 끌 수 있다.

왜 여기 나오나운영 환경이 k3s이므로 운영에도 Traefik이 있다. 그래서 실험대에서 끄면 안 된다. 우리가 검증하려는 2홉 헤더 문제가 정확히 nginx → Traefik 경계에서 발생한다.

275. 호스트 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 컨트롤러

276. servicelb (klipper-lb)

무엇인가 — k3s 내장 LoadBalancer 컨트롤러. 클라우드 LB가 없는 환경에서 type: LoadBalancer 서비스를 처리하기 위해, 모든 노드에 hostPort를 여는 DaemonSet 파드를 띄운다.

왜 여기 나오나 — 이것 덕분에 Traefik이 192.168.122.11:80192.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

277. flannel VXLAN

무엇인가 — k3s 기본 CNI(컨테이너 네트워크 인터페이스) 백엔드. 노드가 다르면 파드 간 트래픽을 UDP 8472로 캡슐화해서 전달한다.

왜 여기 나오나 — Keycloak 파드 두 개가 서로 다른 노드에 있으면 JGroups 통신이 이 VXLAN 터널을 탄다. 노드 간 방화벽 실험을 할 때 "무엇을 막을 것인가"가 여기에 달려 있다.

278. 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로 하면, 운영에서 쓸 방식 그대로 검증하게 된다.

279. 매니페스트 읽는 법 — 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를 따로 걸지 않으면 한 네임스페이스가 노드 메모리를 다 먹을 수 있다.

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개로 줄어든 채 남아 롤백 경로가 된다.

kubectl -n header-lab get deploy,rs,pods
kubectl -n header-lab rollout status deployment/echo
kubectl -n header-lab rollout undo deployment/echo    # 직전 버전으로

라벨과 셀렉터 — 쿠버네티스의 근본 관용구

spec:
  selector:
    matchLabels:
      app: echo          # ← 이 라벨을 가진 파드를 내 것으로 삼는다
  template:
    metadata:
      labels:
        app: echo        # ← 만들어질 파드에 붙는 라벨

쿠버네티스는 리소스를 이름이 아니라 라벨로 연결한다. Deployment도, Service도, NetworkPolicy도 전부 "이 라벨을 가진 파드"를 가리킨다. 느슨한 결합이라 파드가 몇 개든, 어느 노드든 상관없이 성립한다.

틀리면selectortemplate.labels가 어긋나면 Deployment가 자기가 만든 파드를 자기 것으로 인식하지 못하고 무한히 새 파드를 만든다. Service의 selector가 어긋나면 엔드포인트가 비어 502가 난다.

kubectl -n header-lab get pods --show-labels
kubectl -n header-lab get endpoints echo     # 비어 있으면 셀렉터 불일치

마지막 명령이 "Service는 있는데 502" 상황의 첫 확인 지점이다.

replicas: 2topologySpreadConstraints

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에 머문다. 장애 실험에서는 "그래도 뜨는" 쪽이 맞다.

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에 이미 켜져 있다.

kubectl -n header-lab describe pod <파드명> | grep -A3 -E 'Readiness|Liveness'

resources — requests와 limits의 역할이 다르다

resources:
  requests: { memory: 320Mi, cpu: 100m }
  limits:   { memory: 512Mi }
requests limits
쓰이는 곳 스케줄러가 배치할 노드를 고를 때 커널이 실행 중 강제할 때
메모리 초과 OOMKilled (컨테이너 강제 종료)
CPU 초과 스로틀링 (죽지는 않음)

cpu: 100mm은 milli-core다. 1000m = 1코어. 100m은 0.1코어.

limits를 안 주면 한 파드가 노드 메모리를 다 먹고 다른 파드까지 말려든다. RAM 3584M / 2560M짜리 게스트에서 이건 현실적인 위험이다.

CPU limit을 일부러 안 걸었다 — CPU 스로틀링은 지연을 만드는데, 이 실험대는 타이밍(refresh token 경쟁, 세션 복제 지연)을 측정하므로 인위적 스로틀링이 결과를 오염시킨다.

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를 같이 고쳐야 하는 이중 관리를 피한다.

kubectl -n header-lab exec deploy/echo -- java -XX:+PrintFlagsFinal -version 2>/dev/null | grep -i maxheapsize

포트에 이름 붙이기

ports:
  - containerPort: 8081
    name: http          # ← 이름
...
readinessProbe:
  httpGet:
    port: http          # ← 숫자 대신 이름으로 참조
...
# Service
targetPort: http

— 포트 번호를 한 곳에서만 관리하기 위해서다. 8081을 바꿔야 할 때 containerPort 한 줄만 고치면 프로브와 Service가 따라온다. 숫자를 여기저기 적어두면 한 군데를 빠뜨려 조용히 깨진다.

Service

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 층에서 거는 것이 맞다.

kubectl -n header-lab get svc
kubectl -n header-lab get endpoints echo    # 파드 IP 목록이 채워져야 정상

Ingress

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다.

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

280. 무엇을 어디에 설치하는가

도구 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 스테이지가 컨테이너 안에서 수행한다.

281. 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 규칙에 걸린다
브리지 대역 docker0172.17.0.0/16을 점유한다. 클러스터 서비스 대역이나 사내망과 겹치면 라우팅이 깨진다
디스크 같은 이미지가 두 벌 저장된다

이 실험대에는 이유가 하나 더 있다. lab host에는 libvirt가 virbr0 NAT와 자체 방화벽 규칙을 운영 중이다. Docker의 iptables 규칙이 여기에 얹히면 게스트 네트워크가 예측 불가능해진다. 네트워크 장애를 의도적으로 주입하는 실험대에서 원인 불명의 네트워크 변수를 늘리는 것은 치명적이다 — 실험 결과인지 환경 문제인지 구분할 수 없게 된다.

k3s server --docker로 Docker를 런타임으로 지정하는 방법이 과거에 있었지만, 쿠버네티스 1.24의 dockershim 제거 이후 별도 cri-dockerd를 요구하며 권장되지 않는다. 얻는 것이 없다.

282. 그러면 이미지는 어떻게 넣는가

방법 적합한 경우
공개 레지스트리에서 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 -'"

주의 세 가지

  1. 노드마다 따로 반입한다. 스케줄러가 어느 노드에 배치할지 모른다. 한쪽에만 있으면 반대편에 배치될 때 실패한다.
  2. 매니페스트에 imagePullPolicy: Never를 준다. 없으면 로컬에 이미지가 있어도 레지스트리에서 당기려 시도하다 실패한다.
  3. 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 이면 반입 실패

283. 6층. Arch 특이사항

여기 있는 것만이 진짜 "Arch라서" 하는 일이다.

284. nginx 설정 구조 — sites-available은 nginx 기능이 아니다

중요한 사실부터. sites-available / sites-enablednginx의 기능이 아니라 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두 블록에 주면 그때는 오류가 난다.)

285. 롤링 릴리스와 부분 업그레이드 금지

Arch는 고정 릴리스가 없고 패키지가 계속 갱신된다. 그리고 부분 업그레이드를 지원하지 않는다. pacman -Sy 패키지처럼 DB만 갱신하고 일부만 설치하면 공유 라이브러리 버전이 어긋나 시스템이 깨질 수 있다.

명령 의미 안전한가
pacman -Syu DB 갱신 + 전체 업그레이드 안전
pacman -S 패키지 현재 DB 기준 설치 대체로 안전
pacman -Sy 패키지 DB만 갱신 후 일부 설치 위험 — 쓰지 말 것

실험 운영 규칙 — 실험 시작 전에 pacman -Syu + 재부팅을 끝내두고, 실험 기간에는 업그레이드하지 않는다. 커널이 올라가면 재부팅이 필요하고, 재부팅하면 VM이 전부 내려가서 실험이 중단된다.

286. 패키지명 대응표

역할 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

287. 없어서 오히려 편한 것

Arch에는 SELinux도 AppArmor도 기본 활성화되어 있지 않다. RHEL 계열에서 k3s를 설치할 때 필요한 SELinux 정책 패키지 (k3s-selinux)와 컨텍스트 문제가 여기선 아예 없다.

288. 게스트 배포판: 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를 쓴다면 실험 기간에는 꺼두는 것이 맞다.
    sudo systemctl disable --now unattended-upgrades
    
  2. AppArmor — Ubuntu는 기본 활성이다. 컨테이너 런타임이나 Keycloak의 파일 접근이 원인 모르게 거부될 때 의심 대상이 하나 늘어난다. Debian에서는 이 변수가 없다.
  3. 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는 그대로 재사용할 수 있다.


289. 7층. git

290. .gitignore 패턴 앵커링

무엇인가 — 패턴에 슬래시가 어디 있느냐로 적용 범위가 달라진다.

패턴 매칭 범위
target/ 모든 깊이target 디렉터리
/target/ 저장소 루트target
backend/target/ 루트 기준 그 경로 하나만
**/target/ target/과 사실상 동일 (중복)

규칙 — 패턴 중간에 슬래시가 있으면 git은 그것을 루트 기준 경로로 간주하고 앵커링한다. 슬래시가 끝에만 있으면(디렉터리 표시) 앵커링하지 않고 모든 깊이에 적용한다.

왜 여기 나오나 — 기존 .gitignorebackend/target/이 있었는데, 나중에 생긴 bff/targettoken-mediator/target이 빠졌다. target/으로 바꾸면 한 줄로 전부 커버된다.

291. 이미 추적 중인 파일은 무시되지 않는다

무엇인가.gitignore추적되지 않는 파일에만 적용된다. 이미 인덱스에 들어간 파일은 패턴을 추가해도 계속 추적된다.

해결git rm -r --cached <경로>로 인덱스에서만 제거한다 (작업 디렉터리 파일은 남는다).

확인

git ls-files | grep '/target/'           # 0줄이면 rm --cached 불필요
git check-ignore -v bff/target           # 어느 규칙이 무시시키는지 출력
git status --short                        # ?? 목록에서 사라졌는지

292. 8층. 패키지 저장소와 설치 원리

pacman -S qemu-full이나 cloud-init의 packages: [curl, nftables]가 실제로 무슨 일을 하는지. 배포판이 달라도 원리는 동일하다.

293. 저장소(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

핵심은 인덱스와 실제 파일이 분리되어 있다는 점이다. 클라이언트는 인덱스만 먼저 받아서 계산하고, 필요한 파일만 골라 내려받는다.

294. 설치는 다섯 단계로 진행된다

배포판과 무관하게 순서가 같다.

 1. 인덱스 갱신    저장소의 목록 파일을 받아 로컬에 저장
 2. 의존성 해결    "curl 을 깔려면 libcurl4, libssl3 … 이 필요"를 계산
 3. 다운로드       필요한 패키지 파일들을 내려받음
 4. 검증           GPG 서명과 해시를 확인
 5. 설치           압축을 풀어 파일시스템에 배치, 설치 후 스크립트 실행

2번이 패키지 관리자의 존재 이유다. 의존성은 사슬로 이어지고 충돌하기도 해서, 사람이 손으로 풀기 어렵다. 이 계산을 대신해주는 것이 aptpacman이다.

295. 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 가 실제 설치

aptdpkg 의 역할 분담 — 자주 헷갈리는 지점이다.

도구 담당
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 등)

296. 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은 릴리스마다 버전을 고정하므로 이 문제가 없다.

297. 왜 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

298. 세 배포판 대조표

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 자체

299. 이 실험대에서 어디에 나타나는가

  • 호스트(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처럼 자체 업그레이드 경로를 제공하는 소프트웨어에서만 받아들일 만한 방식이다.


300. 9층. deploy/ — 무엇이 살아 있고 무엇이 참조인가

저장소에 있으나 지금 적용되지 않는 설정이 여럿이다. 죽은 코드가 아니라 의도적으로 남겨둔 참조 자산이며, 그 구분을 여기 기록한다.

301. 전체 지도

경로 상태 대상 배포 형태 검증
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

세 가지 상태

  • 적용 중 — 지금 실험대에서 실제로 도는 설정
  • 참조 — 다른 배포 형태의 예제. 실행되지는 않지만 문법·계약 검증은 받는다
  • 미채택 — 조건이 맞지 않아 고르지 않은 경로. 근거를 남기려고 보존한다

302. 왜 적용하지 않는 것을 남겨두는가

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/ 아래로 분리해 일반 배포 설정과 섞이지 않게 두었다.

303. 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가 문서화한 계약이다.

실험대의 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 (덮어쓰기)

세 번째 줄이 신뢰 경계의 차이다. 덧붙이면 클라이언트가 위조한 값이 사슬 앞부분에 남고, 덮어쓰면 사라진다. 이 차이를 실측으로 확정하는 것이 첫 실험의 목적이다.

304. tls/ — 같은 일을 하는 두 구현

nginx.confCaddyfile동일한 결과를 만든다. 공개 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을 두 프로세스가 잡을 수 없다. 예제가 둘인 것은 선택지를 보여주기 위해서다.

305. tunnel/ — 채택하지 않은 이유를 남긴 자산

cloudflared-config.yml은 Cloudflare named tunnel 설정이다. 아웃바운드 연결만 쓰므로 포트포워딩 없이 공개 HTTPS 이름을 얻는다. 공유기를 건드릴 수 없는 환경에서 매력적인 선택지다.

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 직결을 택했다. 조건이 바뀌어(예: 다른 회선으로 이전) 공개 접근이 필요해지면 이 파일이 그대로 쓰인다.

306. .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 공개키 두 줄이다. 공개키 자체는 비밀이 아니지만, 저장소가 공개이므로 호스트 신원 정보를 불필요하게 노출하지 않는다.


307. 10층. 쿠버네티스 리소스 — 이 실험대에서 실제로 쓴 것들

5층이 k3s 자체라면 여기는 그 위에 올린 리소스들이다.

308. 워크로드 세 종류 — 무엇을 언제 쓰는가

보장하는 것 이 실험대에서
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 테이블을 대조하기가 어려워진다. 이유는 셋으로 나뉜다.

내용
이름이 안 바뀐다 재시작한 노드가 새 노드로 보이지 않는다. 이름이 churn 하면 JGROUPS_PING 에 유령 항목이 쌓인다
실험에서 지목이 된다 keycloak-0 을 죽인다」가 성립한다. Deployment 면 지목할 이름이 없다
완전 무상태가 아니다 세션이 Infinispan 메모리에 있다. 노드가 캐시 상태를 들고 있다

공식 Keycloak Operator 도 StatefulSet 으로 배포한다.

정직한 반대편 — Deployment 로도 뜬다. Keycloak 26 에서 persistent-user-sessions 를 켜면 세션이 DB 로 가서 노드가 훨씬 무상태에 가까워진다. StatefulSet 은 동작에 필요해서가 아니라 관측과 재현성 때문에 고른 것이다. 반대로 postgres 는 PVC 를 쓰는데도 Deployment 인데, replica 1 에 strategy: Recreate 라 StatefulSet 의 이점이 필요 없기 때문이다. 「상태가 있으면 StatefulSet」이 아니라 「안정된 이름이 필요하면 StatefulSet」이다.

그럼 운영에서는 Deployment 로 가도 되나 — 관측을 빼도 운영상 이유가 둘 남는다. 둘 다 사람이 아니라 Infinispan 이 신경 쓰는 것이다.

남는 이유 왜 운영에서 문제인가
업데이트 순서 StatefulSet 의 RollingUpdate하나씩, 이전 파드가 Ready 가 된 뒤에 다음으로 간다. Deployment 기본값(maxSurge 25%·maxUnavailable 25%)은 여러 파드가 동시에 교체될 수 있어 클러스터 view 가 요동치고 rebalance 가 겹친다
jdbc-ping 유령 항목 이 근거는 틀렸다. 아래 정정 참고.

「새 노드로 보이는 것」이 사람에게 상관없어도 클러스터에는 상관있다. 새 주소가 뜨고 지면 view change 와 state transfer 가 돌고, 그 구간이 곧 지연이다.

그래도 Deployment 로 운영하려면 아래를 직접 맞춰야 한다. StatefulSet 은 이것을 기본으로 주는 것이다.

strategy:
  rollingUpdate:
    maxSurge: 0          # 새 파드를 먼저 띄우지 않는다
    maxUnavailable: 1    # 한 번에 하나만

여기에 PodDisruptionBudget 까지 붙이면 순차 교체를 흉내 낼 수 있다. 기본값 그대로 Deployment 를 쓰면 배포할 때마다 클러스터가 흔들린다.

★ 정정 — StatefulSet 은 유령 행을 막지 못한다. 「이름이 안정적이니 같은 행을 덮어쓴다」는 설명은 틀렸다. 실측 (docs/evidence/keycloak-multinode-cluster/01-cluster-formed.txt)을 보면 기본키는 address(UUID)이고 namekeycloak-0-49501파드 이름 + 랜덤 접미사다. 파드가 재시작하면 StatefulSet 이라도 UUID 도 접미사도 새로 생겨 새 행이 된다. 안정적인 것은 keycloak-0 이라는 접두사뿐이다.

유령 행이 자동으로 정리되는지는 이 실험대도 아직 확인하지 않았다docs/experiment-plan.md 에 미해결 항목으로 남아 있다.

그래서 StatefulSet 의 근거는 이만큼으로 좁혀진다.

근거 유효한가
로그·JGROUPS_PING 에서 접두사로 대조 가능 ○ (접두사만)
실험에서 keycloak-0지목 가능
교체 순서가 결정적(역순 1개씩) ○ — Deployment 도 정책으로 흉내 가능
유령 행을 덮어쓴다

즉 남는 것은 사람이 읽을 수 있는 접두사순서 결정성이다. 세션이 DB 에 있고 롤링 정책을 명시적으로 조인다면 Deployment 도 정당한 선택이다.

「명시적으로 조이는 편이 낫다」는 원칙은 맞다. 다만 직접 맞춰야 할 항목이 늘면 틀릴 여지도 같이 는다. 기본값이 맞는 형태를 주는 리소스를 고르는 것도 엔지니어링 판단이고, 반대로 그것이 「생각을 안 한 결과」라면 Deployment 쪽이 옳다. 공식 Keycloak Operator 는 StatefulSet 을 쓴다.

이 실험대에 한정하면 StatefulSet 은 편의가 아니라 요구사항이다. A-4·A-8 이 「keycloak-0 을 죽인다」로 성립하는데, Deployment 면 지목할 이름이 없어 실험 자체가 써지지 않는다.

podManagementPolicy

동작
OrderedReady (기본) -0이 Ready가 된 뒤에야 -1을 만든다
Parallel 동시에 시작한다

이 실험대는 Parallel을 쓴다. 이유가 넷인데 마지막이 결정적이다.

# 이유
1 두 노드가 대칭이다. Keycloak 파드는 서로 peer 라 -0 이 특별하지 않다. 순서가 의미를 갖는 것은 primary 를 먼저 띄워야 하는 DB 류다
2 디스커버리가 jdbc-ping 이다. 서로를 DB 의 JGROUPS_PING 테이블로 찾으므로 누가 먼저 떠도 된다. 나중에 뜬 쪽이 테이블을 읽고 합류한다
3 기동이 느리다. JVM + DB 마이그레이션이라 순차면 대기가 두 배다
4 장애 실험이 성립한다. OrderedReady-0 이 Ready 가 안 되는 순간 -1영원히 안 만들어진다. A-4 에서 죽은 노드에 -0 이 묶이면 클러스터 전체가 못 뜬다 — 「한 노드가 죽어도 나머지가 서비스한다」를 검증할 수 없게 된다

그리고 두 파드가 동시에 클러스터 등록을 시도하는 것이 운영에서 실제로 일어나는 상황이라, 그 경합을 그대로 재는 편이 맞다.

podManagementPolicy 는 생성·스케일에만 적용된다. 이미지 교체 같은 업데이트는 updateStrategy 가 지배해서 여전히 역순으로 하나씩 간다. A-8 의 롤링 재시작이 순차로 도는 이유가 이것이다 — 둘을 같은 설정으로 착각하면 「Parallel 인데 왜 하나씩 재시작하지」에서 막힌다.

kubectl -n keycloak-lab get sts keycloak \
  -o jsonpath='{.spec.podManagementPolicy}{"  "}{.spec.updateStrategy.type}{"\n"}'

어디를 봐야 하는가 — 두 값이 각각 ParallelRollingUpdate 다. 다른 축이다. 앞은 「만들 때」, 뒤는 「바꿀 때」를 정한다.

DaemonSet을 node-exporter에 쓴 이유 — replica 수를 지정하지 않는다. 노드가 늘면 자동으로 늘고, 줄면 준다. 죽을 노드에도 반드시 있어야 꺼지기 직전의 마지막 샘플이 남는다.

kubectl get deploy,sts,ds -A

309. 저장소 — 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에 머문다.

strategy:
  type: Recreate      # 옛 파드를 먼저 내리고 새 파드를 띄운다

k3s의 local-path 프로비저너 — 볼륨이 노드에 못박힌다

"nodeAffinity": {
  "required": { "nodeSelectorTerms": [{
    "matchExpressions": [{ "key": "kubernetes.io/hostname", "values": ["kc-lab-2"] }]
  }]}
}
경로: /var/lib/rancher/k3s/storage/pvc-<uuid>_<ns>_<name>

그 노드의 로컬 디스크에 디렉터리를 만드는 것이 전부다. 따라서 PVC를 쓰는 파드는 그 노드를 벗어날 수 없다.

결과
노드가 죽으면 파드가 다른 노드로 재배치되지 못한다
실험 관점 결함이 아니라 조건이다. "DB가 있는 노드가 죽으면"이 의미를 갖는다
kubectl get pvc -A
kubectl get pv
kubectl get pv <name> -o jsonpath='{.spec.nodeAffinity}' | python3 -m json.tool

310. Secret — 감춰지지 않는다

kind: Secret
type: Opaque
stringData:
  POSTGRES_PASSWORD: lab-postgres-change-me

stringData는 평문으로 쓰고 쿠버네티스가 base64로 인코딩해 저장한다. data는 직접 base64로 넣는다.

base64는 암호화가 아니라 인코딩이다.

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번의 주제다.

311. RBAC — ServiceAccount · ClusterRole · Binding

Prometheus가 쿠버네티스 API에 물어서 타깃을 찾으려면 읽기 권한이 필요하다.

  ServiceAccount        파드가 쓰는 신원 (누구인가)
        │
  ClusterRoleBinding    신원과 권한을 잇는다
        │
  ClusterRole           무엇을 할 수 있는가 (리소스 × 동사)
rules:
  - apiGroups: [""]
    resources: [nodes, nodes/metrics, nodes/proxy, services, endpoints, pods]
    verbs: [get, list, watch]

RoleClusterRole의 차이Role은 한 네임스페이스 안에서만, ClusterRole은 클러스터 전체에서 유효하다. 노드는 네임스페이스에 속하지 않으므로 **노드를 읽으려면 반드시 ClusterRole**이다.

서브리소스가 따로 있다 — 실제로 걸린 함정

nodes, nodes/metrics, nodes/proxy서로 다른 권한이다.

/api/v1/nodes/<name>/proxy/metrics
                     ─────
                     이 경로에는 nodes/proxy 가 필요

nodes/proxy를 빠뜨렸을 때 kubelet 타깃만 403 Forbidden으로 실패하고 나머지 잡은 전부 정상이었다. 부분 실패라 rollout status는 성공이라고 말한다. 타깃 목록을 직접 봐야 드러난다.

kubectl auth can-i get nodes/proxy --as=system:serviceaccount:observability:prometheus
kubectl describe clusterrole prometheus

★ 첫 명령은 답 앞에 경고 한 줄을 같이 낸다(2026-09-17, observed). 실패가 아니라 nodes 가 네임스페이스에 속한 자원이 아니라는 안내이고, 볼 것은 그 아래의 yes 또는 no 다.

Warning: resource 'nodes' is not namespace scoped

yes

312. 배치 제어 — nodeSelector · 라벨 · taint

nodeSelector:
  node-role.kubernetes.io/control-plane: "true"

호스트 이름 대신 역할 라벨을 쓴다. kubernetes.io/hostname: kc-lab-1로 못박으면 노드 이름이 바뀔 때 깨지고, 왜 거기 두는지가 드러나지 않는다.

k3s는 server 노드에 node-role.kubernetes.io/control-plane=true를 붙인다.

kubectl get nodes --show-labels
kubectl get nodes -l node-role.kubernetes.io/control-plane=true

taint와 toleration

taint 노드에 붙는 "여기 오지 마" 표시
toleration 파드가 갖는 "그래도 갈 수 있음" 면제권
tolerations:
  - operator: Exists      # 어떤 taint 든 무시한다

node-exporter에 이걸 주는 이유는 관측이 빠지는 노드가 있으면 안 되기 때문이다. taint가 걸린 노드에서도 떠야 한다.

배치를 정하는 세 수단의 차이

수단 성격
nodeSelector 반드시 그 라벨의 노드에
topologySpreadConstraints 골고루 퍼뜨린다
taint / toleration 노드가 거부하고 파드가 면제받는다

313. k3s server와 agent — 죽였을 때가 다르다

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 하나만 있는 노드를 죽이자"고 계획했다가 실제 배치를 조회한 뒤 정정했다.


314. 11층. Keycloak 클러스터링 내부 — Infinispan과 JGroups

315. 두 층으로 되어 있다

  Infinispan     분산 캐시. "세션을 어디에 두고 어떻게 복제할까"
      │
  JGroups        그룹 통신. "누가 멤버이고 어떻게 메시지를 주고받을까"
      │
  TCP 7800       실제 소켓

Keycloak은 Infinispan을 쓰고, Infinispan은 JGroups 위에서 돈다. 로그의 org.infinispan.CLUSTERvendor_jgroups_* 지표가 각각 이 두 층이다.

316. 디스커버리와 트랜스포트는 다른 경로다

이것이 이 실험대를 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의 기본값이다.

317. 코디네이터

coord = t 인 노드가 코디네이터다. 뷰 변경을 확정하고 리밸런싱을 주도한다. 특별한 권한이 아니라 역할이며, 그 노드가 사라지면 남은 멤버가 인계받는다.

실험대를 전원 종료했다 켰을 때 코디네이터가 keycloak-1keycloak-0으로 바뀌는 것을 관찰했다. 먼저 뜬 쪽이 맡는다.

318. 클러스터 뷰

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 노드가 합류했다
kubectl -n keycloak-lab logs keycloak-0 | grep -E 'ISPN000094|ISPN000079|ISPN100000'

319. 주요 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가 합친다.

320. 세션은 어디에 있는가 — 두 곳이되 역할이 다르다

Keycloak 26의 기본값 persistent-user-sessions에서는

저장소 역할 노드 간 공유
PostgreSQL 진실의 원천. 재시작에도 살아남는다 여기서만 일어난다
Infinispan sessions 자기 노드가 로그인시킨 세션만 담는 룩어사이드 캐시 일어나지 않는다

처음에 이 표에 "Infinispan = 캐시 + 노드 간 실시간 전파"라고 썼는데 틀렸다. 실험 0에서 측정해보니 세션 엔트리는 노드 사이를 건너가지 않는다. 두 노드가 같은 답을 하는 이유는 복제가 아니라 같은 DB를 보기 때문이고, 반대편 노드가 실제로 날리는 SELECT ... FROM OFFLINE_USER_SESSION 을 PostgreSQL 로그에서 직접 잡았다. → docs/experiment-00-session-replication.md

--features-disabled=persistent-user-sessions로 끄면 Infinispan만 남는 volatile 모드가 되고, 그때는 캐시가 곧 진실의 원천이므로 복제가 반드시 일어나야 한다. 이 둘의 차이가 로드맵 2번의 주제다.

321. 세션 쓰기 트랜잭션의 세 가지 설계 결정

PostgreSQL 문장 로깅으로 잡은 갱신 트랜잭션 하나에 다 들어 있다.

보이는 것
update ... where ... and VERSION=$5 낙관적 락. 읽을 때의 버전과 같을 때만 쓴다
for no key update ... skip locked 잠긴 행을 기다리지 않고 건너뛴다. 대기 대신 재시도
SET LOCAL synchronous_commit TO OFF WAL 플러시를 기다리지 않고 커밋한다

마지막 것이 특히 중요하다 — DB가 강제 종료되면 직전 수백 밀리초의 세션 갱신이 사라질 수 있다. 버그가 아니라 의도된 트레이드오프다. LAST_SESSION_REFRESH 갱신은 매우 잦고, 잃어도 사용자가 다시 갱신하면 된다.


322. 12층. 관측성 — Prometheus의 구조

323. 세 부분으로 되어 있다

   수집(scrape)  ──▶  저장(TSDB)  ──▶  질의(PromQL)
   15초마다            로컬 디스크        Grafana 또는 API
   HTTP GET /metrics   시계열

Prometheus는 pull 방식이다. 대상이 보내주는 것이 아니라 Prometheus가 주기적으로 /metrics를 긁어간다.

결과
대상이 죽으면 긁기가 실패하고 up이 0이 된다 — 죽은 사실 자체가 데이터가 된다
방화벽 방향 Prometheus → 대상. 대상이 Prometheus 주소를 알 필요가 없다
짧은 작업 긁히기 전에 끝나면 잡히지 않는다 (Pushgateway가 필요한 경우)

324. exporter 패턴

애플리케이션이 Prometheus 형식을 모를 때, 번역기를 옆에 둔다.

exporter 무엇을 노출하는가
node-exporter 머신 — CPU, 메모리, 디스크, 네트워크
kube-state-metrics 쿠버네티스 오브젝트 상태
postgres-exporter PostgreSQL 내부 통계

Keycloak과 Traefik은 exporter가 필요 없다. 자체적으로 Prometheus 형식 엔드포인트를 제공한다(KC_METRICS_ENABLED=true).

325. 서비스 디스커버리 — 타깃을 적어두지 않는다

kubernetes_sd_configs:
  - role: endpoints
    namespaces: { names: [keycloak-lab] }

파드 IP는 재시작마다 바뀐다. 실험대를 전원 종료했다 켜니 모든 파드가 새 주소를 받았다(10.42.1.2210.42.1.25). 정적 목록은 그때마다 깨진다.

role에 따라 무엇을 찾을지가 달라진다.

role 찾는 것
endpoints 서비스 뒤의 실제 파드들 ← 애플리케이션 지표
node 노드
pod 파드 직접
service 서비스

326. relabel — 걸러내고 이름을 붙인다

디스커버리는 전부 다 가져온다. 그중 필요한 것만 남기는 것이 relabel이다.

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로 옮겨야 남는다.

podnode 라벨을 붙이는 것이 실험에서 결정적이다. 없으면 "어느 파드가, 어느 노드에서"에 답할 수 없다.

327. 메트릭 타입

타입 성질
counter 누적. 줄지 않는다 (재시작 시 0으로) ..._requests_total
gauge 오르내린다 node_memory_MemAvailable_bytes
histogram 구간별 분포 + 합계 + 개수 ..._seconds_bucket/_sum/_count
summary 분위수를 클라이언트가 계산

counter는 그대로 보면 의미가 없다. 변화율을 봐야 한다.

rate(http_requests_total[5m])

histogram은 세 지표가 한 벌이다. _bucket으로 분위수를 계산한다.

histogram_quantile(0.95, rate(keycloak_session_expiration_task_seconds_bucket[5m]))

328. up — 가장 중요한 합성 지표

up
up{job="keycloak"}

Prometheus가 직접 만드는 지표다. 긁기에 성공하면 1, 실패하면 0.

장애 실험에서 이것이 핵심인 이유 — 다른 지표는 대상이 죽으면 사라진다. 사라진 데이터로는 "언제부터 죽었나"를 알 수 없다. up0이라는 값으로 남기 때문에 사후에 시각을 특정할 수 있다.

up == 0                          # 지금 죽은 타깃
changes(up[1h])                  # 1시간 동안 몇 번 오르내렸나
min_over_time(up[10m])           # 10분 중 한 번이라도 죽었나

329. TSDB와 보존 기간

--storage.tsdb.path=/prometheus
--storage.tsdb.retention.time=7d

로컬 디스크에 시계열로 저장한다. 보존 기간이 지나면 삭제되므로 볼륨이 무한히 커지지 않는다.

emptyDir에 두면 파드 재시작 시 실험 기록이 통째로 사라진다. 사후 추적이 목적이면 PVC여야 한다.

330. 관측 시스템의 장애 도메인

관측 시스템은 관측 대상과 같이 죽으면 안 된다. 죽는 순간을 기록해야 하는데 같이 죽으면 기록이 없다.

노드가 둘뿐인 실험대에서는 완전히 피할 수 없으므로 규칙으로 정한다.

kc-lab-1 (server)   관측 스택을 둔다. 죽이지 않는다
kc-lab-2 (agent)    장애 주입 대상

nodeSelector로 못박아 실험이 재현 가능하게 만든다.


331. 13층. 가상화 운영 — 실행 중 바꾸는 것들

332. VM 메모리 재배분 — 게스트를 다시 만들지 않는다

virsh setmaxmem kc-lab-1 5120M --config
virsh setmem    kc-lab-1 5120M --config
명령 바꾸는 것
setmaxmem 상한. 부팅 시 게스트가 보는 총량
setmem 현재 할당. 상한 이하여야 한다

순서가 중요하다. 현재값을 상한보다 크게 줄 수 없으므로 setmaxmem이 먼저다.

플래그 적용 범위
--config 영구 정의. 다음 부팅부터
--live 실행 중인 도메인에 즉시
둘 다 지금과 앞으로

setmaxmem --live는 대개 거부된다 — 게스트가 부팅 시 메모리 맵을 정하기 때문이다. 상한을 바꾸려면 게스트를 껐다 켜야 한다.

★ 바꾼 것이 들어갔는지는 dominfo 로 판정하지 않는다. --config 는 다음 기동부터 쓸 설정만 바꾸는데 dominfo지금 돌고 있는 값을 보여 준다. 그래서 명령이 제대로 먹어도 dominfo 의 숫자는 안 바뀌고, 그 화면을 「안 먹었다」로 읽게 된다. 2026-09-17 에 같은 순간을 두 명령으로 나란히 찍었다(observed).

virsh dominfo kc-lab-1 | grep -i memory
  Max memory:     5242880 KiB          ← 돌고 있는 값

virsh dumpxml kc-lab-1 --inactive | grep -E '<memory|<currentMemory'
  <memory unit='KiB'>4194304</memory>  ← 방금 바꾼 설정
  <currentMemory unit='KiB'>4194304</currentMemory>

같은 도메인, 같은 시각, 다른 숫자다. 바꾼 것이 들어갔는지는 --inactive 가 답하고, dominfo 는 지금 무엇으로 돌고 있는지를 답한다.

virsh dominfo kc-lab-1 | grep -i memory
ssh kc-lab-1 free -m        # 게스트가 실제로 인식한 값

호스트에서 8GB→12GB로 물리 증설한 뒤 이 방법으로 재배분했다. 게스트 재생성이나 디스크 조작은 전혀 필요 없었다.

333. 안전한 종료 순서

전원을 내리기 전에 위에서부터 정리한다.

# 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가 돈다. 미리 내려두면 그 위험이 없다.

따라 하는 사람은 3번의 두 게스트를 && 로 잇지 않고 한 줄씩 친다.

virsh shutdown kc-lab-1
virsh shutdown kc-lab-2

두 게스트는 서로 앞뒤가 없고, A && B 는 A 가 성공했을 때만 B 를 실행한다. kc-lab-1 이 이미 shut offvirsh shutdowndomain is not running 으로 실패하므로 kc-lab-2 는 켜진 채로 남고, 바로 다음 줄의 systemctl poweroff 가 그 게스트를 강제로 끈다 — 이 절차가 막으려던 바로 그 상태다. §226 의 net-start && net-autostart 와 같은 자리다. 이 나눈 형태는 이 실험대에서 치지 않았다(unknown).

clean shutdown 확인

ssh kc-lab-2 'sudo ls /var/lib/rancher/k3s/storage/*postgres-data*/pgdata/postmaster.pid'

postmaster.pid가 남아 있지 않아야 정상이다. 남아 있으면 비정상 종료였고 다음 기동에 복구 절차가 실행된다.

334. 복구 순서 — 종료의 역순

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으로 내려두면 자동으로 복구되지 않는다. 명시적으로 올려야 한다.

여기 첫 줄도 && 로 잇지 않고 한 줄씩 친다.

virsh start kc-lab-1
virsh start kc-lab-2

kc-lab-1 이 이미 running 이면 virsh startdomain is already active 로 실패하고 kc-lab-2 는 안 뜬다. 그러면 kubectl get nodes 에 노드가 하나만 나오는데, 증상이 게스트를 안 띄운 것과 노드가 안 붙은 것 사이에서 갈리지 않는다. 이 나눈 형태는 이 실험대에서 치지 않았다(unknown).

335. qcow2 파일을 다른 물리 서버로 옮기면 무엇이 따라가나

1층의 「qcow2와 backing store」가 오버레이 구조를, 「qcow2 파일 내부는 어떻게 생겼나」가 매핑표를 설명했다. 여기서는 그 파일을 다른 호스트로 들고 갔을 때 무엇이 같이 가고 무엇이 안 가는가를 푼다.

무엇인가 — qcow2는 가상 디스크 한 장의 블록을 담는 파일이다. 담는 것은 디스크뿐이다. 게스트가 디스크에 쓴 것(파일시스템·설치 패키지·설정·DB 파일)은 전부 들어 있고 RAM과 CPU 상태는 들어 있지 않다.

먼저 오해 하나를 정리한다 — qcow2가 기본으로 "압축"되는 것은 아니다. 20GB로 만든 이미지가 2GB인 것은 압축이 아니라 희소(sparse) 할당이다. 실제로 쓴 블록만 파일에 존재하고, 안 쓴 영역은 파일에 아예 없다. 진짜 zlib/zstd 압축은 qemu-img convert -c명시적으로 만들었을 때만 걸린다.

왜 여기 나오나 — 실험대를 다른 머신으로 옮기거나 백업에서 되살릴 때 "qcow2만 복사하면 되나"를 판단해야 한다. 답은 디스크는 된다, 실행 상태는 안 된다. 옮긴 결과는 「전원 코드를 뽑았다가 다른 서버에서 다시 켠 것」과 같다. D-1 백업/복원 실험의 전제이기도 하다.

따라가는 것 따라가지 않는 것
파일시스템 전체 — 설치된 패키지, /etc 설정, systemd enable 상태 실행 중인 프로세스 — PID·열린 FD·소켓·JVM 힙
디스크에 쓰인 데이터 — PostgreSQL 데이터 디렉터리, Redis RDB/AOF 메모리에만 있던 것 — Infinispan이 들고 있던 세션, Redis 미영속 키
디스크 캐시 — 컨테이너 이미지, apt/pacman 캐시, k3s /var/lib/rancher 페이지 캐시와 아직 안 내려간 dirty page
정체성 파일 — machine-id, SSH 호스트키, 저장된 MAC 설정 VM 정의 XML — vCPU·RAM·NIC·machine type·CPU 모델
내부 스냅샷(qemu-img snapshot -l에 보이는 것) UEFI NVRAM(/var/lib/libvirt/qemu/nvram/<vm>_VARS.fd)
백킹 파일 — 오버레이만 복사하면 못 뜬다
호스트 쪽 구성 — virbr0 DHCP 예약, nginx, 인증서

없거나 틀리면

증상 원인
부팅 중 fsck·journal recovery, PostgreSQL crash recovery 켜진 채로 복사했다. 실행 중 qcow2는 정합성이 없다
Could not open backing file: No such file 오버레이만 옮기고 백킹 원본을 안 옮겼다
부팅이 UEFI 셸로 떨어지고 디스크를 못 찾는다 nvram VARS 파일을 안 옮겼다
기동 직후 kernel panic / illegal instruction host-passthrough인데 대상 호스트 CPU가 다르다
게스트는 뜨는데 네트워크가 죽어 있다 NIC 이름이 PCI 슬롯 기준이라 바뀌었다(enp1s0→다른 이름)
두 서버에서 IP·ARP가 요동친다 같은 MAC의 VM이 원본과 사본 양쪽에서 동시에 떠 있다
20GB 이미지가 옮기고 나니 200GB sparse를 안 지키고 복사했다(cp 기본, scp, tar 일부)
unsupported machine type pc-q35-9.0 대상 호스트 qemu가 더 낮은 버전이다

프로세스까지 옮기려면 — qcow2 복사로는 안 되고 셋 중 하나다.

방법 옮기는 것 대가
virsh save → 파일 복사 → virsh restore 디스크 + RAM + CPU 상태. 프로세스가 그대로 재개된다 VM이 멈춘다. RAM 크기만큼 별도 파일이 생긴다(5GB VM이면 최대 5GB)
virsh migrate --live --copy-storage-all 같은 것을 무중단으로 두 호스트의 libvirt가 서로 붙어야 하고 CPU 모델이 호환돼야 한다
virsh snapshot-create-as --memspec 특정 시점의 RAM 포함 스냅샷 되돌리기용이지 이식용이 아니다 — 이미지에 상태가 묶인다

확인

# 옮기기 전 — 무엇이 딸려 있는지
qemu-img info --backing-chain /var/lib/libvirt/images/kc-lab-1.qcow2
qemu-img check /var/lib/libvirt/images/kc-lab-1.qcow2   # 반드시 VM 꺼진 상태에서
virsh domblklist kc-lab-1                               # 이 도메인이 실제로 쓰는 디스크
ls /var/lib/libvirt/qemu/nvram/                         # UEFI면 VARS 파일도 대상
virsh domstate kc-lab-1                                 # 'shut off' 확인 — 이게 핵심

qemu-img infovirtual size(게스트가 보는 크기)와 disk size(파일이 실제로 먹는 크기)가 다른 것이 정상이다. 옮길 때 문제가 되는 것은 disk size다.

안전한 이동 절차

# 원본 호스트
virsh shutdown kc-lab-1 && virsh domstate kc-lab-1      # shut off 될 때까지
qemu-img convert -O qcow2 kc-lab-1.qcow2 kc-lab-1-flat.qcow2   # 백킹 체인을 하나로 합침
virsh dumpxml kc-lab-1 > kc-lab-1.xml                   # 정의는 별도로 옮긴다
rsync -avS kc-lab-1-flat.qcow2 kc-lab-1.xml 대상호스트:/var/lib/libvirt/images/

# 대상 호스트 — XML의 디스크 경로·브리지 이름·CPU 모델을 맞춘 뒤
virsh define kc-lab-1.xml && virsh start kc-lab-1

rsync -S(또는 cp --sparse=always)가 희소를 유지한다. 원본을 지우지 않고 사본을 띄울 거라면 XML의 MAC 주소를 반드시 바꾼다 — 같은 MAC이 한 L2에 둘이면 DHCP와 ARP가 깨진다.

용량이 커지면 — 파일 하나로 옮기는 것의 한계

파일 크기는 실제로 쓴 양을 따라간다. 20GB로 선언해도 3GB만 썼으면 3GB 파일이고, 1TB를 채우면 1TB 파일이다. 희소 할당은 "안 쓴 것을 안 적는" 것이지 "쓴 것을 줄이는" 것이 아니다.

메타데이터 오버헤드는 무시할 수준이다. 클러스터 64KiB, L2 항목 8B이므로 8 / 65536 = 0.012%, refcount 2B를 더해도 0.02% 미만이다.

가상 디스크 L2 표 refcount 표 합계 오버헤드
1 TiB 전부 사용 128 MiB 32 MiB 약 160 MiB (0.016%)

★ 게스트에서 지워도 파일은 줄지 않는다. 게스트가 파일을 삭제해도 게스트 파일시스템이 "빈 블록"으로 표시할 뿐, qcow2 입장에서는 이미 할당된 클러스터다. 한 번 1TB까지 부푼 파일은 계속 1TB다. 줄이려면 둘 중 하나다.

# ① 게스트가 TRIM 을 호스트까지 전달하게 한다 (디스크에 discard='unmap' 필요)
ssh kc-lab-1 sudo fstrim -av
# ② 꺼 놓고 다시 뜬다 — 안 쓰는 클러스터를 버리고 새 파일을 만든다
qemu-img convert -O qcow2 old.qcow2 new.qcow2

전송 시간이 현실적인 제약이 된다. 1TB 파일 하나를 옮기는 데 드는 시간:

경로 실효 속도 1TB 소요
1GbE 유선 약 110 MB/s 약 2.5시간
WiFi 6 (이 실험대 호스트) 약 40~70 MB/s 4~7시간
10GbE 약 1.1 GB/s 약 15분
USB 3.2 외장 SSD로 왕복 약 900 MB/s 약 40분 (읽기+쓰기)

test-server이더넷 없이 WiFi만 있다. 대용량 게스트를 이 머신으로 옮기는 것은 사실상 외장 디스크 경로뿐이다.

그래서 운영에서는 통째로 옮기지 않는다. 네 가지 회피책이 있고, 위에서부터 먼저 검토한다.

방법 무엇을 하나 언제 쓰나
디스크 분리 OS 디스크(20GB)와 데이터 디스크(1TB)를 따로 붙인다. OS는 이미지로 재생성하고 데이터 볼륨만 옮기거나 다시 붙인다 기본값. 설계 단계에서 정한다
공유 스토리지 NFS·iSCSI·Ceph에 이미지를 두고 호스트는 마운트만 한다. virsh migrate --live가 디스크를 안 옮겨도 된다 호스트가 여러 대일 때
증분 백업 dirty bitmap으로 바뀐 클러스터만 뽑는다(qemu-img incremental, virsh backup-begin) 주기적으로 같은 곳에 보낼 때
애플리케이션 레벨 복제 디스크가 아니라 데이터를 옮긴다 — pg_basebackup, pg_dump, Redis replica 옮기려는 것이 사실상 DB 하나일 때

확인

qemu-img info kc-lab-1.qcow2                 # virtual size vs disk size
du -h --apparent-size kc-lab-1.qcow2         # 파일이 주장하는 크기
du -h kc-lab-1.qcow2                         # 실제로 먹는 블록 수 ← 옮길 때 기준
virsh domblklist kc-lab-1                    # 디스크가 몇 장 붙어 있나
virsh dumpxml kc-lab-1 | grep -A2 "<driver"  # discard='unmap' 여부

--apparent-size와 그냥 du의 값이 다르면 그 파일은 희소 상태다. 복사할 때 희소를 유지하지 못하면 뒤쪽 값이 앞쪽 값으로 부푼다.

온프렘 → 클라우드 이전 — 원리는 같고, 파일은 그대로 못 올린다

그대로 적용되는 것 — 디스크만 간다, 실행 중 프로세스는 안 간다, 옮기는 시간은 데이터 크기가 정한다, 그래서 OS와 데이터를 분리해 둔 쪽이 유리하다. 어떤 방법으로도 실행 중인 프로세스가 클라우드로 이어지지는 않는다. 하이퍼바이저 자체가 다르므로 컷오버는 반드시 재부팅이다.

달라지는 것은 셋 — 포맷, 게스트 준비, 진입 방식.

① 포맷 — qcow2를 그대로 받는 곳은 거의 없다

대상 요구 포맷 변환 명령
AWS (VM Import/Export) OVA·VMDK·VHD·raw qemu-img convert -O raw d.qcow2 d.raw
Azure (managed disk) 고정 크기 VHD. 동적 VHD는 거부된다 qemu-img convert -f qcow2 -O vpc -o subformat=fixed,force_size=on d.qcow2 d.vhd
GCP (image import) 도구가 qcow2를 포함해 여러 포맷을 받는다(내부에서 변환). 수동 경로는 disk.raw를 tar.gz로 tar -czSf image.tar.gz disk.raw

raw 로 바꾸면 희소가 깨질 수 있다. 20GB 가상 디스크가 20GB 파일이 되어 업로드량이 통째로 늘어난다. qemu-img convert 는 0 구간을 건너뛰지만 결과 파일을 어떻게 복사·업로드하느냐가 또 변수다(cp --sparse=always, tar -S).

② 게스트 준비 — 실제 작업량은 여기 있다

온프렘 게스트는 virtio 드라이버와 고정 IP를 전제로 떠 있다. 클라우드는 장치도 다르고 설정 주입 경로도 다르다. 안 맞추면 부팅은 되는데 SSH가 안 되는 상태가 된다 — 콘솔이 없으면 손쓸 방법이 없다.

항목 안 맞추면 맞추는 법
스토리지·네트워크 드라이버 루트를 못 찾거나 NIC이 안 뜬다 AWS는 ENA·NVMe, Azure는 hv_vmbus·hv_storvsc·hv_netvsc를 initramfs에 포함
게스트 에이전트 키 주입·메타데이터·리사이즈가 안 된다 Azure waagent, GCP google-guest-agent, AWS는 cloud-init
cloud-init datasource 첫 부팅에 아무 설정도 안 들어온다 NoCloudEc2/Azure/GCE로 바꾼다
고정 IP·NIC 이름 네트워크가 죽는다 DHCP로 되돌린다. enp1s0 같은 이름을 config에 박아두지 않는다
/etc/fstab, GRUB의 루트 지정 장치명이 바뀌어 부팅이 멈춘다 전부 UUID
시리얼 콘솔 부팅 실패를 볼 수단이 없다 GRUB에 console=ttyS0 추가

③ 업로드 경로

# AWS — S3 에 올리고 import (vmimport IAM 역할 필요)
aws s3 cp d.raw s3://버킷/ && aws ec2 import-image --disk-containers ...
# Azure — managed disk 직접 업로드
az disk create --upload-size-bytes <바이트> --upload-type Upload ... && azcopy copy d.vhd "<SAS>"
# GCP
gcloud compute images import my-image --source-file gs://버킷/disk.qcow2

대안이 보통 더 낫다 — 세 갈래

방법 내용 언제
재구축 + 데이터만 이전 클라우드에서 같은 구성을 새로 세우고 DB만 옮긴다(pg_basebackup·덤프) 기본값. cloud-init·IaC로 세운 환경이면 이쪽이 빠르고 깨끗하다
전용 마이그레이션 서비스 AWS MGN·Azure Migrate·GCP Migrate to VMs. 게스트에 에이전트를 넣고 켜진 채로 블록을 계속 복제하다가 컷오버 때만 재부팅 1TB급이거나 재구축이 불가능한 레거시. 다운타임이 분 단위로 줄어든다
이미지 변환 업로드 위의 ①~③ 대수가 적고 한 번에 끝낼 때

왜 재구축이 기본인가 — 이미지를 옮기면 온프렘의 드라이버·고정 IP·수작업 설정까지 전부 따라온다. 그것을 클라우드에서 하나씩 걷어내는 비용이, 처음부터 클라우드용 base 이미지에 같은 구성을 얹는 비용보다 대개 크다.

확인

qemu-img convert -O raw d.qcow2 d.raw && du -h --apparent-size d.raw && du -h d.raw
lsinitramfs /boot/initrd.img-$(uname -r) | grep -E 'ena|nvme|hv_'   # 드라이버 포함 여부
grep -E '^(UUID|/dev)' /etc/fstab                                    # 장치명이 박혀 있나
cloud-init query --all | head                                        # 어떤 datasource 로 떴나

그럼 실무는 왜 이미지를 직접 옮기지 않나

먼저 전제를 바로잡는다. 실무는 VM을 안 쓰는 게 아니다. EC2 인스턴스가 VM이고, k8s 노드도 대개 VM이다. 이 실험대의 k3s도 VM 2대 위에 있다. 덜 쓰는 것은 VM이 아니라 「디스크 이미지 파일을 사람이 손으로 복사해 옮기는 방식」 이다. 이유는 편의성이 아니라 재현성이다.

문제 무슨 일이 생기나
어떻게 만들어졌는지 모른다 이미지는 결과만 담는다. 누가 언제 무엇을 설치했고 어떤 설정을 손으로 고쳤는지가 남지 않는다. 그 서버가 죽으면 같은 것을 다시 만들 수 없다
손으로 고친 것이 전부 따라온다 급하게 넣은 임시 패치, 디버깅용 포트 개방, 끄다 만 서비스까지 그대로 복제된다. 이런 서버를 snowflake라고 부른다
크기와 시간 앞 절의 1TB 문제. 게다가 매번 전체를 옮긴다
비밀이 같이 나간다 이미지 안에 SSH 개인키, DB 비밀번호, 토큰, 로그가 들어 있다. 이미지 공유 = 비밀 유출이다
형상관리가 안 된다 파일은 diff도 리뷰도 안 된다. 두 이미지가 어디가 다른지 말할 수 없다

대신 쓰는 것 — 옮기는 대상을 「결과물」에서 「만드는 절차」로 바꾼다.

도구 무엇을 대신하나
인프라 정의 Terraform, CloudFormation "VM을 어떤 사양으로 몇 대"
이미지 빌드 Packer, cloud-init "그 VM 안에 무엇이 들어가나"
설정 Ansible, 컨테이너 이미지 "그 위에 무엇을 얹나"
데이터 백업·복제(pg_basebackup, 스냅샷) 진짜로 옮겨야 하는 유일한 것

절차가 코드로 있으면 이전은 "옮기기"가 아니라 "대상 환경에서 다시 실행" 이 된다. 리뷰·diff·롤백이 전부 따라온다. 이것을 immutable infrastructure, 서버를 가축처럼 다룬다(cattle, not pets)고 부른다.

정직한 반대편 — 이미지 이동이 맞는 자리도 있다

  • 소스도 문서도 없는 레거시 어플라이언스. 재구축이 불가능한 경우
  • 온프렘 폐쇄 데드라인이 박혀 있어 재구축할 시간이 없는 경우(lift-and-shift)
  • 재해복구(DR) — 절차 재실행보다 통째 복원이 빠를 때
  • 벤더 종속 탈출처럼 "지금 상태 그대로"가 요구사항인 경우

그래서 전용 마이그레이션 서비스(AWS MGN 등)가 존재한다. 다만 그것을 쓴 조직도 대개 이전 직후 재구축을 다시 과제로 잡는다. 옮겨간 snowflake는 클라우드에 가도 여전히 snowflake다.

VM과 컨테이너의 자리 — 둘은 대체재가 아니다.

VM 컨테이너
격리 커널이 분리된다. 멀티테넌트·규제 환경 커널 공유. 프로세스 격리
무엇을 담나 OS 전체 프로세스와 의존성
기동 수십 초 수백 ms
적합 커널이 필요한 워크로드, 레거시 OS, 노드 자체 무상태 앱, 잦은 배포

이 실험대가 VM을 쓰는 이유는 0-1절에 있다 — 독립 커널 2개가 필요하고, 오버레이를 지워 몇 초 만에 되돌리고 싶었기 때문이다. 실무에서 VM을 고르는 이유도 같은 종류다(격리와 커널), "옮기기 편해서"가 아니다.

그럼 실무 마이그레이션은 실제로 어떻게 하나

"옮긴다"가 아니라 "양쪽을 띄워놓고 넘긴다"에 가깝다. 구 환경을 끄고 신 환경을 켜는 한 번의 스위치가 아니라, 두 환경이 한동안 공존하고 데이터와 트래픽이 단계적으로 이동한다. 그래서 설계의 중심은 파일 복사가 아니라 다운타임과 롤백이다.

어떤 방식으로 옮길지부터 고른다 — 6R

전략 내용 대가
Rehost (lift-and-shift) 있는 그대로 옮긴다. 이미지 변환 또는 MGN류 빠르지만 문제도 같이 간다
Replatform OS·미들웨어만 관리형으로 바꾼다. 예: 자체 PostgreSQL → RDS 대개 가성비가 가장 좋다
Refactor 애플리케이션 구조를 바꾼다 비싸다. 이걸 이전과 동시에 하면 대개 실패한다
Repurchase SaaS로 갈아탄다 데이터 이전과 재교육
Retain 안 옮긴다 규제·지연·라이선스 때문에 남기는 것이 정답일 때가 있다
Retire 끈다 인벤토리를 떠보면 아무도 안 쓰는 서버가 반드시 나온다

절차 — 컷오버가 중심이다

1. 인벤토리    무엇이 돌고 있고 무엇이 무엇을 부르는가
2. 대상 구축    IaC 로 신환경. 이때부터 양쪽이 공존한다
3. 데이터 동기화 복제를 걸어둔다 (DB replication·DMS·pg_basebackup + WAL)
4. 검증        신환경에 읽기만 태우거나 트래픽을 복제해 결과를 비교
5. 컷오버      DNS TTL 을 미리 낮춤 → 쓰기 정지 → 잔여 복제 → 전환
6. 관찰·롤백    역방향 복제를 살려둔 채 며칠 관찰
7. 폐기        구 환경 종료. 여기까지 해야 끝이다

★ 3번과 5번이 전부다. 나머지는 이 둘을 안전하게 만들기 위한 준비다. 쓰기 정지 구간을 얼마나 짧게 만드느냐가 마이그레이션의 품질이다.

"직접 한다"는 것은 이 일들을 말한다 — 도구가 대신 못 해주는 부분이고, 실제 공수의 대부분이다.

왜 자동화가 안 되나
인벤토리·의존성 추적 하드코딩된 IP, 방화벽 규칙, 크론, 배치 잡은 문서에 없다
시크릿·인증서 이전 값을 아는 사람이 나뉘어 있고 재발급이 필요한 것도 있다
데이터 정합성 검증 "행 수가 같다"로는 부족하다. 무엇을 비교할지는 도메인 지식이다
성능 재조정 클라우드 디스크는 IOPS 모델이 다르다. 온프렘에서 되던 것이 느려진다
컷오버 리허설 실패 시나리오와 롤백 시점은 사람이 정한다

이 실험대와의 연결 — D-1(백업·복원)과 A-4(노드 상실)가 검증하는 것이 결국 3~6번의 축소판이다. 복제가 걸려 있는가, 끊었을 때 무엇을 잃는가, 되돌릴 수 있는가. 규모만 다르고 질문은 같다.


336. 아직 기록하지 않은 개념

실험을 진행하면서 이 문서에 추가한다.

  • 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

337. 이번에 채운 것 (2026-09-11)

13층에 qcow2 이식성 — 디스크는 따라가고 실행 상태는 안 따라간다, virsh save/migrate와의 차이, 안전한 이동 절차, 이미지가 커졌을 때의 전송 비용과 회피책(디스크 분리·공유 스토리지·증분 백업·앱 레벨 복제), 온프렘→클라우드 이전(포맷 변환·게스트 준비·업로드 경로와 재구축 대안), 실무가 이미지를 직접 옮기지 않는 이유(재현성·비밀 유출·형상관리)와 그럼에도 이미지 이동이 맞는 자리, 실무 마이그레이션 절차(6R·컷오버 중심의 7단계·사람이 하는 일). 1층 qcow2 내부 절에는 "매핑표만이 아니라 데이터도 같은 파일 안에 있다"를 보강.

338. 이번에 채운 것 (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 메모리 재배분, 안전한 종료·복구 순서.