기록 84편을 계약 에이전트로 다시 썼다. 기존 71편(kss 25 · virt 46)과, 계약에만 있고 안 쓰여 있던 새 글감 13편이다. 원장 84개를 열어 단계마다 스킬 영수증과 관문 종료 코드를 적었고 verify-pipeline-run.py 가 error 0 으로 닫는다. SSOT 결함 둘을 고쳤다. - kss 의 `약 58일` 이 반입 중 `약 59일` 로 바뀌어 있었다. 원 증거 파일이 「남은 일수: 88일 … 실제 갱신까지 약 58일」로 산수를 직접 적는다. D-4a 쪽 `약 59일` 은 강제 갱신 뒤(`VALID: 89 days`)라 맞는 값이라 그대로 뒀다. - virt §198 의 `11.6GB` 는 §178 의 원 측정 `Mem: 11648`(MiB)과 어긋나는데 원 가이드의 표기 그대로라 고치지 않고 쓰이는 자리에 대조를 적었다. 기록의 수치 오류 셋을 고쳤다 — CASE 요약의 「게스트 셋에 8240MB」(5120+3120 은 둘이다), k3s 편이 같은 것을 여섯·일곱·여덟로 세던 것, no-docker 편의 「셋을 더 든다」(§281 의 표는 네 행이고 디스크 행이 빠져 있었다). 계약을 셋 고쳤다. - kss 의 sourceRepository 리비전이 cdac9b8 이었는데 그 커밋에는 docs/guides/** 28개가 아예 없다. 9465582b 로 바꾸고, 반입한 바이트가 어느 커밋과도 같지 않다는 것을 측정값과 함께 적었다 — 반입은 커밋이 아니라 그 시점의 작업 트리에서 떠 온 것이다(kss 297/306 · virt 12/14 가 작업 트리와 같고, 200 커밋을 거슬러 전수 대조했을 때 가장 가까운 커밋도 28개가 어긋났다). - virt 계약이 「2026-09-11 재배분」이라고 적는데 SSOT 는 재배분 날짜를 적지 않고 재배분 뒤 값은 이미 2026-09-10 측정에 찍혀 있다. - kss 후보 대장이 지나친 절 아홉에 처분을 적었다(warn 9 → 0). 새 글감은 0건이고 넷은 앵커가 h3 슬러그의 접두가 아니라 중간 토막이라 검사기가 못 본 것이었다. style_profile.mjs 의 결함 둘을 고쳤다 — frontmatter 가 문장으로 세어져 (실측 398자짜리 「문장」 하나) 평균 길이를 기준 안으로 밀어 올리고 있었고, engPerSent 의 분자는 목록을 포함한 글에서, 분모는 목록을 걷어낸 글에서 세고 있었다(Question 기록에서 11.94 → 3.86). verify-pipeline.py 전 항목 PASS · error 0 · unittest 334건 OK. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
17532 lines
606 KiB
Markdown
17532 lines
606 KiB
Markdown
# 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부가 1~60, 제3부가 1~38, 제4부가 0~50 이었다. 제7부와 제9부도 같은 방식으로 반입했고, 그 두 부는 반입 전 heading 을 한 칸 **올렸다** — 실제 항목이 사는 단계를 절로 삼았다. 바꾼 자리는 각 부의 첫 절이 표로 적는다.
|
||
|
||
**제1~4부에는 이 테스트 Host 에서 잰 값이 하나도 없다.** 부마다 끝에 OPEN QUESTION 이 있고, 그 목록이 그 네 부가 아직 확인하지 않은 것이다. **제5부부터가 이 호스트에서 관측한 것**이고, 그중 자원의 양과 시간을 잰 것은 제7부뿐이다.
|
||
|
||
**그 관측에는 캡처한 원문이 없다**(observed · 2026-09-16 기준).
|
||
|
||
| 무엇 | 지금 |
|
||
|---|---|
|
||
| 증거 | `evidence/raw` 1 · `evidence/meta` 1 — 두 폴더에 있는 것은 폴더를 설명하는 `README.txt` 한 장씩이고 캡처한 원문은 0건이다 (`rendered`·`browser` 도 같다) |
|
||
|
||
제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 실행 경로를 크게 보면 다음과 같다.
|
||
|
||
```text
|
||
사용자
|
||
|
|
||
| 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다.
|
||
|
||
예:
|
||
|
||
```bash
|
||
virsh start ubuntu-vm
|
||
virsh list
|
||
virsh shutdown ubuntu-vm
|
||
```
|
||
|
||
`virsh start`를 실행했다고 해서 `virsh` 프로세스가 VM을 계속 실행하는 것은 아니다.
|
||
|
||
개념적인 흐름은 다음과 같다.
|
||
|
||
```text
|
||
virsh start ubuntu-vm
|
||
|
|
||
v
|
||
libvirt
|
||
|
|
||
v
|
||
QEMU Process 실행
|
||
```
|
||
|
||
명령 전달이 끝나면 `virsh` 자체는 종료될 수 있고, VM을 실제로 실행하는 QEMU 프로세스는 계속 살아 있다.
|
||
|
||
### 3.2 libvirt
|
||
|
||
libvirt는 VM lifecycle과 구성을 관리하는 계층이다.
|
||
|
||
예를 들어 VM 정의에 다음과 같은 정보가 있다.
|
||
|
||
```text
|
||
RAM: 8 GiB
|
||
vCPU: 4
|
||
Disk: ...
|
||
Network: ...
|
||
```
|
||
|
||
libvirt는 이 정의를 바탕으로 QEMU를 적절한 옵션과 함께 실행하고 관리한다.
|
||
|
||
### 3.3 QEMU
|
||
|
||
QEMU는 Host userspace에서 실행되는 실제 프로세스다.
|
||
|
||
4 vCPU VM이라면 개념적으로 다음과 같은 구조가 만들어진다.
|
||
|
||
```text
|
||
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에 접근한다.
|
||
|
||
```text
|
||
QEMU
|
||
|
|
||
| open("/dev/kvm")
|
||
| ioctl(...)
|
||
v
|
||
/dev/kvm
|
||
|
|
||
v
|
||
KVM
|
||
```
|
||
|
||
대표적인 KVM API에는 다음과 같은 동작이 있다.
|
||
|
||
```text
|
||
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 제조사에 독립적인 공통 부분과 제조사별 구현을 분리해서 볼 수 있다.
|
||
|
||
```text
|
||
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 커널 모듈이 아니다.
|
||
|
||
```text
|
||
VMX = Intel CPU의 하드웨어 가상화 기능
|
||
```
|
||
|
||
VMX에서는 크게 다음 실행 영역을 구분한다.
|
||
|
||
```text
|
||
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를 설정했다고 가정한다.
|
||
|
||
```text
|
||
VM
|
||
|
|
||
+-- vCPU 0
|
||
+-- vCPU 1
|
||
+-- vCPU 2
|
||
+-- vCPU 3
|
||
```
|
||
|
||
Guest OS는 이를 자신의 CPU처럼 인식한다.
|
||
|
||
Host에서는 각 vCPU의 실행 주체에 대응하는 QEMU vCPU thread가 존재한다.
|
||
|
||
```text
|
||
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가 스케줄링 대상으로 보인다.
|
||
|
||
```text
|
||
CPU0 CPU1 CPU2 CPU3 ... CPU11
|
||
```
|
||
|
||
QEMU vCPU thread도 다른 Host thread와 마찬가지로 Linux Scheduler가 실행할 logical CPU를 결정한다.
|
||
|
||
```text
|
||
Chrome Thread ----+
|
||
Java Thread ------+--> Linux Scheduler --> CPU0 ... CPU11
|
||
QEMU vCPU Thread -+
|
||
```
|
||
|
||
따라서 시간에 따라 같은 vCPU thread가 서로 다른 logical CPU에서 실행될 수도 있다.
|
||
|
||
```text
|
||
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`을 요청한다.
|
||
|
||
개념적으로 다음과 같다.
|
||
|
||
```c
|
||
ioctl(vcpu_fd, KVM_RUN, 0);
|
||
```
|
||
|
||
실행 흐름은 다음과 같다.
|
||
|
||
```text
|
||
QEMU vCPU Thread
|
||
|
|
||
| KVM_RUN
|
||
v
|
||
KVM
|
||
|
|
||
| VM Entry
|
||
v
|
||
Physical CPU
|
||
|
|
||
+--> Guest Code
|
||
+--> Guest Code
|
||
+--> Guest Code
|
||
+--> ...
|
||
```
|
||
|
||
이 상태에서 Guest의 일반적인 명령어는 실제 CPU에서 직접 실행된다.
|
||
|
||
예:
|
||
|
||
```text
|
||
ADD
|
||
MOV
|
||
SUB
|
||
CMP
|
||
JMP
|
||
```
|
||
|
||
일반 명령마다 QEMU까지 돌아갔다가 다시 실행하는 구조가 아니다.
|
||
|
||
---
|
||
|
||
## 7. VM Entry와 VM Exit
|
||
|
||
### 7.1 VM Entry
|
||
|
||
KVM이 CPU에게 Guest 실행을 시작하거나 재개하도록 하는 전환이다.
|
||
|
||
```text
|
||
KVM
|
||
|
|
||
| VM Entry
|
||
v
|
||
Guest 실행
|
||
```
|
||
|
||
### 7.2 VM Exit
|
||
|
||
VM Exit은 VM 종료가 아니다.
|
||
|
||
다음과 같은 의미다.
|
||
|
||
> CPU가 VMX Non-Root에서 Guest를 실행하다가 Hypervisor가 개입해야 하는 조건을 만나 Guest 실행에서 빠져나와 VMX Root/KVM 쪽으로 제어권을 넘기는 것.
|
||
|
||
따라서 다음과는 다르다.
|
||
|
||
```text
|
||
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을 사용한다면 다음과 같은 흐름이 가능하다.
|
||
|
||
```text
|
||
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가 이를 가로채도록 설정할 수 있다.
|
||
|
||
예:
|
||
|
||
```asm
|
||
out 0x3f8, al
|
||
```
|
||
|
||
개념적으로:
|
||
|
||
```text
|
||
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 정보를 가상화할 수 있다.
|
||
|
||
```text
|
||
Guest
|
||
|
|
||
| CPUID
|
||
v
|
||
VM Exit
|
||
|
|
||
v
|
||
KVM
|
||
|
|
||
| 가상 CPU 정보 처리
|
||
v
|
||
VM Entry
|
||
```
|
||
|
||
### 8.4 Control Register 접근
|
||
|
||
Guest kernel도 CR0, CR3, CR4 등의 control register를 사용한다.
|
||
|
||
예를 들어 CR3는 페이지 테이블과 관련된 CPU 상태에 사용된다.
|
||
|
||
```asm
|
||
mov cr3, rax
|
||
```
|
||
|
||
하지만 모든 CR 접근이 항상 VM Exit을 발생시키는 것은 아니다.
|
||
|
||
VMX control을 통해 어떤 접근을 가로챌지 결정할 수 있으며, 현대 가상화에서는 성능을 위해 불필요한 Exit을 줄이는 것이 중요하다.
|
||
|
||
### 8.5 MSR 접근
|
||
|
||
CPU에는 MSR(Model-Specific Register)이 있으며 다음 명령으로 접근할 수 있다.
|
||
|
||
```text
|
||
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을 확인한다.
|
||
|
||
```text
|
||
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 상태에 들어갈 수 있다.
|
||
|
||
개념적인 흐름:
|
||
|
||
```text
|
||
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를 다시 실행해야 할 이유가 생기면:
|
||
|
||
```text
|
||
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가 있다고 하자.
|
||
|
||
```text
|
||
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 시간을 두고 경쟁한다.
|
||
|
||
```text
|
||
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은 다음 상황을 나타내는 중요한 단서다.
|
||
|
||
```text
|
||
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:
|
||
|
||
```bash
|
||
grep -E 'vmx|svm' /proc/cpuinfo
|
||
```
|
||
|
||
Intel에서는 `vmx`, AMD에서는 `svm` flag를 확인할 수 있다.
|
||
|
||
### 14.2 KVM 모듈 확인
|
||
|
||
```bash
|
||
lsmod | grep kvm
|
||
```
|
||
|
||
Intel 환경에서는 일반적으로 다음 모듈을 확인할 수 있다.
|
||
|
||
```text
|
||
kvm_intel
|
||
kvm
|
||
```
|
||
|
||
### 14.3 /dev/kvm 확인
|
||
|
||
```bash
|
||
ls -l /dev/kvm
|
||
```
|
||
|
||
QEMU가 KVM API에 접근하는 character device가 존재하는지 확인한다.
|
||
|
||
### 14.4 실행 중인 VM 확인
|
||
|
||
```bash
|
||
virsh list
|
||
```
|
||
|
||
### 14.5 QEMU 프로세스 확인
|
||
|
||
```bash
|
||
ps -ef | grep '[q]emu'
|
||
```
|
||
|
||
`virsh`가 아니라 QEMU 프로세스가 실제 VM lifecycle 동안 살아 있는 것을 확인할 수 있다.
|
||
|
||
### 14.6 QEMU thread 확인
|
||
|
||
```bash
|
||
ps -T -p <QEMU_PID>
|
||
```
|
||
|
||
또는:
|
||
|
||
```bash
|
||
top -H -p <QEMU_PID>
|
||
```
|
||
|
||
환경/QEMU 버전에 따라 이름은 다를 수 있지만 vCPU 관련 thread를 Host에서 관찰할 수 있다.
|
||
|
||
### 14.7 thread가 실행되는 Host CPU 확인
|
||
|
||
```bash
|
||
ps -eLo pid,tid,psr,pcpu,comm | grep qemu
|
||
```
|
||
|
||
`PSR`을 통해 thread가 최근 실행된 logical CPU를 관찰할 수 있다.
|
||
|
||
이는 vCPU가 물리 CPU에 영구 고정되어 있다는 의미가 아니며, pinning을 하지 않았다면 스케줄링에 따라 달라질 수 있다.
|
||
|
||
### 14.8 Guest의 steal time 확인
|
||
|
||
Guest 내부:
|
||
|
||
```bash
|
||
top
|
||
```
|
||
|
||
또는 CPU 통계를 제공하는 다른 Linux 도구에서 steal time을 확인한다.
|
||
|
||
### 14.9 KVM Exit 관찰
|
||
|
||
환경이 지원하면 `perf kvm`을 이용해 KVM 관련 runtime 통계를 확인할 수 있다.
|
||
|
||
예:
|
||
|
||
```bash
|
||
sudo perf kvm stat live
|
||
```
|
||
|
||
지원되는 명령과 표시되는 Exit reason은 kernel, perf 버전, CPU architecture 및 설정에 따라 다를 수 있으므로 실제 환경에서는 다음을 함께 확인한다.
|
||
|
||
```bash
|
||
perf kvm --help
|
||
```
|
||
|
||
필요하면 KVM tracepoint를 이용한 별도 tracing도 검토한다.
|
||
|
||
---
|
||
|
||
## 15. CPU 가상화 관점에서 장애를 보는 방법
|
||
|
||
VM 안의 application이 느릴 때 바로 application 문제라고 결론 내리지 않는다.
|
||
|
||
CPU 실행 경로를 기준으로 다음 계층을 분리한다.
|
||
|
||
```text
|
||
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 경쟁의 원인은 아니다.
|
||
|
||
현재 원래 검증하려는 구조는 다음과 같다.
|
||
|
||
```text
|
||
Client
|
||
|
|
||
v
|
||
Nginx / Load Balancer
|
||
|
|
||
v
|
||
K3s
|
||
|
|
||
+--> Keycloak Node 1
|
||
|
|
||
+--> Keycloak Node 2
|
||
|
|
||
v
|
||
Session / Token State
|
||
|
|
||
+------+------+
|
||
| |
|
||
PostgreSQL Redis
|
||
```
|
||
|
||
테스트 환경에서는 이 구조 아래에 KVM 계층이 추가된다.
|
||
|
||
```text
|
||
Physical Host
|
||
|
|
||
+-- Host Nginx
|
||
|
|
||
+-- VM 1
|
||
| |
|
||
| +-- K3s Node / Keycloak
|
||
|
|
||
+-- VM 2
|
||
|
|
||
+-- K3s Node / Keycloak
|
||
```
|
||
|
||
따라서 테스트 결과를 해석할 때 다음 원인을 분리해야 한다.
|
||
|
||
```text
|
||
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%까지 밀 필요는 없다.
|
||
|
||
예:
|
||
|
||
```text
|
||
Same User
|
||
Same Session
|
||
Same Refresh Token
|
||
|
|
||
+--> Request A --> Node 1
|
||
|
|
||
+--> Request B --> Node 2
|
||
거의 동시에
|
||
```
|
||
|
||
핵심은 높은 전체 트래픽이 아니라 **동일 상태에 대한 동시 접근**이다.
|
||
|
||
사용자 한 명이라도 race condition은 발생할 수 있다.
|
||
|
||
사용자와 트래픽이 많아지면 이런 경쟁이 실제 운영에서 발생할 확률이 높아질 뿐이다.
|
||
|
||
### 17.2 Load / Stress Test
|
||
|
||
별도로 전체 부하를 증가시키면서 시스템의 자원 한계를 확인한다.
|
||
|
||
예:
|
||
|
||
```text
|
||
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 경로는 다음과 같다.
|
||
|
||
```text
|
||
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를 구성하면 다음 계층이 추가된다.
|
||
|
||
```text
|
||
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 상태를 비교한다.
|
||
|
||
확인:
|
||
|
||
```bash
|
||
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 위에 있는지 확인한다.
|
||
|
||
구조에 따라 진단 지표가 달라진다.
|
||
|
||
```text
|
||
Bare metal:
|
||
K3s -> Host Scheduler -> Physical CPU
|
||
|
||
VM:
|
||
K3s -> Guest -> vCPU -> Hypervisor -> Physical CPU
|
||
```
|
||
|
||
---
|
||
|
||
## 21. OPEN QUESTION에서 CASE가 만들어지는 흐름
|
||
|
||
현재 문서 체계에서는 다음 관계를 사용한다.
|
||
|
||
```text
|
||
SSOT
|
||
|
|
||
v
|
||
CONCEPT
|
||
|
|
||
| 이해하면서 검증이 필요한 질문 발생
|
||
v
|
||
OPEN QUESTION
|
||
|
|
||
| 실제 구성 / 명령 / 부하 / 관찰
|
||
v
|
||
CASE
|
||
|
|
||
| 결과에서 새로운 의문 발견
|
||
+------------------> OPEN QUESTION
|
||
```
|
||
|
||
즉 OPEN QUESTION은 CASE에서만 나오는 것이 아니다.
|
||
|
||
```text
|
||
CONCEPT -> OPEN QUESTION
|
||
CASE -> OPEN QUESTION
|
||
```
|
||
|
||
둘 다 가능하다.
|
||
|
||
그리고 OPEN QUESTION을 실제 실험으로 해소하는 과정에서 새로운 CASE가 만들어질 수 있다.
|
||
|
||
예:
|
||
|
||
```text
|
||
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에서 명령을 실행해 다음을 검증한다.
|
||
|
||
```text
|
||
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 안의 애플리케이션이 느려졌을 때 어느 계층에서 문제가 발생했는지 구분하는 것이다.
|
||
|
||
```text
|
||
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를 모두 사용하고 있는 경우다.
|
||
|
||
```text
|
||
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에 할당하는 구성이다.
|
||
|
||
예:
|
||
|
||
```text
|
||
Host: 12 logical CPUs
|
||
|
||
VM1: 8 vCPU
|
||
VM2: 8 vCPU
|
||
VM3: 8 vCPU
|
||
|
||
Total: 24 vCPU
|
||
```
|
||
|
||
Overcommit 자체가 바로 장애라는 의미는 아니다. VM들이 대부분 idle이라면 문제가 없을 수 있다.
|
||
|
||
문제는 여러 VM의 vCPU가 동시에 runnable 상태가 될 때 나타난다.
|
||
|
||
```text
|
||
많은 runnable vCPU threads
|
||
|
|
||
v
|
||
Host Scheduler
|
||
|
|
||
v
|
||
제한된 logical CPUs
|
||
```
|
||
|
||
이때 CPU contention과 scheduling latency가 증가할 수 있다.
|
||
|
||
### 24.3 CPU Contention
|
||
|
||
여러 runnable thread가 같은 Host CPU 자원을 두고 경쟁하는 상태다.
|
||
|
||
경쟁 대상은 QEMU vCPU thread만이 아니다.
|
||
|
||
```text
|
||
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으로 관찰할 수 있다.
|
||
|
||
```text
|
||
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에 배치할 때까지 기다릴 수 있다.
|
||
|
||
```text
|
||
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가 집중될 수 있다.
|
||
|
||
```text
|
||
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이 제한될 수 있다.
|
||
|
||
```text
|
||
Physical CPU
|
||
|
|
||
Host / Hypervisor
|
||
|
|
||
Guest Linux
|
||
|
|
||
K3s
|
||
|
|
||
cgroup CPU limit
|
||
|
|
||
Keycloak Pod
|
||
```
|
||
|
||
이 경우 Host CPU에 여유가 있어도 Keycloak Pod는 설정된 CPU quota 때문에 실행이 제한될 수 있다.
|
||
|
||
따라서 다음 두 문제를 구분해야 한다.
|
||
|
||
```text
|
||
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이 지나치게 빈번하고 그 처리 비용이 커진다면 성능에 영향을 줄 수 있다.
|
||
|
||
```text
|
||
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가 함께 동작한다면 다음과 같은 경쟁이 가능하다.
|
||
|
||
```text
|
||
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의 관계가 성능에 영향을 줄 수 있다.
|
||
|
||
개념적으로:
|
||
|
||
```text
|
||
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 또는 저장소 경쟁 문제라고 판단하지 않는다.
|
||
|
||
최소한 다음 경계를 분리한다.
|
||
|
||
```text
|
||
[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 자원 포화를 관찰한다.
|
||
|
||
이렇게 해야 다음 두 결과를 분리할 수 있다.
|
||
|
||
```text
|
||
"동일 상태에 동시에 접근해서 발생한 문제"
|
||
|
||
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에서는 다음 수준까지만 확정한다.
|
||
|
||
```text
|
||
구조적으로 어떤 문제가 발생할 수 있는가?
|
||
어떤 지표로 그 문제를 의심할 수 있는가?
|
||
어느 계층에서 확인해야 하는가?
|
||
```
|
||
|
||
현재 테스트 서버에서 실제로 발생하는지는 CONCEPT에서 사실로 확정하지 않는다.
|
||
|
||
예:
|
||
|
||
```text
|
||
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을 생성한다.
|
||
|
||
```text
|
||
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의 메모리 접근을 가장 단순하게 표현하면 다음과 같다.
|
||
|
||
```text
|
||
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이 어떤 변수를 읽는다고 하자.
|
||
|
||
```text
|
||
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 안에 다음 프로세스가 있다고 하자.
|
||
|
||
```text
|
||
Guest VM
|
||
|
||
├─ Keycloak
|
||
├─ PostgreSQL
|
||
├─ nginx
|
||
└─ systemd
|
||
```
|
||
|
||
각 프로세스에는 독립적인 Virtual Address Space가 있다.
|
||
|
||
```text
|
||
Keycloak Process
|
||
|
||
Virtual Address Space
|
||
┌─────────────────────────┐
|
||
│ 0x1000 │
|
||
│ 0x2000 │
|
||
│ 0x3000 │
|
||
│ ... │
|
||
└─────────────────────────┘
|
||
|
||
|
||
PostgreSQL Process
|
||
|
||
Virtual Address Space
|
||
┌─────────────────────────┐
|
||
│ 0x1000 │
|
||
│ 0x2000 │
|
||
│ 0x3000 │
|
||
│ ... │
|
||
└─────────────────────────┘
|
||
```
|
||
|
||
두 프로세스가 모두 `0x1000`이라는 주소를 사용할 수 있다. 같은 Virtual Address라도 서로 다른 physical frame으로 매핑할 수 있기 때문이다.
|
||
|
||
```text
|
||
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다.
|
||
|
||
```text
|
||
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 단위로 생각할 수 있다.
|
||
|
||
```text
|
||
Guest Physical Memory
|
||
|
||
┌───────────────┐
|
||
│ Frame 0 │
|
||
├───────────────┤
|
||
│ Frame 1 │
|
||
├───────────────┤
|
||
│ Frame 2 │
|
||
├───────────────┤
|
||
│ Frame 3 │
|
||
└───────────────┘
|
||
```
|
||
|
||
따라서 Page Table의 핵심 역할은 다음과 같다.
|
||
|
||
```text
|
||
Virtual Page
|
||
↓
|
||
Page Table
|
||
↓
|
||
Physical Frame
|
||
```
|
||
|
||
---
|
||
|
||
## 32. Virtual Address = Page + Offset
|
||
|
||
예를 들어 기본 page 크기가 4 KiB(`0x1000`)이고 프로세스가 `0x1234`에 접근한다고 하자.
|
||
|
||
```text
|
||
Virtual Address
|
||
0x1234
|
||
|
||
┌──────────────┬─────────────┐
|
||
│ Virtual Page │ Offset │
|
||
│ 1 │ 0x234 │
|
||
└──────────────┴─────────────┘
|
||
```
|
||
|
||
Page Table에 다음 mapping이 있다고 가정한다.
|
||
|
||
```text
|
||
Virtual Page 1
|
||
↓
|
||
Guest Physical Frame 7
|
||
```
|
||
|
||
그러면 주소 변환 후에도 page 내부 offset `0x234`는 유지된다.
|
||
|
||
```text
|
||
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을 관리한다.
|
||
|
||
단순화한 예:
|
||
|
||
```text
|
||
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)다.
|
||
|
||
```text
|
||
CPU
|
||
│
|
||
│ Virtual Address
|
||
▼
|
||
MMU
|
||
│
|
||
│ Page Table 기반 translation
|
||
▼
|
||
Physical Address
|
||
```
|
||
|
||
현재 Guest 내부 단계만 보면:
|
||
|
||
```text
|
||
Guest Virtual Address
|
||
↓
|
||
MMU
|
||
│
|
||
│ Guest Page Table
|
||
▼
|
||
Guest Physical Address
|
||
```
|
||
|
||
역할을 나누면 다음과 같다.
|
||
|
||
```text
|
||
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한다.
|
||
|
||
```text
|
||
Virtual Address
|
||
↓
|
||
TLB
|
||
┌──┴──┐
|
||
│ │
|
||
HIT MISS
|
||
│ │
|
||
│ ▼
|
||
│ Page Table Walk
|
||
│ │
|
||
└──┬──┘
|
||
▼
|
||
Physical Address
|
||
```
|
||
|
||
예를 들어:
|
||
|
||
```text
|
||
Virtual Page 1 → Physical Frame 7
|
||
```
|
||
|
||
이라는 translation이 TLB에 있다면 같은 page의 다음 접근에서 전체 page-table walk를 피할 수 있다.
|
||
|
||
#### TLB Miss와 Page Fault는 다르다
|
||
|
||
TLB Miss:
|
||
|
||
```text
|
||
TLB에 translation cache가 없음
|
||
↓
|
||
Page Table을 조회
|
||
↓
|
||
정상 mapping 존재
|
||
↓
|
||
계속 실행
|
||
```
|
||
|
||
Page Fault:
|
||
|
||
```text
|
||
Page Table 상태상
|
||
현재 접근을 정상 완료할 수 없음
|
||
```
|
||
|
||
따라서:
|
||
|
||
```text
|
||
TLB Miss ≠ Page Fault
|
||
```
|
||
|
||
다.
|
||
|
||
---
|
||
|
||
## 36. Bare Metal과 VM의 차이
|
||
|
||
Bare-metal Linux에서는 개념적으로 다음으로 끝난다.
|
||
|
||
```text
|
||
Process Virtual Address
|
||
↓
|
||
Page Table
|
||
↓
|
||
Host Physical Address
|
||
↓
|
||
Physical RAM
|
||
```
|
||
|
||
VM에서는 Guest가 얻은 physical address가 실제 Host physical address가 아니다.
|
||
|
||
```text
|
||
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 계열 기능이 있다.
|
||
|
||
```text
|
||
Guest가 관리
|
||
|
||
GVA
|
||
│
|
||
│ Guest Page Table
|
||
▼
|
||
GPA
|
||
|
||
Hypervisor 측
|
||
|
||
GPA
|
||
│
|
||
│ EPT
|
||
▼
|
||
HPA
|
||
```
|
||
|
||
합치면:
|
||
|
||
```text
|
||
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를 사용할 수 있다.
|
||
|
||
```text
|
||
VM1: GPA 0x1000
|
||
VM2: GPA 0x1000
|
||
```
|
||
|
||
그러나 실제 Host RAM에서는 서로 다른 위치로 연결할 수 있어야 한다.
|
||
|
||
```text
|
||
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을 관리하는 방식이 사용될 수 있었다.
|
||
|
||
개념적으로:
|
||
|
||
```text
|
||
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를 갖는다.
|
||
|
||
```text
|
||
QEMU Process
|
||
|
||
Host Virtual Address Space
|
||
|
||
┌──────────────────────────────┐
|
||
│ │
|
||
│ Guest RAM Backing │
|
||
│ 8 GiB │
|
||
│ │
|
||
└──────────────────────────────┘
|
||
```
|
||
|
||
QEMU가 직접 "물리 주소 X부터 8 GiB를 달라"고 RAM hardware를 제어하는 것이 아니다.
|
||
|
||
QEMU memory도 일반 Host process memory처럼:
|
||
|
||
```text
|
||
QEMU Host Virtual Address
|
||
↓
|
||
Host Page Table
|
||
↓
|
||
Host Physical Address
|
||
```
|
||
|
||
로 관리된다.
|
||
|
||
---
|
||
|
||
## 41. KVM_SET_USER_MEMORY_REGION
|
||
|
||
QEMU는 자신이 마련한 Host userspace memory 영역과 Guest GPA 범위의 관계를 KVM에 등록한다.
|
||
|
||
대표 ioctl:
|
||
|
||
```text
|
||
KVM_SET_USER_MEMORY_REGION
|
||
```
|
||
|
||
개념적으로 전달하는 정보:
|
||
|
||
```text
|
||
Guest GPA Range
|
||
↕
|
||
QEMU Host Virtual Address Range
|
||
```
|
||
|
||
예:
|
||
|
||
```text
|
||
Guest GPA
|
||
|
||
0x00000000
|
||
│
|
||
│ 8 GiB
|
||
▼
|
||
...
|
||
|
||
↕ backing
|
||
|
||
QEMU HVA
|
||
|
||
0x7f0000000000
|
||
│
|
||
│ 8 GiB
|
||
▼
|
||
...
|
||
```
|
||
|
||
역할을 정리하면:
|
||
|
||
```text
|
||
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가 반드시 모두 즉시 물리적으로 점유되는 것은 아니다.
|
||
|
||
```text
|
||
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에서 중요한 점이다.
|
||
|
||
```text
|
||
GVA
|
||
↓
|
||
Guest Page Table
|
||
↓
|
||
GPA
|
||
↓
|
||
EPT
|
||
↓
|
||
HPA
|
||
```
|
||
|
||
그런데 Guest Page Table 자체도 Guest Physical Memory에 저장된 자료구조다.
|
||
|
||
따라서 CPU가 Guest page-table entry를 읽는 과정에서도 그 entry가 저장된 GPA를 실제 HPA로 변환해야 한다.
|
||
|
||
개념적으로:
|
||
|
||
```text
|
||
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 접근을 다음처럼 생각하면 안 된다.
|
||
|
||
```text
|
||
잘못된 이해
|
||
|
||
Guest Memory Access
|
||
↓
|
||
VM Exit
|
||
↓
|
||
KVM
|
||
↓
|
||
RAM
|
||
```
|
||
|
||
정상 mapping이 존재하면 CPU hardware가 직접 translation을 수행한다.
|
||
|
||
```text
|
||
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 단계에서 발생한다.
|
||
|
||
```text
|
||
GVA
|
||
↓
|
||
Guest Page Table
|
||
↓
|
||
현재 접근을 완료할 수 없음
|
||
↓
|
||
Guest #PF
|
||
↓
|
||
Guest Kernel Page Fault Handler
|
||
```
|
||
|
||
예를 들어 Guest process가 아직 physical page가 붙지 않은 virtual-memory 영역에 처음 접근할 수 있다.
|
||
|
||
```text
|
||
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
|
||
|
||
```text
|
||
Virtual Memory 영역 존재
|
||
↓
|
||
아직 physical page가 필요하지 않았음
|
||
↓
|
||
첫 실제 접근
|
||
↓
|
||
Page Fault
|
||
↓
|
||
Guest Kernel이 page 준비
|
||
```
|
||
|
||
#### 46.2 Swap-in
|
||
|
||
```text
|
||
필요한 page가 Guest RAM에 없음
|
||
↓
|
||
Page Fault
|
||
↓
|
||
Guest Kernel
|
||
↓
|
||
Guest Swap에서 읽음
|
||
↓
|
||
RAM 복원
|
||
↓
|
||
Page Table 갱신
|
||
```
|
||
|
||
#### 46.3 Permission Fault
|
||
|
||
Page Table Entry에는 mapping뿐 아니라 permission도 있다.
|
||
|
||
```text
|
||
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` 등으로 이어질 수 있다.
|
||
|
||
```text
|
||
Invalid GVA
|
||
↓
|
||
Page Fault
|
||
↓
|
||
Guest Kernel
|
||
↓
|
||
해결 불가
|
||
↓
|
||
SIGSEGV
|
||
```
|
||
|
||
따라서:
|
||
|
||
```text
|
||
Page Fault ≠ Segmentation Fault
|
||
```
|
||
|
||
다.
|
||
|
||
---
|
||
|
||
## 47. EPT Violation
|
||
|
||
이번에는 Guest Page Table translation은 성공했다고 하자.
|
||
|
||
```text
|
||
GVA
|
||
↓
|
||
Guest Page Table
|
||
↓
|
||
GPA
|
||
```
|
||
|
||
그런데 해당 GPA에 대한 second-stage 접근을 현재 EPT 조건으로 완료할 수 없다.
|
||
|
||
```text
|
||
GPA
|
||
↓
|
||
EPT
|
||
↓
|
||
Violation
|
||
```
|
||
|
||
이것이 EPT Violation이다.
|
||
|
||
```text
|
||
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 측 |
|
||
| 앱 오류를 뜻하는가 | 반드시 아님 | 반드시 아님 |
|
||
|
||
핵심:
|
||
|
||
```text
|
||
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 관리가 적용된다.
|
||
|
||
```text
|
||
QEMU Host Virtual Address
|
||
↓
|
||
Host Page Table
|
||
↓
|
||
Host Physical Address
|
||
```
|
||
|
||
따라서 Host 측에서도 demand allocation, reclaim/swap 등의 이유로 page fault가 발생할 수 있다.
|
||
|
||
```text
|
||
QEMU / Guest RAM Backing
|
||
↓
|
||
Host Virtual Memory
|
||
↓
|
||
Host Page Fault
|
||
↓
|
||
Host Kernel
|
||
↓
|
||
필요한 Host page 처리
|
||
```
|
||
|
||
즉 VM 메모리 분석에서는 적어도 다음을 구분해야 한다.
|
||
|
||
```text
|
||
Guest Page Fault
|
||
Host Page Fault
|
||
EPT-related virtualization event
|
||
```
|
||
|
||
---
|
||
|
||
## 50. Huge Page가 필요한 이유
|
||
|
||
8 GiB를 모두 4 KiB page 단위로 표현하면:
|
||
|
||
```text
|
||
8 GiB / 4 KiB
|
||
= 2,097,152 pages
|
||
```
|
||
|
||
2 MiB page라면:
|
||
|
||
```text
|
||
8 GiB / 2 MiB
|
||
= 4,096 pages
|
||
```
|
||
|
||
1 GiB page라면:
|
||
|
||
```text
|
||
8 GiB / 1 GiB
|
||
= 8 pages
|
||
```
|
||
|
||
큰 page는 더 적은 mapping으로 넓은 memory range를 표현할 수 있다.
|
||
|
||
---
|
||
|
||
## 51. Huge Page와 TLB Coverage
|
||
|
||
TLB entry 하나가 표현하는 page가 커지면 하나의 cached translation으로 더 넓은 주소 범위를 커버할 수 있다.
|
||
|
||
단순 예:
|
||
|
||
```text
|
||
4 KiB page × 512 mappings
|
||
= 2 MiB coverage
|
||
|
||
2 MiB page × 512 mappings
|
||
= 1 GiB coverage
|
||
```
|
||
|
||
실제 CPU는 page size별 TLB 구조와 entry 수가 다르므로 이 숫자를 특정 CPU의 실제 TLB 용량으로 해석하면 안 된다.
|
||
|
||
핵심은:
|
||
|
||
```text
|
||
Page Size ↑
|
||
↓
|
||
한 translation이 cover하는 범위 ↑
|
||
↓
|
||
TLB pressure 감소 가능
|
||
```
|
||
|
||
이다.
|
||
|
||
추가로 page-table entry 수와 page-table walk 부담도 줄어들 가능성이 있다.
|
||
|
||
---
|
||
|
||
## 52. VM에서 Huge Page를 볼 때 주의할 점
|
||
|
||
VM에는 두 translation 단계가 있다.
|
||
|
||
```text
|
||
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를 투명하게 활용하려는 기능이다.
|
||
|
||
```text
|
||
Application
|
||
↓
|
||
일반 malloc()/mmap()
|
||
↓
|
||
Linux Kernel
|
||
↓
|
||
조건이 맞으면 Huge Page 활용 시도
|
||
```
|
||
|
||
상태 확인:
|
||
|
||
```bash
|
||
cat /sys/kernel/mm/transparent_hugepage/enabled
|
||
```
|
||
|
||
예:
|
||
|
||
```text
|
||
always [madvise] never
|
||
```
|
||
|
||
현재 정책은 kernel/distribution/Host 설정에 따라 다르므로 실제 시스템에서 확인한다.
|
||
|
||
---
|
||
|
||
## 54. THP의 Trade-off
|
||
|
||
Huge Page에는 큰 contiguous physical-memory 영역이 필요하다.
|
||
|
||
2 MiB는 4 KiB page 512개 크기다.
|
||
|
||
```text
|
||
4 KiB × 512 = 2 MiB
|
||
```
|
||
|
||
memory fragmentation이 심하면 Kernel이 compaction 등의 작업을 수행할 수 있다.
|
||
|
||
```text
|
||
Huge Page 필요
|
||
↓
|
||
큰 contiguous memory 필요
|
||
↓
|
||
Fragmentation
|
||
↓
|
||
Compaction 가능
|
||
↓
|
||
Latency 영향 가능
|
||
```
|
||
|
||
따라서 THP는 항상 성능을 높인다고 단정할 수 없다. 특히 latency-sensitive workload에서는 측정이 필요하다.
|
||
|
||
---
|
||
|
||
## 55. HugeTLB
|
||
|
||
HugeTLB는 명시적인 Huge Page pool을 사용할 수 있는 Linux 메커니즘이다.
|
||
|
||
THP:
|
||
|
||
```text
|
||
Application
|
||
↓
|
||
일반 Memory Allocation
|
||
↓
|
||
Kernel이 자동적으로 Huge Page 활용
|
||
```
|
||
|
||
HugeTLB:
|
||
|
||
```text
|
||
관리자가 Huge Page Pool 준비
|
||
↓
|
||
Application / VM이 명시적으로 사용
|
||
```
|
||
|
||
예:
|
||
|
||
```text
|
||
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 확인:
|
||
|
||
```bash
|
||
grep -i huge /proc/meminfo
|
||
cat /sys/kernel/mm/transparent_hugepage/enabled
|
||
```
|
||
|
||
`AnonHugePages`와 `HugePages_Total`은 같은 의미가 아니다.
|
||
|
||
---
|
||
|
||
## 57. Memory Overcommit
|
||
|
||
예를 들어:
|
||
|
||
```text
|
||
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가 같지 않을 수 있기 때문이다.
|
||
|
||
예:
|
||
|
||
```text
|
||
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:
|
||
|
||
```text
|
||
CPU 부족
|
||
↓
|
||
Scheduler가 execution time을 나눔
|
||
↓
|
||
Runnable task가 기다림
|
||
```
|
||
|
||
Memory:
|
||
|
||
```text
|
||
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을 시도한다.
|
||
|
||
```text
|
||
Memory Pressure 증가
|
||
↓
|
||
Reclaim
|
||
↓
|
||
회수 가능한 cache/page 처리
|
||
↓
|
||
필요하면 anonymous memory swap
|
||
↓
|
||
그래도 부족
|
||
↓
|
||
심각한 pressure / OOM 가능
|
||
```
|
||
|
||
#### File-backed clean page
|
||
|
||
원본이 storage에 있으므로 RAM에서 버리고 필요할 때 다시 읽을 수 있다.
|
||
|
||
```text
|
||
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에 접근한다고 생각한다.
|
||
|
||
```text
|
||
Keycloak
|
||
↓
|
||
Guest Memory Load
|
||
```
|
||
|
||
하지만 Host에서는:
|
||
|
||
```text
|
||
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:
|
||
|
||
```text
|
||
Guest Application
|
||
↓
|
||
Guest Memory Pressure
|
||
↓
|
||
Guest Kernel
|
||
↓
|
||
Guest Swap
|
||
↓
|
||
/dev/vda
|
||
↓
|
||
virtio-blk
|
||
↓
|
||
QEMU
|
||
↓
|
||
Host Storage
|
||
```
|
||
|
||
Host Swap:
|
||
|
||
```text
|
||
Guest RAM
|
||
↓
|
||
QEMU Memory Backing
|
||
↓
|
||
Host Memory Pressure
|
||
↓
|
||
Host Kernel
|
||
↓
|
||
Host Swap
|
||
```
|
||
|
||
따라서:
|
||
|
||
```text
|
||
Guest Swap ≠ Host Swap
|
||
```
|
||
|
||
이다.
|
||
|
||
Guest가 메모리 여유가 있어 보이는데 Host에서 swap/reclaim이 심할 수도 있다.
|
||
|
||
---
|
||
|
||
## 62. Memory Pressure와 Storage Contention의 연결
|
||
|
||
Guest와 Host가 동시에 memory pressure를 겪으면 다음 I/O가 한 storage device로 몰릴 수 있다.
|
||
|
||
```text
|
||
Guest Swap I/O ────────┐
|
||
Host Swap I/O ─────────┼──→ Physical NVMe
|
||
Database I/O ──────────┤
|
||
Filesystem Writeback ──┘
|
||
```
|
||
|
||
따라서:
|
||
|
||
```text
|
||
Host Memory Pressure
|
||
↓
|
||
Reclaim / Swap
|
||
↓
|
||
Storage I/O 증가
|
||
↓
|
||
Storage Contention
|
||
↓
|
||
DB latency 증가
|
||
↓
|
||
Application latency 증가
|
||
```
|
||
|
||
가 가능하다.
|
||
|
||
CPU 사용률이 낮다고 해서 memory/storage 문제가 없는 것은 아니다.
|
||
|
||
---
|
||
|
||
## 63. Swap Used만 보고 장애를 판단하면 안 된다
|
||
|
||
예:
|
||
|
||
```text
|
||
Swap Used = 2 GiB
|
||
```
|
||
|
||
만으로 현재 memory pressure가 심하다고 단정할 수 없다. 과거에 swap-out된 cold page가 남아 있을 수도 있다.
|
||
|
||
더 중요한 질문:
|
||
|
||
```text
|
||
현재 swap-in/out이 지속되는가?
|
||
reclaim pressure가 증가하는가?
|
||
major fault가 증가하는가?
|
||
storage latency가 같이 증가하는가?
|
||
```
|
||
|
||
Guest와 Host를 동시에 확인해야 한다.
|
||
|
||
```bash
|
||
free -h
|
||
vmstat 1
|
||
```
|
||
|
||
---
|
||
|
||
## 64. Ballooning이 필요한 이유
|
||
|
||
Host는 QEMU의 Guest RAM backing을 볼 수 있지만 Guest 내부에서 어떤 memory가 중요한지 완전히 알지 못한다.
|
||
|
||
Guest는 다음 semantics를 알고 있다.
|
||
|
||
```text
|
||
Guest Memory
|
||
|
||
├─ Application Working Set
|
||
├─ JVM Heap
|
||
├─ Page Cache
|
||
├─ Free
|
||
└─ 기타
|
||
```
|
||
|
||
Host가 무작정 Guest backing을 swap-out하기보다 Guest Kernel과 협력해 불필요한 memory를 반환받는 것이 유리할 수 있다.
|
||
|
||
대표적인 메커니즘이 `virtio-balloon`이다.
|
||
|
||
---
|
||
|
||
## 65. virtio-balloon 구조
|
||
|
||
```text
|
||
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한다.
|
||
|
||
```text
|
||
Host/QEMU
|
||
│
|
||
│ Balloon target 조정
|
||
▼
|
||
virtio-balloon
|
||
│
|
||
════════ VM Boundary ═══════
|
||
│
|
||
▼
|
||
Guest Balloon Driver
|
||
│
|
||
│ Guest pages 확보
|
||
▼
|
||
Guest usable memory 감소
|
||
```
|
||
|
||
Guest 안의 balloon이 커지기 때문에 Guest가 사용할 수 있는 RAM이 줄어든다.
|
||
|
||
```text
|
||
Before
|
||
|
||
┌──────────────────────────┐
|
||
│ Guest Usable │
|
||
│ Memory │
|
||
└──────────────────────────┘
|
||
|
||
|
||
After Inflate
|
||
|
||
┌──────────────────────────┐
|
||
│ Guest Usable │
|
||
│ Memory │
|
||
├──────────────────────────┤
|
||
│ Balloon │
|
||
└──────────────────────────┘
|
||
```
|
||
|
||
개념:
|
||
|
||
```text
|
||
Balloon Inflate
|
||
→ Guest usable memory ↓
|
||
→ Host가 회수할 수 있는 backing memory ↑
|
||
```
|
||
|
||
---
|
||
|
||
## 67. Balloon Page 반환의 의미
|
||
|
||
Guest balloon driver는 Guest pages를 확보하고 관련 정보를 Host 측에 전달한다.
|
||
|
||
```text
|
||
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을 줄인다.
|
||
|
||
```text
|
||
Host/QEMU
|
||
↓
|
||
Balloon target 감소
|
||
↓
|
||
Guest Balloon Driver
|
||
↓
|
||
Balloon pages 반환
|
||
↓
|
||
Guest usable memory 증가
|
||
```
|
||
|
||
따라서:
|
||
|
||
```text
|
||
Inflate = Guest usable memory 감소
|
||
Deflate = Guest usable memory 증가
|
||
```
|
||
|
||
다.
|
||
|
||
---
|
||
|
||
## 69. Ballooning을 과도하게 하면 Guest가 압박을 받는다
|
||
|
||
Guest application working set이 큰데 balloon을 과도하게 inflate하면:
|
||
|
||
```text
|
||
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:
|
||
|
||
```text
|
||
기존 Guest Memory Capacity
|
||
↓
|
||
그 범위에서 Host/Guest 간
|
||
usable memory를 회수/반환
|
||
```
|
||
|
||
Memory Hotplug:
|
||
|
||
```text
|
||
기존 Guest RAM
|
||
+
|
||
추가 Memory Device/Region
|
||
↓
|
||
Guest가 추가 capacity 인식
|
||
```
|
||
|
||
따라서:
|
||
|
||
```text
|
||
Ballooning ≠ Memory Hotplug
|
||
```
|
||
|
||
다.
|
||
|
||
현대 가상화에서는 `virtio-mem` 같은 다른 동적 memory 관리 방식도 존재하므로 모든 동적 VM memory 관리를 ballooning 하나로 일반화하면 안 된다.
|
||
|
||
---
|
||
|
||
## 71. OOM
|
||
|
||
Linux가 memory allocation을 만족시키지 못하고 reclaim 등의 방법으로도 필요한 memory를 확보하지 못하면 OOM 상황이 발생할 수 있다.
|
||
|
||
```text
|
||
Memory Allocation 필요
|
||
↓
|
||
Reclaim 등 시도
|
||
↓
|
||
충분한 Memory 확보 실패
|
||
↓
|
||
OOM
|
||
↓
|
||
OOM Killer
|
||
↓
|
||
Process 선택/종료 가능
|
||
↓
|
||
Memory 확보
|
||
```
|
||
|
||
---
|
||
|
||
## 72. Guest OOM과 Host OOM
|
||
|
||
Guest OOM:
|
||
|
||
```text
|
||
Guest RAM 부족
|
||
↓
|
||
Guest Kernel OOM
|
||
↓
|
||
Guest Process Kill
|
||
|
||
예: Keycloak process 종료
|
||
```
|
||
|
||
Host OOM:
|
||
|
||
```text
|
||
Host Physical RAM 부족
|
||
↓
|
||
Host Kernel OOM
|
||
↓
|
||
Host Process Kill 가능
|
||
```
|
||
|
||
Host OOM에서 QEMU가 victim이 되면:
|
||
|
||
```text
|
||
QEMU process killed
|
||
↓
|
||
해당 VM 전체가 중단
|
||
```
|
||
|
||
될 수 있다.
|
||
|
||
따라서:
|
||
|
||
```text
|
||
Guest OOM ≠ Host OOM
|
||
```
|
||
|
||
이다.
|
||
|
||
또한 cgroup memory limit이 있는 환경에서는 Host 전체 RAM이 남아 있어도 해당 cgroup boundary에서 OOM이 발생할 수 있으므로 OOM의 **발생 계층**을 확인해야 한다.
|
||
|
||
---
|
||
|
||
## 73. NUMA
|
||
|
||
지금까지는 RAM을 하나의 균일한 자원처럼 표현했다. multi-socket/NUMA 시스템에서는 어느 CPU가 어느 RAM에 접근하느냐에 따라 비용이 달라질 수 있다.
|
||
|
||
```text
|
||
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:
|
||
|
||
```text
|
||
NUMA Node 0
|
||
|
||
CPU
|
||
│
|
||
▼
|
||
Node 0 RAM
|
||
```
|
||
|
||
Remote:
|
||
|
||
```text
|
||
NUMA Node 0 NUMA Node 1
|
||
|
||
CPU
|
||
│
|
||
└──────── Interconnect ───────→ RAM
|
||
```
|
||
|
||
일반적으로 remote access는 local access와 동일한 비용이라고 가정할 수 없으며 추가 latency/bandwidth 비용이 있을 수 있다.
|
||
|
||
---
|
||
|
||
## 75. vCPU와 NUMA의 연결
|
||
|
||
Guest vCPU는 Host에서 QEMU의 vCPU thread다.
|
||
|
||
```text
|
||
Guest vCPU
|
||
↓
|
||
QEMU vCPU Thread
|
||
↓
|
||
Host Linux Scheduler
|
||
↓
|
||
Host Logical CPU
|
||
```
|
||
|
||
VM1의 vCPU thread가 Node 0 CPU에서 실행되는데 VM1의 Host physical backing page가 Node 1에 있다면:
|
||
|
||
```text
|
||
Node 0 CPU
|
||
│
|
||
│ Remote Access
|
||
▼
|
||
Node 1 RAM
|
||
```
|
||
|
||
이 될 수 있다.
|
||
|
||
Guest에서는 단순한 memory load지만 실제 hardware에서는 NUMA interconnect를 건널 수 있다.
|
||
|
||
---
|
||
|
||
## 76. vCPU Pinning만으로는 NUMA 최적화가 끝나지 않는다
|
||
|
||
예:
|
||
|
||
```text
|
||
VM1 vCPU
|
||
↓
|
||
Node 0 CPU에 Pinning
|
||
|
||
VM1 RAM
|
||
↓
|
||
Node 1에 주로 배치
|
||
```
|
||
|
||
이면 pinning 이후에도 remote memory access가 많아질 수 있다.
|
||
|
||
따라서:
|
||
|
||
```text
|
||
vCPU Placement
|
||
+
|
||
Memory Placement/Binding
|
||
↓
|
||
NUMA Locality
|
||
```
|
||
|
||
를 함께 봐야 한다.
|
||
|
||
이상적인 예:
|
||
|
||
```text
|
||
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 자체를 노출할 수 있다.
|
||
|
||
예:
|
||
|
||
```text
|
||
Guest VM
|
||
|
||
Guest NUMA Node 0
|
||
├─ vCPU 0~7
|
||
└─ RAM 32 GiB
|
||
|
||
Guest NUMA Node 1
|
||
├─ vCPU 8~15
|
||
└─ RAM 32 GiB
|
||
```
|
||
|
||
Host:
|
||
|
||
```text
|
||
Host NUMA Node 0
|
||
├─ Physical CPUs
|
||
└─ RAM
|
||
|
||
Host NUMA Node 1
|
||
├─ Physical CPUs
|
||
└─ RAM
|
||
```
|
||
|
||
가능하면 Guest가 인식하는 topology와 실제 Host placement가 합리적으로 대응되도록 구성할 수 있다.
|
||
|
||
```text
|
||
Guest NUMA 0 → Host NUMA 0
|
||
Guest NUMA 1 → Host NUMA 1
|
||
```
|
||
|
||
---
|
||
|
||
## 78. NUMA는 실제 장비 topology부터 확인한다
|
||
|
||
Host가 NUMA node 1개라면 cross-node remote-memory 문제가 주요 이슈가 아닐 수 있다.
|
||
|
||
```bash
|
||
lscpu
|
||
```
|
||
|
||
예:
|
||
|
||
```text
|
||
NUMA node(s): 2
|
||
NUMA node0 CPU(s): 0-7
|
||
NUMA node1 CPU(s): 8-15
|
||
```
|
||
|
||
추가:
|
||
|
||
```bash
|
||
numactl --hardware
|
||
```
|
||
|
||
QEMU process별 memory distribution:
|
||
|
||
```bash
|
||
numastat -p <QEMU_PID>
|
||
```
|
||
|
||
vCPU placement:
|
||
|
||
```bash
|
||
virsh vcpupin <VM_NAME>
|
||
virsh vcpuinfo <VM_NAME>
|
||
```
|
||
|
||
실제 환경에서는 먼저 topology를 측정하고 NUMA 최적화 필요성을 판단한다.
|
||
|
||
---
|
||
|
||
## 79. 전체 Memory Virtualization 실행 경로
|
||
|
||
최종적으로 Guest application의 memory access는 다음 구조로 이해할 수 있다.
|
||
|
||
```text
|
||
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 관리 경로
|
||
|
||
실행 경로와 관리 경로를 분리해야 한다.
|
||
|
||
```text
|
||
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을 전체적으로 보면:
|
||
|
||
```text
|
||
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는 이 모든 실행을 받친다.
|
||
|
||
```text
|
||
Guest GVA
|
||
↓
|
||
Guest Page Table
|
||
↓
|
||
GPA
|
||
↓
|
||
EPT
|
||
↓
|
||
HPA
|
||
↓
|
||
Host RAM / NUMA
|
||
```
|
||
|
||
그리고 memory pressure는 storage path까지 영향을 줄 수 있다.
|
||
|
||
```text
|
||
Memory Pressure
|
||
↓
|
||
Reclaim / Swap
|
||
↓
|
||
Storage I/O
|
||
↓
|
||
Storage Contention
|
||
↓
|
||
Application Latency
|
||
```
|
||
|
||
CPU placement는 NUMA memory locality와 연결된다.
|
||
|
||
```text
|
||
vCPU Pinning
|
||
+
|
||
Memory Placement
|
||
↓
|
||
Local / Remote Memory Access
|
||
```
|
||
|
||
---
|
||
|
||
## 82. 핵심 Claim Registry
|
||
|
||
### CLAIM-MEM-01
|
||
Guest application은 일반적으로 Host physical address를 직접 사용하지 않는다.
|
||
|
||
```text
|
||
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는 무엇인가?
|
||
|
||
```bash
|
||
lscpu
|
||
numactl --hardware
|
||
```
|
||
|
||
확인할 것:
|
||
|
||
- NUMA node 수
|
||
- node별 CPU
|
||
- node별 memory
|
||
- node distance
|
||
|
||
---
|
||
|
||
### OQ-2. 각 VM의 configured/current memory는 얼마인가?
|
||
|
||
```bash
|
||
virsh dominfo <VM_NAME>
|
||
virsh dumpxml <VM_NAME>
|
||
virsh dommemstat <VM_NAME>
|
||
```
|
||
|
||
Guest:
|
||
|
||
```bash
|
||
free -h
|
||
cat /proc/meminfo
|
||
```
|
||
|
||
Host의 QEMU process 상태와 비교한다.
|
||
|
||
---
|
||
|
||
### OQ-3. QEMU process의 Host resident memory는 어떻게 분포하는가?
|
||
|
||
```bash
|
||
ps -ef | grep qemu
|
||
ps -o pid,rss,vsz,cmd -p <QEMU_PID>
|
||
```
|
||
|
||
필요하면:
|
||
|
||
```bash
|
||
cat /proc/<QEMU_PID>/status
|
||
cat /proc/<QEMU_PID>/smaps_rollup
|
||
```
|
||
|
||
configured memory와 RSS/anonymous/huge-page 상태를 비교한다.
|
||
|
||
---
|
||
|
||
### OQ-4. Host THP 정책은 무엇인가?
|
||
|
||
```bash
|
||
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되어 있는가?
|
||
|
||
```bash
|
||
virsh dumpxml <VM_NAME>
|
||
```
|
||
|
||
libvirt memory backing 관련 설정을 확인하고 Host `/proc/meminfo`, QEMU `smaps` 계열과 교차 검증한다.
|
||
|
||
---
|
||
|
||
### OQ-6. Guest와 Host에서 현재 swap이 발생하는가?
|
||
|
||
Guest:
|
||
|
||
```bash
|
||
free -h
|
||
vmstat 1
|
||
```
|
||
|
||
Host:
|
||
|
||
```bash
|
||
free -h
|
||
vmstat 1
|
||
```
|
||
|
||
단순 swap-used 값보다 현재 swap-in/out activity와 memory pressure를 함께 본다.
|
||
|
||
---
|
||
|
||
### OQ-7. Host memory pressure가 Guest latency에 영향을 주는가?
|
||
|
||
실험 개념:
|
||
|
||
```text
|
||
Baseline
|
||
↓
|
||
Guest Application Latency 측정
|
||
↓
|
||
Host Memory Pressure 유도
|
||
↓
|
||
Host reclaim/swap 관측
|
||
↓
|
||
Guest latency 재측정
|
||
```
|
||
|
||
동시에 CPU와 storage도 관측한다.
|
||
|
||
---
|
||
|
||
### OQ-8. virtio-balloon이 VM에 구성되어 있는가?
|
||
|
||
```bash
|
||
virsh dumpxml <VM_NAME>
|
||
```
|
||
|
||
Guest에서도 관련 driver/device 상태를 확인한다.
|
||
|
||
환경에 따라 driver 이름과 표시 방식이 달라질 수 있으므로 실제 장비에서 검증한다.
|
||
|
||
---
|
||
|
||
### OQ-9. Balloon target 변화가 Guest available memory에 어떻게 반영되는가?
|
||
|
||
관측:
|
||
|
||
```text
|
||
Host/libvirt memory setting
|
||
↓
|
||
Guest free -h / /proc/meminfo
|
||
↓
|
||
Guest reclaim/swap 변화
|
||
```
|
||
|
||
과도한 ballooning 시 Guest latency/swap/OOM 가능성을 별도 실험한다.
|
||
|
||
---
|
||
|
||
### OQ-10. VM vCPU는 어느 Host CPU에 배치되어 있는가?
|
||
|
||
```bash
|
||
virsh vcpuinfo <VM_NAME>
|
||
virsh vcpupin <VM_NAME>
|
||
```
|
||
|
||
CPU 가상화 SSOT의 pinning/overcommit 관측과 연결한다.
|
||
|
||
---
|
||
|
||
### OQ-11. QEMU memory는 어느 NUMA node에 배치되어 있는가?
|
||
|
||
```bash
|
||
numastat -p <QEMU_PID>
|
||
```
|
||
|
||
vCPU placement와 비교한다.
|
||
|
||
```text
|
||
vCPU → Node 0
|
||
Memory → Node 0
|
||
```
|
||
|
||
인지,
|
||
|
||
```text
|
||
vCPU → Node 0
|
||
Memory → Node 1
|
||
```
|
||
|
||
인지 확인한다.
|
||
|
||
---
|
||
|
||
### OQ-12. NUMA remote access가 실제 workload latency에 의미 있는 영향을 주는가?
|
||
|
||
NUMA node가 2개 이상인 경우에만 우선순위를 높인다.
|
||
|
||
```text
|
||
Local placement baseline
|
||
↓
|
||
Latency / throughput / memory metrics
|
||
↓
|
||
Remote-heavy placement
|
||
↓
|
||
동일 workload 비교
|
||
```
|
||
|
||
단순 topology만 보고 성능 문제라고 단정하지 않는다.
|
||
|
||
---
|
||
|
||
### OQ-13. Guest Page Fault가 workload 변화와 함께 증가하는가?
|
||
|
||
Guest에서 page-fault 관련 지표를 관측하고 다음을 분리한다.
|
||
|
||
```text
|
||
정상 demand paging?
|
||
COW?
|
||
Guest swap-in?
|
||
application working-set 증가?
|
||
```
|
||
|
||
Page Fault 증가만으로 오류라고 판단하지 않는다.
|
||
|
||
---
|
||
|
||
### OQ-14. Host Page Fault/major fault와 storage latency가 상관되는가?
|
||
|
||
Host memory pressure 실험 시:
|
||
|
||
```text
|
||
Host Fault
|
||
+
|
||
Swap activity
|
||
+
|
||
Storage latency
|
||
+
|
||
Guest application latency
|
||
```
|
||
|
||
를 같은 시간축으로 비교한다.
|
||
|
||
---
|
||
|
||
## 84. 권장 실험 순서
|
||
|
||
개념 검증은 다음 순서가 좋다.
|
||
|
||
```text
|
||
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. 실험 시 반드시 같이 기록할 것
|
||
|
||
각 실험은 다음 조건을 남긴다.
|
||
|
||
```text
|
||
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이 보이면 한 번에 "메모리 부족"이라고 결론내리지 않는다.
|
||
|
||
```text
|
||
문제
|
||
│
|
||
├─ 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을 한 장으로 기억할 때는 다음 그림을 기준으로 한다.
|
||
|
||
```text
|
||
[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
|
||
```
|
||
|
||
관리 경로는 별도로 기억한다.
|
||
|
||
```text
|
||
virsh
|
||
↓
|
||
libvirt
|
||
↓
|
||
QEMU
|
||
│
|
||
│ Guest RAM backing
|
||
│ KVM_SET_USER_MEMORY_REGION
|
||
▼
|
||
KVM
|
||
│
|
||
│ EPT 관련 mapping 관리
|
||
▼
|
||
CPU MMU
|
||
```
|
||
|
||
그리고 자원 압박 경로:
|
||
|
||
```text
|
||
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 할당으로 보지 않는 것**이다.
|
||
|
||
실제 구조에는 다음 계층이 있다.
|
||
|
||
```text
|
||
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가 수행한다.
|
||
|
||
성능과 장애를 볼 때는 그 위에 다음 요소가 추가된다.
|
||
|
||
```text
|
||
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다.
|
||
|
||
```bash
|
||
virsh list --all
|
||
virsh start vm1
|
||
virsh shutdown vm1
|
||
virsh domiflist vm1
|
||
virsh net-list --all
|
||
```
|
||
|
||
`virsh`는 packet datapath에 직접 참여하지 않는다.
|
||
|
||
```text
|
||
User
|
||
↓
|
||
virsh
|
||
↓
|
||
libvirt
|
||
↓
|
||
QEMU
|
||
```
|
||
|
||
### 90.2 libvirt
|
||
|
||
libvirt는 VM lifecycle 및 configuration을 관리하는 소프트웨어/API 계층이다.
|
||
|
||
관리 대상 예:
|
||
|
||
```text
|
||
vCPU
|
||
Memory
|
||
Disk
|
||
NIC model
|
||
MAC address
|
||
Virtual network
|
||
Bridge
|
||
QEMU arguments
|
||
```
|
||
|
||
### 90.3 virtio
|
||
|
||
`virtio`는 명령어가 아니다.
|
||
|
||
또한 하나의 단일 프로그램이나 단일 커널 모듈을 의미하지 않는다.
|
||
|
||
> Virtio는 Guest와 Host/Hypervisor가 가상 I/O 장치를 효율적으로 사용하기 위한 표준화된 인터페이스/프로토콜이다.
|
||
|
||
대표적인 virtio 장치:
|
||
|
||
```text
|
||
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 측
|
||
|
||
```text
|
||
Guest Kernel
|
||
├─ TCP/IP Stack
|
||
├─ virtio-net Frontend Driver
|
||
└─ virtqueue
|
||
```
|
||
|
||
### Host 측
|
||
|
||
```text
|
||
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
|
||
|
||
```text
|
||
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을 사용하는가
|
||
|
||
물리 서버에서는:
|
||
|
||
```text
|
||
Application
|
||
↓
|
||
Linux TCP/IP Stack
|
||
↓
|
||
Physical NIC Driver
|
||
↓
|
||
Physical NIC
|
||
```
|
||
|
||
VM에서는:
|
||
|
||
```text
|
||
Application
|
||
↓
|
||
Guest TCP/IP Stack
|
||
↓
|
||
virtio-net Driver
|
||
↓
|
||
Virtual NIC
|
||
```
|
||
|
||
이다.
|
||
|
||
Guest는 "QEMU를 호출한다"가 아니라 "내 NIC를 사용한다"고 동작한다.
|
||
|
||
VM 시작 시 QEMU가 Guest에게 virtio 방식의 virtual NIC를 노출한다.
|
||
|
||
```text
|
||
QEMU
|
||
↓
|
||
Virtual PCI Bus에 virtio NIC 노출
|
||
↓
|
||
Guest Linux
|
||
↓
|
||
virtio device 발견
|
||
↓
|
||
virtio-net driver bind
|
||
↓
|
||
ens3 / eth0 형태의 network interface 생성
|
||
```
|
||
|
||
Guest에서 확인:
|
||
|
||
```bash
|
||
lspci
|
||
ip link
|
||
ip addr
|
||
```
|
||
|
||
---
|
||
|
||
## 94. 전체 네트워크 계층
|
||
|
||
가장 기본적인 `virtio-net + vhost-net + TAP + Linux Bridge` 구조를 기준으로 한다.
|
||
|
||
### 수신 방향
|
||
|
||
```text
|
||
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
|
||
```
|
||
|
||
### 송신 방향
|
||
|
||
```text
|
||
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는 실제 네트워크 링크와 서버를 연결하는 하드웨어다.
|
||
|
||
```text
|
||
Network
|
||
↓
|
||
Physical NIC
|
||
↓
|
||
NIC Driver
|
||
↓
|
||
Linux Kernel
|
||
```
|
||
|
||
Linux에서:
|
||
|
||
```bash
|
||
ip link
|
||
```
|
||
|
||
등으로 `enp3s0`, `eno1`, `eth0` 같은 interface를 확인할 수 있다.
|
||
|
||
주의:
|
||
|
||
```text
|
||
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**다.
|
||
|
||
```text
|
||
VM1 TAP ──┐
|
||
│
|
||
VM2 TAP ──┼── br0 ── Physical NIC
|
||
│
|
||
Host NIC ─┘
|
||
```
|
||
|
||
Bridge는 Ethernet frame의 Destination MAC을 보고 어느 port로 전달할지 결정한다.
|
||
|
||
핵심 역할:
|
||
|
||
```text
|
||
L2 forwarding
|
||
MAC learning
|
||
Frame forwarding
|
||
Multiple virtual/physical ports 연결
|
||
```
|
||
|
||
확인:
|
||
|
||
```bash
|
||
bridge link
|
||
bridge fdb show
|
||
ip link show type bridge
|
||
```
|
||
|
||
---
|
||
|
||
## 97. Routing의 역할
|
||
|
||
Routing은 Bridge와 다르다.
|
||
|
||
```text
|
||
Bridge
|
||
→ L2
|
||
→ MAC 기반
|
||
→ 같은 Ethernet network 연결
|
||
|
||
Routing
|
||
→ L3
|
||
→ IP 기반
|
||
→ 서로 다른 IP network 사이 연결
|
||
```
|
||
|
||
Linux routing table 확인:
|
||
|
||
```bash
|
||
ip route
|
||
```
|
||
|
||
Routing은 destination IP를 보고 어느 interface 또는 next-hop으로 packet을 보낼지 결정한다.
|
||
|
||
---
|
||
|
||
## 98. NAT의 역할
|
||
|
||
NAT는 packet의 IP/Port 정보를 변환한다.
|
||
|
||
예:
|
||
|
||
```text
|
||
VM
|
||
192.168.122.10
|
||
↓
|
||
Host NAT
|
||
↓
|
||
203.0.113.10
|
||
↓
|
||
Internet
|
||
```
|
||
|
||
VM이 private subnet을 쓰는 경우 Host가 NAT gateway처럼 동작할 수 있다.
|
||
|
||
따라서 실제 VM network를 분석할 때 다음을 구분해야 한다.
|
||
|
||
```text
|
||
Bridge 기반인가?
|
||
Routing 기반인가?
|
||
NAT 기반인가?
|
||
```
|
||
|
||
---
|
||
|
||
## 99. TAP의 역할
|
||
|
||
TAP은 Host Linux Kernel이 제공하는 **가상 Ethernet network interface**다.
|
||
|
||
물리 장치가 아니다.
|
||
|
||
예:
|
||
|
||
```text
|
||
tap0
|
||
vnet0
|
||
```
|
||
|
||
역할:
|
||
|
||
> VM의 Ethernet frame과 Host Linux networking을 연결하는 접점
|
||
|
||
```text
|
||
Guest Virtual NIC
|
||
↓
|
||
virtio backend
|
||
↓
|
||
TAP
|
||
↓
|
||
Host Linux Network
|
||
```
|
||
|
||
수신:
|
||
|
||
```text
|
||
Linux Bridge
|
||
↓
|
||
TAP
|
||
↓
|
||
VM
|
||
```
|
||
|
||
송신:
|
||
|
||
```text
|
||
VM
|
||
↓
|
||
TAP
|
||
↓
|
||
Linux Bridge
|
||
```
|
||
|
||
확인:
|
||
|
||
```bash
|
||
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를 사용한다.
|
||
|
||
```text
|
||
TX virtqueue
|
||
Guest → Host
|
||
|
||
RX virtqueue
|
||
Host → Guest
|
||
```
|
||
|
||
개념:
|
||
|
||
```text
|
||
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에는 실제로 다음이 존재한다.
|
||
|
||
```text
|
||
Socket
|
||
TCP
|
||
UDP
|
||
IP
|
||
Routing
|
||
Neighbor/ARP
|
||
Firewall
|
||
Network Driver
|
||
```
|
||
|
||
### 101.1 Socket
|
||
|
||
Application과 Kernel network stack 사이의 인터페이스다.
|
||
|
||
대표 API:
|
||
|
||
```text
|
||
socket()
|
||
bind()
|
||
listen()
|
||
accept()
|
||
connect()
|
||
send()
|
||
recv()
|
||
```
|
||
|
||
Keycloak은 Ethernet frame이나 virtqueue를 직접 다루지 않는다.
|
||
|
||
### 101.2 TCP
|
||
|
||
TCP의 대표 책임:
|
||
|
||
```text
|
||
Connection 관리
|
||
Port
|
||
Sequence
|
||
순서 보장
|
||
재전송
|
||
중복 처리
|
||
Flow Control
|
||
Congestion Control
|
||
```
|
||
|
||
예:
|
||
|
||
```text
|
||
Source Port: 53021
|
||
Destination Port: 8080
|
||
```
|
||
|
||
### 101.3 IP
|
||
|
||
IP 계층은 IP 주소와 routing을 담당한다.
|
||
|
||
예:
|
||
|
||
```text
|
||
Source IP: 192.168.122.10
|
||
Destination IP: 192.168.122.20
|
||
```
|
||
|
||
확인:
|
||
|
||
```bash
|
||
ip addr
|
||
ip route
|
||
```
|
||
|
||
### 101.4 Ethernet / Link Layer
|
||
|
||
NIC에 가까운 계층에서는 Ethernet frame과 MAC address를 다룬다.
|
||
|
||
확인:
|
||
|
||
```bash
|
||
ip neigh
|
||
```
|
||
|
||
---
|
||
|
||
## 102. Packet이 Keycloak까지 올라오는 과정
|
||
|
||
```text
|
||
Ethernet Frame
|
||
↓
|
||
IP Packet
|
||
↓
|
||
TCP Segment / Stream
|
||
↓
|
||
Socket
|
||
↓
|
||
HTTP
|
||
↓
|
||
Keycloak
|
||
```
|
||
|
||
Keycloak은 다음을 직접 알 필요가 없다.
|
||
|
||
```text
|
||
virtqueue
|
||
vhost-net
|
||
TAP
|
||
Bridge
|
||
Physical NIC
|
||
```
|
||
|
||
Keycloak은 Guest Linux가 제공하는 TCP socket 위에서 HTTP 요청을 처리한다.
|
||
|
||
---
|
||
|
||
## 103. QEMU virtio Device Model의 역할
|
||
|
||
QEMU의 `virtio Device Model`은 **Host Userspace의 QEMU process 내부**에 존재한다.
|
||
|
||
여기서 역할을 두 개로 분리해야 한다.
|
||
|
||
### 역할 A. 장치 생성/설정/관리
|
||
|
||
```text
|
||
QEMU
|
||
↓
|
||
virtio-net Device Model 생성
|
||
↓
|
||
Guest에게 device 노출
|
||
↓
|
||
feature negotiation
|
||
↓
|
||
virtqueue 설정
|
||
↓
|
||
backend 연결
|
||
```
|
||
|
||
이 역할은 QEMU가 담당한다.
|
||
|
||
### 역할 B. 실제 Packet Datapath 처리
|
||
|
||
#### QEMU backend를 직접 사용하는 경우
|
||
|
||
```text
|
||
TAP
|
||
↓
|
||
QEMU virtio backend
|
||
↓
|
||
virtqueue
|
||
↓
|
||
Guest
|
||
```
|
||
|
||
#### vhost-net을 사용하는 경우
|
||
|
||
```text
|
||
TAP
|
||
↓
|
||
vhost-net
|
||
↓
|
||
virtqueue
|
||
↓
|
||
Guest
|
||
```
|
||
|
||
반복적인 packet I/O를 Host Kernel에서 처리하고 QEMU userspace를 우회한다.
|
||
|
||
---
|
||
|
||
## 104. 왜 `TAP → vhost-net → QEMU → virtqueue`라고 일반화하면 안 되는가
|
||
|
||
다음 그림:
|
||
|
||
```text
|
||
TAP
|
||
↓
|
||
vhost-net
|
||
↓
|
||
QEMU
|
||
↓
|
||
virtqueue
|
||
```
|
||
|
||
은 모든 packet이 `vhost-net → QEMU` 순으로 반드시 지나가는 것처럼 보인다.
|
||
|
||
하지만 `vhost-net`의 중요한 목적 중 하나는 **packet datapath에서 QEMU userspace를 우회하는 것**이다.
|
||
|
||
vhost-net 사용 시 fast path는 다음처럼 이해한다.
|
||
|
||
```text
|
||
TAP
|
||
↓
|
||
vhost-net
|
||
↓
|
||
virtqueue
|
||
↓
|
||
Guest
|
||
```
|
||
|
||
QEMU는 사라지는 것이 아니라 device lifecycle과 configuration을 관리한다.
|
||
|
||
---
|
||
|
||
## 105. Control Path와 Data Path
|
||
|
||
### Control / Setup Path
|
||
|
||
```text
|
||
virsh
|
||
↓
|
||
libvirt
|
||
↓
|
||
QEMU
|
||
↓
|
||
virtio-net Device Model
|
||
↓
|
||
feature negotiation
|
||
virtqueue setup
|
||
vhost-net setup
|
||
```
|
||
|
||
여기서 `control`은 Kubernetes Control Plane을 뜻하지 않는다.
|
||
|
||
일반적인 시스템 용어로 **설정/제어 경로**라는 의미다.
|
||
|
||
### Data Path
|
||
|
||
실제 packet이 반복적으로 흐르는 경로다.
|
||
|
||
vhost-net 사용 시:
|
||
|
||
```text
|
||
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
|
||
|
||
```text
|
||
QEMU
|
||
↓
|
||
vCPU 생성/관리
|
||
|
||
실제 Guest instruction 실행
|
||
↓
|
||
KVM / VMX
|
||
```
|
||
|
||
QEMU가 vCPU를 만든다고 Guest의 `ADD`, `MOV`, `SUB`를 전부 QEMU가 실행하는 것은 아니다.
|
||
|
||
### Network
|
||
|
||
```text
|
||
QEMU
|
||
↓
|
||
virtio-net 생성/관리
|
||
|
||
실제 반복 packet I/O
|
||
↓
|
||
vhost-net / virtqueue
|
||
```
|
||
|
||
QEMU가 virtual NIC를 만든다고 packet 100만 개를 반드시 QEMU가 하나씩 처리할 필요는 없다.
|
||
|
||
---
|
||
|
||
## 107. vhost-net 최적화
|
||
|
||
QEMU userspace가 packet마다 I/O를 처리하면 다음 전환 비용이 누적될 수 있다.
|
||
|
||
```text
|
||
Host Kernel
|
||
↓
|
||
QEMU Userspace
|
||
↓
|
||
Host Kernel
|
||
↓
|
||
...
|
||
```
|
||
|
||
Packet rate가 높아질수록 userspace/kernel transition, scheduling, copy, notification 비용이 커질 수 있다.
|
||
|
||
### QEMU userspace backend
|
||
|
||
```text
|
||
TAP
|
||
↓
|
||
QEMU
|
||
↓
|
||
virtqueue
|
||
```
|
||
|
||
### vhost-net kernel backend
|
||
|
||
```text
|
||
TAP
|
||
↓
|
||
vhost-net
|
||
↓
|
||
virtqueue
|
||
```
|
||
|
||
핵심 최적화 방향:
|
||
|
||
```text
|
||
Packet마다 QEMU userspace 개입
|
||
↓
|
||
Kernel backend로 hot path 이동
|
||
↓
|
||
Context switch / userspace overhead 감소
|
||
```
|
||
|
||
---
|
||
|
||
## 108. vhost-net은 QEMU를 제거하지 않는다
|
||
|
||
vhost-net 사용 시에도 QEMU는 필요하다.
|
||
|
||
QEMU의 역할:
|
||
|
||
```text
|
||
VM lifecycle
|
||
Virtual hardware model
|
||
virtio device 생성
|
||
Feature negotiation
|
||
Queue configuration
|
||
Backend 연결
|
||
Device reset
|
||
Control/configuration handling
|
||
```
|
||
|
||
따라서:
|
||
|
||
```text
|
||
vhost-net != QEMU 제거
|
||
```
|
||
|
||
정확히는:
|
||
|
||
```text
|
||
vhost-net
|
||
=
|
||
QEMU가 담당하던 반복적인 virtio packet datapath의 상당 부분을
|
||
Host Kernel로 offload
|
||
```
|
||
|
||
라고 이해한다.
|
||
|
||
---
|
||
|
||
## 109. Fast Path와 Slow/Control Path
|
||
|
||
### Fast Path
|
||
|
||
빈번하게 반복되는 packet forwarding/data transfer 경로다.
|
||
|
||
예:
|
||
|
||
```text
|
||
TAP
|
||
↓
|
||
vhost-net
|
||
↓
|
||
virtqueue
|
||
```
|
||
|
||
### Control/Slow Path
|
||
|
||
상대적으로 빈도가 낮고 설정/예외 처리를 담당한다.
|
||
|
||
예:
|
||
|
||
```text
|
||
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 여부는 다음에 따라 달라질 수 있다.
|
||
|
||
```text
|
||
Kernel version
|
||
QEMU version
|
||
vhost configuration
|
||
offload
|
||
NIC capability
|
||
packet path
|
||
GSO/GRO/TSO
|
||
```
|
||
|
||
---
|
||
|
||
## 111. Interrupt / Notification 최적화
|
||
|
||
Guest와 Host는 queue에 새로운 packet/buffer가 있음을 서로 알려야 한다.
|
||
|
||
단순화:
|
||
|
||
```text
|
||
Guest TX
|
||
↓
|
||
virtqueue descriptor 등록
|
||
↓
|
||
Host backend notification
|
||
↓
|
||
backend 처리
|
||
```
|
||
|
||
수신:
|
||
|
||
```text
|
||
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를 사용할 수 있다.
|
||
|
||
```text
|
||
RX Queue 0 → vCPU 0
|
||
RX Queue 1 → vCPU 1
|
||
RX Queue 2 → vCPU 2
|
||
RX Queue 3 → vCPU 3
|
||
```
|
||
|
||
목적:
|
||
|
||
```text
|
||
Packet processing 병렬화
|
||
Single queue bottleneck 완화
|
||
Multi-core 활용
|
||
```
|
||
|
||
효과는 workload, CPU affinity, IRQ placement, queue configuration에 따라 달라진다.
|
||
|
||
---
|
||
|
||
## 113. Offload 최적화
|
||
|
||
대표적인 offload:
|
||
|
||
```text
|
||
TSO - TCP Segmentation Offload
|
||
GSO - Generic Segmentation Offload
|
||
GRO - Generic Receive Offload
|
||
Checksum Offload
|
||
```
|
||
|
||
목적:
|
||
|
||
```text
|
||
작은 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을 거치는 것은 아니다.
|
||
|
||
예:
|
||
|
||
```text
|
||
VM1 TAP
|
||
↓
|
||
Linux Bridge
|
||
↓
|
||
VM2 TAP
|
||
```
|
||
|
||
반면 Host가 다음 역할을 하면 L3/Netfilter 경로가 개입한다.
|
||
|
||
```text
|
||
Routing
|
||
NAT
|
||
Host-local termination
|
||
Firewall
|
||
```
|
||
|
||
따라서 다음을 고정된 packet path로 보면 안 된다.
|
||
|
||
```text
|
||
Physical NIC
|
||
↓
|
||
Host TCP/IP Stack
|
||
↓
|
||
Bridge
|
||
```
|
||
|
||
실제 경로는 bridge/routing/NAT 구성에 따라 달라진다.
|
||
|
||
---
|
||
|
||
## 115. Host Physical NIC로 나갈 때 virtio를 다시 거치지 않는다
|
||
|
||
```text
|
||
Guest
|
||
virtio-net
|
||
↓
|
||
vhost-net
|
||
↓
|
||
TAP
|
||
↓
|
||
Linux Bridge
|
||
↓
|
||
Intel NIC Driver
|
||
↓
|
||
Intel Physical NIC
|
||
```
|
||
|
||
즉:
|
||
|
||
```text
|
||
Guest virtio
|
||
→ Host virtio
|
||
→ Physical NIC
|
||
```
|
||
|
||
구조가 아니다.
|
||
|
||
virtio는 Guest virtual I/O device와 Host backend 사이의 인터페이스다.
|
||
|
||
---
|
||
|
||
## 116. 현재 Keycloak/K3s 테스트 환경과 연결
|
||
|
||
```text
|
||
Client
|
||
↓
|
||
Host Physical NIC
|
||
↓
|
||
Host Nginx
|
||
↓
|
||
Host Network
|
||
↓
|
||
VM1 / VM2
|
||
↓
|
||
K3s
|
||
↓
|
||
Keycloak Node 1 / 2
|
||
```
|
||
|
||
VM network까지 펼치면:
|
||
|
||
```text
|
||
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 연결 오류
|
||
|
||
증상:
|
||
|
||
```text
|
||
VM 외부 통신 불가
|
||
Host ↔ VM 통신 불가
|
||
특정 VM만 통신 불가
|
||
```
|
||
|
||
확인:
|
||
|
||
```bash
|
||
ip link
|
||
bridge link
|
||
bridge fdb show
|
||
virsh domiflist <vm>
|
||
```
|
||
|
||
### 117.2 Routing 오류
|
||
|
||
증상:
|
||
|
||
```text
|
||
같은 subnet은 통신되지만 다른 subnet은 안 됨
|
||
gateway까진 되지만 외부 통신 실패
|
||
```
|
||
|
||
확인:
|
||
|
||
```bash
|
||
ip route
|
||
ip rule
|
||
```
|
||
|
||
### 117.3 NAT/Firewall 오류
|
||
|
||
증상:
|
||
|
||
```text
|
||
VM → Internet 실패
|
||
외부 → VM 접근 실패
|
||
특정 port만 실패
|
||
```
|
||
|
||
확인 대상:
|
||
|
||
```text
|
||
nftables
|
||
iptables
|
||
NAT rules
|
||
IP forwarding
|
||
```
|
||
|
||
### 117.4 vhost-net 미사용 또는 비효율적 datapath
|
||
|
||
높은 packet rate에서 QEMU userspace가 datapath를 직접 처리하면 CPU overhead가 커질 수 있다.
|
||
|
||
관찰:
|
||
|
||
```text
|
||
QEMU CPU usage
|
||
vhost thread
|
||
packet rate
|
||
latency
|
||
context switch
|
||
```
|
||
|
||
### 117.5 Single Queue Bottleneck
|
||
|
||
하나의 queue/vCPU에 packet processing이 집중될 수 있다.
|
||
|
||
확인 대상:
|
||
|
||
```text
|
||
virtio multi-queue
|
||
IRQ distribution
|
||
per-vCPU CPU usage
|
||
RSS/RPS/XPS
|
||
```
|
||
|
||
### 117.6 Offload 때문에 packet capture가 예상과 다르게 보임
|
||
|
||
원인 후보:
|
||
|
||
```text
|
||
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
|
||
|
||
```bash
|
||
ip link
|
||
ip addr
|
||
ethtool <interface>
|
||
```
|
||
|
||
### Linux Bridge
|
||
|
||
```bash
|
||
ip link show type bridge
|
||
bridge link
|
||
bridge fdb show
|
||
```
|
||
|
||
### TAP / vnet
|
||
|
||
```bash
|
||
ip link
|
||
ip tuntap show
|
||
```
|
||
|
||
### libvirt VM NIC
|
||
|
||
```bash
|
||
virsh domiflist <domain>
|
||
```
|
||
|
||
### libvirt network
|
||
|
||
```bash
|
||
virsh net-list --all
|
||
virsh net-info <network>
|
||
virsh net-dumpxml <network>
|
||
```
|
||
|
||
### Routing
|
||
|
||
```bash
|
||
ip route
|
||
ip rule
|
||
```
|
||
|
||
### Guest NIC
|
||
|
||
```bash
|
||
ip link
|
||
ip addr
|
||
ip route
|
||
ip neigh
|
||
```
|
||
|
||
### virtio 장치
|
||
|
||
```bash
|
||
lspci
|
||
lsmod | grep virtio
|
||
```
|
||
|
||
### vhost
|
||
|
||
```bash
|
||
lsmod | grep vhost
|
||
```
|
||
|
||
---
|
||
|
||
## 119. 실제 packet path 추적
|
||
|
||
Host:
|
||
|
||
```bash
|
||
sudo tcpdump -ni <physical-nic>
|
||
sudo tcpdump -ni <bridge>
|
||
sudo tcpdump -ni <tap-or-vnet>
|
||
```
|
||
|
||
Guest:
|
||
|
||
```bash
|
||
sudo tcpdump -ni <guest-interface>
|
||
```
|
||
|
||
예:
|
||
|
||
```text
|
||
Physical NIC O
|
||
Bridge O
|
||
TAP X
|
||
```
|
||
|
||
이면 Guest 내부보다 먼저 Host Bridge/TAP mapping을 의심한다.
|
||
|
||
```text
|
||
TAP O
|
||
Guest NIC X
|
||
```
|
||
|
||
이면 virtio/vhost/Guest NIC 계층을 의심한다.
|
||
|
||
```text
|
||
Guest NIC O
|
||
Socket X
|
||
```
|
||
|
||
이면 Guest routing/firewall/listen 상태를 의심한다.
|
||
|
||
---
|
||
|
||
## 120. Keycloak Refresh Token 실험과의 관계
|
||
|
||
Refresh Token 경쟁 자체는 virtio-net 문제가 아니다.
|
||
|
||
하지만 다음 경로를 공유하므로 network virtualization 문제가 실험 결과에 영향을 줄 수 있다.
|
||
|
||
```text
|
||
Client
|
||
↓
|
||
Nginx
|
||
↓
|
||
VM1 / VM2
|
||
↓
|
||
K3s
|
||
↓
|
||
Keycloak
|
||
↓
|
||
PostgreSQL / Redis
|
||
```
|
||
|
||
예:
|
||
|
||
```text
|
||
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까지 이동하는 과정**
|
||
|
||
포함 범위:
|
||
|
||
```text
|
||
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 중 어떤 구조인가?
|
||
|
||
```bash
|
||
virsh net-list --all
|
||
virsh net-dumpxml <network>
|
||
ip link
|
||
bridge link
|
||
ip route
|
||
```
|
||
|
||
### OQ-2. VM1/VM2의 TAP/vnet interface는 무엇인가?
|
||
|
||
```bash
|
||
virsh domiflist vm1
|
||
virsh domiflist vm2
|
||
ip link
|
||
bridge link
|
||
```
|
||
|
||
### OQ-3. 현재 환경에서 vhost-net이 실제 사용되는가?
|
||
|
||
확인 후보:
|
||
|
||
```bash
|
||
lsmod | grep vhost
|
||
```
|
||
|
||
추가로 QEMU arguments와 libvirt domain XML을 확인한다.
|
||
|
||
### OQ-4. QEMU backend와 vhost-net의 성능 차이가 현재 Host에서 관찰 가능한가?
|
||
|
||
비교:
|
||
|
||
```text
|
||
Latency
|
||
Throughput
|
||
QEMU CPU
|
||
Host CPU
|
||
Context Switch
|
||
Packet rate
|
||
```
|
||
|
||
### OQ-5. Multi-queue가 현재 virtio-net에 활성화되어 있는가?
|
||
|
||
확인 대상:
|
||
|
||
```text
|
||
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를 사용하는가?
|
||
|
||
관찰:
|
||
|
||
```text
|
||
QEMU CPU
|
||
vhost thread
|
||
softirq
|
||
Host CPU
|
||
Guest CPU
|
||
network latency
|
||
```
|
||
|
||
---
|
||
|
||
## 123. OPEN QUESTION → CASE
|
||
|
||
```text
|
||
SSOT
|
||
↓
|
||
CONCEPT
|
||
↓
|
||
OPEN QUESTION
|
||
↓
|
||
실제 packet capture / configuration 확인 / load test
|
||
↓
|
||
CASE
|
||
```
|
||
|
||
예:
|
||
|
||
```text
|
||
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
|
||
|
||
```text
|
||
User
|
||
↓
|
||
virsh
|
||
↓
|
||
libvirt
|
||
↓
|
||
QEMU
|
||
↓
|
||
virtio-net Device Model
|
||
├─ virtual NIC 생성
|
||
├─ Guest 노출
|
||
├─ feature negotiation
|
||
├─ virtqueue 설정
|
||
└─ vhost-net backend 설정
|
||
```
|
||
|
||
### Data Path - vhost-net 사용
|
||
|
||
```text
|
||
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 사용
|
||
|
||
```text
|
||
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. 다음 실습 순서
|
||
|
||
```text
|
||
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. 전체 구조
|
||
|
||
```text
|
||
[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을 사용한다.
|
||
|
||
```c
|
||
write(fd, buffer, size);
|
||
```
|
||
|
||
```text
|
||
[Guest Userspace]
|
||
|
||
PostgreSQL
|
||
│
|
||
│ write()
|
||
▼
|
||
|
||
════════ System Call ════════
|
||
|
||
[Guest Kernel]
|
||
|
||
VFS
|
||
```
|
||
|
||
즉 애플리케이션은 저장장치를 직접 조작하는 것이 아니라 Guest Linux Kernel에 파일 연산을 요청한다.
|
||
|
||
대표적인 파일 관련 system call:
|
||
|
||
```text
|
||
open()
|
||
read()
|
||
write()
|
||
close()
|
||
fsync()
|
||
```
|
||
|
||
이 시점에는 아직 QEMU, qcow2, Host NVMe가 등장하지 않는다.
|
||
|
||
---
|
||
|
||
## 130. VFS: 공통 파일 인터페이스 계층
|
||
|
||
VFS(Virtual File System)는 Linux Kernel 내부에서 여러 filesystem을 동일한 API로 사용할 수 있도록 연결하는 공통 계층이다.
|
||
|
||
Guest가 ext4라면:
|
||
|
||
```text
|
||
PostgreSQL
|
||
↓
|
||
write()
|
||
↓
|
||
VFS
|
||
↓
|
||
ext4
|
||
```
|
||
|
||
XFS라면:
|
||
|
||
```text
|
||
PostgreSQL
|
||
↓
|
||
write()
|
||
↓
|
||
VFS
|
||
↓
|
||
XFS
|
||
```
|
||
|
||
VFS의 핵심 역할:
|
||
|
||
```text
|
||
이 fd가 어떤 파일인가?
|
||
↓
|
||
이 파일은 어떤 filesystem에 속하는가?
|
||
↓
|
||
해당 filesystem 구현으로 연산 전달
|
||
```
|
||
|
||
> VFS는 애플리케이션의 공통 파일 연산을 실제 filesystem 구현으로 연결한다.
|
||
|
||
---
|
||
|
||
## 131. Filesystem(ext4/XFS): 파일 세계를 block 공간에 배치
|
||
|
||
SSD는 `/var/lib/postgresql/data` 같은 디렉터리 구조를 모른다.
|
||
|
||
저장장치 입장에서는 결국 block 단위 공간이다.
|
||
|
||
```text
|
||
Block 0
|
||
Block 1
|
||
Block 2
|
||
Block 3
|
||
...
|
||
```
|
||
|
||
하지만 사용자는 다음과 같이 파일과 디렉터리를 본다.
|
||
|
||
```text
|
||
/
|
||
├── etc
|
||
├── home
|
||
└── var
|
||
└── lib
|
||
└── postgresql
|
||
└── data
|
||
```
|
||
|
||
이 논리 구조를 제공하고 관리하는 것이 ext4/XFS 같은 filesystem이다.
|
||
|
||
Filesystem이 관리하는 대표 정보:
|
||
|
||
- 파일 이름과 디렉터리 구조
|
||
- 파일 크기
|
||
- owner / permission
|
||
- timestamp
|
||
- inode / metadata
|
||
- 파일 데이터가 저장될 block
|
||
- free space
|
||
- filesystem consistency
|
||
|
||
개념적으로:
|
||
|
||
```text
|
||
사람/프로그램이 보는 세계
|
||
|
||
/var/lib/postgresql/data/users
|
||
│
|
||
▼
|
||
ext4/XFS
|
||
│
|
||
▼
|
||
저장장치가 보는 세계
|
||
|
||
Block 8142
|
||
Block 8143
|
||
Block 9201
|
||
...
|
||
```
|
||
|
||
---
|
||
|
||
## 132. inode
|
||
|
||
inode는 Linux filesystem에서 파일 metadata와 저장 위치 정보를 관리하는 핵심 자료구조다.
|
||
|
||
```text
|
||
"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까지 즉시 내려갈 필요가 없다.
|
||
|
||
```text
|
||
Application
|
||
│
|
||
│ write()
|
||
▼
|
||
Linux Kernel
|
||
│
|
||
▼
|
||
Page Cache (RAM)
|
||
│
|
||
│ 나중에 writeback
|
||
▼
|
||
Filesystem / Block Layer
|
||
↓
|
||
SSD
|
||
```
|
||
|
||
예를 들어 storage에는 현재 `ABC`가 있는데 애플리케이션이 `DEF`를 추가했다고 하자.
|
||
|
||
```text
|
||
Page Cache (RAM)
|
||
┌──────────────┐
|
||
│ ABCDEF │ ← 최신 상태, dirty
|
||
└──────────────┘
|
||
|
||
SSD
|
||
┌──────────────┐
|
||
│ ABC │ ← 아직 이전 상태
|
||
└──────────────┘
|
||
```
|
||
|
||
storage보다 최신인 Page Cache page를 **dirty page**라고 한다.
|
||
|
||
이후 kernel writeback이 실제 storage 쪽으로 내려간다.
|
||
|
||
```text
|
||
Dirty Page
|
||
↓
|
||
Filesystem
|
||
↓
|
||
Block Layer
|
||
↓
|
||
Storage
|
||
```
|
||
|
||
따라서:
|
||
|
||
```text
|
||
write() 성공
|
||
≠
|
||
Physical SSD 영속화 완료
|
||
```
|
||
|
||
이다.
|
||
|
||
---
|
||
|
||
## 134. Guest Block I/O Layer
|
||
|
||
현재 위치:
|
||
|
||
```text
|
||
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 요청으로 전달·관리한다.
|
||
|
||
```text
|
||
Filesystem 세계
|
||
|
||
/users/data.db
|
||
offset 8192에 4KB write
|
||
│
|
||
▼
|
||
──────────────────────
|
||
Block I/O Layer
|
||
──────────────────────
|
||
│
|
||
▼
|
||
Block Device 세계
|
||
|
||
/dev/vda의 특정 위치에
|
||
READ / WRITE / FLUSH
|
||
```
|
||
|
||
대표 요청:
|
||
|
||
```text
|
||
READ
|
||
WRITE
|
||
FLUSH
|
||
DISCARD
|
||
```
|
||
|
||
실제 Linux 내부에는 `bio`, request, queue, `blk-mq` 등이 존재한다.
|
||
|
||
---
|
||
|
||
## 135. `/dev/vda`: Guest가 보는 가상 Block Device
|
||
|
||
물리 머신에서는:
|
||
|
||
```text
|
||
/dev/sda
|
||
/dev/nvme0n1
|
||
```
|
||
|
||
같은 block device가 보일 수 있다.
|
||
|
||
virtio-blk를 사용하는 VM에서는 흔히:
|
||
|
||
```text
|
||
/dev/vda
|
||
/dev/vdb
|
||
```
|
||
|
||
처럼 보인다.
|
||
|
||
Guest에서:
|
||
|
||
```bash
|
||
lsblk
|
||
```
|
||
|
||
예시:
|
||
|
||
```text
|
||
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 관계
|
||
|
||
```text
|
||
/dev/vda ← Virtual Block Device
|
||
│
|
||
└─ /dev/vda2 ← Partition
|
||
│
|
||
└─ ext4 ← Filesystem
|
||
│
|
||
└─ /
|
||
```
|
||
|
||
위에서 아래로 보면:
|
||
|
||
```text
|
||
/
|
||
↓
|
||
ext4
|
||
↓
|
||
/dev/vda2
|
||
↓
|
||
/dev/vda
|
||
```
|
||
|
||
`cd /var/lib/postgresql`은 filesystem 세계를 보는 것이고, `lsblk`에서 `vda`를 보는 것은 block device 세계를 보는 것이다.
|
||
|
||
---
|
||
|
||
## 137. virtio-blk: Guest의 가상 Block Device Driver
|
||
|
||
```text
|
||
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와 비교:
|
||
|
||
```text
|
||
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에 게시한다.
|
||
|
||
```text
|
||
Guest Kernel
|
||
|
||
ext4
|
||
↓
|
||
Block I/O Layer
|
||
↓
|
||
/dev/vda
|
||
↓
|
||
virtio-blk
|
||
↓
|
||
virtqueue
|
||
```
|
||
|
||
Network에서:
|
||
|
||
```text
|
||
TCP/IP Stack
|
||
↓
|
||
virtio-net
|
||
↓
|
||
virtqueue
|
||
```
|
||
|
||
였던 구조가 Storage에서도 반복된다.
|
||
|
||
---
|
||
|
||
## 139. virtqueue의 실제 의미
|
||
|
||
virtqueue를 단순한 "데이터 파이프"로 보면 부정확하다.
|
||
|
||
Guest memory에 I/O buffer가 있고 descriptor가 그 buffer를 가리킨다.
|
||
|
||
```text
|
||
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 경로:
|
||
|
||
```text
|
||
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를 요청한다.
|
||
|
||
```text
|
||
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에:
|
||
|
||
```text
|
||
/var/lib/libvirt/images/keycloak-node1.qcow2
|
||
```
|
||
|
||
라는 파일이 있다고 하자.
|
||
|
||
Host 관점:
|
||
|
||
```text
|
||
keycloak-node1.qcow2
|
||
"파일 하나"
|
||
```
|
||
|
||
Guest 관점:
|
||
|
||
```text
|
||
/dev/vda
|
||
├─ /dev/vda1
|
||
└─ /dev/vda2
|
||
```
|
||
|
||
즉:
|
||
|
||
```text
|
||
Host 관점
|
||
────────────────────
|
||
vm1.qcow2
|
||
"파일"
|
||
|
||
Guest 관점
|
||
────────────────────
|
||
/dev/vda
|
||
"디스크"
|
||
```
|
||
|
||
둘 다 맞다.
|
||
|
||
---
|
||
|
||
## 143. qcow2 Virtual Size와 실제 Host 사용량
|
||
|
||
qcow2는 가상 disk size와 실제 Host 할당량이 다를 수 있다.
|
||
|
||
```text
|
||
Guest가 보는 공간
|
||
|
||
/dev/vda
|
||
┌──────────────────────────────────────┐
|
||
│ 100 GB │
|
||
└──────────────────────────────────────┘
|
||
|
||
Host 실제 할당 공간
|
||
|
||
vm1.qcow2
|
||
┌──────┐
|
||
│ 3GB │
|
||
└──────┘
|
||
```
|
||
|
||
Guest가 데이터를 기록하면서:
|
||
|
||
```text
|
||
처음
|
||
Virtual 100GB
|
||
Actual 1GB
|
||
|
||
↓ Guest 데이터 기록
|
||
|
||
Virtual 100GB
|
||
Actual 10GB
|
||
|
||
↓ 더 기록
|
||
|
||
Virtual 100GB
|
||
Actual 40GB
|
||
```
|
||
|
||
처럼 실제 사용량이 늘 수 있다.
|
||
|
||
확인:
|
||
|
||
```bash
|
||
qemu-img info vm1.qcow2
|
||
```
|
||
|
||
`virtual size`와 실제 allocation을 구분해서 봐야 한다.
|
||
|
||
---
|
||
|
||
## 144. RAW Image
|
||
|
||
RAW는 qcow2보다 구조가 단순하다.
|
||
|
||
```text
|
||
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는 상대적으로 단순하다.
|
||
|
||
다만:
|
||
|
||
```text
|
||
RAW = 무조건 빠름
|
||
qcow2 = 무조건 느림
|
||
```
|
||
|
||
으로 일반화하면 안 된다.
|
||
|
||
실제 성능은 cache mode, storage backend, workload pattern, queue depth, snapshot chain, underlying filesystem, physical device 등에 영향을 받는다.
|
||
|
||
---
|
||
|
||
## 145. Host Block Device를 직접 backend로 사용 가능
|
||
|
||
반드시 파일일 필요는 없다.
|
||
|
||
```text
|
||
Guest /dev/vda
|
||
↓
|
||
virtio-blk
|
||
↓
|
||
QEMU
|
||
↓
|
||
Host /dev/nvme0n1p3
|
||
```
|
||
|
||
따라서 `Guest에 /dev/vda가 있다`는 정보만으로 backend 구조를 알 수 없다.
|
||
|
||
```text
|
||
/dev/vda
|
||
↓
|
||
|
||
┌─────────────┬─────────────┬──────────────────┐
|
||
↓ ↓ ↓
|
||
qcow2 RAW Host Block Device
|
||
file file /dev/...
|
||
```
|
||
|
||
---
|
||
|
||
## 146. 실제 연결 확인
|
||
|
||
Guest:
|
||
|
||
```bash
|
||
lsblk
|
||
```
|
||
|
||
Host:
|
||
|
||
```bash
|
||
virsh domblklist <VM_NAME>
|
||
```
|
||
|
||
예시:
|
||
|
||
```text
|
||
Target Source
|
||
-----------------------------------------------
|
||
vda /var/lib/libvirt/images/vm1.qcow2
|
||
```
|
||
|
||
그러면:
|
||
|
||
```text
|
||
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를 함께 사용하면:
|
||
|
||
```text
|
||
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()` 완료와 영속화는 다르다
|
||
|
||
```text
|
||
PostgreSQL
|
||
↓
|
||
Guest Page Cache ✓
|
||
↓
|
||
virtio ✓
|
||
↓
|
||
Host Page Cache ✓
|
||
|
||
───────── Host 전원 장애 ─────────
|
||
|
||
Physical SSD ✗
|
||
```
|
||
|
||
가능성이 있다.
|
||
|
||
따라서:
|
||
|
||
```text
|
||
write() 완료
|
||
≠
|
||
writeback 완료
|
||
≠
|
||
fsync/flush 완료
|
||
≠
|
||
전원 장애에도 안전한 durability
|
||
```
|
||
|
||
이다.
|
||
|
||
---
|
||
|
||
## 149. Direct I/O
|
||
|
||
Buffered I/O:
|
||
|
||
```text
|
||
QEMU
|
||
↓
|
||
Host Page Cache
|
||
↓
|
||
Host Filesystem
|
||
↓
|
||
Block Layer
|
||
↓
|
||
SSD
|
||
```
|
||
|
||
Direct I/O:
|
||
|
||
```text
|
||
QEMU
|
||
↓
|
||
Host Filesystem / Block I/O Path
|
||
↓
|
||
Block Layer
|
||
↓
|
||
SSD
|
||
```
|
||
|
||
Linux의 `O_DIRECT`가 대표적으로 관련된다.
|
||
|
||
중요한 구분:
|
||
|
||
```text
|
||
Direct I/O
|
||
≠
|
||
자동 durability 보장
|
||
```
|
||
|
||
Direct I/O의 핵심은 Page Cache 우회다.
|
||
|
||
---
|
||
|
||
## 150. `fsync()`가 필요한 이유
|
||
|
||
```c
|
||
write(fd, data, size);
|
||
```
|
||
|
||
성공만으로 정전 이후 생존을 보장하지 않는다.
|
||
|
||
필요한 시점에:
|
||
|
||
```c
|
||
fsync(fd);
|
||
```
|
||
|
||
를 통해 변경 내용을 필요한 영속성 경계까지 반영하도록 요청한다.
|
||
|
||
VM에서는:
|
||
|
||
```text
|
||
PostgreSQL
|
||
│
|
||
fsync()
|
||
▼
|
||
Guest Filesystem
|
||
│
|
||
▼
|
||
Guest Block Layer
|
||
│
|
||
FLUSH 등
|
||
▼
|
||
virtio-blk
|
||
│
|
||
▼
|
||
QEMU / Backend
|
||
│
|
||
▼
|
||
Host Storage Stack
|
||
│
|
||
▼
|
||
Physical Storage
|
||
```
|
||
|
||
처럼 전체 stack으로 의미가 전달되어야 한다.
|
||
|
||
---
|
||
|
||
## 151. FLUSH
|
||
|
||
단순화하면:
|
||
|
||
```text
|
||
WRITE
|
||
↓
|
||
"이 데이터를 써라"
|
||
|
||
FLUSH
|
||
↓
|
||
"앞서 쓴 데이터를 필요한 영속성 경계까지
|
||
반영하고 완료 상태를 보장해라"
|
||
```
|
||
|
||
이다.
|
||
|
||
실제 ordering/durability semantics는 더 복잡하지만 Storage 가상화에서는 이 구분이 핵심이다.
|
||
|
||
---
|
||
|
||
## 152. 가장 위험한 상황: 거짓 완료
|
||
|
||
Guest가:
|
||
|
||
```text
|
||
WRITE
|
||
↓
|
||
FLUSH
|
||
```
|
||
|
||
를 요청했는데 실제 상태가:
|
||
|
||
```text
|
||
Host RAM
|
||
┌──────────────┐
|
||
│ Data │
|
||
└──────────────┘
|
||
|
||
Physical Storage
|
||
┌──────────────┐
|
||
│ Old Data │
|
||
└──────────────┘
|
||
```
|
||
|
||
인데 Guest에게 `FLUSH 완료`라고 응답하면 문제가 된다.
|
||
|
||
PostgreSQL은 durability가 확보되었다고 판단할 수 있고, 직후 Host 전원이 나가면 RAM의 data가 사라진다.
|
||
|
||
이것은 성능 문제가 아니라 **durability contract가 깨지는 correctness 문제**다.
|
||
|
||
---
|
||
|
||
## 153. QEMU Cache Mode
|
||
|
||
QEMU/libvirt disk에서 대표적으로 볼 수 있는 설정:
|
||
|
||
```text
|
||
cache=none
|
||
cache=writeback
|
||
```
|
||
|
||
이름만 보고:
|
||
|
||
```text
|
||
none = cache 자체가 없음
|
||
writeback = 무조건 위험
|
||
```
|
||
|
||
이라고 해석하면 부정확하다.
|
||
|
||
핵심은 QEMU가 Host Page Cache와 write completion/flush semantics를 어떤 방식으로 사용할 것인가다.
|
||
|
||
---
|
||
|
||
## 154. `cache=none`
|
||
|
||
개념적으로 Host Page Cache를 우회하는 방향의 I/O 구성이다.
|
||
|
||
```text
|
||
Guest Page Cache
|
||
↓
|
||
virtio
|
||
↓
|
||
QEMU
|
||
↓
|
||
Direct I/O 계열
|
||
↓
|
||
Host Filesystem / Block Path
|
||
↓
|
||
Storage
|
||
```
|
||
|
||
이중 caching을 줄일 수 있다.
|
||
|
||
하지만:
|
||
|
||
```text
|
||
Host Page Cache 우회
|
||
≠
|
||
무조건 즉시 durable media 반영
|
||
```
|
||
|
||
이다.
|
||
|
||
---
|
||
|
||
## 155. `cache=writeback`
|
||
|
||
Host Page Cache를 사용할 수 있는 구성이다.
|
||
|
||
```text
|
||
Guest
|
||
↓
|
||
virtio
|
||
↓
|
||
QEMU
|
||
↓
|
||
Host Page Cache
|
||
↓
|
||
writeback
|
||
↓
|
||
Physical Storage
|
||
```
|
||
|
||
일반 write는 Host RAM에서 빠르게 completion될 수 있다.
|
||
|
||
```text
|
||
QEMU
|
||
↓
|
||
Host RAM에 기록
|
||
↓
|
||
WRITE completion
|
||
|
||
...
|
||
|
||
나중에
|
||
|
||
Host RAM
|
||
↓
|
||
Storage
|
||
```
|
||
|
||
하지만 `cache=writeback` 자체가 Guest의 `fsync()`/FLUSH를 무시한다는 뜻은 아니다.
|
||
|
||
정상적인 stack이라면:
|
||
|
||
```text
|
||
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에서 올바르게 보존되는지가 중요하다.
|
||
|
||
```text
|
||
Guest가 요구한 durability
|
||
│
|
||
▼
|
||
Guest Filesystem
|
||
│
|
||
▼
|
||
Guest Block Layer
|
||
│
|
||
▼
|
||
virtio
|
||
│
|
||
▼
|
||
QEMU/backend
|
||
│
|
||
▼
|
||
Host Storage
|
||
│
|
||
▼
|
||
Device
|
||
```
|
||
|
||
전체 chain에서 의미가 깨지지 않아야 한다.
|
||
|
||
---
|
||
|
||
## 157. Device-side Cache
|
||
|
||
Host Page Cache를 우회했다고 끝이 아니다.
|
||
|
||
```text
|
||
QEMU
|
||
↓
|
||
Direct I/O
|
||
↓
|
||
Host Block Layer
|
||
↓
|
||
NVMe Driver
|
||
↓
|
||
NVMe Controller
|
||
↓
|
||
Device-side Cache
|
||
↓
|
||
Flash
|
||
```
|
||
|
||
Storage controller/device가 volatile write cache를 가질 수 있다.
|
||
|
||
따라서:
|
||
|
||
```text
|
||
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가 된다.
|
||
|
||
```text
|
||
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를 공유하면
|
||
|
||
```text
|
||
VM1 QEMU ──┐
|
||
│
|
||
VM2 QEMU ──┼──→ Host Block Layer → NVMe
|
||
│
|
||
Nginx ─────┤
|
||
│
|
||
Host 기타 ─┘
|
||
```
|
||
|
||
여러 source에서 동시에 I/O가 들어올 수 있다.
|
||
|
||
```text
|
||
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`가 중요하다.
|
||
|
||
```text
|
||
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에 전달되는 것은 아니다.
|
||
|
||
```text
|
||
READ A
|
||
WRITE B
|
||
READ C
|
||
WRITE D
|
||
READ E
|
||
↓
|
||
|
||
┌─────────────────────┐
|
||
│ I/O Scheduler │
|
||
│ 요청 dispatch 정책 │
|
||
└──────────┬──────────┘
|
||
↓
|
||
Device Driver
|
||
```
|
||
|
||
대표적으로 볼 수 있는 scheduler:
|
||
|
||
```text
|
||
none
|
||
mq-deadline
|
||
bfq
|
||
```
|
||
|
||
scheduler마다 목적과 정책이 다르다.
|
||
|
||
---
|
||
|
||
## 162. `none`
|
||
|
||
`none`은 복잡한 scheduling 정책을 최소화해서 비교적 직접 device 쪽으로 dispatch하는 방향이다.
|
||
|
||
NVMe처럼 device 자체가 강한 병렬성과 queueing 기능을 가진 경우 이러한 단순한 정책이 적합할 수 있다.
|
||
|
||
단:
|
||
|
||
```text
|
||
none = block layer가 아무 일도 하지 않음
|
||
```
|
||
|
||
은 아니다.
|
||
|
||
---
|
||
|
||
## 163. 실제 I/O Scheduler 확인
|
||
|
||
Host:
|
||
|
||
```bash
|
||
cat /sys/block/nvme0n1/queue/scheduler
|
||
```
|
||
|
||
예시:
|
||
|
||
```text
|
||
[none] mq-deadline
|
||
```
|
||
|
||
대괄호 안이 현재 선택된 scheduler다.
|
||
|
||
SATA/SCSI device라면:
|
||
|
||
```bash
|
||
cat /sys/block/sda/queue/scheduler
|
||
```
|
||
|
||
처럼 확인한다.
|
||
|
||
---
|
||
|
||
## 164. NVMe Driver와 Physical Device
|
||
|
||
```text
|
||
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다.
|
||
|
||
```text
|
||
SSD
|
||
├─ SATA SSD
|
||
│ └─ SATA/AHCI
|
||
│
|
||
└─ NVMe SSD
|
||
└─ PCIe + NVMe
|
||
```
|
||
|
||
NVMe SSD:
|
||
|
||
```text
|
||
Linux NVMe Driver
|
||
↓
|
||
PCIe
|
||
↓
|
||
NVMe Controller
|
||
↓
|
||
Flash
|
||
```
|
||
|
||
---
|
||
|
||
## 166. Storage I/O Completion
|
||
|
||
WRITE 요청은 아래로 내려가고, 완료는 반대 방향으로 올라온다.
|
||
|
||
Request:
|
||
|
||
```text
|
||
Guest
|
||
│
|
||
│ WRITE
|
||
▼
|
||
virtio-blk
|
||
↓
|
||
virtqueue
|
||
↓
|
||
QEMU/backend
|
||
↓
|
||
Host Block Layer
|
||
↓
|
||
NVMe Driver
|
||
↓
|
||
NVMe
|
||
```
|
||
|
||
Completion:
|
||
|
||
```text
|
||
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 경쟁이 발생할 수 있다.
|
||
|
||
```text
|
||
VM1 PostgreSQL
|
||
│
|
||
├────────┐
|
||
│ │
|
||
VM2 Keycloak │
|
||
│ │
|
||
├────────┤
|
||
│ ▼
|
||
│ Host Block Layer
|
||
│ ↓
|
||
│ I/O Queue
|
||
│ ↓
|
||
└──────→ NVMe
|
||
```
|
||
|
||
VM1에서 대량 I/O가 발생하면 VM2의 storage latency가 증가할 수 있다.
|
||
|
||
```text
|
||
CPU Contention
|
||
→ Host logical CPU 실행 시간 경쟁
|
||
|
||
Storage Contention
|
||
→ IOPS / bandwidth / queue / device 처리시간 경쟁
|
||
```
|
||
|
||
둘은 다른 자원 경쟁이다.
|
||
|
||
---
|
||
|
||
## 168. CPU가 정상이어도 Storage 때문에 느릴 수 있다
|
||
|
||
```text
|
||
HTTP Request
|
||
↓
|
||
Keycloak
|
||
↓
|
||
PostgreSQL
|
||
↓
|
||
fsync()
|
||
↓
|
||
Storage
|
||
```
|
||
|
||
PostgreSQL이 storage completion을 기다리고 있으면 CPU usage가 높지 않을 수도 있다.
|
||
|
||
```text
|
||
CPU 30%
|
||
|
||
그런데
|
||
|
||
Request latency 2초
|
||
```
|
||
|
||
가 가능하다.
|
||
|
||
따라서 CPU 지표만으로 latency 원인을 판단하면 안 된다.
|
||
|
||
---
|
||
|
||
## 169. Storage 관측 명령어
|
||
|
||
대표적인 device I/O 관측:
|
||
|
||
```bash
|
||
iostat -xz 1
|
||
```
|
||
|
||
확인 대상:
|
||
|
||
- read/write throughput
|
||
- IOPS
|
||
- request latency
|
||
- queue 상태
|
||
- device utilization 성격의 지표
|
||
|
||
어떤 process가 I/O를 발생시키는지 볼 때:
|
||
|
||
```bash
|
||
iotop
|
||
```
|
||
|
||
Guest:
|
||
|
||
```bash
|
||
lsblk
|
||
mount
|
||
df -h
|
||
cat /proc/mounts
|
||
iostat -xz 1
|
||
```
|
||
|
||
Host:
|
||
|
||
```bash
|
||
virsh domblklist <VM_NAME>
|
||
qemu-img info <disk-image>
|
||
lsblk
|
||
cat /sys/block/<device>/queue/scheduler
|
||
iostat -xz 1
|
||
iotop
|
||
```
|
||
|
||
---
|
||
|
||
## 170. PostgreSQL 예시: WAL과 Durability
|
||
|
||
예를 들어:
|
||
|
||
```sql
|
||
BEGIN;
|
||
|
||
UPDATE users
|
||
SET balance = 1000
|
||
WHERE id = 1;
|
||
|
||
COMMIT;
|
||
```
|
||
|
||
을 생각한다.
|
||
|
||
PostgreSQL은 WAL 등의 durability protocol을 사용하며 필요한 시점에 storage synchronization을 수행한다.
|
||
|
||
```text
|
||
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가 커질 수 있다.
|
||
|
||
```text
|
||
WRITE
|
||
↓
|
||
Storage까지 동기화
|
||
↓
|
||
completion 대기
|
||
```
|
||
|
||
특히 DB workload에서는 `fsync()` latency가 transaction latency와 연결될 수 있다.
|
||
|
||
```text
|
||
더 적극적인 caching
|
||
↓
|
||
write latency 개선 가능
|
||
|
||
하지만
|
||
|
||
durability semantics를 반드시 보존해야 함
|
||
```
|
||
|
||
`fsync()`를 없애서 빨라졌다면 그것이 최적화가 아니라 durability contract를 제거한 것일 수 있다.
|
||
|
||
---
|
||
|
||
## 172. Storage Virtualization Canonical Flow
|
||
|
||
```text
|
||
[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:
|
||
|
||
```text
|
||
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는 같은 의미가 아니다.
|
||
|
||
```text
|
||
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:
|
||
|
||
```bash
|
||
lsblk
|
||
```
|
||
|
||
Host:
|
||
|
||
```bash
|
||
virsh domblklist <VM_NAME>
|
||
```
|
||
|
||
### OQ-2. Backend는 qcow2인가 RAW인가?
|
||
|
||
```bash
|
||
qemu-img info /path/to/disk-image
|
||
```
|
||
|
||
### OQ-3. qcow2 Virtual Size와 실제 Host 사용량은 얼마나 다른가?
|
||
|
||
```bash
|
||
qemu-img info <image>
|
||
du -h <image>
|
||
ls -lh <image>
|
||
```
|
||
|
||
세 명령이 보여주는 의미가 서로 다를 수 있으므로 비교한다.
|
||
|
||
### OQ-4. QEMU disk cache mode는 무엇인가?
|
||
|
||
```bash
|
||
virsh dumpxml <VM_NAME>
|
||
```
|
||
|
||
disk driver 설정의 cache 관련 값을 확인한다.
|
||
|
||
### OQ-5. qcow2가 최종적으로 어느 Host block device 위에 있는가?
|
||
|
||
```bash
|
||
lsblk
|
||
findmnt
|
||
```
|
||
|
||
### OQ-6. Host I/O Scheduler는 무엇인가?
|
||
|
||
```bash
|
||
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. 권장 실습 흐름
|
||
|
||
```text
|
||
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를 직접 다루지 않는다.
|
||
|
||
```text
|
||
Application
|
||
↓
|
||
Guest VFS
|
||
↓
|
||
Guest Filesystem
|
||
↓
|
||
Guest Page Cache
|
||
↓
|
||
Guest Block Layer
|
||
↓
|
||
virtio-blk
|
||
↓
|
||
virtqueue
|
||
```
|
||
|
||
VM 경계를 넘으면:
|
||
|
||
```text
|
||
QEMU
|
||
↓
|
||
qcow2 / RAW / Host Block Device
|
||
↓
|
||
Host Storage Stack
|
||
↓
|
||
Physical SSD/NVMe
|
||
```
|
||
|
||
로 이어진다.
|
||
|
||
이 경로에는 여러 cache, queue, scheduling 지점이 존재한다.
|
||
|
||
특히 DB workload에서는 다음을 항상 구분해야 한다.
|
||
|
||
```text
|
||
write 완료
|
||
≠
|
||
writeback 완료
|
||
≠
|
||
flush 완료
|
||
≠
|
||
전원 장애에도 살아남는 durability
|
||
```
|
||
|
||
Storage 문제를 분석할 때 CPU usage만 보지 말고 다음을 함께 본다.
|
||
|
||
```text
|
||
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/`](../source/docs/guides/) — 기반 7단계 가이드 |
|
||
| 실측 기록 | [`../source/docs/lab-virtualization.md`](../source/docs/lab-virtualization.md) |
|
||
| 개념 누적 | [`../source/docs/session-lab-concepts.md`](../source/docs/session-lab-concepts.md) |
|
||
| 설정 원본 | [`../source/deploy/lab/edge/`](../source/deploy/lab/edge/) |
|
||
| 리비전 | [`../source/.source-revision`](../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`](../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_network` 의
|
||
`guest_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` 가 맨 뒤다.
|
||
|
||
```bash
|
||
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/README.md) — 가이드 묶음이 스스로 정한 규약 |
|
||
| | [`../source/docs/guides/`](../source/docs/guides/) `00-lab-host/` ~ `06-observability/` — 7단계, 묶음 전체 3,223줄 |
|
||
| 설정 원본 | [`../source/deploy/lab/edge/`](../source/deploy/lab/edge/) — `lab-edge-dnat.nft` · `lab-edge-dnat.service` · `nginx-keycloak-lab.conf` · `reload-nginx.sh` |
|
||
| 리비전 | [`../source/.source-revision`](../source/.source-revision) — `9465582b5d1630eb4ae7c4e078021486919bf6b6` |
|
||
|
||
**제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.example` 은 `source/` 에
|
||
반입되지 않았다 — 매니페스트 원문은 여기 없고, 가이드가 화면에 옮겨 적은
|
||
만큼만 있다.
|
||
|
||
## 185. 가이드 묶음이 스스로 정한 규약
|
||
|
||
7단계를 읽기 전에 이 넷을 알아야 각 단계의 코드 블록이 읽힌다.
|
||
|
||
**① 명령을 두 종류로 갈라 적는다.** 실무자가 터미널에서 치는 명령과, 근거를
|
||
남기려고 재는 명령은 길이도 목적도 다르다.
|
||
|
||
| 표시 | 무엇인가 |
|
||
|---|---|
|
||
| **하기** · **확인** | 실무자가 실제로 치는 형태. 짧고, 한 번에 하나씩 |
|
||
| **근거를 재려면** | 이 실험대가 문서에 남기려고 쓴 긴 형태. 평소에는 필요 없다 |
|
||
|
||
`curl` 이 그 둘로 갈린 대표다.
|
||
|
||
```bash
|
||
curl -I https://auth.hyeonworks.com/realms/master # 한 번 볼 때
|
||
curl -s -o /dev/null -w '%{http_code}\n' <url> # 여러 번 재서 비교할 때
|
||
```
|
||
|
||
값만 뽑는 뒤엣것은 골라 놓은 한 칸 말고는 전부 버리므로, 무엇이 잘못됐는지
|
||
모르는 상태에서는 쓸 것이 못 된다. 03 의 층별 확인이 `-I` 로 시작해
|
||
`%{http_code}` 로 줄어드는 순서가 그래서 나온다.
|
||
|
||
**② 자리표시자를 두지 않는다.** `<토큰>` 처럼 적으면 그 값을 어디서 가져오는지가
|
||
문서 밖으로 나간다. 가이드는 값을 찾는 명령을 함께 적는다.
|
||
|
||
```bash
|
||
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` 에만 있다.
|
||
|
||
```bash
|
||
[워크스테이션] $ 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` 을 치면
|
||
이렇게 끝난다.
|
||
|
||
```bash
|
||
[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 `yes` → `virbr0` · `192.168.122.0/24` |
|
||
|
||
네 패키지가 하는 일이 서로 다르다.
|
||
|
||
| 무엇 | 하는 일 |
|
||
|---|---|
|
||
| qemu | 실제로 가상 기계를 돌리는 것 |
|
||
| libvirt | qemu 를 관리하는 층 (`virsh` 가 여기 붙는다) |
|
||
| virt-install | VM 을 만드는 명령 |
|
||
| dnsmasq | 가상 네트워크의 DHCP·DNS |
|
||
|
||
**전제와 되돌리기**
|
||
|
||
전제는 가이드가 두 줄로 적었다 — 「물리 기계 한 대. 이 실험대는 Arch Linux 를
|
||
썼지만 배포판은 상관없다 — 패키지 이름만 다르다.」 앞 단계가 없으므로 이 단계는
|
||
아무것도 전제하지 않는다.
|
||
|
||
**되돌리는 절차는 원본 가이드 00 에 없다**(unknown). 가이드 7편 가운데 되돌리기를
|
||
적은 편은 03 하나다. 이 단계가 호스트에 남기는 것은 패키지 넷, `libvirt` 보조
|
||
그룹 하나, `libvirtd.socket` 활성화, `~/.bashrc` 의 `export` 한 줄, `default`
|
||
네트워크의 autostart 다섯인데, 무엇을 어떤 순서로 걷어내는지는 가이드에 적혀
|
||
있지 않고 이 실험대도 걷어내 본 적이 없다. 여기에 되돌리는 명령을 적으려면
|
||
지어내야 하므로 적지 않는다.
|
||
|
||
**먼저 본다 — 바꾸기 전 상태**
|
||
|
||
두 확인은 아무것도 바꾸지 않는다. BIOS(Basic Input/Output System, 기계를 켤 때
|
||
먼저 도는 펌웨어와 그 설정 화면)에서 가상화가 꺼져 있으면 그 뒤가 전부 헛일이므로
|
||
먼저 본다.
|
||
|
||
**확인 ① CPU 가 하드웨어 가상화 확장을 내놓고 있는가**
|
||
|
||
```bash
|
||
grep -Eo 'vmx|svm' /proc/cpuinfo | head -1
|
||
```
|
||
|
||
**어디를 봐야 하는가** — 출력 한 줄이 전부다. `vmx`(Intel) 또는 `svm`(AMD) 중
|
||
하나가 찍히는가, 아니면 아무것도 안 찍히는가.
|
||
|
||
**이 결과가 의미하는 것** — 찍혔으면 이 호스트에서 KVM 을 쓸 수 있다. 빈 출력은
|
||
「CPU 가 못 한다」가 아니라 대개 **BIOS 에서 꺼져 있다**는 뜻이다 — 재부팅해
|
||
Intel VT-x / AMD-V 를 켜고 다시 잰다. 여기서 막히면 뒤의 어떤 단계도 의미가
|
||
없으므로 진행하지 않는다.
|
||
|
||
**확인 ② 커널이 그 확장을 실제로 잡고 있는가**
|
||
|
||
```bash
|
||
lsmod | grep kvm
|
||
```
|
||
|
||
형태만 옮긴다. 이 실험대에서 캡처해 두지 않았다(unknown).
|
||
|
||
```
|
||
kvm_intel ...
|
||
kvm ...
|
||
```
|
||
|
||
**어디를 봐야 하는가** — 왼쪽 첫 열의 모듈 이름 두 개. 벤더 모듈(`kvm_intel`
|
||
또는 `kvm_amd`)과 공용 `kvm` 이 **둘 다** 있어야 한다. 셋째 열은 이 모듈을 쓰고
|
||
있는 쪽의 수라서, 아직 VM 이 없으면 0 으로 나온다.
|
||
|
||
**이 결과가 의미하는 것** — 두 줄이면 커널이 하드웨어 가상화를 쓸 준비가 됐고
|
||
`virt-install` 이 KVM 가속으로 뜬다. `kvm` 만 있고 벤더 모듈이 없으면 확인 ① 의
|
||
BIOS 설정이 커널까지 안 넘어왔다 — `sudo modprobe kvm_intel` 로 직접 올려 보면
|
||
거부 사유가 그대로 나온다. 아무것도 없으면 확인 ① 로 돌아간다.
|
||
|
||
```bash
|
||
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`
|
||
로 막힌다 — 이 실험대에서 실제로 겪은 형태다.
|
||
|
||
① 이미 세웠다 철거한 적이 있으면 무엇이 남아 있는지 먼저 본다.
|
||
|
||
```bash
|
||
ls ~/workspace
|
||
```
|
||
|
||
② 받는다.
|
||
|
||
```bash
|
||
git clone https://git.learn.hyeonworks.com/donghyeon.kang/keycloak-pattern.git ~/workspace/keycloak-pattern
|
||
cd ~/workspace/keycloak-pattern
|
||
```
|
||
|
||
**확인**
|
||
|
||
```bash
|
||
ls deploy/lab/k8s/
|
||
```
|
||
|
||
**어디를 봐야 하는가** — `keycloak-cluster.yaml` 과 `observability.yaml` 이
|
||
보이는가.
|
||
|
||
**이 결과가 의미하는 것** — 이후 단계에서 `deploy/...` 로 시작하는 상대경로가
|
||
나오면 **전부 이 디렉터리 안에서 치는 것**이다. 다른 데서 치면 파일을 못 찾는다.
|
||
|
||
**★ 한 번 세웠다가 철거했다면 이 디렉터리가 없을 수 있다.** 철거는 VM·디스크·
|
||
네트워크만 지우고 저장소는 각자 관리라, `~/workspace` 에 cloud-init seed 만 남아
|
||
있는 상태가 흔하다. 그래서 ① 을 먼저 친다.
|
||
|
||
**3. 패키지 설치**
|
||
|
||
**목적** — `virsh` 와 `virt-install` 을 PATH(셸이 실행 파일을 찾아 다니는 디렉터리
|
||
목록)에 올리고 가상 네트워크의 DHCP 를 준비한다.
|
||
|
||
```bash
|
||
sudo pacman -S --needed qemu-full libvirt virt-install dnsmasq
|
||
```
|
||
|
||
Debian/Ubuntu 면 이름이 다르다. 이 실험대는 Arch 로 세웠고 아래 줄은 가이드가
|
||
참고로 적어 둔 것이다(external).
|
||
|
||
```bash
|
||
sudo apt install qemu-system-x86 libvirt-daemon-system virtinst cloud-image-utils
|
||
```
|
||
|
||
**확인** — 두 실행 파일이 PATH 에 들어왔는가
|
||
|
||
```bash
|
||
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 에 붙게 한다.
|
||
|
||
```bash
|
||
sudo systemctl enable --now libvirtd.socket
|
||
sudo usermod -aG libvirt "$USER"
|
||
```
|
||
|
||
그리고 **로그아웃했다 다시 들어온다.** 보조 그룹은 로그인할 때 정해지므로
|
||
`usermod` 만으로는 지금 셸에 반영되지 않는다.
|
||
|
||
**확인**
|
||
|
||
```bash
|
||
groups # libvirt 가 보여야 한다
|
||
virsh list --all # sudo 없이 돌아야 한다
|
||
```
|
||
|
||
**실측**(observed)
|
||
|
||
```
|
||
donghyeon libvirt wheel
|
||
```
|
||
|
||
**어디를 봐야 하는가** — `groups` 출력에 `libvirt` 가 끼어 있는가, 그리고
|
||
`virsh list --all` 이 **머리글만 있는 빈 표**라도 오류 없이 끝나는가. 아직 VM 을
|
||
안 만들었으므로 표가 비어 있는 쪽이 정상이다 — 봐야 할 것은 표의 내용이 아니라
|
||
명령이 통과했느냐다.
|
||
|
||
**이 결과가 의미하는 것** — 둘 다 통과하면 이 셸에서 VM 을 만들 수 있다.
|
||
`groups` 에 `libvirt` 가 없으면 `usermod` 는 됐지만 **지금 로그인 세션이 옛 그룹
|
||
목록을 들고 있다** — 로그아웃하고 다시 들어온다. `groups` 에는 있는데 `virsh` 가
|
||
`Permission denied` 면 그룹이 아니라 소켓 문제이므로 유닛 상태를 본다.
|
||
|
||
```bash
|
||
systemctl status libvirtd.socket
|
||
```
|
||
|
||
**`libvirtd.service` 가 아니라 `.socket` 을 켠다.** 소켓 활성화라 데몬이 미리 떠
|
||
있지 않아도 `virsh` 가 접속하는 순간 systemd 가 띄우고, 자원을 아끼며, 데몬을
|
||
재시작해도 클라이언트가 끊기지 않는다.
|
||
|
||
**5. 연결 URI 를 고정한다**
|
||
|
||
**목적** — `virsh` 가 VM 을 만들 곳과 같은 하이퍼바이저를 보게 한다. `virsh` 는
|
||
기본으로 `qemu:///session`(사용자 단위)에 붙는데 VM 은 `qemu:///system`(시스템
|
||
단위)에 만들어야 한다. **이걸 안 맞추면 만든 VM 이 안 보인다.**
|
||
|
||
**이 실험대는 이렇게 했다**(observed)
|
||
|
||
```bash
|
||
echo 'export LIBVIRT_DEFAULT_URI=qemu:///system' >> ~/.bashrc
|
||
virsh uri
|
||
```
|
||
|
||
**따라 하는 사람은** 편집기로 연다. `echo >>` 는 가이드를 두 번 따라 하면 같은
|
||
줄을 한 번 더 붙이고, 파일에 이미 무엇이 들어 있는지도 보여 주지 않는다.
|
||
|
||
```bash
|
||
nano ~/.bashrc
|
||
```
|
||
|
||
그 파일 끝에 이 줄을 더한다.
|
||
|
||
```text
|
||
export LIBVIRT_DEFAULT_URI=qemu:///system
|
||
```
|
||
|
||
저장하고 현재 셸에 반영한 뒤 확인한다.
|
||
|
||
```bash
|
||
source ~/.bashrc
|
||
virsh uri
|
||
```
|
||
|
||
**확인** — 지금 셸이 어느 하이퍼바이저를 보고 있는가
|
||
|
||
```bash
|
||
virsh uri
|
||
```
|
||
|
||
**실측**(observed)
|
||
|
||
```
|
||
qemu:///system
|
||
```
|
||
|
||
**어디를 봐야 하는가** — 끝의 한 낱말. `system` 인가 `session` 인가.
|
||
|
||
**이 결과가 의미하는 것** — `qemu:///system` 이면 뒤에서 만들 VM 과 지금 `virsh`
|
||
가 같은 곳을 본다. `qemu:///session` 이면 사용자 단위 하이퍼바이저를 보고 있어
|
||
**VM 은 만들어졌는데 `virsh list` 에 안 나오는** 상태가 된다. `.bashrc` 에 넣은
|
||
것은 **새로 여는 셸에만** 적용되므로, 지금 셸에서는 `source ~/.bashrc` 를 치거나
|
||
새 셸을 연다.
|
||
|
||
**6. 기본 네트워크**
|
||
|
||
**목적** — VM 이 붙을 `virbr0` 와 `192.168.122.0/24` 를 지금도 재부팅 뒤에도
|
||
있게 한다.
|
||
|
||
**확인** — 가상 네트워크가 살아 있는가
|
||
|
||
```bash
|
||
virsh net-list --all
|
||
```
|
||
|
||
**실측**(observed)
|
||
|
||
```
|
||
Name State Autostart Persistent
|
||
--------------------------------------------
|
||
default active yes yes
|
||
```
|
||
|
||
**어디를 봐야 하는가** — `default` 행의 **State 와 Autostart 두 칸**. `--all` 을
|
||
주는 까닭이 여기 있다 — 빼면 `inactive` 인 네트워크는 아예 목록에 안 나와서
|
||
「없음」과 「꺼짐」을 구분할 수 없다.
|
||
|
||
**이 결과가 의미하는 것** — `active` + `yes` 면 지금도, 호스트를 재부팅한 뒤에도
|
||
`virbr0` 와 `192.168.122.0/24` 가 있다. `inactive` 면 VM 을 만들어도 DHCP 가 없어
|
||
IP 를 못 받는다. Autostart 가 `no` 면 **지금은 되지만 호스트를 재부팅한 다음
|
||
01 의 SSH 가 전부 실패**하고, 그때 원인을 게스트에서 찾게 된다. 둘 중 하나라도
|
||
어긋나면 아래 두 줄로 맞춘다.
|
||
|
||
```bash
|
||
virsh net-start default
|
||
virsh net-autostart default
|
||
```
|
||
|
||
**끝났는지 판정한다**
|
||
|
||
위 일곱 확인이 다 통과했으면 이 단계는 끝났다. 다음으로 넘어가기 전에 한 번에
|
||
다시 보려면 다섯 칸이다.
|
||
|
||
| 무엇 | 명령 | 통과 |
|
||
|---|---|---|
|
||
| 가상화 확장 | `grep -Eo 'vmx\|svm' /proc/cpuinfo \| head -1` | `vmx` 또는 `svm` 한 줄 |
|
||
| 그룹 | `groups` | `libvirt` 가 끼어 있다 |
|
||
| 연결 URI | `virsh uri` | `qemu:///system` |
|
||
| 네트워크 | `virsh net-list --all` | `default` 가 `active` · autostart `yes` |
|
||
| 빈 목록 | `virsh list --all` | sudo 없이 통과. 표가 비어 있어도 된다 |
|
||
|
||
```bash
|
||
virsh uri
|
||
virsh net-list --all
|
||
virsh list --all
|
||
```
|
||
|
||
`virsh list --all` 이 **sudo 없이** 오류 없이 끝나는 것이 이 단계의 통과 조건
|
||
전부다. 표의 내용은 아직 볼 것이 없다.
|
||
|
||
**막히면**
|
||
|
||
| 증상 | 원인 | 확인 |
|
||
|---|---|---|
|
||
| `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 uri` 가 `qemu:///system`
|
||
- (unknown) `lsmod | grep kvm` 의 출력은 캡처해 두지 않았다. 가이드도 줄 모양만
|
||
적고 값을 싣지 않았다
|
||
- (unknown) 가이드의 실측 줄은 「이 실험대의 호스트는 16 코어 전부에서
|
||
지원한다」인데 §178 의 대상 환경은 **논리 코어 8**(i5-1135G7)이다. 두 값이
|
||
어긋나고, 어느 쪽이 이 호스트의 값인지는 재지 않았다
|
||
- (unknown) **원본 가이드에 되돌리는 절차가 없다.** 패키지·그룹·유닛·`.bashrc`·
|
||
네트워크 autostart 를 걷어내 본 적이 없다
|
||
- (unknown) `sudo modprobe kvm_intel` 과 `systemctl status libvirtd.socket` 은
|
||
막혔을 때 치라고 가이드가 적어 둔 것이고, 이 실험대에서는 막히지 않아 치지
|
||
않았다
|
||
- (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-<이름>.iso` — `CIDATA` 라벨 · `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).
|
||
|
||
```bash
|
||
virsh uri
|
||
virsh net-list --all
|
||
virsh list --all
|
||
```
|
||
|
||
**어디를 봐야 하는가** — `qemu:///system` 인가, `default` 가 `active` 인가,
|
||
그리고 `virsh list --all` 이 sudo 없이 통과하는가. 세 게스트를 아직 안 만들었으면
|
||
마지막 표는 비어 있다.
|
||
|
||
**이 결과가 의미하는 것** — 이 셋 가운데 하나라도 어긋난 채로 `virt-install` 을
|
||
치면 증상이 「VM 이 목록에 없다」나 「IP 를 못 받는다」로 나타나고, 원인은 01 이
|
||
아니라 00 에 있다.
|
||
|
||
**실행 절차**
|
||
|
||
번호는 가이드 01 의 것을 그대로 쓴다.
|
||
|
||
**1. base 이미지를 받는다**
|
||
|
||
**목적** — OS 를 설치하지 않는다. 클라우드 이미지는 **이미 설치가 끝난 디스크**
|
||
이고, 첫 부팅에서 cloud-init 이 사용자·SSH 키·호스트명을 채운다.
|
||
|
||
```bash
|
||
cd /var/lib/libvirt/images
|
||
sudo curl -fL --output /var/lib/libvirt/images/base.qcow2 \
|
||
https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2
|
||
```
|
||
|
||
**확인** — 받은 파일이 온전한 qcow2 인가
|
||
|
||
```bash
|
||
qemu-img info /var/lib/libvirt/images/base.qcow2
|
||
```
|
||
|
||
**어디를 봐야 하는가** — 세 줄이다. `file format:` 이 `qcow2` 인가(`raw` 로
|
||
읽히면 내려받기가 중간에 끊겨 HTML 오류 페이지를 저장한 것이다), `virtual size:`
|
||
가 `disk size:` 보다 훨씬 큰가(qcow2 는 희소 파일이라 그것이 정상이다),
|
||
`backing file:` 줄이 **없는가**. base 는 아무것도 뒤에 두지 않는다.
|
||
|
||
**이 결과가 의미하는 것** — 셋이 맞으면 이 파일을 5번에서 오버레이의 바닥으로
|
||
쓸 수 있다. 여기서 형식이 틀린 채로 진행하면 증상이 `virt-install` 이 아니라
|
||
**게스트가 부팅을 못 하는 것**으로 나타나 원인을 찾기 어려워진다. 제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).
|
||
|
||
자리표시자 셋을 실제 값으로 바꿔야 한다. **찾는 명령이 있다.**
|
||
|
||
```bash
|
||
# ① lab host 공개키 — 없으면 만든다
|
||
[ -f ~/.ssh/id_ed25519.pub ] || ssh-keygen -t ed25519 -N '' -f ~/.ssh/id_ed25519
|
||
cat ~/.ssh/id_ed25519.pub
|
||
|
||
# ② 워크스테이션 공개키 — 워크스테이션에서
|
||
cat ~/.ssh/id_ed25519.pub
|
||
|
||
# ③ 콘솔용 비밀번호 — 만들어서 보관한다
|
||
openssl rand -base64 18
|
||
```
|
||
|
||
**이 실험대는 이렇게 했다**(원본 01 본문) — 자리표시자 셋을 `sed` 로 한 번에
|
||
바꿨다.
|
||
|
||
```bash
|
||
sed -i \
|
||
-e "s|__LAB_HOST_KEY__|$(cat ~/.ssh/id_ed25519.pub)|" \
|
||
-e "s|__WORKSTATION_KEY__|<워크스테이션에서 복사해 온 값>|" \
|
||
-e "s|__CONSOLE_PW__|$(openssl rand -base64 18)|" \
|
||
kc-lab-1.yaml
|
||
```
|
||
|
||
**따라 하는 사람은** 파일부터 편집기로 만든다. 배우려는 것이 `hostname:` 과
|
||
`users:` 와 `packages:` 인데 `sed` 의 `-e` 세 개와 `|` 구분자와 `$(...)` 를 먼저
|
||
읽어야 하면 시선이 그쪽으로 간다.
|
||
|
||
```bash
|
||
nano kc-lab-1.yaml
|
||
```
|
||
|
||
**아래는 원본 01 본문이 실은 내용을 그대로 옮긴 것이다.** `__` 로 둘러싼 세
|
||
자리에 위 ①②③ 의 출력을 넣는다. 다른 게스트는 `hostname`·`fqdn` 두 줄만
|
||
`kc-lab-2`·`kc-lab-edge` 로 바꾼다.
|
||
|
||
```yaml
|
||
#cloud-config
|
||
hostname: kc-lab-1
|
||
fqdn: kc-lab-1
|
||
manage_etc_hosts: true
|
||
|
||
users:
|
||
- name: donghyeon
|
||
groups: [sudo]
|
||
shell: /bin/bash
|
||
sudo: ['ALL=(ALL) NOPASSWD:ALL']
|
||
lock_passwd: false
|
||
plain_text_passwd: __CONSOLE_PW__
|
||
ssh_authorized_keys:
|
||
- __LAB_HOST_KEY__
|
||
- __WORKSTATION_KEY__
|
||
|
||
ssh_pwauth: false
|
||
package_update: true
|
||
packages: [curl, nftables]
|
||
```
|
||
|
||
세 가지가 의도적이다.
|
||
|
||
| | 왜 |
|
||
|---|---|
|
||
| `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)
|
||
|
||
```bash
|
||
grep -c '__' kc-lab-1.yaml # 0 이어야 한다
|
||
grep -c 'ssh-ed25519\|ssh-rsa' kc-lab-1.yaml # 2 여야 한다
|
||
python3 -c 'import yaml,sys; yaml.safe_load(open("kc-lab-1.yaml")); print("YAML OK")'
|
||
```
|
||
|
||
**어디를 봐야 하는가** — 숫자 두 개와 낱말 하나. 첫 줄이 `0` 이 아니면
|
||
`__LAB_HOST_KEY__` 같은 문자열이 남아 있고, 둘째 줄이 `2` 가 아니면 `sed` 치환
|
||
중 하나가 안 먹었다. 셋째 줄은 `YAML OK` 가 찍히는가만 본다 — 찍히지 않으면
|
||
대신 파이썬 예외 줄이 나오고, 거기 적힌 `line N` 이 문제의 줄 번호다.
|
||
|
||
**이 결과가 의미하는 것** — `0` / `2` / `YAML OK` 셋이 다 맞아야 시드를 만든다.
|
||
어긋난 채로 3번을 진행하면 **cloud-init 은 파싱에 실패해도 아무 오류를 남기지
|
||
않으므로**, 증상이 「SSH 가 안 붙는다」로만 나타나고 원인이 이 파일에 있다는
|
||
사실이 드러나지 않는다. 여기서 거르는 것이 뒤에서 30분 걸릴 일을 없앤다.
|
||
**`yamllint` 은 이 실험대에 없다.** 있으면 좋지만 없다고 설치하러 가지 않는다.
|
||
|
||
**따라 하는 사람은 앞의 두 `grep` 까지만 그대로 친다.** 숫자 두 개를 눈으로
|
||
비교하는 용도라 그 형태가 맞다. 셋째 줄은 다르다 — YAML 로 파싱되는지만 보고
|
||
cloud-config 의 스키마는 못 본다. 이 형식에는 검증기가 따로 있으므로 쓸 수 있는
|
||
곳에서는 그쪽을 먼저 쓴다. 바로 아래 확인 ② 가 그 검증기다.
|
||
|
||
**첫 파일에서는 그 검증기를 못 쓴다.** `cloud-init schema` 는 게스트 안에 있는
|
||
cloud-init 22.4.2 이고, 첫 파일을 쓰는 시점에는 게스트가 아직 없다. lab host 에
|
||
cloud-init 이 깔려 있는지는 원본 가이드에 적혀 있지 않고 재지도 않았다(unknown).
|
||
그래서 첫 파일은 위 세 줄로 가고, 둘째 파일부터 아래처럼 게스트에서 검사한다.
|
||
|
||
**확인 ② cloud-config 로 유효한가**
|
||
|
||
YAML 로 파싱되는 것과 cloud-config 로 유효한 것은 다르다 — 키 이름 오타(`user`
|
||
와 `users`)는 확인 ① 을 그냥 통과한다.
|
||
|
||
**이 실험대는 이렇게 했다**(observed)
|
||
|
||
```bash
|
||
# 이 파일에는 콘솔 비밀번호가 평문으로 들어 있다 — /tmp 가 아니라
|
||
# 자기 홈에 600 으로 두고, 검사가 끝나면 바로 지운다
|
||
ssh donghyeon@192.168.122.11 'umask 077; cat > ~/kc-lab-2.yaml' < kc-lab-2.yaml
|
||
ssh donghyeon@192.168.122.11 'cloud-init schema -c ~/kc-lab-2.yaml; rm -f ~/kc-lab-2.yaml'
|
||
```
|
||
|
||
**따라 하는 사람은** 한 줄에 SSH 접속·리다이렉션·`umask`·파일 생성·로컬 stdin
|
||
전달을 한꺼번에 놓지 않고 나눈다. 파일을 보내고, 들어가고, 검사하고, 지운다.
|
||
|
||
```bash
|
||
scp kc-lab-2.yaml donghyeon@192.168.122.11:~/
|
||
ssh donghyeon@192.168.122.11
|
||
```
|
||
|
||
게스트 안에서 친다. `[kc-lab-1]`
|
||
|
||
```bash
|
||
chmod 600 ~/kc-lab-2.yaml
|
||
cloud-init schema -c ~/kc-lab-2.yaml
|
||
rm ~/kc-lab-2.yaml
|
||
```
|
||
|
||
명령 수는 둘에서 다섯으로 늘고 **행동 하나가 명령 하나**가 된다. `umask 077` 이
|
||
`chmod 600` 으로 바뀌는 것 하나가 다르다 — `scp` 가 만드는 동안에는 파일이 잠깐
|
||
기본 권한으로 놓이므로, 콘솔 비밀번호가 든 파일을 게스트에 두는 시간을 짧게
|
||
가져간다. 검사가 끝나면 지우는 것은 양쪽이 같다. 이 다섯 줄 형태는 이 실험대에서
|
||
치지 않았다(unknown).
|
||
|
||
**실측** — 통과하면 이 한 줄이다(observed).
|
||
|
||
```
|
||
Valid cloud-config: /home/donghyeon/kc-lab-edge.yaml
|
||
```
|
||
|
||
**어디를 봐야 하는가** — 마지막 한 줄. 통과하면 유효하다는 한 줄만 나오고,
|
||
아니면 `Invalid cloud-config` 아래에 **어느 키가 왜 틀렸는지**가 나열된다.
|
||
경고(`deprecated`)와 오류(`error`)는 다르다 — 경고는 지금 동작한다.
|
||
|
||
**이 결과가 의미하는 것** — 오류가 나면 그 키는 **조용히 무시된다.** `users` 를
|
||
`user` 로 잘못 쓰면 계정이 안 생기고, 증상은 역시 「SSH 가 안 붙는다」 하나로만
|
||
나타난다.
|
||
|
||
**★ `sudo` 는 리스트가 아니라 문자열로 쓴다**(observed). 게스트의 cloud-init
|
||
22.4.2 스키마 검사기가 리스트 형태를 거부한다.
|
||
|
||
```yaml
|
||
sudo: ['ALL=(ALL) NOPASSWD:ALL'] # 거부된다
|
||
sudo: "ALL=(ALL) NOPASSWD:ALL" # 통과한다
|
||
```
|
||
|
||
```
|
||
Error: Cloud config schema errors: users.0: {'name': 'donghyeon', 'groups':
|
||
['sudo'], 'shell': '/bin/bash', ...} is not valid under any of the given schemas
|
||
```
|
||
|
||
어느 키가 문제인지 **안 알려 준다** — `users.0` 전체를 통째로 찍고 「어느
|
||
스키마에도 안 맞는다」고만 한다. 그래서 키를 하나씩 바꿔 가며 좁혀야 한다.
|
||
그리고 리스트 형태도 **부팅은 된다** — `kc-lab-1`·`kc-lab-2` 가 그 상태로
|
||
NOPASSWD sudo 가 멀쩡히 돌고 있다. 검사기만 거부하므로 「검사는 실패했는데 왜
|
||
되지」로 읽히기 쉽다.
|
||
|
||
**3. 시드 이미지를 만든다**
|
||
|
||
**목적** — cloud-init 은 `cidata` 라벨이 붙고 안에 `user-data`·`meta-data` 라는
|
||
**정확한 이름**의 파일이 있는 볼륨을 찾는다.
|
||
|
||
**이 실험대는 이렇게 했다**(observed)
|
||
|
||
```bash
|
||
printf 'instance-id: kc-lab-1-%s\nlocal-hostname: kc-lab-1\n' "$(date +%s)" > meta-kc-lab-1
|
||
|
||
xorrisofs -quiet -output seed-kc-lab-1.iso -volid CIDATA -joliet -rock \
|
||
-graft-points /user-data=kc-lab-1.yaml /meta-data=meta-kc-lab-1
|
||
|
||
virsh vol-create-as default seed-kc-lab-1.iso "$(stat -c%s seed-kc-lab-1.iso)" --format raw
|
||
virsh vol-upload --pool default seed-kc-lab-1.iso seed-kc-lab-1.iso
|
||
```
|
||
|
||
**따라 하는 사람은** meta-data 도 편집기로 쓴다. 첫 줄 하나를 읽으려고 `printf`
|
||
`%s` `\n` `$(...)` `date +%s` `>` 다섯을 해석해야 하는데, 정작 배울 것은
|
||
`instance-id:` 와 `local-hostname:` 두 키다.
|
||
|
||
```bash
|
||
nano meta-kc-lab-1
|
||
```
|
||
|
||
```yaml
|
||
instance-id: kc-lab-1-20260912
|
||
local-hostname: kc-lab-1
|
||
```
|
||
|
||
`kc-lab-1-` 뒤의 `20260912` 는 **예시 값**이다. 원래 명령은 거기에
|
||
`date +%s`(에폭 초)를 넣었다. 값 자체는 아무 의미가 없고, cloud-init 이 이전
|
||
실행과 다른 인스턴스로 알아보게 **이전 값과 겹치지 않게** 둔다. 날짜든 에폭
|
||
초든 저번과 다르기만 하면 된다.
|
||
|
||
나머지 네 줄은 그대로 친다. 시드 이미지를 굽고 볼륨을 만들어 올리는 일이 그
|
||
명령들의 목적이라 CLI 가 맞는 형태다.
|
||
|
||
```bash
|
||
xorrisofs -quiet -output seed-kc-lab-1.iso -volid CIDATA -joliet -rock \
|
||
-graft-points /user-data=kc-lab-1.yaml /meta-data=meta-kc-lab-1
|
||
|
||
virsh vol-create-as default seed-kc-lab-1.iso "$(stat -c%s seed-kc-lab-1.iso)" --format raw
|
||
virsh vol-upload --pool default seed-kc-lab-1.iso seed-kc-lab-1.iso
|
||
```
|
||
|
||
**확인** — 볼륨이 풀에 올라갔고 비어 있지 않은가
|
||
|
||
```bash
|
||
virsh vol-list default
|
||
```
|
||
|
||
**어디를 봐야 하는가** — `seed-kc-lab-1.iso` 행이 목록에 있는가. 있으면 크기까지
|
||
본다.
|
||
|
||
```bash
|
||
virsh vol-info --pool default seed-kc-lab-1.iso
|
||
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 를 줘라」이지 「지금 있는 게스트에게 줘라」가 아니다.
|
||
|
||
```bash
|
||
virsh net-update default add ip-dhcp-host \
|
||
"<host mac='52:54:00:aa:bb:10' name='kc-lab-edge' ip='192.168.122.10'/>" \
|
||
--live --config
|
||
virsh net-update default add ip-dhcp-host \
|
||
"<host mac='52:54:00:aa:bb:11' name='kc-lab-1' ip='192.168.122.11'/>" \
|
||
--live --config
|
||
virsh net-update default add ip-dhcp-host \
|
||
"<host mac='52:54:00:aa:bb:12' name='kc-lab-2' ip='192.168.122.12'/>" \
|
||
--live --config
|
||
```
|
||
|
||
넣을 때 나오는 한 줄이 이것이다(observed).
|
||
|
||
```
|
||
Updated network default persistent config and live state
|
||
```
|
||
|
||
`persistent config` 와 `live 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 -- $entry` 가 `name=""` 로 끝난다. 증상은
|
||
`XML error: Cannot use host name '' in network 'default'` 다. 세 줄을 값 그대로
|
||
쓰는 편이 안전하다.
|
||
|
||
**확인** — 예약이 실제로 들어갔는가
|
||
|
||
```bash
|
||
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-host` 는 `net-update` 의
|
||
**섹션 이름**이지 XML 안에 있는 문자열이 아니다. 그렇게 치면 예약이 멀쩡히
|
||
들어가 있어도 **아무것도 안 나오고**, 예약이 안 들어갔다고 오독하게 된다. XML
|
||
안의 실제 요소는 `<host mac=...>` 다.
|
||
|
||
**이 결과가 의미하는 것** — 세 줄이 다 보이면 게스트는 재부팅해도 같은 IP 를
|
||
받는다. 빠진 줄이 있으면 `--live --config` 중 하나를 빠뜨렸다.
|
||
`net-dumpxml` 은 **지금 돌고 있는 정의**를 보여 주므로 여기 보이는 것은 `--live`
|
||
가 먹었다는 뜻이고, 재부팅 뒤에도 남는지는 따로 본다.
|
||
|
||
```bash
|
||
virsh net-dumpxml --inactive default
|
||
```
|
||
|
||
**5. VM 을 만든다**
|
||
|
||
**목적** — 예약한 MAC 을 달고 base 위 오버레이로 게스트를 띄운다.
|
||
|
||
```bash
|
||
virt-install --name kc-lab-1 --memory 5120 --vcpus 2 \
|
||
--disk size=20,backing_store=/var/lib/libvirt/images/base.qcow2 \
|
||
--disk vol=default/seed-kc-lab-1.iso,device=disk,bus=virtio,readonly=on \
|
||
--network network=default,mac=52:54:00:aa:bb:11 \
|
||
--import --os-variant debian12 --noautoconsole
|
||
```
|
||
|
||
나머지 둘은 **이름·메모리·MAC·시드**만 바꾼다.
|
||
|
||
```bash
|
||
# kc-lab-2 — k3s agent
|
||
virt-install --name kc-lab-2 --memory 4096 --vcpus 2 \
|
||
--disk size=20,backing_store=/var/lib/libvirt/images/base.qcow2 \
|
||
--disk vol=default/seed-kc-lab-2.iso,device=disk,bus=virtio,readonly=on \
|
||
--network network=default,mac=52:54:00:aa:bb:12 \
|
||
--import --os-variant debian12 --noautoconsole
|
||
|
||
# kc-lab-edge — nginx + certbot 만 돌므로 훨씬 작아도 된다
|
||
virt-install --name kc-lab-edge --memory 1024 --vcpus 1 \
|
||
--disk size=10,backing_store=/var/lib/libvirt/images/base.qcow2 \
|
||
--disk vol=default/seed-kc-lab-edge.iso,device=disk,bus=virtio,readonly=on \
|
||
--network network=default,mac=52:54:00:aa:bb:10 \
|
||
--import --os-variant debian12 --noautoconsole
|
||
```
|
||
|
||
**실측** — 엣지 생성 출력이다(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 이 제 일을 했는가**
|
||
|
||
```bash
|
||
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 가 **비밀번호를 묻지
|
||
않고** 통과했는가. ③ `hostname` 이 `kc-lab-1` 인가 `localhost` 인가.
|
||
|
||
**이 결과가 의미하는 것** — 이 세 줄이 cloud-init 성공의 판정 기준 전부다.
|
||
호스트명이 바뀌어 있다는 것은 시드가 읽혔다는 뜻이고, 시드가 읽혔으면 같은
|
||
파일에 있던 SSH 키도 들어갔다는 뜻이다. **`localhost` 가 나오면 SSH 설정을
|
||
고치지 말고 시드부터 의심한다.** Id 번호가 2 보다 큰 것은 아무 뜻도 없다
|
||
(만들고 지운 이력일 뿐이다).
|
||
|
||
**확인 ② 엣지가 예약한 IP 를 받았고 cloud-init 이 끝났는가**
|
||
|
||
```bash
|
||
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 status` 가 `done` 인가.
|
||
|
||
**이 결과가 의미하는 것** — `running` 이면 아직 패키지를 받는 중이니 기다린다 —
|
||
이 실험대에서는 **약 50초** 걸렸다. `error` 면 어느 모듈이 실패했는지 길게 본다.
|
||
|
||
```bash
|
||
ssh kc-lab-edge 'cloud-init status --long'
|
||
```
|
||
|
||
**확인 ③ 게스트에 못 들어갈 때는 화면을 직접 뜬다**
|
||
|
||
```bash
|
||
virsh screenshot kc-lab-1 /tmp/kc1.ppm # 확장자와 무관하게 PNG 로 저장된다
|
||
```
|
||
|
||
**어디를 봐야 하는가** — 이미지를 열어 **로그인 프롬프트 앞의 호스트명 한
|
||
낱말**만 본다. `localhost login:` 인가 `kc-lab-1 login:` 인가.
|
||
|
||
**이 결과가 의미하는 것** — `localhost` 면 cloud-init 이 아예 안 돌았다. 시드를
|
||
못 찾은 것(5번의 `bus=virtio`)이거나 YAML 파싱 실패(2번)이므로 SSH 쪽은 볼
|
||
필요가 없다. `kc-lab-1` 이면 cloud-init 은 돌았고 **그 안의 사용자·키 단계에서
|
||
틀린** 것이라, 이제 콘솔로 들어가 게스트 안의 로그를 본다.
|
||
|
||
```bash
|
||
virsh console kc-lab-1 # 빠져나오려면 Ctrl+]
|
||
```
|
||
|
||
게스트 안에서 친다. `[kc-lab-1]`
|
||
|
||
```bash
|
||
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 info` 가 `raw` 라고 한다 | 내려받기가 끊겨 오류 페이지를 저장했다 | 다시 받는다 |
|
||
| 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.example` 이 `source/` 에 없어 대조하지 못했다
|
||
- (unknown) cloud-init 의 `packages` 에 certbot 이 들어 있었는지가 가이드 안에서
|
||
갈린다. 01 의 예시와 03 의 본문은 `curl`·`nftables` 뿐이라 적고, 04 는
|
||
`kc-lab.yaml.example` 의 `packages` 에 certbot 이 있다고 적는다
|
||
- (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` 로 나온다.
|
||
> `sudo` 도 `ssh` 도 붙이지 않는다.
|
||
|
||
| 무엇 | `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/config` — `600`. `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.sh` 와 `k3s-agent-uninstall.sh` 를 함께 깔지만 **가이드가 그
|
||
이름을 한 번도 적지 않았고** 이 실험대도 부른 적이 없다. 가이드가 재설치를
|
||
말하는 곳은 두 군데인데 둘 다 되돌리는 명령이 아니라 뒤처리를 적어 두었다 —
|
||
노드 IP 가 다른 대역으로 잡혔으면 「그때 고치는 것보다 지금 재설치가 싸다」,
|
||
CA(Certificate Authority, 인증서에 서명해 주는 쪽)가 바뀌었으면 「2번의 복사를 다시
|
||
한다」. 그래서 여기에 걷어내는 명령을 적지
|
||
않는다.
|
||
|
||
**먼저 본다 — 바꾸기 전 상태**
|
||
|
||
**가이드 02 에도 바꾸기 전 상태를 보는 단계가 없다.** 01 이 남긴 확인을 그대로
|
||
다시 치는 것이 이 단계의 전제다(inferred).
|
||
|
||
```bash
|
||
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 을 세우고 자기 자신을 노드로 등록시킨다.
|
||
|
||
```bash
|
||
ssh kc-lab-1 'curl -sfL https://get.k3s.io | sudo sh -s - server --node-ip 192.168.122.11'
|
||
```
|
||
|
||
게스트에 들어가지 않고 원격 실행한다. cloud-init 이 `NOPASSWD:ALL` 을 넣어
|
||
두었으므로 비대화식 `sudo` 가 멈추지 않는다.
|
||
|
||
`--node-ip` 를 준다. 게스트에 인터페이스가 여럿이면 k3s 가 엉뚱한 것을 고를 수
|
||
있고, 그러면 두 노드가 서로를 다른 주소로 알게 된다.
|
||
|
||
**확인** — 서버가 떴고 자기 자신을 노드로 등록했는가
|
||
|
||
```bash
|
||
ssh kc-lab-1 'sudo systemctl is-active k3s; sudo kubectl get nodes'
|
||
```
|
||
|
||
**어디를 봐야 하는가** — 유닛이 `active` 인가, 그리고 `get nodes` 에 `kc-lab-1`
|
||
한 줄이 `Ready` 로 있는가. **설치 직후 30초 남짓은 `NotReady` 이거나 아예 목록이
|
||
비어 있는 것이 정상이다** — CNI 가 아직 안 올라온 시간이다. 한 번 더 친다.
|
||
|
||
이 가이드에서 `kubectl` 을 게스트 쪽에서 돌리는 일은 여기 한 번으로 끝난다. lab host
|
||
에는 아직 kubeconfig 가 없기 때문이고, 그것을 두는 일이 바로 2번이다.
|
||
|
||
**이 결과가 의미하는 것** — `active` + `Ready` 면 API 서버가 살아 있고 kubeconfig
|
||
도 자리를 잡았다는 뜻이라 2번으로 간다. 유닛이 `active` 인데 `get nodes` 가 접속
|
||
오류를 내면 API 서버가 아직 기동 중이다. 유닛이 `activating` 에서 안 넘어가거나
|
||
`failed` 면 설치 자체가 실패한 것이니 로그를 본다.
|
||
|
||
```bash
|
||
ssh kc-lab-1 'sudo journalctl -u k3s -n 50 --no-pager'
|
||
```
|
||
|
||
**2. lab host 에 kubeconfig 를 둔다**
|
||
|
||
**목적** — 이 뒤로 `kubectl` 을 계속 쓴다. 지금 한 번 해 두면 남은 단계에서
|
||
`ssh` 도 `sudo` 도 붙이지 않는다. **그리고 agent 노드에는 kubeconfig 가 없으므로**
|
||
(6번) 클러스터를 어디서 볼지 먼저 정해 두는 편이 낫다.
|
||
|
||
**이 실험대는 이렇게 했다**(observed)
|
||
|
||
```bash
|
||
mkdir -p ~/.kube
|
||
ssh kc-lab-1 'sudo cat /etc/rancher/k3s/k3s.yaml' \
|
||
| sed 's|127.0.0.1|192.168.122.11|' > ~/.kube/config
|
||
chmod 600 ~/.kube/config
|
||
```
|
||
|
||
세 줄이 다 필요하다.
|
||
|
||
| 줄 | 빠뜨리면 |
|
||
|---|---|
|
||
| `mkdir -p ~/.kube` | `>` 는 파일을 열 뿐 경로를 만들지 않는다. `cat` 이 시작되기도 전에 `No such file or directory` 로 끝난다 |
|
||
| `sed` | k3s 가 쓴 주소는 `https://127.0.0.1:6443` 이다. **게스트 안에서만 맞는 주소**라 lab host 에서는 자기 자신의 6443 을 두드리게 된다 |
|
||
| `chmod 600` | 이 파일은 클러스터 admin 자격증명이다. 비밀번호 파일과 같은 급으로 다룬다 |
|
||
|
||
`sudo` 는 `cat` 에만 걸리고 `>` 에는 걸리지 않는다. 리다이렉션은 셸이 **명령보다
|
||
먼저** 현재 사용자 권한으로 처리하기 때문이다. 그래서 출력 파일은 홈 아래
|
||
(`~/.kube/config`)에 둔다.
|
||
|
||
**따라 하는 사람은** 받아 놓고 편집기로 고친다. kubeconfig 는 앞으로 클러스터를
|
||
볼 때마다 다시 열어 보게 되는 파일이라, 한 번은 전체가 어떻게 생겼는지 보는
|
||
편이 낫다. `sed` 로 치환하면 바꾼 줄도 나머지 줄도 보지 않고 지나간다
|
||
(unknown — 이 실험대는 위의 파이프로 했고 아래 형태는 치지 않았다).
|
||
|
||
```bash
|
||
mkdir -p ~/.kube
|
||
ssh kc-lab-1 'sudo cat /etc/rancher/k3s/k3s.yaml' > ~/.kube/config
|
||
chmod 600 ~/.kube/config
|
||
```
|
||
|
||
```bash
|
||
nano ~/.kube/config
|
||
```
|
||
|
||
`server:` 줄 하나를 고친다. 나머지는 그대로 둔다.
|
||
|
||
```yaml
|
||
server: https://192.168.122.11:6443
|
||
```
|
||
|
||
**확인** — 주소가 바뀌었고, 밖에서 붙는가
|
||
|
||
```bash
|
||
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 nodes` 가 **`sudo` 없이** 도는가. 아직 노드는 한 줄뿐인
|
||
것이 정상이다 — agent 는 4번에서 붙인다.
|
||
|
||
**이 결과가 의미하는 것** — 통과하면 이 뒤의 `kubectl` 은 전부 lab host 에서
|
||
친다. `x509` 오류가 나면 파일 안의 CA 와 서버가 지금 쓰는 CA 가 어긋났다 —
|
||
k3s 를 다시 깔았다면 이 복사도 다시 해야 한다. `connection refused` 면 주소는
|
||
맞는데 API 서버가 아직 안 떴다.
|
||
|
||
**인증서 SAN 에 두 주소가 다 들어 있어서** 이 `sed` 가 통한다. 확인하려면 이렇게
|
||
친다.
|
||
|
||
```bash
|
||
ssh kc-lab-1 'sudo openssl x509 -in /var/lib/rancher/k3s/server/tls/serving-kube-apiserver.crt -noout -ext subjectAltName'
|
||
```
|
||
|
||
`IP Address:127.0.0.1` 과 `IP Address:192.168.122.11` 이 둘 다 보인다. 8번의 SSH
|
||
터널이 `127.0.0.1` 로 붙을 수 있는 것도 같은 까닭이다.
|
||
|
||
**3. 토큰을 꺼낸다**
|
||
|
||
**목적** — agent 가 클러스터에 들어올 때 낼 자격을 손에 쥔다. 화면에 찍어 눈으로
|
||
옮기지 말고 변수로 받는다.
|
||
|
||
```bash
|
||
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번을 칠 바로 그 셸에서** 다시 친다 |
|
||
|
||
파일이 있는지부터 볼 때는 이 줄이다.
|
||
|
||
```bash
|
||
ssh kc-lab-1 'sudo ls -l /var/lib/rancher/k3s/server/node-token'
|
||
```
|
||
|
||
**★ 4번을 3번과 같은 셸에서 친다.** 변수는 셸 밖으로 나가지 않는다. 창을 새로
|
||
열거나 `ssh` 로 어딘가 들어갔다 나오면 `TOKEN` 은 없다.
|
||
|
||
**4. agent 를 붙인다 (kc-lab-2)**
|
||
|
||
**목적** — 두 번째 노드를 클러스터에 넣는다. 3번과 **같은 셸**에서 친다.
|
||
|
||
```bash
|
||
[ ${#TOKEN} -ge 50 ] || echo "TOKEN 이 비었다 — 3번으로 돌아간다"
|
||
|
||
ssh kc-lab-2 "curl -sfL https://get.k3s.io | sudo sh -s - agent \
|
||
--server https://192.168.122.11:6443 \
|
||
--token '$TOKEN' \
|
||
--node-ip 192.168.122.12"
|
||
```
|
||
|
||
첫 줄의 가드를 빼지 않는다. `$TOKEN` 이 비면 게스트에는
|
||
`--token '' --node-ip ...` 가 전달되고, agent 는 기동 즉시
|
||
`level=fatal msg="Error: --token is required"` 로 죽는다. **그런데 설치
|
||
스크립트는 내려받기·유닛 생성·`enable` 까지 다 성공으로 찍고 끝나고**, 유닛은
|
||
`Restart=always` 라 5초마다 조용히 재시도한다. 가드 한 줄이 그 몇 분을 막는다.
|
||
|
||
토큰을 셸 히스토리에 남기고 싶지 않으면 파일로 넘긴다. 이러면 변수를 쓰지
|
||
않으므로 3번의 「같은 셸」 제약도 없어진다.
|
||
|
||
**이 실험대는 이렇게 했다**(원본 02 본문)
|
||
|
||
```bash
|
||
ssh kc-lab-1 'sudo cat /var/lib/rancher/k3s/server/node-token' \
|
||
| ssh kc-lab-2 'sudo tee /tmp/token >/dev/null'
|
||
ssh kc-lab-2 "curl -sfL https://get.k3s.io | sudo sh -s - agent \
|
||
--server https://192.168.122.11:6443 --token-file /tmp/token \
|
||
--node-ip 192.168.122.12; rm -f /tmp/token"
|
||
```
|
||
|
||
**따라 하는 사람은** 두 `ssh` 를 파이프로 잇지 않는다. 이 두 줄 안에 원격 셸
|
||
둘과 `sudo` 둘, 파이프 하나, 설치 스크립트 하나, 마지막 `rm` 하나가 겹쳐 있어
|
||
실패했을 때 어느 쪽이 실패했는지 갈리지 않는다. 토큰을 lab host 에 한 번
|
||
내려놓고, 옮기고, 게스트에 들어가서 설치한다.
|
||
|
||
```bash
|
||
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]`
|
||
|
||
```bash
|
||
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` 대신 `scp` 와 `chmod 600`
|
||
이 그 파일을 만든다. 설치 스크립트는 `sudo` 아래 root 로 도니 홈의 `600` 파일도
|
||
읽는다. 설치가 끝나면 지우는 것은 양쪽이 같다. 이 여덟 줄 형태는 이 실험대에서
|
||
치지 않았다(unknown).
|
||
|
||
**확인** — agent 가 클러스터에 들어왔는가, 그리고 **제 주소로** 들어왔는가
|
||
|
||
```bash
|
||
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 쪽 로그를 본다.
|
||
|
||
```bash
|
||
ssh kc-lab-2 'sudo journalctl -u k3s-agent -n 30 --no-pager'
|
||
```
|
||
|
||
**5. 유닛 이름이 다르다**
|
||
|
||
| 노드 | 유닛 |
|
||
|---|---|
|
||
| server | `k3s.service` |
|
||
| agent | `k3s-agent.service` |
|
||
|
||
**확인** — 어느 노드가 무슨 이름으로, 어떤 인자로 돌고 있는가
|
||
|
||
```bash
|
||
ssh kc-lab-1 'systemctl cat k3s | grep -A3 ExecStart='
|
||
ssh kc-lab-2 'systemctl cat k3s-agent | grep -A4 ExecStart='
|
||
```
|
||
|
||
**실측**(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 wide` 는 **k3s 가 보고한** 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]`. 무엇이 이미 돌고 있고, 기본 저장소가 무엇인가
|
||
|
||
```bash
|
||
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**. `Running` 과 `Completed` 가 섞여 있는 것이 정상이다 —
|
||
`helm-install-traefik-*` 는 일회성 잡이라 `Completed` 로 남는다.
|
||
`svclb-traefik-*` 가 **두 줄**인 것도 봐 둔다. DaemonSet 이라 노드마다 하나씩
|
||
뜨는 것이고, 이 두 줄이 4번의 join 이 실제로 먹었다는 또 하나의 증거다.
|
||
`get storageclass` 에서는 이름 뒤의 **`(default)` 표시**가 어디 붙어 있는가.
|
||
|
||
**이 결과가 의미하는 것** — 여기 뜬 것들은 우리가 안 깔았는데 있는 것이고, 뒤
|
||
단계에서 「왜 80 포트가 이미 잡혀 있지」·「왜 PVC 가 이 노드에만 묶이지」의 답이
|
||
전부 이 목록에 있다. `local-path` 에 `(default)` 가 붙어 있으면 05 의 PVC 는
|
||
StorageClass 를 안 적어도 이것으로 만들어진다. `Pending` 이나 `CrashLoopBackOff`
|
||
가 섞여 있으면 그 파드부터 `describe` 로 본다. 제4부 §131·§159 가 적은 노드 로컬
|
||
디스크가 `local-path` 를 통해 여기서 그대로 걸린다.
|
||
|
||
**8. 워크스테이션에서 쓰려면**
|
||
|
||
**2번의 kubeconfig 를 그대로 가져와도 안 된다.** 게스트는 libvirt NAT 안에 있어서
|
||
워크스테이션에서 `192.168.122.11` 로 가는 경로가 없다. lab host 를 거치는 터널을
|
||
뚫는다.
|
||
|
||
**이 실험대는 이렇게 했다**(observed). `[워크스테이션]`
|
||
|
||
```bash
|
||
# 1) 터널. 이 창은 열어 둔다
|
||
ssh -N -L 6443:192.168.122.11:6443 test-server
|
||
|
||
# 2) 다른 창에서 — 게스트 원본을 그대로 가져온다. sed 가 필요 없다
|
||
mkdir -p ~/.kube
|
||
ssh test-server "ssh kc-lab-1 'sudo cat /etc/rancher/k3s/k3s.yaml'" > ~/.kube/kc-lab.yaml
|
||
chmod 600 ~/.kube/kc-lab.yaml
|
||
export KUBECONFIG=~/.kube/kc-lab.yaml
|
||
```
|
||
|
||
**따라 하는 사람은** `ssh` 를 겹치지 않는다. 두 번째 줄은 워크스테이션에서 lab
|
||
host 로 붙고 lab host 가 다시 게스트로 붙는데, 따옴표가 두 겹이라 어느 기계에서
|
||
어느 명령이 도는지가 한 줄에 묻힌다. 파일을 게스트에서 lab host 로 한 번, lab
|
||
host 에서 워크스테이션으로 한 번 옮기면 기계마다 한 명령이 된다(unknown — 이
|
||
실험대는 위의 한 줄로 했고 아래 형태는 치지 않았다).
|
||
|
||
```bash
|
||
# lab host 에서 — 게스트 원본을 자기 홈에 받는다
|
||
ssh kc-lab-1 'sudo cat /etc/rancher/k3s/k3s.yaml' > kc-lab.yaml
|
||
chmod 600 kc-lab.yaml
|
||
```
|
||
|
||
```bash
|
||
# 워크스테이션에서 — 받아 오고 원본은 지운다
|
||
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
|
||
자신을 가리키기 때문이었다. **같은 파일이라도 어느 기계에서 읽느냐에 따라 맞는
|
||
주소가 다르다.**
|
||
|
||
**확인**
|
||
|
||
```bash
|
||
grep server: ~/.kube/kc-lab.yaml
|
||
kubectl get nodes
|
||
```
|
||
|
||
**어디를 봐야 하는가** — `server:` 가 `https://127.0.0.1:6443` 인가. 그다음
|
||
`get nodes` 가 4번과 **같은 두 줄**을 내놓는가.
|
||
|
||
**이 결과가 의미하는 것** — 터널 덕에 워크스테이션의 6443 이 `kc-lab-1` 의 6443
|
||
이다. API 서버 인증서 SAN 에 `127.0.0.1` 이 들어 있어서 인증서 검증도 통과한다.
|
||
타임아웃이면 1)의 터널 창이 닫힌 것이고, `connection refused` 면 터널은 살아
|
||
있는데 반대편 API 서버가 죽었다.
|
||
|
||
**★ 터널이 닫히면 `kubectl` 이 통째로 멎는다.** 이 실험대의 A층 실험은 노드를
|
||
죽이고 살리는 것이 목적이라, 터널 상태와 클러스터 상태가 섞여 오독하기 쉽다.
|
||
**A층 실험은 lab host 에서 치는 것을 권한다.**
|
||
|
||
**끝났는지 판정한다**
|
||
|
||
| 무엇 | 명령 | 통과 |
|
||
|---|---|---|
|
||
| 두 노드 | `kubectl get nodes -o wide` | 둘 다 `Ready` · INTERNAL-IP 가 `.11`·`.12` |
|
||
| 자리 | 위 명령에 `sudo` 도 `ssh` 도 안 붙는다 | 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 에서 `kubectl` 이 `No such file` | `mkdir -p ~/.kube` 를 빠뜨렸다 | 위 2번 |
|
||
| lab host 에서 `connection refused` | kubeconfig 의 `127.0.0.1` 을 안 바꿨다 | `grep server: ~/.kube/config` |
|
||
| `kc-lab-2` 에서 `localhost:8080` | agent 에는 kubeconfig 가 없다. **정상이다** | 위 6번 |
|
||
| 워크스테이션에서 타임아웃 | 터널이 없다 | 위 8번 |
|
||
| `x509` 오류 | k3s 재설치로 CA 가 바뀌었다 | 2번의 복사를 다시 한다 |
|
||
| 파드가 한 노드에만 몰림 | 스케줄러 판단 | `topologySpreadConstraints` 로 강제 |
|
||
|
||
**이 절차가 성립하는 범위**
|
||
|
||
- (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 subjectAltName` 과
|
||
`sudo 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_input` 의 `reject` 를 적었다. 여기서는 **세운 것 자체**를 적는다.
|
||
|
||
| 어디에 | 무엇 | 저장소 원본 |
|
||
|---|---|---|
|
||
| `[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 구멍 | 유닛의 `ExecStartPost` 가 `guest_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 이 까는 것은 `curl` 과 `nftables` 뿐이라 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]`
|
||
|
||
```bash
|
||
which nginx
|
||
```
|
||
|
||
**관측** — 2026-09-11, 새로 만든 `kc-lab-edge` 에서 실제로 나온 것(observed)
|
||
|
||
```
|
||
donghyeon@kc-lab-edge:~$ cd /etc/nginx/
|
||
-bash: cd: /etc/nginx/: No such file or directory
|
||
```
|
||
|
||
**어디를 봐야 하는가** — `which nginx` 가 **아무것도 안 찍으면** 미설치다.
|
||
`/etc/nginx` 디렉터리는 패키지가 만든다. 그것이 없다는 것은 **경로를 잘못 찾은
|
||
것이 아니라 설치가 안 된 것**이다.
|
||
|
||
**실행 절차**
|
||
|
||
번호는 가이드 03 의 것을 그대로 쓴다.
|
||
|
||
**0. nginx 를 깔고 기본 사이트를 끈다** — `[kc-lab-edge]`
|
||
|
||
**목적** — 80 을 듣는 것이 우리 설정 하나가 되게 만든다.
|
||
|
||
```bash
|
||
sudo apt update && sudo apt install -y nginx
|
||
```
|
||
|
||
**확인** — 떴는가, 그리고 Debian 관례의 두 디렉터리가 있는가
|
||
|
||
```bash
|
||
systemctl status nginx --no-pager | head -5
|
||
ls /etc/nginx/
|
||
```
|
||
|
||
**어디를 봐야 하는가** — `Active:` 줄이 `active (running)` 인가, 그리고 `ls`
|
||
결과에 **`sites-available` 과 `sites-enabled` 가 둘 다** 있는가. Debian 계열은
|
||
설치와 동시에 기동까지 한다 — 따로 `systemctl start` 를 칠 일이 없다.
|
||
|
||
**이 결과가 의미하는 것** — 이 시점에 nginx 는 **이미 80 포트를 잡고 있다.**
|
||
그것을 잡고 있는 것이 `sites-enabled/default` 이고, 1번에서 쓸 설정도
|
||
`listen 80 default_server` 라 **그대로 두면 겹친다.**
|
||
|
||
기본 사이트를 끈다.
|
||
|
||
```bash
|
||
ls -l /etc/nginx/sites-enabled/
|
||
sudo rm /etc/nginx/sites-enabled/default
|
||
```
|
||
|
||
**어디를 봐야 하는가** — `ls -l` 의 화살표다. `default -> ../sites-available/default`
|
||
처럼 **심볼릭 링크**다. 지우는 것은 링크뿐이고 원본은 `sites-available/default`
|
||
에 그대로 있다 — 되돌리려면 `ln -s` 로 다시 걸면 된다.
|
||
|
||
**이 결과가 의미하는 것** — 안 지우면 2번의 `nginx -t` 가
|
||
`a duplicate default server for 0.0.0.0:80` 으로 막는다. 설정이 틀린 것이 아니라
|
||
**기본 사이트와 겹친 것**이다.
|
||
|
||
**★ `sites-available` 은 복수형이다.** `site-available` 로 치면 `nano` 가 군말
|
||
없이 **빈 새 파일을 연다.** 저장해도 nginx 는 그 파일을 영원히 안 읽고,
|
||
`nginx -t` 는 멀쩡히 통과한다 — **아무 에러 없이 아무 일도 안 일어나는** 가장
|
||
찾기 어려운 형태다. 설정을 썼는데 변화가 없으면 경로 오타부터 의심한다.
|
||
|
||
**확인** — 방금 쓴 파일이 제자리에 있는가
|
||
|
||
```bash
|
||
ls /etc/nginx/sites-available/
|
||
```
|
||
|
||
**어디를 봐야 하는가** — 방금 쓴 파일 이름이 **여기** 보이는가. 안 보이면 다른
|
||
데다 썼다. 어디다 썼는지는 이렇게 찾는다.
|
||
|
||
```bash
|
||
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 시점에** 읽기 때문이다.
|
||
|
||
파일을 연다.
|
||
|
||
```bash
|
||
sudo nano /etc/nginx/sites-available/keycloak-lab
|
||
```
|
||
|
||
아래 내용을 쓴다. 저장은 `Ctrl+O` → `Enter`, 나가기는 `Ctrl+X`.
|
||
|
||
```nginx
|
||
# file: /etc/nginx/sites-available/keycloak-lab
|
||
upstream k3s_traefik {
|
||
server 192.168.122.11:80;
|
||
server 192.168.122.12:80;
|
||
}
|
||
|
||
server {
|
||
listen 80 default_server;
|
||
server_name _;
|
||
|
||
location / {
|
||
proxy_pass http://k3s_traefik;
|
||
proxy_http_version 1.1;
|
||
proxy_set_header Host $host;
|
||
proxy_set_header X-Forwarded-Host $host;
|
||
proxy_set_header X-Forwarded-Proto http;
|
||
proxy_set_header X-Forwarded-Port 80;
|
||
proxy_set_header X-Forwarded-For $remote_addr;
|
||
proxy_set_header X-Real-IP $remote_addr;
|
||
proxy_read_timeout 3600s;
|
||
proxy_send_timeout 3600s;
|
||
}
|
||
}
|
||
```
|
||
|
||
**어디를 봐야 하는가** — `X-Forwarded-Proto` 가 `http` 다. **04 에서 `https` 로
|
||
바꾼다.** 이 헤더는 뒷단에 「원래 요청이 무슨 스킴이었나」를 알려 주는 값이라,
|
||
실제로 80 으로 들어오는데 `https` 라고 적으면 Keycloak 이 리다이렉트 주소를
|
||
`https://` 로 만들고 **로그인 도중에 끊긴다.** 거짓말하면 안 되는 헤더다.
|
||
|
||
**upstream 이 둘인 까닭.** 두 노드 모두 Traefik 이 뜨므로 어느 쪽으로 보내도
|
||
된다. nginx 는 기본 라운드로빈으로 번갈아 보내고, **한쪽이 죽으면 자동으로
|
||
뺀다.**
|
||
|
||
활성화한다.
|
||
|
||
```bash
|
||
sudo ln -sf /etc/nginx/sites-available/keycloak-lab /etc/nginx/sites-enabled/
|
||
```
|
||
|
||
**확인** — 링크가 생겼고 `default` 가 없는가
|
||
|
||
```bash
|
||
ls -l /etc/nginx/sites-enabled/
|
||
```
|
||
|
||
**어디를 봐야 하는가** — `keycloak-lab ->` 링크가 생겼는가, 그리고 **`default`
|
||
가 없는가**(0번에서 지웠다). 둘 다 `listen 80 default_server` 라 같이 있으면
|
||
2번의 `nginx -t` 가 `duplicate default server` 로 막는다.
|
||
|
||
**2. 문법을 보고 적용한다** — `[kc-lab-edge]`
|
||
|
||
**목적** — 설정이 깨진 채로 reload 하지 않는다.
|
||
|
||
**확인**
|
||
|
||
```bash
|
||
sudo nginx -t && sudo systemctl reload nginx
|
||
```
|
||
|
||
통과하면 이런 형태다. Debian 12 는 `[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
|
||
```
|
||
|
||
**어디를 봐야 하는가** — `nginx -t` 가 내놓는 **마지막 줄**이다. `syntax is ok`
|
||
와 `test is successful` 두 마디가 다 나와야 통과다. 앞에 나오는 `[warn]` 줄(예:
|
||
`types_hash_max_size`)은 통과를 막지 않는다 — **04 에서 이 경고를 실패로
|
||
오독하는 일이 실제로 벌어지므로, 여기서 경고와 오류를 구분하는 눈을 들여 둔다.**
|
||
실패면 `[emerg]` 줄에 파일과 줄 번호가 찍힌다.
|
||
|
||
**이 결과가 의미하는 것** — 통과했으면 `&&` 뒤의 reload 가 이어서 돌고,
|
||
`systemctl reload` 는 아무 말 없이 끝난다(무소식이 좋은 소식이다). 실패했으면
|
||
`&&` 가 reload 를 **막아 준 것**이고, 지금 돌고 있는 nginx 는 옛 설정 그대로
|
||
멀쩡하다.
|
||
|
||
**확인** — reload 가 정말 반영됐는가
|
||
|
||
```bash
|
||
systemctl status nginx --no-pager | head -20
|
||
```
|
||
|
||
**어디를 봐야 하는가** — `Active:` 줄이 `active (running)` 인가, 그리고 그 아래
|
||
프로세스 트리에 `nginx: master process` 하나와 `nginx: worker process` 여럿이
|
||
붙어 있는가. 워커 줄의 **PID** 를 눈에 담아 둔다.
|
||
|
||
**이 결과가 의미하는 것** — reload 는 마스터를 그대로 두고 **워커만** 갈아
|
||
끼운다. 그래서 reload 전후로 워커 PID 가 바뀌면 새 설정이 실제로 적용된 것이고,
|
||
안 바뀌었으면 `-t` 는 통과했는데 reload 가 안 갔다. **04 에서 인증서 갱신이
|
||
서빙까지 닿았는지를 정확히 이 방법으로 판정한다.**
|
||
|
||
**3. 호스트에서 엣지로 넘긴다 (DNAT)** — `[lab host]`
|
||
|
||
**목적** — 여기까지 하면 엣지 nginx 는 살아 있는데 **아무도 거기로 안 보낸다.**
|
||
tailnet 주소 `100.83.212.4:443` 을 받는 것은 여전히 물리 호스트다. 그 트래픽을
|
||
엣지로 넘기는 일이 이 단계고, **물리 호스트가 실험대를 위해 하는 일의 전부**다 —
|
||
DNAT 규칙 하나와 DHCP 예약 세 줄이고 둘 다 한 번 쓰고 다시 안 건드린다.
|
||
|
||
규칙 파일을 쓴다. 디렉터리가 없으면 `nano` 가 저장할 곳을 못 찾으므로 먼저
|
||
만든다.
|
||
|
||
```bash
|
||
sudo mkdir -p /etc/nftables.d
|
||
sudo nano /etc/nftables.d/lab-edge-dnat.nft
|
||
```
|
||
|
||
```
|
||
# file: /etc/nftables.d/lab-edge-dnat.nft
|
||
#!/usr/sbin/nft -f
|
||
table ip lab_edge
|
||
delete table ip lab_edge
|
||
|
||
table ip lab_edge {
|
||
chain prerouting {
|
||
type nat hook prerouting priority dstnat; policy accept;
|
||
iifname "tailscale0" tcp dport { 80, 443 } dnat to 192.168.122.10
|
||
}
|
||
|
||
}
|
||
```
|
||
|
||
**어디를 봐야 하는가** — 맨 위의 `table ip lab_edge` 와 `delete table ip lab_edge`
|
||
두 줄, 그리고 **포트 숫자**다.
|
||
|
||
- **두 줄짜리 관용구** — 없는 테이블을 지우면 에러이므로 한 번 만들고 지운다.
|
||
이래야 같은 파일을 몇 번 적용해도 안전하다.
|
||
- **`443` 을 `433` 으로 치지 않는다.** `433` 도 유효한 포트라 nft 가 군말 없이
|
||
받는다. 80 은 멀쩡히 넘어가므로 3·4번은 다 통과하고, **04 에서 HTTPS 만 안
|
||
되는 형태로 뒤늦게 터진다.** 이 실험대에서 실제로 나왔던 오타다.
|
||
|
||
유닛 파일을 쓴다. 경로를 `/etc/systemd/` 가 아니라 `/etc/systemd/system/` 까지
|
||
친다.
|
||
|
||
```bash
|
||
sudo nano /etc/systemd/system/lab-edge-dnat.service
|
||
```
|
||
|
||
```ini
|
||
# file: /etc/systemd/system/lab-edge-dnat.service
|
||
[Unit]
|
||
Description=Lab edge DNAT (tailnet :80/:443 -> kc-lab-edge)
|
||
After=network-online.target libvirtd.service
|
||
Wants=network-online.target
|
||
|
||
[Service]
|
||
Type=oneshot
|
||
RemainAfterExit=yes
|
||
ExecStart=/usr/sbin/nft -f /etc/nftables.d/lab-edge-dnat.nft
|
||
ExecStartPost=-/usr/sbin/nft insert rule ip libvirt_network guest_input oif virbr0 ip daddr 192.168.122.10 tcp dport {80,443} ct state new counter accept
|
||
ExecStop=/usr/sbin/nft delete table ip lab_edge
|
||
|
||
[Install]
|
||
WantedBy=multi-user.target
|
||
```
|
||
|
||
**★ 경로를 끝까지 친다 — `/etc/systemd/` 가 아니라 `/etc/systemd/system/`.**
|
||
`nano` 는 없는 파일이면 **말없이 새로 만든다.** 그래서 한 단계 위에 만들어도 아무
|
||
경고가 없고, systemd 는 거기를 유닛 디렉터리로 읽지 않아
|
||
`Unit lab-edge-dnat.service does not exist` 만 반복된다. 이 실험대에서 실제로
|
||
겪은 형태다 — `/etc/systemd/` 는 `journald.conf` 같은 **systemd 자체 설정**이
|
||
사는 곳이다.
|
||
|
||
**확인** — 두 파일이 제자리에 있는가
|
||
|
||
```bash
|
||
ls -l /etc/nftables.d/lab-edge-dnat.nft /etc/systemd/system/lab-edge-dnat.service
|
||
```
|
||
|
||
**어디를 봐야 하는가** — **두 줄이 다 나와야 한다.** 한 줄이라도 `No such file`
|
||
이면 다음 명령은 무조건 실패한다. systemctl 이 이상한 것이 아니라 **파일이 없는
|
||
것**이다.
|
||
|
||
적용한다.
|
||
|
||
```bash
|
||
sudo systemctl daemon-reload
|
||
sudo systemctl enable --now lab-edge-dnat.service
|
||
```
|
||
|
||
**★ `daemon-reload` 를 빠뜨리지 않는다.** 유닛 파일을 새로 써도 systemd 는 다시
|
||
읽기 전까지 모른다. 파일은 제자리에 있는데 `does not exist` 가 나오면 여기를
|
||
의심한다.
|
||
|
||
규칙의 알맹이는 두 줄이고, **둘이 사는 곳이 다르다.**
|
||
|
||
| 하는 일 | 어디에 |
|
||
|---|---|
|
||
| `iifname "tailscale0" tcp dport { 80, 443 } dnat to 192.168.122.10` | 우리 테이블 `lab_edge` (`.nft` 파일) |
|
||
| `oif virbr0 ip daddr 192.168.122.10 … ct state new accept` | **libvirt 테이블 `libvirt_network` 의 `guest_input` 체인** (유닛의 `ExecStartPost`) |
|
||
|
||
**★ SNAT 을 걸지 않는다.** 게스트의 기본 게이트웨이가 호스트라 응답은 어차피
|
||
여기로 돌아오고, conntrack 이 알아서 되돌린다. masquerade 를 붙이면 출발지가
|
||
덮여서 **엣지가 모든 클라이언트를 `192.168.122.1` 로 본다** — 이 실험대가 재고
|
||
있는 `X-Forwarded-For` 계약이 통째로 무의미해진다.
|
||
|
||
**★ 두 번째 줄이 왜 libvirt 테이블 안으로 들어가나 — 여기가 이 단계에서 가장
|
||
많이 막히는 곳이다.** libvirt 는 게스트 대역으로 **새로 들어오는 연결을 거절**
|
||
한다. 자기 테이블 `libvirt_network` 의 `guest_input` 체인이 이렇게 끝난다.
|
||
|
||
```
|
||
oif "virbr0" ip daddr 192.168.122.0/24 ct state established,related accept
|
||
oif "virbr0" reject ← 여기서 죽는다
|
||
```
|
||
|
||
**우리 테이블에 아무리 먼저 `accept` 를 놔도 소용이 없다.** 까닭은 §180 에 있다.
|
||
그래서 구멍은 **libvirt 체인 맨 앞에** 뚫는다. `insert` 가 체인 맨 앞에 넣는다는
|
||
점이 핵심이다(`add` 는 맨 뒤 = reject 뒤 = 의미 없음).
|
||
|
||
손으로 한 번 넣어 볼 때는 이 줄이다.
|
||
|
||
```bash
|
||
sudo nft insert rule ip libvirt_network guest_input oif virbr0 ip daddr 192.168.122.10 tcp dport '{80,443}' ct state new counter accept
|
||
```
|
||
|
||
**★ `'{80,443}'` 의 따옴표를 빼지 않는다.** bash·zsh 가 중괄호를 **`80 443` 두
|
||
낱말로 펼쳐** 버려서 `Error: syntax error, unexpected ct` 가 난다. 규칙은 안
|
||
들어갔는데 에러만 보고 넘기기 쉽다.
|
||
|
||
**확인** — 새 규칙이 `reject` **위**에 있는가
|
||
|
||
```bash
|
||
sudo nft -a list chain ip libvirt_network guest_input
|
||
```
|
||
|
||
**이 규칙은 휘발성이다.** libvirt 가 네트워크를 다시 세우면(호스트 재부팅,
|
||
`virsh net-start`, libvirtd 재시작) `guest_input` 을 새로 쓰면서 날아간다. 그래서
|
||
유닛의 `ExecStartPost` 에 넣어 두고, 날아갔으면 다시 넣는다.
|
||
|
||
```bash
|
||
sudo systemctl restart lab-edge-dnat.service
|
||
```
|
||
|
||
**확인** — 우리 테이블이 들어갔는가
|
||
|
||
```bash
|
||
sudo nft list table ip lab_edge
|
||
```
|
||
|
||
**어디를 봐야 하는가** — `dnat to 192.168.122.10` 한 줄이 있는가, **포트가
|
||
`80, 443` 인가**, 그리고 **`masquerade` 나 `snat` 이 없는가.**
|
||
|
||
**이 결과가 의미하는 것** — 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 를 건너뛴다)
|
||
|
||
```bash
|
||
curl -I http://192.168.122.11
|
||
```
|
||
|
||
**형태** (이 실험대에서 캡처해 두지 않았다 — 봐야 할 줄만)
|
||
|
||
```
|
||
HTTP/1.1 404 Not Found
|
||
...
|
||
```
|
||
|
||
**어디를 봐야 하는가** — 첫 줄의 상태 줄 하나. 그리고 그 앞에 **아무 오류도 없이**
|
||
헤더가 나왔다는 사실. `curl: (7) Failed to connect` 이면 응답 자체가 없는 것이라
|
||
상태 코드를 볼 일도 없다.
|
||
|
||
**이 결과가 의미하는 것** — **`404` 가 성공 신호다.** 게스트의 80 을 누가 듣고
|
||
있고(Traefik), 그가 요청을 받아 「매칭되는 Ingress 규칙이 없다」고 답했다.
|
||
이 층은 통과. `502` 면 Traefik 은 떴는데 그 뒤 백엔드가 없는 것이고, 연결 거부·
|
||
타임아웃이면 Traefik 이 안 떴거나 게스트가 죽은 것이라 **02 로 돌아간다.**
|
||
|
||
두 노드가 같은지 볼 때는 값만 뽑는 형태가 낫다. 두 줄을 나란히 놓고 눈으로
|
||
비교하는 것이 목적이기 때문이다.
|
||
|
||
```bash
|
||
curl -s -o /dev/null -w '%{http_code}\n' http://192.168.122.11
|
||
curl -s -o /dev/null -w '%{http_code}\n' http://192.168.122.12
|
||
```
|
||
|
||
**실측** — `.11` 에서 잰 값이다(observed).
|
||
|
||
```
|
||
404
|
||
```
|
||
|
||
**어디를 봐야 하는가** — 두 줄이 **같은 코드**인가.
|
||
|
||
**이 결과가 의미하는 것** — 둘이 같으면 nginx 가 어느 쪽으로 보내도 같은 결과가
|
||
나온다. 한쪽만 다르면 upstream 두 개 중 하나가 죽은 것이고, 그 상태에서는
|
||
**요청의 절반만 실패**해서 「가끔 안 된다」로 보인다.
|
||
|
||
**확인 ② 엣지 nginx 가 직접 응답하나** (DNAT 을 건너뛴다)
|
||
|
||
```bash
|
||
curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' http://192.168.122.10
|
||
```
|
||
|
||
**어디를 봐야 하는가** — `301` 과 `Location` 이 나오는가. 여기서 막히면 문제는
|
||
**엣지 안**이다(설정·기동). 통과하는데 아래 ③ 이 안 되면 문제는 **DNAT** 이다.
|
||
이 한 층을 끼워 두면 그 둘을 헷갈리지 않는다.
|
||
|
||
**확인 ③ 밖에서, 즉 DNAT 을 거쳐 닿나**
|
||
|
||
```bash
|
||
curl -I http://auth.hyeonworks.com
|
||
```
|
||
|
||
**형태** (봐야 할 두 줄만)
|
||
|
||
```
|
||
HTTP/1.1 301 Moved Permanently
|
||
Location: https://auth.hyeonworks.com/
|
||
...
|
||
```
|
||
|
||
**어디를 봐야 하는가** — 상태 줄과 **`Location:` 헤더 한 줄**. `Location` 이
|
||
`https://` 로 시작하고 원래 호스트명을 그대로 들고 있는가. `$host` 대신 설정에
|
||
이름을 박아 두면 여기서 엉뚱한 호스트가 나온다.
|
||
|
||
**이 결과가 의미하는 것** — 301 이 나왔다는 것은 **바깥 요청이 DNAT 을 거쳐 엣지
|
||
nginx 까지 닿았다**는 뜻이다(①은 Traefik 에 직접, ②는 엣지에 직접 친 것이라
|
||
DNAT 을 안 거쳤다). DNS·DNAT·80 리스너가 전부 살아 있다. 응답이 아예 없으면
|
||
nginx 가 안 떴거나 80 이 막혔다. 리다이렉트를 따라가 끝까지 보려면 `-L` 을
|
||
붙인다.
|
||
|
||
값을 반복해서 잴 때의 형태는 이것이고, 아래가 이 실험대의 실측이다(observed).
|
||
|
||
```bash
|
||
curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' http://auth.hyeonworks.com
|
||
```
|
||
|
||
```
|
||
301 https://auth.hyeonworks.com/
|
||
```
|
||
|
||
**확인 ④ 끝까지 닿나** (TLS 이후)
|
||
|
||
```bash
|
||
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
|
||
```
|
||
|
||
```
|
||
200
|
||
```
|
||
|
||
**어디를 봐야 하는가** — 코드 한 칸. 여기서만 값만 뽑는 형태를 바로 쓰는 까닭은,
|
||
이 200 이 **04·05 에서 매번 같은 명령으로 다시 잴 기준값**이기 때문이다. 처음 한
|
||
번은 헤더까지 보고, 그다음부터 이 형태로 줄인다.
|
||
|
||
```bash
|
||
curl -I https://auth.hyeonworks.com/realms/master
|
||
```
|
||
|
||
**이 결과가 의미하는 것** — `200` 이면 nginx → Traefik → 파드까지 2홉이 다
|
||
이어졌다. `502` 는 nginx 는 살아 있는데 upstream 을 못 잡은 것(아래 로그를 본다),
|
||
`curl: (60)` 같은 인증서 오류는 아직 04 를 안 했다는 뜻이다. TLS 단계에서 막히면
|
||
코드만 보지 말고 `curl -v` 로 협상 과정을 읽는다 — 04 에서 그렇게 한다.
|
||
|
||
**로그를 볼 때** — 실무자가 치는 형태다.
|
||
|
||
```bash
|
||
journalctl -u nginx -p err -n 5 # 최근 에러만
|
||
journalctl -u nginx -f # 지금 벌어지는 것
|
||
```
|
||
|
||
**어디를 봐야 하는가** — 각 줄의 **괄호 안 errno**(`113`·`111`)와 그 뒤의
|
||
`upstream: "http://192.168.122.1x:80/..."` 부분. 어느 upstream 이 문제인지가
|
||
거기 적혀 있다. 그리고 **타임스탬프** — 방금 친 요청 시각과 안 맞으면 지금 보고
|
||
있는 것은 옛 사고다.
|
||
|
||
**이 결과가 의미하는 것** — `-p err` 로 걸러도 아무것도 안 나오면 nginx 는
|
||
정상이고 문제는 더 위(Traefik·파드)에 있다. 두 번째 명령은 **띄워 놓은 채로 다른
|
||
창에서 요청을 치는** 용도다 — 요청과 로그 줄을 눈으로 짝지으면 「이 요청이 어느
|
||
upstream 으로 갔나」가 바로 보인다. 끝내려면 Ctrl+C.
|
||
|
||
**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 로그를 본다** — 거기엔 제한이 없다.
|
||
|
||
```bash
|
||
grep oauth2/callback /var/log/nginx/access.log | tail -1
|
||
```
|
||
|
||
**어디를 봐야 하는가** — 그 한 줄의 **끝**이다. 요청 URL 이 `"` 로 제대로 닫혀
|
||
있으면 온전한 줄이고, 단어 중간에서 멈춰 있으면 잘렸다. 길이가 궁금하면
|
||
세어 본다.
|
||
|
||
```bash
|
||
grep oauth2/callback /var/log/nginx/access.log | tail -1 | wc -c
|
||
```
|
||
|
||
**이 결과가 의미하는 것** — error 로그와 access 로그의 같은 요청 길이가 다르면
|
||
**error 쪽이 잘린 것**이지 요청이 잘린 것이 아니다. 이 실험대에서 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 은 안 깐다 | `which nginx` |
|
||
| `nginx -t` 가 `duplicate default server` | `sites-enabled/default` 가 살아 있다 | `ls -l /etc/nginx/sites-enabled/` |
|
||
| 설정을 썼는데 아무 변화가 없다 | 경로 오타 — `site-available`(단수)에 썼다 | `ls /etc/nginx/sites-available/` |
|
||
| `unknown directive "http2"` | nginx < 1.25.1. Debian 12 는 1.22 다 | `nginx -v` → `listen 443 ssl http2` 형태로 |
|
||
| `systemctl reload` 가 실패한다 | 설정이 `nginx -t` 를 못 통과했다 | **reload 말고 `sudo nginx -t` 를 먼저** 친다 |
|
||
| `cp: cannot stat 'deploy/...'` | 엣지에서 쳤거나, lab host 에 리포가 없다 | `ls ~/workspace` → 없으면 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_input` 의 `reject`. 3번의 두 번째 줄이 빠졌거나 날아갔다 | `sudo nft -a list chain ip libvirt_network guest_input` — `reject` 줄의 카운터가 올라가면 여기다. §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)
|
||
|
||
## 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.ini` — `600`, 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.timer` → `certbot-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번에서
|
||
쓸 설정 문법을 가른다.
|
||
|
||
**확인 ① 이 도메인이 무엇으로 풀리는가**
|
||
|
||
```bash
|
||
dig +short auth.hyeonworks.com
|
||
```
|
||
|
||
**실측**(observed)
|
||
|
||
```
|
||
100.83.212.4
|
||
```
|
||
|
||
**어디를 봐야 하는가** — 주소 한 줄. 그 주소가 공개 인터넷 대역인가,
|
||
`100.64.0.0/10` 안인가.
|
||
|
||
**이 결과가 의미하는 것** — `100.64.0.0/10` 은 CGNAT 용으로 예약된 대역이라
|
||
**공개 인터넷에서 라우팅 자체가 되지 않는다.** 방화벽을 여는 문제가 아니라 그
|
||
주소가 인터넷에 존재하지 않고, Let's Encrypt 를 tailnet 에 초대할 방법도 없다.
|
||
**그래서 HTTP-01 은 쓸 수 없고 DNS-01 을 쓴다.** 여기서 공개 주소가 나오는
|
||
환경이라면 위 표의 왼쪽을 고르는 편이 낫다.
|
||
|
||
**확인 ② nginx 판 번호는 몇인가**
|
||
|
||
```bash
|
||
nginx -v
|
||
```
|
||
|
||
**실측**(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.example` 의 `packages` 에 들어 있다.
|
||
|
||
```bash
|
||
sudo apt install -y certbot python3-certbot-dns-cloudflare
|
||
```
|
||
|
||
**확인** — 쓸 수 있는 검증 방식이 무엇인가
|
||
|
||
```bash
|
||
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-tokens` → **Create Token**
|
||
2. **`Edit zone DNS`** 템플릿 → **Use template**
|
||
3. **Permissions** — `Zone` · `DNS` · `Edit` (템플릿이 채워 준 그대로)
|
||
4. **Zone Resources** — `Include` · `Specific zone` · **`hyeonworks.com`**
|
||
5. **Continue to summary** → **Create Token**
|
||
|
||
**★ 토큰 값은 이 화면에서 한 번만 보인다.** 창을 닫으면 복구가 없다.
|
||
|
||
**★ `All zones` 로 두지 않는다.** 계정의 모든 도메인에 대한 DNS 수정 권한이 엣지
|
||
VM 안 평문 파일에 놓인다. Global API Key 는 더 나쁘다 — 계정 전체 만능 키라
|
||
폐기하면 그 키를 쓰던 다른 것들이 전부 같이 죽는다.
|
||
|
||
**값은 이 문서에도 터미널에도 적지 않는다.** 아래 확인은 전부 길이와 존재 여부만
|
||
본다.
|
||
|
||
**2-2. 토큰 파일을 만든다** — `[kc-lab-edge]`
|
||
|
||
**목적** — certbot 이 읽을 자격증명을 root 만 읽을 수 있는 파일에 둔다.
|
||
|
||
```bash
|
||
sudo install -m 600 /dev/null /etc/letsencrypt/cloudflare.ini
|
||
sudo nano /etc/letsencrypt/cloudflare.ini
|
||
```
|
||
|
||
```ini
|
||
# file: /etc/letsencrypt/cloudflare.ini
|
||
dns_cloudflare_api_token = 발급받은_토큰_값
|
||
```
|
||
|
||
**`install -m 600` 을 먼저 치는 이유** — 파일을 **비어 있을 때** 미리 600 으로
|
||
만든다. 토큰을 쓰고 나서 권한을 고치면 그사이가 열려 있다.
|
||
|
||
**확인** — 권한과 크기
|
||
|
||
```bash
|
||
ls -l /etc/letsencrypt/cloudflare.ini
|
||
sudo wc -c /etc/letsencrypt/cloudflare.ini
|
||
```
|
||
|
||
**어디를 봐야 하는가** — `-rw-------` 이고 바이트 수가 0 이 아닌가. `sudo` 없이
|
||
`wc` 를 치면 `Permission denied` 가 나는데 **그게 정상이다**(600 = root 만 읽기).
|
||
토큰 값 자체는 출력하지 않는다.
|
||
|
||
**이 결과가 의미하는 것** — 권한이 `-rw-r--r--` 면 같은 게스트의 다른 사용자도
|
||
토큰을 읽는다. 바이트 수가 0 이면 `nano` 에서 저장을 안 했거나 다른 경로에 썼다.
|
||
|
||
**2-3. 토큰이 살아 있고 권한이 맞는지 본다** — `[kc-lab-edge]`
|
||
|
||
```bash
|
||
CF=$(sudo awk -F'= *' '/api_token/{print $2}' /etc/letsencrypt/cloudflare.ini)
|
||
curl -s https://api.cloudflare.com/client/v4/user/tokens/verify -H "Authorization: Bearer $CF"
|
||
```
|
||
|
||
**어디를 봐야 하는가** — `"status":"active"` 와 `"success":true`. `"code":6003`
|
||
이면 토큰 값이 틀렸거나 잘렸고, `"code":9109` 면 권한 범위가 모자라다(`Zone` ·
|
||
`Zone` · `Read` 를 한 줄 더한다).
|
||
|
||
**이 결과가 의미하는 것** — 여기서 걸러 두면 뒤에서 실패했을 때 「DNS 문제인지 토큰
|
||
문제인지」를 헷갈리지 않는다. 값은 셸 변수에만 담기고 화면에 찍히지 않는다.
|
||
|
||
**2-4. 시험 발급 — `--dry-run`**
|
||
|
||
**목적** — 발급 한도를 쓰지 않고 경로 전체를 한 번 돌려 본다.
|
||
|
||
```bash
|
||
sudo certbot certonly --dns-cloudflare \
|
||
--dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
|
||
-d hyeonworks.com -d '*.hyeonworks.com' --dry-run
|
||
```
|
||
|
||
**어디를 봐야 하는가** — 마지막 줄 `The dry run was successful.`
|
||
|
||
**★ dry-run 은 인증서를 저장하지 않는다.** 스테이징 서버에 대고 시험만 하는 것이라
|
||
`/etc/letsencrypt/live/` 에는 아무것도 안 생긴다. **여기서 `certbot certificates`
|
||
를 치면 `No certificates found` 가 나오는 것이 정상**이다. 이걸 먼저 돌리는 이유는
|
||
Let's Encrypt 의 **주당 중복 인증서 5장** 한도를 dry-run 이 쓰지 않기 때문이다.
|
||
|
||
**★ 최초 실행이면 계정 등록 대화가 먼저 뜬다.**
|
||
|
||
| 질문 | 답 |
|
||
|---|---|
|
||
| `Enter email address` | **본인 이메일** (빈 값이면 `Invalid email address: .` 로 되묻는다) |
|
||
| Terms of Service | **Y** |
|
||
| EFF 뉴스레터 | **N** — 발급과 무관하다 |
|
||
|
||
최초 1회뿐이다. 프롬프트 없이 돌리려면 `-m <이메일> --agree-tos --no-eff-email`
|
||
을 붙인다. `--register-unsafely-without-email` 은 쓰지 않는다 — 갱신 실패를
|
||
알려줄 통로가 사라지는데, 5번이 재는 것이 바로 그 갱신이다.
|
||
|
||
**2-5. 실제 발급 — `--dry-run` 을 뺀 같은 명령**
|
||
|
||
```bash
|
||
sudo certbot certonly --dns-cloudflare \
|
||
--dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
|
||
-d hyeonworks.com -d '*.hyeonworks.com'
|
||
```
|
||
|
||
**어디를 봐야 하는가** — `Successfully received certificate.` 와 그 아래 저장
|
||
경로. **DNS-01 은 느리다** — TXT 가 퍼질 때까지 기다리느라 수십 초 걸린다. 중간에
|
||
끊지 않는다.
|
||
|
||
**2-6. 무엇을 받았는지 확인한다**
|
||
|
||
```bash
|
||
sudo certbot certificates
|
||
```
|
||
|
||
**어디를 봐야 하는가** — 네 줄이다.
|
||
|
||
| 줄 | 값 |
|
||
|---|---|
|
||
| `Domains:` | `hyeonworks.com *.hyeonworks.com` — **한 줄에** 둘 다 |
|
||
| `Expiry Date:` | 오늘 + 90일, `VALID` |
|
||
| `Certificate Path:` | `/etc/letsencrypt/live/hyeonworks.com/fullchain.pem` |
|
||
| `Private Key Path:` | `/etc/letsencrypt/live/hyeonworks.com/privkey.pem` |
|
||
|
||
**★ 디렉터리 이름과 인증서가 덮는 이름은 별개다**(observed). 여기서 가장 많이
|
||
헷갈린다.
|
||
|
||
| | 무엇 | 정해지는 방식 |
|
||
|---|---|---|
|
||
| 디렉터리 이름 `live/hyeonworks.com/` | certbot 이 이 묶음(lineage)을 관리하려고 붙인 **라벨** | **첫 번째 `-d`** 에서 따온다. 서빙과 무관 |
|
||
| SAN 목록 | 브라우저가 보는 **실제 유효 호스트명** | `-d` 로 준 이름 전부 |
|
||
|
||
**확인** — 인증서가 실제로 어떤 이름에 유효한가
|
||
|
||
```bash
|
||
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 에서 쓴 그 파일이다.
|
||
|
||
```bash
|
||
sudo nano /etc/nginx/sites-available/keycloak-lab
|
||
```
|
||
|
||
03 에서 쓴 내용을 이것으로 바꾼다. 저장은 `Ctrl+O` → `Enter`, 나가기는 `Ctrl+X`.
|
||
|
||
```nginx
|
||
# file: /etc/nginx/sites-available/keycloak-lab
|
||
upstream k3s_traefik {
|
||
server 192.168.122.11:80;
|
||
server 192.168.122.12:80;
|
||
}
|
||
|
||
server {
|
||
listen 80 default_server;
|
||
server_name _;
|
||
return 301 https://$host$request_uri;
|
||
}
|
||
|
||
server {
|
||
listen 443 ssl http2 default_server;
|
||
server_name _;
|
||
|
||
ssl_certificate /etc/letsencrypt/live/hyeonworks.com/fullchain.pem;
|
||
ssl_certificate_key /etc/letsencrypt/live/hyeonworks.com/privkey.pem;
|
||
ssl_protocols TLSv1.2 TLSv1.3;
|
||
|
||
location / {
|
||
proxy_pass http://k3s_traefik;
|
||
proxy_http_version 1.1;
|
||
proxy_set_header Host $host;
|
||
proxy_set_header X-Forwarded-Host $host;
|
||
proxy_set_header X-Forwarded-Proto https;
|
||
proxy_set_header X-Forwarded-Port 443;
|
||
proxy_set_header X-Forwarded-For $remote_addr;
|
||
proxy_set_header X-Real-IP $remote_addr;
|
||
proxy_read_timeout 3600s;
|
||
proxy_send_timeout 3600s;
|
||
}
|
||
}
|
||
```
|
||
|
||
**어디를 봐야 하는가** — 03 에서 바뀐 곳이 셋이다.
|
||
|
||
| 줄 | 03 에서는 | 지금 |
|
||
|---|---|---|
|
||
| 80 블록 | `location / { proxy_pass … }` | `return 301 https://…` 리다이렉트만 |
|
||
| 443 블록 | 없었다 | 인증서와 함께 새로 |
|
||
| `X-Forwarded-Proto` | `http` | **`https`** |
|
||
|
||
**`cert.pem` 이 아니라 `fullchain.pem`.** 서버 인증서만 보내면 중간 인증서가 빠져
|
||
체인이 끊긴다. 브라우저는 대개 캐시나 AIA 로 보완해서 **정상으로 보이고**, 캐시가
|
||
없는 클라이언트에서만 깨진다. 그래서 발견이 늦다. 2번에서 certbot 이 찍어 준 경로와
|
||
**한 글자도 다르면 안 된다.**
|
||
|
||
**확인** — 문법을 보고 적용한다
|
||
|
||
```bash
|
||
sudo nginx -t && sudo systemctl reload nginx
|
||
```
|
||
|
||
**어디를 봐야 하는가** — `syntax is ok` 와 `test is successful` 두 마디가 다 나와야
|
||
통과다. `types_hash_max_size` 같은 `[warn]` 줄은 통과를 막지 않는다 — **경고와
|
||
오류를 구분한다.**
|
||
|
||
**이 결과가 의미하는 것** — 통과했으면 `&&` 뒤의 reload 가 이어서 돌고, 실패했으면
|
||
`&&` 가 reload 를 막아 준 것이라 지금 돌고 있는 nginx 는 옛 설정 그대로 멀쩡하다.
|
||
확인 ② 에서 본 판 번호가 1.25.1 미만인데 `http2 on;` 을 지시어로 썼다면 여기서
|
||
`unknown directive "http2"` 가 나온다.
|
||
|
||
**5. 갱신이 서빙까지 닿게 한다 — 여기가 이 단계의 알맹이다** — `[kc-lab-edge]`
|
||
|
||
**목적** — 타이머가 도는 것만으로는 부족하다. 갱신된 인증서를 **누가 읽게 만드는
|
||
일**까지 세운다.
|
||
|
||
**확인 ① 타이머가 있는가**
|
||
|
||
```bash
|
||
systemctl list-timers certbot-renew.timer
|
||
```
|
||
|
||
**어디를 봐야 하는가** — 네 칸이다. `NEXT`(다음 실행 시각)와 `LEFT`(남은 시간)가
|
||
채워져 있는가, `LAST`/`PASSED` 가 하루 안쪽인가, `UNIT` 옆의 `ACTIVATES` 가
|
||
`certbot-renew.service` 를 가리키는가. **표가 통째로 비어 나오면 타이머가 없는
|
||
것이다** — 이름이 배포판마다 다르니 이렇게 찾는다.
|
||
|
||
```bash
|
||
systemctl list-timers --all | grep -i certbot
|
||
```
|
||
|
||
**이 결과가 의미하는 것** — 여기까지가 「갱신이 돌기는 하는가」의 답이고, 대부분의
|
||
문서가 여기서 끝난다. **그런데 이것이 `active` 여도 갱신된 인증서가 서빙되지는
|
||
않는다.** nginx 는 인증서를 기동 시점에 읽어 메모리에 들고 있고 certbot 은 `live/`
|
||
심볼릭 링크만 갈아 끼운다. **경로는 그대로이고 내용만 바뀌므로 nginx 는 모른다.**
|
||
|
||
**확인 ② 배포판 기본 유닛이 reload 를 부르는가**
|
||
|
||
```bash
|
||
systemctl cat certbot-renew.service
|
||
```
|
||
|
||
```
|
||
[Service]
|
||
Type=oneshot
|
||
ExecStart=/usr/bin/certbot -q renew
|
||
PrivateTmp=true
|
||
```
|
||
|
||
**어디를 봐야 하는가** — `ExecStart=` 한 줄과, 그 아래에 `ExecStartPost=` 가
|
||
**있는지 없는지**. 그리고 `ExecStart` 의 인자에 `--deploy-hook` 이 붙어 있는지.
|
||
여기 없는 것을 보는 것이 이 명령의 목적이다.
|
||
|
||
**이 결과가 의미하는 것** — `ExecStartPost` 도 `--deploy-hook` 도 없다. 이 유닛은
|
||
**인증서를 새로 받는 데까지만** 책임지고, 받은 것을 누가 읽게 만드는 일은 아무도
|
||
하지 않는다. 배포판이 이렇게 준다는 것이 요점이다 — 「기본값이니 괜찮겠지」가 바로
|
||
이 결함의 서식지다. 여기 뭔가 적혀 있는 배포판이라면 아래 훅은 필요 없고, 대신 그
|
||
명령이 nginx 를 reload 하는지만 확인한다.
|
||
|
||
**훅 하나를 넣는다.**
|
||
|
||
```bash
|
||
sudo nano /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
|
||
```
|
||
|
||
```sh
|
||
# file: /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
|
||
#!/bin/sh
|
||
nginx -t && nginx -s reload
|
||
```
|
||
|
||
**실행 권한을 준다.** 없으면 certbot 이 **조용히 건너뛴다.**
|
||
|
||
```bash
|
||
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
|
||
```
|
||
|
||
**확인 ③ 훅이 실행 파일인가**
|
||
|
||
```bash
|
||
ls -l /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
|
||
```
|
||
|
||
**어디를 봐야 하는가** — 권한 문자열에 `x` 가 세 번(`-rwxr-xr-x`) 보이는가.
|
||
`-rw-r--r--` 면 아직 실행 파일이 아니다.
|
||
|
||
**이 결과가 의미하는 것** — `deploy/` 에 넣는 까닭은 `post/` 가 갱신이 없어도 매번
|
||
돌아 하루 두 번 워커를 갈아치우기 때문이다. `deploy/` 는 **실제로 갱신됐을 때만**
|
||
실행된다. 이 훅은 한동안 저장소에 없었고 호스트에만 있어서, 호스트를 초기화하면
|
||
**아무 오류 없이 사라지고** 아래 표의 왼쪽 칸으로 되돌아갔다.
|
||
|
||
**확인 ④ 훅이 호출되기는 하는가** — 상태를 바꾸지 않는 쪽부터 친다
|
||
|
||
```bash
|
||
sudo certbot renew --dry-run
|
||
```
|
||
|
||
**어디를 봐야 하는가** — 출력 끝의 `Running deploy-hook command` 줄과
|
||
`simulated renewals` 요약. 훅 줄이 아예 안 나오면 파일이 `deploy/` 가 아닌 곳에
|
||
있거나 실행 권한이 없다(확인 ③ 의 `ls -l` 로 `x` 를 본다).
|
||
|
||
**이 결과가 의미하는 것** — dry-run 은 훅이 **호출되는지**까지만 말해 준다. 호출된
|
||
훅이 nginx 를 정말 갈아 끼웠는지는 dry-run 으로 알 수 없다.
|
||
|
||
**확인 ⑤ 훅이 nginx 를 정말 갈아 끼웠는가** — 이 확인은 **상태를 바꾼다**
|
||
|
||
`--force-renewal` 은 인증서를 실제로 새로 받으므로 발급 한도(주당 중복 5장)를
|
||
깎는다. 진짜 판정이 필요할 때만 한 번 쓴다.
|
||
|
||
```bash
|
||
# 강제 갱신 전에 워커 PID 를 적어 둔다
|
||
ps -eo pid,lstart,args | grep 'nginx: worker' | grep -v grep
|
||
|
||
sudo certbot renew --force-renewal
|
||
|
||
# 워커 PID 가 바뀌었으면 reload 된 것이다
|
||
ps -eo pid,lstart,args | grep 'nginx: worker' | grep -v grep
|
||
```
|
||
|
||
**어디를 봐야 하는가** — 두 출력의 **첫 열(PID)과 둘째 열 묶음(lstart, 프로세스
|
||
시작 시각)**. 앞뒤로 놓고 PID 집합이 통째로 바뀌었는지만 본다. `lstart` 를 같이
|
||
뽑는 이유는 PID 가 우연히 재사용됐을 때를 가르기 위해서다. 마스터 프로세스는
|
||
그대로이고 **워커만** 갈리는 것이 정상이다.
|
||
|
||
**이 결과가 의미하는 것** — PID 가 바뀌었으면 훅이 돌아 nginx 가 새 인증서를 읽었다.
|
||
안 바뀌었으면 인증서는 갱신됐는데 **서빙되는 것은 옛것**이고, 그 상태가 아래 표의
|
||
왼쪽 칸이다.
|
||
|
||
**판정은 로그 문구가 아니라 워커 PID 로 한다.** certbot 이
|
||
`Hook 'deploy-hook' ran with error output` 이라고 찍는데 **실패가 아니다** — nginx
|
||
의 `types_hash` 경고가 stderr 로 나갔을 뿐이고 내용은 `test is successful` ·
|
||
`signal process started` 다. 로그에서 `error` 를 grep 하는 감시를 걸면 성공한 훅을
|
||
실패로 오독한다.
|
||
|
||
**실측**(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 번은 전부 엣지에서 쳤지만 **확인은 밖에서 들어와야
|
||
의미가 있다.**
|
||
|
||
**확인 ① 열리나 — 처음 한 번은 협상 과정을 읽는다**
|
||
|
||
```bash
|
||
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` 은 본문만 버리므로 이 줄들은 화면에 그대로 찍힌다.
|
||
**`subject` 가 `hyeonworks.com` 인 것이 맞다** — 와일드카드 인증서라 CN 은 apex
|
||
이름이고 `auth.hyeonworks.com` 은 SAN 의 `*.hyeonworks.com` 에 걸린다.
|
||
|
||
**이 결과가 의미하는 것** — 네 줄이 다 나오면 인증서가 붙었고 체인이 클라이언트
|
||
기준으로 검증됐다. **인증서를 처음 붙인 직후에는 코드 한 칸이 아니라 이 화면을
|
||
본다.** `verify` 줄 대신 `unable to get local issuer certificate` 가 나오면 중간
|
||
인증서가 빠진 것이고, 원인은 3번의 `cert.pem`/`fullchain.pem` 이라 확인 ② 로 간다.
|
||
|
||
같은 것을 반복해서 재거나 03·05 의 값과 나란히 비교할 때만 값만 뽑는 형태로 줄인다.
|
||
|
||
```bash
|
||
curl -s -o /dev/null -w '%{http_code} tls=%{ssl_verify_result}\n' https://auth.hyeonworks.com/
|
||
```
|
||
|
||
**실측**(observed) — 2026-09-11, tailnet 클라이언트에서
|
||
|
||
```
|
||
404 tls=0
|
||
```
|
||
|
||
**어디를 봐야 하는가** — `tls=0` 이 인증서 검증 통과다. 앞의 `404` 는 05 이후에
|
||
`200` 으로 바뀐다.
|
||
|
||
**확인 ② 체인 단계와 검증**
|
||
|
||
```bash
|
||
echo | openssl s_client -connect auth.hyeonworks.com:443 \
|
||
-servername auth.hyeonworks.com 2>/dev/null \
|
||
| grep -E '^ *[0-9]+ s:|^ *i:|Verify return code'
|
||
```
|
||
|
||
**실측**(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` 은 첫 인증서를 검증 못 함.
|
||
|
||
**확인 ③ 이름 세 개가 한 인증서인가**
|
||
|
||
```bash
|
||
for H in auth app1 app2; do
|
||
echo | openssl s_client -connect $H.hyeonworks.com:443 -servername $H.hyeonworks.com 2>/dev/null \
|
||
| openssl x509 -noout -serial
|
||
done
|
||
```
|
||
|
||
**어디를 봐야 하는가** — 찍히는 세 줄의 **일련번호가 서로 같은가**. 값 자체는 아무
|
||
의미가 없고 **셋이 일치하는지만** 본다.
|
||
|
||
**이 결과가 의미하는 것** — 셋이 같으면 SAN 하나에 이름 셋이 들어 있는 인증서 한
|
||
장이고, 갱신도 한 번에 끝난다. 다르면 인증서가 여러 장이라 **갱신 훅도 장마다 따로
|
||
돌고**, 한 장만 갱신됐을 때 나머지 이름이 만료되는 상황이 생긴다. 이름별로 무엇이
|
||
실려 있는지 보려면 SAN 을 직접 편다.
|
||
|
||
```bash
|
||
echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \
|
||
| openssl x509 -noout -ext subjectAltName
|
||
```
|
||
|
||
**다섯 칸으로 다시 본다.**
|
||
|
||
| 무엇 | 명령 | 통과 |
|
||
|---|---|---|
|
||
| 열리는가 | `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 집합이 바뀐다 |
|
||
|
||
**근거를 재려면 (선택)** — 평소에는 필요 없다. 갱신 중 가용성을 문서에 남길 때만
|
||
이렇게까지 한다. **주입 전에 대조군부터** 잡는다. 평시 오류율을 모르면 갱신 중에
|
||
나온 실패 한 건을 해석할 수 없다.
|
||
|
||
```bash
|
||
# 대조군 — 0.2초 × 900회 = 180초
|
||
i=0; while [ $i -lt 900 ]; do
|
||
curl -s -o /dev/null -w '%{http_code} %{time_total}\n' --max-time 5 \
|
||
https://auth.hyeonworks.com/realms/master
|
||
i=$((i+1)); sleep 0.2
|
||
done > /tmp/control.txt
|
||
awk '{print $1}' /tmp/control.txt | sort | uniq -c
|
||
```
|
||
|
||
**여기서는 값만 뽑는 형태가 맞다.** 900번을 재서 코드별로 세는 것이 목적이고 헤더는
|
||
볼 일이 없다. 앞의 확인 ①과 형태가 다른 이유가 이것이다.
|
||
|
||
**어디를 봐야 하는가** — `uniq -c` 가 내놓는 **줄이 몇 개인가**. 한 줄이면 900번이
|
||
전부 같은 코드였다는 뜻이고, 그 줄의 왼쪽 수가 900 인지 본다. 두 줄 이상이면 그
|
||
자체가 평시 오류가 있다는 뜻이다. 응답 시간이 궁금하면 둘째 열을 따로 본다.
|
||
|
||
```bash
|
||
awk '{print $2}' /tmp/control.txt | sort -n | tail -1 # 최악값
|
||
```
|
||
|
||
**이 결과가 의미하는 것** — 이 실험대의 대조군은 **900건 전부 200, 오류 0**
|
||
이었다(observed). 그래서 갱신 중 비200 이 한 번이라도 나오면 갱신 탓으로 귀속할 수
|
||
있었다. 대조군에 이미 오류가 섞여 있다면 주입 중의 오류 한 건은 아무것도 증명하지
|
||
못하므로, **대조군이 깨끗해질 때까지는 주입을 하지 않는다.**
|
||
|
||
**그리고 두 기계의 시각을 나란히 놓기 전에 시계부터 잰다.**
|
||
|
||
```bash
|
||
A=$(date -u +%s.%N); B=$(ssh test-server 'date -u +%s.%N'); C=$(date -u +%s.%N)
|
||
# 왜곡 ≈ B − (A+C)/2 , 어느 쪽이 맞는지는 외부 기준으로 가른다
|
||
curl -sI https://www.google.com | grep -i '^date:'
|
||
timedatectl show -p NTP -p NTPSynchronized
|
||
```
|
||
|
||
**어디를 봐야 하는가** — 세 수 `A`·`B`·`C` 를 눈으로 빼서 **초 단위 차이가
|
||
몇인가**. `A` 와 `C` 는 같은 기계에서 SSH 왕복 직전·직후에 찍은 것이라, 그 가운데가
|
||
「저쪽 시각을 잰 순간의 이쪽 시각」이다. 그다음 `Date:` 헤더의 시각이 둘 중 어느
|
||
쪽에 가까운가. 마지막으로 `NTPSynchronized=yes` 인가.
|
||
|
||
**이 결과가 의미하는 것** — 차이가 수초 이내면 두 기계의 로그를 그대로 나란히 놓아도
|
||
된다. 크면 **먼저 어느 쪽이 틀렸는지 가른 다음** 보정한다. 이 실험대는 test-server
|
||
가 NTP 미동기로 **106초** 빨랐고, 보정하지 않은 첫 계산은 훅이 인증서 발급보다
|
||
**104초 먼저** 실행된 것이 되어 물리적으로 불가능했다 — **음수 지연이 나오면 계산이
|
||
아니라 시계를 의심한다.**
|
||
|
||
**막히면**
|
||
|
||
| 증상 | 원인 | 확인 |
|
||
|---|---|---|
|
||
| certbot 검증 실패 | DNS 가 이 호스트를 안 가리킴 · 80 이 막힘 | `dig +short auth.hyeonworks.com` · 밖에서 `curl -I http://auth.hyeonworks.com` |
|
||
| 체인 단계가 1개 | `cert.pem` 을 씀 | 위 확인 ② |
|
||
| 갱신은 됐는데 옛 인증서가 나감 | **deploy 훅 없음** | 워커 PID · `sudo ls /etc/letsencrypt/renewal-hooks/deploy/` |
|
||
| 훅이 실패한 것처럼 보임 | stderr 경고를 error 로 표시 | 문구 말고 **워커 PID** |
|
||
| 발급 한도 | 주당 중복 인증서 5장 | `--dry-run` 으로 먼저 시험 |
|
||
| `unrecognized arguments: --dns-cloudflare` | 플러그인 미설치 | `certbot plugins \| grep '^\*'` |
|
||
| DNS-01 이 오래 걸림 | TXT 전파 대기 | **정상이다.** 끊지 않는다 |
|
||
| 재구축 뒤 인증서가 없음 | 발급하지 말고 **백업을 되돌린다** | 한도를 아끼는 길이다 |
|
||
| `cannot load certificate` | 설정에 lineage 디렉터리 이름을 잘못 적었다 | `sudo certbot certificates` 의 `Certificate Path:` 와 대조 |
|
||
| 엣지에서 친 `curl` 이 `Connection 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: Namespace` 라 `apply` 가 같이 만든다 |
|
||
| 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-secrets` — `KC_BOOTSTRAP_ADMIN_PASSWORD` 19 bytes · `POSTGRES_PASSWORD` 22 bytes |
|
||
| PVC | StorageClass `local-path`, 이름 끝에 파드 번호가 붙는다 |
|
||
| Ingress | HOSTS 가 `auth.hyeonworks.com` |
|
||
| 클러스터링 | JGroups — 디스커버리 테이블 `jgroups_ping`, 메시지 포트 7800 |
|
||
| 관리 포트 | `9000` — `/metrics` 가 거기 있다 |
|
||
|
||
**판 번호는 여기 없다.** 가이드 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 이 아님을 확인한다.
|
||
|
||
**확인 ① 지금 이 디렉터리에서 매니페스트가 보이는가**
|
||
|
||
```bash
|
||
cd ~/workspace/keycloak-pattern
|
||
ls deploy/lab/k8s/
|
||
```
|
||
|
||
**어디를 봐야 하는가** — `keycloak-cluster.yaml` 과 `observability.yaml` 이 보이는가.
|
||
|
||
**이 결과가 의미하는 것** — 보이면 아래 `kubectl apply -f deploy/...` 가 파일을
|
||
찾는다. 안 보이면 [00] 의 0번으로 돌아간다 — 한 번 세웠다 철거한 뒤에는 VM 과
|
||
디스크만 지워지고 저장소는 각자 관리라 이 디렉터리가 없는 상태가 흔하다.
|
||
|
||
**확인 ② 이 이름이 아직 Keycloak 이 아니다**
|
||
|
||
[04] 의 확인 ① 을 그대로 다시 친다.
|
||
|
||
```bash
|
||
curl -s -o /dev/null -w '%{http_code} tls=%{ssl_verify_result}\n' https://auth.hyeonworks.com/
|
||
```
|
||
|
||
**어디를 봐야 하는가** — 04 에서 잰 `404 tls=0` 이 그대로인가. `tls=0` 이 아니면
|
||
05 를 시작할 때가 아니라 04 로 돌아갈 때다.
|
||
|
||
**이 결과가 의미하는 것** — 가이드 04 가 이 값 옆에 한 줄을 적어 두었다 —
|
||
「앞의 `404` 는 05 이후에 `200` 으로 바뀐다.」 지금 재 두면 아래 5번의 `200` 이
|
||
이 단계가 만든 변화인지가 분명해진다.
|
||
|
||
**실행 절차**
|
||
|
||
번호는 가이드 05 의 것을 그대로 쓴다. 3번(안 뜰 때)은 아래 「막히면」으로, 4·5 번은
|
||
「끝났는지 판정한다」로 옮겼다.
|
||
|
||
**1. 매니페스트를 적용한다** — `[lab host]`, 저장소 루트에서
|
||
|
||
**목적** — 네임스페이스부터 Ingress 까지 한 파일로 세운다.
|
||
|
||
```bash
|
||
cd ~/workspace/keycloak-pattern
|
||
kubectl apply -f deploy/lab/k8s/keycloak-cluster.yaml
|
||
```
|
||
|
||
**★ 네임스페이스를 따로 만들지 않는다.** 매니페스트 첫 문서가 `kind: Namespace` 라
|
||
`apply` 가 같이 만든다. `kubectl create namespace` 를 먼저 치면 두 번째 실행부터
|
||
`AlreadyExists` 로 막힌다.
|
||
|
||
**확인** — 적용이 끝날 때까지 기다린다
|
||
|
||
```bash
|
||
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=300s
|
||
```
|
||
|
||
```
|
||
partitioned roll out complete: 2 new pods have been updated...
|
||
```
|
||
|
||
**어디를 봐야 하는가** — 이 명령은 **끝날 때까지 아무것도 안 찍고 멈춰 있다.** 그
|
||
침묵이 정상이다. 마지막에 나오는 한 줄에서 `complete` 라는 낱말과 파드 개수 `2` 를
|
||
본다. 300초를 다 쓰고 타임아웃으로 끝나면 그것도 답이다 — 「안 떴다」가 확정된 것이니
|
||
아래 「막히면」으로 간다.
|
||
|
||
**이 결과가 의미하는 것** — `complete` 면 두 파드가 다 Ready 가 됐다는 뜻이라 2번의
|
||
층별 확인으로 넘어간다. `rollout status` 를 쓰는 이유는 `get pods` 를 반복해서 치는
|
||
것보다 나아서만이 아니라, **언제 끝났는지를 사람이 판정하지 않아도 되기** 때문이다.
|
||
StatefulSet 은 파드를 하나씩 순서대로 띄우므로 중간에 `0/2` 로 한참 멈춰 있는 것은
|
||
정상이다.
|
||
|
||
**2. 리소스가 제대로 만들어졌는지 — 층별로 본다**
|
||
|
||
`kubectl get pods` 만 보면 놓치는 것이 많다. **위에서 아래로** 확인한다.
|
||
|
||
**2-1. 무엇이 만들어졌나**
|
||
|
||
**확인** — 이 네임스페이스에 무엇이 서 있는가
|
||
|
||
```bash
|
||
kubectl -n keycloak-lab get all
|
||
```
|
||
|
||
**어디를 봐야 하는가** — 종류별로 묶여 나오는 왼쪽 이름 열을 훑고, 파드 줄에서는
|
||
**READY 칸의 `1/1`** 과 **RESTARTS 칸**을 본다. RESTARTS 가 0 이 아니면 지금은
|
||
`Running` 이어도 한 번 죽었다 살아난 것이라, 아래 `logs --previous` 를 볼 이유가 된다.
|
||
|
||
**이 결과가 의미하는 것** — 여기 보이는 것이 워크로드의 전부다. 그런데 `all` 은
|
||
이름과 달리 전부는 아니다 — **Secret·ConfigMap·PVC·Ingress 는 안 나온다.** 이 넷이
|
||
빠졌다는 사실을 모르고 「다 만들어졌다」고 판정하는 것이 흔한 오독이라, 한 번 더 친다.
|
||
|
||
```bash
|
||
kubectl -n keycloak-lab get secret,configmap,pvc,ingress
|
||
```
|
||
|
||
**어디를 봐야 하는가** — 네 종류가 **하나씩이라도 있는가**. PVC 줄의 STATUS 는
|
||
`Bound` 인가, Ingress 줄의 HOSTS 칸이 `auth.hyeonworks.com` 인가.
|
||
|
||
**이 결과가 의미하는 것** — 매니페스트에 있는데 여기 없는 종류가 있다면 `apply` 가
|
||
부분적으로만 먹었다. Ingress 의 호스트 이름이 04 에서 발급한 인증서의 이름과 다르면,
|
||
밖에서는 TLS 는 되는데 404 가 나온다.
|
||
|
||
**2-2. Deployment → ReplicaSet → Pod 사슬**
|
||
|
||
Deployment 는 파드를 직접 만들지 않는다. **ReplicaSet 을 만들고 그것이 파드를
|
||
만든다.** 이 사슬 어디서 끊겼는지가 진단의 출발이다. **이 단계에서 Deployment 는
|
||
`postgres` 하나뿐이다** — Keycloak 은 StatefulSet 이라 이 사슬을 타지 않는다.
|
||
|
||
**확인** — 사슬 어디까지 갔는가
|
||
|
||
```bash
|
||
kubectl -n keycloak-lab get rs,pod -l app=postgres
|
||
```
|
||
|
||
**실측**(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 를
|
||
보려면 라벨 없이 친다.
|
||
|
||
```bash
|
||
kubectl -n keycloak-lab get deploy
|
||
```
|
||
|
||
**★ StatefulSet 은 ReplicaSet 을 만들지 않는다.** 파드를 직접 만든다. 그래서
|
||
`keycloak-0`·`keycloak-1` 처럼 **이름이 고정**이고, 원본 가이드 05 는 그 성질이
|
||
A-4 에서 어떻게 나타나는지를 덧붙인다 — **`Terminating` 파드가 안 지워지면 대체
|
||
파드가 안 생긴다.** 이름이 고정이라 같은 이름의 파드가 둘일 수 없기 때문이다
|
||
(observed — 가이드가 적은 것이고 이 실험대에서 재현해 보지는 않았다). A-4 는
|
||
`source/` 에 반입되지 않은 실험 문서다(unknown).
|
||
|
||
```bash
|
||
kubectl -n keycloak-lab get sts,rs,pod -l app=keycloak
|
||
```
|
||
|
||
```
|
||
NAME READY STATUS RESTARTS AGE
|
||
pod/keycloak-0 1/1 Running 0 19m
|
||
pod/keycloak-1 1/1 Running 0 19m
|
||
```
|
||
|
||
ReplicaSet 줄이 하나도 없다. **`keycloak-0` 처럼 순번 이름이 붙는 것도 이 때문이다**
|
||
— 해시를 끼워 넣을 중간 객체가 없다.
|
||
|
||
**이 결과가 의미하는 것** — 배포를 여러 번 한 Deployment 는 **ReplicaSet 이 여러 개
|
||
쌓인다.** 새로 만들고 옛것은 `0` 으로 남기기 때문이고, 그래서 `kubectl rollout undo`
|
||
가 가능하다. 파드가 옛 해시를 달고 있으면 새 배포가 아직 안 넘어온 것이고, 그 상태로
|
||
실험하면 **고친 적 없는 코드를 재게 된다.** 사슬이 어디서 끊겼는지는 이렇게 읽는다.
|
||
|
||
| 보이는 것 | 뜻 |
|
||
|---|---|
|
||
| Deployment 는 있는데 RS 가 없다 | 컨트롤러가 못 돌았다 — RBAC·admission 확인 |
|
||
| RS 는 있는데 DESIRED 만 있고 CURRENT 가 0 | 파드를 못 만든다 — 이벤트를 본다 |
|
||
| Pod 은 있는데 `0/1` | 컨테이너가 안 뜬다 — 로그와 describe |
|
||
|
||
**2-3. Secret 이 실제로 들어갔나 — 세 층으로 본다**
|
||
|
||
값이 있는 것과 파드가 그 값을 받은 것은 다르다. **세 확인 모두 값을 찍지 않고 길이만
|
||
본다.**
|
||
|
||
**확인 ① Secret 에 무슨 키가, 얼마만큼 들어 있나**
|
||
|
||
```bash
|
||
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 를 푼 뒤의 길이다. 어떤 키 하나가
|
||
의심스러워 다시 잴 때만 이 형태를 쓴다.
|
||
|
||
```bash
|
||
kubectl -n keycloak-lab get secret keycloak-lab-secrets \
|
||
-o jsonpath='{.data.POSTGRES_PASSWORD}' | base64 -d | wc -c
|
||
```
|
||
|
||
```
|
||
22
|
||
```
|
||
|
||
**어디를 봐야 하는가** — 숫자 하나. 그리고 그것이 확인 ①의 `22 bytes` 와 같은가.
|
||
|
||
**이 결과가 의미하는 것** — 두 값이 같으면 Secret 쪽은 더 볼 것이 없다.
|
||
`base64: invalid input` 이 나오면 키 이름을 잘못 썼다(없는 키는 빈 문자열로 나온다).
|
||
여기까지는 **Secret 안에 무엇이 있나**이고, 파드가 그것을 받았는지는 아직 모른다.
|
||
|
||
**확인 ③ 파드 안에 주입됐나 — 여기가 진짜다**
|
||
|
||
```bash
|
||
kubectl -n keycloak-lab exec keycloak-0 -- \
|
||
sh -c 'echo "길이=${#KC_BOOTSTRAP_ADMIN_PASSWORD}"'
|
||
```
|
||
|
||
```
|
||
길이=19
|
||
```
|
||
|
||
**어디를 봐야 하는가** — 숫자 하나. `${#VAR}` 는 값이 아니라 **글자 수**만 내놓는다.
|
||
이것이 확인 ①의 `19 bytes` 와 같은가.
|
||
|
||
**이 결과가 의미하는 것** — 같으면 Secret → 파드 환경변수까지 이어졌다. `길이=0` 이면
|
||
Secret 에는 있는데 **이 파드가 그것을 안 받은** 것이다 — `envFrom`/`valueFrom` 을
|
||
빠뜨렸거나, 파드가 Secret 을 고치기 **전에** 떠서 옛 값을 들고 있는 경우다(환경변수로
|
||
주입한 Secret 은 값을 바꿔도 파드를 다시 만들기 전까지 갱신되지 않는다).
|
||
|
||
**어느 환경변수가 어느 Secret 에서 왔는지**도 볼 수 있다.
|
||
|
||
```bash
|
||
kubectl -n keycloak-lab get pod keycloak-0 \
|
||
-o jsonpath='{range .spec.containers[0].env[*]}{.name}{"\t"}{.valueFrom.secretKeyRef.name}{"\n"}{end}'
|
||
```
|
||
|
||
```
|
||
KC_DB
|
||
KC_DB_URL
|
||
KC_DB_USERNAME
|
||
KC_DB_PASSWORD keycloak-lab-secrets ← Secret 에서 온 것만 오른쪽에 이름이 있다
|
||
```
|
||
|
||
**어디를 봐야 하는가** — **오른쪽 칸이 채워진 줄만**. 왼쪽만 있는 줄은 매니페스트에
|
||
값이 그대로 적힌 것이고, 오른쪽에 이름이 있는 줄이 Secret 을 참조한다.
|
||
|
||
**이 결과가 의미하는 것** — 비밀이어야 할 변수의 오른쪽이 비어 있으면 **그 값은
|
||
매니페스트에 평문으로 적혀 있다는 뜻**이고, 그 파일은 대개 git 에 들어간다. 여기서는
|
||
`KC_DB_PASSWORD` 만 Secret 에서 온다.
|
||
|
||
**2-4. Service 가 파드를 잡고 있나 — Endpoints**
|
||
|
||
Service 가 있어도 **셀렉터가 안 맞으면 뒤가 비어 있다.** 이때 증상은 「연결은 되는데
|
||
응답이 없다」라 원인을 찾기 어렵다.
|
||
|
||
**확인** — 실무자가 가장 자주 쓰는 형태
|
||
|
||
```bash
|
||
kubectl -n keycloak-lab describe svc keycloak | grep -i endpoints
|
||
```
|
||
|
||
```
|
||
Endpoints: 10.42.0.67:8080,10.42.1.155:8080
|
||
```
|
||
|
||
**어디를 봐야 하는가** — 쉼표로 갈린 **주소가 몇 개인가**, 그리고 그 IP 들이
|
||
`kubectl -n keycloak-lab get pods -o wide` 의 파드 IP 와 같은가. 포트 번호가
|
||
컨테이너가 실제로 듣는 포트인가도 함께 본다.
|
||
|
||
**이 결과가 의미하는 것** — 두 개면 Service 가 두 파드를 다 잡고 있다. **비어
|
||
있으면** Service 는 있는데 뒤가 없는 것이고, 이때 증상은 「연결은 되는데 응답이
|
||
없다」라 원인이 Service 에 있다는 것이 잘 안 보인다. 하나뿐이면 나머지 한 파드가
|
||
readiness 를 통과하지 못한 것이라, 그 상태로 이중화 실험을 하면 **이미 한쪽으로만
|
||
가고 있던 트래픽**을 이중화 실패로 오독하게 된다.
|
||
|
||
목록으로 보려면 **EndpointSlice** 를 쓴다.
|
||
|
||
```bash
|
||
kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak
|
||
```
|
||
|
||
```
|
||
NAME ADDRESSTYPE PORTS ENDPOINTS AGE
|
||
keycloak-xdph6 IPv4 8080 10.42.0.67,10.42.1.155 3d23h
|
||
```
|
||
|
||
**`kubectl get endpoints` 는 쓰지 않는다.** v1.33 부터 deprecated 이고 실행하면 경고가
|
||
나온다 — 이 실험대의 k3s 는 `v1.36.4+k3s1` 이라 해당한다.
|
||
|
||
```
|
||
Warning: v1 Endpoints is deprecated in v1.33+; use discovery.k8s.io/v1 EndpointSlice
|
||
```
|
||
|
||
준비 상태까지 함께 보려면 이렇게 뽑는다.
|
||
|
||
```bash
|
||
kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak \
|
||
-o jsonpath='{range .items[*].endpoints[*]}{.addresses[0]}{"\t"}{.conditions.ready}{"\n"}{end}'
|
||
```
|
||
|
||
```
|
||
10.42.0.67 true
|
||
10.42.1.155 true
|
||
```
|
||
|
||
**어디를 봐야 하는가** — 오른쪽 칸이 두 줄 다 `true` 인가. 여기서만 값을 뽑는 형태를
|
||
쓰는 이유는, 이 두 칸이 **실험 전후로 반복해서 비교할 값**이기 때문이다. 처음 볼
|
||
때는 위의 `describe svc` 로 충분하다.
|
||
|
||
**이 결과가 의미하는 것** — `ready` 가 `false` 면 파드는 있는데 **readiness 프로브를
|
||
통과하지 못한** 것이라, Service 가 그 파드로 트래픽을 보내지 않는다. 파드 목록에서는
|
||
`Running` 으로 보이므로 `get pods` 만 봐서는 알 수 없다 — `0/1` 인지 `1/1` 인지가
|
||
같은 사실을 말해 준다. **비어 있으면** 셀렉터와 파드 라벨이 안 맞는다.
|
||
|
||
```bash
|
||
kubectl -n keycloak-lab get svc keycloak -o jsonpath='{.spec.selector}'; echo
|
||
kubectl -n keycloak-lab get pods --show-labels
|
||
```
|
||
|
||
**2-5. PVC 가 실제로 붙었나**
|
||
|
||
**확인** — 볼륨이 실제로 잡혔는가
|
||
|
||
```bash
|
||
kubectl -n keycloak-lab get pvc
|
||
```
|
||
|
||
**어디를 봐야 하는가** — STATUS 칸(`Bound`/`Pending`)과 VOLUME 칸(비어 있는지),
|
||
그리고 STORAGECLASS 칸이 `local-path` 인가. StatefulSet 이면 PVC 이름 끝에 파드
|
||
번호가 붙어 있어(`...-keycloak-0`) 어느 파드 것인지 바로 보인다.
|
||
|
||
**이 결과가 의미하는 것** — `Bound` 면 볼륨이 붙었다. `Pending` 이면 StorageClass 가
|
||
없거나 노드에 자리가 없다. **`local-path` 는 파드가 스케줄될 때까지
|
||
기다린다**(WaitForFirstConsumer)므로, 파드가 안 뜨면 PVC 도 `Pending` 인 것이
|
||
정상이다. 둘이 서로를 기다리는 것처럼 보이지만 파드 쪽 원인을 먼저 본다. 사유는
|
||
PVC 의 이벤트에 적혀 있다.
|
||
|
||
```bash
|
||
kubectl -n keycloak-lab describe pvc # 이름을 안 주면 전부 나온다
|
||
```
|
||
|
||
각 PVC 절의 맨 아래 `Events` 에 `waiting for first consumer` 인지 `no persistent
|
||
volumes available` 인지가 적혀 있고, 둘은 대응이 다르다 — 앞엣것은 파드를 고치는
|
||
문제, 뒤엣것은 저장소를 고치는 문제다.
|
||
|
||
**끝났는지 판정한다 — 클러스터가 형성됐는지를 셋으로 본다**
|
||
|
||
가이드 05 의 4·5 번이 여기다. **셋이 다른 것을 본다** — 로그는 「그때 그렇게
|
||
보였다」이고, 테이블은 「지금 등록되어 있다」이며, 지표는 「지금 그 노드가 그렇게
|
||
안다」이다.
|
||
|
||
**확인 ① 로그 — 그때 그렇게 보였다**
|
||
|
||
```bash
|
||
kubectl -n keycloak-lab logs keycloak-0 | grep ISPN000094 | tail -1
|
||
```
|
||
|
||
```
|
||
ISPN000094: Received new cluster view for channel ISPN:
|
||
[keycloak-0-10001|1] (2) [keycloak-0-10001, keycloak-1-52537]
|
||
```
|
||
|
||
**어디를 봐야 하는가** — 세 군데다. 괄호 안의 **`(2)` 가 멤버 수**, 대괄호 안의
|
||
**이름 목록**, 그리고 `|1` 이 **뷰 번호**(멤버가 들고 날 때마다 올라간다).
|
||
`tail -1` 을 붙였으므로 지금 보고 있는 것은 **가장 최근 뷰** 하나다.
|
||
|
||
**이 결과가 의미하는 것** — `(2)` 면 이 노드는 상대를 봤다. `(1)` 이면 혼자 있다고
|
||
알고 있다. 뷰 번호가 계속 오르고 있으면 멤버가 붙었다 떨어지기를 반복하는 중이라,
|
||
「지금 2 다」라는 스냅숏보다 그 사실이 더 중요하다. **이 줄은 과거형이다** — 지금
|
||
상태는 확인 ③ 에서 본다. `grep` 이 아무것도 못 찾으면 클러스터링을 아직 시작도 못
|
||
한 것이니 로그를 통째로 본다.
|
||
|
||
**확인 ② 디스커버리 테이블 — 지금 등록되어 있다**
|
||
|
||
```bash
|
||
kubectl -n keycloak-lab exec deploy/postgres -- \
|
||
psql -U keycloak -d keycloak -c 'select name, ip from jgroups_ping'
|
||
```
|
||
|
||
**어디를 봐야 하는가** — 나온 **행이 몇 개인가**, 그리고 `ip` 칸이 2-4 에서 본 파드
|
||
IP 와 같은가. 끝의 `(N rows)` 한 줄이 개수를 말해 준다.
|
||
|
||
**이 결과가 의미하는 것** — 이 표는 「**등록**되어 있다」이지 「서로 말이 통한다」가
|
||
아니다. 두 행이 다 있는데 확인 ①이 `(1)` 이면, 서로를 찾기는 했는데 7800 포트로
|
||
메시지가 안 가는 것이다 — 이 실험대에서 실제로 그 일이 벌어졌다(observed). 옛 파드의
|
||
행이 남아 있을 수도 있으므로 IP 를 지금 파드와 대조한다.
|
||
|
||
**확인 ③ 지표 — 지금 그 노드가 그렇게 안다**
|
||
|
||
**★ Keycloak 컨테이너에는 `curl` 이 없다**(observed). 공식 이미지가 최소 구성이라
|
||
`wget` 도 `nc` 도 없다. 안에서 치면 이렇게 된다.
|
||
|
||
```
|
||
sh: line 1: curl: command not found
|
||
command terminated with exit code 127
|
||
```
|
||
|
||
그래서 밖에서 물어본다. **Prometheus 에 묻는 것이 가장 짧다**([06] 이 그것을 세운다).
|
||
|
||
```bash
|
||
kubectl -n observability exec deploy/prometheus -- \
|
||
wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size'
|
||
```
|
||
|
||
**어디를 봐야 하는가** — 응답은 **줄바꿈 없는 JSON 한 줄**로 온다. `data.result`
|
||
배열에서 원소가 **몇 개인가**(노드 수), 각 원소에서 두 군데만 읽는다 — `"metric"`
|
||
안의 `node` 라벨(어느 Keycloak 인가)과 `"value"` 배열의 **둘째 원소**(따옴표에 싸인
|
||
값). **이 실험대에는 `jq` 가 없다.** 파서를 따로 짜지 말고 화면에 나온 JSON 을 그대로
|
||
읽는다.
|
||
|
||
**실측**(observed) — 그렇게 읽어낸 값이다
|
||
|
||
```
|
||
keycloak-1 → 2
|
||
keycloak-0 → 2
|
||
```
|
||
|
||
**이 결과가 의미하는 것** — 원소가 둘이고 값이 둘 다 2 면 두 노드가 서로를 보고 있다.
|
||
원소가 하나뿐이면 나머지 노드의 스크레이프가 실패한 것이라 **클러스터 문제가 아니라
|
||
관측 문제**일 수 있다 — [06] 의 targets 를 본다. 그리고 각 노드가 자기가 아는 멤버
|
||
수를 보고하므로 **한 노드만 보면 분단을 놓친다.** 분단되면 한쪽은 2, 다른 쪽은 1 이
|
||
된다.
|
||
|
||
Prometheus 가 아직 없다면 임시 파드를 띄운다.
|
||
|
||
```bash
|
||
K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
|
||
kubectl -n keycloak-lab run m --rm -i --restart=Never \
|
||
--image=curlimages/curl:8.11.1 --quiet --command -- \
|
||
sh -c "curl -s http://$K0:9000/metrics | grep '^vendor_cluster_size'"
|
||
```
|
||
|
||
```
|
||
vendor_cluster_size{cache_manager="keycloak",node="keycloak-0-46674"} 2.0
|
||
```
|
||
|
||
**어디를 봐야 하는가** — 줄 끝의 **숫자**와, 중괄호 안 `node=` 라벨이 **어느
|
||
파드인가**. 이 형태는 그 파드 하나에게 직접 물은 것이라 라벨의 노드 이름과 `$K0` 로
|
||
고른 파드가 반드시 일치한다.
|
||
|
||
**이 결과가 의미하는 것** — `--rm` 을 붙였으므로 파드는 끝나면 사라진다.
|
||
`curlimages/curl` 을 쓰는 이유는 **Keycloak 이미지에 도구가 없기** 때문이고, 같은
|
||
이유로 이 방법은 Keycloak 뿐 아니라 최소 이미지 전부에 쓴다. 값이 안 나오고 연결
|
||
거부가 나면 9000(관리 포트)이 안 열렸다.
|
||
|
||
**확인 ④ 밖에서 닿나** — 2홉을 다 지나 파드까지
|
||
|
||
```bash
|
||
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
|
||
```
|
||
|
||
```
|
||
200
|
||
```
|
||
|
||
**어디를 봐야 하는가** — 코드 한 칸. 여기서 값만 뽑는 형태를 쓰는 것은 **03·04 에서
|
||
잰 것과 같은 명령으로 같은 값이 나오는지** 비교하는 것이 목적이기 때문이다. 이
|
||
자리에서 처음 보는 형태가 아니다.
|
||
|
||
**이 결과가 의미하는 것** — `200` 이면 nginx → Traefik → Ingress → Service → 파드가
|
||
전부 이어졌다. `502`·`503` 이면 뒤에서부터 되짚는다 — Ingress 가 있는지(2-1),
|
||
Service 뒤에 파드가 있는지(2-4), 파드가 Ready 인지(2-2) 순서다. 처음 보는 오류라
|
||
헤더가 필요하면 값만 뽑는 형태를 버리고 읽는 형태로 바꾼다.
|
||
|
||
```bash
|
||
curl -I https://auth.hyeonworks.com/realms/master
|
||
```
|
||
|
||
**확인 ⑤ 관리 콘솔에 로그인된다**
|
||
|
||
브라우저로 `https://auth.hyeonworks.com/admin` 에 들어가 관리자로 로그인한다.
|
||
비밀번호는 위 2-3 의 Secret 에 있다. **이 문서는 그 값을 적지 않는다** — 확인 ①~③
|
||
에서 보듯 이 실험대의 확인은 전부 길이까지만 본다.
|
||
|
||
**네 칸으로 다시 본다.**
|
||
|
||
| 무엇 | 명령 | 통과 |
|
||
|---|---|---|
|
||
| 롤아웃 | `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 |
|
||
| 밖에서 | `curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master` | `200` |
|
||
|
||
**근거를 재려면 (선택)** — 세션이 실제로 어디 저장되는지는 DB 를 직접 본다.
|
||
|
||
```bash
|
||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||
-c "select offline_flag, count(*) from offline_user_session group by 1"
|
||
```
|
||
|
||
**어디를 봐야 하는가** — `offline_flag` 가 `0` 인 행의 `count`. **로그인 전과 후에 두
|
||
번 재서 그 수의 차이**를 본다. 한 번만 재면 아무것도 알 수 없다. 행이 아예
|
||
없으면(`0 rows`) 표는 있는데 비어 있다.
|
||
|
||
**이 결과가 의미하는 것** — 로그인 뒤 수가 늘면 세션이 DB 에 남는
|
||
것(`persistent-user-sessions` 켜짐)이고, 안 늘면 메모리에만 있다. 메모리에만 있으면
|
||
파드를 재시작하는 순간 세션이 사라지고, DB 에 있으면 살아남는다.
|
||
|
||
**막히면**
|
||
|
||
가이드 05 의 3번이 순서를 정해 두었다. **로그부터 보지 않는다.**
|
||
|
||
**① 이벤트부터.** 스케줄링·이미지·볼륨 실패가 여기 나온다.
|
||
|
||
```bash
|
||
kubectl -n keycloak-lab get events --sort-by=.lastTimestamp | tail -20
|
||
```
|
||
|
||
**어디를 봐야 하는가** — 맨 아랫줄부터 거꾸로 읽는다. `--sort-by` 를 준 이유가
|
||
그것이다. TYPE 이 `Warning` 인 줄, REASON 칸(`FailedScheduling`·`Failed`·`BackOff`),
|
||
그리고 OBJECT 칸이 어느 파드인가. **이벤트는 기본 한 시간만 남는다** — 아무것도 없으면
|
||
「문제가 없다」가 아니라 「이미 지워졌다」일 수 있다.
|
||
|
||
**이 결과가 의미하는 것** — REASON 하나가 다음 행동을 정한다. `FailedScheduling` 은
|
||
노드에 자리가 없는 것이라 파드가 아니라 클러스터를 봐야 하고, `ErrImagePull` 은 이미지
|
||
이름 문제라 로그를 볼 것도 없다. `BackOff` 는 컨테이너가 떴다가 죽는 중이라는 뜻이라
|
||
③으로 간다.
|
||
|
||
**② describe.** 그 파드에 한정된 이벤트와 상태를 함께 본다.
|
||
|
||
```bash
|
||
kubectl -n keycloak-lab describe pod keycloak-0
|
||
```
|
||
|
||
**어디를 봐야 하는가** — 위에서부터 세 곳이다. `Conditions` 절에서 `Ready` 가 `False`
|
||
인가, 컨테이너 절의 `State`/`Last State` 와 그 안의 **`Exit Code`**, 그리고 맨 아래
|
||
`Events`. Exit Code 는 그것만으로 말이 된다 — `137` 은 OOM 이나 강제 종료, `1` 은
|
||
애플리케이션이 스스로 끝낸 것, `127` 은 명령을 못 찾은 것이다.
|
||
|
||
**이 결과가 의미하는 것** — Exit Code 가 `137` 이면 로그에는 아무 단서도 없을 수
|
||
있다(맞아 죽은 쪽은 유언을 못 남긴다) — 메모리 한도를 본다. `Ready` 만 `False` 이고
|
||
컨테이너는 살아 있으면 readiness 프로브 문제이므로 2-4 로 돌아간다.
|
||
|
||
**③ 로그.** 컨테이너가 떴는데 죽는 경우다.
|
||
|
||
```bash
|
||
kubectl -n keycloak-lab logs keycloak-0
|
||
kubectl -n keycloak-lab logs keycloak-0 --previous # 재시작 직전 로그
|
||
```
|
||
|
||
**어디를 봐야 하는가** — 첫 명령에서는 **마지막 줄들**, 둘째 명령에서는 스택
|
||
트레이스의 **맨 윗줄**(가장 안쪽 예외가 아니라 최초 원인 줄)이다. Keycloak 은 기동에
|
||
성공하면 `Keycloak ... started in` 한 줄을 남기므로, 그 줄이 있는지 없는지가 「기동
|
||
중」과 「기동 실패」를 가른다.
|
||
|
||
**이 결과가 의미하는 것** — `--previous` 가 중요하다. CrashLoopBackOff 면 지금
|
||
컨테이너는 방금 뜬 것이라 **죽은 이유는 이전 컨테이너 로그에 있다.** `--previous` 가
|
||
`not found` 를 내면 아직 한 번도 재시작하지 않은 것이고, 그러면 지금 로그가 곧 전부다.
|
||
|
||
**④ 그래도 모르면 안에서 본다.**
|
||
|
||
```bash
|
||
kubectl -n keycloak-lab exec -it keycloak-0 -- sh
|
||
```
|
||
|
||
| 증상 | 어디를 보나 |
|
||
|---|---|
|
||
| 파드가 `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 은 쿠버네티스 쪽 규격이고
|
||
이 실험대가 잰 값이 아니다
|
||
- (unknown) `keycloak-cluster.yaml` 원문은 `source/` 에 없다. **Keycloak 과
|
||
PostgreSQL 의 이미지 태그**, 파드 자원 한도, 프로브 설정, `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 가 없으면 클러스터 크기를 물을 때마다 임시 파드를 띄운다.
|
||
|
||
```bash
|
||
K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
|
||
kubectl -n keycloak-lab run m --rm -i --restart=Never \
|
||
--image=curlimages/curl:8.11.1 --quiet --command -- \
|
||
sh -c "curl -s http://$K0:9000/metrics | grep '^vendor_cluster_size'"
|
||
```
|
||
|
||
**어디를 봐야 하는가** — 이 명령이 물을 수 있는 대상은 **`$K0` 로 고른 파드
|
||
하나뿐이다.** `keycloak-1` 을 보려면 IP 를 다시 잡아 한 번 더 친다.
|
||
|
||
**이 결과가 의미하는 것** — 한 노드만 보면 분단을 놓친다. 두 노드를 매번 따로 물어야
|
||
하고, 값을 나란히 놓아 비교하기도 어렵다. 이 단계는 그 질문을 한 번에 받아 준다.
|
||
|
||
**실행 절차**
|
||
|
||
**1. 매니페스트를 적용한다** — `[lab host]`, 저장소 루트에서
|
||
|
||
**목적** — Prometheus · Grafana · node-exporter 를 `observability` 네임스페이스에
|
||
세운다.
|
||
|
||
```bash
|
||
cd ~/workspace/keycloak-pattern
|
||
kubectl apply -f deploy/lab/k8s/observability.yaml
|
||
kubectl -n observability rollout status deploy/prometheus --timeout=180s
|
||
```
|
||
|
||
**확인** — 무엇이 몇 개 떴는가
|
||
|
||
```bash
|
||
kubectl -n observability get pods
|
||
```
|
||
|
||
**실측**(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` 로 확인한다.
|
||
|
||
```bash
|
||
kubectl -n observability get pods -o wide
|
||
```
|
||
|
||
**끝났는지 판정한다**
|
||
|
||
**확인 ① 무엇을 긁고 있나 — 여기가 중요하다**
|
||
|
||
```bash
|
||
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 이 있었는지부터** 본다. 가이드는 그것을 「스크린샷 누락」이 아니라
|
||
**측정된 공백**으로 기록했다.
|
||
|
||
**확인 ② 목록에 있는데 값이 안 나올 때 — 상태를 본다**
|
||
|
||
```bash
|
||
kubectl -n observability exec deploy/prometheus -- \
|
||
wget -qO- localhost:9090/api/v1/targets | tr ',' '\n' | grep -E '"(job|health|lastError)"'
|
||
```
|
||
|
||
**어디를 봐야 하는가** — `"health":"up"` 이 아닌 줄과, 그 **바로 뒤에 붙는
|
||
`lastError`**. `tr ',' '\n'` 으로 쉼표마다 줄을 나눴으므로 필드가 원래 순서대로
|
||
세로로 늘어선다 — job 줄 아래에 그 대상의 health 가 온다.
|
||
|
||
**이 결과가 의미하는 것** — `down` 인 대상이 있으면 `lastError` 가 이유를 그대로 말해
|
||
준다(연결 거부·타임아웃·404). 확인 ③ 에서 값이 한 노드만 나오는 증상의 원인이 대개
|
||
여기 있고, 그때 **클러스터가 아니라 스크레이프가 문제다.**
|
||
|
||
**확인 ③ 클러스터 상태 — 두 노드가 각각 몇 명을 보고 있는가**
|
||
|
||
```bash
|
||
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` 배열의
|
||
원소가 **몇 개인가**(보고하는 노드 수), 각 원소에서 두 군데만 읽는다 — `"metric"`
|
||
안의 `node` 라벨과 `"value"` 배열의 **둘째 원소**(따옴표에 싸인 값). `jq` 가 없으므로
|
||
눈으로 읽는다.
|
||
|
||
**이 결과가 의미하는 것** — **두 노드가 각각 자기가 아는 멤버 수를 보고한다.** 둘 다
|
||
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 이므로 **「살아 있지만 쓸모없는」 상태를 보지 못한다.**
|
||
|
||
```bash
|
||
kubectl -n observability exec deploy/prometheus -- \
|
||
wget -qO- 'localhost:9090/api/v1/query?query=up'
|
||
```
|
||
|
||
**어디를 봐야 하는가** — 원소마다 `job` 라벨과 값(`"1"`/`"0"`). 값이 1 이라는 것은
|
||
**마지막 스크레이프가 성공했다**는 사실 하나만 말한다.
|
||
|
||
**이 결과가 의미하는 것** — `up=1` 은 「프로세스가 살아 있고 `/metrics` 가
|
||
응답했다」이지 「그 서비스가 쓸모 있다」가 아니다. 그래서 경보를 `up == 0` 하나로 걸면
|
||
**「살아 있지만 503」 상태를 통째로 놓친다.** 기능 지표를 함께 본다 — 밖에서 실제
|
||
응답을 받아 보는 것이 가장 짧다.
|
||
|
||
```bash
|
||
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
|
||
```
|
||
|
||
**어디를 봐야 하는가** — 코드 한 칸. 여기서 값만 뽑는 형태를 쓰는 것은 `up` 의 1/0 과
|
||
**나란히 놓고 비교하기 위해서**다. 처음 보는 오류를 파고들 때는 `curl -I` 나
|
||
`curl -v` 로 바꾼다([04] 참조).
|
||
|
||
**이 결과가 의미하는 것** — `up=1` 인데 이쪽이 200 이 아니면 그 조합이 곧 「살아
|
||
있지만 쓸모없는」 상태의 증거다. 그 두 값을 같이 기록해 두는 것을 판정 근거로 삼았다.
|
||
|
||
**확인 ⑤ Grafana 를 볼 때** — 밖에 열지 않고 포트포워드로 본다
|
||
|
||
```bash
|
||
kubectl -n observability port-forward svc/grafana 3000:3000
|
||
```
|
||
|
||
**어디를 봐야 하는가** — `Forwarding from 127.0.0.1:3000 -> 3000` 한 줄이 찍히고
|
||
**명령이 그대로 멈춰 있는가**. 이 명령은 끝나지 않는 것이 정상이라, 터미널 하나를
|
||
여기에 내준다. 브라우저를 열면 그 아래에 `Handling connection` 줄이 하나씩 붙는다 —
|
||
그것이 붙지 않으면 브라우저가 다른 곳을 보고 있다.
|
||
|
||
**이 결과가 의미하는 것** — 이 터널은 **명령을 실행한 기계에서만** 열린다.
|
||
워크스테이션에서 쳤으면 워크스테이션 브라우저로 `http://localhost:3000`, lab host 에서
|
||
쳤으면 lab host 에서 봐야 한다. `bind: address already in use` 면 3000 을 이미 누가
|
||
쓰는 것이니 `3001:3000` 처럼 왼쪽만 바꾼다. Ctrl+C 로 끊으면 터널도 사라진다 — 밖에
|
||
포트를 여는 것이 아니라 **보는 동안만 뚫는 것**이라 실험대의 노출면이 늘지 않는다.
|
||
|
||
실험 중에는 Grafana 보다 **Prometheus 쿼리 API** 가 편하다. 값을 그대로 뽑아 비교할 수
|
||
있고 스크린샷보다 근거로 남기기 좋다.
|
||
|
||
**네 칸으로 다시 본다.**
|
||
|
||
| 무엇 | 명령 | 통과 |
|
||
|---|---|---|
|
||
| 파드 | `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) 파드 네 줄과 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/targets` 의 `health` 훑기·`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` 네트워크가 만드는 `virbr0` 와 `192.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」로
|
||
갈린다. 어느 쪽이 이 실험대인지 (unknown)
|
||
- cloud-init 의 `packages` 에 certbot 이 있었는가. 01·03 은 `curl`·`nftables`
|
||
뿐이라 적고 04 는 들어 있다고 적는다. `kc-lab.yaml.example` 이 `source/` 에
|
||
없어 대조하지 못했다 (unknown)
|
||
- `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`](../source/docs/lab-virtualization.md) — 496줄. 이 부는 그 문서 전문이다 |
|
||
| 리비전 | [`../source/.source-revision`](../source/.source-revision) — `9465582b5d1630eb4ae7c4e078021486919bf6b6` |
|
||
| 측정일 | **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.sh` 는 `source/` 에 반입되지
|
||
않았다**(unknown) — 이 저장소에는 그 스크립트의 원문이 없고 파일 이름만 있다.
|
||
|
||
## 196. 이 문서가 무엇인가
|
||
|
||
[`docs/guides/`](guides/) 가 **「무엇을 어떤 순서로 치는가」** 를 적는다면, 이
|
||
문서는 **「그때 실제로 어떤 값이 나왔는가」** 를 적는다. 가이드에 이미 있는
|
||
절차는 반복하지 않는다.
|
||
|
||
여기 적힌 숫자는 전부 **2026-09-10 에 `test-server` 에서 실제로 돌려 받은
|
||
출력**이다. 추정값·예상값은 없다. 없는 값은 「미측정」이라고 쓴다.
|
||
|
||
| 이 문서가 답하는 것 | 가이드가 답하는 것 |
|
||
|---|---|
|
||
| 5120MB 를 줬는데 실제로 얼마를 쓰나 | 메모리를 얼마로 주나 |
|
||
| 20GB 오버레이가 디스크를 얼마나 먹나 | 오버레이를 어떻게 만드나 |
|
||
| cloud-init 이 몇 초 걸리나 | cloud-init 을 어떻게 쓰나 |
|
||
| 철거하면 무엇이 남나 | 무엇을 세우나 |
|
||
|
||
---
|
||
|
||
## 197. 측정 환경
|
||
|
||
```bash
|
||
lscpu | grep -E "^Model name|^CPU\(s\):|^Thread|^Core"
|
||
free -m | head -2
|
||
df -h /
|
||
virsh --version; qemu-system-x86_64 --version | head -1; uname -r
|
||
```
|
||
|
||
**실측**
|
||
|
||
```
|
||
CPU(s): 8
|
||
Model name: 11th Gen Intel(R) Core(TM) i5-1135G7 @ 2.40GHz
|
||
Thread(s) per core: 2
|
||
Core(s) per socket: 4
|
||
|
||
total used free shared buff/cache available
|
||
Mem: 11648 5642 2599 4 3776 6005
|
||
|
||
/dev/nvme0n1p3 226G 9.9G 204G 5% /
|
||
|
||
12.7.0
|
||
QEMU emulator version 11.1.1
|
||
7.2.2-arch1-1
|
||
```
|
||
|
||
**어디를 봐야 하는가** — `Core(s) per socket` 4 에 `Thread(s) per core` 2 라
|
||
논리 코어가 8 이다. **VM 에 주는 vCPU 는 물리 코어가 아니라 논리 코어를 나눠
|
||
쓰는 것**이고, 합이 8 을 넘어도 libvirt 는 막지 않는다(오버커밋). 이 실험대는
|
||
`2 + 2 + 1 = 5` 로 잡아 여유를 뒀다.
|
||
|
||
`free` 의 `available`(6005MB)이 `free`(2599MB)보다 훨씬 큰 것이 정상이다 —
|
||
`buff/cache` 3776MB 는 필요하면 회수된다. **VM 을 얼마나 더 띄울 수 있는지는
|
||
`free` 가 아니라 `available` 로 본다.**
|
||
|
||
### 중첩 가상화
|
||
|
||
```bash
|
||
lscpu | grep Virtualization
|
||
cat /sys/module/kvm_intel/parameters/nested
|
||
```
|
||
|
||
```
|
||
Virtualization: VT-x
|
||
Y
|
||
```
|
||
|
||
**켜져 있지만 이 실험대는 쓰지 않는다.** 일회용으로 만들려는 층(게스트)은
|
||
이미 일회용이고, 비싼 층(호스트의 인증서·DNS)은 중첩으로 해결되지 않는다.
|
||
그리고 A-6 의 지연 주입처럼 **시간을 재는 실험**에서 중첩은 측정값을 왜곡한다.
|
||
|
||
---
|
||
|
||
## 198. 자원 — 할당과 실사용은 다르다
|
||
|
||
VM 에 준 메모리는 **상한이지 점유가 아니다.** virtio-balloon 이 안 쓰는 만큼
|
||
호스트에 돌려준다.
|
||
|
||
```bash
|
||
for v in kc-lab-1 kc-lab-2; do
|
||
printf "%-10s 할당 %sMB 실사용 %sMB\n" "$v" \
|
||
"$(( $(virsh dommemstat $v | awk '/actual/{print $2}') / 1024 ))" \
|
||
"$(( ($(virsh dommemstat $v | awk '/actual/{print $2}') \
|
||
- $(virsh dommemstat $v | awk '/^unused/{print $2}')) / 1024 ))"
|
||
done
|
||
```
|
||
|
||
**실측** — k3s 만 떠 있고 Keycloak 은 아직 안 올린 상태
|
||
|
||
```
|
||
kc-lab-1 할당 5120MB 실사용 353MB
|
||
kc-lab-2 할당 3120MB 실사용 301MB
|
||
```
|
||
|
||
**어디를 봐야 하는가** — 두 가지다.
|
||
|
||
① **실사용이 할당의 7% 밖에 안 된다.** k3s server 가 353MB 다. 「5GB 를 줬으니
|
||
5GB 를 쓴다」가 아니다.
|
||
|
||
② **`kc-lab-2` 의 할당이 4096 이 아니라 3120 이다.** `virt-install --memory 4096`
|
||
으로 만들었는데 줄어 있다. virtio-balloon 이 회수해 간 것으로 보인다.
|
||
`dommemstat` 의 `actual` 은 **현재 할당**이지 선언한 상한이 아니다. 상한은
|
||
`virsh dominfo` 의 `Max memory` 에 있다 — **다만 이 실험대에서 그 값을 나란히
|
||
찍어 보지는 않았다(미측정).** 둘을 헷갈리면 「메모리가 왜 줄었지」가 된다.
|
||
|
||
**이 결과가 의미하는 것** — 합계 8240MB 를 할당했지만 실제 점유는 654MB 다.
|
||
그래서 11.6GB 짜리 호스트에서 VM 세 대가 무리 없이 돈다. **여기 적힌 `11.6GB` 는
|
||
원 가이드의 표기 그대로다** — §178 의 원 측정은 `free -m` 의 `Mem: 11648` 이고 그것은
|
||
MiB 라 11,648MiB, 약 11.4GiB 다. 고쳐 쓰지 않고 어긋남을 적어 둔다. **그리고 이것은 지금
|
||
k3s 만 떠 있어서다** — Keycloak 2 파드 + PostgreSQL + Redis + Prometheus 가
|
||
올라가면 늘어난다. 그 시점의 값은 **미측정**이다.
|
||
|
||
---
|
||
|
||
## 199. 디스크 — 오버레이는 얼마나 쓰나
|
||
|
||
게스트 디스크는 `base.qcow2` 위의 **오버레이**다. 20GB 를 두 개 만들어도
|
||
바닥 이미지는 한 벌이고 변경분만 쌓인다.
|
||
|
||
```bash
|
||
qemu-img info /var/lib/libvirt/images/base.qcow2 | head -4
|
||
ls -l /var/lib/libvirt/images/
|
||
```
|
||
|
||
**실측** — 엣지를 만들기 **전**, k3s 2 노드만 있던 시점이다.
|
||
|
||
```
|
||
image: /var/lib/libvirt/images/base.qcow2
|
||
file format: qcow2
|
||
virtual size: 3 GiB (3221225472 bytes)
|
||
disk size: 335 MiB
|
||
|
||
-rw-r--r-- base.qcow2 351404032 (335 MiB)
|
||
-rw------- kc-lab-1.qcow2 1521025024 (1.4 GiB) ← 선언 20GB
|
||
-rw------- kc-lab-2.qcow2 697499648 (665 MiB) ← 선언 20GB
|
||
-rw------- seed-kc-lab-1.iso 378880 (370 KiB)
|
||
-rw------- seed-kc-lab-2.iso 378880 (370 KiB)
|
||
```
|
||
|
||
**어디를 봐야 하는가** — `base.qcow2` 의 `virtual size` 3GiB 와 `disk size`
|
||
335MiB 의 차이. **그리고 게스트 디스크가 20GB 로 선언됐는데 1.4GB 와 665MB
|
||
라는 것.** k3s server 가 agent 보다 두 배 이상 쓰는 것은 컨트롤 플레인
|
||
바이너리와 SQLite 때문이다.
|
||
|
||
**이 결과가 의미하는 것** — 20GB × 2 = 40GB 를 선언했지만 실제로는 2.1GB 를
|
||
썼다. 디스크는 병목이 아니다. **`df` 로 본 사용량과 VM 에 선언한 크기를 같은
|
||
것으로 보면 안 된다.**
|
||
|
||
### 스토리지 풀
|
||
|
||
```bash
|
||
virsh pool-info default
|
||
```
|
||
|
||
```
|
||
Name: default
|
||
State: running
|
||
Persistent: yes Autostart: yes
|
||
Capacity: 225.31 GiB
|
||
Allocation: 7.84 GiB
|
||
Available: 217.46 GiB
|
||
```
|
||
|
||
`Allocation` 은 **풀 전체**(호스트 루트 파일시스템)의 사용량이지 VM 만의
|
||
사용량이 아니다. VM 이 얼마를 쓰는지는 위 `ls -l` 로 본다.
|
||
|
||
---
|
||
|
||
## 200. 부팅 — cloud-init 은 얼마나 걸리나
|
||
|
||
```bash
|
||
for i in $(seq 1 30); do
|
||
ssh -o BatchMode=yes -o ConnectTimeout=4 kc-lab-edge 'cloud-init status' 2>/dev/null \
|
||
| grep -q 'status: done' && { echo "완료 (약 $((i*10))초)"; break; }
|
||
sleep 10
|
||
done
|
||
ssh kc-lab-edge 'hostname; ip -4 -br addr show enp1s0; cloud-init status'
|
||
```
|
||
|
||
**실측** — `package_update: true` 에 패키지 5개(`curl` `nftables` `nginx`
|
||
`certbot` `python3-certbot-dns-cloudflare`)를 받는 게스트
|
||
|
||
```
|
||
완료 (약 50초)
|
||
|
||
kc-lab-edge
|
||
enp1s0 UP 192.168.122.10/24 metric 100
|
||
status: done
|
||
```
|
||
|
||
**어디를 봐야 하는가** — `cloud-init status` 의 세 상태를 구분한다.
|
||
|
||
| 값 | 뜻 |
|
||
|---|---|
|
||
| `running` | 아직 진행 중. **기다린다** |
|
||
| `done` | 끝났다 |
|
||
| `error` | 실패. `cloud-init status --long` 으로 어느 모듈인지 본다 |
|
||
|
||
**이 결과가 의미하는 것** — SSH 가 붙는 것과 cloud-init 이 끝난 것은 다르다.
|
||
SSH 는 먼저 열리고 패키지 설치는 뒤에 이어진다. **`done` 을 안 기다리고 다음
|
||
단계를 치면 「방금 깐 패키지가 없다」가 나온다.**
|
||
|
||
---
|
||
|
||
## 201. 네트워크 — DHCP 예약의 실제 동작
|
||
|
||
```bash
|
||
virsh net-update default add ip-dhcp-host \
|
||
"<host mac='52:54:00:aa:bb:10' name='kc-lab-edge' ip='192.168.122.10'/>" \
|
||
--live --config
|
||
```
|
||
|
||
**실측**
|
||
|
||
```
|
||
Updated network default persistent config and live state
|
||
```
|
||
|
||
**어디를 봐야 하는가** — **`persistent config` 와 `live state` 두 마디가 다
|
||
나오는가.** `--live` 만 주면 앞의 것이, `--config` 만 주면 뒤의 것이 빠진다.
|
||
한쪽만 나온 것을 성공으로 읽으면 「재부팅하니 IP 가 바뀐다」가 된다.
|
||
|
||
### 예약을 먼저, VM 을 나중에
|
||
|
||
이 실험대는 **예약을 넣고 나서 `virt-install`** 했고, 게스트가 첫 부팅에서
|
||
바로 `.10` 을 받았다.
|
||
|
||
```
|
||
enp1s0 UP 192.168.122.10/24 metric 100
|
||
```
|
||
|
||
순서가 반대면 게스트가 동적 대역(`192.168.122.2`–`.254`)에서 아무 주소나 받고,
|
||
예약이 먹으려면 리스 만료를 기다리거나 재부팅해야 한다.
|
||
|
||
### 리스는 예약과 별개로 남는다
|
||
|
||
```bash
|
||
virsh net-dhcp-leases default
|
||
```
|
||
|
||
```
|
||
Expiry Time MAC address IP address Hostname
|
||
2026-09-10 09:59:54 52:54:00:aa:bb:11 192.168.122.11/24 kc-lab-1
|
||
2026-09-10 09:58:46 52:54:00:aa:bb:12 192.168.122.12/24 kc-lab-2
|
||
```
|
||
|
||
`net-dumpxml` 의 예약은 **줄 의도**이고, `net-dhcp-leases` 는 **실제로 준
|
||
기록**이다. 둘이 다를 수 있다.
|
||
|
||
### virbr0 는 게스트가 없으면 내려간다
|
||
|
||
```bash
|
||
ip -br addr show virbr0
|
||
```
|
||
|
||
VM 세 대가 돌 때:
|
||
```
|
||
virbr0 UP 192.168.122.1/24
|
||
```
|
||
|
||
전부 철거한 뒤:
|
||
```
|
||
virbr0 DOWN 192.168.122.1/24
|
||
```
|
||
|
||
**주소는 그대로 있고 상태만 `DOWN` 이다.** 브리지에 붙은 tap 인터페이스가
|
||
하나도 없어서다. 네트워크 정의가 사라진 것이 아니므로 **VM 을 다시 띄우면
|
||
자동으로 `UP` 이 된다.** 이걸 고장으로 오독해 `virsh net-start` 를 찾아
|
||
헤매지 않는다.
|
||
|
||
---
|
||
|
||
## 202. 철거 — 실제 출력 전문
|
||
|
||
가이드에는 세우는 절차만 있고 철거가 없다. 2026-09-10 에 실제로 돌린 기록이다.
|
||
|
||
### 게스트
|
||
|
||
```bash
|
||
for v in kc-lab-edge kc-lab-2 kc-lab-1; do
|
||
virsh destroy "$v"
|
||
virsh undefine "$v" --remove-all-storage
|
||
done
|
||
```
|
||
|
||
**실측** (한 대분)
|
||
|
||
```
|
||
Domain 'kc-lab-edge' destroyed
|
||
Domain 'kc-lab-edge' has been undefined
|
||
Volume 'vda'(/var/lib/libvirt/images/kc-lab-edge.qcow2) removed.
|
||
Volume 'vdb'(/var/lib/libvirt/images/seed-kc-lab-edge.iso) removed.
|
||
```
|
||
|
||
**어디를 봐야 하는가** — `Volume` 줄이 **두 개** 나오는가. `vda`(오버레이
|
||
디스크)와 `vdb`(시드 ISO)다. `--remove-all-storage` 를 빠뜨리면 도메인만
|
||
사라지고 **디스크 파일이 남아 다음 `virt-install` 이 「이미 있다」로 실패**한다.
|
||
|
||
`destroy` 는 종료 신호가 아니라 **전원을 뽑는 것**이다. 정상 종료를 원하면
|
||
`virsh shutdown` 을 쓰고 꺼질 때까지 기다린다.
|
||
|
||
### DHCP 예약
|
||
|
||
```bash
|
||
virsh net-update default delete ip-dhcp-host \
|
||
"<host mac='52:54:00:aa:bb:10' name='kc-lab-edge' ip='192.168.122.10'/>" \
|
||
--live --config
|
||
```
|
||
|
||
**★ 삭제할 때도 `mac`·`name`·`ip` 세 속성을 다 준다.** 하나라도 비면 이렇게
|
||
거부된다.
|
||
|
||
```
|
||
error: Failed to update network default
|
||
error: XML error: Cannot use host name '' in network 'default'
|
||
```
|
||
|
||
> **zsh 에서 루프로 돌리면 이 오류를 만난다.** zsh 는 따옴표 없는 변수를
|
||
> **단어 분리하지 않는다.** bash 에서 되던 `set -- $entry` 가 zsh 에서는
|
||
> `$1` 에 문자열 전체를 넣고 `$2`·`$3` 을 비운다. 그래서 `name=""` 이 된다.
|
||
> 세 줄을 값 그대로 쓰는 편이 안전하다.
|
||
|
||
**철거 후**
|
||
|
||
```
|
||
### 남은 예약
|
||
(없음)
|
||
|
||
### dhcp 블록
|
||
<dhcp>
|
||
<range start='192.168.122.2' end='192.168.122.254'/>
|
||
</dhcp>
|
||
```
|
||
|
||
동적 대역만 남는 것이 정상이다.
|
||
|
||
### 철거 전후 비교 — 실측
|
||
|
||
| | 철거 전 | 철거 후 |
|
||
|---|---|---|
|
||
| `virsh list --all` | 3 대 running | (없음) |
|
||
| `virsh vol-list default` | 7 개 | `base.qcow2` 1 개 |
|
||
| DHCP 예약 | 3 줄 | 0 줄 |
|
||
| `df -h /` | 11G | **7.9G** |
|
||
| `virbr0` | UP | DOWN |
|
||
|
||
**3.1GB 가 회수됐다.** 내역은 `kc-lab-1` 1.4GB + `kc-lab-2` 665MB + 시드 ISO
|
||
3개(각 370KB)이고, **`kc-lab-edge` 의 디스크 크기는 재 두지 않았다(미측정)** —
|
||
합계에서 역산하면 1GB 안팎이다.
|
||
|
||
`base.qcow2` 335MB 는 남긴다 — 다음 재구축의 바닥이고, 다시 받으면 몇 분이다.
|
||
|
||
---
|
||
|
||
## 203. 실측으로 드러난 함정 셋
|
||
|
||
전부 **아무 오류 없이 조용히 지나가거나, 엉뚱한 곳에서 증상이 나오는** 유형이다.
|
||
|
||
### ① cloud-init `sudo` 는 리스트가 아니라 문자열
|
||
|
||
```bash
|
||
ssh kc-lab-1 'cloud-init schema -c ~/chk.yaml'
|
||
```
|
||
|
||
리스트 형태(`sudo: ['ALL=(ALL) NOPASSWD:ALL']`)일 때:
|
||
|
||
```
|
||
Error: Cloud config schema errors: users.0: {'name': 'donghyeon', 'groups':
|
||
['sudo'], 'shell': '/bin/bash', ...} is not valid under any of the given schemas
|
||
```
|
||
|
||
문자열 형태(`sudo: "ALL=(ALL) NOPASSWD:ALL"`)일 때:
|
||
|
||
```
|
||
Valid cloud-config: /home/donghyeon/chk.yaml
|
||
```
|
||
|
||
**어느 키가 문제인지 안 알려 준다.** `users.0` 블록을 통째로 찍고 「어느
|
||
스키마에도 안 맞는다」고만 한다. 그리고 **리스트 형태도 부팅은 된다** —
|
||
`kc-lab-1`·`kc-lab-2` 가 그 상태로 NOPASSWD sudo 가 멀쩡히 돌았다. 검사기만
|
||
거부하는 것이라 「검사는 실패인데 왜 되지」로 헷갈린다.
|
||
|
||
게스트 cloud-init 버전: `22.4.2` (Debian 12 genericcloud).
|
||
|
||
### ② nginx `http2 on;` 은 배포판에 따라 없다
|
||
|
||
```
|
||
엣지 (Debian 12): nginx version: nginx/1.22.1
|
||
lab host (Arch): nginx version: nginx/1.30.4
|
||
```
|
||
|
||
`http2 on;` 은 nginx 1.25.1 이상이다. Arch 에서 쓰던 설정을 Debian 게스트로
|
||
그대로 옮기면:
|
||
|
||
```
|
||
[emerg] 1339#1339: unknown directive "http2" in /etc/nginx/sites-enabled/keycloak-lab:29
|
||
nginx: configuration file /etc/nginx/nginx.conf test failed
|
||
```
|
||
|
||
`listen 443 ssl http2;` 형태는 1.22 와 1.30 양쪽에서 다 돈다.
|
||
|
||
### ③ Debian 기본 사이트가 `default_server` 를 먹고 있다
|
||
|
||
Debian 계열은 `/etc/nginx/sites-enabled/default` 가 처음부터 붙어 있고 `:80`
|
||
에 `default_server` 로 선언돼 있다. 실험대 설정의 `listen 80 default_server`
|
||
와 **충돌한다.** 심볼릭 링크를 걸 때 같이 지운다.
|
||
|
||
```bash
|
||
sudo rm -f /etc/nginx/sites-enabled/default
|
||
```
|
||
|
||
Arch 는 `sites-available` 관례 자체가 없어서 이 함정이 없는 대신, `nginx.conf`
|
||
에 `include /etc/nginx/sites-enabled/*;` 를 직접 넣어야 한다. **배포판이 바뀌면
|
||
함정도 바뀐다.**
|
||
|
||
---
|
||
|
||
## 204. 재구축할 때 무엇이 남아 있나
|
||
|
||
철거해도 남는 것과 사라지는 것을 구분해 둔다. 이걸 모르면 재구축이 「왜
|
||
이건 이미 있지」와 「왜 이건 없지」의 반복이 된다.
|
||
|
||
| | 상태 | 왜 |
|
||
|---|---|---|
|
||
| `base.qcow2` (335MB) | **남는다** | 다음 오버레이의 바닥 |
|
||
| 패키지 (libvirt·qemu·nginx·certbot·kubectl) | **남는다** | 재설치가 무의미 |
|
||
| `~/workspace/cloud/kc-lab-{1,2}.yaml` | **남는다** | 키와 비밀번호가 들어 있다 |
|
||
| `~/.ssh/config` 의 `kc-lab-*` 항목 | **남는다** | 재구축해도 IP 가 같다 |
|
||
| libvirt `default` 네트워크 정의 | **남는다** | 예약만 지웠다 |
|
||
| `/etc/letsencrypt/` | **남긴다** (정책) | 아래 참고 |
|
||
| 게스트 디스크·시드 ISO | 사라진다 | `--remove-all-storage` |
|
||
| DHCP 예약 | 사라진다 | `net-update delete` |
|
||
| k3s·Keycloak·모든 워크로드 | 사라진다 | 게스트와 함께 |
|
||
|
||
### 인증서를 지우지 않는 이유
|
||
|
||
**한도 때문이 아니다.** Let's Encrypt 의 「같은 이름 조합에 주당 중복 5장」
|
||
제한은 가끔 재구축하는 정도로는 근처에도 못 간다.
|
||
|
||
**진짜 이유는 「지금 재발급이 되는지를 모른다」는 것이다.** 이 실험대의 이름
|
||
셋은 tailnet 주소를 가리킨다.
|
||
|
||
```bash
|
||
dig +short auth.hyeonworks.com
|
||
```
|
||
```
|
||
100.83.212.4
|
||
```
|
||
|
||
`100.64.0.0/10` 은 CGNAT 용 예약 대역이라 **공개 인터넷에서 라우팅되지
|
||
않는다.** HTTP-01 검증은 Let's Encrypt 가 우리 서버로 들어오는 방식이므로,
|
||
이 주소로는 검증이 성립하지 않는다.
|
||
|
||
| 지금 설정이 | 지우고 나면 |
|
||
|---|---|
|
||
| `dns-cloudflare` (DNS-01) | 다시 받으면 끝. **백업은 헛수고였던 것** |
|
||
| `webroot`·`standalone` (HTTP-01) | 검증 방식부터 손봐야 한다 |
|
||
|
||
**「못 한다」가 아니라 「일이 하나 생긴다」이다.** 최악의 경우라도 A 레코드를
|
||
공인 IP 로 잠깐 돌려 받거나 DNS-01 로 전환하면 된다. 다만 재구축하려고 앉은
|
||
자리에서 그 일부터 하게 된다.
|
||
|
||
**어느 쪽인지 모르는 채로는 지우지 않는다**는 것이 이 정책의 전부다. 백업은
|
||
tar 하나에 30초고, 답을 알고 나면 지우면 된다.
|
||
|
||
```bash
|
||
sudo tar czf ~/letsencrypt-backup-$(date +%Y%m%d-%H%M%S).tgz -C /etc letsencrypt
|
||
```
|
||
|
||
복원은 반대로 한 줄이다.
|
||
|
||
```bash
|
||
sudo tar xzf ~/letsencrypt-backup-<stamp>.tgz -C /etc
|
||
```
|
||
|
||
**미측정** — 이 실험대의 certbot 이 HTTP-01 인지 DNS-01 인지 아직 확인하지
|
||
않았다. 호스트의 `sudo` 가 비밀번호를 요구해 비대화식으로 읽지 못했다.
|
||
다음 한 줄이 답이다.
|
||
|
||
```bash
|
||
sudo grep -H authenticator /etc/letsencrypt/renewal/*.conf
|
||
```
|
||
|
||
`webroot`·`standalone` 이면 HTTP-01 이라 위 문제가 실재하고, `dns-cloudflare`
|
||
면 DNS-01 이라 tailnet 안에서도 재발급·갱신이 된다. 플러그인은 이미 깔려
|
||
있다 — `certbot plugins` 에 `dns-cloudflare` 가 보인다.
|
||
|
||
---
|
||
|
||
## 205. 관련 문서
|
||
|
||
| 문서 | 무엇 |
|
||
|---|---|
|
||
| [`guides/01-vms/`](guides/01-vms/) | VM 세 대를 세우는 절차 |
|
||
| [`guides/00-lab-host/`](guides/00-lab-host/) | 호스트 가상화 준비 |
|
||
| [`session-lab-concepts.md`](session-lab-concepts.md) | 여기 나온 개념의 정의 |
|
||
| [`deploy/lab/host/teardown-host.sh`](../deploy/lab/host/teardown-host.sh) | 호스트 계층 철거 |
|
||
|
||
---
|
||
|
||
# 제8부 — 설정 원본이 자기 안에 적어 둔 것
|
||
|
||
§189·§190 은 **가이드가 치라고 적은 설정**을 옮겼다. 그 설정의 **정본 파일**은
|
||
저장소의 `deploy/lab/edge/` 에 따로 있고, 거기에는 가이드에 없는 것이 하나 더
|
||
들어 있다 — **왜 이렇게 썼는지를 적은 주석**이다. 이 부가 그 주석을 옮긴다.
|
||
|
||
## 206. 이 부의 출처와 범위
|
||
|
||
| | |
|
||
|---|---|
|
||
| 원본 | [`../source/deploy/lab/edge/`](../source/deploy/lab/edge/) — 네 파일, 합쳐 125줄 |
|
||
| 리비전 | [`../source/.source-revision`](../source/.source-revision) — `9465582b5d1630eb4ae7c4e078021486919bf6b6` |
|
||
|
||
**무엇이 이미 있고 무엇이 없었나**(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.service` — `ExecStartPost` 앞의 `-` 가 무엇을 봐주나
|
||
|
||
```
|
||
# 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 어디에도 없던 줄이다.
|
||
|
||
```nginx
|
||
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.sh` — `deploy/` 와 `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.md` 는 `source/` 에 반입되지 않았다(unknown) |
|
||
|
||
**`38m25s` 와 `2305초 (38분 25초)` 는 같은 값이다.** 2305 ÷ 60 = 38분 25초.
|
||
|
||
|
||
---
|
||
|
||
# 제9부 — 실험대 개념 사전
|
||
|
||
제5~8부는 이 실험대에서 **일어난 일**을 적었다. 이 부는 그 일을 하면서 **쌓아 둔
|
||
개념 정의**다. 원본이 층으로 쌓아 올린 사전이고, 절마다 「무엇인가 · 왜 여기
|
||
나오나 · 없거나 틀리면 · 확인」 네 칸을 갖추려 한 문서다.
|
||
|
||
## 211. 이 부의 출처와 범위
|
||
|
||
| | |
|
||
|---|---|
|
||
| 원본 | [`../source/docs/session-lab-concepts.md`](../source/docs/session-lab-concepts.md) — 4,725줄. 이 부는 그 문서 전문이다 |
|
||
| 리비전 | [`../source/.source-revision`](../source/.source-revision) — `9465582b5d1630eb4ae7c4e078021486919bf6b6` |
|
||
| 원본이 밝힌 갱신일 | 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 → Traefik`의 **2홉 구조**가 운영(`desktop`)과 같다는 점이 이 배치의
|
||
핵심이다. 운영은 `nginx → 127.0.0.1:30080(NodePort) → Traefik`이고
|
||
여기는 `nginx → 노드 IP:80(servicelb) → Traefik`으로, **단일 노드냐 2노드냐의
|
||
차이만 있다.**
|
||
|
||
**구축 완료 판정 기준** — 아래가 전부 통과해야 다음 단계로 넘어간다.
|
||
|
||
```bash
|
||
kubectl get nodes # Ready 2개
|
||
dig A auth.hyeonworks.com +short # 100.83.212.4
|
||
curl -sI https://auth.hyeonworks.com | head -1 # HTTP/2 404
|
||
curl -s -o /dev/null -w '%{ssl_verify_result}\n' https://auth.hyeonworks.com # 0
|
||
curl -sI http://auth.hyeonworks.com | head -1 # 301
|
||
systemctl is-active nginx certbot-renew.timer # active active
|
||
```
|
||
|
||
**`404`가 성공 신호다.** TLS가 정상 종료되고 Traefik까지 도달했으나 매칭되는
|
||
Ingress 규칙이 없다는 뜻이다. 여기서 `502`나 `connection refused`가 나오면
|
||
체인 어딘가가 끊긴 것이다.
|
||
|
||
---
|
||
|
||
## 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 가속 없음" 경고를 내고 진행한다.
|
||
|
||
**확인**
|
||
|
||
```bash
|
||
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 생성이 실패한다.
|
||
|
||
**확인**
|
||
|
||
```bash
|
||
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 예산 계산이 직관적으로
|
||
이해된다.
|
||
|
||
**확인**
|
||
|
||
```bash
|
||
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 정의가 어디에도 저장되지
|
||
않아 재부팅 시 전부 사라진다.
|
||
|
||
**확인**
|
||
|
||
```bash
|
||
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`도 필요 없어진다.
|
||
|
||
```bash
|
||
echo 'export LIBVIRT_DEFAULT_URI=qemu:///system' >> ~/.zshrc
|
||
exec zsh # 또는 재로그인
|
||
```
|
||
|
||
**확인**
|
||
|
||
```bash
|
||
virsh uri # qemu:///system 이 나와야 함
|
||
virsh -c qemu:///system net-list --all # URI를 매번 명시하는 방법
|
||
```
|
||
|
||
**sudo와 비-sudo를 섞지 말 것** — 이 문제는 한 번 고쳐도 반복해서 재발한다.
|
||
`sudo`는 기본적으로 환경 변수를 물려주지 않으므로, `~/.zshrc`에
|
||
`LIBVIRT_DEFAULT_URI`를 걸어둬도 **`sudo virsh`에는 전달되지 않는다.**
|
||
다만 sudo는 root로 실행되니 결과적으로 `qemu:///system`이 되어 동작한다.
|
||
그래서 두 방식 모두 되긴 하는데, **섞어 쓰면 어떤 명령은 되고 어떤 명령은
|
||
"Network not found"가 나는 상황**이 만들어진다.
|
||
|
||
**한 가지만 고른다. 권장은 sudo를 쓰지 않는 쪽이다.**
|
||
|
||
```bash
|
||
echo 'export LIBVIRT_DEFAULT_URI=qemu:///system' >> ~/.zshrc
|
||
exec zsh
|
||
virsh uri # qemu:///system 확인 후, 이제 sudo 없이 모든 virsh 명령
|
||
```
|
||
|
||
**증상 → 원인 대조표**
|
||
|
||
| 증상 | 실제 원인 |
|
||
|---|---|
|
||
| `Network not found: no network with matching name 'default'` | 세션 인스턴스를 보고 있다. 네트워크가 없는 게 아니다 |
|
||
| `sudo`로는 되는데 그냥은 안 됨 | 위와 동일 |
|
||
| `net-update`가 오류 없이 끝났는데 반영이 안 됨 | `--config`만 주고 `--live`를 빠뜨렸다 (또는 반대) |
|
||
| 재부팅하니 설정이 사라짐 | `--live`만 주고 `--config`를 빠뜨렸다 |
|
||
|
||
**변경이 실제로 남았는지 보는 법** — libvirt는 "실행 중 정의"와
|
||
"영구 정의"를 따로 들고 있다. 둘 다 확인해야 한다.
|
||
|
||
```bash
|
||
virsh net-dumpxml default # 실행 중 정의 (--live 가 반영되는 곳)
|
||
virsh net-dumpxml --inactive default # 영구 정의 (--config 가 반영되는 곳)
|
||
```
|
||
|
||
## 225. 보조 그룹과 재로그인
|
||
|
||
**무엇인가** — `usermod -aG libvirt $USER`는 `/etc/group` 파일을 수정한다.
|
||
그런데 프로세스의 그룹 목록은 **로그인 시점에 한 번 읽혀서 고정**되고,
|
||
이미 떠 있는 셸에는 소급 적용되지 않는다.
|
||
|
||
**왜 여기 나오나** — `usermod` 직후 같은 터미널에서 `virsh -c qemu:///system`을
|
||
치면 권한 거부가 날 수 있다. 명령이 잘못된 게 아니라 셸이 옛날 그룹 목록을
|
||
들고 있는 것이다. 새 SSH 세션이나 재로그인이면 정상 동작한다.
|
||
|
||
**확인**
|
||
|
||
```bash
|
||
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 && B`는 **A가 성공했을 때만 B를 실행**한다.
|
||
그래서 `net-start && net-autostart`를 두 번째로 실행하면
|
||
`net-start`가 "이미 active"로 실패하면서 `net-autostart`가 **아예 실행되지
|
||
않는다.** 오류 메시지만 보면 둘 다 실패한 것처럼 보이지만,
|
||
실제로는 앞선 실행에서 이미 목적을 달성한 상태다.
|
||
|
||
**다시 실행해도 안전한 형태** — `&&` 대신 `;`를 쓰고 실패를 삼킨다.
|
||
|
||
```bash
|
||
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`가 실패한다.
|
||
|
||
**확인**
|
||
|
||
```bash
|
||
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 경로를 절대경로로 기억한다.
|
||
|
||
**확인**
|
||
|
||
```bash
|
||
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 이미지**다.
|
||
|
||
```bash
|
||
dd if=/dev/sda of=disk.img bs=4M # 디스크 전체를 파일로
|
||
dd if=disk.img of=/dev/sdb bs=4M # 파일을 다른 디스크로 되돌림
|
||
```
|
||
|
||
되돌린 디스크는 원본과 **바이트 단위로 동일**하므로 똑같이 부팅된다.
|
||
"OS를 복사했다"는 말의 실체가 이것이다. 특별한 마법이 아니라
|
||
**배열을 그대로 옮긴 것**이다.
|
||
|
||
**3단계 — VM에서는 그 파일이 곧 디스크다**
|
||
|
||
물리 디스크로 되돌릴 필요조차 없다. QEMU에게 "이 파일을 디스크로 취급하라"고
|
||
하면 게스트는 그것을 진짜 디스크로 인식한다. 게스트가 섹터 1234를 읽으면
|
||
QEMU가 파일의 해당 오프셋을 읽어 돌려준다.
|
||
|
||
```
|
||
게스트 커널: "섹터 1234 읽어줘"
|
||
│
|
||
▼
|
||
QEMU: 파일의 1234 × 512 바이트 위치를 읽음
|
||
│
|
||
▼
|
||
호스트 파일시스템: kc-lab-1.qcow2
|
||
```
|
||
|
||
**4단계 — qcow2는 raw의 개선판이다**
|
||
|
||
raw 이미지는 20GB짜리 디스크면 파일도 20GB다. qcow2는 세 가지를 더한다.
|
||
|
||
| 기능 | 내용 |
|
||
|---|---|
|
||
| 희소 저장 | 실제로 쓰인 영역만 파일에 담는다 (20G 디스크 → 264M 파일) |
|
||
| backing file | 다른 이미지를 "바탕"으로 삼고 차이만 저장 |
|
||
| 스냅샷 | 특정 시점 상태를 보존 |
|
||
|
||
qcow2 내부는 **2단계 페이지 테이블**과 같은 구조다.
|
||
|
||
```
|
||
게스트 섹터 주소
|
||
│
|
||
▼
|
||
┌─────────┐ ┌─────────┐ ┌──────────────┐
|
||
│ L1 테이블│ ─────▶ │ L2 테이블│ ─────▶ │ 데이터 클러스터│
|
||
└─────────┘ └─────────┘ │ (기본 64KB) │
|
||
│ └──────────────┘
|
||
│ 항목이 비어 있으면
|
||
▼
|
||
backing file 로 위임
|
||
(base.qcow2)
|
||
```
|
||
|
||
**읽기**: L1 → L2를 따라가 클러스터를 찾는다. 항목이 비어 있으면
|
||
**backing file에게 그 주소를 다시 묻는다.** 그래서 오버레이에 아무것도
|
||
없어도 base의 내용이 그대로 보인다.
|
||
|
||
**쓰기 (copy-on-write)**: 그 클러스터를 backing에서 읽어와 오버레이에
|
||
복사한 뒤 수정한다. 이후 그 클러스터는 오버레이에서 직접 읽힌다.
|
||
**base 파일은 절대 수정되지 않는다.**
|
||
|
||
이것이 20G VM 두 대가 850M만 쓰는 이유이고, 실험을 망쳤을 때
|
||
**오버레이만 지우면 몇 초 만에 초기 상태로 돌아가는** 이유다.
|
||
|
||
**5단계 — 그대로 복제할 때 남는 문제**
|
||
|
||
디스크가 바이트 단위로 같으므로 **안에 적힌 식별자도 같아진다.**
|
||
|
||
| 항목 | 중복되면 |
|
||
|---|---|
|
||
| machine-id | systemd/DHCP가 두 기계를 같은 기계로 오인 |
|
||
| SSH 호스트 키 | 두 서버가 같은 신원을 주장 → MITM 탐지 무력화 |
|
||
| 파일시스템 UUID | `/etc/fstab`이 엉뚱한 디스크를 마운트 |
|
||
| hostname | 로그·클러스터에서 노드 구분 불가 |
|
||
|
||
클라우드 이미지가 이 값들을 **비워둔 채 배포하고**, cloud-init이 첫 부팅에
|
||
채우는 이유가 바로 이것이다. 앞 항목의 "빈칸" 표와 여기가 연결된다.
|
||
|
||
## 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 다.
|
||
|
||
```bash
|
||
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` 이 있고
|
||
거기에 경로가 문자열로 들어간다. **바닥을 옮기거나 이름을 바꾸면 게스트가
|
||
부팅하지 못한다.** 오버레이만 다른 기계로 복사하면 안 되는 이유다.
|
||
|
||
```bash
|
||
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 info` 가 `file format: raw` 로
|
||
읽으면 그 파일은 qcow2 가 아니다 — 01 에서 base 이미지를 받다가 끊겨
|
||
HTML 오류 페이지를 저장했을 때 정확히 그렇게 나온다.
|
||
|
||
### 압축 — 배포용 이미지는 실제로 압축돼 있다
|
||
|
||
qcow2 는 **클러스터 단위 zlib 압축**을 지원한다. 배포용 클라우드 이미지는
|
||
그것을 켜서 만든다. 「희소해서 작다」만으로는 설명이 안 되는 부분이 여기다.
|
||
|
||
```bash
|
||
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 설치까지) (실험 중 변경분)
|
||
```
|
||
|
||
```bash
|
||
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 를 만들지 않는다.**
|
||
|
||
```bash
|
||
# 바닥 — 받는다. 이미 압축된 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 을 이미지로 뜬다」는 별도 작업이 없다. 필요한 것은 **그 파일을
|
||
재사용 가능한 바닥으로 굳히는** 작업이고, 그건 다른 일이다.
|
||
|
||
```bash
|
||
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
|
||
이 「이미 실행됨」으로 표시돼 있어서 **새 게스트에서 아예 돌지 않는다** —
|
||
증상은 「호스트명이 안 바뀐다」로 나타난다.
|
||
|
||
### 오버레이를 쌓는 법
|
||
|
||
```bash
|
||
# ① 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=...` 가 내부적으로 이것을 부른다.
|
||
직접 칠 일은 골든을 만들거나 오버레이만 초기화할 때다.
|
||
|
||
**★ 바닥은 절대 수정하지 않는다.** 오버레이는 「바닥이 그대로」를 전제로
|
||
변경분만 들고 있다. 바닥을 고치면 그 위 게스트가 **전부** 깨진다. 골든을
|
||
갱신할 때는 수정이 아니라 **새 파일을 만들고 새 게스트부터 그것을 쓰게**
|
||
한다.
|
||
|
||
**★ 경로는 절대경로로 준다.** 헤더에 문자열로 박히므로 상대경로면 작업
|
||
디렉터리가 바뀌는 순간 못 찾는다.
|
||
|
||
**확인**
|
||
|
||
```bash
|
||
qemu-img info --backing-chain kc-lab-1.qcow2 # 지우기·고치기 전 항상 이것부터
|
||
```
|
||
|
||
### 사슬을 끊는 두 가지 방법
|
||
|
||
골든을 정리하고 싶은데 오버레이가 물려 있을 때 쓴다.
|
||
|
||
| 명령 | 무엇을 하나 | 결과 |
|
||
|---|---|---|
|
||
| `qemu-img commit <오버레이>` | 오버레이의 변경분을 **바닥에 병합** | 바닥이 바뀐다. **다른 오버레이가 있으면 그것들이 깨진다** |
|
||
| `qemu-img convert -O qcow2 <오버레이> <새파일>` | 사슬 전체를 읽어 **단일 파일로 평탄화** | 바닥과 무관해진다. 용량은 늘어난다 |
|
||
|
||
**옮길 때는 `convert` 가 안전하다.** 다른 기계로 게스트를 보낼 때 오버레이만
|
||
복사하면 바닥이 없어 부팅하지 못한다. 평탄화하면 파일 하나로 완결된다.
|
||
|
||
```bash
|
||
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
|
||
라서 내부 스냅샷이 거부된다** — 위 「그래서 마이그레이션과 스냅샷이 된다」
|
||
참고.
|
||
|
||
**확인**
|
||
|
||
```bash
|
||
qemu-img info <파일> # 포맷·크기·cluster_size·backing file
|
||
qemu-img check <파일> # 매핑표와 refcount 정합성 검사
|
||
qemu-img map --output=json <파일> | head # 어느 구간이 실제로 할당됐는지
|
||
qemu-img info --backing-chain <파일> # 바닥까지 사슬 전체
|
||
```
|
||
|
||
## 232. `qemu-img` 와 `qemu-system-x86_64` 는 다른 도구다
|
||
|
||
**무엇인가** — 이름이 비슷해서 헷갈리는데 하는 일이 완전히 다르다.
|
||
|
||
| 도구 | 무엇을 하나 | VM 을 돌리나 |
|
||
|---|---|---|
|
||
| `qemu-img` | **디스크 이미지 파일**을 만들고·보고·변환한다 | **아니다** |
|
||
| `qemu-system-x86_64` | 가상 머신을 **실행**한다 | 그렇다 |
|
||
|
||
`qemu-img` 는 파일만 만진다. VM 이 꺼져 있어도 돌고, 애초에 VM 이 존재하지
|
||
않아도 된다.
|
||
|
||
```bash
|
||
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` 를 부른다. 게스트를 만들지 않고 디스크만 손보고
|
||
싶을 때(골든 이미지, 오버레이 재생성) 이 도구를 직접 쓴다.
|
||
|
||
**확인**
|
||
|
||
```bash
|
||
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` 이 난다. 빼 주어야 한다.
|
||
|
||
```bash
|
||
virsh snapshot-create-as kc-lab-2 clean-k3s \
|
||
--diskspec vda,snapshot=internal --diskspec vdb,snapshot=no
|
||
```
|
||
|
||
**확인**
|
||
|
||
```bash
|
||
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 명세가 금지한다. 반드시
|
||
스페이스여야 한다. 에디터가 탭을 넣도록 설정돼 있으면 파일 전체가
|
||
파싱 실패한다. 눈으로는 구분이 안 되므로 다음으로 확인한다.
|
||
|
||
```bash
|
||
grep -Pn '\t' user-data.yaml # 아무것도 안 나와야 정상
|
||
```
|
||
|
||
2. **리스트 항목의 하위 키는 `-` 다음 컬럼에 맞춰 더 들여쓴다.**
|
||
|
||
```yaml
|
||
users:
|
||
- name: donghyeon # '-' 는 2칸
|
||
groups: [sudo] # 하위 키는 4칸 ('n' 과 같은 열)
|
||
shell: /bin/bash
|
||
```
|
||
|
||
`groups`를 `-`와 같은 열에 두면 리스트 항목 밖으로 빠져나가
|
||
구조가 깨진다.
|
||
|
||
3. **`NOPASSWD` 오타는 YAML을 통과하지만 게스트를 망가뜨린다.**
|
||
cloud-init은 이 문자열을 `/etc/sudoers.d/90-cloud-init-users`에
|
||
그대로 쓴다. `NOPASSD`처럼 잘못된 태그가 들어가면 sudoers 문법 오류가
|
||
되어 **그 게스트에서 sudo 전체가 동작하지 않는다.** k3s 설치가
|
||
시작조차 못 한다. YAML 검증기로는 잡히지 않는 종류의 오류다.
|
||
|
||
**디스크 확장(growpart)** — 클라우드 이미지의 파티션은 원본 크기(2GB 안팎)
|
||
그대로다. `--disk size=20`으로 20GB를 줘도 루트 파티션은 처음엔 2GB다.
|
||
cloud-init의 `growpart` 모듈이 첫 부팅에 파티션과 파일시스템을 디스크
|
||
끝까지 자동 확장한다. Debian 클라우드 이미지는 이 모듈이 기본 활성화라
|
||
따로 설정할 필요가 없다.
|
||
|
||
**반드시 비상 접근 수단을 남겨둘 것 (실제로 겪은 교훈)**
|
||
|
||
`ssh_pwauth: false` + 키 인증만 설정한 상태에서 cloud-init이 실패하면
|
||
**그 게스트에는 들어갈 방법이 전혀 없다.** 사용자가 생성되지 않았으니
|
||
키도 비밀번호도 없고, 콘솔에 붙어도 로그인할 수 없다. 즉
|
||
**실패 원인을 기록한 `/var/log/cloud-init.log`를 읽을 수가 없다.**
|
||
진단이 불가능해서 VM을 지우고 다시 만드는 것 외에 선택지가 없어진다.
|
||
|
||
콘솔 로그인용 비밀번호를 넣어두면 이 막다른 골목을 피할 수 있다.
|
||
`ssh_pwauth: false`는 그대로 둬도 된다 — 콘솔 로그인은 sshd가 아니라
|
||
로컬 PAM을 타므로 영향받지 않는다.
|
||
|
||
```yaml
|
||
users:
|
||
- name: donghyeon
|
||
lock_passwd: false
|
||
plain_text_passwd: __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`로 보이고
|
||
정상 인식된다.
|
||
|
||
```bash
|
||
# 1) 시드 ISO 를 직접 만든다 (virt-install 의 임시 ISO 에 의존하지 않는다)
|
||
mkdir -p seed-1
|
||
cp kc-lab-1.yaml seed-1/user-data
|
||
printf 'instance-id: kc-lab-1-001\nlocal-hostname: kc-lab-1\n' > seed-1/meta-data
|
||
xorrisofs -output seed-kc-lab-1.iso -volid CIDATA -joliet -rock \
|
||
seed-1/user-data seed-1/meta-data
|
||
|
||
# 2) libvirt 풀에 올린다 (홈이 700 이면 qemu 가 못 읽는다)
|
||
SZ=$(stat -c%s seed-kc-lab-1.iso)
|
||
virsh vol-create-as default seed-kc-lab-1.iso "$SZ" --format raw
|
||
virsh vol-upload --pool default seed-kc-lab-1.iso seed-kc-lab-1.iso
|
||
|
||
# 3) --cloud-init 대신 virtio 디스크로 붙인다
|
||
virt-install --name kc-lab-1 --memory 3584 --vcpus 2 \
|
||
--disk size=20,backing_store=/var/lib/libvirt/images/base.qcow2 \
|
||
--disk vol=default/seed-kc-lab-1.iso,device=disk,bus=virtio,readonly=on \
|
||
--network network=default,mac=52:54:00:aa:bb:11 \
|
||
--import --os-variant debian12 --noautoconsole
|
||
```
|
||
|
||
**성공 판정**
|
||
|
||
```bash
|
||
virsh domblklist kc-lab-1 # vdb 에 seed ISO 가 보여야 한다 (sda 가 아니라)
|
||
ssh kc-lab-1 'hostname; lsblk -o NAME,LABEL,FSTYPE | grep -i cidata'
|
||
# kc-lab-1
|
||
# vdb CIDATA iso9660
|
||
```
|
||
|
||
## 238. 시드 ISO 를 굽는 세 명령이 각각 하는 일
|
||
|
||
```bash
|
||
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 가 못 찾는다. 그래서 예전에는 스테이징
|
||
디렉터리에 규정된 이름으로 복사해서 구웠다.
|
||
|
||
```bash
|
||
# 예전 방식 — 스테이징 디렉터리가 필요했다
|
||
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-points` 는 **ISO 안 경로를 직접 지정**하게 해준다.
|
||
|
||
```
|
||
/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/images` 는 **root 소유**라 일반 사용자가 못 쓴다 |
|
||
| 읽기 | 홈에 두면 **홈이 `700` 이라 qemu(`libvirt-qemu` 사용자)가 못 읽는다** |
|
||
|
||
`virsh` 가 libvirtd 를 통해 대신 쓰므로 `sudo` 없이 된다. 그리고 **풀에
|
||
등록**되어 `virt-install --disk vol=default/seed-kc-lab-1.iso` 로 참조할 수
|
||
있게 된다.
|
||
|
||
### 다시 구울 때는 볼륨을 먼저 지운다
|
||
|
||
같은 이름의 볼륨이 이미 있으면 `vol-create-as` 가 실패한다.
|
||
|
||
```bash
|
||
virsh vol-delete --pool default seed-kc-lab-1.iso 2>/dev/null || true
|
||
```
|
||
|
||
**그리고 다시 구운 시드는 이미 떠 있는 게스트에 반영되지 않는다.**
|
||
cloud-init 은 per-instance 모듈을 `instance-id` 당 한 번만 돌린다. 그래서
|
||
`meta-data` 의 `instance-id` 에 타임스탬프를 넣어 새 인스턴스로 보이게 하고,
|
||
**게스트를 새로 만들어야** 효과가 있다.
|
||
|
||
**확인**
|
||
|
||
```bash
|
||
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-data`와 `meta-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 안의 경로를
|
||
직접 지정하면 복사본이 필요 없다.
|
||
|
||
```bash
|
||
xorrisofs -output seed-kc-lab-1.iso -volid CIDATA -joliet -rock -graft-points \
|
||
/user-data=kc-lab-1.yaml /meta-data=meta-kc-lab-1
|
||
```
|
||
|
||
**주의: 같은 내용이 세 곳에 존재한다** — 원본 YAML, ISO 안, 그리고 libvirt
|
||
풀에 업로드된 사본. **원본 YAML을 고쳐도 실행 중인 VM에는 아무 영향이 없다.**
|
||
ISO 재생성 → 풀 재업로드 → VM 재생성까지 해야 반영된다. 이 세 단계를
|
||
스크립트로 묶어두지 않으면 "고쳤는데 왜 안 바뀌지"로 시간을 잃는다.
|
||
|
||
**다른 선택지** — 시드를 SATA로 두고 싶다면 base 이미지를 `genericcloud`가
|
||
아니라 **`generic`** 변종으로 바꾸면 된다. 드라이버가 더 들어 있어 SATA를
|
||
인식한다. 대신 이미지가 커진다.
|
||
|
||
## 240. 진단 도구: `virsh screenshot`
|
||
|
||
**이 문제를 푼 결정적 도구다.** 게스트에 로그인할 수 없을 때 화면을
|
||
그대로 PNG로 떠서 볼 수 있다.
|
||
|
||
```bash
|
||
virsh screenshot kc-lab-1 /tmp/kc1.ppm # 확장자와 무관하게 PNG 로 저장된다
|
||
```
|
||
|
||
`localhost login:`이 보이면 cloud-init 미실행,
|
||
`kc-lab-1 login:`이면 실행됨. **hostname 한 줄이 곧 판정**이다.
|
||
`virsh console`은 tty를 요구하고 새 출력이 없으면 아무것도 안 보이지만,
|
||
screenshot은 현재 화면 상태를 항상 보여준다.
|
||
|
||
키 입력이 필요하면 `virsh send-key`로 보낼 수 있다.
|
||
|
||
```bash
|
||
for k in KEY_R KEY_O KEY_O KEY_T KEY_ENTER; do
|
||
virsh send-key kc-lab-1 --codeset linux "$k"
|
||
done
|
||
```
|
||
|
||
## 241. base 이미지가 무엇인지 확인하는 법
|
||
|
||
변종을 잘못 받았는지 의심될 때는 공식 체크섬과 대조하면 확실하다.
|
||
|
||
```bash
|
||
H=$(sha512sum /var/lib/libvirt/images/base.qcow2 | cut -d' ' -f1)
|
||
curl -sSL https://cloud.debian.org/images/cloud/bookworm/latest/SHA512SUMS \
|
||
| grep -i "^$H"
|
||
# -> debian-12-genericcloud-amd64.qcow2
|
||
```
|
||
|
||
**`--noautoconsole`의 대가** — 이 옵션을 주면 virt-install이 즉시 반환하고
|
||
`/var/lib/libvirt/boot/`의 임시 cloud-init ISO를 정리한다. 첫 부팅을
|
||
눈으로 확인할 수 없어서, cloud-init 성공 여부를 **SSH가 될 때까지 알 수 없다.**
|
||
시드 ISO를 위처럼 영구 볼륨으로 직접 관리하면 이 문제도 함께 사라진다.
|
||
콘솔에서 빠져나올 때는 `Ctrl + ]`.
|
||
|
||
**확인** (게스트 안에서)
|
||
|
||
```bash
|
||
cloud-init status --long # done 이어야 정상
|
||
sudo cat /var/log/cloud-init-output.log
|
||
sudo grep -iE 'error|warn|traceback' /var/log/cloud-init.log | head -30
|
||
cat /run/cloud-init/result.json
|
||
sudo blkid | grep -i cidata # NoCloud 시드 ISO 가 실제로 보였는지
|
||
df -h / # growpart 가 동작했는지 (20G 근처여야 함)
|
||
```
|
||
|
||
마지막에서 두 번째 줄이 핵심이다. `cidata` 레이블이 안 보이면 게스트가
|
||
user-data를 **아예 받지 못한 것**이고, 보이는데도 실패했다면 YAML 내용이나
|
||
모듈 실행 단계의 문제다. 원인 범위를 절반으로 줄여준다.
|
||
|
||
## 242. UEFI / OVMF (`edk2-ovmf`)
|
||
|
||
**무엇인가** — VM에 제공할 펌웨어. 기본값은 SeaBIOS(레거시 BIOS)이고,
|
||
OVMF는 UEFI 펌웨어 구현이다.
|
||
|
||
**왜 여기 나오나** — x86 generic 클라우드 이미지는 대개 BIOS로도 부팅되니
|
||
**필수는 아니다.** 다만 최근 클라우드(EC2 UEFI 부팅 모드 포함)와 Secure
|
||
Boot 환경을 흉내내려면 필요하고, UEFI 전용 이미지를 만나면 없으면 못 뜬다.
|
||
"깔아두면 손해 없는" 부류다.
|
||
|
||
**확인**
|
||
|
||
```bash
|
||
ls /usr/share/edk2/x64/OVMF_CODE.4m.fd # Arch 기준 경로
|
||
```
|
||
|
||
## 243. `--os-variant` / osinfo
|
||
|
||
**무엇인가** — 게스트 OS 종류를 libvirt에 알려주는 값. libvirt는 이걸로
|
||
적절한 가상 장치 모델(virtio 사용 여부, 디스크 버스, NIC 모델)을 고른다.
|
||
|
||
**왜 여기 나오나** — 잘못 주거나 생략하면 성능이 크게 떨어진다.
|
||
예를 들어 virtio 대신 e1000 에뮬레이션 NIC이 붙으면 네트워크 처리량이
|
||
몇 배 나빠지고, 그러면 우리가 측정하려는 노드 간 지연이 오염된다.
|
||
|
||
**확인**
|
||
|
||
```bash
|
||
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가 해결한다.
|
||
|
||
**확인**
|
||
|
||
```bash
|
||
ip -brief addr show virbr0
|
||
virsh net-dumpxml default
|
||
```
|
||
|
||
**`virbr0`이 `DOWN`으로 보이는 것은 정상이다** — 리눅스 브리지는
|
||
활성 포트가 하나도 붙어 있지 않으면 캐리어가 없는 것으로 간주되어
|
||
`DOWN`/`NO-CARRIER`로 표시된다. VM이 한 대라도 뜨면 그 VM의 `vnetN`
|
||
인터페이스가 브리지에 붙으면서 `UP`으로 바뀐다. IP(`192.168.122.1/24`)가
|
||
이미 할당되어 있다면 네트워크 정의 자체는 정상이다.
|
||
|
||
## 246. dnsmasq (libvirt 내장 DHCP/DNS)
|
||
|
||
**무엇인가** — 경량 DHCP + DNS 서버. libvirt가 `default` 네트워크마다
|
||
dnsmasq 인스턴스를 하나씩 띄워서 VM에 IP를 나눠주고 이름을 해석해준다.
|
||
|
||
**왜 여기 나오나** — 이 패키지가 없으면 **VM이 부팅은 되는데 IP를 못 받는다.**
|
||
증상이 "네트워크가 안 된다"로 나타나서 원인을 찾기 어렵다.
|
||
|
||
**확인**
|
||
|
||
```bash
|
||
ps aux | grep dnsmasq | grep virbr0
|
||
virsh net-dhcp-leases default # 실제로 나간 IP 목록
|
||
```
|
||
|
||
## 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 클라이언트로 두면 된다 |
|
||
|
||
**명령 분해**
|
||
|
||
```bash
|
||
virsh net-update default add ip-dhcp-host \
|
||
"<host mac='52:54:00:aa:bb:11' name='kc-lab-1' ip='192.168.122.11'/>" \
|
||
--live --config
|
||
```
|
||
|
||
| 토큰 | 의미 |
|
||
|---|---|
|
||
| `net-update` | 네트워크 정의 XML을 **부분 수정**. 전체를 편집기로 여는 `net-edit`과 달리 특정 섹션만 건드린다 |
|
||
| `default` | 대상 네트워크 이름 |
|
||
| `add` | 수행할 동작. 다른 값으로 `add-first`, `modify`, `delete` |
|
||
| `ip-dhcp-host` | 수정할 섹션. 네트워크 XML의 `<ip><dhcp><host>` 요소를 가리킨다 |
|
||
| `"<host .../>"` | 삽입할 XML 조각. `mac`=대상 식별, `ip`=줄 주소, `name`=dnsmasq DNS에 등록될 이름(선택) |
|
||
| `--live` | **실행 중인** 네트워크에 즉시 적용. libvirt가 dnsmasq 설정을 다시 쓰고 재로드시킨다 |
|
||
| `--config` | **영구 정의**(`/etc/libvirt/qemu/networks/default.xml`)에도 저장 |
|
||
|
||
**XML이 실제로 어떻게 바뀌나**
|
||
|
||
```xml
|
||
<!-- before -->
|
||
<ip address='192.168.122.1' netmask='255.255.255.0'>
|
||
<dhcp>
|
||
<range start='192.168.122.2' end='192.168.122.254'/>
|
||
</dhcp>
|
||
</ip>
|
||
|
||
<!-- after -->
|
||
<ip address='192.168.122.1' netmask='255.255.255.0'>
|
||
<dhcp>
|
||
<range start='192.168.122.2' end='192.168.122.254'/>
|
||
<host mac='52:54:00:aa:bb:11' name='kc-lab-1' ip='192.168.122.11'/>
|
||
<host mac='52:54:00:aa:bb:12' name='kc-lab-2' ip='192.168.122.12'/>
|
||
</dhcp>
|
||
</ip>
|
||
```
|
||
|
||
**부팅 시 실제로 일어나는 일**
|
||
|
||
1. VM 부팅 → 게스트 커널이 virtio NIC 인식 → DHCP 클라이언트가
|
||
`DHCPDISCOVER`를 브로드캐스트한다. 이 프레임의 출발지 MAC이
|
||
`52:54:00:aa:bb:11`이다.
|
||
2. `virbr0`에 붙어 있는 dnsmasq가 수신하고, 예약 테이블에서 그 MAC을 찾는다.
|
||
3. 매칭되면 동적 범위에서 아무 주소나 고르는 대신 `192.168.122.11`을
|
||
`DHCPOFFER`로 제시한다.
|
||
4. 게스트가 `DHCPREQUEST` → dnsmasq가 `DHCPACK`. 게스트에 그 IP가 적용된다.
|
||
리스가 만료되어 갱신할 때도 같은 규칙이 적용되므로 주소가 유지된다.
|
||
|
||
**가장 흔한 실패: MAC 불일치** — 예약의 `mac`과 VM 생성 시
|
||
`--network network=default,mac=52:54:00:aa:bb:11`의 값이 **정확히 같아야
|
||
한다.** 다르면 예약이 조용히 무시되고 동적 범위에서 아무 주소나 받는다.
|
||
오류 메시지가 없으므로 증상은 "왜 IP가 다르지?"로만 나타난다.
|
||
|
||
**동적 범위와의 겹침** — 현재 범위는 `.2`~`.254`라 예약 주소 `.11`, `.12`가
|
||
그 안에 들어간다. dnsmasq는 정적으로 예약된 주소를 다른 클라이언트에게
|
||
내주지 않으므로 **이대로도 정상 동작한다.** 더 방어적으로 가려면 범위를
|
||
`.100`~`.254`로 좁혀 예약 대역과 분리할 수 있다.
|
||
|
||
**`--live`가 실패할 때** — 네트워크가 비활성 상태면 `--live`는 쓸 수 없다.
|
||
그때는 `--config`만 주고 네트워크를 시작하면 된다.
|
||
|
||
**확인**
|
||
|
||
```bash
|
||
virsh net-dumpxml default | grep -A5 dhcp # <host> 항목이 들어갔는지
|
||
virsh net-dhcp-leases default # 실제로 나간 리스
|
||
ssh kc-lab-1 ip -brief addr # 게스트가 받은 주소
|
||
```
|
||
|
||
**삭제**
|
||
|
||
```bash
|
||
virsh net-update default delete ip-dhcp-host \
|
||
"<host mac='52:54:00:aa:bb:11'/>" --live --config
|
||
```
|
||
|
||
## 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 이더넷 어댑터를 꽂는 것이다.
|
||
|
||
**확인**
|
||
|
||
```bash
|
||
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에서 자동화도 된다.
|
||
|
||
```yaml
|
||
ssh_authorized_keys:
|
||
- <test-server 의 ~/.ssh/id_ed25519.pub> # 홉 2 자동화용
|
||
- <노트북의 ~/.ssh/id_ed25519_test_server.pub> # 노트북 직행용
|
||
```
|
||
|
||
**노트북에서 게스트로 직행하기** (`~/.ssh/config`)
|
||
|
||
```
|
||
Host kc-lab-1
|
||
HostName 192.168.122.11
|
||
User donghyeon
|
||
ProxyJump test-server
|
||
IdentityFile ~/.ssh/id_ed25519_test_server
|
||
```
|
||
|
||
`ProxyJump`는 test-server를 **터널로만** 쓰고 인증은 게스트와 직접 한다.
|
||
그래서 test-server에 개인키를 두지 않아도 노트북에서 게스트로 붙을 수 있다.
|
||
|
||
**`ssh-copy-id`를 쓸 수 없는 이유 (닭과 달걀)** — 보통은 서버를 만든 뒤
|
||
`ssh-copy-id`로 공개키를 밀어 넣는다. 그런데 클라우드 이미지에는
|
||
**비밀번호가 설정된 계정이 아예 없다.** 비밀번호 로그인이 불가능하므로
|
||
키를 밀어 넣을 최초의 통로 자체가 없다.
|
||
|
||
그래서 키는 **부팅 전에** 심어야 하고, 그것이 cloud-init의 존재 이유다.
|
||
`ssh_authorized_keys`는 게스트가 처음 부팅하는 순간 이미 적용되어 있다.
|
||
순서가 `키 생성 → cloud-init에 기입 → VM 생성`인 것은 이 제약 때문이다.
|
||
|
||
**게스트 재생성과 호스트 키 변경** — 실험 중 VM을 지우고 다시 만들면
|
||
게스트의 **호스트 키가 매번 새로 생성된다.** 같은 IP에 다른 호스트 키가
|
||
오므로 SSH가 중간자 공격으로 간주하고 접속을 거부한다.
|
||
|
||
```
|
||
WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!
|
||
```
|
||
|
||
자동화 스크립트가 여기서 멈춘다. 폐기 가능한 실험용 게스트에 한해
|
||
아래 설정으로 우회한다.
|
||
|
||
```
|
||
Host kc-lab-*
|
||
StrictHostKeyChecking no
|
||
UserKnownHostsFile /dev/null
|
||
```
|
||
|
||
**이 설정은 실험용 사설망 게스트에만 쓴다.** 호스트 키 검증을 끄는 것은
|
||
중간자 공격 탐지를 포기하는 것이므로, 실제 서버 대상으로는 절대 쓰지 않는다.
|
||
|
||
## 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)이면
|
||
동작은 하지만 본인이 수정할 수 없다. 소유권을 넘겨두는 편이 낫다.
|
||
|
||
```bash
|
||
sudo chown "$USER:$USER" ~/.ssh/config
|
||
chmod 600 ~/.ssh/config
|
||
```
|
||
|
||
**`LogLevel ERROR`을 넣는 이유** — `UserKnownHostsFile /dev/null`을 쓰면
|
||
접속할 때마다 `Warning: Permanently added ... to the list of known hosts.`가
|
||
출력된다. 스크립트 출력이 이 경고로 뒤덮이므로 함께 눌러둔다.
|
||
|
||
**확인 — `ssh -G`가 최종 판정이다**
|
||
|
||
```bash
|
||
ssh -G kc-lab-1
|
||
```
|
||
|
||
실제로 접속하지 않고 **모든 블록을 해석한 최종 설정값**을 출력한다.
|
||
`hostname`, `user`, `identityfile`, `stricthostkeychecking` 줄이
|
||
의도한 값인지 여기서 확인한다. 파일을 눈으로 읽는 것보다 정확하다.
|
||
|
||
**확인**
|
||
|
||
```bash
|
||
ssh -v donghyeon@192.168.122.11 2>&1 | grep -i 'offering\|accepted'
|
||
```
|
||
|
||
## 253. `/etc/hosts`와 이름 해석 순서
|
||
|
||
**무엇인가** — DNS에 물어보기 **전에** 먼저 참조하는 로컬 이름↔주소 매핑
|
||
파일. 조회 순서는 `/etc/nsswitch.conf`의 `hosts:` 줄이 정하며,
|
||
`files`가 곧 `/etc/hosts`다. 파일에서 답을 찾으면 DNS로 나가지 않는다.
|
||
|
||
**`127.0.0.1 localhost`가 필요한 이유** — `localhost`라는 이름은 DNS에
|
||
존재하지 않는다. 로컬 파일로만 해석된다. 그런데 수많은 소프트웨어가
|
||
`localhost`로 접속한다(JDBC URL, 헬스체크 스크립트, `curl localhost`,
|
||
프록시 대상).
|
||
|
||
`::1 localhost`만 있고 IPv4 줄이 없으면 **IPv6로만 해석된다.**
|
||
IPv4 소켓으로만 리스닝하는 서버에 `localhost`로 붙으면 `::1`로 시도하다
|
||
`Connection refused`가 난다. 반대 상황도 생긴다.
|
||
이 실패는 "ping은 되는데 접속이 안 된다"는 형태로 나타나서 진단이 오래 걸린다.
|
||
|
||
**`127.0.1.1 <호스트명>`이 필요한 이유** — 자기 자신의 호스트명이 해석
|
||
가능해야 하는 프로그램이 있다.
|
||
|
||
| 프로그램 | 해석 실패 시 |
|
||
|---|---|
|
||
| `sudo` | `unable to resolve host` 경고, 타임아웃만큼 느려짐 |
|
||
| `hostname -f` | FQDN 조회 실패 |
|
||
| Java `InetAddress.getLocalHost()` | 예외. **JGroups가 로컬 주소를 정할 때 이 경로를 탄다** |
|
||
|
||
마지막 줄이 이 실험대와 직결된다. Keycloak 클러스터링은 JGroups를 쓰고,
|
||
JGroups는 자기 주소를 결정해야 한다. 게스트에서 호스트명이 안 풀리면
|
||
클러스터 형성 단계에서 엉뚱한 오류가 난다.
|
||
|
||
**`127.0.0.1`이 아니라 `127.0.1.1`을 쓰는 이유** — 루프백 대역
|
||
(`127.0.0.0/8`) 안이지만 `localhost`와는 **구분되는** 주소를 쓰기 위해서다.
|
||
호스트명을 `127.0.0.1`에 직접 붙이면 `localhost`와 같은 주소가 되어,
|
||
호스트명으로 바인딩한 서비스가 의도치 않게 `localhost`로도 노출된다.
|
||
Debian 계열의 관례이며 Arch에서도 같은 이유로 유용하다.
|
||
|
||
**게스트에서는 cloud-init이 대신 해준다** — `cloud-init-*.yaml`에 넣은
|
||
`manage_etc_hosts: true`가 정확히 이 작업을 수행한다. 게스트의 `/etc/hosts`에
|
||
호스트명 매핑을 자동으로 써준다. **호스트(test-server)에는 cloud-init이
|
||
없으므로 직접 써야 한다.**
|
||
|
||
**최종 내용** (Arch 기본값 + 호스트명 한 줄)
|
||
|
||
```
|
||
# Static table lookup for hostnames.
|
||
# See hosts(5) for details.
|
||
127.0.0.1 localhost
|
||
::1 localhost
|
||
127.0.1.1 test-server
|
||
```
|
||
|
||
**확인**
|
||
|
||
```bash
|
||
grep '^hosts:' /etc/nsswitch.conf # 조회 순서
|
||
getent hosts localhost # 127.0.0.1 이 나와야 함
|
||
getent hosts "$(hostname)" # 127.0.1.1 이 나와야 함
|
||
```
|
||
|
||
`getent`는 실제 이름 해석 경로를 그대로 타므로 `ping`보다 정확한 확인이다.
|
||
|
||
---
|
||
|
||
## 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.conf` 에 `include` 를 직접 넣었다. 게스트는 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` |
|
||
|
||
**확인**
|
||
|
||
```bash
|
||
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_network` 의 `guest_input` 체인을 이렇게 끝낸다.
|
||
|
||
```
|
||
oif "virbr0" ip daddr 192.168.122.0/24 ct state established,related accept
|
||
oif "virbr0" reject
|
||
```
|
||
|
||
**게스트 대역으로 새로 들어오는 연결을 거절**한다. 그래서 우리 테이블
|
||
`lab_edge` 에 `priority filter - 10` 으로 먼저 `accept` 를 놔도 소용이 없다.
|
||
구멍은 **libvirt 체인 맨 앞에** 뚫어야 한다.
|
||
|
||
```bash
|
||
sudo nft insert rule ip libvirt_network guest_input \
|
||
oif virbr0 ip daddr 192.168.122.10 tcp dport '{80,443}' ct state new counter accept
|
||
```
|
||
|
||
`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` 에 넣어 재적용되게 한다.
|
||
|
||
**확인**
|
||
|
||
```bash
|
||
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.conf`](reverse-proxy-headers.md)는
|
||
`proxy_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`](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가 별개 제품인 것과
|
||
다른 점이다.
|
||
|
||
```nginx
|
||
http {
|
||
# L7 : TLS 종료 + X-Forwarded-* + 경로 라우팅 ← ALB 역할
|
||
}
|
||
|
||
stream {
|
||
# L4 : TCP 를 그대로 통과시킨다 ← NLB 역할
|
||
upstream k8s_api {
|
||
server 192.168.122.11:6443;
|
||
server 192.168.122.12:6443;
|
||
}
|
||
server {
|
||
listen 6443;
|
||
proxy_pass k8s_api;
|
||
}
|
||
}
|
||
```
|
||
|
||
`stream` 블록이 실제로 필요해지는 경우는 셋이다.
|
||
|
||
- k3s API 서버(6443)를 밖에서 접근 — 클라이언트 인증서 기반이라 TLS 통과 필수
|
||
- PostgreSQL(5432)·Redis(6379)를 게스트 밖에서 직접 관찰
|
||
- Keycloak mTLS 실험
|
||
|
||
| 자리 | 클라우드 | 이 실험대 |
|
||
|---|---|---|
|
||
| L7 진입 (TLS 종료·경로 라우팅) | ALB | 호스트 nginx `http {}` |
|
||
| L4 진입 (TCP 통과·IP 보존) | NLB | 호스트 nginx `stream {}` (아직 없음) |
|
||
| 클러스터 내 L7 라우팅 | ingress controller | Traefik |
|
||
|
||
**한 머신 안에서 nginx를 여러 개 띄우는 것은 의미가 없다**
|
||
|
||
nginx는 이미 **master 프로세스 1개 + worker N개** 구조다. worker들이 리스닝
|
||
소켓을 공유하며 CPU 코어 수만큼 병렬로 처리한다. 즉 프로세스 다중화는 이미
|
||
되어 있다. 그리고 같은 머신에 인스턴스를 늘려도 **그 머신이 죽으면 전부
|
||
죽는다.** 가용성은 전혀 늘지 않는다.
|
||
|
||
**진짜 이중화는 머신을 늘리는 것이고, 그러면 새 질문이 생긴다 —
|
||
"그럼 어느 nginx로 갈지는 누가 정하는가?"**
|
||
|
||
앞에 LB를 또 두면 그 LB가 SPOF다. **재귀가 끝나지 않는다.**
|
||
실무에서 이 재귀는 **소프트웨어가 아니라 네트워크 계층의 장치**로 끊는다.
|
||
|
||
| 방법 | 재귀를 끊는 원리 | 전환 시간 |
|
||
|---|---|---|
|
||
| **VIP + VRRP** (keepalived) | 선택자가 없다. **IP 자체가 이동**한다 | 1~3초 |
|
||
| **DNS 다중 A 레코드** | 클라이언트가 고른다. 실패 시 다음 IP로 재시도 | TTL 의존, 느림 |
|
||
| **애니캐스트 + BGP/ECMP** | 라우터가 가장 가까운 경로로 보낸다 | 즉시, 대규모 전용 |
|
||
| **클라우드 LB에 위임** | AWS가 내부적으로 다중 AZ로 이중화. 사용자는 DNS 이름만 받음 | 관리 불필요 |
|
||
|
||
**VRRP가 동작하는 방식** — 가장 흔한 온프레미스 답이다.
|
||
|
||
```
|
||
VIP 192.168.0.100 (가상 IP, 한 번에 한 대만 보유)
|
||
│
|
||
┌───────┴───────┐
|
||
│ │
|
||
nginx-1 nginx-2
|
||
MASTER BACKUP
|
||
(VIP 보유) (대기, MASTER 생존 신호를 감시)
|
||
|
||
MASTER 사망 → BACKUP 이 VIP 를 가져가고
|
||
gratuitous ARP 를 브로드캐스트
|
||
→ 스위치의 MAC 테이블이 갱신됨
|
||
→ 같은 IP 인데 트래픽이 다른 장비로 흐른다
|
||
```
|
||
|
||
**핵심은 "선택하는 주체가 없다"는 점이다.** 클라이언트는 계속 같은 IP로
|
||
접속하고, 그 IP가 어느 장비에 붙어 있는지가 바뀔 뿐이다. L2 계층의 ARP를
|
||
이용해 재귀를 끊는다.
|
||
|
||
**클라우드가 편한 이유가 여기 있다.** ALB/NLB는 내부적으로 여러 AZ에
|
||
이중화되어 있고, 사용자는 DNS 이름 하나만 받는다. **재귀를 AWS가 대신
|
||
풀어준 것**이지 재귀가 없는 것이 아니다.
|
||
|
||
**이 실험대에서는 하지 않는다.** 물리 머신이 한 대라 keepalived를 구성해도
|
||
그 머신이 죽으면 끝이라 의미가 없고, 검증 대상은 Keycloak의 세션·토큰이지
|
||
LB 가용성이 아니다. 다만 **Traefik은 이미 두 노드에 떠 있으므로**
|
||
"노드 하나를 죽이고 호스트 nginx의 upstream이 어떻게 반응하는지"는
|
||
그대로 관찰할 수 있다. 그것이 이 실험대가 다루는 범위다.
|
||
|
||
## 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을 가리킨다.
|
||
|
||
**확인**
|
||
|
||
```bash
|
||
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.4` 는
|
||
`100.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.md`](guides/04-tls/README.md) 는
|
||
> `certbot certonly --webroot`(HTTP-01)로 적혀 있고 전제도 「공개 DNS 에
|
||
> 이름 셋이 이 호스트를 가리켜야 한다」이다. 지금 DNS 는 tailnet 주소를
|
||
> 가리키므로 **그 전제는 성립하지 않는다.** 어느 쪽이 실제인지는 아래로
|
||
> 확인한다.
|
||
|
||
**확인**
|
||
|
||
```bash
|
||
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(= 서로 다른 커널)에 두는 것이 "진짜 노드 상실"과
|
||
"노드 간 방화벽" 실험의 전제 조건이다.
|
||
|
||
**확인**
|
||
|
||
```bash
|
||
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로 바꿔야 한다.
|
||
|
||
```bash
|
||
mkdir -p ~/.kube # 이 줄을 빠뜨리면 아래가 실패한다
|
||
ssh kc-lab-1 'sudo cat /etc/rancher/k3s/k3s.yaml' \
|
||
| sed 's/127.0.0.1/192.168.122.11/' > ~/.kube/config
|
||
chmod 600 ~/.kube/config
|
||
```
|
||
|
||
**이 명령은 호스트(test-server)에서 실행한다.** 게스트 안에서 실행하면
|
||
`ssh kc-lab-1` 부분이 자기 자신에게 다시 접속하는 꼴이 되고, 애초에
|
||
**server 게스트**에서는 `sudo kubectl`이 `/etc/rancher/k3s/k3s.yaml`을
|
||
자동으로 집으므로 kubeconfig가 따로 필요 없다. **agent 게스트는 다르다** —
|
||
아래 「agent 노드에는 kubeconfig가 없다」를 본다.
|
||
|
||
**리다이렉션은 셸이 명령보다 먼저 처리한다** — 자주 걸리는 함정이다.
|
||
|
||
```
|
||
sudo cat 원본 > ~/.kube/config
|
||
└──┬──┘ └─────┬─────┘
|
||
│ │
|
||
│ └─ ① 셸이 먼저 이 파일을 연다 (현재 사용자 권한으로)
|
||
└─ ② 그 다음에야 명령이 실행된다
|
||
```
|
||
|
||
그래서 `~/.kube` 디렉터리가 없으면 `cat`이 시작되기도 전에
|
||
`No such file or directory`로 끝난다. **`>`는 파일을 열 뿐 경로를 만들지
|
||
않는다.** 같은 이유로, `sudo`를 붙였는데도 출력 파일 쓰기가 거부되는
|
||
현상이 생긴다 — `sudo`는 `cat`에만 적용되고 `>`에는 적용되지 않기 때문이다.
|
||
그럴 때는 `sudo tee`를 쓴다.
|
||
|
||
```bash
|
||
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로 복사해 넣지 않는다.**
|
||
|
||
**어디서 치나** — 셋 중 하나다.
|
||
|
||
```bash
|
||
# ① 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'
|
||
```
|
||
|
||
**확인**
|
||
|
||
```bash
|
||
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:80`과
|
||
`192.168.122.12:80` **양쪽 모두에서** 응답한다. 그래서 호스트 nginx의
|
||
`upstream`에 VM 두 대를 그냥 나열하면 된다. 별도 LB 구성이 필요 없다.
|
||
|
||
**확인**
|
||
|
||
```bash
|
||
kubectl -n kube-system get svc traefik # EXTERNAL-IP에 노드 IP들이 뜸
|
||
kubectl -n kube-system get ds # svclb-* DaemonSet
|
||
```
|
||
|
||
## 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`
|
||
로 확인하라고 했는데 **틀렸다.** 내장 구현이라 별도 파드로 뜨지 않는다.
|
||
올바른 확인은 아래와 같다.
|
||
|
||
```bash
|
||
# 1) 비활성화 플래그가 걸려 있지 않은지
|
||
sudo grep -i 'disable-network-policy' /etc/systemd/system/k3s.service
|
||
|
||
# 2) 실제로 강제되는지 — 테스트 정책을 적용해보는 것이 확실하다
|
||
kubectl create ns netpol-test
|
||
kubectl -n netpol-test apply -f - <<'EOF'
|
||
apiVersion: networking.k8s.io/v1
|
||
kind: NetworkPolicy
|
||
metadata:
|
||
name: deny-all
|
||
spec:
|
||
podSelector: {}
|
||
policyTypes: [Ingress]
|
||
EOF
|
||
# 이 네임스페이스의 파드로 들어가는 트래픽이 막히면 컨트롤러가 동작 중
|
||
```
|
||
|
||
**왜 여기 나오나** — JGroups 7800 포트만 골라서 막는 실험을 nftables가
|
||
아니라 NetworkPolicy로 하면, **운영에서 쓸 방식 그대로** 검증하게 된다.
|
||
|
||
## 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를 따로 걸지 않으면 한 네임스페이스가 노드 메모리를 다 먹을 수 있다.
|
||
|
||
```bash
|
||
kubectl get ns
|
||
kubectl -n header-lab get all
|
||
```
|
||
|
||
### Deployment · ReplicaSet · Pod
|
||
|
||
**계층 구조** — 세 개가 자동으로 얹혀 만들어진다.
|
||
|
||
```
|
||
Deployment "echo 를 2개 유지하고, 바뀌면 무중단으로 교체하라"
|
||
│ 생성
|
||
ReplicaSet "이 템플릿의 파드를 정확히 2개 유지하라" (버전마다 하나씩)
|
||
│ 생성
|
||
Pod 실제로 도는 컨테이너 묶음
|
||
```
|
||
|
||
**Deployment를 직접 쓰는 이유** — 파드를 직접 만들면 죽었을 때 아무도
|
||
되살리지 않는다. **노드를 죽이는 실험을 하는데 파드가 안 살아나면 실험이
|
||
안 된다.** ReplicaSet은 Deployment가 알아서 만들므로 손댈 일이 없다.
|
||
|
||
**롤아웃** — 이미지나 env를 바꾸면 Deployment가 **새 ReplicaSet을 만들고**
|
||
파드를 점진 교체한다. 이전 ReplicaSet은 0개로 줄어든 채 남아 롤백 경로가 된다.
|
||
|
||
```bash
|
||
kubectl -n header-lab get deploy,rs,pods
|
||
kubectl -n header-lab rollout status deployment/echo
|
||
kubectl -n header-lab rollout undo deployment/echo # 직전 버전으로
|
||
```
|
||
|
||
### 라벨과 셀렉터 — 쿠버네티스의 근본 관용구
|
||
|
||
```yaml
|
||
spec:
|
||
selector:
|
||
matchLabels:
|
||
app: echo # ← 이 라벨을 가진 파드를 내 것으로 삼는다
|
||
template:
|
||
metadata:
|
||
labels:
|
||
app: echo # ← 만들어질 파드에 붙는 라벨
|
||
```
|
||
|
||
**쿠버네티스는 리소스를 이름이 아니라 라벨로 연결한다.** Deployment도,
|
||
Service도, NetworkPolicy도 전부 "이 라벨을 가진 파드"를 가리킨다.
|
||
느슨한 결합이라 파드가 몇 개든, 어느 노드든 상관없이 성립한다.
|
||
|
||
**틀리면** — `selector`와 `template.labels`가 어긋나면 Deployment가 자기가
|
||
만든 파드를 자기 것으로 인식하지 못하고 **무한히 새 파드를 만든다.**
|
||
Service의 `selector`가 어긋나면 엔드포인트가 비어 502가 난다.
|
||
|
||
```bash
|
||
kubectl -n header-lab get pods --show-labels
|
||
kubectl -n header-lab get endpoints echo # 비어 있으면 셀렉터 불일치
|
||
```
|
||
|
||
마지막 명령이 "Service는 있는데 502" 상황의 첫 확인 지점이다.
|
||
|
||
### `replicas: 2`와 `topologySpreadConstraints`
|
||
|
||
```yaml
|
||
topologySpreadConstraints:
|
||
- maxSkew: 1
|
||
topologyKey: kubernetes.io/hostname
|
||
whenUnsatisfiable: ScheduleAnyway
|
||
labelSelector:
|
||
matchLabels:
|
||
app: echo
|
||
```
|
||
|
||
| 항목 | 의미 |
|
||
|---|---|
|
||
| `topologyKey` | 무엇을 기준으로 나눌지. `kubernetes.io/hostname`이면 **노드 단위** |
|
||
| `maxSkew: 1` | 그룹 간 개수 차이를 최대 1로 유지 → 2노드에 2개면 1:1 |
|
||
| `whenUnsatisfiable` | 만족 못 할 때 **`ScheduleAnyway`**(그래도 배치) / `DoNotSchedule`(대기) |
|
||
|
||
**왜 필요한가** — 두 파드가 한 노드에 몰리면 **호스트 nginx의 upstream 분배를
|
||
관찰할 수 없다.** 어느 노드로 보내든 같은 파드가 답하기 때문이다.
|
||
스티키 세션 실험도 성립하지 않는다.
|
||
|
||
**`ScheduleAnyway`를 고른 이유** — 한 노드를 죽이는 실험을 할 때
|
||
`DoNotSchedule`이면 남은 파드가 **배치되지 못하고 Pending에 머문다.**
|
||
장애 실험에서는 "그래도 뜨는" 쪽이 맞다.
|
||
|
||
```bash
|
||
kubectl -n header-lab get pods -o wide # NODE 열이 갈려야 한다
|
||
```
|
||
|
||
**파드 IP로도 노드를 알 수 있다.** flannel이 노드마다 `/24`를 하나씩 준다.
|
||
|
||
```
|
||
10.42.0.x → kc-lab-1
|
||
10.42.1.x → kc-lab-2
|
||
```
|
||
|
||
`/api/echo`가 돌려주는 `localAddr`이 이 파드 IP이므로, **응답만 보고 어느
|
||
노드가 처리했는지 알 수 있다.**
|
||
|
||
### 프로브 — readiness와 liveness는 하는 일이 다르다
|
||
|
||
가장 자주 혼동되는 항목이다.
|
||
|
||
| | readinessProbe | livenessProbe |
|
||
|---|---|---|
|
||
| 질문 | "지금 **트래픽을 받을 수 있나**" | "이 프로세스가 **살아 있나**" |
|
||
| 실패하면 | Service 엔드포인트에서 **제외**. 파드는 계속 돈다 | 컨테이너를 **죽이고 재시작** |
|
||
| 용도 | 기동 중, 일시적 과부하, 의존성 끊김 | 데드락, 응답 불능 |
|
||
|
||
**둘을 같게 설정하면 위험하다.** 일시적으로 느려졌을 뿐인데 liveness가
|
||
재시작을 걸면, 부하가 몰린 상황에서 **재시작 폭풍**이 일어난다.
|
||
그래서 liveness의 `initialDelaySeconds`와 주기를 readiness보다 넉넉히 준다
|
||
(여기서는 45초 / 15초 대 15초 / 5초).
|
||
|
||
Spring Boot는 `management.endpoint.health.probes.enabled: true`일 때
|
||
`/actuator/health/readiness`와 `/actuator/health/liveness`를 따로 노출한다.
|
||
[`backend/src/main/resources/application.yml`](../backend/src/main/resources/application.yml)에
|
||
이미 켜져 있다.
|
||
|
||
```bash
|
||
kubectl -n header-lab describe pod <파드명> | grep -A3 -E 'Readiness|Liveness'
|
||
```
|
||
|
||
### `resources` — requests와 limits의 역할이 다르다
|
||
|
||
```yaml
|
||
resources:
|
||
requests: { memory: 320Mi, cpu: 100m }
|
||
limits: { memory: 512Mi }
|
||
```
|
||
|
||
| | requests | limits |
|
||
|---|---|---|
|
||
| 쓰이는 곳 | **스케줄러**가 배치할 노드를 고를 때 | **커널**이 실행 중 강제할 때 |
|
||
| 메모리 초과 | — | **OOMKilled** (컨테이너 강제 종료) |
|
||
| CPU 초과 | — | 스로틀링 (죽지는 않음) |
|
||
|
||
**`cpu: 100m`의 `m`은 milli-core다.** `1000m` = 1코어. `100m`은 0.1코어.
|
||
|
||
**limits를 안 주면** 한 파드가 노드 메모리를 다 먹고 **다른 파드까지
|
||
말려든다.** RAM 3584M / 2560M짜리 게스트에서 이건 현실적인 위험이다.
|
||
|
||
**CPU limit을 일부러 안 걸었다** — CPU 스로틀링은 지연을 만드는데,
|
||
이 실험대는 **타이밍(refresh token 경쟁, 세션 복제 지연)을 측정**하므로
|
||
인위적 스로틀링이 결과를 오염시킨다.
|
||
|
||
```bash
|
||
kubectl -n header-lab top pods # 실제 사용량
|
||
kubectl -n header-lab describe pod <파드명> | grep -i -A2 'Last State' # OOMKilled 확인
|
||
```
|
||
|
||
### `JAVA_TOOL_OPTIONS: -XX:MaxRAMPercentage=70`
|
||
|
||
**문제** — JVM은 기본적으로 **호스트 전체 메모리**를 보고 힙 크기를 정한다.
|
||
컨테이너 메모리 limit이 512Mi인데 게스트 RAM이 3584M이면, JVM이 그것을
|
||
기준으로 힙을 잡았다가 **limit을 넘겨 OOMKilled**된다.
|
||
|
||
**해결** — 최신 JVM은 cgroup limit을 인식하지만, 비율을 명시하는 편이 확실하다.
|
||
`MaxRAMPercentage=70`이면 512Mi의 70%인 약 358Mi를 힙 상한으로 삼고, 나머지를
|
||
메타스페이스·스레드 스택·네이티브 메모리에 남긴다.
|
||
|
||
**`-Xmx`가 아니라 백분율을 쓰는 이유** — limit을 바꿀 때마다 `-Xmx`를 같이
|
||
고쳐야 하는 이중 관리를 피한다.
|
||
|
||
```bash
|
||
kubectl -n header-lab exec deploy/echo -- java -XX:+PrintFlagsFinal -version 2>/dev/null | grep -i maxheapsize
|
||
```
|
||
|
||
### 포트에 이름 붙이기
|
||
|
||
```yaml
|
||
ports:
|
||
- containerPort: 8081
|
||
name: http # ← 이름
|
||
...
|
||
readinessProbe:
|
||
httpGet:
|
||
port: http # ← 숫자 대신 이름으로 참조
|
||
...
|
||
# Service
|
||
targetPort: http
|
||
```
|
||
|
||
**왜** — 포트 번호를 한 곳에서만 관리하기 위해서다. 8081을 바꿔야 할 때
|
||
`containerPort` 한 줄만 고치면 프로브와 Service가 따라온다. 숫자를 여기저기
|
||
적어두면 한 군데를 빠뜨려 조용히 깨진다.
|
||
|
||
### Service
|
||
|
||
```yaml
|
||
spec:
|
||
selector: { app: echo }
|
||
ports:
|
||
- port: 8081 # Service 가 여는 포트
|
||
targetPort: http # 파드 쪽 포트(이름)
|
||
```
|
||
|
||
**무엇인가** — 파드 집합에 **고정된 이름과 가상 IP(ClusterIP)** 를 준다.
|
||
파드는 죽고 다시 뜨며 IP가 매번 바뀌지만, Service 이름은 바뀌지 않는다.
|
||
클러스터 안에서는 `echo.header-lab.svc.cluster.local`로 접근한다.
|
||
|
||
**타입을 안 적으면 `ClusterIP`가 기본이다** — 클러스터 내부에서만 접근 가능.
|
||
외부 노출은 Ingress가 담당하므로 이게 맞다.
|
||
|
||
**부하 분산 방식** — kube-proxy가 iptables/IPVS 규칙으로 **무작위 분배**한다.
|
||
**세션 어피니티는 기본적으로 없다.** 필요하면
|
||
`spec.sessionAffinity: ClientIP`를 주지만, 프록시 뒤에서는 모든 요청의
|
||
출발지가 Traefik이라 사실상 무의미하다. **스티키는 호스트 nginx 층에서
|
||
거는 것이 맞다.**
|
||
|
||
```bash
|
||
kubectl -n header-lab get svc
|
||
kubectl -n header-lab get endpoints echo # 파드 IP 목록이 채워져야 정상
|
||
```
|
||
|
||
### Ingress
|
||
|
||
```yaml
|
||
spec:
|
||
ingressClassName: traefik
|
||
rules:
|
||
- host: app1.hyeonworks.com
|
||
http:
|
||
paths:
|
||
- path: /api
|
||
pathType: Prefix
|
||
backend:
|
||
service: { name: echo, port: { number: 8081 } }
|
||
```
|
||
|
||
| 항목 | 의미 |
|
||
|---|---|
|
||
| `ingressClassName` | **어느 컨트롤러가 이 규칙을 처리할지.** k3s 기본은 `traefik` |
|
||
| `host` | HTTP `Host` 헤더가 이 값일 때만 매칭 |
|
||
| `path` + `pathType` | 경로 매칭 |
|
||
| `backend` | 어느 Service의 어느 포트로 보낼지 |
|
||
|
||
**`pathType` 세 가지**
|
||
|
||
| 값 | 매칭 |
|
||
|---|---|
|
||
| `Prefix` | 경로 세그먼트 단위 접두사. `/api`는 `/api`, `/api/echo`에 매칭되고 `/apifoo`에는 안 된다 |
|
||
| `Exact` | 완전 일치만 |
|
||
| `ImplementationSpecific` | 컨트롤러 재량. 이식성이 없으므로 피한다 |
|
||
|
||
**`ingressClassName`을 빼면** 기본 IngressClass가 지정돼 있지 않은 한
|
||
**어느 컨트롤러도 이 규칙을 집지 않는다.** 리소스는 생성되는데 트래픽이
|
||
흐르지 않고 오류도 없다.
|
||
|
||
**`host`가 중요한 이유** — 호스트 nginx가 `proxy_set_header Host $host`로
|
||
원래 호스트명을 그대로 넘기기 때문에, Traefik이 그 값으로 이 규칙을 찾는다.
|
||
nginx가 Host를 자기 것으로 덮어쓰면 **여기서 404가 난다.**
|
||
지금 보이는 404가 정상 신호인 것도 같은 원리다 — 규칙이 없으면 404다.
|
||
|
||
```bash
|
||
kubectl -n header-lab get ingress
|
||
kubectl -n header-lab describe ingress echo
|
||
kubectl -n kube-system logs -l app.kubernetes.io/name=traefik --tail=30
|
||
```
|
||
|
||
## 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 규칙에 걸린다 |
|
||
| 브리지 대역 | `docker0`가 `172.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 -
|
||
```
|
||
|
||
```bash
|
||
docker build -t keycloak-pattern-api:lab backend
|
||
docker save keycloak-pattern-api:lab \
|
||
| ssh test-server "ssh kc-lab-1 'sudo k3s ctr images import -'"
|
||
docker save keycloak-pattern-api:lab \
|
||
| ssh test-server "ssh kc-lab-2 'sudo k3s ctr images import -'"
|
||
```
|
||
|
||
**주의 세 가지**
|
||
|
||
1. **노드마다 따로 반입한다.** 스케줄러가 어느 노드에 배치할지 모른다.
|
||
한쪽에만 있으면 반대편에 배치될 때 실패한다.
|
||
2. **매니페스트에 `imagePullPolicy: Never`를 준다.** 없으면 로컬에 이미지가
|
||
있어도 레지스트리에서 당기려 시도하다 실패한다.
|
||
3. **`ctr`이 아니라 `k3s ctr`을 쓴다.** `k3s ctr`은 k3s의 containerd 소켓을
|
||
가리키는 래퍼다. 시스템에 별도 `ctr`이 있으면 다른 소켓을 보게 되어
|
||
"성공했는데 파드는 이미지를 못 찾는" 상태가 된다.
|
||
|
||
**ssh가 두 번 중첩되는 이유** — 게스트가 lab host의 libvirt NAT 뒤에 있어서
|
||
워크스테이션에서 직접 접속할 수 없다. lab host의 `~/.ssh/config`에 있는
|
||
`kc-lab-*` 별칭을 거쳐야 한다.
|
||
|
||
**확인**
|
||
|
||
```bash
|
||
ssh test-server "ssh kc-lab-1 'sudo k3s ctr images ls -q | grep keycloak-pattern'"
|
||
kubectl -n header-lab get pods -o wide # ErrImageNeverPull 이면 반입 실패
|
||
```
|
||
|
||
---
|
||
|
||
## 283. 6층. Arch 특이사항
|
||
|
||
여기 있는 것만이 진짜 "Arch라서" 하는 일이다.
|
||
|
||
## 284. nginx 설정 구조 — `sites-available`은 nginx 기능이 아니다
|
||
|
||
**중요한 사실부터.** `sites-available` / `sites-enabled`는 **nginx의 기능이
|
||
아니라 Debian/Ubuntu 패키지 메인테이너가 만든 관례**다. nginx가 아는 것은
|
||
`include` 지시어 하나뿐이고, 나머지는 패키지가 미리 깔아둔 디렉터리 구조다.
|
||
|
||
**두 가지 관례가 있다**
|
||
|
||
| | `sites-available` + `sites-enabled` | `conf.d` |
|
||
|---|---|---|
|
||
| 출처 | Debian / Ubuntu 패키지 | nginx 업스트림, RHEL 계열 |
|
||
| include 줄 | `include /etc/nginx/sites-enabled/*;` | `include /etc/nginx/conf.d/*.conf;` |
|
||
| 켜기 | `sites-enabled`에 **심볼릭 링크** 생성 | `.conf` 확장자로 파일 배치 |
|
||
| 끄기 | 링크만 삭제 (원본은 보존) | 확장자 변경 (`.conf.disabled`) |
|
||
|
||
`sites-available` 방식의 목적은 **파일을 지우지 않고 껐다 켜는 것**이다.
|
||
원본은 `sites-available`에 그대로 두고 링크만 조작한다.
|
||
|
||
**Arch는 둘 다 만들어주지 않는다.** `/etc/nginx/nginx.conf` 한 파일이
|
||
전부이고 include 줄도 없다. 그래서 어느 쪽을 쓸지 **직접 정해서 만들어야
|
||
한다.** 처음 Arch에서 nginx를 다룰 때 "경로가 없다"고 당황하는 이유다.
|
||
|
||
```bash
|
||
grep -n 'include.*\(conf.d\|sites-enabled\)' /etc/nginx/nginx.conf
|
||
ls -d /etc/nginx/sites-available /etc/nginx/conf.d 2>&1
|
||
```
|
||
|
||
**이 실험대는 `sites-available` 방식을 쓴다.** 운영(`desktop`)이 Ubuntu라
|
||
그 구조를 쓰고 있으므로, 설정을 운영으로 옮길 때 경로가 그대로 맞는 편이
|
||
낫다는 판단이다. nginx 동작에는 차이가 없다.
|
||
|
||
```bash
|
||
sudo mkdir -p /etc/nginx/sites-available /etc/nginx/sites-enabled
|
||
sudo sed -i 's|^http {|http {\n include /etc/nginx/sites-enabled/*;|' /etc/nginx/nginx.conf
|
||
```
|
||
|
||
**틀리면** — Debian 감각으로 `sites-enabled`에 파일을 넣었는데 include 줄이
|
||
없으면 **아무 일도 일어나지 않는다. 오류조차 나지 않는다.**
|
||
`nginx -T`(대문자)로 최종 병합된 설정을 출력해 내 파일이 실제로 들어갔는지
|
||
확인하는 것이 확실하다.
|
||
|
||
```bash
|
||
sudo nginx -T | grep -n 'server_name\|upstream'
|
||
```
|
||
|
||
**Arch 기본 nginx.conf에는 자체 `server` 블록이 있다** (38~80줄 부근,
|
||
`listen 80; server_name localhost;`). 지우지 않아도 된다. 내 블록에
|
||
`listen 80 default_server;`를 주면 명시적 지정이 암묵적 기본값을 이긴다.
|
||
(`default_server`를 **두 블록에** 주면 그때는 오류가 난다.)
|
||
|
||
## 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를 쓴다면
|
||
실험 기간에는 꺼두는 것이 맞다.
|
||
```bash
|
||
sudo systemctl disable --now unattended-upgrades
|
||
```
|
||
2. **AppArmor** — Ubuntu는 기본 활성이다. 컨테이너 런타임이나 Keycloak의
|
||
파일 접근이 원인 모르게 거부될 때 의심 대상이 하나 늘어난다.
|
||
Debian에서는 이 변수가 없다.
|
||
3. **snap** — Ubuntu의 snap 패키지는 자동 갱신된다. 이것도 재현성의 적이다.
|
||
|
||
**k3s 관점에서는 둘 다 공식 지원**이며 설치 스크립트도 동일하다.
|
||
따라서 **선택 기준은 "운영 환경과 같은 것"뿐이다.** 기술적 우열이 아니라
|
||
게스트와 운영의 커널·systemd·기본 설정 차이에서 오는 잡음을 없애는 것이
|
||
VM을 쓰는 이유 중 하나였기 때문이다.
|
||
|
||
**Ubuntu로 교체하는 방법** (k3s 설치 전이라면 10분이면 된다)
|
||
|
||
```bash
|
||
sudo curl -L -o /var/lib/libvirt/images/base-ubuntu.qcow2 \
|
||
https://cloud-images.ubuntu.com/releases/24.04/release/ubuntu-24.04-server-cloudimg-amd64.img
|
||
```
|
||
|
||
확장자가 `.img`지만 **내용은 qcow2**다. Ubuntu의 관례이며
|
||
`qemu-img info`로 확인하면 `file format: qcow2`가 나온다.
|
||
VM 재생성 시 `backing_store` 경로와 `--os-variant ubuntu24.04`만 바꾸면 되고,
|
||
**cloud-init YAML과 시드 ISO는 그대로 재사용**할 수 있다.
|
||
|
||
---
|
||
|
||
## 289. 7층. git
|
||
|
||
## 290. `.gitignore` 패턴 앵커링
|
||
|
||
**무엇인가** — 패턴에 슬래시가 어디 있느냐로 적용 범위가 달라진다.
|
||
|
||
| 패턴 | 매칭 범위 |
|
||
|---|---|
|
||
| `target/` | **모든 깊이**의 `target` 디렉터리 |
|
||
| `/target/` | 저장소 **루트**의 `target`만 |
|
||
| `backend/target/` | 루트 기준 그 경로 하나만 |
|
||
| `**/target/` | `target/`과 사실상 동일 (중복) |
|
||
|
||
**규칙** — 패턴 중간에 슬래시가 있으면 git은 그것을 **루트 기준 경로**로
|
||
간주하고 앵커링한다. 슬래시가 끝에만 있으면(디렉터리 표시) 앵커링하지
|
||
않고 모든 깊이에 적용한다.
|
||
|
||
**왜 여기 나오나** — 기존 `.gitignore`에 `backend/target/`이 있었는데,
|
||
나중에 생긴 `bff/target`과 `token-mediator/target`이 빠졌다.
|
||
`target/`으로 바꾸면 한 줄로 전부 커버된다.
|
||
|
||
## 291. 이미 추적 중인 파일은 무시되지 않는다
|
||
|
||
**무엇인가** — `.gitignore`는 **추적되지 않는 파일**에만 적용된다.
|
||
이미 인덱스에 들어간 파일은 패턴을 추가해도 계속 추적된다.
|
||
|
||
**해결** — `git rm -r --cached <경로>`로 인덱스에서만 제거한다
|
||
(작업 디렉터리 파일은 남는다).
|
||
|
||
**확인**
|
||
|
||
```bash
|
||
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번이 패키지 관리자의 존재 이유다.** 의존성은 사슬로 이어지고 충돌하기도
|
||
해서, 사람이 손으로 풀기 어렵다. 이 계산을 대신해주는 것이 `apt`와 `pacman`이다.
|
||
|
||
## 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 가 실제 설치
|
||
```
|
||
|
||
**`apt` 와 `dpkg` 의 역할 분담** — 자주 헷갈리는 지점이다.
|
||
|
||
| 도구 | 담당 |
|
||
|---|---|
|
||
| `apt` | 저장소 접근, 의존성 해결, 다운로드 |
|
||
| `dpkg` | 받아온 `.deb` 하나를 실제로 푸는 저수준 도구 |
|
||
|
||
그래서 `dpkg -i foo.deb`는 의존성을 해결하지 못하고 실패할 수 있다.
|
||
|
||
**`.deb` 파일의 정체** — `ar` 아카이브다. 마법이 없다.
|
||
|
||
```bash
|
||
ar t curl_7.88.1-10_amd64.deb
|
||
# debian-binary 포맷 버전
|
||
# control.tar.xz 메타데이터 + 설치 전/후 스크립트
|
||
# data.tar.xz 실제 파일들 (/usr/bin/curl 등)
|
||
```
|
||
|
||
## 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`로 관리) |
|
||
|
||
**그래서 미러가 성립한다.** 전 세계 수백 개 서버가 같은 내용을 복제해
|
||
배포할 수 있고, 어느 미러에서 받든 서명이 맞으면 정품이다. 미러 운영자가
|
||
파일을 바꿔치기해도 서명 검증에서 걸린다.
|
||
|
||
```bash
|
||
# 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`](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.conf`와 `Caddyfile`은 **동일한 결과**를 만든다. 공개 443에서 TLS를
|
||
종료하고 사설 네트워크의 `keycloak:8080`으로 평문 전달한다.
|
||
|
||
```
|
||
nginx Caddy
|
||
ssl_certificate …crt tls /etc/tls/tls.crt /etc/tls/tls.key
|
||
ssl_certificate_key …key
|
||
proxy_set_header Host $host header_up Host {host}
|
||
proxy_set_header X-Forwarded-* header_up X-Forwarded-*
|
||
```
|
||
|
||
**차이는 인증서 수명주기를 누가 관리하는가 하나뿐이다.**
|
||
|
||
| | nginx | Caddy |
|
||
|---|---|---|
|
||
| 발급·갱신 | **운영자**가 담당 (certbot 등) | **프록시가 ACME로 자동** |
|
||
| 설정 분량 | 많다 | 적다 |
|
||
| 통제력 | 세밀 | 자동화에 위임 |
|
||
|
||
**이 실험대는 nginx + certbot을 골랐다.** DNS-01 와일드카드가 필요했고,
|
||
인증서 발급 시점과 방식을 직접 통제해야 했기 때문이다.
|
||
|
||
**둘을 동시에 진입점으로 띄우지 않는다.** 같은 443을 두 프로세스가 잡을 수
|
||
없다. 예제가 둘인 것은 선택지를 보여주기 위해서다.
|
||
|
||
## 305. `tunnel/` — 채택하지 않은 이유를 남긴 자산
|
||
|
||
`cloudflared-config.yml`은 Cloudflare named tunnel 설정이다.
|
||
**아웃바운드 연결만 쓰므로 포트포워딩 없이 공개 HTTPS 이름을 얻는다.**
|
||
공유기를 건드릴 수 없는 환경에서 매력적인 선택지다.
|
||
|
||
```yaml
|
||
ingress:
|
||
- hostname: auth.example.test
|
||
service: http://reverse-proxy:8080 # ← 127.0.0.1 이 아니다
|
||
- service: http_status:404 # ← catch-all
|
||
```
|
||
|
||
- `service:`에 `127.0.0.1`을 쓰면 **cloudflared 컨테이너 자신**을 가리킨다.
|
||
Compose 서비스 DNS 이름을 써야 한다
|
||
- 마지막 catch-all은 알 수 없는 hostname을 404로 끝낸다. 없으면 오류가 난다
|
||
|
||
**그런데 이 실험대는 채택하지 않았다.** 이유가 실험의 성격과 맞물린다.
|
||
|
||
```
|
||
터널 사용 : 브라우저 → Cloudflare 엣지 → nginx → Traefik → Pod (3홉)
|
||
현재 구성 : 브라우저 → nginx → Traefik → Pod (2홉)
|
||
```
|
||
|
||
**Cloudflare 엣지가 TLS를 끊고 다시 맺으면서 홉이 하나 늘고**,
|
||
`CF-Connecting-IP` 같은 자체 헤더가 섞인다. 이 실험대가 측정하려는 것이
|
||
정확히 **`nginx → Traefik` 2홉의 forwarded 헤더 계약**이므로,
|
||
앞에 한 겹이 더 붙으면 **측정이 오염된다.**
|
||
|
||
그래서 tailnet 직결을 택했다. 조건이 바뀌어(예: 다른 회선으로 이전) 공개
|
||
접근이 필요해지면 이 파일이 그대로 쓰인다.
|
||
|
||
## 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 은
|
||
이것을 기본으로 주는 것이다.
|
||
|
||
```yaml
|
||
strategy:
|
||
rollingUpdate:
|
||
maxSurge: 0 # 새 파드를 먼저 띄우지 않는다
|
||
maxUnavailable: 1 # 한 번에 하나만
|
||
```
|
||
|
||
여기에 PodDisruptionBudget 까지 붙이면 순차 교체를 흉내 낼 수 있다.
|
||
**기본값 그대로 Deployment 를 쓰면 배포할 때마다 클러스터가 흔들린다.**
|
||
|
||
**★ 정정 — StatefulSet 은 유령 행을 막지 못한다.** 「이름이 안정적이니 같은
|
||
행을 덮어쓴다」는 설명은 **틀렸다.** 실측
|
||
(`docs/evidence/keycloak-multinode-cluster/01-cluster-formed.txt`)을 보면
|
||
기본키는 `address`(UUID)이고 `name` 은 `keycloak-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 인데 왜 하나씩 재시작하지」에서 막힌다.
|
||
|
||
```bash
|
||
kubectl -n keycloak-lab get sts keycloak \
|
||
-o jsonpath='{.spec.podManagementPolicy}{" "}{.spec.updateStrategy.type}{"\n"}'
|
||
```
|
||
|
||
**어디를 봐야 하는가** — 두 값이 각각 `Parallel` 과 `RollingUpdate` 다.
|
||
**다른 축이다.** 앞은 「만들 때」, 뒤는 「바꿀 때」를 정한다.
|
||
|
||
**DaemonSet을 node-exporter에 쓴 이유** — replica 수를 지정하지 않는다.
|
||
노드가 늘면 자동으로 늘고, 줄면 준다. **죽을 노드에도 반드시 있어야**
|
||
꺼지기 직전의 마지막 샘플이 남는다.
|
||
|
||
```bash
|
||
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에 머문다.
|
||
|
||
```yaml
|
||
strategy:
|
||
type: Recreate # 옛 파드를 먼저 내리고 새 파드를 띄운다
|
||
```
|
||
|
||
**k3s의 `local-path` 프로비저너 — 볼륨이 노드에 못박힌다**
|
||
|
||
```json
|
||
"nodeAffinity": {
|
||
"required": { "nodeSelectorTerms": [{
|
||
"matchExpressions": [{ "key": "kubernetes.io/hostname", "values": ["kc-lab-2"] }]
|
||
}]}
|
||
}
|
||
경로: /var/lib/rancher/k3s/storage/pvc-<uuid>_<ns>_<name>
|
||
```
|
||
|
||
**그 노드의 로컬 디스크에 디렉터리를 만드는 것이 전부**다. 따라서
|
||
**PVC를 쓰는 파드는 그 노드를 벗어날 수 없다.**
|
||
|
||
| 결과 | |
|
||
|---|---|
|
||
| 노드가 죽으면 | **파드가 다른 노드로 재배치되지 못한다** |
|
||
| 실험 관점 | **결함이 아니라 조건이다.** "DB가 있는 노드가 죽으면"이 의미를 갖는다 |
|
||
|
||
```bash
|
||
kubectl get pvc -A
|
||
kubectl get pv
|
||
kubectl get pv <name> -o jsonpath='{.spec.nodeAffinity}' | python3 -m json.tool
|
||
```
|
||
|
||
## 310. Secret — 감춰지지 않는다
|
||
|
||
```yaml
|
||
kind: Secret
|
||
type: Opaque
|
||
stringData:
|
||
POSTGRES_PASSWORD: lab-postgres-change-me
|
||
```
|
||
|
||
`stringData`는 평문으로 쓰고 쿠버네티스가 base64로 인코딩해 저장한다.
|
||
`data`는 직접 base64로 넣는다.
|
||
|
||
**base64는 암호화가 아니라 인코딩이다.**
|
||
|
||
```bash
|
||
kubectl -n keycloak-lab get secret keycloak-lab-secrets -o jsonpath='{.data.POSTGRES_PASSWORD}' | base64 -d
|
||
```
|
||
|
||
한 줄로 읽힌다. etcd에도 그대로 들어 있다.
|
||
|
||
| 그래도 Secret을 쓰는 이유 | |
|
||
|---|---|
|
||
| RBAC로 접근을 나눌 수 있다 | ConfigMap과 별도로 권한 관리 |
|
||
| 로그·`describe`에 값이 안 찍힌다 | 사고로 노출될 확률이 준다 |
|
||
| 볼륨·env 주입 방식이 표준화된다 | |
|
||
|
||
**진짜 보호는 별도 계층이다** — SealedSecret, 외부 KMS, 또는 클라우드
|
||
시크릿 매니저. 로드맵 11번의 주제다.
|
||
|
||
## 311. RBAC — ServiceAccount · ClusterRole · Binding
|
||
|
||
Prometheus가 쿠버네티스 API에 물어서 타깃을 찾으려면 **읽기 권한**이 필요하다.
|
||
|
||
```
|
||
ServiceAccount 파드가 쓰는 신원 (누구인가)
|
||
│
|
||
ClusterRoleBinding 신원과 권한을 잇는다
|
||
│
|
||
ClusterRole 무엇을 할 수 있는가 (리소스 × 동사)
|
||
```
|
||
|
||
```yaml
|
||
rules:
|
||
- apiGroups: [""]
|
||
resources: [nodes, nodes/metrics, nodes/proxy, services, endpoints, pods]
|
||
verbs: [get, list, watch]
|
||
```
|
||
|
||
**`Role`과 `ClusterRole`의 차이** — `Role`은 한 네임스페이스 안에서만,
|
||
`ClusterRole`은 클러스터 전체에서 유효하다. 노드는 네임스페이스에 속하지
|
||
않으므로 **노드를 읽으려면 반드시 `ClusterRole`**이다.
|
||
|
||
**서브리소스가 따로 있다 — 실제로 걸린 함정**
|
||
|
||
`nodes`, `nodes/metrics`, `nodes/proxy`는 **서로 다른 권한**이다.
|
||
|
||
```
|
||
/api/v1/nodes/<name>/proxy/metrics
|
||
─────
|
||
이 경로에는 nodes/proxy 가 필요
|
||
```
|
||
|
||
`nodes/proxy`를 빠뜨렸을 때 kubelet 타깃만 **403 Forbidden**으로 실패하고
|
||
나머지 잡은 전부 정상이었다. **부분 실패라 `rollout status`는 성공이라고
|
||
말한다.** 타깃 목록을 직접 봐야 드러난다.
|
||
|
||
```bash
|
||
kubectl auth can-i get nodes/proxy --as=system:serviceaccount:observability:prometheus
|
||
kubectl describe clusterrole prometheus
|
||
```
|
||
|
||
## 312. 배치 제어 — nodeSelector · 라벨 · taint
|
||
|
||
```yaml
|
||
nodeSelector:
|
||
node-role.kubernetes.io/control-plane: "true"
|
||
```
|
||
|
||
**호스트 이름 대신 역할 라벨을 쓴다.** `kubernetes.io/hostname: kc-lab-1`로
|
||
못박으면 노드 이름이 바뀔 때 깨지고, **왜 거기 두는지가 드러나지 않는다.**
|
||
|
||
k3s는 server 노드에 `node-role.kubernetes.io/control-plane=true`를 붙인다.
|
||
|
||
```bash
|
||
kubectl get nodes --show-labels
|
||
kubectl get nodes -l node-role.kubernetes.io/control-plane=true
|
||
```
|
||
|
||
**taint와 toleration**
|
||
|
||
| | |
|
||
|---|---|
|
||
| **taint** | 노드에 붙는 "여기 오지 마" 표시 |
|
||
| **toleration** | 파드가 갖는 "그래도 갈 수 있음" 면제권 |
|
||
|
||
```yaml
|
||
tolerations:
|
||
- operator: Exists # 어떤 taint 든 무시한다
|
||
```
|
||
|
||
node-exporter에 이걸 주는 이유는 **관측이 빠지는 노드가 있으면 안 되기**
|
||
때문이다. taint가 걸린 노드에서도 떠야 한다.
|
||
|
||
**배치를 정하는 세 수단의 차이**
|
||
|
||
| 수단 | 성격 |
|
||
|---|---|
|
||
| `nodeSelector` | **반드시** 그 라벨의 노드에 |
|
||
| `topologySpreadConstraints` | **골고루** 퍼뜨린다 |
|
||
| taint / toleration | 노드가 **거부**하고 파드가 **면제**받는다 |
|
||
|
||
## 313. k3s server와 agent — 죽였을 때가 다르다
|
||
|
||
```bash
|
||
kubectl get nodes -o custom-columns=\
|
||
'NODE:.metadata.name,CP:.metadata.labels.node-role\.kubernetes\.io/control-plane'
|
||
```
|
||
|
||
| | kc-lab-1 (**server**) | kc-lab-2 (**agent**) |
|
||
|---|---|---|
|
||
| 실행 | API 서버 · 스케줄러 · etcd(SQLite) | kubelet · containerd |
|
||
| 이 실험대에서 | keycloak-1 · traefik · **coredns** · metrics-server · local-path-provisioner | keycloak-0 · postgres |
|
||
| 죽이면 | **`kubectl`이 안 된다. DNS·인그레스도 사라진다** | 클러스터 제어는 살아 있다 |
|
||
|
||
**노드 상실 실험은 agent를 죽이는 것이다.** server를 죽이는 것은 노드 상실이
|
||
아니라 **컨트롤 플레인 상실**이며 성격이 완전히 다르다.
|
||
|
||
이 사실을 모르고 "keycloak 하나만 있는 노드를 죽이자"고 계획했다가
|
||
실제 배치를 조회한 뒤 정정했다.
|
||
|
||
---
|
||
|
||
## 314. 11층. Keycloak 클러스터링 내부 — Infinispan과 JGroups
|
||
|
||
## 315. 두 층으로 되어 있다
|
||
|
||
```
|
||
Infinispan 분산 캐시. "세션을 어디에 두고 어떻게 복제할까"
|
||
│
|
||
JGroups 그룹 통신. "누가 멤버이고 어떻게 메시지를 주고받을까"
|
||
│
|
||
TCP 7800 실제 소켓
|
||
```
|
||
|
||
Keycloak은 Infinispan을 쓰고, Infinispan은 JGroups 위에서 돈다.
|
||
로그의 `org.infinispan.CLUSTER`와 `vendor_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-1` → `keycloak-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` | 노드가 합류했다 |
|
||
|
||
```bash
|
||
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`](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. 서비스 디스커버리 — 타깃을 적어두지 않는다
|
||
|
||
```yaml
|
||
kubernetes_sd_configs:
|
||
- role: endpoints
|
||
namespaces: { names: [keycloak-lab] }
|
||
```
|
||
|
||
**파드 IP는 재시작마다 바뀐다.** 실험대를 전원 종료했다 켜니 모든 파드가
|
||
새 주소를 받았다(`10.42.1.22` → `10.42.1.25`). 정적 목록은 그때마다 깨진다.
|
||
|
||
`role`에 따라 무엇을 찾을지가 달라진다.
|
||
|
||
| role | 찾는 것 |
|
||
|---|---|
|
||
| `endpoints` | 서비스 뒤의 실제 파드들 ← 애플리케이션 지표 |
|
||
| `node` | 노드 |
|
||
| `pod` | 파드 직접 |
|
||
| `service` | 서비스 |
|
||
|
||
## 326. relabel — 걸러내고 이름을 붙인다
|
||
|
||
디스커버리는 **전부 다** 가져온다. 그중 필요한 것만 남기는 것이 relabel이다.
|
||
|
||
```yaml
|
||
relabel_configs:
|
||
- source_labels: [__meta_kubernetes_service_name, __meta_kubernetes_endpoint_port_name]
|
||
action: keep
|
||
regex: keycloak-headless;management
|
||
- source_labels: [__meta_kubernetes_pod_name]
|
||
target_label: pod
|
||
```
|
||
|
||
| `action` | 하는 일 |
|
||
|---|---|
|
||
| `keep` | regex에 맞는 것만 남긴다 |
|
||
| `drop` | 맞는 것을 버린다 |
|
||
| `replace` (기본) | 라벨 값을 만든다 |
|
||
| `labelmap` | 메타 라벨을 일반 라벨로 복사 |
|
||
|
||
**`__`로 시작하는 라벨은 내부용**이며 저장되지 않는다. `__meta_*`는
|
||
디스커버리가 붙여준 정보이고, 필요하면 `target_label`로 옮겨야 남는다.
|
||
|
||
**`pod`과 `node` 라벨을 붙이는 것이 실험에서 결정적이다.** 없으면
|
||
"어느 파드가, 어느 노드에서"에 답할 수 없다.
|
||
|
||
## 327. 메트릭 타입
|
||
|
||
| 타입 | 성질 | 예 |
|
||
|---|---|---|
|
||
| **counter** | **누적. 줄지 않는다** (재시작 시 0으로) | `..._requests_total` |
|
||
| **gauge** | 오르내린다 | `node_memory_MemAvailable_bytes` |
|
||
| **histogram** | 구간별 분포 + 합계 + 개수 | `..._seconds_bucket/_sum/_count` |
|
||
| summary | 분위수를 클라이언트가 계산 | |
|
||
|
||
**counter는 그대로 보면 의미가 없다.** 변화율을 봐야 한다.
|
||
|
||
```promql
|
||
rate(http_requests_total[5m])
|
||
```
|
||
|
||
**histogram은 세 지표가 한 벌**이다. `_bucket`으로 분위수를 계산한다.
|
||
|
||
```promql
|
||
histogram_quantile(0.95, rate(keycloak_session_expiration_task_seconds_bucket[5m]))
|
||
```
|
||
|
||
## 328. `up` — 가장 중요한 합성 지표
|
||
|
||
```promql
|
||
up
|
||
up{job="keycloak"}
|
||
```
|
||
|
||
Prometheus가 **직접 만드는** 지표다. 긁기에 성공하면 1, 실패하면 0.
|
||
|
||
**장애 실험에서 이것이 핵심인 이유** — 다른 지표는 대상이 죽으면 **사라진다.**
|
||
사라진 데이터로는 "언제부터 죽었나"를 알 수 없다. `up`은 **0이라는 값으로
|
||
남기 때문에** 사후에 시각을 특정할 수 있다.
|
||
|
||
```promql
|
||
up == 0 # 지금 죽은 타깃
|
||
changes(up[1h]) # 1시간 동안 몇 번 오르내렸나
|
||
min_over_time(up[10m]) # 10분 중 한 번이라도 죽었나
|
||
```
|
||
|
||
## 329. TSDB와 보존 기간
|
||
|
||
```yaml
|
||
--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 메모리 재배분 — 게스트를 다시 만들지 않는다
|
||
|
||
```bash
|
||
virsh setmaxmem kc-lab-1 5120M --config
|
||
virsh setmem kc-lab-1 5120M --config
|
||
```
|
||
|
||
| 명령 | 바꾸는 것 |
|
||
|---|---|
|
||
| `setmaxmem` | **상한**. 부팅 시 게스트가 보는 총량 |
|
||
| `setmem` | **현재 할당**. 상한 이하여야 한다 |
|
||
|
||
**순서가 중요하다.** 현재값을 상한보다 크게 줄 수 없으므로 `setmaxmem`이
|
||
먼저다.
|
||
|
||
| 플래그 | 적용 범위 |
|
||
|---|---|
|
||
| `--config` | 영구 정의. **다음 부팅부터** |
|
||
| `--live` | 실행 중인 도메인에 즉시 |
|
||
| 둘 다 | 지금과 앞으로 |
|
||
|
||
`setmaxmem --live`는 대개 거부된다 — 게스트가 부팅 시 메모리 맵을 정하기
|
||
때문이다. **상한을 바꾸려면 게스트를 껐다 켜야 한다.**
|
||
|
||
```bash
|
||
virsh dominfo kc-lab-1 | grep -i memory
|
||
ssh kc-lab-1 free -m # 게스트가 실제로 인식한 값
|
||
```
|
||
|
||
호스트에서 8GB→12GB로 물리 증설한 뒤 이 방법으로 재배분했다.
|
||
**게스트 재생성이나 디스크 조작은 전혀 필요 없었다.**
|
||
|
||
## 333. 안전한 종료 순서
|
||
|
||
전원을 내리기 전에 **위에서부터** 정리한다.
|
||
|
||
```bash
|
||
# 1. 애플리케이션 — 클러스터에서 정상 탈퇴
|
||
kubectl -n keycloak-lab scale statefulset/keycloak --replicas=0
|
||
kubectl -n keycloak-lab wait --for=delete pod -l app=keycloak --timeout=120s
|
||
|
||
# 2. 데이터베이스 — 마지막에, 충분한 시간을 주고
|
||
kubectl -n keycloak-lab scale deployment/postgres --replicas=0
|
||
kubectl -n keycloak-lab wait --for=delete pod -l app=postgres --timeout=120s
|
||
|
||
# 3. 게스트 — ACPI 정상 종료
|
||
virsh shutdown kc-lab-1 && virsh shutdown kc-lab-2
|
||
|
||
# 4. 호스트
|
||
sudo systemctl poweroff
|
||
```
|
||
|
||
**왜 순서가 중요한가** — `virsh shutdown`은 게스트 systemd가 k3s를 멈추고,
|
||
k3s가 컨테이너에 SIGTERM을 보낸다. 유예 시간이 짧으면 **PostgreSQL이
|
||
강제 종료되어 다음 기동에 crash recovery가 돈다.** 미리 내려두면 그 위험이
|
||
없다.
|
||
|
||
**clean shutdown 확인**
|
||
|
||
```bash
|
||
ssh kc-lab-2 'sudo ls /var/lib/rancher/k3s/storage/*postgres-data*/pgdata/postmaster.pid'
|
||
```
|
||
|
||
**`postmaster.pid`가 남아 있지 않아야 정상**이다. 남아 있으면 비정상 종료였고
|
||
다음 기동에 복구 절차가 실행된다.
|
||
|
||
## 334. 복구 순서 — 종료의 역순
|
||
|
||
```bash
|
||
virsh start kc-lab-1 && virsh start kc-lab-2
|
||
kubectl get nodes # Ready 2개 대기
|
||
kubectl -n keycloak-lab scale deployment/postgres --replicas=1
|
||
kubectl -n keycloak-lab rollout status deployment/postgres
|
||
kubectl -n keycloak-lab scale statefulset/keycloak --replicas=2
|
||
```
|
||
|
||
**PostgreSQL이 먼저다.** Keycloak이 DB 없이 뜨면 기동에 실패한다.
|
||
|
||
**스케일을 0으로 내려두면 자동으로 복구되지 않는다.** 명시적으로 올려야 한다.
|
||
|
||
## 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 포함 스냅샷 | **되돌리기용이지 이식용이 아니다** — 이미지에 상태가 묶인다 |
|
||
|
||
**확인**
|
||
|
||
```bash
|
||
# 옮기기 전 — 무엇이 딸려 있는지
|
||
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 info`의 `virtual size`(게스트가 보는 크기)와 `disk size`(파일이 실제로
|
||
먹는 크기)가 다른 것이 정상이다. **옮길 때 문제가 되는 것은 `disk size`다.**
|
||
|
||
**안전한 이동 절차**
|
||
|
||
```bash
|
||
# 원본 호스트
|
||
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다. 줄이려면 둘 중 하나다.
|
||
|
||
```bash
|
||
# ① 게스트가 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 하나일 때 |
|
||
|
||
**확인**
|
||
|
||
```bash
|
||
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 | 첫 부팅에 아무 설정도 안 들어온다 | `NoCloud` → `Ec2`/`Azure`/`GCE`로 바꾼다 |
|
||
| 고정 IP·NIC 이름 | 네트워크가 죽는다 | DHCP로 되돌린다. `enp1s0` 같은 이름을 config에 박아두지 않는다 |
|
||
| `/etc/fstab`, GRUB의 루트 지정 | 장치명이 바뀌어 부팅이 멈춘다 | 전부 **UUID**로 |
|
||
| 시리얼 콘솔 | 부팅 실패를 볼 수단이 없다 | GRUB에 `console=ttyS0` 추가 |
|
||
|
||
**③ 업로드 경로**
|
||
|
||
```bash
|
||
# 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 이미지에 같은 구성을 얹는 비용보다 대개 크다.
|
||
|
||
**확인**
|
||
|
||
```bash
|
||
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 메모리 재배분,
|
||
안전한 종료·복구 순서.
|