feat(pipeline): keycloak-session-store 25편·virtualization 59편을 S3→S5→S6 으로 돌린다
기록 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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d473609e0a
commit
2109f726fe
+203
@@ -0,0 +1,203 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: declared-memory-and-disk-are-ceilings-not-occupancy
|
||||
title: 5120MB 를 줬는데 353MB 를 쓰고 있었다 — 선언한 양과 실제로 드는 양
|
||||
topic: lab-environment-build
|
||||
topicName: 실험대 환경 구성
|
||||
project: virtualization
|
||||
status: 게시 전
|
||||
lastVerifiedOn: 2026-09-10
|
||||
sourceRevision: 9465582b5d1630eb4ae7c4e078021486919bf6b6
|
||||
source:
|
||||
- final/document.md#198-자원-할당과-실사용은-다르다
|
||||
- final/document.md#199-디스크-오버레이는-얼마나-쓰나
|
||||
- final/document.md#202-철거-실제-출력-전문
|
||||
- final/document.md#197-측정-환경
|
||||
- final/document.md#195-이-부의-출처와-범위
|
||||
- final/document.md#211-이-부의-출처와-범위
|
||||
---
|
||||
|
||||
# 5120MB 를 줬는데 353MB 를 쓰고 있었다 — 선언한 양과 실제로 드는 양
|
||||
|
||||
k3s 만 올린 상태에서 kc-lab-1 은 5120MB 를 할당받고 353MB 를 쓰고 있었다. k3s 두 노드에 8240MB 를 선언해 실제 점유는 654MB 였고, 디스크는 40GB 를 선언해 2.1GB 를 썼다. 2026-09-10 에 test-server 에서 쟀다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **이 호스트의 가상 머신들은 설정한 메모리와 지금 잡고 있는 메모리가 얼마나 다른가**
|
||||
그 물음이 묻는 세 값 가운데 configured 와 게스트 사용량이 여기서 나왔다. 호스트 resident 는 비어 있어서 그 물음은 닫히지 않았다.
|
||||
- **이 disk image 는 qcow2 인가 RAW 인가, 그리고 Guest 가 보는 크기와 Host 가 실제로 쓰는 크기는 얼마나 다른가**
|
||||
그 물음이 요구하는 세 값 가운데 `qemu-img info` 와 `ls` 가 여기서 나왔고 `du` 는 돌리지 않았다.
|
||||
- **QEMU 프로세스가 실제로 붙잡고 있는 호스트 메모리는 얼마이고 어떻게 나뉘어 있는가**
|
||||
게스트 안에서 본 실사용과 호스트가 실제로 잡아 둔 양은 다른 수다. 후자를 그 물음이 받는다.
|
||||
- **qcow2 파일 안 — 매핑표와 클러스터, 그리고 항목이 0 이면 바닥에 다시 묻는다**
|
||||
20GB 를 선언한 파일이 1.4GiB 인 까닭을 그 글이 매핑표와 오버레이로 설명한다.
|
||||
- **실험대를 철거하고 무엇이 남는지 확인한다**
|
||||
회수량 3.1GB 를 낸 철거 절차가 그 기록에 있다.
|
||||
- **실험대를 껐다 켜고 게스트 메모리를 다시 나눈다**
|
||||
`kc-lab-1` 이 3584M 에서 5120MB 가 된 재배분 절차가 그 기록에 있다.
|
||||
- **도구가 낸 출력은 대상의 상태가 아니다 — 그 명령이 무엇을 세는지부터 가른다**
|
||||
`dommemstat` 의 `actual`, `free` 의 `available`, `pool-info` 의 `Allocation` 이 셋 다 이름과 다른 것을 센다.
|
||||
|
||||
## 문제
|
||||
|
||||
제1~4부에는 이 호스트에서 잰 값이 하나도 없다. 제5·6부는 버전과 주소와 명령까지만 적었다.
|
||||
|
||||
게스트 세 대에 메모리 8240MB 와 디스크 40GB 를 선언해 두었는데, 그 선언이 11,648MiB 와 226G 짜리 호스트 한 대에서 실제로 얼마를 먹는지는 어디에도 없었다. 「5GB 를 줬으니 5GB 를 쓴다」와 「20GB 두 장이면 40GB 를 쓴다」가 맞는지 모르는 채로 게스트를 더 띄울지 정해야 했다.
|
||||
|
||||
## 결론
|
||||
|
||||
선언한 양은 상한이고 점유가 아니다. k3s 만 떠 있고 Keycloak 은 아직 안 올린 상태에서 넷을 쟀다.
|
||||
|
||||
메모리 : `kc-lab-1` 은 할당 5120MB 에 실사용 353MB, `kc-lab-2` 는 할당 3120MB 에 실사용 301MB
|
||||
메모리 합계 : 8240MB 를 할당했고 실제 점유는 654MB
|
||||
디스크 : `kc-lab-1.qcow2` 1.4GiB, `kc-lab-2.qcow2` 665MiB, 바닥 `base.qcow2` 는 `virtual size` 3 GiB 에 `disk size` 335MiB
|
||||
디스크 합계 : 20GB 를 두 장 선언했고 실제로 쓴 것은 2.1GB
|
||||
철거 : 게스트 셋을 지우자 `df -h /` 가 11G 에서 7.9G 로 내려 3.1GB 가 회수됐다
|
||||
|
||||
`virt-install --memory 4096` 으로 만든 `kc-lab-2` 의 할당이 3120 으로 보인다. `dommemstat` 의 `actual` 은 현재 할당이지 선언한 상한이 아니고, 상한은 `virsh dominfo` 의 `Max memory` 에 있다. 줄어든 까닭은 virtio-balloon 회수로 보이는데, 두 값을 나란히 찍어 보지는 않았다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
측정일 : 2026-09-10
|
||||
호스트 : `test-server`, Arch Linux
|
||||
CPU : `11th Gen Intel(R) Core(TM) i5-1135G7 @ 2.40GHz`, 논리 코어 8
|
||||
RAM : 11,648MiB
|
||||
루트 파일시스템 : 226G 가운데 9.9G 사용
|
||||
QEMU : 11.1.1
|
||||
libvirt : 12.7.0
|
||||
커널 : `7.2.2-arch1-1`
|
||||
중첩 가상화 : `nested` 가 `Y` 지만 이 실험대는 쓰지 않는다
|
||||
게스트 : Debian 12 genericcloud 3대 — 엣지 1대, k3s 2노드
|
||||
워크로드 : k3s 만 떠 있고 Keycloak · PostgreSQL · Redis · Prometheus 는 올리기 전
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. k3s 두 노드만 띄우고 Keycloak · PostgreSQL · Redis · Prometheus 는 올리지 않는다.
|
||||
2. 게스트마다 `virsh dommemstat` 을 돌려 `actual` 과 `unused` 를 읽고, 그 차이를 실사용으로 잡는다.
|
||||
3. `qemu-img info` 로 `base.qcow2` 의 `virtual size` 와 `disk size` 를 읽고, `ls -l /var/lib/libvirt/images/` 로 오버레이와 시드 파일의 바이트 수를 읽는다.
|
||||
4. 철거하기 전에 `df -h /` 를 읽어 둔다.
|
||||
5. `virsh destroy` 와 `virsh undefine --remove-all-storage` 로 게스트 셋을 지우고 `df -h /` 를 다시 읽는다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 이 호스트에서 처음으로 양을 쟀다
|
||||
|
||||
이 실험대의 문서는 제5·6부까지 버전과 주소와 명령을 적었고 자원의 양은 적지 않았다. 제7부가 그 양과 시간을 처음 쟀고, 원본이 표기 규약을 스스로 밝혀 두었다.
|
||||
|
||||
> 여기 적힌 숫자는 전부 2026-09-10 에 `test-server` 에서 실제로 돌려 받은 출력이다. 추정값·예상값은 없다. 없는 값은 「미측정」이라고 쓴다.
|
||||
|
||||
측정 환경부터 읽어 둔다. 논리 코어가 8 이고 게스트 셋에 vCPU 를 2 + 2 + 1 로 잡아 여유를 뒀다.
|
||||
|
||||
```text label="측정 환경 — free -m 의 앞 두 줄"
|
||||
total used free shared buff/cache available
|
||||
Mem: 11648 5642 2599 4 3776 6005
|
||||
```
|
||||
|
||||
`available` 이 6005 로 `free` 2599 보다 훨씬 큰데, `buff/cache` 3776 이 필요해지면 회수되기 때문이다. 게스트를 몇 대 더 띄울 수 있는지는 `free` 가 아니라 `available` 로 읽는다.
|
||||
|
||||
## 메모리 — 할당 5120MB 에 실사용 353MB
|
||||
|
||||
게스트마다 `dommemstat` 의 `actual` 에서 `unused` 를 뺀 값이 실사용이다.
|
||||
|
||||
```text label="k3s 만 떠 있고 Keycloak 은 아직 안 올린 상태"
|
||||
kc-lab-1 할당 5120MB 실사용 353MB
|
||||
kc-lab-2 할당 3120MB 실사용 301MB
|
||||
```
|
||||
|
||||
k3s server 한 대가 353MB 를 쓰니 할당의 7% 다. 둘을 합치면 8240MB 를 할당했고 실제 점유는 654MB 여서, 11,648MiB 짜리 호스트 한 대에서 게스트 세 대가 무리 없이 돈다.
|
||||
|
||||
## `kc-lab-2` 의 할당이 4096 이 아니라 3120 이다
|
||||
|
||||
`kc-lab-2` 는 `virt-install --memory 4096` 으로 만들었는데 `dommemstat` 이 3120 을 낸다.
|
||||
|
||||
`dommemstat` 의 `actual` 은 현재 할당이지 선언한 상한이 아니라서, 상한을 보려면 `virsh dominfo` 의 `Max memory` 를 읽어야 한다. 둘을 같은 값으로 읽으면 「메모리가 왜 줄었지」가 된다.
|
||||
|
||||
줄어든 까닭은 virtio-balloon 회수로 보인다. 게스트가 안 쓰는 만큼 balloon 드라이버가 호스트에 돌려주고, 돌려준 만큼 현재 할당이 내려간다. 다만 이 실험대에서 `dommemstat` 의 `actual` 과 `dominfo` 의 `Max memory` 를 나란히 찍어 대조한 기록이 없어서, 3120 이 balloon 회수의 결과라는 것은 관측이 아니라 추론이다.
|
||||
|
||||
## 디스크 — 40GB 를 선언해 2.1GB
|
||||
|
||||
게스트 디스크는 `base.qcow2` 위의 오버레이다. 20GB 짜리를 두 장 만들어도 바닥은 한 벌이고 변경분만 쌓인다.
|
||||
|
||||
```text label="qemu-img info 가 읽은 바닥 이미지"
|
||||
image: /var/lib/libvirt/images/base.qcow2
|
||||
file format: qcow2
|
||||
virtual size: 3 GiB (3221225472 bytes)
|
||||
disk size: 335 MiB
|
||||
```
|
||||
|
||||
```text label="엣지를 만들기 전, k3s 2 노드만 있던 시점의 ls -l"
|
||||
-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)
|
||||
```
|
||||
|
||||
바닥은 게스트가 3 GiB 로 보는데 파일은 335MiB 이고, 20GB 로 선언한 오버레이 둘도 실제로는 1.4GiB 와 665MiB 다. 합쳐 40GB 를 선언하고 2.1GB 를 썼다. `kc-lab-1` 이 `kc-lab-2` 의 두 배 이상을 쓰는 것은 k3s server 가 컨트롤 플레인 바이너리와 SQLite 를 들고 있기 때문이다.
|
||||
|
||||
## `virsh pool-info` 의 `Allocation` 은 VM 사용량이 아니다
|
||||
|
||||
같은 날 `virsh pool-info default` 도 읽었다.
|
||||
|
||||
```text label="스토리지 풀 default"
|
||||
Name: default
|
||||
State: running
|
||||
Persistent: yes Autostart: yes
|
||||
Capacity: 225.31 GiB
|
||||
Allocation: 7.84 GiB
|
||||
Available: 217.46 GiB
|
||||
```
|
||||
|
||||
`Allocation` 7.84 GiB 는 풀이 얹힌 호스트 루트 파일시스템 전체의 사용량이다. VM 이 얼마를 쓰는지는 위의 `ls -l` 이 말한다. 두 수를 같은 것으로 읽으면 게스트 둘이 7.84 GiB 를 먹은 것이 된다.
|
||||
|
||||
## 철거 — `df -h /` 가 11G 에서 7.9G 로
|
||||
|
||||
게스트 셋을 `virsh destroy` 로 내리고 `virsh undefine --remove-all-storage` 로 지웠다. 한 대분 출력은 이렇다.
|
||||
|
||||
```text label="kc-lab-edge 한 대를 철거한 출력"
|
||||
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` 와 시드 ISO `vdb` 다.
|
||||
|
||||
| 무엇을 읽었나 | 철거 전 | 철거 후 |
|
||||
|---|---|---|
|
||||
| `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)다. `base.qcow2` 335MB 는 다음 재구축의 바닥이라 남긴다. 다시 받아도 몇 분이면 된다.
|
||||
|
||||
## 같은 대상의 숫자가 두 벌이다
|
||||
|
||||
이 SSOT 안에는 같은 실험대의 스냅샷이 두 벌 있다. §218 은 2026-09-03 값이고 이 기록은 2026-09-10 값이다.
|
||||
|
||||
| 무엇 | 2026-09-03 | 2026-09-10 |
|
||||
|---|---|---|
|
||||
| 호스트 RAM | `RAM 7.4Gi` | `Mem: 11648` |
|
||||
| `kc-lab-1` | `RAM 3584M · vCPU 2` | 할당 5120MB |
|
||||
| `kc-lab-2` | `RAM 2560M · vCPU 2` | 할당 3120MB (선언 4096) |
|
||||
| 게스트 수 | 2 (엣지 없음) | 3 (엣지 추가) |
|
||||
|
||||
그사이에 호스트 RAM 이 8GB 에서 12GB 로 물리 증설됐고 `setmaxmem` 과 `setmem` 으로 게스트 메모리가 재배분됐다. 두 값이 어긋나 보이면 틀린 것이 아니라 다른 날이다.
|
||||
|
||||
날짜가 아니라 단위로 갈리는 것도 하나 있다. §198 은 같은 호스트의 RAM 을 `11.6GB` 로도 적는데, 그 표기가 원 가이드에서 온 것이라 고쳐 쓰지 않고 어긋남을 적어 둔다고 스스로 밝힌다. `free -m` 의 `Mem: 11648` 은 MiB 단위이므로 11,648MiB, 약 11.4GiB 다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
이 값은 호스트 한 대의 한 시점이다. Keycloak 2 파드와 PostgreSQL 과 Redis 와 Prometheus 가 올라간 뒤의 메모리는 재지 않았다. 원본도 §198 에서 그 시점의 값을 미측정으로 밝힌다.
|
||||
|
||||
디스크 값과 철거 값은 시점이 서로 다르다. `ls -l` 은 엣지를 만들기 전 k3s 2노드만 있던 때의 것이고, 3.1GB 회수는 엣지까지 세 대가 있던 때의 것이다. `kc-lab-edge` 의 디스크 크기는 따로 재 두지 않았고, 회수 합계에서 역산하면 1GB 안팎이다.
|
||||
|
||||
게스트 안에서 본 실사용과 QEMU 프로세스가 호스트에서 붙잡고 있는 양은 다른 수인데, 뒤엣것은 이 기록이 재지 않았다.
|
||||
|
||||
여기 옮긴 출력은 전부 SSOT 본문의 코드 블록에서 왔고, 이 저장소의 `final/evidence/` 에는 그 명령들의 출력 원문이 파일로 없다. 같은 측정을 다시 돌려 `final/evidence/raw/` 에 남기면 그때 원문을 댈 수 있다.
|
||||
|
||||
<!-- body:end -->
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: nftables-accept-did-not-stop-the-libvirt-reject
|
||||
title: 호스트 안에서는 404, 밖에서는 connection refused — 앞 체인의 accept 가 libvirt 의 reject 를 막지 못했다
|
||||
topic: lab-environment-build
|
||||
topicName: 실험대 환경 구성
|
||||
project: virtualization
|
||||
status: 게시 전
|
||||
assets:
|
||||
- key: nftables-forward-hook-chain-order
|
||||
file: ../../../final/assets/diagrams/nftables-forward-hook-chain-order/nftables-forward-hook-chain-order.svg
|
||||
sourceRevision: 9465582b5d1630eb4ae7c4e078021486919bf6b6
|
||||
source:
|
||||
- final/document.md#180-nftables-는-앞-체인의-accept-로-뒤-체인의-reject-를-막지-못한다
|
||||
- final/document.md#179-엣지를-물리-호스트에서-vm-으로-옮기면-무엇이-새로-필요해지나
|
||||
- final/document.md#178-이-부의-출처와-범위
|
||||
---
|
||||
|
||||
# 호스트 안에서는 404, 밖에서는 connection refused — 앞 체인의 accept 가 libvirt 의 reject 를 막지 못했다
|
||||
|
||||
밖에서 온 요청은 먼저 돌게 해 둔 forward 체인의 accept 를 지나고도 libvirt 의 guest_input 체인 끝 reject 에서 끊겼다. nftables 가 같은 훅에 붙은 base 체인을 우선순위 순으로 전부 평가하기 때문이다. 그래서 구멍을 libvirt 체인 맨 앞에 넣었다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **엣지 nginx 를 호스트에서 게스트 VM 으로 옮긴다 — 성능이 아니라 더러워지는 층의 격리**
|
||||
그 결정으로 새로 필요해진 libvirt 방화벽 구멍을 이 사건에서 실제로 뚫었다.
|
||||
- **libvirt 의 firewall_backend 가 iptables 일 때도 guest_input 에 구멍이 필요한가**
|
||||
여기서 재지 않고 넘긴 미확인 항목을 그 질문이 받는다.
|
||||
- **단계별 구축 문서는 작성 시점이 아니라 실행 순서로 검증한다**
|
||||
같은 구축에서 나온 문서 결함들을 하나의 규칙으로 정리한 글이다.
|
||||
- **Guest 의 packet 이 Host Physical NIC 에 닿기까지 — virtio-net · virtqueue · vhost-net · TAP · Bridge**
|
||||
밖에서 온 패킷이 게스트에 닿기까지의 경로를 그 글이 세우고, 이 사건은 그 경로의 한 구간에서 막혔다.
|
||||
- **packet 이 어디서 끊겼는지는 계층마다 capture 해서 가른다**
|
||||
어느 계층까지 패킷이 보이는지로 의심 구간을 좁히는 절차이고, 여기서는 규칙 카운터가 같은 일을 했다.
|
||||
|
||||
## 문제
|
||||
|
||||
엣지 nginx 를 호스트에서 게스트 VM 으로 옮긴 뒤 밖에서 들어오는 요청만 엣지에 닿지 않았다.
|
||||
|
||||
호스트에서 친 요청 : curl http://192.168.122.10 → 404, 엣지 nginx 가 응답한다
|
||||
밖에서 친 요청 : curl http://100.83.212.4 → connection refused
|
||||
|
||||
호스트에서 친 요청에는 404 를 돌려줬으니 엣지 nginx 는 게스트 안에서 돌고 있었다. 밖에서 친 요청만 끊겼고, 타임아웃이 아니라 즉시 거절이었다.
|
||||
|
||||
## 결론
|
||||
|
||||
밖에서 온 패킷을 거절한 것은 libvirt 가 만든 규칙이다. libvirt 는 자기 테이블 ip libvirt_network 의 guest_input 체인을 ct state established,related accept 다음의 reject 로 끝낸다. 그 reject 규칙의 카운터가 4 패킷 240 바이트로 밖에서 친 curl 횟수와 정확히 일치해서 범인을 확정했다.
|
||||
|
||||
DNAT 를 정의한 파일에는 priority filter - 10 을 줘서 먼저 돌게 한 forward 체인이 있는데, 거기 넣은 ct state new accept 가 그 reject 를 막지 못한다. nftables 가 같은 훅에 붙은 base 체인을 우선순위 순으로 전부 평가하기 때문이다. 앞 체인의 accept 는 그 체인을 통과했다는 뜻이지 평가가 끝났다는 뜻이 아니고, 즉시 종결하는 것은 drop 뿐이다.
|
||||
|
||||
해결 : 구멍을 libvirt 체인 맨 앞에 넣는다. insert 가 맨 앞이고 add 가 맨 뒤다.
|
||||
수명 : libvirt 가 네트워크를 다시 세우면 guest_input 을 새로 쓰면서 그 규칙이 날아간다. 그래서 DNAT 유닛의 ExecStartPost 에 넣는다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
호스트 : test-server, Arch Linux
|
||||
CPU : i5-1135G7, 논리 코어 8
|
||||
RAM : 11,648MiB
|
||||
QEMU : 11.1.1
|
||||
libvirt : 12.7.0
|
||||
libvirt firewall_backend : nftables
|
||||
호스트 이더넷 : 없다 — WiFi 만 있다
|
||||
게스트 네트워크 : libvirt NAT, virbr0
|
||||
게스트 : Debian 12 genericcloud 3대 — 엣지 1대(nginx·certbot), k3s 2노드
|
||||
엣지 게스트 주소 : 192.168.122.10
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 엣지 nginx 를 게스트 192.168.122.10 에 두고, 호스트 커널의 DNAT 로 밖에서 들어온 요청을 그 게스트로 넘긴다.
|
||||
2. 호스트에서 curl http://192.168.122.10 을 친다. 엣지 nginx 가 404 로 응답한다.
|
||||
3. 밖에서 curl http://100.83.212.4 를 친다. connection refused 가 온다.
|
||||
4. libvirt 테이블 ip libvirt_network 의 guest_input 체인을 규칙과 카운터까지 덤프한다. 마지막 reject 규칙의 패킷 수가 3번을 친 횟수와 맞으면 그 규칙이 그 패킷을 끝냈다.
|
||||
5. guest_input 맨 앞에 구멍을 넣고 3번을 다시 친다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 엣지 nginx 를 게스트로 옮기고 새로 필요해진 것
|
||||
|
||||
이 호스트에는 이더넷이 없고 WiFi 만 있어서 브리지를 못 쓰고, libvirt NAT(`virbr0`)에 호스트로 들어온 요청을 넘기는 구조를 택했다. 그 위에 Debian 12 게스트 세 대가 있고, 그중 한 대가 nginx 와 certbot 을 돌리는 엣지다. 엣지 nginx 는 원래 호스트에 있었고 사는 곳만 게스트로 바꿨다.
|
||||
|
||||
```text label="엣지 이동 전과 후"
|
||||
전: tailnet:443 ─▶ [호스트 nginx] ─────────────▶ Traefik(게스트 .11/.12)
|
||||
후: tailnet:443 ─▶ [호스트 커널 DNAT] ─▶ [엣지 nginx(.10)] ─▶ Traefik(.11/.12)
|
||||
```
|
||||
|
||||
L7 홉 수는 전후 모두 2홉이고 늘어난 것은 커널이 하는 L4 전달 한 번뿐이라 `X-Forwarded-*` 계약은 그대로 성립한다. 옮긴 이유도 성능이 아니라 더러워지는 층의 격리였다. nginx 설정과 인증서, certbot, deploy 훅은 자주 갈아엎는 것들인데 호스트에 있으면 초기화가 불가능하고, 엣지 장애 실험이 SSH 까지 위험하게 만든다.
|
||||
|
||||
그 대가로 일곱 가지가 새로 필요해졌는데 그중 둘은 배포판 차이가 아니라 패킷이 지나는 길이 달라져서 생겼다. 하나는 DNAT(Destination NAT) 다. 들어온 패킷의 도착지 주소를 바꿔 다른 기계로 넘기는 것을 말한다. 전에는 호스트가 직접 `:443` 을 들었으니 넘길 일이 없었는데 지금은 호스트에 리스너가 아예 없다. 다른 하나는 libvirt 방화벽에 구멍을 내는 일이다. 호스트가 게스트에 접속할 때는 OUTPUT 경로라 필터를 안 탔지만, 밖에서 게스트로 들어오는 것은 FORWARD 다. 「호스트가 게스트에 접속한다」와 「밖에서 게스트로 들어온다」는 커널이 보기에 완전히 다른 일이다. 그 구멍을 뚫는 데서 이 구축이 가장 오래 막혔다.
|
||||
|
||||
## 호스트 안에서는 되는데 밖에서만 안 된다
|
||||
|
||||
엣지 nginx 는 호스트에서 친 요청에 404 를 돌려줬으니 게스트 안에서 살아 있었는데, 밖에서 친 요청만 끊겼다.
|
||||
|
||||
| 어디서 쳤나 | 결과 |
|
||||
|---|---|
|
||||
| 호스트에서 `curl http://192.168.122.10` | 404, 엣지 nginx 가 응답 |
|
||||
| 밖에서 `curl http://100.83.212.4` | connection refused |
|
||||
|
||||
패킷을 조용히 버리는 `drop` 이면 클라이언트가 응답을 기다리다 죽으므로, 타임아웃이 아니라 즉시 거절이 돌아왔다는 것이 단서였다.
|
||||
|
||||
## guest_input 체인 끝의 reject 와 카운터 4 패킷
|
||||
|
||||
libvirt 는 자기 테이블 `ip libvirt_network` 안에 `guest_input` 체인을 만들고, 이미 맺어진 연결과 그에 딸린 연결만 통과시킨 다음 나머지를 거절하는 규칙으로 그 체인을 끝낸다.
|
||||
|
||||
```text label="libvirt 가 만든 guest_input 체인의 끝"
|
||||
oif "virbr0" ip daddr 192.168.122.0/24 ct state established,related accept
|
||||
oif "virbr0" counter packets 4 bytes 240 reject ← 여기서 죽는다
|
||||
```
|
||||
|
||||
규칙 하나를 지목해 놓고 시작한 것이 아니다. `nft list ruleset` 에서 `reject` 와 `drop` 이 든 줄만 뽑아 놓고, 밖에서 친 횟수와 카운터가 맞아떨어지는 줄을 찾았다. 이 `reject` 규칙의 카운터가 4 패킷 240 바이트였고, 밖에서 친 `curl` 횟수와 정확히 일치했다. 범인 확정에 쓴 것이 이 숫자다.
|
||||
|
||||
## 왜 앞 체인의 accept 가 안 먹혔나
|
||||
|
||||
DNAT 를 정의한 파일에는 libvirt 체인보다 먼저 돌도록 `priority filter - 10` 을 준 `forward` 체인을 두고 거기에 `ct state new accept` 를 넣어 두었다. 밖에서 온 패킷은 그 `accept` 를 지나고도 거절됐는데, nftables 가 같은 훅에 붙은 base 체인을 우선순위 순으로 전부 평가하기 때문이다. 앞 체인의 `accept` 는 「이 체인은 통과」라는 뜻이지 「평가 끝」이 아니고, 즉시 종결하는 것은 `drop` 뿐이다. iptables 감각으로 쓰면 정확히 여기서 틀린다.
|
||||
|
||||
그 `forward` 체인은 지금 DNAT 파일에 없다. 남은 체인은 `prerouting` 하나이고, 체인을 그냥 지우는 대신 주석 하나를 남겨 두었다 — 여기에 `forward` 체인을 두지 않은 것이 의도이며 구멍은 유닛의 `ExecStartPost` 가 libvirt 자기 체인 안에 넣는다는 내용이다.
|
||||
|
||||

|
||||
|
||||
점선 상자 두 개가 같은 훅에 붙은 base 체인 둘이고, 왼쪽이 먼저 돈다. 점선 화살표는 구멍을 넣기 전의 경로다 — 앞 체인의 `accept` 를 지난 패킷이 `guest_input` 으로 이어지고, 위 덤프에 적힌 `ct state established,related accept` 에 걸리지 못한 채 체인 끝 `reject` 에 닿아 connection refused 로 끝난다.
|
||||
|
||||
실선 화살표는 다음 절에서 뚫을 구멍을 지나는 경로다. `guest_input` 상자 안에 놓인 두 규칙의 위아래가 체인 안의 순서이고, 구멍이 위에 있어서 같은 패킷이 아래의 `reject` 를 보기 전에 그 규칙에서 `accept` 된다.
|
||||
|
||||
## 구멍은 맨 앞에 넣고, 네트워크를 다시 세울 때 다시 넣는다
|
||||
|
||||
그래서 구멍을 libvirt 체인 맨 앞에 뚫었다. `insert` 가 맨 앞이고 `add` 가 맨 뒤다.
|
||||
|
||||
```bash label="guest_input 맨 앞에 구멍을 넣는다"
|
||||
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
|
||||
```
|
||||
|
||||
이 규칙은 휘발성이다. libvirt 가 네트워크를 다시 세우면 `guest_input` 을 새로 쓰면서 규칙이 날아가므로, DNAT 유닛의 `ExecStartPost` 에 넣어 네트워크가 다시 설 때마다 같은 규칙이 다시 들어가게 했다.
|
||||
|
||||
그 `ExecStartPost` 줄 앞에는 `-` 를 붙였다. `libvirt_network` 테이블은 가상 네트워크가 올라온 뒤에야 생기므로, 그 전에 유닛이 뜨면 이 줄이 실패한다. `-` 를 붙여 두면 그때도 DNAT 은 그대로 올라가고 구멍만 빠지며, 빠진 구멍은 유닛을 다시 시작해서 넣는다. 그리고 그렇게 걸어 둔 뒤 libvirt 네트워크를 실제로 다시 세워 규칙이 되돌아오는지는 확인하지 않았다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
이 호스트 한 대에서만 봤다. libvirt 의 `firewall_backend` 가 iptables 인 호스트에서도 같은 구멍이 필요한지는 재지 않았고, 이 호스트는 nftables 백엔드다.
|
||||
|
||||
카운터 4 패킷 240 바이트는 위에 옮긴 규칙 덤프에 찍힌 값이다. 그 덤프와 `curl` 출력의 원문은 `final/evidence/` 에 파일로 남기지 않았다. 같은 재현을 다시 돌려 출력을 파일로 남기면 그때 원문을 댈 수 있다.
|
||||
|
||||
<!-- body:end -->
|
||||
Reference in New Issue
Block a user